Add better relay review support

This commit is contained in:
Jonathan Staab 2023-06-10 12:41:45 -07:00
parent 6b293b33a2
commit 5fc98007d2
17 changed files with 139 additions and 25 deletions

View File

@ -1,5 +1,9 @@
# Changelog # Changelog
# 0.2.31
- [x] Added the ability to view and write reviews on relays, with ratings
# 0.2.30 # 0.2.30
- [x] Prefer followed users when mentioning people - [x] Prefer followed users when mentioning people

View File

@ -3,9 +3,6 @@
- [ ] List detail pages with follow all and add all to list - [ ] List detail pages with follow all and add all to list
- [ ] Use vida to stream development - [ ] Use vida to stream development
- [ ] Fix connection management stuff. Have GPT help - [ ] Fix connection management stuff. Have GPT help
- [ ] Relay reviews
- Add curated relay list, and an easy way to view content on the relays
- Deploy ontology.coracle.social
- [ ] Add preview proxy thing - [ ] Add preview proxy thing
- [ ] White-labeled - [ ] White-labeled
- [ ] Add invite code registration for relay - [ ] Add invite code registration for relay
@ -21,6 +18,7 @@
# Core # Core
- [ ] Deploy ontology.coracle.social
- [ ] Add threads - replies by self get shown at the top of replies? - [ ] Add threads - replies by self get shown at the top of replies?
- [ ] Show link previews when posting - [ ] Show link previews when posting
- [ ] Embedded music players for Spotify, youtube, etc - [ ] Embedded music players for Spotify, youtube, etc

View File

