Q147: the conversation header lists its real members, how each agent replies, and who can read
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
02309546dc
commit
983d37937d
13 files changed
+173
-44
No files matched your search
@@ -0,0 +1,18 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
/** The agent that answers a channel message naming no one: its most senior active member with an enabled provider. */
|
||||
export function channelAnchorAgentId(db: DatabaseSync, orgId: string, spaceId: string): string | null {
|
||||
const anchor = db.prepare(`
|
||||
select a.id as agent_id
|
||||
from chat_memberships cm
|
||||
join agents a on a.id = cm.principal_id and a.org_id = ?
|
||||
join providers p on p.id = a.model_provider_id and p.org_id = a.org_id
|
||||
where cm.space_id = ? and cm.principal_type = 'agent'
|
||||
and a.lifecycle = 'active'
|
||||
and p.enabled = 1
|
||||
order by case a.role_level when 'c_suite' then 0 when 'director' then 1 when 'lead' then 2 else 3 end,
|
||||
a.created_at asc
|
||||
limit 1
|
||||
`).get(orgId, spaceId) as { agent_id: string } | undefined;
|
||||
return anchor?.agent_id ?? null;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { channelAnchorAgentId } from "./chat-anchor";
|
||||
import { readVerifiedFile } from "./files/file-object-store";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { CreateChatSpaceInput, DeleteMessageInput, EditMessageInput, MarkChatSpaceReadInput, SendMessageInput } from "../../shared/commands";
|
||||
@@ -313,24 +314,13 @@ export class ChatService {
|
||||
* exactly like a room going quiet until the highest-ranking person speaks.
|
||||
*/
|
||||
private wakeChannelAnchor(input: SendMessageInput, space: SpaceRow, messageId: string, threadId: string, now: string): void {
|
||||
const anchor = this.db.prepare(`
|
||||
select a.id as agent_id
|
||||
from chat_memberships cm
|
||||
join agents a on a.id = cm.principal_id and a.org_id = ?
|
||||
join providers p on p.id = a.model_provider_id and p.org_id = a.org_id
|
||||
where cm.space_id = ? and cm.principal_type = 'agent'
|
||||
and a.lifecycle = 'active'
|
||||
and p.enabled = 1
|
||||
order by case a.role_level when 'c_suite' then 0 when 'director' then 1 when 'lead' then 2 else 3 end,
|
||||
a.created_at asc
|
||||
limit 1
|
||||
`).get(input.orgId, input.spaceId) as { agent_id: string } | undefined;
|
||||
if (!anchor) {
|
||||
const anchorId = channelAnchorAgentId(this.db, input.orgId, input.spaceId);
|
||||
if (!anchorId) {
|
||||
console.info(`[chat] no runnable agent member in #${space.name}; the message stays unanswered until one is hired or a provider is enabled.`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.agentWake.wakeFromMention(input, anchor.agent_id, messageId, threadId, now);
|
||||
this.agentWake.wakeFromMention(input, anchorId, messageId, threadId, now);
|
||||
} catch (error) {
|
||||
console.warn(`[chat] waking channel anchor for #${space.name} failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { currentProviderUsageWindow } from "../../shared/provider-runtime-domain
|
||||
import { buildUsageStats } from "./usage-stats";
|
||||
import { taskHolds } from "./task-holds";
|
||||
import { chatReplyStatuses } from "./chat-reply-status";
|
||||
import { channelAnchorAgentId } from "./chat-anchor";
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
type ProviderUsageWindowRow = Pick<
|
||||
@@ -100,6 +101,11 @@ export class SnapshotService {
|
||||
chatMemberships: list(this.db, "chat_memberships", "space_id asc, principal_type asc, principal_id asc"),
|
||||
chatMessages,
|
||||
chatReplyStatuses: chatReplyStatuses(this.db, chatMessages.map((message) => message.id), holds),
|
||||
chatSpaceAnchors: (this.db.prepare("select id, org_id from chat_spaces where space_type <> 'direct'").all() as Array<{ id: string; org_id: string }>)
|
||||
.flatMap((space) => {
|
||||
const agentId = channelAnchorAgentId(this.db, space.org_id, space.id);
|
||||
return agentId ? [{ spaceId: space.id, agentId }] : [];
|
||||
}),
|
||||
// Every thread a loaded message belongs to, plus the most recently active ones, so no shown message loses its thread.
|
||||
chatThreads: all<Row>(this.db.prepare(`
|
||||
select * from chat_threads
|
||||
|
||||
@@ -38,6 +38,14 @@ export const zh: Record<string, string> = {
|
||||
"Waiting on you.": "等待你处理。",
|
||||
"Not sent: {reason}": "未发送:{reason}",
|
||||
"Not saved: {reason}": "未保存:{reason}",
|
||||
"Members ({count})": "成员({count})",
|
||||
"Reads and writes": "可读可写",
|
||||
"Replies to every message": "回复每条消息",
|
||||
"Replies when @mentioned, in threads it joined, and to messages that name no one": "被 @ 时、在参与过的话题中、以及消息未点名任何人时回复",
|
||||
"Replies when @mentioned and in threads it joined": "被 @ 时和在参与过的话题中回复",
|
||||
"Only the people in this conversation can read it.": "只有本会话中的人可以读取。",
|
||||
"Everyone in the company can read this channel.": "公司里的每个人都可以读取此频道。",
|
||||
"Only members can read this channel.": "只有成员可以读取此频道。",
|
||||
"Remove {name}": "移除 {name}",
|
||||
"Not attached: {reason}": "未能附加:{reason}",
|
||||
"Reply in thread": "在话题中回复",
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ChatConversation } from "./chat/ChatConversation";
|
||||
import type { Draft, SendState } from "./chat/ChatComposer";
|
||||
import {
|
||||
buildConversations,
|
||||
collectParticipants,
|
||||
spaceMembers,
|
||||
filterApprovalsForSpace,
|
||||
messageReaders,
|
||||
} from "./chat/chat-helpers";
|
||||
@@ -99,10 +99,11 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
||||
() => (activeSpace ? messagesAll.filter((m) => m.spaceId === activeSpace.id) : []),
|
||||
[messagesAll, activeSpace],
|
||||
);
|
||||
const participants = useMemo(
|
||||
() => collectParticipants(messagesInSpace, employees, agents),
|
||||
[messagesInSpace, employees, agents],
|
||||
);
|
||||
const members = useMemo(() => {
|
||||
if (!activeSpace) return [];
|
||||
const anchor = snapshot.chatSpaceAnchors.find((item) => item.spaceId === activeSpace.id)?.agentId ?? null;
|
||||
return spaceMembers(activeSpace, snapshot.chatMemberships, employees, agents, anchor);
|
||||
}, [activeSpace, snapshot.chatSpaceAnchors, snapshot.chatMemberships, employees, agents]);
|
||||
const pendingApprovalsInSpace = filterApprovalsForSpace(approvalsAll, activeSpace);
|
||||
|
||||
const sendInChannel = (event: FormEvent<HTMLFormElement>) => {
|
||||
@@ -236,7 +237,7 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
||||
<ChatConversation
|
||||
key={activeSpace?.id ?? "none"}
|
||||
space={activeSpace}
|
||||
participants={participants}
|
||||
members={members}
|
||||
messagesInSpace={messagesInSpace}
|
||||
threads={snapshot.chatThreads}
|
||||
replyStatuses={snapshot.chatReplyStatuses}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type FormEvent, type MutableRefObject } from "react";
|
||||
import { AvatarStack, Icon, IconButton } from "../../shared/ui";
|
||||
import { Avatar, AvatarStack, Icon, IconButton } from "../../shared/ui";
|
||||
import type {
|
||||
ApprovalRecord,
|
||||
ChatAttachmentRecord,
|
||||
@@ -18,12 +18,13 @@ import type { ChatReplyStatus, SpaceHistoryPage } from "../../../../shared/chat-
|
||||
|
||||
/** Older messages fetched per "Show earlier messages". */
|
||||
const HISTORY_PAGE = 150;
|
||||
import type { MessageContext } from "./chat-helpers";
|
||||
import { spaceReaders, type MessageContext, type SpaceMember } from "./chat-helpers";
|
||||
import { Popover } from "../../shared/Popover";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
export function ChatConversation({
|
||||
space,
|
||||
participants,
|
||||
members,
|
||||
messagesInSpace,
|
||||
threads,
|
||||
replyStatuses,
|
||||
@@ -51,7 +52,7 @@ export function ChatConversation({
|
||||
composerRef,
|
||||
}: {
|
||||
space: ChatSpaceRecord | null;
|
||||
participants: string[];
|
||||
members: SpaceMember[];
|
||||
messagesInSpace: ChatMessageRecord[];
|
||||
threads: ChatThreadRecord[];
|
||||
replyStatuses: ChatReplyStatus[];
|
||||
@@ -196,7 +197,7 @@ export function ChatConversation({
|
||||
return (
|
||||
<>
|
||||
<section style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", background: "var(--panel)" }}>
|
||||
{space && <ConversationHeader space={space} participants={participants} query={query} onQueryChange={setQuery} />}
|
||||
{space && <ConversationHeader space={space} members={members} query={query} onQueryChange={setQuery} />}
|
||||
<div style={{ position: "relative", flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
@@ -272,12 +273,12 @@ export function ChatConversation({
|
||||
|
||||
function ConversationHeader({
|
||||
space,
|
||||
participants,
|
||||
members,
|
||||
query,
|
||||
onQueryChange,
|
||||
}: {
|
||||
space: ChatSpaceRecord;
|
||||
participants: string[];
|
||||
members: SpaceMember[];
|
||||
query: string;
|
||||
onQueryChange: (next: string) => void;
|
||||
}) {
|
||||
@@ -320,9 +321,39 @@ function ConversationHeader({
|
||||
style={{ width: 200, height: 28, padding: "0 10px" }}
|
||||
/>
|
||||
)}
|
||||
{!searchOpen && participants.length > 0 && <AvatarStack names={participants.slice(0, 5)} bot />}
|
||||
{!searchOpen && <MembersButton space={space} members={members} />}
|
||||
<IconButton icon="search" label={t("Search")} onClick={toggleSearch} />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
/** The real member list: who is in the conversation, how each agent replies, and who else can read it. */
|
||||
function MembersButton({ space, members }: { space: ChatSpaceRecord; members: SpaceMember[] }) {
|
||||
return (
|
||||
<Popover
|
||||
trigger={({ toggle }) => (
|
||||
<button type="button" onClick={toggle} aria-label={t("Members ({count})", { count: String(members.length) })} style={{ display: "flex", alignItems: "center", gap: 6, border: 0, background: "transparent", cursor: "pointer", padding: 0 }}>
|
||||
<AvatarStack names={members.slice(0, 5).map((member) => member.name)} />
|
||||
<span style={{ fontSize: 12, color: "var(--text-3)" }}>{members.length}</span>
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{() => (
|
||||
// A large roster scrolls inside the window instead of running past it.
|
||||
<div className="members-roster" style={{ padding: 12, width: 320, maxHeight: "min(480px, calc(100vh - 120px))", overflowY: "auto", display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<div style={{ fontSize: 12, color: "var(--text-3)" }}>{spaceReaders(space)}</div>
|
||||
{members.map((member) => (
|
||||
<div key={member.id} className="row" style={{ gap: 8, alignItems: "flex-start" }}>
|
||||
<Avatar name={member.name} size="sm" bot={member.bot} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 540 }}>{member.name}</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-3)" }}>{member.duty}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -66,24 +66,40 @@ export function messageAttachments(
|
||||
.filter((file): file is FileObjectRecord => Boolean(file));
|
||||
}
|
||||
|
||||
export function collectParticipants(
|
||||
messages: ChatMessageRecord[],
|
||||
/** One member of a conversation and what they do in it. */
|
||||
export interface SpaceMember {
|
||||
id: string;
|
||||
name: string;
|
||||
bot: boolean;
|
||||
duty: string;
|
||||
}
|
||||
|
||||
/** Every member of the conversation, spoken or not, with the rule by which each agent replies (the same rules the server wakes them by). */
|
||||
export function spaceMembers(
|
||||
space: ChatSpaceRecord,
|
||||
memberships: ChatMembershipRecord[],
|
||||
employees: EmployeeRecord[],
|
||||
agents: AgentRecord[],
|
||||
): string[] {
|
||||
const seen = new Map<string, string>();
|
||||
for (const message of messages) {
|
||||
if (!message.senderId) continue;
|
||||
if (seen.has(message.senderId)) continue;
|
||||
if (message.senderType === "employee") {
|
||||
const employee = employees.find((item) => item.id === message.senderId);
|
||||
if (employee) seen.set(message.senderId, employee.displayName);
|
||||
} else if (message.senderType === "agent") {
|
||||
const agent = agents.find((item) => item.id === message.senderId);
|
||||
if (agent) seen.set(message.senderId, agent.name);
|
||||
anchorAgentId: string | null,
|
||||
): SpaceMember[] {
|
||||
const direct = space.spaceType === "direct";
|
||||
return memberships.filter((membership) => membership.spaceId === space.id).map((membership) => {
|
||||
if (membership.principalType === "employee") {
|
||||
const employee = employees.find((item) => item.id === membership.principalId);
|
||||
return { id: membership.principalId, name: employee ? employee.displayName : t("Former member"), bot: false, duty: t("Reads and writes") };
|
||||
}
|
||||
}
|
||||
return [...seen.values()];
|
||||
const agent = agents.find((item) => item.id === membership.principalId);
|
||||
const duty = direct ? t("Replies to every message")
|
||||
: membership.principalId === anchorAgentId ? t("Replies when @mentioned, in threads it joined, and to messages that name no one")
|
||||
: t("Replies when @mentioned and in threads it joined");
|
||||
return { id: membership.principalId, name: agent ? agent.name : t("Former member"), bot: true, duty };
|
||||
});
|
||||
}
|
||||
|
||||
/** Who can read the conversation beyond its listed members. */
|
||||
export function spaceReaders(space: ChatSpaceRecord): string {
|
||||
if (space.spaceType === "direct") return t("Only the people in this conversation can read it.");
|
||||
return space.visibility === "workspace" ? t("Everyone in the company can read this channel.") : t("Only members can read this channel.");
|
||||
}
|
||||
|
||||
export function filterApprovalsForSpace(
|
||||
|
||||
@@ -113,3 +113,9 @@ export type ChatReplyStatus = { messageId: string; agentId: string } & (
|
||||
| { state: "expired" | "failed"; detail: string; resumesAt: null }
|
||||
| { state: "stopped"; detail: string | null; resumesAt: null }
|
||||
);
|
||||
|
||||
/** The agent that answers a channel message naming no one, per channel that has one. */
|
||||
export interface ChatSpaceAnchor {
|
||||
spaceId: string;
|
||||
agentId: string;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ProviderDataPolicy } from "./provider-policy-domain";
|
||||
export { BOARD_INTERVENTION_ACTION, BOARD_INTERVENTION_MODE, CHAT_THREAD_BOARD_STATE, type BoardInterventionAction, type BoardInterventionMode, type BoardInterventionRecord, type ChatThreadBoardState } from "./board-domain";
|
||||
export type { ChatReplyStatus, ChatSpaceRecord, ChatMessageRecord, ChatMembershipRecord, ChatThreadRecord, ChatAttachmentRecord, ChatMentionRecord, ChatSlashInvocationRecord, ChatPresenceRecord, ChatReadStateRecord } from "./chat-domain";
|
||||
export type { ChatReplyStatus, ChatSpaceAnchor, ChatSpaceRecord, ChatMessageRecord, ChatMembershipRecord, ChatThreadRecord, ChatAttachmentRecord, ChatMentionRecord, ChatSlashInvocationRecord, ChatPresenceRecord, ChatReadStateRecord } from "./chat-domain";
|
||||
export { COMPLIANCE_EXPORT_STATE, COMPLIANCE_EXPORT_TYPE, type ComplianceExportRecord, type ComplianceExportState, type ComplianceExportType, type ComplianceSubjectType, type LegalHoldEntityType, type LegalHoldRecord, type RetentionFreezeRecord, type RetentionFreezeScopeType } from "./compliance-domain";
|
||||
export { ACCESS_DECISION, IDENTITY_SOURCE, ROLE_KEY, type AccessDecision, type AccessDecisionRecord, type EmployeeIdentityRecord, type IdentitySource, type PrincipalType, type RoleGrantRecord, type RoleKey, type ServiceIdentityRecord } from "./identity-domain";
|
||||
export { ROUTE_TO_TYPE, type ApprovalAssignmentRecord, type ApprovalRoutingRuleRecord, type HiringPolicyRecord, type OrgDepartmentRecord, type RouteToType } from "./org-governance-domain";
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
ChatSpaceRecord,
|
||||
ChatThreadRecord,
|
||||
ChatReplyStatus,
|
||||
ChatSpaceAnchor,
|
||||
} from "./chat-domain";
|
||||
import type { ComplianceExportRecord, LegalHoldRecord, RetentionFreezeRecord } from "./compliance-domain";
|
||||
import type {
|
||||
@@ -86,6 +87,7 @@ export interface AppSnapshot {
|
||||
chatMessages: ChatMessageRecord[];
|
||||
chatThreads: ChatThreadRecord[];
|
||||
chatReplyStatuses: ChatReplyStatus[];
|
||||
chatSpaceAnchors: ChatSpaceAnchor[];
|
||||
chatAttachments: ChatAttachmentRecord[];
|
||||
chatMentions: ChatMentionRecord[];
|
||||
chatSlashInvocations: ChatSlashInvocationRecord[];
|
||||
|
||||
@@ -204,6 +204,17 @@ test("the pinned CEO conversation is the Messages front door", async () => {
|
||||
}
|
||||
await expect(start).toBeVisible();
|
||||
await expect(chatLog.getByText("General line 0", { exact: true })).toBeVisible();
|
||||
|
||||
// The header lists the real members, how each agent replies, and who can read.
|
||||
await page.getByRole("button", { name: /^Members \(/ }).click();
|
||||
const roster = page.getByRole("menu");
|
||||
await expect(roster).toContainText("Everyone in the company can read this channel.");
|
||||
await expect(roster.locator(".members-roster")).toHaveCSS("overflow-y", "auto");
|
||||
await expect(roster).toContainText("Replies when @mentioned, in threads it joined, and to messages that name no one");
|
||||
await page.keyboard.press("Escape");
|
||||
await page.locator(".convo-item").first().click();
|
||||
await page.getByRole("button", { name: /^Members \(/ }).click();
|
||||
await expect(page.getByRole("menu")).toContainText("Replies to every message");
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
|
||||
@@ -41,6 +41,9 @@ describe("a message's reply status", () => {
|
||||
const status = () => new SnapshotService(connection.db).load().chatReplyStatuses.filter((item) => item.messageId === messageId);
|
||||
|
||||
expect(status()).toEqual([{ messageId, agentId: ceoId, state: "queued", detail: null, resumesAt: null }]);
|
||||
// The CEO answers channel messages that name no one.
|
||||
const general = (connection.db.prepare("select id from chat_spaces where org_id = ? and name = 'general'").get(orgId) as { id: string }).id;
|
||||
expect(new SnapshotService(connection.db).load().chatSpaceAnchors.find((item) => item.spaceId === general)).toEqual({ spaceId: general, agentId: ceoId });
|
||||
// A second message folded into the same queued reply gets the same status.
|
||||
const follow = connection.transaction(() => new ChatService(connection.db).sendMessage({
|
||||
orgId, spaceId: dm, threadId: (connection.db.prepare("select thread_id from chat_messages where id = ?").get(messageId) as { thread_id: string }).thread_id,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { spaceMembers, spaceReaders } from "../../src/renderer/src/pages/chat/chat-helpers";
|
||||
import type { AgentRecord, ChatMembershipRecord, ChatSpaceRecord, EmployeeRecord } from "../../src/shared/domain";
|
||||
|
||||
const ORG = "org_1";
|
||||
|
||||
function agent(id: string, name: string): AgentRecord {
|
||||
return {
|
||||
id, orgId: ORG, name, role: name, department: "Engineering", modelProviderId: null, modelId: null,
|
||||
budgetCents: 0, managerAgentId: null, title: name, roleLevel: "staff",
|
||||
modelStrategy: "explicit", workspaceId: null, heartbeatEnabled: false, heartbeatIntervalSec: 0,
|
||||
lifecycle: "active", avatarEmoji: null, avatarColor: null, avatarImage: null, createdAt: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
const employees: EmployeeRecord[] = [{ id: "emp_yang", orgId: ORG, displayName: "Yang", role: "founder", avatarEmoji: null, avatarColor: null, avatarImage: null, createdAt: "2026-01-01T00:00:00Z" }];
|
||||
const agents = [agent("agt_ceo", "CEO"), agent("agt_dana", "Dana"), agent("agt_eve", "Eve")];
|
||||
const member = (spaceId: string, principalType: "employee" | "agent", principalId: string): ChatMembershipRecord => ({ spaceId, principalType, principalId, role: "member", muteState: "unmuted" });
|
||||
const space = (id: string, spaceType: string, visibility: string): ChatSpaceRecord => ({ id, orgId: ORG, spaceType, name: id, topic: "t", visibility, retentionPolicy: "standard", createdAt: "2026-01-01T00:00:00Z" });
|
||||
|
||||
describe("who is in a conversation", () => {
|
||||
test("lists every member, spoken or not, with how each agent replies", () => {
|
||||
const memberships = [member("eng", "employee", "emp_yang"), member("eng", "agent", "agt_ceo"), member("eng", "agent", "agt_dana"), member("dm", "employee", "emp_yang"), member("dm", "agent", "agt_dana")];
|
||||
expect(spaceMembers(space("eng", "department", "workspace"), memberships, employees, agents, "agt_ceo")).toEqual([
|
||||
{ id: "emp_yang", name: "Yang", bot: false, duty: "Reads and writes" },
|
||||
{ id: "agt_ceo", name: "CEO", bot: true, duty: "Replies when @mentioned, in threads it joined, and to messages that name no one" },
|
||||
{ id: "agt_dana", name: "Dana", bot: true, duty: "Replies when @mentioned and in threads it joined" },
|
||||
]);
|
||||
expect(spaceMembers(space("dm", "direct", "restricted"), memberships, employees, agents, null).map((item) => item.duty)).toEqual(["Reads and writes", "Replies to every message"]);
|
||||
});
|
||||
|
||||
test("says who can read beyond the member list", () => {
|
||||
expect(spaceReaders(space("eng", "department", "workspace"))).toBe("Everyone in the company can read this channel.");
|
||||
expect(spaceReaders(space("ops", "project", "restricted"))).toBe("Only members can read this channel.");
|
||||
expect(spaceReaders(space("dm", "direct", "restricted"))).toBe("Only the people in this conversation can read it.");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user