Migrate Reports dialog to ActionItems dialog, add room join requests to queue

This commit is contained in:
hodlbod 2026-03-19 15:40:34 +00:00
parent 0761cdd28f
commit 1f5be54cb1
15 changed files with 326 additions and 115 deletions

View file

@ -169,6 +169,7 @@ src/
- When creating forms, use `FieldInline` or `Field` instead of custom elements/tailwindcss - When creating forms, use `FieldInline` or `Field` instead of custom elements/tailwindcss
- Do not define svelte event handlers inline, instead name them and put them in the script section of templates - Do not define svelte event handlers inline, instead name them and put them in the script section of templates
- Avoid using `as`, except where necessary. Instead, annotate function parameters, and ensure upstream values are typed correctly. - Avoid using `as`, except where necessary. Instead, annotate function parameters, and ensure upstream values are typed correctly.
- Instead of `getTag(tagName, event.tags)?.[1] || ""`, use `getTagValue(tagName, event.tags)`
**Human-First Simplicity (Jon Staab Style):** **Human-First Simplicity (Jon Staab Style):**

View file

@ -30,6 +30,7 @@
import EventInfo from "@app/components/EventInfo.svelte" import EventInfo from "@app/components/EventInfo.svelte"
import ProfileBadges from "@app/components/ProfileBadges.svelte" import ProfileBadges from "@app/components/ProfileBadges.svelte"
import {pubkeyLink, deriveUserIsSpaceAdmin, deriveSpaceBannedPubkeyItems} from "@app/core/state" import {pubkeyLink, deriveUserIsSpaceAdmin, deriveSpaceBannedPubkeyItems} from "@app/core/state"
import {addSpaceMembers} from "@app/core/commands"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
import {makeChatPath} from "@app/util/routes" import {makeChatPath} from "@app/util/routes"
@ -85,10 +86,7 @@
}) })
const restoreMember = async () => { const restoreMember = async () => {
const {error} = await manageRelay(url!, { const error = await addSpaceMembers(url!, [pubkey])
method: ManagementMethod.AllowPubkey,
params: [pubkey],
})
if (error) { if (error) {
pushToast({theme: "error", message: error}) pushToast({theme: "error", message: error})

View file

@ -15,10 +15,10 @@
type Props = { type Props = {
url: string url: string
event: TrustedEvent event: TrustedEvent
onDelete?: () => void onResolved?: () => void
} }
const {url, event, onDelete}: Props = $props() const {url, event, onResolved}: Props = $props()
const etag = getTag("e", event.tags) const etag = getTag("e", event.tags)
const ptag = getTag("p", event.tags) const ptag = getTag("p", event.tags)
@ -45,7 +45,7 @@
{/if} {/if}
</span> </span>
</div> </div>
<ReportMenu {url} {event} {onDelete} /> <ReportMenu {url} {event} {onResolved} />
</div> </div>
{#if event.content} {#if event.content}
<div class="border-l-2 border-primary pl-3"> <div class="border-l-2 border-primary pl-3">

View file

@ -208,7 +208,7 @@
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input type="checkbox" class="checkbox" bind:checked={values.isClosed} /> <input type="checkbox" class="checkbox" bind:checked={values.isClosed} />
<span class="text-sm opacity-75">Ignore requests to join</span> <span class="text-sm opacity-75">Membership requires approval</span>
</div> </div>
</ModalBody> </ModalBody>
{@render footer({loading})} {@render footer({loading})}

View file

@ -0,0 +1,83 @@
<script lang="ts">
import {getTagValue} from "@welshman/util"
import type {TrustedEvent, PublishedRoomMeta} from "@welshman/util"
import {repository} from "@welshman/app"
import Button from "@lib/components/Button.svelte"
import ProfileName from "@app/components/ProfileName.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import RoomName from "@app/components/RoomName.svelte"
import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
import {deriveRoom} from "@app/core/state"
import {addRoomMembers} from "@app/core/commands"
type Props = {
url: string
event: TrustedEvent
onResolved?: () => void
}
const {url, event, onResolved}: Props = $props()
const h = getTagValue("h", event.tags) || ""
const room = deriveRoom(url, h)
const showProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey, url})
const dismiss = async () => {
loading = true
try {
const {error} = await manageRelay(url, {
method: ManagementMethod.BanEvent,
params: [event.id, "Join request dismissed"]
})
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Join request has been dismissed."})
repository.removeEvent(event.id)
onResolved?.()
}
} finally {
loading = false
}
}
const accept = async () => {
loading = true
try {
const error = await addRoomMembers(url, $room as PublishedRoomMeta, [event.pubkey])
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Member has been added to the room!"})
onResolved?.()
}
} finally {
loading = false
}
}
let loading = $state(false)
</script>
<div class="column gap-4 card2 card2-sm bg-alt">
<div class="flex justify-between gap-2">
<div>
<Button class="inline text-primary" onclick={showProfile}>
<ProfileName pubkey={event.pubkey} {url} />
</Button>
<span>
requested membership in #<RoomName {url} {h} />
</span>
</div>
<div class="flex gap-2">
<Button class="btn btn-neutral btn-sm" onclick={dismiss} disabled={loading}>Dismiss</Button>
<Button class="btn btn-primary btn-sm" onclick={accept} disabled={loading}>Accept</Button>
</div>
</div>
</div>

View file

@ -2,9 +2,8 @@
import {onMount} from "svelte" import {onMount} from "svelte"
import {setKey, popKey} from "@lib/implicit" import {setKey, popKey} from "@lib/implicit"
import {sleep} from "@welshman/lib" import {sleep} from "@welshman/lib"
import {ManagementMethod} from "@welshman/util" import {displayProfileByPubkey} from "@welshman/app"
import {manageRelay} from "@welshman/app" import type {PublishedRoomMeta} from "@welshman/util"
import {addRoomMember, displayProfileByPubkey, waitForThunkError} from "@welshman/app"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@ -22,6 +21,7 @@
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
import {pushModal} from "@app/util/modal" import {pushModal} from "@app/util/modal"
import {deriveRoom, deriveSpaceMembers} from "@app/core/state" import {deriveRoom, deriveSpaceMembers} from "@app/core/state"
import {addRoomMembers} from "@app/core/commands"
interface Props { interface Props {
url: string url: string
@ -42,35 +42,14 @@
// Show loading for auto submit callback // Show loading for auto submit callback
await sleep(500) await sleep(500)
const results = await Promise.all( const error = await addRoomMembers(url, $room as PublishedRoomMeta, pubkeys)
pubkeys
.filter(pubkey => !$spaceMembers.includes(pubkey))
.map(pubkey =>
manageRelay(url, {
method: ManagementMethod.AllowPubkey,
params: [pubkey],
}),
),
)
for (const {error} of results) { if (error) {
if (error) { pushToast({theme: "error", message: error})
return pushToast({theme: "error", message: error}) } else {
} pushToast({message: "Members have successfully been added!"})
back()
} }
const errors = await Promise.all(
pubkeys.map(pubkey => waitForThunkError(addRoomMember(url, $room, pubkey))),
)
for (const error of errors) {
if (error) {
return pushToast({theme: "error", message: errors[0]})
}
}
pushToast({message: "Members have successfully been added!"})
back()
} finally { } finally {
loading = false loading = false
} }

View file

@ -0,0 +1,58 @@
<script lang="ts">
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Button from "@lib/components/Button.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 ReportItem from "@app/components/ReportItem.svelte"
import RoomJoinItem from "@app/components/RoomJoinItem.svelte"
import RelayName from "@app/components/RelayName.svelte"
import {REPORT} from "@welshman/util"
import {deriveSpaceActionItems} from "@app/core/state"
interface Props {
url: string
}
const {url}: Props = $props()
const actionItems = deriveSpaceActionItems(url)
const back = () => history.back()
const onResolved = () => {
if ($actionItems.length === 0) {
back()
}
}
</script>
<Modal>
<ModalBody>
<ModalHeader>
<ModalTitle>Action Items</ModalTitle>
<ModalSubtitle>on <RelayName {url} class="text-primary" /></ModalSubtitle>
</ModalHeader>
<div class="flex flex-col gap-2">
{#each $actionItems as event (event.id)}
{#if event.kind === REPORT}
<ReportItem {url} {event} {onResolved} />
{:else}
<RoomJoinItem {url} {event} {onResolved} />
{/if}
{:else}
<p class="py-12 text-center">No action items found.</p>
{/each}
</div>
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
</ModalFooter>
</Modal>

View file

@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import {displayRelayUrl, ManagementMethod} from "@welshman/util" import {displayRelayUrl} from "@welshman/util"
import {manageRelay} from "@welshman/app"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@ -13,6 +12,7 @@
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte" import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte"
import {addSpaceMembers} from "@app/core/commands"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
interface Props { interface Props {
@ -27,23 +27,14 @@
loading = true loading = true
try { try {
const results = await Promise.all( const error = await addSpaceMembers(url, pubkeys)
pubkeys.map(pubkey =>
manageRelay(url, {
method: ManagementMethod.AllowPubkey,
params: [pubkey],
}),
),
)
for (const {error} of results) { if (error) {
if (error) { pushToast({theme: "error", message: error})
return pushToast({theme: "error", message: error}) } else {
} pushToast({message: "Members have successfully been added!"})
back()
} }
pushToast({message: "Members have successfully been added!"})
back()
} finally { } finally {
loading = false loading = false
} }

View file

@ -17,6 +17,7 @@
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import Profile from "@app/components/Profile.svelte" import Profile from "@app/components/Profile.svelte"
import {deriveSpaceBannedPubkeyItems, deriveSupportedMethods} from "@app/core/state" import {deriveSpaceBannedPubkeyItems, deriveSupportedMethods} from "@app/core/state"
import {addSpaceMembers} from "@app/core/commands"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
interface Props { interface Props {
@ -55,10 +56,7 @@
} }
const restoreMember = async (pubkey: string) => { const restoreMember = async (pubkey: string) => {
const {error} = await manageRelay(url, { const error = await addSpaceMembers(url, [pubkey])
method: ManagementMethod.AllowPubkey,
params: [pubkey],
})
if (error) { if (error) {
pushToast({theme: "error", message: error}) pushToast({theme: "error", message: error})

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {derived} from "svelte/store" import {derived} from "svelte/store"
import {displayRelayUrl, EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED, REPORT} from "@welshman/util" import {displayRelayUrl, EVENT_TIME, ZAP_GOAL, THREAD, CLASSIFIED} from "@welshman/util"
import {deriveRelay, createSearch, pubkey} from "@welshman/app" import {deriveRelay, createSearch, pubkey} from "@welshman/app"
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"
@ -35,7 +35,7 @@
import SpaceJoin from "@app/components/SpaceJoin.svelte" import SpaceJoin from "@app/components/SpaceJoin.svelte"
import RelayName from "@app/components/RelayName.svelte" import RelayName from "@app/components/RelayName.svelte"
import SpaceMembers from "@app/components/SpaceMembers.svelte" import SpaceMembers from "@app/components/SpaceMembers.svelte"
import SpaceReports from "@app/components/SpaceReports.svelte" import SpaceActionItems from "@app/components/SpaceActionItems.svelte"
import RoomCreate from "@app/components/RoomCreate.svelte" import RoomCreate from "@app/components/RoomCreate.svelte"
import SpaceMenuRoomItem from "@app/components/SpaceMenuRoomItem.svelte" import SpaceMenuRoomItem from "@app/components/SpaceMenuRoomItem.svelte"
import VoiceWidget from "@app/components/VoiceWidget.svelte" import VoiceWidget from "@app/components/VoiceWidget.svelte"
@ -52,6 +52,7 @@
deriveUserCanCreateRoom, deriveUserCanCreateRoom,
deriveUserIsSpaceAdmin, deriveUserIsSpaceAdmin,
deriveEventsForUrl, deriveEventsForUrl,
deriveSpaceActionItems,
notificationSettings, notificationSettings,
deriveShouldNotify, deriveShouldNotify,
displayRoom, displayRoom,
@ -73,7 +74,7 @@
const otherVoiceRooms = deriveOtherVoiceRooms(url) const otherVoiceRooms = deriveOtherVoiceRooms(url)
const members = deriveSpaceMembers(url) const members = deriveSpaceMembers(url)
const userIsAdmin = deriveUserIsSpaceAdmin(url) const userIsAdmin = deriveUserIsSpaceAdmin(url)
const reports = deriveEventsForUrl(url, [{kinds: [REPORT]}]) const actionItems = deriveSpaceActionItems(url)
const spaceKinds = derived( const spaceKinds = derived(
deriveEventsForUrl(url, [{kinds: CONTENT_KINDS}]), deriveEventsForUrl(url, [{kinds: CONTENT_KINDS}]),
@ -102,7 +103,7 @@
const showMembers = () => pushModal(SpaceMembers, {url}, {replaceState}) const showMembers = () => pushModal(SpaceMembers, {url}, {replaceState})
const showReports = () => pushModal(SpaceReports, {url}, {replaceState}) const showActionItems = () => pushModal(SpaceActionItems, {url}, {replaceState})
const canCreateRoom = deriveUserCanCreateRoom(url) const canCreateRoom = deriveUserCanCreateRoom(url)
@ -140,11 +141,15 @@
<SecondaryNavSection class="min-h-0 flex-1 flex flex-col overflow-hidden pb-0"> <SecondaryNavSection class="min-h-0 flex-1 flex flex-col overflow-hidden pb-0">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<Button <Button
class="flex w-full flex-col rounded-xl p-3 transition-all hover:bg-base-100" class="relative flex w-full flex-col rounded-xl p-3 transition-all hover:bg-base-100"
onclick={openMenu}> onclick={openMenu}>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<strong class="ellipsize flex items-center gap-1"> <strong class="flex items-center gap-1 relative">
<RelayName {url} /> <RelayName {url} class="ellipsize" />
<div
class="absolute -right-3 top-0 h-2 w-2 rounded-full bg-primary transition-all opacity-0"
class:opacity-100={$userIsAdmin && $actionItems.length > 0}>
</div>
{#if $notificationSettings.push && !$shouldNotify} {#if $notificationSettings.push && !$shouldNotify}
<Icon icon={BellOff} size={3} class="opacity-50" /> <Icon icon={BellOff} size={3} class="opacity-50" />
{/if} {/if}
@ -178,9 +183,12 @@
</li> </li>
{#if $userIsAdmin} {#if $userIsAdmin}
<li> <li>
<Button onclick={showReports}> <Button onclick={showActionItems}>
<Icon icon={Danger} /> <Icon icon={Danger} />
View Reports ({$reports.length}) Action Items ({$actionItems.length})
{#if $actionItems.length > 0}
<div class="h-2 w-2 rounded-full bg-primary"></div>
{/if}
</Button> </Button>
</li> </li>
{/if} {/if}

View file

@ -17,7 +17,7 @@ import {
} from "@welshman/lib" } from "@welshman/lib"
import {Nip01Signer} from "@welshman/signer" import {Nip01Signer} from "@welshman/signer"
import type {UploadTask} from "@welshman/editor" import type {UploadTask} from "@welshman/editor"
import type {TrustedEvent, EventContent, Profile} from "@welshman/util" import type {TrustedEvent, EventContent, Profile, PublishedRoomMeta} from "@welshman/util"
import { import {
DELETE, DELETE,
REPORT, REPORT,
@ -52,6 +52,7 @@ import {
editProfile, editProfile,
createProfile, createProfile,
uniqTags, uniqTags,
ManagementMethod,
} from "@welshman/util" } from "@welshman/util"
import {Pool, AuthStatus, SocketStatus} from "@welshman/net" import {Pool, AuthStatus, SocketStatus} from "@welshman/net"
import {Router} from "@welshman/router" import {Router} from "@welshman/router"
@ -72,6 +73,8 @@ import {
getPubkeyRelays, getPubkeyRelays,
userBlossomServerList, userBlossomServerList,
getThunkError, getThunkError,
addRoomMember,
manageRelay,
} from "@welshman/app" } from "@welshman/app"
import {compressFile} from "@lib/html" import {compressFile} from "@lib/html"
import type {SettingsValues, SpaceNotificationSettings} from "@app/core/state" import type {SettingsValues, SpaceNotificationSettings} from "@app/core/state"
@ -89,6 +92,7 @@ import {
stripPrefix, stripPrefix,
relaysMostlyRestricted, relaysMostlyRestricted,
deriveSocket, deriveSocket,
deriveSpaceMembers,
} from "@app/core/state" } from "@app/core/state"
// Utils // Utils
@ -220,8 +224,7 @@ export const attemptRelayAccess = async (url: string, claim = "") => {
} }
} }
const thunk = publishJoinRequest({url, claim}) const error = await waitForThunkError(publishJoinRequest({url, claim}))
const error = await waitForThunkError(thunk)
if (shouldIgnoreError(error)) return if (shouldIgnoreError(error)) return
if (!claim && error.includes("invite code size")) return if (!claim && error.includes("invite code size")) return
@ -699,3 +702,50 @@ export const updateProfile = ({
return publishThunk({event, relays}) return publishThunk({event, relays})
} }
// Admin actions
export const addSpaceMembers = async (
url: string,
pubkeys: string[],
): Promise<string | undefined> => {
const spaceMembers = get(deriveSpaceMembers(url))
const results = await Promise.all(
pubkeys
.filter(pubkey => !spaceMembers.includes(pubkey))
.map(pubkey =>
manageRelay(url, {
method: ManagementMethod.AllowPubkey,
params: [pubkey],
}),
),
)
for (const {error} of results) {
if (error) {
return error
}
}
}
export const addRoomMembers = async (
url: string,
room: PublishedRoomMeta,
pubkeys: string[],
): Promise<string | undefined> => {
const error = await addSpaceMembers(url, pubkeys)
if (error) {
return error
}
const errors = await Promise.all(
pubkeys.map(pubkey => waitForThunkError(addRoomMember(url, room, pubkey))),
)
for (const error of errors) {
if (error) {
return error
}
}
}

View file

@ -47,10 +47,10 @@ export const makeFeed = ({
onForwardExhausted?: () => void onForwardExhausted?: () => void
at?: number at?: number
}) => { }) => {
const interval = int(WEEK)
const controller = new AbortController() const controller = new AbortController()
const events = writable<TrustedEvent[]>([]) const events = writable<TrustedEvent[]>([])
let interval = int(WEEK)
let buffer: TrustedEvent[] = [] let buffer: TrustedEvent[] = []
let backwardWindow = [at - interval, at] let backwardWindow = [at - interval, at]
let forwardWindow = [at, at + interval] let forwardWindow = [at, at + interval]
@ -111,13 +111,20 @@ export const makeFeed = ({
}), }),
] ]
const loadTimeframe = (since: number, until: number) => { const loadTimeframe = async (since: number, until: number) => {
request({ const events = await request({
relays: [url], relays: [url],
autoClose: true, autoClose: true,
signal: controller.signal, signal: controller.signal,
filters: filters.map(filter => ({...filter, since, until})), filters: filters.map(filter => ({...filter, since, until})),
}) })
// If we found nothing, accelerate
if (events.length === 0) {
interval = Math.round(interval * 1.1)
} else {
interval = int(WEEK)
}
} }
const backwardScroller = createScroller({ const backwardScroller = createScroller({

View file

@ -31,6 +31,7 @@ import {
groupBy, groupBy,
remove, remove,
simpleCache, simpleCache,
removeUndefined,
} from "@welshman/lib" } from "@welshman/lib"
import type {Override} from "@welshman/lib" import type {Override} from "@welshman/lib"
import type {RepositoryUpdate} from "@welshman/net" import type {RepositoryUpdate} from "@welshman/net"
@ -99,6 +100,7 @@ import {
REPOST, REPOST,
GENERIC_REPOST, GENERIC_REPOST,
asDecryptedEvent, asDecryptedEvent,
getTagValue,
getGroupTags, getGroupTags,
getListTags, getListTags,
getPubkeyTagValues, getPubkeyTagValues,
@ -111,6 +113,7 @@ import {
readRoomMeta, readRoomMeta,
makeRoomMeta, makeRoomMeta,
ManagementMethod, ManagementMethod,
sortEventsAsc,
sortEventsDesc, sortEventsDesc,
getAddress, getAddress,
Address, Address,
@ -287,7 +290,7 @@ export const deriveRelaySignedEvents = (url: string, filters: Filter[] = [{}]) =
derived( derived(
[deriveRelay(url), deriveEventsForUrl(url, filters)], [deriveRelay(url), deriveEventsForUrl(url, filters)],
([relay, events]) => events, ([relay, events]) => events,
// khatru doesn't support relay.self, uncomment when it's ready // TODO: khatru doesn't support relay.self, uncomment when it's ready
// filter(spec({pubkey: relay.self}), events) // filter(spec({pubkey: relay.self}), events)
) )
@ -869,7 +872,7 @@ export const deriveRoomMembers = (url: string, h: string) => {
const members = new Set<string>() const members = new Set<string>()
for (const event of sortBy(e => -e.created_at, $events)) { for (const event of sortEventsAsc($events)) {
const pubkeys = getPubkeyTagValues(event.tags) const pubkeys = getPubkeyTagValues(event.tags)
if (event.kind === ROOM_ADD_MEMBER) { if (event.kind === ROOM_ADD_MEMBER) {
@ -903,6 +906,50 @@ export const deriveRoomAdmins = (url: string, h: string) => {
}) })
} }
// Action items (admin review queue)
// const pendingJoins: TrustedEvent[] = []
export const deriveSpaceActionItems = (url: string) =>
derived(
deriveEventsForUrl(url, [
{
kinds: [REPORT, ROOM_JOIN, ROOM_LEAVE, ROOM_MEMBERS],
},
]),
$events => {
const getRoomId = (e: TrustedEvent) => getTagValue(e.kind === ROOM_MEMBERS ? "d" : "h", e.tags)
const reports = $events.filter(e => e.kind === REPORT)
const pendingJoins: TrustedEvent[] = []
// Room-level join requests — most recent per pubkey+h
for (const [h, roomEvents] of groupBy(getRoomId, $events)) {
if (!h) continue
const roomJoins = roomEvents.filter(spec({kind: ROOM_JOIN}))
const roomLeaves = roomEvents.filter(spec({kind: ROOM_LEAVE}))
const roomMembersEvent = roomEvents.find(spec({kind: ROOM_MEMBERS}))
const roomMembers = getTagValues("p", roomMembersEvent?.tags ?? [])
pendingJoins.push(
...removeUndefined(
Array.from(groupBy(e => e.pubkey, roomJoins).values())
.map(sortEventsDesc)
.map(first),
).filter(({pubkey, created_at}) => {
if (roomMembers.includes(pubkey)) return false
if (gt(roomMembersEvent?.created_at, created_at)) return false
if (roomLeaves.some(e => e.pubkey === pubkey && e.created_at > created_at))
return false
return true
}),
)
}
return sortEventsDesc([...reports, ...pendingJoins])
},
)
// User membership status // User membership status
export enum MembershipStatus { export enum MembershipStatus {

View file

@ -13,6 +13,7 @@ import {
ROOM_ADD_MEMBER, ROOM_ADD_MEMBER,
ROOM_REMOVE_MEMBER, ROOM_REMOVE_MEMBER,
ROOM_CREATE_PERMISSION, ROOM_CREATE_PERMISSION,
ROOM_JOIN,
RELAY_MEMBERS, RELAY_MEMBERS,
RELAY_ADD_MEMBER, RELAY_ADD_MEMBER,
RELAY_REMOVE_MEMBER, RELAY_REMOVE_MEMBER,
@ -268,7 +269,7 @@ const syncSpace = (url: string, rooms: string[]) => {
pullAndListen({ pullAndListen({
url, url,
signal: controller.signal, signal: controller.signal,
filters: [{kinds: [ROOM_META, ROOM_ADMINS, ROOM_MEMBERS]}], filters: [{kinds: [ROOM_META, ROOM_ADMINS, ROOM_MEMBERS, LIVEKIT_PARTICIPANTS, ROOM_JOIN]}],
}) })
// Room-scoped kinds: add #h tags when we know which rooms the user is in. // Room-scoped kinds: add #h tags when we know which rooms the user is in.
@ -317,12 +318,6 @@ const syncSpace = (url: string, rooms: string[]) => {
}) })
} }
pullAndListen({
url,
signal: controller.signal,
filters: [{kinds: [LIVEKIT_PARTICIPANTS]}],
})
return () => controller.abort() return () => controller.abort()
} }

View file

@ -381,22 +381,20 @@
<div class="py-20"> <div class="py-20">
<div class="card2 col-8 m-auto max-w-md items-center text-center"> <div class="card2 col-8 m-auto max-w-md items-center text-center">
<p class="opacity-75">You aren't currently a member of this room.</p> <p class="opacity-75">You aren't currently a member of this room.</p>
{#if !$room.isClosed} {#if $membershipStatus === MembershipStatus.Pending}
{#if $membershipStatus === MembershipStatus.Pending} <Button class="btn btn-neutral btn-sm" disabled={leaving} onclick={leave}>
<Button class="btn btn-neutral btn-sm" disabled={leaving} onclick={leave}> <Icon icon={ClockCircle} />
<Icon icon={ClockCircle} /> Access Pending
Access Pending </Button>
</Button> {:else}
{:else} <Button class="btn btn-neutral btn-sm" disabled={joining} onclick={join}>
<Button class="btn btn-neutral btn-sm" disabled={joining} onclick={join}> {#if joining}
{#if joining} <span class="loading loading-spinner loading-sm"></span>
<span class="loading loading-spinner loading-sm"></span> {:else}
{:else} <Icon icon={Login2} />
<Icon icon={Login2} /> {/if}
{/if} Join Room
Join Room </Button>
</Button>
{/if}
{/if} {/if}
</div> </div>
</div> </div>
@ -456,22 +454,20 @@
{:else if $room.isRestricted && $membershipStatus !== MembershipStatus.Granted} {:else if $room.isRestricted && $membershipStatus !== MembershipStatus.Granted}
<div class="bg-alt card m-4 flex flex-row items-center justify-between px-4 py-3"> <div class="bg-alt card m-4 flex flex-row items-center justify-between px-4 py-3">
<p class="opacity-75">Only members are allowed to post to this room.</p> <p class="opacity-75">Only members are allowed to post to this room.</p>
{#if !$room.isClosed} {#if $membershipStatus === MembershipStatus.Pending}
{#if $membershipStatus === MembershipStatus.Pending} <Button class="btn btn-neutral btn-sm" disabled={leaving} onclick={leave}>
<Button class="btn btn-neutral btn-sm" disabled={leaving} onclick={leave}> <Icon icon={ClockCircle} />
<Icon icon={ClockCircle} /> Access Pending
Access Pending </Button>
</Button> {:else}
{:else} <Button class="btn btn-neutral btn-sm" disabled={joining} onclick={join}>
<Button class="btn btn-neutral btn-sm" disabled={joining} onclick={join}> {#if joining}
{#if joining} <span class="loading loading-spinner loading-sm"></span>
<span class="loading loading-spinner loading-sm"></span> {:else}
{:else} <Icon icon={Login2} />
<Icon icon={Login2} /> {/if}
{/if} Ask to Join
Ask to Join </Button>
</Button>
{/if}
{/if} {/if}
</div> </div>
{:else} {:else}