import { useLocation, useNavigate, useParams } from "@solidjs/router"; import { catchError, createEffect, createRenderEffect, createResource, createSignal, Show, type Component, } from "solid-js"; import Scaffold from "../material/Scaffold"; import { AppBar, CircularProgress, IconButton, Toolbar } from "@suid/material"; import { Title } from "../material/typography"; import { Close as CloseIcon, } from "@suid/icons-material"; import { useSessionForAcctStr } from "../masto/clients"; import { resolveCustomEmoji } from "../masto/toot"; import RegularToot, { findElementActionable } from "./RegularToot"; import type { mastodon } from "masto"; import cards from "../material/cards.module.css"; import { css } from "solid-styled"; import { vibrate } from "../platform/hardware"; import { createTimeSource, TimeSourceProvider } from "../platform/timesrc"; import TootComposer from "./TootComposer"; import { useDocumentTitle } from "../utils"; import { createTimelineControlsForArray } from "../masto/timelines"; import TootList from "./TootList"; let cachedEntry: [string, mastodon.v1.Status] | undefined; export function setCache(acct: string, status: mastodon.v1.Status) { cachedEntry = [acct, status]; } function getCache(acct: string, id: string) { if (acct === cachedEntry?.[0] && id === cachedEntry?.[1].id) { return cachedEntry[1]; } } const TootBottomSheet: Component = (props) => { const params = useParams<{ acct: string; id: string }>(); const location = useLocation<{ tootReply?: boolean; }>(); const navigate = useNavigate(); const time = createTimeSource(); const acctText = () => decodeURIComponent(params.acct); const session = useSessionForAcctStr(acctText); const [remoteToot, { mutate: setRemoteToot }] = createResource( () => [session().client, params.id] as const, async ([client, id]) => { return await client.v1.statuses.$select(id).fetch(); }, ); const toot = () => catchError(remoteToot, (error) => { console.error(error); }) ?? getCache(acctText(), params.id); createEffect((lastTootId?: string) => { const tootId = toot()?.id; if (!tootId || lastTootId === tootId) return tootId; const elementId = `toot-${tootId}`; document.getElementById(elementId)?.scrollIntoView({ behavior: "smooth" }); return tootId; }); const [tootContextErrorUncaught, { refetch: refetchContext }] = createResource( () => [session().client, params.id] as const, async ([client, id]) => { return await client.v1.statuses.$select(id).context.fetch(); }, ); const tootContext = () => catchError(tootContextErrorUncaught, (error) => { console.error(error); }); const ancestors = createTimelineControlsForArray( () => tootContext()?.ancestors, ); const descendants = createTimelineControlsForArray( () => tootContext()?.descendants, ); createEffect(() => { if (ancestors.list.length > 0) { document.querySelector(`#toot-${toot()!.id}`)?.scrollIntoView(); } }); useDocumentTitle(() => { const t = toot()?.reblog ?? toot(); const name = t?.account.displayName ?? "Someone"; return `${name}'s toot`; }); const tootDisplayName = () => { const t = toot()?.reblog ?? toot(); if (t) { return resolveCustomEmoji(t.account.displayName, t.account.emojis); } }; const actSession = () => { const s = session(); return s.account ? s : undefined; }; const onBookmark = async () => { const status = remoteToot()!; const client = actSession()!.client; setRemoteToot( Object.assign({}, status, { bookmarked: !status.bookmarked, }), ); const result = await (status.bookmarked ? client.v1.statuses.$select(status.id).unbookmark() : client.v1.statuses.$select(status.id).bookmark()); setRemoteToot(result); }; const onBoost = async () => { const status = remoteToot()!; const client = actSession()!.client; vibrate(50); setRemoteToot( Object.assign({}, status, { reblogged: !status.reblogged, }), ); const result = await (status.reblogged ? client.v1.statuses.$select(status.id).unreblog() : client.v1.statuses.$select(status.id).reblog()); vibrate([20, 30]); setRemoteToot(result.reblog!); }; const onFav = async () => { const status = remoteToot()!; const client = actSession()!.client; setRemoteToot( Object.assign({}, status, { favourited: !status.favourited, }), ); const result = await (status.favourited ? client.v1.statuses.$select(status.id).favourite() : client.v1.statuses.$select(status.id).unfavourite()); setRemoteToot(result); }; const defaultMentions = () => { const tootAcct = remoteToot()?.reblog?.account ?? remoteToot()?.account; if (!tootAcct) { return; } const others = ancestors.list.map((x) => ancestors.get(x)!.value.account); const values = [tootAcct, ...others].map((x) => `@${x.acct}`); return Array.from(new Set(values).keys()); }; const handleMainTootClick = ( event: MouseEvent & { currentTarget: HTMLElement }, ) => { const actionableElement = findElementActionable( event.target as HTMLElement, event.currentTarget, ); if (actionableElement) { if (actionableElement.dataset.action === "acct") { event.stopPropagation(); const target = actionableElement as HTMLAnchorElement; const acct = encodeURIComponent( target.dataset.client || `@${new URL(target.href).origin}`, ); navigate(`/${acct}/profile/${target.dataset.acctId}`); return; } else { console.warn("unknown action", actionableElement.dataset.rel); } } }; css` .name :global(img) { max-height: 1em; } .name { display: grid; grid-template-columns: 1fr auto; height: calc(var(--title-size) * var(--title-line-height)); > :first-child { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } } `; return ( <Scaffold topbar={ <AppBar sx={{ backgroundColor: "var(--tutu-color-surface)", color: "var(--tutu-color-on-surface)", }} elevation={1} position="static" > <Toolbar variant="dense" sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }} > <IconButton color="inherit" onClick={[navigate, -1]} disableRipple> <CloseIcon /> </IconButton> <Title component="div" class="name" use:solid-styled> <span ref={(e: HTMLElement) => createRenderEffect( () => (e.innerHTML = tootDisplayName() ?? "Someone"), ) } ></span> <span>'s toot</span> </Title> </Toolbar> </AppBar> } > <TimeSourceProvider value={time}> <TootList threads={ancestors.list} onUnknownThread={ancestors.getPath} onChangeToot={ancestors.set} /> <article> <Show when={toot()}> <RegularToot id={`toot-${toot()!.id}`} class={cards.card} style={{ "scroll-margin-top": "calc(var(--scaffold-topbar-height) + 20px)", }} status={toot()!} actionable={!!actSession()} evaluated={true} onBookmark={onBookmark} onRetoot={onBoost} onFavourite={onFav} onClick={handleMainTootClick} ></RegularToot> </Show> </article> <Show when={session()!.account}> <TootComposer mentions={defaultMentions()} profile={session().account!} replyToDisplayName={toot()?.account?.displayName || ""} client={session().client} onSent={() => refetchContext()} inReplyToId={remoteToot()?.reblog?.id ?? remoteToot()?.id} /> </Show> <Show when={tootContextErrorUncaught.loading}> <div style={{ display: "flex", "justify-content": "center", "margin-block": "12px", }} > <CircularProgress style="width: 1.5em; height: 1.5em;" /> </div> </Show> <TootList threads={descendants.list} onUnknownThread={descendants.getPath} onChangeToot={descendants.set} /> </TimeSourceProvider> <div style={{ height: "var(--safe-area-inset-bottom, 0)" }}></div> </Scaffold> ); }; export default TootBottomSheet;