Task #31: Let people leave, mute, or archive a group chat

Adds per-user mute / archive / leave actions for chats.

Schema:
- `lib/db/src/schema/conversations.ts`: added `is_muted` and
  `is_archived` boolean columns on `conversation_participants`.
- Columns applied directly via SQL (drizzle push wanted to make
  unrelated app_opens decisions; force-applied is_muted/is_archived
  with `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`).

API (`artifacts/api-server/src/routes/conversations.ts`):
- New endpoint `PATCH /conversations/:id/state` — current user
  toggles their own `isMuted` / `isArchived`.
- New endpoint `POST /conversations/:id/leave` — removes the
  caller from a group conversation; rejects DMs.
- `buildConversationDetails` now returns `isMuted` and
  `isArchived` for the requesting user.
- `sendMessage` auto-clears `isArchived` for all participants so
  archived chats reappear when a new message arrives.

OpenAPI (`lib/api-spec/openapi.yaml`):
- Added the two new operations and `UpdateConversationStateBody`.
- Added `isMuted` / `isArchived` to `ConversationWithDetails`.
- Re-ran codegen for `@workspace/api-zod` and
  `@workspace/api-client-react`.

UI (`artifacts/teaboy-os/src/pages/chat.tsx`):
- Header gets a kebab "chat actions" button visible for both DMs
  and groups when a conversation is open.
- Action sheet offers Mute/Unmute, Archive/Unarchive, and
  (groups only) Leave with a confirmation dialog.
- Conversation list now has Active / Archived tabs and a
  bell-off indicator + dimmed unread badge for muted chats.
- Bilingual strings added to en.json and ar.json.

Side fixes (unrelated pre-existing schema drift discovered while
testing): added missing `users.clock_hour12` and
`conversations.avatar_url` columns directly so login and the
conversations list work; the schema files already declared them.

