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
+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}