implement channels fully, implement servers fully, make dms impossible

to send if no longer friends, update overview information on
invalidation (form response recieved, friends update)
This commit is contained in:
Soph :3 2026-01-11 13:47:46 +02:00
parent 92a95cb365
commit 7af96ca084
8 changed files with 245 additions and 104 deletions

View file

@ -18,11 +18,13 @@
let { let {
currentPage = $bindable<string | null>(), currentPage = $bindable<string | null>(),
subPage = $bindable<string | null>(),
data, data,
user, user,
...restProps ...restProps
}: { }: {
currentPage: string | null; currentPage: string | null;
subPage: string | null;
data: OverviewData; data: OverviewData;
user: SessionValidationResult['user']; user: SessionValidationResult['user'];
} = $props(); } = $props();
@ -254,6 +256,7 @@
onclick={(e) => { onclick={(e) => {
e.preventDefault(); e.preventDefault();
currentPage = friend.id; currentPage = friend.id;
subPage = null;
}} }}
user={friend} user={friend}
></User> ></User>
@ -283,6 +286,7 @@
onclick={(e) => { onclick={(e) => {
e.preventDefault(); e.preventDefault();
currentPage = group.id; currentPage = group.id;
subPage = null;
}} }}
href="##" href="##"
> >
@ -309,24 +313,34 @@
<Sidebar.MenuSub> <Sidebar.MenuSub>
{#each data.servers as server (server.id)} {#each data.servers as server (server.id)}
<Sidebar.MenuSubItem> <Sidebar.MenuSubItem>
<Sidebar.MenuSubButton> <Sidebar.MenuSubButton
<a onclick={(e) => {
onclick={(e) => { e.preventDefault();
e.preventDefault(); currentPage = server.id;
currentPage = server.id; subPage = null;
}} }}
href="##" >
class="flex items-center gap-2" <img
> src={'https://api.dicebear.com/7.x/pixel-art/svg?seed=' + server.name}
<img alt={server.name}
src={'https://api.dicebear.com/7.x/pixel-art/svg?seed=' + server.name} class="size-6 rounded-full"
alt={server.name} />
class="size-6 rounded-full" {server.name}
/>
{server.name}
</a>
</Sidebar.MenuSubButton> </Sidebar.MenuSubButton>
</Sidebar.MenuSubItem> </Sidebar.MenuSubItem>
{#each server.channels as channel (channel.id)}
<a
onclick={(e) => {
e.preventDefault();
currentPage = server.id;
subPage = channel.id;
}}
href="##"
class="flex items-center gap-2"
>
{channel.name}
</a>
{/each}
{/each} {/each}
</Sidebar.MenuSub> </Sidebar.MenuSub>
</Collapsible.Content> </Collapsible.Content>

View file

@ -3,6 +3,8 @@ import { definePrefix, type Puuid } from './puuid';
export const UserID = definePrefix('user'); export const UserID = definePrefix('user');
export const GroupID = definePrefix('group'); export const GroupID = definePrefix('group');
export const ServerID = definePrefix('srv'); export const ServerID = definePrefix('srv');
export const ChannelID = definePrefix('ch');
export const FriendRequestID = definePrefix('frq'); export const FriendRequestID = definePrefix('frq');
export const DirectMessageID = definePrefix('dmid'); export const DirectMessageID = definePrefix('dmid');
@ -10,6 +12,7 @@ export type UserId = Puuid<'user'>;
export type GroupId = Puuid<'group'>; export type GroupId = Puuid<'group'>;
export type ServerId = Puuid<'srv'>; export type ServerId = Puuid<'srv'>;
export type DirectMessageId = Puuid<'dmid'>; export type DirectMessageId = Puuid<'dmid'>;
export type ChannelId = Puuid<'ch'>;
export const Status: Record<string, 1 | 2 | 3> = { export const Status: Record<string, 1 | 2 | 3> = {
OFFLINE: 1, OFFLINE: 1,
@ -23,6 +26,11 @@ export type OverviewUser = {
image: string; image: string;
dmId?: string; dmId?: string;
}; };
export interface Channel {
id: string;
name: string;
}
export interface Message { export interface Message {
id: string; id: string;
authorId: string; authorId: string;
@ -42,6 +50,7 @@ export type OverviewServer = {
name: string; name: string;
ownerId: string; ownerId: string;
image: string; image: string;
channels: Channel[];
}; };
export type OverviewGroup = { export type OverviewGroup = {
id: string; id: string;

View file

@ -10,7 +10,8 @@ export function definePrefix<const P extends string>(prefix: P) {
return { return {
prefix, prefix,
is(value: string): value is Puuid<P> { is(value: string | undefined | null): value is Puuid<P> {
if (!value) return false;
return value.startsWith(prefix + '_'); return value.startsWith(prefix + '_');
}, },

View file

@ -84,16 +84,38 @@ export async function validateSessionToken(token: string) {
}) })
); );
const servers = (user.servers as string[]).length let servers = (user.servers as string[]).length
? await db ? await db
.select({ .select({
id: table.server.id, id: table.server.id,
name: table.server.name, name: table.server.name,
ownerId: table.server.owner ownerId: table.server.owner,
channels: table.server.channels
}) })
.from(table.server) .from(table.server)
.where(inArray(table.server.id, user.servers as string[])) .where(inArray(table.server.id, user.servers as string[]))
: []; : [];
servers = await Promise.all(
servers.map(async (z) => {
return {
...z,
channels: (
await Promise.all(
(z.channels as string[]).map(async (m) => {
const channel = await db.select().from(table.channel).where(eq(table.channel.id, m));
if (!channel || channel.length == 0) return;
return {
name: channel[0].name,
id: channel[0].id
};
})
)
).filter(Boolean)
};
})
);
const groups = (user.groups as string[]).length const groups = (user.groups as string[]).length
? await db ? await db
.select({ .select({

View file

@ -149,8 +149,19 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
messages = (c.messages as Message[]) ?? []; messages = (c.messages as Message[]) ?? [];
messages.push(message); messages.push(message);
_sendToSubscribers(c.id, {
type: 'message',
id: c.id,
message: {
...message,
author: {
id: locals.user.id,
name: locals.user.username
}
}
});
await db.update(table.channel).set({ messages }).where(eq(table.channel.id, channelId)); await db.update(table.channel).set({ messages }).where(eq(table.channel.id, channelId));
return json({ type, id: c.id, messages });
} else if (DirectMessageID.is(grp_srv_dm)) { } else if (DirectMessageID.is(grp_srv_dm)) {
type = 'dms'; type = 'dms';
const dm = ( const dm = (
@ -158,7 +169,12 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
)[0]; )[0];
if (!dm) return new Response('DM not found.', { status: 404 }); if (!dm) return new Response('DM not found.', { status: 404 });
const firstMember = await db.select().from(table.user).where(eq(table.user.id, dm.firstMember));
if (!firstMember || firstMember.length == 0)
return new Response('First member is invalid.', { status: 404 });
if (!(firstMember[0].friends as string[]).includes(dm.secondMember))
return new Response('You are no longer friends.', { status: 404 });
messages = (dm.messages as Message[]) ?? []; messages = (dm.messages as Message[]) ?? [];
messages.push(message); messages.push(message);

View file

@ -1,7 +1,7 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { _clients } from '../+server'; import { _clients } from '../+server';
import { DirectMessageID } from '$lib'; import { ChannelID, DirectMessageID, UserID } from '$lib';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import * as table from '$lib/server/db/schema'; import * as table from '$lib/server/db/schema';
import { or } from 'drizzle-orm'; import { or } from 'drizzle-orm';
@ -33,7 +33,7 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
return json({ error: 'Invalid subscribeTo value' }, { status: 400 }); return json({ error: 'Invalid subscribeTo value' }, { status: 400 });
} }
let isValidDmid = false; let isValid = false;
if (DirectMessageID.is(subscribeTo)) { if (DirectMessageID.is(subscribeTo)) {
const find = await db const find = await db
@ -47,13 +47,35 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
); );
if (find?.length != 0) { if (find?.length != 0) {
isValidDmid = true; isValid = true;
}
}
if (UserID.is(subscribeTo)) {
const find = await db
.select()
.from(table.user)
.where(or(eq(table.user.id, subscribeTo)));
if (find?.length != 0) {
isValid = true;
}
}
if (ChannelID.is(subscribeTo)) {
const find = await db
.select()
.from(table.channel)
.where(or(eq(table.channel.id, subscribeTo)));
if (find?.length != 0) {
isValid = true;
} }
} }
if ( if (
!( !(
isValidDmid || isValid ||
locals.user.groups.find((z) => z.id === subscribeTo) || locals.user.groups.find((z) => z.id === subscribeTo) ||
locals.user.servers.find((z) => z.id === subscribeTo) locals.user.servers.find((z) => z.id === subscribeTo)
) )

View file

@ -8,6 +8,7 @@ import { eq } from 'drizzle-orm';
import { and } from 'drizzle-orm'; import { and } from 'drizzle-orm';
import { type User } from '$lib/server/db/schema'; import { type User } from '$lib/server/db/schema';
import { _sendToSubscribers } from '../api/updates/+server'; import { _sendToSubscribers } from '../api/updates/+server';
import { _findDmId } from '../api/messages/[[grp_srv_dm]]/[[channelId]]/[[channelId]]/+server';
export const load: PageServerLoad = async () => { export const load: PageServerLoad = async () => {
const user = requireLogin(); const user = requireLogin();
return { user }; return { user };

View file

@ -12,7 +12,10 @@
type OverviewGroup, type OverviewGroup,
type OverviewServer, type OverviewServer,
type UserWithStatus, type UserWithStatus,
type ReturnMessage type ReturnMessage,
type Channel,
type ChannelId,
ChannelID
} from '$lib'; } from '$lib';
import type { PageServerData } from './$types'; import type { PageServerData } from './$types';
import AppSidebar from '$lib/components/app-sidebar.svelte'; import AppSidebar from '$lib/components/app-sidebar.svelte';
@ -27,13 +30,17 @@
import SendHorizontal from '@lucide/svelte/icons/send-horizontal'; import SendHorizontal from '@lucide/svelte/icons/send-horizontal';
import PersonStanding from '@lucide/svelte/icons/person-standing'; import PersonStanding from '@lucide/svelte/icons/person-standing';
import MemberSidebar from '$lib/components/member-sidebar.svelte'; import MemberSidebar from '$lib/components/member-sidebar.svelte';
import User from '$lib/components/extra/User.svelte';
import { invalidate, invalidateAll } from '$app/navigation';
let errorOpen = $state(true); let errorOpen = $state(true);
let { form, data }: { form: ActionData; data: PageServerData } = $props(); let { form, data }: { form: ActionData; data: PageServerData } = $props();
let currentPageID: (UserId | GroupId | ServerId) | null = $state(null); let currentPageID: (UserId | GroupId | ServerId) | null = $state(null);
let currentSubPageID: ChannelId | null = $state(null);
let currentPage: OverviewUser | OverviewGroup | OverviewServer | undefined = $state(); let currentPage: OverviewUser | OverviewGroup | OverviewServer | undefined = $state();
let ownerId: string | null = $state(null); let currentSubPage: Channel | null = $state(null);
let isMembersTabOpen = $state(true); let isMembersTabOpen = $state(true);
let members: UserWithStatus[] = $state([]); let members: UserWithStatus[] = $state([]);
@ -49,7 +56,50 @@
servers: [] servers: []
}); });
async function fill_overview_data() {
overview_data.servers = data.user.servers.map((z) => {
console.log(z);
return {
id: ServerID.parse(z.id),
name: z.name,
ownerId: z.ownerId,
channels: z.channels as Channel[],
image: 'https://api.dicebear.com/7.x/pixel-art/svg?seed=' + z.name
};
});
overview_data.groups = data.user.groups.map((z) => {
return {
id: GroupID.parse(z.id),
name: z.name,
ownerId: z.ownerId,
members: z.members,
permissions: z.permissions,
image: 'https://api.dicebear.com/7.x/pixel-art/svg?seed=' + z.name
};
});
overview_data.friends = await Promise.all(
data.user.friends.map(async (friend) => {
const res = await fetch(`/api/status/${friend.id}`);
if (!res.ok) {
throw new Error(`Failed to fetch status for ${friend.id}`);
}
const status = await res.json();
return {
id: UserID.parse(friend.id),
username: friend.username,
status: status.status,
statusMessage: status.statusMessage,
dmId: friend.dmId,
image: 'https://api.dicebear.com/7.x/pixel-art/svg?seed=' + friend.username
};
})
);
}
$effect(() => { $effect(() => {
console.log(currentPageID, currentSubPageID);
if (currentPageID) { if (currentPageID) {
if (UserID.is(currentPageID)) { if (UserID.is(currentPageID)) {
currentPage = overview_data.friends.find((friend) => friend.id === currentPageID); currentPage = overview_data.friends.find((friend) => friend.id === currentPageID);
@ -58,6 +108,15 @@
} else if (ServerID.is(currentPageID)) { } else if (ServerID.is(currentPageID)) {
currentPage = overview_data.servers.find((server) => server.id === currentPageID); currentPage = overview_data.servers.find((server) => server.id === currentPageID);
} }
if (currentSubPageID) {
console.log((currentPage as OverviewServer).channels.find((z) => z.id == currentSubPageID));
currentSubPage = (currentPage as OverviewServer).channels.find(
(z) => z.id == currentSubPageID
) as Channel | null;
} else {
currentSubPage = null;
}
} else { } else {
currentPage = undefined; currentPage = undefined;
} }
@ -72,7 +131,6 @@
members = data.members; members = data.members;
} }
fetchMembers(); fetchMembers();
ownerId = (currentPage as OverviewGroup | OverviewServer).ownerId;
} else { } else {
isMembersTabOpen = false; isMembersTabOpen = false;
} }
@ -80,26 +138,35 @@
$effect(() => { $effect(() => {
if (!currentPageID || !currentPage) return; if (!currentPageID || !currentPage) return;
if (ServerID.is(currentPageID)) return; if (ServerID.is(currentPageID) && !ChannelID.is(currentSubPageID)) return;
async function getMessages() { async function getMessages() {
const targetId = currentPage && 'dmId' in currentPage ? currentPage.dmId : currentPageID; let path = '';
let subscribe = '';
const req = await fetch('/api/messages/' + targetId); if (ServerID.is(currentPageID) && ChannelID.is(currentSubPageID)) {
path = '/api/messages/' + currentPageID + '/' + currentSubPageID;
subscribe = currentSubPageID;
} else {
subscribe = currentPage && 'dmId' in currentPage ? currentPage.dmId : currentPageID;
path = '/api/messages/' + subscribe;
}
const req = await fetch(path);
const data = await req.json(); const data = await req.json();
messages = data.messages; messages = data.messages;
if (previousSubscription && previousSubscription != targetId) { if (previousSubscription && previousSubscription != subscribe) {
await fetch('/api/updates/' + sessionId, { await fetch('/api/updates/' + sessionId, {
body: JSON.stringify({ subscribeTo: previousSubscription }), body: JSON.stringify({ subscribeTo: previousSubscription }),
method: 'DELETE' method: 'DELETE'
}); });
} }
if (previousSubscription != targetId) { if (previousSubscription != subscribe) {
await fetch('/api/updates/' + sessionId, { await fetch('/api/updates/' + sessionId, {
body: JSON.stringify({ subscribeTo: targetId }), body: JSON.stringify({ subscribeTo: subscribe }),
method: 'POST' method: 'POST'
}); });
} }
@ -112,44 +179,10 @@
onMount(() => { onMount(() => {
async function run() { async function run() {
overview_data.servers = data.user.servers.map((z) => { if (form) {
return { await invalidateAll();
id: ServerID.parse(z.id), }
name: z.name, await fill_overview_data();
ownerId: z.ownerId,
image: 'https://api.dicebear.com/7.x/pixel-art/svg?seed=' + z.name
};
});
overview_data.groups = data.user.groups.map((z) => {
return {
id: GroupID.parse(z.id),
name: z.name,
ownerId: z.ownerId,
members: z.members,
permissions: z.permissions,
image: 'https://api.dicebear.com/7.x/pixel-art/svg?seed=' + z.name
};
});
overview_data.friends = await Promise.all(
data.user.friends.map(async (friend) => {
const res = await fetch(`/api/status/${friend.id}`);
if (!res.ok) {
throw new Error(`Failed to fetch status for ${friend.id}`);
}
const status = await res.json();
return {
id: UserID.parse(friend.id),
username: friend.username,
status: status.status,
statusMessage: status.statusMessage,
dmId: friend.dmId,
image: 'https://api.dicebear.com/7.x/pixel-art/svg?seed=' + friend.username
};
})
);
} }
run(); run();
@ -158,7 +191,7 @@
onMount(() => { onMount(() => {
const sse = new EventSource('/api/updates'); const sse = new EventSource('/api/updates');
sse.addEventListener('message', (e) => { sse.addEventListener('message', async (e) => {
const json = JSON.parse(e.data) as const json = JSON.parse(e.data) as
| { type: 'connected'; sessionId: string } | { type: 'connected'; sessionId: string }
| { type: 'message'; message: ReturnMessage } | { type: 'message'; message: ReturnMessage }
@ -166,9 +199,10 @@
| { type: 'friends'; status: string }; | { type: 'friends'; status: string };
if (json.type == 'friends') { if (json.type == 'friends') {
alert(json.status); await invalidateAll();
location.reload(); await fill_overview_data();
} }
if (json.type == 'connected') { if (json.type == 'connected') {
console.log('SSE connected. We are sessionID ' + json.sessionId); console.log('SSE connected. We are sessionID ' + json.sessionId);
sessionId = json.sessionId; sessionId = json.sessionId;
@ -207,17 +241,27 @@
{/if} {/if}
<Sidebar.Provider> <Sidebar.Provider>
<AppSidebar bind:currentPage={currentPageID} user={data.user} data={overview_data} /> <AppSidebar
bind:subPage={currentSubPageID}
bind:currentPage={currentPageID}
user={data.user}
data={overview_data}
/>
<Sidebar.Inset class="h-svh"> <Sidebar.Inset class="h-svh">
<header class="flex h-16 shrink-0 items-center gap-2 border-b px-4"> <header class="flex h-16 shrink-0 items-center gap-2 border-b px-4">
<Sidebar.Trigger class="-ms-1" /> <Sidebar.Trigger class="-ms-1" />
{#if currentPageID && currentPage} {#if currentPageID && currentPage}
{#if ServerID.is(currentPageID)} {#if ServerID.is(currentPageID)}
{@const server = currentPage as OverviewServer} {@const server = currentPage as OverviewServer}
{@const channel = currentSubPage as OverviewServer}
<img src={server!.image} alt={server!.name} class="size-6 rounded-full" /> <img src={server!.image} alt={server!.name} class="size-6 rounded-full" />
<h1>{server!.name}</h1> {#if currentSubPage}
<h1>{server!.name} - {channel.name}</h1>
{:else}
<h1>{server!.name} - Server Info</h1>
{/if}
{:else if UserID.is(currentPageID)} {:else if UserID.is(currentPageID)}
{@const friend = currentPage as UserWithStatus} {@const friend = currentPage as UserWithStatus}
@ -252,46 +296,58 @@
{/if} {/if}
{/if} {/if}
</header> </header>
<div class="h-min shrink overflow-scroll">
{#each messages as message, i (message.id)}
{#if i === 0 || messages[i - 1].authorId !== message.authorId}
<div class="flex gap-2 px-4 py-2">
<img
src={'https://api.dicebear.com/7.x/pixel-art/svg?seed=' + message.author.name}
alt={message.author.name}
class="h-6 w-6 rounded-full"
/>
<div class="flex flex-col"> {#if currentPageID && currentPage && ServerID.is(currentPageID) && !currentSubPageID}
<div class="flex items-baseline gap-2"> <h1>add invite creation, role creation, moderation, et cetera to this page.</h1>
<span class="font-semibold">{message.author.name}</span> {/if}
<span class="text-xs text-gray-400"> {#if currentPageID && currentPage && ((ServerID.is(currentPageID) && ChannelID.is(currentSubPageID)) || UserID.is(currentPageID) || GroupID.is(currentPageID))}
{formatTimestamp(message.timestamp)} <div class="h-min shrink overflow-scroll">
</span> {#each messages as message, i (message.id)}
{#if i === 0 || messages[i - 1].authorId !== message.authorId}
<div class="flex gap-2 px-4 py-2">
<img
src={'https://api.dicebear.com/7.x/pixel-art/svg?seed=' + message.author.name}
alt={message.author.name}
class="h-6 w-6 rounded-full"
/>
<div class="flex flex-col">
<div class="flex items-baseline gap-2">
<span class="font-semibold">{message.author.name}</span>
<span class="text-xs text-gray-400">
{formatTimestamp(message.timestamp)}
</span>
</div>
<div class="whitespace-pre-wrap">{message.content}</div>
</div> </div>
</div>
{:else}
<div class="ml-8 flex gap-8 px-4 py-1">
<div class="whitespace-pre-wrap">{message.content}</div> <div class="whitespace-pre-wrap">{message.content}</div>
</div> </div>
</div> {/if}
{:else} {/each}
<div class="ml-8 flex gap-8 px-4 py-1"> </div>
<div class="whitespace-pre-wrap">{message.content}</div>
</div>
{/if}
{/each}
</div>
{#if currentPageID && currentPage}
<div class="flex shrink-0 gap-2 border-t p-2"> <div class="flex shrink-0 gap-2 border-t p-2">
<Input <Input
bind:value={inputValue} bind:value={inputValue}
onkeydown={async (e) => { onkeydown={async (e) => {
if (e.key == 'Enter') { if (e.key == 'Enter') {
let path = '';
if (ServerID.is(currentPageID) && ChannelID.is(currentSubPageID)) {
path = '/api/messages/' + currentPageID + '/' + currentSubPageID;
} else {
path =
'/api/messages/' +
(currentPage && 'dmId' in currentPage ? currentPage.dmId : currentPageID);
}
let message = inputValue; let message = inputValue;
const targetId =
currentPage && 'dmId' in currentPage ? currentPage.dmId : currentPageID;
inputValue = ''; inputValue = '';
const req = await fetch('/api/messages/' + targetId, { const req = await fetch(path, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: message }) body: JSON.stringify({ content: message })