fix: DM dual-path delivery, poll votes, and guild switch reset

Relay DM messages over contacts when the DM topic is asymmetric, rebind
message scope for reactions/polls, always join contacts mesh, fully close
prior guilds on switch, and refresh roster/channels/presence when guild id
changes.
This commit is contained in:
Raven Scott
2026-07-13 01:44:34 -04:00
parent 54f5ea083a
commit 63eeb4f1a8
8 changed files with 280 additions and 27 deletions
@@ -38,6 +38,7 @@ function buildGuildChannelsSignature (view) {
function evalGuildChannelsNeedsRefresh (prevView, nextView) { function evalGuildChannelsNeedsRefresh (prevView, nextView) {
if (nextView?.mode !== 'guild' || nextView?.guildLoading) return false if (nextView?.mode !== 'guild' || nextView?.guildLoading) return false
if ((prevView?.guild?.id || '') !== (nextView?.guild?.id || '')) return true
const prevLen = prevView?.channels?.length || 0 const prevLen = prevView?.channels?.length || 0
const nextLen = nextView?.channels?.length || 0 const nextLen = nextView?.channels?.length || 0
if (prevLen !== nextLen) return true if (prevLen !== nextLen) return true
@@ -50,6 +51,7 @@ function evalGuildUnreadNeedsRefresh (prevView, nextView, hashFns) {
} }
function evalGuildActiveMessagesNeedsRefresh (prevView, nextView) { function evalGuildActiveMessagesNeedsRefresh (prevView, nextView) {
if ((prevView?.guild?.id || '') !== (nextView?.guild?.id || '')) return true
if (prevView?.activeChannelId !== nextView?.activeChannelId) return true if (prevView?.activeChannelId !== nextView?.activeChannelId) return true
const prevLen = prevView?.messages?.length || 0 const prevLen = prevView?.messages?.length || 0
const nextLen = nextView?.messages?.length || 0 const nextLen = nextView?.messages?.length || 0
@@ -74,6 +76,7 @@ function evalGuildPresenceNeedsRefresh (prevView, nextView, hashFns) {
if (nextView?.mode !== 'guild' || nextView?.guildLoading) return false if (nextView?.mode !== 'guild' || nextView?.guildLoading) return false
const gid = nextView.guild?.id const gid = nextView.guild?.id
if (!gid) return false if (!gid) return false
if ((prevView?.guild?.id || '') !== gid) return true
const prevSig = hashFns.hashPresencePeers( const prevSig = hashFns.hashPresencePeers(
prevView?.presence?.peers, prevView?.presence?.peers,
prevView?.guildPresenceSummary?.[gid] prevView?.guildPresenceSummary?.[gid]
@@ -12,13 +12,29 @@ function listMeshStabilityUiMemberRosterRefreshFieldKeys () {
return meshStabilityUiMemberRosterRefreshFieldKeys.slice() return meshStabilityUiMemberRosterRefreshFieldKeys.slice()
} }
function memberRosterIdentitySignature (view) {
return (view?.members || [])
.map((m) => {
const roles = Array.isArray(m.customRoleIds)
? m.customRoleIds.join('.')
: String(m.roles || '')
return `${m.userId}:${m.nickname || ''}:${roles}`
})
.join('|')
}
function evalMemberRosterNeedsRefresh (prevView, nextView) { function evalMemberRosterNeedsRefresh (prevView, nextView) {
if (nextView?.mode !== 'guild') return false if (nextView?.mode !== 'guild') return false
// Always rebuild when the active server changes (same member count used to skip this).
if ((prevView?.guild?.id || '') !== (nextView?.guild?.id || '')) return true
if (prevView?.guildLoading && !nextView?.guildLoading) return true if (prevView?.guildLoading && !nextView?.guildLoading) return true
if (nextView?.guildLoading) return false if (nextView?.guildLoading) return false
const prevLen = prevView?.members?.length || 0 const prevLen = prevView?.members?.length || 0
const nextLen = nextView?.members?.length || 0 const nextLen = nextView?.members?.length || 0
if (prevLen !== nextLen) return true if (prevLen !== nextLen) return true
if (memberRosterIdentitySignature(prevView) !== memberRosterIdentitySignature(nextView)) {
return true
}
const pr = prevView?.memberRosterSync const pr = prevView?.memberRosterSync
const nr = nextView?.memberRosterSync const nr = nextView?.memberRosterSync
if (!pr && !nr) return false if (!pr && !nr) return false
+10 -3
View File
@@ -338,9 +338,12 @@ const pollSchedulingMixin = {
async votePoll (messageId, optionIndex, voted = null) { async votePoll (messageId, optionIndex, voted = null) {
if (this.mode === 'dm') return this.voteDmPoll(messageId, optionIndex, voted) if (this.mode === 'dm') return this.voteDmPoll(messageId, optionIndex, voted)
const channelId = this.activeChannelId const bound = typeof this._bindActiveMessageScope === 'function'
? this._bindActiveMessageScope()
: null
const channelId = bound?.channelId || this.activeChannelId
const userId = this.identity?.user?.id const userId = this.identity?.user?.id
const guildId = this.guild?.guild?.id const guildId = bound?.guildId || this.guild?.guild?.id
if (!channelId || !userId || !guildId) throw new Error('no active guild channel') if (!channelId || !userId || !guildId) throw new Error('no active guild channel')
const span = this.log.time('poll.vote', { const span = this.log.time('poll.vote', {
spanKind: 'poll.vote', spanKind: 'poll.vote',
@@ -349,7 +352,10 @@ const pollSchedulingMixin = {
channelId channelId
}) })
try { try {
await this._assertPollPostPermission() // Voting only needs view/send participation — do not require manage/post perms.
await this._assertCanParticipate('reaction').catch(() =>
this._assertCanParticipate('send')
)
const msg = await this.db.get(COLLECTIONS.MESSAGES, { channelId, id: messageId }) const msg = await this.db.get(COLLECTIONS.MESSAGES, { channelId, id: messageId })
if (!msg) throw new Error('poll message not found') if (!msg) throw new Error('poll message not found')
const poll = parseDmPollMessageContent(this._messagePlaintext(msg) || msg.content) const poll = parseDmPollMessageContent(this._messagePlaintext(msg) || msg.content)
@@ -359,6 +365,7 @@ const pollSchedulingMixin = {
if (!voteEmoji) throw new Error('invalid poll option') if (!voteEmoji) throw new Error('invalid poll option')
const allowed = new Set(poll.options.map((o) => o.emoji)) const allowed = new Set(poll.options.map((o) => o.emoji))
if (!allowed.has(voteEmoji)) throw new Error('poll option out of bounds') if (!allowed.has(voteEmoji)) throw new Error('poll option out of bounds')
this._bindActiveMessageScope?.()
const rows = await this.messages.listReactions() const rows = await this.messages.listReactions()
const hasTarget = rows.some( const hasTarget = rows.some(
(r) => r.messageId === messageId && r.emoji === voteEmoji && r.userId === userId (r) => r.messageId === messageId && r.emoji === voteEmoji && r.userId === userId
@@ -601,6 +601,11 @@ async _sendPlainMessage (content, opts = {}) {
const previewPlain = const previewPlain =
this._messagePlaintext(msg) || (linked.length ? '📎 Attachment' : '') this._messagePlaintext(msg) || (linked.length ? '📎 Attachment' : '')
await dm.touchLastMessage(msg.channelId, { ...msg, content: previewPlain }) await dm.touchLastMessage(msg.channelId, { ...msg, content: previewPlain })
}
// Dual-path: DM topic swarm + contacts mesh (fixes 0-peer / asymmetric delivery).
if (typeof this._relayDmGossip === 'function') {
await this._relayDmGossip(sharedScope.RPC.MESSAGE_CREATE, gossipMsg)
} else if (dm) {
dm.gossipMessage(gossipMsg) dm.gossipMessage(gossipMsg)
} }
this._queueLinkEmbed(msg).catch(() => {}) this._queueLinkEmbed(msg).catch(() => {})
@@ -817,8 +822,11 @@ async signalTyping () {
guildId: this.mode === 'dm' ? dmScope.DM_GUILD_ID : this.guild?.guild?.id, guildId: this.mode === 'dm' ? dmScope.DM_GUILD_ID : this.guild?.guild?.id,
at: Date.now() at: Date.now()
} }
if (this.mode === 'dm' && this.dm) this.dm.gossipTyping(payload) if (this.mode === 'dm') {
else if (this.mode === 'guild' && this.guild) this.guild.gossipTyping(payload) if (typeof this._relayDmGossip === 'function') {
void this._relayDmGossip(sharedScope.RPC.TYPING_START, payload)
} else if (this.dm) this.dm.gossipTyping(payload)
} else if (this.mode === 'guild' && this.guild) this.guild.gossipTyping(payload)
this.log.debug('typing.signal', { this.log.debug('typing.signal', {
channelId: payload.channelId, channelId: payload.channelId,
mode: this.mode mode: this.mode
@@ -872,8 +880,11 @@ async editMessage (messageId, content) {
} else if (nextUrl && !priorUrl) { } else if (nextUrl && !priorUrl) {
this._queueLinkEmbed(msg).catch(() => {}) this._queueLinkEmbed(msg).catch(() => {})
} }
if (this.mode === 'dm' && this.dm) this.dm.gossipMessageUpdate(msg) if (this.mode === 'dm') {
else if (this.mode === 'guild' && this.guild) this.guild.gossipMessageUpdate(msg) if (typeof this._relayDmGossip === 'function') {
void this._relayDmGossip(sharedScope.RPC.MESSAGE_UPDATE, msg)
} else if (this.dm) this.dm.gossipMessageUpdate(msg)
} else if (this.mode === 'guild' && this.guild) this.guild.gossipMessageUpdate(msg)
if (this.mode === 'guild' && this.guild?.guild?.id && channelId) { if (this.mode === 'guild' && this.guild?.guild?.id && channelId) {
const ch = await this.db.get(sharedScope.COLLECTIONS.CHANNELS, { const ch = await this.db.get(sharedScope.COLLECTIONS.CHANNELS, {
guildId: this.guild.guild.id, guildId: this.guild.guild.id,
@@ -1186,8 +1197,15 @@ async unpinMessage (messageId) {
}, },
async toggleReaction (messageId, emoji) { async toggleReaction (messageId, emoji) {
const guildId = this.mode === 'dm' ? dmScope.DM_GUILD_ID : this.guild?.guild?.id || null // Poll votes and reactions must bind the active channel first — isolation races
const channelId = this.activeChannelId // can leave messages.channelId null/stale and throw "channel not set".
const bound = typeof this._bindActiveMessageScope === 'function'
? this._bindActiveMessageScope()
: null
const guildId =
bound?.guildId ||
(this.mode === 'dm' ? dmScope.DM_GUILD_ID : this.guild?.guild?.id || null)
const channelId = bound?.channelId || this.activeChannelId
const emojiKey = String(emoji || '').slice(0, 16) const emojiKey = String(emoji || '').slice(0, 16)
const span = this.log.time('reaction.toggle', { const span = this.log.time('reaction.toggle', {
spanKind: 'reaction.toggle', spanKind: 'reaction.toggle',
@@ -1199,6 +1217,7 @@ async toggleReaction (messageId, emoji) {
try { try {
const user = this.identity.user const user = this.identity.user
if (!user) throw new Error('register first') if (!user) throw new Error('register first')
if (!channelId || !this.messages?.channelId) throw new Error('channel not set')
if (this.mode === 'guild') await this._assertCanParticipate('reaction') if (this.mode === 'guild') await this._assertCanParticipate('reaction')
if (!this._checkReactionToggleRateLimit(channelId)) { if (!this._checkReactionToggleRateLimit(channelId)) {
throw new Error('reaction rate limited') throw new Error('reaction rate limited')
@@ -1211,17 +1230,24 @@ async toggleReaction (messageId, emoji) {
r.messageId === messageId && r.emoji === emojiKey && r.userId === user.id r.messageId === messageId && r.emoji === emojiKey && r.userId === user.id
) )
} catch (_) {} } catch (_) {}
this._bindActiveMessageScope?.()
const result = await this.messages.toggleReaction(messageId, emoji, user.id) const result = await this.messages.toggleReaction(messageId, emoji, user.id)
let gossipSent = false let gossipSent = false
if (this._shouldGossipReaction(result)) { if (this._shouldGossipReaction(result)) {
if (this.mode === 'dm' && this.dm?.gossipReaction) { if (this.mode === 'dm') {
this.dm.gossipReaction({ const reactionPayload = {
...result, ...result,
guildId: dmScope.DM_GUILD_ID, guildId: dmScope.DM_GUILD_ID,
channelId: this.activeChannelId, channelId: this.activeChannelId || this.messages?.channelId,
createdAt: result.createdAt || Date.now() createdAt: result.createdAt || Date.now()
}) }
if (typeof this._relayDmGossip === 'function') {
void this._relayDmGossip(sharedScope.RPC.REACTION_TOGGLE, reactionPayload)
gossipSent = true gossipSent = true
} else if (this.dm?.gossipReaction) {
this.dm.gossipReaction(reactionPayload)
gossipSent = true
}
} else if (this.mode !== 'dm' && this.guild) { } else if (this.mode !== 'dm' && this.guild) {
const gossipSpan = this.log.time('reaction.gossip', { const gossipSpan = this.log.time('reaction.gossip', {
spanKind: 'reaction.gossip', spanKind: 'reaction.gossip',
@@ -1463,8 +1489,11 @@ async deleteMessage (messageId) {
channelId, channelId,
guildId guildId
} }
if (this.mode === 'dm' && this.dm) this.dm.gossipMessageDelete(payload) if (this.mode === 'dm') {
else if (this.mode === 'guild' && this.guild) this.guild.gossipMessageDelete(payload) if (typeof this._relayDmGossip === 'function') {
void this._relayDmGossip(sharedScope.RPC.MESSAGE_DELETE, payload)
} else if (this.dm) this.dm.gossipMessageDelete(payload)
} else if (this.mode === 'guild' && this.guild) this.guild.gossipMessageDelete(payload)
if (this.mode === 'guild' && this.guild?.guild?.id) { if (this.mode === 'guild' && this.guild?.guild?.id) {
await this._removeMessageFromSearchIndex(messageId, this.guild.guild.id).catch(() => {}) await this._removeMessageFromSearchIndex(messageId, this.guild.guild.id).catch(() => {})
} }
@@ -32,7 +32,8 @@ const {
/** Phase 756 — folders, prefs, notifications, compose drafts */ /** Phase 756 — folders, prefs, notifications, compose drafts */
const platformSettingsFoldersPrefsMixin = { const platformSettingsFoldersPrefsMixin = {
async _joinContactsMesh () { async _joinContactsMesh () {
if (this._localSharedDht) return // Always create/join contacts (including on PEARCORD_LOCAL_DHT). Skipping the mesh
// under local DHT left contacts=null and broke DM dual-path relay + friend DMs in tests.
if (!this.onboarded || !this.identity?.keyPair || !this.identity?.user) return if (!this.onboarded || !this.identity?.keyPair || !this.identity?.user) return
if (!this.contacts) { if (!this.contacts) {
const snap = this.identity.snapshot() const snap = this.identity.snapshot()
@@ -68,6 +69,9 @@ async _joinContactsMesh () {
this.contacts.on('dm-channel', (ch) => { this.contacts.on('dm-channel', (ch) => {
void this._ingestRemoteDmChannel(ch).catch(() => {}) void this._ingestRemoteDmChannel(ch).catch(() => {})
}) })
this.contacts.on('dm-relay', (frame) => {
void this._ingestContactsDmRelay(frame).catch(() => {})
})
} }
const Hyperswarm = require('hyperswarm') const Hyperswarm = require('hyperswarm')
if (!this._contactsSwarm) { if (!this._contactsSwarm) {
+15 -3
View File
@@ -710,7 +710,7 @@ async view (opts = {}) {
let messageById = {} let messageById = {}
for (const m of messages) messageById[m.id] = m for (const m of messages) messageById[m.id] = m
const stats = this.mode === 'dm' const stats = this.mode === 'dm'
? this.dm?.getStats() || null ? (typeof this._dmViewStats === 'function' ? this._dmViewStats() : this.dm?.getStats() || null)
: this.guild?.getStats() || null : this.guild?.getStats() || null
const myPermissions = const myPermissions =
this.mode === 'guild' ? await this._myPermissions(this.activeChannelId) : null this.mode === 'guild' ? await this._myPermissions(this.activeChannelId) : null
@@ -2185,9 +2185,21 @@ async view (opts = {}) {
/* presence optional during teardown */ /* presence optional during teardown */
} }
} }
// Scope presence peers to the active guild roster so switched servers never
// show online dots for members that only existed on the previous mesh.
let presenceSnap = this.presence?.snapshot() || null
if (this.mode === 'guild' && presenceSnap && Array.isArray(members) && members.length) {
const memberIds = new Set(members.map((m) => m.userId).filter(Boolean))
presenceSnap = {
...presenceSnap,
peers: (presenceSnap.peers || []).filter((p) => p?.userId && memberIds.has(p.userId))
}
} else if (this.mode === 'guild' && presenceSnap) {
presenceSnap = { ...presenceSnap, peers: [] }
}
const activityFeed = buildActivityFeed({ const activityFeed = buildActivityFeed({
selfUserId: user?.id, selfUserId: user?.id,
presence: this.presence?.snapshot() || null, presence: presenceSnap,
members: members || [], members: members || [],
contacts: contactsForView contacts: contactsForView
}) })
@@ -2468,7 +2480,7 @@ async view (opts = {}) {
typingUsers: this.mode === 'guild' typingUsers: this.mode === 'guild'
? this._getChannelTypingUserIds(this.activeChannelId) ? this._getChannelTypingUserIds(this.activeChannelId)
: (this.dm?.getTypingUsers() || []), : (this.dm?.getTypingUsers() || []),
presence: this.presence?.snapshot() || null, presence: presenceSnap,
activityFeed, activityFeed,
stats, stats,
dbEngine: this.db.getEngine?.() || 'json', dbEngine: this.db.getEngine?.() || 'json',
@@ -156,7 +156,24 @@ async _ingestRemoteDmChannel (channel, opts = {}) {
guildId: dmScope.DM_GUILD_ID, guildId: dmScope.DM_GUILD_ID,
id: channel.id id: channel.id
}) })
const row = { ...existing, ...channel, guildId: dmScope.DM_GUILD_ID, id: channel.id } let row = { ...existing, ...channel, guildId: dmScope.DM_GUILD_ID, id: channel.id }
// Ensure a joinable topic so both peers land on the same Hyperswarm discovery key.
if (!row.topic) {
if (row.type === sharedScope.CHANNEL_TYPES.GROUP_DM || channel.isGroup) {
const ids = channel.participantIds || row.participantIds || []
if (ids.length >= 2 && sharedScope.groupDmTopic) {
row = { ...row, topic: sharedScope.groupDmTopic(ids) }
}
} else {
const peer =
channel.peerUserId ||
row.peerUserId ||
(channel.participantIds || row.participantIds || []).find((id) => id && id !== uid)
if (peer) {
row = { ...row, topic: sharedScope.dmTopic(uid, peer), peerUserId: peer }
}
}
}
await this.db.insert(sharedScope.COLLECTIONS.CHANNELS, row) await this.db.insert(sharedScope.COLLECTIONS.CHANNELS, row)
const metaStore = await this._ensureDmMetaStore() const metaStore = await this._ensureDmMetaStore()
const isGroup = row.type === sharedScope.CHANNEL_TYPES.GROUP_DM || channel.isGroup const isGroup = row.type === sharedScope.CHANNEL_TYPES.GROUP_DM || channel.isGroup
@@ -167,11 +184,16 @@ async _ingestRemoteDmChannel (channel, opts = {}) {
channelId: row.id, channelId: row.id,
isGroup: !!isGroup, isGroup: !!isGroup,
participantIds, participantIds,
peerUserId: channel.peerUserId || prev.peerUserId || null, peerUserId: channel.peerUserId || prev.peerUserId || row.peerUserId || null,
displayName: channel.name || prev.displayName || null displayName: channel.name || prev.displayName || null
}) })
const bg = await this._ensureDmBackgroundListener() const bg = await this._ensureDmBackgroundListener()
await bg.ensureTopicJoined(row) if (row.topic) {
await bg.ensureTopicJoined(row, { flush: true }).catch(() => {})
if ((bg.peers?.size ?? 0) === 0 && typeof bg.refreshActiveTopicDiscovery === 'function') {
await bg.refreshActiveTopicDiscovery().catch(() => {})
}
}
if (opts.emit !== false) this.emit('dm', row) if (opts.emit !== false) this.emit('dm', row)
return row return row
}, },
@@ -267,6 +289,14 @@ async openDM ({ peerUserId, peerDisplayName, sidebarFilterActive = false }) {
this.mode = 'dm' this.mode = 'dm'
this.messages.setChannel({ channelId: channel.id, guildId: dmScope.DM_GUILD_ID }) this.messages.setChannel({ channelId: channel.id, guildId: dmScope.DM_GUILD_ID })
this.activeChannelId = channel.id this.activeChannelId = channel.id
// Keep contacts friend-topic links warm so dual-path relay works immediately.
if (typeof this.contacts?._syncFriendTopicLinks === 'function') {
void this.contacts._syncFriendTopicLinks().catch(() => {})
}
// Nudge discovery if we still have no live peers (asymmetric 0/1 after guild switch).
if (typeof this.dm.refreshActiveTopicDiscovery === 'function') {
void this.dm.refreshActiveTopicDiscovery().catch(() => {})
}
await this.markChannelRead(channel.id, dmScope.DM_GUILD_ID) await this.markChannelRead(channel.id, dmScope.DM_GUILD_ID)
this.emit('dm', channel) this.emit('dm', channel)
const dmConversations = await this.listDMConversations() const dmConversations = await this.listDMConversations()
@@ -515,6 +545,111 @@ _activeDmMesh () {
return this.dm || this._dmBg || null return this.dm || this._dmBg || null
}, },
/** Peer user ids for the active DM / group DM (excludes self). */
_dmOutboundPeerIds () {
const selfId = this.identity?.user?.id
const ch = this.dm?.channel || this._dmBg?.channel
if (!ch) return []
const ids = new Set()
if (ch.peerUserId && ch.peerUserId !== selfId) ids.add(ch.peerUserId)
for (const id of ch.participantIds || []) {
if (id && id !== selfId) ids.add(id)
}
return [...ids]
},
/**
* Combined DM mesh stats for the UI badge.
* Prefer dedicated DM topic peers; if empty, surface contacts-mesh reachability so
* one side is not stuck showing "0 peers" while the other is live.
*/
_dmViewStats () {
const dm = this._activeDmMesh()
const base = dm?.getStats?.() || { peers: 0, openGossip: 0, topic: null }
const peerIds = this._dmOutboundPeerIds()
let contactsReachable = 0
if (this.contacts && peerIds.length) {
const presence = typeof this.contacts.getFriendPresenceMap === 'function'
? this.contacts.getFriendPresenceMap()
: null
for (const pid of peerIds) {
const st = presence?.[pid]?.status
if (st === 'online' || st === 'idle' || st === 'dnd') contactsReachable++
}
// Contacts swarm has live sockets (even if presence not yet gossiped).
if (contactsReachable === 0 && (this.contacts.peers?.size ?? 0) > 0) {
contactsReachable = Math.min(peerIds.length, this.contacts.peers.size)
}
}
const peers = Math.max(Number(base.peers) || 0, contactsReachable)
const openGossip = Math.max(Number(base.openGossip) || 0, contactsReachable > 0 ? 1 : 0)
return {
...base,
peers,
openGossip,
dmTopicPeers: Number(base.peers) || 0,
contactsReachable,
topic: base.topic || dm?.channel?.topic || null
}
},
/**
* Ingest DM content relayed over the contacts mesh (MESSAGE_CREATE, reactions, ).
*/
async _ingestContactsDmRelay (frame) {
const method = frame?.method
const payload = frame?.payload
if (method == null || !payload) return null
if (payload.guildId && payload.guildId !== dmScope.DM_GUILD_ID) return null
const dm = this._activeDmMesh() || (await this._ensureDmBackgroundListener().catch(() => null))
if (!dm) return null
// Ensure we are on the conversation topic for future direct mesh delivery.
if (payload.channelId) {
const ch = await this.db
.get(sharedScope.COLLECTIONS.CHANNELS, {
guildId: dmScope.DM_GUILD_ID,
id: payload.channelId
})
.catch(() => null)
if (ch?.topic) {
await dm.ensureTopicJoined(ch, { flush: false }).catch(() => {})
}
}
if (typeof dm.simulateGossip === 'function') {
return dm.simulateGossip(method, payload)
}
if (typeof dm._onGossip === 'function') {
dm._onGossip(method, payload, null)
}
return null
},
/**
* Fan out DM gossip on the dedicated DM topic and via contacts topics for each peer.
* Contacts path fixes asymmetric "0 peers" when only one side has the DM swarm link.
*/
async _relayDmGossip (method, payload) {
const dm = this._activeDmMesh()
if (dm) {
dm._ensureDmWireChannels?.()
if (method === sharedScope.RPC.MESSAGE_CREATE) dm.gossipMessage(payload)
else if (method === sharedScope.RPC.MESSAGE_UPDATE) dm.gossipMessageUpdate(payload)
else if (method === sharedScope.RPC.MESSAGE_DELETE) dm.gossipMessageDelete(payload)
else if (method === sharedScope.RPC.REACTION_TOGGLE) dm.gossipReaction(payload)
else if (method === sharedScope.RPC.MESSAGE_PIN) dm.gossipPin(payload)
else if (method === sharedScope.RPC.TYPING_START) dm.gossipTyping(payload)
else if (method === sharedScope.RPC.CHANNEL_READ) dm.gossipReadReceipt(payload)
else if (method === sharedScope.RPC.DM_CHANNEL_UPSERT) dm.gossipDmChannelUpsert(payload)
}
const peerIds = this._dmOutboundPeerIds()
if (!peerIds.length || !this.contacts?.relayToPeer) return
// Always dual-path: receiver dedupes. Covers the case where only one side sees DM peers.
for (const peerId of peerIds) {
void this.contacts.relayToPeer(peerId, method, payload).catch(() => {})
}
},
async _buildDmUsersById (messages = [], dmConversations = []) { async _buildDmUsersById (messages = [], dmConversations = []) {
const map = {} const map = {}
const self = this.identity?.user const self = this.identity?.user
@@ -612,7 +747,10 @@ async createDmPoll ({
async voteDmPoll (messageId, optionIndex, voted = null) { async voteDmPoll (messageId, optionIndex, voted = null) {
if (this.mode !== 'dm') throw new Error('dm poll votes only supported in dm mode') if (this.mode !== 'dm') throw new Error('dm poll votes only supported in dm mode')
const channelId = this.activeChannelId const bound = typeof this._bindActiveMessageScope === 'function'
? this._bindActiveMessageScope()
: null
const channelId = bound?.channelId || this.activeChannelId
const userId = this.identity?.user?.id const userId = this.identity?.user?.id
if (!channelId || !userId) throw new Error('no active dm channel') if (!channelId || !userId) throw new Error('no active dm channel')
const msg = await this.db.get(sharedScope.COLLECTIONS.MESSAGES, { channelId, id: messageId }) const msg = await this.db.get(sharedScope.COLLECTIONS.MESSAGES, { channelId, id: messageId })
@@ -626,6 +764,7 @@ async voteDmPoll (messageId, optionIndex, voted = null) {
if (!voteEmoji) throw new Error('invalid poll option') if (!voteEmoji) throw new Error('invalid poll option')
const allowed = new Set(poll.options.map((o) => o.emoji)) const allowed = new Set(poll.options.map((o) => o.emoji))
if (!allowed.has(voteEmoji)) throw new Error('poll option out of bounds') if (!allowed.has(voteEmoji)) throw new Error('poll option out of bounds')
this._bindActiveMessageScope?.()
const rows = await this.messages.listReactions() const rows = await this.messages.listReactions()
const hasTarget = rows.some((r) => r.messageId === messageId && r.emoji === voteEmoji && r.userId === userId) const hasTarget = rows.some((r) => r.messageId === messageId && r.emoji === voteEmoji && r.userId === userId)
const desired = voted == null ? !hasTarget : !!voted const desired = voted == null ? !hasTarget : !!voted
+46 -3
View File
@@ -645,12 +645,55 @@ class PearcordPlatform extends EventEmitter {
this._stopAuditExportScheduleTimer() this._stopAuditExportScheduleTimer()
this._stopDmScheduledMessageTimer() this._stopDmScheduledMessageTimer()
await this._leaveVoice({ gossip: true }) await this._leaveVoice({ gossip: true })
if (this.guild) await this.guild.leaveMesh().catch(() => {}) if (this.guild) {
// Fully close the prior guild instance (leave topic + destroy swarm).
// leaveMesh alone leaked Hyperswarm instances with the same keyPair across switches.
if (typeof this.guild.close === 'function') {
await this.guild.close().catch(() => {})
} else {
await this.guild.leaveMesh().catch(() => {})
}
}
if (this.dm && this.dm !== this._dmBg) { if (this.dm && this.dm !== this._dmBg) {
await this.dm.leaveMesh().catch(() => {}) await this.dm.leaveMesh().catch(() => {})
} }
} }
/**
* Reset guild-scoped caches when switching servers so members/presence/channels
* from the previous guild cannot leak into the next view.
*/
_resetGuildScopedSessionState () {
this.activeChannelId = null
this._guildOpenNoViewableChannels = false
this._hiddenChannelsForView = []
this._guildTyping = new Map()
this._voiceSpeakingMesh = {}
this._voiceMuteMesh = {}
this._lastVoiceSpeakingGossipKey = ''
this._lastVoiceSpeakingGossipAt = 0
if (typeof this._invalidateReactionListCache === 'function') {
this._invalidateReactionListCache()
}
if (typeof this._invalidatePinListCache === 'function') {
this._invalidatePinListCache()
}
if (typeof this._invalidateSearchCache === 'function') {
this._invalidateSearchCache()
}
this._reactionListPending = null
this._pinListPending = null
// Presence peer map is process-global; clear so the member list does not show
// online dots for people who only belonged to the previous mesh.
if (this.presence?.peers?.clear) {
this.presence.peers.clear()
}
// Fresh message store binding will be created by the open path; drop channel bind.
if (this.messages) {
this.messages.setChannel({ channelId: null, guildId: null })
}
}
async _leaveVoice ({ gossip = false } = {}) { async _leaveVoice ({ gossip = false } = {}) {
if (!this.voice) return null if (!this.voice) return null
const guild = this.guild?.guild const guild = this.guild?.guild
@@ -753,8 +796,8 @@ class PearcordPlatform extends EventEmitter {
} }
leaveSpan.end() leaveSpan.end()
await this._closeDM() await this._closeDM()
this.activeChannelId = null this.guild = null
this._guildOpenNoViewableChannels = false this._resetGuildScopedSessionState()
this.mode = 'guild' this.mode = 'guild'
const user = this.identity.user const user = this.identity.user
this.guild = new PearcordGuild({ this.guild = new PearcordGuild({