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:
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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", {
|
||||
|
||||
Reference in New Issue
Block a user