@devchitchat/chat 4.5.0 → 5.0.1

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.
Files changed (39) hide show
  1. package/index.js +0 -9
  2. package/package.json +2 -3
  3. package/pages/_layout.html +0 -7
  4. package/pages/admin/_layout.html +0 -7
  5. package/pages/channels/[channelId].phtml +17 -42
  6. package/pages/design/_layout.html +349 -0
  7. package/pages/design/_layout.js +13 -0
  8. package/pages/design/components/index.js +3 -0
  9. package/pages/design/components/index.phtml +380 -0
  10. package/pages/design/index.js +3 -0
  11. package/pages/design/index.phtml +78 -0
  12. package/pages/design/principles/index.js +3 -0
  13. package/pages/design/principles/index.phtml +147 -0
  14. package/pages/design/tokens/index.js +3 -0
  15. package/pages/design/tokens/index.phtml +236 -0
  16. package/pages/public/client/app.js +171 -13
  17. package/pages/public/client/controllers/ChatController.js +204 -0
  18. package/pages/public/client/controllers/WebSocketController.js +191 -0
  19. package/pages/public/client/model/AppModel.js +351 -0
  20. package/pages/public/client/model/events.js +41 -0
  21. package/pages/public/client/resizable.js +74 -0
  22. package/pages/public/client/rtc-peer-manager.js +5 -2
  23. package/pages/public/client/settings-sync.js +45 -7
  24. package/pages/public/client/shared/messages.js +21 -9
  25. package/pages/public/client/theme.js +6 -4
  26. package/pages/public/client/views/CallView.js +754 -0
  27. package/pages/public/client/views/ChatHeaderView.js +67 -0
  28. package/pages/public/client/views/ComposerView.js +491 -0
  29. package/pages/public/client/views/MessageListView.js +461 -0
  30. package/pages/public/client/views/SidebarView.js +977 -0
  31. package/pages/public/client/views/ThreadPanelView.js +260 -0
  32. package/pages/public/client/views/shared/EmojiPickerSingleton.js +201 -0
  33. package/pages/public/client/views/shared/MentionPicker.js +139 -0
  34. package/pages/public/client/views/shared/MessageInteractions.js +353 -0
  35. package/pages/public/themes/base.css +29 -31
  36. package/src/ws/ChatServer.js +2 -1
  37. package/src/ws/handlers/rtcHandlers.js +7 -0
  38. package/pages/public/client/islands/call.js +0 -2282
  39. package/pages/public/client/islands/sidebar.js +0 -1198
@@ -1,25 +1,63 @@
1
1
  /**
2
- * settings-sync.js — client-side settings: localStorage + background server sync.
2
+ * settings-sync.js — all client-side persistence in one place.
3
3
  *
4
- * This module owns all localStorage reads/writes and server sync. Islands import
5
- * from here they never touch localStorage or the API directly.
4
+ * Two namespaces:
5
+ * settings synced to server (last_channel_id, mobile_chat_open, …)
6
+ * prefs — local-only UI preferences (theme, panel widths, devices, …)
6
7
  *
7
- * Storage shape: { settings: { last_channel_id, mobile_chat_open }, updated_at: number }
8
+ * Callers never touch localStorage directly they use the exports below.
9
+ *
10
+ * Storage keys:
11
+ * devchitchat_settings { settings: {…}, updated_at: number }
12
+ * devchitchat_prefs { theme, sidebar_width, thread_panel_width,
13
+ * tile_panel_width, tile_layout, devices, … }
8
14
  */
9
15
 
10
- const STORAGE_KEY = 'devchitchat_settings'
16
+ const SETTINGS_KEY = 'devchitchat_settings'
17
+ const PREFS_KEY = 'devchitchat_prefs'
11
18
  const BASE_PATH = window.__BASE_PATH__ ?? ''
12
19
 
13
20
  function readLocal() {
14
21
  try {
15
- return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}')
22
+ return JSON.parse(localStorage.getItem(SETTINGS_KEY) ?? '{}')
16
23
  } catch {
17
24
  return {}
18
25
  }
19
26
  }
20
27
 
