feat: LWW timestamps for bidirectional federation profile & layout sync
Profile and space layout changes on remote instances were being overwritten by stale data on reconnect. Adds Last-Writer-Wins timestamps so the client-relay mesh rejects stale writes: - profile_updated_at column on users table with migration + backfill - Server LWW guards on PATCH /users/@me and PUT /space-layout - Bidirectional profileSync: pulls newer remote profiles to home - LWW layout sync replaces home-authoritative _layoutFromTrueHome flag - Layout pushes to ALL connected instances in parallel
This commit is contained in:
@@ -117,6 +117,12 @@ export function runMigrations(db: Database.Database): void {
|
|||||||
columns: [
|
columns: [
|
||||||
{ name: 'category_id', type: 'TEXT' },
|
{ name: 'category_id', type: 'TEXT' },
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'users',
|
||||||
|
columns: [
|
||||||
|
{ name: 'profile_updated_at', type: 'INTEGER' },
|
||||||
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -233,6 +239,9 @@ export function runMigrations(db: Database.Database): void {
|
|||||||
// ─── Convert video channels to voice (video type removed) ─────────────────
|
// ─── Convert video channels to voice (video type removed) ─────────────────
|
||||||
migrateVideoChannels(db);
|
migrateVideoChannels(db);
|
||||||
|
|
||||||
|
// ─── Backfill profile_updated_at from created_at ──────────────────────────
|
||||||
|
migrateProfileUpdatedAt(db);
|
||||||
|
|
||||||
// ─── Ensure user_space_layout table exists ────────────────────────────────
|
// ─── Ensure user_space_layout table exists ────────────────────────────────
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS user_space_layout (
|
CREATE TABLE IF NOT EXISTS user_space_layout (
|
||||||
@@ -651,6 +660,16 @@ function migrateVideoChannels(db: Database.Database): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Backfill profile_updated_at from created_at for existing users */
|
||||||
|
function migrateProfileUpdatedAt(db: Database.Database): void {
|
||||||
|
const result = db.prepare(
|
||||||
|
'UPDATE users SET profile_updated_at = created_at WHERE profile_updated_at IS NULL'
|
||||||
|
).run();
|
||||||
|
if (result.changes > 0) {
|
||||||
|
console.log(`Migrating: Backfilled profile_updated_at for ${result.changes} user(s)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function migrateReplicatedUsernames(db: Database.Database): void {
|
function migrateReplicatedUsernames(db: Database.Database): void {
|
||||||
const rows = db.prepare(
|
const rows = db.prepare(
|
||||||
"SELECT id, username, home_instance FROM users WHERE home_instance IS NOT NULL AND username NOT LIKE '%@%'"
|
"SELECT id, username, home_instance FROM users WHERE home_instance IS NOT NULL AND username NOT LIKE '%@%'"
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const users = sqliteTable('users', {
|
|||||||
avatarColor: text('avatar_color'),
|
avatarColor: text('avatar_color'),
|
||||||
bio: text('bio'),
|
bio: text('bio'),
|
||||||
isDeleted: integer('is_deleted').default(0),
|
isDeleted: integer('is_deleted').default(0),
|
||||||
|
profileUpdatedAt: integer('profile_updated_at'),
|
||||||
createdAt: integer('created_at').notNull(),
|
createdAt: integer('created_at').notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -255,7 +255,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, banner, accentColor, avatarColor, bio, customStatus, status, replicatedInstances, homeUserId } = request.body;
|
const { displayName, avatar, banner, accentColor, avatarColor, bio, customStatus, status, replicatedInstances, homeUserId, profileUpdatedAt } = request.body;
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
const updateData: Record<string, string | null | undefined> = {};
|
const updateData: Record<string, string | null | undefined> = {};
|
||||||
@@ -370,6 +370,28 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
|
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LWW guard: if the caller provided a profileUpdatedAt and profile fields changed,
|
||||||
|
// reject stale writes by comparing timestamps
|
||||||
|
const profileFields = ['displayName', 'avatar', 'banner', 'accentColor', 'avatarColor', 'bio', 'customStatus'];
|
||||||
|
const hasProfileChange = profileFields.some(f => f in updateData);
|
||||||
|
|
||||||
|
if (hasProfileChange) {
|
||||||
|
if (profileUpdatedAt !== undefined && typeof profileUpdatedAt === 'number') {
|
||||||
|
const currentUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
||||||
|
if (currentUser) {
|
||||||
|
const storedTs = currentUser.profileUpdatedAt ?? currentUser.createdAt;
|
||||||
|
if (profileUpdatedAt < storedTs) {
|
||||||
|
// Incoming data is older — return current state without updating
|
||||||
|
return reply.code(200).send(sanitizeUser(currentUser));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(updateData as Record<string, unknown>).profileUpdatedAt = profileUpdatedAt;
|
||||||
|
} else {
|
||||||
|
// No explicit timestamp — stamp with server time (local edits)
|
||||||
|
(updateData as Record<string, unknown>).profileUpdatedAt = Date.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
db.update(schema.users).set(updateData).where(eq(schema.users.id, request.userId)).run();
|
db.update(schema.users).set(updateData).where(eq(schema.users.id, request.userId)).run();
|
||||||
|
|
||||||
const updatedUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
const updatedUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
||||||
@@ -396,10 +418,7 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Broadcast user_updated for profile field changes
|
// Broadcast user_updated for profile field changes (reuse hasProfileChange from LWW guard above)
|
||||||
const profileFields = ['displayName', 'avatar', 'banner', 'accentColor', 'avatarColor', 'bio', 'customStatus'];
|
|
||||||
const hasProfileChange = profileFields.some(f => f in updateData);
|
|
||||||
|
|
||||||
if (hasProfileChange) {
|
if (hasProfileChange) {
|
||||||
const userUpdatedEvent = { type: 'user_updated' as const, user: sanitized };
|
const userUpdatedEvent = { type: 'user_updated' as const, user: sanitized };
|
||||||
const targetUserIds = new Set<string>();
|
const targetUserIds = new Set<string>();
|
||||||
@@ -444,9 +463,9 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// PUT /api/users/@me/space-layout — save sidebar layout (reorder, folders)
|
// PUT /api/users/@me/space-layout — save sidebar layout (reorder, folders)
|
||||||
app.put<{ Body: { items: SpaceLayoutItem[]; folders: Record<string, { name: string | null; color: string | null; spaceIds: string[] }> } }>(
|
app.put<{ Body: { items: SpaceLayoutItem[]; folders: Record<string, { name: string | null; color: string | null; spaceIds: string[] }>; updatedAt?: number } }>(
|
||||||
'/api/users/@me/space-layout', { preHandler: authenticate }, async (request, reply) => {
|
'/api/users/@me/space-layout', { preHandler: authenticate }, async (request, reply) => {
|
||||||
const { items, folders } = request.body;
|
const { items, folders, updatedAt: incomingTs } = request.body;
|
||||||
const userId = request.userId;
|
const userId = request.userId;
|
||||||
|
|
||||||
if (!Array.isArray(items)) {
|
if (!Array.isArray(items)) {
|
||||||
@@ -472,6 +491,39 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
|
// LWW guard: reject stale layout writes
|
||||||
|
if (incomingTs !== undefined && typeof incomingTs === 'number') {
|
||||||
|
const existingLayout = db.select().from(schema.userSpaceLayout)
|
||||||
|
.where(eq(schema.userSpaceLayout.userId, userId)).get();
|
||||||
|
if (existingLayout && incomingTs < existingLayout.updatedAt) {
|
||||||
|
// Incoming layout is older — return current state without updating
|
||||||
|
const currentItems: SpaceLayoutItem[] = JSON.parse(existingLayout.layout);
|
||||||
|
const currentFolderRows = db.select().from(schema.spaceFolders)
|
||||||
|
.where(eq(schema.spaceFolders.userId, userId))
|
||||||
|
.orderBy(schema.spaceFolders.position)
|
||||||
|
.all();
|
||||||
|
const currentFolders: SpaceFolder[] = currentFolderRows.map(folder => {
|
||||||
|
const memberRows = db.select()
|
||||||
|
.from(schema.spaceFolderMembers)
|
||||||
|
.where(eq(schema.spaceFolderMembers.folderId, folder.id))
|
||||||
|
.orderBy(schema.spaceFolderMembers.position)
|
||||||
|
.all();
|
||||||
|
return {
|
||||||
|
id: folder.id,
|
||||||
|
userId: folder.userId,
|
||||||
|
name: folder.name,
|
||||||
|
color: folder.color,
|
||||||
|
position: folder.position ?? 0,
|
||||||
|
spaceIds: memberRows.map(m => m.spaceId),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return reply.code(200).send({ items: currentItems, folders: currentFolders, updatedAt: existingLayout.updatedAt });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the effective timestamp for this write
|
||||||
|
const effectiveTs = (incomingTs !== undefined && typeof incomingTs === 'number') ? incomingTs : Date.now();
|
||||||
|
|
||||||
// Map new:* folder keys to server-generated IDs
|
// Map new:* folder keys to server-generated IDs
|
||||||
const newIdMap = new Map<string, string>();
|
const newIdMap = new Map<string, string>();
|
||||||
for (const key of Object.keys(folders)) {
|
for (const key of Object.keys(folders)) {
|
||||||
@@ -553,14 +605,14 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
.where(eq(schema.userSpaceLayout.userId, userId)).get();
|
.where(eq(schema.userSpaceLayout.userId, userId)).get();
|
||||||
if (existing) {
|
if (existing) {
|
||||||
tx.update(schema.userSpaceLayout)
|
tx.update(schema.userSpaceLayout)
|
||||||
.set({ layout: JSON.stringify(finalItems), updatedAt: Date.now() })
|
.set({ layout: JSON.stringify(finalItems), updatedAt: effectiveTs })
|
||||||
.where(eq(schema.userSpaceLayout.userId, userId))
|
.where(eq(schema.userSpaceLayout.userId, userId))
|
||||||
.run();
|
.run();
|
||||||
} else {
|
} else {
|
||||||
tx.insert(schema.userSpaceLayout).values({
|
tx.insert(schema.userSpaceLayout).values({
|
||||||
userId,
|
userId,
|
||||||
layout: JSON.stringify(finalItems),
|
layout: JSON.stringify(finalItems),
|
||||||
updatedAt: Date.now(),
|
updatedAt: effectiveTs,
|
||||||
}).run();
|
}).run();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -592,14 +644,17 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const layoutUpdatedAt = finalLayout?.updatedAt ?? effectiveTs;
|
||||||
|
|
||||||
// Broadcast to user's other connections (multi-tab sync)
|
// Broadcast to user's other connections (multi-tab sync)
|
||||||
connectionManager.sendToUser(userId, {
|
connectionManager.sendToUser(userId, {
|
||||||
type: 'space_layout_updated',
|
type: 'space_layout_updated',
|
||||||
layout: finalItems,
|
layout: finalItems,
|
||||||
folders: responseFolders,
|
folders: responseFolders,
|
||||||
|
updatedAt: layoutUpdatedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.code(200).send({ items: finalItems, folders: responseFolders });
|
return reply.code(200).send({ items: finalItems, folders: responseFolders, updatedAt: layoutUpdatedAt });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get<{ Params: { id: string } }>('/api/users/:id', { preHandler: authenticate }, async (request, reply) => {
|
app.get<{ Params: { id: string } }>('/api/users/:id', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
|||||||
customStatus: null,
|
customStatus: null,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
isDeleted: true,
|
isDeleted: true,
|
||||||
|
profileUpdatedAt: 0,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
homeInstance: null,
|
homeInstance: null,
|
||||||
homeUserId: null,
|
homeUserId: null,
|
||||||
@@ -45,6 +46,7 @@ export function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
|||||||
status: (row.status ?? 'offline') as User['status'],
|
status: (row.status ?? 'offline') as User['status'],
|
||||||
customStatus: row.customStatus,
|
customStatus: row.customStatus,
|
||||||
isAdmin: row.isAdmin === 1,
|
isAdmin: row.isAdmin === 1,
|
||||||
|
profileUpdatedAt: row.profileUpdatedAt ?? row.createdAt,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
homeInstance: row.homeInstance ?? null,
|
homeInstance: row.homeInstance ?? null,
|
||||||
homeUserId: row.homeUserId ?? null,
|
homeUserId: row.homeUserId ?? null,
|
||||||
|
|||||||
@@ -654,6 +654,7 @@ function buildReadyPayload(userId: string): {
|
|||||||
dmChannels: DmChannel[];
|
dmChannels: DmChannel[];
|
||||||
folders: SpaceFolder[];
|
folders: SpaceFolder[];
|
||||||
spaceLayout: SpaceLayoutItem[] | null;
|
spaceLayout: SpaceLayoutItem[] | null;
|
||||||
|
layoutUpdatedAt: number | null;
|
||||||
voiceStates: Record<string, string[]>;
|
voiceStates: Record<string, string[]>;
|
||||||
voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>;
|
voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>;
|
||||||
spaceVoiceStates: Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: boolean }>;
|
spaceVoiceStates: Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: boolean }>;
|
||||||
@@ -946,6 +947,7 @@ function buildReadyPayload(userId: string): {
|
|||||||
const layoutRow = db.select().from(schema.userSpaceLayout)
|
const layoutRow = db.select().from(schema.userSpaceLayout)
|
||||||
.where(eq(schema.userSpaceLayout.userId, userId)).get();
|
.where(eq(schema.userSpaceLayout.userId, userId)).get();
|
||||||
const spaceLayout: SpaceLayoutItem[] | null = layoutRow ? JSON.parse(layoutRow.layout) : null;
|
const spaceLayout: SpaceLayoutItem[] | null = layoutRow ? JSON.parse(layoutRow.layout) : null;
|
||||||
|
const layoutUpdatedAt: number | null = layoutRow?.updatedAt ?? null;
|
||||||
|
|
||||||
// Build voice states — tell the client who is currently in voice channels
|
// Build voice states — tell the client who is currently in voice channels
|
||||||
// across all their spaces
|
// across all their spaces
|
||||||
@@ -1037,7 +1039,7 @@ function buildReadyPayload(userId: string): {
|
|||||||
lastReadMessageId: rs.lastReadMessageId,
|
lastReadMessageId: rs.lastReadMessageId,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return { user, spaces, dmChannels, folders, spaceLayout, voiceStates, voiceUserStates, spaceVoiceStates, readStates, activeCalls };
|
return { user, spaces, dmChannels, folders, spaceLayout, layoutUpdatedAt, voiceStates, voiceUserStates, spaceVoiceStates, readStates, activeCalls };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function registerWebSocket(app: FastifyInstance): Promise<void> {
|
export async function registerWebSocket(app: FastifyInstance): Promise<void> {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface User {
|
|||||||
customStatus: string | null;
|
customStatus: string | null;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
isDeleted?: boolean;
|
isDeleted?: boolean;
|
||||||
|
profileUpdatedAt?: number;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
homeInstance: string | null;
|
homeInstance: string | null;
|
||||||
homeUserId: string | null;
|
homeUserId: string | null;
|
||||||
@@ -271,7 +272,7 @@ export type ClientEvent =
|
|||||||
|
|
||||||
// Server → Client Events
|
// Server → Client Events
|
||||||
export type ServerEvent =
|
export type ServerEvent =
|
||||||
| { type: 'ready'; user: User; spaces: SpaceWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: SpaceFolder[]; spaceLayout?: SpaceLayoutItem[] | null; voiceStates?: Record<string, string[]>; voiceUserStates?: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[]; spaceVoiceStates?: Record<string, { spaceMuted: boolean; spaceDeafened: boolean }> }
|
| { type: 'ready'; user: User; spaces: SpaceWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: SpaceFolder[]; spaceLayout?: SpaceLayoutItem[] | null; layoutUpdatedAt?: number; voiceStates?: Record<string, string[]>; voiceUserStates?: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[]; spaceVoiceStates?: Record<string, { spaceMuted: boolean; spaceDeafened: boolean }> }
|
||||||
| { type: 'message_created'; message: MessageWithUser }
|
| { type: 'message_created'; message: MessageWithUser }
|
||||||
| { type: 'message_updated'; message: MessageWithUser }
|
| { type: 'message_updated'; message: MessageWithUser }
|
||||||
| { type: 'message_deleted'; messageId: string; channelId: string }
|
| { type: 'message_deleted'; messageId: string; channelId: string }
|
||||||
@@ -317,7 +318,7 @@ export type ServerEvent =
|
|||||||
| { type: 'category_updated'; category: ChannelCategory; spaceId: string }
|
| { type: 'category_updated'; category: ChannelCategory; spaceId: string }
|
||||||
| { type: 'category_deleted'; categoryId: string; spaceId: string }
|
| { type: 'category_deleted'; categoryId: string; spaceId: string }
|
||||||
| { type: 'channel_layout_updated'; spaceId: string; channels: Channel[]; categories: ChannelCategory[] }
|
| { type: 'channel_layout_updated'; spaceId: string; channels: Channel[]; categories: ChannelCategory[] }
|
||||||
| { type: 'space_layout_updated'; layout: SpaceLayoutItem[]; folders: SpaceFolder[] }
|
| { type: 'space_layout_updated'; layout: SpaceLayoutItem[]; folders: SpaceFolder[]; updatedAt?: number }
|
||||||
| { type: 'pong' }
|
| { type: 'pong' }
|
||||||
| { type: 'error'; message: string };
|
| { type: 'error'; message: string };
|
||||||
|
|
||||||
@@ -385,6 +386,7 @@ export interface UpdateUserRequest {
|
|||||||
status?: UserStatus;
|
status?: UserStatus;
|
||||||
replicatedInstances?: ReplicatedInstance[];
|
replicatedInstances?: ReplicatedInstance[];
|
||||||
homeUserId?: string;
|
homeUserId?: string;
|
||||||
|
profileUpdatedAt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateMemberRequest {
|
export interface UpdateMemberRequest {
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export class BackspaceApiClient {
|
|||||||
};
|
};
|
||||||
|
|
||||||
readonly spaceLayout: {
|
readonly spaceLayout: {
|
||||||
update: (data: { items: SpaceLayoutItem[]; folders: Record<string, { name: string | null; color: string | null; spaceIds: string[] }> }) => Promise<{ items: SpaceLayoutItem[]; folders: SpaceFolder[] }>;
|
update: (data: { items: SpaceLayoutItem[]; folders: Record<string, { name: string | null; color: string | null; spaceIds: string[] }>; updatedAt?: number }) => Promise<{ items: SpaceLayoutItem[]; folders: SpaceFolder[]; updatedAt?: number }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
readonly spaces: {
|
readonly spaces: {
|
||||||
@@ -278,7 +278,7 @@ export class BackspaceApiClient {
|
|||||||
|
|
||||||
this.spaceLayout = {
|
this.spaceLayout = {
|
||||||
update: (data) =>
|
update: (data) =>
|
||||||
request<{ items: SpaceLayoutItem[]; folders: SpaceFolder[] }>('PUT', '/users/@me/space-layout', data),
|
request<{ items: SpaceLayoutItem[]; folders: SpaceFolder[]; updatedAt?: number }>('PUT', '/users/@me/space-layout', data),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.spaces = {
|
this.spaces = {
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
populateFromReady(origin, event.spaces, event.folders, event.dmChannels, event.spaceLayout);
|
populateFromReady(origin, event.spaces, event.folders, event.dmChannels, event.spaceLayout, event.layoutUpdatedAt);
|
||||||
|
|
||||||
// Cache authoritative identity for this origin (federation-safe)
|
// Cache authoritative identity for this origin (federation-safe)
|
||||||
if (!isHome) {
|
if (!isHome) {
|
||||||
@@ -698,15 +698,13 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'space_layout_updated': {
|
case 'space_layout_updated': {
|
||||||
// Accept layout updates from browsing instance OR true home
|
// LWW: only accept if incoming timestamp >= current
|
||||||
const layoutUser = useAuthStore.getState().user;
|
const incomingTs = event.updatedAt ?? 0;
|
||||||
const isLayoutTrueHome = !!layoutUser?.homeInstance && origin !== '' && (() => {
|
const currentTs = useSpaceStore.getState()._layoutUpdatedAt;
|
||||||
try { return new URL(origin).host === layoutUser.homeInstance; } catch { return false; }
|
if (incomingTs >= currentTs) {
|
||||||
})();
|
useSpaceStore.getState().setSpaceLayout(event.layout);
|
||||||
if (!isHome && !isLayoutTrueHome) break;
|
useSpaceStore.setState({ folders: event.folders, _layoutUpdatedAt: incomingTs });
|
||||||
const { setSpaceLayout } = useSpaceStore.getState();
|
}
|
||||||
setSpaceLayout(event.layout);
|
|
||||||
useSpaceStore.setState({ folders: event.folders });
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ interface SpaceState {
|
|||||||
channelPermissions: Map<string, string>; // channelId → myPermissions decimal string
|
channelPermissions: Map<string, string>; // channelId → myPermissions decimal string
|
||||||
channelOriginMap: Map<string, string>; // channelId → instance origin ('' = home)
|
channelOriginMap: Map<string, string>; // channelId → instance origin ('' = home)
|
||||||
categoryOriginMap: Map<string, string>; // categoryId → instance origin ('' = home)
|
categoryOriginMap: Map<string, string>; // categoryId → instance origin ('' = home)
|
||||||
_layoutFromTrueHome: boolean;
|
_layoutUpdatedAt: number;
|
||||||
setSpaces: (spaces: TaggedSpace[]) => void;
|
setSpaces: (spaces: TaggedSpace[]) => void;
|
||||||
setCurrentSpace: (spaceId: string | null) => void;
|
setCurrentSpace: (spaceId: string | null) => void;
|
||||||
setChannels: (channels: Channel[]) => void;
|
setChannels: (channels: Channel[]) => void;
|
||||||
@@ -76,13 +76,40 @@ interface SpaceState {
|
|||||||
removeMember: (userId: string) => void;
|
removeMember: (userId: string) => void;
|
||||||
setSpaceLayout: (layout: SpaceLayoutItem[] | null) => void;
|
setSpaceLayout: (layout: SpaceLayoutItem[] | null) => void;
|
||||||
updateSpaceLayout: (items: SpaceLayoutItem[], folders: Record<string, { name: string | null; color: string | null; spaceIds: string[] }>) => Promise<void>;
|
updateSpaceLayout: (items: SpaceLayoutItem[], folders: Record<string, { name: string | null; color: string | null; spaceIds: string[] }>) => Promise<void>;
|
||||||
populateFromReady: (origin: string, spaces: SpaceWithChannelsAndMembers[], folders?: SpaceFolder[], dmChannels?: DmChannel[], spaceLayout?: SpaceLayoutItem[] | null) => void;
|
populateFromReady: (origin: string, spaces: SpaceWithChannelsAndMembers[], folders?: SpaceFolder[], dmChannels?: DmChannel[], spaceLayout?: SpaceLayoutItem[] | null, layoutUpdatedAt?: number) => void;
|
||||||
addSpaceFromReady: (origin: string, space: SpaceWithChannelsAndMembers) => void;
|
addSpaceFromReady: (origin: string, space: SpaceWithChannelsAndMembers) => void;
|
||||||
removeInstanceSpaces: (origin: string) => void;
|
removeInstanceSpaces: (origin: string) => void;
|
||||||
transferOwnership: (spaceId: string, newOwnerId: string) => Promise<void>;
|
transferOwnership: (spaceId: string, newOwnerId: string) => Promise<void>;
|
||||||
findExistingDmForUser: (targetUser: { id: string; homeUserId?: string | null }) => { dm: DmChannel; origin: string } | null;
|
findExistingDmForUser: (targetUser: { id: string; homeUserId?: string | null }) => { dm: DmChannel; origin: string } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push the current layout to a specific origin whose layout was older.
|
||||||
|
* Used when populateFromReady receives a stale layout from an instance.
|
||||||
|
*/
|
||||||
|
async function pushLayoutToOrigin(
|
||||||
|
origin: string,
|
||||||
|
layout: SpaceLayoutItem[] | null,
|
||||||
|
folders: SpaceFolder[],
|
||||||
|
updatedAt: number,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const targetApi = getApiForOrigin(origin);
|
||||||
|
// Build folder map from SpaceFolder[]
|
||||||
|
const folderMap: Record<string, { name: string | null; color: string | null; spaceIds: string[] }> = {};
|
||||||
|
for (const f of folders) {
|
||||||
|
folderMap[f.id] = { name: f.name, color: f.color, spaceIds: f.spaceIds };
|
||||||
|
}
|
||||||
|
await targetApi.spaceLayout.update({
|
||||||
|
items: layout ?? [],
|
||||||
|
folders: folderMap,
|
||||||
|
updatedAt,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[SpaceStore] Failed to push layout to ${origin || 'home'}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const useSpaceStore = create<SpaceState>((set, get) => ({
|
export const useSpaceStore = create<SpaceState>((set, get) => ({
|
||||||
spaces: [],
|
spaces: [],
|
||||||
currentSpaceId: null,
|
currentSpaceId: null,
|
||||||
@@ -99,7 +126,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
channelPermissions: new Map(),
|
channelPermissions: new Map(),
|
||||||
channelOriginMap: new Map(),
|
channelOriginMap: new Map(),
|
||||||
categoryOriginMap: new Map(),
|
categoryOriginMap: new Map(),
|
||||||
_layoutFromTrueHome: false,
|
_layoutUpdatedAt: 0,
|
||||||
|
|
||||||
setSpaces: (spaces) => set({ spaces }),
|
setSpaces: (spaces) => set({ spaces }),
|
||||||
setCurrentSpace: (spaceId) => set({ currentSpaceId: spaceId }),
|
setCurrentSpace: (spaceId) => set({ currentSpaceId: spaceId }),
|
||||||
@@ -424,33 +451,43 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
setSpaceLayout: (layout) => set({ spaceLayout: layout }),
|
setSpaceLayout: (layout) => set({ spaceLayout: layout }),
|
||||||
|
|
||||||
updateSpaceLayout: async (items, folders) => {
|
updateSpaceLayout: async (items, folders) => {
|
||||||
// Optimistic: apply the layout immediately
|
const now = Date.now();
|
||||||
set({ spaceLayout: items });
|
// Optimistic: apply the layout immediately with new timestamp
|
||||||
|
set({ spaceLayout: items, _layoutUpdatedAt: now });
|
||||||
|
|
||||||
const homeOrigin = getLayoutHomeOrigin();
|
// Push to ALL connected instances in parallel (browsing + remotes)
|
||||||
const homeApi = getApiForOrigin(homeOrigin);
|
const targets: { origin: string; apiClient: BackspaceApiClient }[] = [
|
||||||
|
{ origin: '', apiClient: api },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Dynamically import instanceStore to avoid circular dep
|
||||||
try {
|
try {
|
||||||
const result = await homeApi.spaceLayout.update({ items, folders });
|
const { useInstanceStore } = await import('./instanceStore');
|
||||||
// Server may have resolved new:* IDs
|
const connected = useInstanceStore.getState().instances.filter(i => i.status === 'connected');
|
||||||
set({ spaceLayout: result.items, folders: result.folders });
|
for (const inst of connected) {
|
||||||
} catch (err) {
|
targets.push({ origin: inst.origin, apiClient: inst.api });
|
||||||
// If true home is remote and unreachable, fall back to browsing instance
|
|
||||||
if (homeOrigin) {
|
|
||||||
console.warn(`Layout save to home (${homeOrigin}) failed, falling back to local:`, err);
|
|
||||||
try {
|
|
||||||
const result = await api.spaceLayout.update({ items, folders });
|
|
||||||
set({ spaceLayout: result.items, folders: result.folders });
|
|
||||||
} catch (fallbackErr) {
|
|
||||||
console.error('Failed to save space layout:', fallbackErr);
|
|
||||||
}
|
}
|
||||||
} else {
|
} catch { /* instanceStore not available yet */ }
|
||||||
console.error('Failed to save space layout:', err);
|
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
targets.map(t => t.apiClient.spaceLayout.update({ items, folders, updatedAt: now }))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use the first successful response to resolve new:* IDs
|
||||||
|
for (const result of results) {
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
const resolved = result.value;
|
||||||
|
set({
|
||||||
|
spaceLayout: resolved.items,
|
||||||
|
folders: resolved.folders,
|
||||||
|
_layoutUpdatedAt: resolved.updatedAt ?? now,
|
||||||
|
});
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
populateFromReady: (origin: string, spaces: SpaceWithChannelsAndMembers[], folders?: SpaceFolder[], dmChannels?: DmChannel[], spaceLayout?: SpaceLayoutItem[] | null) => {
|
populateFromReady: (origin: string, spaces: SpaceWithChannelsAndMembers[], folders?: SpaceFolder[], dmChannels?: DmChannel[], spaceLayout?: SpaceLayoutItem[] | null, layoutUpdatedAt?: number) => {
|
||||||
const isHome = !origin;
|
const isHome = !origin;
|
||||||
|
|
||||||
// Tag all incoming servers with their instance origin
|
// Tag all incoming servers with their instance origin
|
||||||
@@ -568,26 +605,19 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
categoryOriginMap,
|
categoryOriginMap,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Determine if this origin is the user's true home (federation-aware)
|
// LWW layout merge: accept incoming layout only if its timestamp is >= ours
|
||||||
const currentUser = useAuthStore.getState().user;
|
const incomingTs = layoutUpdatedAt ?? 0;
|
||||||
const isTrueHome = !!currentUser?.homeInstance && origin !== '' && (() => {
|
const currentTs = get()._layoutUpdatedAt;
|
||||||
try { return new URL(origin).host === currentUser.homeInstance; } catch { return false; }
|
if (incomingTs >= currentTs) {
|
||||||
})();
|
// Incoming is same age or newer — accept
|
||||||
|
|
||||||
// Accept layout from true home (authoritative) or browsing instance (fallback)
|
|
||||||
if (isTrueHome) {
|
|
||||||
// Authoritative: true home always wins
|
|
||||||
update.folders = folders || [];
|
|
||||||
if (spaceLayout !== undefined) {
|
|
||||||
update.spaceLayout = spaceLayout ?? null;
|
|
||||||
}
|
|
||||||
update._layoutFromTrueHome = true;
|
|
||||||
} else if (isHome && !get()._layoutFromTrueHome) {
|
|
||||||
// Fallback: browsing instance's layout, only until true home connects
|
|
||||||
update.folders = folders || [];
|
update.folders = folders || [];
|
||||||
if (spaceLayout !== undefined) {
|
if (spaceLayout !== undefined) {
|
||||||
update.spaceLayout = spaceLayout ?? null;
|
update.spaceLayout = spaceLayout ?? null;
|
||||||
}
|
}
|
||||||
|
(update as any)._layoutUpdatedAt = incomingTs;
|
||||||
|
} else {
|
||||||
|
// Our layout is newer — push back to this instance
|
||||||
|
pushLayoutToOrigin(origin, get().spaceLayout, get().folders, currentTs);
|
||||||
}
|
}
|
||||||
|
|
||||||
set(update as any);
|
set(update as any);
|
||||||
@@ -706,16 +736,6 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the removed origin was the true home, reset the layout authority flag
|
|
||||||
// so the browsing instance's layout can serve as fallback again
|
|
||||||
const currentUser = useAuthStore.getState().user;
|
|
||||||
let resetLayoutFlag = false;
|
|
||||||
if (currentUser?.homeInstance && origin !== '') {
|
|
||||||
try {
|
|
||||||
resetLayoutFlag = new URL(origin).host === currentUser.homeInstance;
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
spaces: remainingSpaces,
|
spaces: remainingSpaces,
|
||||||
channelToSpaceMap,
|
channelToSpaceMap,
|
||||||
@@ -726,7 +746,6 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
currentSpaceId: remainingSpaces.find(s => s.id === state.currentSpaceId)
|
currentSpaceId: remainingSpaces.find(s => s.id === state.currentSpaceId)
|
||||||
? state.currentSpaceId
|
? state.currentSpaceId
|
||||||
: null,
|
: null,
|
||||||
...(resetLayoutFlag ? { _layoutFromTrueHome: false } : {}),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,29 +1,52 @@
|
|||||||
import type { UpdateUserRequest } from '@backspace/shared';
|
import type { UpdateUserRequest } from '@backspace/shared';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../stores/authStore';
|
||||||
import { useInstanceStore, type ConnectedInstance } from '../stores/instanceStore';
|
import { useInstanceStore, type ConnectedInstance } from '../stores/instanceStore';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
|
||||||
// ─── Internal helper ────────────────────────────────────────────────────────
|
// ─── Internal helper ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function downloadHomeAsset(filename: string): Promise<Blob> {
|
async function downloadAsset(filename: string, origin?: string): Promise<Blob> {
|
||||||
if (filename.startsWith('http') || filename.startsWith('blob:')) {
|
if (filename.startsWith('http') || filename.startsWith('blob:')) {
|
||||||
const res = await fetch(filename);
|
const res = await fetch(filename);
|
||||||
return res.blob();
|
return res.blob();
|
||||||
}
|
}
|
||||||
const res = await fetch(`/api/uploads/${filename}`);
|
const base = origin ? `${origin}/api/uploads/${filename}` : `/api/uploads/${filename}`;
|
||||||
|
const res = await fetch(base);
|
||||||
return res.blob();
|
return res.blob();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Full profile sync (connect / reconnect) ────────────────────────────────
|
// ─── Full profile sync (connect / reconnect) ────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Push the entire home profile to a single remote instance.
|
* Bidirectional profile sync with a single remote instance using LWW timestamps.
|
||||||
* Called on initial connect, reconnect, and login-to-remote.
|
* - If home is newer (or both equal): push home → remote (existing behavior)
|
||||||
|
* - If remote is newer: pull remote → home, then relay to all other remotes
|
||||||
|
* - If equal: no-op
|
||||||
*/
|
*/
|
||||||
export async function syncProfileToRemote(inst: ConnectedInstance): Promise<void> {
|
export async function syncProfileToRemote(inst: ConnectedInstance): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const homeUser = useAuthStore.getState().user;
|
const homeUser = useAuthStore.getState().user;
|
||||||
if (!homeUser) return;
|
if (!homeUser) return;
|
||||||
|
|
||||||
|
const homeTs = homeUser.profileUpdatedAt ?? 0;
|
||||||
|
const remoteTs = inst.user?.profileUpdatedAt ?? 0;
|
||||||
|
|
||||||
|
if (homeTs >= remoteTs) {
|
||||||
|
// Home is newer (or equal) — push to remote
|
||||||
|
await pushProfileToRemote(inst, homeUser);
|
||||||
|
} else {
|
||||||
|
// Remote is newer — pull from remote to home, then relay
|
||||||
|
await pullProfileFromRemote(inst);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[ProfileSync] Full sync with ${inst.origin} failed:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push the home profile to a single remote instance.
|
||||||
|
*/
|
||||||
|
async function pushProfileToRemote(inst: ConnectedInstance, homeUser: NonNullable<ReturnType<typeof useAuthStore.getState>['user']>): Promise<void> {
|
||||||
const payload: UpdateUserRequest = {
|
const payload: UpdateUserRequest = {
|
||||||
displayName: homeUser.displayName || undefined,
|
displayName: homeUser.displayName || undefined,
|
||||||
avatarColor: homeUser.avatarColor || undefined,
|
avatarColor: homeUser.avatarColor || undefined,
|
||||||
@@ -31,12 +54,13 @@ export async function syncProfileToRemote(inst: ConnectedInstance): Promise<void
|
|||||||
bio: homeUser.bio || undefined,
|
bio: homeUser.bio || undefined,
|
||||||
customStatus: homeUser.customStatus || undefined,
|
customStatus: homeUser.customStatus || undefined,
|
||||||
status: homeUser.status || undefined,
|
status: homeUser.status || undefined,
|
||||||
|
profileUpdatedAt: homeUser.profileUpdatedAt,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sync avatar
|
// Sync avatar
|
||||||
if (homeUser.avatar) {
|
if (homeUser.avatar) {
|
||||||
try {
|
try {
|
||||||
const blob = await downloadHomeAsset(homeUser.avatar);
|
const blob = await downloadAsset(homeUser.avatar);
|
||||||
const attachment = await inst.api.uploads.upload(new File([blob], homeUser.avatar));
|
const attachment = await inst.api.uploads.upload(new File([blob], homeUser.avatar));
|
||||||
payload.avatar = attachment.filename;
|
payload.avatar = attachment.filename;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -49,7 +73,7 @@ export async function syncProfileToRemote(inst: ConnectedInstance): Promise<void
|
|||||||
// Sync banner
|
// Sync banner
|
||||||
if (homeUser.banner) {
|
if (homeUser.banner) {
|
||||||
try {
|
try {
|
||||||
const blob = await downloadHomeAsset(homeUser.banner);
|
const blob = await downloadAsset(homeUser.banner);
|
||||||
const attachment = await inst.api.uploads.upload(new File([blob], homeUser.banner));
|
const attachment = await inst.api.uploads.upload(new File([blob], homeUser.banner));
|
||||||
payload.banner = attachment.filename;
|
payload.banner = attachment.filename;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -60,8 +84,62 @@ export async function syncProfileToRemote(inst: ConnectedInstance): Promise<void
|
|||||||
}
|
}
|
||||||
|
|
||||||
await inst.api.users.update(payload);
|
await inst.api.users.update(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull a remote instance's newer profile to the browsing (home) instance,
|
||||||
|
* then relay to all other connected remotes.
|
||||||
|
*/
|
||||||
|
async function pullProfileFromRemote(inst: ConnectedInstance): Promise<void> {
|
||||||
|
const remoteUser = inst.user;
|
||||||
|
if (!remoteUser) return;
|
||||||
|
|
||||||
|
const payload: UpdateUserRequest = {
|
||||||
|
displayName: remoteUser.displayName || undefined,
|
||||||
|
avatarColor: remoteUser.avatarColor || undefined,
|
||||||
|
accentColor: remoteUser.accentColor || undefined,
|
||||||
|
bio: remoteUser.bio || undefined,
|
||||||
|
customStatus: remoteUser.customStatus || undefined,
|
||||||
|
profileUpdatedAt: remoteUser.profileUpdatedAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Download and re-upload avatar from remote → home
|
||||||
|
if (remoteUser.avatar) {
|
||||||
|
try {
|
||||||
|
const blob = await downloadAsset(remoteUser.avatar, inst.origin);
|
||||||
|
const attachment = await api.uploads.upload(new File([blob], remoteUser.avatar.split('/').pop() || 'avatar'));
|
||||||
|
payload.avatar = attachment.filename;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`[ProfileSync] Full sync to ${inst.origin} failed:`, err);
|
console.warn('[ProfileSync] Failed to download/upload avatar from remote:', err);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
payload.avatar = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download and re-upload banner from remote → home
|
||||||
|
if (remoteUser.banner) {
|
||||||
|
try {
|
||||||
|
const blob = await downloadAsset(remoteUser.banner, inst.origin);
|
||||||
|
const attachment = await api.uploads.upload(new File([blob], remoteUser.banner.split('/').pop() || 'banner'));
|
||||||
|
payload.banner = attachment.filename;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[ProfileSync] Failed to download/upload banner from remote:', err);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
payload.banner = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH browsing instance (home) with the remote's newer data
|
||||||
|
const updatedUser = await api.users.update(payload);
|
||||||
|
useAuthStore.getState().setUser(updatedUser);
|
||||||
|
|
||||||
|
// Relay to all OTHER connected remotes (exclude the source)
|
||||||
|
const { instances } = useInstanceStore.getState();
|
||||||
|
const otherConnected = instances.filter(i => i.status === 'connected' && i.origin !== inst.origin);
|
||||||
|
if (otherConnected.length > 0) {
|
||||||
|
await Promise.allSettled(
|
||||||
|
otherConnected.map(other => pushProfileToRemote(other, updatedUser))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +166,9 @@ export async function syncProfileUpdateToRemotes(update: Partial<UpdateUserReque
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Include the LWW timestamp so remotes can reject stale writes
|
||||||
|
basePayload.profileUpdatedAt = useAuthStore.getState().user?.profileUpdatedAt;
|
||||||
|
|
||||||
// Pre-download file assets once (if they changed)
|
// Pre-download file assets once (if they changed)
|
||||||
let avatarBlob: Blob | null = null;
|
let avatarBlob: Blob | null = null;
|
||||||
let avatarFilename: string | null = null;
|
let avatarFilename: string | null = null;
|
||||||
@@ -97,7 +178,7 @@ export async function syncProfileUpdateToRemotes(update: Partial<UpdateUserReque
|
|||||||
if ('avatar' in update) {
|
if ('avatar' in update) {
|
||||||
if (update.avatar) {
|
if (update.avatar) {
|
||||||
try {
|
try {
|
||||||
avatarBlob = await downloadHomeAsset(update.avatar);
|
avatarBlob = await downloadAsset(update.avatar);
|
||||||
avatarFilename = update.avatar;
|
avatarFilename = update.avatar;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[ProfileSync] Failed to download avatar for sync:', err);
|
console.warn('[ProfileSync] Failed to download avatar for sync:', err);
|
||||||
@@ -108,7 +189,7 @@ export async function syncProfileUpdateToRemotes(update: Partial<UpdateUserReque
|
|||||||
if ('banner' in update) {
|
if ('banner' in update) {
|
||||||
if (update.banner) {
|
if (update.banner) {
|
||||||
try {
|
try {
|
||||||
bannerBlob = await downloadHomeAsset(update.banner);
|
bannerBlob = await downloadAsset(update.banner);
|
||||||
bannerFilename = update.banner;
|
bannerFilename = update.banner;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[ProfileSync] Failed to download banner for sync:', err);
|
console.warn('[ProfileSync] Failed to download banner for sync:', err);
|
||||||
|
|||||||
Reference in New Issue
Block a user