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:
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user