From 2c1eb2d8606be098186b87f957b096aa2a1b4ec3 Mon Sep 17 00:00:00 2001 From: thislight Date: Thu, 10 Oct 2024 16:24:06 +0800 Subject: [PATCH 01/10] added createTimelineSnapshot --- src/masto/timelines.ts | 54 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/src/masto/timelines.ts b/src/masto/timelines.ts index 2e49145..4e307f7 100644 --- a/src/masto/timelines.ts +++ b/src/masto/timelines.ts @@ -109,3 +109,57 @@ export function useTimeline( }, ] as const; } + +export function createTimelineSnapshot( + timeline: Accessor, + limit: Accessor, +) { + 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 [snapshot, setSnapshot] = createStore([] as mastodon.v1.Status[][]); + + createEffect(() => { + const nls = shot(); + 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 [snapshot, shot, { + refetch, + mutate: setSnapshot + }] as const; +} + +export function createTimeline(timeline: Accessor) { + // TODO +} From f16290e61042ff8f8d19e57fcf240b0c97e2fbab Mon Sep 17 00:00:00 2001 From: thislight Date: Thu, 10 Oct 2024 16:24:27 +0800 Subject: [PATCH 02/10] move TimelinePanel to separated files - added TrendTimelinePanel for trending tab --- src/timelines/Home.tsx | 206 +-------------------------- src/timelines/Thread.tsx | 94 ++++++++++++ src/timelines/TimelinePanel.tsx | 203 ++++++++++++++++++++++++++ src/timelines/TrendTimelinePanel.tsx | 189 ++++++++++++++++++++++++ 4 files changed, 492 insertions(+), 200 deletions(-) create mode 100644 src/timelines/Thread.tsx create mode 100644 src/timelines/TimelinePanel.tsx create mode 100644 src/timelines/TrendTimelinePanel.tsx diff --git a/src/timelines/Home.tsx b/src/timelines/Home.tsx index 8321872..49119da 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) => {
-
diff --git a/src/timelines/Thread.tsx b/src/timelines/Thread.tsx new file mode 100644 index 0000000..d7f4832 --- /dev/null +++ b/src/timelines/Thread.tsx @@ -0,0 +1,94 @@ +import type { mastodon } from "masto"; +import { + For, + Show, + createResource, + createSignal, + type Component, + type Ref, +} from "solid-js"; +import CompactToot from "./CompactToot"; +import { useTimeSource } from "../platform/timesrc"; +import RegularToot from "./RegularToot"; +import cardStyle from "../material/cards.module.css"; +import { css } from "solid-styled"; + +type TootActions = { + onBoost(client: mastodon.rest.Client, status: mastodon.v1.Status): void; + onBookmark(client: mastodon.rest.Client, status: mastodon.v1.Status): void; + onReply(client: mastodon.rest.Client, status: mastodon.v1.Status): void; +}; + +type ThreadProps = { + ref?: Ref; + client: mastodon.rest.Client; + toots: readonly mastodon.v1.Status[]; + isExpended: (status: mastodon.v1.Status) => boolean; + + onItemClick(status: mastodon.v1.Status, event: MouseEvent): void; +} & TootActions; + +const Thread: Component = (props) => { + const boost = (status: mastodon.v1.Status) => { + props.onBoost(props.client, status); + }; + + const bookmark = (status: mastodon.v1.Status) => { + props.onBookmark(props.client, status); + }; + + const reply = (status: mastodon.v1.Status) => { + props.onReply(props.client, status); + }; + + css` + article { + transition: + margin 90ms var(--tutu-anim-curve-sharp), + var(--tutu-transition-shadow); + user-select: none; + cursor: pointer; + } + + .thread-line { + position: relative; + &::before { + content: ""; + position: absolute; + left: 36px; + top: 16px; + bottom: 0; + background-color: var(--tutu-color-secondary); + width: 2px; + display: block; + } + } + `; + return ( +
1, + }} + > + + {(status, index) => ( + bookmark(s)} + onRetoot={(s) => boost(s)} + onReply={(s) => reply(s)} + onClick={[props.onItemClick, status]} + /> + )} + +
+ ); +}; + +export default Thread; diff --git a/src/timelines/TimelinePanel.tsx b/src/timelines/TimelinePanel.tsx new file mode 100644 index 0000000..04b8f46 --- /dev/null +++ b/src/timelines/TimelinePanel.tsx @@ -0,0 +1,203 @@ +import { + Component, + For, + onCleanup, + createSignal, + Show, + untrack, + Match, + Switch as JsSwitch, + ErrorBoundary, +} from "solid-js"; +import { type mastodon } from "masto"; +import { + Button, + LinearProgress, +} from "@suid/material"; +import TootThread from "./TootThread.js"; +import { useTimeline } from "../masto/timelines"; +import { vibrate } from "../platform/hardware"; +import PullDownToRefresh from "./PullDownToRefresh"; +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)}>
+ +
+ +
+
+
+ + + + + + + + +
+
+ ); +}; + +export default TimelinePanel \ No newline at end of file diff --git a/src/timelines/TrendTimelinePanel.tsx b/src/timelines/TrendTimelinePanel.tsx new file mode 100644 index 0000000..ddb97bc --- /dev/null +++ b/src/timelines/TrendTimelinePanel.tsx @@ -0,0 +1,189 @@ +import { + Component, + For, + onCleanup, + createSignal, + Show, + untrack, + Match, + Switch as JsSwitch, + ErrorBoundary, + createSelector, +} from "solid-js"; +import { type mastodon } from "masto"; +import { Button, LinearProgress } 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({ direction: "old" }); + }); + + 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)}>
+ +
+ +
+
+
+ + + + + + + + +
+
+ ); +}; + +export default TrendTimelinePanel; From cd38eeb89ad0a590ca3303dc5bb7bea7ef9b1784 Mon Sep 17 00:00:00 2001 From: thislight Date: Thu, 10 Oct 2024 20:32:54 +0800 Subject: [PATCH 03/10] TrendTimelinePanel: improved error handling --- src/UnexpectedError.tsx | 85 +++++++++++++++++----------- src/masto/timelines.ts | 18 +++--- src/timelines/TrendTimelinePanel.tsx | 19 ++----- 3 files changed, 69 insertions(+), 53 deletions(-) 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 4e307f7..e147cbc 100644 --- a/src/masto/timelines.ts +++ b/src/masto/timelines.ts @@ -1,5 +1,5 @@ import { type mastodon } from "masto"; -import { Accessor, createEffect, createResource } from "solid-js"; +import { Accessor, catchError, createEffect, createResource } from "solid-js"; import { createStore } from "solid-js/store"; type TimelineFetchTips = { @@ -114,7 +114,7 @@ export function createTimelineSnapshot( timeline: Accessor, limit: Accessor, ) { - const [shot, {refetch}] = createResource( + const [shot, { refetch }] = createResource( () => [timeline(), limit()] as const, async ([tl, limit]) => { const ls = await tl.list({ limit }).next(); @@ -125,7 +125,7 @@ export function createTimelineSnapshot( const [snapshot, setSnapshot] = createStore([] as mastodon.v1.Status[][]); createEffect(() => { - const nls = shot(); + 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 @@ -154,10 +154,14 @@ export function createTimelineSnapshot( } }); - return [snapshot, shot, { - refetch, - mutate: setSnapshot - }] as const; + return [ + snapshot, + shot, + { + refetch, + mutate: setSnapshot, + }, + ] as const; } export function createTimeline(timeline: Accessor) { diff --git a/src/timelines/TrendTimelinePanel.tsx b/src/timelines/TrendTimelinePanel.tsx index ddb97bc..a2370e8 100644 --- a/src/timelines/TrendTimelinePanel.tsx +++ b/src/timelines/TrendTimelinePanel.tsx @@ -3,7 +3,6 @@ import { For, onCleanup, createSignal, - Show, untrack, Match, Switch as JsSwitch, @@ -11,7 +10,7 @@ import { createSelector, } from "solid-js"; import { type mastodon } from "masto"; -import { Button, LinearProgress } from "@suid/material"; +import { Button } from "@suid/material"; import { createTimelineSnapshot } from "../masto/timelines.js"; import { vibrate } from "../platform/hardware.js"; import PullDownToRefresh from "./PullDownToRefresh.jsx"; @@ -40,7 +39,7 @@ const TrendTimelinePanel: Component<{ const tlEndObserver = new IntersectionObserver(() => { if (untrack(() => props.prefetch) && !snapshot.loading) - refetchTimeline({ direction: "old" }); + refetchTimeline(); }); onCleanup(() => tlEndObserver.disconnect()); @@ -143,26 +142,19 @@ const TrendTimelinePanel: Component<{
tlEndObserver.observe(e)}>
- -
- -
-
+

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

+
+
+ + + + - +