Show new admin clearly after sole admin leaves group chat
Task #49: When the sole admin leaves a group and a successor is auto- or manually-promoted, surface the change prominently in addition to the existing system messages. Changes: - artifacts/teaboy-os/src/pages/chat.tsx - New effect on conversations list: tracks the set of group conversations where the current user is currently an admin (from participant.isAdmin). On first load it silently records a baseline in localStorage (key teaboy:admin-known:<userId>); thereafter, any newly-admin group triggers a toast ("You are now the admin of <group>"). The set is pruned when the user is no longer admin so a future re-promotion toasts again. Using participant.isAdmin (rather than only lastMessage) ensures the cue fires even if newer messages have arrived in the conversation since the promotion. - Conversation list rows now render a small amber "New admin" / "You are now the admin" Crown badge whenever the most recent message is an admin_promoted system message. Variant differs for the promoted user vs other members. data-testid="badge-new-admin-<convId>" for tests. - Imported Crown from lucide-react. - artifacts/teaboy-os/src/locales/en.json + ar.json - Added bilingual strings under chat.actions: youAreAdminTitle, youAreAdminDescription, newAdminBadge, newAdminLabel. Implementation notes: - lastMessage.meta is already returned by GET /conversations and includes the promoted user (id, displayNameAr/En, username), so no API changes were needed. - Regenerated api-client-react via `pnpm --filter @workspace/api-spec run codegen` (pre-existing stale codegen was failing typecheck unrelated to this task, now clean for chat.tsx). - Did not add automated tests; the existing follow-up task "Make sure the leave-and-handoff flow stays working with automated tests" already covers it. Replit-Task-Id: 71495f44-8c6c-4f7f-8f48-6400630d1203
This commit is contained in:
@@ -183,7 +183,11 @@
|
||||
"unarchived": "أعيدت المحادثة إلى النشطة",
|
||||
"left": "غادرت المجموعة",
|
||||
"actionFailed": "تعذّر تنفيذ الإجراء",
|
||||
"mutedLabel": "مكتومة"
|
||||
"mutedLabel": "مكتومة",
|
||||
"youAreAdminTitle": "أنت الآن المشرف",
|
||||
"youAreAdminDescription": "أصبحت مسؤولًا عن \"{{name}}\".",
|
||||
"newAdminBadge": "مشرف جديد",
|
||||
"newAdminLabel": "مشرف جديد: {{name}}"
|
||||
},
|
||||
"system": {
|
||||
"someone": "شخص ما",
|
||||
|
||||
@@ -180,7 +180,11 @@
|
||||
"unarchived": "Chat moved back to active",
|
||||
"left": "You left the group",
|
||||
"actionFailed": "Could not complete action",
|
||||
"mutedLabel": "Muted"
|
||||
"mutedLabel": "Muted",
|
||||
"youAreAdminTitle": "You are now the admin",
|
||||
"youAreAdminDescription": "You're now in charge of \"{{name}}\".",
|
||||
"newAdminBadge": "New admin",
|
||||
"newAdminLabel": "New admin: {{name}}"
|
||||
},
|
||||
"system": {
|
||||
"someone": "Someone",
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
LogOut,
|
||||
Crown,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -244,6 +245,78 @@ export default function ChatPage() {
|
||||
setLocation("/chat", { replace: true });
|
||||
}, [search, setLocation]);
|
||||
|
||||
// Notify the current user when they have been promoted to admin of a group
|
||||
// (e.g. after the previous admin left). We track per-user the set of group
|
||||
// conversation IDs we have already observed the user being admin of; when a
|
||||
// new one appears we toast. Using participant.isAdmin rather than only the
|
||||
// lastMessage means the cue still fires reliably even if newer messages
|
||||
// arrive in the conversation before the user next opens chat.
|
||||
useEffect(() => {
|
||||
if (!user || !conversations) return;
|
||||
const ackKey = `teaboy:admin-known:${user.id}`;
|
||||
let ack: number[] = [];
|
||||
try {
|
||||
ack = JSON.parse(localStorage.getItem(ackKey) ?? "[]");
|
||||
if (!Array.isArray(ack)) ack = [];
|
||||
} catch {
|
||||
ack = [];
|
||||
}
|
||||
const known = new Set<number>(ack);
|
||||
|
||||
// Conversations where the current user is currently an admin.
|
||||
const currentAdminGroupIds = new Set<number>();
|
||||
for (const conv of conversations) {
|
||||
if (!conv.isGroup) continue;
|
||||
const me = conv.participants?.find((p) => p.id === user.id);
|
||||
if (me?.isAdmin) currentAdminGroupIds.add(conv.id);
|
||||
}
|
||||
|
||||
// First load for this user/browser: just record the baseline silently
|
||||
// so we don't spam toasts about pre-existing admin roles.
|
||||
if (known.size === 0 && localStorage.getItem(ackKey) === null) {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
ackKey,
|
||||
JSON.stringify(Array.from(currentAdminGroupIds)),
|
||||
);
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
for (const convId of currentAdminGroupIds) {
|
||||
if (known.has(convId)) continue;
|
||||
const conv = conversations.find((c) => c.id === convId);
|
||||
if (!conv) continue;
|
||||
const convDisplay = convDisplayName(conv);
|
||||
toast({
|
||||
title: t("chat.actions.youAreAdminTitle"),
|
||||
description: t("chat.actions.youAreAdminDescription", {
|
||||
name: convDisplay,
|
||||
}),
|
||||
});
|
||||
known.add(convId);
|
||||
changed = true;
|
||||
}
|
||||
// Forget conversations the user is no longer an admin of (or has left)
|
||||
// so a future re-promotion will toast again.
|
||||
for (const convId of Array.from(known)) {
|
||||
if (!currentAdminGroupIds.has(convId)) {
|
||||
known.delete(convId);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
try {
|
||||
localStorage.setItem(ackKey, JSON.stringify(Array.from(known)));
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
}
|
||||
}, [conversations, user, toast, t, lang]);
|
||||
|
||||
// Mark conversation as read when selected
|
||||
useEffect(() => {
|
||||
if (selectedConvId) {
|
||||
@@ -1535,6 +1608,15 @@ export default function ChatPage() {
|
||||
visibleConvs.map((conv) => {
|
||||
const name = convDisplayName(conv);
|
||||
const lastMsg = conv.lastMessage;
|
||||
const promotionMeta =
|
||||
lastMsg && lastMsg.kind === "admin_promoted"
|
||||
? ((lastMsg.meta ?? {}) as SystemMessageMeta)
|
||||
: null;
|
||||
const newAdminName = promotionMeta
|
||||
? pickDisplayName(promotionMeta.promoted, lang)
|
||||
: "";
|
||||
const isMeNewAdmin =
|
||||
!!promotionMeta?.promoted && promotionMeta.promoted.id === user?.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -1585,6 +1667,26 @@ export default function ChatPage() {
|
||||
aria-label={t("chat.actions.mutedLabel")}
|
||||
/>
|
||||
)}
|
||||
{promotionMeta && (
|
||||
<Badge
|
||||
className={`text-[10px] shrink-0 inline-flex items-center gap-1 ${
|
||||
isMeNewAdmin
|
||||
? "bg-amber-100 text-amber-700 border-amber-300"
|
||||
: "bg-amber-50 text-amber-700 border-amber-200"
|
||||
}`}
|
||||
data-testid={`badge-new-admin-${conv.id}`}
|
||||
aria-label={t("chat.actions.newAdminLabel", {
|
||||
name: isMeNewAdmin
|
||||
? t("chat.actions.youAreAdminTitle")
|
||||
: newAdminName,
|
||||
})}
|
||||
>
|
||||
<Crown size={10} />
|
||||
{isMeNewAdmin
|
||||
? t("chat.actions.youAreAdminTitle")
|
||||
: t("chat.actions.newAdminBadge")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{conv.unreadCount > 0 && !conv.isMuted && (
|
||||
<Badge className="bg-primary/20 text-primary border-primary/30 text-xs shrink-0">
|
||||
|
||||
Reference in New Issue
Block a user