server roles and management
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<String>, icon_id: Option<u64>, 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<String>, icon_id: Option<u64>, 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<u64>, 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<u64>, invite_code: Op
|
||||
);
|
||||
}
|
||||
|
||||
#[spacetimedb::reducer]
|
||||
pub fn set_default_role(ctx: &ReducerContext, server_id: u64, role_id: Option<u64>) {
|
||||
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();
|
||||
|
||||
@@ -43,6 +43,7 @@ pub struct Server {
|
||||
pub avatar_id: Option<u64>,
|
||||
pub channels: Vec<ChannelMetadata>,
|
||||
pub public: bool,
|
||||
pub default_role_id: Option<u64>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub icon_id: Option<u64>,
|
||||
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 {
|
||||
|
||||
+29
-18
@@ -97,7 +97,7 @@ pub fn get_visible_image_ids(db: &Local, identity: Identity) -> HashSet<u64> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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<u64> = db
|
||||
.server_member()
|
||||
.identity()
|
||||
@@ -109,22 +109,22 @@ pub fn get_visible_image_ids(db: &Local, identity: Identity) -> HashSet<u64> {
|
||||
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<u64> = 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
|
||||
}
|
||||
|
||||
@@ -254,12 +254,39 @@ pub fn visible_server_permissions(ctx: &ViewContext) -> Vec<ServerPermission> {
|
||||
results.push(perm.clone());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
#[spacetimedb::view(accessor = visible_images, public)]
|
||||
pub fn visible_images(ctx: &ViewContext) -> Vec<VisibleImageRow> {
|
||||
#[spacetimedb::view(accessor = visible_server_roles, public)]
|
||||
pub fn visible_server_roles(ctx: &ViewContext) -> Vec<ServerRole> {
|
||||
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<MemberRole> {
|
||||
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<VisibleImageRow> {
|
||||
let image_ids = get_visible_image_ids_read_only(&ctx.db, ctx.sender());
|
||||
let mut results = Vec::new();
|
||||
for id in image_ids {
|
||||
|
||||
@@ -7,8 +7,41 @@
|
||||
|
||||
const chat = getContext<ChatService>("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 @@
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
{#if onlineMembers.length > 0}
|
||||
<div class="member-list-section-header">
|
||||
ONLINE — {onlineMembers.length}
|
||||
</div>
|
||||
{#each onlineMembers as member (member.identity.toHexString())}
|
||||
{@const status = getStatus(member)}
|
||||
{@const voiceState = getVoiceState(member)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="member-item" oncontextmenu={(e) => handleContextMenu(e, member)}>
|
||||
<div class="avatar-container">
|
||||
<Avatar user={member} size="small" isTalking={voiceState?.isTalking} />
|
||||
<div class="status-dot green overlay"></div>
|
||||
</div>
|
||||
<div class="member-details">
|
||||
<span class="member-name {voiceState?.isTalking ? 'talking' : ''}">
|
||||
{member.name || member.identity.toHexString().substring(0, 8)}
|
||||
{#if chat.identity?.isEqual(member.identity)}
|
||||
<span class="me-badge">(You)</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if status}
|
||||
<div class="member-status" title={status}>{status}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div style="flex: 1;"></div>
|
||||
<div class="member-indicators">
|
||||
{#if voiceState?.isSharingScreen}
|
||||
<span class="sharing-badge">LIVE</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if groupedOnlineMembers.length > 0}
|
||||
{#each groupedOnlineMembers as group}
|
||||
<div class="member-list-section-header">
|
||||
{group.role?.name || "ONLINE"} — {group.members.length}
|
||||
</div>
|
||||
{#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)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="member-item" oncontextmenu={(e) => handleContextMenu(e, member)}>
|
||||
<div class="avatar-container">
|
||||
<Avatar user={member} size="small" isTalking={voiceState?.isTalking} />
|
||||
<div class="status-dot green overlay"></div>
|
||||
</div>
|
||||
<div class="member-details">
|
||||
<span class="member-name {voiceState?.isTalking ? 'talking' : ''}" style="color: {topRole?.color || 'inherit'};">
|
||||
{member.name || member.identity.toHexString().substring(0, 8)}
|
||||
{#if topRole?.iconId}
|
||||
<img src={chat.getImageUrl(topRole.iconId)} alt="" class="role-icon" />
|
||||
{/if}
|
||||
{#if chat.identity?.isEqual(member.identity)}
|
||||
<span class="me-badge">(You)</span>
|
||||
{/if}
|
||||
</span> {#if status}
|
||||
<div class="member-status" title={status}>{status}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div style="flex: 1;"></div>
|
||||
<div class="member-indicators">
|
||||
{#if voiceState?.isSharingScreen}
|
||||
<span class="sharing-badge">LIVE</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
@@ -99,6 +137,7 @@
|
||||
</div>
|
||||
{#each offlineMembers as member (member.identity.toHexString())}
|
||||
{@const status = getStatus(member)}
|
||||
{@const topRole = chat.getMemberTopRole(member.identity, chat.activeServerId || 0n)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="member-item offline" oncontextmenu={(e) => handleContextMenu(e, member)}>
|
||||
<div class="avatar-container">
|
||||
@@ -106,8 +145,11 @@
|
||||
<div class="status-dot grey overlay"></div>
|
||||
</div>
|
||||
<div class="member-details">
|
||||
<span class="member-name">
|
||||
<span class="member-name" style="color: {topRole?.color || 'inherit'};">
|
||||
{member.name || member.identity.toHexString().substring(0, 8)}
|
||||
{#if topRole?.iconId}
|
||||
<img src={chat.getImageUrl(topRole.iconId)} alt="" class="role-icon" />
|
||||
{/if}
|
||||
{#if chat.identity?.isEqual(member.identity)}
|
||||
<span class="me-badge">(You)</span>
|
||||
{/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 {
|
||||
|
||||
@@ -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<string, Types.Reaction[]> = {};
|
||||
@@ -417,7 +422,19 @@
|
||||
<div class="message-content">
|
||||
{#if !isGrouped}
|
||||
<div class="message-header">
|
||||
<span class="user-name" onclick={() => (chat.viewingProfileUser = chat.users.find(u => u.identity.isEqual(msg.sender)) || null)} style="cursor: pointer;">{msgUsername}</span>
|
||||
<span
|
||||
class="user-name"
|
||||
onclick={() => (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}
|
||||
<img src={chat.getImageUrl(memberTopRole.iconId)} alt="" class="role-icon" />
|
||||
{/if}
|
||||
</span>
|
||||
{#if !isThread}
|
||||
<span class="message-time">{chat.formatTime(msg.sent)}</span>
|
||||
{/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 {
|
||||
|
||||
@@ -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"}
|
||||
<MemberPermissionsSettings />
|
||||
{/if}
|
||||
|
||||
{#if activeCategory === "roles"}
|
||||
<RoleSettings />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -574,3 +580,4 @@
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -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<ChatService>("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<string | null>(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 @@
|
||||
<button
|
||||
class="member-item"
|
||||
class:active={selectedMemberId === member.identity.toHexString()}
|
||||
onclick={() => selectedMemberId = member.identity.toHexString()}
|
||||
onclick={() => {
|
||||
console.log(`[MemberSettings] Selecting member: ${member.name} (${member.identity.toHexString()})`);
|
||||
selectedMemberId = member.identity.toHexString();
|
||||
}}
|
||||
>
|
||||
<Avatar user={chat.usersById.get(member.identity.toHexString())} size="tiny" />
|
||||
<span class="member-name">{member.name || "Unknown"}</span>
|
||||
@@ -109,28 +114,61 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="permissions-matrix">
|
||||
{#each permissionList as p}
|
||||
{@const hasBit = (memberPerms & p.bit) !== 0n}
|
||||
{@const isRoleLock = isMe && p.bit === Permissions.MANAGE_ROLES}
|
||||
<div class="editor-section">
|
||||
<div class="section-title">Roles</div>
|
||||
<div class="roles-assignment-grid">
|
||||
{#each serverRoles as role}
|
||||
{@const hasRole = server ? chat.getMemberRoles(selectedMember.identity, server.id).some(r => r.id === role.id) : false}
|
||||
<button
|
||||
class="role-pill"
|
||||
class:active={hasRole}
|
||||
disabled={!canManageRoles || !server}
|
||||
onclick={() => {
|
||||
if (!server) return;
|
||||
if (hasRole) {
|
||||
chat.handleRevokeRole(server.id, selectedMember.identity, role.id);
|
||||
} else {
|
||||
chat.handleAssignRole(server.id, selectedMember.identity, role.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="role-dot" style="background-color: {role.color || 'var(--text-muted)'};"></div>
|
||||
{role.name}
|
||||
{#if hasRole}
|
||||
<i class="fas fa-times-circle"></i>
|
||||
{:else}
|
||||
<i class="fas fa-plus-circle"></i>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="matrix-item" class:active={hasBit}>
|
||||
<div class="matrix-info">
|
||||
<span class="perm-label">{p.name}</span>
|
||||
<span class="perm-desc">{p.desc}</span>
|
||||
<div class="editor-section">
|
||||
<div class="section-title">Permission Overrides</div>
|
||||
<div class="permissions-matrix">
|
||||
{#each permissionList as p}
|
||||
{@const hasBit = (memberPerms & p.bit) !== 0n}
|
||||
{@const isRoleLock = isMe && p.bit === Permissions.MANAGE_ROLES}
|
||||
|
||||
<div class="matrix-item" class:active={hasBit}>
|
||||
<div class="matrix-info">
|
||||
<span class="perm-label">{p.name}</span>
|
||||
<span class="perm-desc">{p.desc}</span>
|
||||
</div>
|
||||
<button
|
||||
class="toggle-switch"
|
||||
class:on={hasBit}
|
||||
class:disabled={!canManageRoles || isRoleLock}
|
||||
disabled={!canManageRoles || isRoleLock}
|
||||
onclick={() => togglePermission(selectedMember.identity, p.bit)}
|
||||
aria-label="Toggle {p.name}"
|
||||
>
|
||||
<div class="switch-knob"></div>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="toggle-switch"
|
||||
class:on={hasBit}
|
||||
class:disabled={!canManageRoles || isRoleLock}
|
||||
disabled={!canManageRoles || isRoleLock}
|
||||
onclick={() => togglePermission(selectedMember.identity, p.bit)}
|
||||
aria-label="Toggle {p.name}"
|
||||
>
|
||||
<div class="switch-knob"></div>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty-editor-state">
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,626 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import type { ChatService } from "../../services/chat.svelte";
|
||||
import { Permissions } from "../../services/chat.svelte";
|
||||
import Button from "../ui/Button.svelte";
|
||||
import Input from "../ui/Input.svelte";
|
||||
import type * as Types from "../../../module_bindings/types";
|
||||
|
||||
const chat = getContext<ChatService>("chat");
|
||||
|
||||
const server = $derived(chat.activeServer);
|
||||
const roles = $derived((chat.serverRoles || []).filter(r => r.serverId === server?.id).sort((a, b) => b.position - a.position));
|
||||
|
||||
const canManageRoles = $derived(server ? chat.can(Permissions.MANAGE_ROLES) : false);
|
||||
|
||||
let selectedRoleId = $state<bigint | null>(null);
|
||||
const selectedRole = $derived(roles.find(r => r.id === selectedRoleId));
|
||||
|
||||
// Drag and Drop state
|
||||
let draggedRoleId = $state<bigint | null>(null);
|
||||
let dragOverRoleId = $state<bigint | null>(null);
|
||||
|
||||
function handleDragStart(roleId: bigint) {
|
||||
if (!canManageRoles) return;
|
||||
draggedRoleId = roleId;
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent, roleId: bigint) {
|
||||
if (!canManageRoles) return;
|
||||
e.preventDefault();
|
||||
dragOverRoleId = roleId;
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent, targetRoleId: bigint) {
|
||||
if (!canManageRoles || draggedRoleId === null || draggedRoleId === targetRoleId) {
|
||||
draggedRoleId = null;
|
||||
dragOverRoleId = null;
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
|
||||
const roleList = [...roles];
|
||||
const draggedIdx = roleList.findIndex(r => r.id === draggedRoleId);
|
||||
const targetIdx = roleList.findIndex(r => r.id === targetRoleId);
|
||||
|
||||
if (draggedIdx === -1 || targetIdx === -1) return;
|
||||
|
||||
// Perform local reorder
|
||||
const [draggedRole] = roleList.splice(draggedIdx, 1);
|
||||
roleList.splice(targetIdx, 0, draggedRole);
|
||||
|
||||
// Update positions in backend (highest at top, so index 0 = length-1)
|
||||
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;
|
||||
}
|
||||
|
||||
// Edit State
|
||||
let editName = $state("");
|
||||
let editColor = $state("#b9bbbe");
|
||||
let editIconId = $state<bigint | null>(null);
|
||||
let editPermissions = $state(0n);
|
||||
let isUploadingIcon = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
console.log(`[RoleSettings] Server: ${server?.name} (${server?.id}), Roles: ${roles.length}, CanManage: ${canManageRoles}`);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (selectedRole) {
|
||||
editName = selectedRole.name;
|
||||
editColor = selectedRole.color || "#b9bbbe";
|
||||
editIconId = selectedRole.iconId;
|
||||
editPermissions = selectedRole.permissions;
|
||||
}
|
||||
});
|
||||
|
||||
const permissionList = [
|
||||
{ bit: Permissions.MANAGE_SERVER, name: "Manage Server", desc: "Rename server, change avatar, toggle public status." },
|
||||
{ bit: Permissions.MANAGE_ROLES, name: "Manage Roles", desc: "Assign or revoke permissions for other members." },
|
||||
{ bit: Permissions.MANAGE_CHANNELS, name: "Manage Channels", desc: "Create, delete, or rename channels." },
|
||||
{ bit: Permissions.CREATE_INVITES, name: "Create Invites", desc: "Generate new invite codes for the server." },
|
||||
{ bit: Permissions.KICK_MEMBERS, name: "Kick Members", desc: "Remove members from the server." },
|
||||
{ bit: Permissions.BAN_MEMBERS, name: "Ban Members", desc: "Permanently block members from joining." },
|
||||
{ bit: Permissions.MODERATE_MESSAGES, name: "Moderate Messages", desc: "Delete messages sent by other users." },
|
||||
{ bit: Permissions.USE_VOICE, name: "Use Voice", desc: "Ability to speak in voice channels." },
|
||||
{ bit: Permissions.SHARE_SCREEN, name: "Share Screen", desc: "Ability to stream video or share screen." },
|
||||
{ bit: Permissions.USE_THREADS, name: "Use Threads", desc: "Ability to start new threaded conversations." },
|
||||
{ bit: Permissions.MANAGE_EMOJIS, name: "Manage Emojis", desc: "Upload or delete server custom emojis." },
|
||||
{ bit: Permissions.DELETE_SERVER, name: "Delete Server", desc: "Permanently destroy the server (Nuclear)." },
|
||||
];
|
||||
|
||||
function handleCreateRole() {
|
||||
if (!server) {
|
||||
console.error("[RoleSettings] Cannot create role: No active server found.");
|
||||
return;
|
||||
}
|
||||
if (!canManageRoles) {
|
||||
console.error("[RoleSettings] Cannot create role: Insufficient permissions.");
|
||||
return;
|
||||
}
|
||||
chat.handleCreateRole(server.id, "new role", "#b9bbbe", null, 0n);
|
||||
}
|
||||
|
||||
function handleSaveRole() {
|
||||
if (!selectedRole || !canManageRoles) return;
|
||||
chat.handleUpdateRole(
|
||||
selectedRole.id,
|
||||
editName,
|
||||
editColor === "#b9bbbe" ? null : editColor,
|
||||
editIconId,
|
||||
editPermissions,
|
||||
selectedRole.position
|
||||
);
|
||||
}
|
||||
|
||||
function handleDeleteRole() {
|
||||
if (!selectedRole || !canManageRoles) return;
|
||||
chat.handleDeleteRole(selectedRole.id);
|
||||
selectedRoleId = null;
|
||||
}
|
||||
|
||||
function togglePermission(bit: bigint) {
|
||||
if (editPermissions & bit) {
|
||||
editPermissions &= ~bit;
|
||||
} else {
|
||||
editPermissions |= bit;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleIconUpload(e: Event) {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file || !server) return;
|
||||
|
||||
isUploadingIcon = true;
|
||||
try {
|
||||
const data = new Uint8Array(await file.arrayBuffer());
|
||||
const imageId = await chat.uploadImage(data, file.type, file.name);
|
||||
if (imageId) {
|
||||
editIconId = imageId;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to upload role icon:", err);
|
||||
} finally {
|
||||
isUploadingIcon = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="roles-layout">
|
||||
<div class="roles-sidebar">
|
||||
<div class="sidebar-header-actions">
|
||||
<span>ROLES — {roles.length}</span>
|
||||
{#if canManageRoles}
|
||||
<button class="icon-btn-small" onclick={handleCreateRole} title="Create Role">
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="roles-list">
|
||||
{#each roles as role (role.id.toString())}
|
||||
<button
|
||||
class="role-item"
|
||||
class:active={selectedRoleId === role.id}
|
||||
class:dragging={draggedRoleId === role.id}
|
||||
class:drag-over={dragOverRoleId === role.id}
|
||||
draggable={canManageRoles}
|
||||
ondragstart={() => handleDragStart(role.id)}
|
||||
ondragover={(e) => handleDragOver(e, role.id)}
|
||||
onondragleave={() => dragOverRoleId = null}
|
||||
ondrop={(e) => handleDrop(e, role.id)}
|
||||
onclick={() => selectedRoleId = role.id}
|
||||
>
|
||||
<div class="role-drag-handle">
|
||||
{#if canManageRoles}
|
||||
<i class="fas fa-grip-vertical"></i>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="role-dot" style="background-color: {role.color || 'var(--text-muted)'};"></div>
|
||||
<span class="role-name">{role.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if canManageRoles && roles.length > 0}
|
||||
<div
|
||||
class="bottom-drop-zone"
|
||||
class:drag-over={dragOverRoleId === -1n}
|
||||
ondragover={(e) => { 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;
|
||||
}}
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
{#if roles.length === 0} <div class="empty-roles-notice">
|
||||
<p>No roles defined.</p>
|
||||
{#if canManageRoles}
|
||||
<Button variant="primary" size="small" onclick={handleCreateRole}>Create First Role</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if roles.length > 0}
|
||||
<div class="default-role-section">
|
||||
<div class="section-title">Default Role</div>
|
||||
<p class="section-desc-small">New members receive this role when they join.</p>
|
||||
<select
|
||||
class="default-role-select"
|
||||
value={server?.defaultRoleId?.toString() || ""}
|
||||
disabled={!canManageRoles}
|
||||
onchange={(e) => {
|
||||
if (!server) return;
|
||||
const val = (e.target as HTMLSelectElement).value;
|
||||
chat.handleSetDefaultRole(server.id, val ? BigInt(val) : null);
|
||||
}}
|
||||
>
|
||||
<option value="">None</option>
|
||||
{#each roles as role}
|
||||
<option value={role.id.toString()}>{role.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="role-editor">
|
||||
{#if selectedRole}
|
||||
<div class="editor-content">
|
||||
<div class="editor-section">
|
||||
<Input id="role-name" label="Role Name" bind:value={editName} disabled={!canManageRoles} />
|
||||
|
||||
<div class="color-section">
|
||||
<label class="input-label" for="role-color">Role Color</label>
|
||||
<div class="color-picker-row">
|
||||
<input id="role-color" type="color" bind:value={editColor} disabled={!canManageRoles} />
|
||||
<button class="btn secondary small" onclick={() => editColor = "#b9bbbe"} disabled={!canManageRoles}>Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="icon-section">
|
||||
<label class="input-label" for="role-icon">Role Icon</label>
|
||||
<div class="icon-picker-row">
|
||||
{#if editIconId}
|
||||
<img src={chat.getImageUrl(editIconId)} alt="" class="role-icon-preview" />
|
||||
<button class="btn danger small" onclick={() => editIconId = null} disabled={!canManageRoles}>Remove</button>
|
||||
{:else}
|
||||
<label class="btn secondary small">
|
||||
Upload Icon
|
||||
<input type="file" accept="image/*" onchange={handleIconUpload} disabled={!canManageRoles || isUploadingIcon} style="display: none;" />
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-section">
|
||||
<div class="section-title">Permissions</div>
|
||||
<div class="permissions-list">
|
||||
{#each permissionList as p}
|
||||
{@const hasBit = (editPermissions & p.bit) !== 0n}
|
||||
<div class="perm-row">
|
||||
<div class="perm-info">
|
||||
<span class="perm-name">{p.name}</span>
|
||||
<span class="perm-desc">{p.desc}</span>
|
||||
</div>
|
||||
<button
|
||||
class="toggle-switch"
|
||||
class:on={hasBit}
|
||||
disabled={!canManageRoles}
|
||||
onclick={() => togglePermission(p.bit)}
|
||||
aria-label="Toggle {p.name}"
|
||||
>
|
||||
<div class="switch-knob"></div>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if canManageRoles}
|
||||
<div class="editor-footer">
|
||||
<Button variant="primary" onclick={handleSaveRole}>Save Changes</Button>
|
||||
<div style="flex: 1;"></div>
|
||||
<Button variant="danger" onclick={handleDeleteRole}>Delete Role</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty-editor-state">
|
||||
<i class="fas fa-shield-alt"></i>
|
||||
<p>Select a role from the sidebar to edit its permissions and style.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.roles-layout {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
height: 100%;
|
||||
background-color: var(--background-modifier-accent);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-top: 10px;
|
||||
border: 1px solid var(--background-modifier-accent);
|
||||
}
|
||||
|
||||
.roles-sidebar {
|
||||
width: 240px;
|
||||
background-color: var(--background-secondary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-header-actions {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--background-modifier-accent);
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.roles-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.role-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: var(--channels-default);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
|
||||
.role-item:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
color: var(--interactive-hover);
|
||||
}
|
||||
|
||||
.role-item.active {
|
||||
background-color: var(--background-modifier-selected);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.role-item.dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.role-item.drag-over {
|
||||
border-top: 2px solid var(--brand);
|
||||
}
|
||||
|
||||
.bottom-drop-zone {
|
||||
height: 12px;
|
||||
margin-top: 4px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
|
||||
.bottom-drop-zone.drag-over {
|
||||
background-color: rgba(88, 101, 242, 0.1);
|
||||
border-top: 2px solid var(--brand);
|
||||
}
|
||||
|
||||
.role-drag-handle {
|
||||
width: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--interactive-normal);
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.role-item:hover .role-drag-handle {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.role-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.role-name {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.role-editor {
|
||||
flex: 1;
|
||||
background-color: var(--background-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24px;
|
||||
gap: 32px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.editor-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.color-section, .icon-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.color-picker-row, .icon-picker-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.role-icon-preview {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--background-tertiary);
|
||||
}
|
||||
|
||||
.perm-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--background-modifier-accent);
|
||||
}
|
||||
|
||||
.perm-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.perm-name {
|
||||
color: var(--header-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.perm-desc {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
max-width: 450px;
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
width: 40px;
|
||||
height: 24px;
|
||||
background-color: var(--background-accent);
|
||||
border-radius: 12px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.toggle-switch.on {
|
||||
background-color: var(--status-positive);
|
||||
}
|
||||
|
||||
.toggle-switch:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.switch-knob {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.on .switch-knob {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
.editor-footer {
|
||||
display: flex;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid var(--background-modifier-accent);
|
||||
}
|
||||
|
||||
.empty-editor-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.empty-editor-state i {
|
||||
font-size: 4rem;
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
.icon-btn-small {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--interactive-normal);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.icon-btn-small:hover {
|
||||
color: var(--interactive-hover);
|
||||
}
|
||||
|
||||
.empty-roles-notice {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.default-role-section {
|
||||
padding: 16px;
|
||||
border-top: 1px solid var(--background-modifier-accent);
|
||||
background-color: var(--background-secondary-alt);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.section-desc-small {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.default-role-select {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
background-color: var(--background-tertiary);
|
||||
border: 1px solid var(--background-modifier-accent);
|
||||
border-radius: 4px;
|
||||
color: var(--text-normal);
|
||||
font-size: 0.85rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.default-role-select:focus {
|
||||
border-color: var(--brand);
|
||||
}
|
||||
</style>
|
||||
@@ -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;
|
||||
|
||||
@@ -17,6 +17,8 @@ export class DatabaseService {
|
||||
users = $state<readonly Types.User[]>([]);
|
||||
serverMembers = $state<readonly Types.ServerMember[]>([]);
|
||||
serverPermissions = $state<readonly Types.ServerPermission[]>([]);
|
||||
serverRoles = $state<readonly Types.ServerRole[]>([]);
|
||||
memberRoles = $state<readonly Types.MemberRole[]>([]);
|
||||
threadMessages = $state<readonly Types.Message[]>([]);
|
||||
recentMessages = $state<readonly Types.Message[]>([]);
|
||||
|
||||
@@ -75,6 +77,33 @@ export class DatabaseService {
|
||||
return map;
|
||||
});
|
||||
|
||||
rolesById = $derived.by(() => {
|
||||
const map = new Map<bigint, Types.ServerRole>();
|
||||
for (const role of this.serverRoles) {
|
||||
map.set(role.id, role);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
memberRolesByServerId = $derived.by(() => {
|
||||
const map = new Map<bigint, Map<string, bigint[]>>();
|
||||
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<bigint, Types.VisibleChannelRow>();
|
||||
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));
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user