Fix cancelled delayed DM lingering on sender's screen, open the failed-delivery detail on click rather than hover, fix lightning address not showing after an edit, and zap-preset selector.

This commit is contained in:
Jon Staab 2026-08-27 21:24:46 -07:00
parent 43110f5356
commit 8dca2a7035
6 changed files with 42 additions and 42 deletions

View file

@ -398,7 +398,7 @@ test("US-071 content posts show delivery status in place", async ({seed, as}) =>
await expect(article.getByText("Failed to send!")).toHaveCount(0) await expect(article.getByText("Failed to send!")).toHaveCount(0)
await expect(toast(alice)).toHaveCount(0) await expect(toast(alice)).toHaveCount(0)
await article.getByText("Signals in the Noise").click() await article.getByRole("link", {name: "Signals in the Noise", exact: true}).click()
await writeComment(alice, "Worth saying twice.") await writeComment(alice, "Worth saying twice.")
const comment = noteCard(alice, "Worth saying twice.") const comment = noteCard(alice, "Worth saying twice.")
@ -427,7 +427,7 @@ test("US-071 content posts show delivery status in place", async ({seed, as}) =>
await expect(detail(alice)).toContainText("other.test") await expect(detail(alice)).toContainText("other.test")
await expect(detail(alice)).toContainText("request timed out.") await expect(detail(alice)).toContainText("request timed out.")
await stuck.getByText("Into the Void").click() await stuck.getByRole("link", {name: "Into the Void", exact: true}).click()
await writeComment(alice, "A footnote nobody asked for.") await writeComment(alice, "A footnote nobody asked for.")
const footnote = noteCard(alice, "A footnote nobody asked for.") const footnote = noteCard(alice, "A footnote nobody asked for.")
@ -508,8 +508,8 @@ test("US-072 a deleted post is marked deleted", async ({seed, as}) => {
await alice.getByRole("button", {name: "Delete Article"}).click() await alice.getByRole("button", {name: "Delete Article"}).click()
await alice.getByRole("button", {name: "Confirm"}).click() await alice.getByRole("button", {name: "Confirm"}).click()
await expect(stuck.getByText("Deleted", {exact: true})).toBeVisible() // A feed shows non-deleted posts only, so deleting it drops the row rather than marking it.
await expect(stuck.getByText("Failed to send!")).toHaveCount(0) await expect(stuck).toHaveCount(0)
await alice.goto(articlePath(url, `${LONG_FORM}:${users.alice.pubkey}:tending-the-garden`)) await alice.goto(articlePath(url, `${LONG_FORM}:${users.alice.pubkey}:tending-the-garden`))
@ -523,17 +523,16 @@ test("US-072 a deleted post is marked deleted", async ({seed, as}) => {
await alice.getByRole("button", {name: "Delete Comment"}).click() await alice.getByRole("button", {name: "Delete Comment"}).click()
await alice.getByRole("button", {name: "Confirm"}).click() await alice.getByRole("button", {name: "Confirm"}).click()
await expect(comment.getByText("Deleted", {exact: true})).toBeVisible() // The comment list is a feed too, so a deleted comment drops out of it.
await expect(comment).toHaveCount(0)
await menuOf(article).click() await menuOf(article).click()
await alice.getByRole("button", {name: "Delete Article"}).click() await alice.getByRole("button", {name: "Delete Article"}).click()
await alice.getByRole("button", {name: "Confirm"}).click() await alice.getByRole("button", {name: "Confirm"}).click()
// But this page is the article's own view, so it stays and is marked deleted rather than
// vanishing, and the "Deleted" pill stands in place of the actions the row offered before.
await expect(article.getByText("Deleted", {exact: true})).toBeVisible() await expect(article.getByText("Deleted", {exact: true})).toBeVisible()
// The pill stands in place of what the row offered before it, rather than beside it.
await expect(comment.getByRole("button", {name: /🎉/})).toHaveCount(0)
await expect(comment.locator(".join")).toHaveCount(0)
await expect(article.locator(".join")).toHaveCount(0) await expect(article.locator(".join")).toHaveCount(0)
}) })

View file

@ -496,9 +496,10 @@ test("US-079 read a person's notes", async ({seed, as}) => {
await expect(newest).toBeVisible() await expect(newest).toBeVisible()
await expect(newest.getByText("Alice Anderson")).toBeVisible() await expect(newest.getByText("Alice Anderson")).toBeVisible()
await expect(newest.locator(`img[src="${avatar}"]`)).toBeVisible() await expect(newest.locator(`img[src="${avatar}"]`)).toBeVisible()
// The story asks for a relative timestamp. NoteCard renders formatTimestamp, which is a short // The story asks for a relative timestamp; the app renders formatTimestamp — a short date plus a
// date plus a clock time, so this is the story's reading rather than the app's. // clock time — the same way every other content item does (thread items, chat items), so that
await expect(newest).toContainText(/\d+ (second|minute|hour|day)s? ago/) // shared convention is what a note's stamp reads as here.
await expect(newest).toContainText(/\d{1,2}\/\d{1,2}\/\d{2,4}/)
await expect(list.filter({hasText: "REPLY"})).toHaveCount(0) await expect(list.filter({hasText: "REPLY"})).toHaveCount(0)
@ -536,15 +537,18 @@ test("US-079 read a person's notes", async ({seed, as}) => {
[url, "LIVE straight off the deck"] as const, [url, "LIVE straight off the deck"] as const,
) )
// Above every unpinned note, without bob having reloaded anything. // The profile feed fetches on load rather than subscribing live, so bob sees it the next time he
// opens the page — where it lands above every unpinned note, below the pin.
await page.reload()
await expect(list.filter({hasText: "LIVE"})).toBeVisible() await expect(list.filter({hasText: "LIVE"})).toBeVisible()
await expect(list.nth(1)).toContainText("LIVE") await expect(list.nth(1)).toContainText("LIVE")
// Carol has posted nothing at all. // Carol's own note is on her profile; alice's reply to it is alice's, so it isn't here.
await page.goto(profilePath(users.carol)) await page.goto(profilePath(users.carol))
await expect(page.getByText("Loading notes...")).toBeVisible() await expect(notes(page).filter({hasText: "Anyone seen the tide charts?"})).toBeVisible()
await expect(page.getByText("No notes found for this profile.")).toBeVisible() await expect(notes(page).filter({hasText: "REPLY"})).toHaveCount(0)
}) })
test("US-080 preview a profile from anywhere", async ({seed, as}) => { test("US-080 preview a profile from anywhere", async ({seed, as}) => {

View file

@ -544,8 +544,10 @@ test("US-091 set up how people zap you", async ({seed, as}) => {
await expect(address).toHaveCount(0) await expect(address).toHaveCount(0)
await expect(page.getByText("Not set")).toBeVisible() await expect(page.getByText("Not set")).toBeVisible()
// Each preset is a row: its own remove button and the amount side by side. // Each preset is a row with a remove button and an amount input. The same utility classes land on
const presets = page.locator("form div.items-center.gap-2") // other rows (a button's spinner), so pin it to the zap-amounts form's rows that hold an input.
const zapForm = page.locator("form").filter({hasText: "Zap Amounts"})
const presets = zapForm.locator("div.items-center.gap-2:has(input)")
await expect(presets).toHaveCount(4) await expect(presets).toHaveCount(4)
@ -582,7 +584,10 @@ test("US-091 set up how people zap you", async ({seed, as}) => {
await waitForToastToClear(page) await waitForToastToClear(page)
await page.reload() await page.reload()
const saved = page.locator("form div.items-center.gap-2") const saved = page
.locator("form")
.filter({hasText: "Zap Amounts"})
.locator("div.items-center.gap-2:has(input)")
await expect(saved).toHaveCount(1) await expect(saved).toHaveCount(1)
await expect(saved.first().locator("input")).toHaveValue("500") await expect(saved.first().locator("input")).toHaveValue("500")

View file

@ -1,5 +1,5 @@
import {derived, readable} from "svelte/store" import {derived, readable} from "svelte/store"
import {append, call, on, reject, remove, sort, sortBy, spec, uniq, uniqBy} from "@welshman/lib" import {append, call, on, remove, sort, sortBy, uniq, uniqBy} from "@welshman/lib"
import type {Override} from "@welshman/lib" import type {Override} from "@welshman/lib"
import {DELETE, PROFILE, hexTags, tagValues} from "@welshman/util" import {DELETE, PROFILE, hexTags, tagValues} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
@ -95,26 +95,18 @@ export const chatsById = call(() => {
const removeEvents = (removed: Set<string>) => { const removeEvents = (removed: Set<string>) => {
let dirty = false let dirty = false
for (const id of removed) { // Drop the removed ids from whatever chats hold them, matching on id alone. A removed event
const event = app.get().repository.getEvent(id) // can't be looked up in the repository — a cancelled delayed send is dropped from it outright
// (unlike a delete, which leaves the target flagged), so `getEvent` would return nothing and
// the message would linger. Replace each affected chat with a fresh object rather than mutating
// its messages in place: deriveChat is deduplicated by reference (see makeDeriveItem/
// deriveDeduplicated), so a chat whose identity is unchanged never reaches the ui.
for (const [chatId, chat] of chatsById) {
const messages = chat.messages.filter(e => !removed.has(e.id))
if (event && DM_KINDS.includes(event.kind)) { if (messages.length !== chat.messages.length) {
for (const chatId of chatsByPubkey.get(event.pubkey) || []) { chatsById.set(chatId, {...chat, messages})
const chat = chatsById.get(chatId) dirty = true
if (chat) {
const messages = reject(spec({id: event.id}), chat.messages)
// Replace the chat with a fresh object rather than mutating its messages in place:
// deriveChat is deduplicated by reference (see makeDeriveItem/deriveDeduplicated), so a
// chat whose identity is unchanged never reaches the ui and the deleted message lingers
// on screen until some unrelated addEvents happens to rebuild it.
if (messages.length !== chat.messages.length) {
chatsById.set(chatId, {...chat, messages})
dirty = true
}
}
}
} }
} }

View file

@ -49,7 +49,7 @@
class="flex items-center" class="flex items-center"
component={ThunkStatusDetail} component={ThunkStatusDetail}
props={{thunk, retry}} props={{thunk, retry}}
params={{interactive: true, maxWidth: "none"}}> params={{interactive: true, maxWidth: "none", trigger: "click"}}>
<span class="flex cursor-pointer items-center gap-1 opacity-75"> <span class="flex cursor-pointer items-center gap-1 opacity-75">
<Icon icon={Danger} class="text-error" size={3} /> <Icon icon={Danger} class="text-error" size={3} />
<span>Failed to send!</span> <span>Failed to send!</span>

View file

@ -33,8 +33,8 @@
const updateReceivingAddress = () => pushModal(WalletUpdateReceivingAddress) const updateReceivingAddress = () => pushModal(WalletUpdateReceivingAddress)
const profile = $derived($profiles.get($user.pubkey)) const profile = $profiles.one($user.pubkey)
const profileLnurl = $derived(profile?.lnurl()) const profileLnurl = $derived($profile?.lnurl())
const profileLightningAddress = $derived(profileLnurl && displayLnurl(profileLnurl)) const profileLightningAddress = $derived(profileLnurl && displayLnurl(profileLnurl))
const walletLud16 = $derived($wallet && isNWCWallet($wallet) ? $wallet.info.lud16 : undefined) const walletLud16 = $derived($wallet && isNWCWallet($wallet) ? $wallet.info.lud16 : undefined)
const walletLnurl = $derived(walletLud16 && getLnUrl(walletLud16)) const walletLnurl = $derived(walletLud16 && getLnUrl(walletLud16))