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:
@@ -38,6 +38,7 @@ function buildGuildChannelsSignature (view) {
|
||||
|
||||
function evalGuildChannelsNeedsRefresh (prevView, nextView) {
|
||||
if (nextView?.mode !== 'guild' || nextView?.guildLoading) return false
|
||||
if ((prevView?.guild?.id || '') !== (nextView?.guild?.id || '')) return true
|
||||
const prevLen = prevView?.channels?.length || 0
|
||||
const nextLen = nextView?.channels?.length || 0
|
||||
if (prevLen !== nextLen) return true
|
||||
@@ -50,6 +51,7 @@ function evalGuildUnreadNeedsRefresh (prevView, nextView, hashFns) {
|
||||
}
|
||||
|
||||
function evalGuildActiveMessagesNeedsRefresh (prevView, nextView) {
|
||||
if ((prevView?.guild?.id || '') !== (nextView?.guild?.id || '')) return true
|
||||
if (prevView?.activeChannelId !== nextView?.activeChannelId) return true
|
||||
const prevLen = prevView?.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
|
||||
const gid = nextView.guild?.id
|
||||
if (!gid) return false
|
||||
if ((prevView?.guild?.id || '') !== gid) return true
|
||||
const prevSig = hashFns.hashPresencePeers(
|
||||
prevView?.presence?.peers,
|
||||
prevView?.guildPresenceSummary?.[gid]
|
||||
|
||||
@@ -12,13 +12,29 @@ function listMeshStabilityUiMemberRosterRefreshFieldKeys () {
|
||||
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) {
|
||||
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 (nextView?.guildLoading) return false
|
||||
const prevLen = prevView?.members?.length || 0
|
||||
const nextLen = nextView?.members?.length || 0
|
||||
if (prevLen !== nextLen) return true
|
||||
if (memberRosterIdentitySignature(prevView) !== memberRosterIdentitySignature(nextView)) {
|
||||
return true
|
||||
}
|
||||
const pr = prevView?.memberRosterSync
|
||||
const nr = nextView?.memberRosterSync
|
||||
if (!pr && !nr) return false
|
||||
|
||||
@@ -338,9 +338,12 @@ const pollSchedulingMixin = {
|
||||
|
||||
async votePoll (messageId, optionIndex, voted = null) {
|
||||
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 guildId = this.guild?.guild?.id
|
||||
const guildId = bound?.guildId || this.guild?.guild?.id
|
||||
if (!channelId || !userId || !guildId) throw new Error('no active guild channel')
|
||||
const span = this.log.time('poll.vote', {
|
||||
spanKind: 'poll.vote',
|
||||
@@ -349,7 +352,10 @@ const pollSchedulingMixin = {
|
||||
channelId
|
||||
})
|
||||
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 })
|
||||
if (!msg) throw new Error('poll message not found')
|
||||
const poll = parseDmPollMessageContent(this._messagePlaintext(msg) || msg.content)
|
||||
@@ -359,6 +365,7 @@ const pollSchedulingMixin = {
|
||||
if (!voteEmoji) throw new Error('invalid poll option')
|
||||
const allowed = new Set(poll.options.map((o) => o.emoji))
|
||||
if (!allowed.has(voteEmoji)) throw new Error('poll option out of bounds')
|
||||
this._bindActiveMessageScope?.()
|
||||
const rows = await this.messages.listReactions()
|
||||
const hasTarget = rows.some(
|
||||
(r) => r.messageId === messageId && r.emoji === voteEmoji && r.userId === userId
|
||||
|
||||
@@ -601,6 +601,11 @@ async _sendPlainMessage (content, opts = {}) {
|
||||
const previewPlain =
|
||||
this._messagePlaintext(msg) || (linked.length ? '📎 Attachment' : '')
|
||||
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)
|
||||
}
|
||||
this._queueLinkEmbed(msg).catch(() => {})
|
||||
@@ -817,8 +822,11 @@ async signalTyping () {
|
||||
guildId: this.mode === 'dm' ? dmScope.DM_GUILD_ID : this.guild?.guild?.id,
|
||||
at: Date.now()
|
||||
}
|
||||
if (this.mode === 'dm' && this.dm) this.dm.gossipTyping(payload)
|
||||
else if (this.mode === 'guild' && this.guild) this.guild.gossipTyping(payload)
|
||||
if (this.mode === 'dm') {
|
||||
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', {
|
||||
channelId: payload.channelId,
|
||||
mode: this.mode
|
||||
@@ -872,8 +880,11 @@ async editMessage (messageId, content) {
|
||||
} else if (nextUrl && !priorUrl) {
|
||||
this._queueLinkEmbed(msg).catch(() => {})
|
||||
}
|
||||
if (this.mode === 'dm' && this.dm) this.dm.gossipMessageUpdate(msg)
|
||||
else if (this.mode === 'guild' && this.guild) this.guild.gossipMessageUpdate(msg)
|
||||
if (this.mode === 'dm') {
|
||||
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) {
|
||||
const ch = await this.db.get(sharedScope.COLLECTIONS.CHANNELS, {
|
||||
guildId: this.guild.guild.id,
|
||||
@@ -1186,8 +1197,15 @@ async unpinMessage (messageId) {
|
||||
},
|
||||
|
||||
async toggleReaction (messageId, emoji) {
|
||||
const guildId = this.mode === 'dm' ? dmScope.DM_GUILD_ID : this.guild?.guild?.id || null
|
||||
const channelId = this.activeChannelId
|
||||
// Poll votes and reactions must bind the active channel first — isolation races
|
||||
// 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 span = this.log.time('reaction.toggle', {
|
||||
spanKind: 'reaction.toggle',
|
||||
@@ -1199,6 +1217,7 @@ async toggleReaction (messageId, emoji) {
|
||||
try {
|
||||
const user = this.identity.user
|
||||
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._checkReactionToggleRateLimit(channelId)) {
|
||||
throw new Error('reaction rate limited')
|
||||
@@ -1211,17 +1230,24 @@ async toggleReaction (messageId, emoji) {
|
||||
r.messageId === messageId && r.emoji === emojiKey && r.userId === user.id
|
||||
)
|
||||
} catch (_) {}
|
||||
this._bindActiveMessageScope?.()
|
||||
const result = await this.messages.toggleReaction(messageId, emoji, user.id)
|
||||
let gossipSent = false
|
||||
if (this._shouldGossipReaction(result)) {
|
||||
if (this.mode === 'dm' && this.dm?.gossipReaction) {
|
||||
this.dm.gossipReaction({
|
||||
if (this.mode === 'dm') {
|
||||
const reactionPayload = {
|
||||
...result,
|
||||
guildId: dmScope.DM_GUILD_ID,
|
||||
channelId: this.activeChannelId,
|
||||
channelId: this.activeChannelId || this.messages?.channelId,
|
||||
createdAt: result.createdAt || Date.now()
|
||||
})
|
||||
gossipSent = true
|
||||
}
|
||||
if (typeof this._relayDmGossip === 'function') {
|
||||
void this._relayDmGossip(sharedScope.RPC.REACTION_TOGGLE, reactionPayload)
|
||||
gossipSent = true
|
||||
} else if (this.dm?.gossipReaction) {
|
||||
this.dm.gossipReaction(reactionPayload)
|
||||
gossipSent = true
|
||||
}
|
||||
} else if (this.mode !== 'dm' && this.guild) {
|
||||
const gossipSpan = this.log.time('reaction.gossip', {
|
||||
spanKind: 'reaction.gossip',
|
||||
@@ -1463,8 +1489,11 @@ async deleteMessage (messageId) {
|
||||
channelId,
|
||||
guildId
|
||||
}
|
||||
if (this.mode === 'dm' && this.dm) this.dm.gossipMessageDelete(payload)
|
||||
else if (this.mode === 'guild' && this.guild) this.guild.gossipMessageDelete(payload)
|
||||
if (this.mode === 'dm') {
|
||||
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) {
|
||||
await this._removeMessageFromSearchIndex(messageId, this.guild.guild.id).catch(() => {})
|
||||
}
|
||||
|
||||
@@ -32,7 +32,8 @@ const {
|
||||
/** Phase 756 — folders, prefs, notifications, compose drafts */
|
||||
const platformSettingsFoldersPrefsMixin = {
|
||||
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.contacts) {
|
||||
const snap = this.identity.snapshot()
|
||||
@@ -68,6 +69,9 @@ async _joinContactsMesh () {
|
||||
this.contacts.on('dm-channel', (ch) => {
|
||||
void this._ingestRemoteDmChannel(ch).catch(() => {})
|
||||
})
|
||||
this.contacts.on('dm-relay', (frame) => {
|
||||
void this._ingestContactsDmRelay(frame).catch(() => {})
|
||||
})
|
||||
}
|
||||
const Hyperswarm = require('hyperswarm')
|
||||
if (!this._contactsSwarm) {
|
||||
|
||||
@@ -710,7 +710,7 @@ async view (opts = {}) {
|
||||
let messageById = {}
|
||||
for (const m of messages) messageById[m.id] = m
|
||||
const stats = this.mode === 'dm'
|
||||
? this.dm?.getStats() || null
|
||||
? (typeof this._dmViewStats === 'function' ? this._dmViewStats() : this.dm?.getStats() || null)
|
||||
: this.guild?.getStats() || null
|
||||
const myPermissions =
|
||||
this.mode === 'guild' ? await this._myPermissions(this.activeChannelId) : null
|
||||
@@ -2185,9 +2185,21 @@ async view (opts = {}) {
|
||||
/* 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({
|
||||
selfUserId: user?.id,
|
||||
presence: this.presence?.snapshot() || null,
|
||||
presence: presenceSnap,
|
||||
members: members || [],
|
||||
contacts: contactsForView
|
||||
})
|
||||
@@ -2468,7 +2480,7 @@ async view (opts = {}) {
|
||||
typingUsers: this.mode === 'guild'
|
||||
? this._getChannelTypingUserIds(this.activeChannelId)
|
||||
: (this.dm?.getTypingUsers() || []),
|
||||
presence: this.presence?.snapshot() || null,
|
||||
presence: presenceSnap,
|
||||
activityFeed,
|
||||
stats,
|
||||
dbEngine: this.db.getEngine?.() || 'json',
|
||||
|
||||
@@ -156,7 +156,24 @@ async _ingestRemoteDmChannel (channel, opts = {}) {
|
||||
guildId: dmScope.DM_GUILD_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)
|
||||
const metaStore = await this._ensureDmMetaStore()
|
||||
const isGroup = row.type === sharedScope.CHANNEL_TYPES.GROUP_DM || channel.isGroup
|
||||
@@ -167,11 +184,16 @@ async _ingestRemoteDmChannel (channel, opts = {}) {
|
||||
channelId: row.id,
|
||||
isGroup: !!isGroup,
|
||||
participantIds,
|
||||
peerUserId: channel.peerUserId || prev.peerUserId || null,
|
||||
peerUserId: channel.peerUserId || prev.peerUserId || row.peerUserId || null,
|
||||
displayName: channel.name || prev.displayName || null
|
||||
})
|
||||
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)
|
||||
return row
|
||||
},
|
||||
@@ -267,6 +289,14 @@ async openDM ({ peerUserId, peerDisplayName, sidebarFilterActive = false }) {
|
||||
this.mode = 'dm'
|
||||
this.messages.setChannel({ channelId: channel.id, guildId: dmScope.DM_GUILD_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)
|
||||
this.emit('dm', channel)
|
||||
const dmConversations = await this.listDMConversations()
|
||||
@@ -515,6 +545,111 @@ _activeDmMesh () {
|
||||
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 = []) {
|
||||
const map = {}
|
||||
const self = this.identity?.user
|
||||
@@ -612,7 +747,10 @@ async createDmPoll ({
|
||||
|
||||
async voteDmPoll (messageId, optionIndex, voted = null) {
|
||||
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
|
||||
if (!channelId || !userId) throw new Error('no active dm channel')
|
||||
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')
|
||||
const allowed = new Set(poll.options.map((o) => o.emoji))
|
||||
if (!allowed.has(voteEmoji)) throw new Error('poll option out of bounds')
|
||||
this._bindActiveMessageScope?.()
|
||||
const rows = await this.messages.listReactions()
|
||||
const hasTarget = rows.some((r) => r.messageId === messageId && r.emoji === voteEmoji && r.userId === userId)
|
||||
const desired = voted == null ? !hasTarget : !!voted
|
||||
|
||||
@@ -645,12 +645,55 @@ class PearcordPlatform extends EventEmitter {
|
||||
this._stopAuditExportScheduleTimer()
|
||||
this._stopDmScheduledMessageTimer()
|
||||
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) {
|
||||
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 } = {}) {
|
||||
if (!this.voice) return null
|
||||
const guild = this.guild?.guild
|
||||
@@ -753,8 +796,8 @@ class PearcordPlatform extends EventEmitter {
|
||||
}
|
||||
leaveSpan.end()
|
||||
await this._closeDM()
|
||||
this.activeChannelId = null
|
||||
this._guildOpenNoViewableChannels = false
|
||||
this.guild = null
|
||||
this._resetGuildScopedSessionState()
|
||||
this.mode = 'guild'
|
||||
const user = this.identity.user
|
||||
this.guild = new PearcordGuild({
|
||||
|
||||
Reference in New Issue
Block a user