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

This commit is contained in:
userAdityaa 2026-06-18 16:22:04 +05:30
parent 486385cf1f
commit 0b26eb8058
28 changed files with 1025 additions and 196 deletions

View file

@ -1,10 +1,9 @@
<script lang="ts">
import * as nip19 from "nostr-tools/nip19"
import {onMount} from "svelte"
import {writable} from "svelte/store"
import {tryCatch, uniq} from "@welshman/lib"
import {fromNostrURI} from "@welshman/util"
import {uniq} from "@welshman/lib"
import {loadMessagingRelayList} from "@welshman/app"
import {decodePubkey} from "@lib/util"
import {preventDefault} from "@lib/html"
import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte"
@ -39,22 +38,10 @@
onMount(() => {
return term.subscribe(t => {
if (t.match(/^[0-9a-f]{64}$/)) {
addPubkey(t)
}
const pubkey = decodePubkey(t)
if (t.match(/^(nostr:)?(npub1|nprofile1)/)) {
tryCatch(() => {
const {type, data} = nip19.decode(fromNostrURI(t))
if (type === "npub") {
addPubkey(data)
}
if (type === "nprofile") {
addPubkey(data.pubkey)
}
})
if (pubkey) {
addPubkey(pubkey)
}
})
})

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="card card-interactive flex flex-col gap-4">
<div class="flex justify-between">
<Profile {pubkey} {url} />
<Button onclick={openProfile} class="button button-primary hidden sm:flex">
<Link href={makeProfilePath(pubkey)} class="button button-primary hidden sm:flex">
<Icon icon={UserCircle} />
View Profile
</Button>
</Link>
</div>
<ProfileInfo {pubkey} {url} />
<ProfileBadges {pubkey} {url} />
<Button onclick={openProfile} class="button button-primary sm:hidden">
<Link href={makeProfilePath(pubkey)} class="button button-primary sm:hidden">
<Icon icon={UserCircle} />
View Profile
</Button>
</Link>
</div>

View file

@ -8,7 +8,8 @@
const {urls}: Props = $props()
</script>
<div class="flex max-h-[80vh] flex-col overflow-y-auto rounded-2xl border border-solid bg-surface p-1 shadow-xl border-line">
<div
class="flex max-h-[80vh] flex-col overflow-y-auto rounded-2xl border border-solid bg-surface p-1 shadow-xl border-line">
{#each urls as url (url)}
<PrimaryNavItemSpace {url} showTooltip={false} />
{/each}

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]},

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>

View file

