Split the chat list into conversations and requests (#585)

This commit is contained in:
Coracle-Bot 2026-09-21 20:51:39 +00:00 committed by hodlbod
parent a734147811
commit 939caadc32
9 changed files with 369 additions and 27 deletions

View file

@ -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

View file

@ -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")

View file

@ -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")

View file

@ -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<string, Chat>()
const chatsByPubkey = new Map<string, string[]>()
@ -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<string>
members: Set<string>
scores: Map<string, number>
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<ChatTab, Chat[]>
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),
}),
)

View file

@ -0,0 +1,33 @@
<script lang="ts">
import cx from "classnames"
import Button from "@lib/components/Button.svelte"
import {CHAT_TABS, ChatTab} from "@app/chats"
import type {ChatsByTab} from "@app/chats"
interface Props {
tab: ChatTab
chatsByTab: ChatsByTab
class?: string
}
let {tab = $bindable(), chatsByTab, ...props}: Props = $props()
const setTab = (value: ChatTab) => () => {
tab = value
}
</script>
<div class={cx("flex items-center gap-2", props.class)}>
{#each CHAT_TABS as { value, label } (value)}
<Button
aria-pressed={tab === value}
class={cx(
"button button-sm shrink-0 rounded-full",
tab === value ? "button-primary" : "button-neutral",
)}
onclick={setTab(value)}>
{label}
<span class="opacity-75">{chatsByTab[value].length}</span>
</Button>
{/each}
</div>

View file

@ -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<AppDataReader> {

View file

@ -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 @@
})
</script>
<SecondaryNav class="relative">
<SecondaryNav class="relative w-72 lg:w-80">
<SecondaryNavSection>
<SecondaryNavHeader>
Chats
@ -55,9 +58,10 @@
<Icon icon={Magnifier} />
<input bind:value={term} class="grow" type="text" />
</label>
<ChatTabs bind:tab {chatsByTab} />
</SecondaryNavSection>
<div class="overflow-auto">
{#each chats as { id, pubkeys, messages } (id)}
{#each chatsByTab[tab] as { id, pubkeys, messages } (id)}
<ChatItem {id} {pubkeys} {messages} />
{/each}
{#await promise}

View file

@ -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))
</script>
<PageContent class="flex flex-col gap-2 p-2 sm:gap-4 sm:p-4">
@ -34,7 +37,8 @@
</div>
<ContentSearch class="md:hidden">
{#snippet input()}
<div class="flex gap-2 min-w-0 grow items-center">
<div class="flex flex-col gap-2 min-w-0 grow">
<div class="flex gap-2 min-w-0 items-center">
<label class="input input-group flex grow items-center gap-2">
<Icon icon={Magnifier} />
<input
@ -43,20 +47,29 @@
type="text"
placeholder="Search for conversations..." />
</label>
<MenuButton component={ChatMenu} class="button button-neutral" aria-label="Chat options" />
<MenuButton
component={ChatMenu}
class="button button-neutral"
aria-label="Chat options" />
</div>
<ChatTabs bind:tab {chatsByTab} />
</div>
{/snippet}
{#snippet content()}
<div class="flex flex-col gap-2">
{#each chats as { id, pubkeys, messages } (id)}
{#each chatsByTab[tab] as { id, pubkeys, messages } (id)}
<ChatItem {id} {pubkeys} {messages} class="card" />
{:else}
<div class="py-20 max-w-sm flex flex-col gap-4 items-center m-auto text-center">
{#if tab === ChatTab.Requests}
<p>No message requests.</p>
{:else}
<p>No chats found! Try starting one up.</p>
<Button class="button button-primary" onclick={startChat}>
<Icon icon={AddCircle} />
Start a Chat
</Button>
{/if}
</div>
{/each}
</div>

View file

@ -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}
</FieldInline>
</Card>
<Card class="flex flex-col gap-4">
<strong class="flex items-center gap-3 text-lg">
<Icon icon={LetterUnread} />
Message Requests
</strong>
<p class="text-sm opacity-50">
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.
</p>
<Field>
{#snippet label()}
<p>Proof of work</p>
{/snippet}
{#snippet secondary()}
<p>{$settings.min_dm_pow} bits</p>
{/snippet}
{#snippet input()}
<input
class="range w-full"
type="range"
min="1"
max="32"
step="1"
bind:value={$settings.min_dm_pow} />
{/snippet}
{#snippet info()}
<p>
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.
</p>
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Web of trust</p>
{/snippet}
{#snippet secondary()}
<p>{$settings.min_dm_wot} {$settings.min_dm_wot === 1 ? "person" : "people"}</p>
{/snippet}
{#snippet input()}
<input
class="range w-full"
type="range"
min="1"
max="10"
step="1"
bind:value={$settings.min_dm_wot} />
{/snippet}
{#snippet info()}
<p>
How many of the people you follow have to follow a sender for you to count as knowing
them.
</p>
{/snippet}
</Field>
</Card>
<Card class="sticky -bottom-3 shadow-md flex flex-row items-center justify-between gap-4">
<Button class="button button-neutral" onclick={reset} disabled={loading}
>Discard Changes</Button>