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

View File

@@ -90,8 +90,10 @@ export function App() {
const [showCracker, setShowCracker] = useState(false);
const [crackerRunning, setCrackerRunning] = useState(false);
// Favorites are now stored server-side in appSettings
const favorites: Favorite[] = appSettings?.favorites ?? [];
// Favorites are now stored server-side in appSettings.
// 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
const prevHealthRef = useRef<HealthStatus | null>(null);
@@ -244,12 +246,16 @@ export function App() {
setContacts((prev) => {
const idx = prev.findIndex((c) => c.public_key === contact.public_key);
if (idx >= 0) {
const updated = [...prev];
const existing = prev[idx];
updated[idx] = {
...existing,
...contact,
};
// Skip update if all incoming fields are identical — avoids a new
// array reference (and Sidebar re-render) on every advertisement.
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 [...prev, contact as Contact];
@@ -853,6 +859,15 @@ export function App() {
setSidebarOpen(false);
}, []);
const handleNewMessage = useCallback(() => {
setShowNewMessage(true);
setSidebarOpen(false);
}, []);
const handleToggleCracker = useCallback(() => {
setShowCracker((prev) => !prev);
}, []);
// Sidebar content (shared between desktop and mobile)
const sidebarContent = (
<Sidebar
@@ -860,16 +875,13 @@ export function App() {
channels={channels}
activeConversation={activeConversation}
onSelectConversation={handleSelectConversation}
onNewMessage={() => {
setShowNewMessage(true);
setSidebarOpen(false);
}}
onNewMessage={handleNewMessage}
lastMessageTimes={lastMessageTimes}
unreadCounts={unreadCounts}
mentions={mentions}
showCracker={showCracker}
crackerRunning={crackerRunning}
onToggleCracker={() => setShowCracker((prev) => !prev)}
onToggleCracker={handleToggleCracker}
onMarkAllRead={markAllRead}
favorites={favorites}
sortOrder={appSettings?.sidebar_sort_order ?? 'recent'}

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useMemo, useCallback } from 'react';
import {
CONTACT_TYPE_REPEATER,
type Contact,
@@ -82,132 +82,154 @@ export function Sidebar({
return mentions[key] || false;
};
const getLastMessageTime = (type: 'channel' | 'contact', id: string) => {
const key = getStateKey(type, id);
return lastMessageTimes[key] || 0;
};
const getLastMessageTime = useCallback(
(type: 'channel' | 'contact', id: string) => {
const key = getStateKey(type, id);
return lastMessageTimes[key] || 0;
},
[lastMessageTimes]
);
// Deduplicate channels by name, keeping the first (lowest index)
const uniqueChannels = channels.reduce<Channel[]>((acc, channel) => {
if (!acc.some((c) => c.name === channel.name)) {
acc.push(channel);
}
return acc;
}, []);
const uniqueChannels = useMemo(
() =>
channels.reduce<Channel[]>((acc, channel) => {
if (!acc.some((c) => c.name === channel.name)) {
acc.push(channel);
}
return acc;
}, []),
[channels]
);
// Deduplicate contacts by public key, preferring ones with names
// Also filter out any contacts with empty public keys
const uniqueContacts = contacts
.filter((c) => c.public_key && c.public_key.length > 0)
.sort((a, b) => {
// Sort contacts with names first
if (a.name && !b.name) return -1;
if (!a.name && b.name) return 1;
return (a.name || '').localeCompare(b.name || '');
})
.reduce<Contact[]>((acc, contact) => {
if (!acc.some((c) => c.public_key === contact.public_key)) {
acc.push(contact);
}
return acc;
}, []);
const uniqueContacts = useMemo(
() =>
contacts
.filter((c) => c.public_key && c.public_key.length > 0)
.sort((a, b) => {
// Sort contacts with names first
if (a.name && !b.name) return -1;
if (!a.name && b.name) return 1;
return (a.name || '').localeCompare(b.name || '');
})
.reduce<Contact[]>((acc, contact) => {
if (!acc.some((c) => c.public_key === contact.public_key)) {
acc.push(contact);
}
return acc;
}, []),
[contacts]
);
// Sort channels based on sort order, with Public always first
const sortedChannels = [...uniqueChannels].sort((a, b) => {
// Public channel always sorts to the top
if (a.name === 'Public') return -1;
if (b.name === 'Public') return 1;
const sortedChannels = useMemo(
() =>
[...uniqueChannels].sort((a, b) => {
// Public channel always sorts to the top
if (a.name === 'Public') return -1;
if (b.name === 'Public') return 1;
if (sortOrder === 'recent') {
const timeA = getLastMessageTime('channel', a.key);
const timeB = getLastMessageTime('channel', b.key);
// If both have messages, sort by most recent first
if (timeA && timeB) return timeB - timeA;
// Items with messages come before items without
if (timeA && !timeB) return -1;
if (!timeA && timeB) return 1;
// Fall back to alpha for items without messages
}
return a.name.localeCompare(b.name);
});
if (sortOrder === 'recent') {
const timeA = getLastMessageTime('channel', a.key);
const timeB = getLastMessageTime('channel', b.key);
if (timeA && timeB) return timeB - timeA;
if (timeA && !timeB) return -1;
if (!timeA && timeB) return 1;
}
return a.name.localeCompare(b.name);
}),
[uniqueChannels, sortOrder, getLastMessageTime]
);
// Sort contacts: non-repeaters first (by recent or alpha), then repeaters (always alpha)
const sortedContacts = [...uniqueContacts].sort((a, b) => {
const aIsRepeater = a.type === CONTACT_TYPE_REPEATER;
const bIsRepeater = b.type === CONTACT_TYPE_REPEATER;
const sortedContacts = useMemo(
() =>
[...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) {
return (a.name || a.public_key).localeCompare(b.name || b.public_key);
}
if (aIsRepeater && bIsRepeater) {
return (a.name || a.public_key).localeCompare(b.name || b.public_key);
}
// Both non-repeaters: use selected sort order
if (sortOrder === 'recent') {
const timeA = getLastMessageTime('contact', a.public_key);
const timeB = getLastMessageTime('contact', b.public_key);
// If both have messages, sort by most recent first
if (timeA && timeB) return timeB - timeA;
// Items with messages come before items without
if (timeA && !timeB) return -1;
if (!timeA && timeB) return 1;
// Fall back to alpha for items without messages
}
return (a.name || a.public_key).localeCompare(b.name || b.public_key);
});
if (sortOrder === 'recent') {
const timeA = getLastMessageTime('contact', a.public_key);
const timeB = getLastMessageTime('contact', b.public_key);
if (timeA && timeB) return timeB - timeA;
if (timeA && !timeB) return -1;
if (!timeA && timeB) return 1;
}
return (a.name || a.public_key).localeCompare(b.name || b.public_key);
}),
[uniqueContacts, sortOrder, getLastMessageTime]
);
// Filter by search query
const query = searchQuery.toLowerCase().trim();
const filteredChannels = query
? sortedChannels.filter(
(c) => c.name.toLowerCase().includes(query) || c.key.toLowerCase().includes(query)
)
: sortedChannels;
const filteredContacts = query
? sortedContacts.filter(
(c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(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 filteredChannels = useMemo(
() =>
query
? sortedChannels.filter(
(c) => c.name.toLowerCase().includes(query) || c.key.toLowerCase().includes(query)
)
: sortedChannels,
[sortedChannels, query]
);
const nonFavoriteChannels = filteredChannels.filter(
(c) => !isFavorite(favorites, 'channel', c.key)
);
const nonFavoriteContacts = filteredContacts.filter(
(c) => !isFavorite(favorites, 'contact', c.public_key)
const filteredContacts = useMemo(
() =>
query
? sortedContacts.filter(
(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 };
const favoriteItems: FavoriteItem[] = [
...favoriteChannels.map((channel) => ({ type: 'channel' as const, channel })),
...favoriteContacts.map((contact) => ({ type: 'contact' as const, contact })),
].sort((a, b) => {
const timeA =
a.type === 'channel'
? getLastMessageTime('channel', a.channel.key)
: getLastMessageTime('contact', a.contact.public_key);
const timeB =
b.type === 'channel'
? getLastMessageTime('channel', b.channel.key)
: getLastMessageTime('contact', b.contact.public_key);
// Sort by most recent first
if (timeA && timeB) return timeB - timeA;
if (timeA && !timeB) return -1;
if (!timeA && timeB) return 1;
// Fall back to name comparison
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);
});
const { favoriteItems, nonFavoriteChannels, nonFavoriteContacts } = useMemo(() => {
const favChannels = filteredChannels.filter((c) => isFavorite(favorites, 'channel', c.key));
const favContacts = filteredContacts.filter((c) =>
isFavorite(favorites, 'contact', c.public_key)
);
const nonFavChannels = filteredChannels.filter((c) => !isFavorite(favorites, 'channel', c.key));
const nonFavContacts = filteredContacts.filter(
(c) => !isFavorite(favorites, 'contact', c.public_key)
);
const items: FavoriteItem[] = [
...favChannels.map((channel) => ({ type: 'channel' as const, channel })),
...favContacts.map((contact) => ({ type: 'contact' as const, contact })),
].sort((a, b) => {
const timeA =
a.type === 'channel'
? getLastMessageTime('channel', a.channel.key)
: getLastMessageTime('contact', a.contact.public_key);
const timeB =
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 (
<div className="sidebar w-60 h-full min-h-0 bg-card border-r border-border flex flex-col">

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(() => {
if (channels.length === 0 && contacts.length === 0) return;
if (channelsLen === 0 && contactsLen === 0) return;
fetchUnreads();
}, [channels, contacts, fetchUnreads]);
}, [channelsLen, contactsLen, fetchUnreads]);
// Mark conversation as read when user views it
// Calls server API to persist read state across devices