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:
@@ -19,14 +19,12 @@ cd "$(dirname "$0")"
|
|||||||
# ── Targets ─────────────────────────────────────────────────
|
# ── Targets ─────────────────────────────────────────────────
|
||||||
|
|
||||||
PI_USER="youruser"
|
PI_USER="youruser"
|
||||||
|
PI_LOCAL="192.168.1.10"
|
||||||
|
PI_REMOTE="nova.ddns.net"
|
||||||
|
PI_PATH="~/backspace"
|
||||||
|
|
||||||
declare -A TARGETS=(
|
BETA_HOST="orbit.ddns.net"
|
||||||
[pi_local]="192.168.1.10"
|
BETA_PATH="~/backspace"
|
||||||
[pi_remote]="nova.ddns.net"
|
|
||||||
[pi_path]="~/backspace"
|
|
||||||
[beta_host]="orbit.ddns.net"
|
|
||||||
[beta_path]="~/backspace"
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Rsync excludes ──────────────────────────────────────────
|
# ── Rsync excludes ──────────────────────────────────────────
|
||||||
|
|
||||||
@@ -76,10 +74,10 @@ deploy() {
|
|||||||
# ── Resolve Pi host (LAN or WAN) ───────────────────────────
|
# ── Resolve Pi host (LAN or WAN) ───────────────────────────
|
||||||
|
|
||||||
resolve_pi_host() {
|
resolve_pi_host() {
|
||||||
if ping -c1 -W2 "${TARGETS[pi_local]}" &>/dev/null; then
|
if ping -c1 -W2 "$PI_LOCAL" &>/dev/null; then
|
||||||
echo "${TARGETS[pi_local]}"
|
echo "$PI_LOCAL"
|
||||||
else
|
else
|
||||||
echo "${TARGETS[pi_remote]}"
|
echo "$PI_REMOTE"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,23 +88,23 @@ TARGET="${1:-all}"
|
|||||||
case "$TARGET" in
|
case "$TARGET" in
|
||||||
pi|--local|--remote|-l|-r)
|
pi|--local|--remote|-l|-r)
|
||||||
if [[ "$TARGET" == "--local" || "$TARGET" == "-l" ]]; then
|
if [[ "$TARGET" == "--local" || "$TARGET" == "-l" ]]; then
|
||||||
PI_HOST="${TARGETS[pi_local]}"
|
PI_HOST="$PI_LOCAL"
|
||||||
elif [[ "$TARGET" == "--remote" || "$TARGET" == "-r" ]]; then
|
elif [[ "$TARGET" == "--remote" || "$TARGET" == "-r" ]]; then
|
||||||
PI_HOST="${TARGETS[pi_remote]}"
|
PI_HOST="$PI_REMOTE"
|
||||||
else
|
else
|
||||||
PI_HOST=$(resolve_pi_host)
|
PI_HOST=$(resolve_pi_host)
|
||||||
fi
|
fi
|
||||||
deploy "Pi" "$PI_HOST" "${TARGETS[pi_path]}"
|
deploy "Pi" "$PI_HOST" "$PI_PATH"
|
||||||
;;
|
;;
|
||||||
|
|
||||||
vm|beta|orbit)
|
vm|beta|orbit)
|
||||||
deploy "Beta VM" "${TARGETS[beta_host]}" "${TARGETS[beta_path]}"
|
deploy "Beta VM" "$BETA_HOST" "$BETA_PATH"
|
||||||
;;
|
;;
|
||||||
|
|
||||||
all|both)
|
all|both)
|
||||||
PI_HOST=$(resolve_pi_host)
|
PI_HOST=$(resolve_pi_host)
|
||||||
deploy "Pi" "$PI_HOST" "${TARGETS[pi_path]}"
|
deploy "Pi" "$PI_HOST" "$PI_PATH"
|
||||||
deploy "Beta VM" "${TARGETS[beta_host]}" "${TARGETS[beta_path]}"
|
deploy "Beta VM" "$BETA_HOST" "$BETA_PATH"
|
||||||
;;
|
;;
|
||||||
|
|
||||||
*)
|
*)
|
||||||
|
|||||||
@@ -379,6 +379,24 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
// Register the user in connectionManager so they receive WS broadcasts for this server
|
// Register the user in connectionManager so they receive WS broadcasts for this server
|
||||||
connectionManager.addUserServer(request.userId, id);
|
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));
|
return reply.code(200).send(rowToServer(server));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -413,6 +431,24 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
// Register the user in connectionManager so they receive WS broadcasts for this server
|
// Register the user in connectionManager so they receive WS broadcasts for this server
|
||||||
connectionManager.addUserServer(request.userId, server.id);
|
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));
|
return reply.code(200).send(rowToServer(server));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import { useChatStore } from '../../stores/chatStore';
|
|||||||
import { useServerStore } from '../../stores/serverStore';
|
import { useServerStore } from '../../stores/serverStore';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
import { Embed } from './Embed';
|
import { Embed } from './Embed';
|
||||||
|
import { Username } from '../ui/Username';
|
||||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||||
|
import { isSelf, resolveDisplayIdentity } from '../../utils/identity';
|
||||||
|
|
||||||
interface MessageProps {
|
interface MessageProps {
|
||||||
message: MessageWithUser;
|
message: MessageWithUser;
|
||||||
@@ -47,7 +49,7 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
|
|||||||
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
||||||
|
|
||||||
const channelKey = message.channelId || (message as any).dmChannelId;
|
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 channelPermissions = useServerStore((s) => s.channelPermissions);
|
||||||
const myChPerms = channelPermissions.get(message.channelId);
|
const myChPerms = channelPermissions.get(message.channelId);
|
||||||
const canManageMessages = hasPermissionBit(myChPerms, PermissionBits.MANAGE_MESSAGES);
|
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 servers = useServerStore((s) => s.servers);
|
||||||
const currentServerId = useServerStore((s) => s.currentServerId);
|
const currentServerId = useServerStore((s) => s.currentServerId);
|
||||||
@@ -164,10 +168,10 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
|
|||||||
{isFirstInGroup || message.replyTo ? (
|
{isFirstInGroup || message.replyTo ? (
|
||||||
<div className="mt-0.5">
|
<div className="mt-0.5">
|
||||||
<Avatar
|
<Avatar
|
||||||
src={message.user.avatar}
|
src={displayIdentity.avatar}
|
||||||
name={displayName}
|
name={displayName}
|
||||||
size={40}
|
size={40}
|
||||||
user={message.user}
|
user={displayIdentity}
|
||||||
className="hover:drop-shadow-md transition-all active:translate-y-[1px]"
|
className="hover:drop-shadow-md transition-all active:translate-y-[1px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -180,29 +184,32 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{message.replyTo && (
|
{message.replyTo && (() => {
|
||||||
<div className="flex items-center gap-1 mb-1 ml-[-4px] opacity-80 hover:opacity-100 cursor-pointer group/reply">
|
const replyIdentity = resolveDisplayIdentity(message.replyTo.user, currentUser);
|
||||||
<Avatar src={message.replyTo.user.avatar} name={message.replyTo.user.username} size={16} user={message.replyTo.user} />
|
const replyDisplayName = replyIdentity.displayName ?? replyIdentity.username;
|
||||||
<span
|
return (
|
||||||
className="text-[14px] font-bold text-txt-primary hover:underline"
|
<div className="flex items-center gap-1 mb-1 ml-[-4px] opacity-80 hover:opacity-100 cursor-pointer group/reply">
|
||||||
style={message.replyTo ? replyRoleColor(message.replyTo) : undefined}
|
<Avatar src={replyIdentity.avatar} name={replyDisplayName} size={16} user={replyIdentity} />
|
||||||
>
|
<Username
|
||||||
{message.replyTo.user.displayName ?? message.replyTo.user.username}
|
username={replyDisplayName}
|
||||||
</span>
|
className="text-[14px] font-bold text-txt-primary hover:underline"
|
||||||
<span className="text-[14px] text-txt-message truncate max-w-[400px] hover:text-txt-primary">
|
style={replyRoleColor(message.replyTo)}
|
||||||
{message.replyTo.content}
|
/>
|
||||||
</span>
|
<span className="text-[14px] text-txt-message truncate max-w-[400px] hover:text-txt-primary">
|
||||||
</div>
|
{message.replyTo.content}
|
||||||
)}
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{(isFirstInGroup || message.replyTo) && (
|
{(isFirstInGroup || message.replyTo) && (
|
||||||
<div className="flex items-baseline gap-2 mb-0.5">
|
<div className="flex items-baseline gap-2 mb-0.5">
|
||||||
<span
|
<span onClick={handleUsernameClick}>
|
||||||
onClick={handleUsernameClick}
|
<Username
|
||||||
className="font-semibold cursor-pointer hover:underline text-[15px] leading-tight"
|
username={displayName}
|
||||||
style={roleColor}
|
className="font-semibold cursor-pointer hover:underline text-[15px] leading-tight"
|
||||||
>
|
style={roleColor}
|
||||||
{displayName}
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[11px] text-txt-tertiary leading-tight hover:cursor-default">
|
<span className="text-[11px] text-txt-tertiary leading-tight hover:cursor-default">
|
||||||
{formatTime(message.createdAt)}
|
{formatTime(message.createdAt)}
|
||||||
|
|||||||
@@ -20,8 +20,10 @@ import { PictureInPicture } from '../voice/PictureInPicture';
|
|||||||
import { SoundController } from '../voice/SoundController';
|
import { SoundController } from '../voice/SoundController';
|
||||||
import { GlobalAudioRenderer } from '../voice/GlobalAudioRenderer';
|
import { GlobalAudioRenderer } from '../voice/GlobalAudioRenderer';
|
||||||
import { UserProfilePopout } from '../ui/UserProfilePopout';
|
import { UserProfilePopout } from '../ui/UserProfilePopout';
|
||||||
|
import { ToastContainer } from '../ui/ToastContainer';
|
||||||
import { useAuth } from '../../hooks/useAuth';
|
import { useAuth } from '../../hooks/useAuth';
|
||||||
import { useWebSocket } from '../../hooks/useWebSocket';
|
import { useWebSocket } from '../../hooks/useWebSocket';
|
||||||
|
import { useFederationToasts } from '../../hooks/useFederationToasts';
|
||||||
import { useLiveKit } from '../../hooks/useLiveKit';
|
import { useLiveKit } from '../../hooks/useLiveKit';
|
||||||
import { useServerStore } from '../../stores/serverStore';
|
import { useServerStore } from '../../stores/serverStore';
|
||||||
import { useChatStore } from '../../stores/chatStore';
|
import { useChatStore } from '../../stores/chatStore';
|
||||||
@@ -106,6 +108,9 @@ export function AppLayout() {
|
|||||||
// Initialize WebSocket
|
// Initialize WebSocket
|
||||||
const { isConnected: isWsConnected } = useWebSocket();
|
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
|
// Track the last channel we attempted to connect to, to prevent effect loops
|
||||||
const lastAttemptedRef = React.useRef<string | null>(null);
|
const lastAttemptedRef = React.useRef<string | null>(null);
|
||||||
|
|
||||||
@@ -260,6 +265,9 @@ export function AppLayout() {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Federation toasts */}
|
||||||
|
<ToastContainer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { useNavigate } from 'react-router-dom';
|
||||||
import { useServerStore, getChannelOrigin } from '../../stores/serverStore';
|
import { useServerStore, getChannelOrigin } from '../../stores/serverStore';
|
||||||
import { useChatStore } from '../../stores/chatStore';
|
import { useChatStore } from '../../stores/chatStore';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
import { useInstanceStore } from '../../stores/instanceStore';
|
||||||
import { VoiceChannel } from '../voice/VoiceChannel';
|
import { VoiceChannel } from '../voice/VoiceChannel';
|
||||||
import { VoiceControls } from '../voice/VoiceControls';
|
import { VoiceControls } from '../voice/VoiceControls';
|
||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
@@ -71,6 +72,15 @@ export function ChannelSidebar() {
|
|||||||
const serverPermissions = useServerStore((s) => s.serverPermissions);
|
const serverPermissions = useServerStore((s) => s.serverPermissions);
|
||||||
const server = servers.find(s => s.id === currentServerId);
|
const server = servers.find(s => s.id === currentServerId);
|
||||||
const myServerPerms = currentServerId ? serverPermissions.get(currentServerId) : undefined;
|
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 canManageChannels = hasPermissionBit(myServerPerms, PermissionBits.MANAGE_CHANNELS);
|
||||||
|
|
||||||
const textChannels = channels.filter(c => c.type === 'text');
|
const textChannels = channels.filter(c => c.type === 'text');
|
||||||
@@ -280,7 +290,14 @@ export function ChannelSidebar() {
|
|||||||
onClick={() => openModal('serverSettings')}
|
onClick={() => openModal('serverSettings')}
|
||||||
className="flex-1 h-full px-4 flex items-center justify-between hover:bg-interactive-hover transition-colors min-w-0"
|
className="flex-1 h-full px-4 flex items-center justify-between hover:bg-interactive-hover transition-colors min-w-0"
|
||||||
>
|
>
|
||||||
<span className="font-bold text-[15px] tracking-[-0.02em] text-txt-primary truncate leading-tight">{server.name}</span>
|
<div className="min-w-0">
|
||||||
|
<span className="font-bold text-[15px] tracking-[-0.02em] text-txt-primary truncate leading-tight block">{server.name}</span>
|
||||||
|
{instanceLabel && (
|
||||||
|
<span className="text-[10px] text-txt-tertiary font-medium truncate block leading-tight">
|
||||||
|
{instanceLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="currentColor" className="text-txt-tertiary flex-shrink-0">
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="currentColor" className="text-txt-tertiary flex-shrink-0">
|
||||||
<path d="M5.293 7.293a1 1 0 011.414 0L9 9.586l2.293-2.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" />
|
<path d="M5.293 7.293a1 1 0 011.414 0L9 9.586l2.293-2.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { MemberWithUser } from '@backspace/shared';
|
|||||||
import { useServerStore } from '../../stores/serverStore';
|
import { useServerStore } from '../../stores/serverStore';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
import { Avatar } from '../ui/Avatar';
|
import { Avatar } from '../ui/Avatar';
|
||||||
|
import { Username } from '../ui/Username';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Derives the display group for a member based on their highest-positioned role
|
* Derives the display group for a member based on their highest-positioned role
|
||||||
@@ -111,12 +112,11 @@ export function MemberSidebar() {
|
|||||||
user={member.user}
|
user={member.user}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div
|
<Username
|
||||||
|
username={displayName}
|
||||||
className={`text-[13.5px] leading-[1.2] font-medium truncate ${isOffline ? 'text-txt-tertiary' : (!colorStyle ? 'text-txt-primary' : '')}`}
|
className={`text-[13.5px] leading-[1.2] font-medium truncate ${isOffline ? 'text-txt-tertiary' : (!colorStyle ? 'text-txt-primary' : '')}`}
|
||||||
style={colorStyle}
|
style={colorStyle}
|
||||||
>
|
/>
|
||||||
{displayName}
|
|
||||||
</div>
|
|
||||||
{!isOffline && member.user.customStatus && (
|
{!isOffline && member.user.customStatus && (
|
||||||
<div className="text-[11px] leading-[1.3] text-txt-tertiary truncate">{member.user.customStatus}</div>
|
<div className="text-[11px] leading-[1.3] text-txt-tertiary truncate">{member.user.customStatus}</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { useServerStore } from '../../stores/serverStore';
|
import { useServerStore } from '../../stores/serverStore';
|
||||||
import { useChatStore } from '../../stores/chatStore';
|
import { useChatStore } from '../../stores/chatStore';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
|
import { useInstanceStore } from '../../stores/instanceStore';
|
||||||
|
|
||||||
import { getServerGradient, HOME_GRADIENT } from '../../utils/gradients';
|
import { getServerGradient, HOME_GRADIENT } from '../../utils/gradients';
|
||||||
|
|
||||||
@@ -15,9 +16,10 @@ interface SidebarItemProps {
|
|||||||
type?: 'server' | 'dm' | 'action';
|
type?: 'server' | 'dm' | 'action';
|
||||||
actionType?: 'add' | 'join';
|
actionType?: 'add' | 'join';
|
||||||
hasUnread?: boolean;
|
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 [isHovered, setIsHovered] = useState(false);
|
||||||
const firstLetter = name.charAt(0).toUpperCase();
|
const firstLetter = name.charAt(0).toUpperCase();
|
||||||
|
|
||||||
@@ -79,7 +81,7 @@ function SidebarItem({ id, name, icon, active, onClick, type = 'server', actionT
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button onClick={onClick} className={getButtonClasses()} style={backgroundStyle} title={name}>
|
<button onClick={onClick} className={`${getButtonClasses()} ${dimmed ? 'opacity-40 saturate-50' : ''}`} style={backgroundStyle} title={name}>
|
||||||
{type === 'dm' ? (
|
{type === 'dm' ? (
|
||||||
<span className="text-[17px] font-bold">B</span>
|
<span className="text-[17px] font-bold">B</span>
|
||||||
) : type === 'action' ? (
|
) : type === 'action' ? (
|
||||||
@@ -115,9 +117,36 @@ export function ServerSidebar() {
|
|||||||
const showDms = useUIStore((s) => s.showDms);
|
const showDms = useUIStore((s) => s.showDms);
|
||||||
const setShowDms = useUIStore((s) => s.setShowDms);
|
const setShowDms = useUIStore((s) => s.setShowDms);
|
||||||
const openModal = useUIStore((s) => s.openModal);
|
const openModal = useUIStore((s) => s.openModal);
|
||||||
|
const addToast = useUIStore((s) => s.addToast);
|
||||||
const unreadChannels = useChatStore((s) => s.unreadChannels);
|
const unreadChannels = useChatStore((s) => s.unreadChannels);
|
||||||
|
const instances = useInstanceStore((s) => s.instances);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Group servers by origin
|
||||||
|
const groupedServers = useMemo(() => {
|
||||||
|
const home = servers.filter(s => !(s as any)._instanceOrigin);
|
||||||
|
const remoteMap = new Map<string, typeof servers>();
|
||||||
|
for (const s of servers) {
|
||||||
|
const origin = (s as any)._instanceOrigin;
|
||||||
|
if (!origin) continue;
|
||||||
|
const list = remoteMap.get(origin) || [];
|
||||||
|
list.push(s);
|
||||||
|
remoteMap.set(origin, list);
|
||||||
|
}
|
||||||
|
return { home, remoteGroups: Array.from(remoteMap.entries()) };
|
||||||
|
}, [servers]);
|
||||||
|
|
||||||
|
// Set of disconnected origins
|
||||||
|
const disconnectedOrigins = useMemo(() => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
for (const inst of instances) {
|
||||||
|
if (inst.status === 'disconnected' || inst.status === 'error') {
|
||||||
|
set.add(inst.origin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}, [instances]);
|
||||||
|
|
||||||
// Compute which servers have unread channels
|
// Compute which servers have unread channels
|
||||||
const unreadServerIds = useMemo(() => {
|
const unreadServerIds = useMemo(() => {
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
@@ -137,6 +166,13 @@ export function ServerSidebar() {
|
|||||||
}, [unreadChannels, dmChannels]);
|
}, [unreadChannels, dmChannels]);
|
||||||
|
|
||||||
const handleServerClick = (serverId: string) => {
|
const handleServerClick = (serverId: string) => {
|
||||||
|
const server = servers.find(s => s.id === serverId);
|
||||||
|
const origin = (server as any)?._instanceOrigin;
|
||||||
|
if (origin && disconnectedOrigins.has(origin)) {
|
||||||
|
const inst = instances.find(i => i.origin === origin);
|
||||||
|
addToast(`Reconnecting to ${inst?.label || 'remote instance'}...`, 'warning', 4000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setCurrentServer(serverId);
|
setCurrentServer(serverId);
|
||||||
setShowDms(false);
|
setShowDms(false);
|
||||||
navigate(`/channels/${serverId}`);
|
navigate(`/channels/${serverId}`);
|
||||||
@@ -161,7 +197,8 @@ export function ServerSidebar() {
|
|||||||
|
|
||||||
<div className="w-8 h-[2px] bg-interactive-muted rounded-full mb-1.5" />
|
<div className="w-8 h-[2px] bg-interactive-muted rounded-full mb-1.5" />
|
||||||
|
|
||||||
{servers.map((server) => (
|
{/* Home servers */}
|
||||||
|
{groupedServers.home.map((server) => (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
key={server.id}
|
key={server.id}
|
||||||
id={server.id}
|
id={server.id}
|
||||||
@@ -173,6 +210,33 @@ export function ServerSidebar() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Remote instance groups */}
|
||||||
|
{groupedServers.remoteGroups.map(([origin, groupServers]) => {
|
||||||
|
const inst = instances.find(i => i.origin === origin);
|
||||||
|
const label = inst?.label || (() => { try { return new URL(origin).host; } catch { return '?'; } })();
|
||||||
|
const isDimmed = disconnectedOrigins.has(origin);
|
||||||
|
return (
|
||||||
|
<React.Fragment key={origin}>
|
||||||
|
<div className="w-8 h-[2px] bg-interactive-muted/50 rounded-full my-1" />
|
||||||
|
<div className="text-[9px] text-txt-tertiary font-medium uppercase tracking-wider mb-1 truncate max-w-[52px] text-center" title={label}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
{groupServers.map((server) => (
|
||||||
|
<SidebarItem
|
||||||
|
key={server.id}
|
||||||
|
id={server.id}
|
||||||
|
name={server.name}
|
||||||
|
icon={server.icon}
|
||||||
|
active={currentServerId === server.id}
|
||||||
|
onClick={() => handleServerClick(server.id)}
|
||||||
|
hasUnread={unreadServerIds.has(server.id)}
|
||||||
|
dimmed={isDimmed}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
<div className="w-8 h-[2px] bg-interactive-muted rounded-full mb-1.5" />
|
<div className="w-8 h-[2px] bg-interactive-muted rounded-full mb-1.5" />
|
||||||
|
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
|
|||||||
@@ -1,91 +1,290 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Modal } from '../ui/Modal';
|
import { Modal } from '../ui/Modal';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
import { useServerStore } from '../../stores/serverStore';
|
import { useServerStore, NotConnectedError } from '../../stores/serverStore';
|
||||||
|
import { useInstanceStore, DifferentPasswordError } from '../../stores/instanceStore';
|
||||||
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { parseInviteInput } from '../../utils/inviteParser';
|
||||||
|
|
||||||
|
type JoinPhase = 'input' | 'connect' | 'fallback';
|
||||||
|
|
||||||
export function JoinServerModal() {
|
export function JoinServerModal() {
|
||||||
const { inviteCode: urlInviteCode } = useParams<{ inviteCode?: string }>();
|
const { inviteCode: urlInviteCode } = useParams<{ inviteCode?: string }>();
|
||||||
const [inviteCode, setInviteCode] = useState('');
|
const [inviteCode, setInviteCode] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [phase, setPhase] = useState<JoinPhase>('input');
|
||||||
|
const [parsedCode, setParsedCode] = useState('');
|
||||||
|
const [parsedOrigin, setParsedOrigin] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [fallbackUsername, setFallbackUsername] = useState('');
|
||||||
|
const [fallbackPassword, setFallbackPassword] = useState('');
|
||||||
|
|
||||||
const activeModal = useUIStore((s) => s.activeModal);
|
const activeModal = useUIStore((s) => s.activeModal);
|
||||||
const closeModal = useUIStore((s) => s.closeModal);
|
const closeModal = useUIStore((s) => s.closeModal);
|
||||||
const joinByCode = useServerStore((s) => s.joinByCode);
|
const joinByCode = useServerStore((s) => s.joinByCode);
|
||||||
|
const connectToRemote = useInstanceStore((s) => s.connectToRemote);
|
||||||
|
const loginToRemote = useInstanceStore((s) => s.loginToRemote);
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const isOpen = activeModal === 'joinServer';
|
const isOpen = activeModal === 'joinServer';
|
||||||
|
|
||||||
|
// Pre-fill from URL param and reset state on open/close
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && urlInviteCode) {
|
if (isOpen) {
|
||||||
setInviteCode(urlInviteCode);
|
if (urlInviteCode) setInviteCode(urlInviteCode);
|
||||||
|
} else {
|
||||||
|
// Reset all state when modal closes
|
||||||
|
setInviteCode('');
|
||||||
|
setError('');
|
||||||
|
setPhase('input');
|
||||||
|
setParsedCode('');
|
||||||
|
setParsedOrigin('');
|
||||||
|
setPassword('');
|
||||||
|
setFallbackUsername('');
|
||||||
|
setFallbackPassword('');
|
||||||
}
|
}
|
||||||
}, [isOpen, urlInviteCode]);
|
}, [isOpen, urlInviteCode]);
|
||||||
|
|
||||||
|
const joinAndNavigate = async (code: string, origin?: string) => {
|
||||||
|
const server = await joinByCode(code, origin || undefined);
|
||||||
|
closeModal();
|
||||||
|
navigate(`/channels/${server.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Phase 1: Submit invite code/URL
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
const code = inviteCode.trim();
|
let parsed: { code: string; origin?: string };
|
||||||
if (!code) {
|
try {
|
||||||
setError('Invite code is required');
|
parsed = parseInviteInput(inviteCode);
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setParsedCode(parsed.code);
|
||||||
|
setParsedOrigin(parsed.origin || '');
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const server = await joinByCode(code);
|
await joinAndNavigate(parsed.code, parsed.origin);
|
||||||
closeModal();
|
|
||||||
setInviteCode('');
|
|
||||||
navigate(`/channels/${server.id}`);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to join server');
|
if (err instanceof NotConnectedError) {
|
||||||
|
setPhase('connect');
|
||||||
|
setError('');
|
||||||
|
} else {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to join server');
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Phase 2: Connect to remote instance with password, then join
|
||||||
|
const handleConnect = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await connectToRemote(parsedOrigin, password, user?.displayName || undefined);
|
||||||
|
await joinAndNavigate(parsedCode, parsedOrigin);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof DifferentPasswordError) {
|
||||||
|
setPhase('fallback');
|
||||||
|
setFallbackUsername(err.remoteUsername);
|
||||||
|
setFallbackPassword('');
|
||||||
|
setError('');
|
||||||
|
} else {
|
||||||
|
setError((err as Error).message);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Phase 3: Fallback login with different credentials, then join
|
||||||
|
const handleFallbackLogin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await loginToRemote(parsedOrigin, fallbackUsername, fallbackPassword);
|
||||||
|
await joinAndNavigate(parsedCode, parsedOrigin);
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let hostDisplay = '';
|
||||||
|
try {
|
||||||
|
if (parsedOrigin) hostDisplay = new URL(parsedOrigin).host;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={closeModal} title="Join a Server">
|
<Modal isOpen={isOpen} onClose={closeModal} title="Join a Server">
|
||||||
<form onSubmit={handleSubmit}>
|
{/* Error display (shared across all phases) */}
|
||||||
<p className="text-txt-secondary text-sm mb-4">
|
{error && (
|
||||||
Enter an invite code to join an existing server.
|
<div className="mb-3 p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
|
||||||
</p>
|
{error}
|
||||||
{error && (
|
</div>
|
||||||
<div className="mb-3 p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
|
)}
|
||||||
{error}
|
|
||||||
|
{/* Phase: input — enter invite code or URL */}
|
||||||
|
{phase === 'input' && (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<p className="text-txt-secondary text-sm mb-4">
|
||||||
|
Enter an invite code or link to join a server.
|
||||||
|
</p>
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||||
|
Invite Code or Link
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={inviteCode}
|
||||||
|
onChange={(e) => setInviteCode(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary"
|
||||||
|
placeholder="e.g. abc123 or https://instance.com/join/abc123"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="flex justify-end gap-2">
|
||||||
<div className="mb-4">
|
<button
|
||||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
type="button"
|
||||||
Invite Code
|
onClick={closeModal}
|
||||||
</label>
|
className="px-4 py-2 text-sm text-txt-secondary hover:text-txt-primary transition-colors"
|
||||||
<input
|
>
|
||||||
type="text"
|
Cancel
|
||||||
value={inviteCode}
|
</button>
|
||||||
onChange={(e) => setInviteCode(e.target.value)}
|
<button
|
||||||
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary"
|
type="submit"
|
||||||
placeholder="e.g. abc123"
|
disabled={isLoading || !inviteCode.trim()}
|
||||||
autoFocus
|
className="px-4 py-2 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
|
||||||
/>
|
>
|
||||||
</div>
|
{isLoading ? 'Joining...' : 'Join Server'}
|
||||||
<div className="flex justify-end gap-2">
|
</button>
|
||||||
<button
|
</div>
|
||||||
type="button"
|
</form>
|
||||||
onClick={closeModal}
|
)}
|
||||||
className="px-4 py-2 text-sm text-txt-secondary hover:text-txt-primary transition-colors"
|
|
||||||
>
|
{/* Phase: connect — password prompt to connect to remote instance */}
|
||||||
Cancel
|
{phase === 'connect' && (
|
||||||
</button>
|
<form onSubmit={handleConnect}>
|
||||||
<button
|
<p className="text-txt-secondary text-sm mb-4">
|
||||||
type="submit"
|
Connect to <span className="text-txt-primary font-medium">{hostDisplay}</span> to join this server.
|
||||||
disabled={isLoading}
|
</p>
|
||||||
className="px-4 py-2 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
|
<div className="mb-4 space-y-2">
|
||||||
>
|
<div>
|
||||||
{isLoading ? 'Joining...' : 'Join Server'}
|
<label className="block text-xs text-txt-tertiary mb-1">
|
||||||
</button>
|
Enter your password to connect
|
||||||
</div>
|
</label>
|
||||||
</form>
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => 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
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-txt-tertiary mt-1">
|
||||||
|
Your password is verified locally, then used to create or access your account on the remote instance.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setPhase('input'); setPassword(''); setError(''); }}
|
||||||
|
className="text-xs text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeModal}
|
||||||
|
className="px-4 py-2 text-sm text-txt-secondary hover:text-txt-primary transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading || !password}
|
||||||
|
className="px-4 py-2 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Connecting...' : 'Connect & Join'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Phase: fallback — different password on remote instance */}
|
||||||
|
{phase === 'fallback' && (
|
||||||
|
<form onSubmit={handleFallbackLogin}>
|
||||||
|
<div className="mb-3 p-2 bg-accent-amber/10 border border-accent-amber/30 rounded text-xs text-accent-amber">
|
||||||
|
An account already exists on {hostDisplay} with a different password. Enter the credentials you used on that instance.
|
||||||
|
</div>
|
||||||
|
<div className="mb-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-txt-tertiary mb-1">Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={fallbackUsername}
|
||||||
|
onChange={(e) => 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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-txt-tertiary mb-1">Password for this instance</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={fallbackPassword}
|
||||||
|
onChange={(e) => 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
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setPhase('connect'); setFallbackPassword(''); setError(''); }}
|
||||||
|
className="text-xs text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeModal}
|
||||||
|
className="px-4 py-2 text-sm text-txt-secondary hover:text-txt-primary transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading || !fallbackUsername || !fallbackPassword}
|
||||||
|
className="px-4 py-2 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Logging in...' : 'Login & Join'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="fixed bottom-6 right-6 z-[300] flex flex-col gap-2 pointer-events-none">
|
||||||
|
{toasts.map((toast) => (
|
||||||
|
<div
|
||||||
|
key={toast.id}
|
||||||
|
className={`glass-pill border-l-2 ${borderColors[toast.type]} rounded-[10px] px-4 py-2.5 max-w-[320px] animate-slide-up pointer-events-auto cursor-pointer`}
|
||||||
|
onClick={() => removeToast(toast.id)}
|
||||||
|
>
|
||||||
|
<span className="text-sm text-txt-primary leading-snug">{toast.message}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import type { User } from '@backspace/shared';
|
import type { User } from '@backspace/shared';
|
||||||
import { Avatar } from '../ui/Avatar';
|
import { Avatar } from '../ui/Avatar';
|
||||||
|
import { Username } from '../ui/Username';
|
||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
import { useServerStore } from '../../stores/serverStore';
|
import { useServerStore } from '../../stores/serverStore';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
@@ -77,12 +78,15 @@ export function UserProfilePopout({ user, onClose, position }: UserProfilePopout
|
|||||||
|
|
||||||
{/* Name & info — flows naturally after avatar */}
|
{/* Name & info — flows naturally after avatar */}
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[16px] font-semibold text-txt-primary leading-tight">
|
<Username
|
||||||
{displayName}
|
username={displayName}
|
||||||
</div>
|
className="text-[16px] font-semibold text-txt-primary leading-tight"
|
||||||
<div className="text-[13px] text-txt-tertiary">
|
/>
|
||||||
@{user.username}
|
{user.username.includes('@') ? (
|
||||||
</div>
|
<Username username={user.username} className="text-[13px] text-txt-tertiary" />
|
||||||
|
) : (
|
||||||
|
<div className="text-[13px] text-txt-tertiary">@{user.username}</div>
|
||||||
|
)}
|
||||||
{user.customStatus && (
|
{user.customStatus && (
|
||||||
<div className="text-[13px] text-txt-secondary italic mt-1">
|
<div className="text-[13px] text-txt-secondary italic mt-1">
|
||||||
{user.customStatus}
|
{user.customStatus}
|
||||||
|
|||||||
@@ -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 <span className={className} style={style}>{username}</span>;
|
||||||
|
}
|
||||||
|
const name = username.slice(0, atIndex);
|
||||||
|
const domain = username.slice(atIndex + 1);
|
||||||
|
return (
|
||||||
|
<Tooltip content={username} position="top">
|
||||||
|
<span className={className} style={style}>
|
||||||
|
{name}
|
||||||
|
<span className="text-txt-tertiary text-[0.8em] ml-0.5 font-normal">@{domain}</span>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Map<string, ConnectedInstance['status']>>(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<string, ConnectedInstance['status']>();
|
||||||
|
for (const inst of instances) {
|
||||||
|
next.set(inst.origin, inst.status);
|
||||||
|
}
|
||||||
|
prevStatuses.current = next;
|
||||||
|
}, [instances, addToast]);
|
||||||
|
}
|
||||||
@@ -107,6 +107,13 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
|
|
||||||
populateFromReady(origin, event.servers, event.folders, event.dmChannels);
|
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) {
|
if (isHome && currentServerId) {
|
||||||
loadServerDetail(currentServerId);
|
loadServerDetail(currentServerId);
|
||||||
}
|
}
|
||||||
@@ -518,6 +525,12 @@ function connectToOrigin(origin: string, token: string): void {
|
|||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
conn.ws = null;
|
conn.ws = null;
|
||||||
stopHeartbeat(conn);
|
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)
|
// Only reconnect if the connection is still registered (not explicitly disconnected)
|
||||||
if (connections.has(origin) && conn.token) {
|
if (connections.has(origin) && conn.token) {
|
||||||
const delay = Math.min(1000 * Math.pow(2, conn.reconnectAttempts), 30000);
|
const delay = Math.min(1000 * Math.pow(2, conn.reconnectAttempts), 30000);
|
||||||
|
|||||||
@@ -312,9 +312,12 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
const current = newMessages.get(channelId) ?? [];
|
const current = newMessages.get(channelId) ?? [];
|
||||||
// Avoid duplicates
|
// Avoid duplicates
|
||||||
if (current.find(m => m.id === message.id)) return state;
|
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 => {
|
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;
|
return m.content !== message.content;
|
||||||
});
|
});
|
||||||
let updated = [...filtered, message];
|
let updated = [...filtered, message];
|
||||||
@@ -333,9 +336,10 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
const current = newMessages.get(channelId) ?? [];
|
const current = newMessages.get(channelId) ?? [];
|
||||||
// Avoid duplicates
|
// Avoid duplicates
|
||||||
if (current.find(m => m.id === message.id)) return state;
|
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 => {
|
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;
|
return m.content !== message.content;
|
||||||
});
|
});
|
||||||
let updated = [...filtered, message];
|
let updated = [...filtered, message];
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ interface InstanceState {
|
|||||||
connectToRemote: (origin: string, password: string, displayName?: string) => Promise<void>;
|
connectToRemote: (origin: string, password: string, displayName?: string) => Promise<void>;
|
||||||
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;
|
||||||
syncInstanceList: () => Promise<void>;
|
syncInstanceList: () => Promise<void>;
|
||||||
autoConnectAll: () => Promise<void>;
|
autoConnectAll: () => Promise<void>;
|
||||||
reset: () => 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) => {
|
removeInstance: (origin: string) => {
|
||||||
// Tear down WebSocket connection
|
// Tear down WebSocket connection
|
||||||
disconnectInstance(origin);
|
disconnectInstance(origin);
|
||||||
|
|||||||
@@ -8,6 +8,16 @@ import { resolveAssetUrl, normalizeUserAssets } from '../utils/assetUrls';
|
|||||||
/** Server augmented with instance origin tracking (client-only, not in shared types). */
|
/** Server augmented with instance origin tracking (client-only, not in shared types). */
|
||||||
export type TaggedServer = Server & { _instanceOrigin: string };
|
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 ──────────────────────────────────────────────────────────
|
// ─── Store interface ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface ServerState {
|
interface ServerState {
|
||||||
@@ -41,7 +51,7 @@ interface ServerState {
|
|||||||
updateServer: (serverId: string, data: { name?: string; icon?: string }) => Promise<void>;
|
updateServer: (serverId: string, data: { name?: string; icon?: string }) => Promise<void>;
|
||||||
deleteServer: (serverId: string) => Promise<void>;
|
deleteServer: (serverId: string) => Promise<void>;
|
||||||
joinServer: (serverId: string, inviteCode: 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>;
|
generateInvite: (serverId: string) => Promise<string>;
|
||||||
createChannel: (serverId: string, name: string, type: 'text' | 'voice' | 'video', topic?: string) => Promise<Channel>;
|
createChannel: (serverId: string, name: string, type: 'text' | 'voice' | 'video', topic?: string) => Promise<Channel>;
|
||||||
deleteChannel: (channelId: string) => Promise<void>;
|
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);
|
const server = await api.servers.joinByCode(inviteCode);
|
||||||
set((state) => {
|
set((state) => {
|
||||||
if (state.servers.find(s => s.id === server.id)) return state;
|
if (state.servers.find(s => s.id === server.id)) return state;
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ type ModalType =
|
|||||||
| 'addDmMember'
|
| 'addDmMember'
|
||||||
| null;
|
| null;
|
||||||
|
|
||||||
|
interface Toast {
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
type: 'info' | 'warning' | 'success';
|
||||||
|
}
|
||||||
|
|
||||||
interface UIState {
|
interface UIState {
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
memberListOpen: boolean;
|
memberListOpen: boolean;
|
||||||
@@ -27,6 +33,7 @@ interface UIState {
|
|||||||
user: User | null;
|
user: User | null;
|
||||||
position: { top: number; left: number } | null;
|
position: { top: number; left: number } | null;
|
||||||
};
|
};
|
||||||
|
toasts: Toast[];
|
||||||
toggleSidebar: () => void;
|
toggleSidebar: () => void;
|
||||||
toggleMemberList: () => void;
|
toggleMemberList: () => void;
|
||||||
openModal: (modal: ModalType, data?: Record<string, unknown>) => void;
|
openModal: (modal: ModalType, data?: Record<string, unknown>) => void;
|
||||||
@@ -37,6 +44,8 @@ interface UIState {
|
|||||||
closeImagePreview: () => void;
|
closeImagePreview: () => void;
|
||||||
openUserProfile: (user: User, position: { top: number; left: number }) => void;
|
openUserProfile: (user: User, position: { top: number; left: number }) => void;
|
||||||
closeUserProfile: () => void;
|
closeUserProfile: () => void;
|
||||||
|
addToast: (message: string, type?: 'info' | 'warning' | 'success', duration?: number) => void;
|
||||||
|
removeToast: (id: string) => void;
|
||||||
voiceChatOpen: boolean;
|
voiceChatOpen: boolean;
|
||||||
voiceFullscreen: boolean;
|
voiceFullscreen: boolean;
|
||||||
pipCollapsed: boolean;
|
pipCollapsed: boolean;
|
||||||
@@ -60,6 +69,7 @@ export const useUIStore = create<UIState>()(
|
|||||||
user: null,
|
user: null,
|
||||||
position: null,
|
position: null,
|
||||||
},
|
},
|
||||||
|
toasts: [],
|
||||||
|
|
||||||
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||||
toggleMemberList: () => set((state) => ({ memberListOpen: !state.memberListOpen })),
|
toggleMemberList: () => set((state) => ({ memberListOpen: !state.memberListOpen })),
|
||||||
@@ -91,6 +101,15 @@ export const useUIStore = create<UIState>()(
|
|||||||
userProfilePopout: { user: null, position: null }
|
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,
|
voiceChatOpen: false,
|
||||||
voiceFullscreen: false,
|
voiceFullscreen: false,
|
||||||
pipCollapsed: false,
|
pipCollapsed: false,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user