@@ -61,6 +61,8 @@ document.addEventListener("DOMContentLoaded", function () {
6161 const mobileThemeToggle = document . getElementById ( "mobile-theme-toggle" ) ;
6262 const shareButton = document . getElementById ( "share-button" ) ;
6363 const mobileShareButton = document . getElementById ( "mobile-share-button" ) ;
64+ const sharePresence = document . getElementById ( "share-presence" ) ;
65+ const mobileSharePresence = document . getElementById ( "mobile-share-presence" ) ;
6466 const githubImportModal = document . getElementById ( "github-import-modal" ) ;
6567 const githubImportTitle = document . getElementById ( "github-import-title" ) ;
6668 const githubImportUrlInput = document . getElementById ( "github-import-url" ) ;
@@ -2599,6 +2601,199 @@ This is a fully client-side application. Your content never leaves your browser
25992601 // ============================================
26002602
26012603 const MAX_SHARE_URL_LENGTH = 32000 ;
2604+ const SHARE_PRESENCE_KEY = "mdv:share-presence" ;
2605+ const SHARE_PRESENCE_HEARTBEAT_MS = 15000 ;
2606+ const SHARE_PRESENCE_STALE_MS = SHARE_PRESENCE_HEARTBEAT_MS * 2 ;
2607+ const clientId = `${ Date . now ( ) . toString ( 36 ) } -${ Math . random ( ) . toString ( 36 ) . slice ( 2 , 10 ) } ` ;
2608+ const clientName = generateClientName ( ) ;
2609+ const clientIcon = animalEmojiForId ( clientId ) ;
2610+ let sharePresenceHeartbeatTimer = null ;
2611+
2612+ const adjectives = [
2613+ 'Acidic' , 'Awesome' , 'Bitter' , 'Burnt' , 'Buttery' , 'Creamy' , 'Fantastic' , 'Fresh' , 'Fried' ,
2614+ 'Good' , 'Juicy' , 'Moist' , 'Raw' , 'Roasted' , 'Salty' , 'Seasoned' , 'Sharp' , 'Sour' , 'Sugary' ,
2615+ 'Sweet' , 'Stale' ,
2616+ ] ;
2617+
2618+ const nouns = [
2619+ 'Bamboo' , 'Cabbage' , 'Cactus' , 'Fern' , 'Garlic' , 'Lemon' , 'Lily' , 'Melon' , 'Onion' ,
2620+ 'Palm' , 'Plum' , 'Tofu' , 'Tomato' , 'Watermelon' ,
2621+ ] ;
2622+
2623+ const animalIcons = [ '🐶' , '🐱' , '🦊' , '🐼' , '🐨' , '🐯' , '🦁' , '🐸' , '🐵' , '🐧' , '🦉' , '🦄' ] ;
2624+
2625+ function arrayRandom ( array ) {
2626+ return array [ Math . floor ( Math . random ( ) * array . length ) ] ;
2627+ }
2628+
2629+ function generateClientName ( ) {
2630+ return `${ arrayRandom ( adjectives ) } ${ arrayRandom ( nouns ) } ` ;
2631+ }
2632+
2633+ function animalEmojiForId ( id ) {
2634+ let hash = 0 ;
2635+ for ( let i = 0 ; i < id . length ; i ++ ) hash = ( ( hash << 5 ) - hash ) + id . charCodeAt ( i ) ;
2636+ return animalIcons [ Math . abs ( hash ) % animalIcons . length ] ;
2637+ }
2638+
2639+ function readPresenceState ( ) {
2640+ try {
2641+ return JSON . parse ( localStorage . getItem ( SHARE_PRESENCE_KEY ) ) || { } ;
2642+ } catch ( _ ) {
2643+ return { } ;
2644+ }
2645+ }
2646+
2647+ function writePresenceState ( state ) {
2648+ try {
2649+ localStorage . setItem ( SHARE_PRESENCE_KEY , JSON . stringify ( state ) ) ;
2650+ } catch ( _ ) {
2651+ // ignore storage write failures
2652+ }
2653+ }
2654+
2655+ function cleanPresenceState ( state ) {
2656+ const now = Date . now ( ) ;
2657+ const cleaned = { } ;
2658+ Object . keys ( state ) . forEach ( ( id ) => {
2659+ const user = state [ id ] ;
2660+ if ( ! user || typeof user . lastSeen !== 'number' ) return ;
2661+ if ( now - user . lastSeen <= SHARE_PRESENCE_STALE_MS ) {
2662+ cleaned [ id ] = user ;
2663+ }
2664+ } ) ;
2665+ return cleaned ;
2666+ }
2667+
2668+ function updatePresence ( shareId ) {
2669+ if ( ! shareId ) return ;
2670+ const state = cleanPresenceState ( readPresenceState ( ) ) ;
2671+ state [ clientId ] = {
2672+ id : clientId ,
2673+ shareId,
2674+ name : clientName ,
2675+ icon : clientIcon ,
2676+ lastSeen : Date . now ( ) ,
2677+ } ;
2678+ writePresenceState ( state ) ;
2679+ }
2680+
2681+ function leavePresence ( ) {
2682+ const state = readPresenceState ( ) ;
2683+ if ( state [ clientId ] ) {
2684+ delete state [ clientId ] ;
2685+ writePresenceState ( state ) ;
2686+ }
2687+ }
2688+
2689+ function getShareIdFromHash ( ) {
2690+ const hash = window . location . hash ;
2691+ if ( ! hash . startsWith ( '#share=' ) ) return null ;
2692+ return hash . slice ( '#share=' . length ) || null ;
2693+ }
2694+
2695+ function renderPresence ( ) {
2696+ const shareId = getShareIdFromHash ( ) ;
2697+ const isSharedSession = Boolean ( shareId ) ;
2698+ contentContainer . classList . toggle ( 'shared-active' , isSharedSession ) ;
2699+
2700+ [ sharePresence , mobileSharePresence ] . forEach ( ( container ) => {
2701+ if ( ! container ) return ;
2702+ container . innerHTML = '' ;
2703+ if ( ! isSharedSession ) {
2704+ container . style . display = 'none' ;
2705+ return ;
2706+ }
2707+
2708+ const state = cleanPresenceState ( readPresenceState ( ) ) ;
2709+ const users = Object . values ( state )
2710+ . filter ( ( u ) => u && u . shareId === shareId )
2711+ . sort ( ( a , b ) => b . lastSeen - a . lastSeen ) ;
2712+
2713+ if ( ! users . length ) {
2714+ container . style . display = 'none' ;
2715+ return ;
2716+ }
2717+
2718+ container . style . display = '' ;
2719+ const visibleUsers = users . slice ( 0 , 4 ) ;
2720+ const extraUsers = users . slice ( 4 ) ;
2721+
2722+ visibleUsers . forEach ( ( user ) => {
2723+ const avatar = document . createElement ( 'span' ) ;
2724+ avatar . className = `share-presence-avatar${ user . id === clientId ? ' self' : '' } ` ;
2725+ avatar . textContent = user . icon || '🐾' ;
2726+ avatar . title = user . name || 'Shared user' ;
2727+ avatar . setAttribute ( 'aria-label' , user . name || 'Shared user' ) ;
2728+ container . appendChild ( avatar ) ;
2729+ } ) ;
2730+
2731+ if ( extraUsers . length ) {
2732+ const wrapper = document . createElement ( 'div' ) ;
2733+ wrapper . className = 'dropdown' ;
2734+ const button = document . createElement ( 'button' ) ;
2735+ button . className = 'btn btn-sm dropdown-toggle share-presence-toggle' ;
2736+ button . type = 'button' ;
2737+ button . setAttribute ( 'data-bs-toggle' , 'dropdown' ) ;
2738+ button . setAttribute ( 'aria-expanded' , 'false' ) ;
2739+ button . textContent = `+${ extraUsers . length } ` ;
2740+
2741+ const menu = document . createElement ( 'ul' ) ;
2742+ menu . className = 'dropdown-menu dropdown-menu-end share-presence-more-list' ;
2743+
2744+ extraUsers . forEach ( ( user ) => {
2745+ const item = document . createElement ( 'li' ) ;
2746+ const label = document . createElement ( 'span' ) ;
2747+ label . className = 'dropdown-item share-presence-item' ;
2748+ const avatarInline = document . createElement ( 'span' ) ;
2749+ avatarInline . className = 'avatar-inline' ;
2750+ avatarInline . textContent = user . icon || '🐾' ;
2751+ avatarInline . style . background = 'linear-gradient(135deg, #58a6ff, #1f6feb)' ;
2752+ const nameText = document . createElement ( 'span' ) ;
2753+ nameText . textContent = user . name || 'Shared user' ;
2754+ label . appendChild ( avatarInline ) ;
2755+ label . appendChild ( nameText ) ;
2756+ item . appendChild ( label ) ;
2757+ menu . appendChild ( item ) ;
2758+ } ) ;
2759+
2760+ wrapper . appendChild ( button ) ;
2761+ wrapper . appendChild ( menu ) ;
2762+ container . appendChild ( wrapper ) ;
2763+ }
2764+ } ) ;
2765+ }
2766+
2767+ function syncPresenceLoop ( ) {
2768+ const shareId = getShareIdFromHash ( ) ;
2769+ if ( ! shareId ) {
2770+ if ( sharePresenceHeartbeatTimer ) {
2771+ clearInterval ( sharePresenceHeartbeatTimer ) ;
2772+ sharePresenceHeartbeatTimer = null ;
2773+ }
2774+ leavePresence ( ) ;
2775+ renderPresence ( ) ;
2776+ return ;
2777+ }
2778+
2779+ updatePresence ( shareId ) ;
2780+ renderPresence ( ) ;
2781+
2782+ if ( ! sharePresenceHeartbeatTimer ) {
2783+ sharePresenceHeartbeatTimer = setInterval ( ( ) => {
2784+ const activeShareId = getShareIdFromHash ( ) ;
2785+ if ( ! activeShareId ) {
2786+ clearInterval ( sharePresenceHeartbeatTimer ) ;
2787+ sharePresenceHeartbeatTimer = null ;
2788+ leavePresence ( ) ;
2789+ renderPresence ( ) ;
2790+ return ;
2791+ }
2792+ updatePresence ( activeShareId ) ;
2793+ renderPresence ( ) ;
2794+ } , SHARE_PRESENCE_HEARTBEAT_MS ) ;
2795+ }
2796+ }
26022797
26032798 function encodeMarkdownForShare ( text ) {
26042799 const compressed = pako . deflate ( new TextEncoder ( ) . encode ( text ) ) ;
@@ -2682,6 +2877,12 @@ This is a fully client-side application. Your content never leaves your browser
26822877 }
26832878
26842879 loadFromShareHash ( ) ;
2880+ syncPresenceLoop ( ) ;
2881+ window . addEventListener ( 'hashchange' , syncPresenceLoop ) ;
2882+ window . addEventListener ( 'storage' , ( e ) => {
2883+ if ( e . key === SHARE_PRESENCE_KEY ) renderPresence ( ) ;
2884+ } ) ;
2885+ window . addEventListener ( 'beforeunload' , leavePresence ) ;
26852886
26862887 const dropEvents = [ "dragenter" , "dragover" , "dragleave" , "drop" ] ;
26872888
0 commit comments