fix autoscroll

add settings window
This commit is contained in:
koplenov 2026-05-07 20:13:06 +03:00
parent 70a71d4212
commit 17913397e6
3 changed files with 483 additions and 6 deletions

View file

@ -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;

View file

@ -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

View file

@ -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<string, { hue: number; label: string }> {
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 <input type="color"> 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 => {