mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-01 06:22:54 +02:00
Improve frontend response time
This commit is contained in:
+94
-47
@@ -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
@@ -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',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user