atmo.rsvp
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>