Compare commits

..

9 commits

Author SHA1 Message Date
thislight
6705b754d1
Profile: support subscribe to home timeline
All checks were successful
/ depoly (push) Successful in 3m0s
2024-11-04 18:16:54 +08:00
thislight
97a1fb9cf1
createTimelineControlsForArray: catch error 2024-11-04 18:03:53 +08:00
thislight
c22860a7e3
Menu: support ARIA attributes 2024-11-04 18:03:27 +08:00
thislight
8205ff661b
BottomSheet: remove focus 2024-11-04 18:03:05 +08:00
thislight
9da4a3a4b3
Scaffold: add roles to position containers 2024-11-04 18:02:30 +08:00
thislight
bdaaeadba6
TootList: accept id in props 2024-11-04 17:10:12 +08:00
thislight
dde0c249f4
useSignedInProfiles: removed
- use useSessions() instead
2024-11-04 17:09:20 +08:00
thislight
832a59031a
UnexpectedError: add error repl if failed to parse 2024-11-04 17:08:30 +08:00
thislight
a0811ed6c4
Profile: add current default account into menu 2024-11-04 15:57:53 +08:00
11 changed files with 167 additions and 91 deletions

View file

@ -18,7 +18,16 @@ const UnexpectedError: Component<{ error?: any }> = (props) => {
.join("\n"); .join("\n");
return `${err.name}: ${err.message}\n${strackMsg}`; return `${err.name}: ${err.message}\n${strackMsg}`;
} catch (reason) { } catch (reason) {
return `<failed to build the stacktrace of "${err}"...>\n${reason}`; return `<failed to build the stacktrace of "${err}"...>\n${reason}\n${JSON.stringify(
{
name: err.name,
stack: err.stack,
cause: err.cause,
message: err.message,
},
undefined,
2,
)}`;
} }
} }

View file

@ -1,28 +0,0 @@
import { Accessor, createResource } from "solid-js";
import type { mastodon } from "masto";
import { useSessions } from "./clients";
import { updateAcctInf } from "../accounts/stores";
export function useSignedInProfiles() {
const sessions = useSessions();
const [accessor, tools] = createResource(sessions, async (all) => {
return Promise.all(
all.map(async (x, i) => ({ ...x, inf: await updateAcctInf(i) })),
);
});
return [
() => {
try {
const value = accessor();
if (value) {
return value;
}
} catch (reason) {
console.error("useSignedInProfiles: update acct info failed", reason);
}
return sessions().map((x) => ({ ...x, inf: x.account.inf }));
},
tools,
] as const;
}

View file

