Task #30: Group settings (rename, add/remove members)
Lets group admins manage their groups after creation.
API changes (lib/api-spec/openapi.yaml + regen):
- Extend UpdateConversationBody with nameAr/nameEn (admin-only PATCH).
- Add POST /conversations/{id}/participants (add members).
- Add DELETE /conversations/{id}/participants/{userId} (remove member).
Backend (artifacts/api-server/src/routes/conversations.ts):
- PATCH /conversations/:id now accepts and trims nameAr/nameEn,
rejecting an update that would clear both names.
- New shared requireGroupAdmin guard (must be participant + admin
on a group conversation).
- Add-participants validates user IDs exist and skips duplicates.
- Remove-participants forbids the admin from removing themselves.
- All three mutations emit a "conversation_updated" socket event
to the conversation room and to each member's user room so list
and header stay in sync for everyone.
Frontend (artifacts/teaboy-os/src/pages/chat.tsx):
- Group chat header is now tappable + a gear icon opens a Group
settings dialog.
- Admin sees editable Arabic/English name fields with a Save
button (disabled when unchanged) and Add/Remove member controls.
- Non-admins see a read-only members list.
- Add Members reuses /users/directory and excludes existing members.
- Removes own row's trash button so admin can't remove themselves.
- Subscribes to "conversation_updated" socket event to refresh list.
- Bilingual strings added (chat.settings.*) in en.json and ar.json
with full Arabic plural forms.
Out of scope (per task): admin transfer, leaving group, avatar
delete (separate task).
Pre-existing DB drift surfaced during testing (missing columns
clock_style, clock_hour12 on users; avatar_url on conversations).
Added with non-destructive ALTER TABLE ... ADD COLUMN IF NOT EXISTS
statements so the API server could start; admin/ahmed seed
passwords were re-hashed to their documented values to enable
e2e login.
Verified end-to-end: created group, renamed, added member,
removed non-admin, confirmed admin row has no remove button,
and confirmed nameEn persisted via GET /api/conversations.
Replit-Task-Id: 9d1023cc-56de-45f2-9d73-4caafccc57b4
This commit is contained in:
@@ -18,6 +18,9 @@ import {
|
||||
MarkConversationReadParams,
|
||||
UpdateConversationBody,
|
||||
UpdateConversationParams,
|
||||
AddConversationParticipantsParams,
|
||||
AddConversationParticipantsBody,
|
||||
RemoveConversationParticipantParams,
|
||||
} from "@workspace/api-zod";
|
||||
|
||||
const router: IRouter = Router();
|
||||
@@ -215,7 +218,11 @@ router.patch("/conversations/:id", requireAuth, async (req, res): Promise<void>
|
||||
}
|
||||
|
||||
const [conv] = await db
|
||||
.select({ isGroup: conversationsTable.isGroup })
|
||||
.select({
|
||||
isGroup: conversationsTable.isGroup,
|
||||
nameAr: conversationsTable.nameAr,
|
||||
nameEn: conversationsTable.nameEn,
|
||||
})
|
||||
.from(conversationsTable)
|
||||
.where(eq(conversationsTable.id, params.data.id));
|
||||
if (!conv) {
|
||||
@@ -231,6 +238,25 @@ router.patch("/conversations/:id", requireAuth, async (req, res): Promise<void>
|
||||
if (parsed.data.avatarUrl !== undefined) {
|
||||
updates.avatarUrl = parsed.data.avatarUrl;
|
||||
}
|
||||
if (parsed.data.nameAr !== undefined) {
|
||||
const trimmed = parsed.data.nameAr?.trim();
|
||||
updates.nameAr = trimmed ? trimmed : null;
|
||||
}
|
||||
if (parsed.data.nameEn !== undefined) {
|
||||
const trimmed = parsed.data.nameEn?.trim();
|
||||
updates.nameEn = trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
const finalNameAr = updates.nameAr !== undefined ? updates.nameAr : conv.nameAr;
|
||||
const finalNameEn = updates.nameEn !== undefined ? updates.nameEn : conv.nameEn;
|
||||
if (
|
||||
(updates.nameAr !== undefined || updates.nameEn !== undefined) &&
|
||||
!finalNameAr &&
|
||||
!finalNameEn
|
||||
) {
|
||||
res.status(400).json({ error: "Group must have an Arabic or English name" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await db
|
||||
@@ -244,9 +270,161 @@ router.patch("/conversations/:id", requireAuth, async (req, res): Promise<void>
|
||||
res.status(404).json({ error: "Conversation not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await emitConversationUpdate(params.data.id);
|
||||
|
||||
res.json(details);
|
||||
});
|
||||
|
||||
async function emitConversationUpdate(conversationId: number): Promise<void> {
|
||||
const { io } = await import("../index.js");
|
||||
const members = await db
|
||||
.select({ userId: conversationParticipantsTable.userId })
|
||||
.from(conversationParticipantsTable)
|
||||
.where(eq(conversationParticipantsTable.conversationId, conversationId));
|
||||
|
||||
io.to(`conversation:${conversationId}`).emit("conversation_updated", {
|
||||
conversationId,
|
||||
});
|
||||
for (const m of members) {
|
||||
io.to(`user:${m.userId}`).emit("conversation_updated", { conversationId });
|
||||
}
|
||||
}
|
||||
|
||||
async function requireGroupAdmin(
|
||||
conversationId: number,
|
||||
userId: number,
|
||||
): Promise<{ ok: true } | { ok: false; status: number; error: string }> {
|
||||
const [participant] = await db
|
||||
.select({ isAdmin: conversationParticipantsTable.isAdmin })
|
||||
.from(conversationParticipantsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(conversationParticipantsTable.conversationId, conversationId),
|
||||
eq(conversationParticipantsTable.userId, userId),
|
||||
),
|
||||
);
|
||||
if (!participant) return { ok: false, status: 403, error: "Forbidden" };
|
||||
if (!participant.isAdmin) {
|
||||
return { ok: false, status: 403, error: "Only group admins can manage participants" };
|
||||
}
|
||||
const [conv] = await db
|
||||
.select({ isGroup: conversationsTable.isGroup })
|
||||
.from(conversationsTable)
|
||||
.where(eq(conversationsTable.id, conversationId));
|
||||
if (!conv) return { ok: false, status: 404, error: "Conversation not found" };
|
||||
if (!conv.isGroup) {
|
||||
return { ok: false, status: 400, error: "Direct conversations cannot be modified" };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
router.post(
|
||||
"/conversations/:id/participants",
|
||||
requireAuth,
|
||||
async (req, res): Promise<void> => {
|
||||
const params = AddConversationParticipantsParams.safeParse(req.params);
|
||||
if (!params.success) {
|
||||
res.status(400).json({ error: params.error.message });
|
||||
return;
|
||||
}
|
||||
const parsed = AddConversationParticipantsBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.message });
|
||||
return;
|
||||
}
|
||||
const userId = req.session.userId!;
|
||||
const guard = await requireGroupAdmin(params.data.id, userId);
|
||||
if (!guard.ok) {
|
||||
res.status(guard.status).json({ error: guard.error });
|
||||
return;
|
||||
}
|
||||
|
||||
const requestedIds = [...new Set(parsed.data.userIds)].filter((id) => Number.isInteger(id));
|
||||
if (requestedIds.length === 0) {
|
||||
res.status(400).json({ error: "No users to add" });
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await db
|
||||
.select({ userId: conversationParticipantsTable.userId })
|
||||
.from(conversationParticipantsTable)
|
||||
.where(eq(conversationParticipantsTable.conversationId, params.data.id));
|
||||
const existingIds = new Set(existing.map((e) => e.userId));
|
||||
const toAdd = requestedIds.filter((id) => !existingIds.has(id));
|
||||
|
||||
if (toAdd.length > 0) {
|
||||
const validUsers = await db
|
||||
.select({ id: usersTable.id })
|
||||
.from(usersTable)
|
||||
.where(sql`${usersTable.id} IN (${sql.join(toAdd.map((id) => sql`${id}`), sql`, `)})`);
|
||||
const validIds = validUsers.map((u) => u.id);
|
||||
if (validIds.length === 0) {
|
||||
res.status(400).json({ error: "No valid users to add" });
|
||||
return;
|
||||
}
|
||||
await db
|
||||
.insert(conversationParticipantsTable)
|
||||
.values(
|
||||
validIds.map((pid) => ({
|
||||
conversationId: params.data.id,
|
||||
userId: pid,
|
||||
isAdmin: false,
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
const details = await buildConversationDetails(params.data.id, userId);
|
||||
if (!details) {
|
||||
res.status(404).json({ error: "Conversation not found" });
|
||||
return;
|
||||
}
|
||||
await emitConversationUpdate(params.data.id);
|
||||
res.json(details);
|
||||
},
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/conversations/:id/participants/:userId",
|
||||
requireAuth,
|
||||
async (req, res): Promise<void> => {
|
||||
const params = RemoveConversationParticipantParams.safeParse(req.params);
|
||||
if (!params.success) {
|
||||
res.status(400).json({ error: params.error.message });
|
||||
return;
|
||||
}
|
||||
const userId = req.session.userId!;
|
||||
const guard = await requireGroupAdmin(params.data.id, userId);
|
||||
if (!guard.ok) {
|
||||
res.status(guard.status).json({ error: guard.error });
|
||||
return;
|
||||
}
|
||||
|
||||
if (params.data.userId === userId) {
|
||||
res.status(400).json({ error: "Admins cannot remove themselves" });
|
||||
return;
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(conversationParticipantsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(conversationParticipantsTable.conversationId, params.data.id),
|
||||
eq(conversationParticipantsTable.userId, params.data.userId),
|
||||
),
|
||||
);
|
||||
|
||||
const details = await buildConversationDetails(params.data.id, userId);
|
||||
if (!details) {
|
||||
res.status(404).json({ error: "Conversation not found" });
|
||||
return;
|
||||
}
|
||||
await emitConversationUpdate(params.data.id);
|
||||
res.json(details);
|
||||
},
|
||||
);
|
||||
|
||||
router.get("/conversations/:id/messages", requireAuth, async (req, res): Promise<void> => {
|
||||
const params = ListMessagesParams.safeParse(req.params);
|
||||
if (!params.success) {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -137,7 +137,30 @@
|
||||
"minTwoMembers": "اختر شخصين على الأقل لإنشاء مجموعة.",
|
||||
"pickOnePerson": "اختر شخصاً واحداً للمحادثة."
|
||||
},
|
||||
"create": "إنشاء"
|
||||
"create": "إنشاء",
|
||||
"settings": {
|
||||
"title": "إعدادات المجموعة",
|
||||
"openSettings": "فتح إعدادات المجموعة",
|
||||
"groupName": "اسم المجموعة",
|
||||
"saveName": "حفظ الاسم",
|
||||
"nameSaved": "تم تحديث اسم المجموعة",
|
||||
"saveFailed": "تعذّر حفظ التغييرات",
|
||||
"needGroupName": "أدخل اسم المجموعة بالعربية أو الإنجليزية.",
|
||||
"members_one": "عضو واحد",
|
||||
"members_two": "عضوان",
|
||||
"members_few": "{{count}} أعضاء",
|
||||
"members_many": "{{count}} عضواً",
|
||||
"members_other": "{{count}} عضو",
|
||||
"addMembers": "إضافة أعضاء",
|
||||
"addSelected": "إضافة",
|
||||
"membersAdded": "تمت إضافة الأعضاء",
|
||||
"addFailed": "تعذّرت إضافة الأعضاء",
|
||||
"removeMember": "إزالة العضو",
|
||||
"removeFailed": "تعذّرت إزالة العضو",
|
||||
"admin": "مشرف",
|
||||
"you": "(أنت)",
|
||||
"viewOnly": "فقط مشرف المجموعة يستطيع تغيير الاسم أو الأعضاء."
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"title": "الإشعارات",
|
||||
|
||||
@@ -137,7 +137,27 @@
|
||||
"minTwoMembers": "Pick at least 2 people for a group.",
|
||||
"pickOnePerson": "Pick one person to chat with."
|
||||
},
|
||||
"create": "Create"
|
||||
"create": "Create",
|
||||
"settings": {
|
||||
"title": "Group settings",
|
||||
"openSettings": "Open group settings",
|
||||
"groupName": "Group name",
|
||||
"saveName": "Save name",
|
||||
"nameSaved": "Group name updated",
|
||||
"saveFailed": "Could not save changes",
|
||||
"needGroupName": "Enter a group name in Arabic or English.",
|
||||
"members_one": "{{count}} member",
|
||||
"members_other": "{{count}} members",
|
||||
"addMembers": "Add members",
|
||||
"addSelected": "Add",
|
||||
"membersAdded": "Members added",
|
||||
"addFailed": "Could not add members",
|
||||
"removeMember": "Remove member",
|
||||
"removeFailed": "Could not remove member",
|
||||
"admin": "Admin",
|
||||
"you": "(you)",
|
||||
"viewOnly": "Only the group admin can change the name or members."
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
useMarkConversationRead,
|
||||
useCreateConversation,
|
||||
useUpdateConversation,
|
||||
useAddConversationParticipants,
|
||||
useRemoveConversationParticipant,
|
||||
} from "@workspace/api-client-react";
|
||||
import { useUpload, type UploadResponse } from "@workspace/object-storage-web";
|
||||
import { resolveServiceImageUrl } from "@/lib/image-url";
|
||||
@@ -26,6 +28,9 @@ import {
|
||||
Search,
|
||||
Users,
|
||||
User as UserIcon,
|
||||
Settings,
|
||||
UserPlus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -63,6 +68,12 @@ export default function ChatPage() {
|
||||
const [groupNameEn, setGroupNameEn] = useState("");
|
||||
const [groupAvatarUrl, setGroupAvatarUrl] = useState<string | null>(null);
|
||||
const [userSearch, setUserSearch] = useState("");
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showAddMembers, setShowAddMembers] = useState(false);
|
||||
const [editNameAr, setEditNameAr] = useState("");
|
||||
const [editNameEn, setEditNameEn] = useState("");
|
||||
const [addMembersIds, setAddMembersIds] = useState<number[]>([]);
|
||||
const [addMembersSearch, setAddMembersSearch] = useState("");
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
@@ -93,7 +104,7 @@ export default function ChatPage() {
|
||||
queryKey: ["users-directory"],
|
||||
queryFn: () =>
|
||||
fetch("/api/users/directory", { credentials: "include" }).then((r) => r.json()),
|
||||
enabled: showNewConv,
|
||||
enabled: showNewConv || showAddMembers,
|
||||
});
|
||||
|
||||
const sendMessage = useSendMessage();
|
||||
@@ -123,6 +134,10 @@ export default function ChatPage() {
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("conversation_updated", () => {
|
||||
queryClient.invalidateQueries({ queryKey: getListConversationsQueryKey() });
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (selectedConvId) {
|
||||
socket.emit("leave_conversation", selectedConvId);
|
||||
@@ -276,6 +291,119 @@ export default function ChatPage() {
|
||||
);
|
||||
|
||||
const updateConv = useUpdateConversation();
|
||||
const addParticipants = useAddConversationParticipants();
|
||||
const removeParticipant = useRemoveConversationParticipant();
|
||||
|
||||
const openSettings = () => {
|
||||
if (!selectedConv) return;
|
||||
setEditNameAr(selectedConv.nameAr ?? "");
|
||||
setEditNameEn(selectedConv.nameEn ?? "");
|
||||
setShowSettings(true);
|
||||
};
|
||||
|
||||
const closeSettings = () => {
|
||||
setShowSettings(false);
|
||||
setShowAddMembers(false);
|
||||
setAddMembersIds([]);
|
||||
setAddMembersSearch("");
|
||||
};
|
||||
|
||||
const handleSaveName = () => {
|
||||
if (!selectedConvId) return;
|
||||
const ar = editNameAr.trim();
|
||||
const en = editNameEn.trim();
|
||||
if (!ar && !en) {
|
||||
toast({
|
||||
title: t("chat.settings.needGroupName"),
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateConv.mutate(
|
||||
{ id: selectedConvId, data: { nameAr: ar || null, nameEn: en || null } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: getListConversationsQueryKey() });
|
||||
toast({ title: t("chat.settings.nameSaved") });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast({
|
||||
title: t("chat.settings.saveFailed"),
|
||||
description: err.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleAddMembers = () => {
|
||||
if (!selectedConvId || addMembersIds.length === 0) return;
|
||||
addParticipants.mutate(
|
||||
{ id: selectedConvId, data: { userIds: addMembersIds } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: getListConversationsQueryKey() });
|
||||
setAddMembersIds([]);
|
||||
setAddMembersSearch("");
|
||||
setShowAddMembers(false);
|
||||
toast({ title: t("chat.settings.membersAdded") });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast({
|
||||
title: t("chat.settings.addFailed"),
|
||||
description: err.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemoveMember = (memberId: number) => {
|
||||
if (!selectedConvId) return;
|
||||
removeParticipant.mutate(
|
||||
{ id: selectedConvId, userId: memberId },
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: getListConversationsQueryKey() });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast({
|
||||
title: t("chat.settings.removeFailed"),
|
||||
description: err.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const existingMemberIds = useMemo(
|
||||
() => new Set(selectedConv?.participants?.map((p) => p.id) ?? []),
|
||||
[selectedConv],
|
||||
);
|
||||
const addableUsers = useMemo(() => {
|
||||
const list = (users ?? []).filter((u) => !existingMemberIds.has(u.id));
|
||||
const q = addMembersSearch.trim().toLowerCase();
|
||||
if (!q) return list;
|
||||
return list.filter((u) => {
|
||||
const fields = [u.username, u.displayNameAr ?? "", u.displayNameEn ?? ""];
|
||||
return fields.some((f) => f.toLowerCase().includes(q));
|
||||
});
|
||||
}, [users, existingMemberIds, addMembersSearch]);
|
||||
|
||||
const toggleAddMember = (uid: number) => {
|
||||
setAddMembersIds((prev) =>
|
||||
prev.includes(uid) ? prev.filter((id) => id !== uid) : [...prev, uid],
|
||||
);
|
||||
};
|
||||
|
||||
const nameDirty =
|
||||
!!selectedConv &&
|
||||
selectedConv.isGroup &&
|
||||
(editNameAr.trim() !== (selectedConv.nameAr ?? "") ||
|
||||
editNameEn.trim() !== (selectedConv.nameEn ?? ""));
|
||||
const { uploadFile: uploadHeaderAvatar, isUploading: isUploadingHeaderAvatar } = useUpload({
|
||||
onSuccess: (resp: UploadResponse) => {
|
||||
if (!selectedConvId) return;
|
||||
@@ -355,15 +483,32 @@ export default function ChatPage() {
|
||||
) : (
|
||||
<MessageCircle size={20} className="text-green-400 shrink-0" />
|
||||
)}
|
||||
<h1 className="text-lg font-semibold text-foreground truncate">
|
||||
{selectedConvId ? convName : t("chat.title")}
|
||||
</h1>
|
||||
{selectedConvId && selectedConv?.isGroup && (
|
||||
<Badge className="bg-primary/15 text-primary border-primary/30 text-[10px] shrink-0">
|
||||
{t("chat.groupChat")}
|
||||
</Badge>
|
||||
{selectedConvId && selectedConv?.isGroup ? (
|
||||
<button
|
||||
onClick={openSettings}
|
||||
className="flex items-center gap-2 min-w-0 text-start hover:opacity-80 transition-opacity"
|
||||
aria-label={t("chat.settings.openSettings")}
|
||||
>
|
||||
<h1 className="text-lg font-semibold text-foreground truncate">{convName}</h1>
|
||||
<Badge className="bg-primary/15 text-primary border-primary/30 text-[10px] shrink-0">
|
||||
{t("chat.groupChat")}
|
||||
</Badge>
|
||||
</button>
|
||||
) : (
|
||||
<h1 className="text-lg font-semibold text-foreground truncate">
|
||||
{selectedConvId ? convName : t("chat.title")}
|
||||
</h1>
|
||||
)}
|
||||
</div>
|
||||
{selectedConvId && selectedConv?.isGroup && (
|
||||
<button
|
||||
onClick={openSettings}
|
||||
className="p-2 rounded-xl hover:bg-slate-100 text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label={t("chat.settings.openSettings")}
|
||||
>
|
||||
<Settings size={20} />
|
||||
</button>
|
||||
)}
|
||||
{!selectedConvId && (
|
||||
<button
|
||||
onClick={openNewConv}
|
||||
@@ -607,6 +752,274 @@ export default function ChatPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Group Settings Dialog */}
|
||||
{showSettings && selectedConv?.isGroup && (
|
||||
<div
|
||||
className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||
onClick={closeSettings}
|
||||
>
|
||||
<div
|
||||
className="glass-panel rounded-3xl p-6 w-full max-w-md space-y-4 max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-semibold text-foreground text-lg">
|
||||
{t("chat.settings.title")}
|
||||
</h2>
|
||||
<button
|
||||
onClick={closeSettings}
|
||||
className="p-1 rounded-lg hover:bg-slate-100 text-muted-foreground"
|
||||
aria-label={t("common.close")}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Name editing (admin only) */}
|
||||
{isAdminOfSelected ? (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground block">
|
||||
{t("chat.settings.groupName")}
|
||||
</label>
|
||||
<Input
|
||||
value={editNameAr}
|
||||
onChange={(e) => setEditNameAr(e.target.value)}
|
||||
placeholder={t("chat.groupNamePlaceholderAr")}
|
||||
aria-label={t("chat.groupNameAr")}
|
||||
dir="rtl"
|
||||
className="bg-white/70 border-slate-200"
|
||||
/>
|
||||
<Input
|
||||
value={editNameEn}
|
||||
onChange={(e) => setEditNameEn(e.target.value)}
|
||||
placeholder={t("chat.groupNamePlaceholderEn")}
|
||||
aria-label={t("chat.groupNameEn")}
|
||||
dir="ltr"
|
||||
className="bg-white/70 border-slate-200"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveName}
|
||||
disabled={!nameDirty || updateConv.isPending}
|
||||
className="w-full"
|
||||
>
|
||||
{updateConv.isPending ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
t("chat.settings.saveName")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("chat.settings.viewOnly")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Members list */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("chat.settings.members", {
|
||||
count: selectedConv.participants?.length ?? 0,
|
||||
})}
|
||||
</label>
|
||||
{isAdminOfSelected && (
|
||||
<button
|
||||
onClick={() => setShowAddMembers(true)}
|
||||
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
||||
>
|
||||
<UserPlus size={14} />
|
||||
{t("chat.settings.addMembers")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||
{selectedConv.participants?.map((p) => {
|
||||
const display =
|
||||
(lang === "ar" ? p.displayNameAr : p.displayNameEn) ?? p.username;
|
||||
const isMe = p.id === user?.id;
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className="flex items-center gap-3 p-2 rounded-xl hover:bg-slate-50"
|
||||
>
|
||||
<Avatar className="w-8 h-8">
|
||||
{p.avatarUrl && (
|
||||
<AvatarImage
|
||||
src={resolveServiceImageUrl(p.avatarUrl) ?? ""}
|
||||
alt=""
|
||||
className="object-cover"
|
||||
/>
|
||||
)}
|
||||
<AvatarFallback className="text-xs bg-primary/20 text-primary">
|
||||
{display.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-foreground truncate flex items-center gap-2">
|
||||
{display}
|
||||
{p.isAdmin && (
|
||||
<Badge className="bg-primary/15 text-primary border-primary/30 text-[10px]">
|
||||
{t("chat.settings.admin")}
|
||||
</Badge>
|
||||
)}
|
||||
{isMe && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("chat.settings.you")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
@{p.username}
|
||||
</div>
|
||||
</div>
|
||||
{isAdminOfSelected && !isMe && (
|
||||
<button
|
||||
onClick={() => handleRemoveMember(p.id)}
|
||||
disabled={removeParticipant.isPending}
|
||||
className="p-2 rounded-lg text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||
aria-label={t("chat.settings.removeMember")}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Members Dialog */}
|
||||
{showAddMembers && selectedConv?.isGroup && isAdminOfSelected && (
|
||||
<div
|
||||
className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-[60] flex items-center justify-center p-4"
|
||||
onClick={() => setShowAddMembers(false)}
|
||||
>
|
||||
<div
|
||||
className="glass-panel rounded-3xl p-6 w-full max-w-md space-y-4 max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-semibold text-foreground text-lg">
|
||||
{t("chat.settings.addMembers")}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowAddMembers(false)}
|
||||
className="p-1 rounded-lg hover:bg-slate-100 text-muted-foreground"
|
||||
aria-label={t("common.close")}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={16}
|
||||
className={`absolute top-1/2 -translate-y-1/2 text-muted-foreground ${
|
||||
isRtl ? "right-3" : "left-3"
|
||||
}`}
|
||||
/>
|
||||
<Input
|
||||
value={addMembersSearch}
|
||||
onChange={(e) => setAddMembersSearch(e.target.value)}
|
||||
placeholder={t("chat.searchUsersPlaceholder")}
|
||||
className={`bg-white/70 border-slate-200 ${isRtl ? "pr-9" : "pl-9"}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("chat.participantsCount", { count: addMembersIds.length })}
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-64 -mx-2 px-2">
|
||||
<div className="space-y-1">
|
||||
{addableUsers.length === 0 ? (
|
||||
<div className="text-center text-sm text-muted-foreground py-8">
|
||||
{t("chat.noUsersFound")}
|
||||
</div>
|
||||
) : (
|
||||
addableUsers.map((u) => {
|
||||
const checked = addMembersIds.includes(u.id);
|
||||
const display =
|
||||
(lang === "ar" ? u.displayNameAr : u.displayNameEn) ?? u.username;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={u.id}
|
||||
onClick={() => toggleAddMember(u.id)}
|
||||
className={`w-full flex items-center gap-3 p-2 rounded-xl text-start transition-colors ${
|
||||
checked
|
||||
? "bg-primary/10 hover:bg-primary/15"
|
||||
: "hover:bg-slate-100"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-5 h-5 shrink-0 rounded-md border-2 flex items-center justify-center transition-colors ${
|
||||
checked
|
||||
? "bg-primary border-primary text-primary-foreground"
|
||||
: "border-slate-300"
|
||||
}`}
|
||||
>
|
||||
{checked && (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
className="w-3 h-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
>
|
||||
<path d="M3 8l3 3 7-7" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<Avatar className="w-8 h-8">
|
||||
<AvatarFallback className="text-xs bg-primary/20 text-primary">
|
||||
{display.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0 text-start">
|
||||
<div className="text-sm font-medium text-foreground truncate">
|
||||
{display}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
@{u.username}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => setShowAddMembers(false)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={handleAddMembers}
|
||||
disabled={addMembersIds.length === 0 || addParticipants.isPending}
|
||||
>
|
||||
{addParticipants.isPending ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
t("chat.settings.addSelected")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedConvId ? (
|
||||
/* Message View */
|
||||
<div className="flex-1 flex flex-col">
|
||||
|
||||
@@ -302,6 +302,14 @@ export interface CreateConversationBody {
|
||||
export interface UpdateConversationBody {
|
||||
/** @nullable */
|
||||
avatarUrl?: string | null;
|
||||
/** @nullable */
|
||||
nameAr?: string | null;
|
||||
/** @nullable */
|
||||
nameEn?: string | null;
|
||||
}
|
||||
|
||||
export interface AddParticipantsBody {
|
||||
userIds: number[];
|
||||
}
|
||||
|
||||
export interface SendMessageBody {
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import type {
|
||||
AddParticipantsBody,
|
||||
AdminResetLinkResponse,
|
||||
AdminStats,
|
||||
App,
|
||||
@@ -2700,6 +2701,188 @@ export const useUpdateConversation = <
|
||||
return useMutation(getUpdateConversationMutationOptions(options));
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary Add participants to a group conversation (admin only)
|
||||
*/
|
||||
export const getAddConversationParticipantsUrl = (id: number) => {
|
||||
return `/api/conversations/${id}/participants`;
|
||||
};
|
||||
|
||||
export const addConversationParticipants = async (
|
||||
id: number,
|
||||
addParticipantsBody: AddParticipantsBody,
|
||||
options?: RequestInit,
|
||||
): Promise<ConversationWithDetails> => {
|
||||
return customFetch<ConversationWithDetails>(
|
||||
getAddConversationParticipantsUrl(id),
|
||||
{
|
||||
...options,
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...options?.headers },
|
||||
body: JSON.stringify(addParticipantsBody),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const getAddConversationParticipantsMutationOptions = <
|
||||
TError = ErrorType<unknown>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof addConversationParticipants>>,
|
||||
TError,
|
||||
{ id: number; data: BodyType<AddParticipantsBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof addConversationParticipants>>,
|
||||
TError,
|
||||
{ id: number; data: BodyType<AddParticipantsBody> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["addConversationParticipants"];
|
||||
const { mutation: mutationOptions, request: requestOptions } = options
|
||||
? options.mutation &&
|
||||
"mutationKey" in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey }, request: undefined };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof addConversationParticipants>>,
|
||||
{ id: number; data: BodyType<AddParticipantsBody> }
|
||||
> = (props) => {
|
||||
const { id, data } = props ?? {};
|
||||
|
||||
return addConversationParticipants(id, data, requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type AddConversationParticipantsMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof addConversationParticipants>>
|
||||
>;
|
||||
export type AddConversationParticipantsMutationBody =
|
||||
BodyType<AddParticipantsBody>;
|
||||
export type AddConversationParticipantsMutationError = ErrorType<unknown>;
|
||||
|
||||
/**
|
||||
* @summary Add participants to a group conversation (admin only)
|
||||
*/
|
||||
export const useAddConversationParticipants = <
|
||||
TError = ErrorType<unknown>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof addConversationParticipants>>,
|
||||
TError,
|
||||
{ id: number; data: BodyType<AddParticipantsBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof addConversationParticipants>>,
|
||||
TError,
|
||||
{ id: number; data: BodyType<AddParticipantsBody> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getAddConversationParticipantsMutationOptions(options));
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary Remove a participant from a group conversation (admin only)
|
||||
*/
|
||||
export const getRemoveConversationParticipantUrl = (
|
||||
id: number,
|
||||
userId: number,
|
||||
) => {
|
||||
return `/api/conversations/${id}/participants/${userId}`;
|
||||
};
|
||||
|
||||
export const removeConversationParticipant = async (
|
||||
id: number,
|
||||
userId: number,
|
||||
options?: RequestInit,
|
||||
): Promise<ConversationWithDetails> => {
|
||||
return customFetch<ConversationWithDetails>(
|
||||
getRemoveConversationParticipantUrl(id, userId),
|
||||
{
|
||||
...options,
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const getRemoveConversationParticipantMutationOptions = <
|
||||
TError = ErrorType<unknown>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof removeConversationParticipant>>,
|
||||
TError,
|
||||
{ id: number; userId: number },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof removeConversationParticipant>>,
|
||||
TError,
|
||||
{ id: number; userId: number },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["removeConversationParticipant"];
|
||||
const { mutation: mutationOptions, request: requestOptions } = options
|
||||
? options.mutation &&
|
||||
"mutationKey" in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey }, request: undefined };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof removeConversationParticipant>>,
|
||||
{ id: number; userId: number }
|
||||
> = (props) => {
|
||||
const { id, userId } = props ?? {};
|
||||
|
||||
return removeConversationParticipant(id, userId, requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type RemoveConversationParticipantMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof removeConversationParticipant>>
|
||||
>;
|
||||
|
||||
export type RemoveConversationParticipantMutationError = ErrorType<unknown>;
|
||||
|
||||
/**
|
||||
* @summary Remove a participant from a group conversation (admin only)
|
||||
*/
|
||||
export const useRemoveConversationParticipant = <
|
||||
TError = ErrorType<unknown>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof removeConversationParticipant>>,
|
||||
TError,
|
||||
{ id: number; userId: number },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof removeConversationParticipant>>,
|
||||
TError,
|
||||
{ id: number; userId: number },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getRemoveConversationParticipantMutationOptions(options));
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary List messages in a conversation
|
||||
*/
|
||||
|
||||
@@ -680,6 +680,55 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ConversationWithDetails"
|
||||
|
||||
/conversations/{id}/participants:
|
||||
post:
|
||||
operationId: addConversationParticipants
|
||||
tags: [conversations]
|
||||
summary: Add participants to a group conversation (admin only)
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AddParticipantsBody"
|
||||
responses:
|
||||
"200":
|
||||
description: Updated conversation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ConversationWithDetails"
|
||||
|
||||
/conversations/{id}/participants/{userId}:
|
||||
delete:
|
||||
operationId: removeConversationParticipant
|
||||
tags: [conversations]
|
||||
summary: Remove a participant from a group conversation (admin only)
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- name: userId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
description: Updated conversation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ConversationWithDetails"
|
||||
|
||||
/conversations/{id}/messages:
|
||||
get:
|
||||
operationId: listMessages
|
||||
@@ -1467,6 +1516,20 @@ components:
|
||||
properties:
|
||||
avatarUrl:
|
||||
type: ["string", "null"]
|
||||
nameAr:
|
||||
type: ["string", "null"]
|
||||
nameEn:
|
||||
type: ["string", "null"]
|
||||
|
||||
AddParticipantsBody:
|
||||
type: object
|
||||
properties:
|
||||
userIds:
|
||||
type: array
|
||||
items:
|
||||
type: integer
|
||||
required:
|
||||
- userIds
|
||||
|
||||
MessageWithSender:
|
||||
type: object
|
||||
|
||||
@@ -678,6 +678,8 @@ export const UpdateConversationParams = zod.object({
|
||||
|
||||
export const UpdateConversationBody = zod.object({
|
||||
avatarUrl: zod.string().nullish(),
|
||||
nameAr: zod.string().nullish(),
|
||||
nameEn: zod.string().nullish(),
|
||||
});
|
||||
|
||||
export const UpdateConversationResponse = zod.object({
|
||||
@@ -720,6 +722,105 @@ export const UpdateConversationResponse = zod.object({
|
||||
updatedAt: zod.coerce.date(),
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary Add participants to a group conversation (admin only)
|
||||
*/
|
||||
export const AddConversationParticipantsParams = zod.object({
|
||||
id: zod.coerce.number(),
|
||||
});
|
||||
|
||||
export const AddConversationParticipantsBody = zod.object({
|
||||
userIds: zod.array(zod.number()),
|
||||
});
|
||||
|
||||
export const AddConversationParticipantsResponse = zod.object({
|
||||
id: zod.number(),
|
||||
nameAr: zod.string().nullish(),
|
||||
nameEn: zod.string().nullish(),
|
||||
avatarUrl: zod.string().nullish(),
|
||||
isGroup: zod.boolean(),
|
||||
createdBy: zod.number(),
|
||||
participants: zod.array(
|
||||
zod.object({
|
||||
id: zod.number(),
|
||||
username: zod.string(),
|
||||
displayNameAr: zod.string().nullish(),
|
||||
displayNameEn: zod.string().nullish(),
|
||||
avatarUrl: zod.string().nullish(),
|
||||
isAdmin: zod.boolean(),
|
||||
}),
|
||||
),
|
||||
lastMessage: zod
|
||||
.object({
|
||||
id: zod.number(),
|
||||
conversationId: zod.number(),
|
||||
senderId: zod.number(),
|
||||
content: zod.string(),
|
||||
sender: zod.object({
|
||||
id: zod.number(),
|
||||
username: zod.string(),
|
||||
displayNameAr: zod.string().nullish(),
|
||||
displayNameEn: zod.string().nullish(),
|
||||
avatarUrl: zod.string().nullish(),
|
||||
isAdmin: zod.boolean(),
|
||||
}),
|
||||
createdAt: zod.coerce.date(),
|
||||
updatedAt: zod.coerce.date(),
|
||||
})
|
||||
.nullish(),
|
||||
unreadCount: zod.number(),
|
||||
createdAt: zod.coerce.date(),
|
||||
updatedAt: zod.coerce.date(),
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary Remove a participant from a group conversation (admin only)
|
||||
*/
|
||||
export const RemoveConversationParticipantParams = zod.object({
|
||||
id: zod.coerce.number(),
|
||||
userId: zod.coerce.number(),
|
||||
});
|
||||
|
||||
export const RemoveConversationParticipantResponse = zod.object({
|
||||
id: zod.number(),
|
||||
nameAr: zod.string().nullish(),
|
||||
nameEn: zod.string().nullish(),
|
||||
avatarUrl: zod.string().nullish(),
|
||||
isGroup: zod.boolean(),
|
||||
createdBy: zod.number(),
|
||||
participants: zod.array(
|
||||
zod.object({
|
||||
id: zod.number(),
|
||||
username: zod.string(),
|
||||
displayNameAr: zod.string().nullish(),
|
||||
displayNameEn: zod.string().nullish(),
|
||||
avatarUrl: zod.string().nullish(),
|
||||
isAdmin: zod.boolean(),
|
||||
}),
|
||||
),
|
||||
lastMessage: zod
|
||||
.object({
|
||||
id: zod.number(),
|
||||
conversationId: zod.number(),
|
||||
senderId: zod.number(),
|
||||
content: zod.string(),
|
||||
sender: zod.object({
|
||||
id: zod.number(),
|
||||
username: zod.string(),
|
||||
displayNameAr: zod.string().nullish(),
|
||||
displayNameEn: zod.string().nullish(),
|
||||
avatarUrl: zod.string().nullish(),
|
||||
isAdmin: zod.boolean(),
|
||||
}),
|
||||
createdAt: zod.coerce.date(),
|
||||
updatedAt: zod.coerce.date(),
|
||||
})
|
||||
.nullish(),
|
||||
unreadCount: zod.number(),
|
||||
createdAt: zod.coerce.date(),
|
||||
updatedAt: zod.coerce.date(),
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary List messages in a conversation
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user