Forward whole message to FE on resend so the browser updates

This commit is contained in:
Jack Kingsman
2026-03-13 21:57:19 -07:00
parent f41c7756d3
commit 39a687da58
8 changed files with 149 additions and 6 deletions

View File

@@ -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)."""

View File

@@ -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.

View File

@@ -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}

View File

@@ -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<T>(url: string, options?: RequestInit): Promise<T> {
@@ -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<ResendChannelMessageResponse>(
`/messages/channel/${messageId}/resend${newTimestamp ? '?new_timestamp=true' : ''}`,
{ method: 'POST' }
),

View File

@@ -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(

View File

@@ -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 = {

View File

@@ -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."""

View File

@@ -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(