fix: repair corrupted group DMs, prevent ownerId nulling, show empty groups
Three fixes for group DM data integrity and display: 1. processOwnershipTransferEvent: use resolveOrCreateReplicatedUser instead of resolveLocalUser to guarantee a valid ownerId. The previous ?? null fallback converted group DMs into 1-on-1s when resolution failed. 2. Self-healing migration: detect group DMs with UUID-format federated_id but NULL owner_id (corrupted by the old fallback) and restore owner from the first remaining member. Found and repaired 7 across both instances. 3. Sidebar: group DMs with 0 other members (last person standing) now show as "Empty Group" instead of being hidden. 1-on-1 DMs with 0 others are still correctly filtered out.
This commit is contained in:
@@ -635,6 +635,37 @@ export function runMigrations(db: Database.Database): void {
|
|||||||
db.exec(`ALTER TABLE federation_peers ADD COLUMN remote_max_upload_size INTEGER`);
|
db.exec(`ALTER TABLE federation_peers ADD COLUMN remote_max_upload_size INTEGER`);
|
||||||
} catch { /* column already exists */ }
|
} catch { /* column already exists */ }
|
||||||
|
|
||||||
|
// ─── Data integrity: repair group DMs with nulled-out owner_id ───────────
|
||||||
|
// A bug in processOwnershipTransferEvent (fixed in cd7aff0) could set
|
||||||
|
// owner_id to NULL when resolveLocalUser failed, converting a group DM into
|
||||||
|
// a 1-on-1-looking channel. Detect these by finding dm_channels with a
|
||||||
|
// UUID-format federated_id (group DMs) but NULL owner_id, and restore the
|
||||||
|
// owner from the first remaining member.
|
||||||
|
const corruptedGroups = db.prepare(`
|
||||||
|
SELECT c.id, c.federated_id
|
||||||
|
FROM dm_channels c
|
||||||
|
WHERE c.owner_id IS NULL
|
||||||
|
AND c.federated_id IS NOT NULL
|
||||||
|
AND c.deleted_at IS NULL
|
||||||
|
AND length(c.federated_id) = 36
|
||||||
|
AND c.federated_id LIKE '________-____-____-____-____________'
|
||||||
|
`).all() as Array<{ id: string; federated_id: string }>;
|
||||||
|
|
||||||
|
for (const ch of corruptedGroups) {
|
||||||
|
const firstMember = db.prepare(
|
||||||
|
`SELECT user_id FROM dm_members WHERE dm_channel_id = ? LIMIT 1`
|
||||||
|
).get(ch.id) as { user_id: string } | undefined;
|
||||||
|
|
||||||
|
if (firstMember) {
|
||||||
|
db.prepare(`UPDATE dm_channels SET owner_id = ? WHERE id = ?`).run(firstMember.user_id, ch.id);
|
||||||
|
console.log(`[migration] Repaired group DM ${ch.id}: restored owner_id to ${firstMember.user_id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (corruptedGroups.length > 0) {
|
||||||
|
console.log(`[migration] Repaired ${corruptedGroups.length} corrupted group DM(s).`);
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Migrations complete.');
|
console.log('Migrations complete.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1967,12 +1967,18 @@ function processOwnershipTransferEvent(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve new owner to local user (for local ownerId)
|
// Resolve new owner to local user — use resolveOrCreateReplicatedUser to
|
||||||
const newOwnerLocal = resolveLocalUser(event.ownership.newOwner.homeUserId, db);
|
// guarantee we always get a valid user ID. Never fall back to null, as that
|
||||||
|
// would convert the group DM into a 1-on-1 and destroy its type identity.
|
||||||
|
const newOwnerLocal = resolveOrCreateReplicatedUser(
|
||||||
|
event.ownership.newOwner.homeUserId,
|
||||||
|
event.ownership.newOwner.homeInstance,
|
||||||
|
db,
|
||||||
|
);
|
||||||
|
|
||||||
db.update(schema.dmChannels)
|
db.update(schema.dmChannels)
|
||||||
.set({
|
.set({
|
||||||
ownerId: newOwnerLocal?.id ?? null,
|
ownerId: newOwnerLocal.id,
|
||||||
ownerHomeUserId: event.ownership.newOwner.homeUserId,
|
ownerHomeUserId: event.ownership.newOwner.homeUserId,
|
||||||
ownerHomeInstance: event.ownership.newOwner.homeInstance,
|
ownerHomeInstance: event.ownership.newOwner.homeInstance,
|
||||||
})
|
})
|
||||||
@@ -1982,7 +1988,7 @@ function processOwnershipTransferEvent(
|
|||||||
connectionManager.sendToDmMembers(channel.id, {
|
connectionManager.sendToDmMembers(channel.id, {
|
||||||
type: 'dm_owner_updated',
|
type: 'dm_owner_updated',
|
||||||
dmChannelId: channel.id,
|
dmChannelId: channel.id,
|
||||||
newOwnerId: newOwnerLocal?.id ?? event.ownership.newOwner.homeUserId,
|
newOwnerId: newOwnerLocal.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const prevOwnerLocal = event.ownership.previousOwner
|
const prevOwnerLocal = event.ownership.previousOwner
|
||||||
@@ -1998,8 +2004,8 @@ function processOwnershipTransferEvent(
|
|||||||
userId: prevOwnerId,
|
userId: prevOwnerId,
|
||||||
content: JSON.stringify({
|
content: JSON.stringify({
|
||||||
event: 'owner_changed',
|
event: 'owner_changed',
|
||||||
newOwnerId: newOwnerLocal?.id ?? event.ownership.newOwner.homeUserId,
|
newOwnerId: newOwnerLocal.id,
|
||||||
newOwnerDisplayName: newOwnerLocal?.displayName ?? newOwnerBaseName,
|
newOwnerDisplayName: newOwnerLocal.displayName ?? newOwnerBaseName,
|
||||||
}),
|
}),
|
||||||
type: 'system',
|
type: 'system',
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
|
|||||||
@@ -489,14 +489,16 @@ export function ChannelSidebar() {
|
|||||||
<div className="space-y-[2px]">
|
<div className="space-y-[2px]">
|
||||||
{dmChannels.map((dm) => {
|
{dmChannels.map((dm) => {
|
||||||
const otherMembers = dm.members.filter(m => !isSelf(m, user));
|
const otherMembers = dm.members.filter(m => !isSelf(m, user));
|
||||||
if (otherMembers.length === 0) return null;
|
|
||||||
const isGroup = !!dm.ownerId;
|
const isGroup = !!dm.ownerId;
|
||||||
|
if (otherMembers.length === 0 && !isGroup) return null;
|
||||||
const isDmUnread = unreadChannels.has(dm.id) && currentChannelId !== dm.id;
|
const isDmUnread = unreadChannels.has(dm.id) && currentChannelId !== dm.id;
|
||||||
|
|
||||||
const firstOtherDm = isGroup ? null : otherMembers[0];
|
const firstOtherDm = isGroup ? null : otherMembers[0];
|
||||||
const { baseName: dmBaseName, domain: dmDomain } = parseFederatedUsername(firstOtherDm?.username ?? '');
|
const { baseName: dmBaseName, domain: dmDomain } = parseFederatedUsername(firstOtherDm?.username ?? '');
|
||||||
const dmDisplayName = isGroup
|
const dmDisplayName = isGroup
|
||||||
? otherMembers.map(m => m.displayName ?? parseFederatedUsername(m.username).baseName).join(', ')
|
? (otherMembers.length > 0
|
||||||
|
? otherMembers.map(m => m.displayName ?? parseFederatedUsername(m.username).baseName).join(', ')
|
||||||
|
: 'Empty Group')
|
||||||
: firstOtherDm?.displayName ?? dmBaseName;
|
: firstOtherDm?.displayName ?? dmBaseName;
|
||||||
|
|
||||||
const dmItem = (
|
const dmItem = (
|
||||||
|
|||||||
@@ -59,10 +59,12 @@ export function DmSearchBar() {
|
|||||||
return dmChannels
|
return dmChannels
|
||||||
.map((dm): DmItem | null => {
|
.map((dm): DmItem | null => {
|
||||||
const otherMembers = dm.members.filter(m => !isSelf(m, user));
|
const otherMembers = dm.members.filter(m => !isSelf(m, user));
|
||||||
if (otherMembers.length === 0) return null;
|
|
||||||
const isGroup = !!dm.ownerId;
|
const isGroup = !!dm.ownerId;
|
||||||
|
if (otherMembers.length === 0 && !isGroup) return null;
|
||||||
const displayName = isGroup
|
const displayName = isGroup
|
||||||
? otherMembers.map(m => m.displayName ?? parseFederatedUsername(m.username).baseName).join(', ')
|
? (otherMembers.length > 0
|
||||||
|
? otherMembers.map(m => m.displayName ?? parseFederatedUsername(m.username).baseName).join(', ')
|
||||||
|
: 'Empty Group')
|
||||||
: otherMembers[0]?.displayName ?? otherMembers[0]?.username ?? '';
|
: otherMembers[0]?.displayName ?? otherMembers[0]?.username ?? '';
|
||||||
return { type: 'dm', dm, displayName, otherMembers, isGroup };
|
return { type: 'dm', dm, displayName, otherMembers, isGroup };
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user