diff --git a/artifacts/api-server/src/routes/conversations.ts b/artifacts/api-server/src/routes/conversations.ts index e4f03d25..03aaf586 100644 --- a/artifacts/api-server/src/routes/conversations.ts +++ b/artifacts/api-server/src/routes/conversations.ts @@ -24,6 +24,7 @@ import { RemoveConversationParticipantParams, UpdateConversationStateParams, UpdateConversationStateBody, + LeaveConversationBody, LeaveConversationParams, } from "@workspace/api-zod"; @@ -631,6 +632,12 @@ router.post( res.status(400).json({ error: params.error.message }); return; } + const body = LeaveConversationBody.safeParse(req.body ?? {}); + if (!body.success) { + res.status(400).json({ error: body.error.message }); + return; + } + const requestedSuccessorId = body.data.successorId ?? null; const userId = req.session.userId!; const [conv] = await db @@ -677,7 +684,23 @@ router.post( // Determine if we need to promote a successor admin. const leaverWasOnlyAdmin = leaver.isAdmin && !remaining.some((p) => p.isAdmin); - const successor = leaverWasOnlyAdmin ? remaining[0] : null; + let successor: (typeof remaining)[number] | null = null; + if (leaverWasOnlyAdmin) { + if (requestedSuccessorId !== null) { + const chosen = remaining.find( + (p) => p.userId === requestedSuccessorId, + ); + if (!chosen) { + res + .status(400) + .json({ error: "Chosen successor is not a member of this group" }); + return; + } + successor = chosen; + } else { + successor = remaining[0]; + } + } if (successor) { await db diff --git a/artifacts/teaboy-os/public/opengraph.jpg b/artifacts/teaboy-os/public/opengraph.jpg index 8c2ac7c4..351b3c72 100644 Binary files a/artifacts/teaboy-os/public/opengraph.jpg and b/artifacts/teaboy-os/public/opengraph.jpg differ diff --git a/artifacts/teaboy-os/src/locales/ar.json b/artifacts/teaboy-os/src/locales/ar.json index 2d0d6b14..c56a7e0c 100644 --- a/artifacts/teaboy-os/src/locales/ar.json +++ b/artifacts/teaboy-os/src/locales/ar.json @@ -174,6 +174,9 @@ "leave": "مغادرة المجموعة", "leaveConfirmTitle": "مغادرة هذه المجموعة؟", "leaveConfirmBody": "ستتم إزالتك من \"{{name}}\" ولن تتلقى رسائلها بعد الآن.", + "successorTitle": "اختر المشرف التالي", + "successorHelp": "أنت المشرف الوحيد. اختر من يتولى الإدارة، أو دعنا نرقّي أقدم عضو في المجموعة.", + "successorAuto": "تلقائي (أقدم عضو)", "muted": "تم كتم الإشعارات", "unmuted": "الإشعارات مفعّلة", "archived": "تمت أرشفة المحادثة", diff --git a/artifacts/teaboy-os/src/locales/en.json b/artifacts/teaboy-os/src/locales/en.json index 601a7bd7..ad6cf18c 100644 --- a/artifacts/teaboy-os/src/locales/en.json +++ b/artifacts/teaboy-os/src/locales/en.json @@ -171,6 +171,9 @@ "leave": "Leave group", "leaveConfirmTitle": "Leave this group?", "leaveConfirmBody": "You will be removed from \"{{name}}\" and stop receiving its messages.", + "successorTitle": "Choose the next admin", + "successorHelp": "You're the only admin. Pick who should take over, or let us promote the longest-tenured member.", + "successorAuto": "Auto (longest-tenured member)", "muted": "Notifications muted", "unmuted": "Notifications on", "archived": "Chat archived", diff --git a/artifacts/teaboy-os/src/pages/chat.tsx b/artifacts/teaboy-os/src/pages/chat.tsx index 279a2537..f8e06e56 100644 --- a/artifacts/teaboy-os/src/pages/chat.tsx +++ b/artifacts/teaboy-os/src/pages/chat.tsx @@ -156,6 +156,7 @@ export default function ChatPage() { const [showActions, setShowActions] = useState(false); const [showArchived, setShowArchived] = useState(false); const [confirmLeave, setConfirmLeave] = useState(false); + const [successorChoice, setSuccessorChoice] = useState("auto"); const [editNameAr, setEditNameAr] = useState(""); const [editNameEn, setEditNameEn] = useState(""); const [addMembersIds, setAddMembersIds] = useState([]); @@ -431,8 +432,12 @@ export default function ChatPage() { const handleLeave = () => { if (!selectedConv) return; + const data = + successorChoice === "auto" + ? {} + : { successorId: successorChoice }; leaveConv.mutate( - { id: selectedConv.id }, + { id: selectedConv.id, data }, { onSuccess: () => { queryClient.invalidateQueries({ queryKey: getListConversationsQueryKey() }); @@ -440,6 +445,7 @@ export default function ChatPage() { setConfirmLeave(false); setShowActions(false); setSelectedConvId(null); + setSuccessorChoice("auto"); }, onError: (err: Error) => toast({ title: t("chat.actions.actionFailed"), description: err.message, variant: "destructive" }), @@ -1266,6 +1272,7 @@ export default function ChatPage() { - +
e.stopPropagation()} + data-testid="dialog-confirm-leave" + > +

