2026-06-12 00:40:07 +05:30
<!DOCTYPE html>
< html lang = "en" >
< head >
< meta charset = "UTF-8" >
2026-06-24 17:13:01 +05:30
< meta name = "viewport" content = "width=device-width, initial-scale=1, viewport-fit=cover" >
2026-06-30 17:01:15 +05:30
< title > Biz Connect</ title >
2026-06-24 17:13:01 +05:30
<!-- PWA: installable on Android & iOS ("Add to Home Screen"); also enables iOS web push -->
< link rel = "manifest" href = "/manifest.json" >
< meta name = "theme-color" content = "#1F3B73" >
2026-07-02 15:43:02 +05:30
< link rel = "icon" href = "/favicon.ico?v=3" sizes = "any" >
2026-07-02 12:41:48 +05:30
< link rel = "icon" type = "image/png" sizes = "32x32" href = "/favicon-32.png?v=3" >
2026-07-02 15:43:02 +05:30
< link rel = "apple-touch-icon" href = "/apple-touch-icon-180.png?v=3" >
< link rel = "stylesheet" href = "/bizconnect-toast.css" >
< script src = "/bizconnect-toast.js" ></ script >
2026-06-24 17:13:01 +05:30
< meta name = "apple-mobile-web-app-capable" content = "yes" >
< meta name = "mobile-web-app-capable" content = "yes" >
< meta name = "apple-mobile-web-app-status-bar-style" content = "black-translucent" >
< meta name = "apple-mobile-web-app-title" content = "Connect" >
2026-06-12 00:40:07 +05:30
< 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-30 17:01:15 +05:30
/* #4: the top blue bar is removed everywhere — bell+profile live in the chat-list header. */
header { display : none ;}
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 ;}
2026-06-30 17:01:15 +05:30
. bellbtn { position : relative ; background : var ( -- blue - soft ); border : 1 px solid var ( -- line ); color : var ( -- blue ); cursor : pointer ; width : 38 px ; height : 38 px ; border-radius : 10 px ; display : grid ; place-items : center ;}
. bellbtn : hover { background : #dbe6fb ;}
2026-06-23 16:15:29 +05:30
. bellbtn : hover { background : rgba ( 255 , 255 , 255 , .24 );}
2026-06-30 17:01:15 +05:30
. bell-dot { position : absolute ; top : 2 px ; right : 2 px ; min-width : 16 px ; height : 16 px ; border-radius : 99 px ; background : var ( -- red ); color : #fff ; font-size : .6 rem ; font-weight : 800 ; display : grid ; place-items : center ; padding : 0 .2 rem ; border : 2 px solid var ( -- card );}
2026-06-23 16:15:29 +05:30
. 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 ;}
2026-06-30 17:01:15 +05:30
/* When bell+profile live in the left rail (desktop), open their menus to the RIGHT of the rail,
bottom-aligned, so they aren't clipped off-screen. */
# rail . bell-menu { top : auto ; bottom : 0 ; right : auto ; left : calc ( 100 % + 10 px );}
# rail . profile . pmenu { top : auto ; bottom : 0 ; right : auto ; left : calc ( 100 % + 10 px );}
2026-06-23 16:15:29 +05:30
. 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-30 17:01:15 +05:30
. profile . pbtn . icon-only { padding : 0 ; gap : 0 ; border-radius : 50 % ; background : transparent ; border : none ; position : relative ;}
. pav-dot { position : absolute ; right : -1 px ; bottom : -1 px ; width : 11 px ; height : 11 px ; border-radius : 50 % ; border : 2 px solid var ( -- card ); background : #cbd2dd ;}
. pav-dot . active { background : #16a34a ;} . pav-dot . away { background : #f59e0b ;} . pav-dot . onleave { background : #ef4444 ;} . pav-dot . incall { background : #2563eb ;} . pav-dot . offline { background : #cbd2dd ;}
. pmenu . ps-current { display : flex ; align-items : center ; gap : .45 rem ;}
. pmenu . ps-current . ps-arrow { margin-left : auto ; color : var ( -- muted ); display : inline-flex ;}
. pmenu . ps-options { background : #f8fafc ; border-top : 1 px solid #eef1f6 ; border-bottom : 1 px solid #eef1f6 ;}
. pmenu . ps-opt { display : flex ; align-items : center ; gap : .45 rem ; padding-left : 1.3 rem ;}
. pmenu . ps-opt . on { font-weight : 700 ;}
. pmenu . ps-opt . ps-check { margin-left : auto ; display : none ; color : #16a34a ; align-items : center ;}
. pmenu . ps-opt . on . ps-check { display : inline-flex ;}
. pmenu . ps-div { height : 1 px ; background : #eef1f6 ; margin : .3 rem 0 ;}
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 ;}
2026-06-30 17:01:15 +05:30
/* Bell + profile relocated to the bottom of the rail on desktop. */
# rail # hdrRight { display : flex ; flex-direction : column ; align-items : center ; gap : .55 rem ; margin-top : auto ; padding : .5 rem 0 .2 rem ;}
# rail # hdrRight . pav { width : 34 px ; height : 34 px ; font-size : .85 rem ;}
2026-06-12 00:40:07 +05:30
. 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 ;}
2026-07-02 16:17:30 +05:30
/* Pinned "You" (note-to-self) set apart from the conversation list (#3): tinted + a divider. */
. side-div { height : 1 px ; background : var ( -- line ); margin : .4 rem .7 rem .3 rem ;}
. chat-row . self { background : var ( -- blue - soft );}
. chat-row . self . chat-name :: after { content : "Note to self" ; font-size : .58 rem ; font-weight : 700 ; color : var ( -- blue ); background : #fff ; border : 1 px solid var ( -- line ); border-radius : 99 px ; padding : .03 rem .35 rem ; margin-left : .4 rem ; vertical-align : middle ; text-transform : none ;}
2026-06-23 16:15:29 +05:30
. 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-07-10 15:36:18 +05:30
. dir-empty { display : flex ; gap : .55 rem ; align-items : flex-start ; margin : .5 rem .9 rem ; padding : .6 rem .7 rem ; background : #f6f8fc ; border : 1 px solid var ( -- line ); border-radius : 10 px ; color : var ( -- muted );}
. dir-empty svg { flex : 0 0 auto ; margin-top : .05 rem ; color : var ( -- blue );}
. dir-empty b { display : block ; font-size : .82 rem ; color : var ( -- ink ); margin-bottom : .1 rem ;}
. dir-empty span { font-size : .74 rem ; line-height : 1.35 ;}
2026-06-30 17:01:15 +05:30
/* Chat-list header (now the app's top area — the blue bar is gone). */
. side-head { padding : .85 rem 1 rem .75 rem ; border-bottom : 1 px solid var ( -- line ); background : linear-gradient ( 180 deg , #f3f7fd 0 % , var ( -- card ) 100 % );}
. side-title { display : flex ; align-items : center ; justify-content : space-between ; gap : .5 rem ; margin-bottom : .7 rem ;}
. side-title h2 { font-size : 1.15 rem ; margin : 0 ; color : var ( -- blue ); font-weight : 800 ; letter-spacing : -.01 em ; flex : 1 ;}
. side-title # hdrRight { display : flex ; align-items : center ; gap : .45 rem ; flex : 0 0 auto ;}
. side-search-row { display : flex ; align-items : center ; gap : .5 rem ;}
. side-search-row . search { flex : 1 ; min-width : 0 ;}
. newchat { width : 38 px ; height : 38 px ; flex : 0 0 auto ; border-radius : 10 px ; border : none ; background : var ( -- blue ); color : #fff ; font-size : 1.35 rem ; line-height : 1 ; cursor : pointer ; font-weight : 700 ; display : grid ; place-items : center ; padding : 0 ; box-shadow : 0 2 px 8 px rgba ( 31 , 59 , 115 , .25 );}
. newchat : hover { filter : brightness ( 1.08 );}
2026-06-12 00:40:07 +05:30
. 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
2026-07-01 16:46:40 +05:30
/* Modern thin scrollbars for every scroll area (chat, modals, lists, …). Without this,
Electron on Windows falls back to the classic scrollbar with up/down stepper arrows. */
2026-07-10 13:56:18 +05:30
:: -webkit-scrollbar { width : 11 px ; height : 11 px ;}
2026-07-07 15:54:28 +05:30
:: -webkit-scrollbar-button , :: -webkit-scrollbar-button : start : decrement , :: -webkit-scrollbar-button : end : increment , :: -webkit-scrollbar-button : vertical : start , :: -webkit-scrollbar-button : vertical : end , :: -webkit-scrollbar-button : horizontal : start , :: -webkit-scrollbar-button : horizontal : end { display : none !important ; width : 0 !important ; height : 0 !important ;}
2026-07-01 16:46:40 +05:30
:: -webkit-scrollbar-track { background : transparent ;}
2026-07-10 13:56:18 +05:30
/* Was 6px with a 1px inset border (~4px grabbable) and no min height → the thumb was tiny and felt
"stuck" when dragged. Wider, solid, with a min length so it's easy to grab and drags smoothly. */
:: -webkit-scrollbar-thumb { background : #b9c4d4 ; border-radius : 8 px ; min-height : 44 px ;}
:: -webkit-scrollbar-thumb : hover { background : #9fadc2 ;}
2026-07-01 16:46:40 +05:30
:: -webkit-scrollbar-corner { background : transparent ;}
* { scrollbar-width : thin ; scrollbar-color : #c7d0dd transparent ;}
2026-06-30 17:01:15 +05:30
. chatlist { overflow-y : auto ; flex : 1 1 auto ; padding : .4 rem ; scrollbar-width : thin ; scrollbar-color : transparent transparent ;}
. chatlist : hover { scrollbar-color : #c7d0dd transparent ;}
2026-07-07 23:11:02 +05:30
. chatlist :: -webkit-scrollbar { width : 6 px ;}
. chatlist :: -webkit-scrollbar-button , . chatlist :: -webkit-scrollbar-button : start : decrement , . chatlist :: -webkit-scrollbar-button : end : increment { display : none !important ; height : 0 !important ; width : 0 !important ;}
2026-07-10 13:56:18 +05:30
. convo-msgs :: -webkit-scrollbar { width : 10 px ;} . convo-msgs :: -webkit-scrollbar-button { display : none !important ; height : 0 !important ; width : 0 !important ;}
. convo-msgs :: -webkit-scrollbar-thumb { min-height : 44 px ;}
2026-06-30 17:01:15 +05:30
. chatlist :: -webkit-scrollbar-thumb { background : transparent ; border-radius : 8 px ;}
. chatlist : hover :: -webkit-scrollbar-thumb { background : #c7d0dd ;}
/* Pull-to-refresh indicator (mobile) */
. ptr-ind { position : absolute ; top : 6 px ; left : 50 % ; width : 32 px ; height : 32 px ; margin-left : -16 px ; border-radius : 50 % ; background : var ( -- card ); box-shadow : 0 2 px 10 px rgba ( 20 , 30 , 60 , .18 ); display : grid ; place-items : center ; color : var ( -- blue ); z-index : 40 ; opacity : 0 ; transform : translateY ( -48 px ); transition : opacity .12 s ; pointer-events : none ;}
. ptr-ind . ptr-g { font-size : 1.15 rem ; font-weight : 700 ; line-height : 1 ; display : inline-block ;}
. ptr-ind . spin . ptr-g { animation : ptrspin .7 s linear infinite ;}
@ keyframes ptrspin { to { transform : rotate ( 360 deg );}}
/* Enable-notifications prompt (esp. iOS PWA) */
. notif-prompt { position : fixed ; left : 50 % ; transform : translateX ( -50 % ); bottom : calc ( 74 px + env ( safe - area - inset - bottom , 0 )); z-index : 2000 ; display : flex ; align-items : center ; gap : .7 rem ; width : max-content ; max-width : min ( 460 px , 94 vw ); background : var ( -- blue ); color : #fff ; padding : .6 rem .7 rem .6 rem 1 rem ; border-radius : 12 px ; box-shadow : 0 10 px 30 px rgba ( 20 , 30 , 60 , .32 ); font-size : .86 rem ;}
. notif-prompt . np-actions { display : flex ; align-items : center ; gap : .4 rem ; margin-left : auto ; flex : 0 0 auto ;}
. notif-prompt . btn . sm { background : #fff ; color : var ( -- blue );}
. notif-prompt . np-x { background : transparent ; border : none ; color : #fff ; opacity : .85 ; cursor : pointer ; display : grid ; place-items : center ; padding : .2 rem ;}
@ media ( min-width : 761px ) { . notif-prompt { bottom : 18 px ;} }
. perm-state { font-size : .68 rem ; font-weight : 700 ; padding : .06 rem .45 rem ; border-radius : 99 px ; vertical-align : middle ;}
. perm-state . granted { background : #dcfce7 ; color : #15803d ;}
. perm-state . denied { background : #fee2e2 ; color : #b91c1c ;}
. perm-state . default , . perm-state . unsupported { background : #f1f5f9 ; color : #475569 ;}
2026-06-12 00:40:07 +05:30
. 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-07-08 13:20:19 +05:30
. chat-row . active { background : var ( -- blue - soft ); box-shadow : inset 0 0 0 1.5 px var ( -- brand );} /* #6: thin yellow boundary around the selected chat */
2026-06-30 17:01:15 +05:30
. avatar { width : 42 px ; height : 42 px ; flex : 0 0 42 px ; border-radius : 50 % ; display : grid ; place-items : center ; color : #334155 ; 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 );}
2026-06-30 17:01:15 +05:30
/* presence/status dot colors (#7): in-call = solid red; on-leave = circle with a minus. */
. dot . active { background : #16a34a ;} . dot . away { background : #f59e0b ;} . dot . onleave { background : #dc2626 ;} . dot . incall { background : #dc2626 ;} . dot . offline { background : #cbd2dd ;}
. st-dot { position : relative ; display : inline-block ; width : 10 px ; height : 10 px ; border-radius : 50 % ; margin-right : .4 rem ; vertical-align : middle ; background : #cbd2dd ;}
. st-dot . active { background : #16a34a ;} . st-dot . away { background : #f59e0b ;} . st-dot . onleave { background : #dc2626 ;} . st-dot . incall { background : #dc2626 ;} . st-dot . offline { background : #cbd2dd ;}
/* the "minus in circle" for On leave (on every size of dot) */
. dot . onleave :: after , . st-dot . onleave :: after , . pav-dot . onleave :: after { content : "" ; position : absolute ; left : 50 % ; top : 50 % ; transform : translate ( -50 % , -50 % ); width : 6 px ; height : 1.6 px ; background : #fff ; border-radius : 1 px ;}
2026-06-12 00:40:07 +05:30
. 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 ;}
2026-07-02 11:59:53 +05:30
. prev-tick { display : inline-flex ; vertical-align : middle ; color : var ( -- muted );} . prev-tick . read { color : #2563eb ;} . prev-tick svg { width : 14 px ; height : 14 px ;}
. draft-lbl { color : #dc2626 ; font-weight : 600 ;}
2026-06-12 00:40:07 +05:30
. 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 % ;}
2026-06-30 17:01:15 +05:30
. 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 ); position : relative ;}
/* NB: #chatPanel is .panel which is already position:absolute (a containing block),
so the drop overlay positions against it. Do NOT add position:relative here — an ID
selector would override .panel{position:absolute;inset:0} and collapse the pane. */
# chatPanel . drag-over :: after { content : "Drop to send" ; position : absolute ; inset : 10 px ; border : 2.5 px dashed var ( -- blue ); border-radius : 14 px ; background : rgba ( 31 , 59 , 115 , .07 ); display : grid ; place-items : center ; color : var ( -- blue ); font-weight : 700 ; font-size : 1.15 rem ; z-index : 60 ; pointer-events : none ;}
2026-06-23 16:15:29 +05:30
. ic { display : inline-block ; vertical-align : middle ;}
2026-07-02 12:23:26 +05:30
img . emoji { height : 1.15 em ; width : 1.15 em ; margin : 0 .05 em ; vertical-align : -0.15 em ; display : inline-block ;}
. emoji-grid img . emoji , . emoji-tabs img . emoji { height : 1.4 em ; width : 1.4 em ;}
2026-06-23 16:15:29 +05:30
. 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 );}
2026-07-03 17:17:03 +05:30
/* Live "typing…" — brand blue + italic, in both the header subtitle and the sidebar preview. */
. convo-head . st . typing { color : var ( -- blue ); font-weight : 600 ; font-style : italic ;}
. typing-prev { color : var ( -- blue ); font-weight : 600 ; font-style : italic ;}
2026-06-30 17:01:15 +05:30
/* In-chat search: the header turns into a search bar with count + up/down jump arrows. */
. convo-search-head { position : absolute ; inset : 0 ; display : flex ; align-items : center ; gap : .4 rem ; padding : 0 .8 rem ; background : var ( -- card ); z-index : 6 ;}
. convo-search-head input { flex : 1 ; min-width : 0 ; border : none ; background : transparent ; font-size : .95 rem ; color : var ( -- ink );}
. convo-search-head input : focus { outline : none ;}
. csh-count { font-size : .8 rem ; color : var ( -- muted ); white-space : nowrap ; flex : 0 0 auto ; min-width : 2.5 rem ; text-align : right ;}
. csh-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 ; flex : 0 0 auto ;}
. csh-nav : hover { background : #dbe6fb ;}
mark . search-hit { background : #fde68a ; color : inherit ; border-radius : 2 px ; padding : 0 1 px ;}
mark . search-current { background : #f59e0b ; color : #1f2430 ;}
2026-07-07 15:21:14 +05:30
. bubble . search-flash { animation : searchFlash 1.4 s ease ;}
@ keyframes searchFlash { 0 %, 20 % { box-shadow : 0 0 0 3 px var ( -- brand );} 100 % { box-shadow : 0 0 0 0 transparent ;}}
2026-06-30 17:01:15 +05:30
. gi-media-row { display : flex ; align-items : center ; justify-content : space-between ; width : 100 % ; background : #f6f8fb ; border : 1 px solid var ( -- line ); border-radius : 10 px ; padding : .65 rem .8 rem ; cursor : pointer ; color : var ( -- ink ); font-size : .92 rem ; font-weight : 600 ;}
. gi-media-row : hover { background : #eef2f8 ;}
. gi-media-row . gmr-l { display : flex ; align-items : center ; gap : .6 rem ;}
. gi-media-row . gmr-ic { width : 32 px ; height : 32 px ; flex : 0 0 auto ; border-radius : 9 px ; background : linear-gradient ( 135 deg , #2563eb , #1F3B73 ); color : #fff ; display : grid ; place-items : center ; box-shadow : 0 2 px 6 px rgba ( 31 , 59 , 115 , .25 );}
. gi-media-row . gmr-ic svg { width : 17 px ; height : 17 px ;}
. gi-media-row . gmr-r { color : var ( -- muted ); font-size : .85 rem ; font-weight : 600 ;}
. gi-media-strip { display : flex ; gap : .3 rem ; margin : .4 rem 0 .2 rem ; overflow : hidden ;}
. gms-thumb { width : 60 px ; height : 60 px ; object-fit : cover ; border-radius : 8 px ; background : #f1f5f9 ; cursor : pointer ; flex : 0 0 auto ;}
2026-07-07 23:11:02 +05:30
/* Media / Docs / Links — clean underline tabs (active = brand-blue underline, no chip borders) */
2026-06-30 17:01:15 +05:30
. mv-tabs { display : flex ; gap : 0 ; border-bottom : 1 px solid var ( -- line ); margin : .5 rem 0 0 ;}
. mv-tabs . mp-tab { flex : 1 ; border : none ; background : transparent ; color : var ( -- muted ); font-size : .95 rem ; font-weight : 600 ; padding : .6 rem .3 rem .65 rem ; cursor : pointer ; border-bottom : 2 px solid transparent ; margin-bottom : -1 px ; text-align : center ;}
. mv-tabs . mp-tab : hover { color : var ( -- ink );}
2026-07-07 23:11:02 +05:30
. mv-tabs . mp-tab . on { color : var ( -- blue ); font-weight : 700 ; border-bottom-color : var ( -- blue );}
2026-06-30 17:01:15 +05:30
. mv-body { max-height : 58 vh ; overflow : auto ; margin-top : .5 rem ;}
/* Shared-media tiles: image thumbnail, or audio/video tile with download + duration */
. sh-cell { margin : 0 ; display : flex ; flex-direction : column ; gap : .28 rem ; min-width : 0 ;}
. sh-name { font-size : .7 rem ; font-weight : 700 ; color : var ( -- ink ); text-transform : uppercase ; letter-spacing : .02 em ; white-space : nowrap ; overflow : hidden ; text-overflow : ellipsis ;}
. sh-av { position : relative ; display : block ; width : 100 % ; aspect-ratio : 1 ; border-radius : 8 px ; overflow : hidden ; text-decoration : none ; background : #eef2f8 ; cursor : pointer ;}
. sh-av . aud { background : linear-gradient ( 135 deg , #f7b733 , #ee8c1a );}
. sh-av . vid { background : #0b1220 ;}
. sh-poster { position : absolute ; inset : 0 ; width : 100 % ; height : 100 % ; object-fit : cover ; pointer-events : none ;}
. sh-dl { position : absolute ; left : 50 % ; top : 46 % ; transform : translate ( -50 % , -50 % ); width : 46 px ; height : 46 px ; border-radius : 50 % ; background : rgba ( 20 , 30 , 60 , .28 ); display : grid ; place-items : center ; color : #fff ; transition : background .12 s ;}
. sh-av : hover . sh-dl { background : rgba ( 20 , 30 , 60 , .45 );}
. sh-dur { position : absolute ; left : 0 ; right : 0 ; bottom : 0 ; display : flex ; align-items : center ; gap : .28 rem ; padding : .28 rem .42 rem ; background : linear-gradient ( transparent , rgba ( 0 , 0 , 0 , .5 )); color : #fff ; font-size : .74 rem ; font-weight : 600 ;}
. sh-dur . sh-di { display : inline-flex ;} . sh-dur svg { width : 13 px ; height : 13 px ;}
. sh-dur i { font-style : normal ;}
. fav-on svg { fill : #f59e0b ; color : #f59e0b ;}
. sh-grid { display : grid ; grid-template-columns : repeat ( auto - fill , minmax ( 92 px , 1 fr )); gap : .4 rem ;}
. sh-thumb { width : 100 % ; aspect-ratio : 1 ; object-fit : cover ; border-radius : 8 px ; cursor : pointer ; background : #f1f5f9 ;}
. sh-file { display : flex ; align-items : center ; gap : .55 rem ; padding : .6 rem .55 rem ; border-bottom : 1 px solid var ( -- line ); color : var ( -- ink ); text-decoration : none ;}
. sh-file : hover { background : #f6f8fb ;}
. sh-file . sh-fn { flex : 1 ; min-width : 0 ; white-space : nowrap ; overflow : hidden ; text-overflow : ellipsis ; font-size : .9 rem ;}
. sh-file . sh-sz { font-size : .74 rem ; color : var ( -- muted ); flex : 0 0 auto ;}
. sh-file svg { color : var ( -- blue ); flex : 0 0 auto ;}
2026-06-12 00:40:07 +05:30
. 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 */
2026-07-08 13:20:19 +05:30
. convo-msgs { flex : 1 ; overflow-y : auto ; padding : 1 rem 1.2 rem ; display : flex ; flex-direction : column ; gap : .35 rem ; background : var ( -- bg );}
2026-06-23 16:15:29 +05:30
. 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 ;}
2026-06-30 17:01:15 +05:30
. new-sep { display : flex ; align-items : center ; gap : .6 rem ; margin : .6 rem .2 rem ; color : var ( -- red ); font-size : .72 rem ; font-weight : 700 ;}
. new-sep :: before , . new-sep :: after { content : "" ; flex : 1 ; height : 1 px ; background : #f0c2c2 ;}
2026-06-23 16:15:29 +05:30
. 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 ;}
2026-07-02 17:39:38 +05:30
. bubble . mine . rcpt { opacity : .85 ;}
/* Read receipt on my own (blue) bubble: brand yellow so the double tick actually stands out. */
. bubble . mine . rcpt . seen { color : var ( -- brand ); opacity : 1 ;}
2026-07-06 10:57:08 +05:30
. bubble . mine . rcpt . grp { cursor : pointer ;} /* tap a group tick to see who's read it */
2026-06-23 16:15:29 +05:30
. 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 );}
2026-07-07 23:11:02 +05:30
. lightbox { position : fixed ; inset : 0 ; z-index : 9900 ; background : rgba ( 8 , 12 , 22 , .88 ); display : flex ; align-items : center ; justify-content : center ;} /* above modals (9800) so the media→image preview opens on top */
2026-06-23 16:15:29 +05:30
. 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 );}
2026-07-07 23:11:02 +05:30
. lightbox img { max-width : 82 vw ;}
. lightbox . lb-nav { position : absolute ; top : 50 % ; transform : translateY ( -50 % ); border : none ; background : rgba ( 255 , 255 , 255 , .16 ); color : #fff ; width : 32 px ; height : 32 px ; border-radius : 50 % ; display : grid ; place-items : center ; cursor : pointer ;}
. lightbox . lb-nav : hover { background : rgba ( 255 , 255 , 255 , .34 );}
. lightbox . lb-prev { left : 14 px ;} . lightbox . lb-next { right : 14 px ;}
2026-06-23 16:15:29 +05:30
. 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 );}
2026-07-07 16:02:15 +05:30
. attach-preview { padding : .55 rem .6 rem .15 rem ; gap : .4 rem ; flex-wrap : wrap ; max-height : 118 px ; overflow-y : auto ;}
2026-06-23 16:15:29 +05:30
. 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 % ;}
2026-07-07 15:54:28 +05:30
. meet-grid { flex : 1 ; min-height : 0 ; display : grid ; gap : .6 rem ; padding : .8 rem ; grid-template-columns : repeat ( auto - fit , minmax ( 220 px , 1 fr )); align-content : center ; overflow : hidden ; background : #0b1220 ;} /* #16: center tiles + no stray scrollbar on 1:1/small calls */
2026-06-23 16:15:29 +05:30
. 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 ;}
2026-07-08 11:55:30 +05:30
. meet-tile . meet-sharing-self { position : absolute ; left : 0 ; right : 0 ; bottom : 0 ; display : flex ; align-items : center ; justify-content : center ; gap : .3 rem ; font-size : .72 rem ; color : #fff ; background : rgba ( 37 , 99 , 235 , .9 ); padding : .25 rem ;}
2026-07-07 23:18:14 +05:30
. meet-tile . waiting { border-color : #334155 ;}
. meet-tile . waiting . ringing { position : absolute ; left : 0 ; right : 0 ; bottom : 0 ; display : flex ; align-items : center ; justify-content : center ; gap : .3 rem ; font-size : .8 rem ; color : #cbd5e1 ; background : rgba ( 0 , 0 , 0 , .35 ); padding : .35 rem ; animation : ringPulse 1.4 s ease-in-out infinite ;}
@ keyframes ringPulse { 0 %, 100 % { opacity : .6 ;} 50 % { opacity : 1 ;}}
2026-06-23 16:15:29 +05:30
. 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 ;}
2026-06-30 17:01:15 +05:30
. 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 : #334155 ; font-weight : 700 ; font-size : 1.9 rem ; overflow : hidden ;}
. meet-tile . meet-av img { position : absolute ; inset : 0 ; width : 100 % ; height : 100 % ; object-fit : cover ;}
2026-06-23 16:15:29 +05:30
. 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 );}
2026-06-30 17:01:15 +05:30
. meet-panel . mp-scroll { flex : 1 ; overflow : auto ;}
2026-07-08 15:58:09 +05:30
. meet-panel . mp-invite-sticky { position : sticky ; bottom : .4 rem ; width : calc ( 100 % - 1 rem ); margin : .5 rem ; background : var ( -- blue ); color : #fff ; box-shadow : 0 -6 px 14 px rgba ( 255 , 255 , 255 , .06 );} /* #4: keep Invite reachable while the list scrolls */
2026-06-30 17:01:15 +05:30
. meet-panel . mp-list { padding : .4 rem ;}
2026-07-10 15:44:28 +05:30
. meet-panel . mp-invite-link { display : flex ; align-items : center ; gap : .55 rem ; margin : .5 rem ; padding : .6 rem .7 rem ; background : var ( -- blue - soft ); border : 1 px solid var ( -- line ); border-radius : 10 px ;}
. meet-panel . mp-invite-link svg { flex : 0 0 auto ; color : var ( -- blue );}
. meet-panel . mp-invite-link . mil-txt { flex : 1 ; min-width : 0 ;}
. meet-panel . mp-invite-link . mil-txt b { display : block ; font-size : .82 rem ; color : var ( -- ink );}
. meet-panel . mp-invite-link . mil-txt span { font-size : .7 rem ; color : var ( -- muted ); line-height : 1.3 ; display : block ;}
. meet-panel . mp-copylink { flex : 0 0 auto ; display : inline-flex ; align-items : center ; gap : .3 rem ; background : var ( -- blue ); color : #fff ; border : none ; border-radius : 8 px ; padding : .4 rem .7 rem ; font-size : .78 rem ; font-weight : 600 ; cursor : pointer ;}
. meet-panel . mp-copylink : hover { filter : brightness ( 1.05 );}
2026-06-23 16:15:29 +05:30
. 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 ;}
2026-06-30 17:01:15 +05:30
. meet-panel . mp-sec { font-size : .68 rem ; text-transform : uppercase ; letter-spacing : .05 em ; color : var ( -- muted ); font-weight : 700 ; margin : .7 rem .5 rem .15 rem ;}
. meet-panel . mp-row . waiting { opacity : .7 ;}
. meet-panel . mp-row . pp-wait { margin-left : auto ; font-size : .72 rem ; color : var ( -- muted ); display : inline-flex ; align-items : center ; gap : .25 rem ; white-space : nowrap ;}
2026-06-23 16:15:29 +05:30
. 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 );}
2026-07-08 14:24:17 +05:30
. meet-bar . meet-ic . on { background : var ( -- blue ); color : #fff ;} /* #9: screen-share highlighted while active (record keeps its red pulse via #meetRecBtn.on) */
2026-06-23 16:15:29 +05:30
. 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 ;}
2026-07-07 16:02:15 +05:30
. 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 ; cursor : pointer ;}
2026-07-08 15:58:09 +05:30
. upd-banner { position : fixed ; left : 50 % ; bottom : 16 px ; transform : translateX ( -50 % ) translateY ( 70 px ); z-index : 9500 ; display : none ; align-items : center ; gap : .5 rem ; background : var ( -- blue ); color : #fff ; border-radius : 999 px ; padding : .5 rem .5 rem .5 rem .9 rem ; box-shadow : 0 10 px 30 px rgba ( 20 , 30 , 60 , .32 ); font-size : .85 rem ; font-weight : 600 ; transition : transform .25 s ease ;}
. upd-banner . show { display : flex ; transform : translateX ( -50 % ) translateY ( 0 );}
. upd-banner button { border : none ; background : var ( -- brand ); color : var ( -- blue - d ); border-radius : 999 px ; padding : .35 rem .8 rem ; font-weight : 700 ; cursor : pointer ;}
2026-07-08 16:56:39 +05:30
. upd-dot { position : absolute ; top : -3 px ; right : -3 px ; width : 15 px ; height : 15 px ; border-radius : 50 % ; background : var ( -- brand ); color : var ( -- blue - d ); font-size : .66 rem ; font-weight : 800 ; font-style : italic ; display : grid ; place-items : center ; border : 2 px solid var ( -- blue ); pointer-events : none ;}
# setUpd . has-update { background : var ( -- brand ); color : var ( -- blue - d );}
. set-ver-i { display : inline - grid ; place-items : center ; width : 15 px ; height : 15 px ; border-radius : 50 % ; background : var ( -- brand ); color : var ( -- blue - d ); font-size : .66 rem ; font-weight : 800 ; font-style : italic ; margin-left : .35 rem ; vertical-align : middle ;}
2026-07-08 15:58:09 +05:30
. bubble . msg-link { color : inherit ; text-decoration : underline ; text-underline-offset : 2 px ; word-break : break-word ;}
. bubble . them . msg-link { color : var ( -- blue );} . bubble . mine . msg-link { color : #dbe9ff ;}
2026-07-07 23:11:02 +05:30
. bubble . fwd-label { display : flex ; align-items : center ; gap : .2 rem ; font-size : .72 rem ; font-style : italic ; opacity : .7 ; margin-bottom : .2 rem ;}
. bubble . fwd-label svg { transform : scaleX ( -1 ) rotate ( 0 deg );}
. bubble . mine . fwd-label { color : #e6edfb ;} . bubble . them . fwd-label { color : var ( -- muted );}
2026-07-02 18:01:32 +05:30
/* All message actions live in ONE hover pill anchored to the bubble's top-right corner. It
overlaps the bubble slightly so there's no dead gap — on a short message the icons can't float
off into empty space where the hover (and the buttons) would vanish before you can click. */
. msg-actions { position : absolute ; top : -14 px ; right : 6 px ; z-index : 6 ; display : flex ; gap : 1 px ; background : var ( -- card ); border : 1 px solid var ( -- line ); border-radius : 999 px ; padding : 2 px ; box-shadow : 0 2 px 8 px rgba ( 20 , 30 , 60 , .18 ); opacity : 0 ; pointer-events : none ; transition : opacity .12 s ;}
. bubble : hover . msg-actions , . bubble . show-actions . msg-actions { opacity : 1 ; pointer-events : auto ;}
. msg-actions button { position : static ; width : 26 px ; height : 26 px ; border : none ; background : none ; border-radius : 50 % ; display : grid ; place-items : center ; cursor : pointer ; color : var ( -- blue ); opacity : 1 ; pointer-events : auto ; box-shadow : none ; padding : 0 ; transition : background .12 s ;}
. msg-actions button : hover { background : var ( -- blue - soft );}
. msg-actions . del-btn { color : var ( -- red );} . msg-actions . del-btn : hover { background : #fee2e2 ;}
2026-07-07 16:09:02 +05:30
/* #1 Forward: message selection mode + target picker */
2026-07-07 23:11:02 +05:30
/* Selection mode: a checkbox appears on every message; selected ones highlight in green (distinct
from the blue message bubbles) with a filled tick. */
2026-07-07 16:09:02 +05:30
body . sel-mode . bubble { cursor : pointer ;} body . sel-mode . msg-actions { display : none !important ;}
2026-07-07 23:11:02 +05:30
body . sel-mode . bubble :: after { content : "" ; position : absolute ; top : -7 px ; width : 20 px ; height : 20 px ; border-radius : 50 % ; border : 2 px solid var ( -- muted ); background : var ( -- card ); z-index : 7 ;}
body . sel-mode . bubble . them :: after { left : -8 px ;} body . sel-mode . bubble . mine :: after { right : -8 px ;}
body . sel-mode . bubble . selected :: after { border-color : #16a34a ; background : #16a34a url ( "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E" ) center / 12 px no-repeat ;}
. bubble . selected { outline : 2 px solid #16a34a ; outline-offset : 2 px ;}
body . sel-mode . bubble . selected { background : #eafaf0 ;}
body . sel-mode . bubble . mine . selected { background : #16a34a ;}
2026-07-07 16:09:02 +05:30
. sel-bar { position : fixed ; left : 50 % ; bottom : 18 px ; transform : translateX ( -50 % ); z-index : 9000 ; display : flex ; align-items : center ; gap : .7 rem ; background : var ( -- card ); border : 1 px solid var ( -- line ); border-radius : 999 px ; padding : .4 rem .5 rem .4 rem .4 rem ; box-shadow : 0 10 px 30 px rgba ( 20 , 30 , 60 , .25 );}
. sel-bar . sb-x { border : none ; background : var ( -- blue - soft ); color : var ( -- blue ); width : 34 px ; height : 34 px ; border-radius : 50 % ; display : grid ; place-items : center ; cursor : pointer ;}
. sel-bar . sb-n { font-size : .9 rem ; font-weight : 600 ; color : var ( -- ink );}
. sel-bar . sb-fwd { border : none ; background : var ( -- blue ); color : #fff ; border-radius : 999 px ; padding : .5 rem .9 rem ; font-weight : 700 ; cursor : pointer ; display : inline-flex ; align-items : center ; gap : .3 rem ;}
. sel-bar . sb-fwd : hover { background : var ( -- blue - d );}
. fwd-list { max-height : 44 vh ; overflow-y : auto ; display : flex ; flex-direction : column ; gap : 2 px ;}
2026-07-07 23:11:02 +05:30
. fwd-item { display : flex ; align-items : center ; gap : .6 rem ; padding : .45 rem .5 rem ; border-radius : 10 px ; cursor : pointer ; border : 1 px solid transparent ;}
. fwd-item : hover { background : #f2f6fc ;}
. fwd-item . sel { background : var ( -- blue - soft ); border-color : var ( -- blue );}
2026-07-07 16:09:02 +05:30
. fwd-item . fi-name { flex : 1 ; min-width : 0 ; white-space : nowrap ; overflow : hidden ; text-overflow : ellipsis ; font-size : .92 rem ;}
2026-07-07 23:11:02 +05:30
. fwd-item . fi-check { width : 20 px ; height : 20 px ; flex : 0 0 auto ; border : 2 px solid var ( -- line ); border-radius : 6 px ; display : grid ; place-items : center ; color : transparent ;}
. fwd-item . sel . fi-check { background : var ( -- blue ); border-color : var ( -- blue ); color : #fff ;}
2026-07-07 16:09:02 +05:30
. fwd-item . mini-av { width : 34 px ; height : 34 px ; flex : 0 0 auto ; border-radius : 50 % ; display : grid ; place-items : center ; color : #fff ; font-weight : 700 ; font-size : .8 rem ; overflow : hidden ;}
. fwd-item . mini-av img { width : 100 % ; height : 100 % ; object-fit : cover ;}
2026-06-23 16:15:29 +05:30
. 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 );}
2026-07-02 18:01:32 +05:30
/* (message action button styling is under .msg-actions) */
2026-06-23 16:15:29 +05:30
. 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 );}
2026-07-02 17:45:20 +05:30
. bubble . t . edited { font-style : italic ; opacity : .8 ;}
2026-06-30 17:01:15 +05:30
. bubble . deleted { opacity : .85 ;}
. bubble . deleted . del-msg { font-style : italic ; color : var ( -- muted ); font-size : .9 rem ; display : inline-flex ; align-items : center ; gap : .3 rem ;}
. bubble . mine . deleted . del-msg { color : rgba ( 255 , 255 , 255 , .85 );}
2026-06-23 16:15:29 +05:30
. 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 ;}
2026-06-30 17:01:15 +05:30
. bubble . sender . snd-av { position : relative ; width : 20 px ; height : 20 px ; flex : 0 0 20 px ; border-radius : 50 % ; display : grid ; place-items : center ; color : #334155 ; font-weight : 800 ; font-size : .58 rem ; overflow : hidden ;}
2026-06-23 16:15:29 +05:30
. 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 ;}
2026-07-10 15:44:28 +05:30
/* Guest meeting mode: an external link-joiner sees ONLY the meeting — no rail, sidebar, or profile. */
body . guest-mode . rail , body . guest-mode # chatcol , body . guest-mode # hdrRight , body . guest-mode # navToggle , body . guest-mode . demo-note { display : none !important ;}
body . guest-mode . content { width : 100 % ; flex : 1 1 auto ;}
. guest-left { position : fixed ; inset : 0 ; background : #1F3B73 ; display : flex ; align-items : center ; justify-content : center ; z-index : 9900 ; padding : 1 rem ;}
. guest-left . gl-card { background : #fff ; border-radius : 16 px ; padding : 2 rem 1.6 rem ; text-align : center ; max-width : 360 px ; width : 100 % ; box-shadow : 0 18 px 44 px rgba ( 0 , 0 , 0 , .3 );}
. guest-left . gl-card h2 { margin : .2 rem 0 .3 rem ; font-size : 1.15 rem ; color : var ( -- ink );}
. guest-left . gl-card p { margin : 0 0 1.1 rem ; color : var ( -- muted ); font-size : .9 rem ;}
. guest-left . gl-card . bzc-ok { width : 100 % ; justify-content : center ;}
2026-06-23 16:15:29 +05:30
. 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 );}
2026-07-07 11:46:22 +05:30
/* Branded confirm dialog (replaces the OS/Electron window.confirm). */
. bz-confirm . bzc-msg { margin : .2 rem 0 1.1 rem ; color : var ( -- ink ); font-size : .92 rem ; line-height : 1.5 ;}
2026-07-10 13:56:18 +05:30
/* overflow:hidden on the CARD so it never becomes a 2nd scroll container — .modal defaults to
overflow-y:auto, which nested against .seen-scroll and made the wheel/drag feel "stuck". */
. seen-modal { max-width : 360 px ; display : flex ; flex-direction : column ; max-height : 80 vh ; overflow : hidden ;}
2026-07-10 13:25:37 +05:30
. seen-modal > h3 { flex : 0 0 auto ;}
2026-07-10 13:56:18 +05:30
/* THE single scroll region for the whole list; the two section headers stick as you scroll */
. seen-modal . seen-scroll { flex : 1 1 auto ; overflow-y : auto ; overscroll-behavior : contain ; margin : .2 rem -.4 rem 0 ; padding : 0 .4 rem ; min-height : 0 ;}
. seen-modal . seen-div { height : 1 px ; background : var ( -- line , #e3e8f2 ); margin : .5 rem .2 rem ;}
2026-07-10 13:25:37 +05:30
. seen-modal . seen-row { display : flex ; align-items : center ; gap : .6 rem ; padding : .34 rem .2 rem ;}
2026-07-10 12:51:14 +05:30
/* dark lettering on the pastel avatar (white was invisible on the light background), unless a real DP covers it */
2026-07-10 13:25:37 +05:30
. seen-modal . seen-row . mini-av { width : 34 px ; height : 34 px ; flex : 0 0 auto ; border-radius : 50 % ; display : grid ; place-items : center ; color : #334155 ; font-weight : 700 ; font-size : .76 rem ; overflow : hidden ; position : relative ;}
2026-07-10 12:51:14 +05:30
. seen-modal . seen-row . mini-av . av-img { position : absolute ; inset : 0 ; width : 100 % ; height : 100 % ; object-fit : cover ;}
. seen-modal . seen-row . sn { font-size : .92 rem ; color : var ( -- ink );}
2026-07-10 13:25:37 +05:30
. seen-modal . seen-sec { position : sticky ; top : 0 ; z-index : 1 ; background : var ( -- card , #fff ); display : flex ; align-items : center ; gap : .4 rem ; font-size : .72 rem ; font-weight : 700 ; letter-spacing : .02 em ; text-transform : uppercase ; color : var ( -- blue ); padding : .5 rem .2 rem .3 rem ;}
2026-07-10 12:51:14 +05:30
. seen-modal . seen-sec . dim { color : var ( -- muted );}
. seen-modal . seen-sec . dot { width : 7 px ; height : 7 px ; border-radius : 50 % ; background : #cbd5e1 ;}
. seen-modal . seen-sec . dot . ok { background : #22c55e ;}
2026-07-10 13:25:37 +05:30
. seen-modal . bzc-actions { flex : 0 0 auto ; margin-top : .7 rem ;}
2026-07-07 23:11:02 +05:30
. bzc-actions { display : flex ; justify-content : flex-end ; gap : .5 rem ;}
. bzc-actions button { border : none ; border-radius : 10 px ; padding : .55 rem 1 rem ; font-size : .9 rem ; font-weight : 600 ; cursor : pointer ; font-family : inherit ;}
. bzc-cancel { background : var ( -- blue - soft ); color : var ( -- blue );}
. bzc-cancel : hover { background : #dbe6fa ;}
. bzc-ok { background : var ( -- blue ); color : #fff ;}
. bzc-ok : hover { background : var ( -- blue - d );}
. bzc-ok : disabled { opacity : .5 ; cursor : not-allowed ;}
. bzc-ok . danger { background : var ( -- red );}
. bzc-ok . danger : hover { background : #991b1b ;}
2026-07-07 11:46:22 +05:30
/* "typing…" indicator inside the conversation, just above the composer. */
. typing-row { padding : .1 rem .95 rem .3 rem ; min-height : 0 ; color : var ( -- blue ); font-size : .78 rem ; font-style : italic ; font-weight : 600 ; display : none ; align-items : center ; gap : .4 rem ;}
. typing-row . show { display : flex ;}
. typing-row . tdots { display : inline-flex ; gap : 2 px ;}
. typing-row . tdots i { width : 5 px ; height : 5 px ; border-radius : 50 % ; background : var ( -- blue ); opacity : .5 ; animation : tdot 1 s infinite ;}
. typing-row . tdots i : nth-child ( 2 ) { animation-delay : .15 s ;} . typing-row . tdots i : nth-child ( 3 ) { animation-delay : .3 s ;}
@ keyframes tdot { 0 %, 60 %, 100 % { opacity : .3 ; transform : translateY ( 0 );} 30 % { opacity : 1 ; transform : translateY ( -2 px );}}
2026-06-23 16:15:29 +05:30
. 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 ;}
2026-06-30 17:01:15 +05:30
. convo-fav . on { color : #f59e0b ;}
. convo-fav . on svg { fill : #f59e0b ;}
. gi-title-row { display : flex ; align-items : center ; gap : .35 rem ;}
. fav-star { border : none ; background : transparent ; color : var ( -- muted ); cursor : pointer ; padding : .12 rem ; display : grid ; place-items : center ; border-radius : 6 px ; flex : 0 0 auto ;}
. fav-star : hover { background : #f1f5f9 ;}
. fav-star . on { color : #f59e0b ;} . fav-star . on svg { fill : #f59e0b ;}
. status-dot { display : inline-block ; width : 8 px ; height : 8 px ; border-radius : 50 % ; background : #cbd5e1 ; margin-right : .35 rem ; vertical-align : middle ;}
. status-dot . on { background : #16a34a ;}
2026-06-23 16:15:29 +05:30
. 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 ;}
2026-06-30 17:01:15 +05:30
. 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 : #334155 ; font-weight : 700 ; font-size : .72 rem ;}
2026-06-23 16:15:29 +05:30
. 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) ---- */
2026-07-02 15:43:02 +05:30
. authwrap { flex : 1 1 auto ; display : none ; align-items : center ; justify-content : center ; padding : 1.5 rem ; min-height : 0 ;
background : radial-gradient ( 1200 px 600 px at 50 % -10 % , #24437f 0 % , #1F3B73 42 % , #16294F 100 % );}
. authcard { background : var ( -- card ); border : 1 px solid var ( -- line ); border-radius : 18 px ; padding : 2 rem 2 rem 1.7 rem ; max-width : 400 px ; width : 100 % ; box-shadow : 0 24 px 60 px rgba ( 9 , 17 , 38 , .38 );}
. authcard . auth-brand { display : flex ; flex-direction : column ; align-items : center ; gap : .55 rem ; margin-bottom : 1 rem ;}
. authcard . auth-brand img { width : 66 px ; height : 66 px ; border-radius : 17 px ; box-shadow : 0 10 px 24 px rgba ( 31 , 59 , 115 , .30 );}
. authcard . auth-brand . wm { font-size : 1.15 rem ; font-weight : 800 ; color : var ( -- blue ); letter-spacing : .2 px ;}
. authcard . auth-brand . wm b { color : var ( -- brand - d ); font-weight : 800 ;}
. authcard h1 { font-size : 1.12 rem ; color : var ( -- blue ); margin : 0 0 .3 rem ; text-align : center ;}
2026-06-12 00:40:07 +05:30
. authcard . sub { color : var ( -- muted ); font-size : .9 rem ; text-align : center ; margin-bottom : 1.2 rem ;}
2026-07-02 15:43:02 +05:30
/* Blue card + gold CTA so the sign-in screen carries BOTH brand colours (never mono-blue). */
. authcard . gobtn { background : var ( -- brand ); color : var ( -- blue - d );}
2026-07-02 17:39:38 +05:30
. gobtn : disabled { opacity : .9 ; cursor : default ;}
. btn-spin { display : inline-block ; width : 15 px ; height : 15 px ; border : 2 px solid rgba ( 22 , 41 , 79 , .3 ); border-top-color : var ( -- blue - d ); border-radius : 50 % ; animation : btnspin .6 s linear infinite ; vertical-align : -3 px ; margin-right : 7 px ;}
@ keyframes btnspin { to { transform : rotate ( 360 deg );}}
2026-07-02 15:43:02 +05:30
. authcard . gobtn : hover { filter : brightness ( .95 );}
2026-06-12 00:40:07 +05:30
. 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 ---- */
2026-07-02 15:43:02 +05:30
. loading { position : fixed ; inset : 0 ; display : grid ; place-items : center ; background : #1F3B73 ; z-index : 9000 ; color : rgba ( 255 , 255 , 255 , .82 ); font-size : .9 rem ;}
2026-07-02 17:16:08 +05:30
. loading . ld-inner { display : flex ; flex-direction : column ; align-items : center ; gap : .85 rem ;}
2026-07-02 15:43:02 +05:30
. loading . ld-inner img { width : 72 px ; height : 72 px ;}
2026-07-02 17:16:08 +05:30
. loading . ld-wm { font-size : 1.35 rem ; font-weight : 800 ; color : #fff ; letter-spacing : .3 px ; margin-top : .15 rem ;}
. loading . ld-wm b { color : var ( -- brand ); font-weight : 800 ;}
. loading . ld-sub { color : rgba ( 255 , 255 , 255 , .66 ); font-size : .82 rem ;}
2026-07-02 15:43:02 +05:30
/* Chat thread loading (light pane) and call-connecting (dark stage) — branded, centered. */
. thread-loading { height : 100 % ; display : flex ; flex-direction : column ; align-items : center ; justify-content : center ; gap : .7 rem ; color : var ( -- muted ); font-size : .86 rem ;}
. call-connecting { position : absolute ; inset : 0 ; display : flex ; flex-direction : column ; align-items : center ; justify-content : center ; gap : 1 rem ; background : #16294F ; color : rgba ( 255 , 255 , 255 , .82 ); font-size : .95 rem ;}
. call-connecting . cc-txt { letter-spacing : .2 px ;}
/* in-app toasts now render via /bizconnect-toast.css (BZToast) */
2026-06-12 00:40:07 +05:30
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-30 17:01:15 +05:30
/* #7: drop the top blue bar entirely on mobile to use the full screen. bell+profile are
relocated into the chat-list header (see placeHdrRight). The shell is pushed below the
device status bar so no view overlaps it. */
header { display : none ;}
. shell { padding-top : env ( safe - area - inset - top , 0 );}
. side-title { gap : .5 rem ;}
. side-title h2 { flex : 1 ;}
. side-title # hdrRight { display : flex ; align-items : center ; gap : .4 rem ; flex : 0 0 auto ;}
2026-06-23 16:15:29 +05:30
. 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) */
2026-06-30 17:01:15 +05:30
. chatcol { width : 100 % ; flex : 1 1 100 % ; padding-bottom : calc ( 60 px + env ( safe - area - inset - bottom , 0 ));}
2026-06-23 16:15:29 +05:30
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 ;}
2026-06-30 17:01:15 +05:30
. content . panel { bottom : calc ( 60 px + env ( safe - area - inset - bottom , 0 ));} /* clear the bottom nav + home indicator */
2026-06-23 16:15:29 +05:30
/* 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 ;}
2026-06-30 17:01:15 +05:30
. bell-menu { position : fixed ; left : 8 px ; right : 8 px ; top : calc ( 56 px + env ( safe - area - inset - top , 0 )); width : auto ;}
# rail . bell-menu , # rail . profile . pmenu { left : auto ;} /* desktop rail rule must not leak to the mobile bottom-nav */
2026-06-23 16:15:29 +05:30
/* 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-07-07 23:11:02 +05:30
< script src = "/icons.js?v=5" ></ script >
2026-07-02 12:23:26 +05:30
< script src = "https://cdn.jsdelivr.net/npm/@twemoji/api@15.1.0/dist/twemoji.min.js" crossorigin = "anonymous" ></ script >
2026-07-10 15:44:28 +05:30
< script > window . __BUILD = '2026-07-10-batch68' ; console . log ( '%cBiz Connect' , 'color:#1F3B73;font-weight:bold' , 'build ' + window . __BUILD );
2026-07-02 12:23:26 +05:30
// Render modern (Twemoji) emojis in place of the OS's flat ones. No-op if the CDN didn't load
// (emojis stay as plain Unicode). (#5)
function twemojify ( el ){ try { if ( el && window . twemoji ) window . twemoji . parse ( el , { folder : 'svg' , ext : '.svg' }); } catch ( _ ){} }</ script >
2026-07-02 17:16:08 +05:30
< div class = "loading" id = "loading" >< div class = "ld-inner" >< img src = "/loaders/loader-orbit-dark.svg" alt = "" width = "72" height = "72" >< div class = "ld-wm" > Biz < b > Connect</ b ></ div >< span class = "ld-sub" > Loading…</ span ></ div ></ div >
2026-06-12 00:40:07 +05:30
< 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-07-02 17:39:38 +05:30
< img src = "/mark-light.png" alt = "" style = "height:46px;width:auto;max-width:190px;border-radius:8px;object-fit:contain;image-rendering:-webkit-optimize-contrast" onerror = "this.replaceWith(Object.assign(document.createElement('div'),{className:'logo',textContent:'B'}))" >
2026-06-30 17:01:15 +05:30
< div class = "brand" > Biz < span class = "y" > Connect</ span ></ div >
2026-06-12 00:40:07 +05:30
</ 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 >
</ div >
2026-06-30 17:01:15 +05:30
< div class = "side-search-row" >
< 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" >
< 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 >
</ div >
< button class = "newchat" id = "newChat" title = "New group / chat" aria-label = "New group" > +</ 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 >
< 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' ;}
2026-06-30 17:01:15 +05:30
// Soft pastel avatar backgrounds (with dark lettering) for a light, professional look.
const AV_COLORS = [ '#dbeafe' , '#e0e7ff' , '#dcfce7' , '#cffafe' , '#fae8ff' , '#fce7f3' , '#fee2e2' , '#fef3c7' , '#ecfccb' , '#fde4cf' ];
2026-06-12 00:40:07 +05:30
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 ];}
2026-07-10 13:56:18 +05:30
// Auto-correct: curated high-confidence common-typo → fix map. Applied as you type (on a word
// boundary), so corrections happen automatically — no right-click menu. Works on web/desktop/mobile.
// Kept conservative (only unambiguous typos) to avoid "fighting" the user. Ctrl+Z reverts a fix.
const AUTOCORRECT = {
teh : 'the' , hte : 'the' , thier : 'their' , recieve : 'receive' , recieved : 'received' , beleive : 'believe' , beleived : 'believed' ,
seperate : 'separate' , definately : 'definitely' , occured : 'occurred' , occuring : 'occurring' , untill : 'until' , wich : 'which' ,
becuase : 'because' , becasue : 'because' , accross : 'across' , adress : 'address' , adressed : 'addressed' , alot : 'a lot' ,
arguement : 'argument' , calender : 'calendar' , cemetary : 'cemetery' , changable : 'changeable' , collegue : 'colleague' ,
comming : 'coming' , commited : 'committed' , concious : 'conscious' , definetly : 'definitely' , dissapoint : 'disappoint' ,
embarass : 'embarrass' , enviroment : 'environment' , existance : 'existence' , familier : 'familiar' , finaly : 'finally' ,
foriegn : 'foreign' , fourty : 'forty' , freind : 'friend' , gaurd : 'guard' , goverment : 'government' , grammer : 'grammar' ,
happend : 'happened' , harrass : 'harass' , immediatly : 'immediately' , independant : 'independent' , infomation : 'information' ,
knowlege : 'knowledge' , liason : 'liaison' , libary : 'library' , lisence : 'license' , maintainance : 'maintenance' ,
mispell : 'misspell' , neccessary : 'necessary' , necesary : 'necessary' , noticable : 'noticeable' , occassion : 'occasion' ,
occurance : 'occurrence' , ocurrence : 'occurrence' , paralell : 'parallel' , parliment : 'parliament' , particurly : 'particularly' ,
perhasp : 'perhaps' , persistant : 'persistent' , posession : 'possession' , prefered : 'preferred' , privelege : 'privilege' ,
probaly : 'probably' , probelm : 'problem' , proffesional : 'professional' , promiss : 'promise' , pronounciation : 'pronunciation' ,
publically : 'publicly' , realy : 'really' , reccomend : 'recommend' , recomend : 'recommend' , refered : 'referred' ,
relevent : 'relevant' , religous : 'religious' , rember : 'remember' , remeber : 'remember' , resturant : 'restaurant' ,
rythm : 'rhythm' , secratary : 'secretary' , sieze : 'seize' , similiar : 'similar' , succesful : 'successful' , succesfully : 'successfully' ,
suprise : 'surprise' , suprised : 'surprised' , tommorow : 'tomorrow' , tommorrow : 'tomorrow' , tounge : 'tongue' , truely : 'truly' ,
unfortunatly : 'unfortunately' , usualy : 'usually' , vaccum : 'vacuum' , wierd : 'weird' , writen : 'written' , yeild : 'yield' ,
yuo : 'you' , youre : "you're" , cant : "can't" , dont : "don't" , doesnt : "doesn't" , didnt : "didn't" , wasnt : "wasn't" , werent : "weren't" ,
isnt : "isn't" , wouldnt : "wouldn't" , couldnt : "couldn't" , shouldnt : "shouldn't" , hasnt : "hasn't" , havent : "haven't" ,
ive : "I've" , thats : "that's" , whats : "what's" , lets : "let's" ,
aer : 'are' , adn : 'and' , nad : 'and' , wnat : 'want' , waht : 'what' , knwo : 'know' ,
woudl : 'would' , shoudl : 'should' , coudl : 'could' , funtion : 'function' , retrun : 'return' ,
};
// Apply an auto-correction if the caret just crossed a word boundary onto a known typo.
function maybeAutocorrect ( el ){
try {
if ( ! el || el . selectionStart == null || el . selectionStart !== el . selectionEnd ) return ;
const pos = el . selectionStart , val = el . value , ch = val [ pos - 1 ];
if ( ! ch || ! /[\s.,!?;:]/ . test ( ch )) return ; // only right after a boundary char
const before = val . slice ( 0 , pos - 1 ); const m = before . match ( /([A-Za-z']+)$/ ); if ( ! m ) return ;
const word = m [ 1 ], lower = word . toLowerCase (); const rep = AUTOCORRECT [ lower ];
if ( ! rep ) return ; // not a known typo (native squiggles still flag others)
const fixed = /^[A-Z]/ . test ( word ) ? ( rep . charAt ( 0 ). toUpperCase () + rep . slice ( 1 )) : rep ;
if ( fixed === word ) return ;
const start = pos - 1 - word . length ;
el . value = val . slice ( 0 , start ) + fixed + val . slice ( pos - 1 );
const np = start + fixed . length + 1 ; el . setSelectionRange ( np , np );
} catch ( _ ){}
}
2026-07-02 15:43:02 +05:30
// All in-app toasts route through the branded BZToast (bizconnect-toast.js). We keep the single
// toast(msg) entry point so every existing call site is unchanged, and classify by wording so
// errors/successes get the right colour+icon; everything else shows as a neutral message.
function toast ( msg ){
var s = String ( msg == null ? '' : msg );
if ( ! window . BZToast ) return ; // loaded synchronously in <head>
if ( /could ?n.t|cannot|can.t|failed|too large|invalid|error|denied|lost|blocked|not registered|required|need|no result|already/i . test ( s )) return BZToast . error ( s );
if ( /renamed|updated|enabled|added|saved|created|invited|removed|left the|muted|photo updated|now an admin|no longer|success/i . test ( s )) return BZToast . success ( s );
return BZToast . message ( s );
}
2026-06-12 00:40:07 +05:30
// ---------- 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()">' : '' ;
2026-06-30 17:01:15 +05:30
const cur = ( u && u . status ) || 'active' ;
const lblOf = ( st )=> st === 'away' ? 'Away' : st === 'onleave' ? 'On leave' : 'Available' ;
const opt = ( st )=> '<a class="ps-opt' + ( cur === st ? ' on' : '' ) + '" data-st="' + st + '"><span class="st-dot ' + st + '"></span> ' + lblOf ( st ) + '<span class="ps-check">' + ic ( 'check' , 14 ) + '</span></a>' ;
2026-06-23 16:15:29 +05:30
return '<div class="profile"><button class="pbtn icon-only" id="pbtn" title="' + pEsc ( display ) + '">'
2026-06-30 17:01:15 +05:30
+ '<span class="pav">' + pEsc ( initials ( display )) + img + '</span><span class="pav-dot ' + statusCls ({ online : true , status : cur }) + '"></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-30 17:01:15 +05:30
+ '<a class="ps-current" id="psStatusToggle"><span class="st-dot ' + cur + '"></span><span class="ps-cur-label">' + lblOf ( cur ) + '</span><span class="ps-arrow">' + ic ( 'chevronDown' , 14 ) + '</span></a>'
+ '<div class="ps-options" id="psOptions" style="display:none">' + opt ( 'active' ) + opt ( 'away' ) + opt ( 'onleave' ) + '</div>'
+ '<div class="ps-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 ;
2026-06-30 17:01:15 +05:30
btn . onclick = ( e )=>{ e . stopPropagation (); const bm = document . getElementById ( 'bellMenu' ); if ( bm ) bm . classList . remove ( 'open' ); menu . classList . toggle ( 'open' );};
2026-06-12 00:40:07 +05:30
document . addEventListener ( 'click' ,()=> menu . classList . remove ( 'open' ));
const lo = document . getElementById ( 'plogout' );
2026-06-30 17:01:15 +05:30
if ( lo ) lo . onclick = async ()=>{ await unsubscribePush (); 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 (); };
2026-06-30 17:01:15 +05:30
const stog = menu . querySelector ( '#psStatusToggle' ); if ( stog ) stog . onclick = ( e )=>{ e . stopPropagation (); const o = menu . querySelector ( '#psOptions' ); if ( o ) o . style . display = ( o . style . display === 'none' ? 'block' : 'none' ); };
menu . querySelectorAll ( '.ps-opt' ). forEach ( a => a . onclick = async ( e )=>{ e . stopPropagation (); const st = a . dataset . st ; ME . status = st ;
const cd = menu . querySelector ( '.ps-current .st-dot' ); if ( cd ) cd . className = 'st-dot ' + st ;
const cl = menu . querySelector ( '.ps-cur-label' ); if ( cl ) cl . textContent = ( st === 'away' ? 'Away' : st === 'onleave' ? 'On leave' : 'Available' );
menu . querySelectorAll ( '.ps-opt' ). forEach ( o =>{ o . classList . toggle ( 'on' , o === a ); }); const opts = menu . querySelector ( '#psOptions' ); if ( opts ) opts . style . display = 'none' ;
const pd = document . querySelector ( '.profile .pav-dot' ); if ( pd ) pd . className = 'pav-dot ' + st ;
try { await postJSON ( '/api/me/status' ,{ status : st }); } catch ( _ ){}
});
2026-06-23 16:15:29 +05:30
}
// ---------- 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 ;
2026-06-30 17:01:15 +05:30
b . onclick = ( e )=>{ e . stopPropagation (); const pm = document . getElementById ( 'pmenu' ); if ( pm ) pm . classList . remove ( 'open' ); const open = menu . classList . toggle ( 'open' ); if ( open ){ renderBell (); NOTIFS . forEach ( x => x . read = true ); saveNotifs (); updateBellBadge (); } };
2026-06-23 16:15:29 +05:30
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>' ;
2026-06-30 17:01:15 +05:30
const perm = ( 'Notification' in window ) ? Notification . permission : 'unsupported' ;
const granted = perm === 'granted' ;
const permLabel = granted ? 'Allowed' : ( perm === 'denied' ? 'Denied' : ( perm === 'default' ? 'Not set' : 'Unsupported' ));
2026-06-23 16:15:29 +05:30
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' ))
2026-06-30 17:01:15 +05:30
+ '<label class="gi-setting"><span>Notifications <span class="perm-state ' + perm + '">' + permLabel + '</span><div style="font-size:.72rem;color:var(--muted);font-weight:400;margin-top:.15rem">Pop up messages & calls even when this tab isn’ t open</div></span>' + ( granted ? '' : '<button class="btn sm" id="setPerm">' + ( perm === 'denied' ? 'Allow' : 'Enable' ) + '</button>' ) + '</label>'
2026-07-08 16:56:39 +05:30
+ ( window . bizConnectNative ? ( '<label class="gi-setting"><span>Biz Connect for Desktop<div style="font-size:.72rem;color:var(--muted);font-weight:400;margin-top:.15rem" id="setVer">Version ' + pEsc ( window . bizConnectNative . version || '—' ) + ( _pendingUpdate ? ( '<span class="set-ver-i" title="Update available">i</span> ' + ( _pendingUpdate . phase === 'ready' ? 'Update ready' : 'Update ' + pEsc ( _pendingUpdate . version || '' ) + ' available' )) : '' ) + '</div></span><button class="btn sm' + ( _pendingUpdate ? ' has-update' : '' ) + '" id="setUpd">' + ( _pendingUpdate ? ( _pendingUpdate . phase === 'ready' ? 'Restart now' : 'Downloading…' ) : 'Check for updates' ) + '</button></label>' ) : '' )
2026-06-23 16:15:29 +05:30
+ '<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 ();
2026-07-07 16:12:21 +05:30
const updBtn = ov . querySelector ( '#setUpd' ); // #12: desktop version + manual update check
if ( updBtn ) updBtn . onclick = async ()=>{
2026-07-08 16:56:39 +05:30
if ( _pendingUpdate && _pendingUpdate . phase === 'ready' ){ try { window . bizConnectNative . restartToUpdate && window . bizConnectNative . restartToUpdate (); } catch ( _ ){} return ; } // update already downloaded → restart
if ( _pendingUpdate ){ toast ( 'Update ' + ( _pendingUpdate . version || '' ) + ' is downloading…' ); return ; }
2026-07-07 16:12:21 +05:30
const ver = ov . querySelector ( '#setVer' ); updBtn . disabled = true ; updBtn . textContent = 'Checking…' ;
let done = false ;
try {
const r = ( window . bizConnectNative && window . bizConnectNative . checkForUpdates ) ? await window . bizConnectNative . checkForUpdates () : { status : 'unsupported' };
if ( r . status === 'available' ){ updBtn . textContent = 'Update ' + ( r . version || '' ) + ' found' ; if ( ver ) ver . textContent = 'Downloading ' + ( r . version || '' ) + '… you’ ll be asked to restart when ready.' ; done = true ; }
else if ( r . status === 'current' ){ updBtn . textContent = 'Up to date ✓' ; }
else if ( r . status === 'dev' ){ updBtn . textContent = 'Dev build' ; }
else if ( r . status === 'unsupported' ){ updBtn . textContent = 'Update on newer app' ; }
else { updBtn . textContent = 'Check failed' ; }
} catch ( _ ){ updBtn . textContent = 'Check failed' ; }
setTimeout (()=>{ if ( ! done ){ updBtn . disabled = false ; updBtn . textContent = 'Check for updates' ; } }, 3500 );
};
2026-06-23 16:15:29 +05:30
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-30 17:01:15 +05:30
const permBtn = ov . querySelector ( '#setPerm' ); if ( permBtn ) permBtn . onclick = async ()=>{ try { const r = await Notification . requestPermission (); if ( r === 'granted' ){ try { await subscribePush (); } catch ( _ ){} } } catch ( _ ){} ov . remove (); openSettings (); }; // just trigger the browser prompt + refresh the state (no toast)
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)
2026-06-30 17:01:15 +05:30
// Pull-to-refresh (mobile): drag down at the top of a scroll area to refresh. Touch-only, so it's
// a no-op on desktop. onRefresh is an async function.
function enablePullRefresh ( el , onRefresh ){
if ( ! el || el . __ptr ) return ; el . __ptr = true ;
if ( getComputedStyle ( el ). position === 'static' ) el . style . position = 'relative' ;
const ind = document . createElement ( 'div' ); ind . className = 'ptr-ind' ; ind . innerHTML = '<span class="ptr-g">↻</span>' ; el . appendChild ( ind );
let startY = 0 , pulling = false , dist = 0 , busy = false ; const TRIGGER = 70 ;
el . addEventListener ( 'touchstart' ,( e )=>{ if ( busy || el . scrollTop > 0 || e . touches . length !== 1 ) return ; startY = e . touches [ 0 ]. clientY ; pulling = true ; dist = 0 ; },{ passive : true });
el . addEventListener ( 'touchmove' ,( e )=>{ if ( ! pulling || busy ) return ; dist = e . touches [ 0 ]. clientY - startY ; if ( dist <= 0 || el . scrollTop > 0 ){ pulling = false ; ind . style . opacity = '0' ; ind . style . transform = 'translateY(-48px)' ; return ; } const d = Math . min ( dist * 0.5 , 84 ); ind . style . opacity = String ( Math . min ( d / 56 , 1 )); ind . style . transform = 'translateY(' + ( d - 48 ) + 'px)' ; if ( dist > 10 ) e . preventDefault (); },{ passive : false });
el . addEventListener ( 'touchend' , async ()=>{ if ( ! pulling ) return ; pulling = false ; if ( dist >= TRIGGER ){ busy = true ; ind . classList . add ( 'spin' ); ind . style . opacity = '1' ; ind . style . transform = 'translateY(14px)' ; try { await onRefresh (); } catch ( _ ){} await new Promise ( r => setTimeout ( r , 250 )); ind . classList . remove ( 'spin' ); busy = false ; } ind . style . opacity = '0' ; ind . style . transform = 'translateY(-48px)' ; dist = 0 ; });
}
async function reloadThread (){ if ( ! selected ) return ; const kind = selected . kind , id = selected . id ; const url = kind === 'group' ? ( '/api/messages/thread?group=' + encodeURIComponent ( id )) : ( '/api/messages/thread?with=' + encodeURIComponent ( id )); try { const msgs = await fetch ( url ). then ( r => r . json ()); if ( selected && selected . kind === kind && selected . id === id && Array . isArray ( msgs )){ THREAD = msgs ; THREAD_CACHE . set ( kind + ':' + id , THREAD . slice ()); renderThread (); } } catch ( _ ){} }
2026-07-07 15:21:14 +05:30
// ---- Older-history pagination: the thread loads the newest ~500; scrolling to the top fetches the
// previous page (messages older than the oldest one loaded) and prepends them, preserving scroll. ----
const PAGE = 500 ;
2026-07-07 23:11:02 +05:30
let _hasMoreOlder = false , _loadingOlder = false , _olderCd = 0 ;
2026-07-07 15:21:14 +05:30
function threadUrl ( kind , id , before ){ return ( kind === 'group' ? '/api/messages/thread?group=' + encodeURIComponent ( id ) : '/api/messages/thread?with=' + encodeURIComponent ( id )) + ( before ? ( '&before=' + before ) : '' ); }
async function loadOlder (){
if ( _loadingOlder || ! _hasMoreOlder || ! selected || ! THREAD . length ) return ;
_loadingOlder = true ;
const kind = selected . kind , id = selected . id , before = THREAD [ 0 ]. created_at ;
const box = document . getElementById ( 'msgs' ); const prevH = box ? box . scrollHeight : 0 , prevTop = box ? box . scrollTop : 0 ;
let older = null ; try { const r = await fetch ( threadUrl ( kind , id , before )); if ( r . ok ) older = await r . json (); } catch ( _ ){}
if ( ! selected || selected . kind !== kind || selected . id !== id ){ _loadingOlder = false ; return ; }
if ( Array . isArray ( older ) && older . length ){
const have = new Set ( THREAD . map ( m => m . id )); const add = older . filter ( m => ! have . has ( m . id ));
if ( add . length ){ THREAD = add . concat ( THREAD ); THREAD_CACHE . set ( kind + ':' + id , THREAD . slice ()); }
if ( older . length < PAGE ) _hasMoreOlder = false ;
renderThread ( true ); // keep scroll (don't jump to bottom)
if ( box ) box . scrollTop = box . scrollHeight - prevH + prevTop ; // anchor on the same message
} else { _hasMoreOlder = false ; }
2026-07-07 23:11:02 +05:30
_loadingOlder = false ; _olderCd = Date . now () + 500 ; // brief cooldown so it can't re-fire and fight the scroll
2026-07-07 15:21:14 +05:30
}
// Keep loading older pages until a target timestamp is in the loaded window (used by search jump-to).
async function ensureLoadedBack ( at ){ let guard = 0 ; while ( _hasMoreOlder && THREAD . length && THREAD [ 0 ]. created_at > at && guard < 60 ){ await loadOlder (); guard ++ ; } }
2026-06-30 17:01:15 +05:30
// #9: in-chat search — highlight every match and jump between them with up/down (no filtering).
2026-07-07 15:21:14 +05:30
let _searchHits = [], _searchIdx =- 1 , _searchQ = '' , _searchDebT = null ; // _searchHits: server hits [{id,at}] oldest-first
2026-06-30 17:01:15 +05:30
function clearSearchHighlights (){
const box = document . getElementById ( 'msgs' ); if ( box ){ box . querySelectorAll ( 'mark.search-hit' ). forEach ( m =>{ const t = document . createTextNode ( m . textContent ); m . replaceWith ( t ); }); box . querySelectorAll ( '.bubble' ). forEach ( b =>{ try { b . normalize (); } catch ( _ ){} }); }
}
function _highlightIn ( root , ql ){
const hits = [];
const walker = document . createTreeWalker ( root , NodeFilter . SHOW_TEXT , { acceptNode ( n ){ if ( ! n . nodeValue || n . nodeValue . toLowerCase (). indexOf ( ql ) ===- 1 ) return NodeFilter . FILTER_REJECT ; const p = n . parentElement ; if ( ! p || p . closest ( '.reply-btn,.react-btn,.del-btn,.t,.reacts,.react-chip,.seenby,.rcpt,.att-sz,button' )) return NodeFilter . FILTER_REJECT ; return NodeFilter . FILTER_ACCEPT ; } });
const nodes = []; while ( walker . nextNode ()) nodes . push ( walker . currentNode );
nodes . forEach ( n =>{ const txt = n . nodeValue , low = txt . toLowerCase (); let i , last = 0 ; const frag = document . createDocumentFragment ();
while (( i = low . indexOf ( ql , last )) !==- 1 ){ if ( i > last ) frag . appendChild ( document . createTextNode ( txt . slice ( last , i ))); const mk = document . createElement ( 'mark' ); mk . className = 'search-hit' ; mk . textContent = txt . slice ( i , i + ql . length ); frag . appendChild ( mk ); hits . push ( mk ); last = i + ql . length ; }
if ( last > 0 ){ if ( last < txt . length ) frag . appendChild ( document . createTextNode ( txt . slice ( last ))); n . parentNode . replaceChild ( frag , n ); } });
return hits ;
}
2026-07-07 15:21:14 +05:30
// Full-thread search: ask the server for ALL matches (not just the loaded window), debounced. Then
// jump to any hit — paging older history in if the match is above what's currently loaded.
function runSearch ( q ){ clearTimeout ( _searchDebT ); _searchDebT = setTimeout (()=> doSearch ( q ), 220 ); }
async function doSearch ( q ){
2026-06-30 17:01:15 +05:30
clearSearchHighlights ();
2026-07-07 15:21:14 +05:30
const cnt = document . getElementById ( 'convoSearchCount' );
q = ( q || '' ). trim (); _searchQ = q ; _searchHits = []; _searchIdx =- 1 ;
if ( ! q ){ if ( cnt ) cnt . textContent = '' ; return ; }
if ( ! selected ) return ;
const kind = selected . kind , id = selected . id ;
const url = ( kind === 'group' ? '/api/messages/search?group=' + encodeURIComponent ( id ) : '/api/messages/search?with=' + encodeURIComponent ( id )) + '&q=' + encodeURIComponent ( q );
let hits = []; try { const r = await fetch ( url ); if ( r . ok ){ const j = await r . json (); hits = j . hits || []; } } catch ( _ ){}
if ( ! selected || selected . kind !== kind || selected . id !== id || _searchQ !== q ) return ; // stale (switched chat / kept typing)
_searchHits = hits ;
if ( ! hits . length ){ if ( cnt ) cnt . textContent = '0/0' ; return ; }
gotoHit ( hits . length - 1 ); // jump to the most recent match first
2026-06-30 17:01:15 +05:30
}
2026-07-07 15:21:14 +05:30
function _bubbleEl ( id ){ const box = document . getElementById ( 'msgs' ); if ( ! box ) return null ; const esc = ( window . CSS && CSS . escape ) ? CSS . escape ( id ) : id ; return box . querySelector ( '.bubble[data-id="' + esc + '"]' ); }
2026-07-07 16:02:15 +05:30
// #8: jump to a specific message (e.g. tapping a reply quote) — paging older history in if it's
// above the loaded window — then scroll to it and flash it.
async function jumpToMessage ( id , at ){
if ( ! id ) return ;
let el = _bubbleEl ( id );
if ( ! el && at ){ await ensureLoadedBack ( at ); el = _bubbleEl ( id ); }
if ( ! el ) return ;
try { el . scrollIntoView ({ block : 'center' }); } catch ( _ ){}
el . classList . add ( 'search-flash' ); setTimeout (()=>{ try { el . classList . remove ( 'search-flash' ); } catch ( _ ){} }, 1400 );
}
2026-07-07 16:09:02 +05:30
// ---- #1 Forward: multi-select messages → forward to existing conversations only ----
let _selMode = false ; const _selIds = new Set ();
function enterSelect ( id ){
_selMode = true ; _selIds . clear (); if ( id ) _selIds . add ( id );
document . body . classList . add ( 'sel-mode' );
const box = document . getElementById ( 'msgs' ); if ( box ) box . querySelectorAll ( '.bubble' ). forEach ( b => b . classList . toggle ( 'selected' , _selIds . has ( b . dataset . id )));
renderSelBar ();
}
function toggleSel ( bubble ){
const id = bubble && bubble . dataset . id ; if ( ! id ) return ;
if ( _selIds . has ( id )){ _selIds . delete ( id ); bubble . classList . remove ( 'selected' ); } else { _selIds . add ( id ); bubble . classList . add ( 'selected' ); }
if ( ! _selIds . size ){ exitSelect (); return ; } renderSelBar ();
}
function exitSelect (){
_selMode = false ; _selIds . clear (); document . body . classList . remove ( 'sel-mode' );
const box = document . getElementById ( 'msgs' ); if ( box ) box . querySelectorAll ( '.bubble.selected' ). forEach ( b => b . classList . remove ( 'selected' ));
const bar = document . getElementById ( 'selBar' ); if ( bar ) bar . remove ();
}
function renderSelBar (){
let bar = document . getElementById ( 'selBar' );
if ( ! bar ){ bar = document . createElement ( 'div' ); bar . id = 'selBar' ; bar . className = 'sel-bar' ; document . body . appendChild ( bar ); }
bar . innerHTML = '<button class="sb-x" title="Cancel">' + ic ( 'x' , 18 ) + '</button><span class="sb-n">' + _selIds . size + ' selected</span><button class="sb-fwd">' + ic ( 'arrowRight' , 16 ) + ' Forward</button>' ;
bar . querySelector ( '.sb-x' ). onclick = exitSelect ;
bar . querySelector ( '.sb-fwd' ). onclick = ()=>{ if ( _selIds . size ) openForwardPicker ([... _selIds ]); };
}
function fwdRowHTML ( r ){
const isG = r . kind === 'group' ;
const av = '<span class="mini-av" style="background:' + avColor ( r . name ) + '">' + ( isG ? ic ( 'users' , 15 ) : ( r . avatar ? '<img src="' + pEsc ( r . avatar ) + '" onerror="this.remove()">' : pEsc ( initials ( r . name )))) + '</span>' ;
return '<div class="fwd-item" data-k="' + r . kind + '" data-id="' + pEsc ( r . id ) + '" data-name="' + pEsc (( r . name || '' ). toLowerCase ()) + '">' + av + '<span class="fi-name">' + pEsc ( r . name ) + '</span><span class="fi-check">' + ic ( 'check' , 15 ) + '</span></div>' ;
}
function openForwardPicker ( ids ){
if ( document . getElementById ( 'fwdModal' )) return ;
const rows = ROWS . filter ( r => ! r . self ); // existing conversations only (per spec)
const ov = document . createElement ( 'div' ); ov . className = 'modal-ov' ; ov . id = 'fwdModal' ;
ov . innerHTML = '<div class="modal"><h3>Forward to…</h3>'
+ '<input id="fwdSearch" placeholder="Search chats" autocomplete="off" style="width:100%;padding:.55rem .7rem;border:2px solid var(--line);border-radius:10px;background:#fbfcfe;margin-bottom:.6rem;">'
+ '<div class="fwd-list" id="fwdList">' + ( rows . length ? rows . map ( fwdRowHTML ). join ( '' ) : '<div class="gi-noresult">No conversations yet</div>' ) + '</div>'
+ '<div class="bzc-actions" style="margin-top:.8rem"><button type="button" class="bzc-cancel" id="fwdCancel">Cancel</button><button type="button" class="bzc-ok" id="fwdGo" disabled>Forward (0)</button></div></div>' ;
document . body . appendChild ( ov );
const sel = new Set (); const list = ov . querySelector ( '#fwdList' ), go = ov . querySelector ( '#fwdGo' );
const upd = ()=>{ go . disabled =! sel . size ; go . textContent = 'Forward (' + sel . size + ')' ; };
list . addEventListener ( 'click' , e =>{ const it = e . target . closest ( '.fwd-item' ); if ( ! it ) return ; const key = it . dataset . k + ':' + it . dataset . id ; if ( sel . has ( key )){ sel . delete ( key ); it . classList . remove ( 'sel' ); } else { sel . add ( key ); it . classList . add ( 'sel' ); } upd (); });
ov . querySelector ( '#fwdSearch' ). oninput = e =>{ const q = e . target . value . trim (). toLowerCase (); list . querySelectorAll ( '.fwd-item' ). forEach ( it =>{ it . style . display = ( ! q || ( it . dataset . name || '' ). includes ( q )) ? '' : 'none' ; }); };
ov . querySelector ( '#fwdCancel' ). onclick = ()=> ov . remove ();
ov . addEventListener ( 'mousedown' , e =>{ if ( e . target === ov ) ov . remove (); });
go . onclick = async ()=>{
const targets = [... sel ]. map ( k =>{ const i = k . indexOf ( ':' ); return { kind : k . slice ( 0 , i ), id : k . slice ( i + 1 ) }; });
go . disabled = true ; go . textContent = 'Forwarding…' ;
try { await postJSON ( '/api/messages/forward' ,{ messageIds : ids , targets }); ov . remove (); exitSelect (); toast ( 'Forwarded to ' + targets . length + ( targets . length === 1 ? ' chat' : ' chats' )); }
catch ( e ){ toast ( e . message || 'Could not forward' ); go . disabled = false ; upd (); }
};
}
2026-07-07 15:21:14 +05:30
async function gotoHit ( i ){
const hits = _searchHits ; if ( ! hits . length ) return ;
_searchIdx = (( i % hits . length ) + hits . length ) % hits . length ;
const hit = hits [ _searchIdx ];
const cnt = document . getElementById ( 'convoSearchCount' ); if ( cnt ) cnt . textContent = ( _searchIdx + 1 ) + '/' + hits . length ;
let el = _bubbleEl ( hit . id );
if ( ! el ){ await ensureLoadedBack ( hit . at ); if ( _searchIdx !== (( i % hits . length ) + hits . length ) % hits . length ) return ; el = _bubbleEl ( hit . id ); } // older than the loaded window → page back to it
clearSearchHighlights ();
if ( ! el ) return ;
_highlightIn ( el , _searchQ . toLowerCase ());
const mk = el . querySelector ( 'mark.search-hit' );
if ( mk ){ mk . classList . add ( 'search-current' ); try { mk . scrollIntoView ({ block : 'center' }); } catch ( _ ){} } else { try { el . scrollIntoView ({ block : 'center' }); } catch ( _ ){} }
el . classList . add ( 'search-flash' ); setTimeout (()=>{ try { el . classList . remove ( 'search-flash' ); } catch ( _ ){} }, 1400 );
2026-06-30 17:01:15 +05:30
}
function closeSearch (){ clearSearchHighlights (); const h = document . getElementById ( 'convoSearchHead' ); if ( h ) h . style . display = 'none' ; const i = document . getElementById ( 'convoSearchInput' ); if ( i ) i . value = '' ; }
// #10: "Shared" — Media (images) + Files tabs for a DM or group, opened by tapping the chat name.
// Compact "Media, links & docs" entry (shown in the conversation-info window). Clicking it opens
// the separate full Media/Docs/Links view (openMediaView).
function mediaRowHTML (){ return '<button class="gi-media-row" id="giMediaRow"><span class="gmr-l">Media, links & docs</span><span class="gmr-r" id="giMediaCount">› </span></button><div class="gi-media-strip" id="giMediaStrip"></div>' ; }
async function wireMediaEntry ( root , kind , id , name ){
let data = { media : [], docs : [], links : []}; try { const url = kind === 'group' ? ( '/api/messages/media?group=' + encodeURIComponent ( id )) : ( '/api/messages/media?with=' + encodeURIComponent ( id )); const r = await fetch ( url ). then ( x => x . json ()); if ( r && r . media ) data = r ; } catch ( _ ){}
const total = data . media . length + data . docs . length + data . links . length ;
const cnt = root . querySelector ( '#giMediaCount' ); if ( cnt ) cnt . textContent = ( total || 0 ) + ' › ' ;
const strip = root . querySelector ( '#giMediaStrip' ); if ( strip ){ strip . innerHTML = data . media . filter ( x => x . isImage ). slice ( 0 , 4 ). map ( x => '<img class="gms-thumb att-img" src="/files/' + pEsc ( x . id ) + '" data-img="/files/' + pEsc ( x . id ) + '" loading="lazy">' ). join ( '' ); strip . addEventListener ( 'click' , e =>{ const im = e . target . closest ( '.att-img' ); if ( im && im . dataset . img ) openLightbox ( im . dataset . img ); }); }
const row = root . querySelector ( '#giMediaRow' ); if ( row ) row . onclick = ()=> openMediaView ( kind , id , name , data );
}
function stripExt ( n ){ n = String ( n || '' ); const i = n . lastIndexOf ( '.' ); return ( i > 0 ? n . slice ( 0 , i ) : n ) || 'File' ; }
function fmtDur ( s ){ s = Math . round ( s ); if ( ! isFinite ( s ) || s < 0 ) return '' ; const h = Math . floor ( s / 3600 ), m = Math . floor (( s % 3600 ) / 60 ), ss = s % 60 , p = ( n )=> String ( n ). padStart ( 2 , '0' ); return h > 0 ? ( h + ':' + p ( m ) + ':' + p ( ss )) : ( m + ':' + p ( ss )); }
// One cell in the shared-Media grid: image → thumbnail; audio/video → a tile with a centred
// download button plus a headphone/▶ glyph and (lazily-loaded) duration, matching the design.
function mediaCellHTML ( x ){
const id = pEsc ( x . id ), nm = pEsc ( x . name || '' );
if ( x . isImage ) return '<figure class="sh-cell"><a class="sh-av img att-img" data-img="/files/' + id + '" href="/files/' + id + '"><img class="sh-poster" src="/files/' + id + '" loading="lazy" alt=""></a></figure>' ;
const isVid =!! x . isVideo , kind = isVid ? 'vid' : 'aud' ;
return '<figure class="sh-cell">'
+ '<figcaption class="sh-name" title="' + nm + '">' + pEsc ( stripExt ( x . name )) + '</figcaption>'
+ '<a class="sh-av ' + kind + '" href="/files/' + id + '" download="' + nm + '" title="Download ' + nm + '">'
+ ( isVid ? '<video class="sh-poster" src="/files/' + id + '#t=0.1" preload="metadata" muted playsinline></video>' : '' )
+ '<span class="sh-dl">' + ic ( 'download' , 20 ) + '</span>'
+ '<span class="sh-dur"><span class="sh-di">' + ic ( isVid ? 'play' : 'headphones' , 12 ) + '</span><i data-dsrc="/files/' + id + '" data-dkind="' + ( isVid ? 'video' : 'audio' ) + '">…</i></span>'
+ '</a></figure>' ;
}
// Server doesn't store media duration, so read each audio/video tile's metadata client-side.
function loadMediaDurations ( root ){ if ( ! root ) return ; root . querySelectorAll ( 'i[data-dsrc]' ). forEach ( el =>{ if ( el . dataset . done ) return ; el . dataset . done = '1' ; try { const m = document . createElement ( el . dataset . dkind === 'video' ? 'video' : 'audio' ); m . preload = 'metadata' ; m . muted = true ; m . src = el . dataset . dsrc ; m . addEventListener ( 'loadedmetadata' ,()=>{ el . textContent = fmtDur ( m . duration ) || '' ; }); m . addEventListener ( 'error' ,()=>{ el . textContent = '' ; }); } catch ( _ ){ el . textContent = '' ; } }); }
// Separate full view: Media / Docs / Links tabs.
async function openMediaView ( kind , id , name , data ){
if ( document . getElementById ( 'mediaView' )) return ;
if ( ! data ){ try { const url = kind === 'group' ? ( '/api/messages/media?group=' + encodeURIComponent ( id )) : ( '/api/messages/media?with=' + encodeURIComponent ( id )); data = await fetch ( url ). then ( x => x . json ()); } catch ( _ ){} }
data = data && data . media ? data : { media : [], docs : [], links : []};
const ov = document . createElement ( 'div' ); ov . className = 'modal-ov' ; ov . id = 'mediaView' ;
ov . innerHTML = '<div class="modal gi"><div class="gi-head" style="margin-bottom:.4rem"><button class="iconbtn" id="mvBack" title="Back">' + ic ( 'arrowLeft' , 18 ) + '</button><div class="gi-name"><div class="gi-title">Media, links & docs</div><div class="gi-sub">' + pEsc ( name || '' ) + '</div></div></div>'
+ '<div class="mp-tabs mv-tabs"><button class="mp-tab on" data-t="media">Media</button><button class="mp-tab" data-t="docs">Docs</button><button class="mp-tab" data-t="links">Links</button></div>'
+ '<div class="mv-body" id="mvBody"></div></div>' ;
document . body . appendChild ( ov );
ov . onclick = e =>{ if ( e . target === ov ) ov . remove (); };
ov . querySelector ( '#mvBack' ). onclick = ()=> ov . remove ();
let tab = 'media' ;
const render = ()=>{ const b = ov . querySelector ( '#mvBody' ); if ( ! b ) return ;
if ( tab === 'media' ) b . innerHTML = data . media . length ? '<div class="sh-grid">' + data . media . map ( mediaCellHTML ). join ( '' ) + '</div>' : '<div class="gi-noresult">No media shared yet</div>' ;
else if ( tab === 'docs' ) b . innerHTML = data . docs . length ? data . docs . map ( x => '<a class="sh-file" href="/files/' + pEsc ( x . id ) + '" download="' + pEsc ( x . name || 'file' ) + '">' + ic ( 'fileText' , 16 ) + '<span class="sh-fn">' + pEsc ( x . name || 'file' ) + '</span><span class="sh-sz">' + fmtSize ( x . size ) + '</span></a>' ). join ( '' ) : '<div class="gi-noresult">No documents shared yet</div>' ;
else b . innerHTML = data . links . length ? data . links . map ( x => '<a class="sh-file" href="' + pEsc ( x . url ) + '" target="_blank" rel="noopener">' + ic ( 'link' , 16 ) + '<span class="sh-fn">' + pEsc ( x . url ) + '</span></a>' ). join ( '' ) : '<div class="gi-noresult">No links shared yet</div>' ;
loadMediaDurations ( b );
};
ov . querySelectorAll ( '.mv-tabs .mp-tab' ). forEach ( btn => btn . onclick = ()=>{ tab = btn . dataset . t ; ov . querySelectorAll ( '.mv-tabs .mp-tab' ). forEach ( x => x . classList . toggle ( 'on' , x === btn )); render (); });
ov . querySelector ( '#mvBody' ). addEventListener ( 'click' , e =>{ const im = e . target . closest ( '.att-img' ); if ( im && im . dataset . img ){ e . preventDefault (); openLightbox ( im . dataset . img ); } });
render ();
}
// DM contact-info window (no members): header + favourite + the Media/links/docs entry.
async function openSharedItems ( kind , id , name ){
if ( document . getElementById ( 'sharedModal' )) return ;
const r0 = rowFor ( kind , id ); const fav =!! ( r0 && r0 . favorite ); const online =!! ( r0 && r0 . online );
const ov = document . createElement ( 'div' ); ov . className = 'modal-ov' ; ov . id = 'sharedModal' ;
2026-07-08 16:56:39 +05:30
ov . innerHTML = '<div class="modal gi"><div class="gi-head" style="margin-bottom:.6rem"><button class="gi-photo" id="shPhoto" title="View photo"><span class="avatar" style="width:46px;height:46px;flex:0 0 46px;background:' + avColor ( name ) + '">' + pEsc ( initials ( name || '?' )) + (( r0 && r0 . avatar ) ? '<img class="av-img" src="' + pEsc ( r0 . avatar ) + '" alt="" onerror="this.remove()">' : '' ) + '</span></button><div class="gi-name"><div class="gi-title-row"><span class="gi-title">' + pEsc ( name || '' ) + '</span><button class="fav-star' + ( fav ? ' on' : '' ) + '" id="shFav" title="' + ( fav ? 'Remove from favourites' : 'Add to favourites' ) + '">' + ic ( 'star' , 16 ) + '</button></div><div class="gi-sub"><span class="st-dot ' + statusCls ( r0 ) + '"></span>' + statusLabel ( r0 ) + '</div></div><button class="iconbtn" id="shClose">' + ic ( 'x' , 18 ) + '</button></div>'
2026-06-30 17:01:15 +05:30
+ mediaRowHTML () + '</div>' ;
document . body . appendChild ( ov );
ov . onclick = e =>{ if ( e . target === ov ) ov . remove (); };
ov . querySelector ( '#shClose' ). onclick = ()=> ov . remove ();
2026-07-08 16:56:39 +05:30
const sp = ov . querySelector ( '#shPhoto' ); if ( sp ) sp . onclick = ()=>{ const r = rowFor ( kind , id ) || r0 ; if ( r && r . avatar ) openLightbox ( r . avatar ); else toast ( 'No profile photo' ); }; // #5: click the DP in the info view to preview it
2026-06-30 17:01:15 +05:30
const fb = ov . querySelector ( '#shFav' ); if ( fb ) fb . onclick = async ()=>{ const r = rowFor ( kind , id ); const on =! ( r && r . favorite ); if ( r ) r . favorite = on ; fb . classList . toggle ( 'on' , on ); fb . title = on ? 'Remove from favourites' : 'Add to favourites' ; try { await postJSON ( '/api/favorites' ,{ kind , id , on }); } catch ( _ ){} renderChats ( searchVal ()); };
wireMediaEntry ( ov , kind , id , name );
}
2026-06-23 16:15:29 +05:30
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' });
}
2026-06-30 17:01:15 +05:30
// Presence/status helpers (#7): 'incall' (auto) overrides; offline if not connected.
function statusCls ( it ){ const s = it && it . status ; if ( s === 'incall' ) return 'incall' ; if ( ! it ||! it . online ) return 'offline' ; if ( s === 'away' ) return 'away' ; if ( s === 'onleave' ) return 'onleave' ; return 'active' ; }
function statusLabel ( it ){ const c = statusCls ( it ); return c === 'incall' ? 'In a call' : c === 'away' ? 'Away' : c === 'onleave' ? 'On leave' : c === 'active' ? 'Available' : 'Offline' ; }
2026-06-23 16:15:29 +05:30
function avatarHTML ( it , big ){
const isG = it . kind === 'group' ;
const sz = big ? 'width:38px;height:38px;flex:0 0 38px;' : '' ;
2026-07-02 12:05:32 +05:30
const corner = it . self ? ''
: isG ? ( it . members ? '<span class="mcount">' + ( it . members > 99 ? '99+' : it . members ) + '</span>' : '' )
2026-06-30 17:01:15 +05:30
: '<span class="dot ' + statusCls ( it ) + '"></span>' ;
2026-06-23 16:15:29 +05:30
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>' ;
}
2026-07-03 17:17:03 +05:30
// The sidebar row's preview line (draft / ongoing call / last message w/ tick). Factored out so a
// live typing indicator can temporarily replace it and then restore the real preview on stop.
function rowPreviewHTML ( it ){
2026-06-23 16:15:29 +05:30
const active = selected && selected . kind === it . kind && selected . id === it . id ;
const isG = it . kind === 'group' ;
2026-07-02 11:59:53 +05:30
const draft = getDraft ( it . kind , it . id );
2026-07-03 17:17:03 +05:30
if ( draft && ! active ){ return '<span class="draft-lbl">Draft:</span> ' + pEsc ( draft ); } // unsent draft (#1)
if ( it . callActive ){ return '<span class="call-on">' + ic ( 'phone' , 12 ) + ' Ongoing call</span>' ; }
if ( it . last_from_me && it . last_status && it . last_body ){ // my last message → tick (#3)
return '<span class="prev-tick ' + it . last_status + '">' + ic ( it . last_status === 'sent' ? 'check' : 'checkCheck' , 13 ) + '</span> ' + pEsc ( it . last_body ); }
return pEsc ( it . last_body ? (( it . last_from_me ? 'You: ' : '' ) + it . last_body ) : ( isG ? (( it . members || 0 ) + ' members' ) : 'No messages yet' ));
}
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' ); if ( it . self ) cls . push ( 'self' );
const tl = typingLabel ( it . kind , it . id );
const inner = tl ? ( '<span class="typing-prev">' + pEsc ( tl ) + '</span>' ) : rowPreviewHTML ( it ); // live "typing…" wins over the preview
2026-06-23 16:15:29 +05:30
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>'
2026-07-02 11:59:53 +05:30
+ '<div class="chat-bottom"><span class="chat-prev">' + inner + '</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 ));
2026-06-30 17:01:15 +05:30
let html ;
2026-07-02 12:05:32 +05:30
if ( ! q ){ // "You" pinned at top, then favourites, then the rest
const self = rows . find ( r => r . self ), others = rows . filter ( r => ! r . self );
const favs = others . filter ( r => r . favorite ), rest = others . filter ( r => ! r . favorite );
2026-07-02 16:17:30 +05:30
// "You" (note to self) sits in its own pinned slot, set apart from the conversation list.
let h = self ? ( rowHTML ( self ) + ( others . length ? '<div class="side-div"></div>' : '' )) : '' ;
2026-07-02 12:05:32 +05:30
if ( favs . length ){ h += '<div class="side-sec">' + ic ( 'star' , 12 ) + ' Favourites</div>' + favs . map ( rowHTML ). join ( '' ); if ( rest . length ) h += '<div class="side-sec">All chats</div>' ; }
h += rest . map ( rowHTML ). join ( '' );
html = ( self || others . length ) ? h : '<div class="no-results">No conversations yet.</div>' ;
2026-06-30 17:01:15 +05:30
} else {
html = rows . length ? rows . map ( rowHTML ). join ( '' ) : '<div class="no-results">No chats match “' + pEsc ( filter ) + '”.</div>' ;
}
2026-06-23 16:15:29 +05:30
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 ));
2026-07-10 15:36:18 +05:30
const all = Array . isArray ( list ) ? list : [];
// Only people actually ON Connect can be messaged — show just those (deduped against team contacts).
// Everyone else (on BizGaze but not signed into Connect) is NOT listed as addable; instead we show a
// clear "not on Connect" / "not found" state so the user isn't confused by un-chattable rows.
const onc = all . filter ( p => p . onConnect && p . connectId && ! ( p . email && haveEmail . has ( p . email . toLowerCase ())));
const localHit = box . parentElement && box . parentElement . querySelector ( '.chat-row, .contact-row' );
if ( onc . length ){
box . innerHTML = '<div class="side-sec">On Connect</div>' + onc . map (( p , i )=>{ const sub = [ p . org , p . phone ]. filter ( Boolean ). join ( ' · ' );
return '<div class="dir-row" 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></div>' ; }). join ( '' );
box . querySelectorAll ( '.dir-row' ). forEach ( row =>{ const p = onc [ + row . dataset . i ]; row . onclick = ()=> selectChat ( 'dm' , p . connectId ); });
} else if ( ! localHit ){
// Nothing on Connect matched. Distinguish "exists on BizGaze but not on Connect" from "not found".
box . innerHTML = all . length
? '<div class="dir-empty">' + ic ( 'info' , 16 ) + '<div><b>Not on Connect</b><span>Matching people are on BizGaze but haven’ t joined Connect yet, so they can’ t be messaged.</span></div></div>'
: '<div class="dir-empty">' + ic ( 'search' , 16 ) + '<div><b>Contact not found</b><span>No Connect user matches “' + pEsc ( q ) + '”.</span></div></div>' ;
} else box . innerHTML = '' ;
2026-06-23 16:15:29 +05:30
}, 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' ;
2026-07-01 16:28:15 +05:30
setDesktopBadge ( chats ); // mirror the count onto the desktop taskbar icon
}
// Desktop taskbar icon badge: draw a small red count badge and hand it to the Electron shell
// (which sets it as a taskbar overlay). No-op in a browser or on mobile.
function setDesktopBadge ( count ){
try {
if ( ! ( window . bizConnectNative && window . bizConnectNative . setUnread )) return ;
let dataUrl = null ;
if ( count > 0 ){
2026-07-10 13:25:37 +05:30
// Teams-style overlay: a solid red rounded-square (squircle) with a bold white count and a thin
// white outline so it separates cleanly from the app icon on the taskbar. Drawn at 2× for crispness.
2026-07-10 12:51:14 +05:30
const s = 64 , c = document . createElement ( 'canvas' ); c . width = s ; c . height = s ; const g = c . getContext ( '2d' );
const label = count > 99 ? '99+' : String ( count );
2026-07-10 13:25:37 +05:30
const pad = 3 , rad = 17 , x = pad , y = pad , w = s - pad * 2 , h = s - pad * 2 ;
const rrect = ( rx , ry , rw , rh , r )=>{ g . beginPath (); g . moveTo ( rx + r , ry ); g . arcTo ( rx + rw , ry , rx + rw , ry + rh , r ); g . arcTo ( rx + rw , ry + rh , rx , ry + rh , r ); g . arcTo ( rx , ry + rh , rx , ry , r ); g . arcTo ( rx , ry , rx + rw , ry , r ); g . closePath (); };
g . save (); g . shadowColor = 'rgba(0,0,0,.28)' ; g . shadowBlur = 4 ; g . shadowOffsetY = 1.5 ;
g . fillStyle = '#e6394f' ; rrect ( x , y , w , h , rad ); g . fill (); g . restore (); // solid notification red
g . lineWidth = 3 ; g . strokeStyle = '#fff' ; rrect ( x , y , w , h , rad ); g . stroke (); // crisp white outline
2026-07-01 16:28:15 +05:30
g . fillStyle = '#fff' ; g . textAlign = 'center' ; g . textBaseline = 'middle' ;
2026-07-10 13:25:37 +05:30
g . font = '800 ' + ( label . length > 2 ? 26 : ( label . length > 1 ? 33 : 39 )) + 'px system-ui,Segoe UI,sans-serif' ;
g . fillText ( label , s / 2 , s / 2 + 2 );
2026-07-01 16:28:15 +05:30
dataUrl = c . toDataURL ( 'image/png' );
}
window . bizConnectNative . setUnread ( count , dataUrl );
} catch ( _ ){}
2026-06-23 16:15:29 +05:30
}
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 ( _ ){}
2026-07-02 12:05:32 +05:30
CONTACTS = ( Array . isArray ( contacts ) ? contacts : []). filter ( c => c . id !== ( ME && ME . id )); // self isn't a normal contact (#4)
2026-06-23 16:15:29 +05:30
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 ]; });
2026-07-02 12:05:32 +05:30
// Always-available "You" chat (note to self), pinned to the top (#4).
const selfRow = items . find ( i => i . kind === 'dm' && i . id === ( ME && ME . id ));
if ( selfRow ){ selfRow . self = true ; selfRow . name = 'You' ; selfRow . avatar = ME . avatarUrl || selfRow . avatar || null ; }
else if ( ME && ME . id ){ items . push ({ kind : 'dm' , id : ME . id , name : 'You' , self : true , avatar : ME . avatarUrl || null , online : false , last_body : '' , last_at : 0 , last_from_me : false , unread : 0 }); }
2026-06-23 16:15:29 +05:30
ROWS = items ;
renderChats ( searchVal ());
updateRailUnread ();
2026-07-08 18:00:56 +05:30
prewarmNotifAvatars ();
}
// Desktop only: hand the shell every contact/group DP URL so it caches them up front — the first
// notification (chat OR call) from anyone then shows their photo instantly, with no per-toast wait.
let _avatarsWarmed = false ;
function prewarmNotifAvatars (){
try {
const n = window . bizConnectNative ; if ( ! n ||! n . precacheAvatars || _avatarsWarmed ) return ;
const urls = [... new Set (( ROWS || []). map ( r => r . avatar ). filter ( u => u && /^https?:\/\//i . test ( u )))];
if ( urls . length ){ _avatarsWarmed = true ; n . precacheAvatars ( urls ); }
} catch ( _ ){}
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>'
2026-06-30 17:01:15 +05:30
+ '<h1>Hi, ' + pEsc ( firstName ( ME . name || ME . email )) + '! Welcome to Biz Connect</h1>'
2026-06-12 00:40:07 +05:30
+ '<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 ); }); }
2026-07-02 15:43:02 +05:30
// A DM's subtitle tracks the LIVE call state (pushed on call start AND end) so it can't get stuck
// on "In a call" after the call ends — unlike it.status, which is only derived at conversation load.
function dmSubLabel ( it ){ return ( it && it . callActive ) ? 'In a call' : statusLabel ( it ); }
2026-07-03 17:17:03 +05:30
// The conversation header's default subtitle (used to restore it after a typing indicator clears).
function subtitleFor ( it ){ if ( ! it ) return '' ; return it . self ? 'Message yourself' : ( it . kind === 'group' ? (( it . members || 0 ) + ' members' ) : dmSubLabel ( it )); }
2026-06-23 16:15:29 +05:30
function convoShellHTML ( it ){
const isG = it . kind === 'group' ;
2026-07-03 17:17:03 +05:30
const sub = subtitleFor ( it );
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 )
2026-06-30 17:01:15 +05:30
+ '<div class="convo-titlewrap gi-open" id="convoTitle" title="Shared media & files"><div class="nm">' + pEsc ( it . name ) + '</div><div class="st">' + pEsc ( sub ) + '</div></div>'
+ '<button class="convo-info" id="convoSearch" title="Search messages">' + ic ( 'search' , 18 ) + '</button>'
2026-06-23 16:15:29 +05:30
+ '<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-30 17:01:15 +05:30
+ '<div class="convo-search-head" id="convoSearchHead" style="display:none">'
+ '<button class="convo-back" id="convoSearchClose" title="Close search">' + ic ( 'arrowLeft' , 18 ) + '</button>'
+ '<input id="convoSearchInput" placeholder="Search this chat…" autocomplete="off">'
+ '<span class="csh-count" id="convoSearchCount"></span>'
+ '<button class="csh-nav" id="convoSearchPrev" title="Older match">' + ic ( 'chevronUp' , 18 ) + '</button>'
+ '<button class="csh-nav" id="convoSearchNext" title="Newer match">' + ic ( 'chevronDown' , 18 ) + '</button>'
+ '</div>'
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>'
2026-07-07 11:46:22 +05:30
+ '<div class="typing-row" id="typingRow"></div>'
2026-06-23 16:15:29 +05:30
+ '<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>'
2026-07-10 13:25:37 +05:30
+ '<textarea id="msgInput" placeholder="Type a message…" autocomplete="off" spellcheck="true" autocapitalize="sentences" maxlength="4000" rows="1"></textarea>'
2026-06-23 16:15:29 +05:30
+ ( 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>'
2026-07-07 16:02:15 +05:30
+ '<input type="file" id="fileInput" multiple style="display:none">'
2026-06-23 16:15:29 +05:30
+ '</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>' ;
2026-07-08 14:24:17 +05:30
// Unanswered 1:1: the caller (message sender) sees "Call not answered"; the callee sees "Missed call".
if ( m . evt === 'call-end' && /Missed call|not answered/i . test ( m . body || '' )) return '<div class="sys-msg">📞 ' + ( m . from === ME . id ? 'Call not answered' : 'Missed call' ) + '</div>' ;
2026-06-23 16:15:29 +05:30
if ( m . system || m . from === '__system__' ) return '<div class="sys-msg">' + pEsc ( m . body ) + '</div>' ;
const mine = m . from === ME . id ;
2026-06-30 17:01:15 +05:30
if ( m . deleted ) return '<div class="bubble ' + ( mine ? 'mine' : 'them' ) + ' deleted" data-id="' + pEsc ( m . id ) + '"><span class="del-msg">' + ic ( 'trash' , 12 ) + ' This message was deleted</span><span class="t">' + pEsc ( fmtClock ( m . created_at )) + '</span></div>' ;
2026-06-23 16:15:29 +05:30
const sender = ( convoIsGroup && ! mine && m . fromName ) ? '<div class="sender">' + senderAvatar ( m . from , m . fromName ) + '<span>' + pEsc ( m . fromName ) + '</span></div>' : '' ;
let quote = '' ;
2026-07-07 16:02:15 +05:30
if ( m . reply ){ const t = replyTint ( m . reply . from || m . reply . fromName ); quote = '<div class="quote" data-jid="' + pEsc ( m . reply . id || '' ) + '" data-jat="' + ( m . reply . at || 0 ) + '" title="Go to message" style="background:' + t [ 0 ] + ';border-left-color:' + t [ 1 ] + '"><b style="color:' + t [ 1 ] + '">' + pEsc ( m . reply . fromName || '' ) + '</b>: ' + pEsc ( m . reply . body ) + '</div>' ; }
2026-06-23 16:15:29 +05:30
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' ));
2026-07-06 10:57:08 +05:30
// DM ticks: sent (1 grey) → delivered (2 grey) → read (2 yellow).
2026-06-23 16:15:29 +05:30
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>' ; }
2026-07-06 10:57:08 +05:30
// Group ticks: a per-message receipt on MY messages — sent (1 grey) → seen-by-some (2 grey) →
// seen-by-all (2 yellow). Tap to see exactly who ("Seen by X, Y · N of M"). seenBy = other
// members who've read this message (computed server-side, kept live by onGroupRead).
else if ( mine && convoIsGroup ){
const others = Math . max ( 0 ,( convoMembers ? convoMembers . length : 0 ) - 1 );
const ns = Array . isArray ( m . seenBy ) ? m . seenBy : []; const rc = ns . length ;
const all = others > 0 && rc >= others ;
const ttl = rc ? ( 'Seen by ' + ns . slice ( 0 , 6 ). join ( ', ' ) + ( ns . length > 6 ? ( ' +' + ( ns . length - 6 )) : '' ) + ( others > 0 ? ( ' · ' + rc + ' of ' + others ) : '' )) : 'Sent' ;
rcpt = '<span class="rcpt grp ' + ( all ? 'seen' : '' ) + '" data-seen="' + pEsc ( ns . join ( '|' )) + '" title="' + pEsc ( ttl ) + '">' + ic ( rc > 0 ? 'checkCheck' : 'check' , 13 ) + '</span>' ;
}
2026-07-08 15:58:09 +05:30
// #2: under the group's LAST message, show "Seen by X, Y +N more" — visible to everyone, click for names.
2026-06-23 16:15:29 +05:30
let seen = '' ;
2026-07-08 15:58:09 +05:30
if ( convoIsGroup && m . id === _lastGroupId && Array . isArray ( m . seenBy ) && m . seenBy . length ){ const ns = m . seenBy , head = ns . slice ( 0 , 2 ). join ( ', ' ), more = ns . length > 2 ? ( ' +' + ( ns . length - 2 ) + ' more' ) : '' ; seen = '<button class="seenby" data-seen="' + pEsc ( ns . join ( '|' )) + '">' + ic ( 'checkCheck' , 12 ) + ' Seen by ' + pEsc ( head ) + more + '</button>' ; }
2026-06-23 16:15:29 +05:30
return '<div class="bubble ' + ( mine ? 'mine' : 'them' ) + ( mentionsMe ? ' mention-me' : '' ) + ( m . poll ? ' has-poll' : '' ) + '" data-id="' + pEsc ( m . id ) + '">'
2026-07-07 23:11:02 +05:30
+ sender + ( m . fwd_from ? '<div class="fwd-label">' + ic ( 'arrowRight' , 11 ) + ' Forwarded' + ( m . fwd_from ? ( ' from <b>' + pEsc ( m . fwd_from ) + '</b>' ) : '' ) + '</div>' : '' ) + quote + att + renderMsgBody ( m ) + pollHTML ( m )
2026-07-02 18:01:32 +05:30
+ ( m . deleted ? '' : '<div class="msg-actions">'
+ '<button class="reply-btn" data-id="' + pEsc ( m . id ) + '" title="Reply">' + ic ( 'reply' , 14 ) + '</button>'
2026-07-07 16:09:02 +05:30
+ (( m . body || m . attachment ) &&! m . poll ? '<button class="fwd-btn" data-fwd="' + pEsc ( m . id ) + '" title="Forward">' + ic ( 'arrowRight' , 14 ) + '</button>' : '' )
2026-07-02 18:01:32 +05:30
+ '<button class="react-btn" data-id="' + pEsc ( m . id ) + '" title="React">' + ic ( 'smilePlus' , 14 ) + '</button>'
+ (( mine && m . body && ! m . poll ) ? '<button class="edit-btn" data-edit="' + pEsc ( m . id ) + '" title="Edit message">' + ic ( 'edit' , 13 ) + '</button>' : '' )
+ ( mine ? '<button class="del-btn" data-del="' + pEsc ( m . id ) + '" title="Delete message">' + ic ( 'trash' , 13 ) + '</button>' : '' )
+ '</div>' )
2026-07-02 17:45:20 +05:30
+ '<span class="t">' + pEsc ( fmtClock ( m . created_at )) + ( m . edited_at ? '<span class="edited"> · edited</span>' : '' ) + rcpt + '</span>'
2026-06-23 16:15:29 +05:30
+ 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 = '' ; } }
2026-07-02 17:45:20 +05:30
// Edit an existing message (sender only): load its text into the composer, show an "Editing" bar;
// the next send saves the edit instead of posting a new message.
let editTarget = null ;
function startEdit ( m ){
if ( ! m ) return ; clearReply (); editTarget = m ;
const bar = document . getElementById ( 'replyBar' ); if ( bar ){ bar . innerHTML = '<span>' + ic ( 'edit' , 13 ) + ' Editing message</span><span class="rx" id="editCancel">' + ic ( 'x' , 15 ) + '</span>' ; bar . style . display = 'flex' ; const x = document . getElementById ( 'editCancel' ); if ( x ) x . onclick = cancelEdit ; }
const inp = document . getElementById ( 'msgInput' ); if ( inp ){ inp . value = m . body || '' ; autoGrow ( inp ); inp . focus (); }
}
function cancelEdit (){ editTarget = null ; const bar = document . getElementById ( 'replyBar' ); if ( bar ){ bar . style . display = 'none' ; bar . innerHTML = '' ; } const inp = document . getElementById ( 'msgInput' ); if ( inp ){ inp . value = '' ; autoGrow ( inp ); } }
async function saveEdit ( text ){
const m = editTarget ; if ( ! m ) return ;
if ( ! text ){ cancelEdit (); return ; } // empty → treat as cancel (delete is a separate action)
if ( text === ( m . body || '' )){ cancelEdit (); return ; } // no change
editTarget = null ; const bar = document . getElementById ( 'replyBar' ); if ( bar ){ bar . style . display = 'none' ; bar . innerHTML = '' ; }
const inp = document . getElementById ( 'msgInput' ); if ( inp ){ inp . value = '' ; autoGrow ( inp ); }
try {
await postJSON ( '/api/messages/edit' ,{ id : m . id , body : text });
const t = THREAD . find ( x => x . id === m . id ); if ( t ){ t . body = text ; t . edited_at = Date . now (); updateBubble ( t ); }
THREAD_CACHE . forEach ( arr => arr . forEach ( x =>{ if ( x . id === m . id ){ x . body = text ; x . edited_at = Date . now (); } }));
// keep the chat-list preview in sync if this was the last message
const it = rowFor ( selected . kind , selected . id ); if ( it && it . last_from_me ){ const last = THREAD [ THREAD . length - 1 ]; if ( last && last . id === m . id ){ it . last_body = text ; renderChats ( searchVal ()); } }
} catch ( e ){ toast ( e . message || 'Could not edit' ); }
}
// A message I can see was edited by its sender → update it in place (thread + cache + list preview).
function onChatEdited ( d ){
if ( ! d ||! d . id ) return ;
const t = THREAD . find ( x => x . id === d . id ); if ( t ){ t . body = d . body ; t . edited_at = d . edited_at || Date . now (); updateBubble ( t ); }
THREAD_CACHE . forEach ( arr => arr . forEach ( x =>{ if ( x . id === d . id ){ x . body = d . body ; x . edited_at = d . edited_at || Date . now (); } }));
try { loadSidebar (); } catch ( _ ){} // refresh last-message previews
}
2026-06-23 16:15:29 +05:30
// attachments
2026-07-07 16:02:15 +05:30
let pendingAttachs = []; // #5: multiple files can be queued + sent together (each item: {id,name,mime,uploading})
2026-06-23 16:15:29 +05:30
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 ;
2026-07-07 16:02:15 +05:30
if ( file . size > 25 * 1024 * 1024 ){ toast ( '“' + file . name + '” is too large (max 25 MB)' ); return ; }
const ph = { id : 'up-' + Math . random (). toString ( 36 ). slice ( 2 ), name : file . name , mime : file . type || '' , uploading : true };
pendingAttachs . push ( ph ); renderAttachBar ();
2026-06-23 16:15:29 +05:30
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' );
2026-07-07 16:02:15 +05:30
const i = pendingAttachs . indexOf ( ph ); const item = { ... d , uploading : false }; if ( i >= 0 ) pendingAttachs [ i ] = item ; else pendingAttachs . push ( item );
renderAttachBar ();
2026-06-23 16:15:29 +05:30
const inp = document . getElementById ( 'msgInput' ); if ( inp ) inp . focus ();
2026-07-07 16:02:15 +05:30
} catch ( e ){ const i = pendingAttachs . indexOf ( ph ); if ( i >= 0 ) pendingAttachs . splice ( i , 1 ); renderAttachBar (); toast ( e . message || 'Upload failed' ); }
2026-06-23 16:15:29 +05:30
}
2026-07-07 16:02:15 +05:30
function renderAttachBar (){
2026-06-23 16:15:29 +05:30
const bar = document . getElementById ( 'attachBar' ); if ( ! bar ) return ;
2026-07-07 16:02:15 +05:30
if ( ! pendingAttachs . length ){ bar . style . display = 'none' ; bar . innerHTML = '' ; return ; }
bar . innerHTML = pendingAttachs . map (( a , idx )=>{
const isImg = /^image\// . test ( a . mime || '' );
const lead = ( ! a . uploading && isImg ) ? '<img class="ap-thumb" src="/files/' + pEsc ( a . id ) + '" alt="">' : '<span class="ap-ic">' + ic ( a . uploading ? 'paperclip' : ( isImg ? 'camera' : 'file' ), 18 ) + '</span>' ;
return '<div class="ap-item">' + lead + '<span class="ap-name">' + pEsc ( a . name ) + ( a . uploading ? ' · uploading…' : '' ) + '</span>' + ( a . uploading ? '' : '<button type="button" class="ap-x" data-ai="' + idx + '" title="Remove">' + ic ( 'x' , 15 ) + '</button>' ) + '</div>' ;
}). join ( '' );
bar . style . display = 'flex' ;
bar . querySelectorAll ( '.ap-x' ). forEach ( b => b . onclick = ()=>{ const i =+ b . dataset . ai ; if ( i >= 0 && i < pendingAttachs . length ){ pendingAttachs . splice ( i , 1 ); renderAttachBar (); } });
}
function hideAttach (){ pendingAttachs = []; const bar = document . getElementById ( 'attachBar' ); if ( bar ){ bar . style . display = 'none' ; bar . innerHTML = '' ; } const fi = document . getElementById ( 'fileInput' ); if ( fi ) fi . value = '' ; }
2026-06-23 16:15:29 +05:30
// Paste an image from the clipboard (e.g. a screenshot) straight into the composer.
function onPaste ( e ){
2026-07-10 13:25:37 +05:30
const cd = e . clipboardData ; if ( ! cd ) return ;
// Excel / Google Sheets put BOTH a bitmap AND the cell text on the clipboard. If there's real text,
// let the textarea paste it normally (don't upload the screenshot). Only upload the image when the
// clipboard is image-ONLY (a genuine screenshot / copied picture with no accompanying text).
const plain = ( cd . getData && cd . getData ( 'text/plain' )) || '' ;
if ( plain && plain . trim ()) return ; // → default paste of the text
const items = cd . items || [];
2026-06-23 16:15:29 +05:30
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>' ;
2026-07-02 11:55:14 +05:30
// stopPropagation: switching tabs re-renders the pop (detaching the clicked button), which would
// make the outside-click handler think the click was outside and close the picker.
pop . querySelectorAll ( '.emoji-tabs button' ). forEach ( b => b . onclick = ( ev )=>{ ev . stopPropagation (); emojiCat =+ b . dataset . i ; renderEmojiPop ( pop ); });
2026-06-23 16:15:29 +05:30
const grid = pop . querySelector ( '#emojiGrid' );
2026-07-02 12:23:26 +05:30
grid . innerHTML = EMOJI_CATS [ emojiCat ]. list . trim (). split ( /\s+/ ). map ( e => '<button type="button" data-emoji="' + e + '">' + e + '</button>' ). join ( '' );
grid . querySelectorAll ( 'button' ). forEach ( b => b . onclick = ( ev )=>{ ev . stopPropagation (); onEmojiPick ( b . dataset . emoji ); }); // data-emoji survives twemoji's <img> swap
twemojify ( pop ); // modern emoji images in the picker
2026-06-23 16:15:29 +05:30
}
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.
2026-07-02 15:43:02 +05:30
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 (); }
// #6: keep the chat-list tick in sync with the thread — my last message is now read (blue double).
const it = rowFor ( 'dm' , d . by ); if ( it && it . last_from_me && it . last_status !== 'read' ){ it . last_status = 'read' ; renderChats ( searchVal ()); }
}
2026-07-07 11:46:22 +05:30
// Branded confirm dialog — replaces window.confirm (which shows the OS/Electron default dialog on
// desktop). Returns a Promise<boolean>. Reuses .modal-ov so the global Esc handler dismisses it;
// a MutationObserver resolves false whenever the overlay is removed (Esc / backdrop / cancel).
2026-07-08 16:56:39 +05:30
// #2: "Seen by" as an on-screen popup listing the readers (not a flash toast).
2026-07-10 12:51:14 +05:30
function showSeenByModal ( msg ){
2026-07-08 16:56:39 +05:30
if ( document . getElementById ( 'seenModal' )) return ;
2026-07-10 12:51:14 +05:30
// Accept either a message (rich: DPs + "not seen yet") or a bare names[] (fallback).
const seenNames = Array . isArray ( msg ) ? msg : ( Array . isArray ( msg && msg . seenBy ) ? msg . seenBy . slice () : []);
const senderId = ( msg && ! Array . isArray ( msg )) ? msg . from : null ;
const members = ( convoIsGroup && Array . isArray ( convoMembers )) ? convoMembers : [];
// name → real DP (match member by name, since seenBy carries names).
const avByName = {}; members . forEach ( m =>{ avByName [( m . name || '' ). trim (). toLowerCase ()] = m . avatar || null ; });
// Who hasn't seen it yet = members minus the sender, minus me (I'm reading it), minus those who have.
const seenSet = new Set ( seenNames . map ( n => String ( n ). trim (). toLowerCase ()));
const notSeen = members . filter ( m => m . id !== senderId && m . id !== ( ME && ME . id ) && ! seenSet . has (( m . name || '' ). trim (). toLowerCase ()));
const chip = ( name , avatar )=> '<span class="mini-av" style="background:' + avColor ( name ) + '">' + ( avatar ? ( '<img class="av-img" src="' + pEsc ( avatar ) + '" onerror="this.remove()">' ) : '' ) + pEsc ( initials ( name )) + '</span>' ;
const rowsFromNames = ( arr )=> arr . map ( n => '<div class="seen-row">' + chip ( n , avByName [ String ( n ). trim (). toLowerCase ()] || null ) + '<span class="sn">' + pEsc ( n ) + '</span></div>' ). join ( '' );
const rowsFromMembers = ( arr )=> arr . map ( m => '<div class="seen-row">' + chip ( m . name , m . avatar || null ) + '<span class="sn">' + pEsc ( m . name ) + '</span></div>' ). join ( '' );
2026-07-10 13:25:37 +05:30
let inner = '' ;
if ( seenNames . length ) inner += '<div class="seen-sec"><span class="dot ok"></span>Seen · ' + seenNames . length + '</div>' + rowsFromNames ( seenNames );
else inner += '<div class="gi-noresult" style="padding:.6rem .2rem">No one has seen this yet.</div>' ;
2026-07-10 13:56:18 +05:30
if ( convoIsGroup && notSeen . length ) inner += '<div class="seen-div"></div><div class="seen-sec dim"><span class="dot"></span>Not seen yet · ' + notSeen . length + '</div>' + rowsFromMembers ( notSeen );
2026-07-10 13:25:37 +05:30
// ONE scroll container for the whole list (section headers stick as you scroll) — not a scrollbar
// per section, which split the popup confusingly.
const body = '<div class="seen-scroll">' + inner + '</div>' ;
2026-07-08 16:56:39 +05:30
const ov = document . createElement ( 'div' ); ov . className = 'modal-ov' ; ov . id = 'seenModal' ;
2026-07-10 13:25:37 +05:30
ov . innerHTML = '<div class="modal seen-modal"><h3>' + ic ( 'checkCheck' , 16 ) + ' Message info</h3>' + body + '<div class="bzc-actions"><button type="button" class="bzc-ok" id="seenClose">Close</button></div></div>' ;
2026-07-08 16:56:39 +05:30
document . body . appendChild ( ov );
ov . addEventListener ( 'mousedown' , e =>{ if ( e . target === ov ) ov . remove (); });
ov . querySelector ( '#seenClose' ). onclick = ()=> ov . remove ();
}
2026-07-07 11:46:22 +05:30
function bzConfirm ( message , opts ){
opts = opts || {};
return new Promise (( resolve )=>{
const ov = document . createElement ( 'div' ); ov . className = 'modal-ov' ;
ov . innerHTML = '<div class="modal bz-confirm"><h3>' + pEsc ( opts . title || 'Please confirm' ) + '</h3>'
+ '<p class="bzc-msg">' + pEsc ( message || 'Are you sure?' ) + '</p>'
+ '<div class="bzc-actions"><button type="button" class="bzc-cancel">' + pEsc ( opts . cancelText || 'Cancel' ) + '</button>'
+ '<button type="button" class="bzc-ok' + ( opts . danger ? ' danger' : '' ) + '">' + pEsc ( opts . okText || 'Confirm' ) + '</button></div></div>' ;
document . body . appendChild ( ov );
let done = false ;
const finish = ( v )=>{ if ( done ) return ; done = true ; obs . disconnect (); if ( document . body . contains ( ov )) ov . remove (); resolve ( v ); };
const obs = new MutationObserver (()=>{ if ( ! document . body . contains ( ov )) finish ( false ); }); // Esc/global removal → cancel
obs . observe ( document . body ,{ childList : true });
ov . querySelector ( '.bzc-ok' ). onclick = ()=> finish ( true );
ov . querySelector ( '.bzc-cancel' ). onclick = ()=> finish ( false );
ov . addEventListener ( 'mousedown' ,( e )=>{ if ( e . target === ov ) finish ( false ); }); // backdrop click
setTimeout (()=>{ const b = ov . querySelector ( '.bzc-ok' ); if ( b ) b . focus (); }, 0 );
});
}
2026-06-30 17:01:15 +05:30
// Delete-for-everyone: confirm, tell the server, then blank the message locally (the server also
// broadcasts chat-deleted to the other side / other tabs).
2026-07-07 11:46:22 +05:30
async function deleteMessage ( id ){ if ( ! ( await bzConfirm ( 'This message will be removed for everyone in the chat.' , { title : 'Delete message?' , okText : 'Delete' , danger : true }))) return ; try { await postJSON ( '/api/messages/delete' ,{ id }); markMsgDeleted ( id ); } catch ( e ){ toast ( e . message || 'Could not delete' ); } }
2026-06-30 17:01:15 +05:30
function markMsgDeleted ( id ){
let changed = false ;
THREAD . forEach ( m =>{ if ( m . id === id && ! m . deleted ){ m . deleted = true ; m . body = '' ; m . attachment = null ; m . reactions = []; m . reply = null ; m . poll = null ; changed = true ; } });
THREAD_CACHE . forEach ( arr => arr . forEach ( m =>{ if ( m . id === id ){ m . deleted = true ; m . body = '' ; m . attachment = null ; } }));
if ( changed ) renderThread ();
}
function onChatDeleted ( d ){ if ( ! d ||! d . id ) return ; markMsgDeleted ( d . id ); try { loadSidebar (); } catch ( _ ){} } // refresh last-message previews
2026-06-23 16:15:29 +05:30
// DM delivered: recipient's client acked → second (grey) tick.
2026-07-02 15:43:02 +05:30
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 ); }
// #6: mirror the delivered state onto the chat-list tick (double grey), unless it's already read.
2026-07-08 11:55:30 +05:30
const it = d . with ? rowFor ( 'dm' , d . with ) : null ; if ( it && it . last_from_me && it . last_status !== 'read' && it . last_status !== 'delivered' ){ it . last_status = 'delivered' ; renderChats ( searchVal ()); }
}
// #1: opening a DM loads the true delivered/read state into the thread; make the sidebar tick match
// (it can lag when a delivered/read event arrived before the row reflected my latest message).
function syncDmRowTick ( id ){
const it = rowFor ( 'dm' , id ); if ( ! it || ! THREAD . length ) return ;
const last = [... THREAD ]. reverse (). find ( m => ! m . system && ! m . deleted ); if ( ! last ) return ;
if ( last . from === ME . id ){ const st = last . read_at ? 'read' : ( last . delivered_at ? 'delivered' : 'sent' ); if ( it . last_status !== st || it . last_msg_id !== last . id ){ it . last_status = st ; it . last_from_me = true ; it . last_msg_id = last . id ; renderChats ( searchVal ()); } }
2026-07-02 15:43:02 +05:30
}
2026-06-23 16:15:29 +05:30
// Group read: a member opened the group → add them to "Seen by" on my messages up to that time.
2026-07-08 15:58:09 +05:30
function onGroupRead ( d ){ if ( ! d ||! d . group ) return ; if ( selected && selected . kind === 'group' && selected . id === d . group ){ THREAD . forEach ( m =>{ if ( m . created_at <= d . at && m . from !== d . by && ! m . system && ! m . deleted ){ m . seenBy = m . seenBy || []; if ( d . byName && ! m . seenBy . includes ( d . byName )){ m . seenBy . push ( d . byName ); if ( m . id === _lastGroupId || m . from === ME . id ) updateBubble ( m ); } } }); refreshGroupRowTick ( d . group ); } } // #2: track everyone's reads on every message
2026-07-06 10:57:08 +05:30
// Live-refresh a group's sidebar tick from the open thread (read-by-all → yellow double tick).
function refreshGroupRowTick ( gid ){
const row = rowFor ( 'group' , gid ); if ( ! row ||! row . last_from_me ||! ( selected && selected . kind === 'group' && selected . id === gid )) return ;
const mine = THREAD . filter ( m => m . from === ME . id && ! m . system && ! m . deleted ); const last = mine [ mine . length - 1 ]; if ( ! last ) return ;
const others = Math . max ( 0 ,( convoMembers ? convoMembers . length : 0 ) - 1 ); const rc = Array . isArray ( last . seenBy ) ? last . seenBy . length : 0 ;
row . last_status = ( others > 0 && rc >= others ) ? 'read' : ( rc > 0 ? 'delivered' : 'sent' );
if ( ! typingLabel ( 'group' , gid )){ const el = listEl && listEl . querySelector ( '.chat-row[data-kind="group"][data-id="' + _cssEsc ( gid ) + '"] .chat-prev' ); if ( el ) el . innerHTML = rowPreviewHTML ( row ); }
}
2026-06-23 16:15:29 +05:30
// 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 ){
2026-07-08 17:38:50 +05:30
if ( ! d ||! d . group ) return ; const it = rowFor ( 'group' , d . group ); if ( it ){ it . callActive =!! d . active ; it . callRoom = d . room || null ;
if ( d . active && d . by && d . by !== ME . id ){ it . incomingRoom = d . room ; it . callByName = d . startedByName ; } else if ( ! d . active ){ it . incomingRoom = null ; it . callByName = null ; } }
2026-06-23 16:15:29 +05:30
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 ){
2026-07-07 23:18:14 +05:30
try { const r = await postJSON ( '/api/calls/dm/start' ,{ to : otherId }); if ( r && r . room ){ const _r = rowFor ( 'dm' , otherId ); _dmCallWaiting = { name : ( _r && _r . name ) || 'Contact' , avatar : ( _r && _r . avatar ) || null }; meetReturn = { kind : 'dm' , id : otherId }; switchTab ( 'meeting' ); enterMeeting ( r . room ); startRingback (); /* #17: ring until they answer */ } }
2026-06-23 16:15:29 +05:30
catch ( e ){ toast ( e . message || 'Could not start the call' ); }
}
2026-07-02 15:43:02 +05:30
// Live presence: a contact came online/offline or entered/left a call — update their dot + the
// open chat header immediately (no refresh, which the desktop/mobile apps can't do). #6
function onPresence ( d ){
if ( ! d ||! d . userId ) return ;
const it = rowFor ( 'dm' , d . userId );
if ( it ){ it . online =!! d . online ; it . status = d . status || 'active' ; renderChats ( searchVal ()); }
if ( selected && selected . kind === 'dm' && selected . id === d . userId && it ){
const sub = document . querySelector ( '#convoTitle .st' ); if ( sub ) sub . textContent = dmSubLabel ( it );
const dot = document . querySelector ( '.convo-head .avatar .dot' ); if ( dot ) dot . className = 'dot ' + statusCls ( it );
}
}
2026-06-23 16:15:29 +05:30
function onDmCall ( d ){
2026-07-08 17:38:50 +05:30
if ( ! d ) return ; const it = rowFor ( 'dm' , d . with ); if ( it ){ it . callActive =!! d . active ; it . callRoom = d . room || null ; if ( ! d . active && it . status === 'incall' ) it . status = 'active' ; // #4: drop the stuck "in call" status
if ( d . active && d . by && d . by !== ME . id ){ it . incomingRoom = d . room ; it . callByName = d . byName ; } else if ( ! d . active ){ it . incomingRoom = null ; it . callByName = null ; } } // remember an incoming call so opening the chat can re-show Join
2026-07-02 15:43:02 +05:30
if ( selected && selected . kind === 'dm' && selected . id === d . with ){ updateCallBtn ( !! d . active ); const s = document . querySelector ( '#convoTitle .st' ); if ( s && it ) s . textContent = dmSubLabel ( it ); } // refresh the header subtitle live
2026-06-23 16:15:29 +05:30
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.
2026-07-08 17:38:50 +05:30
function showCallInvite ( room , byName , ret , sub , quiet ){
2026-06-23 16:15:29 +05:30
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 );
let closed = false ;
const close = ()=>{ if ( closed ) return ; closed = true ; try { el . remove (); } catch ( _ ){} stopRing (); };
2026-07-08 18:00:56 +05:30
// Wire Join/Decline BEFORE firing the OS notification so the buttons are live the instant the popup
// shows (previously notify() ran first — the click did nothing until the toast plumbing settled).
2026-06-23 16:15:29 +05:30
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
2026-07-08 18:00:56 +05:30
if ( ! quiet ){ try { notify ( '📞 ' + who , ( sub ? ( 'Group call · ' + sub ) : 'is calling you' ), ret && ret . kind , ret && ret . id , { persistent : true }); } catch ( _ ){} } // OS notification too (skip when merely re-showing on chat open)
2026-06-23 16:15:29 +05:30
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 (); }
2026-06-30 17:01:15 +05:30
// #6: track invitees who haven't joined yet. They show under "Not joined yet"; after 30s the
// invite is re-enabled for them. When they actually join (matched by user id), they're cleared.
function meetInviteJoined ( uid ){ const e = meetInvited . get ( uid ); if ( e ){ if ( e . timer ) clearTimeout ( e . timer ); meetInvited . delete ( uid ); refreshMeetPanel (); } }
function meetAddInvited ( ids ){ ( ids || []). forEach ( uid =>{ const c = ( CONTACTS || []). find ( x => x . id === uid ); const name = ( c && c . name ) || 'Someone' ; const prev = meetInvited . get ( uid ); if ( prev && prev . timer ) clearTimeout ( prev . timer ); const timer = setTimeout (()=>{ meetInvited . delete ( uid ); refreshMeetPanel (); }, 30000 ); meetInvited . set ( uid ,{ name , timer }); }); refreshMeetPanel (); }
2026-07-10 15:44:28 +05:30
// Guest invite link for the current meeting — anyone with it can join (guest token), even without a
// Connect account. Shared from the meeting's Add-people panel and emailed to invitees (#4).
function meetingLink (){ return location . origin + '/home?meet=' + encodeURIComponent ( meetRoom || '' ); }
function copyMeetingLink (){
if ( ! meetRoom ){ toast ( 'No active meeting' ); return ; }
const url = meetingLink (), done = ()=> toast ( 'Invite link copied' );
try { if ( navigator . clipboard && navigator . clipboard . writeText ) return void navigator . clipboard . writeText ( url ). then ( done ). catch (()=> fallbackCopy ( url , done )); } catch ( _ ){}
fallbackCopy ( url , done );
}
function fallbackCopy ( text , cb ){ try { const ta = document . createElement ( 'textarea' ); ta . value = text ; ta . style . position = 'fixed' ; ta . style . opacity = '0' ; document . body . appendChild ( ta ); ta . select (); document . execCommand ( 'copy' ); ta . remove (); cb && cb (); } catch ( _ ){ prompt ( 'Copy this link:' , text ); } }
2026-06-23 16:15:29 +05:30
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 = []; }); }
2026-07-08 15:58:09 +05:30
// #4: 1:1/ad-hoc → only people you've MESSAGED (existing DMs), not the whole team.
const pool = inGroup ? ( _addPool || []) : ( ROWS || []). filter ( r => r . kind === 'dm' && ! r . self ). map ( r =>({ id : r . id , name : r . name }));
const hereUids = new Set (); meetPeerUids . forEach ( uid => hereUids . add ( uid )); if ( ME && ME . id ) hereUids . add ( ME . id );
2026-06-23 16:15:29 +05:30
const here = new Set ( list . map ( pp =>( pp . name || '' ). replace ( /\s*\(you\)$/ , '' ). trim (). toLowerCase ()));
const myName = (( ME && ME . name ) || ( ME && ME . email ) || '' ). trim (). toLowerCase ();
2026-07-08 15:58:09 +05:30
const avail = pool . filter ( c => c . id !== ( ME && ME . id ) && ! hereUids . has ( c . id ) && ( c . name || '' ). trim (). toLowerCase () !== myName && ! here . has (( c . name || '' ). trim (). toLowerCase ()) && ! meetInvited . has ( c . id ));
2026-07-10 15:44:28 +05:30
body = '<div class="mp-invite-link">' + ic ( 'link' , 14 ) + '<div class="mil-txt"><b>Invite by link</b><span>Anyone with the link can join — even without a Connect account.</span></div><button class="mp-copylink" id="mpCopyLink">' + ic ( 'copy' , 13 ) + ' Copy</button></div>'
+ '<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…' : 'No one left to add' ) + '</div>' ) + '</div>' + ( avail . length ? '<button class="gobtn mp-invite-sticky" id="mpInvite">Invite to call</button>' : '' );
2026-06-23 16:15:29 +05:30
} 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>'
2026-06-30 17:01:15 +05:30
+ ( meetInvited . size ? '<div class="mp-sec">Not joined yet</div><div class="mp-list">' + [... meetInvited . entries ()]. map (([ uid , e ])=> '<div class="mp-row waiting"><span class="mini-av" style="background:' + avColor ( e . name ) + '">' + pEsc ( initials ( e . name )) + '</span><span class="mn">' + pEsc ( e . name ) + '</span><span class="pp-wait">' + ic ( 'calendarClock' , 12 ) + ' waiting…</span></div>' ). join ( '' ) + '</div>' : '' )
2026-06-23 16:15:29 +05:30
+ ( 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>' : '' );
}
2026-06-30 17:01:15 +05:30
p . innerHTML = head + tabs + '<div class="mp-scroll">' + body + '</div>' ;
2026-06-23 16:15:29 +05:30
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 (); });
2026-07-10 15:44:28 +05:30
const cpl = p . querySelector ( '#mpCopyLink' ); if ( cpl ) cpl . onclick = ()=> copyMeetingLink ();
2026-06-30 17:01:15 +05:30
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 { await postJSON ( '/api/calls/invite' ,{ room : meetRoom , userIds : ids }); meetAddInvited ( ids ); meetPanelTab = 'people' ; toast ( ids . length === 1 ? 'Waiting for them to join…' : 'Waiting for ' + ids . length + ' people to join…' ); renderMeetPanel (); } catch ( e ){ toast ( e . message || 'Could not invite' ); } };
2026-06-23 16:15:29 +05:30
}
// 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>'
2026-07-08 15:58:09 +05:30
+ (()=>{ const hereUids = new Set (); meetPeerUids . forEach ( uid => hereUids . add ( uid )); if ( ME && ME . id ) hereUids . add ( ME . id ); const cand = ( ROWS || []). filter ( r => r . kind === 'dm' && ! r . self && ! hereUids . has ( r . id ) && ! meetInvited . has ( r . id )); return '<div class="gi-list" style="max-height:46vh;overflow:auto">' + ( cand . length ? cand . 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 one left to add</div>' ) + '</div>' ; })()
+ '<button class="gobtn mp-invite-sticky" id="invBtn" style="width:100%;margin-top:.7rem;position:sticky;bottom:0">Invite</button></div>' ;
2026-06-23 16:15:29 +05:30
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 ;
2026-07-07 16:02:15 +05:30
// #3: build a gallery from the loaded thread's images so ← / → flip through them.
const gallery = THREAD . filter ( m => m . attachment && m . attachment . isImage && ! m . deleted ). map ( m => '/files/' + m . attachment . id );
let idx = gallery . indexOf ( src ); const multi = idx >= 0 && gallery . length > 1 ; if ( idx < 0 ) idx = 0 ;
2026-06-23 16:15:29 +05:30
const ov = document . createElement ( 'div' ); ov . className = 'lightbox' ; ov . id = 'lightbox' ;
2026-07-07 16:02:15 +05:30
ov . innerHTML = '<button class="lb-close" title="Close (Esc)">' + ic ( 'x' , 22 ) + '</button>'
2026-07-07 23:11:02 +05:30
+ ( multi ? '<button class="lb-nav lb-prev" title="Previous (←)">' + ic ( 'chevronLeft' , 18 ) + '</button>' : '' )
2026-07-07 16:02:15 +05:30
+ '<img src="' + pEsc ( src ) + '" alt="">'
2026-07-07 23:11:02 +05:30
+ ( multi ? '<button class="lb-nav lb-next" title="Next (→)">' + ic ( 'chevronRight' , 18 ) + '</button>' : '' )
2026-07-07 16:02:15 +05:30
+ '<a class="lb-dl" href="' + pEsc ( src ) + '" download title="Download">' + ic ( 'download' , 20 ) + '</a>' ;
2026-06-23 16:15:29 +05:30
document . body . appendChild ( ov );
2026-07-07 16:02:15 +05:30
const img = ov . querySelector ( 'img' ), dl = ov . querySelector ( '.lb-dl' );
const show = ( i )=>{ if ( ! multi ) return ; idx = (( i % gallery . length ) + gallery . length ) % gallery . length ; const u = gallery [ idx ]; img . src = u ; if ( dl ) dl . setAttribute ( 'href' , u ); };
2026-06-23 16:15:29 +05:30
const close = ()=>{ ov . remove (); document . removeEventListener ( 'keydown' , onKey ); };
2026-07-07 16:02:15 +05:30
const onKey = ( e )=>{ if ( e . key === 'Escape' ){ e . preventDefault (); close (); } else if ( e . key === 'ArrowLeft' ){ e . preventDefault (); show ( idx - 1 ); } else if ( e . key === 'ArrowRight' ){ e . preventDefault (); show ( idx + 1 ); } };
ov . addEventListener ( 'click' ,( e )=>{ if ( e . target . closest ( '.lb-prev' )){ show ( idx - 1 ); return ; } if ( e . target . closest ( '.lb-next' )){ show ( idx + 1 ); return ; } if ( e . target === ov || e . target . closest ( '.lb-close' )) close (); });
2026-06-23 16:15:29 +05:30
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 + '"]' );
2026-07-02 12:23:26 +05:30
if ( el ){ el . outerHTML = bubbleHTML ( m ); const n = document . querySelector ( '#msgs .bubble[data-id="' + sel + '"]' ); twemojify ( n ); }
2026-06-23 16:15:29 +05:30
}
// 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 ;
2026-07-07 23:11:02 +05:30
if ( box . scrollTop < 120 && _hasMoreOlder && ! _loadingOlder && Date . now () > _olderCd ) loadOlder (); // near the top → pull in older history
2026-06-23 16:15:29 +05:30
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' }); }
2026-07-08 15:58:09 +05:30
let _lastDay = '' , _lastMineId = '' , _lastGroupId = '' ; // _lastGroupId: the group's newest message shows the shared "Seen by"
2026-06-23 16:15:29 +05:30
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 '' ; }
2026-07-08 15:58:09 +05:30
function lastGroupMsgId (){ for ( let i = THREAD . length - 1 ; i >= 0 ; i -- ){ const m = THREAD [ i ]; if ( ! m . system && ! m . deleted ) return m . id ; } return '' ; } // #2: last real message in the group (any sender)
2026-07-07 15:21:14 +05:30
function renderThread ( keepScroll ){
2026-06-23 16:15:29 +05:30
const box = document . getElementById ( 'msgs' ); if ( ! box ) return ;
2026-07-08 15:58:09 +05:30
rendered . clear (); _lastDay = '' ; _lastMineId = lastMineId (); _lastGroupId = lastGroupMsgId ();
2026-06-23 16:15:29 +05:30
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 ); }
2026-07-08 13:20:19 +05:30
box . innerHTML = html ; twemojify ( box );
if ( ! keepScroll ){ box . scrollTop = box . scrollHeight ; // open/refresh → newest at the bottom; re-assert after late content (images/twemoji) settles, unless we jumped to a "New messages" divider
const pin = ()=>{ const b = document . getElementById ( 'msgs' ); if ( b && ! b . querySelector ( '.new-sep' )) b . scrollTop = b . scrollHeight ; };
requestAnimationFrame ( pin ); setTimeout ( pin , 150 ); setTimeout ( pin , 400 );
}
2026-07-07 16:09:02 +05:30
if ( _selMode ) box . querySelectorAll ( '.bubble' ). forEach ( b =>{ if ( _selIds . has ( b . dataset . id )) b . classList . add ( 'selected' ); }); // #1: keep selection across re-render
2026-06-23 16:15:29 +05:30
}
function appendBubble ( m ){
if ( rendered . has ( m . id )) return ; rendered . add ( m . id );
const box = document . getElementById ( 'msgs' ); if ( ! box ) return ;
2026-07-10 14:57:02 +05:30
// Was the user already at the bottom? If they've scrolled UP to read history, an incoming message
// must NOT yank them back down (that made the thread feel "stuck" — you couldn't stay scrolled up,
// especially now that background messages arrive live). Keep their position + flag "jump to latest".
const atBottom = ( box . scrollHeight - box . scrollTop - box . clientHeight ) < 160 ;
2026-06-23 16:15:29 +05:30
const empty = box . querySelector ( '.empty-thread' ); if ( empty ) empty . remove ();
2026-07-08 15:58:09 +05:30
// A new message becomes the newest: move the group "Seen by" off the previous last message.
if ( ! m . system && ! m . deleted ){ if ( _lastGroupId ){ const pe = box . querySelector ( '.bubble[data-id="' + (( window . CSS && CSS . escape ) ? CSS . escape ( _lastGroupId ) : _lastGroupId ) + '"] .seenby' ); if ( pe ) pe . remove (); } _lastGroupId = m . id ; if ( m . from === ME . id ) _lastMineId = m . id ; }
2026-06-23 16:15:29 +05:30
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 ));
2026-07-02 12:23:26 +05:30
twemojify ( box . lastElementChild );
2026-07-10 14:57:02 +05:30
// Auto-scroll only if they were at the bottom, or it's MY own message (sending always jumps down).
if ( atBottom || m . from === ME . id ){ box . scrollTop = box . scrollHeight ; }
else { const jl = document . getElementById ( 'jumpLatest' ); if ( jl ) jl . style . display = 'grid' ; } // let them jump when ready
2026-06-23 16:15:29 +05:30
}
2026-06-30 17:01:15 +05:30
let _openUnread = 0 ; // set by selectChat (the unread count before it's reset) so we can open at the first unread
2026-07-02 11:55:14 +05:30
// Per-conversation drafts: unsent text is kept (across chat switches AND reloads) until sent.
function draftKey ( kind , id ){ return 'draft_' + (( ME && ME . id ) || '' ) + '_' + kind + ':' + id ; }
function getDraft ( kind , id ){ try { return localStorage . getItem ( draftKey ( kind , id )) || '' ; } catch ( _ ){ return '' ; } }
function setDraft ( kind , id , val ){ try { if ( val && val . trim ()) localStorage . setItem ( draftKey ( kind , id ), val ); else localStorage . removeItem ( draftKey ( kind , id )); } catch ( _ ){} }
2026-06-23 16:15:29 +05:30
async function openConvo ( kind , id ){
const it = rowFor ( kind , id ) || { kind , id , name : 'Conversation' };
2026-06-30 17:01:15 +05:30
const _unreadN = _openUnread ; _openUnread = 0 ; // consume the captured unread count (#3)
2026-06-23 16:15:29 +05:30
convoIsGroup = ( kind === 'group' );
const el = document . getElementById ( 'chatPanel' ); el . classList . remove ( 'center' );
el . innerHTML = convoShellHTML ( it );
2026-06-30 17:01:15 +05:30
// #1: drag-and-drop a file/video/image anywhere on the conversation to send it.
el . ondragover = ( e )=>{ if ( e . dataTransfer && Array . from ( e . dataTransfer . types || []). includes ( 'Files' )){ e . preventDefault (); el . classList . add ( 'drag-over' ); } };
el . ondragleave = ( e )=>{ if ( e . relatedTarget === null ||! el . contains ( e . relatedTarget )) el . classList . remove ( 'drag-over' ); };
el . ondrop = ( e )=>{ const f = e . dataTransfer && e . dataTransfer . files && e . dataTransfer . files [ 0 ]; if ( f ){ e . preventDefault (); el . classList . remove ( 'drag-over' ); uploadFile ( f ); } };
2026-06-23 16:15:29 +05:30
const back = document . getElementById ( 'convoBack' ); if ( back ) back . onclick = showWelcome ;
const form = document . getElementById ( 'composer' ); if ( form ) form . addEventListener ( 'submit' ,( e )=>{ e . preventDefault (); sendMessage (); });
2026-07-07 16:02:15 +05:30
clearReply (); cancelEdit (); hideAttach ();
2026-06-23 16:15:29 +05:30
const ab2 = document . getElementById ( 'attachBtn' ); if ( ab2 ) ab2 . onclick = ()=>{ const fi = document . getElementById ( 'fileInput' ); if ( fi ) fi . click (); };
2026-07-07 16:02:15 +05:30
const fi = document . getElementById ( 'fileInput' ); if ( fi ) fi . onchange = ()=>{ Array . from ( fi . files || []). forEach ( uploadFile ); fi . value = '' ; }; // #5: queue every selected file
2026-06-23 16:15:29 +05:30
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 ){
2026-07-02 11:55:14 +05:30
const _d = getDraft ( kind , id ); if ( _d ){ inpEl . value = _d ; autoGrow ( inpEl ); } // restore unsent draft
2026-06-23 16:15:29 +05:30
inpEl . addEventListener ( 'paste' , onPaste );
2026-07-10 13:56:18 +05:30
inpEl . addEventListener ( 'input' , ()=>{ maybeAutocorrect ( inpEl ); autoGrow ( inpEl ); setDraft ( kind , id , inpEl . value ); noteTyping (); }); // auto-correct common typos, keep the draft in sync + emit "typing…"
2026-07-03 17:17:03 +05:30
inpEl . addEventListener ( 'blur' , stopTyping );
2026-06-23 16:15:29 +05:30
inpEl . addEventListener ( 'keydown' , ( e )=>{ if ( e . key === 'Enter' && ! e . shiftKey ){ if ( mentionItems && mentionItems . length ) return ; e . preventDefault (); sendMessage (); } }); // Enter sends, Shift+Enter = newline
2026-07-10 14:57:02 +05:30
// Desktop: LEFT-click a red-squiggled word → native spelling suggestions right there (the shell
// pops a suggestions menu only if the clicked word is actually misspelled). No right-click needed.
if ( window . bizConnectNative && window . bizConnectNative . spellSuggestAt ){
inpEl . addEventListener ( 'click' ,( e )=>{ if ( inpEl . selectionStart !== inpEl . selectionEnd ) return ; try { window . bizConnectNative . spellSuggestAt ( e . clientX , e . clientY ); } catch ( _ ){} });
}
2026-06-23 16:15:29 +05:30
}
2026-07-03 17:17:03 +05:30
refreshTyping ( kind , id ); // if someone's already typing in this conversation, show it right away
2026-07-08 15:58:09 +05:30
// #5: tapping the header avatar previews the DP / group photo full-size.
const hav = document . querySelector ( '#chatPanel .convo-head > .avatar' ); if ( hav ){ hav . style . cursor = 'pointer' ; hav . title = 'View photo' ; hav . onclick = ( e )=>{ e . stopPropagation (); const r = rowFor ( kind , id ) || it ; if ( r && r . avatar ) openLightbox ( r . avatar ); else toast ( 'No profile photo' ); }; }
2026-06-23 16:15:29 +05:30
const ci = document . getElementById ( 'convoInfo' ); if ( ci ) ci . onclick = ()=> openGroupInfo ( id );
2026-06-30 17:01:15 +05:30
const ct = document . getElementById ( 'convoTitle' ); if ( ct ) ct . onclick = ()=>{ if ( kind === 'group' ) openGroupInfo ( id ); else { const r = rowFor ( kind , id ); openSharedItems ( 'dm' , id , ( r && r . name ) || it . name || '' ); } }; // name → group: info+media; DM: media/files
2026-06-23 16:15:29 +05:30
const cc = document . getElementById ( 'convoCall' ); if ( cc ) cc . onclick = ()=>( kind === 'group' ? startOrJoinGroupCall ( id ) : startOrJoinDmCall ( id ));
2026-07-08 17:38:50 +05:30
// If this conversation has an active INCOMING call, (re)show the Join/Decline invite. When the chat
// is opened from a call notification, the original transient invite popup may already have closed —
// this guarantees a landing Join button. `quiet` avoids firing a duplicate OS notification.
if ( it . callActive && it . incomingRoom && meetRoom !== it . incomingRoom && ! document . getElementById ( 'ci-' + it . incomingRoom )){
showCallInvite ( it . incomingRoom , it . callByName || it . name , kind === 'group' ? { kind : 'group' , id } : { kind : 'dm' , id }, kind === 'group' ? it . name : undefined , true );
}
2026-06-30 17:01:15 +05:30
const csb = document . getElementById ( 'convoSearch' ); const cshead = document . getElementById ( 'convoSearchHead' ); const csin = document . getElementById ( 'convoSearchInput' );
if ( csb ) csb . onclick = ()=>{ if ( ! cshead ) return ; cshead . style . display = 'flex' ; if ( csin ){ csin . value = '' ; setTimeout (()=> csin . focus (), 0 ); } };
if ( csin ) csin . oninput = ()=> runSearch ( csin . value );
if ( csin ) csin . addEventListener ( 'keydown' , e =>{ if ( e . key === 'Enter' ){ e . preventDefault (); gotoHit ( _searchIdx + ( e . shiftKey ?- 1 : 1 )); } if ( e . key === 'Escape' ){ closeSearch (); } });
const csClose = document . getElementById ( 'convoSearchClose' ); if ( csClose ) csClose . onclick = closeSearch ;
const csPrev = document . getElementById ( 'convoSearchPrev' ); if ( csPrev ) csPrev . onclick = ()=> gotoHit ( _searchIdx - 1 );
const csNext = document . getElementById ( 'convoSearchNext' ); if ( csNext ) csNext . onclick = ()=> gotoHit ( _searchIdx + 1 );
2026-06-23 16:15:29 +05:30
const box = document . getElementById ( 'msgs' ); if ( box ) box . addEventListener ( 'click' ,( e )=>{
2026-07-07 16:09:02 +05:30
if ( _selMode ){ const bb = e . target . closest ( '.bubble' ); if ( bb ) toggleSel ( bb ); return ; } // #1: selection mode — tap toggles
const fw = e . target . closest ( '.fwd-btn' ); if ( fw ){ enterSelect ( fw . dataset . fwd ); return ; } // #1: enter forward-selection
2026-07-07 16:02:15 +05:30
const qz = e . target . closest ( '.quote' ); if ( qz && qz . dataset . jid ){ jumpToMessage ( qz . dataset . jid , + qz . dataset . jat || 0 ); return ; } // #8: tap a reply → go to the original
2026-06-23 16:15:29 +05:30
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 ; }
2026-07-02 17:45:20 +05:30
const ed = e . target . closest ( '.edit-btn' ); if ( ed ){ const mm = THREAD . find ( x => x . id === ed . dataset . edit ); if ( mm ) startEdit ( mm ); return ; }
2026-06-30 17:01:15 +05:30
const dl = e . target . closest ( '.del-btn' ); if ( dl ){ deleteMessage ( dl . dataset . del ); return ; }
2026-06-23 16:15:29 +05:30
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 ; }
2026-07-10 12:51:14 +05:30
const sb = e . target . closest ( '.seenby,.rcpt.grp' ); if ( sb ){ const bub = sb . closest ( '.bubble' ); const mid = bub && bub . dataset . id ; const msg = ( mid && THREAD . find ( x => x . id === mid )) || { seenBy : ( sb . dataset . seen || '' ). split ( '|' ). filter ( Boolean ) }; showSeenByModal ( msg ); return ; }
2026-06-30 17:01:15 +05:30
// Tap-to-reveal (mobile): a tap on the bubble body (not an action) reveals its reply/react/delete
// icons; the action only fires on a SECOND tap once they're shown (icons are pointer-events:none
// until revealed). Tapping elsewhere hides them.
const bub = e . target . closest ( '.bubble' ); const already = bub && bub . classList . contains ( 'show-actions' );
box . querySelectorAll ( '.bubble.show-actions' ). forEach ( b => b . classList . remove ( 'show-actions' ));
if ( bub && ! already && ! bub . classList . contains ( 'deleted' )) bub . classList . add ( 'show-actions' );
2026-06-23 16:15:29 +05:30
});
if ( box ) box . addEventListener ( 'scroll' , onMsgsScroll );
2026-07-07 15:44:32 +05:30
if ( box ) enablePullRefresh ( box , loadOlder ); // pull down at the top loads OLDER history (or no-ops); never jumps to the newest/bottom
2026-06-23 16:15:29 +05:30
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 (); }
2026-07-02 15:43:02 +05:30
else if ( box ){ box . innerHTML = '<div class="thread-loading"><img src="/loaders/loader-ring.svg" width="34" height="34" alt=""><span>Loading messages…</span></div>' ; } // branded loader instead of a blank pane on slow links
2026-06-23 16:15:29 +05:30
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 ));
2026-07-07 13:47:58 +05:30
// msgs stays null on a failed/non-OK fetch (distinct from a genuinely empty [] thread), so a flaky
// desktop network can't be mistaken for "no messages".
let msgs = null ; try { const r = await fetch ( url ); if ( r . ok ) msgs = await r . json (); } catch ( _ ){}
2026-06-23 16:15:29 +05:30
if ( ! selected || selected . kind !== kind || selected . id !== id ) return ; // switched away while loading
2026-07-07 13:47:58 +05:30
if ( ! Array . isArray ( msgs )){
// Fetch failed / errored — DO NOT blank the chat or overwrite the cache (that wiped messages that
// are still safely on the server → the "messages disappear after reopen" bug). Keep the cached
// render; only show empty if we truly have nothing cached to fall back to.
if ( ! THREAD_CACHE . has ( ckey )){ THREAD = []; renderThread (); }
const inp0 = document . getElementById ( 'msgInput' ); if ( inp0 ) inp0 . focus ();
return ;
}
THREAD = msgs ;
2026-06-23 16:15:29 +05:30
THREAD_CACHE . set ( ckey , THREAD . slice ());
2026-07-07 15:21:14 +05:30
_hasMoreOlder = msgs . length >= PAGE ; _loadingOlder = false ; // a full page back means there may be older history
2026-06-23 16:15:29 +05:30
renderThread ();
2026-07-08 11:55:30 +05:30
if ( kind === 'dm' ) syncDmRowTick ( id ); // #1: keep the sidebar tick consistent with the loaded thread
2026-06-30 17:01:15 +05:30
// #3: if there were unread messages, drop a "New messages" divider, scroll to the first unread,
// and show the "jump to newest" arrow so the user can return to the bottom.
if ( _unreadN > 0 && THREAD . length >= _unreadN ){
const fu = THREAD [ THREAD . length - _unreadN ]; const esc = ( window . CSS && CSS . escape ) ? CSS . escape ( fu . id ) : fu . id ;
const el = document . querySelector ( '#msgs .bubble[data-id="' + esc + '"]' ) || document . querySelector ( '#msgs [data-id="' + esc + '"]' );
if ( el ){ const sep = document . createElement ( 'div' ); sep . className = 'new-sep' ; sep . innerHTML = '<span>New messages</span>' ; el . parentNode . insertBefore ( sep , el ); el . scrollIntoView ({ block : 'start' }); el . parentNode . scrollTop -= 40 ; const jl = document . getElementById ( 'jumpLatest' ); if ( jl ) jl . style . display = 'grid' ; }
}
2026-06-23 16:15:29 +05:30
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 ){
2026-07-08 15:58:09 +05:30
// #1: make URLs clickable (on the already-escaped text). Trailing punctuation is left outside the link.
s = s . replace ( /(https?:\/\/[^\s<]+|www\.[^\s<]+)/g ,( u )=>{ let tail = '' ; const mt = u . match ( /[.,;:!?)\]}'"]+$/ ); if ( mt ){ tail = mt [ 0 ]; u = u . slice ( 0 , - tail . length ); } const href = /^https?:\/\//i . test ( u ) ? u : ( 'https://' + u ); return '<a href="' + href + '" target="_blank" rel="noopener noreferrer" class="msg-link">' + u + '</a>' + tail ; });
2026-06-23 16:15:29 +05:30
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 ();
2026-07-03 17:17:03 +05:30
stopTyping (); // leaving the previous conversation → tell that peer we stopped
2026-07-07 16:09:02 +05:30
if ( _selMode ) exitSelect (); // #1: leave forward-selection when changing chats
2026-06-23 16:15:29 +05:30
selected = { kind , id };
document . body . classList . add ( 'chat-open' ); // mobile: show the conversation pane
2026-06-30 17:01:15 +05:30
const it = rowFor ( kind , id ); _openUnread = ( it && it . unread ) || 0 ; if ( it ) it . unread = 0 ; // capture before reset (#3: open at first unread)
2026-06-23 16:15:29 +05:30
renderChats ( searchVal ());
updateRailUnread ();
await openConvo ( kind , id );
}
async function sendMessage (){
const inp = document . getElementById ( 'msgInput' ); if ( ! inp ) return ;
2026-07-02 17:45:20 +05:30
const text = inp . value . trim ();
if ( editTarget ){ saveEdit ( text ); return ; } // in edit mode → save the edit instead of sending new
2026-07-07 16:02:15 +05:30
const atts = pendingAttachs . slice ();
if ( atts . some ( a => a . uploading )){ toast ( 'Please wait — files are still uploading…' ); return ; }
if (( ! text &&! atts . length ) ||! selected ) return ;
2026-07-03 17:17:03 +05:30
stopTyping (); // sending → we're no longer "typing"
2026-07-02 11:55:14 +05:30
inp . value = '' ; inp . style . height = 'auto' ; setDraft ( selected . kind , selected . id , '' ); // sent → clear the draft
2026-07-07 16:02:15 +05:30
const replyTo = replyTarget ? replyTarget . id : null ; const isG = selected . kind === 'group' ;
hideAttach (); clearReply (); cancelEdit ();
// #5: one message per attachment (the first carries the typed text as its caption), else a single
// text message. Sent in order so they appear chronologically.
const jobs = atts . length ? atts . map (( a , i )=>({ body : i === 0 ? text : '' , replyTo : i === 0 ? replyTo : null , attachmentId : a . id })) : [{ body : text , replyTo , attachmentId : null }];
for ( const j of jobs ){
const payload = isG ? { group : selected . id , body : j . body , replyTo : j . replyTo , attachmentId : j . attachmentId , mentions : collectMentions ( j . body ) } : { to : selected . id , body : j . body , replyTo : j . replyTo , attachmentId : j . attachmentId };
try {
const m = await postJSON ( '/api/messages' , payload );
// 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 ); }
{ 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 . last_status = 'sent' ; it . last_msg_id = m . id ; it . unread = 0 ; }
} catch ( e ){ toast ( e . message || 'Could not send' ); if ( j . body && ! inp . value ){ inp . value = j . body ; try { autoGrow ( inp ); } catch ( _ ){} } }
}
composeMentions = new Map ();
renderChats ( searchVal ());
2026-06-23 16:15:29 +05:30
}
2026-06-30 17:01:15 +05:30
// Request permission from a user gesture (e.g. opening a chat) AND subscribe on grant — the
// subscribe-on-grant is essential on iOS, where permission is granted in-session and push
// won't work until a subscription exists.
2026-06-30 19:56:59 +05:30
function ensureNotifyPermission (){ try { const p = nativePlatform (); if ( p === 'ios' || p === 'android' ) return ; if ( 'Notification' in window && Notification . permission === 'default' ) Notification . requestPermission (). then ( r =>{ if ( r === 'granted' ){ try { subscribePush (); } catch ( _ ){} } }). catch (()=>{}); } catch ( _ ){} }
2026-06-30 17:01:15 +05:30
// Notification preferences (set in Settings; stored per browser). Default ON.
2026-06-23 16:15:29 +05:30
function notifOn ( kind ){ try { return localStorage . getItem ( 'notif_' + kind ) !== 'off' ; } catch ( _ ){ return true ; } }
2026-06-30 17:01:15 +05:30
// Mobile has no top bar (#7): keep bell+profile in the chat-list header; desktop keeps them in
// the top header. Re-placed on load and on resize. Moving the node preserves its wiring.
function placeHdrRight (){
const hr = document . getElementById ( 'hdrRight' ); if ( ! hr ) return ;
// Desktop: bell+profile sit at the BOTTOM of the left rail (a clear, persistent spot).
// Mobile: top-right of the chat-list header (the main screen).
if ( window . matchMedia ( '(max-width:760px)' ). matches ){ const st = document . querySelector ( '#chatcol .side-title' ); if ( st && hr . parentElement !== st ) st . appendChild ( hr ); }
else { const rail = document . getElementById ( 'rail' ); if ( rail && hr . parentElement !== rail ) rail . appendChild ( hr ); }
}
// Explicit, dismissible prompt when permission is still 'default'. This is the reliable path on
// iOS (permission must be requested from a tap inside the installed PWA).
function maybeNotifPrompt (){
try {
2026-06-30 19:56:59 +05:30
const p = nativePlatform (); if ( p === 'ios' || p === 'android' ) return ; // native app handles its own permission flow
2026-06-30 17:01:15 +05:30
if ( ! ( 'Notification' in window ) || Notification . permission !== 'default' ) return ;
if ( sessionStorage . getItem ( 'notifPromptDismissed' ) === '1' || document . getElementById ( 'notifPrompt' )) return ;
const b = document . createElement ( 'div' ); b . id = 'notifPrompt' ; b . className = 'notif-prompt' ;
b . innerHTML = '<span>' + ic ( 'bell' , 16 ) + ' Turn on notifications so you don’ t miss messages & calls.</span><span class="np-actions"><button class="btn sm" id="npEnable">Enable</button><button class="np-x" id="npX" aria-label="Dismiss">' + ic ( 'x' , 16 ) + '</button></span>' ;
document . body . appendChild ( b );
document . getElementById ( 'npEnable' ). onclick = async ()=>{ try { const r = await Notification . requestPermission (); if ( r === 'granted' ){ toast ( 'Notifications enabled' ); await subscribePush (); } else { toast ( 'Notifications blocked — allow them in your browser/site settings' ); } } catch ( _ ){ toast ( 'Notifications need HTTPS' ); } b . remove (); };
document . getElementById ( 'npX' ). onclick = ()=>{ try { sessionStorage . setItem ( 'notifPromptDismissed' , '1' ); } catch ( _ ){} b . remove (); };
} catch ( _ ){}
}
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 ; }
2026-06-30 19:56:59 +05:30
// ----- Native push (Capacitor app: FCM on Android, APNs on iOS) -----
// In a native shell, Capacitor injects window.Capacitor and exposes the PushNotifications
// plugin. We register the OS device token with /api/v1/devices so the server can deliver via
// FCM/APNs (see server/push.js). No bundler needed — plugins live on Capacitor.Plugins.
function capPlugin ( name ){ const C = window . Capacitor ; return ( C && C . isNativePlatform && C . isNativePlatform () && C . Plugins && C . Plugins [ name ]) ? C . Plugins [ name ] : null ; }
2026-07-08 11:55:30 +05:30
function nativePlatform (){ const C = window . Capacitor ; if ( C && C . getPlatform ){ const p = C . getPlatform (); if ( p === 'ios' || p === 'android' ) return p ; } if ( window . __NATIVE__ === 'desktop' || ( window . bizConnectNative && window . bizConnectNative . platform === 'desktop' )) return 'desktop' ; return '' ; } // also detect older desktop builds that expose bizConnectNative but not __NATIVE__ (#3: those wrongly subscribed Web Push → SW openWindow reloaded on notification click)
2026-07-01 21:28:53 +05:30
// Record this install against the signed-in user (desktop shell exposes a stable installId).
// Fires post-login; the request carries the session so admins can see who installed the app.
function reportInstall (){
try { const n = window . bizConnectNative ; if ( ! n ||! n . installId ) return ;
postJSON ( '/api/v1/telemetry/install' ,{ installId : n . installId , platform : n . platform || 'desktop' , appVersion : n . version || '' , os : n . os || '' }). catch (()=>{});
} catch ( _ ){}
}
2026-06-30 19:56:59 +05:30
let _nativeTok = null ;
async function setupNativePush (){
const PN = capPlugin ( 'PushNotifications' ); const plat = nativePlatform ();
if ( ! PN || ( plat !== 'ios' && plat !== 'android' )) return false ; // not a mobile native app
try {
PN . addListener ( 'registration' , async ( t )=>{ const token = t && t . value ; if ( ! token ) return ; _nativeTok = token ;
try { await postJSON ( '/api/v1/devices' ,{ platform : plat , token }); pushActive = true ; console . log ( '[push] native device registered' ); }
catch ( e ){ console . warn ( '[push] device register failed:' , e ); } });
PN . addListener ( 'registrationError' , ( e )=> console . warn ( '[push] native registration error:' , e ));
// Tapping an OS notification opens the relevant chat (payload carries kind+id).
PN . addListener ( 'pushNotificationActionPerformed' , ( a )=>{ try { const d = ( a && a . notification && a . notification . data ) || {}; if ( d . id ) selectChat ( d . kind || 'dm' , d . id ); } catch ( _ ){} });
let perm = await PN . checkPermissions ();
if ( perm . receive !== 'granted' ) perm = await PN . requestPermissions ();
if ( perm . receive !== 'granted' ){ console . log ( '[push] native permission not granted' ); return true ; }
await PN . register ();
console . log ( '[push] native push registered' );
} catch ( e ){ console . warn ( '[push] native push setup failed:' , e ); }
return true ; // handled the native path (skip Web Push regardless of outcome)
}
2026-06-23 21:58:49 +05:30
async function setupPush (){
2026-07-02 15:43:02 +05:30
if ( await setupNativePush ()) return ; // mobile native app → FCM/APNs, not Web Push
// Desktop shell: the in-page bridge already shows a native toast (notify → replyNotify) while the
// app runs, so ALSO subscribing to Web Push made every message pop TWICE (in-app toast + SW push,
// with different icons). Drop any Web Push subscription here so only the branded native toast fires.
if ( nativePlatform () === 'desktop' ){ try { await unsubscribePush (); } catch ( _ ){} console . log ( '[push] desktop shell → native toasts only, skipping Web Push' ); return ; }
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
}
2026-06-30 17:01:15 +05:30
// On logout, remove this device's push subscription so notifications STOP for the logged-out user.
async function unsubscribePush (){
2026-06-30 19:56:59 +05:30
// Native app: drop this device's FCM/APNs token so push stops for the logged-out user.
try { if ( _nativeTok ){ await postJSON ( '/api/v1/devices/remove' ,{ token : _nativeTok }); _nativeTok = null ; pushActive = false ; return ; } } catch ( _ ){}
2026-06-30 17:01:15 +05:30
try {
if ( ! _swReg ){ try { _swReg = await navigator . serviceWorker . getRegistration (); } catch ( _ ){} }
if ( ! _swReg ) return ;
const sub = await _swReg . pushManager . getSubscription ();
if ( sub ){ try { await postJSON ( '/api/push/unsubscribe' ,{ endpoint : sub . endpoint }); } catch ( _ ){} try { await sub . unsubscribe (); } catch ( _ ){} }
pushActive = false ; console . log ( '[push] unsubscribed (logout)' );
} catch ( _ ){}
}
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.
2026-07-07 15:54:28 +05:30
function openFromNotif ( kind , id ){
try { if ( window . bizConnectNative && window . bizConnectNative . focusApp ) window . bizConnectNative . focusApp (); } catch ( _ ){}
try { window . focus (); } catch ( _ ){}
// The app is already running (the notification came from it) → open the chat IN-PLACE. A full-page
// reload was slow (#19) and dropped live state: the sidebar hadn't reloaded so the header showed
// "Conversation" with no DP (#7), and an incoming call's Join/invite popup was lost (#2).
2026-07-08 18:00:56 +05:30
// NB: use bare `ME`, NOT `window.ME` — ME is declared with `let`, which does NOT create a global
// property, so `window.ME` was always undefined and EVERY click hit the reload fallback below.
if ( ME && ME . id && typeof selectChat === 'function' ){
2026-07-07 15:54:28 +05:30
if ( id ){ try { switchTab ( 'chat' ); } catch ( _ ){} try { selectChat ( kind || 'dm' , id ); } catch ( _ ){} }
return ;
}
// Fallback: app not initialised yet → navigate (boot reads openKind/openId and opens it).
location . assign ( id ? ( '/home?openKind=' + encodeURIComponent ( kind || '' ) + '&openId=' + encodeURIComponent ( id || '' )) : '/home' );
}
2026-07-01 23:26:24 +05:30
// Draw the conversation's avatar (DM = sender pic, group = group DP; else colored initials) to
// a round PNG data URL for the desktop toast image.
function notifAvatarDataUrl ( kind , id , fallbackName ){
return new Promise (( resolve )=>{
try {
const row = rowFor ( kind , id ) || {}; const src = row . avatar || '' ; const nm = row . name || fallbackName || '?' ;
const s = 96 , c = document . createElement ( 'canvas' ); c . width = s ; c . height = s ; const g = c . getContext ( '2d' );
const initialsPng = ()=>{ try { g . clearRect ( 0 , 0 , s , s ); g . fillStyle = avColor ( nm ); g . beginPath (); g . arc ( s / 2 , s / 2 , s / 2 , 0 , 2 * Math . PI ); g . fill (); g . fillStyle = '#334155' ; g . font = 'bold 40px system-ui,Segoe UI,sans-serif' ; g . textAlign = 'center' ; g . textBaseline = 'middle' ; g . fillText ( initials ( nm ), s / 2 , s / 2 + 2 ); resolve ( c . toDataURL ( 'image/png' )); } catch ( _ ){ resolve ( null ); } };
if ( src ){ const img = new Image (); img . crossOrigin = 'anonymous' ; let settled = false ; const done = ( fn )=>{ if ( ! settled ){ settled = true ; fn (); } };
img . onload = ()=> done (()=>{ try { g . save (); g . beginPath (); g . arc ( s / 2 , s / 2 , s / 2 , 0 , 2 * Math . PI ); g . clip (); g . drawImage ( img , 0 , 0 , s , s ); g . restore (); resolve ( c . toDataURL ( 'image/png' )); } catch ( _ ){ initialsPng (); } });
img . onerror = ()=> done ( initialsPng ); img . src = src ; setTimeout (()=> done ( initialsPng ), 1500 );
} else initialsPng ();
} catch ( _ ){ resolve ( null ); }
});
}
2026-07-01 22:36:02 +05:30
// Reply to a conversation without opening it (used by the notification quick-reply).
async function sendReplyTo ( kind , id , text ){ try { await postJSON ( '/api/messages' , kind === 'group' ? { group : id , body : text } : { to : id , body : text }); } catch ( _ ){} }
2026-07-08 16:56:39 +05:30
// #3: desktop auto-update — a progress banner (branded restart) + an update indicator that cascades
// from the profile → Settings → version, so the user always knows a newer version is available.
let _pendingUpdate = null ; // {phase, version}
2026-07-08 15:58:09 +05:30
function wireUpdateBanner (){
const n = window . bizConnectNative ; if ( ! ( n && n . onUpdateEvent )) return ;
n . onUpdateEvent (( d )=>{
if ( ! d ||! d . phase ) return ;
2026-07-08 16:56:39 +05:30
if ( d . phase === 'available' || d . phase === 'downloading' || d . phase === 'ready' ) _pendingUpdate = { phase : d . phase , version : d . version || ( _pendingUpdate && _pendingUpdate . version ) || '' };
else if ( d . phase === 'current' ) _pendingUpdate = null ;
applyUpdateBadge ();
2026-07-08 15:58:09 +05:30
let el = document . getElementById ( 'updBanner' );
const ensure = ()=>{ if ( ! el ){ el = document . createElement ( 'div' ); el . id = 'updBanner' ; el . className = 'upd-banner' ; document . body . appendChild ( el ); } return el ; };
2026-07-08 16:56:39 +05:30
if ( d . phase === 'available' ){ ensure (). innerHTML = ic ( 'download' , 15 ) + ' <span>Downloading update' + ( d . version ? ( ' ' + pEsc ( d . version )) : '' ) + '…</span>' ; el . classList . add ( 'show' ); el . classList . remove ( 'ready' ); }
else if ( d . phase === 'downloading' ){ ensure (). innerHTML = ic ( 'download' , 15 ) + ' <span>Downloading update… ' + ( d . percent || 0 ) + '%</span>' ; el . classList . add ( 'show' ); el . classList . remove ( 'ready' ); }
2026-07-08 15:58:09 +05:30
else if ( d . phase === 'ready' ){ ensure (). innerHTML = ic ( 'check' , 15 ) + ' <span>Update ready</span> <button id="updRestart">Restart now</button>' ; el . classList . add ( 'show' , 'ready' ); const b = el . querySelector ( '#updRestart' ); if ( b ) b . onclick = ()=>{ try { n . restartToUpdate && n . restartToUpdate (); } catch ( _ ){} }; }
else if ( d . phase === 'current' || d . phase === 'error' ){ if ( el ){ el . classList . remove ( 'show' ); setTimeout (()=>{ if ( el &&! el . classList . contains ( 'show' )) el . remove (); }, 400 ); } }
});
}
2026-07-08 16:56:39 +05:30
// Small 'i' badge on the profile button when an update is pending; clicking opens Settings.
function applyUpdateBadge (){
document . querySelectorAll ( '.upd-dot' ). forEach ( x => x . remove ());
if ( ! _pendingUpdate ) return ;
const host = document . getElementById ( 'pbtn' ); if ( ! host ) return ;
const dot = document . createElement ( 'span' ); dot . className = 'upd-dot' ; dot . title = 'Update available — open Settings' ; dot . textContent = 'i' ;
host . appendChild ( dot );
}
2026-07-07 16:20:18 +05:30
// #13: track shown page-notifications by conversation tag so reading elsewhere can dismiss them.
const _shownNotifs = {};
function onNotifClear ( d ){
if ( ! d ) return ; const tag = ( d . kind || '' ) + ':' + ( d . id || '' );
try { const n = _shownNotifs [ tag ]; if ( n ){ n . close (); } delete _shownNotifs [ tag ]; } catch ( _ ){}
// Also close any Service-Worker (Web Push) notifications for this chat.
try { if ( 'serviceWorker' in navigator && navigator . serviceWorker . getRegistration ) navigator . serviceWorker . getRegistration (). then ( reg =>{ if ( reg && reg . getNotifications ) reg . getNotifications ({ tag }). then ( ns => ns . forEach ( n =>{ try { n . close (); } catch ( _ ){} })); }). catch (()=>{}); } catch ( _ ){}
// Drop matching activity-center entries so the bell badge stays in sync.
try { if ( Array . isArray ( NOTIFS )){ const before = NOTIFS . length ; NOTIFS = NOTIFS . filter ( x => ! ( x . link && x . link . kind === d . kind && x . link . id === d . id )); if ( NOTIFS . length !== before ){ saveNotifs && saveNotifs (); updateBellBadge && updateBellBadge (); } } } catch ( _ ){}
}
2026-07-10 13:56:18 +05:30
// Draw a round initials avatar to a PNG data URL (synchronous — no external image, so it never taints
// the canvas). Used as the desktop toast icon when a chat/group has no photo, so every toast shows one.
function initialsAvatarDataUrl ( name , isGroup ){
try {
const s = 96 , c = document . createElement ( 'canvas' ); c . width = s ; c . height = s ; const g = c . getContext ( '2d' );
g . beginPath (); g . arc ( s / 2 , s / 2 , s / 2 , 0 , 2 * Math . PI ); g . fillStyle = isGroup ? '#1F3B73' : avColor ( name || '?' ); g . fill ();
g . fillStyle = isGroup ? '#fff' : '#334155' ; g . font = 'bold 40px system-ui,Segoe UI,sans-serif' ; g . textAlign = 'center' ; g . textBaseline = 'middle' ;
g . fillText ( initials ( name || '?' ), s / 2 , s / 2 + 2 );
return c . toDataURL ( 'image/png' );
} catch ( _ ){ return null ; }
}
2026-07-08 15:43:07 +05:30
function notify ( title , body , kind , id , opts ){
const persistent =!! ( opts && opts . persistent );
2026-06-23 16:15:29 +05:30
try {
2026-07-02 18:01:32 +05:30
// Desktop app: native toast with the sender/group DP. Pass the DP URL directly — the shell
// downloads it for the icon (drawing an external DP to a canvas here tainted it → initials).
2026-07-01 22:36:02 +05:30
if ( window . bizConnectNative && window . bizConnectNative . replyNotify ){
2026-07-10 13:56:18 +05:30
const _row = rowFor ( kind , id ) || {};
// Groups (and contacts) with no photo URL had NO toast icon at all. Draw a generated icon
// (initials on a brand-tinted disc — no external image, so no canvas taint) so every toast,
// especially group messages, carries a recognizable avatar.
let _av = _row . avatar || null ;
if ( ! _av ){ try { _av = initialsAvatarDataUrl ( _row . name || title , kind === 'group' ); } catch ( _ ){ _av = null ; } }
2026-07-08 15:43:07 +05:30
Promise . resolve ( window . bizConnectNative . replyNotify ({ title , body , kind , id , avatar : _av , persistent }))
2026-07-01 23:26:24 +05:30
. then ( r =>{ if ( ! r ) return ;
if ( r . text ) sendReplyTo ( kind , id , r . text ); // replied from the toast
else if ( r . open ) openFromNotif ( kind , id ); // clicked the toast
}). catch (()=>{});
2026-07-01 22:36:02 +05:30
return ;
}
2026-07-02 17:39:38 +05:30
// Web / PWA: standard Notification. Use the sender's/group's DP URL DIRECTLY as the icon —
// the browser fetches notification icons cross-origin fine, whereas drawing an external DP to a
// canvas taints it and silently fell back to initials (the bug). Brand icon only if no DP.
2026-06-23 16:15:29 +05:30
if ( ! ( 'Notification' in window ) || Notification . permission !== 'granted' ) return ;
2026-07-02 17:39:38 +05:30
const _row = rowFor ( kind , id ) || {};
2026-07-07 16:20:18 +05:30
const tag = ( kind || '' ) + ':' + ( id || '' ); // #13: tag by conversation so it can be dismissed cross-device
const n = new Notification ( title , { body , icon : _row . avatar || '/icon-192.png' , badge : '/icon-192.png' , tag });
_shownNotifs [ tag ] = n ;
n . onclick = ()=>{ n . close (); delete _shownNotifs [ tag ]; openFromNotif ( kind , id ); };
n . onclose = ()=>{ if ( _shownNotifs [ tag ] === n ) delete _shownNotifs [ tag ]; };
2026-07-02 17:39:38 +05:30
setTimeout (()=>{ try { n . close (); } catch ( _ ){} }, 8000 );
2026-06-23 16:15:29 +05:30
} catch ( _ ){}
}
let _audioCtx = null ;
2026-06-30 17:01:15 +05:30
// Message chime: a crisp, recognizable rising two-note ("ti-doo") — louder + brighter than the
// old single beep so it's catchy and noticed.
2026-06-23 16:15:29 +05:30
function playPing (){
try {
_audioCtx = _audioCtx || new ( window . AudioContext || window . webkitAudioContext )();
if ( _audioCtx . state === 'suspended' ) _audioCtx . resume ();
2026-06-30 17:01:15 +05:30
const t = _audioCtx . currentTime ;
const tone = ( f , start , dur , peak )=>{ const o = _audioCtx . createOscillator (), g = _audioCtx . createGain (); o . type = 'triangle' ; o . frequency . value = f ; g . gain . setValueAtTime ( 0.0001 , t + start ); g . gain . exponentialRampToValueAtTime ( peak , t + start + 0.015 ); g . gain . exponentialRampToValueAtTime ( 0.0001 , t + start + dur ); o . connect ( g ); g . connect ( _audioCtx . destination ); o . start ( t + start ); o . stop ( t + start + dur + 0.03 ); };
tone ( 784.0 , 0.00 , 0.16 , 0.34 ); // G5
tone ( 1174.7 , 0.11 , 0.40 , 0.34 ); // D6 — rises, rings out -> "ti-doo"
2026-06-23 16:15:29 +05:30
} 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 ;
2026-06-30 17:01:15 +05:30
ringTone ( t , 523.25 , 1.2 , 0.24 ); // C5 (louder so an incoming call is clearly heard)
ringTone ( t + 0.20 , 659.25 , 1.2 , 0.22 ); // E5
ringTone ( t + 0.40 , 783.99 , 1.5 , 0.24 ); // G5
ringTone ( t + 0.60 , 1046.5 , 1.9 , 0.20 ); // C6 — rings out
2026-06-23 16:15:29 +05:30
} 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 ; } }
2026-07-07 15:54:28 +05:30
// #17 Outgoing ringback — the CALLER hears a gentle periodic ring while waiting for the other side to
// pick up. Stops as soon as a peer joins, or the call ends.
let _ringbackTimer = null , _ringbackStop = null ;
function ringbackOnce (){ try { _audioCtx = _audioCtx || new ( window . AudioContext || window . webkitAudioContext )(); if ( _audioCtx . state === 'suspended' ) _audioCtx . resume (); const t = _audioCtx . currentTime ; ringTone ( t , 440 , 1.1 , 0.11 ); ringTone ( t + 0.16 , 480 , 1.1 , 0.11 ); } catch ( _ ){} }
function startRingback (){ if ( _ringbackTimer ) return ; ringbackOnce (); _ringbackTimer = setInterval ( ringbackOnce , 3000 ); _ringbackStop = setTimeout ( stopRingback , 45000 ); /* never ring forever if unanswered/declined */ }
function stopRingback (){ if ( _ringbackTimer ){ clearInterval ( _ringbackTimer ); _ringbackTimer = null ; } if ( _ringbackStop ){ clearTimeout ( _ringbackStop ); _ringbackStop = null ; } }
2026-06-23 16:15:29 +05:30
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 );
2026-07-03 17:17:03 +05:30
if ( m . from && m . from !== ME . id ) clearTypingFrom ( kind , rid , m . from ); // their message landed → drop their "typing…"
2026-06-23 16:15:29 +05:30
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.
2026-07-10 13:25:37 +05:30
if ( notifOn ( kind )){ playPing (); const wantPopup =! ( isOpen && ! document . hidden );
if ( wantPopup && ! ( document . hidden && pushActive )){
const prev = m . body ? ( m . body . length > 80 ? m . body . slice ( 0 , 80 ) + '…' : m . body ) : 'Sent an attachment' ;
// Group: title = GROUP name, body = "Sender: message" (so you know which group it's from).
if ( kind === 'group' ) notify (( it && it . name ) || m . groupName || 'Group' , ( m . fromName ? m . fromName + ': ' : '' ) + prev , kind , rid );
else notify (( m . fromName || 'New message' ), prev , 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 ){
2026-07-02 16:17:30 +05:30
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 );
// #6: the server echoes my own sent message back — don't let that echo DOWNGRADE a delivered/read
// tick that already arrived for this same message (it would flip the list back to a single tick).
if ( m . from === ME . id ){ if ( ! ( it . last_msg_id === m . id && ( it . last_status === 'delivered' || it . last_status === 'read' ))) it . last_status = 'sent' ; it . last_msg_id = m . id ; }
else it . last_status = null ;
2026-06-23 16:15:29 +05:30
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 )){
2026-07-02 15:43:02 +05:30
if ( window . BZToast ) BZToast . message ( m . body ? ( m . body . length > 60 ? m . body . slice ( 0 , 60 ) + '…' : m . body ) : '📎 Attachment' , { title : ( m . fromName || 'New message' )});
2026-06-23 16:15:29 +05:30
}
renderChats ( searchVal ()); updateRailUnread ();
}
2026-07-07 11:46:22 +05:30
let chatWs = null , _chatConnectedOnce = false ;
// After a socket DROP + reconnect, this client may have missed messages/ticks while offline — and
// the desktop/mobile apps can't just be refreshed to recover. So on every RE-connect we re-pull the
// sidebar and re-open the current conversation (fresh thread + receipts). The first connect is
// handled by boot's loadSidebar, so we skip the resync then.
function resyncChat (){
try { loadSidebar (); } catch ( _ ){}
2026-07-07 12:41:12 +05:30
refreshOpenThread (); // NON-destructive catch-up (never wipes the open thread — see below)
}
// Re-fetch the open conversation and MERGE: add messages missed while disconnected + refresh
// read/delivered/seen/edited/deleted flags, but NEVER remove messages already shown. openConvo
// replaced THREAD wholesale, which could momentarily blank a chat (e.g. a just-sent message) if the
// reload raced or returned stale — the cause of the "messages disappear after sending" report.
async function refreshOpenThread (){
if ( ! selected || currentTab () !== 'chat' ) return ;
const kind = selected . kind , id = selected . id ;
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 ( _ ){ return ; }
if ( ! Array . isArray ( msgs )) return ;
if ( ! selected || selected . kind !== kind || selected . id !== id ) return ; // switched away while fetching
const byId = new Map ( THREAD . map ( m =>[ m . id , m ])); let changed = false ;
msgs . forEach ( nm =>{ const ex = byId . get ( nm . id );
if ( ! ex ){ THREAD . push ( nm ); changed = true ; return ; }
if ( nm . read_at &&! ex . read_at ){ ex . read_at = nm . read_at ; changed = true ; }
if ( nm . delivered_at &&! ex . delivered_at ){ ex . delivered_at = nm . delivered_at ; changed = true ; }
if ( nm . deleted &&! ex . deleted ){ ex . deleted = true ; ex . body = '' ; ex . attachment = null ; ex . reactions = []; changed = true ; }
if ( nm . edited_at && nm . edited_at !== ex . edited_at ){ ex . edited_at = nm . edited_at ; ex . body = nm . body ; changed = true ; }
if ( JSON . stringify ( nm . seenBy || []) !== JSON . stringify ( ex . seenBy || [])){ ex . seenBy = nm . seenBy || []; changed = true ; }
});
if ( changed ){ THREAD . sort (( a , b )=>( a . created_at || 0 ) - ( b . created_at || 0 )); THREAD_CACHE . set ( kind + ':' + id , THREAD . slice ()); renderThread (); }
2026-07-07 11:46:22 +05:30
}
2026-07-07 13:09:24 +05:30
let _chatReconnectT = null ;
2026-06-23 16:15:29 +05:30
function connectChatWs (){
try {
2026-07-07 13:09:24 +05:30
// Defensive: drop any prior socket first so a flaky reconnect can't leave TWO live sockets —
// which would deliver every event (and notification) twice. Detach its handlers before closing
// so its onclose doesn't schedule yet another reconnect.
if ( chatWs ){ try { chatWs . onopen = chatWs . onmessage = chatWs . onclose = chatWs . onerror = null ; chatWs . close (); } catch ( _ ){} chatWs = null ; }
if ( _chatReconnectT ){ clearTimeout ( _chatReconnectT ); _chatReconnectT = null ; }
2026-06-23 16:15:29 +05:30
chatWs = new WebSocket (( location . protocol === 'https:' ? 'wss://' : 'ws://' ) + location . host + '/ws' );
2026-07-07 11:46:22 +05:30
chatWs . onopen = ()=>{ try { chatWs . send ( JSON . stringify ({ type : 'chat-hello' })); } catch ( _ ){} if ( _chatConnectedOnce ) resyncChat (); _chatConnectedOnce = true ; };
2026-07-07 16:20:18 +05:30
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-deleted' ) onChatDeleted ( d ); else if ( d . type === 'chat-edited' ) onChatEdited ( d ); 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 === 'presence' ) onPresence ( d ); else if ( d . type === 'chat-typing' ) onTyping ( d ); else if ( d . type === 'notif-clear' ) onNotifClear ( 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 ); };
2026-07-07 13:09:24 +05:30
chatWs . onclose = ()=>{ if ( _chatReconnectT ) clearTimeout ( _chatReconnectT ); _chatReconnectT = setTimeout ( connectChatWs , 3000 ); }; // auto-reconnect (single pending timer)
2026-06-23 16:15:29 +05:30
} catch ( _ ){}
}
2026-07-03 17:17:03 +05:30
// ---- Typing indicators (ephemeral, over the chat socket) ----------------------------------
// Outgoing: while you type in the open conversation we send `chat-typing on` (throttled to once
// per ~2.5s), and `off` after 4s idle / on send / on leaving the chat.
let _typingOn = false , _typingIdleT = null , _lastTypingSent = 0 ;
function _cssEsc ( s ){ try { return CSS . escape ( String ( s )); } catch ( _ ){ return String ( s ). replace ( /[^\w-]/g , '\\$&' ); } }
function _typingSend ( on ){
if ( ! chatWs || chatWs . readyState !== 1 ||! selected || selected . self ) return ;
const t = selected . kind === 'group' ? { group : selected . id } : { to : selected . id };
try { chatWs . send ( JSON . stringify ( Object . assign ({ type : 'chat-typing' , on :!! on }, t ))); } catch ( _ ){}
}
function noteTyping (){ // call on every composer keystroke
if ( ! selected || selected . self ) return ;
const now = Date . now ();
if ( ! _typingOn || now - _lastTypingSent > 2500 ){ _typingOn = true ; _lastTypingSent = now ; _typingSend ( true ); }
clearTimeout ( _typingIdleT ); _typingIdleT = setTimeout ( stopTyping , 4000 );
}
function stopTyping (){ clearTimeout ( _typingIdleT ); if ( _typingOn ){ _typingOn = false ; _typingSend ( false ); } }
// Incoming: per-conversation set of who's currently typing, each with an auto-expiry so a dropped
// 'off' can't leave the indicator stuck. Keyed 'dm:<userId>' or 'group:<groupId>'.
const TYPING = {};
function _tKey ( kind , id ){ return kind + ':' + id ; }
function onTyping ( d ){
if ( ! d ||! d . from || d . from === ME . id ) return ; // ignore our own echo (shouldn't happen, but be safe)
const kind = d . group ? 'group' : 'dm' , id = d . group || d . from , key = _tKey ( kind , id );
let m = TYPING [ key ]; if ( ! m ){ m = new Map (); TYPING [ key ] = m ; }
const cur = m . get ( d . from ); if ( cur && cur . t ) clearTimeout ( cur . t );
if ( d . on ){ const t = setTimeout (()=>{ m . delete ( d . from ); if ( ! m . size ) delete TYPING [ key ]; refreshTyping ( kind , id ); }, 6000 ); m . set ( d . from ,{ name : d . name || '' , t }); }
else { m . delete ( d . from ); if ( ! m . size ) delete TYPING [ key ]; }
refreshTyping ( kind , id );
}
function clearTypingFrom ( kind , id , userId ){ // a real message arrived → they stopped typing
const key = _tKey ( kind , id ), m = TYPING [ key ]; if ( ! m ) return ;
const cur = m . get ( userId ); if ( cur && cur . t ) clearTimeout ( cur . t );
m . delete ( userId ); if ( ! m . size ) delete TYPING [ key ]; refreshTyping ( kind , id );
}
function typingLabel ( kind , id ){
const m = TYPING [ _tKey ( kind , id )]; if ( ! m ||! m . size ) return '' ;
if ( kind === 'dm' ) return 'typing…' ;
const names = [... m . values ()]. map ( v => firstName ( v . name || 'Someone' ));
if ( names . length === 1 ) return names [ 0 ] + ' is typing…' ;
if ( names . length === 2 ) return names [ 0 ] + ' and ' + names [ 1 ] + ' are typing…' ;
return names . length + ' people are typing…' ;
}
function refreshTyping ( kind , id ){
const it = rowFor ( kind , id ), lbl = typingLabel ( kind , id );
2026-07-07 11:46:22 +05:30
if ( selected && selected . kind === kind && selected . id === id ){ // open conversation → header subtitle + in-panel row
2026-07-03 17:17:03 +05:30
const st = document . querySelector ( '#convoTitle .st' );
if ( st ){ if ( lbl ){ st . textContent = lbl ; st . classList . add ( 'typing' ); } else { st . classList . remove ( 'typing' ); st . textContent = subtitleFor ( it || selected ); } }
2026-07-07 11:46:22 +05:30
const tr = document . getElementById ( 'typingRow' );
if ( tr ){ if ( lbl ){ tr . innerHTML = '<span class="tdots"><i></i><i></i><i></i></span> ' + pEsc ( lbl ); tr . classList . add ( 'show' ); } else { tr . classList . remove ( 'show' ); tr . innerHTML = '' ; } }
2026-07-03 17:17:03 +05:30
}
if ( listEl ){ // sidebar row preview
const row = listEl . querySelector ( '.chat-row[data-kind="' + kind + '"][data-id="' + _cssEsc ( id ) + '"]' );
const prev = row && row . querySelector ( '.chat-prev' );
if ( prev ){ if ( lbl ){ prev . innerHTML = '<span class="typing-prev">' + pEsc ( lbl ) + '</span>' ; } else if ( it ){ prev . innerHTML = rowPreviewHTML ( it ); } }
}
}
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">'
2026-06-30 17:01:15 +05:30
+ ( CONTACTS . length ? '<div class="gi-search" style="margin:.55rem 0 .35rem"><input id="grpSearch" placeholder="Search people…" autocomplete="off"></div>' : '' )
+ '<div class="grp-members">' + ( CONTACTS . length ? CONTACTS . map ( c => '<label class="chk" data-name="' + pEsc (( c . name || '' ). toLowerCase ()) + '"><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="gi-noresult" id="grpNoResult" style="display:none">No people found</div>'
2026-06-23 16:15:29 +05:30
+ '<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 ;
2026-06-30 17:01:15 +05:30
const gs = document . getElementById ( 'grpSearch' );
if ( gs ) gs . oninput = ()=>{ const q = gs . value . trim (). toLowerCase (); let shown = 0 ; ov . querySelectorAll ( '.grp-members .chk' ). forEach ( l =>{ const vis =! q || ( l . dataset . name || '' ). includes ( q ); l . style . display = vis ? '' : 'none' ; if ( vis ) shown ++ ; }); const nr = document . getElementById ( 'grpNoResult' ); if ( nr ) nr . style . display = shown ? 'none' : 'block' ; };
2026-06-23 16:15:29 +05:30
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">'
2026-06-30 17:01:15 +05:30
+ '<div class="gi-name-row" id="giView"><span class="gi-title">' + pEsc ( info . name ) + '</span><button class="fav-star' + ((( rowFor ( 'group' , gid ) || {}). favorite ) ? ' on' : '' ) + '" id="giFav" title="Favourite">' + ic ( 'star' , 15 ) + '</button><button class="iconbtn sm" id="giEdit" title="Rename group">' + ic ( 'pencil' , 15 ) + '</button></div>'
2026-06-23 16:15:29 +05:30
+ '<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>' : '' )
2026-06-30 17:01:15 +05:30
+ '<div class="gi-sec-h"><span>Shared</span></div>' + mediaRowHTML ()
2026-06-23 16:15:29 +05:30
+ '<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 );
2026-06-30 17:01:15 +05:30
wireMediaEntry ( ov , 'group' , gid , info . name ); // "Media, links & docs" entry above the members
2026-06-23 16:15:29 +05:30
ov . onclick = ( e )=>{ if ( e . target === ov ) ov . remove (); };
ov . querySelector ( '#giClose' ). onclick = ()=> ov . remove ();
2026-06-30 17:01:15 +05:30
{ const gf = ov . querySelector ( '#giFav' ); if ( gf ) gf . onclick = async ()=>{ const r = rowFor ( 'group' , gid ); const on =! ( r && r . favorite ); if ( r ) r . favorite = on ; gf . classList . toggle ( 'on' , on ); gf . title = on ? 'Remove from favourites' : 'Add to favourites' ; try { await postJSON ( '/api/favorites' ,{ kind : 'group' , id : gid , on }); } catch ( _ ){} renderChats ( searchVal ()); }; }
2026-06-23 16:15:29 +05:30
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)
2026-06-30 17:01:15 +05:30
const meetAvatars = new Map (); // peerId -> avatar URL (for participant-tile profile pics)
const meetPeerUids = new Map (); // peerId -> user id (to tell when an invitee has joined)
const meetCamOff = new Map (); // peerId -> camera off? (a disabled remote track still arrives, so show the avatar)
const meetInvited = new Map (); // user id -> {name, timer} for invitees who haven't joined yet (#6)
2026-06-23 16:15:29 +05:30
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 ( _ ){} }
2026-07-06 13:20:59 +05:30
// ================= LiveKit SFU media plane (feature-flagged) =================
// When the server reports sfu:true, meeting MEDIA flows through LiveKit instead of the P2P mesh:
// each client uploads once and the SFU forwards to everyone, so rooms scale past ~5. Everything
// else — the meeting WebSocket control plane (join/host/mute/screen/recording/transcript), the tile
// UI, recording and transcript — is UNCHANGED. Tiles stay keyed by the WS peerId; LiveKit tracks map
// back to a peer via its uid (LiveKit identity = app user id = the peer's uid in meetPeerUids).
let SFU = { on : false , url : '' , room : null , lib : null , peers : new Map () }; // peers: pid -> {audio,cam,screen}
async function sfuInit (){ try { const c = await fetch ( '/api/meetings/config' ). then ( r => r . json ()); SFU . on =!! ( c && c . sfu ); SFU . url = ( c && c . url ) || '' ; } catch ( _ ){ SFU . on = false ; } }
function sfuLoadLib (){ return new Promise (( res , rej )=>{ if ( window . LivekitClient ) return res ( window . LivekitClient ); const s = document . createElement ( 'script' ); s . src = '/vendor/livekit-client.umd.min.js' ; s . onload = ()=> res ( window . LivekitClient ); s . onerror = ()=> rej ( new Error ( 'livekit sdk failed to load' )); document . head . appendChild ( s ); }); }
function peerIdForUid ( uid ){ for ( const [ pid , u ] of meetPeerUids ){ if ( u === uid ) return pid ; } return null ; }
async function sfuConnect (){
const LK = await sfuLoadLib (); SFU . lib = LK ;
2026-07-10 15:44:28 +05:30
// Guests (external link joiners, not signed in) get an unauthenticated guest token for this room.
const tk = ( ME && ME . guest )
? await postJSON ( '/api/meetings/guest-token' ,{ room : meetRoom , name : ME . name })
: await postJSON ( '/api/meetings/token' ,{ room : meetRoom }); // per-user, per-room join credential
2026-07-08 13:20:19 +05:30
// adaptiveStream + dynacast BOTH off: we attach media via manual srcObject (not track.attach), so
// LiveKit can't observe tile visibility. With dynacast on, a screen-share layer was paused unless a
// camera track was also flowing → the screen showed blank / only when the camera was on, and was
// slow to appear (#9). Off = every published track always flows at full quality.
const room = new LK . Room ({ adaptiveStream : false , dynacast : false }); SFU . room = room ;
2026-07-06 13:20:59 +05:30
room . on ( LK . RoomEvent . TrackSubscribed , ( track , pub , participant )=> sfuAttach ( pub , track , participant ));
room . on ( LK . RoomEvent . TrackUnsubscribed , ( track , pub , participant )=> sfuDetach ( pub , track , participant ));
await room . connect ( SFU . url , tk . token );
}
// Per-peer track bookkeeping → one tile stream that prefers the screen track over the camera,
// mirroring the mesh's single-video-per-tile model and driving the existing sharing/stage UI.
function sfuRebuild ( pid ){
const p = SFU . peers . get ( pid ) || {}; const st = new MediaStream ();
const vid = p . screen || p . cam ; if ( vid ) st . addTrack ( vid ); if ( p . audio ) st . addTrack ( p . audio );
addTile ( pid , st , meetNames . get ( pid ) || 'Guest' , false ); setTileScreen ( pid , !! p . screen ); meetWatchStream ( pid , st );
}
2026-07-07 23:18:14 +05:30
function sfuAttach ( pub , track , participant , _try ){
const pid = peerIdForUid ( participant . identity );
if ( ! pid ){ // #9: uid→peerId map (from the mesh join) may lag the LiveKit track — retry a few times
if (( _try || 0 ) < 10 ){ setTimeout (()=> sfuAttach ( pub , track , participant ,( _try || 0 ) + 1 ), 400 ); }
return ;
}
2026-07-06 13:20:59 +05:30
const p = SFU . peers . get ( pid ) || {}; SFU . peers . set ( pid , p ); const mt = track . mediaStreamTrack ;
if ( track . kind === 'audio' ) p . audio = mt ;
else if ( pub . source === SFU . lib . Track . Source . ScreenShare ){ p . screen = mt ; meetSharers . add ( pid ); }
else p . cam = mt ;
sfuRebuild ( pid ); updateShareMode ();
}
function sfuDetach ( pub , track , participant ){
const pid = peerIdForUid ( participant . identity ); if ( ! pid ) return ; const p = SFU . peers . get ( pid ); if ( ! p ) return ; const mt = track . mediaStreamTrack ;
if ( p . audio === mt ) p . audio = null ; else if ( p . screen === mt ){ p . screen = null ; meetSharers . delete ( pid ); setTileScreen ( pid , false ); } else if ( p . cam === mt ) p . cam = null ;
sfuRebuild ( pid ); updateShareMode ();
}
function sfuDropPeer ( pid ){ SFU . peers . delete ( pid ); }
// Reflect my LiveKit local mic/cam tracks into meetLocalStream so the self tile + active-speaker
// meter + canvas recording (which read tile <video> srcObjects) keep working unchanged.
function sfuSyncLocal (){
const lp = SFU . room && SFU . room . localParticipant ; if ( ! lp ) return ; const LK = SFU . lib ;
const mic = lp . getTrackPublication ( LK . Track . Source . Microphone ), cam = lp . getTrackPublication ( LK . Track . Source . Camera );
const mt = mic && mic . track && mic . track . mediaStreamTrack , ct = cam && cam . track && cam . track . mediaStreamTrack ;
meetLocalStream . getTracks (). forEach ( t =>{ if ( t !== mt && t !== ct ) meetLocalStream . removeTrack ( t ); });
if ( mt && meetLocalStream . getTracks (). indexOf ( mt ) < 0 ) meetLocalStream . addTrack ( mt );
if ( ct && meetLocalStream . getTracks (). indexOf ( ct ) < 0 ) meetLocalStream . addTrack ( ct );
addTile ( '__local' , meetLocalStream , ( ME && ME . name ) ? ME . name : 'You' , true ); meetWatchStream ( '__local' , meetLocalStream );
}
async function sfuSetMic ( on ){ const lp = SFU . room && SFU . room . localParticipant ; if ( ! lp ) return ; await lp . setMicrophoneEnabled ( on ); sfuSyncLocal (); }
async function sfuSetCam ( on ){ const lp = SFU . room && SFU . room . localParticipant ; if ( ! lp ) return ; await lp . setCameraEnabled ( on ); sfuSyncLocal (); }
function sfuDisconnect (){ if ( SFU . room ){ try { SFU . room . disconnect (); } catch ( _ ){} SFU . room = null ; } SFU . peers . clear (); }
2026-07-07 11:46:22 +05:30
// #15: turn a getUserMedia / LiveKit media failure into a SPECIFIC, actionable message (and log the
// raw error) instead of always blaming "permission" — which hid the real cause on the desktop app.
function mediaErrMsg ( e , what ){
const n = (( e && e . name ) || '' ) + '' ; try { console . error ( '[media] ' + what + ' failed:' , n , e && e . message , e ); } catch ( _ ){}
const isDesk = ( typeof nativePlatform === 'function' && nativePlatform () === 'desktop' );
const Cap = what . charAt ( 0 ). toUpperCase () + what . slice ( 1 );
if ( n === 'NotAllowedError' || n === 'PermissionDeniedError' || n === 'SecurityError' ) return isDesk
? Cap + ' access is blocked. Enable it in Windows Settings → Privacy & security → ' + ( what === 'camera' ? 'Camera' : 'Microphone' ) + ' (turn on "Let desktop apps access…"), then retry.'
: Cap + ' permission was blocked — allow it from your browser’ s address-bar icon and retry.' ;
if ( n === 'NotFoundError' || n === 'DevicesNotFoundError' ) return 'No ' + what + ' was found on this device.' ;
if ( n === 'NotReadableError' || n === 'TrackStartError' || n === 'AbortError' ) return 'Your ' + what + ' is in use by another app — close it and retry.' ;
if ( n === 'OverconstrainedError' ) return 'No ' + what + ' matches the requested settings.' ;
return 'Couldn’ t start ' + what + ( e && e . message ? ( ' (' + e . message + ')' ) : '' ) + '.' ;
}
2026-07-06 13:20:59 +05:30
// ================= end LiveKit SFU =================
2026-06-23 16:15:29 +05:30
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' ; }
};
}
2026-07-02 15:43:02 +05:30
// Branded connecting screen shown from the moment a call is entered until the room is ready —
// so a slow link shows our loader, not a blank/half meeting window.
function renderCallConnecting (){
const el = document . getElementById ( 'meetingPanel' ); if ( ! el ) return ;
el . innerHTML = '<div class="call-connecting"><img src="/loaders/loader-orbit-dark.svg" width="76" height="76" alt=""><div class="cc-txt">Connecting to call…</div></div>' ;
}
2026-06-23 16:15:29 +05:30
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 ;
2026-06-30 17:01:15 +05:30
const av = ( id === '__local' ) ? (( ME && ME . avatarUrl ) || null ) : ( meetAvatars . get ( id ) || null ); // profile pic on the tile
tile . innerHTML = '<video autoplay playsinline' + ( muted ? ' muted' : '' ) + '></video><div class="meet-av" style="background:' + avColor ( label || '?' ) + '">' + pEsc ( initials ( label || '?' )) + ( av ? '<img src="' + pEsc ( av ) + '" alt="" onerror="this.remove()">' : '' ) + '</div><div class="meet-mute" style="display:none">' + ic ( 'micOff' , 14 ) + '</div><span class="nm">' + pEsc ( label || '' ) + '</span>' ; grid . appendChild ( tile ); }
2026-06-23 16:15:29 +05:30
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' ));
2026-07-08 14:24:17 +05:30
tile . classList . toggle ( 'novid' , ! hasVid || ( meetCamOff . get ( id ) === true && ! meetSharers . has ( id ))); // camOff → avatar, UNLESS they're sharing a screen (#9: screen must show even with camera off)
2026-06-23 16:15:29 +05:30
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' ); } }
2026-06-30 17:01:15 +05:30
function removeTile ( id ){ const t = document . getElementById ( 'meet-tile-' + id ); if ( t ) t . remove (); meetMuted . delete ( id ); meetCamOff . delete ( id ); meetUnwatch ( id ); }
2026-07-07 23:18:14 +05:30
// #7: on an outgoing 1:1 call, show the person you're calling (DP + name + "Ringing…") so it's clear
// who the call is to, instead of only your own tile. Removed as soon as they answer.
let _dmCallWaiting = null ;
function addWaitingTile ( name , avatar ){
const grid = document . getElementById ( 'meetGrid' ); if ( ! grid || document . getElementById ( 'meet-tile-__waiting' )) return ;
const tile = document . createElement ( 'div' ); tile . className = 'meet-tile novid waiting' ; tile . id = 'meet-tile-__waiting' ;
tile . innerHTML = '<div class="meet-av" style="background:' + avColor ( name || '?' ) + '">' + pEsc ( initials ( name || '?' )) + ( avatar ? '<img src="' + pEsc ( avatar ) + '" alt="" onerror="this.remove()">' : '' ) + '</div><span class="nm">' + pEsc ( name || '' ) + '</span><div class="ringing">' + ic ( 'phone' , 13 ) + ' Ringing…</div>' ;
grid . appendChild ( tile );
}
function removeWaitingTile (){ const t = document . getElementById ( 'meet-tile-__waiting' ); if ( t ) t . remove (); }
2026-06-23 16:15:29 +05:30
// ---- 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 ){
2026-07-06 13:20:59 +05:30
// SFU mode: no P2P peer connection — just show the tile (avatar until LiveKit delivers media).
if ( SFU . on ){ addTile ( peerId , null , meetNames . get ( peerId ) || name || 'Guest' , false ); return null ; }
2026-06-23 16:15:29 +05:30
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 ; }
2026-07-06 13:20:59 +05:30
if ( SFU . on ){
try { await SFU . room . localParticipant . setScreenShareEnabled ( true ); } catch ( e ){ return ; } // user cancelled / denied
const pub = SFU . room . localParticipant . getTrackPublication ( SFU . lib . Track . Source . ScreenShare );
const st = pub && pub . track && pub . track . mediaStreamTrack ; if ( ! st ){ return ; }
meetScreen = true ; meetScreenStream = new MediaStream ([ st ]);
try { st . addEventListener ( 'ended' , ()=> stopScreen ()); } catch ( _ ){} // browser's native "Stop sharing" bar
2026-07-08 11:55:30 +05:30
// #9: keep MY tile on camera/avatar — do NOT show my own screen back to me. Sharing the whole
// screen would then capture the tile showing it → infinite "hall of mirrors". Others still see it.
addTile ( '__local' , meetLocalStream , ( ME && ME . name ) ? ME . name : 'You' , true ); setLocalSharing ( true );
2026-07-06 13:20:59 +05:30
updateScreenBtn (); meetSend ({ type : 'meeting-screen' , on : true }); return ;
}
2026-06-23 16:15:29 +05:30
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 ( _ ){} }
}
2026-07-08 11:55:30 +05:30
addTile ( '__local' , meetLocalStream , ( ME && ME . name ) ? ME . name : 'You' , true ); setLocalSharing ( true ); // #9: keep camera/avatar locally, not my own screen
2026-06-23 16:15:29 +05:30
updateScreenBtn (); meetSend ({ type : 'meeting-screen' , on : true });
}
2026-07-08 11:55:30 +05:30
// #9: a badge on MY tile that I'm sharing (instead of showing my own screen, which recurses).
function setLocalSharing ( on ){
const t = document . getElementById ( 'meet-tile-__local' ); if ( ! t ) return ;
let b = t . querySelector ( '.meet-sharing-self' );
if ( on ){ if ( ! b ){ b = document . createElement ( 'div' ); b . className = 'meet-sharing-self' ; b . innerHTML = ic ( 'monitor' , 13 ) + ' You’ re sharing your screen' ; t . appendChild ( b ); } }
else if ( b ){ b . remove (); }
}
2026-06-23 16:15:29 +05:30
function stopScreen (){
if ( ! meetScreen ) return ; meetScreen = false ;
2026-07-08 11:55:30 +05:30
if ( SFU . on ){ try { SFU . room . localParticipant . setScreenShareEnabled ( false ); } catch ( _ ){} meetScreenStream = null ; addTile ( '__local' , meetLocalStream , ( ME && ME . name ) ? ME . name : 'You' , true ); setLocalSharing ( false ); updateScreenBtn (); meetSend ({ type : 'meeting-screen' , on : false }); return ; }
2026-06-23 16:15:29 +05:30
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 ; }
2026-07-08 11:55:30 +05:30
addTile ( '__local' , meetLocalStream , ( ME && ME . name ) ? ME . name : 'You' , true ); setLocalSharing ( false );
2026-06-23 16:15:29 +05:30
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.
2026-07-08 11:55:30 +05:30
function getSharerIds (){ const a = []; meetSharers . forEach ( id => a . push ( id )); return a ; } // #9: '__local' excluded — I don't display my own screen, so it's never the local stage
2026-06-23 16:15:29 +05:30
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
2026-07-10 12:51:14 +05:30
// Joining this room → clear any lingering incoming-call invite popup for it (and stop its ring).
// Fixes: joining via the header "Join" button left the Join/Decline popup on screen. Belt-and-braces
// we clear ALL open invites, since you can only be in one call at a time.
if ( code ) dismissCallInvite ( code );
document . querySelectorAll ( '.call-invite' ). forEach ( el =>{ try { el . remove (); } catch ( _ ){} }); stopRing ( true );
2026-06-23 16:15:29 +05:30
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 );
2026-07-02 15:43:02 +05:30
renderCallConnecting (); // branded "Connecting…" until the room is created/joined (esp. on slow links)
2026-06-23 16:15:29 +05:30
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.
2026-06-30 17:01:15 +05:30
for ( const p of ( m . peers || [])){ meetNames . set ( p . peerId , p . name ); if ( p . avatar ) meetAvatars . set ( p . peerId , p . avatar ); if ( p . uid ){ meetPeerUids . set ( p . peerId , p . uid ); meetInviteJoined ( p . uid ); } meetMakePeer ( p . peerId , p . name ); }
2026-07-07 23:18:14 +05:30
if ( _dmCallWaiting && ! ( m . peers && m . peers . length )) addWaitingTile ( _dmCallWaiting . name , _dmCallWaiting . avatar ); // #7: show who we're calling while it rings
2026-07-06 13:20:59 +05:30
// SFU: connect to LiveKit for media once (peer uid→peerId map is populated above). Mic/cam are
// off at join, so nothing publishes yet — toggleMic/toggleCam publish on demand.
if ( SFU . on ){ try { await sfuConnect (); } catch ( err ){ const e2 = document . getElementById ( 'meetErr' ); if ( e2 ) e2 . textContent = 'Could not connect meeting media' ; } }
2026-06-23 16:15:29 +05:30
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 ;
}
2026-07-08 13:20:19 +05:30
if ( m . type === 'meeting-ended' ){ toast ( m . reason === 'unanswered' ? 'No answer' : 'Call ended' ); leaveMeeting ( true ); return ; } // 1:1 hangup / host ended / unanswered
2026-06-23 16:15:29 +05:30
if ( m . type === 'meeting-peer-joined' ){
2026-07-07 23:18:14 +05:30
stopRingback (); removeWaitingTile (); _dmCallWaiting = null ; // #17/#7: they answered → stop ring + drop the ringing tile
2026-06-30 17:01:15 +05:30
meetNames . set ( m . peerId , m . name ); if ( m . avatar ) meetAvatars . set ( m . peerId , m . avatar ); if ( m . uid ){ meetPeerUids . set ( m . peerId , m . uid ); meetInviteJoined ( m . uid ); }
2026-06-23 16:15:29 +05:30
const pc = meetMakePeer ( m . peerId , m . name ); // I'm an existing peer → I OFFER to the newcomer (carries my screen)
2026-07-06 13:20:59 +05:30
if ( pc ){ try { const offer = await pc . createOffer (); await pc . setLocalDescription ( offer ); meetSend ({ type : 'meeting-signal' , to : m . peerId , data : { sdp : pc . localDescription }}); } catch ( _ ){} } // (SFU: LiveKit handles media, no offer)
2026-06-23 16:15:29 +05:30
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 ;
}
2026-07-08 14:24:17 +05:30
if ( m . type === 'meeting-peer-state' ){ setTileMute ( m . peerId , !! m . muted ); meetCamOff . set ( m . peerId , !! m . camOff ); const _t = document . getElementById ( 'meet-tile-' + m . peerId ); if ( _t ){ const v = _t . querySelector ( 'video' ), s = v && v . srcObject ; const hv =!! ( s && s . getVideoTracks && s . getVideoTracks (). some ( tr => tr . enabled && tr . readyState !== 'ended' )); _t . classList . toggle ( 'novid' , ! hv || ( !! m . camOff && ! meetSharers . has ( m . peerId ))); } refreshMeetPanel (); return ; } // camOff -> avatar, unless sharing a screen (#9)
2026-06-23 16:15:29 +05:30
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 ; }
2026-07-06 13:20:59 +05:30
if ( m . type === 'meeting-peer-left' ){ const p = meetPeers . get ( m . peerId ); if ( p ){ try { p . pc . close ();} catch ( _ ){} meetPeers . delete ( m . peerId );} if ( SFU . on ) sfuDropPeer ( m . peerId ); meetSharers . delete ( m . peerId ); removeTile ( m . peerId ); refreshMeetPanel (); return ; }
2026-06-23 16:15:29 +05:30
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 ;
}
2026-07-02 15:43:02 +05:30
if ( m . type === 'error' ){ const msg = m . message ; leaveMeeting ( true ); const e2 = document . getElementById ( 'meetErr' ); if ( e2 ) e2 . textContent = msg || 'Meeting error' ; return ; }
2026-06-23 16:15:29 +05:30
}
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 ;
2026-07-07 11:46:22 +05:30
if ( SFU . on ){ const next =! meetMic ; try { await sfuSetMic ( next ); meetMic = next ; } catch ( e ){ toast ( mediaErrMsg ( e , 'microphone' )); return ; } updateMicBtn (); setTileMute ( '__local' , ! meetMic ); meetSend ({ type : 'meeting-state' , muted :! meetMic , camOff :! meetCam }); return ; }
2026-06-23 16:15:29 +05:30
const hasTrack = meetLocalStream . getAudioTracks (). length > 0 ;
if ( ! hasTrack ){
let astream ; try { astream = await navigator . mediaDevices . getUserMedia ({ audio : true }); }
2026-07-07 11:46:22 +05:30
catch ( e ){ toast ( mediaErrMsg ( e , 'microphone' )); return ; }
2026-06-23 16:15:29 +05:30
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 ;
2026-07-07 11:46:22 +05:30
if ( SFU . on ){ const next =! meetCam ; try { await sfuSetCam ( next ); meetCam = next ; meetAudioOnly = false ; } catch ( e ){ toast ( mediaErrMsg ( e , 'camera' )); return ; } updateCamBtn (); addTile ( '__local' , meetLocalStream , ( ME && ME . name ) ? ME . name : 'You' , true ); setTileMute ( '__local' , ! meetMic ); meetSend ({ type : 'meeting-state' , muted :! meetMic , camOff :! meetCam }); return ; }
2026-06-23 16:15:29 +05:30
const hasTrack = meetLocalStream . getVideoTracks (). length > 0 ;
if ( ! hasTrack ){
let vstream ; try { vstream = await navigator . mediaDevices . getUserMedia ({ video : true }); }
2026-07-07 11:46:22 +05:30
catch ( e ){ toast ( mediaErrMsg ( e , 'camera' )); return ; }
2026-06-23 16:15:29 +05:30
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 ;
2026-07-02 15:43:02 +05:30
// forced = the call is ending for us (1:1 hangup, host ended, room closed/error) rather than us
// voluntarily stepping out of a group others are still in. Only a VOLUNTARY exit from a GROUP
// call hands the host role on; a 1:1 call (or a forced end) has nobody to hand to — handing off
// there wrongly announced "Host handed to <the person who just left>" (bug).
function leaveMeeting ( forced ){
2026-06-23 16:15:29 +05:30
if ( meetLeaving ) return ; meetLeaving = true ;
2026-07-07 23:18:14 +05:30
stopRingback (); removeWaitingTile (); _dmCallWaiting = null ; // #17/#7: any call exit stops the ring + ringing tile
2026-07-02 15:43:02 +05:30
const isDm =!! ( meetReturn && meetReturn . kind === 'dm' );
if ( ! forced && ! isDm && meetIsHost && meetPeers . size > 0 ){
2026-06-23 16:15:29 +05:30
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' });
2026-07-06 13:20:59 +05:30
if ( SFU . on ) sfuDisconnect (); // tear down the LiveKit room
2026-06-23 16:15:29 +05:30
meetUnwatchAll (); meetSharers . clear ();
2026-06-30 17:01:15 +05:30
meetPeers . forEach ( p =>{ try { p . pc . close ();} catch ( _ ){} }); meetPeers . clear (); meetNames . clear (); meetAvatars . clear (); meetPeerUids . clear (); meetCamOff . clear (); meetInvited . forEach ( e =>{ if ( e . timer ) clearTimeout ( e . timer ); }); meetInvited . clear (); meetMuted . clear ();
2026-06-23 16:15:29 +05:30
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 ;
2026-07-10 15:44:28 +05:30
if ( ME && ME . guest ){ renderGuestLeft (); return ; } // guests have no chat to return to — show a leave screen
2026-06-23 16:15:29 +05:30
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
}
2026-07-10 15:44:28 +05:30
// Guest post-call screen: they can't drop back into a chat/sidebar (there is none), so show a simple
// "you've left" card with a one-click Rejoin.
let _guestRoom = null ;
function renderGuestLeft (){
document . querySelectorAll ( '.guest-left' ). forEach ( x => x . remove ());
const el = document . createElement ( 'div' ); el . className = 'guest-left' ;
el . innerHTML = '<div class="gl-card"><img src="/mark-light.png" alt="" style="height:46px;width:auto;object-fit:contain;margin-bottom:.4rem" onerror="this.remove()">'
+ '<h2>You’ ve left the meeting</h2><p>Thanks for joining.</p>'
+ ( _guestRoom ? '<button class="bzc-ok" id="glRejoin">Rejoin meeting</button>' : '' ) + '</div>' ;
document . body . appendChild ( el );
const rj = el . querySelector ( '#glRejoin' ); if ( rj ) rj . onclick = ()=>{ location . href = '/home?meet=' + encodeURIComponent ( _guestRoom ); };
}
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-30 17:01:15 +05:30
// Esc closes the topmost popup/search FIRST (capture phase, before the conversation-close below).
document . addEventListener ( 'keydown' ,( e )=>{
if ( e . key !== 'Escape' ) return ;
2026-07-07 23:11:02 +05:30
const lb = document . getElementById ( 'lightbox' ); if ( lb ){ lb . remove (); e . preventDefault (); e . stopPropagation (); return ; } // #10: close the image preview first (leave the media view underneath)
2026-06-30 17:01:15 +05:30
const ovs = document . querySelectorAll ( '.modal-ov' );
if ( ovs . length ){ ovs [ ovs . length - 1 ]. remove (); e . preventDefault (); e . stopPropagation (); return ; }
2026-07-07 16:09:02 +05:30
if ( _selMode ){ exitSelect (); e . preventDefault (); e . stopPropagation (); return ; } // #1: leave forward-selection
2026-06-30 17:01:15 +05:30
const sh = document . getElementById ( 'convoSearchHead' ); if ( sh && sh . style . display !== 'none' ){ closeSearch (); e . preventDefault (); e . stopPropagation (); return ; }
}, true );
2026-07-07 11:46:22 +05:30
document . addEventListener ( 'keydown' ,( e )=>{ if ( e . key === 'Escape' && ! document . querySelector ( '.modal-ov' ) && ! document . getElementById ( 'lightbox' ) && currentTab () === 'chat' && selected != null ){ showWelcome (); } }); // #4: an open image preview closes first (its own Esc handler); the conversation only closes once no overlay remains
2026-06-30 17:01:15 +05:30
// #10: the device/browser Back button closes the topmost layer (popup → search → open chat on
// mobile) instead of leaving the app. We seed a history entry and re-seed after each handled back.
function bzcBack (){
const ovs = document . querySelectorAll ( '.modal-ov' ); if ( ovs . length ){ ovs [ ovs . length - 1 ]. remove (); return true ; }
const sh = document . getElementById ( 'convoSearchHead' ); if ( sh && sh . style . display !== 'none' ){ closeSearch (); return true ; }
if ( window . matchMedia ( '(max-width:760px)' ). matches && selected ){ showWelcome (); return true ; }
return false ;
}
try { history . pushState ( null , '' , location . href ); } catch ( _ ){}
window . addEventListener ( 'popstate' , ()=>{ if ( bzcBack ()){ try { history . pushState ( null , '' , location . href ); } catch ( _ ){} } });
2026-06-23 16:15:29 +05:30
// 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">
2026-07-02 17:39:38 +05:30
<div class="auth-brand"><img src="/icon-192.png" alt="Biz Connect"><div class="wm">Biz <b>Connect</b></div></div>
2026-07-02 15:43:02 +05:30
<h1>Welcome back</h1>
2026-06-12 00:40:07 +05:30
<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>` : '' }
2026-07-01 18:19:01 +05:30
<form id="loginForm">
2026-07-02 17:16:08 +05:30
<span class="lbl">Email or phone</span><input id="li_email" type="text" inputmode="email" placeholder="you@bizgaze.com or mobile number" autocomplete="username">
2026-06-12 00:40:07 +05:30
<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>
2026-07-01 18:19:01 +05:30
<button class="gobtn" id="li_btn" type="submit">Sign in</button>
2026-06-12 00:40:07 +05:30
<p id="li_err" class="formerr"></p>
2026-07-01 18:19:01 +05:30
</form>
2026-06-12 00:40:07 +05:30
${ regOpen ? `<div id="regForm" class="hidden">
2026-06-30 17:01:15 +05:30
<span class="lbl">Team name</span><input id="rg_team" placeholder="e.g. Acme Inc">
2026-06-12 00:40:07 +05:30
<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>` ;
2026-07-01 18:19:01 +05:30
document . getElementById ( 'loginForm' ). addEventListener ( 'submit' ,( e )=>{ e . preventDefault (); doLogin (); }); // Enter or "Sign in" both submit
2026-06-12 00:40:07 +05:30
wireEyes ();
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' );
2026-07-02 17:39:38 +05:30
const btn = document . getElementById ( 'li_btn' ); const orig = btn ? btn . innerHTML : '' ;
if ( btn ){ btn . disabled = true ; btn . innerHTML = '<span class="btn-spin"></span>Signing in…' ; } // show progress on Enter/click
2026-06-12 00:40:07 +05:30
try {
await postJSON ( '/api/login' ,{ email : document . getElementById ( 'li_email' ). value , password : document . getElementById ( 'li_pw' ). value , remember : document . getElementById ( 'li_remember' ). checked });
2026-07-02 17:39:38 +05:30
location . reload (); // the branded splash takes over from here
} catch ( e ){ if ( btn ){ btn . disabled = false ; btn . innerHTML = orig ; } showErr ( 'li_err' , /invalid credentials/i . test ( e . message ) ? 'Incorrect email or password. Please try again.' : e . message ); }
2026-06-12 00:40:07 +05:30
}
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 ( _ ){}
2026-07-10 15:44:28 +05:30
// Guest meeting join: someone opened a shared meeting link (…/home?meet=CODE or /j/CODE). If they
// aren't signed in, run in GUEST mode — no chat/sidebar, just a name prompt then straight into the
// call. Signed-in users fall through and auto-join after their normal boot (handled below).
let _meet = null ; try { _meet = ( new URLSearchParams ( location . search )). get ( 'meet' ); } catch ( _ ){}
if ( _meet && /^\d{6}$/ . test ( _meet ) && ! me ){ document . getElementById ( 'loading' ). style . display = 'none' ; return startGuestMeeting ( _meet ); }
2026-06-12 00:40:07 +05:30
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 ();
2026-06-30 17:01:15 +05:30
placeHdrRight (); window . addEventListener ( 'resize' , placeHdrRight ); // mobile: bell+profile live in the chat-list header (no top bar)
2026-06-23 16:15:29 +05:30
connectChatWs ();
2026-07-06 13:20:59 +05:30
sfuInit (); // learn whether meetings use the LiveKit SFU or the P2P mesh (before any call starts)
2026-07-01 21:28:53 +05:30
reportInstall (); // desktop/mobile shell: record this install against the signed-in user
2026-07-08 15:58:09 +05:30
wireUpdateBanner (); // #3: desktop update-progress banner
2026-06-23 21:58:49 +05:30
setupPush (); // register the notification service worker + subscribe to Web Push (if granted)
2026-06-30 17:01:15 +05:30
{ const cl = document . getElementById ( 'chatlist' ); if ( cl ) enablePullRefresh ( cl , loadSidebar ); } // pull-to-refresh the chat list
setTimeout ( maybeNotifPrompt , 1500 ); // gentle "enable notifications" prompt if still undecided (key for iOS PWA)
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)
2026-07-02 17:16:08 +05:30
document . getElementById ( 'loading' ). style . display = 'none' ; // the chat pane shows its own thread loader
2026-06-23 16:15:29 +05:30
loadSidebar (). then (()=>{ try { const it = rowFor ( okind , oid ); const nm = document . querySelector ( '#chatPanel .convo-head .nm' ); if ( it && nm ) nm . textContent = it . name ; } catch ( _ ){} });
2026-07-02 17:16:08 +05:30
} else {
await loadSidebar (); renderChatPanel ();
document . getElementById ( 'loading' ). style . display = 'none' ; // keep the branded splash up until the list is ready (no blank/stuck gap)
}
2026-07-10 15:44:28 +05:30
// Signed-in user opened a meeting link → jump straight into that meeting.
if ( _meet && /^\d{6}$/ . test ( _meet )){ try { history . replaceState ( null , '' , '/home' ); } catch ( _ ){} switchTab ( 'meeting' ); enterMeeting ( _meet ); }
2026-06-12 00:40:07 +05:30
})();
2026-07-10 15:44:28 +05:30
// GUEST meeting: a lightweight pre-join (name) then join the call with a throwaway guest identity —
// reuses the full meeting client (LiveKit/mesh, tiles, mic/cam) with the chat/sidebar chrome hidden.
async function startGuestMeeting ( code ){
document . body . classList . add ( 'guest-mode' );
try { history . replaceState ( null , '' , '/home?meet=' + code ); } catch ( _ ){}
const savedName = (()=>{ try { return localStorage . getItem ( 'bzc_guest_name' ) || '' ; } catch ( _ ){ return '' ; } })();
const ov = document . createElement ( 'div' ); ov . className = 'modal-ov' ; ov . id = 'guestJoin' ; ov . style . zIndex = '9900' ;
ov . innerHTML = '<div class="modal" style="max-width:380px;text-align:center">'
+ '<img src="/mark-light.png" alt="" style="height:44px;width:auto;margin:0 auto .4rem;object-fit:contain" onerror="this.remove()">'
+ '<h3 style="justify-content:center">Join the meeting</h3>'
+ '<p class="bzc-msg" style="text-align:center">You’ re joining meeting <b>' + pEsc ( code ) + '</b> as a guest.</p>'
+ '<input id="guestName" placeholder="Your name" autocomplete="name" value="' + pEsc ( savedName ) + '" style="width:100%;box-sizing:border-box;border:1px solid var(--line);border-radius:9px;padding:.6rem .7rem;font-size:.95rem;margin:.2rem 0 .9rem">'
+ '<button class="bzc-ok" id="guestGo" style="width:100%;justify-content:center">Join meeting</button></div>' ;
document . body . appendChild ( ov );
const inp = ov . querySelector ( '#guestName' ), go = ov . querySelector ( '#guestGo' );
setTimeout (()=>{ try { inp . focus (); } catch ( _ ){} }, 50 );
const join = async ()=>{
const nm = ( inp . value || '' ). trim () || 'Guest' ;
try { localStorage . setItem ( 'bzc_guest_name' , nm ); } catch ( _ ){}
ME = { id : 'guest-' + Math . random (). toString ( 36 ). slice ( 2 , 10 ), name : nm , email : '' , guest : true , avatarUrl : null };
_guestRoom = code ; ov . remove ();
try { await sfuInit (); } catch ( _ ){}
switchTab ( 'meeting' ); enterMeeting ( code );
};
go . onclick = join ;
inp . addEventListener ( 'keydown' , e =>{ if ( e . key === 'Enter' ){ e . preventDefault (); join (); } });
}
2026-06-12 00:40:07 +05:30
</ script >
</ body >
</ html >