21
28
  function writeLocal(data) {
22
- localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
29
+ localStorage.setItem(SETTINGS_KEY, JSON.stringify(data))
30
+ }
31
+
32
+ // ── Local-only UI preferences ─────────────────────────────────────────────────
33
+
34
+ function readPrefs() {
35
+ try {
36
+ return JSON.parse(localStorage.getItem(PREFS_KEY) ?? '{}')
37
+ } catch {
38
+ return {}
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Read a UI preference. Returns `defaultValue` when the key has never been set.
44
+ * @param {string} key
45
+ * @param {*} [defaultValue]
46
+ */
47
+ export function getPref(key, defaultValue = null) {
48
+ const prefs = readPrefs()
49
+ return key in prefs ? prefs[key] : defaultValue
50
+ }
51
+
52
+ /**
53
+ * Write one or more UI preferences.
54
+ * @param {string|Record<string,*>} keyOrPatch — key string or { key: value } map
55
+ * @param {*} [value] — value when keyOrPatch is a string
56
+ */
57
+ export function setPref(keyOrPatch, value) {
58
+ const prefs = readPrefs()
59
+ const patch = typeof keyOrPatch === 'string' ? { [keyOrPatch]: value } : keyOrPatch
60
+ localStorage.setItem(PREFS_KEY, JSON.stringify({ ...prefs, ...patch }))
23
61
  }
24
62
 
25
63
  // Returns current settings object (instant, synchronous)
@@ -33,8 +33,9 @@ export function makeDateSeparator(dateKey) {
33
33
 
34
34
  /**
35
35
  * Escape HTML then wrap @handles in <span class="mention"> (or mention-self for current user).
36
+ * Only handles that appear in `knownHandles` are styled; unrecognised @words are left as plain text.
36
37
  * @param {string} text
37
- * @param {{ userHandle?: string }} [opts]
38
+ * @param {{ userHandle?: string, knownHandles?: Set<string> }} [opts]
38
39
  */
39
40
  // Combined regex (operates on raw text before HTML-escaping):
40
41
  // group 1 (+ inner 2, 3) — markdown link: [text](url)
@@ -42,7 +43,7 @@ export function makeDateSeparator(dateKey) {
42
43
  // group 5 — @mention
43
44
  const INLINE_RE = /(\[([^\]]*)\]\((https?:\/\/[^)]+)\))|(https?:\/\/[^\s<>"'[\]()*]+)|(@[a-zA-Z0-9_.-]+)/g
44
45
 
45
- export function renderText(text, { userHandle } = {}) {
46
+ export function renderText(text, { userHandle, knownHandles } = {}) {
46
47
  let result = ''
47
48
  let lastIndex = 0
48
49
  INLINE_RE.lastIndex = 0
@@ -58,9 +59,14 @@ export function renderText(text, { userHandle } = {}) {
58
59
  const trailing = bareUrl.slice(trimmed.length)
59
60
  result += `<a href="${escHtml(trimmed)}" target="_blank" rel="noopener noreferrer">${escHtml(trimmed)}</a>${escHtml(trailing)}`
60
61
  } else if (mention) {
61
- const handle = mention.slice(1)
62
- const isSelf = userHandle && handle.toLowerCase() === userHandle.toLowerCase()
63
- result += `<span class="mention${isSelf ? ' mention-self' : ''}">${escHtml(mention)}</span>`
62
+ const handle = mention.slice(1).toLowerCase()
63
+ const known = !knownHandles || knownHandles.has(handle)
64
+ if (known) {
65
+ const isSelf = userHandle && handle === userHandle.toLowerCase()
66
+ result += `<span class="mention${isSelf ? ' mention-self' : ''}">${escHtml(mention)}</span>`
67
+ } else {
68
+ result += escHtml(mention)
69
+ }
64
70
  }
65
71
  lastIndex = m.index + full.length
66
72
  }
@@ -75,7 +81,7 @@ export function renderText(text, { userHandle } = {}) {
75
81
  * @param {Element} el
76
82
  * @param {{ userHandle?: string }} [opts]
77
83
  */
78
- export function applyInlineRenderingToTextNodes(el, { userHandle } = {}) {
84
+ export function applyInlineRenderingToTextNodes(el, { userHandle, knownHandles } = {}) {
79
85
  const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
80
86
  const nodes = []
81
87
  let n
@@ -86,7 +92,7 @@ export function applyInlineRenderingToTextNodes(el, { userHandle } = {}) {
86
92
  for (const textNode of nodes) {
87
93
  const raw = textNode.textContent
88
94
  if (!raw.trim()) continue
89
- const rendered = renderText(raw, { userHandle })
95
+ const rendered = renderText(raw, { userHandle, knownHandles })
90
96
  if (rendered === escHtml(raw)) continue // nothing changed
91
97
  const span = document.createElement('span')
92
98
  span.innerHTML = rendered
@@ -123,7 +129,7 @@ export function renderAttachment(a) {
123
129
  * @param {{ userId?: string, userHandle?: string, isThreadReply?: boolean }} [ctx]
124
130
  * isThreadReply — omits the "Reply in thread" button (threads can't be nested)
125
131
  */
126
- export function makeMessageEl({ msg_id, seq, user_id, user_display_name, ts, text, rendered_text, edited_at, attachments }, { userId, userHandle, isThreadReply = false } = {}) {
132
+ export function makeMessageEl({ msg_id, seq, user_id, user_display_name, ts, text, rendered_text, edited_at, attachments }, { userId, userHandle, knownHandles, isThreadReply = false } = {}) {
127
133
  const article = document.createElement('article')
128
134
  article.className = 'message'
129
135
  article.dataset.seq = seq
@@ -137,7 +143,7 @@ export function makeMessageEl({ msg_id, seq, user_id, user_display_name, ts, tex
137
143
  const editedHtml = edited_at ? '<span class="message-edited">(edited)</span>' : ''
138
144
  const replyBtn = isThreadReply ? '' : '<button class="btn-reply btn-icon" type="button" title="Reply in thread" aria-label="Reply in thread">&#x21A9;</button>'
139
145
  const actionsHtml = `<div class="message-hover-actions"><span class="quick-picks"></span>${replyBtn}<button class="btn-react btn-icon" type="button" title="Add reaction" aria-label="Add reaction">🙂</button>${isSelf ? '<button class="btn-msg-actions btn-icon" type="button" title="Message actions">…</button>' : ''}</div>`
140
- const textHtml = rendered_text ?? (text ? renderText(text, { userHandle }) : '')
146
+ const textHtml = rendered_text ?? (text ? renderText(text, { userHandle, knownHandles }) : '')
141
147
  article.innerHTML = `
142
148
  <span class="message-handle${isSelf ? '' : ' dm-trigger'}" data-user-id="${escHtml(user_id)}" title="${isSelf ? '' : 'Send a direct message'}">${escHtml(user_display_name ?? user_id)}</span>
143
149
  <time class="message-time" datetime="${ts}">${time}${editedHtml}</time>
@@ -146,5 +152,11 @@ export function makeMessageEl({ msg_id, seq, user_id, user_display_name, ts, tex
146
152
  <div class="reaction-bar"></div>
147
153
  ${actionsHtml}
148
154
  `
155
+ // When the server provides rendered_text, @mention styling is not included.
156
+ // Apply it now so every code path gets consistent output.
157
+ if (rendered_text) {
158
+ const textEl = article.querySelector('.message-text')
159
+ if (textEl) applyInlineRenderingToTextNodes(textEl, { userHandle, knownHandles })
160
+ }
149
161
  return article
150
162
  }
@@ -3,9 +3,11 @@
3
3
  *
4
4
  * Each theme is a separate CSS file in /themes/<name>.css.
5
5
  * The <html data-theme> attribute is set so themes can also use attribute selectors.
6
+ * Theme preference is stored via settings-sync getPref/setPref ('theme' key).
6
7
  */
8
+ import { getPref, setPref } from './settings-sync.js'
9
+
7
10
  const THEMES = ['dark', 'light', 'ocean', 'forest', 'rose']
8
- const STORAGE_KEY = 'devchitchat_theme'
9
11
  const BASE_PATH = window.__BASE_PATH__ ?? ''
10
12
  const stylesheet = document.getElementById('theme-stylesheet')
11
13
  const picker = document.getElementById('theme-picker')
@@ -15,13 +17,13 @@ function applyTheme(name) {
15
17
  document.documentElement.dataset.theme = theme
16
18
  if (stylesheet) stylesheet.href = `${BASE_PATH}/themes/${theme}.css`
17
19
  if (picker) picker.value = theme
18
- localStorage.setItem(STORAGE_KEY, theme)
20
+ setPref('theme', theme)
19
21
  }
20
22
 
21
23
  // Restore saved theme immediately (before paint)
22
- applyTheme(localStorage.getItem(STORAGE_KEY) ?? 'dark')
24
+ applyTheme(getPref('theme', 'dark'))
23
25
 
24
26
  // Wire picker
25
27
  if (picker) {
26
- picker.addEventListener('change', (e) => applyTheme(e.target.value))
28
+ picker.addEventListener('change', e => applyTheme(e.target.value))
27
29
  }