Calm down sidebar refreshes with better contact don't-set behavior, unread count checks, and memoized sorting etc.

This commit is contained in:
Jack Kingsman
2026-02-13 00:00:53 -08:00
parent 430b5aaba7
commit 57d007dec2
3 changed files with 159 additions and 121 deletions
+24 -12
View File
@@ -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
View File
@@ -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">
+7 -3
View File
@@ -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