diff --git a/src/masto/timelines.ts b/src/masto/timelines.ts index 24c580c..b8b38b6 100644 --- a/src/masto/timelines.ts +++ b/src/masto/timelines.ts @@ -17,22 +17,15 @@ type Timeline = { type TimelineParamsOf = T extends Timeline ? P : never; -export function createTimelineSnapshot< - T extends Timeline, ->(timeline: Accessor, limit: Accessor) { +export function createTimelineControlsForArray( + status: () => mastodon.v1.Status[] | undefined, +) { const lookup = new ReactiveMap>(); - const [shot, { refetch }] = createResource( - () => [timeline(), limit()] as const, - async ([tl, limit]) => { - const ls = await tl.list({ limit }).next(); - return ls.value; - }, - ); const [threads, setThreads] = createStore([] as mastodon.v1.Status["id"][]); createEffect(() => { - const nls = catchError(shot, (e) => console.error(e)); + const nls = status(); if (!nls) return; setThreads([]); @@ -70,24 +63,40 @@ export function createTimelineSnapshot< setThreads(newThreads); }); - 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); - }, + 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); + }, + }; +} + +export function createTimelineSnapshot< + T extends Timeline, +>(timeline: Accessor, limit: Accessor) { + const [shot, { refetch }] = createResource( + () => [timeline(), limit()] as const, + async ([tl, limit]) => { + const ls = await tl.list({ limit }).next(); + return ls.value; + }, + ); + + const controls = createTimelineControlsForArray(shot); + + return [ + controls, shot, { refetch, diff --git a/src/profiles/Profile.tsx b/src/profiles/Profile.tsx index f3ba85e..55b37c6 100644 --- a/src/profiles/Profile.tsx +++ b/src/profiles/Profile.tsx @@ -1,4 +1,5 @@ import { + catchError, createRenderEffect, createResource, createSignal, @@ -77,13 +78,18 @@ const Profile: Component = () => { ); onCleanup(() => obx.disconnect()); - const [profile] = createResource( + const [profileErrorUncaught] = createResource( () => [session().client, params.id] as const, async ([client, id]) => { return await client.v1.accounts.$select(id).fetch(); }, ); + const profile = () => + catchError(profileErrorUncaught, (err) => { + console.error(err); + }); + const [recentTootFilter, setRecentTootFilter] = createSignal({ boost: false, reply: true, @@ -271,7 +277,7 @@ const Profile: Component = () => { ref={(e) => obx.observe(e)} src={bannerImg()} style={{ - "object-fit": "contain", + "object-fit": "cover", width: "100%", height: "100%", }} diff --git a/src/timelines/RegularToot.tsx b/src/timelines/RegularToot.tsx index 59d8c28..3487ae3 100644 --- a/src/timelines/RegularToot.tsx +++ b/src/timelines/RegularToot.tsx @@ -5,20 +5,13 @@ import { type JSX, Show, createRenderEffect, - createSignal, createEffect, - type Accessor, + createMemo, } from "solid-js"; import tootStyle from "./toot.module.css"; import { formatRelative } from "date-fns"; import Img from "../material/Img.js"; -import { - Body1, - Body2, - Caption, - Subheading, - Title, -} from "../material/typography.js"; +import { Body1, Body2, Title } from "../material/typography.js"; import { css } from "solid-styled"; import { BookmarkAddOutlined, @@ -32,7 +25,7 @@ import { } from "@suid/icons-material"; import { useTimeSource } from "../platform/timesrc.js"; import { resolveCustomEmoji } from "../masto/toot.js"; -import { Divider, IconButton } from "@suid/material"; +import { Divider } from "@suid/material"; import cardStyle from "../material/cards.module.css"; import Button from "../material/Button.js"; import MediaAttachmentGrid from "./MediaAttachmentGrid.js"; @@ -43,13 +36,24 @@ import { canShare, share } from "../platform/share"; import { makeAcctText, useDefaultSession } from "../masto/clients"; import { useNavigate } from "@solidjs/router"; +function preventDefault(event: Event) { + event.preventDefault(); +} + type TootContentViewProps = { source?: string; emojis?: mastodon.v1.CustomEmoji[]; + mentions: mastodon.v1.StatusMention[]; } & JSX.HTMLAttributes; const TootContentView: Component = (props) => { - const [managed, rest] = splitProps(props, ["source", "emojis"]); + const session = useDefaultSession(); + const [managed, rest] = splitProps(props, ["source", "emojis", "mentions"]); + + const clientFinder = createMemo(() => + session() ? makeAcctText(session()!) : undefined, + ); + return (
{ @@ -60,6 +64,21 @@ const TootContentView: Component = (props) => { : managed.source : ""; }); + + createRenderEffect(() => { + const finder = clientFinder(); + for (const mention of props.mentions) { + const elements = ref.querySelectorAll( + `a[href='${mention.url}']`, + ); + for (const e of elements) { + e.onclick = preventDefault; + e.dataset.rel = "acct"; + e.dataset.client = finder; + e.dataset.acctId = mention.id.toString(); + } + } + }); }} {...rest} >
@@ -212,16 +231,18 @@ function TootActionGroup( ); } -function TootAuthorGroup(props: { - status: mastodon.v1.Status; - now: Date; - onClick?: JSX.EventHandlerUnion; -}) { - const toot = () => props.status; +function TootAuthorGroup( + props: { + status: mastodon.v1.Status; + now: Date; + } & JSX.HTMLElementTags["div"], +) { + const [managed, rest] = splitProps(props, ["status", "now"]); + const toot = () => managed.status; const dateFnLocale = useDateFnLocale(); return ( -
+
@@ -431,10 +452,17 @@ const RegularToot: Component = (props) => {
- + diff --git a/src/timelines/TootBottomSheet.tsx b/src/timelines/TootBottomSheet.tsx index b917c10..cebd6de 100644 --- a/src/timelines/TootBottomSheet.tsx +++ b/src/timelines/TootBottomSheet.tsx @@ -25,6 +25,8 @@ 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; @@ -48,7 +50,7 @@ const TootBottomSheet: Component = (props) => { const time = createTimeSource(); const [isInTyping, setInTyping] = createSignal(false); const acctText = () => decodeURIComponent(params.acct); - const session = useSessionForAcctStr(acctText) + const session = useSessionForAcctStr(acctText); const pushedCount = () => { return location.state?.tootBottomSheetPushedCount || 0; @@ -84,20 +86,24 @@ const TootBottomSheet: Component = (props) => { }, ); - const ancestors = () => tootContext()?.ancestors ?? []; - const descendants = () => tootContext()?.descendants ?? []; + const ancestors = createTimelineControlsForArray( + () => tootContext()?.ancestors, + ); + const descendants = createTimelineControlsForArray( + () => tootContext()?.descendants, + ); createEffect(() => { - if (ancestors().length > 0) { + 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 t = toot()?.reblog ?? toot(); + const name = t?.account.displayName ?? "Someone"; + return `${name}'s toot`; + }); const tootDisplayName = () => { const t = toot()?.reblog ?? toot(); @@ -174,7 +180,7 @@ const TootBottomSheet: Component = (props) => { return; } - const others = ancestors().map((x) => x.account); + 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()); @@ -216,11 +222,7 @@ const TootBottomSheet: Component = (props) => { {pushedCount() > 0 ? : } - + <Title component="div" class="name" use:solid-styled> <span ref={(e: HTMLElement) => createRenderEffect( @@ -235,17 +237,11 @@ const TootBottomSheet: Component = (props) => { } > <TimeSourceProvider value={time}> - <For each={ancestors()}> - {(item) => ( - <RegularToot - id={`toot-${item.id}`} - class={cards.card} - status={item} - actionable={false} - onClick={[switchContext, item]} - ></RegularToot> - )} - </For> + <TootList + threads={ancestors.list} + onUnknownThread={ancestors.getPath} + onChangeToot={ancestors.set} + /> <article> <Show when={toot()}> @@ -291,17 +287,11 @@ const TootBottomSheet: Component = (props) => { </div> </Show> - <For each={descendants()}> - {(item) => ( - <RegularToot - id={`toot-${item.id}`} - class={cards.card} - status={item} - actionable={false} - onClick={[switchContext, item]} - ></RegularToot> - )} - </For> + <TootList + threads={descendants.list} + onUnknownThread={descendants.getPath} + onChangeToot={descendants.set} + /> </TimeSourceProvider> <div style={{ height: "var(--safe-area-inset-bottom, 0)" }}></div> </Scaffold> diff --git a/src/timelines/TootList.tsx b/src/timelines/TootList.tsx index 9efea10..f62f659 100644 --- a/src/timelines/TootList.tsx +++ b/src/timelines/TootList.tsx @@ -9,12 +9,29 @@ import { import { type mastodon } from "masto"; import { vibrate } from "../platform/hardware"; import Thread from "./Thread.jsx"; -import { useDefaultSession } from "../masto/clients"; +import { makeAcctText, useDefaultSession } from "../masto/clients"; import { useHeroSource } from "../platform/anim"; import { HERO as BOTTOM_SHEET_HERO } from "../material/BottomSheet"; import { setCache as setTootBottomSheetCache } from "./TootBottomSheet"; import { useNavigate } from "@solidjs/router"; +/** + * find bottom-to-top the element with `data-rel`. + */ +function findElementActionable( + element: HTMLElement, + top: HTMLElement, +): HTMLElement | undefined { + let current = element; + while (!current.dataset.rel) { + if (!current.parentElement || current.parentElement === top) { + return undefined; + } + current = current.parentElement; + } + return current; +} + const TootList: Component<{ ref?: Ref<HTMLDivElement>; threads: string[]; @@ -90,11 +107,35 @@ const TootList: Component<{ }); }; - const onItemClick = (status: mastodon.v1.Status, event: MouseEvent) => { - if (status.id !== expandedThreadId()) { - setExpandedThreadId((x) => (x ? undefined : status.id)); + const onItemClick = ( + status: mastodon.v1.Status, + event: MouseEvent & { target: HTMLElement; currentTarget: HTMLElement }, + ) => { + const actionableElement = findElementActionable( + event.target, + event.currentTarget, + ); + + if (actionableElement) { + if (actionableElement.dataset.rel === "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}`); + } else { + console.warn("unknown action", actionableElement.dataset.rel); + } } else { - openFullScreenToot(status, event.currentTarget as HTMLElement); + if (status.id !== expandedThreadId()) { + setExpandedThreadId((x) => (x ? undefined : status.id)); + } else { + openFullScreenToot(status, event.currentTarget as HTMLElement); + } } };