Verified end-to-end with the testing tool: mute, archive,
unarchive, and leave-group flows all pass.
This commit is contained in:
Riyadh
2026-04-21 08:29:01 +00:00
parent 78b51b9112
commit 90a7a807e9
10 changed files with 768 additions and 14 deletions
@@ -284,6 +284,8 @@ export interface ConversationWithDetails {
participants: ParticipantInfo[];
lastMessage?: MessageWithSender | null;
unreadCount: number;
isMuted: boolean;
isArchived: boolean;
createdAt: string;
updatedAt: string;
}
@@ -308,6 +310,11 @@ export interface UpdateConversationBody {
nameEn?: string | null;
}
export interface UpdateConversationStateBody {
isMuted?: boolean;
isArchived?: boolean;
}
export interface AddParticipantsBody {
userIds: number[];
}
+176
View File
@@ -49,6 +49,7 @@ import type {
UpdateClockHour12Body,
UpdateClockStyleBody,
UpdateConversationBody,
UpdateConversationStateBody,
UpdateLanguageBody,
UpdateMyAppOrderBody,
UpdateServiceBody,
@@ -2883,6 +2884,181 @@ export const useRemoveConversationParticipant = <
return useMutation(getRemoveConversationParticipantMutationOptions(options));
};
/**
* @summary Update per-user state (mute, archive)
*/
export const getUpdateConversationStateUrl = (id: number) => {
return `/api/conversations/${id}/state`;
};
export const updateConversationState = async (
id: number,
updateConversationStateBody: UpdateConversationStateBody,
options?: RequestInit,
): Promise<ConversationWithDetails> => {
return customFetch<ConversationWithDetails>(
getUpdateConversationStateUrl(id),
{
...options,
method: "PATCH",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(updateConversationStateBody),
},
);
};
export const getUpdateConversationStateMutationOptions = <
TError = ErrorType<unknown>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateConversationState>>,
TError,
{ id: number; data: BodyType<UpdateConversationStateBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateConversationState>>,
TError,
{ id: number; data: BodyType<UpdateConversationStateBody> },
TContext
> => {
const mutationKey = ["updateConversationState"];
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 updateConversationState>>,
{ id: number; data: BodyType<UpdateConversationStateBody> }
> = (props) => {
const { id, data } = props ?? {};
return updateConversationState(id, data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateConversationStateMutationResult = NonNullable<
Awaited<ReturnType<typeof updateConversationState>>
>;
export type UpdateConversationStateMutationBody =
BodyType<UpdateConversationStateBody>;
export type UpdateConversationStateMutationError = ErrorType<unknown>;
/**
* @summary Update per-user state (mute, archive)
*/
export const useUpdateConversationState = <
TError = ErrorType<unknown>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateConversationState>>,
TError,
{ id: number; data: BodyType<UpdateConversationStateBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateConversationState>>,
TError,
{ id: number; data: BodyType<UpdateConversationStateBody> },
TContext
> => {
return useMutation(getUpdateConversationStateMutationOptions(options));
};
/**
* @summary Leave a group conversation
*/
export const getLeaveConversationUrl = (id: number) => {
return `/api/conversations/${id}/leave`;
};
export const leaveConversation = async (
id: number,
options?: RequestInit,
): Promise<SuccessResponse> => {
return customFetch<SuccessResponse>(getLeaveConversationUrl(id), {
...options,
method: "POST",
});
};
export const getLeaveConversationMutationOptions = <
TError = ErrorType<unknown>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof leaveConversation>>,
TError,
{ id: number },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof leaveConversation>>,
TError,
{ id: number },
TContext
> => {
const mutationKey = ["leaveConversation"];
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 leaveConversation>>,
{ id: number }
> = (props) => {
const { id } = props ?? {};
return leaveConversation(id, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type LeaveConversationMutationResult = NonNullable<
Awaited<ReturnType<typeof leaveConversation>>
>;
export type LeaveConversationMutationError = ErrorType<unknown>;
/**
* @summary Leave a group conversation
*/
export const useLeaveConversation = <
TError = ErrorType<unknown>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof leaveConversation>>,
TError,
{ id: number },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof leaveConversation>>,
TError,
{ id: number },
TContext
> => {
return useMutation(getLeaveConversationMutationOptions(options));
};
/**
* @summary List messages in a conversation
*/
+58
View File
@@ -729,6 +729,50 @@ paths:
schema:
$ref: "#/components/schemas/ConversationWithDetails"
/conversations/{id}/state:
patch:
operationId: updateConversationState
tags: [conversations]
summary: Update per-user state (mute, archive)
parameters:
- name: id
in: path
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpdateConversationStateBody"
responses:
"200":
description: Updated conversation
content:
application/json:
schema:
$ref: "#/components/schemas/ConversationWithDetails"
/conversations/{id}/leave:
post:
operationId: leaveConversation
tags: [conversations]
summary: Leave a group conversation
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
"200":
description: Left conversation
content:
application/json:
schema:
$ref: "#/components/schemas/SuccessResponse"
/conversations/{id}/messages:
get:
operationId: listMessages
@@ -1458,6 +1502,10 @@ components:
nullable: true
unreadCount:
type: integer
isMuted:
type: boolean
isArchived:
type: boolean
createdAt:
type: string
format: date-time
@@ -1470,6 +1518,8 @@ components:
- createdBy
- participants
- unreadCount
- isMuted
- isArchived
- createdAt
- updatedAt
@@ -1521,6 +1571,14 @@ components:
nameEn:
type: ["string", "null"]
UpdateConversationStateBody:
type: object
properties:
isMuted:
type: boolean
isArchived:
type: boolean
AddParticipantsBody:
type: object
properties:
+75
View File
@@ -604,6 +604,8 @@ export const ListConversationsResponseItem = zod.object({
})
.nullish(),
unreadCount: zod.number(),
isMuted: zod.boolean(),
isArchived: zod.boolean(),
createdAt: zod.coerce.date(),
updatedAt: zod.coerce.date(),
});
@@ -665,6 +667,8 @@ export const GetConversationResponse = zod.object({
})
.nullish(),
unreadCount: zod.number(),
isMuted: zod.boolean(),
isArchived: zod.boolean(),
createdAt: zod.coerce.date(),
updatedAt: zod.coerce.date(),
});
@@ -718,6 +722,8 @@ export const UpdateConversationResponse = zod.object({
})
.nullish(),
unreadCount: zod.number(),
isMuted: zod.boolean(),
isArchived: zod.boolean(),
createdAt: zod.coerce.date(),
updatedAt: zod.coerce.date(),
});
@@ -769,6 +775,8 @@ export const AddConversationParticipantsResponse = zod.object({
})
.nullish(),
unreadCount: zod.number(),
isMuted: zod.boolean(),
isArchived: zod.boolean(),
createdAt: zod.coerce.date(),
updatedAt: zod.coerce.date(),
});
@@ -817,10 +825,77 @@ export const RemoveConversationParticipantResponse = zod.object({
})
.nullish(),
unreadCount: zod.number(),
isMuted: zod.boolean(),
isArchived: zod.boolean(),
createdAt: zod.coerce.date(),
updatedAt: zod.coerce.date(),
});
/**
* @summary Update per-user state (mute, archive)
*/
export const UpdateConversationStateParams = zod.object({
id: zod.coerce.number(),
});
export const UpdateConversationStateBody = zod.object({
isMuted: zod.boolean().optional(),
isArchived: zod.boolean().optional(),
});
export const UpdateConversationStateResponse = 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(),
isMuted: zod.boolean(),
isArchived: zod.boolean(),
createdAt: zod.coerce.date(),
updatedAt: zod.coerce.date(),
});
/**
* @summary Leave a group conversation
*/
export const LeaveConversationParams = zod.object({
id: zod.coerce.number(),
});
export const LeaveConversationResponse = zod.object({
success: zod.boolean(),
});
/**
* @summary List messages in a conversation
*/
+2
View File
@@ -19,6 +19,8 @@ export const conversationParticipantsTable = pgTable("conversation_participants"
userId: integer("user_id").notNull().references(() => usersTable.id, { onDelete: "cascade" }),
joinedAt: timestamp("joined_at", { withTimezone: true }).notNull().defaultNow(),
isAdmin: boolean("is_admin").notNull().default(false),
isMuted: boolean("is_muted").notNull().default(false),
isArchived: boolean("is_archived").notNull().default(false),
}, (t) => [primaryKey({ columns: [t.conversationId, t.userId] })]);
export const messagesTable = pgTable("messages", {