@ -26,7 +26,9 @@ export function createTimelineControlsForArray(
const [threads, setThreads] = createStore([] as mastodon.v1.Status["id"][]); const [threads, setThreads] = createStore([] as mastodon.v1.Status["id"][]);
createEffect(() => { createEffect(() => {
const nls = status(); const nls = catchError(status, (e) => {
console.error(e);
});
if (!nls) return; if (!nls) return;
setThreads([]); setThreads([]);
@ -226,9 +228,7 @@ export type TimelineControls = {
set(id: string, value: mastodon.v1.Status): void; set(id: string, value: mastodon.v1.Status): void;
}; };
export type TimelineResource< export type TimelineResource<R> = [
R,
> = [
TimelineControls, TimelineControls,
Resource<R>, Resource<R>,
{ refetch(info?: TimelineFetchDirection): void }, { refetch(info?: TimelineFetchDirection): void },
@ -238,7 +238,7 @@ export type TimelineResource<
* Create auto managed timeline controls. * Create auto managed timeline controls.
* *
* The error from the resource is not thrown in the * The error from the resource is not thrown in the
* {@link TimelineControls.list} and {@link TimelineControls}.get*. * {@link TimelineControls["list"]} and {@link TimelineControls}.get*.
* Use the second value from {@link TimelineResource} to catch the error. * Use the second value from {@link TimelineResource} to catch the error.
*/ */
export function createTimeline< export function createTimeline<

View file

@ -228,6 +228,8 @@ const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
}} }}
onClick={onDialogClick} onClick={onDialogClick}
ref={element!} ref={element!}
tabIndex={-1}
role="presentation"
> >
{ochildren() ?? cache()} {ochildren() ?? cache()}
</dialog> </dialog>

View file

@ -23,6 +23,11 @@
&.e4 { &.e4 {
box-shadow: var(--tutu-shadow-e12); box-shadow: var(--tutu-shadow-e12);
} }
&>.container {
background: var(--tutu-color-surface);
display: contents;
}
} }
dialog.Menu::backdrop { dialog.Menu::backdrop {

View file

@ -3,6 +3,7 @@ import { MenuList } from "@suid/material";
import { import {
createEffect, createEffect,
createSignal, createSignal,
splitProps,
type Component, type Component,
type JSX, type JSX,
type ParentProps, type ParentProps,
@ -16,11 +17,15 @@ import {
export type Anchor = Pick<DOMRect, "top" | "left" | "right"> & { e?: number }; export type Anchor = Pick<DOMRect, "top" | "left" | "right"> & { e?: number };
export type MenuProps = ParentProps<{ export type MenuProps = ParentProps<
open?: boolean; {
onClose?: JSX.EventHandlerUnion<HTMLDialogElement, Event>; open?: boolean;
anchor: () => Anchor; onClose?: JSX.EventHandlerUnion<HTMLDialogElement, Event>;
}>; anchor: () => Anchor;
id?: string;
} & JSX.AriaAttributes
>;
function px(n?: number) { function px(n?: number) {
if (n) { if (n) {
@ -74,6 +79,7 @@ export function createManagedMenuState() {
const Menu: Component<MenuProps> = (props) => { const Menu: Component<MenuProps> = (props) => {
let root: HTMLDialogElement; let root: HTMLDialogElement;
const windowSize = useWindowSize(); const windowSize = useWindowSize();
const [, rest] = splitProps(props, ["open", "onClose", "anchor"]);
const [anchorPos, setAnchorPos] = createSignal<{ const [anchorPos, setAnchorPos] = createSignal<{
left?: number; left?: number;
@ -83,15 +89,16 @@ const Menu: Component<MenuProps> = (props) => {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
createEffect(() => { createEffect(() => {
switch (anchorPos().e) { if (anchorPos().e)
case 1: switch (anchorPos().e) {
case 2: case 1:
case 3: case 2:
case 3: case 3:
return; case 4:
default: return;
console.warn('value %s is invalid for param "e"', anchorPos().e); default:
} console.warn('value %s is invalid for param "e"', anchorPos().e);
}
}); });
} }
@ -202,12 +209,13 @@ const Menu: Component<MenuProps> = (props) => {
top: px(anchorPos().top), top: px(anchorPos().top),
/* FIXME: the content may be overflow */ /* FIXME: the content may be overflow */
}} }}
role="presentation"
tabIndex={-1}
{...rest}
> >
<div <div
style={{ class="container"
background: "var(--tutu-color-surface)", role="presentation"
display: "contents"
}}
> >
<MenuList>{props.children}</MenuList> <MenuList>{props.children}</MenuList>
</div> </div>

View file

@ -36,12 +36,12 @@ const Scaffold: Component<ScaffoldProps> = (props) => {
return ( return (
<> <>
<Show when={props.topbar}> <Show when={props.topbar}>
<div class="Scaffold__topbar" ref={setTopbarElement}> <div class="Scaffold__topbar" ref={setTopbarElement} role="presentation">
{props.topbar} {props.topbar}
</div> </div>
</Show> </Show>
<Show when={props.fab}> <Show when={props.fab}>
<div class="Scaffold__fab-dock">{props.fab}</div> <div class="Scaffold__fab-dock" role="presentation">{props.fab}</div>
</Show> </Show>
<div <div
ref={(e) => { ref={(e) => {
@ -61,7 +61,7 @@ const Scaffold: Component<ScaffoldProps> = (props) => {
{managed.children} {managed.children}
</div> </div>
<Show when={props.bottom}> <Show when={props.bottom}>
<div class="Scaffold__bottom-dock">{props.bottom}</div> <div class="Scaffold__bottom-dock" role="presentation">{props.bottom}</div>
</Show> </Show>
</> </>
); );

View file

@ -10,12 +10,14 @@ import {
onCleanup, onCleanup,
Show, Show,
type Component, type Component,
createMemo,
} from "solid-js"; } from "solid-js";
import Scaffold from "../material/Scaffold"; import Scaffold from "../material/Scaffold";
import { import {
AppBar, AppBar,
Avatar, Avatar,
Button, Button,
Checkbox,
CircularProgress, CircularProgress,
Divider, Divider,
IconButton, IconButton,
@ -66,6 +68,8 @@ const Profile: Component = () => {
const time = createTimeSource(); const time = createTimeSource();
const menuButId = createUniqueId(); const menuButId = createUniqueId();
const recentTootListId = createUniqueId();
const optMenuId = createUniqueId();
const [menuOpen, setMenuOpen] = createSignal(false); const [menuOpen, setMenuOpen] = createSignal(false);
@ -87,17 +91,20 @@ const Profile: Component = () => {
); );
onCleanup(() => obx.disconnect()); onCleanup(() => obx.disconnect());
const [profileErrorUncaught] = createResource( const [profileUncaught] = 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 = () => const profile = () => {
catchError(profileErrorUncaught, (err) => { try {
console.error(err); return profileUncaught();
}); } catch (reason) {
console.error(reason);
}
};
const isCurrentSessionProfile = () => { const isCurrentSessionProfile = () => {
return session().account?.inf?.url === profile()?.url; return session().account?.inf?.url === profile()?.url;
@ -126,6 +133,22 @@ const Profile: Component = () => {
}, },
); );
const [relationshipUncaught, { mutate: mutateRelationship }] = createResource(
() => [session(), params.id] as const,
async ([sess, id]) => {
if (!sess.account) return; // No account, no relation
const relations = await session().client.v1.accounts.relationships.fetch({
id: [id],
});
return relations.length > 0 ? relations[0] : undefined;
},
);
const relationship = () =>
catchError(relationshipUncaught, (reason) => {
console.error(reason);
});
const bannerImg = () => profile()?.header; const bannerImg = () => profile()?.header;
const avatarImg = () => profile()?.avatar; const avatarImg = () => profile()?.avatar;
const displayName = () => const displayName = () =>
@ -137,10 +160,38 @@ const Profile: Component = () => {
recentTootChunk.loading || recentTootChunk.loading ||
(recentTootFilter().pinned && pinnedTootChunk.loading); (recentTootFilter().pinned && pinnedTootChunk.loading);
const sessionDisplayName = createMemo(() =>
resolveCustomEmoji(
session().account?.inf?.displayName || "",
session().account?.inf?.emojis ?? [],
),
);
const useSessionDisplayName = (e: HTMLElement) => {
createRenderEffect(() => (e.innerHTML = sessionDisplayName()));
};
const toggleSubscribeHome = async () => {
const client = session().client;
if (!session().account) return;
const isSubscribed = relationship()?.following ?? false;
mutateRelationship((x) => Object.assign({ following: !isSubscribed }, x));
subscribeMenuState.onClose();
if (isSubscribed) {
const nrel = await client.v1.accounts.$select(params.id).unfollow();
mutateRelationship(nrel);
} else {
const nrel = await client.v1.accounts.$select(params.id).follow();
mutateRelationship(nrel);
}
};
return ( return (
<Scaffold <Scaffold
topbar={ topbar={
<AppBar <AppBar
role="navigation"
position="static" position="static"
color={scrolledPastBanner() ? "primary" : "transparent"} color={scrolledPastBanner() ? "primary" : "transparent"}
elevation={scrolledPastBanner() ? undefined : 0} elevation={scrolledPastBanner() ? undefined : 0}
@ -174,8 +225,10 @@ const Profile: Component = () => {
<IconButton <IconButton
id={menuButId} id={menuButId}
aria-controls={optMenuId}
color="inherit" color="inherit"
onClick={[setMenuOpen, true]} onClick={[setMenuOpen, true]}
aria-label="Open Options for the Profile"
> >
<MoreVert /> <MoreVert />
</IconButton> </IconButton>
@ -185,12 +238,25 @@ const Profile: Component = () => {
class="Profile" class="Profile"
> >
<Menu <Menu
id={optMenuId}
open={menuOpen()} open={menuOpen()}
onClose={[setMenuOpen, false]} onClose={[setMenuOpen, false]}
anchor={() => anchor={() =>
document.getElementById(menuButId)!.getBoundingClientRect() document.getElementById(menuButId)!.getBoundingClientRect()
} }
aria-label="Options for the Profile"
> >
<Show when={session().account}>
<MenuItem>
<ListItemAvatar>
<Avatar src={session().account?.inf?.avatar} />
</ListItemAvatar>
<ListItemText secondary={"Default account"}>
<span ref={useSessionDisplayName}></span>
</ListItemText>
{/* <ArrowRight /> // for future */}
</MenuItem>
</Show>
<Show when={session().account && profile()}> <Show when={session().account && profile()}>
<Show <Show
when={isCurrentSessionProfile()} when={isCurrentSessionProfile()}
@ -273,6 +339,7 @@ const Profile: Component = () => {
"margin-top": "margin-top":
"calc(-1 * (var(--scaffold-topbar-height) + var(--safe-area-inset-top)))", "calc(-1 * (var(--scaffold-topbar-height) + var(--safe-area-inset-top)))",
}} }}
role="presentation"
> >
<img <img
ref={(e) => obx.observe(e)} ref={(e) => obx.observe(e)}
@ -283,7 +350,8 @@ const Profile: Component = () => {
height: "100%", height: "100%",
}} }}
crossOrigin="anonymous" crossOrigin="anonymous"
onLoad={async (event) => { alt={`Banner image for ${profile()?.displayName || "the user"}`}
onLoad={(event) => {
const ins = new FastAverageColor(); const ins = new FastAverageColor();
const colors = ins.getColor(event.currentTarget); const colors = ins.getColor(event.currentTarget);
setBannerSampledColors({ setBannerSampledColors({
@ -296,23 +364,21 @@ const Profile: Component = () => {
</div> </div>
<Menu {...subscribeMenuState}> <Menu {...subscribeMenuState}>
<MenuItem disabled> <MenuItem
onClick={toggleSubscribeHome}
aria-details="Subscribe or Unsubscribe this account on your home timeline"
>
<ListItemAvatar> <ListItemAvatar>
<Avatar src={session().account?.inf?.avatar}></Avatar> <Avatar src={session().account?.inf?.avatar}></Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText> <ListItemText
<span secondary={relationship()?.following ? "Subscribed" : undefined}
ref={(e) => >
createRenderEffect(() => { <span ref={useSessionDisplayName}></span>
e.innerHTML = resolveCustomEmoji(
session().account?.inf?.displayName || "",
session().account?.inf?.emojis ?? [],
);
})
}
></span>
<span>'s Home</span> <span>'s Home</span>
</ListItemText> </ListItemText>
<Checkbox checked={relationship()?.following ?? false} />
</MenuItem> </MenuItem>
</Menu> </Menu>
@ -323,9 +389,10 @@ const Profile: Component = () => {
color: bannerSampledColors()?.text, color: bannerSampledColors()?.text,
}} }}
> >
<div class="acct-grp"> <section class="acct-grp">
<Avatar <Avatar
src={avatarImg()} src={avatarImg()}
alt={`${profile()?.displayName || "the user"}'s avatar`}
sx={{ sx={{
marginTop: "calc(-16px - 72px / 2)", marginTop: "calc(-16px - 72px / 2)",
width: "72px", width: "72px",
@ -337,12 +404,19 @@ const Profile: Component = () => {
ref={(e) => ref={(e) =>
createRenderEffect(() => (e.innerHTML = displayName())) createRenderEffect(() => (e.innerHTML = displayName()))
} }
aria-label="Display name"
></span> ></span>
<span>{fullUsername()}</span> <span aria-label="Complete username">{fullUsername()}</span>
</div> </div>
<div> <div>
<Switch> <Switch>
<Match when={!session().account || profileErrorUncaught.loading}> <Match
when={
!session().account ||
profileUncaught.loading ||
profileUncaught.error
}
>
{<></>} {<></>}
</Match> </Match>
<Match when={isCurrentSessionProfile()}> <Match when={isCurrentSessionProfile()}>
@ -360,20 +434,24 @@ const Profile: Component = () => {
); );
}} }}
> >
Subscribe {relationship()?.following ? "Subscribed" : "Subscribe"}
</Button> </Button>
</Match> </Match>
</Switch> </Switch>
</div> </div>
</div> </section>
<div <section
class="description" class="description"
aria-label={`${profile()?.displayName || "the user"}'s description`}
ref={(e) => ref={(e) =>
createRenderEffect(() => (e.innerHTML = description() || "")) createRenderEffect(() => (e.innerHTML = description() || ""))
} }
></div> ></section>
<table class="acct-fields"> <table
class="acct-fields"
aria-label={`${profile()?.displayName || "the user"}'s fields`}
>
<tbody> <tbody>
<For each={profile()?.fields ?? []}> <For each={profile()?.fields ?? []}>
{(item, index) => { {(item, index) => {
@ -422,6 +500,7 @@ const Profile: Component = () => {
<Divider /> <Divider />
</Show> </Show>
<TootList <TootList
id={recentTootListId}
threads={recentToots.list} threads={recentToots.list}
onUnknownThread={recentToots.getPath} onUnknownThread={recentToots.getPath}
onChangeToot={recentToots.set} onChangeToot={recentToots.set}
@ -437,6 +516,7 @@ const Profile: Component = () => {
> >
<IconButton <IconButton
aria-label="Load More" aria-label="Load More"
aria-controls={recentTootListId}
size="large" size="large"
color="primary" color="primary"
onClick={[refetchRecentToots, "prev"]} onClick={[refetchRecentToots, "prev"]}

View file

@ -31,12 +31,10 @@ import {
import { A, useNavigate } from "@solidjs/router"; import { A, useNavigate } from "@solidjs/router";
import { Title } from "../material/typography.jsx"; import { Title } from "../material/typography.jsx";
import { css } from "solid-styled"; import { css } from "solid-styled";
import { useSignedInProfiles } from "../masto/acct.js";
import { signOut, type Account } from "../accounts/stores.js"; import { signOut, type Account } from "../accounts/stores.js";
import { format } from "date-fns"; import { format } from "date-fns";
import { useStore } from "@nanostores/solid"; import { useStore } from "@nanostores/solid";
import { $settings } from "./stores.js"; import { $settings } from "./stores.js";
import { useRegisterSW } from "virtual:pwa-register/solid";
import { import {
autoMatchLangTag, autoMatchLangTag,
autoMatchRegion, autoMatchRegion,
@ -46,6 +44,7 @@ import {
import { type Template } from "@solid-primitives/i18n"; import { type Template } from "@solid-primitives/i18n";
import BottomSheet from "../material/BottomSheet.jsx"; import BottomSheet from "../material/BottomSheet.jsx";
import { useServiceWorker } from "../platform/host.js"; import { useServiceWorker } from "../platform/host.js";
import { useSessions } from "../masto/clients.js";
type Strings = { type Strings = {
["lang.auto"]: Template<{ detected: string }>; ["lang.auto"]: Template<{ detected: string }>;
@ -64,7 +63,7 @@ const Settings: ParentComponent = (props) => {
const { needRefresh, offlineReady } = useServiceWorker(); const { needRefresh, offlineReady } = useServiceWorker();
const dateFnLocale = useDateFnLocale(); const dateFnLocale = useDateFnLocale();
const [profiles] = useSignedInProfiles(); const profiles = useSessions();
const doSignOut = (acct: Account) => { const doSignOut = (acct: Account) => {
signOut((a) => a.site === acct.site && a.accessToken === acct.accessToken); signOut((a) => a.site === acct.site && a.accessToken === acct.accessToken);
@ -118,9 +117,9 @@ const Settings: ParentComponent = (props) => {
<Divider /> <Divider />
</ul> </ul>
<For each={profiles()}> <For each={profiles()}>
{({ account: acct, inf }) => ( {({ account: acct }) => (
<ul data-site={acct.site} data-username={inf?.username}> <ul data-site={acct.site} data-username={acct.inf?.username}>
<ListSubheader>{`@${inf?.username ?? "..."}@${new URL(acct.site).host}`}</ListSubheader> <ListSubheader>{`@${acct.inf?.username ?? "..."}@${new URL(acct.site).host}`}</ListSubheader>
<ListItemButton disabled> <ListItemButton disabled>
<ListItemText>{t("Notifications")}</ListItemText> <ListItemText>{t("Notifications")}</ListItemText>
<ListItemSecondaryAction> <ListItemSecondaryAction>

View file

@ -30,10 +30,10 @@ import { $settings } from "../settings/stores";
import { useStore } from "@nanostores/solid"; import { useStore } from "@nanostores/solid";
import { HeroSourceProvider, type HeroSource } from "../platform/anim"; import { HeroSourceProvider, type HeroSource } from "../platform/anim";
import { useNavigate } from "@solidjs/router"; import { useNavigate } from "@solidjs/router";
import { useSignedInProfiles } from "../masto/acct";
import { setCache as setTootBottomSheetCache } from "./TootBottomSheet"; import { setCache as setTootBottomSheetCache } from "./TootBottomSheet";
import TrendTimelinePanel from "./TrendTimelinePanel"; import TrendTimelinePanel from "./TrendTimelinePanel";
import TimelinePanel from "./TimelinePanel"; import TimelinePanel from "./TimelinePanel";
import { useSessions } from "../masto/clients";
const Home: ParentComponent = (props) => { const Home: ParentComponent = (props) => {
let panelList: HTMLDivElement; let panelList: HTMLDivElement;
@ -42,11 +42,11 @@ const Home: ParentComponent = (props) => {
const settings$ = useStore($settings); const settings$ = useStore($settings);
const [profiles] = useSignedInProfiles(); const profiles = useSessions();
const profile = () => { const profile = () => {
const all = profiles(); const all = profiles();
if (all.length > 0) { if (all.length > 0) {
return all[0].inf; return all[0].account.inf;
} }
}; };
const client = () => { const client = () => {

View file

@ -18,6 +18,7 @@ import { findElementActionable } from "./RegularToot";
const TootList: Component<{ const TootList: Component<{
ref?: Ref<HTMLDivElement>; ref?: Ref<HTMLDivElement>;
id?: string;
threads: readonly string[]; threads: readonly string[];
onUnknownThread: (id: string) => { value: mastodon.v1.Status }[] | undefined; onUnknownThread: (id: string) => { value: mastodon.v1.Status }[] | undefined;
onChangeToot: (id: string, value: mastodon.v1.Status) => void; onChangeToot: (id: string, value: mastodon.v1.Status) => void;
@ -149,7 +150,7 @@ const TootList: Component<{
return <p>Oops: {String(err)}</p>; return <p>Oops: {String(err)}</p>;
}} }}
> >
<div ref={props.ref} class="toot-list"> <div ref={props.ref} id={props.id} class="toot-list">
<For each={props.threads}> <For each={props.threads}>
{(itemId, index) => { {(itemId, index) => {
const path = props.onUnknownThread(itemId)!; const path = props.onUnknownThread(itemId)!;