2026-06-12 00:40:07 +05:30
<!DOCTYPE html>
< html lang = "en" >
< head >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1" >
< title > BizGaze Connect</ title >
< style >
: root { --brand : #FFC708 ; --brand-d : #E0AC00 ; --blue : #1F3B73 ; --blue-d : #16294f ; --blue-soft : #EAF0FB ; --ink : #1f2430 ; --muted : #6b7280 ; --bg : #f6f8fb ; --card : #fff ; --line : #e6e9ef ; --green : #16a34a ; --red : #b91c1c ; }
* { box-sizing : border-box ;}
html , body { height : 100 % ;}
2026-06-23 16:15:29 +05:30
body { font-family : 'Segoe UI' , system-ui , sans-serif ; background : var ( -- bg ); color : var ( -- ink ); margin : 0 ; display : flex ; flex-direction : column ; height : 100 vh ; height : 100 dvh ; overflow : hidden ;}
2026-06-12 00:40:07 +05:30
/* ---- Top bar ---- */
2026-06-23 16:15:29 +05:30
header { background : var ( -- blue ); padding : .7 rem 1.4 rem ; display : flex ; justify-content : space-between ; align-items : center ; flex : 0 0 auto ; position : relative ; z-index : 1300 ;}
2026-06-12 00:40:07 +05:30
. brandrow { display : flex ; align-items : center ; gap : .6 rem ;}
. logo { width : 30 px ; height : 30 px ; border-radius : 8 px ; background : var ( -- brand ); display : grid ; place-items : center ; font-weight : 800 ; color : var ( -- blue );}
. brand { font-weight : 700 ; color : #fff ; font-size : 1.05 rem ;} . brand span . y { color : var ( -- brand ); font-weight : 700 ;}
2026-06-23 16:15:29 +05:30
/* ---- Header actions: notification bell + profile ---- */
# hdrRight { display : flex ; align-items : center ; gap : .45 rem ;}
. bell { position : relative ;}
. bellbtn { position : relative ; background : rgba ( 255 , 255 , 255 , .14 ); border : 1 px solid #46598c ; color : #fff ; cursor : pointer ; width : 40 px ; height : 40 px ; border-radius : 10 px ; display : grid ; place-items : center ;}
. bellbtn : hover { background : rgba ( 255 , 255 , 255 , .24 );}
. bell-dot { position : absolute ; top : 4 px ; right : 4 px ; min-width : 16 px ; height : 16 px ; border-radius : 99 px ; background : var ( -- brand ); color : var ( -- blue ); font-size : .6 rem ; font-weight : 800 ; display : grid ; place-items : center ; padding : 0 .2 rem ; border : 2 px solid var ( -- blue );}
. bell-menu { position : absolute ; right : 0 ; top : calc ( 100 % + 6 px ); width : 340 px ; max-width : 92 vw ; background : #fff ; border : 1 px solid #e6e9ef ; border-radius : 12 px ; box-shadow : 0 12 px 30 px rgba ( 0 , 0 , 0 , .18 ); z-index : 5000 ; display : none ; overflow : hidden ;}
. bell-menu . open { display : block ;}
. bell-head { display : flex ; align-items : center ; justify-content : space-between ; padding : .6 rem .85 rem ; border-bottom : 1 px solid #eef1f6 ; font-weight : 700 ; font-size : .9 rem ; color : var ( -- ink );}
. bell-head button { background : none ; border : none ; color : var ( -- blue ); font-size : .78 rem ; cursor : pointer ; font-weight : 600 ;}
. bell-list { max-height : 62 vh ; overflow : auto ;}
. bell-item { display : flex ; gap : .6 rem ; align-items : flex-start ; padding : .6 rem .85 rem ; border-bottom : 1 px solid #f4f6fa ; cursor : pointer ;}
. bell-item : hover { background : #f6f8fb ;}
. bell-item . unread { background : #eef4ff ;}
. bell-ico { width : 30 px ; height : 30 px ; border-radius : 50 % ; background : var ( -- blue - soft ); color : var ( -- blue ); display : grid ; place-items : center ; flex : 0 0 30 px ;}
. bell-body { min-width : 0 ;}
. bell-tx { font-size : .85 rem ; color : var ( -- ink ); line-height : 1.3 ;}
. bell-tm { font-size : .7 rem ; color : var ( -- muted ); margin-top : .1 rem ;}
. bell-empty { padding : 1.6 rem ; text-align : center ; color : var ( -- muted ); font-size : .85 rem ;}
2026-06-12 00:40:07 +05:30
/* ---- Profile dropdown (from console.html) ---- */
. profile { position : relative }
2026-06-23 16:15:29 +05:30
. profile . pbtn . icon-only { padding : .3 rem ; gap : 0 ; border-radius : 50 % ;}
2026-06-12 00:40:07 +05:30
. profile . pbtn { display : flex ; align-items : center ; gap : .5 rem ; background : rgba ( 255 , 255 , 255 , .14 ); color : #fff ; border : 1 px solid #46598c ; border-radius : 10 px ; padding : .4 rem .85 rem .4 rem .5 rem ; font-weight : 600 ; font-size : .88 rem ; cursor : pointer }
. profile . pbtn : hover { background : rgba ( 255 , 255 , 255 , .24 )}
2026-06-23 16:15:29 +05:30
. profile . pbtn . pav { position : relative ; width : 28 px ; height : 28 px ; border-radius : 50 % ; background : var ( -- brand ); color : var ( -- blue ); display : grid ; place-items : center ; font-weight : 800 ; font-size : .78 rem ; overflow : hidden }
. profile . pbtn . pav img { position : absolute ; inset : 0 ; width : 100 % ; height : 100 % ; object-fit : cover }
2026-06-12 00:40:07 +05:30
. profile . pmenu { position : absolute ; right : 0 ; top : calc ( 100 % + 6 px ); background : #fff ; border : 1 px solid #e6e9ef ; border-radius : 10 px ; box-shadow : 0 10 px 28 px rgba ( 0 , 0 , 0 , .18 ); min-width : 210 px ; overflow : hidden ; z-index : 5000 ; display : none }
. profile . pmenu . open { display : block }
. profile . pmenu . phead { padding : .7 rem .9 rem ; border-bottom : 1 px solid #eef1f6 }
. profile . pmenu . phead . n { font-weight : 700 ; font-size : .9 rem }
. profile . pmenu . phead . e { color : var ( -- muted ); font-size : .78 rem }
2026-06-23 16:15:29 +05:30
. profile . pmenu a { display : flex ; align-items : center ; gap : .55 rem ; padding : .6 rem .9 rem ; color : #1f2430 ; text-decoration : none ; font-size : .9 rem ; cursor : pointer }
2026-06-12 00:40:07 +05:30
. profile . pmenu a : hover { background : #f1f5f9 }
2026-06-23 16:15:29 +05:30
. profile . pmenu a . ic { color : var ( -- muted )}
2026-06-12 00:40:07 +05:30
. profile . pmenu a . danger { color : #b91c1c ; border-top : 1 px solid #eef1f6 }
2026-06-23 16:15:29 +05:30
. profile . pmenu a . danger . ic { color : #b91c1c }
2026-06-12 00:40:07 +05:30
/* ---- Shell ---- */
. shell { flex : 1 1 auto ; display : flex ; min-height : 0 ;}
/* ---- Icon rail ---- */
. rail { width : 74 px ; flex : 0 0 74 px ; background : var ( -- card ); border-right : 1 px solid var ( -- line ); display : flex ; flex-direction : column ; align-items : center ; padding : .8 rem 0 ; gap : .4 rem ;}
. railbtn { position : relative ; width : 50 px ; height : 50 px ; border : none ; background : transparent ; border-radius : 14 px ; color : var ( -- muted ); cursor : pointer ; display : grid ; place-items : center ; transition : background .12 s , color .12 s ;}
. railbtn : hover { background : var ( -- blue - soft ); color : var ( -- blue );}
. railbtn . active { background : var ( -- blue ); color : #fff ;}
. railbtn . rdot { position : absolute ; top : 8 px ; right : 8 px ; min-width : 16 px ; height : 16 px ; border-radius : 99 px ; background : var ( -- brand ); color : var ( -- blue ); font-size : .62 rem ; font-weight : 800 ; display : grid ; place-items : center ; padding : 0 .2 rem ; border : 2 px solid var ( -- card );}
. railbtn . active . rdot { border-color : var ( -- blue );}
. railbtn . livedot { position : absolute ; top : 8 px ; right : 8 px ; width : 11 px ; height : 11 px ; border-radius : 50 % ; background : var ( -- green ); border : 2 px solid var ( -- card ); display : none ;}
. railbtn . active . livedot { border-color : var ( -- blue );}
. railbtn . live . livedot { display : block ; animation : livePulse 1.4 s infinite ;}
@ keyframes livePulse { 0 %, 100 % { opacity : 1 } 50 % { opacity : .3 }}
2026-06-23 16:15:29 +05:30
. railbtn . rlabel { display : none ; font-size : .6 rem ; margin-top : 0 ;}
2026-06-12 00:40:07 +05:30
/* tooltip */
. railbtn :: after { content : attr ( data - tip ); position : absolute ; left : calc ( 100 % + 12 px ); top : 50 % ; transform : translateY ( -50 % ); background : var ( -- blue - d ); color : #fff ; padding : .35 rem .6 rem ; border-radius : 8 px ; font-size : .78 rem ; font-weight : 600 ; white-space : nowrap ; opacity : 0 ; pointer-events : none ; transition : opacity .14 s ; z-index : 200 ; box-shadow : 0 6 px 16 px rgba ( 0 , 0 , 0 , .25 );}
. railbtn :: before { content : "" ; position : absolute ; left : calc ( 100 % + 6 px ); top : 50 % ; transform : translateY ( -50 % ); border : 6 px solid transparent ; border-right-color : var ( -- blue - d ); opacity : 0 ; pointer-events : none ; transition : opacity .14 s ; z-index : 200 ;}
. railbtn : hover :: after , . railbtn : hover :: before { opacity : 1 ;}
. rail-spacer { flex : 1 1 auto ;}
. caption { font-size : .58 rem ; color : var ( -- muted ); text-align : center ; line-height : 1.2 ;}
/* ---- Chat list column ---- */
. chatcol { width : 312 px ; flex : 0 0 312 px ; background : var ( -- card ); border-right : 1 px solid var ( -- line ); display : flex ; flex-direction : column ; min-height : 0 ;}
. chatcol . hidden { display : none ;}
2026-06-23 16:15:29 +05:30
. side-sec { font-size : .68 rem ; text-transform : uppercase ; letter-spacing : .05 em ; color : var ( -- muted ); font-weight : 700 ; padding : .6 rem .9 rem .25 rem ;}
. contact-row , . dir-row { display : flex ; align-items : center ; gap : .6 rem ; padding : .5 rem .9 rem ; cursor : pointer ;}
. contact-row : hover , . dir-row : hover { background : var ( -- blue - soft );}
. contact-row . cr-name , . dir-row . cr-name { font-size : .9 rem ; color : var ( -- ink ); white-space : nowrap ; overflow : hidden ; text-overflow : ellipsis ;}
. dir-row . dr-main { display : flex ; flex-direction : column ; min-width : 0 ; flex : 1 ;}
. dir-row . dr-sub { font-size : .74 rem ; color : var ( -- muted ); white-space : nowrap ; overflow : hidden ; text-overflow : ellipsis ;}
. dir-row . dr-tag { font-size : .62 rem ; font-weight : 700 ; color : #92600b ; background : #fff3cd ; border-radius : 99 px ; padding : .1 rem .4 rem ; flex : 0 0 auto ;}
2026-06-12 00:40:07 +05:30
. side-head { padding : 1 rem 1 rem .75 rem ; border-bottom : 1 px solid var ( -- line );}
. side-title { display : flex ; align-items : center ; justify-content : space-between ; margin-bottom : .7 rem ;}
. side-title h2 { font-size : .95 rem ; margin : 0 ; color : var ( -- blue );}
. newchat { width : 30 px ; height : 30 px ; border-radius : 9 px ; border : none ; background : var ( -- blue - soft ); color : var ( -- blue ); font-size : 1.2 rem ; line-height : 1 ; cursor : pointer ; font-weight : 700 ; display : grid ; place-items : center ; padding : 0 ;}
. newchat : hover { background : #dbe6fb ;}
. search { position : relative ;}
2026-06-23 16:15:29 +05:30
. search > svg { position : absolute ; left : .65 rem ; top : 50 % ; transform : translateY ( -50 % ); color : var ( -- muted );}
2026-06-12 00:40:07 +05:30
. search input { width : 100 % ; padding : .55 rem .7 rem .55 rem 2.1 rem ; border-radius : 10 px ; border : 2 px solid var ( -- line ); background : #fbfcfe ; color : var ( -- ink ); font-size : .9 rem ;}
. search input : focus { outline : none ; border-color : var ( -- brand );}
2026-06-23 16:15:29 +05:30
. search input { padding-right : 2.1 rem ;}
. search-x { position : absolute ; right : .5 rem ; top : 50 % ; transform : translateY ( -50 % ); border : none ; background : transparent ; color : var ( -- muted ); cursor : pointer ; padding : .2 rem ; border-radius : 6 px ; display : grid ; place-items : center ;}
. search-x : hover { color : var ( -- blue ); background : var ( -- blue - soft );}
2026-06-12 00:40:07 +05:30
. chatlist { overflow-y : auto ; flex : 1 1 auto ; padding : .4 rem ;}
. chat-row { display : flex ; gap : .7 rem ; align-items : center ; padding : .6 rem .65 rem ; border-radius : 12 px ; cursor : pointer ; position : relative ;}
. chat-row : hover { background : #f3f6fb ;}
2026-06-23 16:15:29 +05:30
. chat-row . active { background : var ( -- blue - soft ); box-shadow : inset 3 px 0 0 var ( -- brand );}
2026-06-12 00:40:07 +05:30
. chat-row . active :: before { content : "" ; position : absolute ; left : 0 ; top : .7 rem ; bottom : .7 rem ; width : 3 px ; border-radius : 3 px ; background : var ( -- blue );}
. avatar { width : 42 px ; height : 42 px ; flex : 0 0 42 px ; border-radius : 50 % ; display : grid ; place-items : center ; color : #fff ; font-weight : 700 ; font-size : .92 rem ; position : relative ;}
2026-06-23 16:15:29 +05:30
. avatar . av-img { position : absolute ; inset : 0 ; width : 100 % ; height : 100 % ; object-fit : cover ; border-radius : inherit ;}
. avatar . dot { position : absolute ; right : -1 px ; bottom : -1 px ; width : 11 px ; height : 11 px ; border-radius : 50 % ; border : 2 px solid #fff ; background : #cbd2dd ; z-index : 1 ;}
2026-06-12 00:40:07 +05:30
. avatar . dot . on { background : var ( -- green );}
. chat-main { flex : 1 1 auto ; min-width : 0 ;}
. chat-top { display : flex ; justify-content : space-between ; align-items : baseline ; gap : .5 rem ;}
2026-06-23 16:15:29 +05:30
. chat-name { font-weight : 400 ; font-size : .92 rem ; white-space : nowrap ; overflow : hidden ; text-overflow : ellipsis ;}
2026-06-12 00:40:07 +05:30
. chat-time { color : var ( -- muted ); font-size : .72 rem ; flex : 0 0 auto ;}
. chat-bottom { display : flex ; justify-content : space-between ; align-items : center ; gap : .5 rem ; margin-top : .15 rem ;}
. chat-prev { color : var ( -- muted ); font-size : .82 rem ; white-space : nowrap ; overflow : hidden ; text-overflow : ellipsis ; flex : 1 1 auto ;}
. chat-row . unread . chat-prev { color : var ( -- ink ); font-weight : 500 ;}
. chat-row . unread . chat-name { font-weight : 700 ;}
2026-06-23 16:15:29 +05:30
. badge { flex : 0 0 auto ; background : var ( -- brand ); color : var ( -- blue ); font-size : .7 rem ; font-weight : 800 ; min-width : 19 px ; height : 19 px ; border-radius : 99 px ; padding : 0 .35 rem ; display : grid ; place-items : center ;}
2026-06-12 00:40:07 +05:30
. no-results { padding : 2 rem 1 rem ; text-align : center ; color : var ( -- muted ); font-size : .85 rem ;}
. demo-note { padding : .5 rem 1 rem ; border-top : 1 px solid var ( -- line ); color : var ( -- muted ); font-size : .72 rem ; text-align : center ; background : #fbfcfe ;}
/* ---- Main content ---- */
. content { flex : 1 1 auto ; position : relative ; min-width : 0 ; min-height : 0 ; background : var ( -- bg );}
. panel { position : absolute ; inset : 0 ; display : none ;}
. panel . active { display : flex ;}
. panel . center { align-items : center ; justify-content : center ; padding : 2 rem ; overflow-y : auto ;}
. panel iframe { width : 100 % ; height : 100 % ; border : 0 ; display : block ; background : var ( -- bg );}
/* welcome + feature cards */
. welcome { text-align : center ; max-width : 560 px ;}
. welcome . wave { font-size : 3 rem ; line-height : 1 ; margin-bottom : .4 rem ;}
. welcome h1 { font-size : 1.8 rem ; color : var ( -- blue ); margin : .2 rem 0 .5 rem ;}
. welcome p { color : var ( -- muted ); font-size : 1 rem ; line-height : 1.6 ; margin : 0 auto 1.8 rem ; max-width : 440 px ;}
. wcards { display : flex ; gap : 1 rem ; flex-wrap : wrap ; justify-content : center ;}
. wcard { flex : 1 ; min-width : 150 px ; max-width : 180 px ; background : var ( -- card ); border : 1 px solid var ( -- line ); border-radius : 14 px ; padding : 1.2 rem 1 rem ; cursor : pointer ; transition : transform .12 s , box-shadow .12 s , border-color .12 s ; text-align : center ;}
. wcard : hover { transform : translateY ( -3 px ); box-shadow : 0 12 px 28 px rgba ( 20 , 30 , 60 , .1 ); border-color : var ( -- brand );}
. wcard . wi { width : 46 px ; height : 46 px ; border-radius : 12 px ; display : grid ; place-items : center ; margin : 0 auto .6 rem ; background : var ( -- blue - soft ); color : var ( -- blue );}
. wcard h3 { margin : 0 0 .2 rem ; font-size : .95 rem ; color : var ( -- blue );}
. wcard p { margin : 0 ; font-size : .78 rem ; color : var ( -- muted ); line-height : 1.4 ;}
. card { background : var ( -- card ); border : 1 px solid var ( -- line ); border-radius : 16 px ; padding : 2.2 rem ; box-shadow : 0 6 px 18 px rgba ( 20 , 30 , 60 , .05 ); text-align : center ; max-width : 520 px ;}
. feat-icon { width : 72 px ; height : 72 px ; border-radius : 20 px ; display : grid ; place-items : center ; margin : 0 auto 1.2 rem ;}
. feat-icon . yellow { background : #fff6d8 ; color : var ( -- brand - d );}
. card h1 { font-size : 1.45 rem ; margin : 0 0 .5 rem ; color : var ( -- blue );}
. card p { color : var ( -- muted ); font-size : .95 rem ; line-height : 1.55 ; margin : 0 auto 1.6 rem ; max-width : 400 px ;}
. pill-soon { display : inline-block ; background : #fff6d8 ; color : var ( -- brand - d ); font-size : .74 rem ; font-weight : 700 ; padding : .25 rem .7 rem ; border-radius : 99 px ; letter-spacing : .03 em ; margin-bottom : 1.2 rem ;}
. btn { display : inline-flex ; align-items : center ; gap : .5 rem ; text-decoration : none ; padding : .8 rem 1.6 rem ; background : var ( -- brand ); color : var ( -- ink ); border : none ; border-radius : 11 px ; font-weight : 700 ; font-size : .95 rem ; cursor : pointer ;}
. btn : hover { background : var ( -- brand - d );}
. hint { margin-top : 1.4 rem ; font-size : .8 rem ; color : var ( -- muted );}
/* conversation placeholder (selected chat, no backend yet) */
. convo { flex-direction : column ; display : flex ; width : 100 % ; height : 100 % ;}
. convo-head { display : flex ; align-items : center ; gap : .7 rem ; padding : .9 rem 1.2 rem ; border-bottom : 1 px solid var ( -- line ); background : var ( -- card );}
2026-06-23 16:15:29 +05:30
. ic { display : inline-block ; vertical-align : middle ;}
. convo-back { border : none ; background : var ( -- blue - soft ); color : var ( -- blue ); width : 34 px ; height : 34 px ; border-radius : 9 px ; cursor : pointer ; display : grid ; place-items : center ; flex : 0 0 auto ;}
2026-06-12 00:40:07 +05:30
. convo-back : hover { background : #dbe6fb ;}
. convo-head . nm { font-weight : 700 ; color : var ( -- ink );}
. convo-head . st { font-size : .78 rem ; color : var ( -- muted );}
. convo-body { flex : 1 ; display : grid ; place-items : center ; text-align : center ; color : var ( -- muted ); padding : 2 rem ;}
. convo-body . big { font-size : 2.4 rem ; margin-bottom : .4 rem ;}
2026-06-23 16:15:29 +05:30
/* message thread */
. convo-msgs { flex : 1 ; overflow-y : auto ; padding : 1 rem 1.2 rem ; display : flex ; flex-direction : column ; gap : .35 rem ; background : var ( -- bg );}
. bubble { max-width : 72 % ; padding : .5 rem .75 rem ; border-radius : 14 px ; font-size : .9 rem ; line-height : 1.4 ; white-space : pre-wrap ; word-break : break-word ; box-shadow : 0 1 px 2 px rgba ( 20 , 30 , 60 , .06 );}
. bubble . them { align-self : flex-start ; background : #fff ; border : 1 px solid var ( -- line ); color : var ( -- ink ); border-bottom-left-radius : 4 px ;}
. bubble . mine { align-self : flex-end ; background : var ( -- blue ); color : #fff ; border-bottom-right-radius : 4 px ;}
. bubble . t { display : block ; font-size : .64 rem ; opacity : .65 ; margin-top : .15 rem ; text-align : right ;}
. day-sep { align-self : center ; font-size : .72 rem ; margin : .7 rem 0 .3 rem ; text-align : center ;}
. day-sep span { background : #fde7b0 ; color : #7a5b05 ; padding : .22 rem .8 rem ; border-radius : 99 px ; font-weight : 600 ; box-shadow : 0 1 px 3 px rgba ( 20 , 30 , 60 , .1 );}
. float-date { position : absolute ; top : .6 rem ; left : 50 % ; transform : translateX ( -50 % ); z-index : 5 ; background : #fde7b0 ; color : #7a5b05 ; padding : .22 rem .85 rem ; border-radius : 99 px ; font-weight : 600 ; font-size : .72 rem ; box-shadow : 0 3 px 10 px rgba ( 20 , 30 , 60 , .18 ); pointer-events : none ;}
. jump-latest { position : absolute ; right : 16 px ; bottom : 86 px ; z-index : 5 ; width : 42 px ; height : 42 px ; border-radius : 50 % ; border : 1 px solid var ( -- line ); background : var ( -- card ); color : var ( -- blue ); box-shadow : 0 4 px 14 px rgba ( 20 , 30 , 60 , .22 ); cursor : pointer ; display : grid ; place-items : center ;}
. jump-latest : hover { background : var ( -- brand ); color : var ( -- blue ); border-color : var ( -- brand - d );}
. empty-thread { align-self : center ; font-size : .85 rem ; color : var ( -- muted ); margin : auto ;}
. sys-msg { align-self : center ; font-size : .76 rem ; color : var ( -- muted ); background : rgba ( 0 , 0 , 0 , .04 ); padding : .25 rem .7 rem ; border-radius : 99 px ; margin : .2 rem 0 ; max-width : 90 % ; text-align : center ;}
. bubble . t { display : flex ; align-items : center ; justify-content : flex-end ; gap : .25 rem ; font-size : .64 rem ; opacity : .65 ; margin-top : .15 rem ;}
. bubble . rcpt { display : inline-flex ;}
. bubble . mine . rcpt { opacity : .8 ;}
. bubble . mine . rcpt . seen { color : #8fd3ff ; opacity : 1 ;}
. att-img { cursor : zoom-in ;}
. fmt-bar { display : flex ; align-items : center ; gap : .05 rem ; padding : .3 rem .4 rem .1 rem ; flex-wrap : wrap ; border-bottom : 1 px dashed var ( -- line );}
. fmt-bar button { border : none ; background : transparent ; color : var ( -- muted ); cursor : pointer ; width : 30 px ; height : 30 px ; border-radius : 7 px ; display : grid ; place-items : center ;}
. fmt-bar button : hover { color : var ( -- blue ); background : var ( -- blue - soft );}
. fmt-sep { width : 1 px ; height : 18 px ; background : var ( -- line ); margin : 0 .3 rem ;}
. bubble . msg-list { margin : .15 rem 0 ; padding-left : 1.25 rem ;}
. bubble . msg-list li { margin : .05 rem 0 ;}
. bubble code { background : rgba ( 0 , 0 , 0 , .08 ); padding : .05 rem .3 rem ; border-radius : 5 px ; font-size : .86 em ; font-family : ui-monospace , Consolas , monospace ;}
. bubble . mine code { background : rgba ( 255 , 255 , 255 , .2 );}
. lightbox { position : fixed ; inset : 0 ; z-index : 6000 ; background : rgba ( 8 , 12 , 22 , .88 ); display : flex ; align-items : center ; justify-content : center ;}
. lightbox img { max-width : 92 vw ; max-height : 88 vh ; border-radius : 10 px ; box-shadow : 0 16 px 50 px rgba ( 0 , 0 , 0 , .5 );}
. lightbox . lb-close , . lightbox . lb-dl { position : absolute ; top : 18 px ; border : none ; background : rgba ( 255 , 255 , 255 , .14 ); color : #fff ; width : 44 px ; height : 44 px ; border-radius : 50 % ; display : grid ; place-items : center ; cursor : pointer ; text-decoration : none ;}
. lightbox . lb-close { right : 18 px ;}
. lightbox . lb-dl { right : 74 px ;}
. lightbox . lb-close : hover , . lightbox . lb-dl : hover { background : rgba ( 255 , 255 , 255 , .28 );}
. composer { display : flex ; align-items : flex-end ; gap : .5 rem ; padding : .6 rem .8 rem ; border-top : 1 px solid var ( -- line ); background : var ( -- card );}
. composer-box { flex : 1 ; min-width : 0 ; border : 1.5 px solid var ( -- line ); border-radius : 16 px ; background : #fbfcfe ; display : flex ; flex-direction : column ; overflow : hidden ;}
. composer-box : focus-within { border-color : var ( -- blue );}
. composer-row { display : flex ; align-items : flex-end ; gap : .15 rem ; padding : .15 rem .3 rem ;}
. composer-row input , . composer-row textarea { flex : 1 ; min-width : 0 ; box-sizing : border-box ; border : none ; background : transparent ; padding : .5 rem .4 rem ; margin : 0 ; font-size : .92 rem ; color : var ( -- ink ); font-family : inherit ; resize : none ; line-height : 1.45 ; max-height : 140 px ; overflow-y : hidden ; display : block ; scrollbar-width : thin ;}
. composer-row input : focus , . composer-row textarea : focus { outline : none ;}
. ic-btn { border : none ; background : transparent ; color : var ( -- muted ); cursor : pointer ; width : 36 px ; height : 36 px ; border-radius : 10 px ; display : grid ; place-items : center ; flex : 0 0 auto ;}
. ic-btn : hover { color : var ( -- blue ); background : var ( -- blue - soft );}
. attach-preview { padding : .55 rem .6 rem .15 rem ;}
. ap-item { display : inline-flex ; align-items : center ; gap : .55 rem ; background : #fff ; border : 1 px solid var ( -- line ); border-radius : 11 px ; padding : .35 rem .5 rem ; max-width : 100 % ;}
. ap-thumb { width : 42 px ; height : 42 px ; border-radius : 8 px ; object-fit : cover ; flex : 0 0 auto ;}
. ap-ic { display : grid ; place-items : center ; color : var ( -- blue ); flex : 0 0 auto ;}
. ap-name { font-size : .82 rem ; color : var ( -- ink ); overflow : hidden ; text-overflow : ellipsis ; white-space : nowrap ; max-width : 240 px ;}
. ap-x { border : none ; background : transparent ; color : var ( -- muted ); cursor : pointer ; display : grid ; place-items : center ; padding : .15 rem ; border-radius : 6 px ; flex : 0 0 auto ;}
. ap-x : hover { color : var ( -- red ); background : #fee2e2 ;}
/* meetings */
. meet { display : flex ; flex-direction : column ; width : 100 % ; height : 100 % ;}
. meet-grid { flex : 1 ; display : grid ; gap : .6 rem ; padding : .8 rem ; grid-template-columns : repeat ( auto - fit , minmax ( 220 px , 1 fr )); align-content : start ; overflow : auto ; background : #0b1220 ;}
. meet-tile { position : relative ; background : #0b1220 ; border-radius : 12 px ; overflow : hidden ; aspect-ratio : 4 / 3 ; border : 1 px solid #1e293b ; transition : box-shadow .12 s , border-color .12 s ;}
. meet-tile . speaking { border-color : #22c55e ; box-shadow : 0 0 0 2 px #22c55e , 0 0 14 px rgba ( 34 , 197 , 94 , .5 );}
. meet-tile . meet-screen { position : absolute ; right : .5 rem ; top : .5 rem ; display : inline-flex ; align-items : center ; gap : .25 rem ; background : rgba ( 37 , 99 , 235 , .92 ); color : #fff ; font-size : .66 rem ; font-weight : 700 ; padding : .14 rem .42 rem ; border-radius : 6 px ;}
. meet-tile . sharing { grid-column : span 2 ; border-color : #2563eb ;}
/* Screen-share "stage": the chosen shared screen (.stage) fills a FIXED area on the left; every
other tile — participants AND any other shared screens — sits in a persistent small column on the
right. Click another shared screen there to switch which one is on the stage. Screen never shrinks. */
. meet-grid . sharing-mode { display : flex ; flex-flow : column wrap ; align-content : flex-start ; height : 100 % ; min-height : 0 ; gap : .5 rem ;}
. meet-grid . sharing-mode . meet-tile { aspect-ratio : auto ;}
. meet-grid . sharing-mode . meet-tile . stage { order : -1 ; height : 100 % ; width : calc ( 100 % - 176 px ); min-width : 0 ; border-color : #2563eb ;}
. meet-grid . sharing-mode . meet-tile . stage video { object-fit : contain ; background : #000 ;}
. meet-grid . sharing-mode . meet-tile : not ( . stage ) { width : 160 px ; height : 94 px ; flex : 0 0 auto ;}
. meet-grid . sharing-mode . meet-tile . sharing : not ( . stage ) { cursor : pointer ;}
. meet-grid . sharing-mode . meet-tile . sharing : not ( . stage ) :: after { content : "Click to view" ; position : absolute ; left : 0 ; right : 0 ; bottom : 0 ; font-size : .58 rem ; text-align : center ; background : rgba ( 37 , 99 , 235 , .88 ); color : #fff ; padding : 1 px 0 ;}
@ media ( max-width : 760px ) {
. meet-grid . sharing-mode { flex-flow : row wrap ; height : auto ;}
. meet-grid . sharing-mode . meet-tile . stage { width : 100 % ; height : auto ; flex : 1 1 100 % ; min-height : 40 vh ;}
. meet-grid . sharing-mode . meet-tile : not ( . stage ) { width : 46 % ; height : 84 px ;}
}
. meet-bar . meet-ic . on { background : var ( -- blue ); color : #fff ;}
. meet-bar # meetRecBtn . on { background : #dc2626 ; color : #fff ; animation : livePulse 1.6 s infinite ;}
. rec-notice { position : fixed ; top : 14 px ; left : 50 % ; transform : translateX ( -50 % ); z-index : 6500 ; display : inline-flex ; align-items : center ; gap : .4 rem ; background : rgba ( 220 , 38 , 38 , .95 ); color : #fff ; font-size : .82 rem ; font-weight : 700 ; padding : .32 rem .7 rem ; border-radius : 99 px ; box-shadow : 0 6 px 18 px rgba ( 220 , 38 , 38 , .4 );}
. rec-notice . rec-dot { width : 9 px ; height : 9 px ; border-radius : 50 % ; background : #fff ; animation : livePulse 1.4 s infinite ;}
. tx-notice { position : fixed ; top : 14 px ; left : 14 px ; z-index : 6500 ; display : inline-flex ; align-items : center ; gap : .35 rem ; background : rgba ( 37 , 99 , 235 , .95 ); color : #fff ; font-size : .78 rem ; font-weight : 700 ; padding : .3 rem .65 rem ; border-radius : 99 px ; box-shadow : 0 6 px 18 px rgba ( 37 , 99 , 235 , .4 );}
. si-recs { display : flex ; flex-wrap : wrap ; gap : .5 rem ; margin-top : .55 rem ;}
. rec-dl { display : inline-flex ; align-items : center ; gap : .35 rem ; font-size : .78 rem ; font-weight : 700 ; text-decoration : none ; border-radius : 9 px ; padding : .36 rem .7 rem ; border : 1 px solid transparent ; transition : filter .12 s ;}
. rec-dl : hover { filter : brightness ( .96 );}
. rec-dl . vid { background : #eef2ff ; color : #4338ca ; border-color : #c7d2fe ;}
. rec-dl . txt { background : #ecfdf5 ; color : #047857 ; border-color : #a7f3d0 ;}
. rec-dl . rd-dur { background : rgba ( 0 , 0 , 0 , .09 ); border-radius : 6 px ; padding : .04 rem .32 rem ; font-size : .72 rem ; font-weight : 700 ;}
. meet-panel . pp-screen { color : var ( -- blue ); display : inline-flex ;}
. meet-panel . mp-setting { margin : .3 rem ; font-size : .8 rem ;}
. meet-tile video { width : 100 % ; height : 100 % ; object-fit : cover ; background : #0b1220 ;}
. meet-tile . nm { position : absolute ; left : .5 rem ; bottom : .5 rem ; background : rgba ( 0 , 0 , 0 , .55 ); color : #fff ; font-size : .75 rem ; padding : .15 rem .5 rem ; border-radius : 6 px ;}
. meet-tile . meet-av { position : absolute ; inset : 0 ; margin : auto ; width : 84 px ; height : 84 px ; border-radius : 50 % ; display : none ; align-items : center ; justify-content : center ; color : #fff ; font-weight : 700 ; font-size : 1.9 rem ;}
. meet-tile . meet-mute { position : absolute ; left : .5 rem ; top : .5 rem ; width : 26 px ; height : 26 px ; border-radius : 50 % ; background : #dc2626 ; color : #fff ; place-items : center ; box-shadow : 0 1 px 3 px rgba ( 0 , 0 , 0 , .4 );}
. meet-panel { position : absolute ; right : 12 px ; top : 12 px ; bottom : 78 px ; width : 280 px ; max-width : 80 vw ; background : var ( -- card ); border : 1 px solid var ( -- line ); border-radius : 14 px ; box-shadow : 0 14 px 40 px rgba ( 0 , 0 , 0 , .3 ); z-index : 30 ; display : flex ; flex-direction : column ; overflow : hidden ;}
. meet-panel . mp-head { display : flex ; align-items : center ; gap : .5 rem ; padding : .7 rem .8 rem ; border-bottom : 1 px solid var ( -- line ); font-size : .9 rem ;}
. meet-panel . mp-muteall { border : 1 px solid var ( -- line ); background : #fee2e2 ; color : var ( -- red ); border-radius : 8 px ; padding : .3 rem .55 rem ; font-size : .78 rem ; font-weight : 600 ; cursor : pointer ; display : inline-flex ; align-items : center ; gap : .25 rem ;}
. meet-panel . mp-x { border : none ; background : transparent ; color : var ( -- muted ); cursor : pointer ; display : grid ; place-items : center ;}
. meet-panel . mp-tabs { display : flex ; gap : .25 rem ; padding : .4 rem .5 rem 0 ; border-bottom : 1 px solid var ( -- line );}
. meet-panel . mp-tab { flex : 1 ; border : none ; background : transparent ; color : var ( -- muted ); font-size : .8 rem ; font-weight : 600 ; padding : .45 rem .3 rem ; cursor : pointer ; border-bottom : 2 px solid transparent ; display : inline-flex ; align-items : center ; justify-content : center ; gap : .25 rem ;}
. meet-panel . mp-tab . on { color : var ( -- blue ); border-bottom-color : var ( -- blue );}
. meet-panel . mp-list { flex : 1 ; overflow : auto ; padding : .4 rem ;}
. meet-panel . chk { display : flex ; align-items : center ; gap : .5 rem ; padding : .4 rem .5 rem ; border-radius : 8 px ; cursor : pointer ; font-size : .88 rem ;}
. meet-panel . chk : hover { background : #f6f8fb ;}
. meet-panel . chk input { width : 16 px ; height : 16 px ; flex : 0 0 16 px ; accent-color : var ( -- blue ); margin : 0 ;}
. meet-panel . chk . mn { flex : 1 ; min-width : 0 ; white-space : nowrap ; overflow : hidden ; text-overflow : ellipsis ;}
. meet-panel . mp-row { display : flex ; align-items : center ; gap : .5 rem ; padding : .4 rem .5 rem ; border-radius : 8 px ;}
. meet-panel . mp-row . mn { flex : 1 ; min-width : 0 ; font-size : .88 rem ; white-space : nowrap ; overflow : hidden ; text-overflow : ellipsis ;}
. meet-panel . mp-row . pp-mute { color : var ( -- red ); display : inline-flex ;}
. meet-panel . mp-makehost { border : none ; background : var ( -- blue - soft ); color : var ( -- blue ); border-radius : 7 px ; padding : .2 rem .4 rem ; cursor : pointer ; display : grid ; place-items : center ;}
. host-tag { display : inline-flex ; align-items : center ; gap : .2 rem ; font-size : .62 rem ; font-weight : 700 ; background : #fff3cd ; color : #7a5b05 ; padding : .05 rem .35 rem ; border-radius : 99 px ;}
. admin-tag { display : inline-flex ; align-items : center ; gap : .2 rem ; font-size : .6 rem ; font-weight : 700 ; background : #fef3c7 ; color : #92600b ; padding : .05 rem .4 rem ; border-radius : 99 px ; vertical-align : middle ;}
. iconbtn . role { color : var ( -- muted );} . iconbtn . role . is-admin { color : #d4a106 ;} . iconbtn . role : hover { color : #d4a106 ;}
. si-invited { display : inline-flex ; align-items : center ; gap : .3 rem ; font-size : .76 rem ; color : var ( -- muted ); margin-top : .25 rem ;}
. meet-tile . novid . meet-av { display : flex ;}
. meet-tile . novid video { visibility : hidden ;}
. meet-bar { display : flex ; align-items : center ; gap : .6 rem ; padding : .7 rem 1 rem ; background : var ( -- card ); border-top : 1 px solid var ( -- line );}
. meet-bar . code { margin-right : auto ; color : var ( -- muted ); font-size : .85 rem ;}
. meet-bar . code b { color : var ( -- blue ); font-size : 1 rem ; letter-spacing : .06 em ;}
. meet-bar button { border : none ; border-radius : 10 px ; padding : .6 rem 1 rem ; font-weight : 600 ; cursor : pointer ; font-size : .9 rem ; display : inline-flex ; align-items : center ; gap : .35 rem ;}
. meet-bar . tgl { background : #eef1f6 ; color : var ( -- blue );}
. meet-bar . tgl . off { background : #fee2e2 ; color : var ( -- red );}
. meet-bar . leave { background : #dc2626 ; color : #fff ;}
. meet-bar . meet-ic { width : 46 px ; height : 46 px ; padding : 0 ; border-radius : 50 % ; background : #e8edf5 ; color : var ( -- blue ); display : inline-flex ; align-items : center ; justify-content : center ;}
. meet-bar . meet-ic : hover { background : #dbe4f0 ;}
. meet-bar . meet-ic . off { background : #fee2e2 ; color : var ( -- red );}
. meet-bar . meet-ic . leave , . meet-bar . meet-ic . leave . off { background : #dc2626 ; color : #fff ;}
. meet-bar . meet-ic . leave : hover { background : #b91c1c ;}
/* Meetings dashboard (full-width, scrollable) */
. meet-dash { width : 100 % ; height : 100 % ; overflow-y : auto ; padding : 1.4 rem clamp ( 1 rem , 4 vw , 2.4 rem ) 2 rem ; background : var ( -- bg );}
. md-top { display : flex ; align-items : flex-start ; justify-content : space-between ; gap : 1 rem ; flex-wrap : wrap ; margin-bottom : .5 rem ;}
. md-title h1 { margin : 0 ; font-size : 1.5 rem ; color : var ( -- ink );}
. md-title p { margin : .3 rem 0 0 ; color : var ( -- muted ); font-size : .9 rem ; max-width : 520 px ;}
. md-actions { display : flex ; align-items : stretch ; gap : .5 rem ; flex-wrap : wrap ;}
. md-join { display : flex ; gap : .4 rem ; flex : 1 1 240 px ; min-width : 0 ;}
. md-join input { flex : 1 1 auto ; min-width : 0 ; text-align : center ; letter-spacing : .18 rem ; font-size : 1.05 rem ; font-weight : 600 ; border : 1 px solid var ( -- line ); border-radius : 11 px ; padding : .7 rem .6 rem ; background : var ( -- card ); color : var ( -- ink );}
. md-join . btn { flex : 0 0 auto ; padding : .55 rem 1.1 rem ; font-size : .9 rem ;}
. md-actions > . btn { white-space : nowrap ; padding : .7 rem 1.05 rem ;}
. btn . primary { background : var ( -- blue ); color : #fff ;}
. btn . primary : hover { filter : brightness ( 1.06 );}
. meet-dash . hint { min-height : 0 ; color : var ( -- red ); font-size : .82 rem ; margin : .2 rem 0 ;}
. sched-empty { color : var ( -- muted ); font-size : .92 rem ; background : var ( -- card ); border : 1 px dashed var ( -- line ); border-radius : 12 px ; padding : 1.6 rem ; text-align : center ; margin-top : 1 rem ;}
/* chat: reply + emoji */
. convo { position : relative ;}
. bubble { position : relative ;}
. bubble . quote { border-left : 3 px solid var ( -- line ); padding : .22 rem .5 rem ; margin-bottom : .3 rem ; font-size : .78 rem ; border-radius : 6 px ; color : #33384a ;}
. reply-btn { position : absolute ; top : -9 px ; right : 6 px ; background : var ( -- card ); color : var ( -- blue ); border : 1 px solid var ( -- line ); border-radius : 50 % ; width : 22 px ; height : 22 px ; font-size : .8 rem ; line-height : 1 ; cursor : pointer ; opacity : 0 ; transition : opacity .12 s ; box-shadow : 0 1 px 3 px rgba ( 0 , 0 , 0 , .12 );}
. bubble : hover . reply-btn { opacity : 1 ;}
. reply-bar { display : flex ; align-items : center ; gap : .5 rem ; padding : .45 rem .8 rem ; border-top : 1 px solid var ( -- line ); background : #eef3fb ; font-size : .82 rem ; color : var ( -- muted );}
. reply-bar b { color : var ( -- ink );}
. reply-bar . rx { margin-left : auto ; cursor : pointer ; font-size : 1 rem ;}
. reply-bar . rx : hover { color : var ( -- red );}
. reply-btn , . react-btn { display : grid ; place-items : center ;}
. emoji-pop { position : absolute ; bottom : 64 px ; left : 12 px ; width : 330 px ; height : 300 px ; background : #fff ; border : 1 px solid var ( -- line ); border-radius : 12 px ; box-shadow : 0 10 px 28 px rgba ( 0 , 0 , 0 , .18 ); z-index : 50 ; display : flex ; flex-direction : column ; overflow : hidden ;}
. mention-pop { position : absolute ; left : 12 px ; right : 12 px ; bottom : 64 px ; max-height : 240 px ; overflow : auto ; background : var ( -- card ); border : 1 px solid var ( -- line ); border-radius : 12 px ; box-shadow : 0 10 px 28 px rgba ( 0 , 0 , 0 , .18 ); z-index : 60 ; padding : .3 rem ;}
. mention-pop . mrow { display : flex ; align-items : center ; gap : .55 rem ; padding : .4 rem .55 rem ; border-radius : 8 px ; cursor : pointer ;}
. mention-pop . mrow . sel { background : var ( -- blue - soft );}
. mention-pop . mn { font-weight : 600 ; color : var ( -- ink );}
. mention-pop . sub { color : var ( -- muted ); font-size : .78 rem ; margin-left : auto ;}
. mention { color : var ( -- blue ); background : var ( -- blue - soft ); border-radius : 5 px ; padding : 0 .18 rem ; font-weight : 600 ;}
. bubble . mine . mention { color : #fff ; background : rgba ( 255 , 255 , 255 , .22 );}
. bubble . mention-me { box-shadow : 0 1 px 2 px rgba ( 20 , 30 , 60 , .06 ), inset 3 px 0 0 var ( -- brand );}
. bubble . has-poll { max-width : 88 % ;}
. poll { margin-top : .5 rem ; display : flex ; flex-direction : column ; gap : .35 rem ; min-width : 240 px ;}
. poll-q { font-size : .72 rem ; font-weight : 700 ; letter-spacing : .03 em ; text-transform : uppercase ; opacity : .7 ; display : flex ; align-items : center ; gap : .3 rem ;}
. poll-opt { position : relative ; overflow : hidden ; display : flex ; align-items : center ; justify-content : space-between ; gap : .5 rem ; border : 1 px solid var ( -- line ); background : var ( -- card ); color : var ( -- ink ); border-radius : 9 px ; padding : .45 rem .6 rem ; font-size : .86 rem ; cursor : pointer ; text-align : left ;}
. poll-opt : hover : not ([ disabled ]) { border-color : var ( -- blue );}
. poll-opt [ disabled ] { cursor : default ;}
. poll-opt . mine { border-color : var ( -- blue ); background : var ( -- blue - soft );}
. poll-opt . po-bar { position : absolute ; left : 0 ; top : 0 ; bottom : 0 ; background : var ( -- blue - soft ); z-index : 0 ; transition : width .25 s ;}
. poll-opt . mine . po-bar { background : #d8e4f8 ;}
. poll-opt . po-txt { position : relative ; z-index : 1 ; font-weight : 600 ; display : flex ; align-items : center ; gap : .3 rem ;}
. poll-opt . po-pct { position : relative ; z-index : 1 ; color : var ( -- muted ); font-size : .78 rem ; flex : 0 0 auto ;}
. poll-foot { font-size : .76 rem ; color : var ( -- muted ); margin-top : .1 rem ;}
. poll-foot . poll-close { color : var ( -- red ); cursor : pointer ; font-weight : 600 ;}
. bubble . mine . poll-opt { color : var ( -- ink );}
. poll-opt-row { display : flex ; align-items : center ; gap : .4 rem ; margin-bottom : .4 rem ;}
. emoji-tabs { display : flex ; border-bottom : 1 px solid var ( -- line ); flex : 0 0 auto ;}
. emoji-tabs button { flex : 1 ; border : none ; background : transparent ; font-size : 1.05 rem ; padding : .35 rem 0 ; cursor : pointer ; opacity : .55 ;}
. emoji-tabs button . active { opacity : 1 ; background : var ( -- blue - soft );}
. emoji-grid { flex : 1 ; overflow-y : auto ; display : grid ; grid-template-columns : repeat ( 8 , 1 fr ); gap : .1 rem ; padding : .4 rem ; align-content : start ;}
. emoji-grid button { border : none ; background : transparent ; font-size : 1.25 rem ; cursor : pointer ; padding : .2 rem ; border-radius : 6 px ; line-height : 1.15 ;}
. emoji-grid button : hover { background : var ( -- blue - soft );}
. react-btn { position : absolute ; top : -9 px ; right : 32 px ; background : var ( -- card ); color : var ( -- blue ); border : 1 px solid var ( -- line ); border-radius : 50 % ; width : 22 px ; height : 22 px ; font-size : .8 rem ; line-height : 1 ; cursor : pointer ; opacity : 0 ; transition : opacity .12 s ; box-shadow : 0 1 px 3 px rgba ( 0 , 0 , 0 , .12 );}
. bubble : hover . react-btn { opacity : 1 ;}
. reacts { display : flex ; flex-wrap : wrap ; gap : .2 rem ; margin-top : .3 rem ;}
. react-chip { border : 1 px solid var ( -- line ); background : var ( -- card ); color : var ( -- ink ); border-radius : 999 px ; font-size : .74 rem ; padding : .05 rem .4 rem ; cursor : pointer ; line-height : 1.5 ;}
. react-chip . mine { background : var ( -- blue - soft ); border-color : #c7d6f0 ; color : var ( -- blue );}
/* attachments */
. attach-btn , . emoji-btn { border : none ; background : transparent ; cursor : pointer ; padding : .35 rem ; color : var ( -- muted ); display : grid ; place-items : center ; border-radius : 8 px ;}
. attach-btn : hover , . emoji-btn : hover { color : var ( -- blue ); background : var ( -- blue - soft );}
. composer-row . sendbtn { flex : 0 0 auto ; align-self : flex-end ; width : 34 px ; height : 34 px ; margin : 1 px ; border : none ; background : var ( -- brand ); color : var ( -- ink ); cursor : pointer ; border-radius : 50 % ; display : grid ; place-items : center ;}
. composer-row . sendbtn : hover { background : var ( -- brand - d );}
. att-img { max-width : 240 px ; max-height : 240 px ; border-radius : 8 px ; display : block ; margin : .15 rem 0 ;}
. att-file { display : inline-flex ; align-items : center ; gap : .4 rem ; background : rgba ( 0 , 0 , 0 , .06 ); border : 1 px solid var ( -- line ); border-radius : 8 px ; padding : .4 rem .6 rem ; color : inherit ; text-decoration : none ; font-size : .85 rem ; margin : .15 rem 0 ; max-width : 240 px ;}
. att-file span { white-space : nowrap ; overflow : hidden ; text-overflow : ellipsis ;}
. bubble . mine . att-file { background : rgba ( 255 , 255 , 255 , .18 ); border-color : rgba ( 255 , 255 , 255 , .3 );}
. att-file . att-sz { opacity : .65 ; font-size : .75 rem ; flex : 0 0 auto ;}
/* groups */
. bubble . sender { font-size : .7 rem ; color : var ( -- blue ); font-weight : 700 ; margin-bottom : .12 rem ; display : flex ; align-items : center ; gap : .35 rem ;}
. bubble . sender . snd-av { position : relative ; width : 18 px ; height : 18 px ; flex : 0 0 18 px ; border-radius : 50 % ; display : grid ; place-items : center ; color : #fff ; font-weight : 700 ; font-size : .55 rem ; overflow : hidden ;}
. bubble . sender . snd-av img { position : absolute ; inset : 0 ; width : 100 % ; height : 100 % ; object-fit : cover ;}
. avatar . grp { border-radius : 12 px ; font-size : 1.15 rem ;}
. modal-ov { position : fixed ; inset : 0 ; background : rgba ( 15 , 23 , 42 , .45 ); display : flex ; align-items : center ; justify-content : center ; z-index : 9800 ; padding : 1 rem ;}
. modal { background : #fff ; border-radius : 16 px ; padding : 1.4 rem ; max-width : 380 px ; width : 100 % ; box-shadow : 0 18 px 44 px rgba ( 0 , 0 , 0 , .3 ); max-height : calc ( 100 vh - 2 rem ); max-height : calc ( 100 dvh - 2 rem ); overflow-y : auto ;}
. modal h3 { margin : 0 0 .8 rem ; color : var ( -- blue );}
. modal input # grpName , . modal input # giName { width : 100 % ; padding : .6 rem .7 rem ; border : 2 px solid var ( -- line ); border-radius : 10 px ; background : #fbfcfe ; font-size : .95 rem ; margin-bottom : .8 rem ;}
. modal input # grpName : focus , . modal input # giName : focus { outline : none ; border-color : var ( -- brand );}
. avatar . mcount { position : absolute ; right : -3 px ; bottom : -3 px ; min-width : 16 px ; height : 16 px ; border-radius : 99 px ; background : var ( -- blue ); color : #fff ; font-size : .6 rem ; font-weight : 800 ; display : grid ; place-items : center ; border : 2 px solid var ( -- card ); padding : 0 .15 rem ; z-index : 1 ;}
. convo-titlewrap { flex : 1 ; min-width : 0 ;}
. convo-info { border : none ; background : var ( -- blue - soft ); color : var ( -- blue ); width : 32 px ; height : 32 px ; border-radius : 9 px ; font-size : 1 rem ; cursor : pointer ; flex : 0 0 auto ; display : grid ; place-items : center ;}
. convo-info : hover { background : #dbe4f0 ;}
. convo-call { border : none ; background : var ( -- blue - soft ); color : var ( -- blue ); height : 32 px ; border-radius : 9 px ; cursor : pointer ; flex : 0 0 auto ; display : inline-flex ; align-items : center ; gap : .3 rem ; padding : 0 .6 rem ; font-weight : 600 ;}
. convo-call : hover { background : #dbe4f0 ;}
. convo-call . joinable { background : #dcfce7 ; color : #15803d ;}
. convo-call . joinable : hover { background : #bbf7d0 ;}
. convo-call span { font-size : .84 rem ;}
. call-on { display : inline-flex ; align-items : center ; gap : .3 rem ; color : #15803d ; font-weight : 600 ;}
. call-invite { position : fixed ; right : 18 px ; bottom : 18 px ; z-index : 6000 ; display : flex ; align-items : center ; gap : .7 rem ; background : #fff ; border : 1 px solid var ( -- line ); border-left : 4 px solid #15803d ; border-radius : 14 px ; padding : .7 rem .9 rem ; box-shadow : 0 12 px 30 px rgba ( 20 , 30 , 60 , .25 ); max-width : 340 px ;}
. call-invite . ci-ico { width : 38 px ; height : 38 px ; border-radius : 50 % ; background : #dcfce7 ; color : #15803d ; display : grid ; place-items : center ; flex : 0 0 auto ;}
. call-invite . ci-txt { font-size : .88 rem ; color : var ( -- ink ); line-height : 1.25 ;}
. call-invite . ci-join { border : none ; background : #15803d ; color : #fff ; border-radius : 9 px ; padding : .45 rem .7 rem ; font-weight : 700 ; cursor : pointer ; display : inline-flex ; align-items : center ; gap : .3 rem ; flex : 0 0 auto ;}
. call-invite . ci-join : hover { background : #13703a ;}
. call-invite . ci-x { border : none ; background : transparent ; color : var ( -- muted ); cursor : pointer ; display : grid ; place-items : center ; flex : 0 0 auto ;}
. call-invite . ci-x : hover { color : var ( -- red );}
. call-invite . ci-decline { border : none ; background : #fee2e2 ; color : #b91c1c ; border-radius : 9 px ; padding : .45 rem .7 rem ; font-weight : 700 ; cursor : pointer ; display : inline-flex ; align-items : center ; gap : .3 rem ; flex : 0 0 auto ;}
. call-invite . ci-decline : hover { background : #fecaca ;}
. convo-info : hover { background : #dbe6fb ;}
/* group info (Slack-style) */
. modal . gi { max-width : 400 px ;}
. gi-head { display : flex ; align-items : center ; gap : .7 rem ; margin-bottom : .6 rem ;}
. gi-name { flex : 1 ; min-width : 0 ;}
. gi-name input { width : 100 % ; border : none ; border-bottom : 2 px solid transparent ; font-size : 1.1 rem ; font-weight : 700 ; color : var ( -- ink ); padding : .1 rem 0 ; background : transparent ;}
. gi-name input : focus { outline : none ; border-bottom-color : var ( -- brand );}
. gi-sub { font-size : .78 rem ; color : var ( -- muted );}
. gi-name-row { display : flex ; align-items : center ; gap : .4 rem ;}
. gi-title { font-size : 1.1 rem ; font-weight : 700 ; color : var ( -- ink ); white-space : nowrap ; overflow : hidden ; text-overflow : ellipsis ;}
. iconbtn . sm { width : 24 px ; height : 24 px ;}
. gi-edit { display : flex ; align-items : center ; gap : .3 rem ;}
. gi-edit input { flex : 1 ; min-width : 0 ; border : 2 px solid var ( -- line ); border-radius : 8 px ; padding : .3 rem .5 rem ; font-size : 1 rem ; font-weight : 600 ; background : #fbfcfe ; color : var ( -- ink );}
. gi-edit input : focus { outline : none ; border-color : var ( -- brand );}
. gi-actions { display : flex ; gap : .6 rem ; margin : .2 rem 0 .7 rem ;}
. gi-search { position : relative ; margin-bottom : .5 rem ;}
. gi-search input { width : 100 % ; border : 1 px solid var ( -- line ); border-radius : 9 px ; padding : .45 rem 2 rem .45 rem .6 rem ; font-size : .88 rem ; background : #fbfcfe ; color : var ( -- ink ); box-sizing : border-box ;}
. gi-search input : focus { outline : none ; border-color : var ( -- blue );}
. gi-noresult { text-align : center ; color : var ( -- muted ); font-size : .84 rem ; padding : .7 rem 0 ;}
. gi-created { font-size : .78 rem ; color : var ( -- muted ); margin : 0 0 .7 rem ; line-height : 1.45 ;}
. gi-created svg { vertical-align : -2 px ; margin-right : .25 rem ;}
. gi-created b { color : var ( -- ink ); font-weight : 600 ;}
. gi-setting { display : flex ; align-items : center ; justify-content : space-between ; gap : .5 rem ; font-size : .84 rem ; color : var ( -- ink ); background : #f6f8fb ; border : 1 px solid var ( -- line ); border-radius : 9 px ; padding : .5 rem .7 rem ; margin : 0 0 .7 rem ; cursor : pointer ;}
. gi-setting span { display : inline-flex ; align-items : center ; gap : .4 rem ;}
. switch { position : relative ; display : inline-block ; width : 40 px ; height : 22 px ; flex : 0 0 auto ;}
. switch input { opacity : 0 ; width : 0 ; height : 0 ; position : absolute ; margin : 0 ;}
. switch . slider { position : absolute ; inset : 0 ; background : #cbd2dd ; border-radius : 99 px ; transition : background .15 s ; cursor : pointer ;}
. switch . slider :: before { content : "" ; position : absolute ; width : 18 px ; height : 18 px ; left : 2 px ; top : 2 px ; background : #fff ; border-radius : 50 % ; transition : transform .15 s ; box-shadow : 0 1 px 2 px rgba ( 0 , 0 , 0 , .25 );}
. switch input : checked + . slider { background : var ( -- blue );}
. switch input : checked + . slider :: before { transform : translateX ( 18 px );}
. seenby { display : block ; margin-top : .2 rem ; border : none ; background : transparent ; color : var ( -- muted ); font-size : .68 rem ; cursor : pointer ; padding : 0 ; display : inline-flex ; align-items : center ; gap : .25 rem ;}
. seenby : hover { color : var ( -- blue );}
. bubble . mine . seenby { color : rgba ( 255 , 255 , 255 , .8 );}
. bubble . mine . seenby : hover { color : #fff ;}
. gi-act { flex : 1 ; border : 1 px solid var ( -- line ); background : var ( -- card ); border-radius : 10 px ; padding : .6 rem ; font-weight : 600 ; color : var ( -- blue ); cursor : pointer ; display : flex ; align-items : center ; justify-content : center ; gap : .4 rem ;}
. gi-act : hover { background : var ( -- blue - soft ); border-color : #c7d6f0 ;}
. gi-open { cursor : pointer ;}
. sched-wrap { width : 100 % ; margin : 1.2 rem 0 0 ; text-align : left ;}
. sched-sec { margin-bottom : 1.4 rem ;}
. sched-h { font-size : .74 rem ; text-transform : uppercase ; letter-spacing : .05 em ; color : var ( -- muted ); margin : 0 0 .55 rem ; font-weight : 700 ;}
. sched-list { display : grid ; grid-template-columns : repeat ( auto - fill , minmax ( 330 px , 1 fr )); gap : .7 rem ;}
. sched-item { display : flex ; align-items : flex-start ; gap : .6 rem ; background : var ( -- card ); border : 1 px solid var ( -- line ); border-radius : 12 px ; padding : .8 rem .9 rem ;}
. sched-item . live { border-color : #34d399 ; background : #f0fdf4 ;}
. sched-item . cancelled { opacity : .7 ;}
. sched-item . cancelled . si-title { text-decoration : line-through ; text-decoration-color : var ( -- muted );}
. cancel-tag { font-size : .62 rem ; font-weight : 700 ; background : #fee2e2 ; color : #b91c1c ; padding : .05 rem .4 rem ; border-radius : 99 px ; text-decoration : none ;}
/* Schedule form: custom date/time pickers, recurring day chips, inline error highlight */
. sch-row { display : flex ; gap : .6 rem ; flex-wrap : wrap ;}
. sch-row > div { flex : 1 1 140 px ; min-width : 0 ;}
. picker-field { position : relative ;}
. pick-btn { display : flex ; align-items : center ; gap : .45 rem ; width : 100 % ; text-align : left ; cursor : pointer ; color : var ( -- ink );}
. pick-btn svg { color : var ( -- blue ); flex : 0 0 auto ;}
. pick-pop { position : absolute ; z-index : 60 ; top : 100 % ; left : 0 ; margin-top : .3 rem ; background : #fff ; border : 1 px solid var ( -- line ); border-radius : 14 px ; box-shadow : 0 14 px 36 px rgba ( 20 , 30 , 60 , .22 ); padding : .6 rem ; width : 280 px ; max-width : 84 vw ;}
. pick-pop . time-pop { display : grid ; grid-template-columns : repeat ( 3 , 1 fr ); gap : .35 rem ; max-height : 240 px ; overflow : auto ; width : 240 px ;}
. pick-pop . hidden { display : none !important ;} /* must beat .pick-pop.time-pop's display:grid */
. cal-head { display : flex ; align-items : center ; justify-content : space-between ; margin-bottom : .4 rem ; font-size : .92 rem ; color : var ( -- ink );}
. cal-nav { border : none ; background : var ( -- blue - soft ); color : var ( -- blue ); width : 30 px ; height : 30 px ; border-radius : 8 px ; cursor : pointer ; display : grid ; place-items : center ;}
. cal-nav : disabled { opacity : .35 ; cursor : default ;}
. cal-grid { display : grid ; grid-template-columns : repeat ( 7 , 1 fr ); gap : 2 px ;}
. cal-dow { text-align : center ; font-size : .66 rem ; color : var ( -- muted ); font-weight : 700 ; padding : .2 rem 0 ;}
. cal-day { border : none ; background : transparent ; color : var ( -- ink ); height : 34 px ; border-radius : 9 px ; cursor : pointer ; font-size : .84 rem ;}
. cal-day : hover : not ( : disabled ) { background : var ( -- blue - soft );}
. cal-day . today { outline : 1 px solid var ( -- blue );}
. cal-day . sel { background : var ( -- blue ); color : #fff ; font-weight : 700 ;}
. cal-day : disabled { color : #cbd2dc ; cursor : default ;}
. time-chip { border : 1 px solid var ( -- line ); background : var ( -- card ); color : var ( -- ink ); border-radius : 8 px ; padding : .4 rem .2 rem ; font-size : .78 rem ; cursor : pointer ;}
. time-chip : hover { background : var ( -- blue - soft );}
. time-chip . sel { background : var ( -- blue ); color : #fff ; border-color : var ( -- blue ); font-weight : 700 ;}
. switch-row { display : flex ; align-items : center ; justify-content : space-between ; background : #f6f8fb ; border : 1 px solid var ( -- line ); border-radius : 10 px ; padding : .55 rem .8 rem ; margin : .8 rem 0 .4 rem ;}
. switch-row > span : first-child { display : flex ; align-items : center ; gap : .45 rem ; font-size : .88 rem ; color : var ( -- ink );}
. sch-days { display : flex ; flex-wrap : wrap ; align-items : center ; gap : .4 rem ; margin : .3 rem 0 .2 rem ;}
. day-chip { width : 34 px ; height : 34 px ; border : 1 px solid var ( -- line ); background : var ( -- card ); color : var ( -- ink ); border-radius : 50 % ; font-size : .78 rem ; font-weight : 700 ; cursor : pointer ; display : grid ; place-items : center ;}
. day-chip . on { background : var ( -- blue ); color : #fff ; border-color : var ( -- blue );}
. day-all { margin-left : auto ; border : 1 px solid var ( -- line ); background : var ( -- card ); color : var ( -- blue ); border-radius : 99 px ; padding : .32 rem .7 rem ; font-size : .78 rem ; font-weight : 700 ; cursor : pointer ;}
. si-actions . iconbtn { width : 28 px ; height : 28 px ; border-radius : 7 px ;}
. si-actions . iconbtn . edit { color : var ( -- blue );} . si-actions . iconbtn . edit : hover { background : var ( -- blue - soft );}
. si-actions . iconbtn . cancel-ic { color : #b91c1c ;} . si-actions . iconbtn . cancel-ic : hover { background : #fee2e2 ;}
. finput . field-err , . pick-btn . field-err { border-color : #dc2626 !important ; box-shadow : 0 0 0 2 px rgba ( 220 , 38 , 38 , .18 );}
. si-main { flex : 1 ; min-width : 0 ;}
. si-title { font-weight : 700 ; color : var ( -- ink ); display : flex ; align-items : center ; gap : .5 rem ;}
. si-meta { font-size : .8 rem ; color : var ( -- muted ); margin-top : .15 rem ;}
. si-desc { font-size : .84 rem ; color : var ( -- ink ); margin-top : .35 rem ; opacity : .85 ;}
. livedot { color : #059669 ; font-size : .72 rem ; font-weight : 700 ;}
. si-actions { display : flex ; align-items : center ; gap : .25 rem ; flex : 0 0 auto ;}
. btn . sm { padding : .28 rem .7 rem ; font-size : .8 rem ; border-radius : 7 px ; line-height : 1.2 ;}
. btn . join { background : var ( -- blue ); color : #fff ; border : none ; cursor : pointer ;}
. btn . sm . cancel { background : #fee2e2 ; color : #b91c1c ; border : 1 px solid #fecaca ;}
. btn . sm . cancel : hover { background : #fecaca ;}
. modal . sched { max-width : 440 px ;}
. flbl { display : block ; font-size : .78 rem ; font-weight : 600 ; color : var ( -- ink ); margin : .6 rem 0 .25 rem ;}
. flbl . opt { color : var ( -- muted ); font-weight : 400 ;}
. finput { width : 100 % ; border : 1 px solid var ( -- line ); border-radius : 9 px ; padding : .55 rem .65 rem ; font-size : .92 rem ; font-family : inherit ; background : #fbfcfe ; color : var ( -- ink ); box-sizing : border-box ;}
. finput : focus { outline : none ; border-color : var ( -- blue );}
. iconbtn { border : none ; background : transparent ; color : var ( -- muted ); cursor : pointer ; width : 30 px ; height : 30 px ; border-radius : 8 px ; display : grid ; place-items : center ; flex : 0 0 auto ;}
. iconbtn : hover { background : #f1f5f9 ; color : var ( -- blue );}
. iconbtn . rm : hover { color : var ( -- red ); background : #fee2e2 ;}
. gi-save { width : 100 % ; background : var ( -- brand ); color : var ( -- ink ); margin-bottom : .6 rem ;}
. gi-sec-h { display : flex ; align-items : center ; justify-content : space-between ; font-size : .74 rem ; text-transform : uppercase ; letter-spacing : .05 em ; color : var ( -- muted ); margin : .5 rem 0 .3 rem ;}
. gobtn { border : none ; border-radius : 11 px ; padding : .7 rem 1 rem ; font-weight : 700 ; font-size : .92 rem ; cursor : pointer ; background : var ( -- blue ); color : #fff ; transition : filter .12 s ;}
. gobtn : hover { filter : brightness ( 1.08 );}
. linkbtn { border : 1 px solid var ( -- line ); background : var ( -- card ); color : var ( -- blue ); cursor : pointer ; font-size : .8 rem ; font-weight : 600 ; display : inline-flex ; align-items : center ; gap : .35 rem ; padding : .35 rem .7 rem ; border-radius : 8 px ; text-transform : none ; letter-spacing : 0 ;}
. linkbtn : hover { background : var ( -- blue - soft ); border-color : #c7d6f0 ;}
. gi-list { display : flex ; flex-direction : column ; gap : .1 rem ; max-height : 240 px ; overflow-y : auto ;}
. mrow { display : flex ; align-items : center ; gap : .6 rem ; padding : .35 rem .3 rem ; border-radius : 8 px ; font-size : .92 rem ;}
. mrow : hover { background : #f6f8fb ;}
. mrow . mn { flex : 1 ; min-width : 0 ; white-space : nowrap ; overflow : hidden ; text-overflow : ellipsis ;}
. mrow . iconbtn { opacity : 0 ;}
. mrow : hover . iconbtn { opacity : 1 ;}
. mini-av { position : relative ; overflow : hidden ; width : 30 px ; height : 30 px ; flex : 0 0 30 px ; border-radius : 50 % ; display : grid ; place-items : center ; color : #fff ; font-weight : 700 ; font-size : .72 rem ;}
. mini-av . av-img { position : absolute ; inset : 0 ; width : 100 % ; height : 100 % ; object-fit : cover ;}
. gi-photo { position : relative ; border : none ; background : transparent ; padding : 0 ; cursor : pointer ; flex : 0 0 auto ;}
. gi-photo . avatar . grp { flex : 0 0 46 px ;}
. gi-photo-cam { position : absolute ; right : -4 px ; bottom : -4 px ; width : 22 px ; height : 22 px ; border-radius : 50 % ; background : var ( -- blue ); color : #fff ; display : grid ; place-items : center ; border : 2 px solid var ( -- card ); box-shadow : 0 1 px 3 px rgba ( 0 , 0 , 0 , .22 );}
. gi-photo-cam . ic { width : 12 px ; height : 12 px ;}
. gi-photo : hover . gi-photo-cam { filter : brightness ( 1.12 );}
. youtag { font-size : .62 rem ; background : var ( -- blue - soft ); color : var ( -- blue ); padding : .05 rem .35 rem ; border-radius : 99 px ; margin-left : .3 rem ; vertical-align : middle ; text-transform : uppercase ; letter-spacing : .03 em ;}
. gi-add { margin-top : .4 rem ; border : 1 px solid var ( -- line ); border-radius : 10 px ; padding : .5 rem ;}
. gi-add . chk { display : flex ; align-items : center ; gap : .5 rem ; padding : .25 rem ; border-radius : 6 px ; cursor : pointer ;}
. gi-add . chk : hover { background : #f6f8fb ;}
. gi-leave { width : 100 % ; margin-top : .9 rem ; border : 1 px solid #fecaca ; background : #fff ; color : var ( -- red ); border-radius : 10 px ; padding : .55 rem ; font-weight : 600 ; cursor : pointer ; display : flex ; align-items : center ; justify-content : center ; gap : .4 rem ;}
. gi-leave : hover { background : #fee2e2 ;}
. grp-members { max-height : 220 px ; overflow-y : auto ; border : 1 px solid var ( -- line ); border-radius : 10 px ; padding : .5 rem .6 rem ; display : flex ; flex-direction : column ; gap : .4 rem ;}
. modal . chk { display : flex ; align-items : center ; gap : .5 rem ; font-size : .9 rem ; cursor : pointer ;}
. modal . chk input { width : 16 px ; height : 16 px ; accent-color : var ( -- blue ); margin : 0 ;}
. modal-actions { display : flex ; gap : .6 rem ; margin-top : 1 rem ;}
. modal-actions . gobtn { flex : 1 ; border : none ; border-radius : 10 px ; padding : .6 rem ; font-weight : 700 ; cursor : pointer ;}
2026-06-12 00:40:07 +05:30
/* ---- Login (shown on /home when logged out) ---- */
. authwrap { flex : 1 1 auto ; display : none ; align-items : center ; justify-content : center ; padding : 1.5 rem ; min-height : 0 ;}
. authcard { background : var ( -- card ); border : 1 px solid var ( -- line ); border-radius : 16 px ; padding : 2 rem ; max-width : 400 px ; width : 100 % ; box-shadow : 0 10 px 30 px rgba ( 20 , 30 , 60 , .08 );}
. authcard h1 { font-size : 1.3 rem ; color : var ( -- blue ); margin : 0 0 .3 rem ; text-align : center ;}
. authcard . sub { color : var ( -- muted ); font-size : .9 rem ; text-align : center ; margin-bottom : 1.2 rem ;}
. authtabs { display : flex ; gap : .5 rem ; margin-bottom : 1.1 rem ;}
. authtabs button { flex : 1 ; background : #eef1f6 ; color : var ( -- muted ); font-weight : 600 ; border : none ; border-radius : 9 px ; padding : .5 rem ; cursor : pointer ; font-size : .9 rem ;}
. authtabs button . active { background : var ( -- blue ); color : #fff ;}
. authcard . lbl { display : block ; font-size : .74 rem ; color : var ( -- muted ); text-transform : uppercase ; letter-spacing : .06 em ; margin : .7 rem 0 .15 rem ;}
. authcard input { width : 100 % ; padding : .6 rem .7 rem ; border-radius : 10 px ; border : 2 px solid var ( -- line ); background : #fbfcfe ; color : var ( -- ink ); font-size : .92 rem ;}
. authcard input : focus { outline : none ; border-color : var ( -- brand );}
. authcard . gobtn { width : 100 % ; margin-top : 1 rem ; padding : .7 rem ; background : var ( -- brand ); color : var ( -- ink ); border : none ; border-radius : 10 px ; font-weight : 700 ; cursor : pointer ; font-size : .95 rem ;}
. authcard . gobtn : hover { background : var ( -- brand - d );}
. authcard . pwwrap { position : relative ;} . authcard . pwwrap input { padding-right : 2.6 rem ;}
. authcard . eye { position : absolute ; right : .35 rem ; top : 50 % ; transform : translateY ( -50 % ); background : none ; border : none ; padding : .3 rem ; width : auto ; color : var ( -- muted ); display : inline-flex ; align-items : center ; cursor : pointer ;}
. authcard . eye : hover { color : var ( -- blue );}
. formerr { color : #b91c1c ; font-weight : 600 ; font-size : .88 rem ; margin : .7 rem 0 0 ; min-height : 1 em ;}
. formerr . show { display : flex ; align-items : center ; gap : .5 rem ; background : #fee2e2 ; border : 1 px solid #fca5a5 ; border-radius : 9 px ; padding : .6 rem .75 rem ; animation : errShake .35 s ;}
. formerr . show :: before { content : "⚠" ; font-size : 1 rem ;}
@ keyframes errShake { 0 %, 100 % { transform : translateX ( 0 )} 20 %, 60 % { transform : translateX ( -5 px )} 40 %, 80 % { transform : translateX ( 5 px )}}
. hidden { display : none ;}
/* ---- Loading / toast ---- */
. loading { position : fixed ; inset : 0 ; display : grid ; place-items : center ; background : var ( -- bg ); z-index : 9000 ; color : var ( -- muted ); font-size : .9 rem ;}
. toast { position : fixed ; left : 50 % ; bottom : 1.6 rem ; transform : translateX ( -50 % ) translateY ( 1 rem ); background : var ( -- blue ); color : #fff ; padding : .7 rem 1.2 rem ; border-radius : 10 px ; font-size : .88 rem ; box-shadow : 0 10 px 28 px rgba ( 0 , 0 , 0 , .22 ); opacity : 0 ; pointer-events : none ; transition : opacity .2 s , transform .2 s ; z-index : 9500 ;}
. toast . show { opacity : 1 ; transform : translateX ( -50 % ) translateY ( 0 );}
2026-06-23 16:15:29 +05:30
/* Hamburger menu button (header) */
. navtoggle { background : transparent ; border : none ; color : #fff ; cursor : pointer ; display : grid ; place-items : center ; width : 38 px ; height : 38 px ; border-radius : 9 px ;}
. navtoggle : hover { background : rgba ( 255 , 255 , 255 , .14 );}
/* Desktop: collapse the rail to enlarge content */
body . rail-hidden . rail { display : none ;}
. rail-backdrop { display : none ; position : fixed ; inset : 0 ; background : rgba ( 0 , 0 , 0 , .45 ); z-index : 1100 ;}
@ media ( max-width : 900px ) {
. chatcol { width : 280 px ; flex : 0 0 280 px ;}
}
/* ---- Mobile / tablet ---- */
2026-06-12 00:40:07 +05:30
@ media ( max-width : 760px ) {
2026-06-23 16:15:29 +05:30
header { padding : .5 rem .8 rem ;}
. brand { font-size : .98 rem ;}
. navtoggle { display : none ;} . rail-backdrop { display : none !important ;} /* bottom nav replaces the drawer */
/* App-style bottom navigation bar */
. rail { position : fixed ; left : 0 ; right : 0 ; bottom : 0 ; top : auto ; width : auto ; height : 60 px ; flex-direction : row ; justify-content : space-around ; align-items : center ; gap : 0 ; padding : 0 .3 rem env ( safe - area - inset - bottom , 0 ) .3 rem ; border-right : none ; border-top : 1 px solid var ( -- line ); box-shadow : 0 -3 px 14 px rgba ( 20 , 30 , 60 , .08 ); transform : none !important ; z-index : 1200 ;}
. rail . rail-spacer { display : none ;}
. railbtn { display : flex ; flex-direction : column ; justify-content : center ; gap : 2 px ; width : 62 px ; height : 50 px ; border-radius : 12 px ;}
. railbtn . rlabel { display : block ; font-size : .66 rem ; font-weight : 700 ; line-height : 1 ;}
. railbtn svg { width : 20 px ; height : 20 px ;}
. railbtn :: after , . railbtn :: before { display : none ;}
. railbtn : not ( . active ) { color : #334155 ; background : transparent ;}
. railbtn . active { background : var ( -- blue - soft ); color : var ( -- blue );}
/* Meeting room code: always fully visible, full-width, easy to read */
. meet-bar . code { flex : 1 1 100 % ; font-size : .9 rem ; white-space : normal ; word-break : break-word ; text-align : center ; background : var ( -- blue - soft ); border-radius : 8 px ; padding : .35 rem .5 rem ;}
/* Chat: one pane at a time (list, then the open conversation) */
. chatcol { width : 100 % ; flex : 1 1 100 % ; padding-bottom : 60 px ;}
body . chat-open . chatcol { display : none ;}
. content { display : none ;}
body . chat-open . content , . shell : not ( . is-chat ) . content { display : block ;}
. shell : not ( . is-chat ) . chatcol { display : none ;}
. content . panel { bottom : 60 px ;} /* keep panel content above the bottom nav */
/* Bigger touch targets */
. chat-row { padding : .7 rem .85 rem ;}
. convo-head { padding : .7 rem .85 rem ;}
. modal { width : 94 vw ;}
/* Meeting + embedded panels full-width, controls above the nav */
. meet-grid { grid-template-columns : repeat ( auto - fit , minmax ( 150 px , 1 fr ));}
. meet-bar { flex-wrap : wrap ; gap : .4 rem ; padding : .5 rem .6 rem ;}
. meet-bar . code { flex : 1 1 100 % ; margin : 0 0 .2 rem ;}
. meet-bar . meet-ic { width : 44 px ; height : 44 px ;}
. meet-panel { width : 90 vw ; max-width : none ; right : 5 vw ; top : 6 px ; bottom : 74 px ;}
. md-top { flex-direction : column ;}
/* Code+Join on a full-width row; Start & Schedule as two equal halves below */
. md-actions { width : 100 % ; display : grid ; grid-template-columns : 1 fr 1 fr ; gap : .5 rem ;}
. md-join { grid-column : 1 / -1 ;}
. md-actions > . btn { width : 100 % ; justify-content : center ;}
. md-join { flex : 1 1 100 % ;}
. call-invite { left : 8 px ; right : 8 px ; max-width : none ; bottom : 70 px ;}
. bell-menu { position : fixed ; left : 8 px ; right : 8 px ; top : 58 px ; width : auto ;}
/* Touch devices have no hover: keep member-row actions (make-admin / remove) always visible */
. mrow . iconbtn { opacity : 1 ;}
2026-06-12 00:40:07 +05:30
}
</ style >
</ head >
< body >
2026-06-23 18:47:24 +05:30
< script src = "/icons.js?v=3" ></ script >
2026-06-24 16:30:17 +05:30
< script > window . __BUILD = '2026-06-24-push4' ; console . log ( '%cBizGaze Connect' , 'color:#1F3B73;font-weight:bold' , 'build ' + window . __BUILD );</ script >
2026-06-12 00:40:07 +05:30
< div class = "loading" id = "loading" > Loading…</ div >
< header >
< div class = "brandrow" >
2026-06-23 16:15:29 +05:30
< button class = "navtoggle" id = "navToggle" title = "Toggle menu" aria-label = "Toggle menu" >< svg viewBox = "0 0 24 24" width = "22" height = "22" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" >< line x1 = "3" y1 = "6" x2 = "21" y2 = "6" />< line x1 = "3" y1 = "12" x2 = "21" y2 = "12" />< line x1 = "3" y1 = "18" x2 = "21" y2 = "18" /></ svg ></ button >
2026-06-12 00:40:07 +05:30
< img src = "/logo.png" alt = "" style = "height:46px;width:auto;max-width:190px;border-radius:8px;object-fit:contain;background:#fff;padding:5px 12px;image-rendering:-webkit-optimize-contrast" onerror = "this.replaceWith(Object.assign(document.createElement('div'),{className:'logo',textContent:'B'}))" >
< div class = "brand" > BizGaze < span class = "y" > Connect</ span ></ div >
</ div >
< div id = "hdrRight" ></ div >
</ header >
2026-06-23 16:15:29 +05:30
< div class = "shell is-chat" >
< div class = "rail-backdrop" id = "railBackdrop" ></ div >
2026-06-12 00:40:07 +05:30
<!-- ---------- Icon rail ---------- -->
< nav class = "rail" id = "rail" >
< button class = "railbtn active" data-tab = "chat" data-tip = "Chat" aria-label = "Chat" >
< svg viewBox = "0 0 24 24" width = "22" height = "22" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >< path d = "M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" /></ svg >
2026-06-23 16:15:29 +05:30
< span class = "rdot" id = "railUnread" style = "display:none" > 0</ span >< span class = "rlabel" > Chat</ span >
2026-06-12 00:40:07 +05:30
</ button >
< button class = "railbtn" data-tab = "share" data-tip = "Share Screen" aria-label = "Share Screen" >
< svg viewBox = "0 0 24 24" width = "22" height = "22" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >< rect x = "2" y = "3" width = "20" height = "14" rx = "2" />< line x1 = "8" y1 = "21" x2 = "16" y2 = "21" />< line x1 = "12" y1 = "17" x2 = "12" y2 = "21" /></ svg >
2026-06-23 16:15:29 +05:30
< span class = "livedot" ></ span >< span class = "rlabel" > Share</ span >
2026-06-12 00:40:07 +05:30
</ button >
< button class = "railbtn" data-tab = "connect" data-tip = "Connect Screen" aria-label = "Connect Screen" >
< svg viewBox = "0 0 24 24" width = "22" height = "22" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >< path d = "M5 12.55a11 11 0 0 1 14.08 0" />< path d = "M1.42 9a16 16 0 0 1 21.16 0" />< path d = "M8.53 16.11a6 6 0 0 1 6.95 0" />< line x1 = "12" y1 = "20" x2 = "12.01" y2 = "20" /></ svg >
2026-06-23 16:15:29 +05:30
< span class = "livedot" ></ span >< span class = "rlabel" > Connect</ span >
2026-06-12 00:40:07 +05:30
</ button >
< button class = "railbtn" data-tab = "meeting" data-tip = "Meeting" aria-label = "Meeting" >
< svg viewBox = "0 0 24 24" width = "22" height = "22" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >< polygon points = "23 7 16 12 23 17 23 7" />< rect x = "1" y = "5" width = "15" height = "14" rx = "2" ry = "2" /></ svg >
2026-06-23 16:15:29 +05:30
< span class = "livedot" ></ span >< span class = "rlabel" > Meet</ span >
2026-06-12 00:40:07 +05:30
</ button >
< div class = "rail-spacer" ></ div >
</ nav >
<!-- ---------- Chat list (Chat tab only) ---------- -->
< aside class = "chatcol" id = "chatcol" >
< div class = "side-head" >
< div class = "side-title" >
< h2 > Chats</ h2 >
< button class = "newchat" id = "newChat" title = "New chat" aria-label = "New chat" > +</ button >
</ div >
< div class = "search" >
< svg viewBox = "0 0 24 24" width = "16" height = "16" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >< circle cx = "11" cy = "11" r = "8" />< line x1 = "21" y1 = "21" x2 = "16.65" y2 = "16.65" /></ svg >
< input id = "chatSearch" placeholder = "Search chats" autocomplete = "off" >
2026-06-23 16:15:29 +05:30
< button class = "search-x" id = "chatSearchX" title = "Clear" aria-label = "Clear" style = "display:none" >< svg viewBox = "0 0 24 24" width = "15" height = "15" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >< path d = "M18 6 6 18" />< path d = "m6 6 12 12" /></ svg ></ button >
2026-06-12 00:40:07 +05:30
</ div >
</ div >
< div class = "chatlist" id = "chatlist" ></ div >
2026-06-23 16:15:29 +05:30
< div class = "demo-note" > 💬 Messages with your BizGaze teammates</ div >
2026-06-12 00:40:07 +05:30
</ aside >
<!-- ---------- Main content ---------- -->
< main class = "content" >
<!-- Chat panel: welcome (no selection) OR conversation placeholder -->
< div class = "panel center active" data-panel = "chat" id = "chatPanel" ></ div >
<!-- Share -->
< div class = "panel" data-panel = "share" id = "sharePanel" ></ div >
<!-- Connect -->
< div class = "panel" data-panel = "connect" id = "connectPanel" ></ div >
2026-06-23 16:15:29 +05:30
<!-- Meeting (mesh video; rendered by JS) -->
< div class = "panel" data-panel = "meeting" id = "meetingPanel" ></ div >
2026-06-12 00:40:07 +05:30
</ main >
</ div >
< div class = "authwrap" id = "authwrap" ></ div >
< div class = "toast" id = "toast" ></ div >
< script >
// ---------- Helpers ----------
function pEsc ( s ){ return String ( s == null ? '' : s ). replace ( /[&<>"]/g , c =>({ '&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' }[ c ]));}
function initials ( name ){ const p = String ( name || '?' ). trim (). split ( /\s+/ ); return (( p [ 0 ] || '?' )[ 0 ] + ( p [ 1 ] ? p [ 1 ][ 0 ] : '' )). toUpperCase ();}
2026-06-23 16:15:29 +05:30
function fmtDateTime ( ts ){ if ( ! ts ) return '' ; const d = new Date ( ts ); return d . toLocaleDateString ([],{ year : 'numeric' , month : 'short' , day : 'numeric' }) + ' · ' + d . toLocaleTimeString ([],{ hour : '2-digit' , minute : '2-digit' }); }
function fmtClock ( ts ){ if ( ! ts ) return '' ; return new Date ( ts ). toLocaleTimeString ([],{ hour : '2-digit' , minute : '2-digit' }); } // bubble time = clock only; date is the center separator
function autoGrow ( el ){ if ( ! el ) return ; el . style . height = 'auto' ; const max = 140 ; el . style . height = Math . min ( el . scrollHeight , max ) + 'px' ; el . style . overflowY = el . scrollHeight > max ? 'auto' : 'hidden' ; }
// Mild, light tint for a quoted reply, color-coded by who is being quoted. [bg, bar]
const REPLY_TINTS = [[ '#eef4ff' , '#3b6fd4' ],[ '#eafaf2' , '#1f9d57' ],[ '#fef4e9' , '#d98324' ],[ '#f6eefe' , '#8b46c9' ],[ '#fdeef3' , '#d6457f' ],[ '#e9fafa' , '#179a9a' ],[ '#fef7e6' , '#c9a227' ]];
function replyTint ( key ){ let h = 0 ; const s = String ( key || '' ); for ( let i = 0 ; i < s . length ; i ++ ) h = ( h * 31 + s . charCodeAt ( i )) >>> 0 ; return REPLY_TINTS [ h % REPLY_TINTS . length ]; }
2026-06-12 00:40:07 +05:30
function firstName ( name ){ return String ( name || '' ). trim (). split ( /\s+/ )[ 0 ] || 'there' ;}
const AV_COLORS = [ '#1F3B73' , '#2563eb' , '#0e7490' , '#7c3aed' , '#be185d' , '#b45309' , '#15803d' , '#9d174d' ];
function avColor ( name ){ let h = 0 ; for ( const c of String ( name )) h = ( h * 31 + c . charCodeAt ( 0 )) >>> 0 ; return AV_COLORS [ h % AV_COLORS . length ];}
let toastTimer = null ;
function toast ( msg ){ const t = document . getElementById ( 'toast' ); t . textContent = msg ; t . classList . add ( 'show' ); clearTimeout ( toastTimer ); toastTimer = setTimeout (()=> t . classList . remove ( 'show' ), 2600 );}
// ---------- Profile dropdown (mirrors profileHTML()/wireProfile() from console.html) ----------
function profileHTML ( u ){
const display = u . name || u . email ;
2026-06-23 16:15:29 +05:30
const img = u . avatarUrl ? '<img src="' + pEsc ( u . avatarUrl ) + '" alt="" onerror="this.remove()">' : '' ;
return '<div class="profile"><button class="pbtn icon-only" id="pbtn" title="' + pEsc ( display ) + '">'
+ '<span class="pav">' + pEsc ( initials ( display )) + img + '</span></button>'
2026-06-12 00:40:07 +05:30
+ '<div class="pmenu" id="pmenu">'
+ '<div class="phead"><div class="n">' + pEsc ( display ) + '</div><div class="e">' + pEsc ( u . email ) + ( u . role ? ' · ' + pEsc ( u . role ) : '' ) + '</div></div>'
2026-06-23 16:15:29 +05:30
+ '<a href="/dashboard">' + ic ( 'layoutDashboard' , 16 ) + ' Dashboard</a>'
+ '<a id="psettings">' + ic ( 'settings' , 16 ) + ' Settings</a>'
+ '<a class="danger" id="plogout">' + ic ( 'logOut' , 16 ) + ' Logout</a>'
2026-06-12 00:40:07 +05:30
+ '</div></div>' ;
}
function wireProfile (){
const btn = document . getElementById ( 'pbtn' ), menu = document . getElementById ( 'pmenu' );
if ( ! btn ) return ;
btn . onclick = ( e )=>{ e . stopPropagation (); menu . classList . toggle ( 'open' );};
document . addEventListener ( 'click' ,()=> menu . classList . remove ( 'open' ));
const lo = document . getElementById ( 'plogout' );
if ( lo ) lo . onclick = async ()=>{ try { await fetch ( '/api/logout' ,{ method : 'POST' });} catch ( _ ){} location . href = '/' ;};
2026-06-23 16:15:29 +05:30
const ps = document . getElementById ( 'psettings' );
if ( ps ) ps . onclick = ()=>{ menu . classList . remove ( 'open' ); openSettings (); };
}
// ---------- Notification bell (activity center) ----------
// Captures things that otherwise have no home: scheduled-meeting invites, reminders, reactions to
// your messages, new polls, and new chat requests. Stored per user in this browser.
let NOTIFS = [];
function notifKey (){ return 'notifs_' + (( ME && ME . id ) || '' ); }
function loadNotifs (){ try { NOTIFS = JSON . parse ( localStorage . getItem ( notifKey ()) || '[]' ); } catch ( _ ){ NOTIFS = []; } if ( ! Array . isArray ( NOTIFS )) NOTIFS = []; updateBellBadge (); }
function saveNotifs (){ try { NOTIFS = NOTIFS . slice ( 0 , 50 ); localStorage . setItem ( notifKey (), JSON . stringify ( NOTIFS )); } catch ( _ ){} }
function addNotif ( n ){ NOTIFS . unshift ( Object . assign ({ id : 'n' + Date . now () + '_' + Math . round (( window . performance && performance . now ()) || Math . random () * 1e6 ), ts : Date . now (), read : false }, n )); saveNotifs (); updateBellBadge (); const menu = document . getElementById ( 'bellMenu' ); if ( menu && menu . classList . contains ( 'open' )) renderBell (); }
function bellHTML (){ return '<div class="bell" id="bellWrap"><button class="bellbtn" id="bellBtn" title="Notifications" aria-label="Notifications">' + ic ( 'bell' , 20 ) + '<span class="bell-dot" id="bellDot" style="display:none">0</span></button><div class="bell-menu" id="bellMenu"></div></div>' ; }
function updateBellBadge (){ const d = document . getElementById ( 'bellDot' ); if ( ! d ) return ; const n = NOTIFS . filter ( x => ! x . read ). length ; if ( n > 0 ){ d . textContent = n > 99 ? '99+' : n ; d . style . display = 'grid' ; } else d . style . display = 'none' ; }
function renderBell (){ const menu = document . getElementById ( 'bellMenu' ); if ( ! menu ) return ;
const items = NOTIFS . length ? NOTIFS . map ( n => '<div class="bell-item' + ( n . read ? '' : ' unread' ) + '" data-id="' + n . id + '"><span class="bell-ico">' + ic ( n . icon || 'bell' , 16 ) + '</span><div class="bell-body"><div class="bell-tx">' + n . text + '</div><div class="bell-tm">' + fmtTime ( n . ts ) + '</div></div></div>' ). join ( '' ) : '<div class="bell-empty">No notifications yet</div>' ;
menu . innerHTML = '<div class="bell-head"><span>Notifications</span>' + ( NOTIFS . length ? '<button id="bellClear">Clear all</button>' : '' ) + '</div><div class="bell-list">' + items + '</div>' ;
const cl = menu . querySelector ( '#bellClear' ); if ( cl ) cl . onclick = ( e )=>{ e . stopPropagation (); NOTIFS = []; saveNotifs (); updateBellBadge (); renderBell (); };
menu . querySelectorAll ( '.bell-item' ). forEach ( el => el . onclick = ()=>{ const n = NOTIFS . find ( x => x . id === el . dataset . id ); if ( n ) openNotif ( n ); });
}
function openNotif ( n ){ NOTIFS = NOTIFS . filter ( x => x . id !== n . id ); saveNotifs (); updateBellBadge (); const menu = document . getElementById ( 'bellMenu' ); if ( menu ) menu . classList . remove ( 'open' );
if ( n . link ){ if ( n . link . kind === 'meeting' ){ switchTab ( 'meeting' ); loadScheduledMeetings (); } else if ( n . link . kind === 'dm' || n . link . kind === 'group' ){ switchTab ( 'chat' ); selectChat ( n . link . kind , n . link . id ); } } // meeting → just open the tab, don't auto-join
renderBell ();
}
function wireBell (){ const b = document . getElementById ( 'bellBtn' ), menu = document . getElementById ( 'bellMenu' ); if ( ! b ||! menu ) return ;
b . onclick = ( e )=>{ e . stopPropagation (); const open = menu . classList . toggle ( 'open' ); if ( open ){ renderBell (); NOTIFS . forEach ( x => x . read = true ); saveNotifs (); updateBellBadge (); } };
document . addEventListener ( 'click' ,()=> menu . classList . remove ( 'open' )); menu . onclick = ( e )=> e . stopPropagation (); updateBellBadge ();
}
// Settings: notification preferences (browser permission + per-type), stored per browser.
function openSettings (){
if ( document . getElementById ( 'setModal' )) return ;
const ov = document . createElement ( 'div' ); ov . className = 'modal-ov' ; ov . id = 'setModal' ;
const sw = ( id , label , on )=> '<label class="gi-setting"><span>' + label + '</span><span class="switch"><input type="checkbox" id="' + id + '"' + ( on ? ' checked' : '' ) + '><span class="slider"></span></span></label>' ;
const granted = ( 'Notification' in window ) && Notification . permission === 'granted' ;
ov . innerHTML = '<div class="modal sched"><div class="gi-head" style="margin-bottom:.6rem"><div class="avatar grp" style="width:40px;height:40px;flex:0 0 40px;background:var(--blue)">' + ic ( 'settings' , 20 ) + '</div>'
+ '<div class="gi-name"><div class="gi-title">Settings</div><div class="gi-sub">Notifications</div></div>'
+ '<button class="iconbtn" id="setClose" title="Close">' + ic ( 'x' , 18 ) + '</button></div>'
+ sw ( 'setGroup' , 'Group message notifications' , notifOn ( 'group' ))
+ sw ( 'setDm' , 'Direct message notifications' , notifOn ( 'dm' ))
+ '<label class="gi-setting"><span>Browser/desktop pop-ups</span><button class="btn sm" id="setPerm"' + ( granted ? ' disabled' : '' ) + '>' + ( granted ? 'Enabled' : 'Enable' ) + '</button></label>'
+ '<div class="hint" style="margin-top:.4rem">These preferences are saved on this device.</div></div>' ;
document . body . appendChild ( ov );
ov . onclick = e =>{ if ( e . target === ov ) ov . remove (); };
ov . querySelector ( '#setClose' ). onclick = ()=> ov . remove ();
const setPref = ( k , v )=>{ try { localStorage . setItem ( 'notif_' + k , v ? 'on' : 'off' ); } catch ( _ ){} };
ov . querySelector ( '#setGroup' ). onchange = e => setPref ( 'group' , e . target . checked );
ov . querySelector ( '#setDm' ). onchange = e => setPref ( 'dm' , e . target . checked );
2026-06-23 21:58:49 +05:30
const perm = ov . querySelector ( '#setPerm' ); if ( perm ) perm . onclick = async ()=>{ try { const r = await Notification . requestPermission (); if ( r === 'granted' ){ perm . textContent = 'Enabled' ; perm . disabled = true ; toast ( 'Desktop notifications enabled' ); try { await subscribePush (); } catch ( _ ){} } else toast ( 'Notifications blocked — allow them in your browser site settings' ); } catch ( _ ){ toast ( 'Notifications need HTTPS or localhost' ); } };
2026-06-12 00:40:07 +05:30
}
2026-06-23 16:15:29 +05:30
// ---------- Chat (1:1 + groups) ----------
let ME = {};
let CONTACTS = []; // team users (for new DMs / picking group members)
let ROWS = []; // sidebar items: {kind:'dm'|'group', id, name, online?, members?, last_body, last_at, last_from_me, unread}
let selected = null ; // {kind,id} or null = welcome
let convoIsGroup = false ; // the open thread is a group (drives per-message sender labels)
let THREAD = [];
const THREAD_CACHE = new Map (); // key 'kind:id' -> messages[] ; lets a notification click render synchronously (paints immediately)
let convoMembers = []; // members of the currently open group (for @mentions + highlight)
let composeMentions = new Map (); // token ('@Name' | 'everyone') -> userId | 'everyone' for the draft
const rendered = new Set ();
2026-06-12 00:40:07 +05:30
const listEl = document . getElementById ( 'chatlist' );
2026-06-23 16:15:29 +05:30
function searchVal (){ const s = document . getElementById ( 'chatSearch' ); return s ? s . value : '' ; }
function rowFor ( kind , id ){ return ROWS . find ( r => r . kind === kind && r . id === id ); }
function currentName (){ const it = selected && rowFor ( selected . kind , selected . id ); return it ? it . name : '' ; }
function fmtTime ( ts ){
if ( ! ts ) return '' ;
const d = new Date ( ts ), n = new Date ();
if ( d . toDateString () === n . toDateString ()) return d . toLocaleTimeString ([], { hour : '2-digit' , minute : '2-digit' });
const y = new Date ( n ); y . setDate ( n . getDate () - 1 );
if ( d . toDateString () === y . toDateString ()) return 'Yesterday' ;
return d . toLocaleDateString ([], { month : 'short' , day : 'numeric' });
}
function avatarHTML ( it , big ){
const isG = it . kind === 'group' ;
const sz = big ? 'width:38px;height:38px;flex:0 0 38px;' : '' ;
const corner = isG ? ( it . members ? '<span class="mcount">' + ( it . members > 99 ? '99+' : it . members ) + '</span>' : '' )
: '<span class="dot' + ( it . online ? ' on' : '' ) + '"></span>' ;
const inner = isG ? ic ( 'users' , big ? 20 : 18 ) : pEsc ( initials ( it . name ));
// Photo overlay (BizGaze profile picture, or an uploaded group image). If it fails to
// load it removes itself, revealing the initials/glyph underneath.
const img = it . avatar ? '<img class="av-img" src="' + pEsc ( it . avatar ) + '" alt="" onerror="this.remove()">' : '' ;
return '<div class="avatar' + ( isG ? ' grp' : '' ) + '" style="' + sz + 'background:' + avColor ( it . name ) + '">' + inner + img + corner + '</div>' ;
}
function rowHTML ( it ){
const active = selected && selected . kind === it . kind && selected . id === it . id ;
const cls = [ 'chat-row' ]; if ( active ) cls . push ( 'active' ); if ( it . unread > 0 ) cls . push ( 'unread' );
const isG = it . kind === 'group' ;
const preview = it . last_body ? (( it . last_from_me ? 'You: ' : '' ) + it . last_body ) : ( isG ? (( it . members || 0 ) + ' members' ) : 'No messages yet' );
return '<div class="' + cls . join ( ' ' ) + '" data-kind="' + it . kind + '" data-id="' + pEsc ( it . id ) + '">'
+ avatarHTML ( it , false )
+ '<div class="chat-main"><div class="chat-top"><span class="chat-name">' + pEsc ( it . name ) + '</span><span class="chat-time">' + pEsc ( fmtTime ( it . last_at )) + '</span></div>'
+ '<div class="chat-bottom"><span class="chat-prev">' + ( it . callActive ? '<span class="call-on">' + ic ( 'phone' , 12 ) + ' Ongoing call</span>' : pEsc ( preview )) + '</span>' + ( it . unread > 0 ? '<span class="badge">' + ( it . unread > 99 ? '99+' : it . unread ) + '</span>' : '' ) + '</div></div></div>' ;
2026-06-12 00:40:07 +05:30
}
function renderChats ( filter ){
const q = ( filter || '' ). trim (). toLowerCase ();
2026-06-23 16:15:29 +05:30
const rows = ROWS . filter ( it => ! q || it . name . toLowerCase (). includes ( q ) || ( it . last_body || '' ). toLowerCase (). includes ( q ))
. sort (( a , b )=>( b . last_at - a . last_at ) || a . name . localeCompare ( b . name ));
let html = rows . length ? rows . map ( rowHTML ). join ( '' )
: '<div class="no-results">' + ( ROWS . length ? ( 'No chats match “' + pEsc ( filter ) + '”.' ) : 'No conversations yet.' ) + '</div>' ;
if ( q . length >= 1 ){
// Team contacts you haven't messaged yet → start a new DM.
const haveDm = new Set ( ROWS . filter ( r => r . kind === 'dm' ). map ( r => r . id ));
const cmatch = CONTACTS . filter ( c => ! haveDm . has ( c . id ) && (( c . name || '' ). toLowerCase (). includes ( q ) || ( c . email || '' ). toLowerCase (). includes ( q )));
if ( cmatch . length ) html += '<div class="side-sec">Start a chat</div>' + cmatch . map ( c => '<div class="contact-row" data-id="' + pEsc ( c . id ) + '"><span class="mini-av" style="background:' + avColor ( c . name ) + '">' + pEsc ( initials ( c . name )) + '</span><span class="cr-name">' + pEsc ( c . name ) + '</span></div>' ). join ( '' );
html += '<div id="dirResults"></div>' ; // BizGaze directory (filled async)
}
listEl . innerHTML = html ;
listEl . querySelectorAll ( '.chat-row' ). forEach ( row =>{ row . onclick = ()=> selectChat ( row . dataset . kind , row . dataset . id ); });
listEl . querySelectorAll ( '.contact-row' ). forEach ( row =>{ row . onclick = ()=> selectChat ( 'dm' , row . dataset . id ); });
if ( q . length >= 2 ) queryDirectory ( filter . trim ());
}
let _dirT = null , _dirSeq = 0 ;
// Search the wider BizGaze directory (cross-tenant) — proxied server-side. Debounced.
function queryDirectory ( q ){
clearTimeout ( _dirT ); const seq =++ _dirSeq ;
_dirT = setTimeout ( async ()=>{
let list = []; try { list = await fetch ( '/api/directory/search?q=' + encodeURIComponent ( q )). then ( r => r . json ()); } catch ( _ ){ }
if ( seq !== _dirSeq ) return ; // a newer search superseded this one
const box = document . getElementById ( 'dirResults' ); if ( ! box ) return ;
const haveEmail = new Set ( CONTACTS . map ( c =>( c . email || '' ). toLowerCase ()). filter ( Boolean ));
const ext = ( Array . isArray ( list ) ? list : []). filter ( p => ! ( p . email && haveEmail . has ( p . email . toLowerCase ()))); // hide dups of team contacts
if ( ! ext . length ){ box . innerHTML = '' ; return ; }
box . innerHTML = '<div class="side-sec">On BizGaze</div>' + ext . map (( p , i )=>{ const sub = [ p . org , p . phone ]. filter ( Boolean ). join ( ' · ' );
return '<div class="dir-row' + ( p . onConnect ? '' : ' ext' ) + '" data-i="' + i + '"><span class="mini-av" style="background:' + avColor ( p . name || '?' ) + '">' + pEsc ( initials ( p . name || '?' )) + '</span><span class="dr-main"><span class="cr-name">' + pEsc ( p . name || p . email || 'Unknown' ) + '</span>' + ( sub ? '<span class="dr-sub">' + pEsc ( sub ) + '</span>' : '' ) + '</span>' + ( p . onConnect ? '' : '<span class="dr-tag">Not on Connect</span>' ) + '</div>' ; }). join ( '' );
box . querySelectorAll ( '.dir-row' ). forEach ( row =>{ const p = ext [ + row . dataset . i ]; row . onclick = ()=>{ if ( p . onConnect && p . connectId ) selectChat ( 'dm' , p . connectId ); else toast ( pEsc ( p . name || 'This person' ) + ' is on BizGaze but hasn’ t joined Connect yet — they’ ll be reachable once they sign in.' ); }; });
}, 280 );
2026-06-12 00:40:07 +05:30
}
function updateRailUnread (){
2026-06-23 16:15:29 +05:30
let chats = 0 ; ROWS . forEach ( it =>{ if (( it . unread || 0 ) > 0 ) chats ++ ; }); // number of chats with unread, not total messages
2026-06-12 00:40:07 +05:30
const d = document . getElementById ( 'railUnread' );
2026-06-23 16:15:29 +05:30
if ( chats > 0 ){ d . textContent = chats > 99 ? '99+' : chats ; d . style . display = 'grid' ; } else d . style . display = 'none' ;
}
async function loadSidebar (){
let convos = [], contacts = [];
try { [ convos , contacts ] = await Promise . all ([ fetch ( '/api/messages/conversations' ). then ( r => r . json ()), fetch ( '/api/messages/contacts' ). then ( r => r . json ()) ]); } catch ( _ ){}
CONTACTS = Array . isArray ( contacts ) ? contacts : [];
const items = Array . isArray ( convos ) ? convos . slice () : [];
const dmIds = new Set ( items . filter ( i => i . kind === 'dm' ). map ( i => i . id ));
for ( const c of CONTACTS ){ if ( ! dmIds . has ( c . id )) items . push ({ kind : 'dm' , id : c . id , name : c . name , online :!! c . online , last_body : '' , last_at : 0 , last_from_me : false , unread : 0 }); }
const onlineById = {}; CONTACTS . forEach ( c => onlineById [ c . id ] =!! c . online );
items . forEach ( it =>{ if ( it . kind === 'dm' ) it . online =!! onlineById [ it . id ]; });
ROWS = items ;
renderChats ( searchVal ());
updateRailUnread ();
2026-06-12 00:40:07 +05:30
}
2026-06-23 16:15:29 +05:30
// ----- conversation view -----
2026-06-12 00:40:07 +05:30
function welcomeHTML (){
return '<div class="welcome">'
+ '<div class="wave">👋</div>'
+ '<h1>Hi, ' + pEsc ( firstName ( ME . name || ME . email )) + '! Welcome to BizGaze Connect</h1>'
+ '<p>Pick a conversation on the left to start chatting, or jump straight into a session from the sidebar.</p>'
+ '<div class="wcards">'
+ '<div class="wcard" data-go="share"><div class="wi"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg></div><h3>Share Screen</h3><p>Show your screen with a 6-digit code</p></div>'
+ '<div class="wcard" data-go="connect"><div class="wi"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg></div><h3>Connect Screen</h3><p>Enter a customer\'s code to help</p></div>'
+ '<div class="wcard" data-go="meeting"><div class="wi"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg></div><h3>Meeting</h3><p>Multi-party video — coming soon</p></div>'
+ '</div></div>' ;
}
2026-06-23 16:15:29 +05:30
function wireWelcome (){ document . querySelectorAll ( '#chatPanel .wcard' ). forEach ( card =>{ card . onclick = ()=> switchTab ( card . dataset . go ); }); }
function convoShellHTML ( it ){
const isG = it . kind === 'group' ;
const sub = isG ? (( it . members || 0 ) + ' members' ) : ( it . online ? 'Online' : 'Offline' );
2026-06-12 00:40:07 +05:30
return '<div class="convo">'
+ '<div class="convo-head">'
2026-06-23 16:15:29 +05:30
+ '<button class="convo-back" id="convoBack" title="Back (Esc)" aria-label="Back">' + ic ( 'arrowLeft' , 18 ) + '</button>'
+ avatarHTML ( it , true )
+ '<div class="convo-titlewrap' + ( isG ? ' gi-open" id="convoTitle" title="Group info"' : '"' ) + '><div class="nm">' + pEsc ( it . name ) + '</div><div class="st">' + pEsc ( sub ) + '</div></div>'
+ '<button class="convo-call' + ( it . callActive ? ' joinable' : '' ) + '" id="convoCall" title="' + ( it . callActive ? 'Join call' : 'Start call' ) + '">' + ic ( it . callActive ? 'video' : 'phone' , 18 ) + ( it . callActive ? '<span>Join</span>' : '' ) + '</button>'
+ ( isG ? '<button class="convo-info" id="convoInfo" title="Group info">' + ic ( 'info' , 18 ) + '</button>' : '' )
2026-06-12 00:40:07 +05:30
+ '</div>'
2026-06-23 16:15:29 +05:30
+ '<div class="convo-msgs" id="msgs"></div>'
+ '<div class="float-date" id="floatDate" style="display:none"></div>'
+ '<button class="jump-latest" id="jumpLatest" title="Jump to latest" style="display:none">' + ic ( 'chevronDown' , 20 ) + '</button>'
+ '<div class="reply-bar" id="replyBar" style="display:none"></div>'
+ '<form class="composer" id="composer">'
+ '<div class="composer-box">'
+ '<div class="attach-preview" id="attachBar" style="display:none"></div>'
+ '<div class="fmt-bar" id="fmtBar" style="display:none">'
+ '<button type="button" data-fmt="bold" title="Bold (**)">' + ic ( 'bold' , 16 ) + '</button>'
+ '<button type="button" data-fmt="italic" title="Italic (*)">' + ic ( 'italic' , 16 ) + '</button>'
+ '<button type="button" data-fmt="strike" title="Strikethrough (~~)">' + ic ( 'strikethrough' , 16 ) + '</button>'
+ '<button type="button" data-fmt="code" title="Code (`)">' + ic ( 'code' , 16 ) + '</button>'
+ '<span class="fmt-sep"></span>'
+ '<button type="button" data-fmt="ul" title="Bulleted list">' + ic ( 'list' , 16 ) + '</button>'
+ '<button type="button" data-fmt="ol" title="Numbered list">' + ic ( 'listOrdered' , 16 ) + '</button>'
+ '</div>'
+ '<div class="composer-row">'
+ '<button type="button" class="ic-btn" id="attachBtn" title="Attach a file">' + ic ( 'paperclip' , 20 ) + '</button>'
+ '<button type="button" class="ic-btn" id="fmtBtn" title="Formatting">' + ic ( 'type' , 20 ) + '</button>'
+ '<textarea id="msgInput" placeholder="Type a message…" autocomplete="off" maxlength="4000" rows="1"></textarea>'
+ ( isG ? '<button type="button" class="ic-btn" id="pollBtn" title="Create a poll">' + ic ( 'barChart' , 20 ) + '</button>' : '' )
+ '<button type="button" class="ic-btn" id="emojiBtn" title="Emoji">' + ic ( 'smile' , 20 ) + '</button>'
+ '<button type="submit" class="sendbtn" title="Send" aria-label="Send">' + ic ( 'send' , 18 ) + '</button>'
+ '</div>'
+ '</div>'
+ '<input type="file" id="fileInput" style="display:none">'
+ '</form>'
+ '<div class="emoji-pop" id="emojiPop" style="display:none"></div>'
+ '<div class="mention-pop" id="mentionPop" style="display:none"></div>'
2026-06-12 00:40:07 +05:30
+ '</div>' ;
}
2026-06-23 16:15:29 +05:30
// Small round avatar (photo if the member has one, else colored initials) for a group message sender.
function senderAvatar ( id , name ){ const mem = convoMembers . find ( x => x . id === id ); const av = mem && mem . avatar ; return '<span class="snd-av" style="background:' + avColor ( name || '?' ) + '">' + ( av ? '<img src="' + pEsc ( av ) + '" alt="" onerror="this.remove()">' : '' ) + pEsc ( initials ( name || '?' )) + '</span>' ; }
function bubbleHTML ( m ){
if ( m . evt === 'call-start' ) return '<div class="sys-msg">📞 ' + ( m . from === ME . id ? 'You' : pEsc ( m . byName || 'Someone' )) + ' started a call</div>' ;
if ( m . system || m . from === '__system__' ) return '<div class="sys-msg">' + pEsc ( m . body ) + '</div>' ;
const mine = m . from === ME . id ;
const sender = ( convoIsGroup && ! mine && m . fromName ) ? '<div class="sender">' + senderAvatar ( m . from , m . fromName ) + '<span>' + pEsc ( m . fromName ) + '</span></div>' : '' ;
let quote = '' ;
if ( m . reply ){ const t = replyTint ( m . reply . from || m . reply . fromName ); quote = '<div class="quote" style="background:' + t [ 0 ] + ';border-left-color:' + t [ 1 ] + '"><b style="color:' + t [ 1 ] + '">' + pEsc ( m . reply . fromName || '' ) + '</b>: ' + pEsc ( m . reply . body ) + '</div>' ; }
const reacts = ( m . reactions && m . reactions . length ) ? '<div class="reacts">' + m . reactions . map ( r => '<button class="react-chip' + ( r . mine ? ' mine' : '' ) + '" data-id="' + pEsc ( m . id ) + '" data-emoji="' + pEsc ( r . emoji ) + '" title="' + pEsc (( r . who || []). join ( ', ' )) + '">' + pEsc ( r . emoji ) + ' ' + r . count + '</button>' ). join ( '' ) + '</div>' : '' ;
const att = m . attachment ? ( m . attachment . isImage
? '<img class="att-img" src="/files/' + pEsc ( m . attachment . id ) + '" data-img="/files/' + pEsc ( m . attachment . id ) + '" alt="' + pEsc ( m . attachment . name ) + '" title="Click to view">'
: '<a class="att-file" href="/files/' + pEsc ( m . attachment . id ) + '" download="' + pEsc ( m . attachment . name ) + '">' + ic ( 'file' , 15 ) + ' <span>' + pEsc ( m . attachment . name ) + '</span> <span class="att-sz">' + fmtSize ( m . attachment . size ) + '</span></a>' ) : '' ;
const mentionsMe = convoIsGroup && ! mine && Array . isArray ( m . mentions ) && ( m . mentions . includes ( ME . id ) || m . mentions . includes ( 'everyone' ));
// DM ticks: sent (1 grey) → delivered (2 grey) → read (2 blue).
let rcpt = '' ;
if ( mine && ! convoIsGroup ){ const st = m . read_at ? 'seen' : ( m . delivered_at ? 'delivered' : 'sent' ); rcpt = '<span class="rcpt ' + st + '" title="' + ( st === 'seen' ? 'Seen' : st === 'delivered' ? 'Delivered' : 'Sent' ) + '">' + ic ( st === 'sent' ? 'check' : 'checkCheck' , 13 ) + '</span>' ; }
// Group "Seen by …" on my own messages.
let seen = '' ;
if ( mine && convoIsGroup && m . id === _lastMineId && Array . isArray ( m . seenBy ) && m . seenBy . length ){ const ns = m . seenBy , head = ns . slice ( 0 , 3 ). join ( ', ' ), more = ns . length > 3 ? ( ' +' + ( ns . length - 3 ) + ' more' ) : '' ; seen = '<button class="seenby" data-seen="' + pEsc ( ns . join ( '|' )) + '">' + ic ( 'checkCheck' , 12 ) + ' Seen by ' + pEsc ( head ) + more + '</button>' ; }
return '<div class="bubble ' + ( mine ? 'mine' : 'them' ) + ( mentionsMe ? ' mention-me' : '' ) + ( m . poll ? ' has-poll' : '' ) + '" data-id="' + pEsc ( m . id ) + '">'
+ sender + quote + att + renderMsgBody ( m ) + pollHTML ( m )
+ '<button class="reply-btn" data-id="' + pEsc ( m . id ) + '" title="Reply">' + ic ( 'reply' , 14 ) + '</button>'
+ '<button class="react-btn" data-id="' + pEsc ( m . id ) + '" title="React">' + ic ( 'smilePlus' , 14 ) + '</button>'
+ '<span class="t">' + pEsc ( fmtClock ( m . created_at )) + rcpt + '</span>'
+ reacts + seen + '</div>' ;
}
// reply + emoji state/helpers
let replyTarget = null ;
// Categorized emoji set (covers the common ones used across Slack/Teams/WhatsApp).
const EMOJI_CATS = [
{ icon : '😀' , list : '😀 😃 😄 😁 😆 😅 🤣 😂 🙂 🙃 🫠 😉 😊 😇 🥰 😍 🤩 😘 😗 😚 😙 🥲 😋 😛 😜 🤪 😝 🤑 🤗 🤭 🫢 🫣 🤫 🤔 🫡 🤐 🤨 😐 😑 😶 🫥 😏 😒 🙄 😬 🤥 😌 😔 😪 🤤 😴 😷 🤒 🤕 🤢 🤮 🤧 🥵 🥶 🥴 😵 🤯 🤠 🥳 🥸 😎 🤓 🧐 😕 🫤 😟 🙁 ☹️ 😮 😯 😲 😳 🥺 🥹 😦 😧 😨 😰 😥 😢 😭 😱 😖 😣 😞 😓 😩 😫 🥱 😤 😡 😠 🤬 😈 👿 💀 ☠️ 💩 🤡 👹 👺 👻 👽 👾 🤖 😺 😸 😹 😻 😼 😽 🙀 😿 😾' },
{ icon : '👍' , list : '👋 🤚 🖐️ ✋ 🖖 🫱 🫲 🫳 🫴 👌 🤌 🤏 ✌️ 🤞 🫰 🤟 🤘 🤙 👈 👉 👆 🖕 👇 ☝️ 🫵 👍 👎 ✊ 👊 🤛 🤜 👏 🙌 🫶 👐 🤲 🤝 🙏 ✍️ 💅 🤳 💪 🦾 🦵 🦶 👣 👀 👁️ 🫦 👄 🫀 🫁 🧠 🦷 🦴 👶 🧒 👦 👧 🧑 👨 👩 🧔 👱 🧓 👴 👵 🙇 💁 🙅 🙆 🙋 🤦 🤷 👮 🕵️ 💂 👷 🤴 👸 👳 🧕 🤵 👰 🤰 🤱 👼 🎅 🤶 🦸 🦹 🧙 🧚 🧛 🧜 🧝 🧞 🧟 💆 💇 🚶 🧍 🧎 🏃 💃 🕺 👯 🧘' },
{ icon : '❤️' , list : '❤️ 🧡 💛 💚 💙 💜 🖤 🤍 🤎 💔 ❣️ 💕 💞 💓 💗 💖 💘 💝 💟 ❤️🔥 ❤️🩹 💋 💯 💢 💥 💫 💦 💨 💬 🗨️ 🗯️ 💭 💤 ♥️ ✨ ⭐ 🌟 ⚡ 🔥 🌈 ☀️ ⛅ ☁️ 🌧️ ⛈️ 🌩️ ❄️ ☃️ ⛄ 💧 🌊' },
{ icon : '🐻' , list : '🐶 🐱 🐭 🐹 🐰 🦊 🐻 🐼 🐻❄️ 🐨 🐯 🦁 🐮 🐷 🐽 🐸 🐵 🙈 🙉 🙊 🐒 🐔 🐧 🐦 🐤 🐣 🐥 🦆 🦅 🦉 🦇 🐺 🐗 🐴 🦄 🐝 🐛 🦋 🐌 🐞 🐜 🪰 🪲 🦗 🕷️ 🦂 🐢 🐍 🦎 🦖 🦕 🐙 🦑 🦐 🦞 🦀 🐡 🐠 🐟 🐬 🐳 🐋 🦈 🐊 🐅 🐆 🦓 🦍 🦧 🐘 🦛 🦏 🐪 🐫 🦒 🦘 🐃 🐄 🐎 🐖 🐏 🐑 🐐 🦌 🐕 🐩 🦮 🐈 🐓 🦃 🦚 🦜 🦢 🕊️ 🐇 🦝 🦨 🦦 🦥 🐁 🐀 🐿️ 🦔 🐾 🐉 🌵 🎄 🌲 🌳 🌴 🌱 🌿 ☘️ 🍀 🍃 🍂 🍁 🍄 🐚 🌾 💐 🌷 🌹 🥀 🌺 🌸 🌼 🌻' },
{ icon : '🍔' , list : '🍏 🍎 🍐 🍊 🍋 🍌 🍉 🍇 🍓 🫐 🍈 🍒 🍑 🥭 🍍 🥥 🥝 🍅 🍆 🥑 🥦 🥬 🥒 🌶️ 🫑 🌽 🥕 🫒 🧄 🧅 🥔 🍠 🥐 🥯 🍞 🥖 🥨 🧀 🥚 🍳 🧈 🥞 🧇 🥓 🥩 🍗 🍖 🌭 🍔 🍟 🍕 🫓 🥪 🥙 🧆 🌮 🌯 🫔 🥗 🥘 🫕 🥫 🍝 🍜 🍲 🍛 🍣 🍱 🥟 🦪 🍤 🍙 🍚 🍘 🍥 🥠 🥮 🍢 🍡 🍧 🍨 🍦 🥧 🧁 🍰 🎂 🍮 🍭 🍬 🍫 🍿 🍩 🍪 🌰 🥜 🍯 🥛 🍼 ☕ 🍵 🧃 🥤 🧋 🍶 🍺 🍻 🥂 🍷 🥃 🍸 🍹 🧉 🍾' },
{ icon : '⚽' , list : '⚽ 🏀 🏈 ⚾ 🥎 🎾 🏐 🏉 🥏 🎱 🪀 🏓 🏸 🏒 🏑 🥍 🏏 🥅 ⛳ 🪁 🏹 🎣 🤿 🥊 🥋 🎽 🛹 🛼 🛷 ⛸️ 🥌 🎿 ⛷️ 🏂 🪂 🏋️ 🤼 🤸 ⛹️ 🤺 🤾 🏌️ 🏇 🧘 🏄 🏊 🤽 🚣 🧗 🚵 🚴 🏆 🥇 🥈 🥉 🏅 🎖️ 🏵️ 🎗️ 🎫 🎟️ 🎪 🤹 🎭 🩰 🎨 🎬 🎤 🎧 🎼 🎹 🥁 🪘 🎷 🎺 🪗 🎸 🪕 🎻 🎲 ♟️ 🎯 🎳 🎮 🎰 🧩' },
{ icon : '🚗' , list : '🚗 🚕 🚙 🚌 🚎 🏎️ 🚓 🚑 🚒 🚐 🛻 🚚 🚛 🚜 🛴 🚲 🛵 🏍️ 🛺 🚨 🚔 🚍 🚘 🚖 🚡 🚠 🚟 🚃 🚋 🚞 🚝 🚄 🚅 🚈 🚂 🚆 🚇 🚊 🚉 ✈️ 🛫 🛬 🛩️ 💺 🛰️ 🚀 🛸 🚁 🛶 ⛵ 🚤 🛥️ 🛳️ ⛴️ 🚢 ⚓ ⛽ 🚧 🚦 🚥 🗺️ 🗿 🗽 🗼 🏰 🏯 🏟️ 🎡 🎢 🎠 ⛲ ⛱️ 🏖️ 🏝️ 🏜️ 🌋 ⛰️ 🏔️ 🗻 🏕️ ⛺ 🏠 🏡 🏘️ 🏗️ 🏭 🏢 🏬 🏣 🏥 🏦 🏨 🏪 🏫 ⛪ 🕌 🛕 🕋 🌁 🌃 🏙️ 🌅 🌆 🌉 🌌 🎇 🎆 🌠' },
{ icon : '💡' , list : '⌚ 📱 📲 💻 ⌨️ 🖥️ 🖨️ 🖱️ 🕹️ 💽 💾 💿 📀 📼 📷 📸 📹 🎥 📽️ 🎞️ 📞 ☎️ 📟 📠 📺 📻 🎙️ 🎚️ 🎛️ 🧭 ⏱️ ⏲️ ⏰ 🕰️ ⌛ ⏳ 📡 🔋 🔌 💡 🔦 🕯️ 🪔 🧯 💸 💵 💴 💶 💷 🪙 💰 💳 💎 ⚖️ 🪜 🧰 🪛 🔧 🔨 ⚒️ 🛠️ ⛏️ 🔩 ⚙️ 🧱 ⛓️ 🧲 🔫 💣 🧨 🪓 🔪 🗡️ ⚔️ 🛡️ 🚬 ⚰️ ⚱️ 🏺 🔮 📿 🧿 💈 ⚗️ 🔭 🔬 💊 💉 🩸 🧬 🦠 🧫 🧪 🌡️ 🧹 🧺 🧻 🚽 🚿 🛁 🧼 🪥 🧽 🪣 🧴 🛎️ 🔑 🗝️ 🚪 🪑 🛋️ 🛏️ 🧸 🖼️ 🛍️ 🛒 🎁 🎈 🎏 🎀 🪄 🎊 🎉 🏮 🎐 🧧 ✉️ 📩 📨 📧 💌 📦 🏷️ 📜 📄 📊 📈 📉 📅 📋 📁 📂 🗂️ 📰 📓 📔 📕 📗 📘 📙 📚 📖 🔖 🔗 📎 📐 📏 🧮 📌 📍 ✂️ 🖊️ 🖋️ ✒️ 🖌️ 🖍️ 📝 ✏️ 🔍 🔎 🔒 🔓' },
{ icon : '✅' , list : '✅ ❌ ⭕ 🚫 ❓ ❔ ❗ ❕ ‼️ ⁉️ 💯 🔅 🔆 ⚠️ 🚸 ☢️ ☣️ ⬆️ ↗️ ➡️ ↘️ ⬇️ ↙️ ⬅️ ↖️ ↕️ ↔️ ↩️ ↪️ ⤴️ ⤵️ 🔃 🔄 🔙 🔚 🔛 🔜 🔝 🛐 ⚛️ 🕉️ ✡️ ☸️ ☯️ ✝️ ☦️ ☪️ ☮️ 🕎 🔯 ♈ ♉ ♊ ♋ ♌ ♍ ♎ ♏ ♐ ♑ ♒ ♓ ⛎ 🔀 🔁 🔂 ▶️ ⏩ ⏭️ ◀️ ⏪ ⏮️ 🔼 ⏫ 🔽 ⏬ ⏸️ ⏹️ ⏺️ ⏏️ ♀️ ♂️ ⚧️ ✖️ ➕ ➖ ➗ ♾️ 💲 💱 ™️ ©️ ®️ 〰️ ➰ ➿ ✔️ ☑️ 🔘 🔴 🟠 🟡 🟢 🔵 🟣 🟤 ⚫ ⚪ 🟥 🟧 🟨 🟩 🟦 🟪 🟫 ⬛ ⬜ 🔶 🔷 🔸 🔹 🔺 🔻 💠 🔳 🔲' },
{ icon : '🏁' , list : '🏁 🚩 🎌 🏴 🏳️ 🏳️🌈 🏳️⚧️ 🏴☠️ 🇮🇳 🇺🇸 🇬🇧 🇨🇦 🇦🇺 🇩🇪 🇫🇷 🇮🇹 🇪🇸 🇯🇵 🇰🇷 🇨🇳 🇧🇷 🇷🇺 🇿🇦 🇦🇪 🇸🇬 🇲🇾 🇮🇩 🇵🇭 🇹🇭 🇳🇿 🇮🇪 🇳🇱 🇸🇪 🇨🇭' },
];
let emojiCat = 0 , emojiMode = 'compose' ;
function setReply ( m ){
replyTarget = m ;
const bar = document . getElementById ( 'replyBar' ); if ( ! bar ) return ;
const who = m . from === ME . id ? 'yourself' : ( m . fromName || currentName () || 'them' );
bar . innerHTML = '<span>' + ic ( 'reply' , 13 ) + ' Replying to <b>' + pEsc ( who ) + '</b>: ' + pEsc ( m . body . length > 80 ? m . body . slice ( 0 , 80 ) + '…' : m . body ) + '</span><span class="rx" id="replyCancel">' + ic ( 'x' , 15 ) + '</span>' ;
bar . style . display = 'flex' ;
document . getElementById ( 'replyCancel' ). onclick = clearReply ;
const inp = document . getElementById ( 'msgInput' ); if ( inp ) inp . focus ();
}
function clearReply (){ replyTarget = null ; const bar = document . getElementById ( 'replyBar' ); if ( bar ){ bar . style . display = 'none' ; bar . innerHTML = '' ; } }
// attachments
let pendingAttach = null ;
function fmtSize ( b ){ b =+ b || 0 ; if ( b < 1024 ) return b + ' B' ; if ( b < 1048576 ) return Math . round ( b / 1024 ) + ' KB' ; return ( b / 1048576 ). toFixed ( 1 ) + ' MB' ; }
async function uploadFile ( file ){
if ( ! file ) return ;
if ( file . size > 25 * 1024 * 1024 ){ toast ( 'File too large (max 25 MB)' ); return ; }
showAttachPending ( file . name , true );
try {
const r = await fetch ( '/api/messages/upload' ,{ method : 'POST' , headers : { 'Content-Type' : file . type || 'application/octet-stream' , 'X-Filename' : encodeURIComponent ( file . name ) }, body : file });
const d = await r . json (); if ( ! r . ok ) throw new Error ( d . error || 'upload failed' );
pendingAttach = d ; showAttachPending ( d . name , false );
const inp = document . getElementById ( 'msgInput' ); if ( inp ) inp . focus ();
} catch ( e ){ pendingAttach = null ; hideAttach (); toast ( e . message || 'Upload failed' ); }
}
function showAttachPending ( name , uploading ){
const bar = document . getElementById ( 'attachBar' ); if ( ! bar ) return ;
const a = pendingAttach ;
const isImg = a && /^image\// . test ( a . mime || '' );
const lead = ( ! uploading && isImg )
? '<img class="ap-thumb" src="/files/' + pEsc ( a . id ) + '" alt="">'
: '<span class="ap-ic">' + ic ( uploading ? 'paperclip' : ( isImg ? 'camera' : 'file' ), 18 ) + '</span>' ;
bar . innerHTML = '<div class="ap-item">' + lead + '<span class="ap-name">' + pEsc ( name ) + ( uploading ? ' · uploading…' : '' ) + '</span>' + ( uploading ? '' : '<button type="button" class="ap-x" id="attachCancel" title="Remove">' + ic ( 'x' , 15 ) + '</button>' ) + '</div>' ;
bar . style . display = 'block' ;
const x = document . getElementById ( 'attachCancel' ); if ( x ) x . onclick = ()=>{ pendingAttach = null ; hideAttach (); };
}
function hideAttach (){ const bar = document . getElementById ( 'attachBar' ); if ( bar ){ bar . style . display = 'none' ; bar . innerHTML = '' ; } const fi = document . getElementById ( 'fileInput' ); if ( fi ) fi . value = '' ; }
// Paste an image from the clipboard (e.g. a screenshot) straight into the composer.
function onPaste ( e ){
const items = ( e . clipboardData && e . clipboardData . items ) || [];
for ( const it of items ){
if ( it . type && it . type . indexOf ( 'image' ) === 0 ){
const blob = it . getAsFile ();
if ( blob ){ e . preventDefault (); const ext = ( blob . type . split ( '/' )[ 1 ] || 'png' ); uploadFile ( new File ([ blob ], 'screenshot-' + Date . now () + '.' + ext ,{ type : blob . type })); return ; }
}
}
}
// Close the emoji picker when clicking anywhere outside it (or its button).
document . addEventListener ( 'click' ,( e )=>{
if ( ! emojiOpen ) return ;
const pop = document . getElementById ( 'emojiPop' ), btn = document . getElementById ( 'emojiBtn' );
if ( pop && ! pop . contains ( e . target ) && ! ( btn && btn . contains ( e . target )) && ! e . target . closest ( '.react-btn' )) closeEmoji ();
});
let emojiOpen = false ;
function openEmoji ( mode , anchorEl ){ emojiMode = mode || 'compose' ; const pop = document . getElementById ( 'emojiPop' ); if ( ! pop ) return ; emojiOpen = true ; renderEmojiPop ( pop ); pop . style . display = 'flex' ; positionEmojiPop ( pop , anchorEl ); }
// Anchor the picker at its trigger: above the composer emoji icon, or at the message you reacted to.
function positionEmojiPop ( pop , anchorEl ){
const parent = pop . offsetParent || pop . parentElement ; if ( ! parent ){ return ; }
const pr = parent . getBoundingClientRect (); const popW = pop . offsetWidth || 330 , popH = pop . offsetHeight || 300 ;
let left , top ;
if ( anchorEl && anchorEl . getBoundingClientRect ){ const r = anchorEl . getBoundingClientRect (); left = r . left - pr . left ; top = r . top - pr . top - popH - 8 ; }
else { left = 12 ; top = pr . height - popH - 64 ; }
left = Math . max ( 8 , Math . min ( left , pr . width - popW - 8 ));
top = Math . max ( 8 , Math . min ( top , pr . height - popH - 8 ));
pop . style . left = left + 'px' ; pop . style . top = top + 'px' ; pop . style . bottom = 'auto' ;
}
function closeEmoji (){ const pop = document . getElementById ( 'emojiPop' ); if ( pop ) pop . style . display = 'none' ; emojiOpen = false ; }
function renderEmojiPop ( pop ){
pop . innerHTML = '<div class="emoji-tabs">' + EMOJI_CATS . map (( c , i )=> '<button type="button" data-i="' + i + '" class="' + ( i === emojiCat ? 'active' : '' ) + '">' + c . icon + '</button>' ). join ( '' ) + '</div><div class="emoji-grid" id="emojiGrid"></div>' ;
pop . querySelectorAll ( '.emoji-tabs button' ). forEach ( b => b . onclick = ()=>{ emojiCat =+ b . dataset . i ; renderEmojiPop ( pop ); });
const grid = pop . querySelector ( '#emojiGrid' );
grid . innerHTML = EMOJI_CATS [ emojiCat ]. list . trim (). split ( /\s+/ ). map ( e => '<button type="button">' + e + '</button>' ). join ( '' );
grid . querySelectorAll ( 'button' ). forEach ( b => b . onclick = ()=> onEmojiPick ( b . textContent ));
}
function onEmojiPick ( e ){ if ( emojiMode && emojiMode . react ){ reactToMessage ( emojiMode . react , e ); closeEmoji (); } else { insertEmoji ( e ); } }
function openEmojiForReact ( messageId , anchorEl ){ openEmoji ({ react : messageId }, anchorEl ); }
function insertEmoji ( e ){
const inp = document . getElementById ( 'msgInput' ); if ( ! inp ) return ;
const s = inp . selectionStart ?? inp . value . length , en = inp . selectionEnd ?? inp . value . length ;
inp . value = inp . value . slice ( 0 , s ) + e + inp . value . slice ( en );
const np = s + e . length ; inp . focus (); try { inp . setSelectionRange ( np , np ); } catch ( _ ){}
}
// reactions
function applyReaction ( m , emoji , userId , added ){
m . reactions = m . reactions || [];
let r = m . reactions . find ( x => x . emoji === emoji );
if ( added ){ if ( ! r ){ r = { emoji , count : 0 , mine : false }; m . reactions . push ( r ); } r . count ++ ; if ( userId === ME . id ) r . mine = true ; }
else if ( r ){ r . count -- ; if ( userId === ME . id ) r . mine = false ; if ( r . count <= 0 ) m . reactions = m . reactions . filter ( x => x . emoji !== emoji ); }
}
async function reactToMessage ( messageId , emoji ){
const m = THREAD . find ( x => x . id === messageId );
try { const r = await postJSON ( '/api/messages/react' ,{ messageId , emoji }); if ( m ){ m . reactions = r . reactions || []; updateBubble ( m ); } } catch ( _ ){}
}
function showMeetingCancelled ( mtg ){ if ( ! mtg ) return ; try { playPing (); } catch ( _ ){}
const w = mtg . when ? ( ' on ' + mtg . when ) : '' ;
addNotif ({ icon : 'calendarX' , text : pEsc ( mtg . by || 'Someone' ) + ' cancelled “' + pEsc ( mtg . title || 'a meeting' ) + '”' + pEsc ( w ), link : { kind : 'meeting' }});
try { notify ( '❌ Meeting cancelled' , ( mtg . by || 'Someone' ) + ' cancelled ' + ( mtg . title || 'a meeting' ) + w ); } catch ( _ ){}
toast ( '❌ “' + ( mtg . title || 'Meeting' ) + '”' + w + ' was cancelled' ); if ( currentTab () === 'meeting' ) loadScheduledMeetings (); }
function onGroupRole ( d ){ if ( ! d ||! d . group ) return ; toast ( d . admin ? 'You are now an admin of this group' : 'You are no longer an admin' ); const gi = document . getElementById ( 'groupInfo' ); if ( gi ){ gi . remove (); if ( selected && selected . kind === 'group' && selected . id === d . group ) openGroupInfo ( d . group ); } }
// A group's membership changed → refresh the sidebar (member count) live; refresh open group-info.
function onGroupUpdate ( d ){ if ( ! d ||! d . group ) return ;
loadSidebar (). then (()=>{ if ( selected && selected . kind === 'group' && selected . id === d . group ){ if ( d . removed ){ showWelcome (); toast ( 'You were removed from the group' ); } else openConvo ( 'group' , d . group ); } });
const gi = document . getElementById ( 'groupInfo' ); if ( gi ){ gi . remove (); if ( ! d . removed && selected && selected . kind === 'group' && selected . id === d . group ) openGroupInfo ( d . group ); } // refresh open group-info
}
function onChatReaction ( d ){ const m = THREAD . find ( x => x . id === d . messageId ); if ( m && d . reactions ){ m . reactions = d . reactions ; updateBubble ( m ); }
if ( d . added && d . owner === ME . id && d . byId && d . byId !== ME . id ){ addNotif ({ icon : 'smilePlus' , text : pEsc ( d . by || 'Someone' ) + ' reacted ' + ( d . emoji || '' ) + ' to your message' , link : d . convId ? { kind : 'group' , id : d . convId } : { kind : 'dm' , id : d . byId }}); }
}
// Read receipts (DM): the other party read my messages → mark mine as seen.
function onChatRead ( d ){ if ( ! d ||! d . by ) return ; if ( selected && selected . kind === 'dm' && selected . id === d . by ){ let changed = false ; THREAD . forEach ( m =>{ if ( m . from === ME . id && ! m . read_at ){ m . read_at = Date . now (); changed = true ; } }); if ( changed ) renderThread (); } }
// DM delivered: recipient's client acked → second (grey) tick.
function onChatDelivered ( d ){ if ( ! d ||! d . id ) return ; const m = THREAD . find ( x => x . id === d . id ); if ( m && ! m . delivered_at ){ m . delivered_at = Date . now (); updateBubble ( m ); } }
// Group read: a member opened the group → add them to "Seen by" on my messages up to that time.
function onGroupRead ( d ){ if ( ! d ||! d . group ) return ; if ( selected && selected . kind === 'group' && selected . id === d . group ){ THREAD . forEach ( m =>{ if ( m . from === ME . id && m . created_at <= d . at ){ m . seenBy = m . seenBy || []; if ( d . byName && ! m . seenBy . includes ( d . byName )){ m . seenBy . push ( d . byName ); updateBubble ( m ); } } }); } }
// Shared group call: start it (or join the live one — the server returns the existing room).
async function startOrJoinGroupCall ( group ){
try { const r = await postJSON ( '/api/groups/call/start' ,{ group }); if ( r && r . room ){ meetReturn = { kind : 'group' , id : group }; switchTab ( 'meeting' ); enterMeeting ( r . room ); } }
catch ( e ){ toast ( e . message || 'Could not start the call' ); }
}
function updateCallBtn ( active ){ const cc = document . getElementById ( 'convoCall' ); if ( ! cc ) return ; cc . classList . toggle ( 'joinable' , active ); cc . title = active ? 'Join call' : 'Start call' ; cc . innerHTML = ic ( active ? 'video' : 'phone' , 18 ) + ( active ? '<span>Join</span>' : '' ); }
function onGroupCall ( d ){
if ( ! d ||! d . group ) return ; const it = rowFor ( 'group' , d . group ); if ( it ){ it . callActive =!! d . active ; it . callRoom = d . room || null ; }
if ( selected && selected . kind === 'group' && selected . id === d . group ) updateCallBtn ( !! d . active );
if ( d . active && d . by && d . by !== ME . id && meetRoom !== d . room ) showCallInvite ( d . room , d . startedByName , { kind : 'group' , id : d . group }, d . groupName || ( it && it . name ) || 'Group' ); // ring members in
if ( ! d . active ) dismissCallInvite ( d . room ); // call ended — stop ringing
renderChats ( searchVal ());
}
function dismissCallInvite ( room ){ if ( ! room ) return ; const el = document . getElementById ( 'ci-' + room ); if ( el ){ try { el . remove (); } catch ( _ ){} stopRing (); } }
// 1:1 call: start/join from the DM header; live state updates the button + shows an incoming invite.
async function startOrJoinDmCall ( otherId ){
try { const r = await postJSON ( '/api/calls/dm/start' ,{ to : otherId }); if ( r && r . room ){ meetReturn = { kind : 'dm' , id : otherId }; switchTab ( 'meeting' ); enterMeeting ( r . room ); } }
catch ( e ){ toast ( e . message || 'Could not start the call' ); }
}
function onDmCall ( d ){
if ( ! d ) return ; const it = rowFor ( 'dm' , d . with ); if ( it ){ it . callActive =!! d . active ; it . callRoom = d . room || null ; }
if ( selected && selected . kind === 'dm' && selected . id === d . with ) updateCallBtn ( !! d . active );
if ( d . active && d . by && d . by !== ME . id ) showCallInvite ( d . room , d . byName , { kind : 'dm' , id : d . with }); // incoming 1:1 call
if ( ! d . active ) dismissCallInvite ( d . room ); // call ended/declined — stop ringing
renderChats ( searchVal ());
}
// Incoming-call banner (1:1 call or an add-participant invite) with Join / Dismiss.
// ret: where to land when the call ends (the originating chat), or null for the meetings tab.
// sub: a second line under the caller — e.g. the group name for a group call.
function showCallInvite ( room , byName , ret , sub ){
if ( ! room || document . getElementById ( 'ci-' + room )) return ;
startRing ();
const who = byName || 'Someone' ;
const line2 = sub ? ( 'is calling · ' + pEsc ( sub )) : 'is calling you' ;
const el = document . createElement ( 'div' ); el . className = 'call-invite' ; el . id = 'ci-' + room ;
el . innerHTML = '<span class="ci-ico">' + ic ( 'phone' , 18 ) + '</span><span class="ci-txt"><b>' + pEsc ( who ) + '</b><br>' + line2 + '</span>'
+ '<button class="ci-join">' + ic ( 'video' , 16 ) + ' Join</button>'
+ '<button class="ci-decline" title="Decline">' + ic ( 'callEnd' , 16 ) + ' Decline</button>' ;
document . body . appendChild ( el );
try { notify ( '📞 ' + who , ( sub ? ( 'Group call · ' + sub ) : 'is calling you' ), ret && ret . kind , ret && ret . id ); } catch ( _ ){} // OS notification too
let closed = false ;
const close = ()=>{ if ( closed ) return ; closed = true ; try { el . remove (); } catch ( _ ){} stopRing (); };
el . querySelector ( '.ci-join' ). onclick = ()=>{ close (); meetReturn = ret || null ; switchTab ( 'meeting' ); enterMeeting ( room ); };
el . querySelector ( '.ci-decline' ). onclick = ()=>{ close (); if ( ret && ret . kind === 'dm' ){ postJSON ( '/api/calls/decline' ,{ room }). catch (()=>{}); } }; // 1:1 → notify caller; group → silent
setTimeout ( close , 45000 );
}
// Scheduled-meeting invitation (toast + the meeting shows up in their list).
function showMeetingInvite ( mtg ){ if ( ! mtg ) return ; try { playPing (); } catch ( _ ){} toast ( '📅 ' + ( mtg . by || 'Someone' ) + ' invited you to “' + mtg . title + '”' + ( mtg . whenText ? ( ' · ' + mtg . whenText ) : '' ));
addNotif ({ icon : 'calendar' , text : pEsc ( mtg . by || 'Someone' ) + ' invited you to “' + pEsc ( mtg . title || 'a meeting' ) + '”' + ( mtg . whenText ? ( ' · ' + pEsc ( mtg . whenText )) : '' ), link : { kind : 'meeting' , code : mtg . room }});
try { notify ( '📅 Meeting invite' , ( mtg . by || 'Someone' ) + ' invited you to ' + ( mtg . title || 'a meeting' )); } catch ( _ ){}
if ( currentTab () === 'meeting' ) loadScheduledMeetings (); }
// 10-minute reminder before a scheduled meeting — prominent banner with Join.
function showMeetingReminder ( mtg ){ if ( ! mtg ||! mtg . room ) return ; try { playPing (); } catch ( _ ){}
addNotif ({ icon : 'bell' , text : '“' + pEsc ( mtg . title || 'A meeting' ) + '” starts in ~10 minutes' , link : { kind : 'meeting' , code : mtg . room }});
try { notify ( '⏰ Meeting reminder' , ( mtg . title || 'A meeting' ) + ' starts in ~10 minutes' ); } catch ( _ ){}
if ( document . getElementById ( 'mr-' + mtg . id )) return ;
const el = document . createElement ( 'div' ); el . className = 'call-invite' ; el . id = 'mr-' + mtg . id ;
el . innerHTML = '<span class="ci-ico">' + ic ( 'calendar' , 18 ) + '</span><span class="ci-txt"><b>' + pEsc ( mtg . title ) + '</b><br>starts in ~10 minutes</span><button class="ci-join">' + ic ( 'video' , 16 ) + ' Join</button><button class="ci-x" title="Dismiss">' + ic ( 'x' , 16 ) + '</button>' ;
document . body . appendChild ( el );
const close = ()=>{ try { el . remove (); } catch ( _ ){} };
el . querySelector ( '.ci-join' ). onclick = ()=>{ close (); switchTab ( 'meeting' ); enterMeeting ( mtg . room ); };
el . querySelector ( '.ci-x' ). onclick = close ;
}
// In-call participants panel (everyone) + host controls (mute all, transfer host).
function meetParticipantsList (){ const a = [{ id : '__local' , name : (( ME && ME . name ) || ( ME && ME . email ) || 'You' ) + ' (you)' }]; meetPeers . forEach (( p , pid )=> a . push ({ id : pid , name : meetNames . get ( pid ) || p . name || 'Guest' })); return a ; }
function refreshMeetPanel (){ if ( document . getElementById ( 'meetPanel' )) renderMeetPanel (); }
let meetPanelTab = 'people' ; // 'people' | 'add'
let _addPool = null ; // cached candidates for "Add people" (group members for a group call)
function toggleMeetPanel (){ const ex = document . getElementById ( 'meetPanel' ); if ( ex ){ ex . remove (); return ; } const meet = document . querySelector ( '.meet' ); if ( ! meet ) return ; meetPanelTab = 'people' ; const p = document . createElement ( 'div' ); p . id = 'meetPanel' ; p . className = 'meet-panel' ; meet . appendChild ( p ); renderMeetPanel (); }
function renderMeetPanel (){
const p = document . getElementById ( 'meetPanel' ); if ( ! p ) return ;
const list = meetParticipantsList ();
const isHostRow = pp => pp . id === '__local' ? meetIsHost : pp . id === meetHostId ; // YOUR row uses meetIsHost
const head = '<div class="mp-head"><b>Participants</b><span style="flex:1"></span>' + (( meetIsHost && meetPanelTab === 'people' ) ? '<button class="mp-muteall" title="Mute everyone">' + ic ( 'micOff' , 14 ) + ' Mute all</button>' : '' ) + '<button class="mp-x" title="Close">' + ic ( 'x' , 16 ) + '</button></div>' ;
const tabs = '<div class="mp-tabs"><button class="mp-tab' + ( meetPanelTab === 'people' ? ' on' : '' ) + '" data-tab="people">In call (' + list . length + ')</button><button class="mp-tab' + ( meetPanelTab === 'add' ? ' on' : '' ) + '" data-tab="add">' + ic ( 'userPlus' , 13 ) + ' Add people</button></div>' ;
let body ;
if ( meetPanelTab === 'add' ){
// Group call → only that group's members may be added. 1:1/ad-hoc → all team contacts.
const inGroup = meetReturn && meetReturn . kind === 'group' ;
if ( inGroup && _addPool === null ){ _addPool = []; fetch ( '/api/groups/members?group=' + encodeURIComponent ( meetReturn . id )). then ( r => r . json ()). then ( ms =>{ _addPool = ( Array . isArray ( ms ) ? ms : []). map ( x =>({ id : x . id , name : x . name })); if ( document . getElementById ( 'meetPanel' ) && meetPanelTab === 'add' ) renderMeetPanel (); }). catch (()=>{ _addPool = []; }); }
const pool = inGroup ? ( _addPool || []) : ( CONTACTS || []);
const here = new Set ( list . map ( pp =>( pp . name || '' ). replace ( /\s*\(you\)$/ , '' ). trim (). toLowerCase ()));
const myName = (( ME && ME . name ) || ( ME && ME . email ) || '' ). trim (). toLowerCase ();
const avail = pool . filter ( c => c . id !== ( ME && ME . id ) && ( c . name || '' ). trim (). toLowerCase () !== myName && ! here . has (( c . name || '' ). trim (). toLowerCase ()));
body = '<div class="mp-list">' + ( avail . length ? avail . map ( c => '<label class="chk"><input type="checkbox" value="' + pEsc ( c . id ) + '"><span class="mini-av" style="background:' + avColor ( c . name ) + '">' + pEsc ( initials ( c . name )) + '</span><span class="mn">' + pEsc ( c . name ) + '</span></label>' ). join ( '' ) : '<div class="gi-noresult">' + ( inGroup && _addPool === null ? 'Loading…' : 'Everyone\'s already here' ) + '</div>' ) + '</div><button class="gobtn" id="mpInvite" style="margin:.5rem">Invite to call</button>' ;
} else {
body = '<div class="mp-list">' + list . map ( pp => '<div class="mp-row"><span class="mini-av" style="background:' + avColor ( pp . name ) + '">' + pEsc ( initials ( pp . name )) + '</span><span class="mn">' + pEsc ( pp . name ) + '</span>' + ( isHostRow ( pp ) ? '<span class="host-tag">' + ic ( 'crown' , 11 ) + ' Host</span>' : '' ) + (( pp . id === '__local' ? meetScreen : meetSharers . has ( pp . id )) ? '<span class="pp-screen" title="Sharing screen">' + ic ( 'monitor' , 13 ) + '</span>' : '' ) + ( meetMuted . get ( pp . id ) ? '<span class="pp-mute">' + ic ( 'micOff' , 13 ) + '</span>' : '' ) + (( meetIsHost && pp . id !== '__local' &&! isHostRow ( pp )) ? '<button class="mp-makehost" data-id="' + pEsc ( pp . id ) + '" title="Make host">' + ic ( 'crown' , 14 ) + '</button>' : '' ) + '</div>' ). join ( '' ) + '</div>'
+ ( meetIsHost ? '<label class="gi-setting mp-setting"><span>' + ic ( 'monitor' , 15 ) + ' Allow multiple people to share</span><span class="switch"><input type="checkbox" id="mpMulti"' + ( meetMultiShare ? ' checked' : '' ) + '><span class="slider"></span></span></label>' : '' );
}
p . innerHTML = head + tabs + body ;
const x = p . querySelector ( '.mp-x' ); if ( x ) x . onclick = ()=> p . remove ();
p . querySelectorAll ( '.mp-tab' ). forEach ( b => b . onclick = ()=>{ meetPanelTab = b . dataset . tab ; renderMeetPanel (); });
const ma = p . querySelector ( '.mp-muteall' ); if ( ma ) ma . onclick = ()=>{ meetSend ({ type : 'meeting-muteall' }); toast ( 'Muted everyone' ); };
const mm = p . querySelector ( '#mpMulti' ); if ( mm ) mm . onchange = ()=>{ meetMultiShare = mm . checked ; meetSend ({ type : 'meeting-sharemode' , multi : meetMultiShare }); };
p . querySelectorAll ( '.mp-makehost' ). forEach ( b => b . onclick = ()=>{ meetSend ({ type : 'meeting-host' , to : b . dataset . id }); meetHostId = b . dataset . id ; meetIsHost = false ; renderMeetPanel (); });
const inv = p . querySelector ( '#mpInvite' ); if ( inv ) inv . onclick = async ()=>{ const ids = [... p . querySelectorAll ( 'input:checked' )]. map ( i => i . value ); if ( ! ids . length ){ toast ( 'Pick people to invite' ); return ; } try { const r = await postJSON ( '/api/calls/invite' ,{ room : meetRoom , userIds : ids }); toast ( 'Invited ' + r . invited + ( r . invited === 1 ? ' person' : ' people' )); meetPanelTab = 'people' ; renderMeetPanel (); } catch ( e ){ toast ( e . message || 'Could not invite' ); } };
}
// Add people to the current call (from the in-call bar).
function openInvitePicker ( room ){
if ( ! room || document . getElementById ( 'invModal' )) return ;
const ov = document . createElement ( 'div' ); ov . className = 'modal-ov' ; ov . id = 'invModal' ;
ov . innerHTML = '<div class="modal sched"><div class="gi-head" style="margin-bottom:.6rem"><div class="avatar grp" style="width:40px;height:40px;flex:0 0 40px;background:var(--blue)">' + ic ( 'userPlus' , 20 ) + '</div><div class="gi-name"><div class="gi-title">Add people to the call</div><div class="gi-sub">They get an incoming-call invite</div></div><button class="iconbtn" id="invClose">' + ic ( 'x' , 18 ) + '</button></div>'
+ '<div class="gi-list" style="max-height:46vh;overflow:auto">' + ( CONTACTS . length ? CONTACTS . map ( c => '<label class="chk"><input type="checkbox" value="' + pEsc ( c . id ) + '"><span class="mini-av" style="background:' + avColor ( c . name ) + '">' + pEsc ( initials ( c . name )) + '</span><span class="mn">' + pEsc ( c . name ) + '</span></label>' ). join ( '' ) : '<div class="gi-noresult">No contacts available</div>' ) + '</div>'
+ '<button class="gobtn" id="invBtn" style="width:100%;margin-top:.7rem">Invite</button></div>' ;
document . body . appendChild ( ov );
ov . onclick = e =>{ if ( e . target === ov ) ov . remove (); };
ov . querySelector ( '#invClose' ). onclick = ()=> ov . remove ();
ov . querySelector ( '#invBtn' ). onclick = async ()=>{ const ids = [... ov . querySelectorAll ( 'input:checked' )]. map ( i => i . value ); if ( ! ids . length ){ toast ( 'Pick people to invite' ); return ; } try { const r = await postJSON ( '/api/calls/invite' ,{ room , userIds : ids }); ov . remove (); toast ( 'Invited ' + r . invited + ( r . invited === 1 ? ' person' : ' people' )); } catch ( e ){ toast ( e . message || 'Could not invite' ); } };
}
// In-chat image viewer (lightbox): open on click, close on ✕ / backdrop / Esc.
function openLightbox ( src ){
if ( document . getElementById ( 'lightbox' )) return ;
const ov = document . createElement ( 'div' ); ov . className = 'lightbox' ; ov . id = 'lightbox' ;
ov . innerHTML = '<button class="lb-close" title="Close (Esc)">' + ic ( 'x' , 22 ) + '</button><img src="' + pEsc ( src ) + '" alt=""><a class="lb-dl" href="' + pEsc ( src ) + '" download title="Download">' + ic ( 'download' , 20 ) + '</a>' ;
document . body . appendChild ( ov );
const close = ()=>{ ov . remove (); document . removeEventListener ( 'keydown' , onKey ); };
const onKey = ( e )=>{ if ( e . key === 'Escape' ){ e . preventDefault (); close (); } };
ov . addEventListener ( 'click' ,( e )=>{ if ( e . target === ov || e . target . closest ( '.lb-close' )) close (); });
document . addEventListener ( 'keydown' , onKey );
}
function updateBubble ( m ){
const sel = ( window . CSS && CSS . escape ) ? CSS . escape ( m . id ) : m . id ;
const el = document . querySelector ( '#msgs .bubble[data-id="' + sel + '"]' );
if ( el ) el . outerHTML = bubbleHTML ( m );
}
// Floating date pill (updates to the day at the top of the viewport) + jump-to-latest button.
function onMsgsScroll (){
const box = document . getElementById ( 'msgs' ); if ( ! box ) return ;
const nearBottom = ( box . scrollHeight - box . scrollTop - box . clientHeight ) < 120 ;
const jl = document . getElementById ( 'jumpLatest' ); if ( jl ) jl . style . display = nearBottom ? 'none' : 'grid' ;
const fd = document . getElementById ( 'floatDate' ); if ( ! fd ) return ;
if ( nearBottom ){ fd . style . display = 'none' ; return ; }
const boxTop = box . getBoundingClientRect (). top ; let label = '' ;
box . querySelectorAll ( '.day-sep' ). forEach ( s =>{ if ( s . getBoundingClientRect (). top - boxTop <= 10 ) label = s . textContent ; });
if ( label ){ fd . style . top = ( box . offsetTop + 6 ) + 'px' ; fd . textContent = label ; fd . style . display = 'block' ; } else fd . style . display = 'none' ;
}
function dayKey ( ts ){ return new Date ( ts || Date . now ()). toDateString (); }
function dayLabel ( ts ){ const d = new Date ( ts || Date . now ()), n = new Date (); if ( d . toDateString () === n . toDateString ()) return 'Today' ; const y = new Date ( n ); y . setDate ( n . getDate () - 1 ); if ( d . toDateString () === y . toDateString ()) return 'Yesterday' ; return d . toLocaleDateString ([], { weekday : 'long' , month : 'long' , day : 'numeric' , year : 'numeric' }); }
let _lastDay = '' , _lastMineId = '' ; // _lastMineId: only my newest message shows the group "Seen by"
function lastMineId (){ for ( let i = THREAD . length - 1 ; i >= 0 ; i -- ){ if ( THREAD [ i ]. from === ME . id && ! THREAD [ i ]. system ) return THREAD [ i ]. id ; } return '' ; }
function renderThread (){
const box = document . getElementById ( 'msgs' ); if ( ! box ) return ;
rendered . clear (); _lastDay = '' ; _lastMineId = lastMineId ();
if ( ! THREAD . length ){ box . innerHTML = '<div class="empty-thread">No messages yet — say hello 👋</div>' ; return ; }
let html = '' ;
for ( const m of THREAD ){ const dk = dayKey ( m . created_at ); if ( dk !== _lastDay ){ html += '<div class="day-sep"><span>' + pEsc ( dayLabel ( m . created_at )) + '</span></div>' ; _lastDay = dk ; } rendered . add ( m . id ); html += bubbleHTML ( m ); }
box . innerHTML = html ; box . scrollTop = box . scrollHeight ;
}
function appendBubble ( m ){
if ( rendered . has ( m . id )) return ; rendered . add ( m . id );
const box = document . getElementById ( 'msgs' ); if ( ! box ) return ;
const empty = box . querySelector ( '.empty-thread' ); if ( empty ) empty . remove ();
// A new message of mine becomes the newest: drop the "Seen by" from the previous one.
if ( m . from === ME . id && ! m . system ){ if ( _lastMineId ){ const pe = box . querySelector ( '.bubble[data-id="' + (( window . CSS && CSS . escape ) ? CSS . escape ( _lastMineId ) : _lastMineId ) + '"] .seenby' ); if ( pe ) pe . remove (); } _lastMineId = m . id ; }
const dk = dayKey ( m . created_at ); if ( dk !== _lastDay ){ box . insertAdjacentHTML ( 'beforeend' , '<div class="day-sep"><span>' + pEsc ( dayLabel ( m . created_at )) + '</span></div>' ); _lastDay = dk ; }
box . insertAdjacentHTML ( 'beforeend' , bubbleHTML ( m ));
box . scrollTop = box . scrollHeight ;
}
async function openConvo ( kind , id ){
const it = rowFor ( kind , id ) || { kind , id , name : 'Conversation' };
convoIsGroup = ( kind === 'group' );
const el = document . getElementById ( 'chatPanel' ); el . classList . remove ( 'center' );
el . innerHTML = convoShellHTML ( it );
const back = document . getElementById ( 'convoBack' ); if ( back ) back . onclick = showWelcome ;
const form = document . getElementById ( 'composer' ); if ( form ) form . addEventListener ( 'submit' ,( e )=>{ e . preventDefault (); sendMessage (); });
clearReply (); pendingAttach = null ; hideAttach ();
const ab2 = document . getElementById ( 'attachBtn' ); if ( ab2 ) ab2 . onclick = ()=>{ const fi = document . getElementById ( 'fileInput' ); if ( fi ) fi . click (); };
const fi = document . getElementById ( 'fileInput' ); if ( fi ) fi . onchange = ()=>{ if ( fi . files && fi . files [ 0 ]) uploadFile ( fi . files [ 0 ]); };
const eb = document . getElementById ( 'emojiBtn' ); if ( eb ) eb . onclick = ( e )=>{ e . stopPropagation (); emojiOpen ? closeEmoji () : openEmoji ( 'compose' , eb ); };
const fb = document . getElementById ( 'fmtBtn' ); if ( fb ) fb . onclick = ()=>{ const bar = document . getElementById ( 'fmtBar' ); if ( bar ) bar . style . display = bar . style . display === 'none' ? 'flex' : 'none' ; };
const fbar = document . getElementById ( 'fmtBar' ); if ( fbar ) fbar . querySelectorAll ( 'button[data-fmt]' ). forEach ( b => b . onclick = ()=> applyFmt ( b . dataset . fmt ));
const pb = document . getElementById ( 'pollBtn' ); if ( pb ) pb . onclick = ()=> openPollModal ( id );
const inpEl = document . getElementById ( 'msgInput' );
if ( inpEl ){
inpEl . addEventListener ( 'paste' , onPaste );
inpEl . addEventListener ( 'input' , ()=> autoGrow ( inpEl ));
inpEl . addEventListener ( 'keydown' , ( e )=>{ if ( e . key === 'Enter' && ! e . shiftKey ){ if ( mentionItems && mentionItems . length ) return ; e . preventDefault (); sendMessage (); } }); // Enter sends, Shift+Enter = newline
}
const ci = document . getElementById ( 'convoInfo' ); if ( ci ) ci . onclick = ()=> openGroupInfo ( id );
const ct = document . getElementById ( 'convoTitle' ); if ( ct ) ct . onclick = ()=> openGroupInfo ( id );
const cc = document . getElementById ( 'convoCall' ); if ( cc ) cc . onclick = ()=>( kind === 'group' ? startOrJoinGroupCall ( id ) : startOrJoinDmCall ( id ));
const box = document . getElementById ( 'msgs' ); if ( box ) box . addEventListener ( 'click' ,( e )=>{
const im = e . target . closest ( '.att-img' ); if ( im && im . dataset . img ){ openLightbox ( im . dataset . img ); return ; }
const po = e . target . closest ( '.poll-opt' ); if ( po ){ if ( ! po . disabled ) votePoll ( po . dataset . poll , + po . dataset . idx ); return ; }
const pcl = e . target . closest ( '.poll-close' ); if ( pcl ){ closePoll ( pcl . dataset . poll ); return ; }
const rb = e . target . closest ( '.reply-btn' ); if ( rb ){ const mm = THREAD . find ( x => x . id === rb . dataset . id ); if ( mm ) setReply ( mm ); return ; }
const ab = e . target . closest ( '.react-btn' ); if ( ab ){ openEmojiForReact ( ab . dataset . id , ab ); return ; }
const ch = e . target . closest ( '.react-chip' ); if ( ch ){ reactToMessage ( ch . dataset . id , ch . dataset . emoji ); return ; }
const sb = e . target . closest ( '.seenby' ); if ( sb ){ const ns = ( sb . dataset . seen || '' ). split ( '|' ). filter ( Boolean ); toast ( 'Seen by: ' + ns . join ( ', ' )); return ; }
});
if ( box ) box . addEventListener ( 'scroll' , onMsgsScroll );
const jl = document . getElementById ( 'jumpLatest' ); if ( jl ) jl . onclick = ()=>{ const b = document . getElementById ( 'msgs' ); if ( b ) b . scrollTop = b . scrollHeight ; };
composeMentions = new Map (); convoMembers = [];
// Synchronous render from cache (within the click's activation → paints immediately, even
// when opened from a notification; an async-only render would defer the paint until a click).
const ckey = kind + ':' + id ;
if ( THREAD_CACHE . has ( ckey )){ THREAD = THREAD_CACHE . get ( ckey ). slice (); renderThread (); }
if ( kind === 'group' ){ try { convoMembers = await fetch ( '/api/groups/members?group=' + encodeURIComponent ( id )). then ( r => r . json ()) || []; } catch ( _ ){ convoMembers = []; } }
if ( ! selected || selected . kind !== kind || selected . id !== id ) return ;
wireMentions ();
const url = kind === 'group' ? ( '/api/messages/thread?group=' + encodeURIComponent ( id )) : ( '/api/messages/thread?with=' + encodeURIComponent ( id ));
let msgs = []; try { msgs = await fetch ( url ). then ( r => r . json ()); } catch ( _ ){}
if ( ! selected || selected . kind !== kind || selected . id !== id ) return ; // switched away while loading
THREAD = Array . isArray ( msgs ) ? msgs : [];
THREAD_CACHE . set ( ckey , THREAD . slice ());
renderThread ();
const inp = document . getElementById ( 'msgInput' ); if ( inp ) inp . focus ();
}
// ---------- @mentions (group chat) ----------
let mentionItems = [], mentionIdx = 0 , mentionStart =- 1 ;
function wireMentions (){
const inp = document . getElementById ( 'msgInput' ); if ( ! inp ) return ;
inp . addEventListener ( 'input' , onMentionInput );
inp . addEventListener ( 'keydown' , onMentionKey );
inp . addEventListener ( 'blur' , ()=> setTimeout ( closeMention , 150 ));
}
function onMentionInput ( e ){
const inp = e . target ; if ( ! convoIsGroup ){ closeMention (); return ; }
const pos = inp . selectionStart ;
const m = inp . value . slice ( 0 , pos ). match ( /(?:^|\s)@([\p{L}\p{N}_]*)$/u );
if ( ! m ){ closeMention (); return ; }
mentionStart = pos - m [ 1 ]. length - 1 ;
const q = m [ 1 ]. toLowerCase ();
const opts = [];
if ( ! q || 'everyone' . startsWith ( q ) || 'all' . startsWith ( q )) opts . push ({ id : 'everyone' , name : 'everyone' , sub : 'Notify the whole group' });
for ( const mem of convoMembers ){ if ( mem . id === ME . id ) continue ; if ( ! q || ( mem . name || '' ). toLowerCase (). includes ( q )) opts . push ({ id : mem . id , name : mem . name , avatar : mem . avatar }); }
if ( ! opts . length ){ closeMention (); return ; }
mentionItems = opts . slice ( 0 , 8 ); mentionIdx = 0 ; renderMentionPop ();
}
function renderMentionPop (){
const pop = document . getElementById ( 'mentionPop' ); if ( ! pop ) return ;
pop . innerHTML = mentionItems . map (( o , i )=> '<div class="mrow' + ( i === mentionIdx ? ' sel' : '' ) + '" data-i="' + i + '">'
+ ( o . id === 'everyone' ? '<span class="mini-av" style="background:var(--blue)">' + ic ( 'users' , 15 ) + '</span>' : '<span class="mini-av" style="background:' + avColor ( o . name ) + '">' + pEsc ( initials ( o . name )) + ( o . avatar ? '<img class="av-img" src="' + pEsc ( o . avatar ) + '" alt="" onerror="this.remove()">' : '' ) + '</span>' )
+ '<span class="mn">' + ( o . id === 'everyone' ? '@everyone' : pEsc ( o . name )) + '</span>' + ( o . sub ? '<span class="sub">' + pEsc ( o . sub ) + '</span>' : '' ) + '</div>' ). join ( '' );
pop . style . display = 'block' ;
pop . querySelectorAll ( '.mrow' ). forEach ( r =>{ r . onmousedown = ( ev )=>{ ev . preventDefault (); chooseMention ( + r . dataset . i ); }; });
}
function closeMention (){ const pop = document . getElementById ( 'mentionPop' ); if ( pop ){ pop . style . display = 'none' ; pop . innerHTML = '' ; } mentionItems = []; mentionStart =- 1 ; }
function onMentionKey ( e ){
if ( ! mentionItems . length ) return ;
if ( e . key === 'ArrowDown' ){ e . preventDefault (); mentionIdx = ( mentionIdx + 1 ) % mentionItems . length ; renderMentionPop (); }
else if ( e . key === 'ArrowUp' ){ e . preventDefault (); mentionIdx = ( mentionIdx - 1 + mentionItems . length ) % mentionItems . length ; renderMentionPop (); }
else if ( e . key === 'Enter' || e . key === 'Tab' ){ e . preventDefault (); chooseMention ( mentionIdx ); }
else if ( e . key === 'Escape' ){ closeMention (); }
}
function chooseMention ( i ){
const o = mentionItems [ i ]; if ( ! o ) return ;
const inp = document . getElementById ( 'msgInput' ); if ( ! inp || mentionStart < 0 ) return ;
const pos = inp . selectionStart ;
const before = inp . value . slice ( 0 , mentionStart ), after = inp . value . slice ( pos );
const token = ( o . id === 'everyone' ) ? '@everyone' : ( '@' + o . name );
inp . value = before + token + ' ' + after ;
const np = ( before + token + ' ' ). length ; inp . setSelectionRange ( np , np );
composeMentions . set ( o . id === 'everyone' ? 'everyone' : token , o . id );
closeMention (); inp . focus ();
}
// Resolve the draft's mentions to ids (only those still present in the text) + literal @everyone/@all.
function collectMentions ( text ){
const out = [];
for ( const [ tok , idv ] of composeMentions ){ if ( tok === 'everyone' ) continue ; if ( text . includes ( tok )) out . push ( idv ); }
if ( /(?:^|\s)@(everyone|all)\b/i . test ( text )) out . push ( 'everyone' );
return [... new Set ( out )];
}
// ---- Composer formatting toolbar (Markdown-style) ----
function applyFmt ( kind ){
const inp = document . getElementById ( 'msgInput' ); if ( ! inp ) return ;
const wrap = ( pre , suf )=>{ const s = inp . selectionStart , e = inp . selectionEnd , sel = inp . value . slice ( s , e ) || 'text' ; inp . value = inp . value . slice ( 0 , s ) + pre + sel + suf + inp . value . slice ( e ); inp . focus (); inp . setSelectionRange ( s + pre . length , s + pre . length + sel . length ); autoGrow ( inp ); };
const prefixLines = ( ol )=>{ const v = inp . value ; let s = inp . selectionStart , e = inp . selectionEnd ; let ls = v . lastIndexOf ( '\n' , s - 1 ) + 1 , le = v . indexOf ( '\n' , e ); if ( le ===- 1 ) le = v . length ; const lines = v . slice ( ls , le ). split ( '\n' ); const out = lines . map (( ln , i )=>( ol ? (( i + 1 ) + '. ' ) : '- ' ) + ln ). join ( '\n' ); inp . value = v . slice ( 0 , ls ) + out + v . slice ( le ); inp . focus (); inp . setSelectionRange ( ls , ls + out . length ); autoGrow ( inp ); };
if ( kind === 'bold' ) wrap ( '**' , '**' ); else if ( kind === 'italic' ) wrap ( '*' , '*' ); else if ( kind === 'strike' ) wrap ( '~~' , '~~' ); else if ( kind === 'code' ) wrap ( '`' , '`' ); else if ( kind === 'ul' ) prefixLines ( false ); else if ( kind === 'ol' ) prefixLines ( true );
}
// Inline Markdown on an already-HTML-escaped line (code/bold/strike/italic).
function fmtInline ( s ){
s = s . replace ( /`([^`\n]+)`/g , '<code>$1</code>' );
s = s . replace ( /\*\*([^*\n]+)\*\*/g , '<b>$1</b>' );
s = s . replace ( /~~([^~\n]+)~~/g , '<s>$1</s>' );
s = s . replace ( /(^|[^\w*])\*([^*\n]+)\*(?=[^\w*]|$)/g , '$1<i>$2</i>' );
return s ;
}
function fmtMentions ( s ){ // s already HTML-escaped
s = s . replace ( /(^|\s)@(everyone|all)\b/gi ,( mm , pre )=> pre + '<span class="mention all">@everyone</span>' );
if ( convoIsGroup ){ for ( const mem of convoMembers ){ if ( ! mem . name ) continue ; const esc = pEsc ( '@' + mem . name ); if ( esc && s . includes ( esc )) s = s . split ( esc ). join ( '<span class="mention">' + esc + '</span>' ); } }
return s ;
}
const fmtSeg = ( line )=> fmtInline ( fmtMentions ( line ));
// Render a message body: lists (-, *, •, 1.) + inline Markdown + mentions, newlines as <br>.
function renderMsgBody ( m ){
const lines = pEsc ( m . body || '' ). split ( '\n' );
let out = '' , list = null ; const buf = [], para = [];
const flushList = ()=>{ if ( list ){ out += '<' + list + ' class="msg-list">' + buf . map ( x => '<li>' + x + '</li>' ). join ( '' ) + '</' + list + '>' ; buf . length = 0 ; list = null ; } };
const flushPara = ()=>{ if ( para . length ){ out += para . join ( '<br>' ); para . length = 0 ; } };
for ( const ln of lines ){
const ul = ln . match ( /^\s*(?:[-*•])\s+(.+)$/ ), ol = ln . match ( /^\s*\d+[.)]\s+(.+)$/ );
if ( ul ){ flushPara (); if ( list !== 'ul' ){ flushList (); list = 'ul' ; } buf . push ( fmtSeg ( ul [ 1 ])); }
else if ( ol ){ flushPara (); if ( list !== 'ol' ){ flushList (); list = 'ol' ; } buf . push ( fmtSeg ( ol [ 1 ])); }
else { flushList (); para . push ( fmtSeg ( ln )); }
}
flushList (); flushPara ();
return out ;
}
// ---------- Polls (group chat) ----------
function pollHTML ( m ){
const p = m . poll ; if ( ! p ) return '' ;
const max = Math . max ( 1 , ... p . options . map ( o => o . votes || 0 ));
const opts = p . options . map (( o , i )=>{
const pct = p . totalVotes ? Math . round ( o . votes / p . totalVotes * 100 ) : 0 ;
const w = Math . round (( o . votes || 0 ) / max * 100 );
return '<button class="poll-opt' + ( o . mine ? ' mine' : '' ) + '" data-poll="' + pEsc ( p . id ) + '" data-idx="' + i + '"' + ( p . closed ? ' disabled' : '' ) + '>'
+ '<span class="po-bar" style="width:' + w + '%"></span>'
+ '<span class="po-txt">' + ( o . mine ? ic ( 'check' , 13 ) : '' ) + ' ' + pEsc ( o . text ) + '</span>'
+ '<span class="po-pct">' + pct + '% · ' + ( o . votes || 0 ) + '</span></button>' ;
}). join ( '' );
const foot = '<div class="poll-foot">' + p . voters + ' voter' + ( p . voters === 1 ? '' : 's' ) + ( p . multi ? ' · choose multiple' : '' ) + ( p . closed ? ' · <b>Closed</b>' : '' )
+ (( p . isOwner &&! p . closed ) ? ' · <a class="poll-close" data-poll="' + pEsc ( p . id ) + '">Close poll</a>' : '' ) + '</div>' ;
return '<div class="poll" data-msg="' + pEsc ( m . id ) + '"><div class="poll-q">' + ic ( 'barChart' , 14 ) + ' Poll' + ( p . multi ? ' · multiple' : '' ) + '</div>' + opts + foot + '</div>' ;
}
function updatePollInThread ( messageId , poll ){
const m = THREAD . find ( x => x . id === messageId ); if ( m ) m . poll = poll ;
document . querySelectorAll ( '.poll' ). forEach ( el =>{ if ( el . getAttribute ( 'data-msg' ) === messageId && m ){ el . outerHTML = pollHTML ( m ); } });
}
async function votePoll ( pollId , idx ){
try { const poll = await postJSON ( '/api/polls/vote' ,{ pollId , optionIdx : idx }); const m = THREAD . find ( x => x . poll && x . poll . id === pollId ); if ( m ) updatePollInThread ( m . id , poll ); }
catch ( e ){ toast ( e . message || 'Could not vote' ); }
}
async function closePoll ( pollId ){
if ( ! confirm ( 'Close this poll? No more votes can be cast.' )) return ;
try { const poll = await postJSON ( '/api/polls/close' ,{ pollId }); const m = THREAD . find ( x => x . poll && x . poll . id === pollId ); if ( m ) updatePollInThread ( m . id , poll ); }
catch ( e ){ toast ( e . message || 'Could not close poll' ); }
}
function onPollUpdate ( d ){ const m = THREAD . find ( x => x . poll && x . poll . id === d . poll . id ); if ( m ) updatePollInThread ( m . id , d . poll ); }
function openPollModal ( gid ){
if ( document . getElementById ( 'pollModal' )) return ;
const ov = document . createElement ( 'div' ); ov . className = 'modal-ov' ; ov . id = 'pollModal' ;
ov . innerHTML = '<div class="modal sched">'
+ '<div class="gi-head" style="margin-bottom:.6rem"><div class="avatar grp" style="width:40px;height:40px;flex:0 0 40px;background:var(--blue)">' + ic ( 'barChart' , 20 ) + '</div><div class="gi-name"><div class="gi-title">Create a poll</div><div class="gi-sub">Members vote in the group</div></div><button class="iconbtn" id="pollClose">' + ic ( 'x' , 18 ) + '</button></div>'
+ '<label class="flbl">Question</label><input id="pollQ" class="finput" placeholder="What should we decide?" maxlength="300">'
+ '<label class="flbl">Options</label><div id="pollOpts"></div>'
+ '<button class="linkbtn" id="pollAdd" style="margin-top:.4rem">' + ic ( 'plus' , 15 ) + ' Add option</button>'
+ '<label class="chk" style="margin-top:.7rem"><input type="checkbox" id="pollMulti"> Allow multiple choices</label>'
+ '<button class="gobtn" id="pollCreate" style="width:100%;margin-top:.9rem;background:var(--blue);color:#fff">Create poll</button>'
+ '<div class="hint" id="pollErr"></div></div>' ;
document . body . appendChild ( ov );
const optsWrap = ov . querySelector ( '#pollOpts' );
const addOpt = ()=>{ if ( optsWrap . children . length >= 10 ) return ; const row = document . createElement ( 'div' ); row . className = 'poll-opt-row' ; row . innerHTML = '<input class="finput" maxlength="120" placeholder="Option ' + ( optsWrap . children . length + 1 ) + '"><button class="iconbtn rm" title="Remove">' + ic ( 'x' , 15 ) + '</button>' ; optsWrap . appendChild ( row ); row . querySelector ( '.rm' ). onclick = ()=>{ if ( optsWrap . children . length > 2 ) row . remove (); }; };
addOpt (); addOpt ();
ov . onclick = e =>{ if ( e . target === ov ) ov . remove (); };
ov . querySelector ( '#pollClose' ). onclick = ()=> ov . remove ();
ov . querySelector ( '#pollAdd' ). onclick = addOpt ;
setTimeout (()=>{ const q = ov . querySelector ( '#pollQ' ); if ( q ) q . focus (); }, 0 );
ov . querySelector ( '#pollCreate' ). onclick = async ()=>{
const q = ov . querySelector ( '#pollQ' ). value . trim ();
const options = [... optsWrap . querySelectorAll ( 'input' )]. map ( i => i . value . trim ()). filter ( Boolean );
const multi = ov . querySelector ( '#pollMulti' ). checked ;
const err = ov . querySelector ( '#pollErr' );
if ( ! q ){ err . textContent = 'Add a question.' ; return ; }
if ( options . length < 2 ){ err . textContent = 'Add at least two options.' ; return ; }
try { await postJSON ( '/api/polls' ,{ group : gid , question : q , options , multi }); ov . remove (); }
catch ( e ){ err . textContent = e . message || 'Could not create poll' ; }
};
}
async function selectChat ( kind , id ){
ensureNotifyPermission ();
selected = { kind , id };
document . body . classList . add ( 'chat-open' ); // mobile: show the conversation pane
const it = rowFor ( kind , id ); if ( it ) it . unread = 0 ;
renderChats ( searchVal ());
updateRailUnread ();
await openConvo ( kind , id );
}
async function sendMessage (){
const inp = document . getElementById ( 'msgInput' ); if ( ! inp ) return ;
const text = inp . value . trim (); if (( ! text &&! pendingAttach ) ||! selected ) return ;
inp . value = '' ; inp . style . height = 'auto' ;
const replyTo = replyTarget ? replyTarget . id : null ;
const attachmentId = pendingAttach ? pendingAttach . id : null ;
const payload = selected . kind === 'group' ? { group : selected . id , body : text , replyTo , attachmentId , mentions : collectMentions ( text ) } : { to : selected . id , body : text , replyTo , attachmentId };
try {
const m = await postJSON ( '/api/messages' , payload );
composeMentions = new Map ();
2026-06-24 13:39:42 +05:30
// Dedup by id: the server echoes our message over WS (to sync other tabs) and that echo
// can arrive BEFORE this POST resolves, so onChatMessage may have already appended it.
if ( ! THREAD . some ( x => x . id === m . id )){ THREAD . push ( m ); appendBubble ( m ); }
clearReply (); pendingAttach = null ; hideAttach ();
2026-06-23 16:15:29 +05:30
{ const ck = selected . kind + ':' + selected . id ; if ( THREAD_CACHE . has ( ck )){ const a = THREAD_CACHE . get ( ck ); if ( ! a . some ( x => x . id === m . id )) a . push ( m ); } }
const it = rowFor ( selected . kind , selected . id );
if ( it ){ it . last_body = m . body || ( m . attachment ? '📎 ' + ( m . attachment . name || 'Attachment' ) : '' ); it . last_at = m . created_at ; it . last_from_me = true ; it . unread = 0 ; }
renderChats ( searchVal ());
} catch ( e ){ inp . value = text ; toast ( e . message || 'Could not send' ); }
}
function ensureNotifyPermission (){ try { if ( 'Notification' in window && Notification . permission === 'default' ) Notification . requestPermission (); } catch ( _ ){} }
// Notification preferences (set in Dashboard → Settings; stored per browser). Default ON.
function notifOn ( kind ){ try { return localStorage . getItem ( 'notif_' + kind ) !== 'off' ; } catch ( _ ){ return true ; } }
document . addEventListener ( 'click' , ensureNotifyPermission , { once : true });
2026-06-23 21:58:49 +05:30
// ----- Web Push: reliable notifications when the tab is backgrounded / frozen / closed -----
// A notification-only service worker (sw.js, no caching) shows OS popups via the push service.
// pushActive => the page can leave hidden-tab popups to push (avoids double-notifying).
let pushActive = false , _swReg = null ;
function urlB64ToUint8 ( base64 ){ const pad = '=' . repeat (( 4 - base64 . length % 4 ) % 4 ); const b = ( base64 + pad ). replace ( /-/g , '+' ). replace ( /_/g , '/' ); const raw = atob ( b ); const arr = new Uint8Array ( raw . length ); for ( let i = 0 ; i < raw . length ; i ++ ) arr [ i ] = raw . charCodeAt ( i ); return arr ; }
async function setupPush (){
2026-06-24 13:57:03 +05:30
if ( ! ( 'serviceWorker' in navigator ) || ! ( 'PushManager' in window )){ console . warn ( '[push] not supported by this browser' ); return ; }
try { await navigator . serviceWorker . register ( '/sw.js' ); } catch ( e ){ console . warn ( '[push] SW register failed:' , e ); return ; }
try { _swReg = await navigator . serviceWorker . ready ; } catch ( e ){ console . warn ( '[push] SW never became ready:' , e ); return ; } // ensure an ACTIVE worker before subscribe()
console . log ( '[push] service worker ready' );
2026-06-23 21:58:49 +05:30
// Clicking an OS notification asks us (if a tab is already open) to open that chat in place.
try { navigator . serviceWorker . addEventListener ( 'message' ,( e )=>{ const d = e . data || {}; if ( d . type === 'open-chat' && d . id ){ try { selectChat ( d . kind || 'dm' , d . id ); } catch ( _ ){} } }); } catch ( _ ){}
2026-06-24 13:57:03 +05:30
await subscribePush ();
2026-06-23 21:58:49 +05:30
}
async function subscribePush (){
2026-06-24 13:57:03 +05:30
try {
if ( ! _swReg ){ try { _swReg = await navigator . serviceWorker . ready ; } catch ( _ ){ return ; } }
if ( ! ( 'Notification' in window ) || Notification . permission !== 'granted' ){ console . log ( '[push] permission not granted (' + ( window . Notification ? Notification . permission : 'n/a' ) + ') — skipping subscribe' ); return ; }
let cfg ; try { cfg = await fetch ( '/api/push/vapid' ). then ( r => r . json ()); } catch ( e ){ console . warn ( '[push] /api/push/vapid failed:' , e ); return ; }
if ( ! cfg || ! cfg . enabled || ! cfg . key ){ console . warn ( '[push] server push disabled (VAPID not set):' , cfg ); return ; }
let sub = await _swReg . pushManager . getSubscription ();
if ( ! sub ){ sub = await _swReg . pushManager . subscribe ({ userVisibleOnly : true , applicationServerKey : urlB64ToUint8 ( cfg . key ) }); }
await postJSON ( '/api/push/subscribe' , sub . toJSON ());
pushActive = true ; console . log ( '[push] subscribed OK' );
} catch ( e ){ console . warn ( '[push] subscribe failed:' , e ); } // surfaced (not swallowed) so we can see the real reason
2026-06-23 21:58:49 +05:30
}
// Open the chat from an in-page notification. Navigation reliably repaints across browsers (a
2026-06-23 16:15:29 +05:30
// notification click is not an in-page gesture, so an in-place open won't paint until you
// tap). The reload is made fast by HTTP caching + a boot fast-path that opens the chat first.
function notify ( title , body , kind , id ){
try {
if ( ! ( 'Notification' in window ) || Notification . permission !== 'granted' ) return ;
const n = new Notification ( title , { body , icon : '/logo.png' });
n . onclick = ()=>{ try { window . focus (); } catch ( _ ){} const u = '/home?openKind=' + encodeURIComponent ( kind || '' ) + '&openId=' + encodeURIComponent ( id || '' ); n . close (); location . assign ( u ); };
setTimeout (()=>{ try { n . close (); } catch ( _ ){} }, 8000 );
} catch ( _ ){}
}
let _audioCtx = null ;
function playPing (){
try {
_audioCtx = _audioCtx || new ( window . AudioContext || window . webkitAudioContext )();
if ( _audioCtx . state === 'suspended' ) _audioCtx . resume ();
const t = _audioCtx . currentTime , o = _audioCtx . createOscillator (), g = _audioCtx . createGain ();
o . type = 'sine' ; o . frequency . setValueAtTime ( 880 , t ); o . frequency . setValueAtTime ( 660 , t + 0.09 );
g . gain . setValueAtTime ( 0.0001 , t ); g . gain . exponentialRampToValueAtTime ( 0.14 , t + 0.012 ); g . gain . exponentialRampToValueAtTime ( 0.0001 , t + 0.35 );
o . connect ( g ); g . connect ( _audioCtx . destination ); o . start ( t ); o . stop ( t + 0.36 );
} catch ( _ ){}
}
// Continuous incoming-call ring. A soft, soothing bell chime — a gentle ascending arpeggio
// (C major) of pure sine tones with an octave shimmer and a long smooth decay, so it rings
// rather than buzzes. Ref-counted: several pending invites share one ring; it stops on the last close.
let _ringTimer = null , _ringRefs = 0 ;
function ringTone ( t , freq , dur , peak ){
const g = _audioCtx . createGain (); g . connect ( _audioCtx . destination );
g . gain . setValueAtTime ( 0.0001 , t );
g . gain . exponentialRampToValueAtTime ( peak , t + 0.03 ); // gentle attack (no click)
g . gain . exponentialRampToValueAtTime ( 0.0001 , t + dur ); // long, smooth bell-like decay
const o = _audioCtx . createOscillator (); o . type = 'sine' ; o . frequency . value = freq ; o . connect ( g ); o . start ( t ); o . stop ( t + dur );
const o2 = _audioCtx . createOscillator (); o2 . type = 'sine' ; o2 . frequency . value = freq * 2 ; // octave shimmer
const g2 = _audioCtx . createGain (); g2 . gain . value = 0.3 ; o2 . connect ( g2 ); g2 . connect ( g ); o2 . start ( t ); o2 . stop ( t + dur );
}
function ringOnce (){
try {
_audioCtx = _audioCtx || new ( window . AudioContext || window . webkitAudioContext )();
if ( _audioCtx . state === 'suspended' ) _audioCtx . resume ();
const t = _audioCtx . currentTime ;
ringTone ( t , 523.25 , 1.2 , 0.11 ); // C5
ringTone ( t + 0.20 , 659.25 , 1.2 , 0.10 ); // E5
ringTone ( t + 0.40 , 783.99 , 1.5 , 0.11 ); // G5
ringTone ( t + 0.60 , 1046.5 , 1.9 , 0.09 ); // C6 — rings out softly
} catch ( _ ){}
}
function startRing (){ _ringRefs ++ ; if ( _ringTimer ) return ; ringOnce (); _ringTimer = setInterval ( ringOnce , 3500 ); }
function stopRing ( force ){ _ringRefs = force ? 0 : Math . max ( 0 , _ringRefs - 1 ); if ( _ringRefs > 0 ) return ; if ( _ringTimer ){ clearInterval ( _ringTimer ); _ringTimer = null ; } }
function onChatMessage ( m ){
const isGroupMsg =!! m . conversation_id ;
const kind = isGroupMsg ? 'group' : 'dm' ;
const rid = isGroupMsg ? m . conversation_id : ( m . from === ME . id ? m . to : m . from );
let it = rowFor ( kind , rid ); const wasNew =! it ;
if ( ! it ){ loadSidebar (); } // first DM / a new group we were added to — refresh the list
// Keep the thread cache warm so a notification click can render this chat synchronously.
const ckey = kind + ':' + rid ;
if ( THREAD_CACHE . has ( ckey )){ const arr = THREAD_CACHE . get ( ckey ); if ( ! arr . some ( x => x . id === m . id )) arr . push ( m ); }
else { const pu = ( kind === 'group' ? '/api/messages/thread?group=' + encodeURIComponent ( rid ) : '/api/messages/thread?with=' + encodeURIComponent ( rid )) + '&peek=1' ; fetch ( pu ). then ( r => r . json ()). then ( a =>{ if ( Array . isArray ( a )) THREAD_CACHE . set ( ckey , a ); }). catch (()=>{}); }
const isOpen = selected && selected . kind === kind && selected . id === rid && currentTab () === 'chat' ;
const isSys =!! m . system || m . from === '__system__' ; // activity lines: show in chat, but no ping/notify/unread
if ( m . from !== ME . id && ! isSys ){
if ( ! isGroupMsg && chatWs && chatWs . readyState === 1 ){ try { chatWs . send ( JSON . stringify ({ type : 'chat-delivered' , id : m . id })); } catch ( _ ){} } // ack DM delivery
2026-06-23 21:58:49 +05:30
// Popup rule: ping always. In-page popup only when the tab is VISIBLE but you're on another
// chat. When the tab is HIDDEN, let Web Push show it (the SW). If push isn't active, fall
// back to an in-page popup so hidden-tab users still get alerted.
if ( notifOn ( kind )){ playPing (); const wantPopup =! ( isOpen && ! document . hidden ); if ( wantPopup && ! ( document . hidden && pushActive )) notify (( m . fromName || 'New message' ), m . body ? ( m . body . length > 80 ? m . body . slice ( 0 , 80 ) + '…' : m . body ) : 'Sent an attachment' , kind , rid ); }
2026-06-23 16:15:29 +05:30
// Activity-center entries for things easy to miss.
if ( m . poll ) addNotif ({ icon : 'barChart' , text : pEsc ( m . fromName || 'Someone' ) + ' created a poll' + ( m . poll . question ? ': ' + pEsc ( m . poll . question ) : '' ), link : { kind , id : rid }});
else if ( kind === 'dm' && wasNew ) addNotif ({ icon : 'chat' , text : 'New chat from ' + pEsc ( m . fromName || 'someone' ), link : { kind : 'dm' , id : rid }});
}
if ( it ){
it . last_body = isSys ? m . body : ( m . body || ( m . attachment ? '📎 ' + ( m . attachment . name || 'Attachment' ) : '' )); it . last_at = m . created_at ; it . last_from_me = ( m . from === ME . id );
if ( m . from !== ME . id && ! isOpen && ! isSys ) it . unread = ( it . unread || 0 ) + 1 ;
}
if ( isOpen ){
2026-06-23 18:06:00 +05:30
// Dedup by id: the server echoes our own sent message back (multi-tab/device sync), and
// sendMessage already appended it optimistically — so skip if it's already in the thread.
if ( ! THREAD . some ( x => x . id === m . id )){ THREAD . push ( m ); appendBubble ( m ); }
2026-06-23 16:15:29 +05:30
if ( m . from !== ME . id && ! isSys ){ if ( it ) it . unread = 0 ; const body = JSON . stringify ( kind === 'group' ? { group : rid } : { with : rid }); try { fetch ( '/api/messages/read' ,{ method : 'POST' , headers : { 'Content-Type' : 'application/json' }, body }); } catch ( _ ){} }
} else if ( m . from !== ME . id && ! isSys && notifOn ( kind )){
toast (( m . fromName || 'New message' ) + ': ' + ( m . body ? ( m . body . length > 60 ? m . body . slice ( 0 , 60 ) + '…' : m . body ) : '📎 Attachment' ));
}
renderChats ( searchVal ()); updateRailUnread ();
}
let chatWs = null ;
function connectChatWs (){
try {
chatWs = new WebSocket (( location . protocol === 'https:' ? 'wss://' : 'ws://' ) + location . host + '/ws' );
chatWs . onopen = ()=>{ try { chatWs . send ( JSON . stringify ({ type : 'chat-hello' })); } catch ( _ ){} };
chatWs . onmessage = ( e )=>{ let d ; try { d = JSON . parse ( e . data ); } catch ( _ ){ return ; } if ( d . type === 'chat-message' && d . message ) onChatMessage ( d . message ); else if ( d . type === 'chat-reaction' ) onChatReaction ( d ); else if ( d . type === 'poll-update' && d . poll ) onPollUpdate ( d ); else if ( d . type === 'chat-read' ) onChatRead ( d ); else if ( d . type === 'chat-delivered' ) onChatDelivered ( d ); else if ( d . type === 'group-read' ) onGroupRead ( d ); else if ( d . type === 'group-call' ) onGroupCall ( d ); else if ( d . type === 'dm-call' ) onDmCall ( d ); else if ( d . type === 'group-update' ) onGroupUpdate ( d ); else if ( d . type === 'call-invite' ) showCallInvite ( d . room , d . byName ); else if ( d . type === 'meeting-invite' ) showMeetingInvite ( d . meeting ); else if ( d . type === 'meeting-reminder' ) showMeetingReminder ( d . meeting ); else if ( d . type === 'meeting-cancelled' ) showMeetingCancelled ( d . meeting ); else if ( d . type === 'group-role' ) onGroupRole ( d ); };
chatWs . onclose = ()=>{ setTimeout ( connectChatWs , 3000 ); }; // auto-reconnect
} catch ( _ ){}
}
2026-06-12 00:40:07 +05:30
function renderChatPanel (){
const el = document . getElementById ( 'chatPanel' );
2026-06-23 16:15:29 +05:30
if ( ! selected ){ el . classList . add ( 'center' ); el . innerHTML = welcomeHTML (); wireWelcome (); }
else { el . classList . remove ( 'center' ); openConvo ( selected . kind , selected . id ); }
2026-06-12 00:40:07 +05:30
}
2026-06-23 16:15:29 +05:30
// ----- New group modal -----
function openNewGroup (){
if ( document . getElementById ( 'groupModal' )) return ;
const ov = document . createElement ( 'div' ); ov . className = 'modal-ov' ; ov . id = 'groupModal' ;
ov . innerHTML = '<div class="modal"><h3>New group</h3>'
+ '<input id="grpName" placeholder="Group name" maxlength="80">'
+ '<div class="grp-members">' + ( CONTACTS . length ? CONTACTS . map ( c => '<label class="chk"><input type="checkbox" value="' + pEsc ( c . id ) + '"> ' + pEsc ( c . name ) + '</label>' ). join ( '' ) : '<div class="muted" style="padding:.5rem">No teammates to add yet.</div>' ) + '</div>'
+ '<div class="modal-actions"><button class="gobtn" id="grpCancel" style="background:#eef1f6;color:var(--blue)">Cancel</button><button class="gobtn" id="grpCreate">Create group</button></div></div>' ;
document . body . appendChild ( ov );
ov . onclick = ( e )=>{ if ( e . target === ov ) ov . remove (); };
document . getElementById ( 'grpCancel' ). onclick = ()=> ov . remove ();
document . getElementById ( 'grpCreate' ). onclick = createGroup ;
const n = document . getElementById ( 'grpName' ); if ( n ) n . focus ();
}
async function createGroup (){
const name = document . getElementById ( 'grpName' ). value . trim ();
const ids = [... document . querySelectorAll ( '#groupModal .grp-members input:checked' )]. map ( i => i . value );
if ( ! name ){ toast ( 'Enter a group name' ); return ; }
if ( ! ids . length ){ toast ( 'Pick at least one member' ); return ; }
try {
const g = await postJSON ( '/api/groups' ,{ name , memberIds : ids });
const ov = document . getElementById ( 'groupModal' ); if ( ov ) ov . remove ();
await loadSidebar ();
selectChat ( 'group' , g . id );
} catch ( e ){ toast ( e . message || 'Could not create group' ); }
}
async function openGroupInfo ( gid ){
let info ; try { info = await fetch ( '/api/groups/info?group=' + encodeURIComponent ( gid )). then ( r => r . json ()); } catch ( _ ){ return ; }
if ( ! info ||! info . id ) return ;
const inSet = new Set ( info . members . map ( m => m . id ));
const addable = CONTACTS . filter ( c => ! inSet . has ( c . id ));
const canManage = info . isAdmin || ! info . adminOnly ; // who may add/remove members
const miniAv = ( name , avatar )=> '<div class="mini-av" style="background:' + avColor ( name ) + '">' + pEsc ( initials ( name )) + ( avatar ? '<img class="av-img" src="' + pEsc ( avatar ) + '" alt="" onerror="this.remove()">' : '' ) + '</div>' ;
const ov = document . createElement ( 'div' ); ov . className = 'modal-ov' ; ov . id = 'groupInfo' ;
ov . innerHTML = '<div class="modal gi">'
+ '<input type="file" id="giPhotoInput" accept="image/*" style="display:none">'
+ '<div class="gi-head"><button class="gi-photo" id="giPhoto" title="Change group photo"><span class="avatar grp" style="width:46px;height:46px;background:' + avColor ( info . name ) + '">' + ic ( 'users' , 24 ) + ( info . avatar ? '<img class="av-img" src="' + pEsc ( info . avatar ) + '" alt="" onerror="this.remove()">' : '' ) + '</span><span class="gi-photo-cam">' + ic ( 'camera' , 13 ) + '</span></button>'
+ '<div class="gi-name">'
+ '<div class="gi-name-row" id="giView"><span class="gi-title">' + pEsc ( info . name ) + '</span><button class="iconbtn sm" id="giEdit" title="Rename group">' + ic ( 'pencil' , 15 ) + '</button></div>'
+ '<div class="gi-edit hidden" id="giEditRow"><input id="giName" value="' + pEsc ( info . name ) + '" maxlength="80"><button class="iconbtn" id="giSave" title="Save">' + ic ( 'check' , 18 ) + '</button><button class="iconbtn" id="giCancelEdit" title="Cancel">' + ic ( 'x' , 16 ) + '</button></div>'
+ '<div class="gi-sub">' + info . members . length + ' member' + ( info . members . length === 1 ? '' : 's' ) + '</div>'
+ '</div>'
+ '<button class="iconbtn" id="giClose" title="Close">' + ic ( 'x' , 18 ) + '</button></div>'
+ '<div class="gi-actions"><button class="gi-act" id="giCall">' + ic ( 'phone' , 18 ) + '<span>Call</span></button><button class="gi-act" id="giSchedule">' + ic ( 'calendar' , 18 ) + '<span>Schedule</span></button></div>'
+ ( info . createdByName ? '<div class="gi-created">' + ic ( 'info' , 13 ) + ' Created by <b>' + pEsc ( info . createdByName ) + '</b> on ' + pEsc ( fmtDateTime ( info . createdAt )) + '</div>' : '' )
+ ( info . isAdmin ? '<label class="gi-setting"><span>' + ic ( 'lock' , 15 ) + ' Only admins can add or remove people</span><span class="switch"><input type="checkbox" id="giAdminOnly"' + ( info . adminOnly ? ' checked' : '' ) + '><span class="slider"></span></span></label>' : '' )
+ '<div class="gi-sec-h"><span>Members</span>' + ( canManage ? '<button class="linkbtn" id="giAddToggle">' + ic ( 'userPlus' , 15 ) + ' Add people</button>' : '' ) + '</div>'
+ '<div class="gi-list">' + info . members . map ( m => '<div class="mrow">' + miniAv ( m . name , m . avatar ) + '<span class="mn">' + pEsc ( m . name ) + ( m . isMe ? ' <span class="youtag">you</span>' : '' ) + ( m . admin ? ' <span class="admin-tag">' + ic ( 'crown' , 11 ) + ' Admin</span>' : '' ) + '</span>' + (( info . isAdmin &&! m . isMe ) ? '<button class="iconbtn role' + ( m . admin ? ' is-admin' : '' ) + '" data-role="' + pEsc ( m . id ) + '" data-val="' + ( m . admin ? '0' : '1' ) + '" title="' + ( m . admin ? 'Remove admin' : 'Make admin' ) + '">' + ic ( 'crown' , 15 ) + '</button>' : '' ) + (( canManage &&! m . isMe ) ? '<button class="iconbtn rm" data-rm="' + pEsc ( m . id ) + '" title="Remove">' + ic ( 'trash' , 15 ) + '</button>' : '' ) + '</div>' ). join ( '' ) + '</div>'
+ '<div class="gi-add hidden" id="giAddWrap">'
+ ( addable . length
? ( '<div class="gi-search"><input id="giAddSearch" placeholder="Search people…" autocomplete="off"><button class="search-x" id="giAddSearchX" title="Clear" style="display:none">' + ic ( 'x' , 14 ) + '</button></div>'
+ '<div class="gi-list" id="giAddList">' + addable . map ( c => '<label class="chk" data-name="' + pEsc (( c . name || '' ). toLowerCase ()) + '"><input type="checkbox" value="' + pEsc ( c . id ) + '">' + miniAv ( c . name , c . avatar ) + '<span class="mn">' + pEsc ( c . name ) + '</span></label>' ). join ( '' ) + '</div>'
+ '<div class="gi-noresult" id="giAddEmpty" style="display:none">No results found</div>'
+ '<button class="gobtn" id="giAddBtn" style="width:100%;margin-top:.5rem">Add selected</button>' )
: '<div class="gi-noresult">Everyone in your workspace is already in this group.</div>' )
+ '</div>'
+ '<button class="gi-leave" id="giLeave">' + ic ( 'logOut' , 16 ) + ' Leave group</button>'
+ '</div>' ;
const _old = document . getElementById ( 'groupInfo' ); if ( _old ) _old . remove (); // never stack group-info modals (stale close buttons)
document . body . appendChild ( ov );
ov . onclick = ( e )=>{ if ( e . target === ov ) ov . remove (); };
ov . querySelector ( '#giClose' ). onclick = ()=> ov . remove ();
const refresh = async ()=>{ await loadSidebar (); if ( selected && selected . kind === 'group' && selected . id === gid ) openConvo ( 'group' , gid ); };
const view = ov . querySelector ( '#giView' ), editRow = ov . querySelector ( '#giEditRow' ), nameInp = ov . querySelector ( '#giName' ), closeBtn = ov . querySelector ( '#giClose' );
document . getElementById ( 'giEdit' ). onclick = ()=>{ view . classList . add ( 'hidden' ); editRow . classList . remove ( 'hidden' ); closeBtn . style . display = 'none' ; nameInp . focus (); nameInp . select (); };
document . getElementById ( 'giCancelEdit' ). onclick = ()=>{ nameInp . value = info . name ; editRow . classList . add ( 'hidden' ); view . classList . remove ( 'hidden' ); closeBtn . style . display = '' ; };
const doRename = async ()=>{ const nm = nameInp . value . trim (); if ( ! nm ){ toast ( 'Name required' ); return ; } try { await postJSON ( '/api/groups/rename' ,{ group : gid , name : nm }); ov . remove (); await refresh (); toast ( 'Group renamed' ); } catch ( e ){ toast ( e . message ); } };
document . getElementById ( 'giSave' ). onclick = doRename ;
nameInp . addEventListener ( 'keydown' , e =>{ if ( e . key === 'Enter' ){ e . preventDefault (); doRename (); } });
document . getElementById ( 'giCall' ). onclick = ()=> startGroupCall ( gid );
document . getElementById ( 'giSchedule' ). onclick = ()=> scheduleGroupCall ( gid );
const adminOnlyCb = document . getElementById ( 'giAdminOnly' ); if ( adminOnlyCb ) adminOnlyCb . onchange = async ()=>{ try { await postJSON ( '/api/groups/admin-only' ,{ group : gid , value : adminOnlyCb . checked }); ov . remove (); await refresh (); openGroupInfo ( gid ); } catch ( e ){ adminOnlyCb . checked =! adminOnlyCb . checked ; toast ( e . message || 'Could not update' ); } };
const addTgl = document . getElementById ( 'giAddToggle' ); if ( addTgl ) addTgl . onclick = ()=>{ const w = document . getElementById ( 'giAddWrap' ); if ( w ){ w . classList . toggle ( 'hidden' ); if ( ! w . classList . contains ( 'hidden' )){ const s = document . getElementById ( 'giAddSearch' ); if ( s ) setTimeout (()=> s . focus (), 0 ); } } };
const addSearch = document . getElementById ( 'giAddSearch' );
if ( addSearch ){
const filter = ()=>{ const q = addSearch . value . trim (). toLowerCase (); const xb = document . getElementById ( 'giAddSearchX' ); if ( xb ) xb . style . display = q ? 'grid' : 'none' ; let shown = 0 ; ov . querySelectorAll ( '#giAddList .chk' ). forEach ( l =>{ const vis = ( ! q || ( l . dataset . name || '' ). includes ( q )); l . style . display = vis ? '' : 'none' ; if ( vis ) shown ++ ; }); const empty = document . getElementById ( 'giAddEmpty' ); if ( empty ) empty . style . display = shown ? 'none' : 'block' ; };
addSearch . addEventListener ( 'input' , filter );
const xb = document . getElementById ( 'giAddSearchX' ); if ( xb ) xb . onclick = ()=>{ addSearch . value = '' ; filter (); addSearch . focus (); };
}
const addBtn = document . getElementById ( 'giAddBtn' ); if ( addBtn ) addBtn . onclick = async ()=>{ const ids = [... ov . querySelectorAll ( '#giAddWrap input:checked' )]. map ( i => i . value ); if ( ! ids . length ){ toast ( 'Pick members to add' ); return ; } try { await postJSON ( '/api/groups/add' ,{ group : gid , memberIds : ids }); ov . remove (); await refresh (); openGroupInfo ( gid ); } catch ( e ){ toast ( e . message ); } };
const nameOf = ( uid )=>{ const mm = info . members . find ( x => x . id === uid ); return mm ? mm . name : 'this person' ; };
ov . querySelectorAll ( '[data-rm]' ). forEach ( b => b . onclick = async ()=>{ const nm = nameOf ( b . dataset . rm ); if ( ! confirm ( 'Remove ' + nm + ' from the group?\n\nThey will lose access to this conversation.' )) return ; try { await postJSON ( '/api/groups/remove' ,{ group : gid , userId : b . dataset . rm }); ov . remove (); await refresh (); openGroupInfo ( gid ); } catch ( e ){ toast ( e . message ); } });
ov . querySelectorAll ( '[data-role]' ). forEach ( b => b . onclick = async ()=>{ const mk = b . dataset . val === '1' ; const nm = nameOf ( b . dataset . role ); if ( ! confirm ( mk ? ( 'Make ' + nm + ' an admin?\n\nThey will be able to add or remove members and manage this group.' ) : ( 'Remove ' + nm + ' as an admin?' ))) return ; try { await postJSON ( '/api/groups/admin' ,{ group : gid , userId : b . dataset . role , value : mk }); ov . remove (); await refresh (); openGroupInfo ( gid ); } catch ( e ){ toast ( e . message ); } });
document . getElementById ( 'giLeave' ). onclick = async ()=>{
const admins = info . members . filter ( m => m . admin ), others = info . members . filter ( m => ! m . isMe );
// #10: last admin must pick a successor before leaving.
if ( info . isAdmin && others . length && admins . length === 1 ){ ov . remove (); pickSuccessorThenLeave ( gid , others ); return ; }
if ( ! confirm ( 'Leave this group?' )) return ;
try { await postJSON ( '/api/groups/remove' ,{ group : gid }); ov . remove (); selected = null ; await loadSidebar (); renderChatPanel (); } catch ( e ){ toast ( e . message ); }
};
// Group photo: click avatar -> pick image -> upload -> set as group image.
const photoBtn = document . getElementById ( 'giPhoto' ), photoInput = document . getElementById ( 'giPhotoInput' );
if ( photoBtn && photoInput ){
photoBtn . onclick = ()=> photoInput . click ();
photoInput . onchange = async ()=>{
const file = photoInput . files && photoInput . files [ 0 ]; if ( ! file ) return ;
if ( ! /^image\// . test ( file . type || '' )){ toast ( 'Please choose an image file' ); return ; }
try {
const up = await fetch ( '/api/messages/upload' ,{ method : 'POST' , headers : { 'Content-Type' : file . type || 'application/octet-stream' , 'X-Filename' : encodeURIComponent ( file . name ) }, body : file }). then ( r => r . json ());
if ( ! up ||! up . id ) throw new Error ( up && up . error || 'Upload failed' );
await postJSON ( '/api/groups/avatar' ,{ group : gid , attachmentId : up . id });
ov . remove (); await refresh (); openGroupInfo ( gid ); toast ( 'Group photo updated' );
} catch ( e ){ toast ( e . message || 'Could not update photo' ); }
};
}
}
// Start an audio call with the group: spin up an audio-only meeting and post the join code to the group chat.
// Group-info "Call" button → start (or join) the SHARED group call so every member is notified/rung.
function startGroupCall ( gid ){ const ov = document . getElementById ( 'groupInfo' ); if ( ov ) ov . remove (); startOrJoinGroupCall ( gid ); }
// Schedule a call tied to this group: close group info, open the schedule modal.
function scheduleGroupCall ( gid ){ const ov = document . getElementById ( 'groupInfo' ); if ( ov ) ov . remove (); openScheduleModal ( gid ); }
// ---------- Meetings (mesh P2P video) ----------
let meetWs = null , meetLocalStream = null , meetRoom = null , meetMyId = null , meetState = 'idle' ;
let meetAnnounceGroup = null ; // when set, the new meeting's code is posted to this group chat
let meetAudioOnly = false ; // audio call (no camera) — tiles show avatars instead of video
const meetPeers = new Map (); // peerId -> { pc, name }
const meetMuted = new Map (); // peerId|'__local' -> muted (for the tile mic-off badge)
const meetNames = new Map (); // peerId -> name (peers that arrive before their offer)
let meetReturn = null ; // {kind:'dm'|'group', id} — chat to land on when the call ends (null = meetings tab)
const meetVU = new Map (); // peerId|'__local' -> {ctx,analyser,data,raf} active-speaker meters
let MEET_ICE = { iceServers : [{ urls : 'stun:stun.l.google.com:19302' }] };
let meetMic = true , meetCam = true ;
let meetIsHost = false , meetHostId = null ; // host = the meeting creator (transferable by the host only)
let meetScreen = false , meetScreenStream = null ; // am I sharing my screen + the display stream
const meetSharers = new Set (); // peerIds of OTHERS currently sharing their screen
let meetMultiShare = false ; // host setting: allow several people to share at once (default: one at a time)
let meetRec = null ; // active composite recording (host) {rec, stop()}
let meetTranscribe = false ; // am I subscribed to a transcript copy
let meetRoomTx = false ; // is the room transcription active (≥1 subscriber → all mics transcribe)
let meetSR = null ; // my SpeechRecognition instance
let meetStageId = null ; // which shared screen is currently on the stage (peerId|'__local')
function meetSend ( o ){ try { if ( meetWs && meetWs . readyState === 1 ) meetWs . send ( JSON . stringify ( o )); } catch ( _ ){} }
function meetRailLive ( on ){ const b = document . querySelector ( '.railbtn[data-tab="meeting"]' ); if ( b ) b . classList . toggle ( 'live' , !! on ); }
function renderMeetingLobby (){
meetState = 'idle' ;
const el = document . getElementById ( 'meetingPanel' ); if ( ! el ) return ;
el . innerHTML = '<div class="meet-dash">'
+ '<div class="md-top">'
+ '<div class="md-title"><h1>Meetings</h1><p>Start or join a video meeting, or schedule one for later. Small group (mesh) for now — larger rooms coming with the SFU.</p></div>'
+ '<div class="md-actions">'
+ '<div class="md-join"><input id="meetCode" placeholder="Enter code" inputmode="numeric" maxlength="6"><button class="btn primary" id="meetJoinBtn">Join</button></div>'
+ '<button class="btn" id="meetStart">' + ic ( 'video' , 16 ) + ' Start a meeting</button>'
+ '<button class="btn primary" id="meetSchedule">' + ic ( 'calendar' , 16 ) + ' Schedule</button>'
+ '</div>'
+ '</div>'
+ '<div class="hint" id="meetErr"></div>'
+ '<div class="sched-wrap" id="schedWrap"><div class="sched-empty">Loading meetings…</div></div>'
+ '</div>' ;
document . getElementById ( 'meetStart' ). onclick = ()=> enterMeeting ( null );
document . getElementById ( 'meetSchedule' ). onclick = ()=> openScheduleModal ( null );
const doJoin = ()=>{ const c = document . getElementById ( 'meetCode' ). value . trim (); if ( /^\d{6}$/ . test ( c )) enterMeeting ( c ); else document . getElementById ( 'meetErr' ). textContent = 'Enter a valid 6-digit code.' ; };
document . getElementById ( 'meetJoinBtn' ). onclick = doJoin ;
document . getElementById ( 'meetCode' ). addEventListener ( 'keydown' , e =>{ if ( e . key === 'Enter' ) doJoin (); });
loadScheduledMeetings ();
}
// Fetch + render scheduled meetings, bucketed into Running / Upcoming / Past.
async function loadScheduledMeetings (){
const wrap = document . getElementById ( 'schedWrap' ); if ( ! wrap ) return ;
let list ; try { list = await fetch ( '/api/meetings' ). then ( r => r . json ()); } catch ( _ ){ return ; }
if ( ! Array . isArray ( list ) ||! list . length ){ wrap . innerHTML = '<div class="sched-empty">No meetings yet. Start one now or schedule it for later.</div>' ; return ; }
const running = list . filter ( m => m . status === 'running' );
const upcoming = list . filter ( m => m . status === 'upcoming' ). sort (( a , b )=> a . scheduledAt - b . scheduledAt );
const past = list . filter ( m => m . status === 'past' || m . status === 'cancelled' ). sort (( a , b )=> b . scheduledAt - a . scheduledAt ). slice ( 0 , 12 );
const fmt = ts => new Date ( ts ). toLocaleString ([],{ weekday : 'short' , month : 'short' , day : 'numeric' , hour : 'numeric' , minute : '2-digit' });
const card = m =>{
const cancelled = m . status === 'cancelled' ;
const meta = []; if ( m . groupName ) meta . push ( pEsc ( m . groupName )); meta . push ( fmt ( m . scheduledAt ));
if ( m . durationMins ) meta . push ( m . durationMins + ' min' );
if ( m . recurrenceLabel ) meta . push ( '🔁 ' + pEsc ( m . recurrenceLabel ));
if ( m . status === 'running' ) meta . push ( m . inCall + ' in call' );
meta . push ( m . isHost ? 'You\'re the host' : ( 'Host: ' + pEsc ( m . createdByName || '—' )));
if ( m . invited && m . invited . length ) meta . push ( m . invited . length + ' invited' );
// Cancel only on a FUTURE upcoming meeting (#13: not once the time has passed).
const canCancel = m . canManage && m . status === 'upcoming' && m . scheduledAt > Date . now ();
// Start any time BEFORE the meeting's end; once the window passes it's 'past' and can't start (#3).
const canStart = m . status === 'running' || m . status === 'upcoming' ;
return '<div class="sched-item' + ( m . status === 'running' ? ' live' : '' ) + ( cancelled ? ' cancelled' : '' ) + '">'
+ '<div class="si-main"><div class="si-title">' + pEsc ( m . title ) + ( m . status === 'running' ? '<span class="livedot">● Live</span>' : '' ) + ( cancelled ? '<span class="cancel-tag">Cancelled</span>' : '' ) + ( m . isHost ? '<span class="host-tag">' + ic ( 'crown' , 11 ) + ' Host</span>' : '' ) + '</div>'
+ '<div class="si-meta">' + meta . join ( ' · ' ) + '</div>'
+ ( m . description ? '<div class="si-desc">' + pEsc ( m . description ) + '</div>' : '' )
+ ( m . invited && m . invited . length ? '<div class="si-invited" title="' + pEsc ( m . invited . join ( ', ' )) + '">' + ic ( 'users' , 12 ) + ' ' + pEsc ( m . invited . slice ( 0 , 3 ). join ( ', ' )) + ( m . invited . length > 3 ? ( ' +' + ( m . invited . length - 3 )) : '' ) + '</div>' : '' )
+ ( m . recordings && m . recordings . length ? '<div class="si-recs">' + m . recordings . map ( r => '<a class="rec-dl ' + ( r . kind === 'video' ? 'vid' : 'txt' ) + '" href="' + pEsc ( r . url ) + '" title="Download ' + ( r . kind === 'video' ? 'recording' : 'transcript' ) + '">' + ic ( 'download' , 14 ) + '<span>' + ( r . kind === 'video' ? 'Recording' : 'Transcript' ) + '</span>' + ( r . kind === 'video' && r . durationMs ? '<span class="rd-dur">' + fmtElapsed ( r . durationMs ) + '</span>' : '' ) + '</a>' ). join ( '' ) + '</div>' : '' ) + '</div>'
+ '<div class="si-actions">'
+ (( m . status !== 'past' &&! cancelled && canStart ) ? '<button class="btn sm join" data-code="' + pEsc ( m . roomCode ) + '">' + ( m . status === 'running' ? 'Join' : 'Start' ) + '</button>' : '' )
+ ( canCancel ? '<button class="iconbtn edit" data-edit="' + pEsc ( m . id ) + '" title="Edit meeting" aria-label="Edit meeting">' + ic ( 'pencil' , 14 ) + '</button>' : '' )
+ ( canCancel ? '<button class="iconbtn cancel-ic" data-cancel="' + pEsc ( m . id ) + '" title="Cancel meeting" aria-label="Cancel meeting">' + ic ( 'calendarX' , 14 ) + '</button>' : '' )
+ '</div></div>' ;
};
const byId = {}; list . forEach ( m => byId [ m . id ] = m );
const sec = ( title , arr )=> arr . length ? ( '<div class="sched-sec"><div class="sched-h">' + title + '</div><div class="sched-list">' + arr . map ( card ). join ( '' ) + '</div></div>' ) : '' ;
wrap . innerHTML = sec ( 'Ongoing now' , running ) + sec ( 'Upcoming meetings' , upcoming ) + sec ( 'Past meetings' , past );
wrap . querySelectorAll ( '[data-code]' ). forEach ( b => b . onclick = ()=> enterMeeting ( b . dataset . code ));
wrap . querySelectorAll ( '[data-edit]' ). forEach ( b => b . onclick = ()=>{ const m = byId [ b . dataset . edit ]; if ( m ) openScheduleModal ( m . groupId || null , m ); });
wrap . querySelectorAll ( '[data-cancel]' ). forEach ( b => b . onclick = ()=> cancelMeeting ( byId [ b . dataset . cancel ]));
}
// Last admin leaving must hand off: pick who becomes the new admin, then leave (#10, selective).
function pickSuccessorThenLeave ( gid , others ){
const ov = document . createElement ( 'div' ); ov . className = 'modal-ov' ;
ov . innerHTML = '<div class="modal" style="max-width:380px"><div class="gi-title" style="margin-bottom:.2rem">Choose a new admin</div><p style="color:var(--muted);font-size:.88rem;margin:.3rem 0 .8rem">You\'re the only admin. Pick someone to take over before you leave.</p><div class="gi-list" style="max-height:40vh;overflow:auto">' + others . map ( m => '<label class="chk"><input type="radio" name="heir" value="' + pEsc ( m . id ) + '"><span class="mini-av" style="background:' + avColor ( m . name ) + '">' + pEsc ( initials ( m . name )) + '</span><span class="mn">' + pEsc ( m . name ) + '</span></label>' ). join ( '' ) + '</div><button class="gobtn" id="heirGo" style="width:100%;margin-top:.8rem;background:var(--blue);color:#fff">Make admin & leave</button><div class="hint" id="heirErr"></div></div>' ;
document . body . appendChild ( ov ); ov . onclick = e =>{ if ( e . target === ov ) ov . remove (); };
ov . querySelector ( '#heirGo' ). onclick = async ()=>{ const sel = ov . querySelector ( 'input[name=heir]:checked' ); if ( ! sel ){ ov . querySelector ( '#heirErr' ). textContent = 'Please pick a member.' ; return ; }
try { await postJSON ( '/api/groups/remove' ,{ group : gid , newAdmin : sel . value }); ov . remove (); selected = null ; await loadSidebar (); renderChatPanel (); toast ( 'Left the group' ); } catch ( e ){ ov . querySelector ( '#heirErr' ). textContent = e . message || 'Could not leave' ; } };
}
// Cancel a meeting. Recurring → ask whether to cancel just this occurrence or all future (#recurring).
function cancelMeeting ( m ){
if ( ! m ) return ;
const doCancel = async ( scope )=>{ try { await postJSON ( '/api/meetings/cancel' ,{ id : m . id , scope }); loadScheduledMeetings (); } catch ( e ){ toast ( e . message ); } };
if ( m . recurrence && m . recurrence . length ){
const dlabel = new Date ( m . scheduledAt ). toLocaleString ([],{ weekday : 'short' , month : 'short' , day : 'numeric' , hour : 'numeric' , minute : '2-digit' });
const ov = document . createElement ( 'div' ); ov . className = 'modal-ov' ;
ov . innerHTML = '<div class="modal" style="max-width:390px"><div class="gi-title" style="margin-bottom:.2rem">Cancel recurring meeting</div><p style="color:var(--muted);font-size:.9rem;margin:.3rem 0 1rem">“' + pEsc ( m . title ) + '” repeats ' + pEsc ( m . recurrenceLabel || 'weekly' ) + '. What do you want to cancel?</p><div style="display:flex;flex-direction:column;gap:.5rem"><button class="btn" id="cmOne" style="background:var(--blue);color:#fff">Only this occurrence (' + pEsc ( dlabel ) + ')</button><button class="btn" id="cmAll" style="background:#fee2e2;color:#b91c1c">Cancel all future</button><button class="linkbtn" id="cmNo" style="align-self:center;border:none">Keep meeting</button></div></div>' ;
document . body . appendChild ( ov ); ov . onclick = e =>{ if ( e . target === ov ) ov . remove (); };
ov . querySelector ( '#cmOne' ). onclick = ()=>{ ov . remove (); doCancel ( 'one' ); };
ov . querySelector ( '#cmAll' ). onclick = ()=>{ ov . remove (); doCancel ( 'all' ); };
ov . querySelector ( '#cmNo' ). onclick = ()=> ov . remove ();
} else { if ( confirm ( 'Cancel this meeting? Participants will be notified.' )) doCancel ( 'all' ); }
}
// Schedule / edit modal with a custom calendar + time picker. gid ties it to a group; editMtg edits.
function openScheduleModal ( gid , editMtg ){
const pad = n => String ( n ). padStart ( 2 , '0' ); const now = new Date ();
const startOfDay = d =>{ const x = new Date ( d ); x . setHours ( 0 , 0 , 0 , 0 ); return x ; };
const editing =!! editMtg ;
const base = editing ? new Date ( editMtg . scheduledAt ) : new Date ( now . getTime () + 30 * 60000 );
let selDate = startOfDay ( base );
let selMin = Math . round (( base . getHours () * 60 + base . getMinutes ()) / 15 ) * 15 ;
let viewY = selDate . getFullYear (), viewM = selDate . getMonth ();
let recur = ( editing && Array . isArray ( editMtg . recurrence )) ? editMtg . recurrence . slice () : [];
const DAYW = [ 'Sun' , 'Mon' , 'Tue' , 'Wed' , 'Thu' , 'Fri' , 'Sat' ], DAY1 = [ 'S' , 'M' , 'T' , 'W' , 'T' , 'F' , 'S' ];
const todayMid = startOfDay ( now ). getTime ();
const label12 = m =>{ let h = Math . floor ( m / 60 ), mm = m % 60 ; const ap = h < 12 ? 'AM' : 'PM' ; let h12 = h % 12 || 12 ; return h12 + ':' + pad ( mm ) + ' ' + ap ; };
const dateLabel = d => d . toLocaleDateString ([],{ weekday : 'short' , month : 'short' , day : 'numeric' });
const invitedIds = new Set ( editing && Array . isArray ( editMtg . invitedIds ) ? editMtg . invitedIds : []);
const ov = document . createElement ( 'div' ); ov . className = 'modal-ov' ; ov . id = 'schedModal' ;
ov . innerHTML = '<div class="modal sched">'
+ '<div class="gi-head" style="margin-bottom:.6rem"><div class="avatar grp" style="width:40px;height:40px;flex:0 0 40px;background:var(--blue)">' + ic ( 'calendarClock' , 20 ) + '</div>'
+ '<div class="gi-name"><div class="gi-title">' + ( editing ? 'Edit meeting' : 'Schedule a call' ) + '</div><div class="gi-sub">Pick a date & time and add details</div></div>'
+ '<button class="iconbtn" id="schClose" title="Close">' + ic ( 'x' , 18 ) + '</button></div>'
+ '<label class="flbl">Title</label><input id="schTitle" class="finput" placeholder="e.g. Weekly sync" maxlength="120">'
+ '<div class="sch-row">'
+ '<div class="picker-field"><label class="flbl">Date</label><button type="button" class="finput pick-btn" id="schDateBtn">' + ic ( 'calendar' , 16 ) + '<span id="schDateLbl"></span></button><div class="pick-pop hidden" id="schCal"></div></div>'
+ '<div class="picker-field"><label class="flbl">Time</label><button type="button" class="finput pick-btn" id="schTimeBtn">' + ic ( 'calendarClock' , 16 ) + '<span id="schTimeLbl"></span></button><div class="pick-pop time-pop hidden" id="schTimePop"></div></div>'
+ '<div><label class="flbl">Duration</label><select id="schDur" class="finput"><option value="15">15 min</option><option value="30">30 min</option><option value="45">45 min</option><option value="60">1 hour</option><option value="90">1.5 hours</option><option value="120">2 hours</option></select></div>'
+ '</div>'
+ '<label class="chk2 switch-row"><span>' + ic ( 'calendarClock' , 15 ) + ' Repeat weekly</span><span class="switch"><input type="checkbox" id="schRepeat"><span class="slider"></span></span></label>'
+ '<div class="sch-days hidden" id="schDays">' + DAY1 . map (( d , i )=> '<button type="button" class="day-chip" data-d="' + i + '" title="' + DAYW [ i ] + '">' + d + '</button>' ). join ( '' ) + '<button type="button" class="day-all" data-all="1">Everyday</button></div>'
+ '<label class="flbl">Description <span class="opt">(optional)</span></label><textarea id="schDesc" class="finput" rows="2" placeholder="What\'s this call about?"></textarea>'
+ '<label class="flbl">Invite participants</label>'
+ '<div class="gi-list" id="schPeople" style="max-height:24vh;overflow:auto">' + ( CONTACTS . length ? CONTACTS . map ( c => '<label class="chk"><input type="checkbox" value="' + pEsc ( c . id ) + '"' + ( invitedIds . has ( c . id ) ? ' checked' : '' ) + '><span class="mini-av" style="background:' + avColor ( c . name ) + '">' + pEsc ( initials ( c . name )) + '</span><span class="mn">' + pEsc ( c . name ) + '</span></label>' ). join ( '' ) : '<div class="gi-noresult">No contacts to invite</div>' ) + '</div>'
+ '<button class="gobtn" id="schSave" style="width:100%;margin-top:.9rem;background:var(--blue);color:#fff">' + ( editing ? 'Save changes' : 'Schedule & invite' ) + '</button>'
+ '<div class="hint" id="schErr"></div></div>' ;
document . body . appendChild ( ov );
ov . onclick = e =>{ if ( e . target === ov ) ov . remove (); };
document . getElementById ( 'schClose' ). onclick = ()=> ov . remove ();
const $ = id => document . getElementById ( id );
if ( editing ){ $ ( 'schTitle' ). value = editMtg . title || '' ; $ ( 'schDesc' ). value = editMtg . description || '' ; if ( editMtg . durationMins ) $ ( 'schDur' ). value = String ( editMtg . durationMins ); }
else $ ( 'schDur' ). value = '30' ;
const err = $ ( 'schErr' );
const dateBtn = $ ( 'schDateBtn' ), timeBtn = $ ( 'schTimeBtn' ), cal = $ ( 'schCal' ), timePop = $ ( 'schTimePop' );
const clearErrAll = ()=>{ err . textContent = '' ; [ dateBtn , timeBtn , $ ( 'schTitle' )]. forEach ( e => e && e . classList . remove ( 'field-err' )); };
function syncLabels (){ $ ( 'schDateLbl' ). textContent = dateLabel ( selDate ); $ ( 'schTimeLbl' ). textContent = label12 ( selMin ); }
function renderCal (){
const first = new Date ( viewY , viewM , 1 ), startDow = first . getDay (), dim = new Date ( viewY , viewM + 1 , 0 ). getDate ();
const canPrev =! ( viewY === now . getFullYear () && viewM === now . getMonth ());
let h = '<div class="cal-head"><button type="button" class="cal-nav" id="calPrev"' + ( canPrev ? '' : ' disabled' ) + '>' + ic ( 'arrowLeft' , 16 ) + '</button><b>' + first . toLocaleDateString ([],{ month : 'long' , year : 'numeric' }) + '</b><button type="button" class="cal-nav" id="calNext">' + ic ( 'arrowRight' , 16 ) + '</button></div><div class="cal-grid">' + DAY1 . map ( d => '<span class="cal-dow">' + d + '</span>' ). join ( '' );
for ( let i = 0 ; i < startDow ; i ++ ) h += '<span></span>' ;
for ( let day = 1 ; day <= dim ; day ++ ){ const dt = new Date ( viewY , viewM , day ). getTime (); const past = dt < todayMid ; h += '<button type="button" class="cal-day' + ( dt === selDate . getTime () ? ' sel' : '' ) + ( dt === todayMid ? ' today' : '' ) + '"' + ( past ? ' disabled' : '' ) + ' data-day="' + day + '">' + day + '</button>' ; }
cal . innerHTML = h + '</div>' ;
const pv = cal . querySelector ( '#calPrev' ); if ( pv && canPrev ) pv . onclick = ()=>{ if ( -- viewM < 0 ){ viewM = 11 ; viewY -- ;} renderCal (); };
cal . querySelector ( '#calNext' ). onclick = ()=>{ if ( ++ viewM > 11 ){ viewM = 0 ; viewY ++ ;} renderCal (); };
cal . querySelectorAll ( '.cal-day:not([disabled])' ). forEach ( b => b . onclick = ()=>{ selDate = new Date ( viewY , viewM , + b . dataset . day ); cal . classList . add ( 'hidden' ); clearErrAll (); renderTimes (); syncLabels (); });
}
function renderTimes (){
const isToday = selDate . getTime () === todayMid ; const minM = isToday ? ( now . getHours () * 60 + now . getMinutes ()) :- 1 ;
if ( selMin <= minM ){ selMin = Math . ceil (( minM + 1 ) / 15 ) * 15 ; }
let h = '' , any = false ;
for ( let m = 0 ; m < 24 * 60 ; m += 15 ){ if ( m <= minM ) continue ; any = true ; h += '<button type="button" class="time-chip' + ( m === selMin ? ' sel' : '' ) + '" data-m="' + m + '">' + label12 ( m ) + '</button>' ; }
timePop . innerHTML = any ? h : '<div class="gi-noresult" style="padding:.7rem">No times left today</div>' ;
timePop . querySelectorAll ( '.time-chip' ). forEach ( b => b . onclick = ()=>{ selMin =+ b . dataset . m ; timePop . classList . add ( 'hidden' ); clearErrAll (); syncLabels (); });
syncLabels ();
}
dateBtn . onclick = e =>{ e . stopPropagation (); timePop . classList . add ( 'hidden' ); cal . classList . toggle ( 'hidden' ); if ( ! cal . classList . contains ( 'hidden' )){ viewY = selDate . getFullYear (); viewM = selDate . getMonth (); renderCal (); } };
timeBtn . onclick = e =>{ e . stopPropagation (); cal . classList . add ( 'hidden' ); timePop . classList . toggle ( 'hidden' ); if ( ! timePop . classList . contains ( 'hidden' )){ renderTimes (); const s = timePop . querySelector ( '.time-chip.sel' ); if ( s ) s . scrollIntoView ({ block : 'center' }); } };
ov . addEventListener ( 'click' , e =>{ if ( ! e . target . closest ( '.picker-field' )){ cal . classList . add ( 'hidden' ); timePop . classList . add ( 'hidden' ); } });
renderTimes (); syncLabels ();
// Repeat (iOS switch) + circular day chips
const repeat = $ ( 'schRepeat' ), daysWrap = $ ( 'schDays' );
if ( editing && recur . length ){ repeat . checked = true ; daysWrap . classList . remove ( 'hidden' ); recur . forEach ( d =>{ const b = daysWrap . querySelector ( '.day-chip[data-d="' + d + '"]' ); if ( b ) b . classList . add ( 'on' ); }); }
repeat . onchange = ()=>{ daysWrap . classList . toggle ( 'hidden' , ! repeat . checked ); };
daysWrap . querySelectorAll ( '.day-chip' ). forEach ( b => b . onclick = ()=> b . classList . toggle ( 'on' ));
daysWrap . querySelector ( '.day-all' ). onclick = ()=>{ const allOn = daysWrap . querySelectorAll ( '.day-chip.on' ). length === 7 ; daysWrap . querySelectorAll ( '.day-chip' ). forEach ( x => x . classList . toggle ( 'on' , ! allOn )); };
$ ( 'schTitle' ). addEventListener ( 'input' , clearErrAll );
setTimeout (()=> $ ( 'schTitle' ). focus (), 0 );
$ ( 'schSave' ). onclick = async ()=>{
const title = $ ( 'schTitle' ). value . trim ();
const desc = $ ( 'schDesc' ). value . trim ();
const durationMins = parseInt ( $ ( 'schDur' ). value , 10 ) || 30 ;
const participants = [... ov . querySelectorAll ( '#schPeople input:checked' )]. map ( i => i . value );
clearErrAll ();
if ( ! title ){ err . textContent = 'Please add a title.' ; $ ( 'schTitle' ). classList . add ( 'field-err' ); $ ( 'schTitle' ). focus (); return ; }
const ts = new Date ( selDate . getFullYear (), selDate . getMonth (), selDate . getDate (), Math . floor ( selMin / 60 ), selMin % 60 , 0 , 0 ). getTime ();
if ( ts < Date . now () + 60000 ){ err . textContent = 'That time has already passed — pick a future time.' ; timeBtn . classList . add ( 'field-err' ); return ; }
let recurrence = []; if ( repeat . checked ){ recurrence = [... daysWrap . querySelectorAll ( '.day-chip.on' )]. map ( b => + b . dataset . d ); if ( ! recurrence . length ) recurrence = [ new Date ( ts ). getDay ()]; }
const whenText = new Date ( ts ). toLocaleString ([],{ weekday : 'short' , month : 'short' , day : 'numeric' , hour : 'numeric' , minute : '2-digit' });
try {
if ( editing ){ await postJSON ( '/api/meetings/update' ,{ id : editMtg . id , title , description : desc , scheduledAt : ts , durationMins , participants , recurrence }); toast ( 'Meeting updated' ); }
else { await postJSON ( '/api/meetings/schedule' ,{ group : gid || undefined , title , description : desc , scheduledAt : ts , whenText , participants , durationMins , recurrence }); toast ( 'Meeting scheduled' + ( participants . length ? ' · ' + participants . length + ' invited' : '' )); }
ov . remove (); switchTab ( 'meeting' ); loadScheduledMeetings ();
} catch ( e ){ err . textContent = e . message || 'Could not save' ; }
};
}
function renderCall (){
const el = document . getElementById ( 'meetingPanel' ); if ( ! el ) return ;
el . innerHTML = '<div class="meet"><div class="meet-grid" id="meetGrid"></div>'
+ '<div class="meet-bar"><span class="code" id="meetCodeChip">Room <b>' + pEsc ( meetRoom || '' ) + '</b> · share to invite</span>'
+ '<button class="meet-ic' + ( meetMic ? '' : ' off' ) + '" id="meetMicBtn" title="' + ( meetMic ? 'Mute' : 'Unmute' ) + '">' + ic ( meetMic ? 'mic' : 'micOff' , 20 ) + '</button>'
+ '<button class="meet-ic' + ( meetCam ? '' : ' off' ) + '" id="meetCamBtn" title="' + ( meetCam ? 'Turn camera off' : 'Turn camera on' ) + '">' + ic ( meetCam ? 'video' : 'videoOff' , 20 ) + '</button>'
+ '<button class="meet-ic" id="meetScreenBtn" title="Share screen">' + ic ( 'monitor' , 20 ) + '</button>'
+ '<button class="meet-ic host-only" id="meetRecBtn" title="Record meeting" style="display:none">' + ic ( 'record' , 20 ) + '</button>'
+ '<button class="meet-ic" id="meetTransBtn" title="Live transcript">' + ic ( 'fileText' , 20 ) + '</button>'
+ '<button class="meet-ic" id="meetPplBtn" title="Participants">' + ic ( 'users' , 20 ) + '</button>'
+ '<button class="meet-ic leave" id="meetLeaveBtn" title="Leave">' + ic ( 'callEnd' , 20 ) + '</button></div></div>' ;
document . getElementById ( 'meetMicBtn' ). onclick = toggleMic ;
document . getElementById ( 'meetCamBtn' ). onclick = toggleCam ;
document . getElementById ( 'meetScreenBtn' ). onclick = toggleScreen ;
document . getElementById ( 'meetRecBtn' ). onclick = toggleRecord ;
document . getElementById ( 'meetTransBtn' ). onclick = toggleTranscribe ;
document . getElementById ( 'meetPplBtn' ). onclick = toggleMeetPanel ;
document . getElementById ( 'meetLeaveBtn' ). onclick = leaveMeeting ;
updateHostControls ();
// Click another shared screen (in the side column) to bring it onto the stage.
const grid = document . getElementById ( 'meetGrid' ); if ( grid ) grid . addEventListener ( 'click' , e =>{ if ( ! grid . classList . contains ( 'sharing-mode' )) return ; const t = e . target . closest ( '.meet-tile.sharing' ); if ( t && ! t . classList . contains ( 'stage' )){ setStage ( t . id . replace ( 'meet-tile-' , '' )); } });
addTile ( '__local' , meetLocalStream , ( ME && ME . name ) ? ME . name : 'You' , true );
setTileMute ( '__local' , ! meetMic );
}
function addTile ( id , stream , label , muted ){
const grid = document . getElementById ( 'meetGrid' ); if ( ! grid ) return ;
let tile = document . getElementById ( 'meet-tile-' + id );
if ( ! tile ){ tile = document . createElement ( 'div' ); tile . className = 'meet-tile' ; tile . id = 'meet-tile-' + id ;
tile . innerHTML = '<video autoplay playsinline' + ( muted ? ' muted' : '' ) + '></video><div class="meet-av" style="background:' + avColor ( label || '?' ) + '">' + pEsc ( initials ( label || '?' )) + '</div><div class="meet-mute" style="display:none">' + ic ( 'micOff' , 14 ) + '</div><span class="nm">' + pEsc ( label || '' ) + '</span>' ; grid . appendChild ( tile ); }
const v = tile . querySelector ( 'video' ); if ( v && stream && v . srcObject !== stream ) v . srcObject = stream ;
const hasVid =!! ( stream && stream . getVideoTracks && stream . getVideoTracks (). some ( t => t . enabled && t . readyState !== 'ended' ));
tile . classList . toggle ( 'novid' , ! hasVid );
if ( meetMuted . has ( id )) setTileMute ( id , meetMuted . get ( id )); // apply any known mute state
}
function setTileMute ( id , muted ){ meetMuted . set ( id , !! muted ); const t = document . getElementById ( 'meet-tile-' + id ); if ( t ){ const b = t . querySelector ( '.meet-mute' ); if ( b ) b . style . display = muted ? 'grid' : 'none' ; if ( muted ) t . classList . remove ( 'speaking' ); } }
function removeTile ( id ){ const t = document . getElementById ( 'meet-tile-' + id ); if ( t ) t . remove (); meetMuted . delete ( id ); meetUnwatch ( id ); }
// ---- Active-speaker meter: highlight a tile while its audio is above a threshold ----
function meetWatchStream ( id , stream ){
if ( ! stream || ! stream . getAudioTracks || ! stream . getAudioTracks (). length ) return ; // no audio yet
meetUnwatch ( id );
let ctx ; try { ctx = new ( window . AudioContext || window . webkitAudioContext )(); } catch ( _ ){ return ; }
try { ctx . resume (); } catch ( _ ){}
let src ; try { src = ctx . createMediaStreamSource ( stream ); } catch ( _ ){ try { ctx . close ();} catch ( e ){} return ; }
const an = ctx . createAnalyser (); an . fftSize = 512 ; an . smoothingTimeConstant = 0.6 ; src . connect ( an );
const data = new Uint8Array ( an . frequencyBinCount ); const rec = { ctx , an , data , src , raf : 0 , on : false };
meetVU . set ( id , rec );
const tick = ()=>{
an . getByteFrequencyData ( data ); let sum = 0 ; for ( let i = 0 ; i < data . length ; i ++ ) sum += data [ i ];
const speaking = ( sum / data . length ) > 16 && ! meetMuted . get ( id );
if ( speaking !== rec . on ){ rec . on = speaking ; const t = document . getElementById ( 'meet-tile-' + id ); if ( t ) t . classList . toggle ( 'speaking' , speaking ); }
rec . raf = requestAnimationFrame ( tick );
};
rec . raf = requestAnimationFrame ( tick );
}
function meetUnwatch ( id ){ const r = meetVU . get ( id ); if ( ! r ) return ; try { cancelAnimationFrame ( r . raf );} catch ( _ ){} try { r . src . disconnect ();} catch ( _ ){} try { r . ctx . close ();} catch ( _ ){} meetVU . delete ( id ); const t = document . getElementById ( 'meet-tile-' + id ); if ( t ) t . classList . remove ( 'speaking' ); }
function meetUnwatchAll (){ for ( const id of Array . from ( meetVU . keys ())) meetUnwatch ( id ); }
function meetMakePeer ( peerId , name ){
const pc = new RTCPeerConnection ( MEET_ICE );
const entry = { pc , name , vsender : null }; meetPeers . set ( peerId , entry );
addTile ( peerId , null , meetNames . get ( peerId ) || name || 'Guest' , false ); // show the tile right away (avatar)
meetLocalStream . getTracks (). forEach ( t =>{ const s = pc . addTrack ( t , meetLocalStream ); if ( t . kind === 'video' ) entry . vsender = s ; });
// If I'm already sharing my screen, send the screen (not the camera) to this new peer.
if ( meetScreen && meetScreenStream ){ const st = meetScreenStream . getVideoTracks ()[ 0 ]; if ( st ){ try { if ( entry . vsender ) entry . vsender . replaceTrack ( st ); else entry . vsender = pc . addTrack ( st , meetLocalStream ); } catch ( _ ){} } }
pc . onicecandidate = ( ev )=>{ if ( ev . candidate ) meetSend ({ type : 'meeting-signal' , to : peerId , data : { candidate : ev . candidate }}); };
pc . ontrack = ( ev )=>{ addTile ( peerId , ev . streams [ 0 ], ( meetPeers . get ( peerId ) || {}). name || name || 'Guest' , false ); meetWatchStream ( peerId , ev . streams [ 0 ]); };
return pc ;
}
// Screen share (mesh): swap the outgoing video track for a display-capture track via replaceTrack
// (no extra tiles, the peer's video just shows the screen). Falls back to addTrack+renegotiate if
// no video sender exists yet (camera never turned on). Stopping restores the camera (or avatar).
async function toggleScreen (){
if ( meetScreen ){ stopScreen (); return ; }
if ( ! meetMultiShare && meetSharers . size > 0 ){ toast ( 'Someone is already sharing their screen' ); return ; }
let ds ; try { ds = await navigator . mediaDevices . getDisplayMedia ({ video : true , audio : false }); }
catch ( e ){ return ; } // user cancelled the picker
const track = ds . getVideoTracks ()[ 0 ]; if ( ! track ){ try { ds . getTracks (). forEach ( t => t . stop ()); } catch ( _ ){} return ; }
meetScreenStream = ds ; meetScreen = true ;
track . onended = ()=> stopScreen (); // browser's native "Stop sharing" bar
for ( const [ pid , p ] of meetPeers ){
if ( p . vsender ){ try { await p . vsender . replaceTrack ( track ); } catch ( _ ){} }
else { try { p . vsender = p . pc . addTrack ( track , meetLocalStream ); const off = await p . pc . createOffer (); await p . pc . setLocalDescription ( off ); meetSend ({ type : 'meeting-signal' , to : pid , data : { sdp : p . pc . localDescription }}); } catch ( _ ){} }
}
addTile ( '__local' , ds , (( ME && ME . name ) ? ME . name : 'You' ) + ' (screen)' , true ); setTileScreen ( '__local' , true );
updateScreenBtn (); meetSend ({ type : 'meeting-screen' , on : true });
}
function stopScreen (){
if ( ! meetScreen ) return ; meetScreen = false ;
const cam = meetLocalStream && meetLocalStream . getVideoTracks ()[ 0 ];
for ( const [, p ] of meetPeers ){ if ( p . vsender ){ try { p . vsender . replaceTrack ( cam || null ); } catch ( _ ){} } }
if ( meetScreenStream ){ try { meetScreenStream . getTracks (). forEach ( t => t . stop ()); } catch ( _ ){} meetScreenStream = null ; }
addTile ( '__local' , meetLocalStream , ( ME && ME . name ) ? ME . name : 'You' , true ); setTileScreen ( '__local' , false );
updateScreenBtn (); meetSend ({ type : 'meeting-screen' , on : false });
}
function updateScreenBtn (){ const b = document . getElementById ( 'meetScreenBtn' ); if ( ! b ) return ; b . classList . toggle ( 'on' , meetScreen ); b . title = meetScreen ? 'Stop sharing' : 'Share screen' ; b . innerHTML = ic ( 'monitor' , 20 ); }
function setTileScreen ( id , on ){ const t = document . getElementById ( 'meet-tile-' + id ); if ( ! t ) return ; t . classList . toggle ( 'sharing' , !! on ); let b = t . querySelector ( '.meet-screen' ); if ( on ){ if ( ! b ){ b = document . createElement ( 'div' ); b . className = 'meet-screen' ; b . innerHTML = ic ( 'monitor' , 12 ) + ' Screen' ; t . appendChild ( b ); } } else if ( b ){ b . remove (); } updateShareMode (); }
// Stage mode: one chosen shared screen fills the area; other screens + people are small on the right.
function getSharerIds (){ const a = []; if ( meetScreen ) a . push ( '__local' ); meetSharers . forEach ( id => a . push ( id )); return a ; }
function updateShareMode (){ const g = document . getElementById ( 'meetGrid' ); if ( ! g ) return ;
const sharers = getSharerIds (); const on = sharers . length > 0 ;
g . classList . toggle ( 'sharing-mode' , on );
if ( on ){ if ( ! meetStageId || sharers . indexOf ( meetStageId ) < 0 ) meetStageId = sharers [ 0 ]; } else meetStageId = null ;
applyStage ();
}
function applyStage (){ const g = document . getElementById ( 'meetGrid' ); if ( ! g ) return ; g . querySelectorAll ( '.meet-tile.stage' ). forEach ( t => t . classList . remove ( 'stage' )); if ( meetStageId ){ const t = document . getElementById ( 'meet-tile-' + meetStageId ); if ( t ) t . classList . add ( 'stage' ); } }
function setStage ( id ){ if ( ! id ) return ; meetStageId = id ; applyStage (); }
// Record + transcript are host-only; show/hide their buttons when host status changes.
function updateHostControls (){ document . querySelectorAll ( '.meet-bar .host-only' ). forEach ( b =>{ b . style . display = meetIsHost ? 'inline-flex' : 'none' ; }); }
// ---- Recording: composite all tiles onto a canvas + mix everyone's audio, then MediaRecorder ----
function drawCover ( ctx , v , x , y , w , h ){ const vw = v . videoWidth , vh = v . videoHeight ; if ( ! vw ||! vh ) return ; const s = Math . max ( w / vw , h / vh ), dw = vw * s , dh = vh * s ; ctx . save (); ctx . beginPath (); ctx . rect ( x , y , w , h ); ctx . clip (); ctx . drawImage ( v , x + ( w - dw ) / 2 , y + ( h - dh ) / 2 , dw , dh ); ctx . restore (); }
function recMime (){ const c = [ 'video/webm;codecs=vp9,opus' , 'video/webm;codecs=vp8,opus' , 'video/webm' ]; for ( const t of c ){ if ( window . MediaRecorder && MediaRecorder . isTypeSupported ( t )) return t ; } return '' ; }
function toggleRecord (){ if ( ! meetIsHost ){ toast ( 'Only the host can record' ); return ; } if ( meetRec ){ meetRec . stop (); return ; } startRecord (); }
function startRecord (){
if ( ! window . MediaRecorder ){ toast ( 'Recording is not supported in this browser' ); return ; }
const canvas = document . createElement ( 'canvas' ); canvas . width = 1280 ; canvas . height = 720 ; const ctx = canvas . getContext ( '2d' );
let raf = 0 ;
const draw = ()=>{
const vids = [... document . querySelectorAll ( '#meetGrid .meet-tile video' )]. filter ( v => v . srcObject );
ctx . fillStyle = '#0b1220' ; ctx . fillRect ( 0 , 0 , canvas . width , canvas . height );
const n = Math . max ( 1 , vids . length ), cols = Math . ceil ( Math . sqrt ( n )), rows = Math . ceil ( n / cols ), cw = canvas . width / cols , ch = canvas . height / rows ;
vids . forEach (( v , i )=>{ const r = Math . floor ( i / cols ), c = i % cols ; drawCover ( ctx , v , c * cw , r * ch , cw - 4 , ch - 4 ); });
raf = requestAnimationFrame ( draw );
};
const cstream = canvas . captureStream ( 25 );
let actx = null , dest = null ;
try { actx = new ( window . AudioContext || window . webkitAudioContext )(); dest = actx . createMediaStreamDestination (); const seen = new Set ();
document . querySelectorAll ( '#meetGrid .meet-tile video' ). forEach ( v =>{ const s = v . srcObject ; if ( s &&! seen . has ( s ) && s . getAudioTracks && s . getAudioTracks (). length ){ seen . add ( s ); try { actx . createMediaStreamSource ( s ). connect ( dest ); } catch ( _ ){} } });
} catch ( _ ){}
const tracks = [... cstream . getVideoTracks ()]; if ( dest ) tracks . push (... dest . stream . getAudioTracks ());
let rec ; try { rec = new MediaRecorder ( new MediaStream ( tracks ), recMime () ? { mimeType : recMime ()} : undefined ); }
catch ( e ){ toast ( 'Recording is not supported in this browser' ); try { actx && actx . close ();} catch ( _ ){} return ; }
const chunks = []; const startedAt = Date . now (); let stopped = false ;
rec . ondataavailable = e =>{ if ( e . data && e . data . size ) chunks . push ( e . data ); };
rec . onstop = ()=>{ try { cancelAnimationFrame ( raf ); } catch ( _ ){} try { actx && actx . close (); } catch ( _ ){} const blob = new Blob ( chunks ,{ type : ( chunks [ 0 ] && chunks [ 0 ]. type ) || 'video/webm' }); uploadRecording ( blob , Date . now () - startedAt ); };
const stop = ()=>{ if ( stopped ) return ; stopped = true ; try { rec . stop (); } catch ( _ ){} meetRec = null ; updateRecBtn (); meetSend ({ type : 'meeting-recording' , on : false }); recNotice ( false ); toast ( 'Recording saved to Past meetings' ); };
meetRec = { rec , stop }; draw (); rec . start ( 2000 ); updateRecBtn ();
meetSend ({ type : 'meeting-recording' , on : true }); recNotice ( true , 'You' ); // notify everyone (visual + voice)
}
function speak ( text ){ try { if ( window . speechSynthesis ){ const u = new SpeechSynthesisUtterance ( text ); u . rate = 1 ; speechSynthesis . cancel (); speechSynthesis . speak ( u ); } } catch ( _ ){} }
function fmtElapsed ( ms ){ const s = Math . max ( 0 , Math . floor ( ms / 1000 )); const m = Math . floor ( s / 60 ); return String ( m ). padStart ( 2 , '0' ) + ':' + String ( s % 60 ). padStart ( 2 , '0' ); }
let _recTimer = null , _recStart = 0 ;
function recNotice ( on , by ){
let el = document . getElementById ( 'recNotice' );
if ( on ){
if ( _recTimer ) return ; // already showing
if ( ! el ){ el = document . createElement ( 'div' ); el . id = 'recNotice' ; el . className = 'rec-notice' ; document . body . appendChild ( el ); }
_recStart = Date . now ();
const tick = ()=>{ const e = document . getElementById ( 'recNotice' ); if ( e ) e . innerHTML = '<span class="rec-dot"></span> Recording · ' + fmtElapsed ( Date . now () - _recStart ); };
tick (); _recTimer = setInterval ( tick , 1000 );
speak ( 'This meeting is now being recorded' );
} else {
if ( _recTimer ){ clearInterval ( _recTimer ); _recTimer = null ; }
if ( el ) el . remove ();
speak ( 'Recording stopped' );
}
}
function updateRecBtn (){ const b = document . getElementById ( 'meetRecBtn' ); if ( ! b ) return ; b . classList . toggle ( 'on' , !! meetRec ); b . title = meetRec ? 'Stop recording' : 'Record meeting' ; }
async function uploadRecording ( blob , durMs ){
if ( ! blob ||! blob . size ) return ;
try { const gid = ( meetReturn && meetReturn . kind === 'group' ) ? meetReturn . id : '' ;
const q = '/api/meetings/recording?room=' + encodeURIComponent ( meetRoom || '' ) + ( gid ? ( '&group=' + encodeURIComponent ( gid )) : '' ) + '&dur=' + Math . round ( durMs || 0 );
const r = await fetch ( q ,{ method : 'POST' , headers : { 'Content-Type' : 'application/octet-stream' }, body : blob });
if ( ! r . ok ) throw 0 ;
} catch ( _ ){ toast ( 'Could not upload the recording' ); }
}
// ---- Live transcript: each participant transcribes their own mic; the server assembles it ----
// Transcript: subscribe to get your OWN private copy of the FULL conversation. While anyone is
// subscribed, every client transcribes its own mic into one shared transcript (merged with speaker
// names); each subscriber gets a private copy. Unsubscribing only drops YOUR copy, not others'.
function toggleTranscribe (){ meetTranscribe =! meetTranscribe ; meetSend ({ type : 'meeting-transcribe' , on : meetTranscribe }); updateTransBtn (); toast ( meetTranscribe ? 'Transcript on — your private copy is saved to Past meetings after the call' : 'You left the transcript — your copy is being saved' ); }
function applyRoomTx ( active ){ if ( active === meetRoomTx ) return ; meetRoomTx = active ; if ( active ) startSR (); else stopSR (); transcribeNotice ( active ); }
function startSR (){ if ( meetSR ) return ; const SR = window . SpeechRecognition || window . webkitSpeechRecognition ; if ( ! SR ){ if ( meetTranscribe ) toast ( 'Live transcript needs Chrome or Edge' ); return ; }
try { meetSR = new SR (); } catch ( _ ){ return ; }
meetSR . continuous = true ; meetSR . interimResults = false ; meetSR . lang = 'en-US' ;
meetSR . onresult = ( e )=>{ for ( let i = e . resultIndex ; i < e . results . length ; i ++ ){ const r = e . results [ i ]; if ( r . isFinal ){ const text = (( r [ 0 ] && r [ 0 ]. transcript ) || '' ). trim (); if ( text ) meetSend ({ type : 'meeting-transcript' , text }); } } };
meetSR . onerror = ()=>{}; meetSR . onend = ()=>{ if ( meetRoomTx ){ try { meetSR . start (); } catch ( _ ){} } };
try { meetSR . start (); } catch ( _ ){}
}
function stopSR (){ if ( meetSR ){ try { meetSR . onend = null ; meetSR . stop (); } catch ( _ ){} meetSR = null ; } }
function updateTransBtn (){ const b = document . getElementById ( 'meetTransBtn' ); if ( ! b ) return ; b . classList . toggle ( 'on' , meetTranscribe ); b . title = meetTranscribe ? 'Stop my transcript' : 'Transcribe (your private copy)' ; }
function transcribeNotice ( on ){ let el = document . getElementById ( 'txNotice' ); if ( on ){ if ( ! el ){ el = document . createElement ( 'div' ); el . id = 'txNotice' ; el . className = 'tx-notice' ; el . innerHTML = ic ( 'fileText' , 12 ) + ' Transcribing' ; document . body . appendChild ( el ); } } else if ( el ){ el . remove (); } }
async function enterMeeting ( code , audioOnly ){
if ( meetState === 'call' ){ switchTab ( 'meeting' ); return ; } // already in a call — ignore double-join
meetAudioOnly =!! audioOnly ;
// Start with NO media — mic & cam OFF by default (no permission prompt until the user
// turns one on). Tracks are acquired on demand by toggleMic / toggleCam.
meetLocalStream = new MediaStream ();
meetMic = false ; meetCam = false ; meetIsHost = false ; meetHostId = null ;
meetScreen = false ; meetScreenStream = null ; meetSharers . clear (); meetMultiShare = false ;
meetRec = null ; meetTranscribe = false ; meetRoomTx = false ; meetSR = null ; _addPool = null ; meetStageId = null ;
try { const c = await fetch ( '/api/ice' ). then ( r => r . json ()); if ( c && c . iceServers ) MEET_ICE = c ; } catch ( _ ){}
meetState = 'call' ; meetRailLive ( true );
meetWs = new WebSocket (( location . protocol === 'https:' ? 'wss://' : 'ws://' ) + location . host + '/ws' );
meetWs . onmessage = onMeetMsg ;
meetWs . onopen = ()=>{ if ( code ){ meetRoom = code ; renderCall (); meetSend ({ type : 'meeting-join' , room : code , name : ( ME . name || ME . email || 'Guest' )}); } else { meetSend ({ type : 'meeting-create' }); } };
}
async function onMeetMsg ( e ){
let m ; try { m = JSON . parse ( e . data ); } catch ( _ ){ return ; }
if ( m . type === 'meeting-created' ){ meetRoom = m . room ; renderCall (); meetSend ({ type : 'meeting-join' , room : m . room , name : ( ME . name || ME . email || 'Guest' )}); if ( meetAnnounceGroup ){ const g = meetAnnounceGroup ; meetAnnounceGroup = null ; try { postJSON ( '/api/messages' ,{ group : g , body : '📹 Started a group call — join with code ' + m . room }); } catch ( _ ){} } return ; }
if ( m . type === 'meeting-joined' ){
meetMyId = m . peerId ;
if ( m . isHost ){ meetIsHost = true ; meetHostId = meetMyId ; } // host = the meeting creator (server-decided)
meetWatchStream ( '__local' , meetLocalStream ); // active-speaker detection on my own mic
// Existing peers OFFER to me (their offers carry their tracks incl. any active screen share);
// I just set up the connections and wait. Avoids the "newcomer can't receive screen" bug.
for ( const p of ( m . peers || [])){ meetNames . set ( p . peerId , p . name ); meetMakePeer ( p . peerId , p . name ); }
meetSend ({ type : 'meeting-state' , muted :! meetMic , camOff :! meetCam }); // tell existing peers my state
if ( meetIsHost ) meetSend ({ type : 'meeting-host' , to : meetMyId }); // announce host so others know
refreshMeetPanel (); updateHostControls ();
return ;
}
if ( m . type === 'meeting-ended' ){ toast ( 'Call ended' ); leaveMeeting (); return ; } // 1:1 hangup, or host ended
if ( m . type === 'meeting-peer-joined' ){
meetNames . set ( m . peerId , m . name );
const pc = meetMakePeer ( m . peerId , m . name ); // I'm an existing peer → I OFFER to the newcomer (carries my screen)
try { const offer = await pc . createOffer (); await pc . setLocalDescription ( offer ); meetSend ({ type : 'meeting-signal' , to : m . peerId , data : { sdp : pc . localDescription }}); } catch ( _ ){}
meetSend ({ type : 'meeting-state' , muted :! meetMic , camOff :! meetCam });
if ( meetIsHost ){ meetSend ({ type : 'meeting-host' , to : meetHostId }); meetSend ({ type : 'meeting-sharemode' , multi : meetMultiShare }); if ( meetRec ) meetSend ({ type : 'meeting-recording' , on : true }); }
if ( meetScreen ) meetSend ({ type : 'meeting-screen' , on : true });
refreshMeetPanel (); return ;
}
if ( m . type === 'meeting-peer-state' ){ setTileMute ( m . peerId , !! m . muted ); refreshMeetPanel (); return ; }
if ( m . type === 'meeting-host' ){ const was = meetIsHost ; meetHostId = m . hostPeerId ; meetIsHost = ( m . hostPeerId === meetMyId ); if ( meetIsHost &&! was ){ toast ( 'You are now the host' ); try { speak ( 'You are now the host' ); } catch ( _ ){} } refreshMeetPanel (); updateHostControls (); return ; }
if ( m . type === 'meeting-transcribe-state' ){ applyRoomTx ( !! m . active ); return ; } // ≥1 subscriber → transcribe my mic
if ( m . type === 'meeting-recording' ){ recNotice ( !! m . on , m . by ); return ; } // someone is recording — show + announce
if ( m . type === 'meeting-peer-screen' ){ if ( m . on ) meetSharers . add ( m . from ); else meetSharers . delete ( m . from ); setTileScreen ( m . from , !! m . on ); refreshMeetPanel (); return ; }
if ( m . type === 'meeting-sharemode' ){ meetMultiShare =!! m . multi ; refreshMeetPanel (); return ; }
if ( m . type === 'meeting-muteall' ){ if ( meetMic && meetLocalStream ){ meetMic = false ; meetLocalStream . getAudioTracks (). forEach ( t => t . enabled = false ); updateMicBtn (); setTileMute ( '__local' , true ); meetSend ({ type : 'meeting-state' , muted : true , camOff :! meetCam }); } toast ( 'You were muted by the host' ); return ; }
if ( m . type === 'meeting-peer-left' ){ const p = meetPeers . get ( m . peerId ); if ( p ){ try { p . pc . close ();} catch ( _ ){} meetPeers . delete ( m . peerId );} meetSharers . delete ( m . peerId ); removeTile ( m . peerId ); refreshMeetPanel (); return ; }
if ( m . type === 'meeting-signal' ){
const from = m . from , d = m . data || {};
if ( d . sdp ){
let p = meetPeers . get ( from ), pc = p && p . pc ;
if ( d . sdp . type === 'offer' ){
if ( ! pc ) pc = meetMakePeer ( from , meetNames . get ( from ) || 'Guest' );
try { await pc . setRemoteDescription ( d . sdp ); const ans = await pc . createAnswer (); await pc . setLocalDescription ( ans ); meetSend ({ type : 'meeting-signal' , to : from , data : { sdp : pc . localDescription }}); } catch ( _ ){}
} else if ( d . sdp . type === 'answer' ){ if ( pc ){ try { await pc . setRemoteDescription ( d . sdp ); } catch ( _ ){} } }
} else if ( d . candidate ){ const p = meetPeers . get ( from ); if ( p && p . pc ){ try { await p . pc . addIceCandidate ( d . candidate ); } catch ( _ ){} } }
return ;
}
if ( m . type === 'error' ){ const msg = m . message ; leaveMeeting (); const e2 = document . getElementById ( 'meetErr' ); if ( e2 ) e2 . textContent = msg || 'Meeting error' ; return ; }
}
function updateMicBtn (){ const b = document . getElementById ( 'meetMicBtn' ); if ( b ){ b . classList . toggle ( 'off' , ! meetMic ); b . title = meetMic ? 'Mute' : 'Unmute' ; b . innerHTML = ic ( meetMic ? 'mic' : 'micOff' , 20 ); } }
function updateCamBtn (){ const b = document . getElementById ( 'meetCamBtn' ); if ( b ){ b . classList . toggle ( 'off' , ! meetCam ); b . title = meetCam ? 'Turn camera off' : 'Turn camera on' ; b . innerHTML = ic ( meetCam ? 'video' : 'videoOff' , 20 ); } }
// Unmute acquires the mic on demand (no prompt until then) and renegotiates with peers.
async function toggleMic (){
if ( ! meetLocalStream ) return ;
const hasTrack = meetLocalStream . getAudioTracks (). length > 0 ;
if ( ! hasTrack ){
let astream ; try { astream = await navigator . mediaDevices . getUserMedia ({ audio : true }); }
catch ( e ){ toast ( 'Microphone permission is required to unmute' ); return ; }
const track = astream . getAudioTracks ()[ 0 ]; if ( ! track ) return ;
meetLocalStream . addTrack ( track ); meetMic = true ; meetWatchStream ( '__local' , meetLocalStream );
for ( const [ pid , p ] of meetPeers ){ try { p . pc . addTrack ( track , meetLocalStream ); const off = await p . pc . createOffer (); await p . pc . setLocalDescription ( off ); meetSend ({ type : 'meeting-signal' , to : pid , data : { sdp : p . pc . localDescription }}); } catch ( _ ){} }
} else {
meetMic =! meetMic ; meetLocalStream . getAudioTracks (). forEach ( t => t . enabled = meetMic );
}
updateMicBtn (); setTileMute ( '__local' , ! meetMic ); meetSend ({ type : 'meeting-state' , muted :! meetMic , camOff :! meetCam });
}
// Toggle the camera. In an audio call (no video track yet) this acquires the camera and
// renegotiates with every peer, so you can always turn video on once a meeting has started.
async function toggleCam (){
if ( ! meetLocalStream ) return ;
const hasTrack = meetLocalStream . getVideoTracks (). length > 0 ;
if ( ! hasTrack ){
let vstream ; try { vstream = await navigator . mediaDevices . getUserMedia ({ video : true }); }
catch ( e ){ toast ( 'Camera permission is required to turn on video' ); return ; }
const track = vstream . getVideoTracks ()[ 0 ]; if ( ! track ) return ;
meetLocalStream . addTrack ( track ); meetCam = true ; meetAudioOnly = false ;
for ( const [ pid , p ] of meetPeers ){ try { const s = p . pc . addTrack ( track , meetLocalStream ); if ( ! p . vsender ) p . vsender = s ; const off = await p . pc . createOffer (); await p . pc . setLocalDescription ( off ); meetSend ({ type : 'meeting-signal' , to : pid , data : { sdp : p . pc . localDescription }}); } catch ( _ ){} }
const chip = document . getElementById ( 'meetCodeChip' ); if ( chip ) chip . innerHTML = 'Room <b>' + pEsc ( meetRoom || '' ) + '</b> · share to invite' ;
} else {
meetCam =! meetCam ; meetLocalStream . getVideoTracks (). forEach ( t => t . enabled = meetCam );
}
updateCamBtn ();
addTile ( '__local' , meetLocalStream , ( ME && ME . name ) ? ME . name : 'You' , true );
setTileMute ( '__local' , ! meetMic ); meetSend ({ type : 'meeting-state' , muted :! meetMic , camOff :! meetCam });
}
let meetLeaving = false ;
function leaveMeeting (){
if ( meetLeaving ) return ; meetLeaving = true ;
// Host leaving voluntarily must hand off so the meeting isn't left host-less.
if ( meetIsHost && meetPeers . size > 0 ){
const next = meetPeers . keys (). next (). value ; // first remaining participant
if ( next ){ meetSend ({ type : 'meeting-host' , to : next }); toast ( 'Host handed to ' + ( meetNames . get ( next ) || 'a participant' )); }
}
if ( meetRec ) meetRec . stop (); // saves the recording
if ( meetTranscribe ) meetSend ({ type : 'meeting-transcribe' , on : false }); // server saves my copy
meetTranscribe = false ; meetRoomTx = false ; stopSR (); transcribeNotice ( false );
if ( meetScreen ) stopScreen ();
if ( _recTimer ){ clearInterval ( _recTimer ); _recTimer = null ; } const _rn = document . getElementById ( 'recNotice' ); if ( _rn ) _rn . remove (); // clear any "Recording" badge
meetSend ({ type : 'meeting-leave' });
meetUnwatchAll (); meetSharers . clear ();
meetPeers . forEach ( p =>{ try { p . pc . close ();} catch ( _ ){} }); meetPeers . clear (); meetNames . clear (); meetMuted . clear ();
if ( meetLocalStream ){ try { meetLocalStream . getTracks (). forEach ( t => t . stop ()); } catch ( _ ){} meetLocalStream = null ; }
if ( meetWs ){ try { meetWs . close (); } catch ( _ ){} meetWs = null ; }
meetRoom = null ; meetMyId = null ; meetState = 'idle' ; meetIsHost = false ; meetHostId = null ; meetRailLive ( false );
const ret = meetReturn ; meetReturn = null ; meetLeaving = false ;
if ( ret ){ switchTab ( 'chat' ); selectChat ( ret . kind , ret . id ); } // land back on the originating chat
else renderMeetingLobby ();
2026-06-12 00:40:07 +05:30
}
// ---------- Tabs (icon rail) ----------
// Chat and Meeting are in-shell panels; Share and Connect load in the center panel via
// a single, same-origin, lazily-loaded iframe (cheap isolation, no page navigation).
const railBtns = document . querySelectorAll ( '.railbtn' );
const panels = document . querySelectorAll ( '.panel' );
const chatcol = document . getElementById ( 'chatcol' );
let loaded = { share : false , connect : false };
function currentTab (){ const b = document . querySelector ( '.railbtn.active' ); return b ? b . dataset . tab : 'chat' ; }
function switchTab ( tab ){
railBtns . forEach ( b => b . classList . toggle ( 'active' , b . dataset . tab === tab ));
panels . forEach ( p => p . classList . toggle ( 'active' , p . dataset . panel === tab ));
chatcol . classList . toggle ( 'hidden' , tab !== 'chat' );
2026-06-23 16:15:29 +05:30
document . querySelector ( '.shell' ). classList . toggle ( 'is-chat' , tab === 'chat' ); // mobile one-pane layout
if ( tab !== 'chat' ) document . body . classList . remove ( 'chat-open' );
document . body . classList . remove ( 'rail-open' ); // close the mobile drawer after picking a tab
2026-06-12 00:40:07 +05:30
// Lazy-load the embedded flows on first open; keep them mounted afterwards so a
// live session survives tab switches.
if ( tab === 'share' && ! loaded . share ){ document . getElementById ( 'sharePanel' ). innerHTML = '<iframe src="/share?embed=1" allow="camera; microphone; display-capture; clipboard-read; clipboard-write"></iframe>' ; loaded . share = true ; }
if ( tab === 'connect' && ! loaded . connect ){ document . getElementById ( 'connectPanel' ). innerHTML = '<iframe src="/connect?embed=1" allow="camera; microphone; display-capture; clipboard-read; clipboard-write"></iframe>' ; loaded . connect = true ; }
2026-06-23 16:15:29 +05:30
if ( tab === 'meeting' && meetState === 'idle' ){ renderMeetingLobby (); }
2026-06-12 00:40:07 +05:30
}
2026-06-23 16:15:29 +05:30
function showWelcome (){ selected = null ; document . body . classList . remove ( 'chat-open' ); renderChats ( searchVal ()); renderChatPanel (); updateRailUnread (); }
2026-06-12 00:40:07 +05:30
railBtns . forEach ( btn =>{ btn . onclick = ()=>{
const tab = btn . dataset . tab ;
// Re-clicking Chat (while already on it) returns to the welcome screen.
2026-06-23 16:15:29 +05:30
if ( tab === 'chat' && currentTab () === 'chat' && selected != null ){ showWelcome (); }
2026-06-12 00:40:07 +05:30
switchTab ( tab );
}; });
// Esc clears the open conversation and brings back the welcome screen.
2026-06-23 16:15:29 +05:30
document . addEventListener ( 'keydown' ,( e )=>{ if ( e . key === 'Escape' && currentTab () === 'chat' && selected != null ){ showWelcome (); } });
// Hamburger: on mobile it slides the rail drawer in/out; on desktop it collapses the rail.
function toggleNav (){ if ( window . innerWidth <= 760 ) document . body . classList . toggle ( 'rail-open' ); else document . body . classList . toggle ( 'rail-hidden' ); }
const _navT = document . getElementById ( 'navToggle' ); if ( _navT ) _navT . onclick = toggleNav ;
const _railBd = document . getElementById ( 'railBackdrop' ); if ( _railBd ) _railBd . onclick = ()=> document . body . classList . remove ( 'rail-open' );
// Mobile chat: "back" returns from an open conversation to the chat list.
function chatBack (){ document . body . classList . remove ( 'chat-open' ); }
2026-06-12 00:40:07 +05:30
// Embedded Share/Connect flows report session start/stop so the rail can show a "live"
// dot — that's how you know a session is still running after switching to Chat.
window . addEventListener ( 'message' ,( e )=>{
if ( e . origin !== location . origin ) return ;
const d = e . data ;
if ( ! d || d . type !== 'bzc-session' || ( d . flow !== 'share' && d . flow !== 'connect' )) return ;
const btn = document . querySelector ( '.railbtn[data-tab="' + d . flow + '"]' );
if ( ! btn ) return ;
btn . classList . toggle ( 'live' , !! d . active );
if ( d . active && currentTab () !== d . flow ){
toast (( d . flow === 'share' ? 'Screen share' : 'Connection' ) + ' is live — tap the highlighted icon to return' );
}
});
// Sidebar + misc wiring
2026-06-23 16:15:29 +05:30
document . getElementById ( 'chatSearch' ). addEventListener ( 'input' , e =>{ const x = document . getElementById ( 'chatSearchX' ); if ( x ) x . style . display = e . target . value ? 'grid' : 'none' ; renderChats ( e . target . value ); });
( function (){ const x = document . getElementById ( 'chatSearchX' ); if ( x ) x . onclick = ()=>{ const s = document . getElementById ( 'chatSearch' ); s . value = '' ; x . style . display = 'none' ; renderChats ( '' ); s . focus (); }; })();
document . getElementById ( 'newChat' ). title = 'New group' ;
document . getElementById ( 'newChat' ). innerHTML = ic ( 'userPlus' , 18 );
document . getElementById ( 'newChat' ). onclick = ()=> openNewGroup ();
2026-06-12 00:40:07 +05:30
// ---------- Login (shown here on /home when logged out) ----------
const EYE_OFF = '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>' ;
const EYE_ON = '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>' ;
function pwField ( id , ph ){ return '<div class="pwwrap"><input id="' + id + '" type="password" placeholder="' + ph + '"><button type="button" class="eye" data-for="' + id + '" aria-label="Show password"></button></div>' ;}
function wireEyes (){ document . querySelectorAll ( '.eye' ). forEach ( b =>{ if ( b . _w ) return ; b . _w = 1 ; b . innerHTML = EYE_OFF ; b . onclick = ()=>{ const inp = document . getElementById ( b . getAttribute ( 'data-for' )); if ( ! inp ) return ; const show = inp . type === 'password' ; inp . type = show ? 'text' : 'password' ; b . innerHTML = show ? EYE_ON : EYE_OFF ;};});}
function onEnter ( ids , fn ){ ids . forEach ( id =>{ const el = document . getElementById ( id ); if ( el ) el . addEventListener ( 'keydown' , e =>{ if ( e . key === 'Enter' ){ e . preventDefault (); fn ();}});});}
function showErr ( id , msg ){ const el = document . getElementById ( id ); el . textContent = msg ; el . classList . add ( 'show' );}
function clearErr ( id ){ const el = document . getElementById ( id ); el . textContent = '' ; el . classList . remove ( 'show' );}
async function postJSON ( path , body ){ const r = await fetch ( path ,{ method : 'POST' , headers : { 'Content-Type' : 'application/json' }, body : JSON . stringify ( body )}); const d = await r . json (). catch (()=>({})); if ( ! r . ok ) throw new Error ( d . error || 'request failed' ); return d ;}
async function renderLogin (){
document . querySelector ( '.shell' ). style . display = 'none' ;
const aw = document . getElementById ( 'authwrap' ); aw . style . display = 'flex' ;
let regOpen = false ; try { regOpen = ( await ( await fetch ( '/api/setup-state' )). json ()). registrationOpen ; } catch ( _ ){}
aw . innerHTML = `<div class="authcard">
<h1>Welcome to BizGaze Connect</h1>
<div class="sub">Sign in to access chats, screen share and connect.</div>
${ regOpen ? `<div class="authtabs">
<button id="tabLogin" class="active">Sign in</button>
<button id="tabReg">Register team</button>
</div>` : '' }
<div id="loginForm">
<span class="lbl">Email</span><input id="li_email" type="email" placeholder="you@bizgaze.com">
<span class="lbl">Password</span> ${ pwField ( 'li_pw' , 'password' ) }
<label style="display:flex;align-items:center;gap:.5rem;margin:.7rem 0;color:var(--ink);font-size:.9rem;cursor:pointer"><input type="checkbox" id="li_remember" style="width:18px;height:18px;accent-color:var(--blue);margin:0"> Remember me on this device</label>
<button class="gobtn" id="li_btn">Sign in</button>
<p id="li_err" class="formerr"></p>
</div>
${ regOpen ? `<div id="regForm" class="hidden">
<span class="lbl">Team name</span><input id="rg_team" placeholder="e.g. BizGaze Support">
<span class="lbl">Email</span><input id="rg_email" type="email" placeholder="you@bizgaze.com">
<span class="lbl">Password</span> ${ pwField ( 'rg_pw' , 'min 8 characters' ) }
<button class="gobtn" id="rg_btn">Create team</button>
<p id="rg_err" class="formerr"></p>
</div>` : '' }
</div>` ;
document . getElementById ( 'li_btn' ). onclick = doLogin ;
wireEyes ();
onEnter ([ 'li_email' , 'li_pw' ], doLogin );
if ( regOpen ){
const lf = document . getElementById ( 'loginForm' ), rf = document . getElementById ( 'regForm' );
const tl = document . getElementById ( 'tabLogin' ), tr = document . getElementById ( 'tabReg' );
tl . onclick = ()=>{ lf . classList . remove ( 'hidden' ); rf . classList . add ( 'hidden' ); tl . classList . add ( 'active' ); tr . classList . remove ( 'active' );};
tr . onclick = ()=>{ rf . classList . remove ( 'hidden' ); lf . classList . add ( 'hidden' ); tr . classList . add ( 'active' ); tl . classList . remove ( 'active' );};
document . getElementById ( 'rg_btn' ). onclick = doRegister ;
onEnter ([ 'rg_team' , 'rg_email' , 'rg_pw' ], doRegister );
}
}
async function doLogin (){
clearErr ( 'li_err' );
try {
await postJSON ( '/api/login' ,{ email : document . getElementById ( 'li_email' ). value , password : document . getElementById ( 'li_pw' ). value , remember : document . getElementById ( 'li_remember' ). checked });
location . reload ();
} catch ( e ){ showErr ( 'li_err' , /invalid credentials/i . test ( e . message ) ? 'Incorrect email or password. Please try again.' : e . message ); }
}
async function doRegister (){
clearErr ( 'rg_err' );
try {
await postJSON ( '/api/register' ,{ email : document . getElementById ( 'rg_email' ). value , password : document . getElementById ( 'rg_pw' ). value , teamName : document . getElementById ( 'rg_team' ). value });
await postJSON ( '/api/login' ,{ email : document . getElementById ( 'rg_email' ). value , password : document . getElementById ( 'rg_pw' ). value });
location . reload ();
} catch ( e ){ showErr ( 'rg_err' , e . message ); }
}
// ---------- Boot: show the app if signed in, otherwise the login ----------
( async function (){
let me = null ;
try { const r = await fetch ( '/api/me' ); if ( r . ok ) me = await r . json (); } catch ( _ ){}
if ( ! me ){ await renderLogin (); document . getElementById ( 'loading' ). style . display = 'none' ; return ; }
ME = me ;
2026-06-23 16:15:29 +05:30
document . getElementById ( 'hdrRight' ). innerHTML = bellHTML () + profileHTML ( me );
loadNotifs (); wireBell (); wireProfile ();
connectChatWs ();
2026-06-23 21:58:49 +05:30
setupPush (); // register the notification service worker + subscribe to Web Push (if granted)
2026-06-12 00:40:07 +05:30
document . getElementById ( 'loading' ). style . display = 'none' ;
2026-06-23 16:15:29 +05:30
// Fast-path: when opened from a notification, show the chat immediately (only needs the
// thread fetch) and load the sidebar in the background — don't make the reload wait on it.
let oid = null , okind = 'dm' ;
try { const q = new URLSearchParams ( location . search ); oid = q . get ( 'openId' ); okind = q . get ( 'openKind' ) || 'dm' ; if ( oid ) history . replaceState ( null , '' , '/home' ); } catch ( _ ){}
if ( oid ){
selectChat ( okind , oid ); // open the chat right away (only waits on the thread fetch)
loadSidebar (). then (()=>{ try { const it = rowFor ( okind , oid ); const nm = document . querySelector ( '#chatPanel .convo-head .nm' ); if ( it && nm ) nm . textContent = it . name ; } catch ( _ ){} });
} else { await loadSidebar (); renderChatPanel (); }
2026-06-12 00:40:07 +05:30
})();
</ script >
</ body >
</ html >