atmo.rsvp
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 244 lines 6.9 kB view raw
1<script lang="ts"> 2 import { Modal, Button, Checkbox, Label } from '@foxui/core'; 3 import { 4 MicrobloggingPostCreator, 5 editorJsonToBlueskyPost, 6 createBlueskyMentionSearch, 7 LinkCard, 8 type MicrobloggingPostContent 9 } from '@foxui/social'; 10 import type { JSONContent, SvelteTiptap } from '@foxui/text'; 11 import type { Readable } from 'svelte/store'; 12 import { get } from 'svelte/store'; 13 import { getRecord } from '$lib/atproto/methods'; 14 import { notifyContrailOfUpdate } from '$lib/contrail'; 15 import type { EditorAdapter, EditorViewer } from '$lib/components/editor/adapter'; 16 17 let { 18 open = $bindable(false), 19 canSetEventComments = false, 20 eventDid, 21 eventRkey, 22 eventName, 23 eventUrl, 24 eventDescription, 25 ogImageUrl, 26 initialText, 27 adapter, 28 viewer, 29 onPosted 30 }: { 31 open: boolean; 32 canSetEventComments?: boolean; 33 eventDid: string; 34 eventRkey: string; 35 eventName: string; 36 eventUrl: string; 37 eventDescription?: string; 38 ogImageUrl?: string; 39 initialText: string; 40 adapter: EditorAdapter; 41 viewer: EditorViewer; 42 onPosted?: (ref: { uri: string; cid: string; showComments: boolean }) => void; 43 } = $props(); 44 45 function textToDoc(text: string): JSONContent { 46 const lines = text.split('\n'); 47 const content = lines.map((line) => 48 line.length > 0 49 ? { type: 'paragraph', content: [{ type: 'text', text: line }] } 50 : { type: 'paragraph' } 51 ); 52 return { type: 'doc', content } as JSONContent; 53 } 54 55 const searchMentions = createBlueskyMentionSearch(); 56 57 let postContent = $state<MicrobloggingPostContent>({ 58 text: initialText, 59 json: textToDoc(initialText) 60 }); 61 let editorStore = $state<Readable<SvelteTiptap.Editor> | undefined>(); 62 let prefilledForOpen = $state(false); 63 let showComments = $state(true); 64 let posting = $state(false); 65 let errorMessage = $state<string | null>(null); 66 67 // Each time the modal opens, push the initial text into the editor once it's 68 // ready. The editor instance arrives via a readable store after PlainTextEditor 69 // has mounted internally, so we subscribe and write content on the first 70 // non-null value we see for this open cycle. 71 $effect(() => { 72 if (!open) { 73 prefilledForOpen = false; 74 showComments = true; 75 errorMessage = null; 76 return; 77 } 78 if (!editorStore || prefilledForOpen) return; 79 const unsub = editorStore.subscribe((ed) => { 80 if (!ed || prefilledForOpen) return; 81 ed.commands.setContent(textToDoc(initialText)); 82 prefilledForOpen = true; 83 }); 84 return unsub; 85 }); 86 87 // Bluesky's external embed accepts a thumb blob up to ~1MB. Fetch the OG 88 // image, upload it to the user's PDS, return a clean blob ref. On any failure 89 // (CORS, large image, network) we fall back to a thumb-less embed rather than 90 // blocking the post. 91 async function fetchAndUploadThumbnail(url: string) { 92 try { 93 const resp = await fetch(url); 94 if (!resp.ok) return null; 95 const blob = await resp.blob(); 96 if (!blob.type.startsWith('image/')) return null; 97 if (blob.size > 1_000_000) return null; 98 const result = await adapter.uploadBlob(blob); 99 return { 100 $type: result.$type, 101 ref: result.ref, 102 mimeType: result.mimeType, 103 size: result.size 104 }; 105 } catch (err) { 106 console.warn('PostToBlueskyModal: thumbnail upload failed, posting without thumb', err); 107 return null; 108 } 109 } 110 111 async function handlePost() { 112 if (!viewer.did || posting) return; 113 114 // Read the editor's live JSON directly — postContent only updates on the 115 // editor's onupdate callback and can lag behind setContent prefills. 116 const editor = editorStore ? get(editorStore) : undefined; 117 const liveJson: JSONContent = editor 118 ? (editor.getJSON() as JSONContent) 119 : postContent.json; 120 121 posting = true; 122 errorMessage = null; 123 try { 124 const { text, facets } = editorJsonToBlueskyPost(liveJson); 125 126 if (!text.trim()) { 127 errorMessage = 'Post text cannot be empty'; 128 posting = false; 129 return; 130 } 131 132 const externalEmbed: Record<string, unknown> = { 133 uri: eventUrl, 134 title: eventName, 135 description: eventDescription ?? '' 136 }; 137 if (ogImageUrl) { 138 const thumb = await fetchAndUploadThumbnail(ogImageUrl); 139 if (thumb) externalEmbed.thumb = thumb; 140 } 141 142 const postRecord: Record<string, unknown> = { 143 $type: 'app.bsky.feed.post', 144 text, 145 createdAt: new Date().toISOString(), 146 embed: { 147 $type: 'app.bsky.embed.external', 148 external: externalEmbed 149 } 150 }; 151 if (facets.length > 0) postRecord.facets = facets; 152 153 const postResp = await adapter.createRecord({ 154 collection: 'app.bsky.feed.post', 155 record: postRecord 156 }); 157 if (!postResp.uri || !postResp.cid) { 158 console.error('PostToBlueskyModal: PDS response missing uri/cid', postResp); 159 throw new Error( 160 'PDS rejected the post — try logging out and back in to refresh permissions' 161 ); 162 } 163 const postUri = postResp.uri; 164 const postCid = postResp.cid; 165 166 if (canSetEventComments) { 167 const fresh = await getRecord({ 168 did: eventDid as `did:${string}:${string}`, 169 collection: 'community.lexicon.calendar.event', 170 rkey: eventRkey 171 }); 172 const freshValue = (fresh as { value?: Record<string, unknown> }).value ?? {}; 173 const updatedRecord = { 174 ...freshValue, 175 bskyPostRef: { 176 uri: postUri, 177 cid: postCid, 178 showComments 179 } 180 }; 181 await adapter.putRecord({ 182 collection: 'community.lexicon.calendar.event', 183 rkey: eventRkey, 184 record: updatedRecord 185 }); 186 187 await notifyContrailOfUpdate( 188 `at://${eventDid}/community.lexicon.calendar.event/${eventRkey}` 189 ); 190 } 191 192 onPosted?.({ uri: postUri, cid: postCid, showComments }); 193 open = false; 194 } catch (err) { 195 console.error('PostToBlueskyModal: post failed', err); 196 errorMessage = err instanceof Error ? err.message : 'Failed to post'; 197 } finally { 198 posting = false; 199 } 200 } 201</script> 202 203<Modal bind:open> 204 <div class="space-y-4"> 205 <h2 class="text-base-900 dark:text-base-50 text-xl font-bold">Share to Bluesky</h2> 206 207 <div 208 class="border-base-200 dark:border-base-800 bg-base-50 dark:bg-base-950/30 space-y-3 rounded-xl border p-3" 209 > 210 <MicrobloggingPostCreator 211 bind:editor={editorStore} 212 bind:content={postContent} 213 {searchMentions} 214 maxLength={300} 215 textEditorClass="max-h-48 overflow-y-auto" 216 /> 217 <LinkCard 218 href={eventUrl} 219 meta={{ 220 title: eventName, 221 description: eventDescription, 222 image: ogImageUrl 223 }} 224 /> 225 </div> 226 227 {#if canSetEventComments} 228 <Label class="flex items-center gap-2"> 229 <Checkbox bind:checked={showComments} /> 230 <span class="text-base-700 dark:text-base-300 text-sm"> 231 Show comments on event page 232 </span> 233 </Label> 234 {/if} 235 236 {#if errorMessage} 237 <p class="text-red-600 dark:text-red-400 text-sm">{errorMessage}</p> 238 {/if} 239 240 <Button class="w-full" onclick={handlePost} disabled={posting}> 241 {posting ? 'Posting…' : 'Post'} 242 </Button> 243 </div> 244</Modal>