Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 5 additions & 8 deletions src/features/identity-names/react.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { NamingIdentity } from "./policy";
import { useSyncExternalStore, useCallback } from "react";
import { useListedChannel } from "../relay/listed-channel";
import type { RelaySession } from "../relay/session";
import type { IdentityNameView } from "./service";
const noop = () => () => {};
Expand Down Expand Up @@ -28,21 +29,17 @@ export function useIdentityNames(names: IdentityNameView | undefined) {
);
}

const noChannels = () => undefined;
const noMembers: readonly string[] = [];
/** Channel membership defines ambiguity, not the community-wide profile cache. */
export function useChannelIdentityNames(
session: RelaySession | undefined,
channelId: string | undefined,
) {
const list = useSyncExternalStore(
session?.channels?.subscribeList ?? noop,
session?.channels?.list ?? noChannels,
noChannels,
const members = useListedChannel(
session?.channels,
channelId,
(channel) => channel?.members ?? noMembers,
);
const members =
list?.channels.find((channel) => channel.id === channelId)?.members ??
noMembers;
const resolve = useIdentityNames(session?.names);
return useCallback(
(pubkey: string, fallback: string) => resolve(pubkey, fallback, members),
Expand Down
20 changes: 12 additions & 8 deletions src/features/messages/MessageManagement.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Button } from "../../shared/design-system/ui/Button";
import { AlertDialog } from "../../shared/design-system/ui/AlertDialog";
import { MenuItem, MenuIcon } from "../../shared/design-system/ui/Menu";
import type { ChannelMessage } from "../relay/contracts";
import { useListedChannel } from "../relay/listed-channel";
import type { RelaySession } from "../relay/session";
import type { OutgoingEvent } from "../relay/outbox";
import { MessageEditScope, useMessageEditScope } from "./MessageEditScope";
Expand Down Expand Up @@ -172,9 +173,15 @@ export function MessageManagementItems({
const management = useContext(Management);
const editor = useMessageEditScope();
const afterClose = useAfterMessageMenuClose();
const channels = useSyncExternalStore(
session.channels.subscribeList,
session.channels.list,
const writable = useListedChannel(
session.channels,
row.channelId,
(channel) => !!channel && !channel.readOnly,
);
const archived = useListedChannel(
session.channels,
row.channelId,
(channel) => !!channel?.archived,
);
const target = {
kind: "message" as const,
Expand All @@ -192,17 +199,14 @@ export function MessageManagementItems({
(row.delivery && !["accepted", "seen"].includes(row.delivery))
)
return null;
const member = channels.channels.find(
(channel) => channel.id === row.channelId,
);
if (!member || member.readOnly) return null;
if (!writable) return null;
const busy = management.operations.some(
(item) =>
["sending", "accepted"].includes(item.delivery) &&
[5, 40003].includes(item.event.kind) &&
item.event.tags.some(([name, id]) => name === "e" && id === row.id),
);
const own = row.authorId === session.viewer && !member.archived;
const own = row.authorId === session.viewer && !archived;
const canEdit = own && editor && lastEditableMessage(session, [row]);
const canDelete = own && session.outbox?.supports(5);
const attention = session.unread.attention(row.channelId, row.id);
Expand Down
52 changes: 25 additions & 27 deletions src/features/messages/MessageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
type FocusEvent,
type ReactNode,
} from "react";
import { useListedChannel } from "../relay/listed-channel";
import type { RelaySession } from "../relay/session";
import type { UnreadCapability } from "../relay/unread";
import { MediaAttachment, type MediaPlayback } from "./MediaAttachment";
Expand Down Expand Up @@ -45,13 +46,6 @@ import { ToastNotice } from "../../shared/design-system/ui/Toast";
import { ReportMessageDialog } from "./ReportMessageDialog";
import { messageCopyLink, messageCopyText } from "./message-copy";

const emptySubscribe = () => () => {};
const EMPTY_CHANNEL_LIST = Object.freeze({
status: "unavailable" as const,
channels: Object.freeze([]),
});
const emptyChannelList = () => EMPTY_CHANNEL_LIST;

export type MessageRowProps = {
row: ChannelMessage;
session?: RelaySession | undefined;
Expand Down Expand Up @@ -166,13 +160,26 @@ export const MessageRow = memo(function MessageRow({
row.channelId,
row.threadRootId ?? row.id,
);
const channelList = useSyncExternalStore(
session?.channels.subscribeList ?? emptySubscribe,
session?.channels.list ?? emptyChannelList,
session?.channels.list ?? emptyChannelList,
const channels = session?.channels;
const listed = useListedChannel(
channels,
row.channelId,
(channel) => !!channel,
);
const cached = useListedChannel(
channels,
row.channelId,
(channel) => !!channel?.cached,
);
const archived = useListedChannel(
channels,
row.channelId,
(channel) => !!channel?.archived,
);
const cached = channelList.channels.some(
(channel) => channel.id === row.channelId && channel.cached,
const readOnly = useListedChannel(
channels,
row.channelId,
(channel) => !!channel?.readOnly,
);
const unreadLabel =
threadUnread?.manual === "local-only"
Expand Down Expand Up @@ -212,12 +219,9 @@ export const MessageRow = memo(function MessageRow({
scope &&
session.outbox?.supports(7) &&
session.outbox.supports(5) &&
(!session.channels.get ||
channelList.channels.some((channel) => channel.id === row.channelId)) &&
!channelList.channels.find((channel) => channel.id === row.channelId)
?.archived &&
!channelList.channels.find((channel) => channel.id === row.channelId)
?.readOnly
(!session.channels.get || listed) &&
!archived &&
!readOnly
);
const rowRef = useRef<HTMLDivElement>(null);
const menuTrigger = useRef<HTMLButtonElement>(null);
Expand Down Expand Up @@ -415,14 +419,8 @@ export const MessageRow = memo(function MessageRow({
!!(
row.delivery && !["accepted", "seen"].includes(row.delivery)
) ||
!!channelList.channels.find(
(channel) => channel.id === row.channelId,
)?.archived ||
(!!session?.channels.get &&
!channelList.channels.some(
(channel) =>
channel.id === row.channelId && !channel.readOnly,
))
archived ||
(!!session?.channels.get && (!listed || readOnly))
}
link={messageCopyLink(row, scope)}
copyText={() =>
Expand Down
51 changes: 43 additions & 8 deletions src/features/messages/ReferenceText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useChannelIdentityNames } from "../identity-names/react";
import { useSyncExternalStore, type ReactNode } from "react";
import { AtIcon, RobotIcon } from "../../shared/design-system/icons/index";
import type { RelaySession } from "../relay/session";
import type { Profile, ChannelSummary } from "../relay/contracts";
import type { ChannelList, ChannelSummary, Profile } from "../relay/contracts";
import { messageReferences } from "./message-references";
import { MessageLink } from "../conversation/MessageLink";
import type { ConversationExtensions } from "../conversation/contracts";
Expand All @@ -11,7 +11,12 @@ import type { AgentLibrary } from "../agents/library";
import styles from "../../shared/InlineReference.module.css";

const emptyProfiles: ReadonlyMap<string, Profile> = new Map();
const emptyChannels: readonly ChannelSummary[] = [];
/** The only channel fields a reference may read; the rest can be out of date. */
export type ReferenceChannel = Pick<
ChannelSummary,
"id" | "name" | "channelType" | "private" | "archived"
>;
const emptyChannels: readonly ReferenceChannel[] = [];
const emptyAgents: AgentLibrary["identities"] = [];
export const emptyReferenceDirectory = {
profiles: emptyProfiles,
Expand All @@ -20,8 +25,37 @@ export const emptyReferenceDirectory = {
};
const noop = () => () => {};
const profilesSnapshot = () => emptyProfiles;
const channelsSnapshot = () => undefined;
const channelsSnapshot = () => emptyChannels;
const agentsSnapshot = () => undefined;
type ChannelLists = Pick<RelaySession["channels"], "list">;
const referenced = new WeakMap<
ChannelLists,
{ list: ChannelList; channels: readonly ReferenceChannel[] }
>();
/** Previews and activity replace the list and its summaries on every message.
* Retain the prior channels while every field a reference reads is unchanged,
* so mounted rows render only when a label could change. */
function referenceChannels(queries: ChannelLists) {
const list = queries.list();
const prior = referenced.get(queries);
if (prior?.list === list) return prior.channels;
const channels =
prior?.channels.length === list.channels.length &&
list.channels.every((channel, index) => {
const old = prior.channels[index];
return (
old?.id === channel.id &&
old.name === channel.name &&
old.channelType === channel.channelType &&
old.private === channel.private &&
old.archived === channel.archived
);
})
? prior.channels
: list.channels;
referenced.set(queries, { list, channels });
return channels;
}

export function useReferenceDirectory(
session: RelaySession | undefined,
Expand All @@ -34,9 +68,10 @@ export function useReferenceDirectory(
: (session?.profiles?.snapshot ?? profilesSnapshot),
profilesSnapshot,
);
const queries = session?.channels;
const channels = useSyncExternalStore(
session?.channels?.subscribeList ?? noop,
session?.channels?.list ?? channelsSnapshot,
queries?.subscribeList ?? noop,
queries?.list ? () => referenceChannels(queries) : channelsSnapshot,
channelsSnapshot,
);
const agents = useSyncExternalStore(
Expand All @@ -46,15 +81,15 @@ export function useReferenceDirectory(
);
return {
profiles,
channels: channels?.channels ?? emptyChannels,
channels,
agents: agents?.identities ?? emptyAgents,
};
}

/** Buzz links carry no community, so they always name a channel in the receiving one. */
export function channelForLink(
url: string,
channels: readonly ChannelSummary[],
channels: readonly ReferenceChannel[],
) {
const parsed = parseBuzzLink(url);
const target = parsed?.format === "legacy" ? parsed : undefined;
Expand All @@ -63,7 +98,7 @@ export function channelForLink(

export function channelLinkLabel(
url: string,
channels: readonly ChannelSummary[],
channels: readonly ReferenceChannel[],
) {
const channel = channelForLink(url, channels);
const parsed = parseBuzzLink(url);
Expand Down
3 changes: 3 additions & 0 deletions src/features/messages/RichComposerInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { mentionCandidates } from "./mention-candidates";
import { useAgentChoices } from "../agents/use-choices";
import { useLayoutEffect, useMemo, useRef } from "react";
import { useIdentityNames } from "../identity-names/react";
import { useListedChannel } from "../relay/listed-channel";
import { InlineChip } from "../../shared/design-system/ui/InlineChip";
import type { ConversationExtensions } from "../conversation/contracts";
import type { CustomEmoji } from "../relay/emoji";
Expand Down Expand Up @@ -42,6 +43,8 @@ export function RichComposerInput({
inviteAgents?: boolean;
}) {
const directory = useReferenceDirectory(session);
// Candidates read this channel's roster and state during render.
useListedChannel(session.channels, channelId, (channel) => channel);
useMentionArchives(session);
const roster = useContext(DraftMentionRoster);
const profiles = new Map(directory.profiles);
Expand Down
17 changes: 17 additions & 0 deletions src/features/relay/budget.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { expect, it } from "vitest";
import { byteSize, listByteSize } from "./budget";

it("sizes a list exactly as byteSize does, including after it changes", () => {
const items = [
{ id: "a", content: "plain", tags: [["h", "channel"]] },
{ id: "b", content: 'quote " and \\ slash', tags: [] },
{ id: "c", content: "héllo 🐝 \n", tags: [["e", "a"]] },
];
for (const count of [0, 1, 2, 3, 2, 3])
expect(listByteSize(items.slice(0, count))).toBe(
byteSize(items.slice(0, count)),
);
expect(listByteSize([...items].reverse())).toBe(
byteSize([...items].reverse()),
);
});
17 changes: 17 additions & 0 deletions src/features/relay/budget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,23 @@ export const OUTBOX_INPUT_MAX_BYTES = 32 * 1024;
export const byteSize = (value: unknown) =>
new TextEncoder().encode(JSON.stringify(value)).byteLength;

const sizes = new WeakMap<object, number>();
/** `byteSize` of a list whose members never change, such as verified events.
* A live window checks its budget on every message; each member is serialized
* once instead of the whole window each time. */
export function listByteSize(values: readonly object[]) {
let bytes = 2 + Math.max(0, values.length - 1);
for (const value of values) {
let size = sizes.get(value);
if (size === undefined) {
size = byteSize(value);
sizes.set(value, size);
}
bytes += size;
}
return bytes;
}

export class ByteLru<T> {
private items = new Map<string, { value: T; bytes: number }>();
private bytes = 0;
Expand Down
60 changes: 60 additions & 0 deletions src/features/relay/listed-channel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// @vitest-environment jsdom
import { act, cleanup, render } from "@testing-library/react";
import { afterEach, expect, it } from "vitest";
import type { ChannelList } from "./contracts";
import { useListedChannel } from "./listed-channel";

afterEach(cleanup);

it("renders only when the selected fact of its own channel changes", () => {
const members = ["a"];
let list: ChannelList = {
status: "ready",
channels: [
{ id: "one", name: "One", members },
{ id: "two", name: "Two", members: [] },
],
};
const listeners = new Set<() => void>();
const queries = {
list: () => list,
subscribeList: (listener: () => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
const publish = (next: ChannelList) =>
act(() => {
list = next;
for (const notify of listeners) notify();
});
const seen: unknown[] = [];
function Probe() {
const archived = useListedChannel(queries, "one", (c) => !!c?.archived);
const roster = useListedChannel(queries, "one", (c) => c?.members);
seen.push([archived, roster]);
return null;
}
render(<Probe />);
expect(seen).toEqual([[false, members]]);

// A message replaces the list and every summary; no selected fact changed.
publish({
...list,
channels: [
{ id: "one", name: "One", members, preview: "new" },
{ id: "two", name: "Two", members: [], archived: true },
],
});
expect(seen).toHaveLength(1);

publish({
...list,
channels: [{ id: "one", name: "One", members, archived: true }],
});
expect(seen.at(-1)).toEqual([true, members]);
expect(seen).toHaveLength(2);

publish({ ...list, channels: [] });
expect(seen.at(-1)).toEqual([false, undefined]);
});
Loading
Loading