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:
riyadhafraa
2026-04-21 11:53:59 +00:00
parent 216ff65e04
commit 42c2910d4b
3 changed files with 112 additions and 2 deletions
+5 -1
View File
@@ -183,7 +183,11 @@
"unarchived": "أعيدت المحادثة إلى النشطة",
"left": "غادرت المجموعة",
"actionFailed": "تعذّر تنفيذ الإجراء",
"mutedLabel": "مكتومة"
"mutedLabel": "مكتومة",
"youAreAdminTitle": "أنت الآن المشرف",
"youAreAdminDescription": "أصبحت مسؤولًا عن \"{{name}}\".",
"newAdminBadge": "مشرف جديد",
"newAdminLabel": "مشرف جديد: {{name}}"
},
"system": {
"someone": "شخص ما",
+5 -1
View File
@@ -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",
+102
View File
@@ -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">