chat / public /js /app.js
incognitolm
Mobile UI
83057f7
Raw History Blame
18.8 kB
// app.js β€” bootstrap, input handling, sidebar, attach, paste
import { send, on } from './ws.js';
import { isAuthenticated, logout, getTempId, getClientId } from './auth.js';
import {
createNewSession, showWelcomeScreen,
switchSession, currentSessionId, onSessionChange,
} from './sessions.js';
import { submitMessage, renderSession, setActiveSession, getIsStreaming } from './chat.js';
import { openAuthModal, closeModal, openPasteEditor } from './modals.js';
import { openSettings, applyTheme } from './settings.js';
import { showNotification, autoResize, escHtml } from './ui.js';
// ── TOS banner β€” push content up so input isn't obscured ─────────────────
(function fixTosBanner() {
const banner = document.getElementById('tos-banner');
if (!banner) return;
const applyPadding = () => {
const isMobile = window.innerWidth <= 768;
if (isMobile) {
// On mobile: TOS is fixed at bottom, bottom-input-bar padding is set via CSS
// Just ensure welcome-view accounts for TOS height
const h = banner.offsetHeight || 36;
const welcomeView = document.getElementById('welcome-view');
if (welcomeView) welcomeView.style.paddingBottom = `${h + 8}px`;
} else {
const h = banner.offsetHeight || 42;
document.getElementById('bottom-input-bar')?.style.setProperty('padding-bottom', `${h + 8}px`);
const welcomeView = document.getElementById('welcome-view');
if (welcomeView) welcomeView.style.paddingBottom = `${h + 16}px`;
}
};
requestAnimationFrame(() => { applyPadding(); });
window.addEventListener('resize', applyPadding);
})();
// ── Sidebar ───────────────────────────────────────────────────────────────
const sidebar = document.getElementById('sidebar');
const toggleBtn = document.getElementById('toggle-sidebar-btn');
function expandSidebar() { sidebar?.classList.remove('collapsed'); sidebar?.classList.add('expanded'); }
function collapseSidebar(){ sidebar?.classList.remove('expanded'); sidebar?.classList.add('collapsed'); }
function toggleSidebar() { sidebar?.classList.contains('expanded') ? collapseSidebar() : expandSidebar(); }
toggleBtn?.addEventListener('click', toggleSidebar);
// ── Mobile top bar + sidebar logic ───────────────────────────────────────
(function setupMobile() {
// Create backdrop
const backdrop = document.createElement('div');
backdrop.id = 'sidebar-backdrop';
backdrop.className = 'sidebar-backdrop';
document.body.appendChild(backdrop);
function openSidebar() { sidebar?.classList.remove('collapsed'); sidebar?.classList.add('expanded'); backdrop.classList.add('visible'); }
function closeSidebar() { sidebar?.classList.remove('expanded'); sidebar?.classList.add('collapsed'); backdrop.classList.remove('visible'); }
backdrop.addEventListener('click', closeSidebar);
// Wire mobile top bar buttons
document.getElementById('mobile-sidebar-btn')?.addEventListener('click', () => {
sidebar?.classList.contains('expanded') ? closeSidebar() : openSidebar();
});
document.getElementById('mobile-newchat-btn')?.addEventListener('click', () => {
showWelcomeScreen();
const ci = document.getElementById('center-input');
if (ci) { ci.value = ''; autoResize(ci, 6); }
const box = document.getElementById('chat-messages');
if (box) box.innerHTML = '';
});
// Auto-close drawer when a session is switched on mobile
onSessionChange((event) => {
if ((event === 'switched' || event === 'created') && window.innerWidth <= 768) {
closeSidebar();
}
});
// ── Hide/show top bar on scroll ─────────────────────────────────────────
let lastScrollY = 0;
let ticking = false;
const topBar = document.getElementById('mobile-top-bar');
function handleChatScroll(e) {
if (window.innerWidth > 768) return;
const el = e.target;
const currentY = el.scrollTop;
if (!ticking) {
requestAnimationFrame(() => {
if (currentY > lastScrollY && currentY > 60) {
// Scrolling down β€” hide bar
topBar?.classList.add('hidden-bar');
} else {
// Scrolling up β€” show bar
topBar?.classList.remove('hidden-bar');
}
lastScrollY = currentY <= 0 ? 0 : currentY;
ticking = false;
});
ticking = true;
}
}
// Attach to chat-view scroll
document.getElementById('chat-view')?.addEventListener('scroll', handleChatScroll, { passive: true });
// Always show top bar when on welcome screen
onSessionChange((event, data) => {
if (event === 'switched' && !data) {
topBar?.classList.remove('hidden-bar');
lastScrollY = 0;
}
});
})();
// ── New chat β€” just show the welcome screen; don't create a session yet ───
document.getElementById('new-chat-btn')?.addEventListener('click', () => {
showWelcomeScreen();
if (window.innerWidth < 768) { /* handled by setupMobile onSessionChange */ }
const ci = document.getElementById('center-input');
if (ci) { ci.value = ''; autoResize(ci, 6); }
const box = document.getElementById('chat-messages');
if (box) box.innerHTML = '';
});
// ── Session switching ─────────────────────────────────────────────────────
onSessionChange((event, data) => {
if (event === 'switched') {
setActiveSession(data);
if (!data) {
// Clear chat when going to welcome
const box = document.getElementById('chat-messages');
if (box) box.innerHTML = '';
document.getElementById('welcome-view')?.classList.remove('hidden');
document.getElementById('chat-view')?.classList.add('hidden');
document.getElementById('bottom-input-bar')?.classList.add('hidden');
}
}
if (event === 'data') {
if (data.id === currentSessionId) renderSession(data);
}
if (event === 'created') {
setActiveSession(data.id);
switchSession(data.id);
}
});
// ── Center input (welcome view) ───────────────────────────────────────────
const centerInput = document.getElementById('center-input');
const centerSendBtn = document.getElementById('center-send-btn');
centerInput?.addEventListener('input', () => autoResize(centerInput, 6));
centerInput?.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); triggerCenterSend(); }
});
centerSendBtn?.addEventListener('click', triggerCenterSend);
// Center tool buttons
document.querySelectorAll('#center-tool-search, #center-tool-image, #center-tool-video, #center-tool-audio')
.forEach(btn => btn.addEventListener('click', () => btn.classList.toggle('active')));
function triggerCenterSend() {
const text = centerInput?.value.trim();
const attachments = pendingAttachments.splice(0);
if (!text && attachments.length === 0) return;
if (centerInput) { centerInput.value = ''; autoResize(centerInput, 6); }
clearFilePreviewRow();
const pendingText = text, pendingAttach = attachments;
const unsub = onSessionChange((ev, s) => {
if (ev !== 'switched' || !s) return;
unsub();
doSend(pendingText, pendingAttach);
});
createNewSession();
}
// ── Bottom input (active chat) ────────────────────────────────────────────
const bottomInput = document.getElementById('bottom-input');
const bottomSendBtn = document.getElementById('bottom-send-btn');
bottomInput?.addEventListener('input', () => autoResize(bottomInput, 6));
bottomInput?.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); triggerBottomSend(); }
});
bottomSendBtn?.addEventListener('click', () => {
if (getIsStreaming()) { send({ type: 'chat:stop' }); return; }
triggerBottomSend();
});
function triggerBottomSend() {
const text = bottomInput?.value.trim();
const attachments = pendingAttachments.splice(0);
if (!text && attachments.length === 0) return;
if (bottomInput) { bottomInput.value = ''; autoResize(bottomInput, 6); }
clearFilePreviewRow();
doSend(text || '', attachments);
}
document.querySelectorAll('.tool-btn-sm').forEach(btn =>
btn.addEventListener('click', () => btn.classList.toggle('active')));
// ── Core send ─────────────────────────────────────────────────────────────
function doSend(text, attachments = []) {
submitMessage(text, attachments);
}
// ── Attachments ───────────────────────────────────────────────────────────
let pendingAttachments = [];
const LARGE_PASTE_THRESHOLD = 10000;
function openAttachMenu(e, triggerEl) {
e.preventDefault(); e.stopPropagation();
const menu = document.getElementById('attach-context-menu');
if (!menu) return;
menu.innerHTML = '';
for (const item of [
{ label: 'πŸ“„ Upload file', onClick: () => document.getElementById('file-input')?.click() },
{ label: 'πŸ–ΌοΈ Upload image', onClick: () => document.getElementById('image-input')?.click() },
]) {
const el = document.createElement('div');
el.className = 'context-item'; el.textContent = item.label;
el.addEventListener('click', () => { menu.classList.add('hidden'); item.onClick(); });
menu.appendChild(el);
}
menu.classList.remove('hidden');
const rect = triggerEl.getBoundingClientRect();
const mh = menu.getBoundingClientRect().height || 80;
menu.style.left = `${Math.max(8, rect.left)}px`;
menu.style.top = `${rect.top - mh - 8}px`;
setTimeout(() => document.addEventListener('click', () => menu.classList.add('hidden'), { once: true }), 0);
}
document.getElementById('center-attach-btn')?.addEventListener('click', e =>
openAttachMenu(e, document.getElementById('center-attach-btn')));
document.getElementById('bottom-attach-btn')?.addEventListener('click', e =>
openAttachMenu(e, document.getElementById('bottom-attach-btn')));
document.getElementById('file-input')?.addEventListener('change', async function() {
for (const file of this.files) {
const text = await file.text();
pendingAttachments.push({ type: 'text', name: file.name, content: text });
}
this.value = '';
renderFilePreviewRow();
});
document.getElementById('image-input')?.addEventListener('change', async function() {
for (const file of this.files) await addImageFile(file);
this.value = '';
});
async function addImageFile(file) {
const dataUrl = await new Promise((res, rej) => {
const reader = new FileReader();
reader.onload = () => res(reader.result);
reader.onerror = rej;
reader.readAsDataURL(file);
});
const comma = dataUrl.indexOf(',');
const mimeType = dataUrl.slice(5, dataUrl.indexOf(';'));
const base64 = dataUrl.slice(comma + 1);
pendingAttachments.push({ type: 'image', name: file.name, base64, mimeType });
renderFilePreviewRow();
}
function buildAttachmentItem(a, i) {
const wrap = document.createElement('div');
wrap.className = 'attach-preview-item';
if (a.type === 'image') {
const img = document.createElement('img');
img.src = `data:${a.mimeType};base64,${a.base64}`;
img.alt = a.name;
wrap.appendChild(img);
} else {
const chip = document.createElement('div');
chip.className = 'pasted-chip';
const icon = document.createElement('span'); icon.textContent = 'πŸ“„';
const name = document.createElement('span'); name.textContent = a.name;
chip.appendChild(icon); chip.appendChild(name);
chip.addEventListener('click', () => openPasteEditor(a.content, nc => { pendingAttachments[i].content = nc; }));
wrap.appendChild(chip);
}
const rm = document.createElement('button');
rm.className = 'attach-preview-remove';
rm.textContent = 'Γ—';
rm.addEventListener('click', e => { e.stopPropagation(); pendingAttachments.splice(i, 1); renderFilePreviewRow(); });
wrap.appendChild(rm);
return wrap;
}
function renderFilePreviewRow() {
const centerRow = document.getElementById('center-file-preview-row');
const bottomRow = document.getElementById('file-preview-row');
[centerRow, bottomRow].forEach(row => {
if (!row) return;
row.innerHTML = '';
if (pendingAttachments.length === 0) { row.style.display = 'none'; return; }
row.style.display = 'flex';
pendingAttachments.forEach((a, i) => row.appendChild(buildAttachmentItem(a, i)));
});
}
function clearFilePreviewRow() {
pendingAttachments = [];
['center-file-preview-row', 'file-preview-row'].forEach(id => {
const row = document.getElementById(id);
if (row) { row.innerHTML = ''; row.style.display = 'none'; }
});
}
// ── Paste & drag-drop ─────────────────────────────────────────────────────
function handlePaste(e) {
const items = Array.from(e.clipboardData?.items || []);
const imgItem = items.find(i => i.kind === 'file' && i.type.startsWith('image/'));
if (imgItem) { e.preventDefault(); addImageFile(imgItem.getAsFile()); return; }
const textItem = items.find(i => i.kind === 'string' && i.type === 'text/plain');
if (textItem) {
textItem.getAsString(text => {
if (text.length > LARGE_PASTE_THRESHOLD) {
e.preventDefault();
pendingAttachments.push({ type: 'text', name: `pasted-${Date.now()}.txt`, content: text });
renderFilePreviewRow();
}
});
}
}
[centerInput, bottomInput].forEach(input => {
input?.addEventListener('paste', handlePaste);
input?.addEventListener('dragover', e => e.preventDefault());
input?.addEventListener('drop', async e => {
e.preventDefault();
for (const file of e.dataTransfer.files)
if (file.type.startsWith('image/')) await addImageFile(file);
});
});
// ── Auth/settings buttons ─────────────────────────────────────────────────
document.getElementById('signin-btn')?.addEventListener('click', () => openAuthModal('signin'));
document.getElementById('signin-icon-btn')?.addEventListener('click', () => openAuthModal('signin'));
document.getElementById('settings-btn')?.addEventListener('click', () => openSettings('chat'));
document.getElementById('settings-btn-guest')?.addEventListener('click', () => openSettings('chat'));
document.getElementById('user-profile-btn')?.addEventListener('click', e => {
const btn = e.currentTarget;
const rect = btn.getBoundingClientRect();
const menu = document.getElementById('user-context-menu');
if (!menu) return;
const menuItems = [
{ label: 'βš™οΈ Settings', onClick: () => openSettings('chat') },
{ label: 'πŸ‘€ Account', onClick: () => openSettings('account') },
{ label: 'πŸ’³ Billing Portal', onClick: () => window.open('https://sharktide-lightning.hf.space/portal', '_blank') },
{ sep: true },
{ label: 'πŸ—‘οΈ Clear All Chats', danger: true,
onClick: () => { if (confirm('Delete all chats? This cannot be undone.')) send({ type: 'sessions:deleteAll' }); }},
{ sep: true },
{ label: 'πŸšͺ Sign Out', danger: true, onClick: () => logout() },
];
menu.innerHTML = '';
for (const item of menuItems) {
if (item.sep) {
const s = document.createElement('div'); s.style.cssText = 'height:1px;background:var(--border);margin:3px 0;';
menu.appendChild(s); continue;
}
const el = document.createElement('div');
el.className = 'context-item' + (item.danger ? ' danger' : '');
el.textContent = item.label;
el.addEventListener('click', () => { menu.classList.add('hidden'); item.onClick(); });
menu.appendChild(el);
}
menu.classList.remove('hidden');
const mw = 200;
let left = rect.left, top = rect.top;
const mh = menu.scrollHeight || 200;
top = rect.top - mh - 8;
if (left + mw > window.innerWidth - 8) left = window.innerWidth - mw - 8;
menu.style.left = `${Math.max(8, left)}px`;
menu.style.top = `${Math.max(8, top)}px`;
setTimeout(() => document.addEventListener('click', () => menu.classList.add('hidden'), { once: true }), 0);
});
// ── Share import from URL ?share=token ────────────────────────────────────
function checkShareParam() {
const params = new URLSearchParams(location.search);
const token = params.get('share');
if (!token) return;
const banner = document.getElementById('share-import-banner');
const bannerText= document.getElementById('share-banner-text');
const importBtn = document.getElementById('share-import-btn');
const dismissBtn= document.getElementById('share-dismiss-btn');
fetch(`/api/share/${encodeURIComponent(token)}`)
.then(r => r.ok ? r.json() : null)
.then(data => {
if (!data) return;
if (bannerText) bannerText.textContent = `Import shared chat: "${data.name}"?`;
banner?.classList.remove('hidden');
})
.catch(() => {});
importBtn?.addEventListener('click', () => {
if (!isAuthenticated()) { openAuthModal('signin'); return; }
send({ type: 'sessions:import', token });
banner?.classList.add('hidden');
history.replaceState({}, '', '/');
});
dismissBtn?.addEventListener('click', () => {
banner?.classList.add('hidden');
history.replaceState({}, '', '/');
});
}
// ── Connection notifications & reconnect handling ─────────────────────────
let wasDisconnected = false;
on('ws:disconnected', () => {
wasDisconnected = true;
showNotification({ type: 'warning', message: 'Connection lost β€” reconnecting…', duration: 3000 });
});
on('ws:connected', () => {
if (wasDisconnected) {
showNotification({ type: 'success', message: 'Reconnected', duration: 2000 });
}
wasDisconnected = false;
});
// ── Init ──────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
checkShareParam();
applyTheme('dark'); // default until auth resolves
});