domma-cms 0.105.2 → 0.106.0
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/admin/js/lib/related.js +1 -1
- package/admin/js/templates/docs/usage-tools.html +4 -0
- package/package.json +1 -1
- package/plugins/_lib/admin/ui/transcript.js +154 -0
- package/plugins/_lib/call/chatStore.js +103 -0
- package/plugins/_lib/call/rooms.js +104 -21
- package/plugins/_lib/call/routes.js +55 -5
- package/plugins/_lib/public/call/call.css +1 -1
- package/plugins/_lib/public/call/call.js +229 -62
- package/plugins/booking/CLAUDE.md +9 -0
- package/plugins/booking/admin/lib/kit.js +1 -0
- package/plugins/booking/admin/views/panels.js +10 -0
- package/plugins/booking/docs/guide.md +1 -1
- package/plugins/booking/plugin.js +41 -3
- package/plugins/booking/plugin.json +1 -1
- package/plugins/booking/tests/api.test.js +64 -1
- package/plugins/free-tier.lock.json +9 -9
- package/server/routes/api/calls.js +26 -6
- package/server/services/instantCalls.js +54 -4
|
@@ -1 +1 @@
|
|
|
1
|
-
.dmc{--dmc-gap: .75rem;display:flex;flex-direction:column;gap:1rem;min-height:70vh;color:var(--dm-text, inherit)}.dmc [hidden]{display:none!important}.dmc-head{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap}.dmc-title{font-size:1.35rem;margin:0;flex:1 1 auto}.dmc-rec{color:#fff;background:var(--dm-danger, #c62828);border-radius:999px;padding:.2rem .7rem;font-size:.85rem;font-weight:600}.dmc-count{color:var(--dm-text-muted, #666);font-size:.9rem}.dmc-panel{display:grid;grid-template-columns:minmax(0,3fr) minmax(0,2fr);gap:1.5rem;align-items:start}.dmc-panel--msg{display:block;text-align:center;padding:3rem 1rem}.dmc-preview{position:relative;aspect-ratio:16 / 9;background:#111;border-radius:.75rem;overflow:hidden}.dmc-preview video,.dmc-tile video{width:100%;height:100%;object-fit:cover;display:block}.dmc-self,.dmc-self-preview{transform:scaleX(-1)}.dmc-nocam{position:absolute;inset:0;display:grid;place-items:center;color:#ddd;margin:0}.dmc-check h2{margin-top:0}.dmc-label{display:grid;gap:.35rem;margin:1rem 0;font-weight:600}.dmc-input{font:inherit;padding:.55rem .7rem;border:1px solid var(--dm-border, #ccc);border-radius:.5rem;background:var(--dm-surface, #fff);color:inherit;min-width:0}.dmc-row{display:flex;gap:.5rem;flex-wrap:wrap;margin:1rem 0}.dmc-note{color:var(--dm-text-muted, #666)}.dmc-error{color:var(--dm-danger, #c62828);font-weight:600}.dmc-btn{font:inherit;cursor:pointer;padding:.55rem 1rem;border-radius:.5rem;border:1px solid var(--dm-border, #ccc);background:var(--dm-surface, #fff);color:inherit;display:inline-flex;align-items:center;justify-content:center;gap:.45rem;position:relative}.dmc-i{display:inline-flex}.dmc-btn svg,.dmc-rec svg,.dmc-count svg{width:18px;height:18px;flex:none}.dmc-rec svg{width:10px;height:10px;fill:currentColor}.dmc-count{display:inline-flex;align-items:center;gap:.35rem}.dmc-tool{flex-direction:column;gap:.2rem;min-width:5.5rem;padding:.5rem .75rem;font-size:.85rem}.dmc-btn.is-on{border-color:var(--dm-primary, #1f6feb);color:var(--dm-primary, #1f6feb)}.dmc-btn--rec.is-on{border-color:var(--dm-danger, #c62828);color:var(--dm-danger, #c62828)}.dmc-btn--rec.is-on svg{fill:currentColor}.dmc-badge{position:absolute;top:.2rem;right:.35rem;min-width:1.1rem;height:1.1rem;padding:0 .25rem;border-radius:999px;background:var(--dm-danger, #c62828);color:#fff;font-size:.7rem;line-height:1.1rem;text-align:center}.dmc-btn:hover{border-color:var(--dm-primary, #555)}.dmc-btn:disabled{opacity:.6;cursor:default}.dmc-btn.is-off{background:var(--dm-danger, #c62828);border-color:transparent;color:#fff}.dmc-btn--go{background:var(--dm-primary, #1f6feb);border-color:transparent;color:#fff}.dmc-btn--leave{background:var(--dm-danger, #c62828);border-color:transparent;color:#fff}.dmc-btn--rec{border-color:var(--dm-danger, #c62828)}.dmc-btn--small{padding:.3rem .65rem;font-size:.9rem}.dmc-call{display:grid;grid-template-columns:minmax(0,1fr) auto;grid-template-rows:1fr auto;gap:var(--dmc-gap)}.dmc-main{display:grid;gap:var(--dmc-gap);grid-template-columns:repeat(auto-fit,minmax(min(100%,280px),1fr));align-content:start}.dmc-main[data-people="1"]{grid-template-columns:minmax(0,1fr)}.dmc-main[data-people="2"]{grid-template-columns:repeat(auto-fit,minmax(min(100%,360px),1fr))}.dmc-grid,.dmc-remote{display:contents}.dmc-tile{position:relative;margin:0;aspect-ratio:16 / 9;background:#111;border-radius:.75rem;overflow:hidden}.dmc-tile figcaption{position:absolute;left:.5rem;bottom:.5rem;padding:.15rem .5rem;border-radius:.35rem;background:#0009;color:#fff;font-size:.85rem}.dmc-side{width:300px;display:flex;flex-direction:column;gap:1rem;grid-row:1 / span 1;grid-column:2}.dmc-side h3{margin:0 0 .5rem;font-size:1rem}.dmc-waiting ul{list-style:none;margin:0;padding:0;display:grid;gap:.5rem}.dmc-waiting li{display:flex;align-items:center;gap:.4rem;flex-wrap:wrap}.dmc-waiting li span{flex:1 1 100%;font-weight:600}.dmc-chat{display:flex;flex-direction:column;min-height:0;flex:1}.dmc-messages{list-style:none;margin:0 0 .5rem;padding:0;overflow-y:auto;max-height:50vh;display:grid;gap:.4rem}.dmc-messages li.is-mine strong{color:var(--dm-primary, #1f6feb)}.dmc-send{display:flex;gap:.4rem}.dmc-send .dmc-input{flex:1}.dmc-bar{grid-column:1 / -1;display:flex;gap:.5rem;flex-wrap:wrap;justify-content:center}@media(max-width:760px){.dmc-panel,.dmc-call{grid-template-columns:1fr}.dmc-side{width:auto;grid-column:1;grid-row:auto}.dmc-bar{position:sticky;bottom:0;background:var(--dm-background, #fff);padding:.5rem 0}.dmc-bar .dmc-btn{flex:1 1 auto;min-width:3rem}.dmc-lbl{display:none}.dmc-tool{padding:.6rem}}
|
|
1
|
+
.dmc{--dmc-gap: .75rem;--dmc-stage-bg: #0f1114;--dmc-glass: rgba(32, 34, 38, .72);--dmc-glass-strong: rgba(28, 30, 34, .94);--dmc-ctl: rgba(255, 255, 255, .12);--dmc-ctl-hover: rgba(255, 255, 255, .2);--dmc-on-dark: #f1f3f4;--dmc-muted-dark: #9aa0a6;--dmc-red: #e5484d;--dmc-red-hover: #d13b40;--dmc-blue: #8ab4f8;--dmc-chat-bg: #0b141a;--dmc-chat-head: #202c33;--dmc-bubble-in: #202c33;--dmc-bubble-out: #005c4b;--dmc-chat-text: #e9edef;--dmc-chat-meta: rgba(233, 237, 239, .6);--dmc-chat-sys: #182229;--dmc-chat-sys-text: #8696a0;--dmc-green: #00a884;--dmc-offset: 5rem;display:flex;flex-direction:column;gap:1rem;color:var(--dm-text, inherit)}.dmc [hidden]{display:none!important}.dmc-head{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap}.dmc-title{font-size:1.5rem;margin:0;flex:1 1 auto;letter-spacing:-.01em}.dmc-btn{font:inherit;cursor:pointer;padding:.55rem 1rem;border-radius:.6rem;border:1px solid var(--dm-border, #ccc);background:var(--dm-surface, #fff);color:inherit;display:inline-flex;align-items:center;justify-content:center;gap:.45rem;position:relative;transition:background-color .15s,border-color .15s,color .15s,transform .1s}.dmc-btn:hover{border-color:var(--dm-primary, #555)}.dmc-btn:active{transform:scale(.97)}.dmc-btn:disabled{opacity:.55;cursor:default;transform:none}.dmc-btn:focus-visible,.dmc-compose:focus-visible{outline:2px solid var(--dmc-blue);outline-offset:2px}.dmc-i{display:inline-flex}.dmc-btn svg,.dmc-count svg,.dmc-note svg,.dmc-chip svg{width:18px;height:18px;flex:none}.dmc-btn.is-off{background:var(--dm-danger, #c62828);border-color:transparent;color:#fff}.dmc-btn--go{background:var(--dm-primary, #1f6feb);border-color:transparent;color:#fff;font-weight:600}.dmc-btn--go:hover{filter:brightness(1.08);border-color:transparent}.dmc-btn--small{padding:.35rem .7rem;font-size:.88rem}.dmc-panel{display:grid;grid-template-columns:minmax(0,3fr) minmax(0,2fr);gap:2rem;align-items:center}.dmc-panel--msg{display:flex;flex-direction:column;align-items:center;text-align:center;padding:4rem 1rem;gap:.25rem}.dmc-panel--msg h2{margin:.5rem 0 .25rem}.dmc-panel--msg p{color:var(--dm-text-muted, #666);margin:0 0 1rem}.dmc-pulse{width:72px;height:72px;border-radius:50%;display:grid;place-items:center;color:#fff;background:var(--dm-primary, #1f6feb);box-shadow:0 0 color-mix(in srgb,var(--dm-primary, #1f6feb) 45%,transparent);animation:dmc-pulse 2s infinite}.dmc-pulse svg{width:30px;height:30px}@keyframes dmc-pulse{70%{box-shadow:0 0 0 18px transparent}to{box-shadow:0 0 0 0 transparent}}.dmc-preview{position:relative;aspect-ratio:16 / 9;background:#16181c;border-radius:1rem;overflow:hidden;box-shadow:0 10px 30px #0000002e}.dmc-preview video,.dmc-tile video{width:100%;height:100%;object-fit:cover;display:block}.dmc-self,.dmc-self-preview{transform:scaleX(-1)}.dmc-nocam{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.6rem;color:#cfd3d8;margin:0}.dmc-avatar-dot{width:84px;height:84px;border-radius:50%;display:grid;place-items:center;background:#3c4043;color:#fff;font-size:2rem;font-weight:600}.dmc-preview-tools{position:absolute;left:0;right:0;bottom:1rem;display:flex;justify-content:center;gap:.75rem}.dmc-round{width:3rem;height:3rem;padding:0;border-radius:50%;border:1px solid rgba(255,255,255,.5);background:#00000059;color:#fff;backdrop-filter:blur(6px)}.dmc-round:hover{background:#00000080;border-color:#fff}.dmc-round.is-off{background:var(--dmc-red);border-color:transparent}.dmc-check h2{margin:0 0 .25rem;font-size:1.6rem}.dmc-label{display:grid;gap:.35rem;margin:1rem 0 .5rem;font-weight:600}.dmc-input{font:inherit;padding:.6rem .8rem;border:1px solid var(--dm-border, #ccc);border-radius:.6rem;background:var(--dm-surface, #fff);color:inherit;min-width:0}.dmc-row{display:flex;gap:.5rem;flex-wrap:wrap;margin:.75rem 0}.dmc-chipbtn{border-radius:999px;padding:.4rem .9rem;font-size:.9rem}.dmc-note{color:var(--dm-text-muted, #666)}.dmc-note--rec{display:flex;gap:.5rem;align-items:flex-start;font-size:.92rem}.dmc-note--rec svg{color:var(--dm-danger, #c62828);fill:currentColor;width:12px;height:12px;margin-top:.3rem}.dmc-error{color:var(--dm-danger, #c62828);font-weight:600}.dmc-check .dmc-btn--go{width:100%;padding:.8rem 1rem;border-radius:999px;font-size:1.05rem;margin-top:.5rem}.dmc-call{display:flex;gap:0;color:var(--dmc-on-dark);background:var(--dmc-stage-bg);border-radius:1rem;overflow:hidden;height:calc(100vh - var(--dmc-offset) - 1rem);height:calc(100dvh - var(--dmc-offset) - 1rem);min-height:26rem;box-shadow:0 12px 40px #00000040;color-scheme:dark}.dmc-stage{position:relative;flex:1 1 auto;min-width:0;container:dmc-stage / size;overflow:hidden}.dmc-top{position:absolute;top:0;left:0;right:0;z-index:3;display:flex;align-items:center;justify-content:space-between;gap:1rem;padding:.85rem 1.1rem 1.6rem;background:linear-gradient(rgba(0,0,0,.6),transparent);pointer-events:none}.dmc-top>*{pointer-events:auto}.dmc-top-l{display:flex;align-items:baseline;gap:.75rem;min-width:0}.dmc-top-title{margin:0;font-size:1rem;font-weight:600;color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dmc-timer{font-size:.88rem;color:var(--dmc-muted-dark);font-variant-numeric:tabular-nums}.dmc-top-r{display:flex;align-items:center;gap:.5rem;flex:none}.dmc-rec{display:inline-flex;align-items:center;gap:.4rem;color:#fff;background:var(--dmc-red);border-radius:999px;padding:.22rem .7rem;font-size:.8rem;font-weight:600}.dmc-rec-dot{width:8px;height:8px;border-radius:50%;background:#fff;animation:dmc-blink 1.4s infinite}@keyframes dmc-blink{50%{opacity:.25}}.dmc-count{display:inline-flex;align-items:center;gap:.35rem;font-size:.85rem;color:#fff;background:#ffffff24;border-radius:999px;padding:.22rem .65rem}.dmc-count svg{width:15px;height:15px}.dmc-main{position:absolute;inset:0}.dmc-remote{position:absolute;inset:3.6rem 1rem 5.6rem;display:flex;flex-wrap:wrap;align-content:center;justify-content:center;gap:var(--dmc-gap);--cols: 1;--rows: 1}.dmc-cell{container-type:size;display:grid;place-items:center;min-width:0;width:calc((100% - (var(--cols) - 1) * var(--dmc-gap)) / var(--cols));height:calc((100% - (var(--rows) - 1) * var(--dmc-gap)) / var(--rows))}.dmc-main[data-tiles="2"] .dmc-remote{--cols: 2}.dmc-main[data-tiles="3"] .dmc-remote,.dmc-main[data-tiles="4"] .dmc-remote{--cols: 2;--rows: 2}.dmc-main[data-tiles="5"] .dmc-remote,.dmc-main[data-tiles="6"] .dmc-remote{--cols: 3;--rows: 2}.dmc-tile{position:relative;margin:0;background:#202124;border-radius:.9rem;overflow:hidden}.dmc-cell .dmc-tile{width:min(100cqw,100cqh * 16 / 9);aspect-ratio:16 / 9;box-shadow:0 4px 18px #00000059}.dmc-tile video{position:relative;z-index:1}.dmc-avatar{position:absolute;inset:0;display:grid;place-items:center;z-index:0}.dmc-avatar span{width:clamp(3rem,22%,6.5rem);aspect-ratio:1;border-radius:50%;display:grid;place-items:center;background:#3c4043;color:#fff;font-size:clamp(1.2rem,4cqw,2.6rem);font-weight:600}.dmc-tile--me .dmc-avatar{z-index:2;background:#202124}.dmc-chip{position:absolute;left:.6rem;bottom:.6rem;z-index:3;display:inline-flex;align-items:center;gap:.3rem;max-width:calc(100% - 1.2rem);padding:.2rem .55rem;border-radius:.45rem;background:#0000008c;backdrop-filter:blur(6px);color:#fff;font-size:.8rem;line-height:1.3}.dmc-chip>span:last-child{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dmc-chip-mute{display:inline-flex;color:#fff;background:var(--dmc-red);border-radius:50%;padding:2px}.dmc-chip-mute svg{width:12px;height:12px}.dmc-tile--me{position:absolute;z-index:2;right:1rem;bottom:5.6rem;width:clamp(9.5rem,20%,16rem);aspect-ratio:16 / 9;box-shadow:0 6px 24px #00000080;outline:1px solid rgba(255,255,255,.14);transition:all .25s ease}.dmc-tile--me .dmc-chip{left:.4rem;bottom:.4rem;font-size:.72rem;padding:.12rem .45rem}.dmc-main:not([data-tiles="0"]):not([data-tiles="1"]) .dmc-tile--me{width:clamp(8.5rem,15%,13rem)}.dmc-main[data-tiles="0"] .dmc-tile--me{inset:0;width:auto;aspect-ratio:auto;border-radius:0;outline:0;box-shadow:none}.dmc-main[data-tiles="0"] .dmc-tile--me .dmc-chip{left:1rem;bottom:5.6rem;font-size:.8rem;padding:.2rem .55rem}.dmc-main[data-tiles="0"] .dmc-tile--me:after{content:"";position:absolute;inset:0;z-index:2;background:radial-gradient(ellipse at center,#00000040,#0009);pointer-events:none}.dmc-alone{position:absolute;inset:0;z-index:2;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:1rem;pointer-events:none}.dmc-alone-title{margin:0;font-size:clamp(1.1rem,2.4vw,1.5rem);font-weight:600;color:#fff;text-shadow:0 1px 8px rgba(0,0,0,.6)}.dmc-alone-sub{margin:.35rem 0 0;color:#dadce0;font-size:.92rem;text-shadow:0 1px 6px rgba(0,0,0,.6)}.dmc-waiting{position:absolute;z-index:4;top:3.4rem;right:1rem;width:min(21rem,calc(100% - 2rem));padding:.85rem;border-radius:.9rem;background:var(--dmc-glass-strong);backdrop-filter:blur(14px);box-shadow:0 10px 30px #00000073;border:1px solid rgba(255,255,255,.08);animation:dmc-drop .25s ease}@keyframes dmc-drop{0%{opacity:0;transform:translateY(-6px)}}.dmc-waiting h3{display:flex;align-items:center;gap:.5rem;margin:0 0 .6rem;font-size:.9rem;font-weight:600;color:#fff}.dmc-waiting-dot{width:8px;height:8px;border-radius:50%;background:#fbbc04;box-shadow:0 0 0 3px #fbbc0440}.dmc-waiting ul{list-style:none;margin:0;padding:0;display:grid;gap:.55rem;max-height:14rem;overflow-y:auto}.dmc-waiting li{display:grid;grid-template-columns:auto 1fr;align-items:center;gap:.35rem .6rem}.dmc-winitial{width:2rem;height:2rem;border-radius:50%;display:grid;place-items:center;background:#5f6368;color:#fff;font-weight:600;font-size:.9rem}.dmc-wname{font-weight:600;color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dmc-wbtns{grid-column:2;display:flex;gap:.4rem}.dmc-waiting .dmc-btn{border-radius:999px}.dmc-waiting .dmc-btn svg{width:15px;height:15px}.dmc-waiting .dmc-btn--go{background:var(--dmc-blue);color:#202124}.dmc-btn--ghost{background:transparent;color:#e8eaed;border-color:#ffffff40}.dmc-btn--ghost:hover{background:#ffffff14;border-color:#fff6}.dmc-bar{position:absolute;z-index:4;left:50%;bottom:1rem;transform:translate(-50%);display:flex;align-items:center;gap:.6rem;padding:.55rem .7rem;border-radius:999px;background:var(--dmc-glass);backdrop-filter:blur(14px) saturate(1.3);box-shadow:0 8px 28px #0006;border:1px solid rgba(255,255,255,.08)}.dmc-tool{width:3rem;height:3rem;padding:0;border-radius:50%;border:0;background:var(--dmc-ctl);color:#fff;flex:none}.dmc-tool:hover{background:var(--dmc-ctl-hover)}.dmc-tool svg{width:20px;height:20px}.dmc-tool.is-off{background:var(--dmc-red)}.dmc-tool.is-off:hover{background:var(--dmc-red-hover)}.dmc-tool.is-on{background:var(--dmc-blue);color:#202124}.dmc-btn--rec svg{color:var(--dmc-red);fill:currentColor;width:16px;height:16px}.dmc-btn--rec.is-on{background:var(--dmc-red);color:#fff;animation:dmc-recglow 1.6s infinite}.dmc-btn--rec.is-on svg{color:#fff}@keyframes dmc-recglow{0%,to{box-shadow:0 0 #e5484d99}50%{box-shadow:0 0 0 6px #e5484d00}}.dmc-btn--leave{background:var(--dmc-red);width:4rem;border-radius:999px}.dmc-btn--leave:hover{background:var(--dmc-red-hover)}.dmc-lbl{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap}.dmc-tool:after{content:attr(aria-label);position:absolute;bottom:calc(100% + .7rem);left:50%;transform:translate(-50%,4px);padding:.3rem .55rem;border-radius:.4rem;background:#3c4043;color:#fff;font-size:.75rem;white-space:nowrap;opacity:0;pointer-events:none;transition:opacity .15s,transform .15s}.dmc-tool:hover:after,.dmc-tool:focus-visible:after{opacity:1;transform:translate(-50%)}.dmc-badge{position:absolute;top:-.2rem;right:-.2rem;min-width:1.2rem;height:1.2rem;padding:0 .3rem;border-radius:999px;background:var(--dmc-green);color:#fff;font-size:.7rem;font-weight:700;line-height:1.2rem;text-align:center;box-shadow:0 0 0 2px #2a2c30}@container dmc-stage (max-aspect-ratio: 1 / 1){.dmc-main[data-tiles="2"] .dmc-remote{--cols: 1;--rows: 2}.dmc-main[data-tiles="5"] .dmc-remote,.dmc-main[data-tiles="6"] .dmc-remote{--cols: 2;--rows: 3}.dmc-cell .dmc-tile{width:100%;height:100%;aspect-ratio:auto}.dmc-tile--me{width:clamp(6.5rem,28%,9rem);aspect-ratio:3 / 4}.dmc-main:not([data-tiles="0"]):not([data-tiles="1"]) .dmc-tile--me{width:clamp(5.5rem,22%,7rem)}}.dmc-chat{flex:none;width:0;display:flex;flex-direction:column;background:var(--dmc-chat-bg);color:var(--dmc-chat-text);overflow:hidden;transition:width .25s ease;border-left:1px solid rgba(255,255,255,.06)}.dmc-call.is-chat .dmc-chat{width:21.25rem}.dmc-chat>*{min-width:21.25rem}.dmc-chat-head{display:flex;align-items:center;gap:.5rem;padding:.7rem .75rem .7rem 1rem;background:var(--dmc-chat-head)}.dmc-chat-titles{flex:1;min-width:0;display:flex;flex-direction:column}.dmc-chat-titles h3{margin:0;font-size:1rem;font-weight:600;color:var(--dmc-chat-text)}.dmc-chat-titles span{font-size:.76rem;color:var(--dmc-chat-sys-text)}.dmc-chat-back,.dmc-chat-close{width:2.25rem;height:2.25rem;border:0;border-radius:50%;background:transparent;color:#aebac1;cursor:pointer;display:grid;place-items:center}.dmc-chat-back:hover,.dmc-chat-close:hover{background:#ffffff14;color:#fff}.dmc-chat-back svg,.dmc-chat-close svg{width:20px;height:20px}.dmc-chat-back{display:none}.dmc-chat-body{position:relative;flex:1;min-height:0;display:flex}.dmc-messages{list-style:none;margin:0;padding:.6rem .9rem 1rem;flex:1;overflow-y:auto;overscroll-behavior:contain;scrollbar-width:thin;scrollbar-color:#374045 transparent}.dmc-chat-empty{position:absolute;inset:auto 1.5rem 45%;margin:0;text-align:center;color:var(--dmc-chat-sys-text);font-size:.85rem}.dmc-msg{display:flex;flex-direction:column;margin-top:2px}.dmc-msg.is-first{margin-top:.6rem}.dmc-msg.is-mine{align-items:flex-end}.dmc-msg.is-other{align-items:flex-start}.dmc-msg.is-sys{align-items:center;margin-top:.6rem}.dmc-msg.is-sys+.dmc-msg.is-sys:not(.has-day){margin-top:.3rem}.dmc-day{align-self:center;margin:.5rem 0 .35rem}.dmc-day span,.dmc-sys span{display:inline-block;padding:.28rem .65rem;border-radius:.5rem;background:var(--dmc-chat-sys);color:var(--dmc-chat-sys-text);font-size:.74rem;box-shadow:0 1px .5px #00000021}.dmc-day span{text-transform:uppercase;letter-spacing:.03em;font-size:.7rem}.dmc-msg.has-day.is-first{margin-top:.2rem}.dmc-bubble{position:relative;max-width:85%;padding:.38rem .55rem .3rem .6rem;border-radius:.55rem;background:var(--dmc-bubble-in);color:var(--dmc-chat-text);font-size:.9rem;line-height:1.32;box-shadow:0 1px .5px #00000021;overflow-wrap:anywhere}.is-mine .dmc-bubble{background:var(--dmc-bubble-out)}.is-first.is-mine .dmc-bubble{border-top-right-radius:0}.is-first.is-other .dmc-bubble{border-top-left-radius:0}.is-first.is-mine .dmc-bubble:after{content:"";position:absolute;top:0;right:-8px;border-top:10px solid var(--dmc-bubble-out);border-right:8px solid transparent}.is-first.is-other .dmc-bubble:after{content:"";position:absolute;top:0;left:-8px;border-top:10px solid var(--dmc-bubble-in);border-left:8px solid transparent}.dmc-from{display:block;font-size:.8rem;font-weight:600;margin-bottom:.1rem;color:#53bdeb}.dmc-text{white-space:pre-wrap}.dmc-time{float:right;position:relative;top:.3rem;margin-left:.6rem;font-size:.68rem;line-height:1;color:var(--dmc-chat-meta);font-variant-numeric:tabular-nums}.dmc-msg[data-hue="1"] .dmc-from{color:#ffd279}.dmc-msg[data-hue="2"] .dmc-from{color:#ff72a1}.dmc-msg[data-hue="3"] .dmc-from{color:#a5b337}.dmc-msg[data-hue="4"] .dmc-from{color:#06cf9c}.dmc-msg[data-hue="5"] .dmc-from{color:#f79877}.dmc-msg[data-hue="6"] .dmc-from{color:#a791ff}.dmc-msg[data-hue="7"] .dmc-from{color:#7ee2ff}.dmc-newchip{position:absolute;left:50%;bottom:.75rem;transform:translate(-50%);display:inline-flex;align-items:center;gap:.35rem;cursor:pointer;border:0;border-radius:999px;padding:.4rem .8rem;background:#2a3942;color:var(--dmc-chat-text);font:inherit;font-size:.8rem;box-shadow:0 2px 10px #0006}.dmc-newchip svg{width:15px;height:15px;color:var(--dmc-green)}.dmc-send{display:flex;align-items:flex-end;gap:.5rem;padding:.6rem .75rem;background:var(--dmc-chat-head)}.dmc-compose{flex:1;resize:none;border:0;border-radius:1.3rem;padding:.6rem 1rem;background:#2a3942;color:var(--dmc-chat-text);line-height:1.35rem;font-size:.92rem;overflow-y:hidden;max-height:calc(1.35rem * 4 + 1.2rem);display:block}.dmc-compose::placeholder{color:var(--dmc-chat-sys-text)}.dmc-compose:focus{outline:0;box-shadow:inset 0 0 0 1px #00a88499}.dmc-sendbtn{width:2.55rem;height:2.55rem;padding:0;border:0;border-radius:50%;background:var(--dmc-green);color:#fff;flex:none}.dmc-sendbtn:hover{background:#06cf9c}.dmc-sendbtn:disabled{opacity:1;background:#2a3942;color:var(--dmc-chat-sys-text)}.dmc-sendbtn svg{width:19px;height:19px;margin-left:-2px}@media(max-width:760px){.dmc-panel{grid-template-columns:1fr;gap:1.25rem}.dmc-title{font-size:1.25rem}.dmc-call{border-radius:.75rem;min-height:30rem}.dmc-top{padding:.7rem .8rem 1.4rem}.dmc-remote{inset:3.2rem .6rem 5.4rem}.dmc-tile--me{right:.75rem;bottom:5.4rem}.dmc-main[data-tiles="0"] .dmc-tile--me .dmc-chip{bottom:5.4rem}.dmc-waiting{top:3.1rem;left:.6rem;right:.6rem;width:auto}.dmc-bar{left:.5rem;right:.5rem;bottom:.5rem;transform:none;justify-content:space-around;gap:.25rem;border-radius:1.4rem;padding:.5rem .4rem calc(.5rem + env(safe-area-inset-bottom,0px))}.dmc-tool{width:3.1rem;height:3.1rem}.dmc-btn--leave{width:3.1rem}.dmc-tool:after{display:none}.dmc-chat{position:fixed;inset:0;z-index:1000;width:auto;border:0;transform:translateY(100%);transition:transform .25s ease,visibility 0s .25s;visibility:hidden}.dmc-call.is-chat .dmc-chat{width:auto;transform:none;visibility:visible;transition:transform .25s ease}.dmc-chat>*{min-width:0}.dmc-chat-back{display:grid}.dmc-chat-close{display:none}.dmc-send{padding-bottom:calc(.6rem + env(safe-area-inset-bottom,0px))}.dmc-bubble{max-width:88%}}@media(prefers-reduced-motion:reduce){.dmc *,.dmc *:after{animation:none!important;transition:none!important}}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Mounts on every `[data-dm-call]` element. Its JSON config:
|
|
5
5
|
*
|
|
6
|
-
* {api, room, cred: {t|key|h: '…'}, title, name?, askName?}
|
|
6
|
+
* {api, room, cred: {t|key|h: '…'}, title, name?, askName?, recordingAllowed?, closesAt?}
|
|
7
7
|
*
|
|
8
8
|
* api the Tool's call API (e.g. /api/plugins/booking-pro/call)
|
|
9
9
|
* room the room id the Tool gave the link
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
* Browsers connect to each other directly (a mesh - up to 6 people); the
|
|
16
16
|
* site only passes the messages that set that up (see _lib/call/routes.js).
|
|
17
17
|
* Every piece of text goes in through bindings (textContent), never as HTML.
|
|
18
|
+
* The chat shows the room's history (`admitted.chat`) then live items, de-duplicated by id
|
|
19
|
+
* (item shape: plugins/_lib/call/CLAUDE.md, "Chat history").
|
|
18
20
|
*/
|
|
19
21
|
|
|
20
22
|
const M = window.M;
|
|
@@ -24,33 +26,42 @@ const ico = (name) => `<span class="dmc-ico" data-icon="${name}" data-icon-size=
|
|
|
24
26
|
/** Two icons, one shown by a condition: the bindings are on the wrappers, never on the icons. */
|
|
25
27
|
const pair = (cond, on, off) => `<span class="dmc-i" data-bind-hidden="!(${cond})">${ico(on)}</span><span class="dmc-i" data-bind-hidden="${cond}">${ico(off)}</span>`;
|
|
26
28
|
|
|
29
|
+
/**
|
|
30
|
+
* The screen. In the call it is a dark stage (tiles, a top bar, a floating control bar whose labels are for
|
|
31
|
+
* screen readers and the hover tooltip - CSS reads aria-label) beside a WhatsApp-style chat panel.
|
|
32
|
+
* Chat rows are plain objects built in JS (see addItem) - every string reaches the page as textContent.
|
|
33
|
+
*/
|
|
27
34
|
const TEMPLATE = `
|
|
28
|
-
<div class="dmc">
|
|
29
|
-
<header class="dmc-head">
|
|
35
|
+
<div class="dmc" data-bind-data-stage="stage.value">
|
|
36
|
+
<header class="dmc-head" data-bind-hidden="stage.value === 'in'">
|
|
30
37
|
<h1 class="dmc-title" data-bind-text="title.value"></h1>
|
|
31
|
-
<span class="dmc-rec" data-bind-hidden="!recording.value" role="status">${ico('circle')} Recording</span>
|
|
32
|
-
<span class="dmc-count" data-bind-hidden="!countText.value">${ico('users')} <span data-bind-text="countText.value"></span></span>
|
|
33
38
|
</header>
|
|
34
39
|
|
|
35
40
|
<section class="dmc-panel" data-bind-hidden="stage.value !== 'check'">
|
|
36
41
|
<div class="dmc-preview"><video class="dmc-self-preview" muted playsinline autoplay></video>
|
|
37
|
-
<
|
|
42
|
+
<div class="dmc-nocam" data-bind-hidden="camOn.value"><span class="dmc-avatar-dot" data-bind-text="initial.value"></span><span>Camera off</span></div>
|
|
43
|
+
<div class="dmc-preview-tools">
|
|
44
|
+
<button type="button" class="dmc-btn dmc-round" data-on-click="toggleMic" data-bind-class="!micOn.value && 'is-off'" data-bind-aria-pressed="!micOn.value" data-bind-aria-label="micOn.value ? 'Turn the microphone off' : 'Turn the microphone on'">${pair('micOn.value', 'mic', 'mic-off')}</button>
|
|
45
|
+
<button type="button" class="dmc-btn dmc-round" data-on-click="toggleCam" data-bind-class="!camOn.value && 'is-off'" data-bind-aria-pressed="!camOn.value" data-bind-aria-label="camOn.value ? 'Turn the camera off' : 'Turn the camera on'">${pair('camOn.value', 'video', 'video-off')}</button>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
38
48
|
<div class="dmc-check">
|
|
39
49
|
<h2>Ready to join?</h2>
|
|
40
50
|
<p class="dmc-note" data-bind-text="deviceNote.value"></p>
|
|
41
51
|
<label class="dmc-label" data-bind-hidden="!askName">Your name
|
|
42
|
-
<input class="dmc-input" data-model="name.value" maxlength="60" autocomplete="name"></label>
|
|
52
|
+
<input class="dmc-input" data-model="name.value" maxlength="60" autocomplete="name" placeholder="How the others will see you"></label>
|
|
43
53
|
<div class="dmc-row">
|
|
44
|
-
<button type="button" class="dmc-btn" data-on-click="toggleMic" data-bind-class="!micOn.value && 'is-off'" data-bind-aria-pressed="!micOn.value">${pair('micOn.value', 'mic', 'mic-off')}<span data-bind-text="micOn.value ? 'Mic on' : 'Mic off'"></span></button>
|
|
45
|
-
<button type="button" class="dmc-btn" data-on-click="toggleCam" data-bind-class="!camOn.value && 'is-off'" data-bind-aria-pressed="!camOn.value">${pair('camOn.value', 'video', 'video-off')}<span data-bind-text="camOn.value ? 'Camera on' : 'Camera off'"></span></button>
|
|
54
|
+
<button type="button" class="dmc-btn dmc-chipbtn" data-on-click="toggleMic" data-bind-class="!micOn.value && 'is-off'" data-bind-aria-pressed="!micOn.value">${pair('micOn.value', 'mic', 'mic-off')}<span data-bind-text="micOn.value ? 'Mic on' : 'Mic off'"></span></button>
|
|
55
|
+
<button type="button" class="dmc-btn dmc-chipbtn" data-on-click="toggleCam" data-bind-class="!camOn.value && 'is-off'" data-bind-aria-pressed="!camOn.value">${pair('camOn.value', 'video', 'video-off')}<span data-bind-text="camOn.value ? 'Camera on' : 'Camera off'"></span></button>
|
|
46
56
|
</div>
|
|
47
|
-
<p class="dmc-note" data-bind-hidden="!recordingAllowed.value">The host may record this call. You will see a red Recording notice while they do.</p>
|
|
57
|
+
<p class="dmc-note dmc-note--rec" data-bind-hidden="!recordingAllowed.value">${ico('circle')}<span>The host may record this call. You will see a red Recording notice while they do.</span></p>
|
|
48
58
|
<p class="dmc-error" data-bind-hidden="!error.value" data-bind-text="error.value" role="alert"></p>
|
|
49
59
|
<button type="button" class="dmc-btn dmc-btn--go" data-on-click="join" data-bind-disabled="busy.value">${ico('phone')}<span>Join the call</span></button>
|
|
50
60
|
</div>
|
|
51
61
|
</section>
|
|
52
62
|
|
|
53
63
|
<section class="dmc-panel dmc-panel--msg" data-bind-hidden="stage.value !== 'waiting'">
|
|
64
|
+
<div class="dmc-pulse" aria-hidden="true">${ico('users')}</div>
|
|
54
65
|
<h2>Waiting for the host</h2><p>They know you are here and will let you in shortly.</p>
|
|
55
66
|
<button type="button" class="dmc-btn" data-on-click="leave">${ico('log-out')}<span>Leave</span></button>
|
|
56
67
|
</section>
|
|
@@ -59,50 +70,92 @@ const TEMPLATE = `
|
|
|
59
70
|
<h2 data-bind-text="endTitle.value"></h2><p data-bind-text="endText.value"></p>
|
|
60
71
|
</section>
|
|
61
72
|
|
|
62
|
-
<section class="dmc-call" data-bind-hidden="stage.value !== 'in'">
|
|
63
|
-
<div class="dmc-
|
|
64
|
-
<div class="dmc-
|
|
65
|
-
<
|
|
66
|
-
<
|
|
73
|
+
<section class="dmc-call" data-bind-hidden="stage.value !== 'in'" data-bind-class="sideOpen.value && 'is-chat'">
|
|
74
|
+
<div class="dmc-stage">
|
|
75
|
+
<div class="dmc-main" data-bind-data-people="people.value" data-bind-data-tiles="tiles.value">
|
|
76
|
+
<div class="dmc-remote" data-each="peers key=id">
|
|
77
|
+
<div class="dmc-cell"><figure class="dmc-tile" data-bind-data-peer="id">
|
|
78
|
+
<div class="dmc-avatar" aria-hidden="true"><span data-bind-text="initial"></span></div>
|
|
79
|
+
<video class="dmc-video" playsinline autoplay></video>
|
|
80
|
+
<figcaption class="dmc-chip"><span data-bind-text="caption.value"></span></figcaption></figure></div>
|
|
81
|
+
</div>
|
|
82
|
+
<figure class="dmc-tile dmc-tile--me">
|
|
83
|
+
<div class="dmc-avatar" data-bind-hidden="camOn.value" aria-hidden="true"><span data-bind-text="initial.value"></span></div>
|
|
84
|
+
<video class="dmc-self" muted playsinline autoplay data-bind-hidden="!camOn.value && !sharing.value"></video>
|
|
85
|
+
<figcaption class="dmc-chip"><span class="dmc-chip-mute" data-bind-hidden="micOn.value" title="Your microphone is off">${ico('mic-off')}</span><span data-bind-text="meCaption.value"></span></figcaption>
|
|
86
|
+
</figure>
|
|
87
|
+
<div class="dmc-alone" data-bind-hidden="tiles.value !== '0'">
|
|
88
|
+
<p class="dmc-alone-title">Waiting for others to join</p>
|
|
89
|
+
<p class="dmc-alone-sub" data-bind-text="aloneText.value"></p>
|
|
90
|
+
</div>
|
|
67
91
|
</div>
|
|
68
|
-
<div class="dmc-remote" data-each="peers key=id">
|
|
69
|
-
<figure class="dmc-tile" data-bind-data-peer="id"><video class="dmc-video" playsinline autoplay></video>
|
|
70
|
-
<figcaption data-bind-text="caption.value"></figcaption></figure>
|
|
71
|
-
</div>
|
|
72
|
-
</div>
|
|
73
92
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
93
|
+
<header class="dmc-top">
|
|
94
|
+
<div class="dmc-top-l">
|
|
95
|
+
<h2 class="dmc-top-title" data-bind-text="title.value"></h2>
|
|
96
|
+
<span class="dmc-timer" aria-label="Time in the call" data-bind-text="clock.value"></span>
|
|
97
|
+
</div>
|
|
98
|
+
<div class="dmc-top-r">
|
|
99
|
+
<span class="dmc-rec" data-bind-hidden="!recording.value" role="status"><span class="dmc-rec-dot" aria-hidden="true"></span>Recording</span>
|
|
100
|
+
<span class="dmc-count" data-bind-hidden="!countText.value" data-bind-title="countTitle.value" data-bind-aria-label="countTitle.value">${ico('users')}<span data-bind-text="countText.value"></span></span>
|
|
101
|
+
</div>
|
|
102
|
+
</header>
|
|
103
|
+
|
|
104
|
+
<div class="dmc-waiting" data-bind-hidden="!waiting.value.length" role="region" aria-label="Waiting to join">
|
|
105
|
+
<h3><span class="dmc-waiting-dot" aria-hidden="true"></span><span data-bind-text="waitingTitle.value"></span></h3>
|
|
77
106
|
<ul data-each="waiting key=id">
|
|
78
|
-
<li><span data-bind-text="name"></span>
|
|
79
|
-
<
|
|
80
|
-
|
|
107
|
+
<li><span class="dmc-winitial" aria-hidden="true" data-bind-text="initial"></span><span class="dmc-wname" data-bind-text="name"></span>
|
|
108
|
+
<span class="dmc-wbtns">
|
|
109
|
+
<button type="button" class="dmc-btn dmc-btn--small dmc-btn--go" data-on-click="$parent.admit($data)">${ico('check')}<span>Let in</span></button>
|
|
110
|
+
<button type="button" class="dmc-btn dmc-btn--small dmc-btn--ghost" data-on-click="$parent.turnAway($data)">${ico('x')}<span>Turn away</span></button>
|
|
111
|
+
</span></li>
|
|
81
112
|
</ul>
|
|
82
113
|
</div>
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
<
|
|
86
|
-
|
|
114
|
+
|
|
115
|
+
<nav class="dmc-bar" aria-label="Call controls">
|
|
116
|
+
<button type="button" class="dmc-btn dmc-tool" data-on-click="toggleMic" data-bind-class="!micOn.value && 'is-off'" data-bind-aria-pressed="!micOn.value" data-bind-aria-label="micOn.value ? 'Mute' : 'Unmute'">${pair('micOn.value', 'mic', 'mic-off')}<span class="dmc-lbl" data-bind-text="micOn.value ? 'Mute' : 'Unmute'"></span></button>
|
|
117
|
+
<button type="button" class="dmc-btn dmc-tool" data-on-click="toggleCam" data-bind-class="!camOn.value && 'is-off'" data-bind-aria-pressed="!camOn.value" data-bind-aria-label="camOn.value ? 'Stop video' : 'Start video'">${pair('camOn.value', 'video', 'video-off')}<span class="dmc-lbl" data-bind-text="camOn.value ? 'Stop video' : 'Start video'"></span></button>
|
|
118
|
+
<button type="button" class="dmc-btn dmc-tool dmc-tool--share" data-on-click="toggleShare" data-bind-hidden="!canShare" data-bind-class="sharing.value && 'is-on'" data-bind-aria-pressed="sharing.value" data-bind-aria-label="sharing.value ? 'Stop sharing' : 'Share your screen'">${ico('monitor')}<span class="dmc-lbl" data-bind-text="sharing.value ? 'Stop sharing' : 'Share screen'"></span></button>
|
|
119
|
+
<button type="button" class="dmc-btn dmc-tool dmc-tool--chat" data-on-click="toggleSide" data-bind-class="sideOpen.value && 'is-on'" data-bind-aria-pressed="sideOpen.value" data-bind-aria-label="chatLabel.value">${ico('message-circle')}<span class="dmc-lbl" data-bind-text="chatLabel.value"></span><span class="dmc-badge" data-bind-hidden="!unread.value || sideOpen.value" data-bind-text="unreadText.value"></span></button>
|
|
120
|
+
<button type="button" class="dmc-btn dmc-tool dmc-btn--rec" data-on-click="toggleRecord" data-bind-hidden="!(host.value && recordingAllowed.value && canRecord)" data-bind-class="iRecord.value && 'is-on'" data-bind-aria-pressed="iRecord.value" data-bind-aria-label="iRecord.value ? 'Stop recording' : 'Record the call'">${ico('circle')}<span class="dmc-lbl" data-bind-text="iRecord.value ? 'Stop recording' : 'Record'"></span></button>
|
|
121
|
+
<button type="button" class="dmc-btn dmc-tool dmc-btn--leave" data-on-click="leave" aria-label="Leave the call">${ico('phone-off')}<span class="dmc-lbl">Leave</span></button>
|
|
122
|
+
</nav>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<aside class="dmc-chat" aria-label="Chat" data-bind-aria-hidden="!sideOpen.value" data-bind-inert="!sideOpen.value">
|
|
126
|
+
<header class="dmc-chat-head">
|
|
127
|
+
<button type="button" class="dmc-chat-back" data-on-click="toggleSide" aria-label="Back to the call">${ico('arrow-left')}</button>
|
|
128
|
+
<div class="dmc-chat-titles"><h3>Chat</h3><span data-bind-text="chatSub.value"></span></div>
|
|
129
|
+
<button type="button" class="dmc-chat-close" data-on-click="toggleSide" aria-label="Close the chat">${ico('x')}</button>
|
|
130
|
+
</header>
|
|
131
|
+
<div class="dmc-chat-body">
|
|
132
|
+
<ol class="dmc-messages" data-each="messages key=id" aria-live="polite">
|
|
133
|
+
<li data-bind-class="cls" data-bind-data-hue="hue">
|
|
134
|
+
<div class="dmc-day" data-bind-hidden="!day"><span data-bind-text="day"></span></div>
|
|
135
|
+
<div class="dmc-sys" data-bind-hidden="!sys"><span data-bind-text="text"></span></div>
|
|
136
|
+
<div class="dmc-bubble" data-bind-hidden="sys"><span class="dmc-from" data-bind-hidden="!showName" data-bind-text="name"></span><span class="dmc-text" data-bind-text="text"></span><span class="dmc-time" data-bind-text="time"></span></div>
|
|
137
|
+
</li>
|
|
87
138
|
</ol>
|
|
88
|
-
<
|
|
89
|
-
|
|
90
|
-
<button type="submit" class="dmc-btn dmc-btn--small" aria-label="Send">${ico('message-circle')}<span>Send</span></button>
|
|
91
|
-
</form>
|
|
139
|
+
<p class="dmc-chat-empty" data-bind-hidden="messages.value.length">No messages yet. Messages are seen by everyone in the call.</p>
|
|
140
|
+
<button type="button" class="dmc-newchip" data-bind-hidden="!newBelow.value" data-on-click="toBottom">${ico('arrow-down')}<span>New messages</span></button>
|
|
92
141
|
</div>
|
|
142
|
+
<form class="dmc-send" data-on-submit="sendChat">
|
|
143
|
+
<textarea class="dmc-input dmc-compose" rows="1" data-model="draft.value" maxlength="1000" placeholder="Type a message" aria-label="Message everyone" data-on-keydown="chatKey" data-on-input="grow"></textarea>
|
|
144
|
+
<button type="submit" class="dmc-btn dmc-sendbtn" aria-label="Send" data-bind-disabled="!canSend.value">${ico('send')}</button>
|
|
145
|
+
</form>
|
|
93
146
|
</aside>
|
|
94
|
-
|
|
95
|
-
<nav class="dmc-bar" aria-label="Call controls">
|
|
96
|
-
<button type="button" class="dmc-btn dmc-tool" data-on-click="toggleMic" data-bind-class="!micOn.value && 'is-off'" data-bind-aria-pressed="!micOn.value" data-bind-title="micOn.value ? 'Mute' : 'Unmute'">${pair('micOn.value', 'mic', 'mic-off')}<span class="dmc-lbl" data-bind-text="micOn.value ? 'Mute' : 'Unmute'"></span></button>
|
|
97
|
-
<button type="button" class="dmc-btn dmc-tool" data-on-click="toggleCam" data-bind-class="!camOn.value && 'is-off'" data-bind-aria-pressed="!camOn.value" data-bind-title="camOn.value ? 'Stop video' : 'Start video'">${pair('camOn.value', 'video', 'video-off')}<span class="dmc-lbl" data-bind-text="camOn.value ? 'Stop video' : 'Start video'"></span></button>
|
|
98
|
-
<button type="button" class="dmc-btn dmc-tool" data-on-click="toggleShare" data-bind-hidden="!canShare" data-bind-class="sharing.value && 'is-on'" data-bind-aria-pressed="sharing.value" data-bind-title="sharing.value ? 'Stop sharing' : 'Share your screen'">${ico('monitor')}<span class="dmc-lbl" data-bind-text="sharing.value ? 'Stop sharing' : 'Share screen'"></span></button>
|
|
99
|
-
<button type="button" class="dmc-btn dmc-tool" data-on-click="toggleSide" data-bind-class="sideOpen.value && 'is-on'" data-bind-aria-pressed="sideOpen.value" data-bind-title="chatLabel.value">${ico('message-circle')}<span class="dmc-lbl" data-bind-text="chatLabel.value"></span><span class="dmc-badge" data-bind-hidden="!unread.value || sideOpen.value" data-bind-text="unread.value"></span></button>
|
|
100
|
-
<button type="button" class="dmc-btn dmc-tool dmc-btn--rec" data-on-click="toggleRecord" data-bind-hidden="!(host.value && recordingAllowed.value && canRecord)" data-bind-class="iRecord.value && 'is-on'" data-bind-aria-pressed="iRecord.value" data-bind-title="iRecord.value ? 'Stop recording' : 'Record the call'">${ico('circle')}<span class="dmc-lbl" data-bind-text="iRecord.value ? 'Stop recording' : 'Record'"></span></button>
|
|
101
|
-
<button type="button" class="dmc-btn dmc-tool dmc-btn--leave" data-on-click="leave" title="Leave the call">${ico('phone-off')}<span class="dmc-lbl">Leave</span></button>
|
|
102
|
-
</nav>
|
|
103
147
|
</section>
|
|
104
148
|
</div>`;
|
|
105
149
|
|
|
150
|
+
/** A stable colour for a sender (8 hues, picked by a hash of their tag). */
|
|
151
|
+
const hueOf = (key) => {
|
|
152
|
+
let h = 0;
|
|
153
|
+
for (const ch of String(key || '')) h = (h * 31 + ch.codePointAt(0)) >>> 0;
|
|
154
|
+
return String(h % 8);
|
|
155
|
+
};
|
|
156
|
+
const initialOf = (n) => (String(n || '').trim()[0] || '?').toUpperCase();
|
|
157
|
+
const GROUP_MS = 5 * 60e3;
|
|
158
|
+
|
|
106
159
|
function mount(el) {
|
|
107
160
|
let cfg;
|
|
108
161
|
try { cfg = JSON.parse(el.getAttribute('data-dm-call') || '{}'); } catch { cfg = {}; }
|
|
@@ -133,20 +186,30 @@ function mount(el) {
|
|
|
133
186
|
const draft = M.observable('');
|
|
134
187
|
const peers = M.observableArray([]); // {id, name, host, caption: observable}
|
|
135
188
|
const waiting = M.observableArray([]); // {id, name}
|
|
136
|
-
const messages = M.observableArray([]); //
|
|
189
|
+
const messages = M.observableArray([]); // chat rows, built by addItem()
|
|
190
|
+
const newBelow = M.observable(false); // the reader scrolled up and something new arrived
|
|
191
|
+
const clock = M.observable('0:00');
|
|
192
|
+
const tiles = M.observable('0'); // remote tiles on the stage (self is the picture-in-picture)
|
|
137
193
|
const ended = M.observable({title: 'You have left the call', text: 'You can close this page.'});
|
|
138
194
|
const max = M.observable(6);
|
|
139
195
|
|
|
140
196
|
const endTitle = M.computed(() => ended.value.title);
|
|
141
197
|
const endText = M.computed(() => ended.value.text);
|
|
142
|
-
const meCaption = M.computed(() => `${name.value || 'You'} (you)
|
|
143
|
-
const
|
|
198
|
+
const meCaption = M.computed(() => `${name.value || 'You'} (you)`);
|
|
199
|
+
const initial = M.computed(() => initialOf(name.value || 'You'));
|
|
200
|
+
const countText = M.computed(() => (stage.value === 'in' ? `${peers.value.length + 1}` : ''));
|
|
201
|
+
const countTitle = M.computed(() => `${peers.value.length + 1} of ${max.value} people in the call`);
|
|
144
202
|
const people = M.computed(() => String(Math.min(6, peers.value.length + 1)));
|
|
145
203
|
const chatLabel = M.computed(() => (sideOpen.value ? 'Hide chat' : 'Chat'));
|
|
204
|
+
const unreadText = M.computed(() => (unread.value > 99 ? '99+' : String(unread.value)));
|
|
205
|
+
const chatSub = M.computed(() => `Everyone in the call · ${peers.value.length + 1}`);
|
|
206
|
+
const waitingTitle = M.computed(() => (waiting.value.length === 1 ? '1 person waiting to join' : `${waiting.value.length} people waiting to join`));
|
|
207
|
+
const aloneText = M.computed(() => (host.value ? 'Share the link - you will be asked to let each guest in.' : 'The others will appear here as they join.'));
|
|
208
|
+
const canSend = M.computed(() => /\S/.test(draft.value || ''));
|
|
146
209
|
const title = M.observable(cfg.title || 'Call');
|
|
147
210
|
|
|
148
211
|
// ---- The connection to the site ---------------------------------------------------
|
|
149
|
-
let me = null; // {id, secret}
|
|
212
|
+
let me = null; // {id, secret, who}
|
|
150
213
|
let since = 0;
|
|
151
214
|
let polling = false;
|
|
152
215
|
const headers = () => ({'Content-Type': 'application/json', 'X-Call-Id': me?.id || '', 'X-Call-Secret': me?.secret || ''});
|
|
@@ -158,6 +221,8 @@ function mount(el) {
|
|
|
158
221
|
let screen = null;
|
|
159
222
|
let iceServers = [];
|
|
160
223
|
const pcs = new Map(); // peer id -> {pc, polite, makingOffer, ignore, stream}
|
|
224
|
+
// peers is kept in step with tiles (a plain observable, not a computed over a computed).
|
|
225
|
+
const stopTiles = M.effect(() => { tiles.value = String(Math.min(6, peers.value.length)); });
|
|
161
226
|
|
|
162
227
|
const selfVideos = () => el.querySelectorAll('.dmc-self, .dmc-self-preview');
|
|
163
228
|
const showSelf = () => selfVideos().forEach(v => { v.srcObject = screen || local; });
|
|
@@ -263,12 +328,56 @@ function mount(el) {
|
|
|
263
328
|
// ---- Events from the site ----------------------------------------------------------
|
|
264
329
|
function addPeer(p) {
|
|
265
330
|
if (p.id === me.id || peers.value.some(x => x.id === p.id)) return;
|
|
266
|
-
const row = {id: p.id, name: p.name, host: p.host, caption: M.observable('')};
|
|
331
|
+
const row = {id: p.id, name: p.name, host: p.host, initial: initialOf(p.name), caption: M.observable('')};
|
|
267
332
|
row.caption.value = captionFor(row, 'new');
|
|
268
333
|
peers.push(row);
|
|
269
334
|
connect(row);
|
|
270
335
|
}
|
|
271
336
|
|
|
337
|
+
// ---- Chat -------------------------------------------------------------------------------
|
|
338
|
+
// Items come from the site as {id, seq, at, kind: 'chat'|'system', from, who, name, text, event?}: the history
|
|
339
|
+
// in `admitted.chat`, then one per `chat` event (and on joined/left/recording events as `item`). Each becomes a
|
|
340
|
+
// display row here, grouped the WhatsApp way against the row before it; the same id never shows twice.
|
|
341
|
+
const seenIds = new Set();
|
|
342
|
+
let lastRow = null;
|
|
343
|
+
const scroller = () => el.querySelector('.dmc-messages');
|
|
344
|
+
const nearBottom = () => { const l = scroller(); return !l || l.scrollHeight - l.scrollTop - l.clientHeight < 80; };
|
|
345
|
+
const toBottom = (smooth) => requestAnimationFrame(() => {
|
|
346
|
+
const l = scroller();
|
|
347
|
+
if (l) l.scrollTo({top: l.scrollHeight, behavior: smooth ? 'smooth' : 'auto'});
|
|
348
|
+
newBelow.value = false;
|
|
349
|
+
});
|
|
350
|
+
const dayKey = (d) => `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
|
351
|
+
function dayLabel(d) {
|
|
352
|
+
const today = new Date();
|
|
353
|
+
const yesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1);
|
|
354
|
+
if (dayKey(d) === dayKey(today)) return 'Today';
|
|
355
|
+
if (dayKey(d) === dayKey(yesterday)) return 'Yesterday';
|
|
356
|
+
return d.toLocaleDateString([], {weekday: 'short', day: 'numeric', month: 'short', ...(d.getFullYear() !== today.getFullYear() && {year: 'numeric'})});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function addItem(raw, {live = true} = {}) {
|
|
360
|
+
if (!raw || typeof raw.text !== 'string' || !raw.id || seenIds.has(raw.id)) return;
|
|
361
|
+
seenIds.add(raw.id);
|
|
362
|
+
const at = new Date(raw.at || Date.now());
|
|
363
|
+
const when = Number.isNaN(at.getTime()) ? new Date() : at;
|
|
364
|
+
const sys = raw.kind === 'system';
|
|
365
|
+
const sender = raw.who || raw.from || raw.name;
|
|
366
|
+
const mine = !sys && Boolean(me) && ((raw.who && raw.who === me.who) || raw.from === me.id);
|
|
367
|
+
const day = !lastRow || dayKey(lastRow.when) !== dayKey(when) ? dayLabel(when) : '';
|
|
368
|
+
const first = sys || Boolean(day) || !lastRow || lastRow.sys || lastRow.sender !== sender || when - lastRow.when > GROUP_MS;
|
|
369
|
+
const row = {id: raw.id, sys, mine, text: raw.text, name: raw.name || 'Someone', day, sender, when,
|
|
370
|
+
showName: first && !mine && !sys, hue: hueOf(sender),
|
|
371
|
+
time: when.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit', hourCycle: 'h23'}),
|
|
372
|
+
cls: ['dmc-msg', sys ? 'is-sys' : mine ? 'is-mine' : 'is-other', first ? 'is-first' : 'is-cont', day ? 'has-day' : ''].join(' ').trim()};
|
|
373
|
+
const stick = nearBottom() || mine || !live;
|
|
374
|
+
messages.push(row);
|
|
375
|
+
lastRow = row;
|
|
376
|
+
if (live && !sys && !mine && !sideOpen.value) unread.value += 1;
|
|
377
|
+
if (stick) toBottom(live);
|
|
378
|
+
else if (live) newBelow.value = true;
|
|
379
|
+
}
|
|
380
|
+
|
|
272
381
|
function handle(e) {
|
|
273
382
|
if (!me) return; // the call ended part-way through a batch of events
|
|
274
383
|
switch (e.type) {
|
|
@@ -276,19 +385,21 @@ function mount(el) {
|
|
|
276
385
|
case 'admitted':
|
|
277
386
|
stage.value = 'in';
|
|
278
387
|
recording.value = e.recording === true;
|
|
388
|
+
(e.chat || []).forEach(item => addItem(item, {live: false}));
|
|
279
389
|
(e.peers || []).forEach(addPeer);
|
|
280
390
|
showSelf();
|
|
391
|
+
startClock();
|
|
392
|
+
fit();
|
|
281
393
|
break;
|
|
282
|
-
case 'joined': addPeer(e.peer); break;
|
|
283
|
-
case 'left': dropPeer(e.id); break;
|
|
394
|
+
case 'joined': addPeer(e.peer); addItem(e.item); break;
|
|
395
|
+
case 'left': dropPeer(e.id); addItem(e.item); break;
|
|
284
396
|
case 'signal': onSignal(e.from, e.data || {}); break;
|
|
285
|
-
case 'waiting': waiting.value = (e.waiting || []).map(w => ({id: w.id, name: w.name})); break;
|
|
397
|
+
case 'waiting': waiting.value = (e.waiting || []).map(w => ({id: w.id, name: w.name, initial: initialOf(w.name)})); break;
|
|
286
398
|
case 'chat':
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
requestAnimationFrame(() => { const list = el.querySelector('.dmc-messages'); if (list) list.scrollTop = list.scrollHeight; });
|
|
399
|
+
// An older site sends from/name/text only: make an item of it (its queue seq is unique per person).
|
|
400
|
+
addItem(e.item || {id: `q${e.seq}`, kind: 'chat', at: new Date(e.at || Date.now()).toISOString(), from: e.from, name: e.name, text: e.text});
|
|
290
401
|
break;
|
|
291
|
-
case 'recording': recording.value = e.on === true; break;
|
|
402
|
+
case 'recording': recording.value = e.on === true; addItem(e.item); break;
|
|
292
403
|
case 'turned-away': finish('away', 'The host did not let you in', 'You can close this page.'); break;
|
|
293
404
|
case 'ended': finish('ended', 'The call has ended', 'You can close this page.'); break;
|
|
294
405
|
default: break;
|
|
@@ -313,8 +424,37 @@ function mount(el) {
|
|
|
313
424
|
}
|
|
314
425
|
const pause = (ms) => new Promise(r => setTimeout(r, ms));
|
|
315
426
|
|
|
427
|
+
// ---- The stage: its height and the call timer ---------------------------------------------
|
|
428
|
+
let clockTimer = null;
|
|
429
|
+
function startClock() {
|
|
430
|
+
if (clockTimer) return;
|
|
431
|
+
const from = Date.now();
|
|
432
|
+
const tick = () => {
|
|
433
|
+
const s = Math.floor((Date.now() - from) / 1000);
|
|
434
|
+
const hh = Math.floor(s / 3600); const mm = Math.floor(s / 60) % 60; const ss = String(s % 60).padStart(2, '0');
|
|
435
|
+
clock.value = hh ? `${hh}:${String(mm).padStart(2, '0')}:${ss}` : `${mm}:${ss}`;
|
|
436
|
+
};
|
|
437
|
+
tick();
|
|
438
|
+
clockTimer = setInterval(tick, 1000);
|
|
439
|
+
}
|
|
440
|
+
/** The stage fills the window below whatever the site puts above it (its header): measured, not guessed. */
|
|
441
|
+
function fit() {
|
|
442
|
+
requestAnimationFrame(() => {
|
|
443
|
+
const call = el.querySelector('.dmc-call');
|
|
444
|
+
if (!call || call.hidden) return;
|
|
445
|
+
const top = call.getBoundingClientRect().top + window.scrollY;
|
|
446
|
+
el.style.setProperty('--dmc-offset', `${Math.round(Math.max(0, Math.min(top, window.innerHeight * 0.35)))}px`);
|
|
447
|
+
requestAnimationFrame(() => {
|
|
448
|
+
const r = call.getBoundingClientRect();
|
|
449
|
+
if (r.bottom > window.innerHeight + 2 || r.top < 0) call.scrollIntoView({block: 'end', behavior: 'smooth'});
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
window.addEventListener('resize', () => { if (stage.value === 'in') fit(); });
|
|
454
|
+
|
|
316
455
|
function finish(kind, title, text) {
|
|
317
456
|
ended.value = {title, text};
|
|
457
|
+
clearInterval(clockTimer); clockTimer = null;
|
|
318
458
|
stage.value = kind;
|
|
319
459
|
stopRecording();
|
|
320
460
|
for (const id of [...pcs.keys()]) dropPeer(id);
|
|
@@ -380,16 +520,25 @@ function mount(el) {
|
|
|
380
520
|
|
|
381
521
|
// ---- What the buttons do -------------------------------------------------------------
|
|
382
522
|
const model = {
|
|
383
|
-
title, people, chatLabel, askName: cfg.askName !== false, canShare: Boolean(navigator.mediaDevices?.getDisplayMedia), canRecord,
|
|
384
|
-
stage, name, micOn, camOn, sharing, recording, iRecord, recordingAllowed, host, busy, error, deviceNote, sideOpen, unread,
|
|
385
|
-
draft, peers, waiting, messages, endTitle, endText, meCaption, countText,
|
|
523
|
+
title, people, tiles, chatLabel, askName: cfg.askName !== false, canShare: Boolean(navigator.mediaDevices?.getDisplayMedia), canRecord,
|
|
524
|
+
stage, name, initial, micOn, camOn, sharing, recording, iRecord, recordingAllowed, host, busy, error, deviceNote, sideOpen, unread,
|
|
525
|
+
unreadText, draft, canSend, peers, waiting, waitingTitle, messages, newBelow, endTitle, endText, meCaption, countText, countTitle,
|
|
526
|
+
clock, chatSub, aloneText,
|
|
386
527
|
|
|
387
528
|
toggleMic() { micOn.value = !micOn.value; setTracks(); },
|
|
388
529
|
toggleCam() {
|
|
389
530
|
if (!local?.getVideoTracks().length) { error.value = 'No camera is available.'; return; }
|
|
390
531
|
camOn.value = !camOn.value; setTracks();
|
|
391
532
|
},
|
|
392
|
-
toggleSide() {
|
|
533
|
+
toggleSide() {
|
|
534
|
+
sideOpen.value = !sideOpen.value;
|
|
535
|
+
if (!sideOpen.value) return;
|
|
536
|
+
unread.value = 0;
|
|
537
|
+
toBottom(false);
|
|
538
|
+
// Not on a phone: the keyboard would cover the messages the moment the sheet opens.
|
|
539
|
+
if (window.matchMedia('(min-width: 761px)').matches) setTimeout(() => el.querySelector('.dmc-compose')?.focus({preventScroll: true}), 250);
|
|
540
|
+
},
|
|
541
|
+
toBottom() { toBottom(true); },
|
|
393
542
|
|
|
394
543
|
async join() {
|
|
395
544
|
error.value = '';
|
|
@@ -400,7 +549,7 @@ function mount(el) {
|
|
|
400
549
|
const body = await r.json().catch(() => ({}));
|
|
401
550
|
if (r.status === 425 && body.opensAt) { error.value = `This call opens at ${new Date(body.opensAt).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})}. Come back then.`; return; }
|
|
402
551
|
if (!r.ok) { error.value = body.error || 'Could not join the call.'; return; }
|
|
403
|
-
me = {id: body.id, secret: body.secret};
|
|
552
|
+
me = {id: body.id, secret: body.secret, who: body.who || body.id};
|
|
404
553
|
iceServers = body.iceServers || [];
|
|
405
554
|
host.value = body.host === true;
|
|
406
555
|
if (body.name) name.value = body.name;
|
|
@@ -424,11 +573,27 @@ function mount(el) {
|
|
|
424
573
|
turnAway(w) { post('answer', {who: w.id, yes: false}); },
|
|
425
574
|
|
|
426
575
|
sendChat() {
|
|
427
|
-
const text = draft.value.trim();
|
|
576
|
+
const text = String(draft.value || '').trim();
|
|
428
577
|
if (text && me) post('chat', {text});
|
|
429
578
|
draft.value = '';
|
|
579
|
+
requestAnimationFrame(() => model.grow());
|
|
430
580
|
return false;
|
|
431
581
|
},
|
|
582
|
+
/** Enter sends, Shift+Enter is a new line (and nothing is sent mid-composition, e.g. an IME). */
|
|
583
|
+
chatKey(e) {
|
|
584
|
+
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); model.sendChat(); }
|
|
585
|
+
},
|
|
586
|
+
/** The box grows with what is typed, up to four lines, then scrolls. */
|
|
587
|
+
grow() {
|
|
588
|
+
const box = el.querySelector('.dmc-compose');
|
|
589
|
+
if (!box) return;
|
|
590
|
+
const cs = getComputedStyle(box);
|
|
591
|
+
const edges = parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth);
|
|
592
|
+
const most = (parseFloat(cs.lineHeight) || 20) * 4 + parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom) + edges;
|
|
593
|
+
box.style.height = 'auto';
|
|
594
|
+
box.style.height = `${Math.min(box.scrollHeight + edges, most)}px`;
|
|
595
|
+
box.style.overflowY = box.scrollHeight + edges > most ? 'auto' : 'hidden';
|
|
596
|
+
},
|
|
432
597
|
|
|
433
598
|
async toggleShare() {
|
|
434
599
|
// Each connection's video sender: the camera's, or one added for a screen when there was no camera
|
|
@@ -476,9 +641,11 @@ function mount(el) {
|
|
|
476
641
|
const scan = () => { try { window.I?.scan?.(el); } catch { /* no icons: the labels still read */ } };
|
|
477
642
|
scan();
|
|
478
643
|
const stopScan = M.effect(() => { waiting.value; requestAnimationFrame(scan); });
|
|
644
|
+
// Reading scrolled back up: the "new messages" chip goes once they are at the bottom again.
|
|
645
|
+
el.querySelector('.dmc-messages')?.addEventListener('scroll', () => { if (newBelow.value && nearBottom()) newBelow.value = false; }, {passive: true});
|
|
479
646
|
window.addEventListener('pagehide', () => { if (me) post('leave', {}, {keepalive: true}); });
|
|
480
647
|
getDevices().then(setTracks);
|
|
481
|
-
return () => { stopAttach?.(); stopScan?.(); handleBindings?.dispose?.(); };
|
|
648
|
+
return () => { stopAttach?.(); stopScan?.(); stopTiles?.(); clearInterval(clockTimer); handleBindings?.dispose?.(); };
|
|
482
649
|
}
|
|
483
650
|
|
|
484
651
|
document.querySelectorAll('[data-dm-call]').forEach(mount);
|
|
@@ -20,6 +20,15 @@ service - no import of the Calendar, which is a separate paid Tool).
|
|
|
20
20
|
Booking Pro (not yet built) is an ADD-ON that registers into this one (`lib/extensions.js`), the Cart / Cart Pro
|
|
21
21
|
pattern - it does not supersede it. Payment is Pro only.
|
|
22
22
|
|
|
23
|
+
1.5.0 (2026-09-30): the call's chat is saved per SESSION (`sessionOf` - a class's shared call has one chat) in
|
|
24
|
+
`data/call-chat.json` (`options.dataDir` in tests) through core's `_lib/call/chatStore.js` (dynamic import beside
|
|
25
|
+
`mountCallRoutes`; an older core has none and nothing changes), passed as the engine's `chat` - so the call reopened
|
|
26
|
+
shows it. `GET /bookings/:id` adds `callChat` (typed messages) on online bookings; `GET /bookings/:id/call-chat`
|
|
27
|
+
(booking.read; 404 unless online) answers `{messages, items}` without participant ids, the session's host lines
|
|
28
|
+
(`whoOf('<bookingId>:host')` for each booking of the session) marked `mine`. The panel (`openBooking`) shows "Call chat
|
|
29
|
+
(N messages)" when N > 0 and opens `/plugins/_lib/admin/ui/transcript.js` (read-only, domma-reactive). The customer
|
|
30
|
+
has no admin view of it. Kept: 500 items a session, the 1000 most recent sessions. Tests: tests/api.test.js.
|
|
31
|
+
|
|
23
32
|
1.4.0 (2026-09-30): "Call now" hooks for Booking Pro. Registry `version: 4`: `actions: [{id, icon, title, module}]`
|
|
24
33
|
(module under `/plugins/<owner>/`; ids `[a-z0-9-]`, never a screen's or tab's key, first owner keeps an id) ->
|
|
25
34
|
`registry.actions()` -> bootstrap `actions` -> a banner button each (`booking-x-<id>`) that imports the module and calls
|
|
@@ -61,6 +61,7 @@ export const api = {
|
|
|
61
61
|
bookings: {
|
|
62
62
|
list: (q = {}) => request('GET', `${P}/bookings?${qs(q)}`),
|
|
63
63
|
get: (id) => request('GET', `${P}/bookings/${seg(id)}`),
|
|
64
|
+
callChat: (id) => request('GET', `${P}/bookings/${seg(id)}/call-chat`),
|
|
64
65
|
create: (d) => request('POST', `${P}/bookings`, d),
|
|
65
66
|
update: (id, d) => request('PUT', `${P}/bookings/${seg(id)}`, d),
|
|
66
67
|
move: (id, d) => request('PUT', `${P}/bookings/${seg(id)}/move`, d),
|
|
@@ -478,6 +478,9 @@ export async function openBooking(ctx, id) {
|
|
|
478
478
|
${b.hostUrl && live ? `<div class="bk-actions bk-actions-start bk-call-row">
|
|
479
479
|
<a class="btn btn-sm btn-primary" href="${esc(b.hostUrl)}" target="_blank" rel="noopener"><span data-icon="video" data-icon-size="13"></span> Start the call</a>
|
|
480
480
|
<span class="bk-muted">You are the host: let ${esc(b.name || 'them')} in when they arrive. ${Date.parse(b.callOpenedAt || '') > Date.now() - 2 * 3600000 ? 'It was opened early, so it is open now.' : 'It opens 10 minutes before the start.'}</span></div>` : ''}
|
|
481
|
+
${b.online && Number(b.callChat) > 0 ? `<div class="bk-actions bk-actions-start bk-call-chat">
|
|
482
|
+
<button type="button" class="btn btn-sm btn-outline" data-on-click="callChat"><span data-icon="message-circle" data-icon-size="13"></span> Call chat (${Number(b.callChat)} message${Number(b.callChat) === 1 ? '' : 's'})</button>
|
|
483
|
+
<span class="bk-muted">What was typed in the call's chat, read-only.</span></div>` : ''}
|
|
481
484
|
<div class="bk-actions bk-actions-start">
|
|
482
485
|
<button type="button" class="btn btn-sm btn-outline" data-on-click="move" ${live ? '' : 'disabled'}><span data-icon="move" data-icon-size="13"></span> Move</button>
|
|
483
486
|
<button type="button" class="btn btn-sm btn-outline" data-on-click="done" ${live && b.status !== 'completed' ? '' : 'disabled'}><span data-icon="check" data-icon-size="13"></span> Done</button>
|
|
@@ -505,6 +508,13 @@ export async function openBooking(ctx, id) {
|
|
|
505
508
|
E.toast(`Not saved: ${errText(err)}`, {type: 'error'});
|
|
506
509
|
}
|
|
507
510
|
},
|
|
511
|
+
async callChat() {
|
|
512
|
+
// The call's chat, read-only (1.5) - one per session, so a class's shared call has one.
|
|
513
|
+
try {
|
|
514
|
+
const [r, ui] = await Promise.all([api.bookings.callChat(b.id), import('/plugins/_lib/admin/ui/transcript.js')]);
|
|
515
|
+
await ui.openTranscript({title: `Call chat: ${b.serviceName}`, subtitle: timeRange(b, ctx.tz), items: r?.items || []});
|
|
516
|
+
} catch (err) { E.toast(`The chat could not be opened: ${errText(err)}`, {type: 'error'}); }
|
|
517
|
+
},
|
|
508
518
|
move: act(() => moveBooking(ctx, b)),
|
|
509
519
|
done: act(() => setStatus(ctx, b, 'completed')),
|
|
510
520
|
noshow: act(() => setStatus(ctx, b, 'no-show')),
|
|
@@ -57,7 +57,7 @@ Booking has its own permission, **Booking**, which admins have by default. **See
|
|
|
57
57
|
## Booking Pro
|
|
58
58
|
|
|
59
59
|
Booking Pro adds the following to the free Tool:
|
|
60
|
-
- Online appointments, held as a video call on your site.
|
|
60
|
+
- Online appointments, held as a video call on your site. What is typed in the call's chat is kept: open the booking and click **Call chat** to read it afterwards (a class shares one chat).
|
|
61
61
|
- **Call now**: open an online booking's call early, or start a video call with anyone straight away - from Booking, a contact or an email.
|
|
62
62
|
- Services that need more than one person or room at once.
|
|
63
63
|
- Classes and group sessions with places and a waiting list.
|