feat: stateless federated identity resolver + federation UX improvements

Add identity.ts with isSelf() and resolveDisplayIdentity() — pure
stateless functions that detect replicated-self using the immutable
(username, homeInstance) composite key. No store lookups, no data
mutation. Fixes wrong avatar gradient and missing edit/delete on
own messages in remote channels.

Also includes: optimistic message dedup fix for cross-instance
messages (content-only matching), federation toast notifications,
Username component with @domain display, invite parser, and
deploy script simplification.
This commit is contained in:
Jannis Braun
2026-03-04 03:37:49 +01:00
parent 64fd8edfc9
commit dca9c4dc83
19 changed files with 713 additions and 110 deletions
+8 -4
View File
@@ -312,9 +312,12 @@ export const useChatStore = create<ChatState>((set, get) => ({
const current = newMessages.get(channelId) ?? [];
// Avoid duplicates
if (current.find(m => m.id === message.id)) return state;
// Remove any optimistic temp message from same user with same content
// Remove any optimistic temp message with same content.
// Don't require userId match — for federated messages the home user ID
// differs from the replicated user ID, but content match is sufficient
// since temp messages are unique within the short optimistic window.
const filtered = current.filter(m => {
if (!m.id.startsWith('temp_') || m.userId !== message.userId) return true;
if (!m.id.startsWith('temp_')) return true;
return m.content !== message.content;
});
let updated = [...filtered, message];
@@ -333,9 +336,10 @@ export const useChatStore = create<ChatState>((set, get) => ({
const current = newMessages.get(channelId) ?? [];
// Avoid duplicates
if (current.find(m => m.id === message.id)) return state;
// Remove any optimistic temp message from same user with same content
// Remove any optimistic temp message with same content (no userId check —
// federated messages arrive with a different replicated user ID)
const filtered = current.filter(m => {
if (!m.id.startsWith('temp_') || m.userId !== message.userId) return true;
if (!m.id.startsWith('temp_')) return true;
return m.content !== message.content;
});
let updated = [...filtered, message];
+9
View File
@@ -91,6 +91,7 @@ interface InstanceState {
connectToRemote: (origin: string, password: string, displayName?: string) => Promise<void>;
loginToRemote: (origin: string, username: string, password: string) => Promise<void>;
removeInstance: (origin: string) => void;
setInstanceStatus: (origin: string, status: ConnectedInstance['status'], error?: string) => void;
syncInstanceList: () => Promise<void>;
autoConnectAll: () => Promise<void>;
reset: () => void;
@@ -276,6 +277,14 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
}
},
setInstanceStatus: (origin, status, error) => {
set((state) => ({
instances: state.instances.map(i =>
i.origin === origin ? { ...i, status, error } : i
),
}));
},
removeInstance: (origin: string) => {
// Tear down WebSocket connection
disconnectInstance(origin);
+30 -2
View File
@@ -8,6 +8,16 @@ import { resolveAssetUrl, normalizeUserAssets } from '../utils/assetUrls';
/** Server augmented with instance origin tracking (client-only, not in shared types). */
export type TaggedServer = Server & { _instanceOrigin: string };
// ─── Error types ─────────────────────────────────────────────────────────────
/** Thrown when joinByCode targets a remote origin the user is not connected to. */
export class NotConnectedError extends Error {
constructor(public origin: string) {
super(`Not connected to ${origin}`);
this.name = 'NotConnectedError';
}
}
// ─── Store interface ──────────────────────────────────────────────────────────
interface ServerState {
@@ -41,7 +51,7 @@ interface ServerState {
updateServer: (serverId: string, data: { name?: string; icon?: string }) => Promise<void>;
deleteServer: (serverId: string) => Promise<void>;
joinServer: (serverId: string, inviteCode: string) => Promise<void>;
joinByCode: (inviteCode: string) => Promise<Server>;
joinByCode: (inviteCode: string, origin?: string) => Promise<Server>;
generateInvite: (serverId: string) => Promise<string>;
createChannel: (serverId: string, name: string, type: 'text' | 'voice' | 'video', topic?: string) => Promise<Channel>;
deleteChannel: (channelId: string) => Promise<void>;
@@ -182,7 +192,25 @@ export const useServerStore = create<ServerState>((set, get) => ({
});
},
joinByCode: async (inviteCode: string) => {
joinByCode: async (inviteCode: string, origin?: string) => {
if (origin) {
// Remote instance — verify connectivity via dynamic import (avoids circular dep)
const { useInstanceStore } = await import('./instanceStore');
const connected = useInstanceStore.getState().instances.some(
(i) => i.origin === origin && i.status === 'connected',
);
if (!connected) throw new NotConnectedError(origin);
const remoteApi = getApiForOrigin(origin);
const server = await remoteApi.servers.joinByCode(inviteCode);
set((state) => {
if (state.servers.find(s => s.id === server.id)) return state;
return { servers: [...state.servers, { ...server, _instanceOrigin: origin } as TaggedServer] };
});
return server;
}
// Home instance
const server = await api.servers.joinByCode(inviteCode);
set((state) => {
if (state.servers.find(s => s.id === server.id)) return state;
+19
View File
@@ -15,6 +15,12 @@ type ModalType =
| 'addDmMember'
| null;
interface Toast {
id: string;
message: string;
type: 'info' | 'warning' | 'success';
}
interface UIState {
sidebarOpen: boolean;
memberListOpen: boolean;
@@ -27,6 +33,7 @@ interface UIState {
user: User | null;
position: { top: number; left: number } | null;
};
toasts: Toast[];
toggleSidebar: () => void;
toggleMemberList: () => void;
openModal: (modal: ModalType, data?: Record<string, unknown>) => void;
@@ -37,6 +44,8 @@ interface UIState {
closeImagePreview: () => void;
openUserProfile: (user: User, position: { top: number; left: number }) => void;
closeUserProfile: () => void;
addToast: (message: string, type?: 'info' | 'warning' | 'success', duration?: number) => void;
removeToast: (id: string) => void;
voiceChatOpen: boolean;
voiceFullscreen: boolean;
pipCollapsed: boolean;
@@ -60,6 +69,7 @@ export const useUIStore = create<UIState>()(
user: null,
position: null,
},
toasts: [],
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
toggleMemberList: () => set((state) => ({ memberListOpen: !state.memberListOpen })),
@@ -91,6 +101,15 @@ export const useUIStore = create<UIState>()(
userProfilePopout: { user: null, position: null }
}),
addToast: (message, type = 'info', duration = 5000) => {
const id = Date.now().toString(36) + Math.random().toString(36).slice(2);
set((state) => ({ toasts: [...state.toasts, { id, message, type }] }));
setTimeout(() => {
set((state) => ({ toasts: state.toasts.filter(t => t.id !== id) }));
}, duration);
},
removeToast: (id) => set((state) => ({ toasts: state.toasts.filter(t => t.id !== id) })),
voiceChatOpen: false,
voiceFullscreen: false,
pipCollapsed: false,