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:
@@ -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.
|
||||
|
||||
**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.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).
|
||||
|
||||
@@ -5,7 +5,7 @@ const path = require('bare-path')
|
||||
const EventEmitter = require('bare-events')
|
||||
const b4a = require('b4a')
|
||||
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 { normalizeActivity } = require('pearcord-activity')
|
||||
|
||||
@@ -50,15 +50,25 @@ class PearcordContacts extends EventEmitter {
|
||||
this._meshJoined = false
|
||||
this._ephemeralTopics = new Set()
|
||||
this._friendTopics = new Set()
|
||||
this._globalPresenceTopics = new Set()
|
||||
this._globalPresenceEnabled = true
|
||||
this._blockedPeerIds = new Set()
|
||||
this._friendPresence = new Map()
|
||||
this._lastSelfPresence = null
|
||||
}
|
||||
|
||||
async ready () {
|
||||
await this.store.ready()
|
||||
await this._refreshBlockedCache()
|
||||
return this
|
||||
}
|
||||
|
||||
async _refreshBlockedCache () {
|
||||
this._blockedPeerIds = new Set(
|
||||
(await this.listBlocked()).map((r) => r.peerUserId).filter(Boolean)
|
||||
)
|
||||
}
|
||||
|
||||
async list () {
|
||||
return this.store.find(CONTACTS_COLLECTION, { ownerId: this.userId })
|
||||
}
|
||||
@@ -193,6 +203,8 @@ class PearcordContacts extends EventEmitter {
|
||||
...this._profilePayload(),
|
||||
toUserId: peerUserId
|
||||
}).catch(() => {})
|
||||
this._friendPresence.delete(peerUserId)
|
||||
await this._refreshBlockedCache()
|
||||
this.emit('contact', row)
|
||||
return row
|
||||
}
|
||||
@@ -211,6 +223,7 @@ class PearcordContacts extends EventEmitter {
|
||||
...this._profilePayload(),
|
||||
toUserId: peerUserId
|
||||
}).catch(() => {})
|
||||
await this._refreshBlockedCache()
|
||||
this.emit('contact', null)
|
||||
return row
|
||||
}
|
||||
@@ -236,6 +249,8 @@ class PearcordContacts extends EventEmitter {
|
||||
row.status = CONTACT_STATUS.BLOCKED
|
||||
row.acceptedAt = null
|
||||
await this.store.insert(CONTACTS_COLLECTION, row)
|
||||
this._friendPresence.delete(from)
|
||||
await this._refreshBlockedCache()
|
||||
this.emit('contact', row)
|
||||
return row
|
||||
}
|
||||
@@ -315,7 +330,10 @@ class PearcordContacts extends EventEmitter {
|
||||
|
||||
ingestFriendPresence (payload) {
|
||||
if (!payload?.userId || payload.userId === this.userId) return null
|
||||
if (this._blockedPeerIds.has(payload.userId)) return null
|
||||
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.emit('friend-presence', row)
|
||||
if (row.username || row.displayName) {
|
||||
@@ -335,10 +353,50 @@ class PearcordContacts extends EventEmitter {
|
||||
this._lastSelfPresence = presenceGossipRow(payload, this.userId)
|
||||
if (this._meshJoined) {
|
||||
await broadcastContactsGossip(this, RPC.PRESENCE_UPDATE, this._lastSelfPresence)
|
||||
if (this._globalPresenceEnabled) {
|
||||
await this._broadcastGlobalPresence(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) {
|
||||
if (!payload?.userId || !this._meshJoined) return null
|
||||
await broadcastContactsGossip(this, RPC.PROFILE_COSMETIC_UPDATE, payload)
|
||||
@@ -427,9 +485,37 @@ class PearcordContacts extends EventEmitter {
|
||||
await this.swarm.leave(topicToBuffer(topic)).catch(() => {})
|
||||
this._friendTopics.delete(topic)
|
||||
}
|
||||
await this._syncGlobalPresenceTopicLinks(accepted)
|
||||
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)) {
|
||||
if (!this.swarm) return
|
||||
await Promise.race([
|
||||
@@ -488,6 +574,16 @@ class PearcordContacts extends EventEmitter {
|
||||
}),
|
||||
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)
|
||||
this._meshJoined = true
|
||||
const linkMs = Number(process.env.PEARCORD_CONTACTS_FRIEND_LINK_MS) || 8000
|
||||
@@ -506,9 +602,11 @@ class PearcordContacts extends EventEmitter {
|
||||
this._channels.clear()
|
||||
this._ephemeralTopics.clear()
|
||||
this._friendTopics.clear()
|
||||
this._globalPresenceTopics.clear()
|
||||
this._meshJoined = false
|
||||
if (this.swarm) {
|
||||
await this.swarm.leave(topicToBuffer(contactsTopic(this.userId))).catch(() => {})
|
||||
await this.swarm.leave(topicToBuffer(globalPresenceTopic(this.userId))).catch(() => {})
|
||||
this.swarm.removeAllListeners('connection')
|
||||
}
|
||||
this.swarm = null
|
||||
@@ -519,6 +617,8 @@ class PearcordContacts extends EventEmitter {
|
||||
return {
|
||||
peers,
|
||||
friendTopics: this._friendTopics.size,
|
||||
globalPresenceTopics: this._globalPresenceTopics.size,
|
||||
globalPresenceEnabled: this._globalPresenceEnabled,
|
||||
meshLive: peers > 0
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user