mirror of
https://github.com/coracle-social/coracle.git
synced 2024-09-18 19:23:40 +00:00
Merge branch 'dev'
This commit is contained in:
commit
f51ba597c7
4
.env
4
.env
@ -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
|
||||
|
@ -1,2 +0,0 @@
|
||||
VITE_DUFFLEPUD_URL=http://localhost:8000
|
||||
VITE_SHOW_DEBUG_ROUTE=true
|
@ -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
1
.gitignore
vendored
@ -7,6 +7,7 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
.env.local
|
||||
|
||||
node_modules
|
||||
dist
|
||||
|
13
CHANGELOG.md
13
CHANGELOG.md
@ -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
|
||||
|
17
ROADMAP.md
17
ROADMAP.md
@ -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
|
||||
|
@ -1,2 +0,0 @@
|
||||
VITE_DUFFLEPUD_URL=https://dufflepud.onrender.com
|
||||
VITE_SHOW_DEBUG_ROUTE=false
|
@ -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>
|
||||
|
@ -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,
|
||||
|
@ -199,8 +199,8 @@ const applyContext = (notes, context) => {
|
||||
}
|
||||
|
||||
export default {
|
||||
listen,
|
||||
load,
|
||||
listen,
|
||||
loadPeople,
|
||||
loadParents,
|
||||
streamContext,
|
||||
|
@ -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,
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -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}
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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))
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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},
|
||||
|
@ -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",
|
||||
})
|
||||
|
@ -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",
|
||||
})
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
96
src/partials/ContentEditable.svelte
Normal file
96
src/partials/ContentEditable.svelte
Normal 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 />
|
@ -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}
|
||||
|
@ -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} />
|
||||
|
48
src/partials/Suggestions.svelte
Normal file
48
src/partials/Suggestions.svelte
Normal 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}
|
@ -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>
|
||||
|
||||
|
@ -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),
|
||||
|
@ -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)
|
||||
|
@ -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}
|
||||
|
@ -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
|
||||
|
@ -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")
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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">
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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">
|
||||
|
@ -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" />
|
||||
|
@ -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}
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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")} />
|
||||
|
@ -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")} />
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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}
|
||||
|
@ -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)
|
||||
|
Loading…
Reference in New Issue
Block a user