personal web client for Bluesky
typescript solidjs bluesky atcute
at trunk 5.3 kB view raw
1import { Match, Show, Switch, createMemo } from 'solid-js'; 2 3import type { AppBskyGraphDefs } from '@atcute/bluesky'; 4import type { Did, RecordKey, ResourceUri } from '@atcute/lexicons'; 5import { useQueryClient } from '@mary/solid-query'; 6 7import { ContextContentMedia } from '~/api/moderation/constants'; 8import { moderateGeneric } from '~/api/moderation/entities/generic'; 9import { moderateProfile } from '~/api/moderation/entities/profile'; 10import { precacheProfile } from '~/api/queries-cache/profile-precache'; 11import { createListMetaQuery } from '~/api/queries/list'; 12import { createListMembersQuery } from '~/api/queries/list-members'; 13import { makeAtUri } from '~/api/types/at-uri'; 14import { trimRichText } from '~/api/utils/richtext'; 15 16import { useParams, useTitle } from '~/lib/navigation/router'; 17import { inject } from '~/lib/states/singleton'; 18import ModerationService from '~/lib/states/singletons/moderation'; 19 20import Avatar, { getUserAvatarType } from '~/components/avatar'; 21import * as Boxed from '~/components/boxed'; 22import Button from '~/components/button'; 23import CircularProgressView from '~/components/circular-progress-view'; 24import ErrorView from '~/components/error-view'; 25import IconButton from '~/components/icon-button'; 26import MoreHorizOutlinedIcon from '~/components/icons-central/more-horiz-outline'; 27import * as Page from '~/components/page'; 28import PagedList from '~/components/paged-list'; 29import ProfileItem from '~/components/profiles/profile-item'; 30import VirtualItem from '~/components/virtual-item'; 31 32const ProfileModerationListPage = () => { 33 const { did, rkey } = useParams<{ 34 did: Did; 35 rkey: RecordKey; 36 }>(); 37 38 const uri = makeAtUri(did, 'app.bsky.graph.list', rkey); 39 const query = createListMetaQuery(() => uri); 40 41 useTitle(() => { 42 const data = query.data; 43 if (data) { 44 return `${data.name}${import.meta.env.VITE_APP_NAME}`; 45 } 46 47 return `Moderation List — ${import.meta.env.VITE_APP_NAME}`; 48 }); 49 50 return ( 51 <> 52 <Page.Header> 53 <Page.HeaderAccessory> 54 <Page.Back to={`/${did}`} /> 55 </Page.HeaderAccessory> 56 57 <Show when={!query.data}> 58 <Page.Heading title="Moderation List" /> 59 </Show> 60 61 <Show when={query.data}> 62 {(list) => ( 63 <Page.HeaderAccessory> 64 <Show when={!list().viewer?.blocked && !list().viewer?.muted}> 65 <Button variant="primary" size="sm"> 66 Subscribe 67 </Button> 68 </Show> 69 70 <IconButton 71 title="More actions" 72 icon={MoreHorizOutlinedIcon} 73 onClick={() => { 74 // 75 }} 76 /> 77 </Page.HeaderAccessory> 78 )} 79 </Show> 80 </Page.Header> 81 82 <Switch> 83 <Match when={query.data}> 84 {(list) => ( 85 <> 86 <InfoView list={list()} /> 87 <MembersList uri={uri} /> 88 </> 89 )} 90 </Match> 91 92 <Match when={query.error}> 93 {(error) => <ErrorView error={error()} onRetry={() => query.refetch()} />} 94 </Match> 95 96 <Match when> 97 <CircularProgressView /> 98 </Match> 99 </Switch> 100 </> 101 ); 102}; 103 104export default ProfileModerationListPage; 105 106const InfoView = (props: { list: AppBskyGraphDefs.ListView }) => { 107 const queryClient = useQueryClient(); 108 109 const moderationOptions = inject(ModerationService); 110 111 const list = () => props.list; 112 const creator = () => list().creator; 113 114 const moderation = createMemo(() => { 115 return [ 116 ...moderateGeneric(list(), creator().did, moderationOptions()), 117 ...moderateProfile(creator(), moderationOptions()), 118 ]; 119 }); 120 121 return ( 122 <Boxed.Container> 123 <div class="px-4"> 124 <div class="flex gap-4"> 125 <Avatar 126 type="list" 127 src={list().avatar} 128 size={null} 129 moderation={moderation()} 130 modContext={ContextContentMedia} 131 class="h-13 w-13" 132 /> 133 134 <div class="flex min-w-0 grow flex-col"> 135 <p class="mb-1 mt-0.75 overflow-hidden text-ellipsis break-words text-lg font-bold leading-none"> 136 {list().name} 137 </p> 138 139 <a 140 href={`/${creator().did}`} 141 onClick={() => precacheProfile(queryClient, creator())} 142 class="group mt-1 flex items-center" 143 > 144 <Avatar type={getUserAvatarType(creator())} src={creator().avatar} size="xs" class="mr-2" /> 145 146 <span class="mr-1 overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium text-contrast-muted group-hover:underline"> 147 {creator().handle.toLowerCase()} 148 </span> 149 </a> 150 </div> 151 </div> 152 </div> 153 154 <Boxed.Group> 155 <Boxed.List> 156 <div class="flex flex-col whitespace-pre-wrap px-4 py-3 text-left text-sm empty:hidden"> 157 {trimRichText(list().description ?? '') || ( 158 <span class="text-contrast-muted">No description set.</span> 159 )} 160 </div> 161 </Boxed.List> 162 </Boxed.Group> 163 </Boxed.Container> 164 ); 165}; 166 167const MembersList = ({ uri }: { uri: ResourceUri }) => { 168 const members = createListMembersQuery(() => uri); 169 170 return ( 171 <PagedList 172 data={members.data?.pages.map((page) => page.members)} 173 error={members.error} 174 render={(item) => { 175 return ( 176 <VirtualItem estimateHeight={64}> 177 <ProfileItem item={/* @once */ item.subject} /> 178 </VirtualItem> 179 ); 180 }} 181 hasNextPage={members.hasNextPage} 182 isFetchingNextPage={members.isFetching} 183 onEndReached={() => members.fetchNextPage()} 184 /> 185 ); 186};