556 lines
16 KiB
TypeScript
556 lines
16 KiB
TypeScript
import {
|
|
catchError,
|
|
createRenderEffect,
|
|
createResource,
|
|
createSignal,
|
|
createUniqueId,
|
|
For,
|
|
Switch,
|
|
Match,
|
|
onCleanup,
|
|
Show,
|
|
type Component,
|
|
createMemo,
|
|
} from "solid-js";
|
|
import Scaffold from "../material/Scaffold";
|
|
import {
|
|
AppBar,
|
|
Avatar,
|
|
Button,
|
|
Checkbox,
|
|
CircularProgress,
|
|
Divider,
|
|
IconButton,
|
|
ListItemAvatar,
|
|
ListItemIcon,
|
|
ListItemSecondaryAction,
|
|
ListItemText,
|
|
MenuItem,
|
|
Toolbar,
|
|
} from "@suid/material";
|
|
import {
|
|
Close,
|
|
Edit,
|
|
ExpandMore,
|
|
Group,
|
|
Lock,
|
|
MoreVert,
|
|
OpenInBrowser,
|
|
PersonOff,
|
|
PlaylistAdd,
|
|
Send,
|
|
Share,
|
|
SmartToySharp,
|
|
Subject,
|
|
Verified,
|
|
} from "@suid/icons-material";
|
|
import { Body2, 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 { createTimeline, createTimelineSnapshot } from "../masto/timelines";
|
|
import TootList from "../timelines/TootList";
|
|
import { createTimeSource, TimeSourceProvider } from "../platform/timesrc";
|
|
import TootFilterButton from "./TootFilterButton";
|
|
import Menu, { createManagedMenuState } from "../material/Menu";
|
|
import { share } from "../platform/share";
|
|
import "./Profile.css";
|
|
|
|
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 menuButId = createUniqueId();
|
|
const recentTootListId = createUniqueId();
|
|
const optMenuId = createUniqueId();
|
|
|
|
const [menuOpen, setMenuOpen] = createSignal(false);
|
|
|
|
const [openSubscribeMenu, subscribeMenuState] = createManagedMenuState();
|
|
|
|
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 [profileUncaught] = createResource(
|
|
() => [session().client, params.id] as const,
|
|
async ([client, id]) => {
|
|
return await client.v1.accounts.$select(id).fetch();
|
|
},
|
|
);
|
|
|
|
const profile = () => {
|
|
try {
|
|
return profileUncaught();
|
|
} catch (reason) {
|
|
console.error(reason);
|
|
}
|
|
};
|
|
|
|
const isCurrentSessionProfile = () => {
|
|
return session().account?.inf?.url === profile()?.url;
|
|
};
|
|
|
|
const [recentTootFilter, setRecentTootFilter] = createSignal({
|
|
pinned: true,
|
|
boost: false,
|
|
reply: true,
|
|
original: true,
|
|
});
|
|
|
|
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 };
|
|
},
|
|
);
|
|
|
|
const [pinnedToots, pinnedTootChunk] = createTimelineSnapshot(
|
|
() => session().client.v1.accounts.$select(params.id).statuses,
|
|
() => {
|
|
return { limit: 20, pinned: true };
|
|
},
|
|
);
|
|
|
|
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 = () =>
|
|
resolveCustomEmoji(profile()?.displayName || "", profile()?.emojis ?? []);
|
|
const fullUsername = () => (profile()?.acct ? `@${profile()!.acct!}` : ""); // TODO: full user name
|
|
const description = () => profile()?.note;
|
|
|
|
const isTootListLoading = () =>
|
|
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}
|
|
>
|
|
<Toolbar
|
|
variant="dense"
|
|
sx={{
|
|
display: "flex",
|
|
color: scrolledPastBanner()
|
|
? undefined
|
|
: bannerSampledColors()?.text,
|
|
paddingTop: "var(--safe-area-inset-top)",
|
|
}}
|
|
>
|
|
<IconButton
|
|
color="inherit"
|
|
onClick={[navigate, -1]}
|
|
aria-label="Close"
|
|
>
|
|
<Close />
|
|
</IconButton>
|
|
<Title
|
|
class="Profile__page-title"
|
|
style={{
|
|
visibility: scrolledPastBanner() ? undefined : "hidden",
|
|
}}
|
|
ref={(e: HTMLElement) =>
|
|
createRenderEffect(() => (e.innerHTML = displayName()))
|
|
}
|
|
></Title>
|
|
|
|
<IconButton
|
|
id={menuButId}
|
|
aria-controls={optMenuId}
|
|
color="inherit"
|
|
onClick={[setMenuOpen, true]}
|
|
aria-label="Open Options for the Profile"
|
|
>
|
|
<MoreVert />
|
|
</IconButton>
|
|
</Toolbar>
|
|
</AppBar>
|
|
}
|
|
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()}
|
|
fallback={
|
|
<MenuItem
|
|
onClick={(event) => {
|
|
const { left, right, top } =
|
|
event.currentTarget.getBoundingClientRect();
|
|
openSubscribeMenu({
|
|
left,
|
|
right,
|
|
top,
|
|
e: 1,
|
|
});
|
|
}}
|
|
>
|
|
<ListItemIcon>
|
|
<PlaylistAdd />
|
|
</ListItemIcon>
|
|
<ListItemText>Subscribe...</ListItemText>
|
|
</MenuItem>
|
|
}
|
|
>
|
|
<MenuItem disabled>
|
|
<ListItemIcon>
|
|
<Edit />
|
|
</ListItemIcon>
|
|
<ListItemText>Edit...</ListItemText>
|
|
</MenuItem>
|
|
</Show>
|
|
<Divider />
|
|
</Show>
|
|
<MenuItem disabled>
|
|
<ListItemIcon>
|
|
<Group />
|
|
</ListItemIcon>
|
|
<ListItemText>Followers</ListItemText>
|
|
<ListItemSecondaryAction>
|
|
<span aria-label="The number of the account follower">
|
|
{profile()?.followersCount ?? ""}
|
|
</span>
|
|
</ListItemSecondaryAction>
|
|
</MenuItem>
|
|
<MenuItem disabled>
|
|
<ListItemIcon>
|
|
<Subject />
|
|
</ListItemIcon>
|
|
<ListItemText>Following</ListItemText>
|
|
<ListItemSecondaryAction>
|
|
<span aria-label="The number the account following">
|
|
{profile()?.followingCount ?? ""}
|
|
</span>
|
|
</ListItemSecondaryAction>
|
|
</MenuItem>
|
|
<MenuItem disabled>
|
|
<ListItemIcon>
|
|
<PersonOff />
|
|
</ListItemIcon>
|
|
<ListItemText>Blocklist</ListItemText>
|
|
</MenuItem>
|
|
<MenuItem disabled>
|
|
<ListItemIcon>
|
|
<Send />
|
|
</ListItemIcon>
|
|
<ListItemText>Mention in...</ListItemText>
|
|
</MenuItem>
|
|
<Divider />
|
|
<MenuItem
|
|
component={"a"}
|
|
href={profile()?.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
<ListItemIcon>
|
|
<OpenInBrowser />
|
|
</ListItemIcon>
|
|
<ListItemText>Open in browser...</ListItemText>
|
|
</MenuItem>
|
|
<MenuItem onClick={() => share({ url: profile()?.url })}>
|
|
<ListItemIcon>
|
|
<Share />
|
|
</ListItemIcon>
|
|
<ListItemText>Share...</ListItemText>
|
|
</MenuItem>
|
|
</Menu>
|
|
<div
|
|
style={{
|
|
height: `${268 * (Math.min(560, windowSize.width) / 560)}px`,
|
|
}}
|
|
class="banner"
|
|
role="presentation"
|
|
>
|
|
<img
|
|
ref={(e) => obx.observe(e)}
|
|
src={bannerImg()}
|
|
crossOrigin="anonymous"
|
|
alt={`Banner image for ${profile()?.displayName || "the user"}`}
|
|
onLoad={(event) => {
|
|
const ins = new FastAverageColor();
|
|
const colors = ins.getColor(event.currentTarget);
|
|
setBannerSampledColors({
|
|
average: colors.hex,
|
|
text: colors.isDark ? "white" : "black",
|
|
});
|
|
ins.destroy();
|
|
}}
|
|
></img>
|
|
</div>
|
|
|
|
<Menu {...subscribeMenuState}>
|
|
<MenuItem
|
|
onClick={toggleSubscribeHome}
|
|
aria-label={`${relationship()?.following ? "Unfollow" : "Follow"} on your home timeline`}
|
|
>
|
|
<ListItemAvatar>
|
|
<Avatar src={session().account?.inf?.avatar}></Avatar>
|
|
</ListItemAvatar>
|
|
<ListItemText
|
|
secondary={
|
|
relationship()?.following
|
|
? undefined
|
|
: profile()?.locked
|
|
? "A request will be sent"
|
|
: undefined
|
|
}
|
|
>
|
|
<span ref={useSessionDisplayName}></span>
|
|
<span>'s Home</span>
|
|
</ListItemText>
|
|
|
|
<Checkbox checked={relationship()?.following ?? false} />
|
|
</MenuItem>
|
|
</Menu>
|
|
|
|
<div
|
|
class="intro"
|
|
style={{
|
|
"background-color": bannerSampledColors()?.average,
|
|
color: bannerSampledColors()?.text,
|
|
}}
|
|
>
|
|
<section class="acct-grp">
|
|
<Avatar
|
|
src={avatarImg()}
|
|
alt={`${profile()?.displayName || "the user"}'s avatar`}
|
|
sx={{
|
|
marginTop: "calc(-16px - 72px / 2)",
|
|
width: "72px",
|
|
height: "72px",
|
|
}}
|
|
></Avatar>
|
|
<div class="name-grp">
|
|
<div class="display-name">
|
|
<Show when={profile()?.bot}>
|
|
<SmartToySharp class="acct-mark" aria-label="Bot" />
|
|
</Show>
|
|
<Show when={profile()?.locked}>
|
|
<Lock class="acct-mark" aria-label="Locked" />
|
|
</Show>
|
|
<Body2
|
|
component="span"
|
|
ref={(e: HTMLElement) =>
|
|
createRenderEffect(() => (e.innerHTML = displayName()))
|
|
}
|
|
aria-label="Display name"
|
|
></Body2>
|
|
</div>
|
|
<span aria-label="Complete username">{fullUsername()}</span>
|
|
</div>
|
|
<div role="presentation">
|
|
<Switch>
|
|
<Match
|
|
when={
|
|
!session().account ||
|
|
profileUncaught.loading ||
|
|
profileUncaught.error
|
|
}
|
|
>
|
|
{<></>}
|
|
</Match>
|
|
<Match when={isCurrentSessionProfile()}>
|
|
<IconButton color="inherit">
|
|
<Edit />
|
|
</IconButton>
|
|
</Match>
|
|
<Match when={true}>
|
|
<Button
|
|
variant="contained"
|
|
color="secondary"
|
|
onClick={(event) => {
|
|
openSubscribeMenu(
|
|
event.currentTarget.getBoundingClientRect(),
|
|
);
|
|
}}
|
|
>
|
|
{relationship()?.following ? "Subscribed" : "Subscribe"}
|
|
</Button>
|
|
</Match>
|
|
</Switch>
|
|
</div>
|
|
</section>
|
|
<section
|
|
class="description"
|
|
aria-label={`${profile()?.displayName || "the user"}'s description`}
|
|
ref={(e) =>
|
|
createRenderEffect(() => (e.innerHTML = description() || ""))
|
|
}
|
|
></section>
|
|
|
|
<table
|
|
class="acct-fields"
|
|
aria-label={`${profile()?.displayName || "the user"}'s 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>
|
|
|
|
<div class="toot-list-toolbar">
|
|
<TootFilterButton
|
|
options={{
|
|
pinned: "Pinneds",
|
|
boost: "Boosts",
|
|
reply: "Replies",
|
|
original: "Originals",
|
|
}}
|
|
applied={recentTootFilter()}
|
|
onApply={setRecentTootFilter}
|
|
disabledKeys={["original"]}
|
|
></TootFilterButton>
|
|
</div>
|
|
|
|
<TimeSourceProvider value={time}>
|
|
<Show when={recentTootFilter().pinned && pinnedToots.list.length > 0}>
|
|
<TootList
|
|
threads={pinnedToots.list}
|
|
onUnknownThread={pinnedToots.getPath}
|
|
onChangeToot={pinnedToots.set}
|
|
/>
|
|
<Divider />
|
|
</Show>
|
|
<TootList
|
|
id={recentTootListId}
|
|
threads={recentToots.list}
|
|
onUnknownThread={recentToots.getPath}
|
|
onChangeToot={recentToots.set}
|
|
/>
|
|
</TimeSourceProvider>
|
|
|
|
<Show when={!recentTootChunk()?.done}>
|
|
<div
|
|
style={{
|
|
"text-align": "center",
|
|
"padding-bottom": "var(--safe-area-inset-bottom)",
|
|
}}
|
|
>
|
|
<IconButton
|
|
aria-label="Load More"
|
|
aria-controls={recentTootListId}
|
|
size="large"
|
|
color="primary"
|
|
onClick={[refetchRecentToots, "prev"]}
|
|
disabled={isTootListLoading()}
|
|
>
|
|
<Show when={isTootListLoading()} fallback={<ExpandMore />}>
|
|
<CircularProgress sx={{ width: "24px", height: "24px" }} />
|
|
</Show>
|
|
</IconButton>
|
|
</div>
|
|
</Show>
|
|
</Scaffold>
|
|
);
|
|
};
|
|
|
|
export default Profile;
|