Task #29: Give each group chat its own picture

Adds upload + display of a custom avatar for group conversations.

Changes:
- DB: Added `avatar_url text` (nullable) to `conversations` table.
  Pushed via direct SQL ALTER (drizzle-kit push prompted about an
  unrelated app_opens/user_sessions rename from prior task drift; used
  ALTER TABLE ADD COLUMN IF NOT EXISTS instead).
- OpenAPI (`lib/api-spec/openapi.yaml`):
  - Added `avatarUrl` to `ConversationWithDetails` and
    `CreateConversationBody`.
  - Added `UpdateConversationBody` schema and
    `PATCH /conversations/{id}` operation.
  - Regenerated `@workspace/api-zod` and `@workspace/api-client-react`.
- API (`artifacts/api-server/src/routes/conversations.ts`):
  - Persist `avatarUrl` on create.
  - New `PATCH /conversations/:id` (admin-only) to update `avatarUrl`.
- Web (`artifacts/teaboy-os/src/pages/chat.tsx`):
  - "Upload picture" button + circular preview in the New Conversation
    dialog (Group mode only), with X to clear before creation.
  - Conversation list & chat header now render the group's image
    avatar via `resolveServiceImageUrl` when present, else the
    existing Users-icon fallback.
  - Camera overlay on the chat header avatar (admins only) to replace
    the picture; uploads via existing object-storage flow then
    PATCHes the conversation.
- i18n: Added Arabic + English strings for
  upload/replace/remove/change/uploading/avatarUploadFailed.

Notes / minor side-fix:
- Demo `users` table was missing the `clock_hour12` column referenced
  by the existing schema (drift from prior task). Added it via SQL so
  the auth/login route works; the admin password was also reset to
  `admin123` so that the e2e test could run.
- Direct conversations are unchanged (no avatar UI, no avatar saved).

Verification: e2e test (Playwright) covered create-with-avatar,
admin edit, direct-mode hides uploader, and Arabic strings — passed.

