Clean up report item design, bad/restore user actions, space description input, add feed to home page

This commit is contained in:
Jon Staab
2026-02-13 13:05:17 -08:00
parent 5bb55c453f
commit 30653fe344
10 changed files with 306 additions and 125 deletions
+8 -5
View File
@@ -2,6 +2,7 @@
import type {Snippet} from "svelte"
import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {Router} from "@welshman/router"
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import EmojiButton from "@lib/components/EmojiButton.svelte"
@@ -11,26 +12,28 @@
import {publishDelete, publishReaction, canEnforceNip70} from "@app/core/commands"
type Props = {
url: string
event: TrustedEvent
children?: Snippet
url?: string
}
const {url, event, children}: Props = $props()
const shouldProtect = canEnforceNip70(url)
const relays = url ? [url] : Router.get().Event(event).getUrls()
const shouldProtect = url ? canEnforceNip70(url) : Promise.resolve(false)
const deleteReaction = async (event: TrustedEvent) =>
publishDelete({relays: [url], event, protect: await shouldProtect})
publishDelete({relays, event, protect: await shouldProtect})
const createReaction = async (template: EventContent) =>
publishReaction({...template, event, relays: [url], protect: await shouldProtect})
publishReaction({...template, event, relays, protect: await shouldProtect})
const onEmoji = async (emoji: NativeEmoji) =>
publishReaction({
event,
relays,
content: emoji.unicode,
relays: [url],
protect: await shouldProtect,
})
</script>
+35 -7
View File
@@ -15,6 +15,7 @@
import Letter from "@assets/icons/letter-opened.svg?dataurl"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
import Restart from "@assets/icons/restart.svg?dataurl"
import {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import ImageIcon from "@lib/components/ImageIcon.svelte"
@@ -30,7 +31,7 @@
import EventInfo from "@app/components/EventInfo.svelte"
import ProfileBadges from "@app/components/ProfileBadges.svelte"
import ChatEnable from "@app/components/ChatEnable.svelte"
import {pubkeyLink, deriveUserIsSpaceAdmin} from "@app/core/state"
import {pubkeyLink, deriveUserIsSpaceAdmin, deriveSpaceBannedPubkeyItems} from "@app/core/state"
import {pushModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast"
import {makeChatPath} from "@app/util/routes"
@@ -46,6 +47,10 @@
const userIsAdmin = deriveUserIsSpaceAdmin(url)
const bannedPubkeys = url ? deriveSpaceBannedPubkeyItems(url) : undefined
const isBanned = $derived($bannedPubkeys?.some(item => item.pubkey === pubkey) ?? false)
const back = () => history.back()
const chatPath = makeChatPath([pubkey])
@@ -81,6 +86,20 @@
},
})
const restoreMember = async () => {
const {error} = await manageRelay(url!, {
method: ManagementMethod.AllowPubkey,
params: [pubkey],
})
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "User has successfully been restored!"})
back()
}
}
let showMenu = $state(false)
onMount(() => {
@@ -112,12 +131,21 @@
</li>
{/if}
{#if $userIsAdmin}
<li>
<Button class="text-error" onclick={banMember}>
<Icon icon={MinusCircle} />
Ban User
</Button>
</li>
{#if isBanned}
<li>
<Button onclick={restoreMember}>
<Icon icon={Restart} />
Restore User
</Button>
</li>
{:else}
<li>
<Button class="text-error" onclick={banMember}>
<Icon icon={MinusCircle} />
Ban User
</Button>
</li>
{/if}
{/if}
</ul>
</Popover>
+1 -3
View File
@@ -40,9 +40,7 @@
<ModalSubtitle>All reports for this event are shown below.</ModalSubtitle>
</ModalHeader>
{#each $reports.values() as report (report.id)}
<div class="card2 card2-sm bg-alt">
<ReportItem {url} event={report} {onDelete} />
</div>
<ReportItem {url} event={report} {onDelete} />
{/each}
</ModalBody>
<ModalFooter>
+3 -15
View File
@@ -3,14 +3,12 @@
import {getTag, getIdFilters} from "@welshman/util"
import {load, LOCAL_RELAY_URL} from "@welshman/net"
import type {TrustedEvent} from "@welshman/util"
import {pubkey} from "@welshman/app"
import Button from "@lib/components/Button.svelte"
import Profile from "@app/components/Profile.svelte"
import ProfileName from "@app/components/ProfileName.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
import ReportMenu from "@app/components/ReportMenu.svelte"
import {publishDelete, canEnforceNip70} from "@app/core/commands"
import {pushModal} from "@app/util/modal"
import {goToEvent} from "@app/util/routes"
@@ -25,7 +23,6 @@
const etag = getTag("e", event.tags)
const ptag = getTag("p", event.tags)
const reason = etag?.[2] || ptag?.[2]
const shouldProtect = canEnforceNip70(url)
const onClick = (e: Event, event: TrustedEvent) => {
// @ts-ignore
@@ -35,17 +32,12 @@
goToEvent(event)
}
}
const deleteReport = async () => {
publishDelete({event, relays: [url], protect: await shouldProtect})
onDelete?.()
}
</script>
<div class="column gap-4">
<div class="column gap-4 card2 card2-sm bg-alt">
<div class="flex justify-between">
<div>
<Profile pubkey={event.pubkey} {url} avatarSize={5} />
<ProfileName pubkey={event.pubkey} {url} />
<span>
Reported this event
{#if reason}
@@ -53,11 +45,7 @@
{/if}
</span>
</div>
{#if event.pubkey === $pubkey}
<Button class="btn-default btn" onclick={deleteReport}>Delete Report</Button>
{:else}
<ReportMenu {url} {event} />
{/if}
<ReportMenu {url} {event} {onDelete} />
</div>
{#if event.content}
<div class="border-l-2 border-primary pl-3">
+44 -19
View File
@@ -1,26 +1,32 @@
<script lang="ts">
import {getTag, ManagementMethod} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {manageRelay, repository, displayProfileByPubkey} from "@welshman/app"
import {pubkey, manageRelay, repository, displayProfileByPubkey} from "@welshman/app"
import InboxOut from "@assets/icons/inbox-out.svg?dataurl"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
import Close from "@assets/icons/close.svg?dataurl"
import {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte"
import Popover from "@lib/components/Popover.svelte"
import Button from "@lib/components/Button.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import {deriveUserIsSpaceAdmin} from "@app/core/state"
import {publishDelete, canEnforceNip70} from "@app/core/commands"
import {pushToast} from "@app/util/toast"
import {pushModal} from "@app/util/modal"
type Props = {
url: string
event: TrustedEvent
onDelete?: () => void
}
const {url, event}: Props = $props()
const {url, event, onDelete}: Props = $props()
const shouldProtect = canEnforceNip70(url)
const userIsAdmin = deriveUserIsSpaceAdmin(url)
const etag = getTag("e", event.tags)
const ptag = getTag("p", event.tags)
@@ -32,6 +38,11 @@
isOpen = false
}
const deleteReport = async () => {
publishDelete({event, relays: [url], protect: await shouldProtect})
onDelete?.()
}
const dismissReport = async () => {
const {error} = await manageRelay(url, {
method: ManagementMethod.BanEvent,
@@ -43,7 +54,7 @@
} else {
pushToast({message: "Content has successfully been deleted!"})
repository.removeEvent(event.id)
history.back()
onDelete?.()
}
}
@@ -51,7 +62,7 @@
const [_, id, reason = ""] = etag!
pushModal(Confirm, {
title: `Delete Content`,
title: `Remove Content`,
message: `Are you sure you want to delete this content from the space?`,
confirm: async () => {
const {error} = await manageRelay(url, {
@@ -63,15 +74,17 @@
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Content has successfully been deleted!"})
repository.removeEvent(event.id)
repository.removeEvent(id)
history.back()
setTimeout(() => onDelete?.())
}
},
})
}
const banMember = () => {
const [pubkey, reason = ""] = ptag!
const [_, pubkey, reason = ""] = ptag!
pushModal(Confirm, {
title: "Ban User",
@@ -86,7 +99,9 @@
pushToast({theme: "error", message: error})
} else {
pushToast({message: "User has successfully been banned!"})
repository.removeEvent(event.id)
history.back()
setTimeout(() => onDelete?.())
}
},
})
@@ -104,27 +119,37 @@
<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={dismissReport}>
<Icon icon={InboxOut} />
Dismiss Report
</Button>
</li>
{#if etag}
{#if event.pubkey === $pubkey}
<li>
<Button class="text-error" onclick={banContent}>
<Icon icon={TrashBin2} />
Remove Content
<Button onclick={deleteReport}>
<Icon icon={Close} />
Delete Report
</Button>
</li>
{/if}
{#if ptag}
{#if $userIsAdmin}
<li>
<Button class="text-error" onclick={banMember}>
<Icon icon={MinusCircle} />
Ban User
<Button onclick={dismissReport}>
<Icon icon={InboxOut} />
Dismiss Report
</Button>
</li>
{#if etag}
<li>
<Button class="text-error" onclick={banContent}>
<Icon icon={TrashBin2} />
Remove Content
</Button>
</li>
{/if}
{#if ptag}
<li>
<Button class="text-error" onclick={banMember}>
<Icon icon={MinusCircle} />
Ban User
</Button>
</li>
{/if}
{/if}
</ul>
</Popover>
+4 -4
View File
@@ -172,12 +172,12 @@
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Description</p>
<p class="flex flex-col items-start h-full">Description</p>
{/snippet}
{#snippet input()}
<label class="input input-bordered flex w-full items-center gap-2">
<input bind:value={values.description} class="grow" type="text" />
</label>
<textarea
bind:value={values.description}
class="min-h-24 textarea textarea-bordered flex w-full"></textarea>
{/snippet}
</FieldInline>
</ModalBody>
+7 -1
View File
@@ -22,6 +22,12 @@
const reports = deriveEventsForUrl(url, [{kinds: [REPORT]}])
const back = () => history.back()
const onDelete = () => {
if ($reports.length === 0) {
back()
}
}
</script>
<Modal>
@@ -32,7 +38,7 @@
</ModalHeader>
<div class="flex flex-col gap-2">
{#each $reports as event (event.id)}
<ReportItem {url} {event} />
<ReportItem {url} {event} {onDelete} />
{:else}
<p class="py-12 text-center">No reports found.</p>
{/each}
+4
View File
@@ -50,6 +50,7 @@ import {
makeDeriveItem,
deriveItemsByKey,
deriveDeduplicated,
deriveEventsById,
deriveEventsByIdByUrl,
deriveEventsByIdForUrl,
getEventsByIdForUrl,
@@ -231,6 +232,9 @@ export const deriveEvent = makeDeriveEvent({
onDerive: (filters: Filter[], relays: string[]) => load({filters, relays}),
})
export const deriveEvents = (filters: Filter[] = [{}]) =>
deriveEventsDesc(deriveEventsById({repository, filters}))
export const getEventsForUrl = (url: string, filters: Filter[] = [{}]) =>
getEventsByIdForUrl({url, tracker, repository, filters}).values()
+7 -5
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import cx from "classnames"
import type {Snippet} from "svelte"
interface Props {
@@ -8,12 +9,13 @@
}
let {children, element = $bindable(), ...props}: Props = $props()
const className = cx(
props.class,
"scroll-container cw cb fixed top-[calc(var(--sait)+5rem)] md:top-[calc(var(--sait)+3rem)] z-feature overflow-y-auto overflow-x-hidden",
)
</script>
<div
{...props}
bind:this={element}
data-component="PageContent"
class="scroll-container cw cb fixed top-[calc(var(--sait)+5rem)] md:top-[calc(var(--sait)+3rem)] z-feature overflow-y-auto overflow-x-hidden {props.class}">
<div {...props} bind:this={element} data-component="PageContent" class={className}>
{@render children?.()}
</div>
+193 -66
View File
@@ -1,77 +1,204 @@
<script lang="ts">
import {onMount} from "svelte"
import {goto} from "$app/navigation"
import {shouldUnwrap} from "@welshman/app"
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import Compass from "@assets/icons/compass.svg?dataurl"
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
import {derived, writable} from "svelte/store"
import {batch, call, sortBy, uniqBy} from "@welshman/lib"
import {
NOTE,
MESSAGE,
THREAD,
CLASSIFIED,
ZAP_GOAL,
EVENT_TIME,
COMMENT,
getTagValue,
getTagValues,
getIdAndAddress,
} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {
makeKindFeed,
makeRelayFeed,
makeScopeFeed,
makeIntersectionFeed,
makeUnionFeed,
Scope,
} from "@welshman/feeds"
import {repository, tracker, makeFeedController, loadUserFollowList} from "@welshman/app"
import History from "@assets/icons/history.svg?dataurl"
import {createScroller} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
import CardButton from "@lib/components/CardButton.svelte"
import SpaceAdd from "@app/components/SpaceAdd.svelte"
import ChatEnable from "@app/components/ChatEnable.svelte"
import {pushModal} from "@app/util/modal"
import {goToSpace} from "@app/util/routes"
import {PLATFORM_NAME, PLATFORM_RELAYS} from "@app/core/state"
import PageBar from "@lib/components/PageBar.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import NoteItem from "@app/components/NoteItem.svelte"
import ThreadItem from "@app/components/ThreadItem.svelte"
import ClassifiedItem from "@app/components/ClassifiedItem.svelte"
import GoalItem from "@app/components/GoalItem.svelte"
import CalendarEventItem from "@app/components/CalendarEventItem.svelte"
import RecentConversation from "@app/components/RecentConversation.svelte"
import {makeRoomId, userSpaceUrls, loadUserGroupList, CONTENT_KINDS} from "@app/core/state"
const addSpace = () => pushModal(SpaceAdd)
type Activity = {
type: "message" | "content"
event: TrustedEvent
timestamp: number
count: number
url: string
}
const openChat = () => ($shouldUnwrap ? goto("/chat") : pushModal(ChatEnable, {next: "/chat"}))
const controller = new AbortController()
const events = writable<TrustedEvent[]>([])
const limit = writable(0)
onMount(async () => {
if (PLATFORM_RELAYS.length > 0) {
goToSpace(PLATFORM_RELAYS[0])
const recentActivity = derived([events, limit], ([$events, $limit]) => {
const activity: Activity[] = []
const activityByRoom = new Map<string, Activity>()
const latestActivityByKey = new Map<string, number>()
for (const event of $events.slice(0, $limit)) {
if (event.kind === MESSAGE) {
const h = getTagValue("h", event.tags)
if (!h) continue
for (const url of tracker.getRelays(event.id)) {
const id = makeRoomId(url, h)
const item = activityByRoom.get(id)
if (!item) {
activityByRoom.set(id, {
type: "message",
event,
timestamp: event.created_at,
count: 1,
url,
})
} else if (item.timestamp < event.created_at) {
item.count++
item.event = event
item.timestamp = event.created_at
}
}
} else if (event.kind === COMMENT) {
for (const k of getTagValues(["E", "A"], event.tags)) {
latestActivityByKey.set(k, Math.max(latestActivityByKey.get(k) || 0, event.created_at))
}
} else {
for (const k of getIdAndAddress(event)) {
latestActivityByKey.set(k, Math.max(latestActivityByKey.get(k) || 0, event.created_at))
}
}
}
for (const item of activityByRoom.values()) {
activity.push(item)
}
for (const [address, timestamp] of latestActivityByKey.entries()) {
const event = repository.getEvent(address)
if (event) {
for (const url of tracker.getRelays(event.id)) {
activity.push({type: "content", event, timestamp, url, count: 1})
break
}
}
}
return sortBy(
a => -a.timestamp,
uniqBy(a => a.event.id, activity),
)
})
let loading = $state(true)
let element: Element | undefined = $state()
onMount(() => {
const promise = call(async () => {
await Promise.all([loadUserGroupList(), loadUserFollowList()])
const ctrl = makeFeedController({
useWindowing: true,
signal: controller.signal,
feed: makeUnionFeed(
makeIntersectionFeed(
makeRelayFeed(...$userSpaceUrls),
makeKindFeed(COMMENT, ...CONTENT_KINDS),
),
makeIntersectionFeed(makeScopeFeed(Scope.Follows), makeKindFeed(NOTE)),
),
onEvent: batch(100, (evts: TrustedEvent[]) => {
events.update($events => [...$events, ...evts])
}),
onExhausted: () => {
loading = false
},
})
const scroller = createScroller({
element: element!,
delay: 800,
threshold: 3000,
onScroll: async () => {
console.log("scroll")
limit.update($limit => {
if ($events.length - $limit < 50) {
ctrl.load(50)
}
return $limit + 10
})
},
})
return () => {
scroller.stop()
controller.abort()
}
})
return () => promise.then(call)
})
</script>
<div class="hero min-h-screen overflow-auto pb-8">
<div class="hero-content">
<div class="column content gap-4">
<h1 class="text-center text-5xl">Welcome to</h1>
<h1 class="mb-4 text-center text-5xl font-bold uppercase">{PLATFORM_NAME}</h1>
<div class="col-3">
<Button onclick={addSpace}>
<CardButton class="btn-neutral">
{#snippet icon()}
<Icon icon={AddCircle} size={7} />
{/snippet}
{#snippet title()}
<div>Add a space</div>
{/snippet}
{#snippet info()}
<div>Use an invite link, or create your own space.</div>
{/snippet}
</CardButton>
</Button>
<Link href="/discover">
<CardButton class="btn-neutral">
{#snippet icon()}
<Icon icon={Compass} size={7} />
{/snippet}
{#snippet title()}
<div>Browse the network</div>
{/snippet}
{#snippet info()}
<div>Find communities on the nostr network.</div>
{/snippet}
</CardButton>
</Link>
<Button onclick={openChat}>
<CardButton class="btn-neutral">
{#snippet icon()}
<Icon icon={ChatRound} size={7} />
{/snippet}
{#snippet title()}
<div>Start a conversation</div>
{/snippet}
{#snippet info()}
<div>Use nostr's encrypted group chats to stay in touch.</div>
{/snippet}
</CardButton>
</Button>
</div>
<PageBar>
{#snippet icon()}
<div class="center">
<Icon icon={History} />
</div>
</div>
</div>
{/snippet}
{#snippet title()}
<strong>Recent Activity</strong>
{/snippet}
{#snippet action()}
<div class="row-2"></div>
{/snippet}
</PageBar>
<PageContent class="flex flex-col gap-2 p-2 pt-4" bind:element>
{#each $recentActivity as { type, event, url, count } (event.id)}
{#if type === "message"}
<RecentConversation {url} {event} {count} />
{:else if event.kind === THREAD}
<ThreadItem {url} {event} />
{:else if event.kind === CLASSIFIED}
<ClassifiedItem {url} {event} />
{:else if event.kind === ZAP_GOAL}
<GoalItem {url} {event} />
{:else if event.kind === EVENT_TIME}
<CalendarEventItem {url} {event} />
{:else}
<NoteItem {url} {event} />
{/if}
{:else}
{#if loading}
<div class="flex justify-center items-center py-20">
<span class="loading loading-spinner mr-3"></span>
Loading recent activity...
</div>
{:else}
<p class="flex flex-col items-center py-20 text-center">No recent activity found!</p>
{/if}
{/each}
</PageContent>