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 @@
+
+
+
+
+
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