diff --git a/src/lib/cards/index.ts b/src/lib/cards/index.ts index 1425a9b..abba065 100644 --- a/src/lib/cards/index.ts +++ b/src/lib/cards/index.ts @@ -69,6 +69,7 @@ import { ButtondownCardDefinition } from './social/ButtondownCard'; import { BufoStatusCardDefinition } from './social/BufoStatusCard'; import { VideoCardDefinition } from './media/VideoCard'; import { SkyboardCardDefinition } from './social/SkyboardCard'; +import { RoomyChatCardDefinition } from './social/RoomyChatCard'; // import { Model3DCardDefinition } from './visual/Model3DCard'; export const AllCardDefinitions = [ @@ -143,7 +144,8 @@ export const AllCardDefinitions = [ RPGActorCardDefinition, ButtondownCardDefinition, BufoStatusCardDefinition, - SkyboardCardDefinition + SkyboardCardDefinition, + RoomyChatCardDefinition ] as const; export const CardDefinitionsByType = AllCardDefinitions.reduce( diff --git a/src/lib/cards/social/RoomyChatCard/CreateRoomyChatCardModal.svelte b/src/lib/cards/social/RoomyChatCard/CreateRoomyChatCardModal.svelte new file mode 100644 index 0000000..e96d3bc --- /dev/null +++ b/src/lib/cards/social/RoomyChatCard/CreateRoomyChatCardModal.svelte @@ -0,0 +1,53 @@ + + + +
{ + const input = item.cardData.href?.trim(); + if (!input) return; + + const parsed = parseRoomyUrl(input); + if (!parsed) { + errorMessage = 'Please enter a valid Roomy room URL'; + return; + } + + item.cardData.href = input; + item.cardData.roomId = parsed.roomId; + if (parsed.spaceId) item.cardData.spaceId = parsed.spaceId; + + item.w = 4; + item.h = 5; + item.mobileW = 8; + item.mobileH = 7; + + oncreate?.(); + }} + class="flex flex-col gap-2" + > + Enter a Roomy room URL + + + {#if errorMessage} +

{errorMessage}

+ {/if} + +
+ + +
+
+
diff --git a/src/lib/cards/social/RoomyChatCard/RoomyChatCard.svelte b/src/lib/cards/social/RoomyChatCard/RoomyChatCard.svelte new file mode 100644 index 0000000..553881c --- /dev/null +++ b/src/lib/cards/social/RoomyChatCard/RoomyChatCard.svelte @@ -0,0 +1,219 @@ + + +
+ +
+
+ + + + + {room?.name ? `#${room.name}` : 'Roomy'} + +
+ + Open + + + + +
+ + + {#if messages.length > 0} +
+ {#each messages as message (message.id)} +
+ {#if message.authorAvatar} + + {:else} +
+ {message.authorName.slice(0, 2)} +
+ {/if} +
+
+ + {message.authorName} + + {#if message.timestamp} + + {relativeTime(message.timestamp)} + + {/if} +
+
+ + {@html renderContent(message.content)} +
+ {#if message.reactions.length > 0} +
+ {#each message.reactions as reaction (reaction.emoji)} + + {reaction.emoji} + {reaction.dids.length} + + {/each} +
+ {/if} +
+
+ {/each} +
+ {:else} +
+ {#if loading} + Loading messages… + {:else if roomId} + No messages yet. + {:else} + No room selected. + {/if} +
+ {/if} +
diff --git a/src/lib/cards/social/RoomyChatCard/api.remote.ts b/src/lib/cards/social/RoomyChatCard/api.remote.ts new file mode 100644 index 0000000..724107f --- /dev/null +++ b/src/lib/cards/social/RoomyChatCard/api.remote.ts @@ -0,0 +1,85 @@ +import { query, getRequestEvent } from '$app/server'; +import { createCache } from '$lib/helpers/cache'; +import type { RoomyMessage, RoomyRoomData } from './types'; + +const API_URL = 'https://api.roomy.space/xrpc'; + +// How many recent messages to fetch/render. +const MESSAGE_LIMIT = 50; + +/** + * Resolve a Roomy image reference to a usable URL. + * `atblob://{did}/{cid}` refs are served via the Bluesky CDN; everything else + * is assumed to already be a normal URL. + */ +function cdnImageUrl( + uri: string | undefined, + opts?: { size: 'full' | 'thumbnail' } +): string | undefined { + if (!uri) return undefined; + if (uri.startsWith('atblob://')) { + const split = uri.split('atblob://')[1]?.split('/'); + if (!split || split.length !== 2) return undefined; + const [did, cid] = split; + const variant = opts?.size === 'thumbnail' ? 'feed_thumbnail' : 'feed_fullsize'; + return `https://cdn.bsky.app/img/${variant}/plain/${did}/${cid}`; + } + return uri; +} + +/** + * Fetch the latest messages (and room name) for a Roomy room. + * Readonly: we only render what `getMessages` / `getMetadata` return. + */ +export const fetchRoomyRoom = query( + 'unchecked', + async ({ roomId }: { roomId: string }): Promise => { + const { platform } = getRequestEvent(); + const cache = createCache(platform); + + const cached = await cache?.getJSON('roomy', roomId); + if (cached) return cached; + + const [messagesRes, metaRes] = await Promise.all([ + fetch( + `${API_URL}/space.roomy.room.getMessages?roomId=${encodeURIComponent(roomId)}&limit=${MESSAGE_LIMIT}` + ), + fetch(`${API_URL}/space.roomy.room.getMetadata?roomId=${encodeURIComponent(roomId)}`) + ]); + + if (!messagesRes.ok) return undefined; + + const messagesData = await messagesRes.json(); + const meta = metaRes.ok ? await metaRes.json() : undefined; + + const messages: RoomyMessage[] = (messagesData?.messages ?? []).map( + (m: Record) => ({ + id: String(m.id), + content: String(m.content ?? ''), + authorDid: String(m.authorDid ?? ''), + authorName: String(m.authorName ?? m.authorHandle ?? 'unknown'), + authorHandle: String(m.authorHandle ?? ''), + authorAvatar: cdnImageUrl(m.authorAvatar ? String(m.authorAvatar) : undefined, { + size: 'thumbnail' + }), + timestamp: String(m.timestamp ?? ''), + reactions: Array.isArray(m.reactions) + ? (m.reactions as RoomyMessage['reactions']).map((r) => ({ + emoji: String(r.emoji), + dids: Array.isArray(r.dids) ? r.dids.map(String) : [] + })) + : [] + }) + ); + + const data: RoomyRoomData = { + roomId, + spaceId: meta?.spaceId ? String(meta.spaceId) : undefined, + name: meta?.name ? String(meta.name) : undefined, + messages + }; + + await cache?.putJSON('roomy', roomId, data); + return data; + } +); diff --git a/src/lib/cards/social/RoomyChatCard/index.ts b/src/lib/cards/social/RoomyChatCard/index.ts new file mode 100644 index 0000000..a960271 --- /dev/null +++ b/src/lib/cards/social/RoomyChatCard/index.ts @@ -0,0 +1,123 @@ +import type { CardDefinition } from '../../types'; +import CreateRoomyChatCardModal from './CreateRoomyChatCardModal.svelte'; +import RoomyChatCard from './RoomyChatCard.svelte'; +import SourceSettings from '../../_settings/SourceSettings.svelte'; +import { fetchRoomyRoom } from './api.remote'; +import type { RoomyRoomData } from './types'; + +export type { RoomyLoadedData } from './types'; + +async function loadRooms(items: { cardData: Record }[]) { + const rooms: Record = {}; + const roomIds = [ + ...new Set( + items + .map((item) => item.cardData.roomId as string | undefined) + .filter((id): id is string => !!id) + ) + ]; + await Promise.all( + roomIds.map(async (roomId) => { + try { + const data = await fetchRoomyRoom({ roomId }); + if (data) rooms[roomId] = data; + } catch (error) { + console.error('Failed to fetch roomy room:', error); + } + }) + ); + return rooms; +} + +export const RoomyChatCardDefinition = { + type: 'roomy-chat', + contentComponent: RoomyChatCard, + creationModalComponent: CreateRoomyChatCardModal, + settingsComponent: SourceSettings, + source: { + label: 'Room URL', + placeholder: 'https://lite.roomy.space/did:plc:.../01...', + errorMessage: "That doesn't look like a Roomy room link" + }, + + // fetchRoomyRoom does its own KV caching (`roomy` namespace), so the + // SWR layer (cacheLoadData) isn't needed here. + loadData: loadRooms, + loadDataServer: loadRooms, + + createNew: (card) => { + card.cardData = {}; + card.w = 4; + card.h = 5; + card.mobileW = 8; + card.mobileH = 7; + }, + + onUrlHandler: (url, item) => { + const parsed = parseRoomyUrl(url); + if (!parsed) return null; + + item.cardData.href = url; + item.cardData.roomId = parsed.roomId; + if (parsed.spaceId) item.cardData.spaceId = parsed.spaceId; + + item.w = 4; + item.h = 5; + item.mobileW = 8; + item.mobileH = 7; + return item; + }, + urlHandlerPriority: 5, + + canChange: (item) => Boolean(parseRoomyUrl(item.cardData.href)), + change: (item) => { + const parsed = parseRoomyUrl(item.cardData.href); + if (parsed) { + item.cardData.roomId = parsed.roomId; + if (parsed.spaceId) item.cardData.spaceId = parsed.spaceId; + } + return item; + }, + + minW: 2, + minH: 2, + + name: 'Roomy Chat', + keywords: ['chat', 'roomy', 'messages', 'channel', 'community'], + groups: ['Social'], + icon: `` +} as CardDefinition & { type: 'roomy-chat' }; + +const ULID_RE = /^[0-9A-HJKMNP-TV-Z]{26}$/i; + +/** + * Parse a Roomy room link into { roomId, spaceId? }. + * Accepts: + * https://lite.roomy.space/{spaceId}/{roomId} + * https://lite.roomy.space/{spaceId}/{roomId}?parent=... + * The roomId is the trailing ULID segment. + */ +export function parseRoomyUrl( + url: string | undefined +): { roomId: string; spaceId?: string } | undefined { + if (!url) return; + const trimmed = url.trim(); + + try { + const parsed = new URL(trimmed.includes('://') ? trimmed : `https://${trimmed}`); + if (!/^(lite\.|www\.)?roomy\.space$/.test(parsed.hostname)) return; + + const segments = parsed.pathname.split('/').filter(Boolean); + // /{spaceId}/{roomId} + if (segments.length >= 2 && ULID_RE.test(segments[1])) { + return { spaceId: segments[0], roomId: segments[1] }; + } + // /{roomId} + if (segments.length === 1 && ULID_RE.test(segments[0])) { + return { roomId: segments[0] }; + } + return; + } catch { + return; + } +} diff --git a/src/lib/cards/social/RoomyChatCard/types.ts b/src/lib/cards/social/RoomyChatCard/types.ts new file mode 100644 index 0000000..6108533 --- /dev/null +++ b/src/lib/cards/social/RoomyChatCard/types.ts @@ -0,0 +1,29 @@ +// Subset of the Roomy XRPC responses this readonly card renders. +// Source of truth: https://api.roomy.space/xrpc/space.roomy.room.getMessages +// https://api.roomy.space/xrpc/space.roomy.room.getMetadata + +export interface RoomyReaction { + emoji: string; + dids: string[]; +} + +export interface RoomyMessage { + id: string; + content: string; + authorDid: string; + authorName: string; + authorHandle: string; + authorAvatar?: string; + timestamp: string; + reactions: RoomyReaction[]; +} + +export interface RoomyRoomData { + roomId: string; + spaceId?: string; + name?: string; + messages: RoomyMessage[]; +} + +// Loaded data is keyed by roomId. +export type RoomyLoadedData = Record; diff --git a/src/lib/helpers/cache.ts b/src/lib/helpers/cache.ts index 5f3f1e6..0540c07 100644 --- a/src/lib/helpers/cache.ts +++ b/src/lib/helpers/cache.ts @@ -10,6 +10,7 @@ const NAMESPACE_TTL = { listenbrainz: 60 * 60, // 1 hour (default, overridable per-put) npmx: 60 * 60 * 12, // 12 hours skyboard: 60 * 5, // 5 minutes (collaborative boards change often) + roomy: 60 * 2, // 2 minutes (chat messages change often) og: 60 * 60 * 24 * 30, // 30 days profile: 60 * 60 * 24, // 24 hours ical: 60 * 60 * 2, // 2 hours