Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions desktop/src-tauri/src/commands/channels.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
28 changes: 25 additions & 3 deletions desktop/src/features/channels/ui/MembersSidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as React from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
useAddChannelMembersMutation,
useChannelMembersQuery,
Expand All @@ -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 {
Expand Down Expand Up @@ -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 } =
Expand Down Expand Up @@ -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={
Expand All @@ -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);
}}
Expand Down Expand Up @@ -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>
Expand Down
79 changes: 67 additions & 12 deletions desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -17,17 +24,22 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";

type MembersSidebarMemberCardProps = {
canChangeRole: boolean;
canRemoveMember: boolean;
isActionPending: boolean;
isArchived: boolean;
managedAgent?: ManagedAgent;
member: ChannelMember;
memberIsBot: boolean;
memberLabel: string;
onChangeRole: (member: ChannelMember, role: string) => void;
onManagedAgentAction: (agent: ManagedAgent) => void;
onRemoveMember: (member: ChannelMember) => void;
presenceStatus?: PresenceStatus | null;
Expand Down Expand Up @@ -56,13 +68,15 @@ function formatManagedAgentStatus(agent: ManagedAgent) {
}

export function MembersSidebarMemberCard({
canChangeRole,
canRemoveMember,
isActionPending,
isArchived,
managedAgent,
member,
memberIsBot,
memberLabel,
onChangeRole,
onManagedAgentAction,
onRemoveMember,
presenceStatus,
Expand All @@ -72,7 +86,7 @@ export function MembersSidebarMemberCard({
const disabled = isActionPending || isArchived;
const hasActions = memberIsBot
? Boolean(managedAgent) || canRemoveMember
: canRemoveMember;
: canRemoveMember || canChangeRole;

return (
<div
Expand Down Expand Up @@ -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}
/>
Expand All @@ -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>
Expand All @@ -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>
Expand Down
8 changes: 8 additions & 0 deletions desktop/src/shared/api/tauri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand Down