diff --git a/bun.lockb b/bun.lockb index 25e9097..3dc8a47 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 19f3a48..e5a7f53 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@solid-primitives/event-listener": "^2.3.3", "@solid-primitives/i18n": "^2.1.1", "@solid-primitives/intersection-observer": "^2.1.6", + "@solid-primitives/map": "^0.4.13", "@solid-primitives/resize-observer": "^2.0.26", "@solidjs/router": "^0.14.5", "@suid/icons-material": "^0.8.0", diff --git a/src/UnexpectedError.tsx b/src/UnexpectedError.tsx index 38e3c70..aa7f468 100644 --- a/src/UnexpectedError.tsx +++ b/src/UnexpectedError.tsx @@ -1,40 +1,59 @@ -import { Button } from '@suid/material'; -import {Component, createResource} from 'solid-js' -import { css } from 'solid-styled'; +import { Button } from "@suid/material"; +import { Component, createResource } from "solid-js"; +import { css } from "solid-styled"; -const UnexpectedError: Component<{error?: any}> = (props) => { +const UnexpectedError: Component<{ error?: any }> = (props) => { + const [errorMsg] = createResource( + () => props.error, + async (err) => { + if (err instanceof Error) { + const mod = await import("stacktrace-js"); + try { + const stacktrace = await mod.fromError(err); + const strackMsg = stacktrace + .map( + (entry) => + `${entry.functionName ?? ""}@${entry.fileName}:(${entry.lineNumber}:${entry.columnNumber})`, + ) + .join("\n"); + return `${err.name}: ${err.message}\n${strackMsg}`; + } catch (reason) { + return `\n${reason}`; + } + } - const [errorMsg] = createResource(() => props.error, async (err) => { - if (err instanceof Error) { - const mod = await import('stacktrace-js') - const stacktrace = await mod.fromError(err) - const strackMsg = stacktrace.map(entry => `${entry.functionName ?? ""}@${entry.fileName}:(${entry.lineNumber}:${entry.columnNumber})`).join('\n') - return `${err.name}: ${err.message}\n${strackMsg}` - } - - return err.toString() - }) + return err.toString(); + }, + ); css` - main { - padding: calc(var(--safe-area-inset-top) + 20px) calc(var(--safe-area-inset-right) + 20px) calc(var(--safe-area-inset-bottom) + 20px) calc(var(--safe-area-inset-left) + 20px); - } - ` + main { + padding: calc(var(--safe-area-inset-top) + 20px) + calc(var(--safe-area-inset-right) + 20px) + calc(var(--safe-area-inset-bottom) + 20px) + calc(var(--safe-area-inset-left) + 20px); + } + `; - return
-

Oh, it is our fault.

-

There is an unexpected error in our app, and it's not your fault.

-

You can reload to see if this guy is gone. If you meet this guy repeatly, please report to us.

