Split up space information and directory

This commit is contained in:
Jon Staab
2026-06-22 16:06:00 -07:00
parent 7ec5a28d1f
commit c44c3793fa
27 changed files with 710 additions and 433 deletions
+6 -1
View File
@@ -85,7 +85,7 @@
} }
@utility card2 { @utility card2 {
@apply rounded-box text-base-content p-4 sm:p-6; @apply rounded-box text-base-content border-base-content/20 border border-solid p-4 sm:p-6 shadow-xl/5 bg-base-100;
} }
@utility column { @utility column {
@@ -276,6 +276,11 @@
@apply text-base-content p-2 sm:p-4; @apply text-base-content p-2 sm:p-4;
} }
.card2 .card2,
.dialog .card2 {
@apply shadow-none;
}
[data-tip]::before { [data-tip]::before {
@apply overflow-hidden text-ellipsis; @apply overflow-hidden text-ellipsis;
} }
+1 -1
View File
@@ -19,7 +19,7 @@
</script> </script>
<Link <Link
class="cv col-3 card2 bg-alt w-full cursor-pointer shadow-md" class="cv col-3 card2 w-full cursor-pointer"
href={makeCalendarPath(url, getAddress(event))}> href={makeCalendarPath(url, getAddress(event))}>
<CalendarEventHeader {event} /> <CalendarEventHeader {event} />
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row"> <div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
+1 -1
View File
@@ -25,7 +25,7 @@
</script> </script>
<Link <Link
class="cv col-2 card2 bg-alt w-full cursor-pointer shadow-xl" class="cv col-2 card2 w-full cursor-pointer"
href={makeClassifiedPath(url, getAddress(event))}> href={makeClassifiedPath(url, getAddress(event))}>
{#if title} {#if title}
<div class="flex w-full items-center justify-between gap-2"> <div class="flex w-full items-center justify-between gap-2">
+2 -2
View File
@@ -77,7 +77,7 @@
<span class="loading loading-spinner"></span> <span class="loading loading-spinner"></span>
</div> </div>
{:then preview} {:then preview}
<div class="bg-alt flex max-w-xl flex-col leading-normal"> <div class="border border-solid border-base-content/20 flex max-w-xl flex-col leading-normal rounded-box">
{#if preview.image && !hideImage} {#if preview.image && !hideImage}
<img <img
alt="" alt=""
@@ -92,7 +92,7 @@
</div> </div>
</div> </div>
{:catch} {:catch}
<p class="bg-alt p-12 text-center leading-normal"> <p class="border border-solid border-base-content/20 p-12 text-center leading-normal">
Unable to load a preview for {url} Unable to load a preview for {url}
</p> </p>
{/await} {/await}
+1 -1
View File
@@ -53,7 +53,7 @@
<NoteContentMinimal trimParent {url} event={$quote} /> <NoteContentMinimal trimParent {url} event={$quote} />
</div> </div>
{:else} {:else}
<NoteCard noShadow event={$quote} {url} class="bg-alt rounded-box p-4"> <NoteCard noShadow event={$quote} {url} class="border border-solid border-base-content/20 rounded-box p-4">
<NoteContentMinimal {url} event={$quote} /> <NoteContentMinimal {url} event={$quote} />
</NoteCard> </NoteCard>
{/if} {/if}
@@ -0,0 +1,76 @@
<script lang="ts">
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import Field from "@lib/components/Field.svelte"
import Icon from "@lib/components/Icon.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import InputList from "@lib/components/InputList.svelte"
import RelayName from "@app/components/RelayName.svelte"
import {setFeaturedContent} from "@app/featured"
import {pushToast} from "@app/toast"
type Props = {
url: string
initial: string[]
}
const {url, initial}: Props = $props()
let content = $state([...initial])
let loading = $state(false)
const back = () => history.back()
const submit = async () => {
loading = true
try {
const error = await setFeaturedContent(url, content)
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Featured content updated!"})
back()
}
} finally {
loading = false
}
}
</script>
<Modal>
<ModalBody>
<ModalHeader>
<ModalTitle>Featured Content</ModalTitle>
<ModalSubtitle>on <RelayName {url} class="text-primary" /></ModalSubtitle>
</ModalHeader>
<Field>
{#snippet info()}
<p>Each entry is shown on the space's About page. Links will be fetched and displayed automatically.</p>
{/snippet}
{#snippet input()}
<InputList bind:value={content} placeholder="URL or nevent...">
{#snippet addLabel()}
Add content
{/snippet}
</InputList>
{/snippet}
</Field>
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button class="btn btn-primary" onclick={submit} disabled={loading}>
<Spinner {loading}>Save changes</Spinner>
</Button>
</ModalFooter>
</Modal>
+1 -1
View File
@@ -21,7 +21,7 @@
</script> </script>
<Link <Link
class="cv col-2 card2 bg-alt w-full cursor-pointer shadow-md" class="cv col-2 card2 w-full cursor-pointer"
href={makeGoalPath(url, event.id)}> href={makeGoalPath(url, event.id)}>
<p class="text-2xl">{event.content}</p> <p class="text-2xl">{event.content}</p>
<Content <Content
+1 -1
View File
@@ -40,7 +40,7 @@
}) })
</script> </script>
<NoteCard {event} {url} class="cv card2 bg-alt"> <NoteCard {event} {url} class="cv card2">
<NoteContent {event} expandMode="inline" /> <NoteContent {event} expandMode="inline" />
<div class="flex w-full justify-between gap-2"> <div class="flex w-full justify-between gap-2">
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-right"> <ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-right">
+1 -1
View File
@@ -25,7 +25,7 @@
const onClick = () => goto(h ? makeRoomPath(url, h) : makeSpaceChatPath(url)) const onClick = () => goto(h ? makeRoomPath(url, h) : makeSpaceChatPath(url))
</script> </script>
<Button class="cv card2 bg-alt shadow-md" onclick={onClick}> <Button class="cv card2" onclick={onClick}>
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<div class="flex items-center gap-2 text-sm"> <div class="flex items-center gap-2 text-sm">
{#if h} {#if h}
+34
View File
@@ -0,0 +1,34 @@
<script lang="ts">
import {THREAD, CLASSIFIED, ZAP_GOAL, EVENT_TIME, POLL} from "@welshman/util"
import NoteItem from "@app/components/NoteItem.svelte"
import ThreadItem from "@app/components/ThreadItem.svelte"
import ClassifiedItem from "@app/components/ClassifiedItem.svelte"
import GoalItem from "@app/components/GoalItem.svelte"
import CalendarEventItem from "@app/components/CalendarEventItem.svelte"
import PollItem from "@app/components/PollItem.svelte"
import RecentConversation from "@app/components/RecentConversation.svelte"
import type {RecentActivityItem} from "@app/recent"
type Props = {
url: string
item: RecentActivityItem
}
const {url, item}: Props = $props()
</script>
{#if item.type === "message"}
<RecentConversation {url} event={item.event} count={item.count} />
{:else if item.event.kind === THREAD}
<ThreadItem {url} event={item.event} />
{:else if item.event.kind === CLASSIFIED}
<ClassifiedItem {url} event={item.event} />
{:else if item.event.kind === ZAP_GOAL}
<GoalItem {url} event={item.event} />
{:else if item.event.kind === EVENT_TIME}
<CalendarEventItem {url} event={item.event} />
{:else if item.event.kind === POLL}
<PollItem {url} event={item.event} />
{:else}
<NoteItem {url} event={item.event} />
{/if}
-120
View File
@@ -1,120 +0,0 @@
<script lang="ts">
import {displayRelayUrl} from "@welshman/util"
import {deriveRelay} from "@welshman/app"
import Pen from "@assets/icons/pen.svg?dataurl"
import Server from "@assets/icons/server.svg?dataurl"
import ShieldUser from "@assets/icons/shield-user.svg?dataurl"
import BillList from "@assets/icons/bill-list.svg?dataurl"
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import RelayIcon from "@app/components/RelayIcon.svelte"
import RelayName from "@app/components/RelayName.svelte"
import RelayDescription from "@app/components/RelayDescription.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte"
import SpaceEdit from "@app/components/SpaceEdit.svelte"
import SocketStatusIndicator from "@app/components/SocketStatusIndicator.svelte"
import {deriveUserIsSpaceAdmin} from "@app/members"
import {pushModal} from "@app/modal"
interface Props {
url: string
}
const {url}: Props = $props()
const relay = deriveRelay(url)
const userIsAdmin = deriveUserIsSpaceAdmin(url)
const startEdit = () => pushModal(SpaceEdit, {url, initialValues: $relay || {url}})
</script>
<div class="card2 bg-alt flex flex-col gap-4">
<div class="flex justify-between">
<div class="relative flex gap-4">
<div class="relative">
<RelayIcon {url} size={14} class="rounded-full" />
</div>
<div class="flex min-w-0 flex-col">
<h1 class="ellipsize whitespace-nowrap">
<RelayName {url} class="text-2xl font-bold" />
</h1>
<p class="ellipsize text-sm text-primary">{displayRelayUrl(url)}</p>
</div>
</div>
{#if $userIsAdmin}
<Button class="btn btn-primary" onclick={startEdit}>
<Icon icon={Pen} />
Edit
</Button>
{/if}
</div>
<RelayDescription {url} />
{#if $relay?.terms_of_service || $relay?.privacy_policy}
<div class="flex gap-3">
{#if $relay.terms_of_service}
<Link href={$relay.terms_of_service} class="badge badge-neutral flex gap-2">
<Icon icon={BillList} size={4} />
Terms of Service
</Link>
{/if}
{#if $relay.privacy_policy}
<Link href={$relay.privacy_policy} class="badge badge-neutral flex gap-2">
<Icon icon={ShieldUser} size={4} />
Privacy Policy
</Link>
{/if}
</div>
{/if}
{#if $relay}
{@const {pubkey, software, version, supported_nips, limitation} = $relay}
<div class="flex flex-wrap gap-1">
{#if pubkey}
<div class="badge badge-neutral text-wrap h-auto">
<span class="ellipsize">Administrator: <ProfileLink unstyled {pubkey} /></span>
</div>
{/if}
{#if $relay?.contact}
<div class="badge badge-neutral text-wrap h-auto">
<span class="ellipsize">Contact: {$relay.contact}</span>
</div>
{/if}
{#if software}
<div class="badge badge-neutral text-wrap h-auto">
<span class="ellipsize">Software: {software}</span>
</div>
{/if}
{#if version}
<div class="badge badge-neutral text-wrap h-auto">
<span class="ellipsize">Version: {version}</span>
</div>
{/if}
{#if Array.isArray(supported_nips)}
<p class="badge badge-neutral text-wrap h-auto">
<span class="ellipsize">Supported NIPs: {supported_nips.join(", ")}</span>
</p>
{/if}
{#if limitation?.auth_required}
<p class="badge badge-warning">
<span class="ellipsize">Auth Required</span>
</p>
{/if}
{#if limitation?.payment_required}
<p class="badge badge-warning">
<span class="ellipsize">Payment Required</span>
</p>
{/if}
{#if limitation?.min_pow_difficulty}
<p class="badge badge-warning text-wrap h-auto">
<span class="ellipsize">Min PoW: {limitation?.min_pow_difficulty}</span>
</p>
{/if}
</div>
{/if}
</div>
@@ -0,0 +1,51 @@
<script lang="ts">
import Bookmark from "@assets/icons/bookmark.svg?dataurl"
import Pen from "@assets/icons/pen.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Content from "@app/components/Content.svelte"
import EditFeaturedContent from "@app/components/EditFeaturedContent.svelte"
import SpaceRecentSummary from "@app/components/SpaceRecentSummary.svelte"
import {deriveFeaturedContent} from "@app/featured"
import {deriveSupportedMethods} from "@app/relays"
import {pushModal} from "@app/modal"
type Props = {
url: string
}
const {url}: Props = $props()
const content = deriveFeaturedContent(url)
const supportedMethods = deriveSupportedMethods(url)
const canEdit = $derived($supportedMethods.some(m => (m as string) === "signevent"))
const edit = () => pushModal(EditFeaturedContent, {url, initial: $content})
</script>
{#if $content.length > 0 || canEdit}
<div class="card2 bg-alt flex flex-col gap-3">
<div class="flex items-center justify-between gap-2">
<h3 class="flex items-center gap-2 text-lg font-bold">
<Icon icon={Bookmark} />
Featured
</h3>
{#if canEdit}
<Button class="btn btn-square btn-ghost btn-sm" onclick={edit}>
<Icon icon={Pen} />
</Button>
{/if}
</div>
{#if $content.length === 0}
<p class="text-sm opacity-70">No featured content yet.</p>
{:else}
<div class="flex flex-col gap-2">
{#each $content as value (value)}
<Content event={{content: value, tags: []}} {url} />
{/each}
</div>
{/if}
</div>
{:else}
<SpaceRecentSummary {url} />
{/if}
+40 -52
View File
@@ -3,8 +3,6 @@
import {displayProfileByPubkey} from "@welshman/app" import {displayProfileByPubkey} from "@welshman/app"
import {fly} from "@lib/transition" import {fly} from "@lib/transition"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl" import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
import Letter from "@assets/icons/letter-opened.svg?dataurl"
import Pen from "@assets/icons/pen.svg?dataurl" import Pen from "@assets/icons/pen.svg?dataurl"
import UserMinus from "@assets/icons/user-minus.svg?dataurl" import UserMinus from "@assets/icons/user-minus.svg?dataurl"
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl" import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
@@ -21,7 +19,6 @@
import {deriveSupportedMethods} from "@app/relays" import {deriveSupportedMethods} from "@app/relays"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {goToChat} from "@app/routes"
interface Props { interface Props {
url: string url: string
@@ -47,11 +44,6 @@
pushModal(ProfileDetail, {pubkey, url}) pushModal(ProfileDetail, {pubkey, url})
} }
const sendMessage = () => {
menuOpen = false
goToChat([pubkey])
}
const editRoles = () => { const editRoles = () => {
menuOpen = false menuOpen = false
pushModal(SpaceMemberRoles, {url, pubkey}) pushModal(SpaceMemberRoles, {url, pubkey})
@@ -94,7 +86,7 @@
} }
</script> </script>
<div class="card2 card2-sm border border-solid border-base-content/20 relative"> <div class="card2 card2-sm relative">
<button <button
type="button" type="button"
class="absolute inset-0 cursor-pointer rounded-box" class="absolute inset-0 cursor-pointer rounded-box"
@@ -115,48 +107,44 @@
<ProfileInfo {pubkey} {url} singleLine /> <ProfileInfo {pubkey} {url} singleLine />
</div> </div>
</div> </div>
<div class="pointer-events-auto relative shrink-0"> {#if canAssign || canUnassign || canUnallow || canBan}
<Button class="btn btn-square btn-ghost btn-sm" onclick={() => (menuOpen = !menuOpen)}> <div class="pointer-events-auto relative shrink-0">
<Icon icon={MenuDots} /> <Button class="btn btn-square btn-ghost btn-sm" onclick={() => (menuOpen = !menuOpen)}>
</Button> <Icon icon={MenuDots} />
{#if menuOpen} </Button>
<Popover hideOnClick onClose={closeMenu}> {#if menuOpen}
<ul <Popover hideOnClick onClose={closeMenu}>
transition:fly <ul
class="menu absolute right-0 z-popover mt-2 w-52 gap-1 rounded-box bg-base-100 p-2 shadow-md"> transition:fly
<li> class="menu absolute right-0 z-popover mt-2 w-52 gap-1 rounded-box bg-base-100 p-2 shadow-md">
<Button onclick={sendMessage}> {#if canAssign || canUnassign}
<Icon icon={Letter} /> <li>
Send message <Button onclick={editRoles}>
</Button> <Icon icon={Pen} />
</li> Edit roles
{#if canAssign || canUnassign} </Button>
<li> </li>
<Button onclick={editRoles}> {/if}
<Icon icon={Pen} /> {#if canUnallow}
Edit roles <li>
</Button> <Button onclick={removeMember}>
</li> <Icon icon={UserMinus} />
{/if} Remove member
{#if canUnallow} </Button>
<li> </li>
<Button onclick={removeMember}> {/if}
<Icon icon={UserMinus} /> {#if canBan}
Remove member <li>
</Button> <Button class="text-error" onclick={banMember}>
</li> <Icon icon={MinusCircle} />
{/if} Ban member
{#if canBan} </Button>
<li> </li>
<Button class="text-error" onclick={banMember}> {/if}
<Icon icon={MinusCircle} /> </ul>
Ban member </Popover>
</Button> {/if}
</li> </div>
{/if} {/if}
</ul>
</Popover>
{/if}
</div>
</div> </div>
</div> </div>
+2 -2
View File
@@ -90,13 +90,13 @@
{:else} {:else}
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{#each $roles as role (role.id)} {#each $roles as role (role.id)}
<label class="card2 card2-sm bg-alt flex cursor-pointer items-center gap-3"> <label class="card2 card2-sm flex justify-between cursor-pointer gap-3">
<RoleItem {role} />
<input <input
type="checkbox" type="checkbox"
class="checkbox checkbox-sm" class="checkbox checkbox-sm"
checked={selected.has(role.id)} checked={selected.has(role.id)}
onchange={() => toggle(role.id)} /> onchange={() => toggle(role.id)} />
<RoleItem {role} />
</label> </label>
{/each} {/each}
</div> </div>
@@ -0,0 +1,76 @@
<script lang="ts">
import {derived} from "svelte/store"
import {RELAY_ADD_MEMBER, RELAY_JOIN, getPubkeyTagValues} from "@welshman/util"
import {deriveRelay} from "@welshman/app"
import UsersGroup from "@assets/icons/users-group-rounded.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Profile from "@app/components/Profile.svelte"
import {deriveSpaceMembers} from "@app/members"
import {deriveEventsForUrl} from "@app/repository"
import {makeSpacePath} from "@app/routes"
type Props = {
url: string
}
const {url}: Props = $props()
const relay = deriveRelay(url)
const members = deriveSpaceMembers(url)
const memberEvents = deriveEventsForUrl(url, [{kinds: [RELAY_ADD_MEMBER, RELAY_JOIN]}])
const admins = $derived($relay?.pubkey ? [$relay.pubkey] : [])
const directoryPath = makeSpacePath(url, "directory")
// Members sorted by their most recent join/add event, excluding admins.
const newMembers = derived(
[members, memberEvents, relay],
([$members, $memberEvents, $relay]) => {
const adminSet = new Set($relay?.pubkey ? [$relay.pubkey] : [])
const joinedAt = new Map<string, number>()
for (const event of $memberEvents) {
const pubkeys = event.kind === RELAY_JOIN ? [event.pubkey] : getPubkeyTagValues(event.tags)
for (const pubkey of pubkeys) {
joinedAt.set(pubkey, Math.max(joinedAt.get(pubkey) || 0, event.created_at))
}
}
return $members
.filter(pubkey => !adminSet.has(pubkey))
.sort((a, b) => (joinedAt.get(b) || 0) - (joinedAt.get(a) || 0))
.slice(0, 5)
},
)
</script>
<div class="card2 bg-alt flex flex-col gap-3">
<h3 class="flex items-center gap-2 text-lg font-bold">
<Icon icon={UsersGroup} />
Members
</h3>
{#if admins.length > 0}
<div class="flex flex-col gap-2">
<p class="text-xs uppercase tracking-wide opacity-60">Admins</p>
{#each admins as pubkey (pubkey)}
<Profile {pubkey} {url} />
{/each}
</div>
{/if}
{#if $newMembers.length > 0}
<div class="flex flex-col gap-2">
<p class="text-xs uppercase tracking-wide opacity-60">New members</p>
{#each $newMembers as pubkey (pubkey)}
<Profile {pubkey} {url} />
{/each}
</div>
{/if}
<Link href={directoryPath} class="btn btn-neutral btn-sm">
View all members
<Icon icon={AltArrowRight} size={4} />
</Link>
</div>
+4 -1
View File
@@ -5,7 +5,7 @@
import {fly} from "@lib/transition" import {fly} from "@lib/transition"
import Magnifier from "@assets/icons/magnifier.svg?dataurl" import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl" import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
import RemoteControllerMinimalistic from "@assets/icons/remote-controller-minimalistic.svg?dataurl" import UsersGroup from "@assets/icons/users-group-rounded.svg?dataurl"
import Home from "@assets/icons/home.svg?dataurl" import Home from "@assets/icons/home.svg?dataurl"
import Danger from "@assets/icons/danger.svg?dataurl" import Danger from "@assets/icons/danger.svg?dataurl"
import LinkRound from "@assets/icons/link-round.svg?dataurl" import LinkRound from "@assets/icons/link-round.svg?dataurl"
@@ -219,6 +219,9 @@
<Icon icon={ChatRound} /> Chat <Icon icon={ChatRound} /> Chat
</SecondaryNavItem> </SecondaryNavItem>
{/if} {/if}
<SecondaryNavItem href={makeSpacePath(url, "directory")}>
<Icon icon={UsersGroup} /> Directory
</SecondaryNavItem>
{#if ENABLE_ZAPS && $spaceKinds.has(ZAP_GOAL)} {#if ENABLE_ZAPS && $spaceKinds.has(ZAP_GOAL)}
<SecondaryNavItem href={goalsPath}> <SecondaryNavItem href={goalsPath}>
<Icon icon={StarFallMinimalistic} /> Goals <Icon icon={StarFallMinimalistic} /> Goals
@@ -0,0 +1,38 @@
<script lang="ts">
import History from "@assets/icons/history.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import RecentItem from "@app/components/RecentItem.svelte"
import {deriveRecentActivity} from "@app/recent"
import {makeSpacePath} from "@app/routes"
type Props = {
url: string
}
const {url}: Props = $props()
const recentActivity = deriveRecentActivity(url)
const recentPath = makeSpacePath(url, "recent")
</script>
<div class="card2 bg-alt flex flex-col gap-3">
<h3 class="flex items-center gap-2 text-lg font-bold">
<Icon icon={History} />
Recent Activity
</h3>
{#if $recentActivity.length === 0}
<p class="text-sm opacity-70">No recent activity yet.</p>
{:else}
<div class="flex flex-col gap-2">
{#each $recentActivity.slice(0, 3) as item (item.event.id)}
<RecentItem {url} {item} />
{/each}
</div>
{/if}
<Link href={recentPath} class="btn btn-neutral btn-sm">
View all recent activity
<Icon icon={AltArrowRight} size={4} />
</Link>
</div>
+1 -1
View File
@@ -21,7 +21,7 @@
</script> </script>
<Link <Link
class="cv col-2 card2 bg-alt w-full cursor-pointer shadow-xl" class="cv col-2 card2 w-full cursor-pointer"
href={makeThreadPath(url, event.id)}> href={makeThreadPath(url, event.id)}>
{#if title} {#if title}
<div class="flex w-full items-center justify-between gap-2"> <div class="flex w-full items-center justify-between gap-2">
+49
View File
@@ -0,0 +1,49 @@
import {derived} from "svelte/store"
import {first, now} from "@welshman/lib"
import {APP_DATA, getTagValues} from "@welshman/util"
import type {ManagementMethod} from "@welshman/util"
import {publish} from "@welshman/net"
import {deriveRelay, manageRelay} from "@welshman/app"
import {deriveEventsForUrl} from "@app/repository"
// NIP-78 app data published by the relay's self key. Each featured entry is a
// ["content", <value>] tag (freeform text, intended to be a url or nevent).
export const FEATURED_CONTENT_D = "flotilla/featured-content"
export const deriveFeaturedContent = (url: string) =>
derived(
[deriveRelay(url), deriveEventsForUrl(url, [{kinds: [APP_DATA], "#d": [FEATURED_CONTENT_D]}])],
([$relay, $events]) => {
const self = $relay?.self || $relay?.pubkey
const event = (self && $events.find(e => e.pubkey === self)) || first($events)
return getTagValues("content", event?.tags ?? [])
},
)
// Publish the featured content list by asking the relay to sign it with its self
// key (the unofficial NIP-86 "signevent" method).
export const setFeaturedContent = async (
url: string,
content: string[],
): Promise<string | undefined> => {
const template = {
kind: APP_DATA,
created_at: now(),
content: "",
tags: [
["d", FEATURED_CONTENT_D],
...content
.map(value => value.trim())
.filter(Boolean)
.map(value => ["content", value]),
],
}
const {error} = await manageRelay(url, {
method: "signevent" as ManagementMethod,
params: [template],
})
return error
}
+67
View File
@@ -0,0 +1,67 @@
import {derived} from "svelte/store"
import {groupBy, first, sortBy, uniqBy, ago, MONTH} from "@welshman/lib"
import {MESSAGE, COMMENT, getTagValue, getTagValues, getIdAndAddress} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {repository} from "@welshman/app"
import {deriveEventsForUrl} from "@app/repository"
import {CONTENT_KINDS} from "@app/content"
export type RecentActivityItem = {
type: "message" | "content"
event: TrustedEvent
count: number
timestamp: number
}
// Recent activity for a space: latest message per room plus content with the
// most recent activity (post or comment), sorted newest first.
export const deriveRecentActivity = (url: string) => {
const since = ago(3, MONTH)
const messages = deriveEventsForUrl(url, [{kinds: [MESSAGE], since}])
const content = deriveEventsForUrl(url, [{kinds: CONTENT_KINDS, since}])
const comments = deriveEventsForUrl(url, [{kinds: [COMMENT], since}])
return derived([messages, content, comments], ([$messages, $content, $comments]) => {
const activity: RecentActivityItem[] = []
const byRoom = groupBy(e => getTagValue("h", e.tags), $messages)
for (const roomMessages of byRoom.values()) {
const latest = first(roomMessages)
if (latest) {
activity.push({
type: "message",
event: latest,
count: roomMessages.length,
timestamp: latest.created_at,
})
}
}
const latestActivityByKey = new Map<string, number>()
for (const event of $content) {
for (const k of getIdAndAddress(event)) {
latestActivityByKey.set(k, Math.max(latestActivityByKey.get(k) || 0, event.created_at))
}
}
for (const event of $comments) {
for (const k of getTagValues(["E", "A"], event.tags)) {
latestActivityByKey.set(k, Math.max(latestActivityByKey.get(k) || 0, event.created_at))
}
}
for (const [address, timestamp] of latestActivityByKey.entries()) {
const event = repository.getEvent(address)
if (event) {
activity.push({type: "content", event, timestamp, count: 1})
}
}
return sortBy(
a => -a.timestamp,
uniqBy(a => a.event.id, activity),
)
})
}
+1 -1
View File
@@ -87,7 +87,7 @@ export const goToSpace = async (url: string) => {
} else if (!hasNip29(getRelay(url))) { } else if (!hasNip29(getRelay(url))) {
goto(makeSpaceChatPath(url), {replaceState: true}) goto(makeSpaceChatPath(url), {replaceState: true})
} else if (window.matchMedia(`(min-width: ${theme.screens.md})`).matches) { } else if (window.matchMedia(`(min-width: ${theme.screens.md})`).matches) {
goto(makeSpacePath(url, "recent"), {replaceState: true}) goto(makeSpacePath(url, "about"), {replaceState: true})
} else { } else {
goto(makeSpacePath(url), {replaceState: true}) goto(makeSpacePath(url), {replaceState: true})
} }
+3
View File
@@ -20,6 +20,7 @@ import {
RELAY_REMOVE_MEMBER, RELAY_REMOVE_MEMBER,
MESSAGE, MESSAGE,
POLL_RESPONSE, POLL_RESPONSE,
APP_DATA,
isSignedEvent, isSignedEvent,
unionFilters, unionFilters,
} from "@welshman/util" } from "@welshman/util"
@@ -54,6 +55,7 @@ import {
import {decodeRelay} from "@app/relays" import {decodeRelay} from "@app/relays"
import {loadFeedsForPubkey} from "@app/feeds" import {loadFeedsForPubkey} from "@app/feeds"
import {RELAY_ROLE} from "@app/members" import {RELAY_ROLE} from "@app/members"
import {FEATURED_CONTENT_D} from "@app/featured"
import {hasBlossomSupport} from "@app/uploads" import {hasBlossomSupport} from "@app/uploads"
import {LIVEKIT_PARTICIPANTS} from "@app/call/voice" import {LIVEKIT_PARTICIPANTS} from "@app/call/voice"
@@ -278,6 +280,7 @@ const syncSpace = (url: string) => {
signal: controller.signal, signal: controller.signal,
filters: [ filters: [
{kinds: [...relayKinds, ...roomMetaKinds, ...roomDeleteKinds, ...CONTENT_KINDS, MESSAGE]}, {kinds: [...relayKinds, ...roomMetaKinds, ...roomDeleteKinds, ...CONTENT_KINDS, MESSAGE]},
{kinds: [APP_DATA], "#d": [FEATURED_CONTENT_D]},
{kinds: [...REACTION_KINDS, POLL_RESPONSE], since}, {kinds: [...REACTION_KINDS, POLL_RESPONSE], since},
makeCommentFilter(CONTENT_KINDS, {since}), makeCommentFilter(CONTENT_KINDS, {since}),
], ],
+1 -1
View File
@@ -44,7 +44,7 @@
) )
</script> </script>
<div class="center fixed inset-0 z-modal"> <div class="dialog center fixed inset-0 z-modal">
<button <button
type="button" type="button"
aria-label="Close dialog" aria-label="Close dialog"
+94 -135
View File
@@ -1,163 +1,122 @@
<script lang="ts"> <script lang="ts">
import {tick} from "svelte"
import {derived} from "svelte/store"
import {page} from "$app/stores" import {page} from "$app/stores"
import {displayRelayUrl} from "@welshman/util" import {displayRelayUrl} from "@welshman/util"
import {displayProfileByPubkey, deriveRelay} from "@welshman/app" import {deriveRelay} from "@welshman/app"
import Pen from "@assets/icons/pen.svg?dataurl" import Pen from "@assets/icons/pen.svg?dataurl"
import UsersGroup from "@assets/icons/users-group-rounded.svg?dataurl" import ShieldUser from "@assets/icons/shield-user.svg?dataurl"
import BillList from "@assets/icons/bill-list.svg?dataurl" import BillList from "@assets/icons/bill-list.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Popover from "@lib/components/Popover.svelte"
import PageContent from "@lib/components/PageContent.svelte" import PageContent from "@lib/components/PageContent.svelte"
import ContentSearch from "@lib/components/ContentSearch.svelte" import RelayIcon from "@app/components/RelayIcon.svelte"
import RelayName from "@app/components/RelayName.svelte" import RelayName from "@app/components/RelayName.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte" import RelayDescription from "@app/components/RelayDescription.svelte"
import SpaceDetails from "@app/components/SpaceDetails.svelte" import ProfileLink from "@app/components/ProfileLink.svelte"
import SpaceMember from "@app/components/SpaceMember.svelte" import SpaceEdit from "@app/components/SpaceEdit.svelte"
import SpaceInvite from "@app/components/SpaceInvite.svelte" import SpaceMembersSummary from "@app/components/SpaceMembersSummary.svelte"
import SpaceRoles from "@app/components/SpaceRoles.svelte" import SpaceFeaturedContent from "@app/components/SpaceFeaturedContent.svelte"
import SpaceMembersBanned from "@app/components/SpaceMembersBanned.svelte" import {deriveUserIsSpaceAdmin} from "@app/members"
import {
deriveSpaceRoles,
deriveSpaceMembers,
deriveSpaceMemberRoles,
deriveUserIsSpaceAdmin,
type SpaceRole,
} from "@app/members"
import {decodeRelay} from "@app/relays" import {decodeRelay} from "@app/relays"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
const url = decodeRelay($page.params.relay!) const url = decodeRelay($page.params.relay!)
const relay = deriveRelay(url) const relay = deriveRelay(url)
const roles = deriveSpaceRoles(url)
const owner = $derived($relay?.pubkey)
const members = deriveSpaceMembers(url)
const memberRoles = deriveSpaceMemberRoles(url)
const userIsAdmin = deriveUserIsSpaceAdmin(url) const userIsAdmin = deriveUserIsSpaceAdmin(url)
// Each member with their resolved roles (sorted by order). const startEdit = () => pushModal(SpaceEdit, {url, initialValues: $relay || {url}})
const memberList = derived([members, memberRoles, roles], ([$members, $memberRoles, $roles]) => {
const byId = new Map($roles.map(role => [role.id, role]))
return $members.map(pubkey => ({
pubkey,
roleList: ($memberRoles.get(pubkey) ?? [])
.map(id => byId.get(id))
.filter((role): role is SpaceRole => Boolean(role)),
}))
})
let menuOpen = $state(false)
const inviteMembers = () => {
menuOpen = false
pushModal(SpaceInvite, {url})
}
const manageRoles = () => {
menuOpen = false
pushModal(SpaceRoles, {url})
}
const bannedMembers = () => {
menuOpen = false
pushModal(SpaceMembersBanned, {url})
}
// In-place search: filter member cards by member info, and keep role sections
// whose name matches the term even when their members don't.
let term = $state("")
const matchesTerm = (pubkey: string, t: string) =>
displayProfileByPubkey(pubkey).toLowerCase().includes(t) || pubkey.toLowerCase().includes(t)
// In-place search: match by member info or by the name of any role they hold.
const visibleMembers = $derived.by(() => {
const t = term.trim().toLowerCase()
if (!t) return $memberList
return $memberList.filter(
({pubkey, roleList}) =>
matchesTerm(pubkey, t) || roleList.some(role => role.label.toLowerCase().includes(t)),
)
})
const clearSearch = () => {
term = ""
}
</script> </script>
<PageContent class="flex flex-col gap-4 p-4"> <PageContent class="flex flex-col gap-4 p-4">
<SpaceDetails {url} />
<div class="card2 bg-alt flex flex-col gap-4"> <div class="card2 bg-alt flex flex-col gap-4">
<div class="flex justify-between"> <div class="flex justify-between">
<h3 class="flex items-center gap-2 text-lg font-semibold"> <div class="relative flex gap-4">
<Icon icon={UsersGroup} /> <div class="relative">
Members <RelayIcon {url} size={14} class="rounded-full" />
</h3> </div>
<div class="flex gap-2"> <div class="flex min-w-0 flex-col">
<button class="btn btn-primary btn-sm" onclick={inviteMembers}> <h1 class="ellipsize whitespace-nowrap">
<Icon icon={AddCircle} /> <RelayName {url} class="text-2xl font-bold" />
Invite people </h1>
</button> <p class="ellipsize text-sm text-primary">{displayRelayUrl(url)}</p>
{#if $userIsAdmin} </div>
<div class="relative"> </div>
<button {#if $userIsAdmin}
class="btn btn-neutral btn-sm btn-square" <Button class="btn btn-primary" onclick={startEdit}>
aria-label="More options" <Icon icon={Pen} />
onclick={() => (menuOpen = !menuOpen)}> Edit
<Icon size={4} icon={MenuDots} /> </Button>
</button> {/if}
{#if menuOpen} </div>
<Popover hideOnClick onClose={() => (menuOpen = false)}> <RelayDescription {url} />
<ul {#if $relay?.terms_of_service || $relay?.privacy_policy}
transition:fly <div class="flex gap-3">
class="menu absolute right-0 z-popover mt-2 w-48 gap-1 rounded-box bg-base-100 p-2 shadow-md"> {#if $relay.terms_of_service}
<li> <Link href={$relay.terms_of_service} class="badge badge-neutral flex gap-2">
<Button onclick={manageRoles}> <Icon icon={BillList} size={4} />
<Icon icon={UsersGroup} /> Terms of Service
Manage Roles </Link>
</Button> {/if}
</li> {#if $relay.privacy_policy}
<li> <Link href={$relay.privacy_policy} class="badge badge-neutral flex gap-2">
<Button onclick={bannedMembers}> <Icon icon={ShieldUser} size={4} />
<Icon icon={MinusCircle} /> Privacy Policy
Banned Members </Link>
</Button>
</li>
</ul>
</Popover>
{/if}
</div>
{/if} {/if}
</div> </div>
</div> {/if}
<label class="input input-sm input-bordered flex w-full items-center gap-2"> {#if $relay}
<Icon size={4} icon={Magnifier} /> {@const {pubkey, software, version, supported_nips, limitation} = $relay}
<input <div class="flex flex-wrap gap-1">
bind:value={term} {#if pubkey}
class="min-w-0 grow" <div class="badge badge-neutral text-wrap h-auto">
type="text" <span class="ellipsize">Administrator: <ProfileLink unstyled {pubkey} /></span>
placeholder="Search people or roles..." /> </div>
</label> {/if}
{#if visibleMembers.length === 0} {#if $relay?.contact}
<p class="flex flex-col items-center py-20 text-center">No members found.</p> <div class="badge badge-neutral text-wrap h-auto">
{:else} <span class="ellipsize">Contact: {$relay.contact}</span>
<div class="grid grid-cols-1 gap-2 lg:grid-cols-2 xl:grid-cols-3"> </div>
{#each visibleMembers as { pubkey, roleList } (pubkey)} {/if}
<SpaceMember {url} {pubkey} roles={roleList} /> {#if software}
{/each} <div class="badge badge-neutral text-wrap h-auto">
<span class="ellipsize">Software: {software}</span>
</div>
{/if}
{#if version}
<div class="badge badge-neutral text-wrap h-auto">
<span class="ellipsize">Version: {version}</span>
</div>
{/if}
{#if Array.isArray(supported_nips)}
<p class="badge badge-neutral text-wrap h-auto">
<span class="ellipsize">Supported NIPs: {supported_nips.join(", ")}</span>
</p>
{/if}
{#if limitation?.auth_required}
<p class="badge badge-warning">
<span class="ellipsize">Auth Required</span>
</p>
{/if}
{#if limitation?.payment_required}
<p class="badge badge-warning">
<span class="ellipsize">Payment Required</span>
</p>
{/if}
{#if limitation?.min_pow_difficulty}
<p class="badge badge-warning text-wrap h-auto">
<span class="ellipsize">Min PoW: {limitation?.min_pow_difficulty}</span>
</p>
{/if}
</div> </div>
{/if} {/if}
</div> </div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div class="lg:col-span-2 flex flex-col gap-4">
<SpaceFeaturedContent {url} />
</div>
<div class="flex flex-col gap-4">
<SpaceMembersSummary {url} />
</div>
</div>
</PageContent> </PageContent>
@@ -0,0 +1,150 @@
<script lang="ts">
import {derived} from "svelte/store"
import {page} from "$app/stores"
import {displayProfileByPubkey} from "@welshman/app"
import UsersGroup from "@assets/icons/users-group-rounded.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Popover from "@lib/components/Popover.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
import SpaceMember from "@app/components/SpaceMember.svelte"
import SpaceInvite from "@app/components/SpaceInvite.svelte"
import SpaceRoles from "@app/components/SpaceRoles.svelte"
import SpaceMembersBanned from "@app/components/SpaceMembersBanned.svelte"
import {
deriveSpaceRoles,
deriveSpaceMembers,
deriveSpaceMemberRoles,
deriveUserIsSpaceAdmin,
type SpaceRole,
} from "@app/members"
import {decodeRelay} from "@app/relays"
import {pushModal} from "@app/modal"
const url = decodeRelay($page.params.relay!)
const roles = deriveSpaceRoles(url)
const members = deriveSpaceMembers(url)
const memberRoles = deriveSpaceMemberRoles(url)
const userIsAdmin = deriveUserIsSpaceAdmin(url)
// Each member with their resolved roles (sorted by order).
const memberList = derived([members, memberRoles, roles], ([$members, $memberRoles, $roles]) => {
const byId = new Map($roles.map(role => [role.id, role]))
return $members.map(pubkey => ({
pubkey,
roleList: ($memberRoles.get(pubkey) ?? [])
.map(id => byId.get(id))
.filter((role): role is SpaceRole => Boolean(role)),
}))
})
let menuOpen = $state(false)
const inviteMembers = () => {
menuOpen = false
pushModal(SpaceInvite, {url})
}
const manageRoles = () => {
menuOpen = false
pushModal(SpaceRoles, {url})
}
const bannedMembers = () => {
menuOpen = false
pushModal(SpaceMembersBanned, {url})
}
// In-place search: filter member cards by member info, and keep role sections
// whose name matches the term even when their members don't.
let term = $state("")
const matchesTerm = (pubkey: string, t: string) =>
displayProfileByPubkey(pubkey).toLowerCase().includes(t) || pubkey.toLowerCase().includes(t)
// In-place search: match by member info or by the name of any role they hold.
const visibleMembers = $derived.by(() => {
const t = term.trim().toLowerCase()
if (!t) return $memberList
return $memberList.filter(
({pubkey, roleList}) =>
matchesTerm(pubkey, t) || roleList.some(role => role.label.toLowerCase().includes(t)),
)
})
</script>
<SpaceBar>
{#snippet leading()}
<Icon icon={UsersGroup} />
{/snippet}
{#snippet title()}
<strong>Members</strong>
{/snippet}
{#snippet action()}
<button class="btn btn-primary btn-sm" onclick={inviteMembers}>
<Icon icon={AddCircle} />
Invite people
</button>
{#if $userIsAdmin}
<div class="relative">
<button
class="btn btn-neutral btn-sm btn-square"
aria-label="More options"
onclick={() => (menuOpen = !menuOpen)}>
<Icon size={4} icon={MenuDots} />
</button>
{#if menuOpen}
<Popover hideOnClick onClose={() => (menuOpen = false)}>
<ul
transition:fly
class="menu absolute right-0 z-popover mt-2 w-48 gap-1 rounded-box bg-base-100 p-2 shadow-md">
<li>
<Button onclick={manageRoles}>
<Icon icon={UsersGroup} />
Manage Roles
</Button>
</li>
<li>
<Button onclick={bannedMembers}>
<Icon icon={MinusCircle} />
Banned Members
</Button>
</li>
</ul>
</Popover>
{/if}
</div>
{/if}
{/snippet}
</SpaceBar>
<PageContent class="flex flex-col gap-4 p-4">
<div class="card2 bg-alt flex flex-col gap-2">
<label class="input input-sm input-bordered flex w-full items-center gap-2">
<Icon size={4} icon={Magnifier} />
<input
bind:value={term}
class="min-w-0 grow"
type="text"
placeholder="Search people or roles..." />
</label>
{#if visibleMembers.length === 0}
<p class="flex flex-col items-center py-20 text-center">No members found.</p>
{:else}
<div class="grid grid-cols-1 gap-2 lg:grid-cols-2 xl:grid-cols-3">
{#each visibleMembers as { pubkey, roleList } (pubkey)}
<SpaceMember {url} {pubkey} roles={roleList} />
{/each}
</div>
{/if}
</div>
</PageContent>
+7 -109
View File
@@ -1,38 +1,11 @@
<script lang="ts"> <script lang="ts">
import {tick, onMount} from "svelte" import {tick, onMount} from "svelte"
import {derived} from "svelte/store"
import {page} from "$app/stores" import {page} from "$app/stores"
import {debounce} from "throttle-debounce" import {debounce} from "throttle-debounce"
import { import {formatTimestampAsDate, groupBy, now, MINUTE, HOUR, DAY, WEEK, uniqBy} from "@welshman/lib"
formatTimestampAsDate,
groupBy,
ago,
now,
MONTH,
MINUTE,
HOUR,
DAY,
WEEK,
first,
sortBy,
uniqBy,
} from "@welshman/lib"
import {request} from "@welshman/net" import {request} from "@welshman/net"
import { import {MESSAGE, getTagValue, sortEventsDesc} from "@welshman/util"
MESSAGE,
THREAD,
CLASSIFIED,
ZAP_GOAL,
EVENT_TIME,
COMMENT,
POLL,
getTagValue,
getTagValues,
getIdAndAddress,
sortEventsDesc,
} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {repository} from "@welshman/app"
import History from "@assets/icons/history.svg?dataurl" import History from "@assets/icons/history.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl" import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl" import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
@@ -42,76 +15,15 @@
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import PageContent from "@lib/components/PageContent.svelte" import PageContent from "@lib/components/PageContent.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte" import SpaceBar from "@app/components/SpaceBar.svelte"
import NoteItem from "@app/components/NoteItem.svelte" import RecentItem from "@app/components/RecentItem.svelte"
import ThreadItem from "@app/components/ThreadItem.svelte"
import ClassifiedItem from "@app/components/ClassifiedItem.svelte"
import GoalItem from "@app/components/GoalItem.svelte"
import CalendarEventItem from "@app/components/CalendarEventItem.svelte"
import PollItem from "@app/components/PollItem.svelte"
import RecentConversation from "@app/components/RecentConversation.svelte"
import {decodeRelay} from "@app/relays" import {decodeRelay} from "@app/relays"
import {deriveEventsForUrl} from "@app/repository"
import {CONTENT_KINDS} from "@app/content" import {CONTENT_KINDS} from "@app/content"
import {deriveRecentActivity} from "@app/recent"
import {goToEvent} from "@app/routes" import {goToEvent} from "@app/routes"
const url = decodeRelay($page.params.relay!) const url = decodeRelay($page.params.relay!)
const since = ago(3, MONTH)
const messages = deriveEventsForUrl(url, [{kinds: [MESSAGE], since}]) const recentActivity = deriveRecentActivity(url)
const content = deriveEventsForUrl(url, [{kinds: CONTENT_KINDS, since}])
const comments = deriveEventsForUrl(url, [{kinds: [COMMENT], since}])
const recentActivity = derived(
[messages, content, comments],
([$messages, $content, $comments]) => {
const activity: Array<{
type: "message" | "content"
event: TrustedEvent
count: number
timestamp: number
}> = []
const byRoom = groupBy(e => getTagValue("h", e.tags), $messages)
for (const roomMessages of byRoom.values()) {
const latest = first(roomMessages)
if (latest) {
activity.push({
type: "message",
event: latest,
count: roomMessages.length,
timestamp: latest.created_at,
})
}
}
const latestActivityByKey = new Map<string, number>()
for (const event of $content) {
for (const k of getIdAndAddress(event)) {
latestActivityByKey.set(k, Math.max(latestActivityByKey.get(k) || 0, event.created_at))
}
}
for (const event of $comments) {
for (const k of getTagValues(["E", "A"], event.tags)) {
latestActivityByKey.set(k, Math.max(latestActivityByKey.get(k) || 0, event.created_at))
}
}
for (const [address, timestamp] of latestActivityByKey.entries()) {
const event = repository.getEvent(address)
if (event) {
activity.push({type: "content", event, timestamp, count: 1})
}
}
return sortBy(
a => -a.timestamp,
uniqBy(a => a.event.id, activity),
)
},
)
let term = $state("") let term = $state("")
let showSearch = $state(false) let showSearch = $state(false)
@@ -298,22 +210,8 @@
{#if $recentActivity.length === 0} {#if $recentActivity.length === 0}
<p class="flex flex-col items-center py-20 text-center">No recent activity found!</p> <p class="flex flex-col items-center py-20 text-center">No recent activity found!</p>
{:else} {:else}
{#each $recentActivity.slice(0, limit) as { type, event, count = 0 } (event.id)} {#each $recentActivity.slice(0, limit) as item (item.event.id)}
{#if type === "message"} <RecentItem {url} {item} />
<RecentConversation {url} {event} {count} />
{:else if event.kind === THREAD}
<ThreadItem {url} {event} />
{:else if event.kind === CLASSIFIED}
<ClassifiedItem {url} {event} />
{:else if event.kind === ZAP_GOAL}
<GoalItem {url} {event} />
{:else if event.kind === EVENT_TIME}
<CalendarEventItem {url} {event} />
{:else if event.kind === POLL}
<PollItem {url} {event} />
{:else}
<NoteItem {url} {event} />
{/if}
{/each} {/each}
{/if} {/if}
</PageContent> </PageContent>
+2 -2
View File
@@ -23,7 +23,7 @@
</PageHeader> </PageHeader>
<div class="flex w-full max-w-lg flex-col gap-4 lg:max-w-4xl"> <div class="flex w-full max-w-lg flex-col gap-4 lg:max-w-4xl">
<div class="grid grid-cols-1 gap-2 lg:grid-cols-2"> <div class="grid grid-cols-1 gap-2 lg:grid-cols-2">
<div class="card2 bg-alt flex flex-col gap-5"> <div class="card2 flex flex-col gap-5">
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<div class="bg-primary/20 flex h-10 w-10 items-center justify-center rounded-md"> <div class="bg-primary/20 flex h-10 w-10 items-center justify-center rounded-md">
<Icon icon={CloudCheck} class="text-primary" /> <Icon icon={CloudCheck} class="text-primary" />
@@ -59,7 +59,7 @@
<Icon icon={ArrowRight} /> <Icon icon={ArrowRight} />
</Link> </Link>
</div> </div>
<div class="card2 bg-alt border-primary flex flex-col gap-5 border"> <div class="card2 border-primary flex flex-col gap-5 border">
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<img alt="Coracle Logo" src="/coracle.png" class="h-10 w-10" /> <img alt="Coracle Logo" src="/coracle.png" class="h-10 w-10" />