Merge branch 'master' into nip07

This commit is contained in:
Jon Staab 2022-12-06 22:06:17 -08:00 committed by GitHub
commit 801641f900
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 1318 additions and 383 deletions

2
.ackrc
View File

@ -1 +1,3 @@
--ignore-dir=node_modules
--ignore-dir=dist
--ignore-file=match:package-lock.json

1
.env.local Normal file
View File

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

1
.env.production Normal file
View File

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

1
.gitignore vendored
View File

@ -10,7 +10,6 @@ lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*

View File

@ -1,15 +1,30 @@
Bugs
- [ ] Pagination
- [ ] Improve data loading. Ditch nostr-tools, or use eose
- [ ] Pubkeys expand the width of the page, hiding the plus post button
- [ ] Permalink note detail (share/permalink button?)
- [ ] Back button no longer works if a modal is closed normally
- [ ] Prevent tabs from re-mounting (or at least re- animating)
- [ ] Go "back" after adding a note
- [ ] uniq and sortBy are sprinkled all over the place, figure out a better solution
- [ ] With link/image previews, remove the url from the note body if it's on a separate last line
Features
- [x] Chat
- [x] Threads/social
- [ ] Followers
- [ ] Server discovery
- [x] Search
- [ ] Mentions
- [ ] Add "view thread" page that recurs more deeply
- [ ] Fix replies - notes may only include a "root" in its tags
- [x] Link previews
- [ ] Add notes, follows, likes tab to profile
- [ ] Notifications
- [ ] Images
- [ ] Server discovery and relay publishing - https://github.com/nostr-protocol/nips/pull/32/files
- [ ] Favorite chat rooms
- [ ] Optimistically load events the user publishes (e.g. to reduce reflow for reactions/replies).
- Essentially, we can pretend to be our own in-memory relay.
- This allows us to keep a copy of all user data, and possibly user likes/reply parents
Nostr implementation comments

View File

@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Coracle</title>
</head>
<body>
<body class="w-full">
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>

BIN
package-lock.json generated

Binary file not shown.

View File

@ -28,9 +28,12 @@
"dexie": "^3.2.2",
"fuse.js": "^6.6.2",
"hurdak": "github:ConsignCloud/hurdak",
"nostr-tools": "^0.24.1",
"nostr-tools": "github:fiatjaf/nostr-tools#1b798b2",
"ramda": "^0.28.0",
"svelte-link-preview": "^0.3.3",
"svelte-loading-spinners": "^0.3.4",
"svelte-routing": "^1.6.0",
"svelte-switch": "^0.0.5",
"throttle-debounce": "^5.0.0",
"vite-plugin-node-polyfills": "^0.5.0"
}

View File