-
- -
-
- {errorMsg.loading ? 'Generating ' : " "}Technical Infomation (Bring to us if you report the problem) -
-        {errorMsg()}
-      
-
-
-} + return ( +
+

Oh, it is our fault.

+

There is an unexpected error in our app, and it's not your fault.

+

+ You can reload to see if this guy is gone. If you meet this guy + repeatly, please report to us. +

+
+ +
+
+ + {errorMsg.loading ? "Generating " : " "}Technical Infomation + +
{errorMsg()}
+
+
+ ); +}; export default UnexpectedError; diff --git a/src/masto/timelines.ts b/src/masto/timelines.ts index 2e49145..8eb9c81 100644 --- a/src/masto/timelines.ts +++ b/src/masto/timelines.ts @@ -1,111 +1,258 @@ +import { ReactiveMap } from "@solid-primitives/map"; import { type mastodon } from "masto"; -import { Accessor, createEffect, createResource } from "solid-js"; +import { + Accessor, + batch, + catchError, + createEffect, + createResource, + untrack, + type ResourceFetcherInfo, +} from "solid-js"; import { createStore } from "solid-js/store"; -type TimelineFetchTips = { - direction?: "new" | "old"; -}; - type Timeline = { list(params: { + /** Return results older than this ID. */ + readonly maxId?: string; + /** Return results newer than this ID. */ + readonly sinceId?: string; + /** Get a list of items with ID greater than this value excluding this ID */ + readonly minId?: string; + /** Maximum number of results to return per page. Defaults to 40. NOTE: Pagination is done with the Link header from the response. */ readonly limit?: number; }): mastodon.Paginator; }; -export function useTimeline( +export function createTimelineSnapshot( timeline: Accessor, - cfg?: { - /** - * Use full refresh mode. This mode ignores paging, it will refetch the specified number - * of toots at every refetch(). - */ - fullRefresh?: number; - }, + limit: Accessor, ) { - let otl: Timeline | undefined; - let npager: mastodon.Paginator | undefined; - let opager: mastodon.Paginator | undefined; - const [snapshot, { refetch }] = createResource< - { - records: mastodon.v1.Status[]; - direction: "new" | "old" | "items"; - tlChanged: boolean; - }, - [Timeline], - TimelineFetchTips | undefined - >( - () => [timeline()] as const, - async ([tl], info) => { - let tlChanged = false; - if (otl !== tl) { - npager = opager = undefined; - otl = tl; - tlChanged = true; - } - const fullRefresh = cfg?.fullRefresh; - if (typeof fullRefresh !== "undefined") { - const records = await tl - .list({ - limit: fullRefresh, - }) - .next(); - return { - direction: "items", - records: records.value ?? [], - end: records.done, - tlChanged, - }; - } - const direction = - typeof info.refetching !== "boolean" - ? (info.refetching?.direction ?? "old") - : "old"; - if (direction === "old") { - if (!opager) { - opager = tl.list({}).setDirection("next"); - } - const next = await opager.next(); - return { - direction, - records: next.value ?? [], - end: next.done, - tlChanged, - }; - } else { - if (!npager) { - npager = tl.list({}).setDirection("prev"); - } - const next = await npager.next(); - const page = next.value ?? []; - return { direction, records: page, end: next.done, tlChanged }; - } + const [shot, { refetch }] = createResource( + () => [timeline(), limit()] as const, + async ([tl, limit]) => { + const ls = await tl.list({ limit }).next(); + return ls.value?.map((x) => [x]) ?? []; }, ); - const [store, setStore] = createStore([] as mastodon.v1.Status[]); + const [snapshot, setSnapshot] = createStore([] as mastodon.v1.Status[][]); createEffect(() => { - const shot = snapshot(); - if (!shot) return; - const { direction, records, tlChanged } = shot; - if (tlChanged) { - setStore(() => []); - } - if (direction === "new") { - setStore((x) => [...records, ...x]); - } else if (direction === "old") { - setStore((x) => [...x, ...records]); - } else if (direction === "items") { - setStore(() => records); + const nls = catchError(shot, (e) => console.error(e)); + if (!nls) return; + const ols = Array.from(snapshot); + // The algorithm below assumes the snapshot is not changing + for (let i = 0; i < nls.length; i++) { + if (i >= ols.length) { + setSnapshot(i, nls[i]); + } else { + if (nls[i].length !== ols[i].length) { + setSnapshot(i, nls[i]); + } else { + const oth = ols[i], + nth = nls[i]; + for (let j = 0; j < oth.length; j++) { + const ost = oth[j], + nst = nth[j]; + for (const key of Object.keys( + nst, + ) as unknown as (keyof mastodon.v1.Status)[]) { + if (ost[key] !== nst[key]) { + setSnapshot(i, j, key, nst[key]); + } + } + } + } + } } }); return [ - store, snapshot, + shot, { refetch, - mutate: setStore, + mutate: setSnapshot, }, ] as const; } + +export type TimelineFetchDirection = mastodon.Direction; + +export type TimelineChunk = { + tl: Timeline; + rebuilt: boolean; + chunk: readonly mastodon.v1.Status[]; + done?: boolean; + direction: TimelineFetchDirection; + limit: number; +}; + +type TreeNode = { + parent?: TreeNode; + value: T; + children?: TreeNode[]; +}; + +/** Collect the path of a node for the root. + * The first element is the node itself, the last element is the root. + */ +function collectPath(node: TreeNode) { + const path = [node] as TreeNode[]; + let current = node; + while (current.parent) { + path.push(current.parent); + current = current.parent; + } + return path; +} + +function createTimelineChunk( + timeline: Accessor, + limit: Accessor, +) { + let vpMaxId: string | undefined, vpMinId: string | undefined; + + const fetchExtendingPage = async ( + tl: Timeline, + direction: TimelineFetchDirection, + limit: number, + ) => { + switch (direction) { + case "next": { + const page = await tl + .list({ limit, sinceId: vpMaxId }) + .setDirection(direction) + .next(); + if ((page.value?.length ?? 0) > 0) { + vpMaxId = page.value![0].id; + } + return page; + } + + case "prev": { + const page = await tl + .list({ limit, maxId: vpMinId }) + .setDirection(direction) + .next(); + if ((page.value?.length ?? 0) > 0) { + vpMinId = page.value![page.value!.length - 1].id; + } + return page; + } + } + }; + + return createResource( + () => [timeline(), limit()] as const, + async ( + [tl, limit], + info: ResourceFetcherInfo< + Readonly, + TimelineFetchDirection + >, + ) => { + const direction = + typeof info.refetching === "boolean" ? "prev" : info.refetching; + const rebuildTimeline = tl !== info.value?.tl; + if (rebuildTimeline) { + vpMaxId = undefined; + vpMinId = undefined; + } + const posts = await fetchExtendingPage(tl, direction, limit); + return { + tl, + rebuilt: rebuildTimeline, + chunk: posts.value ?? [], + done: posts.done, + direction, + limit, + }; + }, + ); +} + +export function createTimeline( + timeline: Accessor, + limit: Accessor, +) { + const lookup = new ReactiveMap>(); + const [threads, setThreads] = createStore([] as mastodon.v1.Status["id"][]); + + const [chunk, { refetch }] = createTimelineChunk(timeline, limit); + + createEffect(() => { + const chk = catchError(chunk, (e) => console.error(e)); + if (!chk) { + return; + } + + if (chk.rebuilt) { + lookup.clear(); + setThreads([]); + } + + const existence = [] as boolean[]; + + for (const [idx, status] of chk.chunk.entries()) { + existence[idx] = !!untrack(() => lookup.get(status.id)); + lookup.set(status.id, { + value: status, + }); + } + + for (const status of chk.chunk) { + const node = untrack(() => lookup.get(status.id))!; + if (status.inReplyToId) { + const parent = lookup.get(status.inReplyToId); + if (parent) { + const children = parent.children ?? []; + if (!children.find((x) => x.value.id == status.id)) { + children.push(node); + } + parent.children = children; + node.parent = parent; + } + } + } + + const nThreadIds = chk.chunk + .filter((x, i) => !existence[i]) + .map((x) => x.id); + + batch(() => { + if (chk.direction === "prev") { + setThreads((threads) => [...threads, ...nThreadIds]); + } else if (chk.direction === "next") { + setThreads((threads) => [...nThreadIds, ...threads]); + } + + setThreads((threads) => + threads.filter((id) => (lookup.get(id)!.children?.length ?? 0) === 0), + ); + }); + }); + + return [ + { + list: threads, + get(id: string) { + return lookup.get(id); + }, + getPath(id: string) { + const node = lookup.get(id); + if (!node) return; + return collectPath(node); + }, + set(id: string, value: mastodon.v1.Status) { + const node = untrack(() => lookup.get(id)); + if (!node) return; + node.value = value; + lookup.set(id, node); + }, + }, + chunk, + { refetch }, + ] as const; +} diff --git a/src/timelines/Home.tsx b/src/timelines/Home.tsx index 8321872..91ba685 100644 --- a/src/timelines/Home.tsx +++ b/src/timelines/Home.tsx @@ -1,26 +1,16 @@ import { - Component, - For, - onCleanup, createSignal, Show, - untrack, onMount, type ParentComponent, children, Suspense, - Match, - Switch as JsSwitch, - ErrorBoundary, } from "solid-js"; import { useDocumentTitle } from "../utils"; import { type mastodon } from "masto"; import Scaffold from "../material/Scaffold"; import { AppBar, - Button, - Fab, - LinearProgress, ListItemSecondaryAction, ListItemText, MenuItem, @@ -29,205 +19,21 @@ import { } from "@suid/material"; import { css } from "solid-styled"; import { TimeSourceProvider, createTimeSource } from "../platform/timesrc"; -import TootThread from "./TootThread.js"; import ProfileMenuButton from "./ProfileMenuButton"; import Tabs from "../material/Tabs"; import Tab from "../material/Tab"; -import { Create as CreateTootIcon } from "@suid/icons-material"; -import { useTimeline } from "../masto/timelines"; import { makeEventListener } from "@solid-primitives/event-listener"; import BottomSheet, { HERO as BOTTOM_SHEET_HERO, } from "../material/BottomSheet"; import { $settings } from "../settings/stores"; import { useStore } from "@nanostores/solid"; -import { vibrate } from "../platform/hardware"; -import PullDownToRefresh from "./PullDownToRefresh"; import { HeroSourceProvider, type HeroSource } from "../platform/anim"; import { useNavigate } from "@solidjs/router"; import { useSignedInProfiles } from "../masto/acct"; import { setCache as setTootBottomSheetCache } from "./TootBottomSheet"; -import TootComposer from "./TootComposer"; - -const TimelinePanel: Component<{ - client: mastodon.rest.Client; - name: "home" | "public" | "trends"; - prefetch?: boolean; - fullRefetch?: number; - - openFullScreenToot: ( - toot: mastodon.v1.Status, - srcElement?: HTMLElement, - reply?: boolean, - ) => void; -}> = (props) => { - const [scrollLinked, setScrollLinked] = createSignal(); - const [ - timeline, - snapshot, - { refetch: refetchTimeline, mutate: mutateTimeline }, - ] = useTimeline( - () => - props.name !== "trends" - ? props.client.v1.timelines[props.name] - : props.client.v1.trends.statuses, - { fullRefresh: props.fullRefetch }, - ); - const [expandedThreadId, setExpandedThreadId] = createSignal(); - const [typing, setTyping] = createSignal(false); - - const tlEndObserver = new IntersectionObserver(() => { - if (untrack(() => props.prefetch) && !snapshot.loading) - refetchTimeline({ direction: "old" }); - }); - - onCleanup(() => tlEndObserver.disconnect()); - - const onBookmark = async ( - index: number, - client: mastodon.rest.Client, - status: mastodon.v1.Status, - ) => { - const result = await (status.bookmarked - ? client.v1.statuses.$select(status.id).unbookmark() - : client.v1.statuses.$select(status.id).bookmark()); - mutateTimeline((o) => { - o[index] = result; - return o; - }); - }; - - const onBoost = async ( - index: number, - client: mastodon.rest.Client, - status: mastodon.v1.Status, - ) => { - const reblogged = status.reblog - ? status.reblog.reblogged - : status.reblogged; - vibrate(50); - mutateTimeline(index, (x) => { - if (x.reblog) { - x.reblog = { ...x.reblog, reblogged: !reblogged }; - return Object.assign({}, x); - } else { - return Object.assign({}, x, { - reblogged: !reblogged, - }); - } - }); - const result = reblogged - ? await client.v1.statuses.$select(status.id).unreblog() - : (await client.v1.statuses.$select(status.id).reblog()).reblog!; - mutateTimeline((o) => { - Object.assign(o[index].reblog ?? o[index], { - reblogged: result.reblogged, - reblogsCount: result.reblogsCount, - }); - return o; - }); - }; - - return ( - { - return

Oops: {String(err)}

; - }} - > - refetchTimeline({ direction: "new" })} - /> -
- setTimeout(() => { - setScrollLinked(e.parentElement!); - }, 0) - } - > - - refetchTimeline({ direction: "new" })} - /> - - - {(item, index) => { - let element: HTMLElement | undefined; - return ( - onBoost(index(), ...args)} - onBookmark={(...args) => onBookmark(index(), ...args)} - onReply={(client, status) => - props.openFullScreenToot(status, element, true) - } - client={props.client} - expanded={item.id === expandedThreadId() ? 1 : 0} - onExpandChange={(x) => { - setTyping(false) - if (item.id !== expandedThreadId()) { - setExpandedThreadId((x) => (x ? undefined : item.id)); - } else if (x === 2) { - props.openFullScreenToot(item, element); - } - }} - /> - ); - }} - -
- -
tlEndObserver.observe(e)}>
- -
- -
-
-
- - - - - - - - -
-
- ); -}; +import TrendTimelinePanel from "./TrendTimelinePanel"; +import TimelinePanel from "./TimelinePanel"; const Home: ParentComponent = (props) => { let panelList: HTMLDivElement; @@ -340,7 +146,9 @@ const Home: ParentComponent = (props) => { console.warn("no account info?"); return; } - setHeroSrc((x) => Object.assign({}, x, { [BOTTOM_SHEET_HERO]: srcElement })); + setHeroSrc((x) => + Object.assign({}, x, { [BOTTOM_SHEET_HERO]: srcElement }), + ); const acct = `${inf.username}@${p.account.site}`; setTootBottomSheetCache(acct, toot); navigate(`/${encodeURIComponent(acct)}/${toot.id}`, { @@ -439,12 +247,10 @@ const Home: ParentComponent = (props) => {
-
@@ -464,7 +270,9 @@ const Home: ParentComponent = (props) => { - {child()} + navigate(-1)}> + {child()} + diff --git a/src/timelines/MediaAttachmentGrid.tsx b/src/timelines/MediaAttachmentGrid.tsx index 567e67c..e22a767 100644 --- a/src/timelines/MediaAttachmentGrid.tsx +++ b/src/timelines/MediaAttachmentGrid.tsx @@ -66,7 +66,7 @@ const MediaAttachmentGrid: Component<{ css` .attachments { - column-count: ${columnCount.toString()}; + column-count: ${columnCount().toString()}; } `; return ( diff --git a/src/timelines/PullDownToRefresh.tsx b/src/timelines/PullDownToRefresh.tsx index 55859e5..1c80829 100644 --- a/src/timelines/PullDownToRefresh.tsx +++ b/src/timelines/PullDownToRefresh.tsx @@ -52,7 +52,7 @@ const PullDownToRefresh: Component<{ let lts = -1; let ds = 0; let holding = false; - const K = 10; + const K = 20; const updatePullDown = (ts: number) => { released = false; try { @@ -60,8 +60,9 @@ const PullDownToRefresh: Component<{ const dt = lts !== -1 ? ts - lts : 1 / 60; const vspring = holding ? 0 : K * x * dt; v = ds / dt - vspring; + const final = Math.max(Math.min(x + v * dt, stopPos()), 0); - setPullDown(Math.max(Math.min(x + v * dt, stopPos()), 0)); + setPullDown(final); if (Math.abs(x) > 1 || Math.abs(v) > 1) { requestAnimationFrame(updatePullDown); @@ -69,15 +70,6 @@ const PullDownToRefresh: Component<{ v = 0; lts = -1; } - - if ( - !holding && - untrack(pullDown) >= stopPos() && - !props.loading && - props.onRefresh - ) { - setTimeout(props.onRefresh, 0); - } } finally { ds = 0; released = true; @@ -89,6 +81,11 @@ const PullDownToRefresh: Component<{ const onWheelNotUpdated = () => { wheelTimeout = undefined; holding = false; + + if (released) { + released = false; + requestAnimationFrame(updatePullDown); + } }; const handleLinkedWheel = (event: WheelEvent) => { @@ -97,11 +94,18 @@ const PullDownToRefresh: Component<{ const d = untrack(pullDown); if (d > 1) event.preventDefault(); ds = -(event.deltaY / window.devicePixelRatio / 2); - holding = d < stopPos(); if (wheelTimeout) { clearTimeout(wheelTimeout); } - wheelTimeout = setTimeout(onWheelNotUpdated, 200); + if (d >= stopPos() && !props.loading) { + props.onRefresh?.(); + + holding = false; + wheelTimeout = undefined; + } else { + holding = true; + wheelTimeout = setTimeout(onWheelNotUpdated, 200); + } if (released) { released = false; @@ -151,12 +155,8 @@ const PullDownToRefresh: Component<{ lastTouchId = undefined; lastTouchScreenY = 0; holding = false; - if ( - untrack(indicatorOfsY) >= stopPos() && - !props.loading && - props.onRefresh - ) { - setTimeout(props.onRefresh, 0); + if (untrack(pullDown) >= stopPos() && !props.loading) { + props.onRefresh?.(); } else { if (released) { released = false; @@ -203,9 +203,7 @@ const PullDownToRefresh: Component<{ background-color: var(--tutu-color-surface); > :global(.refresh-icon) { - transform: rotate( - ${`${((indicatorOfsY() / 160) * 180).toString()}deg`} - ); + transform: rotate(${`${(indicatorOfsY() / 160 / 2).toString()}turn`}); will-change: transform; } diff --git a/src/timelines/RegularToot.tsx b/src/timelines/RegularToot.tsx index 1c9eb60..2042618 100644 --- a/src/timelines/RegularToot.tsx +++ b/src/timelines/RegularToot.tsx @@ -111,13 +111,17 @@ type TootActionGroupProps = { onRetoot?: (value: T) => void; onFavourite?: (value: T) => void; onBookmark?: (value: T) => void; - onReply?: (value: T) => void; + onReply?: ( + value: T, + event: MouseEvent & { currentTarget: HTMLButtonElement }, + ) => void; }; type TootCardProps = { status: mastodon.v1.Status; actionable?: boolean; evaluated?: boolean; + thread?: "top" | "bottom" | "middle"; } & TootActionGroupProps & JSX.HTMLElementTags["article"]; @@ -125,19 +129,40 @@ function isolatedCallback(e: MouseEvent) { e.stopPropagation(); } +export function findRootToot(element: HTMLElement) { + let current: HTMLElement | null = element; + while (current && !current.classList.contains(tootStyle.toot)) { + current = current.parentElement; + } + if (!current) { + throw Error( + `the element must be placed under a element with ${tootStyle.toot}`, + ); + } + return current; +} + function TootActionGroup( props: TootActionGroupProps & { value: T }, ) { + let actGrpElement: HTMLDivElement; const toot = () => props.value; return ( -
- +
+ + + + + + + + + +
+ + ); +}; + +export default TimelinePanel; diff --git a/src/timelines/TootBottomSheet.tsx b/src/timelines/TootBottomSheet.tsx index dc1bc4e..0081c97 100644 --- a/src/timelines/TootBottomSheet.tsx +++ b/src/timelines/TootBottomSheet.tsx @@ -62,9 +62,6 @@ const TootBottomSheet: Component = (props) => { } ); }; - const profile = () => { - return session().account; - }; const pushedCount = () => { return location.state?.tootBottomSheetPushedCount || 0; diff --git a/src/timelines/TrendTimelinePanel.tsx b/src/timelines/TrendTimelinePanel.tsx new file mode 100644 index 0000000..a2370e8 --- /dev/null +++ b/src/timelines/TrendTimelinePanel.tsx @@ -0,0 +1,182 @@ +import { + Component, + For, + onCleanup, + createSignal, + untrack, + Match, + Switch as JsSwitch, + ErrorBoundary, + createSelector, +} from "solid-js"; +import { type mastodon } from "masto"; +import { Button } from "@suid/material"; +import { createTimelineSnapshot } from "../masto/timelines.js"; +import { vibrate } from "../platform/hardware.js"; +import PullDownToRefresh from "./PullDownToRefresh.jsx"; +import Thread from "./Thread.jsx"; + +const TrendTimelinePanel: Component<{ + client: mastodon.rest.Client; + prefetch?: boolean; + + openFullScreenToot: ( + toot: mastodon.v1.Status, + srcElement?: HTMLElement, + reply?: boolean, + ) => void; +}> = (props) => { + const [scrollLinked, setScrollLinked] = createSignal(); + const [ + timeline, + snapshot, + { refetch: refetchTimeline, mutate: mutateTimeline }, + ] = createTimelineSnapshot( + () => props.client.v1.trends.statuses, + () => 120, + ); + const [expandedId, setExpandedId] = createSignal(); + + const tlEndObserver = new IntersectionObserver(() => { + if (untrack(() => props.prefetch) && !snapshot.loading) + refetchTimeline(); + }); + + onCleanup(() => tlEndObserver.disconnect()); + + const isExpandedId = createSelector(expandedId); + + const isExpanded = (st: mastodon.v1.Status) => isExpandedId(st.id); + + const onBookmark = async ( + index: number, + client: mastodon.rest.Client, + status: mastodon.v1.Status, + ) => { + const result = await (status.bookmarked + ? client.v1.statuses.$select(status.id).unbookmark() + : client.v1.statuses.$select(status.id).bookmark()); + mutateTimeline((o) => { + o![index] = [result]; + return o; + }); + }; + + const onBoost = async ( + index: number, + client: mastodon.rest.Client, + status: mastodon.v1.Status, + ) => { + const reblogged = status.reblog + ? status.reblog.reblogged + : status.reblogged; + vibrate(50); + mutateTimeline(index, (th) => { + const x = th[0]; + if (x.reblog) { + x.reblog = { ...x.reblog, reblogged: !reblogged }; + return [Object.assign({}, x)]; + } else { + return [ + Object.assign({}, x, { + reblogged: !reblogged, + }), + ]; + } + }); + const result = reblogged + ? await client.v1.statuses.$select(status.id).unreblog() + : (await client.v1.statuses.$select(status.id).reblog()).reblog!; + mutateTimeline(index, (th) => { + Object.assign(th[0].reblog ?? th[0], { + reblogged: result.reblogged, + reblogsCount: result.reblogsCount, + }); + return th; + }); + }; + + return ( + { + return

Oops: {String(err)}

; + }} + > + refetchTimeline({ direction: "new" })} + /> +
+ setTimeout(() => { + setScrollLinked(e.parentElement!); + }, 0) + } + > + + {(item, index) => { + let element: HTMLElement | undefined; + return ( + onBoost(index(), ...args)} + onBookmark={(...args) => onBookmark(index(), ...args)} + onReply={(client, status) => + props.openFullScreenToot(status, element, true) + } + client={props.client} + isExpended={isExpanded} + onItemClick={(x) => { + if (x.id !== expandedId()) { + setExpandedId((o) => (o ? undefined : x.id)); + } else { + props.openFullScreenToot(x, element); + } + }} + /> + ); + }} + +
+ +
tlEndObserver.observe(e)}>
+
+ + +

{`Oops: ${snapshot.error}`}

+ + +
+ + + +
+
+
+ ); +}; + +export default TrendTimelinePanel;