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 | | Methods | Called from |
|---|---| |---|---|
| `supportedMethods`, `listMethodAssignees`, `listBannedPubkeys` (cached) | `SpaceManagement` (`src/app/management.ts`) | | `supportedMethods`, `listMethodAssignees`, `listBannedPubkeys` (cached) | `SpaceManagement` (`src/app/management.ts`) |
| `banPubkey`, `unbanPubkey`, `allowPubkey`, `unallowPubkey`, `listBannedPubkeys` | `ProfileDetail`, `SpaceMemberMenu`, `SpaceMemberBannedMenu`, `ReportMenuList`, `allowPubkeys` (`SpaceMembersAdd`, `RoleAddMembers`, `addRoomMembers`) | | `banPubkey`, `unbanPubkey`, `allowPubkey`, `unallowPubkey`, `listBannedPubkeys` | `ProfileDetail`, `SpaceMemberMenu`, `SpaceMemberBannedMenu`, `ReportMenu`, `allowPubkeys` (`SpaceMembersAdd`, `RoleAddMembers`, `addRoomMembers`) |
| `banEvent` | `RoomItemMenu`, `EventMenu`, `ReportMenuList`, `RoomJoinItem` (dismissing a join request) | | `banEvent` | `RoomItemMenu`, `EventMenu`, `ReportMenu`, `RoomJoinItem` (dismissing a join request) |
| `createRole`, `editRole`, `deleteRole`, `assignRole`, `unassignRole` | `RoleCreate`, `RoleEdit`, `SpaceRoleMenu`, `SpaceMemberRoles` (via `syncAssignments`), `RoleAddMembers` | | `createRole`, `editRole`, `deleteRole`, `assignRole`, `unassignRole` | `RoleCreate`, `RoleEdit`, `SpaceRoleMenu`, `SpaceMemberRoles` (via `syncAssignments`), `RoleAddMembers` |
| `listClaims`, `createClaim` | `Access.prepareInvite` | | `listClaims`, `createClaim` | `Access.prepareInvite` |
| `changeRelayName`, `changeRelayDescription`, `changeRelayIcon` | `SpaceEdit` | | `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`) | | 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`) | | 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`) | | 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` | | 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 | | 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` | | 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` | | 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 | | 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` | | Reports | `REPORT` (1984) | `Report` | `src/app/actionItems.ts` | `Report`, `ReportMenu` |
| Deletes | `DELETE` (5) | `Delete`, through the `Deletes` plugin | none | `EventDeleteConfirm`, `ReportMenuList` | | Deletes | `DELETE` (5) | `Delete`, through the `Deletes` plugin | none | `EventDeleteConfirm`, `ReportMenu` |
## Direct messages ## 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` | | `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` | | `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` | | `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` | | `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` | | `Form` | the fields shared by a Create/Edit pair | `ClassifiedForm`, `RoomForm`, `RoleForm` |
| `Detail` | a modal with everything known about an entity | `ProfileDetail`, `RoomDetail`, `ContentLinkDetail` | | `Detail` | a modal with everything known about an entity | `ProfileDetail`, `RoomDetail`, `ContentLinkDetail` |
| `Info` | a modal with an event's underlying data | `EventInfo`, `ProfileInfo` | | `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` | | `Bar` | a horizontal strip across the top or bottom of something | `SpaceBar`, `ComposeBar`, `EventActionBar` |
| `Compose` | a message composer | `ChatCompose`, `RoomCompose`, `CommentCompose` | | `Compose` | a message composer | `ChatCompose`, `RoomCompose`, `CommentCompose` |
| `Confirm` | the step that confirms an action or a code | `EventDeleteConfirm`, `LogInOTPConfirm`, `SignUpEmailConfirm` | | `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` | | `Select` | a picker, as a modal or a bindable input | `PinboardSelect`, `TopicMultiSelect` |
| `Button` | a self-contained trigger | `ZapButton`, `DictationButton`, `RoomItemEmojiButton` | | `Button` | a self-contained trigger | `ZapButton`, `DictationButton`, `RoomItemEmojiButton` |
| `Name`, `Image`, `Icon`, `Link`, `Circle` | one identifier rendered | `RoomName`, `RelayIcon`, `ProfileLink`, `ProfileCircle` | | `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 Button from "@lib/components/Button.svelte"
import Confirm from "@lib/components/Confirm.svelte" import Confirm from "@lib/components/Confirm.svelte"
import EventInfo from "@app/components/EventInfo.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 PinboardEdit from "@app/components/PinboardEdit.svelte"
import {deletes, relays, user} from "@app/core" import {deletes, relays, user} from "@app/core"
import {shareEvent} from "@app/share" import {shareEvent} from "@app/share"
@ -30,7 +30,7 @@
const share = () => shareEvent(url, "Shelf", board.event) 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}) const edit = () => pushModal(PinboardEdit, {url, board})

View file

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

View file

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

View file

@ -6,7 +6,7 @@
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {fade} from "@lib/transition" import {fade} from "@lib/transition"
import Button from "@lib/components/Button.svelte" 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 ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte" import ProfileCircles from "@app/components/ProfileCircles.svelte"
import {makeChatPath, goToChat} from "@app/routes" import {makeChatPath, goToChat} from "@app/routes"
@ -45,20 +45,14 @@
<div class="flex flex-col justify-start gap-1"> <div class="flex flex-col justify-start gap-1">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-2"> <div class="flex min-w-0 items-center gap-2">
{#if others.length === 0} {#if others.length > 1}
<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}
<ProfileCircles pubkeys={others} size={5} /> <ProfileCircles pubkeys={others} size={5} />
<p class="overflow-hidden text-ellipsis whitespace-nowrap"> {:else}
<ProfileName pubkey={others[0]} /> <ProfileCircle pubkey={others[0] || $user.pubkey} size={5} />
and {others.length - 1}
{others.length > 2 ? "others" : "other"}
</p>
{/if} {/if}
<p class="overflow-hidden text-ellipsis whitespace-nowrap">
<ChatTitle pubkeys={props.pubkeys} />
</p>
</div> </div>
{#if !active && $notifications.has(path)} {#if !active && $notifications.has(path)}
<div class="h-2 w-2 rounded-full bg-primary text-primary-content" transition:fade></div> <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"> <script lang="ts">
import HomeInboxItemCard from "@app/components/HomeInboxItemCard.svelte"
import HomeInboxItemChat from "@app/components/HomeInboxItemChat.svelte" import HomeInboxItemChat from "@app/components/HomeInboxItemChat.svelte"
import HomeInboxItemRoom from "@app/components/HomeInboxItemRoom.svelte" import RelayIcon from "@app/components/RelayIcon.svelte"
import HomeInboxItemSpaceChat from "@app/components/HomeInboxItemSpaceChat.svelte" import RelayName from "@app/components/RelayName.svelte"
import RoomName from "@app/components/RoomName.svelte"
import type {Activity} from "@app/notifications" import type {Activity} from "@app/notifications"
type Props = { type Props = {
@ -9,22 +11,26 @@
} }
const {conversation}: Props = $props() const {conversation}: Props = $props()
const {path, url, h, pubkeys = [], event} = $derived(conversation)
</script> </script>
{#if conversation.url && conversation.h} {#if url}
<HomeInboxItemRoom <HomeInboxItemCard {path} {url} {event}>
path={conversation.path} {#snippet icon()}
url={conversation.url} <RelayIcon {url} size={9} class="shrink-0" />
h={conversation.h} {/snippet}
event={conversation.event} /> {#snippet title()}
{:else if conversation.url} {#if h}
<HomeInboxItemSpaceChat <RoomName {url} {h} />
path={conversation.path} {:else}
url={conversation.url} Chat
event={conversation.event} /> {/if}
{/snippet}
{#snippet subtitle()}
<RelayName {url} />
{/snippet}
</HomeInboxItemCard>
{:else} {:else}
<HomeInboxItemChat <HomeInboxItemChat {path} {pubkeys} {event} />
path={conversation.path}
pubkeys={conversation.pubkeys ?? []}
event={conversation.event} />
{/if} {/if}

View file

@ -2,8 +2,8 @@
import {remove, uniq} from "@welshman/lib" import {remove, uniq} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import HomeInboxItemCard from "@app/components/HomeInboxItemCard.svelte" import HomeInboxItemCard from "@app/components/HomeInboxItemCard.svelte"
import ChatTitle from "@app/components/ChatTitle.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte" import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileName from "@app/components/ProfileName.svelte"
import {user} from "@app/core" import {user} from "@app/core"
type Props = { type Props = {
@ -22,14 +22,7 @@
<ProfileCircle pubkey={others[0] || $user.pubkey} size={9} class="shrink-0" /> <ProfileCircle pubkey={others[0] || $user.pubkey} size={9} class="shrink-0" />
{/snippet} {/snippet}
{#snippet title()} {#snippet title()}
{#if others.length > 0} <ChatTitle {pubkeys} />
<ProfileName pubkey={others[0]} />
{#if others.length > 1}
and {others.length - 1} {others.length > 2 ? "others" : "other"}
{/if}
{:else}
Note to self
{/if}
{/snippet} {/snippet}
{#snippet subtitle()} {#snippet subtitle()}
direct message 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 Button from "@lib/components/Button.svelte"
import Confirm from "@lib/components/Confirm.svelte" import Confirm from "@lib/components/Confirm.svelte"
import EventInfo from "@app/components/EventInfo.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 {deletes, relays, user} from "@app/core"
import {shareEvent} from "@app/share" import {shareEvent} from "@app/share"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
@ -28,7 +28,7 @@
const share = () => shareEvent(url, "Link", pin.event) const share = () => shareEvent(url, "Link", pin.event)
const edit = () => pushModal(PinEdit, {url, pin}) const edit = () => pushModal(PinSave, {url, pin})
const deletePin = async () => { const deletePin = async () => {
try { 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"> <script lang="ts">
import {writable} from "svelte/store" 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 {slide} from "@lib/transition"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import GallerySend from "@assets/icons/gallery-send.svg?dataurl" import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
@ -26,42 +20,63 @@
import RelayName from "@app/components/RelayName.svelte" import RelayName from "@app/components/RelayName.svelte"
import EditorContent from "@app/editor/EditorContent.svelte" import EditorContent from "@app/editor/EditorContent.svelte"
import {makeEditor} from "@app/editor" 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 {pushToast} from "@app/toast"
import {clearModals} from "@app/modal" import {clearModals} from "@app/modal"
type Props = { type Props = {
url: string url: string
heading: string address?: string
action: string reference?: string
successMessage: string pin?: PinReader
values?: Partial<PinFormValues>
submit: (values: PinFormValues) => Promise<string | undefined>
} }
const {url, heading, action, successMessage, values = {}, submit}: Props = $props() const {url, address, reference = "", pin}: Props = $props()
let title = $state(values.title ?? "") let title = $state(pin?.title() ?? "")
let topics = $state<string[]>(values.topics ?? []) let topics = $state<string[]>(pin?.topics() ?? [])
let value = $state(values.value ?? "") let value = $state(pin ? pinToReference(pin) : reference)
let content = $state<string | object>(values.content ?? "") let content = $state<string | object>(pin?.content() ?? "")
let loading = $state(false) let loading = $state(false)
const uploading = writable(false) const uploading = writable(false)
const back = () => history.back() 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 () => { const onSubmit = async () => {
loading = true loading = true
try { try {
const ed = await editor const error = await submit()
const description = ed.getText({blockSeparator: "\n"}).trim()
const error = await submit({title, topics, value, content: description})
if (error) { if (error) {
pushToast({theme: "error", message: error}) pushToast({theme: "error", message: error})
} else { } else {
pushToast({message: successMessage}) pushToast({message: pin ? "Link updated!" : "Link added!"})
clearModals() clearModals()
} }
} finally { } finally {
@ -81,7 +96,13 @@
<Modal> <Modal>
<ModalBody> <ModalBody>
<ModalHeader> <ModalHeader>
<ModalTitle>{heading}</ModalTitle> <ModalTitle>
{#if pin}
Edit Link
{:else}
Add Link
{/if}
</ModalTitle>
<ModalSubtitle>on <RelayName {url} class="text-primary" /></ModalSubtitle> <ModalSubtitle>on <RelayName {url} class="text-primary" /></ModalSubtitle>
</ModalHeader> </ModalHeader>
<Field> <Field>
@ -148,7 +169,13 @@
class="button button-primary" class="button button-primary"
onclick={onSubmit} onclick={onSubmit}
disabled={loading || $uploading || !value.trim()}> disabled={loading || $uploading || !value.trim()}>
<Spinner {loading}>{action}</Spinner> <Spinner {loading}>
{#if pin}
Save changes
{:else}
Add link
{/if}
</Spinner>
</Button> </Button>
</ModalFooter> </ModalFooter>
</Modal> </Modal>

View file

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

View file

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

View file

@ -1,34 +1,52 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte"
import type {Snippet} from "svelte" import type {Snippet} from "svelte"
import type {Maybe} from "@welshman/lib" import {removeUndefined} from "@welshman/lib"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl" 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 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 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 = { type Props = {
pubkey: string pubkey: string
url?: string url?: string
onClick: () => void
customActions?: Snippet 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> </script>
<Button onclick={showPopover} class="button button-circle button-ghost button-sm"> <ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
<Tippy <li>
bind:controller={tippy} <Button onclick={showShare}>
component={ProfileMenuList} <Icon size={4} icon={ShareCircle} />
props={{pubkey, url, customActions, onClick: hidePopover}} Share
params={{trigger: "manual", interactive: true, placement: "bottom-end"}}> </Button>
<Icon icon={MenuDots} /> </li>
</Tippy> {#if $profile}
</Button> <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 Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import MenuButton from "@lib/components/MenuButton.svelte"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import ProfileAbout from "@app/components/ProfileAbout.svelte" import ProfileAbout from "@app/components/ProfileAbout.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte" import ProfileCircle from "@app/components/ProfileCircle.svelte"
@ -170,7 +171,7 @@
{/if} {/if}
<div class="shrink-0"> <div class="shrink-0">
<ProfileMenu pubkey={target} /> <MenuButton component={ProfileMenu} componentProps={{pubkey: target}} />
</div> </div>
</div> </div>
{/if} {/if}

View file

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

View file

@ -1,15 +1,131 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import MenuButton from "@lib/components/MenuButton.svelte" import {Report} from "@welshman/domain"
import ReportMenuList from "@app/components/ReportMenuList.svelte" 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 = { type Props = {
url: string url: string
event: TrustedEvent event: TrustedEvent
onResolved?: () => void 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> </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 ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte"
import ChatTitle from "@app/components/ChatTitle.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte" import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte" import ProfileCircles from "@app/components/ProfileCircles.svelte"
import ProfileName from "@app/components/ProfileName.svelte"
import RelayName from "@app/components/RelayName.svelte" import RelayName from "@app/components/RelayName.svelte"
import RoomNameWithImage from "@app/components/RoomNameWithImage.svelte" import RoomNameWithImage from "@app/components/RoomNameWithImage.svelte"
import {chatSearch} from "@app/chats" import {chatSearch} from "@app/chats"
@ -95,19 +95,12 @@
onclick={() => toggle(path)}> onclick={() => toggle(path)}>
{#if others.length > 1} {#if others.length > 1}
<ProfileCircles pubkeys={others} size={5} /> <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} {:else}
<ProfileCircle pubkey={$user.pubkey} size={5} /> <ProfileCircle pubkey={others[0] || $user.pubkey} size={5} />
Note to self
{/if} {/if}
<span class="min-w-0 truncate">
<ChatTitle pubkeys={chat.pubkeys} />
</span>
</Button> </Button>
{/each} {/each}
</div> </div>

View file

@ -3,28 +3,16 @@
import {DatePicker} from "@svelte-plugins/datepicker" import {DatePicker} from "@svelte-plugins/datepicker"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl" import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl" import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
import {anchorDatepicker} from "@lib/html"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
type Props = { type Props = {
value?: number | undefined value?: number | undefined
end?: number | undefined
isRange?: boolean
} }
let {value = $bindable()}: Props = $props() let {value = $bindable(), end = $bindable(), isRange = false}: 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"
}
})
const toggle = () => { const toggle = () => {
isOpen = !isOpen isOpen = !isOpen
@ -38,24 +26,93 @@
const fmt = (ts: number) => const fmt = (ts: number) =>
new Date(ts).toLocaleString([], {dateStyle: "medium", timeStyle: "short"}) 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(() => { $effect(() => {
if (isOpen && element) { if (isOpen && element) {
return anchorDatepicker(element) return anchor()
} }
}) })
$effect(() => { $effect(() => {
value = startDate ? Math.round(startDate / 1000) : undefined 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> </script>
<div class="relative focus-within:z-modal" bind:this={element}> <div class="relative focus-within:z-modal" bind:this={element}>
<DatePicker <DatePicker
bind:isOpen bind:isOpen
bind:startDate bind:startDate
bind:endDate
bind:startDateTime bind:startDateTime
bind:endDateTime
{isRange}
showTimePicker showTimePicker
enableFutureDates enableFutureDates
includeFont={false}> includeFont={false}>
@ -65,10 +122,10 @@
type="text" type="text"
readonly readonly
class="grow cursor-pointer" class="grow cursor-pointer"
placeholder="Select date" placeholder={isRange ? "Select dates" : "Select date"}
value={display} value={display}
onclick={toggle} /> onclick={toggle} />
{#if startDate} {#if startDate && !isRange}
<Button onclick={clear} class="h-5"> <Button onclick={clear} class="h-5">
<Icon icon={CloseCircle} /> <Icon icon={CloseCircle} />
</Button> </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) => { export const copyToClipboard = (text: string) => {
const {activeElement} = document const {activeElement} = document
const input = document.createElement("textarea") const input = document.createElement("textarea")

View file

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