Skip to content

Commit 226f32e

Browse files
feat: add shared-session avatars and participant overflow menu
Co-authored-by: ThisIs-Developer <109382325+ThisIs-Developer@users.noreply.github.com> Agent-Logs-Url: https://github.com/ThisIs-Developer/Markdown-Viewer/sessions/c9f0ce95-5f5b-495d-be1f-1df388f75c8e
1 parent 38b6993 commit 226f32e

4 files changed

Lines changed: 282 additions & 0 deletions

File tree

index.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ <h1 class="h4 mb-0 me-2">Markdown Viewer</h1>
128128
<button id="share-button" class="tool-button" title="Share via URL">
129129
<i class="bi bi-share"></i> Share
130130
</button>
131+
<div id="share-presence" class="share-presence" aria-label="Shared session participants" style="display:none;"></div>
131132

132133
<button id="theme-toggle" class="tool-button" title="Toggle Dark Mode">
133134
<i class="bi bi-moon"></i>
@@ -224,6 +225,7 @@ <h5>Menu</h5>
224225
<button id="mobile-share-button" class="mobile-menu-item" title="Share via URL">
225226
<i class="bi bi-share me-2"></i> Share
226227
</button>
228+
<div id="mobile-share-presence" class="share-presence share-presence-mobile" aria-label="Shared session participants" style="display:none;"></div>
227229

228230
<button id="mobile-theme-toggle" class="mobile-menu-item" title="Toggle Dark Mode">
229231
<i class="bi bi-moon me-2"></i> Dark Mode

script.js

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

styles.css

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,79 @@ body {
302302
font-size: 16px;
303303
}
304304

305+
.share-presence {
306+
display: inline-flex;
307+
align-items: center;
308+
gap: 4px;
309+
}
310+
311+
.share-presence-toggle {
312+
color: var(--text-color);
313+
border-color: var(--border-color);
314+
background: var(--button-bg);
315+
}
316+
317+
.share-presence-toggle:hover,
318+
.share-presence-toggle:focus {
319+
background: var(--button-hover);
320+
color: var(--text-color);
321+
border-color: var(--border-color);
322+
}
323+
324+
.share-presence-avatar {
325+
width: 28px;
326+
height: 28px;
327+
border-radius: 50%;
328+
border: 2px solid var(--bg-color);
329+
margin-left: -8px;
330+
background: linear-gradient(135deg, #58a6ff, #1f6feb);
331+
color: #ffffff;
332+
display: inline-flex;
333+
align-items: center;
334+
justify-content: center;
335+
font-size: 14px;
336+
line-height: 1;
337+
position: relative;
338+
}
339+
340+
.share-presence-avatar:first-child {
341+
margin-left: 0;
342+
}
343+
344+
.share-presence-avatar.self {
345+
border-color: #2ea043;
346+
box-shadow: 0 0 0 1px #2ea043;
347+
}
348+
349+
.share-presence-more-list {
350+
min-width: 220px;
351+
max-width: 320px;
352+
max-height: 220px;
353+
overflow-y: auto;
354+
}
355+
356+
.share-presence-item {
357+
display: flex;
358+
align-items: center;
359+
gap: 8px;
360+
}
361+
362+
.share-presence-item .avatar-inline {
363+
width: 20px;
364+
height: 20px;
365+
border-radius: 50%;
366+
display: inline-flex;
367+
align-items: center;
368+
justify-content: center;
369+
font-size: 12px;
370+
color: #fff;
371+
flex-shrink: 0;
372+
}
373+
374+
.content-container.shared-active {
375+
box-shadow: inset 0 0 0 2px #2ea043;
376+
}
377+
305378
.file-input {
306379
display: none;
307380
}
@@ -782,6 +855,10 @@ a:focus {
782855
background-color: var(--button-active);
783856
}
784857

858+
.share-presence-mobile {
859+
margin-top: 0.25rem;
860+
}
861+
785862
/* close button override */
786863
#close-mobile-menu.tool-button {
787864
padding: 0.25rem 0.5rem;

wiki/Features.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,8 @@ The **Share** feature encodes your Markdown content into the page URL hash, allo
183183

184184
Recipients open the link and see your document pre-loaded in the editor. No server or sign-in required.
185185

186+
When a shared `#share=` URL is open, the app also shows lightweight local **presence avatars** (animal icons) for people currently using that same share link in their browser on the same device profile. Each user gets a generated name in the `<Adjective> <Noun>` format, active users are highlighted with a green shared-session frame, and if more than 4 users are present, extras are grouped under a hover dropdown (`+N`).
187+
186188
---
187189

188190
## Content Statistics

0 commit comments

Comments
 (0)