Re-work space navigation #223

This commit is contained in:
Jon Staab
2025-10-06 11:23:19 -07:00
committed by hodlbod
parent b3533c285f
commit f9ac13ba11
68 changed files with 2807 additions and 884 deletions
+33 -28
View File
@@ -1,31 +1,33 @@
<script lang="ts">
import type {TrustedEvent, EventContent} from "@welshman/util"
import {getTagValue} from "@welshman/util"
import {pubkey} from "@welshman/app"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Link from "@lib/components/Link.svelte"
import ChannelName from "@app/components/ChannelName.svelte"
import ReactionSummary from "@app/components/ReactionSummary.svelte"
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte"
import CalendarEventEdit from "@app/components/CalendarEventEdit.svelte"
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
import {makeCalendarPath} from "@app/util/routes"
import {makeCalendarPath, makeSpacePath} from "@app/util/routes"
import {pushModal} from "@app/util/modal"
import Pen2 from "@assets/icons/pen-2.svg?dataurl"
const {
url,
event,
showActivity = false,
}: {
type Props = {
url: string
event: TrustedEvent
showRoom?: boolean
showActivity?: boolean
} = $props()
}
const shouldProtect = canEnforceNip70(url)
const {url, event, showRoom, showActivity}: Props = $props()
const room = getTagValue("h", event.tags)
const path = makeCalendarPath(url, event.id)
const shouldProtect = canEnforceNip70(url)
const editEvent = () => pushModal(CalendarEventEdit, {url, event})
@@ -36,24 +38,27 @@
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
</script>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-grow flex-wrap justify-end gap-2">
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
<ThunkStatusOrDeleted {event} />
{#if showActivity}
<EventActivity {url} {path} {event} />
{/if}
<EventActions {url} {event} noun="Event">
{#snippet customActions()}
{#if event.pubkey === $pubkey}
<li>
<Button onclick={editEvent}>
<Icon size={4} icon={Pen2} />
Edit Event
</Button>
</li>
{/if}
{/snippet}
</EventActions>
</div>
<div class="flex flex-grow flex-wrap justify-end gap-2">
{#if room && showRoom}
<Link href={makeSpacePath(url, room)} class="btn btn-neutral btn-xs rounded-full">
Posted in #<ChannelName {room} {url} />
</Link>
{/if}
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
<ThunkStatusOrDeleted {event} />
{#if showActivity}
<EventActivity {url} {path} {event} />
{/if}
<EventActions {url} {event} noun="Event">
{#snippet customActions()}
{#if event.pubkey === $pubkey}
<li>
<Button onclick={editEvent}>
<Icon size={4} icon={Pen2} />
Edit Event
</Button>
</li>
{/if}
{/snippet}
</EventActions>
</div>
@@ -4,12 +4,13 @@
type Props = {
url: string
room?: string
}
const {url}: Props = $props()
const {url, room}: Props = $props()
</script>
<CalendarEventForm {url}>
<CalendarEventForm {url} {room}>
{#snippet header()}
<ModalHeader>
{#snippet title()}
+11 -8
View File
@@ -8,13 +8,16 @@
const {event}: Props = $props()
const meta = $derived(fromPairs(event.tags) as Record<string, string>)
const startDate = $derived(secondsToDate(parseInt(meta.start)))
const start = $derived(parseInt(meta.start))
</script>
<div
class="hidden h-32 w-32 min-w-32 flex-col items-center justify-center gap-1 rounded-box bg-base-300 p-2 sm:flex">
<strong>{Intl.DateTimeFormat(LOCALE, {month: "short"}).format(startDate)}</strong>
<span class="text-4xl">{Intl.DateTimeFormat(LOCALE, {day: "numeric"}).format(startDate)}</span>
<span class="text-xs opacity-75"
>{Intl.DateTimeFormat(LOCALE, {weekday: "long"}).format(startDate)}</span>
</div>
{#if !isNaN(start)}
{@const startDate = secondsToDate(start)}
<div
class="hidden h-32 w-32 min-w-32 flex-col items-center justify-center gap-1 rounded-box bg-base-300 p-2 sm:flex">
<strong>{Intl.DateTimeFormat(LOCALE, {month: "short"}).format(startDate)}</strong>
<span class="text-4xl">{Intl.DateTimeFormat(LOCALE, {day: "numeric"}).format(startDate)}</span>
<span class="text-xs opacity-75"
>{Intl.DateTimeFormat(LOCALE, {weekday: "long"}).format(startDate)}</span>
</div>
{/if}
+6 -1
View File
@@ -23,6 +23,7 @@
type Props = {
url: string
room?: string
header: Snippet
initialValues?: {
d: string
@@ -34,7 +35,7 @@
}
}
const {url, header, initialValues}: Props = $props()
const {url, room, header, initialValues}: Props = $props()
const shouldProtect = canEnforceNip70(url)
@@ -84,6 +85,10 @@
tags.push(PROTECTED)
}
if (room) {
tags.push(["h", room])
}
const event = makeEvent(EVENT_TIME, {content, tags})
pushToast({message: "Your event has been saved!"})
+12 -10
View File
@@ -17,18 +17,20 @@
const meta = $derived(fromPairs(event.tags) as Record<string, string>)
const start = $derived(parseInt(meta.start))
const end = $derived(parseInt(meta.end))
const startDateDisplay = $derived(formatTimestampAsDate(start))
const endDateDisplay = $derived(formatTimestampAsDate(end))
const isSingleDay = $derived(startDateDisplay === endDateDisplay)
</script>
<div class="flex flex-grow flex-wrap justify-between gap-2">
<p class="text-xl">{meta.title || meta.name}</p>
<div class="flex items-center gap-2 text-sm">
<Icon icon={ClockCircle} size={4} />
<span class="sm:hidden">{formatTimestampAsDate(start)}</span>
{formatTimestampAsTime(start)}{isSingleDay
? formatTimestampAsTime(end)
: formatTimestamp(end)}
</div>
{#if !isNaN(start) && !isNaN(end)}
{@const startDateDisplay = formatTimestampAsDate(start)}
{@const endDateDisplay = formatTimestampAsDate(end)}
{@const isSingleDay = startDateDisplay === endDateDisplay}
<div class="flex items-center gap-2 text-sm">
<Icon icon={ClockCircle} size={4} />
<span class="hidden sm:block">{formatTimestampAsDate(start)}</span>
{formatTimestampAsTime(start)}{isSingleDay
? formatTimestampAsTime(end)
: formatTimestamp(end)}
</div>
{/if}
</div>
@@ -1,9 +1,11 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import {getTagValue} from "@welshman/util"
import Link from "@lib/components/Link.svelte"
import CalendarEventActions from "@app/components/CalendarEventActions.svelte"
import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte"
import ChannelLink from "@app/components/ChannelLink.svelte"
import {makeCalendarPath} from "@app/util/routes"
type Props = {
@@ -12,6 +14,8 @@
}
const {url, event}: Props = $props()
const room = getTagValue("h", event.tags)
</script>
<Link class="col-3 card2 bg-alt w-full cursor-pointer" href={makeCalendarPath(url, event.id)}>
@@ -19,6 +23,9 @@
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
<span class="whitespace-nowrap py-1 text-sm opacity-75">
Posted by <ProfileLink pubkey={event.pubkey} {url} />
{#if room}
in <ChannelLink {url} {room} />
{/if}
</span>
<CalendarEventActions showActivity {url} {event} />
</div>
+39 -13
View File
@@ -1,23 +1,28 @@
<script lang="ts">
import type {Instance} from "tippy.js"
import {writable} from "svelte/store"
import type {EventContent} from "@welshman/util"
import {isMobile, preventDefault} from "@lib/html"
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
import WidgetAdd from "@assets/icons/widget-add.svg?dataurl"
import Plane from "@assets/icons/plane-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Tippy from "@lib/components/Tippy.svelte"
import ComposeMenu from "@app/components/ComposeMenu.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {makeEditor} from "@app/editor"
import {onDestroy, onMount} from "svelte"
type Props = {
url?: string
room?: string
content?: string
onEditPrevious?: () => void
onSubmit: (event: EventContent) => void
}
const {content, onEditPrevious, onSubmit, url}: Props = $props()
const {url, room, content, onEditPrevious, onSubmit}: Props = $props()
const autofocus = !isMobile
@@ -36,6 +41,10 @@
const uploadFiles = () => editor.then(ed => ed.chain().selectFiles().run())
const showPopover = () => popover?.show()
const hidePopover = () => popover?.hide()
const submit = async () => {
if ($uploading) return
@@ -50,7 +59,9 @@
ed.chain().clearContent().run()
}
const editor = makeEditor({url, autofocus, content, submit, uploading, aggressive: true})
const editor = makeEditor({url, content, autofocus, submit, uploading, aggressive: true})
let popover: Instance | undefined = $state()
onMount(async () => {
const ed = await editor
@@ -64,17 +75,32 @@
</script>
<form class="relative z-feature flex gap-2 p-2" onsubmit={preventDefault(submit)}>
<Button
data-tip="Add an image"
class="center tooltip tooltip-right h-10 w-10 min-w-10 rounded-box bg-base-300 transition-colors hover:bg-base-200"
disabled={$uploading}
onclick={uploadFiles}>
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Icon icon={GallerySend} />
{/if}
</Button>
<div class="join">
<Button
data-tip="Add an image"
class="center join-item tooltip tooltip-right h-10 w-10 min-w-10 rounded-full border border-solid border-base-200 bg-base-300"
disabled={$uploading}
onclick={uploadFiles}>
{#if $uploading}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Icon icon={GallerySend} />
{/if}
</Button>
<Tippy
bind:popover
component={ComposeMenu}
props={{url, room, onClick: hidePopover}}
params={{trigger: "manual", interactive: true}}>
<Button
data-tip="More options"
class="center join-item tooltip tooltip-right h-10 w-10 min-w-10 rounded-full border border-solid border-base-200 bg-base-300"
disabled={$uploading}
onclick={showPopover}>
<Icon icon={WidgetAdd} />
</Button>
</Tippy>
</div>
<div class="chat-editor flex-grow overflow-hidden">
<EditorContent {editor} />
</div>
@@ -5,7 +5,7 @@
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
const {
verb,
@@ -19,16 +19,11 @@
</script>
<div
class="relative border-l-2 border-solid border-primary bg-base-300 px-2 py-1 pr-8 text-xs"
class="relative border-l-2 border-solid border-primary bg-base-300 px-2 py-1 pr-8"
transition:slide>
<p class="text-primary">{verb} @{displayProfileByPubkey(event.pubkey)}</p>
<p class="text-xs text-primary">{verb} @{displayProfileByPubkey(event.pubkey)}</p>
{#key event.id}
<NoteContent
{event}
hideMediaAtDepth={0}
minLength={100}
maxLength={300}
expandMode="disabled" />
<NoteContentMinimal trimParent {event} />
{/key}
<Button class="absolute right-2 top-2 cursor-pointer" onclick={clear}>
<Icon icon={CloseCircle} />
@@ -1,24 +1,35 @@
<script lang="ts">
import {hash, now, formatTimestampAsTime, formatTimestampAsDate} from "@welshman/lib"
import cx from "classnames"
import {hash, now, displayList, formatTimestampAsTime, formatTimestampAsDate} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {thunks, deriveProfile, deriveProfileDisplay} from "@welshman/app"
import {MESSAGE, COMMENT} from "@welshman/util"
import {
thunks,
pubkey,
deriveProfile,
deriveProfileDisplay,
displayProfileByPubkey,
} from "@welshman/app"
import {isMobile} from "@lib/html"
import Pen from "@assets/icons/pen.svg?dataurl"
import Reply from "@assets/icons/reply-2.svg?dataurl"
import ReplyAlt from "@assets/icons/reply.svg?dataurl"
import TapTarget from "@lib/components/TapTarget.svelte"
import Avatar from "@lib/components/Avatar.svelte"
import Reply from "@assets/icons/reply-2.svg?dataurl"
import Pen from "@assets/icons/pen.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
import Content from "@app/components/Content.svelte"
import ThunkFailure from "@app/components/ThunkFailure.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import ReactionSummary from "@app/components/ReactionSummary.svelte"
import ChannelMessageZapButton from "@app/components/ChannelMessageZapButton.svelte"
import ChannelMessageEmojiButton from "@app/components/ChannelMessageEmojiButton.svelte"
import ChannelMessageMenuButton from "@app/components/ChannelMessageMenuButton.svelte"
import ChannelMessageMenuMobile from "@app/components/ChannelMessageMenuMobile.svelte"
import {colors, ENABLE_ZAPS} from "@app/core/state"
import ChannelItemZapButton from "@app/components/ChannelItemZapButton.svelte"
import ChannelItemEmojiButton from "@app/components/ChannelItemEmojiButton.svelte"
import ChannelItemMenuButton from "@app/components/ChannelItemMenuButton.svelte"
import ChannelItemMenuMobile from "@app/components/ChannelItemMenuMobile.svelte"
import ChannelItemContent from "@app/components/ChannelItemContent.svelte"
import {colors, ENABLE_ZAPS, deriveEventsForUrl} from "@app/core/state"
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
import {getChannelItemPath} from "@app/util/routes"
import {pushModal} from "@app/util/modal"
interface Props {
@@ -42,16 +53,18 @@
}: Props = $props()
const thunk = $thunks[event.id]
const path = getChannelItemPath(url, event)
const shouldProtect = canEnforceNip70(url)
const today = formatTimestampAsDate(now())
const profile = deriveProfile(event.pubkey, [url])
const profileDisplay = deriveProfileDisplay(event.pubkey, [url])
const [_, colorValue] = colors[parseInt(hash(event.pubkey)) % colors.length]
const comments = deriveEventsForUrl(url, [{kinds: [COMMENT], "#e": [event.id]}])
const reply = () => replyTo!(event)
const edit = canEdit(event) ? () => onEdit(event) : undefined
const onTap = () => pushModal(ChannelMessageMenuMobile, {url, event, reply, edit})
const onTap = () => pushModal(ChannelItemMenuMobile, {url, event, reply, edit})
const openProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey, url})
@@ -65,7 +78,7 @@
<TapTarget
data-event={event.id}
onTap={inert ? null : onTap}
class="group relative flex w-full cursor-default flex-col p-2 pb-3 text-left">
class="group relative flex w-full cursor-default flex-col p-2 pb-3 text-left hover:bg-base-100/50">
<div class="flex w-full gap-3 overflow-auto">
{#if showPubkey}
<Button onclick={openProfile} class="flex items-start">
@@ -90,10 +103,10 @@
</span>
</div>
{/if}
<div class="text-sm">
<Content minimalQuote {event} {url} />
<div class:mt-2={showPubkey && event.kind !== MESSAGE}>
<ChannelItemContent {url} {event} />
{#if thunk}
<ThunkFailure showToastOnRetry {thunk} class="mt-2" />
<ThunkFailure showToastOnRetry {thunk} class="mt-2 text-sm" />
{/if}
</div>
</div>
@@ -105,15 +118,32 @@
{deleteReaction}
{createReaction}
reactionClass="tooltip-right" />
{#if path && $comments.length > 0}
{@const pubkeys = $comments.map(e => e.pubkey)}
{@const isOwn = $pubkey && pubkeys.includes($pubkey)}
{@const info = displayList(pubkeys.map(pubkey => displayProfileByPubkey(pubkey)))}
{@const tooltip = `${info} commented`}
<div data-tip={tooltip} class="tooltip tooltip-right flex">
<Link
href={path}
class={cx("btn btn-xs gap-1 rounded-full", {
"btn-neutral": !isOwn,
"btn-primary": isOwn,
})}>
<Icon icon={ReplyAlt} />
<span>{$comments.length} comment{$comments.length === 1 ? "" : "s"}</span>
</Link>
</div>
{/if}
</div>
{#if !isMobile}
<button
class="join absolute right-1 top-1 border border-solid border-neutral text-xs opacity-0 transition-all"
class:group-hover:opacity-100={!isMobile}>
{#if ENABLE_ZAPS}
<ChannelMessageZapButton {url} {event} />
<ChannelItemZapButton {url} {event} />
{/if}
<ChannelMessageEmojiButton {url} {event} />
<ChannelItemEmojiButton {url} {event} />
{#if replyTo}
<Button class="btn join-item btn-xs" onclick={reply}>
<Icon icon={Reply} size={4} />
@@ -124,7 +154,7 @@
<Icon icon={Pen} size={4} />
</Button>
{/if}
<ChannelMessageMenuButton {url} {event} />
<ChannelItemMenuButton {url} {event} />
</button>
{/if}
</TapTarget>
@@ -0,0 +1,23 @@
<script lang="ts">
import cx from "classnames"
import type {ComponentProps} from "svelte"
import {MESSAGE} from "@welshman/util"
import {isMobile} from "@lib/html"
import Link from "@lib/components/Link.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
import {getChannelItemPath} from "@app/util/routes"
const props: ComponentProps<typeof NoteContent> = $props()
const path = getChannelItemPath(props.url!, props.event)
</script>
<div class={cx("text-sm", {"card2 card2-sm bg-alt": props.event.kind !== MESSAGE})}>
{#if path && !isMobile}
<Link href={path}>
<NoteContent {...props} />
</Link>
{:else}
<NoteContent {...props} />
{/if}
</div>
@@ -1,16 +1,23 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import {pubkey} from "@welshman/app"
import Code2 from "@assets/icons/code-2.svg?dataurl"
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
import Danger from "@assets/icons/danger.svg?dataurl"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import EventReport from "@app/components/EventReport.svelte"
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import {pushModal} from "@app/util/modal"
import Code2 from "@assets/icons/code-2.svg?dataurl"
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
import Danger from "@assets/icons/danger.svg?dataurl"
const {url, event, onClick} = $props()
type Props = {
url: string
event: TrustedEvent
onClick: () => void
}
const {url, event, onClick}: Props = $props()
const report = () => {
onClick()
@@ -32,7 +39,7 @@
<li>
<Button onclick={showInfo}>
<Icon size={4} icon={Code2} />
Message Details
Show JSON
</Button>
</li>
{#if event.pubkey === $pubkey}
@@ -5,7 +5,7 @@
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Tippy from "@lib/components/Tippy.svelte"
import ChannelMessageMenu from "@app/components/ChannelMessageMenu.svelte"
import ChannelItemMenu from "@app/components/ChannelItemMenu.svelte"
const {url, event} = $props()
@@ -34,7 +34,7 @@
</Button>
<Tippy
bind:popover
component={ChannelMessageMenu}
component={ChannelItemMenu}
props={{url, event, onClick}}
params={{trigger: "manual", interactive: true}} />
</div>
@@ -2,7 +2,14 @@
import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent} from "@welshman/util"
import {pubkey} from "@welshman/app"
import Bolt from "@assets/icons/bolt.svg?dataurl"
import Reply from "@assets/icons/reply-2.svg?dataurl"
import Code2 from "@assets/icons/code-2.svg?dataurl"
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import Button from "@lib/components/Button.svelte"
import Link from "@lib/components/Link.svelte"
import Icon from "@lib/components/Icon.svelte"
import EmojiPicker from "@lib/components/EmojiPicker.svelte"
import ZapButton from "@app/components/ZapButton.svelte"
@@ -10,12 +17,8 @@
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import {ENABLE_ZAPS} from "@app/core/state"
import {publishReaction, canEnforceNip70} from "@app/core/commands"
import {getChannelItemPath} from "@app/util/routes"
import {pushModal} from "@app/util/modal"
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
import Bolt from "@assets/icons/bolt.svg?dataurl"
import Reply from "@assets/icons/reply-2.svg?dataurl"
import Code2 from "@assets/icons/code-2.svg?dataurl"
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
type Props = {
url: string
@@ -25,6 +28,8 @@
const {url, event, reply}: Props = $props()
const path = getChannelItemPath(url, event)
const shouldProtect = canEnforceNip70(url)
const onEmoji = (async (event: TrustedEvent, url: string, emoji: NativeEmoji) => {
@@ -49,29 +54,35 @@
const showDelete = () => pushModal(EventDeleteConfirm, {url, event})
</script>
<div class="col-2">
<Button class="btn btn-primary w-full" onclick={showEmojiPicker}>
<Icon size={4} icon={SmileCircle} />
Send Reaction
</Button>
{#if ENABLE_ZAPS}
<ZapButton replaceState {url} {event} class="btn btn-secondary w-full">
<Icon size={4} icon={Bolt} />
Send Zap
</ZapButton>
{/if}
<Button class="btn btn-neutral w-full" onclick={sendReply}>
<Icon size={4} icon={Reply} />
Send Reply
</Button>
<Button class="btn btn-neutral" onclick={showInfo}>
<Icon size={4} icon={Code2} />
Message Details
</Button>
<div class="flex flex-col gap-2">
{#if event.pubkey === $pubkey}
<Button class="btn btn-neutral text-error" onclick={showDelete}>
<Icon size={4} icon={TrashBin2} />
Delete Message
Delete
</Button>
{/if}
<Button class="btn btn-neutral" onclick={showInfo}>
<Icon size={4} icon={Code2} />
Show JSON
</Button>
{#if path}
<Link class="btn btn-neutral" href={path}>
<Icon size={4} icon={MenuDots} />
View Details
</Link>
{/if}
<Button class="btn btn-outline btn-neutral w-full" onclick={sendReply}>
<Icon size={4} icon={Reply} />
Reply
</Button>
<Button class="btn btn-secondary w-full" onclick={showEmojiPicker}>
<Icon size={4} icon={SmileCircle} />
React
</Button>
{#if ENABLE_ZAPS}
<ZapButton replaceState {url} {event} class="btn btn-primary w-full">
<Icon size={4} icon={Bolt} />
Zap
</ZapButton>
{/if}
</div>
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
import cx from "classnames"
import Link from "@lib/components/Link.svelte"
import ChannelName from "@app/components/ChannelName.svelte"
import {makeSpacePath} from "@app/util/routes"
type Props = {
room: string
url: string
class?: string
unstyled?: boolean
}
const {room, url, unstyled, ...props}: Props = $props()
const path = makeSpacePath(url, room)
</script>
<Link href={path} class={cx(props.class, {"link-content bg-alt": !unstyled})}>
#<ChannelName {room} {url} />
</Link>
+4 -9
View File
@@ -5,7 +5,7 @@
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
const {
verb,
@@ -19,16 +19,11 @@
</script>
<div
class="relative border-l-2 border-solid border-primary bg-base-300 px-2 py-1 pr-8 text-xs"
class="relative border-l-2 border-solid border-primary bg-base-300 px-2 py-1 pr-8"
transition:slide>
<p class="text-primary">{verb} @{displayProfileByPubkey(event.pubkey)}</p>
<p class="text-xs text-primary">{verb} @{displayProfileByPubkey(event.pubkey)}</p>
{#key event.id}
<NoteContent
{event}
hideMediaAtDepth={0}
minLength={100}
maxLength={300}
expandMode="disabled" />
<NoteContentMinimal trimParent {event} />
{/key}
<Button class="absolute right-2 top-2 cursor-pointer" onclick={clear}>
<Icon icon={CloseCircle} />
+53
View File
@@ -0,0 +1,53 @@
<script lang="ts">
import {onMount} from "svelte"
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic.svg?dataurl"
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import {pushModal} from "@app/util/modal"
import CalendarEventCreate from "@app/components/CalendarEventCreate.svelte"
import ThreadCreate from "@app/components/ThreadCreate.svelte"
import GoalCreate from "@app/components/GoalCreate.svelte"
type Props = {
url: string
onClick: () => void
room?: string
}
const {url, room, onClick}: Props = $props()
const createGoal = () => pushModal(GoalCreate, {url, room})
const createCalendarEvent = () => pushModal(CalendarEventCreate, {url, room})
const createThread = () => pushModal(ThreadCreate, {url, room})
let ul: Element
onMount(() => {
ul.addEventListener("click", onClick)
})
</script>
<ul class="menu whitespace-nowrap rounded-box bg-base-100 p-2 shadow-xl" bind:this={ul}>
<li>
<Button onclick={createGoal}>
<Icon size={4} icon={StarFallMinimalistic} />
Create Funding Goal
</Button>
</li>
<li>
<Button onclick={createCalendarEvent}>
<Icon size={4} icon={CalendarMinimalistic} />
Create Calendar Event
</Button>
</li>
<li>
<Button onclick={createThread}>
<Icon size={4} icon={NotesMinimalistic} />
Create Thread
</Button>
</li>
</ul>
+40 -25
View File
@@ -18,6 +18,7 @@
isAddress,
isNewline,
} from "@welshman/content"
import type {Parsed} from "@welshman/content"
import {preventDefault, stopPropagation} from "@lib/html"
import Link from "@lib/components/Link.svelte"
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
@@ -39,10 +40,8 @@
minLength?: number
maxLength?: number
showEntire?: boolean
hideMediaAtDepth?: number
expandMode?: string
minimalQuote?: boolean
depth?: number
trimParent?: boolean
url?: string
}
@@ -51,10 +50,8 @@
minLength = 500,
maxLength = 700,
showEntire = $bindable(false),
hideMediaAtDepth = 1,
expandMode = "block",
minimalQuote = false,
depth = 0,
trimParent = false,
url,
}: Props = $props()
@@ -67,13 +64,13 @@
const isBlock = (i: number) => {
const parsed = fullContent[i]
if (!parsed || hideMediaAtDepth <= depth) return false
if (!parsed) return false
if (isLink(parsed) && $userSettingsValues.show_media && isStartAndEnd(i)) {
return true
}
if ((isEvent(parsed) || isAddress(parsed)) && isStartAndEnd(i)) {
if (isQuote(parsed) && isStartAndEnd(i)) {
return true
}
@@ -95,6 +92,8 @@
const isStartAndEnd = (i: number) => isStart(i) && isEnd(i)
const isQuote = (p: Parsed) => isEvent(p) || isAddress(p)
const ignoreWarning = () => {
warning = null
}
@@ -103,15 +102,37 @@
$userSettingsValues.hide_sensitive && event.tags.find(nthEq(0, "content-warning"))?.[1],
)
const shortContent = $derived(
showEntire
? fullContent
: truncate(fullContent, {
minLength,
maxLength,
mediaLength: hideMediaAtDepth <= depth ? 20 : 200,
}),
)
const dropWhile = <T,>(f: (x: T) => boolean, xs: Iterable<T>) => {
const result: T[] = []
for (const x of xs) {
if (result.length === 0 && f(x)) {
continue
}
result.push(x)
}
return result
}
const shortContent = $derived.by(() => {
let result = fullContent
if (trimParent && result.length > 0 && isQuote(result[0])) {
result = dropWhile(p => isQuote(p) || isNewline(p), result)
}
if (!showEntire) {
result = truncate(result, {
minLength,
maxLength,
mediaLength: 200,
})
}
return result
})
const hasEllipsis = $derived(shortContent.some(isEllipsis))
const expandInline = $derived(hasEllipsis && expandMode === "inline")
@@ -152,15 +173,9 @@
{/if}
{:else if isProfile(parsed)}
<ContentMention value={parsed.value} {url} />
{:else if isEvent(parsed) || isAddress(parsed)}
{:else if isQuote(parsed)}
{#if isBlock(i)}
<ContentQuote
{depth}
{url}
{hideMediaAtDepth}
value={parsed.value}
{event}
minimal={minimalQuote} />
<ContentQuote {url} value={parsed.value} {event} />
{:else}
<Link
external
+135
View File
@@ -0,0 +1,135 @@
<script lang="ts">
import {fromNostrURI} from "@welshman/util"
import {nthEq} from "@welshman/lib"
import {
parse,
truncate,
renderAsHtml,
isText,
isEmoji,
isTopic,
isCode,
isCashu,
isInvoice,
isLink,
isProfile,
isEvent,
isAddress,
isNewline,
} from "@welshman/content"
import type {Parsed} from "@welshman/content"
import Link from "@lib/components/Link.svelte"
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import ContentToken from "@app/components/ContentToken.svelte"
import ContentEmoji from "@app/components/ContentEmoji.svelte"
import ContentCode from "@app/components/ContentCode.svelte"
import ContentLinkInline from "@app/components/ContentLinkInline.svelte"
import ContentNewline from "@app/components/ContentNewline.svelte"
import ContentTopic from "@app/components/ContentTopic.svelte"
import ContentMention from "@app/components/ContentMention.svelte"
import {entityLink, userSettingsValues} from "@app/core/state"
interface Props {
event: any
trimParent?: boolean
url?: string
}
const {event, trimParent = false, url}: Props = $props()
const fullContent = parse(event)
const isBoundary = (i: number) => {
const parsed = fullContent[i]
if (!parsed || isNewline(parsed)) return true
if (isText(parsed)) return Boolean(parsed.value.match(/^\s+$/))
return false
}
const isStart = (i: number) => isBoundary(i - 1)
const isEnd = (i: number) => isBoundary(i + 1)
const isStartAndEnd = (i: number) => isStart(i) && isEnd(i)
const isQuote = (p: Parsed) => isEvent(p) || isAddress(p)
const ignoreWarning = () => {
warning = null
}
let warning = $state(
$userSettingsValues.hide_sensitive && event.tags.find(nthEq(0, "content-warning"))?.[1],
)
const dropWhile = <T,>(f: (x: T) => boolean, xs: Iterable<T>) => {
const result: T[] = []
for (const x of xs) {
if (result.length === 0 && f(x)) {
continue
}
result.push(x)
}
return result
}
const shortContent = $derived.by(() => {
let result = fullContent
if (trimParent && result.length > 0 && isQuote(result[0])) {
result = dropWhile(p => isQuote(p) || isNewline(p), result)
}
return truncate(result, {minLength: 200, maxLength: 300, mediaLength: 20})
})
</script>
<div class="relative">
{#if warning}
<div class="card2 card2-sm bg-alt row-2">
<Icon icon={Danger} />
<p>
This note has been flagged by the author as "{warning}".<br />
<Button class="link" onclick={ignoreWarning}>Show anyway</Button>
</p>
</div>
{:else}
<div class="overflow-hidden text-ellipsis break-words">
{#each shortContent as parsed, i}
{#if isNewline(parsed)}
<ContentNewline value={parsed.value} />
{:else if isTopic(parsed)}
<ContentTopic value={parsed.value} />
{:else if isEmoji(parsed)}
<ContentEmoji value={parsed.value} />
{:else if isCode(parsed)}
<ContentCode
value={parsed.value}
isBlock={isStartAndEnd(i) || parsed.value.includes("\n")} />
{:else if isCashu(parsed) || isInvoice(parsed)}
<ContentToken value={parsed.value} />
{:else if isLink(parsed)}
<ContentLinkInline value={parsed.value} />
{:else if isProfile(parsed)}
<ContentMention value={parsed.value} {url} />
{:else if isQuote(parsed)}
<Link
external
class="overflow-hidden text-ellipsis whitespace-nowrap underline"
href={entityLink(parsed.raw)}>
{fromNostrURI(parsed.raw).slice(0, 16) + "…"}
</Link>
{:else}
{@html renderAsHtml(parsed)}
{/if}
{/each}
</div>
{/if}
</div>
+6 -9
View File
@@ -6,20 +6,17 @@
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import NoteCard from "@app/components/NoteCard.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
import {deriveEvent, entityLink} from "@app/core/state"
import {goToEvent} from "@app/util/routes"
type Props = {
value: any
hideMediaAtDepth: number
event: TrustedEvent
depth: number
url?: string
minimal?: boolean
}
const {value, event, depth, hideMediaAtDepth, url, minimal}: Props = $props()
const {value, event, url}: Props = $props()
const {id, identifier, kind, pubkey, relays = []} = value
const idOrAddress = id || new Address(kind, pubkey, identifier).toString()
@@ -43,17 +40,17 @@
}
</script>
<Button class="my-2 block max-w-full text-left" {onclick}>
<Button class="my-2 block w-full max-w-full text-left" {onclick}>
{#if $quote}
{#if minimal && $quote.kind === MESSAGE}
{#if $quote.kind === MESSAGE}
<div
class="border-l-2 border-solid border-l-primary py-1 pl-2 opacity-90"
style="background-color: color-mix(in srgb, var(--primary) 10%, var(--base-300) 90%);">
<NoteContent {hideMediaAtDepth} {url} event={$quote} depth={depth + 1} />
<NoteContentMinimal trimParent {url} event={$quote} />
</div>
{:else}
<NoteCard event={$quote} {url} class="bg-alt rounded-box p-4">
<NoteContent {hideMediaAtDepth} {url} event={$quote} depth={depth + 1} />
<NoteContentMinimal {url} event={$quote} />
</NoteCard>
{/if}
{:else}
+3 -3
View File
@@ -4,7 +4,7 @@
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 Content from "@app/components/Content.svelte"
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte"
import {goToEvent} from "@app/util/routes"
@@ -36,7 +36,7 @@
{/if}
<span class="text-nowrap">{formatTimestamp(earliest.created_at)}</span>
</div>
<Content minimalQuote minLength={100} maxLength={400} event={earliest} />
<NoteContentMinimal event={earliest} />
</div>
</div>
<div class="ml-13 flex items-center justify-between">
@@ -67,7 +67,7 @@
{formatTimestamp(latest.created_at)}
</span>
</div>
<Content minimalQuote minLength={100} maxLength={400} event={latest} />
<NoteContentMinimal event={latest} />
</div>
</Button>
{/if}
+17 -6
View File
@@ -1,20 +1,24 @@
<script lang="ts">
import {onMount} from "svelte"
import type {Snippet} from "svelte"
import {goto} from "$app/navigation"
import type {TrustedEvent} from "@welshman/util"
import {COMMENT} from "@welshman/util"
import {pubkey} from "@welshman/app"
import {pubkey, relaysByUrl} from "@welshman/app"
import ShareCircle from "@assets/icons/share-circle.svg?dataurl"
import Code2 from "@assets/icons/code-2.svg?dataurl"
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
import Danger from "@assets/icons/danger.svg?dataurl"
import {setKey} from "@lib/implicit"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import EventReport from "@app/components/EventReport.svelte"
import EventShare from "@app/components/EventShare.svelte"
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import {hasNip29} from "@app/core/state"
import {pushModal} from "@app/util/modal"
import ShareCircle from "@assets/icons/share-circle.svg?dataurl"
import Code2 from "@assets/icons/code-2.svg?dataurl"
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
import Danger from "@assets/icons/danger.svg?dataurl"
import {makeSpaceChatPath} from "@app/util/routes"
type Props = {
url: string
@@ -32,7 +36,14 @@
const showInfo = () => pushModal(EventInfo, {url, event})
const share = () => pushModal(EventShare, {url, event})
const share = async () => {
if (hasNip29($relaysByUrl.get(url))) {
pushModal(EventShare, {url, event})
} else {
setKey("share", event)
goto(makeSpaceChatPath(url))
}
}
const showDelete = () => pushModal(EventDeleteConfirm, {url, event})
+1 -1
View File
@@ -4,6 +4,7 @@
import {preventDefault} from "@lib/html"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import {setKey} from "@lib/implicit"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
@@ -11,7 +12,6 @@
import ChannelName from "@app/components/ChannelName.svelte"
import {channelsByUrl} from "@app/core/state"
import {makeRoomPath} from "@app/util/routes"
import {setKey} from "@lib/implicit"
const {url, noun, event}: {url: string; noun: string; event: TrustedEvent} = $props()
+22 -15
View File
@@ -1,23 +1,27 @@
<script lang="ts">
import type {TrustedEvent, EventContent} from "@welshman/util"
import {getTagValue} from "@welshman/util"
import Link from "@lib/components/Link.svelte"
import ReactionSummary from "@app/components/ReactionSummary.svelte"
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte"
import ChannelName from "@app/components/ChannelName.svelte"
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
import {makeGoalPath} from "@app/util/routes"
import {makeGoalPath, makeSpacePath} from "@app/util/routes"
interface Props {
url: any
event: any
url: string
event: TrustedEvent
showRoom?: boolean
showActivity?: boolean
}
const {url, event, showActivity = false}: Props = $props()
const shouldProtect = canEnforceNip70(url)
const {url, event, showRoom, showActivity}: Props = $props()
const path = makeGoalPath(url, event.id)
const room = getTagValue("h", event.tags)
const shouldProtect = canEnforceNip70(url)
const deleteReaction = async (event: TrustedEvent) =>
publishDelete({relays: [url], event, protect: await shouldProtect})
@@ -26,13 +30,16 @@
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
</script>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-grow flex-wrap justify-end gap-2">
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
<ThunkStatusOrDeleted {event} />
{#if showActivity}
<EventActivity {url} {path} {event} />
{/if}
<EventActions {url} {event} hideZap noun="Goal" />
</div>
<div class="flex flex-grow flex-wrap justify-end gap-2">
{#if room && showRoom}
<Link href={makeSpacePath(url, room)} class="btn btn-neutral btn-xs rounded-full">
Posted in #<ChannelName {room} {url} />
</Link>
{/if}
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
<ThunkStatusOrDeleted {event} />
{#if showActivity}
<EventActivity {url} {path} {event} />
{/if}
<EventActions {url} {event} hideZap noun="Goal" />
</div>
+10 -1
View File
@@ -18,7 +18,12 @@
import {makeEditor} from "@app/editor"
import {canEnforceNip70} from "@app/core/commands"
const {url} = $props()
type Props = {
url: string
room?: string
}
const {url, room}: Props = $props()
const shouldProtect = canEnforceNip70(url)
@@ -59,6 +64,10 @@
tags.push(PROTECTED)
}
if (room) {
tags.push(["h", room])
}
publishThunk({
relays: [url],
event: makeEvent(ZAP_GOAL, {content, tags}),
+5
View File
@@ -6,6 +6,7 @@
import ProfileLink from "@app/components/ProfileLink.svelte"
import GoalActions from "@app/components/GoalActions.svelte"
import GoalSummary from "@app/components/GoalSummary.svelte"
import ChannelLink from "@app/components/ChannelLink.svelte"
import {makeGoalPath} from "@app/util/routes"
type Props = {
@@ -16,6 +17,7 @@
const {url, event}: Props = $props()
const summary = getTagValue("summary", event.tags)
const room = getTagValue("h", event.tags)
</script>
<Link class="col-2 card2 bg-alt w-full cursor-pointer" href={makeGoalPath(url, event.id)}>
@@ -30,6 +32,9 @@
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
<span class="whitespace-nowrap py-1 text-sm opacity-75">
Posted by <ProfileLink pubkey={event.pubkey} {url} />
{#if room}
in <ChannelLink {url} {room} />
{/if}
</span>
<GoalActions showActivity {url} {event} />
</div>
+4 -3
View File
@@ -9,11 +9,12 @@
import ZapButton from "@app/components/ZapButton.svelte"
type Props = {
url: string
url?: string
event: TrustedEvent
class?: string
}
const {url, event}: Props = $props()
const {url, event, ...props}: Props = $props()
const zaps = deriveEventsMapped<Zap>(repository, {
filters: [{kinds: [ZAP_RESPONSE], "#e": [event.id]}],
@@ -27,7 +28,7 @@
const daysOld = Math.ceil((now() - event.created_at) / DAY)
</script>
<div class="card2 bg-alt flex flex-col gap-8">
<div class="flex flex-col gap-8 {props.class}">
<div class="flex gap-8">
<div>
<p class="text-xl text-primary">{zapAmount} sats</p>
+1 -4
View File
@@ -17,7 +17,6 @@
import {pushModal, clearModals} from "@app/util/modal"
import {PLATFORM_NAME, BURROW_URL} from "@app/core/state"
import {pushToast} from "@app/util/toast"
import {loadUserData} from "@app/core/requests"
import {setChecked} from "@app/util/notifications"
let signers: any[] = $state([])
@@ -27,9 +26,7 @@
const signUp = () => pushModal(SignUp)
const onSuccess = async (session: Session, relays: string[] = []) => {
await loadUserData(session.pubkey, relays)
const onSuccess = async (session: Session) => {
addSession(session)
pushToast({message: "Successfully logged in!"})
setChecked("*")
-6
View File
@@ -14,7 +14,6 @@
import BunkerConnect from "@app/components/BunkerConnect.svelte"
import BunkerUrl from "@app/components/BunkerUrl.svelte"
import {Nip46Controller} from "@app/util/nip46"
import {loadUserData} from "@app/core/requests"
import {clearModals} from "@app/util/modal"
import {setChecked} from "@app/util/notifications"
import {pushToast} from "@app/util/toast"
@@ -33,9 +32,6 @@
const pubkey = await controller.broker.getPublicKey()
loginWithNip46(pubkey, controller.clientSecret, response.event.pubkey, SIGNER_RELAYS)
await loadUserData(pubkey)
setChecked("*")
clearModals()
},
@@ -75,8 +71,6 @@
broker.cleanup()
controller.stop()
await loadUserData(pubkey)
loginWithNip46(pubkey, clientSecret, signerPubkey, relays)
} else {
return pushToast({
-3
View File
@@ -16,7 +16,6 @@
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import PasswordResetRequest from "@app/components/PasswordResetRequest.svelte"
import {loadUserData} from "@app/core/requests"
import {clearModals, pushModal} from "@app/util/modal"
import {setChecked} from "@app/util/notifications"
import {pushToast} from "@app/util/toast"
@@ -96,8 +95,6 @@
const pubkey = await broker.getPublicKey()
const session = makeNip46Session(pubkey, clientSecret, response.event.pubkey, relays)
await loadUserData(pubkey)
addSession({...session, email})
broker.cleanup()
setChecked("*")
+79 -33
View File
@@ -1,14 +1,16 @@
<script lang="ts">
import {onMount} from "svelte"
import {displayRelayUrl, getTagValue} from "@welshman/util"
import {deriveRelay} from "@welshman/app"
import {displayRelayUrl, getTagValue, EVENT_TIME, ZAP_GOAL, THREAD} from "@welshman/util"
import {deriveRelay, repository} from "@welshman/app"
import {fly} from "@lib/transition"
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
import RemoteControllerMinimalistic from "@assets/icons/remote-controller-minimalistic.svg?dataurl"
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Exit from "@assets/icons/logout-3.svg?dataurl"
import Letter from "@assets/icons/letter.svg?dataurl"
import Login from "@assets/icons/login-3.svg?dataurl"
import HomeSmile from "@assets/icons/home-smile.svg?dataurl"
import History from "@assets/icons/history.svg?dataurl"
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic-2.svg?dataurl"
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
@@ -16,14 +18,17 @@
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
import Bell from "@assets/icons/bell.svg?dataurl"
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 SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
import SecondaryNavHeader from "@lib/components/SecondaryNavHeader.svelte"
import SecondaryNavSection from "@lib/components/SecondaryNavSection.svelte"
import SpaceDetail from "@app/components/SpaceDetail.svelte"
import SpaceInvite from "@app/components/SpaceInvite.svelte"
import SpaceExit from "@app/components/SpaceExit.svelte"
import SpaceJoin from "@app/components/SpaceJoin.svelte"
import RelayName from "@app/components/RelayName.svelte"
import ProfileList from "@app/components/ProfileList.svelte"
import AlertAdd from "@app/components/AlertAdd.svelte"
import Alerts from "@app/components/Alerts.svelte"
@@ -36,13 +41,14 @@
memberships,
deriveUserRooms,
deriveOtherRooms,
trackerStore,
hasNip29,
alerts,
deriveUserCanCreateRoom,
} from "@app/core/state"
import {notifications} from "@app/util/notifications"
import {pushModal} from "@app/util/modal"
import {makeSpacePath} from "@app/util/routes"
import {makeSpacePath, makeChatPath} from "@app/util/routes"
const {url} = $props()
@@ -53,8 +59,21 @@
const calendarPath = makeSpacePath(url, "calendar")
const userRooms = deriveUserRooms(url)
const otherRooms = deriveOtherRooms(url)
const owner = $derived($relay?.profile?.pubkey)
const hasAlerts = $derived($alerts.some(a => getTagValue("feed", a.tags)?.includes(url)))
const spaceKinds = $derived(
Array.from($trackerStore.getIds(url)).reduce((kinds, id) => {
const event = repository.getEvent(id)
if (event) {
kinds.add(event.kind)
}
return kinds
}, new Set()),
)
const openMenu = () => {
showMenu = true
}
@@ -63,6 +82,8 @@
showMenu = !showMenu
}
const showDetail = () => pushModal(SpaceDetail, {url}, {replaceState})
const showMembers = () =>
pushModal(
ProfileList,
@@ -103,17 +124,28 @@
<div bind:this={element} class="flex h-full flex-col justify-between">
<SecondaryNavSection>
<div>
<SecondaryNavItem class="w-full !justify-between" onclick={openMenu}>
<strong class="ellipsize flex items-center gap-3">
{displayRelayUrl(url)}
</strong>
<Icon icon={AltArrowDown} />
</SecondaryNavItem>
<Button
class="flex w-full flex-col rounded-xl p-3 transition-all hover:bg-base-100"
onclick={openMenu}>
<div class="flex items-center justify-between">
<strong class="ellipsize flex items-center gap-3">
<RelayName {url} />
</strong>
<Icon icon={AltArrowDown} />
</div>
<span class="text-xs text-primary">{displayRelayUrl(url)}</span>
</Button>
{#if showMenu}
<Popover hideOnClick onClose={toggleMenu}>
<ul
transition:fly
class="menu absolute z-popover mt-2 w-full gap-1 rounded-box bg-base-100 p-2 shadow-xl">
<li>
<Button onclick={showDetail}>
<Icon icon={RemoteControllerMinimalistic} />
Space Information
</Button>
</li>
<li>
<Button onclick={showMembers}>
<Icon icon={UserRounded} />
@@ -126,6 +158,14 @@
Create Invite
</Button>
</li>
{#if owner}
<li>
<Link href={makeChatPath([owner])}>
<Icon icon={Letter} />
Contact Owner
</Link>
</li>
{/if}
<li>
{#if $userRoomsByUrl.has(url)}
<Button onclick={leaveSpace} class="text-error">
@@ -144,10 +184,19 @@
{/if}
</div>
<div class="flex max-h-[calc(100vh-150px)] min-h-0 flex-col gap-1 overflow-auto">
<SecondaryNavItem {replaceState} href={makeSpacePath(url)}>
<Icon icon={HomeSmile} /> Home
</SecondaryNavItem>
{#if ENABLE_ZAPS}
{#if hasNip29($relay)}
<SecondaryNavItem {replaceState} href={makeSpacePath(url, "recent")}>
<Icon icon={History} /> Recent Activity
</SecondaryNavItem>
{:else}
<SecondaryNavItem
{replaceState}
href={chatPath}
notification={$notifications.has(chatPath)}>
<Icon icon={ChatRound} /> Chat
</SecondaryNavItem>
{/if}
{#if ENABLE_ZAPS && spaceKinds.has(ZAP_GOAL)}
<SecondaryNavItem
{replaceState}
href={goalsPath}
@@ -155,18 +204,22 @@
<Icon icon={StarFallMinimalistic} /> Goals
</SecondaryNavItem>
{/if}
<SecondaryNavItem
{replaceState}
href={threadsPath}
notification={$notifications.has(threadsPath)}>
<Icon icon={NotesMinimalistic} /> Threads
</SecondaryNavItem>
<SecondaryNavItem
{replaceState}
href={calendarPath}
notification={$notifications.has(calendarPath)}>
<Icon icon={CalendarMinimalistic} /> Calendar
</SecondaryNavItem>
{#if spaceKinds.has(THREAD)}
<SecondaryNavItem
{replaceState}
href={threadsPath}
notification={$notifications.has(threadsPath)}>
<Icon icon={NotesMinimalistic} /> Threads
</SecondaryNavItem>
{/if}
{#if spaceKinds.has(EVENT_TIME)}
<SecondaryNavItem
{replaceState}
href={calendarPath}
notification={$notifications.has(calendarPath)}>
<Icon icon={CalendarMinimalistic} /> Calendar
</SecondaryNavItem>
{/if}
{#if hasNip29($relay)}
{#if $userRooms.length > 0}
<div class="h-2"></div>
@@ -194,13 +247,6 @@
Create room
</SecondaryNavItem>
{/if}
{:else}
<SecondaryNavItem
{replaceState}
href={chatPath}
notification={$notifications.has(chatPath)}>
<Icon icon={ChatRound} /> Chat
</SecondaryNavItem>
{/if}
</div>
</SecondaryNavSection>
+9 -13
View File
@@ -1,24 +1,20 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {EVENT_TIME} from "@welshman/util"
import {EVENT_TIME, ZAP_GOAL, THREAD} from "@welshman/util"
import NoteContentEventTime from "@app/components/NoteContentEventTime.svelte"
import NoteContentThread from "@app/components/NoteContentThread.svelte"
import NoteContentGoal from "@app/components/NoteContentGoal.svelte"
import Content from "@app/components/Content.svelte"
import CalendarEventDate from "@app/components/CalendarEventDate.svelte"
import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte"
const props: ComponentProps<typeof Content> = $props()
</script>
{#if props.event.kind === EVENT_TIME}
<div class="flex items-start gap-4">
<CalendarEventDate event={props.event} />
<div class="flex flex-grow flex-col">
<CalendarEventHeader event={props.event} />
<div class="flex py-2 opacity-50">
<div class="h-px flex-grow bg-base-content opacity-25"></div>
</div>
<Content {...props} />
</div>
</div>
<NoteContentEventTime {...props} />
{:else if props.event.kind === THREAD}
<NoteContentThread {...props} />
{:else if props.event.kind === ZAP_GOAL}
<NoteContentGoal {...props} />
{:else}
<Content {...props} />
{/if}
@@ -0,0 +1,19 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import Content from "@app/components/Content.svelte"
import CalendarEventDate from "@app/components/CalendarEventDate.svelte"
import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte"
const props: ComponentProps<typeof Content> = $props()
</script>
<div class="flex items-start gap-4">
<CalendarEventDate event={props.event} />
<div class="flex flex-grow flex-col">
<CalendarEventHeader event={props.event} />
<div class="flex py-2 opacity-50">
<div class="h-px flex-grow bg-base-content opacity-25"></div>
</div>
<Content {...props} />
</div>
</div>
+17
View File
@@ -0,0 +1,17 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {getTagValue} from "@welshman/util"
import Content from "@app/components/Content.svelte"
import GoalSummary from "@app/components/GoalSummary.svelte"
const props: ComponentProps<typeof Content> = $props()
const content = getTagValue("summary", props.event.tags)
const fakeEvent = {content, tags: props.event.tags}
</script>
<div class="flex flex-col gap-2">
<p class="text-2xl">{props.event.content}</p>
<Content {...props} event={fakeEvent} expandMode="inline" minLength={50} maxLength={300} />
<GoalSummary url={props.url} event={props.event} />
</div>
@@ -0,0 +1,22 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {EVENT_TIME, ZAP_GOAL, THREAD} from "@welshman/util"
import NoteContentMinimalEventTime from "@app/components/NoteContentMinimalEventTime.svelte"
import NoteContentMinimalThread from "@app/components/NoteContentMinimalThread.svelte"
import NoteContentMinimalGoal from "@app/components/NoteContentMinimalGoal.svelte"
import ContentMinimal from "@app/components/ContentMinimal.svelte"
const props: ComponentProps<typeof ContentMinimal> = $props()
</script>
<div class="text-xs">
{#if props.event.kind === EVENT_TIME}
<NoteContentMinimalEventTime {...props} />
{:else if props.event.kind === THREAD}
<NoteContentMinimalThread {...props} />
{:else if props.event.kind === ZAP_GOAL}
<NoteContentMinimalGoal {...props} />
{:else}
<ContentMinimal {...props} />
{/if}
</div>
@@ -0,0 +1,36 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {
fromPairs,
formatTimestamp,
formatTimestampAsDate,
formatTimestampAsTime,
} from "@welshman/lib"
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import ContentMinimal from "@app/components/ContentMinimal.svelte"
const props: ComponentProps<typeof ContentMinimal> = $props()
const meta = $derived(fromPairs(props.event.tags) as Record<string, string>)
const start = $derived(parseInt(meta.start))
const end = $derived(parseInt(meta.end))
</script>
<div class="flex flex-col">
<div class="flex flex-grow flex-wrap justify-between gap-2">
<p class="text-sm">{meta.title || meta.name}</p>
{#if !isNaN(start) && !isNaN(end)}
{@const startDateDisplay = formatTimestampAsDate(start)}
{@const endDateDisplay = formatTimestampAsDate(end)}
{@const isSingleDay = startDateDisplay === endDateDisplay}
<div class="flex items-center gap-2">
<Icon icon={ClockCircle} size={4} />
<span class="hidden sm:block">{formatTimestampAsDate(start)}</span>
{formatTimestampAsTime(start)}{isSingleDay
? formatTimestampAsTime(end)
: formatTimestamp(end)}
</div>
{/if}
</div>
<ContentMinimal {...props} />
</div>
@@ -0,0 +1,34 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {sum} from "@welshman/lib"
import type {Zap, TrustedEvent} from "@welshman/util"
import {getTagValue, fromMsats, ZAP_RESPONSE} from "@welshman/util"
import {deriveEventsMapped} from "@welshman/store"
import {repository, getValidZap} from "@welshman/app"
import Bolt from "@assets/icons/bolt.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import ContentMinimal from "@app/components/ContentMinimal.svelte"
const props: ComponentProps<typeof ContentMinimal> = $props()
const content = getTagValue("summary", props.event.tags)
const fakeEvent = {content, tags: props.event.tags}
const zaps = deriveEventsMapped<Zap>(repository, {
filters: [{kinds: [ZAP_RESPONSE], "#e": [props.event.id]}],
itemToEvent: item => item.response,
eventToItem: (response: TrustedEvent) => getValidZap(response, props.event),
})
const goalAmount = parseInt(getTagValue("amount", props.event.tags) || "0")
const zapAmount = $derived(fromMsats(sum($zaps.map(zap => zap.invoiceAmount))))
</script>
<div class="flex justify-between">
<span class="text-sm">{props.event.content}</span>
<div class="flex items-center gap-1">
<Icon icon={Bolt} size={4} />
{zapAmount}/{goalAmount} sats funded
</div>
</div>
<ContentMinimal {...props} event={fakeEvent} />
@@ -0,0 +1,16 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {getTagValue} from "@welshman/util"
import ContentMinimal from "@app/components/ContentMinimal.svelte"
const props: ComponentProps<typeof ContentMinimal> = $props()
const title = getTagValue("title", props.event.tags)
</script>
{#if title}
<span class="text-sm">{title}</span>
{/if}
{#if props.event.content}
<ContentMinimal {...props} />
{/if}
@@ -0,0 +1,28 @@
<script lang="ts">
import type {ComponentProps} from "svelte"
import {formatTimestamp} from "@welshman/lib"
import {getTagValue} from "@welshman/util"
import Content from "@app/components/Content.svelte"
const props: ComponentProps<typeof Content> = $props()
const title = getTagValue("title", props.event.tags)
</script>
<div class="flex flex-col gap-2">
{#if title}
<div class="flex w-full items-center justify-between gap-2">
<p class="text-xl">{title}</p>
<p class="text-sm opacity-75">
{formatTimestamp(props.event.created_at)}
</p>
</div>
{:else}
<p class="mb-3 h-0 text-xs opacity-75">
{formatTimestamp(props.event.created_at)}
</p>
{/if}
{#if props.event.content}
<Content {...props} />
{/if}
</div>
@@ -1,18 +1,23 @@
<script lang="ts">
import {goto} from "$app/navigation"
import {displayRelayUrl} from "@welshman/util"
import PrimaryNavItem from "@lib/components/PrimaryNavItem.svelte"
import SpaceAvatar from "@app/components/SpaceAvatar.svelte"
import {encodeRelay} from "@app/core/state"
import {makeSpacePath} from "@app/util/routes"
import {lastPageBySpaceUrl} from "@app/util/history"
import {notifications} from "@app/util/notifications"
const {url} = $props()
const path = makeSpacePath(url)
const onClick = () => goto(lastPageBySpaceUrl.get(encodeRelay(url)) || path)
</script>
<PrimaryNavItem
onclick={onClick}
title={displayRelayUrl(url)}
href={path}
class="tooltip-right"
notification={$notifications.has(path)}>
<SpaceAvatar {url} />
+2 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import {removeNil} from "@welshman/lib"
import {deriveProfile} from "@welshman/app"
import Content from "@app/components/Content.svelte"
import ContentMinimal from "@app/components/ContentMinimal.svelte"
export type Props = {
pubkey: string
@@ -14,5 +14,5 @@
</script>
{#if $profile}
<Content event={{content: $profile.about || "", tags: []}} hideMediaAtDepth={0} />
<ContentMinimal event={{content: $profile.about || "", tags: []}} />
{/if}
+3 -1
View File
@@ -18,6 +18,8 @@
const openProfile = () => pushModal(ProfileDetail, {pubkey, url})
</script>
<Button onclick={preventDefault(openProfile)} class={cx(props.class, {"link-content": !unstyled})}>
<Button
onclick={preventDefault(openProfile)}
class={cx(props.class, {"link-content bg-alt": !unstyled})}>
@<ProfileName {pubkey} {url} />
</Button>
+7 -9
View File
@@ -118,7 +118,7 @@
<button
type="button"
data-tip={`This content has been reported as "${displayList(reportReasons)}".`}
class="btn btn-error btn-xs tooltip-right flex items-center gap-1 rounded-full"
class="btn btn-error btn-xs tooltip-right flex items-center gap-1 rounded-full font-normal"
class:tooltip={!noTooltip && !isMobile}
onclick={stopPropagation(preventDefault(onReportClick))}>
<Icon icon={Danger} />
@@ -134,11 +134,10 @@
<button
type="button"
data-tip={tooltip}
class="flex-inline btn btn-neutral btn-xs gap-1 rounded-full {reactionClass}"
class="flex-inline btn btn-neutral btn-xs gap-1 rounded-full text-xs font-normal {reactionClass}"
class:tooltip={!noTooltip && !isMobile}
class:border={isOwn}
class:border-solid={isOwn}
class:border-primary={isOwn}>
class:btn-neutral={!isOwn}
class:btn-primary={isOwn}>
<Reaction event={zaps[0].request} />
<span>{amount}</span>
</button>
@@ -152,11 +151,10 @@
<button
type="button"
data-tip={tooltip}
class="flex-inline btn btn-neutral btn-xs gap-1 rounded-full {reactionClass}"
class="flex-inline btn btn-neutral btn-xs gap-1 rounded-full font-normal {reactionClass}"
class:tooltip={!noTooltip && !isMobile}
class:border={isOwn}
class:border-solid={isOwn}
class:border-primary={isOwn}
class:btn-neutral={!isOwn}
class:btn-primary={isOwn}
onclick={stopPropagation(preventDefault(onClick))}>
<Reaction event={events[0]} />
{#if events.length > 1}
+82
View File
@@ -0,0 +1,82 @@
<script lang="ts">
import {displayRelayUrl} from "@welshman/util"
import {deriveRelay} from "@welshman/app"
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
import ShieldUser from "@assets/icons/shield-user.svg?dataurl"
import BillList from "@assets/icons/bill-list.svg?dataurl"
import Ghost from "@assets/icons/ghost-smile.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
import RelayName from "@app/components/RelayName.svelte"
import SpaceRelayStatus from "@app/components/SpaceRelayStatus.svelte"
import RelayDescription from "@app/components/RelayDescription.svelte"
import ProfileLatest from "@app/components/ProfileLatest.svelte"
type Props = {
url: string
}
const {url}: Props = $props()
const relay = deriveRelay(url)
const owner = $derived($relay?.profile?.pubkey)
const back = () => history.back()
</script>
<div class="column gap-4">
<div class="relative flex gap-4">
<div class="relative">
<div class="avatar relative">
<div
class="center !flex h-16 w-16 min-w-16 rounded-full border-2 border-solid border-base-300 bg-base-300">
{#if $relay?.profile?.icon}
<img alt="" src={$relay.profile.icon} />
{:else}
<Icon icon={Ghost} size={6} />
{/if}
</div>
</div>
</div>
<div class="flex min-w-0 flex-col gap-1">
<h1 class="ellipsize whitespace-nowrap text-2xl font-bold">
<RelayName {url} />
</h1>
<p class="ellipsize text-sm opacity-75">{displayRelayUrl(url)}</p>
</div>
</div>
<RelayDescription {url} />
{#if $relay?.profile?.terms_of_service || $relay?.profile?.privacy_policy}
<div class="flex gap-3">
{#if $relay.profile.terms_of_service}
<Link href={$relay.profile.terms_of_service} class="badge badge-neutral flex gap-2">
<Icon icon={BillList} size={4} />
Terms of Service
</Link>
{/if}
{#if $relay.profile.privacy_policy}
<Link href={$relay?.profile?.privacy_policy} class="badge badge-neutral flex gap-2">
<Icon icon={ShieldUser} size={4} />
Privacy Policy
</Link>
{/if}
</div>
{/if}
<SpaceRelayStatus {url} />
<div class="flex flex-col gap-2">
{#if owner}
<div class="card2 bg-alt">
<h3 class="mb-4 flex items-center gap-2 text-lg font-semibold">
<Icon icon={UserRounded} />
Latest Updates
</h3>
<ProfileLatest {url} pubkey={owner}>
{#snippet fallback()}
<p class="text-sm opacity-60">No recent posts from the relay admin</p>
{/snippet}
</ProfileLatest>
</div>
{/if}
</div>
<Button class="btn btn-primary" onclick={back}>Got it</Button>
</div>
-144
View File
@@ -1,144 +0,0 @@
<script lang="ts">
import {deriveRelay} from "@welshman/app"
import {fade} from "@lib/transition"
import CompassBig from "@assets/icons/compass-big.svg?dataurl"
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic.svg?dataurl"
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import Lock from "@assets/icons/lock-keyhole.svg?dataurl"
import Hashtag from "@assets/icons/hashtag.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
import RoomCreate from "@app/components/RoomCreate.svelte"
import ChannelName from "@app/components/ChannelName.svelte"
import {makeRoomPath, makeSpacePath} from "@app/util/routes"
import {
hasNip29,
deriveUserRooms,
deriveOtherRooms,
makeChannelId,
channelsById,
} from "@app/core/state"
import {notifications} from "@app/util/notifications"
import {pushModal} from "@app/util/modal"
type Props = {
url: string
}
const {url}: Props = $props()
const relay = deriveRelay(url)
const userRooms = deriveUserRooms(url)
const otherRooms = deriveOtherRooms(url)
const chatPath = makeSpacePath(url, "chat")
const goalsPath = makeSpacePath(url, "goals")
const threadsPath = makeSpacePath(url, "threads")
const calendarPath = makeSpacePath(url, "calendar")
const addRoom = () => pushModal(RoomCreate, {url})
const filteredRooms = $derived(() => {
if (!term) return [...$userRooms, ...$otherRooms]
const query = term.toLowerCase()
const allRooms = [...$userRooms, ...$otherRooms]
return allRooms.filter(room => {
const channel = $channelsById.get(makeChannelId(url, room))
const roomName = channel?.name || room
return roomName.toLowerCase().includes(query)
})
})
let term = $state("")
</script>
<div class="card2 bg-alt md:hidden">
<h3 class="mb-4 flex items-center gap-2 text-lg font-semibold">
<Icon icon={CompassBig} />
Quick Links
</h3>
<div class="flex flex-col gap-2">
<Link href={goalsPath} class="btn btn-neutral w-full justify-start">
<div class="relative flex items-center gap-2">
<Icon icon={StarFallMinimalistic} />
Goals
{#if $notifications.has(goalsPath)}
<div
class="absolute -right-3 -top-1 h-2 w-2 rounded-full bg-neutral-content"
transition:fade>
</div>
{/if}
</div>
</Link>
<Link href={threadsPath} class="btn btn-neutral w-full justify-start">
<div class="relative flex items-center gap-2">
<Icon icon={NotesMinimalistic} />
Threads
{#if $notifications.has(threadsPath)}
<div
class="absolute -right-3 -top-1 h-2 w-2 rounded-full bg-neutral-content"
transition:fade>
</div>
{/if}
</div>
</Link>
<Link href={calendarPath} class="btn btn-neutral w-full justify-start">
<div class="relative flex items-center gap-2">
<Icon icon={CalendarMinimalistic} />
Calendar
{#if $notifications.has(calendarPath)}
<div
class="absolute -right-3 -top-1 h-2 w-2 rounded-full bg-neutral-content"
transition:fade>
</div>
{/if}
</div>
</Link>
{#if hasNip29($relay)}
{#if $userRooms.length + $otherRooms.length > 10}
<label class="input input-sm input-bordered flex flex-grow items-center gap-2">
<Icon icon={Magnifier} size={4} />
<input bind:value={term} class="grow" type="text" placeholder="Search rooms..." />
</label>
{/if}
{#each filteredRooms() as room (room)}
{@const roomPath = makeRoomPath(url, room)}
{@const channel = $channelsById.get(makeChannelId(url, room))}
<Link href={roomPath} class="btn btn-neutral btn-sm relative w-full justify-start">
<div class="flex min-w-0 items-center gap-2 overflow-hidden text-nowrap">
{#if channel?.closed || channel?.private}
<Icon icon={Lock} size={4} />
{:else}
<Icon icon={Hashtag} />
{/if}
<ChannelName {url} {room} />
</div>
{#if $notifications.has(roomPath)}
<div class="absolute right-1 top-1 h-2 w-2 rounded-full bg-primary" transition:fade>
</div>
{/if}
</Link>
{/each}
<Button onclick={addRoom} class="btn btn-neutral btn-sm w-full justify-start">
<Icon icon={AddCircle} />
Create Room
</Button>
{:else}
<Link href={chatPath} class="btn btn-neutral w-full justify-start">
<div class="relative flex items-center gap-2">
<Icon icon={ChatRound} />
Chat
{#if $notifications.has(chatPath)}
<div class="absolute -right-3 -top-1 h-2 w-2 rounded-full bg-primary" transition:fade>
</div>
{/if}
</div>
</Link>
{/if}
</div>
</div>
@@ -1,106 +0,0 @@
<script lang="ts">
import {derived} from "svelte/store"
import {groupBy, ago, MONTH, first, last, uniq, avg, overlappingPairs} from "@welshman/lib"
import {MESSAGE, getTagValue} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import ConversationCard from "@app/components/ConversationCard.svelte"
import {deriveEventsForUrl} from "@app/core/state"
type Props = {
url: string
}
const {url}: Props = $props()
const since = ago(MONTH)
const messages = deriveEventsForUrl(url, [{kinds: [MESSAGE], since}])
const conversations = derived(messages, $messages => {
const convs = []
for (const [room, messages] of groupBy(e => getTagValue("h", e.tags), $messages).entries()) {
const avgTime = avg(overlappingPairs(messages).map(([a, b]) => a.created_at - b.created_at))
const groups: TrustedEvent[][] = []
const group: TrustedEvent[] = []
// Group conversations by time between messages
let prevCreatedAt = messages[0].created_at
for (const message of messages) {
if (prevCreatedAt - message.created_at < avgTime) {
group.push(message)
} else {
groups.push(group.splice(0))
}
prevCreatedAt = message.created_at
}
if (group.length > 0) {
groups.push(group.splice(0))
}
// Convert each group into a conversation
for (const events of groups) {
if (events.length < 2) {
continue
}
const latest = first(events)!
const earliest = last(events)!
const participants = uniq(events.map(msg => msg.pubkey))
convs.push({room, events, latest, earliest, participants})
}
}
return convs
})
const viewMore = () => {
limit += 3
}
let limit = $state(3)
</script>
<div class="card2 bg-alt">
<div class="flex flex-col gap-4">
<h3 class="flex items-center gap-2 text-lg font-semibold">
<Icon icon={ChatRound} />
Recent Conversations
</h3>
<div class="flex flex-col gap-4">
{#if $conversations.length === 0}
{#if $messages.length > 0}
{@const events = $messages.slice(0, 1)}
{@const event = events[0]}
{@const room = getTagValue("h", event.tags)}
<ConversationCard
{url}
{room}
{events}
latest={event}
earliest={event}
participants={[event.pubkey]} />
{:else}
<div class="py-8 text-center opacity-70">
<p>No recent conversations</p>
</div>
{/if}
{:else}
{#each $conversations.slice(0, limit) as { room, events, latest, earliest, participants } (latest.id)}
<ConversationCard {url} {room} {events} {latest} {earliest} {participants} />
{/each}
{#if $conversations.length > limit}
<Button class="btn btn-primary" onclick={viewMore}>
View more conversations
<Icon icon={AltArrowDown} />
</Button>
{/if}
{/if}
</div>
</div>
</div>
+22 -15
View File
@@ -1,23 +1,27 @@
<script lang="ts">
import type {TrustedEvent, EventContent} from "@welshman/util"
import {getTagValue} from "@welshman/util"
import Link from "@lib/components/Link.svelte"
import ChannelName from "@app/components/ChannelName.svelte"
import ReactionSummary from "@app/components/ReactionSummary.svelte"
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte"
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
import {makeThreadPath} from "@app/util/routes"
import {makeThreadPath, makeSpacePath} from "@app/util/routes"
interface Props {
url: any
event: any
url: string
event: TrustedEvent
showRoom?: boolean
showActivity?: boolean
}
const {url, event, showActivity = false}: Props = $props()
const shouldProtect = canEnforceNip70(url)
const {url, event, showRoom, showActivity}: Props = $props()
const path = makeThreadPath(url, event.id)
const room = getTagValue("h", event.tags)
const shouldProtect = canEnforceNip70(url)
const deleteReaction = async (event: TrustedEvent) =>
publishDelete({relays: [url], event, protect: await shouldProtect})
@@ -26,13 +30,16 @@
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
</script>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-grow flex-wrap justify-end gap-2">
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
<ThunkStatusOrDeleted {event} />
{#if showActivity}
<EventActivity {url} {path} {event} />
{/if}
<EventActions {url} {event} noun="Thread" />
</div>
<div class="flex flex-grow flex-wrap justify-end gap-2">
{#if room && showRoom}
<Link href={makeSpacePath(url, room)} class="btn btn-neutral btn-xs rounded-full">
Posted in #<ChannelName {room} {url} />
</Link>
{/if}
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-left" />
<ThunkStatusOrDeleted {event} />
{#if showActivity}
<EventActivity {url} {path} {event} />
{/if}
<EventActions {url} {event} noun="Thread" />
</div>
+10 -1
View File
@@ -16,7 +16,12 @@
import {makeEditor} from "@app/editor"
import {canEnforceNip70} from "@app/core/commands"
const {url} = $props()
type Props = {
url: string
room?: string
}
const {url, room}: Props = $props()
const shouldProtect = canEnforceNip70(url)
@@ -52,6 +57,10 @@
tags.push(PROTECTED)
}
if (room) {
tags.push(["h", room])
}
publishThunk({
relays: [url],
event: makeEvent(THREAD, {content, tags}),
+10 -3
View File
@@ -1,10 +1,12 @@
<script lang="ts">
import {nthEq, formatTimestamp} from "@welshman/lib"
import {formatTimestamp} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {getTagValue} from "@welshman/util"
import Link from "@lib/components/Link.svelte"
import Content from "@app/components/Content.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte"
import ThreadActions from "@app/components/ThreadActions.svelte"
import ChannelLink from "@app/components/ChannelLink.svelte"
import {makeThreadPath} from "@app/util/routes"
type Props = {
@@ -14,7 +16,8 @@
const {url, event}: Props = $props()
const title = event.tags.find(nthEq(0, "title"))?.[1]
const title = getTagValue("title", event.tags)
const room = getTagValue("h", event.tags)
</script>
<Link class="col-2 card2 bg-alt w-full cursor-pointer" href={makeThreadPath(url, event.id)}>
@@ -33,7 +36,11 @@
<Content {event} {url} expandMode="inline" />
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
<span class="whitespace-nowrap py-1 text-sm opacity-75">
Posted by <ProfileLink pubkey={event.pubkey} {url} />
Posted by
<ProfileLink pubkey={event.pubkey} {url} />
{#if room}
in <ChannelLink {url} {room} />
{/if}
</span>
<ThreadActions showActivity {url} {event} />
</div>
+1 -1
View File
@@ -9,7 +9,7 @@
import {pushModal} from "@app/util/modal"
type Props = {
url: string
url?: string
event: TrustedEvent
children: Snippet
replaceState?: boolean
+1 -96
View File
@@ -1,10 +1,6 @@
import {get, writable} from "svelte/store"
import {
partition,
chunk,
sample,
sleep,
shuffle,
uniq,
int,
YEAR,
@@ -18,12 +14,9 @@ import {
fromPairs,
} from "@welshman/lib"
import {
MESSAGE,
DELETE,
THREAD,
EVENT_TIME,
AUTH_INVITE,
COMMENT,
ALERT_EMAIL,
ALERT_WEB,
ALERT_IOS,
@@ -47,24 +40,10 @@ import {
thunkQueue,
makeFeedController,
loadRelay,
loadMutes,
loadFollows,
loadProfile,
loadBlossomServers,
loadRelaySelections,
loadInboxRelaySelections,
} from "@welshman/app"
import {createScroller} from "@lib/html"
import {daysBetween} from "@lib/util"
import {
NOTIFIER_RELAY,
INDEXER_RELAYS,
defaultPubkeys,
userRoomsByUrl,
getUrlsForEvent,
loadMembership,
loadSettings,
} from "@app/core/state"
import {NOTIFIER_RELAY, getUrlsForEvent} from "@app/core/state"
// Utils
@@ -359,80 +338,6 @@ export const loadAlertStatuses = (pubkey: string) =>
filters: [{kinds: [ALERT_STATUS], "#p": [pubkey]}],
})
// Application requests
export const listenForNotifications = () => {
const controller = new AbortController()
for (const [url, allRooms] of userRoomsByUrl.get()) {
// Limit how many rooms we load at a time, since we have to send a separate filter
// for each one due to relay29 being picky
const rooms = shuffle(Array.from(allRooms)).slice(0, 30)
load({
signal: controller.signal,
relays: [url],
filters: [
{kinds: [THREAD], limit: 1},
{kinds: [MESSAGE], limit: 1},
{kinds: [COMMENT], "#K": [String(THREAD)], limit: 1},
...rooms.map(room => ({kinds: [MESSAGE], "#h": [room], limit: 1})),
],
})
request({
signal: controller.signal,
relays: [url],
filters: [
{kinds: [THREAD], since: now()},
{kinds: [MESSAGE], since: now()},
{kinds: [COMMENT], "#K": [String(THREAD)], since: now()},
...rooms.map(room => ({kinds: [MESSAGE], "#h": [room], since: now()})),
],
})
}
return () => controller.abort()
}
export const loadUserData = async (pubkey: string, relays: string[] = []) => {
await Promise.race([sleep(3000), loadRelaySelections(pubkey, relays)])
const promise = Promise.race([
sleep(3000),
Promise.all([
loadInboxRelaySelections(pubkey, relays),
loadBlossomServers(pubkey, relays),
loadMembership(pubkey, relays),
loadSettings(pubkey, relays),
loadProfile(pubkey, relays),
loadFollows(pubkey, relays),
loadMutes(pubkey, relays),
loadAlertStatuses(pubkey),
loadAlerts(pubkey),
]),
])
// Load followed profiles slowly in the background without clogging other stuff up. Only use a single
// indexer relay to avoid too many redundant validations, which slow things down and eat bandwidth
promise.then(async () => {
for (const pubkeys of chunk(50, get(defaultPubkeys))) {
const relays = sample(1, INDEXER_RELAYS)
await sleep(1000)
for (const pubkey of pubkeys) {
loadMembership(pubkey, relays)
loadProfile(pubkey, relays)
loadFollows(pubkey, relays)
loadMutes(pubkey, relays)
}
}
})
return promise
}
export const discoverRelays = (lists: List[]) =>
Promise.all(
uniq(lists.flatMap($l => getRelaysFromList($l)))
+250
View File
@@ -0,0 +1,250 @@
import {page} from "$app/stores"
import type {Unsubscriber} from "svelte/store"
import {derived, get} from "svelte/store"
import {call, chunk, sleep, now, identity, WEEK, ago} from "@welshman/lib"
import {
getListTags,
getRelayTagValues,
WRAP,
MESSAGE,
ZAP_GOAL,
THREAD,
EVENT_TIME,
COMMENT,
isSignedEvent,
} from "@welshman/util"
import {request, pull} from "@welshman/net"
import {
pubkey,
loadRelay,
userFollows,
userRelaySelections,
userInboxRelaySelections,
loadRelaySelections,
loadInboxRelaySelections,
loadBlossomServers,
loadFollows,
loadMutes,
loadProfile,
repository,
} from "@welshman/app"
import {
INDEXER_RELAYS,
canDecrypt,
loadSettings,
userMembership,
defaultPubkeys,
decodeRelay,
loadMembership,
} from "@app/core/state"
import {loadAlerts, loadAlertStatuses} from "@app/core/requests"
const syncRelays = () => {
for (const url of INDEXER_RELAYS) {
loadRelay(url)
}
const unsubscribePage = page.subscribe($page => {
if ($page.params.relay) {
loadRelay(decodeRelay($page.params.relay))
}
})
const unsubscribeMembership = userMembership.subscribe($l => {
for (const url of getRelayTagValues(getListTags($l))) {
loadRelay(url)
}
})
return () => {
unsubscribePage()
unsubscribeMembership()
}
}
const syncUserData = () => {
const unsubscribePubkey = pubkey.subscribe($pubkey => {
if ($pubkey) {
loadRelaySelections($pubkey)
}
})
const unsubscribeSelections = userRelaySelections.subscribe($l => {
const $pubkey = pubkey.get()
if ($pubkey) {
loadAlerts($pubkey)
loadAlertStatuses($pubkey)
loadBlossomServers($pubkey)
loadFollows($pubkey)
loadMembership($pubkey)
loadMutes($pubkey)
loadProfile($pubkey)
loadSettings($pubkey)
}
})
const unsubscribeFollows = userFollows.subscribe(async $l => {
for (const pubkeys of chunk(10, get(defaultPubkeys))) {
// This isn't urgent, avoid clogging other stuff up
await sleep(1000)
for (const pk of pubkeys) {
loadRelaySelections(pk).then(() => {
loadMembership(pk)
loadProfile(pk)
loadFollows(pk)
loadMutes(pk)
})
}
}
})
return () => {
unsubscribePubkey()
unsubscribeSelections()
unsubscribeFollows()
}
}
const syncSpace = (url: string) => {
const controller = new AbortController()
// Load historical data
pull({
relays: [url],
signal: controller.signal,
filters: [{kinds: [ZAP_GOAL, EVENT_TIME, THREAD, MESSAGE, COMMENT]}],
events: repository
.query([{kinds: [ZAP_GOAL, EVENT_TIME, THREAD, MESSAGE, COMMENT]}])
.filter(isSignedEvent),
})
// Load new events
request({
relays: [url],
signal: controller.signal,
filters: [{kinds: [ZAP_GOAL, EVENT_TIME, THREAD, MESSAGE, COMMENT], since: now()}],
})
return () => controller.abort()
}
const syncSpaces = () => {
const unsubscribersByUrl = new Map<string, Unsubscriber>()
const unsubscribeMembership = userMembership.subscribe($l => {
const urls = getRelayTagValues(getListTags($l))
// Start syncing newly added spaces
for (const url of urls) {
if (!unsubscribersByUrl.has(url)) {
unsubscribersByUrl.set(url, syncSpace(url))
}
}
// stop syncing removed spaces
for (const [url, unsubscribe] of unsubscribersByUrl.entries()) {
if (!urls.includes(url)) {
unsubscribersByUrl.delete(url)
unsubscribe()
}
}
})
return () => {
Array.from(unsubscribersByUrl.values()).forEach(call)
unsubscribeMembership()
}
}
const syncDMRelay = (url: string, pubkey: string) => {
const controller = new AbortController()
// Load historical data
pull({
relays: [url],
signal: controller.signal,
filters: [{kinds: [WRAP], "#p": [pubkey], until: ago(WEEK, 2)}],
events: repository
.query([{kinds: [ZAP_GOAL, EVENT_TIME, THREAD, MESSAGE, COMMENT]}])
.filter(isSignedEvent),
})
// Load new events
request({
relays: [url],
signal: controller.signal,
filters: [{kinds: [WRAP], "#p": [pubkey], since: ago(WEEK, 2)}],
})
return () => controller.abort()
}
const syncDMs = () => {
const unsubscribersByUrl = new Map<string, Unsubscriber>()
let currentPubkey: string | undefined
const unsubscribeAll = () => {
for (const [url, unsubscribe] of unsubscribersByUrl.entries()) {
unsubscribersByUrl.delete(url)
unsubscribe()
}
}
const subscribeAll = (pubkey: string, urls: string[]) => {
// Start syncing newly added relays
for (const url of urls) {
if (!unsubscribersByUrl.has(url)) {
unsubscribersByUrl.set(url, syncDMRelay(url, pubkey))
}
}
// Stop syncing removed spaces
for (const [url, unsubscribe] of unsubscribersByUrl.entries()) {
if (!urls.includes(url)) {
unsubscribersByUrl.delete(url)
unsubscribe()
}
}
}
// When pubkey changes, re-sync
const unsubscribePubkey = derived([pubkey, canDecrypt], identity).subscribe(
([$pubkey, $canDecrypt]) => {
if ($pubkey !== currentPubkey) {
unsubscribeAll()
}
// If we have a pubkey, refresh our user's relay selections then sync our subscriptions
if ($pubkey && $canDecrypt) {
loadRelaySelections($pubkey)
.then(() => loadInboxRelaySelections($pubkey))
.then($l => subscribeAll($pubkey, getRelayTagValues(getListTags($l))))
}
currentPubkey = $pubkey
},
)
// When user inbox relays change, update synchronization
const unsubscribeSelections = userInboxRelaySelections.subscribe($l => {
const $pubkey = pubkey.get()
if ($pubkey && $l) {
subscribeAll($pubkey, getRelayTagValues(getListTags($l)))
}
})
return () => {
unsubscribeAll()
unsubscribePubkey()
unsubscribeSelections()
}
}
export const syncApplicationData = () => {
const unsubscribers = [syncRelays(), syncUserData(), syncSpaces(), syncDMs()]
return () => unsubscribers.forEach(call)
}
+11
View File
@@ -0,0 +1,11 @@
import {page} from "$app/stores"
export const lastPageBySpaceUrl = new Map<string, string>()
export const setupHistory = () => {
page.subscribe($page => {
if ($page.params.relay) {
lastPageBySpaceUrl.set($page.params.relay, $page.url.pathname)
}
})
}
+40 -30
View File
@@ -3,10 +3,11 @@ import {synced, throttled} from "@welshman/store"
import {pubkey, relaysByUrl} from "@welshman/app"
import {prop, spec, identity, now, groupBy} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {EVENT_TIME, MESSAGE, THREAD, COMMENT, getTagValue} from "@welshman/util"
import {ZAP_GOAL, EVENT_TIME, MESSAGE, THREAD, COMMENT, getTagValue} from "@welshman/util"
import {
makeSpacePath,
makeChatPath,
makeGoalPath,
makeThreadPath,
makeCalendarPath,
makeSpaceChatPath,
@@ -76,45 +77,52 @@ export const notifications = derived(
}
}
const allThreadEvents = $repository.query([
{kinds: [THREAD]},
{kinds: [COMMENT], "#K": [String(THREAD)]},
])
const allGoalComments = $repository.query([{kinds: [COMMENT], "#K": [String(ZAP_GOAL)]}])
const allCalendarEvents = $repository.query([
{kinds: [EVENT_TIME]},
{kinds: [COMMENT], "#K": [String(EVENT_TIME)]},
])
const allThreadComments = $repository.query([{kinds: [COMMENT], "#K": [String(THREAD)]}])
const allMessageEvents = $repository.query([{kinds: [MESSAGE]}])
const allCalendarComments = $repository.query([{kinds: [COMMENT], "#K": [String(EVENT_TIME)]}])
const allMessages = $repository.query([{kinds: [MESSAGE, THREAD, ZAP_GOAL, EVENT_TIME]}])
for (const [url, rooms] of $userRoomsByUrl.entries()) {
const spacePath = makeSpacePath(url)
const goalPath = makeGoalPath(url)
const threadPath = makeThreadPath(url)
const calendarPath = makeCalendarPath(url)
const messagesPath = makeSpaceChatPath(url)
const threadEvents = allThreadEvents.filter(e => $getUrlsForEvent(e.id).includes(url))
const calendarEvents = allCalendarEvents.filter(e => $getUrlsForEvent(e.id).includes(url))
const messagesEvents = allMessageEvents.filter(e => $getUrlsForEvent(e.id).includes(url))
const goalComments = allGoalComments.filter(e => $getUrlsForEvent(e.id).includes(url))
const threadComments = allThreadComments.filter(e => $getUrlsForEvent(e.id).includes(url))
const calendarComments = allCalendarComments.filter(e => $getUrlsForEvent(e.id).includes(url))
const messages = allMessages.filter(e => $getUrlsForEvent(e.id).includes(url))
if (hasNotification(threadPath, threadEvents[0])) {
paths.add(spacePath)
paths.add(threadPath)
}
const commentsByGoalId = groupBy(
e => getTagValue("E", e.tags),
goalComments.filter(spec({kind: COMMENT})),
)
if (hasNotification(calendarPath, calendarEvents[0])) {
paths.add(spacePath)
paths.add(calendarPath)
for (const [goalId, [comment]] of commentsByGoalId.entries()) {
const goalItemPath = makeGoalPath(url, goalId)
if (hasNotification(goalPath, comment)) {
paths.add(goalPath)
}
if (hasNotification(goalItemPath, comment)) {
paths.add(goalItemPath)
}
}
const commentsByThreadId = groupBy(
e => getTagValue("E", e.tags),
threadEvents.filter(spec({kind: COMMENT})),
threadComments.filter(spec({kind: COMMENT})),
)
for (const [threadId, [comment]] of commentsByThreadId.entries()) {
const threadItemPath = makeThreadPath(url, threadId)
if (hasNotification(threadPath, comment)) {
paths.add(threadPath)
}
if (hasNotification(threadItemPath, comment)) {
paths.add(threadItemPath)
}
@@ -122,24 +130,26 @@ export const notifications = derived(
const commentsByEventId = groupBy(
e => getTagValue("E", e.tags),
calendarEvents.filter(spec({kind: COMMENT})),
calendarComments.filter(spec({kind: COMMENT})),
)
for (const [eventId, [comment]] of commentsByEventId.entries()) {
const calendarEventPath = makeCalendarPath(url, eventId)
const calendarItemPath = makeCalendarPath(url, eventId)
if (hasNotification(calendarEventPath, comment)) {
paths.add(calendarEventPath)
if (hasNotification(calendarPath, comment)) {
paths.add(calendarPath)
}
if (hasNotification(calendarItemPath, comment)) {
paths.add(calendarItemPath)
}
}
if (hasNip29($relaysByUrl.get(url))) {
for (const room of rooms) {
const roomPath = makeRoomPath(url, room)
const latestEvent = allMessageEvents.find(
e =>
$getUrlsForEvent(e.id).includes(url) &&
e.tags.find(t => t[0] === "h" && t[1] === room),
const latestEvent = allMessages.find(
e => $getUrlsForEvent(e.id).includes(url) && e.tags.find(spec(["h", room])),
)
if (hasNotification(roomPath, latestEvent)) {
@@ -148,7 +158,7 @@ export const notifications = derived(
}
}
} else {
if (hasNotification(messagesPath, messagesEvents[0])) {
if (hasNotification(messagesPath, messages[0])) {
paths.add(spacePath)
paths.add(messagesPath)
}
+21 -1
View File
@@ -3,7 +3,7 @@ import * as nip19 from "nostr-tools/nip19"
import {goto} from "$app/navigation"
import {nthEq, sleep} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {tracker} from "@welshman/app"
import {tracker, relaysByUrl} from "@welshman/app"
import {scrollToEvent} from "@lib/html"
import {identity} from "@welshman/lib"
import {
@@ -23,6 +23,7 @@ import {
decodeRelay,
encodeRelay,
userRoomsByUrl,
hasNip29,
ROOM,
} from "@app/core/state"
@@ -36,6 +37,14 @@ export const makeSpacePath = (url: string, ...extra: (string | undefined)[]) =>
.filter(identity)
.map(s => encodeURIComponent(s as string))
.join("/")
} else {
const relay = relaysByUrl.get().get(url)
if (hasNip29(relay)) {
path += "/recent"
} else {
path += "/chat"
}
}
return path
@@ -143,3 +152,14 @@ export const getEventPath = async (event: TrustedEvent, urls: string[]) => {
return entityLink(nip19.neventEncode({id: event.id, relays: urls}))
}
export const getChannelItemPath = (url: string, event: TrustedEvent) => {
switch (event.kind) {
case THREAD:
return makeThreadPath(url, event.id)
case ZAP_GOAL:
return makeGoalPath(url, event.id)
case EVENT_TIME:
return makeCalendarPath(url, event.id)
}
}
+4 -10
View File
@@ -167,20 +167,14 @@ const syncTracker = async () => {
tracker.load(relaysById)
let p = Promise.resolve()
const updateOne = batch(3000, (ids: string[]) => {
p = p.then(() => {
collection.add(ids.map(id => [id, Array.from(tracker.getRelays(id))]))
})
collection.add(ids.map(id => [id, Array.from(tracker.getRelays(id))]))
})
const updateAll = throttle(3000, () => {
p = p.then(() => {
collection.set(
Array.from(tracker.relaysById.entries()).map(([id, relays]) => [id, Array.from(relays)]),
)
})
collection.set(
Array.from(tracker.relaysById.entries()).map(([id, relays]) => [id, Array.from(relays)]),
)
})
tracker.on("add", updateOne)
File diff suppressed because it is too large Load Diff
+7 -63
View File
@@ -4,31 +4,17 @@
import {throttle} from "throttle-debounce"
import {onMount} from "svelte"
import * as nip19 from "nostr-tools/nip19"
import {get, derived} from "svelte/store"
import {get} from "svelte/store"
import {App, type URLOpenListenerEvent} from "@capacitor/app"
import {dev} from "$app/environment"
import {goto} from "$app/navigation"
import {sync, localStorageProvider} from "@welshman/store"
import {
ago,
assoc,
call,
defer,
dissoc,
identity,
memoize,
on,
sleep,
spec,
TaskQueue,
WEEK,
} from "@welshman/lib"
import {assoc, call, defer, dissoc, on, sleep, spec, TaskQueue} from "@welshman/lib"
import type {TrustedEvent, StampedEvent} from "@welshman/util"
import {WRAP} from "@welshman/util"
import {Nip46Broker, makeSecret} from "@welshman/signer"
import type {Socket, RelayMessage, ClientMessage} from "@welshman/net"
import {
request,
defaultSocketPolicies,
makeSocketPolicyAuth,
SocketEvent,
@@ -40,7 +26,6 @@
isClientClose,
} from "@welshman/net"
import {
loadRelay,
repository,
pubkey,
session,
@@ -64,20 +49,18 @@
import {preferencesStorageProvider} from "@lib/storage"
import AppContainer from "@app/components/AppContainer.svelte"
import ModalContainer from "@app/components/ModalContainer.svelte"
import {setupHistory} from "@app/util/history"
import {setupTracking} from "@app/util/tracking"
import {setupAnalytics} from "@app/util/analytics"
import {
INDEXER_RELAYS,
userMembership,
userSettingsValues,
relaysPendingTrust,
ensureUnwrapped,
canDecrypt,
getSetting,
relaysMostlyRestricted,
userInboxRelays,
} from "@app/core/state"
import {loadUserData, listenForNotifications} from "@app/core/requests"
import {syncApplicationData} from "@app/core/sync"
import {theme} from "@app/util/theme"
import {toast, pushToast} from "@app/util/toast"
import {initializePushNotifications} from "@app/util/push"
@@ -192,8 +175,6 @@
// TODO: remove ack result
if (pubkey && ["ack", connectSecret].includes(result)) {
await loadUserData(pubkey)
loginWithNip46(pubkey, clientSecret, signerPubkey, relays)
broker.cleanup()
success = true
@@ -224,6 +205,7 @@
if (!initialized) {
initialized = true
setupHistory()
setupTracking()
setupAnalytics()
@@ -374,46 +356,8 @@
},
)
// Load relay info
for (const url of INDEXER_RELAYS) {
loadRelay(url)
}
// Load user data
if ($pubkey) {
await loadUserData($pubkey)
}
// Listen for space data, populate space-based notifications
let unsubSpaces: any
userMembership.subscribe(
memoize($membership => {
unsubSpaces?.()
unsubSpaces = listenForNotifications()
}),
)
// Listen for chats, populate chat-based notifications
let controller: AbortController
derived([pubkey, canDecrypt, userInboxRelays], identity).subscribe(
([$pubkey, $canDecrypt, $userInboxRelays]) => {
controller?.abort()
controller = new AbortController()
if ($pubkey && $canDecrypt) {
request({
signal: controller.signal,
relays: $userInboxRelays,
filters: [
{kinds: [WRAP], "#p": [$pubkey], since: ago(WEEK, 2)},
{kinds: [WRAP], "#p": [$pubkey], limit: 100},
],
})
}
},
)
// Load user data, listen for messages, etc
syncApplicationData()
// subscribe to badge count for changes
notifications.badgeCount.subscribe(notifications.handleBadgeCountChanges)
+2 -2
View File
@@ -17,7 +17,7 @@
import FieldInline from "@lib/components/FieldInline.svelte"
import Button from "@lib/components/Button.svelte"
import Avatar from "@lib/components/Avatar.svelte"
import Content from "@app/components/Content.svelte"
import ContentMinimal from "@app/components/ContentMinimal.svelte"
import ProfileEdit from "@app/components/ProfileEdit.svelte"
import ProfileDelete from "@app/components/ProfileDelete.svelte"
import SignerStatus from "@app/components/SignerStatus.svelte"
@@ -66,7 +66,7 @@
</Button>
</div>
{#key $profile?.about}
<Content event={{content: $profile?.about || "", tags: []}} hideMediaAtDepth={0} />
<ContentMinimal event={{content: $profile?.about || "", tags: []}} />
{/key}
</div>
{#if $session?.email}
+4 -119
View File
@@ -1,125 +1,10 @@
<script lang="ts">
import {page} from "$app/stores"
import {displayRelayUrl} from "@welshman/util"
import {deriveRelay} from "@welshman/app"
import HomeSmile from "@assets/icons/home-smile.svg?dataurl"
import Login2 from "@assets/icons/login-3.svg?dataurl"
import Letter from "@assets/icons/letter-opened.svg?dataurl"
import Ghost from "@assets/icons/ghost-smile.svg?dataurl"
import BillList from "@assets/icons/bill-list.svg?dataurl"
import ShieldUser from "@assets/icons/shield-user.svg?dataurl"
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte"
import ProfileLatest from "@app/components/ProfileLatest.svelte"
import SpaceJoin from "@app/components/SpaceJoin.svelte"
import RelayName from "@app/components/RelayName.svelte"
import RelayDescription from "@app/components/RelayDescription.svelte"
import SpaceQuickLinks from "@app/components/SpaceQuickLinks.svelte"
import SpaceRecentActivity from "@app/components/SpaceRecentActivity.svelte"
import SpaceRelayStatus from "@app/components/SpaceRelayStatus.svelte"
import {decodeRelay, userRoomsByUrl} from "@app/core/state"
import {makeChatPath} from "@app/util/routes"
import {pushModal} from "@app/util/modal"
import {goto} from "$app/navigation"
import {decodeRelay} from "@app/core/state"
import {makeSpacePath} from "@app/util/routes"
const url = decodeRelay($page.params.relay!)
const relay = deriveRelay(url)
const joinSpace = () => pushModal(SpaceJoin, {url})
const owner = $derived($relay?.profile?.pubkey)
goto(makeSpacePath(url, "recent"))
</script>
<PageBar>
{#snippet icon()}
<div class="center">
<Icon icon={HomeSmile} />
</div>
{/snippet}
{#snippet title()}
<strong>Home</strong>
{/snippet}
{#snippet action()}
<div class="row-2">
{#if !$userRoomsByUrl.has(url)}
<Button class="btn btn-primary btn-sm" onclick={joinSpace}>
<Icon icon={Login2} />
Join Space
</Button>
{:else if owner}
<Link class="btn btn-primary btn-sm" href={makeChatPath([owner])}>
<Icon icon={Letter} />
Contact Owner
</Link>
{/if}
<MenuSpaceButton {url} />
</div>
{/snippet}
</PageBar>
<PageContent class="flex flex-col gap-2 p-2 pt-4">
<div class="card2 bg-alt flex flex-col gap-4 text-left">
<div class="relative flex gap-4">
<div class="relative">
<div class="avatar relative">
<div
class="center !flex h-16 w-16 min-w-16 rounded-full border-2 border-solid border-base-300 bg-base-300">
{#if $relay?.profile?.icon}
<img alt="" src={$relay.profile.icon} />
{:else}
<Icon icon={Ghost} size={6} />
{/if}
</div>
</div>
</div>
<div class="flex min-w-0 flex-col gap-1">
<h1 class="ellipsize whitespace-nowrap text-2xl font-bold">
<RelayName {url} />
</h1>
<p class="ellipsize text-sm opacity-75">{displayRelayUrl(url)}</p>
</div>
</div>
<RelayDescription {url} />
{#if $relay?.profile?.terms_of_service || $relay?.profile?.privacy_policy}
<div class="flex gap-3">
{#if $relay.profile.terms_of_service}
<Link href={$relay.profile.terms_of_service} class="badge badge-neutral flex gap-2">
<Icon icon={BillList} size={4} />
Terms of Service
</Link>
{/if}
{#if $relay.profile.privacy_policy}
<Link href={$relay?.profile?.privacy_policy} class="badge badge-neutral flex gap-2">
<Icon icon={ShieldUser} size={4} />
Privacy Policy
</Link>
{/if}
</div>
{/if}
</div>
<SpaceQuickLinks {url} />
<div class="grid grid-cols-1 gap-2 lg:grid-cols-2">
<div class="flex flex-col gap-2">
<SpaceRecentActivity {url} />
</div>
<div class="flex flex-col gap-2">
<SpaceRelayStatus {url} />
{#if owner}
<div class="card2 bg-alt">
<h3 class="mb-4 flex items-center gap-2 text-lg font-semibold">
<Icon icon={UserRounded} />
Latest Updates
</h3>
<ProfileLatest {url} pubkey={owner}>
{#snippet fallback()}
<p class="text-sm opacity-60">No recent posts from the relay admin</p>
{/snippet}
</ProfileLatest>
</div>
{/if}
</div>
</div>
</PageContent>
+11 -7
View File
@@ -13,6 +13,9 @@
makeRoomMeta,
MESSAGE,
DELETE,
THREAD,
EVENT_TIME,
ZAP_GOAL,
ROOM_ADD_USER,
ROOM_REMOVE_USER,
} from "@welshman/util"
@@ -33,7 +36,7 @@
import ThunkToast from "@app/components/ThunkToast.svelte"
import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte"
import ChannelName from "@app/components/ChannelName.svelte"
import ChannelMessage from "@app/components/ChannelMessage.svelte"
import ChannelItem from "@app/components/ChannelItem.svelte"
import ChannelCompose from "@app/components/ChannelCompose.svelte"
import ChannelComposeParent from "@app/components/ChannelComposeParent.svelte"
import {
@@ -65,7 +68,7 @@
const lastChecked = $checked[$page.url.pathname]
const url = decodeRelay(relay)
const channel = deriveChannel(url, room)
const filter = {kinds: [MESSAGE], "#h": [room]}
const filter = {kinds: [MESSAGE, THREAD, EVENT_TIME, ZAP_GOAL], "#h": [room]}
const isFavorite = $derived($userRoomsByUrl.get(url)?.has(room))
const shouldProtect = canEnforceNip70(url)
const membershipStatus = deriveUserMembershipStatus(url, room)
@@ -429,7 +432,7 @@
<Divider>{value}</Divider>
{:else}
<div in:slide class:-mt-1={!showPubkey}>
<ChannelMessage
<ChannelItem
{url}
{replyTo}
event={$state.snapshot(value as TrustedEvent)}
@@ -485,11 +488,12 @@
</div>
{#key eventToEdit}
<ChannelCompose
bind:this={compose}
content={eventToEdit?.content}
{onSubmit}
{url}
{onEditPrevious} />
{room}
{onSubmit}
{onEditPrevious}
content={eventToEdit?.content}
bind:this={compose} />
{/key}
{/if}
</div>
@@ -46,17 +46,19 @@
let haveISeenTheFuture = false
let prevDateDisplay: string
return $events.map<Item>(event => {
const newDateDisplay = formatTimestampAsDate(getStart(event))
const dateDisplay = prevDateDisplay === newDateDisplay ? undefined : newDateDisplay
const isFuture = todayDateDisplay === newDateDisplay || event.created_at > now()
const isFirstFutureEvent = !haveISeenTheFuture && isFuture
return $events
.filter(event => !isNaN(getStart(event)))
.map<Item>(event => {
const newDateDisplay = formatTimestampAsDate(getStart(event))
const dateDisplay = prevDateDisplay === newDateDisplay ? undefined : newDateDisplay
const isFuture = todayDateDisplay === newDateDisplay || event.created_at > now()
const isFirstFutureEvent = !haveISeenTheFuture && isFuture
prevDateDisplay = newDateDisplay
haveISeenTheFuture = isFuture
prevDateDisplay = newDateDisplay
haveISeenTheFuture = isFuture
return {event, dateDisplay, isFirstFutureEvent}
})
return {event, dateDisplay, isFirstFutureEvent}
})
})
let previousScrollHeight = 0
@@ -93,7 +93,7 @@
</div>
</div>
<div class="flex w-full flex-col justify-end sm:flex-row">
<CalendarEventActions {url} event={$event} />
<CalendarEventActions showRoom {url} event={$event} />
</div>
</div>
{#if !showAll && $replies.length > 4}
+10 -9
View File
@@ -5,7 +5,7 @@
import {readable} from "svelte/store"
import {now, formatTimestampAsDate, MINUTE, ago} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {makeEvent, MESSAGE, DELETE} from "@welshman/util"
import {makeEvent, MESSAGE, DELETE, THREAD, EVENT_TIME, ZAP_GOAL} from "@welshman/util"
import {pubkey, publishThunk} from "@welshman/app"
import {slide, fade, fly} from "@lib/transition"
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
@@ -18,7 +18,7 @@
import Divider from "@lib/components/Divider.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte"
import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte"
import ChannelMessage from "@app/components/ChannelMessage.svelte"
import ChannelItem from "@app/components/ChannelItem.svelte"
import ChannelCompose from "@app/components/ChannelCompose.svelte"
import ChannelComposeParent from "@app/components/ChannelComposeParent.svelte"
import {
@@ -38,7 +38,7 @@
const mounted = now()
const lastChecked = $checked[$page.url.pathname]
const url = decodeRelay($page.params.relay!)
const filter = {kinds: [MESSAGE]}
const filter = {kinds: [MESSAGE, THREAD, EVENT_TIME, ZAP_GOAL]}
const shouldProtect = canEnforceNip70(url)
const replyTo = (event: TrustedEvent) => {
@@ -282,11 +282,12 @@
{:else if type === "date"}
<Divider>{value}</Divider>
{:else}
{@const event = $state.snapshot(value as TrustedEvent)}
<div in:slide class:-mt-1={!showPubkey}>
<ChannelMessage
<ChannelItem
{url}
{event}
{replyTo}
event={$state.snapshot(value as TrustedEvent)}
{showPubkey}
canEdit={canEditEvent}
onEdit={onEditEvent} />
@@ -316,11 +317,11 @@
</div>
{#key eventToEdit}
<ChannelCompose
bind:this={compose}
content={eventToEdit?.content}
{onSubmit}
{url}
{onEditPrevious} />
{onSubmit}
{onEditPrevious}
content={eventToEdit?.content}
bind:this={compose} />
{/key}
</div>
@@ -87,7 +87,7 @@
<div class="col-3 ml-12">
<Content showEntire event={{...$event, content: summary}} {url} />
<GoalSummary event={$event} {url} />
<GoalActions event={$event} {url} />
<GoalActions showRoom event={$event} {url} />
</div>
</NoteCard>
{#if !showAll && $replies.length > 4}
@@ -0,0 +1,118 @@
<script lang="ts">
import {onMount} from "svelte"
import {derived} from "svelte/store"
import {page} from "$app/stores"
import {groupBy, ago, MONTH, first, last, uniq, avg, overlappingPairs} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {MESSAGE, getTagValue} from "@welshman/util"
import History from "@assets/icons/history.svg?dataurl"
import {createScroller} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import MenuSpaceButton from "@app/components/MenuSpaceButton.svelte"
import ConversationCard from "@app/components/ConversationCard.svelte"
import {decodeRelay, deriveEventsForUrl} from "@app/core/state"
const url = decodeRelay($page.params.relay!)
const since = ago(MONTH)
const messages = deriveEventsForUrl(url, [{kinds: [MESSAGE], since}])
const conversations = derived(messages, $messages => {
const convs = []
for (const [room, messages] of groupBy(e => getTagValue("h", e.tags), $messages).entries()) {
const avgTime = avg(overlappingPairs(messages).map(([a, b]) => a.created_at - b.created_at))
const groups: TrustedEvent[][] = []
const group: TrustedEvent[] = []
// Group conversations by time between messages
let prevCreatedAt = messages[0].created_at
for (const message of messages) {
if (prevCreatedAt - message.created_at < avgTime) {
group.push(message)
} else {
groups.push(group.splice(0))
}
prevCreatedAt = message.created_at
}
if (group.length > 0) {
groups.push(group.splice(0))
}
// Convert each group into a conversation
for (const events of groups) {
if (events.length < 2) {
continue
}
const latest = first(events)!
const earliest = last(events)!
const participants = uniq(events.map(msg => msg.pubkey))
convs.push({room, events, latest, earliest, participants})
}
}
return convs
})
let limit = $state(3)
let element: Element | undefined = $state()
onMount(() => {
const scroller = createScroller({
element: element!,
onScroll: () => {
limit += 3
},
})
return () => scroller.stop()
})
</script>
<PageBar>
{#snippet icon()}
<div class="center">
<Icon icon={History} />
</div>
{/snippet}
{#snippet title()}
<strong>Recent Activity</strong>
{/snippet}
{#snippet action()}
<div class="row-2">
<MenuSpaceButton {url} />
</div>
{/snippet}
</PageBar>
<div bind:this={element}>
<PageContent class="flex flex-col gap-2 p-2 pt-4">
{#if $conversations.length === 0}
{#if $messages.length > 0}
{@const events = $messages.slice(0, 1)}
{@const event = events[0]}
{@const room = getTagValue("h", event.tags)}
<ConversationCard
{url}
{room}
{events}
latest={event}
earliest={event}
participants={[event.pubkey]} />
{:else}
<div class="py-8 text-center opacity-70">
<p>No recent conversations</p>
</div>
{/if}
{:else}
{#each $conversations.slice(0, limit) as { room, events, latest, earliest, participants } (latest.id)}
<ConversationCard {url} {room} {events} {latest} {earliest} {participants} />
{/each}
{/if}
</PageContent>
</div>
@@ -84,7 +84,7 @@
<NoteCard event={$event} {url} class="card2 bg-alt z-feature w-full">
<div class="col-3 ml-12">
<Content showEntire event={$event} {url} />
<ThreadActions event={$event} {url} />
<ThreadActions showRoom event={$event} {url} />
</div>
</NoteCard>
{#if !showAll && $replies.length > 4}