Improve frontend response time

This commit is contained in:
Jack Kingsman
2026-02-03 15:58:52 -08:00
parent c167e59a64
commit b302bd74ff
17 changed files with 1015 additions and 777 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -13,7 +13,7 @@
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<script type="module" crossorigin src="/assets/index-CL0FbnNJ.js"></script>
<script type="module" crossorigin src="/assets/index-BrthzT77.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DJA5wYVF.css">
</head>
<body>
+94 -47
View File
@@ -282,12 +282,32 @@ export function App() {
}
}, []);
// Initial fetch for config and settings
// Fetch all contacts, paginating if >1000
const fetchAllContacts = useCallback(async (): Promise<Contact[]> => {
const pageSize = 1000;
const first = await api.getContacts(pageSize, 0);
if (first.length < pageSize) return first;
let all = [...first];
let offset = pageSize;
while (true) {
const page = await api.getContacts(pageSize, offset);
all = all.concat(page);
if (page.length < pageSize) break;
offset += pageSize;
}
return all;
}, []);
// Initial fetch for config, settings, and data
useEffect(() => {
fetchConfig();
fetchAppSettings();
fetchUndecryptedCount();
}, [fetchConfig, fetchAppSettings, fetchUndecryptedCount]);
// Fetch contacts and channels via REST (parallel, faster than WS serial push)
api.getChannels().then(setChannels).catch(console.error);
fetchAllContacts().then(setContacts).catch(console.error);
}, [fetchConfig, fetchAppSettings, fetchUndecryptedCount, fetchAllContacts]);
// One-time migration of localStorage preferences to server
const hasMigratedRef = useRef(false);
@@ -355,61 +375,53 @@ export function App() {
migratePreferences();
}, [appSettings]);
// Resolve URL hash to a conversation
const resolveHashToConversation = useCallback((): Conversation | null => {
const hashConv = parseHashConversation();
if (!hashConv) return null;
// Phase 1: Set initial conversation from URL hash or default to Public channel
// Only needs channels (fast path) - doesn't wait for contacts
const hasSetDefaultConversation = useRef(false);
useEffect(() => {
if (hasSetDefaultConversation.current || activeConversation) return;
if (channels.length === 0) return;
if (hashConv.type === 'raw') {
return { type: 'raw', id: 'raw', name: 'Raw Packet Feed' };
const hashConv = parseHashConversation();
// Handle non-data views immediately
if (hashConv?.type === 'raw') {
setActiveConversation({ type: 'raw', id: 'raw', name: 'Raw Packet Feed' });
hasSetDefaultConversation.current = true;
return;
}
if (hashConv.type === 'map') {
return {
if (hashConv?.type === 'map') {
setActiveConversation({
type: 'map',
id: 'map',
name: 'Node Map',
mapFocusKey: hashConv.mapFocusKey,
};
});
hasSetDefaultConversation.current = true;
return;
}
if (hashConv.type === 'visualizer') {
return { type: 'visualizer', id: 'visualizer', name: 'Mesh Visualizer' };
}
if (hashConv.type === 'channel') {
const channel = channels.find(
(c) => c.name === hashConv.name || c.name === `#${hashConv.name}`
);
if (channel) {
return { type: 'channel', id: channel.key, name: channel.name };
}
}
if (hashConv.type === 'contact') {
const contact = contacts.find(
(c) => getContactDisplayName(c.name, c.public_key) === hashConv.name
);
if (contact) {
return {
type: 'contact',
id: contact.public_key,
name: getContactDisplayName(contact.name, contact.public_key),
};
}
}
return null;
}, [channels, contacts]);
// Set initial conversation from URL hash or default to Public channel
const hasSetDefaultConversation = useRef(false);
useEffect(() => {
if (hasSetDefaultConversation.current || activeConversation) return;
if (channels.length === 0 && contacts.length === 0) return;
const conv = resolveHashToConversation();
if (conv) {
setActiveConversation(conv);
if (hashConv?.type === 'visualizer') {
setActiveConversation({ type: 'visualizer', id: 'visualizer', name: 'Mesh Visualizer' });
hasSetDefaultConversation.current = true;
return;
}
// Handle channel hash
if (hashConv?.type === 'channel') {
const channel = channels.find(
(c) => c.name === hashConv.name || c.name === `#${hashConv.name}`
);
if (channel) {
setActiveConversation({ type: 'channel', id: channel.key, name: channel.name });
hasSetDefaultConversation.current = true;
return;
}
}
// Contact hash — wait for phase 2
if (hashConv?.type === 'contact') return;
// No hash or unresolvable — default to Public
const publicChannel = channels.find((c) => c.name === 'Public');
if (publicChannel) {
setActiveConversation({
@@ -419,7 +431,42 @@ export function App() {
});
hasSetDefaultConversation.current = true;
}
}, [channels, contacts, activeConversation, resolveHashToConversation]);
}, [channels, activeConversation]);
// Phase 2: Resolve contact hash (only if phase 1 didn't set a conversation)
useEffect(() => {
if (hasSetDefaultConversation.current || activeConversation) return;
if (contacts.length === 0) return;
const hashConv = parseHashConversation();
if (hashConv?.type === 'contact') {
const contact = contacts.find(
(c) => getContactDisplayName(c.name, c.public_key) === hashConv.name
);
if (contact) {
setActiveConversation({
type: 'contact',
id: contact.public_key,
name: getContactDisplayName(contact.name, contact.public_key),
});
hasSetDefaultConversation.current = true;
return;
}
}
// Contact hash didn't match — fall back to Public if channels loaded
if (channels.length > 0) {
const publicChannel = channels.find((c) => c.name === 'Public');
if (publicChannel) {
setActiveConversation({
type: 'channel',
id: publicChannel.key,
name: publicChannel.name,
});
hasSetDefaultConversation.current = true;
}
}
}, [contacts, channels, activeConversation]);
// Keep ref in sync and update URL hash
useEffect(() => {
+5 -14
View File
@@ -13,13 +13,11 @@ import type {
RadioConfig,
RadioConfigUpdate,
TelemetryResponse,
UnreadCounts,
} from './types';
const API_BASE = '/api';
/** Max messages fetched per conversation for unread counting. If count equals this, there may be more. */
export const UNREAD_FETCH_LIMIT = 100;
async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${url}`, {
...options,
@@ -153,17 +151,6 @@ export const api = {
const query = searchParams.toString();
return fetchJson<Message[]>(`/messages${query ? `?${query}` : ''}`, { signal });
},
getMessagesBulk: (
conversations: Array<{ type: 'PRIV' | 'CHAN'; conversation_key: string }>,
limitPerConversation: number = UNREAD_FETCH_LIMIT
) =>
fetchJson<Record<string, Message[]>>(
`/messages/bulk?limit_per_conversation=${limitPerConversation}`,
{
method: 'POST',
body: JSON.stringify(conversations),
}
),
sendDirectMessage: (destination: string, text: string) =>
fetchJson<Message>('/messages/direct', {
method: 'POST',
@@ -193,6 +180,10 @@ export const api = {
}),
// Read State
getUnreads: (name?: string) => {
const params = name ? `?name=${encodeURIComponent(name)}` : '';
return fetchJson<UnreadCounts>(`/read-state/unreads${params}`);
},
markAllRead: () =>
fetchJson<{ status: string; timestamp: number }>('/read-state/mark-all-read', {
method: 'POST',
+2 -3
View File
@@ -5,7 +5,6 @@ import { getContactDisplayName } from '../utils/pubkey';
import { ContactAvatar } from './ContactAvatar';
import { CONTACT_TYPE_REPEATER } from '../utils/contactAvatar';
import { isFavorite } from '../utils/favorites';
import { UNREAD_FETCH_LIMIT } from '../api';
import { Input } from './ui/input';
import { Button } from './ui/button';
import { cn } from '@/lib/utils';
@@ -33,9 +32,9 @@ interface SidebarProps {
onSortOrderChange?: (order: SortOrder) => void;
}
/** Format unread count, showing "X+" if at the fetch limit (indicating there may be more) */
/** Format unread count for display */
function formatUnreadCount(count: number): string {
return count >= UNREAD_FETCH_LIMIT ? `${count}+` : `${count}`;
return `${count}`;
}
export function Sidebar({
+24 -106
View File
@@ -1,5 +1,5 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { api, UNREAD_FETCH_LIMIT } from '../api';
import { api } from '../api';
import {
getLastMessageTimes,
setLastMessageTime,
@@ -19,15 +19,6 @@ export interface UseUnreadCountsResult {
trackNewMessage: (msg: Message) => void;
}
/** Check if a message text contains a mention of the given name in @[name] format */
function messageContainsMention(text: string, name: string | null): boolean {
if (!name) return false;
// Escape special regex characters in the name
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const mentionPattern = new RegExp(`@\\[${escaped}\\]`, 'i');
return mentionPattern.test(text);
}
export function useUnreadCounts(
channels: Channel[],
contacts: Contact[],
@@ -44,106 +35,32 @@ export function useUnreadCounts(
myNameRef.current = myName;
}, [myName]);
// Track which channels/contacts we've already fetched unreads for
const fetchedChannels = useRef<Set<string>>(new Set());
const fetchedContacts = useRef<Set<string>>(new Set());
// Fetch unreads from the server-side endpoint
const fetchUnreads = useCallback(async () => {
try {
const data = await api.getUnreads(myNameRef.current ?? undefined);
// Fetch messages and count unreads for new channels/contacts
// Uses server-side last_read_at for consistent read state across devices
useEffect(() => {
const newChannels = channels.filter((c) => !fetchedChannels.current.has(c.key));
const newContacts = contacts.filter(
(c) => c.public_key && !fetchedContacts.current.has(c.public_key)
);
// Replace (not merge) — server counts are authoritative
setUnreadCounts(data.counts);
setMentions(data.mentions);
if (newChannels.length === 0 && newContacts.length === 0) return;
// Mark as fetched before starting (to avoid duplicate fetches if effect re-runs)
newChannels.forEach((c) => fetchedChannels.current.add(c.key));
newContacts.forEach((c) => fetchedContacts.current.add(c.public_key));
const fetchAndCountUnreads = async () => {
const conversations: Array<{ type: 'PRIV' | 'CHAN'; conversation_key: string }> = [
...newChannels.map((c) => ({ type: 'CHAN' as const, conversation_key: c.key })),
...newContacts.map((c) => ({ type: 'PRIV' as const, conversation_key: c.public_key })),
];
if (conversations.length === 0) return;
try {
// Fetch messages in chunks to avoid huge single requests
const chunkSize = 200;
const bulkMessages: Record<string, Message[]> = {};
for (let i = 0; i < conversations.length; i += chunkSize) {
const chunk = conversations.slice(i, i + chunkSize);
const chunkResult = await api.getMessagesBulk(chunk, UNREAD_FETCH_LIMIT);
Object.assign(bulkMessages, chunkResult);
}
const newUnreadCounts: Record<string, number> = {};
const newMentions: Record<string, boolean> = {};
const newLastMessageTimes: Record<string, number> = {};
// Process channel messages - use server-side last_read_at
for (const channel of newChannels) {
const msgs = bulkMessages[`CHAN:${channel.key}`] || [];
if (msgs.length > 0) {
const key = getStateKey('channel', channel.key);
// Use server-side last_read_at, fallback to 0 if never read
const lastRead = channel.last_read_at || 0;
const unreadMsgs = msgs.filter((m) => !m.outgoing && m.received_at > lastRead);
if (unreadMsgs.length > 0) {
newUnreadCounts[key] = unreadMsgs.length;
// Check if any unread message mentions the user
if (unreadMsgs.some((m) => messageContainsMention(m.text, myNameRef.current))) {
newMentions[key] = true;
}
}
const latestTime = Math.max(...msgs.map((m) => m.received_at));
newLastMessageTimes[key] = latestTime;
setLastMessageTime(key, latestTime);
}
}
// Process contact messages - use server-side last_read_at
for (const contact of newContacts) {
const msgs = bulkMessages[`PRIV:${contact.public_key}`] || [];
if (msgs.length > 0) {
const key = getStateKey('contact', contact.public_key);
// Use server-side last_read_at, fallback to 0 if never read
const lastRead = contact.last_read_at || 0;
const unreadMsgs = msgs.filter((m) => !m.outgoing && m.received_at > lastRead);
if (unreadMsgs.length > 0) {
newUnreadCounts[key] = unreadMsgs.length;
// Check if any unread message mentions the user
if (unreadMsgs.some((m) => messageContainsMention(m.text, myNameRef.current))) {
newMentions[key] = true;
}
}
const latestTime = Math.max(...msgs.map((m) => m.received_at));
newLastMessageTimes[key] = latestTime;
setLastMessageTime(key, latestTime);
}
}
if (Object.keys(newUnreadCounts).length > 0) {
setUnreadCounts((prev) => ({ ...prev, ...newUnreadCounts }));
}
if (Object.keys(newMentions).length > 0) {
setMentions((prev) => ({ ...prev, ...newMentions }));
if (Object.keys(data.last_message_times).length > 0) {
// Update in-memory cache and state
for (const [key, ts] of Object.entries(data.last_message_times)) {
setLastMessageTime(key, ts);
}
setLastMessageTimes(getLastMessageTimes());
} catch (err) {
console.error('Failed to fetch messages bulk:', err);
}
};
} catch (err) {
console.error('Failed to fetch unreads:', err);
}
}, []);
fetchAndCountUnreads();
}, [channels, contacts]);
// Fetch when channels or contacts arrive/change
useEffect(() => {
if (channels.length === 0 && contacts.length === 0) return;
fetchUnreads();
}, [channels, contacts, fetchUnreads]);
// Mark conversation as read when user views it
// Calls server API to persist read state across devices
@@ -151,7 +68,8 @@ export function useUnreadCounts(
if (
activeConversation &&
activeConversation.type !== 'raw' &&
activeConversation.type !== 'map'
activeConversation.type !== 'map' &&
activeConversation.type !== 'visualizer'
) {
const key = getStateKey(
activeConversation.type as 'channel' | 'contact',
@@ -221,7 +139,7 @@ export function useUnreadCounts(
// Mark a specific conversation as read
// Calls server API to persist read state across devices
const markConversationRead = useCallback((conv: Conversation) => {
if (conv.type === 'raw' || conv.type === 'map') return;
if (conv.type === 'raw' || conv.type === 'map' || conv.type === 'visualizer') return;
const key = getStateKey(conv.type as 'channel' | 'contact', conv.id);
+6
View File
@@ -209,3 +209,9 @@ export interface CommandResponse {
response: string;
sender_timestamp: number | null;
}
export interface UnreadCounts {
counts: Record<string, number>;
mentions: Record<string, boolean>;
last_message_times: Record<string, number>;
}