fix: persist homeUserId for federated users to ensure consistent avatar colors
Store the original home snowflake ID (homeUserId) during federation replication so that avatar gradient colors resolve identically across instances. Previously, replicated users got new snowflake IDs on each instance, causing different gradient colors. Now Avatar, UserProfilePopout, VoiceUser, StreamTile, and VoiceChannel all resolve through homeUserId when available. Includes backfill logic for existing federated users missing the field.
This commit is contained in:
@@ -60,7 +60,8 @@ export function runMigrations(db: Database.Database): void {
|
|||||||
name: 'users',
|
name: 'users',
|
||||||
columns: [
|
columns: [
|
||||||
{ name: 'home_instance', type: 'TEXT' },
|
{ name: 'home_instance', type: 'TEXT' },
|
||||||
{ name: 'replicated_instances', type: "TEXT DEFAULT '[]'" }
|
{ name: 'replicated_instances', type: "TEXT DEFAULT '[]'" },
|
||||||
|
{ name: 'home_user_id', type: 'TEXT' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export const users = sqliteTable('users', {
|
|||||||
customStatus: text('custom_status'),
|
customStatus: text('custom_status'),
|
||||||
isAdmin: integer('is_admin').default(0),
|
isAdmin: integer('is_admin').default(0),
|
||||||
homeInstance: text('home_instance'),
|
homeInstance: text('home_instance'),
|
||||||
|
homeUserId: text('home_user_id'),
|
||||||
replicatedInstances: text('replicated_instances').default('[]'),
|
replicatedInstances: text('replicated_instances').default('[]'),
|
||||||
createdAt: integer('created_at').notNull(),
|
createdAt: integer('created_at').notNull(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
const { username, password, displayName, homeInstance } = request.body;
|
const { username, password, displayName, homeInstance, homeUserId } = request.body;
|
||||||
|
|
||||||
if (!username || typeof username !== 'string') {
|
if (!username || typeof username !== 'string') {
|
||||||
return reply.code(400).send({ error: 'Username is required', statusCode: 400 });
|
return reply.code(400).send({ error: 'Username is required', statusCode: 400 });
|
||||||
@@ -102,6 +102,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
status: 'online',
|
status: 'online',
|
||||||
isAdmin: isFirstUser ? 1 : 0,
|
isAdmin: isFirstUser ? 1 : 0,
|
||||||
homeInstance: homeInstance || null,
|
homeInstance: homeInstance || null,
|
||||||
|
homeUserId: (homeInstance && homeUserId && typeof homeUserId === 'string') ? homeUserId : null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
}).run();
|
}).run();
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.patch<{ Body: UpdateUserRequest }>('/api/users/@me', { preHandler: authenticate }, async (request, reply) => {
|
app.patch<{ Body: UpdateUserRequest }>('/api/users/@me', { preHandler: authenticate }, async (request, reply) => {
|
||||||
const { displayName, avatar, customStatus, status, replicatedInstances } = request.body;
|
const { displayName, avatar, customStatus, status, replicatedInstances, homeUserId } = request.body;
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
const updateData: Record<string, string | null | undefined> = {};
|
const updateData: Record<string, string | null | undefined> = {};
|
||||||
@@ -97,6 +97,14 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
updateData.replicatedInstances = JSON.stringify(replicatedInstances);
|
updateData.replicatedInstances = JSON.stringify(replicatedInstances);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (homeUserId !== undefined) {
|
||||||
|
// Only allow setting homeUserId for replicated users (has homeInstance)
|
||||||
|
const currentUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
||||||
|
if (currentUser?.homeInstance && typeof homeUserId === 'string' && homeUserId.length > 0) {
|
||||||
|
updateData.homeUserId = homeUserId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.keys(updateData).length === 0) {
|
if (Object.keys(updateData).length === 0) {
|
||||||
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
|
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
|||||||
isAdmin: row.isAdmin === 1,
|
isAdmin: row.isAdmin === 1,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
homeInstance: row.homeInstance ?? null,
|
homeInstance: row.homeInstance ?? null,
|
||||||
|
homeUserId: row.homeUserId ?? null,
|
||||||
replicatedInstances,
|
replicatedInstances,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface User {
|
|||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
homeInstance: string | null;
|
homeInstance: string | null;
|
||||||
|
homeUserId: string | null;
|
||||||
replicatedInstances: ReplicatedInstance[];
|
replicatedInstances: ReplicatedInstance[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,6 +254,7 @@ export interface RegisterRequest {
|
|||||||
password: string;
|
password: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
homeInstance?: string;
|
homeInstance?: string;
|
||||||
|
homeUserId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginRequest {
|
export interface LoginRequest {
|
||||||
@@ -293,6 +295,7 @@ export interface UpdateUserRequest {
|
|||||||
customStatus?: string;
|
customStatus?: string;
|
||||||
status?: UserStatus;
|
status?: UserStatus;
|
||||||
replicatedInstances?: ReplicatedInstance[];
|
replicatedInstances?: ReplicatedInstance[];
|
||||||
|
homeUserId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateMemberRequest {
|
export interface UpdateMemberRequest {
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ const makeRequest = (overrides: Partial<FriendRequest> = {}): FriendRequest => (
|
|||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
homeInstance: null,
|
homeInstance: null,
|
||||||
|
homeUserId: null,
|
||||||
replicatedInstances: [],
|
replicatedInstances: [],
|
||||||
},
|
},
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -218,6 +219,7 @@ describe('FriendsPage', () => {
|
|||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
homeInstance: null,
|
homeInstance: null,
|
||||||
|
homeUserId: null,
|
||||||
replicatedInstances: [],
|
replicatedInstances: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -265,6 +267,7 @@ describe('FriendsPage', () => {
|
|||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
homeInstance: null,
|
homeInstance: null,
|
||||||
|
homeUserId: null,
|
||||||
replicatedInstances: [],
|
replicatedInstances: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -307,6 +310,7 @@ describe('FriendsPage', () => {
|
|||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
homeInstance: null,
|
homeInstance: null,
|
||||||
|
homeUserId: null,
|
||||||
replicatedInstances: [],
|
replicatedInstances: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import React, { useMemo } from 'react';
|
|||||||
import type { MemberWithUser } from '@backspace/shared';
|
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 { useAuthStore } from '../../stores/authStore';
|
|
||||||
import { resolveDisplayIdentity } from '../../utils/identity';
|
|
||||||
import { Avatar } from '../ui/Avatar';
|
import { Avatar } from '../ui/Avatar';
|
||||||
import { Username } from '../ui/Username';
|
import { Username } from '../ui/Username';
|
||||||
|
|
||||||
@@ -48,7 +46,6 @@ export function MemberSidebar() {
|
|||||||
const currentServerId = useServerStore((s) => s.currentServerId);
|
const currentServerId = useServerStore((s) => s.currentServerId);
|
||||||
const memberListOpen = useUIStore((s) => s.memberListOpen);
|
const memberListOpen = useUIStore((s) => s.memberListOpen);
|
||||||
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
||||||
const authUser = useAuthStore((s) => s.user);
|
|
||||||
|
|
||||||
const server = servers.find(s => s.id === currentServerId);
|
const server = servers.find(s => s.id === currentServerId);
|
||||||
const ownerId = server?.ownerId;
|
const ownerId = server?.ownerId;
|
||||||
@@ -100,7 +97,6 @@ export function MemberSidebar() {
|
|||||||
const renderMember = (member: MemberWithUser, isOffline = false) => {
|
const renderMember = (member: MemberWithUser, isOffline = false) => {
|
||||||
const displayName = member.user.displayName ?? member.user.username;
|
const displayName = member.user.displayName ?? member.user.username;
|
||||||
const colorStyle = isOffline ? undefined : getMemberColor(member);
|
const colorStyle = isOffline ? undefined : getMemberColor(member);
|
||||||
const resolvedUser = resolveDisplayIdentity(member.user, authUser ?? null);
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={member.userId}
|
key={member.userId}
|
||||||
@@ -114,7 +110,6 @@ export function MemberSidebar() {
|
|||||||
status={isOffline ? 'offline' : member.user.status}
|
status={isOffline ? 'offline' : member.user.status}
|
||||||
className={isOffline ? 'opacity-60' : undefined}
|
className={isOffline ? 'opacity-60' : undefined}
|
||||||
user={member.user}
|
user={member.user}
|
||||||
userId={resolvedUser.id}
|
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<Username
|
<Username
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function Avatar({ src, name, size = 40, status, className = '', onClick,
|
|||||||
const initials = name.charAt(0).toUpperCase();
|
const initials = name.charAt(0).toUpperCase();
|
||||||
// Match prototype: 24px→10px, 32-34px→12px, 40px→15px, 56px+→18px
|
// Match prototype: 24px→10px, 32-34px→12px, 40px→15px, 56px+→18px
|
||||||
const fontPx = size <= 24 ? 10 : size <= 34 ? 12 : size <= 44 ? 15 : 18;
|
const fontPx = size <= 24 ? 10 : size <= 34 ? 12 : size <= 44 ? 15 : 18;
|
||||||
const gradient = getAvatarGradient(userId ?? user?.id, name);
|
const gradient = getAvatarGradient(userId ?? user?.homeUserId ?? user?.id, name);
|
||||||
|
|
||||||
const handleClick = (e: React.MouseEvent) => {
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
if (onClick) {
|
if (onClick) {
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export function UserProfilePopout({ user, onClose, position }: UserProfilePopout
|
|||||||
<div
|
<div
|
||||||
className="h-[48px] rounded-t-[12px]"
|
className="h-[48px] rounded-t-[12px]"
|
||||||
style={{
|
style={{
|
||||||
background: getAvatarGradient(user.id, displayName).gradient,
|
background: getAvatarGradient(user.homeUserId ?? user.id, displayName).gradient,
|
||||||
opacity: 0.6,
|
opacity: 0.6,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||||
import { Avatar } from '../ui/Avatar';
|
import { Avatar } from '../ui/Avatar';
|
||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
|
||||||
import { getActiveRoom, setStreamSubscription } from '../../hooks/useLiveKit';
|
import { getActiveRoom, setStreamSubscription } from '../../hooks/useLiveKit';
|
||||||
import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover';
|
import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover';
|
||||||
import { stopScreenShare, changeScreenShare } from '../../utils/screenShare';
|
import { stopScreenShare, changeScreenShare } from '../../utils/screenShare';
|
||||||
@@ -24,8 +23,7 @@ export function StreamTile({ tile, large }: StreamTileProps) {
|
|||||||
const { participant } = tile;
|
const { participant } = tile;
|
||||||
const isLocal = participant.isLocal;
|
const isLocal = participant.isLocal;
|
||||||
const userId = participant.userId;
|
const userId = participant.userId;
|
||||||
const homeUser = useAuthStore((s) => s.user);
|
const avatarUserId = participant.homeUserId ?? userId;
|
||||||
const avatarUserId = isLocal ? (homeUser?.id ?? userId) : userId;
|
|
||||||
|
|
||||||
const isWatching = watchingStreams.has(userId);
|
const isWatching = watchingStreams.has(userId);
|
||||||
const streamVolume = streamVolumes.get(userId) ?? 100;
|
const streamVolume = streamVolumes.get(userId) ?? 100;
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
|
||||||
import { isSelf } from '../../utils/identity';
|
|
||||||
|
|
||||||
const EMPTY_VOICE_USERS: string[] = [];
|
const EMPTY_VOICE_USERS: string[] = [];
|
||||||
import { useServerStore } from '../../stores/serverStore';
|
import { useServerStore } from '../../stores/serverStore';
|
||||||
@@ -20,8 +18,11 @@ export function VoiceChannel({ channelId, channelName, onClick }: VoiceChannelPr
|
|||||||
const localIsDeafened = useVoiceStore((s) => s.isDeafened);
|
const localIsDeafened = useVoiceStore((s) => s.isDeafened);
|
||||||
const localIsMuted = useVoiceStore((s) => s.isMuted);
|
const localIsMuted = useVoiceStore((s) => s.isMuted);
|
||||||
const voiceUserStates = useVoiceStore((s) => s.voiceUserStates);
|
const voiceUserStates = useVoiceStore((s) => s.voiceUserStates);
|
||||||
const authUser = useAuthStore((s) => s.user);
|
const currentUserId = useVoiceStore((s) => {
|
||||||
const currentUserId = authUser?.id;
|
// Derive from participants — avoids unnecessary authStore dependency
|
||||||
|
const local = s.participants.find(p => p.isLocal);
|
||||||
|
return local?.userId ?? null;
|
||||||
|
});
|
||||||
const members = useServerStore((s) => s.members);
|
const members = useServerStore((s) => s.members);
|
||||||
const isActive = currentVoiceChannel === channelId;
|
const isActive = currentVoiceChannel === channelId;
|
||||||
|
|
||||||
@@ -75,7 +76,7 @@ export function VoiceChannel({ channelId, channelName, onClick }: VoiceChannelPr
|
|||||||
name={displayName}
|
name={displayName}
|
||||||
size={24}
|
size={24}
|
||||||
status={status}
|
status={status}
|
||||||
userId={(authUser && member?.user && isSelf(member.user, authUser)) ? authUser.id : userId}
|
userId={member?.user.homeUserId ?? userId}
|
||||||
/>
|
/>
|
||||||
<span className="text-[13px] text-txt-secondary truncate flex-1 min-w-0">{displayName}</span>
|
<span className="text-[13px] text-txt-secondary truncate flex-1 min-w-0">{displayName}</span>
|
||||||
{/* Status badges */}
|
{/* Status badges */}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||||
import { Avatar } from '../ui/Avatar';
|
import { Avatar } from '../ui/Avatar';
|
||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
|
||||||
import type { UserTile } from '../../hooks/useLiveKit';
|
import type { UserTile } from '../../hooks/useLiveKit';
|
||||||
|
|
||||||
interface VoiceUserProps {
|
interface VoiceUserProps {
|
||||||
@@ -21,8 +20,7 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
|
|||||||
|
|
||||||
const perUserVolume = participantVolumes.get(participant.userId) ?? 100;
|
const perUserVolume = participantVolumes.get(participant.userId) ?? 100;
|
||||||
const isLocal = participant.isLocal;
|
const isLocal = participant.isLocal;
|
||||||
const homeUser = useAuthStore((s) => s.user);
|
const avatarUserId = participant.homeUserId ?? participant.userId;
|
||||||
const avatarUserId = isLocal ? (homeUser?.id ?? participant.userId) : participant.userId;
|
|
||||||
|
|
||||||
// --- VIDEO & UI ---
|
// --- VIDEO & UI ---
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
LocalAudioTrack,
|
LocalAudioTrack,
|
||||||
LocalTrackPublication,
|
LocalTrackPublication,
|
||||||
} from 'livekit-client';
|
} from 'livekit-client';
|
||||||
import { getApiForOrigin, getChannelOrigin } from '../stores/serverStore';
|
import { getApiForOrigin, getChannelOrigin, useServerStore } from '../stores/serverStore';
|
||||||
import { useVoiceStore } from '../stores/voiceStore';
|
import { useVoiceStore } from '../stores/voiceStore';
|
||||||
import { AudioManager } from '../audio/AudioManager';
|
import { AudioManager } from '../audio/AudioManager';
|
||||||
import { SpeakingDetector } from '../audio/SpeakingDetector';
|
import { SpeakingDetector } from '../audio/SpeakingDetector';
|
||||||
@@ -37,6 +37,7 @@ export interface ParticipantInfo {
|
|||||||
identity: string;
|
identity: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
homeUserId: string | null;
|
||||||
isMuted: boolean;
|
isMuted: boolean;
|
||||||
isDeafened: boolean;
|
isDeafened: boolean;
|
||||||
isCameraOn: boolean;
|
isCameraOn: boolean;
|
||||||
@@ -152,6 +153,8 @@ export function useLiveKit() {
|
|||||||
const processParticipant = (p: Participant, isLocal: boolean) => {
|
const processParticipant = (p: Participant, isLocal: boolean) => {
|
||||||
if (!p.identity) return;
|
if (!p.identity) return;
|
||||||
const { userId, username } = parseIdentity(p.identity);
|
const { userId, username } = parseIdentity(p.identity);
|
||||||
|
const memberMatch = useServerStore.getState().members.find(m => m.userId === userId);
|
||||||
|
const homeUserId = memberMatch?.user.homeUserId ?? null;
|
||||||
let audioTrack: MediaStreamTrack | null = null;
|
let audioTrack: MediaStreamTrack | null = null;
|
||||||
let videoTrack: MediaStreamTrack | null = null;
|
let videoTrack: MediaStreamTrack | null = null;
|
||||||
let screenTrack: MediaStreamTrack | null = null;
|
let screenTrack: MediaStreamTrack | null = null;
|
||||||
@@ -194,6 +197,7 @@ export function useLiveKit() {
|
|||||||
identity: p.identity,
|
identity: p.identity,
|
||||||
userId,
|
userId,
|
||||||
username,
|
username,
|
||||||
|
homeUserId,
|
||||||
isMuted: isPartMuted,
|
isMuted: isPartMuted,
|
||||||
isDeafened: isPartDeafened,
|
isDeafened: isPartDeafened,
|
||||||
isCameraOn: !!videoTrack,
|
isCameraOn: !!videoTrack,
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
|
|||||||
password,
|
password,
|
||||||
displayName: displayName || currentUser.displayName || undefined,
|
displayName: displayName || currentUser.displayName || undefined,
|
||||||
homeInstance,
|
homeInstance,
|
||||||
|
homeUserId: currentUser.id,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = (err as Error).message;
|
const message = (err as Error).message;
|
||||||
@@ -162,6 +163,7 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
|
|||||||
password,
|
password,
|
||||||
displayName: displayName || currentUser.displayName || undefined,
|
displayName: displayName || currentUser.displayName || undefined,
|
||||||
homeInstance,
|
homeInstance,
|
||||||
|
homeUserId: currentUser.id,
|
||||||
});
|
});
|
||||||
} catch (err2) {
|
} catch (err2) {
|
||||||
const msg2 = (err2 as Error).message;
|
const msg2 = (err2 as Error).message;
|
||||||
@@ -380,6 +382,14 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
|
|||||||
// Non-critical — keep cached label
|
// Non-critical — keep cached label
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backfill homeUserId if missing (existing federated users before this field existed)
|
||||||
|
if (user.homeInstance && !user.homeUserId) {
|
||||||
|
const homeUser = useAuthStore.getState().user;
|
||||||
|
if (homeUser) {
|
||||||
|
client.users.update({ homeUserId: homeUser.id }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const connectedInstance: ConnectedInstance = {
|
const connectedInstance: ConnectedInstance = {
|
||||||
origin,
|
origin,
|
||||||
label,
|
label,
|
||||||
|
|||||||
Reference in New Issue
Block a user