Compare commits

...

3 commits

Author SHA1 Message Date
thislight
1e6d4933b9
PullDownToRefresh: adjust multiply rate for wheel
All checks were successful
/ depoly (push) Successful in 1m8s
2024-08-12 11:05:20 +08:00
thislight
7b67c79bc8
PullDownToRefresh: init prototype 2024-08-07 17:04:54 +08:00
thislight
fc8d489977
useTimeline: use store to provide partial editing 2024-08-06 20:27:30 +08:00
6 changed files with 282 additions and 32 deletions

BIN
bun.lockb

Binary file not shown.

View file

@ -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",

View file

@ -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
View file

@ -0,0 +1,6 @@
export function vibrate(pattern: number | number[]) {
if (typeof navigator.vibrate !== "undefined") {
return navigator.vibrate(pattern);
}
return false;
}

View file

@ -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;

View 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;