nip-02 along with a hacky implementation.

This commit is contained in:
fiatjaf 2020-12-07 23:56:21 -03:00
parent 4d4c7d1c48
commit f3872fb6f7
10 changed files with 249 additions and 52 deletions

51
nips/02.md Normal file
View File

@ -0,0 +1,51 @@
NIP-02
======
Petname sharing through a special event `3`: "contact list"
-------------------------------------------------------
`draft` `optional`
A special event with kind `3`, meaning "contact list" is defined as having a `content` equal to a JSON string (yes, encoded JSON inside a JSON string) that decodes to the a list of tuples of `<pubkey>` (32 bytes hex) and `<name>` (arbitrary UTF-8 name, maximum 32 characters), as the following example:
```json
[
["91cf9c4c5d9b675c00afd68db164e5ca", "alice"],
["14aeb4daf2045e469f2dff496f48dad4", "bob"],
["612ae030a3856e463a363390250e610f", "carol"]
]
```
Every new contact list that gets published overwrites the past ones, so it should contain all entries. Relays and clients SHOULD delete past contact lists as soon as they receive a new one.
This can be published by anyone and used by clients to construct local ["petname"](http://www.skyhunter.com/marcs/petnames/IntroPetNames.html) tables derived from other people's contact lists.
## Example
A user has an internal contact list that says
```json
[
["21df6d143fb96c2ec9d63726bf9edc71", "erin"]
]
```
And receives two contact lists, one from `21df6d143fb96c2ec9d63726bf9edc71` that says
```json
[
["a8bb3d884d5d90b413d9891fe4c4e46d", "david"]
]
```
and another from `a8bb3d884d5d90b413d9891fe4c4e46d` that says
```json
[
["f57f54057d2a7af0efecc8b0b66f5708", "frank"]
]
```
When the user sees `21df6d143fb96c2ec9d63726bf9edc71` the client can show _erin_ instead;
When the user sees `a8bb3d884d5d90b413d9891fe4c4e46d` the client can show _david.erin_ instead;
When the user sees `f57f54057d2a7af0efecc8b0b66f5708` the client can show _frank.david.erin_ instead.

View File

@ -14,6 +14,7 @@ const (
KindSetMetadata uint8 = 0 KindSetMetadata uint8 = 0
KindTextNote uint8 = 1 KindTextNote uint8 = 1
KindRecommendServer uint8 = 2 KindRecommendServer uint8 = 2
KindContactList uint8 = 3
) )
type Event struct { type Event struct {

View File

@ -53,15 +53,18 @@ func saveEvent(w http.ResponseWriter, r *http.Request) {
// react to different kinds of events // react to different kinds of events
switch evt.Kind { switch evt.Kind {
case 0: case KindSetMetadata:
// delete past set_metadata events from this user // delete past set_metadata events from this user
db.Exec(`DELETE FROM event WHERE pubkey = $1 AND kind = 1`, evt.PubKey) db.Exec(`DELETE FROM event WHERE pubkey = $1 AND kind = 1`, evt.PubKey)
case 1: case KindTextNote:
// do nothing // do nothing
case 2: case KindRecommendServer:
// delete past recommend_server events that match this one // delete past recommend_server events equal to this one
db.Exec(`DELETE FROM event WHERE pubkey = $1 AND kind = 2 AND content = $2`, db.Exec(`DELETE FROM event WHERE pubkey = $1 AND kind = 2 AND content = $2`,
evt.PubKey, evt.Content) evt.PubKey, evt.Content)
case KindContactList:
// delete past contact lists from this same pubkey
db.Exec(`DELETE FROM event WHERE pubkey = $1 AND kind = 3`, evt.PubKey)
} }
// insert // insert

View File

@ -12,7 +12,7 @@
/> />
</div> </div>
<div> <div>
<form v-if="editingName !== null" @submit="savePetName"> <form v-if="editingName !== null" @submit="setContactName">
<label> <label>
Petname: Petname:
<input v-model="editingName" /> <input v-model="editingName" />
@ -110,9 +110,9 @@
this.$store.dispatch('publishMetadata', this.editingMetadata) this.$store.dispatch('publishMetadata', this.editingMetadata)
this.editingMetadata = null this.editingMetadata = null
}, },
savePetName(e) { setContactName(e) {
e.preventDefault() e.preventDefault()
this.$store.commit('savePetName', { this.$store.dispatch('setContactName', {
pubkey: this.$route.params.key, pubkey: this.$route.params.key,
name: this.editingName name: this.editingName
}) })

View File

@ -1,11 +1,19 @@
// vuex store actions // vuex store actions
import {verifySignature, publishEvent, broadcastEvent} from './helpers' import {
verifySignature,
publishEvent,
broadcastEvent,
overwriteEvent
} from './helpers'
import { import {
CONTEXT_NOW, CONTEXT_NOW,
CONTEXT_REQUESTED,
CONTEXT_PAST,
KIND_METADATA, KIND_METADATA,
KIND_TEXTNOTE, KIND_TEXTNOTE,
KIND_RECOMMENDSERVER KIND_RECOMMENDSERVER,
KIND_CONTACTLIST
} from './constants' } from './constants'
import {db} from './globals' import {db} from './globals'
@ -34,7 +42,25 @@ export default {
switch (event.kind) { switch (event.kind) {
case KIND_METADATA: case KIND_METADATA:
store.commit('receivedSetMetadata', {event, context}) if (context === CONTEXT_REQUESTED) {
// just someone we're viewing
store.commit('receivedSetMetadata', {event, context})
} else if (context === CONTEXT_NOW) {
// an update from someone we follow that happened just now
store.commit('receivedSetMetadata', {event, context})
await db.events
.where({kind: KIND_METADATA, pubkey: event.pubkey})
.delete()
await db.events.put(event)
} else if (context === CONTEXT_PAST) {
// someone we follow, but an old update -- check first
// check first if we don't have a newer one
let foundNewer = await overwriteEvent(
{kind: KIND_METADATA, pubkey: event.pubkey},
event
)
if (!foundNewer) store.commit('receivedSetMetadata', {event, context})
}
break break
case KIND_TEXTNOTE: case KIND_TEXTNOTE:
store.commit('receivedTextNote', {event, context}) store.commit('receivedTextNote', {event, context})
@ -73,6 +99,62 @@ export default {
store.commit('loadedRelays', await db.relays.toArray()) store.commit('loadedRelays', await db.relays.toArray())
break break
case KIND_CONTACTLIST:
// if (!(event.pubkey in store.state.petnames)) {
// // we don't know this person, so we won't use their contact list
// return
// }
// check if we have a newest version already
let foundNewer = await overwriteEvent(
{pubkey: event.pubkey, kind: KIND_CONTACTLIST},
event
)
// process
if (!foundNewer) store.dispatch('processContactList', event)
break
}
},
async setContactName(store, {pubkey, name}) {
db.contactlist.put({pubkey, name})
store.commit('savePetName', {pubkey, name: [name]})
// publish our new contact list
publishEvent(
{
pubkey: store.getters.pubKeyHex,
created_at: Math.round(new Date().getTime() / 1000),
kind: KIND_CONTACTLIST,
content: JSON.stringify(
(await db.contactlist.toArray()).map(({pubkey, name}) => [
pubkey,
name
])
)
},
store.state.key,
store.getters.writeServers
)
},
processContactList(store, event) {
// parse event content
var entries = []
try {
entries = JSON.parse(event.content)
} catch (err) {
return
}
for (let i = 0; i < entries.length; i++) {
let [pubkey, name] = entries[i]
if (pubkey in store.state.petnames) {
// we have our own petname for this key already
continue
}
store.commit('savePetName', {pubkey, name: [name, event.pubkey]})
} }
}, },
async addRelay(store, relay) { async addRelay(store, relay) {

View File

@ -1,6 +1,7 @@
export const KIND_METADATA = 0 export const KIND_METADATA = 0
export const KIND_TEXTNOTE = 1 export const KIND_TEXTNOTE = 1
export const KIND_RECOMMENDSERVER = 2 export const KIND_RECOMMENDSERVER = 2
export const KIND_CONTACTLIST = 3
export const CONTEXT_REQUESTED = 'r' export const CONTEXT_REQUESTED = 'r'
export const CONTEXT_PAST = 'p' export const CONTEXT_PAST = 'p'

View File

@ -3,17 +3,24 @@ import Dexie from 'dexie'
export const db = new Dexie('db') export const db = new Dexie('db')
db.version(1).stores({ db.version(1).stores({
// local personal settings and store
relays: 'host', relays: 'host',
following: 'pubkey', following: 'pubkey',
mynotes: 'id, kind, created_at', mynotes: 'id, kind, created_at',
cachedmetadata: 'pubkey',
publishlog: '[id+host]', publishlog: '[id+host]',
// any special events from other people we may want to use later
// like metadata or contactlists from others, for example
// not simple notes or transient things
events: 'id, pubkey, kind',
// the set of local pet names and maybe other things we assign to others
contactlist: 'pubkey' contactlist: 'pubkey'
}) })
if (localStorage.getItem('deleted') < '3') { if (localStorage.getItem('deleted') < '4') {
db.delete().then(() => { db.delete().then(() => {
localStorage.setItem('deleted', '3') localStorage.setItem('deleted', '4')
location.reload() location.reload()
}) })
} }

View File

@ -89,3 +89,24 @@ export function serializeEvent(evt) {
return Buffer.concat([version, pubkey, time, kind, reference, content]) return Buffer.concat([version, pubkey, time, kind, reference, content])
} }
export async function overwriteEvent(conditions, event) {
let events = await db.events.where(conditions).toArray()
for (let i = 0; i < events.length; i++) {
// only save if it's newer than what we have
let evt = events[i]
if (evt.created_at > event.created_at) {
// we found a newer one
return true
}
// this is older, delete it
db.events.delete(evt.id)
}
// we didn't find a newer one
await db.events.put(event)
return false
}

View File

@ -34,37 +34,56 @@ export default {
state.following.splice(state.following.indexOf(key), 1) state.following.splice(state.following.indexOf(key), 1)
db.following.delete(key) db.following.delete(key)
}, },
savePetName(state, {pubkey, name}) { savePetName(state, payload) {
state.petnames[pubkey] = name state.petnames[payload.pubkey] = state.petnames[payload.pubkey] || []
db.contactlist.put({pubkey, name}) // maybe stop here if we already have enough names for <payload.pubkey>?
}, if (state.petnames[payload.pubkey].length > 4) {
receivedSetMetadata(state, {event, context}) { return
let meta = JSON.parse(event.content)
let storeable = {
pubkey: event.pubkey,
time: event.created_at,
meta
} }
if (context === CONTEXT_REQUESTED) { if (payload.name.length === 2) {
// just someone we're viewing // this is a hierarchy of type [<name>, <author-pubkey>]
if (!state.metadata.has(event.pubkey)) { if (payload.name[1] in state.petnames) {
state.metadata.set(event.pubkey, meta) // if we have any names for <author-pubkey>, replace them here
state.petnames[payload.name[1]].forEach(supername => {
state.petnames[payload.pubkey].push([payload.name[0], ...supername])
})
// and keep all references to [<name>, <replaced-hierarchy>]?
// maybe keeping just some is better?
state.petnames[payload.pubkey].sort((a, b) => a.length - b.length)
state.petnames = state.petnames.slice(0, 5)
} else {
// otherwise save this in raw form
state.petnames[payload.pubkey].push(payload.name)
} }
} else if (context === CONTEXT_NOW) { } else if (payload.name.length === 1) {
// an update from someone we follow that happened just now // also save this in raw form if it's a single thing
state.metadata.set(event.pubkey, meta) state.petnames[payload.pubkey].push(payload.name)
db.cachedmetadata.put(storeable)
} else if (context === CONTEXT_PAST) {
// someone we follow, but an old update
db.cachedmetadata.get(event.pubkey).then(data => {
// only save if it's newer than what we have
if (!data || data.time < storeable.time) {
state.metadata.set(event.pubkey, meta)
db.cachedmetadata.put(storeable)
}
})
} }
// now search all our other names for any that uses <payload.pubkey>
for (let pubkey in state.petnames) {
if (pubkey === payload.pubkey) continue
let names = state.petnames[pubkey]
for (let i = 0; i < names.length; i++) {
let name = names[i]
if (name[name.length - 1] === payload.pubkey) {
// found one, replace it with the name we just got
names[i] = name.slice(0, -1).concat(payload.name)
}
}
}
// print this mess
console.log(state.petnames)
},
receivedSetMetadata(state, {event, context}) {
try {
let meta = JSON.parse(event.content)
state.metadata.set(event.pubkey, meta)
} catch (err) {}
}, },
receivedTextNote(state, {event: evt, context}) { receivedTextNote(state, {event: evt, context}) {
// all notes go to browsing // all notes go to browsing

View File

@ -6,6 +6,7 @@ import {pubkeyFromPrivate, makeRandom32} from './helpers'
import {db} from './globals' import {db} from './globals'
import actions from './actions' import actions from './actions'
import mutations from './mutations' import mutations from './mutations'
import {KIND_METADATA, KIND_CONTACTLIST} from './constants'
export default createStore({ export default createStore({
plugins: (process.env.NODE_ENV !== 'production' plugins: (process.env.NODE_ENV !== 'production'
@ -57,10 +58,11 @@ export default createStore({
.filter(({policy}) => policy.indexOf('r') !== -1) .filter(({policy}) => policy.indexOf('r') !== -1)
.map(({host}) => host), .map(({host}) => host),
keyName: state => pubkey => keyName: state => pubkey =>
state.petnames[pubkey] || state.petnames[pubkey]
(state.metadata.get(pubkey) || {}).name || ? state.petnames[pubkey].map(name => name.join('.')).join(', ')
(pubkey && pubkey.slice(0, 4) + '…' + pubkey.slice(-4)) || : (state.metadata.get(pubkey) || {}).name ||
'' (pubkey && pubkey.slice(0, 4) + '…' + pubkey.slice(-4)) ||
''
}, },
mutations, mutations,
actions actions
@ -87,17 +89,21 @@ async function init(store) {
(a, b) => b.split(':')[1] - a.split(':')[1] (a, b) => b.split(':')[1] - a.split(':')[1]
) )
}), }),
db.cachedmetadata.toArray().then(metas => { db.events
var metadata = {} .where({kind: KIND_METADATA})
metas.forEach(({meta, pubkey}) => { .toArray()
metadata[pubkey] = meta .then(events => {
}) var metadata = {}
return metadata events.forEach(({content, pubkey}) => {
}), let meta = JSON.parse(content)
metadata[pubkey] = meta
})
return metadata
}),
db.contactlist.toArray().then(contacts => { db.contactlist.toArray().then(contacts => {
var petnames = {} var petnames = {}
contacts.forEach(({pubkey, name}) => { contacts.forEach(({pubkey, name}) => {
petnames[pubkey] = name petnames[pubkey] = [[name]]
}) })
return petnames return petnames
}) })
@ -110,6 +116,12 @@ async function init(store) {
metadata: data[3], metadata: data[3],
petnames: data[4] petnames: data[4]
}) })
// process contact lists (nip-02)
let events = await db.events.where({kind: KIND_CONTACTLIST}).toArray()
for (let i = 0; i < events.length; i++) {
store.dispatch('processContactList', events[i])
}
} }
function publishStatusLoader(store) { function publishStatusLoader(store) {