Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a804d094f | |||
| deb2b31466 | |||
| b86632e86e | |||
| 3f96b5547c | |||
| eebd764a18 | |||
| 3945685554 |
@@ -6,6 +6,7 @@
|
|||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import NoteCard from "@app/components/NoteCard.svelte"
|
import NoteCard from "@app/components/NoteCard.svelte"
|
||||||
|
import NoteContent from "@app/components/NoteContent.svelte"
|
||||||
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
|
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
|
||||||
import {deriveEvent} from "@app/repository"
|
import {deriveEvent} from "@app/repository"
|
||||||
import {entityLink} from "@app/env"
|
import {entityLink} from "@app/env"
|
||||||
@@ -43,7 +44,9 @@
|
|||||||
|
|
||||||
<Button class="my-2 block w-full max-w-full text-left" {onclick}>
|
<Button class="my-2 block w-full max-w-full text-left" {onclick}>
|
||||||
{#if $quote}
|
{#if $quote}
|
||||||
{#if $quote.kind === MESSAGE}
|
{#if $quote.content.trim().match(/^(nostr:)?nevent1[a-z0-9]+$/)}
|
||||||
|
<NoteContent {url} event={$quote} />
|
||||||
|
{:else if $quote.kind === MESSAGE}
|
||||||
<div
|
<div
|
||||||
class="border-l-2 border-solid border-l-primary py-1 pl-2 opacity-90"
|
class="border-l-2 border-solid border-l-primary py-1 pl-2 opacity-90"
|
||||||
style="background-color: color-mix(in srgb, var(--color-primary) 10%, var(--color-base-300) 90%);">
|
style="background-color: color-mix(in srgb, var(--color-primary) 10%, var(--color-base-300) 90%);">
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
import {onMount} from "svelte"
|
||||||
import {writable} from "svelte/store"
|
import {writable} from "svelte/store"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {isMobile, preventDefault} from "@lib/html"
|
import {isMobile, preventDefault} from "@lib/html"
|
||||||
import {fly} from "@lib/transition"
|
import {fly} from "@lib/transition"
|
||||||
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
|
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||||
|
import ChatComposeParent from "@app/components/ChatComposeParent.svelte"
|
||||||
import {publishComment} from "@app/comments"
|
import {publishComment} from "@app/comments"
|
||||||
import {canEnforceNip70} from "@app/relays"
|
import {canEnforceNip70} from "@app/relays"
|
||||||
import {PROTECTED} from "@app/groups"
|
import {PROTECTED, prependParent} from "@app/groups"
|
||||||
import {makeEditor} from "@app/editor"
|
import {makeEditor} from "@app/editor"
|
||||||
import {DraftKey} from "@app/drafts"
|
import {DraftKey} from "@app/drafts"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
@@ -18,8 +20,17 @@
|
|||||||
content?: string | object
|
content?: string | object
|
||||||
}
|
}
|
||||||
|
|
||||||
const {url, event, onClose, onSubmit} = $props()
|
type Props = {
|
||||||
const draftKey = new DraftKey<Values>(`reply:${event.id}`)
|
url: string
|
||||||
|
event: TrustedEvent
|
||||||
|
parent?: TrustedEvent
|
||||||
|
onClose: () => void
|
||||||
|
onClearParent?: () => void
|
||||||
|
onSubmit: (thunk: unknown) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, event, parent, onClose, onClearParent, onSubmit}: Props = $props()
|
||||||
|
const draftKey = new DraftKey<Values>(`reply:${event.id}:${parent?.id || ""}`)
|
||||||
const initialValues = draftKey.get()
|
const initialValues = draftKey.get()
|
||||||
const shouldProtect = canEnforceNip70(url)
|
const shouldProtect = canEnforceNip70(url)
|
||||||
const uploading = writable(false)
|
const uploading = writable(false)
|
||||||
@@ -31,8 +42,8 @@
|
|||||||
if ($uploading) return
|
if ($uploading) return
|
||||||
|
|
||||||
const ed = await editor
|
const ed = await editor
|
||||||
const content = ed.getText({blockSeparator: "\n"}).trim()
|
let content = ed.getText({blockSeparator: "\n"}).trim()
|
||||||
const tags = ed.storage.nostr.getEditorTags()
|
let tags = ed.storage.nostr.getEditorTags()
|
||||||
|
|
||||||
if (await shouldProtect) {
|
if (await shouldProtect) {
|
||||||
tags.push(PROTECTED)
|
tags.push(PROTECTED)
|
||||||
@@ -45,6 +56,10 @@
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parent) {
|
||||||
|
;({content, tags} = prependParent(parent, {content, tags}, url))
|
||||||
|
}
|
||||||
|
|
||||||
draftKey.clear()
|
draftKey.clear()
|
||||||
onSubmit(publishComment({event, content, tags, relays: [url]}))
|
onSubmit(publishComment({event, content, tags, relays: [url]}))
|
||||||
}
|
}
|
||||||
@@ -87,6 +102,9 @@
|
|||||||
onsubmit={preventDefault(submit)}
|
onsubmit={preventDefault(submit)}
|
||||||
class="left-content bottom-sai right-sai fixed z-feature mb-14 md:mb-0 w-full md:w-auto pr-2">
|
class="left-content bottom-sai right-sai fixed z-feature mb-14 md:mb-0 w-full md:w-auto pr-2">
|
||||||
<div class="card2 mx-2 my-2 bg-alt shadow-md">
|
<div class="card2 mx-2 my-2 bg-alt shadow-md">
|
||||||
|
{#if parent}
|
||||||
|
<ChatComposeParent event={parent} clear={() => onClearParent?.()} verb="Replying to" />
|
||||||
|
{/if}
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="note-editor grow overflow-hidden">
|
<div class="note-editor grow overflow-hidden">
|
||||||
<EditorContent {autofocus} {editor} />
|
<EditorContent {autofocus} {editor} />
|
||||||
|
|||||||
@@ -76,7 +76,7 @@
|
|||||||
onclick={() => selectAccount(option)}
|
onclick={() => selectAccount(option)}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
class="card2 bg-alt flex w-full items-center p-3 text-left">
|
class="card2 bg-alt flex w-full items-center p-3 text-left">
|
||||||
<Profile pubkey={option.pubkey} />
|
<Profile inert pubkey={option.pubkey} />
|
||||||
</Button>
|
</Button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,11 +4,16 @@
|
|||||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||||
import {Router} from "@welshman/router"
|
import {Router} from "@welshman/router"
|
||||||
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
|
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
|
||||||
|
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
||||||
|
import {fly} from "@lib/transition"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Popover from "@lib/components/Popover.svelte"
|
||||||
import EmojiButton from "@lib/components/EmojiButton.svelte"
|
import EmojiButton from "@lib/components/EmojiButton.svelte"
|
||||||
import NoteContent from "@app/components/NoteContent.svelte"
|
import NoteContent from "@app/components/NoteContent.svelte"
|
||||||
import NoteCard from "@app/components/NoteCard.svelte"
|
import NoteCard from "@app/components/NoteCard.svelte"
|
||||||
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
import ReactionSummary from "@app/components/ReactionSummary.svelte"
|
||||||
|
import ProfileNoteMenu from "@app/components/ProfileNoteMenu.svelte"
|
||||||
import {publishDelete} from "@app/deletes"
|
import {publishDelete} from "@app/deletes"
|
||||||
import {publishReaction} from "@app/reactions"
|
import {publishReaction} from "@app/reactions"
|
||||||
import {canEnforceNip70} from "@app/relays"
|
import {canEnforceNip70} from "@app/relays"
|
||||||
@@ -17,9 +22,10 @@
|
|||||||
event: TrustedEvent
|
event: TrustedEvent
|
||||||
children?: Snippet
|
children?: Snippet
|
||||||
url?: string
|
url?: string
|
||||||
|
editable?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const {url, event, children}: Props = $props()
|
const {url, event, children, editable = false}: Props = $props()
|
||||||
|
|
||||||
const relays = url ? [url] : Router.get().Event(event).getUrls()
|
const relays = url ? [url] : Router.get().Event(event).getUrls()
|
||||||
|
|
||||||
@@ -38,9 +44,35 @@
|
|||||||
content: emoji.unicode,
|
content: emoji.unicode,
|
||||||
protect: await shouldProtect,
|
protect: await shouldProtect,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const toggleMenu = () => {
|
||||||
|
showMenu = !showMenu
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeMenu = () => {
|
||||||
|
showMenu = false
|
||||||
|
}
|
||||||
|
|
||||||
|
let showMenu = $state(false)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<NoteCard {event} {url} class="cv card2 bg-alt">
|
<NoteCard {event} {url} class="cv card2 bg-alt">
|
||||||
|
{#if editable}
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<div class="relative">
|
||||||
|
<Button class="btn btn-circle btn-ghost btn-xs" onclick={toggleMenu}>
|
||||||
|
<Icon icon={MenuDots} size={4} />
|
||||||
|
</Button>
|
||||||
|
{#if showMenu}
|
||||||
|
<Popover hideOnClick onClose={closeMenu}>
|
||||||
|
<div transition:fly class="absolute right-0 z-popover">
|
||||||
|
<ProfileNoteMenu {event} onClose={closeMenu} />
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<NoteContent {event} expandMode="inline" />
|
<NoteContent {event} expandMode="inline" />
|
||||||
<div class="flex w-full justify-between gap-2">
|
<div class="flex w-full justify-between gap-2">
|
||||||
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-right">
|
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-right">
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import UserCircle from "@assets/icons/user-circle.svg?dataurl"
|
import UserCircle from "@assets/icons/user-circle.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
import Profile from "@app/components/Profile.svelte"
|
import Profile from "@app/components/Profile.svelte"
|
||||||
import ProfileInfo from "@app/components/ProfileInfo.svelte"
|
import ProfileInfo from "@app/components/ProfileInfo.svelte"
|
||||||
import ProfileBadges from "@app/components/ProfileBadges.svelte"
|
import ProfileBadges from "@app/components/ProfileBadges.svelte"
|
||||||
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
import {makeProfilePath} from "@app/routes"
|
||||||
import {pushModal} from "@app/modal"
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
pubkey: string
|
pubkey: string
|
||||||
@@ -14,22 +13,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const {pubkey, url}: Props = $props()
|
const {pubkey, url}: Props = $props()
|
||||||
|
|
||||||
const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="card2 bg-alt flex flex-col gap-4 shadow-md">
|
<div class="card2 bg-alt flex flex-col gap-4 shadow-md">
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<Profile {pubkey} {url} />
|
<Profile {pubkey} {url} />
|
||||||
<Button onclick={openProfile} class="btn btn-primary hidden sm:flex">
|
<Link href={makeProfilePath(pubkey)} class="btn btn-primary hidden sm:flex">
|
||||||
<Icon icon={UserCircle} />
|
<Icon icon={UserCircle} />
|
||||||
View Profile
|
View Profile
|
||||||
</Button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<ProfileInfo {pubkey} {url} />
|
<ProfileInfo {pubkey} {url} />
|
||||||
<ProfileBadges {pubkey} {url} />
|
<ProfileBadges {pubkey} {url} />
|
||||||
<Button onclick={openProfile} class="btn btn-primary sm:hidden">
|
<Link href={makeProfilePath(pubkey)} class="btn btn-primary sm:hidden">
|
||||||
<Icon icon={UserCircle} />
|
<Icon icon={UserCircle} />
|
||||||
View Profile
|
View Profile
|
||||||
</Button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,10 +29,8 @@
|
|||||||
const openSpaces = () => pushModal(ProfileSpaces, {pubkey, url})
|
const openSpaces = () => pushModal(ProfileSpaces, {pubkey, url})
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
// Make sure we have their relay selections before we load their posts
|
|
||||||
await loadRelayList(pubkey)
|
await loadRelayList(pubkey)
|
||||||
|
|
||||||
// Load groups and at least one note, regardless of time frame
|
|
||||||
load({
|
load({
|
||||||
filters: [
|
filters: [
|
||||||
{authors: [pubkey], kinds: [ROOMS]},
|
{authors: [pubkey], kinds: [ROOMS]},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import cx from "classnames"
|
import cx from "classnames"
|
||||||
import {getProfile, loadProfile} from "@welshman/app"
|
import {loadProfile} from "@welshman/app"
|
||||||
import {isMobile} from "@lib/html"
|
import {isMobile} from "@lib/html"
|
||||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||||
|
|
||||||
@@ -22,17 +22,13 @@
|
|||||||
: {box: "h-8 w-8", overlap: "-mr-3", overflow: "text-xs"},
|
: {box: "h-8 w-8", overlap: "-mr-3", overflow: "text-xs"},
|
||||||
)
|
)
|
||||||
|
|
||||||
for (const pubkey of pubkeys) {
|
$effect(() => {
|
||||||
loadProfile(pubkey)
|
for (const pk of pubkeys) {
|
||||||
}
|
loadProfile(pk)
|
||||||
|
}
|
||||||
const visiblePubkeys = $derived.by(() => {
|
|
||||||
const filtered = pubkeys.filter(pubkey => getProfile(pubkey)?.picture)
|
|
||||||
|
|
||||||
return filtered.length > 0 ? filtered : pubkeys.slice(0, 1)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const displayPubkeys = $derived(visiblePubkeys.toSorted().slice(0, effectiveLimit))
|
const displayPubkeys = $derived([...pubkeys].toSorted().slice(0, effectiveLimit))
|
||||||
const overflowCount = $derived(Math.max(0, pubkeys.length - effectiveLimit))
|
const overflowCount = $derived(Math.max(0, pubkeys.length - effectiveLimit))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -10,14 +10,12 @@
|
|||||||
} from "@welshman/app"
|
} from "@welshman/app"
|
||||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
import Code2 from "@assets/icons/code-2.svg?dataurl"
|
import Code2 from "@assets/icons/code-2.svg?dataurl"
|
||||||
import Letter from "@assets/icons/letter-opened.svg?dataurl"
|
import UserCircle from "@assets/icons/user-circle.svg?dataurl"
|
||||||
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
||||||
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
|
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
|
||||||
import Restart from "@assets/icons/restart.svg?dataurl"
|
import Restart from "@assets/icons/restart.svg?dataurl"
|
||||||
import {fly} from "@lib/transition"
|
import {fly} from "@lib/transition"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import ImageIcon from "@lib/components/ImageIcon.svelte"
|
|
||||||
import Link from "@lib/components/Link.svelte"
|
|
||||||
import Confirm from "@lib/components/Confirm.svelte"
|
import Confirm from "@lib/components/Confirm.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Popover from "@lib/components/Popover.svelte"
|
import Popover from "@lib/components/Popover.svelte"
|
||||||
@@ -28,11 +26,10 @@
|
|||||||
import ProfileInfo from "@app/components/ProfileInfo.svelte"
|
import ProfileInfo from "@app/components/ProfileInfo.svelte"
|
||||||
import EventInfo from "@app/components/EventInfo.svelte"
|
import EventInfo from "@app/components/EventInfo.svelte"
|
||||||
import ProfileBadges from "@app/components/ProfileBadges.svelte"
|
import ProfileBadges from "@app/components/ProfileBadges.svelte"
|
||||||
import {pubkeyLink} from "@app/env"
|
|
||||||
import {deriveUserIsSpaceAdmin, deriveSpaceBannedPubkeyItems, addSpaceMembers} from "@app/members"
|
import {deriveUserIsSpaceAdmin, deriveSpaceBannedPubkeyItems, addSpaceMembers} from "@app/members"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
import {goToChat} from "@app/routes"
|
import {goToProfile} from "@app/routes"
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
pubkey: string
|
pubkey: string
|
||||||
@@ -53,9 +50,9 @@
|
|||||||
|
|
||||||
const showInfo = () => pushModal(EventInfo, {url, event: $profile!.event})
|
const showInfo = () => pushModal(EventInfo, {url, event: $profile!.event})
|
||||||
|
|
||||||
const openChat = () => goToChat([pubkey])
|
const viewProfile = () => goToProfile(pubkey)
|
||||||
|
|
||||||
const toggleMenu = (pubkey: string) => {
|
const toggleMenu = () => {
|
||||||
showMenu = !showMenu
|
showMenu = !showMenu
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +104,7 @@
|
|||||||
<Profile showPubkey avatarSize={14} {pubkey} {url} />
|
<Profile showPubkey avatarSize={14} {pubkey} {url} />
|
||||||
{#if $profile || $userIsAdmin}
|
{#if $profile || $userIsAdmin}
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<Button class="btn btn-circle btn-ghost btn-sm" onclick={() => toggleMenu(pubkey)}>
|
<Button class="btn btn-circle btn-ghost btn-sm" onclick={toggleMenu}>
|
||||||
<Icon icon={MenuDots} />
|
<Icon icon={MenuDots} />
|
||||||
</Button>
|
</Button>
|
||||||
{#if showMenu}
|
{#if showMenu}
|
||||||
@@ -156,13 +153,9 @@
|
|||||||
Go back
|
Go back
|
||||||
</Button>
|
</Button>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Link external href={pubkeyLink(pubkey)} class="btn btn-neutral">
|
<Button onclick={viewProfile} class="btn btn-primary">
|
||||||
<ImageIcon alt="" src="/coracle.png" />
|
<Icon icon={UserCircle} />
|
||||||
Open in Coracle
|
View Full Profile
|
||||||
</Link>
|
|
||||||
<Button onclick={openChat} class="btn btn-primary">
|
|
||||||
<Icon icon={Letter} />
|
|
||||||
Message
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import {preventDefault} from "@lib/html"
|
import {preventDefault} from "@lib/html"
|
||||||
import UserCircle from "@assets/icons/user-circle.svg?dataurl"
|
import UserCircle from "@assets/icons/user-circle.svg?dataurl"
|
||||||
import MapPoint from "@assets/icons/map-point.svg?dataurl"
|
import MapPoint from "@assets/icons/map-point.svg?dataurl"
|
||||||
|
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Field from "@lib/components/Field.svelte"
|
import Field from "@lib/components/Field.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
@@ -85,6 +86,24 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</Field>
|
</Field>
|
||||||
{#if !isSignup}
|
{#if !isSignup}
|
||||||
|
<Field>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Website</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<label class="input input-bordered flex w-full items-center gap-2">
|
||||||
|
<Icon icon={LinkRound} />
|
||||||
|
<input
|
||||||
|
bind:value={values.profile.website}
|
||||||
|
class="grow"
|
||||||
|
type="text"
|
||||||
|
placeholder="https://" />
|
||||||
|
</label>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet info()}
|
||||||
|
A link to your personal site or portfolio.
|
||||||
|
{/snippet}
|
||||||
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
{#snippet label()}
|
{#snippet label()}
|
||||||
<p>Nostr Address</p>
|
<p>Nostr Address</p>
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {nthEq} from "@welshman/lib"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {getListTags, getEventTagValues} from "@welshman/util"
|
||||||
|
import {pin, unpin, tagEvent, userPinList, waitForThunkError} from "@welshman/app"
|
||||||
|
import {Router} from "@welshman/router"
|
||||||
|
import Pin from "@assets/icons/pin.svg?dataurl"
|
||||||
|
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Confirm from "@lib/components/Confirm.svelte"
|
||||||
|
import {publishDelete} from "@app/deletes"
|
||||||
|
import {pushModal} from "@app/modal"
|
||||||
|
import {pushToast} from "@app/toast"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
event: TrustedEvent
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const {event, onClose}: Props = $props()
|
||||||
|
|
||||||
|
const relays = Router.get().Event(event).getUrls()
|
||||||
|
const pinnedIds = $derived(getEventTagValues(getListTags($userPinList)))
|
||||||
|
const isPinned = $derived(pinnedIds.includes(event.id))
|
||||||
|
|
||||||
|
const togglePin = async () => {
|
||||||
|
onClose()
|
||||||
|
|
||||||
|
const thunk = isPinned ? await unpin(event.id) : await pin(tagEvent(event).find(nthEq(0, "e"))!)
|
||||||
|
|
||||||
|
const error = await waitForThunkError(thunk)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
pushToast({theme: "error", message: "Failed to update pinned notes."})
|
||||||
|
} else {
|
||||||
|
pushToast({message: isPinned ? "Note unpinned." : "Note pinned to your profile."})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
onClose()
|
||||||
|
|
||||||
|
pushModal(Confirm, {
|
||||||
|
title: "Delete Note",
|
||||||
|
message: "Are you sure you want to delete this note?",
|
||||||
|
confirm: async () => {
|
||||||
|
await publishDelete({event, relays, protect: false})
|
||||||
|
|
||||||
|
pushToast({message: "Delete request sent."})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ul class="menu whitespace-nowrap rounded-box bg-base-100 p-2 shadow-md">
|
||||||
|
<li>
|
||||||
|
<Button onclick={togglePin}>
|
||||||
|
<Icon size={4} icon={Pin} />
|
||||||
|
{isPinned ? "Unpin from profile" : "Pin to profile"}
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Button onclick={confirmDelete} class="text-error">
|
||||||
|
<Icon size={4} icon={TrashBin2} />
|
||||||
|
Delete note
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as nip19 from "nostr-tools/nip19"
|
||||||
|
import cx from "classnames"
|
||||||
|
import {goto} from "$app/navigation"
|
||||||
|
import {compressFile} from "@lib/html"
|
||||||
|
import {displayPubkey} from "@welshman/util"
|
||||||
|
import {
|
||||||
|
pubkey,
|
||||||
|
followLists,
|
||||||
|
deriveProfile,
|
||||||
|
deriveProfileDisplay,
|
||||||
|
deriveUserWotScore,
|
||||||
|
getFollows,
|
||||||
|
follow,
|
||||||
|
unfollow,
|
||||||
|
tagPubkey,
|
||||||
|
} from "@welshman/app"
|
||||||
|
import {clamp} from "@welshman/lib"
|
||||||
|
import Copy from "@assets/icons/copy.svg?dataurl"
|
||||||
|
import QrCode from "@assets/icons/qr-code.svg?dataurl"
|
||||||
|
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
||||||
|
import SquareArrowRight from "@assets/icons/square-arrow-right-up.svg?dataurl"
|
||||||
|
import Letter from "@assets/icons/letter-opened.svg?dataurl"
|
||||||
|
import UserPlus from "@assets/icons/user-plus.svg?dataurl"
|
||||||
|
import PenNewSquare from "@assets/icons/pen-new-square.svg?dataurl"
|
||||||
|
import Settings from "@assets/icons/settings.svg?dataurl"
|
||||||
|
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
|
||||||
|
import Code2 from "@assets/icons/code-2.svg?dataurl"
|
||||||
|
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
|
||||||
|
import Shield from "@assets/icons/shield-minimalistic.svg?dataurl"
|
||||||
|
import UsersGroup from "@assets/icons/users-group-rounded.svg?dataurl"
|
||||||
|
import {fly} from "@lib/transition"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Link from "@lib/components/Link.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Popover from "@lib/components/Popover.svelte"
|
||||||
|
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||||
|
import ProfileInfo from "@app/components/ProfileInfo.svelte"
|
||||||
|
import ProfileTrust from "@app/components/ProfileTrust.svelte"
|
||||||
|
import ProfileSharedSpaces from "@app/components/ProfileSharedSpaces.svelte"
|
||||||
|
import ProfilePinnedNotes from "@app/components/ProfilePinnedNotes.svelte"
|
||||||
|
import ProfilePageNotes from "@app/components/ProfilePageNotes.svelte"
|
||||||
|
import ProfilePageSpaces from "@app/components/ProfilePageSpaces.svelte"
|
||||||
|
import ProfileEdit from "@app/components/ProfileEdit.svelte"
|
||||||
|
import ProfileQrCode from "@app/components/ProfileQrCode.svelte"
|
||||||
|
import EventInfo from "@app/components/EventInfo.svelte"
|
||||||
|
import {deriveGroupList, getSpaceUrlsFromGroupList, userSpaceUrls} from "@app/groups"
|
||||||
|
import {updateProfile} from "@app/profiles"
|
||||||
|
import {uploadFile} from "@app/uploads"
|
||||||
|
import {pushModal} from "@app/modal"
|
||||||
|
import {clip, pushToast} from "@app/toast"
|
||||||
|
import {goToChat} from "@app/routes"
|
||||||
|
|
||||||
|
type Tab = "about" | "notes" | "spaces"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
pubkey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const {pubkey: target}: Props = $props()
|
||||||
|
|
||||||
|
const profile = deriveProfile(target)
|
||||||
|
const profileDisplay = deriveProfileDisplay(target)
|
||||||
|
const groupList = deriveGroupList(target)
|
||||||
|
const score = deriveUserWotScore(target)
|
||||||
|
const encodedNpub = nip19.npubEncode(target)
|
||||||
|
const isSelf = $derived($pubkey === target)
|
||||||
|
const isFollowing = $derived.by(() => {
|
||||||
|
void $followLists
|
||||||
|
|
||||||
|
return $pubkey ? getFollows($pubkey).includes(target) : false
|
||||||
|
})
|
||||||
|
const spaceUrls = $derived(getSpaceUrlsFromGroupList($groupList))
|
||||||
|
const sharedSpaceUrls = $derived($userSpaceUrls.filter(url => spaceUrls.includes(url)))
|
||||||
|
const displayScore = $derived(Math.round(clamp([0, 100], $score)))
|
||||||
|
const website = $derived($profile?.website?.replace(/^https?:\/\//, ""))
|
||||||
|
const websiteHref = $derived(
|
||||||
|
$profile?.website?.match(/^https?:\/\//)
|
||||||
|
? $profile.website
|
||||||
|
: `https://${$profile?.website || ""}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
let tab = $state<Tab>("about")
|
||||||
|
let showMenu = $state(false)
|
||||||
|
let bannerLoading = $state(false)
|
||||||
|
let bannerInput: HTMLInputElement | undefined = $state()
|
||||||
|
|
||||||
|
const setTab = (next: Tab) => {
|
||||||
|
tab = next
|
||||||
|
}
|
||||||
|
|
||||||
|
const showAboutTab = () => setTab("about")
|
||||||
|
|
||||||
|
const showNotesTab = () => setTab("notes")
|
||||||
|
|
||||||
|
const showSpacesTab = () => setTab("spaces")
|
||||||
|
|
||||||
|
const copyNpub = () => clip(encodedNpub)
|
||||||
|
|
||||||
|
const showQr = () => pushModal(ProfileQrCode, {code: encodedNpub})
|
||||||
|
|
||||||
|
const toggleMenu = () => {
|
||||||
|
showMenu = !showMenu
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeMenu = () => {
|
||||||
|
showMenu = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const showInfo = () => {
|
||||||
|
closeMenu()
|
||||||
|
pushModal(EventInfo, {event: $profile!.event})
|
||||||
|
}
|
||||||
|
|
||||||
|
const startEdit = () => pushModal(ProfileEdit)
|
||||||
|
|
||||||
|
const openSettings = () => goto("/settings/profile")
|
||||||
|
|
||||||
|
const openSpaces = () => goto("/spaces")
|
||||||
|
|
||||||
|
const openRelaySettings = () => goto("/settings/relays")
|
||||||
|
|
||||||
|
const openChat = () => goToChat([target])
|
||||||
|
|
||||||
|
const toggleFollow = async () => {
|
||||||
|
if (!$pubkey || isSelf) return
|
||||||
|
|
||||||
|
if (isFollowing) {
|
||||||
|
await unfollow(target)
|
||||||
|
} else {
|
||||||
|
await follow(tagPubkey(target))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openBannerPicker = () => bannerInput?.click()
|
||||||
|
|
||||||
|
const onBannerChange = async (e: Event) => {
|
||||||
|
const input = e.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
|
||||||
|
input.value = ""
|
||||||
|
|
||||||
|
if (!file || !$profile) return
|
||||||
|
|
||||||
|
bannerLoading = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {result} = await uploadFile(await compressFile(file))
|
||||||
|
|
||||||
|
if (result?.url) {
|
||||||
|
await updateProfile({profile: {...$profile, banner: result.url}})
|
||||||
|
pushToast({message: "Banner updated."})
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
bannerLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="col-4">
|
||||||
|
<div class="flex flex-col gap-3 xl:flex-row xl:items-start">
|
||||||
|
<div class="min-w-0 flex-1 overflow-hidden border-base-300 bg-alt md:rounded-box md:border">
|
||||||
|
<div class="relative overflow-hidden border-b border-base-300 bg-base-300">
|
||||||
|
{#if $profile?.banner}
|
||||||
|
<img src={$profile.banner} alt="" class="h-28 w-full object-cover sm:h-32 md:h-40" />
|
||||||
|
{:else}
|
||||||
|
<div class="h-28 w-full bg-linear-to-br from-base-300 to-base-100 sm:h-32 md:h-40"></div>
|
||||||
|
{/if}
|
||||||
|
{#if isSelf}
|
||||||
|
<Button
|
||||||
|
class="btn btn-neutral btn-sm absolute top-2 right-2 sm:top-3 sm:right-3"
|
||||||
|
disabled={bannerLoading}
|
||||||
|
onclick={openBannerPicker}>
|
||||||
|
<Icon icon={GallerySend} size={4} />
|
||||||
|
<span class="hidden sm:inline">Change banner</span>
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
bind:this={bannerInput}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden"
|
||||||
|
onchange={onBannerChange} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative border-b border-base-300 px-4 pb-4 sm:px-3 sm:pb-5">
|
||||||
|
<div class="-mt-8 sm:-mt-10">
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
|
||||||
|
<div class="w-fit shrink-0">
|
||||||
|
{#if isSelf}
|
||||||
|
<Button
|
||||||
|
class="w-fit rounded-full sm:border-4 sm:border-base-200 sm:bg-base-200"
|
||||||
|
onclick={startEdit}>
|
||||||
|
<ProfileCircle pubkey={target} size={16} class="sm:hidden" />
|
||||||
|
<ProfileCircle pubkey={target} size={20} class="hidden sm:block" />
|
||||||
|
</Button>
|
||||||
|
{:else}
|
||||||
|
<div class="w-fit rounded-full sm:border-4 sm:border-base-200 sm:bg-base-200">
|
||||||
|
<ProfileCircle pubkey={target} size={16} class="sm:hidden" />
|
||||||
|
<ProfileCircle pubkey={target} size={20} class="hidden sm:block" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col gap-3 sm:gap-2 sm:pt-14">
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-x-4">
|
||||||
|
<h1 class="min-w-0 text-xl leading-tight font-bold sm:text-2xl">
|
||||||
|
{$profileDisplay}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{#if (isSelf || $pubkey) && $profile}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#if isSelf}
|
||||||
|
<Button
|
||||||
|
class="btn btn-primary btn-md flex-1 sm:btn-sm sm:flex-none"
|
||||||
|
onclick={startEdit}>
|
||||||
|
<Icon icon={PenNewSquare} size={4} />
|
||||||
|
Edit profile
|
||||||
|
</Button>
|
||||||
|
{:else}
|
||||||
|
<Button
|
||||||
|
class="btn btn-neutral btn-md flex-1 sm:btn-sm sm:flex-none"
|
||||||
|
onclick={toggleFollow}>
|
||||||
|
<Icon icon={UserPlus} size={4} />
|
||||||
|
{isFollowing ? "Unfollow" : "Follow"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
class="btn btn-primary btn-md flex-1 sm:btn-sm sm:flex-none"
|
||||||
|
onclick={openChat}>
|
||||||
|
<Icon icon={Letter} size={4} />
|
||||||
|
Message
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="relative shrink-0">
|
||||||
|
<Button class="btn btn-circle btn-ghost btn-sm" onclick={toggleMenu}>
|
||||||
|
<Icon icon={MenuDots} />
|
||||||
|
</Button>
|
||||||
|
{#if showMenu}
|
||||||
|
<Popover hideOnClick onClose={closeMenu}>
|
||||||
|
<ul
|
||||||
|
transition:fly
|
||||||
|
class="bg-alt menu absolute right-0 z-popover w-48 gap-1 rounded-box p-2 shadow-md">
|
||||||
|
<li>
|
||||||
|
<Button onclick={showInfo}>
|
||||||
|
<Icon icon={Code2} />
|
||||||
|
User Details
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
{#if isSelf}
|
||||||
|
<li>
|
||||||
|
<Button onclick={openSettings}>
|
||||||
|
<Icon icon={Settings} />
|
||||||
|
Account Settings
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
</Popover>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-1 text-sm leading-none opacity-75">
|
||||||
|
<span>{displayPubkey(target)}</span>
|
||||||
|
<Button onclick={copyNpub} class="btn btn-ghost btn-xs h-5 min-h-5 w-5 p-0">
|
||||||
|
<Icon size={3} icon={Copy} />
|
||||||
|
</Button>
|
||||||
|
<Button onclick={showQr} class="btn btn-ghost btn-xs h-5 min-h-5 w-5 p-0">
|
||||||
|
<Icon size={3} icon={QrCode} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if website}
|
||||||
|
<Link
|
||||||
|
external
|
||||||
|
href={websiteHref}
|
||||||
|
class="link link-primary row-2 w-fit text-sm font-medium">
|
||||||
|
<Icon icon={LinkRound} size={4} />
|
||||||
|
{website}
|
||||||
|
<Icon icon={SquareArrowRight} size={4} />
|
||||||
|
</Link>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="badge badge-neutral inline-flex h-6 items-center gap-1.5 border-0">
|
||||||
|
<Icon icon={Shield} size={3} />
|
||||||
|
Trust score {displayScore}
|
||||||
|
</span>
|
||||||
|
{#if sharedSpaceUrls.length > 0}
|
||||||
|
<button
|
||||||
|
class="badge badge-neutral inline-flex h-6 items-center gap-1.5 border-0"
|
||||||
|
onclick={showSpacesTab}>
|
||||||
|
<Icon icon={UsersGroup} size={3} />
|
||||||
|
{sharedSpaceUrls.length} shared {sharedSpaceUrls.length === 1
|
||||||
|
? "space"
|
||||||
|
: "spaces"}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="sticky top-0 z-10 border-b border-base-300 bg-base-200/90 px-4 backdrop-blur-sm sm:px-3">
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
class="tabs tabs-bordered -mb-px flex w-full justify-between bg-transparent sm:justify-start">
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
class={cx("tab flex-none px-0 sm:px-4", {"tab-active": tab === "about"})}
|
||||||
|
onclick={showAboutTab}>
|
||||||
|
About
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
class={cx("tab flex-none px-0 sm:px-4", {"tab-active": tab === "notes"})}
|
||||||
|
onclick={showNotesTab}>
|
||||||
|
Notes
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
class={cx("tab flex-none px-0 sm:px-4", {"tab-active": tab === "spaces"})}
|
||||||
|
onclick={showSpacesTab}>
|
||||||
|
Spaces
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-4 py-3 sm:px-3 sm:py-4">
|
||||||
|
<div class="sm:pl-3">
|
||||||
|
{#if tab === "about"}
|
||||||
|
<div class="col-3 sm:col-4">
|
||||||
|
<ProfileInfo pubkey={target} />
|
||||||
|
<div class="col-3 xl:hidden">
|
||||||
|
<ProfileTrust pubkey={target} />
|
||||||
|
<ProfileSharedSpaces pubkey={target} limit={3} onViewAll={showSpacesTab} />
|
||||||
|
</div>
|
||||||
|
<ProfilePinnedNotes
|
||||||
|
pubkey={target}
|
||||||
|
limit={2}
|
||||||
|
editable={isSelf}
|
||||||
|
onViewAll={showNotesTab} />
|
||||||
|
</div>
|
||||||
|
{:else if tab === "notes"}
|
||||||
|
{#if isSelf}
|
||||||
|
<p class="mb-4 text-sm opacity-75">
|
||||||
|
Notes are public posts on your write relays. Pin notes to highlight them on your
|
||||||
|
profile, or manage relays in
|
||||||
|
<Button class="link link-primary" onclick={openRelaySettings}>relay settings</Button
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<ProfilePageNotes pubkey={target} editable={isSelf} />
|
||||||
|
{:else}
|
||||||
|
{#if isSelf}
|
||||||
|
<div class="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<p class="text-sm opacity-75">Spaces come from your published group list.</p>
|
||||||
|
<Button class="btn btn-neutral btn-sm w-full sm:w-auto" onclick={openSpaces}>
|
||||||
|
Manage spaces
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<ProfilePageSpaces pubkey={target} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="hidden w-80 shrink-0 xl:block xl:border-l xl:border-base-300 xl:pl-4">
|
||||||
|
<div class="col-3">
|
||||||
|
<ProfileTrust pubkey={target} />
|
||||||
|
<ProfileSharedSpaces pubkey={target} limit={3} onViewAll={showSpacesTab} />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {onMount} from "svelte"
|
||||||
|
import {sortBy, uniqBy} from "@welshman/lib"
|
||||||
|
import {feedFromFilter} from "@welshman/feeds"
|
||||||
|
import {NOTE, getReplyTags} from "@welshman/util"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {makeFeedController} from "@welshman/app"
|
||||||
|
import {createScroller} from "@lib/html"
|
||||||
|
import {fly} from "@lib/transition"
|
||||||
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
|
import NoteItem from "@app/components/NoteItem.svelte"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
pubkey: string
|
||||||
|
editable?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const {pubkey, editable = false}: Props = $props()
|
||||||
|
|
||||||
|
const ctrl = makeFeedController({
|
||||||
|
useWindowing: true,
|
||||||
|
feed: feedFromFilter({kinds: [NOTE], authors: [pubkey]}),
|
||||||
|
onEvent: (event: TrustedEvent) => {
|
||||||
|
if (getReplyTags(event.tags).replies.length === 0) {
|
||||||
|
buffer.push(event)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
let element: Element | undefined = $state()
|
||||||
|
let events: TrustedEvent[] = $state([])
|
||||||
|
let buffer: TrustedEvent[] = []
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const scroller = createScroller({
|
||||||
|
element: element!,
|
||||||
|
delay: 300,
|
||||||
|
threshold: 3000,
|
||||||
|
onScroll: () => {
|
||||||
|
buffer = uniqBy(
|
||||||
|
e => e.id,
|
||||||
|
sortBy(e => -e.created_at, buffer),
|
||||||
|
)
|
||||||
|
|
||||||
|
events = uniqBy(e => e.id, [...events, ...buffer.splice(0, 5)])
|
||||||
|
|
||||||
|
if (buffer.length < 50) {
|
||||||
|
ctrl.load(50)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => scroller.stop()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="col-4" bind:this={element}>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
{#each events as event (event.id)}
|
||||||
|
<div in:fly>
|
||||||
|
<NoteItem {event} {editable} />
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<p class="center my-12 flex">
|
||||||
|
<Spinner loading />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Link from "@lib/components/Link.svelte"
|
||||||
|
import RelayIcon from "@app/components/RelayIcon.svelte"
|
||||||
|
import RelayName from "@app/components/RelayName.svelte"
|
||||||
|
import {deriveGroupList, getSpaceUrlsFromGroupList, groupListPubkeysByUrl} from "@app/groups"
|
||||||
|
import {makeSpacePath} from "@app/routes"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
pubkey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const {pubkey}: Props = $props()
|
||||||
|
|
||||||
|
const groupList = deriveGroupList(pubkey)
|
||||||
|
const spaceUrls = $derived(getSpaceUrlsFromGroupList($groupList))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="col-2">
|
||||||
|
{#each spaceUrls as url (url)}
|
||||||
|
{@const count = $groupListPubkeysByUrl.get(url)?.size || 0}
|
||||||
|
<div
|
||||||
|
class="card2 card2-sm bg-alt flex flex-col gap-3 border border-base-300 sm:flex-row sm:items-center">
|
||||||
|
<RelayIcon {url} size={10} />
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<RelayName {url} class="font-medium" />
|
||||||
|
<p class="text-sm opacity-75">
|
||||||
|
{#if count >= 1000}
|
||||||
|
{(count / 1000).toFixed(1).replace(/\.0$/, "")}K members
|
||||||
|
{:else}
|
||||||
|
{count} {count === 1 ? "member" : "members"}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
<p class="ellipsize text-xs opacity-60">{url}</p>
|
||||||
|
</div>
|
||||||
|
<Link class="btn btn-primary btn-sm w-full sm:w-auto" href={makeSpacePath(url)}>
|
||||||
|
Go to space
|
||||||
|
<Icon icon={AltArrowRight} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="card2 bg-alt border border-base-300 text-center">
|
||||||
|
<p class="opacity-75">No spaces found for this user.</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {onMount} from "svelte"
|
||||||
|
import {sortBy} from "@welshman/lib"
|
||||||
|
import {getListTags, getEventTagValues} from "@welshman/util"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {derivePinList, repository} from "@welshman/app"
|
||||||
|
import {Router} from "@welshman/router"
|
||||||
|
import {load} from "@welshman/net"
|
||||||
|
import {fly} from "@lib/transition"
|
||||||
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
|
import NoteItem from "@app/components/NoteItem.svelte"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
pubkey: string
|
||||||
|
limit?: number
|
||||||
|
onViewAll?: () => void
|
||||||
|
editable?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const {pubkey, limit, onViewAll, editable = false}: Props = $props()
|
||||||
|
|
||||||
|
const pinList = derivePinList(pubkey)
|
||||||
|
const pinnedIds = $derived(getEventTagValues(getListTags($pinList)))
|
||||||
|
const displayIds = $derived(limit ? pinnedIds.slice(0, limit) : pinnedIds)
|
||||||
|
|
||||||
|
const pinnedEvents = $derived.by(() => {
|
||||||
|
return sortBy(
|
||||||
|
e => -pinnedIds.indexOf(e.id),
|
||||||
|
displayIds
|
||||||
|
.map(id => repository.getEvent(id))
|
||||||
|
.filter((event): event is TrustedEvent => Boolean(event)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
let loading = $state(pinnedIds.length > 0)
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (pinnedIds.length === 0) {
|
||||||
|
loading = false
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const missing = pinnedIds.filter(id => !repository.getEvent(id))
|
||||||
|
|
||||||
|
if (missing.length === 0) {
|
||||||
|
loading = false
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
load({
|
||||||
|
relays: Router.get().FromPubkeys([pubkey]).getUrls(),
|
||||||
|
filters: [{ids: missing}],
|
||||||
|
onEvent: () => {
|
||||||
|
loading = !pinnedIds.every(id => repository.getEvent(id))
|
||||||
|
},
|
||||||
|
onClose: () => {
|
||||||
|
loading = false
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if pinnedIds.length > 0}
|
||||||
|
<div class="col-4 border-t border-base-300 pt-4">
|
||||||
|
<strong>Pinned notes</strong>
|
||||||
|
{#if loading && pinnedEvents.length === 0}
|
||||||
|
<p class="center flex py-8">
|
||||||
|
<Spinner loading />
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<div class="col-2">
|
||||||
|
{#each pinnedEvents as event (event.id)}
|
||||||
|
<div in:fly>
|
||||||
|
<NoteItem {event} {editable} />
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if onViewAll && pinnedIds.length > (limit || pinnedIds.length)}
|
||||||
|
<button class="link link-primary row-2 text-sm" onclick={onViewAll}>
|
||||||
|
View all pinned notes
|
||||||
|
<span aria-hidden="true">→</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Modal from "@lib/components/Modal.svelte"
|
||||||
|
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||||
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
|
import QRCode from "@app/components/QRCode.svelte"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
code: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const {code}: Props = $props()
|
||||||
|
|
||||||
|
const back = () => history.back()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal>
|
||||||
|
<ModalBody>
|
||||||
|
<div class="col-4 items-center text-center">
|
||||||
|
<strong>Profile QR Code</strong>
|
||||||
|
<QRCode {code} class="max-w-64" />
|
||||||
|
<p class="break-all text-sm opacity-75">{code}</p>
|
||||||
|
<p class="text-sm opacity-75">Tap the QR code to copy this npub.</p>
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button onclick={back} class="hidden md:btn md:btn-link">
|
||||||
|
<Icon icon={AltArrowLeft} />
|
||||||
|
Go back
|
||||||
|
</Button>
|
||||||
|
<Button onclick={back} class="btn btn-neutral">Close</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import cx from "classnames"
|
||||||
|
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
|
||||||
|
import UsersGroup from "@assets/icons/users-group-rounded.svg?dataurl"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Link from "@lib/components/Link.svelte"
|
||||||
|
import RelayIcon from "@app/components/RelayIcon.svelte"
|
||||||
|
import RelayName from "@app/components/RelayName.svelte"
|
||||||
|
import {
|
||||||
|
groupListPubkeysByUrl,
|
||||||
|
userSpaceUrls,
|
||||||
|
deriveGroupList,
|
||||||
|
getSpaceUrlsFromGroupList,
|
||||||
|
} from "@app/groups"
|
||||||
|
import {makeSpacePath} from "@app/routes"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
pubkey: string
|
||||||
|
limit?: number
|
||||||
|
onViewAll?: () => void
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const {pubkey, limit, onViewAll, ...props}: Props = $props()
|
||||||
|
|
||||||
|
const groupList = deriveGroupList(pubkey)
|
||||||
|
const spaceUrls = $derived(getSpaceUrlsFromGroupList($groupList))
|
||||||
|
const sharedSpaceUrls = $derived($userSpaceUrls.filter(url => spaceUrls.includes(url)))
|
||||||
|
const displayUrls = $derived(limit ? sharedSpaceUrls.slice(0, limit) : sharedSpaceUrls)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={cx("card2 bg-alt col-3 border border-base-300 max-sm:p-5 sm:col-4", props.class)}>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<div class="row-2">
|
||||||
|
<Icon icon={UsersGroup} size={5} />
|
||||||
|
<strong>Shared spaces</strong>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-neutral">{sharedSpaceUrls.length}</span>
|
||||||
|
</div>
|
||||||
|
{#if displayUrls.length > 0}
|
||||||
|
<div class="col-2 border-t border-base-300 pt-4 sm:pt-4">
|
||||||
|
{#each displayUrls as url (url)}
|
||||||
|
{@const count = $groupListPubkeysByUrl.get(url)?.size || 0}
|
||||||
|
<Link
|
||||||
|
href={makeSpacePath(url)}
|
||||||
|
class="row-2 rounded-box border border-base-300 p-4 transition-colors hover:bg-base-300/30 sm:p-3">
|
||||||
|
<RelayIcon {url} size={8} />
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<RelayName {url} class="ellipsize text-sm font-medium" />
|
||||||
|
<p class="text-xs opacity-75">
|
||||||
|
{#if count >= 1000}
|
||||||
|
{(count / 1000).toFixed(1).replace(/\.0$/, "")}K members
|
||||||
|
{:else}
|
||||||
|
{count} {count === 1 ? "member" : "members"}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if onViewAll && sharedSpaceUrls.length > (limit || sharedSpaceUrls.length)}
|
||||||
|
<button
|
||||||
|
class="link link-primary row-2 border-t border-base-300 pt-4 text-sm max-sm:pt-4"
|
||||||
|
onclick={onViewAll}>
|
||||||
|
View all shared spaces
|
||||||
|
<Icon icon={AltArrowRight} size={4} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<p class="border-t border-base-300 pt-4 text-sm opacity-75 max-sm:pt-4">
|
||||||
|
No shared spaces yet.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {clamp, uniq} from "@welshman/lib"
|
||||||
|
import {
|
||||||
|
pubkey,
|
||||||
|
followLists,
|
||||||
|
deriveUserWotScore,
|
||||||
|
deriveProfileDisplay,
|
||||||
|
deriveFollowList,
|
||||||
|
getFollows,
|
||||||
|
} from "@welshman/app"
|
||||||
|
import Shield from "@assets/icons/shield-minimalistic.svg?dataurl"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import ProfileCircles from "@app/components/ProfileCircles.svelte"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
pubkey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const {pubkey: target}: Props = $props()
|
||||||
|
|
||||||
|
const score = deriveUserWotScore(target)
|
||||||
|
const profileDisplay = deriveProfileDisplay(target)
|
||||||
|
const targetFollowList = deriveFollowList(target)
|
||||||
|
|
||||||
|
const mutualFollows = $derived.by(() => {
|
||||||
|
const viewer = $pubkey
|
||||||
|
void $followLists
|
||||||
|
void $targetFollowList
|
||||||
|
|
||||||
|
if (!viewer) return []
|
||||||
|
|
||||||
|
const viewerFollows = new Set(getFollows(viewer))
|
||||||
|
|
||||||
|
return uniq(
|
||||||
|
getFollows(target).filter(pk => pk !== viewer && pk !== target && viewerFollows.has(pk)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const displayScore = $derived(Math.round(clamp([0, 100], $score)))
|
||||||
|
const progress = $derived(displayScore)
|
||||||
|
|
||||||
|
const trustMessage = $derived.by(() => {
|
||||||
|
if (displayScore >= 70) return "This user is highly trusted in your network."
|
||||||
|
if (displayScore >= 30) return "This user has some trust in your network."
|
||||||
|
|
||||||
|
return "This user is not well known in your network."
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="card2 bg-alt col-3 border border-base-300 max-sm:p-5 sm:col-4">
|
||||||
|
<div class="row-2">
|
||||||
|
<Icon icon={Shield} size={5} />
|
||||||
|
<strong>Reputation</strong>
|
||||||
|
</div>
|
||||||
|
<div class="col-2 border-t border-base-300 pt-4 sm:pt-4">
|
||||||
|
<div class="flex items-end justify-between gap-2">
|
||||||
|
<span class="text-sm opacity-75">Trust score</span>
|
||||||
|
<span class="text-lg font-semibold">{displayScore} / 100</span>
|
||||||
|
</div>
|
||||||
|
<progress class="progress progress-primary w-full" value={progress} max="100"></progress>
|
||||||
|
<p class="text-sm opacity-75">{trustMessage}</p>
|
||||||
|
</div>
|
||||||
|
{#if mutualFollows.length > 0}
|
||||||
|
<div class="col-2 border-t border-base-300 pt-4 sm:pt-4">
|
||||||
|
<p class="text-sm font-medium">Mutual follows</p>
|
||||||
|
<ProfileCircles pubkeys={mutualFollows} limit={5} />
|
||||||
|
<p class="text-sm opacity-75">
|
||||||
|
{mutualFollows.length}
|
||||||
|
{mutualFollows.length === 1 ? "person" : "people"} you and {$profileDisplay} both follow.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -29,20 +29,22 @@
|
|||||||
<Button onclick={back} class="place-self-start pr-3 md:hidden">
|
<Button onclick={back} class="place-self-start pr-3 md:hidden">
|
||||||
<Icon icon={ArrowLeft} size={7} />
|
<Icon icon={ArrowLeft} size={7} />
|
||||||
</Button>
|
</Button>
|
||||||
<div class="ellipsize whitespace-nowrap flex grow items-center justify-between gap-4">
|
<div class="flex grow items-center justify-between gap-4">
|
||||||
<div class="flex flex-col">
|
<div class="flex min-w-0 flex-col">
|
||||||
<div class="flex gap-2 items-center">
|
<div class="flex min-w-0 items-start gap-2">
|
||||||
<RelayIcon {url} size={5} class="rounded-full md:hidden" />
|
<RelayIcon {url} size={5} class="shrink-0 rounded-full md:hidden" />
|
||||||
<div class="hidden md:contents">
|
<div class="hidden shrink-0 md:flex md:items-center">
|
||||||
{@render leading?.()}
|
{@render leading?.()}
|
||||||
</div>
|
</div>
|
||||||
{@render title?.()}
|
<div class="min-w-0">
|
||||||
|
{@render title?.()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-primary md:hidden">
|
<div class="text-xs text-primary pl-7 md:hidden">
|
||||||
{displayRelayUrl(url)}
|
{displayRelayUrl(url)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 items-start">
|
<div class="flex shrink-0 items-center gap-2">
|
||||||
{@render action?.()}
|
{@render action?.()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import RoomName from "@app/components/RoomName.svelte"
|
||||||
|
import ThreadBoardItem from "@app/components/ThreadBoardItem.svelte"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
h: string
|
||||||
|
threads: TrustedEvent[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, h, threads}: Props = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="overflow-hidden rounded-box border border-base-content/15 bg-base-100 shadow-sm">
|
||||||
|
<header
|
||||||
|
class="flex items-center justify-between gap-2 border-b border-base-content/15 bg-base-200/70 px-4 py-2.5">
|
||||||
|
<h2 class="text-sm font-bold sm:text-base">
|
||||||
|
{#if h}
|
||||||
|
#<RoomName {url} {h} />
|
||||||
|
{:else}
|
||||||
|
General
|
||||||
|
{/if}
|
||||||
|
</h2>
|
||||||
|
<span class="text-xs opacity-60">
|
||||||
|
{threads.length}
|
||||||
|
{threads.length === 1 ? "topic" : "topics"}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
<div
|
||||||
|
class="hidden border-b border-base-content/10 bg-base-200/40 px-4 py-2 text-xs font-bold uppercase tracking-wide opacity-60 sm:grid sm:grid-cols-[1fr_8rem_5rem_8rem] sm:gap-x-4">
|
||||||
|
<span>Topic</span>
|
||||||
|
<span>Author</span>
|
||||||
|
<span class="text-center">Replies</span>
|
||||||
|
<span class="text-right">Last post</span>
|
||||||
|
</div>
|
||||||
|
{#each threads as event (event.id)}
|
||||||
|
<ThreadBoardItem {url} {event} />
|
||||||
|
{/each}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {formatTimestamp, max} from "@welshman/lib"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {COMMENT, getTagValue} from "@welshman/util"
|
||||||
|
import Link from "@lib/components/Link.svelte"
|
||||||
|
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||||
|
import ProfileName from "@app/components/ProfileName.svelte"
|
||||||
|
import {deriveEventsForUrl} from "@app/repository"
|
||||||
|
import {makeThreadPath} from "@app/routes"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
event: TrustedEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, event}: Props = $props()
|
||||||
|
|
||||||
|
const filters = [{kinds: [COMMENT], "#E": [event.id]}]
|
||||||
|
const replies = deriveEventsForUrl(url, filters)
|
||||||
|
const replyCount = $derived($replies.length)
|
||||||
|
const lastActive = $derived(max([...$replies, event].map(e => e.created_at)))
|
||||||
|
const title = getTagValue("title", event.tags)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={makeThreadPath(url, event.id)}
|
||||||
|
class="grid grid-cols-[1fr_auto] gap-x-3 gap-y-1 border-b border-base-content/10 px-3 py-3 transition-colors hover:bg-base-200/50 sm:grid-cols-[1fr_8rem_5rem_8rem] sm:items-center sm:gap-x-4 sm:px-4">
|
||||||
|
<div class="col-span-2 min-w-0 sm:col-span-1">
|
||||||
|
<p class="ellipsize text-sm font-bold sm:text-base">{title || "Untitled thread"}</p>
|
||||||
|
<p class="ellipsize mt-0.5 text-xs opacity-60 sm:hidden">
|
||||||
|
by <ProfileName pubkey={event.pubkey} {url} />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="hidden items-center gap-2 sm:flex">
|
||||||
|
<ProfileCircle pubkey={event.pubkey} {url} size={6} />
|
||||||
|
<span class="ellipsize text-sm">
|
||||||
|
<ProfileName pubkey={event.pubkey} {url} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-right text-xs opacity-75 sm:text-center sm:text-sm">
|
||||||
|
<span class="opacity-60 sm:hidden">Replies · </span>
|
||||||
|
{replyCount}
|
||||||
|
</p>
|
||||||
|
<p class="text-right text-xs opacity-75 sm:text-sm">
|
||||||
|
<span class="opacity-60 sm:hidden">Last · </span>
|
||||||
|
{formatTimestamp(lastActive)}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import cx from "classnames"
|
||||||
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
|
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
|
||||||
|
import DoubleAltArrowLeft from "@assets/icons/double-alt-arrow-left.svg?dataurl"
|
||||||
|
import DoubleAltArrowRight from "@assets/icons/double-alt-arrow-right.svg?dataurl"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
page: number
|
||||||
|
pageCount: number
|
||||||
|
onPage: (page: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const {page, pageCount, onPage}: Props = $props()
|
||||||
|
|
||||||
|
const goFirst = () => onPage(1)
|
||||||
|
const goPrev = () => onPage(page - 1)
|
||||||
|
const goNext = () => onPage(page + 1)
|
||||||
|
const goLast = () => onPage(pageCount)
|
||||||
|
const goToPage = (target: number) => onPage(target)
|
||||||
|
|
||||||
|
const pages = $derived.by(() => {
|
||||||
|
if (pageCount <= 7) {
|
||||||
|
return Array.from({length: pageCount}, (_, i) => i + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = new Set<number>([1, pageCount, page])
|
||||||
|
|
||||||
|
if (page > 2) result.add(page - 1)
|
||||||
|
if (page < pageCount - 1) result.add(page + 1)
|
||||||
|
if (page > 3) result.add(page - 2)
|
||||||
|
if (page < pageCount - 2) result.add(page + 2)
|
||||||
|
|
||||||
|
return Array.from(result).sort((a, b) => a - b)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center gap-3 border-t border-base-content/10 py-4">
|
||||||
|
<p class="text-sm opacity-75">Page {page} of {pageCount}</p>
|
||||||
|
<div class="join">
|
||||||
|
<Button class="btn join-item btn-sm" disabled={page <= 1} onclick={goFirst}>
|
||||||
|
<Icon icon={DoubleAltArrowLeft} size={4} />
|
||||||
|
</Button>
|
||||||
|
<Button class="btn join-item btn-sm" disabled={page <= 1} onclick={goPrev}>
|
||||||
|
<Icon icon={AltArrowLeft} size={4} />
|
||||||
|
</Button>
|
||||||
|
{#each pages as p, i (p)}
|
||||||
|
{#if i > 0 && p - pages[i - 1] > 1}
|
||||||
|
<Button class="btn join-item btn-sm btn-disabled" disabled>…</Button>
|
||||||
|
{/if}
|
||||||
|
<Button
|
||||||
|
class={cx("btn join-item btn-sm", page === p && "btn-primary")}
|
||||||
|
onclick={() => goToPage(p)}>
|
||||||
|
{p}
|
||||||
|
</Button>
|
||||||
|
{/each}
|
||||||
|
<Button class="btn join-item btn-sm" disabled={page >= pageCount} onclick={goNext}>
|
||||||
|
<Icon icon={AltArrowRight} size={4} />
|
||||||
|
</Button>
|
||||||
|
<Button class="btn join-item btn-sm" disabled={page >= pageCount} onclick={goLast}>
|
||||||
|
<Icon icon={DoubleAltArrowRight} size={4} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {formatTimestamp} from "@welshman/lib"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
|
import {COMMENT} from "@welshman/util"
|
||||||
|
import {deriveHandleForPubkey, deriveProfileDisplay, displayHandle} from "@welshman/app"
|
||||||
|
import Reply from "@assets/icons/reply-2.svg?dataurl"
|
||||||
|
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||||
|
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
||||||
|
import NoteContent from "@app/components/NoteContent.svelte"
|
||||||
|
import Content from "@app/components/Content.svelte"
|
||||||
|
import CommentActions from "@app/components/CommentActions.svelte"
|
||||||
|
import ThreadActions from "@app/components/ThreadActions.svelte"
|
||||||
|
import {makeEventPermalink} from "@app/routes"
|
||||||
|
import {pushModal} from "@app/modal"
|
||||||
|
import {clip} from "@app/toast"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
event: TrustedEvent
|
||||||
|
threadPubkey: string
|
||||||
|
onReply: (event: TrustedEvent) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, event, threadPubkey, onReply}: Props = $props()
|
||||||
|
|
||||||
|
const profileDisplay = deriveProfileDisplay(event.pubkey, [url])
|
||||||
|
const handle = deriveHandleForPubkey(event.pubkey)
|
||||||
|
const isOp = event.pubkey === threadPubkey
|
||||||
|
const isComment = event.kind === COMMENT
|
||||||
|
|
||||||
|
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey, url})
|
||||||
|
|
||||||
|
const copyPermalink = () => clip(makeEventPermalink(event, url))
|
||||||
|
|
||||||
|
const reply = () => onReply(event)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<article
|
||||||
|
id="post-{event.id}"
|
||||||
|
data-event={event.id}
|
||||||
|
class="border-b border-base-content/15 bg-base-100">
|
||||||
|
<div class="flex flex-col md:flex-row">
|
||||||
|
<aside
|
||||||
|
class="flex shrink-0 flex-row items-center gap-3 border-b border-base-content/10 bg-base-200/50 p-3 md:w-40 md:flex-col md:items-center md:border-b-0 md:border-r md:p-4 md:text-center">
|
||||||
|
<Button onclick={openProfile}>
|
||||||
|
<ProfileCircle pubkey={event.pubkey} {url} size={10} class="md:size-14" />
|
||||||
|
</Button>
|
||||||
|
<div class="flex min-w-0 flex-col gap-1 md:items-center">
|
||||||
|
<Button onclick={openProfile} class="text-bold ellipsize text-sm">
|
||||||
|
{$profileDisplay}
|
||||||
|
</Button>
|
||||||
|
{#if $handle}
|
||||||
|
<span class="ellipsize text-xs opacity-75">{displayHandle($handle)}</span>
|
||||||
|
{/if}
|
||||||
|
{#if isOp}
|
||||||
|
<span class="badge badge-primary badge-sm">OP</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div class="flex min-w-0 grow flex-col">
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap items-center justify-between gap-2 border-b border-base-content/10 bg-base-200/40 px-3 py-2 text-xs sm:px-4 sm:text-sm">
|
||||||
|
<span class="opacity-75">{formatTimestamp(event.created_at)}</span>
|
||||||
|
<Button class="btn btn-ghost btn-xs h-auto min-h-0 gap-1 px-1 py-0" onclick={copyPermalink}>
|
||||||
|
<Icon icon={LinkRound} size={3} />
|
||||||
|
Permalink
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div class="px-3 py-4 sm:px-4">
|
||||||
|
{#if isComment}
|
||||||
|
<Content showEntire {event} {url} />
|
||||||
|
{:else}
|
||||||
|
<NoteContent showEntire {event} {url} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex shrink-0 flex-col gap-2 border-t border-base-content/10 bg-base-200/20 px-3 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-4">
|
||||||
|
<Button class="btn btn-neutral btn-xs w-fit gap-1" onclick={reply}>
|
||||||
|
<Icon icon={Reply} size={4} />
|
||||||
|
Reply
|
||||||
|
</Button>
|
||||||
|
{#if isComment}
|
||||||
|
<CommentActions segment="threads" {event} {url} />
|
||||||
|
{:else}
|
||||||
|
<ThreadActions {event} {url} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
import {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url?: string
|
||||||
pubkey: string
|
pubkey: string
|
||||||
eventId?: string
|
eventId?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
import {clip, pushToast} from "@app/toast"
|
import {clip, pushToast} from "@app/toast"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url?: string
|
||||||
pubkey: string
|
pubkey: string
|
||||||
eventId?: string
|
eventId?: string
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -18,7 +18,7 @@ import {
|
|||||||
now,
|
now,
|
||||||
on,
|
on,
|
||||||
sortBy,
|
sortBy,
|
||||||
WEEK,
|
MONTH,
|
||||||
YEAR,
|
YEAR,
|
||||||
} from "@welshman/lib"
|
} from "@welshman/lib"
|
||||||
import {
|
import {
|
||||||
@@ -122,7 +122,7 @@ export const makeFeed = ({
|
|||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const events = writable<TrustedEvent[]>([])
|
const events = writable<TrustedEvent[]>([])
|
||||||
|
|
||||||
let interval = int(WEEK)
|
let interval = int(MONTH)
|
||||||
let buffer = sortEventsDesc(getEventsForUrl(url, filters))
|
let buffer = sortEventsDesc(getEventsForUrl(url, filters))
|
||||||
let backwardWindow = [at - interval, at]
|
let backwardWindow = [at - interval, at]
|
||||||
let forwardWindow = [at, at + interval]
|
let forwardWindow = [at, at + interval]
|
||||||
@@ -213,7 +213,7 @@ export const makeFeed = ({
|
|||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
interval = Math.round(interval * 1.1)
|
interval = Math.round(interval * 1.1)
|
||||||
} else {
|
} else {
|
||||||
interval = int(WEEK)
|
interval = int(MONTH)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,7 +280,7 @@ export const makeCalendarFeed = ({
|
|||||||
element: HTMLElement
|
element: HTMLElement
|
||||||
onExhausted?: () => void
|
onExhausted?: () => void
|
||||||
}) => {
|
}) => {
|
||||||
const interval = int(5, WEEK)
|
const interval = int(5, MONTH)
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
|
|
||||||
let exhaustedScrollers = 0
|
let exhaustedScrollers = 0
|
||||||
|
|||||||
+18
-1
@@ -20,7 +20,7 @@ import {
|
|||||||
getRelaysFromList,
|
getRelaysFromList,
|
||||||
} from "@welshman/util"
|
} from "@welshman/util"
|
||||||
import {makeChatId} from "@app/chats"
|
import {makeChatId} from "@app/chats"
|
||||||
import {entityLink} from "@app/env"
|
import {entityLink, PLATFORM_URL} from "@app/env"
|
||||||
import {encodeRelay, hasNip29} from "@app/relays"
|
import {encodeRelay, hasNip29} from "@app/relays"
|
||||||
import {DM_KINDS} from "@app/content"
|
import {DM_KINDS} from "@app/content"
|
||||||
import {ROOM} from "@app/groups"
|
import {ROOM} from "@app/groups"
|
||||||
@@ -44,6 +44,12 @@ export const setupHistory = () =>
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Profiles
|
||||||
|
|
||||||
|
export const makeProfilePath = (pubkey: string) => `/people/${nip19.npubEncode(pubkey)}`
|
||||||
|
|
||||||
|
export const goToProfile = (pubkey: string) => goto(makeProfilePath(pubkey))
|
||||||
|
|
||||||
// Chat
|
// Chat
|
||||||
|
|
||||||
export const makeChatPath = (pubkeys: string[]) => `/chat/${makeChatId(pubkeys)}`
|
export const makeChatPath = (pubkeys: string[]) => `/chat/${makeChatId(pubkeys)}`
|
||||||
@@ -211,6 +217,17 @@ export const getEventPath = (event: TrustedEvent, urls: string[]) => {
|
|||||||
return entityLink(nip19.neventEncode({id: event.id, relays: urls}))
|
return entityLink(nip19.neventEncode({id: event.id, relays: urls}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const makeEventPermalink = (event: TrustedEvent, url?: string) => {
|
||||||
|
const urls = url ? [url] : Array.from(tracker.getRelays(event.id))
|
||||||
|
const path = getEventPath(event, urls)
|
||||||
|
|
||||||
|
if (path.includes("://")) {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${PLATFORM_URL}${path}#${nip19.neventEncode({id: event.id, relays: urls})}`
|
||||||
|
}
|
||||||
|
|
||||||
export const getRoomItemPath = (url: string, event: TrustedEvent) => {
|
export const getRoomItemPath = (url: string, event: TrustedEvent) => {
|
||||||
switch (event.kind) {
|
switch (event.kind) {
|
||||||
case THREAD:
|
case THREAD:
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ import {
|
|||||||
loadFollowList,
|
loadFollowList,
|
||||||
loadMuteList,
|
loadMuteList,
|
||||||
loadProfile,
|
loadProfile,
|
||||||
|
userFollowList,
|
||||||
|
getFollows,
|
||||||
repository,
|
repository,
|
||||||
shouldUnwrap,
|
shouldUnwrap,
|
||||||
hasNegentropy,
|
hasNegentropy,
|
||||||
@@ -248,6 +250,17 @@ const syncUserData = () => {
|
|||||||
loadFeedsForPubkey(pubkey)
|
loadFeedsForPubkey(pubkey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const syncFollowNetwork = ($userFollowList: List | undefined) => {
|
||||||
|
const author = $userFollowList?.event?.pubkey
|
||||||
|
|
||||||
|
if (!author) return
|
||||||
|
|
||||||
|
for (const follow of getFollows(author)) {
|
||||||
|
loadFollowList(follow)
|
||||||
|
loadMuteList(follow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const unsubscribeGroupList = merged([userGroupList]).subscribe(([$userGroupList]) => {
|
const unsubscribeGroupList = merged([userGroupList]).subscribe(([$userGroupList]) => {
|
||||||
syncGroupList($userGroupList)
|
syncGroupList($userGroupList)
|
||||||
})
|
})
|
||||||
@@ -256,10 +269,13 @@ const syncUserData = () => {
|
|||||||
syncRelayList($userRelayList)
|
syncRelayList($userRelayList)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const unsubscribeFollowList = userFollowList.subscribe(syncFollowNetwork)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribersByKey.forEach(call)
|
unsubscribersByKey.forEach(call)
|
||||||
unsubscribeGroupList()
|
unsubscribeGroupList()
|
||||||
unsubscribeRelayList()
|
unsubscribeRelayList()
|
||||||
|
unsubscribeFollowList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+21
-1
@@ -1,5 +1,6 @@
|
|||||||
import {append, identity, uniq} from "@welshman/lib"
|
import {append, identity, uniq} from "@welshman/lib"
|
||||||
import {repository} from "@welshman/app"
|
import * as nip19 from "nostr-tools/nip19"
|
||||||
|
import {repository, displayProfileByPubkey} from "@welshman/app"
|
||||||
import {displayPubkey, getTagValue} from "@welshman/util"
|
import {displayPubkey, getTagValue} from "@welshman/util"
|
||||||
import {PLATFORM_NAME} from "@app/env"
|
import {PLATFORM_NAME} from "@app/env"
|
||||||
import {decodeRelay} from "@app/relays"
|
import {decodeRelay} from "@app/relays"
|
||||||
@@ -24,6 +25,7 @@ const staticTitles = new Map<string, string>([
|
|||||||
["/chat", "Messages"],
|
["/chat", "Messages"],
|
||||||
["/join", "Join Space"],
|
["/join", "Join Space"],
|
||||||
["/people", "Find People"],
|
["/people", "Find People"],
|
||||||
|
["/people/[npub]", "Profile"],
|
||||||
["/settings/about", "About"],
|
["/settings/about", "About"],
|
||||||
["/settings/profile", "Profile Settings"],
|
["/settings/profile", "Profile Settings"],
|
||||||
["/settings/content", "Content Settings"],
|
["/settings/content", "Content Settings"],
|
||||||
@@ -120,6 +122,24 @@ export const getPageTitle = ({page, pubkey}: PageTitleContext) => {
|
|||||||
return makeTitle(getChatTitle(page.params.chat, pubkey))
|
return makeTitle(getChatTitle(page.params.chat, pubkey))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (routeId === "/people/[npub]") {
|
||||||
|
try {
|
||||||
|
const decoded = nip19.decode(page.params.npub!)
|
||||||
|
const profilePubkey =
|
||||||
|
decoded.type === "npub"
|
||||||
|
? decoded.data
|
||||||
|
: decoded.type === "nprofile"
|
||||||
|
? decoded.data.pubkey
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
if (profilePubkey) {
|
||||||
|
return makeTitle(displayProfileByPubkey(profilePubkey))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (routeId === "/spaces/[relay]/[h]") {
|
if (routeId === "/spaces/[relay]/[h]") {
|
||||||
return makeTitle(getRoomTitle(page.params))
|
return makeTitle(getRoomTitle(page.params))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,21 @@
|
|||||||
import {page} from "$app/stores"
|
import {page} from "$app/stores"
|
||||||
import {goto} from "$app/navigation"
|
import {goto} from "$app/navigation"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import {goToEvent} from "@app/routes"
|
import {goToEvent, makeProfilePath} from "@app/routes"
|
||||||
|
|
||||||
const {bech32} = $page.params as MakeNonOptional<typeof $page.params>
|
const {bech32} = $page.params as MakeNonOptional<typeof $page.params>
|
||||||
|
|
||||||
const attemptToNavigate = async () => {
|
const attemptToNavigate = async () => {
|
||||||
const {type, data} = nip19.decode(bech32) as any
|
const {type, data} = nip19.decode(bech32) as any
|
||||||
|
|
||||||
|
if (type === "npub") {
|
||||||
|
return goto(makeProfilePath(data), {replaceState: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "nprofile") {
|
||||||
|
return goto(makeProfilePath(data.pubkey), {replaceState: true})
|
||||||
|
}
|
||||||
|
|
||||||
if (!["nevent", "naddr"].includes(type) && data.relays.length > 0) {
|
if (!["nevent", "naddr"].includes(type) && data.relays.length > 0) {
|
||||||
return goto("/", {replaceState: true})
|
return goto("/", {replaceState: true})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {onMount} from "svelte"
|
||||||
|
import * as nip19 from "nostr-tools/nip19"
|
||||||
|
import {page} from "$app/stores"
|
||||||
|
import {goto} from "$app/navigation"
|
||||||
|
import type {MakeNonOptional} from "@welshman/lib"
|
||||||
|
import {
|
||||||
|
loadProfile,
|
||||||
|
loadRelayList,
|
||||||
|
loadFollowList,
|
||||||
|
loadMessagingRelayList,
|
||||||
|
loadPinList,
|
||||||
|
} from "@welshman/app"
|
||||||
|
import {load} from "@welshman/net"
|
||||||
|
import {Router} from "@welshman/router"
|
||||||
|
import {ROOMS, NOTE} from "@welshman/util"
|
||||||
|
import Page from "@lib/components/Page.svelte"
|
||||||
|
import PageContent from "@lib/components/PageContent.svelte"
|
||||||
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
|
import ProfilePage from "@app/components/ProfilePage.svelte"
|
||||||
|
import {loadGroupList} from "@app/groups"
|
||||||
|
|
||||||
|
const {npub} = $page.params as MakeNonOptional<typeof $page.params>
|
||||||
|
|
||||||
|
let pubkey = $state<string | undefined>()
|
||||||
|
let ready = $state(false)
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const decoded = nip19.decode(npub)
|
||||||
|
|
||||||
|
if (decoded.type === "npub") {
|
||||||
|
pubkey = decoded.data
|
||||||
|
} else if (decoded.type === "nprofile") {
|
||||||
|
pubkey = decoded.data.pubkey
|
||||||
|
} else {
|
||||||
|
goto("/people", {replaceState: true})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadProfile(pubkey)
|
||||||
|
await loadRelayList(pubkey)
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
loadFollowList(pubkey),
|
||||||
|
loadPinList(pubkey),
|
||||||
|
loadGroupList(pubkey),
|
||||||
|
loadMessagingRelayList(pubkey),
|
||||||
|
])
|
||||||
|
|
||||||
|
load({
|
||||||
|
relays: Router.get().FromPubkeys([pubkey]).getUrls(),
|
||||||
|
filters: [
|
||||||
|
{authors: [pubkey], kinds: [ROOMS]},
|
||||||
|
{authors: [pubkey], kinds: [NOTE], limit: 1},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
ready = true
|
||||||
|
} catch {
|
||||||
|
goto("/people", {replaceState: true})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Page>
|
||||||
|
<PageContent class="p-0 md:p-4">
|
||||||
|
{#if ready && pubkey}
|
||||||
|
<ProfilePage {pubkey} />
|
||||||
|
{:else}
|
||||||
|
<p class="center flex py-20">
|
||||||
|
<Spinner loading />
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</PageContent>
|
||||||
|
</Page>
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import {session} from "@welshman/app"
|
||||||
import {Capacitor} from "@capacitor/core"
|
import {Capacitor} from "@capacitor/core"
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Zap from "@app/components/Zap.svelte"
|
||||||
|
import ZapInvoice from "@app/components/ZapInvoice.svelte"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
import ProfileDetail from "@app/components/ProfileDetail.svelte"
|
||||||
import {PLATFORM_NAME} from "@app/env"
|
import {PLATFORM_NAME} from "@app/env"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
|
import {makeSpacePath} from "@app/routes"
|
||||||
import Code from "@assets/icons/code-2.svg?dataurl"
|
import Code from "@assets/icons/code-2.svg?dataurl"
|
||||||
import Global from "@assets/icons/global.svg?dataurl"
|
import Global from "@assets/icons/global.svg?dataurl"
|
||||||
import Pen from "@assets/icons/pen.svg?dataurl"
|
import Pen from "@assets/icons/pen.svg?dataurl"
|
||||||
@@ -16,6 +20,8 @@
|
|||||||
const pubkey = "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322"
|
const pubkey = "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322"
|
||||||
|
|
||||||
const openProfile = () => pushModal(ProfileDetail, {pubkey})
|
const openProfile = () => pushModal(ProfileDetail, {pubkey})
|
||||||
|
|
||||||
|
const zap = () => pushModal($session?.wallet ? Zap : ZapInvoice, {pubkey})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mt-8 min-h-screen bg-base-200 sm:hero">
|
<div class="mt-8 min-h-screen bg-base-200 sm:hero">
|
||||||
@@ -28,18 +34,14 @@
|
|||||||
<div class="card2 bg-alt flex flex-col gap-2 text-center shadow-lg">
|
<div class="card2 bg-alt flex flex-col gap-2 text-center shadow-lg">
|
||||||
<h3 class="text-2xl sm:h-12">Donate</h3>
|
<h3 class="text-2xl sm:h-12">Donate</h3>
|
||||||
<p class="sm:h-16">Funds will be used to support development.</p>
|
<p class="sm:h-16">Funds will be used to support development.</p>
|
||||||
<Link external href="https://geyser.fund/project/flotilla" class="btn btn-primary">
|
<Button onclick={zap} class="btn btn-primary">Zap the Developer</Button>
|
||||||
Support the Developer
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="card2 bg-alt flex flex-col gap-2 text-center shadow-lg">
|
<div class="card2 bg-alt flex flex-col gap-2 text-center shadow-lg">
|
||||||
<h3 class="text-2xl sm:h-12">Get in touch</h3>
|
<h3 class="text-2xl sm:h-12">Get in touch</h3>
|
||||||
<p class="sm:h-16">Having problems? Let us know.</p>
|
<p class="sm:h-16">Having problems? Let us know.</p>
|
||||||
<Link
|
<Link class="btn btn-primary" href={makeSpacePath("support.flotilla.social")}>
|
||||||
class="btn btn-primary"
|
Get Support
|
||||||
href="/chat/97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322">
|
|
||||||
Chat with the Developer
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,10 +3,9 @@
|
|||||||
import {readable} from "svelte/store"
|
import {readable} from "svelte/store"
|
||||||
import type {Readable} from "svelte/store"
|
import type {Readable} from "svelte/store"
|
||||||
import {page} from "$app/stores"
|
import {page} from "$app/stores"
|
||||||
import {sortBy, partition, spec, max, pushToMapKey} from "@welshman/lib"
|
import {sortBy, partition, spec, max, pushToMapKey, groupBy} from "@welshman/lib"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {THREAD, getTagValue} from "@welshman/util"
|
import {THREAD, getTagValue} from "@welshman/util"
|
||||||
import {fly} from "@lib/transition"
|
|
||||||
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
|
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
|
||||||
import Add from "@assets/icons/add.svg?dataurl"
|
import Add from "@assets/icons/add.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
@@ -14,9 +13,10 @@
|
|||||||
import PageContent from "@lib/components/PageContent.svelte"
|
import PageContent from "@lib/components/PageContent.svelte"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import SpaceBar from "@app/components/SpaceBar.svelte"
|
import SpaceBar from "@app/components/SpaceBar.svelte"
|
||||||
import ThreadItem from "@app/components/ThreadItem.svelte"
|
import ThreadBoard from "@app/components/ThreadBoard.svelte"
|
||||||
import ThreadCreate from "@app/components/ThreadCreate.svelte"
|
import ThreadCreate from "@app/components/ThreadCreate.svelte"
|
||||||
import {decodeRelay} from "@app/relays"
|
import {decodeRelay} from "@app/relays"
|
||||||
|
import {displayRoom} from "@app/groups"
|
||||||
import {makeCommentFilter} from "@app/content"
|
import {makeCommentFilter} from "@app/content"
|
||||||
import {makeFeed} from "@app/feeds"
|
import {makeFeed} from "@app/feeds"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
@@ -29,9 +29,9 @@
|
|||||||
|
|
||||||
const createThread = () => pushModal(ThreadCreate, {url})
|
const createThread = () => pushModal(ThreadCreate, {url})
|
||||||
|
|
||||||
const items = $derived.by(() => {
|
const threadFeed = $derived.by(() => {
|
||||||
const scores = new Map<string, number[]>()
|
const scores = new Map<string, number[]>()
|
||||||
const [goals, comments] = partition(spec({kind: THREAD}), $events)
|
const [threads, comments] = partition(spec({kind: THREAD}), $events)
|
||||||
|
|
||||||
for (const comment of comments) {
|
for (const comment of comments) {
|
||||||
const id = getTagValue("E", comment.tags)
|
const id = getTagValue("E", comment.tags)
|
||||||
@@ -41,7 +41,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return sortBy(e => -max([...(scores.get(e.id) || []), e.created_at]), goals)
|
const items = sortBy(e => -max([...(scores.get(e.id) || []), e.created_at]), threads)
|
||||||
|
|
||||||
|
const byRoom = groupBy(e => getTagValue("h", e.tags) || "", items)
|
||||||
|
const roomName = (h: string) => (h ? displayRoom(url, h) : "general").toLowerCase()
|
||||||
|
const boards = sortBy(([h]) => roomName(h), Array.from(byRoom.entries()))
|
||||||
|
|
||||||
|
return {items, boards}
|
||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -77,17 +83,15 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</SpaceBar>
|
</SpaceBar>
|
||||||
|
|
||||||
<PageContent bind:element class="flex flex-col gap-2 p-2">
|
<PageContent bind:element class="flex flex-col gap-4 p-2">
|
||||||
{#each items as event (event.id)}
|
{#each threadFeed.boards as [h, threads] (h || "general")}
|
||||||
<div in:fly>
|
<ThreadBoard {url} {h} {threads} />
|
||||||
<ThreadItem {url} event={$state.snapshot(event)} />
|
|
||||||
</div>
|
|
||||||
{/each}
|
{/each}
|
||||||
<p class="flex h-10 items-center justify-center py-20">
|
<p class="flex h-10 items-center justify-center py-20">
|
||||||
<Spinner {loading}>
|
<Spinner {loading}>
|
||||||
{#if loading}
|
{#if loading}
|
||||||
Looking for threads...
|
Looking for threads...
|
||||||
{:else if items.length === 0}
|
{:else if threadFeed.items.length === 0}
|
||||||
No threads found.
|
No threads found.
|
||||||
{:else}
|
{:else}
|
||||||
That's all!
|
That's all!
|
||||||
|
|||||||
@@ -1,26 +1,31 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
import {onMount} from "svelte"
|
||||||
|
import * as nip19 from "nostr-tools/nip19"
|
||||||
import {page} from "$app/stores"
|
import {page} from "$app/stores"
|
||||||
|
import {goto} from "$app/navigation"
|
||||||
import {sleep} from "@welshman/lib"
|
import {sleep} from "@welshman/lib"
|
||||||
import type {MakeNonOptional} from "@welshman/lib"
|
import type {MakeNonOptional} from "@welshman/lib"
|
||||||
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {COMMENT, getTagValue} from "@welshman/util"
|
import {COMMENT, getTagValue} from "@welshman/util"
|
||||||
import {repository} from "@welshman/app"
|
import {repository} from "@welshman/app"
|
||||||
import {request} from "@welshman/net"
|
import {request} from "@welshman/net"
|
||||||
import {deriveEventsById, deriveEventsAsc} from "@welshman/store"
|
import {deriveEventsById, deriveEventsAsc} from "@welshman/store"
|
||||||
import SortVertical from "@assets/icons/sort-vertical.svg?dataurl"
|
|
||||||
import Reply from "@assets/icons/reply-2.svg?dataurl"
|
import Reply from "@assets/icons/reply-2.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import PageContent from "@lib/components/PageContent.svelte"
|
import PageContent from "@lib/components/PageContent.svelte"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Link from "@lib/components/Link.svelte"
|
||||||
import SpaceBar from "@app/components/SpaceBar.svelte"
|
import SpaceBar from "@app/components/SpaceBar.svelte"
|
||||||
import NoteContent from "@app/components/NoteContent.svelte"
|
import ThreadPost from "@app/components/ThreadPost.svelte"
|
||||||
import NoteCard from "@app/components/NoteCard.svelte"
|
import ThreadPagination from "@app/components/ThreadPagination.svelte"
|
||||||
import ThreadActions from "@app/components/ThreadActions.svelte"
|
|
||||||
import CommentActions from "@app/components/CommentActions.svelte"
|
|
||||||
import EventReply from "@app/components/EventReply.svelte"
|
import EventReply from "@app/components/EventReply.svelte"
|
||||||
|
import RoomName from "@app/components/RoomName.svelte"
|
||||||
import {deriveEvent} from "@app/repository"
|
import {deriveEvent} from "@app/repository"
|
||||||
import {decodeRelay} from "@app/relays"
|
import {decodeRelay} from "@app/relays"
|
||||||
|
import {makeSpacePath, scrollToEvent} from "@app/routes"
|
||||||
|
|
||||||
|
const POSTS_PER_PAGE = 20
|
||||||
|
|
||||||
const {relay, id} = $page.params as MakeNonOptional<typeof $page.params>
|
const {relay, id} = $page.params as MakeNonOptional<typeof $page.params>
|
||||||
const url = decodeRelay(relay)
|
const url = decodeRelay(relay)
|
||||||
@@ -30,20 +35,106 @@
|
|||||||
|
|
||||||
const back = () => history.back()
|
const back = () => history.back()
|
||||||
|
|
||||||
const openReply = () => {
|
const posts = $derived.by(() => {
|
||||||
|
if (!$event) return []
|
||||||
|
|
||||||
|
return [$event, ...$replies]
|
||||||
|
})
|
||||||
|
|
||||||
|
const replyCount = $derived(Math.max(0, posts.length - 1))
|
||||||
|
const h = $derived(getTagValue("h", $event?.tags || []))
|
||||||
|
|
||||||
|
const pageCount = $derived(Math.max(1, Math.ceil(posts.length / POSTS_PER_PAGE)))
|
||||||
|
|
||||||
|
const currentPage = $derived.by(() => {
|
||||||
|
const raw = parseInt($page.url.searchParams.get("page") || "1")
|
||||||
|
|
||||||
|
if (Number.isNaN(raw) || raw < 1) return 1
|
||||||
|
if (raw > pageCount) return pageCount
|
||||||
|
|
||||||
|
return raw
|
||||||
|
})
|
||||||
|
|
||||||
|
const pagePosts = $derived(
|
||||||
|
posts.slice((currentPage - 1) * POSTS_PER_PAGE, currentPage * POSTS_PER_PAGE),
|
||||||
|
)
|
||||||
|
|
||||||
|
const setPage = (nextPage: number) => {
|
||||||
|
const params = new URLSearchParams($page.url.searchParams)
|
||||||
|
|
||||||
|
if (nextPage <= 1) {
|
||||||
|
params.delete("page")
|
||||||
|
} else {
|
||||||
|
params.set("page", String(nextPage))
|
||||||
|
}
|
||||||
|
|
||||||
|
const search = params.toString()
|
||||||
|
|
||||||
|
goto(`${$page.url.pathname}${search ? `?${search}` : ""}`, {
|
||||||
|
keepFocus: true,
|
||||||
|
noScroll: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const openReply = (post: TrustedEvent) => {
|
||||||
|
replyTo = post
|
||||||
showReply = true
|
showReply = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeReply = () => {
|
const closeReply = () => {
|
||||||
showReply = false
|
showReply = false
|
||||||
|
replyTo = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const expand = () => {
|
const openThreadReply = () => {
|
||||||
showAll = true
|
if ($event) {
|
||||||
|
openReply($event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearReplyParent = () => {
|
||||||
|
if ($event) {
|
||||||
|
replyTo = $event
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let showAll = $state(false)
|
|
||||||
let showReply = $state(false)
|
let showReply = $state(false)
|
||||||
|
let replyTo: TrustedEvent | undefined = $state()
|
||||||
|
let hashHandled = $state(false)
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (hashHandled || posts.length === 0) return
|
||||||
|
|
||||||
|
const hash = window.location.hash.replace(/^#/, "")
|
||||||
|
|
||||||
|
if (!hash.startsWith("nevent1")) return
|
||||||
|
|
||||||
|
let eventId: string
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = nip19.decode(hash)
|
||||||
|
|
||||||
|
if (decoded.type !== "nevent") return
|
||||||
|
|
||||||
|
eventId = decoded.data.id
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = posts.findIndex(post => post.id === eventId)
|
||||||
|
|
||||||
|
if (index < 0) return
|
||||||
|
|
||||||
|
hashHandled = true
|
||||||
|
|
||||||
|
const targetPage = Math.ceil((index + 1) / POSTS_PER_PAGE)
|
||||||
|
|
||||||
|
if (targetPage !== currentPage) {
|
||||||
|
setPage(targetPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => scrollToEvent(posts[index]!.id), 100)
|
||||||
|
})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
@@ -56,43 +147,44 @@
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<SpaceBar {back}>
|
<SpaceBar {back} class="!h-auto min-h-20 py-3">
|
||||||
{#snippet title()}
|
{#snippet title()}
|
||||||
<h1 class="text-xl">{getTagValue("title", $event?.tags || []) || ""}</h1>
|
<div class="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<h1 class="ellipsize text-base leading-none font-bold sm:text-xl">
|
||||||
|
{getTagValue("title", $event?.tags || []) || ""}
|
||||||
|
</h1>
|
||||||
|
<p class="text-xs opacity-75">
|
||||||
|
{replyCount}
|
||||||
|
{replyCount === 1 ? "reply" : "replies"}
|
||||||
|
{#if h}
|
||||||
|
· <Link href={makeSpacePath(url, h)} class="link">#<RoomName {url} {h} /></Link>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</SpaceBar>
|
</SpaceBar>
|
||||||
|
|
||||||
<PageContent class="flex flex-col gap-2 p-2">
|
<PageContent class="flex flex-col">
|
||||||
{#if $event}
|
{#if $event}
|
||||||
<div class="flex flex-col gap-3">
|
<div class="border-y border-base-content/15 bg-base-100">
|
||||||
<NoteCard event={$event} {url} class="card2 bg-alt z-feature w-full">
|
{#each pagePosts as post (post.id)}
|
||||||
<div class="col-3 ml-12">
|
<ThreadPost {url} event={post} threadPubkey={$event.pubkey} onReply={openReply} />
|
||||||
<NoteContent showEntire event={$event} {url} />
|
|
||||||
<ThreadActions showRoom event={$event} {url} />
|
|
||||||
</div>
|
|
||||||
</NoteCard>
|
|
||||||
{#if !showAll && $replies.length > 4}
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<Button class="btn btn-link" onclick={expand}>
|
|
||||||
<Icon icon={SortVertical} />
|
|
||||||
Show all {$replies.length} replies
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#each $replies.slice(0, showAll ? undefined : 4) as reply (reply.id)}
|
|
||||||
<NoteCard event={reply} {url} class="card2 bg-alt z-feature w-full">
|
|
||||||
<div class="col-3 ml-12">
|
|
||||||
<NoteContent showEntire event={reply} {url} />
|
|
||||||
<CommentActions segment="threads" event={reply} {url} />
|
|
||||||
</div>
|
|
||||||
</NoteCard>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{#if showReply}
|
{#if pageCount > 1}
|
||||||
<EventReply {url} event={$event} onClose={closeReply} onSubmit={closeReply} />
|
<ThreadPagination page={currentPage} {pageCount} onPage={setPage} />
|
||||||
|
{/if}
|
||||||
|
{#if showReply && replyTo && $event}
|
||||||
|
<EventReply
|
||||||
|
{url}
|
||||||
|
event={$event}
|
||||||
|
parent={replyTo.id === $event.id ? undefined : replyTo}
|
||||||
|
onClose={closeReply}
|
||||||
|
onClearParent={clearReplyParent}
|
||||||
|
onSubmit={closeReply} />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end p-4">
|
||||||
<Button class="btn btn-primary" onclick={openReply}>
|
<Button class="btn btn-primary" onclick={openThreadReply}>
|
||||||
<Icon icon={Reply} />
|
<Icon icon={Reply} />
|
||||||
Reply to thread
|
Reply to thread
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Reference in New Issue
Block a user