Add resend button for 30s

This commit is contained in:
Jack Kingsman
2026-02-14 17:37:51 -08:00
parent 7b2d5b817e
commit 5a82d469b4
23 changed files with 570 additions and 171 deletions
+15
View File
@@ -666,6 +666,18 @@ 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',
});
}
}, []);
// Handle sender click to add mention
const handleSenderClick = useCallback((sender: string) => {
messageInputRef.current?.appendText(`@[${sender}] `);
@@ -1182,6 +1194,9 @@ export function App() {
activeConversation.type === 'channel' ? handleSenderClick : undefined
}
onLoadOlder={fetchOlderMessages}
onResendChannelMessage={
activeConversation.type === 'channel' ? handleResendChannelMessage : undefined
}
radioName={config?.name}
config={config}
/>
+4
View File
@@ -166,6 +166,10 @@ 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',
}),
// Packets
getUndecryptedPacketCount: () => fetchJson<{ count: number }>('/packets/undecrypted/count'),
+61 -3
View File
@@ -23,6 +23,7 @@ interface MessageListProps {
hasOlderMessages?: boolean;
onSenderClick?: (sender: string) => void;
onLoadOlder?: () => void;
onResendChannelMessage?: (messageId: number) => void;
radioName?: string;
config?: RadioConfig | null;
}
@@ -134,6 +135,8 @@ function HopCountBadge({ paths, onClick, variant }: HopCountBadgeProps) {
);
}
const RESEND_WINDOW_SECONDS = 30;
export function MessageList({
messages,
contacts,
@@ -142,6 +145,7 @@ export function MessageList({
hasOlderMessages = false,
onSenderClick,
onLoadOlder,
onResendChannelMessage,
radioName,
config,
}: MessageListProps) {
@@ -153,6 +157,8 @@ export function MessageList({
paths: MessagePath[];
senderInfo: SenderInfo;
} | null>(null);
const [resendableIds, setResendableIds] = useState<Set<number>>(new Set());
const resendTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
// Capture scroll state in the scroll handler BEFORE any state updates
const scrollStateRef = useRef({
@@ -216,6 +222,43 @@ export function MessageList({
}
}, [messages.length]);
// Track resendable outgoing CHAN messages (within 30s window)
useEffect(() => {
if (!onResendChannelMessage) return;
const now = Math.floor(Date.now() / 1000);
const newResendable = new Set<number>();
const timers = resendTimersRef.current;
for (const msg of messages) {
if (!msg.outgoing || msg.type !== 'CHAN' || msg.sender_timestamp === null) continue;
const remaining = RESEND_WINDOW_SECONDS - (now - msg.sender_timestamp);
if (remaining <= 0) continue;
newResendable.add(msg.id);
// Schedule removal if not already tracked
if (!timers.has(msg.id)) {
const timer = setTimeout(() => {
setResendableIds((prev) => {
const next = new Set(prev);
next.delete(msg.id);
return next;
});
timers.delete(msg.id);
}, remaining * 1000);
timers.set(msg.id, timer);
}
}
setResendableIds(newResendable);
return () => {
for (const timer of timers.values()) clearTimeout(timer);
timers.clear();
};
}, [messages, onResendChannelMessage]);
// Handle scroll - capture state and detect when user is near top/bottom
const handleScroll = useCallback(() => {
if (!listRef.current) return;
@@ -463,11 +506,23 @@ 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();
onResendChannelMessage(msg.id);
}}
title="Resend message"
>
</button>
)}
{msg.outgoing &&
(msg.acked > 0 ? (
msg.paths && msg.paths.length > 0 ? (
<span
className="cursor-pointer hover:text-primary"
className="text-muted-foreground cursor-pointer hover:text-primary"
onClick={(e) => {
e.stopPropagation();
setSelectedPath({
@@ -483,10 +538,13 @@ export function MessageList({
title="View echo paths"
>{`${msg.acked > 1 ? msg.acked : ''}`}</span>
) : (
`${msg.acked > 1 ? msg.acked : ''}`
<span className="text-muted-foreground">{`${msg.acked > 1 ? msg.acked : ''}`}</span>
)
) : (
<span title="No repeats heard yet"> ?</span>
<span className="text-muted-foreground" title="No repeats heard yet">
{' '}
?
</span>
))}
</div>
</div>
-26
View File
@@ -123,7 +123,6 @@ export function SettingsModal(props: SettingsModalProps) {
const [cr, setCr] = useState('');
const [privateKey, setPrivateKey] = useState('');
const [maxRadioContacts, setMaxRadioContacts] = useState('');
const [experimentalChannelDoubleSend, setExperimentalChannelDoubleSend] = useState(false);
// Loading states
const [busySection, setBusySection] = useState<SettingsSection | null>(null);
@@ -202,7 +201,6 @@ export function SettingsModal(props: SettingsModalProps) {
useEffect(() => {
if (appSettings) {
setMaxRadioContacts(String(appSettings.max_radio_contacts));
setExperimentalChannelDoubleSend(appSettings.experimental_channel_double_send);
setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert);
setAdvertInterval(String(appSettings.advert_interval));
setBots(appSettings.bots || []);
@@ -368,9 +366,6 @@ export function SettingsModal(props: SettingsModalProps) {
if (!isNaN(newMaxRadioContacts) && newMaxRadioContacts !== appSettings?.max_radio_contacts) {
update.max_radio_contacts = newMaxRadioContacts;
}
if (experimentalChannelDoubleSend !== appSettings?.experimental_channel_double_send) {
update.experimental_channel_double_send = experimentalChannelDoubleSend;
}
if (Object.keys(update).length > 0) {
await onSaveAppSettings(update);
}
@@ -900,27 +895,6 @@ export function SettingsModal(props: SettingsModalProps) {
</p>
</div>
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-md space-y-3">
<p className="text-sm text-yellow-500">
<strong>Experimental:</strong> Adds a duplicate channel send after a 3-second
delay, using the exact same timestamp bytes.
</p>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={experimentalChannelDoubleSend}
onChange={(e) => setExperimentalChannelDoubleSend(e.target.checked)}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Always send channel messages twice</span>
</label>
<p className="text-xs text-muted-foreground">
This increases channel airtime and adds a 3-second second-attempt delay. Most
clients deduplicate repeats by payload and timestamp, but behavior can vary by
firmware/client.
</p>
</div>
<Button
onClick={handleSaveConnectivity}
disabled={isSectionBusy('connectivity')}
-1
View File
@@ -182,7 +182,6 @@ const baseConfig = {
const baseSettings = {
max_radio_contacts: 200,
experimental_channel_double_send: false,
favorites: [] as Array<{ type: 'channel' | 'contact'; id: string }>,
auto_decrypt_dm_on_advert: false,
sidebar_sort_order: 'recent' as const,
@@ -158,7 +158,6 @@ describe('App startup hash resolution', () => {
});
mocks.api.getSettings.mockResolvedValue({
max_radio_contacts: 200,
experimental_channel_double_send: false,
favorites: [],
auto_decrypt_dm_on_advert: false,
sidebar_sort_order: 'recent',
-19
View File
@@ -36,7 +36,6 @@ const baseHealth: HealthStatus = {
const baseSettings: AppSettings = {
max_radio_contacts: 200,
experimental_channel_double_send: false,
favorites: [],
auto_decrypt_dm_on_advert: false,
sidebar_sort_order: 'recent',
@@ -194,24 +193,6 @@ describe('SettingsModal', () => {
});
});
it('saves experimental channel double-send toggle through onSaveAppSettings', async () => {
const { onSaveAppSettings } = renderModal({
appSettings: { ...baseSettings, experimental_channel_double_send: false },
});
openConnectivitySection();
const toggle = screen.getByLabelText('Always send channel messages twice');
fireEvent.click(toggle);
fireEvent.click(screen.getByRole('button', { name: 'Save Settings' }));
await waitFor(() => {
expect(onSaveAppSettings).toHaveBeenCalledWith({
experimental_channel_double_send: true,
});
});
});
it('renders selected section from external sidebar nav on desktop mode', async () => {
renderModal({
externalSidebarNav: true,
-2
View File
@@ -124,7 +124,6 @@ export interface BotConfig {
export interface AppSettings {
max_radio_contacts: number;
experimental_channel_double_send: boolean;
favorites: Favorite[];
auto_decrypt_dm_on_advert: boolean;
sidebar_sort_order: 'recent' | 'alpha';
@@ -137,7 +136,6 @@ export interface AppSettings {
export interface AppSettingsUpdate {
max_radio_contacts?: number;
experimental_channel_double_send?: boolean;
auto_decrypt_dm_on_advert?: boolean;
sidebar_sort_order?: 'recent' | 'alpha';
advert_interval?: number;