@unhingged/vizu-core 0.1.13 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -1275,8 +1275,50 @@ var Popover = class {
1275
1275
  this.mentionPicker = null;
1276
1276
  /** Dismiss handlers wired to document for the open picker; cleared in closeMentionPicker. */
1277
1277
  this.mentionPickerDismiss = null;
1278
+ /** All mentionable users fetched for the open picker — pre-filter list. */
1279
+ this.mentionPickerUsers = [];
1280
+ /** Currently visible subset of `mentionPickerUsers` after the user's filter. */
1281
+ this.mentionPickerFiltered = [];
1282
+ /** Highlighted index in `mentionPickerFiltered`. Up/Down arrows mutate this. */
1283
+ this.mentionPickerSelected = 0;
1284
+ /**
1285
+ * 'manual' = opened from the @ mention button (separate search input).
1286
+ * 'editor' = opened by typing `@` in the editor; filter is derived
1287
+ * from the text after `@` in the editor itself, no search input.
1288
+ */
1289
+ this.mentionPickerMode = "manual";
1290
+ /**
1291
+ * In editor mode: a Range covering `@<query>` in the editor's text.
1292
+ * Used to replace that span with the mention chip on commit, and to
1293
+ * recompute the bounding rect when re-positioning after typing.
1294
+ */
1295
+ this.mentionPickerEditorRange = null;
1278
1296
  this.handleEditorKey = (e) => {
1279
1297
  const editor = e.currentTarget;
1298
+ if (this.mentionPicker && this.mentionPickerMode === "editor") {
1299
+ if (e.key === "ArrowDown") {
1300
+ e.preventDefault();
1301
+ this.moveMentionPickerSelection(1);
1302
+ return;
1303
+ }
1304
+ if (e.key === "ArrowUp") {
1305
+ e.preventDefault();
1306
+ this.moveMentionPickerSelection(-1);
1307
+ return;
1308
+ }
1309
+ if (e.key === "Enter" || e.key === "Tab") {
1310
+ if (this.mentionPickerFiltered.length > 0) {
1311
+ e.preventDefault();
1312
+ this.commitMentionPickerSelection();
1313
+ return;
1314
+ }
1315
+ }
1316
+ if (e.key === "Escape") {
1317
+ e.preventDefault();
1318
+ this.closeMentionPicker();
1319
+ return;
1320
+ }
1321
+ }
1280
1322
  if (e.key === "#") {
1281
1323
  e.preventDefault();
1282
1324
  const savedRange = this.saveRange();
@@ -1299,6 +1341,21 @@ var Popover = class {
1299
1341
  this.callbacks.onClose();
1300
1342
  }
1301
1343
  };
1344
+ /**
1345
+ * Fires after every keystroke in the editor (post-insertion). Looks
1346
+ * for an `@<query>` pattern adjacent to the caret. If found, opens
1347
+ * (or refreshes) the mention picker in editor mode. If the trigger
1348
+ * disappears (user typed a space, backspaced over @, etc.), closes
1349
+ * the picker.
1350
+ */
1351
+ this.handleEditorInput = () => {
1352
+ const trigger = this.detectMentionTrigger();
1353
+ if (trigger) {
1354
+ void this.openOrUpdateEditorMentionPicker(trigger.range, trigger.query);
1355
+ } else if (this.mentionPicker && this.mentionPickerMode === "editor") {
1356
+ this.closeMentionPicker();
1357
+ }
1358
+ };
1302
1359
  this.handleEditorPaste = (e) => {
1303
1360
  if (this.callbacks.canUploadAttachments()) {
1304
1361
  const items = e.clipboardData?.items ?? null;
@@ -1559,6 +1616,7 @@ var Popover = class {
1559
1616
  const editor = this.el.querySelector(".vz-textarea");
1560
1617
  editor.focus();
1561
1618
  editor.addEventListener("keydown", this.handleEditorKey);
1619
+ editor.addEventListener("input", this.handleEditorInput);
1562
1620
  editor.addEventListener("paste", this.handleEditorPaste);
1563
1621
  const wrap = this.el.querySelector(".vz-editor-wrap");
1564
1622
  if (wrap && this.callbacks.canUploadAttachments()) {
@@ -1588,6 +1646,37 @@ var Popover = class {
1588
1646
  <div class="vz-anchor-list">${chips}</div>
1589
1647
  `;
1590
1648
  }
1649
+ /**
1650
+ * Walk back from the caret looking for the most recent `@` that
1651
+ * starts a mention trigger — must be at start-of-text-node or
1652
+ * preceded by whitespace, and there must be no whitespace between
1653
+ * `@` and the caret. Returns the Range covering `@<query>` plus
1654
+ * the bare query string.
1655
+ */
1656
+ detectMentionTrigger() {
1657
+ const sel = window.getSelection();
1658
+ if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return null;
1659
+ const caret = sel.getRangeAt(0);
1660
+ const node = caret.startContainer;
1661
+ if (node.nodeType !== Node.TEXT_NODE) return null;
1662
+ const text = node.textContent ?? "";
1663
+ const offset = caret.startOffset;
1664
+ for (let i = offset - 1; i >= 0; i--) {
1665
+ const ch = text[i];
1666
+ if (ch === "@") {
1667
+ const prev = i > 0 ? text[i - 1] : "";
1668
+ if (prev !== "" && !/\s/.test(prev)) return null;
1669
+ const query = text.slice(i + 1, offset);
1670
+ if (/\s/.test(query)) return null;
1671
+ const range = document.createRange();
1672
+ range.setStart(node, i);
1673
+ range.setEnd(node, offset);
1674
+ return { range, query };
1675
+ }
1676
+ if (/\s/.test(ch)) return null;
1677
+ }
1678
+ return null;
1679
+ }
1591
1680
  async uploadAndAppend(file) {
1592
1681
  if (!this.callbacks.canUploadAttachments()) return;
1593
1682
  const placeholderId = "upload-" + Math.random().toString(36).slice(2, 9);
@@ -1718,54 +1807,195 @@ var Popover = class {
1718
1807
  return parts.join("").trim();
1719
1808
  }
1720
1809
  /**
1721
- * Open a dropdown listing the workspace's mentionable users. The user
1722
- * clicks one to insert a mention chip for them; Esc or an outside
1723
- * click closes the dropdown.
1724
- *
1725
- * The dropdown is fetched fresh on every open so member adds/removes
1726
- * in another tab are picked up without a popover reopen. Falls back
1727
- * to silent no-op if `onMentionSearch` isn't wired (non-cloud
1728
- * adapters) or returns an empty list.
1810
+ * Manual-mode picker: opens from the @ mention button. Renders a
1811
+ * search input above the list; the user types into the input to
1812
+ * filter, arrows + Enter to pick. The selection inserts a mention
1813
+ * chip at the editor's current caret (no `@<query>` replacement —
1814
+ * the user didn't type one).
1729
1815
  */
1730
1816
  async openMentionPicker(anchor) {
1731
1817
  this.closeMentionPicker();
1732
1818
  if (!this.callbacks.onMentionSearch) return;
1819
+ this.mentionPickerMode = "manual";
1820
+ this.mentionPickerEditorRange = null;
1821
+ const picker = this.createMentionPickerShell();
1822
+ this.positionMentionPickerByElement(picker, anchor);
1823
+ const users = await this.fetchMentionables();
1824
+ if (this.mentionPicker !== picker) return;
1825
+ this.mentionPickerUsers = users;
1826
+ this.renderMentionPickerBody("");
1827
+ this.positionMentionPickerByElement(picker, anchor);
1828
+ const input = picker.querySelector(".vz-mention-search");
1829
+ input?.focus();
1830
+ this.wireMentionPickerDismiss();
1831
+ }
1832
+ /**
1833
+ * Editor-mode picker: opens because the user typed `@` in the
1834
+ * editor. No search input — the editor IS the search; the query is
1835
+ * the text after `@`. On commit, replaces the `@<query>` range with
1836
+ * the chip.
1837
+ */
1838
+ async openOrUpdateEditorMentionPicker(range, query) {
1839
+ this.mentionPickerEditorRange = range;
1840
+ if (!this.mentionPicker || this.mentionPickerMode !== "editor") {
1841
+ this.closeMentionPicker();
1842
+ this.mentionPickerMode = "editor";
1843
+ const picker = this.createMentionPickerShell({ showSearch: false });
1844
+ this.positionMentionPickerByRange(picker, range);
1845
+ const users = await this.fetchMentionables();
1846
+ if (this.mentionPicker !== picker) return;
1847
+ this.mentionPickerUsers = users;
1848
+ this.renderMentionPickerBody(query);
1849
+ this.positionMentionPickerByRange(picker, range);
1850
+ this.wireMentionPickerDismiss();
1851
+ return;
1852
+ }
1853
+ this.renderMentionPickerBody(query);
1854
+ this.positionMentionPickerByRange(this.mentionPicker, range);
1855
+ }
1856
+ /**
1857
+ * Build the picker DOM scaffold and attach it to the popover. Does
1858
+ * NOT fetch users or render the body — caller's responsibility.
1859
+ * Search input is hidden in editor mode where the user already has
1860
+ * a perfectly good text-entry surface (the editor itself).
1861
+ */
1862
+ createMentionPickerShell({ showSearch = true } = {}) {
1733
1863
  const picker = document.createElement("div");
1734
1864
  picker.className = "vz-mention-picker";
1735
1865
  picker.setAttribute("data-vz-ignore", "");
1736
- picker.innerHTML = `<div class="vz-mention-loading">Loading\u2026</div>`;
1866
+ picker.innerHTML = `
1867
+ ${showSearch ? `<input class="vz-mention-search" type="text" placeholder="Search teammates\u2026" autocomplete="off" spellcheck="false" />` : ""}
1868
+ <div class="vz-mention-list" data-vz="mention-list">
1869
+ <div class="vz-mention-loading">Loading\u2026</div>
1870
+ </div>
1871
+ `;
1737
1872
  this.el.appendChild(picker);
1738
1873
  this.mentionPicker = picker;
1739
- this.positionMentionPicker(picker, anchor);
1740
- let users = [];
1874
+ this.mentionPickerFiltered = [];
1875
+ this.mentionPickerSelected = 0;
1876
+ picker.addEventListener("mousemove", (e) => {
1877
+ const btn = e.target?.closest(".vz-mention-item");
1878
+ if (!btn) return;
1879
+ const idx = Number(btn.getAttribute("data-mention-index") ?? -1);
1880
+ if (idx >= 0 && idx !== this.mentionPickerSelected) {
1881
+ this.mentionPickerSelected = idx;
1882
+ this.refreshMentionPickerHighlight();
1883
+ }
1884
+ });
1885
+ picker.addEventListener("click", (e) => {
1886
+ const btn = e.target?.closest(".vz-mention-item");
1887
+ if (!btn) return;
1888
+ e.preventDefault();
1889
+ e.stopPropagation();
1890
+ const idx = Number(btn.getAttribute("data-mention-index") ?? -1);
1891
+ if (idx >= 0) {
1892
+ this.mentionPickerSelected = idx;
1893
+ this.commitMentionPickerSelection();
1894
+ }
1895
+ });
1896
+ if (showSearch) {
1897
+ const input = picker.querySelector(".vz-mention-search");
1898
+ input?.addEventListener("input", () => this.renderMentionPickerBody(input.value));
1899
+ input?.addEventListener("keydown", (e) => {
1900
+ if (e.key === "ArrowDown") {
1901
+ e.preventDefault();
1902
+ this.moveMentionPickerSelection(1);
1903
+ } else if (e.key === "ArrowUp") {
1904
+ e.preventDefault();
1905
+ this.moveMentionPickerSelection(-1);
1906
+ } else if (e.key === "Enter" || e.key === "Tab") {
1907
+ if (this.mentionPickerFiltered.length > 0) {
1908
+ e.preventDefault();
1909
+ this.commitMentionPickerSelection();
1910
+ }
1911
+ } else if (e.key === "Escape") {
1912
+ e.preventDefault();
1913
+ this.closeMentionPicker();
1914
+ }
1915
+ });
1916
+ }
1917
+ return picker;
1918
+ }
1919
+ /** Fetch the mentionable list. Swallows errors → []. */
1920
+ async fetchMentionables() {
1921
+ if (!this.callbacks.onMentionSearch) return [];
1741
1922
  try {
1742
- users = await this.callbacks.onMentionSearch();
1923
+ return await this.callbacks.onMentionSearch();
1743
1924
  } catch {
1925
+ return [];
1744
1926
  }
1745
- if (this.mentionPicker !== picker) return;
1746
- if (users.length === 0) {
1747
- picker.innerHTML = `<div class="vz-mention-empty">No teammates to mention yet.</div>`;
1748
- this.positionMentionPicker(picker, anchor);
1749
- this.wireMentionPickerDismiss();
1927
+ }
1928
+ /**
1929
+ * Re-filter the cached users by `query`, render the list, reset
1930
+ * selected to 0. Called on every search input change and on every
1931
+ * editor input event while in editor mode.
1932
+ */
1933
+ renderMentionPickerBody(query) {
1934
+ const picker = this.mentionPicker;
1935
+ if (!picker) return;
1936
+ const list = picker.querySelector('[data-vz="mention-list"]');
1937
+ if (!list) return;
1938
+ const q = query.toLowerCase().trim();
1939
+ this.mentionPickerFiltered = q ? this.mentionPickerUsers.filter((u) => u.name.toLowerCase().includes(q)) : this.mentionPickerUsers.slice();
1940
+ this.mentionPickerSelected = 0;
1941
+ if (this.mentionPickerFiltered.length === 0) {
1942
+ list.innerHTML = `<div class="vz-mention-empty">${this.mentionPickerUsers.length === 0 ? "No teammates to mention yet." : "No teammates match."}</div>`;
1750
1943
  return;
1751
1944
  }
1752
- const itemsHtml = users.map((u) => {
1945
+ list.innerHTML = this.mentionPickerFiltered.map((u, i) => {
1753
1946
  const avatar = u.avatarUrl ? `<img class="vz-mention-avatar" src="${escapeHtml(u.avatarUrl)}" alt="" />` : `<span class="vz-mention-avatar vz-mention-avatar-initials">${escapeHtml(initials(u.name) || "?")}</span>`;
1754
- return `<button class="vz-mention-item" type="button" data-mention-id="${escapeHtml(u.id)}" data-mention-name="${escapeHtml(u.name)}">${avatar}<span class="vz-mention-item-name">${escapeHtml(u.name)}</span></button>`;
1947
+ return `<button class="vz-mention-item${i === 0 ? " is-selected" : ""}" type="button" data-mention-index="${i}">${avatar}<span class="vz-mention-item-name">${escapeHtml(u.name)}</span></button>`;
1755
1948
  }).join("");
1756
- picker.innerHTML = itemsHtml;
1757
- this.positionMentionPicker(picker, anchor);
1758
- picker.addEventListener("click", (e) => {
1759
- const btn = e.target?.closest(".vz-mention-item");
1760
- if (!btn) return;
1761
- e.preventDefault();
1762
- e.stopPropagation();
1763
- const id = btn.getAttribute("data-mention-id") || "";
1764
- const name = btn.getAttribute("data-mention-name") || "";
1765
- if (id && name) this.insertMentionChip({ id, name });
1766
- this.closeMentionPicker();
1767
- });
1768
- this.wireMentionPickerDismiss();
1949
+ }
1950
+ moveMentionPickerSelection(delta) {
1951
+ if (this.mentionPickerFiltered.length === 0) return;
1952
+ const n = this.mentionPickerFiltered.length;
1953
+ this.mentionPickerSelected = (this.mentionPickerSelected + delta + n) % n;
1954
+ this.refreshMentionPickerHighlight();
1955
+ }
1956
+ refreshMentionPickerHighlight() {
1957
+ const picker = this.mentionPicker;
1958
+ if (!picker) return;
1959
+ const items = picker.querySelectorAll(".vz-mention-item");
1960
+ items.forEach((el, i) => el.classList.toggle("is-selected", i === this.mentionPickerSelected));
1961
+ items[this.mentionPickerSelected]?.scrollIntoView({ block: "nearest" });
1962
+ }
1963
+ commitMentionPickerSelection() {
1964
+ const user = this.mentionPickerFiltered[this.mentionPickerSelected];
1965
+ if (!user) return;
1966
+ if (this.mentionPickerMode === "editor" && this.mentionPickerEditorRange) {
1967
+ this.replaceRangeWithMentionChip(this.mentionPickerEditorRange, user);
1968
+ } else {
1969
+ this.insertMentionChip(user);
1970
+ }
1971
+ this.closeMentionPicker();
1972
+ }
1973
+ /**
1974
+ * Swap a Range covering `@<query>` for a mention chip + trailing
1975
+ * space, then place the caret after the space so the user can keep
1976
+ * typing. Used only in editor-trigger mode.
1977
+ */
1978
+ replaceRangeWithMentionChip(range, user) {
1979
+ const editor = this.el.querySelector(".vz-textarea");
1980
+ if (!editor) return;
1981
+ const chip = document.createElement("span");
1982
+ chip.className = "vz-chip vz-chip-mention";
1983
+ chip.contentEditable = "false";
1984
+ chip.setAttribute("data-mention-id", user.id);
1985
+ chip.textContent = "@" + user.name;
1986
+ range.deleteContents();
1987
+ range.insertNode(chip);
1988
+ const space = document.createTextNode(" ");
1989
+ chip.after(space);
1990
+ const sel = window.getSelection();
1991
+ if (sel) {
1992
+ const after = document.createRange();
1993
+ after.setStartAfter(space);
1994
+ after.collapse(true);
1995
+ sel.removeAllRanges();
1996
+ sel.addRange(after);
1997
+ }
1998
+ editor.focus();
1769
1999
  }
1770
2000
  /** Wires Esc + outside-click handlers to close the open picker. */
1771
2001
  wireMentionPickerDismiss() {
@@ -1779,6 +2009,10 @@ var Popover = class {
1779
2009
  const onOutside = (e) => {
1780
2010
  if (!this.mentionPicker) return;
1781
2011
  if (this.mentionPicker.contains(e.target)) return;
2012
+ if (this.mentionPickerMode === "editor") {
2013
+ const editor = this.el.querySelector(".vz-textarea");
2014
+ if (editor && editor.contains(e.target)) return;
2015
+ }
1782
2016
  this.closeMentionPicker();
1783
2017
  };
1784
2018
  document.addEventListener("keydown", onKey, true);
@@ -1797,18 +2031,23 @@ var Popover = class {
1797
2031
  this.mentionPicker.remove();
1798
2032
  this.mentionPicker = null;
1799
2033
  }
2034
+ this.mentionPickerFiltered = [];
2035
+ this.mentionPickerSelected = 0;
2036
+ this.mentionPickerEditorRange = null;
1800
2037
  }
1801
- /**
1802
- * Place the picker below the anchor button. Stays within the
1803
- * popover's right edge so we don't overflow the popover frame.
1804
- */
1805
- positionMentionPicker(picker, anchor) {
2038
+ /** Position the picker below the given anchor (the @ mention button). */
2039
+ positionMentionPickerByElement(picker, anchor) {
1806
2040
  const popRect = this.el.getBoundingClientRect();
1807
2041
  const btnRect = anchor.getBoundingClientRect();
1808
- const left = btnRect.left - popRect.left;
1809
- const top = btnRect.bottom - popRect.top + 4;
1810
- picker.style.left = `${left}px`;
1811
- picker.style.top = `${top}px`;
2042
+ picker.style.left = `${btnRect.left - popRect.left}px`;
2043
+ picker.style.top = `${btnRect.bottom - popRect.top + 4}px`;
2044
+ }
2045
+ /** Position the picker below the `@<query>` text range in the editor. */
2046
+ positionMentionPickerByRange(picker, range) {
2047
+ const popRect = this.el.getBoundingClientRect();
2048
+ const rect = range.getBoundingClientRect();
2049
+ picker.style.left = `${rect.left - popRect.left}px`;
2050
+ picker.style.top = `${rect.bottom - popRect.top + 4}px`;
1812
2051
  }
1813
2052
  /**
1814
2053
  * Insert a mention chip for the given user into the editor at the
@@ -2687,6 +2926,26 @@ var STYLES = `
2687
2926
  flex-direction: column;
2688
2927
  gap: 1px;
2689
2928
  }
2929
+ .vz-mention-search {
2930
+ width: 100%;
2931
+ padding: 7px 9px;
2932
+ margin-bottom: 4px;
2933
+ background: rgba(255,255,255,0.04);
2934
+ border: 1px solid var(--vz-border, rgba(255,255,255,0.08));
2935
+ border-radius: 6px;
2936
+ color: #fafafa;
2937
+ font-size: 13px;
2938
+ outline: none;
2939
+ box-sizing: border-box;
2940
+ }
2941
+ .vz-mention-search:focus { border-color: var(--vz-accent, #ff8b6e); }
2942
+ .vz-mention-search::placeholder { color: var(--vz-text-muted); }
2943
+ .vz-mention-list {
2944
+ display: flex;
2945
+ flex-direction: column;
2946
+ gap: 1px;
2947
+ min-height: 0;
2948
+ }
2690
2949
  .vz-mention-loading,
2691
2950
  .vz-mention-empty {
2692
2951
  padding: 10px 12px;
@@ -2708,9 +2967,9 @@ var STYLES = `
2708
2967
  cursor: pointer;
2709
2968
  transition: background 80ms;
2710
2969
  }
2711
- .vz-mention-item:hover,
2970
+ .vz-mention-item.is-selected,
2712
2971
  .vz-mention-item:focus-visible {
2713
- background: var(--vz-bg-hover, rgba(255,255,255,0.06));
2972
+ background: var(--vz-bg-hover, rgba(255,255,255,0.08));
2714
2973
  outline: none;
2715
2974
  }
2716
2975
  .vz-mention-avatar {
@@ -3013,7 +3272,9 @@ var STYLES = `
3013
3272
  .vz-ref:hover { background: color-mix(in srgb, var(--vz-accent) 30%, var(--vz-bg-hover)); transform: translateY(-1px); }
3014
3273
  .vz-ref::before { content: ''; width: 5px; height: 5px; border-radius: 50%; background: var(--vz-accent); flex-shrink: 0; }
3015
3274
 
3016
- /* Inline @mention chip (rendered inside displayed comments) */
3275
+ /* Inline @mention chip (rendered inside displayed comments). user-select
3276
+ * prevents the user from selecting the chip's text and editing it; the
3277
+ * displayed comment is intentionally read-only after save. */
3017
3278
  .vz-mention {
3018
3279
  display: inline-flex; align-items: center;
3019
3280
  padding: 1px 8px 1px 8px;
@@ -3023,13 +3284,21 @@ var STYLES = `
3023
3284
  font-size: 12px; font-weight: 600;
3024
3285
  vertical-align: baseline;
3025
3286
  margin: 0 1px;
3287
+ user-select: none;
3288
+ -webkit-user-select: none;
3289
+ cursor: default;
3026
3290
  }
3027
3291
 
3028
- /* Mention chip inside the editor (contenteditable=false token) */
3292
+ /* Mention chip inside the editor (contenteditable=false token). Same
3293
+ * non-selection rules as .vz-mention so the chip stays atomic even
3294
+ * inside the editable surface \u2014 caret can land before/after but not
3295
+ * inside, no partial-text edit. */
3029
3296
  .vz-chip-mention {
3030
3297
  background: color-mix(in srgb, var(--vz-accent) 16%, var(--vz-bg-hover));
3031
3298
  color: var(--vz-accent);
3032
3299
  font-weight: 600;
3300
+ user-select: none;
3301
+ -webkit-user-select: none;
3033
3302
  }
3034
3303
  .vz-chip-mention::before { display: none; }
3035
3304
 
@@ -3723,10 +3992,29 @@ var Vizu = class {
3723
3992
  replies: [...this.comments[idx].replies ?? [], reply]
3724
3993
  };
3725
3994
  this.comments[idx] = updated;
3726
- if (this.storage.addReply) {
3727
- await this.storage.addReply(this.opts.namespace, commentId, reply);
3728
- } else {
3729
- await this.storage.updateComment(this.opts.namespace, commentId, { replies: updated.replies });
3995
+ try {
3996
+ if (this.storage.addReply) {
3997
+ await this.storage.addReply(this.opts.namespace, commentId, reply);
3998
+ } else {
3999
+ await this.storage.updateComment(this.opts.namespace, commentId, { replies: updated.replies });
4000
+ }
4001
+ } catch (err) {
4002
+ const cur = this.comments[idx];
4003
+ this.comments[idx] = {
4004
+ ...cur,
4005
+ replies: (cur.replies ?? []).filter((r) => r.id !== reply.id)
4006
+ };
4007
+ const code = err?.code ?? "";
4008
+ const status = err?.status;
4009
+ const msg = status === 403 || code === "forbidden" ? "Reply failed \u2014 you don't have permission." : status === 401 || code === "auth_required" ? "Reply failed \u2014 please sign in again." : "Reply failed. Please try again.";
4010
+ this.pill?.toast(msg);
4011
+ this.refreshUi();
4012
+ if (this.popover?.isOpen()) {
4013
+ const anchors = this.popover.getAnchors();
4014
+ if (anchors[0]) this.popover.update(this.commentsForElement(anchors[0]));
4015
+ }
4016
+ if (typeof console !== "undefined") console.warn("[vizu] addReply failed", err);
4017
+ return null;
3730
4018
  }
3731
4019
  this.bus.emit("comment:updated", { comment: updated });
3732
4020
  this.refreshUi();