PageSourceSearch

https://boyfriend.dk/js/libs/mobile.js?1790939997

js boyfriend.dk collected 2026-10-02 11:20:25 UTC 105,188 bytes, 2,189 lines download raw bytes

1class BFMobile {
2    constructor() {
3        this._pwaPrompt = null;
4        this._DESKTOP_DESIGN_W = 930;
5
6        // beforeinstallprompt fyrer TIDLIGT — ofte før pwa() (der kører efter
7        // hello-roundtrippet). Fang eventet allerede her ved instansiering
8        // (boyfriend.js:56, script-load), så det ikke går tabt; pwa() henter
9        // det gemte prompt bagefter.
10        window.addEventListener('beforeinstallprompt', (e) => {
11            e.preventDefault();
12            this._pwaPrompt = e;
13            document.querySelectorAll('[data-action="pwa-install"]').forEach((el) => {
14                const standalone = window.matchMedia('(display-mode: standalone)').matches
15                    || window.navigator.standalone === true;
16                if (!standalone) el.hidden = false;
17            });
18        });
19    }
20
21
22    // Luk qsort-sheetet (klasse + dataset-state på body) — var kopieret
23    // 5 steder.
24    closeQsortSheet() {
25        document.body.classList.remove('bf-qsort-open');
26        delete document.body.dataset.bfQsortOnly;
27        delete document.body.dataset.bfQsortPane;
28    }
29
30    boot() {
31        this.pwa();
32        this.topbarCounters();
33
34        const uaWantsDesktop =
35            (navigator.maxTouchPoints > 1
36                && !!navigator.userAgentData
37                && navigator.userAgentData.mobile === false
38                && !/Android/.test(navigator.userAgent))
39            || (navigator.maxTouchPoints > 1
40                && !navigator.userAgentData
41                && /Macintosh/.test(navigator.userAgent)
42                && Math.min(screen.width, screen.height) < 700);
43
44        // Mus som primær input = rigtig computer (også touch-laptops) → altid
45        // desktop-layout uanset vinduesbredde: browser-zoom krymper CSS-px, så
46        // zoom+reload gav ellers mobilsite på pc. Devtools-emulering spoofer
47        // pointer:coarse og rammes ikke.
48        const mouseIsPrimary = !!(window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches);
49
50        // px-anker (ikke 64em): menu-registry.js bruger (max-width: 1023px) —
51        // ved root-font ≠ 16px ville em/px-grænserne divergere.
52        const isMobileViewport = !uaWantsDesktop && !mouseIsPrimary && window.matchMedia('(max-width: 1023px)').matches;
53        const hasExistingSwiper = !!document.getElementById('bf_mobile_swiper');
54
55        this._lockDesktopViewport(isMobileViewport);
56
57        const hideBootSplash = () => {
58            const el = document.getElementById('bf_boot_splash');
59            if (!el || el.dataset.closing === '1') return;
60            el.dataset.closing = '1';
61            const minVisible = 280;
62            const elapsed = performance.now() - _api.splash;
63            const wait = Math.max(0, minVisible - elapsed);
64            setTimeout(() => {
65                el.classList.remove('is-open');
66                el.classList.add('is-closing');
67                setTimeout(() => { if (el.parentNode) el.parentNode.removeChild(el); }, 220);
68            }, wait);
69        };
70        requestAnimationFrame(() => requestAnimationFrame(hideBootSplash));
71
72        if (!isMobileViewport || hasExistingSwiper) {
73            document.body.classList.toggle('is-mobile-layout', isMobileViewport && hasExistingSwiper);
74            document.documentElement.classList.toggle('is-mobile-layout', isMobileViewport && hasExistingSwiper);
75            return;
76        }
77
78        const $menu = document.querySelector('#toprowcontainer #menu');
79        const $content = document.querySelector('#content_container .content');
80        const $status = document.getElementById('status');
81        const $contentContainer = document.getElementById('content_container');
82
83        function wrap(el, paneClass) {
84            const w = document.createElement('div');
85            w.className = 'bf-pane ' + paneClass;
86            w.appendChild(el);
87            return w;
88        }
89
90        const bindMailPill = (pill) => {
91            if (!pill) return;
92            const link = pill.closest('a');
93            const base = link ? link.textContent.trim() : '';
94            pill.setAttribute('aria-hidden', 'true');
95            const apply = (n) => {
96                n = parseInt(n, 10) || 0;
97                pill.textContent = n;
98                pill.classList.toggle('hidden', n === 0);
99                pill.style.display = '';
100                if (link) {
101                    if (n > 0) {
102                        link.setAttribute('aria-label', base + ', ' + n + (n === 1 ? ' ulæst' : ' ulæste'));
103                    } else {
104                        link.removeAttribute('aria-label');
105                    }
106                }
107            };
108            if (typeof _mail.unread_count === 'number') {
109                apply(_mail.unread_count);
110            } else {
111                pill.classList.add('hidden');
112            }
113            document.addEventListener('bf:mail-unread-count', (e) => apply((e.detail && e.detail.count)));
114        };
115
116        const swiper = document.createElement('div');
117        swiper.id = 'bf_mobile_swiper';
118        document.body.classList.add('is-mobile-layout');
119        document.documentElement.classList.add('is-mobile-layout');
120
121        const menuPane = _template.loadById('template_drawer_mobile')[0];
122        const menuSplit = menuPane.querySelector('.bf-menu-split');
123        const menuLeft  = menuPane.querySelector('.bf-menu-left');
124        const menuRight = menuPane.querySelector('.bf-menu-right');
125        const footer    = menuPane.querySelector('.bf-menu-footer-block');
126        const bottomRow = menuPane.querySelector('.bf-menu-footer');
127        const subSlot   = menuPane.querySelector('#bf_menu_sub_slot');
128        const installBox = menuPane.querySelector('.bf-menu-install-box');
129        const infoBox    = menuPane.querySelector('.bf-menu-info-tile');
130        const gdprBox    = menuPane.querySelector('.bf-menu-gdpr-tile');
131        const scrollToContent = () => setTimeout(
132            () => swiper.scrollTo({ left: 0, behavior: 'smooth' }), 300);
133        const $topmenuRight = $menu.querySelector('#topmenu_right');
134        if ($topmenuRight) menuRight.insertBefore($topmenuRight, subSlot);
135
136        menuLeft.insertBefore($menu, footer);
137
138        const $newsticker = document.getElementById('newsticker');
139        if ($newsticker) {
140            footer.appendChild($newsticker);
141            $newsticker.addEventListener('click', (e) => {
142                if (!e.target.closest('a')) return;
143                scrollToContent();
144            }, true);
145        }
146
147        const $statusOnlineBox = document.querySelector('.status_online');
148        const $profilesBox = $statusOnlineBox ? $statusOnlineBox.querySelector('.profiles_container') : null;
149        if ($profilesBox) {
150            const _disc = $profilesBox.querySelector('.disclaimer');
151            if (_disc) _disc.remove();
152        }
153        if ($statusOnlineBox) {
154            const visits = $statusOnlineBox.querySelector('.visits_container');
155            const online = $statusOnlineBox.querySelector('.online_container');
156            if (visits) {
157                visits.style.cursor = 'pointer';
158                visits.addEventListener('click', () => {
159                    document.location.href = '/#/s/lists_own/guests';
160                    scrollToContent();
161                });
162                bottomRow.insertBefore(visits, infoBox);
163            }
164            if (online) {
165                online.style.cursor = 'pointer';
166                online.addEventListener('click', () => {
167                    document.location.href = '/#/s/lists/online';
168                    scrollToContent();
169                });
170                bottomRow.insertBefore(online, infoBox);
171            }
172        }
173        const $itemNavn = $status.querySelector('#item_navn');
174
175        infoBox.addEventListener('click', () => {
176            document.location.href = '/#/s/info/info';
177            scrollToContent();
178        });
179        gdprBox.addEventListener('click', () => {
180            document.location.href = '/#/s/info/persondata';
181            scrollToContent();
182        });
183
184        swiper.appendChild(menuPane);
185
186        const menuBackdrop = document.createElement('div');
187        menuBackdrop.id = 'bf_menu_backdrop';
188        document.body.appendChild(menuBackdrop);
189
190        const closeMenu = () => document.body.classList.remove('bf-menu-open');
191        const toggleMenu = () => document.body.classList.toggle('bf-menu-open');
192
193        menuBackdrop.addEventListener('click', closeMenu);
194
195        menuPane.addEventListener('click', (e) => {
196            const topA = e.target.closest('#menu > ul > li > a');
197            if (topA) {
198                const li = topA.parentElement;
199                if (li.querySelector(':scope > ul') && !li.classList.contains('bf-menu-open')) {
200                    return;
201                }
202            }
203
204            if (e.target.closest('a, .visits_container, .online_container, .bf-menu-info-tile, .bf-menu-gdpr-tile')) {
205                setTimeout(closeMenu, 250);
206                scrollToContent();
207            }
208        }, true);
209
210        swiper.appendChild(wrap($content, 'bf-pane--content'));
211
212        const hjemLi = $menu.querySelector('#menu_logged_in > li:first-child');
213        if (hjemLi) {
214            const subItems = hjemLi.querySelectorAll(':scope > ul > li');
215            const ul = hjemLi.parentElement;
216            subItems.forEach(item => {
217                const _a = item.querySelector('a');
218                const href = (_a ? _a.getAttribute('href') : '') || '';
219                if (href.includes('/news/')) {
220                    item.remove();
221                    return;
222                }
223                item.classList.add('bf-flat-from-hjem');
224                ul.appendChild(item);
225            });
226            hjemLi.remove();
227        }
228
229        const _infoA = $menu.querySelector('#menu_logged_in > li > a[href="#/s/info/info"]');
230        const infoLi = _infoA ? _infoA.parentElement : null;
231        if (infoLi) infoLi.remove();
232
233        const chatA = $menu.querySelector('#menu_logged_in > li > a.chat');
234        if (chatA) {
235            chatA.setAttribute('href', '#/s/chat/fri');
236            const _chatUl = chatA.parentElement.querySelector(':scope > ul');
237            if (_chatUl) _chatUl.remove();
238            chatA.addEventListener('click', (e) => {
239                if (e.ctrlKey || e.metaKey || e.shiftKey || e.which === 2) {
240                    return;
241                }
242                e.preventDefault();
243                e.stopPropagation();
244                e.stopImmediatePropagation();
245                const room = (_chat.last_room === 'scor' || _chat.last_room === 'fri')
246                    ? _chat.last_room : 'fri';
247                _nav.load('/s/chat/' + room);
248            }, true);
249        }
250        const debatA = $menu.querySelector('#menu_logged_in > li > a[href="#/s/debat/debat"]');
251        if (debatA) {
252            const _debatUl = debatA.parentElement.querySelector(':scope > ul');
253            if (_debatUl) _debatUl.remove();
254        }
255
256        const kontrolpanelLi = $menu.querySelector('#menu_logged_in > li.bf-flat-from-hjem');
257        const _logafA = $menu.querySelector('#menu_logged_in > li > a[data-action="profile-logout"]');
258        const logafLi = _logafA ? _logafA.parentElement : null;
259        const userBlock = document.createElement('div');
260        userBlock.className = 'bf-user-block';
261        if ($itemNavn) userBlock.appendChild($itemNavn);
262        if (kontrolpanelLi || logafLi) {
263            const userActions = document.createElement('div');
264            userActions.className = 'bf-user-actions';
265            const tipsLi = document.createElement('li');
266            tipsLi.className = 'bf-user-action-tips';
267            const tipsA = document.createElement('a');
268            tipsA.href = '#';
269            tipsA.setAttribute('data-action', 'show-tips');
270            tipsA.textContent = 'tips';
271            tipsLi.appendChild(tipsA);
272            userActions.appendChild(tipsLi);
273            if (kontrolpanelLi) userActions.appendChild(kontrolpanelLi);
274            if (logafLi) userActions.appendChild(logafLi);
275            userActions.addEventListener('click', (e) => {
276                if (!e.target.closest('a')) return;
277                setTimeout(() => {
278                    swiper.scrollTo({ left: 0, behavior: 'smooth' });
279                }, 300);
280            }, true);
281            userBlock.appendChild(userActions);
282        }
283        if (userBlock.children.length) {
284            menuLeft.insertBefore(userBlock, menuLeft.firstChild);
285        }
286
287        const _postA = $menu.querySelector('#menu_logged_in > li > a[href="#/s/mail/inbox"]');
288        const postLi = _postA ? _postA.parentElement : null;
289        if (postLi) {
290            const pill = document.createElement('span');
291            pill.className = 'bf-menu-pill';
292            const _postLink = postLi.querySelector('a');
293            if (_postLink) _postLink.appendChild(pill);
294            bindMailPill(pill);
295        }
296
297        menuLeft.addEventListener('click', (e) => {
298            const a = e.target.closest('#menu > ul > li > a');
299            if (!a) return;
300            const li = a.parentElement;
301            if (!li.querySelector(':scope > ul')) return;
302            if (li.classList.contains('bf-menu-open')) return;
303            e.preventDefault();
304            e.stopPropagation();
305            e.stopImmediatePropagation();
306            li.parentElement.querySelectorAll(':scope > li.bf-menu-open').forEach(other => {
307                other.classList.remove('bf-menu-open');
308            });
309            li.classList.add('bf-menu-open');
310        }, true);
311
312        menuLeft.addEventListener('click', (e) => {
313            const a = e.target.closest('#menu a, #newsticker #link a');
314            if (!a) return;
315            const li = a.parentElement;
316            if (
317                li &&
318                li.matches('#menu > ul > li') &&
319                li.querySelector(':scope > ul') &&
320                !li.classList.contains('bf-menu-open')
321            ) {
322                return;
323            }
324            const href = a.getAttribute('href') || '';
325            if (
326                href === '#/s/mail/inbox' ||
327                href === '#/s/chat/scor' ||
328                href === '#/s/chat/fri' ||
329                href.startsWith('#/s/mail/show/')
330            ) {
331                return;
332            }
333            setTimeout(() => {
334                swiper.scrollTo({ left: 0, behavior: 'smooth' });
335            }, 300);
336        });
337
338        const $logo = document.querySelector('#toprowcontainer #logo');
339        if ($logo) {
340            $logo.addEventListener('click', (e) => {
341                _elements.constructor.display_target = null;
342                if (_profile.user && _profile.user.id) {
343                    e.preventDefault();
344                    // stopImmediatePropagation (ikke stopPropagation): elements.js
345                    // binder OGSÅ click på SAMME element (bubble). Uden dette når
346                    // dens location.href at fyre → dobbelt render ved logo-tap.
347                    e.stopImmediatePropagation();
348                    _nav.load('/s/profile/profile');
349                }
350                setTimeout(() => {
351                    swiper.scrollTo({ left: 0, behavior: 'smooth' });
352                }, 300);
353            }, true);
354        }
355
356        const $bell = document.querySelector('#bf-topbar-counters a[data-counter="notifications"]');
357        if ($bell) {
358            $bell.addEventListener('click', () => {
359                setTimeout(() => {
360                    swiper.scrollTo({ left: 0, behavior: 'smooth' });
361                }, 300);
362            });
363        }
364
365        const statusPane = document.createElement('div');
366        statusPane.className = 'bf-pane bf-pane--status';
367
368        const tabBody = document.createElement('div');
369        tabBody.id = 'bf_status_tab_body';
370        statusPane.appendChild(tabBody);
371
372        // Status-panens faner (post/scor/fri/privat) er ALDRIG paginerede
373        // lister; kun content-panen hoster infiload. Uden denne gate sideloader
374        // scroll i scor/mail den anden panes liste, fordi infiload_call er global
375        // — og guarden manglede infiload_max, så den fyrede efter sidste side.
376        tabBody.addEventListener('scroll', () => {
377            if (!_lists.infiload_call || _lists.infiload_loading || _lists.infiload_max) return;
378            if (tabBody.dataset.bfHostsList !== '1') return;
379            const margin = _lists.infiload_margin || 250;
380            const remaining = tabBody.scrollHeight - tabBody.scrollTop - tabBody.clientHeight;
381            if (remaining < margin) {
382                _lists.infiload_call();
383            }
384        }, { passive: true });
385
386        const statusLegacy = document.createElement('div');
387        statusLegacy.id = 'bf_status_legacy';
388        statusLegacy.appendChild($status);
389        statusPane.appendChild(statusLegacy);
390
391        swiper.appendChild(statusPane);
392
393        const statusBottomNav = _template.loadById('template_status_bottomnav')[0];
394        statusBottomNav.addEventListener('click', (e) => {
395            const a = e.target.closest('a[data-bn]');
396            if (!a) return;
397            e.preventDefault();
398            e.stopPropagation();
399            const tab = a.dataset.tab;
400            if (tab) activateTab(tab);
401        });
402        const statusBottomBar = document.createElement('div');
403        statusBottomBar.id = 'bf_status_bottombar';
404        statusBottomBar.appendChild(statusBottomNav);
405
406        const statusAd = document.createElement('div');
407        statusAd.id = 'if_ad_status';
408        statusBottomBar.insertBefore(statusAd, statusBottomNav);
409
410        statusPane.appendChild(statusBottomBar);
411        // Klasse-spejl af :has(> #bf_status_bottombar) (site.css) — :has() dør på
412        // iOS 15.0-15.3; bottombaren bygges altid, så spejlet er altid korrekt.
413        statusPane.classList.add('bf-has-bottombar');
414
415        bindMailPill(statusBottomNav.querySelector('a[data-tab="post"] .bf-bn-pill'));
416
417        const tabRoute = {
418            post: () => section('mail', 'inbox'),
419            scor: () => section('chat', 'scor'),
420            fri:  () => section('chat', 'fri'),
421            private: () => section('chat', 'private'),
422        };
423
424        function activateTab(name) {
425            statusBottomNav.querySelectorAll('a[data-tab]').forEach((b) => {
426                b.classList.toggle('is-active', b.dataset.tab === name);
427            });
428            _elements.constructor.display_target = '#bf_status_tab_body';
429
430            const _histBefore = location.hash;
431            const $ifpm = document.getElementById('if_profile_menu');
432            const $ifpc = document.getElementById('if_profile_content');
433            // Status-fanernes ruter (section('mail'/'chat')) kalder _menu.show,
434            // som renderer i #if_menu — et element der FYSISK ligger i content-
435            // panen. Uden snapshot ville content-panens egen sektionsmenu blive
436            // erstattet af "post/svar"/"scor/fri/privat" når man swiper tilbage.
437            const $ifmenu = document.getElementById('if_menu');
438            const menuSnapshot = $ifmenu ? $ifmenu.innerHTML : null;
439            const wasMenuVisible = $ifpm && $ifpm.style.display !== 'none' && $ifpm.children.length > 0;
440            const wasProfileVisible = $ifpc && $ifpc.style.display !== 'none' && $ifpc.children.length > 0;
441            const railEl = wasProfileVisible
442                ? document.getElementById('bf_profile_action_rail')
443                : null;
444            if (railEl) railEl.remove();
445            try {
446                tabRoute[name]();
447            } catch (err) {
448                tabBody.textContent = 'Kunne ikke indlæse fanen: ' + err.message;
449            }
450            if ($ifmenu && menuSnapshot !== null) $ifmenu.innerHTML = menuSnapshot;
451            if (wasMenuVisible && $ifpm) $ifpm.style.display = '';
452            if (wasProfileVisible && $ifpc) $ifpc.style.display = '';
453            if (wasProfileVisible && railEl) {
454                const _oldRail = document.getElementById('bf_profile_action_rail');
455                if (_oldRail) _oldRail.remove();
456                document.body.appendChild(railEl);
457            }
458            if (location.hash !== _histBefore) {
459                history.replaceState(history.state, '', _histBefore || location.pathname);
460            }
461        }
462
463        function gotoStatusTab(name) {
464            statusInit = true;
465            activateTab(name);
466            swiper.scrollTo({
467                left: swiper.scrollWidth - swiper.clientWidth,
468                behavior: 'smooth',
469            });
470        }
471
472        swiper.addEventListener('scroll', () => {
473            requestAnimationFrame(() => {
474                const max = swiper.scrollWidth - swiper.clientWidth;
475                const paaStatus = max > 0 && swiper.scrollLeft > max - (swiper.clientWidth / 2);
476                document.body.classList.toggle('bf-status-pane-active', paaStatus);
477            });
478        }, { passive: true });
479
480        const _origNavLoad = _nav.load.bind(_nav);
481        const setDisplayTarget = (val) => {
482            _elements.constructor.display_target = val;
483        };
484        _nav.load = function(loc, noregister) {
485            if (!_nav._mob_guarding && typeof _dialog === 'object' && _dialog.guardNavigation && _dialog.hasDirtyGuard()) {
486                _dialog.guardNavigation(() => {
487                    _nav._mob_guarding = true;
488                    try {
489                        _nav.load(loc, noregister);
490                    } finally {
491                        // Uden finally forbliver guarding=true hvis load kaster →
492                        // dirty-guarden er omgået resten af sessionen.
493                        _nav._mob_guarding = false;
494                    }
495                });
496                return;
497            }
498
499            if (typeof loc === 'string' && loc.length) {
500                if (loc === '/s/mail/inbox') {
501                    gotoStatusTab('post');
502                    return;
503                }
504                const showMatch = loc.match(/^\/s\/mail\/show\/(.+)$/);
505                if (showMatch) {
506                    statusInit = true;
507                    setDisplayTarget('#bf_status_tab_body');
508                    statusBottomNav.querySelectorAll('a[data-tab]').forEach((b) => {
509                        b.classList.toggle('is-active', b.dataset.tab === 'post');
510                    });
511                    const _histBefore = location.hash;
512                    _mail.messenger(showMatch[1]);
513                    if (location.hash !== _histBefore) {
514                        history.replaceState(history.state, '', _histBefore || location.pathname);
515                    }
516                    swiper.scrollTo({
517                        left: swiper.scrollWidth - swiper.clientWidth,
518                        behavior: 'smooth',
519                    });
520                    return;
521                }
522                if (loc === '/s/chat/scor') {
523                    gotoStatusTab('scor');
524                    return;
525                }
526                if (loc === '/s/chat/fri') {
527                    gotoStatusTab('fri');
528                    return;
529                }
530                if (loc === '/s/chat/private') {
531                    gotoStatusTab('private');
532                    return;
533                }
534            }
535            setDisplayTarget(null);
536            return _origNavLoad(loc, noregister);
537        };
538
539        _nav.showmail = function(mailId) {
540            _nav.load('/s/mail/show/' + mailId);
541        };
542
543        setTimeout(() => {
544            const hash = location.hash || '';
545            if (!hash.startsWith('#/s/')) return;
546            const route = hash.substring(1);
547            if (route === '/s/mail/inbox' ||
548                route === '/s/chat/scor' ||
549                route === '/s/chat/fri' ||
550                route === '/s/chat/private' ||
551                /^\/s\/mail\/show\/.+/.test(route)) {
552                const mainContent = document.getElementById('if_content');
553                if (mainContent) mainContent.innerHTML = '';
554                _nav.load(route);
555            }
556        }, 0);
557
558        let statusInit = false;
559        const statusObserver = new IntersectionObserver((entries) => {
560            for (const ent of entries) {
561                if (ent.isIntersecting && !statusInit) {
562                    statusInit = true;
563                    statusObserver.disconnect();
564                    activateTab('post');
565                }
566            }
567        }, { root: swiper, threshold: 0.5 });
568        statusObserver.observe(statusPane);
569
570        $contentContainer.parentNode.insertBefore(swiper, $contentContainer);
571        $contentContainer.remove();
572
573        const contentPane = swiper.querySelector('.bf-pane--content');
574        // #content_container (role="main") demonteres i mobil-layoutet —
575        // landmarket følger med over på indholds-panen.
576        contentPane.setAttribute('role', 'main');
577        const contentScroll = document.createElement('div');
578        contentScroll.className = 'bf-content-scroll';
579        while (contentPane.firstChild) contentScroll.appendChild(contentPane.firstChild);
580        contentPane.appendChild(contentScroll);
581
582
583        const renderMobileQsortDock = () => {
584            document.querySelectorAll('.bf-qsort-filter-btn').forEach((btn) => {
585                const paneEl = btn.closest('.bf-pane--content, .bf-pane--status');
586                const scope = paneEl || document;
587                const qs = scope.querySelector('#qsort_filter_display')
588                    || document.getElementById('qsort_filter_display');
589                if (!qs) return;
590                const cells = btn.querySelectorAll('[data-helper]');
591
592                cells.forEach((cell) => {
593                    const id  = cell.getAttribute('data-helper');
594                    const src = qs.querySelector('#' + id);
595                    const txt = src ? (src.textContent || '').trim() : '';
596                    const v   = cell.querySelector('.bf-qsort-val');
597                    if (v) v.textContent = txt || '—';
598                });
599
600                const _sortInp = scope.querySelector('input[name=sort]');
601                const cur = String((_sortInp ? _sortInp.value : '') || '');
602                cells.forEach((cell) => {
603                    const up    = cell.getAttribute('data-sort-up');
604                    const dn    = cell.getAttribute('data-sort-down');
605                    const badge = cell.querySelector('.bf-qsort-sort');
606                    if (!badge) return;
607                    badge.classList.remove('is-active', 'is-up', 'is-down');
608                    if      (cur === up) badge.classList.add('is-active', 'is-up');
609                    else if (cur === dn) badge.classList.add('is-active', 'is-down');
610                });
611            });
612        };
613        document.addEventListener('bf:qsort-updated', renderMobileQsortDock);
614
615        const wireQsort = () => {
616            document.querySelectorAll('#qsort_filter_display:not([data-bf-mobile-wired])').forEach((qs) => {
617                qs.dataset.bfMobileWired = '1';
618
619                const qsInStatus = !!qs.closest('.bf-pane--status');
620                const barSel = qsInStatus
621                    ? '#bf_status_bottombar > #qsort_filter_display'
622                    : '#bf_content_bottombar > #qsort_filter_display';
623                document.querySelectorAll(barSel)
624                    .forEach((old) => { if (old !== qs) old.remove(); });
625
626                const filterWrap = qs.closest('#qsort_filter') || qs.parentNode;
627                if (!filterWrap) { return; }
628                const inner = filterWrap.parentNode;
629                const h1 = inner ? inner.querySelector('h1') : null;
630
631                const visBox = qs.querySelector('[data-template="qsort_mode_display_container"]')
632                    || qs.querySelector('#qsort_mode_display_container');
633                if (visBox) visBox.classList.add('bf-qsort-vis');
634
635                const btn = _template.loadById('template_qsort_dock_btn')[0];
636                btn.addEventListener('click', (ev) => {
637                    const cell = ev.target.closest('[data-helper]');
638                    const map = {
639                        qsort_age_display: 'age',
640                        qsort_gender_display: 'gender',
641                        qsort_area_display: 'area',
642                        qsort_target_display: 'target',
643                    };
644                    const which = cell ? map[cell.getAttribute('data-helper')] : null;
645                    if (which) {
646                        const qsortPane = btn.closest('.bf-pane--status') ? 'status' : 'content';
647                        if (document.body.classList.contains('bf-qsort-open')
648                            && document.body.dataset.bfQsortOnly === which
649                            && document.body.dataset.bfQsortPane === qsortPane) {
650                            _mobile.closeQsortSheet();
651                            return;
652                        }
653                        document.body.dataset.bfQsortOnly = which;
654                        document.body.dataset.bfQsortPane = qsortPane;
655                        document.body.classList.add('bf-qsort-open');
656                        if (which === 'age') ensureAgeSlider(qsortPane);
657                    }
658                });
659
660                if (h1 && !h1.parentNode.classList.contains('bf-list-head')) {
661                    const head = document.createElement('div');
662                    head.className = 'bf-list-head';
663                    h1.parentNode.insertBefore(head, h1);
664                    head.appendChild(h1);
665                }
666
667                const pane = filterWrap.closest('.bf-pane--content, .bf-pane--status')
668                    || document.querySelector('.bf-pane--content');
669                if (pane) {
670                    const isStatus = pane.classList.contains('bf-pane--status');
671                    const barId = isStatus ? 'bf_status_bottombar' : 'bf_content_bottombar';
672                    const navId = isStatus ? 'bf_status_bottomnav' : 'bf_content_bottomnav';
673                    const bar = pane.querySelector('#' + barId) || pane;
674                    let dock = bar.querySelector('.bf-qsort-filter-dock');
675                    if (!dock) {
676                        dock = document.createElement('div');
677                        dock.className = 'bf-qsort-filter-dock';
678                        const nav = bar.querySelector('#' + navId);
679                        if (nav) bar.insertBefore(dock, nav);
680                        else bar.appendChild(dock);
681                    } else {
682                        dock.innerHTML = '';
683                    }
684                    dock.appendChild(btn);
685
686                    // Reparent qs ind i bottombar over dock — så samme flex-stak.
687                    // VIGTIGT: når qs forlader <form name="qsort"> mister inputs
688                    // deres form-association og form.serializeArray() ser ikke
689                    // checkboxene (serveren tolker som "alle valgt"). Giv form
690                    // et id og sæt HTML5 [form]-attribut på alle felter så de
691                    // forbliver i form.elements efter reparent.
692                    // Find DETTE filters egen form. Den ligger INDE i filterWrap
693                    // (#qsort_filter) — ikke som ancestor — så closest() er forkert
694                    // (den fandt før den GLOBALT første form = listens, hvorved
695                    // scor's felter blev [form]-associeret til listens form og aldrig
696                    // submittet). Giv hver form et UNIKT id pr. sub.
697                    // Fallback MÅ IKKE være et opdigtet navn: 'list' findes ikke i
698                    // serverens QSORT_LISTS-whitelist, så save_qsort afviste
699                    // gemningen TAVST — filteret så ud til at virke og var væk ved
700                    // næste indlæsning. Fald tilbage på formens eget active-felt
701                    // (den kender sit sub), og ellers _qsort_active.
702                    const ownSub = (filterWrap && filterWrap.getAttribute('data-qsort-sub'))
703                        || (() => {
704                            const f = filterWrap && filterWrap.querySelector('form[name="qsort"] input[name=active]');
705                            return (f && f.value) || _lists._qsort_active || '';
706                        })();
707
708                    // Søgeresultater: kriterierne bor i søgeformen, og serveren
709                    // skipper qsort-FILTRENE for search (skip_filter) — docken
710                    // må kun tilbyde sortering, som på desktop
711                    if (ownSub === 'search') {
712                        btn.classList.add('bf-qsort-sortonly');
713                        qs.classList.add('bf-qsort-sortonly');
714                    }
715
716                    // Docken deles af alle qsort-filtre — kun scorechattens må
717                    // rammes af skjul-valget (body.bf-chat-qsort-hidden)
718                    dock.classList.toggle('bf-qsort-dock-chat', ownSub === 'scor');
719                    const qsortForm = (filterWrap && filterWrap.querySelector('form[name="qsort"]'))
720                        || qs.closest('form[name="qsort"]')
721                        || filterWrap.closest('form[name="qsort"]');
722                    if (qsortForm) {
723                        if (!qsortForm.id) qsortForm.id = 'qsort_form_' + ownSub;
724                        qs.querySelectorAll('input, select, textarea').forEach((el) => {
725                            el.setAttribute('form', qsortForm.id);
726                        });
727                    }
728                    if (qs.parentNode !== bar) {
729                        bar.insertBefore(qs, dock);
730                    }
731
732                    const filterNoMode = filterWrap && filterWrap.getAttribute('data-qsort-nomode') === '1';
733                    if (!isStatus && !filterNoMode) {
734                        const visCell = _template.loadById('template_qsort_vis_cell')[0];
735                        const renderVis = () => {
736                            const isGalleri = _lists._qsortForm(ownSub).find("input[name=qsort_mode]").val() === '1';
737                            const ic = visCell.querySelector('.bf-qsort-ic');
738                            ic.innerHTML = '<span data-auto="icon" data-icon="'
739                                + (isGalleri ? 'mob-vis-list' : 'mob-vis-grid')
740                                + '"></span>';
741                            visCell.title = isGalleri ? 'Vis som liste' : 'Vis som galleri';
742                        };
743                        visCell.addEventListener('click', (ev) => {
744                            ev.stopPropagation();
745                            const isGalleri = _lists._qsortForm(ownSub).find("input[name=qsort_mode]").val() === '1';
746                            _lists.qsort_mode(isGalleri ? 'oversigt' : 'galleri', ownSub);
747                            _lists.qsort_submit(ownSub);
748                            renderVis();
749                        });
750                        renderVis();
751                        dock.appendChild(visCell);
752
753                        // bf:qsort-updated (qsort_update) er det præcise signal —
754                        // den gamle MutationObserver kørte renderVis ved HVER
755                        // mutation i filteret (inkl. ikon-loads) og blev aldrig
756                        // disconnectet. Selvoprydning når cellen forsvinder.
757                        const onQsortUpdated = () => {
758                            if (!visCell.isConnected) {
759                                document.removeEventListener('bf:qsort-updated', onQsortUpdated);
760                                return;
761                            }
762                            renderVis();
763                        };
764                        document.addEventListener('bf:qsort-updated', onQsortUpdated);
765                    }
766
767                    const resetCell = _template.loadById('template_qsort_reset_cell')[0];
768                    resetCell.addEventListener('click', (ev) => {
769                        ev.stopPropagation();
770                        _lists.qsort_reset(ownSub);
771                    });
772                    dock.appendChild(resetCell);
773                }
774
775                    const wiredSub = (filterWrap && filterWrap.getAttribute('data-qsort-sub'))
776                        || (qs.closest('#qsort_filter') && qs.closest('#qsort_filter').getAttribute('data-qsort-sub'));
777                    if (wiredSub && _lists.qsort_update) {
778                        _lists.qsort_update(wiredSub);
779                    }
780
781                    renderMobileQsortDock();
782
783                    const inStatusPane = !!btn.closest('.bf-pane--status');
784                    const btnPaneEl = btn.closest('.bf-pane--content, .bf-pane--status');
785                    const ownFilter = btnPaneEl && btnPaneEl.querySelector('#qsort_filter');
786                    const btnNoMode = !!ownFilter && ownFilter.getAttribute('data-qsort-nomode') === '1';
787                    const noSort = inStatusPane || btnNoMode;
788                    if (!noSort) {
789                        btn.querySelectorAll('[data-helper]').forEach((cell) => {
790                            cell.addEventListener('contextmenu', (ev) => ev.preventDefault());
791                        });
792                        const fireSort = (cell) => {
793                            const up = cell.getAttribute('data-sort-up');
794                            const dn = cell.getAttribute('data-sort-down');
795                            const sortSub = ownFilter ? ownFilter.getAttribute('data-qsort-sub') : null;
796                            const $sort = _lists._qsortForm(sortSub).find('input[name=sort]');
797                            const cur = String($sort.val() || '');
798                            const next = (cur === up) ? dn : up;
799                            $sort.val(next);
800                            renderMobileQsortDock();
801                            renderSortBar();
802                            _lists.qsort_submit(sortSub);
803                        };
804                        this._wireLongPress(btn, {
805                            selector: '[data-helper]',
806                            onLongPress: (cell) =>
806 fireSort(cell),
807                        });
808
809                        const sortBar = document.createElement('div');
810                        sortBar.className = 'bf-qsort-sortbar';
811                        qs.appendChild(sortBar);
812
813                        const CELL_LBL = {
814                            qsort_age_display:    'alder',
815                            qsort_gender_display: 'køn',
816                            qsort_area_display:   'område',
817                            qsort_target_display: 'søger',
818                        };
819                        function renderSortBar() {
820                            const map = {
821                                age:    'qsort_age_display',
822                                gender: 'qsort_gender_display',
823                                area:   'qsort_area_display',
824                                target: 'qsort_target_display',
825                            };
826                            const helper = map[document.body.dataset.bfQsortOnly];
827                            const cell = helper ? btn.querySelector('[data-helper="' + helper + '"]') : null;
828                            if (!cell) return;
829                            const sortSub = ownFilter ? ownFilter.getAttribute('data-qsort-sub') : null;
830                            const cur = String(_lists._qsortForm(sortSub).find('input[name=sort]').val() || '');
831                            sortBar.innerHTML = '';
832                            const lbl = document.createElement('span');
833                            lbl.className = 'bf-qsort-sortbar-lbl';
834                            lbl.textContent = 'Sortér:';
835                            sortBar.appendChild(lbl);
836                            [['up', cell.getAttribute('data-sort-up')],
837                             ['down', cell.getAttribute('data-sort-down')]].forEach(([dir, val]) => {
838                                const chip = document.createElement('button');
839                                chip.type = 'button';
840                                chip.className = 'bf-qsort-sortchip' + (cur === val ? ' is-active' : '');
841                                chip.innerHTML = '<span data-auto="icon" data-icon="sort-' + dir + '"></span><span>'
842                                    + CELL_LBL[helper] + '</span>';
843                                chip.querySelectorAll('[data-auto="icon"]').forEach((el) => _elements.auto_load(el));
844                                chip.addEventListener('click', (ev) => {
845                                    ev.stopPropagation();
846                                    if (cur === val) return;
847                                    _lists._qsortForm(sortSub).find('input[name=sort]').val(val);
848                                    renderMobileQsortDock();
849                                    renderSortBar();
850                                    _lists.qsort_submit(sortSub);
851                                });
852                                sortBar.appendChild(chip);
853                            });
854                        }
855                        btn.addEventListener('click', () => renderSortBar());
856                    }
857            });
858        };
859        let submitTimer = null;
860        const scheduleSubmit = () => {
861            if (submitTimer) clearTimeout(submitTimer);
862            submitTimer = setTimeout(() => {
863                submitTimer = null;
864                _lists.qsort_submit();
865            }, 200);
866        };
867        document.addEventListener('change', (e) => {
868            if (!document.body.classList.contains('bf-qsort-open')) return;
869            if (!e.target.closest('#qsort_filter_display')) return;
870            if (e.target.closest('[data-change]')) return;
871            scheduleSubmit();
872        });
873
874        const ensureAgeSlider = (paneName) => {
875            const paneSel = (paneName === 'status') ? '.bf-pane--status' : '.bf-pane--content';
876            const paneEl = document.querySelector(paneSel) || document;
877            const display = paneEl.querySelector('#qsort_filter_display')
878                || document.getElementById('qsort_filter_display');
879            if (!display) return;
880            const ageBox = display.querySelector('.qsort_optionbox:first-child .qsort_optionbox_options');
881            if (!ageBox || ageBox.querySelector('.bf-age-slider')) return;
882            const fromEl = paneEl.querySelector('input[name=qsort_age_from]') || ageBox.querySelector('input[name=qsort_age_from]');
883            const toEl = paneEl.querySelector('input[name=qsort_age_to]') || ageBox.querySelector('input[name=qsort_age_to]');
884            const $from = $(fromEl);
885            const $to = $(toEl);
886            ageBox.insertAdjacentHTML('afterbegin',
887                _elements.age_slider_markup($from.val(), $to.val(), false));
888            _elements.age_slider_bind(ageBox.querySelector('.bf-age-slider'), (from, to) => {
889                $from.val(from);
890                $to.val(to);
891                if (fromEl) fromEl.dispatchEvent(new Event('change', { bubbles: true }));
892            });
893        };
894
895        wireQsort();
896        const syncDockVisibility = () => {
897            const pane = document.querySelector('.bf-pane--content');
898            const hasList = !!contentScroll.querySelector('#qsort_filter');
899            const dock = pane && pane.querySelector('.bf-qsort-filter-dock');
900            const sheet = pane && pane.querySelector('#bf_content_bottombar > #qsort_filter_display');
901            const profMenu = pane && pane.querySelector('#if_profile_menu');
902            const profCont = pane && pane.querySelector('#if_profile_content');
903            const profileVisible = (el) => el && el.offsetParent !== null
904                && getComputedStyle(el).display !== 'none';
905            const overlaid = profileVisible(profMenu) || profileVisible(profCont);
906            if (!hasList && dock) {
907                dock.remove();
908            } else if (dock) {
909                dock.style.display = overlaid ? 'none' : '';
910                if (!overlaid && window._mobiletips) {
911                    _mobiletips.qsortTip();
912                }
913            }
914            if (!hasList && sheet) {
915                sheet.remove();
916                if (document.body.classList.contains('bf-qsort-open')
917                    && document.body.dataset.bfQsortPane !== 'status') {
918                    _mobile.closeQsortSheet();
919                }
920            }
921            const contentSheetOpen = !!(pane && pane.querySelector('#bf_content_bottombar > #qsort_filter_display'));
922            if (overlaid && contentSheetOpen && document.body.classList.contains('bf-qsort-open')
923                && document.body.dataset.bfQsortPane !== 'status') {
924                _mobile.closeQsortSheet();
925            }
926            const mailTools = pane && pane.querySelector('#bf_content_bottombar > #mail_tools_mobile');
927            if (mailTools) {
928                const hasInbox = !!contentScroll.querySelector('.section_mail_inbox, .section_mail_reply');
929                if (!hasInbox) {
930                    mailTools.remove();
931                } else {
932                    mailTools.style.display = overlaid ? 'none' : '';
933                }
934            }
935
936            syncPagerDock(pane, overlaid);
937
938            const statusPaneEl = document.querySelector('.bf-pane--status');
939            if (statusPaneEl) {
940                const sBody = statusPaneEl.querySelector('#bf_status_tab_body');
941                const sBar = statusPaneEl.querySelector('#bf_status_bottombar');
942                const hasChatFilter = !!(sBody && sBody.querySelector('#qsort_filter'));
943                const hasChatRoom = !!(sBody && sBody.querySelector('.section_chat'));
944                const hasMessenger = !!(sBody && sBody.querySelector('.section_mail_messenger'));
945                const sDock = sBar && sBar.querySelector('.bf-qsort-filter-dock');
946                const sSheet = sBar && sBar.querySelector('#qsort_filter_display');
947                const sEditor = sBar && sBar.querySelector('#chat_editor');
948                if (!hasChatFilter && sDock) sDock.remove();
949                if (!hasChatFilter && sSheet) {
950                    sSheet.remove();
951                    if (document.body.classList.contains('bf-qsort-open')
952                        && document.body.dataset.bfQsortPane !== 'content') {
953                        _mobile.closeQsortSheet();
954                    }
955                }
956                if (!hasChatRoom && sEditor) sEditor.remove();
957                if (!hasMessenger && sBar) {
958                    sBar.querySelectorAll(':scope > .msgr_compose').forEach((el) => el.remove());
959                }
960                const hasInboxS = !!(sBody && sBody.querySelector('.section_mail_inbox, .section_mail_reply'));
961                if (!hasInboxS && sBar) {
962                    sBar.querySelectorAll(':scope > #mail_tools_mobile').forEach((el) => el.remove());
963                }
964            }
965        };
966
967        function syncPagerDock(pane, overlaid) {
968            if (!pane) return;
969            const profMenu = pane.querySelector('#if_profile_menu');
970            if (!profMenu) return;
971            let dock = profMenu.querySelector(':scope > .bf-profile-pager-dock');
972            const hasItems = Array.isArray(_lists.current_items)
973                && _lists.current_items.length
974                && _lists.current_name;
975            const memberId = _profile.member ? _profile.member.id : null;
976            const fromList = _nav.fromList === true;
977            if (!overlaid || !hasItems || !memberId || !fromList) {
978                if (dock) dock.remove();
979                return;
980            }
981            const items = _lists.current_items;
982            const index = items.findIndex((x) => Number(x) === Number(memberId));
983            const atStart = index <= 0;
984            const moreAvailable = (typeof _lists.infiload_call === 'function') && !_lists.infiload_max;
985            const atEnd = (index < 0) || ((index + 1) === items.length);
986            const nextDisabled = atEnd && !moreAvailable;
987            if (!dock) {
988                dock = document.createElement('div');
989                dock.className = 'bf-profile-pager-dock';
990                profMenu.insertBefore(dock, profMenu.firstChild);
991            }
992            // Signatur-guard: genopbyg kun innerHTML + rebind ved reelt skift.
993            // Ellers rev observeren docken ned og bandt lyttere igen på hver
994            // mutation, selv når intet var ændret.
995            const sig = memberId + '|' + _lists.current_name + '|' + atStart + '|' + nextDisabled;
996            if (dock.dataset.bfPagerSig === sig) return;
997            dock.dataset.bfPagerSig = sig;
998            dock.innerHTML =
999                '<div class="bf-pager-text">'
1000              +   '<span>Du ser nu resultater for:</span>'
1001              +   '<b></b>'
1002              + '</div>'
1003              + '<button type="button" class="bf-qsort-cell bf-qsort-iconcell bf-pager-return" title="Tilbage til listen">'
1004              +   '<span class="bf-qsort-ic"><span data-auto="icon" data-icon="mob-list"></span></span>'
1005              +   '<span class="bf-qsort-lbl">liste</span>'
1006              + '</button>'
1007              + '<button type="button" class="bf-qsort-cell bf-qsort-iconcell bf-pager-prev"' + (atStart ? ' disabled' : '') + ' title="Forrige">'
1008              +   '<span class="bf-qsort-ic"><span data-auto="icon" data-icon="mob-chev-left"></span></span>'
1009              +   '<span class="bf-qsort-lbl">forrige</span>'
1010              + '</button>'
1011              + '<button type="button" class="bf-qsort-cell bf-qsort-iconcell bf-pager-next"' + (nextDisabled ? ' disabled' : '') + ' title="N\u00e6ste">'
1012              +   '<span class="bf-qsort-ic"><span data-auto="icon" data-icon="mob-chev-right"></span></span>'
1013              +   '<span class="bf-qsort-lbl">n\u00e6ste</span>'
1014              + '</button>';
1015            dock.querySelectorAll('[data-auto="icon"]').forEach((el) => _elements.auto_load(el));
1016            dock.querySelector('.bf-pager-text b').textContent = _lists.current_name;
1017            dock.querySelector('.bf-pager-return').addEventListener('click', () => _nav.list_return());
1018            if (!atStart) {
1019                dock.querySelector('.bf-pager-prev').addEventListener('click', () => _nav.list_back());
1020            }
1021            if (!nextDisabled) {
1022                dock.querySelector('.bf-pager-next').addEventListener('click', () => _nav.list_next());
1023            }
1024        }
1025        syncDockVisibility();
1026        // rAF-coalescing: uden det fyrer wireQsort/syncDockVisibility (og for
1027        // tabBody decorateMailRows) på HVER eneste childList-mutation under en
1028        // render-storm. Batch til ét kald pr. frame (som mirrorObserver).
1029        const rafBatch = (fn) => {
1030            let queued = false;
1031            return () => {
1032                if (queued) return;
1033                queued = true;
1034                requestAnimationFrame(() => { queued = false; fn(); });
1035            };
1036        };
1037        new MutationObserver(rafBatch(() => {
1038            wireQsort();
1039            syncDockVisibility();
1040        })).observe(contentScroll, {
1041            childList: true, subtree: true,
1042        });
1043        const decorateMailRows = () => {
1044            document.querySelectorAll('#mail_lines > div[user]').forEach((row) => {
1045                if (row.querySelector(':scope > .bf-mail-row-actions')) return;
1046                const uid = Number(row.getAttribute('user'));
1047                if (!uid) return;
1048                const box = document.createElement('div');
1049                box.className = 'bf-mail-row-actions';
1050                [
1051                    { icon: 'mail-pdf',    title: 'Gem post som PDF', act: () => _mail.pdf(uid) },
1052                    { icon: 'mail-delete', title: 'Slet samtale',     act: () => _mail.delete_thread(uid) },
1053                ].forEach((it) => {
1054                    const b = document.createElement('button');
1055                    b.type = 'button';
1056                    b.title = it.title;
1057                    b.innerHTML = '<span data-auto="icon" data-icon="' + it.icon + '"></span>';
1058                    b.querySelectorAll('[data-auto="icon"]').forEach((el) => _elements.auto_load(el));
1059                    b.addEventListener('click', (e) => {
1060                        e.preventDefault();
1061                        e.stopPropagation();
1062                        it.act();
1063                    });
1064                    box.appendChild(b);
1065                });
1066                row.appendChild(box);
1067            });
1068        };
1069        new MutationObserver(rafBatch(() => {
1070            wireQsort();
1071            syncDockVisibility();
1072            decorateMailRows();
1073            if (window._mobiletips) _mobiletips.mailTip();
1074        })).observe(tabBody, {
1075            childList: true, subtree: true,
1076        });
1077        const attached = new WeakSet();
1078        const tryAttach = () => {
1079            ['#if_profile_menu', '#if_profile_content'].forEach((sel) => {
1080                const el = contentPane.querySelector(sel);
1081                if (el && !attached.has(el)) {
1082                    attached.add(el);
1083                    new MutationObserver(() => syncDockVisibility()).observe(el, {
1084                        attributes: true, attributeFilter: ['style', 'class'],
1085                    });
1086                    syncDockVisibility();
1087                }
1088            });
1089        };
1090        tryAttach();
1091        new MutationObserver(tryAttach).observe(contentPane, {
1092            childList: true, subtree: true,
1093        });
1094        document.addEventListener('click', (e) => {
1095            if (!document.body.classList.contains('bf-qsort-open')) return;
1096            if (!e.target.closest('#qsort_filter_display') && !e.target.closest('.bf-qsort-filter-btn')) {
1097                _mobile.closeQsortSheet();
1098            }
1099        });
1100
1101        const buildMainNav = (id) => {
1102            const nav = document.createElement('nav');
1103            nav.id = id;
1104            nav.setAttribute('aria-label', 'Hovedmenu');
1105            nav.innerHTML = `
1106            <a href="#" data-bn="menu">
1107                <span class="bf-bn-icon">
1108                    <span data-auto="icon" data-icon="mob-menu"></span>
1109                </span>
1110                <span>menu</span>
1111            </a>
1112            <a href="#/s/mail/inbox" data-bn="chat">
1113                <span class="bf-bn-icon">
1114                    <span data-auto="icon" data-icon="mob-post"></span>
1115                    <span class="bf-bn-pill hidden"></span>
1116                </span>
1117                <span>post/chat</span>
1118            </a>
1119            <a href="#/s/lounges/lounges" data-bn="lounges" data-bn-area="lounges">
1120                <span data-auto="icon" data-icon="mob-lounges"></span>
1121                <span>lounges</span>
1122            </a>
1123            <a href="#/s/debat/debat" data-bn="debat" data-bn-area="debat">
1124                <span data-auto="icon" data-icon="mob-debat"></span>
1125                <span>debat</span>
1126            </a>
1127            <a href="#/s/lists/online" data-bn="lister" data-bn-area="lists">
1128                <span data-auto="icon" data-icon="mob-list"></span>
1129                <span>lister</span>
1130            </a>
1131            <a href="#/s/search/search" data-bn="soeg" data-bn-area="search">
1132                <span data-auto="icon" data-icon="mob-search"></span>
1133                <span>søg</span>
1134            </a>
1135            `;
1136            nav.querySelectorAll('[data-auto="icon"]').forEach((el) => _elements.auto_load(el));
1137            return nav;
1138        };
1139        const bottomNav = buildMainNav('bf_content_bottomnav');
1140        bottomNav.addEventListener('click', (e) => {
1141            const a = e.target.closest('a[href]');
1142            if (!a) return;
1143            if (a.dataset.bn !== 'menu'
1144                && (e.ctrlKey || e.metaKey || e.shiftKey || e.which === 2)) {
1145                return;
1146            }
1147            e.preventDefault();
1148            e.stopPropagation();
1149            const href = a.getAttribute('href');
1150            if (a.dataset.bn === 'menu') {
1151                toggleMenu();
1152                return;
1153            }
1154            if (a.dataset.bn === 'chat') {
1155                statusInit = true;
1156                if (!tabBody.firstChild) {
1157                    const _histBefore = location.hash;
1158                    _elements.constructor.display_target = '#bf_status_tab_body';
1159                    section('mail', 'inbox');
1160                    if (location.hash !== _histBefore) {
1161                        history.replaceState(history.state, '', _histBefore || location.pathname);
1162                    }
1163                }
1164                swiper.scrollTo({
1165                    left: swiper.scrollWidth - swiper.clientWidth,
1166                    behavior: 'smooth',
1167                });
1168                return;
1169            }
1170            _nav.load(href.charAt(0) === '#' ? href.slice(1) : href);
1171            if (a.dataset.bnArea) openLp(a);
1172        });
1173
1174        const statusMainNav = buildMainNav('bf_status_mainnav');
1175        statusBottomBar.appendChild(statusMainNav);
1176        bindMailPill(statusMainNav.querySelector('a[data-bn="chat"] .bf-bn-pill'));
1177        statusMainNav.addEventListener('click', (e) => {
1178            const a = e.target.closest('a[href]');
1179            if (!a) return;
1180            if (a.dataset.bn !== 'menu'
1181                && (e.ctrlKey || e.metaKey || e.shiftKey || e.which === 2)) {
1182                return;
1183            }
1184            e.preventDefault();
1185            e.stopPropagation();
1186            if (a.dataset.bn === 'menu') {
1187                toggleMenu();
1188                return;
1189            }
1190            if (a.dataset.bn === 'chat') return;
1191            const href = a.getAttribute('href');
1192            _nav.load(href.charAt(0) === '#' ? href.slice(1) : href);
1193            if (a.dataset.bnArea) openLp(a);
1194            setTimeout(() => {
1195                swiper.scrollTo({ left: 0, behavior: 'smooth' });
1196            }, 300);
1197        });
1198        const bottomBar = document.createElement('div');
1199        bottomBar.id = 'bf_content_bottombar';
1200        bottomBar.appendChild(bottomNav);
1201        const profMenuForBar = contentPane.querySelector('#if_profile_menu');
1202        if (profMenuForBar) bottomBar.insertBefore(profMenuForBar, bottomNav);
1203        contentPane.appendChild(bottomBar);
1204        // Klasse-spejl af :has(> #bf_content_bottombar) (mobile.css) — se
1205        // status-panens spejl ovenfor; bottombaren bygges altid.
1206        contentPane.classList.add('bf-has-bottombar');
1207
1208        const updateBottomNavVar = () => {
1209            const h = bottomBar.getBoundingClientRect().height;
1210            if (h) document.documentElement.style.setProperty('--bf-bottomnav-h', Math.round(h) + 'px');
1211            const sh = statusBottomBar.getBoundingClientRect().height;
1212            if (sh) document.documentElement.style.setProperty('--bf-status-bottomnav-h', Math.round(sh) + 'px');
1213        };
1214        requestAnimationFrame(() => requestAnimationFrame(updateBottomNavVar));
1215        if (typeof ResizeObserver !== 'undefined') {
1216            new ResizeObserver(updateBottomNavVar).observe(bottomBar);
1217            new ResizeObserver(updateBottomNavVar).observe(statusBottomBar);
1218        }
1219        window.addEventListener('orientationchange', updateBottomNavVar);
1220        window.addEventListener('resize', updateBottomNavVar);
1221        if (window.visualViewport) {
1222            window.visualViewport.addEventListener('resize', updateBottomNavVar);
1223        }
1224
1225        const KB_MIN = 150;
1226        let vpMax = window.visualViewport ? window.visualViewport.height : window.innerHeight;
1227        let kbFixTimers = [];
1228        let sidsteAnker = null;
1229        const BUND_TOL = 120;
1230        const kbFocusSel ='input:not([type=button]):not([type=submit]):not([type=checkbox]):not([type=radio]), textarea, [contenteditable=""], [contenteditable="true"]';
1231        const syncKeyboardState = () => {
1232            const vv = window.visualViewport;
1233            if (!vv) return;
1234            if (vv.height > vpMax) vpMax = vv.height;
1235            const dækketVp = vpMax - vv.height;
1236            const dækketLayout = Math.max(0, document.documentElement.clientHeight - vv.height);
1237            const dækket = Math.max(dækketVp, dækketLayout);
1238            const skriverIFelt = !!(document.activeElement
1239                && document.activeElement.matches
1240                && document.activeElement.matches(kbFocusSel));
1241            const åbent = skriverIFelt && dækket > KB_MIN;
1242            if (document.body.classList.contains('bf-keyboard-open') === åbent) {
1243                if (sidsteAnker) sidsteAnker();
1244                return;
1245            }
1246
1247            const scrollere = [];
1248            document.querySelectorAll('#bf_mobile_swiper *').forEach((el) => {
1249                if (el.scrollHeight <= el.clientHeight + 1 || el.clientHeight < 50) return;
1250                const oy = getComputedStyle(el).overflowY;
1251                if (oy !== 'auto' && oy !== 'scroll') return;
1252                if (el.offsetParent === null) return;
1253                scrollere.push({
1254                    el: el,
1255                    fraBund: el.scrollHeight - el.scrollTop - el.clientHeight
1256                });
1257            });
1258            for (let i = scrollere.length - 1; i >= 0; i--) {
1259                if (scrollere.some((a, j) => j !== i && scrollere[i].el.contains(a.el))) {
1260                    scrollere.splice(i, 1);
1261                }
1262            }
1263            const genskabScroll = () => {
1264                updateBottomNavVar();
1265                scrollere.forEach((s) => {
1266                    const maxTop = s.el.scrollHeight - s.el.clientHeight;
1267                    const mål = (s.fraBund <= BUND_TOL) ? maxTop : (maxTop - s.fraBund);
1268                    const clamped = Math.max(0, Math.min(mål, maxTop));
1269                    if (Math.abs(s.el.scrollTop - clamped) > 1) s.el.scrollTop = clamped;
1270                });
1271            };
1272
1273            document.body.classList.toggle('bf-keyboard-open', åbent);
1274
1275            kbFixTimers.forEach(clearTimeout);
1276            kbFixTimers = (Ã¥bent ? [60, 150, 300, 500] : [60]).map((ms) =>
1277                setTimeout(genskabScroll, ms));
1278            requestAnimationFrame(genskabScroll);
1279
1280            sidsteAnker = åbent ? genskabScroll : null;
1281        };
1282        if (window.visualViewport) {
1283            window.visualViewport.addEventListener('resize', syncKeyboardState);
1284            window.visualViewport.addEventListener('scroll', syncKeyboardState);
1285        }
1286        document.addEventListener('focusin', () => setTimeout(syncKeyboardState, 250));
1287        document.addEventListener('focusout', () => setTimeout(syncKeyboardState, 250));
1288        window.addEventListener('orientationchange', () => {
1289            vpMax = window.visualViewport ? window.visualViewport.height : window.innerHeight;
1290            setTimeout(syncKeyboardState, 300);
1291        });
1292        syncKeyboardState();
1293        if (document.fonts && document.fonts.ready) {
1294            document.fonts.ready.then(updateBottomNavVar).catch(() => {});
1295        }
1296
1297        const lpPanel = document.createElement('nav');
1298        lpPanel.id = 'bf_lp_panel';
1299        lpPanel.hidden = true;
1300        bottomBar.insertBefore(lpPanel, bottomBar.firstChild);
1301
1302        if (window._mobiletips) {
1303            _mobiletips.init();
1304        }
1305
1306        const LP_ICON = {
1307            lists: {
1308                online:     'mob-lp-online',
1309                newest:     'mob-lp-newest',
1310                diary:      'mob-lp-diary',
1311                updated:    'mob-lp-updated',
1312                nearby:     'mob-lp-nearby',
1313                guests:     'mob-lp-guests',
1314                favorites:  'mob-lp-favorites',
1315                interested: 'msgr-reply',
1316                notes:      'mob-lp-notes',
1317                visited:    'mob-lp-visited',
1318                blocked:    'mob-lp-blocked',
1319            },
1320            lounges: {
1321                lounges: 'mob-lounges',
1322                own:     'mob-lp-guests',
1323                suggest: 'mob-lp-suggest',
1324            },
1325            debat: {
1326                debat: 'mob-debat',
1327                fokus: 'mob-lp-fokus',
1328                egne:  'mob-lp-guests',
1329            },
1330        };
1331        const FALLBACK_ICON_NAME = 'mob-lp-fallback';
1332
1333        function buildLpPanel(area) {
1334            lpPanel.innerHTML = '';
1335            if (typeof MENU_REGISTRY === 'undefined' || !MENU_REGISTRY[area]) return;
1336
1337            const { items } = resolveMenu(area, { userId: ((_profile.user && _profile.user.id) != null ? (_profile.user && _profile.user.id) : null) });
1338            const map = LP_ICON[area] || {};
1339            lpPanel.dataset.area = area;
1340            items.forEach((it) => {
1341                const row = _template.loadById('template_lp_panel_row')[0];
1342                row.setAttribute('href', '#' + it.link);
1343                row.dataset.bnList = it.ref;
1344                row.querySelector('[data-auto="icon"]')
1345                    .setAttribute('data-icon', map[it.ref] || FALLBACK_ICON_NAME);
1346                row.querySelector('.bf-lp-label').textContent = it.text;
1347                lpPanel.appendChild(row);
1348            });
1349
1350            if (area === 'lists') {
1351                _api.call('lists', 'latest_counts', 0, (data) => {
1352                    if (lpPanel.dataset.area !== 'lists') return;
1353                    ['newest', 'diary', 'updated', 'favorites'].forEach((kind) => {
1354                        const n = (data && data[kind]) || 0;
1355                        if (!n) return;
1356                        const icon = lpPanel.querySelector('a[data-bn-list="' + kind + '"] .bf-bn-icon');
1357                        if (!icon || icon.querySelector('.bf-bn-pill')) return;
1358                        const pill = document.createElement('span');
1359                        pill.className = 'bf-bn-pill';
1360                        pill.textContent = n;
1361                        icon.appendChild(pill);
1362                    });
1363                });
1364            }
1365        }
1366
1367        function buildSearchLpPanel() {
1368            lpPanel.innerHTML = '';
1369            lpPanel.dataset.area = 'search';
1370            const rows = [];
1371            (_search._saved_searches || []).forEach((v) => {
1372                rows.push({
1373                    icon: 'mob-search',
1374                    text: v.name,
1375                    action: () => {
1376                        section('search', 'search');
1377                        setTimeout(() => _search.stored(v.id), 60);
1378                    },
1379                });
1380            });
1381            if (_search.activeSearch()) {
1382                rows.push({
1383                    icon: 'mob-search',
1384                    text: 'Aktuelle søgeresultater',
1385                    action: () => _search.openActiveSearch(),
1386                });
1387            }
1388            if (!rows.length) {
1389                rows.push({
1390                    icon: 'mob-search',
1391                    text: 'Søg',
1392                    action: () => section('search', 'search'),
1393                });
1394            }
1395            rows.forEach((r) => {
1396                const row = _template.loadById('template_lp_panel_row')[0];
1397                row.setAttribute('href', '#');
1398                row.dataset.lpAction = 'search';
1399                row.querySelector('[data-auto="icon"]').setAttribute('data-icon', r.icon);
1400                row.querySelector('.bf-lp-label').textContent = r.text;
1401                row._lpAction = r.action;
1402                lpPanel.appendChild(row);
1403            });
1404        }
1405
1406        let lpOpenBtn = null;
1407        let lpOpenedAt = 0;
1408        const openLp = (btn) => {
1409            const area = btn.dataset.bnArea;
1410            if (!area) return;
1411            if (area === 'search') buildSearchLpPanel();
1412            else buildLpPanel(area);
1413            lpOpenBtn = btn;
1414            lpOpenedAt = Date.now();
1415            lpPanel.hidden = false;
1416            requestAnimationFrame(() => lpPanel.classList.add('is-open'));
1417        };
1418        const closeLp = () => {
1419            lpPanel.classList.remove('is-open');
1420            lpOpenBtn = null;
1421            setTimeout(() => { if (!lpPanel.classList.contains('is-open')) lpPanel.hidden = true; }, 200);
1422        };
1423
1424        lpPanel.addEventListener('click', (e) => {
1425            const a = e.target.closest('a[href]');
1426            if (!a) return;
1427            e.preventDefault();
1428            e.stopPropagation();
1429            const href = a.getAttribute('href').replace(/^#/, '');
1430            closeLp();
1431            _nav.load(href);
1432        });
1433
1434        const LP_MS = 300;
1435        const LP_TOL = 10;
1436        bottomNav.querySelectorAll('a[data-bn-area]').forEach((btn) => {
1437            this._wireLongPress(btn, {
1438                ms: LP_MS, tol: LP_TOL,
1439                onLongPress: () => openLp(btn),
1440            });
1441            btn.addEventListener('click', (e) => {
1442                if (lpPanel.classList.contains('is-open') && lpOpenBtn === btn) {
1443                    e.preventDefault();
1444                    e.stopPropagation();
1445                    e.stopImmediatePropagation();
1446                    closeLp();
1447                }
1448            }, true);
1449        });
1450
1451        document.addEventListener('pointerdown', (e) => {
1452            if (!lpPanel.classList.contains('is-open')) return;
1453            if (lpPanel.contains(e.target)) return;
1454            if (lpOpenBtn && lpOpenBtn.contains(e.target)) return;
1455            closeLp();
1456        });
1457        if (contentScroll) {
1458            contentScroll.addEventListener('scroll', () => {
1459                if (!lpPanel.classList.contains('is-open')) return;
1460                if (Date.now() - lpOpenedAt < 1200) return;
1461                closeLp();
1462            }, { passive: true });
1463        }
1464
1465        const PROFILE_TAB_LP = {
1466            diary: (ctx) => [
1467                { icon: 'mob-lp-diary',  text: 'Egne indlæg',      href: '#/s/profile/diary/' + ctx.own },
1468                { icon: 'note',          text: 'Skriv nyt indlæg', action: () => _diary.write_new() },
1469            ],
1470            pictures: (ctx) => [
1471                { icon: 'mob-pm-pictures', text: 'Mine billeder', href: '#/s/pictures/pictures/member/' + ctx.own },
1472                { icon: 'msgr-attach',     text: 'Tilføj',        action: () => _pictures.open_upload() },
1473                { icon: 'folder',          text: 'Mapper',        action: () => _pictures.folders_manage() },
1474            ],
1475            guestbook: (ctx) => {
1476                if (ctx.viewingOwn) {
1477                    return [
1478                        { icon: 'mob-pm-guestbook', text: 'Min gæstebog',          action: () => section('profile', 'guestbook', { member: ctx.own, which: 'other' }) },
1479                        { icon: 'reply',            text: 'Mine indlæg hos andre', action: () => section('profile', 'guestbook', { member: ctx.own, which: 'own' }) },
1480                    ];
1481                }
1482                const navn = ctx.viewedName || 'brugerens';
1483                const genitiv = navn + (/s$/i.test(navn) ? "'" : 's');
1484                return [
1485                    { icon: 'mob-pm-guestbook', text: genitiv + ' gæstebog',      action: () => section('profile', 'guestbook', { member: ctx.viewedId, which: 'other' }) },
1486                    { icon: 'reply',            text: genitiv + ' indlæg hos andre', action: () => section('profile', 'guestbook', { member: ctx.viewedId, which: 'own' }) },
1487                    { icon: 'note',             text: 'Skriv i gæstebogen',       action: () => _guestbook.write_new(ctx.viewedId) },
1488                ];
1489            },
1490        };
1491
1492        const OWN_ONLY_TABS = new Set(['diary', 'pictures']);
1493
1494        const viewedMemberId = () => {
1495            const li = document.querySelector('#if_profile_menu li[data-ref][data-link]');
1496            const m = ((li ? li.getAttribute('data-link') : '') || '').match(/\/(\d+)(?:\/|$)/);
1497            return m ? Number(m[1]) : null;
1498        };
1499
1500        const viewingOwnProfile = () => {
1501            const own = (_profile.user && _profile.user.id);
1502            if (!own) return false;
1503            const vid = viewedMemberId();
1504            return vid !== null ? vid === Number(own) : false;
1505        };
1506
1507        const tabHasLp = (ref) => {
1508            if (!PROFILE_TAB_LP[ref]) return false;
1509            if (OWN_ONLY_TABS.has(ref)) return viewingOwnProfile();
1510            return true;
1511        };
1512
1513        const openProfileTabLp = (tab, ref) => {
1514            const own = (_profile.user && _profile.user.id);
1515            const ctx = {
1516                own,
1517                viewingOwn: viewingOwnProfile(),
1518                viewedId: viewedMemberId(),
1519                viewedName: (_profile.member && _profile.member.username),
1520            };
1521            const rows = (PROFILE_TAB_LP[ref] || (() => []))(ctx);
1522            if (!rows.length) return;
1523            lpPanel.innerHTML = '';
1524            lpPanel.dataset.area = ref;
1525            rows.forEach((r, i) => {
1526                const row = _template.loadById('template_lp_panel_row')[0];
1527                row.setAttribute('href', r.href || '#');
1528                if (r.action) row.dataset.lpAction = ref + ':' + i;
1529                row.querySelector('[data-auto="icon"]').setAttribute('data-icon', r.icon);
1530                row.querySelector('.bf-lp-label').textContent = r.text;
1531                row._lpAction = r.action || null;
1532                lpPanel.appendChild(row);
1533            });
1534            lpOpenBtn = tab;
1535            lpOpenedAt = Date.now();
1536            lpPanel.hidden = false;
1537            requestAnimationFrame(() => lpPanel.classList.add('is-open'));
1538        };
1539
1540        lpPanel.addEventListener('click', (e) => {
1541            const row = e.target.closest('a[data-lp-action]');
1542            if (!row || typeof row._lpAction !== 'function') return;
1543            e.preventDefault();
1544            e.stopPropagation();
1545            const fn = row._lpAction;
1546            closeLp();
1547            fn();
1548        }, true);
1549
1550        this._wireLongPress(document, {
1551            selector: '#if_profile_menu li[data-ref]',
1552            dedupeKey: 'bfProfileTabLp',
1553            shouldStart: (tab) => tabHasLp(tab.getAttribute('data-ref')),
1554            onLongPress: (tab) => openProfileTabLp(tab, tab.getAttribute('data-ref')),
1555        });
1556
1557        document.addEventListener('click', (e) => {
1558            const tab = e.target.closest('#if_profile_menu li[data-ref]');
1559            if (!tab) return;
1560            const ref = tab.getAttribute('data-ref');
1561            if (!tabHasLp(ref)) return;
1562            if (lpPanel.classList.contains('is-open') && lpOpenBtn === tab) {
1563                closeLp();
1564                return;
1565            }
1566            openProfileTabLp(tab, ref);
1567        });
1568
1569        bindMailPill(bottomNav.querySelector('a[data-bn="chat"] .bf-bn-pill'));
1570
1571        const mailSheet = document.createElement('nav');
1572        mailSheet.id = 'bf_mail_actions';
1573        mailSheet.hidden = true;
1574        const statusBar = document.querySelector('#bf_status_bottombar') || bottomBar;
1575        statusBar.insertBefore(mailSheet, statusBar.firstChild);
1576
1577        const closeMailSheet = () => {
1578            mailSheet.classList.remove('is-open');
1579            mailSheet.classList.remove('bf-mail-sheet--insert');
1580            setTimeout(() => { if (!mailSheet.classList.contains('is-open')) mailSheet.hidden = true; }, 200);
1581        };
1582
1583        const renderSheetShell = (title, sheetClass) => {
1584            if (sheetClass) mailSheet.classList.add(sheetClass);
1585            else            mailSheet.classList.remove('bf-mail-sheet--insert');
1586            mailSheet.innerHTML = '';
1587            _template.loadById('template_mail_sheet_shell').appendTo(mailSheet);
1588            const titleEl = mailSheet.querySelector('.bf-mail-actions-title');
1589            if (title) titleEl.textContent = title;
1590            else       titleEl.remove();
1591            return mailSheet.querySelector('.bf-mail-actions-list');
1592        };
1593
1594        const openSheet = () => {
1595            mailSheet.hidden = false;
1596            requestAnimationFrame(() => mailSheet.classList.add('is-open'));
1597        };
1598
1599        const appendActionRows = (listEl, items) => {
1600            items.forEach((it, i) => {
1601                const row = _template.loadById('template_mail_action_row')[0];
1602                row.dataset.bfAct = String(i);
1603                if (it.disabled) row.classList.add('is-disabled');
1604                row.querySelector('[data-auto="icon"]').setAttribute('data-icon', it.icon);
1605                row.querySelector('.bf-mail-action-label').textContent = it.label;
1606                if (it.count != null) {
1607                    const pill = document.createElement('span');
1608                    pill.className = 'count';
1609                    pill.textContent = it.count;
1610                    row.querySelector('.bf-pm-icon').appendChild(pill);
1611                }
1612                row.addEventListener('click', (e) => {
1613                    e.preventDefault();
1614                    e.stopPropagation();
1615                    if (it.disabled) return;
1616                    closeMailSheet();
1617                    if (typeof it.click === 'function') it.click();
1618                });
1619                listEl.appendChild(row);
1620            });
1621        };
1622
1623        const showActionSheet = (title, items) => {
1624            if (!items.length) return;
1625            appendActionRows(renderSheetShell(title), items);
1626            openSheet();
1627        };
1628
1629        this.mailToolsSheet = (title, items, ctxEl) => {
1630            const contentBar = document.getElementById('bf_content_bottombar');
1631            const iContent = ctxEl && !ctxEl.closest('.bf-pane--status');
1632            const host = (iContent && contentBar) ? contentBar : statusBar;
1633
1634            if (mailSheet.parentElement !== host) {
1635                host.insertBefore(mailSheet, host.firstChild);
1636            }
1637
1638            showActionSheet(title, items);
1639        };
1640
1641        const openInboxRowSheet = (row) => {
1642            const uid = Number(row.getAttribute('user'));
1643            if (!uid) return;
1644
1645            const items = [
1646                { label: 'Gem som PDF',  icon: 'mail-pdf',    click: () => _mail.pdf(uid) },
1647                { label: 'Slet samtale', icon: 'mail-delete', click: () => _mail.delete_thread(uid) },
1648            ];
1649
1650            showActionSheet('Værktøjer', items);
1651        };
1652
1653        const openMessageSheet = (msgRow) => {
1654            const mailEl = msgRow.querySelector('.mail_body[mail], .mail_attachment[mail]');
1655            const mailId = mailEl ? Number(mailEl.getAttribute('mail')) : 0;
1656            const hasDelete = !!msgRow.querySelector('.mail_options .mail_opt_delete_single, .mail_options span[title*="Slet"]');
1657            if (!hasDelete && !mailId) return;
1658
1659            const copyMessageText = () => {
1660                const body = msgRow.querySelector('.mail_body');
1661                if (!body) return;
1662                const txt = (body.textContent || '').trim();
1663                if (!txt) return;
1664
1665                const execFallback = () => {
1666                    const ta = document.createElement('textarea');
1667                    ta.value = txt;
1668                    ta.setAttribute('readonly', '');
1669                    ta.style.position = 'fixed';
1670                    ta.style.opacity = '0';
1671                    document.body.appendChild(ta);
1672                    ta.select();
1673                    try { document.execCommand('copy'); } catch (_) {}
1674                    document.body.removeChild(ta);
1675                };
1676                if ((navigator.clipboard && navigator.clipboard.writeText)) {
1677                    navigator.clipboard.writeText(txt).catch(execFallback);
1678                } else {
1679                    execFallback();
1680                }
1681            };
1682
1683            const items = [{ label: 'Kopier tekst', icon: 'mail-copy', click: copyMessageText }];
1684            if (mailId) {
1685                const own = msgRow.classList.contains('mail_from_me') || !!msgRow.closest('.mail_from_me');
1686                items.push({ label: 'Slet besked', icon: 'mail-delete', click: () => _mail.delete_single(mailId, own) });
1687            }
1688            showActionSheet('Værktøjer', items);
1689        };
1690
1691        const openEmoteSheet = (textarea) => {
1692            if (!textarea) return;
1693            const listEl = renderSheetShell('Smileys', 'bf-mail-sheet--insert');
1694            const grid = document.createElement('div');
1695            grid.className = 'bf-emote-grid';
1696            _elements.emote_codes().forEach((code) => {
1697                const a = document.createElement('a');
1698                a.href = '#';
1699                a.innerHTML = `<img class="emote-svg" src="${_elements.emote_url(code)}" alt=":${code}:" />`;
1700                a.addEventListener('click', (e) => {
1701                    e.preventDefault();
1702                    e.stopPropagation();
1703                    _elements.emote_insert(code, textarea);
1704                    closeMailSheet();
1705                });
1706                grid.appendChild(a);
1707            });
1708            listEl.appendChild(grid);
1709            openSheet();
1710        };
1711
1712        const openReplySheet = (textarea) => {
1713            if (!textarea) return;
1714            _api.call('mail', 'reply_list', 0, (data) => {
1715                if (data === 'error') {
1716                    const listEl = renderSheetShell('Standardsvar', 'bf-mail-sheet--insert');
1717                    const empty = document.createElement('div');
1718                    empty.className = 'bf-reply-empty';
1719                    empty.textContent = 'Kræver abonnement';
1720                    listEl.appendChild(empty);
1721                    openSheet();
1722                    return;
1723                }
1724                if (!data || typeof data !== 'object') return;
1725                const listEl = renderSheetShell('Standardsvar', 'bf-mail-sheet--insert');
1726                const list = document.createElement('div');
1727                list.className = 'bf-reply-list';
1728                Object.values(data).forEach((v) => {
1729                    const id = Number(v.id);
1730                    if (!id) return;
1731                    const a = document.createElement('a');
1732                    a.href = '#';
1733                    a.textContent = String(v.navn || '');
1734                    a.addEventListener('click', (e) => {
1735                        e.preventDefault();
1736                        e.stopPropagation();
1737                        _mail.standard_reply_insert(id, textarea);
1738                        closeMailSheet();
1739                    });
1740                    list.appendChild(a);
1741                });
1742                listEl.appendChild(list);
1743                openSheet();
1744            });
1745        };
1746
1747        const wireMailLpOn = (host, rowSelector, openFn, datasetKey) => {
1748            if (!host || host.dataset[datasetKey] === '1') return;
1749            this._wireLongPress(host, {
1750                selector: rowSelector,
1751                dedupeKey: datasetKey,
1752                onLongPress: (row) => openFn(row),
1753            });
1754            host.addEventListener('scroll', () => {
1755                if (mailSheet.classList.contains('is-open')) closeMailSheet();
1756            }, { passive: true });
1757        };
1758        wireMailLpOn(contentScroll, '#mail_lines > div', openInboxRowSheet, 'bfMailLpInbox');
1759        wireMailLpOn(tabBody, '#mail_lines > div', openInboxRowSheet, 'bfMailLpInbox');
1760        wireMailLpOn(tabBody, '#mail_read > .mail_msg', openMessageSheet, 'bfMailLpMessage');
1761
1762        const openFormatSheet = (textarea) => {
1763            if (!textarea) return;
1764            showActionSheet('Formatering', [
1765                { label: 'Fed: *tekst*',                icon: 'format-fed',          click: () => _elements.format_apply(textarea, '*') },
1766                { label: 'Kursiv: /tekst/',             icon: 'format-kursiv',       click: () => _elements.format_apply(textarea, '/') },
1767                { label: 'Understreget: _tekst_',       icon: 'format-understreget', click: () => _elements.format_apply(textarea, '_') },
1768                { label: 'Omtal profil: #profilnavn#',  icon: 'format-navn',         click: () => _elements.format_apply(textarea, '#') },
1769            ]);
1770        };
1771
1772        const wireMsgrInsertSheets = (host) => {
1773            if (!host || host.dataset.bfMsgrInsert === '1') return;
1774            host.dataset.bfMsgrInsert = '1';
1775            host.addEventListener('click', (e) => {
1776                const btn = e.target.closest('.msgr_tool_smiley, .msgr_tool_reply, .msgr_tool_format');
1777                if (!btn) return;
1778                const compose = host.querySelector(':scope > .msgr_compose');
1779                if (!compose || !compose.contains(btn)) return;
1780                const textarea = compose.querySelector('#mailtxt');
1781                if (!textarea) return;
1782                e.preventDefault();
1783                e.stopPropagation();
1784                e.stopImmediatePropagation();
1785                if (btn.classList.contains('msgr_tool_smiley')) {
1786                    openEmoteSheet(textarea);
1787                } else if (btn.classList.contains('msgr_tool_format')) {
1788                    openFormatSheet(textarea);
1789                } else {
1790                    openReplySheet(textarea);
1791                }
1792            }, true);
1793        };
1794        wireMsgrInsertSheets(statusBar);
1795
1796        document.addEventListener('pointerdown', (e) => {
1797            if (!mailSheet.classList.contains('is-open')) return;
1798            if (mailSheet.contains(e.target)) return;
1799            closeMailSheet();
1800        });
1801
1802        const $guestSrc = document.getElementById('guest_container');
1803        const $favSrc   = document.getElementById('favorite_container');
1804
1805        subSlot.innerHTML = `
1806            <section class="bf-mirror" data-mirror="guests">
1807                <a href="#/s/lists_own/guests" class="bf-mirror-heading">dine gæster<span class="left_arrow"></span></a>
1808                <div class="bf-mirror-list" id="bf_mirror_guests"></div>
1809            </section>
1810            <section class="bf-mirror" data-mirror="favorites">
1811                <a href="#/s/lists_own/favorites" class="bf-mirror-heading">favoritter<span class="left_arrow"></span></a>
1812                <div class="bf-mirror-list" id="bf_mirror_favorites"></div>
1813            </section>
1814            <section class="bf-mirror" data-mirror="logins">
1815                <span class="bf-mirror-heading">gemte logins</span>
1816                <div class="bf-mirror-list" id="bf_mirror_logins"></div>
1817            </section>
1818        `;
1819        const $mirrorGuests = subSlot.querySelector('#bf_mirror_guests');
1820        const $mirrorFavs   = subSlot.querySelector('#bf_mirror_favorites');
1821        const $mirrorLogins = subSlot.querySelector('#bf_mirror_logins');
1822
1823        const $loginsSection = subSlot.querySelector('[data-mirror="logins"]');
1824        if ($loginsSection && menuRight) {
1825            menuRight.insertBefore($loginsSection, menuRight.firstChild);
1826        }
1827
1828        const wireMirrorClicks = (host, prefix) => {
1829            host.addEventListener('click', (e) => {
1830                const row = e.target.closest('.link[data-mirror-of^="' + prefix + '_"]');
1831                if (!row) return;
1832                e.preventDefault();
1833                e.stopPropagation();
1834                const id = parseInt(row.getAttribute('data-mirror-of').slice(prefix.length + 1), 10);
1835                if (!id) return;
1836                _nav.profile_display(id);
1837                document.body.classList.remove('bf-menu-open');
1838            });
1839        };
1840        wireMirrorClicks($mirrorGuests, 'guest_container');
1841        wireMirrorClicks($mirrorFavs,   'favorite_container');
1842
1843        function syncMirror(src, dst) {
1844            if (!src) { dst.innerHTML = ''; return; }
1845            // Skjulte rækker = offline (favorite_hide slideUp'er dem) — spejl
1846            // kun de synlige, og alle sammen (gæste-kilden er selv cappet).
1847            const slice = Array.from(src.children).filter((el) => el.id && el.style.display !== 'none');
1848            dst.innerHTML = '';
1849            slice.forEach((row) => {
1850                const c = row.cloneNode(true);
1851                c.style.display = '';
1852                c.setAttribute('data-mirror-of', row.id);
1853                c.removeAttribute('id');
1854                c.querySelectorAll('[id]').forEach((el) => {
1855                    if (!el.closest('svg')) el.removeAttribute('id');
1856                });
1857                dst.appendChild(c);
1858            });
1859            if (!slice.length) {
1860                dst.innerHTML = '<div class="bf-mirror-empty">— ingen —</div>';
1861            }
1862        }
1863
1864        const $loginsSrc = document.getElementById('logins_saved');
1865        const syncLoginsMirror = () => {
1866            if (!$mirrorLogins) return;
1867            const rows = $loginsSrc
1868                ? Array.from($loginsSrc.querySelectorAll('li[data-login-saved]'))
1869                    .filter((li) => li.style.display !== 'none')
1870                : [];
1871            $mirrorLogins.innerHTML = '';
1872            rows.forEach((li) => {
1873                const id = parseInt(li.getAttribute('data-login-saved'), 10);
1874                const name = (li.querySelector('a') || { textContent: '' }).textContent;
1875                if (!id || !name) return;
1876                const row = document.createElement('div');
1877                row.className = 'link';
1878                const a = document.createElement('a');
1879                a.href = '#';
1880                a.textContent = name;
1881                row.appendChild(a);
1882                row.addEventListener('click', (e) => {
1883                    e.preventDefault();
1884                    document.body.classList.remove('bf-menu-open');
1885                    _profile.login_saved(id);
1886                });
1887                $mirrorLogins.appendChild(row);
1888            });
1889            if ($loginsSection) $loginsSection.style.display = $mirrorLogins.children.length ? '' : 'none';
1890        };
1891
1892        const refreshMirrors = () => {
1893            syncMirror($guestSrc, $mirrorGuests);
1894            syncMirror($favSrc,   $mirrorFavs);
1895            syncLoginsMirror();
1896        };
1897        refreshMirrors();
1898
1899        let mirrorRaf = 0;
1900        const mirrorObserver = new MutationObserver(() => {
1901            if (mirrorRaf) return;
1902            mirrorRaf = requestAnimationFrame(() => {
1903                mirrorRaf = 0;
1904                refreshMirrors();
1905            });
1906        });
1907        if ($guestSrc) mirrorObserver.observe($guestSrc, { childList: true, subtree: true, attributes: true, attributeFilter: ['style'] });
1908        if ($favSrc)   mirrorObserver.observe($favSrc,   { childList: true, subtree: true, attributes: true, attributeFilter: ['style'] });
1909        if ($loginsSrc) mirrorObserver.observe($loginsSrc, { childList: true, subtree: true, attributes: true, attributeFilter: ['style'] });
1910
1911        requestAnimationFrame(() => {
1912            swiper.scrollLeft = 0;
1913        });
1914
1915        let loggedOutFooter = null;
1916        let currentMode = null;
1917
1918        const $toprow = document.querySelector('#toprowcontainer .toprow');
1919        let blivmedlemSlot = null;
1920
1921        function mountBlivmedlemInHeader() {
1922            if (!$toprow) return;
1923            if (!blivmedlemSlot) {
1924                blivmedlemSlot = document.createElement('div');
1925                blivmedlemSlot.className = 'bf-header-blivmedlem';
1926            }
1927            if (blivmedlemSlot.parentElement !== $toprow) {
1928                $toprow.appendChild(blivmedlemSlot);
1929            }
1930            const btn = document.querySelector('.section_login .blivmedlem a')
1931                     || document.querySelector('.section_login .blivmedlem');
1932            if (btn && btn.parentElement !== blivmedlemSlot) {
1933                blivmedlemSlot.appendChild(btn);
1934            }
1935        }
1936
1937        function unmountBlivmedlemFromHeader() {
1938            if (blivmedlemSlot && blivmedlemSlot.parentElement) {
1939                blivmedlemSlot.parentElement.removeChild(blivmedlemSlot);
1940            }
1941        }
1942
1943        function applyLoggedOutLayout() {
1944            if (currentMode === 'out') return;
1945            currentMode = 'out';
1946            document.body.classList.add('is-logged-out');
1947            mountBlivmedlemInHeader();
1948
1949            if (!loggedOutFooter) {
1950                loggedOutFooter = document.createElement('div');
1951                loggedOutFooter.className = 'bf-loggedout-footer';
1952            }
1953            let topRow = loggedOutFooter.querySelector('.bf-loggedout-top');
1954            if (!topRow) {
1955                topRow = document.createElement('div');
1956                topRow.className = 'bf-loggedout-top';
1957                loggedOutFooter.appendChild(topRow);
1958            }
1959            if (footer && footer.children.length) topRow.appendChild(footer);
1960            if (installBox) topRow.appendChild(installBox);
1961
1962            if (bottomRow) {
1963                const visits = bottomRow.querySelector('.visits_container');
1964                if (visits) visits.style.display = 'none';
1965                if ($profilesBox && $profilesBox.parentElement !== bottomRow) {
1966                    bottomRow.insertBefore($profilesBox, bottomRow.firstChild);
1967                }
1968                if (bottomRow.children.length) loggedOutFooter.appendChild(bottomRow);
1969            }
1970            const footerHost = contentScroll || contentPane;
1971            if (footerHost) footerHost.appendChild(loggedOutFooter);
1972        }
1973
1974        function applyLoggedInLayout() {
1975            if (currentMode === 'in') return;
1976            currentMode = 'in';
1977            document.body.classList.remove('is-logged-out');
1978            unmountBlivmedlemFromHeader();
1979            if (footer && menuLeft) menuLeft.appendChild(footer);
1980            if (bottomRow && menuSplit) menuSplit.appendChild(bottomRow);
1981            if (installBox && menuRight) menuRight.appendChild(installBox);
1982            if (bottomRow) {
1983                const visits = bottomRow.querySelector('.visits_container');
1984                if (visits) visits.style.display = '';
1985            }
1986            if ($profilesBox && $statusOnlineBox && $profilesBox.parentElement !== $statusOnlineBox) {
1987                $statusOnlineBox.appendChild($profilesBox);
1988            }
1989            if (loggedOutFooter && loggedOutFooter.parentElement) {
1990                loggedOutFooter.parentElement.removeChild(loggedOutFooter);
1991            }
1992        }
1993
1994        document.addEventListener('bf:login-change', (e) => {
1995            if (e.detail && e.detail.loggedIn) applyLoggedInLayout();
1996            else applyLoggedOutLayout();
1997        });
1998
1999        document.addEventListener('bf:login-page-rendered', () => {
2000            if (currentMode === 'out') mountBlivmedlemInHeader();
2001        });
2002
2003        const $ifAd = document.getElementById('if_ad');
2004        if ($ifAd && bottomBar) {
2005            bottomBar.insertBefore($ifAd, bottomBar.firstChild);
2006        }
2007    }
2008
2009    _wireLongPress(host, opts) {
2010        if (!host) return;
2011        const { selector = null, onLongPress, ms = 300, tol = 10, dedupeKey = null, shouldStart = null } = opts || {};
2012        if (typeof onLongPress !== 'function') return;
2013        if (dedupeKey && host.dataset) {
2014            if (host.dataset[dedupeKey] === '1') return;
2015            host.dataset[dedupeKey] = '1';
2016        }
2017
2018        let timer = 0, fired = false, x = 0, y = 0, target = null;
2019        const cancel = () => { if (timer) { clearTimeout(timer); timer = 0; } };
2020        const resolve = (e) => selector ? (e.target.closest ? e.target.closest(selector) : null) : host;
2021
2022        if (!selector && host.classList) host.classList.add('bf-lp-noselect');
2023
2024        host.addEventListener('pointerdown', (e) => {
2025            const t = resolve(e);
2026            if (!t) return;
2027            if (shouldStart && !shouldStart(t)) return;
2028            if (selector && t.classList) t.classList.add('bf-lp-noselect');
2029            target = t; fired = false; x = e.clientX; y = e.clientY;
2030            cancel();
2031            timer = setTimeout(() => {
2032                timer = 0; fired = true;
2033                if (navigator.vibrate) navigator.vibrate(10);
2034                onLongPress(target, e);
2035            }, ms);
2036        });
2037        host.addEventListener('pointermove', (e) => {
2038            if (!timer) return;
2039            if (Math.hypot(e.clientX - x, e.clientY - y) > tol) cancel();
2040        });
2041        ['pointerup', 'pointercancel', 'pointerleave'].forEach((ev) => host.addEventListener(ev, cancel));
2042        host.addEventListener('click', (e) => {
2043            if (!fired) return;
2044            const t = selector ? (e.target.closest ? e.target.closest(selector) : null) : host;
2045            if (t !== target) return;
2046            e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
2047            fired = false;
2048        }, true);
2049        host.addEventListener('contextmenu', (e) => {
2050            if (!selector || e.target.closest(selector)) e.preventDefault();
2051        });
2052    }
2053
2054
2055    _lockDesktopViewport(isMobileViewport) {
2056        if (isMobileViewport) return;
2057
2058        const app = document.getElementById('boyfriend');
2059        if (!app) return;
2060
2061        const DESIGN = this._DESKTOP_DESIGN_W;
2062
2063        const apply = () => {
2064            try {
2065                const portrait = window.matchMedia('(orientation: portrait)').matches;
2066                // LAYOUT-viewporten, IKKE window.innerWidth: pinch-zoom ændrer
2067                // innerWidth (og fyrer resize på iOS), så skaleringen ellers
2068                // regner ny scale af den zoomede bredde og slås med brugerens
2069                // zoom — det er dét der får siden til at hoppe.
2070                const avail = document.documentElement.clientWidth;
2071
2072                if (portrait && avail < DESIGN) {
2073                    const scale = avail / DESIGN;
2074                    document.body.classList.add('bf-desktop-zoomed');
2075                    app.style.width = DESIGN + 'px';
2076                    app.style.maxWidth = 'none';
2077                    app.style.boxSizing = 'border-box';
2078                    app.style.transformOrigin = 'top left';
2079                    app.style.transform = 'scale(' + scale + ')';
2080                    app.style.marginLeft = '0';
2081                    app.style.marginRight = '0';
2082                    const h = app.offsetHeight;
2083                    app.style.marginBottom = (-(h * (1 - scale))) + 'px';
2084                } else {
2085                    document.body.classList.remove('bf-desktop-zoomed');
2086                    app.style.width = '';
2087                    app.style.maxWidth = '';
2088                    app.style.boxSizing = '';
2089                    app.style.transformOrigin = '';
2090                    app.style.transform = '';
2091                    app.style.marginLeft = '';
2092                    app.style.marginRight = '';
2093                    app.style.marginBottom = '';
2094                }
2095            } catch (e) {  }
2096        };
2097
2098        setTimeout(apply, 300);
2099        const mq = window.matchMedia('(orientation: portrait)');
2100        if (mq.addEventListener) mq.addEventListener('change', () => setTimeout(apply, 100));
2101        else if (mq.addListener) mq.addListener(() => setTimeout(apply, 100));
2102        window.addEventListener('resize', () => setTimeout(apply, 100));
2103    }
2104
2105    pwa() {
2106        const ua = window.navigator.userAgent;
2107        const isStandalone = () => window.matchMedia('(display-mode: standalone)').matches
2108            || window.navigator.standalone === true;
2109        const isIos = /iphone|ipad|ipod/i.test(ua);
2110        const isIosSafari = isIos && !/crios|fxios|edgios/i.test(ua);
2111        const isIosOther = isIos && !isIosSafari;
2112
2113        const setHidden = (selector, hidden) => {
2114            document.querySelectorAll(selector).forEach((el) => { el.hidden = hidden; });
2115        };
2116
2117        setHidden('[data-action="pwa-install"]', true);
2118        setHidden('[data-pwa-ios-hint]', true);
2119        setHidden('[data-pwa-ios-other]', true);
2120
2121        if (!isStandalone()) {
2122            if (isIosSafari) setHidden('[data-pwa-ios-hint]', false);
2123            else if (isIosOther) setHidden('[data-pwa-ios-other]', false);
2124        }
2125
2126        // beforeinstallprompt fanges nu i constructoren (før dette kald).
2127        // Hvis eventet allerede er kommet, vis knappen med det samme.
2128        if (this._pwaPrompt && !isStandalone()) {
2129            setHidden('[data-action="pwa-install"]', false);
2130        }
2131
2132        window.addEventListener('appinstalled', () => {
2133            this._pwaPrompt = null;
2134            setHidden('[data-action="pwa-install"]', true);
2135            setHidden('[data-pwa-ios-hint]', true);
2136            setHidden('[data-pwa-ios-other]', true);
2137        });
2138
2139        document.addEventListener('click', (e) => {
2140            const btn = e.target.closest('[data-action="pwa-install"]');
2141            if (!btn) return;
2142            e.preventDefault();
2143            if (!this._pwaPrompt) return;
2144            const prompt = this._pwaPrompt;
2145            prompt.prompt();
2146            const done = () => {
2147                this._pwaPrompt = null;
2148                setHidden('[data-action="pwa-install"]', true);
2149            };
2150            prompt.userChoice.then(done, done);
2151        });
2152
2153        if ('serviceWorker' in navigator) {
2154            // pwa() kører efter hello-roundtrippet; er 'load' allerede fyret,
2155            // ville en load-listener aldrig blive kaldt → SW aldrig registreret.
2156            // Registrér direkte hvis dokumentet er færdigloadet, ellers vent.
2157            const registerSw = () => navigator.serviceWorker.register('/sw.js', { scope: '/' })
2158                .catch((err) => console.warn('SW registration failed:', err));
2159
2160            if (document.readyState === 'complete') {
2161                registerSw();
2162            } else {
2163                window.addEventListener('load', registerSw, { once: true });
2164            }
2165        }
2166    }
2167
2168    topbarCounters() {
2169        const root = document.getElementById('bf-topbar-counters');
2170        const bell = root.querySelector('a[data-counter="notifications"]');
2171        const pill = bell.querySelector('.bf-tc-pill');
2172        pill.setAttribute('aria-hidden', 'true');
2173
2174        const setPill = (n) => {
2175            n = Math.max(0, parseInt(n, 10) || 0);
2176            pill.textContent = n;
2177            pill.style.display = n > 0 ? '' : 'none';
2178            bell.setAttribute('aria-label', n > 0 ? `notifikationer, ${n} ${n === 1 ? 'ny' : 'nye'}` : 'notifikationer');
2179        };
2180        const _nbadge = document.getElementById('notifications_badge');
2181        setPill(_nbadge ? _nbadge.textContent : undefined);
2182        document.addEventListener('bf:notifications-unread-count', (e) => setPill((e.detail && e.detail.count)));
2183
2184        document.addEventListener('bf:login-change', (e) => {
2185            root.style.display = (e.detail && e.detail.loggedIn) ? '' : 'none';
2186        });
2187        if (((_profile && _profile.user) && (_profile && _profile.user).id)) root.style.display = '';
2188    }
2189}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.