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");
return `${err.name}: ${err.message}\n${strackMsg}`;
} 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"][]);
createEffect(() => {
const nls = status();
const nls = catchError(status, (e) => {
console.error(e);
});
if (!nls) return;
setThreads([]);
@ -226,9 +228,7 @@ export type TimelineControls = {
set(id: string, value: mastodon.v1.Status): void;
};
export type TimelineResource<
R,
> = [
export type TimelineResource<R> = [
TimelineControls,
Resource<R>,
{ refetch(info?: TimelineFetchDirection): void },
@ -238,7 +238,7 @@ export type TimelineResource<
* Create auto managed timeline controls.
*
* 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.
*/
export function createTimeline<

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -18,6 +18,7 @@ import { findElementActionable } from "./RegularToot";
const TootList: Component<{
ref?: Ref<HTMLDivElement>;
id?: string;
threads: readonly string[];
onUnknownThread: (id: string) => { value: mastodon.v1.Status }[] | undefined;
onChangeToot: (id: string, value: mastodon.v1.Status) => void;
@ -149,7 +150,7 @@ const TootList: Component<{
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}>
{(itemId, index) => {
const path = props.onUnknownThread(itemId)!;