toggleAddServerModal(true)}
data-testid="server-list-add-button"
>
diff --git a/src/components/message/MessageItem.tsx b/src/components/message/MessageItem.tsx
index e29eebaa..92f0ab2f 100644
--- a/src/components/message/MessageItem.tsx
+++ b/src/components/message/MessageItem.tsx
@@ -2,7 +2,6 @@ import type * as React from "react";
import { memo, useCallback, useMemo, useRef, useState } from "react";
import { useLongPress } from "../../hooks/useLongPress";
import { useMediaQuery } from "../../hooks/useMediaQuery";
-import ircClient from "../../lib/ircClient";
import {
isUrlFromFilehost,
isUserVerified,
@@ -16,8 +15,8 @@ import {
mediaLevelToSettings,
} from "../../lib/mediaUtils";
import { stripIrcFormatting } from "../../lib/messageFormatter";
-import useStore, { loadSavedMetadata } from "../../store";
-import type { MessageType, PrivateChat, User } from "../../types";
+import useStore, { type AppState, loadSavedMetadata } from "../../store";
+import type { MessageType, User } from "../../types";
import MessageBottomSheet from "../mobile/MessageBottomSheet";
import { EnhancedLinkWrapper } from "../ui/LinkWrapper";
import type { CollapsibleMessageHandle } from "./CollapsibleMessage";
@@ -97,23 +96,14 @@ const getUserMetadata = (username: string, serverId: string) => {
return null;
};
-// Helper function to get full user object from shared channels
-const getUserFromChannels = (username: string, serverId: string) => {
- const state = useStore.getState();
- const server = state.servers.find((s) => s.id === serverId);
- if (!server) return null;
-
- // Search through all channels for this user
- for (const channel of server.channels) {
- const user = channel.users.find(
- (u) => u.username.toLowerCase() === username.toLowerCase(),
- );
- if (user) {
- return user;
- }
- }
-
- return null;
+// Helper function to get user from global normalization store
+const getUserFromGlobal = (
+ state: AppState,
+ serverId: string,
+ username: string,
+) => {
+ const key = `${serverId}-${username.toLowerCase()}`;
+ return state.globalUsers[key];
};
// When index 0 has no preview (type null), the first known-type entry must be shown
@@ -196,117 +186,19 @@ export const MessageItem = memo((props: MessageItemProps) => {
}
};
- const ircCurrentUser = ircClient.getCurrentUser(message.serverId);
- const isCurrentUser = ircCurrentUser?.username === message.userId;
-
- // Get the user key using reactive selector
- const userKey = useStore(
+ const messageUser: User | undefined = useStore(
useCallback(
(state) => {
- if (!serverId) return "none";
-
- if (!channelId) {
- const server = state.servers.find((s) => s.id === serverId);
- // Prefer channel user — covers current user who has no PrivateChat entry
- if (server) {
- const user = server.channels
- .flatMap((c) => c.users)
- .find(
- (u) =>
- u.username.toLowerCase() === message.userId.toLowerCase(),
- );
- if (user) {
- return `channel-${user.id}`;
- }
- }
- const privateChat = server?.privateChats?.find(
- (pc) => pc.username === message.userId,
- );
- if (privateChat) {
- return `pm-${privateChat.id}`;
- }
- return "none";
- }
-
- const server = state.servers.find((s) => s.id === serverId);
- const channel = server?.channels.find((c) => c.id === channelId);
- const user = channel?.users.find(
- (user) => user.username === message.userId,
- );
- return user ? `channel-${user.id}` : "none";
- },
- [serverId, channelId, message.userId],
- ),
- );
-
- const rawMessageUser = useStore(
- useCallback(
- (state) => {
- if (userKey === "none") return undefined;
-
- if (userKey.startsWith("pm-")) {
- const privateChatId = userKey.slice(3);
- const privateChat = state.servers
- .find((s) => s.id === serverId)
- ?.privateChats?.find((pc) => pc.id === privateChatId);
- if (privateChat) return privateChat;
- } else if (userKey.startsWith("channel-")) {
- const userId = userKey.slice(8);
- const server = state.servers.find((s) => s.id === serverId);
- if (channelId) {
- const channel = server?.channels.find((c) => c.id === channelId);
- return channel?.users.find((user) => user.id === userId);
- }
- // DM context: no channelId, search all channels
- return server?.channels
- .flatMap((c) => c.users)
- .find((user) => user.id === userId);
- }
-
- return undefined;
+ const key = `${serverId}-${message.userId.toLowerCase()}`;
+ return state.globalUsers[key];
},
- [userKey, serverId, channelId],
+ [serverId, message.userId],
),
);
- const metadataChangeCounter = useStore(
- (state) => state.metadataChangeCounter,
- );
-
- // useMemo instead of useStore — safe to read localStorage without infinite loop via useSyncExternalStore
- // biome-ignore lint/correctness/useExhaustiveDependencies: metadataChangeCounter is intentional reactive trigger
- const pmUserMetadata = useMemo(() => {
- if (!userKey.startsWith("pm-")) return null;
- const pmUsername = (rawMessageUser as PrivateChat)?.username;
- if (!pmUsername || !serverId) return null;
- return getUserMetadata(pmUsername, serverId);
- }, [metadataChangeCounter, userKey, rawMessageUser, serverId]);
-
- const messageUser: User | undefined = useMemo(() => {
- if (!rawMessageUser) return undefined;
-
- // For PM users, rawMessageUser is the privateChat object
- // We need to construct a proper User object
- if (userKey.startsWith("pm-")) {
- const privateChat = rawMessageUser as PrivateChat;
- const user: User = {
- id: privateChat.id,
- username: privateChat.username,
- realname: "",
- account: "",
- isOnline: privateChat.isOnline ?? true,
- isAway: privateChat.isAway ?? false,
- status: "",
- isBot: false,
- isIrcOp: false,
- metadata: pmUserMetadata || {},
- };
- return user;
- }
-
- // For channel users, rawMessageUser is already a proper User object
- return rawMessageUser as User;
- }, [rawMessageUser, pmUserMetadata, userKey]);
+ const ircCurrentUser = useStore((state) => state.currentUser);
+ const isCurrentUser =
+ ircCurrentUser?.username.toLowerCase() === message.userId.toLowerCase();
const avatarUrl = messageUser?.metadata?.avatar?.value;
const displayName = messageUser?.metadata?.["display-name"]?.value;
diff --git a/src/index.css b/src/index.css
index ff208ddf..a6c9c19d 100644
--- a/src/index.css
+++ b/src/index.css
@@ -26,12 +26,20 @@
}
body {
- font-family: 'Roboto Mono', monospace;
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
width: 100%;
height: 100%;
overflow: hidden;
font-size: 14px;
- background-color: #202225;
+ background-color: #0f1113;
+ color: #f2f3f5;
+}
+
+.glass {
+ background: rgba(32, 34, 37, 0.7);
+ backdrop-filter: blur(8px);
+ -webkit-backdrop-filter: blur(8px);
+ border: 1px solid rgba(255, 255, 255, 0.05);
}
/* iOS safe area support for modals */
diff --git a/src/store/handlers/batches.ts b/src/store/handlers/batches.ts
index 0c425a66..cd48d618 100644
--- a/src/store/handlers/batches.ts
+++ b/src/store/handlers/batches.ts
@@ -362,10 +362,14 @@ export function registerBatchHandlers(store: StoreApi
): void {
[serverId]: remainingBatches,
},
messages: { ...state.messages, [key]: finalMessages },
- processedMessageIds:
- newMsgIds.length > 0
- ? new Set([...state.processedMessageIds, ...newMsgIds])
- : state.processedMessageIds,
+ processedMessageIds: (() => {
+ if (newMsgIds.length === 0) return state.processedMessageIds;
+ const newMap = new Map(state.processedMessageIds);
+ for (const id of newMsgIds) {
+ newMap.set(id, Date.now());
+ }
+ return newMap;
+ })(),
servers: state.servers.map((s) => {
if (s.id !== serverId) return s;
return {
@@ -464,10 +468,14 @@ export function registerBatchHandlers(store: StoreApi): void {
[serverId]: remainingBatches,
},
messages: { ...state.messages, [key]: finalMessages },
- processedMessageIds:
- newMsgIds.length > 0
- ? new Set([...state.processedMessageIds, ...newMsgIds])
- : state.processedMessageIds,
+ processedMessageIds: (() => {
+ if (newMsgIds.length === 0) return state.processedMessageIds;
+ const newMap = new Map(state.processedMessageIds);
+ for (const id of newMsgIds) {
+ newMap.set(id, Date.now());
+ }
+ return newMap;
+ })(),
};
}
diff --git a/src/store/handlers/messages.ts b/src/store/handlers/messages.ts
index 68f3644e..a7d136d2 100644
--- a/src/store/handlers/messages.ts
+++ b/src/store/handlers/messages.ts
@@ -275,10 +275,9 @@ export function registerMessageHandlers(store: StoreApi): void {
// Add processed message ID if present
if (mtags?.msgid) {
- newState.processedMessageIds = new Set([
- ...state.processedMessageIds,
- mtags.msgid,
- ]);
+ const newMap = new Map(state.processedMessageIds);
+ newMap.set(mtags.msgid, Date.now());
+ newState.processedMessageIds = newMap;
}
return newState;
@@ -415,12 +414,13 @@ export function registerMessageHandlers(store: StoreApi): void {
? [mtags.msgid]
: [];
if (idsToTrack.length > 0) {
- store.setState((state) => ({
- processedMessageIds: new Set([
- ...state.processedMessageIds,
- ...idsToTrack,
- ]),
- }));
+ store.setState((state) => {
+ const newMap = new Map(state.processedMessageIds);
+ for (const id of idsToTrack) {
+ newMap.set(id, Date.now());
+ }
+ return { processedMessageIds: newMap };
+ });
}
store.getState().addMessage(newMessage);
@@ -492,12 +492,13 @@ export function registerMessageHandlers(store: StoreApi): void {
? [mtags.msgid]
: [];
if (idsToTrack.length > 0) {
- store.setState((state) => ({
- processedMessageIds: new Set([
- ...state.processedMessageIds,
- ...idsToTrack,
- ]),
- }));
+ store.setState((state) => {
+ const newMap = new Map(state.processedMessageIds);
+ for (const id of idsToTrack) {
+ newMap.set(id, Date.now());
+ }
+ return { processedMessageIds: newMap };
+ });
}
store.getState().addMessage(newMessage);
}
@@ -560,12 +561,13 @@ export function registerMessageHandlers(store: StoreApi): void {
? [mtags.msgid]
: [];
if (idsToTrack.length > 0) {
- store.setState((state) => ({
- processedMessageIds: new Set([
- ...state.processedMessageIds,
- ...idsToTrack,
- ]),
- }));
+ store.setState((state) => {
+ const newMap = new Map(state.processedMessageIds);
+ for (const id of idsToTrack) {
+ newMap.set(id, Date.now());
+ }
+ return { processedMessageIds: newMap };
+ });
}
store.getState().addMessage(newMessage);
@@ -701,12 +703,11 @@ export function registerMessageHandlers(store: StoreApi): void {
// Mark this message ID as processed to prevent duplicates
if (mtags?.msgid) {
- store.setState((state) => ({
- processedMessageIds: new Set([
- ...state.processedMessageIds,
- mtags.msgid,
- ]),
- }));
+ store.setState((state) => {
+ const newMap = new Map(state.processedMessageIds);
+ newMap.set(mtags.msgid, Date.now());
+ return { processedMessageIds: newMap };
+ });
}
store.getState().addMessage(newMessage);
@@ -841,12 +842,11 @@ export function registerMessageHandlers(store: StoreApi): void {
// Mark this message ID as processed to prevent duplicates
if (mtags?.msgid) {
- store.setState((state) => ({
- processedMessageIds: new Set([
- ...state.processedMessageIds,
- mtags.msgid,
- ]),
- }));
+ store.setState((state) => {
+ const newMap = new Map(state.processedMessageIds);
+ newMap.set(mtags.msgid, Date.now());
+ return { processedMessageIds: newMap };
+ });
}
// If the stored username casing differs from the server-sent nick, correct it now.
@@ -1077,12 +1077,11 @@ export function registerMessageHandlers(store: StoreApi): void {
// Mark this message ID as processed to prevent duplicates
if (mtags?.msgid) {
- store.setState((state) => ({
- processedMessageIds: new Set([
- ...state.processedMessageIds,
- mtags.msgid,
- ]),
- }));
+ store.setState((state) => {
+ const newMap = new Map(state.processedMessageIds);
+ newMap.set(mtags.msgid, Date.now());
+ return { processedMessageIds: newMap };
+ });
}
store.getState().addMessage(newMessage);
@@ -1237,12 +1236,11 @@ export function registerMessageHandlers(store: StoreApi): void {
// Mark this message ID as processed to prevent duplicates
if (mtags?.msgid) {
- store.setState((state) => ({
- processedMessageIds: new Set([
- ...state.processedMessageIds,
- mtags.msgid,
- ]),
- }));
+ store.setState((state) => {
+ const newMap = new Map(state.processedMessageIds);
+ newMap.set(mtags.msgid, Date.now());
+ return { processedMessageIds: newMap };
+ });
}
store.getState().addMessage(newMessage);
diff --git a/src/store/handlers/users.ts b/src/store/handlers/users.ts
index 28887f48..060efa84 100644
--- a/src/store/handlers/users.ts
+++ b/src/store/handlers/users.ts
@@ -1,7 +1,7 @@
import { v4 as uuidv4 } from "uuid";
import type { StoreApi } from "zustand";
import ircClient from "../../lib/ircClient";
-import type { Message, User } from "../../types";
+import type { Message } from "../../types";
import {
generateDeterministicId,
getCurrentSelection,
@@ -146,45 +146,26 @@ export function registerUserHandlers(store: StoreApi): void {
});
// Fall through to shared message creation below — same JOIN event, same path.
} else {
- store.setState((state) => {
- const updatedServers = state.servers.map((server) => {
- if (server.id !== serverId) return server;
-
- // Restore metadata so live joins show avatars immediately.
- const savedMetadata = storage.metadata.load();
- const serverMetadata = savedMetadata[serverId];
- const joinedUserMetadata = resolveUserMetadata(
- username,
- serverMetadata,
- server.channels,
- );
-
- const updatedChannels = server.channels.map((channel) => {
- if (channel.name.toLowerCase() !== channelName.toLowerCase())
- return channel;
-
- const alreadyIn = channel.users.some(
- (u) => u.username.toLowerCase() === username.toLowerCase(),
- );
- if (alreadyIn) return channel;
-
- const newUser: User = {
- id: uuidv4(),
- username,
- isOnline: true,
- account: account || undefined,
- realname: realname || undefined,
- metadata: joinedUserMetadata,
- };
-
- return { ...channel, users: [...channel.users, newUser] };
- });
+ const state = store.getState();
+ const server = state.servers.find((s) => s.id === serverId);
+ if (server) {
+ // Restore metadata so live joins show avatars immediately.
+ const savedMetadata = storage.metadata.load();
+ const serverMetadata = savedMetadata[serverId];
+ const joinedUserMetadata = resolveUserMetadata(
+ username,
+ serverMetadata,
+ server.channels,
+ );
- return { ...server, channels: updatedChannels };
+ state.updateGlobalUser(serverId, {
+ username,
+ account: account || undefined,
+ realname: realname || undefined,
+ metadata: joinedUserMetadata,
+ isOnline: true,
});
-
- return { servers: updatedServers };
- });
+ }
}
const state = store.getState();
@@ -220,7 +201,6 @@ export function registerUserHandlers(store: StoreApi): void {
const state = store.getState();
const batch = state.activeBatches[serverId]?.[batchTag];
if (batch?.type === "chathistory") {
- // Historical nick change from event-playback — create a message record, skip live mutation.
if (
state.globalSettings.showEvents &&
state.globalSettings.showNickChanges
@@ -257,48 +237,10 @@ export function registerUserHandlers(store: StoreApi): void {
return;
}
}
- store.setState((state) => {
- const updatedServers = state.servers.map((server) => {
- if (server.id === serverId) {
- const updatedChannels = server.channels.map((channel) => {
- const updatedUsers = channel.users.map((user) => {
- if (user.username === oldNick) {
- return { ...user, username: newNick };
- }
- return user;
- });
- return { ...channel, users: updatedUsers };
- });
- return { ...server, channels: updatedChannels };
- }
- return server;
- });
-
- // Update currentUser only if this nick change is for the currently selected server
- // and it's our own nick that changed
- let updatedCurrentUser = state.currentUser;
- const isSelectedServer = state.ui.selectedServerId === serverId;
- const serverCurrentUser = ircClient.getCurrentUser(serverId);
- const isOurNick =
- serverCurrentUser?.username === oldNick ||
- serverCurrentUser?.username === newNick;
-
- if (
- isSelectedServer &&
- isOurNick &&
- state.currentUser &&
- state.currentUser.username === oldNick
- ) {
- updatedCurrentUser = { ...state.currentUser, username: newNick };
- }
-
- return {
- servers: updatedServers,
- currentUser: updatedCurrentUser,
- };
- });
const state = store.getState();
+ state.changeGlobalNick(serverId, oldNick, newNick);
+
const server = state.servers.find((s) => s.id === serverId);
if (
server &&
@@ -323,7 +265,7 @@ export function registerUserHandlers(store: StoreApi): void {
makeEventMessage(
"nick",
nickContent,
- oldNick, // userId is old nick so the message avatar/link resolves correctly
+ oldNick,
channel.id,
serverId,
new Date(),
@@ -331,43 +273,18 @@ export function registerUserHandlers(store: StoreApi): void {
);
}
});
+ }
- const privateChat = server.privateChats?.find(
- (pc) =>
- pc.username.toLowerCase() === oldNick.toLowerCase() ||
- pc.username.toLowerCase() === newNick.toLowerCase(),
- );
- if (privateChat) {
- store.setState((state) => {
- const updatedServers = state.servers.map((s) => {
- if (s.id === serverId) {
- const updatedPrivateChats = s.privateChats?.map((pc) => {
- if (pc.username.toLowerCase() === oldNick.toLowerCase()) {
- return { ...pc, username: newNick };
- }
- return pc;
- });
- return { ...s, privateChats: updatedPrivateChats };
- }
- return s;
- });
- return { servers: updatedServers };
- });
-
- appendMessage(
- store,
- serverId,
- privateChat.id,
- makeEventMessage(
- "nick",
- nickContent,
- oldNick,
- privateChat.id,
- serverId,
- new Date(),
- ),
- );
- }
+ // Update currentUser if it's our nick
+ if (
+ state.ui.selectedServerId === serverId &&
+ state.currentUser?.username === oldNick
+ ) {
+ store.setState((s) => ({
+ currentUser: s.currentUser
+ ? { ...s.currentUser, username: newNick }
+ : null,
+ }));
}
});
diff --git a/src/store/index.ts b/src/store/index.ts
index 7ef1cff3..2bb61ef4 100644
--- a/src/store/index.ts
+++ b/src/store/index.ts
@@ -450,6 +450,7 @@ export interface AppState {
selectedServerId: string | null;
connectionError: string | null;
messages: Record;
+ globalUsers: Record; // serverId-username -> User (Normalized identity store)
typingUsers: Record;
typingTimers: Record>;
globalNotifications: {
@@ -527,7 +528,8 @@ export interface AppState {
// Channel order persistence
channelOrder: ChannelOrderMap; // serverId -> ordered array of channel names
// Message deduplication tracking
- processedMessageIds: Set; // Set of msgid values that have already been processed
+ processedMessageIds: Map; // msgid -> timestamp of processing
+ processedMessageCleanupTimer?: NodeJS.Timeout;
// Auto-connect prevention
hasConnectedToSavedServers: boolean;
// UI state
@@ -631,7 +633,17 @@ export interface AppState {
) => void;
setName: (serverId: string, realname: string) => void;
changeNick: (serverId: string, newNick: string) => void;
+ changeGlobalNick: (
+ serverId: string,
+ oldNick: string,
+ newNick: string,
+ ) => void;
addMessage: (message: Message) => void;
+ updateGlobalUser: (
+ serverId: string,
+ user: Partial & { username: string },
+ ) => void;
+ pruneProcessedMessageIds: () => void;
addGlobalNotification: (notification: {
type: "fail" | "warn" | "note";
command: string;
@@ -846,9 +858,10 @@ const useStore = create((set, get) => ({
whoisData: {},
pendingRegistration: null,
channelOrder: loadChannelOrder(),
- processedMessageIds: new Set(),
+ processedMessageIds: new Map(),
hasConnectedToSavedServers: false,
selectedServerId: null,
+ globalUsers: {},
// UI state
ui: {
@@ -1464,54 +1477,169 @@ const useStore = create((set, get) => ({
ircClient.changeNick(serverId, newNick);
},
+ changeGlobalNick: (serverId, oldNick, newNick) => {
+ set((state) => {
+ const oldKey = `${serverId}-${oldNick.toLowerCase()}`;
+ const newKey = `${serverId}-${newNick.toLowerCase()}`;
+ const user = state.globalUsers[oldKey];
+
+ if (!user) return {};
+
+ const { [oldKey]: _, ...remainingUsers } = state.globalUsers;
+ const updatedUser = { ...user, username: newNick };
+
+ // Propagate to channel lists
+ const updatedServers = state.servers.map((s) => {
+ if (s.id === serverId) {
+ return {
+ ...s,
+ channels: s.channels.map((ch) => ({
+ ...ch,
+ users: ch.users.map((u) =>
+ u.username.toLowerCase() === oldNick.toLowerCase()
+ ? { ...u, username: newNick }
+ : u,
+ ),
+ })),
+ };
+ }
+ return s;
+ });
+
+ return {
+ globalUsers: { ...remainingUsers, [newKey]: updatedUser },
+ servers: updatedServers,
+ };
+ });
+ },
+
addMessage: (message) => {
set((state) => {
const channelKey = `${message.serverId}-${message.channelId}`;
- const currentMessages = state.messages[channelKey] || [];
+ const existing = state.messages[channelKey] || [];
+
+ // Check for duplicates using the optimized Map-based tracking
+ if (message.msgid && state.processedMessageIds.has(message.msgid)) {
+ return state;
+ }
- // Check for duplicate messages (same id, or same content/timestamp/user)
- const isDuplicate = currentMessages.some((existingMessage) => {
- return (
- existingMessage.id === message.id ||
- (existingMessage.content === message.content &&
+ // Fallback for messages without msgid (basic content duplication check)
+ if (!message.msgid) {
+ const isDuplicate = existing.some((existingMessage) => {
+ return (
+ existingMessage.content === message.content &&
new Date(existingMessage.timestamp).getTime() ===
new Date(message.timestamp).getTime() &&
- existingMessage.userId === message.userId)
- );
+ existingMessage.userId === message.userId
+ );
+ });
+ if (isDuplicate) return state;
+ }
+
+ const updated = [...existing, message].sort((a, b) => {
+ const tA = new Date(a.timestamp).getTime();
+ const tB = new Date(b.timestamp).getTime();
+ return tA - tB;
});
- if (isDuplicate) {
- return state; // Don't add duplicate message
+ // Enforce global history limit
+ const pruned = updated.slice(-MAX_MESSAGES_PER_CHANNEL);
+
+ const nextMessages = { ...state.messages, [channelKey]: pruned };
+ const nextProcessed = new Map(state.processedMessageIds);
+
+ // Track this msgid with current timestamp for TTL pruning
+ if (message.msgid) {
+ nextProcessed.set(message.msgid, Date.now());
+ }
+ if (message.multilineMessageIds) {
+ for (const id of message.multilineMessageIds) {
+ nextProcessed.set(id, Date.now());
+ }
+ }
+
+ // Initialize pruning timer (runs once an hour)
+ if (!state.processedMessageCleanupTimer) {
+ const timer = setInterval(
+ () => {
+ get().pruneProcessedMessageIds();
+ },
+ 60 * 60 * 1000,
+ ) as unknown as NodeJS.Timeout;
+ return {
+ messages: nextMessages,
+ processedMessageIds: nextProcessed,
+ processedMessageCleanupTimer: timer,
+ };
}
- // Add message and sort chronologically by timestamp
- const updatedMessages = [...currentMessages, message].sort((a, b) => {
- const timeA =
- a.timestamp instanceof Date
- ? a.timestamp.getTime()
- : new Date(a.timestamp).getTime();
- const timeB =
- b.timestamp instanceof Date
- ? b.timestamp.getTime()
- : new Date(b.timestamp).getTime();
- return timeA - timeB;
- });
+ return {
+ messages: nextMessages,
+ processedMessageIds: nextProcessed,
+ };
+ });
+ },
+
+ updateGlobalUser: (serverId, user) => {
+ set((state) => {
+ const key = `${serverId}-${user.username.toLowerCase()}`;
+ const existing = state.globalUsers[key];
+ const updated = {
+ ...(existing || {
+ id: uuidv4(),
+ username: user.username,
+ isOnline: true,
+ metadata: {},
+ }),
+ ...user,
+ };
- // Cap per-channel history to prevent unbounded memory growth in long sessions.
- const cappedMessages =
- updatedMessages.length > MAX_MESSAGES_PER_CHANNEL
- ? updatedMessages.slice(-MAX_MESSAGES_PER_CHANNEL)
- : updatedMessages;
+ // Propagate changes to all views for immediate UI reactivity
+ const updatedServers = state.servers.map((s) => {
+ if (s.id === serverId) {
+ return {
+ ...s,
+ channels: s.channels.map((ch) => ({
+ ...ch,
+ users: ch.users.map((u) =>
+ u.username.toLowerCase() === user.username.toLowerCase()
+ ? { ...u, ...user }
+ : u,
+ ),
+ })),
+ privateChats: s.privateChats?.map((pc) =>
+ pc.username.toLowerCase() === user.username.toLowerCase()
+ ? { ...pc, ...user }
+ : pc,
+ ),
+ };
+ }
+ return s;
+ });
return {
- messages: {
- ...state.messages,
- [channelKey]: cappedMessages,
- },
+ globalUsers: { ...state.globalUsers, [key]: updated },
+ servers: updatedServers,
};
});
},
+ pruneProcessedMessageIds: () => {
+ set((state) => {
+ const now = Date.now();
+ const TTL = 24 * 60 * 60 * 1000; // 24 hours
+ const nextMap = new Map();
+
+ for (const [id, ts] of state.processedMessageIds.entries()) {
+ if (now - ts < TTL) {
+ nextMap.set(id, ts);
+ }
+ }
+
+ return { processedMessageIds: nextMap };
+ });
+ },
+
addGlobalNotification: (notification) => {
set((state) => ({
globalNotifications: [
diff --git a/tailwind.config.js b/tailwind.config.js
index 8dbf3e76..9fb6f5d0 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -90,7 +90,7 @@ module.exports = {
},
},
fontFamily: {
- sans: ["Roboto Mono", "monospace"],
+ sans: ["Inter", "system-ui", "sans-serif"],
mono: ["Roboto Mono", "monospace"],
},
animation: {
diff --git a/tests/store/multilineDedup.test.ts b/tests/store/multilineDedup.test.ts
index fd231648..fde5a8b1 100644
--- a/tests/store/multilineDedup.test.ts
+++ b/tests/store/multilineDedup.test.ts
@@ -23,7 +23,7 @@ describe("Multiline message deduplication", () => {
// Reset messages and processedMessageIds
useStore.setState({
messages: {},
- processedMessageIds: new Set(),
+ processedMessageIds: new Map(),
});
});
@@ -87,12 +87,13 @@ describe("Multiline message deduplication", () => {
const idsToTrack =
messageIds.length > 0 ? messageIds : mtags?.msgid ? [mtags.msgid] : [];
- useStore.setState((state) => ({
- processedMessageIds: new Set([
- ...state.processedMessageIds,
- ...idsToTrack,
- ]),
- }));
+ useStore.setState((state) => {
+ const newMap = new Map(state.processedMessageIds);
+ for (const id of idsToTrack) {
+ newMap.set(id, Date.now());
+ }
+ return { processedMessageIds: newMap };
+ });
const state = useStore.getState();
expect(state.processedMessageIds.has(batchMsgId)).toBe(true);
@@ -102,12 +103,11 @@ describe("Multiline message deduplication", () => {
const batchMsgId = "dmsurc3xgc5v3nufdga8ag2xnw";
// First call: track the batch msgid
- useStore.setState((state) => ({
- processedMessageIds: new Set([
- ...state.processedMessageIds,
- batchMsgId,
- ]),
- }));
+ useStore.setState((state) => {
+ const newMap = new Map(state.processedMessageIds);
+ newMap.set(batchMsgId, Date.now());
+ return { processedMessageIds: newMap };
+ });
// Second call: check dedup
const currentState = useStore.getState();
@@ -166,12 +166,13 @@ describe("Multiline message deduplication", () => {
const messageIds = ["dm-msg-1", "dm-msg-2"];
const idsToTrack = messageIds.length > 0 ? messageIds : [batchMsgId];
- useStore.setState((state) => ({
- processedMessageIds: new Set([
- ...state.processedMessageIds,
- ...idsToTrack,
- ]),
- }));
+ useStore.setState((state) => {
+ const newMap = new Map(state.processedMessageIds);
+ for (const id of idsToTrack) {
+ newMap.set(id, Date.now());
+ }
+ return { processedMessageIds: newMap };
+ });
addMessage(
makeDmMessage({
@@ -201,9 +202,13 @@ describe("Multiline message deduplication", () => {
);
expect(shouldSkip1).toBe(false);
- useStore.setState((s) => ({
- processedMessageIds: new Set([...s.processedMessageIds, ...messageIds]),
- }));
+ useStore.setState((s) => {
+ const newMap = new Map(s.processedMessageIds);
+ for (const id of messageIds) {
+ newMap.set(id, Date.now());
+ }
+ return { processedMessageIds: newMap };
+ });
addMessage(
makeDmMessage({
id: "dm-stored-1",
@@ -289,12 +294,13 @@ describe("Multiline message deduplication", () => {
? [mtags.msgid]
: [];
if (idsToTrack1.length > 0) {
- useStore.setState((s) => ({
- processedMessageIds: new Set([
- ...s.processedMessageIds,
- ...idsToTrack1,
- ]),
- }));
+ useStore.setState((s) => {
+ const newMap = new Map(s.processedMessageIds);
+ for (const id of idsToTrack1) {
+ newMap.set(id, Date.now());
+ }
+ return { processedMessageIds: newMap };
+ });
}
addMessage(makeMessage({ id: "msg-1", msgid: batchMsgId }));