feat: add full profile page at /people/[npub] #307

Open
userAdityaa wants to merge 1 commits from userAdityaa/flotilla:165-profile-page into dev
19 changed files with 1121 additions and 39 deletions
Showing only changes of commit 2df890f7c5 - Show all commits
+33 -1
View File
@@ -4,11 +4,16 @@
import type {TrustedEvent, EventContent} from "@welshman/util"
import {Router} from "@welshman/router"
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Popover from "@lib/components/Popover.svelte"
import EmojiButton from "@lib/components/EmojiButton.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
import NoteCard from "@app/components/NoteCard.svelte"
import ReactionSummary from "@app/components/ReactionSummary.svelte"
import ProfileNoteMenu from "@app/components/ProfileNoteMenu.svelte"
import {publishDelete} from "@app/deletes"
import {publishReaction} from "@app/reactions"
import {canEnforceNip70} from "@app/relays"
@@ -17,9 +22,10 @@
event: TrustedEvent
children?: Snippet
url?: string
editable?: boolean
}
const {url, event, children}: Props = $props()
const {url, event, children, editable = false}: Props = $props()
const relays = url ? [url] : Router.get().Event(event).getUrls()
@@ -38,9 +44,35 @@
content: emoji.unicode,
protect: await shouldProtect,
})
const toggleMenu = () => {
showMenu = !showMenu
}
const closeMenu = () => {
showMenu = false
}
let showMenu = $state(false)
</script>
<NoteCard {event} {url} class="cv card2 bg-alt">
{#if editable}
<div class="flex justify-end">
<div class="relative">
<Button class="btn btn-circle btn-ghost btn-xs" onclick={toggleMenu}>
<Icon icon={MenuDots} size={4} />
</Button>
{#if showMenu}
<Popover hideOnClick onClose={closeMenu}>
<div transition:fly class="absolute right-0 z-popover">
<ProfileNoteMenu {event} onClose={closeMenu} />
</div>
</Popover>
{/if}
</div>
</div>
{/if}
<NoteContent {event} expandMode="inline" />
<div class="flex w-full justify-between gap-2">
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-right">
+6 -9
View File
@@ -1,12 +1,11 @@
<script lang="ts">
import UserCircle from "@assets/icons/user-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Link from "@lib/components/Link.svelte"
import Profile from "@app/components/Profile.svelte"
import ProfileInfo from "@app/components/ProfileInfo.svelte"
import ProfileBadges from "@app/components/ProfileBadges.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import {pushModal} from "@app/modal"
import {makeProfilePath} from "@app/routes"
type Props = {
pubkey: string
@@ -14,22 +13,20 @@
}
const {pubkey, url}: Props = $props()
const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
</script>
<div class="card2 bg-alt flex flex-col gap-4 shadow-md">
<div class="flex justify-between">
<Profile {pubkey} {url} />
<Button onclick={openProfile} class="btn btn-primary hidden sm:flex">
<Link href={makeProfilePath(pubkey)} class="btn btn-primary hidden sm:flex">
<Icon icon={UserCircle} />
View Profile
</Button>
</Link>
</div>
<ProfileInfo {pubkey} {url} />
<ProfileBadges {pubkey} {url} />
<Button onclick={openProfile} class="btn btn-primary sm:hidden">
<Link href={makeProfilePath(pubkey)} class="btn btn-primary sm:hidden">
<Icon icon={UserCircle} />
View Profile
</Button>
</Link>
</div>
-2
View File
@@ -29,10 +29,8 @@
const openSpaces = () => pushModal(ProfileSpaces, {pubkey, url})
onMount(async () => {
// Make sure we have their relay selections before we load their posts
await loadRelayList(pubkey)
// Load groups and at least one note, regardless of time frame
load({
filters: [
{authors: [pubkey], kinds: [ROOMS]},
+6 -10
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import cx from "classnames"
import {getProfile, loadProfile} from "@welshman/app"
import {loadProfile} from "@welshman/app"
import {isMobile} from "@lib/html"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
@@ -22,17 +22,13 @@
: {box: "h-8 w-8", overlap: "-mr-3", overflow: "text-xs"},
)
for (const pubkey of pubkeys) {
loadProfile(pubkey)
}
const visiblePubkeys = $derived.by(() => {
const filtered = pubkeys.filter(pubkey => getProfile(pubkey)?.picture)
return filtered.length > 0 ? filtered : pubkeys.slice(0, 1)
$effect(() => {
for (const pk of pubkeys) {
loadProfile(pk)
}
})
const displayPubkeys = $derived(visiblePubkeys.toSorted().slice(0, effectiveLimit))
const displayPubkeys = $derived([...pubkeys].toSorted().slice(0, effectiveLimit))
const overflowCount = $derived(Math.max(0, pubkeys.length - effectiveLimit))
</script>
+8 -15
View File
@@ -10,14 +10,12 @@
} from "@welshman/app"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Code2 from "@assets/icons/code-2.svg?dataurl"
import Letter from "@assets/icons/letter-opened.svg?dataurl"
import UserCircle from "@assets/icons/user-circle.svg?dataurl"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
import Restart from "@assets/icons/restart.svg?dataurl"
import {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import ImageIcon from "@lib/components/ImageIcon.svelte"
import Link from "@lib/components/Link.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import Button from "@lib/components/Button.svelte"
import Popover from "@lib/components/Popover.svelte"
@@ -28,11 +26,10 @@
import ProfileInfo from "@app/components/ProfileInfo.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import ProfileBadges from "@app/components/ProfileBadges.svelte"
import {pubkeyLink} from "@app/env"
import {deriveUserIsSpaceAdmin, deriveSpaceBannedPubkeyItems, addSpaceMembers} from "@app/members"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
import {goToChat} from "@app/routes"
import {goToProfile} from "@app/routes"
export type Props = {
pubkey: string
@@ -53,9 +50,9 @@
const showInfo = () => pushModal(EventInfo, {url, event: $profile!.event})
const openChat = () => goToChat([pubkey])
const viewProfile = () => goToProfile(pubkey)
const toggleMenu = (pubkey: string) => {
const toggleMenu = () => {
showMenu = !showMenu
}
@@ -107,7 +104,7 @@
<Profile showPubkey avatarSize={14} {pubkey} {url} />
{#if $profile || $userIsAdmin}
<div class="relative">
<Button class="btn btn-circle btn-ghost btn-sm" onclick={() => toggleMenu(pubkey)}>
<Button class="btn btn-circle btn-ghost btn-sm" onclick={toggleMenu}>
<Icon icon={MenuDots} />
</Button>
{#if showMenu}
@@ -156,13 +153,9 @@
Go back
</Button>
<div class="flex gap-2">
<Link external href={pubkeyLink(pubkey)} class="btn btn-neutral">
<ImageIcon alt="" src="/coracle.png" />
Open in Coracle
</Link>
<Button onclick={openChat} class="btn btn-primary">
<Icon icon={Letter} />
Message
<Button onclick={viewProfile} class="btn btn-primary">
<Icon icon={UserCircle} />
View Full Profile
</Button>
</div>
</ModalFooter>
+19
View File
@@ -4,6 +4,7 @@
import {preventDefault} from "@lib/html"
import UserCircle from "@assets/icons/user-circle.svg?dataurl"
import MapPoint from "@assets/icons/map-point.svg?dataurl"
import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte"
@@ -85,6 +86,24 @@
{/snippet}
</Field>
{#if !isSignup}
<Field>
{#snippet label()}
<p>Website</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<Icon icon={LinkRound} />
<input
bind:value={values.profile.website}
class="grow"
type="text"
placeholder="https://" />
</label>
{/snippet}
{#snippet info()}
A link to your personal site or portfolio.
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Nostr Address</p>
+69
View File
@@ -0,0 +1,69 @@
<script lang="ts">
import {nthEq} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {getListTags, getEventTagValues} from "@welshman/util"
import {pin, unpin, tagEvent, userPinList, waitForThunkError} from "@welshman/app"
import {Router} from "@welshman/router"
import Pin from "@assets/icons/pin.svg?dataurl"
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import {publishDelete} from "@app/deletes"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
type Props = {
event: TrustedEvent
onClose: () => void
}
const {event, onClose}: Props = $props()
const relays = Router.get().Event(event).getUrls()
const pinnedIds = $derived(getEventTagValues(getListTags($userPinList)))
const isPinned = $derived(pinnedIds.includes(event.id))
const togglePin = async () => {
onClose()
const thunk = isPinned ? await unpin(event.id) : await pin(tagEvent(event).find(nthEq(0, "e"))!)
const error = await waitForThunkError(thunk)
if (error) {
pushToast({theme: "error", message: "Failed to update pinned notes."})
} else {
pushToast({message: isPinned ? "Note unpinned." : "Note pinned to your profile."})
}
}
const confirmDelete = () => {
onClose()
pushModal(Confirm, {
title: "Delete Note",
message: "Are you sure you want to delete this note?",
confirm: async () => {
await publishDelete({event, relays, protect: false})
pushToast({message: "Delete request sent."})
},
})
}
</script>
<ul class="menu whitespace-nowrap rounded-box bg-base-100 p-2 shadow-md">
<li>
<Button onclick={togglePin}>
<Icon size={4} icon={Pin} />
{isPinned ? "Unpin from profile" : "Pin to profile"}
</Button>
</li>
<li>
<Button onclick={confirmDelete} class="text-error">
<Icon size={4} icon={TrashBin2} />
Delete note
</Button>
</li>
</ul>
+379
View File
@@ -0,0 +1,379 @@
<script lang="ts">
import * as nip19 from "nostr-tools/nip19"
import cx from "classnames"
import {goto} from "$app/navigation"
import {compressFile} from "@lib/html"
import {displayPubkey} from "@welshman/util"
import {
pubkey,
followLists,
deriveProfile,
deriveProfileDisplay,
deriveUserWotScore,
followersByPubkey,
getFollows,
getFollowers,
follow,
unfollow,
tagPubkey,
} from "@welshman/app"
import {clamp} from "@welshman/lib"
import Copy from "@assets/icons/copy.svg?dataurl"
import QrCode from "@assets/icons/qr-code.svg?dataurl"
import LinkRound from "@assets/icons/link-round.svg?dataurl"
import SquareArrowRight from "@assets/icons/square-arrow-right-up.svg?dataurl"
import Letter from "@assets/icons/letter-opened.svg?dataurl"
import UserPlus from "@assets/icons/user-plus.svg?dataurl"
import PenNewSquare from "@assets/icons/pen-new-square.svg?dataurl"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import Code2 from "@assets/icons/code-2.svg?dataurl"
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
import Shield from "@assets/icons/shield-minimalistic.svg?dataurl"
import UsersGroup from "@assets/icons/users-group-rounded.svg?dataurl"
import {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
import Popover from "@lib/components/Popover.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileInfo from "@app/components/ProfileInfo.svelte"
import ProfileTrust from "@app/components/ProfileTrust.svelte"
import ProfileSharedSpaces from "@app/components/ProfileSharedSpaces.svelte"
import ProfilePinnedNotes from "@app/components/ProfilePinnedNotes.svelte"
import ProfilePageNotes from "@app/components/ProfilePageNotes.svelte"
import ProfilePageSpaces from "@app/components/ProfilePageSpaces.svelte"
import ProfileQrCode from "@app/components/ProfileQrCode.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import {deriveGroupList, getSpaceUrlsFromGroupList, userSpaceUrls} from "@app/groups"
import {updateProfile} from "@app/profiles"
import {uploadFile} from "@app/uploads"
import {pushModal} from "@app/modal"
import {clip, pushToast} from "@app/toast"
import {goToChat} from "@app/routes"
type Tab = "about" | "notes" | "spaces"
type Props = {
pubkey: string
}
const {pubkey: target}: Props = $props()
const profile = deriveProfile(target)
const profileDisplay = deriveProfileDisplay(target)
const groupList = deriveGroupList(target)
const score = deriveUserWotScore(target)
const encodedNpub = nip19.npubEncode(target)
const isSelf = $derived($pubkey === target)
const followerCount = $derived.by(() => {
void $followersByPubkey
return getFollowers(target).length
})
const isFollowing = $derived.by(() => {
void $followLists
return $pubkey ? getFollows($pubkey).includes(target) : false
})
const spaceUrls = $derived(getSpaceUrlsFromGroupList($groupList))
const sharedSpaceUrls = $derived($userSpaceUrls.filter(url => spaceUrls.includes(url)))
const displayScore = $derived(isSelf ? followerCount : Math.round(clamp([0, 100], $score)))
const spaceBadgeCount = $derived(isSelf ? spaceUrls.length : sharedSpaceUrls.length)
const website = $derived($profile?.website?.replace(/^https?:\/\//, ""))
const websiteHref = $derived(
$profile?.website?.match(/^https?:\/\//)
? $profile.website
: `https://${$profile?.website || ""}`,
)
let tab = $state<Tab>("about")
let showMenu = $state(false)
let bannerLoading = $state(false)
let bannerInput: HTMLInputElement | undefined = $state()
const setTab = (next: Tab) => {
tab = next
}
const showAboutTab = () => setTab("about")
const showNotesTab = () => setTab("notes")
const showSpacesTab = () => setTab("spaces")
const copyNpub = () => clip(encodedNpub)
const showQr = () => pushModal(ProfileQrCode, {code: encodedNpub})
const toggleMenu = () => {
showMenu = !showMenu
}
const closeMenu = () => {
showMenu = false
}
const showInfo = () => {
closeMenu()
pushModal(EventInfo, {event: $profile!.event})
}
const openSettings = () => goto("/settings/profile")
const openSpaces = () => goto("/spaces")
const openRelaySettings = () => goto("/settings/relays")
const openChat = () => goToChat([target])
const toggleFollow = async () => {
if (!$pubkey || isSelf) return
if (isFollowing) {
await unfollow(target)
} else {
await follow(tagPubkey(target))
}
}
const openBannerPicker = () => bannerInput?.click()
const onBannerChange = async (e: Event) => {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
input.value = ""
if (!file || !$profile) return
bannerLoading = true
try {
const {result} = await uploadFile(await compressFile(file))
if (result?.url) {
await updateProfile({profile: {...$profile, banner: result.url}})
pushToast({message: "Banner updated."})
}
} finally {
bannerLoading = false
}
}
</script>
<div class="col-4">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start">
<div class="min-w-0 flex-1 overflow-hidden border-base-300 bg-alt md:rounded-box md:border">
<div class="relative overflow-hidden border-b border-base-300 bg-base-300">
{#if $profile?.banner}
<img src={$profile.banner} alt="" class="h-28 w-full object-cover sm:h-32 md:h-40" />
{:else}
<div class="h-28 w-full bg-linear-to-br from-base-300 to-base-100 sm:h-32 md:h-40"></div>
{/if}
{#if isSelf}
<Button
class="btn btn-neutral btn-sm absolute top-2 right-2 sm:top-3 sm:right-3"
disabled={bannerLoading}
onclick={openBannerPicker}>
<Icon icon={GallerySend} size={4} />
<span class="hidden sm:inline">Change banner</span>
</Button>
<input
bind:this={bannerInput}
type="file"
accept="image/*"
class="hidden"
onchange={onBannerChange} />
{/if}
</div>
<div class="relative border-b border-base-300 px-4 pb-4 sm:px-3 sm:pb-5">
<div class="-mt-8 sm:-mt-10">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
<div class="w-fit shrink-0">
<div class="w-fit rounded-full sm:border-4 sm:border-base-200 sm:bg-base-200">
<ProfileCircle pubkey={target} size={16} class="sm:hidden" />
<ProfileCircle pubkey={target} size={20} class="hidden sm:block" />
</div>
</div>
<div class="flex min-w-0 flex-1 flex-col gap-3 sm:gap-2 sm:pt-14">
<div
class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-x-4">
<h1 class="min-w-0 text-xl leading-tight font-bold sm:text-2xl">
{$profileDisplay}
</h1>
{#if (isSelf || $pubkey) && $profile}
<div class="flex items-center gap-2">
{#if isSelf}
<Button
class="btn btn-primary btn-md flex-1 sm:btn-sm sm:flex-none"
onclick={openSettings}>
<Icon icon={PenNewSquare} size={4} />
Edit profile
</Button>
{:else}
<Button
class="btn btn-neutral btn-md flex-1 sm:btn-sm sm:flex-none"
onclick={toggleFollow}>
<Icon icon={UserPlus} size={4} />
{isFollowing ? "Unfollow" : "Follow"}
</Button>
<Button
class="btn btn-primary btn-md flex-1 sm:btn-sm sm:flex-none"
onclick={openChat}>
<Icon icon={Letter} size={4} />
Message
</Button>
{/if}
<div class="relative shrink-0">
<Button class="btn btn-circle btn-ghost btn-sm" onclick={toggleMenu}>
<Icon icon={MenuDots} />
</Button>
{#if showMenu}
<Popover hideOnClick onClose={closeMenu}>
<ul
transition:fly
class="bg-alt menu absolute right-0 z-popover w-48 gap-1 rounded-box p-2 shadow-md">
<li>
<Button onclick={showInfo}>
<Icon icon={Code2} />
User Details
</Button>
</li>
</ul>
</Popover>
{/if}
</div>
</div>
{/if}
</div>
<div class="flex items-center gap-1 text-sm leading-none opacity-75">
<span>{displayPubkey(target)}</span>
<Button onclick={copyNpub} class="btn btn-ghost btn-xs h-5 min-h-5 w-5 p-0">
<Icon size={3} icon={Copy} />
</Button>
<Button onclick={showQr} class="btn btn-ghost btn-xs h-5 min-h-5 w-5 p-0">
<Icon size={3} icon={QrCode} />
</Button>
</div>
{#if website}
<Link
external
href={websiteHref}
class="link link-primary row-2 w-fit text-sm font-medium">
<Icon icon={LinkRound} size={4} />
{website}
<Icon icon={SquareArrowRight} size={4} />
</Link>
{/if}
<div class="flex flex-wrap items-center gap-2">
<span class="badge badge-neutral inline-flex h-6 items-center gap-1.5 border-0">
<Icon icon={Shield} size={3} />
{#if isSelf}
{followerCount} {followerCount === 1 ? "follower" : "followers"}
{:else}
Trust score {displayScore}
{/if}
</span>
{#if spaceBadgeCount > 0}
<button
class="badge badge-neutral inline-flex h-6 items-center gap-1.5 border-0"
onclick={showSpacesTab}>
<Icon icon={UsersGroup} size={3} />
{spaceBadgeCount}
{#if isSelf}
{spaceBadgeCount === 1 ? "space" : "spaces"}
{:else}
shared {spaceBadgeCount === 1 ? "space" : "spaces"}
{/if}
</button>
{/if}
</div>
</div>
</div>
</div>
</div>
<div
class="sticky top-0 z-10 border-b border-base-300 bg-base-200/90 px-4 backdrop-blur-sm sm:px-3">
<div
role="tablist"
class="tabs tabs-bordered -mb-px flex w-full justify-between bg-transparent sm:justify-start">
<button
role="tab"
class={cx("tab flex-none px-0 sm:px-4", {"tab-active": tab === "about"})}
onclick={showAboutTab}>
About
</button>
<button
role="tab"
class={cx("tab flex-none px-0 sm:px-4", {"tab-active": tab === "notes"})}
onclick={showNotesTab}>
Notes
</button>
<button
role="tab"
class={cx("tab flex-none px-0 sm:px-4", {"tab-active": tab === "spaces"})}
onclick={showSpacesTab}>
Spaces
</button>
</div>
</div>
<div class="px-4 py-3 sm:px-3 sm:py-4">
<div class="sm:pl-3">
{#if tab === "about"}
<div class="col-3 sm:col-4">
<ProfileInfo pubkey={target} />
<div class="col-3 xl:hidden">
{@render profileAside()}
</div>
<ProfilePinnedNotes
pubkey={target}
limit={2}
editable={isSelf}
onViewAll={showNotesTab} />
</div>
{:else if tab === "notes"}
{#if isSelf}
<p class="mb-4 text-sm opacity-75">
Notes are public posts on your write relays. Pin notes to highlight them on your
profile, or manage relays in
<Button class="link link-primary" onclick={openRelaySettings}>relay settings</Button
Review

Remove this, we will eventually add the ability to publish notes, which will cover this.

Remove this, we will eventually add the ability to publish notes, which will cover this.
>.
</p>
{/if}
<ProfilePageNotes pubkey={target} editable={isSelf} />
{:else}
{#if isSelf}
<div class="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p class="text-sm opacity-75">Spaces come from your published group list.</p>
<Button class="btn btn-neutral btn-sm w-full sm:w-auto" onclick={openSpaces}>
Manage spaces
</Button>
</div>
{/if}
<ProfilePageSpaces pubkey={target} />
{/if}
</div>
</div>
</div>
<aside class="hidden w-80 shrink-0 xl:block xl:border-l xl:border-base-300 xl:pl-4">
<div class="col-3">
{@render profileAside()}
</div>
</aside>
</div>
</div>
{#snippet profileAside()}
<ProfileTrust pubkey={target} {isSelf} />
<ProfileSharedSpaces pubkey={target} {isSelf} limit={3} onViewAll={showSpacesTab} />
{/snippet}
@@ -0,0 +1,78 @@
<script lang="ts">
import {onMount} from "svelte"
import {sortBy, uniqBy} from "@welshman/lib"
import {feedFromFilter} from "@welshman/feeds"
import {NOTE, getReplyTags} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {makeFeedController} from "@welshman/app"
import {createScroller} from "@lib/html"
import {fly} from "@lib/transition"
import Spinner from "@lib/components/Spinner.svelte"
import NoteItem from "@app/components/NoteItem.svelte"
type Props = {
pubkey: string
editable?: boolean
}
const {pubkey, editable = false}: Props = $props()
let element: Element | undefined = $state()
let events: TrustedEvent[] = $state([])
let buffer: TrustedEvent[] = []
let exhausted = $state(false)
const ctrl = makeFeedController({
useWindowing: true,
feed: feedFromFilter({kinds: [NOTE], authors: [pubkey]}),
onEvent: (event: TrustedEvent) => {
if (getReplyTags(event.tags).replies.length === 0) {
buffer.push(event)
}
},
onExhausted: () => {
exhausted = true
},
})
onMount(() => {
const scroller = createScroller({
element: element!,
delay: 300,
threshold: 3000,
onScroll: () => {
buffer = uniqBy(
e => e.id,
sortBy(e => -e.created_at, buffer),
)
events = uniqBy(e => e.id, [...events, ...buffer.splice(0, 5)])
if (buffer.length < 50) {
ctrl.load(50)
}
},
})
return () => scroller.stop()
})
Review

Use makeFeed from app/feeds if possible.

Use makeFeed from app/feeds if possible.
</script>
<div class="col-4" bind:this={element}>
<div class="flex flex-col gap-2">
{#each events as event (event.id)}
<div in:fly>
<NoteItem {event} {editable} />
</div>
{:else}
{#if exhausted}
<p class="py-12 text-center text-sm opacity-75">No notes found for this profile.</p>
{/if}
{/each}
{#if !exhausted}
<p class="center my-12 flex">
<Spinner loading />
</p>
{/if}
</div>
</div>
@@ -0,0 +1,47 @@
<script lang="ts">
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import RelayIcon from "@app/components/RelayIcon.svelte"
import RelayName from "@app/components/RelayName.svelte"
import {deriveGroupList, getSpaceUrlsFromGroupList, groupListPubkeysByUrl} from "@app/groups"
import {makeSpacePath} from "@app/routes"
type Props = {
pubkey: string
}
const {pubkey}: Props = $props()
const groupList = deriveGroupList(pubkey)
const spaceUrls = $derived(getSpaceUrlsFromGroupList($groupList))
</script>
<div class="col-2">
{#each spaceUrls as url (url)}
{@const count = $groupListPubkeysByUrl.get(url)?.size || 0}
<div
class="card2 card2-sm bg-alt flex flex-col gap-3 border border-base-300 sm:flex-row sm:items-center">
<RelayIcon {url} size={10} />
<div class="min-w-0 flex-1">
<RelayName {url} class="font-medium" />
<p class="text-sm opacity-75">
{#if count >= 1000}
{(count / 1000).toFixed(1).replace(/\.0$/, "")}K members
{:else}
{count} {count === 1 ? "member" : "members"}
{/if}
</p>
<p class="ellipsize text-xs opacity-60">{url}</p>
</div>
<Link class="btn btn-primary btn-sm w-full sm:w-auto" href={makeSpacePath(url)}>
Go to space
<Icon icon={AltArrowRight} />
</Link>
</div>
{:else}
<div class="card2 bg-alt border border-base-300 text-center">
<p class="opacity-75">No spaces found for this user.</p>
</div>
{/each}
</div>
@@ -0,0 +1,112 @@
<script lang="ts">
import {derived} from "svelte/store"
import {sortBy} from "@welshman/lib"
import {getListTags, getEventTagValues} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {derivePinList, repository} from "@welshman/app"
import {Router} from "@welshman/router"
import {load} from "@welshman/net"
import {deriveEventsById, deriveEventsDesc} from "@welshman/store"
import {fly} from "@lib/transition"
import Spinner from "@lib/components/Spinner.svelte"
import NoteItem from "@app/components/NoteItem.svelte"
type Props = {
pubkey: string
limit?: number
onViewAll?: () => void
editable?: boolean
}
const {pubkey, limit, onViewAll, editable = false}: Props = $props()
const pinList = derivePinList(pubkey)
const pinnedIds = derived(pinList, $pinList => getEventTagValues(getListTags($pinList)))
const displayIds = derived(pinnedIds, $pinnedIds =>
limit ? $pinnedIds.slice(0, limit) : $pinnedIds,
)
const pinnedEvents = derived(
displayIds,
($displayIds, set) => {
if ($displayIds.length === 0) {
set([])
return
}
return deriveEventsDesc(
deriveEventsById({repository, filters: [{ids: $displayIds}]}),
).subscribe(events => {
set(sortBy(event => -$displayIds.indexOf(event.id), events))
})
},
[] as TrustedEvent[],
)
let fetching = $state(false)
$effect(() => {
const ids = $displayIds
if (ids.length === 0) {
fetching = false
return
}
const missing = ids.filter(id => !repository.getEvent(id))
if (missing.length === 0) {
fetching = false
return
}
fetching = true
const controller = new AbortController()
load({
relays: Router.get().FromPubkeys([pubkey]).getUrls(),
filters: [{ids: missing}],
signal: controller.signal,
onClose: () => {
fetching = false
},
})
return () => controller.abort()
})
const loading = $derived(
fetching || ($displayIds.length > 0 && $pinnedEvents.length < $displayIds.length),
)
</script>
{#if $displayIds.length > 0 || loading}
<div class="col-4 border-t border-base-300 pt-4">
<strong>Pinned notes</strong>
{#if loading && $pinnedEvents.length === 0}
<p class="center flex py-8">
<Spinner loading />
</p>
{:else if $pinnedEvents.length > 0}
<div class="col-2">
{#each $pinnedEvents as event (event.id)}
<div in:fly>
<NoteItem {event} {editable} />
</div>
{/each}
</div>
{#if onViewAll && limit && $pinnedIds.length > limit}
<button class="link link-primary row-2 text-sm" onclick={onViewAll}>
View all pinned notes
<span aria-hidden="true"></span>
</button>
{/if}
{/if}
</div>
{/if}
+35
View File
@@ -0,0 +1,35 @@
<script lang="ts">
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import QRCode from "@app/components/QRCode.svelte"
type Props = {
code: string
}
const {code}: Props = $props()
const back = () => history.back()
</script>
<Modal>
<ModalBody>
<div class="col-4 items-center text-center">
<strong>Profile QR Code</strong>
<QRCode {code} class="max-w-64" />
<p class="break-all text-sm opacity-75">{code}</p>
<p class="text-sm opacity-75">Tap the QR code to copy this npub.</p>
</div>
</ModalBody>
<ModalFooter>
<Button onclick={back} class="hidden md:btn md:btn-link">
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button onclick={back} class="btn btn-neutral">Close</Button>
</ModalFooter>
</Modal>
@@ -0,0 +1,76 @@
<script lang="ts">
import cx from "classnames"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import UsersGroup from "@assets/icons/users-group-rounded.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import RelayIcon from "@app/components/RelayIcon.svelte"
import RelayName from "@app/components/RelayName.svelte"
import {
groupListPubkeysByUrl,
userSpaceUrls,
deriveGroupList,
getSpaceUrlsFromGroupList,
} from "@app/groups"
import {makeSpacePath} from "@app/routes"
type Props = {
pubkey: string
isSelf?: boolean
limit?: number
onViewAll?: () => void
class?: string
}
const {pubkey, isSelf = false, limit, onViewAll, ...props}: Props = $props()
const groupList = deriveGroupList(pubkey)
const spaceUrls = $derived(getSpaceUrlsFromGroupList($groupList))
const sharedSpaceUrls = $derived($userSpaceUrls.filter(url => spaceUrls.includes(url)))
const listedSpaceUrls = $derived(isSelf ? spaceUrls : sharedSpaceUrls)
const displayUrls = $derived(limit ? listedSpaceUrls.slice(0, limit) : listedSpaceUrls)
</script>
<div class={cx("card2 bg-alt col-3 border border-base-300 max-sm:p-5 sm:col-4", props.class)}>
<div class="flex items-center justify-between gap-2">
<div class="row-2">
<Icon icon={UsersGroup} size={5} />
<strong>{isSelf ? "Your spaces" : "Shared spaces"}</strong>
</div>
<span class="badge badge-neutral">{listedSpaceUrls.length}</span>
</div>
{#if displayUrls.length > 0}
<div class="col-2 border-t border-base-300 pt-4 sm:pt-4">
{#each displayUrls as url (url)}
{@const count = $groupListPubkeysByUrl.get(url)?.size || 0}
<Link
href={makeSpacePath(url)}
class="row-2 rounded-box border border-base-300 p-4 transition-colors hover:bg-base-300/30 sm:p-3">
<RelayIcon {url} size={8} />
<div class="min-w-0 flex-1">
<RelayName {url} class="ellipsize text-sm font-medium" />
<p class="text-xs opacity-75">
{#if count >= 1000}
{(count / 1000).toFixed(1).replace(/\.0$/, "")}K members
{:else}
{count} {count === 1 ? "member" : "members"}
{/if}
</p>
</div>
</Link>
{/each}
</div>
{#if onViewAll && listedSpaceUrls.length > (limit || listedSpaceUrls.length)}
<button
class="link link-primary row-2 border-t border-base-300 pt-4 text-sm max-sm:pt-4"
onclick={onViewAll}>
{isSelf ? "View all your spaces" : "View all shared spaces"}
<Icon icon={AltArrowRight} size={4} />
</button>
{/if}
{:else}
<p class="border-t border-base-300 pt-4 text-sm opacity-75 max-sm:pt-4">
{isSelf ? "You aren't in any spaces yet." : "No shared spaces yet."}
</p>
{/if}
</div>
+113
View File
@@ -0,0 +1,113 @@
<script lang="ts">
import {clamp, uniq} from "@welshman/lib"
import {
pubkey,
followLists,
userFollowList,
deriveUserWotScore,
deriveProfileDisplay,
deriveFollowList,
followersByPubkey,
loadFollowList,
getFollows,
getFollowers,
} from "@welshman/app"
import Shield from "@assets/icons/shield-minimalistic.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte"
type Props = {
pubkey: string
isSelf?: boolean
}
const {pubkey: target, isSelf = false}: Props = $props()
const score = deriveUserWotScore(target)
const profileDisplay = deriveProfileDisplay(target)
const targetFollowList = deriveFollowList(target)
$effect(() => {
if (isSelf) return
loadFollowList(target)
const viewer = $pubkey
if (viewer) {
loadFollowList(viewer)
}
})
const followerCount = $derived.by(() => {
void $followersByPubkey
return getFollowers(target).length
})
const mutualFollows = $derived.by(() => {
if (isSelf) return []
const viewer = $pubkey
void $followLists
void $targetFollowList
void $userFollowList
if (!viewer) return []
const viewerFollows = new Set(getFollows(viewer))
return uniq(
getFollows(target).filter(pk => pk !== viewer && pk !== target && viewerFollows.has(pk)),
)
})
const displayScore = $derived(isSelf ? followerCount : Math.round(clamp([0, 100], $score)))
const progress = $derived(isSelf ? undefined : displayScore)
const trustMessage = $derived.by(() => {
if (isSelf) {
if (followerCount === 0) return "No one follows you in the network we know about yet."
return `${followerCount} ${followerCount === 1 ? "person follows" : "people follow"} you on the network we know about.`
}
if (displayScore >= 70) return "This user is highly trusted in your network."
if (displayScore >= 30) return "This user has some trust in your network."
return "This user is not well known in your network."
})
</script>
<div class="card2 bg-alt col-3 border border-base-300 max-sm:p-5 sm:col-4">
<div class="row-2">
<Icon icon={Shield} size={5} />
<strong>Reputation</strong>
</div>
<div class="col-2 border-t border-base-300 pt-4 sm:pt-4">
<div class="flex items-end justify-between gap-2">
<span class="text-sm opacity-75">{isSelf ? "Followers" : "Trust score"}</span>
<span class="text-lg font-semibold">
{#if isSelf}
{displayScore}
{:else}
{displayScore} / 100
{/if}
</span>
</div>
{#if !isSelf}
<progress class="progress progress-primary w-full" value={progress} max="100"></progress>
{/if}
<p class="text-sm opacity-75">{trustMessage}</p>
</div>
{#if mutualFollows.length > 0}
<div class="col-2 border-t border-base-300 pt-4 sm:pt-4">
<p class="text-sm font-medium">Mutual follows</p>
<ProfileCircles pubkeys={mutualFollows} limit={5} />
<p class="text-sm opacity-75">
{mutualFollows.length}
{mutualFollows.length === 1 ? "person" : "people"} you and {$profileDisplay} both follow.
</p>
</div>
{/if}
</div>
+6
View File
@@ -44,6 +44,12 @@ export const setupHistory = () =>
}
})
// Profiles
export const makeProfilePath = (pubkey: string) => `/people/${nip19.npubEncode(pubkey)}`
export const goToProfile = (pubkey: string) => goto(makeProfilePath(pubkey))
// Chat
export const makeChatPath = (pubkeys: string[]) => `/chat/${makeChatId(pubkeys)}`
+16
View File
@@ -37,6 +37,8 @@ import {
loadFollowList,
loadMuteList,
loadProfile,
userFollowList,
getFollows,
repository,
shouldUnwrap,
hasNegentropy,
@@ -248,6 +250,17 @@ const syncUserData = () => {
loadFeedsForPubkey(pubkey)
}
const syncFollowNetwork = ($userFollowList: List | undefined) => {
const author = $userFollowList?.event?.pubkey
if (!author) return
for (const follow of getFollows(author)) {
loadFollowList(follow)
loadMuteList(follow)
}
}
const unsubscribeGroupList = merged([userGroupList]).subscribe(([$userGroupList]) => {
syncGroupList($userGroupList)
})
@@ -256,10 +269,13 @@ const syncUserData = () => {
syncRelayList($userRelayList)
})
const unsubscribeFollowList = userFollowList.subscribe(syncFollowNetwork)
return () => {
unsubscribersByKey.forEach(call)
unsubscribeGroupList()
unsubscribeRelayList()
unsubscribeFollowList()
}
}
+20 -1
View File
@@ -1,5 +1,6 @@
import {append, identity, uniq} from "@welshman/lib"
import {repository} from "@welshman/app"
import * as nip19 from "nostr-tools/nip19"
import {repository, displayProfileByPubkey} from "@welshman/app"
import {displayPubkey, getTagValue} from "@welshman/util"
import {PLATFORM_NAME} from "@app/env"
import {decodeRelay} from "@app/relays"
@@ -120,6 +121,24 @@ export const getPageTitle = ({page, pubkey}: PageTitleContext) => {
return makeTitle(getChatTitle(page.params.chat, pubkey))
}
if (routeId === "/people/[npub]") {
try {
const decoded = nip19.decode(page.params.npub!)
const profilePubkey =
decoded.type === "npub"
? decoded.data
: decoded.type === "nprofile"
? decoded.data.pubkey
: undefined
if (profilePubkey) {
return makeTitle(displayProfileByPubkey(profilePubkey))
}
} catch {
// fall through
}
}
if (routeId === "/spaces/[relay]/[h]") {
return makeTitle(getRoomTitle(page.params))
}
+9 -1
View File
@@ -8,13 +8,21 @@
import {page} from "$app/stores"
import {goto} from "$app/navigation"
import Spinner from "@lib/components/Spinner.svelte"
import {goToEvent} from "@app/routes"
import {goToEvent, makeProfilePath} from "@app/routes"
const {bech32} = $page.params as MakeNonOptional<typeof $page.params>
const attemptToNavigate = async () => {
const {type, data} = nip19.decode(bech32) as any
if (type === "npub") {
return goto(makeProfilePath(data), {replaceState: true})
}
if (type === "nprofile") {
return goto(makeProfilePath(data.pubkey), {replaceState: true})
}
if (!["nevent", "naddr"].includes(type) && data.relays.length > 0) {
return goto("/", {replaceState: true})
}
+89
View File
@@ -0,0 +1,89 @@
<script lang="ts">
import {onMount} from "svelte"
import {get} from "svelte/store"
import * as nip19 from "nostr-tools/nip19"
import {page} from "$app/stores"
import {goto} from "$app/navigation"
import type {MakeNonOptional} from "@welshman/lib"
import type {Filter} from "@welshman/util"
import {ROOMS, NOTE, FOLLOWS} from "@welshman/util"
import {
loadProfile,
loadRelayList,
loadFollowList,
loadMessagingRelayList,
loadPinList,
pubkey as sessionPubkey,
} from "@welshman/app"
import {load} from "@welshman/net"
import {Router} from "@welshman/router"
import Page from "@lib/components/Page.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ProfilePage from "@app/components/ProfilePage.svelte"
import {loadGroupList} from "@app/groups"
const {npub} = $page.params as MakeNonOptional<typeof $page.params>
let pubkey = $state<string | undefined>()
let ready = $state(false)
onMount(async () => {
try {
const decoded = nip19.decode(npub)
if (decoded.type === "npub") {
pubkey = decoded.data
} else if (decoded.type === "nprofile") {
pubkey = decoded.data.pubkey
} else {
goto("/people", {replaceState: true})
return
}
Review

We repeat this decoding logic in several places, just add a utility somewhere to extract the pubkey from a nip19 entity, returning undefined if unable.

We repeat this decoding logic in several places, just add a utility somewhere to extract the pubkey from a nip19 entity, returning undefined if unable.
await loadProfile(pubkey)
await loadRelayList(pubkey)
const viewer = get(sessionPubkey)
await Promise.all([
loadFollowList(pubkey),
loadPinList(pubkey),
loadGroupList(pubkey),
loadMessagingRelayList(pubkey),
viewer && viewer !== pubkey ? loadFollowList(viewer) : undefined,
])
const filters: Filter[] = [
{authors: [pubkey], kinds: [ROOMS]},
{authors: [pubkey], kinds: [NOTE], limit: 1},
]
if (get(sessionPubkey) === pubkey) {
filters.push({kinds: [FOLLOWS], "#p": [pubkey], limit: 500})
}
load({
relays: Router.get().FromPubkeys([pubkey]).getUrls(),
filters,
})
ready = true
} catch {
goto("/people", {replaceState: true})
}
})
</script>
<Page>
<PageContent class="p-0 md:p-4">
{#if ready && pubkey}
<ProfilePage {pubkey} />
{:else}
<p class="center flex py-20">
<Spinner loading />
</p>
{/if}
</PageContent>
</Page>