personal web client for Bluesky
typescript
solidjs
bluesky
atcute
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};