Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/lib/cards/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -143,7 +144,8 @@ export const AllCardDefinitions = [
RPGActorCardDefinition,
ButtondownCardDefinition,
BufoStatusCardDefinition,
SkyboardCardDefinition
SkyboardCardDefinition,
RoomyChatCardDefinition
] as const;

export const CardDefinitionsByType = AllCardDefinitions.reduce(
Expand Down
53 changes: 53 additions & 0 deletions src/lib/cards/social/RoomyChatCard/CreateRoomyChatCardModal.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<script lang="ts">
import { Button, Input, Subheading } from '@foxui/core';
import Modal from '$lib/components/modal/Modal.svelte';
import type { CreationModalComponentProps } from '../../types';
import { parseRoomyUrl } from './index';

let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props();

let errorMessage = $state('');
</script>

<Modal open={true} closeButton={false}>
<form
onsubmit={() => {
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"
>
<Subheading>Enter a Roomy room URL</Subheading>
<Input
bind:value={item.cardData.href}
placeholder="https://lite.roomy.space/did:plc:.../01..."
class="mt-4"
/>

{#if errorMessage}
<p class="mt-2 text-sm text-red-600">{errorMessage}</p>
{/if}

<div class="mt-4 flex justify-end gap-2">
<Button onclick={oncancel} variant="ghost">Cancel</Button>
<Button type="submit">Create</Button>
</div>
</form>
</Modal>
219 changes: 219 additions & 0 deletions src/lib/cards/social/RoomyChatCard/RoomyChatCard.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { marked } from 'marked';
import { sanitize } from '$lib/helpers/sanitize';
import { getAdditionalUserData } from '$lib/website/data/context';
import type { ContentComponentProps } from '../../types';
import type { RoomyRoomData, RoomyLoadedData } from './types';
import { fetchRoomyRoom } from './api.remote';

let { item }: ContentComponentProps = $props();

const roomId = $derived(item.cardData.roomId as string | undefined);
const spaceId = $derived(item.cardData.spaceId as string | undefined);

const href = $derived(
(item.cardData.href as string | undefined) ??
(spaceId && roomId ? `https://lite.roomy.space/${spaceId}/${roomId}` : 'https://roomy.space')
);

// Message links are written in the context of the Roomy room, so resolve any
// relative ones against the room URL instead of the host Blento page.
function resolveHref(linkHref: string | undefined): string {
if (!linkHref) return href;
try {
return new URL(linkHref, href).href;
} catch {
return linkHref;
}
}

const renderer = new marked.Renderer();
renderer.link = ({ href: linkHref, title, text }) =>
`<a target="_blank" rel="noopener noreferrer" href="${resolveHref(linkHref)}" title="${title ?? ''}">${text}</a>`;

function renderContent(content: string): string {
return sanitize(marked.parse(content, { renderer, breaks: true, gfm: true }) as string, {
ADD_ATTR: ['target', 'rel']
});
}

const additional = getAdditionalUserData();

// svelte-ignore state_referenced_locally
let room = $state<RoomyRoomData | undefined>(
roomId ? (additional[item.cardType] as RoomyLoadedData)?.[roomId] : undefined
);
let loading = $state(false);

// Messages arrive oldest-first; render chronologically and pin to the bottom.
const messages = $derived(room?.messages ?? []);

let scroller: HTMLDivElement | undefined = $state();

function scrollToBottom() {
if (scroller) scroller.scrollTop = scroller.scrollHeight;
}

async function load() {
if (!roomId) return;
loading = true;
try {
const data = await fetchRoomyRoom({ roomId });
if (data) {
room = data;
additional[item.cardType] ??= {};
(additional[item.cardType] as RoomyLoadedData)[roomId] = data;
await tick();
scrollToBottom();
}
} catch (error) {
console.error('Failed to fetch roomy room:', error);
} finally {
loading = false;
}
}

onMount(async () => {
if (room) {
await tick();
scrollToBottom();
return;
}
await load();
});

const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });

function relativeTime(timestamp: string): string {
const then = new Date(timestamp).getTime();
if (Number.isNaN(then)) return '';
const diff = then - Date.now();
const abs = Math.abs(diff);
const min = 60 * 1000;
const hour = 60 * min;
const day = 24 * hour;
if (abs < hour) return rtf.format(Math.round(diff / min), 'minute');
if (abs < day) return rtf.format(Math.round(diff / hour), 'hour');
if (abs < 30 * day) return rtf.format(Math.round(diff / day), 'day');
return new Date(timestamp).toLocaleDateString();
}
</script>

<div class="@container flex h-full w-full flex-col overflow-hidden">
<!-- Header -->
<div class="flex shrink-0 items-center justify-between gap-2 px-3 pt-3 pb-2">
<div class="flex min-w-0 items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-accent-500 accent:text-white size-4 shrink-0"
>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
<span
class="text-base-900 dark:text-base-100 accent:text-white min-w-0 truncate text-sm font-semibold"
>
{room?.name ? `#${room.name}` : 'Roomy'}
</span>
</div>
<a
{href}
target="_blank"
rel="noopener noreferrer"
class="text-base-500 hover:text-accent-500 dark:text-base-400 accent:text-white/70 accent:hover:text-white flex shrink-0 items-center gap-1 text-xs font-medium transition-colors"
>
Open
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-3"
>
<path d="M7 17 17 7M7 7h10v10" />
</svg>
</a>
</div>

<!-- Body -->
{#if messages.length > 0}
<div
bind:this={scroller}
class="flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto px-3 pb-3"
>
{#each messages as message (message.id)}
<div class="flex gap-2">
{#if message.authorAvatar}
<img
src={message.authorAvatar}
alt=""
loading="lazy"
class="bg-base-200 dark:bg-base-700 mt-0.5 size-6 shrink-0 rounded-full object-cover"
/>
{:else}
<div
class="bg-base-200 dark:bg-base-700 accent:bg-white/20 text-base-500 dark:text-base-400 accent:text-white mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold uppercase"
>
{message.authorName.slice(0, 2)}
</div>
{/if}
<div class="min-w-0 flex-1">
<div class="flex items-baseline gap-1.5">
<span
class="text-base-900 dark:text-base-100 accent:text-white truncate text-xs font-semibold"
>
{message.authorName}
</span>
{#if message.timestamp}
<span
class="text-base-400 dark:text-base-500 accent:text-white/50 shrink-0 text-[10px]"
>
{relativeTime(message.timestamp)}
</span>
{/if}
</div>
<div
class="prose prose-sm dark:prose-invert prose-neutral prose-p:my-0.5 prose-p:first:mt-0 prose-p:last:mb-0 prose-a:text-accent-600 dark:prose-a:text-accent-400 accent:prose-a:text-white prose-a:no-underline prose-pre:my-1 prose-pre:text-[10px] prose-code:text-[11px] prose-blockquote:my-1 prose-blockquote:not-italic prose-ul:my-0.5 prose-ol:my-0.5 text-base-700 dark:text-base-300 accent:text-white/90 max-w-none text-xs leading-snug break-words"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html renderContent(message.content)}
</div>
{#if message.reactions.length > 0}
<div class="mt-1 flex flex-wrap gap-1">
{#each message.reactions as reaction (reaction.emoji)}
<span
class="bg-base-100 dark:bg-base-800 accent:bg-white/10 text-base-600 dark:text-base-300 accent:text-white inline-flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[10px]"
>
{reaction.emoji}
{reaction.dids.length}
</span>
{/each}
</div>
{/if}
</div>
</div>
{/each}
</div>
{:else}
<div
class="text-base-400 dark:text-base-500 accent:text-white/60 flex flex-1 items-center justify-center px-3 pb-3 text-center text-sm"
>
{#if loading}
Loading messages…
{:else if roomId}
No messages yet.
{:else}
No room selected.
{/if}
</div>
{/if}
</div>
85 changes: 85 additions & 0 deletions src/lib/cards/social/RoomyChatCard/api.remote.ts
Original file line number Diff line number Diff line change
@@ -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<RoomyRoomData | undefined> => {
const { platform } = getRequestEvent();
const cache = createCache(platform);

const cached = await cache?.getJSON<RoomyRoomData>('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<string, unknown>) => ({
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;
}
);
Loading
Loading