Unread mentions are now red

This commit is contained in:
Jack Kingsman
2026-01-13 19:03:43 -08:00
parent 1cee26bff1
commit 1eeed67b14
9 changed files with 736 additions and 568 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -13,7 +13,7 @@
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<script type="module" crossorigin src="/assets/index-EJf0Snt6.js"></script>
<script type="module" crossorigin src="/assets/index-70eOpM-W.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DZ67iE5i.css">
</head>
<body>
+33 -12
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { api } from './api';
import { useWebSocket } from './useWebSocket';
import { useRepeaterMode, useUnreadCounts, useConversationMessages } from './hooks';
import { useRepeaterMode, useUnreadCounts, useConversationMessages, getMessageContentKey } from './hooks';
import { StatusBar } from './components/StatusBar';
import { Sidebar } from './components/Sidebar';
import { MessageList } from './components/MessageList';
@@ -37,8 +37,9 @@ const MAX_RAW_PACKETS = 500;
export function App() {
const messageInputRef = useRef<MessageInputHandle>(null);
const activeConversationRef = useRef<Conversation | null>(null);
// Track seen message IDs to prevent duplicate unread increments
const seenMessageIdsRef = useRef<Set<number>>(new Set());
// Track seen message content to prevent duplicate unread increments
// Uses content-based key (type-conversation_key-text-sender_timestamp) for deduplication
const seenMessageContentRef = useRef<Set<string>>(new Set());
const [health, setHealth] = useState<HealthStatus | null>(null);
const [config, setConfig] = useState<RadioConfig | null>(null);
const [appSettings, setAppSettings] = useState<AppSettings | null>(null);
@@ -57,6 +58,21 @@ export function App() {
// Track previous health status to detect changes
const prevHealthRef = useRef<HealthStatus | null>(null);
// Keep user's name in ref for mention detection in WebSocket callback
const myNameRef = useRef<string | null>(null);
useEffect(() => {
myNameRef.current = config?.name ?? null;
}, [config?.name]);
// Check if a message mentions the user
const checkMention = useCallback((text: string): boolean => {
const name = myNameRef.current;
if (!name) return false;
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const mentionPattern = new RegExp(`@\\[${escaped}\\]`, 'i');
return mentionPattern.test(text);
}, []);
// Custom hooks for extracted functionality
const {
messages,
@@ -72,11 +88,12 @@ export function App() {
const {
unreadCounts,
mentions,
lastMessageTimes,
incrementUnread,
markAllRead,
trackNewMessage,
} = useUnreadCounts(channels, contacts, activeConversation);
} = useUnreadCounts(channels, contacts, activeConversation, config?.name);
const {
repeaterLoggedIn,
@@ -137,16 +154,18 @@ export function App() {
// Count unread for non-active, incoming messages (with deduplication)
if (!msg.outgoing && !isForActiveConversation) {
// Skip if we've already seen this message ID (prevents duplicate increments)
if (seenMessageIdsRef.current.has(msg.id)) {
// Skip if we've already seen this message content (prevents duplicate increments
// when the same message arrives via multiple mesh paths)
const contentKey = getMessageContentKey(msg);
if (seenMessageContentRef.current.has(contentKey)) {
return;
}
seenMessageIdsRef.current.add(msg.id);
seenMessageContentRef.current.add(contentKey);
// Limit set size to prevent memory issues
if (seenMessageIdsRef.current.size > 1000) {
const ids = Array.from(seenMessageIdsRef.current);
seenMessageIdsRef.current = new Set(ids.slice(-500));
if (seenMessageContentRef.current.size > 1000) {
const keys = Array.from(seenMessageContentRef.current);
seenMessageContentRef.current = new Set(keys.slice(-500));
}
let stateKey: string | null = null;
@@ -156,7 +175,8 @@ export function App() {
stateKey = getStateKey('contact', msg.conversation_key);
}
if (stateKey) {
incrementUnread(stateKey);
const hasMention = checkMention(msg.text);
incrementUnread(stateKey, hasMention);
}
}
},
@@ -194,7 +214,7 @@ export function App() {
onMessageAcked: (messageId: number, ackCount: number) => {
updateMessageAck(messageId, ackCount);
},
}), [addMessageIfNew, trackNewMessage, incrementUnread, updateMessageAck]);
}), [addMessageIfNew, trackNewMessage, incrementUnread, updateMessageAck, checkMention]);
// Connect to WebSocket
useWebSocket(wsHandlers);
@@ -503,6 +523,7 @@ export function App() {
}}
lastMessageTimes={lastMessageTimes}
unreadCounts={unreadCounts}
mentions={mentions}
showCracker={showCracker}
crackerRunning={crackerRunning}
onToggleCracker={() => setShowCracker((prev) => !prev)}
+23 -2
View File
@@ -18,6 +18,8 @@ interface SidebarProps {
onNewMessage: () => void;
lastMessageTimes: ConversationTimes;
unreadCounts: Record<string, number>;
/** Tracks which conversations have unread messages that mention the user */
mentions: Record<string, boolean>;
showCracker: boolean;
crackerRunning: boolean;
onToggleCracker: () => void;
@@ -51,6 +53,7 @@ export function Sidebar({
onNewMessage,
lastMessageTimes,
unreadCounts,
mentions,
showCracker,
crackerRunning,
onToggleCracker,
@@ -79,6 +82,12 @@ export function Sidebar({
return unreadCounts[key] || 0;
};
// Check if a conversation has a mention
const hasMention = (type: 'channel' | 'contact', id: string): boolean => {
const key = getStateKey(type, id);
return mentions[key] || false;
};
const getLastMessageTime = (type: 'channel' | 'contact', id: string) => {
const key = getStateKey(type, id);
return lastMessageTimes[key] || 0;
@@ -295,6 +304,7 @@ export function Sidebar({
</div>
{filteredChannels.map((channel) => {
const unreadCount = getUnreadCount('channel', channel.key);
const isMention = hasMention('channel', channel.key);
return (
<div
key={`chan-${channel.key}`}
@@ -314,7 +324,12 @@ export function Sidebar({
<span className="text-muted-foreground text-xs">#</span>
<span className="name flex-1 truncate">{channel.name}</span>
{unreadCount > 0 && (
<span className="bg-primary text-primary-foreground text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center">
<span className={cn(
"text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center",
isMention
? "bg-destructive text-destructive-foreground"
: "bg-primary text-primary-foreground"
)}>
{unreadCount}
</span>
)}
@@ -341,6 +356,7 @@ export function Sidebar({
</div>
{filteredContacts.map((contact) => {
const unreadCount = getUnreadCount('contact', contact.public_key);
const isMention = hasMention('contact', contact.public_key);
return (
<div
key={contact.public_key}
@@ -362,7 +378,12 @@ export function Sidebar({
{getContactDisplayName(contact.name, contact.public_key)}
</span>
{unreadCount > 0 && (
<span className="bg-primary text-primary-foreground text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center">
<span className={cn(
"text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center",
isMention
? "bg-destructive text-destructive-foreground"
: "bg-primary text-primary-foreground"
)}>
{unreadCount}
</span>
)}
+70 -11
View File
@@ -10,21 +10,40 @@ import type { Channel, Contact, Conversation, Message } from '../types';
export interface UseUnreadCountsResult {
unreadCounts: Record<string, number>;
/** Tracks which conversations have unread messages that mention the user */
mentions: Record<string, boolean>;
lastMessageTimes: ConversationTimes;
incrementUnread: (stateKey: string) => void;
incrementUnread: (stateKey: string, hasMention?: boolean) => void;
markAllRead: () => void;
markConversationRead: (conv: Conversation) => void;
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[],
activeConversation: Conversation | null
activeConversation: Conversation | null,
myName: string | null = null
): UseUnreadCountsResult {
const [unreadCounts, setUnreadCounts] = useState<Record<string, number>>({});
const [mentions, setMentions] = useState<Record<string, boolean>>({});
const [lastMessageTimes, setLastMessageTimes] = useState<ConversationTimes>(getLastMessageTimes);
// Keep myName in a ref so callbacks always have current value
const myNameRef = useRef(myName);
useEffect(() => {
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());
@@ -52,6 +71,7 @@ export function useUnreadCounts(
try {
const bulkMessages = await api.getMessagesBulk(conversations, 100);
const newUnreadCounts: Record<string, number> = {};
const newMentions: Record<string, boolean> = {};
const newLastMessageTimes: Record<string, number> = {};
// Process channel messages - use server-side last_read_at
@@ -62,9 +82,13 @@ export function useUnreadCounts(
// Use server-side last_read_at, fallback to 0 if never read
const lastRead = channel.last_read_at || 0;
const unreadCount = msgs.filter(m => !m.outgoing && m.received_at > lastRead).length;
if (unreadCount > 0) {
newUnreadCounts[key] = unreadCount;
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));
@@ -81,9 +105,13 @@ export function useUnreadCounts(
// Use server-side last_read_at, fallback to 0 if never read
const lastRead = contact.last_read_at || 0;
const unreadCount = msgs.filter(m => !m.outgoing && m.received_at > lastRead).length;
if (unreadCount > 0) {
newUnreadCounts[key] = unreadCount;
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));
@@ -95,6 +123,9 @@ export function useUnreadCounts(
if (Object.keys(newUnreadCounts).length > 0) {
setUnreadCounts(prev => ({ ...prev, ...newUnreadCounts }));
}
if (Object.keys(newMentions).length > 0) {
setMentions(prev => ({ ...prev, ...newMentions }));
}
setLastMessageTimes(getLastMessageTimes());
} catch (err) {
console.error('Failed to fetch messages bulk:', err);
@@ -107,7 +138,7 @@ export function useUnreadCounts(
// Mark conversation as read when user views it
// Calls server API to persist read state across devices
useEffect(() => {
if (activeConversation && activeConversation.type !== 'raw') {
if (activeConversation && activeConversation.type !== 'raw' && activeConversation.type !== 'map') {
const key = getStateKey(
activeConversation.type as 'channel' | 'contact',
activeConversation.id
@@ -123,6 +154,16 @@ export function useUnreadCounts(
return prev;
});
// Also clear mentions for this conversation
setMentions((prev) => {
if (prev[key]) {
const next = { ...prev };
delete next[key];
return next;
}
return prev;
});
// Persist to server (fire-and-forget, errors logged but not blocking)
if (activeConversation.type === 'channel') {
api.markChannelRead(activeConversation.id).catch((err) => {
@@ -137,11 +178,17 @@ export function useUnreadCounts(
}, [activeConversation]);
// Increment unread count for a conversation
const incrementUnread = useCallback((stateKey: string) => {
const incrementUnread = useCallback((stateKey: string, hasMention?: boolean) => {
setUnreadCounts((prev) => ({
...prev,
[stateKey]: (prev[stateKey] || 0) + 1,
}));
if (hasMention) {
setMentions((prev) => ({
...prev,
[stateKey]: true,
}));
}
}, []);
// Mark all conversations as read
@@ -149,6 +196,7 @@ export function useUnreadCounts(
const markAllRead = useCallback(() => {
// Update local state immediately
setUnreadCounts({});
setMentions({});
// Persist to server with single bulk request
api.markAllRead().catch((err) => {
@@ -159,7 +207,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') return;
if (conv.type === 'raw' || conv.type === 'map') return;
const key = getStateKey(conv.type as 'channel' | 'contact', conv.id);
@@ -173,6 +221,16 @@ export function useUnreadCounts(
return prev;
});
// Also clear mentions for this conversation
setMentions((prev) => {
if (prev[key]) {
const next = { ...prev };
delete next[key];
return next;
}
return prev;
});
// Persist to server (fire-and-forget)
if (conv.type === 'channel') {
api.markChannelRead(conv.id).catch((err) => {
@@ -203,6 +261,7 @@ export function useUnreadCounts(
return {
unreadCounts,
mentions,
lastMessageTimes,
incrementUnread,
markAllRead,
+67
View File
@@ -175,3 +175,70 @@ describe('getUnreadCount', () => {
expect(getUnreadCount('contact', 'xyz999999999fullkey12345678901234567890123456789', counts)).toBe(0);
});
});
/**
* Check if a message text contains a mention of the given name in @[name] format.
* Extracted from useUnreadCounts.ts for testing.
*/
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);
}
describe('messageContainsMention', () => {
it('returns true when text contains mention of the name', () => {
expect(messageContainsMention('Hey @[Alice] check this out', 'Alice')).toBe(true);
});
it('returns false when text does not contain the mention', () => {
expect(messageContainsMention('Hey Alice check this out', 'Alice')).toBe(false);
});
it('returns false when name is null', () => {
expect(messageContainsMention('Hey @[Alice] check this out', null)).toBe(false);
});
it('returns false when text is empty', () => {
expect(messageContainsMention('', 'Alice')).toBe(false);
});
it('matches case insensitively', () => {
expect(messageContainsMention('Hey @[ALICE] check this out', 'alice')).toBe(true);
expect(messageContainsMention('Hey @[alice] check this out', 'ALICE')).toBe(true);
});
it('handles emojis in names', () => {
expect(messageContainsMention('Hey @[FlightlessDt🥝] nice!', 'FlightlessDt🥝')).toBe(true);
expect(messageContainsMention('Hey @[🎉Party🎉]', '🎉Party🎉')).toBe(true);
});
it('handles special regex characters in names', () => {
// Names with characters that have special meaning in regex
expect(messageContainsMention('Hey @[Test.User] hello', 'Test.User')).toBe(true);
expect(messageContainsMention('Hey @[User+1] hello', 'User+1')).toBe(true);
expect(messageContainsMention('Hey @[User*Star] hello', 'User*Star')).toBe(true);
expect(messageContainsMention('Hey @[What?] hello', 'What?')).toBe(true);
});
it('does not match partial names', () => {
// @[Alice] should not match a name of just "Ali"
expect(messageContainsMention('Hey @[Alice] check this', 'Ali')).toBe(false);
});
it('handles mention at start of text', () => {
expect(messageContainsMention('@[Bob] hello there', 'Bob')).toBe(true);
});
it('handles mention at end of text', () => {
expect(messageContainsMention('hello @[Bob]', 'Bob')).toBe(true);
});
it('handles multiple mentions - matches if user is mentioned', () => {
expect(messageContainsMention('@[Alice] and @[Bob] should see this', 'Alice')).toBe(true);
expect(messageContainsMention('@[Alice] and @[Bob] should see this', 'Bob')).toBe(true);
expect(messageContainsMention('@[Alice] and @[Bob] should see this', 'Charlie')).toBe(false);
});
});