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:
Riyadh
2026-04-20 16:39:33 +00:00
parent 9d8a2734ad
commit 2480f39b24
3 changed files with 336 additions and 66 deletions
+18 -1
View File
@@ -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": "الإشعارات",
+19 -2
View File
@@ -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",
+299 -63
View File
@@ -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}