Split up space information and directory
This commit is contained in:
+6
-1
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 {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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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))) {
|
} 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})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
Reference in New Issue
Block a user