Compare commits

..

No commits in common. "21afb718f7aa429e16cb61aa2107c1035143aa02" and "e9551d6915c047931183ba90aae3fc967e9e9669" have entirely different histories.

12 changed files with 355 additions and 852 deletions

BIN
bun.lockb

Binary file not shown.

View file

@ -33,7 +33,6 @@
"@solid-primitives/event-listener": "^2.3.3", "@solid-primitives/event-listener": "^2.3.3",
"@solid-primitives/i18n": "^2.1.1", "@solid-primitives/i18n": "^2.1.1",
"@solid-primitives/intersection-observer": "^2.1.6", "@solid-primitives/intersection-observer": "^2.1.6",
"@solid-primitives/map": "^0.4.13",
"@solid-primitives/resize-observer": "^2.0.26", "@solid-primitives/resize-observer": "^2.0.26",
"@solidjs/router": "^0.14.5", "@solidjs/router": "^0.14.5",
"@suid/icons-material": "^0.8.0", "@suid/icons-material": "^0.8.0",

View file

@ -1,59 +1,40 @@
import { Button } from "@suid/material"; import { Button } from '@suid/material';
import { Component, createResource } from "solid-js"; import {Component, createResource} from 'solid-js'
import { css } from "solid-styled"; 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 ?? "<unknown>"}@${entry.fileName}:(${entry.lineNumber}:${entry.columnNumber})`,
)
.join("\n");
return `${err.name}: ${err.message}\n${strackMsg}`;
} catch (reason) {
return `<failed to build the stacktrace of "${err}"...>\n${reason}`;
}
}
return err.toString(); 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 ?? "<unknown>"}@${entry.fileName}:(${entry.lineNumber}:${entry.columnNumber})`).join('\n')
return `${err.name}: ${err.message}\n${strackMsg}`
}
return err.toString()
})
css` css`
main { main {
padding: calc(var(--safe-area-inset-top) + 20px) 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);
calc(var(--safe-area-inset-right) + 20px) }
calc(var(--safe-area-inset-bottom) + 20px) `
calc(var(--safe-area-inset-left) + 20px);
}
`;
return ( return <main>
<main> <h1>Oh, it is our fault.</h1>
<h1>Oh, it is our fault.</h1> <p>There is an unexpected error in our app, and it's not your fault.</p>
<p>There is an unexpected error in our app, and it's not your fault.</p> <p>You can reload to see if this guy is gone. If you meet this guy repeatly, please report to us.</p>
<p> <div>
You can reload to see if this guy is gone. If you meet this guy <Button onClick={() => window.location.reload()}>Reload</Button>
repeatly, please report to us. </div>
</p> <details>
<div> <summary>{errorMsg.loading ? 'Generating ' : " "}Technical Infomation (Bring to us if you report the problem)</summary>
<Button onClick={() => window.location.reload()}>Reload</Button> <pre>
</div> {errorMsg()}
<details> </pre>
<summary> </details>
{errorMsg.loading ? "Generating " : " "}Technical Infomation </main>
</summary> }
<pre>{errorMsg()}</pre>
</details>
</main>
);
};
export default UnexpectedError; export default UnexpectedError;

View file

