fix autoscroll
add settings window
This commit is contained in:
parent
70a71d4212
commit
17913397e6
3 changed files with 483 additions and 6 deletions
183
xmpp.view.css
183
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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
207
xmpp.view.ts
207
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<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 => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue