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.

Replit-Task-Id: faec58bb-12c6-4f6f-9ddb-f3f9f6c033f4
This commit is contained in:
riyadhafraa
2026-04-21 08:29:01 +00:00
parent f6a7bc294d
commit 7b77de107f
10 changed files with 768 additions and 14 deletions
+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
*/