feat: add full profile page at /people/[npub]
This commit is contained in:
parent
486385cf1f
commit
0b26eb8058
28 changed files with 1025 additions and 196 deletions
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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]},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
271
src/app/components/ProfilePage.svelte
Normal file
271
src/app/components/ProfilePage.svelte
Normal 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>
|
||||
79
src/app/components/ProfilePageNotes.svelte
Normal file
79
src/app/components/ProfilePageNotes.svelte
Normal 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>
|
||||
96
src/app/components/ProfilePinnedNotes.svelte
Normal file
96
src/app/components/ProfilePinnedNotes.svelte
Normal 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}
|
||||
83
src/app/components/ProfileQrCode.svelte
Normal file
83
src/app/components/ProfileQrCode.svelte
Normal 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>
|
||||
69
src/app/components/ProfileSharedSpaces.svelte
Normal file
69
src/app/components/ProfileSharedSpaces.svelte
Normal 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>
|
||||
121
src/app/components/ProfileTrust.svelte
Normal file
121
src/app/components/ProfileTrust.svelte
Normal 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>
|
||||
|
|
@ -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]}),
|
||||
})
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}`
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
87
src/routes/people/[npub]/+page.svelte
Normal file
87
src/routes/people/[npub]/+page.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue