Merge branch 'dev'

This commit is contained in:
Jonathan Staab 2023-03-27 09:15:00 -05:00
commit f51ba597c7
46 changed files with 931 additions and 509 deletions

4
.env
View File

@ -1,2 +1,4 @@
VITE_THEME_DARK=transparent:transparent,black:#0f0f0e,white:#FFFFFF,accent:#EB5E28,accent-light:#FB652C,gray-1:#FFFFFF,gray-2:#FAF6F1,gray-3:#F2EBE1,gray-4:#E9E0D3,gray-5:#B3AA98,gray-6:#565249,gray-7:#393530,gray-8:#252422,danger:#ff0000,warning:#ebd112,success:#37ab51,input:#F2EBE1,input-hover:#FAF6F1
VITE_THEME_DARK=transparent:transparent,black:#0f0f0e,white:#FFFFFF,accent:#EB5E28,accent-light:#FB652C,gray-1:#FFFFFF,gray-2:#FAF6F1,gray-3:#F2EBE1,gray-4:#E9E0D3,gray-5:#B3AA98,gray-6:#565249,gray-7:#393530,gray-8:#252422,danger:#ff0000,warning:#ebd112,success:#37ab51,input:#FAF6F1,input-hover:#F2EBE1
VITE_THEME_LIGHT=transparent:transparent,black:#0f0f0e,white:#FFFFFF,accent:#EB5E28,accent-light:#FB652C,gray-8:#FFFFFF,gray-7:#FAF6F1,gray-6:#F2EBE1,gray-5:#E9E0D3,gray-4:#B3AA98,gray-3:#565249,gray-2:#393530,gray-1:#252422,danger:#ff0000,warning:#ebd112,success:#37ab51,input:#FAF6F1,input-hover:#F2EBE1
VITE_DUFFLEPUD_URL=https://dufflepud.onrender.com
VITE_SHOW_DEBUG_ROUTE=false

View File

@ -1,2 +0,0 @@
VITE_DUFFLEPUD_URL=http://localhost:8000
VITE_SHOW_DEBUG_ROUTE=true

View File

