From 17778e173657967c3a5c7d91e664f3eb30bd8c66 Mon Sep 17 00:00:00 2001 From: fucksophie Date: Sun, 11 Jan 2026 14:57:39 +0200 Subject: [PATCH] add members from group, remove members from group, change title of group --- bun.lock | 5 + package.json | 1 + src/lib/components/member-sidebar.svelte | 56 ++++++++- src/lib/components/ui/sonner/index.ts | 1 + src/lib/components/ui/sonner/sonner.svelte | 34 ++++++ src/routes/+layout.svelte | 11 +- src/routes/api/updates/+server.ts | 9 +- src/routes/app/+page.server.ts | 135 ++++++++++++++++++++- src/routes/app/+page.svelte | 36 ++++-- 9 files changed, 266 insertions(+), 22 deletions(-) create mode 100644 src/lib/components/ui/sonner/index.ts create mode 100644 src/lib/components/ui/sonner/sonner.svelte diff --git a/bun.lock b/bun.lock index 1e384be..3311576 100644 --- a/bun.lock +++ b/bun.lock @@ -37,6 +37,7 @@ "prettier-plugin-tailwindcss": "^0.7.2", "svelte": "^5.45.6", "svelte-check": "^4.3.4", + "svelte-sonner": "^1.0.7", "tailwind-merge": "^3.4.0", "tailwind-variants": "^3.2.2", "tailwindcss": "^4.1.17", @@ -683,6 +684,8 @@ "svelte-eslint-parser": ["svelte-eslint-parser@1.4.1", "", { "dependencies": { "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.0.0", "espree": "^10.0.0", "postcss": "^8.4.49", "postcss-scss": "^4.0.9", "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-1eqkfQ93goAhjAXxZiu1SaKI9+0/sxp4JIWQwUpsz7ybehRE5L8dNuz7Iry7K22R47p5/+s9EM+38nHV2OlgXA=="], + "svelte-sonner": ["svelte-sonner@1.0.7", "", { "dependencies": { "runed": "^0.28.0" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-1EUFYmd7q/xfs2qCHwJzGPh9n5VJ3X6QjBN10fof2vxgy8fYE7kVfZ7uGnd7i6fQaWIr5KvXcwYXE/cmTEjk5A=="], + "svelte-toolbelt": ["svelte-toolbelt@0.10.6", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.35.1", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ=="], "tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="], @@ -769,6 +772,8 @@ "mode-watcher/svelte-toolbelt": ["svelte-toolbelt@0.7.1", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.23.2", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ=="], + "svelte-sonner/runed": ["runed@0.28.0", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ=="], + "vite/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], diff --git a/package.json b/package.json index ab183b0..a0b8687 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "prettier-plugin-tailwindcss": "^0.7.2", "svelte": "^5.45.6", "svelte-check": "^4.3.4", + "svelte-sonner": "^1.0.7", "tailwind-merge": "^3.4.0", "tailwind-variants": "^3.2.2", "tailwindcss": "^4.1.17", diff --git a/src/lib/components/member-sidebar.svelte b/src/lib/components/member-sidebar.svelte index f7d28e9..55e609c 100644 --- a/src/lib/components/member-sidebar.svelte +++ b/src/lib/components/member-sidebar.svelte @@ -9,22 +9,26 @@ import { GroupID, ServerID, + type OverviewData, type OverviewGroup, type OverviewServer, type UserWithStatus } from '$lib'; import Button from './ui/button/button.svelte'; + import Input from './ui/input/input.svelte'; // Props for the member sidebar. let { open = $bindable(true), members = $bindable([]), user, + data, currentEntity, currentEntityId = $bindable(null) }: { open: boolean; members: UserWithStatus[]; + data: OverviewData; user: SessionValidationResult['user']; currentEntity: OverviewGroup | OverviewServer; currentEntityId: string | null; @@ -104,15 +108,59 @@ {/if} -
+
{#if (currentEntity as OverviewGroup).permissions.addMembers || user.id == currentEntity.ownerId} -

you have permission to add members

+ {@const addableMembers = data.friends.filter( + (z) => !members.find((h) => h.id == z.id) + )} + + {#if addableMembers.length !== 0} +
+ + +

Add members

+ + {#each addableMembers as friend (friend.id)} + + {/each} + + +
+
+ {/if} {/if} + {#if (currentEntity as OverviewGroup).permissions.changeTitle || user.id == currentEntity.ownerId} -

you have permission to change title

+
+ + +

Change title

+ + + + +
+
{/if} + {#if (currentEntity as OverviewGroup).permissions.removeMembers || user.id == currentEntity.ownerId} -

you have permission to remove members

+
+ + +

Remove members

+ + {#each members.filter((z) => z.id != currentEntity.ownerId) as member (member.id)} + + {/each} + + +
{/if}
diff --git a/src/lib/components/ui/sonner/index.ts b/src/lib/components/ui/sonner/index.ts new file mode 100644 index 0000000..1ad9f4a --- /dev/null +++ b/src/lib/components/ui/sonner/index.ts @@ -0,0 +1 @@ +export { default as Toaster } from "./sonner.svelte"; diff --git a/src/lib/components/ui/sonner/sonner.svelte b/src/lib/components/ui/sonner/sonner.svelte new file mode 100644 index 0000000..08a1865 --- /dev/null +++ b/src/lib/components/ui/sonner/sonner.svelte @@ -0,0 +1,34 @@ + + +{#snippet loadingIcon()} + + {/snippet} + {#snippet successIcon()} + + {/snippet} + {#snippet errorIcon()} + + {/snippet} + {#snippet infoIcon()} + + {/snippet} + {#snippet warningIcon()} + + {/snippet} + diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 870476c..756422a 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,12 +1,13 @@ + {@render children()} diff --git a/src/routes/api/updates/+server.ts b/src/routes/api/updates/+server.ts index fc608e1..7cc2493 100644 --- a/src/routes/api/updates/+server.ts +++ b/src/routes/api/updates/+server.ts @@ -21,6 +21,13 @@ export function _sendToSubscribers(id: string, payload: unknown) { } } } +export function _sendToUser(userId: string, payload: unknown) { + for (const [_, client] of _clients) { + if (client.userId == userId) { + client.controller.enqueue(`data: ${JSON.stringify(payload)}\n\n`); + } + } +} export function _isUserConnected(userId: string): boolean { for (const client of _clients.values()) { @@ -38,7 +45,7 @@ export async function GET({ locals, request }) { //@TODO add more to subscribed eventually, server members, et cetera const subscribed = locals.user.friends.map((f) => f.id); subscribed.push(userId); // shit such as friend requests - + const overwrite = locals.user.statusOverwrite; const sessionId = crypto.randomUUID(); diff --git a/src/routes/app/+page.server.ts b/src/routes/app/+page.server.ts index 33fcecc..cd8d6ac 100644 --- a/src/routes/app/+page.server.ts +++ b/src/routes/app/+page.server.ts @@ -7,7 +7,7 @@ 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 } from '../api/updates/+server'; +import { _sendToSubscribers, _sendToUser } from '../api/updates/+server'; import { _findDmId } from '../api/messages/[[grp_srv_dm]]/[[channelId]]/[[channelId]]/+server'; export const load: PageServerLoad = async () => { const user = requireLogin(); @@ -245,6 +245,7 @@ export const actions = { await db.transaction(async (tx) => { for await (const member of members) { + _sendToUser(member, { type: 'group', status: 'added-to-group' }); const user = await tx.select().from(table.user).where(eq(table.user.id, member)).limit(1); await tx .update(table.user) @@ -293,6 +294,138 @@ export const actions = { return { success: true }; }, + addMembers: async ({ request, locals }) => { + const data = await request.formData(); + const groupId = data.get('groupId'); + const memberIds = data.getAll('memberIds').map(String); + + if (typeof groupId !== 'string') { + return fail(400, { error: 'Invalid group ID' }); + } + + if (!memberIds.length) { + return fail(400, { error: 'No members selected' }); + } + + const group = await db.select().from(table.group).where(eq(table.group.id, groupId)).limit(1); + if (!group.length) return fail(404, { error: 'Group not found' }); + + const g = group[0]; + + if (!(g.members as string[]).includes(locals.user!.id)) { + return fail(403, { error: 'You do not have permission to act on this group.' }); + } + + const isOwner = g.owner === locals.user!.id; + if (!isOwner && !g.addMembers) { + return fail(403, { error: 'No permission to add members' }); + } + + for (const id of memberIds) { + if (!locals.user!.friends.find((f) => f.id === id)) { + return fail(403, { error: 'Can only add friends' }); + } + } + + const newMembers = [...new Set([...(g.members as string[]), ...memberIds])]; + + await db.transaction(async (tx) => { + await tx.update(table.group).set({ members: newMembers }).where(eq(table.group.id, groupId)); + + for (const id of memberIds) { + const user = await tx.select().from(table.user).where(eq(table.user.id, id)).limit(1); + if (!user.length) continue; + if (newMembers.includes(id)) { + _sendToUser(id, { type: 'group', status: 'added-to-group' }); + } else { + _sendToUser(id, { type: 'group', status: 'member-added-to-group' }); + } + + await tx + .update(table.user) + .set({ groups: (user[0].groups as string[]).concat(groupId) }) + .where(eq(table.user.id, id)); + } + }); + + return { success: true }; + }, + removeMembers: async ({ request, locals }) => { + const data = await request.formData(); + const groupId = data.get('groupId'); + const memberIds = data.getAll('memberIds').map(String); + + if (typeof groupId !== 'string') { + return fail(400, { error: 'Invalid group ID' }); + } + + const group = await db.select().from(table.group).where(eq(table.group.id, groupId)).limit(1); + if (!group.length) return fail(404, { error: 'Group not found' }); + + const g = group[0]; + + if (!(g.members as string[]).includes(locals.user!.id)) { + return fail(403, { error: 'You do not have permission to act on this group.' }); + } + const isOwner = g.owner === locals.user!.id; + if (!isOwner && !g.removeMembers) { + return fail(403, { error: 'No permission to remove members' }); + } + + if (memberIds.includes(g.owner)) { + return fail(400, { error: 'Cannot remove group owner' }); + } + + const remaining = (g.members as string[]).filter((id) => !memberIds.includes(id)); + + await db.transaction(async (tx) => { + await tx.update(table.group).set({ members: remaining }).where(eq(table.group.id, groupId)); + + for (const id of remaining) { + _sendToUser(id, { type: 'group', status: 'someone-was-removed' }); + } + + for (const id of memberIds) { + _sendToUser(id, { type: 'group', status: 'removed-from-group' }); + + await tx + .update(table.user) + .set({ + groups: locals.user!.groups.map((z) => z.id).filter((g) => g !== groupId) + }) + .where(eq(table.user.id, id)); + } + }); + + return { success: true }; + }, + changeTitle: async ({ request, locals }) => { + const data = await request.formData(); + const groupId = data.get('groupId'); + const title = data.get('title'); + + if (typeof groupId !== 'string' || typeof title !== 'string' || title.length < 1) { + return fail(400, { error: 'Invalid input' }); + } + + const group = await db.select().from(table.group).where(eq(table.group.id, groupId)).limit(1); + if (!group.length) return fail(404, { error: 'Group not found' }); + + const g = group[0]; + if (!(g.members as string[]).includes(locals.user!.id)) { + return fail(403, { error: 'You do not have permission to act on this group.' }); + } + const isOwner = g.owner === locals.user!.id; + + if (!isOwner && !g.changeTitle) { + return fail(403, { error: 'No permission to change title' }); + } + + await db.update(table.group).set({ name: title }).where(eq(table.group.id, groupId)); + //@TODO if a user isn't in the group screen this doesnt get propogated + _sendToSubscribers(groupId, { type: 'group', status: 'name-changed' }); + return { success: true }; + }, createServer: async ({ request, locals }) => { const data = await request.formData(); diff --git a/src/routes/app/+page.svelte b/src/routes/app/+page.svelte index ea1b426..6506b27 100644 --- a/src/routes/app/+page.svelte +++ b/src/routes/app/+page.svelte @@ -30,9 +30,8 @@ import SendHorizontal from '@lucide/svelte/icons/send-horizontal'; import PersonStanding from '@lucide/svelte/icons/person-standing'; import MemberSidebar from '$lib/components/member-sidebar.svelte'; - import User from '$lib/components/extra/User.svelte'; - import { invalidate, invalidateAll } from '$app/navigation'; - + import { invalidateAll } from '$app/navigation'; + import { toast } from 'svelte-sonner'; let errorOpen = $state(true); let { form, data }: { form: ActionData; data: PageServerData } = $props(); @@ -196,13 +195,25 @@ | { type: 'connected'; sessionId: string } | { type: 'message'; message: ReturnMessage } | { type: 'status'; id: string; status: 1 | 2 | 3 } - | { type: 'friends'; status: string }; + | { type: 'friends'; status: string } + | { type: 'group'; status: string }; if (json.type == 'friends') { + toast('Invalidation from friends updates, recieved ' + json.status); await invalidateAll(); await fill_overview_data(); } + if (json.type == 'group') { + toast('Invalidation from group updates, recieved ' + json.status); + if (json.status == 'removed-from-group' && GroupID.is(currentPageID)) { + currentPageID = null; + currentPage = null; + } + await invalidateAll(); + + await fill_overview_data(); + } if (json.type == 'connected') { console.log('SSE connected. We are sessionID ' + json.sessionId); sessionId = json.sessionId; @@ -386,13 +397,16 @@ - + {#if currentPage && currentPageID && overview_data && members && !UserID.is(currentPageID)} + + {/if}