personal web client for Bluesky
typescript
solidjs
bluesky
atcute
1import { type JSX, createEffect, createSignal } from 'solid-js';
2
3import type { Blob as AtpBlob } from '@atcute/lexicons';
4import { remove as removeExif } from '@mary/exif-rm';
5import { createInfiniteQuery } from '@mary/solid-query';
6
7import { listRecords } from '~/api/utils/records';
8
9import { hasModals, openModal } from '~/globals/modals';
10
11import { MAX_ORIGINAL_SIZE, SUPPORTED_IMAGE_TYPES } from '~/lib/bluemoji/compress';
12import { getCdnUrl } from '~/lib/bluemoji/render';
13import { createEventListener } from '~/lib/hooks/event-listener';
14import { useTitle } from '~/lib/navigation/router';
15import { useAgent } from '~/lib/states/agent';
16import { useSession } from '~/lib/states/session';
17import { on } from '~/lib/utils/misc';
18
19import IconButton from '~/components/icon-button';
20import AddOutlinedIcon from '~/components/icons-central/add-outline';
21import * as Page from '~/components/page';
22import PagedList from '~/components/paged-list';
23import * as Prompt from '~/components/prompt';
24import AddEmotePrompt from '~/components/settings/bluemoji/add-emote-prompt';
25
26const BluemojiEmotesPage = () => {
27 const handleBlob = async (blob: Blob) => {
28 const exifRemoved = removeExif(new Uint8Array(await blob.arrayBuffer()));
29 if (exifRemoved !== null) {
30 blob = new Blob([exifRemoved as Uint8Array<ArrayBuffer>], { type: blob.type });
31 }
32
33 if (blob.size > MAX_ORIGINAL_SIZE) {
34 openModal(() => (
35 <Prompt.Confirm
36 title="This image is too large"
37 description="Images used for emotes cannot exceed more than 1 MB"
38 confirmLabel="Okay"
39 noCancel
40 />
41 ));
42
43 return;
44 }
45
46 openModal(() => <AddEmotePrompt blob={blob} onAdd={() => {}} />);
47 };
48
49 const { client } = useAgent();
50 const { currentAccount } = useSession();
51
52 const query = createInfiniteQuery(() => ({
53 queryKey: ['bluemoji', 'emotes'],
54 async queryFn(ctx) {
55 return listRecords(client, {
56 repo: currentAccount!.did,
57 collection: 'blue.moji.collection.item',
58 limit: 100,
59 cursor: ctx.pageParam,
60 });
61 },
62 initialPageParam: undefined as string | undefined,
63 getNextPageParam: (last) => last.cursor,
64 }));
65
66 useTitle(() => `Bluemoji emotes — ${import.meta.env.VITE_APP_NAME}`);
67
68 return (
69 <>
70 <FileDnd onAdd={handleBlob} />
71
72 <Page.Header>
73 <Page.HeaderAccessory>
74 <Page.Back to="/settings" />
75 </Page.HeaderAccessory>
76
77 <Page.Heading title="Emotes" />
78
79 <Page.HeaderAccessory>
80 <IconButton
81 title="Upload emote"
82 icon={AddOutlinedIcon}
83 onClick={() => {
84 const input = document.createElement('input');
85 input.type = 'file';
86 input.accept = SUPPORTED_IMAGE_TYPES.join(',');
87
88 input.oninput = () => {
89 const files = input.files;
90 if (files && files.length !== 0) {
91 handleBlob(files[0]);
92 }
93 };
94
95 input.click();
96 }}
97 />
98 </Page.HeaderAccessory>
99 </Page.Header>
100
101 <p class="text-pretty p-4 text-de text-contrast-muted">
102 Here be dragons, this is an experimental feature, and things can change at any time. Animated emotes
103 are not yet supported.
104 </p>
105
106 <PagedList
107 data={query.data?.pages.map((page) => page.records)}
108 render={(item) => {
109 const record = item.value;
110 const formats = record.formats;
111
112 const blob = formats.png_128 ?? formats.original;
113
114 return (
115 <div class="flex items-center gap-4 px-4 py-4">
116 <img
117 src={/* @once */ getCdnUrl(currentAccount!.did, (blob! as AtpBlob).ref.$link)}
118 class="h-8 w-8 object-cover"
119 />
120
121 <div class="grow text-sm font-medium">
122 <span class="text-contrast-muted">:</span>
123 <span>{/* @once */ record.name}</span>
124 <span class="text-contrast-muted">:</span>
125 </div>
126 </div>
127 );
128 }}
129 fallback={<p class="py-6 text-center text-base font-medium">No emotes added yet.</p>}
130 hasNextPage={query.hasNextPage}
131 isFetchingNextPage={query.isFetching}
132 onEndReached={() => query.fetchNextPage()}
133 />
134 </>
135 );
136};
137
138export default BluemojiEmotesPage;
139
140const FileDnd = ({ onAdd }: { onAdd: (blob: Blob) => void }) => {
141 const [dropping, setDropping] = createSignal(false);
142
143 createEffect(() => {
144 if (hasModals()) {
145 return;
146 }
147
148 let tracked: any;
149
150 createEventListener(document, 'paste', (ev) => {
151 const clipboardData = ev.clipboardData;
152 if (!clipboardData) {
153 return;
154 }
155
156 if (clipboardData.types.includes('Files')) {
157 const files = Array.from(clipboardData.files).filter((file) =>
158 SUPPORTED_IMAGE_TYPES.includes(file.type),
159 );
160
161 ev.preventDefault();
162
163 if (files.length !== 0) {
164 console.log(files);
165 onAdd(files[0]);
166 }
167 }
168 });
169
170 createEventListener(document, 'drop', (ev) => {
171 const dataTransfer = ev.dataTransfer;
172 if (!dataTransfer) {
173 return;
174 }
175
176 ev.preventDefault();
177 setDropping(false);
178
179 tracked = undefined;
180
181 if (dataTransfer.types.includes('Files')) {
182 const files = Array.from(dataTransfer.files).filter((file) =>
183 SUPPORTED_IMAGE_TYPES.includes(file.type),
184 );
185
186 if (files.length !== 0) {
187 console.log(files);
188 onAdd(files[0]);
189 }
190 }
191 });
192
193 createEventListener(document, 'dragover', (ev) => {
194 ev.preventDefault();
195 });
196
197 createEventListener(document, 'dragenter', (ev) => {
198 setDropping(true);
199 tracked = ev.target;
200 });
201
202 createEventListener(document, 'dragleave', (ev) => {
203 if (tracked === ev.target) {
204 setDropping(false);
205 tracked = undefined;
206 }
207 });
208 });
209
210 return on(dropping, ($dropping) => {
211 if (!$dropping) {
212 return;
213 }
214
215 return (
216 <div class="pointer-events-none fixed inset-0 z-[3] flex items-center justify-center bg-contrast-overlay/75">
217 <div class="rounded-lg bg-background p-2">
218 <p class="rounded border-2 border-dashed border-outline px-9 py-11">Drop to add emote</p>
219 </div>
220 </div>
221 );
222 }) as unknown as JSX.Element;
223};