@ -1,258 +1,111 @@
import { ReactiveMap } from "@solid-primitives/map";
import { type mastodon } from "masto"; import { type mastodon } from "masto";
import { import { Accessor, createEffect, createResource } from "solid-js";
Accessor,
batch,
catchError,
createEffect,
createResource,
untrack,
type ResourceFetcherInfo,
} from "solid-js";
import { createStore } from "solid-js/store"; import { createStore } from "solid-js/store";
type TimelineFetchTips = {
direction?: "new" | "old";
};
type Timeline = { type Timeline = {
list(params: { list(params: {
/** Return results older than this ID. */
readonly maxId?: string;
/** Return results newer than this ID. */
readonly sinceId?: string;
/** Get a list of items with ID greater than this value excluding this ID */
readonly minId?: string;
/** Maximum number of results to return per page. Defaults to 40. NOTE: Pagination is done with the Link header from the response. */
readonly limit?: number; readonly limit?: number;
}): mastodon.Paginator<mastodon.v1.Status[], unknown>; }): mastodon.Paginator<mastodon.v1.Status[], unknown>;
}; };
export function createTimelineSnapshot( export function useTimeline(
timeline: Accessor<Timeline>, timeline: Accessor<Timeline>,
limit: Accessor<number>, cfg?: {
/**
* Use full refresh mode. This mode ignores paging, it will refetch the specified number
* of toots at every refetch().
*/
fullRefresh?: number;
},
) { ) {
const [shot, { refetch }] = createResource( let otl: Timeline | undefined;
() => [timeline(), limit()] as const, let npager: mastodon.Paginator<mastodon.v1.Status[], unknown> | undefined;
async ([tl, limit]) => { let opager: mastodon.Paginator<mastodon.v1.Status[], unknown> | undefined;
const ls = await tl.list({ limit }).next(); const [snapshot, { refetch }] = createResource<
return ls.value?.map((x) => [x]) ?? []; {
records: mastodon.v1.Status[];
direction: "new" | "old" | "items";
tlChanged: boolean;
},
[Timeline],
TimelineFetchTips | undefined
>(
() => [timeline()] as const,
async ([tl], info) => {
let tlChanged = false;
if (otl !== tl) {
npager = opager = undefined;
otl = tl;
tlChanged = true;
}
const fullRefresh = cfg?.fullRefresh;
if (typeof fullRefresh !== "undefined") {
const records = await tl
.list({
limit: fullRefresh,
})
.next();
return {
direction: "items",
records: records.value ?? [],
end: records.done,
tlChanged,
};
}
const direction =
typeof info.refetching !== "boolean"
? (info.refetching?.direction ?? "old")
: "old";
if (direction === "old") {
if (!opager) {
opager = tl.list({}).setDirection("next");
}
const next = await opager.next();
return {
direction,
records: next.value ?? [],
end: next.done,
tlChanged,
};
} else {
if (!npager) {
npager = tl.list({}).setDirection("prev");
}
const next = await npager.next();
const page = next.value ?? [];
return { direction, records: page, end: next.done, tlChanged };
}
}, },
); );
const [snapshot, setSnapshot] = createStore([] as mastodon.v1.Status[][]); const [store, setStore] = createStore([] as mastodon.v1.Status[]);
createEffect(() => { createEffect(() => {
const nls = catchError(shot, (e) => console.error(e)); const shot = snapshot();
if (!nls) return; if (!shot) return;
const ols = Array.from(snapshot); const { direction, records, tlChanged } = shot;
// The algorithm below assumes the snapshot is not changing if (tlChanged) {
for (let i = 0; i < nls.length; i++) { setStore(() => []);
if (i >= ols.length) { }
setSnapshot(i, nls[i]); if (direction === "new") {
} else { setStore((x) => [...records, ...x]);
if (nls[i].length !== ols[i].length) { } else if (direction === "old") {
setSnapshot(i, nls[i]); setStore((x) => [...x, ...records]);
} else { } else if (direction === "items") {
const oth = ols[i], setStore(() => records);
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 [ return [
store,
snapshot, snapshot,
shot,
{ {
refetch, refetch,
mutate: setSnapshot, mutate: setStore,
}, },
] as const; ] as const;
} }
export type TimelineFetchDirection = mastodon.Direction;
export type TimelineChunk = {
tl: Timeline;
rebuilt: boolean;
chunk: readonly mastodon.v1.Status[];
done?: boolean;
direction: TimelineFetchDirection;
limit: number;
};
type TreeNode<T> = {
parent?: TreeNode<T>;
value: T;
children?: TreeNode<T>[];
};
/** Collect the path of a node for the root.
* The first element is the node itself, the last element is the root.
*/
function collectPath<T>(node: TreeNode<T>) {
const path = [node] as TreeNode<T>[];
let current = node;
while (current.parent) {
path.push(current.parent);
current = current.parent;
}
return path;
}
function createTimelineChunk(
timeline: Accessor<Timeline>,
limit: Accessor<number>,
) {
let vpMaxId: string | undefined, vpMinId: string | undefined;
const fetchExtendingPage = async (
tl: Timeline,
direction: TimelineFetchDirection,
limit: number,
) => {
switch (direction) {
case "next": {
const page = await tl
.list({ limit, sinceId: vpMaxId })
.setDirection(direction)
.next();
if ((page.value?.length ?? 0) > 0) {
vpMaxId = page.value![0].id;
}
return page;
}
case "prev": {
const page = await tl
.list({ limit, maxId: vpMinId })
.setDirection(direction)
.next();
if ((page.value?.length ?? 0) > 0) {
vpMinId = page.value![page.value!.length - 1].id;
}
return page;
}
}
};
return createResource(
() => [timeline(), limit()] as const,
async (
[tl, limit],
info: ResourceFetcherInfo<
Readonly<TimelineChunk>,
TimelineFetchDirection
>,
) => {
const direction =
typeof info.refetching === "boolean" ? "prev" : info.refetching;
const rebuildTimeline = tl !== info.value?.tl;
if (rebuildTimeline) {
vpMaxId = undefined;
vpMinId = undefined;
}
const posts = await fetchExtendingPage(tl, direction, limit);
return {
tl,
rebuilt: rebuildTimeline,
chunk: posts.value ?? [],
done: posts.done,
direction,
limit,
};
},
);
}
export function createTimeline(
timeline: Accessor<Timeline>,
limit: Accessor<number>,
) {
const lookup = new ReactiveMap<string, TreeNode<mastodon.v1.Status>>();
const [threads, setThreads] = createStore([] as mastodon.v1.Status["id"][]);
const [chunk, { refetch }] = createTimelineChunk(timeline, limit);
createEffect(() => {
const chk = catchError(chunk, (e) => console.error(e));
if (!chk) {
return;
}
if (chk.rebuilt) {
lookup.clear();
setThreads([]);
}
const existence = [] as boolean[];
for (const [idx, status] of chk.chunk.entries()) {
existence[idx] = !!untrack(() => lookup.get(status.id));
lookup.set(status.id, {
value: status,
});
}
for (const status of chk.chunk) {
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 nThreadIds = chk.chunk
.filter((x, i) => !existence[i])
.map((x) => x.id);
batch(() => {
if (chk.direction === "prev") {
setThreads((threads) => [...threads, ...nThreadIds]);
} else if (chk.direction === "next") {
setThreads((threads) => [...nThreadIds, ...threads]);
}
setThreads((threads) =>
threads.filter((id) => (lookup.get(id)!.children?.length ?? 0) === 0),
);
});
});
return [
{
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);
},
},
chunk,
{ refetch },
] as const;
}

View file

@ -1,16 +1,26 @@
import { import {
Component,
For,
onCleanup,
createSignal, createSignal,
Show, Show,
untrack,
onMount, onMount,
type ParentComponent, type ParentComponent,
children, children,
Suspense, Suspense,
Match,
Switch as JsSwitch,
ErrorBoundary,
} from "solid-js"; } from "solid-js";
import { useDocumentTitle } from "../utils"; import { useDocumentTitle } from "../utils";
import { type mastodon } from "masto"; import { type mastodon } from "masto";
import Scaffold from "../material/Scaffold"; import Scaffold from "../material/Scaffold";
import { import {
AppBar, AppBar,
Button,
Fab,
LinearProgress,
ListItemSecondaryAction, ListItemSecondaryAction,
ListItemText, ListItemText,
MenuItem, MenuItem,
@ -19,21 +29,205 @@ import {
} from "@suid/material"; } from "@suid/material";
import { css } from "solid-styled"; import { css } from "solid-styled";
import { TimeSourceProvider, createTimeSource } from "../platform/timesrc"; import { TimeSourceProvider, createTimeSource } from "../platform/timesrc";
import TootThread from "./TootThread.js";
import ProfileMenuButton from "./ProfileMenuButton"; import ProfileMenuButton from "./ProfileMenuButton";
import Tabs from "../material/Tabs"; import Tabs from "../material/Tabs";
import Tab from "../material/Tab"; 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 { makeEventListener } from "@solid-primitives/event-listener";
import BottomSheet, { import BottomSheet, {
HERO as BOTTOM_SHEET_HERO, HERO as BOTTOM_SHEET_HERO,
} from "../material/BottomSheet"; } from "../material/BottomSheet";
import { $settings } from "../settings/stores"; import { $settings } from "../settings/stores";
import { useStore } from "@nanostores/solid"; import { useStore } from "@nanostores/solid";
import { vibrate } from "../platform/hardware";
import PullDownToRefresh from "./PullDownToRefresh";
import { HeroSourceProvider, type HeroSource } from "../platform/anim"; import { HeroSourceProvider, type HeroSource } from "../platform/anim";
import { useNavigate } from "@solidjs/router"; import { useNavigate } from "@solidjs/router";
import { useSignedInProfiles } from "../masto/acct"; import { useSignedInProfiles } from "../masto/acct";
import { setCache as setTootBottomSheetCache } from "./TootBottomSheet"; import { setCache as setTootBottomSheetCache } from "./TootBottomSheet";
import TrendTimelinePanel from "./TrendTimelinePanel"; import TootComposer from "./TootComposer";
import TimelinePanel from "./TimelinePanel";
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<HTMLElement>();
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<string>();
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 (
<ErrorBoundary
fallback={(err, reset) => {
return <p>Oops: {String(err)}</p>;
}}
>
<PullDownToRefresh
linkedElement={scrollLinked()}
loading={snapshot.loading}
onRefresh={() => refetchTimeline({ direction: "new" })}
/>
<div
ref={(e) =>
setTimeout(() => {
setScrollLinked(e.parentElement!);
}, 0)
}
>
<Show when={props.name === "home"}>
<TootComposer
style={{
"--scaffold-topbar-height": "0px",
}}
isTyping={typing()}
onTypingChange={setTyping}
client={props.client}
onSent={() => refetchTimeline({ direction: "new" })}
/>
</Show>
<For each={timeline}>
{(item, index) => {
let element: HTMLElement | undefined;
return (
<TootThread
ref={element}
status={item}
onBoost={(...args) => 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);
}
}}
/>
);
}}
</For>
</div>
<div ref={(e) => tlEndObserver.observe(e)}></div>
<Show when={snapshot.loading}>
<div
class="loading-line"
style={{
width: "100%",
}}
>
<LinearProgress />
</div>
</Show>
<div
style={{
display: "flex",
padding: "20px 0 calc(20px + var(--safe-area-inset-bottom, 0px))",
"align-items": "center",
"justify-content": "center",
}}
>
<JsSwitch>
<Match when={snapshot.error}>
<Button
variant="contained"
onClick={[refetchTimeline, "old"]}
disabled={snapshot.loading}
>
Retry
</Button>
</Match>
<Match when={typeof props.fullRefetch === "undefined"}>
<Button
variant="contained"
onClick={[refetchTimeline, "old"]}
disabled={snapshot.loading}
>
Load More
</Button>
</Match>
</JsSwitch>
</div>
</ErrorBoundary>
);
};
const Home: ParentComponent = (props) => { const Home: ParentComponent = (props) => {
let panelList: HTMLDivElement; let panelList: HTMLDivElement;
@ -146,9 +340,7 @@ const Home: ParentComponent = (props) => {
console.warn("no account info?"); console.warn("no account info?");
return; return;
} }
setHeroSrc((x) => setHeroSrc((x) => Object.assign({}, x, { [BOTTOM_SHEET_HERO]: srcElement }));
Object.assign({}, x, { [BOTTOM_SHEET_HERO]: srcElement }),
);
const acct = `${inf.username}@${p.account.site}`; const acct = `${inf.username}@${p.account.site}`;
setTootBottomSheetCache(acct, toot); setTootBottomSheetCache(acct, toot);
navigate(`/${encodeURIComponent(acct)}/${toot.id}`, { navigate(`/${encodeURIComponent(acct)}/${toot.id}`, {
@ -247,10 +439,12 @@ const Home: ParentComponent = (props) => {
</div> </div>
<div class="tab-panel"> <div class="tab-panel">
<div> <div>
<TrendTimelinePanel <TimelinePanel
client={client()} client={client()}
name="trends"
prefetch={prefetching()} prefetch={prefetching()}
openFullScreenToot={openFullScreenToot} openFullScreenToot={openFullScreenToot}
fullRefetch={120}
/> />
</div> </div>
</div> </div>
@ -270,9 +464,7 @@ const Home: ParentComponent = (props) => {
</TimeSourceProvider> </TimeSourceProvider>
<Suspense> <Suspense>
<HeroSourceProvider value={[heroSrc, setHeroSrc]}> <HeroSourceProvider value={[heroSrc, setHeroSrc]}>
<BottomSheet open={!!child()} onClose={() => navigate(-1)}> <BottomSheet open={!!child()}>{child()}</BottomSheet>
{child()}
</BottomSheet>
</HeroSourceProvider> </HeroSourceProvider>
</Suspense> </Suspense>
</Scaffold> </Scaffold>

View file

@ -66,7 +66,7 @@ const MediaAttachmentGrid: Component<{
css` css`
.attachments { .attachments {
column-count: ${columnCount().toString()}; column-count: ${columnCount.toString()};
} }
`; `;
return ( return (

View file

@ -52,7 +52,7 @@ const PullDownToRefresh: Component<{
let lts = -1; let lts = -1;
let ds = 0; let ds = 0;
let holding = false; let holding = false;
const K = 20; const K = 10;
const updatePullDown = (ts: number) => { const updatePullDown = (ts: number) => {
released = false; released = false;
try { try {
@ -60,9 +60,8 @@ const PullDownToRefresh: Component<{
const dt = lts !== -1 ? ts - lts : 1 / 60; const dt = lts !== -1 ? ts - lts : 1 / 60;
const vspring = holding ? 0 : K * x * dt; const vspring = holding ? 0 : K * x * dt;
v = ds / dt - vspring; v = ds / dt - vspring;
const final = Math.max(Math.min(x + v * dt, stopPos()), 0);
setPullDown(final); setPullDown(Math.max(Math.min(x + v * dt, stopPos()), 0));
if (Math.abs(x) > 1 || Math.abs(v) > 1) { if (Math.abs(x) > 1 || Math.abs(v) > 1) {
requestAnimationFrame(updatePullDown); requestAnimationFrame(updatePullDown);
@ -70,6 +69,15 @@ const PullDownToRefresh: Component<{
v = 0; v = 0;
lts = -1; lts = -1;
} }
if (
!holding &&
untrack(pullDown) >= stopPos() &&
!props.loading &&
props.onRefresh
) {
setTimeout(props.onRefresh, 0);
}
} finally { } finally {
ds = 0; ds = 0;
released = true; released = true;
@ -81,11 +89,6 @@ const PullDownToRefresh: Component<{
const onWheelNotUpdated = () => { const onWheelNotUpdated = () => {
wheelTimeout = undefined; wheelTimeout = undefined;
holding = false; holding = false;
if (released) {
released = false;
requestAnimationFrame(updatePullDown);
}
}; };
const handleLinkedWheel = (event: WheelEvent) => { const handleLinkedWheel = (event: WheelEvent) => {
@ -94,18 +97,11 @@ const PullDownToRefresh: Component<{
const d = untrack(pullDown); const d = untrack(pullDown);
if (d > 1) event.preventDefault(); if (d > 1) event.preventDefault();
ds = -(event.deltaY / window.devicePixelRatio / 2); ds = -(event.deltaY / window.devicePixelRatio / 2);
holding = d < stopPos();
if (wheelTimeout) { if (wheelTimeout) {
clearTimeout(wheelTimeout); clearTimeout(wheelTimeout);
} }
if (d >= stopPos() && !props.loading) { wheelTimeout = setTimeout(onWheelNotUpdated, 200);
props.onRefresh?.();
holding = false;
wheelTimeout = undefined;
} else {
holding = true;
wheelTimeout = setTimeout(onWheelNotUpdated, 200);
}
if (released) { if (released) {
released = false; released = false;
@ -155,8 +151,12 @@ const PullDownToRefresh: Component<{
lastTouchId = undefined; lastTouchId = undefined;
lastTouchScreenY = 0; lastTouchScreenY = 0;
holding = false; holding = false;
if (untrack(pullDown) >= stopPos() && !props.loading) { if (
props.onRefresh?.(); untrack(indicatorOfsY) >= stopPos() &&
!props.loading &&
props.onRefresh
) {
setTimeout(props.onRefresh, 0);
} else { } else {
if (released) { if (released) {
released = false; released = false;
@ -203,7 +203,9 @@ const PullDownToRefresh: Component<{
background-color: var(--tutu-color-surface); background-color: var(--tutu-color-surface);
> :global(.refresh-icon) { > :global(.refresh-icon) {
transform: rotate(${`${(indicatorOfsY() / 160 / 2).toString()}turn`}); transform: rotate(
${`${((indicatorOfsY() / 160) * 180).toString()}deg`}
);
will-change: transform; will-change: transform;
} }

View file

@ -111,17 +111,13 @@ type TootActionGroupProps<T extends mastodon.v1.Status> = {
onRetoot?: (value: T) => void; onRetoot?: (value: T) => void;
onFavourite?: (value: T) => void; onFavourite?: (value: T) => void;
onBookmark?: (value: T) => void; onBookmark?: (value: T) => void;
onReply?: ( onReply?: (value: T) => void;
value: T,
event: MouseEvent & { currentTarget: HTMLButtonElement },
) => void;
}; };
type TootCardProps = { type TootCardProps = {
status: mastodon.v1.Status; status: mastodon.v1.Status;
actionable?: boolean; actionable?: boolean;
evaluated?: boolean; evaluated?: boolean;
thread?: "top" | "bottom" | "middle";
} & TootActionGroupProps<mastodon.v1.Status> & } & TootActionGroupProps<mastodon.v1.Status> &
JSX.HTMLElementTags["article"]; JSX.HTMLElementTags["article"];
@ -129,40 +125,19 @@ function isolatedCallback(e: MouseEvent) {
e.stopPropagation(); e.stopPropagation();
} }
export function findRootToot(element: HTMLElement) {
let current: HTMLElement | null = element;
while (current && !current.classList.contains(tootStyle.toot)) {
current = current.parentElement;
}
if (!current) {
throw Error(
`the element must be placed under a element with ${tootStyle.toot}`,
);
}
return current;
}
function TootActionGroup<T extends mastodon.v1.Status>( function TootActionGroup<T extends mastodon.v1.Status>(
props: TootActionGroupProps<T> & { value: T }, props: TootActionGroupProps<T> & { value: T },
) { ) {
let actGrpElement: HTMLDivElement;
const toot = () => props.value; const toot = () => props.value;
return ( return (
<div <div class={tootStyle.tootBottomActionGrp} onClick={isolatedCallback}>
ref={actGrpElement!} <Button
class={tootStyle.tootBottomActionGrp} class={tootStyle.tootActionWithCount}
onClick={isolatedCallback} onClick={() => props.onReply?.(toot())}
> >
<Show when={props.onReply}> <ReplyAll />
<Button <span>{toot().repliesCount}</span>
class={tootStyle.tootActionWithCount} </Button>
onClick={[props.onReply!, props.value]}
>
<ReplyAll />
<span>{toot().repliesCount}</span>
</Button>
</Show>
<Button <Button
class={tootStyle.tootActionWithCount} class={tootStyle.tootActionWithCount}
style={{ style={{
@ -313,7 +288,7 @@ const RegularToot: Component<TootCardProps> = (props) => {
let rootRef: HTMLElement; let rootRef: HTMLElement;
const [managed, managedActionGroup, rest] = splitProps( const [managed, managedActionGroup, rest] = splitProps(
props, props,
["status", "lang", "class", "actionable", "evaluated", "thread"], ["status", "lang", "class", "actionable", "evaluated"],
["onRetoot", "onFavourite", "onBookmark", "onReply"], ["onRetoot", "onFavourite", "onBookmark", "onReply"],
); );
const now = useTimeSource(); const now = useTimeSource();
@ -325,42 +300,6 @@ const RegularToot: Component<TootCardProps> = (props) => {
margin-left: calc(var(--toot-avatar-size) + var(--card-pad) + 8px); margin-left: calc(var(--toot-avatar-size) + var(--card-pad) + 8px);
margin-block: 8px; margin-block: 8px;
} }
.thread-top,
.thread-mid,
.thread-btm {
position: relative;
&::before {
content: "";
position: absolute;
left: 36px;
background-color: var(--tutu-color-secondary);
width: 2px;
display: block;
}
}
.thread-mid {
&::before {
top: 0;
bottom: 0;
}
}
.thread-top {
&::before {
top: 16px;
bottom: 0;
}
}
.thread-btm {
&::before {
top: 0;
height: 16px;
}
}
`; `;
return ( return (
@ -369,9 +308,6 @@ const RegularToot: Component<TootCardProps> = (props) => {
classList={{ classList={{
[tootStyle.toot]: true, [tootStyle.toot]: true,
[tootStyle.expanded]: managed.evaluated, [tootStyle.expanded]: managed.evaluated,
"thread-top": managed.thread === "top",
"thread-mid": managed.thread === "middle",
"thread-btm": managed.thread === "bottom",
[managed.class || ""]: true, [managed.class || ""]: true,
}} }}
ref={rootRef!} ref={rootRef!}

View file

@ -1,92 +0,0 @@
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, { findRootToot } from "./RegularToot";
import cardStyle from "../material/cards.module.css";
import { css } from "solid-styled";
type TootActionTarget = {
client: mastodon.rest.Client;
status: mastodon.v1.Status;
};
type TootActions = {
onBoost(client: mastodon.rest.Client, status: mastodon.v1.Status): void;
onBookmark(client: mastodon.rest.Client, status: mastodon.v1.Status): void;
onReply(target: TootActionTarget, element: HTMLElement): void;
};
type ThreadProps = {
ref?: Ref<HTMLElement>;
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<ThreadProps> = (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,
event: MouseEvent & { currentTarget: HTMLElement },
) => {
const element = findRootToot(event.currentTarget);
props.onReply({ client: props.client, status }, element);
};
css`
.thread {
user-select: none;
cursor: pointer;
}
`
return (
<article ref={props.ref} class="thread">
<For each={props.toots}>
{(status, index) => {
const useThread = props.toots.length > 1;
const threadPosition = useThread
? index() === 0
? "top"
: index() === props.toots.length - 1
? "bottom"
: "middle"
: undefined;
return (
<RegularToot
data-status-id={status.id}
data-thread-sort={index()}
status={status}
thread={threadPosition}
class={cardStyle.card}
evaluated={props.isExpended(status)}
actionable={props.isExpended(status)}
onBookmark={(s) => bookmark(s)}
onRetoot={(s) => boost(s)}
onReply={reply}
onClick={[props.onItemClick, status]}
/>
);
}}
</For>
</article>
);
};
export default Thread;

View file

@ -1,189 +0,0 @@
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 { createTimeline } from "../masto/timelines";
import { vibrate } from "../platform/hardware";
import PullDownToRefresh from "./PullDownToRefresh";
import TootComposer from "./TootComposer";
import Thread from "./Thread.jsx";
const TimelinePanel: Component<{
client: mastodon.rest.Client;
name: "home" | "public";
prefetch?: boolean;
openFullScreenToot: (
toot: mastodon.v1.Status,
srcElement?: HTMLElement,
reply?: boolean,
) => void;
}> = (props) => {
const [scrollLinked, setScrollLinked] = createSignal<HTMLElement>();
const [timeline, snapshot, { refetch: refetchTimeline }] = createTimeline(
() => props.client.v1.timelines[props.name],
() => 20,
);
const [expandedThreadId, setExpandedThreadId] = createSignal<string>();
const [typing, setTyping] = createSignal(false);
const tlEndObserver = new IntersectionObserver(() => {
if (untrack(() => props.prefetch) && !snapshot.loading)
refetchTimeline("next");
});
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 (
<ErrorBoundary
fallback={(err, reset) => {
return <p>Oops: {String(err)}</p>;
}}
>
<PullDownToRefresh
linkedElement={scrollLinked()}
loading={snapshot.loading}
onRefresh={() => refetchTimeline("next")}
/>
<div
ref={(e) =>
setTimeout(() => {
setScrollLinked(e.parentElement!);
}, 0)
}
>
<Show when={props.name === "home"}>
<TootComposer
style={{
"--scaffold-topbar-height": "0px",
}}
isTyping={typing()}
onTypingChange={setTyping}
client={props.client}
onSent={() => refetchTimeline("prev")}
/>
</Show>
<For each={timeline.list}>
{(itemId, index) => {
const path = timeline.getPath(itemId)!;
const toots = path.reverse().map((x) => x.value);
return (
<Thread
toots={toots}
onBoost={onBoost}
onBookmark={onBookmark}
onReply={({ status }, element) =>
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,
);
}
}}
/>
);
}}
</For>
</div>
<div ref={(e) => tlEndObserver.observe(e)}></div>
<Show when={snapshot.loading}>
<div
class="loading-line"
style={{
width: "100%",
}}
>
<LinearProgress />
</div>
</Show>
<div
style={{
display: "flex",
padding: "20px 0 calc(20px + var(--safe-area-inset-bottom, 0px))",
"align-items": "center",
"justify-content": "center",
}}
>
<JsSwitch>
<Match when={snapshot.error}>
<Button
variant="contained"
onClick={[refetchTimeline, "next"]}
disabled={snapshot.loading}
>
Retry
</Button>
</Match>
<Match when={true}>
<Button
variant="contained"
onClick={[refetchTimeline, "prev"]}
disabled={snapshot.loading}
>
Load More
</Button>
</Match>
</JsSwitch>
</div>
</ErrorBoundary>
);
};
export default TimelinePanel;

View file

@ -62,6 +62,9 @@ const TootBottomSheet: Component = (props) => {
} }
); );
}; };
const profile = () => {
return session().account;
};
const pushedCount = () => { const pushedCount = () => {
return location.state?.tootBottomSheetPushedCount || 0; return location.state?.tootBottomSheetPushedCount || 0;

View file

@ -1,182 +0,0 @@
import {
Component,
For,
onCleanup,
createSignal,
untrack,
Match,
Switch as JsSwitch,
ErrorBoundary,
createSelector,
} from "solid-js";
import { type mastodon } from "masto";
import { Button } 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<HTMLElement>();
const [
timeline,
snapshot,
{ refetch: refetchTimeline, mutate: mutateTimeline },
] = createTimelineSnapshot(
() => props.client.v1.trends.statuses,
() => 120,
);
const [expandedId, setExpandedId] = createSignal<string>();
const tlEndObserver = new IntersectionObserver(() => {
if (untrack(() => props.prefetch) && !snapshot.loading)
refetchTimeline();
});
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 (
<ErrorBoundary
fallback={(err, reset) => {
return <p>Oops: {String(err)}</p>;
}}
>
<PullDownToRefresh
linkedElement={scrollLinked()}
loading={snapshot.loading}
onRefresh={() => refetchTimeline({ direction: "new" })}
/>
<div
ref={(e) =>
setTimeout(() => {
setScrollLinked(e.parentElement!);
}, 0)
}
>
<For each={timeline}>
{(item, index) => {
let element: HTMLElement | undefined;
return (
<Thread
ref={element}
toots={item}
onBoost={(...args) => 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);
}
}}
/>
);
}}
</For>
</div>
<div ref={(e) => tlEndObserver.observe(e)}></div>
<div
style={{
display: "flex",
padding: "20px 0 calc(20px + var(--safe-area-inset-bottom, 0px))",
"align-items": "center",
"justify-content": "center",
"flex-flow": "column",
gap: "20px"
}}
>
<JsSwitch>
<Match when={snapshot.error}>
<p>{`Oops: ${snapshot.error}`}</p>
<Button
variant="contained"
onClick={[refetchTimeline, undefined]}
disabled={snapshot.loading}
>
Retry
</Button>
</Match>
<Match when={true}>
<Button
variant="contained"
onClick={[refetchTimeline, undefined]}
disabled={snapshot.loading}
>
Refresh
</Button>
</Match>
</JsSwitch>
</div>
</ErrorBoundary>
);
};
export default TrendTimelinePanel;