From dca9c4dc838242cfc4f73b15892e350741f564be Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 4 Mar 2026 03:37:49 +0100 Subject: [PATCH] feat: stateless federated identity resolver + federation UX improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- deploy.sh | 30 +- packages/server/src/routes/servers.ts | 36 +++ packages/web/src/components/chat/Message.tsx | 55 ++-- .../web/src/components/layout/AppLayout.tsx | 8 + .../src/components/layout/ChannelSidebar.tsx | 21 +- .../src/components/layout/MemberSidebar.tsx | 8 +- .../src/components/layout/ServerSidebar.tsx | 70 ++++- .../web/src/components/modals/JoinServer.tsx | 297 +++++++++++++++--- .../web/src/components/ui/ToastContainer.tsx | 29 ++ .../src/components/ui/UserProfilePopout.tsx | 16 +- packages/web/src/components/ui/Username.tsx | 25 ++ packages/web/src/hooks/useFederationToasts.ts | 47 +++ packages/web/src/hooks/useWebSocket.ts | 13 + packages/web/src/stores/chatStore.ts | 12 +- packages/web/src/stores/instanceStore.ts | 9 + packages/web/src/stores/serverStore.ts | 32 +- packages/web/src/stores/uiStore.ts | 19 ++ packages/web/src/utils/identity.ts | 32 ++ packages/web/src/utils/inviteParser.ts | 64 ++++ 19 files changed, 713 insertions(+), 110 deletions(-) create mode 100644 packages/web/src/components/ui/ToastContainer.tsx create mode 100644 packages/web/src/components/ui/Username.tsx create mode 100644 packages/web/src/hooks/useFederationToasts.ts create mode 100644 packages/web/src/utils/identity.ts create mode 100644 packages/web/src/utils/inviteParser.ts diff --git a/deploy.sh b/deploy.sh index afd45248..e4179d3b 100755 --- a/deploy.sh +++ b/deploy.sh @@ -19,14 +19,12 @@ cd "$(dirname "$0")" # ── Targets ───────────────────────────────────────────────── PI_USER="youruser" +PI_LOCAL="192.168.1.10" +PI_REMOTE="nova.ddns.net" +PI_PATH="~/backspace" -declare -A TARGETS=( - [pi_local]="192.168.1.10" - [pi_remote]="nova.ddns.net" - [pi_path]="~/backspace" - [beta_host]="orbit.ddns.net" - [beta_path]="~/backspace" -) +BETA_HOST="orbit.ddns.net" +BETA_PATH="~/backspace" # ── Rsync excludes ────────────────────────────────────────── @@ -76,10 +74,10 @@ deploy() { # ── Resolve Pi host (LAN or WAN) ─────────────────────────── resolve_pi_host() { - if ping -c1 -W2 "${TARGETS[pi_local]}" &>/dev/null; then - echo "${TARGETS[pi_local]}" + if ping -c1 -W2 "$PI_LOCAL" &>/dev/null; then + echo "$PI_LOCAL" else - echo "${TARGETS[pi_remote]}" + echo "$PI_REMOTE" fi } @@ -90,23 +88,23 @@ TARGET="${1:-all}" case "$TARGET" in pi|--local|--remote|-l|-r) if [[ "$TARGET" == "--local" || "$TARGET" == "-l" ]]; then - PI_HOST="${TARGETS[pi_local]}" + PI_HOST="$PI_LOCAL" elif [[ "$TARGET" == "--remote" || "$TARGET" == "-r" ]]; then - PI_HOST="${TARGETS[pi_remote]}" + PI_HOST="$PI_REMOTE" else PI_HOST=$(resolve_pi_host) fi - deploy "Pi" "$PI_HOST" "${TARGETS[pi_path]}" + deploy "Pi" "$PI_HOST" "$PI_PATH" ;; vm|beta|orbit) - deploy "Beta VM" "${TARGETS[beta_host]}" "${TARGETS[beta_path]}" + deploy "Beta VM" "$BETA_HOST" "$BETA_PATH" ;; all|both) PI_HOST=$(resolve_pi_host) - deploy "Pi" "$PI_HOST" "${TARGETS[pi_path]}" - deploy "Beta VM" "${TARGETS[beta_host]}" "${TARGETS[beta_path]}" + deploy "Pi" "$PI_HOST" "$PI_PATH" + deploy "Beta VM" "$BETA_HOST" "$BETA_PATH" ;; *) diff --git a/packages/server/src/routes/servers.ts b/packages/server/src/routes/servers.ts index 21e4641e..5bdf87cf 100644 --- a/packages/server/src/routes/servers.ts +++ b/packages/server/src/routes/servers.ts @@ -379,6 +379,24 @@ export async function serverRoutes(app: FastifyInstance): Promise { // Register the user in connectionManager so they receive WS broadcasts for this server connectionManager.addUserServer(request.userId, id); + // Broadcast member_joined to existing server members + const joiningUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get(); + if (joiningUser) { + const memberPayload: MemberWithUser = { + serverId: id, + userId: request.userId, + nickname: null, + joinedAt: now, + user: sanitizeUser(joiningUser), + roles: [], + }; + connectionManager.sendToServer(id, { + type: 'member_joined', + serverId: id, + member: memberPayload, + }); + } + return reply.code(200).send(rowToServer(server)); }); @@ -413,6 +431,24 @@ export async function serverRoutes(app: FastifyInstance): Promise { // Register the user in connectionManager so they receive WS broadcasts for this server connectionManager.addUserServer(request.userId, server.id); + // Broadcast member_joined to existing server members + const joiningUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get(); + if (joiningUser) { + const memberPayload: MemberWithUser = { + serverId: server.id, + userId: request.userId, + nickname: null, + joinedAt: now, + user: sanitizeUser(joiningUser), + roles: [], + }; + connectionManager.sendToServer(server.id, { + type: 'member_joined', + serverId: server.id, + member: memberPayload, + }); + } + return reply.code(200).send(rowToServer(server)); }); diff --git a/packages/web/src/components/chat/Message.tsx b/packages/web/src/components/chat/Message.tsx index 91fc8c39..d54fe6e4 100644 --- a/packages/web/src/components/chat/Message.tsx +++ b/packages/web/src/components/chat/Message.tsx @@ -8,7 +8,9 @@ import { useChatStore } from '../../stores/chatStore'; import { useServerStore } from '../../stores/serverStore'; import { useUIStore } from '../../stores/uiStore'; import { Embed } from './Embed'; +import { Username } from '../ui/Username'; import { hasPermissionBit, PermissionBits } from '../../utils/permissions'; +import { isSelf, resolveDisplayIdentity } from '../../utils/identity'; interface MessageProps { message: MessageWithUser; @@ -47,7 +49,7 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) { const openUserProfile = useUIStore((s) => s.openUserProfile); const channelKey = message.channelId || (message as any).dmChannelId; - const isAuthor = currentUser?.id === message.userId; + const isAuthor = isSelf(message.user, currentUser); const channelPermissions = useServerStore((s) => s.channelPermissions); const myChPerms = channelPermissions.get(message.channelId); const canManageMessages = hasPermissionBit(myChPerms, PermissionBits.MANAGE_MESSAGES); @@ -121,7 +123,9 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) { } }; - const displayName = message.user.displayName ?? message.user.username; + // Resolve display identity: replicated-self messages show home user's avatar/name + const displayIdentity = resolveDisplayIdentity(message.user, currentUser); + const displayName = displayIdentity.displayName ?? displayIdentity.username; const servers = useServerStore((s) => s.servers); const currentServerId = useServerStore((s) => s.currentServerId); @@ -164,10 +168,10 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) { {isFirstInGroup || message.replyTo ? (
@@ -180,29 +184,32 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) { {/* Content */}
- {message.replyTo && ( -
- - - {message.replyTo.user.displayName ?? message.replyTo.user.username} - - - {message.replyTo.content} - -
- )} + {message.replyTo && (() => { + const replyIdentity = resolveDisplayIdentity(message.replyTo.user, currentUser); + const replyDisplayName = replyIdentity.displayName ?? replyIdentity.username; + return ( +
+ + + + {message.replyTo.content} + +
+ ); + })()} {(isFirstInGroup || message.replyTo) && (
- - {displayName} + + {formatTime(message.createdAt)} diff --git a/packages/web/src/components/layout/AppLayout.tsx b/packages/web/src/components/layout/AppLayout.tsx index 0d7c4343..e4ddf22e 100644 --- a/packages/web/src/components/layout/AppLayout.tsx +++ b/packages/web/src/components/layout/AppLayout.tsx @@ -20,8 +20,10 @@ import { PictureInPicture } from '../voice/PictureInPicture'; import { SoundController } from '../voice/SoundController'; import { GlobalAudioRenderer } from '../voice/GlobalAudioRenderer'; import { UserProfilePopout } from '../ui/UserProfilePopout'; +import { ToastContainer } from '../ui/ToastContainer'; import { useAuth } from '../../hooks/useAuth'; import { useWebSocket } from '../../hooks/useWebSocket'; +import { useFederationToasts } from '../../hooks/useFederationToasts'; import { useLiveKit } from '../../hooks/useLiveKit'; import { useServerStore } from '../../stores/serverStore'; import { useChatStore } from '../../stores/chatStore'; @@ -106,6 +108,9 @@ export function AppLayout() { // Initialize WebSocket const { isConnected: isWsConnected } = useWebSocket(); + // Federation toast notifications for remote instance connection state changes + useFederationToasts(); + // Track the last channel we attempted to connect to, to prevent effect loops const lastAttemptedRef = React.useRef(null); @@ -260,6 +265,9 @@ export function AppLayout() { /> )} + + {/* Federation toasts */} +
); } diff --git a/packages/web/src/components/layout/ChannelSidebar.tsx b/packages/web/src/components/layout/ChannelSidebar.tsx index e2022f19..2442ba8a 100644 --- a/packages/web/src/components/layout/ChannelSidebar.tsx +++ b/packages/web/src/components/layout/ChannelSidebar.tsx @@ -1,9 +1,10 @@ -import React, { useState, useRef, useEffect, useCallback } from 'react'; +import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { useServerStore, getChannelOrigin } from '../../stores/serverStore'; import { useChatStore } from '../../stores/chatStore'; import { useUIStore } from '../../stores/uiStore'; import { useAuthStore } from '../../stores/authStore'; +import { useInstanceStore } from '../../stores/instanceStore'; import { VoiceChannel } from '../voice/VoiceChannel'; import { VoiceControls } from '../voice/VoiceControls'; import { useVoiceStore } from '../../stores/voiceStore'; @@ -71,6 +72,15 @@ export function ChannelSidebar() { const serverPermissions = useServerStore((s) => s.serverPermissions); const server = servers.find(s => s.id === currentServerId); const myServerPerms = currentServerId ? serverPermissions.get(currentServerId) : undefined; + + const federationInstances = useInstanceStore((s) => s.instances); + const instanceLabel = useMemo(() => { + const origin = (server as any)?._instanceOrigin; + if (!origin) return null; + const inst = federationInstances.find(i => i.origin === origin); + if (inst) return inst.label; + try { return new URL(origin).host; } catch { return origin; } + }, [server, federationInstances]); const canManageChannels = hasPermissionBit(myServerPerms, PermissionBits.MANAGE_CHANNELS); const textChannels = channels.filter(c => c.type === 'text'); @@ -280,7 +290,14 @@ export function ChannelSidebar() { onClick={() => openModal('serverSettings')} className="flex-1 h-full px-4 flex items-center justify-between hover:bg-interactive-hover transition-colors min-w-0" > - {server.name} +
+ {server.name} + {instanceLabel && ( + + {instanceLabel} + + )} +
diff --git a/packages/web/src/components/layout/MemberSidebar.tsx b/packages/web/src/components/layout/MemberSidebar.tsx index 25e03ebe..4d6a4e6a 100644 --- a/packages/web/src/components/layout/MemberSidebar.tsx +++ b/packages/web/src/components/layout/MemberSidebar.tsx @@ -3,6 +3,7 @@ import type { MemberWithUser } from '@backspace/shared'; import { useServerStore } from '../../stores/serverStore'; import { useUIStore } from '../../stores/uiStore'; import { Avatar } from '../ui/Avatar'; +import { Username } from '../ui/Username'; /** * Derives the display group for a member based on their highest-positioned role @@ -111,12 +112,11 @@ export function MemberSidebar() { user={member.user} />
-
- {displayName} -
+ /> {!isOffline && member.user.customStatus && (
{member.user.customStatus}
)} diff --git a/packages/web/src/components/layout/ServerSidebar.tsx b/packages/web/src/components/layout/ServerSidebar.tsx index b70b1aa4..a1fc5e27 100644 --- a/packages/web/src/components/layout/ServerSidebar.tsx +++ b/packages/web/src/components/layout/ServerSidebar.tsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { useServerStore } from '../../stores/serverStore'; import { useChatStore } from '../../stores/chatStore'; import { useUIStore } from '../../stores/uiStore'; +import { useInstanceStore } from '../../stores/instanceStore'; import { getServerGradient, HOME_GRADIENT } from '../../utils/gradients'; @@ -15,9 +16,10 @@ interface SidebarItemProps { type?: 'server' | 'dm' | 'action'; actionType?: 'add' | 'join'; hasUnread?: boolean; + dimmed?: boolean; } -function SidebarItem({ id, name, icon, active, onClick, type = 'server', actionType, hasUnread }: SidebarItemProps) { +function SidebarItem({ id, name, icon, active, onClick, type = 'server', actionType, hasUnread, dimmed }: SidebarItemProps) { const [isHovered, setIsHovered] = useState(false); const firstLetter = name.charAt(0).toUpperCase(); @@ -79,7 +81,7 @@ function SidebarItem({ id, name, icon, active, onClick, type = 'server', actionT
)} - - -
- +
+ + +
+ + )} + + {/* Phase: connect — password prompt to connect to remote instance */} + {phase === 'connect' && ( +
+

+ Connect to {hostDisplay} to join this server. +

+
+
+ + setPassword(e.target.value)} + placeholder="Your account password" + className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary" + disabled={isLoading} + autoFocus + /> +
+ Your password is verified locally, then used to create or access your account on the remote instance. +
+
+
+
+ +
+ + +
+
+
+ )} + + {/* Phase: fallback — different password on remote instance */} + {phase === 'fallback' && ( +
+
+ An account already exists on {hostDisplay} with a different password. Enter the credentials you used on that instance. +
+
+
+ + setFallbackUsername(e.target.value)} + placeholder="Your username on this instance" + className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary" + disabled={isLoading} + /> +
+
+ + setFallbackPassword(e.target.value)} + placeholder="Password on the remote instance" + className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary" + disabled={isLoading} + autoFocus + /> +
+
+
+ +
+ + +
+
+
+ )} ); } diff --git a/packages/web/src/components/ui/ToastContainer.tsx b/packages/web/src/components/ui/ToastContainer.tsx new file mode 100644 index 00000000..7b6e04dd --- /dev/null +++ b/packages/web/src/components/ui/ToastContainer.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { useUIStore } from '../../stores/uiStore'; + +const borderColors = { + info: 'border-l-accent-sky', + warning: 'border-l-accent-amber', + success: 'border-l-accent-mint', +} as const; + +export function ToastContainer() { + const toasts = useUIStore((s) => s.toasts); + const removeToast = useUIStore((s) => s.removeToast); + + if (toasts.length === 0) return null; + + return ( +
+ {toasts.map((toast) => ( +
removeToast(toast.id)} + > + {toast.message} +
+ ))} +
+ ); +} diff --git a/packages/web/src/components/ui/UserProfilePopout.tsx b/packages/web/src/components/ui/UserProfilePopout.tsx index b1cc14b9..7c84c4de 100644 --- a/packages/web/src/components/ui/UserProfilePopout.tsx +++ b/packages/web/src/components/ui/UserProfilePopout.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { useNavigate } from 'react-router-dom'; import type { User } from '@backspace/shared'; import { Avatar } from '../ui/Avatar'; +import { Username } from '../ui/Username'; import { api } from '../../api/client'; import { useServerStore } from '../../stores/serverStore'; import { useUIStore } from '../../stores/uiStore'; @@ -77,12 +78,15 @@ export function UserProfilePopout({ user, onClose, position }: UserProfilePopout {/* Name & info — flows naturally after avatar */}
-
- {displayName} -
-
- @{user.username} -
+ + {user.username.includes('@') ? ( + + ) : ( +
@{user.username}
+ )} {user.customStatus && (
{user.customStatus} diff --git a/packages/web/src/components/ui/Username.tsx b/packages/web/src/components/ui/Username.tsx new file mode 100644 index 00000000..74e725ca --- /dev/null +++ b/packages/web/src/components/ui/Username.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Tooltip } from './Tooltip'; + +interface UsernameProps { + username: string; + className?: string; + style?: React.CSSProperties; +} + +export function Username({ username, className, style }: UsernameProps) { + const atIndex = username.indexOf('@'); + if (atIndex === -1) { + return {username}; + } + const name = username.slice(0, atIndex); + const domain = username.slice(atIndex + 1); + return ( + + + {name} + @{domain} + + + ); +} diff --git a/packages/web/src/hooks/useFederationToasts.ts b/packages/web/src/hooks/useFederationToasts.ts new file mode 100644 index 00000000..d007ca6d --- /dev/null +++ b/packages/web/src/hooks/useFederationToasts.ts @@ -0,0 +1,47 @@ +import { useEffect, useRef } from 'react'; +import { useInstanceStore, type ConnectedInstance } from '../stores/instanceStore'; +import { useUIStore } from '../stores/uiStore'; + +/** + * Watches instanceStore for status changes on remote instances and fires + * toast notifications when connections are lost or restored. + */ +export function useFederationToasts() { + const instances = useInstanceStore((s) => s.instances); + const addToast = useUIStore((s) => s.addToast); + const prevStatuses = useRef>(new Map()); + + useEffect(() => { + const prev = prevStatuses.current; + + for (const inst of instances) { + const prevStatus = prev.get(inst.origin); + if (prevStatus === undefined) { + // First time seeing this instance — record but don't toast + continue; + } + if (prevStatus === inst.status) continue; + + const label = inst.label || (() => { try { return new URL(inst.origin).host; } catch { return inst.origin; } })(); + + if ( + prevStatus === 'connected' && + (inst.status === 'disconnected' || inst.status === 'error') + ) { + addToast(`Lost connection to ${label} — reconnecting...`, 'warning'); + } else if ( + (prevStatus === 'disconnected' || prevStatus === 'error' || prevStatus === 'connecting') && + inst.status === 'connected' + ) { + addToast(`Reconnected to ${label}`, 'success'); + } + } + + // Update prev statuses + const next = new Map(); + for (const inst of instances) { + next.set(inst.origin, inst.status); + } + prevStatuses.current = next; + }, [instances, addToast]); +} diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index aec34c82..d1098776 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -107,6 +107,13 @@ function handleEvent(origin: string, event: ServerEvent): void { populateFromReady(origin, event.servers, event.folders, event.dmChannels); + // Mark remote instance as connected in instanceStore + if (!isHome) { + import('../stores/instanceStore').then(({ useInstanceStore }) => { + useInstanceStore.getState().setInstanceStatus(origin, 'connected'); + }); + } + if (isHome && currentServerId) { loadServerDetail(currentServerId); } @@ -518,6 +525,12 @@ function connectToOrigin(origin: string, token: string): void { ws.onclose = () => { conn.ws = null; stopHeartbeat(conn); + // Mark remote instance as disconnected in instanceStore + if (origin !== HOME_ORIGIN) { + import('../stores/instanceStore').then(({ useInstanceStore }) => { + useInstanceStore.getState().setInstanceStatus(origin, 'disconnected', 'Connection lost — reconnecting'); + }); + } // Only reconnect if the connection is still registered (not explicitly disconnected) if (connections.has(origin) && conn.token) { const delay = Math.min(1000 * Math.pow(2, conn.reconnectAttempts), 30000); diff --git a/packages/web/src/stores/chatStore.ts b/packages/web/src/stores/chatStore.ts index 9895c529..6aba34d4 100644 --- a/packages/web/src/stores/chatStore.ts +++ b/packages/web/src/stores/chatStore.ts @@ -312,9 +312,12 @@ export const useChatStore = create((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((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]; diff --git a/packages/web/src/stores/instanceStore.ts b/packages/web/src/stores/instanceStore.ts index 878b1f20..ce6151c4 100644 --- a/packages/web/src/stores/instanceStore.ts +++ b/packages/web/src/stores/instanceStore.ts @@ -91,6 +91,7 @@ interface InstanceState { connectToRemote: (origin: string, password: string, displayName?: string) => Promise; loginToRemote: (origin: string, username: string, password: string) => Promise; removeInstance: (origin: string) => void; + setInstanceStatus: (origin: string, status: ConnectedInstance['status'], error?: string) => void; syncInstanceList: () => Promise; autoConnectAll: () => Promise; reset: () => void; @@ -276,6 +277,14 @@ export const useInstanceStore = create((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); diff --git a/packages/web/src/stores/serverStore.ts b/packages/web/src/stores/serverStore.ts index 4fb49afb..7206c5dd 100644 --- a/packages/web/src/stores/serverStore.ts +++ b/packages/web/src/stores/serverStore.ts @@ -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; deleteServer: (serverId: string) => Promise; joinServer: (serverId: string, inviteCode: string) => Promise; - joinByCode: (inviteCode: string) => Promise; + joinByCode: (inviteCode: string, origin?: string) => Promise; generateInvite: (serverId: string) => Promise; createChannel: (serverId: string, name: string, type: 'text' | 'voice' | 'video', topic?: string) => Promise; deleteChannel: (channelId: string) => Promise; @@ -182,7 +192,25 @@ export const useServerStore = create((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; diff --git a/packages/web/src/stores/uiStore.ts b/packages/web/src/stores/uiStore.ts index 81b93d8c..0f4b981a 100644 --- a/packages/web/src/stores/uiStore.ts +++ b/packages/web/src/stores/uiStore.ts @@ -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) => 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()( 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()( 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, diff --git a/packages/web/src/utils/identity.ts b/packages/web/src/utils/identity.ts new file mode 100644 index 00000000..dda24744 --- /dev/null +++ b/packages/web/src/utils/identity.ts @@ -0,0 +1,32 @@ +import type { User } from '@backspace/shared'; + +/** + * Stateless check: is `user` a replicated alias of `homeUser`? + * Uses the immutable (username, homeInstance) composite key — + * no store lookups, no snowflake ID mapping. + */ +export function isSelf( + user: { id: string; username: string; homeInstance?: string | null }, + homeUser: { id: string; username: string } | null, +): boolean { + if (!homeUser) return false; + // Same instance, same ID — trivial case + if (user.id === homeUser.id) return true; + // Replicated user: homeInstance matches our origin + if (!user.homeInstance) return false; + if (user.homeInstance !== window.location.host) return false; + // Username: "youruser" or "youruser@nova.ddns.net" → base must match + const baseUsername = user.username.split('@')[0]; + return baseUsername === homeUser.username; +} + +/** + * If `user` is a replicated alias of `homeUser`, return `homeUser` + * for display purposes (avatar gradient, display name). Otherwise + * return the original user unchanged. Data is never mutated. + */ +export function resolveDisplayIdentity(user: User, homeUser: User | null): User { + if (!homeUser) return user; + if (isSelf(user, homeUser)) return homeUser; + return user; +} diff --git a/packages/web/src/utils/inviteParser.ts b/packages/web/src/utils/inviteParser.ts new file mode 100644 index 00000000..0e31a095 --- /dev/null +++ b/packages/web/src/utils/inviteParser.ts @@ -0,0 +1,64 @@ +/** + * Parse invite input into a code and optional remote origin. + * + * Supported formats: + * - Bare code: "a3f1b2c4" + * - Full URL: "https://remote.com/join/a3f1b2c4" + * - Qualified code: "a3f1b2c4@remote.com" + */ +export function parseInviteInput(input: string): { code: string; origin?: string } { + const trimmed = input.trim(); + if (!trimmed) throw new Error('Invite code is required'); + + // Full URL: starts with http:// or https:// + if (/^https?:\/\//i.test(trimmed)) { + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + throw new Error('Invalid invite link'); + } + + // Extract code from /join/{code} path + const match = parsed.pathname.match(/^\/join\/([^/]+)$/); + if (!match) { + throw new Error('Invalid invite link — expected format: https://instance/join/CODE'); + } + + const code = match[1]!; + + // If the URL points at our own instance, treat as a bare code + if (parsed.origin === window.location.origin) { + return { code }; + } + + return { code, origin: parsed.origin }; + } + + // Qualified code: CODE@domain (contains @ but no spaces, no protocol) + if (trimmed.includes('@') && !trimmed.includes(' ')) { + const atIndex = trimmed.indexOf('@'); + const code = trimmed.slice(0, atIndex); + const domain = trimmed.slice(atIndex + 1); + + if (!code || !domain) { + throw new Error('Invalid invite format — expected: CODE@domain'); + } + + const origin = `https://${domain}`; + + // If it resolves to our own instance, treat as bare code + try { + if (new URL(origin).origin === window.location.origin) { + return { code }; + } + } catch { + throw new Error('Invalid domain in invite'); + } + + return { code, origin }; + } + + // Bare code + return { code: trimmed }; +}