Compare commits
3 commits
9486ac34ea
...
1e6d4933b9
Author | SHA1 | Date | |
---|---|---|---|
|
1e6d4933b9 | ||
|
7b67c79bc8 | ||
|
fc8d489977 |
6 changed files with 282 additions and 32 deletions
BIN
bun.lockb
BIN
bun.lockb
Binary file not shown.
|
@ -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",
|
||||
|
|
|
@ -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<Timeline>) {
|
|||
let maxId: string | undefined;
|
||||
let otl: Timeline | undefined;
|
||||
const idSet = new Set<string>();
|
||||
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<Timeline>) {
|
|||
minId = undefined;
|
||||
maxId = undefined;
|
||||
idSet.clear();
|
||||
info.value = [];
|
||||
otl = tl;
|
||||
}
|
||||
const direction =
|
||||
|
@ -44,7 +44,6 @@ export function useTimeline(timeline: Accessor<Timeline>) {
|
|||
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<Timeline>) {
|
|||
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;
|
||||
}
|
||||
|
|
6
src/platform/hardware.ts
Normal file
6
src/platform/hardware.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export function vibrate(pattern: number | number[]) {
|
||||
if (typeof navigator.vibrate !== "undefined") {
|
||||
return navigator.vibrate(pattern);
|
||||
}
|
||||
return false;
|
||||
}
|
|
@ -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<HTMLElement>();
|
||||
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 (
|
||||
<>
|
||||
<div>
|
||||
<For each={timeline()}>
|
||||
<PullDownToRefresh
|
||||
linkedElement={scrollLinked()}
|
||||
loading={snapshot.loading}
|
||||
onRefresh={() => refetchTimeline({ direction: "new" })}
|
||||
/>
|
||||
<div
|
||||
ref={(e) =>
|
||||
setTimeout(() => {
|
||||
setScrollLinked(e.parentElement!);
|
||||
}, 0)
|
||||
}
|
||||
>
|
||||
<For each={timeline}>
|
||||
{(item, index) => {
|
||||
return (
|
||||
<TootThread
|
||||
|
@ -113,12 +137,12 @@ const TimelinePanel: Component<{
|
|||
</div>
|
||||
|
||||
<div ref={(e) => tlEndObserver.observe(e)}></div>
|
||||
<Show when={timeline.loading}>
|
||||
<Show when={snapshot.loading}>
|
||||
<div class="loading-line" style={{ width: "100%" }}>
|
||||
<LinearProgress />
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={timeline.error}>
|
||||
<Show when={snapshot.error}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
|
@ -132,7 +156,7 @@ const TimelinePanel: Component<{
|
|||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!props.prefetch && !timeline.loading}>
|
||||
<Show when={!props.prefetch && !snapshot.loading}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
|
@ -238,8 +262,9 @@ const Home: ParentComponent = (props) => {
|
|||
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;
|
||||
|
|
200
src/timelines/PullDownToRefresh.tsx
Normal file
200
src/timelines/PullDownToRefresh.tsx
Normal file
|
@ -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 (
|
||||
<div ref={rootElement!} class="pull-down">
|
||||
<span class="indicator">
|
||||
<Show
|
||||
when={props.loading}
|
||||
fallback={<RefreshIcon class="refresh-icon" />}
|
||||
>
|
||||
<CircularProgress class="refresh-indicator" />
|
||||
</Show>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PullDownToRefresh;
|
Loading…
Reference in a new issue