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;
 |