diff --git a/xmpp.view.css b/xmpp.view.css index a6ae28b..b746dfb 100644 --- a/xmpp.view.css +++ b/xmpp.view.css @@ -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; diff --git a/xmpp.view.tree b/xmpp.view.tree index 33d4db6..e6de581 100644 --- a/xmpp.view.tree +++ b/xmpp.view.tree @@ -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 / diff --git a/xmpp.view.ts b/xmpp.view.ts index 3656e4c..9fa65ae 100644 --- a/xmpp.view.ts +++ b/xmpp.view.ts @@ -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 { + return (this.$.$mol_state_local.value('xmpp_folders') as Record | null) ?? {} + } + + private _save_folders(obj: Record) { + 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) {