diff --git a/src/accounts/stores.ts b/src/accounts/stores.ts index c44a012..006ea9e 100644 --- a/src/accounts/stores.ts +++ b/src/accounts/stores.ts @@ -6,10 +6,19 @@ import { } from "masto"; import { createMastoClientFor } from "../masto/clients"; -export type Account = { +export type RemoteServer = { site: string; - accessToken: string; +}; +export type AccountKey = RemoteServer & { + accessToken: string; +}; + +export function isAccountKey(object: RemoteServer): object is AccountKey { + return !!(object as Record)["accessToken"]; +} + +export type Account = AccountKey & { tokenType: string; scope: string; createdAt: number; @@ -17,6 +26,10 @@ export type Account = { inf?: mastodon.v1.AccountCredentials; }; +export function isAccount(object: RemoteServer) { + return isAccountKey(object) && !!(object as Record)["tokenType"]; +} + export const $accounts = persistentAtom("accounts", [], { encode: JSON.stringify, decode: JSON.parse, diff --git a/src/masto/base.ts b/src/masto/base.ts new file mode 100644 index 0000000..823fad7 --- /dev/null +++ b/src/masto/base.ts @@ -0,0 +1,23 @@ +import { createCacheBucket } from "~platform/cache"; + +export const cacheBucket = /* @__PURE__ */ createCacheBucket("mastodon"); + +export function toSmallCamelCase(object: T) :T { + if (!object || typeof object !== "object") { + return object; + } else if (Array.isArray(object)) { + return object.map(toSmallCamelCase) as T + } + + const result = {} as Record; + for (const k in object) { + const value = toSmallCamelCase(object[k]) + const nk = + typeof k === "string" + ? k.replace(/_(.)/g, (_, match) => match.toUpperCase()) + : k; + result[nk] = value; + } + + return result as T; +} diff --git a/src/masto/clients.ts b/src/masto/clients.ts index fc26ee5..7cd22e7 100644 --- a/src/masto/clients.ts +++ b/src/masto/clients.ts @@ -7,7 +7,7 @@ import { untrack, useContext, } from "solid-js"; -import { Account } from "../accounts/stores"; +import { Account, type RemoteServer } from "../accounts/stores"; import { createRestAPIClient, mastodon } from "masto"; import { useLocation } from "@solidjs/router"; import { useNavigator } from "~platform/StackedRouter"; @@ -115,7 +115,7 @@ export function useDefaultSession() { * - If the username is not present, any session on the site is returned; or, * - If no available session available for the pattern, an unauthorised session is returned. * - * In an unauthorised session, the `.account` is `undefined` and the `client` is an + * In an unauthorised session, the `.account` is {@link RemoteServer} and the `client` is an * unauthorised client for the site. This client may not available for some operations. */ export function useSessionForAcctStr(acct: Accessor) { @@ -131,8 +131,8 @@ export function useSessionForAcctStr(acct: Accessor) { return ( authedSession ?? { client: createUnauthorizedClient(inputSite), - account: undefined, - } + account: { site: inputSite } as RemoteServer, // TODO: we need some security checks here? + } as const ); }); } diff --git a/src/masto/statuses.ts b/src/masto/statuses.ts new file mode 100644 index 0000000..91fdc7a --- /dev/null +++ b/src/masto/statuses.ts @@ -0,0 +1,30 @@ +import { CachedFetch } from "~platform/cache"; +import { cacheBucket, toSmallCamelCase } from "./base"; +import { + isAccountKey, + type RemoteServer, +} from "../accounts/stores"; +import type { mastodon } from "masto"; + + + +export const fetchStatus = /* @__PURE__ */ new CachedFetch( + cacheBucket, + (session: RemoteServer, id: string) => { + const headers = new Headers({ + Accept: "application/json", + }); + if (isAccountKey(session)) { + headers.set("Authorization", `Bearer ${session.accessToken}`); + } + return { + url: new URL(`./api/v1/statuses/${id}`, session.site).toString(), + headers, + }; + }, + async (response) => { + return toSmallCamelCase( + await response.json(), + ) as unknown as mastodon.v1.Status; + }, +); diff --git a/src/platform/cache.ts b/src/platform/cache.ts new file mode 100644 index 0000000..5d5b10a --- /dev/null +++ b/src/platform/cache.ts @@ -0,0 +1,145 @@ +import { addMinutes, formatRFC7231 } from "date-fns"; +import { + createRenderEffect, + createResource, + untrack, +} from "solid-js"; + +export function createCacheBucket(name: string) { + let bucket: Cache | undefined; + + return async () => { + if (bucket) { + return bucket; + } + + bucket = await self.caches.open(name); + + return bucket; + }; +} + +export type FetchRequest = { + url: string; + headers?: HeadersInit | Headers; +}; + +async function searchCache(request: Request) { + return await self.caches.match(request); +} + +/** + * Create a {@link fetch} helper with additional caching support. + */ +export class CachedFetch< + Transformer extends (response: Response) => any, + Keyer extends (...args: any[]) => FetchRequest, +> { + private cacheBucket: () => Promise; + keyFor: Keyer; + private transform: Transformer; + + constructor( + cacheBucket: () => Promise, + keyFor: Keyer, + tranformer: Transformer, + ) { + this.cacheBucket = cacheBucket; + this.keyFor = keyFor; + this.transform = tranformer; + } + + private async validateCache(request: Request) { + const buk = await this.cacheBucket(); + const response = await fetch(request); + buk.put(request, response.clone()); + return response; + } + + private request(...args: Parameters) { + const { url, ...init } = this.keyFor(...args); + const request = new Request(url, init); + return request; + } + + /** + * Race between the cache and the network result, + * use the fastest result. + * + * The cache will be revalidated. + */ + async fastest( + ...args: Parameters + ): Promise>> { + const request = this.request(...args); + const validating = this.validateCache(request); + + const searching = searchCache(request); + + const earlyResult = await Promise.race([validating, searching]); + + if (earlyResult) { + return await this.transform(earlyResult); + } + + return await this.transform(await validating); + } + + /** + * Validate and return the result. + */ + async validate( + ...args: Parameters + ): Promise>> { + return await this.transform( + await this.validateCache(this.request(...args)), + ); + } + + /** Set a response as the cache. + * Recommend to set `Expires` or `Cache-Control` to limit its live time. + */ + async set(key: Parameters, response: Response) { + const buk = await this.cacheBucket(); + await buk.put(this.request(...key), response); + } + + /** Set a json object as the cache. + * Only available for 5 minutes. + */ + async setJson(key: Parameters, object: unknown) { + const response = new Response(JSON.stringify(object), { + status: 200, + headers: { + "Content-Type": "application/json", + Expires: formatRFC7231(addMinutes(new Date(), 5)), + "X-Cache-Src": "set", + }, + }); + + await this.set(key, response); + } + + /** + * Return a resource, using the cache at first, and revalidate + * later. + */ + cachedAndRevalidate(args: () => Parameters) { + const res = createResource(args, (p) => this.validate(...p)); + + const checkCacheIfStillLoading = async () => { + const saved = await searchCache(this.request(...args())); + if (!saved) { + return; + } + const transformed = await this.transform(saved); + if (res[0].loading) { + res[1].mutate(transformed); + } + }; + + createRenderEffect(() => void untrack(() => checkCacheIfStillLoading())); + + return res; + } +} diff --git a/src/profiles/Profile.tsx b/src/profiles/Profile.tsx index 6e24cbf..b743acd 100644 --- a/src/profiles/Profile.tsx +++ b/src/profiles/Profile.tsx @@ -1,6 +1,5 @@ import { catchError, - createRenderEffect, createResource, createSignal, createUniqueId, @@ -14,7 +13,6 @@ import { } from "solid-js"; import Scaffold from "~material/Scaffold"; import { - AppBar, Avatar, Button, Checkbox, @@ -26,7 +24,6 @@ import { ListItemSecondaryAction, ListItemText, MenuItem, - Toolbar, } from "@suid/material"; import { Close, @@ -63,6 +60,7 @@ import { default as ItemSelectionProvider, } from "../timelines/toots/ItemSelectionProvider"; import AppTopBar from "~material/AppTopBar"; +import type { Account } from "../accounts/stores"; const Profile: Component = () => { const { pop } = useNavigator(); @@ -117,7 +115,7 @@ const Profile: Component = () => { }; const isCurrentSessionProfile = () => { - return session().account?.inf?.url === profile()?.url; + return (session().account as Account).inf?.url === profile()?.url; }; const [recentTootFilter, setRecentTootFilter] = createSignal({ @@ -172,8 +170,8 @@ const Profile: Component = () => { const sessionDisplayName = createMemo(() => resolveCustomEmoji( - session().account?.inf?.displayName || "", - session().account?.inf?.emojis ?? [], + (session().account as Account).inf?.displayName || "", + (session().account as Account).inf?.emojis ?? [], ), ); @@ -244,7 +242,7 @@ const Profile: Component = () => { - + @@ -367,7 +365,7 @@ const Profile: Component = () => { aria-label={`${relationship()?.following ? "Unfollow" : "Follow"} on your home timeline`} > - + void; @@ -52,6 +53,107 @@ export function useTootEnv() { return env; } +/** + * Create default toot env. + * + * This function does not provides the "reply" action. + */ +export function createDefaultTootEnv( + client: () => mastodon.rest.Client | undefined, + setToot: (id: string, status: mastodon.v1.Status) => void, +): TootEnv { + return { + async bookmark(status: mastodon.v1.Status) { + const c = client(); + if (!c) return; + + const result = await (status.bookmarked + ? c.v1.statuses.$select(status.id).unbookmark() + : c.v1.statuses.$select(status.id).bookmark()); + + setToot(result.id, result); + }, + + async boost(status: mastodon.v1.Status) { + const c = client(); + if (!c) return; + + vibrate(50); + const rootStatus = status.reblog ? status.reblog : status; + const reblogged = rootStatus.reblogged; + if (status.reblog) { + setToot(status.id, { + ...status, + reblog: { ...status.reblog!, reblogged: !reblogged }, + reblogged: !reblogged, + }); + } else { + setToot(status.id, { + ...status, + reblogged: !reblogged, + }); + } + // modified the original + + const result = reblogged + ? await c.v1.statuses.$select(status.id).unreblog() + : (await c.v1.statuses.$select(status.id).reblog()); + + if (status.reblog && !reblogged) { + // When calling /reblog, the result is the boost object (the actor + // is the calling account); for /unreblog, the result is the original + // toot. So we only do this trick only on the reblogging. + setToot(status.id, { + ...status, + reblog: result.reblog, + }); + } else { + setToot(status.id, result); + } + }, + + async favourite(status: mastodon.v1.Status) { + const c = client(); + if (!c) return; + + const ovalue = status.favourited; + setToot(status.id, { ...status, favourited: !ovalue }); + + const result = ovalue + ? await c.v1.statuses.$select(status.id).unfavourite() + : await c.v1.statuses.$select(status.id).favourite(); + setToot(status.id, result); + }, + + async vote(status: mastodon.v1.Status, votes: readonly number[]) { + const c = client(); + if (!c) return; + + const toot = status.reblog ?? status; + if (!toot.poll) return; + + const npoll = await c.v1.polls.$select(toot.poll.id).votes.create({ + choices: votes, + }); + + if (status.reblog) { + setToot(status.id, { + ...status, + reblog: { + ...status.reblog, + poll: npoll, + }, + }); + } else { + setToot(status.id, { + ...status, + poll: npoll, + }); + } + }, + }; +} + type RegularTootProps = { status: mastodon.v1.Status; actionable?: boolean; @@ -203,7 +305,7 @@ const RegularToot: Component = (oprops) => { class={cardStyle.cardNoPad} style={{ "margin-top": "8px" }} /> - + diff --git a/src/timelines/TootBottomSheet.tsx b/src/timelines/TootBottomSheet.tsx index eecc8fc..79fed0f 100644 --- a/src/timelines/TootBottomSheet.tsx +++ b/src/timelines/TootBottomSheet.tsx @@ -1,26 +1,17 @@ -import { useLocation, useParams } from "@solidjs/router"; -import { - catchError, - createEffect, - createRenderEffect, - createResource, - Show, - type Component, -} from "solid-js"; +import { useParams } from "@solidjs/router"; +import { catchError, createResource, Show, type Component } from "solid-js"; import Scaffold from "~material/Scaffold"; -import { AppBar, CircularProgress, IconButton, Toolbar } from "@suid/material"; +import { CircularProgress } from "@suid/material"; import { Title } from "~material/typography"; -import { Close as CloseIcon } from "@suid/icons-material"; import { useSessionForAcctStr } from "../masto/clients"; import { resolveCustomEmoji } from "../masto/toot"; import RegularToot, { + createDefaultTootEnv, findElementActionable, TootEnvProvider, } from "./RegularToot"; -import type { mastodon } from "masto"; import cards from "~material/cards.module.css"; import { css } from "solid-styled"; -import { vibrate } from "~platform/hardware"; import { createTimeSource, TimeSourceProvider } from "~platform/timesrc"; import TootComposer from "./TootComposer"; import { useDocumentTitle } from "../utils"; @@ -33,18 +24,8 @@ import ItemSelectionProvider, { createSingluarItemSelection, } from "./toots/ItemSelectionProvider"; import AppTopBar from "~material/AppTopBar"; - -let cachedEntry: [string, mastodon.v1.Status] | undefined; - -export function setCache(acct: string, status: mastodon.v1.Status) { - cachedEntry = [acct, status]; -} - -function getCache(acct: string, id: string) { - if (acct === cachedEntry?.[0] && id === cachedEntry?.[1].id) { - return cachedEntry[1]; - } -} +import { fetchStatus } from "../masto/statuses"; +import { type Account } from "../accounts/stores"; const TootBottomSheet: Component = (props) => { const params = useParams<{ acct: string; id: string }>(); @@ -54,29 +35,19 @@ const TootBottomSheet: Component = (props) => { const session = useSessionForAcctStr(acctText); const [, selectionState] = createSingluarItemSelection(); - const [remoteToot, { mutate: setRemoteToot }] = createResource( - () => [session().client, params.id] as const, - async ([client, id]) => { - return await client.v1.statuses.$select(id).fetch(); - }, - ); + const [remoteToot, { mutate: setRemoteToot }] = + fetchStatus.cachedAndRevalidate( + () => [session().account, params.id] as const, + ); const toot = () => catchError(remoteToot, (error) => { console.error(error); - }) ?? getCache(acctText(), params.id); - - createEffect((lastTootId?: string) => { - const tootId = toot()?.id; - if (!tootId || lastTootId === tootId) return tootId; - const elementId = `toot-${tootId}`; - document.getElementById(elementId)?.scrollIntoView({ behavior: "smooth" }); - return tootId; - }); + }); const [tootContextErrorUncaught, { refetch: refetchContext }] = createResource( - () => [session().client, params.id] as const, + () => [session().client, toot()?.reblog?.id ?? params.id] as const, async ([client, id]) => { return await client.v1.statuses.$select(id).context.fetch(); }, @@ -94,12 +65,6 @@ const TootBottomSheet: Component = (props) => { () => tootContext()?.descendants, ); - createEffect(() => { - if (ancestors.list.length > 0) { - document.querySelector(`#toot-${toot()!.id}`)?.scrollIntoView(); - } - }); - useDocumentTitle(() => { const t = toot()?.reblog ?? toot(); const name = t?.account.displayName ?? "Someone"; @@ -118,49 +83,10 @@ const TootBottomSheet: Component = (props) => { return s.account ? s : undefined; }; - const onBookmark = async () => { - const status = remoteToot()!; - const client = actSession()!.client; - setRemoteToot( - Object.assign({}, status, { - bookmarked: !status.bookmarked, - }), - ); - const result = await (status.bookmarked - ? client.v1.statuses.$select(status.id).unbookmark() - : client.v1.statuses.$select(status.id).bookmark()); - setRemoteToot(result); - }; - - const onBoost = async () => { - const status = remoteToot()!; - const client = actSession()!.client; - vibrate(50); - setRemoteToot( - Object.assign({}, status, { - reblogged: !status.reblogged, - }), - ); - const result = await (status.reblogged - ? client.v1.statuses.$select(status.id).unreblog() - : client.v1.statuses.$select(status.id).reblog()); - vibrate([20, 30]); - setRemoteToot(result.reblog!); - }; - - const onFav = async () => { - const status = remoteToot()!; - const client = actSession()!.client; - setRemoteToot( - Object.assign({}, status, { - favourited: !status.favourited, - }), - ); - const result = await (status.favourited - ? client.v1.statuses.$select(status.id).favourite() - : client.v1.statuses.$select(status.id).unfavourite()); - setRemoteToot(result); - }; + const mainTootEnv = createDefaultTootEnv( + () => actSession()?.client, + (_, status) => setRemoteToot(status), + ); const defaultMentions = () => { const tootAcct = remoteToot()?.reblog?.account ?? remoteToot()?.account; @@ -174,33 +100,6 @@ const TootBottomSheet: Component = (props) => { return Array.from(new Set(values).keys()); }; - const vote = async (status: mastodon.v1.Status, votes: readonly number[]) => { - const client = session()?.client; - if (!client) return; - - const toot = status.reblog ?? status; - if (!toot.poll) return; - - const npoll = await client.v1.polls.$select(toot.poll.id).votes.create({ - choices: votes, - }); - - if (status.reblog) { - setRemoteToot({ - ...status, - reblog: { - ...status.reblog, - poll: npoll, - }, - }); - } else { - setRemoteToot({ - ...status, - poll: npoll, - }); - } - }; - const handleMainTootClick = ( event: MouseEvent & { currentTarget: HTMLElement }, ) => { @@ -266,58 +165,51 @@ const TootBottomSheet: Component = (props) => { >
- - -
- - - - - -
- - - refetchContext()} - inReplyToId={remoteToot()?.reblog?.id ?? remoteToot()?.id} - /> - - - -
- -
-
- + + +
+ + + + + +
+ + + refetchContext()} + inReplyToId={remoteToot()?.reblog?.id ?? remoteToot()?.id} + /> + + + +
+ +
+
+ ; style?: JSX.CSSProperties; - profile?: Account; + profile?: mastodon.v1.Account; replyToDisplayName?: string; mentions?: readonly string[]; client?: mastodon.rest.Client; @@ -248,15 +248,15 @@ const TootComposer: Component<{ createEffect(() => { if (active()) { - setTimeout(() => inputRef.focus(), 0); + setTimeout(() => inputRef!.focus(), 0); } }); createEffect(() => { - if (inputRef.value !== "") return; + if (inputRef!.value !== "") return; if (props.mentions) { const prepText = props.mentions.join(" ") + " "; - inputRef.value = prepText; + inputRef!.value = prepText; } }); @@ -294,7 +294,7 @@ const TootComposer: Component<{ try { const status = await client.v1.statuses.create( { - status: inputRef.value, + status: inputRef!.value, language: language(), visibility: visibility(), inReplyToId: props.inReplyToId, @@ -309,7 +309,7 @@ const TootComposer: Component<{ ); props.onSent?.(status); - inputRef.value = ""; + inputRef!.value = ""; } finally { setSending(false); } @@ -363,7 +363,7 @@ const TootComposer: Component<{
diff --git a/src/timelines/TootList.tsx b/src/timelines/TootList.tsx index 35986a5..b56d98c 100644 --- a/src/timelines/TootList.tsx +++ b/src/timelines/TootList.tsx @@ -1,19 +1,16 @@ import { Component, - createSignal, ErrorBoundary, type Ref, - createSelector, Index, createMemo, For, createUniqueId, } from "solid-js"; import { type mastodon } from "masto"; -import { vibrate } from "~platform/hardware"; import { useDefaultSession } from "../masto/clients"; -import { setCache as setTootBottomSheetCache } from "./TootBottomSheet"; import RegularToot, { + createDefaultTootEnv, findElementActionable, findRootToot, TootEnvProvider, @@ -23,6 +20,7 @@ import type { ThreadNode } from "../masto/timelines"; import { useNavigator } from "~platform/StackedRouter"; import { ANIM_CURVE_STD } from "~material/theme"; import { useItemSelection } from "./toots/ItemSelectionProvider"; +import { fetchStatus } from "../masto/statuses"; function durationOf(rect0: DOMRect, rect1: DOMRect) { const distancelt = Math.sqrt( @@ -61,62 +59,12 @@ const TootList: Component<{ const [isExpanded, setExpanded] = useItemSelection(); const { push } = useNavigator(); - const onBookmark = async (status: mastodon.v1.Status) => { - const client = session()?.client; - if (!client) return; + const tootEnv = createDefaultTootEnv( + () => session()?.client, + (...args) => props.onChangeToot(...args), + ); - const result = await (status.bookmarked - ? client.v1.statuses.$select(status.id).unbookmark() - : client.v1.statuses.$select(status.id).bookmark()); - props.onChangeToot(result.id, result); - }; - - const toggleBoost = async (status: mastodon.v1.Status) => { - const client = session()?.client; - if (!client) return; - - vibrate(50); - const rootStatus = status.reblog ? status.reblog : status; - const reblogged = rootStatus.reblogged; - if (status.reblog) { - props.onChangeToot(status.id, { - ...status, - reblog: { ...status.reblog, reblogged: !reblogged }, - }); - } else { - props.onChangeToot(status.id, { - ...status, - reblogged: !reblogged, - }); - } - - const result = reblogged - ? await client.v1.statuses.$select(status.id).unreblog() - : (await client.v1.statuses.$select(status.id).reblog()).reblog!; - - if (status.reblog) { - props.onChangeToot(status.id, { - ...status, - reblog: result, - }); - } else { - props.onChangeToot(status.id, result); - } - }; - - const toggleFavourite = async (status: mastodon.v1.Status) => { - const client = session()?.client; - if (!client) return; - const ovalue = status.favourited; - props.onChangeToot(status.id, { ...status, favourited: !ovalue }); - - const result = ovalue - ? await client.v1.statuses.$select(status.id).unfavourite() - : await client.v1.statuses.$select(status.id).favourite(); - props.onChangeToot(status.id, result); - }; - - const openFullScreenToot = ( + const openFullScreenToot = async ( toot: mastodon.v1.Status, srcElement: HTMLElement, reply?: boolean, @@ -130,7 +78,7 @@ const TootList: Component<{ } const acct = `${inf.username}@${p.site}`; - setTootBottomSheetCache(acct, toot); + await fetchStatus.setJson([p, toot.id], toot) push(`/${encodeURIComponent(acct)}/toot/${toot.id}`, { animateOpen(element) { @@ -234,33 +182,6 @@ const TootList: Component<{ openFullScreenToot(status, element, true); }; - const vote = async (status: mastodon.v1.Status, votes: readonly number[]) => { - const client = session()?.client; - if (!client) return; - - const toot = status.reblog ?? status; - if (!toot.poll) return; - - const npoll = await client.v1.polls.$select(toot.poll.id).votes.create({ - choices: votes, - }); - - if (status.reblog) { - props.onChangeToot(status.id, { - ...status, - reblog: { - ...status.reblog, - poll: npoll, - }, - }); - } else { - props.onChangeToot(status.id, { - ...status, - poll: npoll, - }); - } - }; - return ( { @@ -270,11 +191,8 @@ const TootList: Component<{ >
diff --git a/src/timelines/toots/TootActionGroup.tsx b/src/timelines/toots/TootActionGroup.tsx index fef75ef..280de36 100644 --- a/src/timelines/toots/TootActionGroup.tsx +++ b/src/timelines/toots/TootActionGroup.tsx @@ -24,13 +24,19 @@ function isolatedCallback(e: MouseEvent) { e.stopPropagation(); } +/** + * The actions of the toot card. + * + * The `value` must be the original toot (contains `reblog` if + * it's a boost), since the value will be passed to the callbacks. + */ function TootActionGroup(props: { value: T; class?: string; }) { const { reply, boost, favourite, bookmark } = useTootEnv(); let actGrpElement: HTMLDivElement; - const toot = () => props.value; + const toot = () => props.value.reblog ?? props.value; return (