@ -255,7 +255,7 @@ const loadParents = (notes, opts = {}) => {
return load({ return load({
relays: sampleRelays(aggregateScores(notesWithParent.map(getRelaysForEventParent)), 0.3), relays: sampleRelays(aggregateScores(notesWithParent.map(getRelaysForEventParent)), 0.3),
filter: {kinds: [1], ids: notesWithParent.map(findReplyId)}, filter: {ids: notesWithParent.map(findReplyId)},
...opts, ...opts,
}) })
} }
@ -268,7 +268,7 @@ const streamContext = ({notes, onChunk, maxDepth = 2}) => {
const loadChunk = (events, depth) => { const loadChunk = (events, depth) => {
// Remove anything from the chunk we've already seen // Remove anything from the chunk we've already seen
events = events.filter(e => e.kind === 1 && !seen.has(e.id)) events = events.filter(e => ![7, 9735].includes(e.kind) && !seen.has(e.id))
// If we have no new information, no need to re-subscribe // If we have no new information, no need to re-subscribe
if (events.length === 0) { if (events.length === 0) {
@ -333,7 +333,7 @@ const streamContext = ({notes, onChunk, maxDepth = 2}) => {
const applyContext = (notes, context) => { const applyContext = (notes, context) => {
context = context.map(assoc("isContext", true)) context = context.map(assoc("isContext", true))
const replies = context.filter(propEq("kind", 1)) const replies = context.filter(e => ![7, 9735].includes(e.kind))
const reactions = context.filter(propEq("kind", 7)) const reactions = context.filter(propEq("kind", 7))
const zaps = context.filter(propEq("kind", 9735)) const zaps = context.filter(propEq("kind", 9735))

View File

@ -21,6 +21,7 @@
export let shouldDisplay = always(true) export let shouldDisplay = always(true)
export let parentsTimeout = 500 export let parentsTimeout = 500
export let invertColors = false export let invertColors = false
export let onEvent = null
let notes = [] let notes = []
let notesBuffer = [] let notesBuffer = []
@ -90,6 +91,11 @@
// Show replies grouped by parent whenever possible // Show replies grouped by parent whenever possible
const merged = sortBy(e => -e.created_at, mergeParents(combined)) const merged = sortBy(e => -e.created_at, mergeParents(combined))
// Notify caller if they asked for it
for (const e of merged) {
onEvent?.(e)
}
// Split into notes before and after we started loading // Split into notes before and after we started loading
const [bottom, top] = partition(e => e.created_at < since, merged) const [bottom, top] = partition(e => e.created_at < since, merged)

View File

@ -1,14 +1,16 @@
<script lang="ts"> <script lang="ts">
import {objOf, reverse} from "ramda" import {objOf, reverse} from "ramda"
import {nip19} from 'nostr-tools'
import {fly} from "svelte/transition" import {fly} from "svelte/transition"
import {splice} from "hurdak/lib/hurdak" import {splice, switcher, switcherFn} from "hurdak/lib/hurdak"
import {warn} from "src/util/logger" import {warn} from "src/util/logger"
import {displayPerson, parseContent, Tags} from "src/util/nostr" import {displayPerson, parseContent, getLabelQuality, displayRelay, Tags} from "src/util/nostr"
import {modal} from "src/partials/state" import {modal} from "src/partials/state"
import MediaSet from "src/partials/MediaSet.svelte" import MediaSet from "src/partials/MediaSet.svelte"
import Card from "src/partials/Card.svelte" import Card from "src/partials/Card.svelte"
import Spinner from "src/partials/Spinner.svelte" import Spinner from "src/partials/Spinner.svelte"
import Anchor from "src/partials/Anchor.svelte" import Anchor from "src/partials/Anchor.svelte"
import Rating from "src/partials/Rating.svelte"
import PersonCircle from "src/app/shared/PersonCircle.svelte" import PersonCircle from "src/app/shared/PersonCircle.svelte"
import {sampleRelays} from "src/agent/relays" import {sampleRelays} from "src/agent/relays"
import user from "src/agent/user" import user from "src/agent/user"
@ -25,6 +27,7 @@
const shouldTruncate = !showEntire && note.content.length > maxLength const shouldTruncate = !showEntire && note.content.length > maxLength
let content = parseContent(note) let content = parseContent(note)
let rating = note.kind === 1985 ? getLabelQuality("review/relay", note) : null
const links = [] const links = []
const ranges = [] const ranges = []
@ -118,6 +121,31 @@
<div class="flex flex-col gap-2 overflow-hidden text-ellipsis"> <div class="flex flex-col gap-2 overflow-hidden text-ellipsis">
<p> <p>
{#if rating}
{@const [type, value] = Tags.from(note).reject(t => ['l', 'L'].includes(t[0])).first()}
{@const action = switcher(type, {
r: () => modal.push({type: 'relay/detail', url: value}),
p: () => modal.push({type: 'person/feed', pubkey: value}),
e: () => modal.push({type: 'note/detail', note: {id: value}}),
})}
{@const display = switcherFn(type, {
r: () => displayRelay({url: value}),
p: () => displayPerson(getPersonWithFallback(value)),
e: () => "a note",
default: "something"
})}
<div class="flex items-center gap-2 pl-2 mb-4 border-l-2 border-solid border-gray-5">
Rated
{#if action}
<Anchor on:click={action}>{display}</Anchor>
{:else}
{display}
{/if}
<div class="text-sm">
<Rating inert value={rating} />
</div>
</div>
{/if}
{#each content as { type, value }, i} {#each content as { type, value }, i}
{#if type === "newline"} {#if type === "newline"}
{#each value as _} {#each value as _}

View File

@ -6,7 +6,7 @@
export let relays export let relays
export let invertColors = false export let invertColors = false
const filter = {kinds: [1], authors: [pubkey]} const filter = {kinds: [1, 1985], authors: [pubkey]}
</script> </script>
<Feed {relays} {filter} {invertColors} parentsTimeout={3000} delta={timedelta(1, "days")} /> <Feed {relays} {filter} {invertColors} parentsTimeout={3000} delta={timedelta(1, "days")} />

View File

@ -8,12 +8,14 @@
import {displayRelay} from "src/util/nostr" import {displayRelay} from "src/util/nostr"
import {modal} from "src/partials/state" import {modal} from "src/partials/state"
import Toggle from "src/partials/Toggle.svelte" import Toggle from "src/partials/Toggle.svelte"
import Rating from "src/partials/Rating.svelte"
import Anchor from "src/partials/Anchor.svelte" import Anchor from "src/partials/Anchor.svelte"
import pool from "src/agent/pool" import pool from "src/agent/pool"
import user from "src/agent/user" import user from "src/agent/user"
import {loadAppData} from "src/app/state" import {loadAppData} from "src/app/state"
export let relay export let relay
export let rating = null
export let theme = "gray-8" export let theme = "gray-8"
export let showStatus = false export let showStatus = false
export let hideActions = false export let hideActions = false
@ -82,6 +84,11 @@
{message} {message}
</p> </p>
{/if} {/if}
{#if rating}
<div class="px-4 text-sm" in:fly={{y: 20}}>
<Rating inert value={rating} />
</div>
{/if}
</div> </div>
{#if !hideActions} {#if !hideActions}
<slot name="actions"> <slot name="actions">

View File

@ -1,9 +1,13 @@
<script> <script>
import {pluck} from "ramda" import {onMount} from "svelte"
import {pluck, groupBy} from "ramda"
import {mapValues} from "hurdak/lib/hurdak"
import {fuzzy} from "src/util/misc" import {fuzzy} from "src/util/misc"
import {normalizeRelayUrl} from "src/util/nostr" import {normalizeRelayUrl, Tags, getAvgQuality} from "src/util/nostr"
import Input from "src/partials/Input.svelte" import Input from "src/partials/Input.svelte"
import RelayCard from "src/app/shared/RelayCard.svelte" import RelayCard from "src/app/shared/RelayCard.svelte"
import {getUserReadRelays} from "src/agent/relays"
import network from "src/agent/network"
import {watch} from "src/agent/db" import {watch} from "src/agent/db"
import user from "src/agent/user" import user from "src/agent/user"
@ -14,8 +18,14 @@
export let hideIfEmpty = false export let hideIfEmpty = false
let search let search
let reviews = []
let knownRelays = watch("relays", t => t.all()) let knownRelays = watch("relays", t => t.all())
$: ratings = mapValues(
events => getAvgQuality("review/relay", events),
groupBy(e => Tags.from(e).getMeta("r"), reviews)
)
$: { $: {
const joined = new Set(pluck("url", $relays)) const joined = new Set(pluck("url", $relays))
@ -24,6 +34,18 @@
{keys: ["name", "description", "url"]} {keys: ["name", "description", "url"]}
) )
} }
onMount(async () => {
reviews = await network.load({
relays: getUserReadRelays(),
filter: {
limit: 1000,
kinds: [1985],
"#l": ["review/relay"],
"#L": ["social.coracle.ontology"],
},
})
})
</script> </script>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
@ -35,7 +57,7 @@
{/if} {/if}
{#each !q && hideIfEmpty ? [] : search(q).slice(0, limit) as relay (relay.url)} {#each !q && hideIfEmpty ? [] : search(q).slice(0, limit) as relay (relay.url)}
<slot name="item" {relay}> <slot name="item" {relay}>
<RelayCard {relay} /> <RelayCard rating={ratings[relay.url]} {relay} />
</slot> </slot>
{/each} {/each}
<slot name="footer"> <slot name="footer">

View File

@ -4,10 +4,12 @@
import {displayRelay} from "src/util/nostr" import {displayRelay} from "src/util/nostr"
import {webSocketURLToPlainOrBase64} from "src/util/misc" import {webSocketURLToPlainOrBase64} from "src/util/misc"
import {poll, stringToHue, hsl} from "src/util/misc" import {poll, stringToHue, hsl} from "src/util/misc"
import Rating from "src/partials/Rating.svelte"
import Anchor from "src/partials/Anchor.svelte" import Anchor from "src/partials/Anchor.svelte"
import pool from "src/agent/pool" import pool from "src/agent/pool"
export let relay export let relay
export let rating = null
let quality = null let quality = null
let message = null let message = null
@ -47,4 +49,9 @@
class:opacity-1={showStatus}> class:opacity-1={showStatus}>
{message} {message}
</p> </p>
{#if rating}
<div class="px-4 text-sm">
<Rating inert value={rating} />
</div>
{/if}
</div> </div>

View File

@ -34,7 +34,7 @@
if (!displayNote.pubkey) { if (!displayNote.pubkey) {
await network.load({ await network.load({
relays: sampleRelays(relays), relays: sampleRelays(relays),
filter: {kinds: [1], ids: [displayNote.id]}, filter: {ids: [displayNote.id]},
onChunk: events => { onChunk: events => {
displayNote = asDisplayEvent(first(events)) displayNote = asDisplayEvent(first(events))
}, },

View File

@ -1,24 +1,33 @@
<script lang="ts"> <script lang="ts">
import {displayRelay, normalizeRelayUrl} from "src/util/nostr" import {batch} from "src/util/misc"
import {displayRelay, normalizeRelayUrl, getAvgQuality} from "src/util/nostr"
import Content from "src/partials/Content.svelte" import Content from "src/partials/Content.svelte"
import Feed from "src/app/shared/Feed.svelte" import Feed from "src/app/shared/Feed.svelte"
import Tabs from "src/partials/Tabs.svelte" import Tabs from "src/partials/Tabs.svelte"
import Rating from "src/partials/Rating.svelte"
import RelayTitle from "src/app/shared/RelayTitle.svelte" import RelayTitle from "src/app/shared/RelayTitle.svelte"
import RelayActions from "src/app/shared/RelayActions.svelte" import RelayActions from "src/app/shared/RelayActions.svelte"
import {relays} from "src/agent/db" import {relays} from "src/agent/db"
export let url export let url
let reviews = []
let activeTab = "reviews" let activeTab = "reviews"
url = normalizeRelayUrl(url) url = normalizeRelayUrl(url)
$: rating = getAvgQuality("review/relay", reviews)
const relay = relays.get(url) || {url} const relay = relays.get(url) || {url}
const tabs = ["reviews", "notes"] const tabs = ["reviews", "notes"]
const setActiveTab = tab => { const setActiveTab = tab => {
activeTab = tab activeTab = tab
} }
const onReview = batch(1000, chunk => {
reviews = reviews.concat(chunk)
})
document.title = displayRelay(relay) document.title = displayRelay(relay)
</script> </script>
@ -27,6 +36,11 @@
<RelayTitle {relay} /> <RelayTitle {relay} />
<RelayActions {relay} /> <RelayActions {relay} />
</div> </div>
{#if rating}
<div class="text-sm">
<Rating inert value={rating} />
</div>
{/if}
{#if relay.description} {#if relay.description}
<p>{relay.description}</p> <p>{relay.description}</p>
{/if} {/if}
@ -34,9 +48,10 @@
{#if activeTab === "reviews"} {#if activeTab === "reviews"}
<Feed <Feed
invertColors invertColors
onEvent={onReview}
filter={{ filter={{
kinds: [1985], kinds: [1985],
"#l": ["review"], "#l": ["review/relay"],
"#L": ["social.coracle.ontology"], "#L": ["social.coracle.ontology"],
"#r": [relay.url], "#r": [relay.url],
}} /> }} />

View File

@ -4,24 +4,26 @@
import Button from "src/partials/Button.svelte" import Button from "src/partials/Button.svelte"
import Content from "src/partials/Content.svelte" import Content from "src/partials/Content.svelte"
import Heading from "src/partials/Heading.svelte" import Heading from "src/partials/Heading.svelte"
import Textarea from "src/partials/Textarea.svelte" import Compose from "src/partials/Compose.svelte"
import Rating from "src/partials/Rating.svelte" import Rating from "src/partials/Rating.svelte"
import user from "src/agent/user" import user from "src/agent/user"
import cmd from "src/agent/cmd" import cmd from "src/agent/cmd"
export let url export let url
let review let compose
let rating let rating
const onSubmit = () => { const onSubmit = () => {
const review = compose.parse()
cmd cmd
.createLabel({ .createLabel({
content: review, content: review,
tagClient: false, tagClient: false,
tags: [ tags: [
["L", "social.coracle.ontology"], ["L", "social.coracle.ontology"],
["l", "review", "social.coracle.ontology"], ["l", "review/relay", "social.coracle.ontology", JSON.stringify({quality: rating})],
["r", url], ["r", url],
], ],
}) })
@ -41,7 +43,11 @@
<Rating bind:value={rating} /> <Rating bind:value={rating} />
</div> </div>
</div> </div>
<Textarea bind:value={review} placeholder="Share your thoughts..." /> <Compose
{onSubmit}
class="shadow-inset rounded bg-input text-black"
style="min-height: 6rem"
bind:this={compose} />
<Button type="submit" class="flex-grow text-center">Send</Button> <Button type="submit" class="flex-grow text-center">Send</Button>
</div> </div>
</Content> </Content>

View File

@ -217,7 +217,12 @@
</script> </script>
<div class="flex"> <div class="flex">
<ContentEditable bind:this={contenteditable} on:keydown={onKeyDown} on:keyup={onKeyUp} /> <ContentEditable
style={$$props.style}
class={$$props.class}
bind:this={contenteditable}
on:keydown={onKeyDown}
on:keyup={onKeyUp} />
<slot name="addon" /> <slot name="addon" />
</div> </div>

View File

@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import cx from "classnames"
let input = null let input = null
// Line breaks are wrapped in divs sometimes // Line breaks are wrapped in divs sometimes
@ -86,7 +88,8 @@
</script> </script>
<div <div
class="w-full min-w-0 p-2 text-gray-2 outline-0" style={$$props.style}
class={cx($$props.class, "w-full min-w-0 p-2 text-gray-2 outline-0")}
autofocus autofocus
contenteditable contenteditable
bind:this={input} bind:this={input}

View File

@ -1,14 +1,18 @@
<script lang="ts"> <script lang="ts">
import cx from "classnames"
import {range} from "hurdak/lib/hurdak" import {range} from "hurdak/lib/hurdak"
export let inert = false
export let value = 1 export let value = 1
</script> </script>
<div class="flex gap-1"> <div class="flex gap-1">
{#each range(0, 5) as x} {#each range(0, 5) as x}
<i <i
class="fa fa-star cursor-pointer text-gray-5 hover:text-warning hover:opacity-75" class={cx("fa fa-star text-gray-5", {
class:text-warning={value >= x / 5} "cursor-pointer hover:text-warning hover:opacity-75": !inert,
"text-warning": value >= x / 5,
})}
on:click={() => { on:click={() => {
value = x / 5 value = x / 5
}} /> }} />

View File

@ -219,7 +219,7 @@ export const defer = (): Deferred<any> => {
return Object.assign(p, {resolve, reject}) return Object.assign(p, {resolve, reject})
} }
export const avg = xs => sum(xs) / xs.length export const avg = xs => (xs.length > 0 ? sum(xs) / xs.length : 0)
// https://stackoverflow.com/a/21682946 // https://stackoverflow.com/a/21682946
export const stringToHue = value => { export const stringToHue = value => {

View File

@ -2,7 +2,7 @@ import type {DisplayEvent} from "src/util/types"
import {is, fromPairs, mergeLeft, last, identity, objOf, prop, flatten, uniq} from "ramda" import {is, fromPairs, mergeLeft, last, identity, objOf, prop, flatten, uniq} from "ramda"
import {nip19} from "nostr-tools" import {nip19} from "nostr-tools"
import {ensurePlural, ellipsize, first} from "hurdak/lib/hurdak" import {ensurePlural, ellipsize, first} from "hurdak/lib/hurdak"
import {tryJson} from "src/util/misc" import {tryJson, avg} from "src/util/misc"
import {invoiceAmount} from "src/util/lightning" import {invoiceAmount} from "src/util/lightning"
export const personKinds = [0, 2, 3, 10001, 10002] export const personKinds = [0, 2, 3, 10001, 10002]
@ -60,6 +60,9 @@ export class Tags {
filter(f) { filter(f) {
return new Tags(this.tags.filter(f)) return new Tags(this.tags.filter(f))
} }
reject(f) {
return new Tags(this.tags.filter(t => !f(t)))
}
any(f) { any(f) {
return this.filter(f).exists() return this.filter(f).exists()
} }
@ -363,3 +366,9 @@ export const processZaps = (zaps, author) =>
export const fromNostrURI = s => s.replace(/^[\w\+]+:\/?\/?/, "") export const fromNostrURI = s => s.replace(/^[\w\+]+:\/?\/?/, "")
export const toNostrURI = s => `web+nostr://${s}` export const toNostrURI = s => `web+nostr://${s}`
export const getLabelQuality = (label, event) =>
tryJson(() => JSON.parse(last(Tags.from(event).type("l").equals(label).first())).quality)
export const getAvgQuality = (label, events) =>
avg(events.map(e => getLabelQuality(label, e)).filter(identity))