|
|
|
@@ -4,6 +4,7 @@ import { Component } from "./Component.js";
|
|
|
|
|
import { assetUrl } from "../assetVersion.js";
|
|
|
|
|
import { ChatSocket } from "../chat/ChatSocket.js";
|
|
|
|
|
import { shouldGroup } from "../chat/MessageGrouping.js";
|
|
|
|
|
import { dateKey, dateLabel } from "../chat/DateSeparators.js";
|
|
|
|
|
import { Http } from "../Http.js";
|
|
|
|
|
import { Avatar } from "../Avatar.js";
|
|
|
|
|
import { DomUtils } from "../DomUtils.js";
|
|
|
|
@@ -17,11 +18,12 @@ const TYPING_THROTTLE_MS = 1500;
|
|
|
|
|
const TYPING_HIDE_MS = 4000;
|
|
|
|
|
const AUTO_SCROLL_MARGIN_PX = 100;
|
|
|
|
|
const STABILIZE_MAX_FRAMES = 300;
|
|
|
|
|
const SEND_TIMEOUT_MS = 8000;
|
|
|
|
|
const SEND_TIMEOUT_MS = 20000;
|
|
|
|
|
const MOBILE_BREAKPOINT_PX = 768;
|
|
|
|
|
const AI_INDICATOR_MS = 4000;
|
|
|
|
|
const SEARCH_DEBOUNCE_MS = 200;
|
|
|
|
|
const AUTO_GROW_MAX_HEIGHT_PX = 160;
|
|
|
|
|
const CONVERSATION_PAGE_SIZE = 500;
|
|
|
|
|
|
|
|
|
|
const LIGHT_THEME_OVERRIDES = {
|
|
|
|
|
"--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._autoGrowFrame = 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() {
|
|
|
|
@@ -73,18 +83,23 @@ export class AppChat extends Component {
|
|
|
|
|
this._applyThemeOverrides();
|
|
|
|
|
this._adopt();
|
|
|
|
|
this._ensureSkeleton();
|
|
|
|
|
this._ensureConnectionBanner();
|
|
|
|
|
this._normalizeDateSeparators();
|
|
|
|
|
this._capAttachments();
|
|
|
|
|
this._initComposer();
|
|
|
|
|
this._initRetry();
|
|
|
|
|
this._initSearch();
|
|
|
|
|
this._initMobilePane();
|
|
|
|
|
this._bindHistory();
|
|
|
|
|
this._bindViewport();
|
|
|
|
|
this._bindAutoScroll();
|
|
|
|
|
this._startScrollWatcher();
|
|
|
|
|
this._ensureJumpButton();
|
|
|
|
|
this._connect();
|
|
|
|
|
this._initPresence();
|
|
|
|
|
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() {
|
|
|
|
@@ -97,11 +112,13 @@ export class AppChat extends Component {
|
|
|
|
|
this._mobileQuery.removeEventListener("change", this._onMobileChange);
|
|
|
|
|
}
|
|
|
|
|
clearTimeout(this._typingHideTimer);
|
|
|
|
|
clearTimeout(this._disconnectTimer);
|
|
|
|
|
cancelAnimationFrame(this._autoGrowFrame);
|
|
|
|
|
for (const entry of this._pendingSends.values()) clearTimeout(entry.timeoutId);
|
|
|
|
|
this._pendingSends.clear();
|
|
|
|
|
this._failedSends.clear();
|
|
|
|
|
if (this._presence) this._presence.stop();
|
|
|
|
|
if (this._onPopState) window.removeEventListener("popstate", this._onPopState);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_readConfig() {
|
|
|
|
@@ -263,7 +280,7 @@ export class AppChat extends Component {
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
this.input.addEventListener("keydown", (event) => {
|
|
|
|
|
if (event.key === "Enter" && !event.shiftKey) {
|
|
|
|
|
if (event.key === "Enter" && !event.shiftKey && !this._isCoarse()) {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
this.form.requestSubmit();
|
|
|
|
|
}
|
|
|
|
@@ -319,7 +336,7 @@ export class AppChat extends Component {
|
|
|
|
|
|
|
|
|
|
_refreshSendButton() {
|
|
|
|
|
if (!this.sendBtn) return;
|
|
|
|
|
const busy = this._uploading || this._pendingSends.size > 0;
|
|
|
|
|
const busy = this._uploading;
|
|
|
|
|
this.sendBtn.disabled = busy;
|
|
|
|
|
this.sendBtn.classList.toggle("is-sending", busy);
|
|
|
|
|
}
|
|
|
|
@@ -346,6 +363,7 @@ export class AppChat extends Component {
|
|
|
|
|
if (hint) hint.remove();
|
|
|
|
|
} else {
|
|
|
|
|
bubble = this._appendOptimisticBubble(content, clientId, attachmentUids.length);
|
|
|
|
|
if (/@ai\s+\S/i.test(content)) this._showAiPending(bubble);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ok = !!(this.socket && this.socket.send({
|
|
|
|
@@ -428,6 +446,7 @@ export class AppChat extends Component {
|
|
|
|
|
|
|
|
|
|
const el = document.createElement("dp-content");
|
|
|
|
|
if (senderRole === "Admin") el.setAttribute("data-author-admin", "");
|
|
|
|
|
el.dataset.source = content || "";
|
|
|
|
|
el.textContent = content || "";
|
|
|
|
|
bubble.appendChild(el);
|
|
|
|
|
|
|
|
|
@@ -461,27 +480,61 @@ export class AppChat extends Component {
|
|
|
|
|
receipt.hidden = true;
|
|
|
|
|
receipt.innerHTML = "✓✓";
|
|
|
|
|
bubble.appendChild(receipt);
|
|
|
|
|
} else if (uid) {
|
|
|
|
|
bubble.appendChild(this._reportButton(uid));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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) {
|
|
|
|
|
if (!attachments || !attachments.length) return null;
|
|
|
|
|
const single = attachments.length === 1;
|
|
|
|
|
const gallery = document.createElement("div");
|
|
|
|
|
gallery.className = "attachment-gallery";
|
|
|
|
|
gallery.className = `attachment-gallery${single ? " single" : ""}`;
|
|
|
|
|
for (const att of attachments) {
|
|
|
|
|
const item = document.createElement("div");
|
|
|
|
|
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) {
|
|
|
|
|
const img = document.createElement("img");
|
|
|
|
|
img.src = att.thumbnail_url || att.url;
|
|
|
|
|
img.alt = att.original_filename || "";
|
|
|
|
|
img.src = (single || !att.thumbnail_url) ? (att.url || att.thumbnail_url) : att.thumbnail_url;
|
|
|
|
|
img.alt = filename;
|
|
|
|
|
img.loading = "lazy";
|
|
|
|
|
img.className = "gallery-thumb";
|
|
|
|
|
img.dataset.lightbox = "";
|
|
|
|
|
img.dataset.full = att.url;
|
|
|
|
|
if (att.mime_type) img.dataset.mime = att.mime_type;
|
|
|
|
|
img.dataset.full = att.url || att.thumbnail_url || "";
|
|
|
|
|
if (mime) img.dataset.mime = mime;
|
|
|
|
|
item.appendChild(img);
|
|
|
|
|
} else if (att.is_video) {
|
|
|
|
|
const video = document.createElement("video");
|
|
|
|
@@ -490,7 +543,7 @@ export class AppChat extends Component {
|
|
|
|
|
video.preload = "metadata";
|
|
|
|
|
video.className = "gallery-video";
|
|
|
|
|
item.appendChild(video);
|
|
|
|
|
} else if (att.is_audio) {
|
|
|
|
|
} else if (isAudio) {
|
|
|
|
|
const audio = document.createElement("audio");
|
|
|
|
|
audio.src = att.url;
|
|
|
|
|
audio.controls = true;
|
|
|
|
@@ -503,8 +556,8 @@ export class AppChat extends Component {
|
|
|
|
|
link.target = "_blank";
|
|
|
|
|
link.rel = "noopener";
|
|
|
|
|
link.className = "non-image";
|
|
|
|
|
link.download = att.original_filename || "file";
|
|
|
|
|
link.textContent = att.original_filename || "file";
|
|
|
|
|
link.download = filename;
|
|
|
|
|
link.textContent = filename;
|
|
|
|
|
item.appendChild(link);
|
|
|
|
|
}
|
|
|
|
|
gallery.appendChild(item);
|
|
|
|
@@ -522,9 +575,34 @@ export class AppChat extends Component {
|
|
|
|
|
} else {
|
|
|
|
|
this.thread.appendChild(bubble);
|
|
|
|
|
}
|
|
|
|
|
this._normalizeDateSeparators();
|
|
|
|
|
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() {
|
|
|
|
|
const url = await this._resolveWsUrl();
|
|
|
|
|
if (!url) return;
|
|
|
|
@@ -533,6 +611,7 @@ export class AppChat extends Component {
|
|
|
|
|
onMessage: (frame) => this.onFrame(frame),
|
|
|
|
|
onClose: () => {
|
|
|
|
|
this._socketReady = false;
|
|
|
|
|
this._noteDisconnected();
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
this.socket.connect();
|
|
|
|
@@ -568,7 +647,69 @@ export class AppChat extends Component {
|
|
|
|
|
onReady(payload) {
|
|
|
|
|
this._socketReady = true;
|
|
|
|
|
if (payload && payload.user_uid) this.selfUid = payload.user_uid;
|
|
|
|
|
this._noteConnected();
|
|
|
|
|
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) {
|
|
|
|
@@ -629,21 +770,24 @@ export class AppChat extends Component {
|
|
|
|
|
senderRole: frame.sender_role,
|
|
|
|
|
attachments: frame.attachments,
|
|
|
|
|
});
|
|
|
|
|
if (frame.ai_pending) this._showAiPending(bubble);
|
|
|
|
|
this._insertBubble(bubble);
|
|
|
|
|
if (!mine && this._socketReady) {
|
|
|
|
|
if (!mine && this._socketReady && this._threadIsActive()) {
|
|
|
|
|
this.socket.send({ type: "read", with_uid: this.withUid });
|
|
|
|
|
}
|
|
|
|
|
if (!mine && !this._userAtBottom) this._noteNewMessage();
|
|
|
|
|
}
|
|
|
|
|
this._bumpConversation(frame, frame.sender_uid === this.selfUid);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_applyFrameContent(bubble, frame) {
|
|
|
|
|
const oldBody = bubble.querySelector("dp-content");
|
|
|
|
|
const previousContent = oldBody ? oldBody.textContent : "";
|
|
|
|
|
const previousContent = this._contentSource(oldBody);
|
|
|
|
|
let changed = false;
|
|
|
|
|
if (oldBody && frame.content !== undefined && frame.content !== previousContent) {
|
|
|
|
|
const content = document.createElement("dp-content");
|
|
|
|
|
if (frame.sender_role === "Admin") content.setAttribute("data-author-admin", "");
|
|
|
|
|
content.dataset.source = frame.content || "";
|
|
|
|
|
content.textContent = frame.content || "";
|
|
|
|
|
oldBody.replaceWith(content);
|
|
|
|
|
changed = true;
|
|
|
|
@@ -659,8 +803,16 @@ export class AppChat extends Component {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.aiIndicatorEnabled && frame.ai_processed && changed) {
|
|
|
|
|
this._showAiIndicator(bubble);
|
|
|
|
|
if (frame.uid && !bubble.dataset.msgUid) bubble.dataset.msgUid = frame.uid;
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
@@ -673,6 +825,7 @@ export class AppChat extends Component {
|
|
|
|
|
const hint = bubble.querySelector(".retry-hint");
|
|
|
|
|
if (hint) hint.remove();
|
|
|
|
|
|
|
|
|
|
if (frame.ai_pending) this._showAiPending(bubble);
|
|
|
|
|
this._applyFrameContent(bubble, frame);
|
|
|
|
|
|
|
|
|
|
const time = bubble.querySelector(".message-time");
|
|
|
|
@@ -687,10 +840,25 @@ export class AppChat extends Component {
|
|
|
|
|
const previousMeta = this._previousMetaBefore(bubble);
|
|
|
|
|
bubble.classList.toggle("grouped", shouldGroup(previousMeta, this._bubbleMeta(bubble)));
|
|
|
|
|
|
|
|
|
|
this._normalizeDateSeparators();
|
|
|
|
|
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) {
|
|
|
|
|
this._clearAiPending(bubble);
|
|
|
|
|
const existing = bubble.querySelector(".ai-adjusted-note");
|
|
|
|
|
if (existing) existing.remove();
|
|
|
|
|
const note = document.createElement("div");
|
|
|
|
@@ -711,7 +879,7 @@ export class AppChat extends Component {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_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 });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
@@ -734,9 +902,16 @@ export class AppChat extends Component {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
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");
|
|
|
|
|
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;
|
|
|
|
|
if (list && list.firstElementChild !== item) {
|
|
|
|
|
list.insertBefore(item, list.firstElementChild);
|
|
|
|
@@ -807,12 +982,7 @@ export class AppChat extends Component {
|
|
|
|
|
}
|
|
|
|
|
item.appendChild(time);
|
|
|
|
|
|
|
|
|
|
item.addEventListener("click", () => {
|
|
|
|
|
if (!this._isMobile()) return;
|
|
|
|
|
this._activePane = "thread";
|
|
|
|
|
this._applyPane();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this._bindConversationClick(item);
|
|
|
|
|
return item;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -868,7 +1038,12 @@ export class AppChat extends Component {
|
|
|
|
|
if (this._stabilizePending) return;
|
|
|
|
|
const atBottom = this.thread.scrollHeight - this.thread.scrollTop - this.thread.clientHeight < AUTO_SCROLL_MARGIN_PX;
|
|
|
|
|
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 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -877,10 +1052,12 @@ export class AppChat extends Component {
|
|
|
|
|
const viewport = window.visualViewport;
|
|
|
|
|
|
|
|
|
|
const ensureInputVisible = () => {
|
|
|
|
|
const vh = viewport ? viewport.height : window.innerHeight;
|
|
|
|
|
const visibleBottom = viewport
|
|
|
|
|
? viewport.offsetTop + viewport.height
|
|
|
|
|
: window.innerHeight;
|
|
|
|
|
const formRect = this.form.getBoundingClientRect();
|
|
|
|
|
const currentInset = parseInt(this.style.getPropertyValue("--kb-inset")) || 0;
|
|
|
|
|
const delta = formRect.bottom - vh;
|
|
|
|
|
const currentInset = parseInt(this.style.getPropertyValue("--kb-inset"), 10) || 0;
|
|
|
|
|
const delta = formRect.bottom - visibleBottom;
|
|
|
|
|
const newInset = Math.max(0, currentInset + delta);
|
|
|
|
|
if (newInset !== currentInset) {
|
|
|
|
|
this.style.setProperty("--kb-inset", `${Math.round(newInset)}px`);
|
|
|
|
@@ -916,9 +1093,10 @@ export class AppChat extends Component {
|
|
|
|
|
|
|
|
|
|
this.input.addEventListener("focus", () => {
|
|
|
|
|
this._userAtBottom = true;
|
|
|
|
|
ensureInputVisible();
|
|
|
|
|
const doScroll = () => {
|
|
|
|
|
this._scrollThreadToEnd();
|
|
|
|
|
if (window.innerWidth < MOBILE_BREAKPOINT_PX) {
|
|
|
|
|
if (this._isMobile()) {
|
|
|
|
|
const retry = (delay) => window.setTimeout(() => {
|
|
|
|
|
ensureInputVisible();
|
|
|
|
|
this._scrollThreadToEnd();
|
|
|
|
@@ -931,7 +1109,9 @@ export class AppChat extends Component {
|
|
|
|
|
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");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
@@ -942,8 +1122,21 @@ export class AppChat extends Component {
|
|
|
|
|
return window.innerWidth <= MOBILE_BREAKPOINT_PX;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_isCoarse() {
|
|
|
|
|
if (!this._coarseQuery) {
|
|
|
|
|
this._coarseQuery = window.matchMedia("(hover: none) and (pointer: coarse)");
|
|
|
|
|
}
|
|
|
|
|
return this._coarseQuery.matches;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_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.main.classList.toggle("hide", this._activePane !== "thread");
|
|
|
|
|
}
|
|
|
|
@@ -956,32 +1149,358 @@ export class AppChat extends Component {
|
|
|
|
|
if (this.backBtn) {
|
|
|
|
|
this.backBtn.addEventListener("click", () => {
|
|
|
|
|
if (!this._isMobile()) return;
|
|
|
|
|
this._activePane = "list";
|
|
|
|
|
this._applyPane();
|
|
|
|
|
window.history.replaceState(null, "", "/messages");
|
|
|
|
|
this._showList();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.list.querySelectorAll(".conversation-item").forEach((item) => {
|
|
|
|
|
item.addEventListener("click", () => {
|
|
|
|
|
if (!this._isMobile()) return;
|
|
|
|
|
this._activePane = "thread";
|
|
|
|
|
this._applyPane();
|
|
|
|
|
});
|
|
|
|
|
this._bindConversationClick(item);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this._mobileQuery = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT_PX}px)`);
|
|
|
|
|
this._onMobileChange = () => {
|
|
|
|
|
if (!this._isMobile()) {
|
|
|
|
|
this.list.classList.remove("hide");
|
|
|
|
|
this.main.classList.remove("hide");
|
|
|
|
|
} else {
|
|
|
|
|
this._applyPane();
|
|
|
|
|
}
|
|
|
|
|
this._applyPane();
|
|
|
|
|
};
|
|
|
|
|
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() {
|
|
|
|
|
if (this.mode === "embed" || !this.searchInput) return;
|
|
|
|
|
const wrap = this.searchInput.parentElement;
|
|
|
|
@@ -1001,7 +1520,10 @@ export class AppChat extends Component {
|
|
|
|
|
chooseOnTab: false,
|
|
|
|
|
isOpen: () => DomUtils.isShown(dropdown),
|
|
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
@@ -1041,6 +1563,15 @@ export class AppChat extends Component {
|
|
|
|
|
}, 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) => {
|
|
|
|
|
if (!wrap.contains(event.target)) hide();
|
|
|
|
|
});
|
|
|
|
|