Merge PinAdd and PinEdit into PinSave, fold the date range input into DateTimeInput, share chat titles through ChatTitle, and open profile and report menus with MenuButton

This commit is contained in:
Jon Staab 2026-09-25 22:07:43 -07:00
parent 97d1b1dc49
commit 78e866ad73
29 changed files with 411 additions and 639 deletions

View file

@ -168,8 +168,8 @@ relay's URL, each call signed with a fresh NIP-98 event. Every method resolves t
| Methods | Called from |
|---|---|
| `supportedMethods`, `listMethodAssignees`, `listBannedPubkeys` (cached) | `SpaceManagement` (`src/app/management.ts`) |
| `banPubkey`, `unbanPubkey`, `allowPubkey`, `unallowPubkey`, `listBannedPubkeys` | `ProfileDetail`, `SpaceMemberMenu`, `SpaceMemberBannedMenu`, `ReportMenuList`, `allowPubkeys` (`SpaceMembersAdd`, `RoleAddMembers`, `addRoomMembers`) |
| `banEvent` | `RoomItemMenu`, `EventMenu`, `ReportMenuList`, `RoomJoinItem` (dismissing a join request) |
| `banPubkey`, `unbanPubkey`, `allowPubkey`, `unallowPubkey`, `listBannedPubkeys` | `ProfileDetail`, `SpaceMemberMenu`, `SpaceMemberBannedMenu`, `ReportMenu`, `allowPubkeys` (`SpaceMembersAdd`, `RoleAddMembers`, `addRoomMembers`) |
| `banEvent` | `RoomItemMenu`, `EventMenu`, `ReportMenu`, `RoomJoinItem` (dismissing a join request) |
| `createRole`, `editRole`, `deleteRole`, `assignRole`, `unassignRole` | `RoleCreate`, `RoleEdit`, `SpaceRoleMenu`, `SpaceMemberRoles` (via `syncAssignments`), `RoleAddMembers` |
| `listClaims`, `createClaim` | `Access.prepareInvite` |
| `changeRelayName`, `changeRelayDescription`, `changeRelayIcon` | `SpaceEdit` |

View file

@ -16,7 +16,7 @@ and NIP-43 tables in [SKILL.md](SKILL.md). Routes are under `src/routes/`.
| Classifieds | `CLASSIFIED` (30402) | `Classified` | `src/app/classifieds.ts` | `spaces/[relay]/classifieds`, `classifieds/[address]` (`ClassifiedForm`) |
| Goals | `ZAP_GOAL` (9041) | `ZapGoal` | none | `spaces/[relay]/goals`, `goals/[id]` (`GoalCreate`) |
| Polls | `POLL` (1068), `POLL_RESPONSE` (1018) | `Poll`, `PollResponse` | none | `spaces/[relay]/polls`, `polls/[id]` (`PollCreate`, `PollVotes`) |
| Library | `PINBOARD` (30067), `PIN` (39067) | `Pinboard`, `Pin` | `src/app/pinboards.ts` | `spaces/[relay]/library` (`PinboardEdit`, `PinAdd`); published by any member |
| Library | `PINBOARD` (30067), `PIN` (39067) | `Pinboard`, `Pin` | `src/app/pinboards.ts` | `spaces/[relay]/library` (`PinboardEdit`, `PinSave`); published by any member |
| Room pins | `ROOM_PINS` (39005), `ROOM_UPDATE_PINS` (9010) | `RoomPins`, `RoomUpdatePins` | `src/app/roomPins.ts` | `RoomItemMenu`, `RoomItemMenuMobile`, `RoomPinnedMessagesAll` |
| Featured content | `APP_DATA` (30078), `d` = `flotilla/featured-content` | `AppData` | `src/app/featured.ts` | `SpaceFeaturedContent`; published as the relay |
| Bot commands (NIP-CD) | `COMMAND` (31992) | `Command` | `src/app/commands.ts` | `RoomCompose`, `ContentCommand` |
@ -28,8 +28,8 @@ and NIP-43 tables in [SKILL.md](SKILL.md). Routes are under `src/routes/`.
|---|---|---|---|---|
| Reactions | `REACTION` (7) | `Reaction`, through the `Reactions` plugin | `src/app/reactions.ts` | `RoomItem`, `EventReactButtons`, `ContentActions`, `CommentActions`, `ArticleActionBar` |
| Zaps | `ZAP_REQUEST` (9734), `ZAP_RECEIPT` (9735) | `ZapRequest`; receipts checked by `Zappers.validZapReceipts` | `src/app/lightning.ts` (wallets) | `Zap`, `ZapButton`, `GoalSummary`; off on iOS |
| Reports | `REPORT` (1984) | `Report` | `src/app/actionItems.ts` | `Report`, `ReportMenuList` |
| Deletes | `DELETE` (5) | `Delete`, through the `Deletes` plugin | none | `EventDeleteConfirm`, `ReportMenuList` |
| Reports | `REPORT` (1984) | `Report` | `src/app/actionItems.ts` | `Report`, `ReportMenu` |
| Deletes | `DELETE` (5) | `Delete`, through the `Deletes` plugin | none | `EventDeleteConfirm`, `ReportMenu` |
## Direct messages

View file

@ -10,9 +10,9 @@ PascalCase. This is what the qualifiers mean, drawn from the ~320 components the
| `Item` | one row or card in a list | `ClassifiedItem`, `NoteItem`, `PeopleItem`, `ReportItem`, `RoomItem` (a message in a room), `SpaceMenuRoomItem` |
| `Actions` | the footer row under an event: reactions, status, overflow menu | `ContentActions`, `CommentActions`, `EventActions` |
| `Menu` | the contents of a popover, taking an `onClick` that closes it | `EventMenu`, `ChatMenu`, `RoomItemMenu`, `SpaceMemberMenu` |
| `MenuList` | the popover contents when `*Menu` is the trigger instead | `ProfileMenu` → `ProfileMenuList`, `ReportMenu` → `ReportMenuList` |
| `Mobile` | the same actions as a modal, pushed on tap instead of hovered | `RoomItemMenuMobile`, `ChatMessageMenuMobile`, `SpaceMenuMobile` |
| `Create` / `Edit` | a modal that publishes a new or edited event | `ClassifiedCreate`, `RoomEdit`, `PinEdit` |
| `Create` / `Edit` | a modal that publishes a new or edited event | `ClassifiedCreate`, `RoomEdit`, `GoalCreate` |
| `Save` | one modal that creates or edits, depending on whether it's given the event | `PinSave` |
| `Form` | the fields shared by a Create/Edit pair | `ClassifiedForm`, `RoomForm`, `RoleForm` |
| `Detail` | a modal with everything known about an entity | `ProfileDetail`, `RoomDetail`, `ContentLinkDetail` |
| `Info` | a modal with an event's underlying data | `EventInfo`, `ProfileInfo` |
@ -22,7 +22,7 @@ PascalCase. This is what the qualifiers mean, drawn from the ~320 components the
| `Bar` | a horizontal strip across the top or bottom of something | `SpaceBar`, `ComposeBar`, `EventActionBar` |
| `Compose` | a message composer | `ChatCompose`, `RoomCompose`, `CommentCompose` |
| `Confirm` | the step that confirms an action or a code | `EventDeleteConfirm`, `LogInOTPConfirm`, `SignUpEmailConfirm` |
| `Add` | a modal that adds something to a collection | `SpaceAdd`, `PinAdd`, `RoomMembersAdd` |
| `Add` | a modal that adds something to a collection | `SpaceAdd`, `RoomMembersAdd` |
| `Select` | a picker, as a modal or a bindable input | `PinboardSelect`, `TopicMultiSelect` |
| `Button` | a self-contained trigger | `ZapButton`, `DictationButton`, `RoomItemEmojiButton` |
| `Name`, `Image`, `Icon`, `Link`, `Circle` | one identifier rendered | `RoomName`, `RelayIcon`, `ProfileLink`, `ProfileCircle` |

View file

@ -11,7 +11,7 @@
import Button from "@lib/components/Button.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import PinAdd from "@app/components/PinAdd.svelte"
import PinSave from "@app/components/PinSave.svelte"
import PinboardEdit from "@app/components/PinboardEdit.svelte"
import {deletes, relays, user} from "@app/core"
import {shareEvent} from "@app/share"
@ -30,7 +30,7 @@
const share = () => shareEvent(url, "Shelf", board.event)
const addLink = () => pushModal(PinAdd, {url, address: board.address()})
const addLink = () => pushModal(PinSave, {url, address: board.address()})
const edit = () => pushModal(PinboardEdit, {url, board})

View file

