From 17913397e6e9ea82532bb9da85b6448bb64ef147 Mon Sep 17 00:00:00 2001 From: koplenov Date: Thu, 7 May 2026 20:13:06 +0300 Subject: [PATCH] fix autoscroll add settings window --- xmpp.view.css | 183 +++++++++++++++++++++++++++++++++++++++++++ xmpp.view.tree | 99 ++++++++++++++++++++++- xmpp.view.ts | 207 ++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 483 insertions(+), 6 deletions(-) diff --git a/xmpp.view.css b/xmpp.view.css index f8ee0bf..125c33f 100644 --- a/xmpp.view.css +++ b/xmpp.view.css @@ -235,6 +235,7 @@ } [xmpp_Folders_pane] [xmpp_Disconnect_button], +[xmpp_Folders_pane] [xmpp_Settings_button], [xmpp_Folders_pane] [xmpp_Lights2] { min-height: 2rem; padding: .35rem; @@ -243,6 +244,154 @@ justify-content: center; } +/* ── Settings modal ────────────────────────────────────────────── */ + +[xmpp_Settings_modal] { + position: fixed; + inset: 0; + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; +} + +[xmpp_Settings_dim] { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.5); + cursor: pointer; +} + +[xmpp_Settings_dialog] { + position: relative; + width: min(560px, 90vw); + max-height: 90vh; + overflow-y: auto; + background: var(--mol_theme_back); + border: 1px solid var(--mol_theme_line); + border-radius: .75rem; + box-shadow: 0 8px 32px rgba(0, 0, 0, .25); + padding: 1rem 1.25rem; + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +[xmpp_Settings_header] { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--mol_theme_line); + padding-bottom: .5rem; +} + +[xmpp_Settings_title] { + font-size: 1.1rem; + font-weight: bold; +} + +[xmpp_Notif_section], +[xmpp_Sound_section], +[xmpp_Theme_section] { + display: flex; + flex-direction: column; + gap: .5rem; +} + +[xmpp_Notif_section_title], +[xmpp_Sound_section_title], +[xmpp_Theme_section_title] { + font-weight: bold; + font-size: .9rem; + opacity: .7; +} + +[xmpp_Notif_section] { + flex-direction: row; + flex-wrap: wrap; + gap: .5rem; + align-items: center; +} + +[xmpp_Notif_section_title] { + width: 100%; +} + +[xmpp_Notif_inapp_button], +[xmpp_Notif_system_button] { + padding: .5rem .9rem; + border-radius: .5rem; + border: 1px solid var(--mol_theme_line); +} + +[xmpp_Notif_inapp_button][xmpp_active="true"], +[xmpp_Notif_system_button][xmpp_active="true"] { + background: var(--mol_theme_focus); + color: var(--mol_theme_card); + border-color: var(--mol_theme_focus); +} + +[xmpp_Sound_section] { + flex-wrap: wrap; + flex-direction: row; + gap: .5rem; + align-items: center; +} + +[xmpp_Sound_section_title] { width: 100%; } + +[xmpp_Sound_status] { + width: 100%; + font-size: .85rem; + opacity: .7; +} + +[xmpp_Sound_pick_button], +[xmpp_Sound_test_button], +[xmpp_Sound_clear_button] { + padding: .4rem .7rem; + border-radius: .4rem; + border: 1px solid var(--mol_theme_line); +} + +[xmpp_Theme_presets] { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: .5rem; +} + +[xmpp_Theme_preset] { + padding: .5rem .9rem; + border-radius: .5rem; + border: 1px solid var(--mol_theme_line); +} + +[xmpp_Theme_preset][xmpp_active="true"] { + background: var(--mol_theme_focus); + color: var(--mol_theme_card); + border-color: var(--mol_theme_focus); +} + +[xmpp_Theme_custom] { + display: flex; + flex-direction: row; + align-items: center; + gap: .75rem; + padding-top: .5rem; + border-top: 1px solid var(--mol_theme_line); +} + +[xmpp_Theme_color_input] { + height: 2.5rem; + width: 4rem; + cursor: pointer; + padding: 0; + border: 1px solid var(--mol_theme_line); + border-radius: .4rem; +} + [xmpp_Roster_list] { flex: 1; overflow-y: auto; @@ -288,12 +437,46 @@ margin-right: 0; } +[xmpp_Messages_area] { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + position: relative; +} + [xmpp_Messages_list] { flex: 1; overflow-y: auto; min-height: 0; } +[xmpp_Scroll_down_button] { + position: absolute; + right: 1rem; + bottom: 1rem; + width: 2.5rem; + height: 2.5rem; + min-width: 2.5rem; + border-radius: 50%; + background: var(--mol_theme_card); + border: 1px solid var(--mol_theme_line); + box-shadow: 0 2px 8px rgba(0, 0, 0, .15); + display: flex; + align-items: center; + justify-content: center; + padding: 0; + z-index: 10; +} + +[xmpp_Scroll_down_button]:hover { + background: var(--mol_theme_hover, var(--mol_theme_card)); +} + +[xmpp_Scroll_down_button][hidden] { + display: none; +} + [xmpp_Compose_pane] { display: flex; flex-direction: row; diff --git a/xmpp.view.tree b/xmpp.view.tree index 2284665..d813998 100644 --- a/xmpp.view.tree +++ b/xmpp.view.tree @@ -71,6 +71,11 @@ $xmpp $mol_view sub / <= new_folder_text @ \+ Drop chat <= Folders_spacer $mol_view + <= Settings_button $mol_button_minor + hint @ \Settings + click? <=> do_open_settings? null + sub / + <= Settings_icon $mol_icon_cog <= Lights2 $mol_lights_toggle <= Disconnect_button $mol_button_minor hint <= disconnect_label @ \Disconnect @@ -107,6 +112,86 @@ $xmpp $mol_view title <= search_action_room_title \ click? <=> do_search_room? null - + Settings_modal $mol_view + sub / + <= Settings_dim $mol_view + event * + ^ + click? <=> do_close_settings? null + <= Settings_dialog $mol_view + sub / + <= Settings_header $mol_view + sub / + <= Settings_title $mol_view + sub / + <= settings_title @ \Settings + <= Settings_close $mol_button_minor + click? <=> do_close_settings? null + sub / + <= Settings_close_icon $mol_icon_close + <= Notif_section $mol_view + sub / + <= Notif_section_title $mol_view + sub / + <= notif_section_title @ \Notifications + <= Notif_inapp_button $mol_button_minor + click? <=> do_set_notif_inapp? null + attr * + ^ + xmpp_active <= notif_inapp_active false + sub / + <= notif_inapp_label @ \In-app toast + <= Notif_system_button $mol_button_minor + click? <=> do_set_notif_system? null + attr * + ^ + xmpp_active <= notif_system_active false + sub / + <= notif_system_label @ \System notification + <= Sound_section $mol_view + sub / + <= Sound_section_title $mol_view + sub / + <= sound_section_title @ \Notification sound + <= Sound_status $mol_view + sub / + <= sound_status_text \ + <= Sound_pick_button $mol_button_minor + click? <=> do_pick_sound? null + sub / + <= sound_pick_label @ \Choose sound file + <= Sound_test_button $mol_button_minor + click? <=> do_test_sound? null + sub / + <= sound_test_label @ \Test + <= Sound_clear_button $mol_button_minor + click? <=> do_clear_sound? null + sub / + <= sound_clear_label @ \Reset to default + <= Theme_section $mol_view + sub / + <= Theme_section_title $mol_view + sub / + <= theme_section_title @ \Theme + <= Theme_presets $mol_view + sub <= theme_preset_views / + <= Theme_custom $mol_view + sub / + <= Theme_color_label $mol_view + sub / + <= theme_color_label @ \Custom hue + <= Theme_color_input $mol_string + type \color + value? <=> theme_color? \ + - + Theme_preset* $mol_button_minor + click? <=> do_apply_preset*? null + attr * + ^ + xmpp_active <= theme_preset_active* false + sub / + <= theme_preset_label* \ + - Roster_static_header $mol_view sub / <= roster_static_text \ @@ -146,8 +231,18 @@ $xmpp $mol_view attr * ^ hidden <= chat_leave_hidden* \ - <= Messages_list* $mol_list - rows <= message_rows* / + <= Messages_area* $mol_view + sub / + <= Messages_list* $mol_list + rows <= message_rows* / + <= Scroll_down_button* $mol_button_minor + hint @ \Scroll to bottom + click? <=> do_scroll_down*? null + attr * + ^ + hidden <= scroll_down_hidden* \ + sub / + <= Scroll_down_icon* $mol_icon_chevron_double_down <= Compose_pane* $mol_view sub / <= Compose_input* $mol_string diff --git a/xmpp.view.ts b/xmpp.view.ts index f2b20f2..c878787 100644 --- a/xmpp.view.ts +++ b/xmpp.view.ts @@ -744,11 +744,165 @@ private _handle_message(el: Element) { // ── Panes (Telegram-like 3-column layout) ───────────────────────────── panes() { + this._apply_theme() if (this.status() !== 'connected') { this._maybe_auto_connect() return [this.Login_pane()] } - return [this.Folders_pane(), this.Roster_pane(), this.Chat_pane()] + const list: $mol_view[] = [this.Folders_pane(), this.Roster_pane(), this.Chat_pane()] + if (this.settings_open()) list.push(this.Settings_modal()) + return list + } + + @ $mol_mem + settings_open(next?: boolean) { return next ?? false } + + do_open_settings() { this.settings_open(true) } + do_close_settings() { this.settings_open(false) } + + // Per-chat reactive flag — true when scroll is near the bottom of Messages_list. + @ $mol_mem_key + scroll_at_bottom(_jid: string, next?: boolean) { return next ?? true } + + scroll_down_hidden(jid: string): string { + return this.scroll_at_bottom(jid) ? 'hidden' : '' + } + + do_scroll_down(jid: string) { + this._scroll_to_bottom(jid) + } + + // ── Notifications mode ──────────────────────────────────────────────── + + notif_mode(next?: 'inapp' | 'system') { + return (this.$.$mol_state_local.value('xmpp_notif_mode', next) as 'inapp' | 'system' | null) ?? 'inapp' + } + + notif_inapp_active() { return this.notif_mode() === 'inapp' } + notif_system_active() { return this.notif_mode() === 'system' } + + do_set_notif_inapp() { this.notif_mode('inapp') } + do_set_notif_system() { + this.notif_mode('system') + if (typeof Notification !== 'undefined' && Notification.permission === 'default') { + void Notification.requestPermission() + } + } + + // ── Notification sound ──────────────────────────────────────────────── + + notif_sound_uri(next?: string | null) { + return (this.$.$mol_state_local.value('xmpp_notif_sound', next) as string | null) ?? '' + } + + sound_status_text() { + return this.notif_sound_uri() ? '✓ Custom sound loaded' : 'Default beep' + } + + do_pick_sound() { + const input = document.createElement('input') + input.type = 'file' + input.accept = 'audio/*' + input.onchange = () => { + const file = input.files?.[0] + if (!file) return + if (file.size > 1_000_000) { + this.error_text('Sound file too large (max 1 MB)') + return + } + const reader = new FileReader() + reader.onload = () => { + this.notif_sound_uri(String(reader.result)) + } + reader.readAsDataURL(file) + } + input.click() + } + + do_test_sound() { this._beep() } + + do_clear_sound() { this.notif_sound_uri(null) } + + // ── Theme ───────────────────────────────────────────────────────────── + + theme_preset(next?: string | null) { + return (this.$.$mol_state_local.value('xmpp_theme_preset', next) as string | null) ?? 'default' + } + + theme_hue(next?: number | null) { + return (this.$.$mol_state_local.value('xmpp_theme_hue', next) as number | null) ?? 240 + } + + // Built-in presets keyed by hue (deg). + private _theme_presets(): Record { + return { + default: { hue: 240, label: 'Default' }, + ocean: { hue: 200, label: 'Ocean' }, + forest: { hue: 130, label: 'Forest' }, + sunset: { hue: 20, label: 'Sunset' }, + cherry: { hue: 350, label: 'Cherry' }, + violet: { hue: 290, label: 'Violet' }, + } + } + + theme_preset_views() { + const presets = this._theme_presets() + return Object.keys(presets).map(name => this.Theme_preset(name)) + } + + theme_preset_label(name: string) { return this._theme_presets()[name]?.label ?? name } + theme_preset_active(name: string) { return this.theme_preset() === name } + + do_apply_preset(name: string) { + this.theme_preset(name) + const p = this._theme_presets()[name] + if (p) this.theme_hue(p.hue) + } + + // Bridge between HSL hue and the hex value. + theme_color(next?: string) { + if (next === undefined) { + return this._hsl_to_hex(this.theme_hue(), 60, 50) + } + const hue = this._hex_to_hue(next) + this.theme_preset('custom') + this.theme_hue(hue) + return next + } + + private _hsl_to_hex(h: number, s: number, l: number): string { + s /= 100; l /= 100 + const k = (n: number) => (n + h / 30) % 12 + const a = s * Math.min(l, 1 - l) + const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1)) + const to = (x: number) => Math.round(x * 255).toString(16).padStart(2, '0') + return `#${ to(f(0)) }${ to(f(8)) }${ to(f(4)) }` + } + + private _hex_to_hue(hex: string): number { + const m = hex.match(/^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i) + if (!m) return 240 + const r = parseInt(m[1], 16) / 255 + const g = parseInt(m[2], 16) / 255 + const b = parseInt(m[3], 16) / 255 + const max = Math.max(r, g, b), min = Math.min(r, g, b) + if (max === min) return 0 + const d = max - min + let h = 0 + if (max === r) h = ((g - b) / d) % 6 + else if (max === g) h = (b - r) / d + 2 + else h = (r - g) / d + 4 + h = Math.round(h * 60) + if (h < 0) h += 360 + return h + } + + // Reactively apply theme variables to the document root. + @ $mol_mem + _apply_theme() { + if (typeof document === 'undefined') return + const hue = this.theme_hue() + document.documentElement.style.setProperty('--mol_theme_hue', `${ hue }deg`) } chat_pane_content() { @@ -1420,7 +1574,8 @@ private _handle_message(el: Element) { private _scroll_to_bottom(jid: string) { requestAnimationFrame(() => { try { - this.Chat_page(jid).Body().dom_node().scrollTop = 999999 + const el = this.Messages_list(jid).dom_node() + el.scrollTop = el.scrollHeight } catch {} }) } @@ -1494,10 +1649,39 @@ private _handle_message(el: Element) { const open_peer = this.$.$mol_state_arg.value('chat') const visible = typeof document !== 'undefined' && document.visibilityState === 'visible' if (open_peer === peer && visible) return - this._show_toast(peer, title, body) + if (this.notif_mode() === 'system') this._system_notify(title, body, peer) + else this._show_toast(peer, title, body) this._beep() } + private _system_notify(title: string, body: string, peer: string) { + if (typeof Notification === 'undefined') { + this._show_toast(peer, title, body) + return + } + if (Notification.permission === 'default') { + void Notification.requestPermission().then(p => { + if (p === 'granted') this._system_notify(title, body, peer) + else this._show_toast(peer, title, body) + }) + return + } + if (Notification.permission !== 'granted') { + this._show_toast(peer, title, body) + return + } + try { + const n = new Notification(title, { body, tag: peer, requireInteraction: true }) + n.onclick = () => { + try { window.focus() } catch {} + this.$.$mol_state_arg.value('chat', peer) + n.close() + } + } catch { + this._show_toast(peer, title, body) + } + } + private _ensure_toast_container(): HTMLDivElement { if (this._toast_container && this._toast_container.isConnected) return this._toast_container const c = document.createElement('div') @@ -1535,6 +1719,15 @@ private _handle_message(el: Element) { } private _beep() { + const custom = this.notif_sound_uri() + if (custom) { + try { + const audio = new Audio(custom) + audio.volume = 0.5 + void audio.play().catch(() => {}) + return + } catch {} + } try { const Ctor = (window as any).AudioContext || (window as any).webkitAudioContext if (!Ctor) return @@ -1574,9 +1767,15 @@ private _handle_message(el: Element) { this._scroll_setup.add(jid) requestAnimationFrame(() => { try { - const el = this.Chat_page(jid).Body().dom_node() as HTMLElement + const el = this.Messages_list(jid).dom_node() as HTMLElement + const update_at_bottom = () => { + const near = el.scrollHeight - el.scrollTop - el.clientHeight < 100 + this.scroll_at_bottom(jid, near) + } + update_at_bottom() el.addEventListener('scroll', () => { if (el.scrollTop < 200) this._load_more_history(jid) + update_at_bottom() }, { passive: true }) // Late-loading images push content; if user is near the bottom, follow. el.addEventListener('load', e => {