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 ### US-028 — Share a message somewhere else
As alice, I want to forward a message into another room or conversation, so that As alice, I want to forward a message into another room in the same space, so
I can pass it along without retyping it. that I can pass it along without retyping it.
Acceptance: Acceptance:
- "Share" on a message inside a space opens a picker of that space's rooms; from - "Share" on a message inside a space opens a picker of that space's rooms.
a context with no fixed space, it offers a search across all her rooms and
conversations.
- Choosing a destination navigates there with the composer pre-filled with a - Choosing a destination navigates there with the composer pre-filled with a
quote of the shared message. quote of the shared message.
- Sending posts the quoted message in the destination, visible to bob there. - 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 {MessagingRelayList, RelayList, displayPubkey} from "@welshman/domain"
import type {Locator, Page} from "@playwright/test" import type {Locator, Page} from "@playwright/test"
import {expect, makeTestUser, roomPath, spacePath, test, users} from "../harness" 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 // A handle to a seeded event. SeededEvent isn't exported from the harness, and only its id and
// timestamp are ever read back here. // 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}) => { test("US-028 share a message somewhere else", async ({seed, as}) => {
let dm!: SeededRumor
const scenario = await seed(({relay, user, at}) => { const scenario = await seed(({relay, user, at}) => {
const space = relay("space") 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.alice)
seedChatter(space, user.bob) seedChatter(space, user.bob)
dm = space.dm(user.bob, [user.alice], "are you around?", at(20, MINUTE))
}) })
const {url} = scenario.space("space") 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(alice, "heads up")).toContainText("the dock is closed on sunday")
await expect(message(bob, "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, RELAYS,
type ManagementResponse, type ManagementResponse,
} from "@welshman/util" } 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 {Sync, User, publish} from "@welshman/app"
import {stripPrefix} from "@lib/util" import {stripPrefix} from "@lib/util"
import {app, command, relayManagement, roomLists, thunks, writer} from "@app/core" 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 = "") => export const publishJoinRequest = (url: string, claim?: string) => {
command(writer(RelayJoin).forceRelays(url).setClaim(claim)).then(publish) const eventWriter = writer(RelayJoin).forceRelays(url)
if (claim) {
eventWriter.setClaim(claim)
}
return command(eventWriter).then(publish)
}
export const publishLeaveRequest = (url: string) => export const publishLeaveRequest = (url: string) =>
command(writer(RelayLeave).forceRelays(url)).then(publish) 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) const eventWriter = writer(RoomJoin).setRoom(url, h)
if (code) { if (code) {
eventWriter.setClaim(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) => { export const publishRoomInvite = async (url: string, h: string) => {
@ -198,12 +230,13 @@ export const attemptRelayAccess = async (url: string, claim = "") => {
if (shouldIgnoreError(error)) return if (shouldIgnoreError(error)) return
if (claim) {
if (error.includes("invite code")) { if (error.includes("invite code")) {
return "join request rejected" return "join request rejected"
} }
} else if (error.includes("invite code")) {
return // 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) 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) { async acceptInvite(data: InviteData, notifications: boolean) {
const spaceUrls = roomLists.get().urls(User.require(app.get()).pubkey).get() const spaceUrls = roomLists.get().urls(User.require(app.get()).pubkey).get()
const error = await this.joinSpace({ const error = await this.joinSpace({
@ -340,7 +362,7 @@ export class Access {
} }
if (data.h && data.code) { 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> </button>
{/each} {/each}
</div> </div>
<ThunkStatusOrDeleted {event}>
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip-left" /> <ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip-left" />
<ThunkStatusOrDeleted {event} />
{#if showActivity} {#if showActivity}
<EventActivity {url} {path} {event} /> <EventActivity {url} {path} {event} />
{/if} {/if}
<EventActions {url} {event} noun="Article" /> <EventActions {url} {event} noun="Article" />
</ThunkStatusOrDeleted>
</div> </div>

View file

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

View file

@ -41,8 +41,8 @@
Posted in #<RoomName {h} {url} /> Posted in #<RoomName {h} {url} />
</Link> </Link>
{/if} {/if}
<ThunkStatusOrDeleted {event}>
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip tip-left" /> <ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip tip-left" />
<ThunkStatusOrDeleted {event} />
{#if showActivity} {#if showActivity}
<EventActivity {url} {path} {event} /> <EventActivity {url} {path} {event} />
{/if} {/if}
@ -58,4 +58,5 @@
{/if} {/if}
{/snippet} {/snippet}
</EventActions> </EventActions>
</ThunkStatusOrDeleted>
</div> </div>

View file

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

View file

@ -55,10 +55,11 @@
</button> </button>
{/each} {/each}
</div> </div>
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip-left" />
<ThunkStatusOrDeleted {event}> <ThunkStatusOrDeleted {event}>
{#snippet status()}
<ClassifiedStatus {event} /> <ClassifiedStatus {event} />
</ThunkStatusOrDeleted> {/snippet}
<ReactionSummary {url} {event} {deleteReaction} {createReaction} reactionClass="tip-left" />
{#if showActivity} {#if showActivity}
<EventActivity {url} {path} {event} /> <EventActivity {url} {path} {event} />
{/if} {/if}
@ -74,4 +75,5 @@
{/if} {/if}
{/snippet} {/snippet}
</EventActions> </EventActions>
</ThunkStatusOrDeleted>
</div> </div>

View file

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

View file

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

View file

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

View file

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

View file

@ -63,7 +63,8 @@
<ProfileCircles pubkeys={$followers} limit={5} /> <ProfileCircles pubkeys={$followers} limit={5} />
<p class="text-sm opacity-75"> <p class="text-sm opacity-75">
{$followers.length} {$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}. {$profileDisplay}.
</p> </p>
</div> </div>

View file

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

View file

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

View file

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

View file

@ -28,7 +28,8 @@
const {url, h, initialValues, onEscape, onEditPrevious, onSubmit}: Props = $props() 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 const autofocus = !isMobile

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -18,6 +18,7 @@
const {url, pubkey, roles = []}: Props = $props() const {url, pubkey, roles = []}: Props = $props()
const profileDisplay = $profiles.display(pubkey).$
const supportedMethods = deriveSpaceSupportedMethods(url) const supportedMethods = deriveSpaceSupportedMethods(url)
const canUnallow = $derived($supportedMethods.includes("unallowpubkey")) const canUnallow = $derived($supportedMethods.includes("unallowpubkey"))
const canBan = $derived($supportedMethods.includes("banpubkey")) const canBan = $derived($supportedMethods.includes("banpubkey"))
@ -31,7 +32,7 @@
<button <button
type="button" type="button"
class="absolute inset-0 cursor-pointer rounded-2xl" 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}> onclick={openProfile}>
</button> </button>
<div class="pointer-events-none relative flex items-start justify-between gap-2"> <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 InfoSignatures from "@app/components/InfoSignatures.svelte"
import {relaysPendingTrust} from "@app/policies" import {relaysPendingTrust} from "@app/policies"
import {addTrustedRelay, removeTrustedRelay} from "@app/settings" import {addTrustedRelay, removeTrustedRelay} from "@app/settings"
import {pushModal} from "@app/modal" import {popModal, pushModal} from "@app/modal"
import {roomLists} from "@app/core" import {roomLists} from "@app/core"
type Props = { type Props = {
@ -48,6 +48,7 @@
await addTrustedRelay(url) await addTrustedRelay(url)
relaysPendingTrust.update($r => remove(url, $r)) relaysPendingTrust.update($r => remove(url, $r))
popModal()
} finally { } finally {
loading = false loading = false
} }

View file

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

View file

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

View file

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

View file

@ -43,6 +43,8 @@ export const deriveSpaceSupportedMethods = simpleCache(([url]: [string | undefin
// User // 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) => export const deriveUserIsSpaceAdmin = (url?: string) =>
derived(deriveSpaceSupportedMethods(url), $methods => $methods.length > 0) derived(deriveSpaceSupportedMethods(url), $methods => $methods.length > 0)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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