274 lines
8.3 KiB
TypeScript
274 lines
8.3 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* The group profile's Settings tab: rename, choose who may post, and leave.
|
|
*
|
|
* Editing is admin-only; everyone else sees the same facts read-only, so the
|
|
* tab still answers "why can't I post here?" for a plain member.
|
|
*/
|
|
|
|
import { useRef, useState } from "react";
|
|
import { Camera, Loader2, LogOut } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { useRouter } from "next/navigation";
|
|
import { Button } from "@/components/ui/button";
|
|
import { UserAvatar } from "@/components/ui/user-avatar";
|
|
import { ConfirmModal } from "@/components/ui/confirm-modal";
|
|
import { compressImage } from "@/lib/compress-image";
|
|
import {
|
|
chat,
|
|
groupAvatarUrl,
|
|
isGroupAdmin,
|
|
uploadGroupAvatar,
|
|
type ConversationDetail,
|
|
type SendPolicy,
|
|
} from "@/lib/chat";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export function GroupSettingsTab({
|
|
conversationId,
|
|
detail,
|
|
selfHandle,
|
|
onChanged,
|
|
}: {
|
|
conversationId: string;
|
|
detail: ConversationDetail;
|
|
selfHandle: string;
|
|
onChanged: () => void;
|
|
}) {
|
|
const router = useRouter();
|
|
const [title, setTitle] = useState(detail.title ?? "");
|
|
const [busy, setBusy] = useState(false);
|
|
const [confirmLeave, setConfirmLeave] = useState(false);
|
|
const [uploading, setUploading] = useState(false);
|
|
const fileRef = useRef<HTMLInputElement>(null);
|
|
const iAmAdmin = isGroupAdmin(detail.my_role);
|
|
const renamed = title.trim() !== (detail.title ?? "").trim();
|
|
|
|
/**
|
|
* Upload first, then point the group at the new key. Two steps because the
|
|
* bytes go straight to storage and never through the BFF — the same path
|
|
* message attachments take.
|
|
*/
|
|
const changePhoto = async (file: File) => {
|
|
setUploading(true);
|
|
try {
|
|
const compressed = await compressImage(file).catch(() => file);
|
|
const key = await uploadGroupAvatar(conversationId, compressed);
|
|
await chat.updateGroup(conversationId, { avatar_key: key });
|
|
toast.success("Group photo updated");
|
|
onChanged();
|
|
} catch {
|
|
toast.error("Could not update the group photo");
|
|
} finally {
|
|
setUploading(false);
|
|
}
|
|
};
|
|
|
|
const rename = async () => {
|
|
if (!renamed || title.trim() === "") return;
|
|
setBusy(true);
|
|
try {
|
|
await chat.updateGroup(conversationId, { title: title.trim() });
|
|
toast.success("Group renamed");
|
|
onChanged();
|
|
} catch {
|
|
toast.error("Could not rename the group");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const setPolicy = async (policy: SendPolicy) => {
|
|
if (policy === detail.send_policy) return;
|
|
setBusy(true);
|
|
try {
|
|
await chat.updateGroup(conversationId, { send_policy: policy });
|
|
toast.success(
|
|
policy === "admins"
|
|
? "Only admins can send messages now"
|
|
: "Everyone can send messages now",
|
|
);
|
|
onChanged();
|
|
} catch {
|
|
toast.error("Could not change the setting");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const leave = async () => {
|
|
setBusy(true);
|
|
try {
|
|
await chat.removeMember(conversationId, selfHandle);
|
|
toast.success("You left the group");
|
|
router.push("/chats");
|
|
} catch {
|
|
toast.error("Could not leave the group");
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<section className="space-y-2">
|
|
<h3 className="text-sm font-medium text-foreground">Group photo</h3>
|
|
<div className="flex items-center gap-3">
|
|
<div className="relative">
|
|
<UserAvatar
|
|
src={groupAvatarUrl(conversationId, detail.avatar_key)}
|
|
name={detail.title ?? "Group"}
|
|
className="size-16"
|
|
textClassName="text-lg"
|
|
/>
|
|
{uploading && (
|
|
<span className="absolute inset-0 flex items-center justify-center rounded-full bg-black/40">
|
|
<Loader2 className="size-5 animate-spin text-white" />
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{iAmAdmin ? (
|
|
<>
|
|
<input
|
|
ref={fileRef}
|
|
type="file"
|
|
accept="image/*"
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) void changePhoto(file);
|
|
// Reset so re-picking the same file still fires a change.
|
|
e.target.value = "";
|
|
}}
|
|
/>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={uploading || busy}
|
|
onClick={() => fileRef.current?.click()}
|
|
>
|
|
<Camera className="size-4" />
|
|
{detail.avatar_key ? "Change photo" : "Add photo"}
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<p className="text-xs text-muted-foreground">
|
|
Only group admins can change the photo.
|
|
</p>
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="space-y-2">
|
|
<h3 className="text-sm font-medium text-foreground">Group name</h3>
|
|
{iAmAdmin ? (
|
|
<div className="flex gap-2">
|
|
<input
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
maxLength={120}
|
|
className="flex-1 rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm outline-none pointer-coarse:text-base focus:ring-1 focus:ring-primary/40"
|
|
/>
|
|
<Button size="sm" disabled={!renamed || busy} onClick={rename}>
|
|
Save
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">{detail.title}</p>
|
|
)}
|
|
</section>
|
|
|
|
<section className="space-y-2">
|
|
<h3 className="text-sm font-medium text-foreground">
|
|
Who can send messages
|
|
</h3>
|
|
<div className="space-y-2">
|
|
<PolicyOption
|
|
active={detail.send_policy === "everyone"}
|
|
disabled={!iAmAdmin || busy}
|
|
onClick={() => setPolicy("everyone")}
|
|
title="Everyone"
|
|
description="Any member can send messages."
|
|
/>
|
|
<PolicyOption
|
|
active={detail.send_policy === "admins"}
|
|
disabled={!iAmAdmin || busy}
|
|
onClick={() => setPolicy("admins")}
|
|
title="Only admins"
|
|
description="Members can read but not post — an announcement group."
|
|
/>
|
|
</div>
|
|
{!iAmAdmin && (
|
|
<p className="text-xs text-muted-foreground">
|
|
Only group admins can change these settings.
|
|
</p>
|
|
)}
|
|
</section>
|
|
|
|
<section className="space-y-2">
|
|
<h3 className="text-sm font-medium text-foreground">Leave</h3>
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
disabled={busy}
|
|
onClick={() => setConfirmLeave(true)}
|
|
>
|
|
<LogOut className="size-4" /> Leave group
|
|
</Button>
|
|
{detail.my_role === "owner" && (
|
|
<p className="text-xs text-muted-foreground">
|
|
You own this group. If you leave, the longest-serving admin becomes
|
|
the owner.
|
|
</p>
|
|
)}
|
|
</section>
|
|
<ConfirmModal
|
|
open={confirmLeave}
|
|
onClose={() => setConfirmLeave(false)}
|
|
title="Leave this group?"
|
|
message={
|
|
detail.my_role === "owner"
|
|
? "You own this group. The longest-serving admin becomes the owner in your place."
|
|
: "You'll stop receiving messages from this conversation."
|
|
}
|
|
confirmLabel="Leave"
|
|
onConfirm={leave}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PolicyOption({
|
|
active,
|
|
disabled,
|
|
onClick,
|
|
title,
|
|
description,
|
|
}: {
|
|
active: boolean;
|
|
disabled: boolean;
|
|
onClick: () => void;
|
|
title: string;
|
|
description: string;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
disabled={disabled}
|
|
onClick={onClick}
|
|
aria-pressed={active}
|
|
className={cn(
|
|
"flex w-full flex-col items-start gap-0.5 rounded-lg border p-3 text-left transition-colors",
|
|
active
|
|
? "border-primary bg-primary/5"
|
|
: "border-border hover:bg-muted/60",
|
|
disabled && "cursor-default opacity-70 hover:bg-transparent",
|
|
)}
|
|
>
|
|
<span className="text-sm font-medium text-foreground">{title}</span>
|
|
<span className="text-xs text-muted-foreground">{description}</span>
|
|
</button>
|
|
);
|
|
}
|