From be83699559208e36cb94b60eda0cdaeeb4915756 Mon Sep 17 00:00:00 2001
From: rbrackney-sq <75040004+rbrackney-sq@users.noreply.github.com>
Date: Tue, 14 Apr 2026 08:14:36 -0700
Subject: [PATCH] feat: add change member role in channel sidebar (#313)
Co-authored-by: Claude Opus 4.6 (1M context)
---
desktop/src-tauri/src/commands/channels.rs | 20 +++++
desktop/src-tauri/src/lib.rs | 1 +
.../features/channels/ui/MembersSidebar.tsx | 28 ++++++-
.../channels/ui/MembersSidebarMemberCard.tsx | 79 ++++++++++++++++---
desktop/src/shared/api/tauri.ts | 8 ++
5 files changed, 121 insertions(+), 15 deletions(-)
diff --git a/desktop/src-tauri/src/commands/channels.rs b/desktop/src-tauri/src/commands/channels.rs
index 0a2ab77be..2844fd1f5 100644
--- a/desktop/src-tauri/src/commands/channels.rs
+++ b/desktop/src-tauri/src/commands/channels.rs
@@ -195,6 +195,26 @@ pub async fn remove_channel_member(
Ok(())
}
+#[tauri::command]
+pub async fn change_channel_member_role(
+ channel_id: String,
+ pubkey: String,
+ role: String,
+ state: State<'_, AppState>,
+) -> Result<(), String> {
+ let uuid = parse_channel_uuid(&channel_id)?;
+ // Only allow permission-tier roles for humans and bot/guest for bots.
+ // Owner changes require a dedicated transfer-ownership flow.
+ let role_str = match role.as_str() {
+ "admin" | "member" | "guest" | "bot" => role.as_str(),
+ "owner" => return Err("cannot assign owner role — use transfer ownership".into()),
+ other => return Err(format!("invalid role: {other}")),
+ };
+ let builder = events::build_add_member(uuid, &pubkey, Some(role_str))?;
+ submit_event(builder, &state).await?;
+ Ok(())
+}
+
#[tauri::command]
pub async fn join_channel(channel_id: String, state: State<'_, AppState>) -> Result<(), String> {
let uuid = parse_channel_uuid(&channel_id)?;
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index f26ab2d81..095824c2d 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -437,6 +437,7 @@ pub fn run() {
delete_channel,
add_channel_members,
remove_channel_member,
+ change_channel_member_role,
join_channel,
leave_channel,
get_canvas,
diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx
index 06c419530..a1255f127 100644
--- a/desktop/src/features/channels/ui/MembersSidebar.tsx
+++ b/desktop/src/features/channels/ui/MembersSidebar.tsx
@@ -1,4 +1,5 @@
import * as React from "react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
useAddChannelMembersMutation,
useChannelMembersQuery,
@@ -7,6 +8,7 @@ import { useClassifiedMembers } from "@/features/channels/lib/useClassifiedMembe
import { formatMemberName } from "@/features/channels/lib/memberUtils";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { usePresenceQuery } from "@/features/presence/hooks";
+import { changeChannelMemberRole } from "@/shared/api/tauri";
import type { Channel, ChannelMember } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
import {
@@ -35,8 +37,24 @@ export function MembersSidebar({
onOpenChange,
}: MembersSidebarProps) {
const channelId = channel?.id ?? null;
+ const queryClient = useQueryClient();
const membersQuery = useChannelMembersQuery(channelId, open);
const addMembersMutation = useAddChannelMembersMutation(channelId);
+ const changeRoleMutation = useMutation({
+ mutationFn: async ({ pubkey, role }: { pubkey: string; role: string }) => {
+ if (!channelId) throw new Error("No channel selected.");
+ await changeChannelMemberRole(channelId, pubkey, role);
+ },
+ onSettled: async () => {
+ await queryClient.invalidateQueries({
+ queryKey: ["channels", channelId],
+ });
+ },
+ });
+ const changeRoleError =
+ changeRoleMutation.error instanceof Error
+ ? changeRoleMutation.error.message
+ : null;
const rawMembers = membersQuery.data ?? [];
const { people, bots, isBot, isMyBot, managedAgentsQuery } =
@@ -127,8 +145,9 @@ export function MembersSidebar({
function renderMemberCard(member: ChannelMember, memberIsBot: boolean) {
return (
{
+ void changeRoleMutation.mutateAsync({ pubkey: m.pubkey, role });
+ }}
onManagedAgentAction={(agent) => {
void handleAgentLifecycleAction(agent);
}}
@@ -250,12 +272,12 @@ export function MembersSidebar({
) : null}
- {actionErrorMessage ? (
+ {actionErrorMessage || changeRoleError ? (
- {actionErrorMessage}
+ {actionErrorMessage ?? changeRoleError}
) : null}
diff --git a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx
index 8cac47e48..8b5625ea0 100644
--- a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx
+++ b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx
@@ -1,4 +1,11 @@
-import { Ellipsis, Play, RotateCcw, Square, Trash2 } from "lucide-react";
+import {
+ Ellipsis,
+ Play,
+ RotateCcw,
+ Shield,
+ Square,
+ Trash2,
+} from "lucide-react";
import {
getManagedAgentPrimaryActionLabel,
@@ -17,10 +24,14 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
type MembersSidebarMemberCardProps = {
+ canChangeRole: boolean;
canRemoveMember: boolean;
isActionPending: boolean;
isArchived: boolean;
@@ -28,6 +39,7 @@ type MembersSidebarMemberCardProps = {
member: ChannelMember;
memberIsBot: boolean;
memberLabel: string;
+ onChangeRole: (member: ChannelMember, role: string) => void;
onManagedAgentAction: (agent: ManagedAgent) => void;
onRemoveMember: (member: ChannelMember) => void;
presenceStatus?: PresenceStatus | null;
@@ -56,6 +68,7 @@ function formatManagedAgentStatus(agent: ManagedAgent) {
}
export function MembersSidebarMemberCard({
+ canChangeRole,
canRemoveMember,
isActionPending,
isArchived,
@@ -63,6 +76,7 @@ export function MembersSidebarMemberCard({
member,
memberIsBot,
memberLabel,
+ onChangeRole,
onManagedAgentAction,
onRemoveMember,
presenceStatus,
@@ -72,7 +86,7 @@ export function MembersSidebarMemberCard({
const disabled = isActionPending || isArchived;
const hasActions = memberIsBot
? Boolean(managedAgent) || canRemoveMember
- : canRemoveMember;
+ : canRemoveMember || canChangeRole;
return (
{hasActions ? (
@@ -131,23 +147,32 @@ export function MembersSidebarMemberCard({
);
}
+const PEOPLE_ROLES = ["admin", "member", "guest"] as const;
+
function MemberActionsMenu({
+ canChangeRole,
canRemoveMember,
disabled,
managedAgent,
member,
memberIsBot,
+ onChangeRole,
onManagedAgentAction,
onRemoveMember,
}: {
+ canChangeRole: boolean;
canRemoveMember: boolean;
disabled: boolean;
managedAgent?: ManagedAgent;
member: ChannelMember;
memberIsBot: boolean;
+ onChangeRole: (member: ChannelMember, role: string) => void;
onManagedAgentAction: (agent: ManagedAgent) => void;
onRemoveMember: (member: ChannelMember) => void;
}) {
+ const showChangeRole =
+ canChangeRole && !memberIsBot && member.role !== "owner";
+
return (
@@ -173,19 +198,49 @@ function MemberActionsMenu({
{getManagedAgentActionIcon(managedAgent)}
{getManagedAgentPrimaryActionLabel(managedAgent)}
- {canRemoveMember ? : null}
+ {canRemoveMember || showChangeRole ? (
+
+ ) : null}
>
) : null}
+ {showChangeRole ? (
+
+
+
+ Change role
+
+
+ {PEOPLE_ROLES.map((role) => (
+ onChangeRole(member, role)}
+ >
+ {role[0]?.toUpperCase()}
+ {role.slice(1)}
+ {member.role === role ? " (current)" : ""}
+
+ ))}
+
+
+ ) : null}
{canRemoveMember ? (
- onRemoveMember(member)}
- >
-
- Remove from channel
-
+ <>
+ {showChangeRole ? : null}
+ onRemoveMember(member)}
+ >
+
+ Remove from channel
+
+ >
) : null}
diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts
index b105c090e..daa55e4e2 100644
--- a/desktop/src/shared/api/tauri.ts
+++ b/desktop/src/shared/api/tauri.ts
@@ -615,6 +615,14 @@ export async function removeChannelMember(
await invokeTauri("remove_channel_member", { channelId, pubkey });
}
+export async function changeChannelMemberRole(
+ channelId: string,
+ pubkey: string,
+ role: string,
+): Promise {
+ await invokeTauri("change_channel_member_role", { channelId, pubkey, role });
+}
+
export async function joinChannel(channelId: string): Promise {
await invokeTauri("join_channel", { channelId });
}