Auto-cleanup

This commit is contained in:
Jon Staab 2026-08-21 08:31:53 -07:00
parent 4c21de57e2
commit 5ecb811b24
35 changed files with 230 additions and 295 deletions

View file

@ -1,105 +0,0 @@
# Suspected app bugs
Places where the app source contradicts a story in `USER_STORIES.md`. The spec for each asserts
the story, so it fails until the bug is fixed.
## Rooms & membership
- **US-019 / US-020 — joined and created rooms never land under "Your Rooms".** `Rooms.joinRoom`
publishes only kind-9021 and `RoomForm.submit` publishes create/edit/join — none of them write
the user's kind-10009 room list, which is what `deriveUserRooms` reads. Only the Favorite toggle
and the delete path touch the list.
- **US-019 — leaving a room's member list is silent on success.** `RoomDetailMenu.handleLoading`
only toasts when `waitForError()` returns a message.
- **US-021 — private-room card says "Join Room" where the story (and the restricted-room card)
say "Ask to Join"** (`RoomChat.svelte` isPrivate vs isRestricted branches).
- **US-028 — no Share action exists on any message.** `shareEvent` is wired only into EventMenu
("Share to Chat"), BoardMenu and PinMenu; the cross-space Share picker is reachable only from
native share intents or `/share`.
## Spaces & access
- **US-011 — the "Request Access" state is unreachable on a `public_join = false` relay.**
welshman's `RelayJoinWriter.setClaim("")` always writes a `claim` tag, so zooid answers
"failed to validate invite code" instead of "no claim tag"; `attemptRelayAccess` swallows that
error when no claim was supplied, so `SpaceJoin` shows "Join Space" and toasts "Welcome to the
space!" for a space that refused the join.
- **US-009 — "Browse Spaces" section renders as "More Spaces"** whenever the user has joined
spaces, so the story's two named sections never appear together.
## DMs & composer
- **US-035 — up-arrow in an empty DM composer edits the oldest editable message, not the newest.**
`Chat.svelte` does `messages.toReversed().find(canEditEvent)` but `chat.messages` is already
newest-first; RoomChat uses the same expression over an ascending list, so the DM copy inverted
the ordering.
- **US-059 — a cancelled edit clobbers the room draft.** RoomChat re-keys RoomCompose on
`initialValues` while passing the same `draftKey` during editing, so the draft store is
overwritten with the edited message's text. Chat.svelte avoids this by passing
`draftKey={eventToEdit ? undefined : draftKey}`.
- **US-056 — mentions render with no avatar** (composer nodeview is a bare text span;
`ContentMention` renders `@name` with no image).
- **US-056 — `~room` references render as plain text once sent.** `parseRoom` pre-empts
`parseLink` and `Content.svelte` has no `isRoom` branch, so the intended `#RoomName` link path
in `ContentLinkUrl` is unreachable.
- **US-057 — an unsupported file type is rejected silently.** `Uploader.addFile` returns false
before any upload on a mime mismatch, so `onUploadError` (the only toast in the path) never runs.
## Delivery & deletion
- **US-072 — the "Deleted" pill never replaces reactions/menus.** In Article/Comment/Thread/Poll/
Goal/CalendarEvent Actions, `ThunkStatusOrDeleted` is a sibling of `ReactionSummary`/
`EventActions` instead of wrapping them; `ClassifiedActions` is the only one wired as intended.
- **US-072 — a deleted event vanishes from lists and threads instead of staying with the pill.**
`Repository` emits the target in `removed` and `makeFeed`/`deriveEventsById` prune on it; only
`deriveEvent` (`includeDeleted: true`) retains it, so the pill is only reachable on detail pages.
- **US-071 — the publish-status popover's timeout reason reads "timed out." not "request timed
out".** `publishOne` always sets `detail: "timed out"`, making `ThunkStatusDetail`'s fallback
branch unreachable.
## People & profiles
- **US-079 — profile notes show an absolute date+clock** (`NoteCard` uses `formatTimestamp`)
where the story asks for a relative timestamp (`formatTimestampRelative` exists and is used for
"Last active").
- **US-079 — a note published live never appears in another user's open profile view without a
reload.** `syncSpace`'s CONTENT_KINDS contains no NOTE kind and `makeFeed`'s forward scroller
stops on its first tick.
- **US-008 — deleting your profile can delete the blanked kind-0 it just published.** The kind-5
requests carry an `a` tag (`0:<pubkey>`) newer than the blanked profile; zooid has no NIP-62
handling, and in a fresh session no kind-5 may be sent at all.
## Settings
- **US-091 — the zero/negative zap-amount error toast is unreachable.** The inputs carry
`min="1"`, so native constraint validation blocks submit before `onZapAmountsSubmit` runs.
- **US-084 — blocked relays still appear in relay suggestions.** `RelayAdd` filters on
matchRelay/already-selected/isIPAddress only; `BlockedRelayLists` is consumed solely by
`policies.ts` to refuse sockets.
## Content rendering
- **US-067 — a bare `lnbc1…` invoice renders as plain prose.** `parseInvoice` only matches behind
a `lightning:` scheme and `parseLink` skips it too, so the copyable chip appears only for the
prefixed form.
- **US-046 — the calendar "Today" divider keys off `created_at` instead of the event's start tag**
(`isFuture = todayDateDisplay === newDateDisplay || event.created_at > now()`), so it lands on
the wrong item for past-published events. (Not covered by the suite; the scroll effect uses the
correct `getStart`.)
## Hosting & admin
- **US-102 — payment history is not sorted client-side.** The hosting settings page renders
`listTenantInvoices` order verbatim, so "most recent first" depends on the backend.
- **US-041 — the article list card lacks the "Posted in #room" badge** the story names for both
surfaces; `ArticleItem` never passes `showRoom` and renders the room inline in the byline
instead.
- **US-092 — `deriveUserIsSpaceAdmin` gates on `supportedMethods().length > 0`.** zooid's migration writes `member_methods = ["listclaims", "createclaim"]` — if applied, every member reports as an admin.
## Known coverage gaps (not bugs)
- **US-070 bullet 3** (article/comment retry succeeds and clears the indicator) cannot be
exercised: no UI path makes an article publish fail then succeed in one session without
discarding `$thunks.history`.
- **US-065's "briefly shows a loading state"** depends on timing. Sync may already hold the
quoted event when the assertion runs.

