Compare commits

...

5 commits

Author SHA1 Message Date
thislight
bea1d6abfa
Profile: first prototype
All checks were successful
/ depoly (push) Successful in 1m15s
2024-10-18 19:15:35 +08:00
thislight
e9c39492ec
TootBottomSheet: fix context switch wrong target 2024-10-18 18:13:07 +08:00
thislight
657c886fab
add useSessionForAcctStr 2024-10-18 11:59:39 +08:00
thislight
040016ddce
BottomSheet: fix the animation problem 2024-10-17 20:57:20 +08:00
thislight
b5da86fa5c
TootBottomSheet: moved to /:acct/toot/:id 2024-10-17 20:39:04 +08:00
9 changed files with 515 additions and 112 deletions

View file

@ -24,9 +24,7 @@ import {
ResultDispatcher, ResultDispatcher,
type JSONRPC, type JSONRPC,
} from "./serviceworker/workerrpc.js"; } from "./serviceworker/workerrpc.js";
import { import { Service } from "./serviceworker/services.js";
Service
} from "./serviceworker/services.js"
import { makeEventListener } from "@solid-primitives/event-listener"; import { makeEventListener } from "@solid-primitives/event-listener";
import { ServiceWorkerProvider } from "./platform/host.js"; import { ServiceWorkerProvider } from "./platform/host.js";
@ -41,6 +39,7 @@ const MotionSettings = lazy(() => import("./settings/Motions.js"));
const LanguageSettings = lazy(() => import("./settings/Language.js")); const LanguageSettings = lazy(() => import("./settings/Language.js"));
const RegionSettings = lazy(() => import("./settings/Region.jsx")); const RegionSettings = lazy(() => import("./settings/Region.jsx"));
const UnexpectedError = lazy(() => import("./UnexpectedError.js")); const UnexpectedError = lazy(() => import("./UnexpectedError.js"));
const Profile = lazy(() => import("./profiles/Profile.js"));
const Routing: Component = () => { const Routing: Component = () => {
return ( return (
@ -53,7 +52,8 @@ const Routing: Component = () => {
<Route path="/region" component={RegionSettings}></Route> <Route path="/region" component={RegionSettings}></Route>
<Route path="/motions" component={MotionSettings}></Route> <Route path="/motions" component={MotionSettings}></Route>
</Route> </Route>
<Route path="/:acct/:id" component={TootBottomSheet}></Route> <Route path="/:acct/toot/:id" component={TootBottomSheet}></Route>
<Route path="/:acct/profile/:id" component={Profile}></Route>
</Route> </Route>
<Route path={"/accounts"}> <Route path={"/accounts"}>
<Route path={"/sign-in"} component={AccountSignIn} /> <Route path={"/sign-in"} component={AccountSignIn} />

View file

@ -1,9 +1,9 @@
import { import {
Accessor, Accessor,
createContext, createContext,
createMemo,
createRenderEffect, createRenderEffect,
createResource, createResource,
Signal,
useContext, useContext,
} from "solid-js"; } from "solid-js";
import { Account } from "../accounts/stores"; import { Account } from "../accounts/stores";
@ -76,3 +76,60 @@ function useSessionsRaw() {
} }
return store; return store;
} }
const DefaultSessionContext = /* @__PURE__ */ createContext<Accessor<number>>(() => 0)
export const DefaultSessionProvider = DefaultSessionContext.Provider;
/**
* Return the default session (the first session).
*
* This function may return `undefined`, but it will try to redirect the user to the sign in.
*/
export function useDefaultSession() {
const sessions = useSessions()
const sessionIndex = useContext(DefaultSessionContext)
return () => {
if (sessions().length > 0) {
return sessions()[sessionIndex()]
}
}
}
/**
* Get a session for the specific acct string.
*
* Acct string is a string in the pattern of `{username}@{site_with_protocol}`,
* like `@thislight@https://mastodon.social`, can be used to identify (tempoarily)
* an session on the tutu instance.
*
* The `site_with_protocol` is required.
*
* - If the username is present, the session matches the username and 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.
*
* In an unauthorised session, the `.account` is `undefined` and the `client` is an
* unauthorised client for the site. This client may not available for some operations.
*/
export function useSessionForAcctStr(acct: Accessor<string>) {
const allSessions = useSessions()
return createMemo(() => {
const [inputUsername, inputSite] = acct().split("@", 2);
const authedSession = allSessions().find(
(x) =>
x.account.site === inputSite &&
x.account.inf?.username === inputUsername,
);
return (
authedSession ?? {
client: createUnauthorizedClient(inputSite),
account: undefined,
}
);
});
}

View file

@ -23,9 +23,20 @@
box-shadow: var(--tutu-shadow-e16); box-shadow: var(--tutu-shadow-e16);
:global(.MuiToolbar-root) > :global(.MuiButtonBase-root):first-child { :global(.MuiToolbar-root) {
margin-left: -0.5em; > :global(.MuiButtonBase-root) {
margin-right: 24px;
&:first-child {
margin-left: -0.5em;
margin-right: 24px;
}
&:last-child {
margin-right: -0.5em;
margin-left: 24px;
}
}
} }
@media (max-width: 560px) { @media (max-width: 560px) {
@ -43,7 +54,6 @@
&.animated { &.animated {
position: absolute; position: absolute;
transform: translateY(-50%);
overflow: hidden; overflow: hidden;
will-change: width, height, top, left; will-change: width, height, top, left;
@ -54,12 +64,6 @@
& * { & * {
overflow: hidden; overflow: hidden;
} }
@media (max-width: 560px) {
& {
transform: none;
}
}
} }
&.bottom { &.bottom {
@ -71,7 +75,6 @@
& { & {
transform: none; transform: none;
height: unset; height: unset;
} }
} }
} }

View file

@ -118,12 +118,9 @@ const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
animation = element.animate( animation = element.animate(
{ {
top: [`${rect.top}px`, `${rect.top}px`],
left: reserve left: reserve
? [`${rect.left}px`, `${window.innerWidth}px`] ? [`${rect.left}px`, `${window.innerWidth}px`]
: [`${window.innerWidth}px`, `${rect.left}px`], : [`${window.innerWidth}px`, `${rect.left}px`],
width: [`${rect.width}px`, `${rect.width}px`],
height: [`${rect.height}px`, `${rect.height}px`],
}, },
{ easing, duration }, { easing, duration },
); );
@ -151,12 +148,9 @@ const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
animation = element.animate( animation = element.animate(
{ {
left: [`${rect.left}px`, `${rect.left}px`],
top: reserve top: reserve
? [`${rect.top}px`, `${window.innerHeight}px`] ? [`${rect.top}px`, `${window.innerHeight}px`]
: [`${window.innerHeight}px`, `${rect.top}px`], : [`${window.innerHeight}px`, `${rect.top}px`],
width: [`${rect.width}px`, `${rect.width}px`],
height: [`${rect.height}px`, `${rect.height}px`],
}, },
{ easing, duration }, { easing, duration },
); );

252
src/profiles/Profile.tsx Normal file
View file

@ -0,0 +1,252 @@
import {
createRenderEffect,
createResource,
createSignal,
For,
onCleanup,
Show,
type Component,
} from "solid-js";
import Scaffold from "../material/Scaffold";
import { AppBar, Avatar, Button, IconButton, Toolbar } from "@suid/material";
import { Close, MoreVert, Verified } from "@suid/icons-material";
import { Title } from "../material/typography";
import { useNavigate, useParams } from "@solidjs/router";
import { useSessionForAcctStr } from "../masto/clients";
import { resolveCustomEmoji } from "../masto/toot";
import { FastAverageColor } from "fast-average-color";
import { useWindowSize } from "@solid-primitives/resize-observer";
import { css } from "solid-styled";
import { createTimeline } from "../masto/timelines";
import TootList from "../timelines/TootList";
import { createIntersectionObserver } from "@solid-primitives/intersection-observer";
import { createTimeSource, TimeSourceProvider } from "../platform/timesrc";
const Profile: Component = () => {
const navigate = useNavigate();
const params = useParams<{ acct: string; id: string }>();
const acctText = () => decodeURIComponent(params.acct);
const session = useSessionForAcctStr(acctText);
const [bannerSampledColors, setBannerSampledColors] = createSignal<{
average: string;
text: string;
}>();
const windowSize = useWindowSize();
const time = createTimeSource();
const [scrolledPastBanner, setScrolledPastBanner] = createSignal(false);
const obx = new IntersectionObserver(
(entries) => {
const ent = entries[0];
if (ent.intersectionRatio < 0.1) {
setScrolledPastBanner(true);
} else {
setScrolledPastBanner(false);
}
},
{
threshold: 0.1,
},
);
onCleanup(() => obx.disconnect());
const [profile] = createResource(
() => [session().client, params.id] as const,
async ([client, id]) => {
return await client.v1.accounts.$select(id).fetch();
},
);
const [recentToots] = createTimeline(
() => session().client.v1.accounts.$select(params.id).statuses,
() => 20,
);
const bannerImg = () => profile()?.header;
const avatarImg = () => profile()?.avatar;
const displayName = () =>
resolveCustomEmoji(profile()?.displayName || "", profile()?.emojis ?? []);
const fullUsername = () => `@${profile()?.acct ?? "..."}`; // TODO: full user name
const description = () => profile()?.note;
css`
.intro {
background-color: var(--tutu-color-surface-d);
color: var(--tutu-color-on-surface);
padding: 16px 12px;
display: flex;
flex-flow: column nowrap;
gap: 16px;
}
.acct-grp {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 16px;
align-items: center;
}
.name-grp {
display: flex;
flex-flow: column nowrap;
}
table.acct-fields {
& td > :global(a) {
display: inline-flex;
min-height: 44px;
align-items: center;
color: inherit;
}
& :global(a > .invisible) {
display: none;
}
& :global(svg) {
vertical-align: middle;
}
}
.page-title {
flex-grow: 1;
}
`;
return (
<Scaffold
topbar={
<AppBar
position="static"
color={scrolledPastBanner() ? "primary" : "transparent"}
elevation={scrolledPastBanner() ? undefined : 0}
>
<Toolbar
variant="dense"
sx={{
display: "flex",
color: bannerSampledColors()?.text,
paddingTop: "var(--safe-area-inset-top)",
}}
>
<IconButton color="inherit" onClick={[navigate, -1]}>
<Close />
</IconButton>
<Title
use:solid-styled
class="page-title"
style={{
visibility: scrolledPastBanner() ? undefined : "hidden",
}}
ref={(e: HTMLElement) =>
createRenderEffect(() => (e.innerHTML = displayName()))
}
></Title>
<IconButton color="inherit">
<MoreVert />
</IconButton>
</Toolbar>
</AppBar>
}
>
<div
style={{
width: "100%",
height: `${268 * (Math.min(560, windowSize.width) / 560)}px`,
"margin-top":
"calc(-1 * (var(--scaffold-topbar-height) + var(--safe-area-inset-top)))",
}}
>
<img
ref={(e) => obx.observe(e)}
src={bannerImg()}
style={{
"object-fit": "contain",
width: "100%",
height: "100%",
}}
crossOrigin="anonymous"
onLoad={async (event) => {
const ins = new FastAverageColor();
const colors = ins.getColor(event.currentTarget);
setBannerSampledColors({
average: colors.hex,
text: colors.isDark ? "white" : "black",
});
}}
></img>
</div>
<div
class="intro"
style={{
"background-color": bannerSampledColors()?.average,
color: bannerSampledColors()?.text,
}}
>
<div class="acct-grp">
<Avatar
src={avatarImg()}
sx={{
marginTop: "calc(-16px - 72px / 2)",
width: "72px",
height: "72px",
}}
></Avatar>
<div class="name-grp">
<span
ref={(e) =>
createRenderEffect(() => (e.innerHTML = displayName()))
}
></span>
<span>{fullUsername()}</span>
</div>
<div>
<Button variant="contained" color="secondary">
Subscribe
</Button>
</div>
</div>
<div
ref={(e) =>
createRenderEffect(() => (e.innerHTML = description() || ""))
}
></div>
<table class="acct-fields">
<tbody>
<For each={profile()?.fields ?? []}>
{(item, index) => {
return (
<tr data-field-index={index()}>
<td>{item.name}</td>
<td>
<Show when={item.verifiedAt}>
<Verified />
</Show>
</td>
<td
ref={(e) => {
createRenderEffect(() => (e.innerHTML = item.value));
}}
></td>
</tr>
);
}}
</For>
</tbody>
</table>
</div>
<TimeSourceProvider value={time}>
<TootList
threads={recentToots.list}
onUnknownThread={recentToots.getPath}
onChangeToot={recentToots.set}
/>
</TimeSourceProvider>
</Scaffold>
);
};
export default Profile;

View file

@ -151,7 +151,7 @@ const Home: ParentComponent = (props) => {
); );
const acct = `${inf.username}@${p.account.site}`; const acct = `${inf.username}@${p.account.site}`;
setTootBottomSheetCache(acct, toot); setTootBottomSheetCache(acct, toot);
navigate(`/${encodeURIComponent(acct)}/${toot.id}`, { navigate(`/${encodeURIComponent(acct)}/toot/${toot.id}`, {
state: reply state: reply
? { ? {
tootReply: true, tootReply: true,
@ -213,7 +213,7 @@ const Home: ParentComponent = (props) => {
Public Public
</Tab> </Tab>
</Tabs> </Tabs>
<ProfileMenuButton profile={profile()}> <ProfileMenuButton profile={profiles()[0]}>
<MenuItem <MenuItem
onClick={(e) => onClick={(e) =>
$settings.setKey( $settings.setKey(

View file

@ -24,7 +24,10 @@ import {
import { A } from "@solidjs/router"; import { A } from "@solidjs/router";
const ProfileMenuButton: ParentComponent<{ const ProfileMenuButton: ParentComponent<{
profile?: { displayName: string; avatar: string; username: string }; profile?: {
account: { site: string };
inf?: { displayName: string; avatar: string; username: string; id: string };
};
onClick?: () => void; onClick?: () => void;
onClose?: () => void; onClose?: () => void;
}> = (props) => { }> = (props) => {
@ -48,79 +51,83 @@ const ProfileMenuButton: ParentComponent<{
return ( return (
<> <>
<ButtonBase <ButtonBase
aria-haspopup="true" aria-haspopup="true"
sx={{ borderRadius: "50%" }} sx={{ borderRadius: "50%" }}
id={buttonId} id={buttonId}
onClick={onClick} onClick={onClick}
aria-controls={open() ? menuId : undefined} aria-controls={open() ? menuId : undefined}
aria-expanded={open() ? "true" : undefined} aria-expanded={open() ? "true" : undefined}
>
<Avatar
alt={`${props.profile?.inf?.displayName}'s avatar`}
src={props.profile?.inf?.avatar}
></Avatar>
</ButtonBase>
<Menu
id={menuId}
anchorEl={anchor()}
open={open()}
onClose={onClose}
MenuListProps={{
"aria-labelledby": buttonId,
sx: {
minWidth: "220px",
},
}}
anchorOrigin={{
vertical: "top",
horizontal: "right",
}}
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
>
<MenuItem
component={A}
href={`/${encodeURIComponent(`${props.profile?.inf?.username}@${props.profile?.account.site}`)}/profile/${props.profile?.inf?.id}`}
disabled={!props.profile}
> >
<Avatar <ListItemAvatar>
alt={`${props.profile?.displayName}'s avatar`} <Avatar src={props.profile?.inf?.avatar}></Avatar>
src={props.profile?.avatar} </ListItemAvatar>
></Avatar> <ListItemText
</ButtonBase> primary={props.profile?.inf?.displayName}
<Menu secondary={`@${props.profile?.inf?.username}`}
id={menuId} ></ListItemText>
anchorEl={anchor()} </MenuItem>
open={open()}
onClose={onClose}
MenuListProps={{
"aria-labelledby": buttonId,
sx: {
minWidth: "220px",
},
}}
anchorOrigin={{
vertical: "top",
horizontal: "right",
}}
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
>
<MenuItem>
<ListItemAvatar>
<Avatar src={props.profile?.avatar}></Avatar>
</ListItemAvatar>
<ListItemText
primary={props.profile?.displayName}
secondary={`@${props.profile?.username}`}
></ListItemText>
</MenuItem>
<MenuItem> <MenuItem>
<ListItemIcon> <ListItemIcon>
<BookmarkIcon /> <BookmarkIcon />
</ListItemIcon> </ListItemIcon>
<ListItemText>Bookmarks</ListItemText> <ListItemText>Bookmarks</ListItemText>
</MenuItem> </MenuItem>
<MenuItem> <MenuItem>
<ListItemIcon> <ListItemIcon>
<LikeIcon /> <LikeIcon />
</ListItemIcon> </ListItemIcon>
<ListItemText>Likes</ListItemText> <ListItemText>Likes</ListItemText>
</MenuItem> </MenuItem>
<MenuItem> <MenuItem>
<ListItemIcon> <ListItemIcon>
<ListIcon /> <ListIcon />
</ListItemIcon> </ListItemIcon>
<ListItemText>Lists</ListItemText> <ListItemText>Lists</ListItemText>
</MenuItem> </MenuItem>
<Divider />
<Show when={props.children}>
{props.children}
<Divider /> <Divider />
<Show when={props.children}> </Show>
{props.children} <MenuItem component={A} href="/settings" onClick={onClose}>
<Divider /> <ListItemIcon>
</Show> <SettingsIcon />
<MenuItem component={A} href="/settings" onClick={onClose}> </ListItemIcon>
<ListItemIcon> <ListItemText>Settings</ListItemText>
<SettingsIcon /> </MenuItem>
</ListItemIcon> </Menu>
<ListItemText>Settings</ListItemText>
</MenuItem>
</Menu>
</> </>
); );
}; };

View file

@ -15,7 +15,7 @@ import {
ArrowBack as BackIcon, ArrowBack as BackIcon,
Close as CloseIcon, Close as CloseIcon,
} from "@suid/icons-material"; } from "@suid/icons-material";
import { createUnauthorizedClient, useSessions } from "../masto/clients"; import { useSessionForAcctStr } from "../masto/clients";
import { resolveCustomEmoji } from "../masto/toot"; import { resolveCustomEmoji } from "../masto/toot";
import RegularToot from "./RegularToot"; import RegularToot from "./RegularToot";
import type { mastodon } from "masto"; import type { mastodon } from "masto";
@ -45,24 +45,10 @@ const TootBottomSheet: Component = (props) => {
tootReply?: boolean; tootReply?: boolean;
}>(); }>();
const navigate = useNavigate(); const navigate = useNavigate();
const allSession = useSessions();
const time = createTimeSource(); const time = createTimeSource();
const [isInTyping, setInTyping] = createSignal(false); const [isInTyping, setInTyping] = createSignal(false);
const acctText = () => decodeURIComponent(params.acct); const acctText = () => decodeURIComponent(params.acct);
const session = () => { const session = useSessionForAcctStr(acctText)
const [inputUsername, inputSite] = acctText().split("@", 2);
const authedSession = allSession().find(
(x) =>
x.account.site === inputSite &&
x.account.inf?.username === inputUsername,
);
return (
authedSession ?? {
client: createUnauthorizedClient(inputSite),
account: undefined,
}
);
};
const pushedCount = () => { const pushedCount = () => {
return location.state?.tootBottomSheetPushedCount || 0; return location.state?.tootBottomSheetPushedCount || 0;
@ -175,7 +161,7 @@ const TootBottomSheet: Component = (props) => {
return; return;
} }
setCache(params.acct, status); setCache(params.acct, status);
navigate(`/${params.acct}/${status.id}`, { navigate(`/${params.acct}/toot/${status.id}`, {
state: { state: {
tootBottomSheetPushedCount: pushedCount() + 1, tootBottomSheetPushedCount: pushedCount() + 1,
}, },

104
src/timelines/TootList.tsx Normal file
View file

@ -0,0 +1,104 @@
import {
Component,
For,
onCleanup,
createSignal,
Show,
untrack,
Match,
Switch as JsSwitch,
ErrorBoundary,
type Ref,
} from "solid-js";
import { type mastodon } from "masto";
import { Button, LinearProgress } from "@suid/material";
import { createTimeline } from "../masto/timelines";
import { vibrate } from "../platform/hardware";
import PullDownToRefresh from "./PullDownToRefresh";
import TootComposer from "./TootComposer";
import Thread from "./Thread.jsx";
import { useDefaultSession } from "../masto/clients";
const TootList: Component<{
ref?: Ref<HTMLDivElement>;
threads: string[];
onUnknownThread: (id: string) => { value: mastodon.v1.Status }[] | undefined;
onChangeToot: (id: string, value: mastodon.v1.Status) => void;
}> = (props) => {
const session = useDefaultSession();
const [expandedThreadId, setExpandedThreadId] = createSignal<string>();
const onBookmark = async (
client: mastodon.rest.Client,
status: mastodon.v1.Status,
) => {
const result = await (status.bookmarked
? client.v1.statuses.$select(status.id).unbookmark()
: client.v1.statuses.$select(status.id).bookmark());
props.onChangeToot(result.id, result);
};
const onBoost = async (
client: mastodon.rest.Client,
status: mastodon.v1.Status,
) => {
vibrate(50);
const rootStatus = status.reblog ? status.reblog : status;
const reblogged = rootStatus.reblogged;
if (status.reblog) {
status.reblog = { ...status.reblog, reblogged: !reblogged };
props.onChangeToot(status.id, status);
} else {
props.onChangeToot(
status.id,
Object.assign(status, {
reblogged: !reblogged,
}),
);
}
const result = reblogged
? await client.v1.statuses.$select(status.id).unreblog()
: (await client.v1.statuses.$select(status.id).reblog()).reblog!;
props.onChangeToot(
status.id,
Object.assign(status.reblog ?? status, result.reblog),
);
};
return (
<ErrorBoundary
fallback={(err, reset) => {
return <p>Oops: {String(err)}</p>;
}}
>
<div ref={props.ref}>
<For each={props.threads}>
{(itemId, index) => {
const path = props.onUnknownThread(itemId)!;
const toots = path.reverse().map((x) => x.value);
return (
<Thread
toots={toots}
onBoost={onBoost}
onBookmark={onBookmark}
onReply={({ status }, element) => {}}
client={session()?.client!}
isExpended={(status) => status.id === expandedThreadId()}
onItemClick={(status, event) => {
if (status.id !== expandedThreadId()) {
setExpandedThreadId((x) => (x ? undefined : status.id));
} else {
// TODO: open full-screen toot
}
}}
/>
);
}}
</For>
</div>
</ErrorBoundary>
);
};
export default TootList;