diff --git a/app/routers/messages.py b/app/routers/messages.py index 5ce7f10..314ce22 100644 --- a/app/routers/messages.py +++ b/app/routers/messages.py @@ -306,10 +306,17 @@ RESEND_WINDOW_SECONDS = 30 @router.post("/channel/{message_id}/resend") -async def resend_channel_message(message_id: int) -> dict: - """Resend a channel message within 30 seconds of original send. +async def resend_channel_message( + message_id: int, + new_timestamp: bool = Query(default=False), +) -> dict: + """Resend a channel message. - Performs a byte-perfect resend using the same timestamp bytes as the original. + When new_timestamp=False (default): byte-perfect resend using the original timestamp. + Only allowed within 30 seconds of the original send. + + When new_timestamp=True: resend with a fresh timestamp so repeaters treat it as a + new packet. Creates a new message row in the database. No time window restriction. """ mc = require_connected() @@ -328,16 +335,22 @@ async def resend_channel_message(message_id: int) -> dict: if msg.sender_timestamp is None: raise HTTPException(status_code=400, detail="Message has no timestamp") - elapsed = int(time.time()) - msg.sender_timestamp - if elapsed > RESEND_WINDOW_SECONDS: - raise HTTPException(status_code=400, detail="Resend window has expired (30 seconds)") + # Byte-perfect resend enforces the 30s window; new-timestamp resend does not + if not new_timestamp: + elapsed = int(time.time()) - msg.sender_timestamp + if elapsed > RESEND_WINDOW_SECONDS: + raise HTTPException(status_code=400, detail="Resend window has expired (30 seconds)") db_channel = await ChannelRepository.get_by_key(msg.conversation_key) if not db_channel: raise HTTPException(status_code=404, detail=f"Channel {msg.conversation_key} not found") - # Reconstruct timestamp bytes - timestamp_bytes = msg.sender_timestamp.to_bytes(4, "little") + # Choose timestamp: original for byte-perfect, fresh for new-timestamp + if new_timestamp: + now = int(time.time()) + timestamp_bytes = now.to_bytes(4, "little") + else: + timestamp_bytes = msg.sender_timestamp.to_bytes(4, "little") # Strip sender prefix: DB stores "RadioName: message" but radio needs "message" radio_name = mc.self_info.get("name", "") if mc.self_info else "" @@ -374,5 +387,47 @@ async def resend_channel_message(message_id: int) -> dict: status_code=500, detail=f"Failed to resend message: {result.payload}" ) + # For new-timestamp resend, create a new message row and broadcast it + if new_timestamp: + new_msg_id = await MessageRepository.create( + msg_type="CHAN", + text=msg.text, + conversation_key=msg.conversation_key, + sender_timestamp=now, + received_at=now, + outgoing=True, + ) + if new_msg_id is None: + # Timestamp-second collision (same text+channel within the same second). + # The radio already transmitted, so log and return the original ID rather + # than surfacing a 500 for a message that was successfully sent over the air. + logger.warning( + "Duplicate timestamp collision resending message %d — radio sent but DB row not created", + message_id, + ) + return {"status": "ok", "message_id": message_id} + + broadcast_event( + "message", + Message( + id=new_msg_id, + type="CHAN", + conversation_key=msg.conversation_key, + text=msg.text, + sender_timestamp=now, + received_at=now, + outgoing=True, + acked=0, + ).model_dump(), + ) + + logger.info( + "Resent channel message %d as new message %d to %s", + message_id, + new_msg_id, + db_channel.name, + ) + return {"status": "ok", "message_id": new_msg_id} + logger.info("Resent channel message %d to %s", message_id, db_channel.name) return {"status": "ok", "message_id": message_id} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c4b023e..5257af0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -371,16 +371,21 @@ export function App() { ); // Handle resend channel message - const handleResendChannelMessage = useCallback(async (messageId: number) => { - try { - await api.resendChannelMessage(messageId); - toast.success('Message resent'); - } catch (err) { - toast.error('Failed to resend', { - description: err instanceof Error ? err.message : 'Unknown error', - }); - } - }, []); + const handleResendChannelMessage = useCallback( + async (messageId: number, newTimestamp?: boolean) => { + try { + // New-timestamp resend creates a new message; the backend broadcast_event + // will add it to the conversation via WebSocket. + await api.resendChannelMessage(messageId, newTimestamp); + toast.success(newTimestamp ? 'Message resent with new timestamp' : 'Message resent'); + } catch (err) { + toast.error('Failed to resend', { + description: err instanceof Error ? err.message : 'Unknown error', + }); + } + }, + [] + ); // Handle sender click to add mention const handleSenderClick = useCallback((sender: string) => { diff --git a/frontend/src/api.ts b/frontend/src/api.ts index dd77764..9fdee96 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -167,10 +167,11 @@ export const api = { method: 'POST', body: JSON.stringify({ channel_key: channelKey, text }), }), - resendChannelMessage: (messageId: number) => - fetchJson<{ status: string; message_id: number }>(`/messages/channel/${messageId}/resend`, { - method: 'POST', - }), + resendChannelMessage: (messageId: number, newTimestamp?: boolean) => + fetchJson<{ status: string; message_id: number }>( + `/messages/channel/${messageId}/resend${newTimestamp ? '?new_timestamp=true' : ''}`, + { method: 'POST' } + ), // Packets getUndecryptedPacketCount: () => fetchJson<{ count: number }>('/packets/undecrypted/count'), diff --git a/frontend/src/components/MessageList.tsx b/frontend/src/components/MessageList.tsx index 786a6a7..7555ae4 100644 --- a/frontend/src/components/MessageList.tsx +++ b/frontend/src/components/MessageList.tsx @@ -23,7 +23,7 @@ interface MessageListProps { hasOlderMessages?: boolean; onSenderClick?: (sender: string) => void; onLoadOlder?: () => void; - onResendChannelMessage?: (messageId: number) => void; + onResendChannelMessage?: (messageId: number, newTimestamp?: boolean) => void; radioName?: string; config?: RadioConfig | null; } @@ -156,12 +156,11 @@ export function MessageList({ const [selectedPath, setSelectedPath] = useState<{ paths: MessagePath[]; senderInfo: SenderInfo; + messageId?: number; + isOutgoingChan?: boolean; } | null>(null); const [resendableIds, setResendableIds] = useState>(new Set()); const resendTimersRef = useRef>>(new Map()); - const activeBurstsRef = useRef[]>>(new Map()); - const onResendRef = useRef(onResendChannelMessage); - onResendRef.current = onResendChannelMessage; // Capture scroll state in the scroll handler BEFORE any state updates const scrollStateRef = useRef({ @@ -262,17 +261,6 @@ export function MessageList({ }; }, [messages, onResendChannelMessage]); - // Clean up burst timers on unmount - useEffect(() => { - const bursts = activeBurstsRef.current; - return () => { - for (const timers of bursts.values()) { - for (const t of timers) clearTimeout(t); - } - bursts.clear(); - }; - }, []); - // Handle scroll - capture state and detect when user is near top/bottom const handleScroll = useCallback(() => { if (!listRef.current) return; @@ -315,6 +303,21 @@ export function MessageList({ [messages] ); + // Sender info for outgoing messages (used by path modal on own messages) + const selfSenderInfo = useMemo( + () => ({ + name: config?.name || 'Unknown', + publicKeyOrPrefix: config?.public_key || '', + lat: config?.lat ?? null, + lon: config?.lon ?? null, + }), + [config?.name, config?.public_key, config?.lat, config?.lon] + ); + + // Derive live so the byte-perfect button disables if the 30s window expires while modal is open + const isSelectedMessageResendable = + selectedPath?.messageId !== undefined && resendableIds.has(selectedPath.messageId); + // Look up contact by public key const getContact = (conversationKey: string | null): Contact | null => { if (!conversationKey) return null; @@ -520,34 +523,6 @@ export function MessageList({ )} )} - {msg.outgoing && onResendChannelMessage && resendableIds.has(msg.id) && ( - - )} {msg.outgoing && (msg.acked > 0 ? ( msg.paths && msg.paths.length > 0 ? ( @@ -557,12 +532,9 @@ export function MessageList({ e.stopPropagation(); setSelectedPath({ paths: msg.paths!, - senderInfo: { - name: config?.name || 'Unknown', - publicKeyOrPrefix: config?.public_key || '', - lat: config?.lat ?? null, - lon: config?.lon ?? null, - }, + senderInfo: selfSenderInfo, + messageId: msg.id, + isOutgoingChan: msg.type === 'CHAN' && !!onResendChannelMessage, }); }} title="View echo paths" @@ -570,6 +542,23 @@ export function MessageList({ ) : ( {` ✓${msg.acked > 1 ? msg.acked : ''}`} ) + ) : onResendChannelMessage && msg.type === 'CHAN' ? ( + { + e.stopPropagation(); + setSelectedPath({ + paths: [], + senderInfo: selfSenderInfo, + messageId: msg.id, + isOutgoingChan: true, + }); + }} + title="Message status" + > + {' '} + ? + ) : ( {' '} @@ -616,6 +605,10 @@ export function MessageList({ senderInfo={selectedPath.senderInfo} contacts={contacts} config={config ?? null} + messageId={selectedPath.messageId} + isOutgoingChan={selectedPath.isOutgoingChan} + isResendable={isSelectedMessageResendable} + onResend={onResendChannelMessage} /> )} diff --git a/frontend/src/components/PathModal.tsx b/frontend/src/components/PathModal.tsx index af79ea4..1721ba6 100644 --- a/frontend/src/components/PathModal.tsx +++ b/frontend/src/components/PathModal.tsx @@ -28,14 +28,34 @@ interface PathModalProps { senderInfo: SenderInfo; contacts: Contact[]; config: RadioConfig | null; + messageId?: number; + isOutgoingChan?: boolean; + isResendable?: boolean; + onResend?: (messageId: number, newTimestamp?: boolean) => void; } -export function PathModal({ open, onClose, paths, senderInfo, contacts, config }: PathModalProps) { +export function PathModal({ + open, + onClose, + paths, + senderInfo, + contacts, + config, + messageId, + isOutgoingChan, + isResendable, + onResend, +}: PathModalProps) { + const hasResendActions = isOutgoingChan && messageId !== undefined && onResend; + const hasPaths = paths.length > 0; + // Resolve all paths - const resolvedPaths = paths.map((p) => ({ - ...p, - resolved: resolvePath(p.path, senderInfo, contacts, config), - })); + const resolvedPaths = hasPaths + ? paths.map((p) => ({ + ...p, + resolved: resolvePath(p.path, senderInfo, contacts, config), + })) + : []; const hasSinglePath = paths.length === 1; @@ -43,9 +63,15 @@ export function PathModal({ open, onClose, paths, senderInfo, contacts, config } !isOpen && onClose()}> - Message Path{!hasSinglePath && `s (${paths.length})`} + + {hasPaths + ? `Message Path${!hasSinglePath ? `s (${paths.length})` : ''}` + : 'Message Status'} + - {hasSinglePath ? ( + {!hasPaths ? ( + <>No echoes heard yet. Echoes appear when repeaters re-broadcast your message. + ) : hasSinglePath ? ( <> This shows one route that this message traveled through the mesh network. Routers may be incorrectly identified due to prefix collisions between heard and @@ -60,63 +86,98 @@ export function PathModal({ open, onClose, paths, senderInfo, contacts, config } -
- {/* Raw path summary */} -
- {paths.map((p, index) => { - const hops = parsePathHops(p.path); - const rawPath = hops.length > 0 ? hops.join('->') : 'direct'; - return ( -
- Path {index + 1}:{' '} - {rawPath} -
- ); - })} -
+ {hasPaths && ( +
+ {/* Raw path summary */} +
+ {paths.map((p, index) => { + const hops = parsePathHops(p.path); + const rawPath = hops.length > 0 ? hops.join('->') : 'direct'; + return ( +
+ Path {index + 1}:{' '} + {rawPath} +
+ ); + })} +
- {/* Straight-line distance (sender to receiver, same for all routes) */} - {resolvedPaths.length > 0 && - isValidLocation( - resolvedPaths[0].resolved.sender.lat, - resolvedPaths[0].resolved.sender.lon - ) && - isValidLocation( - resolvedPaths[0].resolved.receiver.lat, - resolvedPaths[0].resolved.receiver.lon - ) && ( -
- Straight-line distance: - - {formatDistance( - calculateDistance( - resolvedPaths[0].resolved.sender.lat, - resolvedPaths[0].resolved.sender.lon, - resolvedPaths[0].resolved.receiver.lat, - resolvedPaths[0].resolved.receiver.lon - )! - )} - -
- )} - - {resolvedPaths.map((pathData, index) => ( -
- {!hasSinglePath && ( -
- Path {index + 1}{' '} - - — received {formatTime(pathData.received_at)} + {/* Straight-line distance (sender to receiver, same for all routes) */} + {resolvedPaths.length > 0 && + isValidLocation( + resolvedPaths[0].resolved.sender.lat, + resolvedPaths[0].resolved.sender.lon + ) && + isValidLocation( + resolvedPaths[0].resolved.receiver.lat, + resolvedPaths[0].resolved.receiver.lon + ) && ( +
+ Straight-line distance: + + {formatDistance( + calculateDistance( + resolvedPaths[0].resolved.sender.lat, + resolvedPaths[0].resolved.sender.lon, + resolvedPaths[0].resolved.receiver.lat, + resolvedPaths[0].resolved.receiver.lon + )! + )}
)} - -
- ))} -
- - + {resolvedPaths.map((pathData, index) => ( +
+ {!hasSinglePath && ( +
+ Path {index + 1}{' '} + + — received {formatTime(pathData.received_at)} + +
+ )} + +
+ ))} +
+ )} + + + {hasResendActions && ( +
+ {isResendable && ( + + )} + +
+ )} +
diff --git a/frontend/src/hooks/useConversationRouter.ts b/frontend/src/hooks/useConversationRouter.ts index b8a4372..94d3c0e 100644 --- a/frontend/src/hooks/useConversationRouter.ts +++ b/frontend/src/hooks/useConversationRouter.ts @@ -131,7 +131,7 @@ export function useConversationRouter({ setActiveConversationState(publicConversation); hasSetDefaultConversation.current = true; } - }, [channels, activeConversation, getPublicChannelConversation]); + }, [channels, activeConversation, getPublicChannelConversation, hasSetDefaultConversation]); // Phase 2: Resolve contact hash (only if phase 1 didn't set a conversation) useEffect(() => { @@ -186,7 +186,14 @@ export function useConversationRouter({ hasSetDefaultConversation.current = true; } } - }, [contacts, channels, activeConversation, contactsLoaded, getPublicChannelConversation]); + }, [ + contacts, + channels, + activeConversation, + contactsLoaded, + getPublicChannelConversation, + hasSetDefaultConversation, + ]); // Keep ref in sync and update URL hash useEffect(() => { @@ -221,7 +228,7 @@ export function useConversationRouter({ id: publicChannel.key, name: publicChannel.name, }); - }, [activeConversation, channels]); + }, [activeConversation, channels, hasSetDefaultConversation, pendingDeleteFallbackRef]); // Handle conversation selection (closes sidebar on mobile) const handleSelectConversation = useCallback( diff --git a/tests/test_send_messages.py b/tests/test_send_messages.py index 23877df..44ed3b1 100644 --- a/tests/test_send_messages.py +++ b/tests/test_send_messages.py @@ -283,7 +283,7 @@ class TestResendChannelMessage: assert msg_id is not None with patch("app.routers.messages.require_connected", return_value=mc): - result = await resend_channel_message(msg_id) + result = await resend_channel_message(msg_id, new_timestamp=False) assert result["status"] == "ok" assert result["message_id"] == msg_id @@ -316,11 +316,42 @@ class TestResendChannelMessage: patch("app.routers.messages.require_connected", return_value=mc), pytest.raises(HTTPException) as exc_info, ): - await resend_channel_message(msg_id) + await resend_channel_message(msg_id, new_timestamp=False) assert exc_info.value.status_code == 400 assert "expired" in exc_info.value.detail.lower() + @pytest.mark.asyncio + async def test_resend_new_timestamp_collision_returns_original_id(self, test_db): + """When new-timestamp resend collides (same second), return original ID gracefully.""" + mc = _make_mc(name="MyNode") + chan_key = "dd" * 16 + await ChannelRepository.upsert(key=chan_key, name="#collision") + + now = int(time.time()) + msg_id = await MessageRepository.create( + msg_type="CHAN", + text="MyNode: duplicate", + conversation_key=chan_key.upper(), + sender_timestamp=now, + received_at=now, + outgoing=True, + ) + assert msg_id is not None + + with ( + patch("app.routers.messages.require_connected", return_value=mc), + patch("app.routers.messages.broadcast_event"), + patch("app.routers.messages.time") as mock_time, + ): + # Force the same second so MessageRepository.create returns None (duplicate) + mock_time.time.return_value = float(now) + result = await resend_channel_message(msg_id, new_timestamp=True) + + # Should succeed gracefully, returning the original message ID + assert result["status"] == "ok" + assert result["message_id"] == msg_id + @pytest.mark.asyncio async def test_resend_non_outgoing_returns_400(self, test_db): """Resend of incoming message fails.""" @@ -343,7 +374,7 @@ class TestResendChannelMessage: patch("app.routers.messages.require_connected", return_value=mc), pytest.raises(HTTPException) as exc_info, ): - await resend_channel_message(msg_id) + await resend_channel_message(msg_id, new_timestamp=False) assert exc_info.value.status_code == 400 assert "outgoing" in exc_info.value.detail.lower() @@ -369,7 +400,7 @@ class TestResendChannelMessage: patch("app.routers.messages.require_connected", return_value=mc), pytest.raises(HTTPException) as exc_info, ): - await resend_channel_message(msg_id) + await resend_channel_message(msg_id, new_timestamp=False) assert exc_info.value.status_code == 400 assert "channel" in exc_info.value.detail.lower() @@ -383,7 +414,7 @@ class TestResendChannelMessage: patch("app.routers.messages.require_connected", return_value=mc), pytest.raises(HTTPException) as exc_info, ): - await resend_channel_message(999999) + await resend_channel_message(999999, new_timestamp=False) assert exc_info.value.status_code == 404 @@ -406,7 +437,126 @@ class TestResendChannelMessage: assert msg_id is not None with patch("app.routers.messages.require_connected", return_value=mc): - await resend_channel_message(msg_id) + await resend_channel_message(msg_id, new_timestamp=False) call_kwargs = mc.commands.send_chan_msg.await_args.kwargs assert call_kwargs["msg"] == "hello world" + + @pytest.mark.asyncio + async def test_resend_new_timestamp_skips_window(self, test_db): + """new_timestamp=True succeeds even when the 30s window has expired.""" + mc = _make_mc(name="MyNode") + chan_key = "dd" * 16 + await ChannelRepository.upsert(key=chan_key, name="#old") + + old_ts = int(time.time()) - 60 # 60 seconds ago — outside byte-perfect window + msg_id = await MessageRepository.create( + msg_type="CHAN", + text="MyNode: old message", + conversation_key=chan_key.upper(), + sender_timestamp=old_ts, + received_at=old_ts, + outgoing=True, + ) + assert msg_id is not None + + with ( + patch("app.routers.messages.require_connected", return_value=mc), + patch("app.routers.messages.broadcast_event"), + ): + result = await resend_channel_message(msg_id, new_timestamp=True) + + assert result["status"] == "ok" + # Should return a NEW message id, not the original + assert result["message_id"] != msg_id + + @pytest.mark.asyncio + async def test_resend_new_timestamp_creates_new_message(self, test_db): + """new_timestamp=True creates a new DB row with a different sender_timestamp.""" + mc = _make_mc(name="MyNode") + chan_key = "dd" * 16 + await ChannelRepository.upsert(key=chan_key, name="#new") + + old_ts = int(time.time()) - 10 + msg_id = await MessageRepository.create( + msg_type="CHAN", + text="MyNode: test", + conversation_key=chan_key.upper(), + sender_timestamp=old_ts, + received_at=old_ts, + outgoing=True, + ) + assert msg_id is not None + + with ( + patch("app.routers.messages.require_connected", return_value=mc), + patch("app.routers.messages.broadcast_event"), + ): + result = await resend_channel_message(msg_id, new_timestamp=True) + + new_msg_id = result["message_id"] + new_msg = await MessageRepository.get_by_id(new_msg_id) + original_msg = await MessageRepository.get_by_id(msg_id) + + assert new_msg is not None + assert original_msg is not None + assert new_msg.sender_timestamp != original_msg.sender_timestamp + assert new_msg.text == original_msg.text + assert new_msg.outgoing is True + + @pytest.mark.asyncio + async def test_resend_new_timestamp_broadcasts_message(self, test_db): + """new_timestamp=True broadcasts the new message via WebSocket.""" + mc = _make_mc(name="MyNode") + chan_key = "dd" * 16 + await ChannelRepository.upsert(key=chan_key, name="#broadcast") + + old_ts = int(time.time()) - 5 + msg_id = await MessageRepository.create( + msg_type="CHAN", + text="MyNode: broadcast test", + conversation_key=chan_key.upper(), + sender_timestamp=old_ts, + received_at=old_ts, + outgoing=True, + ) + assert msg_id is not None + + with ( + patch("app.routers.messages.require_connected", return_value=mc), + patch("app.routers.messages.broadcast_event") as mock_broadcast, + ): + result = await resend_channel_message(msg_id, new_timestamp=True) + + mock_broadcast.assert_called_once() + event_type, event_data = mock_broadcast.call_args.args + assert event_type == "message" + assert event_data["id"] == result["message_id"] + assert event_data["outgoing"] is True + + @pytest.mark.asyncio + async def test_resend_byte_perfect_still_enforces_window(self, test_db): + """Default (byte-perfect) resend still enforces the 30s window.""" + mc = _make_mc(name="MyNode") + chan_key = "dd" * 16 + await ChannelRepository.upsert(key=chan_key, name="#window") + + old_ts = int(time.time()) - 60 + msg_id = await MessageRepository.create( + msg_type="CHAN", + text="MyNode: expired", + conversation_key=chan_key.upper(), + sender_timestamp=old_ts, + received_at=old_ts, + outgoing=True, + ) + assert msg_id is not None + + with ( + patch("app.routers.messages.require_connected", return_value=mc), + pytest.raises(HTTPException) as exc_info, + ): + await resend_channel_message(msg_id, new_timestamp=False) + + assert exc_info.value.status_code == 400 + assert "expired" in exc_info.value.detail.lower()