Add server-side read management

This commit is contained in:
Jack Kingsman
2026-01-12 23:26:02 -08:00
parent 1e1b1e2bb5
commit 5ce5a988c7
20 changed files with 887 additions and 102 deletions
+27 -6
View File
@@ -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:
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-6T32T4ZI.js"></script>
<script type="module" crossorigin src="/assets/index-Cp9RQ4Uj.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DaLCXB8p.css">
</head>
<body>
+1
View File
@@ -393,6 +393,7 @@ export function App() {
lon: null,
last_seen: null,
on_radio: false,
last_read_at: null,
};
setContacts((prev) => [...prev, newContact]);
+14
View File
@@ -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) =>
+41 -26
View File
@@ -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
+2
View File
@@ -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 {
+6 -18
View File
@@ -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: