mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Claude Opus 4.6
parent
0f5f306364
commit
be83699559
@@ -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)?;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user