mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-03 07:22:31 +02:00
Unread mentions are now red
This commit is contained in:
+541
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
-541
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user