tutu/src/timelines/Home.tsx

340 lines
9.7 KiB
TypeScript

import {
Component,
For,
onCleanup,
createSignal,
Show,
untrack,
onMount,
type ParentComponent,
children,
} from "solid-js";
import { useDocumentTitle } from "../utils";
import { useSessions } from "../masto/clients";
import { type mastodon } from "masto";
import Scaffold from "../material/Scaffold";
import {
AppBar,
Button,
Fab,
LinearProgress,
ListItemSecondaryAction,
ListItemText,
MenuItem,
Switch,
Toolbar,
} from "@suid/material";
import { css } from "solid-styled";
import { TimeSourceProvider, createTimeSource } from "../platform/timesrc";
import TootThread from "./TootThread.js";
import { useAcctProfile } from "../masto/acct";
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 from "../material/BottomSheet";
import { $settings } from "../settings/stores";
import { useStore } from "@nanostores/solid";
const TimelinePanel: Component<{
client: mastodon.rest.Client;
name: "home" | "public" | "trends";
prefetch?: boolean;
}> = (props) => {
const [timeline, { refetch: refetchTimeline, mutate: mutateTimeline }] =
useTimeline(() =>
props.name !== "trends"
? props.client.v1.timelines[props.name]
: props.client.v1.trends.statuses,
);
const tlEndObserver = new IntersectionObserver(() => {
if (untrack(() => props.prefetch) && !timeline.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 = false;
mutateTimeline((o) => {
Object.assign(o[index].reblog ?? o[index], {
reblogged: !reblogged,
});
return o;
});
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 (
<>
<div>
<For each={timeline()}>
{(item, index) => {
return (
<TootThread
status={item}
onBoost={(...args) => onBoost(index(), ...args)}
onBookmark={(...args) => onBookmark(index(), ...args)}
client={props.client}
/>
);
}}
</For>
</div>
<div ref={(e) => tlEndObserver.observe(e)}></div>
<Show when={timeline.loading}>
<div class="loading-line" style={{ width: "100%" }}>
<LinearProgress />
</div>
</Show>
<Show when={timeline.error}>
<div
style={{
display: "flex",
padding: "20px 0 var(--safe-area-inset-bottom, 20px)",
"align-items": "center",
"justify-content": "center",
}}
>
<Button variant="contained" onClick={[refetchTimeline, "old"]}>
Retry
</Button>
</div>
</Show>
<Show when={!props.prefetch && !timeline.loading}>
<div
style={{
display: "flex",
padding: "20px 0 var(--safe-area-inset-bottom, 20px)",
"align-items": "center",
"justify-content": "center",
}}
>
<Button variant="contained" onClick={[refetchTimeline, "old"]}>
Load More
</Button>
</div>
</Show>
</>
);
};
const Home: ParentComponent = (props) => {
let panelList: HTMLDivElement;
useDocumentTitle("Timelines");
const now = createTimeSource();
const settings$ = useStore($settings)
const sessions = useSessions();
const client = () => sessions()[0].client;
const [profile] = useAcctProfile(client);
const [panelOffset, setPanelOffset] = createSignal(0);
const prefetching = () => !settings$().prefetchTootsDisabled
const [currentFocusOn, setCurrentFocusOn] = createSignal<HTMLElement[]>([]);
const [focusRange, setFocusRange] = createSignal([0, 0] as readonly [
number,
number,
]);
const child = children(() => props.children)
let scrollEventLockReleased = true;
const recalculateTabIndicator = () => {
scrollEventLockReleased = false;
try {
const { x: panelX, width: panelWidth } =
panelList.getBoundingClientRect();
let minIdx = +Infinity,
maxIdx = -Infinity;
const items = panelList.querySelectorAll(".tab-panel");
const ranges = Array.from(items).map((x) => {
const rect = x.getBoundingClientRect();
const inlineStart = rect.x - panelX;
const inlineEnd = rect.width + inlineStart;
return [inlineStart, inlineEnd] as const;
});
for (let i = 0; i < items.length; i++) {
const e = items.item(i);
const [inlineStart, inlineEnd] = ranges[i];
if (inlineStart >= 0 && inlineEnd <= panelWidth) {
minIdx = Math.min(minIdx, i);
maxIdx = Math.max(maxIdx, i);
e.classList.add("active");
} else {
e.classList.remove("active");
}
}
if (isFinite(minIdx) && isFinite(maxIdx)) {
setFocusRange([minIdx, maxIdx]);
}
} finally {
scrollEventLockReleased = true;
}
};
onMount(() => {
makeEventListener(panelList, "scroll", () => {
if (scrollEventLockReleased) {
requestAnimationFrame(recalculateTabIndicator);
}
});
makeEventListener(window, "resize", () => {
if (scrollEventLockReleased) {
requestAnimationFrame(recalculateTabIndicator);
}
});
requestAnimationFrame(recalculateTabIndicator);
});
const isTabFocus = (idx: number) => {
const [start, end] = focusRange();
if (!isFinite(start) || !isFinite(end)) return false;
return idx >= start && idx <= end;
};
const onTabClick = (idx: number) => {
const items = panelList.querySelectorAll(".tab-panel");
if (items.length > idx) {
items.item(idx).scrollIntoView({ block: "start", behavior: "smooth" });
}
};
css`
.tab-panel {
overflow: visible auto;
max-width: 560px;
height: 100%;
padding: 40px 16px;
scroll-snap-align: center;
@media (max-width: 600px) {
padding: 0;
}
}
.panel-list {
display: grid;
grid-auto-columns: 560px;
grid-auto-flow: column;
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-snap-stop: always;
height: calc(100vh - var(--scaffold-topbar-height, 0px));
height: calc(100dvh - var(--scaffold-topbar-height, 0px));
padding-left: var(--safe-area-inset-left, 0);
padding-right: var(--safe-area-inset-right, 0);
@media (max-width: 600px) {
grid-auto-columns: 100%;
}
}
`;
return (
<>
<Scaffold
topbar={
<AppBar position="static">
<Toolbar variant="dense" class="responsive" sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }}>
<Tabs onFocusChanged={setCurrentFocusOn} offset={panelOffset()}>
<Tab focus={isTabFocus(0)} onClick={[onTabClick, 0]}>
Home
</Tab>
<Tab focus={isTabFocus(1)} onClick={[onTabClick, 1]}>
Trending
</Tab>
<Tab focus={isTabFocus(2)} onClick={[onTabClick, 2]}>
Public
</Tab>
</Tabs>
<ProfileMenuButton profile={profile()}>
<MenuItem onClick={(e) => $settings.setKey("prefetchTootsDisabled", !$settings.get().prefetchTootsDisabled)}>
<ListItemText>Prefetch Toots</ListItemText>
<ListItemSecondaryAction>
<Switch checked={prefetching()}></Switch>
</ListItemSecondaryAction>
</MenuItem>
</ProfileMenuButton>
</Toolbar>
</AppBar>
}
fab={
<Fab color="secondary">
<CreateTootIcon />
</Fab>
}
>
<TimeSourceProvider value={now}>
<div class="panel-list" ref={panelList!}>
<div class="tab-panel">
<div>
<TimelinePanel
client={client()}
name="home"
prefetch={prefetching()}
/>
</div>
</div>
<div class="tab-panel">
<div>
<TimelinePanel
client={client()}
name="trends"
prefetch={prefetching()}
/>
</div>
</div>
<div class="tab-panel">
<div>
<TimelinePanel
client={client()}
name="public"
prefetch={prefetching()}
/>
</div>
</div>
<div></div>
</div>
</TimeSourceProvider>
<BottomSheet open={!!child()}>{child()}</BottomSheet>
</Scaffold>
</>
);
};
export default Home;