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:
parent
97d1b1dc49
commit
78e866ad73
29 changed files with 411 additions and 639 deletions
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
24
src/app/components/ChatTitle.svelte
Normal file
24
src/app/components/ChatTitle.svelte
Normal 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}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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} />
|
||||
|
|
@ -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} />
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
Loading…
Reference in a new issue