Split up space information and directory
This commit is contained in:
+6
-1
@@ -85,7 +85,7 @@
|
||||
}
|
||||
|
||||
@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 {
|
||||
@@ -276,6 +276,11 @@
|
||||
@apply text-base-content p-2 sm:p-4;
|
||||
}
|
||||
|
||||
.card2 .card2,
|
||||
.dialog .card2 {
|
||||
@apply shadow-none;
|
||||
}
|
||||
|
||||
[data-tip]::before {
|
||||
@apply overflow-hidden text-ellipsis;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
</script>
|
||||
|
||||
<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))}>
|
||||
<CalendarEventHeader {event} />
|
||||
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
</script>
|
||||
|
||||
<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))}>
|
||||
{#if title}
|
||||
<div class="flex w-full items-center justify-between gap-2">
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
<span class="loading loading-spinner"></span>
|
||||
</div>
|
||||
{: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}
|
||||
<img
|
||||
alt=""
|
||||
@@ -92,7 +92,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{: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}
|
||||
</p>
|
||||
{/await}
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
<NoteContentMinimal trimParent {url} event={$quote} />
|
||||
</div>
|
||||
{: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} />
|
||||
</NoteCard>
|
||||
{/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>
|
||||
@@ -21,7 +21,7 @@
|
||||
</script>
|
||||
|
||||
<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)}>
|
||||
<p class="text-2xl">{event.content}</p>
|
||||
<Content
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
<NoteCard {event} {url} class="cv card2 bg-alt">
|
||||
<NoteCard {event} {url} class="cv card2">
|
||||
<NoteContent {event} expandMode="inline" />
|
||||
<div class="flex w-full justify-between gap-2">
|
||||
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tooltip-right">
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
const onClick = () => goto(h ? makeRoomPath(url, h) : makeSpaceChatPath(url))
|
||||
</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 items-center gap-2 text-sm">
|
||||
{#if h}
|
||||
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -3,8 +3,6 @@
|
||||
import {displayProfileByPubkey} from "@welshman/app"
|
||||
import {fly} from "@lib/transition"
|
||||
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 UserMinus from "@assets/icons/user-minus.svg?dataurl"
|
||||
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
|
||||
@@ -21,7 +19,6 @@
|
||||
import {deriveSupportedMethods} from "@app/relays"
|
||||
import {pushModal} from "@app/modal"
|
||||
import {pushToast} from "@app/toast"
|
||||
import {goToChat} from "@app/routes"
|
||||
|
||||
interface Props {
|
||||
url: string
|
||||
@@ -47,11 +44,6 @@
|
||||
pushModal(ProfileDetail, {pubkey, url})
|
||||
}
|
||||
|
||||
const sendMessage = () => {
|
||||
menuOpen = false
|
||||
goToChat([pubkey])
|
||||
}
|
||||
|
||||
const editRoles = () => {
|
||||
menuOpen = false
|
||||
pushModal(SpaceMemberRoles, {url, pubkey})
|
||||
@@ -94,7 +86,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="card2 card2-sm border border-solid border-base-content/20 relative">
|
||||
<div class="card2 card2-sm relative">
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-0 cursor-pointer rounded-box"
|
||||
@@ -115,48 +107,44 @@
|
||||
<ProfileInfo {pubkey} {url} singleLine />
|
||||
</div>
|
||||
</div>
|
||||
<div class="pointer-events-auto relative shrink-0">
|
||||
<Button class="btn btn-square btn-ghost btn-sm" onclick={() => (menuOpen = !menuOpen)}>
|
||||
<Icon icon={MenuDots} />
|
||||
</Button>
|
||||
{#if menuOpen}
|
||||
<Popover hideOnClick onClose={closeMenu}>
|
||||
<ul
|
||||
transition:fly
|
||||
class="menu absolute right-0 z-popover mt-2 w-52 gap-1 rounded-box bg-base-100 p-2 shadow-md">
|
||||
<li>
|
||||
<Button onclick={sendMessage}>
|
||||
<Icon icon={Letter} />
|
||||
Send message
|
||||
</Button>
|
||||
</li>
|
||||
{#if canAssign || canUnassign}
|
||||
<li>
|
||||
<Button onclick={editRoles}>
|
||||
<Icon icon={Pen} />
|
||||
Edit roles
|
||||
</Button>
|
||||
</li>
|
||||
{/if}
|
||||
{#if canUnallow}
|
||||
<li>
|
||||
<Button onclick={removeMember}>
|
||||
<Icon icon={UserMinus} />
|
||||
Remove member
|
||||
</Button>
|
||||
</li>
|
||||
{/if}
|
||||
{#if canBan}
|
||||
<li>
|
||||
<Button class="text-error" onclick={banMember}>
|
||||
<Icon icon={MinusCircle} />
|
||||
Ban member
|
||||
</Button>
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
{#if canAssign || canUnassign || canUnallow || canBan}
|
||||
<div class="pointer-events-auto relative shrink-0">
|
||||
<Button class="btn btn-square btn-ghost btn-sm" onclick={() => (menuOpen = !menuOpen)}>
|
||||
<Icon icon={MenuDots} />
|
||||
</Button>
|
||||
{#if menuOpen}
|
||||
<Popover hideOnClick onClose={closeMenu}>
|
||||
<ul
|
||||
transition:fly
|
||||
class="menu absolute right-0 z-popover mt-2 w-52 gap-1 rounded-box bg-base-100 p-2 shadow-md">
|
||||
{#if canAssign || canUnassign}
|
||||
<li>
|
||||
<Button onclick={editRoles}>
|
||||
<Icon icon={Pen} />
|
||||
Edit roles
|
||||
</Button>
|
||||
</li>
|
||||
{/if}
|
||||
{#if canUnallow}
|
||||
<li>
|
||||
<Button onclick={removeMember}>
|
||||
<Icon icon={UserMinus} />
|
||||
Remove member
|
||||
</Button>
|
||||
</li>
|
||||
{/if}
|
||||
{#if canBan}
|
||||
<li>
|
||||
<Button class="text-error" onclick={banMember}>
|
||||
<Icon icon={MinusCircle} />
|
||||
Ban member
|
||||
</Button>
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -90,13 +90,13 @@
|
||||
{:else}
|
||||
<div class="flex flex-col gap-2">
|
||||
{#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
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
checked={selected.has(role.id)}
|
||||
onchange={() => toggle(role.id)} />
|
||||
<RoleItem {role} />
|
||||
</label>
|
||||
{/each}
|
||||
</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>
|
||||
@@ -5,7 +5,7 @@
|
||||
import {fly} from "@lib/transition"
|
||||
import Magnifier from "@assets/icons/magnifier.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 Danger from "@assets/icons/danger.svg?dataurl"
|
||||
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
||||
@@ -219,6 +219,9 @@
|
||||
<Icon icon={ChatRound} /> Chat
|
||||
</SecondaryNavItem>
|
||||
{/if}
|
||||
<SecondaryNavItem href={makeSpacePath(url, "directory")}>
|
||||
<Icon icon={UsersGroup} /> Directory
|
||||
</SecondaryNavItem>
|
||||
{#if ENABLE_ZAPS && $spaceKinds.has(ZAP_GOAL)}
|
||||
<SecondaryNavItem href={goalsPath}>
|
||||
<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>
|
||||
@@ -21,7 +21,7 @@
|
||||
</script>
|
||||
|
||||
<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)}>
|
||||
{#if title}
|
||||
<div class="flex w-full items-center justify-between gap-2">
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -87,7 +87,7 @@ export const goToSpace = async (url: string) => {
|
||||
} else if (!hasNip29(getRelay(url))) {
|
||||
goto(makeSpaceChatPath(url), {replaceState: true})
|
||||
} else if (window.matchMedia(`(min-width: ${theme.screens.md})`).matches) {
|
||||
goto(makeSpacePath(url, "recent"), {replaceState: true})
|
||||
goto(makeSpacePath(url, "about"), {replaceState: true})
|
||||
} else {
|
||||
goto(makeSpacePath(url), {replaceState: true})
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
RELAY_REMOVE_MEMBER,
|
||||
MESSAGE,
|
||||
POLL_RESPONSE,
|
||||
APP_DATA,
|
||||
isSignedEvent,
|
||||
unionFilters,
|
||||
} from "@welshman/util"
|
||||
@@ -54,6 +55,7 @@ import {
|
||||
import {decodeRelay} from "@app/relays"
|
||||
import {loadFeedsForPubkey} from "@app/feeds"
|
||||
import {RELAY_ROLE} from "@app/members"
|
||||
import {FEATURED_CONTENT_D} from "@app/featured"
|
||||
import {hasBlossomSupport} from "@app/uploads"
|
||||
import {LIVEKIT_PARTICIPANTS} from "@app/call/voice"
|
||||
|
||||
@@ -278,6 +280,7 @@ const syncSpace = (url: string) => {
|
||||
signal: controller.signal,
|
||||
filters: [
|
||||
{kinds: [...relayKinds, ...roomMetaKinds, ...roomDeleteKinds, ...CONTENT_KINDS, MESSAGE]},
|
||||
{kinds: [APP_DATA], "#d": [FEATURED_CONTENT_D]},
|
||||
{kinds: [...REACTION_KINDS, POLL_RESPONSE], since},
|
||||
makeCommentFilter(CONTENT_KINDS, {since}),
|
||||
],
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
)
|
||||
</script>
|
||||
|
||||
<div class="center fixed inset-0 z-modal">
|
||||
<div class="dialog center fixed inset-0 z-modal">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close dialog"
|
||||
|
||||
@@ -1,163 +1,122 @@
|
||||
<script lang="ts">
|
||||
import {tick} from "svelte"
|
||||
import {derived} from "svelte/store"
|
||||
import {page} from "$app/stores"
|
||||
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 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 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 Link from "@lib/components/Link.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Popover from "@lib/components/Popover.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 SpaceBar from "@app/components/SpaceBar.svelte"
|
||||
import SpaceDetails from "@app/components/SpaceDetails.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 RelayDescription from "@app/components/RelayDescription.svelte"
|
||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||
import SpaceEdit from "@app/components/SpaceEdit.svelte"
|
||||
import SpaceMembersSummary from "@app/components/SpaceMembersSummary.svelte"
|
||||
import SpaceFeaturedContent from "@app/components/SpaceFeaturedContent.svelte"
|
||||
import {deriveUserIsSpaceAdmin} from "@app/members"
|
||||
import {decodeRelay} from "@app/relays"
|
||||
import {pushModal} from "@app/modal"
|
||||
|
||||
const url = decodeRelay($page.params.relay!)
|
||||
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)
|
||||
|
||||
// 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)),
|
||||
)
|
||||
})
|
||||
|
||||
const clearSearch = () => {
|
||||
term = ""
|
||||
}
|
||||
const startEdit = () => pushModal(SpaceEdit, {url, initialValues: $relay || {url}})
|
||||
</script>
|
||||
|
||||
<PageContent class="flex flex-col gap-4 p-4">
|
||||
<SpaceDetails {url} />
|
||||
<div class="card2 bg-alt flex flex-col gap-4">
|
||||
<div class="flex justify-between">
|
||||
<h3 class="flex items-center gap-2 text-lg font-semibold">
|
||||
<Icon icon={UsersGroup} />
|
||||
Members
|
||||
</h3>
|
||||
<div class="flex gap-2">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<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}
|
||||
{/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>
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
@@ -1,38 +1,11 @@
|
||||
<script lang="ts">
|
||||
import {tick, onMount} from "svelte"
|
||||
import {derived} from "svelte/store"
|
||||
import {page} from "$app/stores"
|
||||
import {debounce} from "throttle-debounce"
|
||||
import {
|
||||
formatTimestampAsDate,
|
||||
groupBy,
|
||||
ago,
|
||||
now,
|
||||
MONTH,
|
||||
MINUTE,
|
||||
HOUR,
|
||||
DAY,
|
||||
WEEK,
|
||||
first,
|
||||
sortBy,
|
||||
uniqBy,
|
||||
} from "@welshman/lib"
|
||||
import {formatTimestampAsDate, groupBy, now, MINUTE, HOUR, DAY, WEEK, uniqBy} from "@welshman/lib"
|
||||
import {request} from "@welshman/net"
|
||||
import {
|
||||
MESSAGE,
|
||||
THREAD,
|
||||
CLASSIFIED,
|
||||
ZAP_GOAL,
|
||||
EVENT_TIME,
|
||||
COMMENT,
|
||||
POLL,
|
||||
getTagValue,
|
||||
getTagValues,
|
||||
getIdAndAddress,
|
||||
sortEventsDesc,
|
||||
} from "@welshman/util"
|
||||
import {MESSAGE, getTagValue, sortEventsDesc} from "@welshman/util"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {repository} from "@welshman/app"
|
||||
import History from "@assets/icons/history.svg?dataurl"
|
||||
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
|
||||
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
||||
@@ -42,76 +15,15 @@
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import PageContent from "@lib/components/PageContent.svelte"
|
||||
import SpaceBar from "@app/components/SpaceBar.svelte"
|
||||
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 RecentItem from "@app/components/RecentItem.svelte"
|
||||
import {decodeRelay} from "@app/relays"
|
||||
import {deriveEventsForUrl} from "@app/repository"
|
||||
import {CONTENT_KINDS} from "@app/content"
|
||||
import {deriveRecentActivity} from "@app/recent"
|
||||
import {goToEvent} from "@app/routes"
|
||||
|
||||
const url = decodeRelay($page.params.relay!)
|
||||
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}])
|
||||
|
||||
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),
|
||||
)
|
||||
},
|
||||
)
|
||||
const recentActivity = deriveRecentActivity(url)
|
||||
|
||||
let term = $state("")
|
||||
let showSearch = $state(false)
|
||||
@@ -298,22 +210,8 @@
|
||||
{#if $recentActivity.length === 0}
|
||||
<p class="flex flex-col items-center py-20 text-center">No recent activity found!</p>
|
||||
{:else}
|
||||
{#each $recentActivity.slice(0, limit) as { type, event, count = 0 } (event.id)}
|
||||
{#if type === "message"}
|
||||
<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 $recentActivity.slice(0, limit) as item (item.event.id)}
|
||||
<RecentItem {url} {item} />
|
||||
{/each}
|
||||
{/if}
|
||||
</PageContent>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
</PageHeader>
|
||||
<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="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="bg-primary/20 flex h-10 w-10 items-center justify-center rounded-md">
|
||||
<Icon icon={CloudCheck} class="text-primary" />
|
||||
@@ -59,7 +59,7 @@
|
||||
<Icon icon={ArrowRight} />
|
||||
</Link>
|
||||
</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 items-start justify-between">
|
||||
<img alt="Coracle Logo" src="/coracle.png" class="h-10 w-10" />
|
||||
|
||||
Reference in New Issue
Block a user