tutu/src/profiles/Profile.tsx

432 lines
12 KiB
TypeScript
Raw Normal View History

2024-10-18 19:15:35 +08:00
import {
catchError,
2024-10-18 19:15:35 +08:00
createRenderEffect,
createResource,
createSignal,
2024-10-25 22:37:50 +08:00
createUniqueId,
2024-10-18 19:15:35 +08:00
For,
2024-11-03 20:50:31 +08:00
Switch,
Match,
2024-10-18 19:15:35 +08:00
onCleanup,
Show,
type Component,
} from "solid-js";
import Scaffold from "../material/Scaffold";
2024-10-25 22:37:50 +08:00
import {
AppBar,
Avatar,
Button,
CircularProgress,
2024-10-25 22:37:50 +08:00
Divider,
IconButton,
ListItemIcon,
ListItemText,
MenuItem,
Toolbar,
} from "@suid/material";
import {
Close,
Edit,
ExpandMore,
2024-11-03 18:00:13 +08:00
Group,
2024-10-25 22:37:50 +08:00
MoreVert,
OpenInBrowser,
2024-11-03 18:00:13 +08:00
PersonOff,
PlaylistAdd,
2024-10-25 22:37:50 +08:00
Send,
Share,
2024-11-03 18:00:13 +08:00
Translate,
2024-10-25 22:37:50 +08:00
Verified,
} from "@suid/icons-material";
2024-10-18 19:15:35 +08:00
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";
2024-10-31 00:18:47 +08:00
import { createTimeline, createTimelineSnapshot } from "../masto/timelines";
2024-10-18 19:15:35 +08:00
import TootList from "../timelines/TootList";
import { createTimeSource, TimeSourceProvider } from "../platform/timesrc";
import TootFilterButton from "./TootFilterButton";
2024-11-03 20:50:31 +08:00
import Menu, { createManagedMenuState } from "../material/Menu";
2024-10-25 22:37:50 +08:00
import { share } from "../platform/share";
2024-11-03 20:50:31 +08:00
import "./Profile.css";
2024-10-18 19:15:35 +08:00
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();
2024-10-25 22:37:50 +08:00
const menuButId = createUniqueId();
const [menuOpen, setMenuOpen] = createSignal(false);
2024-11-03 20:50:31 +08:00
const [subscribeMenuOpen, setSubscribeMenuOpen] = createSignal(false);
let subcribeMenuAnchor: { top: number; right: number; left: number };
const [openSubscribeMenu, subscribeMenuState] = createManagedMenuState();
2024-10-25 22:37:50 +08:00
2024-10-18 19:15:35 +08:00
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 [profileErrorUncaught] = createResource(
2024-10-18 19:15:35 +08:00
() => [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);
});
2024-11-03 18:00:13 +08:00
const isCurrentSessionProfile = () => {
return session().account?.inf?.url === profile()?.url;
};
const [recentTootFilter, setRecentTootFilter] = createSignal({
2024-10-31 00:18:47 +08:00
pinned: true,
2024-10-25 22:37:50 +08:00
boost: false,
reply: true,
original: true,
});
2024-10-25 22:37:50 +08:00
const [recentToots, recentTootChunk, { refetch: refetchRecentToots }] =
createTimeline(
() => session().client.v1.accounts.$select(params.id).statuses,
() => {
const { boost, reply } = recentTootFilter();
return { limit: 20, excludeReblogs: !boost, excludeReplies: !reply };
},
);
2024-10-18 19:15:35 +08:00
2024-10-31 00:18:47 +08:00
const [pinnedToots, pinnedTootChunk] = createTimelineSnapshot(
() => session().client.v1.accounts.$select(params.id).statuses,
() => {
return { limit: 20, pinned: true };
},
);
2024-10-18 19:15:35 +08:00
const bannerImg = () => profile()?.header;
const avatarImg = () => profile()?.avatar;
const displayName = () =>
resolveCustomEmoji(profile()?.displayName || "", profile()?.emojis ?? []);
const fullUsername = () => (profile()?.acct ? `@${profile()!.acct!}` : ""); // TODO: full user name
2024-10-18 19:15:35 +08:00
const description = () => profile()?.note;
2024-10-31 00:18:47 +08:00
const isTootListLoading = () =>
recentTootChunk.loading ||
(recentTootFilter().pinned && pinnedTootChunk.loading);
2024-10-18 19:15:35 +08:00
return (
<Scaffold
topbar={
<AppBar
position="static"
color={scrolledPastBanner() ? "primary" : "transparent"}
elevation={scrolledPastBanner() ? undefined : 0}
>
<Toolbar
variant="dense"
sx={{
display: "flex",
color: scrolledPastBanner()
? undefined
: bannerSampledColors()?.text,
2024-10-18 19:15:35 +08:00
paddingTop: "var(--safe-area-inset-top)",
}}
>
2024-10-25 22:37:50 +08:00
<IconButton
color="inherit"
onClick={[navigate, -1]}
aria-label="Close"
>
2024-10-18 19:15:35 +08:00
<Close />
</IconButton>
<Title
2024-11-03 20:50:31 +08:00
class="Profile__page-title"
2024-10-18 19:15:35 +08:00
style={{
visibility: scrolledPastBanner() ? undefined : "hidden",
}}
ref={(e: HTMLElement) =>
createRenderEffect(() => (e.innerHTML = displayName()))
}
></Title>
2024-10-25 22:37:50 +08:00
<IconButton
id={menuButId}
color="inherit"
onClick={[setMenuOpen, true]}
>
2024-10-18 19:15:35 +08:00
<MoreVert />
</IconButton>
</Toolbar>
</AppBar>
}
2024-11-03 20:50:31 +08:00
class="Profile"
2024-10-18 19:15:35 +08:00
>
2024-10-25 22:37:50 +08:00
<Menu
open={menuOpen()}
onClose={[setMenuOpen, false]}
anchor={() =>
document.getElementById(menuButId)!.getBoundingClientRect()
}
>
2024-11-03 20:50:31 +08:00
<Show when={session().account}>
<Show
when={isCurrentSessionProfile()}
fallback={
<MenuItem disabled>
<ListItemIcon>
<PlaylistAdd />
</ListItemIcon>
<ListItemText>Subscribe...</ListItemText>
</MenuItem>
}
>
2024-11-03 18:00:13 +08:00
<MenuItem disabled>
<ListItemIcon>
2024-11-03 20:50:31 +08:00
<Edit />
2024-11-03 18:00:13 +08:00
</ListItemIcon>
2024-11-03 20:50:31 +08:00
<ListItemText>Edit...</ListItemText>
2024-11-03 18:00:13 +08:00
</MenuItem>
2024-11-03 20:50:31 +08:00
</Show>
<Divider />
2024-11-03 18:00:13 +08:00
</Show>
2024-10-25 22:37:50 +08:00
<MenuItem disabled>
<ListItemIcon>
2024-11-03 18:00:13 +08:00
<Group />
2024-10-25 22:37:50 +08:00
</ListItemIcon>
2024-11-03 18:00:13 +08:00
<ListItemText>Subscribers</ListItemText>
</MenuItem>
<MenuItem disabled>
<ListItemIcon>
<PersonOff />
</ListItemIcon>
<ListItemText>Blocklist</ListItemText>
</MenuItem>
<MenuItem disabled>
<ListItemIcon>
<Translate />
</ListItemIcon>
<ListItemText>Translate Name and Bio...</ListItemText>
2024-10-25 22:37:50 +08:00
</MenuItem>
<MenuItem disabled>
<ListItemIcon>
<Send />
</ListItemIcon>
2024-11-03 18:00:13 +08:00
<ListItemText>Mention in...</ListItemText>
2024-10-25 22:37:50 +08:00
</MenuItem>
<Divider />
<MenuItem
component={"a"}
href={profile()?.url}
target="_blank"
rel="noopener noreferrer"
>
2024-10-25 22:37:50 +08:00
<ListItemIcon>
<OpenInBrowser />
</ListItemIcon>
<ListItemText>Open in browser...</ListItemText>
</MenuItem>
<MenuItem onClick={() => share({ url: profile()?.url })}>
<ListItemIcon>
<Share />
</ListItemIcon>
<ListItemText>Share...</ListItemText>
</MenuItem>
</Menu>
2024-10-18 19:15:35 +08:00
<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": "cover",
2024-10-18 19:15:35 +08:00
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",
});
ins.destroy();
2024-10-18 19:15:35 +08:00
}}
></img>
</div>
2024-11-03 20:50:31 +08:00
<Menu {...subscribeMenuState}>
<MenuItem disabled>
<ListItemText>
<span>{session().account?.inf?.displayName || ""}</span>
<span>'s timeline</span>
</ListItemText>
</MenuItem>
</Menu>
2024-10-18 19:15:35 +08:00
<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>
2024-11-03 20:50:31 +08:00
<Switch>
<Match when={!session().account}>{<></>}</Match>
<Match when={isCurrentSessionProfile()}>
<IconButton color="inherit">
<Edit />
</IconButton>
</Match>
<Match when={true}>
<Button
variant="contained"
color="secondary"
onClick={(event) => {
openSubscribeMenu(
event.currentTarget.getBoundingClientRect(),
);
}}
>
Subscribe
</Button>
</Match>
</Switch>
2024-10-18 19:15:35 +08:00
</div>
</div>
<div
2024-10-29 19:48:15 +08:00
class="description"
2024-10-18 19:15:35 +08:00
ref={(e) =>
createRenderEffect(() => (e.innerHTML = description() || ""))
}
></div>
2024-10-18 19:15:35 +08:00
<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>
2024-10-31 00:00:07 +08:00
<div class="toot-list-toolbar">
<TootFilterButton
options={{
2024-10-31 00:18:47 +08:00
pinned: "Pinneds",
2024-10-25 22:37:50 +08:00
boost: "Boosts",
reply: "Replies",
original: "Originals",
}}
applied={recentTootFilter()}
onApply={setRecentTootFilter}
disabledKeys={["original"]}
></TootFilterButton>
</div>
2024-10-18 19:15:35 +08:00
<TimeSourceProvider value={time}>
<Show when={recentTootFilter().pinned && pinnedToots.list.length > 0}>
2024-10-31 00:18:47 +08:00
<TootList
threads={pinnedToots.list}
onUnknownThread={pinnedToots.getPath}
onChangeToot={pinnedToots.set}
/>
<Divider />
</Show>
2024-10-18 19:15:35 +08:00
<TootList
threads={recentToots.list}
onUnknownThread={recentToots.getPath}
onChangeToot={recentToots.set}
/>
</TimeSourceProvider>
2024-10-25 22:37:50 +08:00
<Show when={!recentTootChunk()?.done}>
<div
style={{
"text-align": "center",
"padding-bottom": "var(--safe-area-inset-bottom)",
}}
>
<IconButton
aria-label="Load More"
size="large"
color="primary"
onClick={[refetchRecentToots, "prev"]}
2024-10-31 00:18:47 +08:00
disabled={isTootListLoading()}
2024-10-25 22:37:50 +08:00
>
2024-10-31 00:18:47 +08:00
<Show when={isTootListLoading()} fallback={<ExpandMore />}>
<CircularProgress sx={{ width: "24px", height: "24px" }} />
</Show>
2024-10-25 22:37:50 +08:00
</IconButton>
</div>
</Show>
2024-10-18 19:15:35 +08:00
</Scaffold>
);
};
export default Profile;