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:
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user