Fix mobile chat composer glitches and add date separators/reconnect banner
Composer focus/blur no longer fights the on-screen keyboard: tapping Send blurred the textarea first, which unconditionally zeroed the visualViewport keyboard-inset compensation before the immediate refocus could recompute it, so the composer could sit misplaced for up to 600ms after every mobile send. Blur now only resets the inset when focus is actually leaving the composer form, and focus recomputes it immediately instead of waiting on the retry timers. Emoji picker and @mention wiring, previously only ever applied once at page load, are now re-run whenever AppChat rebuilds a composer for a client-side conversation switch, so both survive tapping a conversation from the list instead of only working after a full page reload. Viewport meta gains interactive-widget=resizes-content for native keyboard-aware layout. Chat scroll panes get overscroll-behavior: contain plus momentum scrolling, matching the pattern already used elsewhere in the app. The conversation-search dropdown's literal z-index is replaced with the --z-popover token. Adds sticky Today/Yesterday/date separators between message groups (chat/DateSeparators.js, viewer-local-time day boundaries, normalized on every thread mutation) and a debounced "Reconnecting..." banner reflecting live WebSocket state. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qsdit8UXhbUn9ZnfgjbXqt
This commit is contained in:
@@ -69,7 +69,7 @@ dp-chat[mode="embed"] {
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-top: none;
|
border-top: none;
|
||||||
border-radius: 0 0 var(--radius) var(--radius);
|
border-radius: 0 0 var(--radius) var(--radius);
|
||||||
z-index: 100;
|
z-index: var(--z-popover);
|
||||||
max-height: 240px;
|
max-height: 240px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
@@ -93,6 +93,8 @@ dp-chat[mode="embed"] {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
}
|
}
|
||||||
@@ -148,6 +150,42 @@ dp-chat[mode="embed"] {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-jump-new {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 72px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: var(--z-popover);
|
||||||
|
padding: var(--space-xs) var(--space-md);
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--on-accent);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-jump-new[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-connection-banner {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: var(--space-xs) var(--space-lg);
|
||||||
|
background: var(--warning);
|
||||||
|
color: var(--white);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-connection-banner[hidden] {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.messages-main-header {
|
.messages-main-header {
|
||||||
@@ -206,6 +244,8 @@ dp-chat[mode="embed"] {
|
|||||||
.messages-thread {
|
.messages-thread {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
padding: var(--space-lg);
|
padding: var(--space-lg);
|
||||||
padding-bottom: var(--space-sm);
|
padding-bottom: var(--space-sm);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -322,6 +362,25 @@ dp-chat[mode="embed"] {
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.date-separator {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: var(--z-popover);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-separator span {
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
.typing-indicator {
|
.typing-indicator {
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -376,6 +435,16 @@ dp-chat[mode="embed"] {
|
|||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.messages-input-area .mention-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-input-area .mention-wrapper textarea {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.messages-input-area textarea {
|
.messages-input-area textarea {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -507,13 +576,19 @@ dp-chat[mode="embed"] {
|
|||||||
/* ── Mobile (<= 768px) ─────────────────────────────────── */
|
/* ── Mobile (<= 768px) ─────────────────────────────────── */
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
.page:has(.page-messages) {
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.page-messages {
|
.page-messages {
|
||||||
padding-bottom: max(env(safe-area-inset-bottom), var(--kb-inset, 0px));
|
padding-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.messages-layout,
|
.messages-layout,
|
||||||
dp-chat[mode="embed"] {
|
dp-chat[mode="embed"] {
|
||||||
padding-bottom: max(env(safe-area-inset-bottom), var(--kb-inset, 0px));
|
padding-bottom: var(--kb-inset, 0px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.messages-layout {
|
.messages-layout {
|
||||||
@@ -531,6 +606,7 @@ dp-chat[mode="embed"] {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dp-chat[with-uid]:not(.show-list) .messages-list,
|
||||||
.messages-list.hide {
|
.messages-list.hide {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -540,6 +616,8 @@ dp-chat[mode="embed"] {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dp-chat.show-list .messages-main,
|
||||||
|
dp-chat:not([with-uid]) .messages-main,
|
||||||
.messages-main.hide {
|
.messages-main.hide {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -602,6 +680,11 @@ dp-chat[mode="embed"] {
|
|||||||
/* ── Touch devices: larger tap targets (min 44x44px) ────── */
|
/* ── Touch devices: larger tap targets (min 44x44px) ────── */
|
||||||
|
|
||||||
@media (hover: none) and (pointer: coarse) {
|
@media (hover: none) and (pointer: coarse) {
|
||||||
|
.messages-input-area .emoji-toggle-btn {
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
.messages-send-btn {
|
.messages-send-btn {
|
||||||
width: 44px;
|
width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
@@ -637,21 +720,11 @@ dp-chat[mode="embed"] {
|
|||||||
border: none;
|
border: none;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
opacity: 0;
|
opacity: 1;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-bubble:hover .message-report-btn,
|
.message-report-btn:hover,
|
||||||
.message-bubble:focus-within .message-report-btn {
|
.message-report-btn:focus {
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-report-btn:hover {
|
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (hover: none) and (pointer: coarse) {
|
|
||||||
.message-report-btn {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
|
export function dateKey(iso) {
|
||||||
|
if (!iso) return null;
|
||||||
|
const ms = Date.parse(iso);
|
||||||
|
if (Number.isNaN(ms)) return null;
|
||||||
|
const d = new Date(ms);
|
||||||
|
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dateLabel(iso) {
|
||||||
|
if (!iso) return "";
|
||||||
|
const ms = Date.parse(iso);
|
||||||
|
if (Number.isNaN(ms)) return "";
|
||||||
|
const d = new Date(ms);
|
||||||
|
const startOfDay = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
|
||||||
|
const diffDays = Math.round((startOfDay(new Date()) - startOfDay(d)) / 86400000);
|
||||||
|
if (diffDays === 0) return "Today";
|
||||||
|
if (diffDays === 1) return "Yesterday";
|
||||||
|
const dd = String(d.getDate()).padStart(2, "0");
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
return `${dd}/${mm}/${d.getFullYear()}`;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { Component } from "./Component.js";
|
|||||||
import { assetUrl } from "../assetVersion.js";
|
import { assetUrl } from "../assetVersion.js";
|
||||||
import { ChatSocket } from "../chat/ChatSocket.js";
|
import { ChatSocket } from "../chat/ChatSocket.js";
|
||||||
import { shouldGroup } from "../chat/MessageGrouping.js";
|
import { shouldGroup } from "../chat/MessageGrouping.js";
|
||||||
|
import { dateKey, dateLabel } from "../chat/DateSeparators.js";
|
||||||
import { Http } from "../Http.js";
|
import { Http } from "../Http.js";
|
||||||
import { Avatar } from "../Avatar.js";
|
import { Avatar } from "../Avatar.js";
|
||||||
import { DomUtils } from "../DomUtils.js";
|
import { DomUtils } from "../DomUtils.js";
|
||||||
@@ -17,11 +18,12 @@ const TYPING_THROTTLE_MS = 1500;
|
|||||||
const TYPING_HIDE_MS = 4000;
|
const TYPING_HIDE_MS = 4000;
|
||||||
const AUTO_SCROLL_MARGIN_PX = 100;
|
const AUTO_SCROLL_MARGIN_PX = 100;
|
||||||
const STABILIZE_MAX_FRAMES = 300;
|
const STABILIZE_MAX_FRAMES = 300;
|
||||||
const SEND_TIMEOUT_MS = 8000;
|
const SEND_TIMEOUT_MS = 20000;
|
||||||
const MOBILE_BREAKPOINT_PX = 768;
|
const MOBILE_BREAKPOINT_PX = 768;
|
||||||
const AI_INDICATOR_MS = 4000;
|
const AI_INDICATOR_MS = 4000;
|
||||||
const SEARCH_DEBOUNCE_MS = 200;
|
const SEARCH_DEBOUNCE_MS = 200;
|
||||||
const AUTO_GROW_MAX_HEIGHT_PX = 160;
|
const AUTO_GROW_MAX_HEIGHT_PX = 160;
|
||||||
|
const CONVERSATION_PAGE_SIZE = 500;
|
||||||
|
|
||||||
const LIGHT_THEME_OVERRIDES = {
|
const LIGHT_THEME_OVERRIDES = {
|
||||||
"--bg-primary": "#f5f3fa",
|
"--bg-primary": "#f5f3fa",
|
||||||
@@ -54,6 +56,14 @@ export class AppChat extends Component {
|
|||||||
this._supportsFieldSizing = typeof CSS !== "undefined" && !!CSS.supports && CSS.supports("field-sizing", "content");
|
this._supportsFieldSizing = typeof CSS !== "undefined" && !!CSS.supports && CSS.supports("field-sizing", "content");
|
||||||
this._autoGrowFrame = null;
|
this._autoGrowFrame = null;
|
||||||
this._lastAutoGrowHeight = null;
|
this._lastAutoGrowHeight = null;
|
||||||
|
this._hasOlder = false;
|
||||||
|
this._loadingOlder = false;
|
||||||
|
this._opening = false;
|
||||||
|
this._newCount = 0;
|
||||||
|
this._lastSyncAt = null;
|
||||||
|
this._coarseQuery = null;
|
||||||
|
this._connBanner = null;
|
||||||
|
this._disconnectTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
_ensureCss() {
|
_ensureCss() {
|
||||||
@@ -73,18 +83,23 @@ export class AppChat extends Component {
|
|||||||
this._applyThemeOverrides();
|
this._applyThemeOverrides();
|
||||||
this._adopt();
|
this._adopt();
|
||||||
this._ensureSkeleton();
|
this._ensureSkeleton();
|
||||||
|
this._ensureConnectionBanner();
|
||||||
|
this._normalizeDateSeparators();
|
||||||
this._capAttachments();
|
this._capAttachments();
|
||||||
this._initComposer();
|
this._initComposer();
|
||||||
this._initRetry();
|
this._initRetry();
|
||||||
this._initSearch();
|
this._initSearch();
|
||||||
this._initMobilePane();
|
this._initMobilePane();
|
||||||
|
this._bindHistory();
|
||||||
this._bindViewport();
|
this._bindViewport();
|
||||||
this._bindAutoScroll();
|
this._bindAutoScroll();
|
||||||
this._startScrollWatcher();
|
this._startScrollWatcher();
|
||||||
|
this._ensureJumpButton();
|
||||||
this._connect();
|
this._connect();
|
||||||
this._initPresence();
|
this._initPresence();
|
||||||
this._scrollThreadToEnd();
|
this._scrollThreadToEnd();
|
||||||
if (this.input) this.input.focus({ preventScroll: true });
|
this._hasOlder = !!(this.thread && this.thread.querySelectorAll(".message-bubble").length >= CONVERSATION_PAGE_SIZE);
|
||||||
|
if (this.input && !this._isCoarse()) this.input.focus({ preventScroll: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
@@ -97,11 +112,13 @@ export class AppChat extends Component {
|
|||||||
this._mobileQuery.removeEventListener("change", this._onMobileChange);
|
this._mobileQuery.removeEventListener("change", this._onMobileChange);
|
||||||
}
|
}
|
||||||
clearTimeout(this._typingHideTimer);
|
clearTimeout(this._typingHideTimer);
|
||||||
|
clearTimeout(this._disconnectTimer);
|
||||||
cancelAnimationFrame(this._autoGrowFrame);
|
cancelAnimationFrame(this._autoGrowFrame);
|
||||||
for (const entry of this._pendingSends.values()) clearTimeout(entry.timeoutId);
|
for (const entry of this._pendingSends.values()) clearTimeout(entry.timeoutId);
|
||||||
this._pendingSends.clear();
|
this._pendingSends.clear();
|
||||||
this._failedSends.clear();
|
this._failedSends.clear();
|
||||||
if (this._presence) this._presence.stop();
|
if (this._presence) this._presence.stop();
|
||||||
|
if (this._onPopState) window.removeEventListener("popstate", this._onPopState);
|
||||||
}
|
}
|
||||||
|
|
||||||
_readConfig() {
|
_readConfig() {
|
||||||
@@ -263,7 +280,7 @@ export class AppChat extends Component {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.input.addEventListener("keydown", (event) => {
|
this.input.addEventListener("keydown", (event) => {
|
||||||
if (event.key === "Enter" && !event.shiftKey) {
|
if (event.key === "Enter" && !event.shiftKey && !this._isCoarse()) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.form.requestSubmit();
|
this.form.requestSubmit();
|
||||||
}
|
}
|
||||||
@@ -319,7 +336,7 @@ export class AppChat extends Component {
|
|||||||
|
|
||||||
_refreshSendButton() {
|
_refreshSendButton() {
|
||||||
if (!this.sendBtn) return;
|
if (!this.sendBtn) return;
|
||||||
const busy = this._uploading || this._pendingSends.size > 0;
|
const busy = this._uploading;
|
||||||
this.sendBtn.disabled = busy;
|
this.sendBtn.disabled = busy;
|
||||||
this.sendBtn.classList.toggle("is-sending", busy);
|
this.sendBtn.classList.toggle("is-sending", busy);
|
||||||
}
|
}
|
||||||
@@ -346,6 +363,7 @@ export class AppChat extends Component {
|
|||||||
if (hint) hint.remove();
|
if (hint) hint.remove();
|
||||||
} else {
|
} else {
|
||||||
bubble = this._appendOptimisticBubble(content, clientId, attachmentUids.length);
|
bubble = this._appendOptimisticBubble(content, clientId, attachmentUids.length);
|
||||||
|
if (/@ai\s+\S/i.test(content)) this._showAiPending(bubble);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ok = !!(this.socket && this.socket.send({
|
const ok = !!(this.socket && this.socket.send({
|
||||||
@@ -428,6 +446,7 @@ export class AppChat extends Component {
|
|||||||
|
|
||||||
const el = document.createElement("dp-content");
|
const el = document.createElement("dp-content");
|
||||||
if (senderRole === "Admin") el.setAttribute("data-author-admin", "");
|
if (senderRole === "Admin") el.setAttribute("data-author-admin", "");
|
||||||
|
el.dataset.source = content || "";
|
||||||
el.textContent = content || "";
|
el.textContent = content || "";
|
||||||
bubble.appendChild(el);
|
bubble.appendChild(el);
|
||||||
|
|
||||||
@@ -461,27 +480,61 @@ export class AppChat extends Component {
|
|||||||
receipt.hidden = true;
|
receipt.hidden = true;
|
||||||
receipt.innerHTML = "✓✓";
|
receipt.innerHTML = "✓✓";
|
||||||
bubble.appendChild(receipt);
|
bubble.appendChild(receipt);
|
||||||
|
} else if (uid) {
|
||||||
|
bubble.appendChild(this._reportButton(uid));
|
||||||
}
|
}
|
||||||
|
|
||||||
return bubble;
|
return bubble;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_reportButton(uid) {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.className = "message-report-btn report-btn";
|
||||||
|
btn.dataset.reportType = "message";
|
||||||
|
btn.dataset.reportUid = uid;
|
||||||
|
btn.setAttribute("aria-label", "Report this message");
|
||||||
|
btn.title = "Report";
|
||||||
|
const icon = document.createElement("span");
|
||||||
|
icon.className = "icon";
|
||||||
|
icon.setAttribute("aria-hidden", "true");
|
||||||
|
icon.textContent = "\u{1F6A9}";
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.className = "label";
|
||||||
|
label.textContent = " Report";
|
||||||
|
btn.append(icon, label);
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
_contentSource(el) {
|
||||||
|
if (!el) return "";
|
||||||
|
if (el.dataset && Object.prototype.hasOwnProperty.call(el.dataset, "source")) {
|
||||||
|
return el.dataset.source;
|
||||||
|
}
|
||||||
|
if (el._source != null) return el._source;
|
||||||
|
return el.textContent || "";
|
||||||
|
}
|
||||||
|
|
||||||
_renderAttachments(attachments) {
|
_renderAttachments(attachments) {
|
||||||
if (!attachments || !attachments.length) return null;
|
if (!attachments || !attachments.length) return null;
|
||||||
|
const single = attachments.length === 1;
|
||||||
const gallery = document.createElement("div");
|
const gallery = document.createElement("div");
|
||||||
gallery.className = "attachment-gallery";
|
gallery.className = `attachment-gallery${single ? " single" : ""}`;
|
||||||
for (const att of attachments) {
|
for (const att of attachments) {
|
||||||
const item = document.createElement("div");
|
const item = document.createElement("div");
|
||||||
item.className = "attachment-gallery-item";
|
item.className = "attachment-gallery-item";
|
||||||
|
const filename = att.original_filename || att.filename || "file";
|
||||||
|
const mime = att.mime_type || "";
|
||||||
|
const isAudio = att.is_audio || mime.startsWith("audio/");
|
||||||
if (att.is_image) {
|
if (att.is_image) {
|
||||||
const img = document.createElement("img");
|
const img = document.createElement("img");
|
||||||
img.src = att.thumbnail_url || att.url;
|
img.src = (single || !att.thumbnail_url) ? (att.url || att.thumbnail_url) : att.thumbnail_url;
|
||||||
img.alt = att.original_filename || "";
|
img.alt = filename;
|
||||||
img.loading = "lazy";
|
img.loading = "lazy";
|
||||||
img.className = "gallery-thumb";
|
img.className = "gallery-thumb";
|
||||||
img.dataset.lightbox = "";
|
img.dataset.lightbox = "";
|
||||||
img.dataset.full = att.url;
|
img.dataset.full = att.url || att.thumbnail_url || "";
|
||||||
if (att.mime_type) img.dataset.mime = att.mime_type;
|
if (mime) img.dataset.mime = mime;
|
||||||
item.appendChild(img);
|
item.appendChild(img);
|
||||||
} else if (att.is_video) {
|
} else if (att.is_video) {
|
||||||
const video = document.createElement("video");
|
const video = document.createElement("video");
|
||||||
@@ -490,7 +543,7 @@ export class AppChat extends Component {
|
|||||||
video.preload = "metadata";
|
video.preload = "metadata";
|
||||||
video.className = "gallery-video";
|
video.className = "gallery-video";
|
||||||
item.appendChild(video);
|
item.appendChild(video);
|
||||||
} else if (att.is_audio) {
|
} else if (isAudio) {
|
||||||
const audio = document.createElement("audio");
|
const audio = document.createElement("audio");
|
||||||
audio.src = att.url;
|
audio.src = att.url;
|
||||||
audio.controls = true;
|
audio.controls = true;
|
||||||
@@ -503,8 +556,8 @@ export class AppChat extends Component {
|
|||||||
link.target = "_blank";
|
link.target = "_blank";
|
||||||
link.rel = "noopener";
|
link.rel = "noopener";
|
||||||
link.className = "non-image";
|
link.className = "non-image";
|
||||||
link.download = att.original_filename || "file";
|
link.download = filename;
|
||||||
link.textContent = att.original_filename || "file";
|
link.textContent = filename;
|
||||||
item.appendChild(link);
|
item.appendChild(link);
|
||||||
}
|
}
|
||||||
gallery.appendChild(item);
|
gallery.appendChild(item);
|
||||||
@@ -522,9 +575,34 @@ export class AppChat extends Component {
|
|||||||
} else {
|
} else {
|
||||||
this.thread.appendChild(bubble);
|
this.thread.appendChild(bubble);
|
||||||
}
|
}
|
||||||
|
this._normalizeDateSeparators();
|
||||||
this._scrollThreadToEnd();
|
this._scrollThreadToEnd();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_buildDateSeparator(iso) {
|
||||||
|
const sep = document.createElement("div");
|
||||||
|
sep.className = "date-separator";
|
||||||
|
const pill = document.createElement("span");
|
||||||
|
pill.textContent = dateLabel(iso);
|
||||||
|
sep.appendChild(pill);
|
||||||
|
return sep;
|
||||||
|
}
|
||||||
|
|
||||||
|
_normalizeDateSeparators() {
|
||||||
|
if (!this.thread) return;
|
||||||
|
this.thread.querySelectorAll(".date-separator").forEach((el) => el.remove());
|
||||||
|
let previousKey = null;
|
||||||
|
this.thread.querySelectorAll(".message-bubble").forEach((bubble) => {
|
||||||
|
const time = bubble.querySelector(".message-time[datetime], .message-time time[datetime]");
|
||||||
|
const iso = time ? time.getAttribute("datetime") : null;
|
||||||
|
const key = dateKey(iso);
|
||||||
|
if (key && key !== previousKey) {
|
||||||
|
this.thread.insertBefore(this._buildDateSeparator(iso), bubble);
|
||||||
|
previousKey = key;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async _connect() {
|
async _connect() {
|
||||||
const url = await this._resolveWsUrl();
|
const url = await this._resolveWsUrl();
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
@@ -533,6 +611,7 @@ export class AppChat extends Component {
|
|||||||
onMessage: (frame) => this.onFrame(frame),
|
onMessage: (frame) => this.onFrame(frame),
|
||||||
onClose: () => {
|
onClose: () => {
|
||||||
this._socketReady = false;
|
this._socketReady = false;
|
||||||
|
this._noteDisconnected();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.socket.connect();
|
this.socket.connect();
|
||||||
@@ -568,7 +647,69 @@ export class AppChat extends Component {
|
|||||||
onReady(payload) {
|
onReady(payload) {
|
||||||
this._socketReady = true;
|
this._socketReady = true;
|
||||||
if (payload && payload.user_uid) this.selfUid = payload.user_uid;
|
if (payload && payload.user_uid) this.selfUid = payload.user_uid;
|
||||||
|
this._noteConnected();
|
||||||
this._markRead();
|
this._markRead();
|
||||||
|
this._syncMissed();
|
||||||
|
}
|
||||||
|
|
||||||
|
_ensureConnectionBanner() {
|
||||||
|
if (!this.main) return;
|
||||||
|
if (!this._connBanner) {
|
||||||
|
const banner = document.createElement("div");
|
||||||
|
banner.className = "messages-connection-banner";
|
||||||
|
banner.hidden = true;
|
||||||
|
banner.textContent = "Reconnecting\u2026";
|
||||||
|
this._connBanner = banner;
|
||||||
|
}
|
||||||
|
if (this._connBanner.parentElement !== this.main || this.main.firstChild !== this._connBanner) {
|
||||||
|
this.main.insertBefore(this._connBanner, this.main.firstChild || null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_noteDisconnected() {
|
||||||
|
if (this._disconnectTimer) return;
|
||||||
|
this._disconnectTimer = window.setTimeout(() => {
|
||||||
|
this._disconnectTimer = null;
|
||||||
|
if (!this._socketReady && this._connBanner) this._connBanner.hidden = false;
|
||||||
|
}, 900);
|
||||||
|
}
|
||||||
|
|
||||||
|
_noteConnected() {
|
||||||
|
clearTimeout(this._disconnectTimer);
|
||||||
|
this._disconnectTimer = null;
|
||||||
|
if (this._connBanner) this._connBanner.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_syncMissed() {
|
||||||
|
if (!this._socketReady || !this.socket) return;
|
||||||
|
const since = this._latestStamp() || this._lastSyncAt;
|
||||||
|
if (!since) {
|
||||||
|
this._lastSyncAt = new Date().toISOString();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.socket.send({
|
||||||
|
type: "sync",
|
||||||
|
since,
|
||||||
|
with_uid: this.withUid || "",
|
||||||
|
});
|
||||||
|
this._lastSyncAt = new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
_latestStamp() {
|
||||||
|
if (!this.thread) return null;
|
||||||
|
const times = [...this.thread.querySelectorAll(".message-bubble .message-time[datetime], .message-bubble .message-time time[datetime]")];
|
||||||
|
let latest = null;
|
||||||
|
for (const el of times) {
|
||||||
|
const value = el.getAttribute("datetime");
|
||||||
|
if (value && (!latest || value > latest)) latest = value;
|
||||||
|
}
|
||||||
|
return latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
_threadIsActive() {
|
||||||
|
if (!this.withUid) return false;
|
||||||
|
if (!this._isMobile()) return true;
|
||||||
|
return this._activePane === "thread";
|
||||||
}
|
}
|
||||||
|
|
||||||
onFrame(frame) {
|
onFrame(frame) {
|
||||||
@@ -629,21 +770,24 @@ export class AppChat extends Component {
|
|||||||
senderRole: frame.sender_role,
|
senderRole: frame.sender_role,
|
||||||
attachments: frame.attachments,
|
attachments: frame.attachments,
|
||||||
});
|
});
|
||||||
|
if (frame.ai_pending) this._showAiPending(bubble);
|
||||||
this._insertBubble(bubble);
|
this._insertBubble(bubble);
|
||||||
if (!mine && this._socketReady) {
|
if (!mine && this._socketReady && this._threadIsActive()) {
|
||||||
this.socket.send({ type: "read", with_uid: this.withUid });
|
this.socket.send({ type: "read", with_uid: this.withUid });
|
||||||
}
|
}
|
||||||
|
if (!mine && !this._userAtBottom) this._noteNewMessage();
|
||||||
}
|
}
|
||||||
this._bumpConversation(frame, frame.sender_uid === this.selfUid);
|
this._bumpConversation(frame, frame.sender_uid === this.selfUid);
|
||||||
}
|
}
|
||||||
|
|
||||||
_applyFrameContent(bubble, frame) {
|
_applyFrameContent(bubble, frame) {
|
||||||
const oldBody = bubble.querySelector("dp-content");
|
const oldBody = bubble.querySelector("dp-content");
|
||||||
const previousContent = oldBody ? oldBody.textContent : "";
|
const previousContent = this._contentSource(oldBody);
|
||||||
let changed = false;
|
let changed = false;
|
||||||
if (oldBody && frame.content !== undefined && frame.content !== previousContent) {
|
if (oldBody && frame.content !== undefined && frame.content !== previousContent) {
|
||||||
const content = document.createElement("dp-content");
|
const content = document.createElement("dp-content");
|
||||||
if (frame.sender_role === "Admin") content.setAttribute("data-author-admin", "");
|
if (frame.sender_role === "Admin") content.setAttribute("data-author-admin", "");
|
||||||
|
content.dataset.source = frame.content || "";
|
||||||
content.textContent = frame.content || "";
|
content.textContent = frame.content || "";
|
||||||
oldBody.replaceWith(content);
|
oldBody.replaceWith(content);
|
||||||
changed = true;
|
changed = true;
|
||||||
@@ -659,8 +803,16 @@ export class AppChat extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.aiIndicatorEnabled && frame.ai_processed && changed) {
|
if (frame.uid && !bubble.dataset.msgUid) bubble.dataset.msgUid = frame.uid;
|
||||||
this._showAiIndicator(bubble);
|
if (frame.uid && !bubble.classList.contains("mine") && !bubble.querySelector(".message-report-btn")) {
|
||||||
|
bubble.appendChild(this._reportButton(frame.uid));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frame.ai_pending && !frame.ai_processed) {
|
||||||
|
this._showAiPending(bubble);
|
||||||
|
} else if (frame.ai_processed) {
|
||||||
|
this._clearAiPending(bubble);
|
||||||
|
if (this.aiIndicatorEnabled && changed) this._showAiIndicator(bubble);
|
||||||
}
|
}
|
||||||
return changed;
|
return changed;
|
||||||
}
|
}
|
||||||
@@ -673,6 +825,7 @@ export class AppChat extends Component {
|
|||||||
const hint = bubble.querySelector(".retry-hint");
|
const hint = bubble.querySelector(".retry-hint");
|
||||||
if (hint) hint.remove();
|
if (hint) hint.remove();
|
||||||
|
|
||||||
|
if (frame.ai_pending) this._showAiPending(bubble);
|
||||||
this._applyFrameContent(bubble, frame);
|
this._applyFrameContent(bubble, frame);
|
||||||
|
|
||||||
const time = bubble.querySelector(".message-time");
|
const time = bubble.querySelector(".message-time");
|
||||||
@@ -687,10 +840,25 @@ export class AppChat extends Component {
|
|||||||
const previousMeta = this._previousMetaBefore(bubble);
|
const previousMeta = this._previousMetaBefore(bubble);
|
||||||
bubble.classList.toggle("grouped", shouldGroup(previousMeta, this._bubbleMeta(bubble)));
|
bubble.classList.toggle("grouped", shouldGroup(previousMeta, this._bubbleMeta(bubble)));
|
||||||
|
|
||||||
|
this._normalizeDateSeparators();
|
||||||
this._bumpConversation(frame, true);
|
this._bumpConversation(frame, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_showAiPending(bubble) {
|
||||||
|
this._clearAiPending(bubble);
|
||||||
|
const note = document.createElement("div");
|
||||||
|
note.className = "ai-adjusted-note ai-pending-note";
|
||||||
|
note.textContent = "Adjusting\u2026";
|
||||||
|
bubble.appendChild(note);
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearAiPending(bubble) {
|
||||||
|
const pending = bubble.querySelector(".ai-pending-note");
|
||||||
|
if (pending) pending.remove();
|
||||||
|
}
|
||||||
|
|
||||||
_showAiIndicator(bubble) {
|
_showAiIndicator(bubble) {
|
||||||
|
this._clearAiPending(bubble);
|
||||||
const existing = bubble.querySelector(".ai-adjusted-note");
|
const existing = bubble.querySelector(".ai-adjusted-note");
|
||||||
if (existing) existing.remove();
|
if (existing) existing.remove();
|
||||||
const note = document.createElement("div");
|
const note = document.createElement("div");
|
||||||
@@ -711,7 +879,7 @@ export class AppChat extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_markRead() {
|
_markRead() {
|
||||||
if (this._socketReady && this.withUid && this.socket) {
|
if (this._socketReady && this.withUid && this.socket && this._threadIsActive()) {
|
||||||
this.socket.send({ type: "read", with_uid: this.withUid });
|
this.socket.send({ type: "read", with_uid: this.withUid });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -734,9 +902,16 @@ export class AppChat extends Component {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const preview = item.querySelector(".conversation-preview");
|
const preview = item.querySelector(".conversation-preview");
|
||||||
if (preview) preview.textContent = contentRenderer.preview(frame.content, 60);
|
if (preview) preview.textContent = contentRenderer.preview(frame.content, 60) || (frame.attachments && frame.attachments.length ? "Attachment" : "");
|
||||||
const dot = item.querySelector(".conversation-unread-dot");
|
const dot = item.querySelector(".conversation-unread-dot");
|
||||||
if (dot) dot.hidden = mine || partnerUid === this.withUid;
|
if (dot) dot.hidden = mine || (partnerUid === this.withUid && this._threadIsActive());
|
||||||
|
const time = item.querySelector(".conversation-time");
|
||||||
|
if (time && frame.created_at) {
|
||||||
|
time.setAttribute("datetime", frame.created_at);
|
||||||
|
time.dataset.dt = "";
|
||||||
|
time.dataset.dtMode = "ago";
|
||||||
|
if (window.app && window.app.localTime) window.app.localTime.apply(time);
|
||||||
|
}
|
||||||
const list = item.parentElement;
|
const list = item.parentElement;
|
||||||
if (list && list.firstElementChild !== item) {
|
if (list && list.firstElementChild !== item) {
|
||||||
list.insertBefore(item, list.firstElementChild);
|
list.insertBefore(item, list.firstElementChild);
|
||||||
@@ -807,12 +982,7 @@ export class AppChat extends Component {
|
|||||||
}
|
}
|
||||||
item.appendChild(time);
|
item.appendChild(time);
|
||||||
|
|
||||||
item.addEventListener("click", () => {
|
this._bindConversationClick(item);
|
||||||
if (!this._isMobile()) return;
|
|
||||||
this._activePane = "thread";
|
|
||||||
this._applyPane();
|
|
||||||
});
|
|
||||||
|
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -868,7 +1038,12 @@ export class AppChat extends Component {
|
|||||||
if (this._stabilizePending) return;
|
if (this._stabilizePending) return;
|
||||||
const atBottom = this.thread.scrollHeight - this.thread.scrollTop - this.thread.clientHeight < AUTO_SCROLL_MARGIN_PX;
|
const atBottom = this.thread.scrollHeight - this.thread.scrollTop - this.thread.clientHeight < AUTO_SCROLL_MARGIN_PX;
|
||||||
this._userAtBottom = atBottom;
|
this._userAtBottom = atBottom;
|
||||||
if (atBottom) this._stabilizeScroll();
|
if (atBottom) {
|
||||||
|
this._newCount = 0;
|
||||||
|
this._updateJumpButton();
|
||||||
|
this._stabilizeScroll();
|
||||||
|
}
|
||||||
|
if (this.thread.scrollTop < 48) this._loadOlder();
|
||||||
}, { passive: true });
|
}, { passive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -877,10 +1052,12 @@ export class AppChat extends Component {
|
|||||||
const viewport = window.visualViewport;
|
const viewport = window.visualViewport;
|
||||||
|
|
||||||
const ensureInputVisible = () => {
|
const ensureInputVisible = () => {
|
||||||
const vh = viewport ? viewport.height : window.innerHeight;
|
const visibleBottom = viewport
|
||||||
|
? viewport.offsetTop + viewport.height
|
||||||
|
: window.innerHeight;
|
||||||
const formRect = this.form.getBoundingClientRect();
|
const formRect = this.form.getBoundingClientRect();
|
||||||
const currentInset = parseInt(this.style.getPropertyValue("--kb-inset")) || 0;
|
const currentInset = parseInt(this.style.getPropertyValue("--kb-inset"), 10) || 0;
|
||||||
const delta = formRect.bottom - vh;
|
const delta = formRect.bottom - visibleBottom;
|
||||||
const newInset = Math.max(0, currentInset + delta);
|
const newInset = Math.max(0, currentInset + delta);
|
||||||
if (newInset !== currentInset) {
|
if (newInset !== currentInset) {
|
||||||
this.style.setProperty("--kb-inset", `${Math.round(newInset)}px`);
|
this.style.setProperty("--kb-inset", `${Math.round(newInset)}px`);
|
||||||
@@ -916,9 +1093,10 @@ export class AppChat extends Component {
|
|||||||
|
|
||||||
this.input.addEventListener("focus", () => {
|
this.input.addEventListener("focus", () => {
|
||||||
this._userAtBottom = true;
|
this._userAtBottom = true;
|
||||||
|
ensureInputVisible();
|
||||||
const doScroll = () => {
|
const doScroll = () => {
|
||||||
this._scrollThreadToEnd();
|
this._scrollThreadToEnd();
|
||||||
if (window.innerWidth < MOBILE_BREAKPOINT_PX) {
|
if (this._isMobile()) {
|
||||||
const retry = (delay) => window.setTimeout(() => {
|
const retry = (delay) => window.setTimeout(() => {
|
||||||
ensureInputVisible();
|
ensureInputVisible();
|
||||||
this._scrollThreadToEnd();
|
this._scrollThreadToEnd();
|
||||||
@@ -931,7 +1109,9 @@ export class AppChat extends Component {
|
|||||||
requestAnimationFrame(doScroll);
|
requestAnimationFrame(doScroll);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.input.addEventListener("blur", () => {
|
this.input.addEventListener("blur", (event) => {
|
||||||
|
const next = event.relatedTarget;
|
||||||
|
if (next && this.form && this.form.contains(next)) return;
|
||||||
this.style.setProperty("--kb-inset", "0px");
|
this.style.setProperty("--kb-inset", "0px");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -942,8 +1122,21 @@ export class AppChat extends Component {
|
|||||||
return window.innerWidth <= MOBILE_BREAKPOINT_PX;
|
return window.innerWidth <= MOBILE_BREAKPOINT_PX;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_isCoarse() {
|
||||||
|
if (!this._coarseQuery) {
|
||||||
|
this._coarseQuery = window.matchMedia("(hover: none) and (pointer: coarse)");
|
||||||
|
}
|
||||||
|
return this._coarseQuery.matches;
|
||||||
|
}
|
||||||
|
|
||||||
_applyPane() {
|
_applyPane() {
|
||||||
if (!this._isMobile() || !this.list || !this.main) return;
|
if (!this.list || !this.main) return;
|
||||||
|
this.classList.toggle("show-list", this._isMobile() && this._activePane === "list");
|
||||||
|
if (!this._isMobile()) {
|
||||||
|
this.list.classList.remove("hide");
|
||||||
|
this.main.classList.remove("hide");
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.list.classList.toggle("hide", this._activePane !== "list");
|
this.list.classList.toggle("hide", this._activePane !== "list");
|
||||||
this.main.classList.toggle("hide", this._activePane !== "thread");
|
this.main.classList.toggle("hide", this._activePane !== "thread");
|
||||||
}
|
}
|
||||||
@@ -956,32 +1149,358 @@ export class AppChat extends Component {
|
|||||||
if (this.backBtn) {
|
if (this.backBtn) {
|
||||||
this.backBtn.addEventListener("click", () => {
|
this.backBtn.addEventListener("click", () => {
|
||||||
if (!this._isMobile()) return;
|
if (!this._isMobile()) return;
|
||||||
this._activePane = "list";
|
this._showList();
|
||||||
this._applyPane();
|
|
||||||
window.history.replaceState(null, "", "/messages");
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.list.querySelectorAll(".conversation-item").forEach((item) => {
|
this.list.querySelectorAll(".conversation-item").forEach((item) => {
|
||||||
item.addEventListener("click", () => {
|
this._bindConversationClick(item);
|
||||||
if (!this._isMobile()) return;
|
|
||||||
this._activePane = "thread";
|
|
||||||
this._applyPane();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this._mobileQuery = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT_PX}px)`);
|
this._mobileQuery = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT_PX}px)`);
|
||||||
this._onMobileChange = () => {
|
this._onMobileChange = () => {
|
||||||
if (!this._isMobile()) {
|
this._applyPane();
|
||||||
this.list.classList.remove("hide");
|
|
||||||
this.main.classList.remove("hide");
|
|
||||||
} else {
|
|
||||||
this._applyPane();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
this._mobileQuery.addEventListener("change", this._onMobileChange);
|
this._mobileQuery.addEventListener("change", this._onMobileChange);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_showList() {
|
||||||
|
this._activePane = "list";
|
||||||
|
this._applyPane();
|
||||||
|
const url = "/messages";
|
||||||
|
if (window.location.pathname + window.location.search !== url) {
|
||||||
|
window.history.replaceState({ withUid: null }, "", url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_bindConversationClick(item) {
|
||||||
|
if (item.dataset.chatBound) return;
|
||||||
|
item.dataset.chatBound = "1";
|
||||||
|
item.addEventListener("click", (event) => {
|
||||||
|
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||||||
|
const uid = item.dataset.convUid;
|
||||||
|
if (!uid) return;
|
||||||
|
event.preventDefault();
|
||||||
|
this._openConversation(uid, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_bindHistory() {
|
||||||
|
if (this.mode === "embed" || this._onPopState) return;
|
||||||
|
this._onPopState = () => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const uid = params.get("with_uid");
|
||||||
|
if (uid) this._openConversation(uid, false);
|
||||||
|
else this._showList();
|
||||||
|
};
|
||||||
|
window.addEventListener("popstate", this._onPopState);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _openConversation(uid, push) {
|
||||||
|
if (!uid || this._opening) return;
|
||||||
|
if (uid === this.withUid && this._activePane === "thread" && this.thread) {
|
||||||
|
this._activePane = "thread";
|
||||||
|
this._applyPane();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (uid === this.withUid && this.thread && this.thread.querySelector(".message-bubble, .messages-empty")) {
|
||||||
|
this._activePane = "thread";
|
||||||
|
this._applyPane();
|
||||||
|
this._markRead();
|
||||||
|
const url = `/messages?with_uid=${encodeURIComponent(uid)}`;
|
||||||
|
if (push && window.location.pathname + window.location.search !== url) {
|
||||||
|
window.history.pushState({ withUid: uid }, "", url);
|
||||||
|
}
|
||||||
|
if (this.input && !this._isCoarse()) this.input.focus({ preventScroll: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._opening = true;
|
||||||
|
try {
|
||||||
|
const data = await Http.getJson(`/messages?with_uid=${encodeURIComponent(uid)}`);
|
||||||
|
this._renderConversation(uid, data);
|
||||||
|
const url = `/messages?with_uid=${encodeURIComponent(uid)}`;
|
||||||
|
if (push) window.history.pushState({ withUid: uid }, "", url);
|
||||||
|
else window.history.replaceState({ withUid: uid }, "", url);
|
||||||
|
this._activePane = "thread";
|
||||||
|
this._applyPane();
|
||||||
|
this._markRead();
|
||||||
|
this._scrollThreadToEnd();
|
||||||
|
} catch {
|
||||||
|
window.location.href = `/messages?with_uid=${encodeURIComponent(uid)}`;
|
||||||
|
} finally {
|
||||||
|
this._opening = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderConversation(uid, data) {
|
||||||
|
this.withUid = uid;
|
||||||
|
this.setAttribute("with-uid", uid);
|
||||||
|
this._ensureMainShell(data.other_user || {});
|
||||||
|
const receiver = this.form ? this.form.querySelector('input[name="receiver_uid"]') : null;
|
||||||
|
if (receiver) receiver.value = uid;
|
||||||
|
this._fillHeader(data.other_user || {});
|
||||||
|
this._fillThread(data.messages || []);
|
||||||
|
this.querySelectorAll(".conversation-item").forEach((item) => {
|
||||||
|
const active = item.dataset.convUid === uid;
|
||||||
|
item.classList.toggle("active", active);
|
||||||
|
if (active) item.setAttribute("aria-current", "true");
|
||||||
|
else item.removeAttribute("aria-current");
|
||||||
|
const dot = item.querySelector(".conversation-unread-dot");
|
||||||
|
if (dot && active) dot.hidden = true;
|
||||||
|
});
|
||||||
|
this._newCount = 0;
|
||||||
|
this._updateJumpButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
_ensureMainShell(otherUser) {
|
||||||
|
if (this.thread && this.form) return;
|
||||||
|
const empty = this.main ? this.main.querySelector(".messages-empty") : null;
|
||||||
|
if (empty) empty.remove();
|
||||||
|
if (!this.main) {
|
||||||
|
this._ensureSkeleton();
|
||||||
|
this._adopt();
|
||||||
|
this._initComposer();
|
||||||
|
this._ensureConnectionBanner();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.main.innerHTML = "";
|
||||||
|
this.main.appendChild(this._buildHeader(otherUser));
|
||||||
|
this.thread = document.createElement("div");
|
||||||
|
this.thread.className = "messages-thread";
|
||||||
|
this.thread.setAttribute("role", "log");
|
||||||
|
this.thread.setAttribute("aria-label", "Message transcript");
|
||||||
|
this.thread.setAttribute("aria-live", "polite");
|
||||||
|
this.thread.setAttribute("aria-relevant", "additions");
|
||||||
|
this.typingEl = document.createElement("div");
|
||||||
|
this.typingEl.className = "typing-indicator";
|
||||||
|
this.typingEl.id = "typing-indicator";
|
||||||
|
this.typingEl.hidden = true;
|
||||||
|
this.typingEl.append(document.createElement("span"), document.createElement("span"), document.createElement("span"));
|
||||||
|
this.thread.appendChild(this.typingEl);
|
||||||
|
this.form = this._buildComposer(otherUser.uid || this.withUid);
|
||||||
|
this.main.append(this.thread, this.form);
|
||||||
|
this._adopt();
|
||||||
|
this._initComposer();
|
||||||
|
this._wireContentEnhancer();
|
||||||
|
this._bindAutoScroll();
|
||||||
|
this._startScrollWatcher();
|
||||||
|
this._ensureJumpButton();
|
||||||
|
this._capAttachments();
|
||||||
|
this._ensureConnectionBanner();
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildHeader(user) {
|
||||||
|
const header = document.createElement("div");
|
||||||
|
header.className = "messages-main-header";
|
||||||
|
const back = document.createElement("button");
|
||||||
|
back.type = "button";
|
||||||
|
back.className = "messages-back-btn";
|
||||||
|
back.id = "messages-back-btn";
|
||||||
|
back.setAttribute("aria-label", "Back to conversations");
|
||||||
|
back.innerHTML = "←";
|
||||||
|
back.addEventListener("click", () => {
|
||||||
|
if (!this._isMobile()) return;
|
||||||
|
this._showList();
|
||||||
|
});
|
||||||
|
this.backBtn = back;
|
||||||
|
header.appendChild(back);
|
||||||
|
const info = document.createElement("div");
|
||||||
|
info.className = "messages-header-info";
|
||||||
|
const title = document.createElement("h3");
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = `/profile/${encodeURIComponent(user.username || "")}`;
|
||||||
|
link.textContent = user.username || "";
|
||||||
|
title.appendChild(link);
|
||||||
|
const presence = document.createElement("span");
|
||||||
|
presence.className = "messages-presence";
|
||||||
|
presence.id = "messages-presence";
|
||||||
|
presence.setAttribute("role", "status");
|
||||||
|
presence.setAttribute("aria-live", "polite");
|
||||||
|
presence.setAttribute("data-presence-label", "");
|
||||||
|
presence.setAttribute("data-presence-uid", user.uid || "");
|
||||||
|
presence.setAttribute("data-presence-last-seen", user.last_seen || "");
|
||||||
|
presence.textContent = "offline";
|
||||||
|
info.append(title, presence);
|
||||||
|
header.appendChild(info);
|
||||||
|
return header;
|
||||||
|
}
|
||||||
|
|
||||||
|
_fillHeader(user) {
|
||||||
|
const header = this.querySelector(".messages-main-header");
|
||||||
|
if (!header) return;
|
||||||
|
const avatarHost = header.querySelector(".user-avatar-link, .avatar-badge");
|
||||||
|
if (avatarHost && user.uid) {
|
||||||
|
const badge = Avatar.badgeElement(user);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = `/profile/${encodeURIComponent(user.username || "")}`;
|
||||||
|
link.className = "user-avatar-link";
|
||||||
|
link.appendChild(badge);
|
||||||
|
avatarHost.replaceWith(link);
|
||||||
|
}
|
||||||
|
const nameLink = header.querySelector(".messages-header-info h3 a, .messages-header-info a");
|
||||||
|
if (nameLink) {
|
||||||
|
nameLink.href = `/profile/${encodeURIComponent(user.username || "")}`;
|
||||||
|
nameLink.textContent = user.username || "";
|
||||||
|
}
|
||||||
|
const presence = header.querySelector("#messages-presence, .messages-presence");
|
||||||
|
if (presence) {
|
||||||
|
presence.setAttribute("data-presence-uid", user.uid || "");
|
||||||
|
presence.setAttribute("data-presence-last-seen", user.last_seen || "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_wireContentEnhancer() {
|
||||||
|
if (!window.app || !window.app.content) return;
|
||||||
|
window.app.content.initEmojiPickers();
|
||||||
|
window.app.content.initMentionInputs();
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildComposer(receiverUid) {
|
||||||
|
const form = document.createElement("form");
|
||||||
|
form.className = "messages-input-area";
|
||||||
|
form.setAttribute("method", "POST");
|
||||||
|
form.setAttribute("action", this.sendUrl);
|
||||||
|
form.setAttribute("data-live-form", "");
|
||||||
|
const receiverInput = document.createElement("input");
|
||||||
|
receiverInput.type = "hidden";
|
||||||
|
receiverInput.name = "receiver_uid";
|
||||||
|
receiverInput.value = receiverUid || "";
|
||||||
|
this.input = document.createElement("textarea");
|
||||||
|
this.input.name = "content";
|
||||||
|
this.input.rows = 1;
|
||||||
|
this.input.maxLength = 2000;
|
||||||
|
this.input.autocomplete = "off";
|
||||||
|
this.input.placeholder = "Type a message...";
|
||||||
|
this.input.setAttribute("aria-label", "Type a message");
|
||||||
|
this.input.className = "emoji-picker-target";
|
||||||
|
this.input.setAttribute("data-mention", "");
|
||||||
|
this.upload = document.createElement("dp-upload");
|
||||||
|
this.upload.setAttribute("multiple", "");
|
||||||
|
this.upload.setAttribute("paste", "");
|
||||||
|
this.upload.setAttribute("max-files", String(this.maxAttachments));
|
||||||
|
this.sendBtn = document.createElement("button");
|
||||||
|
this.sendBtn.type = "submit";
|
||||||
|
this.sendBtn.className = "messages-send-btn";
|
||||||
|
this.sendBtn.setAttribute("aria-label", "Send");
|
||||||
|
const sendIcon = document.createElement("span");
|
||||||
|
sendIcon.className = "send-icon";
|
||||||
|
sendIcon.textContent = "\u27A4";
|
||||||
|
const sendSpinner = document.createElement("span");
|
||||||
|
sendSpinner.className = "send-spinner";
|
||||||
|
sendSpinner.setAttribute("aria-hidden", "true");
|
||||||
|
this.sendBtn.append(sendIcon, sendSpinner);
|
||||||
|
form.append(receiverInput, this.input, this.upload, this.sendBtn);
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
|
||||||
|
_fillThread(items) {
|
||||||
|
if (!this.thread) return;
|
||||||
|
this.thread.querySelectorAll(".message-bubble").forEach((el) => el.remove());
|
||||||
|
for (const item of items) {
|
||||||
|
const message = item.message || item;
|
||||||
|
const bubble = this._buildBubble({
|
||||||
|
content: message.content || "",
|
||||||
|
mine: !!item.is_mine,
|
||||||
|
iso: message.created_at,
|
||||||
|
uid: message.uid,
|
||||||
|
senderRole: (item.sender && item.sender.role) || "",
|
||||||
|
attachments: item.attachments || [],
|
||||||
|
});
|
||||||
|
if (item.grouped) bubble.classList.add("grouped");
|
||||||
|
if (item.is_mine && message.read) {
|
||||||
|
const receipt = bubble.querySelector(".message-receipt");
|
||||||
|
if (receipt) receipt.hidden = false;
|
||||||
|
}
|
||||||
|
if (this.typingEl && this.typingEl.parentElement === this.thread) {
|
||||||
|
this.thread.insertBefore(bubble, this.typingEl);
|
||||||
|
} else {
|
||||||
|
this.thread.appendChild(bubble);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._hasOlder = items.length >= CONVERSATION_PAGE_SIZE;
|
||||||
|
this._userAtBottom = true;
|
||||||
|
this._normalizeDateSeparators();
|
||||||
|
}
|
||||||
|
|
||||||
|
_ensureJumpButton() {
|
||||||
|
if (!this.main || this._jumpBtn) return;
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.className = "messages-jump-new";
|
||||||
|
btn.hidden = true;
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
this._userAtBottom = true;
|
||||||
|
this._newCount = 0;
|
||||||
|
this._updateJumpButton();
|
||||||
|
this._scrollThreadToEnd();
|
||||||
|
});
|
||||||
|
this.main.appendChild(btn);
|
||||||
|
this._jumpBtn = btn;
|
||||||
|
this._updateJumpButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
_noteNewMessage() {
|
||||||
|
this._newCount += 1;
|
||||||
|
this._updateJumpButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateJumpButton() {
|
||||||
|
if (!this._jumpBtn) return;
|
||||||
|
if (!this._newCount) {
|
||||||
|
this._jumpBtn.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._jumpBtn.hidden = false;
|
||||||
|
this._jumpBtn.textContent = this._newCount === 1 ? "New message" : `${this._newCount} new messages`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _loadOlder() {
|
||||||
|
if (!this.thread || !this.withUid || this._loadingOlder || !this._hasOlder) return;
|
||||||
|
const oldest = this.thread.querySelector(".message-bubble .message-time[datetime], .message-bubble .message-time time[datetime]");
|
||||||
|
const before = oldest ? oldest.getAttribute("datetime") : "";
|
||||||
|
if (!before) {
|
||||||
|
this._hasOlder = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._loadingOlder = true;
|
||||||
|
const previousHeight = this.thread.scrollHeight;
|
||||||
|
const previousTop = this.thread.scrollTop;
|
||||||
|
try {
|
||||||
|
const data = await Http.getJson(`/messages?with_uid=${encodeURIComponent(this.withUid)}&before=${encodeURIComponent(before)}`);
|
||||||
|
const items = data.messages || [];
|
||||||
|
if (!items.length) {
|
||||||
|
this._hasOlder = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const first = this.thread.querySelector(".message-bubble");
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (const item of items) {
|
||||||
|
const message = item.message || item;
|
||||||
|
if (message.uid && this.thread.querySelector(`.message-bubble[data-msg-uid="${message.uid}"]`)) continue;
|
||||||
|
const bubble = this._buildBubble({
|
||||||
|
content: message.content || "",
|
||||||
|
mine: !!item.is_mine,
|
||||||
|
iso: message.created_at,
|
||||||
|
uid: message.uid,
|
||||||
|
senderRole: (item.sender && item.sender.role) || "",
|
||||||
|
attachments: item.attachments || [],
|
||||||
|
});
|
||||||
|
if (item.grouped) bubble.classList.add("grouped");
|
||||||
|
fragment.appendChild(bubble);
|
||||||
|
}
|
||||||
|
if (fragment.childNodes.length) {
|
||||||
|
this.thread.insertBefore(fragment, first || this.typingEl || null);
|
||||||
|
}
|
||||||
|
this._normalizeDateSeparators();
|
||||||
|
this._hasOlder = items.length >= CONVERSATION_PAGE_SIZE;
|
||||||
|
this.thread.scrollTop = previousTop + (this.thread.scrollHeight - previousHeight);
|
||||||
|
} catch {
|
||||||
|
this._hasOlder = false;
|
||||||
|
} finally {
|
||||||
|
this._loadingOlder = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_initSearch() {
|
_initSearch() {
|
||||||
if (this.mode === "embed" || !this.searchInput) return;
|
if (this.mode === "embed" || !this.searchInput) return;
|
||||||
const wrap = this.searchInput.parentElement;
|
const wrap = this.searchInput.parentElement;
|
||||||
@@ -1001,7 +1520,10 @@ export class AppChat extends Component {
|
|||||||
chooseOnTab: false,
|
chooseOnTab: false,
|
||||||
isOpen: () => DomUtils.isShown(dropdown),
|
isOpen: () => DomUtils.isShown(dropdown),
|
||||||
onChoose: (item) => {
|
onChoose: (item) => {
|
||||||
window.location.href = item.href;
|
hide();
|
||||||
|
const uid = new URL(item.href, window.location.origin).searchParams.get("with_uid");
|
||||||
|
if (uid) this._openConversation(uid, true);
|
||||||
|
else window.location.href = item.href;
|
||||||
},
|
},
|
||||||
onEscape: hide,
|
onEscape: hide,
|
||||||
});
|
});
|
||||||
@@ -1041,6 +1563,15 @@ export class AppChat extends Component {
|
|||||||
}, SEARCH_DEBOUNCE_MS);
|
}, SEARCH_DEBOUNCE_MS);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
dropdown.addEventListener("click", (event) => {
|
||||||
|
const item = event.target.closest(".search-dropdown-item");
|
||||||
|
if (!item) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const uid = new URL(item.href, window.location.origin).searchParams.get("with_uid");
|
||||||
|
hide();
|
||||||
|
if (uid) this._openConversation(uid, true);
|
||||||
|
});
|
||||||
|
|
||||||
document.addEventListener("click", (event) => {
|
document.addEventListener("click", (event) => {
|
||||||
if (!wrap.contains(event.target)) hide();
|
if (!wrap.contains(event.target)) hide();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content">
|
||||||
<meta name="theme-color" content="#271b5b">
|
<meta name="theme-color" content="#271b5b">
|
||||||
<meta name="asset-version" content="{{ static_version }}">
|
<meta name="asset-version" content="{{ static_version }}">
|
||||||
<title>{% if page_title %}{{ page_title }}{% else %}DevPlace - The Developer Social Network{% endif %}</title>
|
<title>{% if page_title %}{{ page_title }}{% else %}DevPlace - The Developer Social Network{% endif %}</title>
|
||||||
|
|||||||
Reference in New Issue
Block a user