Add profile search

This commit is contained in:
Jon Staab 2024-05-28 13:10:51 -07:00
parent 0d44094e02
commit 606a707e3d
10 changed files with 139 additions and 104 deletions

View File

@ -6,13 +6,13 @@
import PersonBadge from "src/app/shared/PersonBadge.svelte"
import ContentEditable from "src/partials/ContentEditable.svelte"
import Suggestions from "src/partials/Suggestions.svelte"
import {displayProfile} from "src/domain"
import {
hints,
follows,
derivePerson,
displayPerson,
searchPeople,
profileSearch,
createPeopleLoader,
getProfileByPubkey,
} from "src/engine"
export let onSubmit
@ -45,8 +45,8 @@
let results = []
if (word.length > 1 && word.startsWith("@")) {
const [followed, notFollowed] = partition(
p => $follows.has(p.pubkey),
$searchPeople(word.slice(1)),
profile => $follows.has(profile.event.pubkey),
$profileSearch.searchOptions(word.slice(1)),
)
results = followed.concat(notFollowed)
@ -64,7 +64,7 @@
return {selection, node, offset, word}
}
const autocomplete = ({person = null, force = false} = {}) => {
const autocomplete = ({profile = null, force = false} = {}) => {
let completed = false
const {selection, node, offset, word} = getInfo()
@ -98,8 +98,8 @@
}
// Mentions
if ((force || word.length > 1) && word.startsWith("@") && person) {
annotate("@", displayPerson(person).trim(), pubkeyEncoder.encode(person.pubkey))
if ((force || word.length > 1) && word.startsWith("@") && profile) {
annotate("@", displayProfile(profile).trim(), pubkeyEncoder.encode(profile.event.pubkey))
}
// Topics
@ -130,7 +130,7 @@
// Enter adds a newline, so do it on key down
if (["Enter"].includes(e.code)) {
autocomplete({person: suggestions.get()})
autocomplete({profile: suggestions.get()})
}
// Only autocomplete topics on space
@ -149,7 +149,7 @@
applySearch(word)
if (["Tab"].includes(e.code)) {
autocomplete({person: suggestions.get()})
autocomplete({profile: suggestions.get()})
}
if (["Escape", "Space"].includes(e.code)) {
@ -179,7 +179,7 @@
selection.getRangeAt(0).insertNode(spaceNode)
selection.collapse(input, 1)
autocomplete({person: derivePerson(pubkey).get(), force: true})
autocomplete({profile: getProfileByPubkey(pubkey), force: true})
}
const createNewLines = (n = 1) => {
@ -269,7 +269,7 @@
<Suggestions
bind:this={suggestions}
select={person => autocomplete({person})}
select={profile => autocomplete({profile})}
loading={$loadingPeople}>
<div slot="item" let:item>
<PersonBadge inert pubkey={item.pubkey} />

View File

@ -3,7 +3,7 @@
import Anchor from "src/partials/Anchor.svelte"
import SearchSelect from "src/partials/SearchSelect.svelte"
import PersonBadge from "src/app/shared/PersonBadge.svelte"
import {searchPubkeys, displayPersonByPubkey} from "src/engine"
import {profileSearch, displayPersonByPubkey} from "src/engine"
import {router} from "src/app/util/router"
export let feed
@ -14,7 +14,7 @@
<SearchSelect
multiple
value={feed.slice(2)}
search={$searchPubkeys}
search={$profileSearch.search}
onChange={pubkeys => onChange([FeedType.Tag, "#p", ...pubkeys])}>
<span slot="item" let:item let:context>
{#if context === "value"}

View File

@ -4,7 +4,8 @@
import {createScroller} from "src/util/misc"
import FlexColumn from "src/partials/FlexColumn.svelte"
import PersonSummary from "src/app/shared/PersonSummary.svelte"
import {loadPubkeys, derivePerson, personHasName} from "src/engine"
import {profileHasName} from "src/domain"
import {loadPubkeys, getProfileByPubkey} from "src/engine"
export let pubkeys
@ -15,7 +16,7 @@
limit += 10
}
const hasName = pubkey => personHasName(derivePerson(pubkey).get())
const hasName = pubkey => profileHasName(getProfileByPubkey(pubkey))
onMount(() => {
const scroller = createScroller(loadMore, {

View File

@ -5,7 +5,7 @@
import SearchSelect from "src/partials/SearchSelect.svelte"
import PersonBadge from "src/app/shared/PersonBadge.svelte"
import {router} from "src/app/util/router"
import {searchPubkeys, createPeopleLoader, displayPersonByPubkey} from "src/engine"
import {profileSearch, createPeopleLoader, displayPersonByPubkey} from "src/engine"
export let value
export let multiple = false
@ -33,7 +33,7 @@
}
})
return $searchPubkeys(term)
return $profileSearch.search(term)
}
</script>

View File

@ -5,7 +5,7 @@
import {parseAnything} from "src/util/nostr"
import {router} from "src/app/util/router"
import type {Person, Topic} from "src/engine"
import {topics, searchPeople, createPeopleLoader} from "src/engine"
import {topics, profileSearch, createPeopleLoader} from "src/engine"
export let term
export let replace = false
@ -13,7 +13,7 @@
const openTopic = topic => router.at("topics").of(topic).open({replace})
const openPerson = pubkey => router.at("people").of(pubkey).open({replace})
const openProfile = pubkey => router.at("people").of(pubkey).open({replace})
const onClick = result => {
if (result.type === "topic") {
@ -21,7 +21,7 @@
}
if (result.type === "profile") {
openPerson(result.id)
openProfile(result.event.pubkey)
}
}
@ -52,8 +52,8 @@
.derived($topics => fuzzy($topics, {keys: ["name"], threshold: 0.5, shouldSort: true}))
const results = derived<{type: string; id: string; person?: Person; topic?: Topic}[]>(
[term, searchTopics, searchPeople],
([$term, $searchTopics, $searchPeople]) => {
[term, searchTopics, profileSearch],
([$term, $searchTopics, $profileSearch]) => {
$term = $term || ""
if ($term.length > 30) {
@ -62,7 +62,9 @@
return $term.startsWith("#")
? $searchTopics($term.slice(1)).map(topic => ({type: "topic", id: topic.name, topic}))
: $searchPeople($term).map(person => ({type: "profile", id: person.pubkey, person}))
: $profileSearch
.searchOptions($term)
.map(profile => ({type: "profile", id: profile.event.pubkey, profile}))
},
)

View File

@ -12,7 +12,7 @@
import PersonSummary from "src/app/shared/PersonSummary.svelte"
import RelayCard from "src/app/shared/RelayCard.svelte"
import type {Relay} from "src/engine"
import {urlToRelay, createPeopleLoader, searchPeople, searchRelays} from "src/engine"
import {urlToRelay, createPeopleLoader, profileSearch, searchRelays} from "src/engine"
export let relays
export let follows
@ -245,14 +245,11 @@
<Input bind:value={term}>
<i slot="before" class="fa fa-search" />
</Input>
{#each $searchPeople(term).slice(0, 30) as person (person.pubkey)}
<PersonSummary pubkey={person.pubkey}>
{#each $profileSearch.search(term).slice(0, 30) as pubkey (pubkey)}
<PersonSummary {pubkey}>
<div slot="actions" class="flex items-start justify-end">
{#if follows.includes(person.pubkey)}
<Anchor
button
class="flex items-center gap-2"
on:click={() => removeFollow(person.pubkey)}>
{#if follows.includes(pubkey)}
<Anchor button class="flex items-center gap-2" on:click={() => removeFollow(pubkey)}>
<i class="fa fa-user-slash" /> Unfollow
</Anchor>
{:else}
@ -260,7 +257,7 @@
button
accent
class="flex items-center gap-2"
on:click={() => addFollow(person.pubkey)}>
on:click={() => addFollow(pubkey)}>
<i class="fa fa-user-plus" /> Follow
</Anchor>
{/if}

View File

@ -14,6 +14,7 @@
import Anchor from "src/partials/Anchor.svelte"
import RelayCard from "src/app/shared/RelayCard.svelte"
import Note from "src/app/shared/Note.svelte"
import {profileHasName} from "src/domain"
import type {Relay} from "src/engine"
import {
load,
@ -22,8 +23,7 @@
pubkey,
follows,
deriveRelay,
derivePerson,
personHasName,
getProfileByPubkey,
displayPersonByPubkey,
relayPolicies,
relayPolicyUrls,
@ -41,7 +41,7 @@
const m = new Map<string, string[]>()
for (const pk of $follows) {
if (!personHasName(derivePerson(pk).get())) {
if (!profileHasName(getProfileByPubkey(pk))) {
continue
}

View File

@ -1,8 +1,7 @@
import {nip19} from 'nostr-tools'
import {ellipsize} from 'hurdak'
import {nip19} from "nostr-tools"
import {ellipsize} from "hurdak"
import {PROFILE} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {SearchHelper} from "src/util/misc"
import {tryJson} from "src/util/misc"
export type Profile = {
@ -41,11 +40,16 @@ export const readProfile = (event: TrustedEvent) => {
return {...profile, event} as PublishedProfile
}
export const createProfile = ({event, ...profile}: Profile) =>
({kind: PROFILE, content: JSON.stringify(profile)})
export const createProfile = ({event, ...profile}: Profile) => ({
kind: PROFILE,
content: JSON.stringify(profile),
})
export const editProfile = ({event, ...profile}: PublishedProfile) =>
({kind: PROFILE, content: JSON.stringify(profile), tags: event.tags})
export const editProfile = ({event, ...profile}: PublishedProfile) => ({
kind: PROFILE,
content: JSON.stringify(profile),
tags: event.tags,
})
export const displayPubkey = pubkey => {
const d = nip19.npubEncode(pubkey)
@ -63,16 +67,4 @@ export const displayProfile = (profile?: Profile) => {
return "[no name]"
}
export class ProfileSearch extends SearchHelper<PublishedProfile, string> {
config = {
keys: ["name", "display_name", {name: "nip05", weight: 0.5}, {name: "about", weight: 0.1}],
threshold: 0.3,
shouldSort: false,
includeScore: true,
}
getValue = (option: PublishedProfile) => option.event.pubkey
display = (address: string) =>
displayProfile(this.options.find(profile => this.getValue(profile) === address))
}
export const profileHasName = (profile?: Profile) => Boolean(profile?.name || profile?.display_name)

View File

@ -56,6 +56,7 @@ import {
inc,
sort,
groupBy,
indexBy,
} from "@welshman/lib"
import {
READ_RECEIPT,
@ -95,7 +96,17 @@ import {
subscribe as baseSubscribe,
} from "@welshman/net"
import type {Publish, PublishRequest, SubscribeRequest} from "@welshman/net"
import {fuzzy, synced, withGetter, createBatcher, pushToKey, tryJson, fromCsv} from "src/util/misc"
import {
fuzzy,
synced,
withGetter,
getter,
createBatcher,
pushToKey,
tryJson,
fromCsv,
SearchHelper,
} from "src/util/misc"
import {parseContent} from "src/util/notes"
import {
appDataKeys,
@ -109,11 +120,18 @@ import {
reactionKinds,
} from "src/util/nostr"
import logger from "src/util/logger"
import type {PublishedFeed, PublishedListFeed, Collection, PublishedList, Profile} from "src/domain"
import type {
PublishedFeed,
PublishedProfile,
PublishedListFeed,
Collection,
PublishedList,
} from "src/domain"
import {
EDITABLE_LIST_KINDS,
ListSearch,
FeedSearch,
profileHasName,
readFeed,
readList,
readProfile,
@ -123,6 +141,7 @@ import {
readHandlers,
mapListToFeed,
getHandlerAddress,
displayProfile,
} from "src/domain"
import type {
Channel,
@ -254,12 +273,73 @@ export const settings = user.derived(getSettings)
// Profiles
export const profiles = deriveEventsMapped<Profile>({
export const profiles = deriveEventsMapped<PublishedProfile>({
filters: [{kinds: [PROFILE]}],
eventToItem: readProfile,
itemToEvent: prop("event"),
})
export const profilesByPubkey = derived(profiles, $profiles =>
indexBy(p => p.event.pubkey, $profiles),
)
export const getProfilesByPubkey = getter(profilesByPubkey)
export const getProfileByPubkey = (pubkey: string) => getProfilesByPubkey().get(pubkey)
export const profilesWithName = derived(profiles, $profiles => $profiles.filter(profileHasName))
export class ProfileSearch extends SearchHelper<PublishedProfile, string> {
config = {
keys: ["name", "display_name", {name: "nip05", weight: 0.5}, {name: "about", weight: 0.1}],
threshold: 0.3,
shouldSort: false,
includeScore: true,
}
getValue = (option: PublishedProfile) => option.event.pubkey
display = (address: string) =>
displayProfile(this.options.find(profile => this.getValue(profile) === address))
getSearch = () => {
const $pubkey = pubkey.get()
primeWotCaches($pubkey)
const options = this.options.map(profile => ({
profile,
score: getWotScore($pubkey, profile.event.pubkey),
}))
const fuse = new Fuse(options, {
keys: [
"profile.name",
"profile.display_name",
{name: "profile.nip05", weight: 0.5},
{name: "profile.about", weight: 0.1},
],
threshold: 0.3,
shouldSort: false,
includeScore: true,
})
return (term: string) => {
if (!term) {
return sortBy(item => -item.score, options).map(item => item.profile)
}
return doPipe(fuse.search(term), [
results =>
sortBy((r: any) => r.score - Math.pow(Math.max(0, r.item.score), 1 / 100), results),
results => results.map((r: any) => r.item.profile),
])
}
}
}
export const profileSearch = derived(profilesWithName, $profiles => new ProfileSearch($profiles))
// People
export const fetchHandle = createBatcher(500, async (handles: string[]) => {
@ -281,8 +361,6 @@ export const getHandle = cached({
getValue: ([handle]) => fetchHandle(handle),
})
export const personHasName = ({profile: p}: Person) => Boolean(p?.name || p?.display_name)
export const getPersonWithDefault = pubkey => ({pubkey, ...people.key(pubkey).get()})
export const displayPerson = ({pubkey, profile}: Person) => {
@ -451,8 +529,6 @@ export const decodePerson = entity => {
})
}
export const peopleWithName = people.derived(filter(personHasName))
export const derivePerson = pubkey => people.key(pubkey).derived(defaultTo({pubkey}))
export const mutes = user.derived(getMutes)
@ -465,41 +541,6 @@ export const deriveMuted = (value: string) => mutes.derived(s => s.has(value))
export const deriveFollowing = (pubkey: string) => follows.derived(s => s.has(pubkey))
export const searchPeople = new Derived(
[pubkey, peopleWithName.throttle(300)],
([$pubkey, $peopleWithName]: [string, Person[]]): ((term: string) => Person[]) => {
primeWotCaches($pubkey)
const options = $peopleWithName.map(p => ({person: p, score: getWotScore($pubkey, p.pubkey)}))
const fuse = new Fuse(options, {
keys: [
"person.profile.name",
"person.profile.display_name",
{name: "person.profile.nip05", weight: 0.5},
{name: "person.profile.about", weight: 0.1},
],
threshold: 0.3,
shouldSort: false,
includeScore: true,
})
return (term: string) => {
if (!term) {
return sortBy(item => -item.score, options).map(item => item.person)
}
return doPipe(fuse.search(term), [
results =>
sortBy((r: any) => r.score - Math.pow(Math.max(0, r.item.score), 1 / 100), results),
results => results.map((r: any) => r.item.person),
])
}
},
)
export const searchPubkeys = searchPeople.derived(search => term => pluck("pubkey", search(term)))
// Events
export const isEventMuted = new Derived(

View File

@ -345,7 +345,7 @@ export const getStringWidth = (text: string) => {
export class SearchHelper<T, V> {
config: any
fuzzy?: (term: string) => T[]
_search?: (term: string) => T[]
constructor(readonly options: T[]) {}
@ -353,17 +353,19 @@ export class SearchHelper<T, V> {
getValue = (option: T): V => option as unknown as V
getFuzzy = () => fuzzy(this.options, this.config)
getSearch = () => fuzzy(this.options, this.config)
display = (value: V) => String(value)
search = (term: string) => {
if (!this.fuzzy) {
this.fuzzy = this.getFuzzy()
searchOptions = (term: string) => {
if (!this._search) {
this._search = this.getSearch()
}
return this.fuzzy(term).map(this.getValue)
return this._search(term)
}
search = (term: string) => this.searchOptions(term).map(this.getValue)
}
export const fromCsv = s => (s || "").split(",").filter(identity)