dsh-a2a-groups 0.0.0-stage → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README-ui.md +350 -0
- package/README.md +160 -2
- package/bin/agent-groups.js +438 -0
- package/client.js +3782 -0
- package/cordis.patch.yml +17 -0
- package/docs/ARCHITECTURE.md +281 -0
- package/docs/BACKLOG.md +201 -0
- package/docs/CONTRACT.md +592 -0
- package/docs/DEPLOY.md +645 -0
- package/docs/DESIGN.md +390 -0
- package/docs/MCP.md +260 -0
- package/docs/PRODUCT.md +186 -0
- package/docs/RUNBOOK.md +406 -0
- package/lib/core/bridge.js +1167 -0
- package/lib/core/card.js +217 -0
- package/lib/core/colleague-cards.js +149 -0
- package/lib/core/config.js +235 -0
- package/lib/core/endpoint.js +69 -0
- package/lib/core/groups.js +3010 -0
- package/lib/core/invite.js +412 -0
- package/lib/core/memberships.js +366 -0
- package/lib/core/probe.js +219 -0
- package/lib/core/registry.js +344 -0
- package/lib/core/server.js +1304 -0
- package/lib/core/skills-group.js +1164 -0
- package/lib/core/skills-session.js +116 -0
- package/lib/core/skills.js +76 -0
- package/lib/core/wire.js +772 -0
- package/lib/dsh/deliver.js +601 -0
- package/lib/dsh/inbox.js +658 -0
- package/lib/dsh/profile.js +195 -0
- package/lib/dsh/prompt.js +55 -0
- package/lib/dsh/sessions.js +278 -0
- package/lib/dsh/toolkit.js +166 -0
- package/lib/dsh/tools.js +101 -0
- package/lib/index.js +648 -0
- package/lib/mcp/mcp-deps.js +145 -0
- package/lib/mcp-server.js +261 -0
- package/lib/shared/tool-runtime.js +1349 -0
- package/lib/shared/tool-specs.js +1123 -0
- package/lib/types/client.d.ts +144 -0
- package/package.json +84 -4
package/client.js
ADDED
|
@@ -0,0 +1,3782 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-a2a — browser half (DSH Web client plugin).
|
|
3
|
+
*
|
|
4
|
+
* HOW THIS FILE IS LOADED (mirrors every shipped plugin bundle):
|
|
5
|
+
* window.__ModuleLoader__.load({ id: "dsh-a2a-groups", factory: (require) => exports })
|
|
6
|
+
* The shell only *registers* the factory; the factory body runs later, on first
|
|
7
|
+
* materialization, and the `require` handed to it is the shell's synchronous
|
|
8
|
+
* lazy CJS table (react / react/jsx-runtime / react-dom / @deepseek-ai/cordis /
|
|
9
|
+
* zod / @tanstack/react-virtual / @deepseek-ai/dsh-client-store /
|
|
10
|
+
* @deepseek-ai/dsh-client-ui-slots / @deepseek-ai/dsh-client-ui-primitives /
|
|
11
|
+
* @deepseek-ai/dsh-client-ui-dockkit). Therefore:
|
|
12
|
+
* - no `import` / `export` statements anywhere in this file (it is a script,
|
|
13
|
+
* not a module);
|
|
14
|
+
* - only the modules above may be required.
|
|
15
|
+
*
|
|
16
|
+
* WHAT IT RENDERS: a read-only 「协作群」 left-sidebar entry plus its central
|
|
17
|
+
* panel page. Data is pull-only, from a Host-injected source:
|
|
18
|
+
* 1. exports.setDataSource({ read, subscribe }) — preferred, this is the slot
|
|
19
|
+
* the Lead wires to `ctx.remote` from lib/index.js;
|
|
20
|
+
* 2. window.__DSH_A2A_GROUPS__ — zero-integration fallback (plain snapshot,
|
|
21
|
+
* or an object exposing subscribe(listener)).
|
|
22
|
+
* Missing / empty / malformed data always renders 「还没有加入任何群」; nothing
|
|
23
|
+
* in this file may throw out of a render.
|
|
24
|
+
*
|
|
25
|
+
* See README-ui.md for the full data contract and integration steps.
|
|
26
|
+
*/
|
|
27
|
+
window.__ModuleLoader__.load({
|
|
28
|
+
id: "dsh-a2a-groups",
|
|
29
|
+
factory: (require) => {
|
|
30
|
+
var module = { exports: {} };
|
|
31
|
+
var exports = module.exports;
|
|
32
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
33
|
+
|
|
34
|
+
/** Seed-table module: real React 18.3.1 handed down by the web shell. */
|
|
35
|
+
const react = require("react");
|
|
36
|
+
/**
|
|
37
|
+
* Optional shared design kit. Required defensively: every primitive used
|
|
38
|
+
* below is feature-detected, so a version skew degrades to plain markup
|
|
39
|
+
* instead of breaking the panel.
|
|
40
|
+
*/
|
|
41
|
+
let primitives = {};
|
|
42
|
+
try {
|
|
43
|
+
primitives = require("@deepseek-ai/dsh-client-ui-primitives") || {};
|
|
44
|
+
} catch (error) {
|
|
45
|
+
primitives = {};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const h = react.createElement;
|
|
49
|
+
|
|
50
|
+
//#region contract constants
|
|
51
|
+
/** Global snapshot key the Host may inject without any code change. */
|
|
52
|
+
const GLOBAL_KEY = "__DSH_A2A_GROUPS__";
|
|
53
|
+
/** Stable slot identity: the sidebar row's `id` and the `main` panel's `key`. */
|
|
54
|
+
const PANEL_ID = "a2a-groups";
|
|
55
|
+
/** DOM event a Host may dispatch to push a fresh snapshot without polling. */
|
|
56
|
+
const CHANGE_EVENT = "dsh-a2a:groups-changed";
|
|
57
|
+
/** How many feed lines one expanded group shows. */
|
|
58
|
+
const FEED_LIMIT = 20;
|
|
59
|
+
/** Polling fallback while the panel is mounted (ms). Cheap: signature diff. */
|
|
60
|
+
const POLL_MS = 2000;
|
|
61
|
+
/** Sidebar row order; schedule uses 10 and the workspace picker sits above. */
|
|
62
|
+
const PANEL_ORDER = 40;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Key of the right-column members tab.
|
|
66
|
+
*
|
|
67
|
+
* One key for both the body and the title: the owner dispatches them through two
|
|
68
|
+
* slots, and a body registered under a key with no title renders as blank chrome.
|
|
69
|
+
*/
|
|
70
|
+
const MEMBERS_TAB_KEY = "a2a-members";
|
|
71
|
+
|
|
72
|
+
/** Kind of the right-column members page. Also its tab key. */
|
|
73
|
+
const MEMBERS_TAB_KIND = "a2a-members-page";
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Services the components reach for, captured in `apply(ctx)`.
|
|
77
|
+
*
|
|
78
|
+
* Components are rendered by the host rather than called by us, so they cannot take
|
|
79
|
+
* the context as an argument. One module-level handle is the smallest way to let a
|
|
80
|
+
* click handler ask the right column to open.
|
|
81
|
+
*/
|
|
82
|
+
const services = { sidebarRight: undefined, sidebarRightTabs: undefined };
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The panel's tabs, in the order a person scans them.
|
|
86
|
+
*
|
|
87
|
+
* `icon` names are resolved through {@link icon}, which prefers the product's own
|
|
88
|
+
* primitives export and falls back to geometry extracted from the shipped shell.
|
|
89
|
+
* Each tab keeps its text label: an icon-only tab bar in a 280px column makes the
|
|
90
|
+
* reader hover to find out what 「我参与」 means, and these four words are short
|
|
91
|
+
* enough that the glyph is a scanning aid rather than a replacement.
|
|
92
|
+
*/
|
|
93
|
+
const PANEL_TABS = [
|
|
94
|
+
{ id: "all", label: "全部", icon: "list" },
|
|
95
|
+
{ id: "mine", label: "我创建", icon: "plus" },
|
|
96
|
+
{ id: "joined", label: "我参与", icon: "users" },
|
|
97
|
+
{ id: "cards", label: "同事名片", icon: "user" }
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
//#endregion
|
|
101
|
+
|
|
102
|
+
//#region host bridge
|
|
103
|
+
/** The object that may carry the injected snapshot (browser window). */
|
|
104
|
+
function hostObject() {
|
|
105
|
+
if (typeof window !== "undefined" && window !== null) return window;
|
|
106
|
+
if (typeof globalThis !== "undefined" && globalThis !== null) return globalThis;
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Host-controlled data source. Either a function returning a snapshot, or a
|
|
112
|
+
* `{ read, subscribe }` object. Set through `exports.setDataSource`, which is
|
|
113
|
+
* the seam lib/index.js uses to forward `ctx.remote`.
|
|
114
|
+
*/
|
|
115
|
+
let dataSource;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Install the Host data source (Lead integration seam).
|
|
119
|
+
* @param source - `{ read: () => snapshot, subscribe?: (fn) => () => void }`
|
|
120
|
+
* or a bare snapshot reader function; `null`/anything else clears it.
|
|
121
|
+
*/
|
|
122
|
+
function setDataSource(source) {
|
|
123
|
+
if (typeof source === "function") {
|
|
124
|
+
dataSource = { read: source };
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (source && typeof source === "object" && typeof source.read === "function") {
|
|
128
|
+
dataSource = source;
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
dataSource = undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Read the raw, still-unvalidated snapshot from the best available source. */
|
|
135
|
+
function readRaw() {
|
|
136
|
+
if (dataSource !== undefined) {
|
|
137
|
+
try {
|
|
138
|
+
const value = dataSource.read();
|
|
139
|
+
if (value !== undefined) return value;
|
|
140
|
+
} catch (error) {
|
|
141
|
+
// A Host read failure must never surface as a render error.
|
|
142
|
+
warn("data source read failed", error);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const host = hostObject();
|
|
146
|
+
if (host === undefined) return undefined;
|
|
147
|
+
try {
|
|
148
|
+
return host[GLOBAL_KEY];
|
|
149
|
+
} catch (error) {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Subscribe to Host pushes. Always returns a disposer, never throws. */
|
|
155
|
+
function subscribeGroups(listener) {
|
|
156
|
+
const disposers = [];
|
|
157
|
+
if (dataSource !== undefined && typeof dataSource.subscribe === "function") {
|
|
158
|
+
try {
|
|
159
|
+
const off = dataSource.subscribe(listener);
|
|
160
|
+
if (typeof off === "function") disposers.push(off);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
warn("data source subscribe failed", error);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const host = hostObject();
|
|
166
|
+
const raw = readRaw();
|
|
167
|
+
if (raw !== null && typeof raw === "object" && typeof raw.subscribe === "function") {
|
|
168
|
+
try {
|
|
169
|
+
const off = raw.subscribe(listener);
|
|
170
|
+
if (typeof off === "function") disposers.push(off);
|
|
171
|
+
} catch (error) {
|
|
172
|
+
warn("snapshot subscribe failed", error);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (host !== undefined && typeof host.addEventListener === "function") {
|
|
176
|
+
const handler = () => {
|
|
177
|
+
listener();
|
|
178
|
+
};
|
|
179
|
+
try {
|
|
180
|
+
host.addEventListener(CHANGE_EVENT, handler);
|
|
181
|
+
disposers.push(() => {
|
|
182
|
+
host.removeEventListener(CHANGE_EVENT, handler);
|
|
183
|
+
});
|
|
184
|
+
} catch (error) {
|
|
185
|
+
/* non-DOM host: polling still covers it */
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return () => {
|
|
189
|
+
for (const dispose of disposers) {
|
|
190
|
+
try {
|
|
191
|
+
dispose();
|
|
192
|
+
} catch (error) {
|
|
193
|
+
/* disposers are best-effort */
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Console warning that cannot itself throw (no console, or a frozen one). */
|
|
200
|
+
function warn(message, detail) {
|
|
201
|
+
try {
|
|
202
|
+
if (typeof console !== "undefined" && console !== null && typeof console.warn === "function") {
|
|
203
|
+
console.warn("[dsh-a2a] " + message, detail === undefined ? "" : detail);
|
|
204
|
+
}
|
|
205
|
+
} catch (error) {
|
|
206
|
+
/* logging is never load-bearing */
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
//#endregion
|
|
210
|
+
|
|
211
|
+
//#region normalizers (total functions: garbage in, empty snapshot out)
|
|
212
|
+
function pickString(value) {
|
|
213
|
+
if (typeof value === "string") return value.trim();
|
|
214
|
+
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
215
|
+
return "";
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function pickNumber(value) {
|
|
219
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : 0;
|
|
220
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
221
|
+
const parsed = Number(value);
|
|
222
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
223
|
+
}
|
|
224
|
+
return 0;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Accept epoch ms, epoch seconds, Date, or an ISO/parsable string. */
|
|
228
|
+
function pickTime(value) {
|
|
229
|
+
if (value instanceof Date) {
|
|
230
|
+
const ms = value.getTime();
|
|
231
|
+
return Number.isFinite(ms) ? ms : 0;
|
|
232
|
+
}
|
|
233
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
234
|
+
if (value <= 0) return 0;
|
|
235
|
+
return value < 1e12 ? Math.round(value * 1000) : value;
|
|
236
|
+
}
|
|
237
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
238
|
+
const parsed = Date.parse(value);
|
|
239
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
240
|
+
return pickTime(Number(value));
|
|
241
|
+
}
|
|
242
|
+
return 0;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function firstDefined(bag, keys) {
|
|
246
|
+
for (const key of keys) {
|
|
247
|
+
if (bag[key] !== undefined && bag[key] !== null) return bag[key];
|
|
248
|
+
}
|
|
249
|
+
return undefined;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* One roster member. `personId`/`displayName` are lib/groups.js `publicMember`.
|
|
254
|
+
*
|
|
255
|
+
* `about` is that member's `roleAbout` — what they do IN THIS GROUP. It is read
|
|
256
|
+
* here rather than from the person table because it is per-group by design: the
|
|
257
|
+
* same person is "研发" in one group and "产品" in another, and the members pane
|
|
258
|
+
* shows one group, so it must show that group's answer.
|
|
259
|
+
*/
|
|
260
|
+
function normalizeMember(raw, index) {
|
|
261
|
+
if (!raw || typeof raw !== "object") return undefined;
|
|
262
|
+
const name = pickString(firstDefined(raw, ["displayName", "name", "title", "label"]));
|
|
263
|
+
// Both names are accepted: this bundle is cached by the page and may talk to a Host
|
|
264
|
+
// that still sends the older field. Do not drop the old one — a cached bundle and a
|
|
265
|
+
// fresh Host are a normal pairing for a few navigations after an upgrade.
|
|
266
|
+
const id = pickString(firstDefined(raw, ["personId", "memberId", "id", "sessionId", "key"])) || "member-" + String(index);
|
|
267
|
+
// "present but empty" and "absent" mean different things: the tool leaves a field
|
|
268
|
+
// alone when it is omitted, so empty == cleared. Losing that distinction would let
|
|
269
|
+
// a cleared description be silently re-filled from the person table.
|
|
270
|
+
const aboutRaw = firstDefined(raw, ["roleAbout", "about", "headline"]);
|
|
271
|
+
return {
|
|
272
|
+
id: id,
|
|
273
|
+
name: name !== "" ? name : id,
|
|
274
|
+
role: pickString(firstDefined(raw, ["role", "kind"])),
|
|
275
|
+
roleAbout: pickString(aboutRaw),
|
|
276
|
+
roleAboutKnown: aboutRaw !== undefined,
|
|
277
|
+
tags: (Array.isArray(raw.tags) ? raw.tags : []).filter(function (tag) {
|
|
278
|
+
return typeof tag === "string" && tag !== "";
|
|
279
|
+
}),
|
|
280
|
+
online: raw.online === true,
|
|
281
|
+
self: raw.self === true || raw.isSelf === true
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* One feed / last-message entry.
|
|
287
|
+
*
|
|
288
|
+
* `seq`/`msgId`/`at`/`from:{personId,displayName}`/`kind`/`text` is exactly
|
|
289
|
+
* `lib/groups.js` `publicEntry()`; the rest are conveniences for other Hosts.
|
|
290
|
+
*/
|
|
291
|
+
function normalizeEntry(raw, index) {
|
|
292
|
+
if (!raw || typeof raw !== "object") return undefined;
|
|
293
|
+
const text = pickString(firstDefined(raw, ["text", "body", "summary", "message", "content", "preview"]));
|
|
294
|
+
// `from` is an object in groups.js (`{personId, displayName}`), a string elsewhere.
|
|
295
|
+
const from = raw.from;
|
|
296
|
+
const fromName = from !== null && typeof from === "object" ? pickString(firstDefined(from, ["displayName", "name", "personId", "memberId"])) : pickString(from);
|
|
297
|
+
const senderName = fromName !== "" ? fromName : pickString(firstDefined(raw, ["senderName", "sender", "author", "name"]));
|
|
298
|
+
const time = pickTime(firstDefined(raw, ["at", "time", "ts", "createdAt", "sentAt"]));
|
|
299
|
+
const seq = pickNumber(firstDefined(raw, ["seq", "sequence"]));
|
|
300
|
+
const id = pickString(firstDefined(raw, ["msgId", "id", "messageId"]));
|
|
301
|
+
return {
|
|
302
|
+
id: id !== "" ? id : seq > 0 ? "seq-" + String(seq) : "entry-" + String(index),
|
|
303
|
+
seq: seq,
|
|
304
|
+
text: text,
|
|
305
|
+
senderName: senderName !== "" ? senderName : "未知成员",
|
|
306
|
+
time: time,
|
|
307
|
+
kind: pickString(firstDefined(raw, ["kind", "type"]))
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** One collaboration group plus its roster and feed. */
|
|
312
|
+
function normalizeGroup(raw, index) {
|
|
313
|
+
if (!raw || typeof raw !== "object") return undefined;
|
|
314
|
+
const id = pickString(firstDefined(raw, ["id", "groupId", "group"])) || "group-" + String(index);
|
|
315
|
+
const name = pickString(firstDefined(raw, ["name", "title", "label"])) || "未命名群";
|
|
316
|
+
|
|
317
|
+
// `roster` is the list a Session cached after calling with a member credential;
|
|
318
|
+
// `members` is groups.js `members()` for a group hosted right here. Either is
|
|
319
|
+
// authoritative — both came from a caller that held a credential.
|
|
320
|
+
const membersRaw = Array.isArray(raw.roster)
|
|
321
|
+
? raw.roster
|
|
322
|
+
: Array.isArray(raw.members)
|
|
323
|
+
? raw.members
|
|
324
|
+
: Array.isArray(raw.memberList)
|
|
325
|
+
? raw.memberList
|
|
326
|
+
: [];
|
|
327
|
+
const members = [];
|
|
328
|
+
for (let i = 0; i < membersRaw.length; i += 1) {
|
|
329
|
+
const member = normalizeMember(membersRaw[i], i);
|
|
330
|
+
if (member !== undefined) members.push(member);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// `feedTail` is what lib/groups.js `snapshot()` calls it; `feed`/`messages` are conveniences.
|
|
334
|
+
const feedRaw = Array.isArray(raw.feed) ? raw.feed : Array.isArray(raw.messages) ? raw.messages : Array.isArray(raw.feedTail) ? raw.feedTail : [];
|
|
335
|
+
const feed = [];
|
|
336
|
+
for (let i = 0; i < feedRaw.length; i += 1) {
|
|
337
|
+
const entry = normalizeEntry(feedRaw[i], i);
|
|
338
|
+
if (entry !== undefined) feed.push(entry);
|
|
339
|
+
}
|
|
340
|
+
feed.sort((left, right) => left.time - right.time || left.seq - right.seq);
|
|
341
|
+
const recentFeed = feed.length > FEED_LIMIT ? feed.slice(feed.length - FEED_LIMIT) : feed;
|
|
342
|
+
|
|
343
|
+
// The Host no longer sends message bodies — deliberately: the snapshot is written into the page
|
|
344
|
+
// HTML, and a group member reading the group is not the same as the body appearing in every
|
|
345
|
+
// page render. So the summary is assembled from routing facts.
|
|
346
|
+
//
|
|
347
|
+
// `lastFromName` names who spoke without quoting them. A system line has no author, which is
|
|
348
|
+
// why it is carried as its own flag rather than inferred from an empty name — "nobody said
|
|
349
|
+
// anything" and "the system said something" are different statements.
|
|
350
|
+
const lastFrom = pickString(firstDefined(raw, ["lastFromName", "lastFrom"]));
|
|
351
|
+
const lastIsSystem = raw.lastIsSystem === true;
|
|
352
|
+
const lastAt = pickTime(firstDefined(raw, ["lastTime", "lastPostAt", "lastMessageAt"]));
|
|
353
|
+
|
|
354
|
+
// An older Host (or a fixture written against one) sends a whole `lastPost` object including
|
|
355
|
+
// `text`. Its author and time are useful; its body must be dropped here rather than carried
|
|
356
|
+
// into the summary, or the leak returns through the compatibility path where nobody looks.
|
|
357
|
+
// The oldest explicit form: a whole `lastMessage` object. Its `from`/`at` are useful and its
|
|
358
|
+
// `body`/`text` are dropped on the way in.
|
|
359
|
+
const legacyExplicit = raw.lastMessage !== null && typeof raw.lastMessage === "object"
|
|
360
|
+
? raw.lastMessage
|
|
361
|
+
: undefined;
|
|
362
|
+
const legacyLast = raw.lastPost !== null && typeof raw.lastPost === "object" ? raw.lastPost : undefined;
|
|
363
|
+
const legacyFrom = legacyLast === undefined
|
|
364
|
+
? ""
|
|
365
|
+
: pickString(firstDefined(legacyLast, ["from", "senderName", "displayName"]))
|
|
366
|
+
|| (legacyLast.from !== null && typeof legacyLast.from === "object"
|
|
367
|
+
? pickString(legacyLast.from.displayName)
|
|
368
|
+
: "");
|
|
369
|
+
const legacyAt = legacyLast === undefined ? 0 : pickTime(firstDefined(legacyLast, ["at", "time"]));
|
|
370
|
+
|
|
371
|
+
// The oldest shape: a feed array. Its last entry names an author and a time, and its `text` is
|
|
372
|
+
// dropped — a compatibility path that forwards the body is the same leak in a place nobody
|
|
373
|
+
// audits.
|
|
374
|
+
const feedTailAuthor = (() => {
|
|
375
|
+
if (lastFrom !== "" && lastAt > 0) return { from: "", at: 0 };
|
|
376
|
+
if (!Array.isArray(raw.feed) || raw.feed.length === 0) return { from: "", at: 0 };
|
|
377
|
+
const last = raw.feed[raw.feed.length - 1];
|
|
378
|
+
if (last === null || typeof last !== "object") return { from: "", at: 0 };
|
|
379
|
+
const name = pickString(firstDefined(last, ["senderName", "fromName"]))
|
|
380
|
+
|| (last.from !== null && typeof last.from === "object" ? pickString(last.from.displayName) : "")
|
|
381
|
+
|| (typeof last.from === "string" ? pickString(last.from) : "");
|
|
382
|
+
return { from: name, at: pickTime(firstDefined(last, ["at", "time"])) };
|
|
383
|
+
})();
|
|
384
|
+
|
|
385
|
+
const explicitFrom = legacyExplicit === undefined
|
|
386
|
+
? ""
|
|
387
|
+
: pickString(firstDefined(legacyExplicit, ["senderName", "fromName"]))
|
|
388
|
+
|| (legacyExplicit.from !== null && typeof legacyExplicit.from === "object"
|
|
389
|
+
? pickString(legacyExplicit.from.displayName)
|
|
390
|
+
: pickString(legacyExplicit.from));
|
|
391
|
+
const explicitAt = legacyExplicit === undefined ? 0 : pickTime(firstDefined(legacyExplicit, ["at", "time"]));
|
|
392
|
+
|
|
393
|
+
const effectiveFrom = lastFrom !== "" ? lastFrom
|
|
394
|
+
: (legacyFrom !== "" ? legacyFrom
|
|
395
|
+
: (explicitFrom !== "" ? explicitFrom : feedTailAuthor.from));
|
|
396
|
+
const effectiveAt = lastAt > 0 ? lastAt
|
|
397
|
+
: (legacyAt > 0 ? legacyAt
|
|
398
|
+
: (explicitAt > 0 ? explicitAt : feedTailAuthor.at));
|
|
399
|
+
|
|
400
|
+
// Kept as an object so every existing renderer keeps working; `text` is intentionally
|
|
401
|
+
// absent, and `from` carries the author instead.
|
|
402
|
+
// An author or a system marker is required. A timestamp on its own is not evidence of a message:
|
|
403
|
+
// it may be a settings change, and rendering "有新消息" for it would invent a line the group never
|
|
404
|
+
// had. Ordering reads `lastTime` separately, so nothing is lost.
|
|
405
|
+
const hasSummary = effectiveFrom !== "" || lastIsSystem === true;
|
|
406
|
+
let lastMessage = hasSummary === false
|
|
407
|
+
? undefined
|
|
408
|
+
: {
|
|
409
|
+
id: "",
|
|
410
|
+
seq: 0,
|
|
411
|
+
time: effectiveAt,
|
|
412
|
+
from: effectiveFrom,
|
|
413
|
+
// `senderName` is the name other renderers read; kept because the card, the feed rows and
|
|
414
|
+
// the tests all address an author by it.
|
|
415
|
+
senderName: effectiveFrom,
|
|
416
|
+
// Never the message body. This string is the whole summary the card shows.
|
|
417
|
+
text: lastIsSystem === true
|
|
418
|
+
? "群里有新消息"
|
|
419
|
+
: (effectiveFrom === "" ? "有新消息" : effectiveFrom + " 发来一条消息"),
|
|
420
|
+
system: lastIsSystem
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
const declaredMemberCount = pickNumber(firstDefined(raw, ["memberCount", "membersCount", "memberTotal"]));
|
|
424
|
+
const unreadCount = Math.max(0, Math.round(pickNumber(firstDefined(raw, ["unreadCount", "unread", "unreadTotal"]))));
|
|
425
|
+
|
|
426
|
+
return {
|
|
427
|
+
id: id,
|
|
428
|
+
name: name,
|
|
429
|
+
// `topic` is the secondary line in groups.js; shown as the card tooltip.
|
|
430
|
+
topic: pickString(firstDefined(raw, ["topic", "description"])),
|
|
431
|
+
memberCount: declaredMemberCount > 0 ? Math.round(declaredMemberCount) : members.length,
|
|
432
|
+
unreadCount: unreadCount,
|
|
433
|
+
lastMessage: lastMessage,
|
|
434
|
+
lastTime: lastMessage === undefined ? 0 : lastMessage.time,
|
|
435
|
+
members: members,
|
|
436
|
+
// No feed array: the Host does not send one any more, and keeping an always-empty field invites
|
|
437
|
+
// a future renderer to depend on it.
|
|
438
|
+
feed: recentFeed,
|
|
439
|
+
// `role` is the permission bit (owner | member). It decides which section
|
|
440
|
+
// the group belongs to, because the ACTIONS differ: an owner approves and
|
|
441
|
+
// invites, a member only reads and posts.
|
|
442
|
+
role: pickString(firstDefined(raw, ["role", "permission"])) || "member",
|
|
443
|
+
// A group that exists only as a directory row: not joined, so it belongs in
|
|
444
|
+
// "discover" and its only action is "ask to join".
|
|
445
|
+
discovered: raw.discovered === true || raw.joined === false,
|
|
446
|
+
// Which Session this membership belongs to, when the Host says.
|
|
447
|
+
sessionId: pickString(firstDefined(raw, ["sessionId", "session"])),
|
|
448
|
+
// My own person id IN THIS GROUP, when the Host puts it on the membership row
|
|
449
|
+
// (`lib/index.js` does). It is what lets the pane say "这是你" before any
|
|
450
|
+
// roster has been fetched, which is the normal state on first open.
|
|
451
|
+
personId: pickString(firstDefined(raw, ["personId", "memberId"])),
|
|
452
|
+
muted: raw.muted === true || raw.mute === true
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Coerce anything the Host (or a future `ctx.remote` read) hands over into
|
|
458
|
+
* the shape the components consume.
|
|
459
|
+
* @param raw - array of groups, `{ groups: [...] }`, `{ items: [...] }`, or junk.
|
|
460
|
+
* @returns `{ groups, updatedAt, unreadTotal, ok }` — always safe to spread.
|
|
461
|
+
*/
|
|
462
|
+
function readSnapshot(raw) {
|
|
463
|
+
const empty = {
|
|
464
|
+
groups: [],
|
|
465
|
+
cards: [],
|
|
466
|
+
updatedAt: 0,
|
|
467
|
+
unreadTotal: 0,
|
|
468
|
+
ok: false
|
|
469
|
+
};
|
|
470
|
+
try {
|
|
471
|
+
const container = Array.isArray(raw) ? raw : raw !== null && typeof raw === "object" ? (Array.isArray(raw.groups) ? raw.groups : Array.isArray(raw.items) ? raw.items : []) : [];
|
|
472
|
+
const groups = [];
|
|
473
|
+
for (let i = 0; i < container.length; i += 1) {
|
|
474
|
+
const group = normalizeGroup(container[i], i);
|
|
475
|
+
if (group !== undefined) groups.push(group);
|
|
476
|
+
}
|
|
477
|
+
let unreadTotal = 0;
|
|
478
|
+
for (const group of groups) unreadTotal += group.unreadCount;
|
|
479
|
+
const updatedAt = raw !== null && typeof raw === "object" && !Array.isArray(raw) ? pickTime(firstDefined(raw, ["updatedAt", "time", "ts"])) : 0;
|
|
480
|
+
return {
|
|
481
|
+
groups: groups,
|
|
482
|
+
cards: readCards(raw),
|
|
483
|
+
updatedAt: updatedAt,
|
|
484
|
+
unreadTotal: unreadTotal,
|
|
485
|
+
ok: true
|
|
486
|
+
};
|
|
487
|
+
} catch (error) {
|
|
488
|
+
return empty;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** Current snapshot straight from the Host, normalized. */
|
|
493
|
+
function currentSnapshot() {
|
|
494
|
+
return readSnapshot(readRaw());
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Cheap change signature so polling only re-renders on real movement. */
|
|
498
|
+
function signatureOf(snapshot) {
|
|
499
|
+
let signature = String(snapshot.groups.length) + "|" + String(snapshot.unreadTotal);
|
|
500
|
+
for (const group of snapshot.groups) {
|
|
501
|
+
signature += "|" + group.id + ":" + String(group.memberCount) + ":" + String(group.unreadCount) + ":" + String(group.lastTime);
|
|
502
|
+
const last = group.lastMessage;
|
|
503
|
+
if (last !== undefined) signature += ":" + last.id + ":" + String(last.text.length);
|
|
504
|
+
}
|
|
505
|
+
return signature;
|
|
506
|
+
}
|
|
507
|
+
//#endregion
|
|
508
|
+
|
|
509
|
+
//#region time formatting (no dependency on primitive time helpers)
|
|
510
|
+
function pad2(value) {
|
|
511
|
+
return value < 10 ? "0" + String(value) : String(value);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** Compact Chinese relative time, e.g. 「刚刚」「3 分钟前」「昨天 14:05」. */
|
|
515
|
+
function formatTime(time) {
|
|
516
|
+
if (!Number.isFinite(time) || time <= 0) return "";
|
|
517
|
+
const now = Date.now();
|
|
518
|
+
const delta = now - time;
|
|
519
|
+
if (delta >= 0 && delta < 60 * 1000) return "刚刚";
|
|
520
|
+
if (delta >= 60 * 1000 && delta < 60 * 60 * 1000) return String(Math.floor(delta / (60 * 1000))) + " 分钟前";
|
|
521
|
+
if (delta >= 60 * 60 * 1000 && delta < 24 * 60 * 60 * 1000) return String(Math.floor(delta / (60 * 60 * 1000))) + " 小时前";
|
|
522
|
+
const then = new Date(time);
|
|
523
|
+
const today = new Date(now);
|
|
524
|
+
const clock = pad2(then.getHours()) + ":" + pad2(then.getMinutes());
|
|
525
|
+
const sameDay = then.getFullYear() === today.getFullYear() && then.getMonth() === today.getMonth() && then.getDate() === today.getDate();
|
|
526
|
+
if (sameDay) return clock;
|
|
527
|
+
const yesterday = new Date(now - 24 * 60 * 60 * 1000);
|
|
528
|
+
const isYesterday = then.getFullYear() === yesterday.getFullYear() && then.getMonth() === yesterday.getMonth() && then.getDate() === yesterday.getDate();
|
|
529
|
+
if (isYesterday) return "昨天 " + clock;
|
|
530
|
+
if (then.getFullYear() === today.getFullYear()) return String(then.getMonth() + 1) + "月" + String(then.getDate()) + "日 " + clock;
|
|
531
|
+
return String(then.getFullYear()) + "-" + pad2(then.getMonth() + 1) + "-" + pad2(then.getDate());
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** One-line preview: collapse newlines, clamp length. */
|
|
535
|
+
function previewText(text, limit) {
|
|
536
|
+
const flat = String(text === undefined || text === null ? "" : text).replace(/\s+/g, " ").trim();
|
|
537
|
+
if (flat === "") return "(暂无内容)";
|
|
538
|
+
const max = typeof limit === "number" && limit > 0 ? limit : 60;
|
|
539
|
+
return flat.length > max ? flat.slice(0, max) + "…" : flat;
|
|
540
|
+
}
|
|
541
|
+
//#endregion
|
|
542
|
+
|
|
543
|
+
//#region styles (inline only: no CSS file, no build artifacts)
|
|
544
|
+
const S = {
|
|
545
|
+
root: {
|
|
546
|
+
display: "flex",
|
|
547
|
+
flexDirection: "column",
|
|
548
|
+
gap: "10px",
|
|
549
|
+
padding: "16px",
|
|
550
|
+
height: "100%",
|
|
551
|
+
minHeight: "0",
|
|
552
|
+
overflowY: "auto",
|
|
553
|
+
boxSizing: "border-box",
|
|
554
|
+
color: "inherit",
|
|
555
|
+
font: "13px/1.5 inherit"
|
|
556
|
+
},
|
|
557
|
+
header: {
|
|
558
|
+
display: "flex",
|
|
559
|
+
alignItems: "baseline",
|
|
560
|
+
gap: "8px",
|
|
561
|
+
flexWrap: "wrap"
|
|
562
|
+
},
|
|
563
|
+
title: {
|
|
564
|
+
fontSize: "15px",
|
|
565
|
+
fontWeight: "600",
|
|
566
|
+
margin: 0
|
|
567
|
+
},
|
|
568
|
+
hint: {
|
|
569
|
+
fontSize: "12px",
|
|
570
|
+
opacity: 0.55
|
|
571
|
+
},
|
|
572
|
+
empty: {
|
|
573
|
+
display: "flex",
|
|
574
|
+
flexDirection: "column",
|
|
575
|
+
gap: "6px",
|
|
576
|
+
alignItems: "flex-start",
|
|
577
|
+
padding: "20px 16px",
|
|
578
|
+
borderRadius: "10px",
|
|
579
|
+
border: "1px dashed rgba(127,127,127,0.35)",
|
|
580
|
+
opacity: 0.85
|
|
581
|
+
},
|
|
582
|
+
emptyTitle: {
|
|
583
|
+
fontSize: "14px",
|
|
584
|
+
fontWeight: "600",
|
|
585
|
+
margin: 0
|
|
586
|
+
},
|
|
587
|
+
/* Dimmed illustration for the empty state: present enough to soften the panel,
|
|
588
|
+
faint enough that it never competes with the sentence under it. */
|
|
589
|
+
emptyIcon: {
|
|
590
|
+
display: "inline-flex",
|
|
591
|
+
alignItems: "center",
|
|
592
|
+
justifyContent: "center",
|
|
593
|
+
marginBottom: "2px",
|
|
594
|
+
opacity: 0.3,
|
|
595
|
+
color: "currentColor"
|
|
596
|
+
},
|
|
597
|
+
emptyBody: {
|
|
598
|
+
fontSize: "12px",
|
|
599
|
+
opacity: 0.6,
|
|
600
|
+
margin: 0
|
|
601
|
+
},
|
|
602
|
+
card: {
|
|
603
|
+
borderRadius: "10px",
|
|
604
|
+
border: "1px solid rgba(127,127,127,0.25)",
|
|
605
|
+
overflow: "hidden",
|
|
606
|
+
background: "transparent"
|
|
607
|
+
},
|
|
608
|
+
cardHead: {
|
|
609
|
+
display: "flex",
|
|
610
|
+
alignItems: "center",
|
|
611
|
+
gap: "8px",
|
|
612
|
+
width: "100%",
|
|
613
|
+
padding: "9px 11px",
|
|
614
|
+
border: "none",
|
|
615
|
+
background: "transparent",
|
|
616
|
+
color: "inherit",
|
|
617
|
+
cursor: "pointer",
|
|
618
|
+
textAlign: "left",
|
|
619
|
+
font: "inherit"
|
|
620
|
+
},
|
|
621
|
+
chevron: {
|
|
622
|
+
flex: "none",
|
|
623
|
+
display: "inline-flex",
|
|
624
|
+
alignItems: "center",
|
|
625
|
+
opacity: 0.55,
|
|
626
|
+
color: "inherit"
|
|
627
|
+
},
|
|
628
|
+
groupName: {
|
|
629
|
+
flex: "1 1 auto",
|
|
630
|
+
minWidth: 0,
|
|
631
|
+
fontWeight: "600",
|
|
632
|
+
whiteSpace: "nowrap",
|
|
633
|
+
overflow: "hidden",
|
|
634
|
+
textOverflow: "ellipsis"
|
|
635
|
+
},
|
|
636
|
+
badge: {
|
|
637
|
+
flex: "none",
|
|
638
|
+
minWidth: "17px",
|
|
639
|
+
padding: "0 5px",
|
|
640
|
+
borderRadius: "9px",
|
|
641
|
+
textAlign: "center",
|
|
642
|
+
fontSize: "11px",
|
|
643
|
+
lineHeight: "17px",
|
|
644
|
+
background: "rgba(240,80,80,0.18)",
|
|
645
|
+
color: "inherit",
|
|
646
|
+
fontWeight: "600"
|
|
647
|
+
},
|
|
648
|
+
/* A count with a glyph instead of a word: 「3 人」 became an icon plus a number,
|
|
649
|
+
which is how every IM client shows a roster size and costs one third the width. */
|
|
650
|
+
metaChip: {
|
|
651
|
+
flex: "none",
|
|
652
|
+
display: "inline-flex",
|
|
653
|
+
alignItems: "center",
|
|
654
|
+
gap: "3px",
|
|
655
|
+
fontSize: "11px",
|
|
656
|
+
opacity: 0.55,
|
|
657
|
+
lineHeight: "1"
|
|
658
|
+
},
|
|
659
|
+
memberCount: {
|
|
660
|
+
flex: "none",
|
|
661
|
+
display: "inline-flex",
|
|
662
|
+
alignItems: "center",
|
|
663
|
+
gap: "3px",
|
|
664
|
+
fontSize: "11px",
|
|
665
|
+
opacity: 0.55
|
|
666
|
+
},
|
|
667
|
+
cardBody: {
|
|
668
|
+
display: "flex",
|
|
669
|
+
flexDirection: "column",
|
|
670
|
+
gap: "10px",
|
|
671
|
+
padding: "0 11px 11px"
|
|
672
|
+
},
|
|
673
|
+
last: {
|
|
674
|
+
display: "flex",
|
|
675
|
+
gap: "6px",
|
|
676
|
+
fontSize: "12px",
|
|
677
|
+
opacity: 0.72,
|
|
678
|
+
minWidth: 0
|
|
679
|
+
},
|
|
680
|
+
lastWho: {
|
|
681
|
+
flex: "none",
|
|
682
|
+
fontWeight: "600"
|
|
683
|
+
},
|
|
684
|
+
lastText: {
|
|
685
|
+
flex: "1 1 auto",
|
|
686
|
+
minWidth: 0,
|
|
687
|
+
whiteSpace: "nowrap",
|
|
688
|
+
overflow: "hidden",
|
|
689
|
+
textOverflow: "ellipsis"
|
|
690
|
+
},
|
|
691
|
+
lastTime: {
|
|
692
|
+
flex: "none",
|
|
693
|
+
fontSize: "11px",
|
|
694
|
+
opacity: 0.7
|
|
695
|
+
},
|
|
696
|
+
sectionLabel: {
|
|
697
|
+
fontSize: "11px",
|
|
698
|
+
letterSpacing: "0.04em",
|
|
699
|
+
textTransform: "uppercase",
|
|
700
|
+
opacity: 0.5,
|
|
701
|
+
margin: 0
|
|
702
|
+
},
|
|
703
|
+
memberRow: {
|
|
704
|
+
display: "flex",
|
|
705
|
+
flexWrap: "wrap",
|
|
706
|
+
gap: "4px"
|
|
707
|
+
},
|
|
708
|
+
chip: {
|
|
709
|
+
padding: "1px 7px",
|
|
710
|
+
borderRadius: "9px",
|
|
711
|
+
border: "1px solid rgba(127,127,127,0.3)",
|
|
712
|
+
fontSize: "11px",
|
|
713
|
+
opacity: 0.85
|
|
714
|
+
},
|
|
715
|
+
feed: {
|
|
716
|
+
display: "flex",
|
|
717
|
+
flexDirection: "column",
|
|
718
|
+
gap: "5px",
|
|
719
|
+
margin: 0,
|
|
720
|
+
padding: 0,
|
|
721
|
+
listStyle: "none"
|
|
722
|
+
},
|
|
723
|
+
feedItem: {
|
|
724
|
+
display: "flex",
|
|
725
|
+
gap: "6px",
|
|
726
|
+
fontSize: "12px",
|
|
727
|
+
minWidth: 0
|
|
728
|
+
},
|
|
729
|
+
feedWho: {
|
|
730
|
+
flex: "none",
|
|
731
|
+
fontWeight: "600",
|
|
732
|
+
opacity: 0.85
|
|
733
|
+
},
|
|
734
|
+
feedText: {
|
|
735
|
+
flex: "1 1 auto",
|
|
736
|
+
minWidth: 0,
|
|
737
|
+
wordBreak: "break-word",
|
|
738
|
+
opacity: 0.78
|
|
739
|
+
},
|
|
740
|
+
sidebarGlyph: {
|
|
741
|
+
position: "relative",
|
|
742
|
+
display: "inline-flex",
|
|
743
|
+
alignItems: "center",
|
|
744
|
+
justifyContent: "center",
|
|
745
|
+
lineHeight: 0
|
|
746
|
+
},
|
|
747
|
+
sidebarBadge: {
|
|
748
|
+
position: "absolute",
|
|
749
|
+
top: "-4px",
|
|
750
|
+
right: "-6px",
|
|
751
|
+
minWidth: "12px",
|
|
752
|
+
height: "12px",
|
|
753
|
+
padding: "0 3px",
|
|
754
|
+
borderRadius: "6px",
|
|
755
|
+
fontSize: "9px",
|
|
756
|
+
lineHeight: "12px",
|
|
757
|
+
textAlign: "center",
|
|
758
|
+
background: "rgba(240,80,80,0.9)",
|
|
759
|
+
color: "#fff",
|
|
760
|
+
fontWeight: "700"
|
|
761
|
+
}
|
|
762
|
+
};
|
|
763
|
+
//#endregion
|
|
764
|
+
|
|
765
|
+
//#region hooks
|
|
766
|
+
/** Live snapshot: initial read + Host subscription + cheap polling. */
|
|
767
|
+
function useSnapshot() {
|
|
768
|
+
const [snapshot, setSnapshot] = react.useState(currentSnapshot);
|
|
769
|
+
react.useEffect(() => {
|
|
770
|
+
let signature = signatureOf(snapshot);
|
|
771
|
+
const refresh = () => {
|
|
772
|
+
const next = currentSnapshot();
|
|
773
|
+
const nextSignature = signatureOf(next);
|
|
774
|
+
if (nextSignature === signature) return;
|
|
775
|
+
signature = nextSignature;
|
|
776
|
+
setSnapshot(next);
|
|
777
|
+
};
|
|
778
|
+
const dispose = subscribeGroups(refresh);
|
|
779
|
+
let timer;
|
|
780
|
+
if (typeof window !== "undefined" && window !== null && typeof window.setInterval === "function") {
|
|
781
|
+
timer = window.setInterval(refresh, POLL_MS);
|
|
782
|
+
}
|
|
783
|
+
return () => {
|
|
784
|
+
if (typeof dispose === "function") dispose();
|
|
785
|
+
if (timer !== undefined && typeof window.clearInterval === "function") window.clearInterval(timer);
|
|
786
|
+
};
|
|
787
|
+
}, []);
|
|
788
|
+
return snapshot;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** Expand/collapse memory for the group cards. */
|
|
792
|
+
function useExpanded() {
|
|
793
|
+
const [expanded, setExpanded] = react.useState({});
|
|
794
|
+
const toggle = (id) => {
|
|
795
|
+
setExpanded((previous) => {
|
|
796
|
+
const next = {};
|
|
797
|
+
for (const key of Object.keys(previous)) if (key !== id) next[key] = previous[key];
|
|
798
|
+
if (previous[id] !== true) next[id] = true;
|
|
799
|
+
return next;
|
|
800
|
+
});
|
|
801
|
+
};
|
|
802
|
+
return [expanded, toggle];
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* A single boolean that survives re-renders.
|
|
807
|
+
*
|
|
808
|
+
* Not persisted on purpose: a strip that remembered being open would surprise
|
|
809
|
+
* someone who collapsed it in a different Session, and collapsed is the quiet
|
|
810
|
+
* default for something that sits above every composer.
|
|
811
|
+
*/
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Insert \`@name \` into the composer, through the owner's two-step API.
|
|
815
|
+
*
|
|
816
|
+
* \`captureInsertion()\` first, then \`insertText(text, span)\`: the span carries the
|
|
817
|
+
* selection and the draft revision, so an insert into a draft that changed since the
|
|
818
|
+
* click is REFUSED rather than applied in the wrong place. Passing text alone does
|
|
819
|
+
* not throw — it silently returns false — which is why the one-argument version
|
|
820
|
+
* looked like a dead button.
|
|
821
|
+
*
|
|
822
|
+
* Returns true when something was actually written.
|
|
823
|
+
*/
|
|
824
|
+
function insertMention(inputActions, name) {
|
|
825
|
+
const text = "@" + name + " ";
|
|
826
|
+
try {
|
|
827
|
+
if (inputActions === null || inputActions === undefined) return false;
|
|
828
|
+
if (typeof inputActions.captureInsertion === "function"
|
|
829
|
+
&& typeof inputActions.insertText === "function") {
|
|
830
|
+
const span = inputActions.captureInsertion();
|
|
831
|
+
return inputActions.insertText(text, span) === true;
|
|
832
|
+
}
|
|
833
|
+
// Last resort for a Host that only offers a whole-draft setter. It replaces
|
|
834
|
+
// the draft, which is why it is the fallback and not the first choice.
|
|
835
|
+
if (typeof inputActions.setDraft === "function") {
|
|
836
|
+
inputActions.setDraft(text);
|
|
837
|
+
return true;
|
|
838
|
+
}
|
|
839
|
+
return false;
|
|
840
|
+
} catch (error) {
|
|
841
|
+
reportUnavailable("inputActions.insertText", error);
|
|
842
|
+
return false;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* Component-local state that survives re-renders.
|
|
848
|
+
*
|
|
849
|
+
* Deliberately unpersisted: the panel keeps its active tab and its tag filter here,
|
|
850
|
+
* and neither should outlive the page. A remembered tag filter would silently hide
|
|
851
|
+
* colleagues after a reload, which is worse than losing the filter.
|
|
852
|
+
*/
|
|
853
|
+
function useLocalState(initial) {
|
|
854
|
+
const [value, setValue] = react.useState(initial === undefined ? false : initial);
|
|
855
|
+
return {
|
|
856
|
+
value: value,
|
|
857
|
+
set: setValue,
|
|
858
|
+
/** The active tag filter, when this state holds a {tab, tag} pair. */
|
|
859
|
+
get tag() {
|
|
860
|
+
return value !== null && typeof value === "object" ? value.tag : "";
|
|
861
|
+
}
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
//#endregion
|
|
865
|
+
|
|
866
|
+
//#region icons
|
|
867
|
+
/**
|
|
868
|
+
* Icon layer.
|
|
869
|
+
*
|
|
870
|
+
* ## Where these come from
|
|
871
|
+
*
|
|
872
|
+
* `@dsh-client-ui-primitives` is a BUILD-TIME VENDORED module in the web shell: it
|
|
873
|
+
* is not resolvable from disk (there is no `lib/index.js` anywhere under the `dsh`
|
|
874
|
+
* install), but the shell's seed table binds it to the real package, so
|
|
875
|
+
* `require("@dsh-client-ui-primitives")` is the only correct way in. It is required
|
|
876
|
+
* once at the top of the factory and read through `primitives.IconXxxYyy`.
|
|
877
|
+
*
|
|
878
|
+
* The bundle's icon components share one contract, verified in the shipped shell:
|
|
879
|
+
*
|
|
880
|
+
* ({ size = 16, className, strokeWidth }) => svg{ width: size, height: size,
|
|
881
|
+
* viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", strokeWidth, ... }
|
|
882
|
+
*
|
|
883
|
+
* Two consequences for the code below:
|
|
884
|
+
* - every icon is already 16x16 and `aria-hidden`, so we pass `size` and nothing else
|
|
885
|
+
* decorative;
|
|
886
|
+
* - the variant suffix is weight, not shape: `...Regular` is 1px and `...Medium` is
|
|
887
|
+
* 1.3px. The geometry is identical, so a fallback drawn at 1px matches Regular.
|
|
888
|
+
*
|
|
889
|
+
* ## Why every icon has an inline-SVG fallback
|
|
890
|
+
*
|
|
891
|
+
* The primitives package is versioned with the shell. A profile whose shell predates
|
|
892
|
+
* an icon, or a future rename, would otherwise render a blank square where an
|
|
893
|
+
* affordance used to be — the worst failure mode for a clickable row. Each fallback
|
|
894
|
+
* below is not invented: its geometry was extracted from the shipped shell bundle
|
|
895
|
+
* with `scripts/extract-icon-paths.mjs`, so a fallback is pixel-identical to the
|
|
896
|
+
* product icon it stands in for. Every icon in this table is on the 16x16 grid, but
|
|
897
|
+
* each fallback still carries its own `viewBox` because the product is not uniformly
|
|
898
|
+
* 16x16 in general (`IconCheckCircleFillRegular` is 36x36).
|
|
899
|
+
*
|
|
900
|
+
* ## Icons used, and what each one means here
|
|
901
|
+
*
|
|
902
|
+
* | export | drawn as | used for |
|
|
903
|
+
* |---------------------------------|----------|---------------------------------------|
|
|
904
|
+
* | IconFlatListOutlineRegular | list | tab 「全部」 |
|
|
905
|
+
* | IconPlusOutlineRegular | plus | tab 「我创建」 |
|
|
906
|
+
* | IconUsersOutlineRegular | 2 people | tab 「我参与」/ members header / count |
|
|
907
|
+
* | IconUserOutlineRegular | 1 person | tab 「同事名片」 |
|
|
908
|
+
* | IconChevronDownOutlineRegular | ⌄ | expanded group card |
|
|
909
|
+
* | IconChevronRightOutlineRegular | › | collapsed group card |
|
|
910
|
+
* | IconNewChatOutlineRegular | speech | empty state 「还没有加入任何群」 |
|
|
911
|
+
*
|
|
912
|
+
* `IconEditOutlineRegular` is mapped for the 「改我的名片」 / edit affordances; the
|
|
913
|
+
* members rows use a text chip (`编辑介绍`) instead, because a 12px pencil next to a
|
|
914
|
+
* name reads as "rename this person" rather than "describe yourself".
|
|
915
|
+
*
|
|
916
|
+
* `@所有人` deliberately has **no** icon: it renders a literal `@`, which is the
|
|
917
|
+
* universal broadcast marker and is exactly the character the click inserts.
|
|
918
|
+
*/
|
|
919
|
+
const ICONS = {
|
|
920
|
+
list: "IconFlatListOutlineRegular",
|
|
921
|
+
plus: "IconPlusOutlineRegular",
|
|
922
|
+
users: "IconUsersOutlineRegular",
|
|
923
|
+
user: "IconUserOutlineRegular",
|
|
924
|
+
chevronDown: "IconChevronDownOutlineRegular",
|
|
925
|
+
chevronRight: "IconChevronRightOutlineRegular",
|
|
926
|
+
edit: "IconEditOutlineRegular",
|
|
927
|
+
chat: "IconNewChatOutlineRegular"
|
|
928
|
+
};
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* Fallback geometry, copied out of the shipped shell bundle.
|
|
932
|
+
*
|
|
933
|
+
* `paths` are attribute maps; `attrs` are the values the shell's shared base svg
|
|
934
|
+
* puts on the <svg> element and its children inherit (`stroke`, `strokeLinecap`).
|
|
935
|
+
* `viewBox` is per icon because not all of them use the 16x16 grid.
|
|
936
|
+
*/
|
|
937
|
+
const ICON_FALLBACKS = {
|
|
938
|
+
IconFlatListOutlineRegular: {
|
|
939
|
+
viewBox: "0 0 16 16",
|
|
940
|
+
attrs: { fill: "none", stroke: "currentColor", strokeLinecap: "round" },
|
|
941
|
+
paths: [{ d: "M6 3.5h7.5M6 8h7.5M6 12.5h7.5" }, { d: "M2.6 3.5h.01M2.6 8h.01M2.6 12.5h.01" }]
|
|
942
|
+
},
|
|
943
|
+
IconPlusOutlineRegular: {
|
|
944
|
+
viewBox: "0 0 16 16",
|
|
945
|
+
attrs: { fill: "none", stroke: "currentColor" },
|
|
946
|
+
paths: [{ d: "M8 2V14", stroke: "currentColor" }, { d: "M2 8H14", stroke: "currentColor" }]
|
|
947
|
+
},
|
|
948
|
+
IconUsersOutlineRegular: {
|
|
949
|
+
viewBox: "0 0 16 16",
|
|
950
|
+
attrs: { fill: "none", stroke: "currentColor" },
|
|
951
|
+
paths: [
|
|
952
|
+
{ d: "M6 8.25C7.51878 8.25 8.75 7.01878 8.75 5.5C8.75 3.98122 7.51878 2.75 6 2.75C4.48122 2.75 3.25 3.98122 3.25 5.5C3.25 7.01878 4.48122 8.25 6 8.25Z", stroke: "currentColor" },
|
|
953
|
+
{ d: "M1 14.5C1 11.5 3.5 10.25 6 10.25C8.5 10.25 11 11.5 11 14.5", stroke: "currentColor" },
|
|
954
|
+
{ d: "M10.5 2.9C11.65 3.35 12.45 4.35 12.45 5.5C12.45 6.65 11.65 7.65 10.5 8.1", stroke: "currentColor" },
|
|
955
|
+
{ d: "M12.4 10.6C13.9 11.3 15 12.6 15 14.5", stroke: "currentColor" }
|
|
956
|
+
]
|
|
957
|
+
},
|
|
958
|
+
IconUserOutlineRegular: {
|
|
959
|
+
viewBox: "0 0 16 16",
|
|
960
|
+
attrs: { fill: "none", stroke: "currentColor" },
|
|
961
|
+
paths: [
|
|
962
|
+
{ d: "M8 8.25C9.51878 8.25 10.75 7.01878 10.75 5.5C10.75 3.98122 9.51878 2.75 8 2.75C6.48122 2.75 5.25 3.98122 5.25 5.5C5.25 7.01878 6.48122 8.25 8 8.25Z", stroke: "currentColor" },
|
|
963
|
+
{ d: "M2.5 14.5C2.5 11.5 5.25 10.25 8 10.25C10.75 10.25 13.5 11.5 13.5 14.5", stroke: "currentColor" }
|
|
964
|
+
]
|
|
965
|
+
},
|
|
966
|
+
IconChevronDownOutlineRegular: {
|
|
967
|
+
viewBox: "0 0 16 16",
|
|
968
|
+
attrs: { fill: "none", stroke: "currentColor" },
|
|
969
|
+
paths: [{ d: "M4 6L7.29289 9.29289C7.68342 9.68342 8.31658 9.68342 8.70711 9.29289L12 6", stroke: "currentColor" }]
|
|
970
|
+
},
|
|
971
|
+
IconChevronRightOutlineRegular: {
|
|
972
|
+
viewBox: "0 0 16 16",
|
|
973
|
+
attrs: { fill: "none", stroke: "currentColor" },
|
|
974
|
+
paths: [{ d: "M6 12L9.29289 8.70711C9.68342 8.31658 9.68342 7.68342 9.29289 7.29289L6 4", stroke: "currentColor" }]
|
|
975
|
+
},
|
|
976
|
+
IconEditOutlineRegular: {
|
|
977
|
+
viewBox: "0 0 16 16",
|
|
978
|
+
attrs: { fill: "none", stroke: "currentColor" },
|
|
979
|
+
paths: [
|
|
980
|
+
{ d: "M8.85596 2.69971H4.19971C3.37141 2.69971 2.69992 3.37146 2.69971 4.19971V11.8003C2.69992 12.6285 3.37141 13.3003 4.19971 13.3003H11.8003C12.6283 13.2999 13.3001 12.6283 13.3003 11.8003V7.89893H14.3003V11.8003C14.3001 13.1806 13.1806 14.2999 11.8003 14.3003H4.19971C2.81913 14.3003 1.69992 13.1808 1.69971 11.8003V4.19971C1.69992 2.81918 2.81913 1.69971 4.19971 1.69971H8.85596V2.69971Z", fill: "currentColor" },
|
|
981
|
+
{ d: "M7.7849 8.23878L13.888 2.13574", stroke: "currentColor" }
|
|
982
|
+
]
|
|
983
|
+
},
|
|
984
|
+
IconNewChatOutlineRegular: {
|
|
985
|
+
viewBox: "0 0 16 16",
|
|
986
|
+
attrs: { fill: "none", stroke: "currentColor" },
|
|
987
|
+
paths: [
|
|
988
|
+
{ d: "M2.37091 11.2501C1.58745 9.89288 1.32067 8.29835 1.61969 6.76006C1.91872 5.22177 2.76342 3.8433 3.99826 2.87846C5.2331 1.91362 6.77494 1.42737 8.33988 1.50925C9.90482 1.59113 11.3875 2.23562 12.5149 3.32406C13.6425 4.41269 14.3387 5.87206 14.4754 7.4334C14.612 8.99474 14.18 10.5529 13.2587 11.8209C12.3375 13.0888 10.9891 13.9813 9.46194 14.3337C8.18691 14.628 6.85895 14.5294 5.64989 14.0605C5.1712 13.8748 4.76962 13.4932 4.26534 13.3967C3.67413 13.2835 2.95257 13.5598 2.03794 14.3337", stroke: "currentColor" },
|
|
989
|
+
{ d: "M8 5V11", stroke: "currentColor" },
|
|
990
|
+
{ d: "M5 8H11", stroke: "currentColor" }
|
|
991
|
+
]
|
|
992
|
+
},
|
|
993
|
+
};
|
|
994
|
+
|
|
995
|
+
/** Narrow a primitives export to something callable as a component. */
|
|
996
|
+
function isRenderable(value) {
|
|
997
|
+
return value !== null && value !== undefined && (typeof value === "function" || typeof value === "object");
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* Draw one icon.
|
|
1002
|
+
*
|
|
1003
|
+
* Resolves, in order: the primitives export for `name`, then the extracted
|
|
1004
|
+
* fallback geometry, then nothing (`null`). It never throws: an icon is decoration,
|
|
1005
|
+
* and a missing one must cost the glyph, not the row that carries the action.
|
|
1006
|
+
*
|
|
1007
|
+
* @param {string} name - key into {@link ICONS}, or a raw `Icon*` export name.
|
|
1008
|
+
* @param {number} [size] - pixel size; the product default is 16.
|
|
1009
|
+
* @returns {object|null} a vnode, or null when neither source has the icon.
|
|
1010
|
+
*/
|
|
1011
|
+
function icon(name, size) {
|
|
1012
|
+
const exportName = ICONS[name] === undefined ? name : ICONS[name];
|
|
1013
|
+
const px = typeof size === "number" && size > 0 ? size : 14;
|
|
1014
|
+
try {
|
|
1015
|
+
const component = primitives[exportName];
|
|
1016
|
+
if (isRenderable(component)) return h(component, { size: px });
|
|
1017
|
+
} catch (error) {
|
|
1018
|
+
/* a throwing getter must fall through to the drawn fallback */
|
|
1019
|
+
}
|
|
1020
|
+
const shape = ICON_FALLBACKS[exportName];
|
|
1021
|
+
if (shape === undefined) return null;
|
|
1022
|
+
const attrs = Object.assign(
|
|
1023
|
+
{
|
|
1024
|
+
width: px,
|
|
1025
|
+
height: px,
|
|
1026
|
+
viewBox: shape.viewBox,
|
|
1027
|
+
strokeWidth: 1,
|
|
1028
|
+
strokeLinecap: shape.attrs.strokeLinecap === undefined ? "round" : shape.attrs.strokeLinecap,
|
|
1029
|
+
strokeLinejoin: "round",
|
|
1030
|
+
// Per the product contract: icons are decoration. The accessible label lives on
|
|
1031
|
+
// the button's `title`, never on the glyph.
|
|
1032
|
+
"aria-hidden": "true",
|
|
1033
|
+
focusable: "false",
|
|
1034
|
+
style: { flex: "0 0 auto", display: "block", color: "currentColor" }
|
|
1035
|
+
},
|
|
1036
|
+
shape.attrs
|
|
1037
|
+
);
|
|
1038
|
+
return h(
|
|
1039
|
+
"svg",
|
|
1040
|
+
attrs,
|
|
1041
|
+
shape.paths.map(function (shapeSpec, index) {
|
|
1042
|
+
const tag = shapeSpec.circle === undefined ? "path" : "circle";
|
|
1043
|
+
const spec = shapeSpec.circle === undefined ? shapeSpec : shapeSpec.circle;
|
|
1044
|
+
return h(tag, Object.assign({ key: "p" + String(index) }, spec));
|
|
1045
|
+
})
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/** `icon()` sized and styled for a text-sized inline slot. */
|
|
1050
|
+
function inlineIcon(name, size) {
|
|
1051
|
+
const drawn = icon(name, typeof size === "number" && size > 0 ? size : 12);
|
|
1052
|
+
return drawn;
|
|
1053
|
+
}
|
|
1054
|
+
//#endregion
|
|
1055
|
+
|
|
1056
|
+
//#region components
|
|
1057
|
+
/**
|
|
1058
|
+
* Empty state required by the contract: never an error, never a spinner.
|
|
1059
|
+
*
|
|
1060
|
+
* The illustration is a single dimmed speech-bubble glyph, not a paragraph of
|
|
1061
|
+
* explanation: a reader who has joined nothing needs one glanceable signal plus one
|
|
1062
|
+
* sentence, and an empty panel is where a wall of text is least likely to be read.
|
|
1063
|
+
* Both the glyph and the copy still say the same thing in words, so nothing is
|
|
1064
|
+
* carried by the picture alone.
|
|
1065
|
+
*/
|
|
1066
|
+
function EmptyState() {
|
|
1067
|
+
return h(
|
|
1068
|
+
"div",
|
|
1069
|
+
{ style: S.empty },
|
|
1070
|
+
h("span", { style: S.emptyIcon, "aria-hidden": "true" }, icon("chat", 28)),
|
|
1071
|
+
h("p", { style: S.emptyTitle }, "还没有加入任何群"),
|
|
1072
|
+
h("p", { style: S.emptyBody }, "在会话里让助手用 a2a_group_join 加入一个协作群,或接受别人发来的邀请链接。")
|
|
1073
|
+
);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/** Feed line. */
|
|
1077
|
+
function FeedRow(props) {
|
|
1078
|
+
const entry = props.entry;
|
|
1079
|
+
return h(
|
|
1080
|
+
"li",
|
|
1081
|
+
{ style: S.feedItem },
|
|
1082
|
+
h("span", { style: S.feedWho }, entry.senderName + ":"),
|
|
1083
|
+
h("span", { style: S.feedText }, previewText(entry.text, 200)),
|
|
1084
|
+
entry.time > 0 ? h("span", { style: S.lastTime }, formatTime(entry.time)) : null
|
|
1085
|
+
);
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/** One group: collapsed summary row, expanded roster + feed. */
|
|
1089
|
+
function GroupCard(props) {
|
|
1090
|
+
const group = props.group;
|
|
1091
|
+
const open = props.open === true;
|
|
1092
|
+
const onToggle = props.onToggle;
|
|
1093
|
+
const last = group.lastMessage;
|
|
1094
|
+
|
|
1095
|
+
const head = h(
|
|
1096
|
+
"button",
|
|
1097
|
+
{
|
|
1098
|
+
type: "button",
|
|
1099
|
+
style: S.cardHead,
|
|
1100
|
+
onClick: onToggle,
|
|
1101
|
+
"aria-expanded": open,
|
|
1102
|
+
title: group.topic !== "" ? group.name + " · " + group.topic : group.name
|
|
1103
|
+
},
|
|
1104
|
+
h("span", { style: S.chevron }, icon(open ? "chevronDown" : "chevronRight", 12)),
|
|
1105
|
+
h("span", { style: S.groupName }, group.name),
|
|
1106
|
+
group.unreadCount > 0
|
|
1107
|
+
? h(
|
|
1108
|
+
"span",
|
|
1109
|
+
{
|
|
1110
|
+
style: S.badge,
|
|
1111
|
+
// The dot is the count; the word 「未读」 lives in the tooltip, so the
|
|
1112
|
+
// row stays readable at a glance without losing the accessible label.
|
|
1113
|
+
title: String(group.unreadCount) + " 条未读"
|
|
1114
|
+
},
|
|
1115
|
+
group.unreadCount > 99 ? "99+" : String(group.unreadCount)
|
|
1116
|
+
)
|
|
1117
|
+
: null,
|
|
1118
|
+
h(
|
|
1119
|
+
"span",
|
|
1120
|
+
{ style: S.memberCount, title: String(group.memberCount) + " 个成员" },
|
|
1121
|
+
icon("users", 12),
|
|
1122
|
+
String(group.memberCount)
|
|
1123
|
+
)
|
|
1124
|
+
);
|
|
1125
|
+
|
|
1126
|
+
const summary =
|
|
1127
|
+
last === undefined
|
|
1128
|
+
? h("div", { style: S.last }, h("span", { style: S.lastText }, "(还没有消息)"))
|
|
1129
|
+
: h(
|
|
1130
|
+
"div",
|
|
1131
|
+
{ style: S.last },
|
|
1132
|
+
h("span", { style: S.lastWho }, last.senderName + ":"),
|
|
1133
|
+
h("span", { style: S.lastText }, previewText(last.text, 60)),
|
|
1134
|
+
last.time > 0 ? h("span", { style: S.lastTime }, formatTime(last.time)) : null
|
|
1135
|
+
);
|
|
1136
|
+
|
|
1137
|
+
const detail = open
|
|
1138
|
+
? h(
|
|
1139
|
+
"div",
|
|
1140
|
+
{ style: S.cardBody },
|
|
1141
|
+
h(
|
|
1142
|
+
"div",
|
|
1143
|
+
{ style: S.memberRow },
|
|
1144
|
+
h("p", { style: S.sectionLabel }, "成员"),
|
|
1145
|
+
group.members.length === 0
|
|
1146
|
+
// Not an error: naming the members needs a member credential, and
|
|
1147
|
+
// this page is rendered before any Session exists. Say where the
|
|
1148
|
+
// answer lives instead of leaving a gap that reads as "nobody here".
|
|
1149
|
+
? h("span", { style: S.chip }, "名单需在会话里查看(a2a_group_role)")
|
|
1150
|
+
: group.members.map((member) =>
|
|
1151
|
+
h(
|
|
1152
|
+
"span",
|
|
1153
|
+
{ key: member.id, style: S.chip, title: member.role === "" ? member.name : member.name + " · " + member.role },
|
|
1154
|
+
member.self ? member.name + "(我)" : member.name
|
|
1155
|
+
)
|
|
1156
|
+
)
|
|
1157
|
+
),
|
|
1158
|
+
h(
|
|
1159
|
+
"div",
|
|
1160
|
+
{ style: S.memberRow },
|
|
1161
|
+
h("p", { style: S.sectionLabel }, "最近消息"),
|
|
1162
|
+
group.feed.length === 0
|
|
1163
|
+
? h("span", { style: S.chip }, "(还没有消息)")
|
|
1164
|
+
: h("ul", { style: S.feed }, group.feed.map((entry) => h(FeedRow, { key: entry.id, entry: entry })))
|
|
1165
|
+
),
|
|
1166
|
+
h("p", { style: S.emptyBody }, "只读视图:发消息请在会话里让助手用 a2a_group_post。")
|
|
1167
|
+
)
|
|
1168
|
+
: null;
|
|
1169
|
+
|
|
1170
|
+
return h("div", { style: S.card }, head, h("div", { style: S.cardBody }, summary), detail);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* Central panel page (registered into the `main` slot under key PANEL_ID).
|
|
1175
|
+
* Receives no props from the shell; all data comes from the Host source.
|
|
1176
|
+
*/
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* Split groups into the sections the panel draws.
|
|
1180
|
+
*
|
|
1181
|
+
* Order is deliberate and not "by recency": ownership first (it needs action), then
|
|
1182
|
+
* membership (where the work happens), then discovery (a browse), and colleagues last
|
|
1183
|
+
* because a card is reference material rather than a place to be.
|
|
1184
|
+
*
|
|
1185
|
+
* A group with no explicit role is treated as `joined`: that is the safe default,
|
|
1186
|
+
* because showing an owner control for a group somebody merely belongs to would offer
|
|
1187
|
+
* an action the server will refuse.
|
|
1188
|
+
*/
|
|
1189
|
+
|
|
1190
|
+
/**
|
|
1191
|
+
* Colleague cards from the snapshot.
|
|
1192
|
+
*
|
|
1193
|
+
* A card is a person's own description aggregated across their groups: name,
|
|
1194
|
+
* headline, tags, and which groups they share with this instance. The Host
|
|
1195
|
+
* assembles it from what members declared, so nothing here invents identity — an
|
|
1196
|
+
* absent card means "not fetched" and the tab says so.
|
|
1197
|
+
*/
|
|
1198
|
+
function readCards(raw) {
|
|
1199
|
+
const list = raw !== null && typeof raw === "object" && !Array.isArray(raw) && Array.isArray(raw.cards)
|
|
1200
|
+
? raw.cards
|
|
1201
|
+
: [];
|
|
1202
|
+
const cards = [];
|
|
1203
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
1204
|
+
const entry = list[i];
|
|
1205
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
1206
|
+
const name = pickString(firstDefined(entry, [ "name", "displayName", "title" ]));
|
|
1207
|
+
if (name === "") continue;
|
|
1208
|
+
cards.push({
|
|
1209
|
+
id: pickString(firstDefined(entry, [ "personId", "memberId", "id", "name" ])) || name,
|
|
1210
|
+
name: name,
|
|
1211
|
+
headline: pickString(firstDefined(entry, [ "headline", "roleAbout", "role", "about" ])),
|
|
1212
|
+
tags: (Array.isArray(entry.tags) ? entry.tags : []).filter(function (tag) {
|
|
1213
|
+
return typeof tag === "string" && tag !== "";
|
|
1214
|
+
}),
|
|
1215
|
+
groups: Array.isArray(entry.groups) ? entry.groups : [],
|
|
1216
|
+
// Every role they declared, in group order. The card shows the group names
|
|
1217
|
+
// beside the roles, so a person who is "研发" in one group and "产品" in
|
|
1218
|
+
// another reads as one colleague with two contexts rather than two people.
|
|
1219
|
+
roles: (Array.isArray(entry.roles) ? entry.roles : []).filter(function (role) {
|
|
1220
|
+
return typeof role === "string" && role !== "";
|
|
1221
|
+
}),
|
|
1222
|
+
// My own card: the only one the reader can change, so the only one styled as
|
|
1223
|
+
// editable. The Host decides this by member id, not by name.
|
|
1224
|
+
isSelf: entry.isSelf === true,
|
|
1225
|
+
online: entry.online === true,
|
|
1226
|
+
// Their A2A address, when the host knows one. Editable from the card, and the
|
|
1227
|
+
// basis for a future "reach them directly" action.
|
|
1228
|
+
endpoint: pickString(firstDefined(entry, [ "endpoint", "baseUrl", "url" ]))
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
return cards;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
/**
|
|
1235
|
+
* The Self view the members pane needs: who "I" am, and what I wrote in this group.
|
|
1236
|
+
*
|
|
1237
|
+
* THREE SOURCES, deliberately ranked, because no single one is complete:
|
|
1238
|
+
*
|
|
1239
|
+
* 1. the roster row the Host marked `isSelf` — authoritative for the display name
|
|
1240
|
+
* and for `roleAbout` IN THIS GROUP;
|
|
1241
|
+
* 2. the person table (`snapshot.cards`) entry with `isSelf` — carries the default
|
|
1242
|
+
* `headline` and the tags, so a member whose roster row predates the
|
|
1243
|
+
* `roleAbout` field still shows something rather than an empty line;
|
|
1244
|
+
* 3. the membership row's own display name / personId, when neither list arrived.
|
|
1245
|
+
*
|
|
1246
|
+
* The fallback ORDER matters for exactly one case: after I clear my own about, the
|
|
1247
|
+
* roster carries nothing but the person table still remembers the last headline I
|
|
1248
|
+
* ever declared. Showing it would tell me I still have a description I just deleted.
|
|
1249
|
+
* So a roster that arrived but says nothing about me is taken at its word —
|
|
1250
|
+
* `roleAbout === ""` with `hasSelf === true` means "cleared", not "unknown".
|
|
1251
|
+
*
|
|
1252
|
+
* @param {object|undefined} group - normalized group.
|
|
1253
|
+
* @param {Array} cards - normalized person table.
|
|
1254
|
+
* @returns {{hasSelf: boolean, personId: string, name: string, about: string, aboutFrom: string, online: boolean, tags: string[]}}
|
|
1255
|
+
* `aboutFrom` is "group" | "person" | "none", which the pane uses to tell a
|
|
1256
|
+
* cleared description from an unset one.
|
|
1257
|
+
*/
|
|
1258
|
+
function pickSelf(group, cards) {
|
|
1259
|
+
const result = {
|
|
1260
|
+
hasSelf: false,
|
|
1261
|
+
personId: "",
|
|
1262
|
+
name: "",
|
|
1263
|
+
about: "",
|
|
1264
|
+
aboutFrom: "none",
|
|
1265
|
+
online: false,
|
|
1266
|
+
tags: []
|
|
1267
|
+
};
|
|
1268
|
+
|
|
1269
|
+
const roster = group !== undefined && Array.isArray(group.members) ? group.members : [];
|
|
1270
|
+
// Identity is matched by person id, never by display name: two members of one
|
|
1271
|
+
// group may both be called 我 (or a display name may be edited to match someone
|
|
1272
|
+
// else's), and the wrong match would offer an edit of the wrong profile.
|
|
1273
|
+
const ownId = group !== undefined ? pickString(group.personId) : "";
|
|
1274
|
+
let own = roster.filter(function (member) {
|
|
1275
|
+
return member.self === true;
|
|
1276
|
+
})[0];
|
|
1277
|
+
if (own === undefined && ownId !== "") {
|
|
1278
|
+
own = roster.filter(function (member) {
|
|
1279
|
+
return member.id === ownId;
|
|
1280
|
+
})[0];
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
const selfCard = (Array.isArray(cards) ? cards : []).filter(function (card) {
|
|
1284
|
+
return card.isSelf === true;
|
|
1285
|
+
})[0];
|
|
1286
|
+
|
|
1287
|
+
// Last identity resort, for a Host that ships neither `group.personId` nor a
|
|
1288
|
+
// `self` marker on the roster but does mark its own card: match the roster row
|
|
1289
|
+
// against the card's person id. Still id-based, so it cannot pick the wrong
|
|
1290
|
+
// person the way a name match would.
|
|
1291
|
+
if (own === undefined && selfCard !== undefined && selfCard.id !== "") {
|
|
1292
|
+
own = roster.filter(function (member) {
|
|
1293
|
+
return member.id === selfCard.id;
|
|
1294
|
+
})[0];
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
if (own !== undefined) {
|
|
1298
|
+
result.hasSelf = true;
|
|
1299
|
+
result.personId = own.id;
|
|
1300
|
+
result.name = own.name;
|
|
1301
|
+
result.online = own.online;
|
|
1302
|
+
result.tags = own.tags;
|
|
1303
|
+
if (own.roleAbout !== "") {
|
|
1304
|
+
result.about = own.roleAbout;
|
|
1305
|
+
result.aboutFrom = "group";
|
|
1306
|
+
} else if (own.roleAboutKnown !== true && selfCard !== undefined && selfCard.headline !== "") {
|
|
1307
|
+
// The roster row has no about field at all — it predates `roleAbout`, or
|
|
1308
|
+
// this Host sends it only in the table. The table is the documented default.
|
|
1309
|
+
result.about = selfCard.headline;
|
|
1310
|
+
result.aboutFrom = "person";
|
|
1311
|
+
}
|
|
1312
|
+
// `roleAboutKnown === true` with an empty value means I cleared it. Leaving
|
|
1313
|
+
// `about` empty is the point: backfilling the old headline would tell me the
|
|
1314
|
+
// clear had failed.
|
|
1315
|
+
if (result.name === "" && selfCard !== undefined) result.name = selfCard.name;
|
|
1316
|
+
return result;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
if (selfCard !== undefined) {
|
|
1320
|
+
result.hasSelf = true;
|
|
1321
|
+
result.personId = selfCard.id;
|
|
1322
|
+
result.name = selfCard.name;
|
|
1323
|
+
result.about = selfCard.headline;
|
|
1324
|
+
result.aboutFrom = selfCard.headline === "" ? "none" : "person";
|
|
1325
|
+
result.online = selfCard.online;
|
|
1326
|
+
result.tags = selfCard.tags;
|
|
1327
|
+
return result;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// No roster and no table: the membership row still knows who I am, which is
|
|
1331
|
+
// enough to draw the row and to name the group in the edit instruction.
|
|
1332
|
+
if (group !== undefined && ownId !== "") {
|
|
1333
|
+
result.hasSelf = true;
|
|
1334
|
+
result.personId = ownId;
|
|
1335
|
+
result.name = ownId;
|
|
1336
|
+
}
|
|
1337
|
+
return result;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
/**
|
|
1341
|
+
* Split a roster into the row order the pane renders.
|
|
1342
|
+
*
|
|
1343
|
+
* ## The order, and why
|
|
1344
|
+
*
|
|
1345
|
+
* 1. 「所有人」 — added by the pane, not part of this function: it is an address, not
|
|
1346
|
+
* a person, and it is always first regardless of who is in the group.
|
|
1347
|
+
* 2. 群主 — the person who can approve and invite. In a collaboration group the
|
|
1348
|
+
* owner is the one you address when something is stuck, so they outrank everyone.
|
|
1349
|
+
* 3. 我 — where am I, and the only row with a second action (edit).
|
|
1350
|
+
* 4. everyone else — online first, then by name.
|
|
1351
|
+
*
|
|
1352
|
+
* Alphabetical order is right only for strangers. In a group you share with eight
|
|
1353
|
+
* people, "who runs this", "where am I" and "who is around" are the questions that
|
|
1354
|
+
* actually get asked.
|
|
1355
|
+
*
|
|
1356
|
+
* ## When I am the owner
|
|
1357
|
+
*
|
|
1358
|
+
* `mine` and `owner` are then the SAME person, and rendering both would show me twice
|
|
1359
|
+
* in a list whose whole purpose is telling me who is here. The row is returned once,
|
|
1360
|
+
* in `owner`, flagged `isSelf` so the pane can label it 「群主 · 我」 and keep the edit
|
|
1361
|
+
* action. When the owner is somebody else, `mine` is me and `owner` is them.
|
|
1362
|
+
*
|
|
1363
|
+
* A group whose owner is not in the roster yields `owner: undefined` — an empty slot
|
|
1364
|
+
* at position 2 would read as a missing person rather than as absent data.
|
|
1365
|
+
*
|
|
1366
|
+
* @param {Array} members - normalized roster.
|
|
1367
|
+
* @param {object} self - output of {@link pickSelf}.
|
|
1368
|
+
* @returns {{owner: object|undefined, mine: object|undefined, others: Array}}
|
|
1369
|
+
* `owner` carries `isSelf` when I run this group. Never returns the same person twice.
|
|
1370
|
+
*/
|
|
1371
|
+
|
|
1372
|
+
/**
|
|
1373
|
+
* The instruction that invites one colleague into THIS group.
|
|
1374
|
+
*
|
|
1375
|
+
* Both facts are known here — the group from the pane, the person from the pick — so the
|
|
1376
|
+
* sentence needs no choice from the reader. That is the whole reason the action lives on the
|
|
1377
|
+
* group rather than on the card: an invitation whose subject is ambiguous must not be offered.
|
|
1378
|
+
*
|
|
1379
|
+
* Named by id, not by name: two members of one group can share a display name.
|
|
1380
|
+
*
|
|
1381
|
+
* @param {object} group - the group whose pane this is.
|
|
1382
|
+
* @param {object} person - the colleague to invite.
|
|
1383
|
+
* @returns {string} the text to insert.
|
|
1384
|
+
*/
|
|
1385
|
+
function draftGroupInvite(group, person) {
|
|
1386
|
+
const who = (person.name ?? "") === "" ? person.id : person.name;
|
|
1387
|
+
return "用 a2a_person_send 邀请 " + who + "(personId: " + person.id + ")加入「"
|
|
1388
|
+
+ (group.name ?? group.id) + "」(groupId: " + group.id + ")。"
|
|
1389
|
+
+ "\n(发送后会告诉你对方是否在线;此时在线则立刻送到。)";
|
|
1390
|
+
}
|
|
1391
|
+
function splitRoster(members, self) {
|
|
1392
|
+
const list = Array.isArray(members) ? members : [];
|
|
1393
|
+
const mineId = self !== undefined && self.hasSelf === true ? self.personId : "";
|
|
1394
|
+
const byDefault = function (left, right) {
|
|
1395
|
+
if (left.online !== right.online) return left.online ? -1 : 1;
|
|
1396
|
+
return left.name.localeCompare(right.name, "zh-Hans-CN");
|
|
1397
|
+
};
|
|
1398
|
+
|
|
1399
|
+
let owner;
|
|
1400
|
+
let mine;
|
|
1401
|
+
const others = [];
|
|
1402
|
+
|
|
1403
|
+
for (const member of list) {
|
|
1404
|
+
const isSelf = member.self === true || (mineId !== "" && member.id === mineId);
|
|
1405
|
+
const isOwner = member.role === "owner";
|
|
1406
|
+
if (isOwner && owner === undefined) {
|
|
1407
|
+
// One row only, even when the owner is me. `isSelf` here is what makes the
|
|
1408
|
+
// pane label it 「群主 · 我」 instead of showing a second row below.
|
|
1409
|
+
owner = Object.assign({}, member, { isSelf: isSelf || member.self === true });
|
|
1410
|
+
continue;
|
|
1411
|
+
}
|
|
1412
|
+
if (isSelf && mine === undefined) {
|
|
1413
|
+
// `isSelf` is the flag the row builder keys its behaviour and badges off.
|
|
1414
|
+
// Without it the row would render as an ordinary member and I would lose the
|
|
1415
|
+
// only edit affordance in the pane.
|
|
1416
|
+
mine = Object.assign({}, member, { isSelf: true });
|
|
1417
|
+
continue;
|
|
1418
|
+
}
|
|
1419
|
+
others.push(member);
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
others.sort(byDefault);
|
|
1423
|
+
return { owner: owner, mine: mine, others: others };
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
function groupByRelationship(groups) {
|
|
1427
|
+
const mine = [];
|
|
1428
|
+
const joined = [];
|
|
1429
|
+
const discover = [];
|
|
1430
|
+
for (const group of groups) {
|
|
1431
|
+
if (group.discovered === true) {
|
|
1432
|
+
discover.push(group);
|
|
1433
|
+
} else if (group.role === "owner") {
|
|
1434
|
+
mine.push(group);
|
|
1435
|
+
} else {
|
|
1436
|
+
joined.push(group);
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
// Most recently active first inside each section: a directory sorted by name is
|
|
1440
|
+
// useless when what you want is "what is happening".
|
|
1441
|
+
const byActivity = (left, right) => (right.lastTime || 0) - (left.lastTime || 0);
|
|
1442
|
+
mine.sort(byActivity);
|
|
1443
|
+
joined.sort(byActivity);
|
|
1444
|
+
discover.sort(byActivity);
|
|
1445
|
+
return { mine: mine, joined: joined, discover: discover };
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
|
|
1449
|
+
/**
|
|
1450
|
+
* The panel's horizontal tab bar.
|
|
1451
|
+
*
|
|
1452
|
+
* Counts are shown on each tab because "我创建 (0)" is information: it says the
|
|
1453
|
+
* question is answerable, and the answer is none. A tab that vanished when empty
|
|
1454
|
+
* would leave a person wondering whether the feature existed.
|
|
1455
|
+
*/
|
|
1456
|
+
function TabBar(props) {
|
|
1457
|
+
return h(
|
|
1458
|
+
"div",
|
|
1459
|
+
{
|
|
1460
|
+
style: {
|
|
1461
|
+
display: "flex",
|
|
1462
|
+
gap: "2px",
|
|
1463
|
+
borderBottom: "1px solid rgba(128, 128, 128, 0.35)",
|
|
1464
|
+
paddingBottom: "0"
|
|
1465
|
+
}
|
|
1466
|
+
},
|
|
1467
|
+
props.tabs.map(function (tab) {
|
|
1468
|
+
const active = props.active === tab.id;
|
|
1469
|
+
return h(
|
|
1470
|
+
"button",
|
|
1471
|
+
{
|
|
1472
|
+
key: tab.id,
|
|
1473
|
+
type: "button",
|
|
1474
|
+
title: tab.label,
|
|
1475
|
+
style: {
|
|
1476
|
+
display: "inline-flex",
|
|
1477
|
+
alignItems: "center",
|
|
1478
|
+
gap: "4px",
|
|
1479
|
+
padding: "6px 8px",
|
|
1480
|
+
border: "none",
|
|
1481
|
+
borderBottom: active
|
|
1482
|
+
? "2px solid currentColor"
|
|
1483
|
+
: "2px solid transparent",
|
|
1484
|
+
background: "transparent",
|
|
1485
|
+
color: "inherit",
|
|
1486
|
+
font: "inherit",
|
|
1487
|
+
fontSize: "12px",
|
|
1488
|
+
fontWeight: active ? 600 : 400,
|
|
1489
|
+
opacity: active ? 1 : 0.65,
|
|
1490
|
+
cursor: "pointer",
|
|
1491
|
+
whiteSpace: "nowrap"
|
|
1492
|
+
},
|
|
1493
|
+
onClick: function () {
|
|
1494
|
+
props.onSelect(tab.id);
|
|
1495
|
+
}
|
|
1496
|
+
},
|
|
1497
|
+
// Decorative glyph, then the word: the icon speeds up scanning, the
|
|
1498
|
+
// label keeps the tab answerable without hovering.
|
|
1499
|
+
tab.icon === undefined ? null : icon(tab.icon, 12),
|
|
1500
|
+
tab.label,
|
|
1501
|
+
tab.count === undefined
|
|
1502
|
+
? null
|
|
1503
|
+
: h("span", { style: { opacity: active ? 0.75 : 0.55, marginLeft: "1px" } }, String(tab.count))
|
|
1504
|
+
);
|
|
1505
|
+
})
|
|
1506
|
+
);
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
/**
|
|
1510
|
+
* One colleague's card, in the roster style rather than the group-card style.
|
|
1511
|
+
*
|
|
1512
|
+
* A card answers "who is this and what do they do", so it leads with the name and
|
|
1513
|
+
* the tags rather than with an unread count. Tags are clickable: filtering the
|
|
1514
|
+
* roster by "测试" is the whole point of having tags at all.
|
|
1515
|
+
*/
|
|
1516
|
+
|
|
1517
|
+
/**
|
|
1518
|
+
* The instruction that edits a profile, prefilled with the current values.
|
|
1519
|
+
*
|
|
1520
|
+
* Written as a sentence a person would say, because that is what the Session turn will
|
|
1521
|
+
* read: a JSON blob would work but would be something to get wrong rather than something
|
|
1522
|
+
* to edit.
|
|
1523
|
+
*
|
|
1524
|
+
* @param {object} card - the person's card.
|
|
1525
|
+
* @param {string} groupName - which group to act in (the tool needs one for the credential).
|
|
1526
|
+
* @returns {string} the text to insert.
|
|
1527
|
+
*/
|
|
1528
|
+
function draftProfileEdit(card, groupName) {
|
|
1529
|
+
const tags = (card.tags ?? []).join("、");
|
|
1530
|
+
const lines = [
|
|
1531
|
+
"帮我更新我在「" + (groupName === "" ? "这个群" : groupName) + "」的名片信息:"
|
|
1532
|
+
];
|
|
1533
|
+
if ((card.headline ?? "") !== "") lines.push("简介:" + card.headline);
|
|
1534
|
+
if (tags !== "") lines.push("标签:" + tags);
|
|
1535
|
+
if ((card.endpoint ?? "") !== "") lines.push("A2A 地址:" + card.endpoint);
|
|
1536
|
+
// The last line is the actual ask: the reader edits the values above it and sends.
|
|
1537
|
+
lines.push("");
|
|
1538
|
+
lines.push("(改上面的内容后直接发送即可;留空的项会被清空。)");
|
|
1539
|
+
return lines.join("\n") + " ";
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
/**
|
|
1543
|
+
* The instruction that edits MY role in ONE group — the members pane's edit action.
|
|
1544
|
+
*
|
|
1545
|
+
* ## Why an instruction and not a save button
|
|
1546
|
+
*
|
|
1547
|
+
* The browser bundle holds no membership credential. `a2a_group_role` authenticates
|
|
1548
|
+
* with `{personId, personKey}`, and that key lives in `memberships.json` on the Host
|
|
1549
|
+
* (0600) and is never published into the page — `lib/index.js` publishes only the
|
|
1550
|
+
* read-only snapshot. A form that POSTed from here would have to either carry the
|
|
1551
|
+
* key into the DOM or invent a second write channel to the Host; both are worse than
|
|
1552
|
+
* the thing they replace. One write path (the Session turn, which already holds the
|
|
1553
|
+
* credential) is also why the panel and the assistant cannot disagree about what
|
|
1554
|
+
* was written.
|
|
1555
|
+
*
|
|
1556
|
+
* So the click writes a prefilled instruction into the composer. The reader edits
|
|
1557
|
+
* the sentence and presses Enter; the assistant calls `a2a_group_role` with the
|
|
1558
|
+
* values that are now visible in the draft. History, validation and error reporting
|
|
1559
|
+
* are the turn's, which means the failure message ("about is too long") appears in
|
|
1560
|
+
* the conversation next to the thing that caused it rather than in a toast that
|
|
1561
|
+
* cannot say what to do about it.
|
|
1562
|
+
*
|
|
1563
|
+
* `groupId` is included because the tool requires it and the name alone is ambiguous
|
|
1564
|
+
* — two groups may share a display name, and the assistant must not have to guess.
|
|
1565
|
+
* Both are stated as facts the reader can check, not as a JSON payload: an editable
|
|
1566
|
+
* sentence is something a person can correct, and a payload is something to get wrong.
|
|
1567
|
+
*
|
|
1568
|
+
* @param {object} input
|
|
1569
|
+
* @param {string} input.groupName - display name, for the sentence.
|
|
1570
|
+
* @param {string} input.groupId - the tool's required key.
|
|
1571
|
+
* @param {string} [input.about] - current text, prefilled so a small edit stays small.
|
|
1572
|
+
* @param {string[]} [input.tags] - current tags.
|
|
1573
|
+
* @returns {string} text to insert into the composer.
|
|
1574
|
+
*/
|
|
1575
|
+
/**
|
|
1576
|
+
* Write a profile through the Host, over DSH's Connection RPC.
|
|
1577
|
+
*
|
|
1578
|
+
* Lazy and cached: the module is resolved on first save rather than at render, so a host without
|
|
1579
|
+
* the Connection plugin shows "saving is unavailable" instead of breaking the pane.
|
|
1580
|
+
*
|
|
1581
|
+
* @returns {object|undefined} the RPC client, or undefined when unavailable.
|
|
1582
|
+
*/
|
|
1583
|
+
/**
|
|
1584
|
+
* Caller into the Host, installed once by apply().
|
|
1585
|
+
*
|
|
1586
|
+
* The browser half of Connection publishes `ctx.connection.rpc`, and that is the same service object
|
|
1587
|
+
* the Host half registers channels on. Handing the call function down as a prop keeps this file free of
|
|
1588
|
+
* any service lookup at render time, which is where a pane must not throw.
|
|
1589
|
+
*
|
|
1590
|
+
* @type {((channel: string, endpoint: string, payload: unknown) => Promise<object>)|undefined}
|
|
1591
|
+
*/
|
|
1592
|
+
let panelRpcCaller;
|
|
1593
|
+
|
|
1594
|
+
/** Why the caller is unavailable, for the self-check to show instead of a silent failure. */
|
|
1595
|
+
let panelRpcError = "";
|
|
1596
|
+
|
|
1597
|
+
/**
|
|
1598
|
+
* Install the Connection caller from the plugin's context.
|
|
1599
|
+
*
|
|
1600
|
+
* Called from apply(), where the service exists. Records the reason when it does not, because a panel
|
|
1601
|
+
* that says "unavailable" without saying why costs a debugging session per occurrence.
|
|
1602
|
+
*
|
|
1603
|
+
* @param {object} ctx - plugin context.
|
|
1604
|
+
*/
|
|
1605
|
+
function installPanelRpc(ctx) {
|
|
1606
|
+
try {
|
|
1607
|
+
const connection = ctx !== null && ctx !== undefined && typeof ctx.get === "function"
|
|
1608
|
+
? ctx.get("connection")
|
|
1609
|
+
: undefined;
|
|
1610
|
+
if (connection !== undefined && connection !== null && connection.rpc !== undefined
|
|
1611
|
+
&& typeof connection.rpc.call === "function") {
|
|
1612
|
+
panelRpcCaller = function (channel, endpoint, payload) {
|
|
1613
|
+
return connection.rpc.call(channel, endpoint, payload);
|
|
1614
|
+
};
|
|
1615
|
+
panelRpcError = "";
|
|
1616
|
+
return;
|
|
1617
|
+
}
|
|
1618
|
+
panelRpcError = "connection service has no rpc.call";
|
|
1619
|
+
} catch (error) {
|
|
1620
|
+
panelRpcError = String((error && error.message) || error);
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
/**
|
|
1625
|
+
* Save one profile field through the Host.
|
|
1626
|
+
*
|
|
1627
|
+
* Resolves to `{ ok, message }` rather than throwing: the caller is a button handler, and a
|
|
1628
|
+
* rejected promise there would surface as silence.
|
|
1629
|
+
*
|
|
1630
|
+
* @param {object} input - `{ groupId, sessionId, about?, tags?, headline? }`.
|
|
1631
|
+
* @returns {Promise<{ok: boolean, message: string}>} the outcome, phrased for a person.
|
|
1632
|
+
*/
|
|
1633
|
+
async function saveProfile(input) {
|
|
1634
|
+
if (panelRpcCaller === undefined) {
|
|
1635
|
+
return { ok: false, message: "面板写入通道不可用(" + (panelRpcError === "" ? "未安装" : panelRpcError) + ")" };
|
|
1636
|
+
}
|
|
1637
|
+
try {
|
|
1638
|
+
const result = await panelRpcCaller("/a2a-panel", "profile", input);
|
|
1639
|
+
if (result !== null && result !== undefined && result.ok === true) {
|
|
1640
|
+
return { ok: true, message: "已保存" };
|
|
1641
|
+
}
|
|
1642
|
+
const code = result && result.error ? result.error.code : "unknown";
|
|
1643
|
+
return { ok: false, message: "保存失败:" + code };
|
|
1644
|
+
} catch (error) {
|
|
1645
|
+
return { ok: false, message: "保存失败:" + String((error && error.message) || error) };
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
/**
|
|
1650
|
+
* Call the Host's `ping` endpoint and report what came back.
|
|
1651
|
+
*
|
|
1652
|
+
* Uses the same client as `saveProfile`, on purpose: a probe that takes a different path proves
|
|
1653
|
+
* nothing about the path the feature will use.
|
|
1654
|
+
*
|
|
1655
|
+
* @returns {Promise<string>} a line to show in the pane.
|
|
1656
|
+
*/
|
|
1657
|
+
async function panelSelfCheck() {
|
|
1658
|
+
if (panelRpcCaller === undefined) {
|
|
1659
|
+
return "面板通道不可用:" + (panelRpcError === "" ? "未安装" : panelRpcError);
|
|
1660
|
+
}
|
|
1661
|
+
try {
|
|
1662
|
+
const result = await panelRpcCaller("/a2a-panel", "ping", { at: Date.now() });
|
|
1663
|
+
if (result !== null && result !== undefined && result.ok === true) {
|
|
1664
|
+
const value = result.value ?? {};
|
|
1665
|
+
return "面板通道正常(" + String(value.at ?? "?") + ")";
|
|
1666
|
+
}
|
|
1667
|
+
return "面板通道返回失败:" + String(result && result.error ? result.error.code : "?");
|
|
1668
|
+
} catch (error) {
|
|
1669
|
+
return "面板通道异常:" + String((error && error.message) || error);
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
/**
|
|
1674
|
+
* Inline editor for my group role: a description and tags, saved in place.
|
|
1675
|
+
*
|
|
1676
|
+
* Two fields rather than one, because they answer different questions — "what do I do here" and
|
|
1677
|
+
* "what should people search for to find me" — and the Host stores them separately.
|
|
1678
|
+
*
|
|
1679
|
+
* @param {object} props - `{ groupId, sessionId, about, tags, onDone, onCancel }`.
|
|
1680
|
+
* @returns {object} the editor.
|
|
1681
|
+
*/
|
|
1682
|
+
function RoleEditor(props) {
|
|
1683
|
+
const [about, setAbout] = react.useState(props.about ?? "");
|
|
1684
|
+
const [tags, setTags] = react.useState((props.tags ?? []).join("、"));
|
|
1685
|
+
// The person-level headline. Different scope from `about`: that one is this membership, this one
|
|
1686
|
+
// follows the person into every group and onto every colleague card.
|
|
1687
|
+
const [headline, setHeadline] = react.useState(props.headline ?? "");
|
|
1688
|
+
const [state, setState] = react.useState("idle");
|
|
1689
|
+
const [message, setMessage] = react.useState("");
|
|
1690
|
+
|
|
1691
|
+
/** Save both fields; report the outcome inline rather than in an alert. */
|
|
1692
|
+
function save() {
|
|
1693
|
+
setState("saving");
|
|
1694
|
+
setMessage("");
|
|
1695
|
+
// Tags are typed as a separated list because that is how a person thinks of them; the Host
|
|
1696
|
+
// stores an array.
|
|
1697
|
+
const parsed = String(tags ?? "")
|
|
1698
|
+
.split(/[、,,\s]+/u)
|
|
1699
|
+
.map((tag) => tag.trim())
|
|
1700
|
+
.filter((tag) => tag !== "");
|
|
1701
|
+
saveProfile({
|
|
1702
|
+
groupId: props.groupId,
|
|
1703
|
+
sessionId: props.sessionId,
|
|
1704
|
+
about: String(about ?? "").trim(),
|
|
1705
|
+
tags: parsed,
|
|
1706
|
+
// Omitted when untouched rather than sent empty: an absent field means "leave it alone",
|
|
1707
|
+
// and a cleared one would wipe the person's headline on every role edit.
|
|
1708
|
+
headline: String(headline ?? "").trim() === String(props.headline ?? "").trim()
|
|
1709
|
+
? undefined
|
|
1710
|
+
: String(headline ?? "").trim(),
|
|
1711
|
+
}).then(function (result) {
|
|
1712
|
+
setState(result.ok ? "done" : "failed");
|
|
1713
|
+
setMessage(result.message);
|
|
1714
|
+
if (result.ok && typeof props.onSaved === "function") props.onSaved();
|
|
1715
|
+
});
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
return h(
|
|
1719
|
+
"div",
|
|
1720
|
+
{ style: membersLayout.editorBox },
|
|
1721
|
+
// Scope stated in the label, not only in a placeholder: editing the wrong one produces a
|
|
1722
|
+
// change that does not show up where the reader expects, and the difference is invisible in a
|
|
1723
|
+
// screenshot.
|
|
1724
|
+
h("span", { style: { fontSize: "10px", opacity: 0.6 } }, "个人介绍(所有群共用,显示在同事名片上)"),
|
|
1725
|
+
h("input", {
|
|
1726
|
+
type: "text",
|
|
1727
|
+
value: headline,
|
|
1728
|
+
placeholder: "我是谁,例如:后端工程师,专注对账系统",
|
|
1729
|
+
onChange: function (event) {
|
|
1730
|
+
setHeadline(event !== null && event !== undefined && event.target !== undefined ? event.target.value : "");
|
|
1731
|
+
},
|
|
1732
|
+
style: membersLayout.editorInput
|
|
1733
|
+
}),
|
|
1734
|
+
h("span", { style: { fontSize: "10px", opacity: 0.6 } }, "群内介绍(只在这个群生效)"),
|
|
1735
|
+
h("input", {
|
|
1736
|
+
type: "text",
|
|
1737
|
+
value: about,
|
|
1738
|
+
placeholder: "我在这个群负责什么,例如:负责对账与发票生成",
|
|
1739
|
+
onChange: function (event) {
|
|
1740
|
+
setAbout(event !== null && event !== undefined && event.target !== undefined ? event.target.value : "");
|
|
1741
|
+
},
|
|
1742
|
+
style: membersLayout.editorInput
|
|
1743
|
+
}),
|
|
1744
|
+
h("input", {
|
|
1745
|
+
type: "text",
|
|
1746
|
+
value: tags,
|
|
1747
|
+
placeholder: "标签,用顿号或逗号分隔,例如:研发、后端",
|
|
1748
|
+
onChange: function (event) {
|
|
1749
|
+
setTags(event !== null && event !== undefined && event.target !== undefined ? event.target.value : "");
|
|
1750
|
+
},
|
|
1751
|
+
style: membersLayout.editorInput
|
|
1752
|
+
}),
|
|
1753
|
+
h(
|
|
1754
|
+
"div",
|
|
1755
|
+
{ style: { display: "flex", alignItems: "center", gap: "6px", flexWrap: "wrap" } },
|
|
1756
|
+
h(
|
|
1757
|
+
"button",
|
|
1758
|
+
{
|
|
1759
|
+
type: "button",
|
|
1760
|
+
disabled: state === "saving",
|
|
1761
|
+
title: "保存到群宿主,立即对所有成员生效",
|
|
1762
|
+
style: Object.assign({}, membersLayout.editorSave, state === "saving" ? { opacity: 0.5 } : {}),
|
|
1763
|
+
onClick: save
|
|
1764
|
+
},
|
|
1765
|
+
state === "saving" ? "保存中…" : "保存"
|
|
1766
|
+
),
|
|
1767
|
+
h(
|
|
1768
|
+
"button",
|
|
1769
|
+
{
|
|
1770
|
+
type: "button",
|
|
1771
|
+
title: "放弃修改",
|
|
1772
|
+
style: membersLayout.editorCancel,
|
|
1773
|
+
onClick: function () { if (typeof props.onCancel === "function") props.onCancel(); }
|
|
1774
|
+
},
|
|
1775
|
+
"取消"
|
|
1776
|
+
),
|
|
1777
|
+
message === ""
|
|
1778
|
+
? null
|
|
1779
|
+
: h(
|
|
1780
|
+
"span",
|
|
1781
|
+
{ style: { fontSize: "10px", opacity: 0.75, color: state === "failed" ? "#c0392b" : "inherit" } },
|
|
1782
|
+
message
|
|
1783
|
+
)
|
|
1784
|
+
)
|
|
1785
|
+
);
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
function draftGroupRoleEdit(input) {
|
|
1789
|
+
const given = input === null || input === undefined ? {} : input;
|
|
1790
|
+
const groupName = pickString(given.groupName);
|
|
1791
|
+
const groupId = pickString(given.groupId);
|
|
1792
|
+
const about = pickString(given.about);
|
|
1793
|
+
const tags = (Array.isArray(given.tags) ? given.tags : []).filter(function (tag) {
|
|
1794
|
+
return typeof tag === "string" && tag !== "";
|
|
1795
|
+
}).join("、");
|
|
1796
|
+
|
|
1797
|
+
const lines = [
|
|
1798
|
+
"用 a2a_group_role 更新我在「" + (groupName === "" ? "这个群" : groupName) + "」的群内介绍" + (groupId === "" ? "" : "(groupId: " + groupId + ")") + ":"
|
|
1799
|
+
];
|
|
1800
|
+
lines.push("about:" + about);
|
|
1801
|
+
if (tags !== "") lines.push("tags:" + tags);
|
|
1802
|
+
lines.push("");
|
|
1803
|
+
lines.push("(改上面的内容后直接发送即可。about 是一句话、最多 120 字;清空 about 就是删掉这条介绍。)");
|
|
1804
|
+
return lines.join("\n") + " ";
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
/**
|
|
1808
|
+
* The instruction that removes MY role in one group.
|
|
1809
|
+
*
|
|
1810
|
+
* Separate from {@link draftGroupRoleEdit} because "clear" is the one edit a reader
|
|
1811
|
+
* cannot express by editing text in place: deleting the line `about:` reads as
|
|
1812
|
+
* "leave it alone" to a careful reader (the tool leaves a field unchanged when it is
|
|
1813
|
+
* omitted), so the intent has to be stated rather than implied.
|
|
1814
|
+
*
|
|
1815
|
+
* @param {{groupName?: string, groupId?: string}} input
|
|
1816
|
+
* @returns {string} text to insert into the composer.
|
|
1817
|
+
*/
|
|
1818
|
+
function draftGroupRoleClear(input) {
|
|
1819
|
+
const given = input === null || input === undefined ? {} : input;
|
|
1820
|
+
const groupName = pickString(given.groupName);
|
|
1821
|
+
const groupId = pickString(given.groupId);
|
|
1822
|
+
return "用 a2a_group_role 把我在「" + (groupName === "" ? "这个群" : groupName) + "」的群内介绍清空"
|
|
1823
|
+
+ (groupId === "" ? "" : "(groupId: " + groupId + ")")
|
|
1824
|
+
+ ":about 传空字符串,tags 传空数组。发送即可。";
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
function ColleagueCard(props) {
|
|
1828
|
+
const card = props.card;
|
|
1829
|
+
return h(
|
|
1830
|
+
"div",
|
|
1831
|
+
{
|
|
1832
|
+
style: {
|
|
1833
|
+
display: "flex",
|
|
1834
|
+
flexDirection: "column",
|
|
1835
|
+
gap: "3px",
|
|
1836
|
+
padding: "8px 10px",
|
|
1837
|
+
// Mine is outlined: the list is otherwise read-only, and the one card the
|
|
1838
|
+
// reader owns should not look like the ones they cannot touch.
|
|
1839
|
+
border: card.isSelf === true
|
|
1840
|
+
? "1px solid currentColor"
|
|
1841
|
+
: "1px solid rgba(128, 128, 128, 0.35)",
|
|
1842
|
+
borderRadius: "6px"
|
|
1843
|
+
}
|
|
1844
|
+
},
|
|
1845
|
+
h(
|
|
1846
|
+
"div",
|
|
1847
|
+
{ style: { display: "flex", alignItems: "baseline", gap: "6px" } },
|
|
1848
|
+
h("span", { style: { fontWeight: 600, fontSize: "13px" } }, card.name),
|
|
1849
|
+
card.isSelf === true
|
|
1850
|
+
? h(
|
|
1851
|
+
"span",
|
|
1852
|
+
{
|
|
1853
|
+
style: {
|
|
1854
|
+
padding: "0 5px",
|
|
1855
|
+
borderRadius: "3px",
|
|
1856
|
+
fontSize: "10px",
|
|
1857
|
+
// Same inverted-chip pair as membersLayout.selfTag; currentColor here produced a
|
|
1858
|
+
// near-white pill with white text in a dark theme. See the note there.
|
|
1859
|
+
background: "light-dark(#1a1a1a, #f2f2f2)",
|
|
1860
|
+
color: "light-dark(#f2f2f2, #1a1a1a)"
|
|
1861
|
+
},
|
|
1862
|
+
title: "这是你自己的名片,只有你能改"
|
|
1863
|
+
},
|
|
1864
|
+
"我"
|
|
1865
|
+
)
|
|
1866
|
+
: null,
|
|
1867
|
+
card.headline !== ""
|
|
1868
|
+
? h("span", { style: { fontSize: "11px", opacity: 0.7 } }, card.headline)
|
|
1869
|
+
: null
|
|
1870
|
+
),
|
|
1871
|
+
card.tags.length === 0
|
|
1872
|
+
? null
|
|
1873
|
+
: h(
|
|
1874
|
+
"div",
|
|
1875
|
+
{ style: { display: "flex", flexWrap: "wrap", gap: "3px" } },
|
|
1876
|
+
card.tags.map(function (tag) {
|
|
1877
|
+
return h(
|
|
1878
|
+
"button",
|
|
1879
|
+
{
|
|
1880
|
+
key: tag,
|
|
1881
|
+
type: "button",
|
|
1882
|
+
title: "只看标签「" + tag + "」的同事",
|
|
1883
|
+
style: {
|
|
1884
|
+
padding: "1px 7px",
|
|
1885
|
+
border: "1px solid rgba(128, 128, 128, 0.5)",
|
|
1886
|
+
borderRadius: "999px",
|
|
1887
|
+
background: "transparent",
|
|
1888
|
+
color: "inherit",
|
|
1889
|
+
font: "inherit",
|
|
1890
|
+
fontSize: "11px",
|
|
1891
|
+
cursor: "pointer",
|
|
1892
|
+
opacity: 0.85
|
|
1893
|
+
},
|
|
1894
|
+
onClick: function () {
|
|
1895
|
+
props.onPickTag(tag);
|
|
1896
|
+
}
|
|
1897
|
+
},
|
|
1898
|
+
tag
|
|
1899
|
+
);
|
|
1900
|
+
})
|
|
1901
|
+
),
|
|
1902
|
+
// Where to find them, by name. The group COUNT used to live here, which told the
|
|
1903
|
+
// reader nothing: they are looking at the card from inside a group they already
|
|
1904
|
+
// share, so the number restates the premise. Names answer the real question —
|
|
1905
|
+
// which context to approach this person in.
|
|
1906
|
+
// Status, then context. The dot alone is decoration — "在线" has to be legible at a
|
|
1907
|
+
// glance, because its whole job is to answer "will they see this now".
|
|
1908
|
+
h(
|
|
1909
|
+
"div",
|
|
1910
|
+
{ style: { display: "flex", alignItems: "center", gap: "4px", flexWrap: "wrap" } },
|
|
1911
|
+
h("span", {
|
|
1912
|
+
style: {
|
|
1913
|
+
width: "6px",
|
|
1914
|
+
height: "6px",
|
|
1915
|
+
borderRadius: "50%",
|
|
1916
|
+
flex: "0 0 auto",
|
|
1917
|
+
background: card.online
|
|
1918
|
+
? "#3fb950"
|
|
1919
|
+
: "rgba(128, 128, 128, 0.5)"
|
|
1920
|
+
},
|
|
1921
|
+
"aria-hidden": "true"
|
|
1922
|
+
}),
|
|
1923
|
+
h(
|
|
1924
|
+
"span",
|
|
1925
|
+
{
|
|
1926
|
+
style: {
|
|
1927
|
+
fontSize: "11px",
|
|
1928
|
+
opacity: card.online ? 0.8 : 0.55,
|
|
1929
|
+
color: card.online ? "#3fb950" : "inherit"
|
|
1930
|
+
},
|
|
1931
|
+
title: card.online
|
|
1932
|
+
? "对方正开着 DSH,发消息会立即送到"
|
|
1933
|
+
: "对方当前不在线,消息会存着等他上线"
|
|
1934
|
+
},
|
|
1935
|
+
card.online ? "在线" : "不在线"
|
|
1936
|
+
),
|
|
1937
|
+
card.groups.length === 0
|
|
1938
|
+
? null
|
|
1939
|
+
: h(
|
|
1940
|
+
"span",
|
|
1941
|
+
{ style: { fontSize: "11px", opacity: 0.6 } },
|
|
1942
|
+
"· " + card.groups.slice(0, 3).join("、")
|
|
1943
|
+
+ (card.groups.length > 3 ? " 等 " + String(card.groups.length) + " 个群" : "")
|
|
1944
|
+
)
|
|
1945
|
+
),
|
|
1946
|
+
// The edit affordance. The panel cannot write a profile itself — it renders before
|
|
1947
|
+
// any Session exists and holds no member credential — so it does the part it CAN
|
|
1948
|
+
// do: put a prefilled instruction in the composer, so editing is "change the words
|
|
1949
|
+
// and press enter" rather than "know the incantation".
|
|
1950
|
+
card.isSelf === true
|
|
1951
|
+
? h(
|
|
1952
|
+
"div",
|
|
1953
|
+
{ style: { display: "flex", alignItems: "center", gap: "6px", marginTop: "2px" } },
|
|
1954
|
+
h(
|
|
1955
|
+
"button",
|
|
1956
|
+
{
|
|
1957
|
+
type: "button",
|
|
1958
|
+
title: "把编辑指令填进输入框,改完发送即可",
|
|
1959
|
+
style: {
|
|
1960
|
+
padding: "2px 8px",
|
|
1961
|
+
border: "1px solid currentColor",
|
|
1962
|
+
borderRadius: "4px",
|
|
1963
|
+
background: "transparent",
|
|
1964
|
+
color: "inherit",
|
|
1965
|
+
font: "inherit",
|
|
1966
|
+
fontSize: "11px",
|
|
1967
|
+
cursor: "pointer"
|
|
1968
|
+
},
|
|
1969
|
+
onClick: function () {
|
|
1970
|
+
// Replace rather than append: the draft is being replaced with an
|
|
1971
|
+
// instruction, and appending to whatever was typed would produce a
|
|
1972
|
+
// sentence nobody wrote.
|
|
1973
|
+
const text = draftProfileEdit(card, props.groupName ?? "");
|
|
1974
|
+
const actions = props.inputActions;
|
|
1975
|
+
if (actions !== null && actions !== undefined
|
|
1976
|
+
&& typeof actions.setDraft === "function") {
|
|
1977
|
+
actions.setDraft(text);
|
|
1978
|
+
} else if (actions !== null && actions !== undefined
|
|
1979
|
+
&& typeof actions.captureInsertion === "function"
|
|
1980
|
+
&& typeof actions.insertText === "function") {
|
|
1981
|
+
actions.insertText(text, actions.captureInsertion());
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
},
|
|
1985
|
+
"改我的名片"
|
|
1986
|
+
),
|
|
1987
|
+
h("span", { style: { fontSize: "10px", opacity: 0.5 } }, "点一下把指令填进输入框")
|
|
1988
|
+
)
|
|
1989
|
+
: null
|
|
1990
|
+
);
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
/** A tag chip row: "all" plus every tag present in the roster. */
|
|
1994
|
+
function TagFilter(props) {
|
|
1995
|
+
return h(
|
|
1996
|
+
"div",
|
|
1997
|
+
{ style: { display: "flex", flexWrap: "wrap", gap: "4px" } },
|
|
1998
|
+
[ "全部" ].concat(props.tags).map(function (tag, index) {
|
|
1999
|
+
const value = index === 0 ? "" : tag;
|
|
2000
|
+
const active = props.active === value;
|
|
2001
|
+
return h(
|
|
2002
|
+
"button",
|
|
2003
|
+
{
|
|
2004
|
+
key: tag,
|
|
2005
|
+
type: "button",
|
|
2006
|
+
style: {
|
|
2007
|
+
padding: "2px 8px",
|
|
2008
|
+
border: "1px solid rgba(128, 128, 128, 0.5)",
|
|
2009
|
+
borderRadius: "999px",
|
|
2010
|
+
background: active ? "light-dark(#1a1a1a, #f2f2f2)" : "transparent",
|
|
2011
|
+
color: active ? "light-dark(#f2f2f2, #1a1a1a)" : "inherit",
|
|
2012
|
+
font: "inherit",
|
|
2013
|
+
fontSize: "11px",
|
|
2014
|
+
cursor: "pointer"
|
|
2015
|
+
},
|
|
2016
|
+
onClick: function () {
|
|
2017
|
+
props.onSelect(value);
|
|
2018
|
+
}
|
|
2019
|
+
},
|
|
2020
|
+
tag
|
|
2021
|
+
);
|
|
2022
|
+
})
|
|
2023
|
+
);
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
/**
|
|
2027
|
+
* One titled section of the panel.
|
|
2028
|
+
*
|
|
2029
|
+
* Rendered only when non-empty. `hint` carries the one line that explains why the
|
|
2030
|
+
* section exists ("你是群主" / "还没加入"), because a heading alone leaves a person
|
|
2031
|
+
* guessing why two groups with the same shape sit in different places.
|
|
2032
|
+
*/
|
|
2033
|
+
function GroupSection(props) {
|
|
2034
|
+
if (props.groups.length === 0) return null;
|
|
2035
|
+
return h(
|
|
2036
|
+
"div",
|
|
2037
|
+
{ style: { display: "flex", flexDirection: "column", gap: "6px" } },
|
|
2038
|
+
h(
|
|
2039
|
+
"div",
|
|
2040
|
+
{ style: { display: "flex", alignItems: "baseline", gap: "8px" } },
|
|
2041
|
+
h("p", { style: S.sectionLabel }, props.title),
|
|
2042
|
+
h("span", { style: S.hint }, String(props.groups.length)),
|
|
2043
|
+
props.hint ? h("span", { style: S.hint }, props.hint) : null
|
|
2044
|
+
),
|
|
2045
|
+
props.groups.map((group) =>
|
|
2046
|
+
h(GroupCard, {
|
|
2047
|
+
key: group.id,
|
|
2048
|
+
group: group,
|
|
2049
|
+
open: props.expanded[group.id] === true,
|
|
2050
|
+
onToggle: () => {
|
|
2051
|
+
props.onToggle(group.id);
|
|
2052
|
+
}
|
|
2053
|
+
})
|
|
2054
|
+
)
|
|
2055
|
+
);
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
|
|
2059
|
+
|
|
2060
|
+
|
|
2061
|
+
/**
|
|
2062
|
+
* Layout for the right-column members pane.
|
|
2063
|
+
*
|
|
2064
|
+
* ## Why nothing here sets `maxWidth`
|
|
2065
|
+
*
|
|
2066
|
+
* The right column is user-resizable. An earlier version bounded the pane at 300px,
|
|
2067
|
+
* which is correct at the default width and wrong everywhere else: widen the column
|
|
2068
|
+
* and the roster stays pinned to the left of an ever-larger empty gutter. A pane that
|
|
2069
|
+
* fills its column is correct at every width, so the constraint moves from the
|
|
2070
|
+
* container to the *text*: each row is `flex` with `minWidth: 0`, so a name takes the
|
|
2071
|
+
* space its column has and ellipsizes only when that space genuinely runs out.
|
|
2072
|
+
*
|
|
2073
|
+
* ## Why `height` is absent, and why every column child is `flex: 0 0 auto`
|
|
2074
|
+
*
|
|
2075
|
+
* Two independent faults pushed the roster to the bottom of a mostly empty column:
|
|
2076
|
+
*
|
|
2077
|
+
* 1. `height: 100%` resolved against a parent whose height is not definite (the tab
|
|
2078
|
+
* seat between the slot owner and this pane has no fixed height), so the pane
|
|
2079
|
+
* became taller than its content and left the slack at the bottom;
|
|
2080
|
+
* 2. `groupName` carried `flex: 1 1 auto`, written when it was meant to stretch in a
|
|
2081
|
+
* ROW. As a direct child of this column container, `flex-grow` applies to the
|
|
2082
|
+
* BLOCK axis, so the group name inflated and pushed everything below it down.
|
|
2083
|
+
*
|
|
2084
|
+
* The fix is the layout itself, not a tuned height: the pane sizes to its content
|
|
2085
|
+
* (`height: auto`) and every direct child is `flex: 0 0 auto`, so no child can grow
|
|
2086
|
+
* and swallow the slack. Scrolling is the parent's job — one scroll container in the
|
|
2087
|
+
* chain, at the level that actually knows its height.
|
|
2088
|
+
*/
|
|
2089
|
+
const membersLayout = {
|
|
2090
|
+
// The inline role editor: a box that appears under my row, in the roster's own visual language so
|
|
2091
|
+
// it reads as part of that row rather than as a separate screen.
|
|
2092
|
+
editorBox: {
|
|
2093
|
+
display: "flex",
|
|
2094
|
+
flexDirection: "column",
|
|
2095
|
+
gap: "5px",
|
|
2096
|
+
margin: "4px 0 6px",
|
|
2097
|
+
padding: "8px",
|
|
2098
|
+
border: "1px solid rgba(128, 128, 128, 0.4)",
|
|
2099
|
+
borderRadius: "6px",
|
|
2100
|
+
background: "transparent"
|
|
2101
|
+
},
|
|
2102
|
+
editorInput: {
|
|
2103
|
+
width: "100%",
|
|
2104
|
+
boxSizing: "border-box",
|
|
2105
|
+
padding: "4px 7px",
|
|
2106
|
+
border: "1px solid rgba(128, 128, 128, 0.35)",
|
|
2107
|
+
borderRadius: "4px",
|
|
2108
|
+
background: "transparent",
|
|
2109
|
+
color: "inherit",
|
|
2110
|
+
font: "inherit",
|
|
2111
|
+
fontSize: "11px",
|
|
2112
|
+
colorScheme: "light dark"
|
|
2113
|
+
},
|
|
2114
|
+
// Solid border and full-strength text: the one action that commits, distinguished by contrast
|
|
2115
|
+
// rather than by a colour that would only work on one theme.
|
|
2116
|
+
editorSave: {
|
|
2117
|
+
padding: "3px 12px",
|
|
2118
|
+
border: "1px solid currentColor",
|
|
2119
|
+
borderRadius: "4px",
|
|
2120
|
+
background: "transparent",
|
|
2121
|
+
color: "inherit",
|
|
2122
|
+
font: "inherit",
|
|
2123
|
+
fontSize: "11px",
|
|
2124
|
+
fontWeight: 600,
|
|
2125
|
+
cursor: "pointer"
|
|
2126
|
+
},
|
|
2127
|
+
editorCancel: {
|
|
2128
|
+
padding: "3px 10px",
|
|
2129
|
+
border: "1px solid rgba(128, 128, 128, 0.4)",
|
|
2130
|
+
borderRadius: "4px",
|
|
2131
|
+
background: "transparent",
|
|
2132
|
+
color: "inherit",
|
|
2133
|
+
font: "inherit",
|
|
2134
|
+
fontSize: "11px",
|
|
2135
|
+
opacity: 0.75,
|
|
2136
|
+
cursor: "pointer"
|
|
2137
|
+
},
|
|
2138
|
+
|
|
2139
|
+
// A fixed overlay: the pane clips its own overflow, so a dialog parented inside it would be cut
|
|
2140
|
+
// off at the pane's edge and the search box would appear to be missing.
|
|
2141
|
+
dialogOverlay: {
|
|
2142
|
+
position: "fixed",
|
|
2143
|
+
inset: "0",
|
|
2144
|
+
// Translucent black works on both themes: it darkens without asserting a palette.
|
|
2145
|
+
background: "rgba(0, 0, 0, 0.35)",
|
|
2146
|
+
display: "flex",
|
|
2147
|
+
alignItems: "center",
|
|
2148
|
+
justifyContent: "center",
|
|
2149
|
+
zIndex: 9999
|
|
2150
|
+
},
|
|
2151
|
+
dialogPanel: {
|
|
2152
|
+
display: "flex",
|
|
2153
|
+
flexDirection: "column",
|
|
2154
|
+
gap: "8px",
|
|
2155
|
+
width: "min(420px, calc(100vw - 32px))",
|
|
2156
|
+
maxHeight: "min(460px, calc(100vh - 64px))",
|
|
2157
|
+
padding: "14px",
|
|
2158
|
+
// `Canvas` is the system colour for a window surface: it resolves to white on a light theme
|
|
2159
|
+
// and near-black on a dark one without this file knowing which is active. The previous
|
|
2160
|
+
// version guessed a dark hex behind a variable that does not exist, so on a light theme it
|
|
2161
|
+
// drew dark grey on black.
|
|
2162
|
+
background: "Canvas",
|
|
2163
|
+
color: "CanvasText",
|
|
2164
|
+
// Two borders: a hairline for shape, and a soft one for separation on themes where the panel
|
|
2165
|
+
// colour and the page colour are nearly the same.
|
|
2166
|
+
border: "1px solid rgba(128, 128, 128, 0.35)",
|
|
2167
|
+
borderRadius: "8px",
|
|
2168
|
+
boxShadow: "0 12px 32px rgba(0, 0, 0, 0.28)",
|
|
2169
|
+
boxSizing: "border-box",
|
|
2170
|
+
overflow: "hidden",
|
|
2171
|
+
// Tells the browser to render form controls and scrollbars for whichever theme is active.
|
|
2172
|
+
colorScheme: "light dark"
|
|
2173
|
+
},
|
|
2174
|
+
dialogHeader: {
|
|
2175
|
+
display: "flex",
|
|
2176
|
+
alignItems: "center",
|
|
2177
|
+
justifyContent: "space-between",
|
|
2178
|
+
gap: "8px"
|
|
2179
|
+
},
|
|
2180
|
+
dialogClose: {
|
|
2181
|
+
padding: "0 4px",
|
|
2182
|
+
border: "none",
|
|
2183
|
+
background: "transparent",
|
|
2184
|
+
color: "inherit",
|
|
2185
|
+
font: "inherit",
|
|
2186
|
+
fontSize: "14px",
|
|
2187
|
+
lineHeight: 1,
|
|
2188
|
+
cursor: "pointer",
|
|
2189
|
+
opacity: 0.6
|
|
2190
|
+
},
|
|
2191
|
+
dialogSearch: {
|
|
2192
|
+
width: "100%",
|
|
2193
|
+
boxSizing: "border-box",
|
|
2194
|
+
padding: "6px 8px",
|
|
2195
|
+
border: "1px solid rgba(128, 128, 128, 0.4)",
|
|
2196
|
+
borderRadius: "5px",
|
|
2197
|
+
background: "transparent",
|
|
2198
|
+
color: "inherit",
|
|
2199
|
+
font: "inherit",
|
|
2200
|
+
fontSize: "12px",
|
|
2201
|
+
// Without this the placeholder uses a browser default that ignores the theme.
|
|
2202
|
+
colorScheme: "light dark"
|
|
2203
|
+
},
|
|
2204
|
+
// Scrolls on its own: the header and the search stay put while the list moves, which is what
|
|
2205
|
+
// makes a long result set usable in a short dialog.
|
|
2206
|
+
dialogList: {
|
|
2207
|
+
display: "flex",
|
|
2208
|
+
flexDirection: "column",
|
|
2209
|
+
gap: "2px",
|
|
2210
|
+
overflowY: "auto",
|
|
2211
|
+
minHeight: 0
|
|
2212
|
+
},
|
|
2213
|
+
dialogItem: {
|
|
2214
|
+
display: "flex",
|
|
2215
|
+
alignItems: "baseline",
|
|
2216
|
+
gap: "8px",
|
|
2217
|
+
width: "100%",
|
|
2218
|
+
padding: "6px 8px",
|
|
2219
|
+
border: "1px solid transparent",
|
|
2220
|
+
borderRadius: "5px",
|
|
2221
|
+
background: "transparent",
|
|
2222
|
+
color: "inherit",
|
|
2223
|
+
font: "inherit",
|
|
2224
|
+
textAlign: "left",
|
|
2225
|
+
cursor: "pointer"
|
|
2226
|
+
},
|
|
2227
|
+
// The pane's single, quiet entry point into the dialog.
|
|
2228
|
+
inviteButton: {
|
|
2229
|
+
display: "inline-flex",
|
|
2230
|
+
alignItems: "center",
|
|
2231
|
+
gap: "5px",
|
|
2232
|
+
alignSelf: "flex-start",
|
|
2233
|
+
padding: "3px 10px",
|
|
2234
|
+
border: "1px dashed rgba(128, 128, 128, 0.5)",
|
|
2235
|
+
borderRadius: "5px",
|
|
2236
|
+
background: "transparent",
|
|
2237
|
+
color: "inherit",
|
|
2238
|
+
font: "inherit",
|
|
2239
|
+
fontSize: "11px",
|
|
2240
|
+
cursor: "pointer"
|
|
2241
|
+
},
|
|
2242
|
+
|
|
2243
|
+
// The unbound pane: an explainer with one action, not a wall of text. Generous line-height
|
|
2244
|
+
// because the copy is read once, carefully, by somebody who does not yet know what a
|
|
2245
|
+
// collaboration group is.
|
|
2246
|
+
unboundBody: {
|
|
2247
|
+
display: "flex",
|
|
2248
|
+
flexDirection: "column",
|
|
2249
|
+
gap: "6px",
|
|
2250
|
+
paddingTop: "2px"
|
|
2251
|
+
},
|
|
2252
|
+
unboundLead: {
|
|
2253
|
+
margin: 0,
|
|
2254
|
+
fontSize: "12px",
|
|
2255
|
+
lineHeight: 1.65,
|
|
2256
|
+
opacity: 0.78
|
|
2257
|
+
},
|
|
2258
|
+
// Step headings are numbered in the text, so the style only has to make them stand out
|
|
2259
|
+
// without implying a required order.
|
|
2260
|
+
unboundStep: {
|
|
2261
|
+
marginTop: "4px",
|
|
2262
|
+
fontSize: "12px",
|
|
2263
|
+
fontWeight: 600,
|
|
2264
|
+
opacity: 0.9
|
|
2265
|
+
},
|
|
2266
|
+
unboundHint: {
|
|
2267
|
+
margin: 0,
|
|
2268
|
+
fontSize: "11px",
|
|
2269
|
+
lineHeight: 1.6,
|
|
2270
|
+
opacity: 0.62
|
|
2271
|
+
},
|
|
2272
|
+
unboundInput: {
|
|
2273
|
+
width: "100%",
|
|
2274
|
+
boxSizing: "border-box",
|
|
2275
|
+
padding: "5px 7px",
|
|
2276
|
+
border: "1px solid rgba(128, 128, 128, 0.35)",
|
|
2277
|
+
borderRadius: "4px",
|
|
2278
|
+
background: "transparent",
|
|
2279
|
+
color: "inherit",
|
|
2280
|
+
font: "inherit",
|
|
2281
|
+
fontSize: "11px",
|
|
2282
|
+
resize: "vertical"
|
|
2283
|
+
},
|
|
2284
|
+
unboundButton: {
|
|
2285
|
+
alignSelf: "flex-start",
|
|
2286
|
+
padding: "3px 10px",
|
|
2287
|
+
border: "1px solid currentColor",
|
|
2288
|
+
borderRadius: "4px",
|
|
2289
|
+
background: "transparent",
|
|
2290
|
+
color: "inherit",
|
|
2291
|
+
font: "inherit",
|
|
2292
|
+
fontSize: "11px",
|
|
2293
|
+
cursor: "pointer"
|
|
2294
|
+
},
|
|
2295
|
+
// Dimmed rather than hidden while the box is empty: a button that disappears teaches the
|
|
2296
|
+
// reader nothing about why.
|
|
2297
|
+
unboundButtonOff: {
|
|
2298
|
+
opacity: 0.4,
|
|
2299
|
+
cursor: "default",
|
|
2300
|
+
borderColor: "rgba(128, 128, 128, 0.5)"
|
|
2301
|
+
},
|
|
2302
|
+
unboundFootnote: {
|
|
2303
|
+
marginTop: "6px",
|
|
2304
|
+
paddingTop: "6px",
|
|
2305
|
+
borderTop: "1px solid rgba(128, 128, 128, 0.35)",
|
|
2306
|
+
fontSize: "10px",
|
|
2307
|
+
lineHeight: 1.6,
|
|
2308
|
+
opacity: 0.5
|
|
2309
|
+
},
|
|
2310
|
+
|
|
2311
|
+
// The invite area sits below the roster, separated by a rule so it does not read as another
|
|
2312
|
+
// member row — an invitee is not in the group yet.
|
|
2313
|
+
inviteBlock: {
|
|
2314
|
+
display: "flex",
|
|
2315
|
+
flexDirection: "column",
|
|
2316
|
+
gap: "4px",
|
|
2317
|
+
marginTop: "6px",
|
|
2318
|
+
paddingTop: "6px",
|
|
2319
|
+
borderTop: "1px solid rgba(128, 128, 128, 0.35)"
|
|
2320
|
+
},
|
|
2321
|
+
inviteLabel: {
|
|
2322
|
+
fontSize: "11px",
|
|
2323
|
+
opacity: 0.6
|
|
2324
|
+
},
|
|
2325
|
+
// A chip rather than a button: several colleagues may be listed, and a row of full-width
|
|
2326
|
+
// buttons would push the roster off the panel.
|
|
2327
|
+
inviteChip: {
|
|
2328
|
+
padding: "2px 8px",
|
|
2329
|
+
border: "1px dashed rgba(128, 128, 128, 0.5)",
|
|
2330
|
+
borderRadius: "999px",
|
|
2331
|
+
background: "transparent",
|
|
2332
|
+
color: "inherit",
|
|
2333
|
+
font: "inherit",
|
|
2334
|
+
fontSize: "11px",
|
|
2335
|
+
cursor: "pointer",
|
|
2336
|
+
maxWidth: "100%",
|
|
2337
|
+
overflow: "hidden",
|
|
2338
|
+
textOverflow: "ellipsis",
|
|
2339
|
+
whiteSpace: "nowrap"
|
|
2340
|
+
},
|
|
2341
|
+
|
|
2342
|
+
root: {
|
|
2343
|
+
display: "flex",
|
|
2344
|
+
flexDirection: "column",
|
|
2345
|
+
gap: "6px",
|
|
2346
|
+
padding: "10px 10px 14px",
|
|
2347
|
+
width: "100%",
|
|
2348
|
+
minWidth: "0",
|
|
2349
|
+
// No `height`. `auto` is what makes the content start at the top.
|
|
2350
|
+
height: "auto",
|
|
2351
|
+
boxSizing: "border-box",
|
|
2352
|
+
fontSize: "12px"
|
|
2353
|
+
},
|
|
2354
|
+
header: {
|
|
2355
|
+
display: "flex",
|
|
2356
|
+
alignItems: "center",
|
|
2357
|
+
justifyContent: "space-between",
|
|
2358
|
+
gap: "6px",
|
|
2359
|
+
flex: "0 0 auto",
|
|
2360
|
+
minWidth: "0",
|
|
2361
|
+
paddingBottom: "6px",
|
|
2362
|
+
borderBottom: "1px solid rgba(128, 128, 128, 0.35)"
|
|
2363
|
+
},
|
|
2364
|
+
headerTitle: {
|
|
2365
|
+
display: "inline-flex",
|
|
2366
|
+
alignItems: "center",
|
|
2367
|
+
gap: "5px",
|
|
2368
|
+
flex: "0 0 auto",
|
|
2369
|
+
fontSize: "11px",
|
|
2370
|
+
letterSpacing: "0.04em",
|
|
2371
|
+
opacity: 0.6
|
|
2372
|
+
},
|
|
2373
|
+
headerMeta: {
|
|
2374
|
+
flex: "0 1 auto",
|
|
2375
|
+
minWidth: "0",
|
|
2376
|
+
overflow: "hidden",
|
|
2377
|
+
textOverflow: "ellipsis",
|
|
2378
|
+
whiteSpace: "nowrap",
|
|
2379
|
+
fontSize: "11px",
|
|
2380
|
+
opacity: 0.6
|
|
2381
|
+
},
|
|
2382
|
+
/* The group name is a heading, so it truncates rather than wraps: two lines of
|
|
2383
|
+
group name above a roster costs a row of people for no new information.
|
|
2384
|
+
`0 0 auto` — it must never grow along the column axis (see root's comment). */
|
|
2385
|
+
groupName: {
|
|
2386
|
+
margin: 0,
|
|
2387
|
+
flex: "0 0 auto",
|
|
2388
|
+
minWidth: "0",
|
|
2389
|
+
overflow: "hidden",
|
|
2390
|
+
textOverflow: "ellipsis",
|
|
2391
|
+
whiteSpace: "nowrap",
|
|
2392
|
+
fontSize: "12px",
|
|
2393
|
+
fontWeight: 600,
|
|
2394
|
+
opacity: 0.9
|
|
2395
|
+
},
|
|
2396
|
+
sectionLabel: {
|
|
2397
|
+
flex: "0 0 auto",
|
|
2398
|
+
fontSize: "11px",
|
|
2399
|
+
letterSpacing: "0.04em",
|
|
2400
|
+
opacity: 0.45,
|
|
2401
|
+
margin: "2px 0 0"
|
|
2402
|
+
},
|
|
2403
|
+
/* The roster list. `flex: 0 0 auto` so it sits at its natural height instead of
|
|
2404
|
+
absorbing the column's leftover space. */
|
|
2405
|
+
roster: {
|
|
2406
|
+
display: "flex",
|
|
2407
|
+
flexDirection: "column",
|
|
2408
|
+
gap: "1px",
|
|
2409
|
+
flex: "0 0 auto",
|
|
2410
|
+
minWidth: "0"
|
|
2411
|
+
},
|
|
2412
|
+
/*
|
|
2413
|
+
* 「所有人」 is a member row, not a banner.
|
|
2414
|
+
*
|
|
2415
|
+
* It shares {@link personRow} with everybody else — same height, same padding, same
|
|
2416
|
+
* font — because that is what @所有人 looks like in WeChat / Feishu / Slack: the
|
|
2417
|
+
* first row of the roster with a special name. An earlier version gave it a dashed
|
|
2418
|
+
* border, which made it read as a dropdown or a button rather than as a row.
|
|
2419
|
+
*
|
|
2420
|
+
* The only difference is the leading glyph: an `@` instead of a presence dot. That
|
|
2421
|
+
* is enough of a cue, and it says "broadcast" without a second visual language.
|
|
2422
|
+
*/
|
|
2423
|
+
personLead: {
|
|
2424
|
+
flex: "0 0 auto",
|
|
2425
|
+
width: "14px",
|
|
2426
|
+
display: "inline-flex",
|
|
2427
|
+
alignItems: "center",
|
|
2428
|
+
justifyContent: "center",
|
|
2429
|
+
alignSelf: "flex-start",
|
|
2430
|
+
marginTop: "1px",
|
|
2431
|
+
opacity: 0.75
|
|
2432
|
+
},
|
|
2433
|
+
/* A person row: name line on top, group role underneath. Button reset in full so
|
|
2434
|
+
it inherits the theme's type instead of the platform form-control styling. */
|
|
2435
|
+
personRow: {
|
|
2436
|
+
display: "flex",
|
|
2437
|
+
alignItems: "flex-start",
|
|
2438
|
+
gap: "6px",
|
|
2439
|
+
width: "100%",
|
|
2440
|
+
minWidth: "0",
|
|
2441
|
+
padding: "5px 6px",
|
|
2442
|
+
border: "1px solid transparent",
|
|
2443
|
+
borderRadius: "5px",
|
|
2444
|
+
background: "transparent",
|
|
2445
|
+
color: "inherit",
|
|
2446
|
+
font: "inherit",
|
|
2447
|
+
fontSize: "12px",
|
|
2448
|
+
textAlign: "left",
|
|
2449
|
+
cursor: "pointer"
|
|
2450
|
+
},
|
|
2451
|
+
/* The row I can edit, marked as such before the pointer arrives. */
|
|
2452
|
+
personRowSelf: {
|
|
2453
|
+
border: "1px solid currentColor",
|
|
2454
|
+
background: "rgba(76, 141, 255, 0.07)"
|
|
2455
|
+
},
|
|
2456
|
+
personBody: {
|
|
2457
|
+
flex: "1 1 auto",
|
|
2458
|
+
minWidth: "0",
|
|
2459
|
+
display: "flex",
|
|
2460
|
+
flexDirection: "column",
|
|
2461
|
+
gap: "1px"
|
|
2462
|
+
},
|
|
2463
|
+
personTop: {
|
|
2464
|
+
display: "flex",
|
|
2465
|
+
alignItems: "baseline",
|
|
2466
|
+
gap: "5px",
|
|
2467
|
+
minWidth: "0",
|
|
2468
|
+
width: "100%"
|
|
2469
|
+
},
|
|
2470
|
+
/* The only cell that shrinks: every sibling is `flex: 0 0 auto`. */
|
|
2471
|
+
personName: {
|
|
2472
|
+
flex: "0 1 auto",
|
|
2473
|
+
minWidth: "0",
|
|
2474
|
+
overflow: "hidden",
|
|
2475
|
+
textOverflow: "ellipsis",
|
|
2476
|
+
whiteSpace: "nowrap",
|
|
2477
|
+
fontWeight: 600
|
|
2478
|
+
},
|
|
2479
|
+
selfTag: {
|
|
2480
|
+
flex: "0 0 auto",
|
|
2481
|
+
fontSize: "10px",
|
|
2482
|
+
padding: "0 4px",
|
|
2483
|
+
borderRadius: "7px",
|
|
2484
|
+
lineHeight: "14px",
|
|
2485
|
+
// An INVERTED chip: dark on light themes, light on dark ones. `currentColor` was used before,
|
|
2486
|
+
// which inverts only one element - a dark theme produced a near-white pill with white text,
|
|
2487
|
+
// i.e. invisible. `light-dark()` picks both sides; every root here declares colorScheme.
|
|
2488
|
+
background: "light-dark(#1a1a1a, #f2f2f2)",
|
|
2489
|
+
color: "light-dark(#f2f2f2, #1a1a1a)"
|
|
2490
|
+
},
|
|
2491
|
+
roleChip: {
|
|
2492
|
+
flex: "0 0 auto",
|
|
2493
|
+
fontSize: "10px",
|
|
2494
|
+
opacity: 0.55
|
|
2495
|
+
},
|
|
2496
|
+
editChip: {
|
|
2497
|
+
flex: "0 0 auto",
|
|
2498
|
+
marginLeft: "auto",
|
|
2499
|
+
fontSize: "10px",
|
|
2500
|
+
padding: "0 4px",
|
|
2501
|
+
border: "1px solid rgba(128, 128, 128, 0.35)",
|
|
2502
|
+
borderRadius: "7px",
|
|
2503
|
+
lineHeight: "14px",
|
|
2504
|
+
opacity: 0.75
|
|
2505
|
+
},
|
|
2506
|
+
/* Wraps and clamps: the group role is content, not decoration. */
|
|
2507
|
+
personAbout: {
|
|
2508
|
+
display: "-webkit-box",
|
|
2509
|
+
WebkitLineClamp: 2,
|
|
2510
|
+
WebkitBoxOrient: "vertical",
|
|
2511
|
+
overflow: "hidden",
|
|
2512
|
+
margin: 0,
|
|
2513
|
+
fontSize: "11px",
|
|
2514
|
+
lineHeight: "1.45",
|
|
2515
|
+
opacity: 0.7,
|
|
2516
|
+
whiteSpace: "normal",
|
|
2517
|
+
wordBreak: "break-word",
|
|
2518
|
+
overflowWrap: "anywhere"
|
|
2519
|
+
},
|
|
2520
|
+
/* My own row shows the whole sentence: it is the thing I am about to edit. */
|
|
2521
|
+
personAboutSelf: {
|
|
2522
|
+
display: "block",
|
|
2523
|
+
margin: 0,
|
|
2524
|
+
fontSize: "11px",
|
|
2525
|
+
lineHeight: "1.45",
|
|
2526
|
+
opacity: 0.8,
|
|
2527
|
+
whiteSpace: "normal",
|
|
2528
|
+
wordBreak: "break-word",
|
|
2529
|
+
overflowWrap: "anywhere"
|
|
2530
|
+
},
|
|
2531
|
+
aboutEmpty: {
|
|
2532
|
+
margin: 0,
|
|
2533
|
+
fontSize: "11px",
|
|
2534
|
+
lineHeight: "1.45",
|
|
2535
|
+
opacity: 0.45,
|
|
2536
|
+
fontStyle: "italic"
|
|
2537
|
+
},
|
|
2538
|
+
dot: {
|
|
2539
|
+
width: "6px",
|
|
2540
|
+
height: "6px",
|
|
2541
|
+
borderRadius: "50%",
|
|
2542
|
+
flex: "0 0 auto",
|
|
2543
|
+
marginTop: "6px"
|
|
2544
|
+
},
|
|
2545
|
+
hint: {
|
|
2546
|
+
margin: 0,
|
|
2547
|
+
fontSize: "11px",
|
|
2548
|
+
lineHeight: "1.5",
|
|
2549
|
+
opacity: 0.55,
|
|
2550
|
+
whiteSpace: "normal",
|
|
2551
|
+
wordBreak: "break-word"
|
|
2552
|
+
}
|
|
2553
|
+
};
|
|
2554
|
+
|
|
2555
|
+
/**
|
|
2556
|
+
* Write a whole draft sentence into the composer, through the owner's two-step API.
|
|
2557
|
+
*
|
|
2558
|
+
* Same contract as {@link insertMention}: `captureInsertion()` snapshots the
|
|
2559
|
+
* selection and the draft revision, and `insertText(text, span)` is refused if the
|
|
2560
|
+
* draft changed between the click and the write — which is the behaviour we want,
|
|
2561
|
+
* because inserting an edit instruction at a stale caret would splice it into
|
|
2562
|
+
* whatever the reader typed in the meantime.
|
|
2563
|
+
*
|
|
2564
|
+
* The one-argument `insertText(text)` form is NOT used: it silently returns false.
|
|
2565
|
+
*
|
|
2566
|
+
* @param {object|undefined} inputActions - the composer's action surface.
|
|
2567
|
+
* @param {string} text - the text to insert.
|
|
2568
|
+
* @returns {boolean} whether something was actually written.
|
|
2569
|
+
*/
|
|
2570
|
+
function fillDraft(inputActions, text) {
|
|
2571
|
+
try {
|
|
2572
|
+
if (inputActions === null || inputActions === undefined) return false;
|
|
2573
|
+
if (typeof inputActions.captureInsertion === "function"
|
|
2574
|
+
&& typeof inputActions.insertText === "function") {
|
|
2575
|
+
const span = inputActions.captureInsertion();
|
|
2576
|
+
return inputActions.insertText(text, span) === true;
|
|
2577
|
+
}
|
|
2578
|
+
// Last resort for a Host that only offers a whole-draft setter. It replaces
|
|
2579
|
+
// the draft, which is why it is the fallback and not the first choice.
|
|
2580
|
+
if (typeof inputActions.setDraft === "function") {
|
|
2581
|
+
inputActions.setDraft(text);
|
|
2582
|
+
return true;
|
|
2583
|
+
}
|
|
2584
|
+
return false;
|
|
2585
|
+
} catch (error) {
|
|
2586
|
+
reportUnavailable("inputActions.insertText", error);
|
|
2587
|
+
return false;
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
/**
|
|
2592
|
+
* Members of the group the current Session belongs to, as a right-hand panel.
|
|
2593
|
+
*
|
|
2594
|
+
* Data comes from the same snapshot the central panel reads, filtered to the group
|
|
2595
|
+
* this Session is in. The panel therefore shows nothing when the Session has not
|
|
2596
|
+
* joined a group — which is correct, and says so, rather than listing strangers.
|
|
2597
|
+
*
|
|
2598
|
+
* ## The row order is IM order, not alphabetical
|
|
2599
|
+
*
|
|
2600
|
+
* 「所有人」 first because it is an address, not a person. 「我」 second and pinned,
|
|
2601
|
+
* because "where am I in this list" is the question a reader asks on opening it, and
|
|
2602
|
+
* because my row is the only one with a second action. Then everyone else, online
|
|
2603
|
+
* before offline, then by name — presence is the fact that changes by the minute.
|
|
2604
|
+
*
|
|
2605
|
+
* ## Why the edit is an instruction and not a form
|
|
2606
|
+
*
|
|
2607
|
+
* See {@link draftGroupRoleEdit}: this bundle holds no membership credential, so it
|
|
2608
|
+
* cannot write. Clicking my row fills the composer with a prefilled instruction and
|
|
2609
|
+
* the Session turn — which does hold the credential — performs the write.
|
|
2610
|
+
*
|
|
2611
|
+
* Props provided by the slot owner: `sessionId` and `inputActions`.
|
|
2612
|
+
*/
|
|
2613
|
+
/**
|
|
2614
|
+
* What the members pane shows in a conversation that has not joined a group.
|
|
2615
|
+
*
|
|
2616
|
+
* Three things, in the order a newcomer needs them: what the feature is, how to get in, and the
|
|
2617
|
+
* one-step path for somebody who already has a link. The paste box fills the composer because
|
|
2618
|
+
* this component has no credential — the Session turn performs the join.
|
|
2619
|
+
*
|
|
2620
|
+
* @param {object} props - `{ inputActions }`.
|
|
2621
|
+
* @returns {object} the pane element.
|
|
2622
|
+
*/
|
|
2623
|
+
function UnboundMembersPane(props) {
|
|
2624
|
+
const inputActions = props !== null && props !== undefined ? props.inputActions : undefined;
|
|
2625
|
+
const [link, setLink] = react.useState("");
|
|
2626
|
+
|
|
2627
|
+
/** Hand the pasted link to the Session as a ready-to-send instruction. */
|
|
2628
|
+
function joinWithLink() {
|
|
2629
|
+
const trimmed = String(link ?? "").trim();
|
|
2630
|
+
if (trimmed === "") return;
|
|
2631
|
+
fillDraft(inputActions, "用 a2a_group_join 加入这个群:" + trimmed);
|
|
2632
|
+
setLink("");
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
return h(
|
|
2636
|
+
"div",
|
|
2637
|
+
{ style: membersLayout.root },
|
|
2638
|
+
h(
|
|
2639
|
+
"div",
|
|
2640
|
+
{ style: membersLayout.header },
|
|
2641
|
+
h("span", { style: membersLayout.headerTitle }, icon("users", 12), "协作群"),
|
|
2642
|
+
h("span", { style: membersLayout.headerMeta }, "未加入")
|
|
2643
|
+
),
|
|
2644
|
+
h(
|
|
2645
|
+
"div",
|
|
2646
|
+
{ style: membersLayout.unboundBody },
|
|
2647
|
+
// 1. What it is. Without this the pane is a form for something the reader cannot name.
|
|
2648
|
+
h(
|
|
2649
|
+
"p",
|
|
2650
|
+
{ style: membersLayout.unboundLead },
|
|
2651
|
+
"这个会话还没有加入协作群。协作群是几个同事一起干活的地方:消息、分工和上下文都留在群里,谁进来都能看到前因后果。"
|
|
2652
|
+
),
|
|
2653
|
+
// 2a. The common case: somebody sent me a link.
|
|
2654
|
+
h("div", { style: membersLayout.unboundStep }, "① 同事给了你邀请链接"),
|
|
2655
|
+
h(
|
|
2656
|
+
"p",
|
|
2657
|
+
{ style: membersLayout.unboundHint },
|
|
2658
|
+
"粘贴到下面,点「填入会话」,再按回车发送即可。"
|
|
2659
|
+
),
|
|
2660
|
+
h("textarea", {
|
|
2661
|
+
value: link,
|
|
2662
|
+
placeholder: "agent-a2a://group?v=1&…",
|
|
2663
|
+
rows: 2,
|
|
2664
|
+
spellcheck: false,
|
|
2665
|
+
onChange: function (event) {
|
|
2666
|
+
setLink(event !== null && event !== undefined && event.target !== undefined
|
|
2667
|
+
? event.target.value
|
|
2668
|
+
: "");
|
|
2669
|
+
},
|
|
2670
|
+
style: membersLayout.unboundInput
|
|
2671
|
+
}),
|
|
2672
|
+
h(
|
|
2673
|
+
"button",
|
|
2674
|
+
{
|
|
2675
|
+
type: "button",
|
|
2676
|
+
// Disabled while empty so the button cannot produce a half-sentence.
|
|
2677
|
+
disabled: String(link ?? "").trim() === "",
|
|
2678
|
+
title: "把加入指令填进输入框,按回车发送",
|
|
2679
|
+
style: Object.assign(
|
|
2680
|
+
{},
|
|
2681
|
+
membersLayout.unboundButton,
|
|
2682
|
+
String(link ?? "").trim() === "" ? membersLayout.unboundButtonOff : {}
|
|
2683
|
+
),
|
|
2684
|
+
onClick: joinWithLink
|
|
2685
|
+
},
|
|
2686
|
+
"填入会话"
|
|
2687
|
+
),
|
|
2688
|
+
// 2b. The other route, for somebody with no link.
|
|
2689
|
+
h("div", { style: membersLayout.unboundStep }, "② 自己找群"),
|
|
2690
|
+
h(
|
|
2691
|
+
"p",
|
|
2692
|
+
{ style: membersLayout.unboundHint },
|
|
2693
|
+
"点左侧栏的「协作群」,从列表里挑一个申请加入;群主同意后你就会收到通知。"
|
|
2694
|
+
),
|
|
2695
|
+
// A thread somebody may already belong to. Saying so prevents the confusing case of
|
|
2696
|
+
// "I know I joined a group" while this pane insists otherwise.
|
|
2697
|
+
h(
|
|
2698
|
+
"p",
|
|
2699
|
+
{ style: membersLayout.unboundFootnote },
|
|
2700
|
+
"已经有别的会话加入过群?那个群属于那个会话,不会自动跟过来——在这里加入同一个群即可,两边看到的是同一份消息。"
|
|
2701
|
+
)
|
|
2702
|
+
)
|
|
2703
|
+
);
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
/**
|
|
2707
|
+
* The roster of a group, whichever name the data arrived under.
|
|
2708
|
+
*
|
|
2709
|
+
* `normalizeGroup` emits `members`; the raw injected snapshot uses `roster`. The pane is fed
|
|
2710
|
+
* both, so it asks this instead of assuming — the alternative is a list that is mysteriously
|
|
2711
|
+
* empty on one of the two paths.
|
|
2712
|
+
*
|
|
2713
|
+
* @param {object} group - the group row, normalized or raw.
|
|
2714
|
+
* @returns {object[]} the roster, never undefined.
|
|
2715
|
+
*/
|
|
2716
|
+
function rosterOf(group) {
|
|
2717
|
+
if (group === null || group === undefined) return [];
|
|
2718
|
+
if (Array.isArray(group.members) && group.members.length > 0) return group.members;
|
|
2719
|
+
if (Array.isArray(group.roster)) return group.roster;
|
|
2720
|
+
if (Array.isArray(group.members)) return group.members;
|
|
2721
|
+
return [];
|
|
2722
|
+
}
|
|
2723
|
+
|
|
2724
|
+
/**
|
|
2725
|
+
* One person's id, whichever field the row carries.
|
|
2726
|
+
*
|
|
2727
|
+
* A card has `personId`; a normalized roster row has `id`. Comparing one to the other without
|
|
2728
|
+
* this returns false for every pair, which is how "already in this group" stops working.
|
|
2729
|
+
*
|
|
2730
|
+
* @param {object} row - a card or a roster row.
|
|
2731
|
+
* @returns {string} the id, or an empty string.
|
|
2732
|
+
*/
|
|
2733
|
+
function personIdOf(row) {
|
|
2734
|
+
if (row === null || row === undefined) return "";
|
|
2735
|
+
const direct = pickString(row.personId);
|
|
2736
|
+
if (direct !== "") return direct;
|
|
2737
|
+
return pickString(row.id);
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
/**
|
|
2741
|
+
* The invite chooser.
|
|
2742
|
+
*
|
|
2743
|
+
* A dialog rather than a list inside the pane, because "who is in this group" and "who could
|
|
2744
|
+
* join it" are different questions and a directory of a thousand answers the second one badly in
|
|
2745
|
+
* a narrow column.
|
|
2746
|
+
*
|
|
2747
|
+
* Search is the primary control: nobody pages through a thousand colleagues, they type part of
|
|
2748
|
+
* a name. The list under the box is what an empty search shows, capped, with the count stated —
|
|
2749
|
+
* a silently truncated list makes a reader conclude somebody is not invitable.
|
|
2750
|
+
*
|
|
2751
|
+
* @param {object} props - `{ candidates, groupName, onPick, onClose }`.
|
|
2752
|
+
* @returns {object} the overlay.
|
|
2753
|
+
*/
|
|
2754
|
+
function InviteDialog(props) {
|
|
2755
|
+
const [query, setQuery] = react.useState("");
|
|
2756
|
+
// Sending / sent-online / sent-offline / failed. `sending` also disables the list: each invitation
|
|
2757
|
+
// mints a single-use link, and a second click would mint a second live one.
|
|
2758
|
+
const [inviteState, setInviteState] = react.useState("idle");
|
|
2759
|
+
const [inviteNote, setInviteNote] = react.useState("");
|
|
2760
|
+
|
|
2761
|
+
/** Send one invitation and record what happened. */
|
|
2762
|
+
function handleInvitePick(person) {
|
|
2763
|
+
if (panelRpcCaller === undefined) {
|
|
2764
|
+
// No channel: fall back to the instruction, so a host without Connection loses the
|
|
2765
|
+
// feedback rather than the ability to invite.
|
|
2766
|
+
fillDraft(props.inputActions, draftGroupInvite(props.group, person));
|
|
2767
|
+
props.onClose();
|
|
2768
|
+
return;
|
|
2769
|
+
}
|
|
2770
|
+
setInviteState("sending");
|
|
2771
|
+
setInviteNote("正在发送…");
|
|
2772
|
+
panelRpcCaller("/a2a-panel", "invite", {
|
|
2773
|
+
groupId: props.groupId,
|
|
2774
|
+
sessionId: props.sessionId,
|
|
2775
|
+
toPersonId: person.id,
|
|
2776
|
+
}).then(function (result) {
|
|
2777
|
+
if (result !== null && result !== undefined && result.ok === true) {
|
|
2778
|
+
const online = result.value !== undefined && result.value !== null && result.value.online === true;
|
|
2779
|
+
setInviteState("sent");
|
|
2780
|
+
setInviteNote(online
|
|
2781
|
+
? `已邀请 ${person.name},对方在线,会立即收到`
|
|
2782
|
+
: `已邀请 ${person.name},对方当前离线,上线后会看到`);
|
|
2783
|
+
return;
|
|
2784
|
+
}
|
|
2785
|
+
const code = result && result.error ? result.error.code : "unknown";
|
|
2786
|
+
setInviteState("failed");
|
|
2787
|
+
setInviteNote(`发送失败:${code}`);
|
|
2788
|
+
}).catch(function (error) {
|
|
2789
|
+
setInviteState("failed");
|
|
2790
|
+
setInviteNote("发送失败:" + String((error && error.message) || error));
|
|
2791
|
+
});
|
|
2792
|
+
}
|
|
2793
|
+
const candidates = Array.isArray(props.candidates) ? props.candidates : [];
|
|
2794
|
+
|
|
2795
|
+
// Local, case-insensitive, and matched against the description too: a person looking for
|
|
2796
|
+
// "the DBA" should find 赵六 even if they cannot recall the name.
|
|
2797
|
+
const needle = String(query ?? "").trim().toLowerCase();
|
|
2798
|
+
const matched = needle === ""
|
|
2799
|
+
? candidates
|
|
2800
|
+
: candidates.filter(function (person) {
|
|
2801
|
+
const hay = (person.name + " " + (person.headline ?? "") + " " + (person.tags ?? []).join(" ")).toLowerCase();
|
|
2802
|
+
return hay.indexOf(needle) !== -1;
|
|
2803
|
+
});
|
|
2804
|
+
|
|
2805
|
+
const LIMIT = 6;
|
|
2806
|
+
const shown = matched.slice(0, LIMIT);
|
|
2807
|
+
const hidden = matched.length - shown.length;
|
|
2808
|
+
|
|
2809
|
+
return h(
|
|
2810
|
+
"div",
|
|
2811
|
+
{ style: membersLayout.dialogOverlay, onClick: props.onClose },
|
|
2812
|
+
// Clicks inside must not close: the overlay closes, the panel does not.
|
|
2813
|
+
h(
|
|
2814
|
+
"div",
|
|
2815
|
+
{ style: membersLayout.dialogPanel, onClick: function (event) { event.stopPropagation(); } },
|
|
2816
|
+
h(
|
|
2817
|
+
"div",
|
|
2818
|
+
{ style: membersLayout.dialogHeader },
|
|
2819
|
+
h("span", { style: { fontWeight: 600, fontSize: "13px" } }, "邀请同事入群"),
|
|
2820
|
+
h(
|
|
2821
|
+
"button",
|
|
2822
|
+
{
|
|
2823
|
+
type: "button",
|
|
2824
|
+
title: "关闭",
|
|
2825
|
+
style: membersLayout.dialogClose,
|
|
2826
|
+
onClick: props.onClose
|
|
2827
|
+
},
|
|
2828
|
+
"✕"
|
|
2829
|
+
)
|
|
2830
|
+
),
|
|
2831
|
+
h(
|
|
2832
|
+
"p",
|
|
2833
|
+
{ style: membersLayout.unboundHint },
|
|
2834
|
+
"选中的同事会收到一条消息,里面带着加入「" + (props.groupName ?? "这个群") + "」的链接。"
|
|
2835
|
+
),
|
|
2836
|
+
h("input", {
|
|
2837
|
+
type: "search",
|
|
2838
|
+
value: query,
|
|
2839
|
+
placeholder: "搜索名字、角色或标签…",
|
|
2840
|
+
autoFocus: true,
|
|
2841
|
+
onChange: function (event) {
|
|
2842
|
+
setQuery(event !== null && event !== undefined && event.target !== undefined
|
|
2843
|
+
? event.target.value
|
|
2844
|
+
: "");
|
|
2845
|
+
},
|
|
2846
|
+
style: membersLayout.dialogSearch
|
|
2847
|
+
}),
|
|
2848
|
+
matched.length === 0
|
|
2849
|
+
? h("p", { style: membersLayout.unboundHint }, "没有匹配的同事。")
|
|
2850
|
+
: h(
|
|
2851
|
+
"div",
|
|
2852
|
+
{ style: membersLayout.dialogList },
|
|
2853
|
+
shown.map(function (person) {
|
|
2854
|
+
return h(
|
|
2855
|
+
"button",
|
|
2856
|
+
{
|
|
2857
|
+
key: person.id,
|
|
2858
|
+
type: "button",
|
|
2859
|
+
style: membersLayout.dialogItem,
|
|
2860
|
+
title: "邀请 " + person.name + " 加入",
|
|
2861
|
+
// Disabled while a send is in flight: each invitation is single-use, and a
|
|
2862
|
+
// second click would mint a second live link.
|
|
2863
|
+
disabled: inviteState === "sending",
|
|
2864
|
+
onClick: function () { handleInvitePick(person); }
|
|
2865
|
+
},
|
|
2866
|
+
h("span", { style: { fontSize: "12px" } }, person.name),
|
|
2867
|
+
(person.headline ?? "") === ""
|
|
2868
|
+
? null
|
|
2869
|
+
: h("span", { style: { fontSize: "11px", opacity: 0.6 } }, person.headline)
|
|
2870
|
+
);
|
|
2871
|
+
})
|
|
2872
|
+
),
|
|
2873
|
+
// Stated rather than implied: a capped list with no count reads as "that is everybody".
|
|
2874
|
+
hidden > 0
|
|
2875
|
+
? h(
|
|
2876
|
+
"p",
|
|
2877
|
+
{ style: membersLayout.unboundHint },
|
|
2878
|
+
"还有 " + String(hidden) + " 位同事未显示,输入名字缩小范围。"
|
|
2879
|
+
)
|
|
2880
|
+
: null,
|
|
2881
|
+
inviteNote === ""
|
|
2882
|
+
? null
|
|
2883
|
+
: h(
|
|
2884
|
+
"p",
|
|
2885
|
+
{
|
|
2886
|
+
style: Object.assign(
|
|
2887
|
+
{},
|
|
2888
|
+
membersLayout.unboundHint,
|
|
2889
|
+
inviteState === "failed" ? { color: "#c0392b" } : {}
|
|
2890
|
+
)
|
|
2891
|
+
},
|
|
2892
|
+
inviteNote
|
|
2893
|
+
)
|
|
2894
|
+
)
|
|
2895
|
+
);
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
function MembersPane(props) {
|
|
2899
|
+
const snapshot = useSnapshot();
|
|
2900
|
+
const inputActions = props !== null && props !== undefined ? props.inputActions : undefined;
|
|
2901
|
+
const sessionId = props !== null && props !== undefined ? pickString(props.sessionId) : "";
|
|
2902
|
+
// Whether the invite chooser is open. Kept here rather than in the dialog so one Escape or
|
|
2903
|
+
// one pick closes it from the outside too.
|
|
2904
|
+
const [inviteOpen, setInviteOpen] = react.useState(false);
|
|
2905
|
+
// Whether my own row is being edited in place. Separate from `inviteOpen` because the two open
|
|
2906
|
+
// different surfaces and only one of them writes.
|
|
2907
|
+
const [editingRole, setEditingRole] = react.useState(false);
|
|
2908
|
+
|
|
2909
|
+
// Which group this Session belongs to. A Session can hold several; the pane shows
|
|
2910
|
+
// the one with the most recent activity, because that is the one being worked in.
|
|
2911
|
+
const candidates = snapshot.groups.filter(function (group) {
|
|
2912
|
+
if (group.discovered === true) return false;
|
|
2913
|
+
if (sessionId === "") return true;
|
|
2914
|
+
return group.sessionId === "" || group.sessionId === sessionId;
|
|
2915
|
+
});
|
|
2916
|
+
candidates.sort(function (left, right) {
|
|
2917
|
+
return (right.lastTime || 0) - (left.lastTime || 0);
|
|
2918
|
+
});
|
|
2919
|
+
const group = candidates[0];
|
|
2920
|
+
|
|
2921
|
+
/** Insert `@name ` into the composer through the owner's two-step API. */
|
|
2922
|
+
function address(name) {
|
|
2923
|
+
fillDraft(inputActions, "@" + name + " ");
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2926
|
+
/** Fill the composer with the prefilled role-edit instruction. */
|
|
2927
|
+
function editOwnRole(current) {
|
|
2928
|
+
fillDraft(inputActions, draftGroupRoleEdit({
|
|
2929
|
+
groupName: group === undefined ? "" : group.name,
|
|
2930
|
+
groupId: group === undefined ? "" : group.id,
|
|
2931
|
+
about: current.about,
|
|
2932
|
+
tags: current.tags
|
|
2933
|
+
}));
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
if (group === undefined) {
|
|
2937
|
+
// A real pane, not one sentence: the entry point exists, so it owes the reader the three
|
|
2938
|
+
// answers a new conversation needs — what this is, how to join, and the one-step path
|
|
2939
|
+
// for somebody who already has a link.
|
|
2940
|
+
return h(UnboundMembersPane, { inputActions: inputActions });
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
// The snapshot may be normalized (`members`) or raw (`roster`); ask once, here.
|
|
2944
|
+
const members = rosterOf(group);
|
|
2945
|
+
const self = pickSelf(group, snapshot.cards);
|
|
2946
|
+
const split = splitRoster(members, self);
|
|
2947
|
+
// A roster that arrived without an `isSelf` marker still gets a 「我」 row: the
|
|
2948
|
+
// person table knows who I am even when the cached roster was written by a Host
|
|
2949
|
+
// that predates the marker. This synthesis is why `split.mine` can be absent while
|
|
2950
|
+
// `self.hasSelf` is true.
|
|
2951
|
+
if (split.mine === undefined && split.owner === undefined && self.hasSelf === true && self.personId !== "") {
|
|
2952
|
+
split.mine = {
|
|
2953
|
+
id: self.personId,
|
|
2954
|
+
name: self.name === "" ? "我" : self.name,
|
|
2955
|
+
role: "",
|
|
2956
|
+
roleAbout: self.about,
|
|
2957
|
+
tags: self.tags,
|
|
2958
|
+
online: self.online,
|
|
2959
|
+
self: true,
|
|
2960
|
+
isSelf: true
|
|
2961
|
+
};
|
|
2962
|
+
}
|
|
2963
|
+
|
|
2964
|
+
const countLine = String(group.memberCount || members.length) + " 人"
|
|
2965
|
+
+ (group.onlineCount > 0 ? " · " + String(group.onlineCount) + " 在线" : "");
|
|
2966
|
+
|
|
2967
|
+
/**
|
|
2968
|
+
* The body shared by every roster row, including 「所有人」.
|
|
2969
|
+
*
|
|
2970
|
+
* One builder for all of them is the point: the broadcast row differs only in its
|
|
2971
|
+
* leading glyph and its name, so any future change to row rhythm (padding, chips,
|
|
2972
|
+
* clamp) lands on it for free instead of drifting into a second visual language.
|
|
2973
|
+
*
|
|
2974
|
+
* @param {string} name - display text.
|
|
2975
|
+
* @param {object} options - `about`, `role`, `badges` (extra chips), `aboutEmpty`.
|
|
2976
|
+
* @returns {object} vnode for the row's text column.
|
|
2977
|
+
*/
|
|
2978
|
+
function rowBody(name, options) {
|
|
2979
|
+
const given = options === null || options === undefined ? {} : options;
|
|
2980
|
+
const about = pickString(given.about);
|
|
2981
|
+
const badges = Array.isArray(given.badges) ? given.badges : [];
|
|
2982
|
+
const chips = [];
|
|
2983
|
+
for (const badge of badges) {
|
|
2984
|
+
if (badge !== null && badge !== undefined) chips.push(badge);
|
|
2985
|
+
}
|
|
2986
|
+
return h(
|
|
2987
|
+
"span",
|
|
2988
|
+
{ style: membersLayout.personBody },
|
|
2989
|
+
h(
|
|
2990
|
+
"span",
|
|
2991
|
+
{ style: membersLayout.personTop },
|
|
2992
|
+
h("span", { style: membersLayout.personName }, name),
|
|
2993
|
+
chips.length === 0 ? null : chips,
|
|
2994
|
+
given.owner === true ? h("span", { style: membersLayout.roleChip }, "群主") : null,
|
|
2995
|
+
about !== "" || given.showEmptyRole !== true
|
|
2996
|
+
? null
|
|
2997
|
+
: h("span", { style: membersLayout.roleChip }, "未填群内介绍")
|
|
2998
|
+
),
|
|
2999
|
+
about === ""
|
|
3000
|
+
? given.emptyText === undefined
|
|
3001
|
+
? null
|
|
3002
|
+
: h("p", { style: membersLayout.aboutEmpty }, given.emptyText)
|
|
3003
|
+
: h("span", { style: given.full ? membersLayout.personAboutSelf : membersLayout.personAbout }, about)
|
|
3004
|
+
);
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
/**
|
|
3008
|
+
* One roster row.
|
|
3009
|
+
*
|
|
3010
|
+
* There are exactly two behaviours, and which one applies is decided by one fact:
|
|
3011
|
+
* whether this row is me.
|
|
3012
|
+
*
|
|
3013
|
+
* - **me** → fill the composer with the role-edit instruction. I cannot @ myself,
|
|
3014
|
+
* so an @-mention here would be a click that produces nothing useful.
|
|
3015
|
+
* - **anyone else** (including the owner) → insert `@name `. That covers the
|
|
3016
|
+
* owner row too: addressing the owner is the common reason to click them.
|
|
3017
|
+
*
|
|
3018
|
+
* @param {object} member - normalized member, with `isSelf` when I am this row.
|
|
3019
|
+
* @param {object} options - `lead` (glyph or `@`), `owner`, `editable`, `key`.
|
|
3020
|
+
* @returns {object} vnode.
|
|
3021
|
+
*/
|
|
3022
|
+
function personRow(member, options) {
|
|
3023
|
+
const given = options === null || options === undefined ? {} : options;
|
|
3024
|
+
const isSelf = member.isSelf === true;
|
|
3025
|
+
const canEdit = isSelf && inputActions !== null && inputActions !== undefined;
|
|
3026
|
+
const about = isSelf ? pickString(self.about) : pickString(member.roleAbout);
|
|
3027
|
+
|
|
3028
|
+
const badges = [];
|
|
3029
|
+
// Suppress the self chip when the display name already says 我: the row would read
|
|
3030
|
+
// 「我 我」, which looks like a bug rather than a label. The row is still marked as
|
|
3031
|
+
// mine by its accent border and its edit chip.
|
|
3032
|
+
if (isSelf && pickString(member.name) !== "我") {
|
|
3033
|
+
badges.push(h("span", { key: "me", style: membersLayout.selfTag }, "我"));
|
|
3034
|
+
}
|
|
3035
|
+
if (given.owner === true) badges.push(h("span", { key: "owner", style: membersLayout.roleChip }, "群主"));
|
|
3036
|
+
if (canEdit) badges.push(h("span", { key: "edit", style: membersLayout.editChip }, "编辑介绍"));
|
|
3037
|
+
if (isSelf && !canEdit) badges.push(h("span", { key: "ro", style: membersLayout.editChip }, "只读"));
|
|
3038
|
+
|
|
3039
|
+
const label = isSelf
|
|
3040
|
+
? (canEdit
|
|
3041
|
+
? "点击修改我在这个群的介绍(会填入输入框,按回车发送)"
|
|
3042
|
+
: "这个面板拿不到输入框,无法发起编辑")
|
|
3043
|
+
: "点击在输入框里 @ " + member.name;
|
|
3044
|
+
|
|
3045
|
+
const button = h(
|
|
3046
|
+
"button",
|
|
3047
|
+
{
|
|
3048
|
+
key: given.key === undefined ? member.id : given.key,
|
|
3049
|
+
type: "button",
|
|
3050
|
+
title: label,
|
|
3051
|
+
style: isSelf
|
|
3052
|
+
? Object.assign({}, membersLayout.personRow, membersLayout.personRowSelf)
|
|
3053
|
+
: membersLayout.personRow,
|
|
3054
|
+
onClick: function () {
|
|
3055
|
+
if (isSelf) {
|
|
3056
|
+
// Prefer the inline editor: it writes straight to the Host. Fall back to the composer
|
|
3057
|
+
// instruction only when the write channel is missing, and say which happened rather than
|
|
3058
|
+
// silently doing the other thing.
|
|
3059
|
+
if (panelRpcCaller !== undefined) {
|
|
3060
|
+
setEditingRole(true);
|
|
3061
|
+
} else {
|
|
3062
|
+
editOwnRole(self);
|
|
3063
|
+
}
|
|
3064
|
+
}
|
|
3065
|
+
else address(member.name);
|
|
3066
|
+
}
|
|
3067
|
+
},
|
|
3068
|
+
given.lead === undefined
|
|
3069
|
+
? h("span", {
|
|
3070
|
+
style: Object.assign({}, membersLayout.dot, {
|
|
3071
|
+
background: member.online === true
|
|
3072
|
+
? "#3fb950"
|
|
3073
|
+
: "rgba(128, 128, 128, 0.5)"
|
|
3074
|
+
}),
|
|
3075
|
+
title: member.online === true ? "在线" : "离线或未知"
|
|
3076
|
+
})
|
|
3077
|
+
: h("span", { style: membersLayout.personLead, "aria-hidden": "true" }, given.lead),
|
|
3078
|
+
rowBody(member.name, {
|
|
3079
|
+
about: about,
|
|
3080
|
+
owner: false,
|
|
3081
|
+
full: isSelf,
|
|
3082
|
+
showEmptyRole: !isSelf,
|
|
3083
|
+
emptyText: isSelf ? (canEdit ? "还没写群内介绍,点这一行添加" : "还没写群内介绍") : undefined,
|
|
3084
|
+
badges: badges
|
|
3085
|
+
})
|
|
3086
|
+
);
|
|
3087
|
+
|
|
3088
|
+
// No separate "clear" control. Clearing is not a sibling of editing — it is the
|
|
3089
|
+
// empty case of it, and the edit draft already says so ("清空 about 就是删掉这条
|
|
3090
|
+
// 介绍"). A second destructive button under the row would be one more thing to
|
|
3091
|
+
// mis-click, and it has no IM precedent: WeChat and Feishu put destructive
|
|
3092
|
+
// actions inside the row's own ⋯ menu, never as a line of the list.
|
|
3093
|
+
//
|
|
3094
|
+
// When that ⋯ menu exists, clearing belongs there. Until then the edit path is
|
|
3095
|
+
// the only writer, which is also the property that keeps the credential on the
|
|
3096
|
+
// host.
|
|
3097
|
+
return button;
|
|
3098
|
+
}
|
|
3099
|
+
|
|
3100
|
+
/**
|
|
3101
|
+
* 「所有人」: a roster row, first, with an `@` instead of a presence dot.
|
|
3102
|
+
*
|
|
3103
|
+
* No dashed border and no separate padding — in WeChat / Feishu / Slack, @所有人 is
|
|
3104
|
+
* simply the first line of the member list with a special name. The glyph is the
|
|
3105
|
+
* whole cue.
|
|
3106
|
+
*/
|
|
3107
|
+
function broadcastRow() {
|
|
3108
|
+
return h(
|
|
3109
|
+
"button",
|
|
3110
|
+
{
|
|
3111
|
+
key: "broadcast",
|
|
3112
|
+
type: "button",
|
|
3113
|
+
title: "点击 @ 所有人",
|
|
3114
|
+
style: membersLayout.personRow,
|
|
3115
|
+
onClick: function () {
|
|
3116
|
+
address("所有人");
|
|
3117
|
+
}
|
|
3118
|
+
},
|
|
3119
|
+
// A literal `@`, not a glyph: it is the universal broadcast marker, it is what
|
|
3120
|
+
// the inserted text actually looks like, and no icon in the product set says
|
|
3121
|
+
// "everyone" more clearly than the character the reader is about to type.
|
|
3122
|
+
h("span", { style: membersLayout.personLead, "aria-hidden": "true" }, "@"),
|
|
3123
|
+
rowBody("所有人", { about: "", showEmptyRole: false })
|
|
3124
|
+
);
|
|
3125
|
+
}
|
|
3126
|
+
|
|
3127
|
+
|
|
3128
|
+
// One flat roster, in the order a person scans it:
|
|
3129
|
+
//
|
|
3130
|
+
// 所有人 → 群主 → 我 → 其他人
|
|
3131
|
+
//
|
|
3132
|
+
// 「所有人」 first because it is an address, not a person. 群主 second because they
|
|
3133
|
+
// are the one to ask when the group is stuck. 我 third because that is the row with
|
|
3134
|
+
// a second action. Everyone else by presence, then name.
|
|
3135
|
+
//
|
|
3136
|
+
// `split.owner` carries `isSelf` when I run the group, so that case renders exactly
|
|
3137
|
+
// one row (labelled 群主 · 我) rather than listing me twice.
|
|
3138
|
+
const rosterRows = [broadcastRow()];
|
|
3139
|
+
if (split.owner !== undefined) {
|
|
3140
|
+
rosterRows.push(personRow(split.owner, { key: "owner", owner: true }));
|
|
3141
|
+
}
|
|
3142
|
+
if (split.mine !== undefined) {
|
|
3143
|
+
rosterRows.push(personRow(split.mine, { key: "self" }));
|
|
3144
|
+
}
|
|
3145
|
+
if (split.others.length === 0 && members.length > 0 && split.owner === undefined && split.mine === undefined) {
|
|
3146
|
+
rosterRows.push(h("p", { key: "alone", style: membersLayout.hint }, "这个群目前只有你。"));
|
|
3147
|
+
}
|
|
3148
|
+
for (const member of split.others) rosterRows.push(personRow(member, {}));
|
|
3149
|
+
|
|
3150
|
+
// Inviting lives HERE, not on the colleague card: the group is the subject of the action, so
|
|
3151
|
+
// the group is what the pane knows. Cards show the group the instruction guessed would have
|
|
3152
|
+
// come from — a first group the reader never picked.
|
|
3153
|
+
const invitable = (Array.isArray(snapshot.cards) ? snapshot.cards : [])
|
|
3154
|
+
.filter(function (card) {
|
|
3155
|
+
return card.isSelf !== true;
|
|
3156
|
+
})
|
|
3157
|
+
.filter(function (card) {
|
|
3158
|
+
// Somebody already in this group cannot be invited to it again.
|
|
3159
|
+
return !members.some(function (member) { return personIdOf(member) === personIdOf(card); });
|
|
3160
|
+
});
|
|
3161
|
+
|
|
3162
|
+
// One quiet button. The choice happens in a dialog sized for choosing; an inline list either
|
|
3163
|
+
// crowds the roster (a big team) or hides its own extent (twelve shown, no count).
|
|
3164
|
+
function inviteRow() {
|
|
3165
|
+
if (invitable.length === 0) return null;
|
|
3166
|
+
return h(
|
|
3167
|
+
"div",
|
|
3168
|
+
{ style: membersLayout.inviteBlock },
|
|
3169
|
+
h(
|
|
3170
|
+
"button",
|
|
3171
|
+
{
|
|
3172
|
+
type: "button",
|
|
3173
|
+
title: "选择要拉进这个群的同事",
|
|
3174
|
+
style: membersLayout.inviteButton,
|
|
3175
|
+
onClick: function () { setInviteOpen(true); }
|
|
3176
|
+
},
|
|
3177
|
+
icon("users", 12),
|
|
3178
|
+
"邀请同事入群"
|
|
3179
|
+
)
|
|
3180
|
+
);
|
|
3181
|
+
}
|
|
3182
|
+
const pane = h(
|
|
3183
|
+
"div",
|
|
3184
|
+
{ style: membersLayout.root },
|
|
3185
|
+
h(
|
|
3186
|
+
"div",
|
|
3187
|
+
{ style: membersLayout.header },
|
|
3188
|
+
h(
|
|
3189
|
+
"span",
|
|
3190
|
+
{ style: membersLayout.headerTitle },
|
|
3191
|
+
icon("users", 12),
|
|
3192
|
+
"群成员"
|
|
3193
|
+
),
|
|
3194
|
+
h("span", { style: membersLayout.headerMeta }, countLine)
|
|
3195
|
+
),
|
|
3196
|
+
h("p", { style: membersLayout.groupName, title: group.topic === "" ? group.name : group.name + " · " + group.topic }, group.name),
|
|
3197
|
+
members.length === 0
|
|
3198
|
+
? h("p", { style: membersLayout.hint }, "名单需要在会话里查一次(a2a_group_info)才会出现。")
|
|
3199
|
+
: h("div", { style: membersLayout.roster }, rosterRows),
|
|
3200
|
+
editingRole && group !== undefined
|
|
3201
|
+
? h(RoleEditor, {
|
|
3202
|
+
groupId: group.id,
|
|
3203
|
+
sessionId: sessionId,
|
|
3204
|
+
about: self.about,
|
|
3205
|
+
tags: self.tags,
|
|
3206
|
+
// Read from the person table, not the roster row: the headline is a person fact and a
|
|
3207
|
+
// roster written before it existed would not carry it.
|
|
3208
|
+
headline: (function () {
|
|
3209
|
+
const mine = (Array.isArray(snapshot.cards) ? snapshot.cards : []).filter(function (card) {
|
|
3210
|
+
return card.isSelf === true;
|
|
3211
|
+
})[0];
|
|
3212
|
+
return mine === undefined ? "" : (mine.headline ?? "");
|
|
3213
|
+
})(),
|
|
3214
|
+
onCancel: function () { setEditingRole(false); },
|
|
3215
|
+
onSaved: function () {
|
|
3216
|
+
// Close on success; the panel refreshes from the Host's next snapshot, so the new
|
|
3217
|
+
// value appears without a reload.
|
|
3218
|
+
setEditingRole(false);
|
|
3219
|
+
}
|
|
3220
|
+
})
|
|
3221
|
+
: null,
|
|
3222
|
+
inviteRow(),
|
|
3223
|
+
inviteOpen
|
|
3224
|
+
? h(InviteDialog, {
|
|
3225
|
+
candidates: invitable,
|
|
3226
|
+
groupName: group.name,
|
|
3227
|
+
// The facts the dialog needs to send for itself: which group, which Session's credential,
|
|
3228
|
+
// and the composer for the no-channel fallback.
|
|
3229
|
+
groupId: group.id,
|
|
3230
|
+
sessionId: sessionId,
|
|
3231
|
+
group: group,
|
|
3232
|
+
inputActions: inputActions,
|
|
3233
|
+
onClose: function () { setInviteOpen(false); }
|
|
3234
|
+
})
|
|
3235
|
+
: null,
|
|
3236
|
+
);
|
|
3237
|
+
|
|
3238
|
+
// The dialog is position: fixed, so it covers the viewport even though it is a child of the pane.
|
|
3239
|
+
// A fixed element is positioned against the viewport regardless of its ancestors, which is why the
|
|
3240
|
+
// pane's own overflow cannot clip it — and why no wrapper is needed.
|
|
3241
|
+
return pane;
|
|
3242
|
+
}
|
|
3243
|
+
|
|
3244
|
+
|
|
3245
|
+
/**
|
|
3246
|
+
* Composer button that opens the members column.
|
|
3247
|
+
*
|
|
3248
|
+
* Lives in `conversation.input.left` beside the other small controls rather than in
|
|
3249
|
+
* the global sidebar: the column shows THIS conversation's group, so a global
|
|
3250
|
+
* destination for it would be a lie about its scope.
|
|
3251
|
+
*/
|
|
3252
|
+
function MembersButton(props) {
|
|
3253
|
+
const snapshot = useSnapshot();
|
|
3254
|
+
const sessionId = props !== null && props !== undefined ? pickString(props.sessionId) : "";
|
|
3255
|
+
|
|
3256
|
+
// Which group THIS conversation is in decides the count, and whether the button is worth
|
|
3257
|
+
// showing at all. Without the session filter this took the most recently active group in
|
|
3258
|
+
// the whole instance, so a new conversation showed the member count of a group it had
|
|
3259
|
+
// never joined. The pane has always filtered this way; the button has to agree with it,
|
|
3260
|
+
// because two answers to one question show up as a visible contradiction.
|
|
3261
|
+
const joined = snapshot.groups.filter(function (group) {
|
|
3262
|
+
if (group.discovered === true) return false;
|
|
3263
|
+
if (sessionId === "") return true;
|
|
3264
|
+
// An older record with no session stamp belongs to whoever asks: turning a missing
|
|
3265
|
+
// field into a hidden feature is worse than showing it to the wrong conversation
|
|
3266
|
+
// once.
|
|
3267
|
+
return group.sessionId === "" || group.sessionId === sessionId;
|
|
3268
|
+
});
|
|
3269
|
+
// The button ALWAYS renders. Hiding it when nothing is bound made the entry point and the pane
|
|
3270
|
+
// depend on the same condition, so closing the pane left no way back. What changes is the
|
|
3271
|
+
// label: a count when bound, the feature's name when not — "群成员 0" would claim the group is
|
|
3272
|
+
// empty, which is a different and false statement.
|
|
3273
|
+
joined.sort(function (left, right) {
|
|
3274
|
+
return (right.lastTime || 0) - (left.lastTime || 0);
|
|
3275
|
+
});
|
|
3276
|
+
const group = joined[0];
|
|
3277
|
+
|
|
3278
|
+
function openColumn() {
|
|
3279
|
+
try {
|
|
3280
|
+
// Record what happened on the window so a missing service is diagnosable
|
|
3281
|
+
// from the page instead of looking like a dead button. Cheap, and the only
|
|
3282
|
+
// way to see the difference between "no service" and "service refused".
|
|
3283
|
+
window.__dsh_a2a_open = {
|
|
3284
|
+
hasService: services.sidebarRight !== undefined,
|
|
3285
|
+
hasTabs: services.sidebarRightTabs !== undefined,
|
|
3286
|
+
kind: MEMBERS_TAB_KIND
|
|
3287
|
+
};
|
|
3288
|
+
const right = services.sidebarRight;
|
|
3289
|
+
if (right === undefined || typeof right.openTab !== "function") {
|
|
3290
|
+
window.__dsh_a2a_open.error = "sidebarRight.openTab unavailable";
|
|
3291
|
+
reportUnavailable("sidebarRight.openTab", undefined);
|
|
3292
|
+
return;
|
|
3293
|
+
}
|
|
3294
|
+
right.openTab(MEMBERS_TAB_KIND);
|
|
3295
|
+
window.__dsh_a2a_open.opened = true;
|
|
3296
|
+
} catch (error) {
|
|
3297
|
+
window.__dsh_a2a_open.error = String(error && error.message ? error.message : error);
|
|
3298
|
+
reportUnavailable("sidebarRight.openTab", error);
|
|
3299
|
+
}
|
|
3300
|
+
}
|
|
3301
|
+
|
|
3302
|
+
return h(
|
|
3303
|
+
"button",
|
|
3304
|
+
{
|
|
3305
|
+
type: "button",
|
|
3306
|
+
title: group === undefined
|
|
3307
|
+
? "打开协作群面板:加入一个群,或查看当前群的成员"
|
|
3308
|
+
: "展开「" + group.name + "」的群成员,点人即可 @ 对方",
|
|
3309
|
+
style: {
|
|
3310
|
+
display: "inline-flex",
|
|
3311
|
+
alignItems: "center",
|
|
3312
|
+
gap: "4px",
|
|
3313
|
+
padding: "2px 8px",
|
|
3314
|
+
border: "1px solid rgba(128, 128, 128, 0.5)",
|
|
3315
|
+
borderRadius: "999px",
|
|
3316
|
+
background: "transparent",
|
|
3317
|
+
color: "inherit",
|
|
3318
|
+
font: "inherit",
|
|
3319
|
+
fontSize: "12px",
|
|
3320
|
+
cursor: "pointer"
|
|
3321
|
+
},
|
|
3322
|
+
onClick: openColumn
|
|
3323
|
+
},
|
|
3324
|
+
group === undefined ? "协作群" : "群成员",
|
|
3325
|
+
group !== undefined && group.memberCount > 0
|
|
3326
|
+
? h("span", { style: { opacity: 0.7 } }, String(group.memberCount))
|
|
3327
|
+
: null
|
|
3328
|
+
);
|
|
3329
|
+
}
|
|
3330
|
+
|
|
3331
|
+
function GroupsPanel(props) {
|
|
3332
|
+
const snapshot = useSnapshot();
|
|
3333
|
+
const [expanded, toggle] = useExpanded();
|
|
3334
|
+
// One state object for the pair: switching tabs clears the tag filter, which is
|
|
3335
|
+
// what a person expects when they change the question they are asking.
|
|
3336
|
+
const local = useLocalState({ tab: "all", tag: "" });
|
|
3337
|
+
const state = local.value !== null && typeof local.value === "object"
|
|
3338
|
+
? local.value
|
|
3339
|
+
: { tab: "all", tag: "" };
|
|
3340
|
+
const tab = typeof state.tab === "string" ? state.tab : "all";
|
|
3341
|
+
const cards = typeof state.tag === "string" ? state.tag : "";
|
|
3342
|
+
|
|
3343
|
+
const sections = groupByRelationship(snapshot.groups);
|
|
3344
|
+
const counts = {
|
|
3345
|
+
all: snapshot.groups.length,
|
|
3346
|
+
mine: sections.mine.length,
|
|
3347
|
+
joined: sections.joined.length,
|
|
3348
|
+
cards: snapshot.cards.length
|
|
3349
|
+
};
|
|
3350
|
+
|
|
3351
|
+
/** The group rows for the active tab; empty on the cards tab. */
|
|
3352
|
+
const activeGroups =
|
|
3353
|
+
tab === "mine"
|
|
3354
|
+
? sections.mine
|
|
3355
|
+
: tab === "joined"
|
|
3356
|
+
? sections.joined
|
|
3357
|
+
: tab === "cards"
|
|
3358
|
+
? []
|
|
3359
|
+
: snapshot.groups;
|
|
3360
|
+
|
|
3361
|
+
/** Every tag present across the roster, so the filter offers real choices only. */
|
|
3362
|
+
const allTags = [];
|
|
3363
|
+
for (const card of snapshot.cards) {
|
|
3364
|
+
for (const tag of card.tags) if (allTags.indexOf(tag) < 0) allTags.push(tag);
|
|
3365
|
+
}
|
|
3366
|
+
allTags.sort();
|
|
3367
|
+
|
|
3368
|
+
const visibleCards = cards === ""
|
|
3369
|
+
? snapshot.cards
|
|
3370
|
+
: snapshot.cards.filter(function (card) {
|
|
3371
|
+
return card.tags.indexOf(cards) > -1;
|
|
3372
|
+
});
|
|
3373
|
+
|
|
3374
|
+
const groupBody = activeGroups.length === 0
|
|
3375
|
+
? h(
|
|
3376
|
+
"p",
|
|
3377
|
+
{ style: S.emptyBody },
|
|
3378
|
+
tab === "mine"
|
|
3379
|
+
? "还没有创建过协作群。在会话里说「建个群叫 xxx」即可。"
|
|
3380
|
+
: tab === "joined"
|
|
3381
|
+
? "这个会话还没有加入协作群。在「全部」里找一个申请加入,或向同事要邀请链接。"
|
|
3382
|
+
: "还没有任何协作群。"
|
|
3383
|
+
)
|
|
3384
|
+
: h(
|
|
3385
|
+
"div",
|
|
3386
|
+
{ style: { display: "flex", flexDirection: "column", gap: "8px" } },
|
|
3387
|
+
activeGroups.map(function (group) {
|
|
3388
|
+
return h(GroupCard, {
|
|
3389
|
+
key: group.id,
|
|
3390
|
+
group: group,
|
|
3391
|
+
open: expanded[group.id] === true,
|
|
3392
|
+
onToggle: function () {
|
|
3393
|
+
toggle(group.id);
|
|
3394
|
+
}
|
|
3395
|
+
});
|
|
3396
|
+
})
|
|
3397
|
+
);
|
|
3398
|
+
|
|
3399
|
+
const cardsBody = snapshot.cards.length === 0
|
|
3400
|
+
? h(
|
|
3401
|
+
"p",
|
|
3402
|
+
{ style: S.emptyBody },
|
|
3403
|
+
"还没有同事名片。名片由各人在群里声明的角色汇总而成——在会话里让助手查一次群成员即可。"
|
|
3404
|
+
)
|
|
3405
|
+
: h(
|
|
3406
|
+
"div",
|
|
3407
|
+
{ style: { display: "flex", flexDirection: "column", gap: "10px" } },
|
|
3408
|
+
h(TagFilter, {
|
|
3409
|
+
tags: allTags,
|
|
3410
|
+
active: cards,
|
|
3411
|
+
onSelect: function (value) {
|
|
3412
|
+
local.set({ tab: tab, tag: value });
|
|
3413
|
+
}
|
|
3414
|
+
}),
|
|
3415
|
+
visibleCards.length === 0
|
|
3416
|
+
? h("p", { style: S.emptyBody }, "没有标签为「" + cards + "」的同事。")
|
|
3417
|
+
: h(
|
|
3418
|
+
"div",
|
|
3419
|
+
{ style: { display: "flex", flexDirection: "column", gap: "6px" } },
|
|
3420
|
+
visibleCards.map(function (card) {
|
|
3421
|
+
return h(ColleagueCard, {
|
|
3422
|
+
key: card.id,
|
|
3423
|
+
card: card,
|
|
3424
|
+
// The composer API, so the self card can fill the draft. Only the
|
|
3425
|
+
// slot owner can provide it, which is why it is threaded down
|
|
3426
|
+
// rather than reached for.
|
|
3427
|
+
inputActions: props !== null && props !== undefined ? props.inputActions : undefined,
|
|
3428
|
+
// Named in the drafted instruction: the profile tool authenticates
|
|
3429
|
+
// through a group, so the text has to say which one.
|
|
3430
|
+
groupName: (function () {
|
|
3431
|
+
const mine = snapshot.groups.filter(function (group) {
|
|
3432
|
+
return group.discovered !== true;
|
|
3433
|
+
});
|
|
3434
|
+
return mine.length > 0 ? mine[0].name : "";
|
|
3435
|
+
})(),
|
|
3436
|
+
onPickTag: function (tag) {
|
|
3437
|
+
local.set({ tab: "cards", tag: tag });
|
|
3438
|
+
}
|
|
3439
|
+
});
|
|
3440
|
+
})
|
|
3441
|
+
)
|
|
3442
|
+
);
|
|
3443
|
+
|
|
3444
|
+
const tabs = PANEL_TABS.map(function (def) {
|
|
3445
|
+
return { id: def.id, label: def.label, icon: def.icon, count: counts[def.id] };
|
|
3446
|
+
});
|
|
3447
|
+
|
|
3448
|
+
return h(
|
|
3449
|
+
"div",
|
|
3450
|
+
{ style: S.root },
|
|
3451
|
+
h(
|
|
3452
|
+
"div",
|
|
3453
|
+
{ style: S.header },
|
|
3454
|
+
h("h2", { style: S.title }, "协作群"),
|
|
3455
|
+
snapshot.unreadTotal > 0
|
|
3456
|
+
? h("span", { style: S.badge }, String(snapshot.unreadTotal) + " 条未读")
|
|
3457
|
+
: null,
|
|
3458
|
+
h("span", { style: S.hint }, "只读")
|
|
3459
|
+
),
|
|
3460
|
+
h(TabBar, {
|
|
3461
|
+
tabs: tabs,
|
|
3462
|
+
active: tab,
|
|
3463
|
+
onSelect: function (id) {
|
|
3464
|
+
local.set({ tab: id, tag: "" });
|
|
3465
|
+
}
|
|
3466
|
+
}),
|
|
3467
|
+
tab === "cards" ? cardsBody : groupBody
|
|
3468
|
+
);
|
|
3469
|
+
}
|
|
3470
|
+
|
|
3471
|
+
/** Two-person glyph, drawn inline so the row needs no extra dependency. */
|
|
3472
|
+
function GroupGlyph(props) {
|
|
3473
|
+
const given = props === null || props === undefined ? {} : props;
|
|
3474
|
+
const size = typeof given.size === "number" && given.size > 0 ? given.size : 16;
|
|
3475
|
+
const IconUsers = primitives.IconUsersOutlineRegular;
|
|
3476
|
+
if (typeof IconUsers === "function" || (IconUsers !== null && typeof IconUsers === "object")) {
|
|
3477
|
+
return h(IconUsers, { size: size });
|
|
3478
|
+
}
|
|
3479
|
+
return h(
|
|
3480
|
+
"svg",
|
|
3481
|
+
{
|
|
3482
|
+
width: size,
|
|
3483
|
+
height: size,
|
|
3484
|
+
viewBox: "0 0 16 16",
|
|
3485
|
+
fill: "none",
|
|
3486
|
+
stroke: "currentColor",
|
|
3487
|
+
strokeWidth: 1.4,
|
|
3488
|
+
strokeLinecap: "round",
|
|
3489
|
+
strokeLinejoin: "round",
|
|
3490
|
+
"aria-hidden": "true"
|
|
3491
|
+
},
|
|
3492
|
+
h("circle", { cx: 6, cy: 5.2, r: 2.3 }),
|
|
3493
|
+
h("path", { d: "M1.8 13.2c0-2.2 1.9-3.6 4.2-3.6s4.2 1.4 4.2 3.6" }),
|
|
3494
|
+
h("path", { d: "M10.7 3.2a2.3 2.3 0 0 1 0 4.2" }),
|
|
3495
|
+
h("path", { d: "M12.4 9.9c1.2.5 1.9 1.5 1.9 2.9" })
|
|
3496
|
+
);
|
|
3497
|
+
}
|
|
3498
|
+
|
|
3499
|
+
/**
|
|
3500
|
+
* Sidebar row glyph (registered into `sidebar.panellist`). The shell passes
|
|
3501
|
+
* `{ size, active }`; unread total is drawn as a corner badge.
|
|
3502
|
+
*/
|
|
3503
|
+
function GroupsSidebarIcon(props) {
|
|
3504
|
+
const snapshot = useSnapshot();
|
|
3505
|
+
const given = props === null || props === undefined ? {} : props;
|
|
3506
|
+
const size = typeof given.size === "number" && given.size > 0 ? given.size : 16;
|
|
3507
|
+
return h(
|
|
3508
|
+
"span",
|
|
3509
|
+
{ style: S.sidebarGlyph },
|
|
3510
|
+
h(GroupGlyph, { size: size }),
|
|
3511
|
+
snapshot.unreadTotal > 0 ? h("span", { style: S.sidebarBadge }, snapshot.unreadTotal > 99 ? "99+" : String(snapshot.unreadTotal)) : null
|
|
3512
|
+
);
|
|
3513
|
+
}
|
|
3514
|
+
//#endregion
|
|
3515
|
+
|
|
3516
|
+
//#region plugin surface
|
|
3517
|
+
/**
|
|
3518
|
+
* Services this plugin hard-requires. `slots` is the only one: everything
|
|
3519
|
+
* else (locale, layout) is read opportunistically through `ctx.get(...)`
|
|
3520
|
+
* so a version skew silently degrades instead of parking the fiber.
|
|
3521
|
+
*/
|
|
3522
|
+
// `sidebarRight` and `sidebarRightTabs` are OPTIONAL services: declaring them makes
|
|
3523
|
+
// the right-column controller and its tab registry reachable, and Cordis keeps the
|
|
3524
|
+
// plugin running on a Host that does not provide them (which is why every use site
|
|
3525
|
+
// still checks for undefined).
|
|
3526
|
+
//
|
|
3527
|
+
// Without them in this list, `ctx.get("sidebarRight")` returns undefined: the
|
|
3528
|
+
// service exists, but this plugin never declared it, so the proxy guard refuses.
|
|
3529
|
+
const inject = ["slots", "sidebarRight", "sidebarRightTabs"];
|
|
3530
|
+
|
|
3531
|
+
/** Registration failures are reported once, never thrown into the shell. */
|
|
3532
|
+
let reported = false;
|
|
3533
|
+
|
|
3534
|
+
function reportUnavailable(stage, error) {
|
|
3535
|
+
if (reported) return;
|
|
3536
|
+
reported = true;
|
|
3537
|
+
warn("panel not mounted (" + stage + ")", error);
|
|
3538
|
+
}
|
|
3539
|
+
|
|
3540
|
+
/**
|
|
3541
|
+
* Client plugin body.
|
|
3542
|
+
*
|
|
3543
|
+
* Two registrations, both evidenced by shipped plugins:
|
|
3544
|
+
* - `sidebar.panellist` (list slot declared by @deepseek-ai/dsh-client-ui-sidebar;
|
|
3545
|
+
* consumed by schedule as `{ name, id, order, locale, label }` + glyph component);
|
|
3546
|
+
* - `main` (keyed slot declared by @deepseek-ai/dsh-client-ui-layout; the sidebar
|
|
3547
|
+
* row's `id` must equal this `key` or `layout.selectPanel` throws).
|
|
3548
|
+
*
|
|
3549
|
+
* @param ctx - browser fiber context; only `slots` is assumed present.
|
|
3550
|
+
*/
|
|
3551
|
+
function apply(ctx) {
|
|
3552
|
+
// Optional services: absent on a Host without the sidebar-right package, and a
|
|
3553
|
+
// missing one must cost the column, not the whole plugin.
|
|
3554
|
+
try {
|
|
3555
|
+
if (ctx !== null && ctx !== undefined && typeof ctx.get === "function") {
|
|
3556
|
+
services.sidebarRight = ctx.get("sidebarRight");
|
|
3557
|
+
services.sidebarRightTabs = ctx.get("sidebarRightTabs");
|
|
3558
|
+
}
|
|
3559
|
+
} catch (error) {
|
|
3560
|
+
reportUnavailable("sidebarRight lookup", error);
|
|
3561
|
+
}
|
|
3562
|
+
|
|
3563
|
+
// The Host-facing caller for panel writes. Installed here because `ctx.connection` exists at apply time,
|
|
3564
|
+
// not at render time: the pane receives the call function as a prop rather than looking a service up.
|
|
3565
|
+
installPanelRpc(ctx);
|
|
3566
|
+
|
|
3567
|
+
const slots = ctx === null || ctx === undefined ? undefined : ctx.slots;
|
|
3568
|
+
if (slots === undefined || slots === null || typeof slots.inject !== "function" || typeof slots.register !== "function") {
|
|
3569
|
+
reportUnavailable("slots service", undefined);
|
|
3570
|
+
return;
|
|
3571
|
+
}
|
|
3572
|
+
|
|
3573
|
+
const panelPage = () => {
|
|
3574
|
+
try {
|
|
3575
|
+
return slots.register(
|
|
3576
|
+
{
|
|
3577
|
+
name: "main",
|
|
3578
|
+
key: PANEL_ID,
|
|
3579
|
+
label: "协作群"
|
|
3580
|
+
},
|
|
3581
|
+
GroupsPanel
|
|
3582
|
+
);
|
|
3583
|
+
} catch (error) {
|
|
3584
|
+
reportUnavailable("main slot register", error);
|
|
3585
|
+
return undefined;
|
|
3586
|
+
}
|
|
3587
|
+
};
|
|
3588
|
+
|
|
3589
|
+
/**
|
|
3590
|
+
* Register the members panel into the right column.
|
|
3591
|
+
*
|
|
3592
|
+
* `sidebar.right.pane.tab` is a keyed, session-scoped slot: the panel receives the
|
|
3593
|
+
* current `sessionId` and the composer's `inputActions`, which is exactly what it
|
|
3594
|
+
* needs and nothing more. A failure here must not stop the main panel from
|
|
3595
|
+
* mounting, so it is reported and swallowed.
|
|
3596
|
+
*/
|
|
3597
|
+
const membersTab = () => {
|
|
3598
|
+
try {
|
|
3599
|
+
return slots.register(
|
|
3600
|
+
{
|
|
3601
|
+
name: "sidebar.right.pane.tab",
|
|
3602
|
+
key: MEMBERS_TAB_KEY
|
|
3603
|
+
},
|
|
3604
|
+
MembersPane
|
|
3605
|
+
);
|
|
3606
|
+
} catch (error) {
|
|
3607
|
+
reportUnavailable("sidebar.right.pane.tab register", error);
|
|
3608
|
+
return undefined;
|
|
3609
|
+
}
|
|
3610
|
+
};
|
|
3611
|
+
|
|
3612
|
+
/**
|
|
3613
|
+
* The tab's title chip.
|
|
3614
|
+
*
|
|
3615
|
+
* Registered separately because the owner dispatches body and title through
|
|
3616
|
+
* different slots: a tab with a body but no title renders as blank chrome.
|
|
3617
|
+
*/
|
|
3618
|
+
const membersTabTitle = () => {
|
|
3619
|
+
try {
|
|
3620
|
+
return slots.register(
|
|
3621
|
+
{
|
|
3622
|
+
name: "sidebar.right.pane.tab.title",
|
|
3623
|
+
key: MEMBERS_TAB_KEY
|
|
3624
|
+
},
|
|
3625
|
+
function MembersTabTitle() {
|
|
3626
|
+
return h("span", { style: { fontSize: "12px" } }, "群成员");
|
|
3627
|
+
}
|
|
3628
|
+
);
|
|
3629
|
+
} catch (error) {
|
|
3630
|
+
reportUnavailable("sidebar.right.pane.tab.title register", error);
|
|
3631
|
+
return undefined;
|
|
3632
|
+
}
|
|
3633
|
+
};
|
|
3634
|
+
|
|
3635
|
+
|
|
3636
|
+
/**
|
|
3637
|
+
* Declare the members page type.
|
|
3638
|
+
*
|
|
3639
|
+
* `priority: "extension"` because this type comes from outside the product: it
|
|
3640
|
+
* outranks shipped viewers and may take over a builtin kind, which is the right
|
|
3641
|
+
* posture for a page only this plugin can draw. `patterns` is omitted because a
|
|
3642
|
+
* page type is opened by kind and recognizes no address.
|
|
3643
|
+
*/
|
|
3644
|
+
const membersTabType = () => {
|
|
3645
|
+
try {
|
|
3646
|
+
const tabs = services.sidebarRightTabs;
|
|
3647
|
+
if (tabs === undefined || tabs === null || typeof tabs.register !== "function") return undefined;
|
|
3648
|
+
return tabs.register({
|
|
3649
|
+
id: MEMBERS_TAB_KIND,
|
|
3650
|
+
kind: MEMBERS_TAB_KIND,
|
|
3651
|
+
priority: "extension",
|
|
3652
|
+
title: function () {
|
|
3653
|
+
return "群成员";
|
|
3654
|
+
}
|
|
3655
|
+
});
|
|
3656
|
+
} catch (error) {
|
|
3657
|
+
reportUnavailable("sidebarRightTabs.register", error);
|
|
3658
|
+
return undefined;
|
|
3659
|
+
}
|
|
3660
|
+
};
|
|
3661
|
+
|
|
3662
|
+
/** Register the members page body under the tab definition id. */
|
|
3663
|
+
const membersTabBody = () => {
|
|
3664
|
+
try {
|
|
3665
|
+
return slots.register(
|
|
3666
|
+
{ name: "sidebar.right.pane.tab", key: MEMBERS_TAB_KIND },
|
|
3667
|
+
MembersPane
|
|
3668
|
+
);
|
|
3669
|
+
} catch (error) {
|
|
3670
|
+
reportUnavailable("sidebar.right.pane.tab register", error);
|
|
3671
|
+
return undefined;
|
|
3672
|
+
}
|
|
3673
|
+
};
|
|
3674
|
+
|
|
3675
|
+
/** Register the tab chip text; a body without one renders as blank chrome. */
|
|
3676
|
+
const membersTabChip = () => {
|
|
3677
|
+
try {
|
|
3678
|
+
return slots.register(
|
|
3679
|
+
{ name: "sidebar.right.pane.tab.title", key: MEMBERS_TAB_KIND },
|
|
3680
|
+
function MembersTabChip() {
|
|
3681
|
+
return h("span", { style: { fontSize: "12px" } }, "群成员");
|
|
3682
|
+
}
|
|
3683
|
+
);
|
|
3684
|
+
} catch (error) {
|
|
3685
|
+
reportUnavailable("sidebar.right.pane.tab.title register", error);
|
|
3686
|
+
return undefined;
|
|
3687
|
+
}
|
|
3688
|
+
};
|
|
3689
|
+
|
|
3690
|
+
/** The opener, in the composer tool row. */
|
|
3691
|
+
const membersButtonSlot = () => {
|
|
3692
|
+
try {
|
|
3693
|
+
return slots.register(
|
|
3694
|
+
{ name: "conversation.input.left", id: MEMBERS_TAB_KIND, order: 40 },
|
|
3695
|
+
MembersButton
|
|
3696
|
+
);
|
|
3697
|
+
} catch (error) {
|
|
3698
|
+
reportUnavailable("conversation.input.left register", error);
|
|
3699
|
+
return undefined;
|
|
3700
|
+
}
|
|
3701
|
+
};
|
|
3702
|
+
|
|
3703
|
+
const sidebarRow = () => {
|
|
3704
|
+
try {
|
|
3705
|
+
return slots.register(
|
|
3706
|
+
{
|
|
3707
|
+
name: "sidebar.panellist",
|
|
3708
|
+
id: PANEL_ID,
|
|
3709
|
+
order: PANEL_ORDER,
|
|
3710
|
+
label: "协作群"
|
|
3711
|
+
},
|
|
3712
|
+
GroupsSidebarIcon
|
|
3713
|
+
);
|
|
3714
|
+
} catch (error) {
|
|
3715
|
+
reportUnavailable("sidebar slot register", error);
|
|
3716
|
+
return undefined;
|
|
3717
|
+
}
|
|
3718
|
+
};
|
|
3719
|
+
|
|
3720
|
+
try {
|
|
3721
|
+
slots.inject("sidebar.panellist", sidebarRow);
|
|
3722
|
+
} catch (error) {
|
|
3723
|
+
reportUnavailable("sidebar.panellist inject", error);
|
|
3724
|
+
}
|
|
3725
|
+
try {
|
|
3726
|
+
slots.inject("main", panelPage);
|
|
3727
|
+
} catch (error) {
|
|
3728
|
+
reportUnavailable("main inject", error);
|
|
3729
|
+
}
|
|
3730
|
+
// Members render in the right column only; see MembersPane.
|
|
3731
|
+
|
|
3732
|
+
// The right-hand members column. All four pieces are needed for it to appear:
|
|
3733
|
+
// the type so openTab has a kind, the body and chip so the seat can draw it, and
|
|
3734
|
+
// the button so a person can ask for it.
|
|
3735
|
+
try {
|
|
3736
|
+
slots.inject("sidebar.right.pane.tab", membersTabBody);
|
|
3737
|
+
} catch (error) {
|
|
3738
|
+
reportUnavailable("sidebar.right.pane.tab inject", error);
|
|
3739
|
+
}
|
|
3740
|
+
try {
|
|
3741
|
+
slots.inject("sidebar.right.pane.tab.title", membersTabChip);
|
|
3742
|
+
} catch (error) {
|
|
3743
|
+
reportUnavailable("sidebar.right.pane.tab.title inject", error);
|
|
3744
|
+
}
|
|
3745
|
+
try {
|
|
3746
|
+
slots.inject("conversation.input.left", membersButtonSlot);
|
|
3747
|
+
} catch (error) {
|
|
3748
|
+
reportUnavailable("conversation.input.left inject", error);
|
|
3749
|
+
}
|
|
3750
|
+
// The type registration is not a slot and needs no inject: it is live as soon as
|
|
3751
|
+
// it is registered, and it must exist before the first click.
|
|
3752
|
+
membersTabType();
|
|
3753
|
+
}
|
|
3754
|
+
//#endregion
|
|
3755
|
+
|
|
3756
|
+
exports.apply = apply;
|
|
3757
|
+
exports.inject = inject;
|
|
3758
|
+
exports.PANEL_ID = PANEL_ID;
|
|
3759
|
+
exports.GLOBAL_KEY = GLOBAL_KEY;
|
|
3760
|
+
exports.CHANGE_EVENT = CHANGE_EVENT;
|
|
3761
|
+
exports.setDataSource = setDataSource;
|
|
3762
|
+
exports.readSnapshot = readSnapshot;
|
|
3763
|
+
exports.currentSnapshot = currentSnapshot;
|
|
3764
|
+
exports.subscribeGroups = subscribeGroups;
|
|
3765
|
+
exports.formatTime = formatTime;
|
|
3766
|
+
exports.GroupsPanel = GroupsPanel;
|
|
3767
|
+
exports.GroupsSidebarIcon = GroupsSidebarIcon;
|
|
3768
|
+
exports.GroupCard = GroupCard;
|
|
3769
|
+
exports.EmptyState = EmptyState;
|
|
3770
|
+
exports.MembersPane = MembersPane;
|
|
3771
|
+
exports.MembersButton = MembersButton;
|
|
3772
|
+
exports.pickSelf = pickSelf;
|
|
3773
|
+
exports.splitRoster = splitRoster;
|
|
3774
|
+
exports.draftGroupRoleEdit = draftGroupRoleEdit;
|
|
3775
|
+
exports.draftGroupInvite = draftGroupInvite;
|
|
3776
|
+
exports.draftGroupRoleClear = draftGroupRoleClear;
|
|
3777
|
+
exports.MEMBERS_TAB_KIND = MEMBERS_TAB_KIND;
|
|
3778
|
+
exports.MEMBERS_TAB_KEY = MEMBERS_TAB_KEY;
|
|
3779
|
+
exports.groupByRelationship = groupByRelationship;
|
|
3780
|
+
return module.exports;
|
|
3781
|
+
}
|
|
3782
|
+
});
|