@ -1,27 +1,27 @@
module.exports = {
root: true,
env: {
browser: true,
es2021: true
browser: true,
es2021: true,
},
plugins: ['svelte3', '@typescript-eslint'],
plugins: ["svelte3", "@typescript-eslint"],
overrides: [
{
files: ['*.svelte'],
processor: 'svelte3/svelte3'
}
files: ["*.svelte"],
processor: "svelte3/svelte3",
},
],
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
parser: '@typescript-eslint/parser',
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
tsconfigRootDir: __dirname,
project: ['./tsconfig.json'],
extraFileExtensions: ['.svelte']
ecmaVersion: "latest",
sourceType: "module",
tsconfigRootDir: __dirname,
project: ["./tsconfig.json"],
extraFileExtensions: [".svelte"],
},
settings: {
'svelte3/typescript': require('typescript'),
"svelte3/typescript": require("typescript"),
},
rules: {
"a11y-click-events-have-key-events": "off",
@ -30,6 +30,7 @@ module.exports = {
"no-async-promise-executor": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-extra-semi": "off",
"@typescript-eslint/ban-ts-comment": "off",
"no-useless-escape": "off",
},
ignorePatterns: ["*.svg"],

1
.gitignore vendored
View File

@ -7,6 +7,7 @@ yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
.env.local
node_modules
dist

View File

@ -1,5 +1,18 @@
# Changelog
## 0.2.19
Maintenance release - bugfixes, style fixes, and refactoring. High points are AUTH support, and much improved note composition/mention interpolation.
- [x] Add confirmation to zap dialog
- [x] Avoid pruning profiles we know we'll use more often
- [x] Re-write pool to remove dependency on nostr-tools.relay
- [x] Add support for AUTH
- [x] Use COUNT for counting follows
- [x] Re-write nost composition input
- [x] Add since to feeds to improve time relevance
- [x] Fix a few styling things
## 0.2.18
- [x] Re-write data storage layer to conserve memory using a custom LRU cache

View File

@ -1,9 +1,9 @@
# Current
- [ ] https://github.com/staab/coracle/issues/42
- [ ] Show loading/success on zap invoice screen
- [ ] Fix iOS/safari/firefox
- [ ] Add AUTH
- [ ] https://github.com/staab/coracle/issues/42
- [ ] Multiplex, charge past a certain usage level based on bandwidth
- [ ] Move blog to https://twitter.com/fiatjaf/status/1638514052014940162
# Others
@ -18,16 +18,19 @@
# Custom views
- [ ] Add QR code that pre-fills follows and relays for a new user
- If logged in, open a detail page that shows the relays and people
- If not logged in, pre-populate follows/relays in onboarding flow
- [ ] If someone logs in with their private key, create a notification to install an extension
- [ ] Add customize icon and route with editable custom view cards using "lists" nip
- nevent1qqspjcqw2hu5gfcpkrjhs0aqvxuzjgtp50l375mcqjfpmk48cg5hevgpr3mhxue69uhkummnw3ez6un9d3shjtnhd3m8xtnnwpskxegpzamhxue69uhkummnw3ezuendwsh8w6t69e3xj7spramhxue69uhkummnw3ez6un9d3shjtnwdahxxefwv93kzer9d4usz9rhwden5te0wfjkccte9ejxzmt4wvhxjmcpr9mhxue69uhkummnw3ezuer9d3hjuum0ve68wctjv5n8hwfg
- [ ] Custom views should combine pubkeys, relays, event ids, and topics
# More
- [ ] Add suggestion list for topics on compose so people know there are suggestions
- [ ] Badges link to https://badges.page/p/97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322
- [ ] Link/embed good chat/DM micro-apps
- [ ] Add QR code that pre-fills follows and relays for a new user
- If logged in, open a detail page that shows the relays and people
- If not logged in, pre-populate follows/relays in onboarding flow
- [ ] If someone logs in with their private key, create a notification to install an extension
- [ ] Copy/share note id button
- [ ] Reposts
- [ ] Add delete button to notes. This will require tracking what relays a note was published to

View File

@ -1,2 +0,0 @@
VITE_DUFFLEPUD_URL=https://dufflepud.onrender.com
VITE_SHOW_DEBUG_ROUTE=false

View File

@ -12,7 +12,7 @@
import {find, is, identity, nthArg, pluck} from "ramda"
import {log, warn} from "src/util/logger"
import {timedelta, hexToBech32, bech32ToHex, shuffle, now, sleep} from "src/util/misc"
import {displayPerson, isLike} from "src/util/nostr"
import {displayPerson, Tags, isLike} from "src/util/nostr"
import cmd from "src/agent/cmd"
import {onReady, relays, people} from "src/agent/tables"
import keys from "src/agent/keys"
@ -83,6 +83,14 @@
$: style.textContent = `:root { ${getThemeVariables($theme)}; background: var(--gray-8); }`
// When we get an AUTH challenge from our pool, attempt to authenticate
pool.eventBus.on("AUTH", async (challenge, connection) => {
const publishable = cmd.authenticate(challenge, url)
const [event] = await publishable.publish([{url: connection.url}])
connection.checkAuth(event.id)
})
onMount(() => {
// Keep scroll position on body, but don't allow scrolling
const unsubModal = modal.subscribe($modal => {
@ -272,8 +280,10 @@
<PersonProfileInfo person={$modal.person} />
{:else if $modal.type === "person/share"}
<PersonShare person={$modal.person} />
{:else if $modal.type === "person/list"}
<PersonList pubkeys={$modal.pubkeys} />
{:else if $modal.type === "person/follows"}
<PersonList type="follows" pubkey={$modal.pubkey} />
{:else if $modal.type === "person/followers"}
<PersonList type="followers" pubkey={$modal.pubkey} />
{:else if $modal.type === "message"}
<Content size="lg">
<div class="text-center">{$modal.message}</div>

View File

@ -1,12 +1,20 @@
import {pick, last, prop, uniqBy} from "ramda"
import {map, pick, last, uniqBy} from "ramda"
import {get} from "svelte/store"
import {roomAttrs, displayPerson, findReplyId, findRootId} from "src/util/nostr"
import {getPubkeyWriteRelays, getRelayForPersonHint, sampleRelays} from "src/agent/relays"
import {getRelayForPersonHint} from "src/agent/relays"
import {getPersonWithFallback} from "src/agent/tables"
import pool from "src/agent/pool"
import sync from "src/agent/sync"
import keys from "src/agent/keys"
const authenticate = (challenge, relay) =>
new PublishableEvent(22242, {
tags: [
["challenge", challenge],
["relay", relay],
],
})
const updateUser = updates => new PublishableEvent(0, {content: JSON.stringify(updates)})
const setRelays = newRelays =>
@ -39,18 +47,47 @@ const createDirectMessage = (pubkey, content) =>
new PublishableEvent(4, {content, tags: [["p", pubkey]]})
const createNote = (content, mentions = [], topics = []) => {
mentions = mentions.map(pubkey => {
const name = displayPerson(getPersonWithFallback(pubkey))
const [{url}] = sampleRelays(getPubkeyWriteRelays(pubkey))
const tags = processMentions(mentions).concat(topics.map(t => ["t", t]))
return ["p", pubkey, url, name]
})
topics = topics.map(t => ["t", t])
return new PublishableEvent(1, {content, tags: mentions.concat(topics)})
return new PublishableEvent(1, {content, tags})
}
const createReaction = (note, content) =>
new PublishableEvent(7, {content, tags: getReplyTags(note)})
const createReply = (note, content, mentions = [], topics = []) => {
// Mentions have to come first so interpolation works
const tags = tagsFromParent(note, processMentions(mentions).concat(topics.map(t => ["t", t])))
return new PublishableEvent(1, {content, tags})
}
const requestZap = (relays, content, pubkey, eventId, amount, lnurl) => {
const tags = [
["relays", ...relays],
["amount", amount.toString()],
["lnurl", lnurl],
["p", pubkey],
]
if (eventId) {
tags.push(["e", eventId])
}
return new PublishableEvent(9734, {content, tags})
}
const deleteEvent = ids => new PublishableEvent(5, {tags: ids.map(id => ["e", id])})
// Utils
const processMentions = map(pubkey => {
const name = displayPerson(getPersonWithFallback(pubkey))
const relay = getRelayForPersonHint(pubkey)
return ["p", pubkey, relay?.url || '', name]
})
const getReplyTags = n => {
const {url} = getRelayForPersonHint(n.pubkey, n)
const rootId = findRootId(n) || findReplyId(n) || n.id
@ -85,40 +122,6 @@ const tagsFromParent = (n, newTags = []) => {
)
}
const createReaction = (note, content) =>
new PublishableEvent(7, {content, tags: getReplyTags(note)})
const createReply = (note, content, mentions = [], topics = []) => {
// Mentions have to come first so interpolation works
const tags = tagsFromParent(
note,
mentions
.map(pk => ["p", pk, prop("url", getRelayForPersonHint(pk, note))])
.concat(topics.map(t => ["t", t]))
)
return new PublishableEvent(1, {content, tags})
}
const requestZap = (relays, content, pubkey, eventId, amount, lnurl) => {
const tags = [
["relays", ...relays],
["amount", amount.toString()],
["lnurl", lnurl],
["p", pubkey],
]
if (eventId) {
tags.push(["e", eventId])
}
return new PublishableEvent(9734, {content, tags})
}
const deleteEvent = ids => new PublishableEvent(5, {tags: ids.map(id => ["e", id])})
// Utils
class PublishableEvent {
event: Record<string, any>
constructor(kind, {content = "", tags = []}) {
@ -140,6 +143,7 @@ class PublishableEvent {
}
export default {
authenticate,
updateUser,
setRelays,
setPetnames,

View File

@ -199,8 +199,8 @@ const applyContext = (notes, context) => {
}
export default {
listen,
load,
listen,
loadPeople,
loadParents,
streamContext,

View File

@ -1,34 +1,60 @@
import type {Relay, Filter} from "nostr-tools"
import type {Deferred} from "src/util/misc"
import type {MyEvent} from "src/util/types"
import {relayInit} from "nostr-tools"
import {pluck, is} from "ramda"
import {ensurePlural} from "hurdak/lib/hurdak"
import {verifySignature} from "nostr-tools"
import {pluck, objOf, identity, is} from "ramda"
import {ensurePlural, noop} from "hurdak/lib/hurdak"
import {warn, log, error} from "src/util/logger"
import {union, now, difference} from "src/util/misc"
import {union, EventBus, defer, tryJson, difference} from "src/util/misc"
import {isRelay, normalizeRelayUrl} from "src/util/nostr"
const forceRelays = (import.meta.env.VITE_FORCE_RELAYS || "")
.split(",")
.filter(identity)
.map(objOf("url"))
// Connection management
const eventBus = new EventBus()
const connections = {}
const CONNECTION_STATUS = {
const STATUS = {
NEW: "new",
ERROR: "error",
PENDING: "pending",
CLOSED: "closed",
ERROR: "error",
READY: "ready",
}
const ERROR = {
CONNECTION: "connection",
UNAUTHORIZED: "unauthorized",
FORBIDDEN: "forbidden",
}
class Connection {
promise: Promise<void>
nostr: Relay
status: string
ws?: WebSocket
url: string
promise?: Deferred<void>
queue: string[]
error: {code: string; message: string, occurredAt: number}
status: {code: string; message: string}
timeout?: number
stats: Record<string, number>
lastConnectionAttempt: number
bus: EventBus
constructor(url) {
if (connections[url]) {
error(`Connection to ${url} already exists`)
}
this.ws = null
this.url = url
this.promise = null
this.nostr = relayInit(url)
this.status = "new"
this.queue = []
this.timeout = null
this.bus = new EventBus()
this.error = null
this.stats = {
timeouts: 0,
subsCount: 0,
@ -38,51 +64,180 @@ class Connection {
activeSubsCount: 0,
}
this.status = {code: STATUS.NEW, message: "Waiting to connect"}
this.listenForAuth()
connections[url] = this
}
hasRecentError() {
return this.status === CONNECTION_STATUS.ERROR && now() - this.lastConnectionAttempt < 10
setStatus(code, message) {
this.status = {code, message}
}
async connect() {
const shouldConnect =
this.status === CONNECTION_STATUS.NEW ||
(this.status === CONNECTION_STATUS.ERROR && now() - this.lastConnectionAttempt > 10)
if (shouldConnect) {
this.status = CONNECTION_STATUS.PENDING
this.promise = this.nostr.connect()
this.nostr.on("connect", () => {
this.status = CONNECTION_STATUS.READY
})
this.nostr.on("error", () => {
this.status = CONNECTION_STATUS.ERROR
})
this.nostr.on("disconnect", () => {
this.status = CONNECTION_STATUS.CLOSED
})
setError(code, message) {
this.error = {code, message, occurredAt: Date.now()}
}
connect() {
if (this.ws) {
error("Attempted to connect when already connected", this)
}
this.lastConnectionAttempt = now()
this.promise = defer()
this.ws = new WebSocket(this.url)
this.setStatus(STATUS.PENDING, "Trying to connect")
try {
await this.promise
} catch (e) {
// This is already handled in the on error handler above
this.ws.addEventListener("open", () => {
log(`Opened connection to ${this.url}`)
this.setStatus(STATUS.READY, "Connected")
this.promise.resolve()
})
this.ws.addEventListener("message", e => {
this.queue.push(e.data)
if (!this.timeout) {
this.timeout = window.setTimeout(() => this.handleMessages(), 10)
}
})
this.ws.addEventListener("error", e => {
log(`Error on connection to ${this.url}`)
this.disconnect()
this.promise.reject()
this.setError(ERROR.CONNECTION, "Disconnected")
this.setStatus(STATUS.CLOSED, "Closed")
})
this.ws.addEventListener("close", () => {
log(`Closed connection to ${this.url}`)
this.disconnect()
this.promise.reject()
this.setStatus(STATUS.CLOSED, "Closed")
})
}
disconnect() {
if (this.ws) {
log(`Disconnecting from ${this.url}`)
this.ws.close()
this.ws = null
}
}
async autoConnect() {
// If the connection has not been opened, or was closed, open 'er up
if (!this.error && [STATUS.NEW, STATUS.CLOSED].includes(this.status.code)) {
this.connect()
}
// If the connection failed, try to re-open after a while
if (this.error?.code === ERROR.CONNECTION && Date.now() - 30_000 > this.error.occurredAt) {
this.disconnect()
this.connect()
}
await this.promise.catch(noop)
return this
}
disconnect() {
this.nostr.close()
handleMessages() {
for (const json of this.queue.splice(0, 10)) {
const message = tryJson(() => JSON.parse(json))
delete connections[this.nostr.url]
if (message) {
const [k, ...payload] = message
this.bus.handle(k, ...payload)
}
}
this.timeout = this.queue.length > 0 ? window.setTimeout(() => this.handleMessages(), 10) : null
}
send(...payload) {
if (this.ws?.readyState !== 1) {
warn("Send attempted before socket was ready", this)
}
this.ws.send(JSON.stringify(payload))
}
subscribe(filters, id, {onEvent, onEose}) {
const [eventChannel, eoseChannel] = [
this.bus.on("EVENT", (subid, e) => subid === id && onEvent(e)),
this.bus.on("EOSE", subid => subid === id && onEose()),
]
this.send("REQ", id, ...filters)
return {
conn: this,
unsub: () => {
if (this.status.code === STATUS.READY) {
this.send("CLOSE", id)
}
this.bus.off("EVENT", eventChannel)
this.bus.off("EOSE", eoseChannel)
},
}
}
publish(event, {onOk, onError}) {
const withCleanup = cb => k => {
if (k === event.id) {
cb()
this.bus.off("OK", okChannel)
this.bus.off("ERROR", errorChannel)
}
}
const [okChannel, errorChannel] = [
this.bus.on("OK", withCleanup(onOk)),
this.bus.on("ERROR", withCleanup(onError)),
]
this.send("EVENT", event)
}
count(filter, id, {onCount}) {
const channel = this.bus.on("COUNT", (subid, ...payload) => {
if (subid === id) {
onCount(...payload)
this.bus.off("COUNT", channel)
}
})
this.send("COUNT", id, ...filter)
}
listenForAuth() {
// Propagate auth to global handler
this.bus.on("AUTH", challenge => {
if (!this.error) {
this.setStatus(STATUS.ERROR, "Logging in")
this.setError(ERROR.UNAUTHORIZED, "Logging in")
eventBus.handle("AUTH", challenge, this)
}
})
}
checkAuth(eid) {
const channel = this.bus.on("OK", (id, ok, message) => {
if (id === eid) {
if (ok) {
this.setStatus(STATUS.READY, "Connected")
} else {
this.disconnect()
this.setError(ERROR.FORBIDDEN, "Access restricted")
}
this.bus.off("OK", channel)
}
})
}
hasRecentError() {
return this.error && Date.now() - 30_000 < this.error.occurredAt
}
getQuality() {
if (this.status === CONNECTION_STATUS.ERROR) {
return [0, "Failed to connect"]
if (this.error) {
return [0, this.error.message]
}
const {timeouts, subsCount, eoseTimer, eoseCount} = this.stats
@ -101,17 +256,11 @@ class Connection {
return [eoseQuality, "Connected"]
}
if ([CONNECTION_STATUS.NEW, CONNECTION_STATUS.PENDING].includes(this.status)) {
return [0.5, "Trying to connect"]
}
if (this.status === CONNECTION_STATUS.CLOSED) {
return [0.5, "Disconnected"]
}
if (this.status === CONNECTION_STATUS.READY) {
if (this.status.code === STATUS.READY) {
return [1, "Connected"]
}
return [0.5, this.status.message]
}
}
@ -132,12 +281,24 @@ const connect = url => {
connections[url] = new Connection(url)
}
return connections[url].connect()
return connections[url].autoConnect()
}
const disconnect = url => {
if (connections[url]) {
connections[url].disconnect()
delete connections[url]
}
}
// Public api - publish/subscribe
const publish = async ({relays, event, onProgress, timeout = 5000}) => {
if (forceRelays.length > 0) {
relays = forceRelays
}
if (relays.length === 0) {
error(`Attempted to publish to zero relays`, event)
} else {
@ -195,20 +356,19 @@ const publish = async ({relays, event, onProgress, timeout = 5000}) => {
relays.map(async relay => {
const conn = await connect(relay.url)
if (conn.status === CONNECTION_STATUS.READY) {
const pub = conn.nostr.publish(event)
pub.on("ok", () => {
succeeded.add(relay.url)
timeouts.delete(relay.url)
failed.delete(relay.url)
attemptToResolve()
})
pub.on("failed", reason => {
failed.add(relay.url)
timeouts.delete(relay.url)
attemptToResolve()
if (conn.status.code === STATUS.READY || conn.error.code === ERROR.UNAUTHORIZED) {
conn.publish(event, {
onOk: () => {
succeeded.add(relay.url)
timeouts.delete(relay.url)
failed.delete(relay.url)
attemptToResolve()
},
onError: () => {
failed.add(relay.url)
timeouts.delete(relay.url)
attemptToResolve()
},
})
} else {
failed.add(relay.url)
@ -231,6 +391,10 @@ type SubscribeOpts = {
const subscribe = async ({relays, filter, onEvent, onEose, onError}: SubscribeOpts) => {
filter = ensurePlural(filter)
if (forceRelays.length > 0) {
relays = forceRelays
}
const id = createFilterId(filter)
const now = Date.now()
const seen = new Set()
@ -251,7 +415,7 @@ const subscribe = async ({relays, filter, onEvent, onEose, onError}: SubscribeOp
const promises = relays.map(async relay => {
const conn = await connect(relay.url)
if (conn.status !== "ready") {
if (conn.status.code !== STATUS.READY) {
if (onError) {
onError(relay.url)
}
@ -259,53 +423,43 @@ const subscribe = async ({relays, filter, onEvent, onEose, onError}: SubscribeOp
return
}
const sub = conn.nostr.sub(filter, {
id,
// This isn't currently working for some reason
// alreadyHaveEvent: (id, url) => {
// conn.stats.eventsCount += 1
// if (seen.has(id)) {
// return true
// }
// seen.add(id)
// return false
// },
})
sub.on("event", e => {
if (!seen.has(e.id)) {
seen.add(e.id)
// Normalize events here, annotate with relay url
onEvent({...e, seen_on: relay.url, content: e.content || ""})
}
})
sub.on("eose", () => {
if (onEose) {
onEose(conn.nostr.url)
}
// Keep track of relay timing stats, but only for the first eose we get
if (!eose.has(conn.nostr.url)) {
eose.add(conn.nostr.url)
conn.stats.eoseCount += 1
conn.stats.eoseTimer += Date.now() - now
}
})
conn.stats.subsCount += 1
conn.stats.activeSubsCount += 1
if (conn.stats.activeSubsCount > 10) {
warn(`Relay ${conn.nostr.url} has >10 active subscriptions`)
warn(`Relay ${conn.url} has >10 active subscriptions`)
}
return Object.assign(sub, {conn})
return conn.subscribe(filter, id, {
onEvent: e => {
if (seen.has(e.id)) {
return
}
seen.add(e.id)
if (!verifySignature(e)) {
return
}
conn.stats.eventsCount += 1
onEvent({...e, seen_on: relay.url, content: e.content || ""})
},
onEose: () => {
if (onEose) {
onEose(conn.url)
}
// Keep track of relay timing stats, but only for the first eose we get
if (!eose.has(conn.url)) {
eose.add(conn.url)
conn.stats.eoseCount += 1
conn.stats.eoseTimer += Date.now() - now
}
},
})
})
return {
@ -331,6 +485,22 @@ const subscribe = async ({relays, filter, onEvent, onEose, onError}: SubscribeOp
}
}
const count = async filter => {
const conn = await connect("wss://rbr.bio")
if (!conn || conn.status.code !== STATUS.READY) {
return null
}
filter = ensurePlural(filter)
return new Promise(resolve => {
conn.count(filter, createFilterId(filter), {
onCount: res => resolve(res?.count),
})
})
}
// Utils
const createFilterId = filters =>
@ -353,9 +523,13 @@ const describeFilter = ({kinds = [], ...filter}) => {
}
export default {
eventBus,
forceRelays,
getConnections,
getConnection,
connect,
disconnect,
publish,
subscribe,
count,
}

View File

@ -119,8 +119,19 @@ export const getRelaysForEventChildren = event => {
export const getRelayForEventHint = event => ({url: event.seen_on, score: 1})
export const getRelayForPersonHint = (pubkey, event) =>
first(getPubkeyWriteRelays(pubkey)) || getRelayForEventHint(event)
export const getRelayForPersonHint = (pubkey, event = null) => {
let relays = getPubkeyWriteRelays(pubkey)
if (relays.length === 0 && event) {
relays = [getRelayForEventHint(event)]
}
if (relays.length === 0) {
relays = getPubkeyReadRelays(pubkey)
}
return first(relays)
}
// If we're replying or reacting to an event, we want the author to know, as well as
// anyone else who is tagged in the original event or the reply. Get everyone's read

View File

@ -224,10 +224,14 @@ export const listener = (() => {
}
})()
type WatchStore<T> = Writable<T> & {
refresh: () => void
}
export const watch = (names, f) => {
names = ensurePlural(names)
const store = writable(null)
const store = writable(null) as WatchStore<any>
const tables = names.map(name => registry[name])
// Initialize synchronously if possible
@ -239,12 +243,12 @@ export const watch = (names, f) => {
}
// Debounce refresh so we don't get UI lag
const refresh = throttle(300, async () => store.set(await f(...tables)))
store.refresh = throttle(300, async () => store.set(await f(...tables)))
// Listen for changes
listener.subscribe(name => {
if (names.includes(name)) {
refresh()
store.refresh()
}
})

View File

@ -209,12 +209,15 @@ addHandler(
addHandler(
10002,
profileHandler("relays", (e, p) => {
return e.tags.map(([_, url, mode]) => {
const read = (mode || "read") === "read"
const write = (mode || "write") === "write"
return Tags.from(e)
.type("r")
.all()
.map(([_, url, mode]) => {
const read = (mode || "read") === "read"
const write = (mode || "write") === "write"
return {url, read, write}
})
return {url, read, write}
})
})
)

View File

@ -1,14 +1,24 @@
import {sortBy, pluck, all, identity} from "ramda"
import {derived} from "svelte/store"
import Cache from "src/util/cache"
import {Tags} from "src/util/nostr"
import {Table, listener, registry} from "src/agent/storage"
import user from "src/agent/user"
const sortByCreatedAt = sortBy(([k, x]) => -x.value.created_at)
const sortByCreatedAt = sortBy(([k, x]) => x.value.created_at)
const sortByLastSeen = sortBy(([k, x]) => x.value.last_seen)
// Temporarily put no upper bound on people for 0.2.18 migration
export const people = new Table("people", "pubkey", {
// cache: new Cache({max: 5000}),
// cache: new Cache({max: 20_000}),
cache: new Cache({
max: 10000,
// Don't delete the user's own profile or those of direct follows
sort: xs => {
const follows = Tags.wrap(user.getPetnames()).values().all()
const whitelist = new Set(follows.concat(user.getPubkey()))
return sortBy(([k, {lru, value}]) => (whitelist.has(value.pubkey) ? 0 : value.created_at), xs)
},
}),
})
export const userEvents = new Table("userEvents", "id", {
@ -19,7 +29,9 @@ export const notifications = new Table("notifications", "id")
export const contacts = new Table("contacts", "pubkey")
export const rooms = new Table("rooms", "id")
export const relays = new Table("relays", "url")
export const routes = new Table("routes", "id")
export const routes = new Table("routes", "id", {
cache: new Cache({max: 5000, sort: sortByLastSeen}),
})
listener.connect()

View File

@ -1,6 +1,6 @@
import type {Relay} from "src/util/types"
import type {Readable} from "svelte/store"
import {slice, prop, find, pipe, assoc, whereEq, when, concat, reject, nth, map} from "ramda"
import {slice, uniqBy, prop, find, pipe, assoc, whereEq, when, concat, reject, nth, map} from "ramda"
import {findReplyId, findRootId} from "src/util/nostr"
import {synced} from "src/util/misc"
import {derived} from "svelte/store"
@ -98,7 +98,7 @@ export default {
relays,
getRelays: () => profileCopy.relays,
updateRelays(f) {
const $relays = f(profileCopy.relays)
const $relays = uniqBy(prop('url'), f(profileCopy.relays))
profile.update(assoc("relays", $relays))

View File

@ -18,7 +18,7 @@ setInterval(() => {
// Alert the user to any heinously slow connections
slowConnections.set(
Object.values(pool.getConnections()).filter(
c => relayUrls.has(c.nostr.url) && first(c.getQuality()) < 0.3
c => relayUrls.has(c.url) && first(c.getQuality()) < 0.3
)
)
}, 30_000)

View File

@ -102,7 +102,7 @@ const listen = async pubkey => {
}
listener = await network.listen({
delay: 10000,
delay: 5000,
relays: getUserReadRelays(),
filter: [
{kinds: [1, 4], authors: [pubkey], since},

View File

@ -15,9 +15,10 @@
{"opacity-50": loading},
switcher(type, {
anchor: "underline",
button: "py-2 px-4 rounded bg-input text-accent whitespace-nowrap",
button:
"py-2 px-4 rounded bg-input text-accent whitespace-nowrap border border-solid border-gray-6 hover:bg-input-hover",
"button-circle":
"w-10 h-10 flex justify-center items-center rounded-full bg-input text-accent whitespace-nowrap border border-solid border-gray-5",
"w-10 h-10 flex justify-center items-center rounded-full bg-input text-accent whitespace-nowrap border border-solid border-gray-6 hover:bg-input-hover",
"button-accent":
"py-2 px-4 rounded bg-accent text-white whitespace-nowrap border border-solid border-accent-light hover:bg-accent-light",
})

View File

@ -10,7 +10,7 @@
"py-2 px-4 rounded cursor-pointer border border-solid transition-all",
{"text-gray-5": disabled},
switcher(theme, {
default: "bg-input text-accent border-gray-5 hover:bg-input-hover",
default: "bg-input text-accent border-gray-6 hover:bg-input-hover",
accent: "text-white bg-accent border-accent-light hover:bg-accent-light",
})
)

View File

@ -3,7 +3,7 @@
import {fly} from "svelte/transition"
import {navigate} from "svelte-routing"
import {prop, path as getPath, reverse, pluck, uniqBy, sortBy, last} from "ramda"
import {sleep, createScroller, Cursor} from "src/util/misc"
import {sleep, timedelta, createScroller, Cursor} from "src/util/misc"
import Spinner from "src/partials/Spinner.svelte"
import user from "src/agent/user"
import {getPersonWithFallback} from "src/agent/tables"
@ -18,7 +18,9 @@
let loading = sleep(30_000)
let annotatedMessages = []
let showNewMessages = false
let cursor = new Cursor()
let cursor = new Cursor({
delta: timedelta(7, "days"),
})
$: {
// Group messages so we're only showing the person once per chunk

View File

@ -1,237 +1,173 @@
<script lang="ts">
import {prop, repeat, reject, sortBy, last} from "ramda"
import {onMount} from "svelte"
import {ensurePlural} from "hurdak/lib/hurdak"
import {fly} from "svelte/transition"
import {nip19} from "nostr-tools"
import {last, pluck, propEq} from "ramda"
import {fuzzy} from "src/util/misc"
import {displayPerson} from "src/util/nostr"
import {fromParentOffset} from "src/util/html"
import Badge from "src/partials/Badge.svelte"
import {people} from "src/agent/tables"
import ContentEditable from "src/partials/ContentEditable.svelte"
import Suggestions from "src/partials/Suggestions.svelte"
import {watch} from "src/agent/storage"
import {getPubkeyWriteRelays} from "src/agent/relays"
export let onSubmit
let index = 0
let mentions = []
let suggestions = []
let input = null
let prevContent = ""
let contenteditable, suggestions
const search = fuzzy(people.all({"kind0.name:!nil": null}), {
keys: ["kind0.name", "pubkey"],
const pubkeyEncoder = {
encode: pubkey => {
const relays = pluck("url", getPubkeyWriteRelays(pubkey))
const nprofile = nip19.nprofileEncode({pubkey, relays})
return "nostr:" + nprofile
},
decode: link => {
// @ts-ignore
return nip19.decode(last(link.split(":"))).data.pubkey
},
}
const searchPeople = watch("people", t => {
return fuzzy(t.all({"kind0.name:!nil": null}), {keys: ["kind0.name", "pubkey"]})
})
const getText = () => {
const selection = document.getSelection()
const range = selection.getRangeAt(0)
range.setStartBefore(input)
const text = range.cloneContents().textContent
range.collapse()
return text
const applySearch = word => {
suggestions.setData(word.startsWith("@") ? $searchPeople(word.slice(1)).slice(0, 5) : [])
}
const getWord = () => {
return last(getText().split(/[\s\u200B]+/))
const getInfo = () => {
const selection = window.getSelection()
const {focusNode: node, focusOffset: offset} = selection
const textBeforeCursor = node.textContent.slice(0, offset)
const word = last(textBeforeCursor.trim().split(/\s+/))
return {selection, node, offset, word}
}
const highlightWord = (prefix, chars, content) => {
const text = getText()
const selection = document.getSelection()
const {focusNode, focusOffset} = selection
const prefixElement = document.createTextNode(prefix)
const span = document.createElement("span")
const autocomplete = ({person}) => {
const {selection, node, offset, word} = getInfo()
// Space includes a zero-width space to avoid having the cursor end up inside
// mention span on backspace, and a space for convenience in composition.
const space = document.createTextNode("\u200B\u00a0")
const annotate = (prefix, text, value) => {
const adjustedOffset = offset - word.length + prefix.length
span.classList.add("underline")
span.innerText = content
// Space includes a zero-width space to avoid having the cursor end up inside
// mention span on backspace, and a space for convenience in composition.
const space = document.createTextNode("\u200B\u00A0")
const span = document.createElement("span")
// Remove our partial mention text
selection.setBaseAndExtent(
...fromParentOffset(input, text.length - chars),
focusNode,
focusOffset
)
selection.deleteFromDocument()
span.classList.add("underline")
span.dataset.coracle = JSON.stringify({prefix, value})
span.innerText = text
// Add the prefix, decorated text, and a trailing space
selection.getRangeAt(0).insertNode(prefixElement)
selection.collapse(prefixElement, 1)
selection.getRangeAt(0).insertNode(span)
selection.collapse(span.nextSibling, 0)
selection.getRangeAt(0).insertNode(space)
selection.collapse(space, 2)
}
// Remove our partial mention text
selection.setBaseAndExtent(node, adjustedOffset, node, offset)
selection.deleteFromDocument()
const pickSuggestion = person => {
const display = displayPerson(person)
// Add the span and space
selection.getRangeAt(0).insertNode(span)
selection.collapse(span.nextSibling, 0)
selection.getRangeAt(0).insertNode(space)
selection.collapse(space, 2)
}
highlightWord("@", getWord().length, display)
// Mentions
if (word.length > 1 && word.startsWith("@")) {
annotate("@", displayPerson(person).trim(), pubkeyEncoder.encode(person.pubkey))
}
mentions.push({
pubkey: person.pubkey,
length: display.length + 1,
end: getText().length - 2,
})
// Topics
if (word.length > 1 && word.startsWith("#")) {
annotate("#", word.slice(1), word.slice(1))
}
index = 0
suggestions = []
suggestions.setData([])
}
const onKeyDown = e => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
if (e.code === "Enter" && (e.ctrlKey || e.metaKey)) {
return onSubmit()
}
if (e.key === "Escape" && suggestions[index]) {
index = 0
suggestions = []
// Don't close a modal, submit the form, or lose focus
if (["Escape", "Tab"].includes(e.code)) {
e.preventDefault()
e.stopPropagation()
}
if (["Enter", "Tab", "ArrowUp", "ArrowDown", " "].includes(e.key) && suggestions[index]) {
// If we have suggestions, re-route keyboard commands
if (suggestions.get() && ["Enter", "ArrowUp", "ArrowDown"].includes(e.code)) {
e.preventDefault()
}
// Enter adds a newline, so do it on key down
if (["Enter"].includes(e.code)) {
autocomplete({person: suggestions.get()})
}
}
const onKeyUp = e => {
if (["Enter", "Tab", " "].includes(e.key) && suggestions[index]) {
pickSuggestion(suggestions[index])
const {word} = getInfo()
// Populate search data
applySearch(word)
if (["Tab"].includes(e.code)) {
autocomplete({person: suggestions.get()})
}
if (e.key === "ArrowUp" && suggestions[index - 1]) {
index -= 1
if (["Escape", "Space"].includes(e.code)) {
suggestions.clear()
}
if (e.key === "ArrowDown" && suggestions[index + 1]) {
index += 1
if (e.code === "ArrowUp") {
suggestions.prev()
}
if (input.innerText !== prevContent) {
const text = getText()
const word = getWord()
if (!text.match(/\s$/) && word.startsWith("@")) {
suggestions = search(word.slice(1)).slice(0, 5)
} else {
index = 0
suggestions = []
}
if (input.innerText.length < prevContent.length) {
const delta = prevContent.length - input.innerText.length
const text = getText()
for (const mention of mentions) {
if (mention.end - mention.length > text.length) {
mention.end -= delta
} else if (mention.end > text.length) {
mention.invalid = true
}
}
}
if (e.code === "ArrowDown") {
suggestions.next()
}
if (input.innerText.length > prevContent.length) {
const topic = getText().match(/#([-\w]+\s)$/)
if (topic) {
highlightWord("#", topic[0].length, topic[1].trim())
}
}
prevContent = input.innerText
}
export const trigger = events => {
ensurePlural(events).forEach(onKeyUp)
}
export const mention = person => {
const input = contenteditable.getInput()
const selection = window.getSelection()
const textNode = document.createTextNode("@")
const spaceNode = document.createTextNode(" ")
export const type = text => {
input.innerText += text
// Insert the text node, then an extra node so we don't break stuff in annotate
selection.getRangeAt(0).insertNode(textNode)
selection.collapse(input, 1)
selection.getRangeAt(0).insertNode(spaceNode)
selection.collapse(input, 1)
for (const c of Array.from(text)) {
onKeyUp({key: c})
}
const selection = document.getSelection()
const extent = fromParentOffset(input, input.textContent.length)
selection.setBaseAndExtent(...extent, ...extent)
autocomplete({person})
}
export const parse = () => {
// Interpolate mentions
let offset = 0
let {content, annotations} = contenteditable.parse()
const topics = pluck("value", annotations.filter(propEq("prefix", "#")))
// For whatever reason the textarea gives us 2x - 1 line breaks
let content = input.innerText.replace(/(\n+)/g, x =>
repeat("\n", Math.round(x.length / 2)).join("")
)
// Remove zero-width and non-breaking spaces
content = content.replace(/[\u200B\u00A0]/g, " ").trim()
const validMentions = sortBy(prop("end"), reject(prop("invalid"), mentions))
for (const [i, {end, length}] of validMentions.entries()) {
const offsetEnd = end - offset
const start = offsetEnd - length
const tag = `#[${i}]`
// We're still using old style mention interpolation until NIP-27
// gets merged https://github.com/nostr-protocol/nips/pull/381/files
const mentions = annotations.filter(propEq("prefix", "@")).map(({value}, index) => {
content = content.replace("@" + value, `#[${index}]`)
content = content.slice(0, start) + tag + content.slice(offsetEnd)
offset += length - tag.length
}
// Remove our zero-length spaces
content = content.replace(/\u200B/g, "").trim()
return {
content,
topics: content.match(/#[-\w]+/g) || [],
mentions: validMentions.map(prop("pubkey")),
}
}
onMount(() => {
input.addEventListener("paste", e => {
e.preventDefault()
const selection = window.getSelection()
if (selection.rangeCount) {
selection.deleteFromDocument()
}
type((e.clipboardData || (window as any).clipboardData).getData("text"))
return pubkeyEncoder.decode(value)
})
})
return {content, topics, mentions}
}
</script>
<div class="flex">
<div
class="w-full min-w-0 p-2 text-gray-3 outline-0"
autofocus
contenteditable
bind:this={input}
on:keydown={onKeyDown}
on:keyup={onKeyUp} />
<ContentEditable bind:this={contenteditable} on:keydown={onKeyDown} on:keyup={onKeyUp} />
<slot name="addon" />
</div>
{#if suggestions.length > 0}
<div class="mt-2 flex flex-col rounded border border-solid border-gray-6" in:fly={{y: 20}}>
{#each suggestions as person, i (person.pubkey)}
<button
class="cursor-pointer border-l-2 border-solid border-black py-2 px-4"
class:bg-gray-8={index !== i}
class:bg-gray-7={index === i}
class:border-accent={index === i}
on:click={() => pickSuggestion(person)}>
<Badge inert {person} />
</button>
{/each}
<Suggestions bind:this={suggestions} select={person => autocomplete({person})}>
<div slot="item" let:item>
<Badge inert person={item} />
</div>
{/if}
</Suggestions>

View File

@ -0,0 +1,96 @@
<script lang="ts">
let input = null
// Line breaks are wrapped in divs sometimes
const isLineBreak = node => {
if (node.tagName === "BR") {
return true
}
if (node.tagName === "DIV" && !node.getAttribute?.("style")) {
return true
}
return false
}
const isFancy = node => node instanceof HTMLElement && !isLineBreak(node)
const onInput = e => {
const selection = window.getSelection()
const {focusNode: node, focusOffset: offset} = selection
for (const node of input.childNodes) {
if (isLineBreak(node)) {
continue
}
// Remove gunk that gets copy/pasted, or bold/italic tags that can be added with hotkeys
if (node instanceof HTMLElement && !node.dataset.coracle) {
const text = document.createTextNode(node.textContent)
if (node.tagName === "DIV") {
const div = document.createElement("div")
div.appendChild(text)
node.replaceWith(div)
} else {
node.replaceWith(text)
}
}
}
// If we're editing something we've already linked, un-link it
if (node !== input && node.parentNode !== input && isFancy(node.parentNode)) {
// @ts-ignore
node.parentNode.replaceWith(node)
selection.collapse(node, offset)
input.normalize()
}
}
export const getInput = () => input
const parseNode = node => {
let content = ""
let annotations = []
for (const child of node.childNodes) {
const lineBreaks = child.querySelectorAll?.("br") || []
// Line breaks may be bare brs or divs wrapping brs
if (lineBreaks.length > 0) {
content += "\n".repeat(lineBreaks.length)
} else if (isLineBreak(child)) {
content += "\n"
}
if (child.dataset?.coracle) {
const {prefix, value} = JSON.parse(child.dataset.coracle)
content += value
annotations = annotations.concat({prefix, value})
} else if (child instanceof Text) {
content += child.textContent
} else {
const info = parseNode(child)
content += info.content
annotations = annotations.concat(info.annotations)
}
}
return {content, annotations}
}
export const parse = () => parseNode(input)
</script>
<div
class="w-full min-w-0 p-2 text-gray-3 outline-0"
autofocus
contenteditable
bind:this={input}
on:input={onInput}
on:keydown
on:keyup />

View File

@ -21,8 +21,7 @@
</div>
{/if}
{#if $$slots.after}
<div
class="absolute top-0 right-0 m-px flex gap-2 rounded px-3 pt-3 text-gray-1 text-black opacity-75">
<div class="absolute top-0 right-0 m-px flex gap-2 rounded px-3 pt-3 text-black opacity-75">
<slot name="after" />
</div>
{/if}

View File

@ -20,7 +20,7 @@
</script>
<div
class="m-auto flex max-w-sm flex-col gap-4 rounded border border-solid border-gray-6 bg-gray-7 p-4">
class="m-auto flex max-w-sm flex-col gap-4 rounded border border-solid border-gray-6 bg-gray-8 p-4">
<canvas class="m-auto rounded" bind:this={canvas} />
<Input value={code}>
<button slot="after" class="fa fa-copy" on:click={copy} />

View File

@ -0,0 +1,48 @@
<script lang="ts">
import {fly} from "svelte/transition"
export let select
let data = []
let index = 0
export const setData = d => {
data = d
if (!data[index]) {
index = 0
}
}
export const clear = () => {
index = 0
data = []
}
export const prev = () => {
index = Math.max(0, index - 1)
}
export const next = () => {
index = Math.min(data.length - 1, index + 1)
}
export const get = () => {
return data[index]
}
</script>
{#if data.length > 0}
<div class="mt-2 flex flex-col rounded border border-solid border-gray-6" in:fly={{y: 20}}>
{#each data as item, i}
<button
class="cursor-pointer border-l-2 border-solid border-black py-2 px-4 text-white"
class:bg-gray-8={index !== i}
class:bg-gray-7={index === i}
class:border-accent={index === i}
on:click={() => select(item)}>
<slot name="item" item={item} />
</button>
{/each}
</div>
{/if}

View File

@ -8,8 +8,8 @@
const className = cx(
$$props.class,
"rounded shadow-inset py-2 px-4 pr-10 w-full bg-gray-3 text-gray-8",
"placeholder:text-gray-7 placeholder:opacity-75"
"rounded shadow-inset py-2 px-4 pr-10 w-full bg-input text-black",
"placeholder:text-gray-4 border border-solid border-gray-3"
)
</script>

View File

@ -17,8 +17,10 @@
activeTab = tab
}
const accepted = watch("contacts", t => t.all({accepted: true}))
const requests = watch("contacts", t => t.all({"accepted:!eq": true}))
const accepted = watch("contacts", t => sortBy(e => -e.lastMessage, t.all({accepted: true})))
const requests = watch("contacts", t =>
sortBy(e => -e.lastMessage, t.all({"accepted:!eq": true}))
)
const getDisplay = tab => ({
title: toTitle(tab),

View File

@ -26,7 +26,7 @@
$: events = $notifications
.slice(0, limit)
.map(e => [e, findReplyId(e)])
.filter(([e, ref]) => userEvents.get(ref))
.filter(([e, ref]) => userEvents.get(ref)?.kind === 1)
.reduce((r, [e, ref]) => {
const prev = last(r)
const prevTimestamp = pluck("created_at", prev?.notifications || []).reduce(max, 0)

View File

@ -1,12 +1,13 @@
<script lang="ts">
import {last, find} from "ramda"
import {last, identity, find} from "ramda"
import {onMount} from "svelte"
import {tweened} from "svelte/motion"
import {fly, fade} from "svelte/transition"
import {navigate} from "svelte-routing"
import {log} from "src/util/logger"
import {renderContent, parseHex} from "src/util/html"
import {displayPerson, Tags, toHex} from "src/util/nostr"
import {numberFmt} from "src/util/misc"
import {displayPerson, toHex} from "src/util/nostr"
import Tabs from "src/partials/Tabs.svelte"
import Content from "src/partials/Content.svelte"
import Anchor from "src/partials/Anchor.svelte"
@ -16,9 +17,10 @@
import Likes from "src/views/person/Likes.svelte"
import Relays from "src/views/person/Relays.svelte"
import user from "src/agent/user"
import pool from "src/agent/pool"
import {sampleRelays, getPubkeyWriteRelays} from "src/agent/relays"
import network from "src/agent/network"
import {getPersonWithFallback, people} from "src/agent/tables"
import {getPersonWithFallback} from "src/agent/tables"
import {routes, modal, theme, getThemeColor} from "src/app/ui"
import PersonCircle from "src/partials/PersonCircle.svelte"
@ -29,11 +31,11 @@
const interpolate = (a, b) => t => a + Math.round((b - a) * t)
const {petnamePubkeys, canPublish, mutes} = user
const getRelays = () => sampleRelays(relays.concat(getPubkeyWriteRelays(pubkey)))
const tabs = ["notes", "likes", pool.forceRelays.length === 0 && "relays"].filter(identity)
let pubkey = toHex(npub)
let following = false
let muted = false
let followers = new Set()
let followersCount = tweened(0, {interpolate, duration: 1000})
let person = getPersonWithFallback(pubkey)
let loading = true
@ -77,7 +79,9 @@
})
}
actions.push({onClick: openProfileInfo, label: "Profile", icon: "info"})
if (pool.forceRelays.length === 0) {
actions.push({onClick: openProfileInfo, label: "Profile", icon: "info"})
}
if (user.getPubkey() === pubkey && $canPublish) {
actions.push({
@ -100,27 +104,27 @@
loading = false
})
// Prime our followers count
people.all().forEach(p => {
if (Tags.wrap(p.petnames).type("p").values().all().includes(pubkey)) {
followers.add(p.pubkey)
followersCount.set(followers.size)
}
})
// Get our followers count
const count = await pool.count({kinds: [3], "#p": [pubkey]})
// Round out our followers count
await network.load({
shouldProcess: false,
relays: getRelays(),
filter: [{kinds: [3], "#p": [pubkey]}],
onChunk: events => {
for (const e of events) {
followers.add(e.pubkey)
}
if (count) {
followersCount.set(count)
} else {
const followers = new Set()
followersCount.set(followers.size)
},
})
await network.load({
shouldProcess: false,
relays: getRelays(),
filter: [{kinds: [3], "#p": [pubkey]}],
onChunk: events => {
for (const e of events) {
followers.add(e.pubkey)
}
followersCount.set(followers.size)
},
})
}
})
const toggleActions = () => {
@ -130,13 +134,11 @@
const setActiveTab = tab => navigate(routes.person(pubkey, tab))
const showFollows = () => {
const pubkeys = Tags.wrap(person.petnames).pubkeys()
modal.set({type: "person/list", pubkeys})
modal.set({type: "person/follows", pubkey})
}
const showFollowers = () => {
modal.set({type: "person/list", pubkeys: Array.from(followers)})
modal.set({type: "person/followers", pubkey})
}
const follow = async () => {
@ -227,22 +229,22 @@
<strong>{person.petnames.length}</strong> following
</button>
<button on:click={showFollowers}>
<strong>{$followersCount}</strong> followers
<strong>{numberFmt.format($followersCount)}</strong> followers
</button>
</div>
{/if}
</div>
</div>
<Tabs tabs={["notes", "likes", "relays"]} {activeTab} {setActiveTab} />
<Tabs {tabs} {activeTab} {setActiveTab} />
{#if activeTab === "notes"}
<Notes {pubkey} />
{:else if activeTab === "likes"}
<Likes {pubkey} />
{:else if activeTab === "relays"}
{#if person?.relays}
<Relays {person} />
{#if getRelays().length > 0}
<Relays relays={getRelays()} />
{:else if loading}
<Spinner />
{:else}

View File

@ -9,7 +9,7 @@ type CacheOptions = {
sort?: SortFn
}
const sortByLru = sortBy(([k, x]) => -x.lru)
const sortByLru = sortBy(([k, x]) => x.lru)
export default class Cache {
max: number

View File

@ -73,6 +73,12 @@ export const blobToString = async blob =>
export const blobToFile = blob => new File([blob], blob.name, {type: blob.type})
export const stripHtml = html => {
const doc = new DOMParser().parseFromString(html, "text/html")
return doc.body.textContent || ""
}
export const escapeHtml = html => {
const div = document.createElement("div")

View File

@ -2,6 +2,8 @@ import {bech32, utf8} from "@scure/base"
import {debounce, throttle} from "throttle-debounce"
import {
gt,
whereEq,
reject,
mergeDeepRight,
aperture,
path as getPath,
@ -18,7 +20,7 @@ import {
} from "ramda"
import Fuse from "fuse.js/dist/fuse.min.js"
import {writable} from "svelte/store"
import {isObject, round} from "hurdak/lib/hurdak"
import {isObject, randomId, round} from "hurdak/lib/hurdak"
import {warn} from "src/util/logger"
export const fuzzy = (data, opts = {}) => {
@ -121,7 +123,7 @@ export const poll = (t, cb) => {
}
export const createScroller = (loadMore, {reverse = false} = {}) => {
const THRESHOLD = 1200
const THRESHOLD = 2000
// NOTE TO FUTURE SELF
// If the scroller is saturating request channels on a slow relay, the
@ -142,7 +144,7 @@ export const createScroller = (loadMore, {reverse = false} = {}) => {
}
// No need to check all that often
await sleep(1000)
await sleep(500)
if (!done) {
requestAnimationFrame(check)
@ -162,16 +164,21 @@ export const createScroller = (loadMore, {reverse = false} = {}) => {
export const randomChoice = xs => xs[Math.floor(Math.random() * xs.length)]
export class Cursor {
delta: number
since: number
until: number
limit: number
count: number
constructor(limit = 20) {
constructor({limit = 50, delta = timedelta(6, "hours")}) {
this.delta = delta
this.since = now() - delta
this.until = now()
this.limit = limit
this.count = 0
}
getFilter() {
return {
since: this.since,
until: this.until,
limit: this.limit,
}
@ -206,6 +213,8 @@ export class Cursor {
// Only paginate part of the way so we can avoid missing stuff
this.until -= Math.round(gap * scale * this.limit)
}
this.since = Math.min(this.since, this.until) - this.delta
}
}
@ -226,7 +235,7 @@ export const shuffle = sortBy(() => Math.random() > 0.5)
export const batch = (t, f) => {
const xs = []
const cb = throttle(t, () => f(xs.splice(0)))
const cb = throttle(t, () => xs.length > 0 && f(xs.splice(0)))
return x => {
xs.push(x)
@ -234,7 +243,12 @@ export const batch = (t, f) => {
}
}
export const defer = () => {
export type Deferred<T> = Promise<T> & {
resolve: (arg: T) => void
reject: (arg: T) => void
}
export const defer = (): Deferred<any> => {
let resolve, reject
const p = new Promise((resolve_, reject_) => {
resolve = resolve_
@ -381,11 +395,36 @@ export const hexToBech32 = (prefix, url) =>
export const bech32ToHex = b32 => utf8.encode(bech32.fromWords(bech32.decode(b32, false).words))
export const formatSats = sats => {
const formatter = new Intl.NumberFormat()
export const numberFmt = new Intl.NumberFormat()
if (sats < 1_000) return formatter.format(sats)
if (sats < 1_000_000) return formatter.format(round(1, sats / 1000)) + "K"
if (sats < 100_000_000) return formatter.format(round(1, sats / 1_000_000)) + "MM"
return formatter.format(round(2, sats / 100_000_000)) + "BTC"
export const formatSats = sats => {
if (sats < 1_000) return numberFmt.format(sats)
if (sats < 1_000_000) return numberFmt.format(round(1, sats / 1000)) + "K"
if (sats < 100_000_000) return numberFmt.format(round(1, sats / 1_000_000)) + "MM"
return numberFmt.format(round(2, sats / 100_000_000)) + "BTC"
}
type EventBusListener = {
id: string
handler: (...args: any[]) => void
}
export class EventBus {
listeners: Record<string, Array<EventBusListener>> = {}
on(name, handler) {
const id = randomId()
this.listeners[name] = this.listeners[name] || ([] as Array<EventBusListener>)
this.listeners[name].push({id, handler})
return id
}
off(name, id) {
this.listeners[name] = reject(whereEq({id}), this.listeners[name])
}
handle(k, ...payload) {
for (const {handler} of this.listeners[k] || []) {
handler(...payload)
}
}
}

View File

@ -2,6 +2,7 @@
import cx from "classnames"
import {displayPerson} from "src/util/nostr"
import user from "src/agent/user"
import pool from "src/agent/pool"
import {menuIsOpen, installPrompt, routes} from "src/app/ui"
import {newNotifications, newDirectMessages, newChatMessages} from "src/app/listener"
import {slowConnections} from "src/app/connection"
@ -88,14 +89,16 @@
</a>
</li>
<li class="mx-3 my-4 h-px bg-gray-6" />
<li class="relative cursor-pointer">
<a class="block px-4 py-2 transition-all hover:bg-accent hover:text-white" href="/relays">
<i class="fa fa-server mr-2" /> Relays
{#if $slowConnections.length > 0}
<div class="absolute top-2 left-8 h-2 w-2 rounded bg-accent" />
{/if}
</a>
</li>
{#if pool.forceRelays.length === 0}
<li class="relative cursor-pointer">
<a class="block px-4 py-2 transition-all hover:bg-accent hover:text-white" href="/relays">
<i class="fa fa-server mr-2" /> Relays
{#if $slowConnections.length > 0}
<div class="absolute top-2 left-8 h-2 w-2 rounded bg-accent" />
{/if}
</a>
</li>
{/if}
{#if $profile.pubkey}
<li class="cursor-pointer">
<a class="block px-4 py-2 transition-all hover:bg-accent hover:text-white" href="/keys">

View File

@ -3,7 +3,7 @@
import {partition, always, propEq, uniqBy, sortBy, prop} from "ramda"
import {fly} from "svelte/transition"
import {quantify} from "hurdak/lib/hurdak"
import {createScroller, now, Cursor} from "src/util/misc"
import {createScroller, now, timedelta, Cursor} from "src/util/misc"
import {asDisplayEvent, mergeFilter} from "src/util/nostr"
import Spinner from "src/partials/Spinner.svelte"
import Content from "src/partials/Content.svelte"
@ -15,6 +15,7 @@
export let filter
export let relays = []
export let delta = timedelta(6, "hours")
export let shouldDisplay = always(true)
export let parentsTimeout = 500
@ -24,7 +25,7 @@
// Add a short buffer so we can get the most possible results for recent notes
const since = now()
const maxNotes = 100
const cursor = new Cursor()
const cursor = new Cursor({delta})
const seen = new Set()
const loadBufferedNotes = () => {
@ -66,7 +67,7 @@
// Stream in additional data and merge it in
network.streamContext({
depth: 2,
notes: combined,
notes: combined.filter(propEq("kind", 1)),
onChunk: context => {
context = user.applyMutes(context)

View File

@ -67,7 +67,7 @@
navigate("/notes/follows")
} else {
pool.getConnection(relay.url).disconnect()
pool.disconnect(relay.url)
}
})
}
@ -115,7 +115,9 @@
}}>here</Anchor
>.
</p>
{#if Object.values(currentRelays).length > 0}
{#if pool.forceRelays.length > 0}
<Spinner />
{:else if Object.values(currentRelays).length > 0}
<p>Currently searching:</p>
{#each Object.values(currentRelays) as relay}
<div class="h-12">

View File

@ -26,6 +26,7 @@
import Compose from "src/partials/Compose.svelte"
import Card from "src/partials/Card.svelte"
import user from "src/agent/user"
import pool from "src/agent/pool"
import keys from "src/agent/keys"
import network from "src/agent/network"
import {getEventPublishRelays, getRelaysForEventParent} from "src/agent/relays"
@ -240,6 +241,7 @@
invoice: null,
loading: false,
startedAt: now(),
confirmed: false,
}
}
@ -281,7 +283,10 @@
},
onChunk: chunk => {
note.zaps = note.zaps.concat(chunk)
cleanupZap()
zap.confirmed = true
setTimeout(cleanupZap, 1000)
},
})
}
@ -445,13 +450,15 @@
let:instance
class="flex flex-col gap-2"
on:click={() => instance.hide()}>
<Anchor
type="button-circle"
on:click={() => {
showRelays = true
}}>
<i class="fa fa-server" />
</Anchor>
{#if pool.forceRelays.length === 0}
<Anchor
type="button-circle"
on:click={() => {
showRelays = true
}}>
<i class="fa fa-server" />
</Anchor>
{/if}
{#if muted}
<Anchor type="button-circle" on:click={unmute}>
<i class="fa fa-microphone" />
@ -572,10 +579,19 @@
<h1 class="staatliches text-2xl">Send a zap</h1>
<p>to {displayPerson($person)}</p>
</div>
{#if zap.invoice}
{#if zap.confirmed}
<div class="flex items-center justify-center gap-2 text-gray-1">
<i class="fa fa-champagne-glasses" />
<p>Success! Zap confirmed.</p>
</div>
{:else if zap.invoice}
<QRCode code={zap.invoice} />
<div class="text-center text-gray-1">
<p class="text-center text-gray-1">
Copy or scan using a lightning wallet to pay your zap.
</p>
<div class="flex items-center justify-center gap-2 text-gray-1">
<i class="fa fa-circle-notch fa-spin" />
Waiting for confirmation...
</div>
{:else}
<Textarea bind:value={zap.message} placeholder="Add an optional message" />

View File

@ -5,7 +5,6 @@
import {last, reject, pluck, propEq} from "ramda"
import {fly} from "svelte/transition"
import {fuzzy} from "src/util/misc"
import {displayPerson} from "src/util/nostr"
import Button from "src/partials/Button.svelte"
import Compose from "src/partials/Compose.svelte"
import ImageInput from "src/partials/ImageInput.svelte"
@ -19,13 +18,14 @@
import {getPersonWithFallback} from "src/agent/tables"
import {watch} from "src/agent/storage"
import cmd from "src/agent/cmd"
import user from "src/agent/user"
import {toast, modal} from "src/app/ui"
import {publishWithToast} from "src/app"
export let pubkey = null
let image = null
let input = null
let compose = null
let relays = getUserWriteRelays()
let showSettings = false
let q = ""
@ -43,14 +43,14 @@
}
const onSubmit = async () => {
let {content, mentions, topics} = input.parse()
let {content, mentions, topics} = compose.parse()
if (image) {
content = (content + "\n" + image).trim()
content = content + "\n" + image
}
if (content) {
const thunk = cmd.createNote(content, mentions, topics)
const thunk = cmd.createNote(content.trim(), mentions, topics)
const [event, promise] = await publishWithToast(relays, thunk)
promise.then(() =>
@ -91,11 +91,8 @@
}
onMount(() => {
if (pubkey) {
const person = getPersonWithFallback(pubkey)
input.type("@" + displayPerson(person))
input.trigger({key: "Enter"})
if (pubkey && pubkey !== user.getPubkey()) {
compose.mention(getPersonWithFallback(pubkey))
}
})
</script>
@ -107,7 +104,7 @@
<div class="flex flex-col gap-2">
<strong>What do you want to say?</strong>
<div class="border-l-2 border-solid border-gray-6 pl-4">
<Compose bind:this={input} {onSubmit} />
<Compose bind:this={compose} {onSubmit} />
</div>
</div>
{#if image}

View File

@ -15,6 +15,7 @@
import {getPubkeyWriteRelays, sampleRelays} from "src/agent/relays"
import {getPersonWithFallback} from "src/agent/tables"
import network from "src/agent/network"
import pool from "src/agent/pool"
import user from "src/agent/user"
import keys from "src/agent/keys"
import {loadAppData} from "src/app"
@ -24,15 +25,19 @@
const {relays: userRelays, petnamePubkeys} = user
let relays =
$userRelays.length > 0
? $userRelays
: [
{url: "wss://nostr-pub.wellorder.net", write: true},
{url: "wss://nostr.zebedee.cloud", write: true},
{url: "wss://nos.lol", write: true},
{url: "wss://brb.io", write: true},
]
let relays = []
if ($userRelays.length > 0) {
relays = $userRelays
} else if (pool.forceRelays.length > 0) {
relays = pool.forceRelays
} else {
relays = [
{url: "wss://nostr-pub.wellorder.net", write: true},
{url: "wss://nostr.zebedee.cloud", write: true},
{url: "wss://nos.lol", write: true},
{url: "wss://brb.io", write: true},
]
}
let follows =
$petnamePubkeys.length > 0

View File

@ -5,11 +5,13 @@
import Anchor from "src/partials/Anchor.svelte"
import Heading from "src/partials/Heading.svelte"
import Content from "src/partials/Content.svelte"
import pool from "src/agent/pool"
import {modal, toast} from "src/app/ui"
export let privkey
const nsec = nip19.nsecEncode(privkey)
const nextStage = pool.forceRelays.length > 0 ? "follows" : "relays"
const copyKey = () => {
copyToClipboard(nsec)
@ -28,7 +30,7 @@
<i slot="before" class="fa fa-lock" />
<button slot="after" class="fa fa-copy cursor-pointer" on:click={copyKey} />
</Input>
<Anchor type="button-accent" on:click={() => modal.set({type: "onboarding", stage: "relays"})}>
<Anchor type="button-accent" on:click={() => modal.set({type: "onboarding", stage: nextStage})}>
Log in
</Anchor>
</div>

View File

@ -1,6 +1,7 @@
<script lang="ts">
import Feed from "src/views/feed/Feed.svelte"
import {isLike} from "src/util/nostr"
import {timedelta} from "src/util/misc"
import {sampleRelays, getPubkeyWriteRelays} from "src/agent/relays"
export let pubkey
@ -10,4 +11,4 @@
const shouldDisplay = e => isLike(e.content)
</script>
<Feed {relays} {filter} {shouldDisplay} parentsTimeout={10_000} />
<Feed {relays} {filter} {shouldDisplay} parentsTimeout={10_000} delta={timedelta(1, "days")} />

View File

@ -1,4 +1,5 @@
<script lang="ts">
import {timedelta} from "src/util/misc"
import Feed from "src/views/feed/Feed.svelte"
import {sampleRelays, getPubkeyWriteRelays} from "src/agent/relays"
@ -8,4 +9,4 @@
const filter = {kinds: [1], authors: [pubkey]}
</script>
<Feed {relays} {filter} parentsTimeout={3000} />
<Feed {relays} {filter} parentsTimeout={3000} delta={timedelta(1, "days")} />

View File

@ -1,19 +1,49 @@
<script type="ts">
import {onMount} from "svelte"
import {uniq, sortBy, pluck} from "ramda"
import {Tags} from "src/util/nostr"
import Content from "src/partials/Content.svelte"
import Spinner from "src/partials/Spinner.svelte"
import PersonInfo from "src/views/person/PersonInfo.svelte"
import {sampleRelays, getPubkeyWriteRelays} from "src/agent/relays"
import {getPersonWithFallback} from "src/agent/tables"
import {watch} from "src/agent/storage"
import network from "src/agent/network"
export let pubkeys
export let type
export let pubkey
const people = watch("people", t => pubkeys.map(getPersonWithFallback))
let pubkeys = []
network.loadPeople(pubkeys)
const person = getPersonWithFallback(pubkey)
const people = watch("people", t => {
return sortBy(p => (p.kind0 ? 0 : 1), pubkeys.map(getPersonWithFallback))
})
onMount(async () => {
if (type === "follows") {
pubkeys = Tags.wrap(person.petnames).values().all()
people.refresh()
} else {
await network.load({
shouldProcess: false,
relays: sampleRelays(getPubkeyWriteRelays(pubkey)),
filter: [{kinds: [3], "#p": [pubkey]}],
onChunk: events => {
pubkeys = uniq(pubkeys.concat(pluck("pubkey", events)))
people.refresh()
},
})
}
network.loadPeople(pubkeys)
})
</script>
<Content gap={2}>
{#each $people || [] as person}
<PersonInfo {person} />
{:else}
<Spinner />
{/each}
</Content>

View File

@ -108,7 +108,9 @@
</div>
</div>
{/if}
<h1 class="staatliches mt-4 text-2xl">NIP05</h1>
{#if loaded && person.verified_as}
<div>
<div class="mb-1 text-lg">NIP05 Identifier</div>
@ -121,7 +123,6 @@
{person.verified_as || "?"}
</div>
</div>
<div>
<div class="mb-1 text-lg">NIP05 Validation Endpoint</div>
<div class="font-mono text-sm">
@ -134,7 +135,6 @@
{nip05QueryEndpoint || "?"}
</div>
</div>
{#if nip05ProfileData}
<div>
<div class="mb-2 text-lg">NIP05 Relay Configuration</div>

View File

@ -3,7 +3,7 @@
import Content from "src/partials/Content.svelte"
import RelayCard from "src/views/relays/RelayCard.svelte"
export let person
export let relays
</script>
<div in:fly={{y: 20}}>
@ -12,10 +12,10 @@
Below are the relays this user publishes to. Join one or more to make sure you never miss
their updates.
</p>
{#if (person.relays || []).length === 0}
{#if relays.length === 0}
<div class="pt-8 text-center">No relays found</div>
{:else}
{#each person.relays as relay (relay.url)}
{#each relays as relay (relay.url)}
{#if relay.write !== "!"}
<RelayCard {relay} />
{/if}

View File

@ -1,10 +1,10 @@
import * as path from 'path'
import {defineConfig} from 'vite'
import {VitePWA} from 'vite-plugin-pwa'
import mkcert from 'vite-plugin-mkcert'
import sveltePreprocess from 'svelte-preprocess'
import {svelte} from '@sveltejs/vite-plugin-svelte'
import {nodePolyfills} from 'vite-plugin-node-polyfills'
import * as path from "path"
import {defineConfig} from "vite"
import {VitePWA} from "vite-plugin-pwa"
import mkcert from "vite-plugin-mkcert"
import sveltePreprocess from "svelte-preprocess"
import {svelte} from "@sveltejs/vite-plugin-svelte"
import {nodePolyfills} from "vite-plugin-node-polyfills"
export default defineConfig({
server: {
@ -15,8 +15,8 @@ export default defineConfig({
},
resolve: {
alias: {
src: path.resolve(__dirname, 'src'),
}
src: path.resolve(__dirname, "src"),
},
},
plugins: [
mkcert(),
@ -24,13 +24,13 @@ export default defineConfig({
protocolImports: true,
}),
VitePWA({
registerType: 'autoUpdate',
injectRegister: 'auto',
registerType: "autoUpdate",
injectRegister: "auto",
manifest: {
name: 'Coracle',
short_name: 'Coracle',
description: 'Nostr, your way.',
theme_color: '#EB5E28',
name: "Coracle",
short_name: "Coracle",
description: "Nostr, your way.",
theme_color: "#EB5E28",
icons: [
{type: "image/png", sizes: "192x192", src: "/images/favicon/android-icon-192x192.png"},
{type: "image/png", sizes: "512x512", src: "/images/favicon/android-icon-512x512.png"},
@ -40,8 +40,7 @@ export default defineConfig({
svelte({
preprocess: sveltePreprocess(),
onwarn: (warning, handler) => {
if (warning.code.startsWith('a11y-')) return
if (warning.code.startsWith("a11y-")) return
if (warning.filename.includes("node_modules")) return
handler(warning)