Compare commits
6 Commits
d1ce5d0951
...
13cdb605ca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13cdb605ca | ||
|
|
45ab939b7f | ||
|
|
23035b6ab4 | ||
|
|
3f267e3b13 | ||
|
|
be99a02e78 | ||
|
|
a8d79cf138 |
@@ -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
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -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']
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
168
src/routes/api/matrix-space/+server.ts
Normal file
168
src/routes/api/matrix-space/+server.ts
Normal 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 });
|
||||||
|
};
|
||||||
184
src/routes/api/matrix-space/members/+server.ts
Normal file
184
src/routes/api/matrix-space/members/+server.ts
Normal 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' }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
2
supabase/migrations/021_org_matrix_space.sql
Normal file
2
supabase/migrations/021_org_matrix_space.sql
Normal 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;
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user