@ -1,17 +1,13 @@
<script lang="ts">
import {onMount} from "svelte"
import {removeUndefined} from "@welshman/lib"
import {deriveProfile, displayProfileByPubkey, loadMessagingRelayList} from "@welshman/app"
import {displayProfileByPubkey, loadMessagingRelayList} 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"
@ -20,9 +16,7 @@
import ModalFooter from "@lib/components/ModalFooter.svelte"
import Profile from "@app/components/Profile.svelte"
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,
@ -31,7 +25,7 @@
} 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
@ -40,8 +34,6 @@
const {pubkey, url}: Props = $props()
const profile = deriveProfile(pubkey, removeUndefined([url]))
const userIsAdmin = deriveUserIsSpaceAdmin(url)
const bannedPubkeys = url ? deriveSpaceBannedPubkeyItems(url) : undefined
@ -50,11 +42,9 @@
const back = () => history.back()
const showInfo = () => pushModal(EventInfo, {url, event: $profile!.event})
const viewProfile = () => goToProfile(pubkey)
const openChat = () => goToChat([pubkey])
const toggleMenu = (pubkey: string) => {
const toggleMenu = () => {
showMenu = !showMenu
}
@ -101,42 +91,30 @@
<div class="flex flex-col gap-4">
<div class="flex justify-between">
<Profile showPubkey avatarSize={14} {pubkey} {url} />
{#if $profile || $userIsAdmin}
{#if $userIsAdmin}
<div class="relative">
<Button
class="button button-ghost button-sm button-circle"
onclick={() => toggleMenu(pubkey)}>
<Button class="button button-circle button-ghost button-sm" onclick={toggleMenu}>
<Icon icon={MenuDots} />
</Button>
{#if showMenu}
<Popover hideOnClick onClose={closeMenu}>
<ul
transition:fly
class="bg-surface menu absolute right-0 z-popover w-48 gap-1 rounded-2xl p-2 shadow-md">
{#if $profile}
class="menu bg-surface absolute right-0 z-popover w-48 gap-1 rounded-2xl p-2">
{#if isBanned}
<li>
<Button onclick={showInfo}>
<Icon icon={Code2} />
User Details
<Button onclick={restoreMember}>
<Icon icon={Restart} />
Restore User
</Button>
</li>
{:else}
<li>
<Button class="text-error" onclick={banMember}>
<Icon icon={MinusCircle} />
Ban User
</Button>
</li>
{/if}
{#if $userIsAdmin}
{#if isBanned}
<li>
<Button onclick={restoreMember}>
<Icon icon={Restart} />
Restore User
</Button>
</li>
{:else}
<li>
<Button class="text-error" onclick={banMember}>
<Icon icon={MinusCircle} />
Ban User
</Button>
</li>
{/if}
{/if}
</ul>
</Popover>
@ -154,13 +132,9 @@
Go back
</Button>
<div class="flex gap-2">
<Link external href={pubkeyLink(pubkey)} class="button button-neutral">
<ImageIcon alt="" src="/coracle.png" />
Open in Coracle
</Link>
<Button onclick={openChat} class="button button-primary">
<Icon icon={Letter} />
Message
<Button onclick={viewProfile} class="button button-primary">
<Icon icon={UserCircle} />
View Full Profile
</Button>
</div>
</ModalFooter>

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"
@ -83,6 +84,24 @@
{/snippet}
</Field>
{#if !isSignup}
<Field>
{#snippet label()}
<p>Website</p>
{/snippet}
{#snippet input()}
<label class="input 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>

View file

@ -1,10 +1,10 @@
<script lang="ts">
import * as nip19 from "nostr-tools/nip19"
import {writable} from "svelte/store"
import type {Writable} from "svelte/store"
import {type Instance} from "tippy.js"
import {append, remove, uniq} from "@welshman/lib"
import {profileSearch} from "@welshman/app"
import {decodePubkey} from "@lib/util"
import Suggestions from "@lib/components/Suggestions.svelte"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
@ -38,22 +38,10 @@
}
const onInput = (e: any) => {
if (e.target.value.match(/^[a-f0-9]{64}$/)) {
selectPubkey(e.target.value)
}
const pubkey = decodePubkey(e.target.value)
try {
const {type, data} = nip19.decode(e.target.value) as any
if (type === "npub") {
selectPubkey(data)
}
if (type === "nprofile") {
selectPubkey(data.pubkey)
}
} catch (e) {
// pass
if (pubkey) {
selectPubkey(pubkey)
}
}

View file

@ -0,0 +1,271 @@
<script lang="ts">
import * as nip19 from "nostr-tools/nip19"
import {displayPubkey} from "@welshman/util"
import {
pubkey,
followLists,
deriveProfile,
deriveProfileDisplay,
getFollows,
follow,
unfollow,
tagPubkey,
} from "@welshman/app"
import {compressFile} from "@lib/html"
import Copy from "@assets/icons/copy.svg?dataurl"
import LinkRound from "@assets/icons/link-round.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 ShareCircle from "@assets/icons/share-circle.svg?dataurl"
import GallerySend from "@assets/icons/gallery-send.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 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 ProfileQrCode from "@app/components/ProfileQrCode.svelte"
import ProfileEdit from "@app/components/ProfileEdit.svelte"
import WotScore from "@app/components/WotScore.svelte"
import Content from "@app/components/Content.svelte"
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 Props = {
pubkey: string
}
const {pubkey: target}: Props = $props()
const profile = deriveProfile(target)
const profileDisplay = deriveProfileDisplay(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 website = $derived($profile?.website?.replace(/^https?:\/\//, ""))
const websiteHref = $derived(
$profile?.website?.match(/^https?:\/\//)
? $profile.website
: `https://${$profile?.website || ""}`,
)
let showMenu = $state(false)
let bannerLoading = $state(false)
let bannerInput: HTMLInputElement | undefined = $state()
const copyNpub = () => clip(encodedNpub)
const showShare = () => {
closeMenu()
pushModal(ProfileQrCode, {pubkey: target})
}
const startEdit = () => pushModal(ProfileEdit)
const toggleMenu = () => {
showMenu = !showMenu
}
const closeMenu = () => {
showMenu = false
}
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="flex flex-col gap-4">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start">
<div class="min-w-0 flex-1 overflow-hidden border-line bg-surface md:rounded-3xl md:border">
<div class="relative overflow-hidden border-b border-line bg-surface-more">
{#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-surface-more to-surface sm:h-32 md:h-40">
</div>
{/if}
{#if isSelf}
<Button
class="button button-neutral button-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-line 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 border-4 border-surface bg-surface sm:border-surface-more sm:bg-surface-more">
<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="flex min-w-0 items-center gap-2 text-xl leading-tight font-bold sm:text-2xl">
<span class="truncate">{$profileDisplay}</span>
{#if !isSelf}
<WotScore pubkey={target} />
{/if}
</h1>
{#if $profile}
<div class="flex items-center gap-2">
{#if isSelf}
<Button
class="button button-primary flex-1 sm:button-sm sm:flex-none"
onclick={startEdit}>
<Icon icon={PenNewSquare} size={4} />
Edit profile
</Button>
{:else if $pubkey}
<Button
class="button button-neutral flex-1 sm:button-sm sm:flex-none"
onclick={toggleFollow}>
<Icon icon={UserPlus} size={4} />
{isFollowing ? "Unfollow" : "Follow"}
</Button>
<Button
class="button button-primary flex-1 sm:button-sm sm:flex-none"
onclick={openChat}>
<Icon icon={Letter} size={4} />
Message
</Button>
{/if}
<div class="relative shrink-0">
<Button
class="button button-circle button-ghost button-sm"
onclick={toggleMenu}>
<Icon icon={MenuDots} />
</Button>
{#if showMenu}
<Popover hideOnClick onClose={closeMenu}>
<ul
transition:fly
class="menu bg-surface absolute right-0 z-popover w-48 gap-1 rounded-2xl p-2">
<li>
<Button onclick={showShare}>
<Icon icon={ShareCircle} />
Share
</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="button button-ghost button-xs h-5 min-h-5 w-5 p-0">
<Icon size={3} icon={Copy} />
</Button>
</div>
{#if website}
<Link
external
href={websiteHref}
class="link flex w-fit items-center gap-2 text-sm font-medium">
<Icon icon={LinkRound} size={4} />
{website}
</Link>
{/if}
{#if $profile?.about}
<Content
event={{content: $profile.about, tags: []}}
expandMode="block"
minLength={150}
maxLength={250} />
{/if}
</div>
</div>
</div>
</div>
<div class="bg-surface-less px-4 py-3 sm:px-3 sm:py-4">
<div class="flex flex-col gap-4 sm:pl-3">
<div class="flex flex-col gap-3 xl:hidden">
<ProfileTrust pubkey={target} {isSelf} />
<ProfileSharedSpaces pubkey={target} {isSelf} />
</div>
<ProfilePinnedNotes pubkey={target} />
<ProfilePageNotes pubkey={target} />
</div>
</div>
</div>
<aside class="hidden w-80 shrink-0 xl:block xl:pl-4">
<div class="flex flex-col gap-3">
<ProfileTrust pubkey={target} {isSelf} />
<ProfileSharedSpaces pubkey={target} {isSelf} />
</div>
</aside>
</div>
</div>

View file

@ -0,0 +1,79 @@
<script lang="ts">
import {onMount} from "svelte"
import {derived, get, writable} from "svelte/store"
import {sortBy, uniqBy} from "@welshman/lib"
import {feedFromFilter} from "@welshman/feeds"
import {NOTE, getReplyTags, getListTags, getEventTagValues} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {derivePinList, 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
}
const {pubkey}: Props = $props()
const pinList = derivePinList(pubkey)
const pinnedIds = derived(pinList, $pinList => new Set(getEventTagValues(getListTags($pinList))))
let element: HTMLElement | undefined = $state()
const events = writable<TrustedEvent[]>([])
let exhausted = $state(false)
let buffer: TrustedEvent[] = []
const ctrl = makeFeedController({
useWindowing: true,
feed: feedFromFilter({kinds: [NOTE], authors: [pubkey]}),
onEvent: (event: TrustedEvent) => {
if (getReplyTags(event.tags).replies.length === 0 && !get(pinnedIds).has(event.id)) {
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.update($events => uniqBy(e => e.id, [...$events, ...buffer.splice(0, 5)]))
if (buffer.length < 50) {
ctrl.load(50)
}
},
})
return () => scroller.stop()
})
</script>
<div class="flex flex-col gap-4" bind:this={element}>
{#each $events as event (event.id)}
<div in:fly>
<NoteItem {event} />
</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="my-12 flex items-center justify-center">
<Spinner loading />
</p>
{/if}
</div>

View file

@ -0,0 +1,96 @@
<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
}
const {pubkey}: Props = $props()
const pinList = derivePinList(pubkey)
const pinnedIds = derived(pinList, $pinList => getEventTagValues(getListTags($pinList)))
const pinnedEvents = derived(
pinnedIds,
($pinnedIds, set) => {
if ($pinnedIds.length === 0) {
set([])
return
}
return deriveEventsDesc(
deriveEventsById({repository, filters: [{ids: $pinnedIds}]}),
).subscribe(events => {
set(sortBy(event => -$pinnedIds.indexOf(event.id), events))
})
},
[] as TrustedEvent[],
)
let fetching = $state(false)
$effect(() => {
const ids = $pinnedIds
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 || ($pinnedIds.length > 0 && $pinnedEvents.length < $pinnedIds.length),
)
</script>
{#if $pinnedIds.length > 0 || loading}
{#if loading && $pinnedEvents.length === 0}
<p class="flex items-center justify-center py-8">
<Spinner loading />
</p>
{:else if $pinnedEvents.length > 0}
<div class="flex flex-col gap-4">
{#each $pinnedEvents as event (event.id)}
<div in:fly>
<NoteItem {event} />
</div>
{/each}
</div>
{/if}
{/if}

View file

@ -0,0 +1,83 @@
<script lang="ts">
import {onMount} from "svelte"
import {Share} from "@capacitor/share"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Upload from "@assets/icons/upload.svg?dataurl"
import Copy from "@assets/icons/copy.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"
import {pubkeyLink} from "@app/env"
import {clip} from "@app/toast"
type Props = {
pubkey: string
}
const {pubkey}: Props = $props()
const code = pubkeyLink(pubkey)
const back = () => history.back()
const copyCode = () => clip(code)
const shareProfile = async () => {
if (!canShare) return
try {
await Share.share({url: code})
} catch (e) {
console.error(e)
}
}
let canShare = $state(false)
onMount(async () => {
try {
const {value} = await Share.canShare()
canShare = value
} catch {
canShare = false
}
})
</script>
<Modal>
<ModalBody>
<div class="flex flex-col items-center gap-4 text-center">
<strong>Share Profile</strong>
<div class="w-48">
<QRCode {code} />
</div>
<div class="flex w-full gap-2">
{#if canShare}
<Button
class="input flex w-12 shrink-0 items-center justify-center p-0"
onclick={shareProfile}>
<Icon icon={Upload} />
</Button>
{/if}
<label class="input flex min-w-0 flex-1 items-center gap-2">
<Icon icon={LinkRound} class="shrink-0" />
<input readonly class="min-w-0 grow truncate" value={code} />
<Button class="shrink-0" onclick={copyCode}>
<Icon icon={Copy} />
</Button>
</label>
</div>
<p class="text-sm opacity-75">Tap the QR code to copy this profile link.</p>
</div>
</ModalBody>
<ModalFooter>
<Button onclick={back} class="button button-link">
<Icon icon={AltArrowLeft} />
Go Back
</Button>
</ModalFooter>
</Modal>

View file

@ -0,0 +1,69 @@
<script lang="ts">
import cx from "classnames"
import UsersGroup from "@assets/icons/users-group-rounded.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Badge from "@lib/components/Badge.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
class?: string
}
const {pubkey, isSelf = false, ...props}: Props = $props()
const groupList = deriveGroupList(pubkey)
const spaceUrls = $derived(getSpaceUrlsFromGroupList($groupList))
</script>
<div class={cx("card card-sm flex flex-col gap-3 sm:gap-4", props.class)}>
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<Icon icon={UsersGroup} size={5} />
<strong>Spaces</strong>
</div>
<Badge variant="neutral">{spaceUrls.length}</Badge>
</div>
{#if spaceUrls.length > 0}
<div class="flex flex-col gap-2 border-t border-line pt-4">
{#each spaceUrls as url (url)}
{@const count = $groupListPubkeysByUrl.get(url)?.size || 0}
{@const isMember = !isSelf && $userSpaceUrls.includes(url)}
<Link
href={makeSpacePath(url)}
class="card card-interactive flex flex-row items-center gap-3 p-3 sm:p-4">
<RelayIcon {url} size={8} />
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<RelayName {url} class="truncate text-sm font-medium" />
{#if isMember}
<Badge variant="primary" class="text-xs">Member</Badge>
{/if}
</div>
<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>
{:else}
<p class="border-t border-line pt-4 text-sm opacity-75">
{isSelf ? "You aren't in any spaces yet." : "No spaces found."}
</p>
{/if}
</div>

View file

@ -0,0 +1,121 @@
<script lang="ts">
import {clamp} from "@welshman/lib"
import {
pubkey,
followLists,
deriveUserWotScore,
deriveProfileDisplay,
deriveFollowList,
followersByPubkey,
loadFollowList,
getFollowsWhoFollow,
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 followsWhoFollow = $derived.by(() => {
if (isSelf) return []
const viewer = $pubkey
void $followLists
void $targetFollowList
if (!viewer) return []
return getFollowsWhoFollow(viewer, target)
})
const networkFollowCount = $derived(isSelf ? followerCount : followsWhoFollow.length)
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 followers in your network yet."
return `Followed by ${followerCount}+ people in your network.`
}
if (!$pubkey) {
if (displayScore >= 50) return "This user is highly trusted."
if (displayScore >= 10) return "This user has some trust."
return "This user is not well known."
}
if (networkFollowCount > 0) {
return `Followed by ${networkFollowCount}+ people in your network.`
}
if (displayScore >= 50) return "This user is highly trusted in your network."
if (displayScore >= 10) return "This user has some trust in your network."
return "This user is not well known in your network."
})
</script>
<div class="card card-sm flex flex-col gap-3 sm:gap-4">
<div class="flex items-center gap-2">
<Icon icon={Shield} size={5} />
<strong>Reputation</strong>
</div>
<div class="flex flex-col gap-2 border-t border-line 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 w-full" value={progress} max="100"></progress>
{/if}
<p class="text-sm opacity-75">{trustMessage}</p>
</div>
{#if followsWhoFollow.length > 0}
<div class="flex flex-col gap-2 border-t border-line pt-4">
<p class="text-sm font-medium">People who follow {$profileDisplay}</p>
<ProfileCircles pubkeys={followsWhoFollow} limit={5} />
<p class="text-sm opacity-75">
{followsWhoFollow.length}
{followsWhoFollow.length === 1 ? "person" : "people"} you follow also follow
{$profileDisplay}.
</p>
</div>
{/if}
</div>

View file

@ -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]}),
})
}),

View file

@ -239,7 +239,8 @@
size={3} />
</div>
{/if}
<span class="pointer-events-none absolute bottom-1 left-1 max-w-[calc(100%-0.5rem)] truncate rounded bg-base-100/80 px-1.5 py-0.5 text-xs">
<span
class="pointer-events-none absolute bottom-1 left-1 max-w-[calc(100%-0.5rem)] truncate rounded bg-base-100/80 px-1.5 py-0.5 text-xs">
{labelFor(tile.liveKitIdentity, tile.source)}{tile.isLocal ? " (you)" : ""}
</span>
{#if videoTiles.length > 1}

View file

@ -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)
}
}

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)}`

View file

@ -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()
}
}

View file

@ -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))
}

View file

@ -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}` : ""}`,
)
</script>
{#if src.includes("image/svg") || src.endsWith(".svg")}
<Icon icon={src} {size} {style} class={props.class} />
<Icon icon={src} {size} {style} class={className} />
{:else}
<img
{src}
{alt}
{style}
class="h-{size} w-{size} min-w-{size} min-h-{size} aspect-square object-cover rounded-full {props.class}" />
<img {src} {alt} style={imgStyle} class="aspect-square object-cover {className}" />
{/if}

View file

@ -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);

View file

@ -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 */

View file

@ -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;
}

View file

@ -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))

View file

@ -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<typeof $page.params>
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) {

View file

@ -0,0 +1,87 @@
<script lang="ts">
import {onMount} from "svelte"
import {get} from "svelte/store"
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 {decodePubkey} from "@lib/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 () => {
const decoded = decodePubkey(npub)
if (!decoded) {
goto("/people", {replaceState: true})
return
}
pubkey = decoded
try {
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="flex items-center justify-center py-20">
<Spinner loading />
</p>
{/if}
</PageContent>
</Page>

View file

@ -319,77 +319,74 @@
</SpaceBar>
<div class="room flex min-h-0 min-w-0 flex-1 flex-col">
<div
bind:this={element}
onscroll={onScroll}
class="room__content scroll-container bg-surface">
{#if loadingForward && elements.length > 0}
<p class="py-20 flex justify-center">
<Spinner loading={loadingForward}>Looking for messages...</Spinner>
</p>
{/if}
{#each elements as { type, id, value, showPubkey }, i (id)}
{#if type === "new-messages"}
<div
{id}
class="flex items-center py-2 text-xs transition-colors"
class:opacity-0={showFixedNewMessages}>
<div class="h-px grow bg-primary text-primary-content"></div>
<p
class="rounded-full bg-primary text-primary-content px-2 py-1"
style="color: var(--primary-content)">
New Messages
</p>
<div class="h-px grow bg-primary text-primary-content"></div>
</div>
{:else if type === "date"}
<Divider>{value}</Divider>
{:else}
{@const event = value as TrustedEvent}
{#if event.kind === RELAY_ADD_MEMBER}
<RoomItemAddMember {url} {event} />
<div bind:this={element} onscroll={onScroll} class="room__content scroll-container bg-surface">
{#if loadingForward && elements.length > 0}
<p class="py-20 flex justify-center">
<Spinner loading={loadingForward}>Looking for messages...</Spinner>
</p>
{/if}
{#each elements as { type, id, value, showPubkey }, i (id)}
{#if type === "new-messages"}
<div
{id}
class="flex items-center py-2 text-xs transition-colors"
class:opacity-0={showFixedNewMessages}>
<div class="h-px grow bg-primary text-primary-content"></div>
<p
class="rounded-full bg-primary text-primary-content px-2 py-1"
style="color: var(--primary-content)">
New Messages
</p>
<div class="h-px grow bg-primary text-primary-content"></div>
</div>
{:else if type === "date"}
<Divider>{value}</Divider>
{:else}
<RoomItem
{url}
{event}
{replyTo}
{showPubkey}
canEdit={canEditEvent}
onEdit={onEditEvent} />
{@const event = value as TrustedEvent}
{#if event.kind === RELAY_ADD_MEMBER}
<RoomItemAddMember {url} {event} />
{:else}
<RoomItem
{url}
{event}
{replyTo}
{showPubkey}
canEdit={canEditEvent}
onEdit={onEditEvent} />
{/if}
{/if}
{/if}
{/each}
<p class="flex h-10 items-center justify-center py-20">
{#if loadingBackward}
<Spinner loading={loadingBackward}>Looking for messages...</Spinner>
{:else}
<Spinner>End of message history</Spinner>
{/if}
</p>
<div class="h-screen"></div>
{/each}
<p class="flex h-10 items-center justify-center py-20">
{#if loadingBackward}
<Spinner loading={loadingBackward}>Looking for messages...</Spinner>
{:else}
<Spinner>End of message history</Spinner>
{/if}
</p>
<div class="h-screen"></div>
</div>
<div class="room__compose bg-surface">
<div>
{#if parent}
<RoomComposeParent event={parent} clear={clearParent} verb="Replying to" />
{/if}
{#if share}
<RoomComposeParent event={share} clear={clearShare} verb="Sharing" />
{/if}
{#if eventToEdit}
<RoomComposeEdit clear={clearEventToEdit} />
{/if}
</div>
{#key eventToEdit}
<RoomCompose
{url}
{onSubmit}
{onEscape}
{onEditPrevious}
initialValues={eventToEdit}
bind:this={compose} />
{/key}
<div>
{#if parent}
<RoomComposeParent event={parent} clear={clearParent} verb="Replying to" />
{/if}
{#if share}
<RoomComposeParent event={share} clear={clearShare} verb="Sharing" />
{/if}
{#if eventToEdit}
<RoomComposeEdit clear={clearEventToEdit} />
{/if}
</div>
{#key eventToEdit}
<RoomCompose
{url}
{onSubmit}
{onEscape}
{onEditPrevious}
initialValues={eventToEdit}
bind:this={compose} />
{/key}
</div>
</div>