Let a room admin delete a message with a NIP-29 delete-event op

This commit is contained in:
Coracle-Bot 2026-09-19 18:43:01 +00:00
parent 3f57607780
commit 7cb3feeeb3
5 changed files with 103 additions and 49 deletions

View file

@ -0,0 +1,49 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import {tagSpec, tagValue} from "@welshman/util"
import Confirm from "@lib/components/Confirm.svelte"
import {app, relayManagement, rooms} from "@app/core"
import {AdminDelete, deriveUserAdminDelete} from "@app/rooms"
import {pushToast} from "@app/toast"
type Props = {
url: string
noun: string
event: TrustedEvent
}
const {url, noun, event}: Props = $props()
const h = tagValue(tagSpec("h"), event.tags) ?? ""
const adminDelete = deriveUserAdminDelete(url, event)
const scope = $derived($adminDelete === AdminDelete.Room ? "room" : "space")
const remove = async () => {
if ($adminDelete === AdminDelete.Room) {
const command = await $rooms.deleteEvent(url, {h}, event.id)
return command.publish().waitForError()
}
const {error} = await $relayManagement.forUrl(url).banEvent(event.id)
return error
}
const confirm = async () => {
const error = await remove()
if (error) {
return pushToast({theme: "error", message: error})
}
pushToast({message: `${noun} has successfully been deleted!`})
$app.repository.removeEvent(event.id)
history.back()
}
</script>
<Confirm
{confirm}
title="Delete {noun}"
message="Are you sure you want to delete this {noun.toLowerCase()} from the {scope}?" />

View file

