Compare commits

..

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

12 changed files with 355 additions and 852 deletions

BIN
bun.lockb

Binary file not shown.

View file

@ -33,7 +33,6 @@
"@solid-primitives/event-listener": "^2.3.3",
"@solid-primitives/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",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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!}

View file

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

View file

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

View file

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

View file

@ -1,182 +0,0 @@
import {
Component,
For,
onCleanup,
createSignal,
untrack,
Match,
Switch as JsSwitch,
ErrorBoundary,
createSelector,
} from "solid-js";
import { type mastodon } from "masto";
import { Button } from "@suid/material";
import { createTimelineSnapshot } from "../masto/timelines.js";
import { vibrate } from "../platform/hardware.js";
import PullDownToRefresh from "./PullDownToRefresh.jsx";
import Thread from "./Thread.jsx";
const TrendTimelinePanel: Component<{
client: mastodon.rest.Client;
prefetch?: boolean;
openFullScreenToot: (
toot: mastodon.v1.Status,
srcElement?: HTMLElement,
reply?: boolean,
) => void;
}> = (props) => {
const [scrollLinked, setScrollLinked] = createSignal<HTMLElement>();
const [
timeline,
snapshot,
{ refetch: refetchTimeline, mutate: mutateTimeline },
] = createTimelineSnapshot(
() => props.client.v1.trends.statuses,
() => 120,
);
const [expandedId, setExpandedId] = createSignal<string>();
const tlEndObserver = new IntersectionObserver(() => {
if (untrack(() => props.prefetch) && !snapshot.loading)
refetchTimeline();
});
onCleanup(() => tlEndObserver.disconnect());
const isExpandedId = createSelector(expandedId);
const isExpanded = (st: mastodon.v1.Status) => isExpandedId(st.id);
const onBookmark = async (
index: number,
client: mastodon.rest.Client,
status: mastodon.v1.Status,
) => {
const result = await (status.bookmarked
? client.v1.statuses.$select(status.id).unbookmark()
: client.v1.statuses.$select(status.id).bookmark());
mutateTimeline((o) => {
o![index] = [result];
return o;
});
};
const onBoost = async (
index: number,
client: mastodon.rest.Client,
status: mastodon.v1.Status,
) => {
const reblogged = status.reblog
? status.reblog.reblogged
: status.reblogged;
vibrate(50);
mutateTimeline(index, (th) => {
const x = th[0];
if (x.reblog) {
x.reblog = { ...x.reblog, reblogged: !reblogged };
return [Object.assign({}, x)];
} else {
return [
Object.assign({}, x, {
reblogged: !reblogged,
}),
];
}
});
const result = reblogged
? await client.v1.statuses.$select(status.id).unreblog()
: (await client.v1.statuses.$select(status.id).reblog()).reblog!;
mutateTimeline(index, (th) => {
Object.assign(th[0].reblog ?? th[0], {
reblogged: result.reblogged,
reblogsCount: result.reblogsCount,
});
return th;
});
};
return (
<ErrorBoundary
fallback={(err, reset) => {
return <p>Oops: {String(err)}</p>;
}}
>
<PullDownToRefresh
linkedElement={scrollLinked()}
loading={snapshot.loading}
onRefresh={() => refetchTimeline({ direction: "new" })}
/>
<div
ref={(e) =>
setTimeout(() => {
setScrollLinked(e.parentElement!);
}, 0)
}
>
<For each={timeline}>
{(item, index) => {
let element: HTMLElement | undefined;
return (
<Thread
ref={element}
toots={item}
onBoost={(...args) => onBoost(index(), ...args)}
onBookmark={(...args) => onBookmark(index(), ...args)}
onReply={(client, status) =>
props.openFullScreenToot(status, element, true)
}
client={props.client}
isExpended={isExpanded}
onItemClick={(x) => {
if (x.id !== expandedId()) {
setExpandedId((o) => (o ? undefined : x.id));
} else {
props.openFullScreenToot(x, element);
}
}}
/>
);
}}
</For>
</div>
<div ref={(e) => tlEndObserver.observe(e)}></div>
<div
style={{
display: "flex",
padding: "20px 0 calc(20px + var(--safe-area-inset-bottom, 0px))",
"align-items": "center",
"justify-content": "center",
"flex-flow": "column",
gap: "20px"
}}
>
<JsSwitch>
<Match when={snapshot.error}>
<p>{`Oops: ${snapshot.error}`}</p>
<Button
variant="contained"
onClick={[refetchTimeline, undefined]}
disabled={snapshot.loading}
>
Retry
</Button>
</Match>
<Match when={true}>
<Button
variant="contained"
onClick={[refetchTimeline, undefined]}
disabled={snapshot.loading}
>
Refresh
</Button>
</Match>
</JsSwitch>
</div>
</ErrorBoundary>
);
};
export default TrendTimelinePanel;