fix: federation avatar colors, cross-instance DMs, and instance persistence
- Fix avatar gradient colors in 7 UI sites by using homeUserId fallback - Merge remote DM channels in populateFromReady (no longer home-only) - Remove isHome guards from 8 DM WS event handlers for cross-instance DMs - Route DM typing events to correct instance origin - Fix autoConnectAll deleting disconnected instances from localStorage - Distinguish network errors from auth errors in instance reconnection - Start WebSocket retry for network-unreachable instances (auto-recovery) - Add reconnectInstance method and Reconnect button in UI
This commit is contained in:
@@ -237,7 +237,7 @@ function FriendItem({ friend, onRemove, onDm }: { friend: TaggedFriend, onRemove
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between px-3 h-[62px] rounded-[8px] hover:bg-interactive-hover group transition-colors border-t border-interactive-muted mx-2">
|
<div className="flex items-center justify-between px-3 h-[62px] rounded-[8px] hover:bg-interactive-hover group transition-colors border-t border-interactive-muted mx-2">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Avatar src={friend.avatar} name={friend.displayName ?? friend.username} size={32} status={friend.status} userId={friend.id} />
|
<Avatar src={friend.avatar} name={friend.displayName ?? friend.username} size={32} status={friend.status} userId={friend.homeUserId ?? friend.id} />
|
||||||
<div className="flex flex-col leading-tight">
|
<div className="flex flex-col leading-tight">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-txt-primary font-semibold text-[15px]">{friend.displayName ?? friend.username}</span>
|
<span className="text-txt-primary font-semibold text-[15px]">{friend.displayName ?? friend.username}</span>
|
||||||
@@ -289,7 +289,7 @@ function RequestItem({ request, type, onAccept, onDecline, onCancel }: {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between px-3 py-2.5 rounded-lg hover:bg-interactive-hover group transition-colors border-t border-interactive-muted mx-2">
|
<div className="flex items-center justify-between px-3 py-2.5 rounded-lg hover:bg-interactive-hover group transition-colors border-t border-interactive-muted mx-2">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Avatar src={user.avatar} name={user.displayName ?? user.username} size={32} status={user.status as any} userId={user.id} />
|
<Avatar src={user.avatar} name={user.displayName ?? user.username} size={32} status={user.status as any} userId={user.homeUserId ?? user.id} />
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-txt-primary font-bold text-sm">{user.displayName ?? user.username}</span>
|
<span className="text-txt-primary font-bold text-sm">{user.displayName ?? user.username}</span>
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export function MentionPopover({ query, selectedIndex, onSelect }: MentionPopove
|
|||||||
name={displayName}
|
name={displayName}
|
||||||
size={24}
|
size={24}
|
||||||
status={member.user.status}
|
status={member.user.status}
|
||||||
userId={member.user.id}
|
userId={member.user.homeUserId ?? member.user.id}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className="text-[14px] font-medium truncate"
|
className="text-[14px] font-medium truncate"
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
|||||||
if (typingTimeoutRef.current) return;
|
if (typingTimeoutRef.current) return;
|
||||||
const isDm = isDmChannel(channelId);
|
const isDm = isDmChannel(channelId);
|
||||||
if (isDm) {
|
if (isDm) {
|
||||||
wsSend({ type: 'dm_typing_start', dmChannelId: channelId });
|
wsSend({ type: 'dm_typing_start', dmChannelId: channelId }, getChannelOrigin(channelId));
|
||||||
} else {
|
} else {
|
||||||
wsSend({ type: 'typing_start', channelId }, getChannelOrigin(channelId));
|
wsSend({ type: 'typing_start', channelId }, getChannelOrigin(channelId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export function AddDmMemberModal() {
|
|||||||
disabled={isAdding}
|
disabled={isAdding}
|
||||||
className="w-full flex items-center gap-3 px-3 py-2 rounded-[4px] hover:bg-interactive-hover transition-colors text-left disabled:opacity-50"
|
className="w-full flex items-center gap-3 px-3 py-2 rounded-[4px] hover:bg-interactive-hover transition-colors text-left disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<Avatar src={user.avatar} name={user.displayName ?? user.username} size={36} status={user.status as any} userId={user.id} />
|
<Avatar src={user.avatar} name={user.displayName ?? user.username} size={36} status={user.status as any} userId={user.homeUserId ?? user.id} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-[14px] font-medium text-txt-primary truncate">
|
<div className="text-[14px] font-medium text-txt-primary truncate">
|
||||||
{user.displayName ?? user.username}
|
{user.displayName ?? user.username}
|
||||||
|
|||||||
@@ -265,6 +265,7 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) {
|
|||||||
export function ConnectedInstances() {
|
export function ConnectedInstances() {
|
||||||
const instances = useInstanceStore((s) => s.instances);
|
const instances = useInstanceStore((s) => s.instances);
|
||||||
const removeInstance = useInstanceStore((s) => s.removeInstance);
|
const removeInstance = useInstanceStore((s) => s.removeInstance);
|
||||||
|
const reconnectInstance = useInstanceStore((s) => s.reconnectInstance);
|
||||||
const [showAddForm, setShowAddForm] = useState(false);
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -312,14 +313,25 @@ export function ConnectedInstances() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0 ml-2">
|
||||||
|
{(inst.status === 'disconnected' || inst.status === 'error') && (
|
||||||
|
<button
|
||||||
|
onClick={() => reconnectInstance(inst.origin)}
|
||||||
|
className="px-2 py-1 text-xs text-accent-primary hover:bg-accent-primary/10 rounded transition-colors"
|
||||||
|
title="Reconnect"
|
||||||
|
>
|
||||||
|
Reconnect
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => removeInstance(inst.origin)}
|
onClick={() => removeInstance(inst.origin)}
|
||||||
className="px-2 py-1 text-xs text-txt-danger hover:bg-accent-rose/10 rounded transition-colors shrink-0 ml-2"
|
className="px-2 py-1 text-xs text-txt-danger hover:bg-accent-rose/10 rounded transition-colors"
|
||||||
title="Disconnect"
|
title="Disconnect"
|
||||||
>
|
>
|
||||||
Disconnect
|
Disconnect
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Add instance button / flow */}
|
{/* Add instance button / flow */}
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export function NewDmModal() {
|
|||||||
onClick={() => handleSelectUser(user)}
|
onClick={() => handleSelectUser(user)}
|
||||||
className="w-full flex items-center gap-3 px-3 py-2 rounded-[4px] hover:bg-interactive-hover transition-colors text-left"
|
className="w-full flex items-center gap-3 px-3 py-2 rounded-[4px] hover:bg-interactive-hover transition-colors text-left"
|
||||||
>
|
>
|
||||||
<Avatar src={user.avatar} name={user.displayName ?? user.username} size={36} status={user.status as any} userId={user.id} />
|
<Avatar src={user.avatar} name={user.displayName ?? user.username} size={36} status={user.status as any} userId={user.homeUserId ?? user.id} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-[14px] font-medium text-txt-primary truncate">
|
<div className="text-[14px] font-medium text-txt-primary truncate">
|
||||||
{user.displayName ?? user.username}
|
{user.displayName ?? user.username}
|
||||||
|
|||||||
@@ -414,7 +414,7 @@ function JoinRequestsSection({ serverId }: { serverId: string }) {
|
|||||||
src={user?.avatar}
|
src={user?.avatar}
|
||||||
name={displayName}
|
name={displayName}
|
||||||
size={32}
|
size={32}
|
||||||
userId={user?.id}
|
userId={user?.homeUserId ?? user?.id}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export function UserSettingsModal() {
|
|||||||
name={user.displayName ?? user.username}
|
name={user.displayName ?? user.username}
|
||||||
size={64}
|
size={64}
|
||||||
status={user.status}
|
status={user.status}
|
||||||
userId={user.id}
|
userId={user.homeUserId ?? user.id}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-bold text-lg">{user.displayName ?? user.username}</div>
|
<div className="font-bold text-lg">{user.displayName ?? user.username}</div>
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
// ─── DM events (home-only) ──────────────────────────────────────────────
|
// ─── DM events (home-only) ──────────────────────────────────────────────
|
||||||
|
|
||||||
case 'dm_message_created': {
|
case 'dm_message_created': {
|
||||||
if (!isHome) break;
|
if (!isHome) normalizeMessageAssets(event.message as any, origin);
|
||||||
addRealtimeMessage(event.message.dmChannelId, event.message as any);
|
addRealtimeMessage(event.message.dmChannelId, event.message as any);
|
||||||
const { dmChannels: currentDmChannels, setDmChannels: setDms, addDmChannel: addDmCh } = useServerStore.getState();
|
const { dmChannels: currentDmChannels, setDmChannels: setDms, addDmChannel: addDmCh } = useServerStore.getState();
|
||||||
const knownDm = currentDmChannels.find(dm => dm.id === event.message.dmChannelId);
|
const knownDm = currentDmChannels.find(dm => dm.id === event.message.dmChannelId);
|
||||||
@@ -292,17 +292,15 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'dm_message_updated':
|
case 'dm_message_updated':
|
||||||
if (!isHome) break;
|
if (!isHome) normalizeMessageAssets(event.message as any, origin);
|
||||||
updateMessage(event.message as any);
|
updateMessage(event.message as any);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'dm_message_deleted':
|
case 'dm_message_deleted':
|
||||||
if (!isHome) break;
|
|
||||||
removeMessage(event.messageId, event.dmChannelId);
|
removeMessage(event.messageId, event.dmChannelId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'dm_typing':
|
case 'dm_typing':
|
||||||
if (!isHome) break;
|
|
||||||
setTyping(event.dmChannelId, event.userId, event.username);
|
setTyping(event.dmChannelId, event.userId, event.username);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -387,24 +385,26 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
// ─── DM channel events (home-only) ──────────────────────────────────────
|
// ─── DM channel events (home-only) ──────────────────────────────────────
|
||||||
|
|
||||||
case 'dm_channel_created':
|
case 'dm_channel_created':
|
||||||
if (!isHome) break;
|
if (!isHome) {
|
||||||
|
for (const m of event.dmChannel.members) {
|
||||||
|
normalizeUserAssets(m, origin);
|
||||||
|
}
|
||||||
|
}
|
||||||
addDmChannel(event.dmChannel, origin);
|
addDmChannel(event.dmChannel, origin);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'dm_channel_closed':
|
case 'dm_channel_closed':
|
||||||
if (!isHome) break;
|
|
||||||
removeDmChannel(event.dmChannelId);
|
removeDmChannel(event.dmChannelId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'dm_member_added': {
|
case 'dm_member_added': {
|
||||||
if (!isHome) break;
|
if (!isHome) normalizeUserAssets(event.user, origin);
|
||||||
const { addDmMember } = useServerStore.getState();
|
const { addDmMember } = useServerStore.getState();
|
||||||
addDmMember(event.dmChannelId, event.user);
|
addDmMember(event.dmChannelId, event.user);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'dm_member_removed': {
|
case 'dm_member_removed': {
|
||||||
if (!isHome) break;
|
|
||||||
const { removeDmMember } = useServerStore.getState();
|
const { removeDmMember } = useServerStore.getState();
|
||||||
removeDmMember(event.dmChannelId, event.userId);
|
removeDmMember(event.dmChannelId, event.userId);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -50,6 +50,14 @@ function saveCachedTokens(instances: ConnectedInstance[]): void {
|
|||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(cache));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(cache));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Network error detection ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Detect network-level failures (unreachable, DNS, timeout) vs application errors (401, etc.) */
|
||||||
|
function isNetworkError(err: unknown): boolean {
|
||||||
|
return err instanceof TypeError ||
|
||||||
|
(err instanceof Error && /fetch|network|ECONNREFUSED|ETIMEDOUT/i.test(err.message));
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Error types ────────────────────────────────────────────────────────────
|
// ─── Error types ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Thrown when the remote instance already has an account for this user with a different password. */
|
/** Thrown when the remote instance already has an account for this user with a different password. */
|
||||||
@@ -92,6 +100,7 @@ interface InstanceState {
|
|||||||
loginToRemote: (origin: string, username: string, password: string) => Promise<void>;
|
loginToRemote: (origin: string, username: string, password: string) => Promise<void>;
|
||||||
removeInstance: (origin: string) => void;
|
removeInstance: (origin: string) => void;
|
||||||
setInstanceStatus: (origin: string, status: ConnectedInstance['status'], error?: string) => void;
|
setInstanceStatus: (origin: string, status: ConnectedInstance['status'], error?: string) => void;
|
||||||
|
reconnectInstance: (origin: string) => Promise<void>;
|
||||||
syncInstanceList: () => Promise<void>;
|
syncInstanceList: () => Promise<void>;
|
||||||
autoConnectAll: () => Promise<void>;
|
autoConnectAll: () => Promise<void>;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
@@ -280,6 +289,49 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
|
|||||||
get().syncInstanceList().catch(() => {});
|
get().syncInstanceList().catch(() => {});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
reconnectInstance: async (origin: string) => {
|
||||||
|
const inst = get().instances.find(i => i.origin === origin);
|
||||||
|
if (!inst || inst.status === 'connected' || inst.status === 'connecting') return;
|
||||||
|
|
||||||
|
// Set to connecting
|
||||||
|
set((state) => ({
|
||||||
|
instances: state.instances.map(i =>
|
||||||
|
i.origin === origin ? { ...i, status: 'connecting' as const, error: undefined } : i
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await inst.api.users.me();
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
instances: state.instances.map(i =>
|
||||||
|
i.origin === origin ? { ...i, status: 'connected' as const, user, error: undefined } : i
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
connectInstance(origin, inst.token);
|
||||||
|
} catch (err) {
|
||||||
|
if (isNetworkError(err)) {
|
||||||
|
set((state) => ({
|
||||||
|
instances: state.instances.map(i =>
|
||||||
|
i.origin === origin
|
||||||
|
? { ...i, status: 'disconnected' as const, error: 'Instance unreachable — retrying in background' }
|
||||||
|
: i
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
connectInstance(origin, inst.token);
|
||||||
|
} else {
|
||||||
|
set((state) => ({
|
||||||
|
instances: state.instances.map(i =>
|
||||||
|
i.origin === origin
|
||||||
|
? { ...i, status: 'error' as const, error: 'Token expired — re-authenticate to reconnect' }
|
||||||
|
: i
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
syncInstanceList: async () => {
|
syncInstanceList: async () => {
|
||||||
const { instances } = get();
|
const { instances } = get();
|
||||||
const currentUser = useAuthStore.getState().user;
|
const currentUser = useAuthStore.getState().user;
|
||||||
@@ -388,21 +440,36 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
|
|||||||
|
|
||||||
// Open WebSocket connection now that we've verified the token
|
// Open WebSocket connection now that we've verified the token
|
||||||
connectInstance(origin, cachedEntry.token);
|
connectInstance(origin, cachedEntry.token);
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Token expired or instance unreachable
|
if (isNetworkError(err)) {
|
||||||
|
// Instance unreachable (NAT hairpinning, DNS, server down) — token may still be valid
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
instances: state.instances.map(i =>
|
instances: state.instances.map(i =>
|
||||||
i.origin === origin
|
i.origin === origin
|
||||||
? { ...i, status: 'disconnected' as const, error: 'Token expired — re-authenticate to reconnect' }
|
? { ...i, status: 'disconnected' as const, error: 'Instance unreachable — retrying in background' }
|
||||||
|
: i
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
// Start WebSocket — its built-in exponential backoff retry will auto-recover
|
||||||
|
// when the network path becomes available (e.g. user switches networks)
|
||||||
|
connectInstance(origin, cachedEntry.token);
|
||||||
|
} else {
|
||||||
|
// Auth failure (401, invalid token, etc.)
|
||||||
|
set((state) => ({
|
||||||
|
instances: state.instances.map(i =>
|
||||||
|
i.origin === origin
|
||||||
|
? { ...i, status: 'error' as const, error: 'Token expired — re-authenticate to reconnect' }
|
||||||
: i
|
: i
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Save final state to localStorage
|
// Save final state to localStorage — persist ALL instances regardless of status
|
||||||
saveCachedTokens(get().instances.filter(i => i.status === 'connected'));
|
// so disconnected instances survive page reload and can auto-reconnect later
|
||||||
|
saveCachedTokens(get().instances);
|
||||||
|
|
||||||
// Log any failures for debugging
|
// Log any failures for debugging
|
||||||
const failures = results.filter(r => r.status === 'rejected');
|
const failures = results.filter(r => r.status === 'rejected');
|
||||||
|
|||||||
@@ -359,19 +359,31 @@ export const useServerStore = create<ServerState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DM channels are home-only
|
// DM channels: process from any origin, normalize remote assets
|
||||||
const dms = isHome ? (dmChannels || []) : get().dmChannels;
|
const incomingDms = dmChannels || [];
|
||||||
if (isHome) {
|
if (!isHome) {
|
||||||
for (const dm of dms) {
|
for (const dm of incomingDms) {
|
||||||
|
for (const member of dm.members) {
|
||||||
|
normalizeUserAssets(member, origin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const dm of incomingDms) {
|
||||||
channelOriginMap.set(dm.id, origin);
|
channelOriginMap.set(dm.id, origin);
|
||||||
if (dm.lastMessage?.id) {
|
if (dm.lastMessage?.id) {
|
||||||
channelLastMessageIds.set(dm.id, dm.lastMessage.id);
|
channelLastMessageIds.set(dm.id, dm.lastMessage.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
// Merge: remove DMs belonging to this origin from existing state, then append incoming
|
||||||
|
const existingDmsFromOtherOrigins = get().dmChannels.filter(dm => {
|
||||||
|
const dmOrigin = get().channelOriginMap.get(dm.id);
|
||||||
|
return dmOrigin !== origin;
|
||||||
|
});
|
||||||
|
const mergedDms = [...existingDmsFromOtherOrigins, ...incomingDms];
|
||||||
|
|
||||||
const update: Partial<ServerState> = {
|
const update: Partial<ServerState> = {
|
||||||
servers: mergedServers,
|
servers: mergedServers,
|
||||||
|
dmChannels: mergedDms,
|
||||||
channelToServerMap,
|
channelToServerMap,
|
||||||
channelLastMessageIds,
|
channelLastMessageIds,
|
||||||
serverPermissions,
|
serverPermissions,
|
||||||
@@ -379,10 +391,9 @@ export const useServerStore = create<ServerState>((set, get) => ({
|
|||||||
channelOriginMap,
|
channelOriginMap,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only set folders and dmChannels from home origin
|
// Only set folders from home origin
|
||||||
if (isHome) {
|
if (isHome) {
|
||||||
update.folders = folders || [];
|
update.folders = folders || [];
|
||||||
update.dmChannels = dms;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
set(update as any);
|
set(update as any);
|
||||||
|
|||||||
Reference in New Issue
Block a user