username update, status message update, status overwriting, global

status in client
This commit is contained in:
Soph :3 2026-01-13 00:20:10 +02:00
parent 9ffb3cf283
commit d9f5919b60
11 changed files with 235 additions and 79 deletions

View file

@ -14,7 +14,8 @@
import Button, { buttonVariants } from './ui/button/button.svelte';
import User from './extra/User.svelte';
import type { SessionValidationResult } from '$lib/server/auth';
import type { OverviewData } from '$lib';
import { Status, statuses, type OverviewData, type OverviewUser } from '$lib';
import Label from './ui/label/label.svelte';
let {
currentPage = $bindable<string | null>(),
@ -259,6 +260,7 @@
subPage = null;
}}
user={friend}
crown={false}
></User>
</Sidebar.MenuSubButton>
</Sidebar.MenuSubItem>
@ -349,5 +351,66 @@
</Sidebar.Menu>
</Sidebar.Group>
</Sidebar.Content>
<Sidebar.Footer class="border-t-2 p-2">
<Dialog.Root>
<Dialog.Trigger>
<User user={user as unknown as OverviewUser} crown={false} />
</Dialog.Trigger>
<Dialog.Content>
<form method="POST" action="?/updateProfile">
<Dialog.Header>
<Dialog.Title>Edit profile</Dialog.Title>
<Dialog.Description>Update how others see you.</Dialog.Description>
</Dialog.Header>
<div class="">
<Label for="userName">Username</Label>
<Input
id="userName"
name="userName"
placeholder="Your name"
value={user?.username}
required
minlength={2}
maxlength={32}
/>
</div>
<!-- Presence -->
<div class="space-y-1">
<Label for="status">Status</Label>
<select id="status" name="status" class="input" required>
<option value={Status.ONLINE} selected={user?.statusOverwrite === Status.ONLINE}
>Online</option
>
<option value={Status.DND} selected={user?.statusOverwrite === Status.DND}
>Do Not Disturb</option
>
<option value={Status.OFFLINE} selected={user?.statusOverwrite === Status.OFFLINE}
>Offline</option
>
</select>
</div>
<div class="space-y-1">
<Label for="statusMessage">Status message</Label>
<Input
id="statusMessage"
name="statusMessage"
placeholder="What's going on?"
value={user?.statusMessage ?? ''}
maxlength={64}
/>
</div>
<Dialog.Footer>
<Dialog.Close class={buttonVariants({ variant: 'outline' })}>Cancel</Dialog.Close>
<Button type="submit">Save</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
</Sidebar.Footer>
<Sidebar.Rail />
</Sidebar.Root>

View file

