From 939caadc32475e86dbb13292efce493832de16bf Mon Sep 17 00:00:00 2001 From: Coracle-Bot Date: Mon, 21 Sep 2026 20:51:39 +0000 Subject: [PATCH] Split the chat list into conversations and requests (#585) --- e2e/USER_STORIES.md | 26 ++++++ e2e/specs/dms.spec.ts | 69 ++++++++++++++- e2e/specs/settings.spec.ts | 42 +++++++++ src/app/chats.ts | 106 +++++++++++++++++++++-- src/app/components/ChatTabs.svelte | 33 +++++++ src/app/settings.ts | 4 + src/routes/chat/+layout.svelte | 10 ++- src/routes/chat/+page.svelte | 47 ++++++---- src/routes/settings/privacy/+page.svelte | 59 +++++++++++++ 9 files changed, 369 insertions(+), 27 deletions(-) create mode 100644 src/app/components/ChatTabs.svelte diff --git a/e2e/USER_STORIES.md b/e2e/USER_STORIES.md index 10737d01..887faddb 100644 --- a/e2e/USER_STORIES.md +++ b/e2e/USER_STORIES.md @@ -616,6 +616,21 @@ Acceptance: - A message deleted or edited out of that conversation stays gone across the same restart. - A reaction I left on one of its messages is still there after the same restart. +### US-128 — Keep messages from strangers out of my conversations + +As alice, I want messages from people I have no connection to held apart from the conversations I +care about, so that a stranger cannot bury them. + +Acceptance: + +- The chat list has a Conversations tab and a Requests tab, each carrying its own count. +- Someone I follow opens under Conversations and someone I share nothing with opens under Requests. + A member of a space I belong to, a sender enough of my follows follow, and a message carrying + enough proof of work each count as a connection too. +- Choosing Requests shows the held conversation, which reads and opens like any other. +- A chat I have written in is mine whatever I know about the other person, so answering a request + moves it to Conversations. + ## Articles & threads ### US-037 — Write and publish an article @@ -1360,6 +1375,17 @@ Acceptance: - Turning off "Report usage" and saving persists across a reload. - "Discard Changes" reverts unsaved toggles. +### US-129 — Raise the thresholds a stranger has to meet + +As alice, I want to set how much proof of work and how many vouches a stranger +needs, so that I decide what reaches my conversations. + +Acceptance: + +- The proof-of-work and web-of-trust sliders start at 16 bits and 3 people, and + moving either updates the value beside its label live. +- Saving persists both across a reload. + ### US-090 — Change the app's appearance As alice, I want to set color scheme, visual theme, and font size, so that the diff --git a/e2e/specs/dms.spec.ts b/e2e/specs/dms.spec.ts index 96016529..0eae02c2 100644 --- a/e2e/specs/dms.spec.ts +++ b/e2e/specs/dms.spec.ts @@ -2,7 +2,7 @@ import {npubEncode} from "nostr-tools/nip19" import type {Locator, Page} from "@playwright/test" import {DAY, HOUR, MINUTE} from "@welshman/lib" import {DIRECT_MESSAGE, REACTION} from "@welshman/util" -import {MessagingRelayList} from "@welshman/domain" +import {FollowList, MessagingRelayList} from "@welshman/domain" import { bubble, chatItems, @@ -39,6 +39,10 @@ const seedPerson = (space: SeededSpace, user: TestUser, name: string, ...rooms: const chatFilter = (page: Page) => page.locator(".secondary-nav input[type='text']") +// The chat list renders twice, once per breakpoint, so a tab is named inside the sidebar. +const chatTab = (page: Page, name: string) => + page.locator(".secondary-nav").getByRole("button", {name}) + // ChatItem's unread mark is a bare dot with no text of its own. const unreadDots = (scope: Locator) => scope.locator(".rounded-full.bg-primary") @@ -644,6 +648,69 @@ test("US-036 receive a new conversation live", async ({seed, as}) => { await expect(bubble(alice, "starting a chat with you")).toBeVisible() }) +test("US-128 keep messages from strangers out of your conversations", async ({seed, as}) => { + const dave = makeTestUser("dave") + const eve = makeTestUser("eve") + + const scenario = await seed(({relay, user, at}) => { + const space = relay("space") + const inbox = relay("other") + + // Bob she follows. Carol she knows only through the space they are both in, which is the + // weakest thing that still counts as knowing someone. + seedPerson(space, user.alice, "Alice Anchor") + seedPerson(space, user.bob, "Bob Barnacle") + seedPerson(space, user.carol, "Carol Cutter") + space.messagingRelayList(user.bob) + space.messagingRelayList(user.carol) + space.event(user.alice, () => + space.kind(FollowList).writer().follow(user.bob.pubkey).renderTemplate(), + ) + + // Dave and Eve write from a relay alice reads her messages on and has not joined, as US-108 + // does. Membership of it is what lets a wrap addressed to someone be stored there, and it + // never reaches a room list, so writing from it vouches for nobody. + space.messagingRelayList(user.alice, [space.url, inbox.url]) + inbox.member(user.alice) + inbox.member(dave) + inbox.member(eve) + + space.dm(user.bob, [user.alice], "lunch tomorrow?", at(30, MINUTE)) + space.dm(user.carol, [user.alice], "the hull is patched", at(1, HOUR)) + inbox.dm(dave, [user.alice], "you have won a prize", at(2, HOUR)) + + // Eve is as much a stranger as Dave, and answering her is the whole difference between them. + inbox.dm(eve, [user.alice], "are you the alice from the boatyard?", at(4, HOUR)) + inbox.dm(user.alice, [eve], "I am, who is this?", at(3, HOUR)) + }) + + // Only her space indexes, as in US-108: the socket a client opens before it knows what a relay + // is for never identifies itself to it, and the inbox relay serves an anonymous reader nothing. + const page = await as(users.alice, "/chat", { + env: {VITE_INDEXER_RELAYS: scenario.space("space").url}, + }) + + await expect(chatTab(page, "Conversations")).toContainText("3") + await expect(chatTab(page, "Requests")).toContainText("1") + + await expect(chatItems(page)).toHaveCount(3) + await expect(chatItems(page).nth(0)).toContainText("lunch tomorrow?") + await expect(chatItems(page).nth(1)).toContainText("the hull is patched") + await expect(chatItems(page).nth(2)).toContainText("I am, who is this?") + + await chatTab(page, "Requests").click() + + const fromDave = chatItems(page).filter({hasText: "you have won a prize"}) + + await expect(chatItems(page)).toHaveCount(1) + await expect(fromDave).toBeVisible() + + // A request is a chat like any other, so it opens and reads as one. + await fromDave.click() + + await expect(bubble(page, "you have won a prize")).toBeVisible() +}) + test("US-108 read messages from a relay you only use for messages", async ({seed, as}) => { const scenario = await seed(({relay, user, at}) => { const space = relay("space") diff --git a/e2e/specs/settings.spec.ts b/e2e/specs/settings.spec.ts index 3a733e8a..0cf69eae 100644 --- a/e2e/specs/settings.spec.ts +++ b/e2e/specs/settings.spec.ts @@ -17,6 +17,12 @@ const relayCard = (scope: Locator, name: string) => scope.locator(".card").filte // makes the assertion that follows about persistence rather than about timing. const waitForToastToClear = (page: Page) => expect(toast(page)).toHaveCount(0) +// A Field lays its slider out under the row holding the label and the current value, so the card +// around both is what a slider is named from. +const requestsCard = (page: Page) => page.locator(".card").filter({hasText: "Message Requests"}) + +const requestSliders = (page: Page) => requestsCard(page).locator('input[type="range"]') + test("US-084 block a relay you never want used", async ({seed, as}) => { await seed(({relay, user}) => { const space = relay("space") @@ -250,6 +256,42 @@ test("US-089 configure privacy preferences", async ({seed, as}) => { await expect(settingToggle(page, "Report usage?")).not.toBeChecked() }) +test("US-129 raise the thresholds a stranger has to meet", async ({seed, as}) => { + await seed(({relay, user}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + + space.relayList(user.alice) + }) + + const page = await as(users.alice, "/settings/privacy") + const pow = requestSliders(page).first() + const wot = requestSliders(page).last() + + await expect(pow).toHaveValue("16") + await expect(wot).toHaveValue("3") + await expect(requestsCard(page).getByText("16 bits")).toBeVisible() + await expect(requestsCard(page).getByText("3 people")).toBeVisible() + + await pow.fill("24") + await wot.fill("1") + + await expect(requestsCard(page).getByText("24 bits")).toBeVisible() + await expect(requestsCard(page).getByText("1 person")).toBeVisible() + + await page.getByRole("button", {name: "Save Changes"}).click() + + await expect(page.getByRole("alert")).toContainText("Your settings have been saved!") + + await waitForToastToClear(page) + await page.reload() + + await expect(requestSliders(page).first()).toHaveValue("24") + await expect(requestSliders(page).last()).toHaveValue("1") +}) + test("US-090 change the app's appearance", async ({seed, as}) => { await seed(({relay, user}) => { const space = relay("space") diff --git a/src/app/chats.ts b/src/app/chats.ts index beb6acc1..c387f298 100644 --- a/src/app/chats.ts +++ b/src/app/chats.ts @@ -1,14 +1,15 @@ import {derived, readable} from "svelte/store" -import {append, call, on, remove, sort, sortBy, uniq, uniqBy} from "@welshman/lib" +import {append, call, on, partition, remove, sort, sortBy, uniq, uniqBy} from "@welshman/lib" import type {Override} from "@welshman/lib" -import {DELETE, PROFILE, hexTags, tagValues} from "@welshman/util" +import {DELETE, PROFILE, getPow, hexTags, tagValues} from "@welshman/util" import type {TrustedEvent} from "@welshman/util" -import type {RepositoryUpdate} from "@welshman/net" +import type {RepositoryUpdate, WrapItem} from "@welshman/net" import {makeDeriveItem, throttled} from "@welshman/store" -import {createSearch} from "@welshman/app" +import {FollowLists, RelayMemberLists, RoomLists, Wot, WotScope, createSearch} from "@welshman/app" import type {App} from "@welshman/app" -import {app, profiles, user} from "@app/core" +import {app, deriveUserItem, fromApp, profiles, user} from "@app/core" import {DM_KINDS} from "@app/content" +import {userSettingsValues} from "@app/settings" export type Chat = { id: string @@ -33,6 +34,15 @@ export const makeChatId = (pubkeys: string[]) => { export const splitChatId = (id: string) => getChatPubkeys(id.split(",")) +// A message the user is party to: they wrote it, or it names them. Anything else decrypted out of +// a wrap addressed to them is a rumor about other people, which is either a mistake or an attempt +// to plant a conversation in their list. +export const isUserMessage = (event: TrustedEvent) => { + const pubkey = user.get().pubkey + + return event.pubkey === pubkey || tagValues(hexTags("p"), event.tags).includes(pubkey) +} + export const chatsById = call(() => { const chatsById = new Map() const chatsByPubkey = new Map() @@ -58,7 +68,7 @@ export const chatsById = call(() => { const addEvents = (events: TrustedEvent[]) => { let dirty = false for (const event of events) { - if (DM_KINDS.includes(event.kind)) { + if (DM_KINDS.includes(event.kind) && isUserMessage(event)) { const pubkeys = getChatPubkeysFromEvent(event) const id = makeChatId(pubkeys) const chat = chatsById.get(id) @@ -164,3 +174,87 @@ export const chatSearch = derived(throttled(1500, chatsById), $chatsByPubkey => }, ) }) + +// Conversations and requests + +export enum ChatTab { + Conversations = "conversations", + Requests = "requests", +} + +export const CHAT_TABS = [ + {value: ChatTab.Conversations, label: "Conversations"}, + {value: ChatTab.Requests, label: "Requests"}, +] + +// Both thresholds are at least one: a pubkey nobody vouches for and a message carrying no nonce +// have met nothing, so a zero would wave the whole list through. +export type ChatContext = { + pubkey: string + follows: Set + members: Set + scores: Map + minPow: number + minWot: number +} + +// The wrap manager keeps everything about a wrap but its ciphertext, and proof of work is read off +// the id and the nonce tag, so an empty content stands in for what it dropped. +const getWrapPow = (wrap: WrapItem) => getPow({...wrap, content: ""}) + +// A gift wrap is the event a sender has to mint, so the work is on it rather than on the rumor +// sealed inside. A message can arrive in more than one wrap; the best of them is what was paid. +const getMessagePow = (event: TrustedEvent) => + Math.max(0, ...app.get().wrapManager.getWraps(event.id).map(getWrapPow)) + +// Someone the user has a standing relationship with, whether or not they have ever written to +// each other. +const isKnown = (pubkey: string, ctx: ChatContext) => + ctx.follows.has(pubkey) || ctx.members.has(pubkey) || (ctx.scores.get(pubkey) ?? 0) >= ctx.minWot + +// A chat the user asked for rather than one that arrived: they have written in it, they know +// everyone else in it, or somebody spent proof of work to reach them. Every other participant has +// to be known, so a stranger cannot get in by adding the user to a group with their friends. +export const isConversation = (chat: Chat, ctx: ChatContext) => { + const others = remove(ctx.pubkey, chat.pubkeys) + + return ( + others.length === 0 || + chat.messages.some(event => event.pubkey === ctx.pubkey) || + others.every(pubkey => isKnown(pubkey, ctx)) || + chat.messages.some(event => getMessagePow(event) >= ctx.minPow) + ) +} + +export type ChatsByTab = Record + +export const groupChatsByTab = (chats: Chat[], ctx: ChatContext): ChatsByTab => { + const [conversations, requests] = partition(chat => isConversation(chat, ctx), chats) + + return {[ChatTab.Conversations]: conversations, [ChatTab.Requests]: requests} +} + +const userFollowList = deriveUserItem(FollowLists) + +// The spaces the user belongs to, read here rather than imported from @app/rooms: that module +// and @app/routes already require each other, and joining the cycle leaves this one holding an +// uninitialized binding. +const userRoomList = deriveUserItem(RoomLists) + +const wotScores = fromApp($app => $app.use(Wot).scores(WotScope.Follows).$) + +const relayMembersByUrl = fromApp($app => $app.use(RelayMemberLists).index.$) + +export const chatContext = derived( + [user, userFollowList, wotScores, relayMembersByUrl, userRoomList, userSettingsValues], + ([$user, $followList, $scores, $membersByUrl, $roomList, $settings]): ChatContext => ({ + pubkey: $user.pubkey, + follows: new Set($followList?.pubkeys() ?? []), + members: new Set( + ($roomList?.urls() ?? []).flatMap(url => $membersByUrl.get(url)?.pubkeys() ?? []), + ), + scores: $scores, + minPow: Math.max(1, $settings.min_dm_pow), + minWot: Math.max(1, $settings.min_dm_wot), + }), +) diff --git a/src/app/components/ChatTabs.svelte b/src/app/components/ChatTabs.svelte new file mode 100644 index 00000000..498b8d0a --- /dev/null +++ b/src/app/components/ChatTabs.svelte @@ -0,0 +1,33 @@ + + +
+ {#each CHAT_TABS as { value, label } (value)} + + {/each} +
diff --git a/src/app/settings.ts b/src/app/settings.ts index beace218..165bb2c5 100644 --- a/src/app/settings.ts +++ b/src/app/settings.ts @@ -35,6 +35,8 @@ export type SettingsValues = { alerts: SpaceNotificationSettings[] zap_amounts: number[] openrouter_key: string + min_dm_pow: number + min_dm_wot: number } export const defaultSettings: SettingsValues = { @@ -48,6 +50,8 @@ export const defaultSettings: SettingsValues = { alerts: [], zap_amounts: [21, 210, 2100, 21000], openrouter_key: "", + min_dm_pow: 16, + min_dm_wot: 3, } export class Settings extends DerivedPlugin { diff --git a/src/routes/chat/+layout.svelte b/src/routes/chat/+layout.svelte index 962a579e..c9a2d817 100644 --- a/src/routes/chat/+layout.svelte +++ b/src/routes/chat/+layout.svelte @@ -17,7 +17,8 @@ import ChatMenu from "@app/components/ChatMenu.svelte" import ChatStart from "@app/components/ChatStart.svelte" import ChatItem from "@app/components/ChatItem.svelte" - import {chatSearch} from "@app/chats" + import ChatTabs from "@app/components/ChatTabs.svelte" + import {ChatTab, chatContext, chatSearch, groupChatsByTab} from "@app/chats" import {pushModal} from "@app/modal" import {shouldUnwrap} from "@app/sync" import type {LayoutProps} from "./$types" @@ -27,8 +28,10 @@ const startChat = () => pushModal(ChatStart) let term = $state("") + let tab = $state(ChatTab.Conversations) const chats = $derived($chatSearch.searchOptions(term)) + const chatsByTab = $derived(groupChatsByTab(chats, $chatContext)) const promise = sleep(10000) @@ -37,7 +40,7 @@ }) - + Chats @@ -55,9 +58,10 @@ +
- {#each chats as { id, pubkeys, messages } (id)} + {#each chatsByTab[tab] as { id, pubkeys, messages } (id)} {/each} {#await promise} diff --git a/src/routes/chat/+page.svelte b/src/routes/chat/+page.svelte index 6ba9b5d3..a586ba2a 100644 --- a/src/routes/chat/+page.svelte +++ b/src/routes/chat/+page.svelte @@ -10,14 +10,17 @@ import ChatItem from "@app/components/ChatItem.svelte" import ChatStart from "@app/components/ChatStart.svelte" import ChatMenu from "@app/components/ChatMenu.svelte" - import {chatSearch} from "@app/chats" + import ChatTabs from "@app/components/ChatTabs.svelte" + import {ChatTab, chatContext, chatSearch, groupChatsByTab} from "@app/chats" import {pushModal} from "@app/modal" let term = $state("") + let tab = $state(ChatTab.Conversations) const startChat = () => pushModal(ChatStart) const chats = $derived($chatSearch.searchOptions(term)) + const chatsByTab = $derived(groupChatsByTab(chats, $chatContext)) @@ -34,29 +37,39 @@
{#snippet input()} -
- - +
+
+ + +
+
{/snippet} {#snippet content()}
- {#each chats as { id, pubkeys, messages } (id)} + {#each chatsByTab[tab] as { id, pubkeys, messages } (id)} {:else}
-

No chats found! Try starting one up.

- + {#if tab === ChatTab.Requests} +

No message requests.

+ {:else} +

No chats found! Try starting one up.

+ + {/if}
{/each}
diff --git a/src/routes/settings/privacy/+page.svelte b/src/routes/settings/privacy/+page.svelte index e7486e1c..2d883b59 100644 --- a/src/routes/settings/privacy/+page.svelte +++ b/src/routes/settings/privacy/+page.svelte @@ -2,7 +2,9 @@ import {publish} from "@welshman/app" import ShieldMinimalistic from "@assets/icons/shield-minimalistic.svg?dataurl" import ForbiddenCircle from "@assets/icons/forbidden-circle.svg?dataurl" + import LetterUnread from "@assets/icons/letter-unread.svg?dataurl" import {preventDefault} from "@lib/html" + import Field from "@lib/components/Field.svelte" import FieldInline from "@lib/components/FieldInline.svelte" import ToggleInput from "@lib/components/ToggleInput.svelte" import Icon from "@lib/components/Icon.svelte" @@ -140,6 +142,63 @@ {/snippet} + + + + Message Requests + +

+ Messages from people you have no connection to wait in the Requests tab of your chat list. + These two thresholds are the other ways in. +

+ + {#snippet label()} +

Proof of work

+ {/snippet} + {#snippet secondary()} +

{$settings.min_dm_pow} bits

+ {/snippet} + {#snippet input()} + + {/snippet} + {#snippet info()} +

+ A sender who spends this much proof of work reaches your conversations whether or not + you know them. Each step up doubles what it costs them, and {PLATFORM_NAME} spends 16 bits + on every message you send. +

+ {/snippet} +
+ + {#snippet label()} +

Web of trust

+ {/snippet} + {#snippet secondary()} +

{$settings.min_dm_wot} {$settings.min_dm_wot === 1 ? "person" : "people"}

+ {/snippet} + {#snippet input()} + + {/snippet} + {#snippet info()} +

+ How many of the people you follow have to follow a sender for you to count as knowing + them. +

+ {/snippet} +
+