update interface, add folders support

This commit is contained in:
koplenov 2026-05-07 19:25:53 +03:00
parent 8c6e7a774d
commit 2355d6836d
3 changed files with 623 additions and 107 deletions

View file

@ -1,3 +1,310 @@
/* ── Three-column Telegram-like layout ────────────────────────────── */
[mol_view_root="$xmpp"] > [xmpp] {
display: flex;
flex-direction: row;
height: 100vh;
width: 100vw;
overflow: hidden;
}
[xmpp_Login_pane] {
flex: 1;
min-width: 0;
}
[xmpp_Folders_pane] {
width: 84px;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: stretch;
padding: .5rem;
gap: .25rem;
background: var(--mol_theme_field);
border-right: 1px solid var(--mol_theme_line);
overflow-y: auto;
}
[xmpp_Folders_pane] [xmpp_My_avatar] {
width: 56px;
height: 56px;
margin: 0 auto .5rem auto;
}
[xmpp_Folders_spacer] {
flex: 1;
}
[xmpp_Folder_all],
[xmpp_Folder_chats],
[xmpp_Folder_rooms] {
display: flex;
align-items: center;
justify-content: center;
padding: .5rem;
border-radius: .5rem;
font-size: .8rem;
color: inherit;
text-align: center;
}
[xmpp_Folder_all]:hover,
[xmpp_Folder_chats]:hover,
[xmpp_Folder_rooms]:hover,
[xmpp_User_folder_link]:hover {
background: var(--mol_theme_hover, rgba(0,0,0,.05));
}
[xmpp_Folder_all][mol_link_current="true"],
[xmpp_Folder_chats][mol_link_current="true"],
[xmpp_Folder_rooms][mol_link_current="true"],
[xmpp_User_folder_link][mol_link_current="true"] {
background: var(--mol_theme_focus, #4a9eff);
color: var(--mol_theme_card, #fff);
font-weight: bold;
}
[xmpp_User_folders] {
display: flex;
flex-direction: column;
gap: .15rem;
}
[xmpp_User_folder] {
display: flex;
flex-direction: row;
align-items: stretch;
gap: .15rem;
}
[xmpp_User_folder_drop] {
flex: 1;
min-width: 0;
}
[xmpp_User_folder_link] {
display: flex;
align-items: center;
justify-content: center;
padding: .4rem .25rem;
border-radius: .5rem;
font-size: .75rem;
color: inherit;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[xmpp_User_folder_delete] {
width: 1.4rem;
min-width: 1.4rem;
height: 1.4rem;
padding: 0;
font-size: .9rem;
opacity: .4;
align-self: center;
}
[xmpp_User_folder_delete]:hover {
opacity: 1;
}
[xmpp_New_folder_zone] {
margin-top: .25rem;
padding: .5rem .25rem;
border: 2px dashed var(--mol_theme_line, rgba(0,0,0,.2));
border-radius: .5rem;
font-size: .7rem;
opacity: .5;
text-align: center;
}
[xmpp_New_folder_zone][mol_drop_status="drag"],
[xmpp_User_folder_drop][mol_drop_status="drag"] [xmpp_User_folder_link] {
background: var(--mol_theme_focus, rgba(74,158,255,.15));
opacity: 1;
}
[xmpp_Roster_contact_link] {
display: flex;
flex-direction: row;
align-items: center;
gap: .5rem;
padding: .5rem .75rem;
color: inherit;
}
[xmpp_Roster_contact_link]:hover {
background: var(--mol_theme_hover, rgba(0,0,0,.05));
}
[xmpp_Roster_contact][mol_drag_status="drag"] [xmpp_Roster_contact_link] {
opacity: .4;
}
[xmpp_Roster_pane] {
width: 320px;
flex-shrink: 0;
display: flex;
flex-direction: column;
border-right: 1px solid var(--mol_theme_line);
overflow: hidden;
}
[xmpp_Roster_header] {
padding: .5rem 1rem;
border-bottom: 1px solid var(--mol_theme_line);
flex-shrink: 0;
}
[xmpp_Roster_static_header] {
font-weight: bold;
font-size: 1rem;
}
[xmpp_Roster_folder_header] {
display: flex;
flex-direction: row;
align-items: center;
gap: .5rem;
width: 100%;
}
[xmpp_Roster_folder_input] {
flex: 1;
min-width: 0;
font-weight: bold;
}
[xmpp_Roster_folder_delete] {
flex-shrink: 0;
font-size: .8rem;
opacity: .6;
}
[xmpp_Roster_folder_delete]:hover {
opacity: 1;
color: #d33;
}
[xmpp_Folders_pane] [xmpp_Disconnect_button],
[xmpp_Folders_pane] [xmpp_Set_avatar_button],
[xmpp_Folders_pane] [xmpp_Lights2] {
min-height: 2rem;
padding: .35rem;
display: flex;
align-items: center;
justify-content: center;
}
[xmpp_Roster_list] {
flex: 1;
overflow-y: auto;
min-height: 0;
}
[xmpp_Chat_pane] {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
[xmpp_Chat_view] {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
[xmpp_Chat_header] {
display: flex;
flex-direction: row;
align-items: center;
gap: .75rem;
padding: .75rem 1rem;
border-bottom: 1px solid var(--mol_theme_line);
flex-shrink: 0;
}
[xmpp_Chat_title] {
flex: 1;
font-weight: bold;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[xmpp_Chat_avatar] {
margin-right: 0;
}
[xmpp_Messages_list] {
flex: 1;
overflow-y: auto;
min-height: 0;
}
[xmpp_Compose_pane] {
display: flex;
flex-direction: row;
align-items: flex-end;
gap: .5rem;
padding: .75rem 1rem;
border-top: 1px solid var(--mol_theme_line);
flex-shrink: 0;
}
[xmpp_Chat_placeholder] {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
opacity: .5;
font-size: 1rem;
}
[xmpp_Search_input] {
margin: .5rem 1rem;
flex-shrink: 0;
flex-grow: 0;
height: 2.25rem;
min-height: 2.25rem;
max-height: 2.25rem;
box-sizing: border-box;
}
[xmpp_Search_actions] {
display: flex;
flex-direction: column;
gap: .25rem;
padding: .25rem .5rem .5rem .5rem;
flex-shrink: 0;
}
[xmpp_Search_actions]:empty {
display: none;
}
[xmpp_Search_action_chat],
[xmpp_Search_action_room] {
font-size: .85rem;
padding: .5rem .75rem;
border-radius: .5rem;
background: var(--mol_theme_card);
text-align: left;
}
[xmpp_Search_action_chat]:hover,
[xmpp_Search_action_room]:hover {
background: var(--mol_theme_hover, rgba(0,0,0,.05));
}
/* ── Messages ─────────────────────────────────────────────────────── */
[xmpp_Msg] {
display: flex;
flex-direction: row;
@ -115,13 +422,6 @@
}
[xmpp_Roster_contact] {
display: flex;
flex-direction: row;
align-items: center;
gap: .5rem;
}
[xmpp_Contact_label] {
flex: 1;
min-width: 0;

View file

@ -1,8 +1,9 @@
$xmpp $mol_book2
$xmpp $mol_view
plugins /
<= Theme $mol_theme_auto
sub <= panes /
-
Login_page $mol_page
Login_pane $mol_page
title @ \XMPP Client
tools /
<= Lights $mol_lights_toggle
@ -35,107 +36,156 @@ $xmpp $mol_book2
click? <=> do_connect? null
disabled <= connecting false
-
Roster_page $mol_page
title <= roster_title @ \Contacts
tools /
<= Lights $mol_lights_toggle
Folders_pane $mol_view
sub /
<= My_avatar $mol_view
dom_name \img
attr *
^
src <= my_avatar_uri \
alt \
<= Folder_all $mol_link
arg *
folder null
sub /
<= folder_all_label @ \All
<= Folder_chats $mol_link
arg *
folder \chats
sub /
<= folder_chats_label @ \Chats
<= Folder_rooms $mol_link
arg *
folder \rooms
sub /
<= folder_rooms_label @ \Rooms
<= User_folders $mol_view
sub <= user_folder_views /
<= New_folder_zone $mol_drop
adopt?transfer <=> folder_adopt?transfer null
receive?obj <=> new_folder_receive?obj null
Sub <= New_folder_label $mol_view
sub /
<= new_folder_text @ \+ Drop chat
<= Folders_spacer $mol_view
<= Set_avatar_button $mol_button_minor
title @ \Set avatar
hint @ \Set avatar
click? <=> do_set_avatar? null
sub /
<= Set_avatar_icon $mol_icon_camera
<= Lights2 $mol_lights_toggle
<= Disconnect_button $mol_button_minor
title <= disconnect_label @ \Disconnect
hint <= disconnect_label @ \Disconnect
click? <=> do_disconnect? null
body /
sub /
<= Disconnect_icon $mol_icon_logout
-
User_folder* $mol_drop
adopt?transfer <=> folder_adopt?transfer null
receive?obj <=> folder_receive*?obj null
Sub <= User_folder_link* $mol_link
arg *
folder <= user_folder_name* \
sub /
<= user_folder_label* \
-
Roster_pane $mol_view
sub /
<= Roster_header $mol_view
sub <= roster_header_sub /
<= Search_input $mol_string
hint @ \Search or enter JID…
value? <=> search_query? \
<= Roster_list $mol_list
rows <= roster_rows /
<= New_chat_form $mol_form
form_fields /
<= New_chat_field $mol_form_field
name @ \New chat with
control <= New_chat_input $mol_string
hint \user@example.com
value? <=> new_chat_jid? \
buttons /
<= New_chat_button $mol_button_major
title @ \Start chat
click? <=> do_new_chat? null
<= Room_join_form $mol_form
form_fields /
<= Room_jid_field $mol_form_field
name @ \Room JID
control <= Room_jid_input $mol_string
hint \room@conference.example.com
value? <=> room_jid? \
<= Room_nick_field $mol_form_field
name @ \Nickname
control <= Room_nick_input $mol_string
hint @ \nickname
value? <=> room_nick? \
buttons /
<= Room_join_button $mol_button_major
title @ \Join Room
click? <=> do_join_room? null
<= Search_actions $mol_view
sub <= search_actions_sub /
-
Chat_page* $mol_page
title <= chat_with* \
Logo <= Chat_avatar* $mol_view
dom_name \img
attr *
^
src <= chat_avatar_uri* \
alt \
tools /
<= Chat_leave* $mol_button_minor
title @ \Leave
click? <=> do_leave_room*? null
attr *
^
hidden <= chat_leave_hidden* \
<= Chat_close* $mol_link
arg *
chat null
Search_action_chat $mol_button_minor
title <= search_action_chat_title \
click? <=> do_search_chat? null
-
Search_action_room $mol_button_minor
title <= search_action_room_title \
click? <=> do_search_room? null
-
Roster_static_header $mol_view
sub /
<= roster_static_text \
-
Roster_folder_header $mol_view
sub /
<= Roster_folder_input $mol_string
value? <=> roster_folder_input? \
hint @ \Folder name
<= Roster_folder_delete $mol_button_minor
title @ \Delete folder
click? <=> do_delete_current_folder? null
-
Chat_pane $mol_view
sub <= chat_pane_content /
-
Chat_placeholder $mol_view
sub /
<= chat_placeholder_text @ \Select a chat to start messaging
-
Chat_view* $mol_view
sub /
<= Chat_header* $mol_view
sub /
<= Chat_close_icon* $mol_icon_close
body /
<= Chat_avatar* $mol_view
dom_name \img
attr *
^
src <= chat_avatar_uri* \
alt \
<= Chat_title* $mol_view
sub /
<= chat_with* \
<= Chat_leave* $mol_button_minor
title @ \Leave
click? <=> do_leave_room*? null
attr *
^
hidden <= chat_leave_hidden* \
<= Messages_list* $mol_list
rows <= message_rows* /
foot /
<= Compose_input* $mol_string
dom_name \textarea
hint @ \Type a message…
value? <=> compose*? \
event *
^
keydown? <=> compose_keydown*? null
paste? <=> compose_paste*? null
<= Attach_button* $mol_button_minor
title @ \Attach
click? <=> do_attach*? null
<= Record_button* $mol_button_minor
title <= record_title* @ \Record
click? <=> do_record*? null
<= Send_button* $mol_button_major
title <= send_label @ \Send
click? <=> do_send*? null
-
Roster_contact* $mol_button_minor
click? <=> open_chat*? null
sub /
<= Contact_avatar* $mol_view
dom_name \img
attr *
^
src <= contact_avatar_uri* \
alt \
<= Contact_label* $mol_view
<= Compose_pane* $mol_view
sub /
<= contact_display* \
<= Compose_input* $mol_string
dom_name \textarea
hint @ \Type a message…
value? <=> compose*? \
event *
^
keydown? <=> compose_keydown*? null
paste? <=> compose_paste*? null
<= Attach_button* $mol_button_minor
title @ \Attach
click? <=> do_attach*? null
<= Record_button* $mol_button_minor
title <= record_title* @ \Record
click? <=> do_record*? null
<= Send_button* $mol_button_major
title <= send_label @ \Send
click? <=> do_send*? null
-
Roster_contact* $mol_drag
transfer *
text/plain <= roster_jid* \
Sub <= Roster_contact_link* $mol_link
arg *
chat <= roster_jid* \
sub /
<= Contact_avatar* $mol_view
dom_name \img
attr *
^
src <= contact_avatar_uri* \
alt \
<= Contact_label* $mol_view
sub /
<= contact_display* \
-
Msg* $mol_view
sub /

View file

@ -732,29 +732,148 @@ private _handle_message(el: Element) {
@ $mol_mem
new_chat_jid(next?: string) { return next ?? '' }
@ $mol_mem
search_query(next?: string) { return next ?? '' }
@ $mol_mem_key
compose(_jid: string, next?: string) { return next ?? '' }
@ $mol_mem_key
recording(_jid: string, next?: boolean) { return next ?? false }
// ── Pages ─────────────────────────────────────────────────────────────
// ── Panes (Telegram-like 3-column layout) ─────────────────────────────
pages() {
panes() {
if (this.status() !== 'connected') {
this._maybe_auto_connect()
return [this.Login_page()]
return [this.Login_pane()]
}
const pages: $mol_view[] = [this.Roster_page()]
const peer = this.$.$mol_state_arg.value('chat')
if (peer) {
pages.push(this.Chat_page(peer))
// load MAM history the first time this chat page is shown
this._maybe_load_history(peer)
}
return pages
return [this.Folders_pane(), this.Roster_pane(), this.Chat_pane()]
}
chat_pane_content() {
const peer = this.$.$mol_state_arg.value('chat')
if (!peer) return [this.Chat_placeholder()]
this._maybe_load_history(peer)
return [this.Chat_view(peer)]
}
folder() { return (this.$.$mol_state_arg.value('folder') as string | null) ?? '' }
// ── User folders (drag chat → drop on +zone or existing folder) ──────
@ $mol_mem
folders_ver(next?: number) { return next ?? 0 }
private _folders_obj(): Record<string, string[]> {
return (this.$.$mol_state_local.value('xmpp_folders') as Record<string, string[]> | null) ?? {}
}
private _save_folders(obj: Record<string, string[]>) {
this.$.$mol_state_local.value('xmpp_folders', obj)
this.folders_ver(this.folders_ver() + 1)
}
folders_list(): string[] {
this.folders_ver()
return Object.keys(this._folders_obj()).sort()
}
private _add_to_folder(name: string, jid: string) {
const obj = { ...this._folders_obj() }
const arr = [...(obj[name] ?? [])]
if (!arr.includes(jid)) arr.push(jid)
obj[name] = arr
this._save_folders(obj)
}
private _remove_from_folder(name: string, jid: string) {
const obj = { ...this._folders_obj() }
const arr = (obj[name] ?? []).filter(j => j !== jid)
if (arr.length === 0) delete obj[name]
else obj[name] = arr
this._save_folders(obj)
}
// View bindings for the user folder list rendered in the left pane.
user_folder_views() {
return this.folders_list().map(name => this.User_folder(name))
}
user_folder_name(name: string) { return name }
user_folder_label(name: string) { return name }
delete_folder(name: string) {
const obj = this._folders_obj()
delete obj[name]
this._save_folders(obj)
if (this.folder() === name) this.$.$mol_state_arg.value('folder', null)
}
// ── Roster header (shows folder name; user folders are renamable + deletable) ──
roster_header_sub() {
const f = this.folder()
if (!f || f === 'chats' || f === 'rooms') return [this.Roster_static_header()]
return [this.Roster_folder_header()]
}
roster_static_text() {
const f = this.folder()
if (f === 'chats') return 'Chats'
if (f === 'rooms') return 'Rooms'
return 'All'
}
roster_folder_input(next?: string) {
const cur = this.folder()
if (next === undefined) return cur
const trimmed = next.trim()
if (!trimmed || trimmed === cur) return cur
if (cur && cur !== 'chats' && cur !== 'rooms') {
const obj = this._folders_obj()
if (obj[cur] && !obj[trimmed]) {
obj[trimmed] = obj[cur]
delete obj[cur]
this._save_folders(obj)
this.$.$mol_state_arg.value('folder', trimmed)
}
}
return trimmed
}
do_delete_current_folder() {
const f = this.folder()
if (!f || f === 'chats' || f === 'rooms') return
const confirmed = window.prompt(`Type "${ f }" to confirm deletion of this folder`)
if (!confirmed || confirmed.trim() !== f) return
this.delete_folder(f)
}
// $mol_drag/$mol_drop integration.
roster_jid(jid: string) { return jid }
folder_adopt(transfer: DataTransfer): string | null {
const jid = transfer.getData('text/plain')
return jid || null
}
folder_receive(name: string, jid: string) {
if (!jid) return
const obj = this._folders_obj()
const arr = obj[name] ?? []
if (arr.includes(jid)) this._remove_from_folder(name, jid)
else this._add_to_folder(name, jid)
}
new_folder_receive(jid: string) {
if (!jid) return
const name = (window.prompt('Folder name')?.trim()) ?? ''
if (!name) return
this._add_to_folder(name, jid)
}
// On first render, if we have saved credentials and aren't connecting/connected, kick off auto-connect.
private _maybe_auto_connect() {
if (this._auto_connect_tried) return
@ -937,10 +1056,57 @@ private _handle_message(el: Element) {
// ── Roster ────────────────────────────────────────────────────────────
@ $mol_mem
roster_rows() {
const contacts = this.contacts().map(c => this.Roster_contact(c.jid))
const rooms = this.rooms().map(r => this.Roster_contact(r.jid))
return [...contacts, ...rooms]
this.folders_ver()
const folder = this.folder()
const contacts = this.contacts()
const rooms = this.rooms()
let jids: string[] = []
if (folder === 'chats') jids = contacts.map(c => c.jid)
else if (folder === 'rooms') jids = rooms.map(r => r.jid)
else if (!folder) jids = [...contacts.map(c => c.jid), ...rooms.map(r => r.jid)]
else jids = this._folders_obj()[folder] ?? []
const q = this.search_query().trim().toLowerCase()
if (q) {
jids = jids.filter(jid => {
const c = contacts.find(x => x.jid === jid)
const r = this._rooms.get(jid)
const name = (c?.name || r?.name || jid).toLowerCase()
return jid.toLowerCase().includes(q) || name.includes(q)
})
}
return jids.map(j => this.Roster_contact(j))
}
// Suggestion buttons appear when the search query looks like a JID and isn't already in the list.
search_actions_sub() {
const q = this.search_query().trim()
if (!q.includes('@')) return []
const has_existing = this._rooms.has(q) || this.contacts().some(c => c.jid === q)
return has_existing ? [] : [this.Search_action_chat(), this.Search_action_room()]
}
search_action_chat_title() { return `+ Start chat with ${ this.search_query().trim() }` }
search_action_room_title() { return `# Join room ${ this.search_query().trim() }` }
do_search_chat() {
const jid = this.search_query().trim()
if (!jid.includes('@')) return
this.search_query('')
this.$.$mol_state_arg.value('chat', jid)
}
do_search_room() {
const jid = this.search_query().trim()
if (!jid.includes('@') || !this._conn) return
const nick = this.my_jid().split('@')[0]
this._conn.join_room(jid, nick)
const room: Xmpp_room = { jid, name: jid.split('@')[0], nick }
this._rooms.set(jid, room)
this.rooms([ ...this._rooms.values() ])
this.search_query('')
this.$.$mol_state_arg.value('chat', jid)
}
contact_display(jid: string) {