From 39a687da5811b8c6bc167e99a30bd84147df4a4b Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Fri, 13 Mar 2026 21:57:19 -0700 Subject: [PATCH] Forward whole message to FE on resend so the browser updates --- app/models.py | 6 ++ app/routers/messages.py | 9 ++- app/services/message_send.py | 2 +- frontend/src/api.ts | 8 ++- frontend/src/hooks/useConversationActions.ts | 13 +++- .../src/test/useConversationActions.test.ts | 68 +++++++++++++++++++ tests/test_api.py | 44 ++++++++++++ tests/test_send_messages.py | 5 ++ 8 files changed, 149 insertions(+), 6 deletions(-) diff --git a/app/models.py b/app/models.py index c34f67a..9a062cb 100644 --- a/app/models.py +++ b/app/models.py @@ -347,6 +347,12 @@ class MessagesAroundResponse(BaseModel): has_newer: bool +class ResendChannelMessageResponse(BaseModel): + status: str + message_id: int + message: Message | None = None + + class RawPacketDecryptedInfo(BaseModel): """Decryption info for a raw packet (when successfully decrypted).""" diff --git a/app/routers/messages.py b/app/routers/messages.py index 5b8a259..a2557e6 100644 --- a/app/routers/messages.py +++ b/app/routers/messages.py @@ -8,6 +8,7 @@ from app.event_handlers import track_pending_ack from app.models import ( Message, MessagesAroundResponse, + ResendChannelMessageResponse, SendChannelMessageRequest, SendDirectMessageRequest, ) @@ -171,11 +172,15 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message: RESEND_WINDOW_SECONDS = 30 -@router.post("/channel/{message_id}/resend") +@router.post( + "/channel/{message_id}/resend", + response_model=ResendChannelMessageResponse, + response_model_exclude_none=True, +) async def resend_channel_message( message_id: int, new_timestamp: bool = Query(default=False), -) -> dict: +) -> dict[str, object]: """Resend a channel message. When new_timestamp=False (default): byte-perfect resend using the original timestamp. diff --git a/app/services/message_send.py b/app/services/message_send.py index 6d5960b..1279ebb 100644 --- a/app/services/message_send.py +++ b/app/services/message_send.py @@ -530,7 +530,7 @@ async def resend_channel_message_record( new_message.id, channel.name, ) - return {"status": "ok", "message_id": new_message.id} + return {"status": "ok", "message_id": new_message.id, "message": new_message} logger.info("Resent channel message %d to %s", message.id, channel.name) return {"status": "ok", "message_id": message.id} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index ef6422c..df40a82 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -34,6 +34,12 @@ import type { UnreadCounts, } from './types'; +export interface ResendChannelMessageResponse { + status: string; + message_id: number; + message?: Message; +} + const API_BASE = '/api'; async function fetchJson(url: string, options?: RequestInit): Promise { @@ -238,7 +244,7 @@ export const api = { body: JSON.stringify({ channel_key: channelKey, text }), }), resendChannelMessage: (messageId: number, newTimestamp?: boolean) => - fetchJson<{ status: string; message_id: number }>( + fetchJson( `/messages/channel/${messageId}/resend${newTimestamp ? '?new_timestamp=true' : ''}`, { method: 'POST' } ), diff --git a/frontend/src/hooks/useConversationActions.ts b/frontend/src/hooks/useConversationActions.ts index 17cff93..f60b50d 100644 --- a/frontend/src/hooks/useConversationActions.ts +++ b/frontend/src/hooks/useConversationActions.ts @@ -69,7 +69,16 @@ export function useConversationActions({ const handleResendChannelMessage = useCallback( async (messageId: number, newTimestamp?: boolean) => { try { - await api.resendChannelMessage(messageId, newTimestamp); + const resent = await api.resendChannelMessage(messageId, newTimestamp); + const resentMessage = resent.message; + if ( + newTimestamp && + resentMessage && + activeConversationRef.current?.type === 'channel' && + activeConversationRef.current.id === resentMessage.conversation_key + ) { + addMessageIfNew(resentMessage); + } toast.success(newTimestamp ? 'Message resent with new timestamp' : 'Message resent'); } catch (err) { toast.error('Failed to resend', { @@ -77,7 +86,7 @@ export function useConversationActions({ }); } }, - [] + [activeConversationRef, addMessageIfNew] ); const handleSetChannelFloodScopeOverride = useCallback( diff --git a/frontend/src/test/useConversationActions.test.ts b/frontend/src/test/useConversationActions.test.ts index 5201dbe..b0ebb26 100644 --- a/frontend/src/test/useConversationActions.test.ts +++ b/frontend/src/test/useConversationActions.test.ts @@ -125,6 +125,74 @@ describe('useConversationActions', () => { expect(args.messageInputRef.current?.appendText).toHaveBeenCalledWith('@[Alice] '); }); + it('appends a new-timestamp resend immediately for the active channel', async () => { + const resentMessage: Message = { + ...sentMessage, + id: 99, + sender_timestamp: 1700000100, + received_at: 1700000100, + }; + mocks.api.resendChannelMessage.mockResolvedValue({ + status: 'ok', + message_id: resentMessage.id, + message: resentMessage, + }); + const args = createArgs(); + + const { result } = renderHook(() => useConversationActions(args)); + + await act(async () => { + await result.current.handleResendChannelMessage(sentMessage.id, true); + }); + + expect(mocks.api.resendChannelMessage).toHaveBeenCalledWith(sentMessage.id, true); + expect(args.addMessageIfNew).toHaveBeenCalledWith(resentMessage); + }); + + it('does not append a byte-perfect resend locally', async () => { + mocks.api.resendChannelMessage.mockResolvedValue({ + status: 'ok', + message_id: sentMessage.id, + }); + const args = createArgs(); + + const { result } = renderHook(() => useConversationActions(args)); + + await act(async () => { + await result.current.handleResendChannelMessage(sentMessage.id, false); + }); + + expect(args.addMessageIfNew).not.toHaveBeenCalled(); + }); + + it('does not append a resend if the user has switched conversations', async () => { + const resentMessage: Message = { + ...sentMessage, + id: 100, + sender_timestamp: 1700000200, + received_at: 1700000200, + }; + mocks.api.resendChannelMessage.mockResolvedValue({ + status: 'ok', + message_id: resentMessage.id, + message: resentMessage, + }); + const args = createArgs(); + const { result } = renderHook(() => useConversationActions(args)); + + await act(async () => { + const resendPromise = result.current.handleResendChannelMessage(sentMessage.id, true); + args.activeConversationRef.current = { + type: 'channel', + id: 'AA'.repeat(16), + name: 'Other', + }; + await resendPromise; + }); + + expect(args.addMessageIfNew).not.toHaveBeenCalled(); + }); + it('merges returned contact data after path discovery', async () => { const contactKey = 'aa'.repeat(32); const discoveredContact: Contact = { diff --git a/tests/test_api.py b/tests/test_api.py index 64be464..810a991 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -491,6 +491,50 @@ class TestMessagesEndpoint: assert send_kwargs["msg"] == "hello world" assert send_kwargs["timestamp"] == sent_at.to_bytes(4, "little") + @pytest.mark.asyncio + async def test_resend_channel_message_new_timestamp_returns_message_payload( + self, test_db, client + ): + """New-timestamp resend returns the created message payload for local UI append.""" + from meshcore import EventType + + chan_key = "EF" * 16 + await ChannelRepository.upsert(key=chan_key, name="#resend-new") + sent_at = int(time.time()) - 5 + msg_id = await MessageRepository.create( + msg_type="CHAN", + text="TestNode: hello again", + conversation_key=chan_key, + sender_timestamp=sent_at, + received_at=sent_at, + outgoing=True, + ) + assert msg_id is not None + + mock_mc = MagicMock() + mock_mc.self_info = {"name": "TestNode", "public_key": "ab" * 32} + mock_mc.commands = MagicMock() + mock_mc.commands.set_channel = AsyncMock( + return_value=MagicMock(type=EventType.OK, payload={}) + ) + mock_mc.commands.send_chan_msg = AsyncMock( + return_value=MagicMock(type=EventType.MSG_SENT, payload={}) + ) + + radio_manager._meshcore = mock_mc + with _patch_require_connected(mock_mc): + response = await client.post( + f"/api/messages/channel/{msg_id}/resend?new_timestamp=true" + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["status"] == "ok" + assert payload["message_id"] != msg_id + assert payload["message"]["id"] == payload["message_id"] + assert payload["message"]["conversation_key"] == chan_key + assert payload["message"]["outgoing"] is True + @pytest.mark.asyncio async def test_resend_channel_message_window_expired(self, test_db, client): """Resend endpoint rejects channel messages older than 30 seconds.""" diff --git a/tests/test_send_messages.py b/tests/test_send_messages.py index b7e3d14..6fee32e 100644 --- a/tests/test_send_messages.py +++ b/tests/test_send_messages.py @@ -766,6 +766,11 @@ class TestResendChannelMessage: assert result["message_id"] != msg_id resent = await MessageRepository.get_by_id(result["message_id"]) assert resent is not None + assert result["message"].id == resent.id + assert result["message"].conversation_key == resent.conversation_key + assert result["message"].text == resent.text + assert result["message"].sender_timestamp == resent.sender_timestamp + assert result["message"].outgoing is True assert resent.sender_timestamp == now + 1 assert resent.received_at == now sent_timestamp = int.from_bytes(