personal web client for Bluesky
typescript solidjs bluesky atcute
at trunk 5.9 kB view raw
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};