Files
spsays-training/components/chat/group-members-tab.tsx
T
2026-08-29 03:49:58 +05:30

164 lines
5.3 KiB
TypeScript

"use client";
/**
* The group profile's Members tab: who is in the group, their role, and the
* admin controls for changing that.
*
* Which controls appear follows the same rules the BFF enforces — the server is
* the authority, this just avoids offering buttons that would be refused.
*/
import { useState } from "react";
import { Plus, ShieldMinus, ShieldPlus, UserMinus } from "lucide-react";
import { toast } from "sonner";
import { UserAvatar } from "@/components/ui/user-avatar";
import { Button } from "@/components/ui/button";
import { AddMembersDialog } from "@/components/chat/add-members-dialog";
import { chat, isGroupAdmin, type ConversationDetail } from "@/lib/chat";
const ROLE_LABEL: Record<string, string> = {
owner: "Owner",
admin: "Admin",
member: "",
};
export function GroupMembersTab({
conversationId,
detail,
selfId,
onChanged,
}: {
conversationId: string;
detail: ConversationDetail;
selfId: string;
onChanged: () => void;
}) {
const [busy, setBusy] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const iAmAdmin = isGroupAdmin(detail.my_role);
const iAmOwner = detail.my_role === "owner";
const act = async (handle: string, fn: () => Promise<unknown>, ok: string) => {
setBusy(handle);
try {
await fn();
toast.success(ok);
onChanged();
} catch {
toast.error("That didn't work");
} finally {
setBusy(null);
}
};
return (
<div className="space-y-3">
{iAmAdmin && (
<Button variant="outline" size="sm" onClick={() => setAdding(true)}>
<Plus className="size-4" /> Add members
</Button>
)}
<ul className="divide-y divide-border rounded-2xl border border-border overflow-hidden">
{detail.participants.map((m) => {
const isSelf = m.id === selfId;
// The owner cannot be demoted or removed by anyone, and only the
// owner may change roles at all.
const canChangeRole = iAmOwner && !isSelf && m.role !== "owner";
// An admin may remove members; only the owner may remove an admin.
const canRemove =
!isSelf &&
m.role !== "owner" &&
(iAmOwner || (iAmAdmin && m.role !== "admin"));
return (
<li key={m.id} className="flex items-center gap-3 px-4 py-3">
<UserAvatar
src={m.avatar_url}
name={m.name}
className="size-9 shrink-0"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{m.name}
{isSelf && (
<span className="text-muted-foreground"> (you)</span>
)}
</p>
<p className="truncate text-xs text-muted-foreground">
@{m.handle}
</p>
</div>
{m.role && ROLE_LABEL[m.role] && (
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
{ROLE_LABEL[m.role]}
</span>
)}
{canChangeRole && (
<button
type="button"
disabled={busy === m.handle}
aria-label={
m.role === "admin" ? "Remove admin" : "Make admin"
}
onClick={() =>
act(
m.handle,
() =>
chat.setMemberRole(
conversationId,
m.handle,
m.role === "admin" ? "member" : "admin",
),
m.role === "admin"
? `${m.name} is no longer an admin`
: `${m.name} is now an admin`,
)
}
className="shrink-0 rounded-md p-1.5 text-muted-foreground hover:text-foreground disabled:opacity-40"
>
{m.role === "admin" ? (
<ShieldMinus className="size-4" />
) : (
<ShieldPlus className="size-4" />
)}
</button>
)}
{canRemove && (
<button
type="button"
disabled={busy === m.handle}
aria-label={`Remove ${m.name}`}
onClick={() => {
if (!window.confirm(`Remove ${m.name} from the group?`)) return;
void act(
m.handle,
() => chat.removeMember(conversationId, m.handle),
`${m.name} was removed`,
);
}}
className="shrink-0 rounded-md p-1.5 text-destructive hover:opacity-80 disabled:opacity-40"
>
<UserMinus className="size-4" />
</button>
)}
</li>
);
})}
</ul>
{adding && (
<AddMembersDialog
conversationId={conversationId}
existing={detail.participants.map((p) => p.handle)}
onClose={() => setAdding(false)}
onAdded={onChanged}
/>
)}
</div>
);
}