diff --git a/src/app/components/NoteItem.svelte b/src/app/components/NoteItem.svelte index 799a065e..b8f4c3d4 100644 --- a/src/app/components/NoteItem.svelte +++ b/src/app/components/NoteItem.svelte @@ -4,11 +4,16 @@ import type {TrustedEvent, EventContent} from "@welshman/util" import {Router} from "@welshman/router" import SmileCircle from "@assets/icons/smile-circle.svg?dataurl" + import MenuDots from "@assets/icons/menu-dots.svg?dataurl" + import {fly} from "@lib/transition" import Icon from "@lib/components/Icon.svelte" + import Button from "@lib/components/Button.svelte" + import Popover from "@lib/components/Popover.svelte" import EmojiButton from "@lib/components/EmojiButton.svelte" import NoteContent from "@app/components/NoteContent.svelte" import NoteCard from "@app/components/NoteCard.svelte" import ReactionSummary from "@app/components/ReactionSummary.svelte" + import ProfileNoteMenu from "@app/components/ProfileNoteMenu.svelte" import {publishDelete} from "@app/deletes" import {publishReaction} from "@app/reactions" import {canEnforceNip70} from "@app/relays" @@ -17,9 +22,10 @@ event: TrustedEvent children?: Snippet url?: string + editable?: boolean } - const {url, event, children}: Props = $props() + const {url, event, children, editable = false}: Props = $props() const relays = url ? [url] : Router.get().Event(event).getUrls() @@ -38,9 +44,35 @@ content: emoji.unicode, protect: await shouldProtect, }) + + const toggleMenu = () => { + showMenu = !showMenu + } + + const closeMenu = () => { + showMenu = false + } + + let showMenu = $state(false) + {#if editable} +
+
+ + {#if showMenu} + +
+ +
+
+ {/if} +
+
+ {/if}
diff --git a/src/app/components/PeopleItem.svelte b/src/app/components/PeopleItem.svelte index af4c0057..cad6f584 100644 --- a/src/app/components/PeopleItem.svelte +++ b/src/app/components/PeopleItem.svelte @@ -1,12 +1,11 @@
- +
- +
diff --git a/src/app/components/ProfileBadges.svelte b/src/app/components/ProfileBadges.svelte index ca89f00f..6c3c2c8d 100644 --- a/src/app/components/ProfileBadges.svelte +++ b/src/app/components/ProfileBadges.svelte @@ -29,10 +29,8 @@ const openSpaces = () => pushModal(ProfileSpaces, {pubkey, url}) onMount(async () => { - // Make sure we have their relay selections before we load their posts await loadRelayList(pubkey) - // Load groups and at least one note, regardless of time frame load({ filters: [ {authors: [pubkey], kinds: [ROOMS]}, diff --git a/src/app/components/ProfileCircles.svelte b/src/app/components/ProfileCircles.svelte index 8d161157..f36e7844 100644 --- a/src/app/components/ProfileCircles.svelte +++ b/src/app/components/ProfileCircles.svelte @@ -1,6 +1,6 @@ diff --git a/src/app/components/ProfileDetail.svelte b/src/app/components/ProfileDetail.svelte index 9f15133d..d8ed22db 100644 --- a/src/app/components/ProfileDetail.svelte +++ b/src/app/components/ProfileDetail.svelte @@ -10,14 +10,12 @@ } 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" @@ -28,11 +26,10 @@ 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, addSpaceMembers} 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 @@ -53,9 +50,9 @@ const showInfo = () => pushModal(EventInfo, {url, event: $profile!.event}) - const openChat = () => goToChat([pubkey]) + const viewProfile = () => goToProfile(pubkey) - const toggleMenu = (pubkey: string) => { + const toggleMenu = () => { showMenu = !showMenu } @@ -107,7 +104,7 @@ {#if $profile || $userIsAdmin}
- {#if showMenu} @@ -156,13 +153,9 @@ Go back
- - - Open in Coracle - -
diff --git a/src/app/components/ProfileEditForm.svelte b/src/app/components/ProfileEditForm.svelte index c44f6640..8e97a489 100644 --- a/src/app/components/ProfileEditForm.svelte +++ b/src/app/components/ProfileEditForm.svelte @@ -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" @@ -85,6 +86,24 @@ {/snippet} {#if !isSignup} + + {#snippet label()} +

Website

+ {/snippet} + {#snippet input()} + + {/snippet} + {#snippet info()} + A link to your personal site or portfolio. + {/snippet} +
{#snippet label()}

Nostr Address

diff --git a/src/app/components/ProfileNoteMenu.svelte b/src/app/components/ProfileNoteMenu.svelte new file mode 100644 index 00000000..6a9935e5 --- /dev/null +++ b/src/app/components/ProfileNoteMenu.svelte @@ -0,0 +1,69 @@ + + + diff --git a/src/app/components/ProfilePage.svelte b/src/app/components/ProfilePage.svelte new file mode 100644 index 00000000..6bb4b17f --- /dev/null +++ b/src/app/components/ProfilePage.svelte @@ -0,0 +1,382 @@ + + +
+
+
+
+ {#if $profile?.banner} + + {:else} +
+ {/if} + {#if isSelf} + + + {/if} +
+ +
+
+
+
+ {#if isSelf} + + {:else} +
+ +
+ {/if} +
+ +
+
+

+ {$profileDisplay} +

+ + {#if (isSelf || $pubkey) && $profile} +
+ {#if isSelf} + + {:else} + + + {/if} + +
+ + {#if showMenu} + + + + {/if} +
+
+ {/if} +
+ +
+ {displayPubkey(target)} + + +
+ + {#if website} + + + {website} + + + {/if} + +
+ + + Trust score {displayScore} + + {#if sharedSpaceUrls.length > 0} + + {/if} +
+
+
+
+
+ +
+
+ + + +
+
+ +
+
+ {#if tab === "about"} +
+ +
+ + +
+ +
+ {:else if tab === "notes"} + {#if isSelf} +

+ Notes are public posts on your write relays. Pin notes to highlight them on your + profile, or manage relays in + . +

+ {/if} + + {:else} + {#if isSelf} +
+

Spaces come from your published group list.

+ +
+ {/if} + + {/if} +
+
+
+ + +
+
diff --git a/src/app/components/ProfilePageNotes.svelte b/src/app/components/ProfilePageNotes.svelte new file mode 100644 index 00000000..a58a8435 --- /dev/null +++ b/src/app/components/ProfilePageNotes.svelte @@ -0,0 +1,68 @@ + + +
+
+ {#each events as event (event.id)} +
+ +
+ {/each} +

+ +

+
+
diff --git a/src/app/components/ProfilePageSpaces.svelte b/src/app/components/ProfilePageSpaces.svelte new file mode 100644 index 00000000..07e4690e --- /dev/null +++ b/src/app/components/ProfilePageSpaces.svelte @@ -0,0 +1,47 @@ + + +
+ {#each spaceUrls as url (url)} + {@const count = $groupListPubkeysByUrl.get(url)?.size || 0} +
+ +
+ +

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

+

{url}

+
+ + Go to space + + +
+ {:else} +
+

No spaces found for this user.

+
+ {/each} +
diff --git a/src/app/components/ProfilePinnedNotes.svelte b/src/app/components/ProfilePinnedNotes.svelte new file mode 100644 index 00000000..2d79ede3 --- /dev/null +++ b/src/app/components/ProfilePinnedNotes.svelte @@ -0,0 +1,88 @@ + + +{#if pinnedIds.length > 0} +
+ Pinned notes + {#if loading && pinnedEvents.length === 0} +

+ +

+ {:else} +
+ {#each pinnedEvents as event (event.id)} +
+ +
+ {/each} +
+ {#if onViewAll && pinnedIds.length > (limit || pinnedIds.length)} + + {/if} + {/if} +
+{/if} diff --git a/src/app/components/ProfileQrCode.svelte b/src/app/components/ProfileQrCode.svelte new file mode 100644 index 00000000..37b52685 --- /dev/null +++ b/src/app/components/ProfileQrCode.svelte @@ -0,0 +1,35 @@ + + + + +
+ Profile QR Code + +

{code}

+

Tap the QR code to copy this npub.

+
+
+ + + + +
diff --git a/src/app/components/ProfileSharedSpaces.svelte b/src/app/components/ProfileSharedSpaces.svelte new file mode 100644 index 00000000..b3eb54ad --- /dev/null +++ b/src/app/components/ProfileSharedSpaces.svelte @@ -0,0 +1,74 @@ + + +
+
+
+ + Shared spaces +
+ {sharedSpaceUrls.length} +
+ {#if displayUrls.length > 0} +
+ {#each displayUrls as url (url)} + {@const count = $groupListPubkeysByUrl.get(url)?.size || 0} + + +
+ +

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

+
+ + {/each} +
+ {#if onViewAll && sharedSpaceUrls.length > (limit || sharedSpaceUrls.length)} + + {/if} + {:else} +

+ No shared spaces yet. +

+ {/if} +
diff --git a/src/app/components/ProfileTrust.svelte b/src/app/components/ProfileTrust.svelte new file mode 100644 index 00000000..64cd7b58 --- /dev/null +++ b/src/app/components/ProfileTrust.svelte @@ -0,0 +1,72 @@ + + +
+
+ + Reputation +
+
+
+ Trust score + {displayScore} / 100 +
+ +

{trustMessage}

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

Mutual follows

+ +

+ {mutualFollows.length} + {mutualFollows.length === 1 ? "person" : "people"} you and {$profileDisplay} both follow. +

+
+ {/if} +
diff --git a/src/app/routes.ts b/src/app/routes.ts index 95ab4a42..43b677ea 100644 --- a/src/app/routes.ts +++ b/src/app/routes.ts @@ -44,6 +44,12 @@ export const setupHistory = () => } }) +// Profiles + +export const makeProfilePath = (pubkey: string) => `/people/${nip19.npubEncode(pubkey)}` + +export const goToProfile = (pubkey: string) => goto(makeProfilePath(pubkey)) + // Chat export const makeChatPath = (pubkeys: string[]) => `/chat/${makeChatId(pubkeys)}` diff --git a/src/app/sync.ts b/src/app/sync.ts index 1b4abfbf..a0ab77ed 100644 --- a/src/app/sync.ts +++ b/src/app/sync.ts @@ -37,6 +37,8 @@ import { loadFollowList, loadMuteList, loadProfile, + userFollowList, + getFollows, repository, shouldUnwrap, hasNegentropy, @@ -248,6 +250,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) }) @@ -256,10 +269,13 @@ const syncUserData = () => { syncRelayList($userRelayList) }) + const unsubscribeFollowList = userFollowList.subscribe(syncFollowNetwork) + return () => { unsubscribersByKey.forEach(call) unsubscribeGroupList() unsubscribeRelayList() + unsubscribeFollowList() } } diff --git a/src/app/title.ts b/src/app/title.ts index fdc29f16..195818ac 100644 --- a/src/app/title.ts +++ b/src/app/title.ts @@ -1,5 +1,6 @@ import {append, identity, uniq} from "@welshman/lib" -import {repository} from "@welshman/app" +import * as nip19 from "nostr-tools/nip19" +import {repository, displayProfileByPubkey} from "@welshman/app" import {displayPubkey, getTagValue} from "@welshman/util" import {PLATFORM_NAME} from "@app/env" import {decodeRelay} from "@app/relays" @@ -24,6 +25,7 @@ const staticTitles = new Map([ ["/chat", "Messages"], ["/join", "Join Space"], ["/people", "Find People"], + ["/people/[npub]", "Profile"], ["/settings/about", "About"], ["/settings/profile", "Profile Settings"], ["/settings/content", "Content Settings"], @@ -120,6 +122,24 @@ export const getPageTitle = ({page, pubkey}: PageTitleContext) => { return makeTitle(getChatTitle(page.params.chat, pubkey)) } + if (routeId === "/people/[npub]") { + try { + const decoded = nip19.decode(page.params.npub!) + const profilePubkey = + decoded.type === "npub" + ? decoded.data + : decoded.type === "nprofile" + ? decoded.data.pubkey + : undefined + + if (profilePubkey) { + return makeTitle(displayProfileByPubkey(profilePubkey)) + } + } catch { + // fall through + } + } + if (routeId === "/spaces/[relay]/[h]") { return makeTitle(getRoomTitle(page.params)) } diff --git a/src/routes/[bech32]/+page.svelte b/src/routes/[bech32]/+page.svelte index 7d875ee0..fb27d3c3 100644 --- a/src/routes/[bech32]/+page.svelte +++ b/src/routes/[bech32]/+page.svelte @@ -8,13 +8,21 @@ import {page} from "$app/stores" import {goto} from "$app/navigation" import Spinner from "@lib/components/Spinner.svelte" - import {goToEvent} from "@app/routes" + import {goToEvent, makeProfilePath} from "@app/routes" const {bech32} = $page.params as MakeNonOptional const attemptToNavigate = async () => { const {type, data} = nip19.decode(bech32) as any + if (type === "npub") { + return goto(makeProfilePath(data), {replaceState: true}) + } + + if (type === "nprofile") { + return goto(makeProfilePath(data.pubkey), {replaceState: true}) + } + if (!["nevent", "naddr"].includes(type) && data.relays.length > 0) { return goto("/", {replaceState: true}) } diff --git a/src/routes/people/[npub]/+page.svelte b/src/routes/people/[npub]/+page.svelte new file mode 100644 index 00000000..0c3802a7 --- /dev/null +++ b/src/routes/people/[npub]/+page.svelte @@ -0,0 +1,77 @@ + + + + + {#if ready && pubkey} + + {:else} +

+ +

+ {/if} +
+