Add mentions when composing a note or reply, tweak some feed timings

This commit is contained in:
Jonathan Staab 2022-12-30 15:52:29 -08:00
parent d0d3844ac2
commit 9dc679a944
18 changed files with 296 additions and 85 deletions

View File

@ -16,8 +16,7 @@ If you like Coracle and want to support its development, you can donate sats via
- [x] Notifications
- [x] Link previews
- [x] Add notes, follows, likes tab to profile
- [ ] Show relative dates
- [ ] Mentions - render done, now reference in compose
- [x] Mentions
- [ ] Image uploads
- [ ] An actual readme
- [ ] Server discovery and relay publishing - https://github.com/nostr-protocol/nips/pull/32/files
@ -38,6 +37,13 @@ If you like Coracle and want to support its development, you can donate sats via
# Changelog
## 0.2.6
- [x] Add support for at-mentions
- [x] Improve cleanup on logout
- [x] Move add note button to be available everywhere
- [x] Fix reporting relay along with tags
## 0.2.5
- [x] Batch load context for feeds

View File

@ -22,6 +22,7 @@
import Alerts from "src/routes/Alerts.svelte"
import Notes from "src/routes/Notes.svelte"
import Login from "src/routes/Login.svelte"
import Logout from "src/routes/Logout.svelte"
import Profile from "src/routes/Profile.svelte"
import Settings from "src/routes/Settings.svelte"
import Keys from "src/routes/Keys.svelte"
@ -45,26 +46,6 @@
let suspendedSubs = []
let mostRecentAlert = $alerts.since
const logout = async () => {
const $connections = get(connections)
const $settings = get(settings)
localStorage.clear()
// Keep relays around
await relay.db.events.clear()
await relay.db.tags.clear()
// Remember the user's relay selection and settings
connections.set($connections)
settings.set($settings)
// do a hard refresh so everything gets totally cleared
setTimeout(() => {
window.location = '/login'
}, 100)
}
onMount(() => {
// Close menu on click outside
document.querySelector("html").addEventListener("click", e => {
@ -148,6 +129,7 @@
<Route path="/profile" component={Profile} />
<Route path="/settings" component={Settings} />
<Route path="/login" component={Login} />
<Route path="/logout" component={Logout} />
<Route path="*" component={NotFound} />
</div>
@ -202,7 +184,7 @@
</a>
</li>
<li class="cursor-pointer">
<a class="block px-4 py-2 hover:bg-accent transition-all" on:click={logout}>
<a class="block px-4 py-2 hover:bg-accent transition-all" href="/logout">
<i class="fa-solid fa-right-from-bracket mr-2" /> Logout
</a>
</li>
@ -228,6 +210,17 @@
{/if}
</div>
{#if $user}
<div class="fixed bottom-0 right-0 m-8">
<a
href="/notes/new"
class="rounded-full bg-accent color-white w-16 h-16 flex justify-center
items-center border border-dark shadow-2xl cursor-pointer">
<span class="fa-sold fa-plus fa-2xl" />
</a>
</div>
{/if}
{#if $modal}
<div class="fixed inset-0 z-10">
<div

View File

@ -3,8 +3,17 @@
import {killEvent} from 'src/util/html'
export let person
export let inert = false
</script>
{#if inert}
<span class="flex gap-2 items-center relative z-10">
<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({person.picture})" />
<span class="text-lg font-bold">{person.name || person.pubkey.slice(0, 8)}</span>
</span>
{:else}
<Link
to={`/people/${person.pubkey}/notes`}
class="flex gap-2 items-center relative z-10"
@ -14,3 +23,4 @@
style="background-image: url({person.picture})" />
<span class="text-lg font-bold">{person.name || person.pubkey.slice(0, 8)}</span>
</Link>
{/if}

View File

@ -3,10 +3,12 @@
import {switcher} from "hurdak/lib/hurdak"
export let theme = "default"
export let disabled = false
const className = cx(
$$props.class,
"py-2 px-4 rounded cursor-pointer",
{'text-light': disabled},
switcher(theme, {
default: "bg-white text-accent",
accent: "text-white bg-accent",

164
src/partials/Compose.svelte Normal file
View File

@ -0,0 +1,164 @@
<script>
import {prop, reject, sortBy, last} from 'ramda'
import {fuzzy} from "src/util/misc"
import {fromParentOffset} from "src/util/html"
import Badge from "src/partials/Badge.svelte"
import {people} from "src/relay"
export let onSubmit
let index = 0
let mentions = []
let suggestions = []
let input = null
let content = ''
let search = fuzzy(
Object.values($people).filter(prop('name')),
{keys: ["name", "pubkey"]}
)
const getText = () => {
const selection = document.getSelection()
const range = selection.getRangeAt(0)
range.setStartBefore(input)
const text = range.cloneContents().textContent
range.collapse()
return text
}
const getWord = () => {
return last(getText().split(/[\s\u200B]+/))
}
const pickSuggestion = ({name, pubkey}) => {
const text = getText()
const word = getWord()
const selection = document.getSelection()
const {focusNode, focusOffset} = selection
const at = document.createTextNode("@")
const span = document.createElement('span')
// Space includes a zero-width space to avoid having the cursor end up inside
// mention span on backspace, and a space for convenience in composition.
const space = document.createTextNode("\u200B\u00a0")
span.classList.add('underline')
span.innerText = name
// Remove our partial mention text
selection.setBaseAndExtent(...fromParentOffset(input, text.length - word.length), focusNode, focusOffset)
selection.deleteFromDocument()
// Add the at sign, decorated mention text, and a trailing space
selection.getRangeAt(0).insertNode(at)
selection.collapse(at, 1)
selection.getRangeAt(0).insertNode(span)
selection.collapse(span.nextSibling, 0)
selection.getRangeAt(0).insertNode(space)
selection.collapse(space, 2)
mentions.push({
name,
pubkey,
length: name.length + 1,
end: getText().length - 2,
})
index = 0
suggestions = []
}
const onKeyDown = e => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
return onSubmit()
}
if (['Enter', 'Tab', 'ArrowUp', 'ArrowDown', ' '].includes(e.key) && suggestions[index]) {
e.preventDefault()
}
}
const onKeyUp = e => {
if (['Enter', 'Tab', ' '].includes(e.key) && suggestions[index]) {
pickSuggestion(suggestions[index])
}
if (e.key === 'ArrowUp' && suggestions[index - 1]) {
index -= 1
}
if (e.key === 'ArrowDown' && suggestions[index + 1]) {
index += 1
}
if (input.innerText !== content) {
const text = getText()
const word = getWord()
if (!text.match(/\s$/) && word.startsWith('@')) {
suggestions = search(word.slice(1)).slice(0, 3)
} else {
index = 0
suggestions = []
}
if (input.innerText.length < content.length) {
const delta = content.length - input.innerText.length
const text = getText()
for (const mention of mentions) {
if (mention.end - mention.length > text.length) {
mention.end -= delta
} else if (mention.end > text.length) {
mention.invalid = true
}
}
}
content = input.innerText
}
}
export const parse = () => {
// Interpolate mentions
let offset = 0
const validMentions = sortBy(prop('end'), reject(prop('invalid'), mentions))
for (const [i, {end, length}] of validMentions.entries()) {
const offsetEnd = end - offset
const start = offsetEnd - length
const tag = `#[${i}]`
content = content.slice(0, start) + tag + content.slice(offsetEnd)
offset += length - tag.length
}
// Remove our zero-length spaces
content = content.replace(/\u200B/g, '')
return {content, mentions: validMentions.map(prop('pubkey'))}
}
</script>
<div class="flex">
<div
class="text-white w-full outline-0 p-2"
autofocus
contenteditable
bind:this={input}
on:keydown={onKeyDown}
on:keyup={onKeyUp} />
<slot name="addon" />
</div>
{#each suggestions as person, i (person.pubkey)}
<div
class="py-2 px-4 cursor-pointer"
class:bg-black={index !== i}
class:bg-dark={index === i}
on:click={() => pickSuggestion(person)}>
<Badge inert {person} />
</div>
{/each}

View File

@ -12,6 +12,7 @@
import {settings, modal} from "src/state/app"
import {formatTimestamp} from 'src/util/misc'
import Badge from "src/partials/Badge.svelte"
import Compose from "src/partials/Compose.svelte"
import Card from "src/partials/Card.svelte"
import relay, {user} from 'src/relay'
@ -76,26 +77,21 @@
const startReply = () => {
if ($user) {
reply = reply || ''
reply = reply || true
} else {
navigate('/login')
}
}
const sendReply = () => {
if (reply) {
relay.cmd.createReply(reply, note)
const {content, mentions} = reply.parse()
if (content) {
relay.cmd.createReply(note, content, mentions)
reply = null
}
}
const onReplyKeyDown = e => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
sendReply()
}
}
</script>
<svelte:body
@ -160,24 +156,19 @@
</div>
</Card>
{#if reply !== null}
{#if reply}
<div
class="note-reply flex bg-medium border-medium border border-solid"
class="note-reply bg-medium border-medium border border-solid"
transition:slide>
<textarea
rows="4"
autofocus
placeholder="Type something..."
bind:value={reply}
on:keydown={onReplyKeyDown}
class="w-full p-2 text-white bg-medium
placeholder:text-light outline-0 resize-none" />
<div
on:click={sendReply}
class="flex flex-col py-8 p-4 justify-center gap-2 border-l border-solid border-dark
hover:bg-accent transition-all cursor-pointer text-white ">
<i class="fa-solid fa-paper-plane fa-xl" />
</div>
<Compose bind:this={reply} onSubmit={sendReply}>
<div
slot="addon"
on:click={sendReply}
class="flex flex-col py-8 p-4 justify-center gap-2 border-l border-solid border-dark
hover:bg-accent transition-all cursor-pointer text-white ">
<i class="fa-solid fa-paper-plane fa-xl" />
</div>
</Compose>
</div>
{/if}

View File

@ -3,7 +3,7 @@
import {onMount} from 'svelte'
import {slide} from 'svelte/transition'
import {quantify} from 'hurdak/lib/hurdak'
import {createScroller, now} from 'src/util/misc'
import {createScroller, sleep, now} from 'src/util/misc'
import {findReply} from 'src/util/nostr'
import Spinner from 'src/partials/Spinner.svelte'
import Note from "src/partials/Note.svelte"
@ -13,12 +13,12 @@
export let queryNotes
const notes = relay.lq(async () => {
const notes = await queryNotes()
const annotated = await relay.annotateChunk(notes)
// Hacky way to wait for the loader to adjust the cursor so we have a nonzero duration
await sleep(100)
return sortBy(
e => -pluck('created_at', e.replies).concat(e.created_at).reduce((a, b) => Math.max(a, b)),
annotated
await relay.annotateChunk(await queryNotes())
)
})
@ -40,7 +40,7 @@
<ul class="py-4 flex flex-col gap-2 max-w-xl m-auto">
{#if newNotesLength > 0}
<div
transition:slide
in:slide
class="mb-2 cursor-pointer text-center underline text-light"
on:click={() => { until = now() }}>
Load {quantify(newNotesLength, 'new note')}

View File

@ -2,6 +2,7 @@
import cx from "classnames"
export let value
export let element = null
const className = cx(
$$props.class,
@ -10,4 +11,6 @@
)
</script>
<textarea {...$$props} class={className} bind:value />
<svelte:options accessors />
<textarea {...$$props} class={className} bind:this={element} bind:value on:keydown on:keypress />

View File

@ -25,13 +25,18 @@ const updateRoom = ({id, ...room}) => publishEvent(41, JSON.stringify(room), [t(
const createMessage = (roomId, content) => publishEvent(42, content, [t("e", roomId, "root")])
const createNote = (content, tags=[]) => publishEvent(1, content, tags)
const createNote = (content, mentions = []) => publishEvent(1, content, mentions.map(p => t("p", p)))
const createReaction = (content, e) =>
publishEvent(7, content, copyTags(e, [t("p", e.pubkey), t("e", e.id, 'reply')]))
const createReply = (content, e) =>
publishEvent(1, content, copyTags(e, [t("p", e.pubkey), t("e", e.id, 'reply')]))
const createReply = (e, content, mentions = []) => {
const tags = mentions.map(p => t("p", p)).concat(
copyTags(e, [t("p", e.pubkey), t("e", e.id, 'reply')])
)
return publishEvent(1, content, tags)
}
const deleteEvent = ids => publishEvent(5, '', ids.map(id => t("e", id)))
@ -48,7 +53,7 @@ const copyTags = (e, newTags = []) => {
}
export const t = (type, content, marker) => {
const tag = [type, content, first(Object.keys(relay.pool.getRelays()))]
const tag = [type, content, first(relay.pool.getRelays())]
if (!isNil(marker)) {
tag.push(marker)

View File

@ -105,7 +105,9 @@ db.events.process = async events => {
}
// On initial load, delete old event data
const threshold = now() - timedelta(1, 'days')
setTimeout(() => {
const threshold = now() - timedelta(1, 'days')
db.events.where('loaded_at').below(threshold).delete()
db.tags.where('loaded_at').below(threshold).delete()
db.events.where('loaded_at').below(threshold).delete()
db.tags.where('loaded_at').below(threshold).delete()
}, timedelta(10, 'seconds'))

View File

@ -1,6 +1,6 @@
<script>
import {onMount} from 'svelte'
import {fade} from 'svelte/transition'
import {fade, fly} from 'svelte/transition'
import {navigate} from 'svelte-routing'
import {generatePrivateKey, getPublicKey} from 'nostr-tools'
import {copyToClipboard} from "src/util/html"
@ -59,7 +59,7 @@
}
</script>
<div class="flex justify-center p-12">
<div class="flex justify-center p-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">Welcome!</h1>

29
src/routes/Logout.svelte Normal file
View File

@ -0,0 +1,29 @@
<script>
import {get} from 'svelte/store'
import {fly} from 'svelte/transition'
import {settings} from "src/state/app"
import relay, {connections} from 'src/relay'
setTimeout(async () => {
const $connections = get(connections)
const $settings = get(settings)
// Clear localstorage
localStorage.clear()
// Keep relays around, but delete events/tags
await relay.db.events.clear()
await relay.db.tags.clear()
// Remember the user's relay selection and settings
connections.set($connections)
settings.set($settings)
// do a hard refresh so everything gets totally cleared
window.location = '/login'
}, 300)
</script>
<div class="max-w-xl m-auto text-center py-20" in:fly={{y:20}}>
Clearing your local database...
</div>

View File

@ -2,21 +2,23 @@
import {onMount} from "svelte"
import {fly} from 'svelte/transition'
import {navigate} from "svelte-routing"
import Textarea from "src/partials/Textarea.svelte"
import Button from "src/partials/Button.svelte"
import Compose from "src/partials/Compose.svelte"
import toast from "src/state/toast"
import relay, {user} from "src/relay"
let values = {}
let input = null
const submit = async e => {
e.preventDefault()
const onSubmit = async e => {
const {content, mentions} = input.parse()
await relay.cmd.createNote(values.content)
if (content) {
await relay.cmd.createNote(content, mentions)
toast.show("info", `Your note has been created!`)
toast.show("info", `Your note has been created!`)
history.back()
history.back()
}
}
onMount(() => {
@ -27,15 +29,17 @@
</script>
<div class="m-auto">
<form on:submit={submit} class="flex justify-center py-8 px-4" in:fly={{y: 20}}>
<form on:submit|preventDefault={onSubmit} class="flex justify-center py-8 px-4" in:fly={{y: 20}}>
<div class="flex flex-col gap-4 max-w-lg w-full">
<div class="flex justify-center items-center flex-col mb-4">
<h1 class="staatliches text-6xl">Create a note</h1>
</div>
<div class="flex flex-col gap-4 w-full">
<div class="flex flex-col gap-1">
<div class="flex flex-col gap-2">
<strong>What do you want to say?</strong>
<Textarea rows="8" name="content" bind:value={values.content} />
<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>

View File

@ -26,14 +26,6 @@
{:else}
<Global />
{/if}
<div class="fixed bottom-0 right-0 p-8">
<a
href="/notes/new"
class="rounded-full bg-accent color-white w-16 h-16 flex justify-center
items-center border border-dark shadow-2xl cursor-pointer">
<span class="fa-sold fa-plus fa-2xl" />
</a>
</div>
{:else}
<div class="flex w-full justify-center items-center py-16">
<div class="text-center max-w-sm">

View File

@ -74,3 +74,13 @@ export const killEvent = e => {
e.stopPropagation()
e.stopImmediatePropagation()
}
export const fromParentOffset = (element, offset) => {
for (const child of element.childNodes) {
if (offset <= child.textContent.length) {
return [child, offset]
}
offset -= child.textContent.length
}
}

View File

@ -93,7 +93,7 @@ export const createScroller = loadMore => {
// Give it a generous timeout from last time something did load
timeout = setTimeout(() => {
didLoad = false
}, 5000)
}, 2000)
}
didLoad = shouldLoad

View File

@ -29,7 +29,7 @@
}
})
const cursor = new Cursor(timedelta(10, 'minutes'))
const cursor = new Cursor(timedelta(20, 'minutes'))
const loadNotes = async () => {
const [since, until] = cursor.step()

View File

@ -5,7 +5,7 @@
export let pubkey
const cursor = new Cursor(timedelta(1, 'days'))
const cursor = new Cursor(timedelta(3, 'days'))
const loadNotes = async () => {
const [since, until] = cursor.step()