|
|
|
@@ -0,0 +1,339 @@
|
|
|
|
|
'use strict'
|
|
|
|
|
|
|
|
|
|
require('bare-process/global')
|
|
|
|
|
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 } = require('pearcord-shared')
|
|
|
|
|
const { attachContactsMesh, broadcastContactsGossip } = require('./mesh')
|
|
|
|
|
|
|
|
|
|
const CONTACTS_COLLECTION = '@pearcord/contacts'
|
|
|
|
|
|
|
|
|
|
const CONTACT_STATUS = {
|
|
|
|
|
PENDING_OUT: 'pending_out',
|
|
|
|
|
PENDING_IN: 'pending_in',
|
|
|
|
|
ACCEPTED: 'accepted',
|
|
|
|
|
BLOCKED: 'blocked'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function contactKey (ownerId, peerUserId) {
|
|
|
|
|
return { ownerId, peerUserId }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class PearcordContacts extends EventEmitter {
|
|
|
|
|
constructor (opts = {}) {
|
|
|
|
|
super()
|
|
|
|
|
this.userId = opts.userId
|
|
|
|
|
this.username = opts.username || null
|
|
|
|
|
this.displayName = opts.displayName || null
|
|
|
|
|
this.storagePath = opts.storagePath || './pearcord-storage'
|
|
|
|
|
this.store = new JsonStore(path.join(this.storagePath, 'contacts'))
|
|
|
|
|
this.swarm = null
|
|
|
|
|
this.peers = new Map()
|
|
|
|
|
this._channels = new Map()
|
|
|
|
|
this._meshJoined = false
|
|
|
|
|
this._ephemeralTopics = new Set()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async ready () {
|
|
|
|
|
await this.store.ready()
|
|
|
|
|
return this
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async list () {
|
|
|
|
|
return this.store.find(CONTACTS_COLLECTION, { ownerId: this.userId })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async get (peerUserId) {
|
|
|
|
|
return this.store.get(CONTACTS_COLLECTION, contactKey(this.userId, peerUserId))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async listAccepted () {
|
|
|
|
|
const rows = await this.list()
|
|
|
|
|
return rows.filter((r) => r.status === CONTACT_STATUS.ACCEPTED)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async listPendingIncoming () {
|
|
|
|
|
const rows = await this.list()
|
|
|
|
|
return rows.filter((r) => r.status === CONTACT_STATUS.PENDING_IN)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async listPendingOutgoing () {
|
|
|
|
|
const rows = await this.list()
|
|
|
|
|
return rows.filter((r) => r.status === CONTACT_STATUS.PENDING_OUT)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_profilePayload () {
|
|
|
|
|
return {
|
|
|
|
|
fromUserId: this.userId,
|
|
|
|
|
fromUsername: this.username,
|
|
|
|
|
fromDisplayName: this.displayName || this.username,
|
|
|
|
|
at: now()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async sendRequest ({ peerUserId, peerDisplayName }) {
|
|
|
|
|
if (!peerUserId) throw new Error('peerUserId required')
|
|
|
|
|
if (peerUserId === this.userId) throw new Error('cannot friend yourself')
|
|
|
|
|
const existing = await this.get(peerUserId)
|
|
|
|
|
if (existing?.status === CONTACT_STATUS.ACCEPTED) {
|
|
|
|
|
throw new Error('already friends')
|
|
|
|
|
}
|
|
|
|
|
if (existing?.status === CONTACT_STATUS.BLOCKED) {
|
|
|
|
|
throw new Error('contact blocked')
|
|
|
|
|
}
|
|
|
|
|
if (existing?.status === CONTACT_STATUS.PENDING_OUT) {
|
|
|
|
|
return existing
|
|
|
|
|
}
|
|
|
|
|
const row = {
|
|
|
|
|
id: existing?.id || id(),
|
|
|
|
|
ownerId: this.userId,
|
|
|
|
|
peerUserId,
|
|
|
|
|
peerDisplayName: peerDisplayName || peerUserId.slice(0, 8),
|
|
|
|
|
status: CONTACT_STATUS.PENDING_OUT,
|
|
|
|
|
requestedAt: now(),
|
|
|
|
|
acceptedAt: null
|
|
|
|
|
}
|
|
|
|
|
await this.store.insert(CONTACTS_COLLECTION, row)
|
|
|
|
|
const payload = {
|
|
|
|
|
...this._profilePayload(),
|
|
|
|
|
toUserId: peerUserId,
|
|
|
|
|
peerDisplayName: row.peerDisplayName
|
|
|
|
|
}
|
|
|
|
|
await this._gossipToPeer(peerUserId, RPC.CONTACT_REQUEST, payload)
|
|
|
|
|
this.emit('contact', row)
|
|
|
|
|
return row
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async acceptRequest (peerUserId) {
|
|
|
|
|
const row = await this.get(peerUserId)
|
|
|
|
|
if (!row || row.status !== CONTACT_STATUS.PENDING_IN) {
|
|
|
|
|
throw new Error('no pending request from this user')
|
|
|
|
|
}
|
|
|
|
|
row.status = CONTACT_STATUS.ACCEPTED
|
|
|
|
|
row.acceptedAt = now()
|
|
|
|
|
await this.store.insert(CONTACTS_COLLECTION, row)
|
|
|
|
|
await this._gossipToPeer(peerUserId, RPC.CONTACT_ACCEPT, {
|
|
|
|
|
...this._profilePayload(),
|
|
|
|
|
toUserId: peerUserId,
|
|
|
|
|
peerDisplayName: row.peerDisplayName
|
|
|
|
|
})
|
|
|
|
|
this.emit('contact', row)
|
|
|
|
|
return row
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async declineRequest (peerUserId) {
|
|
|
|
|
const row = await this.get(peerUserId)
|
|
|
|
|
if (!row || row.status !== CONTACT_STATUS.PENDING_IN) {
|
|
|
|
|
throw new Error('no pending request')
|
|
|
|
|
}
|
|
|
|
|
await this.store.delete(CONTACTS_COLLECTION, contactKey(this.userId, peerUserId))
|
|
|
|
|
await this._gossipToPeer(peerUserId, RPC.CONTACT_DECLINE, {
|
|
|
|
|
...this._profilePayload(),
|
|
|
|
|
toUserId: peerUserId
|
|
|
|
|
})
|
|
|
|
|
this.emit('contact', null)
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async removeContact (peerUserId) {
|
|
|
|
|
const row = await this.get(peerUserId)
|
|
|
|
|
if (!row) return null
|
|
|
|
|
await this.store.delete(CONTACTS_COLLECTION, contactKey(this.userId, peerUserId))
|
|
|
|
|
if (row.status === CONTACT_STATUS.ACCEPTED || row.status === CONTACT_STATUS.PENDING_OUT) {
|
|
|
|
|
await this._gossipToPeer(peerUserId, RPC.CONTACT_REMOVE, {
|
|
|
|
|
...this._profilePayload(),
|
|
|
|
|
toUserId: peerUserId
|
|
|
|
|
}).catch(() => {})
|
|
|
|
|
}
|
|
|
|
|
this.emit('contact', null)
|
|
|
|
|
return row
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async blockContact (peerUserId) {
|
|
|
|
|
const row = (await this.get(peerUserId)) || {
|
|
|
|
|
id: id(),
|
|
|
|
|
ownerId: this.userId,
|
|
|
|
|
peerUserId,
|
|
|
|
|
peerDisplayName: peerUserId.slice(0, 8),
|
|
|
|
|
requestedAt: now()
|
|
|
|
|
}
|
|
|
|
|
row.status = CONTACT_STATUS.BLOCKED
|
|
|
|
|
row.acceptedAt = null
|
|
|
|
|
await this.store.insert(CONTACTS_COLLECTION, row)
|
|
|
|
|
this.emit('contact', row)
|
|
|
|
|
return row
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async ingestRequest (payload) {
|
|
|
|
|
const from = payload?.fromUserId
|
|
|
|
|
if (!from || from === this.userId) return null
|
|
|
|
|
const blocked = await this.get(from)
|
|
|
|
|
if (blocked?.status === CONTACT_STATUS.BLOCKED) return null
|
|
|
|
|
if (blocked?.status === CONTACT_STATUS.ACCEPTED) return blocked
|
|
|
|
|
const mutual = blocked?.status === CONTACT_STATUS.PENDING_OUT
|
|
|
|
|
const row = {
|
|
|
|
|
id: blocked?.id || id(),
|
|
|
|
|
ownerId: this.userId,
|
|
|
|
|
peerUserId: from,
|
|
|
|
|
peerDisplayName: payload.fromDisplayName || payload.fromUsername || from.slice(0, 8),
|
|
|
|
|
status: mutual ? CONTACT_STATUS.ACCEPTED : CONTACT_STATUS.PENDING_IN,
|
|
|
|
|
requestedAt: payload.at || now(),
|
|
|
|
|
acceptedAt: mutual ? now() : null
|
|
|
|
|
}
|
|
|
|
|
if (mutual) {
|
|
|
|
|
await this._gossipToPeer(from, RPC.CONTACT_ACCEPT, {
|
|
|
|
|
...this._profilePayload(),
|
|
|
|
|
toUserId: from,
|
|
|
|
|
peerDisplayName: row.peerDisplayName
|
|
|
|
|
}).catch(() => {})
|
|
|
|
|
}
|
|
|
|
|
await this.store.insert(CONTACTS_COLLECTION, row)
|
|
|
|
|
this.emit('contact-request', row)
|
|
|
|
|
this.emit('contact', row)
|
|
|
|
|
return row
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async ingestAccept (payload) {
|
|
|
|
|
const from = payload?.fromUserId
|
|
|
|
|
if (!from) return null
|
|
|
|
|
let row = await this.get(from)
|
|
|
|
|
if (!row) {
|
|
|
|
|
row = {
|
|
|
|
|
id: id(),
|
|
|
|
|
ownerId: this.userId,
|
|
|
|
|
peerUserId: from,
|
|
|
|
|
peerDisplayName: payload.fromDisplayName || from.slice(0, 8),
|
|
|
|
|
requestedAt: payload.at || now()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
row.status = CONTACT_STATUS.ACCEPTED
|
|
|
|
|
row.acceptedAt = now()
|
|
|
|
|
row.peerDisplayName = payload.fromDisplayName || row.peerDisplayName
|
|
|
|
|
await this.store.insert(CONTACTS_COLLECTION, row)
|
|
|
|
|
this.emit('contact', row)
|
|
|
|
|
return row
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async ingestDecline (payload) {
|
|
|
|
|
const from = payload?.fromUserId
|
|
|
|
|
if (!from) return null
|
|
|
|
|
const row = await this.get(from)
|
|
|
|
|
if (row?.status === CONTACT_STATUS.PENDING_OUT) {
|
|
|
|
|
await this.store.delete(CONTACTS_COLLECTION, contactKey(this.userId, from))
|
|
|
|
|
this.emit('contact', null)
|
|
|
|
|
}
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async ingestRemove (payload) {
|
|
|
|
|
const from = payload?.fromUserId
|
|
|
|
|
if (!from) return null
|
|
|
|
|
await this.store.delete(CONTACTS_COLLECTION, contactKey(this.userId, from))
|
|
|
|
|
this.emit('contact', null)
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_onGossip (method, payload) {
|
|
|
|
|
if (method === RPC.CONTACT_REQUEST) {
|
|
|
|
|
this.ingestRequest(payload).catch(() => {})
|
|
|
|
|
} else if (method === RPC.CONTACT_ACCEPT) {
|
|
|
|
|
this.ingestAccept(payload).catch(() => {})
|
|
|
|
|
} else if (method === RPC.CONTACT_DECLINE) {
|
|
|
|
|
this.ingestDecline(payload).catch(() => {})
|
|
|
|
|
} else if (method === RPC.CONTACT_REMOVE) {
|
|
|
|
|
this.ingestRemove(payload).catch(() => {})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async simulateGossip (method, payload) {
|
|
|
|
|
if (method === RPC.CONTACT_REQUEST) return this.ingestRequest(payload)
|
|
|
|
|
if (method === RPC.CONTACT_ACCEPT) return this.ingestAccept(payload)
|
|
|
|
|
if (method === RPC.CONTACT_DECLINE) return this.ingestDecline(payload)
|
|
|
|
|
if (method === RPC.CONTACT_REMOVE) return this.ingestRemove(payload)
|
|
|
|
|
this._onGossip(method, payload)
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
gossipLocal (method, payload) {
|
|
|
|
|
broadcastContactsGossip(this, method, payload)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async _flushSwarm (ms = Number(process.env.PEARCORD_MESH_FLUSH_MS || 2500)) {
|
|
|
|
|
if (!this.swarm) return
|
|
|
|
|
await Promise.race([
|
|
|
|
|
this.swarm.flush().catch(() => {}),
|
|
|
|
|
new Promise((resolve) => setTimeout(resolve, ms))
|
|
|
|
|
])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async _gossipToPeer (peerUserId, method, payload) {
|
|
|
|
|
if (!this.swarm) {
|
|
|
|
|
this.gossipLocal(method, payload)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
const topic = contactsTopic(peerUserId)
|
|
|
|
|
const buf = topicToBuffer(topic)
|
|
|
|
|
const wasJoined = this._ephemeralTopics.has(topic)
|
|
|
|
|
if (!wasJoined) {
|
|
|
|
|
await this.swarm.join(buf, { server: false, client: true })
|
|
|
|
|
this._ephemeralTopics.add(topic)
|
|
|
|
|
}
|
|
|
|
|
await this._flushSwarm()
|
|
|
|
|
broadcastContactsGossip(this, method, payload)
|
|
|
|
|
if (!wasJoined) {
|
|
|
|
|
await this.swarm.leave(buf).catch(() => {})
|
|
|
|
|
this._ephemeralTopics.delete(topic)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async joinMesh (swarm) {
|
|
|
|
|
if (!swarm) throw new Error('swarm required')
|
|
|
|
|
if (this._meshJoined && this.swarm === swarm) return this
|
|
|
|
|
await this.leaveMesh().catch(() => {})
|
|
|
|
|
this.swarm = swarm
|
|
|
|
|
this.swarm.removeAllListeners('connection')
|
|
|
|
|
this.swarm.on('connection', (conn) => {
|
|
|
|
|
const peerId = b4a.toString(conn.remotePublicKey, 'hex')
|
|
|
|
|
this.peers.set(peerId, conn)
|
|
|
|
|
const ch = attachContactsMesh(this, conn)
|
|
|
|
|
this._channels.set(peerId, ch)
|
|
|
|
|
this.emit('peer', { peerId, type: 'join' })
|
|
|
|
|
conn.on('close', () => {
|
|
|
|
|
this.peers.delete(peerId)
|
|
|
|
|
this._channels.delete(peerId)
|
|
|
|
|
this.emit('peer', { peerId, type: 'leave' })
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
await this.swarm.join(topicToBuffer(contactsTopic(this.userId)), {
|
|
|
|
|
server: true,
|
|
|
|
|
client: true
|
|
|
|
|
})
|
|
|
|
|
await this._flushSwarm(3500)
|
|
|
|
|
this._meshJoined = true
|
|
|
|
|
return this
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async leaveMesh () {
|
|
|
|
|
this.peers.clear()
|
|
|
|
|
this._channels.clear()
|
|
|
|
|
this._ephemeralTopics.clear()
|
|
|
|
|
this._meshJoined = false
|
|
|
|
|
if (this.swarm) {
|
|
|
|
|
await this.swarm.leave(topicToBuffer(contactsTopic(this.userId))).catch(() => {})
|
|
|
|
|
this.swarm.removeAllListeners('connection')
|
|
|
|
|
}
|
|
|
|
|
this.swarm = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getStats () {
|
|
|
|
|
return { peers: this.peers.size }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
module.exports = {
|
|
|
|
|
PearcordContacts,
|
|
|
|
|
CONTACT_STATUS,
|
|
|
|
|
contactsTopic
|
|
|
|
|
}
|