mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-07 09:13:04 +02:00
Move resend button into modal
This commit is contained in:
+15
-10
@@ -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) => {
|
||||
|
||||
+5
-4
@@ -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'),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user