import { createRenderEffect, createResource, createSignal, createUniqueId, For, onCleanup, Show, type Component, } from "solid-js"; import Scaffold from "../material/Scaffold"; import { AppBar, Avatar, Button, CircularProgress, Divider, IconButton, ListItemIcon, ListItemText, MenuItem, Toolbar, } from "@suid/material"; import { Close, Edit, ExpandMore, MoreVert, OpenInBrowser, Send, Share, 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 { createTimeSource, TimeSourceProvider } from "../platform/timesrc"; import TootFilterButton from "./TootFilterButton"; import Menu from "../material/Menu"; import { share } from "../platform/share"; 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 [menuOpen, setMenuOpen] = createSignal(false); 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 [recentTootFilter, setRecentTootFilter] = createSignal({ 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 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; 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: flex; flex-flow: row wrap; gap: 16px; align-items: center; & > :nth-child(2) { flex-grow: 1; } & > :last-child { flex-grow: 1; text-align: right; } } .name-grp { display: flex; flex-flow: column nowrap; } table.acct-fields { word-break: break-all; & td > :global(a) { display: inline-flex; align-items: center; color: inherit; min-height: 44px; } & :global(a > .invisible) { display: none; } & :global(svg) { vertical-align: middle; } } .page-title { flex-grow: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } `; 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, paddingTop: "var(--safe-area-inset-top)", }} > <IconButton color="inherit" onClick={[navigate, -1]} aria-label="Close" > <Close /> </IconButton> <Title use:solid-styled class="page-title" style={{ visibility: scrolledPastBanner() ? undefined : "hidden", }} ref={(e: HTMLElement) => createRenderEffect(() => (e.innerHTML = displayName())) } ></Title> <IconButton id={menuButId} color="inherit" onClick={[setMenuOpen, true]} > <MoreVert /> </IconButton> </Toolbar> </AppBar> } > <Menu open={menuOpen()} onClose={[setMenuOpen, false]} anchor={() => document.getElementById(menuButId)!.getBoundingClientRect() } > <MenuItem disabled> <ListItemIcon> <Edit /> </ListItemIcon> <ListItemText>Edit...</ListItemText> </MenuItem> <Divider /> <MenuItem disabled> <ListItemIcon> <Send /> </ListItemIcon> <ListItemText>Mention {profile()?.displayName || ""}...</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={{ 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", }); ins.destroy(); }} ></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> <div> <TootFilterButton options={{ boost: "Boosts", reply: "Replies", original: "Originals", }} applied={recentTootFilter()} onApply={setRecentTootFilter} disabledKeys={["original"]} ></TootFilterButton> </div> <TimeSourceProvider value={time}> <TootList 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" size="large" color="primary" onClick={[refetchRecentToots, "prev"]} disabled={recentTootChunk.loading} > <Show when={recentTootChunk.loading} fallback={<ExpandMore />}> <CircularProgress sx={{ width: "24px", height: "24px" }} /> </Show> </IconButton> </div> </Show> </Scaffold> ); }; export default Profile;