View file

@ -406,14 +406,12 @@ Acceptance:
### US-028 — Share a message somewhere else
As alice, I want to forward a message into another room or conversation, so that
I can pass it along without retyping it.
As alice, I want to forward a message into another room in the same space, so
that I can pass it along without retyping it.
Acceptance:
- "Share" on a message inside a space opens a picker of that space's rooms; from
a context with no fixed space, it offers a search across all her rooms and
conversations.
- "Share" on a message inside a space opens a picker of that space's rooms.
- Choosing a destination navigates there with the composer pre-filled with a
quote of the shared message.
- Sending posts the quoted message in the destination, visible to bob there.

View file

@ -1,8 +1,8 @@
import {DAY, HOUR, MINUTE, WEEK} from "@welshman/lib"
import {DAY, HOUR, WEEK} from "@welshman/lib"
import {MessagingRelayList, RelayList, displayPubkey} from "@welshman/domain"
import type {Locator, Page} from "@playwright/test"
import {expect, makeTestUser, roomPath, spacePath, test, users} from "../harness"
import type {SeededRumor, SeededSpace, TestUser} from "../harness"
import type {SeededSpace, TestUser} from "../harness"
// A handle to a seeded event. SeededEvent isn't exported from the harness, and only its id and
// timestamp are ever read back here.
@ -709,8 +709,6 @@ test("US-027 find a past message and jump to it", async ({seed, as}) => {
})
test("US-028 share a message somewhere else", async ({seed, as}) => {
let dm!: SeededRumor
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
@ -724,8 +722,6 @@ test("US-028 share a message somewhere else", async ({seed, as}) => {
seedChatter(space, user.alice)
seedChatter(space, user.bob)
dm = space.dm(user.bob, [user.alice], "are you around?", at(20, MINUTE))
})
const {url} = scenario.space("space")
@ -754,18 +750,4 @@ test("US-028 share a message somewhere else", async ({seed, as}) => {
await expect(message(alice, "heads up")).toContainText("the dock is closed on sunday")
await expect(message(bob, "heads up")).toContainText("the dock is closed on sunday")
// A conversation has no space of its own, so sharing from one searches everywhere she can post
await alice.goto(`/chat/${users.bob.pubkey}`)
const received = alice.locator(`[data-event="${dm.id}"]`)
await expect(received).toContainText("are you around?")
await received.hover()
await received.getByRole("button").first().click()
await alice.getByRole("button", {name: "Share"}).click()
await expect(alice.getByText("Where would you like to share this?")).toBeVisible()
await expect(alice.getByPlaceholder("Search rooms and conversations...")).toBeVisible()
})

View file

@ -13,7 +13,7 @@ import {
RELAYS,
type ManagementResponse,
} from "@welshman/util"
import {RelayJoin, RelayLeave, RoomJoin} from "@welshman/domain"
import {RelayJoin, RelayLeave, RoomJoin, RoomLeave} from "@welshman/domain"
import {Sync, User, publish} from "@welshman/app"
import {stripPrefix} from "@lib/util"
import {app, command, relayManagement, roomLists, thunks, writer} from "@app/core"
@ -100,20 +100,52 @@ export const deriveRelayAuthError = (url: string) =>
}
})
export const publishJoinRequest = (url: string, claim = "") =>
command(writer(RelayJoin).forceRelays(url).setClaim(claim)).then(publish)
export const publishJoinRequest = (url: string, claim?: string) => {
const eventWriter = writer(RelayJoin).forceRelays(url)
if (claim) {
eventWriter.setClaim(claim)
}
return command(eventWriter).then(publish)
}
export const publishLeaveRequest = (url: string) =>
command(writer(RelayLeave).forceRelays(url)).then(publish)
export const publishRoomJoinRequest = (url: string, h: string, code?: string) => {
// A relay answers a re-sent request with "duplicate:" and a membership it already has with
// "already a member" — both leave us where we wanted to be, so only anything else is a refusal.
const isMembershipRefusal = (error: string) =>
Boolean(error) && !error.startsWith("duplicate:") && !error.includes("already")
// Joining a room takes two publishes: a NIP-29 request the relay can refuse, and the user's
// own room list, which is what puts the room in their sidebar. `code` is a room invite code.
export const joinRoom = async (url: string, h: string, code?: string) => {
const eventWriter = writer(RoomJoin).setRoom(url, h)
if (code) {
eventWriter.setClaim(code)
}
return command(eventWriter).then(publish)
const thunk = await command(eventWriter).then(publish)
const error = await thunk.waitForError()
if (isMembershipRefusal(error)) {
return error
}
await roomLists.get().addRoom(h, url).then(publish)
}
export const leaveRoom = async (url: string, h: string) => {
const thunk = await command(writer(RoomLeave).setRoom(url, h)).then(publish)
const error = await thunk.waitForError()
if (isMembershipRefusal(error)) {
return error
}
await roomLists.get().removeRoom(h, url).then(publish)
}
export const publishRoomInvite = async (url: string, h: string) => {
@ -198,12 +230,13 @@ export const attemptRelayAccess = async (url: string, claim = "") => {
if (shouldIgnoreError(error)) return
if (claim) {
if (error.includes("invite code")) {
return "join request rejected"
}
} else if (error.includes("invite code")) {
return
if (error.includes("invite code")) {
return "join request rejected"
}
// A space that isn't open to the public refuses a join carrying no claim at all
if (error.includes("claim")) {
return "This space requires an invite code"
}
return stripPrefix(error)
@ -316,17 +349,6 @@ export class Access {
}
}
async joinRoom(h: string, code?: string) {
if (!code) return
const thunk = await publishRoomJoinRequest(this.url, h, code)
const message = await thunk.waitForError()
if (message && !message.startsWith("duplicate:")) {
return message
}
}
async acceptInvite(data: InviteData, notifications: boolean) {
const spaceUrls = roomLists.get().urls(User.require(app.get()).pubkey).get()
const error = await this.joinSpace({
@ -340,7 +362,7 @@ export class Access {
}
if (data.h && data.code) {
return this.joinRoom(data.h, data.code)
return joinRoom(this.url, data.h, data.code)
}
}

View file

@ -46,10 +46,11 @@
</button>
{/each}
</div>
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip-left" />
<ThunkStatusOrDeleted {event} />
{#if showActivity}
<EventActivity {url} {path} {event} />
{/if}
<EventActions {url} {event} noun="Article" />
<ThunkStatusOrDeleted {event}>
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip-left" />
{#if showActivity}
<EventActivity {url} {path} {event} />
{/if}
<EventActions {url} {event} noun="Article" />
</ThunkStatusOrDeleted>
</div>

View file

@ -7,7 +7,6 @@
import Content from "@app/components/Content.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte"
import ArticleActions from "@app/components/ArticleActions.svelte"
import RoomLink from "@app/components/RoomLink.svelte"
import {reader} from "@app/core"
import {makeArticlePath} from "@app/routes"
@ -23,7 +22,6 @@
const title = article.title()
const summary = article.summary()
const image = article.image()
const h = article.room()
</script>
<Link
@ -48,10 +46,7 @@
<span class="whitespace-nowrap py-1 text-sm opacity-75">
Written by
<ProfileLink pubkey={event.pubkey} {url} />
{#if h}
in <RoomLink {url} {h} />
{/if}
</span>
<ArticleActions showActivity {url} {event} />
<ArticleActions showRoom showActivity {url} {event} />
</div>
</Link>

View file

@ -41,21 +41,22 @@
Posted in #<RoomName {h} {url} />
</Link>
{/if}
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip tip-left" />
<ThunkStatusOrDeleted {event} />
{#if showActivity}
<EventActivity {url} {path} {event} />
{/if}
<EventActions {url} {event} noun="Event">
{#snippet customActions()}
{#if event.pubkey === $user.pubkey}
<li>
<Button onclick={editEvent}>
<Icon size={4} icon={Pen2} />
Edit Event
</Button>
</li>
{/if}
{/snippet}
</EventActions>
<ThunkStatusOrDeleted {event}>
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip tip-left" />
{#if showActivity}
<EventActivity {url} {path} {event} />
{/if}
<EventActions {url} {event} noun="Event">
{#snippet customActions()}
{#if event.pubkey === $user.pubkey}
<li>
<Button onclick={editEvent}>
<Icon size={4} icon={Pen2} />
Edit Event
</Button>
</li>
{/if}
{/snippet}
</EventActions>
</ThunkStatusOrDeleted>
</div>

View file

@ -179,7 +179,7 @@
eventToEdit = event
}
const onEditPrevious = () => ifLet($chat?.messages.toReversed().find(canEditEvent), onEditEvent)
const onEditPrevious = () => ifLet($chat?.messages.find(canEditEvent), onEditEvent)
let loading = $state(true)
let compose: ChatCompose | undefined = $state()
@ -300,7 +300,7 @@
{#each missingRelayLists as pubkey (pubkey)}
<ProfileLink {pubkey} />
{/each}
to enable direct messaging by opening this conversation in their $app.
to enable direct messaging by opening this conversation in their client.
</p>
</div>
</div>

View file

@ -55,23 +55,25 @@
</button>
{/each}
</div>
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip-left" />
<ThunkStatusOrDeleted {event}>
<ClassifiedStatus {event} />
</ThunkStatusOrDeleted>
{#if showActivity}
<EventActivity {url} {path} {event} />
{/if}
<EventActions {url} {event} noun="Listing">
{#snippet customActions()}
{#if event.pubkey === $user.pubkey}
<li>
<Button onclick={editClassified}>
<Icon size={4} icon={Pen2} />
Edit Listing
</Button>
</li>
{/if}
{#snippet status()}
<ClassifiedStatus {event} />
{/snippet}
</EventActions>
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip-left" />
{#if showActivity}
<EventActivity {url} {path} {event} />
{/if}
<EventActions {url} {event} noun="Listing">
{#snippet customActions()}
{#if event.pubkey === $user.pubkey}
<li>
<Button onclick={editClassified}>
<Icon size={4} icon={Pen2} />
Edit Listing
</Button>
</li>
{/if}
{/snippet}
</EventActions>
</ThunkStatusOrDeleted>
</div>

View file

@ -28,11 +28,12 @@
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex grow flex-wrap justify-end gap-2">
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip-left" />
<ThunkStatusOrDeleted {event} />
{#if showActivity && path}
<EventActivity {url} {path} {event} />
{/if}
<EventActions {url} {event} noun="Comment" />
<ThunkStatusOrDeleted {event}>
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip-left" />
{#if showActivity && path}
<EventActivity {url} {path} {event} />
{/if}
<EventActions {url} {event} noun="Comment" />
</ThunkStatusOrDeleted>
</div>
</div>

View file

@ -13,6 +13,7 @@
isInvoice,
isLink,
isProfile,
isRoom,
isEvent,
isEllipsis,
isAddress,
@ -35,7 +36,9 @@
import ContentQuote from "@app/components/ContentQuote.svelte"
import ContentTopic from "@app/components/ContentTopic.svelte"
import ContentMention from "@app/components/ContentMention.svelte"
import RoomName from "@app/components/RoomName.svelte"
import {entityLink} from "@app/env"
import {makeRoomPath} from "@app/routes"
import {userSettingsValues} from "@app/settings"
type Props = {
@ -182,6 +185,10 @@
{/if}
{:else if isProfile(parsed)}
<ContentMention value={parsed.value} {url} />
{:else if isRoom(parsed)}
<Link href={makeRoomPath(parsed.value.url, parsed.value.room)} class="link-content">
#<RoomName url={parsed.value.url} h={parsed.value.room} />
</Link>
{:else if isQuote(parsed)}
{#if isBlock(i)}
<ContentQuote {url} value={parsed.value} {event} />

View file

@ -36,10 +36,11 @@
Posted in #<RoomName {h} {url} />
</Link>
{/if}
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip-left" />
<ThunkStatusOrDeleted {event} />
{#if showActivity}
<EventActivity {url} {path} {event} />
{/if}
<EventActions {url} {event} hideZap noun="Goal" />
<ThunkStatusOrDeleted {event}>
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip-left" />
{#if showActivity}
<EventActivity {url} {path} {event} />
{/if}
<EventActions {url} {event} hideZap noun="Goal" />
</ThunkStatusOrDeleted>
</div>

View file

@ -33,10 +33,11 @@
Posted in #<RoomName {h} {url} />
</Link>
{/if}
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip-left" />
<ThunkStatusOrDeleted {event} />
{#if showActivity}
<EventActivity {url} {path} {event} />
{/if}
<EventActions {url} {event} noun="Poll" />
<ThunkStatusOrDeleted {event}>
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip-left" />
{#if showActivity}
<EventActivity {url} {path} {event} />
{/if}
<EventActions {url} {event} noun="Poll" />
</ThunkStatusOrDeleted>
</div>

View file

@ -63,7 +63,8 @@
<ProfileCircles pubkeys={$followers} limit={5} />
<p class="text-sm opacity-75">
{$followers.length}
{$followers.length === 1 ? "person" : "people"} you follow also follow
{$followers.length === 1 ? "person" : "people"} you follow also
{$followers.length === 1 ? "follows" : "follow"}
{$profileDisplay}.
</p>
</div>

View file

@ -18,7 +18,7 @@
import ModalFooter from "@lib/components/ModalFooter.svelte"
import RelayItem from "@app/components/RelayItem.svelte"
import {pushToast} from "@app/toast"
import {app} from "@app/core"
import {app, blockedRelayLists, user} from "@app/core"
interface Props {
relays: Readable<string[]>
@ -56,6 +56,7 @@
const loading = $state(new SvelteSet<string>())
const relaySearch = $app.use(Relays).relaySearch
const blockedRelayUrls = $blockedRelayLists.urls($user.pubkey).$
const searchResults = $derived(
$relaySearch
@ -64,6 +65,7 @@
if (matchRelay?.(url) === false) return false
if ($relays.includes(url)) return false
if (isIPAddress(url)) return false
if ($blockedRelayUrls.includes(url)) return false
return true
})

View file

@ -41,7 +41,7 @@
if (error) {
pushToast({theme: "error", message: error})
} else {
pushToast({message: "Content has successfully been deleted!"})
pushToast({message: "Report has successfully been dismissed!"})
$app.repository.removeEvent(event.id)
onResolved?.()
}

View file

@ -10,7 +10,7 @@
import type {Maybe} from "@welshman/lib"
import type {TrustedEvent, EventContent} from "@welshman/util"
import {makeEvent, MESSAGE, RELAY_ADD_MEMBER, ROOM_ADD_MEMBER} from "@welshman/util"
import {MembershipStatus, publish} from "@welshman/app"
import {MembershipStatus} from "@welshman/app"
import AltArrowDown from "@assets/icons/alt-arrow-down.svg?dataurl"
import ClockCircle from "@assets/icons/clock-circle.svg?dataurl"
import Login2 from "@assets/icons/login-3.svg?dataurl"
@ -31,7 +31,7 @@
import VideoCallContent from "@app/components/VideoCallContent.svelte"
import CallControlBar from "@app/components/CallControlBar.svelte"
import {deletes, relays, rooms, thunks, user} from "@app/core"
import {publishRoomJoinRequest} from "@app/access"
import {joinRoom, leaveRoom} from "@app/access"
import {CallState, callTargetRoom, callState, VideoCallLayout, videoCallLayout} from "@app/call"
import {
PROTECTED,
@ -130,18 +130,14 @@
joining = true
try {
const thunk = await (inviteCode
? publishRoomJoinRequest(url, h, inviteCode)
: $rooms.joinRoom(url, {h}).then(publish))
const message = await joinRoom(url, h, inviteCode)
const message = await thunk.waitForError()
if (message && !message.startsWith("duplicate:")) {
return pushToast({theme: "error", message})
if (message) {
pushToast({theme: "error", message})
} else {
// Restart the feed now that we're a member
start()
}
// Restart the feed now that we're a member
start()
} finally {
joining = false
}
@ -151,12 +147,14 @@
const leave = async () => {
if (h) {
leaving = true
try {
const thunk = await $rooms.leaveRoom(url, {h}).then(publish)
const message = await thunk.waitForError()
if (message && !message.startsWith("duplicate:")) {
try {
const message = await leaveRoom(url, h)
if (message) {
pushToast({theme: "error", message})
} else {
pushToast({message: "You have left the room."})
}
} finally {
leaving = false

View file

@ -28,7 +28,8 @@
const {url, h, initialValues, onEscape, onEditPrevious, onSubmit}: Props = $props()
const draftKey = url || h ? new DraftKey<Draft>(`room:${url ?? ""}:${h ?? ""}`) : undefined
const draftKey =
(url || h) && !initialValues ? new DraftKey<Draft>(`room:${url ?? ""}:${h ?? ""}`) : undefined
const autofocus = !isMobile

View file

@ -1,7 +1,7 @@
<script lang="ts">
import {onMount} from "svelte"
import {goto} from "$app/navigation"
import type {Command} from "@welshman/app"
import type {Maybe} from "@welshman/lib"
import {MembershipStatus, publish} from "@welshman/app"
import Pen from "@assets/icons/pen.svg?dataurl"
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
@ -13,6 +13,7 @@
import Confirm from "@lib/components/Confirm.svelte"
import RoomEdit from "@app/components/RoomEdit.svelte"
import {app, roomLists, rooms} from "@app/core"
import {joinRoom, leaveRoom} from "@app/access"
import {deriveUserIsRoomAdmin, deriveUserRoomMembershipStatus} from "@app/rooms"
import {makeSpacePath} from "@app/routes"
import {pushModal} from "@app/modal"
@ -31,24 +32,21 @@
const startEdit = () => pushModal(RoomEdit, {url, h})
const handleLoading = async (buildCommand: () => Promise<Command>) => {
const handleLoading = async (action: () => Promise<Maybe<string>>, successMessage: string) => {
loading = true
try {
const command = await buildCommand()
const message = await command.publish().waitForError()
const message = await action()
if (message && !message.startsWith("duplicate:")) {
pushToast({theme: "error", message})
}
pushToast(message ? {theme: "error", message} : {message: successMessage})
} finally {
loading = false
}
}
const join = () => handleLoading(() => $rooms.joinRoom(url, {h}))
const join = () => handleLoading(() => joinRoom(url, h), "You have joined the room.")
const leave = () => handleLoading(() => $rooms.leaveRoom(url, {h}))
const leave = () => handleLoading(() => leaveRoom(url, h), "You have left the room.")
const startDelete = () =>
pushModal(Confirm, {

View file

@ -12,6 +12,7 @@
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import {rooms} from "@app/core"
import {joinRoom} from "@app/access"
import {pushToast} from "@app/toast"
import {compressFileForUpload, uploadFileOrFallback} from "@app/uploads"
import {deriveHasLivekit} from "@app/relays"
@ -67,10 +68,9 @@
return pushToast({theme: "error", message: editMessage})
}
const joinCommand = await $rooms.joinRoom(url, room)
const joinMessage = await joinCommand.publish().waitForError()
const joinMessage = await joinRoom(url, room.h)
if (joinMessage && !joinMessage.includes("already")) {
if (joinMessage) {
return pushToast({theme: "error", message: joinMessage})
}

View file

@ -4,6 +4,7 @@
import {tagSpec, tagValue, toNostrURI} from "@welshman/util"
import Code2 from "@assets/icons/code-2.svg?dataurl"
import GalleryWide from "@assets/icons/gallery-wide.svg?dataurl"
import ShareCircle from "@assets/icons/share-circle.svg?dataurl"
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
import Danger from "@assets/icons/danger.svg?dataurl"
import Pin from "@assets/icons/pin.svg?dataurl"
@ -19,6 +20,7 @@
import {app, relayManagement, roomPinLists, user} from "@app/core"
import {deriveUserIsSpaceAdmin} from "@app/management"
import {ROOM, deriveUserIsRoomAdmin} from "@app/rooms"
import {shareEvent} from "@app/share"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
@ -36,6 +38,11 @@
const userIsRoomAdmin = deriveUserIsRoomAdmin(url, h)
const isPinned = $derived($pinIds.includes(event.id))
const share = () => {
onClick()
shareEvent(url, "Message", event)
}
const addToLibrary = () => {
onClick()
pushModal(PinboardSelect, {url, event})
@ -106,6 +113,12 @@
Message Details
</Button>
</li>
<li>
<Button onclick={share}>
<Icon size={4} icon={ShareCircle} />
Share
</Button>
</li>
<li>
<Button onclick={addToLibrary}>
<Icon size={4} icon={GalleryWide} />

View file

@ -5,6 +5,7 @@
import {getIdOrAddress, tagSpec, tagValue, toNostrURI} from "@welshman/util"
import Bolt from "@assets/icons/bolt.svg?dataurl"
import Reply from "@assets/icons/reply-2.svg?dataurl"
import ShareCircle from "@assets/icons/share-circle.svg?dataurl"
import Code2 from "@assets/icons/code-2.svg?dataurl"
import TrashBin2 from "@assets/icons/trash-bin-2.svg?dataurl"
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
@ -25,6 +26,7 @@
import {ROOM, deriveUserIsRoomAdmin} from "@app/rooms"
import {ENABLE_ZAPS} from "@app/env"
import {makeContentPath} from "@app/routes"
import {shareEvent} from "@app/share"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
@ -65,6 +67,11 @@
reply()
}
const share = () => {
history.back()
shareEvent(url, "Message", event)
}
const showInfo = () => pushModal(EventInfo, {url, event}, {replaceState: true})
const showDelete = () => pushModal(EventDeleteConfirm, {url, event})
@ -99,6 +106,10 @@
<Icon size={4} icon={Code2} />
Message Info
</Button>
<Button class="button button-neutral" onclick={share}>
<Icon size={4} icon={ShareCircle} />
Share
</Button>
{#if path}
<Link class="button button-neutral" href={path}>
<Icon size={4} icon={MenuDots} />

View file

@ -28,7 +28,7 @@
const {url, initialValues = {}}: Props = $props()
const values = $state(initialValues)
const values = $state({...initialValues})
const back = () => history.back()

View file

@ -18,6 +18,7 @@
const {url, pubkey, roles = []}: Props = $props()
const profileDisplay = $profiles.display(pubkey).$
const supportedMethods = deriveSpaceSupportedMethods(url)
const canUnallow = $derived($supportedMethods.includes("unallowpubkey"))
const canBan = $derived($supportedMethods.includes("banpubkey"))
@ -31,7 +32,7 @@
<button
type="button"
class="absolute inset-0 cursor-pointer rounded-2xl"
aria-label="View {$profiles.display(pubkey).get()}'s profile"
aria-label="View {$profileDisplay}'s profile"
onclick={openProfile}>
</button>
<div class="pointer-events-none relative flex items-start justify-between gap-2">

View file

@ -18,7 +18,7 @@
import InfoSignatures from "@app/components/InfoSignatures.svelte"
import {relaysPendingTrust} from "@app/policies"
import {addTrustedRelay, removeTrustedRelay} from "@app/settings"
import {pushModal} from "@app/modal"
import {popModal, pushModal} from "@app/modal"
import {roomLists} from "@app/core"
type Props = {
@ -48,6 +48,7 @@
await addTrustedRelay(url)
relaysPendingTrust.update($r => remove(url, $r))
popModal()
} finally {
loading = false
}

View file

@ -32,10 +32,11 @@
Posted in #<RoomName {h} {url} />
</Link>
{/if}
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip-left" />
<ThunkStatusOrDeleted {event} />
{#if showActivity}
<EventActivity {url} {path} {event} />
{/if}
<EventActions {url} {event} noun="Thread" />
<ThunkStatusOrDeleted {event}>
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip-left" />
{#if showActivity}
<EventActivity {url} {path} {event} />
{/if}
<EventActions {url} {event} noun="Thread" />
</ThunkStatusOrDeleted>
</div>

View file

@ -26,14 +26,14 @@
)
const relayMessage = (status: PublishStatus | undefined, detail: string | undefined) => {
if (detail) {
return detail
}
if (status === PublishStatus.Timeout) {
return "request timed out"
}
if (detail) {
return detail
}
return "no details received"
}
</script>

View file

@ -8,10 +8,11 @@
type Props = {
event: TrustedEvent
status?: Snippet
children?: Snippet
}
const {event, children}: Props = $props()
const {event, status, children}: Props = $props()
const deleted = deriveIsDeleted(event)
const history = $thunks.history
@ -20,8 +21,11 @@
{#if $deleted}
<div class="button button-error button-xs rounded-full">Deleted</div>
{:else if thunk.thunks.length > 0 && !thunk.hasStatus(PublishStatus.Success)}
<ThunkStatus {thunk} />
{:else if children}
{:else}
{#if thunk.thunks.length > 0 && !thunk.hasStatus(PublishStatus.Success)}
<ThunkStatus {thunk} />
{:else}
{@render status?.()}
{/if}
{@render children?.()}
{/if}

View file

@ -43,6 +43,8 @@ export const deriveSpaceSupportedMethods = simpleCache(([url]: [string | undefin
// User
// A relay answers supportedmethods with everything it implements rather than with what the
// caller may use, so all this can tell us is that the call wasn't refused outright.
export const deriveUserIsSpaceAdmin = (url?: string) =>
derived(deriveSpaceSupportedMethods(url), $methods => $methods.length > 0)

View file

@ -122,12 +122,12 @@ const trustPolicy = (socket: Socket) => {
const mostlyRestrictedPolicy = (socket: Socket) => {
let total = 0
let restricted = 0
let refused = 0
const pending = new Set<string>()
const updateStatus = (error?: string) => {
if (total > 5 && restricted > total / 2) {
if (total > 5 && refused > total / 2) {
if (error) {
return relaysMostlyRestricted.update(assoc(socket.url, error))
}
@ -136,6 +136,19 @@ const mostlyRestrictedPolicy = (socket: Socket) => {
}
}
// NIP-01 reserves "blocked: " for a ban and "restricted: " for lacking permission.
const countDetails = (details: string) => {
if (details.startsWith("auth-required: ")) {
total--
updateStatus()
}
if (details.startsWith("restricted: ") || details.startsWith("blocked: ")) {
refused++
updateStatus(details)
}
}
const unsubscribers = [
on(socket, SocketEvent.Receive, (message: RelayMessage) => {
if (isRelayOk(message)) {
@ -145,15 +158,7 @@ const mostlyRestrictedPolicy = (socket: Socket) => {
pending.delete(id)
if (!ok) {
if (details.startsWith("auth-required: ")) {
total--
updateStatus()
}
if (details.startsWith("restricted: ")) {
restricted++
updateStatus(details)
}
countDetails(details)
}
}
}
@ -163,16 +168,7 @@ const mostlyRestrictedPolicy = (socket: Socket) => {
if (pending.has(id)) {
pending.delete(id)
if (details.startsWith("auth-required: ")) {
total--
updateStatus()
}
if (details.startsWith("restricted: ")) {
restricted++
updateStatus(details)
}
countDetails(details)
}
}
}),

View file

@ -2,7 +2,7 @@
import {onMount} from "svelte"
import {App} from "@capacitor/app"
import type {Maybe} from "@welshman/lib"
import {indexBy, spec} from "@welshman/lib"
import {indexBy, sortBy, spec} from "@welshman/lib"
import Add from "@assets/icons/add.svg?dataurl"
import Server from "@assets/icons/server.svg?dataurl"
import Bolt from "@assets/icons/bolt.svg?dataurl"
@ -107,7 +107,7 @@
const [t, inv, rel, draft] = results
if (t.status === "fulfilled") tenant = t.value
if (inv.status === "fulfilled") invoices = inv.value
if (inv.status === "fulfilled") invoices = sortBy(invoice => -invoice.created_at, inv.value)
if (rel.status === "fulfilled") relays = rel.value
if (draft.status === "fulfilled") draftInvoice = draft.value

View file

@ -283,7 +283,6 @@
<input
type="number"
class="grow"
min="1"
data-index={index}
value={amount}
oninput={onZapAmountInput} />

View file

@ -300,7 +300,7 @@
<p class="py-12 text-center">You haven't joined any spaces yet.</p>
{/if}
{#if otherSpaces.length > 0}
<Divider>{filteredUserUrls.length > 0 ? "More Spaces" : "Browse Spaces"}</Divider>
<Divider>Browse Spaces</Divider>
{/if}
{#each otherSpaces.slice(0, limit) as relay (relay.url)}
<Button class="card card-interactive" onclick={() => openSpace(relay.url)}>

View file

@ -49,7 +49,7 @@
.map<Item>(event => {
const newDateDisplay = formatTimestampAsDate(getStart(event))
const dateDisplay = prevDateDisplay === newDateDisplay ? undefined : newDateDisplay
const isFuture = todayDateDisplay === newDateDisplay || event.created_at > now()
const isFuture = todayDateDisplay === newDateDisplay || getStart(event) > now()
const isFirstFutureEvent = !haveISeenTheFuture && isFuture
prevDateDisplay = newDateDisplay

View file

@ -16,6 +16,7 @@
import NoteContent from "@app/components/NoteContent.svelte"
import NoteCard from "@app/components/NoteCard.svelte"
import CalendarEventActions from "@app/components/CalendarEventActions.svelte"
import CommentActions from "@app/components/CommentActions.svelte"
import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte"
import CalendarEventMeta from "@app/components/CalendarEventMeta.svelte"
import CalendarEventDate from "@app/components/CalendarEventDate.svelte"
@ -95,7 +96,7 @@
<NoteCard event={reply} {url} class="card z-feature w-full">
<div class="flex flex-col gap-3 ml-12">
<NoteContent showEntire event={reply} {url} />
<CalendarEventActions event={reply} {url} />
<CommentActions segment="calendar" event={reply} {url} />
</div>
</NoteCard>
{/each}