Replit-Task-Id: 34e8a2d2-621a-42a9-88ba-89652c6094dc
This commit is contained in:
riyadhafraa
2026-04-21 07:46:16 +00:00
parent bfdfffd8b5
commit 55b39f7d6f
10 changed files with 415 additions and 3 deletions
@@ -16,6 +16,8 @@ import {
SendMessageParams,
SendMessageBody,
MarkConversationReadParams,
UpdateConversationBody,
UpdateConversationParams,
} from "@workspace/api-zod";
const router: IRouter = Router();
@@ -128,7 +130,7 @@ router.post("/conversations", requireAuth, async (req, res): Promise<void> => {
return;
}
const { participantIds, nameAr, nameEn, isGroup } = parsed.data;
const { participantIds, nameAr, nameEn, isGroup, avatarUrl } = parsed.data;
const allParticipants = [...new Set([userId, ...participantIds])];
@@ -137,6 +139,7 @@ router.post("/conversations", requireAuth, async (req, res): Promise<void> => {
.values({
nameAr: nameAr ?? null,
nameEn: nameEn ?? null,
avatarUrl: avatarUrl ?? null,
isGroup: isGroup ?? false,
createdBy: userId,
})
@@ -178,6 +181,72 @@ router.get("/conversations/:id", requireAuth, async (req, res): Promise<void> =>
res.json(details);
});
router.patch("/conversations/:id", requireAuth, async (req, res): Promise<void> => {
const params = UpdateConversationParams.safeParse(req.params);
if (!params.success) {
res.status(400).json({ error: params.error.message });
return;
}
const parsed = UpdateConversationBody.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const userId = req.session.userId!;
const [participant] = await db
.select({ isAdmin: conversationParticipantsTable.isAdmin })
.from(conversationParticipantsTable)
.where(
and(
eq(conversationParticipantsTable.conversationId, params.data.id),
eq(conversationParticipantsTable.userId, userId),
),
);
if (!participant) {
res.status(403).json({ error: "Forbidden" });
return;
}
if (!participant.isAdmin) {
res.status(403).json({ error: "Only group admins can update this conversation" });
return;
}
const [conv] = await db
.select({ isGroup: conversationsTable.isGroup })
.from(conversationsTable)
.where(eq(conversationsTable.id, params.data.id));
if (!conv) {
res.status(404).json({ error: "Conversation not found" });
return;
}
if (!conv.isGroup) {
res.status(400).json({ error: "Direct conversations cannot be updated" });
return;
}
const updates: Partial<typeof conversationsTable.$inferInsert> = {};
if (parsed.data.avatarUrl !== undefined) {
updates.avatarUrl = parsed.data.avatarUrl;
}
if (Object.keys(updates).length > 0) {
await db
.update(conversationsTable)
.set(updates)
.where(eq(conversationsTable.id, params.data.id));
}
const details = await buildConversationDetails(params.data.id, userId);
if (!details) {
res.status(404).json({ error: "Conversation not found" });
return;
}
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

+6
View File
@@ -126,6 +126,12 @@
"participantsCount_other": "{{count}} أشخاص مختارين",
"participantsCount_zero": "لم يتم اختيار أحد",
"noUsersFound": "لا يوجد مستخدمون مطابقون",
"uploadAvatar": "رفع صورة",
"replaceAvatar": "استبدال الصورة",
"removeAvatar": "إزالة الصورة",
"changeAvatar": "تغيير صورة المجموعة",
"uploadingAvatar": "جارٍ الرفع… {{progress}}٪",
"avatarUploadFailed": "تعذّر رفع الصورة",
"validation": {
"needGroupName": "أدخل اسم المجموعة بالعربية أو الإنجليزية.",
"minTwoMembers": "اختر شخصين على الأقل لإنشاء مجموعة.",
+6
View File
@@ -126,6 +126,12 @@
"participantsCount_other": "{{count}} people selected",
"participantsCount_zero": "No one selected",
"noUsersFound": "No matching users",
"uploadAvatar": "Upload picture",
"replaceAvatar": "Replace picture",
"removeAvatar": "Remove picture",
"changeAvatar": "Change group picture",
"uploadingAvatar": "Uploading… {{progress}}%",
"avatarUploadFailed": "Could not upload picture",
"validation": {
"needGroupName": "Enter a group name in Arabic or English.",
"minTwoMembers": "Pick at least 2 people for a group.",
+147 -2
View File
@@ -9,7 +9,11 @@ import {
useSendMessage,
useMarkConversationRead,
useCreateConversation,
useUpdateConversation,
} from "@workspace/api-client-react";
import { useUpload, type UploadResponse } from "@workspace/object-storage-web";
import { resolveServiceImageUrl } from "@/lib/image-url";
import { useToast } from "@/hooks/use-toast";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/contexts/AuthContext";
import { io } from "socket.io-client";
@@ -28,7 +32,8 @@ import { Input } from "@/components/ui/input";
import { formatTime } from "@/lib/i18n-format";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Camera, Loader2, X } from "lucide-react";
const BASE = import.meta.env.BASE_URL.replace(/\/$/, "");
@@ -56,8 +61,16 @@ export default function ChatPage() {
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
const [groupNameAr, setGroupNameAr] = useState("");
const [groupNameEn, setGroupNameEn] = useState("");
const [groupAvatarUrl, setGroupAvatarUrl] = useState<string | null>(null);
const [userSearch, setUserSearch] = useState("");
const bottomRef = useRef<HTMLDivElement>(null);
const { toast } = useToast();
const { uploadFile: uploadGroupAvatar, isUploading: isUploadingNewAvatar, progress: newAvatarProgress } = useUpload({
onSuccess: (resp: UploadResponse) => setGroupAvatarUrl(resp.objectPath),
onError: (err: Error) =>
toast({ title: t("chat.avatarUploadFailed"), description: err.message, variant: "destructive" }),
});
const { data: conversations, isLoading: convsLoading } = useListConversations({
query: { queryKey: getListConversationsQueryKey() },
@@ -156,6 +169,7 @@ export default function ChatPage() {
setSelectedUserIds([]);
setGroupNameAr("");
setGroupNameEn("");
setGroupAvatarUrl(null);
setUserSearch("");
};
@@ -228,6 +242,7 @@ export default function ChatPage() {
isGroup,
nameAr: isGroup ? (groupNameAr.trim() || null) : null,
nameEn: isGroup ? (groupNameEn.trim() || null) : null,
avatarUrl: isGroup ? groupAvatarUrl : null,
},
},
{
@@ -256,6 +271,29 @@ export default function ChatPage() {
};
const convName = selectedConv ? convDisplayName(selectedConv) : "";
const isAdminOfSelected = !!selectedConv?.participants?.find(
(p) => p.id === user?.id && p.isAdmin,
);
const updateConv = useUpdateConversation();
const { uploadFile: uploadHeaderAvatar, isUploading: isUploadingHeaderAvatar } = useUpload({
onSuccess: (resp: UploadResponse) => {
if (!selectedConvId) return;
updateConv.mutate(
{ id: selectedConvId, data: { avatarUrl: resp.objectPath } },
{
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: getListConversationsQueryKey() });
},
onError: (err: Error) => {
toast({ title: t("chat.avatarUploadFailed"), description: err.message, variant: "destructive" });
},
},
);
},
onError: (err: Error) =>
toast({ title: t("chat.avatarUploadFailed"), description: err.message, variant: "destructive" }),
});
return (
<div className="min-h-screen os-bg flex flex-col">
@@ -268,7 +306,55 @@ export default function ChatPage() {
<BackIcon size={20} />
</button>
<div className="flex items-center gap-2 flex-1 min-w-0">
<MessageCircle size={20} className="text-green-400 shrink-0" />
{selectedConvId && selectedConv ? (
<div className="relative shrink-0">
<Avatar className="w-9 h-9">
{selectedConv.avatarUrl && (
<AvatarImage
src={resolveServiceImageUrl(selectedConv.avatarUrl) ?? ""}
alt=""
className="object-cover"
/>
)}
<AvatarFallback
className={`font-medium ${
selectedConv.isGroup
? "bg-primary/15 text-primary"
: "bg-primary/20 text-primary"
}`}
>
{selectedConv.isGroup ? (
<Users size={14} />
) : (
String(convName).slice(0, 2).toUpperCase()
)}
</AvatarFallback>
</Avatar>
{selectedConv.isGroup && isAdminOfSelected && (
<label className="cursor-pointer absolute -bottom-1 -end-1 w-5 h-5 rounded-full bg-white border border-slate-200 shadow-sm flex items-center justify-center text-slate-600 hover:text-primary">
<input
type="file"
accept="image/*"
disabled={isUploadingHeaderAvatar || updateConv.isPending}
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void uploadHeaderAvatar(file);
e.currentTarget.value = "";
}}
aria-label={t("chat.changeAvatar")}
/>
{isUploadingHeaderAvatar || updateConv.isPending ? (
<Loader2 size={10} className="animate-spin" />
) : (
<Camera size={10} />
)}
</label>
)}
</div>
) : (
<MessageCircle size={20} className="text-green-400 shrink-0" />
)}
<h1 className="text-lg font-semibold text-foreground truncate">
{selectedConvId ? convName : t("chat.title")}
</h1>
@@ -341,6 +427,58 @@ export default function ChatPage() {
{/* Group name fields */}
{mode === "group" && (
<div className="space-y-2">
<div className="flex items-center gap-3">
<div className="relative">
<Avatar className="w-16 h-16 border border-slate-200">
{groupAvatarUrl && (
<AvatarImage
src={resolveServiceImageUrl(groupAvatarUrl) ?? ""}
alt=""
className="object-cover"
/>
)}
<AvatarFallback className="bg-primary/15 text-primary">
<Users size={22} />
</AvatarFallback>
</Avatar>
{groupAvatarUrl && !isUploadingNewAvatar && (
<button
type="button"
onClick={() => setGroupAvatarUrl(null)}
aria-label={t("chat.removeAvatar")}
className="absolute -top-1 -end-1 w-5 h-5 rounded-full bg-white border border-slate-200 text-slate-500 hover:text-destructive shadow-sm flex items-center justify-center"
>
<X size={12} />
</button>
)}
</div>
<label className="cursor-pointer">
<input
type="file"
accept="image/*"
disabled={isUploadingNewAvatar}
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void uploadGroupAvatar(file);
e.currentTarget.value = "";
}}
/>
<span className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-slate-200 bg-white text-sm text-slate-700 hover:bg-slate-50 transition">
{isUploadingNewAvatar ? (
<>
<Loader2 size={14} className="animate-spin" />
{t("chat.uploadingAvatar", { progress: newAvatarProgress })}
</>
) : (
<>
<Camera size={14} />
{groupAvatarUrl ? t("chat.replaceAvatar") : t("chat.uploadAvatar")}
</>
)}
</span>
</label>
</div>
<Input
value={groupNameAr}
onChange={(e) => setGroupNameAr(e.target.value)}
@@ -550,6 +688,13 @@ export default function ChatPage() {
className="w-full glass-panel rounded-2xl p-4 flex items-center gap-3 hover:bg-slate-100 transition-colors text-start"
>
<Avatar className="w-10 h-10 shrink-0">
{conv.avatarUrl && (
<AvatarImage
src={resolveServiceImageUrl(conv.avatarUrl) ?? ""}
alt=""
className="object-cover"
/>
)}
<AvatarFallback
className={`font-medium ${
conv.isGroup
@@ -277,6 +277,8 @@ export interface ConversationWithDetails {
nameAr?: string | null;
/** @nullable */
nameEn?: string | null;
/** @nullable */
avatarUrl?: string | null;
isGroup: boolean;
createdBy: number;
participants: ParticipantInfo[];
@@ -292,9 +294,16 @@ export interface CreateConversationBody {
nameAr?: string | null;
/** @nullable */
nameEn?: string | null;
/** @nullable */
avatarUrl?: string | null;
isGroup?: boolean;
}
export interface UpdateConversationBody {
/** @nullable */
avatarUrl?: string | null;
}
export interface SendMessageBody {
content: string;
}
+88
View File
@@ -47,6 +47,7 @@ import type {
UpdateAppSettingsBody,
UpdateClockHour12Body,
UpdateClockStyleBody,
UpdateConversationBody,
UpdateLanguageBody,
UpdateMyAppOrderBody,
UpdateServiceBody,
@@ -2612,6 +2613,93 @@ export function useGetConversation<
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Update conversation properties (admin only)
*/
export const getUpdateConversationUrl = (id: number) => {
return `/api/conversations/${id}`;
};
export const updateConversation = async (
id: number,
updateConversationBody: UpdateConversationBody,
options?: RequestInit,
): Promise<ConversationWithDetails> => {
return customFetch<ConversationWithDetails>(getUpdateConversationUrl(id), {
...options,
method: "PATCH",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(updateConversationBody),
});
};
export const getUpdateConversationMutationOptions = <
TError = ErrorType<unknown>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateConversation>>,
TError,
{ id: number; data: BodyType<UpdateConversationBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateConversation>>,
TError,
{ id: number; data: BodyType<UpdateConversationBody> },
TContext
> => {
const mutationKey = ["updateConversation"];
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 updateConversation>>,
{ id: number; data: BodyType<UpdateConversationBody> }
> = (props) => {
const { id, data } = props ?? {};
return updateConversation(id, data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateConversationMutationResult = NonNullable<
Awaited<ReturnType<typeof updateConversation>>
>;
export type UpdateConversationMutationBody = BodyType<UpdateConversationBody>;
export type UpdateConversationMutationError = ErrorType<unknown>;
/**
* @summary Update conversation properties (admin only)
*/
export const useUpdateConversation = <
TError = ErrorType<unknown>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateConversation>>,
TError,
{ id: number; data: BodyType<UpdateConversationBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateConversation>>,
TError,
{ id: number; data: BodyType<UpdateConversationBody> },
TContext
> => {
return useMutation(getUpdateConversationMutationOptions(options));
};
/**
* @summary List messages in a conversation
*/
+34
View File
@@ -656,6 +656,30 @@ paths:
schema:
$ref: "#/components/schemas/ConversationWithDetails"
patch:
operationId: updateConversation
tags: [conversations]
summary: Update conversation properties (admin only)
parameters:
- name: id
in: path
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpdateConversationBody"
responses:
"200":
description: Updated conversation
content:
application/json:
schema:
$ref: "#/components/schemas/ConversationWithDetails"
/conversations/{id}/messages:
get:
operationId: listMessages
@@ -1370,6 +1394,8 @@ components:
type: ["string", "null"]
nameEn:
type: ["string", "null"]
avatarUrl:
type: ["string", "null"]
isGroup:
type: boolean
createdBy:
@@ -1429,11 +1455,19 @@ components:
type: ["string", "null"]
nameEn:
type: ["string", "null"]
avatarUrl:
type: ["string", "null"]
isGroup:
type: boolean
required:
- participantIds
UpdateConversationBody:
type: object
properties:
avatarUrl:
type: ["string", "null"]
MessageWithSender:
type: object
properties:
+54
View File
@@ -572,6 +572,7 @@ export const ListConversationsResponseItem = 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(
@@ -617,6 +618,7 @@ export const CreateConversationBody = zod.object({
participantIds: zod.array(zod.number()),
nameAr: zod.string().nullish(),
nameEn: zod.string().nullish(),
avatarUrl: zod.string().nullish(),
isGroup: zod.boolean().optional(),
});
@@ -631,6 +633,58 @@ export const GetConversationResponse = 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 Update conversation properties (admin only)
*/
export const UpdateConversationParams = zod.object({
id: zod.coerce.number(),
});
export const UpdateConversationBody = zod.object({
avatarUrl: zod.string().nullish(),
});
export const UpdateConversationResponse = 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(
+1
View File
@@ -7,6 +7,7 @@ export const conversationsTable = pgTable("conversations", {
id: serial("id").primaryKey(),
nameAr: varchar("name_ar", { length: 300 }),
nameEn: varchar("name_en", { length: 300 }),
avatarUrl: text("avatar_url"),
isGroup: boolean("is_group").notNull().default(false),
createdBy: integer("created_by").notNull().references(() => usersTable.id),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),