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, MoreVert, OpenInBrowser, PersonOff, PlaylistAdd, Send, Share, SmartToySharp, Subject, Translate, 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-details="Subscribe or Unsubscribe this account on your home timeline" > <ListItemAvatar> <Avatar src={session().account?.inf?.avatar}></Avatar> </ListItemAvatar> <ListItemText secondary={relationship()?.following ? "Subscribed" : 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="bot-mark" aria-label="Bot" /> </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;