Add link previews

This commit is contained in:
Jonathan Staab 2022-12-03 12:26:09 -08:00
parent e87c6d6a03
commit 6c7ebd2b1c
13 changed files with 174 additions and 24 deletions

View File

@ -1,20 +1,27 @@
Bugs
- [ ] Pin joined relays at the top
- [ ] Load/publish user preferred relays
- [ ] 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.
- [ ] uniq is sprinkled all over the place, figure out a better solution for de-duplication
- [ ] Fix replies - notes may only include a "root" in its tags
- [ ] Reactions in modal are broken
Features
- [x] Chat
- [x] Threads/social
- [ ] Search
- [x] Search
- [ ] Permalink note detail (share/permalink button?)
- [ ] Add "view thread" page that recurs more deeply
- [ ] Link previews https://github.com/Dhaiwat10/svelte-link-preview, https://microlink.io/sdk
- [ ] Images
- [ ] Followers, blocking
- Make them opt-in
- [ ] With link/image previews, remove the url from the note body if it's on a separate last line
- [ ] Followers, blocks, likes on profile
- [ ] Notifications
- [ ] Server discovery
- [ ] 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

BIN
package-lock.json generated

Binary file not shown.

View File

@ -30,8 +30,10 @@
"hurdak": "github:ConsignCloud/hurdak",
"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

@ -19,6 +19,7 @@
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"
@ -96,9 +97,10 @@
<UserDetail {...params} />
{/key}
</Route>
<Route path="/settings/keys" component={Keys} />
<Route path="/settings/relays" component={RelayList} />
<Route path="/settings/profile" component={Profile} />
<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>
@ -136,15 +138,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

View File

@ -1,16 +1,19 @@
<script>
import cx from 'classnames'
import {find, uniqBy, prop, whereEq} from 'ramda'
import {onMount} from 'svelte'
import {fly, slide} from 'svelte/transition'
import {navigate} from 'svelte-routing'
import {LinkPreview} from 'svelte-link-preview'
import {ellipsize} from 'hurdak/src/core'
import {hasParent, toHtml} from 'src/util/html'
import {hasParent, toHtml, findLink} from 'src/util/html'
import Anchor from 'src/partials/Anchor.svelte'
import {dispatch} from "src/state/dispatch"
import {findReplyTo} from "src/state/nostr"
import {accounts, modal} from "src/state/app"
import {accounts, settings, modal} from "src/state/app"
import {user} from "src/state/user"
import {formatTimestamp} from 'src/util/misc'
import {getLinkPreview} from 'src/util/html'
import UserBadge from "src/partials/UserBadge.svelte"
export let note
@ -19,6 +22,7 @@
export let interactive = false
export let invertColors = false
let preview = null
let like = null
let flag = null
let reply = null
@ -30,6 +34,14 @@
parentId = findReplyTo(note)
}
onMount(async () => {
const link = findLink(note.content)
if (link && $settings.showLinkPreviews) {
preview = await getLinkPreview(link)
}
})
const onClick = e => {
if (!['I'].includes(e.target.tagName) && !hasParent('a', e.target)) {
modal.set({note})
@ -120,6 +132,11 @@
{:else}
{@html toHtml(note.content)}
{/if}
{#if preview}
<div class="mt-2" in:slide on:click={e => e.stopPropagation()}>
<LinkPreview url={preview.url} fetcher={() => preview} />
</div>
{/if}
</p>
<div class="flex gap-6 text-light">
<div>

View File

@ -0,0 +1,29 @@
<script>
import cx from "classnames"
import Switch from "svelte-switch"
export let wrapperClass = ""
export let value
const onChange = e => {
value = e.detail.checked
}
</script>
<div class={wrapperClass}>
<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>
</div>

View File

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

View File

@ -81,7 +81,7 @@
{: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"
You aren't yet connected to any relays. Please click <Anchor href="/relays"
>here</Anchor
> to get started.
</div>

View File

@ -124,7 +124,7 @@
{#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="/settings/relays"
You aren't yet connected to any relays. Please click <Anchor href="/relays"
>here</Anchor
> to get started.
</div>

View File

@ -0,0 +1,50 @@
<script>
import {onMount} from "svelte"
import {fly} from 'svelte/transition'
import {navigate} from "svelte-routing"
import Toggle from "src/partials/Toggle.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')
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>
<Button type="submit" class="text-center">Done</Button>
</div>
</div>
</form>

View File

@ -9,6 +9,15 @@ import {epoch, filterMatches, Listener, channels, relays, findReplyTo} from 'src
export const modal = writable(null)
export const settings = writable({
showLinkPreviews: true,
...getLocalJson("coracle/settings"),
})
settings.subscribe($settings => {
setLocalJson("coracle/settings", $settings)
})
export const logout = () => {
// Give any animations a moment to finish
setTimeout(() => {
@ -59,7 +68,7 @@ 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)
}
// Notes
@ -79,6 +88,8 @@ export const annotateNotes = async (chunk, {showParents = false} = {}) => {
.concat(chunk.filter(e => !find(whereEq({id: findReplyTo(e)}), parents)))
}
chunk = uniqBy(prop('id'), chunk)
if (chunk.length === 0) {
return chunk
}
@ -110,8 +121,8 @@ export const annotateNotes = async (chunk, {showParents = false} = {}) => {
const annotate = e => ({
...e,
user: $accounts[e.pubkey],
replies: (repliesById[e.id] || []).map(reply => annotate(reply)),
reactions: (reactionsById[e.id] || []).map(reaction => annotate(reaction)),
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)))

View File

@ -123,6 +123,7 @@ export class Cursor {
this.sub = null
this.q = []
this.p = Promise.resolve()
this.seen = new Set()
}
async start() {
if (!this.sub) {
@ -150,8 +151,15 @@ export class Cursor {
await this.restart()
}
onEvent(e) {
this.until = e.created_at - 1
this.q.push(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()

View File

@ -1,3 +1,6 @@
import {last} from 'ramda'
import {first} from 'hurdak/lib/hurdak'
export const copyToClipboard = text => {
const {activeElement} = document
const input = document.createElement("textarea")
@ -69,6 +72,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 />')
@ -76,3 +81,17 @@ export const toHtml = content => {
return `<a href="${url}" target="_blank noopener" class="underline">${domain}</a>`
})
}
export const getLinkPreview = async url => {
const res = await fetch('http://localhost:8000/link/preview', {
method: 'POST',
body: JSON.stringify({url}),
headers: {
'Content-Type': 'application/json',
},
})
const json = await res.json()
return {...json, hostname: first(last(url.split('//')).split('/')), sitename: null}
}