Compare commits

..

1 Commits

Author SHA1 Message Date
userAdityaa 5a804d094f feat: add full profile page at /people/[npub] 2026-06-18 16:22:04 +05:30
7 changed files with 88 additions and 173 deletions
+36 -33
View File
@@ -10,9 +10,7 @@
deriveProfile,
deriveProfileDisplay,
deriveUserWotScore,
followersByPubkey,
getFollows,
getFollowers,
follow,
unfollow,
tagPubkey,
@@ -25,6 +23,7 @@
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 Settings from "@assets/icons/settings.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"
@@ -42,6 +41,7 @@
import ProfilePinnedNotes from "@app/components/ProfilePinnedNotes.svelte"
import ProfilePageNotes from "@app/components/ProfilePageNotes.svelte"
import ProfilePageSpaces from "@app/components/ProfilePageSpaces.svelte"
import ProfileEdit from "@app/components/ProfileEdit.svelte"
import ProfileQrCode from "@app/components/ProfileQrCode.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import {deriveGroupList, getSpaceUrlsFromGroupList, userSpaceUrls} from "@app/groups"
@@ -65,11 +65,6 @@
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
@@ -77,8 +72,7 @@
})
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 displayScore = $derived(Math.round(clamp([0, 100], $score)))
const website = $derived($profile?.website?.replace(/^https?:\/\//, ""))
const websiteHref = $derived(
$profile?.website?.match(/^https?:\/\//)
@@ -118,6 +112,8 @@
pushModal(EventInfo, {event: $profile!.event})
}
const startEdit = () => pushModal(ProfileEdit)
const openSettings = () => goto("/settings/profile")
const openSpaces = () => goto("/spaces")
@@ -191,10 +187,19 @@
<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>
{#if isSelf}
<Button
class="w-fit rounded-full sm:border-4 sm:border-base-200 sm:bg-base-200"
onclick={startEdit}>
<ProfileCircle pubkey={target} size={16} class="sm:hidden" />
<ProfileCircle pubkey={target} size={20} class="hidden sm:block" />
</Button>
{:else}
<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>
{/if}
</div>
<div class="flex min-w-0 flex-1 flex-col gap-3 sm:gap-2 sm:pt-14">
@@ -209,7 +214,7 @@
{#if isSelf}
<Button
class="btn btn-primary btn-md flex-1 sm:btn-sm sm:flex-none"
onclick={openSettings}>
onclick={startEdit}>
<Icon icon={PenNewSquare} size={4} />
Edit profile
</Button>
@@ -243,6 +248,14 @@
User Details
</Button>
</li>
{#if isSelf}
<li>
<Button onclick={openSettings}>
<Icon icon={Settings} />
Account Settings
</Button>
</li>
{/if}
</ul>
</Popover>
{/if}
@@ -275,23 +288,16 @@
<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}
Trust score {displayScore}
</span>
{#if spaceBadgeCount > 0}
{#if sharedSpaceUrls.length > 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}
{sharedSpaceUrls.length} shared {sharedSpaceUrls.length === 1
? "space"
: "spaces"}
</button>
{/if}
</div>
@@ -332,7 +338,8 @@
<div class="col-3 sm:col-4">
<ProfileInfo pubkey={target} />
<div class="col-3 xl:hidden">
{@render profileAside()}
<ProfileTrust pubkey={target} />
<ProfileSharedSpaces pubkey={target} limit={3} onViewAll={showSpacesTab} />
</div>
<ProfilePinnedNotes
pubkey={target}
@@ -367,13 +374,9 @@
<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()}
<ProfileTrust pubkey={target} />
<ProfileSharedSpaces pubkey={target} limit={3} onViewAll={showSpacesTab} />
</div>
</aside>
</div>
</div>
{#snippet profileAside()}
<ProfileTrust pubkey={target} {isSelf} />
<ProfileSharedSpaces pubkey={target} {isSelf} limit={3} onViewAll={showSpacesTab} />
{/snippet}
+7 -17
View File
@@ -17,11 +17,6 @@
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]}),
@@ -30,11 +25,12 @@
buffer.push(event)
}
},
onExhausted: () => {
exhausted = true
},
})
let element: Element | undefined = $state()
let events: TrustedEvent[] = $state([])
let buffer: TrustedEvent[] = []
onMount(() => {
const scroller = createScroller({
element: element!,
@@ -64,15 +60,9 @@
<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}
<p class="center my-12 flex">
<Spinner loading />
</p>
</div>
</div>
+26 -50
View File
@@ -1,12 +1,11 @@
<script lang="ts">
import {derived} from "svelte/store"
import {onMount} from "svelte"
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"
@@ -21,87 +20,64 @@
const {pubkey, limit, onViewAll, editable = false}: Props = $props()
const pinList = derivePinList(pubkey)
const pinnedIds = $derived(getEventTagValues(getListTags($pinList)))
const displayIds = $derived(limit ? pinnedIds.slice(0, limit) : pinnedIds)
const pinnedIds = derived(pinList, $pinList => getEventTagValues(getListTags($pinList)))
const pinnedEvents = $derived.by(() => {
return sortBy(
e => -pinnedIds.indexOf(e.id),
displayIds
.map(id => repository.getEvent(id))
.filter((event): event is TrustedEvent => Boolean(event)),
)
})
const displayIds = derived(pinnedIds, $pinnedIds =>
limit ? $pinnedIds.slice(0, limit) : $pinnedIds,
)
let loading = $state(pinnedIds.length > 0)
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
onMount(() => {
if (pinnedIds.length === 0) {
loading = false
return
}
const missing = ids.filter(id => !repository.getEvent(id))
const missing = pinnedIds.filter(id => !repository.getEvent(id))
if (missing.length === 0) {
fetching = false
loading = false
return
}
fetching = true
const controller = new AbortController()
load({
relays: Router.get().FromPubkeys([pubkey]).getUrls(),
filters: [{ids: missing}],
signal: controller.signal,
onEvent: () => {
loading = !pinnedIds.every(id => repository.getEvent(id))
},
onClose: () => {
fetching = false
loading = false
},
})
return () => controller.abort()
})
const loading = $derived(
fetching || ($displayIds.length > 0 && $pinnedEvents.length < $displayIds.length),
)
</script>
{#if $displayIds.length > 0 || loading}
{#if pinnedIds.length > 0}
<div class="col-4 border-t border-base-300 pt-4">
<strong>Pinned notes</strong>
{#if loading && $pinnedEvents.length === 0}
{#if loading && pinnedEvents.length === 0}
<p class="center flex py-8">
<Spinner loading />
</p>
{:else if $pinnedEvents.length > 0}
{:else}
<div class="col-2">
{#each $pinnedEvents as event (event.id)}
{#each pinnedEvents as event (event.id)}
<div in:fly>
<NoteItem {event} {editable} />
</div>
{/each}
</div>
{#if onViewAll && limit && $pinnedIds.length > limit}
{#if onViewAll && pinnedIds.length > (limit || pinnedIds.length)}
<button class="link link-primary row-2 text-sm" onclick={onViewAll}>
View all pinned notes
<span aria-hidden="true"></span>
@@ -16,28 +16,26 @@
type Props = {
pubkey: string
isSelf?: boolean
limit?: number
onViewAll?: () => void
class?: string
}
const {pubkey, isSelf = false, limit, onViewAll, ...props}: Props = $props()
const {pubkey, 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)
const displayUrls = $derived(limit ? sharedSpaceUrls.slice(0, limit) : sharedSpaceUrls)
</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>
<strong>Shared spaces</strong>
</div>
<span class="badge badge-neutral">{listedSpaceUrls.length}</span>
<span class="badge badge-neutral">{sharedSpaceUrls.length}</span>
</div>
{#if displayUrls.length > 0}
<div class="col-2 border-t border-base-300 pt-4 sm:pt-4">
@@ -60,17 +58,17 @@
</Link>
{/each}
</div>
{#if onViewAll && listedSpaceUrls.length > (limit || listedSpaceUrls.length)}
{#if onViewAll && sharedSpaceUrls.length > (limit || sharedSpaceUrls.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"}
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."}
No shared spaces yet.
</p>
{/if}
</div>
+6 -47
View File
@@ -3,14 +3,10 @@
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"
@@ -18,40 +14,18 @@
type Props = {
pubkey: string
isSelf?: boolean
}
const {pubkey: target, isSelf = false}: Props = $props()
const {pubkey: target}: 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 []
@@ -61,17 +35,10 @@
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 displayScore = $derived(Math.round(clamp([0, 100], $score)))
const progress = $derived(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."
@@ -86,18 +53,10 @@
</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>
<span class="text-sm opacity-75">Trust score</span>
<span class="text-lg font-semibold">{displayScore} / 100</span>
</div>
{#if !isSelf}
<progress class="progress progress-primary w-full" value={progress} max="100"></progress>
{/if}
<progress class="progress progress-primary w-full" value={progress} max="100"></progress>
<p class="text-sm opacity-75">{trustMessage}</p>
</div>
{#if mutualFollows.length > 0}
+1
View File
@@ -25,6 +25,7 @@ const staticTitles = new Map<string, string>([
["/chat", "Messages"],
["/join", "Join Space"],
["/people", "Find People"],
["/people/[npub]", "Profile"],
["/settings/about", "About"],
["/settings/profile", "Profile Settings"],
["/settings/content", "Content Settings"],
+5 -17
View File
@@ -1,22 +1,19 @@
<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 {ROOMS, NOTE} from "@welshman/util"
import Page from "@lib/components/Page.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import Spinner from "@lib/components/Spinner.svelte"
@@ -45,28 +42,19 @@
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,
filters: [
{authors: [pubkey], kinds: [ROOMS]},
{authors: [pubkey], kinds: [NOTE], limit: 1},
],
})
ready = true