@@ -3,7 +3,7 @@
< head >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1, viewport-fit=cover" >
< title > BizGaze Connect< / title >
< title > Biz Connect< / title >
<!-- 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" >
@@ -19,7 +19,8 @@
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 ; }
/* ---- Top bar ---- */
header { background : var ( - - blue ) ; padding : .7 rem 1.4 rem ; display : flex ; justify-content : space-between ; align-items : center ; flex : 0 0 auto ; position : relative ; z-index : 1300 ; }
/* #4: the top blue bar is removed everywhere — bell+profile live in the chat-list header. */
header { display : none ; }
. 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 ; }
@@ -27,10 +28,15 @@
/* ---- Header actions: notification bell + profile ---- */
# hdrRight { display : flex ; align-items : center ; gap : .45 rem ; }
. bell { position : relative ; }
. bellbtn { position : relative ; background : rgba ( 255 , 255 , 255 , .14 ) ; border : 1 px solid #46598c ; color : #fff ; cursor : pointer ; width : 40 px ; height : 40 px ; border-radius : 10 px ; display : grid ; place-items : center ; }
. bellbtn { 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 ; }
. bellbtn : hover { background : rgba ( 255 , 255 , 255 , .24 ) ; }
. bell-dot { position : absolute ; top : 4 px ; right : 4 px ; min-width : 16 px ; height : 16 px ; border-radius : 99 px ; background : var ( - - brand ) ; color : var ( - - blue ) ; font-size : .6 rem ; font-weight : 800 ; display : grid ; place-items : center ; padding : 0 .2 rem ; border : 2 px solid var ( - - blue ) ; }
. bell-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 ) ; }
. 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 ; }
/* 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 ) ; }
. 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 ; }
@@ -45,7 +51,17 @@
. bell-empty { padding : 1.6 rem ; text-align : center ; color : var ( - - muted ) ; font-size : .85 rem ; }
/* ---- Profile dropdown (from console.html) ---- */
. profile { position : relative }
. profile . pbtn . icon-only { padding : .3 rem ; gap : 0 ; border-radius : 50 % ; }
. 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 ; }
. 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 ) }
. 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 }
@@ -66,6 +82,9 @@
/* ---- 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 ; }
/* 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 ; }
. 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 ; }
@@ -93,11 +112,15 @@
. 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 ; }
. side-head { padding : 1 rem 1 rem .75 rem ; border-bottom : 1 px solid var ( - - line ) ; }
. side-title { display : flex ; align-items : center ; justify-content : space-between ; margin-bottom : .7 rem ; }
. side-title h2 { font-size : .95 rem ; margin : 0 ; color : var ( - - blue ) ; }
. newchat { width : 30 px ; height : 30 px ; border-radius : 9 px ; border : none ; background : var ( - - blue - soft ) ; color : var ( - - blue ) ; font-size : 1.2 rem ; line-height : 1 ; cursor : pointer ; font-weight : 700 ; display : grid ; place-items : center ; padding : 0 ; }
. newchat : hover { background : #dbe6fb ; }
/* 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 ) ; }
. search { position : relative ; }
. search > svg { position : absolute ; left : .65 rem ; top : 50 % ; transform : translateY ( -50 % ) ; color : var ( - - muted ) ; }
. 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 ; }
@@ -106,15 +129,41 @@
. 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 ) ; }
. chatlist { overflow-y : auto ; flex : 1 1 auto ; padding : .4 rem ; }
. chatlist { overflow-y : auto ; flex : 1 1 auto ; padding : .4 rem ; scrollbar-width : thin ; scrollbar-color : transparent transparent ; }
. chatlist : hover { scrollbar-color : #c7d0dd transparent ; }
. chatlist :: -webkit-scrollbar { width : 7 px ; }
. chatlist :: -webkit-scrollbar-button { display : none ; height : 0 ; width : 0 ; }
. 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 ; }
. 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 ; }
. chat-row . active { background : var ( - - blue - soft ) ; box-shadow : inset 3 px 0 0 var ( - - brand ) ; }
. chat-row . active :: before { content : "" ; position : absolute ; left : 0 ; top : .7 rem ; bottom : .7 rem ; width : 3 px ; border-radius : 3 px ; background : var ( - - blue ) ; }
. avatar { width : 42 px ; height : 42 px ; flex : 0 0 42 px ; border-radius : 50 % ; display : grid ; place-items : center ; color : #fff ; font-weight : 700 ; font-size : .92 rem ; position : relative ; }
. 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 ; }
. 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 ; }
. avatar . dot . on { background : var ( - - green ) ; }
/* 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 ; }
. chat-main { flex : 1 1 auto ; min-width : 0 ; }
. chat-top { display : flex ; justify-content : space-between ; align-items : baseline ; gap : .5 rem ; }
. chat-name { font-weight : 400 ; font-size : .92 rem ; white-space : nowrap ; overflow : hidden ; text-overflow : ellipsis ; }
@@ -158,12 +207,59 @@
/* conversation placeholder (selected chat, no backend yet) */
. convo { flex-direction : column ; display : flex ; width : 100 % ; height : 100 % ; }
. convo-head { display : flex ; align-items : center ; gap : .7 rem ; padding : .9 rem 1.2 rem ; border-bottom : 1 px solid var ( - - line ) ; background : var ( - - card ) ; }
. 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 ; }
. ic { display : inline-block ; vertical-align : middle ; }
. convo-back { border : none ; background : var ( - - blue - soft ) ; color : var ( - - blue ) ; width : 34 px ; height : 34 px ; border-radius : 9 px ; cursor : pointer ; display : grid ; place-items : center ; flex : 0 0 auto ; }
. convo-back : hover { background : #dbe6fb ; }
. convo-head . nm { font-weight : 700 ; color : var ( - - ink ) ; }
. convo-head . st { font-size : .78 rem ; color : var ( - - muted ) ; }
/* 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 ; }
. 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 ; }
/* Media / Docs / Links — clean underline tabs (active = green underline, no chip borders) */
. 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 ) ; }
. mv-tabs . mp-tab . on { color : var ( - - ink ) ; font-weight : 700 ; border-bottom-color : #16a34a ; }
. 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 ; }
. 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 ; }
/* message thread */
@@ -173,6 +269,8 @@
. 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 ; }
. 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 ; }
. 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 ; }
@@ -250,7 +348,8 @@
. meet-panel . mp-setting { margin : .3 rem ; font-size : .8 rem ; }
. meet-tile video { width : 100 % ; height : 100 % ; object-fit : cover ; background : #0b1220 ; }
. meet-tile . nm { position : absolute ; left : .5 rem ; bottom : .5 rem ; background : rgba ( 0 , 0 , 0 , .55 ) ; color : #fff ; font-size : .75 rem ; padding : .15 rem .5 rem ; border-radius : 6 px ; }
. meet-tile . meet-av { position : absolute ; inset : 0 ; margin : auto ; width : 84 px ; height : 84 px ; border-radius : 50 % ; display : none ; align-items : center ; justify-content : center ; color : #fff ; font-weight : 700 ; font-size : 1.9 rem ; }
. meet-tile . meet-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 ; }
. 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 ; }
@@ -259,7 +358,8 @@
. meet-panel . mp-tabs { display : flex ; gap : .25 rem ; padding : .4 rem .5 rem 0 ; border-bottom : 1 px solid var ( - - line ) ; }
. meet-panel . mp-tab { flex : 1 ; border : none ; background : transparent ; color : var ( - - muted ) ; font-size : .8 rem ; font-weight : 600 ; padding : .45 rem .3 rem ; cursor : pointer ; border-bottom : 2 px solid transparent ; display : inline-flex ; align-items : center ; justify-content : center ; gap : .25 rem ; }
. meet-panel . mp-tab . on { color : var ( - - blue ) ; border-bottom-color : var ( - - blue ) ; }
. meet-panel . mp-list { flex : 1 ; overflow : auto ; padding : .4 rem ; }
. meet-panel . mp-scroll { flex : 1 ; overflow : auto ; }
. meet-panel . mp-list { padding : .4 rem ; }
. meet-panel . chk { display : flex ; align-items : center ; gap : .5 rem ; padding : .4 rem .5 rem ; border-radius : 8 px ; cursor : pointer ; font-size : .88 rem ; }
. meet-panel . chk : hover { background : #f6f8fb ; }
. meet-panel . chk input { width : 16 px ; height : 16 px ; flex : 0 0 16 px ; accent-color : var ( - - blue ) ; margin : 0 ; }
@@ -267,6 +367,9 @@
. meet-panel . mp-row { display : flex ; align-items : center ; gap : .5 rem ; padding : .4 rem .5 rem ; border-radius : 8 px ; }
. meet-panel . mp-row . mn { flex : 1 ; min-width : 0 ; font-size : .88 rem ; white-space : nowrap ; overflow : hidden ; text-overflow : ellipsis ; }
. meet-panel . mp-row . pp-mute { color : var ( - - red ) ; display : inline-flex ; }
. meet-panel . mp-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 ; }
. 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 ; }
@@ -304,8 +407,8 @@
. convo { position : relative ; }
. bubble { position : relative ; }
. bubble . quote { border-left : 3 px solid var ( - - line ) ; padding : .22 rem .5 rem ; margin-bottom : .3 rem ; font-size : .78 rem ; border-radius : 6 px ; color : #33384a ; }
. reply-btn { position : absolute ; top : -9 px ; right : 6 px ; background : var ( - - card ) ; color : var ( - - blue ) ; border : 1 px solid var ( - - line ) ; border-radius : 50 % ; width : 22 px ; height : 22 px ; font-size : .8 rem ; line-height : 1 ; cursor : pointer ; opacity : 0 ; transition : opacity .12 s ; box-shadow : 0 1 px 3 px rgba ( 0 , 0 , 0 , .12 ) ; }
. bubble : hover . reply-btn { opacity : 1 ; }
. reply-btn { position : absolute ; top : -9 px ; right : 6 px ; background : var ( - - card ) ; color : var ( - - blue ) ; border : 1 px solid var ( - - line ) ; border-radius : 50 % ; width : 22 px ; height : 22 px ; font-size : .8 rem ; line-height : 1 ; cursor : pointer ; opacity : 0 ; pointer-events : none ; transition : opacity .12 s ; box-shadow : 0 1 px 3 px rgba ( 0 , 0 , 0 , .12 ) ; }
. bubble : hover . reply-btn , . bubble . show-actions . reply-btn { opacity : 1 ; pointer-events : auto ; }
. 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 ; }
@@ -341,8 +444,13 @@
. emoji-grid { flex : 1 ; overflow-y : auto ; display : grid ; grid-template-columns : repeat ( 8 , 1 fr ) ; gap : .1 rem ; padding : .4 rem ; align-content : start ; }
. emoji-grid button { border : none ; background : transparent ; font-size : 1.25 rem ; cursor : pointer ; padding : .2 rem ; border-radius : 6 px ; line-height : 1.15 ; }
. emoji-grid button : hover { background : var ( - - blue - soft ) ; }
. react-btn { position : absolute ; top : -9 px ; right : 32 px ; background : var ( - - card ) ; color : var ( - - blue ) ; border : 1 px solid var ( - - line ) ; border-radius : 50 % ; width : 22 px ; height : 22 px ; font-size : .8 rem ; line-height : 1 ; cursor : pointer ; opacity : 0 ; transition : opacity .12 s ; box-shadow : 0 1 px 3 px rgba ( 0 , 0 , 0 , .12 ) ; }
. bubble : hover . react-btn { opacity : 1 ; }
. react-btn { position : absolute ; top : -9 px ; right : 32 px ; background : var ( - - card ) ; color : var ( - - blue ) ; border : 1 px solid var ( - - line ) ; border-radius : 50 % ; width : 22 px ; height : 22 px ; font-size : .8 rem ; line-height : 1 ; cursor : pointer ; opacity : 0 ; pointer-events : none ; transition : opacity .12 s ; box-shadow : 0 1 px 3 px rgba ( 0 , 0 , 0 , .12 ) ; }
. bubble : hover . react-btn , . bubble . show-actions . react-btn { opacity : 1 ; pointer-events : auto ; }
. del-btn { position : absolute ; top : -9 px ; right : 58 px ; background : var ( - - card ) ; color : var ( - - red ) ; border : 1 px solid var ( - - line ) ; border-radius : 50 % ; width : 22 px ; height : 22 px ; line-height : 1 ; cursor : pointer ; opacity : 0 ; pointer-events : none ; transition : opacity .12 s ; box-shadow : 0 1 px 3 px rgba ( 0 , 0 , 0 , .12 ) ; display : grid ; place-items : center ; }
. bubble : hover . del-btn , . bubble . show-actions . del-btn { opacity : 1 ; pointer-events : auto ; }
. 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 ) ; }
. 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 ) ; }
@@ -358,7 +466,7 @@
. att-file . att-sz { opacity : .65 ; font-size : .75 rem ; flex : 0 0 auto ; }
/* groups */
. bubble . sender { font-size : .7 rem ; color : var ( - - blue ) ; font-weight : 700 ; margin-bottom : .12 rem ; display : flex ; align-items : center ; gap : .35 rem ; }
. bubble . sender . snd-av { position : relative ; width : 18 px ; height : 18 px ; flex : 0 0 18 px ; border-radius : 50 % ; display : grid ; place-items : center ; color : #fff ; font-weight : 700 ; font-size : .55 rem ; overflow : hidden ; }
. bubble . sender . snd-av { 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 ; }
. bubble . sender . snd-av img { position : absolute ; inset : 0 ; width : 100 % ; height : 100 % ; object-fit : cover ; }
. avatar . grp { border-radius : 12 px ; font-size : 1.15 rem ; }
. modal-ov { position : fixed ; inset : 0 ; background : rgba ( 15 , 23 , 42 , .45 ) ; display : flex ; align-items : center ; justify-content : center ; z-index : 9800 ; padding : 1 rem ; }
@@ -369,6 +477,14 @@
. avatar . mcount { position : absolute ; right : -3 px ; bottom : -3 px ; min-width : 16 px ; height : 16 px ; border-radius : 99 px ; background : var ( - - blue ) ; color : #fff ; font-size : .6 rem ; font-weight : 800 ; display : grid ; place-items : center ; border : 2 px solid var ( - - card ) ; padding : 0 .15 rem ; z-index : 1 ; }
. convo-titlewrap { flex : 1 ; min-width : 0 ; }
. convo-info { border : none ; background : var ( - - blue - soft ) ; color : var ( - - blue ) ; width : 32 px ; height : 32 px ; border-radius : 9 px ; font-size : 1 rem ; cursor : pointer ; flex : 0 0 auto ; display : grid ; place-items : center ; }
. convo-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 ; }
. 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 ; }
@@ -493,7 +609,7 @@
. mrow . mn { flex : 1 ; min-width : 0 ; white-space : nowrap ; overflow : hidden ; text-overflow : ellipsis ; }
. mrow . iconbtn { opacity : 0 ; }
. mrow : hover . iconbtn { opacity : 1 ; }
. mini-av { position : relative ; overflow : hidden ; width : 30 px ; height : 30 px ; flex : 0 0 30 px ; border-radius : 50 % ; display : grid ; place-items : center ; color : #fff ; font-weight : 700 ; font-size : .72 rem ; }
. mini-av { 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 ; }
. 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 ; }
@@ -551,7 +667,14 @@
}
/* ---- Mobile / tablet ---- */
@ media ( max-width : 760px ) {
header { padding : .5 rem .8 rem ; }
/* #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 ; }
. brand { font-size : .98 rem ; }
. navtoggle { display : none ; } . rail-backdrop { display : none !important ; } /* bottom nav replaces the drawer */
/* App-style bottom navigation bar */
@@ -566,12 +689,12 @@
/* Meeting room code: always fully visible, full-width, easy to read */
. meet-bar . code { flex : 1 1 100 % ; font-size : .9 rem ; white-space : normal ; word-break : break-word ; text-align : center ; background : var ( - - blue - soft ) ; border-radius : 8 px ; padding : .35 rem .5 rem ; }
/* Chat: one pane at a time (list, then the open conversation) */
. chatcol { width : 100 % ; flex : 1 1 100 % ; padding-bottom : 60 px ; }
. chatcol { width : 100 % ; flex : 1 1 100 % ; padding-bottom : calc ( 60 px + env ( safe - area - inset - bottom , 0 ) ) ; }
body . chat-open . chatcol { display : none ; }
. content { display : none ; }
body . chat-open . content , . shell : not ( . is-chat ) . content { display : block ; }
. shell : not ( . is-chat ) . chatcol { display : none ; }
. content . panel { bottom : 60 px ; } /* keep panel content above the bottom nav */
. content . panel { bottom : calc ( 60 px + env ( safe - area - inset - bottom , 0 ) ) ; } /* clear the bottom nav + home indicator */
/* Bigger touch targets */
. chat-row { padding : .7 rem .85 rem ; }
. convo-head { padding : .7 rem .85 rem ; }
@@ -589,22 +712,23 @@
. md-actions > . btn { width : 100 % ; justify-content : center ; }
. md-join { flex : 1 1 100 % ; }
. call-invite { left : 8 px ; right : 8 px ; max-width : none ; bottom : 70 px ; }
. bell-menu { position : fixed ; left : 8 px ; right : 8 px ; top : 58 px ; width : auto ; }
. 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 */
/* Touch devices have no hover: keep member-row actions (make-admin / remove) always visible */
. mrow . iconbtn { opacity : 1 ; }
}
< / style >
< / head >
< body >
< script src = "/icons.js?v=3 " > < / script >
< script > window . _ _BUILD = '2026-06-25-pwa2 ' ; console . log ( '%cBizGaze Connect' , 'color:#1F3B73;font-weight:bold' , 'build ' + window . _ _BUILD ) ; < / script >
< script src = "/icons.js?v=4 " > < / script >
< script > window . _ _BUILD = '2026-06-30-batch14 ' ; console . log ( '%cBiz Connect' , 'color:#1F3B73;font-weight:bold' , 'build ' + window . _ _BUILD ) ; < / script >
< div class = "loading" id = "loading" > Loading…< / div >
< header >
< div class = "brandrow" >
< 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 >
< img src = "/logo.png" alt = "" style = "height:46px;width:auto;max-width:190px;border-radius:8px;object-fit:contain;background:#fff;padding:5px 12px;image-rendering:-webkit-optimize-contrast" onerror = "this.replaceWith(Object.assign(document.createElement('div'),{className:'logo',textContent:'B'}))" >
< div class = "brand" > BizGaze < span class = "y" > Connect< / span > < / div >
< div class = "brand" > Biz < span class = "y" > Connect< / span > < / div >
< / div >
< div id = "hdrRight" > < / div >
< / header >
@@ -637,12 +761,14 @@
< div class = "side-head" >
< div class = "side-title" >
< h2 > Chats< / h2 >
< button class = "newchat" id = "newChat" title = "New chat" aria-label = "New chat" > +< / button >
< / div >
< div class = "search" >
< svg viewBox = "0 0 24 24" width = "16" height = "16" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < circle cx = "11" cy = "11" r = "8" / > < line x1 = "21" y1 = "21" x2 = "16.65" y2 = "16.65" / > < / svg >
< input id = "chatSearch " placeholder = "Search chats " autocomplete = "off" >
< button class = "search-x" id = "chatSearchX " title = "Clear" aria-label = "Cl ear" 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 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 = "S earch 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 >
< / div >
< / div >
< div class = "chatlist" id = "chatlist" > < / div >
@@ -680,7 +806,8 @@ function autoGrow(el){ if(!el) return; el.style.height='auto'; const max=140; el
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 ] ; }
function firstName ( name ) { return String ( name || '' ) . trim ( ) . split ( /\s+/ ) [ 0 ] || 'there' ; }
const AV _COLORS = [ '#1F3B73' , '#2563eb' , '#0e7490' , '#7c3aed' , '#be185d' , '#b45309' , '#15803d' , '#9d174d' ] ;
// 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' ] ;
function avColor ( name ) { let h = 0 ; for ( const c of String ( name ) ) h = ( h * 31 + c . charCodeAt ( 0 ) ) >>> 0 ; return AV _COLORS [ h % AV _COLORS . length ] ; }
let toastTimer = null ;
@@ -690,10 +817,16 @@ function toast(msg){const t=document.getElementById('toast');t.textContent=msg;t
function profileHTML ( u ) {
const display = u . name || u . email ;
const img = u . avatarUrl ? '<img src="' + pEsc ( u . avatarUrl ) + '" alt="" onerror="this.remove()">' : '' ;
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>' ;
return '<div class="profile"><button class="pbtn icon-only" id="pbtn" title="' + pEsc ( display ) + '">'
+ '<span class="pav">' + pEsc ( initials ( display ) ) + img + '</span></button>'
+ '<span class="pav">' + pEsc ( initials ( display ) ) + img + '</span><span class="pav-dot ' + statusCls ( { online : true , status : cur } ) + '"></span>< /button>'
+ '<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>'
+ '<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>'
+ '<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>'
@@ -702,12 +835,20 @@ function profileHTML(u){
function wireProfile ( ) {
const btn = document . getElementById ( 'pbtn' ) , menu = document . getElementById ( 'pmenu' ) ;
if ( ! btn ) return ;
btn . onclick = ( e ) => { e . stopPropagation ( ) ; menu . classList . toggle ( 'open' ) ; } ;
btn . onclick = ( e ) => { e . stopPropagation ( ) ; const bm = document . getElementById ( 'bellMenu' ) ; if ( bm ) bm . classList . remove ( 'open' ) ; menu . classList . toggle ( 'open' ) ; } ;
document . addEventListener ( 'click' , ( ) => menu . classList . remove ( 'open' ) ) ;
const lo = document . getElementById ( 'plogout' ) ;
if ( lo ) lo . onclick = async ( ) => { try { await fetch ( '/api/logout' , { method : 'POST' } ) ; } catch ( _ ) { } location . href = '/' ; } ;
if ( lo ) lo . onclick = async ( ) => { await unsubscribePush ( ) ; try { await fetch ( '/api/logout' , { method : 'POST' } ) ; } catch ( _ ) { } location . href = '/' ; } ;
const ps = document . getElementById ( 'psettings' ) ;
if ( ps ) ps . onclick = ( ) => { menu . classList . remove ( 'open' ) ; openSettings ( ) ; } ;
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 ( _ ) { }
} ) ;
}
// ---------- Notification bell (activity center) ----------
// Captures things that otherwise have no home: scheduled-meeting invites, reminders, reactions to
@@ -730,7 +871,7 @@ function openNotif(n){ NOTIFS=NOTIFS.filter(x=>x.id!==n.id); saveNotifs(); updat
renderBell ( ) ;
}
function wireBell ( ) { const b = document . getElementById ( 'bellBtn' ) , menu = document . getElementById ( 'bellMenu' ) ; if ( ! b || ! menu ) return ;
b . onclick = ( e ) => { e . stopPropagation ( ) ; const open = menu . classList . toggle ( 'open' ) ; if ( open ) { renderBell ( ) ; NOTIFS . forEach ( x => x . read = true ) ; saveNotifs ( ) ; updateBellBadge ( ) ; } } ;
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 ( ) ; } } ;
document . addEventListener ( 'click' , ( ) => menu . classList . remove ( 'open' ) ) ; menu . onclick = ( e ) => e . stopPropagation ( ) ; updateBellBadge ( ) ;
}
// Settings: notification preferences (browser permission + per-type), stored per browser.
@@ -738,13 +879,15 @@ function openSettings(){
if ( document . getElementById ( 'setModal' ) ) return ;
const ov = document . createElement ( 'div' ) ; ov . className = 'modal-ov' ; ov . id = 'setModal' ;
const sw = ( id , label , on ) => '<label class="gi-setting"><span>' + label + '</span><span class="switch"><input type="checkbox" id="' + id + '"' + ( on ? ' checked' : '' ) + '><span class="slider"></span></span></label>' ;
const granted = ( 'Notification' in window ) && Notification . permission === 'gran ted';
const perm = ( 'Notification' in window ) ? Notification . permission : 'unsuppor ted';
const granted = perm === 'granted' ;
const permLabel = granted ? 'Allowed' : ( perm === 'denied' ? 'Denied' : ( perm === 'default' ? 'Not set' : 'Unsupported' ) ) ;
ov . innerHTML = '<div class="modal sched"><div class="gi-head" style="margin-bottom:.6rem"><div class="avatar grp" style="width:40px;height:40px;flex:0 0 40px;background:var(--blue)">' + ic ( 'settings' , 20 ) + '</div>'
+ '<div class="gi-name"><div class="gi-title">Settings</div><div class="gi-sub">Notifications</div></div>'
+ '<button class="iconbtn" id="setClose" title="Close">' + ic ( 'x' , 18 ) + '</button></div>'
+ sw ( 'setGroup' , 'Group message notifications' , notifOn ( 'group' ) )
+ sw ( 'setDm' , 'Direct message notifications' , notifOn ( 'dm' ) )
+ '<label class="gi-setting"><span>Browser/desktop p op- ups</span> <button class="btn sm" id="setPerm"' + ( granted ? ' disabled' : '' ) + '>' + ( granted ? 'Enabled ': 'Enable' ) + '</button></label>'
+ '<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">P op 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>'
+ '<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 ( ) ; } ;
@@ -752,7 +895,7 @@ function openSettings(){
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 ) ;
const perm = ov . querySelector ( '#setPerm' ) ; if ( perm ) perm . onclick = async ( ) => { try { const r = await Notification . requestPermission ( ) ; if ( r === 'granted' ) { perm . textContent = 'Enabled' ; perm . disabled = true ; toast ( 'Desktop notifications enabled' ) ; try { await subscribePush ( ) ; } catch ( _ ) { } } else toast ( 'Notifications blocked — allow them in your browser site settings' ) ; } catch ( _ ) { toast ( 'Notifications need HTTPS or localhost' ) ; } } ;
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)
}
// ---------- Chat (1:1 + groups) ----------
@@ -763,6 +906,114 @@ 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)
// 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 ( _ ) { } }
// #9: in-chat search — highlight every match and jump between them with up/down (no filtering).
let _searchHits = [ ] , _searchIdx = - 1 ;
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 ( _ ) { } } ) ; }
_searchHits = [ ] ; _searchIdx = - 1 ;
}
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 ;
}
function runSearch ( q ) {
clearSearchHighlights ( ) ;
const box = document . getElementById ( 'msgs' ) , cnt = document . getElementById ( 'convoSearchCount' ) ; if ( ! box ) return ;
q = ( q || '' ) . trim ( ) ; if ( ! q ) { if ( cnt ) cnt . textContent = '' ; return ; }
_searchHits = _highlightIn ( box , q . toLowerCase ( ) ) ;
if ( ! _searchHits . length ) { if ( cnt ) cnt . textContent = '0/0' ; return ; }
gotoHit ( _searchHits . length - 1 ) ; // start at the most recent match
}
function gotoHit ( i ) {
if ( ! _searchHits . length ) return ;
_searchIdx = ( ( i % _searchHits . length ) + _searchHits . length ) % _searchHits . length ;
_searchHits . forEach ( m => m . classList . remove ( 'search-current' ) ) ;
const m = _searchHits [ _searchIdx ] ; m . classList . add ( 'search-current' ) ; try { m . scrollIntoView ( { block : 'center' } ) ; } catch ( _ ) { }
const cnt = document . getElementById ( 'convoSearchCount' ) ; if ( cnt ) cnt . textContent = ( _searchIdx + 1 ) + '/' + _searchHits . length ;
}
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' ;
ov . innerHTML = '<div class="modal gi"><div class="gi-head" style="margin-bottom:.6rem"><span class="avatar" style="width:46px;height:46px;flex:0 0 46px;background:' + avColor ( name ) + '">' + pEsc ( initials ( name || '?' ) ) + '</span><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>'
+ mediaRowHTML ( ) + '</div>' ;
document . body . appendChild ( ov ) ;
ov . onclick = e => { if ( e . target === ov ) ov . remove ( ) ; } ;
ov . querySelector ( '#shClose' ) . onclick = ( ) => ov . remove ( ) ;
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 ) ;
}
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 ( ) ;
@@ -779,11 +1030,14 @@ function fmtTime(ts){
if ( d . toDateString ( ) === y . toDateString ( ) ) return 'Yesterday' ;
return d . toLocaleDateString ( [ ] , { month : 'short' , day : 'numeric' } ) ;
}
// 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' ; }
function avatarHTML ( it , big ) {
const isG = it . kind === 'group' ;
const sz = big ? 'width:38px;height:38px;flex:0 0 38px;' : '' ;
const corner = isG ? ( it . members ? '<span class="mcount">' + ( it . members > 99 ? '99+' : it . members ) + '</span>' : '' )
: '<span class="dot' + ( it . online ? ' on' : '' ) + '"></span>' ;
: '<span class="dot ' + statusCls ( it ) + '"></span>' ;
const inner = isG ? ic ( 'users' , big ? 20 : 18 ) : pEsc ( initials ( it . name ) ) ;
// Photo overlay (BizGaze profile picture, or an uploaded group image). If it fails to
// load it removes itself, revealing the initials/glyph underneath.
@@ -804,8 +1058,13 @@ function renderChats(filter){
const q = ( filter || '' ) . trim ( ) . toLowerCase ( ) ;
const rows = ROWS . filter ( it => ! q || it . name . toLowerCase ( ) . includes ( q ) || ( it . last _body || '' ) . toLowerCase ( ) . includes ( q ) )
. sort ( ( a , b ) => ( b . last _at - a . last _at ) || a . name . localeCompare ( b . name ) ) ;
let html = rows . length ? rows . map ( rowHTML ) . join ( '' )
: '<div class="no-results">' + ( ROWS . length ? ( 'No chat s m atch “' + pEsc ( filter ) + '”.' ) : 'No conversations yet.' ) + '</div>' ;
let html ;
if ( ! q ) { // group favourite s at the top when not searching
const favs = rows . filter ( r => r . favorite ) , rest = rows . filter ( r => ! r . favorite ) ;
html = rows . length ? ( ( favs . length ? '<div class="side-sec">' + ic ( 'star' , 12 ) + ' Favourites</div>' + favs . map ( rowHTML ) . join ( '' ) + ( rest . length ? '<div class="side-sec">All chats</div>' : '' ) : '' ) + rest . map ( rowHTML ) . join ( '' ) ) : '<div class="no-results">No conversations yet.</div>' ;
} else {
html = rows . length ? rows . map ( rowHTML ) . join ( '' ) : '<div class="no-results">No chats match “' + pEsc ( filter ) + '”.</div>' ;
}
if ( q . length >= 1 ) {
// Team contacts you haven't messaged yet → start a new DM.
const haveDm = new Set ( ROWS . filter ( r => r . kind === 'dm' ) . map ( r => r . id ) ) ;
@@ -857,7 +1116,7 @@ async function loadSidebar(){
function welcomeHTML ( ) {
return '<div class="welcome">'
+ '<div class="wave">👋</div>'
+ '<h1>Hi, ' + pEsc ( firstName ( ME . name || ME . email ) ) + '! Welcome to BizGaze Connect</h1>'
+ '<h1>Hi, ' + pEsc ( firstName ( ME . name || ME . email ) ) + '! Welcome to Biz Connect</h1>'
+ '<p>Pick a conversation on the left to start chatting, or jump straight into a session from the sidebar.</p>'
+ '<div class="wcards">'
+ '<div class="wcard" data-go="share"><div class="wi"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg></div><h3>Share Screen</h3><p>Show your screen with a 6-digit code</p></div>'
@@ -868,14 +1127,22 @@ function welcomeHTML(){
function wireWelcome ( ) { document . querySelectorAll ( '#chatPanel .wcard' ) . forEach ( card => { card . onclick = ( ) => switchTab ( card . dataset . go ) ; } ) ; }
function convoShellHTML ( it ) {
const isG = it . kind === 'group' ;
const sub = isG ? ( ( it . members || 0 ) + ' members' ) : ( it . online ? 'Online' : 'Offline' ) ;
const sub = isG ? ( ( it . members || 0 ) + ' members' ) : statusLabel ( it ) ;
return '<div class="convo">'
+ '<div class="convo-head">'
+ '<button class="convo-back" id="convoBack" title="Back (Esc)" aria-label="Back">' + ic ( 'arrowLeft' , 18 ) + '</button>'
+ avatarHTML ( it , true )
+ '<div class="convo-titlewrap' + ( isG ? ' gi-open" id="convoTitle" title="Group info"' : '"' ) + ' ><div class="nm">'+ pEsc ( it . name ) + '</div><div class="st">' + pEsc ( sub ) + '</div></div>'
+ '<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>'
+ '<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>' : '' )
+ '<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>'
+ '</div>'
+ '<div class="convo-msgs" id="msgs"></div>'
+ '<div class="float-date" id="floatDate" style="display:none"></div>'
@@ -914,6 +1181,7 @@ function bubbleHTML(m){
if ( m . evt === 'call-start' ) return '<div class="sys-msg">📞 ' + ( m . from === ME . id ? 'You' : pEsc ( m . byName || 'Someone' ) ) + ' started a call</div>' ;
if ( m . system || m . from === '__system__' ) return '<div class="sys-msg">' + pEsc ( m . body ) + '</div>' ;
const mine = m . from === ME . id ;
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>' ;
const sender = ( convoIsGroup && ! mine && m . fromName ) ? '<div class="sender">' + senderAvatar ( m . from , m . fromName ) + '<span>' + pEsc ( m . fromName ) + '</span></div>' : '' ;
let quote = '' ;
if ( m . reply ) { const t = replyTint ( m . reply . from || m . reply . fromName ) ; quote = '<div class="quote" style="background:' + t [ 0 ] + ';border-left-color:' + t [ 1 ] + '"><b style="color:' + t [ 1 ] + '">' + pEsc ( m . reply . fromName || '' ) + '</b>: ' + pEsc ( m . reply . body ) + '</div>' ; }
@@ -932,6 +1200,7 @@ function bubbleHTML(m){
+ sender + quote + att + renderMsgBody ( m ) + pollHTML ( m )
+ '<button class="reply-btn" data-id="' + pEsc ( m . id ) + '" title="Reply">' + ic ( 'reply' , 14 ) + '</button>'
+ '<button class="react-btn" data-id="' + pEsc ( m . id ) + '" title="React">' + ic ( 'smilePlus' , 14 ) + '</button>'
+ ( mine ? '<button class="del-btn" data-del="' + pEsc ( m . id ) + '" title="Delete message">' + ic ( 'trash' , 13 ) + '</button>' : '' )
+ '<span class="t">' + pEsc ( fmtClock ( m . created _at ) ) + rcpt + '</span>'
+ reacts + seen + '</div>' ;
}
@@ -1059,6 +1328,16 @@ function onChatReaction(d){ const m=THREAD.find(x=>x.id===d.messageId); if(m &&
}
// Read receipts (DM): the other party read my messages → mark mine as seen.
function onChatRead ( d ) { if ( ! d || ! d . by ) return ; if ( selected && selected . kind === 'dm' && selected . id === d . by ) { let changed = false ; THREAD . forEach ( m => { if ( m . from === ME . id && ! m . read _at ) { m . read _at = Date . now ( ) ; changed = true ; } } ) ; if ( changed ) renderThread ( ) ; } }
// Delete-for-everyone: confirm, tell the server, then blank the message locally (the server also
// broadcasts chat-deleted to the other side / other tabs).
async function deleteMessage ( id ) { if ( ! confirm ( 'Delete this message for everyone?' ) ) return ; try { await postJSON ( '/api/messages/delete' , { id } ) ; markMsgDeleted ( id ) ; } catch ( e ) { toast ( e . message || 'Could not delete' ) ; } }
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
// DM delivered: recipient's client acked → second (grey) tick.
function onChatDelivered ( d ) { if ( ! d || ! d . id ) return ; const m = THREAD . find ( x => x . id === d . id ) ; if ( m && ! m . delivered _at ) { m . delivered _at = Date . now ( ) ; updateBubble ( m ) ; } }
// Group read: a member opened the group → add them to "Seen by" on my messages up to that time.
@@ -1129,6 +1408,10 @@ function showMeetingReminder(mtg){ if(!mtg||!mtg.room) return; try{ playPing();
// 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 ( ) ; }
// #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 ( ) ; }
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 ( ) ; }
@@ -1146,19 +1429,20 @@ function renderMeetPanel(){
const pool = inGroup ? ( _addPool || [ ] ) : ( CONTACTS || [ ] ) ;
const here = new Set ( list . map ( pp => ( pp . name || '' ) . replace ( /\s*\(you\)$/ , '' ) . trim ( ) . toLowerCase ( ) ) ) ;
const myName = ( ( ME && ME . name ) || ( ME && ME . email ) || '' ) . trim ( ) . toLowerCase ( ) ;
const avail = pool . filter ( c => c . id !== ( ME && ME . id ) && ( c . name || '' ) . trim ( ) . toLowerCase ( ) !== myName && ! here . has ( ( c . name || '' ) . trim ( ) . toLowerCase ( ) ) ) ;
const avail = pool . filter ( c => c . id !== ( ME && ME . id ) && ( c . name || '' ) . trim ( ) . toLowerCase ( ) !== myName && ! here . has ( ( c . name || '' ) . trim ( ) . toLowerCase ( ) ) && ! meetInvited . has ( c . id ) ) ;
body = '<div class="mp-list">' + ( avail . length ? avail . map ( c => '<label class="chk"><input type="checkbox" value="' + pEsc ( c . id ) + '"><span class="mini-av" style="background:' + avColor ( c . name ) + '">' + pEsc ( initials ( c . name ) ) + '</span><span class="mn">' + pEsc ( c . name ) + '</span></label>' ) . join ( '' ) : '<div class="gi-noresult">' + ( inGroup && _addPool === null ? 'Loading…' : 'Everyone\'s already here' ) + '</div>' ) + '</div><button class="gobtn" id="mpInvite" style="margin:.5rem">Invite to call</button>' ;
} else {
body = '<div class="mp-list">' + list . map ( pp => '<div class="mp-row"><span class="mini-av" style="background:' + avColor ( pp . name ) + '">' + pEsc ( initials ( pp . name ) ) + '</span><span class="mn">' + pEsc ( pp . name ) + '</span>' + ( isHostRow ( pp ) ? '<span class="host-tag">' + ic ( 'crown' , 11 ) + ' Host</span>' : '' ) + ( ( pp . id === '__local' ? meetScreen : meetSharers . has ( pp . id ) ) ? '<span class="pp-screen" title="Sharing screen">' + ic ( 'monitor' , 13 ) + '</span>' : '' ) + ( meetMuted . get ( pp . id ) ? '<span class="pp-mute">' + ic ( 'micOff' , 13 ) + '</span>' : '' ) + ( ( meetIsHost && pp . id !== '__local' && ! isHostRow ( pp ) ) ? '<button class="mp-makehost" data-id="' + pEsc ( pp . id ) + '" title="Make host">' + ic ( 'crown' , 14 ) + '</button>' : '' ) + '</div>' ) . join ( '' ) + '</div>'
+ ( 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>' : '' )
+ ( meetIsHost ? '<label class="gi-setting mp-setting"><span>' + ic ( 'monitor' , 15 ) + ' Allow multiple people to share</span><span class="switch"><input type="checkbox" id="mpMulti"' + ( meetMultiShare ? ' checked' : '' ) + '><span class="slider"></span></span></label>' : '' ) ;
}
p . innerHTML = head + tabs + body ;
p . innerHTML = head + tabs + '<div class="mp-scroll">' + body + '</div>' ;
const x = p . querySelector ( '.mp-x' ) ; if ( x ) x . onclick = ( ) => p . remove ( ) ;
p . querySelectorAll ( '.mp-tab' ) . forEach ( b => b . onclick = ( ) => { meetPanelTab = b . dataset . tab ; renderMeetPanel ( ) ; } ) ;
const ma = p . querySelector ( '.mp-muteall' ) ; if ( ma ) ma . onclick = ( ) => { meetSend ( { type : 'meeting-muteall' } ) ; toast ( 'Muted everyone' ) ; } ;
const mm = p . querySelector ( '#mpMulti' ) ; if ( mm ) mm . onchange = ( ) => { meetMultiShare = mm . checked ; meetSend ( { type : 'meeting-sharemode' , multi : meetMultiShare } ) ; } ;
p . querySelectorAll ( '.mp-makehost' ) . forEach ( b => b . onclick = ( ) => { meetSend ( { type : 'meeting-host' , to : b . dataset . id } ) ; meetHostId = b . dataset . id ; meetIsHost = false ; renderMeetPanel ( ) ; } ) ;
const inv = p . querySelector ( '#mpInvite' ) ; if ( inv ) inv . onclick = async ( ) => { const ids = [ ... p . querySelectorAll ( 'input:checked' ) ] . map ( i => i . value ) ; if ( ! ids . length ) { toast ( 'Pick people to invite' ) ; return ; } try { const r = await postJSON ( '/api/calls/invite' , { room : meetRoom , userIds : ids } ) ; toast ( 'Invited ' + r . invited + ( r . invited === 1 ? ' person' : ' people' ) ) ; meetPanelTab = 'people' ; renderMeetPanel ( ) ; } catch ( e ) { toast ( e . message || 'Could not invite' ) ; } } ;
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' ) ; } } ;
}
// Add people to the current call (from the in-call bar).
function openInvitePicker ( room ) {
@@ -1221,11 +1505,17 @@ function appendBubble(m){
box . insertAdjacentHTML ( 'beforeend' , bubbleHTML ( m ) ) ;
box . scrollTop = box . scrollHeight ;
}
let _openUnread = 0 ; // set by selectChat (the unread count before it's reset) so we can open at the first unread
async function openConvo ( kind , id ) {
const it = rowFor ( kind , id ) || { kind , id , name : 'Conversation' } ;
const _unreadN = _openUnread ; _openUnread = 0 ; // consume the captured unread count (#3)
convoIsGroup = ( kind === 'group' ) ;
const el = document . getElementById ( 'chatPanel' ) ; el . classList . remove ( 'center' ) ;
el . innerHTML = convoShellHTML ( it ) ;
// #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 ) ; } } ;
const back = document . getElementById ( 'convoBack' ) ; if ( back ) back . onclick = showWelcome ;
const form = document . getElementById ( 'composer' ) ; if ( form ) form . addEventListener ( 'submit' , ( e ) => { e . preventDefault ( ) ; sendMessage ( ) ; } ) ;
clearReply ( ) ; pendingAttach = null ; hideAttach ( ) ;
@@ -1242,18 +1532,33 @@ async function openConvo(kind,id){
inpEl . addEventListener ( 'keydown' , ( e ) => { if ( e . key === 'Enter' && ! e . shiftKey ) { if ( mentionItems && mentionItems . length ) return ; e . preventDefault ( ) ; sendMessage ( ) ; } } ) ; // Enter sends, Shift+Enter = newline
}
const ci = document . getElementById ( 'convoInfo' ) ; if ( ci ) ci . onclick = ( ) => openGroupInfo ( id ) ;
const ct = document . getElementById ( 'convoTitle' ) ; if ( ct ) ct . onclick = ( ) => openGroupInfo ( id ) ;
const 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
const cc = document . getElementById ( 'convoCall' ) ; if ( cc ) cc . onclick = ( ) => ( kind === 'group' ? startOrJoinGroupCall ( id ) : startOrJoinDmCall ( id ) ) ;
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 ) ;
const box = document . getElementById ( 'msgs' ) ; if ( box ) box . addEventListener ( 'click' , ( e ) => {
const im = e . target . closest ( '.att-img' ) ; if ( im && im . dataset . img ) { openLightbox ( im . dataset . img ) ; return ; }
const po = e . target . closest ( '.poll-opt' ) ; if ( po ) { if ( ! po . disabled ) votePoll ( po . dataset . poll , + po . dataset . idx ) ; return ; }
const pcl = e . target . closest ( '.poll-close' ) ; if ( pcl ) { closePoll ( pcl . dataset . poll ) ; return ; }
const rb = e . target . closest ( '.reply-btn' ) ; if ( rb ) { const mm = THREAD . find ( x => x . id === rb . dataset . id ) ; if ( mm ) setReply ( mm ) ; return ; }
const dl = e . target . closest ( '.del-btn' ) ; if ( dl ) { deleteMessage ( dl . dataset . del ) ; return ; }
const ab = e . target . closest ( '.react-btn' ) ; if ( ab ) { openEmojiForReact ( ab . dataset . id , ab ) ; return ; }
const ch = e . target . closest ( '.react-chip' ) ; if ( ch ) { reactToMessage ( ch . dataset . id , ch . dataset . emoji ) ; return ; }
const sb = e . target . closest ( '.seenby' ) ; if ( sb ) { const ns = ( sb . dataset . seen || '' ) . split ( '|' ) . filter ( Boolean ) ; toast ( 'Seen by: ' + ns . join ( ', ' ) ) ; return ; }
// 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' ) ;
} ) ;
if ( box ) box . addEventListener ( 'scroll' , onMsgsScroll ) ;
if ( box ) enablePullRefresh ( box , reloadThread ) ; // pull down at the top to refresh the conversation
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
@@ -1269,6 +1574,13 @@ async function openConvo(kind,id){
THREAD = Array . isArray ( msgs ) ? msgs : [ ] ;
THREAD _CACHE . set ( ckey , THREAD . slice ( ) ) ;
renderThread ( ) ;
// #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' ; }
}
const inp = document . getElementById ( 'msgInput' ) ; if ( inp ) inp . focus ( ) ;
}
// ---------- @mentions (group chat) ----------
@@ -1426,7 +1738,7 @@ async function selectChat(kind,id){
ensureNotifyPermission ( ) ;
selected = { kind , id } ;
document . body . classList . add ( 'chat-open' ) ; // mobile: show the conversation pane
const it = rowFor ( kind , id ) ; if ( it ) it . unread = 0 ;
const it = rowFor ( kind , id ) ; _openUnread = ( it && it . unread ) || 0 ; if ( it ) it . unread = 0 ; // capture before reset (#3: open at first unread)
renderChats ( searchVal ( ) ) ;
updateRailUnread ( ) ;
await openConvo ( kind , id ) ;
@@ -1451,10 +1763,34 @@ async function sendMessage(){
renderChats ( searchVal ( ) ) ;
} catch ( e ) { inp . value = text ; toast ( e . message || 'Could not send' ) ; }
}
function ensureNotifyPermission ( ) { try { if ( 'Notification' in window && Notification . permission === 'default' ) Notification . requestPermission ( ) ; } catch ( _ ) { } }
// Notification preferences (set in Dashboard → Settings; stored per browser). Default ON.
// 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.
function ensureNotifyPermission ( ) { try { if ( 'Notification' in window && Notification . permission === 'default' ) Notification . requestPermission ( ) . then ( r => { if ( r === 'granted' ) { try { subscribePush ( ) ; } catch ( _ ) { } } } ) . catch ( ( ) => { } ) ; } catch ( _ ) { } }
// Notification preferences (set in Settings; stored per browser). Default ON.
function notifOn ( kind ) { try { return localStorage . getItem ( 'notif_' + kind ) !== 'off' ; } catch ( _ ) { return true ; } }
document . addEventListener ( 'click' , ensureNotifyPermission , { once : true } ) ;
// 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 {
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 ( _ ) { }
}
// ----- 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).
@@ -1481,6 +1817,16 @@ async function subscribePush(){
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
}
// On logout, remove this device's push subscription so notifications STOP for the logged-out user.
async function unsubscribePush ( ) {
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 ( _ ) { }
}
// Open the chat from an in-page notification. Navigation reliably repaints across browsers (a
// 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.
@@ -1493,14 +1839,16 @@ function notify(title, body, kind, id){
} catch ( _ ) { }
}
let _audioCtx = null ;
// Message chime: a crisp, recognizable rising two-note ("ti-doo") — louder + brighter than the
// old single beep so it's catchy and noticed.
function playPing ( ) {
try {
_audioCtx = _audioCtx || new ( window . AudioContext || window . webkitAudioContext ) ( ) ;
if ( _audioCtx . state === 'suspended' ) _audioCtx . resume ( ) ;
const t = _audioCtx . currentTime , o = _audioCtx . createOscillator ( ) , g = _audioCtx . createGain ( ) ;
o . type = 'sin e' ; o . frequency . setValueAtTime ( 880 , t ) ; o . frequency . setValueAtTime ( 660 , t + 0.09 ) ;
g . gain . setValueAtTime ( 0.0001 , t ) ; g . gain . exponentialRampToValueAtTime ( 0.14 , t + 0.012 ) ; g . gain . exponentialRampToValueAtTime ( 0.0001 , t + 0.35 ) ;
o . connect ( g ) ; g . connect ( _audioCtx . destination ) ; o . start ( t ) ; o . stop ( t + 0.36 ) ;
const t = _audioCtx . currentTime ;
const tone = ( f , start , dur , peak ) => { const o = _audioCtx . createOscillator ( ) , g = _audioCtx . createGain ( ) ; o . type = 'triangl e' ; 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"
} catch ( _ ) { }
}
// Continuous incoming-call ring. A soft, soothing bell chime — a gentle ascending arpeggio
@@ -1521,10 +1869,10 @@ function ringOnce(){
_audioCtx = _audioCtx || new ( window . AudioContext || window . webkitAudioContext ) ( ) ;
if ( _audioCtx . state === 'suspended' ) _audioCtx . resume ( ) ;
const t = _audioCtx . currentTime ;
ringTone ( t , 523.25 , 1.2 , 0.11 ) ; // C5
ringTone ( t + 0.20 , 659.25 , 1.2 , 0.10 ) ; // E5
ringTone ( t + 0.40 , 783.99 , 1.5 , 0.11 ) ; // G5
ringTone ( t + 0.60 , 1046.5 , 1.9 , 0.09 ) ; // C6 — rings out softly
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
} catch ( _ ) { }
}
function startRing ( ) { _ringRefs ++ ; if ( _ringTimer ) return ; ringOnce ( ) ; _ringTimer = setInterval ( ringOnce , 3500 ) ; }
@@ -1570,7 +1918,7 @@ function connectChatWs(){
try {
chatWs = new WebSocket ( ( location . protocol === 'https:' ? 'wss://' : 'ws://' ) + location . host + '/ws' ) ;
chatWs . onopen = ( ) => { try { chatWs . send ( JSON . stringify ( { type : 'chat-hello' } ) ) ; } catch ( _ ) { } } ;
chatWs . onmessage = ( e ) => { let d ; try { d = JSON . parse ( e . data ) ; } catch ( _ ) { return ; } if ( d . type === 'chat-message' && d . message ) onChatMessage ( d . message ) ; else if ( d . type === 'chat-reaction' ) onChatReaction ( d ) ; else if ( d . type === 'poll-update' && d . poll ) onPollUpdate ( d ) ; else if ( d . type === 'chat-read' ) onChatRead ( d ) ; else if ( d . type === 'chat-delivered' ) onChatDelivered ( d ) ; else if ( d . type === 'group-read' ) onGroupRead ( d ) ; else if ( d . type === 'group-call' ) onGroupCall ( d ) ; else if ( d . type === 'dm-call' ) onDmCall ( d ) ; else if ( d . type === 'group-update' ) onGroupUpdate ( d ) ; else if ( d . type === 'call-invite' ) showCallInvite ( d . room , d . byName ) ; else if ( d . type === 'meeting-invite' ) showMeetingInvite ( d . meeting ) ; else if ( d . type === 'meeting-reminder' ) showMeetingReminder ( d . meeting ) ; else if ( d . type === 'meeting-cancelled' ) showMeetingCancelled ( d . meeting ) ; else if ( d . type === 'group-role' ) onGroupRole ( d ) ; } ;
chatWs . 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- reaction') onChatReaction ( d ) ; else if ( d . type === 'poll-update' && d . poll ) onPollUpdate ( d ) ; else if ( d . type === 'chat-read' ) onChatRead ( d ) ; else if ( d . type === 'chat-delivered' ) onChatDelivered ( d ) ; else if ( d . type === 'group-read' ) onGroupRead ( d ) ; else if ( d . type === 'group-call' ) onGroupCall ( d ) ; else if ( d . type === 'dm-call' ) onDmCall ( d ) ; else if ( d . type === 'group-update' ) onGroupUpdate ( d ) ; else if ( d . type === 'call-invite' ) showCallInvite ( d . room , d . byName ) ; else if ( d . type === 'meeting-invite' ) showMeetingInvite ( d . meeting ) ; else if ( d . type === 'meeting-reminder' ) showMeetingReminder ( d . meeting ) ; else if ( d . type === 'meeting-cancelled' ) showMeetingCancelled ( d . meeting ) ; else if ( d . type === 'group-role' ) onGroupRole ( d ) ; } ;
chatWs . onclose = ( ) => { setTimeout ( connectChatWs , 3000 ) ; } ; // auto-reconnect
} catch ( _ ) { }
}
@@ -1585,12 +1933,16 @@ function openNewGroup(){
const ov = document . createElement ( 'div' ) ; ov . className = 'modal-ov' ; ov . id = 'groupModal' ;
ov . innerHTML = '<div class="modal"><h3>New group</h3>'
+ '<input id="grpName" placeholder="Group name" maxlength="80">'
+ '<div class="grp-members">' + ( CONTACTS . length ? CONTACTS . map ( c => '<label class="chk"><input type="checkbox" value="' + pEsc ( c . id ) + '"> ' + pEsc ( c . name ) + '</label>' ) . join ( '' ) : '<div class="muted" style="padding:.5rem">No teammates to add yet.</div>' ) + '</div>'
+ ( 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>'
+ '<div class="modal-actions"><button class="gobtn" id="grpCancel" style="background:#eef1f6;color:var(--blue)">Cancel</button><button class="gobtn" id="grpCreate">Create group</button></div></div>' ;
document . body . appendChild ( ov ) ;
ov . onclick = ( e ) => { if ( e . target === ov ) ov . remove ( ) ; } ;
document . getElementById ( 'grpCancel' ) . onclick = ( ) => ov . remove ( ) ;
document . getElementById ( 'grpCreate' ) . onclick = createGroup ;
const 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' ; } ;
const n = document . getElementById ( 'grpName' ) ; if ( n ) n . focus ( ) ;
}
async function createGroup ( ) {
@@ -1617,7 +1969,7 @@ async function openGroupInfo(gid){
+ '<input type="file" id="giPhotoInput" accept="image/*" style="display:none">'
+ '<div class="gi-head"><button class="gi-photo" id="giPhoto" title="Change group photo"><span class="avatar grp" style="width:46px;height:46px;background:' + avColor ( info . name ) + '">' + ic ( 'users' , 24 ) + ( info . avatar ? '<img class="av-img" src="' + pEsc ( info . avatar ) + '" alt="" onerror="this.remove()">' : '' ) + '</span><span class="gi-photo-cam">' + ic ( 'camera' , 13 ) + '</span></button>'
+ '<div class="gi-name">'
+ '<div class="gi-name-row" id="giView"><span class="gi-title">' + pEsc ( info . name ) + '</span><button class="iconbtn sm" id="giEdit" title="Rename group">' + ic ( 'pencil' , 15 ) + '</button></div>'
+ '<div class="gi-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>'
+ '<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>'
@@ -1625,6 +1977,7 @@ async function openGroupInfo(gid){
+ '<div class="gi-actions"><button class="gi-act" id="giCall">' + ic ( 'phone' , 18 ) + '<span>Call</span></button><button class="gi-act" id="giSchedule">' + ic ( 'calendar' , 18 ) + '<span>Schedule</span></button></div>'
+ ( info . createdByName ? '<div class="gi-created">' + ic ( 'info' , 13 ) + ' Created by <b>' + pEsc ( info . createdByName ) + '</b> on ' + pEsc ( fmtDateTime ( info . createdAt ) ) + '</div>' : '' )
+ ( info . isAdmin ? '<label class="gi-setting"><span>' + ic ( 'lock' , 15 ) + ' Only admins can add or remove people</span><span class="switch"><input type="checkbox" id="giAdminOnly"' + ( info . adminOnly ? ' checked' : '' ) + '><span class="slider"></span></span></label>' : '' )
+ '<div class="gi-sec-h"><span>Shared</span></div>' + mediaRowHTML ( )
+ '<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">'
@@ -1639,8 +1992,10 @@ async function openGroupInfo(gid){
+ '</div>' ;
const _old = document . getElementById ( 'groupInfo' ) ; if ( _old ) _old . remove ( ) ; // never stack group-info modals (stale close buttons)
document . body . appendChild ( ov ) ;
wireMediaEntry ( ov , 'group' , gid , info . name ) ; // "Media, links & docs" entry above the members
ov . onclick = ( e ) => { if ( e . target === ov ) ov . remove ( ) ; } ;
ov . querySelector ( '#giClose' ) . onclick = ( ) => ov . remove ( ) ;
{ 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 ( ) ) ; } ; }
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 ( ) ; } ;
@@ -1698,6 +2053,10 @@ let meetAudioOnly=false; // audio call (no camera) — tiles show avatars ins
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)
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)
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' } ] } ;
@@ -1919,14 +2278,15 @@ function addTile(id, stream, label, muted){
const grid = document . getElementById ( 'meetGrid' ) ; if ( ! grid ) return ;
let tile = document . getElementById ( 'meet-tile-' + id ) ;
if ( ! tile ) { tile = document . createElement ( 'div' ) ; tile . className = 'meet-tile' ; tile . id = 'meet-tile-' + id ;
tile . innerHTML = '<video autoplay playsinline' + ( muted ? ' muted' : '' ) + '></video><div class="meet-av" style="background:' + avColor ( label || '?' ) + '">' + pEsc ( initials ( label || '?' ) ) + '</div><div class="meet-mute" style="display:none">' + ic ( 'micOff' , 14 ) + '</div><span class="nm">' + pEsc ( label || '' ) + '</span>' ; grid . appendChild ( tile ) ; }
const 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 ) ; }
const v = tile . querySelector ( 'video' ) ; if ( v && stream && v . srcObject !== stream ) v . srcObject = stream ;
const hasVid = ! ! ( stream && stream . getVideoTracks && stream . getVideoTracks ( ) . some ( t => t . enabled && t . readyState !== 'ended' ) ) ;
tile . classList . toggle ( 'novid' , ! hasVid ) ;
tile . classList . toggle ( 'novid' , ! hasVid || meetCamOff . get ( id ) === true ) ; // camOff peer → avatar even if a (disabled) track arrives
if ( meetMuted . has ( id ) ) setTileMute ( id , meetMuted . get ( id ) ) ; // apply any known mute state
}
function setTileMute ( id , muted ) { meetMuted . set ( id , ! ! muted ) ; const t = document . getElementById ( 'meet-tile-' + id ) ; if ( t ) { const b = t . querySelector ( '.meet-mute' ) ; if ( b ) b . style . display = muted ? 'grid' : 'none' ; if ( muted ) t . classList . remove ( 'speaking' ) ; } }
function removeTile ( id ) { const t = document . getElementById ( 'meet-tile-' + id ) ; if ( t ) t . remove ( ) ; meetMuted . delete ( id ) ; meetUnwatch ( id ) ; }
function removeTile ( id ) { const t = document . getElementById ( 'meet-tile-' + id ) ; if ( t ) t . remove ( ) ; meetMuted . delete ( id ) ; meetCamOff . delete ( id ) ; meetUnwatch ( id ) ; }
// ---- Active-speaker meter: highlight a tile while its audio is above a threshold ----
function meetWatchStream ( id , stream ) {
if ( ! stream || ! stream . getAudioTracks || ! stream . getAudioTracks ( ) . length ) return ; // no audio yet
@@ -2095,7 +2455,7 @@ async function onMeetMsg(e){
meetWatchStream ( '__local' , meetLocalStream ) ; // active-speaker detection on my own mic
// Existing peers OFFER to me (their offers carry their tracks incl. any active screen share);
// I just set up the connections and wait. Avoids the "newcomer can't receive screen" bug.
for ( const p of ( m . peers || [ ] ) ) { meetNames . set ( p . peerId , p . name ) ; meetMakePeer ( p . peerId , p . name ) ; }
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 ) ; }
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 ( ) ;
@@ -2103,7 +2463,7 @@ async function onMeetMsg(e){
}
if ( m . type === 'meeting-ended' ) { toast ( 'Call ended' ) ; leaveMeeting ( ) ; return ; } // 1:1 hangup, or host ended
if ( m . type === 'meeting-peer-joined' ) {
meetNames . set ( m . peerId , m . name ) ;
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 ) ; }
const pc = meetMakePeer ( m . peerId , m . name ) ; // I'm an existing peer → I OFFER to the newcomer (carries my screen)
try { const offer = await pc . createOffer ( ) ; await pc . setLocalDescription ( offer ) ; meetSend ( { type : 'meeting-signal' , to : m . peerId , data : { sdp : pc . localDescription } } ) ; } catch ( _ ) { }
meetSend ( { type : 'meeting-state' , muted : ! meetMic , camOff : ! meetCam } ) ;
@@ -2111,7 +2471,7 @@ async function onMeetMsg(e){
if ( meetScreen ) meetSend ( { type : 'meeting-screen' , on : true } ) ;
refreshMeetPanel ( ) ; return ;
}
if ( m . type === 'meeting-peer-state' ) { setTileMute ( m . peerId , ! ! m . muted ) ; refreshMeetPanel ( ) ; return ; }
if ( m . type === 'meeting-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 ) ; } refreshMeetPanel ( ) ; return ; } // camOff -> show avatar, not a black tile
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
@@ -2183,7 +2543,7 @@ function leaveMeeting(){
if ( _recTimer ) { clearInterval ( _recTimer ) ; _recTimer = null ; } const _rn = document . getElementById ( 'recNotice' ) ; if ( _rn ) _rn . remove ( ) ; // clear any "Recording" badge
meetSend ( { type : 'meeting-leave' } ) ;
meetUnwatchAll ( ) ; meetSharers . clear ( ) ;
meetPeers . forEach ( p => { try { p . pc . close ( ) ; } catch ( _ ) { } } ) ; meetPeers . clear ( ) ; meetNames . clear ( ) ; meetMuted . clear ( ) ;
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 ( ) ;
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 ) ;
@@ -2221,7 +2581,24 @@ railBtns.forEach(btn=>{ btn.onclick=()=>{
switchTab ( tab ) ;
} ; } ) ;
// Esc clears the open conversation and brings back the welcome screen.
document . addEventListener ( 'keydown' , ( e ) => { if ( e . key === 'Escape' && currentTab ( ) === 'chat' && selected != null ) { showWelcome ( ) ; } } ) ;
// Esc closes the topmost popup/search FIRST (capture phase, before the conversation-close below).
document . addEventListener ( 'keydown' , ( e ) => {
if ( e . key !== 'Escape' ) return ;
const ovs = document . querySelectorAll ( '.modal-ov' ) ;
if ( ovs . length ) { ovs [ ovs . length - 1 ] . remove ( ) ; e . preventDefault ( ) ; e . stopPropagation ( ) ; return ; }
const sh = document . getElementById ( 'convoSearchHead' ) ; if ( sh && sh . style . display !== 'none' ) { closeSearch ( ) ; e . preventDefault ( ) ; e . stopPropagation ( ) ; return ; }
} , true ) ;
document . addEventListener ( 'keydown' , ( e ) => { if ( e . key === 'Escape' && ! document . querySelector ( '.modal-ov' ) && currentTab ( ) === 'chat' && selected != null ) { showWelcome ( ) ; } } ) ;
// #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 ( _ ) { } } } ) ;
// 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 ;
@@ -2264,7 +2641,7 @@ async function renderLogin(){
const aw = document . getElementById ( 'authwrap' ) ; aw . style . display = 'flex' ;
let regOpen = false ; try { regOpen = ( await ( await fetch ( '/api/setup-state' ) ) . json ( ) ) . registrationOpen ; } catch ( _ ) { }
aw . innerHTML = ` <div class="authcard">
<h1>Welcome to BizGaze Connect</h1>
<h1>Welcome to Biz Connect</h1>
<div class="sub">Sign in to access chats, screen share and connect.</div>
${ regOpen ? ` <div class="authtabs">
<button id="tabLogin" class="active">Sign in</button>
@@ -2278,7 +2655,7 @@ async function renderLogin(){
<p id="li_err" class="formerr"></p>
</div>
${ regOpen ? ` <div id="regForm" class="hidden">
<span class="lbl">Team name</span><input id="rg_team" placeholder="e.g. BizGaze Support ">
<span class="lbl">Team name</span><input id="rg_team" placeholder="e.g. Acme Inc ">
<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>
@@ -2321,8 +2698,11 @@ async function doRegister(){
ME = me ;
document . getElementById ( 'hdrRight' ) . innerHTML = bellHTML ( ) + profileHTML ( me ) ;
loadNotifs ( ) ; wireBell ( ) ; wireProfile ( ) ;
placeHdrRight ( ) ; window . addEventListener ( 'resize' , placeHdrRight ) ; // mobile: bell+profile live in the chat-list header (no top bar)
connectChatWs ( ) ;
setupPush ( ) ; // register the notification service worker + subscribe to Web Push (if granted)
{ 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)
document . getElementById ( 'loading' ) . style . display = 'none' ;
// 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.