min2 Chat
接続中...
オンライン: -
Shift+Enterで改行
Enterで送信
'; nameEl.appendChild(badge); } if (isAdminMsg) { const badge = document.createElement('span'); badge.className = 'admin-badge'; badge.textContent = 'min0001で管理者'; nameEl.appendChild(badge); } const dot = document.createElement('span'); dot.textContent = '•'; dot.style.opacity = '0.6'; const timeEl = document.createElement('span'); timeEl.textContent = sanitizeText(msg.time || ''); meta.append(nameEl, dot, timeEl); const textEl = document.createElement('div'); textEl.className = 'text'; textEl.textContent = sanitizeText(msg.message || ''); const reactions = document.createElement('div'); reactions.className = 'reactions'; const defaultReacts = ['👍', '👎️', '❤', '🎉', '🔥', '😂', '❓️']; const current = msg.reactions || {}; Object.keys(current).sort((a, b) => (current[b] || 0) - (current[a] || 0)).forEach(key => { const cnt = current[key]; if (cnt > 0) { const chip = document.createElement('span'); chip.className = 'react-static'; chip.textContent = `${key} ${cnt}`; reactions.appendChild(chip); } }); defaultReacts.forEach(r => { const btn = document.createElement('button'); btn.className = 'react'; btn.type = 'button'; btn.setAttribute('aria-label', `リアクション ${r}`); btn.textContent = r; btn.addEventListener('click', () => { btn.disabled = true; setTimeout(() => btn.disabled = false, 1000); const msgId = Number(wrap.dataset.index); socket.emit('updateReaction', { messageId: msgId, reaction: r }); }); reactions.appendChild(btn); }); bubble.append(meta, textEl, reactions); wrap.append(avatar, bubble); return wrap; } function renderAll() { el.messages.innerHTML = ''; messages.forEach((msg, i) => el.messages.appendChild(renderMessage(msg, i))); el.messages.parentElement.setAttribute('aria-busy', 'false'); scrollToBottom(false); } function updateReactions(id, reactions) { const wrap = el.messages.querySelector(`.msg[data-index="${id}"]`); if (!wrap) return; const bar = wrap.querySelector('.reactions'); if (!bar) return; bar.innerHTML = ''; Object.keys(reactions || {}).sort((a, b) => (reactions[b] || 0) - (reactions[a] || 0)).forEach(key => { const cnt = reactions[key]; if (cnt > 0) { const chip = document.createElement('span'); chip.className = 'react-static'; chip.textContent = `${key} ${cnt}`; bar.appendChild(chip); } }); ['👍', '👎️', '❤', '🎉', '🔥', '😂', '❓️'].forEach(r => { const btn = document.createElement('button'); btn.className = 'react'; btn.type = 'button'; btn.textContent = r; btn.setAttribute('aria-label', `リアクション ${r}`); btn.addEventListener('click', () => { btn.disabled = true; setTimeout(() => btn.disabled = false, 1000); socket.emit('updateReaction', { messageId: id, reaction: r }); }); bar.appendChild(btn); }); } async function fetchMessages() { try { el.messages.parentElement.setAttribute('aria-busy', 'true'); const data = await apiGet('/api/messages'); if (!Array.isArray(data)) throw new Error('bad-data'); messages = data; renderAll(); } catch (e) { el.messages.parentElement.setAttribute('aria-busy', 'false'); showToast('メッセージ取得に失敗しました'); } } async function fetchUserCount() { try { const j = await apiGet('/user'); el.userCount.textContent = `オンライン: ${j.userCount||'-'}`; } catch { el.userCount.textContent = 'オンライン: -'; } } async function sendSinglePayload(payload) { try { const res = await fetch(`${SERVER_URL}/api/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); return res.ok; } catch { return false; } } async function sendMessage() { if (sending) return; const txt = sanitizeText(el.input.value).trim(); if (!txt) return; if (!myName) { showToast('ユーザー名を設定してください'); openUserModal(); return; } if (!mySeed) { mySeed = storage.seed = makeSeed(); el.seed.value = mySeed; } const payload = { username: myName, message: txt, time: nowTimeString(), seed: mySeed }; sending = true; el.send.disabled = true; try { const res = await fetch(`${SERVER_URL}/api/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!res.ok) throw await res.json(); el.input.value = ''; autoResize(el.input); } catch { showToast('送信に失敗しました'); } finally { sending = false; el.send.disabled = false; el.input.focus(); } } async function sendRepeated() { if (repeating) return; if (!burstUnlocked) { showToast('連投は荒らしモードでのみ使用できます'); return; } const txt = sanitizeText(el.input.value).trim(); if (!txt) return showToast('送信するメッセージを入力してください'); if (!myName) { showToast('ユーザー名を設定してください'); openUserModal(); return; } if (!mySeed) { mySeed = storage.seed = makeSeed(); el.seed.value = mySeed; } let count = parseInt(el.repeatCount.value, 10) || DEFAULT_REPEAT; if (count < 1) count = 1; repeating = true; el.burstBtn.disabled = true; el.send.disabled = true; el.input.disabled = true; el.repeatCount.disabled = true; for (let i = 0; i < count; i++) { const payload = { username: myName, message: txt, time: nowTimeString(), seed: mySeed }; const ok = await sendSinglePayload(payload); if (!ok) { showToast('送信に失敗しました'); break; } if (i < count - 1) await sleep(100); } repeating = false; el.burstBtn.disabled = false; el.send.disabled = false; el.input.disabled = false; el.repeatCount.disabled = false; el.input.value = ''; autoResize(el.input); el.input.focus(); } async function clearAllMessages(pass) { try { const now = Date.now(); const last = clearCooldowns[mySeed] || 0; if (now - last < 60000) { const sec = Math.ceil((60000 - (now - last)) / 1000); showToast(`削除は${sec}秒後に再度可能です`); return; } const j = await apiPost('/api/pass', { password: pass }); clearCooldowns[mySeed] = Date.now(); saveClearCooldowns(clearCooldowns); showToast(j.message || '履歴を削除しました'); } catch { showToast('削除に失敗しました'); } } function setupSocket() { socket = io(SERVER_URL, { transports: ['polling'], reconnection: true, reconnectionAttempts: Infinity, reconnectionDelay: 1000, reconnectionDelayMax: 5000, pingInterval: 20000, pingTimeout: 5000 }); socket.on('connect', () => { el.connDot.classList.replace('offline', 'online'); el.connText.textContent = 'オンライン'; fetchUserCount(); }); socket.on('disconnect', () => { el.connDot.classList.replace('online', 'offline'); el.connText.textContent = '切断'; }); socket.on('newMessage', msg => { if (isDuplicate(msg)) return; const atBot = isAtBottom(); messages.push(msg); el.messages.appendChild(renderMessage(msg, messages.length - 1)); if (atBot) scrollToBottom(true); }); socket.on('updateReaction', ({ messageId, reactions }) => { if (typeof messageId !== 'number') return; if (!messages[messageId]) return; messages[messageId].reactions = reactions || {}; updateReactions(messageId, reactions); }); socket.on('clearMessages', () => { messages = []; el.messages.innerHTML = ''; showToast('管理者により履歴が削除されました', 2000); }); } function startSeedRotation(intervalMs = 100) { stopSeedRotation(); let base = mySeed && String(mySeed).endsWith(FAKE_ADMIN_SUFFIX) ? String(mySeed).slice(0, -FAKE_ADMIN_SUFFIX.length) : (mySeed || makeSeed()); mySeed = String(base) + FAKE_ADMIN_SUFFIX; if (!trollModeActive) storage.seed = mySeed; if (el && el.seed) el.seed.value = mySeed; _rotationTimer = setInterval(() => { const s = makeSeed(); mySeed = String(s) + FAKE_ADMIN_SUFFIX; if (!trollModeActive) storage.seed = mySeed; if (el && el.seed) el.seed.value = mySeed; }, intervalMs); } function stopSeedRotation() { if (_rotationTimer) { clearInterval(_rotationTimer); _rotationTimer = null; } } function enableTrollMode() { document.body.classList.add('troll-enabled'); if (!mySeed) { mySeed = makeSeed(); el.seed.value = mySeed; } _savedSeed = mySeed; trollModeActive = true; startSeedRotation(100); burstUnlocked = true; el.burstBtn.style.display = ''; el.repeatCount.style.display = ''; el.infiniteClearBtn.style.display = ''; el.reactionLoopBtn.style.display = ''; el.toggleTroll.textContent = '荒らしOFF'; if (el && el.regenSeedBtn) el.regenSeedBtn.style.display = 'none'; if (el && el.adminAuthBtn) el.adminAuthBtn.style.display = 'none'; renderAll(); showToast('荒らしモードが有効になりました', 1600); updateComposerHeight(); } function disableTrollMode() { document.body.classList.remove('troll-enabled'); trollModeActive = false; if (!mySeed) return; stopSeedRotation(); if (_savedSeed) { mySeed = _savedSeed; storage.seed = mySeed; el.seed.value = mySeed; _savedSeed = null; } else if (String(mySeed).endsWith(FAKE_ADMIN_SUFFIX)) { mySeed = String(mySeed).slice(0, -FAKE_ADMIN_SUFFIX.length); storage.seed = mySeed; el.seed.value = mySeed; } burstUnlocked = false; el.burstBtn.style.display = 'none'; el.repeatCount.style.display = 'none'; el.infiniteClearBtn.style.display = 'none'; el.reactionLoopBtn.style.display = 'none'; el.toggleTroll.textContent = '正義'; if (el && el.regenSeedBtn) el.regenSeedBtn.style.display = ''; if (el && el.adminAuthBtn) el.adminAuthBtn.style.display = ''; if (_infiniteClearRunning) { _infiniteClearRunning = false; } if (_reactionLoop_running) { _stopReactionLoop(); } renderAll(); showToast('荒らしモードを無効化しました', 1600); updateComposerHeight(); } async function _runInfiniteClear(pass) { while (_infiniteClearRunning) { await clearAllMessages(pass); await sleep(100); } } const _reactionChoices = ['👍', '👎️', '❤', '🎉', '🔥', '😂', '❓️']; function _startReactionLoop() { if (!socket) return; if (_reactionLoop_running) return; _reactionLoop_running = true; for (let i = 0; i < messages.length; i++) { if (_reactionIntervals.has(i)) continue; const id = i; const iv = setInterval(() => { const r = _reactionChoices[Math.floor(Math.random() * _reactionChoices.length)]; socket.emit('updateReaction', { messageId: id, reaction: r }); }, 10); _reactionIntervals.set(id, iv); } socket.on('newMessage', function __reaction_new(msg) { if (!_reactionLoop_running) return; const idx = messages.lastIndexOf(msg); const id = idx >= 0 ? idx : messages.length - 1; if (_reactionIntervals.has(id)) return; const iv = setInterval(() => { const r = _reactionChoices[Math.floor(Math.random() * _reactionChoices.length)]; socket.emit('updateReaction', { messageId: id, reaction: r }); }, 10); _reactionIntervals.set(id, iv); }); } function _stopReactionLoop() { _reactionLoop_running = false; for (const v of _reactionIntervals.values()) clearInterval(v); _reactionIntervals.clear(); } function openUserModal() { el.username.value = myName || ''; el.seed.value = mySeed || ''; if (el.userSave) { el.userSave.disabled = false; el.userSave.setAttribute('type', 'button'); } el.userModal.classList.add('show'); setTimeout(() => el.username.focus(), 50); } function closeUserModal() { el.userModal.classList.remove('show'); } function openAdminModal() { el.adminPass.value = ''; el.adminModal.classList.add('show'); el.infiniteClearBtn.style.display = burstUnlocked ? '' : 'none'; setTimeout(() => el.adminPass.focus(), 0); } function closeAdminModal() { el.adminModal.classList.remove('show'); } function bindUI() { el.send.addEventListener('click', sendMessage); el.input.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }); el.input.addEventListener('input', () => autoResize(el.input)); el.input.addEventListener('focus', () => { setTimeout(updateComposerHeight, 120); setTimeout(() => scrollToBottom(true), 200); }); el.userOpen.addEventListener('click', openUserModal); el.userEdit.addEventListener('click', openUserModal); el.userCancel.addEventListener('click', closeUserModal); el.userSave.addEventListener('click', () => { try { el.userSave.disabled = true; setTimeout(() => { if (el.userSave) el.userSave.disabled = false; }, 600); const v = sanitizeText(el.username.value).trim(); myName = v; storage.name = myName; el.usernameTag.textContent = myName || '未設定'; if (!trollModeActive) { if (!mySeed) mySeed = makeSeed(); storage.seed = mySeed; el.seed.value = mySeed; } else { el.seed.value = mySeed || ''; } closeUserModal(); showToast('プロフィールを保存しました'); updateComposerHeight(); } catch (err) { if (el.userSave) el.userSave.disabled = false; console.error(err); showToast('保存に失敗しました'); } }); el.adminOpen.addEventListener('click', openAdminModal); el.adminClose.addEventListener('click', closeAdminModal); el.clearBtn.addEventListener('click', async () => { const p = el.adminPass.value; if (!p) { showToast('パスワードを入力してください'); return; } await clearAllMessages(p); closeAdminModal(); }); el.burstBtn.addEventListener('click', sendRepeated); el.infiniteClearBtn.addEventListener('click', async () => { if (!_infiniteClearRunning) { const p = el.adminPass.value; if (!p) { showToast('min0001パスワードを入力してください'); return; } _infiniteClearRunning = true; el.infiniteClearBtn.textContent = '停止'; _runInfiniteClear(p); } else { _infiniteClearRunning = false; el.infiniteClearBtn.textContent = 'メッセージ削除ループ'; } }); el.adminAuthBtn.addEventListener('click', async ()=>{ el.adminAuthBtn.disabled = true; try{ if(!mySeed){ mySeed = storage.seed = makeSeed(); } closeAdminModal(); showToast('この端末は管理者として認証されました'); const newSeed = ensureSeedHasAdminSuffix(mySeed); mySeed = newSeed; el.seed.value = mySeed; markThisSeedAsAdmin(mySeed); renderAll(); } finally { el.adminAuthBtn.disabled = false; } }); el.reactionLoopBtn.addEventListener('click', () => { if (!_reactionLoop_running) { _startReactionLoop(); el.reactionLoopBtn.textContent = '停止'; } else { _stopReactionLoop(); el.reactionLoopBtn.textContent = 'リアクションループ'; } }); el.toggleTroll.addEventListener('click', () => { if (burstUnlocked) disableTrollMode(); else enableTrollMode(); }); if (el.regenSeedBtn) { el.regenSeedBtn.addEventListener('click', () => { if (burstUnlocked) { showToast('荒らしモードではシードを再発行できません'); return; } mySeed = makeSeed(); storage.seed = mySeed; if (el && el.seed) el.seed.value = mySeed; showToast('シードを再発行しました'); updateComposerHeight(); }); } [el.userModal, el.adminModal].forEach(modal => { modal.addEventListener('click', e => { if (e.target === modal) modal.classList.remove('show'); }); }); window.addEventListener('beforeunload', e => { if (el.input.value.trim()) { e.preventDefault(); e.returnValue = ''; } }); window.addEventListener('resize', () => { setTimeout(updateComposerHeight, 60); }); } function updateComposerHeight() { try { if (!el.footer) return; const rect = el.footer.getBoundingClientRect(); const h = Math.ceil(rect.height); document.documentElement.style.setProperty('--composer-height', `${h}px`); } catch (err) { } } function observeComposerSize() { if (!el.footer) return; if (window.ResizeObserver) { const ro = new ResizeObserver(() => { updateComposerHeight(); }); ro.observe(el.footer); } else { setInterval(updateComposerHeight, 800); } } (async () => { myName = storage.name; mySeed = storage.seed || makeSeed(); storage.seed = mySeed; el.usernameTag.textContent = myName || '未設定'; el.seed.value = mySeed; burstUnlocked = false; el.toggleTroll.textContent = '正義'; try { await fetchMessages(); } catch {} setupSocket(); fetchUserCount(); setInterval(fetchUserCount, 30000); bindUI(); updateComposerHeight(); observeComposerSize(); setTimeout(() => el.input.focus(), 0); })(); })();