chore: publish pearcord-contacts from pearcord workspace

This commit is contained in:
Pearcord
2026-05-21 20:04:54 -04:00
commit 7deab8e1af
4 changed files with 426 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
# pearcord-contacts
P2P friend requests and contact list.
Part of **[Pearcord](https://git.ssh.surf/pearcord)** — 100% peer-to-peer community chat on [Pear](https://pear.holepunch.to). No central servers.
## Repository
- **Org:** [`pearcord`](https://git.ssh.surf/pearcord)
- **Clone:** `git clone https://git.ssh.surf/pearcord/pearcord-contacts.git`
## Install (npm)
```bash
npm install git+https://git.ssh.surf/pearcord/pearcord-contacts.git#main
```
## Stack
Hyperswarm · HyperDB · Protomux · Pear / Bare
## Documentation
See [`pearcord/pearcord-docs`](https://git.ssh.surf/pearcord/pearcord-docs) for architecture, roadmap, and IPC reference.
## License
Pearcord modules are developed for the Pearcord platform. See the org README for contribution guidelines.
+339
View File
@@ -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
}
+44
View File
@@ -0,0 +1,44 @@
'use strict'
const Protomux = require('protomux')
const b4a = require('b4a')
const { encodeRpc, decodeRpc } = require('pearcord-shared')
const CONTACTS_PROTOCOL = 'pearcord-contacts-v1'
function attachContactsMesh (contacts, conn) {
const mux = Protomux.from(conn)
const channel = mux.createChannel({
protocol: CONTACTS_PROTOCOL,
onopen () {
channel.open()
},
onmessage (buf) {
let packet
try {
packet = decodeRpc(buf)
const payload = JSON.parse(b4a.toString(packet.payload))
contacts._onGossip(packet.method, payload, conn)
} catch {
// ignore malformed
}
}
})
return channel
}
function broadcastContactsGossip (contacts, method, payload) {
const buf = encodeRpc(method, b4a.from(JSON.stringify(payload)))
for (const ch of contacts._channels.values()) {
ch.fullyOpened().then((ok) => {
if (!ok) return
try {
ch.send(buf)
} catch {
// peer gone
}
}).catch(() => {})
}
}
module.exports = { attachContactsMesh, broadcastContactsGossip, CONTACTS_PROTOCOL }
+15
View File
@@ -0,0 +1,15 @@
{
"name": "pearcord-contacts",
"version": "0.1.0",
"main": "index.js",
"type": "commonjs",
"description": "P2P friend requests and contact list for Pearcord",
"dependencies": {
"bare-events": "^2.8.0",
"b4a": "^1.6.7",
"hyperswarm": "^4.8.0",
"protomux": "^3.0.0",
"pearcord-db": "git+https://git.ssh.surf/pearcord/pearcord-db.git#main",
"pearcord-shared": "git+https://git.ssh.surf/pearcord/pearcord-shared.git#main"
}
}