@ -15,13 +15,16 @@
import Anchor from 'src/partials/Anchor.svelte'
import NoteDetail from "src/partials/NoteDetail.svelte"
import NotFound from "src/routes/NotFound.svelte"
import Search from "src/routes/Search.svelte"
import Notes from "src/routes/Notes.svelte"
import Login from "src/routes/Login.svelte"
import Profile from "src/routes/Profile.svelte"
import Settings from "src/routes/Settings.svelte"
import Keys from "src/routes/Keys.svelte"
import RelayList from "src/routes/RelayList.svelte"
import AddRelay from "src/routes/AddRelay.svelte"
import UserDetail from "src/routes/UserDetail.svelte"
import UserAdvanced from "src/routes/UserAdvanced.svelte"
import NoteCreate from "src/routes/NoteCreate.svelte"
import Chat from "src/routes/Chat.svelte"
import ChatRoom from "src/routes/ChatRoom.svelte"
@ -34,6 +37,8 @@
const toggleSearch = () => searchIsOpen.update(x => !x)
let menuIcon
let scrollY
let suspendedSubs = []
export let url = ""
@ -45,8 +50,34 @@
}
})
globalHistory.listen(() => {
modal.subscribe($modal => {
// Keep scroll position on body, but don't allow scrolling
if ($modal) {
// This is not idempotent, so don't duplicate it
if (document.body.style.position !== 'fixed') {
scrollY = window.scrollY
document.body.style.top = `-${scrollY}px`
document.body.style.position = `fixed`
}
} else {
document.body.style = ''
window.scrollTo(0, scrollY)
}
// Push another state so back button only closes the modal
if ($modal && !location.hash.startsWith('#modal')) {
globalHistory.navigate(location.pathname + '#modal')
}
})
globalHistory.listen(({action}) => {
// Once we've navigated, close our modal if we don't have m in the hash
setTimeout(() => {
if ($modal && !location.hash.startsWith('#modal')) {
modal.set(null)
}
}, 50)
})
})
</script>
@ -55,21 +86,35 @@
<Router {url}>
<div use:links class="h-full">
<div class="pt-16 text-white h-full" class:overflow-hidden={$modal}>
<Route path="/notes" component={Notes} />
<div class="pt-16 text-white h-full">
<Route path="/search/:type" let:params>
{#key params.type}
<Search {...params} />
{/key}
</Route>
<Route path="/notes/:type" let:params>
{#key params.type}
<Notes {...params} />
{/key}
</Route>
<Route path="/notes/new" component={NoteCreate} />
<Route path="/chat" component={Chat} />
<Route path="/chat/new" component={ChatEdit} />
<Route path="/chat/:room" let:params>
{#key params.room}
<ChatRoom room={params.room} />
<ChatRoom {...params} />
{/key}
</Route>
<Route path="/chat/:room/edit" component={ChatEdit} />
<Route path="/users/:pubkey" component={UserDetail} />
<Route path="/settings/keys" component={Keys} />
<Route path="/settings/relays" component={RelayList} />
<Route path="/settings/profile" component={Profile} />
<Route path="/users/:pubkey" let:params>
{#key params.pubkey}
<UserDetail {...params} />
{/key}
</Route>
<Route path="/keys" component={Keys} />
<Route path="/relays" component={RelayList} />
<Route path="/profile" component={Profile} />
<Route path="/settings" component={Settings} />
<Route path="/login" component={Login} />
<Route path="*" component={NotFound} />
</div>
@ -90,7 +135,12 @@
</li>
{/if}
<li class="cursor-pointer">
<a class="block px-4 py-2 hover:bg-accent transition-all" href="/notes">
<a class="block px-4 py-2 hover:bg-accent transition-all" href="/search/people">
<i class="fa-solid fa-search mr-2" /> Search
</a>
</li>
<li class="cursor-pointer">
<a class="block px-4 py-2 hover:bg-accent transition-all" href="/notes/global">
<i class="fa-solid fa-tag mr-2" /> Notes
</a>
</li>
@ -102,15 +152,20 @@
<li class="h-px mx-3 my-4 bg-medium" />
{#if $user}
<li class="cursor-pointer">
<a class="block px-4 py-2 hover:bg-accent transition-all" href="/settings/keys">
<a class="block px-4 py-2 hover:bg-accent transition-all" href="/keys">
<i class="fa-solid fa-key mr-2" /> Keys
</a>
</li>
<li class="cursor-pointer">
<a class="block px-4 py-2 hover:bg-accent transition-all" href="/settings/relays">
<a class="block px-4 py-2 hover:bg-accent transition-all" href="/relays">
<i class="fa-solid fa-server mr-2" /> Relays
</a>
</li>
<li class="cursor-pointer">
<a class="block px-4 py-2 hover:bg-accent transition-all" href="/settings">
<i class="fa-solid fa-gear mr-2" /> Settings
</a>
</li>
<li class="cursor-pointer">
<a class="block px-4 py-2 hover:bg-accent transition-all" on:click={logout}>
<i class="fa-solid fa-right-from-bracket mr-2" /> Logout
@ -149,6 +204,8 @@
{/key}
{:else if $modal.form === 'relay'}
<AddRelay />
{:else if $modal.form === 'user/advanced'}
<UserAdvanced />
{/if}
</dialog>
</div>

View File

@ -1,6 +1,9 @@
import './app.css'
import App from './App.svelte'
// Annoying global always fails silently. Figure out an eslint rule instead
window.find = null
const app = new App({
target: document.getElementById('app')
})

View File

@ -1,13 +1,16 @@
<script>
import cx from 'classnames'
import {find, last, uniqBy, prop, whereEq} from 'ramda'
import {fly} from 'svelte/transition'
import {find, uniqBy, prop, whereEq} from 'ramda'
import {onMount} from 'svelte'
import {fly, slide} from 'svelte/transition'
import {navigate} from 'svelte-routing'
import {ellipsize} from 'hurdak/src/core'
import {hasParent, toHtml} from 'src/util/html'
import {hasParent, toHtml, findLink} from 'src/util/html'
import Preview from 'src/partials/Preview.svelte'
import Anchor from 'src/partials/Anchor.svelte'
import {dispatch} from "src/state/dispatch"
import {accounts, modal} from "src/state/app"
import {findReplyTo} from "src/state/nostr"
import {accounts, settings, modal} from "src/state/app"
import {user} from "src/state/user"
import {formatTimestamp} from 'src/util/misc'
import UserBadge from "src/partials/UserBadge.svelte"
@ -18,6 +21,7 @@
export let interactive = false
export let invertColors = false
let link = null
let like = null
let flag = null
let reply = null
@ -26,16 +30,21 @@
$: {
like = find(e => e.pubkey === $user?.pubkey && e.content === "+", note.reactions)
flag = find(e => e.pubkey === $user?.pubkey && e.content === "-", note.reactions)
parentId = prop(1, find(t => last(t) === 'reply' ? t[1] : null, note.tags))
parentId = findReplyTo(note)
}
onMount(async () => {
link = $settings.showLinkPreviews ? findLink(note.content) : null
})
const onClick = e => {
if (!['I'].includes(e.target.tagName) && !hasParent('a', e.target)) {
modal.set({note})
}
}
const showParent = () => {
const showParent = async () => {
modal.set({note: {id: parentId}})
}
@ -97,7 +106,7 @@
"border border-solid border-dark hover:border-medium hover:bg-medium": interactive && invertColors,
})}>
<div class="flex gap-4 items-center justify-between">
<UserBadge user={$accounts[note.pubkey]} />
<UserBadge user={{...$accounts[note.pubkey], pubkey: note.pubkey}} />
<p class="text-sm text-light">{formatTimestamp(note.created_at)}</p>
</div>
<div class="ml-6 flex flex-col gap-2">
@ -106,12 +115,23 @@
Reply to <Anchor on:click={showParent}>{parentId.slice(0, 8)}</Anchor>
</small>
{/if}
{#if flag}
<p class="text-light border-l-2 border-solid border-medium pl-4">
You have flagged this content as offensive.
<Anchor on:click={() => deleteReaction(flag)}>Unflag</Anchor>
</p>
{:else}
<p>
{#if note.content.length > 240 && !showEntire}
{ellipsize(note.content, 240)}
{#if note.content.length > 500 && !showEntire}
{ellipsize(note.content, 500)}
{:else}
{@html toHtml(note.content)}
{/if}
{#if link}
<div class="mt-2" on:click={e => e.stopPropagation()}>
<Preview endpoint={`${$settings.dufflepudUrl}/link/preview`} url={link} />
</div>
{/if}
</p>
<div class="flex gap-6 text-light">
<div>
@ -126,20 +146,19 @@
on:click={() => like ? deleteReaction(like) : react("+")} />
{uniqBy(prop('pubkey'), note.reactions.filter(whereEq({content: '+'}))).length}
</div>
<div class={cx({'text-accent': flag})}>
<i
class="fa-solid fa-flag cursor-pointer"
on:click={() => flag ? deleteReaction(flag) : react("-")} />
<div>
<i class="fa-solid fa-flag cursor-pointer" on:click={() => react("-")} />
{uniqBy(prop('pubkey'), note.reactions.filter(whereEq({content: '-'}))).length}
</div>
</div>
{/if}
</div>
</div>
{#if reply !== null}
<div
class="note-reply flex bg-medium border-medium border border-solid"
transition:fly={{y: 20}}>
transition:slide>
<textarea
rows="4"
autofocus

View File

@ -1,29 +1,50 @@
<script>
import {onMount} from 'svelte'
import {find, propEq} from 'ramda'
import {findNotes} from "src/state/app"
import {writable} from 'svelte/store'
import Spinner from 'src/partials/Spinner.svelte'
import {channels} from "src/state/nostr"
import {notesListener, annotateNotes, modal} from "src/state/app"
import {user} from "src/state/user"
import Note from 'src/partials/Note.svelte'
export let note
const notes = writable([])
let cursor
let listener
onMount(() => {
const start = findNotes(
[{ids: [note.id]},
{'#e': [note.id]},
channels.getter
.all({kinds: [1, 5, 7], ids: [note.id]})
.then(annotateNotes)
.then($notes => {
notes.set($notes)
})
listener = notesListener(notes, [
{kinds: [1, 5, 7], '#e': [note.id]},
// We can't target reaction deletes by e tag, so get them
// all so we can support toggling like/flags for our user
{kinds: [5], authors: $user ? [$user.pubkey] : []}],
$notes => {
note = find(propEq('id', note.id), $notes) || note
}
)
{kinds: [5], authors: $user ? [$user.pubkey] : []}
])
return start()
// Populate our initial empty space
listener.start()
// Unsubscribe when modal closes so that others can re-subscribe sooner
const unsubModal = modal.subscribe($modal => {
cursor?.stop()
listener?.stop()
})
return () => {
unsubModal()
}
})
</script>
{#if note.pubkey}
{#each $notes as note (note.id)}
<div n:fly={{y: 20}}>
<Note showEntire note={note} />
{#each note.replies as r (r.id)}
<div class="ml-4 border-l border-solid border-medium">
@ -40,4 +61,7 @@
{/each}
</div>
{/each}
{/if}
</div>
{:else}
<Spinner />
{/each}

View File

@ -0,0 +1,44 @@
<script>
import {onMount} from 'svelte'
import {slide} from 'svelte/transition'
import Anchor from 'src/partials/Anchor.svelte'
export let url
export let endpoint
let preview
onMount(async () => {
const res = await fetch(endpoint, {
method: 'POST',
body: JSON.stringify({url}),
headers: {
'Content-Type': 'application/json',
},
})
const json = await res.json()
if (json.title) {
preview = json
}
})
</script>
{#if preview}
<div in:slide>
<Anchor
external
href={url}
class="rounded border border-solid border-medium flex flex-col bg-white overflow-hidden">
{#if preview.image}
<img src={preview.image} />
<div class="h-px bg-medium" />
{/if}
<div class="px-4 py-2 text-black flex flex-col bg-white">
<strong class="whitespace-nowrap text-ellipsis overflow-hidden">{preview.title}</strong>
<small>{preview.description}</small>
</div>
</Anchor>
</div>
{/if}

View File

@ -0,0 +1,23 @@
<script>
import cx from "classnames"
export let options
export let value
</script>
<div>
<div class="inline-block">
<div class="rounded flex border border-solid border-light cursor-pointer">
{#each options as option, i}
<span
class={cx("px-4 py-2", {
"border-l border-solid border-light": i > 0,
"bg-accent": value === option,
})}
on:click={() => { value = option }}>
{option}
</span>
{/each}
</div>
</div>
</div>

View File

@ -0,0 +1,8 @@
<script>
import {fade} from 'svelte/transition'
import {Circle2} from 'svelte-loading-spinners'
</script>
<div class="py-20 flex justify-center" in:fade={{delay: 1000}}>
<Circle2 colorOuter="#CCC5B9" colorInner="#403D39" colorCenter="#EB5E28" />
</div>

View File

@ -0,0 +1,25 @@
<script>
import Switch from "svelte-switch"
export let value
const onChange = e => {
value = e.detail.checked
}
</script>
<Switch
checked={value}
on:change={onChange}
onColor="#ccc"
offColor="#ccc"
onHandleColor="#EB5E28"
handleDiameter={26}
unCheckedIcon={false}
boxShadow="0px 1px 5px rgba(0, 0, 0, 0.6)"
activeBoxShadow="0px 0px 1px 10px rgba(0, 0, 0, 0.2)"
height={20}
width={48}>
<span slot="checkedIcon" />
<span slot="unCheckedIcon" />
</Switch>

View File

@ -2,11 +2,9 @@
export let user
</script>
{#if user}
<a href={`/users/${user.pubkey}`} class="flex gap-2 items-center">
<a href={`/users/${user?.pubkey}`} class="flex gap-2 items-center">
<div
class="overflow-hidden w-4 h-4 rounded-full bg-cover bg-center shrink-0 border border-solid border-white"
style="background-image: url({user.picture})" />
<span class="text-lg font-bold">{user.name || ''}</span>
style="background-image: url({user?.picture})" />
<span class="text-lg font-bold">{user?.name || user?.pubkey.slice(0, 8)}</span>
</a>
{/if}

View File

@ -11,8 +11,12 @@
let q = ""
let rooms = {}
let search
let nOtherRooms
$: search = fuzzy(Object.values(rooms), {keys: ["name", "about"]})
$: {
search = fuzzy(Object.values(rooms), {keys: ["name", "about"]})
nOtherRooms = Math.floor(0, Object.keys(rooms).length - 8)
}
const createRoom = () => navigate(`/chat/new`)
@ -60,9 +64,11 @@
{/if}
</li>
{/each}
{#if nOtherRooms > 1}
<li class="px-3">
<small>{Math.floor(0, Object.keys(rooms).length - 8)} more rooms found</small>
<small>Enter a search term to discover {nOtherRooms} more rooms.</small>
</li>
{/if}
<li class="bg-medium m-3 h-px" />
<li class="cursor-pointer font-bold hover:bg-accent transition-all px-3 py-2" on:click={createRoom}>
<i class="fa-solid fa-plus" /> Create Room

View File

@ -35,7 +35,7 @@
<div class="flex flex-col gap-8 w-full">
<div class="flex flex-col gap-1">
<strong>Relay URL</strong>
<Input autofocus bind:value={url}>
<Input autofocus bind:value={url} placeholder="wss://relay.example.com">
<i slot="before" class="fa-solid fa-link" />
</Input>
<p class="text-sm text-light">

View File

@ -6,7 +6,6 @@
import Input from "src/partials/Input.svelte"
import Textarea from "src/partials/Textarea.svelte"
import Button from "src/partials/Button.svelte"
import RoomList from "src/partials/chat/RoomList.svelte"
import {dispatch} from "src/state/dispatch"
import {channels} from "src/state/nostr"
import toast from "src/state/toast"
@ -51,9 +50,7 @@
}
</script>
<div class="flex gap-4 h-full">
<div class="sm:ml-56 w-full">
<form on:submit={submit} class="flex justify-center py-8 px-4" in:fly={{y: 20}}>
<form on:submit={submit} class="flex justify-center py-12" in:fly={{y: 20}}>
<div class="flex flex-col gap-4 max-w-2xl">
<div class="flex justify-center items-center flex-col mb-4">
<h1 class="staatliches text-6xl">Name your room</h1>
@ -86,7 +83,4 @@
</div>
</div>
</form>
</div>
<RoomList />
</div>

View File

@ -1,20 +1,22 @@
<script>
import {onMount} from 'svelte'
import {onMount, onDestroy} from 'svelte'
import {fly} from 'svelte/transition'
import {navigate} from 'svelte-routing'
import {prop, uniqBy, sortBy, last} from 'ramda'
import {switcherFn} from 'hurdak/src/core'
import {prop, uniq, pluck, reverse, uniqBy, sortBy, last} from 'ramda'
import {formatTimestamp} from 'src/util/misc'
import {toHtml} from 'src/util/html'
import UserBadge from 'src/partials/UserBadge.svelte'
import {channels} from 'src/state/nostr'
import {accounts, ensureAccounts} from 'src/state/app'
import {Listener, Cursor, epoch} from 'src/state/nostr'
import {accounts, createScroller, ensureAccounts} from 'src/state/app'
import {dispatch} from 'src/state/dispatch'
import {user} from 'src/state/user'
import RoomList from "src/partials/chat/RoomList.svelte"
export let room
let cursor
let listener
let scroller
let textarea
let messages = []
let annotatedMessages = []
@ -22,7 +24,7 @@
$: {
// Group messages so we're only showing the account once per chunk
annotatedMessages = sortBy(prop('created_at'), uniqBy(prop('id'), messages)).reduce(
annotatedMessages = reverse(sortBy(prop('created_at'), uniqBy(prop('id'), messages)).reduce(
(mx, m) => {
const account = $accounts[m.pubkey]
@ -38,7 +40,7 @@
})
},
[]
)
))
}
onMount(async () => {
@ -46,47 +48,63 @@
return navigate('/login')
}
const events = await channels.getter.all({kinds: [40, 41], ids: [room]})
// flex-col means the first is the last
const getLastListItem = () => document.querySelector('ul[name=messages] li')
events.forEach(({pubkey, content}) => {
roomData = {pubkey, ...roomData, ...JSON.parse(content)}
})
const stickToBottom = async (behavior, cb) => {
const shouldStick = window.scrollY + window.innerHeight > document.body.scrollHeight - 200
const $li = getLastListItem()
const isVisible = $el => {
const bodyRect = document.body.getBoundingClientRect()
const {top, height} = $el.getBoundingClientRect()
await cb()
return top + height < bodyRect.height
if ($li && shouldStick) {
$li.scrollIntoView({behavior})
}
}
return channels.watcher.sub({
filter: {
limit: 100,
kinds: [42, 43, 44],
'#e': [room],
cursor = new Cursor({kinds: [42], '#e': [room]})
scroller = createScroller(
cursor,
chunk => {
stickToBottom('auto', async () => {
for (const e of chunk) {
messages = messages.concat(e)
}
if (chunk.length > 0) {
await ensureAccounts(uniq(pluck('pubkey', chunk)))
}
})
},
cb: e => {
switcherFn(e.kind, {
42: () => {
{reverse: true}
)
listener = new Listener(
[{kinds: [40, 41], ids: [room], since: epoch},
{kinds: [42], '#e': [room]}],
e => {
const {pubkey, kind, content} = e
if ([40, 41].includes(kind)) {
roomData = {pubkey, ...roomData, ...JSON.parse(content)}
} else {
stickToBottom('smooth', async () => {
messages = messages.concat(e)
ensureAccounts([e.pubkey])
const $prevListItem = last(document.querySelectorAll('.chat-message'))
if ($prevListItem && isVisible($prevListItem)) {
setTimeout(() => {
const $li = last(document.querySelectorAll('.chat-message'))
$li.scrollIntoView({behavior: "smooth"})
}, 100)
await ensureAccounts([e.pubkey])
})
}
},
43: () => null,
44: () => null,
})
},
}
)
scroller.start()
listener.start()
})
onDestroy(() => {
cursor?.stop()
listener?.stop()
scroller?.stop()
})
const edit = () => {
@ -111,14 +129,15 @@
}
</script>
<svelte:window on:scroll={scroller?.start} />
<div class="flex gap-4 h-full">
<div class="sm:ml-56 w-full">
<div class="relative">
<div class="flex flex-col pt-20 pb-32">
<ul class="p-4 max-h-full flex-grow">
{#each annotatedMessages as m}
<li in:fly={{y: 20}} class="py-1 chat-message">
<div class="flex flex-col py-32">
<ul class="p-4 max-h-full flex-grow flex flex-col-reverse" name="messages">
{#each annotatedMessages as m (m.id)}
<li in:fly={{y: 20}} class="py-1">
{#if m.showAccount}
<div class="flex gap-4 items-center justify-between">
<UserBadge user={m.account} />

View File

@ -37,7 +37,7 @@
<div class="flex flex-col gap-8 w-full">
<div class="flex flex-col gap-1">
<strong>Public Key</strong>
<Input disabled value={$user.pubkey}>
<Input disabled value={$user?.pubkey}>
<i slot="after" class="fa-solid fa-copy cursor-pointer" on:click={() => copyKey('public')} />
</Input>
<p class="text-sm text-light">
@ -48,7 +48,7 @@
{#if $user.privkey}
<div class="flex flex-col gap-1">
<strong>Private Key</strong>
<Input disabled type="password" value={$user.privkey}>
<Input disabled type="password" value={$user?.privkey}>
<i slot="after" class="fa-solid fa-copy cursor-pointer" on:click={() => copyKey('private')} />
</Input>
<p class="text-sm text-light">

View File

@ -48,11 +48,11 @@
const {found} = await dispatch("account/init", { privkey, pubkey })
if ($relays.length === 0) {
navigate('/settings/relays')
navigate('/relays')
} else if (found) {
navigate('/')
navigate('/notes/global')
} else {
navigate('/settings/profile')
navigate('/profile')
}
}
}

View File

@ -2,5 +2,5 @@
import {onMount} from 'svelte'
import {navigate} from 'svelte-routing'
onMount(() => navigate('/notes'))
onMount(() => navigate('/notes/global'))
</script>

View File

@ -17,7 +17,7 @@
toast.show("info", `Your note has been created!`)
navigate('/notes')
navigate('/notes/global')
}
onMount(() => {

View File

@ -1,31 +1,97 @@
<script>
import {onMount} from 'svelte'
import {onMount, onDestroy} from 'svelte'
import {fly} from 'svelte/transition'
import {writable} from 'svelte/store'
import {navigate} from "svelte-routing"
import {uniqBy, reject, prop} from 'ramda'
import Anchor from "src/partials/Anchor.svelte"
import Spinner from "src/partials/Spinner.svelte"
import Note from "src/partials/Note.svelte"
import {relays} from "src/state/nostr"
import {findNotesAndWatchModal} from "src/state/app"
import {relays, Cursor} from "src/state/nostr"
import {user} from "src/state/user"
import {createScroller, getMuffleValue, annotateNotes, notesListener, modal} from "src/state/app"
let notes
export let type
const notes = writable([])
let cursor
let listener
let scroller
let modalUnsub
let authors = $user ? $user.petnames.map(t => t[1]) : []
const createNote = () => {
navigate("/notes/new")
}
onMount(() => {
return findNotesAndWatchModal({
limit: 100,
}, $notes => {
if ($notes.length) {
notes = $notes
onMount(async () => {
cursor = new Cursor(type === 'global' ? {kinds: [1]} : {kinds: [1], authors})
listener = await notesListener(notes, {kinds: [1, 5, 7]})
scroller = createScroller(cursor, async chunk => {
// Remove a sampling of content if desired
chunk = reject(n => Math.random() > getMuffleValue(n.pubkey), chunk)
const annotated = await annotateNotes(chunk, {showParents: true})
notes.update($notes => uniqBy(prop('id'), $notes.concat(annotated)))
})
// When a modal opens, suspend our subscriptions
modalUnsub = modal.subscribe(async $modal => {
if ($modal) {
cursor.stop()
listener.stop()
scroller.stop()
} else {
cursor.start()
listener.start()
scroller.start()
}
})
})
onDestroy(() => {
cursor?.stop()
listener?.stop()
scroller?.stop()
modalUnsub?.()
})
</script>
<ul class="py-8 flex flex-col gap-2 max-w-xl m-auto">
{#each (notes || []) as n (n.id)}
<li class="border-l border-solid border-medium">
<svelte:window on:scroll={scroller?.start} />
{#if $relays.length === 0}
<div class="flex w-full justify-center items-center py-16">
<div class="text-center max-w-md">
You aren't yet connected to any relays. Please click <Anchor href="/relays"
>here</Anchor
> to get started.
</div>
</div>
{:else}
<ul class="border-b border-solid border-dark flex max-w-xl m-auto pt-2" in:fly={{y: 20}}>
<li
class="cursor-pointer hover:border-b border-solid border-medium"
class:border-b={type === 'global'}>
<a class="block px-8 py-4 " href="/notes/global">Global</a>
</li>
<li
class="cursor-pointer hover:border-b border-solid border-medium"
class:border-b={type === 'follows'}>
<a class="block px-8 py-4 " href="/notes/follows">Follows</a>
</li>
</ul>
{#if type === 'follows' && authors.length === 0}
<div class="flex w-full justify-center items-center py-16">
<div class="text-center max-w-md">
You haven't yet followed anyone. Visit a user's profile to follow them.
</div>
</div>
{:else}
<ul class="py-4 flex flex-col gap-2 max-w-xl m-auto">
{#each (notes ? $notes : []) as n (n.id)}
<li>
<Note interactive note={n} />
{#each n.replies as r (r.id)}
<div class="ml-6 border-l border-solid border-medium">
@ -36,7 +102,10 @@
{/each}
</ul>
{#if $relays.length > 0}
<!-- This will always be sitting at the bottom in case infinite scrolling can't keep up -->
<Spinner />
{/if}
<div class="fixed bottom-0 right-0 p-8">
<div
class="rounded-full bg-accent color-white w-16 h-16 flex justify-center
@ -46,13 +115,5 @@
<span class="fa-sold fa-plus fa-2xl" />
</div>
</div>
{:else}
<div class="flex w-full justify-center items-center py-16">
<div class="text-center max-w-md">
You aren't yet connected to any relays. Please click <Anchor href="/settings/relays"
>here</Anchor
> to get started.
</div>
</div>
{/if}

View File

@ -1,16 +1,18 @@
<script>
import {fly} from 'svelte/transition'
import {find, identity, whereEq, reject} from 'ramda'
import {fuzzy} from "src/util/misc"
import Input from "src/partials/Input.svelte"
import Anchor from "src/partials/Anchor.svelte"
import {dispatch} from "src/state/dispatch"
import {relays, knownRelays} from "src/state/nostr"
import {modal} from "src/state/app"
let q = ""
let search
let data
$: search = fuzzy($knownRelays || [], {keys: ["name", "description", "url"]})
$: data = reject(r => $relays.includes(r.url), $knownRelays || [])
$: search = fuzzy(data, {keys: ["name", "description", "url"]})
const join = url => dispatch("relay/join", url)
@ -26,23 +28,32 @@
interact with the network, but you can join as many as you like.
</p>
</div>
<div class="flex gap-4">
<Input bind:value={q} type="text" wrapperClass="flex-grow" placeholder="Type to search">
<i slot="before" class="fa-solid fa-search" />
</Input>
<Anchor type="button" href="/notes">Done</Anchor>
</div>
<div class="flex flex-col gap-6 overflow-auto flex-grow -mx-6 px-6">
{#each $relays.map(url => find(whereEq({url}), $knownRelays)).filter(identity) as relay}
<div class="flex gap-2 justify-between">
<div>
<strong>{relay.name || relay.url}</strong>
<p class="text-light">{relay.description || ''}</p>
</div>
<a class="underline cursor-pointer" on:click={() => leave(relay.url)}>
Leave
</a>
</div>
{/each}
{#if $relays.length > 0}
<div class="pt-2 mb-2 border-b border-solid border-medium" />
{/if}
{#each search(q).slice(0, 10) as relay}
<div class="flex gap-2 justify-between">
<div>
<strong>{relay.name || relay.url}</strong>
<p class="text-light">{relay.description || ''}</p>
</div>
<a
class="underline cursor-pointer"
on:click={() => $relays.includes(relay.url) ? leave(relay.url) : join(relay.url)}>
{$relays.includes(relay.url) ? "Leave" : "Join"}
<a class="underline cursor-pointer" on:click={() => join(relay.url)}>
Join
</a>
</div>
{/each}

133
src/routes/Search.svelte Normal file
View File

@ -0,0 +1,133 @@
<script>
import {onMount, onDestroy} from 'svelte'
import {writable} from 'svelte/store'
import {fly} from 'svelte/transition'
import {uniqBy, pluck, prop} from 'ramda'
import {fuzzy} from "src/util/misc"
import Anchor from "src/partials/Anchor.svelte"
import Input from "src/partials/Input.svelte"
import Spinner from "src/partials/Spinner.svelte"
import Note from "src/partials/Note.svelte"
import {relays, Cursor} from "src/state/nostr"
import {user} from "src/state/user"
import {createScroller, ensureAccounts, accounts, annotateNotes, modal} from "src/state/app"
export let type
const data = writable([])
let q = ''
let search
let results
let cursor
let scroller
let modalUnsub
$: search = fuzzy($data, {keys: type === 'people' ? ["name", "about", "pubkey"] : ["content"]})
$: {
scroller?.start()
results = search(q)
}
onMount(async () => {
cursor = new Cursor({kinds: type === 'people' ? [0] : [1]})
scroller = createScroller(cursor, async chunk => {
if (type === 'people') {
await ensureAccounts(pluck('pubkey', chunk))
data.set(Object.values($accounts))
} else {
const annotated = await annotateNotes(chunk)
data.update($data => uniqBy(prop('id'), $data.concat(annotated)))
}
})
// When a modal opens, suspend our subscriptions
modalUnsub = modal.subscribe(async $modal => {
if ($modal) {
cursor.stop()
scroller.stop()
} else {
cursor.start()
scroller.start()
}
})
})
onDestroy(() => {
cursor?.stop()
scroller?.stop()
modalUnsub?.()
})
</script>
<svelte:window on:scroll={scroller?.start} />
<ul class="border-b border-solid border-dark flex max-w-xl m-auto pt-2" in:fly={{y: 20}}>
<li
class="cursor-pointer hover:border-b border-solid border-medium"
class:border-b={type === 'people'}>
<a class="block px-8 py-4 " href="/search/people">People</a>
</li>
<li
class="cursor-pointer hover:border-b border-solid border-medium"
class:border-b={type === 'notes'}>
<a class="block px-8 py-4 " href="/search/notes">Notes</a>
</li>
</ul>
<div class="max-w-xl m-auto mt-4" in:fly={{y: 20}}>
<Input bind:value={q} placeholder="Search for {type}">
<i slot="before" class="fa-solid fa-search" />
</Input>
</div>
{#if type === 'people'}
<ul class="py-8 flex flex-col gap-2 max-w-xl m-auto">
{#each (results || []) as e (e.pubkey)}
{#if e.pubkey !== $user.pubkey}
<li in:fly={{y: 20}}>
<a href="/users/{e.pubkey}" class="flex gap-4 my-4">
<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({e.picture})" />
<div class="flex-grow">
<h1 class="text-2xl">{e.name || e.pubkey.slice(0, 8)}</h1>
<p>{e.about || ''}</p>
</div>
</a>
<li>
{/if}
{/each}
</ul>
{/if}
{#if type === 'notes'}
<ul class="py-8 flex flex-col gap-2 max-w-xl m-auto">
{#each (results || []) as e (e.id)}
<li in:fly={{y: 20}}>
<Note interactive note={e} />
{#each e.replies as r (r.id)}
<div class="ml-6 border-l border-solid border-medium">
<Note interactive isReply note={r} />
</div>
{/each}
</li>
{/each}
</ul>
{/if}
<!-- This will always be sitting at the bottom in case infinite scrolling can't keep up -->
<Spinner />
{#if $relays.length === 0}
<div class="flex w-full justify-center items-center py-16">
<div class="text-center max-w-md">
You aren't yet connected to any relays. Please click <Anchor href="/relays"
>here</Anchor
> to get started.
</div>
</div>
{/if}

View File

@ -0,0 +1,61 @@
<script>
import {onMount} from "svelte"
import {fly} from 'svelte/transition'
import {navigate} from "svelte-routing"
import Toggle from "src/partials/Toggle.svelte"
import Input from "src/partials/Input.svelte"
import Button from "src/partials/Button.svelte"
import {user} from "src/state/user"
import {settings} from "src/state/app"
import toast from "src/state/toast"
let values = {...$settings}
onMount(async () => {
if (!$user) {
return navigate("/login")
}
})
const submit = async event => {
event.preventDefault()
settings.set(values)
navigate('/notes/global')
toast.show("info", "Your settings have been saved!")
}
</script>
<form on:submit={submit} class="flex justify-center py-8 px-4" in:fly={{y: 20}}>
<div class="flex flex-col gap-4 max-w-2xl">
<div class="flex justify-center items-center flex-col mb-4">
<h1 class="staatliches text-6xl">App Settings</h1>
<p>
Tweak Coracle to work the way you want it to.
</p>
</div>
<div class="flex flex-col gap-8 w-full">
<div class="flex flex-col gap-1">
<strong>Show Link Previews</strong>
<Toggle bind:value={values.showLinkPreviews} />
<p class="text-sm text-light">
If enabled, coracle will automatically retrieve a link preview for the first link
in any note.
</p>
</div>
<div class="flex flex-col gap-1">
<strong>Dufflepud URL</strong>
<Input bind:value={values.dufflepudUrl}>
<i slot="before" class="fa-solid fa-server" />
</Input>
<p class="text-sm text-light">
Enter a custom url for Coracle's helper application. Dufflepud is used for
hosting images and loading link previews.
</p>
</div>
<Button type="submit" class="text-center">Save</Button>
</div>
</div>
</form>

View File

@ -0,0 +1,50 @@
<script>
import {last} from 'ramda'
import {switcher} from 'hurdak/lib/hurdak'
import {fly} from 'svelte/transition'
import Button from "src/partials/Button.svelte"
import SelectButton from "src/partials/SelectButton.svelte"
import {user} from 'src/state/user'
import {dispatch, t} from 'src/state/dispatch'
import {modal, getMuffleValue} from "src/state/app"
const muffleOptions = ['Never', 'Sometimes', 'Often', 'Always']
const values = {
// Scale up to integers for each choice we have
muffle: switcher(Math.round(getMuffleValue($modal.user.pubkey) * 3), muffleOptions),
}
const save = async e => {
e.preventDefault()
// Scale back down to a decimal based on string value
const muffleValue = muffleOptions.indexOf(values.muffle) / 3
const muffle = $user.muffle
.filter(x => x[1] !== $modal.user.pubkey)
.concat([t("p", $modal.user.pubkey, muffleValue.toString())])
.filter(x => last(x) !== "1")
dispatch('account/muffle', muffle)
modal.set(null)
}
</script>
<form class="flex flex-col gap-4 w-full text-white max-w-2xl m-auto" in:fly={{y: 20}} on:submit={save}>
<div class="flex flex-col gap-2">
<h1 class="text-3xl">Advanced Follow</h1>
<p>
Fine grained controls for interacting with other users.
</p>
</div>
<div class="flex flex-col gap-1">
<strong>How often do you want to see notes from this person?</strong>
<SelectButton bind:value={values.muffle} options={muffleOptions} />
<p class="text-sm text-light">
"Never" is effectively a mute, while "Always" will show posts whenever available.
If you want a middle ground, choose "Sometimes" or "Often".
</p>
</div>
<Button type="submit" class="text-center">Done</Button>
</form>

View File

@ -1,30 +1,94 @@
<script>
import {onMount} from 'svelte'
import {reverse} from 'ramda'
import {onMount, onDestroy} from 'svelte'
import {writable} from 'svelte/store'
import {uniqBy, prop} from 'ramda'
import {fly} from 'svelte/transition'
import Note from "src/partials/Note.svelte"
import Spinner from "src/partials/Spinner.svelte"
import Button from "src/partials/Button.svelte"
import {Cursor, epoch} from 'src/state/nostr'
import {user as currentUser} from 'src/state/user'
import {accounts, findNotesAndWatchModal} from "src/state/app"
import {t, dispatch} from 'src/state/dispatch'
import {accounts, getFollow, createScroller, notesListener, modal, annotateNotes} from "src/state/app"
export let pubkey
const notes = writable([])
let user
let notes
let cursor
let listener
let scroller
let interval
let loading = true
let modalUnsub
let following = getFollow(pubkey)
$: user = $accounts[pubkey]
onMount(() => {
return findNotesAndWatchModal({
authors: [pubkey],
limit: 100,
}, $notes => {
if ($notes.length) {
notes = $notes
onMount(async () => {
cursor = new Cursor({kinds: [1], authors: [pubkey]})
listener = await notesListener(notes, [{kinds: [1], authors: [pubkey]}, {kinds: [5, 7]}])
scroller = createScroller(cursor, async chunk => {
const annotated = await annotateNotes(chunk, {showParents: true})
notes.update($notes => uniqBy(prop('id'), $notes.concat(annotated)))
})
// Populate our initial empty space
scroller.start()
// Track loading based on cursor cutoff date
interval = setInterval(() => {
loading = cursor.since > epoch
}, 1000)
// When a modal opens, suspend our subscriptions
modalUnsub = modal.subscribe(async $modal => {
if ($modal) {
cursor.stop()
listener.stop()
} else {
cursor.start()
listener.start()
}
})
})
onDestroy(() => {
cursor?.stop()
listener?.stop()
scroller?.stop()
modalUnsub?.()
clearInterval(interval)
})
const follow = () => {
const petnames = $currentUser.petnames
.concat([t("p", user.pubkey, user.name)])
console.log(petnames)
dispatch('account/petnames', petnames)
following = true
}
const unfollow = () => {
const petnames = $currentUser.petnames
.filter(([_, pubkey]) => pubkey !== user.pubkey)
dispatch('account/petnames', petnames)
following = false
}
const openAdvanced = () => {
modal.set({form: 'user/advanced', user})
}
</script>
<svelte:window on:scroll={scroller?.start} />
{#if user}
<div class="max-w-2xl m-auto flex flex-col gap-4 py-8 px-4">
<div class="flex flex-col gap-4" in:fly={{y: 20}}>
@ -33,22 +97,35 @@
class="overflow-hidden w-12 h-12 rounded-full bg-cover bg-center shrink-0 border border-solid border-white"
style="background-image: url({user.picture})" />
<div class="flex-grow">
<div class="flex justify-between items-center">
<div class="flex items-center gap-2">
<h1 class="text-2xl">{user.name}</h1>
{#if $currentUser?.pubkey === pubkey}
<a href="/settings/profile" class="cursor-pointer text-sm">
<i class="fa-solid fa-edit" /> Edit
</a>
{#if $currentUser && $currentUser.pubkey !== pubkey}
<i class="fa-solid fa-sliders cursor-pointer" on:click={openAdvanced} />
{/if}
</div>
<p>{user.about || ''}</p>
</div>
<div class="whitespace-nowrap">
{#if $currentUser?.pubkey === pubkey}
<a href="/settings/profile" class="cursor-pointer text-sm">
<i class="fa-solid fa-edit" /> Edit
</a>
{:else}
<div class="flex flex-col items-end gap-2">
{#if following}
<Button on:click={unfollow}>Unfollow</Button>
{:else}
<Button on:click={follow}>Follow</Button>
{/if}
</div>
{/if}
</div>
</div>
</div>
<div class="h-px bg-medium" in:fly={{y: 20, delay: 200}} />
<ul class="flex flex-col -mt-4" in:fly={{y: 20, delay: 400}}>
{#each reverse(notes || []) as n (n.id)}
<li class="border-l border-solid border-medium pb-2">
<ul class="flex flex-col" in:fly={{y: 20, delay: 400}}>
{#each (notes ? $notes : []) as n (n.id)}
<li>
<Note interactive note={n} />
{#each n.replies as r (r.id)}
<div class="ml-6 border-l border-solid border-medium">
@ -57,7 +134,11 @@
{/each}
</li>
{:else}
<li class="py-4">This user hasn't posted any notes.</li>
{#if loading}
<li><Spinner /></li>
{:else}
<li class="p-20 text-center" in:fly={{y: 20}}>No notes found.</li>
{/if}
{/each}
</ul>
</div>

View File

@ -1,14 +1,35 @@
import {prop, uniq, sortBy, uniqBy, find, last, groupBy} from 'ramda'
import {when, assoc, prop, identity, whereEq, reverse, uniq, sortBy, uniqBy, find, last, pluck, groupBy} from 'ramda'
import {debounce} from 'throttle-debounce'
import {writable, derived, get} from 'svelte/store'
import {writable, get} from 'svelte/store'
import {navigate} from "svelte-routing"
import {switcherFn, ensurePlural} from 'hurdak/lib/hurdak'
import {getLocalJson, setLocalJson, now, timedelta} from "src/util/misc"
import {getLocalJson, setLocalJson, now, timedelta, sleep} from "src/util/misc"
import {user} from 'src/state/user'
import {channels, relays} from 'src/state/nostr'
import {epoch, filterMatches, Listener, channels, relays, findReplyTo} from 'src/state/nostr'
export const modal = writable(null)
export const settings = writable({
showLinkPreviews: true,
dufflepudUrl: import.meta.env.VITE_DUFFLEPUD_URL,
...getLocalJson("coracle/settings"),
})
settings.subscribe($settings => {
setLocalJson("coracle/settings", $settings)
})
export const logout = () => {
// Give any animations a moment to finish
setTimeout(() => {
user.set(null)
relays.set([])
navigate("/login")
}, 200)
}
// Accounts
export const accounts = writable(getLocalJson("coracle/accounts") || {})
accounts.subscribe($accounts => {
@ -21,17 +42,6 @@ user.subscribe($user => {
}
})
export const logout = () => {
// Give any animations a moment to finish
setTimeout(() => {
user.set(null)
relays.set([])
navigate("/login")
}, 200)
}
// Utils
export const ensureAccounts = async (pubkeys, {force = false} = {}) => {
const $accounts = get(accounts)
@ -41,16 +51,30 @@ export const ensureAccounts = async (pubkeys, {force = false} = {}) => {
)
if (pubkeys.length) {
const events = await channels.getter.all({kinds: [0], authors: pubkeys})
const events = await channels.getter.all({kinds: [0, 3, 12165], authors: uniq(pubkeys)})
await accounts.update($accounts => {
events.forEach(e => {
$accounts[e.pubkey] = {
pubkey: e.pubkey,
const values = {
muffle: [],
petnames: [],
...$accounts[e.pubkey],
...JSON.parse(e.content),
pubkey: e.pubkey,
refreshed: now(),
isUser: true,
}
switcherFn(e.kind, {
0: () => {
$accounts[e.pubkey] = {...values, ...JSON.parse(e.content)}
},
3: () => {
$accounts[e.pubkey] = {...values, petnames: e.tags}
},
12165: () => {
$accounts[e.pubkey] = {...values, muffle: e.tags}
},
})
})
return $accounts
@ -58,108 +82,200 @@ export const ensureAccounts = async (pubkeys, {force = false} = {}) => {
}
// Keep our user in sync
user.update($user => ({...$user, ...get(accounts)[$user.pubkey]}))
user.update($user => $user ? {...$user, ...get(accounts)[$user.pubkey]} : null)
}
export const findNotes = (filters, cb) => {
const start = () => {
const notes = writable([])
const reactions = writable([])
export const getFollow = pubkey => {
const $user = get(user)
let pubkeys = []
return $user && find(t => t[1] === pubkey, $user.petnames)
}
const refreshAccounts = debounce(300, () => {
ensureAccounts(uniq(pubkeys))
export const getMuffleValue = pubkey => {
const $user = get(user)
pubkeys = []
if (!$user) {
return 1
}
const tag = find(t => t[1] === pubkey, $user.muffle)
if (!tag) {
return 1
}
return parseFloat(last(tag))
}
// Notes
export const annotateNotes = async (chunk, {showParents = false} = {}) => {
const parentIds = chunk.map(findReplyTo).filter(identity)
if (showParents && parentIds.length) {
// Find parents of replies to provide context
const parents = await channels.getter.all({
kinds: [1],
ids: parentIds,
})
const closeRequest = channels.watcher.sub({
filter: ensurePlural(filters).map(q => ({kinds: [1, 5, 7], ...q})),
cb: e => {
// Chunk requests to load accounts
pubkeys.push(e.pubkey)
refreshAccounts()
// Remove replies, show parents instead
chunk = parents
.concat(chunk.filter(e => !find(whereEq({id: findReplyTo(e)}), parents)))
}
switcherFn(e.kind, {
1: () => {
notes.update($xs => uniqBy(prop('id'), $xs.concat(e)))
chunk = uniqBy(prop('id'), chunk)
if (chunk.length === 0) {
return chunk
}
const replies = await channels.getter.all({
kinds: [1],
'#e': pluck('id', chunk),
})
const reactions = await channels.getter.all({
kinds: [7],
'#e': pluck('id', chunk.concat(replies)),
})
const repliesById = groupBy(findReplyTo, replies)
const reactionsById = groupBy(findReplyTo, reactions)
await ensureAccounts(uniq(pluck('pubkey', chunk.concat(replies).concat(reactions))))
const $accounts = get(accounts)
const annotate = e => ({
...e,
user: $accounts[e.pubkey],
replies: uniqBy(prop('id'), (repliesById[e.id] || []).map(reply => annotate(reply))),
reactions: uniqBy(prop('id'), (reactionsById[e.id] || []).map(reaction => annotate(reaction))),
})
return reverse(sortBy(prop('created'), chunk.map(annotate)))
}
export const notesListener = (notes, filter) => {
const updateNote = (id, f) =>
notes.update($notes =>
$notes
.map(n => {
if (n.id === id) {
return f(n)
}
return {...n, replies: n.replies.map(when(whereEq({id}), f))}
})
)
const deleteNotes = ($notes, ids) =>
$notes
.filter(e => !ids.includes(e.id))
.map(n => ({
...n,
replies: deleteNotes(n.replies, ids),
reactions: n.reactions.filter(e => !ids.includes(e.id)),
}))
return new Listener(
ensurePlural(filter).map(assoc('since', now())),
e => switcherFn(e.kind, {
1: async () => {
const id = findReplyTo(e)
if (id) {
const [reply] = await annotateNotes([e])
updateNote(id, n => ({...n, replies: n.replies.concat(reply)}))
} else if (filterMatches(filter, e)) {
const [note] = await annotateNotes([e])
notes.update($notes => uniqBy(prop('id'), [note].concat($notes)))
}
},
5: () => {
const ids = e.tags.map(t => t[1])
notes.update($xs => $xs.filter(({id}) => !id.includes(ids)))
reactions.update($xs => $xs.filter(({id}) => !id.includes(ids)))
notes.update($notes => deleteNotes($notes, ids))
},
7: () => {
reactions.update($xs => $xs.concat(e))
},
})
},
})
const id = findReplyTo(e)
const annotatedNotes = derived(
[notes, reactions, accounts],
([$notes, $reactions, $accounts]) => {
const repliesById = groupBy(
n => find(t => last(t) === 'reply', n.tags)[1],
$notes.filter(n => n.tags.map(last).includes('reply'))
)
const reactionsById = groupBy(
n => find(t => last(t) === 'reply', n.tags)[1],
$reactions.filter(n => n.tags.map(last).includes('reply'))
)
const annotate = n => ({
...n,
user: $accounts[n.pubkey],
replies: (repliesById[n.id] || []).map(reply => annotate(reply)),
reactions: (reactionsById[n.id] || []).map(reaction => annotate(reaction)),
})
return sortBy(prop('created'), $notes.map(annotate))
}
)
const unsubscribe = annotatedNotes.subscribe(debounce(100, cb))
return () => {
unsubscribe()
closeRequest()
}
}
// Allow caller to suspend/restart the subscription
return start
}
export const findNotesAndWatchModal = (filters, cb) => {
const start = findNotes(filters, cb)
let stop = start()
// Suspend our subscription while we have note detail open
// so we can avoid exceeding our concurrent subscription limit
const unsub = modal.subscribe($modal => {
if ($modal) {
stop && stop()
stop = null
} else if (!stop) {
// Wait for animations to complete
setTimeout(
() => {
stop = start()
},
600
)
updateNote(id, n => ({...n, reactions: n.reactions.concat(e)}))
}
})
return () => {
stop && stop()
unsub()
}
)
}
// UI
export const createScroller = (
cursor,
onChunk,
{since = epoch, reverse = false} = {}
) => {
const startingDelta = cursor.delta
let active = false
const start = debounce(1000, async () => {
if (active) {
return
}
active = true
/* eslint no-constant-condition: 0 */
while (true) {
// While we have empty space, fill it
const {scrollY, innerHeight} = window
const {scrollHeight} = document.body
if (
(reverse && scrollY > innerHeight * 3)
|| (!reverse && scrollY + innerHeight * 3 < scrollHeight)
) {
break
}
// Stop if we've gone back far enough
if (cursor.since <= since) {
break
}
// Get our chunk
const chunk = await cursor.chunk()
// Notify the caller
if (chunk.length > 0) {
await onChunk(chunk)
}
// If we have an empty chunk, increase our step size so we can get back to where
// we might have old events. Once we get a chunk, knock it down to the default again
if (chunk.length === 0) {
cursor.delta = Math.min(timedelta(30, 'days'), cursor.delta * 2)
} else {
cursor.delta = startingDelta
}
if (!active) {
break
}
// Wait a moment before proceeding to the next chunk for the caller
// to load results into the dom
await sleep(300)
}
active = false
})
return {
start,
stop: () => { active = false },
isActive: () => Boolean(cursor.sub),
}
}

View File

@ -1,4 +1,5 @@
import {identity, last, without} from 'ramda'
import {identity, isNil, uniqBy, last, without} from 'ramda'
import {getPublicKey} from 'nostr-tools'
import {get} from 'svelte/store'
import {first, defmulti} from "hurdak/lib/hurdak"
import {user} from "src/state/user"
@ -14,7 +15,13 @@ export const dispatch = defmulti("dispatch", identity)
dispatch.addMethod("account/init", async (topic, { privkey, pubkey }) => {
// Set what we know about the user to our store
user.set({name: pubkey.slice(0, 8), privkey, pubkey})
user.set({
name: pubkey.slice(0, 8),
privkey,
pubkey,
petnames: [],
muffle: [],
})
// Make sure we have data for this user
await ensureAccounts([pubkey], {force: true})
@ -31,6 +38,26 @@ dispatch.addMethod("account/update", async (topic, updates) => {
await nostr.publish(nostr.event(0, JSON.stringify(updates)))
})
dispatch.addMethod("account/petnames", async (topic, petnames) => {
const $user = get(user)
// Update our local copy
user.set({...$user, petnames})
// Tell the network
await nostr.publish(nostr.event(3, '', petnames))
})
dispatch.addMethod("account/muffle", async (topic, muffle) => {
const $user = get(user)
// Update our local copy
user.set({...$user, muffle})
// Tell the network
await nostr.publish(nostr.event(12165, '', muffle))
})
dispatch.addMethod("relay/join", async (topic, url) => {
const $user = get(user)
@ -78,7 +105,7 @@ dispatch.addMethod("note/create", async (topic, content, tags=[]) => {
})
dispatch.addMethod("reaction/create", async (topic, content, e) => {
const tags = copyTags(e).concat([t("p", e.pubkey), t("e", e.id, 'reply')])
const tags = copyTags(e, [t("p", e.pubkey), t("e", e.id, 'reply')])
const event = nostr.event(7, content, tags)
await nostr.publish(event)
@ -87,7 +114,7 @@ dispatch.addMethod("reaction/create", async (topic, content, e) => {
})
dispatch.addMethod("reply/create", async (topic, content, e) => {
const tags = copyTags(e).concat([t("p", e.pubkey), t("e", e.id, 'reply')])
const tags = copyTags(e, [t("p", e.pubkey), t("e", e.id, 'reply')])
const event = nostr.event(1, content, tags)
await nostr.publish(event)
@ -105,15 +132,18 @@ dispatch.addMethod("event/delete", async (topic, ids) => {
// utils
export const copyTags = e => {
export const copyTags = (e, newTags = []) => {
// Remove reply type from e tags
return e.tags.map(t => last(t) === 'reply' ? t.slice(0, -1) : t)
return uniqBy(
t => t.join(':'),
e.tags.map(t => last(t) === 'reply' ? t.slice(0, -1) : t).concat(newTags)
)
}
export const t = (type, content, marker) => {
const tag = [type, content, first(get(relays))]
if (marker) {
if (!isNil(marker)) {
tag.push(marker)
}

View File

@ -1,80 +1,215 @@
import {writable} from 'svelte/store'
import {debounce} from 'throttle-debounce'
import {writable, get} from 'svelte/store'
import {relayPool, getPublicKey} from 'nostr-tools'
import {last, uniqBy, prop} from 'ramda'
import {first} from 'hurdak/lib/hurdak'
import {getLocalJson, setLocalJson} from "src/util/misc"
import {last, find, intersection, uniqBy, prop} from 'ramda'
import {first, noop, ensurePlural} from 'hurdak/lib/hurdak'
import {getLocalJson, setLocalJson, now, timedelta} from "src/util/misc"
export const nostr = relayPool()
// Track who is subscribing, so we don't go over our limit
export const epoch = 1633046400
const channel = name => {
let active = false
let promise = Promise.resolve('init')
const _chan = {
sub: params => {
if (active) {
console.error(`Channel ${name} is already active.`)
export const filterTags = (where, events) =>
ensurePlural(events)
.flatMap(
e => e.tags.filter(t => {
if (where.tag && where.tag !== t[0]) {
return false
}
active = true
const sub = nostr.sub(params)
return () => {
active = false
sub.unsub()
if (where.type && where.type !== last(t)) {
return false
}
return true
}).map(t => t[1])
)
export const findTag = (where, events) => first(filterTags(where, events))
// Support the deprecated version where tags are marked as replies
export const findReplyTo = e =>
findTag({tag: "e", type: "reply"}, e) || findTag({tag: "e"}, e)
export const filterMatches = (filter, e) => {
return Boolean(find(
f => {
return (
(!f.ids || f.ids.includes(e.id))
&& (!f.authors || f.authors.includes(e.pubkey))
&& (!f.kinds || f.kinds.includes(e.kind))
&& (!f['#e'] || intersection(f['#e'], e.tags.filter(t => t[0] === 'e').map(t => t[1])))
&& (!f['#p'] || intersection(f['#p'], e.tags.filter(t => t[0] === 'p').map(t => t[1])))
&& (!f.since || f.since >= e.created_at)
&& (!f.until || f.until <= e.created_at)
)
},
all: filter => {
// Wait for any other subscriptions to finish
promise = promise.then(() => {
return new Promise(resolve => {
// Collect results
let result = []
ensurePlural(filter)
))
}
// As long as events are coming in, don't resolve. When
// events are no longer streaming, resolve and close the subscription
const done = debounce(300, () => {
unsub()
export class Channel {
constructor(name) {
this.name = name
this.p = Promise.resolve()
}
async sub(filter, cb, onEose = noop) {
// Make sure callers have to wait for the previous sub to be done
// before they can get a new one.
await this.p
// If we don't have any relays, we'll wait forever for an eose, but
// we already know we're done. Use a timeout since callers are
// expecting this to be async and we run into errors otherwise.
if (get(relays).length === 0) {
setTimeout(onEose)
return {unsub: noop}
}
let resolve
const eoseRelays = []
const sub = nostr.sub({filter, cb}, this.name, r => {
eoseRelays.push(r)
if (eoseRelays.length === get(relays).length) {
onEose()
}
})
this.p = new Promise(r => {
resolve = r
})
return {
unsub: () => {
sub.unsub()
resolve()
}
}
}
all(filter) {
/* eslint no-async-promise-executor: 0 */
return new Promise(async resolve => {
const result = []
const sub = await this.sub(
filter,
e => result.push(e),
r => {
sub.unsub()
resolve(result)
})
// Create our usbscription, every time we get an event, attempt to complete
const unsub = _chan.sub({
filter,
cb: e => {
result.push(e)
done()
},
)
})
// If our filter doesn't match anything, be sure to resolve the promise
setTimeout(done, 1000)
})
})
return promise
},
first: async filter => {
return first(await channels.getter.all({...filter, limit: 1}))
},
last: async filter => {
return last(await channels.getter.all({...filter}))
},
}
return _chan
}
export const channels = {
watcher: channel('main'),
getter: channel('getter'),
listener: new Channel('listener'),
getter: new Channel('getter'),
}
// We want to get old events, then listen for new events, then potentially retrieve
// older events again for pagination. Since we have to limit channels to 3 per nip 01,
// this requires us to unsubscribe and re-subscribe frequently
export class Cursor {
constructor(filter, delta) {
this.filter = ensurePlural(filter)
this.delta = delta || timedelta(1, 'hours')
this.since = now() - this.delta
this.until = now()
this.sub = null
this.q = []
this.p = Promise.resolve()
this.seen = new Set()
}
async start() {
if (!this.sub) {
this.sub = await channels.getter.sub(
this.filter.map(f => ({...f, since: this.since, until: this.until})),
e => this.onEvent(e),
r => this.onEose(r)
)
}
}
stop() {
if (this.sub) {
this.sub.unsub()
this.sub = null
}
}
async restart() {
this.stop()
await this.start()
}
async step() {
this.since -= this.delta
await this.restart()
}
onEvent(e) {
// Save a little memory
const shortId = e.id.slice(-10)
if (!this.seen.has(shortId)) {
this.seen.add(shortId)
this.q.push(e)
}
this.until = Math.min(this.until, e.created_at - 1)
}
onEose() {
this.stop()
}
async chunk() {
await this.step()
/* eslint no-constant-condition: 0 */
while (true) {
await new Promise(requestAnimationFrame)
if (!this.sub) {
return this.q.splice(0)
}
}
}
}
export class Listener {
constructor(filter, onEvent) {
this.filter = ensurePlural(filter)
this.onEvent = onEvent
this.since = now()
this.sub = null
this.q = []
this.p = Promise.resolve()
}
async start() {
const {filter, since} = this
if (!this.sub) {
this.sub = await channels.listener.sub(
filter.map(f => ({since, ...f})),
e => {
this.since = e.created_at
this.onEvent(e)
}
)
}
}
stop() {
if (this.sub) {
this.sub.unsub()
this.sub = null
}
}
restart() {
this.stop()
this.start()
}
}
// Augment nostr with some extra methods

View File

@ -13,5 +13,10 @@ user.subscribe($user => {
} else if ($user?.pubkey) {
nostr.pubkeyLogin($user.pubkey)
}
// Migrate data from old formats
if (!$user.petnames || !$user.muffle) {
user.set({...$user, petnames: [], muffle: []})
}
})

View File

@ -1,3 +1,5 @@
import {first} from 'hurdak/lib/hurdak'
export const copyToClipboard = text => {
const {activeElement} = document
const input = document.createElement("textarea")
@ -69,6 +71,8 @@ export const escapeHtml = html => {
return div.innerHTML
}
export const findLink = t => first(t.match(/https?:\/\/([\w.-]+)[^ ]*/))
export const toHtml = content => {
return escapeHtml(content)
.replace(/\n/g, '<br />')

View File

@ -47,3 +47,6 @@ export const formatTimestamp = ts => {
return formatter.format(new Date(ts * 1000))
}
export const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))

View File

@ -14,6 +14,7 @@ module.exports = {
light: "#CCC5B9",
medium: "#403D39",
dark: "#252422",
danger: "#ff0000",
},
},
plugins: [],

View File

@ -4,6 +4,9 @@ import { svelte } from '@sveltejs/vite-plugin-svelte'
import { nodePolyfills } from 'vite-plugin-node-polyfills'
export default defineConfig({
build: {
sourcemap: true,
},
resolve: {
alias: {
src: path.resolve(__dirname, 'src'),