atmo.rsvp
3
fork

Configure Feed

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

Merge pull request #32 from flo-bit/feat/event-comments

event comments

authored by

Florian and committed by
GitHub
7fc5e5d8 2953d729

+530 -30
+21
src/lib/atproto/methods.ts
··· 210 210 } 211 211 212 212 /** 213 + * Creates a new record (PDS rejects if the rkey already exists). Use this 214 + * instead of putRecord when the OAuth grant only includes the `create` action 215 + * for the target collection (e.g. `app.bsky.feed.post`). 216 + */ 217 + export async function createRecord({ 218 + collection, 219 + rkey, 220 + record 221 + }: { 222 + collection: AllowedCollection; 223 + rkey?: string; 224 + record: Record<string, unknown>; 225 + }) { 226 + if (!user.did) throw new Error('Not logged in'); 227 + 228 + const { createRecord: createRecordRemote } = await import('./server/repo.remote'); 229 + const data = await createRecordRemote({ collection, rkey, record }); 230 + return { ok: true, data }; 231 + } 232 + 233 + /** 213 234 * Deletes a record via remote function. 214 235 */ 215 236 export async function deleteRecord({
+28 -2
src/lib/atproto/server/repo.remote.ts
··· 1 1 import { error } from '@sveltejs/kit'; 2 2 import { command, getRequestEvent } from '$app/server'; 3 3 import * as v from 'valibot'; 4 - import { collections } from '../settings'; 4 + import { allowedCollections } from '../settings'; 5 5 6 6 // Validate collection format and check against allowed list from settings 7 7 const collectionSchema = v.pipe( 8 8 v.string(), 9 9 v.regex(/^[a-zA-Z][a-zA-Z0-9-]*(\.[a-zA-Z][a-zA-Z0-9-]*){2,}$/), 10 - v.check((c) => collections.includes(c as (typeof collections)[number]), 'Collection not in allowed list') 10 + v.check( 11 + (c) => allowedCollections.includes(c as (typeof allowedCollections)[number]), 12 + 'Collection not in allowed list' 13 + ) 11 14 ); 12 15 13 16 // AT Protocol rkey: TID, 'self', or other valid record keys (alphanumeric, dash, underscore, dot) ··· 28 31 collection: input.collection as `${string}.${string}.${string}`, 29 32 repo: locals.did, 30 33 rkey: input.rkey || 'self', 34 + record: input.record 35 + } 36 + }); 37 + 38 + return response.data; 39 + } 40 + ); 41 + 42 + export const createRecord = command( 43 + v.object({ 44 + collection: collectionSchema, 45 + rkey: rkeySchema, 46 + record: v.record(v.string(), v.unknown()) 47 + }), 48 + async (input) => { 49 + const { locals } = getRequestEvent(); 50 + if (!locals.client || !locals.did) error(401, 'Not authenticated'); 51 + 52 + const response = await locals.client.post('com.atproto.repo.createRecord', { 53 + input: { 54 + collection: input.collection as `${string}.${string}.${string}`, 55 + repo: locals.did, 56 + ...(input.rkey ? { rkey: input.rkey } : {}), 31 57 record: input.record 32 58 } 33 59 });
+5 -2
src/lib/atproto/settings.ts
··· 10 10 'community.lexicon.calendar.rsvp' 11 11 ] as const; 12 12 13 - export type AllowedCollection = (typeof collections)[number]; 13 + export const allowedCollections = [...collections, 'app.bsky.feed.post']; 14 + 15 + export type AllowedCollection = (typeof allowedCollections)[number]; 14 16 15 17 // OAuth scopes. `include:rsvp.atmo.permissionSet?aud=*` bundles every rpc method 16 18 // the deployment exposes; `aud=*` lets the same consent cover dev (tunnel DID) ··· 21 23 'atproto', 22 24 scope.repo({ collection: [...collections] }), 23 25 scope.blob({ accept: ['image/*'] }), 24 - 'include:rsvp.atmo.permissionSet' 26 + 'include:rsvp.atmo.permissionSet', 27 + 'include:app.bsky.authCreatePosts' 25 28 ]; 26 29 27 30 // set to false to disable signup
+99
src/lib/components/EventComments.svelte
··· 1 + <script lang="ts"> 2 + import { 3 + NestedComments, 4 + blueskyPostToPostData, 5 + type PostData 6 + } from '@foxui/social'; 7 + 8 + let { postUri }: { postUri: string } = $props(); 9 + 10 + let comments = $state<PostData[]>([]); 11 + let loading = $state(true); 12 + let errorMessage = $state<string | null>(null); 13 + 14 + function threadToComments(replies: unknown[]): PostData[] { 15 + return (replies as Array<{ $type?: string; post?: unknown; replies?: unknown[] }>) 16 + .filter((r) => r.$type === 'app.bsky.feed.defs#threadViewPost' && r.post) 17 + .map((r) => { 18 + const { postData, embeds } = blueskyPostToPostData( 19 + r.post as Parameters<typeof blueskyPostToPostData>[0] 20 + ); 21 + postData.embeds = embeds; 22 + if (r.replies?.length) { 23 + postData.replies = threadToComments(r.replies); 24 + } 25 + return postData; 26 + }); 27 + } 28 + 29 + function bskyWebUrl(atUri: string): string | null { 30 + const m = atUri.match(/^at:\/\/([^/]+)\/app\.bsky\.feed\.post\/(.+)$/); 31 + if (!m) return null; 32 + return `https://bsky.app/profile/${m[1]}/post/${m[2]}`; 33 + } 34 + 35 + async function loadThread(uri: string) { 36 + loading = true; 37 + errorMessage = null; 38 + try { 39 + const res = await fetch( 40 + `https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=${encodeURIComponent(uri)}&depth=6` 41 + ); 42 + if (!res.ok) throw new Error(`Failed to load thread (${res.status})`); 43 + const data = await res.json(); 44 + const thread = data.thread; 45 + comments = thread?.replies?.length ? threadToComments(thread.replies) : []; 46 + } catch (err) { 47 + console.error('EventComments: load failed', err); 48 + errorMessage = err instanceof Error ? err.message : 'Failed to load comments'; 49 + comments = []; 50 + } finally { 51 + loading = false; 52 + } 53 + } 54 + 55 + $effect(() => { 56 + if (postUri) loadThread(postUri); 57 + }); 58 + 59 + let replyUrl = $derived(bskyWebUrl(postUri)); 60 + </script> 61 + 62 + <style> 63 + .comments-no-divider :global(> div) { 64 + border-top-width: 0; 65 + } 66 + </style> 67 + 68 + <div> 69 + {#if replyUrl} 70 + <div class="mb-4 text-sm"> 71 + <a 72 + href={replyUrl} 73 + target="_blank" 74 + rel="noopener" 75 + class="text-accent-600 hover:text-accent-700 dark:text-accent-400 dark:hover:text-accent-300 font-medium" 76 + > 77 + Add a comment on bluesky &rarr; 78 + </a> 79 + </div> 80 + {/if} 81 + 82 + {#if loading} 83 + <p class="text-base-500 dark:text-base-400 text-sm">Loading comments…</p> 84 + {:else if errorMessage} 85 + <p class="text-base-500 dark:text-base-400 text-sm">{errorMessage}</p> 86 + {:else if comments.length === 0} 87 + <p class="text-base-500 dark:text-base-400 text-sm">No comments yet.</p> 88 + {:else} 89 + <div class="not-prose comments-no-divider"> 90 + <NestedComments 91 + {comments} 92 + actions={(comment) => ({ 93 + reply: { count: comment.replyCount, href: comment.href }, 94 + like: { count: comment.likeCount, href: comment.href } 95 + })} 96 + /> 97 + </div> 98 + {/if} 99 + </div>
+64 -13
src/lib/components/EventView.svelte
··· 2 2 import { eventUrl, isEventOngoing, type FlatEventRecord } from '$lib/contrail'; 3 3 import { getCDNImageBlobUrl } from '$lib/atproto'; 4 4 import { user } from '$lib/atproto/auth.svelte'; 5 - import { Avatar as FoxAvatar, Button } from '@foxui/core'; 5 + import { Avatar as FoxAvatar, Button, ToggleGroup, ToggleGroupItem } from '@foxui/core'; 6 6 import ShareModal from '$lib/components/ShareModal.svelte'; 7 + import EventComments from '$lib/components/EventComments.svelte'; 7 8 import Avatar from 'svelte-boring-avatars'; 8 9 import EventRsvp from '$lib/components/EventRsvp.svelte'; 9 10 import EventCard from '$lib/components/EventCard.svelte'; ··· 55 56 let showShareModal = $state(false); 56 57 let shareModalTitle = $state('Event created!'); 57 58 let shareModalText: string | undefined = $state(undefined); 59 + // True only when the share modal was opened via the post-creation flow by 60 + // the event's host. Drives the "show comments on event page" checkbox and 61 + // the bskyPostRef write — RSVP shares should never overwrite the comments 62 + // root, even when the RSVPer is the host. 63 + let canSetEventComments = $state(false); 64 + let isHost = $derived(!!user.did && user.did === did); 65 + let hasComments = $derived( 66 + !!eventData.bskyPostRef?.showComments && !!eventData.bskyPostRef?.uri 67 + ); 68 + let aboutCommentsTab = $state<'about' | 'comments'>('about'); 58 69 59 70 onMount(async () => { 60 71 geoLocation = await resolveGeoLocation(eventData.locations, locationData); ··· 66 77 launchConfetti(); 67 78 shareModalTitle = 'Event created!'; 68 79 shareModalText = `I'm hosting "${eventData.name}"!\n\n${shareUrl}`; 80 + canSetEventComments = isHost; 69 81 showShareModal = true; 70 82 } 71 83 }); ··· 135 147 if (status === 'interested') return; 136 148 shareModalTitle = "You're going!"; 137 149 shareModalText = `I'm going to "${eventData.name}".\n\n${shareUrl}`; 150 + canSetEventComments = false; 138 151 showShareModal = true; 139 152 } 140 153 ··· 272 285 </div> 273 286 {/if} 274 287 275 - <!-- About Event --> 276 - {#if descriptionHtml} 288 + <!-- About + Comments --> 289 + {#if descriptionHtml || hasComments} 277 290 <div class="mt-8 mb-8"> 278 - <p 279 - class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 280 - > 281 - About 282 - </p> 283 - <div 284 - class="text-base-700 dark:text-base-300 prose dark:prose-invert prose-a:text-accent-600 dark:prose-a:text-accent-400 prose-a:hover:underline prose-a:no-underline max-w-none leading-relaxed wrap-break-word" 285 - > 286 - {@html descriptionHtml} 287 - </div> 291 + {#if descriptionHtml && hasComments} 292 + <ToggleGroup 293 + type="single" 294 + bind:value={ 295 + () => aboutCommentsTab, 296 + (val) => { 297 + if (val === 'about' || val === 'comments') aboutCommentsTab = val; 298 + } 299 + } 300 + class="mb-4 w-fit" 301 + size="xs" 302 + > 303 + <ToggleGroupItem value="about">About</ToggleGroupItem> 304 + <ToggleGroupItem value="comments">Comments</ToggleGroupItem> 305 + </ToggleGroup> 306 + 307 + {#if aboutCommentsTab === 'about'} 308 + <div 309 + class="text-base-700 dark:text-base-300 prose dark:prose-invert prose-a:text-accent-600 dark:prose-a:text-accent-400 prose-a:hover:underline prose-a:no-underline max-w-none leading-relaxed wrap-break-word" 310 + > 311 + {@html descriptionHtml} 312 + </div> 313 + {:else if eventData.bskyPostRef?.uri} 314 + <EventComments postUri={eventData.bskyPostRef.uri} /> 315 + {/if} 316 + {:else if descriptionHtml} 317 + <p 318 + class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 319 + > 320 + About 321 + </p> 322 + <div 323 + class="text-base-700 dark:text-base-300 prose dark:prose-invert prose-a:text-accent-600 dark:prose-a:text-accent-400 prose-a:hover:underline prose-a:no-underline max-w-none leading-relaxed wrap-break-word" 324 + > 325 + {@html descriptionHtml} 326 + </div> 327 + {:else if eventData.bskyPostRef?.uri} 328 + <p 329 + class="text-base-500 dark:text-base-400 mb-3 text-xs font-semibold tracking-wider uppercase" 330 + > 331 + Comments 332 + </p> 333 + <EventComments postUri={eventData.bskyPostRef.uri} /> 334 + {/if} 288 335 </div> 289 336 {/if} 290 337 ··· 336 383 shareText={shareModalText} 337 384 eventName={eventData.name} 338 385 {ogImageUrl} 386 + {canSetEventComments} 387 + eventDid={did} 388 + eventRkey={rkey} 389 + eventDescription={eventData.description} 339 390 />
+245
src/lib/components/PostToBlueskyModal.svelte
··· 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 { putRecord, getRecord, createRecord, uploadBlob } from '$lib/atproto/methods'; 14 + import { user } from '$lib/atproto/auth.svelte'; 15 + import { notifyContrailOfUpdate } from '$lib/contrail'; 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 + onPosted 28 + }: { 29 + open: boolean; 30 + canSetEventComments?: boolean; 31 + eventDid: string; 32 + eventRkey: string; 33 + eventName: string; 34 + eventUrl: string; 35 + eventDescription?: string; 36 + ogImageUrl?: string; 37 + initialText: string; 38 + onPosted?: (ref: { uri: string; cid: string; showComments: boolean }) => void; 39 + } = $props(); 40 + 41 + function textToDoc(text: string): JSONContent { 42 + const lines = text.split('\n'); 43 + const content = lines.map((line) => 44 + line.length > 0 45 + ? { type: 'paragraph', content: [{ type: 'text', text: line }] } 46 + : { type: 'paragraph' } 47 + ); 48 + return { type: 'doc', content } as JSONContent; 49 + } 50 + 51 + const searchMentions = createBlueskyMentionSearch(); 52 + 53 + let postContent = $state<MicrobloggingPostContent>({ 54 + text: initialText, 55 + json: textToDoc(initialText) 56 + }); 57 + let editorStore = $state<Readable<SvelteTiptap.Editor> | undefined>(); 58 + let prefilledForOpen = $state(false); 59 + let showComments = $state(true); 60 + let posting = $state(false); 61 + let errorMessage = $state<string | null>(null); 62 + 63 + // Each time the modal opens, push the initial text into the editor once it's 64 + // ready. The editor instance arrives via a readable store after PlainTextEditor 65 + // has mounted internally, so we subscribe and write content on the first 66 + // non-null value we see for this open cycle. 67 + $effect(() => { 68 + if (!open) { 69 + prefilledForOpen = false; 70 + showComments = true; 71 + errorMessage = null; 72 + return; 73 + } 74 + if (!editorStore || prefilledForOpen) return; 75 + const unsub = editorStore.subscribe((ed) => { 76 + if (!ed || prefilledForOpen) return; 77 + ed.commands.setContent(textToDoc(initialText)); 78 + prefilledForOpen = true; 79 + }); 80 + return unsub; 81 + }); 82 + 83 + // Bluesky's external embed accepts a thumb blob up to ~1MB. Fetch the OG 84 + // image, upload it to the user's PDS, return a clean blob ref. On any failure 85 + // (CORS, large image, network) we fall back to a thumb-less embed rather than 86 + // blocking the post. 87 + async function fetchAndUploadThumbnail(url: string) { 88 + try { 89 + const resp = await fetch(url); 90 + if (!resp.ok) return null; 91 + const blob = await resp.blob(); 92 + if (!blob.type.startsWith('image/')) return null; 93 + if (blob.size > 1_000_000) return null; 94 + const result = await uploadBlob({ blob }); 95 + return { 96 + $type: result.$type, 97 + ref: result.ref, 98 + mimeType: result.mimeType, 99 + size: result.size 100 + }; 101 + } catch (err) { 102 + console.warn('PostToBlueskyModal: thumbnail upload failed, posting without thumb', err); 103 + return null; 104 + } 105 + } 106 + 107 + async function handlePost() { 108 + if (!user.did || posting) return; 109 + 110 + // Read the editor's live JSON directly — postContent only updates on the 111 + // editor's onupdate callback and can lag behind setContent prefills. 112 + const editor = editorStore ? get(editorStore) : undefined; 113 + const liveJson: JSONContent = editor 114 + ? (editor.getJSON() as JSONContent) 115 + : postContent.json; 116 + 117 + posting = true; 118 + errorMessage = null; 119 + try { 120 + const { text, facets } = editorJsonToBlueskyPost(liveJson); 121 + 122 + if (!text.trim()) { 123 + errorMessage = 'Post text cannot be empty'; 124 + posting = false; 125 + return; 126 + } 127 + 128 + const externalEmbed: Record<string, unknown> = { 129 + uri: eventUrl, 130 + title: eventName, 131 + description: eventDescription ?? '' 132 + }; 133 + if (ogImageUrl) { 134 + const thumb = await fetchAndUploadThumbnail(ogImageUrl); 135 + if (thumb) externalEmbed.thumb = thumb; 136 + } 137 + 138 + const postRecord: Record<string, unknown> = { 139 + $type: 'app.bsky.feed.post', 140 + text, 141 + createdAt: new Date().toISOString(), 142 + embed: { 143 + $type: 'app.bsky.embed.external', 144 + external: externalEmbed 145 + } 146 + }; 147 + if (facets.length > 0) postRecord.facets = facets; 148 + 149 + const postResp = await createRecord({ 150 + collection: 'app.bsky.feed.post', 151 + record: postRecord 152 + }); 153 + const postRespData = postResp.data as 154 + | { uri?: string; cid?: string; error?: string; message?: string } 155 + | undefined; 156 + if (!postRespData?.uri || !postRespData?.cid) { 157 + console.error('PostToBlueskyModal: PDS response from putRecord (post)', postRespData); 158 + throw new Error( 159 + postRespData?.message || 160 + postRespData?.error || 161 + 'PDS rejected the post — try logging out and back in to refresh permissions' 162 + ); 163 + } 164 + const postUri = postRespData.uri; 165 + const postCid = postRespData.cid; 166 + 167 + if (canSetEventComments) { 168 + const fresh = await getRecord({ 169 + did: eventDid as `did:${string}`, 170 + collection: 'community.lexicon.calendar.event', 171 + rkey: eventRkey 172 + }); 173 + const freshValue = (fresh as { value?: Record<string, unknown> }).value ?? {}; 174 + const updatedRecord = { 175 + ...freshValue, 176 + bskyPostRef: { 177 + uri: postUri, 178 + cid: postCid, 179 + showComments 180 + } 181 + }; 182 + await putRecord({ 183 + collection: 'community.lexicon.calendar.event', 184 + rkey: eventRkey, 185 + record: updatedRecord 186 + }); 187 + 188 + await notifyContrailOfUpdate( 189 + `at://${eventDid}/community.lexicon.calendar.event/${eventRkey}` 190 + ); 191 + } 192 + 193 + onPosted?.({ uri: postUri, cid: postCid, showComments }); 194 + open = false; 195 + } catch (err) { 196 + console.error('PostToBlueskyModal: post failed', err); 197 + errorMessage = err instanceof Error ? err.message : 'Failed to post'; 198 + } finally { 199 + posting = false; 200 + } 201 + } 202 + </script> 203 + 204 + <Modal bind:open> 205 + <div class="space-y-4"> 206 + <h2 class="text-base-900 dark:text-base-50 text-xl font-bold">Share to Bluesky</h2> 207 + 208 + <div 209 + class="border-base-200 dark:border-base-800 bg-base-50 dark:bg-base-950/30 space-y-3 rounded-xl border p-3" 210 + > 211 + <MicrobloggingPostCreator 212 + bind:editor={editorStore} 213 + bind:content={postContent} 214 + {searchMentions} 215 + maxLength={300} 216 + textEditorClass="max-h-48 overflow-y-auto" 217 + /> 218 + <LinkCard 219 + href={eventUrl} 220 + meta={{ 221 + title: eventName, 222 + description: eventDescription, 223 + image: ogImageUrl 224 + }} 225 + /> 226 + </div> 227 + 228 + {#if canSetEventComments} 229 + <Label class="flex items-center gap-2"> 230 + <Checkbox bind:checked={showComments} /> 231 + <span class="text-base-700 dark:text-base-300 text-sm"> 232 + Show comments on event page 233 + </span> 234 + </Label> 235 + {/if} 236 + 237 + {#if errorMessage} 238 + <p class="text-red-600 dark:text-red-400 text-sm">{errorMessage}</p> 239 + {/if} 240 + 241 + <Button class="w-full" onclick={handlePost} disabled={posting}> 242 + {posting ? 'Posting…' : 'Post'} 243 + </Button> 244 + </div> 245 + </Modal>
+58 -13
src/lib/components/ShareModal.svelte
··· 2 2 import { Modal, Button, Avatar } from '@foxui/core'; 3 3 import { LinkCard } from '@foxui/social'; 4 4 import { user } from '$lib/atproto/auth.svelte'; 5 + import PostToBlueskyModal from '$lib/components/PostToBlueskyModal.svelte'; 5 6 6 7 let { 7 8 open = $bindable(false), ··· 9 10 title = 'Event created!', 10 11 shareText, 11 12 eventName, 12 - ogImageUrl 13 + ogImageUrl, 14 + canSetEventComments = false, 15 + eventDid, 16 + eventRkey, 17 + eventDescription, 18 + onPosted 13 19 }: { 14 20 open: boolean; 15 21 url: string; ··· 17 23 shareText?: string; 18 24 eventName?: string; 19 25 ogImageUrl?: string; 26 + canSetEventComments?: boolean; 27 + eventDid?: string; 28 + eventRkey?: string; 29 + eventDescription?: string; 30 + onPosted?: (ref: { uri: string; cid: string; showComments: boolean }) => void; 20 31 } = $props(); 21 32 22 33 let copiedUrl = $state(false); 23 34 let copiedText = $state(false); 35 + let showPostModal = $state(false); 24 36 25 - // Split share text into the part before the URL 26 37 let textBeforeUrl = $derived(shareText ? shareText.replace(url, '').trim() : undefined); 27 38 28 39 async function copyUrl() { ··· 42 53 } catch {} 43 54 } 44 55 45 - let blueskyButton: HTMLAnchorElement | null = $state(null); 56 + let blueskyButton: HTMLElement | null = $state(null); 46 57 47 - let blueskyUrl = $derived( 58 + let canPostDirectly = $derived( 59 + !!eventDid && !!eventRkey && !!eventName && user.isLoggedIn 60 + ); 61 + 62 + let blueskyIntentUrl = $derived( 48 63 `https://bsky.app/intent/compose?text=${encodeURIComponent(shareText || url)}` 49 64 ); 65 + 66 + function handleBlueskyClick() { 67 + showPostModal = true; 68 + } 69 + 70 + function handlePostedFromInner(ref: { uri: string; cid: string; showComments: boolean }) { 71 + onPosted?.(ref); 72 + open = false; 73 + } 50 74 </script> 51 75 52 76 <Modal ··· 95 119 {copiedText ? 'Copied!' : 'Copy text'} 96 120 </Button> 97 121 {/if} 98 - <Button 99 - bind:ref={blueskyButton} 100 - class="flex-1" 101 - href={blueskyUrl} 102 - target="_blank" 103 - rel="noopener" 104 - > 105 - Share to Bluesky 106 - </Button> 122 + {#if canPostDirectly} 123 + <Button bind:ref={blueskyButton} class="flex-1" onclick={handleBlueskyClick}> 124 + Share to Bluesky 125 + </Button> 126 + {:else} 127 + <Button 128 + bind:ref={blueskyButton} 129 + class="flex-1" 130 + href={blueskyIntentUrl} 131 + target="_blank" 132 + rel="noopener" 133 + > 134 + Share to Bluesky 135 + </Button> 136 + {/if} 107 137 </div> 108 138 </div> 109 139 </Modal> 140 + 141 + {#if canPostDirectly && eventDid && eventRkey && eventName} 142 + <PostToBlueskyModal 143 + bind:open={showPostModal} 144 + {canSetEventComments} 145 + {eventDid} 146 + {eventRkey} 147 + {eventName} 148 + eventUrl={url} 149 + {eventDescription} 150 + {ogImageUrl} 151 + initialText={textBeforeUrl ?? eventName} 152 + onPosted={handlePostedFromInner} 153 + /> 154 + {/if}
+10
src/lib/event-types.ts
··· 32 32 accentColor: string; 33 33 baseColor: string; 34 34 }; 35 + /** 36 + * Reference to a Bluesky post about this event. When present, the host has 37 + * shared the event to their Bluesky feed; when `showComments` is true, the 38 + * event page renders the post's reply thread as a comments section. 39 + */ 40 + bskyPostRef?: { 41 + uri: string; 42 + cid: string; 43 + showComments: boolean; 44 + }; 35 45 };