@ -14,7 +14,7 @@
import ModalFooter from "@lib/components/ModalFooter.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import DateTimeRangeInput from "@lib/components/DateTimeRangeInput.svelte"
import DateTimeInput from "@lib/components/DateTimeInput.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {publishToSpace} from "@app/rooms"
import {makeEditor} from "@app/editor"
@ -182,7 +182,7 @@
<p>Date and time*</p>
{/snippet}
{#snippet input()}
<DateTimeRangeInput bind:start bind:end />
<DateTimeInput isRange bind:value={start} bind:end />
{/snippet}
</Field>
<Field>

View file

@ -27,12 +27,12 @@
import PageContent from "@lib/components/PageContent.svelte"
import Divider from "@lib/components/Divider.svelte"
import Button from "@lib/components/Button.svelte"
import ProfileName from "@app/components/ProfileName.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import ChatMembers from "@app/components/ChatMembers.svelte"
import ChatTitle from "@app/components/ChatTitle.svelte"
import ChatMessage from "@app/components/ChatMessage.svelte"
import ComposeArea, {type ComposeValues} from "@app/components/ComposeArea.svelte"
import ThunkToast from "@app/components/ThunkToast.svelte"
@ -200,31 +200,16 @@
<div class="flex items-center justify-between gap-4">
<div class="truncate min-w-0 flex items-center gap-4 whitespace-nowrap">
<Button class="flex flex-col gap-1 sm:flex-row sm:gap-2" onclick={showMembers}>
{#if others.length === 0}
<div class="flex gap-2">
<ProfileCircle pubkey={$user.pubkey} size={5} />
<ProfileName pubkey={$user.pubkey} />
</div>
{:else if others.length === 1}
<div class="flex gap-2">
<ProfileCircle pubkey={others[0]} size={5} />
<ProfileName pubkey={others[0]} />
</div>
{:else}
<div class="flex items-center gap-2">
<div class="flex items-center gap-2">
{#if others.length > 1}
<ProfileCircles pubkeys={others} size={5} />
<p class="overflow-hidden text-ellipsis whitespace-nowrap">
<ProfileName pubkey={others[0]} />
and
{#if others.length === 2}
<ProfileName pubkey={others[1]} />
{:else}
{others.length - 1}
{others.length > 2 ? "others" : "other"}
{/if}
</p>
</div>
{/if}
{:else}
<ProfileCircle pubkey={others[0] || $user.pubkey} size={5} />
{/if}
<p class="overflow-hidden text-ellipsis whitespace-nowrap">
<ChatTitle {pubkeys} />
</p>
</div>
</Button>
</div>
</div>

View file

@ -6,7 +6,7 @@
import type {TrustedEvent} from "@welshman/util"
import {fade} from "@lib/transition"
import Button from "@lib/components/Button.svelte"
import ProfileName from "@app/components/ProfileName.svelte"
import ChatTitle from "@app/components/ChatTitle.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte"
import {makeChatPath, goToChat} from "@app/routes"
@ -45,20 +45,14 @@
<div class="flex flex-col justify-start gap-1">
<div class="flex items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-2">
{#if others.length === 0}
<ProfileCircle pubkey={$user.pubkey} size={5} />
Note to self
{:else if others.length === 1}
<ProfileCircle pubkey={others[0]} size={5} />
<ProfileName pubkey={others[0]} />
{:else}
{#if others.length > 1}
<ProfileCircles pubkeys={others} size={5} />
<p class="overflow-hidden text-ellipsis whitespace-nowrap">
<ProfileName pubkey={others[0]} />
and {others.length - 1}
{others.length > 2 ? "others" : "other"}
</p>
{:else}
<ProfileCircle pubkey={others[0] || $user.pubkey} size={5} />
{/if}
<p class="overflow-hidden text-ellipsis whitespace-nowrap">
<ChatTitle pubkeys={props.pubkeys} />
</p>
</div>
{#if !active && $notifications.has(path)}
<div class="h-2 w-2 rounded-full bg-primary text-primary-content" transition:fade></div>

View file

@ -0,0 +1,24 @@
<script lang="ts">
import {remove, uniq} from "@welshman/lib"
import ProfileName from "@app/components/ProfileName.svelte"
import {user} from "@app/core"
type Props = {
pubkeys: string[]
}
const {pubkeys}: Props = $props()
const others = $derived(uniq(remove($user.pubkey, pubkeys)))
</script>
{#if others.length > 0}
<ProfileName pubkey={others[0]} />
{#if others.length === 2}
and <ProfileName pubkey={others[1]} />
{:else if others.length > 2}
and {others.length - 1} others
{/if}
{:else}
Note to self
{/if}

View file

@ -1,7 +1,9 @@
<script lang="ts">
import HomeInboxItemCard from "@app/components/HomeInboxItemCard.svelte"
import HomeInboxItemChat from "@app/components/HomeInboxItemChat.svelte"
import HomeInboxItemRoom from "@app/components/HomeInboxItemRoom.svelte"
import HomeInboxItemSpaceChat from "@app/components/HomeInboxItemSpaceChat.svelte"
import RelayIcon from "@app/components/RelayIcon.svelte"
import RelayName from "@app/components/RelayName.svelte"
import RoomName from "@app/components/RoomName.svelte"
import type {Activity} from "@app/notifications"
type Props = {
@ -9,22 +11,26 @@
}
const {conversation}: Props = $props()
const {path, url, h, pubkeys = [], event} = $derived(conversation)
</script>
{#if conversation.url && conversation.h}
<HomeInboxItemRoom
path={conversation.path}
url={conversation.url}
h={conversation.h}
event={conversation.event} />
{:else if conversation.url}
<HomeInboxItemSpaceChat
path={conversation.path}
url={conversation.url}
event={conversation.event} />
{#if url}
<HomeInboxItemCard {path} {url} {event}>
{#snippet icon()}
<RelayIcon {url} size={9} class="shrink-0" />
{/snippet}
{#snippet title()}
{#if h}
<RoomName {url} {h} />
{:else}
Chat
{/if}
{/snippet}
{#snippet subtitle()}
<RelayName {url} />
{/snippet}
</HomeInboxItemCard>
{:else}
<HomeInboxItemChat
path={conversation.path}
pubkeys={conversation.pubkeys ?? []}
event={conversation.event} />
<HomeInboxItemChat {path} {pubkeys} {event} />
{/if}

View file

@ -2,8 +2,8 @@
import {remove, uniq} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import HomeInboxItemCard from "@app/components/HomeInboxItemCard.svelte"
import ChatTitle from "@app/components/ChatTitle.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileName from "@app/components/ProfileName.svelte"
import {user} from "@app/core"
type Props = {
@ -22,14 +22,7 @@
<ProfileCircle pubkey={others[0] || $user.pubkey} size={9} class="shrink-0" />
{/snippet}
{#snippet title()}
{#if others.length > 0}
<ProfileName pubkey={others[0]} />
{#if others.length > 1}
and {others.length - 1} {others.length > 2 ? "others" : "other"}
{/if}
{:else}
Note to self
{/if}
<ChatTitle {pubkeys} />
{/snippet}
{#snippet subtitle()}
direct message

View file

@ -1,28 +0,0 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import HomeInboxItemCard from "@app/components/HomeInboxItemCard.svelte"
import RelayIcon from "@app/components/RelayIcon.svelte"
import RelayName from "@app/components/RelayName.svelte"
import RoomName from "@app/components/RoomName.svelte"
type Props = {
path: string
url: string
h: string
event: TrustedEvent
}
const {path, url, h, event}: Props = $props()
</script>
<HomeInboxItemCard {path} {url} {event}>
{#snippet icon()}
<RelayIcon {url} size={9} class="shrink-0" />
{/snippet}
{#snippet title()}
<RoomName {url} {h} />
{/snippet}
{#snippet subtitle()}
<RelayName {url} />
{/snippet}
</HomeInboxItemCard>

View file

@ -1,26 +0,0 @@
<script lang="ts">
import type {TrustedEvent} from "@welshman/util"
import HomeInboxItemCard from "@app/components/HomeInboxItemCard.svelte"
import RelayIcon from "@app/components/RelayIcon.svelte"
import RelayName from "@app/components/RelayName.svelte"
type Props = {
path: string
url: string
event: TrustedEvent
}
const {path, url, event}: Props = $props()
</script>
<HomeInboxItemCard {path} {url} {event}>
{#snippet icon()}
<RelayIcon {url} size={9} class="shrink-0" />
{/snippet}
{#snippet title()}
Chat
{/snippet}
{#snippet subtitle()}
<RelayName {url} />
{/snippet}
</HomeInboxItemCard>

View file

@ -1,38 +0,0 @@
<script lang="ts">
import {Pin} from "@welshman/domain"
import {publish} from "@welshman/app"
import PinForm, {type PinFormValues} from "@app/components/PinForm.svelte"
import {command, writer} from "@app/core"
import {setPinReference} from "@app/pinboards"
import {toSpace} from "@app/rooms"
type Props = {
url: string
address: string
reference?: string
}
const {url, address, reference}: Props = $props()
const submit = async ({title, topics, value, content}: PinFormValues) => {
const eventWriter = await toSpace(writer(Pin).setIdentifier().addBoard(address), url)
if (!setPinReference(eventWriter, value)) {
return "Please enter a valid URL or nostr link."
}
eventWriter.setTitle(title).setTopics(topics).setContent(content)
const thunk = await command(eventWriter).then(publish)
return thunk.waitForError()
}
</script>
<PinForm
{url}
heading="Add Link"
action="Add link"
successMessage="Link added!"
values={{value: reference}}
{submit} />

View file

@ -1,43 +0,0 @@
<script lang="ts">
import {Pin} from "@welshman/domain"
import {publish} from "@welshman/app"
import type {PinReader} from "@welshman/domain"
import PinForm, {type PinFormValues} from "@app/components/PinForm.svelte"
import {command, writer} from "@app/core"
import {pinToReference, setPinReference} from "@app/pinboards"
import {toSpace} from "@app/rooms"
type Props = {
url: string
pin: PinReader
}
const {url, pin}: Props = $props()
const submit = async ({title, topics, value, content}: PinFormValues) => {
const eventWriter = await toSpace(writer(Pin, pin), url)
if (!setPinReference(eventWriter, value)) {
return "Please enter a valid URL or nostr link."
}
eventWriter.setTitle(title).setTopics(topics).setContent(content)
const thunk = await command(eventWriter).then(publish)
return thunk.waitForError()
}
</script>
<PinForm
{url}
heading="Edit Link"
action="Save changes"
successMessage="Link updated!"
values={{
title: pin.title(),
topics: pin.topics(),
value: pinToReference(pin),
content: pin.content(),
}}
{submit} />

View file

@ -10,7 +10,7 @@
import Button from "@lib/components/Button.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import EventInfo from "@app/components/EventInfo.svelte"
import PinEdit from "@app/components/PinEdit.svelte"
import PinSave from "@app/components/PinSave.svelte"
import {deletes, relays, user} from "@app/core"
import {shareEvent} from "@app/share"
import {pushModal} from "@app/modal"
@ -28,7 +28,7 @@
const share = () => shareEvent(url, "Link", pin.event)
const edit = () => pushModal(PinEdit, {url, pin})
const edit = () => pushModal(PinSave, {url, pin})
const deletePin = async () => {
try {

View file

@ -1,14 +1,8 @@
<script module lang="ts">
export type PinFormValues = {
title: string
topics: string[]
value: string
content: string
}
</script>
<script lang="ts">
import {writable} from "svelte/store"
import {Pin} from "@welshman/domain"
import {publish} from "@welshman/app"
import type {PinReader} from "@welshman/domain"
import {slide} from "@lib/transition"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
@ -26,42 +20,63 @@
import RelayName from "@app/components/RelayName.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {makeEditor} from "@app/editor"
import {command, writer} from "@app/core"
import {pinToReference, setPinReference} from "@app/pinboards"
import {toSpace} from "@app/rooms"
import {pushToast} from "@app/toast"
import {clearModals} from "@app/modal"
type Props = {
url: string
heading: string
action: string
successMessage: string
values?: Partial<PinFormValues>
submit: (values: PinFormValues) => Promise<string | undefined>
address?: string
reference?: string
pin?: PinReader
}
const {url, heading, action, successMessage, values = {}, submit}: Props = $props()
const {url, address, reference = "", pin}: Props = $props()
let title = $state(values.title ?? "")
let topics = $state<string[]>(values.topics ?? [])
let value = $state(values.value ?? "")
let content = $state<string | object>(values.content ?? "")
let title = $state(pin?.title() ?? "")
let topics = $state<string[]>(pin?.topics() ?? [])
let value = $state(pin ? pinToReference(pin) : reference)
let content = $state<string | object>(pin?.content() ?? "")
let loading = $state(false)
const uploading = writable(false)
const back = () => history.back()
const submit = async () => {
const eventWriter = await toSpace(
address ? writer(Pin).setIdentifier().addBoard(address) : writer(Pin, pin),
url,
)
if (!setPinReference(eventWriter, value)) {
return "Please enter a valid URL or nostr link."
}
const ed = await editor
eventWriter
.setTitle(title)
.setTopics(topics)
.setContent(ed.getText({blockSeparator: "\n"}).trim())
const thunk = await command(eventWriter).then(publish)
return thunk.waitForError()
}
const onSubmit = async () => {
loading = true
try {
const ed = await editor
const description = ed.getText({blockSeparator: "\n"}).trim()
const error = await submit({title, topics, value, content: description})
const error = await submit()
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: successMessage})
pushToast({message: pin ? "Link updated!" : "Link added!"})
clearModals()
}
} finally {
@ -81,7 +96,13 @@
<Modal>
<ModalBody>
<ModalHeader>
<ModalTitle>{heading}</ModalTitle>
<ModalTitle>
{#if pin}
Edit Link
{:else}
Add Link
{/if}
</ModalTitle>
<ModalSubtitle>on <RelayName {url} class="text-primary" /></ModalSubtitle>
</ModalHeader>
<Field>
@ -148,7 +169,13 @@
class="button button-primary"
onclick={onSubmit}
disabled={loading || $uploading || !value.trim()}>
<Spinner {loading}>{action}</Spinner>
<Spinner {loading}>
{#if pin}
Save changes
{:else}
Add link
{/if}
</Spinner>
</Button>
</ModalFooter>
</Modal>

View file

@ -14,7 +14,7 @@
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import RelayName from "@app/components/RelayName.svelte"
import PinAdd from "@app/components/PinAdd.svelte"
import PinSave from "@app/components/PinSave.svelte"
import {events, reader} from "@app/core"
import {eventToReference} from "@app/pinboards"
import {makeSpacePath} from "@app/routes"
@ -34,7 +34,7 @@
const reference = eventToReference(event)
const selectBoard = (board: PinboardReader) =>
pushModal(PinAdd, {url, address: board.address(), reference})
pushModal(PinSave, {url, address: board.address(), reference})
</script>
<Modal class="flex flex-col gap-2">

View file

@ -11,6 +11,7 @@
import Icon from "@lib/components/Icon.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import Button from "@lib/components/Button.svelte"
import MenuButton from "@lib/components/MenuButton.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
@ -100,45 +101,44 @@
<div class="flex flex-col gap-4">
<div class="flex justify-between">
<Profile showPubkey avatarSize={14} {pubkey} {url} />
<ProfileMenu {pubkey} {url}>
{#snippet customActions()}
{#if !isSelf}
{#snippet customActions()}
{#if !isSelf}
<li>
<Button onclick={sendMessage}>
<Icon size={4} icon={Letter} />
Send Message
</Button>
</li>
{/if}
{#if isBanned}
{#if $permissions.addMembers}
<li>
<Button onclick={sendMessage}>
<Icon size={4} icon={Letter} />
Send Message
<Button onclick={restoreMember}>
<Icon size={4} icon={Restart} />
Restore Membership
</Button>
</li>
{/if}
{#if isBanned}
{#if $permissions.addMembers}
<li>
<Button onclick={restoreMember}>
<Icon size={4} icon={Restart} />
Restore Membership
</Button>
</li>
{/if}
{:else}
{#if $permissions.removeMembers}
<li>
<Button onclick={removeMember}>
<Icon size={4} icon={UserMinus} />
Remove Member
</Button>
</li>
{/if}
{#if $permissions.ban}
<li>
<Button class="text-error" onclick={banMember}>
<Icon size={4} icon={MinusCircle} />
Ban User
</Button>
</li>
{/if}
{:else}
{#if $permissions.removeMembers}
<li>
<Button onclick={removeMember}>
<Icon size={4} icon={UserMinus} />
Remove Member
</Button>
</li>
{/if}
{/snippet}
</ProfileMenu>
{#if $permissions.ban}
<li>
<Button class="text-error" onclick={banMember}>
<Icon size={4} icon={MinusCircle} />
Ban User
</Button>
</li>
{/if}
{/if}
{/snippet}
<MenuButton component={ProfileMenu} componentProps={{pubkey, url, customActions}} />
</div>
<ProfileStatus {pubkey} {url} />
<ProfileAbout {pubkey} {url} />

View file

@ -1,34 +1,52 @@
<script lang="ts">
import {onMount} from "svelte"
import type {Snippet} from "svelte"
import type {Maybe} from "@welshman/lib"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import {removeUndefined} from "@welshman/lib"
import Code2 from "@assets/icons/code-2.svg?dataurl"
import ShareCircle from "@assets/icons/share-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Tippy from "@lib/components/Tippy.svelte"
import type {TippyController} from "@lib/components/Tippy.svelte"
import Button from "@lib/components/Button.svelte"
import ProfileMenuList from "@app/components/ProfileMenuList.svelte"
import ProfileInfo from "@app/components/ProfileInfo.svelte"
import ProfileQrCode from "@app/components/ProfileQrCode.svelte"
import {profiles} from "@app/core"
import {pushModal} from "@app/modal"
type Props = {
pubkey: string
url?: string
onClick: () => void
customActions?: Snippet
}
const {pubkey, url, customActions}: Props = $props()
const {pubkey, url, onClick, customActions}: Props = $props()
const showPopover = () => tippy?.show()
const profile = $profiles.one(pubkey, removeUndefined([url]))
const hidePopover = () => tippy?.hide()
const showInfo = () => pushModal(ProfileInfo, {event: $profile!.event, url})
let tippy: Maybe<TippyController> = $state()
const showShare = () => pushModal(ProfileQrCode, {pubkey})
let ul: Element
onMount(() => {
ul.addEventListener("click", onClick)
})
</script>
<Button onclick={showPopover} class="button button-circle button-ghost button-sm">
<Tippy
bind:controller={tippy}
component={ProfileMenuList}
props={{pubkey, url, customActions, onClick: hidePopover}}
params={{trigger: "manual", interactive: true, placement: "bottom-end"}}>
<Icon icon={MenuDots} />
</Tippy>
</Button>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
<li>
<Button onclick={showShare}>
<Icon size={4} icon={ShareCircle} />
Share
</Button>
</li>
{#if $profile}
<li>
<Button onclick={showInfo}>
<Icon size={4} icon={Code2} />
Profile Info
</Button>
</li>
{/if}
{@render customActions?.()}
</ul>

View file

@ -1,52 +0,0 @@
<script lang="ts">
import {onMount} from "svelte"
import type {Snippet} from "svelte"
import {removeUndefined} from "@welshman/lib"
import Code2 from "@assets/icons/code-2.svg?dataurl"
import ShareCircle from "@assets/icons/share-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import ProfileInfo from "@app/components/ProfileInfo.svelte"
import ProfileQrCode from "@app/components/ProfileQrCode.svelte"
import {profiles} from "@app/core"
import {pushModal} from "@app/modal"
type Props = {
pubkey: string
url?: string
onClick: () => void
customActions?: Snippet
}
const {pubkey, url, onClick, customActions}: Props = $props()
const profile = $profiles.one(pubkey, removeUndefined([url]))
const showInfo = () => pushModal(ProfileInfo, {event: $profile!.event, url})
const showShare = () => pushModal(ProfileQrCode, {pubkey})
let ul: Element
onMount(() => {
ul.addEventListener("click", onClick)
})
</script>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
<li>
<Button onclick={showShare}>
<Icon size={4} icon={ShareCircle} />
Share
</Button>
</li>
{#if $profile}
<li>
<Button onclick={showInfo}>
<Icon size={4} icon={Code2} />
Profile Info
</Button>
</li>
{/if}
{@render customActions?.()}
</ul>

View file

@ -11,6 +11,7 @@
import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte"
import MenuButton from "@lib/components/MenuButton.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ProfileAbout from "@app/components/ProfileAbout.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
@ -170,7 +171,7 @@
{/if}
<div class="shrink-0">
<ProfileMenu pubkey={target} />
<MenuButton component={ProfileMenu} componentProps={{pubkey: target}} />
</div>
</div>
{/if}

View file

@ -6,6 +6,7 @@
import Card from "@lib/components/Card.svelte"
import Cv from "@lib/components/Cv.svelte"
import Button from "@lib/components/Button.svelte"
import MenuButton from "@lib/components/MenuButton.svelte"
import Profile from "@app/components/Profile.svelte"
import ProfileName from "@app/components/ProfileName.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
@ -48,7 +49,7 @@
{/if}
</span>
</div>
<ReportMenu {url} {event} {onResolved} />
<MenuButton component={ReportMenu} componentProps={{url, event, onResolved}} />
</div>
{#if event.content}
<div class="border-l-2 pl-3" style="border-color: var(--primary)">

View file

@ -1,15 +1,131 @@
<script lang="ts">
import {onMount} from "svelte"
import type {TrustedEvent} from "@welshman/util"
import MenuButton from "@lib/components/MenuButton.svelte"
import ReportMenuList from "@app/components/ReportMenuList.svelte"
import {Report} from "@welshman/domain"
import InboxOut from "@assets/icons/inbox-out.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 Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import {app, deletes, profiles, reader, relayManagement, relays, user} from "@app/core"
import {deriveSpacePermissions} from "@app/management"
import {pushToast} from "@app/toast"
import {pushModal} from "@app/modal"
type Props = {
url: string
event: TrustedEvent
onResolved?: () => void
onClick: () => void
}
const {url, event, onResolved}: Props = $props()
const {url, event, onResolved, onClick}: Props = $props()
const permissions = deriveSpacePermissions(url)
const report = reader(Report)(event)
const reportedId = report.eventId()
const reportedPubkey = report.pubkey()
const reason = report.reason() ?? ""
const deleteReport = async () => {
const protect = await $relays.hasNip(url, 70)
const command = await $deletes.deleteEvent(event, w => w.setProtected(protect))
command.publishToRelays([url])
onResolved?.()
}
const dismissReport = async () => {
const {error} = await $relayManagement.forUrl(url).banEvent(event.id, "Dismissed by admin")
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Report has successfully been dismissed!"})
$app.repository.removeEvent(event.id)
onResolved?.()
}
}
const banContent = () => {
pushModal(Confirm, {
title: `Remove Content`,
message: `Are you sure you want to delete this content from the space?`,
confirm: async () => {
const {error} = await $relayManagement.forUrl(url).banEvent(reportedId!, reason)
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Content has successfully been deleted!"})
$app.repository.removeEvent(event.id)
$app.repository.removeEvent(reportedId!)
history.back()
setTimeout(() => onResolved?.())
}
},
})
}
const banMember = () => {
pushModal(Confirm, {
title: "Ban User",
message: `Are you sure you want to ban @${$profiles.display(reportedPubkey!, [url]).get()} from the space?`,
confirm: async () => {
const {error} = await $relayManagement.forUrl(url).banPubkey(reportedPubkey!, reason)
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "User has successfully been banned!"})
$app.repository.removeEvent(event.id)
history.back()
setTimeout(() => onResolved?.())
}
},
})
}
let ul: Element
onMount(() => {
ul.addEventListener("click", onClick)
})
</script>
<MenuButton component={ReportMenuList} componentProps={{url, event, onResolved}} />
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
{#if event.pubkey === $user.pubkey}
<li>
<Button onclick={deleteReport}>
<Icon icon={Close} />
Delete Report
</Button>
</li>
{/if}
{#if $permissions.deleteContent}
<li>
<Button onclick={dismissReport}>
<Icon icon={InboxOut} />
Dismiss Report
</Button>
</li>
{#if reportedId}
<li>
<Button class="text-error" onclick={banContent}>
<Icon icon={TrashBin2} />
Remove Content
</Button>
</li>
{/if}
{/if}
{#if reportedPubkey && $permissions.ban}
<li>
<Button class="text-error" onclick={banMember}>
<Icon icon={MinusCircle} />
Ban User
</Button>
</li>
{/if}
</ul>

View file

@ -1,131 +0,0 @@
<script lang="ts">
import {onMount} from "svelte"
import type {TrustedEvent} from "@welshman/util"
import {Report} from "@welshman/domain"
import InboxOut from "@assets/icons/inbox-out.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 Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import {app, deletes, profiles, reader, relayManagement, relays, user} from "@app/core"
import {deriveSpacePermissions} from "@app/management"
import {pushToast} from "@app/toast"
import {pushModal} from "@app/modal"
type Props = {
url: string
event: TrustedEvent
onResolved?: () => void
onClick: () => void
}
const {url, event, onResolved, onClick}: Props = $props()
const permissions = deriveSpacePermissions(url)
const report = reader(Report)(event)
const reportedId = report.eventId()
const reportedPubkey = report.pubkey()
const reason = report.reason() ?? ""
const deleteReport = async () => {
const protect = await $relays.hasNip(url, 70)
const command = await $deletes.deleteEvent(event, w => w.setProtected(protect))
command.publishToRelays([url])
onResolved?.()
}
const dismissReport = async () => {
const {error} = await $relayManagement.forUrl(url).banEvent(event.id, "Dismissed by admin")
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Report has successfully been dismissed!"})
$app.repository.removeEvent(event.id)
onResolved?.()
}
}
const banContent = () => {
pushModal(Confirm, {
title: `Remove Content`,
message: `Are you sure you want to delete this content from the space?`,
confirm: async () => {
const {error} = await $relayManagement.forUrl(url).banEvent(reportedId!, reason)
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Content has successfully been deleted!"})
$app.repository.removeEvent(event.id)
$app.repository.removeEvent(reportedId!)
history.back()
setTimeout(() => onResolved?.())
}
},
})
}
const banMember = () => {
pushModal(Confirm, {
title: "Ban User",
message: `Are you sure you want to ban @${$profiles.display(reportedPubkey!, [url]).get()} from the space?`,
confirm: async () => {
const {error} = await $relayManagement.forUrl(url).banPubkey(reportedPubkey!, reason)
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "User has successfully been banned!"})
$app.repository.removeEvent(event.id)
history.back()
setTimeout(() => onResolved?.())
}
},
})
}
let ul: Element
onMount(() => {
ul.addEventListener("click", onClick)
})
</script>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
{#if event.pubkey === $user.pubkey}
<li>
<Button onclick={deleteReport}>
<Icon icon={Close} />
Delete Report
</Button>
</li>
{/if}
{#if $permissions.deleteContent}
<li>
<Button onclick={dismissReport}>
<Icon icon={InboxOut} />
Dismiss Report
</Button>
</li>
{#if reportedId}
<li>
<Button class="text-error" onclick={banContent}>
<Icon icon={TrashBin2} />
Remove Content
</Button>
</li>
{/if}
{/if}
{#if reportedPubkey && $permissions.ban}
<li>
<Button class="text-error" onclick={banMember}>
<Icon icon={MinusCircle} />
Ban User
</Button>
</li>
{/if}
</ul>

View file

@ -13,9 +13,9 @@
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ChatTitle from "@app/components/ChatTitle.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte"
import ProfileName from "@app/components/ProfileName.svelte"
import RelayName from "@app/components/RelayName.svelte"
import RoomNameWithImage from "@app/components/RoomNameWithImage.svelte"
import {chatSearch} from "@app/chats"
@ -95,19 +95,12 @@
onclick={() => toggle(path)}>
{#if others.length > 1}
<ProfileCircles pubkeys={others} size={5} />
<span class="min-w-0 truncate">
<ProfileName pubkey={others[0]} />
+{others.length - 1}
</span>
{:else if others.length === 1}
<ProfileCircle pubkey={others[0]} size={5} />
<span class="min-w-0 truncate">
<ProfileName pubkey={others[0]} />
</span>
{:else}
<ProfileCircle pubkey={$user.pubkey} size={5} />
Note to self
<ProfileCircle pubkey={others[0] || $user.pubkey} size={5} />
{/if}
<span class="min-w-0 truncate">
<ChatTitle pubkeys={chat.pubkeys} />
</span>
</Button>
{/each}
</div>

View file

@ -3,28 +3,16 @@
import {DatePicker} from "@svelte-plugins/datepicker"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
import {anchorDatepicker} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
type Props = {
value?: number | undefined
end?: number | undefined
isRange?: boolean
}
let {value = $bindable()}: Props = $props()
// The picker works in millisecond timestamps, and we expose unix seconds.
let element: HTMLElement
let isOpen = $state(false)
let startDate: number | undefined = $state(value ? value * 1000 : undefined)
let startDateTime = $state("")
// With no date the library initializes its time input from the epoch.
onMount(() => {
if (!startDate) {
startDateTime = "12:00"
}
})
let {value = $bindable(), end = $bindable(), isRange = false}: Props = $props()
const toggle = () => {
isOpen = !isOpen
@ -38,24 +26,93 @@
const fmt = (ts: number) =>
new Date(ts).toLocaleString([], {dateStyle: "medium", timeStyle: "short"})
const display = $derived(startDate ? fmt(startDate) : "")
// Fixed positioning so the popup escapes scroll-container clipping.
const anchor = () => {
const reposition = () => {
const label = element.querySelector("label")
const container = element.querySelector<HTMLElement>(".calendars-container")
if (label && container) {
const margin = 8
const rect = label.getBoundingClientRect()
const height = container.offsetHeight
const width = container.offsetWidth
let top = rect.bottom + 4
// flip above the input when there's not enough room below, else pin to the bottom edge
if (height && top + height > innerHeight - margin) {
top =
rect.top - height - 4 >= margin
? rect.top - height - 4
: Math.max(margin, innerHeight - height - margin)
}
const left = width
? Math.max(margin, Math.min(rect.left, innerWidth - width - margin))
: rect.left
element.style.setProperty("--datepicker-container-position", "fixed")
element.style.setProperty("--datepicker-container-top", `${top}px`)
element.style.setProperty("--datepicker-container-left", `${left}px`)
}
}
reposition()
addEventListener("scroll", reposition, true)
addEventListener("resize", reposition)
return () => {
removeEventListener("scroll", reposition, true)
removeEventListener("resize", reposition)
}
}
// The picker works in millisecond timestamps, and we expose unix seconds.
let element: HTMLElement
let isOpen = $state(false)
let startDate: number | undefined = $state(value ? value * 1000 : undefined)
let endDate: number | undefined = $state(end ? end * 1000 : undefined)
let startDateTime = $state("")
let endDateTime = $state("")
const display = $derived(
startDate ? (endDate ? `${fmt(startDate)} – ${fmt(endDate)}` : fmt(startDate)) : "",
)
$effect(() => {
if (isOpen && element) {
return anchorDatepicker(element)
return anchor()
}
})
$effect(() => {
value = startDate ? Math.round(startDate / 1000) : undefined
})
$effect(() => {
end = endDate ? Math.round(endDate / 1000) : undefined
})
// With no date the library initializes its time inputs from the epoch.
onMount(() => {
if (!startDate) {
startDateTime = "12:00"
}
if (!endDate) {
endDateTime = "13:00"
}
})
</script>
<div class="relative focus-within:z-modal" bind:this={element}>
<DatePicker
bind:isOpen
bind:startDate
bind:endDate
bind:startDateTime
bind:endDateTime
{isRange}
showTimePicker
enableFutureDates
includeFont={false}>
@ -65,10 +122,10 @@
type="text"
readonly
class="grow cursor-pointer"
placeholder="Select date"
placeholder={isRange ? "Select dates" : "Select date"}
value={display}
onclick={toggle} />
{#if startDate}
{#if startDate && !isRange}
<Button onclick={clear} class="h-5">
<Icon icon={CloseCircle} />
</Button>

View file

@ -1,81 +0,0 @@
<script lang="ts">
import {onMount} from "svelte"
import {DatePicker} from "@svelte-plugins/datepicker"
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
import {anchorDatepicker} from "@lib/html"
import Icon from "@lib/components/Icon.svelte"
type Props = {
start?: number | undefined
end?: number | undefined
}
let {start = $bindable(), end = $bindable()}: Props = $props()
// The picker works in millisecond timestamps, and we expose unix seconds.
let element: HTMLElement
let isOpen = $state(false)
let startDate: number | undefined = $state(start ? start * 1000 : undefined)
let endDate: number | undefined = $state(end ? end * 1000 : undefined)
let startDateTime = $state("")
let endDateTime = $state("")
// With no date the library initializes its time inputs from the epoch.
onMount(() => {
if (!startDate) {
startDateTime = "12:00"
}
if (!endDate) {
endDateTime = "13:00"
}
})
const toggle = () => {
isOpen = !isOpen
}
const fmt = (ts: number) =>
new Date(ts).toLocaleString([], {dateStyle: "medium", timeStyle: "short"})
const display = $derived(
startDate ? (endDate ? `${fmt(startDate)} – ${fmt(endDate)}` : fmt(startDate)) : "",
)
$effect(() => {
if (isOpen && element) {
return anchorDatepicker(element)
}
})
$effect(() => {
start = startDate ? Math.round(startDate / 1000) : undefined
})
$effect(() => {
end = endDate ? Math.round(endDate / 1000) : undefined
})
</script>
<div class="relative focus-within:z-modal" bind:this={element}>
<DatePicker
bind:isOpen
bind:startDate
bind:endDate
bind:startDateTime
bind:endDateTime
isRange
showTimePicker
enableFutureDates
includeFont={false}>
<label class="input input-group cursor-pointer">
<Icon icon={CalendarMinimalistic} />
<input
type="text"
readonly
class="grow cursor-pointer"
placeholder="Select dates"
value={display}
onclick={toggle} />
</label>
</DatePicker>
</div>

View file

@ -26,50 +26,6 @@ export const documentActive = readable(
},
)
// Fixed positioning so an @svelte-plugins/datepicker popup escapes scroll-container clipping.
export const anchorDatepicker = (wrapper: HTMLElement) => {
const reposition = () => {
const anchor = wrapper.querySelector("label")
const container = wrapper.querySelector(".calendars-container") as HTMLElement | null
if (!anchor || !container) {
return
}
const margin = 8
const rect = anchor.getBoundingClientRect()
const height = container.offsetHeight
const width = container.offsetWidth
let top = rect.bottom + 4
// flip above the input when there's not enough room below, else pin to the bottom edge
if (height && top + height > innerHeight - margin) {
top =
rect.top - height - 4 >= margin
? rect.top - height - 4
: Math.max(margin, innerHeight - height - margin)
}
const left = width
? Math.max(margin, Math.min(rect.left, innerWidth - width - margin))
: rect.left
wrapper.style.setProperty("--datepicker-container-position", "fixed")
wrapper.style.setProperty("--datepicker-container-top", `${top}px`)
wrapper.style.setProperty("--datepicker-container-left", `${left}px`)
}
reposition()
addEventListener("scroll", reposition, true)
addEventListener("resize", reposition)
return () => {
removeEventListener("scroll", reposition, true)
removeEventListener("resize", reposition)
}
}
export const copyToClipboard = (text: string) => {
const {activeElement} = document
const input = document.createElement("textarea")

View file

@ -18,7 +18,7 @@
import Spinner from "@lib/components/Spinner.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
import PinItem from "@app/components/PinItem.svelte"
import PinAdd from "@app/components/PinAdd.svelte"
import PinSave from "@app/components/PinSave.svelte"
import PinboardItem from "@app/components/PinboardItem.svelte"
import PinboardEdit from "@app/components/PinboardEdit.svelte"
import {app, events, network, reader} from "@app/core"
@ -61,7 +61,7 @@
const createBoard = () => pushModal(PinboardEdit, {url})
const addLink = () => pushModal(PinAdd, {url, address})
const addLink = () => pushModal(PinSave, {url, address})
const selectBoard = (board: PinboardReader) =>
goto(makeLibraryPath(url, board.address()), {replaceState: true, noScroll: true})