Compare commits

..

4 commits

Author SHA1 Message Date
thislight
5742932c86
fix #35: pass correct value to TootActionGroup
All checks were successful
/ depoly (push) Successful in 1m19s
* add createDefaultTootEnv
2024-12-26 22:06:15 +08:00
thislight
6dd6065711
TootBottomSheet: use new cache semantic 2024-12-26 20:09:03 +08:00
thislight
5ab0d4d0a2
masto: add fetchStatus
* also add types RemoteServer and AccountKey
2024-12-26 20:05:41 +08:00
thislight
97bd6da9ac
add CachedFetch 2024-12-26 20:04:26 +08:00
11 changed files with 409 additions and 282 deletions

View file

@ -6,10 +6,19 @@ import {
} from "masto"; } from "masto";
import { createMastoClientFor } from "../masto/clients"; import { createMastoClientFor } from "../masto/clients";
export type Account = { export type RemoteServer = {
site: string; site: string;
accessToken: string; };
export type AccountKey = RemoteServer & {
accessToken: string;
};
export function isAccountKey(object: RemoteServer): object is AccountKey {
return !!(object as Record<string, unknown>)["accessToken"];
}
export type Account = AccountKey & {
tokenType: string; tokenType: string;
scope: string; scope: string;
createdAt: number; createdAt: number;
@ -17,6 +26,10 @@ export type Account = {
inf?: mastodon.v1.AccountCredentials; inf?: mastodon.v1.AccountCredentials;
}; };
export function isAccount(object: RemoteServer) {
return isAccountKey(object) && !!(object as Record<string, unknown>)["tokenType"];
}
export const $accounts = persistentAtom<Account[]>("accounts", [], { export const $accounts = persistentAtom<Account[]>("accounts", [], {
encode: JSON.stringify, encode: JSON.stringify,
decode: JSON.parse, decode: JSON.parse,

23
src/masto/base.ts Normal file
View file

@ -0,0 +1,23 @@
import { createCacheBucket } from "~platform/cache";
export const cacheBucket = /* @__PURE__ */ createCacheBucket("mastodon");
export function toSmallCamelCase<T>(object: T) :T {
if (!object || typeof object !== "object") {
return object;
} else if (Array.isArray(object)) {
return object.map(toSmallCamelCase) as T
}
const result = {} as Record<keyof any, unknown>;
for (const k in object) {
const value = toSmallCamelCase(object[k])
const nk =
typeof k === "string"
? k.replace(/_(.)/g, (_, match) => match.toUpperCase())
: k;
result[nk] = value;
}
return result as T;
}

View file

@ -7,7 +7,7 @@ import {
untrack, untrack,
useContext, useContext,
} from "solid-js"; } from "solid-js";
import { Account } from "../accounts/stores"; import { Account, type RemoteServer } from "../accounts/stores";
import { createRestAPIClient, mastodon } from "masto"; import { createRestAPIClient, mastodon } from "masto";
import { useLocation } from "@solidjs/router"; import { useLocation } from "@solidjs/router";
import { useNavigator } from "~platform/StackedRouter"; import { useNavigator } from "~platform/StackedRouter";
@ -115,7 +115,7 @@ export function useDefaultSession() {
* - If the username is not present, any session on the site is returned; or, * - If the username is not present, any session on the site is returned; or,
* - If no available session available for the pattern, an unauthorised session is returned. * - If no available session available for the pattern, an unauthorised session is returned.
* *
* In an unauthorised session, the `.account` is `undefined` and the `client` is an * In an unauthorised session, the `.account` is {@link RemoteServer} and the `client` is an
* unauthorised client for the site. This client may not available for some operations. * unauthorised client for the site. This client may not available for some operations.
*/ */
export function useSessionForAcctStr(acct: Accessor<string>) { export function useSessionForAcctStr(acct: Accessor<string>) {
@ -131,8 +131,8 @@ export function useSessionForAcctStr(acct: Accessor<string>) {
return ( return (
authedSession ?? { authedSession ?? {
client: createUnauthorizedClient(inputSite), client: createUnauthorizedClient(inputSite),
account: undefined, account: { site: inputSite } as RemoteServer, // TODO: we need some security checks here?
} } as const
); );
}); });
} }

30
src/masto/statuses.ts Normal file
View file

@ -0,0 +1,30 @@
import { CachedFetch } from "~platform/cache";
import { cacheBucket, toSmallCamelCase } from "./base";
import {
isAccountKey,
type RemoteServer,
} from "../accounts/stores";
import type { mastodon } from "masto";
export const fetchStatus = /* @__PURE__ */ new CachedFetch(
cacheBucket,
(session: RemoteServer, id: string) => {
const headers = new Headers({
Accept: "application/json",
});
if (isAccountKey(session)) {
headers.set("Authorization", `Bearer ${session.accessToken}`);
}
return {
url: new URL(`./api/v1/statuses/${id}`, session.site).toString(),
headers,
};
},
async (response) => {
return toSmallCamelCase(
await response.json(),
) as unknown as mastodon.v1.Status;
},
);

145
src/platform/cache.ts Normal file
View file

@ -0,0 +1,145 @@
import { addMinutes, formatRFC7231 } from "date-fns";
import {
createRenderEffect,
createResource,
untrack,
} from "solid-js";
export function createCacheBucket(name: string) {
let bucket: Cache | undefined;
return async () => {
if (bucket) {
return bucket;
}
bucket = await self.caches.open(name);
return bucket;
};
}
export type FetchRequest = {
url: string;
headers?: HeadersInit | Headers;
};
async function searchCache(request: Request) {
return await self.caches.match(request);
}
/**
* Create a {@link fetch} helper with additional caching support.
*/
export class CachedFetch<
Transformer extends (response: Response) => any,
Keyer extends (...args: any[]) => FetchRequest,
> {
private cacheBucket: () => Promise<Cache>;
keyFor: Keyer;
private transform: Transformer;
constructor(
cacheBucket: () => Promise<Cache>,
keyFor: Keyer,
tranformer: Transformer,
) {
this.cacheBucket = cacheBucket;
this.keyFor = keyFor;
this.transform = tranformer;
}
private async validateCache(request: Request) {
const buk = await this.cacheBucket();
const response = await fetch(request);
buk.put(request, response.clone());
return response;
}
private request(...args: Parameters<Keyer>) {
const { url, ...init } = this.keyFor(...args);
const request = new Request(url, init);
return request;
}
/**
* Race between the cache and the network result,
* use the fastest result.
*
* The cache will be revalidated.
*/
async fastest(
...args: Parameters<Keyer>
): Promise<Awaited<ReturnType<Transformer>>> {
const request = this.request(...args);
const validating = this.validateCache(request);
const searching = searchCache(request);
const earlyResult = await Promise.race([validating, searching]);
if (earlyResult) {
return await this.transform(earlyResult);
}
return await this.transform(await validating);
}
/**
* Validate and return the result.
*/
async validate(
...args: Parameters<Keyer>
): Promise<Awaited<ReturnType<Transformer>>> {
return await this.transform(
await this.validateCache(this.request(...args)),
);
}
/** Set a response as the cache.
* Recommend to set `Expires` or `Cache-Control` to limit its live time.
*/
async set(key: Parameters<Keyer>, response: Response) {
const buk = await this.cacheBucket();
await buk.put(this.request(...key), response);
}
/** Set a json object as the cache.
* Only available for 5 minutes.
*/
async setJson(key: Parameters<Keyer>, object: unknown) {
const response = new Response(JSON.stringify(object), {
status: 200,
headers: {
"Content-Type": "application/json",
Expires: formatRFC7231(addMinutes(new Date(), 5)),
"X-Cache-Src": "set",
},
});
await this.set(key, response);
}
/**
* Return a resource, using the cache at first, and revalidate
* later.
*/
cachedAndRevalidate(args: () => Parameters<Keyer>) {
const res = createResource(args, (p) => this.validate(...p));
const checkCacheIfStillLoading = async () => {
const saved = await searchCache(this.request(...args()));
if (!saved) {
return;
}
const transformed = await this.transform(saved);
if (res[0].loading) {
res[1].mutate(transformed);
}
};
createRenderEffect(() => void untrack(() => checkCacheIfStillLoading()));
return res;
}
}

View file

@ -1,6 +1,5 @@
import { import {
catchError, catchError,
createRenderEffect,
createResource, createResource,
createSignal, createSignal,
createUniqueId, createUniqueId,
@ -14,7 +13,6 @@ import {
} from "solid-js"; } from "solid-js";
import Scaffold from "~material/Scaffold"; import Scaffold from "~material/Scaffold";
import { import {
AppBar,
Avatar, Avatar,
Button, Button,
Checkbox, Checkbox,
@ -26,7 +24,6 @@ import {
ListItemSecondaryAction, ListItemSecondaryAction,
ListItemText, ListItemText,
MenuItem, MenuItem,
Toolbar,
} from "@suid/material"; } from "@suid/material";
import { import {
Close, Close,
@ -63,6 +60,7 @@ import {
default as ItemSelectionProvider, default as ItemSelectionProvider,
} from "../timelines/toots/ItemSelectionProvider"; } from "../timelines/toots/ItemSelectionProvider";
import AppTopBar from "~material/AppTopBar"; import AppTopBar from "~material/AppTopBar";
import type { Account } from "../accounts/stores";
const Profile: Component = () => { const Profile: Component = () => {
const { pop } = useNavigator(); const { pop } = useNavigator();
@ -117,7 +115,7 @@ const Profile: Component = () => {
}; };
const isCurrentSessionProfile = () => { const isCurrentSessionProfile = () => {
return session().account?.inf?.url === profile()?.url; return (session().account as Account).inf?.url === profile()?.url;
}; };
const [recentTootFilter, setRecentTootFilter] = createSignal({ const [recentTootFilter, setRecentTootFilter] = createSignal({
@ -172,8 +170,8 @@ const Profile: Component = () => {
const sessionDisplayName = createMemo(() => const sessionDisplayName = createMemo(() =>
resolveCustomEmoji( resolveCustomEmoji(
session().account?.inf?.displayName || "", (session().account as Account).inf?.displayName || "",
session().account?.inf?.emojis ?? [], (session().account as Account).inf?.emojis ?? [],
), ),
); );
@ -244,7 +242,7 @@ const Profile: Component = () => {
<Show when={session().account}> <Show when={session().account}>
<MenuItem> <MenuItem>
<ListItemAvatar> <ListItemAvatar>
<Avatar src={session().account?.inf?.avatar} /> <Avatar src={(session().account as Account).inf?.avatar} />
</ListItemAvatar> </ListItemAvatar>
<ListItemText secondary={"Default account"}> <ListItemText secondary={"Default account"}>
<span innerHTML={sessionDisplayName()}></span> <span innerHTML={sessionDisplayName()}></span>
@ -367,7 +365,7 @@ const Profile: Component = () => {
aria-label={`${relationship()?.following ? "Unfollow" : "Follow"} on your home timeline`} aria-label={`${relationship()?.following ? "Unfollow" : "Follow"} on your home timeline`}
> >
<ListItemAvatar> <ListItemAvatar>
<Avatar src={session().account?.inf?.avatar}></Avatar> <Avatar src={(session().account as Account).inf?.avatar}></Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText <ListItemText
secondary={ secondary={

View file

@ -23,6 +23,7 @@ import TootPoll from "./toots/TootPoll";
import TootActionGroup from "./toots/TootActionGroup.js"; import TootActionGroup from "./toots/TootActionGroup.js";
import TootAuthorGroup from "./toots/TootAuthorGroup.js"; import TootAuthorGroup from "./toots/TootAuthorGroup.js";
import "./RegularToot.css"; import "./RegularToot.css";
import { vibrate } from "~platform/hardware.js";
export type TootEnv = { export type TootEnv = {
boost: (value: mastodon.v1.Status) => void; boost: (value: mastodon.v1.Status) => void;
@ -52,6 +53,107 @@ export function useTootEnv() {
return env; return env;
} }
/**
* Create default toot env.
*
* This function does not provides the "reply" action.
*/
export function createDefaultTootEnv(
client: () => mastodon.rest.Client | undefined,
setToot: (id: string, status: mastodon.v1.Status) => void,
): TootEnv {
return {
async bookmark(status: mastodon.v1.Status) {
const c = client();
if (!c) return;
const result = await (status.bookmarked
? c.v1.statuses.$select(status.id).unbookmark()
: c.v1.statuses.$select(status.id).bookmark());
setToot(result.id, result);
},
async boost(status: mastodon.v1.Status) {
const c = client();
if (!c) return;
vibrate(50);
const rootStatus = status.reblog ? status.reblog : status;
const reblogged = rootStatus.reblogged;
if (status.reblog) {
setToot(status.id, {
...status,
reblog: { ...status.reblog!, reblogged: !reblogged },
reblogged: !reblogged,
});
} else {
setToot(status.id, {
...status,
reblogged: !reblogged,
});
}
// modified the original
const result = reblogged
? await c.v1.statuses.$select(status.id).unreblog()
: (await c.v1.statuses.$select(status.id).reblog());
if (status.reblog && !reblogged) {
// When calling /reblog, the result is the boost object (the actor
// is the calling account); for /unreblog, the result is the original
// toot. So we only do this trick only on the reblogging.
setToot(status.id, {
...status,
reblog: result.reblog,
});
} else {
setToot(status.id, result);
}
},
async favourite(status: mastodon.v1.Status) {
const c = client();
if (!c) return;
const ovalue = status.favourited;
setToot(status.id, { ...status, favourited: !ovalue });
const result = ovalue
? await c.v1.statuses.$select(status.id).unfavourite()
: await c.v1.statuses.$select(status.id).favourite();
setToot(status.id, result);
},
async vote(status: mastodon.v1.Status, votes: readonly number[]) {
const c = client();
if (!c) return;
const toot = status.reblog ?? status;
if (!toot.poll) return;
const npoll = await c.v1.polls.$select(toot.poll.id).votes.create({
choices: votes,
});
if (status.reblog) {
setToot(status.id, {
...status,
reblog: {
...status.reblog,
poll: npoll,
},
});
} else {
setToot(status.id, {
...status,
poll: npoll,
});
}
},
};
}
type RegularTootProps = { type RegularTootProps = {
status: mastodon.v1.Status; status: mastodon.v1.Status;
actionable?: boolean; actionable?: boolean;
@ -203,7 +305,7 @@ const RegularToot: Component<RegularTootProps> = (oprops) => {
class={cardStyle.cardNoPad} class={cardStyle.cardNoPad}
style={{ "margin-top": "8px" }} style={{ "margin-top": "8px" }}
/> />
<TootActionGroup value={toot()} class={cardStyle.cardGutSkip} /> <TootActionGroup value={status()} class={cardStyle.cardGutSkip} />
</Show> </Show>
</article> </article>
</> </>

View file

@ -1,26 +1,17 @@
import { useLocation, useParams } from "@solidjs/router"; import { useParams } from "@solidjs/router";
import { import { catchError, createResource, Show, type Component } from "solid-js";
catchError,
createEffect,
createRenderEffect,
createResource,
Show,
type Component,
} from "solid-js";
import Scaffold from "~material/Scaffold"; import Scaffold from "~material/Scaffold";
import { AppBar, CircularProgress, IconButton, Toolbar } from "@suid/material"; import { CircularProgress } from "@suid/material";
import { Title } from "~material/typography"; import { Title } from "~material/typography";
import { Close as CloseIcon } from "@suid/icons-material";
import { useSessionForAcctStr } from "../masto/clients"; import { useSessionForAcctStr } from "../masto/clients";
import { resolveCustomEmoji } from "../masto/toot"; import { resolveCustomEmoji } from "../masto/toot";
import RegularToot, { import RegularToot, {
createDefaultTootEnv,
findElementActionable, findElementActionable,
TootEnvProvider, TootEnvProvider,
} from "./RegularToot"; } from "./RegularToot";
import type { mastodon } from "masto";
import cards from "~material/cards.module.css"; import cards from "~material/cards.module.css";
import { css } from "solid-styled"; import { css } from "solid-styled";
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";
@ -33,18 +24,8 @@ import ItemSelectionProvider, {
createSingluarItemSelection, createSingluarItemSelection,
} from "./toots/ItemSelectionProvider"; } from "./toots/ItemSelectionProvider";
import AppTopBar from "~material/AppTopBar"; import AppTopBar from "~material/AppTopBar";
import { fetchStatus } from "../masto/statuses";
let cachedEntry: [string, mastodon.v1.Status] | undefined; import { type Account } from "../accounts/stores";
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 TootBottomSheet: Component = (props) => {
const params = useParams<{ acct: string; id: string }>(); const params = useParams<{ acct: string; id: string }>();
@ -54,29 +35,19 @@ const TootBottomSheet: Component = (props) => {
const session = useSessionForAcctStr(acctText); const session = useSessionForAcctStr(acctText);
const [, selectionState] = createSingluarItemSelection(); const [, selectionState] = createSingluarItemSelection();
const [remoteToot, { mutate: setRemoteToot }] = createResource( const [remoteToot, { mutate: setRemoteToot }] =
() => [session().client, params.id] as const, fetchStatus.cachedAndRevalidate(
async ([client, id]) => { () => [session().account, params.id] as const,
return await client.v1.statuses.$select(id).fetch(); );
},
);
const toot = () => const toot = () =>
catchError(remoteToot, (error) => { catchError(remoteToot, (error) => {
console.error(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 }] = const [tootContextErrorUncaught, { refetch: refetchContext }] =
createResource( createResource(
() => [session().client, params.id] as const, () => [session().client, toot()?.reblog?.id ?? params.id] as const,
async ([client, id]) => { async ([client, id]) => {
return await client.v1.statuses.$select(id).context.fetch(); return await client.v1.statuses.$select(id).context.fetch();
}, },
@ -94,12 +65,6 @@ const TootBottomSheet: Component = (props) => {
() => tootContext()?.descendants, () => tootContext()?.descendants,
); );
createEffect(() => {
if (ancestors.list.length > 0) {
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";
@ -118,49 +83,10 @@ const TootBottomSheet: Component = (props) => {
return s.account ? s : undefined; return s.account ? s : undefined;
}; };
const onBookmark = async () => { const mainTootEnv = createDefaultTootEnv(
const status = remoteToot()!; () => actSession()?.client,
const client = actSession()!.client; (_, status) => setRemoteToot(status),
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 defaultMentions = () => {
const tootAcct = remoteToot()?.reblog?.account ?? remoteToot()?.account; const tootAcct = remoteToot()?.reblog?.account ?? remoteToot()?.account;
@ -174,33 +100,6 @@ const TootBottomSheet: Component = (props) => {
return Array.from(new Set(values).keys()); return Array.from(new Set(values).keys());
}; };
const vote = async (status: mastodon.v1.Status, votes: readonly number[]) => {
const client = session()?.client;
if (!client) return;
const toot = status.reblog ?? status;
if (!toot.poll) return;
const npoll = await client.v1.polls.$select(toot.poll.id).votes.create({
choices: votes,
});
if (status.reblog) {
setRemoteToot({
...status,
reblog: {
...status.reblog,
poll: npoll,
},
});
} else {
setRemoteToot({
...status,
poll: npoll,
});
}
};
const handleMainTootClick = ( const handleMainTootClick = (
event: MouseEvent & { currentTarget: HTMLElement }, event: MouseEvent & { currentTarget: HTMLElement },
) => { ) => {
@ -266,58 +165,51 @@ const TootBottomSheet: Component = (props) => {
> >
<div class="Scrollable"> <div class="Scrollable">
<TimeSourceProvider value={time}> <TimeSourceProvider value={time}>
<TootList
threads={ancestors.list}
onUnknownThread={ancestors.getPath}
onChangeToot={ancestors.set}
/>
<article>
<Show when={toot()}>
<TootEnvProvider
value={{
bookmark: onBookmark,
boost: onBoost,
favourite: onFav,
vote,
}}
>
<RegularToot
id={`toot-${toot()!.id}`}
class={cards.card}
style={{
"scroll-margin-top":
"calc(var(--scaffold-topbar-height) + 20px)",
cursor: "auto",
"user-select": "auto",
}}
status={toot()!}
actionable={!!actSession()}
evaluated={true}
onClick={handleMainTootClick}
></RegularToot>
</TootEnvProvider>
</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 class="progress-line">
<CircularProgress style="width: 1.5em; height: 1.5em;" />
</div>
</Show>
<ItemSelectionProvider value={selectionState}> <ItemSelectionProvider value={selectionState}>
<TootList
threads={ancestors.list}
onUnknownThread={ancestors.getPath}
onChangeToot={ancestors.set}
/>
<article>
<Show when={toot()}>
<TootEnvProvider value={mainTootEnv}>
<RegularToot
id={`toot-${toot()!.id}`}
class={cards.card}
style={{
"scroll-margin-top":
"calc(var(--scaffold-topbar-height) + 20px)",
cursor: "auto",
"user-select": "auto",
}}
status={toot()!}
actionable={!!actSession()}
evaluated={true}
onClick={handleMainTootClick}
></RegularToot>
</TootEnvProvider>
</Show>
</article>
<Show when={(session().account as Account).inf}>
<TootComposer
mentions={defaultMentions()}
profile={(session().account! as Account).inf}
replyToDisplayName={toot()?.account?.displayName || ""}
client={session().client}
onSent={() => refetchContext()}
inReplyToId={remoteToot()?.reblog?.id ?? remoteToot()?.id}
/>
</Show>
<Show when={tootContextErrorUncaught.loading}>
<div class="progress-line">
<CircularProgress style="width: 1.5em; height: 1.5em;" />
</div>
</Show>
<TootList <TootList
threads={descendants.list} threads={descendants.list}
onUnknownThread={descendants.getPath} onUnknownThread={descendants.getPath}

View file

@ -216,7 +216,7 @@ function cancelEvent(event: Event) {
const TootComposer: Component<{ const TootComposer: Component<{
ref?: Ref<HTMLDivElement>; ref?: Ref<HTMLDivElement>;
style?: JSX.CSSProperties; style?: JSX.CSSProperties;
profile?: Account; profile?: mastodon.v1.Account;
replyToDisplayName?: string; replyToDisplayName?: string;
mentions?: readonly string[]; mentions?: readonly string[];
client?: mastodon.rest.Client; client?: mastodon.rest.Client;
@ -248,15 +248,15 @@ const TootComposer: Component<{
createEffect(() => { createEffect(() => {
if (active()) { if (active()) {
setTimeout(() => inputRef.focus(), 0); setTimeout(() => inputRef!.focus(), 0);
} }
}); });
createEffect(() => { createEffect(() => {
if (inputRef.value !== "") return; if (inputRef!.value !== "") return;
if (props.mentions) { if (props.mentions) {
const prepText = props.mentions.join(" ") + " "; const prepText = props.mentions.join(" ") + " ";
inputRef.value = prepText; inputRef!.value = prepText;
} }
}); });
@ -294,7 +294,7 @@ const TootComposer: Component<{
try { try {
const status = await client.v1.statuses.create( const status = await client.v1.statuses.create(
{ {
status: inputRef.value, status: inputRef!.value,
language: language(), language: language(),
visibility: visibility(), visibility: visibility(),
inReplyToId: props.inReplyToId, inReplyToId: props.inReplyToId,
@ -309,7 +309,7 @@ const TootComposer: Component<{
); );
props.onSent?.(status); props.onSent?.(status);
inputRef.value = ""; inputRef!.value = "";
} finally { } finally {
setSending(false); setSending(false);
} }
@ -363,7 +363,7 @@ const TootComposer: Component<{
<div class="reply-input"> <div class="reply-input">
<Show when={props.profile}> <Show when={props.profile}>
<Avatar <Avatar
src={props.profile!.inf?.avatar} src={props.profile!.avatar}
sx={{ marginLeft: "-0.25em" }} sx={{ marginLeft: "-0.25em" }}
/> />
</Show> </Show>

View file

@ -1,19 +1,16 @@
import { import {
Component, Component,
createSignal,
ErrorBoundary, ErrorBoundary,
type Ref, type Ref,
createSelector,
Index, Index,
createMemo, createMemo,
For, For,
createUniqueId, createUniqueId,
} from "solid-js"; } from "solid-js";
import { type mastodon } from "masto"; import { type mastodon } from "masto";
import { vibrate } from "~platform/hardware";
import { useDefaultSession } from "../masto/clients"; import { useDefaultSession } from "../masto/clients";
import { setCache as setTootBottomSheetCache } from "./TootBottomSheet";
import RegularToot, { import RegularToot, {
createDefaultTootEnv,
findElementActionable, findElementActionable,
findRootToot, findRootToot,
TootEnvProvider, TootEnvProvider,
@ -23,6 +20,7 @@ import type { ThreadNode } from "../masto/timelines";
import { useNavigator } from "~platform/StackedRouter"; import { useNavigator } from "~platform/StackedRouter";
import { ANIM_CURVE_STD } from "~material/theme"; import { ANIM_CURVE_STD } from "~material/theme";
import { useItemSelection } from "./toots/ItemSelectionProvider"; import { useItemSelection } from "./toots/ItemSelectionProvider";
import { fetchStatus } from "../masto/statuses";
function durationOf(rect0: DOMRect, rect1: DOMRect) { function durationOf(rect0: DOMRect, rect1: DOMRect) {
const distancelt = Math.sqrt( const distancelt = Math.sqrt(
@ -61,62 +59,12 @@ const TootList: Component<{
const [isExpanded, setExpanded] = useItemSelection(); const [isExpanded, setExpanded] = useItemSelection();
const { push } = useNavigator(); const { push } = useNavigator();
const onBookmark = async (status: mastodon.v1.Status) => { const tootEnv = createDefaultTootEnv(
const client = session()?.client; () => session()?.client,
if (!client) return; (...args) => props.onChangeToot(...args),
);
const result = await (status.bookmarked const openFullScreenToot = async (
? client.v1.statuses.$select(status.id).unbookmark()
: client.v1.statuses.$select(status.id).bookmark());
props.onChangeToot(result.id, result);
};
const toggleBoost = async (status: mastodon.v1.Status) => {
const client = session()?.client;
if (!client) return;
vibrate(50);
const rootStatus = status.reblog ? status.reblog : status;
const reblogged = rootStatus.reblogged;
if (status.reblog) {
props.onChangeToot(status.id, {
...status,
reblog: { ...status.reblog, reblogged: !reblogged },
});
} else {
props.onChangeToot(status.id, {
...status,
reblogged: !reblogged,
});
}
const result = reblogged
? await client.v1.statuses.$select(status.id).unreblog()
: (await client.v1.statuses.$select(status.id).reblog()).reblog!;
if (status.reblog) {
props.onChangeToot(status.id, {
...status,
reblog: result,
});
} else {
props.onChangeToot(status.id, result);
}
};
const toggleFavourite = async (status: mastodon.v1.Status) => {
const client = session()?.client;
if (!client) return;
const ovalue = status.favourited;
props.onChangeToot(status.id, { ...status, favourited: !ovalue });
const result = ovalue
? await client.v1.statuses.$select(status.id).unfavourite()
: await client.v1.statuses.$select(status.id).favourite();
props.onChangeToot(status.id, result);
};
const openFullScreenToot = (
toot: mastodon.v1.Status, toot: mastodon.v1.Status,
srcElement: HTMLElement, srcElement: HTMLElement,
reply?: boolean, reply?: boolean,
@ -130,7 +78,7 @@ const TootList: Component<{
} }
const acct = `${inf.username}@${p.site}`; const acct = `${inf.username}@${p.site}`;
setTootBottomSheetCache(acct, toot); await fetchStatus.setJson([p, toot.id], toot)
push(`/${encodeURIComponent(acct)}/toot/${toot.id}`, { push(`/${encodeURIComponent(acct)}/toot/${toot.id}`, {
animateOpen(element) { animateOpen(element) {
@ -234,33 +182,6 @@ const TootList: Component<{
openFullScreenToot(status, element, true); openFullScreenToot(status, element, true);
}; };
const vote = async (status: mastodon.v1.Status, votes: readonly number[]) => {
const client = session()?.client;
if (!client) return;
const toot = status.reblog ?? status;
if (!toot.poll) return;
const npoll = await client.v1.polls.$select(toot.poll.id).votes.create({
choices: votes,
});
if (status.reblog) {
props.onChangeToot(status.id, {
...status,
reblog: {
...status.reblog,
poll: npoll,
},
});
} else {
props.onChangeToot(status.id, {
...status,
poll: npoll,
});
}
};
return ( return (
<ErrorBoundary <ErrorBoundary
fallback={(err, reset) => { fallback={(err, reset) => {
@ -270,11 +191,8 @@ const TootList: Component<{
> >
<TootEnvProvider <TootEnvProvider
value={{ value={{
boost: toggleBoost, ...tootEnv,
bookmark: onBookmark,
favourite: toggleFavourite,
reply: reply, reply: reply,
vote: vote,
}} }}
> >
<div ref={props.ref} id={props.id} class="toot-list"> <div ref={props.ref} id={props.id} class="toot-list">

View file

@ -24,13 +24,19 @@ function isolatedCallback(e: MouseEvent) {
e.stopPropagation(); e.stopPropagation();
} }
/**
* The actions of the toot card.
*
* The `value` must be the original toot (contains `reblog` if
* it's a boost), since the value will be passed to the callbacks.
*/
function TootActionGroup<T extends mastodon.v1.Status>(props: { function TootActionGroup<T extends mastodon.v1.Status>(props: {
value: T; value: T;
class?: string; class?: string;
}) { }) {
const { reply, boost, favourite, bookmark } = useTootEnv(); const { reply, boost, favourite, bookmark } = useTootEnv();
let actGrpElement: HTMLDivElement; let actGrpElement: HTMLDivElement;
const toot = () => props.value; const toot = () => props.value.reblog ?? props.value;
return ( return (
<div <div
ref={actGrpElement!} ref={actGrpElement!}