mirror of
https://github.com/coracle-social/coracle.git
synced 2024-09-30 00:41:12 +00:00
Add link previews
This commit is contained in:
parent
e87c6d6a03
commit
6c7ebd2b1c
21
README.md
21
README.md
@ -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
BIN
package-lock.json
generated
Binary file not shown.
@ -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"
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
29
src/partials/Toggle.svelte
Normal file
29
src/partials/Toggle.svelte
Normal 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>
|
@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
50
src/routes/Settings.svelte
Normal file
50
src/routes/Settings.svelte
Normal 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>
|
@ -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)))
|
||||
|
@ -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()
|
||||
|
@ -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}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user