Skip to content

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.