Chat Long-Press Delete
System Intent
- What is being built: Long-press gesture on chat items inside the side-menu conversation list that opens an in-component action sheet with a Delete option, wired to the existing
useDeleteChat mutation and backend POST /chats/delete endpoint. - Primary consumer(s):
SideMenu component (main/app/components/side-menu.tsx) rendered inside main/app/app/index.tsx. - Boundary (black-box scope only): The feature spans frontend gesture detection, in-component overlay UI, a React Query mutation hook, a frontend API wrapper, and a Lambda backend endpoint. All pieces exist on branch
feature/chat-delete-action (PR #536). This plan covers merging that branch into master and deploying the Lambda change.
Stage Gate Tracker
- [x] Stage 1 Mermaid approved
- [x] Stage 2 Flows approved
- [ ] Stage 3 Logs + Deployment approved or skipped
Mermaid Diagram
Use the skill at skills/create-mermaid-diagram/SKILL.md to generate this diagram.
graph TD
User["User (long-press 400ms)"]:::unchanged -->|GestureDetector LongPress| SideMenu["side-menu.tsx\npendingDeleteItem state"]:::created
SideMenu -->|renders overlay| ActionSheet["In-component ActionSheet\n(Delete / Cancel)"]:::created
ActionSheet -->|onDeleteConversation item| IndexHandler["index.tsx\nhandleDeleteConversation"]:::created
IndexHandler -->|deleteChat item.id| useDeleteChat["useDeleteChat\n(useChatsApi.ts)"]:::created
useDeleteChat -->|POST /chats/delete| DeleteLambda["ChatsDeleteFunction\n(app.py)"]:::created
DeleteLambda -->|chat_owned_by check| DynamoDB["DynamoDB\nChats + Messages tables"]:::unchanged
DeleteLambda -->|batch delete messages then chat| DynamoDB
DeleteLambda -->|deleted true/false| useDeleteChat
useDeleteChat -->|onSuccess invalidate| ChatsFeed["useChatsFeed\n(chats feed cache)"]:::unchanged
ChatsFeed -->|re-fetch| SideMenu
classDef unchanged fill:#d3d3d3,stroke:#666,stroke-width:1px;
classDef created fill:#a8e6a3,stroke:#666,stroke-width:1px;
Flows
- Flow naming rule:
### Flow: `<flowname>` N/A for test files means explicit no-test-required waiver (not a missing mapping).
Global Types
StandardError {
message: string (human-readable description of what went wrong)
}
ChatSummary {
id: string
title: string
last_message: string (relative label: "Today", "Yesterday", weekday, or "Mon D")
updated_at: string (ISO timestamp)
}
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)
}
ActionSheetState {
pendingDeleteItem: ChatSummary | null
}
Paths
| path | input | output | path-type | notes | updated |
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)
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
}
Paths
| path | input | output | path-type | notes | updated |
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 in this iteration | |
Pseudocode
// ActionSheet confirm button (side-menu.tsx)
onPress(() => {
const item = pendingDeleteItem
setPendingDeleteItem(null) // dismiss overlay immediately
onDeleteConversation(item) // delegate up to index.tsx
})
// 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 })
// Backend — ChatsDeleteFunction
implementation(payload, auth_context):
user_id = auth_context.user_id
chat_id = payload.chat_id.strip()
validate user_id and chat_id non-empty → raise ValueError → HTTP 400
repo = ChatRepository(MESSAGES_TABLE_NAME, CHATS_TABLE_NAME)
deleted = repo.delete_chat(user_id, chat_id)
return { deleted, 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
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 (backend Lambda only; frontend ships with app build) - Deploy command:
cd main/server
sam build
sam deploy --template .aws-sam/build/template.yaml
- Notes:
ChatsDeleteFunction: Python 3.12, 30s timeout. Env vars: CHATS_TABLE_NAME (default Chats), MESSAGES_TABLE_NAME (default Messages). Requires ChatDynamoDBPolicy (read + write on both tables). Route: POST /chats/delete behind Cognito JWT authorizer. - Frontend changes (gesture detection, action sheet, hook wiring) are local-only; no infra change needed beyond the Lambda already in
template.yaml on the branch. - 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.