7 Commits

11 changed files with 787 additions and 49 deletions

View File

@@ -0,0 +1,111 @@
import { describe, it, expect } from 'vitest';
import { renderMentions, isEmojiOnly, formatTime, formatFileSize } from './markdown';
describe('markdown utils', () => {
describe('renderMentions', () => {
it('renders @user:server.com as a mention button', () => {
const result = renderMentions('Hello @alice:matrix.org');
expect(result).toContain('class="mention-ping"');
expect(result).toContain('data-user-id="@alice:matrix.org"');
expect(result).toContain('@alice</button>');
});
it('renders @everyone as a special mention', () => {
const result = renderMentions('Hey @everyone');
expect(result).toContain('mention-everyone');
expect(result).toContain('@everyone');
});
it('renders @here as a special mention', () => {
const result = renderMentions('Attention @here');
expect(result).toContain('mention-everyone');
expect(result).toContain('@here');
});
it('renders @room as a special mention', () => {
const result = renderMentions('FYI @room');
expect(result).toContain('mention-everyone');
expect(result).toContain('@room');
});
it('leaves plain text unchanged', () => {
const result = renderMentions('Hello world');
expect(result).toBe('Hello world');
});
it('handles multiple mentions', () => {
const result = renderMentions('@alice:matrix.org and @bob:example.com');
expect(result).toContain('data-user-id="@alice:matrix.org"');
expect(result).toContain('data-user-id="@bob:example.com"');
});
});
describe('isEmojiOnly', () => {
it('returns true for single emoji', () => {
expect(isEmojiOnly('😀')).toBe(true);
});
it('returns true for multiple emojis', () => {
expect(isEmojiOnly('😀🎉🔥')).toBe(true);
});
it('returns true for emojis with spaces', () => {
expect(isEmojiOnly('😀 🎉')).toBe(true);
});
it('returns false for text with emoji', () => {
expect(isEmojiOnly('hello 😀')).toBe(false);
});
it('returns false for plain text', () => {
expect(isEmojiOnly('hello world')).toBe(false);
});
it('returns false for empty string', () => {
expect(isEmojiOnly('')).toBe(false);
});
it('returns false for whitespace only', () => {
expect(isEmojiOnly(' ')).toBe(false);
});
});
describe('formatTime', () => {
it('formats timestamp to HH:MM', () => {
// Create a date at 14:30
const date = new Date(2024, 0, 15, 14, 30, 0);
const result = formatTime(date.getTime());
expect(result).toMatch(/14:30/);
});
it('formats midnight correctly', () => {
const date = new Date(2024, 0, 15, 0, 0, 0);
const result = formatTime(date.getTime());
expect(result).toMatch(/00:00/);
});
});
describe('formatFileSize', () => {
it('returns empty string for undefined', () => {
expect(formatFileSize(undefined)).toBe('');
});
it('returns empty string for 0', () => {
expect(formatFileSize(0)).toBe('');
});
it('formats bytes', () => {
expect(formatFileSize(500)).toBe('500 B');
});
it('formats kilobytes', () => {
expect(formatFileSize(1024)).toBe('1.0 KB');
expect(formatFileSize(1536)).toBe('1.5 KB');
});
it('formats megabytes', () => {
expect(formatFileSize(1024 * 1024)).toBe('1.0 MB');
expect(formatFileSize(1.5 * 1024 * 1024)).toBe('1.5 MB');
});
});
});

View File

@@ -288,6 +288,13 @@ export const roomSummaries = derived(rooms, ($rooms): RoomSummary[] => {
return summaries; return summaries;
}); });
/**
* Total unread count across all rooms (for nav badge)
*/
export const totalUnreadCount = derived(roomSummaries, ($summaries): number => {
return $summaries.reduce((sum, room) => sum + (room.isSpace ? 0 : room.unreadCount), 0);
});
// ============================================================================ // ============================================================================
// Messages // Messages
// ============================================================================ // ============================================================================

View File

