From c320813cf17339bbb317ca0fe93c4eebfdaf7442 Mon Sep 17 00:00:00 2001 From: Adam Lamers Date: Wed, 22 Apr 2026 01:24:46 -0400 Subject: [PATCH] server roles and management --- spacetimedb/src/lib.rs | 1 + spacetimedb/src/reducers.rs | 194 ++++++ spacetimedb/src/tables.rs | 30 + spacetimedb/src/utils.rs | 47 +- spacetimedb/src/views.rs | 33 +- src/chat/components/MemberList.svelte | 118 +++- src/chat/components/MessageItem.svelte | 29 +- .../components/ServerSettingsPanel.svelte | 7 + .../settings/MemberPermissionsSettings.svelte | 150 ++++- .../components/settings/RoleSettings.svelte | 626 ++++++++++++++++++ src/chat/services/chat.svelte.ts | 71 +- src/chat/services/database.svelte.ts | 33 + src/chat/services/messaging.svelte.ts | 3 + src/chat/services/server-management.svelte.ts | 31 + 14 files changed, 1288 insertions(+), 85 deletions(-) create mode 100644 src/chat/components/settings/RoleSettings.svelte diff --git a/spacetimedb/src/lib.rs b/spacetimedb/src/lib.rs index 61749be..b806a81 100644 --- a/spacetimedb/src/lib.rs +++ b/spacetimedb/src/lib.rs @@ -55,6 +55,7 @@ pub fn init(ctx: &ReducerContext) { avatar_id: None, channels: Vec::new(), public: true, + default_role_id: None, }); let c1 = ctx.db.channel().insert(Channel { id: 0, diff --git a/spacetimedb/src/reducers.rs b/spacetimedb/src/reducers.rs index ee012d0..175a080 100644 --- a/spacetimedb/src/reducers.rs +++ b/spacetimedb/src/reducers.rs @@ -196,10 +196,124 @@ pub fn delete_server(ctx: &ReducerContext, server_id: u64) { for m in ctx.db.server_member().server_id().filter(server_id) { ctx.db.server_member().id().delete(m.id); } + + // Clean up roles and assignments + for r in ctx.db.server_role().server_id().filter(server_id) { + ctx.db.server_role().id().delete(r.id); + } + for ma in ctx.db.member_role().server_id().filter(server_id) { + ctx.db.member_role().id().delete(ma.id); + } + for p in ctx.db.server_permission().server_id().filter(server_id) { + ctx.db.server_permission().id().delete(p.id); + } + ctx.db.server().id().delete(server_id); report_success(&ctx.db, ctx.sender(), "delete_server", ctx.timestamp); } +#[spacetimedb::reducer] +pub fn create_role(ctx: &ReducerContext, server_id: u64, name: String, color: Option, icon_id: Option, permissions: u128) { + if !has_permission(&ctx.db, ctx.sender(), server_id, PERM_MANAGE_ROLES) { + return report_error(&ctx.db, ctx.sender(), "create_role", "Insufficient permissions", ctx.timestamp); + } + + let position = ctx.db.server_role().server_id().filter(server_id).count() as u32; + ctx.db.server_role().insert(ServerRole { + id: 0, + server_id, + name, + color, + icon_id, + permissions, + position, + }); + report_success(&ctx.db, ctx.sender(), "create_role", ctx.timestamp); +} + +#[spacetimedb::reducer] +pub fn update_role(ctx: &ReducerContext, role_id: u64, name: String, color: Option, icon_id: Option, permissions: u128, position: u32) { + let mut role = match ctx.db.server_role().id().find(role_id) { + Some(r) => r, + None => return report_error(&ctx.db, ctx.sender(), "update_role", "Role not found", ctx.timestamp), + }; + + if !has_permission(&ctx.db, ctx.sender(), role.server_id, PERM_MANAGE_ROLES) { + return report_error(&ctx.db, ctx.sender(), "update_role", "Insufficient permissions", ctx.timestamp); + } + + role.name = name; + role.color = color; + role.icon_id = icon_id; + role.permissions = permissions; + role.position = position; + ctx.db.server_role().id().update(role); + report_success(&ctx.db, ctx.sender(), "update_role", ctx.timestamp); +} + +#[spacetimedb::reducer] +pub fn delete_role(ctx: &ReducerContext, role_id: u64) { + let role = match ctx.db.server_role().id().find(role_id) { + Some(r) => r, + None => return report_error(&ctx.db, ctx.sender(), "delete_role", "Role not found", ctx.timestamp), + }; + + if !has_permission(&ctx.db, ctx.sender(), role.server_id, PERM_MANAGE_ROLES) { + return report_error(&ctx.db, ctx.sender(), "delete_role", "Insufficient permissions", ctx.timestamp); + } + + // Remove all assignments for this role + let assignments: Vec<_> = ctx.db.member_role().role_id().filter(role_id).collect(); + for a in assignments { + ctx.db.member_role().id().delete(a.id); + } + + ctx.db.server_role().id().delete(role_id); + report_success(&ctx.db, ctx.sender(), "delete_role", ctx.timestamp); +} + +#[spacetimedb::reducer] +pub fn assign_role(ctx: &ReducerContext, server_id: u64, identity: Identity, role_id: u64) { + if !has_permission(&ctx.db, ctx.sender(), server_id, PERM_MANAGE_ROLES) { + return report_error(&ctx.db, ctx.sender(), "assign_role", "Insufficient permissions", ctx.timestamp); + } + + // Verify role exists and belongs to server + let role = match ctx.db.server_role().id().find(role_id) { + Some(r) => r, + None => return report_error(&ctx.db, ctx.sender(), "assign_role", "Role not found", ctx.timestamp), + }; + if role.server_id != server_id { + return report_error(&ctx.db, ctx.sender(), "assign_role", "Role does not belong to this server", ctx.timestamp); + } + + // Check for duplicate assignment + if ctx.db.member_role().identity().filter(identity).any(|mr| mr.role_id == role_id) { + return; + } + + ctx.db.member_role().insert(MemberRole { + id: 0, + server_id, + identity, + role_id, + }); + report_success(&ctx.db, ctx.sender(), "assign_role", ctx.timestamp); +} + +#[spacetimedb::reducer] +pub fn revoke_role(ctx: &ReducerContext, server_id: u64, identity: Identity, role_id: u64) { + if !has_permission(&ctx.db, ctx.sender(), server_id, PERM_MANAGE_ROLES) { + return report_error(&ctx.db, ctx.sender(), "revoke_role", "Insufficient permissions", ctx.timestamp); + } + + let assignments: Vec<_> = ctx.db.member_role().identity().filter(identity).filter(|mr| mr.role_id == role_id).collect(); + for a in assignments { + ctx.db.member_role().id().delete(a.id); + } + report_success(&ctx.db, ctx.sender(), "revoke_role", ctx.timestamp); +} + #[spacetimedb::reducer] pub fn edit_message(ctx: &ReducerContext, message_id: u64, new_text: String) { if let Err(e) = validate_message_length(&ctx.db, &new_text) { @@ -410,6 +524,7 @@ pub fn create_server(ctx: &ReducerContext, name: String) { avatar_id: None, channels: Vec::new(), public: false, + default_role_id: None, }); ctx.db.server_member().insert(ServerMember { id: 0, @@ -453,6 +568,50 @@ pub fn create_server(ctx: &ReducerContext, name: String) { permissions: u128::MAX, }); + // Create Default Roles + let admin_role = ctx.db.server_role().insert(ServerRole { + id: 0, + server_id: s.id, + name: "Admin".to_string(), + color: Some("#ed4245".to_string()), // Red + icon_id: None, + permissions: u128::MAX, + position: 3, + }); + + ctx.db.server_role().insert(ServerRole { + id: 0, + server_id: s.id, + name: "Moderator".to_string(), + color: Some("#3498db".to_string()), // Blue + icon_id: None, + permissions: PERM_KICK_MEMBERS | PERM_BAN_MEMBERS | PERM_MODERATE_MESSAGES | PERM_USE_VOICE | PERM_SHARE_SCREEN | PERM_USE_THREADS, + position: 2, + }); + + let member_role = ctx.db.server_role().insert(ServerRole { + id: 0, + server_id: s.id, + name: "Member".to_string(), + color: Some("#99aab5".to_string()), // Gray + icon_id: None, + permissions: PERM_USE_VOICE | PERM_SHARE_SCREEN | PERM_USE_THREADS, + position: 1, + }); + + // Assign Admin role to creator + ctx.db.member_role().insert(MemberRole { + id: 0, + server_id: s.id, + identity: ctx.sender(), + role_id: admin_role.id, + }); + + // Set default role for server + let mut s = ctx.db.server().id().find(s.id).unwrap(); + s.default_role_id = Some(member_role.id); + ctx.db.server().id().update(s.clone()); + sync_server_access(&ctx.db, ctx.sender(), s.id); report_success(&ctx.db, ctx.sender(), "create_server", ctx.timestamp); } @@ -653,6 +812,17 @@ pub fn join_server(ctx: &ReducerContext, server_id: Option, invite_code: Op avatar_id: user.avatar_id, online: user.online, }); + + // Auto-assign default role if it exists + if let Some(default_role_id) = s.default_role_id { + ctx.db.member_role().insert(MemberRole { + id: 0, + server_id: target_server_id, + identity: sender, + role_id: default_role_id, + }); + } + sync_server_access(&ctx.db, sender, target_server_id); // Record success @@ -665,6 +835,30 @@ pub fn join_server(ctx: &ReducerContext, server_id: Option, invite_code: Op ); } +#[spacetimedb::reducer] +pub fn set_default_role(ctx: &ReducerContext, server_id: u64, role_id: Option) { + if !has_permission(&ctx.db, ctx.sender(), server_id, PERM_MANAGE_ROLES) { + return report_error(&ctx.db, ctx.sender(), "set_default_role", "Insufficient permissions", ctx.timestamp); + } + + if let Some(mut s) = ctx.db.server().id().find(server_id) { + // Verify role exists and belongs to server if it's not None + if let Some(rid) = role_id { + let role = match ctx.db.server_role().id().find(rid) { + Some(r) => r, + None => return report_error(&ctx.db, ctx.sender(), "set_default_role", "Role not found", ctx.timestamp), + }; + if role.server_id != server_id { + return report_error(&ctx.db, ctx.sender(), "set_default_role", "Role does not belong to this server", ctx.timestamp); + } + } + + s.default_role_id = role_id; + ctx.db.server().id().update(s); + report_success(&ctx.db, ctx.sender(), "set_default_role", ctx.timestamp); + } +} + #[spacetimedb::reducer] pub fn leave_server(ctx: &ReducerContext, server_id: u64) { let sender = ctx.sender(); diff --git a/spacetimedb/src/tables.rs b/spacetimedb/src/tables.rs index 3e2ef47..0b7fc8f 100644 --- a/spacetimedb/src/tables.rs +++ b/spacetimedb/src/tables.rs @@ -43,6 +43,7 @@ pub struct Server { pub avatar_id: Option, pub channels: Vec, pub public: bool, + pub default_role_id: Option, } #[spacetimedb::table(accessor = server_member)] @@ -260,6 +261,35 @@ pub struct ServerPermission { pub permissions: u128, } +#[spacetimedb::table(accessor = server_role)] +#[derive(Clone)] +pub struct ServerRole { + #[primary_key] + #[auto_inc] + pub id: u64, + #[index(btree)] + pub server_id: u64, + pub name: String, + pub color: Option, + pub icon_id: Option, + pub permissions: u128, + pub position: u32, +} + +#[spacetimedb::table(accessor = member_role)] +#[derive(Clone)] +pub struct MemberRole { + #[primary_key] + #[auto_inc] + pub id: u64, + #[index(btree)] + pub server_id: u64, + #[index(btree)] + pub identity: Identity, + #[index(btree)] + pub role_id: u64, +} + #[spacetimedb::table(accessor = custom_emoji, public)] #[derive(Clone)] pub struct CustomEmoji { diff --git a/spacetimedb/src/utils.rs b/spacetimedb/src/utils.rs index b2af2ff..59ed4a3 100644 --- a/spacetimedb/src/utils.rs +++ b/spacetimedb/src/utils.rs @@ -97,7 +97,7 @@ pub fn get_visible_image_ids(db: &Local, identity: Identity) -> HashSet { } } - // Server avatars for servers I am a member of or are public + // Server avatars and role icons for servers I am a member of or are public let my_server_ids: HashSet = db .server_member() .identity() @@ -109,22 +109,22 @@ pub fn get_visible_image_ids(db: &Local, identity: Identity) -> HashSet { if let Some(id) = s.avatar_id { results.insert(id); } + // Role Icons for these servers + for role in db.server_role().server_id().filter(s.id) { + if let Some(id) = role.icon_id { + results.insert(id); + } + } } } - // Avatars for members of servers I am in (and redundant check for server avatars stored in membership) + // Avatars for members of servers I am in for server_id in my_server_ids { for member in db.server_member().server_id().filter(server_id) { if let Some(id) = member.avatar_id { results.insert(id); } } - // Also check if any server I'm in has an avatar id that might not have been caught in the name filter - if let Some(s) = db.server().id().find(server_id) { - if let Some(id) = s.avatar_id { - results.insert(id); - } - } } // Avatars for DM participants @@ -168,7 +168,7 @@ pub fn get_visible_image_ids_read_only(db: &LocalReadOnly, identity: Identity) - } } - // Server avatars for servers I am a member of or are public + // Server avatars and role icons for servers I am a member of or are public let my_server_ids: HashSet = db .server_member() .identity() @@ -180,22 +180,22 @@ pub fn get_visible_image_ids_read_only(db: &LocalReadOnly, identity: Identity) - if let Some(id) = s.avatar_id { results.insert(id); } + // Role Icons for these servers + for role in db.server_role().server_id().filter(s.id) { + if let Some(id) = role.icon_id { + results.insert(id); + } + } } } - // Avatars for members of servers I am in (and redundant check for server avatars stored in membership) + // Avatars for members of servers I am in for server_id in my_server_ids { for member in db.server_member().server_id().filter(server_id) { if let Some(id) = member.avatar_id { results.insert(id); } } - // Also check if any server I'm in has an avatar id that might not have been caught in the name filter - if let Some(s) = db.server().id().find(server_id) { - if let Some(id) = s.avatar_id { - results.insert(id); - } - } } // Avatars for DM participants @@ -490,9 +490,20 @@ pub const PERM_MANAGE_EMOJIS: u128 = 0x400; pub const PERM_DELETE_SERVER: u128 = 0x800; pub fn has_permission(db: &Local, identity: Identity, server_id: u64, bit: u128) -> bool { + let mut total_perms = 0u128; + + // 1. User overrides if let Some(perm) = db.server_permission().server_id().filter(server_id) .find(|p| p.identity == identity) { - return (perm.permissions & bit) != 0; + total_perms |= perm.permissions; } - false + + // 2. Role permissions + for mr in db.member_role().identity().filter(identity).filter(|mr| mr.server_id == server_id) { + if let Some(role) = db.server_role().id().find(mr.role_id) { + total_perms |= role.permissions; + } + } + + (total_perms & bit) != 0 } diff --git a/spacetimedb/src/views.rs b/spacetimedb/src/views.rs index 9eb3790..3b088e4 100644 --- a/spacetimedb/src/views.rs +++ b/spacetimedb/src/views.rs @@ -254,12 +254,39 @@ pub fn visible_server_permissions(ctx: &ViewContext) -> Vec { results.push(perm.clone()); } } - + results } -#[spacetimedb::view(accessor = visible_images, public)] -pub fn visible_images(ctx: &ViewContext) -> Vec { +#[spacetimedb::view(accessor = visible_server_roles, public)] +pub fn visible_server_roles(ctx: &ViewContext) -> Vec { + let identity = ctx.sender(); + let mut results = Vec::new(); + + for member in ctx.db.server_member().identity().filter(identity) { + for role in ctx.db.server_role().server_id().filter(member.server_id) { + results.push(role.clone()); + } + } + + results +} + +#[spacetimedb::view(accessor = visible_member_roles, public)] +pub fn visible_member_roles(ctx: &ViewContext) -> Vec { + let identity = ctx.sender(); + let mut results = Vec::new(); + + for member in ctx.db.server_member().identity().filter(identity) { + for mr in ctx.db.member_role().server_id().filter(member.server_id) { + results.push(mr.clone()); + } + } + + results +} + +#[spacetimedb::view(accessor = visible_images, public)]pub fn visible_images(ctx: &ViewContext) -> Vec { let image_ids = get_visible_image_ids_read_only(&ctx.db, ctx.sender()); let mut results = Vec::new(); for id in image_ids { diff --git a/src/chat/components/MemberList.svelte b/src/chat/components/MemberList.svelte index 9f0a136..d4a967b 100644 --- a/src/chat/components/MemberList.svelte +++ b/src/chat/components/MemberList.svelte @@ -7,8 +7,41 @@ const chat = getContext("chat"); - let onlineMembers = $derived(chat.activeServerMembers.filter((m) => m.online)); - let offlineMembers = $derived(chat.activeServerMembers.filter((m) => !m.online)); + const members = $derived(chat.activeServerMembers); + const onlineMembers = $derived(members.filter((m) => m.online || chat.identity?.isEqual(m.identity))); + const offlineMembers = $derived(members.filter((m) => !m.online && !chat.identity?.isEqual(m.identity))); + + const roles = $derived((chat.serverRoles || []) + .filter(r => r.serverId === chat.activeServerId) + .sort((a, b) => b.position - a.position)); + + const groupedOnlineMembers = $derived.by(() => { + const groups: { role: Types.ServerRole | null, members: Types.ServerMember[] }[] = []; + + // Create buckets for each role + for (const role of roles) { + groups.push({ role, members: [] }); + } + // Final bucket for those with no specific role + const noRoleGroup: { role: Types.ServerRole | null, members: Types.ServerMember[] } = { role: null, members: [] }; + + for (const member of onlineMembers) { + const topRole = chat.getMemberTopRole(member.identity, chat.activeServerId || 0n); + if (topRole) { + const group = groups.find(g => g.role?.id === topRole.id); + if (group) { + group.members.push(member); + } else { + noRoleGroup.members.push(member); + } + } else { + noRoleGroup.members.push(member); + } + } + + groups.push(noRoleGroup); + return groups.filter(g => g.members.length > 0); + }); function getVoiceState(member: Types.ServerMember) { return chat.userStates.find((s) => s.identity.isEqual(member.identity)); @@ -59,37 +92,42 @@ {/each} {:else} - {#if onlineMembers.length > 0} -
- ONLINE — {onlineMembers.length} -
- {#each onlineMembers as member (member.identity.toHexString())} - {@const status = getStatus(member)} - {@const voiceState = getVoiceState(member)} - -
handleContextMenu(e, member)}> -
- -
-
-
- - {member.name || member.identity.toHexString().substring(0, 8)} - {#if chat.identity?.isEqual(member.identity)} - (You) - {/if} - - {#if status} -
{status}
- {/if} -
-
-
- {#if voiceState?.isSharingScreen} - - {/if} -
+ {#if groupedOnlineMembers.length > 0} + {#each groupedOnlineMembers as group} +
+ {group.role?.name || "ONLINE"} — {group.members.length}
+ {#each group.members as member (member.identity.toHexString())} + {@const status = getStatus(member)} + {@const voiceState = getVoiceState(member)} + {@const topRole = chat.getMemberTopRole(member.identity, chat.activeServerId || 0n)} + +
handleContextMenu(e, member)}> +
+ +
+
+
+ + {member.name || member.identity.toHexString().substring(0, 8)} + {#if topRole?.iconId} + + {/if} + {#if chat.identity?.isEqual(member.identity)} + (You) + {/if} + {#if status} +
{status}
+ {/if} +
+
+
+ {#if voiceState?.isSharingScreen} + + {/if} +
+
+ {/each} {/each} {/if} @@ -99,6 +137,7 @@
{#each offlineMembers as member (member.identity.toHexString())} {@const status = getStatus(member)} + {@const topRole = chat.getMemberTopRole(member.identity, chat.activeServerId || 0n)}
handleContextMenu(e, member)}>
@@ -106,8 +145,11 @@
- + {member.name || member.identity.toHexString().substring(0, 8)} + {#if topRole?.iconId} + + {/if} {#if chat.identity?.isEqual(member.identity)} (You) {/if} @@ -203,6 +245,16 @@ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + display: flex; + align-items: center; + gap: 4px; + } + + .role-icon { + width: 14px; + height: 14px; + object-fit: contain; + border-radius: 2px; } .member-name.talking { diff --git a/src/chat/components/MessageItem.svelte b/src/chat/components/MessageItem.svelte index 0cddff1..2f7d231 100644 --- a/src/chat/components/MessageItem.svelte +++ b/src/chat/components/MessageItem.svelte @@ -169,6 +169,11 @@ const canModerate = $derived(chat.can(Permissions.MODERATE_MESSAGES)); const canUseThreads = $derived(chat.can(Permissions.USE_THREADS)); + const memberTopRole = $derived.by(() => { + if (!chat.activeServerId) return undefined; + return chat.getMemberTopRole(msg.sender, chat.activeServerId); + }); + // Reactions and images are now nested in the message object const emojiGroups = $derived.by(() => { const groups: Record = {}; @@ -417,7 +422,19 @@
{#if !isGrouped}
- (chat.viewingProfileUser = chat.users.find(u => u.identity.isEqual(msg.sender)) || null)} style="cursor: pointer;">{msgUsername} + (chat.viewingProfileUser = chat.users.find(u => u.identity.isEqual(msg.sender)) || null)} + style="cursor: pointer; color: {memberTopRole?.color || 'inherit'};" + role="button" + tabindex="0" + onkeydown={(e) => e.key === 'Enter' && (chat.viewingProfileUser = chat.users.find(u => u.identity.isEqual(msg.sender)) || null)} + > + {msgUsername} + {#if memberTopRole?.iconId} + + {/if} + {#if !isThread} {chat.formatTime(msg.sent)} {/if} @@ -718,6 +735,16 @@ .user-name { font-weight: 600; color: var(--header-primary); + display: flex; + align-items: center; + gap: 4px; + } + + .role-icon { + width: 14px; + height: 14px; + object-fit: contain; + border-radius: 2px; } .message-time { diff --git a/src/chat/components/ServerSettingsPanel.svelte b/src/chat/components/ServerSettingsPanel.svelte index 8724410..69d54d8 100644 --- a/src/chat/components/ServerSettingsPanel.svelte +++ b/src/chat/components/ServerSettingsPanel.svelte @@ -7,6 +7,7 @@ import Input from "./ui/Input.svelte"; import Switch from "./ui/Switch.svelte"; import MemberPermissionsSettings from "./settings/MemberPermissionsSettings.svelte"; + import RoleSettings from "./settings/RoleSettings.svelte"; let { onClose } = $props<{ onClose: () => void }>(); @@ -94,6 +95,7 @@ const categories = $derived.by(() => { const list = [{ id: "overview", name: "Overview", icon: "fas fa-info-circle" }]; if (canManageRoles || canManageServer) { + list.push({ id: "roles", name: "Roles", icon: "fas fa-shield-alt" }); list.push({ id: "members", name: "Members", icon: "fas fa-users" }); } return list; @@ -240,6 +242,10 @@ {#if activeCategory === "members"} {/if} + + {#if activeCategory === "roles"} + + {/if}
@@ -574,3 +580,4 @@ to { transform: scale(1); opacity: 1; } } + diff --git a/src/chat/components/settings/MemberPermissionsSettings.svelte b/src/chat/components/settings/MemberPermissionsSettings.svelte index 27186d2..e51b947 100644 --- a/src/chat/components/settings/MemberPermissionsSettings.svelte +++ b/src/chat/components/settings/MemberPermissionsSettings.svelte @@ -4,14 +4,16 @@ import { Permissions } from "../../services/chat.svelte"; import Avatar from "../Avatar.svelte"; import Input from "../ui/Input.svelte"; + import type { Identity } from "spacetimedb"; const chat = getContext("chat"); const server = $derived(chat.activeServer); - const members = $derived(chat.serverMembers.filter(m => m.serverId === server?.id)); - const permissions = $derived(chat.allServerPermissions.filter(p => p.serverId === server?.id)); + const members = $derived((chat.serverMembers || []).filter(m => m.serverId === server?.id)); + const permissions = $derived((chat.allServerPermissions || []).filter(p => p.serverId === server?.id)); + const serverRoles = $derived((chat.serverRoles || []).filter(r => r.serverId === server?.id).sort((a, b) => b.position - a.position)); - const canManageRoles = $derived(chat.can(Permissions.MANAGE_ROLES)); + const canManageRoles = $derived(server ? chat.can(Permissions.MANAGE_ROLES) : false); let searchTerm = $state(""); let selectedMemberId = $state(null); @@ -40,12 +42,12 @@ { bit: Permissions.DELETE_SERVER, name: "Delete Server", desc: "Permanently destroy the server (Nuclear)." }, ]; - function getMemberPermissions(identity: any): bigint { + function getMemberPermissions(identity: Identity): bigint { const p = permissions.find(p => p.identity.isEqual(identity)); return p?.permissions || 0n; } - function togglePermission(identity: any, bit: bigint) { + function togglePermission(identity: Identity, bit: bigint) { if (!canManageRoles || !server) return; const current = getMemberPermissions(identity); @@ -72,7 +74,10 @@
{/if} -
- {#each permissionList as p} - {@const hasBit = (memberPerms & p.bit) !== 0n} - {@const isRoleLock = isMe && p.bit === Permissions.MANAGE_ROLES} +
+
Roles
+
+ {#each serverRoles as role} + {@const hasRole = server ? chat.getMemberRoles(selectedMember.identity, server.id).some(r => r.id === role.id) : false} + + {/each} +
+
-
-
- {p.name} - {p.desc} +
+
Permission Overrides
+
+ {#each permissionList as p} + {@const hasBit = (memberPerms & p.bit) !== 0n} + {@const isRoleLock = isMe && p.bit === Permissions.MANAGE_ROLES} + +
+
+ {p.name} + {p.desc} +
+
- -
- {/each} + {/each} +
{:else}
@@ -245,6 +283,64 @@ font-weight: 800; } + .editor-section { + display: flex; + flex-direction: column; + gap: 16px; + margin-bottom: 32px; + } + + .section-title { + font-size: 0.8rem; + font-weight: bold; + color: var(--header-secondary); + text-transform: uppercase; + border-bottom: 1px solid var(--background-modifier-accent); + padding-bottom: 8px; + } + + .roles-assignment-grid { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .role-pill { + display: flex; + align-items: center; + gap: 8px; + background-color: var(--background-tertiary); + border: 1px solid var(--background-modifier-accent); + padding: 6px 12px; + border-radius: 16px; + color: var(--text-normal); + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + transition: all 0.1s; + } + + .role-pill:hover:not(:disabled) { + background-color: var(--background-modifier-hover); + border-color: var(--brand); + } + + .role-pill.active { + background-color: rgba(88, 101, 242, 0.1); + border-color: var(--brand); + } + + .role-pill i { + font-size: 0.9rem; + opacity: 0.7; + } + + .role-dot { + width: 10px; + height: 10px; + border-radius: 50%; + } + .permissions-matrix { display: flex; flex-direction: column; @@ -297,7 +393,7 @@ background-color: var(--status-positive); } - .toggle-switch.disabled { + .toggle-switch:disabled { opacity: 0.3; cursor: not-allowed; } diff --git a/src/chat/components/settings/RoleSettings.svelte b/src/chat/components/settings/RoleSettings.svelte new file mode 100644 index 0000000..c405c38 --- /dev/null +++ b/src/chat/components/settings/RoleSettings.svelte @@ -0,0 +1,626 @@ + + +
+
+ + +
+ {#each roles as role (role.id.toString())} + + {/each} + + {#if canManageRoles && roles.length > 0} +
{ e.preventDefault(); dragOverRoleId = -1n; }} + onondragleave={() => dragOverRoleId = null} + ondrop={(e) => { + if (draggedRoleId !== null) { + // Logic to move to the very bottom + const roleList = [...roles]; + const draggedIdx = roleList.findIndex(r => r.id === draggedRoleId); + if (draggedIdx !== -1) { + const [draggedRole] = roleList.splice(draggedIdx, 1); + roleList.push(draggedRole); // Add to end + + for (let i = 0; i < roleList.length; i++) { + const role = roleList[i]; + const newPos = (roleList.length - 1) - i; + if (role.position !== newPos) { + chat.handleUpdateRole(role.id, role.name, role.color, role.iconId, role.permissions, newPos); + } + } + } + } + draggedRoleId = null; + dragOverRoleId = null; + }} + >
+ {/if} + + {#if roles.length === 0}
+

No roles defined.

+ {#if canManageRoles} + + {/if} +
+ {/if} +
+ + {#if roles.length > 0} +
+
Default Role
+

New members receive this role when they join.

+ +
+ {/if} +
+ +
+ {#if selectedRole} +
+
+ + +
+ +
+ + +
+
+ +
+ +
+ {#if editIconId} + + + {:else} + + {/if} +
+
+
+ +
+
Permissions
+
+ {#each permissionList as p} + {@const hasBit = (editPermissions & p.bit) !== 0n} +
+
+ {p.name} + {p.desc} +
+ +
+ {/each} +
+
+ + {#if canManageRoles} + + {/if} +
+ {:else} +
+ +

Select a role from the sidebar to edit its permissions and style.

+
+ {/if} +
+
+ + diff --git a/src/chat/services/chat.svelte.ts b/src/chat/services/chat.svelte.ts index 5025ff1..ca59aed 100644 --- a/src/chat/services/chat.svelte.ts +++ b/src/chat/services/chat.svelte.ts @@ -222,15 +222,50 @@ export class ChatService { const serverId = this.activeServerId; if (!myId || !serverId) return 0n; - return this.#db.serverPermissions.find(p => + let totalPerms = 0n; + + // 1. User overrides + const override = this.#db.serverPermissions.find(p => p.serverId === serverId && p.identity.isEqual(myId) - )?.permissions || 0n; + ); + if (override) totalPerms |= override.permissions; + + // 2. Role permissions + const myRoles = this.getMemberRoles(myId, serverId); + for (const role of myRoles) { + totalPerms |= role.permissions; + } + + return totalPerms; + } + + getMemberRoles(identity: Identity, serverId: bigint): Types.ServerRole[] { + const serverMap = this.#db.memberRolesByServerId.get(serverId); + if (!serverMap) return []; + + const roleIds = serverMap.get(identity.toHexString()) || []; + return roleIds + .map(id => this.#db.rolesById.get(id)) + .filter(r => r !== undefined) as Types.ServerRole[]; + } + + getMemberTopRole(identity: Identity, serverId: bigint): Types.ServerRole | undefined { + const roles = this.getMemberRoles(identity, serverId); + if (roles.length === 0) return undefined; + + // Position 0 is lowest. + return roles.sort((a, b) => b.position - a.position)[0]; } can(bit: PermissionBit | bigint | undefined | null): boolean { if (bit === undefined || bit === null) return false; const b = typeof bit === "bigint" ? bit : BigInt(bit); - return (this.myPermissions & b) !== 0n; + const perms = this.myPermissions; + const has = (perms & b) !== 0n; + if (b === Permissions.MANAGE_ROLES) { + console.log(`[ChatService] Permission Check: MANAGE_ROLES? ${has} (Total Perms: ${perms.toString(16)})`); + } + return has; } // Facade Getters/Setters for UI @@ -377,6 +412,12 @@ export class ChatService { get serverMembers() { return this.#db.serverMembers; } + get serverRoles() { + return this.#db.serverRoles; + } + get memberRoles() { + return this.#db.memberRoles; + } get allMessages() { return this.#msg.allMessages; } @@ -826,6 +867,30 @@ export class ChatService { this.#server.handleSetMemberPermissions(serverId, identity, permissions); }; + handleCreateRole = (serverId: bigint, name: string, color: string | null, iconId: bigint | null, permissions: bigint) => { + this.#server.handleCreateRole(serverId, name, color, iconId, permissions); + }; + + handleUpdateRole = (roleId: bigint, name: string, color: string | null, iconId: bigint | null, permissions: bigint, position: number) => { + this.#server.handleUpdateRole(roleId, name, color, iconId, permissions, position); + }; + + handleDeleteRole = (roleId: bigint) => { + this.#server.handleDeleteRole(roleId); + }; + + handleAssignRole = (serverId: bigint, identity: Identity, roleId: bigint) => { + this.#server.handleAssignRole(serverId, identity, roleId); + }; + + handleRevokeRole = (serverId: bigint, identity: Identity, roleId: bigint) => { + this.#server.handleRevokeRole(serverId, identity, roleId); + }; + + handleSetDefaultRole = (serverId: bigint, roleId: bigint | null) => { + this.#server.handleSetDefaultRole(serverId, roleId); + }; + handleOpenDirectMessage = (recipient: Identity) => { this.#dm.handleOpenDirectMessage(recipient); this.activeServerId = null; diff --git a/src/chat/services/database.svelte.ts b/src/chat/services/database.svelte.ts index cf98282..34158a3 100644 --- a/src/chat/services/database.svelte.ts +++ b/src/chat/services/database.svelte.ts @@ -17,6 +17,8 @@ export class DatabaseService { users = $state([]); serverMembers = $state([]); serverPermissions = $state([]); + serverRoles = $state([]); + memberRoles = $state([]); threadMessages = $state([]); recentMessages = $state([]); @@ -75,6 +77,33 @@ export class DatabaseService { return map; }); + rolesById = $derived.by(() => { + const map = new Map(); + for (const role of this.serverRoles) { + map.set(role.id, role); + } + return map; + }); + + memberRolesByServerId = $derived.by(() => { + const map = new Map>(); + for (const mr of this.memberRoles) { + let serverMap = map.get(mr.serverId); + if (!serverMap) { + serverMap = new Map(); + map.set(mr.serverId, serverMap); + } + const identityHex = mr.identity.toHexString(); + let roles = serverMap.get(identityHex); + if (!roles) { + roles = []; + serverMap.set(identityHex, roles); + } + roles.push(mr.roleId); + } + return map; + }); + channelsById = $derived.by(() => { const map = new Map(); for (const channel of this.channels) { @@ -116,6 +145,8 @@ export class DatabaseService { const [userStatesStore] = useTable(tables.visible_user_states); const [serverMembersStore, membersReadyStore] = useTable(tables.visible_server_members); const [serverPermissionsStore] = useTable(tables.visible_server_permissions); + const [serverRolesStore] = useTable(tables.visible_server_roles); + const [memberRolesStore] = useTable(tables.visible_member_roles); const [recentMessagesStore] = useTable(tables.visible_recent_activity); const [threadMessagesStore] = useTable(tables.visible_thread_messages); const [imagesStore, imagesReadyStore] = useTable(tables.visible_images); @@ -136,6 +167,8 @@ export class DatabaseService { userStatesStore.subscribe((v) => (this.userStates = v)); serverMembersStore.subscribe((v) => (this.serverMembers = v)); serverPermissionsStore.subscribe((v) => (this.serverPermissions = v)); + serverRolesStore.subscribe((v) => (this.serverRoles = v)); + memberRolesStore.subscribe((v) => (this.memberRoles = v)); membersReadyStore.subscribe((v) => (this.isMembersReady = v)); recentMessagesStore.subscribe((v) => (this.recentMessages = v)); threadMessagesStore.subscribe((v) => (this.threadMessages = v)); diff --git a/src/chat/services/messaging.svelte.ts b/src/chat/services/messaging.svelte.ts index 6e8857c..aae1852 100644 --- a/src/chat/services/messaging.svelte.ts +++ b/src/chat/services/messaging.svelte.ts @@ -203,6 +203,9 @@ export class MessagingService { queries.push(`SELECT * FROM visible_servers`); queries.push(`SELECT * FROM user WHERE identity = 0x${idHex}`); queries.push(`SELECT * FROM visible_server_members`); + queries.push(`SELECT * FROM visible_server_roles`); + queries.push(`SELECT * FROM visible_member_roles`); + queries.push(`SELECT * FROM visible_server_permissions`); queries.push(`SELECT * FROM visible_channels`); queries.push(`SELECT * FROM visible_recent_activity`); diff --git a/src/chat/services/server-management.svelte.ts b/src/chat/services/server-management.svelte.ts index 42a446b..41452a3 100644 --- a/src/chat/services/server-management.svelte.ts +++ b/src/chat/services/server-management.svelte.ts @@ -1,5 +1,6 @@ import { useReducer } from "spacetimedb/svelte"; import { reducers } from "../../module_bindings"; +import type { Identity } from "spacetimedb"; export class ServerManagementService { #createServerReducer = useReducer(reducers.createServer); @@ -12,6 +13,12 @@ export class ServerManagementService { #deleteServerReducer = useReducer(reducers.deleteServer); #createInviteReducer = useReducer(reducers.createInvite); #setMemberPermissionsReducer = useReducer(reducers.setMemberPermissions); + #createRoleReducer = useReducer(reducers.createRole); + #updateRoleReducer = useReducer(reducers.updateRole); + #deleteRoleReducer = useReducer(reducers.deleteRole); + #assignRoleReducer = useReducer(reducers.assignRole); + #revokeRoleReducer = useReducer(reducers.revokeRole); + #setDefaultRoleReducer = useReducer(reducers.setDefaultRole); handleCreateServer = (name: string) => { if (name.trim()) { @@ -62,4 +69,28 @@ export class ServerManagementService { handleSetMemberPermissions = (serverId: bigint, identity: any, permissions: bigint) => { this.#setMemberPermissionsReducer({ serverId, identity, permissions }); }; + + handleCreateRole = (serverId: bigint, name: string, color: string | null, iconId: bigint | null, permissions: bigint) => { + this.#createRoleReducer({ serverId, name, color: color ?? undefined, iconId: iconId ?? undefined, permissions }); + }; + + handleUpdateRole = (roleId: bigint, name: string, color: string | null, iconId: bigint | null, permissions: bigint, position: number) => { + this.#updateRoleReducer({ roleId, name, color: color ?? undefined, iconId: iconId ?? undefined, permissions, position }); + }; + + handleDeleteRole = (roleId: bigint) => { + this.#deleteRoleReducer({ roleId }); + }; + + handleAssignRole = (serverId: bigint, identity: Identity, roleId: bigint) => { + this.#assignRoleReducer({ serverId, identity, roleId }); + }; + + handleRevokeRole = (serverId: bigint, identity: Identity, roleId: bigint) => { + this.#revokeRoleReducer({ serverId, identity, roleId }); + }; + + handleSetDefaultRole = (serverId: bigint, roleId: bigint | null) => { + this.#setDefaultRoleReducer({ serverId, roleId: roleId ?? undefined }); + }; }