diff --git a/bun.lockb b/bun.lockb index 16c0c62..be11679 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 953d8c5..54e32ca 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@nanostores/persistent": "^0.9.1", "@nanostores/solid": "^0.4.2", "@solid-primitives/event-listener": "^2.3.3", + "@solid-primitives/intersection-observer": "^2.1.6", "@solid-primitives/resize-observer": "^2.0.25", "@solidjs/router": "^0.11.5", "@suid/icons-material": "^0.7.0", diff --git a/src/masto/timelines.ts b/src/masto/timelines.ts index 91e1d84..55d1276 100644 --- a/src/masto/timelines.ts +++ b/src/masto/timelines.ts @@ -1,5 +1,6 @@ import { type mastodon } from "masto"; -import { Accessor, createResource, createSignal } from "solid-js"; +import { Accessor, createEffect, createResource, createSignal } from "solid-js"; +import { createStore } from "solid-js/store"; type TimelineFetchTips = { direction?: "new" | "old"; @@ -17,8 +18,8 @@ export function useTimeline(timeline: Accessor) { let maxId: string | undefined; let otl: Timeline | undefined; const idSet = new Set(); - return createResource< - mastodon.v1.Status[], + const [snapshot, { refetch }] = createResource< + { records: mastodon.v1.Status[]; direction: "old" | "new" }, [Timeline], TimelineFetchTips | undefined >( @@ -28,7 +29,6 @@ export function useTimeline(timeline: Accessor) { minId = undefined; maxId = undefined; idSet.clear(); - info.value = []; otl = tl; } const direction = @@ -44,7 +44,6 @@ export function useTimeline(timeline: Accessor) { minId: maxId, }, ); - const old = info.value || []; const diff = pager.filter((x) => !idSet.has(x.id)); for (const v of diff.map((x) => x.id)) { idSet.add(v); @@ -54,20 +53,39 @@ export function useTimeline(timeline: Accessor) { if (!maxId && pager.length > 0) { maxId = pager[0].id; } - return [...old, ...diff]; + return { + direction: "old" as const, + records: diff, + }; } else { maxId = pager.length > 0 ? pager[0].id : undefined; if (!minId && pager.length > 0) { minId = pager[pager.length - 1]?.id; } - return [...diff, ...old]; + return { direction: "new" as const, records: diff }; } }, - { - initialValue: [], - storage(init) { - return createSignal(init, { equals: false }); - }, - }, ); + + const [store, setStore] = createStore([] as mastodon.v1.Status[]); + + createEffect(() => { + const shot = snapshot(); + if (!shot) return; + const { direction, records } = shot; + if (direction == "new") { + setStore((x) => [...records, ...x]); + } else if (direction == "old") { + setStore((x) => [...x, ...records]); + } + }); + + return [ + store, + snapshot, + { + refetch, + mutate: setStore, + }, + ] as const; } diff --git a/src/platform/hardware.ts b/src/platform/hardware.ts new file mode 100644 index 0000000..2d1d0d6 --- /dev/null +++ b/src/platform/hardware.ts @@ -0,0 +1,6 @@ +export function vibrate(pattern: number | number[]) { + if (typeof navigator.vibrate !== "undefined") { + return navigator.vibrate(pattern); + } + return false; +} diff --git a/src/timelines/Home.tsx b/src/timelines/Home.tsx index 44682e1..2330354 100644 --- a/src/timelines/Home.tsx +++ b/src/timelines/Home.tsx @@ -37,21 +37,27 @@ import { makeEventListener } from "@solid-primitives/event-listener"; import BottomSheet from "../material/BottomSheet"; import { $settings } from "../settings/stores"; import { useStore } from "@nanostores/solid"; +import { vibrate } from "../platform/hardware"; +import PullDownToRefresh from "./PullDownToRefresh"; const TimelinePanel: Component<{ client: mastodon.rest.Client; name: "home" | "public" | "trends"; prefetch?: boolean; }> = (props) => { - const [timeline, { refetch: refetchTimeline, mutate: mutateTimeline }] = - useTimeline(() => - props.name !== "trends" - ? props.client.v1.timelines[props.name] - : props.client.v1.trends.statuses, - ); + 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, + ); const tlEndObserver = new IntersectionObserver(() => { - if (untrack(() => props.prefetch) && !timeline.loading) + if (untrack(() => props.prefetch) && !snapshot.loading) refetchTimeline({ direction: "old" }); }); @@ -76,12 +82,19 @@ const TimelinePanel: Component<{ client: mastodon.rest.Client, status: mastodon.v1.Status, ) => { - const reblogged = false; - mutateTimeline((o) => { - Object.assign(o[index].reblog ?? o[index], { - reblogged: !reblogged, - }); - return o; + 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() @@ -97,8 +110,19 @@ const TimelinePanel: Component<{ return ( <> -
- + refetchTimeline({ direction: "new" })} + /> +
+ setTimeout(() => { + setScrollLinked(e.parentElement!); + }, 0) + } + > + {(item, index) => { return (
tlEndObserver.observe(e)}>
- +
- +
- +
{ overflow: visible auto; max-width: 560px; height: 100%; - padding: 40px 16px; + padding: 0 16px; scroll-snap-align: center; + overscroll-behavior-block: none; @media (max-width: 600px) { padding: 0; diff --git a/src/timelines/PullDownToRefresh.tsx b/src/timelines/PullDownToRefresh.tsx new file mode 100644 index 0000000..3aa7195 --- /dev/null +++ b/src/timelines/PullDownToRefresh.tsx @@ -0,0 +1,200 @@ +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"; + +const PullDownToRefresh: Component<{ + loading?: boolean; + linkedElement?: HTMLElement; + onRefresh?: () => void; +}> = (props) => { + let rootElement: HTMLDivElement; + const [pullDown, setPullDown] = createSignal(0); + + const pullDownDistance = () => { + if (props.loading) { + return 140; + } + return Math.max(Math.min(160, pullDown()), 0); + }; + + const obvx = createVisibilityObserver({ + threshold: 0.0001, + }); + + const rootVisible = obvx(() => rootElement); + + let released = true; + let v = 0; + let lts = -1; + let ds = 0; + let holding = false; + const M = 1; + const K = -10; + const D = -10; + const updatePullDown = (ts: number) => { + released = false; + try { + const x = untrack(pullDown); + const dt = lts !== -1 ? ts - lts : 1 / 60; + const fs = (ds / Math.pow(dt, 2)) * M; + const fh = (x + ds) * K + D * v; + const f = fs + fh; + const a = f / M; + v += a * dt; + if (holding && v < 0) { + v = 0 + } + setPullDown(x + v * dt); + if (Math.abs(x) > 1 || Math.abs(v) > 1) { + requestAnimationFrame(updatePullDown); + } else { + v = 0; + lts = -1; + } + if (!holding && untrack(pullDownDistance) >= 160 && !props.loading && props.onRefresh) { + setTimeout(props.onRefresh, 0) + } + } finally { + ds = 0; + released = true; + } + }; + const handleLinkedWheel = (event: WheelEvent) => { + const scrollTop = (event.target as HTMLElement).scrollTop; + if (scrollTop >= 0 && scrollTop < 1) { + ds = -(event.deltaY / window.devicePixelRatio / 4); + if (released) { + released = false; + requestAnimationFrame(updatePullDown); + } + } + }; + + createEffect((cleanup?: () => void) => { + if (!rootVisible()) { + return; + } + cleanup?.(); + const element = props.linkedElement; + if (!element) return; + return makeEventListener(element, "wheel", handleLinkedWheel); + }); + + let lastTouchId: number | undefined = undefined; + let lastTouchScreenY = 0; + const handleTouch = (event: TouchEvent) => { + if (event.targetTouches.length > 1) { + lastTouchId = 0; + lastTouchScreenY; + return; + } + const item = event.targetTouches.item(0)!; + if (lastTouchId && item.identifier !== lastTouchId) { + lastTouchId = undefined; + lastTouchScreenY = 0; + return; + } + holding = true; + if (lastTouchScreenY !== 0) { + ds = item.screenY - lastTouchScreenY; + } + lastTouchScreenY = item.screenY; + if (released) { + released = false; + requestAnimationFrame(updatePullDown); + } + }; + + const handleTouchEnd = () => { + lastTouchId = undefined; + lastTouchScreenY = 0; + holding = false; + if (untrack(pullDownDistance) >= 160 && !props.loading && props.onRefresh) { + setTimeout(props.onRefresh, 0) + } + }; + + createEffect((cleanup?: () => void) => { + 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()); + }); + + css` + .pull-down { + width: 100%; + display: flex; + justify-content: center; + margin-top: -2rem; + height: calc(1px + 2rem); + } + + .indicator { + display: inline-flex; + justify-content: center; + align-items: center; + box-shadow: ${props.loading + ? "var(--tutu-shadow-e12)" + : "var(--tutu-shadow-e1)"}; + border-radius: 50%; + aspect-ratio: 1/1; + width: 2rem; + color: var(--tutu-color-primary); + transform: translateY(${`${pullDownDistance() - 2}px`}); + will-change: transform; + z-index: var(--tutu-zidx-nav); + background-color: var(--tutu-color-surface); + + > :global(.refresh-icon) { + transform: rotate( + ${`${((pullDownDistance() / 160) * 180).toString()}deg`} + ); + will-change: transform; + } + + > :global(.refresh-indicator) { + width: 1.5rem; + height: 1.5rem; + aspect-ratio: 1/1; + } + } + `; + return ( +
+ + } + > + + + +
+ ); +}; + +export default PullDownToRefresh;