Move resend button into modal

This commit is contained in:
Jack Kingsman
2026-02-21 17:01:13 -08:00
parent 1e53fe9515
commit 7463f4e032
7 changed files with 411 additions and 139 deletions

View File

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

View File

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

View File

@@ -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'),

View File

@@ -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<Set<number>>(new Set());
const resendTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
const activeBurstsRef = useRef<Map<number, ReturnType<typeof setTimeout>[]>>(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<SenderInfo>(
() => ({
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) && (
<button
className="text-muted-foreground hover:text-primary ml-1 text-xs cursor-pointer"
onClick={(e) => {
e.stopPropagation();
if (e.altKey) {
// Burst resend: 5 times, 2 seconds apart
if (activeBurstsRef.current.has(msg.id)) return;
onResendChannelMessage(msg.id); // first send (immediate)
const msgId = msg.id;
const timers: ReturnType<typeof setTimeout>[] = [];
for (let i = 1; i <= 4; i++) {
const timer = setTimeout(() => {
onResendRef.current?.(msgId);
if (i === 4) activeBurstsRef.current.delete(msgId);
}, i * 3000);
timers.push(timer);
}
activeBurstsRef.current.set(msgId, timers);
} else {
onResendChannelMessage(msg.id);
}
}}
title="Resend message"
>
</button>
)}
{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({
) : (
<span className="text-muted-foreground">{`${msg.acked > 1 ? msg.acked : ''}`}</span>
)
) : onResendChannelMessage && msg.type === 'CHAN' ? (
<span
className="text-muted-foreground cursor-pointer hover:text-primary"
onClick={(e) => {
e.stopPropagation();
setSelectedPath({
paths: [],
senderInfo: selfSenderInfo,
messageId: msg.id,
isOutgoingChan: true,
});
}}
title="Message status"
>
{' '}
?
</span>
) : (
<span className="text-muted-foreground" title="No repeats heard yet">
{' '}
@@ -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}
/>
)}
</div>

View File

@@ -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 }
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="max-w-md max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>Message Path{!hasSinglePath && `s (${paths.length})`}</DialogTitle>
<DialogTitle>
{hasPaths
? `Message Path${!hasSinglePath ? `s (${paths.length})` : ''}`
: 'Message Status'}
</DialogTitle>
<DialogDescription>
{hasSinglePath ? (
{!hasPaths ? (
<>No echoes heard yet. Echoes appear when repeaters re-broadcast your message.</>
) : hasSinglePath ? (
<>
This shows <em>one route</em> 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 }
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-2 space-y-4">
{/* Raw path summary */}
<div className="text-sm">
{paths.map((p, index) => {
const hops = parsePathHops(p.path);
const rawPath = hops.length > 0 ? hops.join('->') : 'direct';
return (
<div key={index}>
<span className="text-foreground/70 font-semibold">Path {index + 1}:</span>{' '}
<span className="font-mono text-muted-foreground">{rawPath}</span>
</div>
);
})}
</div>
{hasPaths && (
<div className="flex-1 overflow-y-auto py-2 space-y-4">
{/* Raw path summary */}
<div className="text-sm">
{paths.map((p, index) => {
const hops = parsePathHops(p.path);
const rawPath = hops.length > 0 ? hops.join('->') : 'direct';
return (
<div key={index}>
<span className="text-foreground/70 font-semibold">Path {index + 1}:</span>{' '}
<span className="font-mono text-muted-foreground">{rawPath}</span>
</div>
);
})}
</div>
{/* 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
) && (
<div className="text-sm pb-2 border-b border-border">
<span className="text-muted-foreground">Straight-line distance: </span>
<span className="font-medium">
{formatDistance(
calculateDistance(
resolvedPaths[0].resolved.sender.lat,
resolvedPaths[0].resolved.sender.lon,
resolvedPaths[0].resolved.receiver.lat,
resolvedPaths[0].resolved.receiver.lon
)!
)}
</span>
</div>
)}
{resolvedPaths.map((pathData, index) => (
<div key={index}>
{!hasSinglePath && (
<div className="text-sm text-foreground/70 font-semibold mb-2 pb-1 border-b border-border">
Path {index + 1}{' '}
<span className="font-normal text-muted-foreground">
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
) && (
<div className="text-sm pb-2 border-b border-border">
<span className="text-muted-foreground">Straight-line distance: </span>
<span className="font-medium">
{formatDistance(
calculateDistance(
resolvedPaths[0].resolved.sender.lat,
resolvedPaths[0].resolved.sender.lon,
resolvedPaths[0].resolved.receiver.lat,
resolvedPaths[0].resolved.receiver.lon
)!
)}
</span>
</div>
)}
<PathVisualization resolved={pathData.resolved} senderInfo={senderInfo} />
</div>
))}
</div>
<DialogFooter>
<Button onClick={onClose}>Close</Button>
{resolvedPaths.map((pathData, index) => (
<div key={index}>
{!hasSinglePath && (
<div className="text-sm text-foreground/70 font-semibold mb-2 pb-1 border-b border-border">
Path {index + 1}{' '}
<span className="font-normal text-muted-foreground">
received {formatTime(pathData.received_at)}
</span>
</div>
)}
<PathVisualization resolved={pathData.resolved} senderInfo={senderInfo} />
</div>
))}
</div>
)}
<DialogFooter className="flex-col gap-2 sm:flex-col">
{hasResendActions && (
<div className="flex flex-col gap-2 w-full">
{isResendable && (
<Button
variant="outline"
className="w-full"
onClick={() => {
onResend(messageId);
onClose();
}}
>
Resend (byte-perfect)
</Button>
)}
<Button
variant="destructive"
className="w-full"
onClick={() => {
onResend(messageId, true);
onClose();
}}
>
<span className="flex flex-col items-center leading-tight">
<span> Resend as new</span>
<span className="text-[10px] font-normal opacity-80">
May cause duplicate receives
</span>
</span>
</Button>
</div>
)}
<Button variant="secondary" onClick={onClose} className="w-full">
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

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

View File

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