diff --git a/src/masto/timelines.ts b/src/masto/timelines.ts index 1483694..24c580c 100644 --- a/src/masto/timelines.ts +++ b/src/masto/timelines.ts @@ -20,52 +20,77 @@ type TimelineParamsOf = T extends Timeline ? P : never; export function createTimelineSnapshot< T extends Timeline, >(timeline: Accessor, limit: Accessor) { + const lookup = new ReactiveMap>(); const [shot, { refetch }] = createResource( () => [timeline(), limit()] as const, async ([tl, limit]) => { const ls = await tl.list({ limit }).next(); - return ls.value?.map((x) => [x]) ?? []; + return ls.value; }, ); - const [snapshot, setSnapshot] = createStore([] as mastodon.v1.Status[][]); + const [threads, setThreads] = createStore([] as mastodon.v1.Status["id"][]); createEffect(() => { 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]); - } - } + + setThreads([]); + lookup.clear(); + + const existence = [] as boolean[]; + + for (const [idx, status] of nls.entries()) { + existence[idx] = !!untrack(() => lookup.get(status.id)); + lookup.set(status.id, { + value: status, + }); + } + + for (const status of nls) { + 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 newThreads = nls + .filter((x, i) => !existence[i]) + .map((x) => x.id) + .filter((id) => (lookup.get(id)!.children?.length ?? 0) === 0); + + setThreads(newThreads); }); return [ - snapshot, + { + 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); + }, + }, shot, { refetch, - mutate: setSnapshot, }, ] as const; } @@ -100,10 +125,9 @@ function collectPath(node: TreeNode) { return path; } -function createTimelineChunk>( - timeline: Accessor, - params: Accessor>, -) { +function createTimelineChunk< + T extends Timeline, +>(timeline: Accessor, params: Accessor>) { let vpMaxId: string | undefined, vpMinId: string | undefined; const fetchExtendingPage = async ( diff --git a/src/material/BottomSheet.tsx b/src/material/BottomSheet.tsx index 1fab238..4e00868 100644 --- a/src/material/BottomSheet.tsx +++ b/src/material/BottomSheet.tsx @@ -96,6 +96,9 @@ const BottomSheet: ParentComponent = (props) => { element.showModal(); const srcElement = hero(); const startRect = srcElement?.getBoundingClientRect(); + if (!startRect) { + console.debug("no source element") + } if (startRect) { srcElement!.style.visibility = "hidden"; const endRect = element.getBoundingClientRect(); diff --git a/src/platform/anim.ts b/src/platform/anim.ts index 3108448..87549d0 100644 --- a/src/platform/anim.ts +++ b/src/platform/anim.ts @@ -18,12 +18,15 @@ const HeroSourceContext = createContext>( export const HeroSourceProvider = HeroSourceContext.Provider; -function useHeroSource() { +export function useHeroSource() { return useContext(HeroSourceContext); } /** * Use hero value for the {@link key}. + * + * Note: the setter here won't set the value of the hero source. + * To set hero source, use {@link useHeroSource} and set the corresponding key. */ export function useHeroSignal( key: string | symbol | number, @@ -46,6 +49,7 @@ export function useHeroSignal( return [get, set]; } else { + console.debug("no hero source") return [() => undefined, () => undefined]; } } diff --git a/src/timelines/Home.tsx b/src/timelines/Home.tsx index 268b846..b463d96 100644 --- a/src/timelines/Home.tsx +++ b/src/timelines/Home.tsx @@ -178,7 +178,7 @@ const Home: ParentComponent = (props) => { display: grid; grid-auto-columns: 560px; grid-auto-flow: column; - overflow-x: auto; + overflow: auto hidden; scroll-snap-type: x mandatory; scroll-snap-stop: always; height: calc(100vh - var(--scaffold-topbar-height, 0px)); @@ -232,48 +232,48 @@ const Home: ParentComponent = (props) => { } > - - -
-
-
- + + + +
+
+
+ +
-
-
-
- +
+
+ +
-
-
-
- +
+
+ +
+
-
-
- - - - + + + navigate(-1)}> {child()} - - + + ); diff --git a/src/timelines/PullDownToRefresh.tsx b/src/timelines/PullDownToRefresh.tsx index 1c80829..10df2bf 100644 --- a/src/timelines/PullDownToRefresh.tsx +++ b/src/timelines/PullDownToRefresh.tsx @@ -1,24 +1,15 @@ import { createEffect, - createRenderEffect, createSignal, - onCleanup, Show, untrack, type Component, - type Signal, } from "solid-js"; import { css } from "solid-styled"; import { Refresh as RefreshIcon } from "@suid/icons-material"; import { CircularProgress } from "@suid/material"; -import { - createEventListener, - makeEventListener, -} from "@solid-primitives/event-listener"; -import { - createViewportObserver, - createVisibilityObserver, -} from "@solid-primitives/intersection-observer"; +import { makeEventListener } from "@solid-primitives/event-listener"; +import { createVisibilityObserver } from "@solid-primitives/intersection-observer"; const PullDownToRefresh: Component<{ loading?: boolean; @@ -114,14 +105,13 @@ const PullDownToRefresh: Component<{ } }; - createEffect((cleanup?: () => void) => { + createEffect(() => { if (!rootVisible()) { return; } - cleanup?.(); const element = props.linkedElement; if (!element) return; - return makeEventListener(element, "wheel", handleLinkedWheel); + makeEventListener(element, "wheel", handleLinkedWheel); }); let lastTouchId: number | undefined = undefined; @@ -165,16 +155,14 @@ const PullDownToRefresh: Component<{ } }; - createEffect((cleanup?: () => void) => { + createEffect(() => { if (!rootVisible()) { return; } - cleanup?.(); const element = props.linkedElement; if (!element) return; - const cleanup0 = makeEventListener(element, "touchmove", handleTouch); - const cleanup1 = makeEventListener(element, "touchend", handleTouchEnd); - return () => (cleanup0(), cleanup1()); + makeEventListener(element, "touchmove", handleTouch); + makeEventListener(element, "touchend", handleTouchEnd); }); css` diff --git a/src/timelines/TimelinePanel.tsx b/src/timelines/TimelinePanel.tsx index 332d693..b733da2 100644 --- a/src/timelines/TimelinePanel.tsx +++ b/src/timelines/TimelinePanel.tsx @@ -12,10 +12,10 @@ import { import { type mastodon } from "masto"; import { Button, LinearProgress } from "@suid/material"; import { createTimeline } from "../masto/timelines"; -import { vibrate } from "../platform/hardware"; + import PullDownToRefresh from "./PullDownToRefresh"; import TootComposer from "./TootComposer"; -import Thread from "./Thread.jsx"; +import TootList from "./TootList"; const TimelinePanel: Component<{ client: mastodon.rest.Client; @@ -32,9 +32,8 @@ const TimelinePanel: Component<{ const [timeline, snapshot, { refetch: refetchTimeline }] = createTimeline( () => props.client.v1.timelines[props.name], - () => ({limit: 20}), + () => ({ limit: 20 }), ); - const [expandedThreadId, setExpandedThreadId] = createSignal(); const [typing, setTyping] = createSignal(false); const tlEndObserver = new IntersectionObserver(() => { @@ -44,43 +43,6 @@ const TimelinePanel: Component<{ onCleanup(() => tlEndObserver.disconnect()); - const onBookmark = async ( - 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()); - timeline.set(result.id, result); - }; - - const onBoost = async ( - client: mastodon.rest.Client, - status: mastodon.v1.Status, - ) => { - vibrate(50); - const rootStatus = status.reblog ? status.reblog : status; - const reblogged = rootStatus.reblogged; - if (status.reblog) { - status.reblog = { ...status.reblog, reblogged: !reblogged }; - timeline.set(status.id, status); - } else { - timeline.set( - status.id, - Object.assign(status, { - reblogged: !reblogged, - }), - ); - } - const result = reblogged - ? await client.v1.statuses.$select(status.id).unreblog() - : (await client.v1.statuses.$select(status.id).reblog()).reblog!; - timeline.set( - status.id, - Object.assign(status.reblog ?? status, result.reblog), - ); - }; - return ( { @@ -110,36 +72,12 @@ const TimelinePanel: Component<{ onSent={() => refetchTimeline("prev")} /> - - {(itemId, index) => { - const path = timeline.getPath(itemId)!; - const toots = path.reverse().map((x) => x.value); - return ( - - props.openFullScreenToot(status, element, true) - } - client={props.client} - isExpended={(status) => status.id === expandedThreadId()} - onItemClick={(status, event) => { - setTyping(false); - if (status.id !== expandedThreadId()) { - setExpandedThreadId((x) => (x ? undefined : status.id)); - } else { - props.openFullScreenToot( - status, - event.currentTarget as HTMLElement, - ); - } - }} - /> - ); - }} - +
tlEndObserver.observe(e)}>
diff --git a/src/timelines/TootList.tsx b/src/timelines/TootList.tsx index f0c510f..2cbc6a4 100644 --- a/src/timelines/TootList.tsx +++ b/src/timelines/TootList.tsx @@ -1,24 +1,16 @@ import { Component, For, - onCleanup, createSignal, - Show, - untrack, - Match, - Switch as JsSwitch, ErrorBoundary, type Ref, + createSelector, } from "solid-js"; import { type mastodon } from "masto"; -import { Button, LinearProgress } from "@suid/material"; -import { createTimeline } from "../masto/timelines"; import { vibrate } from "../platform/hardware"; -import PullDownToRefresh from "./PullDownToRefresh"; -import TootComposer from "./TootComposer"; import Thread from "./Thread.jsx"; import { useDefaultSession } from "../masto/clients"; -import { useHeroSignal } from "../platform/anim"; +import { useHeroSignal, useHeroSource } from "../platform/anim"; import { HERO as BOTTOM_SHEET_HERO } from "../material/BottomSheet"; import { setCache as setTootBottomSheetCache } from "./TootBottomSheet"; import { useNavigate } from "@solidjs/router"; @@ -30,7 +22,7 @@ const TootList: Component<{ onChangeToot: (id: string, value: mastodon.v1.Status) => void; }> = (props) => { const session = useDefaultSession(); - const [, setHeroSrc] = useHeroSignal(BOTTOM_SHEET_HERO); + const heroSrc = useHeroSource(); const [expandedThreadId, setExpandedThreadId] = createSignal(); const navigate = useNavigate(); @@ -83,7 +75,10 @@ const TootList: Component<{ console.warn("no account info?"); return; } - setHeroSrc(srcElement); + if (heroSrc) { + heroSrc[1]((x) => ({ ...x, [BOTTOM_SHEET_HERO]: srcElement })); + } + const acct = `${inf.username}@${p.site}`; setTootBottomSheetCache(acct, toot); navigate(`/${encodeURIComponent(acct)}/toot/${toot.id}`, { @@ -95,6 +90,26 @@ const TootList: Component<{ }); }; + const onItemClick = (status: mastodon.v1.Status, event: MouseEvent) => { + if (status.id !== expandedThreadId()) { + setExpandedThreadId((x) => (x ? undefined : status.id)); + } else { + openFullScreenToot(status, event.currentTarget as HTMLElement); + } + }; + + const checkIsExpendedId = createSelector(expandedThreadId); + + const checkIsExpended = (status: mastodon.v1.Status) => + checkIsExpendedId(status.id); + + const onReply = ( + { status }: { status: mastodon.v1.Status }, + element: HTMLElement, + ) => { + openFullScreenToot(status, element, true); + }; + return ( { @@ -112,21 +127,10 @@ const TootList: Component<{ toots={toots} onBoost={onBoost} onBookmark={onBookmark} - onReply={({ status }, element) => - openFullScreenToot(status, element, true) - } + onReply={onReply} client={session()?.client!} - isExpended={(status) => status.id === expandedThreadId()} - onItemClick={(status, event) => { - if (status.id !== expandedThreadId()) { - setExpandedThreadId((x) => (x ? undefined : status.id)); - } else { - openFullScreenToot( - status, - event.currentTarget as HTMLElement, - ); - } - }} + isExpended={checkIsExpended} + onItemClick={onItemClick} /> ); }} diff --git a/src/timelines/TrendTimelinePanel.tsx b/src/timelines/TrendTimelinePanel.tsx index ef4c360..8391e2d 100644 --- a/src/timelines/TrendTimelinePanel.tsx +++ b/src/timelines/TrendTimelinePanel.tsx @@ -13,6 +13,7 @@ import { createTimelineSnapshot } from "../masto/timelines.js"; import { vibrate } from "../platform/hardware.js"; import PullDownToRefresh from "./PullDownToRefresh.jsx"; import Thread from "./Thread.jsx"; +import TootList from "./TootList.jsx"; const TrendTimelinePanel: Component<{ client: mastodon.rest.Client; @@ -24,67 +25,11 @@ const TrendTimelinePanel: Component<{ ) => 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 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; - }); - }; + const [timeline, snapshot, { refetch: refetchTimeline }] = + createTimelineSnapshot( + () => props.client.v1.trends.statuses, + () => 120, + ); return ( - - {(item, index) => { - let element: HTMLElement | undefined; - return ( - onBoost(index(), ...args)} - onBookmark={(...args) => onBookmark(index(), ...args)} - onReply={({ status }, element) => - 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); - } - }} - /> - ); - }} - +