Compare commits
No commits in common. "21afb718f7aa429e16cb61aa2107c1035143aa02" and "e9551d6915c047931183ba90aae3fc967e9e9669" have entirely different histories.
21afb718f7
...
e9551d6915
12 changed files with 355 additions and 852 deletions
BIN
bun.lockb
BIN
bun.lockb
Binary file not shown.
|
@ -33,7 +33,6 @@
|
|||
"@solid-primitives/event-listener": "^2.3.3",
|
||||
"@solid-primitives/i18n": "^2.1.1",
|
||||
"@solid-primitives/intersection-observer": "^2.1.6",
|
||||
"@solid-primitives/map": "^0.4.13",
|
||||
"@solid-primitives/resize-observer": "^2.0.26",
|
||||
"@solidjs/router": "^0.14.5",
|
||||
"@suid/icons-material": "^0.8.0",
|
||||
|
|
|
@ -1,59 +1,40 @@
|
|||
import { Button } from "@suid/material";
|
||||
import { Component, createResource } from "solid-js";
|
||||
import { css } from "solid-styled";
|
||||
import { Button } from '@suid/material';
|
||||
import {Component, createResource} from 'solid-js'
|
||||
import { css } from 'solid-styled';
|
||||
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
const UnexpectedError: Component<{error?: any}> = (props) => {
|
||||
|
||||
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`
|
||||
main {
|
||||
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);
|
||||
}
|
||||
`;
|
||||
main {
|
||||
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);
|
||||
}
|
||||
`
|
||||
|
||||
return (
|
||||
<main>
|
||||
<h1>Oh, it is our fault.</h1>
|
||||
<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>
|
||||
<div>
|
||||
<Button onClick={() => window.location.reload()}>Reload</Button>
|
||||
</div>
|
||||
<details>
|
||||
<summary>
|
||||
{errorMsg.loading ? "Generating " : " "}Technical Infomation
|
||||
</summary>
|
||||
<pre>{errorMsg()}</pre>
|
||||
</details>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
return <main>
|
||||
<h1>Oh, it is our fault.</h1>
|
||||
<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>
|
||||
<div>
|
||||
<Button onClick={() => window.location.reload()}>Reload</Button>
|
||||
</div>
|
||||
<details>
|
||||
<summary>{errorMsg.loading ? 'Generating ' : " "}Technical Infomation (Bring to us if you report the problem)</summary>
|
||||
<pre>
|
||||
{errorMsg()}
|
||||
</pre>
|
||||
</details>
|
||||
</main>
|
||||
}
|
||||
|
||||
export default UnexpectedError;
|
||||
|
|
|
@ -1,258 +1,111 @@
|
|||
import { ReactiveMap } from "@solid-primitives/map";
|
||||
import { type mastodon } from "masto";
|
||||
import {
|
||||
Accessor,
|
||||
batch,
|
||||
catchError,
|
||||
createEffect,
|
||||
createResource,
|
||||
untrack,
|
||||
type ResourceFetcherInfo,
|
||||
} from "solid-js";
|
||||
import { Accessor, createEffect, createResource } from "solid-js";
|
||||
import { createStore } from "solid-js/store";
|
||||
|
||||
type TimelineFetchTips = {
|
||||
direction?: "new" | "old";
|
||||
};
|
||||
|
||||
type Timeline = {
|
||||
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;
|
||||
}): mastodon.Paginator<mastodon.v1.Status[], unknown>;
|
||||
};
|
||||
|
||||
export function createTimelineSnapshot(
|
||||
export function useTimeline(
|
||||
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(
|
||||
() => [timeline(), limit()] as const,
|
||||
async ([tl, limit]) => {
|
||||
const ls = await tl.list({ limit }).next();
|
||||
return ls.value?.map((x) => [x]) ?? [];
|
||||
let otl: Timeline | undefined;
|
||||
let npager: mastodon.Paginator<mastodon.v1.Status[], unknown> | undefined;
|
||||
let opager: mastodon.Paginator<mastodon.v1.Status[], unknown> | undefined;
|
||||
const [snapshot, { refetch }] = createResource<
|
||||
{
|
||||
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(() => {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const shot = snapshot();
|
||||
if (!shot) return;
|
||||
const { direction, records, tlChanged } = shot;
|
||||
if (tlChanged) {
|
||||
setStore(() => []);
|
||||
}
|
||||
if (direction === "new") {
|
||||
setStore((x) => [...records, ...x]);
|
||||
} else if (direction === "old") {
|
||||
setStore((x) => [...x, ...records]);
|
||||
} else if (direction === "items") {
|
||||
setStore(() => records);
|
||||
}
|
||||
});
|
||||
|
||||
return [
|
||||
store,
|
||||
snapshot,
|
||||
shot,
|
||||
{
|
||||
refetch,
|
||||
mutate: setSnapshot,
|
||||
mutate: setStore,
|
||||
},
|
||||
] 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;
|
||||
}
|
||||
|
|
|
@ -1,16 +1,26 @@
|
|||
import {
|
||||
Component,
|
||||
For,
|
||||
onCleanup,
|
||||
createSignal,
|
||||
Show,
|
||||
untrack,
|
||||
onMount,
|
||||
type ParentComponent,
|
||||
children,
|
||||
Suspense,
|
||||
Match,
|
||||
Switch as JsSwitch,
|
||||
ErrorBoundary,
|
||||
} from "solid-js";
|
||||
import { useDocumentTitle } from "../utils";
|
||||
import { type mastodon } from "masto";
|
||||
import Scaffold from "../material/Scaffold";
|
||||
import {
|
||||
AppBar,
|
||||
Button,
|
||||
Fab,
|
||||
LinearProgress,
|
||||
ListItemSecondaryAction,
|
||||
ListItemText,
|
||||
MenuItem,
|
||||
|
@ -19,21 +29,205 @@ import {
|
|||
} from "@suid/material";
|
||||
import { css } from "solid-styled";
|
||||
import { TimeSourceProvider, createTimeSource } from "../platform/timesrc";
|
||||
import TootThread from "./TootThread.js";
|
||||
import ProfileMenuButton from "./ProfileMenuButton";
|
||||
import Tabs from "../material/Tabs";
|
||||
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 BottomSheet, {
|
||||
HERO as BOTTOM_SHEET_HERO,
|
||||
} from "../material/BottomSheet";
|
||||
import { $settings } from "../settings/stores";
|
||||
import { useStore } from "@nanostores/solid";
|
||||
import { vibrate } from "../platform/hardware";
|
||||
import PullDownToRefresh from "./PullDownToRefresh";
|
||||
import { HeroSourceProvider, type HeroSource } from "../platform/anim";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
import { useSignedInProfiles } from "../masto/acct";
|
||||
import { setCache as setTootBottomSheetCache } from "./TootBottomSheet";
|
||||
import TrendTimelinePanel from "./TrendTimelinePanel";
|
||||
import TimelinePanel from "./TimelinePanel";
|
||||
import TootComposer from "./TootComposer";
|
||||
|
||||
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) => {
|
||||
let panelList: HTMLDivElement;
|
||||
|
@ -146,9 +340,7 @@ const Home: ParentComponent = (props) => {
|
|||
console.warn("no account info?");
|
||||
return;
|
||||
}
|
||||
setHeroSrc((x) =>
|
||||
Object.assign({}, x, { [BOTTOM_SHEET_HERO]: srcElement }),
|
||||
);
|
||||
setHeroSrc((x) => Object.assign({}, x, { [BOTTOM_SHEET_HERO]: srcElement }));
|
||||
const acct = `${inf.username}@${p.account.site}`;
|
||||
setTootBottomSheetCache(acct, toot);
|
||||
navigate(`/${encodeURIComponent(acct)}/${toot.id}`, {
|
||||
|
@ -247,10 +439,12 @@ const Home: ParentComponent = (props) => {
|
|||
</div>
|
||||
<div class="tab-panel">
|
||||
<div>
|
||||
<TrendTimelinePanel
|
||||
<TimelinePanel
|
||||
client={client()}
|
||||
name="trends"
|
||||
prefetch={prefetching()}
|
||||
openFullScreenToot={openFullScreenToot}
|
||||
fullRefetch={120}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -270,9 +464,7 @@ const Home: ParentComponent = (props) => {
|
|||
</TimeSourceProvider>
|
||||
<Suspense>
|
||||
<HeroSourceProvider value={[heroSrc, setHeroSrc]}>
|
||||
<BottomSheet open={!!child()} onClose={() => navigate(-1)}>
|
||||
{child()}
|
||||
</BottomSheet>
|
||||
<BottomSheet open={!!child()}>{child()}</BottomSheet>
|
||||
</HeroSourceProvider>
|
||||
</Suspense>
|
||||
</Scaffold>
|
||||
|
|
|
@ -66,7 +66,7 @@ const MediaAttachmentGrid: Component<{
|
|||
|
||||
css`
|
||||
.attachments {
|
||||
column-count: ${columnCount().toString()};
|
||||
column-count: ${columnCount.toString()};
|
||||
}
|
||||
`;
|
||||
return (
|
||||
|
|
|
@ -52,7 +52,7 @@ const PullDownToRefresh: Component<{
|
|||
let lts = -1;
|
||||
let ds = 0;
|
||||
let holding = false;
|
||||
const K = 20;
|
||||
const K = 10;
|
||||
const updatePullDown = (ts: number) => {
|
||||
released = false;
|
||||
try {
|
||||
|
@ -60,9 +60,8 @@ const PullDownToRefresh: Component<{
|
|||
const dt = lts !== -1 ? ts - lts : 1 / 60;
|
||||
const vspring = holding ? 0 : K * x * dt;
|
||||
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) {
|
||||
requestAnimationFrame(updatePullDown);
|
||||
|
@ -70,6 +69,15 @@ const PullDownToRefresh: Component<{
|
|||
v = 0;
|
||||
lts = -1;
|
||||
}
|
||||
|
||||
if (
|
||||
!holding &&
|
||||
untrack(pullDown) >= stopPos() &&
|
||||
!props.loading &&
|
||||
props.onRefresh
|
||||
) {
|
||||
setTimeout(props.onRefresh, 0);
|
||||
}
|
||||
} finally {
|
||||
ds = 0;
|
||||
released = true;
|
||||
|
@ -81,11 +89,6 @@ const PullDownToRefresh: Component<{
|
|||
const onWheelNotUpdated = () => {
|
||||
wheelTimeout = undefined;
|
||||
holding = false;
|
||||
|
||||
if (released) {
|
||||
released = false;
|
||||
requestAnimationFrame(updatePullDown);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLinkedWheel = (event: WheelEvent) => {
|
||||
|
@ -94,18 +97,11 @@ const PullDownToRefresh: Component<{
|
|||
const d = untrack(pullDown);
|
||||
if (d > 1) event.preventDefault();
|
||||
ds = -(event.deltaY / window.devicePixelRatio / 2);
|
||||
holding = d < stopPos();
|
||||
if (wheelTimeout) {
|
||||
clearTimeout(wheelTimeout);
|
||||
}
|
||||
if (d >= stopPos() && !props.loading) {
|
||||
props.onRefresh?.();
|
||||
|
||||
holding = false;
|
||||
wheelTimeout = undefined;
|
||||
} else {
|
||||
holding = true;
|
||||
wheelTimeout = setTimeout(onWheelNotUpdated, 200);
|
||||
}
|
||||
wheelTimeout = setTimeout(onWheelNotUpdated, 200);
|
||||
|
||||
if (released) {
|
||||
released = false;
|
||||
|
@ -155,8 +151,12 @@ const PullDownToRefresh: Component<{
|
|||
lastTouchId = undefined;
|
||||
lastTouchScreenY = 0;
|
||||
holding = false;
|
||||
if (untrack(pullDown) >= stopPos() && !props.loading) {
|
||||
props.onRefresh?.();
|
||||
if (
|
||||
untrack(indicatorOfsY) >= stopPos() &&
|
||||
!props.loading &&
|
||||
props.onRefresh
|
||||
) {
|
||||
setTimeout(props.onRefresh, 0);
|
||||
} else {
|
||||
if (released) {
|
||||
released = false;
|
||||
|
@ -203,7 +203,9 @@ const PullDownToRefresh: Component<{
|
|||
background-color: var(--tutu-color-surface);
|
||||
|
||||
> :global(.refresh-icon) {
|
||||
transform: rotate(${`${(indicatorOfsY() / 160 / 2).toString()}turn`});
|
||||
transform: rotate(
|
||||
${`${((indicatorOfsY() / 160) * 180).toString()}deg`}
|
||||
);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
|
|
|
@ -111,17 +111,13 @@ type TootActionGroupProps<T extends mastodon.v1.Status> = {
|
|||
onRetoot?: (value: T) => void;
|
||||
onFavourite?: (value: T) => void;
|
||||
onBookmark?: (value: T) => void;
|
||||
onReply?: (
|
||||
value: T,
|
||||
event: MouseEvent & { currentTarget: HTMLButtonElement },
|
||||
) => void;
|
||||
onReply?: (value: T) => void;
|
||||
};
|
||||
|
||||
type TootCardProps = {
|
||||
status: mastodon.v1.Status;
|
||||
actionable?: boolean;
|
||||
evaluated?: boolean;
|
||||
thread?: "top" | "bottom" | "middle";
|
||||
} & TootActionGroupProps<mastodon.v1.Status> &
|
||||
JSX.HTMLElementTags["article"];
|
||||
|
||||
|
@ -129,40 +125,19 @@ function isolatedCallback(e: MouseEvent) {
|
|||
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>(
|
||||
props: TootActionGroupProps<T> & { value: T },
|
||||
) {
|
||||
let actGrpElement: HTMLDivElement;
|
||||
const toot = () => props.value;
|
||||
return (
|
||||
<div
|
||||
ref={actGrpElement!}
|
||||
class={tootStyle.tootBottomActionGrp}
|
||||
onClick={isolatedCallback}
|
||||
>
|
||||
<Show when={props.onReply}>
|
||||
<Button
|
||||
class={tootStyle.tootActionWithCount}
|
||||
onClick={[props.onReply!, props.value]}
|
||||
>
|
||||
<ReplyAll />
|
||||
<span>{toot().repliesCount}</span>
|
||||
</Button>
|
||||
</Show>
|
||||
|
||||
<div class={tootStyle.tootBottomActionGrp} onClick={isolatedCallback}>
|
||||
<Button
|
||||
class={tootStyle.tootActionWithCount}
|
||||
onClick={() => props.onReply?.(toot())}
|
||||
>
|
||||
<ReplyAll />
|
||||
<span>{toot().repliesCount}</span>
|
||||
</Button>
|
||||
<Button
|
||||
class={tootStyle.tootActionWithCount}
|
||||
style={{
|
||||
|
@ -313,7 +288,7 @@ const RegularToot: Component<TootCardProps> = (props) => {
|
|||
let rootRef: HTMLElement;
|
||||
const [managed, managedActionGroup, rest] = splitProps(
|
||||
props,
|
||||
["status", "lang", "class", "actionable", "evaluated", "thread"],
|
||||
["status", "lang", "class", "actionable", "evaluated"],
|
||||
["onRetoot", "onFavourite", "onBookmark", "onReply"],
|
||||
);
|
||||
const now = useTimeSource();
|
||||
|
@ -325,42 +300,6 @@ const RegularToot: Component<TootCardProps> = (props) => {
|
|||
margin-left: calc(var(--toot-avatar-size) + var(--card-pad) + 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 (
|
||||
|
@ -369,9 +308,6 @@ const RegularToot: Component<TootCardProps> = (props) => {
|
|||
classList={{
|
||||
[tootStyle.toot]: true,
|
||||
[tootStyle.expanded]: managed.evaluated,
|
||||
"thread-top": managed.thread === "top",
|
||||
"thread-mid": managed.thread === "middle",
|
||||
"thread-btm": managed.thread === "bottom",
|
||||
[managed.class || ""]: true,
|
||||
}}
|
||||
ref={rootRef!}
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -62,6 +62,9 @@ const TootBottomSheet: Component = (props) => {
|
|||
}
|
||||
);
|
||||
};
|
||||
const profile = () => {
|
||||
return session().account;
|
||||
};
|
||||
|
||||
const pushedCount = () => {
|
||||
return location.state?.tootBottomSheetPushedCount || 0;
|
||||
|
|
|
@ -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;
|
Loading…
Reference in a new issue