mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-06 09:52:06 +02:00
Add server-side read management
This commit is contained in:
+27
-6
@@ -26,7 +26,7 @@ frontend/
|
||||
│ ├── styles.css # Dark theme CSS
|
||||
│ ├── utils/
|
||||
│ │ ├── messageParser.ts # Text parsing utilities
|
||||
│ │ ├── conversationState.ts # localStorage for unread tracking
|
||||
│ │ ├── conversationState.ts # localStorage for message times (sidebar sorting)
|
||||
│ │ ├── pubkey.ts # Public key utilities (prefix matching, display names)
|
||||
│ │ └── contactAvatar.ts # Avatar generation (colors, initials/emoji)
|
||||
│ ├── components/
|
||||
@@ -342,7 +342,7 @@ const activeConv = activeConversationRef.current;
|
||||
|
||||
### State Tracking Keys
|
||||
|
||||
State tracking keys (for unread counts and message times) are generated by `getStateKey()`:
|
||||
State tracking keys (for message times used in sidebar sorting) are generated by `getStateKey()`:
|
||||
|
||||
```typescript
|
||||
import { getStateKey } from './utils/conversationState';
|
||||
@@ -355,7 +355,26 @@ getStateKey('contact', publicKey) // e.g., "contact-abc123def456"
|
||||
```
|
||||
|
||||
**Note:** `getStateKey()` is NOT the same as `Message.conversation_key`. The state key is prefixed
|
||||
for localStorage tracking, while `conversation_key` is the raw database field.
|
||||
for local state tracking, while `conversation_key` is the raw database field.
|
||||
|
||||
### Read State (Server-Side)
|
||||
|
||||
Unread tracking uses server-side `last_read_at` timestamps for cross-device consistency:
|
||||
|
||||
```typescript
|
||||
// Contacts and channels include last_read_at from server
|
||||
interface Contact {
|
||||
// ...
|
||||
last_read_at: number | null; // Unix timestamp when conversation was last read
|
||||
}
|
||||
|
||||
// Mark as read via API (called automatically when viewing conversation)
|
||||
await api.markContactRead(publicKey);
|
||||
await api.markChannelRead(channelKey);
|
||||
await api.markAllRead(); // Bulk mark all as read
|
||||
```
|
||||
|
||||
Unread count = messages where `received_at > last_read_at`.
|
||||
|
||||
## Utility Functions
|
||||
|
||||
@@ -389,17 +408,19 @@ getContactDisplayName(name, publicKey) // name or first 12 chars of key
|
||||
### Conversation State (`utils/conversationState.ts`)
|
||||
|
||||
```typescript
|
||||
import { getStateKey, setLastMessageTime, setLastReadTime } from './utils/conversationState';
|
||||
import { getStateKey, setLastMessageTime, getLastMessageTimes } from './utils/conversationState';
|
||||
|
||||
// Generate state tracking key (NOT the same as Message.conversation_key)
|
||||
getStateKey('channel', channelKey)
|
||||
getStateKey('contact', publicKey)
|
||||
|
||||
// Track message times for unread detection
|
||||
// Track message times for sidebar sorting (stored in localStorage)
|
||||
setLastMessageTime(stateKey, timestamp)
|
||||
setLastReadTime(stateKey, timestamp)
|
||||
getLastMessageTimes() // Returns all tracked message times
|
||||
```
|
||||
|
||||
**Note:** Read state (`last_read_at`) is tracked server-side, not in localStorage.
|
||||
|
||||
### Contact Avatar (`utils/contactAvatar.ts`)
|
||||
|
||||
Generates consistent profile "images" for contacts using hash-based colors:
|
||||
|
||||
+38
-38
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-6T32T4ZI.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-Cp9RQ4Uj.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DaLCXB8p.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -393,6 +393,7 @@ export function App() {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
};
|
||||
setContacts((prev) => [...prev, newContact]);
|
||||
|
||||
|
||||
@@ -82,6 +82,10 @@ export const api = {
|
||||
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
markContactRead: (publicKey: string) =>
|
||||
fetchJson<{ status: string; public_key: string }>(`/contacts/${publicKey}/mark-read`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
requestTelemetry: (publicKey: string, password: string) =>
|
||||
fetchJson<TelemetryResponse>(`/contacts/${publicKey}/telemetry`, {
|
||||
method: 'POST',
|
||||
@@ -105,6 +109,10 @@ export const api = {
|
||||
fetchJson<{ synced: number }>('/channels/sync', { method: 'POST' }),
|
||||
deleteChannel: (key: string) =>
|
||||
fetchJson<{ status: string }>(`/channels/${key}`, { method: 'DELETE' }),
|
||||
markChannelRead: (key: string) =>
|
||||
fetchJson<{ status: string; key: string }>(`/channels/${key}/mark-read`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
|
||||
// Messages
|
||||
getMessages: (params?: {
|
||||
@@ -157,6 +165,12 @@ export const api = {
|
||||
body: JSON.stringify(params),
|
||||
}),
|
||||
|
||||
// Read State
|
||||
markAllRead: () =>
|
||||
fetchJson<{ status: string; timestamp: number }>('/read-state/mark-all-read', {
|
||||
method: 'POST',
|
||||
}),
|
||||
|
||||
// App Settings
|
||||
getSettings: () => fetchJson<AppSettings>('/settings'),
|
||||
updateSettings: (settings: AppSettingsUpdate) =>
|
||||
|
||||
@@ -2,9 +2,7 @@ import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { api } from '../api';
|
||||
import {
|
||||
getLastMessageTimes,
|
||||
getLastReadTimes,
|
||||
setLastMessageTime,
|
||||
setLastReadTime,
|
||||
getStateKey,
|
||||
type ConversationTimes,
|
||||
} from '../utils/conversationState';
|
||||
@@ -32,6 +30,7 @@ export function useUnreadCounts(
|
||||
const fetchedContacts = useRef<Set<string>>(new Set());
|
||||
|
||||
// Fetch messages and count unreads for new channels/contacts
|
||||
// Uses server-side last_read_at for consistent read state across devices
|
||||
useEffect(() => {
|
||||
const newChannels = channels.filter(c => !fetchedChannels.current.has(c.key));
|
||||
const newContacts = contacts.filter(c => c.public_key && !fetchedContacts.current.has(c.public_key));
|
||||
@@ -52,16 +51,16 @@ export function useUnreadCounts(
|
||||
|
||||
try {
|
||||
const bulkMessages = await api.getMessagesBulk(conversations, 100);
|
||||
const currentReadTimes = getLastReadTimes();
|
||||
const newUnreadCounts: Record<string, number> = {};
|
||||
const newLastMessageTimes: Record<string, number> = {};
|
||||
|
||||
// Process channel messages
|
||||
// Process channel messages - use server-side last_read_at
|
||||
for (const channel of newChannels) {
|
||||
const msgs = bulkMessages[`CHAN:${channel.key}`] || [];
|
||||
if (msgs.length > 0) {
|
||||
const key = getStateKey('channel', channel.key);
|
||||
const lastRead = currentReadTimes[key] || 0;
|
||||
// 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) {
|
||||
@@ -74,12 +73,13 @@ export function useUnreadCounts(
|
||||
}
|
||||
}
|
||||
|
||||
// Process contact messages
|
||||
// Process contact messages - use server-side last_read_at
|
||||
for (const contact of newContacts) {
|
||||
const msgs = bulkMessages[`PRIV:${contact.public_key}`] || [];
|
||||
if (msgs.length > 0) {
|
||||
const key = getStateKey('contact', contact.public_key);
|
||||
const lastRead = currentReadTimes[key] || 0;
|
||||
// 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) {
|
||||
@@ -105,15 +105,15 @@ export function useUnreadCounts(
|
||||
}, [channels, contacts]);
|
||||
|
||||
// Mark conversation as read when user views it
|
||||
// Calls server API to persist read state across devices
|
||||
useEffect(() => {
|
||||
if (activeConversation && activeConversation.type !== 'raw') {
|
||||
const key = getStateKey(
|
||||
activeConversation.type as 'channel' | 'contact',
|
||||
activeConversation.id
|
||||
);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
setLastReadTime(key, now);
|
||||
|
||||
// Update local state immediately for responsive UI
|
||||
setUnreadCounts((prev) => {
|
||||
if (prev[key]) {
|
||||
const next = { ...prev };
|
||||
@@ -122,6 +122,17 @@ export function useUnreadCounts(
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
|
||||
// Persist to server (fire-and-forget, errors logged but not blocking)
|
||||
if (activeConversation.type === 'channel') {
|
||||
api.markChannelRead(activeConversation.id).catch((err) => {
|
||||
console.error('Failed to mark channel as read on server:', err);
|
||||
});
|
||||
} else if (activeConversation.type === 'contact') {
|
||||
api.markContactRead(activeConversation.id).catch((err) => {
|
||||
console.error('Failed to mark contact as read on server:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [activeConversation]);
|
||||
|
||||
@@ -134,32 +145,25 @@ export function useUnreadCounts(
|
||||
}, []);
|
||||
|
||||
// Mark all conversations as read
|
||||
// Calls single bulk API endpoint to persist read state
|
||||
const markAllRead = useCallback(() => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
for (const channel of channels) {
|
||||
const key = getStateKey('channel', channel.key);
|
||||
setLastReadTime(key, now);
|
||||
}
|
||||
|
||||
for (const contact of contacts) {
|
||||
if (contact.public_key) {
|
||||
const key = getStateKey('contact', contact.public_key);
|
||||
setLastReadTime(key, now);
|
||||
}
|
||||
}
|
||||
|
||||
// Update local state immediately
|
||||
setUnreadCounts({});
|
||||
}, [channels, contacts]);
|
||||
|
||||
// Persist to server with single bulk request
|
||||
api.markAllRead().catch((err) => {
|
||||
console.error('Failed to mark all as read on server:', err);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 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;
|
||||
|
||||
const key = getStateKey(conv.type as 'channel' | 'contact', conv.id);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
setLastReadTime(key, now);
|
||||
|
||||
// Update local state immediately
|
||||
setUnreadCounts((prev) => {
|
||||
if (prev[key]) {
|
||||
const next = { ...prev };
|
||||
@@ -168,6 +172,17 @@ export function useUnreadCounts(
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
|
||||
// Persist to server (fire-and-forget)
|
||||
if (conv.type === 'channel') {
|
||||
api.markChannelRead(conv.id).catch((err) => {
|
||||
console.error('Failed to mark channel as read on server:', err);
|
||||
});
|
||||
} else if (conv.type === 'contact') {
|
||||
api.markContactRead(conv.id).catch((err) => {
|
||||
console.error('Failed to mark contact as read on server:', err);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Track a new incoming message for unread counts
|
||||
|
||||
@@ -55,6 +55,7 @@ export interface Contact {
|
||||
lon: number | null;
|
||||
last_seen: number | null;
|
||||
on_radio: boolean;
|
||||
last_read_at: number | null;
|
||||
}
|
||||
|
||||
export interface Channel {
|
||||
@@ -62,6 +63,7 @@ export interface Channel {
|
||||
name: string;
|
||||
is_hashtag: boolean;
|
||||
on_radio: boolean;
|
||||
last_read_at: number | null;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
/**
|
||||
* localStorage utilities for tracking conversation read/message state.
|
||||
* localStorage utilities for tracking conversation message times.
|
||||
*
|
||||
* Stores two maps:
|
||||
* - lastMessageTime: when each conversation last received a message
|
||||
* - lastReadTime: when the user last viewed each conversation
|
||||
* Stores when each conversation last received a message, used for
|
||||
* sorting conversations by recency in the sidebar.
|
||||
*
|
||||
* A conversation has unread messages if lastMessageTime > lastReadTime.
|
||||
* Read state (last_read_at) is tracked server-side for consistency
|
||||
* across devices - see useUnreadCounts hook.
|
||||
*/
|
||||
|
||||
import { getPubkeyPrefix } from './pubkey';
|
||||
|
||||
const LAST_MESSAGE_KEY = 'remoteterm-lastMessageTime';
|
||||
const LAST_READ_KEY = 'remoteterm-lastReadTime';
|
||||
|
||||
export type ConversationTimes = Record<string, number>;
|
||||
|
||||
@@ -36,10 +35,6 @@ export function getLastMessageTimes(): ConversationTimes {
|
||||
return loadTimes(LAST_MESSAGE_KEY);
|
||||
}
|
||||
|
||||
export function getLastReadTimes(): ConversationTimes {
|
||||
return loadTimes(LAST_READ_KEY);
|
||||
}
|
||||
|
||||
export function setLastMessageTime(stateKey: string, timestamp: number): ConversationTimes {
|
||||
const times = loadTimes(LAST_MESSAGE_KEY);
|
||||
// Only update if this is a newer message
|
||||
@@ -50,15 +45,8 @@ export function setLastMessageTime(stateKey: string, timestamp: number): Convers
|
||||
return times;
|
||||
}
|
||||
|
||||
export function setLastReadTime(stateKey: string, timestamp: number): ConversationTimes {
|
||||
const times = loadTimes(LAST_READ_KEY);
|
||||
times[stateKey] = timestamp;
|
||||
saveTimes(LAST_READ_KEY, times);
|
||||
return times;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a state tracking key for unread counts and message times.
|
||||
* Generate a state tracking key for message times.
|
||||
*
|
||||
* This is NOT the same as Message.conversation_key (the database field).
|
||||
* This creates prefixed keys for localStorage/state tracking:
|
||||
|
||||
Reference in New Issue
Block a user