253 lines
6.9 KiB
TypeScript
253 lines
6.9 KiB
TypeScript
|
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;
|