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
19 changed files with 1036 additions and 39 deletions
+33 -1
View File
@@ -4,11 +4,16 @@
import type {TrustedEvent, EventContent} from "@welshman/util" import type {TrustedEvent, EventContent} from "@welshman/util"
import {Router} from "@welshman/router" import {Router} from "@welshman/router"
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl" 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 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 EmojiButton from "@lib/components/EmojiButton.svelte"
import NoteContent from "@app/components/NoteContent.svelte" import NoteContent from "@app/components/NoteContent.svelte"
import NoteCard from "@app/components/NoteCard.svelte" import NoteCard from "@app/components/NoteCard.svelte"
import ReactionSummary from "@app/components/ReactionSummary.svelte" import ReactionSummary from "@app/components/ReactionSummary.svelte"
import ProfileNoteMenu from "@app/components/ProfileNoteMenu.svelte"
import {publishDelete} from "@app/deletes" import {publishDelete} from "@app/deletes"
import {publishReaction} from "@app/reactions" import {publishReaction} from "@app/reactions"
import {canEnforceNip70} from "@app/relays" import {canEnforceNip70} from "@app/relays"
@@ -17,9 +22,10 @@
event: TrustedEvent event: TrustedEvent
children?: Snippet children?: Snippet
url?: string 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() const relays = url ? [url] : Router.get().Event(event).getUrls()
@@ -38,9 +44,35 @@
content: emoji.unicode, content: emoji.unicode,
protect: await shouldProtect, protect: await shouldProtect,
}) })
const toggleMenu = () => {
showMenu = !showMenu
}
const closeMenu = () => {
showMenu = false
}
let showMenu = $state(false)
</script> </script>
<NoteCard {event} {url} class="cv card2 bg-alt"> <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" /> <NoteContent {event} expandMode="inline" />
<div class="flex w-full justify-between gap-2"> <div class="flex w-full justify-between gap-2">
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-right"> <ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-right">
+6 -9
View File
@@ -1,12 +1,11 @@
<script lang="ts"> <script lang="ts">
import UserCircle from "@assets/icons/user-circle.svg?dataurl" import UserCircle from "@assets/icons/user-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" 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 Profile from "@app/components/Profile.svelte"
import ProfileInfo from "@app/components/ProfileInfo.svelte" import ProfileInfo from "@app/components/ProfileInfo.svelte"
import ProfileBadges from "@app/components/ProfileBadges.svelte" import ProfileBadges from "@app/components/ProfileBadges.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte" import {makeProfilePath} from "@app/routes"
import {pushModal} from "@app/modal"
type Props = { type Props = {
pubkey: string pubkey: string
@@ -14,22 +13,20 @@
} }
const {pubkey, url}: Props = $props() const {pubkey, url}: Props = $props()
const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
</script> </script>
<div class="card2 bg-alt flex flex-col gap-4 shadow-md"> <div class="card2 bg-alt flex flex-col gap-4 shadow-md">
<div class="flex justify-between"> <div class="flex justify-between">
<Profile {pubkey} {url} /> <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} /> <Icon icon={UserCircle} />
View Profile View Profile
</Button> </Link>
</div> </div>
<ProfileInfo {pubkey} {url} /> <ProfileInfo {pubkey} {url} />
<ProfileBadges {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} /> <Icon icon={UserCircle} />
View Profile View Profile
</Button> </Link>
</div> </div>
-2
View File
@@ -29,10 +29,8 @@
const openSpaces = () => pushModal(ProfileSpaces, {pubkey, url}) const openSpaces = () => pushModal(ProfileSpaces, {pubkey, url})
onMount(async () => { onMount(async () => {
// Make sure we have their relay selections before we load their posts
await loadRelayList(pubkey) await loadRelayList(pubkey)
// Load groups and at least one note, regardless of time frame
load({ load({
filters: [ filters: [
{authors: [pubkey], kinds: [ROOMS]}, {authors: [pubkey], kinds: [ROOMS]},
+6 -10
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import cx from "classnames" import cx from "classnames"
import {getProfile, loadProfile} from "@welshman/app" import {loadProfile} from "@welshman/app"
import {isMobile} from "@lib/html" import {isMobile} from "@lib/html"
import ProfileCircle from "@app/components/ProfileCircle.svelte" import ProfileCircle from "@app/components/ProfileCircle.svelte"
@@ -22,17 +22,13 @@
: {box: "h-8 w-8", overlap: "-mr-3", overflow: "text-xs"}, : {box: "h-8 w-8", overlap: "-mr-3", overflow: "text-xs"},
) )
for (const pubkey of pubkeys) { $effect(() => {
loadProfile(pubkey) for (const pk of pubkeys) {
} loadProfile(pk)
}
const visiblePubkeys = $derived.by(() => {
const filtered = pubkeys.filter(pubkey => getProfile(pubkey)?.picture)
return filtered.length > 0 ? filtered : pubkeys.slice(0, 1)
}) })
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)) const overflowCount = $derived(Math.max(0, pubkeys.length - effectiveLimit))
</script> </script>
+8 -15
View File
@@ -10,14 +10,12 @@
} from "@welshman/app" } from "@welshman/app"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Code2 from "@assets/icons/code-2.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 MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl" import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
import Restart from "@assets/icons/restart.svg?dataurl" import Restart from "@assets/icons/restart.svg?dataurl"
import {fly} from "@lib/transition" import {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte" 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 Confirm from "@lib/components/Confirm.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Popover from "@lib/components/Popover.svelte" import Popover from "@lib/components/Popover.svelte"
@@ -28,11 +26,10 @@
import ProfileInfo from "@app/components/ProfileInfo.svelte" import ProfileInfo from "@app/components/ProfileInfo.svelte"
import EventInfo from "@app/components/EventInfo.svelte" import EventInfo from "@app/components/EventInfo.svelte"
import ProfileBadges from "@app/components/ProfileBadges.svelte" import ProfileBadges from "@app/components/ProfileBadges.svelte"
import {pubkeyLink} from "@app/env"
import {deriveUserIsSpaceAdmin, deriveSpaceBannedPubkeyItems, addSpaceMembers} from "@app/members" import {deriveUserIsSpaceAdmin, deriveSpaceBannedPubkeyItems, addSpaceMembers} from "@app/members"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {goToChat} from "@app/routes" import {goToProfile} from "@app/routes"
export type Props = { export type Props = {
pubkey: string pubkey: string
@@ -53,9 +50,9 @@
const showInfo = () => pushModal(EventInfo, {url, event: $profile!.event}) const showInfo = () => pushModal(EventInfo, {url, event: $profile!.event})
const openChat = () => goToChat([pubkey]) const viewProfile = () => goToProfile(pubkey)
const toggleMenu = (pubkey: string) => { const toggleMenu = () => {
showMenu = !showMenu showMenu = !showMenu
} }
@@ -107,7 +104,7 @@
<Profile showPubkey avatarSize={14} {pubkey} {url} /> <Profile showPubkey avatarSize={14} {pubkey} {url} />
{#if $profile || $userIsAdmin} {#if $profile || $userIsAdmin}
<div class="relative"> <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} /> <Icon icon={MenuDots} />
</Button> </Button>
{#if showMenu} {#if showMenu}
@@ -156,13 +153,9 @@
Go back Go back
</Button> </Button>
<div class="flex gap-2"> <div class="flex gap-2">
<Link external href={pubkeyLink(pubkey)} class="btn btn-neutral"> <Button onclick={viewProfile} class="btn btn-primary">
<ImageIcon alt="" src="/coracle.png" /> <Icon icon={UserCircle} />
Open in Coracle View Full Profile
</Link>
<Button onclick={openChat} class="btn btn-primary">
<Icon icon={Letter} />
Message
</Button> </Button>
</div> </div>
</ModalFooter> </ModalFooter>
+19
View File
@@ -4,6 +4,7 @@
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import UserCircle from "@assets/icons/user-circle.svg?dataurl" import UserCircle from "@assets/icons/user-circle.svg?dataurl"
import MapPoint from "@assets/icons/map-point.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 Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte" import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -85,6 +86,24 @@
{/snippet} {/snippet}
</Field> </Field>
{#if !isSignup} {#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> <Field>
{#snippet label()} {#snippet label()}
<p>Nostr Address</p> <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>
+382
View File
@@ -0,0 +1,382 @@
<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,
getFollows,
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 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"
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 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"
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 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(Math.round(clamp([0, 100], $score)))
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 startEdit = () => pushModal(ProfileEdit)
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">
{#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">
<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={startEdit}>
<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>
{#if isSelf}
<li>
<Button onclick={openSettings}>
<Icon icon={Settings} />
Account Settings
</Button>
</li>
{/if}
</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} />
Trust score {displayScore}
</span>
{#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} />
{sharedSpaceUrls.length} shared {sharedSpaceUrls.length === 1
? "space"
: "spaces"}
</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">
<ProfileTrust pubkey={target} />
<ProfileSharedSpaces pubkey={target} limit={3} onViewAll={showSpacesTab} />
</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
>.
</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">
<ProfileTrust pubkey={target} />
<ProfileSharedSpaces pubkey={target} limit={3} onViewAll={showSpacesTab} />
</div>
</aside>
</div>
</div>
@@ -0,0 +1,68 @@
<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()
const ctrl = makeFeedController({
useWindowing: true,
feed: feedFromFilter({kinds: [NOTE], authors: [pubkey]}),
onEvent: (event: TrustedEvent) => {
if (getReplyTags(event.tags).replies.length === 0) {
buffer.push(event)
}
},
})
let element: Element | undefined = $state()
let events: TrustedEvent[] = $state([])
let buffer: TrustedEvent[] = []
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()
})
</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>
{/each}
<p class="center my-12 flex">
<Spinner loading />
</p>
</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,88 @@
<script lang="ts">
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 {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(getEventTagValues(getListTags($pinList)))
const displayIds = $derived(limit ? pinnedIds.slice(0, limit) : pinnedIds)
const pinnedEvents = $derived.by(() => {
return sortBy(
e => -pinnedIds.indexOf(e.id),
displayIds
.map(id => repository.getEvent(id))
.filter((event): event is TrustedEvent => Boolean(event)),
)
})
let loading = $state(pinnedIds.length > 0)
onMount(() => {
if (pinnedIds.length === 0) {
loading = false
return
}
const missing = pinnedIds.filter(id => !repository.getEvent(id))
if (missing.length === 0) {
loading = false
return
}
load({
relays: Router.get().FromPubkeys([pubkey]).getUrls(),
filters: [{ids: missing}],
onEvent: () => {
loading = !pinnedIds.every(id => repository.getEvent(id))
},
onClose: () => {
loading = false
},
})
})
</script>
{#if pinnedIds.length > 0}
<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}
<div class="col-2">
{#each pinnedEvents as event (event.id)}
<div in:fly>
<NoteItem {event} {editable} />
</div>
{/each}
</div>
{#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>
</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,74 @@
<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
limit?: number
onViewAll?: () => void
class?: string
}
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 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>Shared spaces</strong>
</div>
<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">
{#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 && 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}>
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">
No shared spaces yet.
</p>
{/if}
</div>
+72
View File
@@ -0,0 +1,72 @@
<script lang="ts">
import {clamp, uniq} from "@welshman/lib"
import {
pubkey,
followLists,
deriveUserWotScore,
deriveProfileDisplay,
deriveFollowList,
getFollows,
} 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
}
const {pubkey: target}: Props = $props()
const score = deriveUserWotScore(target)
const profileDisplay = deriveProfileDisplay(target)
const targetFollowList = deriveFollowList(target)
const mutualFollows = $derived.by(() => {
const viewer = $pubkey
void $followLists
void $targetFollowList
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(Math.round(clamp([0, 100], $score)))
const progress = $derived(displayScore)
const trustMessage = $derived.by(() => {
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">Trust score</span>
<span class="text-lg font-semibold">{displayScore} / 100</span>
</div>
<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}
<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 // Chat
export const makeChatPath = (pubkeys: string[]) => `/chat/${makeChatId(pubkeys)}` export const makeChatPath = (pubkeys: string[]) => `/chat/${makeChatId(pubkeys)}`
+16
View File
@@ -37,6 +37,8 @@ import {
loadFollowList, loadFollowList,
loadMuteList, loadMuteList,
loadProfile, loadProfile,
userFollowList,
getFollows,
repository, repository,
shouldUnwrap, shouldUnwrap,
hasNegentropy, hasNegentropy,
@@ -248,6 +250,17 @@ const syncUserData = () => {
loadFeedsForPubkey(pubkey) 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]) => { const unsubscribeGroupList = merged([userGroupList]).subscribe(([$userGroupList]) => {
syncGroupList($userGroupList) syncGroupList($userGroupList)
}) })
@@ -256,10 +269,13 @@ const syncUserData = () => {
syncRelayList($userRelayList) syncRelayList($userRelayList)
}) })
const unsubscribeFollowList = userFollowList.subscribe(syncFollowNetwork)
return () => { return () => {
unsubscribersByKey.forEach(call) unsubscribersByKey.forEach(call)
unsubscribeGroupList() unsubscribeGroupList()
unsubscribeRelayList() unsubscribeRelayList()
unsubscribeFollowList()
} }
} }
+21 -1
View File
@@ -1,5 +1,6 @@
import {append, identity, uniq} from "@welshman/lib" 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 {displayPubkey, getTagValue} from "@welshman/util"
import {PLATFORM_NAME} from "@app/env" import {PLATFORM_NAME} from "@app/env"
import {decodeRelay} from "@app/relays" import {decodeRelay} from "@app/relays"
@@ -24,6 +25,7 @@ const staticTitles = new Map<string, string>([
["/chat", "Messages"], ["/chat", "Messages"],
["/join", "Join Space"], ["/join", "Join Space"],
["/people", "Find People"], ["/people", "Find People"],
["/people/[npub]", "Profile"],
["/settings/about", "About"], ["/settings/about", "About"],
["/settings/profile", "Profile Settings"], ["/settings/profile", "Profile Settings"],
["/settings/content", "Content Settings"], ["/settings/content", "Content Settings"],
@@ -120,6 +122,24 @@ export const getPageTitle = ({page, pubkey}: PageTitleContext) => {
return makeTitle(getChatTitle(page.params.chat, pubkey)) 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]") { if (routeId === "/spaces/[relay]/[h]") {
return makeTitle(getRoomTitle(page.params)) return makeTitle(getRoomTitle(page.params))
} }
+9 -1
View File
@@ -8,13 +8,21 @@
import {page} from "$app/stores" import {page} from "$app/stores"
import {goto} from "$app/navigation" import {goto} from "$app/navigation"
import Spinner from "@lib/components/Spinner.svelte" 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 {bech32} = $page.params as MakeNonOptional<typeof $page.params>
const attemptToNavigate = async () => { const attemptToNavigate = async () => {
const {type, data} = nip19.decode(bech32) as any 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) { if (!["nevent", "naddr"].includes(type) && data.relays.length > 0) {
return goto("/", {replaceState: true}) return goto("/", {replaceState: true})
} }
+77
View File
@@ -0,0 +1,77 @@
<script lang="ts">
import {onMount} from "svelte"
import * as nip19 from "nostr-tools/nip19"
import {page} from "$app/stores"
import {goto} from "$app/navigation"
import type {MakeNonOptional} from "@welshman/lib"
import {
loadProfile,
loadRelayList,
loadFollowList,
loadMessagingRelayList,
loadPinList,
} 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"
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
}
await loadProfile(pubkey)
await loadRelayList(pubkey)
await Promise.all([
loadFollowList(pubkey),
loadPinList(pubkey),
loadGroupList(pubkey),
loadMessagingRelayList(pubkey),
])
load({
relays: Router.get().FromPubkeys([pubkey]).getUrls(),
filters: [
{authors: [pubkey], kinds: [ROOMS]},
{authors: [pubkey], kinds: [NOTE], limit: 1},
],
})
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>