@@ -613,6 +613,50 @@ export type Database = {
}, },
] ]
} }
matrix_credentials: {
Row: {
access_token: string
created_at: string
device_id: string | null
homeserver_url: string
id: string
matrix_user_id: string
org_id: string
updated_at: string
user_id: string
}
Insert: {
access_token: string
created_at?: string
device_id?: string | null
homeserver_url: string
id?: string
matrix_user_id: string
org_id: string
updated_at?: string
user_id: string
}
Update: {
access_token?: string
created_at?: string
device_id?: string | null
homeserver_url?: string
id?: string
matrix_user_id?: string
org_id?: string
updated_at?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "matrix_credentials_org_id_fkey"
columns: ["org_id"]
isOneToOne: false
referencedRelation: "organizations"
referencedColumns: ["id"]
},
]
}
org_google_calendars: { org_google_calendars: {
Row: { Row: {
calendar_id: string calendar_id: string
@@ -748,6 +792,13 @@ export type Database = {
referencedRelation: "org_roles" referencedRelation: "org_roles"
referencedColumns: ["id"] referencedColumns: ["id"]
}, },
{
foreignKeyName: "org_members_user_id_profiles_fk"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
] ]
} }
org_roles: { org_roles: {
@@ -803,6 +854,7 @@ export type Database = {
created_at: string | null created_at: string | null
icon_url: string | null icon_url: string | null
id: string id: string
matrix_space_id: string | null
name: string name: string
slug: string slug: string
theme_color: string | null theme_color: string | null
@@ -813,6 +865,7 @@ export type Database = {
created_at?: string | null created_at?: string | null
icon_url?: string | null icon_url?: string | null
id?: string id?: string
matrix_space_id?: string | null
name: string name: string
slug: string slug: string
theme_color?: string | null theme_color?: string | null
@@ -823,6 +876,7 @@ export type Database = {
created_at?: string | null created_at?: string | null
icon_url?: string | null icon_url?: string | null
id?: string id?: string
matrix_space_id?: string | null
name?: string name?: string
slug?: string slug?: string
theme_color?: string | null theme_color?: string | null
@@ -1148,7 +1202,7 @@ export const Constants = {
}, },
} as const } as const
// ── Convenience type aliases ────────────────────────── // ── Convenience type aliases ─────────────────────────────────────────
export type MemberRole = 'owner' | 'admin' | 'editor' | 'viewer'; export type MemberRole = 'owner' | 'admin' | 'editor' | 'viewer';
type PublicTables = Database['public']['Tables'] type PublicTables = Database['public']['Tables']
@@ -1171,3 +1225,4 @@ export type Team = PublicTables['teams']['Row']
export type OrgGoogleCalendar = PublicTables['org_google_calendars']['Row'] export type OrgGoogleCalendar = PublicTables['org_google_calendars']['Row']
export type ActivityLog = PublicTables['activity_log']['Row'] export type ActivityLog = PublicTables['activity_log']['Row']
export type UserPreferences = PublicTables['user_preferences']['Row'] export type UserPreferences = PublicTables['user_preferences']['Row']
export type MatrixCredentials = PublicTables['matrix_credentials']['Row']

View File

@@ -10,6 +10,7 @@
import { hasPermission, type Permission } from "$lib/utils/permissions"; import { hasPermission, type Permission } from "$lib/utils/permissions";
import { setContext } from "svelte"; import { setContext } from "svelte";
import * as m from "$lib/paraglide/messages"; import * as m from "$lib/paraglide/messages";
import { totalUnreadCount } from "$lib/stores/matrix";
interface Member { interface Member {
id: string; id: string;
@@ -126,6 +127,7 @@
href: `/${data.org.slug}/chat`, href: `/${data.org.slug}/chat`,
label: "Chat", label: "Chat",
icon: "chat", icon: "chat",
badge: $totalUnreadCount > 0 ? ($totalUnreadCount > 99 ? "99+" : String($totalUnreadCount)) : null,
}, },
// Settings requires settings.view or admin role // Settings requires settings.view or admin role
...(canAccess("settings.view") ...(canAccess("settings.view")
@@ -223,6 +225,11 @@
? 'opacity-0 max-w-0 overflow-hidden' ? 'opacity-0 max-w-0 overflow-hidden'
: 'opacity-100 max-w-[200px]'}">{item.label}</span : 'opacity-100 max-w-[200px]'}">{item.label}</span
> >
{#if item.badge}
<span class="ml-auto bg-primary text-background text-xs font-bold px-1.5 py-0.5 rounded-full min-w-[18px] text-center shrink-0">
{item.badge}
</span>
{/if}
</a> </a>
{/each} {/each}
</nav> </nav>

View File

@@ -79,15 +79,39 @@
: [], : [],
); );
const filteredRooms = $derived( // All non-space rooms (exclude Space entries themselves from the list)
const allRooms = $derived(
$roomSummaries.filter((r) => !r.isSpace),
);
// Org rooms: rooms that belong to any Space
const orgRooms = $derived(
allRooms.filter((r) => r.parentSpaceId && !r.isDirect),
);
// DMs: direct messages (not tied to org)
const dmRooms = $derived(
allRooms.filter((r) => r.isDirect),
);
// Other rooms: not in a space and not a DM
const otherRooms = $derived(
allRooms.filter((r) => !r.parentSpaceId && !r.isDirect),
);
// Apply search filter across all sections
const filterBySearch = (rooms: typeof allRooms) =>
roomSearchQuery.trim() roomSearchQuery.trim()
? $roomSummaries.filter( ? rooms.filter(
(room) => (room) =>
room.name.toLowerCase().includes(roomSearchQuery.toLowerCase()) || room.name.toLowerCase().includes(roomSearchQuery.toLowerCase()) ||
room.topic?.toLowerCase().includes(roomSearchQuery.toLowerCase()), room.topic?.toLowerCase().includes(roomSearchQuery.toLowerCase()),
) )
: $roomSummaries, : rooms;
);
const filteredOrgRooms = $derived(filterBySearch(orgRooms));
const filteredDmRooms = $derived(filterBySearch(dmRooms));
const filteredOtherRooms = $derived(filterBySearch(otherRooms));
const currentMembers = $derived( const currentMembers = $derived(
$selectedRoomId ? getRoomMembers($selectedRoomId) : [], $selectedRoomId ? getRoomMembers($selectedRoomId) : [],
@@ -140,6 +164,9 @@
accessToken: credentials.accessToken, accessToken: credentials.accessToken,
deviceId: credentials.deviceId || null, deviceId: credentials.deviceId || null,
}); });
// Check if org has a Matrix Space, auto-create if not
await ensureOrgSpace(credentials);
} catch (e: unknown) { } catch (e: unknown) {
console.error("Failed to init Matrix client:", e); console.error("Failed to init Matrix client:", e);
toasts.error("Failed to connect to chat. Please re-login."); toasts.error("Failed to connect to chat. Please re-login.");
@@ -149,6 +176,34 @@
} }
} }
async function ensureOrgSpace(credentials: LoginCredentials) {
try {
const spaceRes = await fetch(`/api/matrix-space?org_id=${data.org.id}`);
const spaceResult = await spaceRes.json();
if (!spaceResult.spaceId) {
// No Space yet — create one using the user's credentials
const createRes = await fetch("/api/matrix-space", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
org_id: data.org.id,
action: "create",
homeserver_url: credentials.homeserverUrl,
access_token: credentials.accessToken,
org_name: data.org.name,
}),
});
const createResult = await createRes.json();
if (createResult.spaceId) {
toasts.success(`Organization space created`);
}
}
} catch (e) {
console.warn("Failed to ensure org space:", e);
}
}
async function handleMatrixLogin() { async function handleMatrixLogin() {
if (!matrixUsername.trim() || !matrixPassword.trim()) { if (!matrixUsername.trim() || !matrixPassword.trim()) {
toasts.error("Please enter username and password"); toasts.error("Please enter username and password");
@@ -438,50 +493,132 @@
</div> </div>
</div> </div>
<!-- Room actions --> <!-- Room list (sectioned) -->
<div class="flex items-center justify-between px-3 py-1">
<span class="text-xs font-semibold text-light/40 uppercase tracking-wider">
Rooms {roomSearchQuery ? `(${filteredRooms.length})` : ""}
</span>
<div class="flex gap-1">
<button
class="w-6 h-6 flex items-center justify-center text-light/40 hover:text-light hover:bg-light/10 rounded transition-colors"
onclick={() => (showCreateRoomModal = true)}
title="Create room"
>
<span class="material-symbols-rounded" style="font-size: 16px;">add_circle</span>
</button>
</div>
</div>
<!-- Room list -->
<nav class="flex-1 overflow-y-auto px-2 pb-2"> <nav class="flex-1 overflow-y-auto px-2 pb-2">
{#if filteredRooms.length === 0} {#if allRooms.length === 0}
<p class="text-light/40 text-sm text-center py-8"> <p class="text-light/40 text-sm text-center py-8">
{roomSearchQuery ? "No matching rooms" : "No rooms yet"} {roomSearchQuery ? "No matching rooms" : "No rooms yet"}
</p> </p>
{:else} {:else}
<ul class="flex flex-col gap-1"> <!-- Org / Space Rooms -->
{#each filteredRooms as room (room.roomId)} {#if filteredOrgRooms.length > 0}
<li> <div class="mb-2">
<div class="flex items-center justify-between px-2 py-1">
<span class="text-xs font-semibold text-light/40 uppercase tracking-wider">
<span class="material-symbols-rounded align-middle" style="font-size: 14px;">workspaces</span>
Organization
</span>
<button <button
class="w-full flex items-center gap-2 pl-1 pr-2 py-1 rounded-[50px] transition-colors text-left class="w-5 h-5 flex items-center justify-center text-light/40 hover:text-light hover:bg-light/10 rounded transition-colors"
{$selectedRoomId === room.roomId ? 'bg-primary/20' : 'hover:bg-light/5'}" onclick={() => (showCreateRoomModal = true)}
onclick={() => handleRoomSelect(room.roomId)} title="Create room"
> >
<Avatar src={room.avatarUrl} name={room.name} size="xs" /> <span class="material-symbols-rounded" style="font-size: 14px;">add</span>
<div class="flex-1 min-w-0">
<span class="font-bold text-sm text-light truncate block">{room.name}</span>
</div>
{#if room.unreadCount > 0}
<span class="bg-primary text-white text-xs px-1.5 py-0.5 rounded-full min-w-[18px] text-center">
{room.unreadCount > 99 ? "99+" : room.unreadCount}
</span>
{/if}
</button> </button>
</li> </div>
{/each} <ul class="flex flex-col gap-0.5">
</ul> {#each filteredOrgRooms as room (room.roomId)}
<li>
<button
class="w-full flex items-center gap-2 pl-1 pr-2 py-1 rounded-[50px] transition-colors text-left
{$selectedRoomId === room.roomId ? 'bg-primary/20' : 'hover:bg-light/5'}"
onclick={() => handleRoomSelect(room.roomId)}
>
<Avatar src={room.avatarUrl} name={room.name} size="xs" />
<div class="flex-1 min-w-0">
<span class="font-bold text-sm text-light truncate block">{room.name}</span>
</div>
{#if room.unreadCount > 0}
<span class="bg-primary text-white text-xs px-1.5 py-0.5 rounded-full min-w-[18px] text-center">
{room.unreadCount > 99 ? "99+" : room.unreadCount}
</span>
{/if}
</button>
</li>
{/each}
</ul>
</div>
{/if}
<!-- Direct Messages -->
{#if filteredDmRooms.length > 0}
<div class="mb-2">
<div class="flex items-center justify-between px-2 py-1">
<span class="text-xs font-semibold text-light/40 uppercase tracking-wider">
<span class="material-symbols-rounded align-middle" style="font-size: 14px;">chat_bubble</span>
Direct Messages
</span>
<button
class="w-5 h-5 flex items-center justify-center text-light/40 hover:text-light hover:bg-light/10 rounded transition-colors"
onclick={() => (showStartDMModal = true)}
title="New DM"
>
<span class="material-symbols-rounded" style="font-size: 14px;">add</span>
</button>
</div>
<ul class="flex flex-col gap-0.5">
{#each filteredDmRooms as room (room.roomId)}
<li>
<button
class="w-full flex items-center gap-2 pl-1 pr-2 py-1 rounded-[50px] transition-colors text-left
{$selectedRoomId === room.roomId ? 'bg-primary/20' : 'hover:bg-light/5'}"
onclick={() => handleRoomSelect(room.roomId)}
>
<Avatar src={room.avatarUrl} name={room.name} size="xs" />
<div class="flex-1 min-w-0">
<span class="font-bold text-sm text-light truncate block">{room.name}</span>
</div>
{#if room.unreadCount > 0}
<span class="bg-primary text-white text-xs px-1.5 py-0.5 rounded-full min-w-[18px] text-center">
{room.unreadCount > 99 ? "99+" : room.unreadCount}
</span>
{/if}
</button>
</li>
{/each}
</ul>
</div>
{/if}
<!-- Other Rooms (not in a space, not DMs) -->
{#if filteredOtherRooms.length > 0}
<div class="mb-2">
<div class="flex items-center justify-between px-2 py-1">
<span class="text-xs font-semibold text-light/40 uppercase tracking-wider">
<span class="material-symbols-rounded align-middle" style="font-size: 14px;">tag</span>
Rooms
</span>
<button
class="w-5 h-5 flex items-center justify-center text-light/40 hover:text-light hover:bg-light/10 rounded transition-colors"
onclick={() => (showCreateRoomModal = true)}
title="Create room"
>
<span class="material-symbols-rounded" style="font-size: 14px;">add</span>
</button>
</div>
<ul class="flex flex-col gap-0.5">
{#each filteredOtherRooms as room (room.roomId)}
<li>
<button
class="w-full flex items-center gap-2 pl-1 pr-2 py-1 rounded-[50px] transition-colors text-left
{$selectedRoomId === room.roomId ? 'bg-primary/20' : 'hover:bg-light/5'}"
onclick={() => handleRoomSelect(room.roomId)}
>
<Avatar src={room.avatarUrl} name={room.name} size="xs" />
<div class="flex-1 min-w-0">
<span class="font-bold text-sm text-light truncate block">{room.name}</span>
</div>
{#if room.unreadCount > 0}
<span class="bg-primary text-white text-xs px-1.5 py-0.5 rounded-full min-w-[18px] text-center">
{room.unreadCount > 99 ? "99+" : room.unreadCount}
</span>
{/if}
</button>
</li>
{/each}
</ul>
</div>
{/if}
{/if} {/if}
</nav> </nav>

View File

@@ -1,11 +1,6 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
// Cast supabase to any to bypass typed client — matrix_credentials table
// was added in migration 020 but types haven't been regenerated yet.
// TODO: Remove casts after running `supabase gen types`
const db = (supabase: any) => supabase;
export const GET: RequestHandler = async ({ url, locals }) => { export const GET: RequestHandler = async ({ url, locals }) => {
const session = await locals.safeGetSession(); const session = await locals.safeGetSession();
if (!session.user) { if (!session.user) {
@@ -17,7 +12,7 @@ export const GET: RequestHandler = async ({ url, locals }) => {
return json({ error: 'org_id is required' }, { status: 400 }); return json({ error: 'org_id is required' }, { status: 400 });
} }
const { data, error } = await db(locals.supabase) const { data, error } = await locals.supabase
.from('matrix_credentials') .from('matrix_credentials')
.select('homeserver_url, matrix_user_id, access_token, device_id') .select('homeserver_url, matrix_user_id, access_token, device_id')
.eq('user_id', session.user.id) .eq('user_id', session.user.id)
@@ -44,7 +39,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
return json({ error: 'Missing required fields' }, { status: 400 }); return json({ error: 'Missing required fields' }, { status: 400 });
} }
const { error } = await db(locals.supabase) const { error } = await locals.supabase
.from('matrix_credentials') .from('matrix_credentials')
.upsert( .upsert(
{ {
@@ -76,7 +71,7 @@ export const DELETE: RequestHandler = async ({ url, locals }) => {
return json({ error: 'org_id is required' }, { status: 400 }); return json({ error: 'org_id is required' }, { status: 400 });
} }
const { error } = await db(locals.supabase) const { error } = await locals.supabase
.from('matrix_credentials') .from('matrix_credentials')
.delete() .delete()
.eq('user_id', session.user.id) .eq('user_id', session.user.id)

View File

@@ -0,0 +1,168 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
/**
* GET: Retrieve the Matrix Space ID for an org
*/
export const GET: RequestHandler = async ({ url, locals }) => {
const session = await locals.safeGetSession();
if (!session.user) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const orgId = url.searchParams.get('org_id');
if (!orgId) {
return json({ error: 'org_id is required' }, { status: 400 });
}
const { data, error } = await locals.supabase
.from('organizations')
.select('matrix_space_id')
.eq('id', orgId)
.single();
if (error) {
return json({ error: error.message }, { status: 500 });
}
return json({ spaceId: data?.matrix_space_id ?? null });
};
/**
* POST: Create a Matrix Space for an org, or link an existing one.
*
* Body options:
* - { org_id, action: "create", homeserver_url, access_token, org_name }
* Creates a new Space on the homeserver and stores the ID.
* - { org_id, action: "link", space_id }
* Links an existing Matrix Space ID to the org.
*/
export const POST: RequestHandler = async ({ request, locals }) => {
const session = await locals.safeGetSession();
if (!session.user) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { org_id, action } = body;
if (!org_id || !action) {
return json({ error: 'org_id and action are required' }, { status: 400 });
}
if (action === 'create') {
const { homeserver_url, access_token, org_name } = body;
if (!homeserver_url || !access_token || !org_name) {
return json({ error: 'homeserver_url, access_token, and org_name are required for create' }, { status: 400 });
}
try {
// Create a Matrix Space via the Client-Server API
const createRes = await fetch(`${homeserver_url}/_matrix/client/v3/createRoom`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${access_token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: org_name,
topic: `Organization space for ${org_name}`,
visibility: 'private',
creation_content: {
type: 'm.space',
},
initial_state: [
{
type: 'm.room.guest_access',
state_key: '',
content: { guest_access: 'can_join' },
},
],
power_level_content_override: {
invite: 50,
kick: 50,
ban: 50,
events_default: 0,
state_default: 50,
},
}),
});
if (!createRes.ok) {
const err = await createRes.json().catch(() => ({}));
return json({ error: err.error || 'Failed to create Matrix Space' }, { status: 500 });
}
const { room_id: spaceId } = await createRes.json();
// Also create default #general room inside the space
const generalRes = await fetch(`${homeserver_url}/_matrix/client/v3/createRoom`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${access_token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: 'General',
topic: 'General discussion',
visibility: 'private',
preset: 'private_chat',
}),
});
if (generalRes.ok) {
const { room_id: generalRoomId } = await generalRes.json();
// Add #general as a child of the space
await fetch(
`${homeserver_url}/_matrix/client/v3/rooms/${encodeURIComponent(spaceId)}/state/m.space.child/${encodeURIComponent(generalRoomId)}`,
{
method: 'PUT',
headers: {
'Authorization': `Bearer ${access_token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
via: [new URL(homeserver_url).hostname],
}),
}
);
}
// Store space ID in org record
const { error: updateError } = await locals.supabase
.from('organizations')
.update({ matrix_space_id: spaceId })
.eq('id', org_id);
if (updateError) {
return json({ error: updateError.message }, { status: 500 });
}
return json({ spaceId, created: true });
} catch (e: any) {
console.error('Failed to create Matrix Space:', e);
return json({ error: e.message || 'Failed to create Matrix Space' }, { status: 500 });
}
}
if (action === 'link') {
const { space_id } = body;
if (!space_id) {
return json({ error: 'space_id is required for link action' }, { status: 400 });
}
const { error: updateError } = await locals.supabase
.from('organizations')
.update({ matrix_space_id: space_id })
.eq('id', org_id);
if (updateError) {
return json({ error: updateError.message }, { status: 500 });
}
return json({ spaceId: space_id, linked: true });
}
return json({ error: 'Invalid action. Use "create" or "link".' }, { status: 400 });
};

View File

@@ -0,0 +1,184 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
/**
* POST: Invite a user to the org's Matrix Space (and its child rooms).
*
* Body: { org_id, matrix_user_id, homeserver_url, access_token }
*/
export const POST: RequestHandler = async ({ request, locals }) => {
const session = await locals.safeGetSession();
if (!session.user) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { org_id, matrix_user_id, homeserver_url, access_token } = body;
if (!org_id || !matrix_user_id || !homeserver_url || !access_token) {
return json({ error: 'Missing required fields' }, { status: 400 });
}
// Get org's Matrix Space ID
const { data: org } = await locals.supabase
.from('organizations')
.select('matrix_space_id')
.eq('id', org_id)
.single();
if (!org?.matrix_space_id) {
return json({ error: 'Organization has no Matrix Space' }, { status: 404 });
}
const spaceId = org.matrix_space_id;
const errors: string[] = [];
// Invite to the Space itself
const inviteRes = await matrixInvite(homeserver_url, access_token, spaceId, matrix_user_id);
if (!inviteRes.ok) {
const err = await inviteRes.json().catch(() => ({}));
// M_FORBIDDEN means already joined, which is fine
if (err.errcode !== 'M_FORBIDDEN') {
errors.push(`Space invite: ${err.error || 'failed'}`);
}
}
// Also invite to all child rooms of the space
try {
const stateRes = await fetch(
`${homeserver_url}/_matrix/client/v3/rooms/${encodeURIComponent(spaceId)}/state`,
{
headers: { 'Authorization': `Bearer ${access_token}` },
}
);
if (stateRes.ok) {
const stateEvents = await stateRes.json();
const childRoomIds = stateEvents
.filter((e: any) => e.type === 'm.space.child' && e.content?.via)
.map((e: any) => e.state_key);
for (const childRoomId of childRoomIds) {
const childInvite = await matrixInvite(homeserver_url, access_token, childRoomId, matrix_user_id);
if (!childInvite.ok) {
const err = await childInvite.json().catch(() => ({}));
if (err.errcode !== 'M_FORBIDDEN') {
errors.push(`Room ${childRoomId}: ${err.error || 'failed'}`);
}
}
}
}
} catch (e) {
errors.push('Failed to fetch space children');
}
return json({
success: errors.length === 0,
invited: matrix_user_id,
spaceId,
errors: errors.length > 0 ? errors : undefined,
});
};
/**
* DELETE: Kick a user from the org's Matrix Space (and its child rooms).
*
* Query: ?org_id=...&matrix_user_id=...&homeserver_url=...&access_token=...
*/
export const DELETE: RequestHandler = async ({ url, locals }) => {
const session = await locals.safeGetSession();
if (!session.user) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const org_id = url.searchParams.get('org_id');
const matrix_user_id = url.searchParams.get('matrix_user_id');
const homeserver_url = url.searchParams.get('homeserver_url');
const access_token = url.searchParams.get('access_token');
if (!org_id || !matrix_user_id || !homeserver_url || !access_token) {
return json({ error: 'Missing required fields' }, { status: 400 });
}
const { data: org } = await locals.supabase
.from('organizations')
.select('matrix_space_id')
.eq('id', org_id)
.single();
if (!org?.matrix_space_id) {
return json({ error: 'Organization has no Matrix Space' }, { status: 404 });
}
const spaceId = org.matrix_space_id;
const errors: string[] = [];
// Kick from child rooms first, then from the space
try {
const stateRes = await fetch(
`${homeserver_url}/_matrix/client/v3/rooms/${encodeURIComponent(spaceId)}/state`,
{
headers: { 'Authorization': `Bearer ${access_token}` },
}
);
if (stateRes.ok) {
const stateEvents = await stateRes.json();
const childRoomIds = stateEvents
.filter((e: any) => e.type === 'm.space.child' && e.content?.via)
.map((e: any) => e.state_key);
for (const childRoomId of childRoomIds) {
const kickRes = await matrixKick(homeserver_url, access_token, childRoomId, matrix_user_id);
if (!kickRes.ok) {
const err = await kickRes.json().catch(() => ({}));
if (err.errcode !== 'M_FORBIDDEN') {
errors.push(`Room ${childRoomId}: ${err.error || 'failed'}`);
}
}
}
}
} catch (e) {
errors.push('Failed to fetch space children');
}
// Kick from the space itself
const kickRes = await matrixKick(homeserver_url, access_token, spaceId, matrix_user_id);
if (!kickRes.ok) {
const err = await kickRes.json().catch(() => ({}));
if (err.errcode !== 'M_FORBIDDEN') {
errors.push(`Space kick: ${err.error || 'failed'}`);
}
}
return json({
success: errors.length === 0,
kicked: matrix_user_id,
spaceId,
errors: errors.length > 0 ? errors : undefined,
});
};
// Helper: invite a user to a room
async function matrixInvite(homeserver: string, token: string, roomId: string, userId: string) {
return fetch(`${homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/invite`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ user_id: userId }),
});
}
// Helper: kick a user from a room
async function matrixKick(homeserver: string, token: string, roomId: string, userId: string) {
return fetch(`${homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/kick`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ user_id: userId, reason: 'Removed from organization' }),
});
}

View File

@@ -1,6 +1,7 @@
@import url('https://fonts.googleapis.com/css2?family=Tilt+Warp&family=Work+Sans:wght@400;500;600;700&family=Inter:wght@400;500;600&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Tilt+Warp&family=Work+Sans:wght@400;500;600;700&family=Inter:wght@400;500;600&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,400,0,0&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,400,0,0&display=swap');
@import 'tailwindcss'; @import 'tailwindcss';
@import 'highlight.js/styles/github-dark.css';
@plugin '@tailwindcss/forms'; @plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography'; @plugin '@tailwindcss/typography';
@@ -102,4 +103,68 @@
.prose h1, .prose h2, .prose h3, .prose h4 { @apply text-light font-heading; margin: 0.75em 0 0.5em; } .prose h1, .prose h2, .prose h3, .prose h4 { @apply text-light font-heading; margin: 0.75em 0 0.5em; }
.prose a { @apply text-primary underline; } .prose a { @apply text-primary underline; }
.prose hr { @apply border-t border-dark my-4; } .prose hr { @apply border-t border-dark my-4; }
.prose img { @apply max-w-full rounded-sm; }
.prose table { @apply w-full border-collapse my-2; }
.prose th, .prose td { @apply border border-dark p-2 text-left; }
.prose th { @apply bg-night font-semibold; }
}
/* Chat: Inline Twemoji sizing */
.twemoji-inline {
display: inline-block;
width: 1.2em;
height: 1.2em;
vertical-align: -0.2em;
margin: 0 0.05em;
}
/* Chat: Emoji-only messages show larger emojis */
.emoji-only .twemoji-inline {
width: 2.8em;
height: 2.8em;
vertical-align: -0.3em;
margin: 0 0.075em;
}
.twemoji {
display: inline-block;
vertical-align: -0.1em;
}
/* Chat: Mention styles */
.mention-ping {
background-color: color-mix(in srgb, var(--color-primary) 20%, transparent);
color: var(--color-primary);
padding: 0 0.25em;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
font-size: inherit;
font-family: inherit;
border: none;
transition: background-color 150ms ease;
}
.mention-ping:hover {
background-color: color-mix(in srgb, var(--color-primary) 35%, transparent);
text-decoration: underline;
}
.mention-everyone {
background-color: color-mix(in srgb, var(--color-warning) 20%, transparent);
color: var(--color-warning);
}
.mention-everyone:hover {
background-color: color-mix(in srgb, var(--color-warning) 35%, transparent);
}
/* Chat: Message highlight animation for reply scroll */
@keyframes message-highlight {
0%, 100% { background-color: transparent; }
50% { background-color: rgba(0, 163, 224, 0.2); }
}
.message-highlight {
animation: message-highlight 1s ease-in-out 2;
} }

View File

@@ -0,0 +1,2 @@
-- Add Matrix Space ID to organizations for org <-> space mapping
ALTER TABLE organizations ADD COLUMN IF NOT EXISTS matrix_space_id TEXT;

View File

@@ -10,6 +10,13 @@ export default defineConfig({
sveltekit(), sveltekit(),
paraglideVitePlugin({ project: './project.inlang', outdir: './src/lib/paraglide' }) paraglideVitePlugin({ project: './project.inlang', outdir: './src/lib/paraglide' })
], ],
optimizeDeps: {
exclude: ['@matrix-org/matrix-sdk-crypto-wasm']
},
ssr: {
noExternal: [],
external: ['@matrix-org/matrix-sdk-crypto-wasm']
},
server: { server: {
watch: { watch: {
// Reduce file-watcher overhead on Windows — ignore heavy dirs // Reduce file-watcher overhead on Windows — ignore heavy dirs