Build global presence mesh on contacts plane with blocked-user privacy.

Join globalPresenceTopic for self and friends, merge presence gossip,
and expose getGlobalPresenceSnapshot for home/DM views.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Raven Scott
2026-06-02 00:49:53 -04:00
co-authored by Cursor
parent d1944a556c
commit dcdf4b0044
2 changed files with 103 additions and 1 deletions
+2
View File
@@ -6,6 +6,8 @@ P2P friend requests, blocks, and friend presence on the contacts Hyperswarm mesh
Maintain the signed-in user's contact graph in JsonStore (`pending_in/out`, `accepted`, `blocked`), gossip request/accept/decline/remove/block RPCs to peer-specific `contactsTopic` buffers, join friend topics for ongoing `PRESENCE_UPDATE` and profile cosmetic sync, and expose friend presence map for DM sidebar and activity feed. Maintain the signed-in user's contact graph in JsonStore (`pending_in/out`, `accepted`, `blocked`), gossip request/accept/decline/remove/block RPCs to peer-specific `contactsTopic` buffers, join friend topics for ongoing `PRESENCE_UPDATE` and profile cosmetic sync, and expose friend presence map for DM sidebar and activity feed.
**v0.8.630 (Phase 656):** Global presence mesh on `globalPresenceTopic(userId)` — join/sync friend topics, `getGlobalPresenceSnapshot`, blocked users hidden from presence, `setGlobalPresenceMeshEnabled`. See [GLOBAL_PRESENCE.md](../../docs/GLOBAL_PRESENCE.md).
**v0.8.557 (Phase 594):** UI `#contacts-hub-panel-live` + `syncContactsHubLivePanelDuringGuildLoading`; **contacts-errors** excludes audit/compliance/slash/webhook/bot/automation/attachment/embed/message/reaction/pin/search/voice/stage/screen/soundboard/presence/discovery; clipboard `contactsHubPanelLiveBusy`; platform `guildCount` on contact span ends; agentctl `blockContact` + refreshView re-verify. Bundle: `test:phase594-contacts` (app repo). **v0.8.557 (Phase 594):** UI `#contacts-hub-panel-live` + `syncContactsHubLivePanelDuringGuildLoading`; **contacts-errors** excludes audit/compliance/slash/webhook/bot/automation/attachment/embed/message/reaction/pin/search/voice/stage/screen/soundboard/presence/discovery; clipboard `contactsHubPanelLiveBusy`; platform `guildCount` on contact span ends; agentctl `blockContact` + refreshView re-verify. Bundle: `test:phase594-contacts` (app repo).
**v0.8.527 (Phase 564):** UI `#contacts-panel-live` + `syncContactsLivePanelDuringGuildLoading`; dev-log contacts meta extend; agentctl send/accept refreshView re-verify. Bundle: `npm run test:phase564-contacts` (app repo). **v0.8.527 (Phase 564):** UI `#contacts-panel-live` + `syncContactsLivePanelDuringGuildLoading`; dev-log contacts meta extend; agentctl send/accept refreshView re-verify. Bundle: `npm run test:phase564-contacts` (app repo).
+101 -1
View File
@@ -5,7 +5,7 @@ const path = require('bare-path')
const EventEmitter = require('bare-events') const EventEmitter = require('bare-events')
const b4a = require('b4a') const b4a = require('b4a')
const { JsonStore } = require('pearcord-db/store-json') const { JsonStore } = require('pearcord-db/store-json')
const { RPC, contactsTopic, topicToBuffer, id, now, USER_STATUS } = require('pearcord-shared') const { RPC, contactsTopic, globalPresenceTopic, topicToBuffer, id, now, USER_STATUS } = require('pearcord-shared')
const { attachContactsMesh, broadcastContactsGossip } = require('./mesh') const { attachContactsMesh, broadcastContactsGossip } = require('./mesh')
const { normalizeActivity } = require('pearcord-activity') const { normalizeActivity } = require('pearcord-activity')
@@ -50,15 +50,25 @@ class PearcordContacts extends EventEmitter {
this._meshJoined = false this._meshJoined = false
this._ephemeralTopics = new Set() this._ephemeralTopics = new Set()
this._friendTopics = new Set() this._friendTopics = new Set()
this._globalPresenceTopics = new Set()
this._globalPresenceEnabled = true
this._blockedPeerIds = new Set()
this._friendPresence = new Map() this._friendPresence = new Map()
this._lastSelfPresence = null this._lastSelfPresence = null
} }
async ready () { async ready () {
await this.store.ready() await this.store.ready()
await this._refreshBlockedCache()
return this return this
} }
async _refreshBlockedCache () {
this._blockedPeerIds = new Set(
(await this.listBlocked()).map((r) => r.peerUserId).filter(Boolean)
)
}
async list () { async list () {
return this.store.find(CONTACTS_COLLECTION, { ownerId: this.userId }) return this.store.find(CONTACTS_COLLECTION, { ownerId: this.userId })
} }
@@ -193,6 +203,8 @@ class PearcordContacts extends EventEmitter {
...this._profilePayload(), ...this._profilePayload(),
toUserId: peerUserId toUserId: peerUserId
}).catch(() => {}) }).catch(() => {})
this._friendPresence.delete(peerUserId)
await this._refreshBlockedCache()
this.emit('contact', row) this.emit('contact', row)
return row return row
} }
@@ -211,6 +223,7 @@ class PearcordContacts extends EventEmitter {
...this._profilePayload(), ...this._profilePayload(),
toUserId: peerUserId toUserId: peerUserId
}).catch(() => {}) }).catch(() => {})
await this._refreshBlockedCache()
this.emit('contact', null) this.emit('contact', null)
return row return row
} }
@@ -236,6 +249,8 @@ class PearcordContacts extends EventEmitter {
row.status = CONTACT_STATUS.BLOCKED row.status = CONTACT_STATUS.BLOCKED
row.acceptedAt = null row.acceptedAt = null
await this.store.insert(CONTACTS_COLLECTION, row) await this.store.insert(CONTACTS_COLLECTION, row)
this._friendPresence.delete(from)
await this._refreshBlockedCache()
this.emit('contact', row) this.emit('contact', row)
return row return row
} }
@@ -315,7 +330,10 @@ class PearcordContacts extends EventEmitter {
ingestFriendPresence (payload) { ingestFriendPresence (payload) {
if (!payload?.userId || payload.userId === this.userId) return null if (!payload?.userId || payload.userId === this.userId) return null
if (this._blockedPeerIds.has(payload.userId)) return null
const row = presenceGossipRow(payload) const row = presenceGossipRow(payload)
const prev = this._friendPresence.get(payload.userId)
if (prev && (prev.at || 0) > (row.at || 0)) return prev
this._friendPresence.set(payload.userId, row) this._friendPresence.set(payload.userId, row)
this.emit('friend-presence', row) this.emit('friend-presence', row)
if (row.username || row.displayName) { if (row.username || row.displayName) {
@@ -335,10 +353,50 @@ class PearcordContacts extends EventEmitter {
this._lastSelfPresence = presenceGossipRow(payload, this.userId) this._lastSelfPresence = presenceGossipRow(payload, this.userId)
if (this._meshJoined) { if (this._meshJoined) {
await broadcastContactsGossip(this, RPC.PRESENCE_UPDATE, this._lastSelfPresence) await broadcastContactsGossip(this, RPC.PRESENCE_UPDATE, this._lastSelfPresence)
if (this._globalPresenceEnabled) {
await this._broadcastGlobalPresence(this._lastSelfPresence)
}
} }
return this._lastSelfPresence return this._lastSelfPresence
} }
async _broadcastGlobalPresence (payload) {
if (!this.swarm || !this._globalPresenceEnabled) return
const topic = globalPresenceTopic(this.userId)
const buf = topicToBuffer(topic)
if (!this._globalPresenceTopics.has(topic)) {
await this.swarm.join(buf, { server: true, client: true })
this._globalPresenceTopics.add(topic)
await this._flushSwarm(2500)
}
await broadcastContactsGossip(this, RPC.PRESENCE_UPDATE, payload)
}
setGlobalPresenceMeshEnabled (enabled) {
const next = enabled !== false
if (this._globalPresenceEnabled === next) return next
this._globalPresenceEnabled = next
if (this._meshJoined) {
void this._syncGlobalPresenceTopicLinks().catch(() => {})
}
return next
}
getGlobalPresenceSnapshot () {
const peers = []
for (const [userId, row] of this._friendPresence) {
if (this._blockedPeerIds.has(userId)) continue
peers.push({ userId, status: row.status, at: row.at || 0, activity: row.activity || null })
}
return {
meshLive: this.peers.size > 0,
peerCount: peers.length,
topicCount: this._globalPresenceTopics.size,
enabled: this._globalPresenceEnabled,
peers: peers.slice(0, 48)
}
}
async broadcastProfileCosmetic (payload) { async broadcastProfileCosmetic (payload) {
if (!payload?.userId || !this._meshJoined) return null if (!payload?.userId || !this._meshJoined) return null
await broadcastContactsGossip(this, RPC.PROFILE_COSMETIC_UPDATE, payload) await broadcastContactsGossip(this, RPC.PROFILE_COSMETIC_UPDATE, payload)
@@ -427,9 +485,37 @@ class PearcordContacts extends EventEmitter {
await this.swarm.leave(topicToBuffer(topic)).catch(() => {}) await this.swarm.leave(topicToBuffer(topic)).catch(() => {})
this._friendTopics.delete(topic) this._friendTopics.delete(topic)
} }
await this._syncGlobalPresenceTopicLinks(accepted)
await this._flushSwarm(3500) await this._flushSwarm(3500)
} }
async _syncGlobalPresenceTopicLinks (acceptedRows) {
if (!this.swarm || !this._meshJoined || !this._globalPresenceEnabled) {
for (const topic of [...this._globalPresenceTopics]) {
if (topic === globalPresenceTopic(this.userId)) continue
await this.swarm.leave(topicToBuffer(topic)).catch(() => {})
this._globalPresenceTopics.delete(topic)
}
return
}
const accepted = acceptedRows || (await this.listAccepted())
const want = new Set([globalPresenceTopic(this.userId)])
for (const row of accepted) {
if (row.peerUserId) want.add(globalPresenceTopic(row.peerUserId))
}
for (const topic of want) {
if (this._globalPresenceTopics.has(topic)) continue
const own = topic === globalPresenceTopic(this.userId)
await this.swarm.join(topicToBuffer(topic), { server: own, client: true })
this._globalPresenceTopics.add(topic)
}
for (const topic of [...this._globalPresenceTopics]) {
if (want.has(topic)) continue
await this.swarm.leave(topicToBuffer(topic)).catch(() => {})
this._globalPresenceTopics.delete(topic)
}
}
async _flushSwarm (ms = Number(process.env.PEARCORD_MESH_FLUSH_MS || 2500)) { async _flushSwarm (ms = Number(process.env.PEARCORD_MESH_FLUSH_MS || 2500)) {
if (!this.swarm) return if (!this.swarm) return
await Promise.race([ await Promise.race([
@@ -488,6 +574,16 @@ class PearcordContacts extends EventEmitter {
}), }),
new Promise((resolve) => setTimeout(resolve, joinMs)) new Promise((resolve) => setTimeout(resolve, joinMs))
]) ])
if (this._globalPresenceEnabled) {
await Promise.race([
this.swarm.join(topicToBuffer(globalPresenceTopic(this.userId)), {
server: true,
client: true
}),
new Promise((resolve) => setTimeout(resolve, joinMs))
])
this._globalPresenceTopics.add(globalPresenceTopic(this.userId))
}
await this._flushSwarm(3500) await this._flushSwarm(3500)
this._meshJoined = true this._meshJoined = true
const linkMs = Number(process.env.PEARCORD_CONTACTS_FRIEND_LINK_MS) || 8000 const linkMs = Number(process.env.PEARCORD_CONTACTS_FRIEND_LINK_MS) || 8000
@@ -506,9 +602,11 @@ class PearcordContacts extends EventEmitter {
this._channels.clear() this._channels.clear()
this._ephemeralTopics.clear() this._ephemeralTopics.clear()
this._friendTopics.clear() this._friendTopics.clear()
this._globalPresenceTopics.clear()
this._meshJoined = false this._meshJoined = false
if (this.swarm) { if (this.swarm) {
await this.swarm.leave(topicToBuffer(contactsTopic(this.userId))).catch(() => {}) await this.swarm.leave(topicToBuffer(contactsTopic(this.userId))).catch(() => {})
await this.swarm.leave(topicToBuffer(globalPresenceTopic(this.userId))).catch(() => {})
this.swarm.removeAllListeners('connection') this.swarm.removeAllListeners('connection')
} }
this.swarm = null this.swarm = null
@@ -519,6 +617,8 @@ class PearcordContacts extends EventEmitter {
return { return {
peers, peers,
friendTopics: this._friendTopics.size, friendTopics: this._friendTopics.size,
globalPresenceTopics: this._globalPresenceTopics.size,
globalPresenceEnabled: this._globalPresenceEnabled,
meshLive: peers > 0 meshLive: peers > 0
} }
} }