feat: add change member role in channel sidebar (#313)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
rbrackney-sq
2026-04-14 08:14:36 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 0f5f306364
commit be83699559
5 changed files with 121 additions and 15 deletions
@@ -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)?;
+1
View File
@@ -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,
@@ -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 (
<MembersSidebarMemberCard
canChangeRole={canManageMembers && member.pubkey !== currentPubkey}
canRemoveMember={canRemoveMember(member)}
isActionPending={isActionPending}
isActionPending={isActionPending || changeRoleMutation.isPending}
isArchived={isArchived}
key={member.pubkey}
managedAgent={
@@ -139,6 +158,9 @@ export function MembersSidebar({
member={member}
memberIsBot={memberIsBot}
memberLabel={formatMemberName(member, currentPubkey)}
onChangeRole={(m, role) => {
void changeRoleMutation.mutateAsync({ pubkey: m.pubkey, role });
}}
onManagedAgentAction={(agent) => {
void handleAgentLifecycleAction(agent);
}}
@@ -250,12 +272,12 @@ export function MembersSidebar({
</p>
) : null}
{actionErrorMessage ? (
{actionErrorMessage || changeRoleError ? (
<p
className="text-sm text-destructive"
data-testid="members-sidebar-action-error"
>
{actionErrorMessage}
{actionErrorMessage ?? changeRoleError}
</p>
) : null}
</div>
@@ -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 (
<div
@@ -118,11 +132,13 @@ export function MembersSidebarMemberCard({
</div>
{hasActions ? (
<MemberActionsMenu
canChangeRole={canChangeRole}
canRemoveMember={canRemoveMember}
disabled={disabled}
managedAgent={managedAgent}
member={member}
memberIsBot={memberIsBot}
onChangeRole={onChangeRole}
onManagedAgentAction={onManagedAgentAction}
onRemoveMember={onRemoveMember}
/>
@@ -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 (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
@@ -173,19 +198,49 @@ function MemberActionsMenu({
{getManagedAgentActionIcon(managedAgent)}
{getManagedAgentPrimaryActionLabel(managedAgent)}
</DropdownMenuItem>
{canRemoveMember ? <DropdownMenuSeparator /> : null}
{canRemoveMember || showChangeRole ? (
<DropdownMenuSeparator />
) : null}
</>
) : null}
{showChangeRole ? (
<DropdownMenuSub>
<DropdownMenuSubTrigger
data-testid={`sidebar-change-role-${member.pubkey}`}
disabled={disabled}
>
<Shield className="h-4 w-4" />
Change role
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{PEOPLE_ROLES.map((role) => (
<DropdownMenuItem
data-testid={`sidebar-role-${role}-${member.pubkey}`}
disabled={disabled || member.role === role}
key={role}
onClick={() => onChangeRole(member, role)}
>
{role[0]?.toUpperCase()}
{role.slice(1)}
{member.role === role ? " (current)" : ""}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
) : null}
{canRemoveMember ? (
<DropdownMenuItem
className="text-destructive focus:text-destructive"
data-testid={`sidebar-remove-member-${member.pubkey}`}
disabled={disabled}
onClick={() => onRemoveMember(member)}
>
<Trash2 className="h-4 w-4" />
Remove from channel
</DropdownMenuItem>
<>
{showChangeRole ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem
className="text-destructive focus:text-destructive"
data-testid={`sidebar-remove-member-${member.pubkey}`}
disabled={disabled}
onClick={() => onRemoveMember(member)}
>
<Trash2 className="h-4 w-4" />
Remove from channel
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
+8
View File
@@ -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<void> {
await invokeTauri("change_channel_member_role", { channelId, pubkey, role });
}
export async function joinChannel(channelId: string): Promise<void> {
await invokeTauri("join_channel", { channelId });
}