This commit is contained in:
parent
e9c39492ec
commit
bea1d6abfa
7 changed files with 474 additions and 80 deletions
|
@ -24,9 +24,7 @@ import {
|
|||
ResultDispatcher,
|
||||
type JSONRPC,
|
||||
} from "./serviceworker/workerrpc.js";
|
||||
import {
|
||||
Service
|
||||
} from "./serviceworker/services.js"
|
||||
import { Service } from "./serviceworker/services.js";
|
||||
import { makeEventListener } from "@solid-primitives/event-listener";
|
||||
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 RegionSettings = lazy(() => import("./settings/Region.jsx"));
|
||||
const UnexpectedError = lazy(() => import("./UnexpectedError.js"));
|
||||
const Profile = lazy(() => import("./profiles/Profile.js"));
|
||||
|
||||
const Routing: Component = () => {
|
||||
return (
|
||||
|
@ -54,6 +53,7 @@ const Routing: Component = () => {
|
|||
<Route path="/motions" component={MotionSettings}></Route>
|
||||
</Route>
|
||||
<Route path="/:acct/toot/:id" component={TootBottomSheet}></Route>
|
||||
<Route path="/:acct/profile/:id" component={Profile}></Route>
|
||||
</Route>
|
||||
<Route path={"/accounts"}>
|
||||
<Route path={"/sign-in"} component={AccountSignIn} />
|
||||
|
|
|
@ -4,7 +4,6 @@ import {
|
|||
createMemo,
|
||||
createRenderEffect,
|
||||
createResource,
|
||||
Signal,
|
||||
useContext,
|
||||
} from "solid-js";
|
||||
import { Account } from "../accounts/stores";
|
||||
|
@ -78,6 +77,26 @@ function useSessionsRaw() {
|
|||
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.
|
||||
*
|
||||
|
@ -98,7 +117,6 @@ export function useSessionForAcctStr(acct: Accessor<string>) {
|
|||
const allSessions = useSessions()
|
||||
|
||||
return createMemo(() => {
|
||||
const parts = acct().split("@", 2)
|
||||
const [inputUsername, inputSite] = acct().split("@", 2);
|
||||
const authedSession = allSessions().find(
|
||||
(x) =>
|
||||
|
@ -113,3 +131,5 @@ export function useSessionForAcctStr(acct: Accessor<string>) {
|
|||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -23,11 +23,22 @@
|
|||
|
||||
box-shadow: var(--tutu-shadow-e16);
|
||||
|
||||
:global(.MuiToolbar-root) > :global(.MuiButtonBase-root):first-child {
|
||||
:global(.MuiToolbar-root) {
|
||||
> :global(.MuiButtonBase-root) {
|
||||
|
||||
&:first-child {
|
||||
margin-left: -0.5em;
|
||||
margin-right: 24px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-right: -0.5em;
|
||||
margin-left: 24px;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
& {
|
||||
left: 0;
|
||||
|
|
252
src/profiles/Profile.tsx
Normal file
252
src/profiles/Profile.tsx
Normal 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;
|
|
@ -213,7 +213,7 @@ const Home: ParentComponent = (props) => {
|
|||
Public
|
||||
</Tab>
|
||||
</Tabs>
|
||||
<ProfileMenuButton profile={profile()}>
|
||||
<ProfileMenuButton profile={profiles()[0]}>
|
||||
<MenuItem
|
||||
onClick={(e) =>
|
||||
$settings.setKey(
|
||||
|
|
|
@ -24,7 +24,10 @@ import {
|
|||
import { A } from "@solidjs/router";
|
||||
|
||||
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;
|
||||
onClose?: () => void;
|
||||
}> = (props) => {
|
||||
|
@ -57,8 +60,8 @@ const ProfileMenuButton: ParentComponent<{
|
|||
aria-expanded={open() ? "true" : undefined}
|
||||
>
|
||||
<Avatar
|
||||
alt={`${props.profile?.displayName}'s avatar`}
|
||||
src={props.profile?.avatar}
|
||||
alt={`${props.profile?.inf?.displayName}'s avatar`}
|
||||
src={props.profile?.inf?.avatar}
|
||||
></Avatar>
|
||||
</ButtonBase>
|
||||
<Menu
|
||||
|
@ -81,13 +84,17 @@ const ProfileMenuButton: ParentComponent<{
|
|||
horizontal: "right",
|
||||
}}
|
||||
>
|
||||
<MenuItem>
|
||||
<MenuItem
|
||||
component={A}
|
||||
href={`/${encodeURIComponent(`${props.profile?.inf?.username}@${props.profile?.account.site}`)}/profile/${props.profile?.inf?.id}`}
|
||||
disabled={!props.profile}
|
||||
>
|
||||
<ListItemAvatar>
|
||||
<Avatar src={props.profile?.avatar}></Avatar>
|
||||
<Avatar src={props.profile?.inf?.avatar}></Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={props.profile?.displayName}
|
||||
secondary={`@${props.profile?.username}`}
|
||||
primary={props.profile?.inf?.displayName}
|
||||
secondary={`@${props.profile?.inf?.username}`}
|
||||
></ListItemText>
|
||||
</MenuItem>
|
||||
|
||||
|
|
104
src/timelines/TootList.tsx
Normal file
104
src/timelines/TootList.tsx
Normal 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;
|
Loading…
Reference in a new issue