@ -9,17 +9,16 @@
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
import Danger from "@assets/icons/danger.svg?dataurl"
import Button from "@lib/components/Button.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import Icon from "@lib/components/Icon.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import Report from "@app/components/Report.svelte"
import EventAdminDeleteConfirm from "@app/components/EventAdminDeleteConfirm.svelte"
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import PinboardSelect from "@app/components/PinboardSelect.svelte"
import {shareEvent} from "@app/share"
import {deriveSpaceSupportedMethods} from "@app/management"
import {deriveUserAdminDelete} from "@app/rooms"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
import {app, relayManagement, user} from "@app/core"
import {user} from "@app/core"
type Props = {
url: string
@ -32,8 +31,7 @@
const {url, noun, event, onClick, customActions}: Props = $props()
const isRoot = event.kind !== COMMENT
const supportedMethods = deriveSpaceSupportedMethods(url)
const canBanEvent = $derived($supportedMethods.includes("banevent"))
const adminDelete = deriveUserAdminDelete(url, event)
const report = () => pushModal(Report, {url, event})
@ -45,22 +43,7 @@
const showDelete = () => pushModal(EventDeleteConfirm, {url, event})
const showAdminDelete = () =>
pushModal(Confirm, {
title: `Delete ${noun}`,
message: `Are you sure you want to delete this ${noun.toLowerCase()} from the space?`,
confirm: async () => {
const {error} = await $relayManagement.forUrl(url).banEvent(event.id)
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Event has successfully been deleted!"})
$app.repository.removeEvent(event.id)
history.back()
}
},
})
const showAdminDelete = () => pushModal(EventAdminDeleteConfirm, {url, noun, event})
let ul: Element
@ -105,7 +88,7 @@
Report Content
</Button>
</li>
{#if canBanEvent}
{#if $adminDelete}
<li>
<Button class="text-error" onclick={showAdminDelete}>
<Icon size={4} icon={TrashBin2} />

View file

@ -12,15 +12,14 @@
import VolumeLoud from "@assets/icons/volume-loud.svg?dataurl"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import Report from "@app/components/Report.svelte"
import PinboardSelect from "@app/components/PinboardSelect.svelte"
import EventAdminDeleteConfirm from "@app/components/EventAdminDeleteConfirm.svelte"
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import ThreadCreate from "@app/components/ThreadCreate.svelte"
import {app, relayManagement, roomPinLists, user} from "@app/core"
import {deriveSpaceSupportedMethods} from "@app/management"
import {deriveUserIsRoomAdmin} from "@app/rooms"
import {roomPinLists, user} from "@app/core"
import {deriveUserAdminDelete, deriveUserIsRoomAdmin} from "@app/rooms"
import {shareEvent} from "@app/share"
import {readAloud} from "@app/speech"
import {pushModal} from "@app/modal"
@ -36,10 +35,9 @@
const h = tagValue(tagSpec("h"), event.tags) ?? ""
const pinIds = $roomPinLists.pins(url, h).$
const supportedMethods = deriveSpaceSupportedMethods(url)
const userIsRoomAdmin = deriveUserIsRoomAdmin(url, h)
const adminDelete = deriveUserAdminDelete(url, event)
const isPinned = $derived($pinIds.includes(event.id))
const canBanEvent = $derived($supportedMethods.includes("banevent"))
const share = () => {
onClick()
@ -80,22 +78,10 @@
pushModal(EventDeleteConfirm, {url, event})
}
const showAdminDelete = () =>
pushModal(Confirm, {
title: `Delete Message`,
message: `Are you sure you want to delete this message from the space?`,
confirm: async () => {
const {error} = await $relayManagement.forUrl(url).banEvent(event.id)
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Event has successfully been deleted!"})
$app.repository.removeEvent(event.id)
history.back()
}
},
})
const showAdminDelete = () => {
onClick()
pushModal(EventAdminDeleteConfirm, {url, noun: "Message", event})
}
const togglePin = async () => {
onClick()
@ -174,7 +160,7 @@
Report Content
</Button>
</li>
{#if canBanEvent}
{#if $adminDelete}
<li>
<Button class="text-error" onclick={showAdminDelete}>
<Icon size={4} icon={TrashBin2} />

View file

@ -25,10 +25,11 @@
import EmojiPicker from "@lib/components/EmojiPicker.svelte"
import ZapButton from "@app/components/ZapButton.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import EventAdminDeleteConfirm from "@app/components/EventAdminDeleteConfirm.svelte"
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import ThreadCreate from "@app/components/ThreadCreate.svelte"
import {reactions, relays, roomPinLists, user} from "@app/core"
import {deriveUserIsRoomAdmin} from "@app/rooms"
import {deriveUserAdminDelete, deriveUserIsRoomAdmin} from "@app/rooms"
import {ENABLE_ZAPS} from "@app/env"
import {makeContentPath} from "@app/routes"
import {shareEvent} from "@app/share"
@ -49,6 +50,7 @@
const path = makeContentPath(url, event.kind, getIdOrAddress(event))
const pinIds = $roomPinLists.pins(url, h).$
const userIsRoomAdmin = deriveUserIsRoomAdmin(url, h)
const adminDelete = deriveUserAdminDelete(url, event)
const isPinned = $derived($pinIds.includes(event.id))
const tile = "button h-auto flex-col gap-1.5 py-4 text-xs"
@ -98,6 +100,8 @@
const showDelete = () => pushModal(EventDeleteConfirm, {url, event})
const showAdminDelete = () => pushModal(EventAdminDeleteConfirm, {url, noun: "Message", event})
const toggleMore = () => {
showMore = !showMore
}
@ -192,6 +196,11 @@
<Icon size={4} icon={TrashBin2} />
Delete Message
</Button>
{:else if $adminDelete}
<Button class="button button-neutral w-full text-error" onclick={showAdminDelete}>
<Icon size={4} icon={TrashBin2} />
Delete Message
</Button>
{/if}
</div>
{/if}

View file

@ -2,7 +2,7 @@ import * as nip19 from "nostr-tools/nip19"
import {derived, get} from "svelte/store"
import {formatTimestampAsDate, int, sortBy, uniq, MINUTE} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import {outbox, relay, seen, toNostrURI} from "@welshman/util"
import {outbox, relay, seen, tagSpec, tagValue, toNostrURI} from "@welshman/util"
import type {EventContent, TrustedEvent} from "@welshman/util"
import {Message} from "@welshman/domain"
import {MembershipStatus, RoomLists, makeRoomKey, createSearch, publish} from "@welshman/app"
@ -19,7 +19,7 @@ import {
user,
writer,
} from "@app/core"
import {deriveUserIsSpaceStaff} from "@app/management"
import {deriveSpaceSupportedMethods, deriveUserIsSpaceStaff} from "@app/management"
import {makeRoomPath} from "@app/routes"
export const PROTECTED = ["-"]
@ -173,6 +173,33 @@ export const deriveUserIsRoomAdmin = (url: string, h: string) =>
$isStaff || Boolean($room?.admins?.pubkeys().includes($user.pubkey)),
)
// Deleting someone else's content goes one of two ways, and a space answers for either one.
export enum AdminDelete {
Room = "room",
Space = "space",
}
// A room admin deletes over NIP-29, which is scoped to the room the content is in. NIP-86 is the
// wider grant and the only one that reaches content belonging to no room, so it comes second.
export const deriveUserAdminDelete = (url: string, event: TrustedEvent) => {
const h = tagValue(tagSpec("h"), event.tags) ?? ""
return derived(
[deriveUserIsRoomAdmin(url, h), deriveSpaceSupportedMethods(url)],
([$isRoomAdmin, $methods]): Maybe<AdminDelete> => {
if (h && $isRoomAdmin) {
return AdminDelete.Room
}
if ($methods.includes("banevent")) {
return AdminDelete.Space
}
return undefined
},
)
}
// Room membership is the relay's business, but a space admin outranks it.
export const deriveUserRoomMembershipStatus = (url: string, h: string) =>
derived(