Task #27: Let users create named group chats (UI only)
Original ask: in the chat page, give users a clear way to create a
group chat (the "+" button already opened a dialog, but it had no name
field, no Direct/Group distinction, and no way to tell groups apart in
the conversation list).
Backend, DB schema, and OpenAPI already supported groups
(isGroup/nameAr/nameEn/participantIds + isAdmin on creator), so this
task was UI-only.
Changes:
- artifacts/teaboy-os/src/pages/chat.tsx — rebuilt the "New
conversation" dialog with:
* Direct / Group segmented tabs (default Direct)
* Bilingual group name fields (AR + EN, at least one required)
* Search-filterable participant list (matches username,
displayNameAr, displayNameEn)
* Custom checkbox UI; direct mode is single-select (clicking
another user replaces selection), group mode multi-select
* Live participant counter and inline validation messages
(pickOnePerson / minTwoMembers / needGroupName)
* Submit button driven by validation; closes on backdrop click
and resets state on open/close
* Switching to Direct mode clears stale group-name input
- Conversation list rows and chat header now show a "Group" / "مجموعة"
badge for is_group conversations; group avatar uses a Users icon
instead of name initials. Direct chats unchanged.
- New convDisplayName helper: prefers active-language name, falls back
to other language, then participant names, then "Direct Message".
- artifacts/teaboy-os/src/locales/{ar,en}.json — added all new strings
under chat.* (modeDirect, modeGroup, groupNameAr/En, placeholders,
searchUsersPlaceholder, participantsCount, validation.*, create,
noUsersFound). Updated EN groupChat label to compact "Group".
No backend, schema, codegen, or OpenAPI changes. Regenerated api
client locally only because pre-existing pages had stale types from
prior merges.
Verification: e2e test passed — login, open dialog, verify Direct/Group
tabs, validation messages, group creation, header + list badge, and
search filtering all work.
Out of scope (proposed as follow-ups): group avatar upload, manage
members after creation, leave/mute/archive a group.
This commit is contained in:
@@ -98,7 +98,24 @@
|
||||
"groupChat": "مجموعة",
|
||||
"directMessage": "محادثة خاصة",
|
||||
"participants": "المشاركون",
|
||||
"searchUsers": "البحث عن مستخدمين"
|
||||
"searchUsers": "البحث عن مستخدمين",
|
||||
"modeDirect": "محادثة خاصة",
|
||||
"modeGroup": "مجموعة",
|
||||
"groupNameAr": "اسم المجموعة (عربي)",
|
||||
"groupNameEn": "اسم المجموعة (إنجليزي)",
|
||||
"groupNamePlaceholderAr": "مثلاً: فريق المبيعات",
|
||||
"groupNamePlaceholderEn": "e.g. Sales team",
|
||||
"searchUsersPlaceholder": "ابحث بالاسم أو اسم المستخدم",
|
||||
"participantsCount_one": "{{count}} شخص مختار",
|
||||
"participantsCount_other": "{{count}} أشخاص مختارين",
|
||||
"participantsCount_zero": "لم يتم اختيار أحد",
|
||||
"noUsersFound": "لا يوجد مستخدمون مطابقون",
|
||||
"validation": {
|
||||
"needGroupName": "أدخل اسم المجموعة بالعربية أو الإنجليزية.",
|
||||
"minTwoMembers": "اختر شخصين على الأقل لإنشاء مجموعة.",
|
||||
"pickOnePerson": "اختر شخصاً واحداً للمحادثة."
|
||||
},
|
||||
"create": "إنشاء"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "الإشعارات",
|
||||
|
||||
@@ -95,10 +95,27 @@
|
||||
"send": "Send",
|
||||
"noConversations": "No conversations",
|
||||
"createConversation": "New Conversation",
|
||||
"groupChat": "Group Chat",
|
||||
"groupChat": "Group",
|
||||
"directMessage": "Direct Message",
|
||||
"participants": "Participants",
|
||||
"searchUsers": "Search users"
|
||||
"searchUsers": "Search users",
|
||||
"modeDirect": "Direct",
|
||||
"modeGroup": "Group",
|
||||
"groupNameAr": "Group name (Arabic)",
|
||||
"groupNameEn": "Group name (English)",
|
||||
"groupNamePlaceholderAr": "مثلاً: فريق المبيعات",
|
||||
"groupNamePlaceholderEn": "e.g. Sales team",
|
||||
"searchUsersPlaceholder": "Search by name or username",
|
||||
"participantsCount_one": "{{count}} person selected",
|
||||
"participantsCount_other": "{{count}} people selected",
|
||||
"participantsCount_zero": "No one selected",
|
||||
"noUsersFound": "No matching users",
|
||||
"validation": {
|
||||
"needGroupName": "Enter a group name in Arabic or English.",
|
||||
"minTwoMembers": "Pick at least 2 people for a group.",
|
||||
"pickOnePerson": "Pick one person to chat with."
|
||||
},
|
||||
"create": "Create"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "wouter";
|
||||
import {
|
||||
@@ -13,7 +13,16 @@ import {
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { io } from "socket.io-client";
|
||||
import { ArrowRight, ArrowLeft, MessageCircle, Plus, Send } from "lucide-react";
|
||||
import {
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
MessageCircle,
|
||||
Plus,
|
||||
Send,
|
||||
Search,
|
||||
Users,
|
||||
User as UserIcon,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { formatTime } from "@/lib/i18n-format";
|
||||
@@ -29,6 +38,8 @@ function getSocket() {
|
||||
});
|
||||
}
|
||||
|
||||
type ConvMode = "direct" | "group";
|
||||
|
||||
export default function ChatPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [, setLocation] = useLocation();
|
||||
@@ -41,7 +52,11 @@ export default function ChatPage() {
|
||||
const [selectedConvId, setSelectedConvId] = useState<number | null>(null);
|
||||
const [messageText, setMessageText] = useState("");
|
||||
const [showNewConv, setShowNewConv] = useState(false);
|
||||
const [mode, setMode] = useState<ConvMode>("direct");
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
|
||||
const [groupNameAr, setGroupNameAr] = useState("");
|
||||
const [groupNameEn, setGroupNameEn] = useState("");
|
||||
const [userSearch, setUserSearch] = useState("");
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: conversations, isLoading: convsLoading } = useListConversations({
|
||||
@@ -136,29 +151,111 @@ export default function ChatPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const resetNewConvState = () => {
|
||||
setMode("direct");
|
||||
setSelectedUserIds([]);
|
||||
setGroupNameAr("");
|
||||
setGroupNameEn("");
|
||||
setUserSearch("");
|
||||
};
|
||||
|
||||
const openNewConv = () => {
|
||||
resetNewConvState();
|
||||
setShowNewConv(true);
|
||||
};
|
||||
|
||||
const closeNewConv = () => {
|
||||
setShowNewConv(false);
|
||||
resetNewConvState();
|
||||
};
|
||||
|
||||
const switchMode = (next: ConvMode) => {
|
||||
setMode(next);
|
||||
if (next === "direct") {
|
||||
if (selectedUserIds.length > 1) {
|
||||
setSelectedUserIds(selectedUserIds.slice(0, 1));
|
||||
}
|
||||
setGroupNameAr("");
|
||||
setGroupNameEn("");
|
||||
}
|
||||
};
|
||||
|
||||
const toggleUser = (uid: number) => {
|
||||
if (mode === "direct") {
|
||||
setSelectedUserIds(selectedUserIds.includes(uid) ? [] : [uid]);
|
||||
} else {
|
||||
setSelectedUserIds(
|
||||
selectedUserIds.includes(uid)
|
||||
? selectedUserIds.filter((id) => id !== uid)
|
||||
: [...selectedUserIds, uid],
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
const list = (users ?? []).filter((u) => u.id !== user?.id);
|
||||
const q = userSearch.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, userSearch, user?.id]);
|
||||
|
||||
const validation: { ok: boolean; message: string | null } = useMemo(() => {
|
||||
if (mode === "direct") {
|
||||
if (selectedUserIds.length !== 1) {
|
||||
return { ok: false, message: t("chat.validation.pickOnePerson") };
|
||||
}
|
||||
return { ok: true, message: null };
|
||||
}
|
||||
if (selectedUserIds.length < 2) {
|
||||
return { ok: false, message: t("chat.validation.minTwoMembers") };
|
||||
}
|
||||
if (!groupNameAr.trim() && !groupNameEn.trim()) {
|
||||
return { ok: false, message: t("chat.validation.needGroupName") };
|
||||
}
|
||||
return { ok: true, message: null };
|
||||
}, [mode, selectedUserIds, groupNameAr, groupNameEn, t]);
|
||||
|
||||
const handleCreateConv = () => {
|
||||
if (!selectedUserIds.length) return;
|
||||
if (!validation.ok) return;
|
||||
const isGroup = mode === "group";
|
||||
createConv.mutate(
|
||||
{ data: { participantIds: selectedUserIds, isGroup: selectedUserIds.length > 1 } },
|
||||
{
|
||||
data: {
|
||||
participantIds: selectedUserIds,
|
||||
isGroup,
|
||||
nameAr: isGroup ? (groupNameAr.trim() || null) : null,
|
||||
nameEn: isGroup ? (groupNameEn.trim() || null) : null,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: (conv) => {
|
||||
queryClient.invalidateQueries({ queryKey: getListConversationsQueryKey() });
|
||||
setSelectedConvId(conv.id);
|
||||
setShowNewConv(false);
|
||||
setSelectedUserIds([]);
|
||||
closeNewConv();
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const selectedConv = conversations?.find((c) => c.id === selectedConvId);
|
||||
const convName = selectedConv
|
||||
? (lang === "ar" ? selectedConv.nameAr : selectedConv.nameEn) ||
|
||||
selectedConv.participants
|
||||
?.filter((p) => p.id !== user?.id)
|
||||
.map((p) => (lang === "ar" ? p.displayNameAr : p.displayNameEn) ?? p.username)
|
||||
.join(", ")
|
||||
: "";
|
||||
const convDisplayName = (
|
||||
conv: NonNullable<typeof selectedConv>,
|
||||
): string => {
|
||||
const langName = lang === "ar" ? conv.nameAr : conv.nameEn;
|
||||
const otherName = lang === "ar" ? conv.nameEn : conv.nameAr;
|
||||
if (langName) return langName;
|
||||
if (otherName) return otherName;
|
||||
const others = conv.participants
|
||||
?.filter((p) => p.id !== user?.id)
|
||||
.map((p) => (lang === "ar" ? p.displayNameAr : p.displayNameEn) ?? p.username);
|
||||
if (others?.length) return others.join(", ");
|
||||
return t("chat.directMessage");
|
||||
};
|
||||
|
||||
const convName = selectedConv ? convDisplayName(selectedConv) : "";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen os-bg flex flex-col">
|
||||
@@ -170,16 +267,22 @@ export default function ChatPage() {
|
||||
>
|
||||
<BackIcon size={20} />
|
||||
</button>
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<MessageCircle size={20} className="text-green-400" />
|
||||
<h1 className="text-lg font-semibold text-foreground">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
{!selectedConvId && (
|
||||
<button
|
||||
onClick={() => setShowNewConv(true)}
|
||||
onClick={openNewConv}
|
||||
className="p-2 rounded-xl hover:bg-slate-100 text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label={t("chat.createConversation")}
|
||||
>
|
||||
<Plus size={20} />
|
||||
</button>
|
||||
@@ -188,51 +291,178 @@ export default function ChatPage() {
|
||||
|
||||
{/* New Conversation Dialog */}
|
||||
{showNewConv && (
|
||||
<div className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div className="glass-panel rounded-3xl p-6 w-full max-w-sm space-y-4">
|
||||
<h2 className="font-semibold text-foreground">{t("chat.createConversation")}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t("chat.searchUsers")}</p>
|
||||
<ScrollArea className="h-48">
|
||||
<div
|
||||
className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||
onClick={closeNewConv}
|
||||
>
|
||||
<div
|
||||
className="glass-panel rounded-3xl p-6 w-full max-w-md space-y-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="font-semibold text-foreground text-lg">
|
||||
{t("chat.createConversation")}
|
||||
</h2>
|
||||
|
||||
{/* Mode tabs */}
|
||||
<div
|
||||
role="tablist"
|
||||
className="grid grid-cols-2 gap-1 p-1 rounded-2xl bg-slate-100/70"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === "direct"}
|
||||
onClick={() => switchMode("direct")}
|
||||
className={`flex items-center justify-center gap-2 py-2 rounded-xl text-sm font-medium transition-colors ${
|
||||
mode === "direct"
|
||||
? "bg-white text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<UserIcon size={16} />
|
||||
{t("chat.modeDirect")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === "group"}
|
||||
onClick={() => switchMode("group")}
|
||||
className={`flex items-center justify-center gap-2 py-2 rounded-xl text-sm font-medium transition-colors ${
|
||||
mode === "group"
|
||||
? "bg-white text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Users size={16} />
|
||||
{t("chat.modeGroup")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Group name fields */}
|
||||
{mode === "group" && (
|
||||
<div className="space-y-2">
|
||||
{users?.filter((u) => u.id !== user?.id).map((u) => (
|
||||
<label key={u.id} className="flex items-center gap-3 p-2 rounded-xl hover:bg-slate-100 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedUserIds.includes(u.id)}
|
||||
onChange={(e) => {
|
||||
setSelectedUserIds(
|
||||
e.target.checked
|
||||
? [...selectedUserIds, u.id]
|
||||
: selectedUserIds.filter((id) => id !== u.id),
|
||||
);
|
||||
}}
|
||||
className="accent-primary"
|
||||
/>
|
||||
<Avatar className="w-8 h-8">
|
||||
<AvatarFallback className="text-xs bg-primary/20 text-primary">
|
||||
{u.username.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">{u.username}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{lang === "ar" ? u.displayNameAr : u.displayNameEn}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
<Input
|
||||
value={groupNameAr}
|
||||
onChange={(e) => setGroupNameAr(e.target.value)}
|
||||
placeholder={t("chat.groupNamePlaceholderAr")}
|
||||
aria-label={t("chat.groupNameAr")}
|
||||
dir="rtl"
|
||||
className="bg-white/70 border-slate-200"
|
||||
/>
|
||||
<Input
|
||||
value={groupNameEn}
|
||||
onChange={(e) => setGroupNameEn(e.target.value)}
|
||||
placeholder={t("chat.groupNamePlaceholderEn")}
|
||||
aria-label={t("chat.groupNameEn")}
|
||||
dir="ltr"
|
||||
className="bg-white/70 border-slate-200"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search */}
|
||||
<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={userSearch}
|
||||
onChange={(e) => setUserSearch(e.target.value)}
|
||||
placeholder={t("chat.searchUsersPlaceholder")}
|
||||
className={`bg-white/70 border-slate-200 ${isRtl ? "pr-9" : "pl-9"}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Counter */}
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{t("chat.participantsCount", { count: selectedUserIds.length })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* User list */}
|
||||
<ScrollArea className="h-56 -mx-2 px-2">
|
||||
<div className="space-y-1">
|
||||
{filteredUsers.length === 0 ? (
|
||||
<div className="text-center text-sm text-muted-foreground py-8">
|
||||
{t("chat.noUsersFound")}
|
||||
</div>
|
||||
) : (
|
||||
filteredUsers.map((u) => {
|
||||
const checked = selectedUserIds.includes(u.id);
|
||||
const display =
|
||||
(lang === "ar" ? u.displayNameAr : u.displayNameEn) ?? u.username;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={u.id}
|
||||
onClick={() => toggleUser(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>
|
||||
|
||||
{/* Validation message */}
|
||||
{validation.message && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-500">
|
||||
{validation.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" className="flex-1" onClick={() => setShowNewConv(false)}>
|
||||
<Button variant="outline" className="flex-1" onClick={closeNewConv}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={handleCreateConv}
|
||||
disabled={!selectedUserIds.length || createConv.isPending}
|
||||
disabled={!validation.ok || createConv.isPending}
|
||||
>
|
||||
{t("common.add")}
|
||||
{t("chat.create")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -310,14 +540,7 @@ export default function ChatPage() {
|
||||
</div>
|
||||
) : (
|
||||
conversations.map((conv) => {
|
||||
const name =
|
||||
(lang === "ar" ? conv.nameAr : conv.nameEn) ||
|
||||
conv.participants
|
||||
?.filter((p) => p.id !== user?.id)
|
||||
.map((p) => (lang === "ar" ? p.displayNameAr : p.displayNameEn) ?? p.username)
|
||||
.join(", ") ||
|
||||
t("chat.directMessage");
|
||||
|
||||
const name = convDisplayName(conv);
|
||||
const lastMsg = conv.lastMessage;
|
||||
|
||||
return (
|
||||
@@ -328,12 +551,25 @@ export default function ChatPage() {
|
||||
>
|
||||
<Avatar className="w-10 h-10 shrink-0">
|
||||
<AvatarFallback className="bg-primary/20 text-primary font-medium">
|
||||
{String(name).slice(0, 2)}
|
||||
{conv.isGroup ? (
|
||||
<Users size={18} />
|
||||
) : (
|
||||
String(name).slice(0, 2)
|
||||
)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-foreground text-sm truncate">{name}</span>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="font-medium text-foreground text-sm truncate">
|
||||
{name}
|
||||
</span>
|
||||
{conv.isGroup && (
|
||||
<Badge className="bg-primary/15 text-primary border-primary/30 text-[10px] shrink-0">
|
||||
{t("chat.groupChat")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{conv.unreadCount > 0 && (
|
||||
<Badge className="bg-primary/20 text-primary border-primary/30 text-xs shrink-0">
|
||||
{conv.unreadCount}
|
||||
|
||||
Reference in New Issue
Block a user