wezard 2.1.2 → 2.1.4
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/dist/daemon/detail.js +12 -0
- package/dist/daemon/detail.js.map +1 -1
- package/dist/daemon/inbound.js +11 -3
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/mirror-bridge.js +55 -4
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/ws.js +3 -0
- package/dist/daemon/ws.js.map +1 -1
- package/dist/shared/role-view.js +21 -3
- package/dist/shared/role-view.js.map +1 -1
- package/dist/shared/session-label.js +1 -1
- package/dist/shared/session-label.js.map +1 -1
- package/package.json +1 -1
- package/web/chat.css +29 -2
- package/web/chat.js +208 -77
package/web/chat.js
CHANGED
|
@@ -142,7 +142,11 @@
|
|
|
142
142
|
};
|
|
143
143
|
var bindGo = function (root, sel) {
|
|
144
144
|
root.querySelectorAll(sel || '.go[data-r]').forEach(function (b) {
|
|
145
|
-
b.onclick = function (e) {
|
|
145
|
+
b.onclick = function (e) {
|
|
146
|
+
e.stopPropagation();
|
|
147
|
+
var row = b.closest('.mrow');
|
|
148
|
+
switchRole(b.getAttribute('data-r'), row && row.getAttribute('data-id'));
|
|
149
|
+
};
|
|
146
150
|
});
|
|
147
151
|
};
|
|
148
152
|
|
|
@@ -275,6 +279,16 @@
|
|
|
275
279
|
|
|
276
280
|
// ── 左栏: 当前 role 的名片 + 会话列表 ──
|
|
277
281
|
var convOf = function (key) { return R.convs.filter(function (c) { return c.key === key; })[0]; };
|
|
282
|
+
// 人的单聊 (user:…) 在数据上是频道, 但对端只有那一个人时同样是一对一。
|
|
283
|
+
var dmOf = function (c) {
|
|
284
|
+
return c && c.kind === 'group' && c.base.indexOf('user:') === 0 && (c.subs || []).length === 1 ? c.subs[0] : null;
|
|
285
|
+
};
|
|
286
|
+
// 一对一会话的对端 (wizard 私聊 / 人的单聊 / 「只看我与 X」); 不是一对一则空。
|
|
287
|
+
var pairPeer = function (c) {
|
|
288
|
+
if (!c) return WITH || '';
|
|
289
|
+
var dm = dmOf(c);
|
|
290
|
+
return c.kind !== 'group' ? c.peer : WITH || (dm ? dm.role : '');
|
|
291
|
+
};
|
|
278
292
|
|
|
279
293
|
// 群聊头像 = 参与者头像的拼图 (最多 4 格, 自己排第一)。私聊就是对端自己的头像。
|
|
280
294
|
var membersOf = function (c) {
|
|
@@ -307,10 +321,48 @@
|
|
|
307
321
|
ms.map(function (m) { return '<i>' + esc(m.label || roleLabel(m.role)) + '</i>'; }).join('') + '</span>';
|
|
308
322
|
};
|
|
309
323
|
|
|
310
|
-
|
|
324
|
+
// ends = [[id, 已知头像]]; 没带头像的按 role 查。
|
|
325
|
+
var pairOf = function (ends) {
|
|
326
|
+
return '<span class="pair">' + ends.map(function (e) { return goSpan('av', e[0], esc(e[1] || roleLabel(e[0]))); }).join('') + '</span>';
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
var line = function (title, ts, pv, lamp, unread) {
|
|
311
330
|
return '<span class="b"><span class="l1"><span class="t">' + title + '</span>' + (lamp || '') +
|
|
312
331
|
'<span class="ts">' + esc(fmtAgo(ts)) + '</span></span>' +
|
|
313
|
-
'<span class="pv">' + esc(pv) + '</span
|
|
332
|
+
'<span class="l2"><span class="pv">' + esc(pv) + '</span>' +
|
|
333
|
+
(unread ? '<b class="ub">' + (unread > 99 ? '99+' : unread) + '</b>' : '') + '</span></span>';
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
// ── 未读: 别人说完、我还没读到的话 ──
|
|
337
|
+
// 服务端给每个会话一份 heard = [说完的时刻, 属于我与谁的那一对], 以及我在群里 / 每一对里
|
|
338
|
+
// 最后开口的时刻 (mine)。一句话的已读水位 = 它所在那一处「我最后开口」与「我看过」的较晚者:
|
|
339
|
+
// 属于某一对的看那一对 (群里说过话不等于读过别的线程), 不属于任何一对的看群。
|
|
340
|
+
// 「看过」记在本地 (刷新 / 换视角都还在), 按视角分账 —— 同一个群换个 role 看, 未读是另一回事。
|
|
341
|
+
var READ_KEY = 'wezard.role.read';
|
|
342
|
+
var READ = (function () {
|
|
343
|
+
try { var v = JSON.parse(localStorage.getItem(READ_KEY) || '{}'); if (v && v.at) return v; } catch (e) { }
|
|
344
|
+
return { at: {} };
|
|
345
|
+
})();
|
|
346
|
+
var saveRead = function () { try { localStorage.setItem(READ_KEY, JSON.stringify(READ)); } catch (e) { } };
|
|
347
|
+
var readKey = function (key, withRole) { return ROLE + '|' + key + '|' + (withRole || ''); };
|
|
348
|
+
var seenAt = function (key, withRole) { return READ.at[readKey(key, withRole)] || 0; };
|
|
349
|
+
var unreadOf = function (c, withRole) {
|
|
350
|
+
var g = seenAt(c.key);
|
|
351
|
+
var mark = {};
|
|
352
|
+
(c.subs || []).forEach(function (s) { mark[s.role] = Math.max(g, s.mine || 0, seenAt(c.key, s.role)); });
|
|
353
|
+
var groupMark = Math.max(g, c.mine || 0);
|
|
354
|
+
return (c.heard || []).filter(function (h) {
|
|
355
|
+
if (withRole && h[1] !== withRole) return false;
|
|
356
|
+
return h[0] > (h[1] ? (mark[h[1]] !== undefined ? mark[h[1]] : groupMark) : groupMark);
|
|
357
|
+
}).length;
|
|
358
|
+
};
|
|
359
|
+
var reading = function () { return VIEW === 'msgs' && !document.hidden; };
|
|
360
|
+
// 读整个群 = 群的水位推到此刻 (子项随之全清); 只读一对 = 只推那一对的。
|
|
361
|
+
var markRead = function (c) {
|
|
362
|
+
if (!reading() || c.key !== CONV || !unreadOf(c, WITH)) return;
|
|
363
|
+
var h = c.heard[c.heard.length - 1];
|
|
364
|
+
READ.at[readKey(c.key, WITH)] = Math.max(R.at, h ? h[0] : 0);
|
|
365
|
+
saveRead();
|
|
314
366
|
};
|
|
315
367
|
|
|
316
368
|
// 展开态按会话各记各的: 点开一个不收起别的, 轮询重画也不动它。
|
|
@@ -347,7 +399,7 @@
|
|
|
347
399
|
return '<button class="si' + (sel ? ' on' : '') + '" data-conv="' + esc(c.key) + '" data-with="' + esc(s.role) + '" ' +
|
|
348
400
|
'title="' + esc('我与 ' + nameOf(s.role) + ' 在这里的 ' + s.count + ' 条往来') + '">' +
|
|
349
401
|
goSpan('av', s.role, esc(s.label)) +
|
|
350
|
-
line(nm(s.role, s.name, true), s.lastTs, s.preview, lampOf(s.status)) + '</button>';
|
|
402
|
+
line(nm(s.role, s.name, true), s.lastTs, s.preview, lampOf(s.status), unreadOf(c, s.role)) + '</button>';
|
|
351
403
|
};
|
|
352
404
|
var rest = MORE[c.key] ? talked.slice(SUB_FOLD).map(sub).join('') : '';
|
|
353
405
|
var subs = talked.length
|
|
@@ -355,13 +407,14 @@
|
|
|
355
407
|
: '';
|
|
356
408
|
var sel = on && !WITH;
|
|
357
409
|
return '<button class="ci' + (sel ? ' on' : '') + '" data-conv="' + esc(c.key) + '">' +
|
|
358
|
-
avatarOf(c) + line(title, c.lastTs, c.preview, lampOf(c.status)) +
|
|
410
|
+
avatarOf(c) + line(title, c.lastTs, c.preview, lampOf(c.status), unreadOf(c)) +
|
|
359
411
|
'</button>' + subs;
|
|
360
412
|
};
|
|
361
413
|
|
|
362
414
|
var renderConvs = function () {
|
|
363
415
|
var groups = R.convs.filter(function (c) { return c.kind === 'group'; });
|
|
364
416
|
var dms = R.convs.filter(function (c) { return c.kind !== 'group'; });
|
|
417
|
+
R.convs.forEach(markRead);
|
|
365
418
|
// 空着的那一栏不画 —— 对谁都一样: 人没有私聊只是这条规则的一个特例。
|
|
366
419
|
var sec = function (title, list) {
|
|
367
420
|
return list.length ? '<h2>' + title + '<span>' + list.length + '</span></h2>' + list.map(convItem).join('') : '';
|
|
@@ -553,7 +606,12 @@
|
|
|
553
606
|
}
|
|
554
607
|
var c = convOf(CONV);
|
|
555
608
|
if (!c) { who.innerHTML = ''; acts.innerHTML = ''; return; }
|
|
556
|
-
|
|
609
|
+
// 一对一 (私聊, 或群里「只看我与 X」) 两端都亮头像: 我在前, 对端在后, 各自是切视角的入口。
|
|
610
|
+
var dm = dmOf(c);
|
|
611
|
+
var peer = pairPeer(c);
|
|
612
|
+
var peerLabel = c.kind !== 'group' ? c.label : dm && peer === dm.role ? dm.label : '';
|
|
613
|
+
who.innerHTML = (peer ? pairOf([[ROLE, R.role && R.role.label], [peer, peerLabel]]) : '') +
|
|
614
|
+
'<span class="t">' + (c.kind === 'wizard' ? nm(c.peer, c.name, true) : esc(c.name)) + '</span>';
|
|
557
615
|
acts.innerHTML = WITH
|
|
558
616
|
? '<span class="with">只看我与 ' + nm(WITH, '', true) + '</span><button class="vb" id="ch-all">看全部</button>'
|
|
559
617
|
: '';
|
|
@@ -567,6 +625,11 @@
|
|
|
567
625
|
// 箭头朝外 = 这条气泡换视角后要去的那一侧; mine 行靠 CSS 翻转。
|
|
568
626
|
var CHEVRON = '<svg class="fc" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M6 3.5 10.5 8 6 12.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
569
627
|
// 行的另一侧是 .flip: 点它 = 换成这条消息的对端 (我发的 → 收信方; 别人发的 → 发话方)。
|
|
628
|
+
// 消息头上的角色头像 (发话方 / 收信方同一种画法), 点它切到那个角色的视角。
|
|
629
|
+
var avBtn = function (id, label) {
|
|
630
|
+
var sw = canSwitch(id) && id !== ROLE;
|
|
631
|
+
return '<button class="av' + (sw ? ' go' : '') + '" data-r="' + esc(id) + '" title="' + esc(nameOf(id)) + '"' + (sw ? '' : ' disabled') + '>' + esc(label || roleLabel(id)) + '</button>';
|
|
632
|
+
};
|
|
570
633
|
var rowHTML = function (m) {
|
|
571
634
|
learn(m.from, m.fromName, m.fromLabel);
|
|
572
635
|
learn(m.to, m.toName, m.toLabel);
|
|
@@ -578,21 +641,21 @@
|
|
|
578
641
|
// 看 key 而不是会话列表: 换视角就地重包时, 列表还是上一个 role 的。
|
|
579
642
|
var group = CONV.indexOf('p:') !== 0;
|
|
580
643
|
// 群里我不是收信方的那条 (X → Y), 头上写清是说给谁的。
|
|
581
|
-
var to = !mine && m.to !== ROLE && group ? '<span class="to">→ ' + nm(m.to, m.toName, true) + '</span>' : '';
|
|
644
|
+
var to = !mine && m.to !== ROLE && group ? '<span class="to">→ ' + avBtn(m.to, m.toLabel) + nm(m.to, m.toName, true) + '</span>' : '';
|
|
582
645
|
var priv = !m.channel && group ? '<span class="ch priv">私聊</span>' : '';
|
|
583
646
|
// 本轮的账 (呼吸点 + 模型 / token / 耗时) 跟在时刻后面 —— 片段是服务端渲染好的。
|
|
584
647
|
var stat = m.meta ? '<span class="mstat">' + m.meta + '</span>' : '';
|
|
648
|
+
// 一对一里收信方就是顶栏那个对端, 我的消息头不再重复写。
|
|
585
649
|
var who = mine
|
|
586
|
-
? '<span class="to">' + (m.to && m.to !== 'human:' ? '→ ' + nm(m.to, m.toName, true) : '') + '</span>' + stamp(m.ts) + stat
|
|
587
|
-
: nm(m.from, m.fromName, true) + to + priv + stamp(m.ts) + stat;
|
|
650
|
+
? '<span class="to">' + (m.to && m.to !== 'human:' && m.to !== (group ? pairPeer(convOf(CONV)) : CONV.slice(2)) ? '→ ' + avBtn(m.to, m.toLabel) + nm(m.to, m.toName, true) : '') + '</span>' + stamp(m.ts) + stat + avBtn(m.from, m.fromLabel)
|
|
651
|
+
: avBtn(m.from, m.fromLabel) + nm(m.from, m.fromName, true) + to + priv + stamp(m.ts) + stat;
|
|
588
652
|
var sw = canSwitch(other);
|
|
589
653
|
var flip = '<button class="flip" data-r="' + esc(other) + '"' + (sw ? '' : ' disabled tabindex="-1"') +
|
|
590
654
|
' aria-label="' + esc(sw ? '切到 ' + nameOf(other) + ' 的视角' : '') + '">' +
|
|
591
655
|
(sw ? '<span class="fi"><span class="fn">' + esc(nameOf(other)) + '</span>' + CHEVRON + '</span>' : '') + '</button>';
|
|
592
|
-
var av = mine ? '' : '<button class="av' + (canSwitch(m.from) ? ' go' : '') + '" data-r="' + esc(m.from) + '" title="' + esc(nameOf(m.from)) + '"' + (canSwitch(m.from) ? '' : ' disabled') + '>' + esc(m.fromLabel || roleLabel(m.from)) + '</button>';
|
|
593
656
|
return '<div class="mrow ' + (mine ? 'mine' : 'them') + '" data-id="' + esc(m.id) + '" data-turn="' + esc(m.turnId || m.id) + '" data-ts="' + m.ts + '"' +
|
|
594
657
|
(m.ping ? ' data-ping="1" data-ping-who="' + esc(m.dir === 'in' ? m.toName : m.fromName) + '"' : '') + ' data-sig="' + esc(m.sig) + '" data-stale-at="' + (m.staleAt || 0) + '">' +
|
|
595
|
-
'<div class="mcol"><div class="mwho">' +
|
|
658
|
+
'<div class="mcol"><div class="mwho">' + who + '</div><div class="mb">' + m.html + '</div></div>' +
|
|
596
659
|
flip +
|
|
597
660
|
'</div>';
|
|
598
661
|
};
|
|
@@ -728,7 +791,9 @@
|
|
|
728
791
|
};
|
|
729
792
|
|
|
730
793
|
// ── role 摘要 ──
|
|
731
|
-
var applyRole = function (d) {
|
|
794
|
+
var applyRole = function (d) { takeRole(d); paintRole(); };
|
|
795
|
+
// 收下摘要 (视角、会话列表、落地窗口) 与按它画页头侧栏分开: 换视角整窗重拉时, 画要等到新行到手的同一帧。
|
|
796
|
+
var takeRole = function (d) {
|
|
732
797
|
R.at = d.at || Date.now(); R.recvAt = Date.now();
|
|
733
798
|
R.role = d.role; R.sessions = d.sessions || []; R.convs = d.convs || [];
|
|
734
799
|
R.relations = !!d.relations; R.schedules = d.schedules || 0;
|
|
@@ -742,10 +807,15 @@
|
|
|
742
807
|
// 没带 conv 的链接 (群里点名字进来) 落在服务端挑的窗口上, 连同它挑的「只看我与谁」;
|
|
743
808
|
// 手里的 conv 失效则只退回整个会话。
|
|
744
809
|
if (!CONV || !convOf(CONV)) { WITH = CONV ? '' : d['with'] || ''; CONV = d.conv || ''; }
|
|
810
|
+
};
|
|
811
|
+
var paintRole = function () {
|
|
745
812
|
reveal();
|
|
746
813
|
renderRole(); renderUsage(); renderConvs(); renderHead();
|
|
747
814
|
};
|
|
748
815
|
|
|
816
|
+
// 切回标签页时正在读的那项就算读过了。
|
|
817
|
+
document.addEventListener('visibilitychange', function () { if (!document.hidden) renderConvs(); });
|
|
818
|
+
|
|
749
819
|
// ── SSE: role 摘要 + 当前窗口的消息增量。断线指数退避重连。 ──
|
|
750
820
|
var backoff = 1000;
|
|
751
821
|
var connect = function () {
|
|
@@ -774,10 +844,14 @@
|
|
|
774
844
|
document.body.innerHTML = '<div class="empty" style="padding:80px">' + esc(d.error || 'not found') + '</div>';
|
|
775
845
|
return;
|
|
776
846
|
}
|
|
777
|
-
|
|
847
|
+
// 带 land 的 (换视角) 先不画: 页头换了高度, 旧行会在新行到来前先被顶一下, FLIP 的起点就错了。
|
|
848
|
+
if (land) takeRole(d); else applyRole(d);
|
|
778
849
|
syncUrl();
|
|
779
850
|
if (VIEW === 'world' || VIEW === 'plan') { W.sel = ROLE; loadWorld(); }
|
|
780
|
-
return loadMsgs(undefined, land).then(
|
|
851
|
+
return loadMsgs(undefined, land && function () { paintRole(); return land(); }).then(function () {
|
|
852
|
+
if (land && !inner.querySelector('.mrow')) paintRole(); // 空窗口不走 land, 页头照样要画
|
|
853
|
+
connect();
|
|
854
|
+
});
|
|
781
855
|
});
|
|
782
856
|
};
|
|
783
857
|
|
|
@@ -797,7 +871,8 @@
|
|
|
797
871
|
|
|
798
872
|
// 换视角: 窗口尽量留在同一个群 —— 从群里的一条消息切过去, 最想看的是对方在这个群
|
|
799
873
|
// 里的样子; 对方不在这个群 (私聊的另一头) 就交给服务端挑它最近的会话。
|
|
800
|
-
|
|
874
|
+
// at: 被点的那条消息 —— 换视角后它留在指针底下, 其余的相对它滑动。
|
|
875
|
+
var switchRole = function (id, at) {
|
|
801
876
|
if (!canSwitch(id) || id === ROLE) return;
|
|
802
877
|
var keep = CONV && CONV.indexOf('c:') === 0 ? CONV : '';
|
|
803
878
|
var from = ROLE;
|
|
@@ -807,49 +882,101 @@
|
|
|
807
882
|
var withBack = keep && WITH ? from : '';
|
|
808
883
|
ROLE = id; WITH = withBack; SESSION = '';
|
|
809
884
|
CONV = keep || (CONV.indexOf('p:') === 0 ? 'p:' + from : '');
|
|
810
|
-
|
|
811
|
-
|
|
885
|
+
var snap = VIEW === 'msgs' && !calm() ? snapRows(at) : null;
|
|
886
|
+
if (same) { flipWindow(snap); return; }
|
|
812
887
|
var kept = VIEW === 'msgs' ? keepBodies() : {};
|
|
813
|
-
refresh(function () {
|
|
888
|
+
refresh(function () {
|
|
889
|
+
adoptBodies(kept);
|
|
890
|
+
if (landFlip(snap)) return true;
|
|
891
|
+
// 一条旧消息都不在了 (换到了别的会话): 没有可配对的, 才整列淡入。
|
|
892
|
+
inner.classList.remove('flipping'); void inner.offsetWidth; inner.classList.add('flipping');
|
|
893
|
+
return false;
|
|
894
|
+
});
|
|
814
895
|
};
|
|
815
896
|
|
|
816
|
-
// ── 换视角的 FLIP: 按 msgid
|
|
817
|
-
//
|
|
818
|
-
//
|
|
897
|
+
// ── 换视角的 FLIP: 按 msgid 记下每条消息各块的位置与底色, 重排后从旧处滑到新处、旧色渐到新色 ──
|
|
898
|
+
// 动的是行里的每一块 (名字行、问句气泡、过程框、终句), 不是整列 .mcol: 换边时块在列里的
|
|
899
|
+
// 相对位置也会变 —— 名字行有没有头像差 6px, 带过程框的列占满整宽、终句在列内从左跳到右,
|
|
900
|
+
// 只挪整列的话这些块都是直接闪过去的。
|
|
901
|
+
// 全量记, 不只记视窗里的: 行高随视角变, 屏外的行会被推进视窗。只在前后有一头落在视窗里时才真动。
|
|
902
|
+
// 回复的外壳 .turn-group 是 overflow:hidden 的 (工具行 nowrap, 不裁会撑出横向滚动)。它一换边就
|
|
903
|
+
// 立刻在新位置, 里面的过程框 / 终句要是按屏上绝对位移平移, 起跑时整个在壳外, 被裁得一干二净 ——
|
|
904
|
+
// 看上去就是对侧空白盖住了气泡。所以壳自己算一块, 壳里的块只补「相对壳」的那点差: 前后都在壳内,
|
|
905
|
+
// 途中也就不出壳。
|
|
906
|
+
var PIECES = '.mwho, .mb > *, .mb > .turn-group > .bubbles > *';
|
|
819
907
|
var BUB = '.bubble.mq, .say > .bubble';
|
|
820
908
|
var calm = function () { return window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches; };
|
|
821
|
-
var
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
return b.height && b.bottom > vp.top && b.top < vp.bottom;
|
|
826
|
-
});
|
|
909
|
+
var rowsById = function () {
|
|
910
|
+
return [].slice.call(inner.querySelectorAll('.mrow:not(.mark)')).reduce(function (acc, r) {
|
|
911
|
+
acc[r.getAttribute('data-id')] = r; return acc;
|
|
912
|
+
}, {});
|
|
827
913
|
};
|
|
828
|
-
var
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
};
|
|
914
|
+
var piecesOf = function (row) { return [].slice.call(row.querySelectorAll(PIECES)); };
|
|
915
|
+
// 先把所有矩形读完再读底色 —— 读与读之间没有写, 整次快照只排一次版。
|
|
916
|
+
var snapRows = function (at) {
|
|
917
|
+
var rows = rowsById();
|
|
918
|
+
var shot = Object.keys(rows).reduce(function (acc, id) {
|
|
919
|
+
var rects = piecesOf(rows[id]).map(function (el) { return el.getBoundingClientRect(); });
|
|
920
|
+
if (rects.some(function (b) { return b.height; })) acc[id] = { rects: rects }; // 关着的 ping 组里 = 看不见
|
|
835
921
|
return acc;
|
|
836
922
|
}, {});
|
|
923
|
+
Object.keys(shot).forEach(function (id) {
|
|
924
|
+
shot[id].bubs = [].slice.call(rows[id].querySelectorAll(BUB)).map(function (b) { return getComputedStyle(b).backgroundColor; });
|
|
925
|
+
});
|
|
926
|
+
return { at: at && shot[at] ? at : '', rows: shot };
|
|
927
|
+
};
|
|
928
|
+
// 滚动锚: 优先被点的那条, 否则第一条前后都看得见的; 把它的首块挪回旧的屏上位置。
|
|
929
|
+
var anchorScroll = function (snap, rows) {
|
|
930
|
+
var head = function (id) { var el = rows[id] && rows[id].querySelector(PIECES); return el && el.getBoundingClientRect(); };
|
|
931
|
+
var shown = function (id) { var b = head(id); return b && b.height; };
|
|
932
|
+
var id = snap.at && shown(snap.at) ? snap.at : Object.keys(snap.rows).filter(shown)[0];
|
|
933
|
+
if (!id) return false;
|
|
934
|
+
thread.scrollTop += head(id).top - snap.rows[id].rects[0].top;
|
|
935
|
+
S.pinned = atBottom();
|
|
936
|
+
return true;
|
|
837
937
|
};
|
|
838
|
-
var playFlip = function (snap) {
|
|
938
|
+
var playFlip = function (snap, rows) {
|
|
839
939
|
var ease = { duration: 340, easing: 'cubic-bezier(.2,.7,.2,1)' };
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
var
|
|
846
|
-
if (
|
|
940
|
+
var vp = thread.getBoundingClientRect();
|
|
941
|
+
var onScreen = function (b) { return b.height && b.bottom > vp.top && b.top < vp.bottom; };
|
|
942
|
+
// 同样先整批读, 再整批写。宽度变了的块 (别人的气泡被列撑宽, 自己的收回内容宽) 贴着新的
|
|
943
|
+
// 那一侧对齐: 靠右的行比右边缘, 靠左的比左边缘。
|
|
944
|
+
var plan = Object.keys(snap.rows).reduce(function (acc, id) {
|
|
945
|
+
var row = rows[id], was = snap.rows[id];
|
|
946
|
+
if (!row) return acc;
|
|
947
|
+
var right = row.classList.contains('mine');
|
|
948
|
+
var shell = null, lift = [0, 0]; // 这一行 .turn-group 的位移, 壳里的块要减掉它
|
|
949
|
+
piecesOf(row).forEach(function (el, i) {
|
|
950
|
+
var a = was.rects[i], b = el.getBoundingClientRect();
|
|
951
|
+
if (!a || !el.animate) return;
|
|
952
|
+
var dx = right ? a.right - b.right : a.left - b.left, dy = a.top - b.top;
|
|
953
|
+
if (el.classList.contains('turn-group')) { shell = el; lift = [dx, dy]; }
|
|
954
|
+
else if (shell && shell.contains(el)) { dx -= lift[0]; dy -= lift[1]; }
|
|
955
|
+
if (!(onScreen(a) || onScreen(b))) return;
|
|
956
|
+
if (dx || dy) acc.push([el, [{ transform: 'translate(' + dx + 'px,' + dy + 'px)' }, { transform: 'none' }]]);
|
|
957
|
+
});
|
|
847
958
|
var bubs = row.querySelectorAll(BUB);
|
|
848
959
|
was.bubs.forEach(function (from, i) {
|
|
849
960
|
var b = bubs[i], to = b && getComputedStyle(b).backgroundColor;
|
|
850
|
-
if (to && to !== from)
|
|
961
|
+
if (to && to !== from) acc.push([b, [{ backgroundColor: from }, { backgroundColor: to }]]);
|
|
851
962
|
});
|
|
852
|
-
|
|
963
|
+
return acc;
|
|
964
|
+
}, []);
|
|
965
|
+
plan.forEach(function (p) { p[0].animate(p[1], ease); });
|
|
966
|
+
// 对侧那片空白 (.flip) 是立即到新布局的, 它的 sticky 箭头与 hover 底色会画在正滑过来的
|
|
967
|
+
// 气泡上面 —— 滑行期间把它藏起来、把列抬到上层 (chat.css 的 .fl-run), 落定再放出来。
|
|
968
|
+
if (!plan.length) return;
|
|
969
|
+
clearTimeout(S.flRun);
|
|
970
|
+
inner.classList.add('fl-run');
|
|
971
|
+
S.flRun = setTimeout(function () { inner.classList.remove('fl-run'); }, ease.duration);
|
|
972
|
+
};
|
|
973
|
+
// 重排之后: 先锚滚动, 再从旧位置滑过来。没有一条可配对的返回 false。
|
|
974
|
+
var landFlip = function (snap) {
|
|
975
|
+
if (!snap) return false;
|
|
976
|
+
var rows = rowsById();
|
|
977
|
+
if (!anchorScroll(snap, rows)) return false;
|
|
978
|
+
playFlip(snap, rows);
|
|
979
|
+
return true;
|
|
853
980
|
};
|
|
854
981
|
// 整窗重拉会把正文换成服务端的新片段 —— 过程框按「已有终句就收起」重新出生, 点开的工具
|
|
855
982
|
// 与取过的正文全丢, 看上去就是工具那一截在切换中突然没了。sig 没变的行把旧 .mb 整块搬回来,
|
|
@@ -868,44 +995,48 @@
|
|
|
868
995
|
if (nb) nb.replaceWith(k.mb);
|
|
869
996
|
});
|
|
870
997
|
};
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
})[0];
|
|
876
|
-
if (!anchor) { inner.classList.remove('flipping'); void inner.offsetWidth; inner.classList.add('flipping'); return false; }
|
|
877
|
-
thread.scrollTop += rowNode(anchor).querySelector('.mcol').getBoundingClientRect().top - snap[anchor].rect.top;
|
|
878
|
-
S.pinned = atBottom();
|
|
879
|
-
playFlip(snap);
|
|
880
|
-
return true;
|
|
881
|
-
};
|
|
882
|
-
|
|
883
|
-
/** 同一窗口换视角: 拿手里的片段就地重包左右, 已渲染的正文整块搬过去; 侧栏交给 SSE 的首个 role 事件。 */
|
|
884
|
-
var flipWindow = function () {
|
|
998
|
+
/** 同一窗口换视角: 拿手里的片段就地重包左右, 已渲染的正文整块搬过去。
|
|
999
|
+
* 先等 role 摘要到手、页头侧栏按新视角画完再重包 —— 页头换了高度 (人没有职责行、有的 wizard 有),
|
|
1000
|
+
* 在动画起跑之后才变, 整列就会在半路被顶一下。旧行在这几十毫秒里原样留着。 */
|
|
1001
|
+
var flipWindow = function (snap) {
|
|
885
1002
|
S.gen++;
|
|
886
1003
|
syncUrl();
|
|
887
|
-
var snap = calm() ? {} : snapRows();
|
|
888
|
-
unfoldPings(inner);
|
|
889
|
-
[].slice.call(inner.querySelectorAll('.mrow')).forEach(function (row) {
|
|
890
|
-
var m = S.frags[row.getAttribute('data-id')];
|
|
891
|
-
// 断点 (/clear /new) 是旧视角 role 自己的, 新视角的由下面补拉。
|
|
892
|
-
if (!m || m.dir === 'mark') { row.remove(); return; }
|
|
893
|
-
var next = frag(rowHTML(m)).firstElementChild;
|
|
894
|
-
var ob = row.querySelector('.mb'), nb = next.querySelector('.mb');
|
|
895
|
-
if (ob && nb) nb.replaceWith(ob);
|
|
896
|
-
row.replaceWith(next);
|
|
897
|
-
bindRow(next);
|
|
898
|
-
});
|
|
899
|
-
foldPings(inner);
|
|
900
|
-
expireRows();
|
|
901
|
-
playFlip(snap);
|
|
902
|
-
connect();
|
|
903
1004
|
var gen = S.gen;
|
|
904
|
-
api('api/
|
|
1005
|
+
api('api/role', viewParams()).then(function (d) {
|
|
905
1006
|
if (!d.ok || gen !== S.gen) return;
|
|
906
|
-
d
|
|
1007
|
+
applyRole(d);
|
|
1008
|
+
unfoldPings(inner);
|
|
1009
|
+
[].slice.call(inner.querySelectorAll('.mrow')).forEach(function (row) {
|
|
1010
|
+
var m = S.frags[row.getAttribute('data-id')];
|
|
1011
|
+
// 断点 (/clear /new) 是旧视角 role 自己的, 新视角的由下面补拉。
|
|
1012
|
+
if (!m || m.dir === 'mark') { row.remove(); return; }
|
|
1013
|
+
var next = frag(rowHTML(m)).firstElementChild;
|
|
1014
|
+
var ob = row.querySelector('.mb'), nb = next.querySelector('.mb');
|
|
1015
|
+
if (ob && nb) nb.replaceWith(ob);
|
|
1016
|
+
row.replaceWith(next);
|
|
1017
|
+
bindRow(next);
|
|
1018
|
+
});
|
|
1019
|
+
foldPings(inner);
|
|
1020
|
+
expireRows();
|
|
1021
|
+
landFlip(snap);
|
|
1022
|
+
connect();
|
|
1023
|
+
return api('api/msgs', viewParams());
|
|
1024
|
+
}).then(function (d) {
|
|
1025
|
+
if (!d || !d.ok || gen !== S.gen) return;
|
|
1026
|
+
// 断点晚到, 插在视窗上方会把正在滑的整列顶下去 —— 没吸底时锚住屏上第一条, 插完挪回原处。
|
|
1027
|
+
var marks = d.msgs.filter(function (m) { return m.dir === 'mark'; });
|
|
1028
|
+
if (!marks.length) return;
|
|
1029
|
+
var ref = !S.pinned && firstShown(), top = ref && ref.getBoundingClientRect().top;
|
|
1030
|
+
marks.forEach(upsertMsg);
|
|
1031
|
+
if (ref && ref.isConnected) thread.scrollTop += ref.getBoundingClientRect().top - top;
|
|
907
1032
|
});
|
|
908
1033
|
};
|
|
1034
|
+
var firstShown = function () {
|
|
1035
|
+
var vp = thread.getBoundingClientRect();
|
|
1036
|
+
return [].slice.call(inner.querySelectorAll('.mrow:not(.mark)')).filter(function (r) {
|
|
1037
|
+
var b = r.getBoundingClientRect(); return b.height && b.bottom > vp.top;
|
|
1038
|
+
})[0];
|
|
1039
|
+
};
|
|
909
1040
|
|
|
910
1041
|
$('#tb-back').onclick = function () { app.classList.remove('reading'); };
|
|
911
1042
|
|
|
@@ -1649,7 +1780,7 @@
|
|
|
1649
1780
|
// 手机上关系/日程占满主区 —— 进入即阅读态。
|
|
1650
1781
|
if (v !== 'msgs') app.classList.add('reading');
|
|
1651
1782
|
renderRole(); renderHead(); renderUsage();
|
|
1652
|
-
if (v === 'msgs') { fxStop(); toBottom(true); }
|
|
1783
|
+
if (v === 'msgs') { fxStop(); toBottom(true); renderConvs(); }
|
|
1653
1784
|
else {
|
|
1654
1785
|
// 关系图一打开就聚焦在当前 role 身上: 它的邻居亮着, 其余压暗。
|
|
1655
1786
|
if (v === 'world') W.sel = ROLE;
|