TootList: mentions in toot links to profile page
All checks were successful
/ depoly (push) Successful in 1m17s
All checks were successful
/ depoly (push) Successful in 1m17s
- bug: main toot in TootBottomSheet does not link profile
This commit is contained in:
parent
92cc451811
commit
31b27237cd
5 changed files with 165 additions and 91 deletions
|
@ -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,8 +63,7 @@ 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);
|
||||||
|
@ -87,7 +79,24 @@ export function createTimelineSnapshot<
|
||||||
node.value = value;
|
node.value = value;
|
||||||
lookup.set(id, node);
|
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,
|
||||||
|
|
|
@ -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%",
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -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(
|
||||||
|
props: {
|
||||||
status: mastodon.v1.Status;
|
status: mastodon.v1.Status;
|
||||||
now: Date;
|
now: Date;
|
||||||
onClick?: JSX.EventHandlerUnion<HTMLDivElement, MouseEvent>;
|
} & 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}>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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,12 +107,36 @@ const TootList: Component<{
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onItemClick = (status: mastodon.v1.Status, event: MouseEvent) => {
|
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 {
|
||||||
if (status.id !== expandedThreadId()) {
|
if (status.id !== expandedThreadId()) {
|
||||||
setExpandedThreadId((x) => (x ? undefined : status.id));
|
setExpandedThreadId((x) => (x ? undefined : status.id));
|
||||||
} else {
|
} else {
|
||||||
openFullScreenToot(status, event.currentTarget as HTMLElement);
|
openFullScreenToot(status, event.currentTarget as HTMLElement);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const checkIsExpendedId = createSelector(expandedThreadId);
|
const checkIsExpendedId = createSelector(expandedThreadId);
|
||||||
|
|
Loading…
Reference in a new issue