TootList: mentions in toot links to profile page
All checks were successful
/ depoly (push) Successful in 1m17s

- bug: main toot in TootBottomSheet does not link
  profile
This commit is contained in:
thislight 2024-10-30 19:25:58 +08:00
parent 92cc451811
commit 31b27237cd
No known key found for this signature in database
GPG key ID: FCFE5192241CCD4E
5 changed files with 165 additions and 91 deletions

View file

@ -17,22 +17,15 @@ type Timeline<T extends mastodon.DefaultPaginationParams> = {
type TimelineParamsOf<T> = T extends Timeline<infer P> ? P : never; type TimelineParamsOf<T> = T extends Timeline<infer P> ? P : never;
export function createTimelineSnapshot< export function createTimelineControlsForArray(
T extends Timeline<mastodon.DefaultPaginationParams>, status: () => mastodon.v1.Status[] | undefined,
>(timeline: Accessor<T>, limit: Accessor<number>) { ) {
const lookup = new ReactiveMap<string, TreeNode<mastodon.v1.Status>>(); const lookup = new ReactiveMap<string, TreeNode<mastodon.v1.Status>>();
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"][]); const [threads, setThreads] = createStore([] as mastodon.v1.Status["id"][]);
createEffect(() => { createEffect(() => {
const nls = catchError(shot, (e) => console.error(e)); const nls = status();
if (!nls) return; if (!nls) return;
setThreads([]); setThreads([]);
@ -70,24 +63,40 @@ export function createTimelineSnapshot<
setThreads(newThreads); setThreads(newThreads);
}); });
return [ return {
{ list: threads,
list: threads, get(id: string) {
get(id: string) { return lookup.get(id);
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);
},
}, },
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<mastodon.DefaultPaginationParams>,
>(timeline: Accessor<T>, limit: Accessor<number>) {
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, shot,
{ {
refetch, refetch,

View file

@ -1,4 +1,5 @@
import { import {
catchError,
createRenderEffect, createRenderEffect,
createResource, createResource,
createSignal, createSignal,
@ -77,13 +78,18 @@ const Profile: Component = () => {
); );
onCleanup(() => obx.disconnect()); onCleanup(() => obx.disconnect());
const [profile] = createResource( const [profileErrorUncaught] = createResource(
() => [session().client, params.id] as const, () => [session().client, params.id] as const,
async ([client, id]) => { async ([client, id]) => {
return await client.v1.accounts.$select(id).fetch(); return await client.v1.accounts.$select(id).fetch();
}, },
); );
const profile = () =>
catchError(profileErrorUncaught, (err) => {
console.error(err);
});
const [recentTootFilter, setRecentTootFilter] = createSignal({ const [recentTootFilter, setRecentTootFilter] = createSignal({
boost: false, boost: false,
reply: true, reply: true,
@ -271,7 +277,7 @@ const Profile: Component = () => {
ref={(e) => obx.observe(e)} ref={(e) => obx.observe(e)}
src={bannerImg()} src={bannerImg()}
style={{ style={{
"object-fit": "contain", "object-fit": "cover",
width: "100%", width: "100%",
height: "100%", height: "100%",
}} }}

View file

@ -5,20 +5,13 @@ import {
type JSX, type JSX,
Show, Show,
createRenderEffect, createRenderEffect,
createSignal,
createEffect, createEffect,
type Accessor, createMemo,
} from "solid-js"; } from "solid-js";
import tootStyle from "./toot.module.css"; import tootStyle from "./toot.module.css";
import { formatRelative } from "date-fns"; import { formatRelative } from "date-fns";
import Img from "../material/Img.js"; import Img from "../material/Img.js";
import { import { Body1, Body2, Title } from "../material/typography.js";
Body1,
Body2,
Caption,
Subheading,
Title,
} from "../material/typography.js";
import { css } from "solid-styled"; import { css } from "solid-styled";
import { import {
BookmarkAddOutlined, BookmarkAddOutlined,
@ -32,7 +25,7 @@ import {
} from "@suid/icons-material"; } from "@suid/icons-material";
import { useTimeSource } from "../platform/timesrc.js"; import { useTimeSource } from "../platform/timesrc.js";
import { resolveCustomEmoji } from "../masto/toot.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 cardStyle from "../material/cards.module.css";
import Button from "../material/Button.js"; import Button from "../material/Button.js";
import MediaAttachmentGrid from "./MediaAttachmentGrid.js"; import MediaAttachmentGrid from "./MediaAttachmentGrid.js";
@ -43,13 +36,24 @@ import { canShare, share } from "../platform/share";
import { makeAcctText, useDefaultSession } from "../masto/clients"; import { makeAcctText, useDefaultSession } from "../masto/clients";
import { useNavigate } from "@solidjs/router"; import { useNavigate } from "@solidjs/router";
function preventDefault(event: Event) {
event.preventDefault();
}
type TootContentViewProps = { type TootContentViewProps = {
source?: string; source?: string;
emojis?: mastodon.v1.CustomEmoji[]; emojis?: mastodon.v1.CustomEmoji[];
mentions: mastodon.v1.StatusMention[];
} & JSX.HTMLAttributes<HTMLDivElement>; } & JSX.HTMLAttributes<HTMLDivElement>;
const TootContentView: Component<TootContentViewProps> = (props) => { const TootContentView: Component<TootContentViewProps> = (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 ( return (
<div <div
ref={(ref) => { ref={(ref) => {
@ -60,6 +64,21 @@ const TootContentView: Component<TootContentViewProps> = (props) => {
: managed.source : managed.source
: ""; : "";
}); });
createRenderEffect(() => {
const finder = clientFinder();
for (const mention of props.mentions) {
const elements = ref.querySelectorAll<HTMLAnchorElement>(
`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} {...rest}
></div> ></div>
@ -212,16 +231,18 @@ function TootActionGroup<T extends mastodon.v1.Status>(
); );
} }
function TootAuthorGroup(props: { function TootAuthorGroup(
status: mastodon.v1.Status; props: {
now: Date; status: mastodon.v1.Status;
onClick?: JSX.EventHandlerUnion<HTMLDivElement, MouseEvent>; now: Date;
}) { } & JSX.HTMLElementTags["div"],
const toot = () => props.status; ) {
const [managed, rest] = splitProps(props, ["status", "now"]);
const toot = () => managed.status;
const dateFnLocale = useDateFnLocale(); const dateFnLocale = useDateFnLocale();
return ( return (
<div class={tootStyle.tootAuthorGrp} onClick={props.onClick}> <div class={tootStyle.tootAuthorGrp} {...rest}>
<Img src={toot().account.avatar} class={tootStyle.tootAvatar} /> <Img src={toot().account.avatar} class={tootStyle.tootAvatar} />
<div class={tootStyle.tootAuthorNameGrp}> <div class={tootStyle.tootAuthorNameGrp}>
<Body2 <Body2
@ -236,7 +257,7 @@ function TootAuthorGroup(props: {
}} }}
/> />
<time datetime={toot().createdAt}> <time datetime={toot().createdAt}>
{formatRelative(toot().createdAt, props.now, { {formatRelative(toot().createdAt, managed.now, {
locale: dateFnLocale(), locale: dateFnLocale(),
})} })}
</time> </time>
@ -431,10 +452,17 @@ const RegularToot: Component<TootCardProps> = (props) => {
</span> </span>
</div> </div>
</Show> </Show>
<TootAuthorGroup status={toot()} now={now()} onClick={openProfile} /> <TootAuthorGroup
status={toot()}
now={now()}
data-rel="acct"
data-client={session() ? makeAcctText(session()!) : undefined}
data-acct-id={toot().account.id}
/>
<TootContentView <TootContentView
source={toot().content} source={toot().content}
emojis={toot().emojis} emojis={toot().emojis}
mentions={toot().mentions}
class={tootStyle.tootContent} class={tootStyle.tootContent}
/> />
<Show when={toot().card}> <Show when={toot().card}>

View file

@ -25,6 +25,8 @@ import { vibrate } from "../platform/hardware";
import { createTimeSource, TimeSourceProvider } from "../platform/timesrc"; import { createTimeSource, TimeSourceProvider } from "../platform/timesrc";
import TootComposer from "./TootComposer"; import TootComposer from "./TootComposer";
import { useDocumentTitle } from "../utils"; import { useDocumentTitle } from "../utils";
import { createTimelineControlsForArray } from "../masto/timelines";
import TootList from "./TootList";
let cachedEntry: [string, mastodon.v1.Status] | undefined; let cachedEntry: [string, mastodon.v1.Status] | undefined;
@ -48,7 +50,7 @@ const TootBottomSheet: Component = (props) => {
const time = createTimeSource(); const time = createTimeSource();
const [isInTyping, setInTyping] = createSignal(false); const [isInTyping, setInTyping] = createSignal(false);
const acctText = () => decodeURIComponent(params.acct); const acctText = () => decodeURIComponent(params.acct);
const session = useSessionForAcctStr(acctText) const session = useSessionForAcctStr(acctText);
const pushedCount = () => { const pushedCount = () => {
return location.state?.tootBottomSheetPushedCount || 0; return location.state?.tootBottomSheetPushedCount || 0;
@ -84,20 +86,24 @@ const TootBottomSheet: Component = (props) => {
}, },
); );
const ancestors = () => tootContext()?.ancestors ?? []; const ancestors = createTimelineControlsForArray(
const descendants = () => tootContext()?.descendants ?? []; () => tootContext()?.ancestors,
);
const descendants = createTimelineControlsForArray(
() => tootContext()?.descendants,
);
createEffect(() => { createEffect(() => {
if (ancestors().length > 0) { if (ancestors.list.length > 0) {
document.querySelector(`#toot-${toot()!.id}`)?.scrollIntoView(); document.querySelector(`#toot-${toot()!.id}`)?.scrollIntoView();
} }
}); });
useDocumentTitle(() => { useDocumentTitle(() => {
const t = toot()?.reblog ?? toot() const t = toot()?.reblog ?? toot();
const name = t?.account.displayName ?? "Someone" const name = t?.account.displayName ?? "Someone";
return `${name}'s toot` return `${name}'s toot`;
}) });
const tootDisplayName = () => { const tootDisplayName = () => {
const t = toot()?.reblog ?? toot(); const t = toot()?.reblog ?? toot();
@ -174,7 +180,7 @@ const TootBottomSheet: Component = (props) => {
return; 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}`); const values = [tootAcct, ...others].map((x) => `@${x.acct}`);
return Array.from(new Set(values).keys()); return Array.from(new Set(values).keys());
@ -216,11 +222,7 @@ const TootBottomSheet: Component = (props) => {
<IconButton color="inherit" onClick={[navigate, -1]} disableRipple> <IconButton color="inherit" onClick={[navigate, -1]} disableRipple>
{pushedCount() > 0 ? <BackIcon /> : <CloseIcon />} {pushedCount() > 0 ? <BackIcon /> : <CloseIcon />}
</IconButton> </IconButton>
<Title <Title component="div" class="name" use:solid-styled>
component="div"
class="name"
use:solid-styled
>
<span <span
ref={(e: HTMLElement) => ref={(e: HTMLElement) =>
createRenderEffect( createRenderEffect(
@ -235,17 +237,11 @@ const TootBottomSheet: Component = (props) => {
} }
> >
<TimeSourceProvider value={time}> <TimeSourceProvider value={time}>
<For each={ancestors()}> <TootList
{(item) => ( threads={ancestors.list}
<RegularToot onUnknownThread={ancestors.getPath}
id={`toot-${item.id}`} onChangeToot={ancestors.set}
class={cards.card} />
status={item}
actionable={false}
onClick={[switchContext, item]}
></RegularToot>
)}
</For>
<article> <article>
<Show when={toot()}> <Show when={toot()}>
@ -291,17 +287,11 @@ const TootBottomSheet: Component = (props) => {
</div> </div>
</Show> </Show>
<For each={descendants()}> <TootList
{(item) => ( threads={descendants.list}
<RegularToot onUnknownThread={descendants.getPath}
id={`toot-${item.id}`} onChangeToot={descendants.set}
class={cards.card} />
status={item}
actionable={false}
onClick={[switchContext, item]}
></RegularToot>
)}
</For>
</TimeSourceProvider> </TimeSourceProvider>
<div style={{ height: "var(--safe-area-inset-bottom, 0)" }}></div> <div style={{ height: "var(--safe-area-inset-bottom, 0)" }}></div>
</Scaffold> </Scaffold>

View file

@ -9,12 +9,29 @@ import {
import { type mastodon } from "masto"; import { type mastodon } from "masto";
import { vibrate } from "../platform/hardware"; import { vibrate } from "../platform/hardware";
import Thread from "./Thread.jsx"; import Thread from "./Thread.jsx";
import { useDefaultSession } from "../masto/clients"; import { makeAcctText, useDefaultSession } from "../masto/clients";
import { useHeroSource } from "../platform/anim"; import { useHeroSource } from "../platform/anim";
import { HERO as BOTTOM_SHEET_HERO } from "../material/BottomSheet"; import { HERO as BOTTOM_SHEET_HERO } from "../material/BottomSheet";
import { setCache as setTootBottomSheetCache } from "./TootBottomSheet"; import { setCache as setTootBottomSheetCache } from "./TootBottomSheet";
import { useNavigate } from "@solidjs/router"; 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<{ const TootList: Component<{
ref?: Ref<HTMLDivElement>; ref?: Ref<HTMLDivElement>;
threads: string[]; threads: string[];
@ -90,11 +107,35 @@ const TootList: Component<{
}); });
}; };
const onItemClick = (status: mastodon.v1.Status, event: MouseEvent) => { const onItemClick = (
if (status.id !== expandedThreadId()) { status: mastodon.v1.Status,
setExpandedThreadId((x) => (x ? undefined : status.id)); 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 { } else {
openFullScreenToot(status, event.currentTarget as HTMLElement); if (status.id !== expandedThreadId()) {
setExpandedThreadId((x) => (x ? undefined : status.id));
} else {
openFullScreenToot(status, event.currentTarget as HTMLElement);
}
} }
}; };