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/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",
|
||||||
|
|
|
@ -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,
|
const [errorMsg] = createResource(() => props.error, async (err) => {
|
||||||
async (err) => {
|
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
const mod = await import("stacktrace-js");
|
const mod = await import('stacktrace-js')
|
||||||
try {
|
const stacktrace = await mod.fromError(err)
|
||||||
const stacktrace = await mod.fromError(err);
|
const strackMsg = stacktrace.map(entry => `${entry.functionName ?? "<unknown>"}@${entry.fileName}:(${entry.lineNumber}:${entry.columnNumber})`).join('\n')
|
||||||
const strackMsg = stacktrace
|
return `${err.name}: ${err.message}\n${strackMsg}`
|
||||||
.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();
|
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>
|
<p>You can reload to see if this guy is gone. If you meet this guy repeatly, please report to us.</p>
|
||||||
You can reload to see if this guy is gone. If you meet this guy
|
|
||||||
repeatly, please report to us.
|
|
||||||
</p>
|
|
||||||
<div>
|
<div>
|
||||||
<Button onClick={() => window.location.reload()}>Reload</Button>
|
<Button onClick={() => window.location.reload()}>Reload</Button>
|
||||||
</div>
|
</div>
|
||||||
<details>
|
<details>
|
||||||
<summary>
|
<summary>{errorMsg.loading ? 'Generating ' : " "}Technical Infomation (Bring to us if you report the problem)</summary>
|
||||||
{errorMsg.loading ? "Generating " : " "}Technical Infomation
|
<pre>
|
||||||
</summary>
|
{errorMsg()}
|
||||||
<pre>{errorMsg()}</pre>
|
</pre>
|
||||||
</details>
|
</details>
|
||||||
</main>
|
</main>
|
||||||
);
|
}
|
||||||
};
|
|
||||||
|
|
||||||
export default UnexpectedError;
|
export default UnexpectedError;
|
||||||
|
|
|
@ -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]);
|
|
||||||
} 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]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (direction === "new") {
|
||||||
|
setStore((x) => [...records, ...x]);
|
||||||
|
} else if (direction === "old") {
|
||||||
|
setStore((x) => [...x, ...records]);
|
||||||
|
} else if (direction === "items") {
|
||||||
|
setStore(() => records);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -66,7 +66,7 @@ const MediaAttachmentGrid: Component<{
|
||||||
|
|
||||||
css`
|
css`
|
||||||
.attachments {
|
.attachments {
|
||||||
column-count: ${columnCount().toString()};
|
column-count: ${columnCount.toString()};
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -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) {
|
|
||||||
props.onRefresh?.();
|
|
||||||
|
|
||||||
holding = false;
|
|
||||||
wheelTimeout = undefined;
|
|
||||||
} else {
|
|
||||||
holding = true;
|
|
||||||
wheelTimeout = setTimeout(onWheelNotUpdated, 200);
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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!}
|
|
||||||
class={tootStyle.tootBottomActionGrp}
|
|
||||||
onClick={isolatedCallback}
|
|
||||||
>
|
|
||||||
<Show when={props.onReply}>
|
|
||||||
<Button
|
<Button
|
||||||
class={tootStyle.tootActionWithCount}
|
class={tootStyle.tootActionWithCount}
|
||||||
onClick={[props.onReply!, props.value]}
|
onClick={() => props.onReply?.(toot())}
|
||||||
>
|
>
|
||||||
<ReplyAll />
|
<ReplyAll />
|
||||||
<span>{toot().repliesCount}</span>
|
<span>{toot().repliesCount}</span>
|
||||||
</Button>
|
</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!}
|
||||||
|
|
|
@ -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 = () => {
|
const pushedCount = () => {
|
||||||
return location.state?.tootBottomSheetPushedCount || 0;
|
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