Skip to content

Commit 241e6fd

Browse files
committed
feat(context-menu): Add WhatsApp-style context menus with right-click support
- Add ContextMenu component with keyboard navigation (↑/↓/Enter/Esc) - Support right-click on chat rows and message bubbles - Position menus dynamically using exact bubble bounds (this.x, this.y) - Handle bottom overflow by anchoring menu to bubble bottom instead - Add chat actions: Archive/Unarchive, Mark as unread, Delete - Add message actions: Reply, Copy, React, Forward, Pin, Star, Delete - Fix archive detection using _chat.archived property - Replace delete emoji with ✕ for better terminal compatibility - Add context menu icons to theme configuration
1 parent 4277501 commit 241e6fd

4 files changed

Lines changed: 98 additions & 24 deletions

File tree

src/components/ContextMenu.ts

Lines changed: 70 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Box, ProxiedVNode, Text, type BoxRenderable } from "@opentui/core"
77
import { WhatsAppTheme, Icons } from "../config/theme"
88
import { appState, type ContextMenuType } from "../state/AppState"
99
import type { ChatSummary, WAMessage } from "@muhammedaksam/waha-node"
10+
import { getRenderer } from "../state/RendererContext"
1011

1112
export interface ContextMenuItem {
1213
id: string
@@ -180,39 +181,89 @@ export function ContextMenu(): ProxiedVNode<typeof BoxRenderable> | null {
180181
})
181182

182183
// Calculate menu height
183-
const menuHeight = items.reduce((acc, item) => acc + (item.separator ? 2 : 1), 0) + 2 // +2 for border
184+
const menuHeight =
185+
items.reduce((acc, item) => acc + (item.separator ? 2 : 1), 0) +
186+
(contextMenu.type === "chat" ? 2 : 1) // +2 for border
184187

185188
// Menu title
186-
const title = contextMenu.type === "chat" ? "Chat Options" : "Message Options"
189+
// const title = contextMenu.type === "chat" ? "Chat Options" : "Message Options"
190+
191+
// Position logic differs by menu type:
192+
// - Chat menu: appears at click position (floating near clicked chat)
193+
// - Message menu: anchored to right side of conversation area, near the clicked message
194+
const pos = contextMenu.position || { x: 10, y: 5 }
195+
196+
let leftPosition: number
197+
let topPosition: number
198+
199+
if (contextMenu.type === "message") {
200+
// For message menu: anchor to top-right corner of the message bubble
201+
// Use the exact bubble position and dimensions passed from the renderable
202+
const message = contextMenu.targetData as { fromMe?: boolean } | null
203+
const isFromMe = message?.fromMe === true
204+
205+
// Get bubble bounds from position
206+
const bubbleX = pos.x
207+
const bubbleY = pos.y
208+
const bubbleWidth = pos.bubbleWidth || 40
209+
const bubbleHeight = pos.bubbleHeight || 3
210+
211+
if (isFromMe) {
212+
// Sent messages (right-aligned) - menu appears 1 block left from the bubble's left edge
213+
// So position is: bubbleX - menuWidth - 1
214+
leftPosition = Math.max(2, bubbleX - menuWidth - 1)
215+
} else {
216+
// Received messages (left-aligned) - menu appears 1 block right from the bubble's right edge
217+
// So position is: bubbleX + bubbleWidth + 1
218+
leftPosition = bubbleX + bubbleWidth + 1
219+
}
220+
221+
// Check for bottom overflow - if menu would go off screen, show above instead
222+
const renderer = getRenderer()
223+
const terminalHeight = renderer.height
224+
// Anchor 1 row down from the top of the bubble
225+
const anchorY = bubbleY + 1
226+
227+
if (anchorY + menuHeight > terminalHeight - 2) {
228+
// Menu would overflow - anchor from bottom of bubble instead (menu grows upward)
229+
// Position so the bottom of the menu aligns with the bottom of the bubble
230+
topPosition = Math.max(2, bubbleY + bubbleHeight - menuHeight)
231+
} else {
232+
// Normal positioning - menu starts 1 row down from bubble top
233+
topPosition = Math.max(2, anchorY)
234+
}
235+
} else {
236+
// For chat menu: use click position
237+
leftPosition = Math.max(2, pos.x)
238+
topPosition = Math.max(2, pos.y)
239+
}
187240

