@@ -4040,27 +4040,53 @@ _setupUI() {
40404040 const _renderGuestChannels = ( ) => {
40414041 const host = document . getElementById ( 'guest-channels-list' ) ;
40424042 if ( ! host ) return ;
4043- const all = ( this . channels || [ ] ) . filter ( c =>
4044- ! c . is_dm && ! c . parent_channel_id
4045- ) ;
4046- if ( all . length === 0 ) {
4043+ const chans = ( this . channels || [ ] ) . filter ( c => ! c . is_dm ) ;
4044+ if ( chans . length === 0 ) {
40474045 host . innerHTML = '<p class="muted-text" style="margin:4px 0;font-size:0.85rem">No channels yet.</p>' ;
40484046 return ;
40494047 }
40504048 // CSV of channel ids. Empty string = no channels (guests can log in but have nowhere to go).
4049+ // (#5401) Sub-channels and voice rooms are listed individually so admins
4050+ // grant guests exactly the channels they intend — no implicit cascade.
40514051 const raw = this . serverSettings ?. guest_channels || '' ;
40524052 const selected = new Set (
40534053 raw . split ( ',' ) . map ( s => s . trim ( ) ) . filter ( Boolean ) . map ( s => parseInt ( s ) ) . filter ( Number . isFinite )
40544054 ) ;
4055- host . innerHTML = all . map ( ch => {
4055+
4056+ const parents = chans . filter ( c => ! c . parent_channel_id )
4057+ . sort ( ( a , b ) => ( a . position ?? 0 ) - ( b . position ?? 0 ) || String ( a . name || '' ) . localeCompare ( String ( b . name || '' ) ) ) ;
4058+ const subsByParent = new Map ( ) ;
4059+ chans . filter ( c => c . parent_channel_id ) . forEach ( c => {
4060+ if ( ! subsByParent . has ( c . parent_channel_id ) ) subsByParent . set ( c . parent_channel_id , [ ] ) ;
4061+ subsByParent . get ( c . parent_channel_id ) . push ( c ) ;
4062+ } ) ;
4063+ for ( const list of subsByParent . values ( ) ) {
4064+ list . sort ( ( a , b ) => ( a . position ?? 0 ) - ( b . position ?? 0 ) || String ( a . name || '' ) . localeCompare ( String ( b . name || '' ) ) ) ;
4065+ }
4066+
4067+ const tagsFor = ( ch ) => {
4068+ const tags = [ ] ;
4069+ if ( ch . is_private || ch . code_visibility === 'private' ) tags . push ( 'private' ) ;
4070+ if ( ch . text_enabled === 0 && ch . voice_enabled ) tags . push ( 'voice' ) ;
4071+ return tags . length
4072+ ? ` <small style="color:var(--text-muted)">(${ tags . join ( ', ' ) } )</small>` : '' ;
4073+ } ;
4074+ const row = ( ch , isSub ) => {
40564075 const checked = selected . has ( ch . id ) ;
4057- const lockTag = ( ch . is_private || ch . code_visibility === 'private' )
4058- ? ' <small style="color:var(--text-muted)">(private)</small>' : '' ;
4059- return `<label style="display:flex;align-items:center;gap:6px;padding:3px 4px;font-size:0.85rem">
4060- <input type="checkbox" class="guest-channel-cb" data-cid="${ ch . id } " ${ checked ? 'checked' : '' } >
4061- <span># ${ this . _escapeHtml ( ch . name || '' ) } ${ lockTag } </span>
4076+ const prefix = isSub ? '↳ ' : '#' ;
4077+ const parentAttr = isSub ? ` data-parent=" ${ ch . parent_channel_id } "` : '' ;
4078+ return `<label style="display:flex;align-items:center;gap:6px;padding:3px 4px;font-size:0.85rem${ isSub ? ';margin-left:18px' : '' } ">
4079+ <input type="checkbox" class="guest-channel-cb" data-cid="${ ch . id } "${ parentAttr } ${ checked ? 'checked' : '' } >
4080+ <span>${ prefix } ${ this . _escapeHtml ( ch . name || '' ) } ${ tagsFor ( ch ) } </span>
40624081 </label>` ;
4063- } ) . join ( '' ) ;
4082+ } ;
4083+
4084+ let html = '' ;
4085+ for ( const p of parents ) {
4086+ html += row ( p , false ) ;
4087+ for ( const sub of ( subsByParent . get ( p . id ) || [ ] ) ) html += row ( sub , true ) ;
4088+ }
4089+ host . innerHTML = html ;
40644090 const toggle = document . getElementById ( 'guests-enabled' ) ;
40654091 if ( toggle ) toggle . checked = ( this . serverSettings ?. guests_enabled === 'true' ) ;
40664092 } ;
@@ -4075,6 +4101,21 @@ _setupUI() {
40754101 document . getElementById ( 'guest-channels-none-btn' ) ?. addEventListener ( 'click' , ( ) => {
40764102 document . querySelectorAll ( '.guest-channel-cb' ) . forEach ( cb => { cb . checked = false ; } ) ;
40774103 } ) ;
4104+ // A sub-channel only appears in the sidebar nested under its parent, so a
4105+ // guest needs the parent too. Keep the checkboxes consistent: ticking a sub
4106+ // ticks its parent; unticking a parent unticks its sub-channels. (#5401)
4107+ document . getElementById ( 'guest-channels-list' ) ?. addEventListener ( 'change' , ( e ) => {
4108+ const cb = e . target . closest ?. ( '.guest-channel-cb' ) ;
4109+ if ( ! cb ) return ;
4110+ if ( cb . checked && cb . dataset . parent ) {
4111+ const parent = document . querySelector ( `.guest-channel-cb[data-cid="${ cb . dataset . parent } "]` ) ;
4112+ if ( parent ) parent . checked = true ;
4113+ }
4114+ if ( ! cb . checked && ! cb . dataset . parent ) {
4115+ document . querySelectorAll ( `.guest-channel-cb[data-parent="${ cb . dataset . cid } "]` )
4116+ . forEach ( sub => { sub . checked = false ; } ) ;
4117+ }
4118+ } ) ;
40784119 document . getElementById ( 'guest-channels-save-btn' ) ?. addEventListener ( 'click' , ( ) => {
40794120 const cbs = Array . from ( document . querySelectorAll ( '.guest-channel-cb' ) ) ;
40804121 const picked = cbs . filter ( cb => cb . checked ) . map ( cb => parseInt ( cb . dataset . cid ) ) . filter ( Number . isFinite ) ;
0 commit comments