Compare commits
4 commits
71bdb21602
...
5742932c86
Author | SHA1 | Date | |
---|---|---|---|
|
5742932c86 | ||
|
6dd6065711 | ||
|
5ab0d4d0a2 | ||
|
97bd6da9ac |
11 changed files with 409 additions and 282 deletions
|
@ -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
23
src/masto/base.ts
Normal 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;
|
||||||
|
}
|
|
@ -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
30
src/masto/statuses.ts
Normal 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
145
src/platform/cache.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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={
|
||||||
|
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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!}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue