Add topic support

This commit is contained in:
Jonathan Staab 2023-01-16 13:55:03 -08:00
parent 383fb6e85d
commit ee66d19822
11 changed files with 177 additions and 87 deletions

View File

@ -77,20 +77,20 @@ If you like Coracle and want to support its development, you can donate sats via
- [x] Still use "my" relays for global, this could make global feed more useful - [x] Still use "my" relays for global, this could make global feed more useful
- [x] If we use my relays for global, we don't have to wait for network to load initially - [x] If we use my relays for global, we don't have to wait for network to load initially
- [x] Figure out fast vs complete tradeoff. Skipping loadContext speeds things up a ton. - [x] Figure out fast vs complete tradeoff. Skipping loadContext speeds things up a ton.
- [ ] Add relays/mentions to note and reply composition - [x] Add relays/mentions to note and reply composition
- [ ] Figure out migrations from previous version - [ ] Figure out migrations from previous version
- [ ] Fix search - [ ] Fix search
- [ ] Move add note to modal
## 0.2.7 ## 0.2.7
- [x] Added error tracking - you can turn this off in settings
- [x] Add support for profile banner images - [x] Add support for profile banner images
- [x] Re-designed relays page - [x] Re-designed relays page
- [x] Support connection status/speed indication - [x] Support connection status/speed indication
- [x] Add toggle to enable writing to a connected relay - [x] Add toggle to enable writing to a connected relay
- [x] Re-designed login page - [x] Re-designed login page
- [x] Use private key login only if extension is not enabled - [x] Use private key login only if extension is not enabled
- [x] Add pubkey login - [x] Add pubkey login support
## 0.2.6 ## 0.2.6

View File

@ -9,7 +9,6 @@
import {cubicInOut} from "svelte/easing" import {cubicInOut} from "svelte/easing"
import {Router, Route, links, navigate} from "svelte-routing" import {Router, Route, links, navigate} from "svelte-routing"
import {globalHistory} from "svelte-routing/src/history" import {globalHistory} from "svelte-routing/src/history"
import {hasParent} from 'src/util/html'
import {displayPerson, isLike} from 'src/util/nostr' import {displayPerson, isLike} from 'src/util/nostr'
import {timedelta, now} from 'src/util/misc' import {timedelta, now} from 'src/util/misc'
import {keys, user, pool, getRelays} from 'src/agent' import {keys, user, pool, getRelays} from 'src/agent'

View File

@ -1,4 +1,4 @@
import {last, objOf, uniq} from 'ramda' import {last, uniqBy, prop, objOf, uniq} from 'ramda'
import {derived, get} from 'svelte/store' import {derived, get} from 'svelte/store'
import {Tags} from 'src/util/nostr' import {Tags} from 'src/util/nostr'
import pool from 'src/agent/pool' import pool from 'src/agent/pool'
@ -52,11 +52,12 @@ export const getRelays = pubkey => {
} }
export const getEventRelays = event => { export const getEventRelays = event => {
return uniq( return uniqBy(
prop('url'),
getRelays(event.pubkey) getRelays(event.pubkey)
.concat(Tags.from(event).relays()) .concat(Tags.from(event).relays())
.concat(event.seen_on) .concat({url: event.seen_on})
).map(objOf('url')) )
} }
export const publish = async (relays, event) => { export const publish = async (relays, event) => {

View File

@ -1,7 +1,7 @@
import {prop, join, uniqBy, last} from 'ramda' import {prop, join, uniqBy, last} from 'ramda'
import {get} from 'svelte/store' import {get} from 'svelte/store'
import {first} from "hurdak/lib/hurdak" import {first} from "hurdak/lib/hurdak"
import {Tags} from 'src/util/nostr' import {Tags, isRelay} from 'src/util/nostr'
import {keys, publish, getRelays} from 'src/agent' import {keys, publish, getRelays} from 'src/agent'
const updateUser = (relays, updates) => const updateUser = (relays, updates) =>
@ -25,11 +25,15 @@ const updateRoom = (relays, {id, ...room}) =>
const createMessage = (relays, roomId, content) => const createMessage = (relays, roomId, content) =>
publishEvent(relays, 42, {content, tags: [["e", roomId, first(relays), "root"]]}) publishEvent(relays, 42, {content, tags: [["e", roomId, first(relays), "root"]]})
const createNote = (relays, content, mentions = []) => const createNote = (relays, content, mentions = [], topics = []) => {
publishEvent(relays, 1, {content, tags: mentions.map(p => ["p", p, first(getRelays(p))])}) mentions = mentions.map(p => ["p", p, prop('url', first(getRelays(p)))])
topics = topics.map(t => ["t", t])
publishEvent(relays, 1, {content, tags: mentions.concat(topics)})
}
const createReaction = (relays, note, content) => { const createReaction = (relays, note, content) => {
const {url} = getBestRelay(note) const {url} = getBestRelay(relays, note)
const tags = uniqBy( const tags = uniqBy(
join(':'), join(':'),
note.tags note.tags
@ -41,17 +45,22 @@ const createReaction = (relays, note, content) => {
return publishEvent(relays, 7, {content, tags}) return publishEvent(relays, 7, {content, tags})
} }
const createReply = (relays, note, content, mentions = []) => { const createReply = (relays, note, content, mentions = [], topics = []) => {
const {url} = getBestRelay(note) mentions = mentions.map(p => ["p", p, prop('url', first(getRelays(p)))])
topics = topics.map(t => ["t", t])
const {url} = getBestRelay(relays, note)
const tags = uniqBy( const tags = uniqBy(
join(':'), join(':'),
note.tags note.tags
.filter(t => ["p", "e"].includes(t[0])) .filter(t => ["e"].includes(t[0]))
.map(t => last(t) === 'reply' ? t.slice(0, -1) : t) .map(t => last(t) === 'reply' ? t.slice(0, -1) : t)
.concat([["p", note.pubkey, url], ["e", note.id, url, 'reply']]) .concat([["p", note.pubkey, url], ["e", note.id, url, 'reply']])
.concat(mentions.map(p => ["p", p, prop('url', first(getRelays(p)))])) .concat(mentions.concat(topics))
) )
console.log(relays)
return publishEvent(relays, 1, {content, tags}) return publishEvent(relays, 1, {content, tags})
} }
@ -60,21 +69,22 @@ const deleteEvent = (relays, ids) =>
// Utils // Utils
const getBestRelay = event => { const getBestRelay = (relays, event) => {
// Find the best relay, based on reply, root, or pubkey // Find the best relay, based on reply, root, or pubkey. Fall back to a
const reply = Tags.from(event).type("e").mark("reply").first() // relay we're going to send the event to
const tags = Tags.from(event).type("e")
const reply = tags.mark("reply").values().first()
const root = tags.mark("root").values().first()
if (reply && reply[2].startsWith('ws')) { if (isRelay(reply)) {
return reply[2] return reply
} }
const root = Tags.from(event).type("e").mark("root").first() if (isRelay(root)) {
return root
if (root && root[2].startsWith('ws')) {
return root[2]
} }
return first(getRelays(event.pubkey)) return first(getRelays(event.pubkey).concat(relays))
} }
const publishEvent = (relays, kind, {content = '', tags = []} = {}) => { const publishEvent = (relays, kind, {content = '', tags = []} = {}) => {

View File

@ -1,5 +1,6 @@
<script> <script>
import {prop, reject, sortBy, last} from 'ramda' import {prop, reject, sortBy, last} from 'ramda'
import {fly} from 'svelte/transition'
import {fuzzy} from "src/util/misc" import {fuzzy} from "src/util/misc"
import {fromParentOffset} from "src/util/html" import {fromParentOffset} from "src/util/html"
import Badge from "src/partials/Badge.svelte" import Badge from "src/partials/Badge.svelte"
@ -34,12 +35,11 @@
return last(getText().split(/[\s\u200B]+/)) return last(getText().split(/[\s\u200B]+/))
} }
const pickSuggestion = ({name, pubkey}) => { const highlightWord = (prefix, chars, content) => {
const text = getText() const text = getText()
const word = getWord()
const selection = document.getSelection() const selection = document.getSelection()
const {focusNode, focusOffset} = selection const {focusNode, focusOffset} = selection
const at = document.createTextNode("@") const prefixElement = document.createTextNode(prefix)
const span = document.createElement('span') const span = document.createElement('span')
// Space includes a zero-width space to avoid having the cursor end up inside // Space includes a zero-width space to avoid having the cursor end up inside
@ -47,19 +47,23 @@
const space = document.createTextNode("\u200B\u00a0") const space = document.createTextNode("\u200B\u00a0")
span.classList.add('underline') span.classList.add('underline')
span.innerText = name span.innerText = content
// Remove our partial mention text // Remove our partial mention text
selection.setBaseAndExtent(...fromParentOffset(input, text.length - word.length), focusNode, focusOffset) selection.setBaseAndExtent(...fromParentOffset(input, text.length - chars), focusNode, focusOffset)
selection.deleteFromDocument() selection.deleteFromDocument()
// Add the at sign, decorated mention text, and a trailing space // Add the prefix, decorated text, and a trailing space
selection.getRangeAt(0).insertNode(at) selection.getRangeAt(0).insertNode(prefixElement)
selection.collapse(at, 1) selection.collapse(prefixElement, 1)
selection.getRangeAt(0).insertNode(span) selection.getRangeAt(0).insertNode(span)
selection.collapse(span.nextSibling, 0) selection.collapse(span.nextSibling, 0)
selection.getRangeAt(0).insertNode(space) selection.getRangeAt(0).insertNode(space)
selection.collapse(space, 2) selection.collapse(space, 2)
}
const pickSuggestion = ({name, pubkey}) => {
highlightWord('@', getWord().length, name)
mentions.push({ mentions.push({
name, name,
@ -77,6 +81,12 @@
return onSubmit() return onSubmit()
} }
if (e.key === 'Escape' && suggestions[index]) {
index = 0
suggestions = []
e.stopPropagation()
}
if (['Enter', 'Tab', 'ArrowUp', 'ArrowDown', ' '].includes(e.key) && suggestions[index]) { if (['Enter', 'Tab', 'ArrowUp', 'ArrowDown', ' '].includes(e.key) && suggestions[index]) {
e.preventDefault() e.preventDefault()
} }
@ -118,10 +128,18 @@
} }
} }
} }
}
if (input.innerText.length > prevContent.length) {
const topic = getText().match(/#([-\w]+\s)$/)
if (topic) {
highlightWord('#', topic[0].length, topic[1].trim())
}
}
prevContent = input.innerText prevContent = input.innerText
} }
}
export const parse = () => { export const parse = () => {
// Interpolate mentions // Interpolate mentions
@ -140,7 +158,11 @@
// Remove our zero-length spaces // Remove our zero-length spaces
content = content.replace(/\u200B/g, '') content = content.replace(/\u200B/g, '')
return {content, mentions: validMentions.map(prop('pubkey'))} return {
content,
topics: content.match(/#[-\w]+/g) || [],
mentions: validMentions.map(prop('pubkey')),
}
} }
</script> </script>
@ -154,12 +176,17 @@
on:keyup={onKeyUp} /> on:keyup={onKeyUp} />
<slot name="addon" /> <slot name="addon" />
</div> </div>
{#each suggestions as person, i (person.pubkey)}
<div {#if suggestions.length > 0}
<div class="rounded border border-solid border-medium mt-2" in:fly={{y: 20}}>
{#each suggestions as person, i (person.pubkey)}
<div
class="py-2 px-4 cursor-pointer" class="py-2 px-4 cursor-pointer"
class:bg-black={index !== i} class:bg-black={index !== i}
class:bg-dark={index === i} class:bg-dark={index === i}
on:click={() => pickSuggestion(person)}> on:click={() => pickSuggestion(person)}>
<Badge inert {person} /> <Badge inert {person} />
</div>
{/each}
</div> </div>
{/each} {/if}

View File

@ -16,7 +16,7 @@
class="absolute inset-0 opacity-75 bg-black cursor-pointer" class="absolute inset-0 opacity-75 bg-black cursor-pointer"
transition:fade transition:fade
on:click={onEscape} /> on:click={onEscape} />
<div class="absolute inset-0 mt-20 sm:mt-32 modal-content" transition:fly={{y: 1000, opacity: 1}}> <div class="absolute inset-0 mt-20 sm:mt-28 modal-content" transition:fly={{y: 1000, opacity: 1}}>
<dialog open class="bg-dark border-t border-solid border-medium h-full w-full overflow-auto"> <dialog open class="bg-dark border-t border-solid border-medium h-full w-full overflow-auto">
<slot /> <slot />
</dialog> </dialog>

View File

@ -2,12 +2,11 @@
import cx from 'classnames' import cx from 'classnames'
import extractUrls from 'extract-urls' import extractUrls from 'extract-urls'
import {nip19} from 'nostr-tools' import {nip19} from 'nostr-tools'
import {whereEq, pluck, reject, propEq, find} from 'ramda' import {whereEq, uniq, pluck, reject, propEq, find} from 'ramda'
import {slide} from 'svelte/transition' import {slide} from 'svelte/transition'
import {navigate} from 'svelte-routing' import {navigate} from 'svelte-routing'
import {quantify} from 'hurdak/lib/hurdak' import {quantify} from 'hurdak/lib/hurdak'
import {hasParent} from 'src/util/html' import {Tags, findReply, findReplyId, displayPerson, isLike} from "src/util/nostr"
import {findReply, findReplyId, isLike} from "src/util/nostr"
import Preview from 'src/partials/Preview.svelte' import Preview from 'src/partials/Preview.svelte'
import Anchor from 'src/partials/Anchor.svelte' import Anchor from 'src/partials/Anchor.svelte'
import {settings, modal, render} from "src/app" import {settings, modal, render} from "src/app"
@ -15,7 +14,7 @@
import Badge from "src/partials/Badge.svelte" import Badge from "src/partials/Badge.svelte"
import Compose from "src/partials/Compose.svelte" import Compose from "src/partials/Compose.svelte"
import Card from "src/partials/Card.svelte" import Card from "src/partials/Card.svelte"
import {user, people, getRelays, getEventRelays} from 'src/agent' import {user, people, getPerson, getRelays, getEventRelays} from 'src/agent'
import cmd from 'src/app/cmd' import cmd from 'src/app/cmd'
export let note export let note
@ -25,6 +24,7 @@
export let invertColors = false export let invertColors = false
let reply = null let reply = null
let replyMentions = Tags.from(note).type("p").values().all()
const links = $settings.showLinkPreviews ? extractUrls(note.content) || [] : null const links = $settings.showLinkPreviews ? extractUrls(note.content) || [] : null
const showEntire = anchorId === note.id const showEntire = anchorId === note.id
@ -44,7 +44,7 @@
$: flag = find(whereEq({pubkey: $user?.pubkey}), flags) $: flag = find(whereEq({pubkey: $user?.pubkey}), flags)
const onClick = e => { const onClick = e => {
if (!['I'].includes(e.target.tagName) && !hasParent('a', e.target)) { if (!['I'].includes(e.target.tagName) && !e.target.closest('a')) {
modal.set({note, relays}) modal.set({note, relays})
} }
} }
@ -92,26 +92,36 @@
} }
} }
const removeMention = pubkey => {
replyMentions = reject(p => p === pubkey, replyMentions)
}
const resetReply = () => {
reply = null
replyMentions = Tags.from(note).type("p").values().all()
}
const sendReply = () => { const sendReply = () => {
const {content, mentions} = reply.parse() let {content, mentions, topics} = reply.parse()
if (content) { if (content) {
cmd.createReply(getEventRelays(note), note, content, mentions) mentions = uniq(mentions.concat(replyMentions))
cmd.createReply(getEventRelays(note), note, content, mentions, topics)
reply = null resetReply()
} }
} }
</script> </script>
<svelte:body <svelte:body
on:click={e => { on:click={e => {
if (!hasParent('.fa-reply', e.target) && !hasParent('.note-reply', e.target)) { if (!e.target.closest('.fa-reply') && !e.target.closest('.note-reply')) {
reply = null resetReply()
} }
}} }}
on:keydown={e => { on:keydown={e => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
reply = null resetReply()
} }
}} }}
/> />
@ -171,9 +181,8 @@
</Card> </Card>
{#if reply} {#if reply}
<div <div transition:slide class="note-reply">
class="note-reply bg-medium border-medium border border-solid" <div class="bg-medium border-medium border border-solid">
transition:slide>
<Compose bind:this={reply} onSubmit={sendReply}> <Compose bind:this={reply} onSubmit={sendReply}>
<div <div
slot="addon" slot="addon"
@ -183,6 +192,18 @@
<i class="fa-solid fa-paper-plane fa-xl" /> <i class="fa-solid fa-paper-plane fa-xl" />
</div> </div>
</Compose> </Compose>
</div>
{#if replyMentions.length > 0}
<div class="text-white text-sm p-2 rounded-b border-t-0 border border-solid border-medium">
{#each replyMentions as p}
<div class="inline-block py-1 px-2 mr-1 mb-2 rounded-full border border-solid border-light">
<i class="fa fa-times cursor-pointer" on:click|stopPropagation={() => removeMention(p)} />
{displayPerson(getPerson(p, true))}
</div>
{/each}
<div class="-mt-2" />
</div>
{/if}
</div> </div>
{/if} {/if}

View File

@ -26,7 +26,7 @@
return toast.show("error", "That isn't a valid websocket url") return toast.show("error", "That isn't a valid websocket url")
} }
addRelay({url, write: '!'}) addRelay({url})
modal.set(null) modal.set(null)
} }
</script> </script>

View File

@ -37,7 +37,7 @@
$: search = fuzzy($knownRelays, {keys: ["name", "description", "url"]}) $: search = fuzzy($knownRelays, {keys: ["name", "description", "url"]})
const join = async url => { const join = async url => {
await addRelay({url, write: "!"}) await addRelay({url})
} }
const leave = async url => { const leave = async url => {

View File

@ -48,22 +48,6 @@ export const stripExifData = async file => {
}) })
} }
export const hasParent = (tagOrClass, $el) => {
while ($el) {
if (tagOrClass.startsWith('.')) {
if ($el.classList?.contains(tagOrClass.slice(1))) {
return true
}
} else if ($el.tagName === tagOrClass.toUpperCase()) {
return true
}
$el = $el.parentNode
}
return false
}
export const escapeHtml = html => { export const escapeHtml = html => {
const div = document.createElement("div") const div = document.createElement("div")

View File

@ -0,0 +1,48 @@
<script>
import {onMount} from "svelte"
import {fly} from 'svelte/transition'
import {navigate} from "svelte-routing"
import Button from "src/partials/Button.svelte"
import Compose from "src/partials/Compose.svelte"
import Content from "src/partials/Content.svelte"
import Heading from 'src/partials/Heading.svelte'
import {user, getRelays} from "src/agent"
import {toast} from "src/app"
import cmd from "src/app/cmd"
let input = null
const onSubmit = async e => {
const {content, mentions, topics} = input.parse()
if (content) {
await cmd.createNote(getRelays(), content, mentions, topics)
toast.show("info", `Your note has been created!`)
history.back()
}
}
onMount(() => {
if (!$user) {
navigate("/login")
}
})
</script>
<form on:submit|preventDefault={onSubmit} in:fly={{y: 20}}>
<Content size="lg">
<Heading class="text-center">Create a note</Heading>
<div class="flex flex-col gap-4 w-full">
<div class="flex flex-col gap-2">
<strong>What do you want to say?</strong>
<div class="border-l-2 border-solid border-medium pl-4">
<Compose bind:this={input} {onSubmit} />
</div>
</div>
<Button type="submit" class="text-center">Send</Button>
</div>
</Content>
</form>