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) {
|
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
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
}
|
||||||
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) {
|
} 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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user