Chat Delete
System Intent
- What this is: Long-press gesture on conversation items in the side-menu that opens an in-component action sheet, wired to a React Query mutation and a backend Lambda that batch-deletes messages then the chat row.
Mermaid Diagram
flowchart TD
User["User (long-press 400ms)"] -->|GestureDetector LongPress| SideMenu["side-menu.tsx\npendingDeleteItem state"]
SideMenu -->|renders overlay| ActionSheet["In-component ActionSheet\n(Delete / Cancel)"]
ActionSheet -->|onDeleteConversation item| IndexHandler["index.tsx\nhandleDeleteConversation"]
IndexHandler -->|deleteChat item.id| useDeleteChat["useDeleteChat\n(useChatsApi.ts)"]
useDeleteChat -->|POST /chats/delete| DeleteLambda["ChatsDeleteFunction\n(app.py)"]
DeleteLambda -->|chat_owned_by check| DynamoDB["DynamoDB: Chats + Messages tables"]
DeleteLambda -->|batch delete messages then chat row| DynamoDB
DeleteLambda -->|deleted true/false| useDeleteChat
useDeleteChat -->|onSuccess invalidateQueries| ChatsFeed["useChatsFeed\n(chats feed cache)"]
ChatsFeed -->|re-fetch| SideMenu
Flows
Flow: longPressOpenActionSheet
- Test files:
main/app/__tests__/side-menu-delete.test.tsx - Core files:
main/app/components/side-menu.tsx
Types
LongPressInput {
item: ChatSummary (the conversation row that was held)
}
ChatSummary {
id: string
title: string
last_message: string (relative label: "Today", "Yesterday", weekday, or "Mon D")
updated_at: string (ISO timestamp)
}
ActionSheetState {
pendingDeleteItem: ChatSummary | null
}
Paths
| path | input | output | path-type | notes |
longPressOpenActionSheet.opens | LongPressInput (400ms hold) | ActionSheetState pendingDeleteItem=item | happy path | GestureDetector fires onEnd, runOnJS marshals to JS thread |
longPressOpenActionSheet.dismiss | tap Cancel | ActionSheetState pendingDeleteItem=null | happy path | overlay hidden, no mutation fired |
longPressOpenActionSheet.shortTap | tap < 400ms | no state change | happy path | LongPress gesture does not fire; Pressable onPress fires normally |
Pseudocode
// side-menu.tsx — per-item render
longPressGesture = Gesture.LongPress()
.minDuration(400)
.onEnd(() => {
runOnJS(setPendingDeleteItem)(item) // runOnJS: worklet → JS thread
})
return (
<GestureDetector gesture={longPressGesture}>
<Pressable onPress={() => onConversationPress(item)}>
...
</Pressable>
</GestureDetector>
)
// GestureHandlerRootView wraps the entire Modal so RNGH can activate
// on Android (bypasses FlatList ScrollView touch-steal)
// Action sheet confirm (side-menu.tsx)
onPress(() => {
const item = pendingDeleteItem
setPendingDeleteItem(null) // dismiss overlay immediately
onDeleteConversation(item) // delegate up to index.tsx
})
Flow: deleteChat
- Test files:
main/app/__tests__/delete-chat-api.test.ts, main/app/__tests__/side-menu-delete.test.tsx - Core files:
main/app/lib/api/chats/deleteChat.ts, main/app/lib/api/chats/useChatsApi.ts, main/app/components/side-menu.tsx, main/app/app/index.tsx, main/server/api/chats/delete/app.py, main/server/layers/shared/python/shared/chat/repository.py
Types
DeleteChatRequest {
chat_id: string (required, non-empty)
}
DeleteChatResponse {
deleted: boolean // false if chat not found or not owned by user
chat_id: string
}
StandardError {
message: string (human-readable description of what went wrong)
}
Paths
| path | input | output | path-type | notes |
deleteChat.success | {chat_id} owned by user | {deleted: true, chat_id} + cache invalidation | happy path | Messages batch-deleted in groups of 25, then chat row deleted; feed refetches |
deleteChat.not_owned | {chat_id} not owned or not found | {deleted: false, chat_id} HTTP 200 | graceful degradation | chat_owned_by check fails silently; no deletion; UI still dismisses |
deleteChat.missing_chat_id | empty payload | HTTP 400 {message: "chat_id is required"} | error | Validated before DB access |
deleteChat.missing_user_id | missing auth context | HTTP 400 {message: "user_id is required"} | error | Validated in implementation() |
deleteChat.networkError | API unreachable | onError logged; no UI feedback shown | error | No user-facing error toast; accessible via mutation.isError and mutation.error |
Pseudocode
// index.tsx — handleDeleteConversation
handleDeleteConversation(item):
logger({ step: "conversation_delete_requested", chat_id: item.id })
deleteChat(item.id) // useDeleteChat().mutate
// useDeleteChat (useChatsApi.ts)
mutationFn(chatId):
logger({ step: "chat_delete_requested", chat_id: chatId })
return deleteChat(chatId) // POST /chats/delete { chat_id }
onSuccess(_data, chatId):
logger({ step: "chat_delete_succeeded", chat_id: chatId })
queryClient.invalidateQueries(["chats", "feed"])
onError(_error, chatId):
logger({ step: "chat_delete_failed", chat_id: chatId })
// deleteChat.ts
POST /chats/delete { chat_id: chatId } timeout: 30_000ms
// response body ignored; void return
// Backend — ChatsDeleteFunction (app.py)
implementation(payload, auth_context):
user_id = auth_context.user_id // raises ValueError if empty
chat_id = payload.chat_id.strip() // raises ValueError if empty
log_event(FLOW, "delete_chat_start", { user_id, chat_id })
repo = ChatRepository(MESSAGES_TABLE_NAME, CHATS_TABLE_NAME)
deleted = repo.delete_chat(user_id, chat_id)
if not deleted:
log_event(FLOW, "delete_chat_not_found", ...)
return { deleted: False, chat_id }
log_event(FLOW, "delete_chat_complete", ...)
return { deleted: True, chat_id }
// ChatRepository.delete_chat(user_id, chat_id):
if not chat_owned_by(user_id, chat_id): return False
loop:
items = messages_table.query(chat_id, Limit=25)
if not items: break
batch_writer.delete_item for each item (key: chat_id + message_id)
if not LastEvaluatedKey: break
chats_table.delete_item(Key: { user_id, chat_id })
return True
Logs
| Source | Location |
| Delete requested (frontend) | step: "conversation_delete_requested" via useLogging in main/app/app/index.tsx |
| Delete mutation start | step: "chat_delete_requested" via useLogging in useDeleteChat |
| Delete mutation success | step: "chat_delete_succeeded" via useLogging in useDeleteChat |
| Delete mutation failure | step: "chat_delete_failed" via useLogging in useDeleteChat |
| Delete Lambda start | step: "delete_chat_start" via log_event in ChatsDeleteFunction |
| Delete Lambda not found | step: "delete_chat_not_found" via log_event in ChatsDeleteFunction |
| Delete Lambda complete | step: "delete_chat_complete" via log_event in ChatsDeleteFunction |
Deployment
- Mechanism:
SAM - Deploy command:
cd main/server
sam build
sam deploy --template .aws-sam/build/template.yaml
- Notes:
ChatsDeleteFunction: Python 3.12, default 30s timeout. Env vars: CHATS_TABLE_NAME (default Chats), MESSAGES_TABLE_NAME (default Messages). Uses ChatDynamoDBPolicy (read + write on both tables). Route: POST /chats/delete behind Cognito JWT authorizer. - Frontend changes (gesture detection, action sheet, hook wiring) are bundled with the app build; no separate infra deploy needed beyond the Lambda.
- Jest mocks required:
react-native-reanimated module aliased to its mock in jest.config.js (moduleNameMapper); runOnJS stubbed as pass-through in jest.setup.js.
Key Files
| File | Role |
main/app/components/side-menu.tsx | Long-press gesture (GestureDetector + RNGH), pendingDeleteItem state, in-component action sheet overlay |
main/app/app/index.tsx | handleDeleteConversation — logs delete request and calls deleteChat mutate |
main/app/lib/api/chats/deleteChat.ts | deleteChat(chatId) — POST /chats/delete API client, 30s timeout |
main/app/lib/api/chats/useChatsApi.ts | useDeleteChat — React Query mutation with logging and ["chats", "feed"] cache invalidation on success |
main/server/api/chats/delete/app.py | ChatsDeleteFunction Lambda — validates input, calls repo.delete_chat, logs events |
main/server/layers/shared/python/shared/chat/repository.py | ChatRepository.delete_chat — ownership check, batch message deletion, chat row deletion |
Architecture Notes
In-Component Action Sheet
The delete confirmation uses an in-component overlay (View with StyleSheet.absoluteFillObject) rather than Alert.alert(). On Android, Alert.alert() targets the Activity window, not the Modal's Dialog window, so it never appears on top of an open Modal. The overlay renders inside the Modal's GestureHandlerRootView at zIndex: 10, guaranteeing correct stacking on both platforms.
GestureHandlerRootView Wrapping
GestureHandlerRootView wraps the entire Modal contents in side-menu.tsx. This is required on Android so RNGH gesture recognizers can activate inside a Modal; without it the FlatList's underlying ScrollView intercepts all touch events and the long-press never fires.
Ownership Check Returns 200
ChatsDeleteFunction returns {deleted: false} with HTTP 200 (not 403) when the chat is not found or not owned by the requesting user. This is a silent no-op design: the UI dismisses the overlay regardless of the deleted flag, and no toast is shown. The backend still logs delete_chat_not_found for observability.