Merge pull request #167 from jkingsman/migrate-favorites

Add favorites as contact field (dug)
This commit is contained in:
Jack Kingsman
2026-04-05 22:19:01 -07:00
committed by GitHub
55 changed files with 482 additions and 466 deletions
+35 -8
View File
@@ -18,6 +18,7 @@ import {
useUnreadTitle,
useRawPacketStatsSession,
} from './hooks';
import { toast } from './components/ui/sonner';
import { AppShell } from './components/AppShell';
import type { MessageInputHandle } from './components/MessageInput';
import { DistanceUnitProvider } from './contexts/DistanceUnitContext';
@@ -150,10 +151,8 @@ export function App() {
const {
appSettings,
favorites,
fetchAppSettings,
handleSaveAppSettings,
handleToggleFavorite,
handleToggleBlockedKey,
handleToggleBlockedName,
handleToggleTrackedTelemetry,
@@ -204,6 +203,38 @@ export function App() {
removeConversationMessagesRef.current(conversationId),
});
const handleToggleFavorite = useCallback(
async (type: 'channel' | 'contact', id: string) => {
// Optimistically toggle the favorite flag
if (type === 'contact') {
setContacts((prev) =>
prev.map((c) => (c.public_key === id ? { ...c, favorite: !c.favorite } : c))
);
} else {
setChannels((prev) =>
prev.map((c) => (c.key === id ? { ...c, favorite: !c.favorite } : c))
);
}
try {
await api.toggleFavorite(type, id);
} catch {
// Revert on failure
if (type === 'contact') {
setContacts((prev) =>
prev.map((c) => (c.public_key === id ? { ...c, favorite: !c.favorite } : c))
);
} else {
setChannels((prev) =>
prev.map((c) => (c.key === id ? { ...c, favorite: !c.favorite } : c))
);
}
toast.error('Failed to update favorite');
}
},
[setContacts, setChannels]
);
// useConversationRouter is called second — it receives channels/contacts as inputs
const {
activeConversation,
@@ -290,8 +321,8 @@ export function App() {
markAllRead,
refreshUnreads,
} = useUnreadCounts(channels, contacts, activeConversation);
useFaviconBadge(unreadCounts, mentions, favorites);
useUnreadTitle(unreadCounts, favorites);
useFaviconBadge(unreadCounts, mentions, channels);
useUnreadTitle(unreadCounts, contacts, channels);
useEffect(() => {
if (activeConversation?.type !== 'channel') {
@@ -491,7 +522,6 @@ export function App() {
onMarkAllRead: () => {
void markAllRead();
},
favorites,
isConversationNotificationsEnabled,
blockedKeys: appSettings?.blocked_keys ?? [],
blockedNames: appSettings?.blocked_names ?? [],
@@ -507,7 +537,6 @@ export function App() {
rawPacketStatsSession,
config,
health,
favorites,
messages: sortedMessages,
preSorted: activeContactIsRoom,
messagesLoading,
@@ -614,7 +643,6 @@ export function App() {
onClose: handleCloseContactInfo,
contacts,
config,
favorites,
onToggleFavorite: handleToggleFavorite,
onNavigateToChannel: handleNavigateToChannel,
onSearchMessagesByKey: (publicKey: string) => {
@@ -632,7 +660,6 @@ export function App() {
channelKey: infoPaneChannelKey,
onClose: handleCloseChannelInfo,
channels,
favorites,
onToggleFavorite: handleToggleFavorite,
};
+2 -3
View File
@@ -9,7 +9,6 @@ import type {
ContactAnalytics,
ContactAdvertPathSummary,
FanoutConfig,
Favorite,
HealthStatus,
MaintenanceResult,
Message,
@@ -334,8 +333,8 @@ export const api = {
}),
// Favorites
toggleFavorite: (type: Favorite['type'], id: string) =>
fetchJson<AppSettings>('/settings/favorites/toggle', {
toggleFavorite: (type: 'channel' | 'contact', id: string) =>
fetchJson<{ type: string; id: string; favorite: boolean }>('/settings/favorites/toggle', {
method: 'POST',
body: JSON.stringify({ type, id }),
}),
+2 -5
View File
@@ -3,17 +3,15 @@ import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip as RechartsTooltip }
import { Star } from 'lucide-react';
import { api } from '../api';
import { formatTime } from '../utils/messageParser';
import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
import { toast } from './ui/sonner';
import type { Channel, ChannelDetail, Favorite, PathHashWidthStats } from '../types';
import type { Channel, ChannelDetail, PathHashWidthStats } from '../types';
interface ChannelInfoPaneProps {
channelKey: string | null;
onClose: () => void;
channels: Channel[];
favorites: Favorite[];
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
}
@@ -21,7 +19,6 @@ export function ChannelInfoPane({
channelKey,
onClose,
channels,
favorites,
onToggleFavorite,
}: ChannelInfoPaneProps) {
const [detail, setDetail] = useState<ChannelDetail | null>(null);
@@ -125,7 +122,7 @@ export function ChannelInfoPane({
className="text-sm flex items-center gap-2 hover:text-primary transition-colors"
onClick={() => onToggleFavorite('channel', channel.key)}
>
{isFavorite(favorites, 'channel', channel.key) ? (
{channel.favorite ? (
<>
<Star className="h-4.5 w-4.5 fill-current text-favorite" aria-hidden="true" />
<span>Remove from favorites</span>
+11 -19
View File
@@ -5,7 +5,6 @@ import { DirectTraceIcon } from './DirectTraceIcon';
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
import { ChannelFloodScopeOverrideModal } from './ChannelFloodScopeOverrideModal';
import { ChannelPathHashModeOverrideModal } from './ChannelPathHashModeOverrideModal';
import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
import { isPublicChannelKey } from '../utils/publicChannel';
import { stripRegionScopePrefix } from '../utils/regionScope';
@@ -13,14 +12,7 @@ import { isPrefixOnlyContact } from '../utils/pubkey';
import { cn } from '../lib/utils';
import { ContactAvatar } from './ContactAvatar';
import { ContactStatusInfo } from './ContactStatusInfo';
import type {
Channel,
Contact,
Conversation,
Favorite,
PathDiscoveryResponse,
RadioConfig,
} from '../types';
import type { Channel, Contact, Conversation, PathDiscoveryResponse, RadioConfig } from '../types';
import { CONTACT_TYPE_ROOM } from '../types';
interface ChatHeaderProps {
@@ -28,7 +20,6 @@ interface ChatHeaderProps {
contacts: Contact[];
channels: Channel[];
config: RadioConfig | null;
favorites: Favorite[];
notificationsSupported: boolean;
notificationsEnabled: boolean;
notificationsPermission: NotificationPermission | 'unsupported';
@@ -49,7 +40,6 @@ export function ChatHeader({
contacts,
channels,
config,
favorites,
notificationsSupported,
notificationsEnabled,
notificationsPermission,
@@ -105,12 +95,18 @@ export function ChatHeader({
const titleClickable =
(conversation.type === 'contact' && onOpenContactInfo) ||
(conversation.type === 'channel' && onOpenChannelInfo);
const isFav =
conversation.type === 'contact'
? (activeContact?.favorite ?? false)
: conversation.type === 'channel'
? (activeChannel?.favorite ?? false)
: false;
const favoriteTitle =
conversation.type === 'contact'
? isFavorite(favorites, 'contact', conversation.id)
? isFav
? 'Remove from favorites. Favorite contacts stay loaded on the radio for ACK support.'
: 'Add to favorites. Favorite contacts stay loaded on the radio for ACK support.'
: isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id)
: isFav
? 'Remove from favorites'
: 'Add to favorites';
@@ -359,13 +355,9 @@ export function ChatHeader({
onToggleFavorite(conversation.type as 'channel' | 'contact', conversation.id)
}
title={favoriteTitle}
aria-label={
isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id)
? 'Remove from favorites'
: 'Add to favorites'
}
aria-label={isFav ? 'Remove from favorites' : 'Add to favorites'}
>
{isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id) ? (
{isFav ? (
<Star className="h-4 w-4 fill-current text-favorite" aria-hidden="true" />
) : (
<Star className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
+1 -5
View File
@@ -29,7 +29,6 @@ import {
} from '../utils/pathUtils';
import { isPublicChannelKey } from '../utils/publicChannel';
import { getMapFocusHash } from '../utils/urlHash';
import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
import { ContactAvatar } from './ContactAvatar';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
@@ -42,7 +41,6 @@ import type {
ContactAnalytics,
ContactAnalyticsHourlyBucket,
ContactAnalyticsWeeklyBucket,
Favorite,
RadioConfig,
} from '../types';
@@ -67,7 +65,6 @@ interface ContactInfoPaneProps {
onClose: () => void;
contacts: Contact[];
config: RadioConfig | null;
favorites: Favorite[];
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
onNavigateToChannel?: (channelKey: string) => void;
onSearchMessagesByKey?: (publicKey: string) => void;
@@ -84,7 +81,6 @@ export function ContactInfoPane({
onClose,
contacts,
config,
favorites,
onToggleFavorite,
onNavigateToChannel,
onSearchMessagesByKey,
@@ -384,7 +380,7 @@ export function ContactInfoPane({
onClick={() => onToggleFavorite('contact', contact.public_key)}
title="Favorite contacts stay loaded on the radio for ACK support"
>
{isFavorite(favorites, 'contact', contact.public_key) ? (
{contact.favorite ? (
<>
<Star className="h-4.5 w-4.5 fill-current text-favorite" aria-hidden="true" />
<span>Remove from favorites</span>
@@ -10,7 +10,6 @@ import type {
Channel,
Contact,
Conversation,
Favorite,
HealthStatus,
Message,
PathDiscoveryResponse,
@@ -42,7 +41,6 @@ interface ConversationPaneProps {
notificationsSupported: boolean;
notificationsEnabled: boolean;
notificationsPermission: NotificationPermission | 'unsupported';
favorites: Favorite[];
messages: Message[];
preSorted?: boolean;
messagesLoading: boolean;
@@ -119,7 +117,6 @@ export function ConversationPane({
notificationsSupported,
notificationsEnabled,
notificationsPermission,
favorites,
messages,
preSorted,
messagesLoading,
@@ -237,7 +234,6 @@ export function ConversationPane({
key={activeConversation.id}
conversation={activeConversation}
contacts={contacts}
favorites={favorites}
notificationsSupported={notificationsSupported}
notificationsEnabled={notificationsEnabled}
notificationsPermission={notificationsPermission}
@@ -266,7 +262,6 @@ export function ConversationPane({
contacts={contacts}
channels={channels}
config={config}
favorites={favorites}
notificationsSupported={notificationsSupported}
notificationsEnabled={notificationsEnabled}
notificationsPermission={notificationsPermission}
+2 -11
View File
@@ -9,17 +9,10 @@ import { RepeaterLogin } from './RepeaterLogin';
import { ServerLoginStatusBanner } from './ServerLoginStatusBanner';
import { useRememberedServerPassword } from '../hooks/useRememberedServerPassword';
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
import { isValidLocation } from '../utils/pathUtils';
import { ContactStatusInfo } from './ContactStatusInfo';
import type {
Contact,
Conversation,
Favorite,
PathDiscoveryResponse,
TelemetryHistoryEntry,
} from '../types';
import type { Contact, Conversation, PathDiscoveryResponse, TelemetryHistoryEntry } from '../types';
import { cn } from '../lib/utils';
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
import { NeighborsPane } from './repeater/RepeaterNeighborsPane';
@@ -41,7 +34,6 @@ export { formatDuration, formatClockDrift } from './repeater/repeaterPaneShared'
interface RepeaterDashboardProps {
conversation: Conversation;
contacts: Contact[];
favorites: Favorite[];
notificationsSupported: boolean;
notificationsEnabled: boolean;
notificationsPermission: NotificationPermission | 'unsupported';
@@ -61,7 +53,6 @@ interface RepeaterDashboardProps {
export function RepeaterDashboard({
conversation,
contacts,
favorites,
notificationsSupported,
notificationsEnabled,
notificationsPermission,
@@ -134,7 +125,7 @@ export function RepeaterDashboard({
setTelemetryHistory(liveHistory);
}, [paneData.status?.telemetry_history]);
const isFav = isFavorite(favorites, 'contact', conversation.id);
const isFav = contact?.favorite ?? false;
const handleRepeaterLogin = async (nextPassword: string) => {
await login(nextPassword);
+6 -17
View File
@@ -19,7 +19,6 @@ import {
type Contact,
type Channel,
type Conversation,
type Favorite,
} from '../types';
import {
buildSidebarSectionSortOrders,
@@ -36,7 +35,6 @@ import { isPublicChannelKey } from '../utils/publicChannel';
import { getContactDisplayName } from '../utils/pubkey';
import { handleKeyboardActivate } from '../utils/a11y';
import { ContactAvatar } from './ContactAvatar';
import { isFavorite } from '../utils/favorites';
import { Input } from './ui/input';
import { Button } from './ui/button';
import { cn } from '@/lib/utils';
@@ -106,7 +104,6 @@ interface SidebarProps {
crackerRunning: boolean;
onToggleCracker: () => void;
onMarkAllRead: () => void;
favorites: Favorite[];
isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
blockedKeys?: string[];
blockedNames?: string[];
@@ -135,7 +132,6 @@ export function Sidebar({
crackerRunning,
onToggleCracker,
onMarkAllRead,
favorites,
isConversationNotificationsEnabled,
blockedKeys = [],
blockedNames = [],
@@ -488,22 +484,16 @@ export function Sidebar({
nonFavoriteRooms,
nonFavoriteRepeaters,
} = useMemo(() => {
const favChannels = filteredChannels.filter((c) => isFavorite(favorites, 'channel', c.key));
const favChannels = filteredChannels.filter((c) => c.favorite);
const favContacts = [
...filteredNonRepeaterContacts,
...filteredRooms,
...filteredRepeaters,
].filter((c) => isFavorite(favorites, 'contact', c.public_key));
const nonFavChannels = filteredChannels.filter((c) => !isFavorite(favorites, 'channel', c.key));
const nonFavContacts = filteredNonRepeaterContacts.filter(
(c) => !isFavorite(favorites, 'contact', c.public_key)
);
const nonFavRooms = filteredRooms.filter(
(c) => !isFavorite(favorites, 'contact', c.public_key)
);
const nonFavRepeaters = filteredRepeaters.filter(
(c) => !isFavorite(favorites, 'contact', c.public_key)
);
].filter((c) => c.favorite);
const nonFavChannels = filteredChannels.filter((c) => !c.favorite);
const nonFavContacts = filteredNonRepeaterContacts.filter((c) => !c.favorite);
const nonFavRooms = filteredRooms.filter((c) => !c.favorite);
const nonFavRepeaters = filteredRepeaters.filter((c) => !c.favorite);
const items: FavoriteItem[] = [
...favChannels.map((channel) => ({ type: 'channel' as const, channel })),
@@ -522,7 +512,6 @@ export function Sidebar({
filteredNonRepeaterContacts,
filteredRooms,
filteredRepeaters,
favorites,
sectionSortOrders.favorites,
sortFavoriteItemsByOrder,
]);
+12 -42
View File
@@ -3,16 +3,11 @@ import { api } from '../api';
import { takePrefetchOrFetch } from '../prefetch';
import { toast } from '../components/ui/sonner';
import { initLastMessageTimes } from '../utils/conversationState';
import { isFavorite } from '../utils/favorites';
import type { AppSettings, AppSettingsUpdate, Favorite } from '../types';
import type { AppSettings, AppSettingsUpdate } from '../types';
export function useAppSettings() {
const [appSettings, setAppSettings] = useState<AppSettings | null>(null);
// Stable empty array prevents a new reference every render when there are none.
const emptyFavorites = useRef<Favorite[]>([]).current;
const favorites: Favorite[] = appSettings?.favorites ?? emptyFavorites;
// One-time migration guard
const hasMigratedRef = useRef(false);
@@ -85,32 +80,6 @@ export function useAppSettings() {
}
}, []);
const handleToggleFavorite = useCallback(async (type: 'channel' | 'contact', id: string) => {
setAppSettings((prev) => {
if (!prev) return prev;
const currentFavorites = prev.favorites ?? [];
const wasFavorited = isFavorite(currentFavorites, type, id);
const optimisticFavorites = wasFavorited
? currentFavorites.filter((f) => !(f.type === type && f.id === id))
: [...currentFavorites, { type, id }];
return { ...prev, favorites: optimisticFavorites };
});
try {
const updatedSettings = await api.toggleFavorite(type, id);
setAppSettings(updatedSettings);
} catch (err) {
console.error('Failed to toggle favorite:', err);
try {
const settings = await api.getSettings();
setAppSettings(settings);
} catch {
// If refetch also fails, leave optimistic state
}
toast.error('Failed to update favorite');
}
}, []);
const handleToggleTrackedTelemetry = useCallback(async (publicKey: string) => {
const key = publicKey.toLowerCase();
setAppSettings((prev) => {
@@ -151,7 +120,7 @@ export function useAppSettings() {
hasMigratedRef.current = true;
const FAVORITES_KEY = 'remoteterm-favorites';
let localFavorites: Favorite[] = [];
let localFavorites: Array<{ type: 'channel' | 'contact'; id: string }> = [];
try {
const stored = localStorage.getItem(FAVORITES_KEY);
if (stored) localFavorites = JSON.parse(stored);
@@ -161,25 +130,26 @@ export function useAppSettings() {
if (localFavorites.length === 0) return;
const migrate = async () => {
try {
for (const f of localFavorites) {
let migrated = 0;
for (const f of localFavorites) {
try {
await api.toggleFavorite(f.type, f.id);
migrated++;
} catch {
// Entity may have been deleted; skip and continue
}
localStorage.removeItem(FAVORITES_KEY);
await fetchAppSettings();
} catch (err) {
console.error('Failed to migrate legacy favorites:', err);
}
localStorage.removeItem(FAVORITES_KEY);
// Reload so contacts/channels pick up the new favorite flags
if (migrated > 0) window.location.reload();
};
migrate();
}, [appSettings, fetchAppSettings]);
}, [appSettings]);
return {
appSettings,
favorites,
fetchAppSettings,
handleSaveAppSettings,
handleToggleFavorite,
handleToggleBlockedKey,
handleToggleBlockedName,
handleToggleTrackedTelemetry,
+36 -20
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef } from 'react';
import type { Favorite } from '../types';
import type { Channel, Contact } from '../types';
import { getStateKey } from '../utils/conversationState';
const APP_TITLE = 'RemoteTerm for MeshCore';
@@ -25,12 +25,11 @@ function getUnreadDirectMessageCount(unreadCounts: Record<string, number>): numb
function getUnreadFavoriteChannelCount(
unreadCounts: Record<string, number>,
favorites: Favorite[]
channels: Channel[]
): number {
return favorites.reduce(
(sum, favorite) =>
sum +
(favorite.type === 'channel' ? unreadCounts[getStateKey('channel', favorite.id)] || 0 : 0),
return channels.reduce(
(sum, channel) =>
sum + (channel.favorite ? unreadCounts[getStateKey('channel', channel.key)] || 0 : 0),
0
);
}
@@ -41,19 +40,29 @@ export function getTotalUnreadCount(unreadCounts: Record<string, number>): numbe
export function getFavoriteUnreadCount(
unreadCounts: Record<string, number>,
favorites: Favorite[]
contacts: Contact[],
channels: Channel[]
): number {
return favorites.reduce((sum, favorite) => {
const stateKey = getStateKey(favorite.type, favorite.id);
return sum + (unreadCounts[stateKey] || 0);
}, 0);
let sum = 0;
for (const contact of contacts) {
if (contact.favorite) {
sum += unreadCounts[getStateKey('contact', contact.public_key)] || 0;
}
}
for (const channel of channels) {
if (channel.favorite) {
sum += unreadCounts[getStateKey('channel', channel.key)] || 0;
}
}
return sum;
}
export function getUnreadTitle(
unreadCounts: Record<string, number>,
favorites: Favorite[]
contacts: Contact[],
channels: Channel[]
): string {
const unreadCount = getFavoriteUnreadCount(unreadCounts, favorites);
const unreadCount = getFavoriteUnreadCount(unreadCounts, contacts, channels);
if (unreadCount <= 0) {
return APP_TITLE;
}
@@ -65,13 +74,13 @@ export function getUnreadTitle(
export function deriveFaviconBadgeState(
unreadCounts: Record<string, number>,
mentions: Record<string, boolean>,
favorites: Favorite[]
channels: Channel[]
): FaviconBadgeState {
if (Object.values(mentions).some(Boolean) || getUnreadDirectMessageCount(unreadCounts) > 0) {
return 'red';
}
if (getUnreadFavoriteChannelCount(unreadCounts, favorites) > 0) {
if (getUnreadFavoriteChannelCount(unreadCounts, channels) > 0) {
return 'green';
}
@@ -128,8 +137,15 @@ function applyFaviconHref(href: string): void {
upsertFaviconLinks('shortcut icon', href);
}
export function useUnreadTitle(unreadCounts: Record<string, number>, favorites: Favorite[]): void {
const title = useMemo(() => getUnreadTitle(unreadCounts, favorites), [favorites, unreadCounts]);
export function useUnreadTitle(
unreadCounts: Record<string, number>,
contacts: Contact[],
channels: Channel[]
): void {
const title = useMemo(
() => getUnreadTitle(unreadCounts, contacts, channels),
[contacts, channels, unreadCounts]
);
useEffect(() => {
document.title = title;
@@ -143,12 +159,12 @@ export function useUnreadTitle(unreadCounts: Record<string, number>, favorites:
export function useFaviconBadge(
unreadCounts: Record<string, number>,
mentions: Record<string, boolean>,
favorites: Favorite[]
channels: Channel[]
): void {
const objectUrlRef = useRef<string | null>(null);
const badgeState = useMemo(
() => deriveFaviconBadgeState(unreadCounts, mentions, favorites),
[favorites, mentions, unreadCounts]
() => deriveFaviconBadgeState(unreadCounts, mentions, channels),
[channels, mentions, unreadCounts]
);
useEffect(() => {
+5 -11
View File
@@ -187,7 +187,6 @@ const baseConfig = {
const baseSettings = {
max_radio_contacts: 200,
favorites: [] as Array<{ type: 'channel' | 'contact'; id: string }>,
auto_decrypt_dm_on_advert: false,
last_message_times: {},
@@ -204,6 +203,7 @@ const publicChannel = {
is_hashtag: false,
on_radio: false,
last_read_at: null,
favorite: false,
};
describe('App favorite toggle flow', () => {
@@ -216,8 +216,9 @@ describe('App favorite toggle flow', () => {
mocks.api.getChannels.mockResolvedValue([publicChannel]);
mocks.api.getContacts.mockResolvedValue([]);
mocks.api.toggleFavorite.mockResolvedValue({
...baseSettings,
favorites: [{ type: 'channel', id: publicChannel.key }],
type: 'channel',
id: publicChannel.key,
favorite: true,
});
});
@@ -239,11 +240,8 @@ describe('App favorite toggle flow', () => {
});
});
it('rolls back favorite state by refetching settings on toggle failure', async () => {
it('rolls back favorite state on toggle failure', async () => {
mocks.api.toggleFavorite.mockRejectedValue(new Error('toggle failed'));
mocks.api.getSettings
.mockResolvedValueOnce({ ...baseSettings }) // initial load
.mockResolvedValueOnce({ ...baseSettings }); // rollback refetch
render(<App />);
@@ -257,10 +255,6 @@ describe('App favorite toggle flow', () => {
expect(mocks.api.toggleFavorite).toHaveBeenCalledWith('channel', publicChannel.key);
});
await waitFor(() => {
expect(mocks.api.getSettings).toHaveBeenCalledTimes(2);
});
await waitFor(() => {
expect(mocks.toast.error).toHaveBeenCalledWith('Failed to update favorite');
});
+1 -1
View File
@@ -215,7 +215,6 @@ describe('App search jump target handling', () => {
});
mocks.api.getSettings.mockResolvedValue({
max_radio_contacts: 200,
favorites: [],
auto_decrypt_dm_on_advert: false,
last_message_times: {},
@@ -230,6 +229,7 @@ describe('App search jump target handling', () => {
is_hashtag: false,
on_radio: false,
last_read_at: null,
favorite: false,
},
]);
mocks.api.getContacts.mockResolvedValue([]);
+5 -1
View File
@@ -145,6 +145,7 @@ const publicChannel = {
is_hashtag: false,
on_radio: false,
last_read_at: null,
favorite: false,
};
describe('App startup hash resolution', () => {
@@ -166,7 +167,6 @@ describe('App startup hash resolution', () => {
});
mocks.api.getSettings.mockResolvedValue({
max_radio_contacts: 200,
favorites: [],
auto_decrypt_dm_on_advert: false,
last_message_times: {},
@@ -247,6 +247,7 @@ describe('App startup hash resolution', () => {
is_hashtag: false,
on_radio: false,
last_read_at: null,
favorite: false,
};
window.location.hash = '';
@@ -278,6 +279,7 @@ describe('App startup hash resolution', () => {
is_hashtag: false,
on_radio: false,
last_read_at: null,
favorite: false,
};
window.location.hash = '';
@@ -308,6 +310,7 @@ describe('App startup hash resolution', () => {
is_hashtag: false,
on_radio: false,
last_read_at: null,
favorite: false,
};
window.location.hash = '';
@@ -345,6 +348,7 @@ describe('App startup hash resolution', () => {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -17,6 +17,7 @@ describe('BulkAddChannelResultModal', () => {
is_hashtag: true,
on_radio: false,
last_read_at: null,
favorite: false,
},
{
key: 'BB'.repeat(16),
@@ -24,6 +25,7 @@ describe('BulkAddChannelResultModal', () => {
is_hashtag: true,
on_radio: false,
last_read_at: null,
favorite: false,
},
],
existing_count: 3,
@@ -2,7 +2,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { ChannelInfoPane } from '../components/ChannelInfoPane';
import type { Channel, ChannelDetail, Favorite } from '../types';
import type { Channel, ChannelDetail } from '../types';
// Mock the api module
vi.mock('../api', () => ({
@@ -15,7 +15,7 @@ import { api } from '../api';
const mockGetChannelDetail = vi.mocked(api.getChannelDetail);
function makeChannel(key: string, name: string, isHashtag: boolean): Channel {
return { key, name, is_hashtag: isHashtag, on_radio: false, last_read_at: null };
return { key, name, is_hashtag: isHashtag, on_radio: false, last_read_at: null, favorite: false };
}
function makeDetail(channel: Channel): ChannelDetail {
@@ -41,7 +41,6 @@ const noop = () => {};
const baseProps = {
onClose: noop,
favorites: [] as Favorite[],
onToggleFavorite: noop,
};
@@ -2,12 +2,12 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea
import { describe, expect, it, vi } from 'vitest';
import { ChatHeader } from '../components/ChatHeader';
import type { Channel, Contact, Conversation, Favorite, PathDiscoveryResponse } from '../types';
import type { Channel, Contact, Conversation, PathDiscoveryResponse } from '../types';
import { CONTACT_TYPE_ROOM } from '../types';
import { PUBLIC_CHANNEL_KEY } from '../utils/publicChannel';
function makeChannel(key: string, name: string, isHashtag: boolean): Channel {
return { key, name, is_hashtag: isHashtag, on_radio: false, last_read_at: null };
return { key, name, is_hashtag: isHashtag, on_radio: false, last_read_at: null, favorite: false };
}
const noop = () => {};
@@ -15,7 +15,6 @@ const noop = () => {};
const baseProps = {
contacts: [],
config: null,
favorites: [] as Favorite[],
notificationsSupported: true,
notificationsEnabled: false,
notificationsPermission: 'granted' as const,
@@ -186,6 +185,7 @@ describe('ChatHeader key visibility', () => {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -237,6 +237,7 @@ describe('ChatHeader key visibility', () => {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -286,6 +287,7 @@ describe('ChatHeader key visibility', () => {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
+1 -1
View File
@@ -47,6 +47,7 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
lon: null,
last_seen: 1700000000,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: 1699990000,
@@ -90,7 +91,6 @@ const baseProps = {
onClose: () => {},
contacts: [] as Contact[],
config: null,
favorites: [],
onToggleFavorite: () => {},
onSearchMessagesByKey: vi.fn(),
onSearchMessagesByName: vi.fn(),
+6 -10
View File
@@ -3,15 +3,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ConversationPane } from '../components/ConversationPane';
import type {
Channel,
Contact,
Conversation,
Favorite,
HealthStatus,
Message,
RadioConfig,
} from '../types';
import type { Channel, Contact, Conversation, HealthStatus, Message, RadioConfig } from '../types';
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
const mocks = vi.hoisted(() => ({
@@ -97,6 +89,7 @@ const channel: Channel = {
is_hashtag: false,
on_radio: false,
last_read_at: null,
favorite: false,
};
const message: Message = {
@@ -134,7 +127,6 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
notificationsSupported: true,
notificationsEnabled: false,
notificationsPermission: 'granted' as const,
favorites: [] as Favorite[],
messages: [message],
messagesLoading: false,
loadingOlder: false,
@@ -205,6 +197,7 @@ describe('ConversationPane', () => {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -278,6 +271,7 @@ describe('ConversationPane', () => {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -372,6 +366,7 @@ describe('ConversationPane', () => {
lon: null,
last_seen: 1700000000,
on_radio: false,
favorite: false,
last_contacted: 1700000000,
last_read_at: null,
first_seen: 1700000000,
@@ -408,6 +403,7 @@ describe('ConversationPane', () => {
lon: null,
last_seen: 1700000000,
on_radio: false,
favorite: false,
last_contacted: 1700000000,
last_read_at: null,
first_seen: 1700000000,
+1
View File
@@ -278,6 +278,7 @@ function makeContact(overrides: Partial<Contact> = {}): Contact {
lon: null,
last_seen: null,
on_radio: true,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
+2
View File
@@ -40,6 +40,7 @@ describe('MapView', () => {
lon: -74,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -74,6 +75,7 @@ describe('MapView', () => {
lon: -73,
last_seen: Math.floor(Date.now() / 1000) - 7 * 24 * 60 * 60 + 60,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
+1
View File
@@ -95,6 +95,7 @@ describe('MessageList channel sender rendering', () => {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -62,6 +62,7 @@ function createContact(publicKey: string, name: string, type = 1): Contact {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
+1
View File
@@ -30,6 +30,7 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -23,6 +23,7 @@ const BOT_CHANNEL: Channel = {
is_hashtag: true,
on_radio: false,
last_read_at: null,
favorite: false,
};
const BOT_PACKET: RawPacket = {
@@ -14,6 +14,7 @@ const TEST_CHANNEL: Channel = {
is_hashtag: true,
on_radio: false,
last_read_at: null,
favorite: false,
};
const COLLIDING_TEST_CHANNEL: Channel = {
@@ -87,6 +88,7 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
+4 -4
View File
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { RepeaterDashboard } from '../components/RepeaterDashboard';
import type { UseRepeaterDashboardResult } from '../hooks/useRepeaterDashboard';
import type { Contact, Conversation, Favorite } from '../types';
import type { Contact, Conversation } from '../types';
// Mock the hook — typed as mutable version of the return type
const mockHook: {
@@ -99,18 +99,16 @@ const contacts: Contact[] = [
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
},
];
const favorites: Favorite[] = [];
const defaultProps = {
conversation,
contacts,
favorites,
notificationsSupported: true,
notificationsEnabled: false,
notificationsPermission: 'granted' as const,
@@ -337,6 +335,7 @@ describe('RepeaterDashboard', () => {
lon: 115.87,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -397,6 +396,7 @@ describe('RepeaterDashboard', () => {
lon: 115.87,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -40,6 +40,7 @@ const roomContact: Contact = {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
+9 -1
View File
@@ -35,7 +35,14 @@ function createSearchResult(overrides: Partial<Message> = {}): Message {
const defaultProps = {
contacts: [],
channels: [
{ key: 'ABC123', name: 'Public', is_hashtag: true, on_radio: false, last_read_at: null },
{
key: 'ABC123',
name: 'Public',
is_hashtag: true,
on_radio: false,
last_read_at: null,
favorite: false,
},
],
onNavigateToMessage: vi.fn(),
};
@@ -239,6 +246,7 @@ describe('SearchView', () => {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
first_seen: null,
last_read_at: null,
-1
View File
@@ -59,7 +59,6 @@ const baseHealth: HealthStatus = {
const baseSettings: AppSettings = {
max_radio_contacts: 200,
favorites: [],
auto_decrypt_dm_on_advert: false,
last_message_times: {},
+27 -34
View File
@@ -2,13 +2,7 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Sidebar } from '../components/Sidebar';
import {
CONTACT_TYPE_REPEATER,
CONTACT_TYPE_ROOM,
type Channel,
type Contact,
type Favorite,
} from '../types';
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM, type Channel, type Contact } from '../types';
import { getStateKey, type ConversationTimes } from '../utils/conversationState';
import { PUBLIC_CHANNEL_KEY } from '../utils/publicChannel';
@@ -19,6 +13,7 @@ function makeChannel(key: string, name: string): Channel {
is_hashtag: false,
on_radio: false,
last_read_at: null,
favorite: false,
};
}
@@ -41,6 +36,7 @@ function makeContact(
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -51,7 +47,6 @@ function makeContact(
function renderSidebar(overrides?: {
unreadCounts?: Record<string, number>;
mentions?: Record<string, boolean>;
favorites?: Favorite[];
lastMessageTimes?: ConversationTimes;
channels?: Channel[];
isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
@@ -59,7 +54,7 @@ function renderSidebar(overrides?: {
const aliceName = 'Alice';
const roomName = 'Ops Board';
const publicChannel = makeChannel('AA'.repeat(16), 'Public');
const flightChannel = makeChannel('BB'.repeat(16), '#flight');
const flightChannel = { ...makeChannel('BB'.repeat(16), '#flight'), favorite: true };
const opsChannel = makeChannel('CC'.repeat(16), '#ops');
const alice = makeContact('11'.repeat(32), aliceName);
const board = makeContact('33'.repeat(32), roomName, CONTACT_TYPE_ROOM);
@@ -73,7 +68,6 @@ function renderSidebar(overrides?: {
[getStateKey('contact', relay.public_key)]: 4,
};
const favorites = overrides?.favorites ?? [{ type: 'channel', id: flightChannel.key }];
const channels = overrides?.channels ?? [publicChannel, flightChannel, opsChannel];
const onSelectConversation = vi.fn();
@@ -91,7 +85,6 @@ function renderSidebar(overrides?: {
crackerRunning={false}
onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()}
favorites={favorites}
isConversationNotificationsEnabled={overrides?.isConversationNotificationsEnabled}
/>
);
@@ -138,7 +131,6 @@ describe('Sidebar section summaries', () => {
crackerRunning={false}
onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()}
favorites={[]}
/>
);
@@ -195,11 +187,26 @@ describe('Sidebar section summaries', () => {
});
it('turns favorite contact row badges red', () => {
const { aliceName } = renderSidebar({
favorites: [{ type: 'contact', id: '11'.repeat(32) }],
});
const alice = makeContact('11'.repeat(32), 'Alice', 1, { favorite: true });
const aliceRow = screen.getByText(aliceName).closest('div');
render(
<Sidebar
contacts={[alice]}
channels={[makeChannel(PUBLIC_CHANNEL_KEY, 'Public')]}
activeConversation={null}
onSelectConversation={vi.fn()}
onNewMessage={vi.fn()}
lastMessageTimes={{}}
unreadCounts={{ [getStateKey('contact', alice.public_key)]: 3 }}
mentions={{}}
showCracker={false}
crackerRunning={false}
onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()}
/>
);
const aliceRow = screen.getByText('Alice').closest('div');
if (!aliceRow) throw new Error('Missing Alice row');
expect(within(aliceRow).getByText('3')).toHaveClass(
'bg-badge-mention',
@@ -297,7 +304,6 @@ describe('Sidebar section summaries', () => {
crackerRunning={false}
onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()}
favorites={[]}
/>
);
@@ -393,7 +399,6 @@ describe('Sidebar section summaries', () => {
crackerRunning: false,
onToggleCracker: vi.fn(),
onMarkAllRead: vi.fn(),
favorites: [],
};
const getChannelsOrder = () => screen.getAllByText(/^#/).map((node) => node.textContent);
@@ -464,7 +469,6 @@ describe('Sidebar section summaries', () => {
crackerRunning={false}
onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()}
favorites={[]}
/>
);
@@ -498,7 +502,6 @@ describe('Sidebar section summaries', () => {
crackerRunning={false}
onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()}
favorites={[]}
/>
);
@@ -546,7 +549,6 @@ describe('Sidebar section summaries', () => {
crackerRunning={false}
onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()}
favorites={[]}
/>
);
@@ -578,7 +580,6 @@ describe('Sidebar section summaries', () => {
crackerRunning={false}
onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()}
favorites={[]}
/>
);
@@ -593,8 +594,8 @@ describe('Sidebar section summaries', () => {
it('sorts favorites independently and persists the favorites sort preference', () => {
const publicChannel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public');
const zed = makeContact('11'.repeat(32), 'Zed', 1, { last_advert: 150 });
const amy = makeContact('22'.repeat(32), 'Amy');
const zed = makeContact('11'.repeat(32), 'Zed', 1, { last_advert: 150, favorite: true });
const amy = makeContact('22'.repeat(32), 'Amy', 1, { favorite: true });
const props = {
contacts: [zed, amy],
@@ -611,10 +612,6 @@ describe('Sidebar section summaries', () => {
crackerRunning: false,
onToggleCracker: vi.fn(),
onMarkAllRead: vi.fn(),
favorites: [
{ type: 'contact', id: zed.public_key },
{ type: 'contact', id: amy.public_key },
] satisfies Favorite[],
};
const getFavoritesOrder = () =>
@@ -641,8 +638,8 @@ describe('Sidebar section summaries', () => {
localStorage.setItem('remoteterm-sortOrder', 'alpha');
const publicChannel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public');
const zed = makeContact('11'.repeat(32), 'Zed', 1, { last_advert: 150 });
const amy = makeContact('22'.repeat(32), 'Amy');
const zed = makeContact('11'.repeat(32), 'Zed', 1, { last_advert: 150, favorite: true });
const amy = makeContact('22'.repeat(32), 'Amy', 1, { favorite: true });
render(
<Sidebar
@@ -660,10 +657,6 @@ describe('Sidebar section summaries', () => {
crackerRunning={false}
onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()}
favorites={[
{ type: 'contact', id: zed.public_key },
{ type: 'contact', id: amy.public_key },
]}
/>
);
+1
View File
@@ -24,6 +24,7 @@ function makeContact(
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
+6
View File
@@ -193,6 +193,7 @@ describe('resolveChannelFromHashToken', () => {
is_hashtag: false,
on_radio: true,
last_read_at: null,
favorite: false,
},
{
key: '11111111111111111111111111111111',
@@ -200,6 +201,7 @@ describe('resolveChannelFromHashToken', () => {
is_hashtag: true,
on_radio: false,
last_read_at: null,
favorite: false,
},
{
key: '22222222222222222222222222222222',
@@ -207,6 +209,7 @@ describe('resolveChannelFromHashToken', () => {
is_hashtag: false,
on_radio: false,
last_read_at: null,
favorite: false,
},
];
@@ -241,6 +244,7 @@ describe('resolveContactFromHashToken', () => {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -258,6 +262,7 @@ describe('resolveContactFromHashToken', () => {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -275,6 +280,7 @@ describe('resolveContactFromHashToken', () => {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -51,6 +51,7 @@ function makeContact(suffix: string): Contact {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -184,6 +185,7 @@ describe('useContactsAndChannels', () => {
is_hashtag: true,
on_radio: false,
last_read_at: null,
favorite: false,
},
],
existing_count: 1,
@@ -33,6 +33,7 @@ const publicChannel: Channel = {
is_hashtag: false,
on_radio: false,
last_read_at: null,
favorite: false,
};
const sentMessage: Message = {
@@ -208,6 +209,7 @@ describe('useConversationActions', () => {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -10,6 +10,7 @@ const publicChannel: Channel = {
is_hashtag: false,
on_radio: false,
last_read_at: null,
favorite: false,
};
function createArgs(overrides: Partial<Parameters<typeof useConversationNavigation>[0]> = {}) {
+57 -27
View File
@@ -10,9 +10,34 @@ import {
useFaviconBadge,
useUnreadTitle,
} from '../hooks/useFaviconBadge';
import type { Favorite } from '../types';
import type { Channel, Contact } from '../types';
import { getStateKey } from '../utils/conversationState';
function makeChannel(key: string, favorite = false): Channel {
return { key, name: key, is_hashtag: false, on_radio: false, last_read_at: null, favorite };
}
function makeContact(publicKey: string, favorite = false): Contact {
return {
public_key: publicKey,
name: publicKey,
type: 1,
flags: 0,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
favorite,
last_contacted: null,
last_read_at: null,
first_seen: null,
};
}
function getIconHref(rel: 'icon' | 'shortcut icon'): string | null {
return (
document.head.querySelector<HTMLLinkElement>(`link[rel="${rel}"]`)?.getAttribute('href') ?? null
@@ -71,16 +96,16 @@ describe('useFaviconBadge', () => {
});
it('derives badge priority from unread counts, mentions, and favorites', () => {
const favorites: Favorite[] = [{ type: 'channel', id: 'fav-chan' }];
const channels = [makeChannel('fav-chan', true)];
expect(deriveFaviconBadgeState({}, {}, favorites)).toBe('none');
expect(deriveFaviconBadgeState({}, {}, channels)).toBe('none');
expect(
deriveFaviconBadgeState(
{
[getStateKey('channel', 'fav-chan')]: 3,
},
{},
favorites
channels
)
).toBe('green');
expect(
@@ -89,7 +114,7 @@ describe('useFaviconBadge', () => {
[getStateKey('contact', 'abc')]: 12,
},
{},
favorites
channels
)
).toBe('red');
expect(
@@ -100,7 +125,7 @@ describe('useFaviconBadge', () => {
{
[getStateKey('channel', 'fav-chan')]: true,
},
favorites
channels
)
).toBe('red');
});
@@ -116,7 +141,7 @@ describe('useFaviconBadge', () => {
it('derives the unread count and page title', () => {
expect(getTotalUnreadCount({})).toBe(0);
expect(getTotalUnreadCount({ a: 2, b: 5 })).toBe(7);
expect(getFavoriteUnreadCount({}, [])).toBe(0);
expect(getFavoriteUnreadCount({}, [], [])).toBe(0);
expect(
getFavoriteUnreadCount(
{
@@ -124,20 +149,19 @@ describe('useFaviconBadge', () => {
[getStateKey('contact', 'fav-contact')]: 3,
[getStateKey('channel', 'other-chan')]: 9,
},
[
{ type: 'channel', id: 'fav-chan' },
{ type: 'contact', id: 'fav-contact' },
]
[makeContact('fav-contact', true)],
[makeChannel('fav-chan', true)]
)
).toBe(10);
expect(getUnreadTitle({}, [])).toBe('RemoteTerm for MeshCore');
expect(getUnreadTitle({}, [], [])).toBe('RemoteTerm for MeshCore');
expect(
getUnreadTitle(
{
[getStateKey('channel', 'fav-chan')]: 7,
[getStateKey('channel', 'other-chan')]: 9,
},
[{ type: 'channel', id: 'fav-chan' }]
[],
[makeChannel('fav-chan', true)]
)
).toBe('(7) RemoteTerm');
expect(
@@ -145,28 +169,29 @@ describe('useFaviconBadge', () => {
{
[getStateKey('channel', 'fav-chan')]: 120,
},
[{ type: 'channel', id: 'fav-chan' }]
[],
[makeChannel('fav-chan', true)]
)
).toBe('(99+) RemoteTerm');
});
it('switches between the base favicon and generated blob badges', async () => {
const favorites: Favorite[] = [{ type: 'channel', id: 'fav-chan' }];
const channels = [makeChannel('fav-chan', true)];
const { rerender } = renderHook(
({
unreadCounts,
mentions,
currentFavorites,
currentChannels,
}: {
unreadCounts: Record<string, number>;
mentions: Record<string, boolean>;
currentFavorites: Favorite[];
}) => useFaviconBadge(unreadCounts, mentions, currentFavorites),
currentChannels: Channel[];
}) => useFaviconBadge(unreadCounts, mentions, currentChannels),
{
initialProps: {
unreadCounts: {},
mentions: {},
currentFavorites: favorites,
currentChannels: channels,
},
}
);
@@ -181,7 +206,7 @@ describe('useFaviconBadge', () => {
[getStateKey('channel', 'fav-chan')]: 1,
},
mentions: {},
currentFavorites: favorites,
currentChannels: channels,
});
await waitFor(() => {
@@ -194,7 +219,7 @@ describe('useFaviconBadge', () => {
[getStateKey('contact', 'dm-key')]: 12,
},
mentions: {},
currentFavorites: favorites,
currentChannels: channels,
});
await waitFor(() => {
@@ -205,7 +230,7 @@ describe('useFaviconBadge', () => {
rerender({
unreadCounts: {},
mentions: {},
currentFavorites: favorites,
currentChannels: channels,
});
await waitFor(() => {
@@ -220,18 +245,22 @@ describe('useFaviconBadge', () => {
});
it('writes unread counts into the page title', () => {
const channels = [makeChannel('fav-chan', true)];
const { rerender, unmount } = renderHook(
({
unreadCounts,
favorites,
contacts,
currentChannels,
}: {
unreadCounts: Record<string, number>;
favorites: Favorite[];
}) => useUnreadTitle(unreadCounts, favorites),
contacts: Contact[];
currentChannels: Channel[];
}) => useUnreadTitle(unreadCounts, contacts, currentChannels),
{
initialProps: {
unreadCounts: {},
favorites: [{ type: 'channel', id: 'fav-chan' }],
contacts: [],
currentChannels: channels,
},
}
);
@@ -243,7 +272,8 @@ describe('useFaviconBadge', () => {
[getStateKey('channel', 'fav-chan')]: 4,
[getStateKey('contact', 'dm-key')]: 2,
},
favorites: [{ type: 'channel', id: 'fav-chan' }],
contacts: [],
currentChannels: channels,
});
expect(document.title).toBe('(4) RemoteTerm');
@@ -28,6 +28,7 @@ const publicChannel: Channel = {
is_hashtag: false,
on_radio: false,
last_read_at: null,
favorite: false,
};
const incomingDm: Message = {
@@ -109,6 +110,7 @@ describe('useRealtimeAppState', () => {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -151,6 +153,7 @@ describe('useRealtimeAppState', () => {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -241,6 +244,7 @@ describe('useRealtimeAppState', () => {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: 1700000000,
last_read_at: null,
first_seen: 1700000000,
@@ -35,6 +35,7 @@ function makeChannel(key: string, name: string): Channel {
is_hashtag: false,
on_radio: false,
last_read_at: null,
favorite: false,
};
}
@@ -52,6 +53,7 @@ function makeContact(pubkey: string): Contact {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
@@ -55,6 +55,7 @@ function createContact(publicKey: string, name: string, type = 1): Contact {
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
+2 -6
View File
@@ -121,6 +121,7 @@ export interface Contact {
lon: number | null;
last_seen: number | null;
on_radio: boolean;
favorite: boolean;
last_contacted: number | null;
last_read_at: number | null;
first_seen: number | null;
@@ -203,6 +204,7 @@ export interface Channel {
flood_scope_override?: string | null;
path_hash_mode_override?: number | null;
last_read_at: number | null;
favorite: boolean;
}
export interface ChannelMessageCounts {
@@ -323,14 +325,8 @@ export interface RawPacket {
} | null;
}
export interface Favorite {
type: 'channel' | 'contact';
id: string; // channel key or contact public key
}
export interface AppSettings {
max_radio_contacts: number;
favorites: Favorite[];
auto_decrypt_dm_on_advert: boolean;
last_message_times: Record<string, number>;
advert_interval: number;
-18
View File
@@ -1,18 +0,0 @@
/**
* Favorites utilities.
*
* Favorites are stored server-side in the database.
*/
import type { Favorite } from '../types';
/**
* Check if a conversation is favorited (from provided favorites array)
*/
export function isFavorite(
favorites: Favorite[],
type: 'channel' | 'contact',
id: string
): boolean {
return favorites.some((f) => f.type === type && f.id === id);
}