diff --git a/xmpp.view.css b/xmpp.view.css index 9cffde9..a6ae28b 100644 --- a/xmpp.view.css +++ b/xmpp.view.css @@ -40,6 +40,26 @@ [xmpp_Compose_input] { flex: 1; + min-height: 2.5rem; + max-height: 8rem; + resize: vertical; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + font: inherit; +} + +[xmpp_Msg_head] { + display: flex; + flex-direction: row; + align-items: baseline; + gap: .5rem; +} + +[xmpp_Msg_time] { + font-size: .7rem; + opacity: .5; + margin-left: auto; } /* media */ diff --git a/xmpp.view.tree b/xmpp.view.tree index 4bdac9b..33d4db6 100644 --- a/xmpp.view.tree +++ b/xmpp.view.tree @@ -54,6 +54,17 @@ $xmpp $mol_book2 body / <= 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 @@ -96,8 +107,13 @@ $xmpp $mol_book2 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 @@ -131,9 +147,14 @@ $xmpp $mol_book2 alt \ <= Msg_content* $mol_view sub / - <= Msg_from* $mol_view + <= Msg_head* $mol_view sub / - <= msg_from* \ + <= Msg_from* $mol_view + sub / + <= msg_from* \ + <= Msg_time* $mol_view + sub / + <= msg_time* \ <= Msg_body* $mol_view sub / <= msg_body* \ diff --git a/xmpp.view.ts b/xmpp.view.ts index eae9487..b83b473 100644 --- a/xmpp.view.ts +++ b/xmpp.view.ts @@ -702,6 +702,9 @@ private _handle_message(el: Element) { @ $mol_mem room_nick(next?: string) { return next ?? '' } + @ $mol_mem + new_chat_jid(next?: string) { return next ?? '' } + @ $mol_mem_key compose(_jid: string, next?: string) { return next ?? '' } @@ -905,6 +908,16 @@ private _handle_message(el: Element) { open_chat(jid: string) { this.$.$mol_state_arg.value('chat', jid) } + do_new_chat() { + const jid = this.new_chat_jid().trim() + if (!jid || !jid.includes('@')) { + this.error_text('Enter a valid JID like user@server') + return + } + this.new_chat_jid('') + this.$.$mol_state_arg.value('chat', jid) + } + // ── Rooms ───────────────────────────────────────────────────────────── do_join_room() { @@ -999,6 +1012,23 @@ private _handle_message(el: Element) { @ $mol_mem_key // ← FIX: make reactive msg_link_uri(id: string) { return this._msg_by_id.get(id)?.body.trim() ?? '' } + @ $mol_mem_key + msg_time(id: string) { + const msg = this._msg_by_id.get(id) + if (!msg) return '' + const d = new Date(msg.time) + const same_day = (() => { + const now = new Date() + return d.getFullYear() === now.getFullYear() + && d.getMonth() === now.getMonth() + && d.getDate() === now.getDate() + })() + const time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + if (same_day) return time + const date = d.toLocaleDateString([], { day: '2-digit', month: '2-digit' }) + return `${ date } ${ time }` + } + // XEP-0333 status for outgoing messages. // ✓ — sent locally; ✓✓ — peer received; ✓✓ (filled) — peer displayed. @ $mol_mem_key @@ -1014,6 +1044,37 @@ private _handle_message(el: Element) { return '✓' } + // ── Compose input event handlers ────────────────────────────────────── + + // Enter sends; Shift+Enter inserts a newline (browser default). + compose_keydown(jid: string, e?: Event | null) { + const ke = e as KeyboardEvent | null | undefined + if (!ke) return null + if (ke.key === 'Enter' && !ke.shiftKey && !ke.ctrlKey && !ke.altKey && !ke.metaKey) { + ke.preventDefault() + this.do_send(jid) + } + return null + } + + // Paste image / file from clipboard → upload via XEP-0363 and send the link. + compose_paste(jid: string, e?: Event | null) { + const ce = e as ClipboardEvent | null | undefined + const items = ce?.clipboardData?.items + if (!items) return null + for (const item of Array.from(items)) { + if (item.kind === 'file') { + const file = item.getAsFile() + if (file) { + ce!.preventDefault() + void this._upload_and_send(jid, file).catch(err => this.error_text(String(err))) + return null + } + } + } + return null + } + // ── Send text ───────────────────────────────────────────────────────── do_send(jid: string) { @@ -1240,6 +1301,12 @@ private _handle_message(el: Element) { el.addEventListener('scroll', () => { if (el.scrollTop < 200) this._load_more_history(jid) }, { passive: true }) + // Late-loading images push content; if user is near the bottom, follow. + el.addEventListener('load', e => { + if (!(e.target instanceof HTMLImageElement)) return + const near = el.scrollHeight - el.scrollTop - el.clientHeight < 200 + if (near) el.scrollTop = el.scrollHeight + }, true) } catch { this._scroll_setup.delete(jid) } @@ -1299,6 +1366,7 @@ private _handle_message(el: Element) { const id = `l${ Date.now() }_${ Math.random().toString(36).slice(2) }` this._conn.send_message(jid, get, id) this._add_message({ id, from: this.my_jid().split('/')[0], to: jid, body: get, time: Date.now() }) + this._scroll_to_bottom(jid) } } }