From 0b26eb8058a43bce55d64cedcf1c901bed2607dd Mon Sep 17 00:00:00 2001 From: userAdityaa Date: Thu, 18 Jun 2026 16:22:04 +0530 Subject: [PATCH] feat: add full profile page at /people/[npub] --- src/app/components/ChatStart.svelte | 23 +- src/app/components/PeopleItem.svelte | 15 +- .../PrimaryNavSpacesOverflow.svelte | 3 +- src/app/components/ProfileBadges.svelte | 2 - src/app/components/ProfileCircles.svelte | 16 +- src/app/components/ProfileDetail.svelte | 70 ++--- src/app/components/ProfileEditForm.svelte | 19 ++ src/app/components/ProfileMultiSelect.svelte | 20 +- src/app/components/ProfilePage.svelte | 271 ++++++++++++++++++ src/app/components/ProfilePageNotes.svelte | 79 +++++ src/app/components/ProfilePinnedNotes.svelte | 96 +++++++ src/app/components/ProfileQrCode.svelte | 83 ++++++ src/app/components/ProfileSharedSpaces.svelte | 69 +++++ src/app/components/ProfileTrust.svelte | 121 ++++++++ src/app/components/ReactionSummary.svelte | 8 +- src/app/components/VideoCallContent.svelte | 3 +- src/app/groups.ts | 10 +- src/app/routes.ts | 6 + src/app/sync.ts | 16 ++ src/app/title.ts | 11 +- src/lib/components/ImageIcon.svelte | 16 +- src/lib/components/base.css | 2 +- src/lib/components/button.css | 9 +- src/lib/components/tooltip.css | 2 +- src/lib/util.ts | 26 +- src/routes/[bech32]/+page.svelte | 9 +- src/routes/people/[npub]/+page.svelte | 87 ++++++ src/routes/spaces/[relay]/chat/+page.svelte | 129 ++++----- 28 files changed, 1025 insertions(+), 196 deletions(-) create mode 100644 src/app/components/ProfilePage.svelte create mode 100644 src/app/components/ProfilePageNotes.svelte create mode 100644 src/app/components/ProfilePinnedNotes.svelte create mode 100644 src/app/components/ProfileQrCode.svelte create mode 100644 src/app/components/ProfileSharedSpaces.svelte create mode 100644 src/app/components/ProfileTrust.svelte create mode 100644 src/routes/people/[npub]/+page.svelte diff --git a/src/app/components/ChatStart.svelte b/src/app/components/ChatStart.svelte index 454e9741..b13b4625 100644 --- a/src/app/components/ChatStart.svelte +++ b/src/app/components/ChatStart.svelte @@ -1,10 +1,9 @@
- +
- +
diff --git a/src/app/components/PrimaryNavSpacesOverflow.svelte b/src/app/components/PrimaryNavSpacesOverflow.svelte index 4c7387c7..c434b866 100644 --- a/src/app/components/PrimaryNavSpacesOverflow.svelte +++ b/src/app/components/PrimaryNavSpacesOverflow.svelte @@ -8,7 +8,8 @@ const {urls}: Props = $props() -
+
{#each urls as url (url)} {/each} diff --git a/src/app/components/ProfileBadges.svelte b/src/app/components/ProfileBadges.svelte index ca89f00f..6c3c2c8d 100644 --- a/src/app/components/ProfileBadges.svelte +++ b/src/app/components/ProfileBadges.svelte @@ -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]}, diff --git a/src/app/components/ProfileCircles.svelte b/src/app/components/ProfileCircles.svelte index 2eecd0a9..bab5c875 100644 --- a/src/app/components/ProfileCircles.svelte +++ b/src/app/components/ProfileCircles.svelte @@ -1,6 +1,6 @@ diff --git a/src/app/components/ProfileDetail.svelte b/src/app/components/ProfileDetail.svelte index 884ad3fb..c7dde61f 100644 --- a/src/app/components/ProfileDetail.svelte +++ b/src/app/components/ProfileDetail.svelte @@ -1,17 +1,13 @@ + +
+
+
+
+ {#if $profile?.banner} + + {:else} +
+
+ {/if} + {#if isSelf} + + + {/if} +
+ +
+
+
+
+
+ +
+
+ +
+
+

+ {$profileDisplay} + {#if !isSelf} + + {/if} +

+ + {#if $profile} +
+ {#if isSelf} + + {:else if $pubkey} + + + {/if} + +
+ + {#if showMenu} + + + + {/if} +
+
+ {/if} +
+ +
+ {displayPubkey(target)} + +
+ + {#if website} + + + {website} + + {/if} + + {#if $profile?.about} + + {/if} +
+
+
+
+ +
+
+
+ + +
+ + +
+
+
+ + +
+
diff --git a/src/app/components/ProfilePageNotes.svelte b/src/app/components/ProfilePageNotes.svelte new file mode 100644 index 00000000..bc24252f --- /dev/null +++ b/src/app/components/ProfilePageNotes.svelte @@ -0,0 +1,79 @@ + + +
+ {#each $events as event (event.id)} +
+ +
+ {:else} + {#if exhausted} +

No notes found for this profile.

+ {/if} + {/each} + {#if !exhausted} +

+ +

+ {/if} +
diff --git a/src/app/components/ProfilePinnedNotes.svelte b/src/app/components/ProfilePinnedNotes.svelte new file mode 100644 index 00000000..088fe0fd --- /dev/null +++ b/src/app/components/ProfilePinnedNotes.svelte @@ -0,0 +1,96 @@ + + +{#if $pinnedIds.length > 0 || loading} + {#if loading && $pinnedEvents.length === 0} +

+ +

+ {:else if $pinnedEvents.length > 0} +
+ {#each $pinnedEvents as event (event.id)} +
+ +
+ {/each} +
+ {/if} +{/if} diff --git a/src/app/components/ProfileQrCode.svelte b/src/app/components/ProfileQrCode.svelte new file mode 100644 index 00000000..63a504af --- /dev/null +++ b/src/app/components/ProfileQrCode.svelte @@ -0,0 +1,83 @@ + + + + +
+ Share Profile +
+ +
+
+ {#if canShare} + + {/if} + +
+

Tap the QR code to copy this profile link.

+
+
+ + + +
diff --git a/src/app/components/ProfileSharedSpaces.svelte b/src/app/components/ProfileSharedSpaces.svelte new file mode 100644 index 00000000..3426731e --- /dev/null +++ b/src/app/components/ProfileSharedSpaces.svelte @@ -0,0 +1,69 @@ + + +
+
+
+ + Spaces +
+ {spaceUrls.length} +
+ {#if spaceUrls.length > 0} +
+ {#each spaceUrls as url (url)} + {@const count = $groupListPubkeysByUrl.get(url)?.size || 0} + {@const isMember = !isSelf && $userSpaceUrls.includes(url)} + + +
+
+ + {#if isMember} + Member + {/if} +
+

+ {#if count >= 1000} + {(count / 1000).toFixed(1).replace(/\.0$/, "")}K members + {:else} + {count} {count === 1 ? "member" : "members"} + {/if} +

+
+ + {/each} +
+ {:else} +

+ {isSelf ? "You aren't in any spaces yet." : "No spaces found."} +

+ {/if} +
diff --git a/src/app/components/ProfileTrust.svelte b/src/app/components/ProfileTrust.svelte new file mode 100644 index 00000000..ac3cb6df --- /dev/null +++ b/src/app/components/ProfileTrust.svelte @@ -0,0 +1,121 @@ + + +
+
+ + Reputation +
+
+
+ {isSelf ? "Followers" : "Trust score"} + + {#if isSelf} + {displayScore} + {:else} + {displayScore} / 100 + {/if} + +
+ {#if !isSelf} + + {/if} +

{trustMessage}

+
+ {#if followsWhoFollow.length > 0} +
+

People who follow {$profileDisplay}

+ +

+ {followsWhoFollow.length} + {followsWhoFollow.length === 1 ? "person" : "people"} you follow also follow + {$profileDisplay}. +

+
+ {/if} +
diff --git a/src/app/components/ReactionSummary.svelte b/src/app/components/ReactionSummary.svelte index 554eb0f1..55e0df87 100644 --- a/src/app/components/ReactionSummary.svelte +++ b/src/app/components/ReactionSummary.svelte @@ -17,6 +17,7 @@ import type {TrustedEvent, EventContent, Zap} from "@welshman/util" import {deriveArray, deriveEventsById, deriveItemsByKey} from "@welshman/store" import {load} from "@welshman/net" + import {Router} from "@welshman/router" import {pubkey, repository, getValidZap, displayProfileByPubkey} from "@welshman/app" import {isMobile} from "@lib/html" import Danger from "@assets/icons/danger-triangle.svg?dataurl" @@ -113,15 +114,16 @@ onMount(() => { const controller = new AbortController() + const relays = url ? [url] : Router.get().ForUser().getUrls() - if (url) { + if (relays.length > 0) { load({ - relays: [url], + relays, signal: controller.signal, filters: getReplyFilters([event], {kinds: REACTION_KINDS}), onEvent: batch(300, (events: TrustedEvent[]) => { load({ - relays: [url], + relays, filters: getReplyFilters(events, {kinds: [DELETE]}), }) }), diff --git a/src/app/components/VideoCallContent.svelte b/src/app/components/VideoCallContent.svelte index ce2018a3..45be792f 100644 --- a/src/app/components/VideoCallContent.svelte +++ b/src/app/components/VideoCallContent.svelte @@ -239,7 +239,8 @@ size={3} />
{/if} - + {labelFor(tile.liveKitIdentity, tile.source)}{tile.isLocal ? " (you)" : ""} {#if videoTiles.length > 1} diff --git a/src/app/groups.ts b/src/app/groups.ts index 8ef0ec21..ba8fd9af 100644 --- a/src/app/groups.ts +++ b/src/app/groups.ts @@ -56,7 +56,6 @@ import { spec, tryCatch, uniq, - uniqBy, } from "@welshman/lib" import {load} from "@welshman/net" import {Router} from "@welshman/router" @@ -214,14 +213,14 @@ export const groupListPubkeysByUrl = derived(groupListsByPubkey, $groupListsByPu const tags = getListTags(list) for (const url of getRelayTagValues(tags)) { - addToMapKey(result, url, list.event.pubkey) + addToMapKey(result, normalizeRelayUrl(url), list.event.pubkey) } for (const tag of getGroupTags(tags)) { const url = tag[2] || "" if (isRelayUrl(url)) { - addToMapKey(result, url, list.event.pubkey) + addToMapKey(result, normalizeRelayUrl(url), list.event.pubkey) } } } @@ -244,14 +243,15 @@ export const getSpaceUrlsFromGroupList = (groupList: List | undefined) => { } } - return uniqBy(normalizeRelayUrl, urls) + return uniq(urls.map(normalizeRelayUrl)) } export const getSpaceRoomsFromGroupList = (url: string, groupList: List | undefined) => { const rooms: string[] = [] + const normalizedUrl = normalizeRelayUrl(url) for (const [_, h, relay] of getGroupTags(getListTags(groupList))) { - if (url === relay) { + if (normalizedUrl === normalizeRelayUrl(relay)) { rooms.push(h) } } diff --git a/src/app/routes.ts b/src/app/routes.ts index b44873bc..23d223e9 100644 --- a/src/app/routes.ts +++ b/src/app/routes.ts @@ -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)}` diff --git a/src/app/sync.ts b/src/app/sync.ts index 07abb6d8..3ecbc5f2 100644 --- a/src/app/sync.ts +++ b/src/app/sync.ts @@ -38,6 +38,8 @@ import { loadFollowList, loadMuteList, loadProfile, + userFollowList, + getFollows, repository, shouldUnwrap, hasNegentropy, @@ -251,6 +253,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) }) @@ -259,10 +272,13 @@ const syncUserData = () => { syncRelayList($userRelayList) }) + const unsubscribeFollowList = userFollowList.subscribe(syncFollowNetwork) + return () => { unsubscribersByKey.forEach(call) unsubscribeGroupList() unsubscribeRelayList() + unsubscribeFollowList() } } diff --git a/src/app/title.ts b/src/app/title.ts index fdc29f16..c5399b68 100644 --- a/src/app/title.ts +++ b/src/app/title.ts @@ -1,7 +1,8 @@ import {append, identity, uniq} from "@welshman/lib" -import {repository} from "@welshman/app" +import {repository, displayProfileByPubkey} from "@welshman/app" import {displayPubkey, getTagValue} from "@welshman/util" import {PLATFORM_NAME} from "@app/env" +import {decodePubkey} from "@lib/util" import {decodeRelay} from "@app/relays" import {getRoom, makeRoomId} from "@app/groups" import {splitChatId} from "@app/chats" @@ -120,6 +121,14 @@ export const getPageTitle = ({page, pubkey}: PageTitleContext) => { return makeTitle(getChatTitle(page.params.chat, pubkey)) } + if (routeId === "/people/[npub]") { + const profilePubkey = decodePubkey(page.params.npub!) + + if (profilePubkey) { + return makeTitle(displayProfileByPubkey(profilePubkey)) + } + } + if (routeId === "/spaces/[relay]/[h]") { return makeTitle(getRoomTitle(page.params)) } diff --git a/src/lib/components/ImageIcon.svelte b/src/lib/components/ImageIcon.svelte index cd96a9a7..a4eaf4b8 100644 --- a/src/lib/components/ImageIcon.svelte +++ b/src/lib/components/ImageIcon.svelte @@ -9,15 +9,17 @@ style?: string } - const {src, alt, size = 5, style = undefined, ...props}: Props = $props() + const {src, alt, size = 5, style, class: className}: Props = $props() + + const rem = size * 0.25 + + const imgStyle = $derived( + `width: ${rem}rem; height: ${rem}rem; min-width: ${rem}rem; min-height: ${rem}rem${style ? `; ${style}` : ""}`, + ) {#if src.includes("image/svg") || src.endsWith(".svg")} - + {:else} - + {/if} diff --git a/src/lib/components/base.css b/src/lib/components/base.css index f9e40674..06ab2a96 100644 --- a/src/lib/components/base.css +++ b/src/lib/components/base.css @@ -247,7 +247,7 @@ body.keyboard-open .room__compose { } .chat-editor .tiptap { /* standard input border, kept at 1px so the :focus border swap doesn't reflow */ - @apply bg-surface rounded-3xl border border-solid border-line; + @apply bg-surface border-line rounded-3xl border border-solid; } .note-editor .tiptap { --tiptap-object-bg: var(--surface); diff --git a/src/lib/components/button.css b/src/lib/components/button.css index 8e915f59..aa700742 100644 --- a/src/lib/components/button.css +++ b/src/lib/components/button.css @@ -92,14 +92,15 @@ /* sizes — height is explicit (not derived from padding/line-height) so sm/xs reliably change the button height even when the content is a fixed-size icon */ - .button, .button-md { - @apply rounded-2xl h-10 px-4; + .button, + .button-md { + @apply h-10 rounded-2xl px-4; } .button-sm { - @apply rounded-xl text-sm h-8 gap-1 px-4; + @apply h-8 gap-1 rounded-xl px-4 text-sm; } .button-xs { - @apply rounded-xl text-xs h-7 gap-1 px-2; + @apply h-7 gap-1 rounded-xl px-2 text-xs; } /* shapes */ diff --git a/src/lib/components/tooltip.css b/src/lib/components/tooltip.css index fa12b49b..c87975fe 100644 --- a/src/lib/components/tooltip.css +++ b/src/lib/components/tooltip.css @@ -4,7 +4,7 @@ &[data-tip]::before { content: attr(data-tip); - @apply bg-black text-white/90 absolute overflow-hidden rounded-xl text-xs text-ellipsis whitespace-nowrap opacity-0 px-2 py-1 z-tooltip max-w-32 pointer-events-none; + @apply z-tooltip pointer-events-none absolute max-w-32 overflow-hidden rounded-xl bg-black px-2 py-1 text-xs text-ellipsis whitespace-nowrap text-white/90 opacity-0; transition: opacity 0.15s ease; } diff --git a/src/lib/util.ts b/src/lib/util.ts index 3c48238a..7fe88871 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -1,6 +1,28 @@ -import {identity} from "@welshman/lib" +import {identity, range, DAY, hexToBytes, bytesToHex} from "@welshman/lib" +import {fromNostrURI} from "@welshman/util" import * as nip19 from "nostr-tools/nip19" -import {range, DAY, hexToBytes, bytesToHex} from "@welshman/lib" + +export const decodePubkey = (entity: string): string | undefined => { + if (/^[a-f0-9]{64}$/i.test(entity)) { + return entity.toLowerCase() + } + + try { + const decoded = nip19.decode(fromNostrURI(entity)) + + if (decoded.type === "npub") { + return decoded.data + } + + if (decoded.type === "nprofile") { + return decoded.data.pubkey + } + + return undefined + } catch { + return undefined + } +} export const nsecEncode = (secret: string) => nip19.nsecEncode(hexToBytes(secret)) diff --git a/src/routes/[bech32]/+page.svelte b/src/routes/[bech32]/+page.svelte index 7d875ee0..e72465a3 100644 --- a/src/routes/[bech32]/+page.svelte +++ b/src/routes/[bech32]/+page.svelte @@ -7,12 +7,19 @@ import {load, LOCAL_RELAY_URL} from "@welshman/net" import {page} from "$app/stores" import {goto} from "$app/navigation" + import {decodePubkey} from "@lib/util" import Spinner from "@lib/components/Spinner.svelte" - import {goToEvent} from "@app/routes" + import {goToEvent, makeProfilePath} from "@app/routes" const {bech32} = $page.params as MakeNonOptional const attemptToNavigate = async () => { + const profilePubkey = decodePubkey(bech32) + + if (profilePubkey) { + return goto(makeProfilePath(profilePubkey), {replaceState: true}) + } + const {type, data} = nip19.decode(bech32) as any if (!["nevent", "naddr"].includes(type) && data.relays.length > 0) { diff --git a/src/routes/people/[npub]/+page.svelte b/src/routes/people/[npub]/+page.svelte new file mode 100644 index 00000000..8377e6a8 --- /dev/null +++ b/src/routes/people/[npub]/+page.svelte @@ -0,0 +1,87 @@ + + + + + {#if ready && pubkey} + + {:else} +

+ +

+ {/if} +
+
diff --git a/src/routes/spaces/[relay]/chat/+page.svelte b/src/routes/spaces/[relay]/chat/+page.svelte index fc2b9e35..4b796944 100644 --- a/src/routes/spaces/[relay]/chat/+page.svelte +++ b/src/routes/spaces/[relay]/chat/+page.svelte @@ -319,77 +319,74 @@
-
- {#if loadingForward && elements.length > 0} -

- Looking for messages... -

- {/if} - {#each elements as { type, id, value, showPubkey }, i (id)} - {#if type === "new-messages"} -
-
-

- New Messages -

-
-
- {:else if type === "date"} - {value} - {:else} - {@const event = value as TrustedEvent} - {#if event.kind === RELAY_ADD_MEMBER} - +
+ {#if loadingForward && elements.length > 0} +

+ Looking for messages... +

+ {/if} + {#each elements as { type, id, value, showPubkey }, i (id)} + {#if type === "new-messages"} +
+
+

+ New Messages +

+
+
+ {:else if type === "date"} + {value} {:else} - + {@const event = value as TrustedEvent} + {#if event.kind === RELAY_ADD_MEMBER} + + {:else} + + {/if} {/if} - {/if} - {/each} -

- {#if loadingBackward} - Looking for messages... - {:else} - End of message history - {/if} -

-
+ {/each} +

+ {#if loadingBackward} + Looking for messages... + {:else} + End of message history + {/if} +

+
-
- {#if parent} - - {/if} - {#if share} - - {/if} - {#if eventToEdit} - - {/if} -
- {#key eventToEdit} - - {/key} +
+ {#if parent} + + {/if} + {#if share} + + {/if} + {#if eventToEdit} + + {/if} +
+ {#key eventToEdit} + + {/key}