@ -1,12 +1,14 @@
<script lang="ts">
import { Status, type UserWithStatus } from '$lib';
import { Status, statuses, type OverviewUser } from '$lib';
import Crown from '@lucide/svelte/icons/crown';
const {
onclick,
user,
crown
}: { crown: boolean; onclick?: (e: MouseEvent) => void; user: UserWithStatus } = $props();
}: { crown: boolean; onclick?: (e: MouseEvent) => void; user: OverviewUser } = $props();
let status: Status | undefined = $derived(statuses.get(user.id));
</script>
<div class="flex flex-row gap-2">
@ -16,20 +18,23 @@
alt={user.username}
class="size-6 rounded-full"
/>
<div class="relative">
{#if user.status === Status.OFFLINE}
<span
class="absolute end-0 bottom-0 block size-2 rounded-full bg-gray-500 ring-1 ring-white"
></span>
{:else if user.status === Status.DND}
<span class="absolute end-0 bottom-0 block size-2 rounded-full bg-red-500 ring-1 ring-white"
></span>
{:else if user.status === Status.ONLINE}
<span
class="absolute end-0 bottom-0 block size-2 rounded-full bg-green-500 ring-1 ring-white"
></span>
{/if}
</div>
{#if status}
<div class="relative">
{#if status.status === Status.OFFLINE}
<span
class="absolute end-0 bottom-0 block size-2 rounded-full bg-gray-500 ring-1 ring-white"
></span>
{:else if status.status === Status.DND}
<span
class="absolute end-0 bottom-0 block size-2 rounded-full bg-red-500 ring-1 ring-white"
></span>
{:else if status.status === Status.ONLINE}
<span
class="absolute end-0 bottom-0 block size-2 rounded-full bg-green-500 ring-1 ring-white"
></span>
{/if}
</div>
{/if}
</div>
<div>
<a
@ -46,8 +51,10 @@
<Crown></Crown>
{/if}
</a>
<div class="pl-2 text-xs text-gray-400 italic">
{user.statusMessage}
</div>
{#if status}
<div class="pl-2 text-xs text-gray-400 italic">
{status.statusMessage}
</div>
{/if}
</div>
</div>

View file

@ -12,7 +12,7 @@
type OverviewData,
type OverviewGroup,
type OverviewServer,
type UserWithStatus
type OverviewUser
} from '$lib';
import Button from './ui/button/button.svelte';
import Input from './ui/input/input.svelte';
@ -20,14 +20,14 @@
// Props for the member sidebar.
let {
open = $bindable(true),
members = $bindable<UserWithStatus[]>([]),
members = $bindable<OverviewUser[]>([]),
user,
data,
currentEntity,
currentEntityId = $bindable<string | null>(null)
}: {
open: boolean;
members: UserWithStatus[];
members: OverviewUser[];
data: OverviewData;
user: SessionValidationResult['user'];
currentEntity: OverviewGroup | OverviewServer;
@ -49,13 +49,13 @@
{#if user && currentEntityId}
<Dialog.Root>
<Dialog.Trigger><Button variant="outline"><Cog></Cog></Button></Dialog.Trigger>
<Dialog.Content class="sm:max-w-[425px]">
<Dialog.Content class="sm:max-w-106.25">
<Dialog.Header>
<Dialog.Title>Group Settings</Dialog.Title>
<Dialog.Description>Configure your group settings here.</Dialog.Description>
</Dialog.Header>
<Tabs.Root value="users" class="w-[400px]">
<Tabs.Root value="users" class="w-100">
<Tabs.List class="grid w-full grid-cols-2">
<Tabs.Trigger value="users">User Permissions</Tabs.Trigger>
{#if user.id == currentEntity.ownerId}
@ -194,9 +194,7 @@
{#each members as member (member.id)}
<Sidebar.MenuItem>
<Sidebar.MenuButton>
{#snippet child({ props })}
<User user={member} crown={member.id == currentEntity.ownerId} />
{/snippet}
<User user={member} crown={member.id == currentEntity.ownerId} />
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/each}

View file

@ -1,4 +1,5 @@
import { definePrefix, type Puuid } from './puuid';
import { SvelteMap } from 'svelte/reactivity';
export const UserID = definePrefix('user');
export const GroupID = definePrefix('group');
@ -14,6 +15,12 @@ export type ServerId = Puuid<'srv'>;
export type DirectMessageId = Puuid<'dmid'>;
export type ChannelId = Puuid<'ch'>;
export interface Status {
statusMessage: string;
status: 1 | 2 | 3;
}
export const statuses: Map<string, Status> = new SvelteMap();
export const Status: Record<string, 1 | 2 | 3> = {
OFFLINE: 1,
DND: 2,
@ -65,13 +72,8 @@ export type OverviewGroup = {
};
};
export interface UserWithStatus extends OverviewUser {
status: 1 | 2 | 3;
statusMessage: string;
}
export interface OverviewData {
friends: UserWithStatus[];
friends: OverviewUser[];
groups: OverviewGroup[];
servers: OverviewServer[];
}

View file

@ -2,7 +2,7 @@ import type { RequestEvent } from '@sveltejs/kit';
import { eq, inArray, or } from 'drizzle-orm';
import { sha256 } from '@oslojs/crypto/sha2';
import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding';
import { db } from '$lib/server/db';
import { db, kvStore } from '$lib/server/db';
import * as table from '$lib/server/db/schema';
import { _findDmId } from '../../routes/api/messages/[[grp_srv_dm]]/[[channelId]]/[[channelId]]/+server';
@ -159,7 +159,8 @@ export async function validateSessionToken(token: string) {
}
};
}),
friendRequests
friendRequests,
statusMessage: kvStore.get('user-' + user.id + '-message')
}
};
}

View file

@ -6,3 +6,8 @@ const sqlite = new Database('database.db');
export const db = drizzle(sqlite);
export const kvStore = new BunSqliteKeyValue('./kvStore.db');
console.log('nuking kvstore');
kvStore.getItems()?.forEach((z) => {
if (z.key.endsWith('state')) kvStore.set(z.key, 1);
});

View file

@ -1,7 +1,7 @@
import { db, kvStore } from '$lib/server/db';
import * as table from '$lib/server/db/schema';
import { eq, inArray } from 'drizzle-orm';
import { GroupID, ServerID } from '$lib';
import { GroupID, ServerID, Status } from '$lib';
import { error, json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
@ -27,8 +27,15 @@ export const GET: RequestHandler = async ({ params }) => {
return json({
members: members.map((member) => ({
id: member.id,
status: kvStore.get('user-' + member.id + '-state'),
statusMessage: Math.random() > 0.5 ? 'vibing 🟢' : 'not vibing',
status:
member.statusOverwrite == Status.OFFLINE
? Status.OFFLINE
: kvStore.get('user-' + member.id + '-state'),
statusMessage:
kvStore.get('user-' + member.id + '-state') != Status.OFFLINE &&
member.statusOverwrite != Status.OFFLINE
? kvStore.get('user-' + member.id + '-message')
: '',
username: member.username,
image: `https://api.dicebear.com/7.x/pixel-art/svg?seed=${member.username}`
}))
@ -50,8 +57,15 @@ export const GET: RequestHandler = async ({ params }) => {
return json({
members: members.map((member) => ({
id: member.id,
status: kvStore.get('user-' + member.id + '-state'),
statusMessage: Math.random() > 0.5 ? 'vibing 🟢' : 'not vibing',
status:
member.statusOverwrite == Status.OFFLINE
? Status.OFFLINE
: kvStore.get('user-' + member.id + '-state'),
statusMessage:
kvStore.get('user-' + member.id + '-state') != Status.OFFLINE &&
member.statusOverwrite != Status.OFFLINE
? kvStore.get('user-' + member.id + '-message')
: '',
username: member.username,
image: `https://api.dicebear.com/7.x/pixel-art/svg?seed=${member.username}`
}))

View file

@ -1,19 +1,32 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { kvStore } from '$lib/server/db';
import { db, kvStore } from '$lib/server/db';
import { Status } from '$lib';
import * as table from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
export const GET: RequestHandler = async ({ params }) => {
const { userId } = params;
const user = await db
.select({
statusOverwrite: table.user.statusOverwrite
})
.from(table.user)
.where(eq(table.user.id, userId));
if (!user || user.length == 0) {
return new Response('User missing', { status: 404 });
}
let current = kvStore.get('user-' + userId + '-state');
if (user[0].statusOverwrite == Status.OFFLINE) {
current = Status.OFFLINE;
}
return json({
userId,
status: kvStore.get('user-' + userId + '-state'),
statusMessage:
kvStore.get('user-' + userId + '-state') != Status.OFFLINE
? Math.random() > 0.5
? 'vibing 🟢'
: 'not vibing'
: ''
status: current,
statusMessage: current != Status.OFFLINE ? kvStore.get('user-' + userId + '-message') : ''
});
};

View file

@ -22,7 +22,7 @@ export function _sendToSubscribers(id: string, payload: unknown) {
}
}
export function _sendToUser(userId: string, payload: unknown) {
for (const [_, client] of _clients) {
for (const [, client] of _clients) {
if (client.userId == userId) {
client.controller.enqueue(`data: ${JSON.stringify(payload)}\n\n`);
}
@ -59,10 +59,15 @@ export async function GET({ locals, request }) {
if (overwrite === Status.DND) {
kvStore.set(`user-${userId}-state`, Status.DND);
_sendToSubscribers(userId, { type: 'status', id: userId, status: Status.DND });
} else {
kvStore.set(`user-${userId}-state`, Status.ONLINE);
_sendToSubscribers(userId, { type: 'status', id: userId, status: Status.ONLINE });
_sendToSubscribers(userId, {
type: 'status',
id: userId,
status: Status.DND,
statusMessage:
kvStore.get('user-' + userId + '-state') != Status.OFFLINE
? kvStore.get('user-' + userId + '-message')
: ''
});
}
request.signal.addEventListener('abort', () => {
@ -74,7 +79,12 @@ export async function GET({ locals, request }) {
if (overwrite === Status.OFFLINE) return;
kvStore.set(`user-${userId}-state`, Status.OFFLINE);
_sendToSubscribers(userId, { type: 'status', id: userId, status: Status.OFFLINE });
_sendToSubscribers(userId, {
type: 'status',
id: userId,
status: Status.OFFLINE,
statusMessage: ''
});
});
},
cancel() {
@ -85,7 +95,12 @@ export async function GET({ locals, request }) {
if (overwrite === Status.OFFLINE) return;
kvStore.set(`user-${userId}-state`, Status.OFFLINE);
_sendToSubscribers(userId, { type: 'status', id: userId, status: Status.OFFLINE });
_sendToSubscribers(userId, {
type: 'status',
id: userId,
status: Status.OFFLINE,
statusMessage: ''
});
}
});

View file

@ -1,14 +1,15 @@
import { fail, redirect } from '@sveltejs/kit';
import { getRequestEvent } from '$app/server';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db';
import { db, kvStore } from '$lib/server/db';
import * as table from '$lib/server/db/schema';
import { DirectMessageID, FriendRequestID, GroupID, ServerID } from '$lib';
import { eq } from 'drizzle-orm';
import { and } from 'drizzle-orm';
import { type User } from '$lib/server/db/schema';
import { _sendToSubscribers, _sendToUser } from '../api/updates/+server';
import { _findDmId } from '../api/messages/[[grp_srv_dm]]/[[channelId]]/[[channelId]]/+server';
import { validateUsername } from '$lib/server/auth';
export const load: PageServerLoad = async () => {
const user = requireLogin();
return { user };
@ -196,6 +197,41 @@ export const actions = {
return { success: true };
},
updateProfile: async ({ request, locals }) => {
const user = locals.user;
if (!user) return fail(401);
const data = await request.formData();
const userName = data.get('userName')?.toString().trim();
const status = data.has('status') ? +data.get('status')! : undefined;
const statusMessage = data.get('statusMessage')?.toString().trim() || undefined;
if (!validateUsername(userName)) {
return fail(400, { message: 'Invalid display name' });
}
if (status != 1 && status != 2 && status != 3) {
return fail(400, { message: 'Invalid status' });
}
kvStore.set('user-' + user.id + '-message', statusMessage);
await db
.update(table.user)
.set({
username: userName,
statusOverwrite: status
})
.where(eq(table.user.id, user.id));
if (statusMessage || status != user.statusOverwrite)
_sendToSubscribers(user.id, { type: 'status', id: user.id, status: status, statusMessage });
if (userName != user.username)
_sendToSubscribers(user.id, { type: 'username', status: 'name-changed' });
return { success: true };
},
createGroup: async ({ request, locals }) => {
const data = await request.formData();
const members = data.getAll('member').map((z) => z.toString());

View file

@ -11,11 +11,11 @@
type OverviewUser,
type OverviewGroup,
type OverviewServer,
type UserWithStatus,
type ReturnMessage,
type Channel,
type ChannelId,
ChannelID
ChannelID,
statuses
} from '$lib';
import type { PageServerData } from './$types';
import AppSidebar from '$lib/components/app-sidebar.svelte';
@ -43,7 +43,7 @@
let sse: EventSource | undefined;
let messagesElement: HTMLDivElement | undefined = $state();
let isMembersTabOpen = $state(true);
let members: UserWithStatus[] = $state([]);
let members: OverviewUser[] = $state([]);
let messages: ReturnMessage[] = $state([]);
let inputValue = $state();
@ -88,11 +88,14 @@
const status = await res.json();
statuses.set(friend.id, {
status: status.status,
statusMessage: status.statusMessage
});
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
};
@ -198,9 +201,10 @@
const json = JSON.parse(e.data) as
| { type: 'connected'; sessionId: string }
| { type: 'message'; message: ReturnMessage }
| { type: 'status'; id: string; status: 1 | 2 | 3 }
| { type: 'status'; id: string; status: 1 | 2 | 3; statusMessage: string }
| { type: 'friends'; status: string }
| { type: 'group'; status: string }
| { type: 'username'; status: string }
| { type: 'server'; status: string };
if (json.type == 'friends') {
toast('Invalidation from friends updates, recieved ' + json.status);
@ -216,25 +220,29 @@
toast('Invalidation from group updates, recieved ' + json.status);
if (json.status == 'removed-from-group' && GroupID.is(currentPageID)) {
currentPageID = null;
currentPage = null;
currentPage = undefined;
}
await invalidateAll();
await fill_overview_data();
}
if (json.type == 'username') {
toast('Invalidation from username updates, recieved ' + json.status);
await invalidateAll();
await fill_overview_data();
}
if (json.type == 'connected') {
console.log('SSE connected. We are sessionID ' + json.sessionId);
sessionId = json.sessionId;
}
if (json.type == 'status') {
//@TODO update everywhere where user is used
const friend = overview_data.friends.find((z) => z.id == json.id);
if (friend) {
friend.status = json.status;
}
statuses.set(json.id, {
status: json.status,
statusMessage: json.statusMessage
});
}
if (json.type == 'message') {
@ -294,18 +302,12 @@
<h1>{server!.name} - Server Info</h1>
{/if}
{:else if UserID.is(currentPageID)}
{@const friend = currentPage as UserWithStatus}
{@const friend = currentPage as OverviewUser}
<img src={friend.image} alt={friend!.username} class="size-6 rounded-full" />
<h1>
{friend!.username} [{friend.status == Status.ONLINE
? 'Online!'
: friend.status == Status.DND
? 'DND'
: friend.status == Status.OFFLINE
? 'Offline'
: 'Unknown'}]
{friend!.username}
</h1>
{:else if GroupID.is(currentPageID)}
{@const group = currentPage as OverviewGroup}