+ {t("chat.actions.leaveConfirmTitle")} +

+

+ {t("chat.actions.leaveConfirmBody", { name: convName })} +

+ {showSuccessor && ( +
+

+ {t("chat.actions.successorTitle")} +

+

+ {t("chat.actions.successorHelp")} +

+
+ + {others.map((p) => ( + + ))} +
+
+ )} +
+ + +
- - )} + ); + })()} {selectedConvId ? ( /* Message View */ diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index 0f4a6589..de23440d 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -562,6 +562,14 @@ export interface AdminAppOpensByUser { opens: AppOpenByUserEntry[]; } +export type LeaveConversationBody = { + /** When the leaver is the only admin, optionally name a specific +remaining member to promote. Omit (or send null) to keep the +automatic behavior of promoting the longest-tenured member. + */ + successorId?: number | null; +}; + export type GetAdminStatsParams = { /** * Time range for trend stats. Use "custom" with from/to. diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index 6f4c38af..4eb7a40e 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -37,6 +37,7 @@ import type { GetAdminStatsParams, HealthStatus, HomeStats, + LeaveConversationBody, LoginBody, MessageWithSender, Notification, @@ -2988,11 +2989,14 @@ export const getLeaveConversationUrl = (id: number) => { export const leaveConversation = async ( id: number, + leaveConversationBody?: LeaveConversationBody, options?: RequestInit, ): Promise => { return customFetch(getLeaveConversationUrl(id), { ...options, method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(leaveConversationBody), }); }; @@ -3003,14 +3007,14 @@ export const getLeaveConversationMutationOptions = < mutation?: UseMutationOptions< Awaited>, TError, - { id: number }, + { id: number; data: BodyType }, TContext >; request?: SecondParameter; }): UseMutationOptions< Awaited>, TError, - { id: number }, + { id: number; data: BodyType }, TContext > => { const mutationKey = ["leaveConversation"]; @@ -3024,11 +3028,11 @@ export const getLeaveConversationMutationOptions = < const mutationFn: MutationFunction< Awaited>, - { id: number } + { id: number; data: BodyType } > = (props) => { - const { id } = props ?? {}; + const { id, data } = props ?? {}; - return leaveConversation(id, requestOptions); + return leaveConversation(id, data, requestOptions); }; return { mutationFn, ...mutationOptions }; @@ -3037,7 +3041,7 @@ export const getLeaveConversationMutationOptions = < export type LeaveConversationMutationResult = NonNullable< Awaited> >; - +export type LeaveConversationMutationBody = BodyType; export type LeaveConversationMutationError = ErrorType; /** @@ -3050,14 +3054,14 @@ export const useLeaveConversation = < mutation?: UseMutationOptions< Awaited>, TError, - { id: number }, + { id: number; data: BodyType }, TContext >; request?: SecondParameter; }): UseMutationResult< Awaited>, TError, - { id: number }, + { id: number; data: BodyType }, TContext > => { return useMutation(getLeaveConversationMutationOptions(options)); diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index a7925fe3..29bb4ca4 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -765,6 +765,20 @@ paths: required: true schema: type: integer + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + successorId: + type: integer + description: | + When the leaver is the only admin, optionally name a specific + remaining member to promote. Omit (or send null) to keep the + automatic behavior of promoting the longest-tenured member. + nullable: true responses: "200": description: Left conversation diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index 9dd5c1a3..c83f5d23 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -946,6 +946,15 @@ export const LeaveConversationParams = zod.object({ id: zod.coerce.number(), }); +export const LeaveConversationBody = zod.object({ + successorId: zod + .number() + .nullish() + .describe( + "When the leaver is the only admin, optionally name a specific\nremaining member to promote. Omit (or send null) to keep the\nautomatic behavior of promoting the longest-tenured member.\n", + ), +}); + export const LeaveConversationResponse = zod.object({ success: zod.boolean(), });