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;
export function createTimelineSnapshot<
T extends Timeline<mastodon.DefaultPaginationParams>,
>(timeline: Accessor<T>, limit: Accessor<number>) {
export function createTimelineControlsForArray(
status: () => mastodon.v1.Status[] | undefined,
) {
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"][]);
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<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,
{
refetch,

View file

@ -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%",
}}

View file

@ -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<HTMLDivElement>;
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 (
<div
ref={(ref) => {
@ -60,6 +64,21 @@ const TootContentView: Component<TootContentViewProps> = (props) => {
: 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}
></div>
@ -212,16 +231,18 @@ function TootActionGroup<T extends mastodon.v1.Status>(
);
}
function TootAuthorGroup(props: {
status: mastodon.v1.Status;
now: Date;
onClick?: JSX.EventHandlerUnion<HTMLDivElement, MouseEvent>;
}) {
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 (
<div class={tootStyle.tootAuthorGrp} onClick={props.onClick}>
<div class={tootStyle.tootAuthorGrp} {...rest}>
<Img src={toot().account.avatar} class={tootStyle.tootAvatar} />
<div class={tootStyle.tootAuthorNameGrp}>
<Body2
@ -236,7 +257,7 @@ function TootAuthorGroup(props: {
}}
/>
<time datetime={toot().createdAt}>
{formatRelative(toot().createdAt, props.now, {
{formatRelative(toot().createdAt, managed.now, {
locale: dateFnLocale(),
})}
</time>
@ -431,10 +452,17 @@ const RegularToot: Component<TootCardProps> = (props) => {
</span>
</div>
</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
source={toot().content}
emojis={toot().emojis}
mentions={toot().mentions}
class={tootStyle.tootContent}
/>
<Show when={toot().card}>

View file

@ -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) => {
<IconButton color="inherit" onClick={[navigate, -1]} disableRipple>
{pushedCount() > 0 ? <BackIcon /> : <CloseIcon />}
</IconButton>
<Title
component="div"
class="name"
use:solid-styled
>
<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>

View file

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