diff --git a/src/accounts/stores.ts b/src/accounts/stores.ts index 006ea9e..c44a012 100644 --- a/src/accounts/stores.ts +++ b/src/accounts/stores.ts @@ -6,19 +6,10 @@ import { } from "masto"; import { createMastoClientFor } from "../masto/clients"; -export type RemoteServer = { +export type Account = { site: 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; @@ -26,10 +17,6 @@ export type Account = AccountKey & { 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 deleted file mode 100644 index 823fad7..0000000 --- a/src/masto/base.ts +++ /dev/null @@ -1,23 +0,0 @@ -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 7cd22e7..fc26ee5 100644 --- a/src/masto/clients.ts +++ b/src/masto/clients.ts @@ -7,7 +7,7 @@ import { untrack, useContext, } from "solid-js"; -import { Account, type RemoteServer } from "../accounts/stores"; +import { Account } 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 {@link RemoteServer} and the `client` is an + * In an unauthorised session, the `.account` is `undefined` 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: { site: inputSite } as RemoteServer, // TODO: we need some security checks here? - } as const + account: undefined, + } ); }); } diff --git a/src/masto/statuses.ts b/src/masto/statuses.ts deleted file mode 100644 index 91fdc7a..0000000 --- a/src/masto/statuses.ts +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 5d5b10a..0000000 --- a/src/platform/cache.ts +++ /dev/null @@ -1,145 +0,0 @@ -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 b743acd..6e24cbf 100644 --- a/src/profiles/Profile.tsx +++ b/src/profiles/Profile.tsx @@ -1,5 +1,6 @@ import { catchError, + createRenderEffect, createResource, createSignal, createUniqueId, @@ -13,6 +14,7 @@ import { } from "solid-js"; import Scaffold from "~material/Scaffold"; import { + AppBar, Avatar, Button, Checkbox, @@ -24,6 +26,7 @@ import { ListItemSecondaryAction, ListItemText, MenuItem, + Toolbar, } from "@suid/material"; import { Close, @@ -60,7 +63,6 @@ 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(); @@ -115,7 +117,7 @@ const Profile: Component = () => { }; const isCurrentSessionProfile = () => { - return (session().account as Account).inf?.url === profile()?.url; + return session().account?.inf?.url === profile()?.url; }; const [recentTootFilter, setRecentTootFilter] = createSignal({ @@ -170,8 +172,8 @@ const Profile: Component = () => { const sessionDisplayName = createMemo(() => resolveCustomEmoji( - (session().account as Account).inf?.displayName || "", - (session().account as Account).inf?.emojis ?? [], + session().account?.inf?.displayName || "", + session().account?.inf?.emojis ?? [], ), ); @@ -242,7 +244,7 @@ const Profile: Component = () => { - + @@ -365,7 +367,7 @@ const Profile: Component = () => { aria-label={`${relationship()?.following ? "Unfollow" : "Follow"} on your home timeline`} > - + void; @@ -53,107 +52,6 @@ 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; @@ -305,7 +203,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 79fed0f..eecc8fc 100644 --- a/src/timelines/TootBottomSheet.tsx +++ b/src/timelines/TootBottomSheet.tsx @@ -1,17 +1,26 @@ -import { useParams } from "@solidjs/router"; -import { catchError, createResource, Show, type Component } from "solid-js"; +import { useLocation, useParams } from "@solidjs/router"; +import { + catchError, + createEffect, + createRenderEffect, + createResource, + Show, + type Component, +} from "solid-js"; import Scaffold from "~material/Scaffold"; -import { CircularProgress } from "@suid/material"; +import { AppBar, CircularProgress, IconButton, Toolbar } 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"; @@ -24,8 +33,18 @@ import ItemSelectionProvider, { createSingluarItemSelection, } from "./toots/ItemSelectionProvider"; import AppTopBar from "~material/AppTopBar"; -import { fetchStatus } from "../masto/statuses"; -import { type Account } from "../accounts/stores"; + +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]; + } +} const TootBottomSheet: Component = (props) => { const params = useParams<{ acct: string; id: string }>(); @@ -35,19 +54,29 @@ const TootBottomSheet: Component = (props) => { const session = useSessionForAcctStr(acctText); const [, selectionState] = createSingluarItemSelection(); - const [remoteToot, { mutate: setRemoteToot }] = - fetchStatus.cachedAndRevalidate( - () => [session().account, params.id] as const, - ); + const [remoteToot, { mutate: setRemoteToot }] = createResource( + () => [session().client, params.id] as const, + async ([client, id]) => { + return await client.v1.statuses.$select(id).fetch(); + }, + ); 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, toot()?.reblog?.id ?? params.id] as const, + () => [session().client, params.id] as const, async ([client, id]) => { return await client.v1.statuses.$select(id).context.fetch(); }, @@ -65,6 +94,12 @@ 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"; @@ -83,10 +118,49 @@ const TootBottomSheet: Component = (props) => { return s.account ? s : undefined; }; - const mainTootEnv = createDefaultTootEnv( - () => actSession()?.client, - (_, status) => setRemoteToot(status), - ); + 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 defaultMentions = () => { const tootAcct = remoteToot()?.reblog?.account ?? remoteToot()?.account; @@ -100,6 +174,33 @@ 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 }, ) => { @@ -165,51 +266,58 @@ const TootBottomSheet: Component = (props) => { >
- - + +
+ + + + + +
+ + + refetchContext()} + inReplyToId={remoteToot()?.reblog?.id ?? remoteToot()?.id} /> + -
- - - - - -
- - - refetchContext()} - inReplyToId={remoteToot()?.reblog?.id ?? remoteToot()?.id} - /> - - - -
- -
-
+ +
+ +
+
+ ; style?: JSX.CSSProperties; - profile?: mastodon.v1.Account; + profile?: 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 b56d98c..35986a5 100644 --- a/src/timelines/TootList.tsx +++ b/src/timelines/TootList.tsx @@ -1,16 +1,19 @@ 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, @@ -20,7 +23,6 @@ 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( @@ -59,12 +61,62 @@ const TootList: Component<{ const [isExpanded, setExpanded] = useItemSelection(); const { push } = useNavigator(); - const tootEnv = createDefaultTootEnv( - () => session()?.client, - (...args) => props.onChangeToot(...args), - ); + const onBookmark = async (status: mastodon.v1.Status) => { + const client = session()?.client; + if (!client) return; - const openFullScreenToot = async ( + 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 = ( toot: mastodon.v1.Status, srcElement: HTMLElement, reply?: boolean, @@ -78,7 +130,7 @@ const TootList: Component<{ } const acct = `${inf.username}@${p.site}`; - await fetchStatus.setJson([p, toot.id], toot) + setTootBottomSheetCache(acct, toot); push(`/${encodeURIComponent(acct)}/toot/${toot.id}`, { animateOpen(element) { @@ -182,6 +234,33 @@ 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 ( { @@ -191,8 +270,11 @@ const TootList: Component<{ >
diff --git a/src/timelines/toots/TootActionGroup.tsx b/src/timelines/toots/TootActionGroup.tsx index 280de36..fef75ef 100644 --- a/src/timelines/toots/TootActionGroup.tsx +++ b/src/timelines/toots/TootActionGroup.tsx @@ -24,19 +24,13 @@ 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.reblog ?? props.value; + const toot = () => props.value; return (