mirror of
https://github.com/coracle-social/coracle.git
synced 2024-09-19 11:43:35 +00:00
Re-work chat/dms
This commit is contained in:
parent
6a7706aa07
commit
14a638c469
12
ROADMAP.md
12
ROADMAP.md
@ -1,6 +1,10 @@
|
||||
# Current
|
||||
|
||||
- [ ] Add messages to database after visiting a chat/dm detail *shrugs shoulders*
|
||||
- [ ] Move non-partials to pod file structure
|
||||
- [ ] Await publish, show error if it fails or times out
|
||||
- Show loading
|
||||
- [ ] Test chat/dms
|
||||
|
||||
# Next
|
||||
|
||||
@ -22,6 +26,8 @@
|
||||
|
||||
# More
|
||||
|
||||
- [ ] Add suggested relays based on follows or topics
|
||||
- [ ] Combine alerts/messages and any other top-level subscriptions to avoid sub limit
|
||||
- [ ] Clean up person detail actions, maybe click one circle and show the rest
|
||||
- [ ] Hover badge to view profile like twitter
|
||||
- [ ] Show created date as bitcoin block height (add a setting?)
|
||||
@ -66,8 +72,12 @@
|
||||
- [ ] Release to android
|
||||
- https://svelte-native.technology/docs
|
||||
- https://ionic.io/blog/capacitor-everything-youve-ever-wanted-to-know
|
||||
- [ ] When publishing fails, enqueue and retry
|
||||
- Track which relays the events should be published to, and which ones have succeeded
|
||||
- Maybe notify and ask user which events to re-publish.
|
||||
- [ ] Add no-relay gossip
|
||||
- Capture certain events in a local db
|
||||
- Capture user events in a local db
|
||||
- Possibly release "local relay" as a library
|
||||
- File import/export from db, NFC transfer
|
||||
- Save user notes to db
|
||||
- Fixes when you hide something, but the event doesn't get retrived, and it gets un-hidden
|
||||
|
@ -23,8 +23,7 @@
|
||||
import user from 'src/agent/user'
|
||||
import {loadAppData} from "src/app"
|
||||
import alerts from "src/app/alerts"
|
||||
import messages from "src/app/messages"
|
||||
import {modal, toast, routes, menuIsOpen, logUsage} from "src/app/ui"
|
||||
import {modal, routes, menuIsOpen, logUsage} from "src/app/ui"
|
||||
import RelayCard from "src/partials/RelayCard.svelte"
|
||||
import Anchor from 'src/partials/Anchor.svelte'
|
||||
import Content from 'src/partials/Content.svelte'
|
||||
@ -32,6 +31,7 @@
|
||||
import Modal from 'src/partials/Modal.svelte'
|
||||
import SideNav from 'src/partials/SideNav.svelte'
|
||||
import Spinner from 'src/partials/Spinner.svelte'
|
||||
import Toast from 'src/partials/Toast.svelte'
|
||||
import TopNav from 'src/partials/TopNav.svelte'
|
||||
import ChatEdit from "src/views/ChatEdit.svelte"
|
||||
import NoteCreate from "src/views/NoteCreate.svelte"
|
||||
@ -46,13 +46,14 @@
|
||||
import AddRelay from "src/routes/AddRelay.svelte"
|
||||
import Alerts from "src/routes/Alerts.svelte"
|
||||
import Bech32Entity from "src/routes/Bech32Entity.svelte"
|
||||
import Chat from "src/routes/Chat.svelte"
|
||||
import ChatRoom from "src/routes/ChatRoom.svelte"
|
||||
import ChatList from "src/routes/ChatList.svelte"
|
||||
import ChatDetail from "src/routes/ChatDetail.svelte"
|
||||
import MessagesList from "src/routes/MessagesList.svelte"
|
||||
import MessagesDetail from "src/routes/MessagesDetail.svelte"
|
||||
import Debug from "src/routes/Debug.svelte"
|
||||
import Keys from "src/routes/Keys.svelte"
|
||||
import Login from "src/routes/Login.svelte"
|
||||
import Logout from "src/routes/Logout.svelte"
|
||||
import Messages from "src/routes/Messages.svelte"
|
||||
import NotFound from "src/routes/NotFound.svelte"
|
||||
import Notes from "src/routes/Notes.svelte"
|
||||
import Person from "src/routes/Person.svelte"
|
||||
@ -186,15 +187,16 @@
|
||||
<Person npub={params.npub} activeTab={params.activeTab} />
|
||||
{/key}
|
||||
</Route>
|
||||
<Route path="/chat" component={Chat} />
|
||||
<Route path="/chat" component={ChatList} />
|
||||
<Route path="/chat/:entity" let:params>
|
||||
{#key params.entity}
|
||||
<ChatRoom entity={params.entity} />
|
||||
<ChatDetail entity={params.entity} />
|
||||
{/key}
|
||||
</Route>
|
||||
<Route path="/messages" component={MessagesList} />
|
||||
<Route path="/messages/:entity" let:params>
|
||||
{#key params.entity}
|
||||
<Messages entity={params.entity} />
|
||||
<MessagesDetail entity={params.entity} />
|
||||
{/key}
|
||||
</Route>
|
||||
<Route path="/keys" component={Keys} />
|
||||
@ -259,28 +261,7 @@
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
{#if $toast}
|
||||
<div
|
||||
class="fixed top-0 left-0 right-0 z-20 pointer-events-none"
|
||||
transition:fly={{y: -50, duration: 300}}>
|
||||
<div
|
||||
class="rounded bg-accent shadow-xl mx-24 sm:mx-32 mt-2 p-3 text-white text-center
|
||||
border border-dark pointer-events-auto">
|
||||
{#if is(String, $toast.message)}
|
||||
{$toast.message}
|
||||
{:else}
|
||||
<div>
|
||||
{$toast.message.text}
|
||||
{#if $toast.message.link}
|
||||
<a class="ml-1 underline" href={$toast.message.link.href}>
|
||||
{$toast.message.link.text}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<Toast />
|
||||
</div>
|
||||
</Router>
|
||||
|
||||
|
@ -1,18 +1,17 @@
|
||||
import type {MyEvent} from 'src/util/types'
|
||||
import {prop, pick, join, uniqBy, last} from 'ramda'
|
||||
import {pick, join, uniqBy, last} from 'ramda'
|
||||
import {get} from 'svelte/store'
|
||||
import {first} from "hurdak/lib/hurdak"
|
||||
import {roomAttrs, displayPerson} from 'src/util/nostr'
|
||||
import {roomAttrs, displayPerson, findReplyId, findRootId} from 'src/util/nostr'
|
||||
import {getPubkeyWriteRelays, getRelayForPersonHint, sampleRelays} from 'src/agent/relays'
|
||||
import database from 'src/agent/database'
|
||||
import network from 'src/agent/network'
|
||||
import pool from 'src/agent/pool'
|
||||
import sync from 'src/agent/sync'
|
||||
import keys from 'src/agent/keys'
|
||||
|
||||
const updateUser = (relays, updates) =>
|
||||
publishEvent(relays, 0, {content: JSON.stringify(updates)})
|
||||
const updateUser = updates =>
|
||||
new PublishableEvent(0, {content: JSON.stringify(updates)})
|
||||
|
||||
const setRelays = (relays, newRelays) =>
|
||||
publishEvent(relays, 10002, {
|
||||
const setRelays = newRelays =>
|
||||
new PublishableEvent(10002, {
|
||||
tags: newRelays.map(r => {
|
||||
const t = ["r", r.url]
|
||||
|
||||
@ -24,25 +23,25 @@ const setRelays = (relays, newRelays) =>
|
||||
}),
|
||||
})
|
||||
|
||||
const setPetnames = (relays, petnames) =>
|
||||
publishEvent(relays, 3, {tags: petnames})
|
||||
const setPetnames = petnames =>
|
||||
new PublishableEvent(3, {tags: petnames})
|
||||
|
||||
const muffle = (relays, muffle) =>
|
||||
publishEvent(relays, 12165, {tags: muffle})
|
||||
const muffle = muffle =>
|
||||
new PublishableEvent(12165, {tags: muffle})
|
||||
|
||||
const createRoom = (relays, room) =>
|
||||
publishEvent(relays, 40, {content: JSON.stringify(pick(roomAttrs, room))})
|
||||
const createRoom = room =>
|
||||
new PublishableEvent(40, {content: JSON.stringify(pick(roomAttrs, room))})
|
||||
|
||||
const updateRoom = (relays, {id, ...room}) =>
|
||||
publishEvent(relays, 41, {content: JSON.stringify(pick(roomAttrs, room)), tags: [["e", id]]})
|
||||
const updateRoom = ({id, ...room}) =>
|
||||
new PublishableEvent(41, {content: JSON.stringify(pick(roomAttrs, room)), tags: [["e", id]]})
|
||||
|
||||
const createChatMessage = (relays, roomId, content) =>
|
||||
publishEvent(relays, 42, {content, tags: [["e", roomId, prop('url', first(relays)), "root"]]})
|
||||
const createChatMessage = (roomId, content, url) =>
|
||||
new PublishableEvent(42, {content, tags: [["e", roomId, url, "root"]]})
|
||||
|
||||
const createDirectMessage = (relays, pubkey, content) =>
|
||||
publishEvent(relays, 4, {content, tags: [["p", pubkey]]})
|
||||
const createDirectMessage = (pubkey, content) =>
|
||||
new PublishableEvent(4, {content, tags: [["p", pubkey]]})
|
||||
|
||||
const createNote = (relays, content, mentions = [], topics = []) => {
|
||||
const createNote = (content, mentions = [], topics = []) => {
|
||||
mentions = mentions.map(pubkey => {
|
||||
const name = displayPerson(database.getPersonWithFallback(pubkey))
|
||||
const [{url}] = sampleRelays(getPubkeyWriteRelays(pubkey))
|
||||
@ -52,10 +51,10 @@ const createNote = (relays, content, mentions = [], topics = []) => {
|
||||
|
||||
topics = topics.map(t => ["t", t])
|
||||
|
||||
return publishEvent(relays, 1, {content, tags: mentions.concat(topics)})
|
||||
return new PublishableEvent(1, {content, tags: mentions.concat(topics)})
|
||||
}
|
||||
|
||||
const createReaction = (relays, note, content) => {
|
||||
const createReaction = (note, content) => {
|
||||
const {url} = getRelayForPersonHint(note.pubkey, note)
|
||||
const tags = uniqBy(
|
||||
join(':'),
|
||||
@ -65,10 +64,10 @@ const createReaction = (relays, note, content) => {
|
||||
.concat([["p", note.pubkey, url], ["e", note.id, url, 'reply']])
|
||||
)
|
||||
|
||||
return publishEvent(relays, 7, {content, tags})
|
||||
return new PublishableEvent(7, {content, tags})
|
||||
}
|
||||
|
||||
const createReply = (relays, note, content, mentions = [], topics = []) => {
|
||||
const createReply = (note, content, mentions = [], topics = []) => {
|
||||
topics = topics.map(t => ["t", t])
|
||||
mentions = mentions.map(pubkey => {
|
||||
const {url} = getRelayForPersonHint(pubkey, note)
|
||||
@ -77,30 +76,44 @@ const createReply = (relays, note, content, mentions = [], topics = []) => {
|
||||
})
|
||||
|
||||
const {url} = getRelayForPersonHint(note.pubkey, note)
|
||||
const rootId = findRootId(note) || findReplyId(note) || note.id
|
||||
const tags = uniqBy(
|
||||
join(':'),
|
||||
note.tags
|
||||
.filter(t => ["e"].includes(t[0]))
|
||||
.map(t => last(t) === 'reply' ? t.slice(0, -1) : t)
|
||||
.concat([["p", note.pubkey, url], ["e", note.id, url, 'reply']])
|
||||
.concat(mentions.concat(topics))
|
||||
.concat([
|
||||
["p", note.pubkey, url],
|
||||
["e", note.id, url, 'reply'],
|
||||
["e", rootId, url, 'root'],
|
||||
])
|
||||
)
|
||||
|
||||
return publishEvent(relays, 1, {content, tags})
|
||||
return new PublishableEvent(1, {content, tags})
|
||||
}
|
||||
|
||||
const deleteEvent = (relays, ids) =>
|
||||
publishEvent(relays, 5, {tags: ids.map(id => ["e", id])})
|
||||
const deleteEvent = ids =>
|
||||
new PublishableEvent(5, {tags: ids.map(id => ["e", id])})
|
||||
|
||||
// Utils
|
||||
|
||||
const publishEvent = (relays, kind, {content = '', tags = []} = {}): [MyEvent, Promise<MyEvent>] => {
|
||||
const pubkey = get(keys.pubkey)
|
||||
const createdAt = Math.round(new Date().valueOf() / 1000)
|
||||
const event = {kind, content, tags, pubkey, created_at: createdAt} as MyEvent
|
||||
class PublishableEvent {
|
||||
event: Record<string, any>
|
||||
constructor(kind, {content = '', tags = []}) {
|
||||
const pubkey = get(keys.pubkey)
|
||||
const createdAt = Math.round(new Date().valueOf() / 1000)
|
||||
|
||||
// Return the event synchronously, separate from the promise
|
||||
return [event, network.publish(relays, event)]
|
||||
this.event = {kind, content, tags, pubkey, created_at: createdAt}
|
||||
}
|
||||
async publish(relays, onProgress = null) {
|
||||
const event = await keys.sign(this.event)
|
||||
const promise = pool.publish({relays, event, onProgress})
|
||||
|
||||
sync.processEvents(event)
|
||||
|
||||
return [event, promise]
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
|
@ -1,10 +1,10 @@
|
||||
import type {Writable} from 'svelte/store'
|
||||
import {debounce} from 'throttle-debounce'
|
||||
import {omit, partition, is, find, without, pluck, all, identity} from 'ramda'
|
||||
import {debounce, throttle} from 'throttle-debounce'
|
||||
import {omit, prop, partition, is, find, without, pluck, all, identity} from 'ramda'
|
||||
import {writable, derived} from 'svelte/store'
|
||||
import {createMap, isObject, ensurePlural} from 'hurdak/lib/hurdak'
|
||||
import {log, error} from 'src/util/logger'
|
||||
import {where, setLocalJson, now, timedelta} from 'src/util/misc'
|
||||
import {where, now, timedelta} from 'src/util/misc'
|
||||
|
||||
// Types
|
||||
|
||||
@ -186,19 +186,30 @@ class Table {
|
||||
}
|
||||
|
||||
const people = new Table('people', 'pubkey')
|
||||
const rooms = new Table('rooms', 'id')
|
||||
const messages = new Table('messages', 'id')
|
||||
const contacts = new Table('contacts', 'pubkey')
|
||||
|
||||
const rooms = new Table('rooms', 'id', {
|
||||
initialize: async table => {
|
||||
// Remove rooms that our user hasn't joined
|
||||
const rooms = Object.values(await table.dump() || {})
|
||||
const [valid, invalid] = partition(prop('joined'), rooms)
|
||||
|
||||
if (invalid.length > 0) {
|
||||
table.bulkRemove(pluck('id', invalid))
|
||||
}
|
||||
|
||||
return createMap('id', valid)
|
||||
},
|
||||
})
|
||||
|
||||
const alerts = new Table('alerts', 'id', {
|
||||
initialize: async table => {
|
||||
// TEMPORARY: we changed our alerts format, clear out the old version
|
||||
const isValid = alert => typeof alert.isMention === 'boolean'
|
||||
const [valid, invalid] = partition(isValid, Object.values(await table.dump() || {}))
|
||||
const alerts = Object.values(await table.dump() || {})
|
||||
const [valid, invalid] = partition(alert => typeof alert.isMention === 'boolean', alerts)
|
||||
|
||||
if (invalid.length > 0) {
|
||||
table.bulkRemove(pluck('id', invalid))
|
||||
setLocalJson("app/alerts/mostRecentAlert", 0)
|
||||
setLocalJson("app/alerts/lastCheckedAlerts", 0)
|
||||
}
|
||||
|
||||
return createMap('id', valid)
|
||||
@ -256,7 +267,7 @@ const watch = (names, f) => {
|
||||
}
|
||||
|
||||
// Debounce refresh so we don't get UI lag
|
||||
const refresh = debounce(300, async () => store.set(await f(...tables)))
|
||||
const refresh = throttle(300, async () => store.set(await f(...tables)))
|
||||
|
||||
// Listen for changes
|
||||
listener.subscribe(name => {
|
||||
@ -292,6 +303,6 @@ const onReady = cb => {
|
||||
}
|
||||
|
||||
export default {
|
||||
watch, getPersonWithFallback, dropAll, people, rooms, messages,
|
||||
watch, getPersonWithFallback, dropAll, people, contacts, rooms,
|
||||
alerts, relays, routes, ready, onReady,
|
||||
}
|
||||
|
@ -6,11 +6,10 @@ import {chunk} from 'hurdak/lib/hurdak'
|
||||
import {batch, timedelta, now} from 'src/util/misc'
|
||||
import {
|
||||
getRelaysForEventParent, getAllPubkeyWriteRelays, aggregateScores,
|
||||
getRelaysForEventChildren, sampleRelays, normalizeRelays,
|
||||
getRelaysForEventChildren, sampleRelays,
|
||||
} from 'src/agent/relays'
|
||||
import database from 'src/agent/database'
|
||||
import pool from 'src/agent/pool'
|
||||
import keys from 'src/agent/keys'
|
||||
import sync from 'src/agent/sync'
|
||||
|
||||
const getStalePubkeys = pubkeys => {
|
||||
@ -22,20 +21,7 @@ const getStalePubkeys = pubkeys => {
|
||||
})
|
||||
}
|
||||
|
||||
const publish = async (relays, event) => {
|
||||
const signedEvent = await keys.sign(event)
|
||||
|
||||
await Promise.all([
|
||||
pool.publish(relays, signedEvent),
|
||||
sync.processEvents(signedEvent),
|
||||
])
|
||||
|
||||
return signedEvent
|
||||
}
|
||||
|
||||
const listen = ({relays, filter, onChunk, shouldProcess = true}) => {
|
||||
relays = normalizeRelays(relays)
|
||||
|
||||
const listen = ({relays, filter, onChunk = null, shouldProcess = true}) => {
|
||||
return pool.subscribe({
|
||||
filter,
|
||||
relays,
|
||||
@ -53,8 +39,6 @@ const listen = ({relays, filter, onChunk, shouldProcess = true}) => {
|
||||
|
||||
const load = ({relays, filter, onChunk = null, shouldProcess = true, timeout = 6000}) => {
|
||||
return new Promise(resolve => {
|
||||
relays = normalizeRelays(relays)
|
||||
|
||||
const now = Date.now()
|
||||
const done = new Set()
|
||||
const allEvents = []
|
||||
@ -210,6 +194,6 @@ const applyContext = (notes, context) => {
|
||||
}
|
||||
|
||||
export default {
|
||||
publish, listen, load, loadPeople, personKinds, loadParents, streamContext, applyContext,
|
||||
listen, load, loadPeople, personKinds, loadParents, streamContext, applyContext,
|
||||
}
|
||||
|
||||
|
@ -1,9 +1,10 @@
|
||||
import type {Relay, Filter} from 'nostr-tools'
|
||||
import type {MyEvent} from 'src/util/types'
|
||||
import {relayInit} from 'nostr-tools'
|
||||
import {is} from 'ramda'
|
||||
import {pluck, is} from 'ramda'
|
||||
import {ensurePlural} from 'hurdak/lib/hurdak'
|
||||
import {warn, log, error} from 'src/util/logger'
|
||||
import {union, difference} from 'src/util/misc'
|
||||
import {isRelay, normalizeRelayUrl} from 'src/util/nostr'
|
||||
|
||||
// Connection management
|
||||
@ -122,10 +123,12 @@ const getConnection = url => connections[url]
|
||||
|
||||
const connect = url => {
|
||||
if (!isRelay(url)) {
|
||||
throw new Error(`Invalid relay url ${url}`)
|
||||
warn(`Invalid relay url ${url}`)
|
||||
}
|
||||
|
||||
url = normalizeRelayUrl(url)
|
||||
if (url !== normalizeRelayUrl(url)) {
|
||||
warn(`Received non-normalized relay url ${url}`)
|
||||
}
|
||||
|
||||
if (!connections[url]) {
|
||||
connections[url] = new Connection(url)
|
||||
@ -136,22 +139,87 @@ const connect = url => {
|
||||
|
||||
// Public api - publish/subscribe
|
||||
|
||||
const publish = async (relays, event) => {
|
||||
const publish = async ({relays, event, onProgress, timeout = 10_000}) => {
|
||||
if (relays.length === 0) {
|
||||
error(`Attempted to publish to zero relays`, event)
|
||||
} else {
|
||||
log(`Publishing to ${relays.length} relays`, event, relays)
|
||||
}
|
||||
|
||||
return Promise.all(
|
||||
const urls = new Set(pluck('url', relays))
|
||||
|
||||
if (urls.size !== relays.length) {
|
||||
warn(`Attempted to publish to non-unique relays`)
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
let resolved = false
|
||||
const timeouts = new Set()
|
||||
const succeeded = new Set()
|
||||
const failed = new Set()
|
||||
|
||||
const getProgress = () => {
|
||||
const completed = union(timeouts, succeeded, failed)
|
||||
const pending = difference(urls, completed)
|
||||
|
||||
return {succeeded, failed, timeouts, completed, pending}
|
||||
}
|
||||
|
||||
const attemptToResolve = () => {
|
||||
// Don't report progress once we're done, even if more errors/ok come through
|
||||
if (resolved) {
|
||||
return
|
||||
}
|
||||
|
||||
const progress = getProgress()
|
||||
|
||||
if (onProgress) {
|
||||
onProgress(progress)
|
||||
}
|
||||
|
||||
if (progress.pending.size === 0) {
|
||||
log(`Finished publishing to ${urls.size} relays`, event, progress)
|
||||
resolve(progress)
|
||||
resolved = true
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
for (const {url} of relays) {
|
||||
if (!succeeded.has(url) && !failed.has(url)) {
|
||||
timeouts.add(url)
|
||||
}
|
||||
}
|
||||
|
||||
attemptToResolve()
|
||||
}, timeout)
|
||||
|
||||
relays.map(async relay => {
|
||||
const conn = await connect(relay.url)
|
||||
|
||||
if (conn.status === CONNECTION_STATUS.READY) {
|
||||
return conn.nostr.publish(event)
|
||||
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()
|
||||
})
|
||||
} else {
|
||||
failed.add(relay.url)
|
||||
attemptToResolve()
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
attemptToResolve()
|
||||
})
|
||||
}
|
||||
|
||||
type SubscribeOpts = {
|
||||
|
@ -1,8 +1,8 @@
|
||||
import type {Relay} from 'src/util/types'
|
||||
import {warn} from 'src/util/logger'
|
||||
import {pick, groupBy, objOf, map, assoc, sortBy, uniqBy, prop} from 'ramda'
|
||||
import {first, createMap, updateIn} from 'hurdak/lib/hurdak'
|
||||
import {Tags, normalizeRelayUrl, isRelay, findReplyId} from 'src/util/nostr'
|
||||
import {filter, pipe, pick, groupBy, objOf, map, assoc, sortBy, uniqBy, prop} from 'ramda'
|
||||
import {first, createMap} from 'hurdak/lib/hurdak'
|
||||
import {Tags, isRelay, findReplyId} from 'src/util/nostr'
|
||||
import {shuffle} from 'src/util/misc'
|
||||
import database from 'src/agent/database'
|
||||
import pool from 'src/agent/pool'
|
||||
@ -125,24 +125,16 @@ export const getEventPublishRelays = event => {
|
||||
const pubkeys = tags.type("p").values().all().concat(event.pubkey)
|
||||
const relayChunks = pubkeys.map(pubkey => getPubkeyReadRelays(pubkey).slice(0, 3))
|
||||
|
||||
return aggregateScores(relayChunks).concat(getUserWriteRelays())
|
||||
return uniqByUrl(aggregateScores(relayChunks).concat(getUserWriteRelays()))
|
||||
}
|
||||
|
||||
|
||||
// Utils
|
||||
|
||||
export const uniqByUrl = uniqBy(prop('url'))
|
||||
export const uniqByUrl = pipe(uniqBy(prop('url')), filter(prop('url')))
|
||||
|
||||
export const sortByScore = sortBy(r => -r.score)
|
||||
|
||||
export const normalizeRelays = (relays: Relay[]): Relay[] =>
|
||||
uniqBy(
|
||||
prop('url'),
|
||||
relays
|
||||
.filter(r => isRelay(r.url))
|
||||
.map(updateIn('url', normalizeRelayUrl))
|
||||
)
|
||||
|
||||
export const sampleRelays = (relays, scale = 1) => {
|
||||
let limit = user.getSetting('relayLimit')
|
||||
|
||||
|
@ -1,23 +1,34 @@
|
||||
import {uniq, pick, identity, isEmpty} from 'ramda'
|
||||
import {nip05} from 'nostr-tools'
|
||||
import {noop, createMap, ensurePlural, switcherFn} from 'hurdak/lib/hurdak'
|
||||
import {warn, log} from 'src/util/logger'
|
||||
import {now, timedelta, shuffle, hash} from 'src/util/misc'
|
||||
import {Tags, personKinds, roomAttrs, isRelay, normalizeRelayUrl} from 'src/util/nostr'
|
||||
import {noop, createMap, ensurePlural, chunk, switcherFn} from 'hurdak/lib/hurdak'
|
||||
import {log} from 'src/util/logger'
|
||||
import {now, sleep, tryJson, timedelta, shuffle, hash} from 'src/util/misc'
|
||||
import {Tags, roomAttrs, personKinds, isRelay, isShareableRelay, normalizeRelayUrl} from 'src/util/nostr'
|
||||
import database from 'src/agent/database'
|
||||
|
||||
const processEvents = async events => {
|
||||
await Promise.all([
|
||||
processProfileEvents(events),
|
||||
processRoomEvents(events),
|
||||
processMessages(events),
|
||||
processRoutes(events),
|
||||
batchProcess(processProfileEvents, events),
|
||||
batchProcess(processRoomEvents, events),
|
||||
batchProcess(processRoutes, events),
|
||||
])
|
||||
}
|
||||
|
||||
const batchProcess = async (processChunk, events) => {
|
||||
const chunks = chunk(100, ensurePlural(events))
|
||||
|
||||
// Don't lock everything up when processing a lot of events
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
processChunk(chunks[i])
|
||||
|
||||
if (i < chunks.length - 1) {
|
||||
await sleep(30)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const processProfileEvents = async events => {
|
||||
const profileEvents = ensurePlural(events)
|
||||
.filter(e => personKinds.includes(e.kind))
|
||||
const profileEvents = events.filter(e => personKinds.includes(e.kind))
|
||||
|
||||
const updates = {}
|
||||
for (const e of profileEvents) {
|
||||
@ -120,55 +131,40 @@ const processProfileEvents = async events => {
|
||||
}
|
||||
}
|
||||
|
||||
// Chat rooms
|
||||
|
||||
const processRoomEvents = async events => {
|
||||
const roomEvents = ensurePlural(events)
|
||||
.filter(e => [40, 41].includes(e.kind))
|
||||
const roomEvents = events.filter(e => [40, 41].includes(e.kind))
|
||||
|
||||
const updates = {}
|
||||
for (const e of roomEvents) {
|
||||
const content = tryJson(() => pick(roomAttrs, JSON.parse(e.content))) as Record<string, any>
|
||||
const content = tryJson(() => pick(roomAttrs, JSON.parse(e.content)))
|
||||
const roomId = e.kind === 40 ? e.id : Tags.from(e).type("e").values().first()
|
||||
|
||||
if (!roomId || !content) {
|
||||
// Ignore non-standard rooms that don't have a name
|
||||
if (!roomId || !content?.name) {
|
||||
continue
|
||||
}
|
||||
|
||||
const room = await database.rooms.get(roomId)
|
||||
const room = database.rooms.get(roomId)
|
||||
|
||||
// Merge edits but don't let old ones override new ones
|
||||
if (room?.edited_at >= e.created_at) {
|
||||
// Don't let old edits override new ones
|
||||
if (room?.updated_at >= e.created_at) {
|
||||
continue
|
||||
}
|
||||
|
||||
// There are some non-standard rooms out there, ignore them
|
||||
// if they don't have a name
|
||||
if (content.name) {
|
||||
updates[roomId] = {
|
||||
joined: false,
|
||||
...room,
|
||||
...updates[roomId],
|
||||
...content,
|
||||
id: roomId,
|
||||
pubkey: e.pubkey,
|
||||
edited_at: e.created_at,
|
||||
updated_at: now(),
|
||||
}
|
||||
updates[roomId] = {
|
||||
...room,
|
||||
...updates,
|
||||
...content,
|
||||
id: roomId,
|
||||
pubkey: e.pubkey,
|
||||
updated_at: e.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
if (!isEmpty(updates)) {
|
||||
await database.rooms.bulkPut(updates)
|
||||
}
|
||||
}
|
||||
|
||||
const processMessages = async events => {
|
||||
const messages = ensurePlural(events)
|
||||
.filter(e => e.kind === 4)
|
||||
.map(e => ({...e, recipient: Tags.from(e).type("p").values().first()}))
|
||||
|
||||
|
||||
if (messages.length > 0) {
|
||||
await database.messages.bulkPut(createMap('id', messages))
|
||||
await database.rooms.bulkPatch(updates)
|
||||
}
|
||||
}
|
||||
|
||||
@ -183,7 +179,7 @@ const getWeight = type => {
|
||||
}
|
||||
|
||||
const calculateRoute = (pubkey, rawUrl, type, mode, created_at) => {
|
||||
if (!isRelay(rawUrl)) {
|
||||
if (!isShareableRelay(rawUrl)) {
|
||||
return
|
||||
}
|
||||
|
||||
@ -211,7 +207,7 @@ const processRoutes = async events => {
|
||||
let updates = []
|
||||
|
||||
// Sample events so we're not burning too many resources
|
||||
for (const e of ensurePlural(shuffle(events)).slice(0, 10)) {
|
||||
for (const e of shuffle(events).slice(0, 10)) {
|
||||
switcherFn(e.kind, {
|
||||
0: () => {
|
||||
updates.push(
|
||||
@ -284,16 +280,6 @@ const processRoutes = async events => {
|
||||
|
||||
// Utils
|
||||
|
||||
const tryJson = f => {
|
||||
try {
|
||||
return f()
|
||||
} catch (e) {
|
||||
if (!e.toString().includes('JSON')) {
|
||||
warn(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const verifyNip05 = (pubkey, as) =>
|
||||
nip05.queryProfile(as).then(result => {
|
||||
if (result?.pubkey === pubkey) {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import type {Person} from 'src/util/types'
|
||||
import type {Readable} from 'svelte/store'
|
||||
import {last, prop, find, pipe, assoc, whereEq, when, concat, reject, nth, map} from 'ramda'
|
||||
import {prop, find, pipe, assoc, whereEq, when, concat, reject, nth, map} from 'ramda'
|
||||
import {synced} from 'src/util/misc'
|
||||
import {derived} from 'svelte/store'
|
||||
import database from 'src/agent/database'
|
||||
@ -55,7 +55,7 @@ const relays = derived(
|
||||
const canPublish = derived(
|
||||
[keys.pubkey, relays],
|
||||
([$pubkey, $relays]) =>
|
||||
keys.canSign() && find(prop('write'), relays)
|
||||
keys.canSign() && find(prop('write'), $relays)
|
||||
)
|
||||
|
||||
// Keep our copies up to date
|
||||
@ -102,39 +102,39 @@ const user = {
|
||||
anonPetnames.set($petnames)
|
||||
|
||||
if (profileCopy) {
|
||||
cmd.setPetnames(relaysCopy, $petnames)
|
||||
return cmd.setPetnames($petnames).publish(relaysCopy)
|
||||
}
|
||||
},
|
||||
addPetname(pubkey, url, name) {
|
||||
const tag = ["p", pubkey, url, name || ""]
|
||||
|
||||
this.updatePetnames(pipe(reject(t => t[1] === pubkey), concat([tag])))
|
||||
return this.updatePetnames(pipe(reject(t => t[1] === pubkey), concat([tag])))
|
||||
},
|
||||
removePetname(pubkey) {
|
||||
this.updatePetnames(reject(t => t[1] === pubkey))
|
||||
return this.updatePetnames(reject(t => t[1] === pubkey))
|
||||
},
|
||||
|
||||
// Relays
|
||||
|
||||
relays,
|
||||
getRelays: () => relaysCopy,
|
||||
async updateRelays(f) {
|
||||
updateRelays(f) {
|
||||
const $relays = f(relaysCopy)
|
||||
|
||||
anonRelays.set($relays)
|
||||
|
||||
if (profileCopy) {
|
||||
await last(cmd.setRelays($relays, $relays))
|
||||
return cmd.setRelays($relays).publish($relays)
|
||||
}
|
||||
},
|
||||
async addRelay(url) {
|
||||
await this.updateRelays($relays => $relays.concat({url, write: true, read: true}))
|
||||
addRelay(url) {
|
||||
return this.updateRelays($relays => $relays.concat({url, write: true, read: true}))
|
||||
},
|
||||
async removeRelay(url) {
|
||||
await this.updateRelays(reject(whereEq({url})))
|
||||
removeRelay(url) {
|
||||
return this.updateRelays(reject(whereEq({url})))
|
||||
},
|
||||
async setRelayWriteCondition(url, write) {
|
||||
await this.updateRelays(map(when(whereEq({url}), assoc('write', write))))
|
||||
setRelayWriteCondition(url, write) {
|
||||
return this.updateRelays(map(when(whereEq({url}), assoc('write', write))))
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -1,69 +0,0 @@
|
||||
import {get} from 'svelte/store'
|
||||
import {uniq, partition, propEq} from 'ramda'
|
||||
import {createMap} from 'hurdak/lib/hurdak'
|
||||
import {synced, timedelta} from 'src/util/misc'
|
||||
import {isAlert, asDisplayEvent, findReplyId} from 'src/util/nostr'
|
||||
import database from 'src/agent/database'
|
||||
import network from 'src/agent/network'
|
||||
import {getUserReadRelays} from 'src/agent/relays'
|
||||
|
||||
let listener
|
||||
|
||||
const mostRecentAlert = synced("app/alerts/mostRecentAlert", 0)
|
||||
const lastCheckedAlerts = synced("app/alerts/lastCheckedAlerts", 0)
|
||||
|
||||
const asAlert = e => asDisplayEvent({...e, repliesFrom: [], likedBy: [], isMention: false})
|
||||
|
||||
const onChunk = async (pubkey, events) => {
|
||||
events = events.filter(e => isAlert(e, pubkey))
|
||||
|
||||
const parents = createMap('id', await network.loadParents(events))
|
||||
|
||||
const isPubkeyChild = e => {
|
||||
const parentId = findReplyId(e)
|
||||
|
||||
return parents[parentId]?.pubkey === pubkey
|
||||
}
|
||||
|
||||
const [likes, notes] = partition(propEq('kind', 7), events)
|
||||
const [replies, mentions] = partition(isPubkeyChild, notes)
|
||||
|
||||
likes.filter(isPubkeyChild).forEach(e => {
|
||||
const parent = parents[findReplyId(e)]
|
||||
const note = database.alerts.get(parent.id) || asAlert(parent)
|
||||
|
||||
database.alerts.put({...note, likedBy: uniq(note.likedBy.concat(e.pubkey))})
|
||||
})
|
||||
|
||||
replies.forEach(e => {
|
||||
const parent = parents[findReplyId(e)]
|
||||
const note = database.alerts.get(parent.id) || asAlert(parent)
|
||||
|
||||
database.alerts.put({...note, repliesFrom: uniq(note.repliesFrom.concat(e.pubkey))})
|
||||
})
|
||||
|
||||
mentions.forEach(e => {
|
||||
const note = database.alerts.get(e.id) || asAlert(e)
|
||||
|
||||
database.alerts.put({...note, isMention: true})
|
||||
})
|
||||
|
||||
mostRecentAlert.update($t => events.reduce((t, e) => Math.max(t, e.created_at), $t))
|
||||
}
|
||||
|
||||
const listen = async pubkey => {
|
||||
// Include an offset so we don't miss alerts on one relay but not another
|
||||
const since = get(mostRecentAlert) - timedelta(7, 'days')
|
||||
|
||||
if (listener) {
|
||||
listener.unsub()
|
||||
}
|
||||
|
||||
listener = await network.listen({
|
||||
relays: getUserReadRelays(),
|
||||
filter: {kinds: [1, 7], '#p': [pubkey], since},
|
||||
onChunk: events => onChunk(pubkey, events)
|
||||
})
|
||||
}
|
||||
|
||||
export default {listen, mostRecentAlert, lastCheckedAlerts}
|
164
src/app/alerts.ts
Normal file
164
src/app/alerts.ts
Normal file
@ -0,0 +1,164 @@
|
||||
import {max, find, pluck, propEq, partition, uniq} from 'ramda'
|
||||
import {derived} from 'svelte/store'
|
||||
import {createMap} from 'hurdak/lib/hurdak'
|
||||
import {synced, now, timedelta} from 'src/util/misc'
|
||||
import {Tags, isAlert, asDisplayEvent, findReplyId} from 'src/util/nostr'
|
||||
import {getUserReadRelays} from 'src/agent/relays'
|
||||
import database from 'src/agent/database'
|
||||
import network from 'src/agent/network'
|
||||
|
||||
let listener
|
||||
|
||||
// State
|
||||
|
||||
const seenAlertIds = synced('app/alerts/seenAlertIds', [])
|
||||
|
||||
export const lastChecked = synced('app/alerts/lastChecked', {})
|
||||
|
||||
export const newAlerts = derived(
|
||||
[database.watch('alerts', t => pluck('created_at', t.all()).reduce(max, 0)), lastChecked],
|
||||
([$lastAlert, $lastChecked]) => $lastAlert > $lastChecked.alerts
|
||||
)
|
||||
|
||||
export const newDirectMessages = derived(
|
||||
[database.watch('contacts', t => t.all()), lastChecked],
|
||||
([contacts, $lastChecked]) =>
|
||||
Boolean(find(c => c.lastMessage > $lastChecked[c.pubkey], contacts))
|
||||
)
|
||||
|
||||
export const newChatMessages = derived(
|
||||
[database.watch('rooms', t => t.all()), lastChecked],
|
||||
([rooms, $lastChecked]) =>
|
||||
Boolean(find(c => c.lastMessage > $lastChecked[c.pubkey], rooms))
|
||||
)
|
||||
|
||||
// Synchronization from events to state
|
||||
|
||||
const processAlerts = async (pubkey, events) => {
|
||||
// Keep track of alerts we've seen so we don't keep fetching parents repeatedly
|
||||
seenAlertIds.update($seenAlertIds => {
|
||||
const seen = new Set($seenAlertIds)
|
||||
|
||||
events = events.filter(e => isAlert(e, pubkey) && !seen.has(e.id))
|
||||
|
||||
events.forEach(e => $seenAlertIds.push(e.id))
|
||||
|
||||
return $seenAlertIds
|
||||
})
|
||||
|
||||
if (events.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const parents = createMap('id', await network.loadParents(events))
|
||||
|
||||
const asAlert = e =>
|
||||
asDisplayEvent({...e, repliesFrom: [], likedBy: [], isMention: false})
|
||||
|
||||
const isPubkeyChild = e => {
|
||||
const parentId = findReplyId(e)
|
||||
|
||||
return parents[parentId]?.pubkey === pubkey
|
||||
}
|
||||
|
||||
const [likes, notes] = partition(propEq('kind', 7), events)
|
||||
const [replies, mentions] = partition(isPubkeyChild, notes)
|
||||
|
||||
likes.filter(isPubkeyChild).forEach(e => {
|
||||
const parent = parents[findReplyId(e)]
|
||||
const note = database.alerts.get(parent.id) || asAlert(parent)
|
||||
|
||||
database.alerts.put({...note, likedBy: uniq(note.likedBy.concat(e.pubkey))})
|
||||
})
|
||||
|
||||
replies.forEach(e => {
|
||||
const parent = parents[findReplyId(e)]
|
||||
const note = database.alerts.get(parent.id) || asAlert(parent)
|
||||
|
||||
database.alerts.put({...note, repliesFrom: uniq(note.repliesFrom.concat(e.pubkey))})
|
||||
})
|
||||
|
||||
mentions.forEach(e => {
|
||||
const note = database.alerts.get(e.id) || asAlert(e)
|
||||
|
||||
database.alerts.put({...note, isMention: true})
|
||||
})
|
||||
}
|
||||
|
||||
const processMessages = async (pubkey, events) => {
|
||||
const messages = events.filter(propEq('kind', 4))
|
||||
|
||||
if (messages.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
lastChecked.update($lastChecked => {
|
||||
for (const message of messages) {
|
||||
if (message.pubkey === pubkey) {
|
||||
const recipient = Tags.from(message).type("p").values().first()
|
||||
|
||||
$lastChecked[recipient] = Math.max($lastChecked[recipient] || 0, message.created_at)
|
||||
database.contacts.patch({pubkey: recipient, accepted: true})
|
||||
} else {
|
||||
const contact = database.contacts.get(message.pubkey)
|
||||
const lastMessage = Math.max(contact?.lastMessage || 0, message.created_at)
|
||||
|
||||
database.contacts.patch({pubkey: message.pubkey, lastMessage})
|
||||
}
|
||||
}
|
||||
|
||||
return $lastChecked
|
||||
})
|
||||
}
|
||||
|
||||
const processChats = async (pubkey, events) => {
|
||||
const messages = events.filter(propEq('kind', 42))
|
||||
|
||||
if (messages.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
lastChecked.update($lastChecked => {
|
||||
for (const message of messages) {
|
||||
const id = Tags.from(message).type("e").values().first()
|
||||
|
||||
if (message.pubkey === pubkey) {
|
||||
$lastChecked[id] = Math.max($lastChecked[id] || 0, message.created_at)
|
||||
} else {
|
||||
const room = database.rooms.get(id)
|
||||
const lastMessage = Math.max(room?.lastMessage || 0, message.created_at)
|
||||
|
||||
database.rooms.patch({id, lastMessage})
|
||||
}
|
||||
}
|
||||
|
||||
return $lastChecked
|
||||
})
|
||||
}
|
||||
|
||||
const listen = async pubkey => {
|
||||
// Include an offset so we don't miss alerts on one relay but not another
|
||||
const since = now() - timedelta(30, 'days')
|
||||
const roomIds = pluck('id', database.rooms.all({joined: true}))
|
||||
|
||||
if (listener) {
|
||||
listener.unsub()
|
||||
}
|
||||
|
||||
listener = await network.listen({
|
||||
relays: getUserReadRelays(),
|
||||
filter: [
|
||||
{kinds: [4], authors: [pubkey], since},
|
||||
{kinds: [1, 7, 4], '#p': [pubkey], since},
|
||||
{kinds: [42], '#e': roomIds, since},
|
||||
],
|
||||
onChunk: async events => {
|
||||
await network.loadPeople(pluck('pubkey', events))
|
||||
await processMessages(pubkey, events)
|
||||
await processAlerts(pubkey, events)
|
||||
await processChats(pubkey, events)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export default {listen}
|
@ -10,14 +10,12 @@ import database from 'src/agent/database'
|
||||
import network from 'src/agent/network'
|
||||
import keys from 'src/agent/keys'
|
||||
import alerts from 'src/app/alerts'
|
||||
import messages from 'src/app/messages'
|
||||
import {routes, modal} from 'src/app/ui'
|
||||
import {routes, modal, toast} from 'src/app/ui'
|
||||
|
||||
export const loadAppData = async pubkey => {
|
||||
if (getUserReadRelays().length > 0) {
|
||||
await Promise.all([
|
||||
alerts.listen(pubkey),
|
||||
messages.listen(pubkey),
|
||||
network.loadPeople(getUserFollows()),
|
||||
])
|
||||
}
|
||||
@ -87,3 +85,23 @@ export const mergeParents = (notes: Array<DisplayEvent>) => {
|
||||
|
||||
return sortBy(e => -e.created_at, Object.values(omit(childIds, notesById)))
|
||||
}
|
||||
|
||||
export const publishWithToast = (relays, thunk) =>
|
||||
thunk.publish(relays, ({completed, succeeded, failed, timeouts, pending}) => {
|
||||
let message = `Published to ${succeeded.size}/${relays.length} relays`
|
||||
|
||||
const extra = []
|
||||
if (failed.size > 0) {
|
||||
extra.push(`${failed.size} failed`)
|
||||
}
|
||||
|
||||
if (timeouts.size > 0) {
|
||||
extra.push(`${timeouts.size} timed out`)
|
||||
}
|
||||
|
||||
if (extra.length > 0) {
|
||||
message += ` (${extra.join(', ')})`
|
||||
}
|
||||
|
||||
toast.show('info', message, pending.size ? null : 5)
|
||||
})
|
||||
|
@ -1,59 +0,0 @@
|
||||
import {pluck, find, reject} from 'ramda'
|
||||
import {derived} from 'svelte/store'
|
||||
import {synced, now, timedelta} from 'src/util/misc'
|
||||
import user from 'src/agent/user'
|
||||
import {getUserReadRelays} from 'src/agent/relays'
|
||||
import database from 'src/agent/database'
|
||||
import network from 'src/agent/network'
|
||||
|
||||
let listener
|
||||
|
||||
const since = now() - timedelta(30, 'days')
|
||||
const mostRecentByPubkey = synced('app/messages/mostRecentByPubkey', {})
|
||||
const lastCheckedByPubkey = synced('app/messages/lastCheckedByPubkey', {})
|
||||
|
||||
const hasNewMessages = derived(
|
||||
[lastCheckedByPubkey, mostRecentByPubkey],
|
||||
([$lastCheckedByPubkey, $mostRecentByPubkey]) => {
|
||||
return Boolean(find(
|
||||
([k, t]) => {
|
||||
return t > now() - timedelta(7, 'days') && ($lastCheckedByPubkey[k] || 0) < t
|
||||
},
|
||||
Object.entries($mostRecentByPubkey)
|
||||
))
|
||||
}
|
||||
)
|
||||
|
||||
const listen = async pubkey => {
|
||||
if (listener) {
|
||||
listener.unsub()
|
||||
}
|
||||
|
||||
listener = await network.listen({
|
||||
relays: getUserReadRelays(),
|
||||
filter: [
|
||||
{kinds: [4], authors: [pubkey], since},
|
||||
{kinds: [4], '#p': [pubkey], since},
|
||||
],
|
||||
onChunk: async events => {
|
||||
// Reload annotated messages, don't alert about messages to self
|
||||
const messages = reject(e => e.pubkey === e.recipient, await database.messages.all())
|
||||
|
||||
if (messages.length > 0) {
|
||||
await network.loadPeople(pluck('pubkey', messages))
|
||||
|
||||
mostRecentByPubkey.update(o => {
|
||||
for (const {pubkey, created_at} of messages) {
|
||||
if (pubkey !== user.getPubkey()) {
|
||||
o[pubkey] = Math.max(created_at, o[pubkey] || 0)
|
||||
}
|
||||
}
|
||||
|
||||
return o
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export default {listen, mostRecentByPubkey, lastCheckedByPubkey, hasNewMessages}
|
@ -29,11 +29,13 @@ toast.show = (type, message, timeout = 5) => {
|
||||
|
||||
toast.set({id, type, message})
|
||||
|
||||
setTimeout(() => {
|
||||
if (prop("id", get(toast)) === id) {
|
||||
toast.set(null)
|
||||
}
|
||||
}, timeout * 1000)
|
||||
if (timeout) {
|
||||
setTimeout(() => {
|
||||
if (prop("id", get(toast)) === id) {
|
||||
toast.set(null)
|
||||
}
|
||||
}, timeout * 1000)
|
||||
}
|
||||
}
|
||||
|
||||
// Menu
|
||||
|
@ -49,7 +49,7 @@
|
||||
on:click={killEvent}
|
||||
in:fly={{y: 20}}
|
||||
class="absolute top-0 mt-8 py-2 px-4 rounded border border-solid border-medium
|
||||
bg-dark grid grid-cols-3 gap-y-2 gap-x-4 z-20">
|
||||
bg-dark grid grid-cols-2 gap-y-2 gap-x-4 z-20">
|
||||
{#each pubkeys as pubkey}
|
||||
<Badge person={database.getPersonWithFallback(pubkey)} />
|
||||
{/each}
|
||||
|
@ -1,26 +1,17 @@
|
||||
<script>
|
||||
import cx from 'classnames'
|
||||
import {onMount} from 'svelte'
|
||||
import {fly} from 'svelte/transition'
|
||||
import {navigate} from 'svelte-routing'
|
||||
import {prop, path as getPath, reverse, uniqBy, sortBy, last} from 'ramda'
|
||||
import {formatTimestamp, sleep, createScroller, Cursor} from 'src/util/misc'
|
||||
import Badge from 'src/partials/Badge.svelte'
|
||||
import Anchor from 'src/partials/Anchor.svelte'
|
||||
import {prop, path as getPath, reverse, pluck, uniqBy, sortBy, last} from 'ramda'
|
||||
import {sleep, createScroller, Cursor} from 'src/util/misc'
|
||||
import Spinner from 'src/partials/Spinner.svelte'
|
||||
import user from 'src/agent/user'
|
||||
import database from 'src/agent/database'
|
||||
import {renderNote} from 'src/app'
|
||||
import network from 'src/agent/network'
|
||||
|
||||
export let name
|
||||
export let link = null
|
||||
export let about
|
||||
export let picture
|
||||
export let loadMessages
|
||||
export let listenForMessages
|
||||
export let sendMessage
|
||||
export let editRoom = null
|
||||
export let type
|
||||
|
||||
let textarea
|
||||
let messages = []
|
||||
@ -33,15 +24,17 @@
|
||||
|
||||
$: {
|
||||
// Group messages so we're only showing the person once per chunk
|
||||
annotatedMessages = reverse(sortBy(prop('created_at'), uniqBy(prop('id'), messages)).reduce(
|
||||
(mx, m) => {
|
||||
const person = database.getPersonWithFallback(m.pubkey)
|
||||
const showPerson = person.pubkey !== getPath(['person', 'pubkey'], last(mx))
|
||||
annotatedMessages = reverse(
|
||||
sortBy(prop('created_at'), uniqBy(prop('id'), messages)).reduce(
|
||||
(mx, m) => {
|
||||
const person = database.getPersonWithFallback(m.pubkey)
|
||||
const showPerson = person.pubkey !== getPath(['person', 'pubkey'], last(mx))
|
||||
|
||||
return mx.concat({...m, person, showPerson})
|
||||
},
|
||||
[]
|
||||
))
|
||||
return mx.concat({...m, person, showPerson})
|
||||
},
|
||||
[]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// flex-col means the first is the last
|
||||
@ -63,37 +56,37 @@
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
onMount(() => {
|
||||
if (!$profile) {
|
||||
return navigate('/login')
|
||||
}
|
||||
|
||||
const sub = await listenForMessages(
|
||||
const sub = listenForMessages(
|
||||
newMessages => stickToBottom('smooth', () => {
|
||||
loading = sleep(30_000)
|
||||
messages = messages.concat(newMessages)
|
||||
network.loadPeople(pluck('pubkey', newMessages))
|
||||
})
|
||||
)
|
||||
|
||||
const scroller = await createScroller(
|
||||
const scroller = createScroller(
|
||||
async () => {
|
||||
await loadMessages(cursor, events => {
|
||||
cursor.onChunk(events)
|
||||
await loadMessages(cursor, newMessages => {
|
||||
cursor.onChunk(newMessages)
|
||||
|
||||
stickToBottom('auto', () => {
|
||||
loading = sleep(30_000)
|
||||
messages = events.concat(messages)
|
||||
messages = sortBy(e => -e.created_at, newMessages.concat(messages))
|
||||
network.loadPeople(pluck('pubkey', newMessages))
|
||||
})
|
||||
})
|
||||
},
|
||||
{reverse: true}
|
||||
)
|
||||
|
||||
return async () => {
|
||||
const {unsub} = await sub
|
||||
|
||||
return () => {
|
||||
scroller.stop()
|
||||
unsub()
|
||||
sub.then(s => s?.unsub())
|
||||
}
|
||||
})
|
||||
|
||||
@ -106,7 +99,7 @@
|
||||
const event = await sendMessage(content)
|
||||
|
||||
stickToBottom('smooth', () => {
|
||||
messages = [event].concat(messages)
|
||||
messages = sortBy(e => -e.created_at, [event].concat(messages))
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -123,29 +116,11 @@
|
||||
|
||||
<div class="flex gap-4 h-full">
|
||||
<div class="relative w-full">
|
||||
<div class="flex flex-col py-20 h-full">
|
||||
<div class="flex flex-col py-18 pb-20 h-full">
|
||||
<ul class="pb-6 p-4 overflow-auto flex-grow flex flex-col-reverse justify-start channel-messages">
|
||||
{#each annotatedMessages as m (m.id)}
|
||||
<li in:fly={{y: 20}} class="py-1 flex flex-col gap-2">
|
||||
{#if type === 'chat' && m.showPerson}
|
||||
<div class="flex gap-4 items-center justify-between">
|
||||
<Badge person={m.person} />
|
||||
<p class="text-sm text-light">{formatTimestamp(m.created_at)}</p>
|
||||
</div>
|
||||
{/if}
|
||||
<div class={cx("flex overflow-hidden text-ellipsis", {
|
||||
'ml-12 justify-end': type === 'dm' && m.person.pubkey === $profile.pubkey,
|
||||
'mr-12': type === 'dm' && m.person.pubkey !== $profile.pubkey,
|
||||
})}>
|
||||
<div class={cx({
|
||||
'ml-6': type === 'chat',
|
||||
'rounded-2xl py-2 px-4 flex max-w-xl': type === 'dm',
|
||||
'bg-light text-black rounded-br-none': type === 'dm' && m.person.pubkey === $profile.pubkey,
|
||||
'bg-dark rounded-bl-none': type === 'dm' && m.person.pubkey !== $profile.pubkey,
|
||||
})}>
|
||||
{@html renderNote(m, {showEntire: true})}
|
||||
</div>
|
||||
</div>
|
||||
<slot name="message" message={m} />
|
||||
</li>
|
||||
{/each}
|
||||
{#await loading}
|
||||
@ -155,43 +130,9 @@
|
||||
{/await}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="fixed z-20 top-0 w-full lg:-ml-56 lg:pl-56 border-b border-solid border-medium bg-dark">
|
||||
<div class="p-4 flex items-start gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
class="fa fa-arrow-left text-2xl cursor-pointer"
|
||||
on:click={() => navigate("/chat")} />
|
||||
<div
|
||||
class="overflow-hidden w-12 h-12 rounded-full bg-cover bg-center shrink-0 border border-solid border-white"
|
||||
style="background-image: url({picture})" />
|
||||
</div>
|
||||
<div class="w-full flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex items-center gap-4">
|
||||
{#if link}
|
||||
<Anchor type="unstyled" href={link} class="text-lg font-bold">{name || ''}</Anchor>
|
||||
{:else}
|
||||
<div class="text-lg font-bold">{name || ''}</div>
|
||||
{/if}
|
||||
{#if editRoom}
|
||||
<button class="text-sm cursor-pointer" on:click={editRoom}>
|
||||
<i class="fa-solid fa-edit" /> Edit
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if type === 'dm'}
|
||||
<i class="fa fa-lock text-light" />
|
||||
<span class="text-light">Encrypted</span>
|
||||
{:else}
|
||||
<i class="fa fa-lock-open text-light" />
|
||||
<span class="text-light">Public</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div>{about || ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fixed z-20 top-0 w-full lg:-ml-56 lg:pl-56 border-b border-solid
|
||||
border-medium bg-dark p-4">
|
||||
<slot name="header" />
|
||||
</div>
|
||||
<div class="fixed z-10 bottom-0 w-full flex bg-medium border-medium border-t border-solid border-dark lg:-ml-56 lg:pl-56">
|
||||
<textarea
|
||||
|
47
src/partials/ChatListItem.svelte
Normal file
47
src/partials/ChatListItem.svelte
Normal file
@ -0,0 +1,47 @@
|
||||
<script>
|
||||
import {nip19} from 'nostr-tools'
|
||||
import {navigate} from 'svelte-routing'
|
||||
import {fly} from 'svelte/transition'
|
||||
import {ellipsize} from 'hurdak/lib/hurdak'
|
||||
import Anchor from 'src/partials/Anchor.svelte'
|
||||
import database from 'src/agent/database'
|
||||
|
||||
export let room
|
||||
|
||||
const enter = () => navigate(`/chat/${nip19.noteEncode(room.id)}`)
|
||||
const join = () => database.rooms.patch({id: room.id, joined: true})
|
||||
const leave = () => database.rooms.patch({id: room.id, joined: false})
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="flex gap-4 px-4 py-6 cursor-pointer hover:bg-medium transition-all rounded border border-solid border-medium bg-dark"
|
||||
on:click={enter}
|
||||
in:fly={{y: 20}}>
|
||||
<div
|
||||
class="overflow-hidden w-14 h-14 rounded-full bg-cover bg-center shrink-0 border border-solid border-white"
|
||||
style="background-image: url({room.picture})" />
|
||||
<div class="flex flex-grow flex-col justify-start gap-2 min-w-0">
|
||||
<div class="flex flex-grow items-start justify-between gap-2">
|
||||
<div class="flex gap-2 items-center overflow-hidden">
|
||||
<i class="fa fa-lock-open text-light" />
|
||||
<h2 class="text-lg">{room.name || ''}</h2>
|
||||
</div>
|
||||
{#if room.joined}
|
||||
<Anchor type="button" class="flex items-center gap-2" on:click={e => { e.stopPropagation(); leave() }}>
|
||||
<i class="fa fa-right-from-bracket" />
|
||||
<span>Leave</span>
|
||||
</Anchor>
|
||||
{:else}
|
||||
<Anchor type="button" class="flex items-center gap-2" on:click={e => { e.stopPropagation(); join() }}>
|
||||
<i class="fa fa-right-to-bracket" />
|
||||
<span>Join</span>
|
||||
</Anchor>
|
||||
{/if}
|
||||
</div>
|
||||
{#if room.about}
|
||||
<p class="text-light text-start">
|
||||
{ellipsize(room.about, 300)}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
46
src/partials/MessagesListItem.svelte
Normal file
46
src/partials/MessagesListItem.svelte
Normal file
@ -0,0 +1,46 @@
|
||||
<script>
|
||||
import {nip19} from 'nostr-tools'
|
||||
import {navigate} from 'svelte-routing'
|
||||
import {fly} from 'svelte/transition'
|
||||
import {ellipsize} from 'hurdak/lib/hurdak'
|
||||
import {displayPerson} from 'src/util/nostr'
|
||||
import database from 'src/agent/database'
|
||||
import {lastChecked} from 'src/app/alerts'
|
||||
|
||||
export let contact
|
||||
|
||||
console.log(contact, $lastChecked[contact.pubkey])
|
||||
const newMessages = contact.lastMessage > $lastChecked[contact.pubkey]
|
||||
const person = database.getPersonWithFallback(contact.pubkey)
|
||||
const enter = () => navigate(`/messages/${nip19.npubEncode(contact.pubkey)}`)
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="flex gap-4 px-4 py-6 cursor-pointer hover:bg-medium transition-all rounded
|
||||
border border-solid border-medium bg-dark"
|
||||
on:click={enter}
|
||||
in:fly={{y: 20}}>
|
||||
<div
|
||||
class="overflow-hidden w-14 h-14 rounded-full bg-cover bg-center shrink-0 border
|
||||
border-solid border-white"
|
||||
style="background-image: url({person.kind0?.picture})" />
|
||||
<div class="flex flex-grow flex-col justify-start gap-2 min-w-0">
|
||||
<div class="flex flex-grow items-start justify-between gap-2">
|
||||
<div class="flex gap-2 items-center overflow-hidden">
|
||||
<i class="fa fa-lock-open text-light" />
|
||||
<h2 class="text-lg">{displayPerson(person)}</h2>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<i class="fa fa-bell" class:text-light={!newMessages} />
|
||||
{#if newMessages}
|
||||
<div class="w-1 h-1 rounded-full bg-accent top-0 right-0 absolute mt-1" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if person.kind0?.about}
|
||||
<p class="text-light text-start">
|
||||
{ellipsize(person.kind0.about, 300)}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
@ -1,13 +1,13 @@
|
||||
<script lang="ts">
|
||||
import cx from 'classnames'
|
||||
import {nip19} from 'nostr-tools'
|
||||
import {last, whereEq, without, uniq, pluck, reject, propEq, find} from 'ramda'
|
||||
import {find, last, whereEq, without, uniq, pluck, reject, propEq} from 'ramda'
|
||||
import {onMount} from 'svelte'
|
||||
import {tweened} from 'svelte/motion'
|
||||
import {slide} from 'svelte/transition'
|
||||
import {navigate} from 'svelte-routing'
|
||||
import {quantify} from 'hurdak/lib/hurdak'
|
||||
import {Tags, findReply, findRoot, findRootId, findReplyId, displayPerson, isLike} from "src/util/nostr"
|
||||
import {Tags, findRootId, findReplyId, displayPerson, isLike} from "src/util/nostr"
|
||||
import {extractUrls} from "src/util/html"
|
||||
import ImageCircle from 'src/partials/ImageCircle.svelte'
|
||||
import Preview from 'src/partials/Preview.svelte'
|
||||
@ -22,6 +22,7 @@
|
||||
import database from 'src/agent/database'
|
||||
import cmd from 'src/agent/cmd'
|
||||
import {routes} from 'src/app/ui'
|
||||
import {publishWithToast} from 'src/app'
|
||||
|
||||
export let note
|
||||
export let depth = 0
|
||||
@ -94,7 +95,7 @@
|
||||
}
|
||||
|
||||
const relays = getEventPublishRelays(note)
|
||||
const [event] = cmd.createReaction(relays, note, content)
|
||||
const [event] = await cmd.createReaction(note, content).publish(relays)
|
||||
|
||||
if (content === '+') {
|
||||
likes = likes.concat(event)
|
||||
@ -106,8 +107,7 @@
|
||||
}
|
||||
|
||||
const deleteReaction = e => {
|
||||
const relays = getEventPublishRelays(note)
|
||||
cmd.deleteEvent(relays, [e.id])
|
||||
cmd.deleteEvent([e.id]).publish(getEventPublishRelays(note))
|
||||
|
||||
if (e.content === '+') {
|
||||
likes = reject(propEq('pubkey', $profile.pubkey), likes)
|
||||
@ -142,24 +142,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
const sendReply = () => {
|
||||
const sendReply = async () => {
|
||||
let {content, mentions, topics} = reply.parse()
|
||||
|
||||
if (content) {
|
||||
mentions = uniq(mentions.concat(replyMentions))
|
||||
|
||||
const relays = getEventPublishRelays(note)
|
||||
const [event] = cmd.createReply(relays, note, content, mentions, topics)
|
||||
const thunk = cmd.createReply(note, content, mentions, topics)
|
||||
const [event, promise] = await publishWithToast(relays, thunk)
|
||||
|
||||
toast.show("info", {
|
||||
text: `Your note has been created!`,
|
||||
link: {
|
||||
text: 'View',
|
||||
href: "/" + nip19.neventEncode({
|
||||
id: event.id,
|
||||
relays: pluck('url', relays.slice(0, 3)),
|
||||
}),
|
||||
},
|
||||
promise.then(({succeeded}) => {
|
||||
if (succeeded.size > 0) {
|
||||
toast.show("info", {
|
||||
text: `Your note has been created!`,
|
||||
link: {
|
||||
text: 'View',
|
||||
href: "/" + nip19.neventEncode({
|
||||
id: event.id,
|
||||
relays: pluck('url', relays.slice(0, 3)),
|
||||
}),
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
resetReply()
|
||||
@ -234,16 +239,20 @@
|
||||
</Anchor>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
{#if findReply(note) && showParent}
|
||||
<small class="text-light">
|
||||
Reply to <Anchor on:click={goToParent}>{findReplyId(note).slice(0, 8)}</Anchor>
|
||||
</small>
|
||||
{/if}
|
||||
{#if findRoot(note) && findRoot(note) !== findReply(note) && showParent}
|
||||
<small class="text-light">
|
||||
Go to <Anchor on:click={goToRoot}>root</Anchor>
|
||||
</small>
|
||||
{/if}
|
||||
<div class="flex gap-2">
|
||||
{#if findReplyId(note) && showParent}
|
||||
<small class="text-light">
|
||||
<i class="fa fa-code-merge" />
|
||||
<Anchor on:click={goToParent}>View Parent</Anchor>
|
||||
</small>
|
||||
{/if}
|
||||
{#if findRootId(note) && findRootId(note) !== findReplyId(note) && showParent}
|
||||
<small class="text-light">
|
||||
<i class="fa fa-code-pull-request" />
|
||||
<Anchor on:click={goToRoot}>View Thread</Anchor>
|
||||
</small>
|
||||
{/if}
|
||||
</div>
|
||||
{#if flag}
|
||||
<p class="text-light border-l-2 border-solid border-medium pl-4">
|
||||
You have flagged this content as offensive.
|
||||
@ -259,16 +268,18 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex justify-between text-light" on:click={e => e.stopPropagation()}>
|
||||
<div class="flex gap-6">
|
||||
<div>
|
||||
<div class="flex">
|
||||
<div class="w-16">
|
||||
<button class="fa fa-reply cursor-pointer" on:click={startReply} />
|
||||
{$repliesCount}
|
||||
</div>
|
||||
<div class={cx({'text-accent': like})}>
|
||||
<button class={cx('fa fa-heart cursor-pointer', {'fa-beat fa-beat-custom': like})} on:click={() => like ? deleteReaction(like) : react("+")} />
|
||||
<div class={cx('w-16', {'text-accent': like})}>
|
||||
<button
|
||||
class={cx('fa fa-heart cursor-pointer', {'fa-beat fa-beat-custom': like})}
|
||||
on:click={() => like ? deleteReaction(like) : react("+")} />
|
||||
{$likesCount}
|
||||
</div>
|
||||
<div>
|
||||
<div class="w-16">
|
||||
<button class="fa fa-flag cursor-pointer" on:click={() => react("-")} />
|
||||
{$flagsCount}
|
||||
</div>
|
||||
|
@ -1,84 +0,0 @@
|
||||
<script>
|
||||
import {onMount} from 'svelte'
|
||||
import {fly} from 'svelte/transition'
|
||||
import {ellipsize} from 'hurdak/lib/hurdak'
|
||||
import Anchor from 'src/partials/Anchor.svelte'
|
||||
import {displayPerson} from 'src/util/nostr'
|
||||
import {now, timedelta} from 'src/util/misc'
|
||||
import messages from 'src/app/messages'
|
||||
|
||||
export let joined = false
|
||||
export let room
|
||||
export let setRoom
|
||||
export let joinRoom
|
||||
export let leaveRoom
|
||||
|
||||
let hasNewMessages = false
|
||||
|
||||
const {mostRecentByPubkey, lastCheckedByPubkey} = messages
|
||||
|
||||
onMount(() => {
|
||||
const interval = setInterval(() => {
|
||||
// TODO notifications for channel messages
|
||||
if (room.type === 'npub') {
|
||||
const mostRecent = $mostRecentByPubkey[room.pubkey] || 0
|
||||
const lastChecked = $lastCheckedByPubkey[room.pubkey] || 0
|
||||
|
||||
// Include a cut-off since we lose read receipts every log out
|
||||
hasNewMessages = mostRecent > now() - timedelta(7, 'days') && lastChecked < mostRecent
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
})
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="flex gap-4 px-4 py-6 cursor-pointer hover:bg-medium transition-all rounded border border-solid border-medium bg-dark"
|
||||
on:click={() => setRoom(room)}
|
||||
in:fly={{y: 20}}>
|
||||
<div
|
||||
class="overflow-hidden w-14 h-14 rounded-full bg-cover bg-center shrink-0 border border-solid border-white"
|
||||
style="background-image: url({room.picture})" />
|
||||
<div class="flex flex-grow flex-col justify-start gap-2 min-w-0">
|
||||
<div class="flex flex-grow items-start justify-between gap-2">
|
||||
<div class="flex gap-2 items-center overflow-hidden">
|
||||
{#if room.type === 'npub'}
|
||||
<i class="fa fa-lock text-light" />
|
||||
<h2 class="text-lg">{displayPerson(room)}</h2>
|
||||
{:else}
|
||||
<i class="fa fa-lock-open text-light" />
|
||||
<h2 class="text-lg">{room.name}</h2>
|
||||
{/if}
|
||||
</div>
|
||||
{#if room.type === 'npub'}
|
||||
{#if hasNewMessages}
|
||||
<div class="relative">
|
||||
<i class="fa fa-bell" />
|
||||
<div class="absolute top-0 right-0 mt-1 w-1 h-1 bg-accent rounded-full" />
|
||||
</div>
|
||||
{:else}
|
||||
<i class="fa fa-bell text-light" />
|
||||
{/if}
|
||||
{/if}
|
||||
{#if room.type === 'note'}
|
||||
{#if joined}
|
||||
<Anchor type="button" class="flex items-center gap-2" on:click={e => { e.stopPropagation(); leaveRoom(room.id) }}>
|
||||
<i class="fa fa-right-from-bracket" />
|
||||
<span>Leave</span>
|
||||
</Anchor>
|
||||
{:else}
|
||||
<Anchor type="button" class="flex items-center gap-2" on:click={e => { e.stopPropagation(); joinRoom(room.id) }}>
|
||||
<i class="fa fa-right-to-bracket" />
|
||||
<span>Join</span>
|
||||
</Anchor>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{#if room.about}
|
||||
<p class="text-light text-start">
|
||||
{ellipsize(room.about, 300)}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
@ -1,20 +0,0 @@
|
||||
<script>
|
||||
export let room
|
||||
export let setRoom
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="flex gap-4 px-2 py-4 cursor-pointer hover:bg-dark transition-all"
|
||||
on:click={() => setRoom(room.id)}>
|
||||
<div
|
||||
class="overflow-hidden w-20 h-20 rounded-full bg-cover bg-center shrink-0 border border-solid border-white"
|
||||
style="background-image: url({room.picture})" />
|
||||
<div class="flex flex-col gap-2">
|
||||
<h2 class="text-lg">{room.name}</h2>
|
||||
{#if room.about}
|
||||
<p class="text-light whitespace-nowrap text-ellipsis overflow-hidden">
|
||||
{room.about}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
@ -2,12 +2,9 @@
|
||||
import {displayPerson} from 'src/util/nostr'
|
||||
import user from 'src/agent/user'
|
||||
import {menuIsOpen, routes} from 'src/app/ui'
|
||||
import alerts from 'src/app/alerts'
|
||||
import messages from 'src/app/messages'
|
||||
import {newAlerts, newDirectMessages, newChatMessages} from 'src/app/alerts'
|
||||
import {slowConnections} from 'src/app/connection'
|
||||
|
||||
const {mostRecentAlert, lastCheckedAlerts} = alerts
|
||||
const {hasNewMessages} = messages
|
||||
const {profile} = user
|
||||
</script>
|
||||
|
||||
@ -28,7 +25,7 @@
|
||||
<li class="cursor-pointer relative">
|
||||
<a class="block px-4 py-2 hover:bg-accent transition-all" href="/alerts">
|
||||
<i class="fa-solid fa-bell mr-2" /> Alerts
|
||||
{#if $mostRecentAlert > $lastCheckedAlerts}
|
||||
{#if $newAlerts}
|
||||
<div class="w-2 h-2 rounded bg-accent absolute top-3 left-6" />
|
||||
{/if}
|
||||
</a>
|
||||
@ -45,10 +42,18 @@
|
||||
</a>
|
||||
</li>
|
||||
{#if $profile}
|
||||
<li class="cursor-pointer relative">
|
||||
<a class="block px-4 py-2 hover:bg-accent transition-all" href="/messages">
|
||||
<i class="fa-solid fa-envelope mr-2" /> Messages
|
||||
{#if $newDirectMessages}
|
||||
<div class="w-2 h-2 rounded bg-accent absolute top-2 left-7" />
|
||||
{/if}
|
||||
</a>
|
||||
</li>
|
||||
<li class="cursor-pointer relative">
|
||||
<a class="block px-4 py-2 hover:bg-accent transition-all" href="/chat">
|
||||
<i class="fa-solid fa-message mr-2" /> Chat
|
||||
{#if $hasNewMessages}
|
||||
<i class="fa-solid fa-comment mr-2" /> Chat
|
||||
{#if $newChatMessages}
|
||||
<div class="w-2 h-2 rounded bg-accent absolute top-2 left-7" />
|
||||
{/if}
|
||||
</a>
|
||||
|
@ -5,15 +5,20 @@
|
||||
export let tabs
|
||||
export let activeTab
|
||||
export let setActiveTab
|
||||
export let getDisplay = tab => ({title: toTitle(tab), badge: null})
|
||||
</script>
|
||||
|
||||
<div class="border-b border-solid border-dark flex pt-2 overflow-auto" in:fly={{y: 20}}>
|
||||
{#each tabs as tab}
|
||||
{@const {title, badge} = getDisplay(tab)}
|
||||
<button
|
||||
class="cursor-pointer hover:border-b border-solid border-medium px-8 py-4"
|
||||
class="cursor-pointer hover:border-b border-solid border-medium px-8 py-4 flex gap-2"
|
||||
class:border-b={activeTab === tab}
|
||||
on:click={() => setActiveTab(tab)}>
|
||||
{toTitle(tab)}
|
||||
<div>{title}</div>
|
||||
{#if badge}
|
||||
<div class="rounded-full bg-medium px-2 h-6">{badge}</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
38
src/partials/Toast.svelte
Normal file
38
src/partials/Toast.svelte
Normal file
@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import cx from 'classnames'
|
||||
import {is} from 'ramda'
|
||||
import {fly} from 'svelte/transition'
|
||||
import {toast} from "src/app/ui"
|
||||
</script>
|
||||
|
||||
{#if $toast}
|
||||
{#key 'key'}
|
||||
<div
|
||||
class="fixed top-0 left-0 right-0 z-20 pointer-events-none flex justify-center"
|
||||
transition:fly={{y: -50, duration: 300}}>
|
||||
<div
|
||||
class={cx(
|
||||
"rounded shadow-xl mx-24 sm:mx-32 mt-2 p-3 text-center border pointer-events-auto",
|
||||
"max-w-xl flex-grow transition-all",
|
||||
{
|
||||
'bg-dark border-medium text-white': $toast.type === 'info',
|
||||
'bg-dark border-warning text-white': $toast.type === 'warning',
|
||||
'bg-dark border-danger text-white': $toast.type === 'error',
|
||||
}
|
||||
)}>
|
||||
{#if is(String, $toast.message)}
|
||||
{$toast.message}
|
||||
{:else}
|
||||
<div>
|
||||
{$toast.message.text}
|
||||
{#if $toast.message.link}
|
||||
<a class="ml-1 underline" href={$toast.message.link.href}>
|
||||
{$toast.message.link.text}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/key}
|
||||
{/if}
|
@ -2,11 +2,7 @@
|
||||
import {onMount} from 'svelte'
|
||||
import Anchor from 'src/partials/Anchor.svelte'
|
||||
import {menuIsOpen} from 'src/app/ui'
|
||||
import alerts from "src/app/alerts"
|
||||
import messages from "src/app/messages"
|
||||
|
||||
const {lastCheckedAlerts, mostRecentAlert} = alerts
|
||||
const {hasNewMessages} = messages
|
||||
import {newAlerts} from "src/app/alerts"
|
||||
|
||||
const toggleMenu = () => menuIsOpen.update(x => !x)
|
||||
|
||||
@ -28,7 +24,7 @@
|
||||
<img alt="Coracle Logo" src="/images/favicon.png" class="w-8" />
|
||||
<h1 class="staatliches text-3xl">Coracle</h1>
|
||||
</Anchor>
|
||||
{#if $mostRecentAlert > $lastCheckedAlerts || $hasNewMessages}
|
||||
{#if $newAlerts}
|
||||
<div class="w-2 h-2 rounded bg-accent absolute top-4 left-12 lg:hidden" />
|
||||
{/if}
|
||||
</div>
|
||||
|
@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import {sortBy} from 'ramda'
|
||||
import {sortBy, assoc} from 'ramda'
|
||||
import {onMount} from 'svelte'
|
||||
import {fly} from 'svelte/transition'
|
||||
import {ellipsize} from 'hurdak/lib/hurdak'
|
||||
@ -11,14 +11,14 @@
|
||||
import ImageCircle from "src/partials/ImageCircle.svelte"
|
||||
import Alert from 'src/partials/Alert.svelte'
|
||||
import database from 'src/agent/database'
|
||||
import alerts from 'src/app/alerts'
|
||||
import {lastChecked} from 'src/app/alerts'
|
||||
import {modal, routes} from 'src/app/ui'
|
||||
|
||||
let limit = 0
|
||||
let notes = null
|
||||
|
||||
onMount(async () => {
|
||||
alerts.lastCheckedAlerts.set(now())
|
||||
onMount(() => {
|
||||
lastChecked.update(assoc('alerts', now()))
|
||||
|
||||
return createScroller(async () => {
|
||||
limit += 10
|
||||
|
@ -1,109 +0,0 @@
|
||||
<script>
|
||||
import {without, uniq, assoc, sortBy} from 'ramda'
|
||||
import {nip19} from 'nostr-tools'
|
||||
import {navigate} from "svelte-routing"
|
||||
import {fuzzy} from "src/util/misc"
|
||||
import user from 'src/agent/user'
|
||||
import network from 'src/agent/network'
|
||||
import database from 'src/agent/database'
|
||||
import {getUserReadRelays} from 'src/agent/relays'
|
||||
import {modal} from 'src/app/ui'
|
||||
import messages from 'src/app/messages'
|
||||
import Room from "src/partials/Room.svelte"
|
||||
import Input from "src/partials/Input.svelte"
|
||||
import Content from "src/partials/Content.svelte"
|
||||
import Spinner from "src/partials/Spinner.svelte"
|
||||
import Anchor from "src/partials/Anchor.svelte"
|
||||
|
||||
let q = ""
|
||||
let roomsCount = 0
|
||||
|
||||
const {mostRecentByPubkey} = messages
|
||||
|
||||
const rooms = database.watch(['rooms', 'messages'], async () => {
|
||||
const rooms = await database.rooms.all({joined: true})
|
||||
const messages = await database.messages.all()
|
||||
const pubkeys = without([user.getPubkey()], uniq(messages.flatMap(m => [m.pubkey, m.recipient])))
|
||||
|
||||
network.loadPeople(pubkeys)
|
||||
|
||||
return sortBy(k => -(mostRecentByPubkey[k] || 0), pubkeys)
|
||||
.map(k => {
|
||||
const person = database.getPersonWithFallback(k)
|
||||
|
||||
return {type: 'npub', id: k, ...person, ...person.kind0}
|
||||
})
|
||||
.concat(rooms.map(room => ({type: 'note', ...room})))
|
||||
})
|
||||
|
||||
const search = database.watch('rooms', async () => {
|
||||
const rooms = await database.rooms.all({'joined:!eq': true})
|
||||
|
||||
roomsCount = rooms.length
|
||||
|
||||
return fuzzy(rooms.map(assoc('type', 'note')), {keys: ["name", "about"]})
|
||||
})
|
||||
|
||||
const setRoom = ({type, id}) => {
|
||||
if (type === 'npub') {
|
||||
navigate(`/messages/${nip19.npubEncode(id)}`)
|
||||
}
|
||||
|
||||
if (type === 'note') {
|
||||
navigate(`/chat/${nip19.noteEncode(id)}`)
|
||||
}
|
||||
}
|
||||
|
||||
const joinRoom = id => {
|
||||
database.rooms.patch({id, joined: true})
|
||||
}
|
||||
|
||||
const leaveRoom = id => {
|
||||
database.rooms.patch({id, joined: false})
|
||||
}
|
||||
|
||||
// Listen and process events, we don't have to do anything with the data
|
||||
network.load({
|
||||
relays: getUserReadRelays(),
|
||||
filter: [{kinds: [40, 41]}],
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if $rooms}
|
||||
<Content>
|
||||
<div class="flex justify-between mt-10">
|
||||
<div class="flex gap-2 items-center">
|
||||
<i class="fa fa-server fa-lg" />
|
||||
<h2 class="staatliches text-2xl">Your rooms</h2>
|
||||
</div>
|
||||
<Anchor type="button-accent" on:click={() => modal.set({type: 'room/edit'})}>
|
||||
<i class="fa-solid fa-plus" /> Create Room
|
||||
</Anchor>
|
||||
</div>
|
||||
{#each ($rooms || []) as room (room.id)}
|
||||
<Room joined {room} {setRoom} {joinRoom} {leaveRoom} />
|
||||
{:else}
|
||||
<p class="text-center py-8">You haven't yet joined any rooms.</p>
|
||||
{/each}
|
||||
<div class="pt-2 mb-2 border-b border-solid border-medium" />
|
||||
<div class="flex gap-2 items-center">
|
||||
<i class="fa fa-globe fa-lg" />
|
||||
<h2 class="staatliches text-2xl">Other rooms</h2>
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<Input bind:value={q} type="text" placeholder="Search rooms">
|
||||
<i slot="before" class="fa-solid fa-search" />
|
||||
</Input>
|
||||
</div>
|
||||
{#each $search ? $search(q).slice(0, 50) : [] as room (room.id)}
|
||||
<Room {room} {setRoom} {joinRoom} {leaveRoom} />
|
||||
{:else}
|
||||
<Spinner />
|
||||
{/each}
|
||||
<small class="text-center">
|
||||
Showing {Math.min(50, roomsCount)} of {roomsCount} known rooms
|
||||
</small>
|
||||
</Content>
|
||||
{:else}
|
||||
<Spinner />
|
||||
{/if}
|
89
src/routes/ChatDetail.svelte
Normal file
89
src/routes/ChatDetail.svelte
Normal file
@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import {assoc} from 'ramda'
|
||||
import {updateIn} from 'hurdak/lib/hurdak'
|
||||
import {now, formatTimestamp} from 'src/util/misc'
|
||||
import {toHex} from 'src/util/nostr'
|
||||
import Channel from 'src/partials/Channel.svelte'
|
||||
import Badge from 'src/partials/Badge.svelte'
|
||||
import Anchor from 'src/partials/Anchor.svelte'
|
||||
import user from 'src/agent/user'
|
||||
import {getRelaysForEventChildren, sampleRelays} from 'src/agent/relays'
|
||||
import network from 'src/agent/network'
|
||||
import database from 'src/agent/database'
|
||||
import cmd from 'src/agent/cmd'
|
||||
import {modal} from 'src/app/ui'
|
||||
import {lastChecked} from 'src/app/alerts'
|
||||
import {renderNote} from 'src/app'
|
||||
|
||||
export let entity
|
||||
|
||||
const id = toHex(entity)
|
||||
const room = database.watch('rooms', t => t.get(id) || {id})
|
||||
const getRelays = () => sampleRelays($room ? getRelaysForEventChildren($room) : [])
|
||||
|
||||
const listenForMessages = onChunk =>
|
||||
network.listen({
|
||||
relays: getRelays(),
|
||||
filter: [{kinds: [42], '#e': [id], since: now()}],
|
||||
onChunk,
|
||||
})
|
||||
|
||||
const loadMessages = ({until, limit}, onChunk) =>
|
||||
network.load({
|
||||
relays: getRelays(),
|
||||
filter: {kinds: [42], '#e': [id], until, limit},
|
||||
onChunk,
|
||||
})
|
||||
|
||||
const edit = () => {
|
||||
modal.set({type: 'room/edit', room: $room})
|
||||
}
|
||||
|
||||
const sendMessage = async content => {
|
||||
const [{url}] = getRelays()
|
||||
const [event] = await cmd.createChatMessage(id, content, url).publish(getRelays())
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
lastChecked.update(updateIn(assoc(id, now())))
|
||||
</script>
|
||||
|
||||
<Channel {loadMessages} {listenForMessages} {sendMessage}>
|
||||
<div slot="header" class="flex gap-4 items-start">
|
||||
<div class="flex items-center gap-4">
|
||||
<Anchor type="unstyled" class="fa fa-arrow-left text-2xl cursor-pointer" href="/chat" />
|
||||
<div
|
||||
class="overflow-hidden w-12 h-12 rounded-full bg-cover bg-center shrink-0 border border-solid border-white"
|
||||
style="background-image: url({$room.picture})" />
|
||||
</div>
|
||||
<div class="w-full flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="text-lg font-bold">{$room.name || ''}</div>
|
||||
{#if $room?.pubkey === user.getPubkey()}
|
||||
<button class="text-sm cursor-pointer" on:click={edit}>
|
||||
<i class="fa-solid fa-edit" /> Edit
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fa fa-lock-open text-light" />
|
||||
<span class="text-light">Public</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>{$room.about || ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div slot="message" let:message>
|
||||
{#if message.showPerson}
|
||||
<div class="flex gap-4 items-center justify-between">
|
||||
<Badge person={message.person} />
|
||||
<p class="text-sm text-light">{formatTimestamp(message.created_at)}</p>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="overflow-hidden text-ellipsis ml-6 my-1">
|
||||
{@html renderNote(message, {showEntire: true})}
|
||||
</div>
|
||||
</div>
|
||||
</Channel>
|
73
src/routes/ChatList.svelte
Normal file
73
src/routes/ChatList.svelte
Normal file
@ -0,0 +1,73 @@
|
||||
<script>
|
||||
import {onMount} from 'svelte'
|
||||
import {fuzzy} from "src/util/misc"
|
||||
import ChatListItem from "src/partials/ChatListItem.svelte"
|
||||
import Input from "src/partials/Input.svelte"
|
||||
import Content from "src/partials/Content.svelte"
|
||||
import Anchor from "src/partials/Anchor.svelte"
|
||||
import database from 'src/agent/database'
|
||||
import network from 'src/agent/network'
|
||||
import {getUserReadRelays} from 'src/agent/relays'
|
||||
import {modal} from 'src/app/ui'
|
||||
|
||||
let q = ""
|
||||
let search
|
||||
let results = []
|
||||
|
||||
const userRooms = database.watch('rooms', t => t.all({joined: true}))
|
||||
const otherRooms = database.watch('rooms', t => t.all({'joined:!eq': true}))
|
||||
|
||||
$: search = fuzzy($otherRooms, {keys: ['name', 'about']})
|
||||
$: results = search(q).slice(0, 50)
|
||||
|
||||
|
||||
onMount(() => {
|
||||
const sub = network.listen({
|
||||
relays: getUserReadRelays(),
|
||||
filter: [{kinds: [40, 41]}],
|
||||
})
|
||||
|
||||
return () => {
|
||||
sub.then(s => s?.unsub())
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<Content>
|
||||
<div class="flex justify-between mt-10">
|
||||
<div class="flex gap-2 items-center">
|
||||
<i class="fa fa-server fa-lg" />
|
||||
<h2 class="staatliches text-2xl">Your rooms</h2>
|
||||
</div>
|
||||
<Anchor type="button-accent" on:click={() => modal.set({type: 'room/edit'})}>
|
||||
<i class="fa-solid fa-plus" /> Create Room
|
||||
</Anchor>
|
||||
</div>
|
||||
{#each $userRooms as room (room.id)}
|
||||
<ChatListItem {room} />
|
||||
{:else}
|
||||
<p class="text-center py-8">You haven't yet joined any rooms.</p>
|
||||
{/each}
|
||||
<div class="pt-2 mb-2 border-b border-solid border-medium" />
|
||||
<div class="flex gap-2 items-center">
|
||||
<i class="fa fa-earth-asia fa-lg" />
|
||||
<h2 class="staatliches text-2xl">Other rooms</h2>
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<Input bind:value={q} type="text" placeholder="Search rooms">
|
||||
<i slot="before" class="fa-solid fa-search" />
|
||||
</Input>
|
||||
</div>
|
||||
{#if results.length > 0}
|
||||
{#each results as room (room.id)}
|
||||
<ChatListItem room={room} />
|
||||
{/each}
|
||||
<small class="text-center">
|
||||
Showing {Math.min(50, $otherRooms.length)} of {$otherRooms.length} known rooms
|
||||
</small>
|
||||
{:else}
|
||||
<small class="text-center">
|
||||
No matching rooms found
|
||||
</small>
|
||||
{/if}
|
||||
</Content>
|
@ -1,63 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {pluck} from 'ramda'
|
||||
import {now} from 'src/util/misc'
|
||||
import {toHex} from 'src/util/nostr'
|
||||
import Channel from 'src/partials/Channel.svelte'
|
||||
import user from 'src/agent/user'
|
||||
import {getRelaysForEventChildren} from 'src/agent/relays'
|
||||
import database from 'src/agent/database'
|
||||
import network from 'src/agent/network'
|
||||
import {modal} from 'src/app/ui'
|
||||
import cmd from 'src/agent/cmd'
|
||||
|
||||
export let entity
|
||||
|
||||
const roomId = toHex(entity)
|
||||
const room = database.watch('rooms', rooms => rooms.get(roomId))
|
||||
|
||||
const listenForMessages = async cb => {
|
||||
// Listen for updates to the room in case we didn't get them before
|
||||
return network.listen({
|
||||
relays: getRelaysForEventChildren($room),
|
||||
filter: [
|
||||
{kinds: [40, 41], ids: [roomId]},
|
||||
{kinds: [42], '#e': [roomId], since: now()},
|
||||
],
|
||||
onChunk: events => {
|
||||
network.loadPeople(pluck('pubkey', events))
|
||||
|
||||
cb(events.filter(e => e.kind === 42))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const loadMessages = ({until, limit}, onChunk) => {
|
||||
network.load({
|
||||
relays: getRelaysForEventChildren($room),
|
||||
filter: {kinds: [42], '#e': [roomId], until, limit},
|
||||
onChunk: events => {
|
||||
network.loadPeople(pluck('pubkey', events))
|
||||
|
||||
onChunk(events)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const editRoom = () => {
|
||||
modal.set({type: 'room/edit', room: $room})
|
||||
}
|
||||
|
||||
const sendMessage = content =>
|
||||
cmd.createChatMessage(getRelaysForEventChildren($room), roomId, content)
|
||||
</script>
|
||||
|
||||
<Channel
|
||||
type="chat"
|
||||
name={$room?.name}
|
||||
about={$room?.about}
|
||||
picture={$room?.picture}
|
||||
editRoom={$room?.pubkey === user.getPubkey() && editRoom}
|
||||
{loadMessages}
|
||||
{listenForMessages}
|
||||
{sendMessage}
|
||||
/>
|
@ -1,80 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {sortBy, pluck} from 'ramda'
|
||||
import {renameProp} from 'hurdak/lib/hurdak'
|
||||
import {personKinds, toHex, displayPerson} from 'src/util/nostr'
|
||||
import {now} from 'src/util/misc'
|
||||
import Channel from 'src/partials/Channel.svelte'
|
||||
import user from 'src/agent/user'
|
||||
import {getAllPubkeyRelays, sampleRelays} from 'src/agent/relays'
|
||||
import database from 'src/agent/database'
|
||||
import network from 'src/agent/network'
|
||||
import keys from 'src/agent/keys'
|
||||
import cmd from 'src/agent/cmd'
|
||||
import {routes} from 'src/app/ui'
|
||||
import messages from 'src/app/messages'
|
||||
|
||||
export let entity
|
||||
|
||||
let crypt = keys.getCrypt()
|
||||
let pubkey = toHex(entity)
|
||||
let person = database.watch('people', () => database.getPersonWithFallback(pubkey))
|
||||
|
||||
messages.lastCheckedByPubkey.update($obj => ({...$obj, [pubkey]: now()}))
|
||||
|
||||
const getRelays = () => sampleRelays(getAllPubkeyRelays([pubkey, user.getPubkey()]))
|
||||
|
||||
const decryptMessages = async events => {
|
||||
// Gotta do it in serial because of extension limitations
|
||||
for (const event of events) {
|
||||
const key = event.pubkey === pubkey ? pubkey : event.recipient
|
||||
|
||||
event.decryptedContent = await crypt.decrypt(key, event.content)
|
||||
}
|
||||
|
||||
return events.map(renameProp('decryptedContent', 'content'))
|
||||
}
|
||||
|
||||
const listenForMessages = cb => network.listen({
|
||||
relays: getRelays(),
|
||||
filter: [
|
||||
{kinds: personKinds, authors: [pubkey]},
|
||||
{kinds: [4], authors: [user.getPubkey()], '#p': [pubkey]},
|
||||
{kinds: [4], authors: [pubkey], '#p': [user.getPubkey()]},
|
||||
],
|
||||
onChunk: async events => {
|
||||
// Reload from db since we annotate messages there
|
||||
const messageIds = pluck('id', events.filter(e => e.kind === 4))
|
||||
const messages = await database.messages.all({id: messageIds})
|
||||
|
||||
cb(await decryptMessages(messages))
|
||||
},
|
||||
})
|
||||
|
||||
const loadMessages = async ({until, limit}) => {
|
||||
const fromThem = await database.messages.all({pubkey})
|
||||
const toThem = await database.messages.all({recipient: pubkey})
|
||||
const events = fromThem.concat(toThem).filter(e => e.created_at < until)
|
||||
const messages = sortBy(e => -e.created_at, events).slice(0, limit)
|
||||
|
||||
return await decryptMessages(messages)
|
||||
}
|
||||
|
||||
const sendMessage = async content => {
|
||||
const cyphertext = await crypt.encrypt(pubkey, content)
|
||||
const [event] = cmd.createDirectMessage(getRelays(), pubkey, cyphertext)
|
||||
|
||||
// Return unencrypted content so we can display it immediately
|
||||
return {...event, content}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Channel
|
||||
type="dm"
|
||||
name={displayPerson($person)}
|
||||
about={$person?.kind0?.about}
|
||||
picture={$person?.kind0?.picture}
|
||||
link={$person ? routes.person($person.pubkey) : null}
|
||||
{loadMessages}
|
||||
{listenForMessages}
|
||||
{sendMessage}
|
||||
/>
|
107
src/routes/MessagesDetail.svelte
Normal file
107
src/routes/MessagesDetail.svelte
Normal file
@ -0,0 +1,107 @@
|
||||
<script lang="ts">
|
||||
import cx from 'classnames'
|
||||
import {assoc} from 'ramda'
|
||||
import {renameProp} from 'hurdak/lib/hurdak'
|
||||
import {toHex, displayPerson} from 'src/util/nostr'
|
||||
import {now} from 'src/util/misc'
|
||||
import {Tags} from 'src/util/nostr'
|
||||
import Channel from 'src/partials/Channel.svelte'
|
||||
import Anchor from 'src/partials/Anchor.svelte'
|
||||
import {getAllPubkeyRelays, sampleRelays} from 'src/agent/relays'
|
||||
import database from 'src/agent/database'
|
||||
import network from 'src/agent/network'
|
||||
import keys from 'src/agent/keys'
|
||||
import user from 'src/agent/user'
|
||||
import cmd from 'src/agent/cmd'
|
||||
import {routes} from 'src/app/ui'
|
||||
import {lastChecked} from 'src/app/alerts'
|
||||
import {renderNote} from 'src/app'
|
||||
|
||||
export let entity
|
||||
|
||||
let crypt = keys.getCrypt()
|
||||
let pubkey = toHex(entity)
|
||||
let person = database.watch('people', () => database.getPersonWithFallback(pubkey))
|
||||
|
||||
lastChecked.update(assoc(pubkey, now()))
|
||||
|
||||
const getRelays = () => {
|
||||
return sampleRelays(getAllPubkeyRelays([pubkey, user.getPubkey()]))
|
||||
}
|
||||
|
||||
const decryptMessages = async events => {
|
||||
// Gotta do it in serial because of extension limitations
|
||||
for (const event of events) {
|
||||
const key = event.pubkey === pubkey
|
||||
? pubkey
|
||||
: Tags.from(event).type("p").values().first()
|
||||
|
||||
event.decryptedContent = await crypt.decrypt(key, event.content)
|
||||
}
|
||||
|
||||
return events.map(renameProp('decryptedContent', 'content'))
|
||||
}
|
||||
|
||||
const getFilters = () => [
|
||||
{kinds: [4], authors: [user.getPubkey()], '#p': [pubkey]},
|
||||
{kinds: [4], authors: [pubkey], '#p': [user.getPubkey()]},
|
||||
]
|
||||
|
||||
const listenForMessages = onChunk =>
|
||||
network.listen({
|
||||
relays: getRelays(),
|
||||
filter: getFilters(),
|
||||
onChunk: async events => onChunk(await decryptMessages(events)),
|
||||
})
|
||||
|
||||
const loadMessages = ({until, limit}, onChunk) =>
|
||||
network.load({
|
||||
relays: getRelays(),
|
||||
filter: getFilters(),
|
||||
onChunk: async events => onChunk(await decryptMessages(events)),
|
||||
})
|
||||
|
||||
const sendMessage = async content => {
|
||||
const cyphertext = await crypt.encrypt(pubkey, content)
|
||||
const [event] = await cmd.createDirectMessage(pubkey, cyphertext).publish(getRelays())
|
||||
|
||||
// Return unencrypted content so we can display it immediately
|
||||
return {...event, content}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Channel {loadMessages} {listenForMessages} {sendMessage}>
|
||||
<div slot="header" class="flex gap-4 items-start">
|
||||
<div class="flex items-center gap-4">
|
||||
<Anchor type="unstyled" class="fa fa-arrow-left text-2xl cursor-pointer" href="/messages" />
|
||||
<div
|
||||
class="overflow-hidden w-12 h-12 rounded-full bg-cover bg-center shrink-0 border border-solid border-white"
|
||||
style="background-image: url({$person.kind0?.picture})" />
|
||||
</div>
|
||||
<div class="w-full flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex items-center gap-4">
|
||||
<Anchor type="unstyled" href={routes.person(pubkey)} class="text-lg font-bold">
|
||||
{displayPerson($person)}
|
||||
</Anchor>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fa fa-lock text-light" />
|
||||
<span class="text-light">Encrypted</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>{$person.kind0?.about || ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div slot="message" let:message class={cx("flex overflow-hidden text-ellipsis", {
|
||||
'ml-12 justify-end': message.person.pubkey === user.getPubkey(),
|
||||
'mr-12': message.person.pubkey !== user.getPubkey(),
|
||||
})}>
|
||||
<div class={cx('rounded-2xl py-2 px-4 flex max-w-xl', {
|
||||
'bg-light text-black rounded-br-none': message.person.pubkey === user.getPubkey(),
|
||||
'bg-dark rounded-bl-none': message.person.pubkey !== user.getPubkey(),
|
||||
})}>
|
||||
{@html renderNote(message, {showEntire: true})}
|
||||
</div>
|
||||
</div>
|
||||
</Channel>
|
30
src/routes/MessagesList.svelte
Normal file
30
src/routes/MessagesList.svelte
Normal file
@ -0,0 +1,30 @@
|
||||
<script>
|
||||
import {sortBy} from 'ramda'
|
||||
import {toTitle} from 'hurdak/lib/hurdak'
|
||||
import Content from "src/partials/Content.svelte"
|
||||
import MessagesListItem from "src/partials/MessagesListItem.svelte"
|
||||
import Tabs from "src/partials/Tabs.svelte"
|
||||
import database from 'src/agent/database'
|
||||
|
||||
let activeTab = 'messages'
|
||||
|
||||
const setActiveTab = tab => {
|
||||
activeTab = tab
|
||||
}
|
||||
|
||||
const accepted = database.watch('contacts', t => t.all({accepted: true}))
|
||||
const requests = database.watch('contacts', t => t.all({'accepted:!eq': true}))
|
||||
|
||||
const getContacts = tab =>
|
||||
sortBy(c => -c.lastMessage, tab === 'messages' ? $accepted : $requests)
|
||||
|
||||
const getDisplay = tab =>
|
||||
({title: toTitle(tab), badge: getContacts(tab).length})
|
||||
</script>
|
||||
|
||||
<Content>
|
||||
<Tabs tabs={['messages', 'requests']} {activeTab} {setActiveTab} {getDisplay} />
|
||||
{#each getContacts(activeTab) as contact (contact.pubkey)}
|
||||
<MessagesListItem {contact} />
|
||||
{/each}
|
||||
</Content>
|
@ -1,10 +1,9 @@
|
||||
<script lang="ts">
|
||||
import {last, prop} from 'ramda'
|
||||
import {last} from 'ramda'
|
||||
import {onMount} from 'svelte'
|
||||
import {tweened} from 'svelte/motion'
|
||||
import {fly} from 'svelte/transition'
|
||||
import {navigate} from 'svelte-routing'
|
||||
import {first} from 'hurdak/lib/hurdak'
|
||||
import {log} from 'src/util/logger'
|
||||
import {renderContent} from 'src/util/html'
|
||||
import {displayPerson, Tags, toHex} from 'src/util/nostr'
|
||||
@ -28,6 +27,7 @@
|
||||
|
||||
const interpolate = (a, b) => t => a + Math.round((b - a) * t)
|
||||
const {petnamePubkeys, canPublish} = user
|
||||
const getRelays = () => sampleRelays(relays.concat(getPubkeyWriteRelays(pubkey)))
|
||||
|
||||
let pubkey = toHex(npub)
|
||||
let following = false
|
||||
@ -58,7 +58,7 @@
|
||||
// Round out our followers count
|
||||
await network.load({
|
||||
shouldProcess: false,
|
||||
relays: sampleRelays(getPubkeyWriteRelays(pubkey)),
|
||||
relays: getRelays(),
|
||||
filter: [{kinds: [3], '#p': [pubkey]}],
|
||||
onChunk: events => {
|
||||
for (const e of events) {
|
||||
@ -83,7 +83,9 @@
|
||||
}
|
||||
|
||||
const follow = async () => {
|
||||
user.addPetname(pubkey, prop('url', first(relays)), displayPerson(person))
|
||||
const [{url}] = getRelays()
|
||||
|
||||
user.addPetname(pubkey, url, displayPerson(person))
|
||||
}
|
||||
|
||||
const unfollow = async () => {
|
||||
|
@ -12,7 +12,8 @@
|
||||
import user from "src/agent/user"
|
||||
import {getUserWriteRelays} from 'src/agent/relays'
|
||||
import cmd from "src/agent/cmd"
|
||||
import {routes, toast} from "src/app/ui"
|
||||
import {routes} from "src/app/ui"
|
||||
import {publishWithToast} from 'src/app'
|
||||
|
||||
let values = user.getProfile().kind0 || {}
|
||||
|
||||
@ -44,11 +45,9 @@
|
||||
const submit = async event => {
|
||||
event.preventDefault()
|
||||
|
||||
cmd.updateUser(getUserWriteRelays(), values)
|
||||
publishWithToast(getUserWriteRelays(), cmd.updateUser(values))
|
||||
|
||||
navigate(routes.person(user.getPubkey(), 'profile'))
|
||||
|
||||
toast.show("info", "Your profile has been updated!")
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -91,6 +91,8 @@ export const extractUrls = content => {
|
||||
}
|
||||
|
||||
export const renderContent = content => {
|
||||
/* eslint no-useless-escape: 0 */
|
||||
|
||||
// Escape html
|
||||
content = escapeHtml(content)
|
||||
|
||||
@ -98,18 +100,19 @@ export const renderContent = content => {
|
||||
for (let url of extractUrls(content)) {
|
||||
const $a = document.createElement('a')
|
||||
|
||||
if (!url.includes('://')) {
|
||||
url = 'https://' + url
|
||||
// It's common for a period to end a url, trim it off
|
||||
if (url.endsWith('.')) {
|
||||
url = url.slice(0, -1)
|
||||
}
|
||||
|
||||
$a.href = url
|
||||
const href = url.includes('://') ? url : 'https://' + url
|
||||
const display = url.replace(/https?:\/\/(www\.)?/, '')
|
||||
|
||||
$a.href = href
|
||||
$a.target = "_blank"
|
||||
$a.className = "underline"
|
||||
$a.innerText = ellipsize(display, 50)
|
||||
|
||||
/* eslint no-useless-escape: 0 */
|
||||
$a.innerText = ellipsize(url.replace(/https?:\/\/(www\.)?/, ''), 50)
|
||||
|
||||
// If the url is on its own line, remove it entirely. Otherwise, replace it with the link
|
||||
content = content.replace(url, $a.outerHTML)
|
||||
}
|
||||
|
||||
|
@ -249,3 +249,19 @@ export const stringToColor = (value, saturation = 100, lightness = 50) => {
|
||||
|
||||
return `hsl(${(hash % 360)}, ${saturation}%, ${lightness}%)`;
|
||||
}
|
||||
|
||||
export const tryJson = f => {
|
||||
try {
|
||||
return f()
|
||||
} catch (e) {
|
||||
if (!e.toString().includes('JSON')) {
|
||||
warn(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const union = (...sets) =>
|
||||
new Set(sets.flatMap(s => Array.from(s)))
|
||||
|
||||
export const difference = (a, b) =>
|
||||
new Set(Array.from(a).filter(x => !b.has(x)))
|
||||
|
@ -26,7 +26,7 @@ export class Tags {
|
||||
return last(this.tags)
|
||||
}
|
||||
relays() {
|
||||
return uniq(flatten(this.tags).filter(isRelay)).map(objOf('url'))
|
||||
return uniq(flatten(this.tags).filter(isShareableRelay)).map(objOf('url'))
|
||||
}
|
||||
pubkeys() {
|
||||
return this.type("p").values().all()
|
||||
@ -71,6 +71,10 @@ export const displayPerson = p => {
|
||||
export const isLike = content => ['', '+', '🤙', '👍', '❤️'].includes(content)
|
||||
|
||||
export const isAlert = (e, pubkey) => {
|
||||
if (![1, 7].includes(e.kind)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Don't show people's own stuff
|
||||
if (e.pubkey === pubkey) {
|
||||
return false
|
||||
@ -88,6 +92,10 @@ export const isRelay = url => (
|
||||
typeof url === 'string'
|
||||
// It should have the protocol included
|
||||
&& url.match(/^wss?:\/\/.+/)
|
||||
)
|
||||
|
||||
export const isShareableRelay = url => (
|
||||
isRelay(url)
|
||||
// Don't match stuff with a port number
|
||||
&& !url.slice(6).match(/:\d+/)
|
||||
// Don't match raw ip addresses
|
||||
|
@ -29,3 +29,11 @@ export type DisplayEvent = MyEvent & {
|
||||
replies: Array<MyEvent>
|
||||
reactions: Array<MyEvent>
|
||||
}
|
||||
|
||||
export type Room = {
|
||||
id: string,
|
||||
pubkey: string
|
||||
name?: string
|
||||
about?: string
|
||||
picture?: string
|
||||
}
|
||||
|
@ -11,6 +11,7 @@
|
||||
import database from 'src/agent/database'
|
||||
import cmd from "src/agent/cmd"
|
||||
import {toast, modal} from "src/app/ui"
|
||||
import {publishWithToast} from 'src/app'
|
||||
|
||||
export let room = {name: null, id: null, about: null, picture: null}
|
||||
|
||||
@ -36,13 +37,16 @@
|
||||
if (!room.name) {
|
||||
toast.show("error", "Please enter a name for your room.")
|
||||
} else {
|
||||
const [event] = room.id
|
||||
? cmd.updateRoom(getUserWriteRelays(), room)
|
||||
: cmd.createRoom(getUserWriteRelays(), room)
|
||||
const relays = getUserWriteRelays()
|
||||
|
||||
await database.rooms.patch({id: room.id || event.id, joined: true})
|
||||
if (room.id) {
|
||||
publishWithToast(relays, cmd.updateRoom(room))
|
||||
} else {
|
||||
const [event] = await publishWithToast(relays, cmd.createRoom(room))
|
||||
|
||||
toast.show("info", `Your room has been ${room.id ? 'updated' : 'created'}!`)
|
||||
// Auto join the room the user just created
|
||||
database.rooms.patch({id: event.id, joined: true})
|
||||
}
|
||||
|
||||
modal.set(null)
|
||||
}
|
||||
|
@ -5,7 +5,7 @@
|
||||
import {last, reject, pluck, propEq} from 'ramda'
|
||||
import {fly} from 'svelte/transition'
|
||||
import {fuzzy} from "src/util/misc"
|
||||
import {isRelay, displayPerson} from "src/util/nostr"
|
||||
import {displayPerson} from "src/util/nostr"
|
||||
import Button from "src/partials/Button.svelte"
|
||||
import Compose from "src/partials/Compose.svelte"
|
||||
import Input from "src/partials/Input.svelte"
|
||||
@ -17,6 +17,7 @@
|
||||
import database from 'src/agent/database'
|
||||
import cmd from "src/agent/cmd"
|
||||
import {toast, modal} from "src/app/ui"
|
||||
import {publishWithToast} from 'src/app'
|
||||
|
||||
export let pubkey = null
|
||||
|
||||
@ -32,7 +33,7 @@
|
||||
const joined = new Set(pluck('url', relays))
|
||||
|
||||
search = fuzzy(
|
||||
$knownRelays.filter(r => isRelay(r.url) && !joined.has(r.url)),
|
||||
$knownRelays.filter(r => !joined.has(r.url)),
|
||||
{keys: ["name", "description", "url"]}
|
||||
)
|
||||
}
|
||||
@ -41,17 +42,20 @@
|
||||
const {content, mentions, topics} = input.parse()
|
||||
|
||||
if (content) {
|
||||
const [event] = cmd.createNote(relays, content, mentions, topics)
|
||||
const thunk = cmd.createNote(content, mentions, topics)
|
||||
const [event, promise] = await publishWithToast(relays, thunk)
|
||||
|
||||
toast.show("info", {
|
||||
text: `Your note has been created!`,
|
||||
link: {
|
||||
text: 'View',
|
||||
href: "/" + nip19.neventEncode({
|
||||
id: event.id,
|
||||
relays: pluck('url', relays.slice(0, 3)),
|
||||
}),
|
||||
},
|
||||
promise.then(() => {
|
||||
toast.show("info", {
|
||||
text: `Your note has been created!`,
|
||||
link: {
|
||||
text: 'View',
|
||||
href: "/" + nip19.neventEncode({
|
||||
id: event.id,
|
||||
relays: pluck('url', relays.slice(0, 3)),
|
||||
}),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
modal.clear()
|
||||
|
@ -9,6 +9,7 @@
|
||||
import {getUserWriteRelays} from 'src/agent/relays'
|
||||
import cmd from 'src/agent/cmd'
|
||||
import {modal} from 'src/app/ui'
|
||||
import {publishWithToast} from 'src/app'
|
||||
|
||||
const muffle = user.getProfile().muffle || []
|
||||
const muffleOptions = ['Never', 'Sometimes', 'Often', 'Always']
|
||||
@ -29,7 +30,7 @@
|
||||
.concat([["p", $modal.person.pubkey, muffleValue.toString()]])
|
||||
.filter(t => last(t) !== "1")
|
||||
|
||||
cmd.muffle(getUserWriteRelays(), muffleTags)
|
||||
publishWithToast(getUserWriteRelays(), cmd.muffle(muffleTags))
|
||||
|
||||
history.back()
|
||||
}
|
||||
|
@ -4,6 +4,10 @@ module.exports = {
|
||||
"./index.html",
|
||||
"./src/**/*.{js,svelte}",
|
||||
],
|
||||
safelist: [
|
||||
"w-4",
|
||||
"h-4",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
colors: {
|
||||
|
Loading…
Reference in New Issue
Block a user