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
# 0.2.31
- [x] Added the ability to view and write reviews on relays, with ratings
# 0.2.30
- [x] Prefer followed users when mentioning people

View File

@ -3,9 +3,6 @@
- [ ] List detail pages with follow all and add all to list
- [ ] Use vida to stream development
- [ ] 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
- [ ] White-labeled
- [ ] Add invite code registration for relay
@ -21,6 +18,7 @@
# Core
- [ ] Deploy ontology.coracle.social
- [ ] Add threads - replies by self get shown at the top of replies?
- [ ] Show link previews when posting
- [ ] Embedded music players for Spotify, youtube, etc

View File

@ -255,7 +255,7 @@ const loadParents = (notes, opts = {}) => {
return load({
relays: sampleRelays(aggregateScores(notesWithParent.map(getRelaysForEventParent)), 0.3),
filter: {kinds: [1], ids: notesWithParent.map(findReplyId)},
filter: {ids: notesWithParent.map(findReplyId)},
...opts,
})
}
@ -268,7 +268,7 @@ const streamContext = ({notes, onChunk, maxDepth = 2}) => {
const loadChunk = (events, depth) => {
// 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 (events.length === 0) {
@ -333,7 +333,7 @@ const streamContext = ({notes, onChunk, maxDepth = 2}) => {
const applyContext = (notes, context) => {
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 zaps = context.filter(propEq("kind", 9735))

View File

@ -21,6 +21,7 @@
export let shouldDisplay = always(true)
export let parentsTimeout = 500
export let invertColors = false
export let onEvent = null
let notes = []
let notesBuffer = []
@ -90,6 +91,11 @@
// Show replies grouped by parent whenever possible
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
const [bottom, top] = partition(e => e.created_at < since, merged)

View File

@ -1,14 +1,16 @@
<script lang="ts">
import {objOf, reverse} from "ramda"
import {nip19} from 'nostr-tools'
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 {displayPerson, parseContent, Tags} from "src/util/nostr"
import {displayPerson, parseContent, getLabelQuality, displayRelay, Tags} from "src/util/nostr"
import {modal} from "src/partials/state"
import MediaSet from "src/partials/MediaSet.svelte"
import Card from "src/partials/Card.svelte"
import Spinner from "src/partials/Spinner.svelte"
import Anchor from "src/partials/Anchor.svelte"
import Rating from "src/partials/Rating.svelte"
import PersonCircle from "src/app/shared/PersonCircle.svelte"
import {sampleRelays} from "src/agent/relays"
import user from "src/agent/user"
@ -25,6 +27,7 @@
const shouldTruncate = !showEntire && note.content.length > maxLength
let content = parseContent(note)
let rating = note.kind === 1985 ? getLabelQuality("review/relay", note) : null
const links = []
const ranges = []
@ -118,6 +121,31 @@
<div class="flex flex-col gap-2 overflow-hidden text-ellipsis">
<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}
{#if type === "newline"}
{#each value as _}

View File

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

View File

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

View File

@ -1,9 +1,13 @@
<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 {normalizeRelayUrl} from "src/util/nostr"
import {normalizeRelayUrl, Tags, getAvgQuality} from "src/util/nostr"
import Input from "src/partials/Input.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 user from "src/agent/user"
@ -14,8 +18,14 @@
export let hideIfEmpty = false
let search
let reviews = []
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))
@ -24,6 +34,18 @@
{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>
<div class="flex flex-col gap-2">
@ -35,7 +57,7 @@
{/if}
{#each !q && hideIfEmpty ? [] : search(q).slice(0, limit) as relay (relay.url)}
<slot name="item" {relay}>
<RelayCard {relay} />
<RelayCard rating={ratings[relay.url]} {relay} />
</slot>
{/each}
<slot name="footer">

View File

@ -4,10 +4,12 @@
import {displayRelay} from "src/util/nostr"
import {webSocketURLToPlainOrBase64} 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 pool from "src/agent/pool"
export let relay
export let rating = null
let quality = null
let message = null
@ -47,4 +49,9 @@
class:opacity-1={showStatus}>
{message}
</p>
{#if rating}
<div class="px-4 text-sm">
<Rating inert value={rating} />
</div>
{/if}
</div>

View File

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

View File

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

View File

@ -4,24 +4,26 @@
import Button from "src/partials/Button.svelte"
import Content from "src/partials/Content.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 user from "src/agent/user"
import cmd from "src/agent/cmd"
export let url
let review
let compose
let rating
const onSubmit = () => {
const review = compose.parse()
cmd
.createLabel({
content: review,
tagClient: false,
tags: [
["L", "social.coracle.ontology"],
["l", "review", "social.coracle.ontology"],
["l", "review/relay", "social.coracle.ontology", JSON.stringify({quality: rating})],
["r", url],
],
})
@ -41,7 +43,11 @@
<Rating bind:value={rating} />
</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>
</div>
</Content>

View File

@ -217,7 +217,12 @@
</script>
<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" />
</div>

View File

@ -1,4 +1,6 @@
<script lang="ts">
import cx from "classnames"
let input = null
// Line breaks are wrapped in divs sometimes
@ -86,7 +88,8 @@
</script>
<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
contenteditable
bind:this={input}

View File

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

View File

@ -219,7 +219,7 @@ export const defer = (): Deferred<any> => {
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
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 {nip19} from "nostr-tools"
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"
export const personKinds = [0, 2, 3, 10001, 10002]
@ -60,6 +60,9 @@ export class Tags {
filter(f) {
return new Tags(this.tags.filter(f))
}
reject(f) {
return new Tags(this.tags.filter(t => !f(t)))
}
any(f) {
return this.filter(f).exists()
}
@ -363,3 +366,9 @@ export const processZaps = (zaps, author) =>
export const fromNostrURI = s => s.replace(/^[\w\+]+:\/?\/?/, "")
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))