188241
return Box(
189242
{
190-
// Position in center of screen for now
191-
// A more sophisticated implementation would position near click location
192243
position: "absolute",
193-
top: 5,
194-
left: 10,
244+
top: topPosition,
245+
left: leftPosition,
195246
width: menuWidth + 2, // +2 for border
196-
height: menuHeight,
247+
height: menuHeight + (contextMenu.type === "message" ? 1 : 0),
197248
backgroundColor: WhatsAppTheme.panelDark,
198249
border: true,
199250
borderColor: WhatsAppTheme.borderLight,
200251
flexDirection: "column",
201252
zIndex: 100,
202253
},
203254
// Header
204-
Box(
205-
{
206-
height: 1,
207-
width: menuWidth,
208-
paddingLeft: 1,
209-
backgroundColor: WhatsAppTheme.panelLight,
210-
},
211-
Text({
212-
content: title,
213-
fg: WhatsAppTheme.textSecondary,
214-
})
215-
),
255+
// Box(
256+
// {
257+
// height: 1,
258+
// width: menuWidth,
259+
// paddingLeft: 1,
260+
// backgroundColor: WhatsAppTheme.panelLight,
261+
// },
262+
// Text({
263+
// content: title,
264+
// fg: WhatsAppTheme.textSecondary,
265+
// })
266+
// ),
216267
// Menu items
217268
...menuItems
218269
)

src/state/AppState.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ export interface ContextMenuState {
2222
targetId: string | null // Chat ID or Message ID
2323
targetData?: ChatSummary | WAMessage | null // The actual chat or message data
2424
selectedIndex: number // Currently highlighted menu item
25+
position: {
26+
x: number
27+
y: number
28+
bubbleWidth?: number // For message bubbles - the width of the bubble
29+
bubbleHeight?: number // For message bubbles - the height of the bubble
30+
}
2531
}
2632

2733
export type ViewType =
@@ -315,7 +321,8 @@ class StateManager {
315321
openContextMenu(
316322
type: ContextMenuType,
317323
targetId: string,
318-
targetData?: ChatSummary | WAMessage | null
324+
targetData?: ChatSummary | WAMessage | null,
325+
position: { x: number; y: number } = { x: 10, y: 5 }
319326
): void {
320327
this.setState({
321328
contextMenu: {
@@ -324,6 +331,7 @@ class StateManager {
324331
targetId,
325332
targetData,
326333
selectedIndex: 0,
334+
position,
327335
},
328336
})
329337
}

src/views/ChatListManager.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,8 @@ class ChatListManager {
226226
if (event.button === 2) {
227227
debugLog("ChatListManager", `Right-clicked chat: ${chatRef.name || chatId}`)
228228
appState.setSelectedChatIndex(chatIndex)
229-
appState.openContextMenu("chat", chatId, chatRef)
229+
// Pass click position for menu placement
230+
appState.openContextMenu("chat", chatId, chatRef, { x: event.x, y: event.y })
230231
return
231232
}
232233

src/views/ConversationView.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -570,10 +570,24 @@ function renderMessage(
570570
borderColor: isFromMe ? WhatsAppTheme.green : WhatsAppTheme.borderColor,
571571
flexDirection: "column",
572572
// Handle right-click for context menu
573-
onMouse: (event) => {
573+
// Use function (not arrow) to get access to 'this' which is the bubble renderable
574+
onMouse: function (event) {
574575
if (event.type === "down" && event.button === 2) {
575-
debugLog("ConversationView", `Right-clicked message: ${msgId}`)
576-
appState.openContextMenu("message", msgId, msgRef as unknown as WAMessage)
576+
// Get bubble's exact screen position and dimensions
577+
const bubbleX = this.x
578+
const bubbleY = this.y
579+
580+
debugLog(
581+
"ConversationView",
582+
`Right-clicked message: ${msgId} at bubble pos (${bubbleX}, ${bubbleY})`
583+
)
584+
585+
// Pass bubble bounds for precise positioning
586+
// The context menu will use this to anchor to the bubble's corner
587+
appState.openContextMenu("message", msgId, msgRef as unknown as WAMessage, {
588+
x: bubbleX,
589+
y: bubbleY,
590+
})
577591
}
578592
},
579593
})

0 commit comments

Comments
 (0)