mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-12 11:43:03 +02:00
Calm down sidebar refreshes with better contact don't-set behavior, unread count checks, and memoized sorting etc.
This commit is contained in:
+24
-12
@@ -90,8 +90,10 @@ export function App() {
|
|||||||
const [showCracker, setShowCracker] = useState(false);
|
const [showCracker, setShowCracker] = useState(false);
|
||||||
const [crackerRunning, setCrackerRunning] = useState(false);
|
const [crackerRunning, setCrackerRunning] = useState(false);
|
||||||
|
|
||||||
// Favorites are now stored server-side in appSettings
|
// Favorites are now stored server-side in appSettings.
|
||||||
const favorites: Favorite[] = appSettings?.favorites ?? [];
|
// Stable empty array prevents a new reference every render when there are none.
|
||||||
|
const emptyFavorites = useRef<Favorite[]>([]).current;
|
||||||
|
const favorites: Favorite[] = appSettings?.favorites ?? emptyFavorites;
|
||||||
|
|
||||||
// Track previous health status to detect changes
|
// Track previous health status to detect changes
|
||||||
const prevHealthRef = useRef<HealthStatus | null>(null);
|
const prevHealthRef = useRef<HealthStatus | null>(null);
|
||||||
@@ -244,12 +246,16 @@ export function App() {
|
|||||||
setContacts((prev) => {
|
setContacts((prev) => {
|
||||||
const idx = prev.findIndex((c) => c.public_key === contact.public_key);
|
const idx = prev.findIndex((c) => c.public_key === contact.public_key);
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
const updated = [...prev];
|
|
||||||
const existing = prev[idx];
|
const existing = prev[idx];
|
||||||
updated[idx] = {
|
// Skip update if all incoming fields are identical — avoids a new
|
||||||
...existing,
|
// array reference (and Sidebar re-render) on every advertisement.
|
||||||
...contact,
|
const merged = { ...existing, ...contact };
|
||||||
};
|
const unchanged = (Object.keys(merged) as (keyof Contact)[]).every(
|
||||||
|
(k) => existing[k] === merged[k]
|
||||||
|
);
|
||||||
|
if (unchanged) return prev;
|
||||||
|
const updated = [...prev];
|
||||||
|
updated[idx] = merged;
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
return [...prev, contact as Contact];
|
return [...prev, contact as Contact];
|
||||||
@@ -853,6 +859,15 @@ export function App() {
|
|||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleNewMessage = useCallback(() => {
|
||||||
|
setShowNewMessage(true);
|
||||||
|
setSidebarOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToggleCracker = useCallback(() => {
|
||||||
|
setShowCracker((prev) => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Sidebar content (shared between desktop and mobile)
|
// Sidebar content (shared between desktop and mobile)
|
||||||
const sidebarContent = (
|
const sidebarContent = (
|
||||||
<Sidebar
|
<Sidebar
|
||||||
@@ -860,16 +875,13 @@ export function App() {
|
|||||||
channels={channels}
|
channels={channels}
|
||||||
activeConversation={activeConversation}
|
activeConversation={activeConversation}
|
||||||
onSelectConversation={handleSelectConversation}
|
onSelectConversation={handleSelectConversation}
|
||||||
onNewMessage={() => {
|
onNewMessage={handleNewMessage}
|
||||||
setShowNewMessage(true);
|
|
||||||
setSidebarOpen(false);
|
|
||||||
}}
|
|
||||||
lastMessageTimes={lastMessageTimes}
|
lastMessageTimes={lastMessageTimes}
|
||||||
unreadCounts={unreadCounts}
|
unreadCounts={unreadCounts}
|
||||||
mentions={mentions}
|
mentions={mentions}
|
||||||
showCracker={showCracker}
|
showCracker={showCracker}
|
||||||
crackerRunning={crackerRunning}
|
crackerRunning={crackerRunning}
|
||||||
onToggleCracker={() => setShowCracker((prev) => !prev)}
|
onToggleCracker={handleToggleCracker}
|
||||||
onMarkAllRead={markAllRead}
|
onMarkAllRead={markAllRead}
|
||||||
favorites={favorites}
|
favorites={favorites}
|
||||||
sortOrder={appSettings?.sidebar_sort_order ?? 'recent'}
|
sortOrder={appSettings?.sidebar_sort_order ?? 'recent'}
|
||||||
|
|||||||
+128
-106
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
CONTACT_TYPE_REPEATER,
|
CONTACT_TYPE_REPEATER,
|
||||||
type Contact,
|
type Contact,
|
||||||
@@ -82,132 +82,154 @@ export function Sidebar({
|
|||||||
return mentions[key] || false;
|
return mentions[key] || false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getLastMessageTime = (type: 'channel' | 'contact', id: string) => {
|
const getLastMessageTime = useCallback(
|
||||||
const key = getStateKey(type, id);
|
(type: 'channel' | 'contact', id: string) => {
|
||||||
return lastMessageTimes[key] || 0;
|
const key = getStateKey(type, id);
|
||||||
};
|
return lastMessageTimes[key] || 0;
|
||||||
|
},
|
||||||
|
[lastMessageTimes]
|
||||||
|
);
|
||||||
|
|
||||||
// Deduplicate channels by name, keeping the first (lowest index)
|
// Deduplicate channels by name, keeping the first (lowest index)
|
||||||
const uniqueChannels = channels.reduce<Channel[]>((acc, channel) => {
|
const uniqueChannels = useMemo(
|
||||||
if (!acc.some((c) => c.name === channel.name)) {
|
() =>
|
||||||
acc.push(channel);
|
channels.reduce<Channel[]>((acc, channel) => {
|
||||||
}
|
if (!acc.some((c) => c.name === channel.name)) {
|
||||||
return acc;
|
acc.push(channel);
|
||||||
}, []);
|
}
|
||||||
|
return acc;
|
||||||
|
}, []),
|
||||||
|
[channels]
|
||||||
|
);
|
||||||
|
|
||||||
// Deduplicate contacts by public key, preferring ones with names
|
// Deduplicate contacts by public key, preferring ones with names
|
||||||
// Also filter out any contacts with empty public keys
|
// Also filter out any contacts with empty public keys
|
||||||
const uniqueContacts = contacts
|
const uniqueContacts = useMemo(
|
||||||
.filter((c) => c.public_key && c.public_key.length > 0)
|
() =>
|
||||||
.sort((a, b) => {
|
contacts
|
||||||
// Sort contacts with names first
|
.filter((c) => c.public_key && c.public_key.length > 0)
|
||||||
if (a.name && !b.name) return -1;
|
.sort((a, b) => {
|
||||||
if (!a.name && b.name) return 1;
|
// Sort contacts with names first
|
||||||
return (a.name || '').localeCompare(b.name || '');
|
if (a.name && !b.name) return -1;
|
||||||
})
|
if (!a.name && b.name) return 1;
|
||||||
.reduce<Contact[]>((acc, contact) => {
|
return (a.name || '').localeCompare(b.name || '');
|
||||||
if (!acc.some((c) => c.public_key === contact.public_key)) {
|
})
|
||||||
acc.push(contact);
|
.reduce<Contact[]>((acc, contact) => {
|
||||||
}
|
if (!acc.some((c) => c.public_key === contact.public_key)) {
|
||||||
return acc;
|
acc.push(contact);
|
||||||
}, []);
|
}
|
||||||
|
return acc;
|
||||||
|
}, []),
|
||||||
|
[contacts]
|
||||||
|
);
|
||||||
|
|
||||||
// Sort channels based on sort order, with Public always first
|
// Sort channels based on sort order, with Public always first
|
||||||
const sortedChannels = [...uniqueChannels].sort((a, b) => {
|
const sortedChannels = useMemo(
|
||||||
// Public channel always sorts to the top
|
() =>
|
||||||
if (a.name === 'Public') return -1;
|
[...uniqueChannels].sort((a, b) => {
|
||||||
if (b.name === 'Public') return 1;
|
// Public channel always sorts to the top
|
||||||
|
if (a.name === 'Public') return -1;
|
||||||
|
if (b.name === 'Public') return 1;
|
||||||
|
|
||||||
if (sortOrder === 'recent') {
|
if (sortOrder === 'recent') {
|
||||||
const timeA = getLastMessageTime('channel', a.key);
|
const timeA = getLastMessageTime('channel', a.key);
|
||||||
const timeB = getLastMessageTime('channel', b.key);
|
const timeB = getLastMessageTime('channel', b.key);
|
||||||
// If both have messages, sort by most recent first
|
if (timeA && timeB) return timeB - timeA;
|
||||||
if (timeA && timeB) return timeB - timeA;
|
if (timeA && !timeB) return -1;
|
||||||
// Items with messages come before items without
|
if (!timeA && timeB) return 1;
|
||||||
if (timeA && !timeB) return -1;
|
}
|
||||||
if (!timeA && timeB) return 1;
|
return a.name.localeCompare(b.name);
|
||||||
// Fall back to alpha for items without messages
|
}),
|
||||||
}
|
[uniqueChannels, sortOrder, getLastMessageTime]
|
||||||
return a.name.localeCompare(b.name);
|
);
|
||||||
});
|
|
||||||
|
|
||||||
// Sort contacts: non-repeaters first (by recent or alpha), then repeaters (always alpha)
|
// Sort contacts: non-repeaters first (by recent or alpha), then repeaters (always alpha)
|
||||||
const sortedContacts = [...uniqueContacts].sort((a, b) => {
|
const sortedContacts = useMemo(
|
||||||
const aIsRepeater = a.type === CONTACT_TYPE_REPEATER;
|
() =>
|
||||||
const bIsRepeater = b.type === CONTACT_TYPE_REPEATER;
|
[...uniqueContacts].sort((a, b) => {
|
||||||
|
const aIsRepeater = a.type === CONTACT_TYPE_REPEATER;
|
||||||
|
const bIsRepeater = b.type === CONTACT_TYPE_REPEATER;
|
||||||
|
|
||||||
// Repeaters always go to the bottom
|
if (aIsRepeater && !bIsRepeater) return 1;
|
||||||
if (aIsRepeater && !bIsRepeater) return 1;
|
if (!aIsRepeater && bIsRepeater) return -1;
|
||||||
if (!aIsRepeater && bIsRepeater) return -1;
|
|
||||||
|
|
||||||
// Both repeaters: always sort alphabetically
|
if (aIsRepeater && bIsRepeater) {
|
||||||
if (aIsRepeater && bIsRepeater) {
|
return (a.name || a.public_key).localeCompare(b.name || b.public_key);
|
||||||
return (a.name || a.public_key).localeCompare(b.name || b.public_key);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Both non-repeaters: use selected sort order
|
if (sortOrder === 'recent') {
|
||||||
if (sortOrder === 'recent') {
|
const timeA = getLastMessageTime('contact', a.public_key);
|
||||||
const timeA = getLastMessageTime('contact', a.public_key);
|
const timeB = getLastMessageTime('contact', b.public_key);
|
||||||
const timeB = getLastMessageTime('contact', b.public_key);
|
if (timeA && timeB) return timeB - timeA;
|
||||||
// If both have messages, sort by most recent first
|
if (timeA && !timeB) return -1;
|
||||||
if (timeA && timeB) return timeB - timeA;
|
if (!timeA && timeB) return 1;
|
||||||
// Items with messages come before items without
|
}
|
||||||
if (timeA && !timeB) return -1;
|
return (a.name || a.public_key).localeCompare(b.name || b.public_key);
|
||||||
if (!timeA && timeB) return 1;
|
}),
|
||||||
// Fall back to alpha for items without messages
|
[uniqueContacts, sortOrder, getLastMessageTime]
|
||||||
}
|
);
|
||||||
return (a.name || a.public_key).localeCompare(b.name || b.public_key);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Filter by search query
|
// Filter by search query
|
||||||
const query = searchQuery.toLowerCase().trim();
|
const query = searchQuery.toLowerCase().trim();
|
||||||
const filteredChannels = query
|
const filteredChannels = useMemo(
|
||||||
? sortedChannels.filter(
|
() =>
|
||||||
(c) => c.name.toLowerCase().includes(query) || c.key.toLowerCase().includes(query)
|
query
|
||||||
)
|
? sortedChannels.filter(
|
||||||
: sortedChannels;
|
(c) => c.name.toLowerCase().includes(query) || c.key.toLowerCase().includes(query)
|
||||||
const filteredContacts = query
|
)
|
||||||
? sortedContacts.filter(
|
: sortedChannels,
|
||||||
(c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
|
[sortedChannels, query]
|
||||||
)
|
|
||||||
: sortedContacts;
|
|
||||||
|
|
||||||
// Separate favorites from regular items
|
|
||||||
const favoriteChannels = filteredChannels.filter((c) => isFavorite(favorites, 'channel', c.key));
|
|
||||||
const favoriteContacts = filteredContacts.filter((c) =>
|
|
||||||
isFavorite(favorites, 'contact', c.public_key)
|
|
||||||
);
|
);
|
||||||
const nonFavoriteChannels = filteredChannels.filter(
|
const filteredContacts = useMemo(
|
||||||
(c) => !isFavorite(favorites, 'channel', c.key)
|
() =>
|
||||||
);
|
query
|
||||||
const nonFavoriteContacts = filteredContacts.filter(
|
? sortedContacts.filter(
|
||||||
(c) => !isFavorite(favorites, 'contact', c.public_key)
|
(c) =>
|
||||||
|
c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
|
||||||
|
)
|
||||||
|
: sortedContacts,
|
||||||
|
[sortedContacts, query]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Combine and sort favorites by most recent message (always recent order)
|
// Separate favorites from regular items, and build combined favorites list
|
||||||
type FavoriteItem = { type: 'channel'; channel: Channel } | { type: 'contact'; contact: Contact };
|
type FavoriteItem = { type: 'channel'; channel: Channel } | { type: 'contact'; contact: Contact };
|
||||||
|
|
||||||
const favoriteItems: FavoriteItem[] = [
|
const { favoriteItems, nonFavoriteChannels, nonFavoriteContacts } = useMemo(() => {
|
||||||
...favoriteChannels.map((channel) => ({ type: 'channel' as const, channel })),
|
const favChannels = filteredChannels.filter((c) => isFavorite(favorites, 'channel', c.key));
|
||||||
...favoriteContacts.map((contact) => ({ type: 'contact' as const, contact })),
|
const favContacts = filteredContacts.filter((c) =>
|
||||||
].sort((a, b) => {
|
isFavorite(favorites, 'contact', c.public_key)
|
||||||
const timeA =
|
);
|
||||||
a.type === 'channel'
|
const nonFavChannels = filteredChannels.filter((c) => !isFavorite(favorites, 'channel', c.key));
|
||||||
? getLastMessageTime('channel', a.channel.key)
|
const nonFavContacts = filteredContacts.filter(
|
||||||
: getLastMessageTime('contact', a.contact.public_key);
|
(c) => !isFavorite(favorites, 'contact', c.public_key)
|
||||||
const timeB =
|
);
|
||||||
b.type === 'channel'
|
|
||||||
? getLastMessageTime('channel', b.channel.key)
|
const items: FavoriteItem[] = [
|
||||||
: getLastMessageTime('contact', b.contact.public_key);
|
...favChannels.map((channel) => ({ type: 'channel' as const, channel })),
|
||||||
// Sort by most recent first
|
...favContacts.map((contact) => ({ type: 'contact' as const, contact })),
|
||||||
if (timeA && timeB) return timeB - timeA;
|
].sort((a, b) => {
|
||||||
if (timeA && !timeB) return -1;
|
const timeA =
|
||||||
if (!timeA && timeB) return 1;
|
a.type === 'channel'
|
||||||
// Fall back to name comparison
|
? getLastMessageTime('channel', a.channel.key)
|
||||||
const nameA = a.type === 'channel' ? a.channel.name : a.contact.name || a.contact.public_key;
|
: getLastMessageTime('contact', a.contact.public_key);
|
||||||
const nameB = b.type === 'channel' ? b.channel.name : b.contact.name || b.contact.public_key;
|
const timeB =
|
||||||
return nameA.localeCompare(nameB);
|
b.type === 'channel'
|
||||||
});
|
? getLastMessageTime('channel', b.channel.key)
|
||||||
|
: getLastMessageTime('contact', b.contact.public_key);
|
||||||
|
if (timeA && timeB) return timeB - timeA;
|
||||||
|
if (timeA && !timeB) return -1;
|
||||||
|
if (!timeA && timeB) return 1;
|
||||||
|
const nameA = a.type === 'channel' ? a.channel.name : a.contact.name || a.contact.public_key;
|
||||||
|
const nameB = b.type === 'channel' ? b.channel.name : b.contact.name || b.contact.public_key;
|
||||||
|
return nameA.localeCompare(nameB);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
favoriteItems: items,
|
||||||
|
nonFavoriteChannels: nonFavChannels,
|
||||||
|
nonFavoriteContacts: nonFavContacts,
|
||||||
|
};
|
||||||
|
}, [filteredChannels, filteredContacts, favorites, getLastMessageTime]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sidebar w-60 h-full min-h-0 bg-card border-r border-border flex flex-col">
|
<div className="sidebar w-60 h-full min-h-0 bg-card border-r border-border flex flex-col">
|
||||||
|
|||||||
@@ -55,11 +55,15 @@ export function useUnreadCounts(
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Fetch when channels or contacts arrive/change
|
// Fetch when the number of channels/contacts changes (e.g. initial load,
|
||||||
|
// sync, create/delete). Using .length avoids refiring on every WebSocket
|
||||||
|
// contact-update that merely mutates an existing entry's fields.
|
||||||
|
const channelsLen = channels.length;
|
||||||
|
const contactsLen = contacts.length;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (channels.length === 0 && contacts.length === 0) return;
|
if (channelsLen === 0 && contactsLen === 0) return;
|
||||||
fetchUnreads();
|
fetchUnreads();
|
||||||
}, [channels, contacts, fetchUnreads]);
|
}, [channelsLen, contactsLen, fetchUnreads]);
|
||||||
|
|
||||||
// Mark conversation as read when user views it
|
// Mark conversation as read when user views it
|
||||||
// Calls server API to persist read state across devices
|
// Calls server API to persist read state across devices
|
||||||
|
|||||||
Reference in New Issue
Block a user