solid-chat 0.0.10 → 0.0.19

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/README.md CHANGED
@@ -21,7 +21,7 @@ Modern, decentralized chat app built on the [Solid](https://solidproject.org) pr
21
21
  ### Real-time
22
22
  - WebSocket updates (NSS `Updates-Via` header)
23
23
  - WebSocketChannel2023 support (CSS/solidcommunity.net)
24
- - Notification sounds when tab is hidden (toggleable)
24
+ - Notification sounds (toggleable)
25
25
 
26
26
  ### Messaging
27
27
  - Send and receive messages
@@ -56,6 +56,19 @@ Modern, decentralized chat app built on the [Solid](https://solidproject.org) pr
56
56
  - Clickable timestamps (permalinks to message URI)
57
57
  - "(edited)" indicator for edited messages
58
58
  - Message count in header
59
+ - Unread message badges
60
+ - Remember current room across refreshes
61
+
62
+ ### Themes
63
+ - Wave (WhatsApp-style green)
64
+ - Signal (clean blue)
65
+ - Telegram (blue with patterns)
66
+ - Solid (purple/gradient)
67
+
68
+ ### Saved Messages
69
+ - Telegram-style "notes to self"
70
+ - Auto-created on your pod
71
+ - Private by default
59
72
 
60
73
  ## Quick Start
61
74
 
@@ -154,11 +167,15 @@ This app implements the [Solid Chat specification](https://solid.github.io/chat/
154
167
  - [x] Create new chats
155
168
  - [x] Deep link sharing (`?chat=<url>`)
156
169
  - [x] Type Index registration
170
+ - [x] Mobile responsive sidebar
171
+ - [x] Unread message badges
172
+ - [x] Theme support
173
+ - [x] Saved Messages
157
174
  - [ ] @mentions with notifications
158
175
  - [ ] End-to-end encryption
159
- - [ ] Mobile responsive sidebar
160
- - [ ] Unread message badges
161
176
  - [ ] Message search
177
+ - [ ] Push notifications
178
+ - [ ] Offline support
162
179
 
163
180
  ## License
164
181
 
package/index.html CHANGED
@@ -2,7 +2,7 @@
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
6
6
  <title>Solid Chat</title>
7
7
  <meta name="description" content="Modern, decentralized chat app for Solid pods. Your messages, your control.">
8
8
 
@@ -68,7 +68,6 @@
68
68
  </div>
69
69
  </div>
70
70
  <div class="header-actions">
71
- <button class="icon-btn" id="soundToggle" title="Toggle notification sound">🔔</button>
72
71
  </div>
73
72
  </div>
74
73
 
@@ -76,8 +75,8 @@
76
75
 
77
76
  <div class="sidebar-footer">
78
77
  <select id="sidebarThemeSelect" class="sidebar-theme-select" title="Switch theme">
79
- <option value="wave">Wave</option>
80
78
  <option value="solid">Solid</option>
79
+ <option value="wave">Wave</option>
81
80
  <option value="telegram">Telegram</option>
82
81
  <option value="signal">Signal</option>
83
82
  </select>
@@ -92,14 +91,6 @@
92
91
  <span id="userStatus" class="user-status">Loading...</span>
93
92
  </div>
94
93
  <div class="header-right" id="headerLoginArea">
95
- <div class="theme-switcher">
96
- <select id="themeSelect" title="Switch theme">
97
- <option value="wave">Wave</option>
98
- <option value="solid">Solid</option>
99
- <option value="telegram">Telegram</option>
100
- <option value="signal">Signal</option>
101
- </select>
102
- </div>
103
94
  <span id="loginArea"></span>
104
95
  </div>
105
96
  </div>
@@ -128,7 +119,7 @@
128
119
 
129
120
  <script type="module">
130
121
  import { longChatPane } from './src/longChatPane.js'
131
- import { chatListPane, addChat, updateChatPreview } from './src/chatListPane.js'
122
+ import { chatListPane, addChat, updateChatPreview, incrementUnread, resetUnread } from './src/chatListPane.js'
132
123
 
133
124
  // Theme management
134
125
  const THEMES = {
@@ -144,9 +135,7 @@ function loadTheme(themeName) {
144
135
  themeLink.href = theme.file
145
136
  localStorage.setItem('solidchat-theme', themeName)
146
137
 
147
- // Update selects
148
- const select = document.getElementById('themeSelect')
149
- if (select) select.value = themeName
138
+ // Update sidebar select
150
139
  const sidebarSelect = document.getElementById('sidebarThemeSelect')
151
140
  if (sidebarSelect) sidebarSelect.value = themeName
152
141
 
@@ -169,10 +158,6 @@ function initTheme() {
169
158
  const saved = localStorage.getItem('solidchat-theme') || 'solid'
170
159
  loadTheme(saved)
171
160
 
172
- document.getElementById('themeSelect').addEventListener('change', (e) => {
173
- loadTheme(e.target.value)
174
- })
175
-
176
161
  document.getElementById('sidebarThemeSelect').addEventListener('change', (e) => {
177
162
  loadTheme(e.target.value)
178
163
  })
@@ -231,18 +216,18 @@ async function handleAuthRedirect() {
231
216
 
232
217
  if (currentSession.info.isLoggedIn) {
233
218
  currentWebId = currentSession.info.webId
234
- updateAuthUI(true)
219
+ await updateAuthUI(true)
235
220
  } else {
236
- updateAuthUI(false)
221
+ await updateAuthUI(false)
237
222
  }
238
223
  } catch (err) {
239
224
  console.error('Auth redirect error:', err)
240
- updateAuthUI(false)
225
+ await updateAuthUI(false)
241
226
  }
242
227
  }
243
228
 
244
229
  // Update UI based on auth state
245
- function updateAuthUI(isLoggedIn) {
230
+ async function updateAuthUI(isLoggedIn) {
246
231
  if (isLoggedIn && currentWebId) {
247
232
  const shortId = currentWebId.split('//')[1]?.split('/')[0] || currentWebId
248
233
  userStatus.innerHTML = `Logged in as <a href="${currentWebId}" target="_blank">${shortId}</a>`
@@ -252,6 +237,12 @@ function updateAuthUI(isLoggedIn) {
252
237
  // Update avatar with user initial
253
238
  const initial = shortId.charAt(0).toUpperCase()
254
239
  document.getElementById('userAvatar').textContent = initial
240
+
241
+ // Add Saved Messages to chat list (pinned at top)
242
+ const savedMessagesUrl = await ensureSavedMessagesChat()
243
+ if (savedMessagesUrl) {
244
+ addChat(savedMessagesUrl, '📌 Saved Messages', 'Private notes to yourself')
245
+ }
255
246
  } else {
256
247
  userStatus.textContent = 'Not logged in'
257
248
  loginArea.innerHTML = `
@@ -295,7 +286,7 @@ async function handleLogout() {
295
286
  await logout()
296
287
  currentSession = null
297
288
  currentWebId = null
298
- updateAuthUI(false)
289
+ await updateAuthUI(false)
299
290
  } catch (err) {
300
291
  console.error('Logout error:', err)
301
292
  }
@@ -390,7 +381,7 @@ let reconnectTimeout = null
390
381
  let soundEnabled = localStorage.getItem('solidchat-sound') !== 'false'
391
382
 
392
383
  function playNotificationSound() {
393
- if (!soundEnabled || !document.hidden) return
384
+ if (!soundEnabled) return
394
385
 
395
386
  const ctx = new AudioContext()
396
387
 
@@ -425,7 +416,11 @@ function toggleSound() {
425
416
 
426
417
  function updateSoundButton() {
427
418
  const btn = document.getElementById('soundToggle')
428
- if (btn) btn.textContent = soundEnabled ? '🔔' : '🔕'
419
+ if (btn) {
420
+ btn.innerHTML = soundEnabled
421
+ ? '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/></svg>'
422
+ : '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/><line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" stroke-width="2"/></svg>'
423
+ }
429
424
  }
430
425
 
431
426
  // Subscribe to real-time updates for a resource
@@ -500,7 +495,10 @@ async function subscribeWebSocketChannel2023(uri, authFetch) {
500
495
  console.log('WebSocketChannel2023 notification:', event.data)
501
496
  // Any message means the resource changed
502
497
  playNotificationSound()
503
- setTimeout(() => refreshChat(), 500)
498
+ if (document.hidden) {
499
+ incrementUnread(uri)
500
+ }
501
+ setTimeout(() => refreshChat(), 1500)
504
502
  }
505
503
 
506
504
  currentWebSocket.onclose = () => {
@@ -537,7 +535,10 @@ function connectLegacyWebSocket(updatesVia, uri) {
537
535
  if (updatedUri === uri || uri.startsWith(updatedUri)) {
538
536
  console.log('Chat updated, refreshing...')
539
537
  playNotificationSound()
540
- setTimeout(() => refreshChat(), 500)
538
+ if (document.hidden) {
539
+ incrementUnread(uri)
540
+ }
541
+ setTimeout(() => refreshChat(), 1500)
541
542
  }
542
543
  }
543
544
  }
@@ -650,6 +651,9 @@ async function loadChat(uri) {
650
651
  currentChatUri = uri
651
652
  subscribeToUpdates(uri)
652
653
 
654
+ // Remember current chat for page refresh
655
+ localStorage.setItem('solidchat-current-room', uri)
656
+
653
657
  } catch (err) {
654
658
  console.error('Error loading chat:', err)
655
659
  chatContainer.innerHTML = `
@@ -769,6 +773,88 @@ async function setPublicReadACL(resourceUrl) {
769
773
  }
770
774
  }
771
775
 
776
+ // Set private ACL (owner-only) for Saved Messages
777
+ async function setPrivateACL(resourceUrl) {
778
+ const aclUrl = resourceUrl + '.acl'
779
+ const authFetch = getAuthFetch()
780
+
781
+ const acl = `@prefix acl: <http://www.w3.org/ns/auth/acl#>.
782
+
783
+ <#owner>
784
+ a acl:Authorization ;
785
+ acl:agent <${currentWebId}> ;
786
+ acl:accessTo <${resourceUrl}> ;
787
+ acl:mode acl:Read, acl:Write, acl:Control .
788
+ `
789
+
790
+ try {
791
+ await authFetch(aclUrl, {
792
+ method: 'PUT',
793
+ headers: { 'Content-Type': 'text/turtle' },
794
+ body: acl
795
+ })
796
+ } catch (e) {
797
+ console.warn('Failed to set private ACL:', e)
798
+ }
799
+ }
800
+
801
+ // Get URI for Saved Messages chat
802
+ function getSavedMessagesUri(podRoot) {
803
+ return podRoot + 'private/saved-messages/chat.ttl'
804
+ }
805
+
806
+ // Ensure Saved Messages chat exists, create if not
807
+ async function ensureSavedMessagesChat() {
808
+ if (!currentWebId) return null
809
+
810
+ const podRoot = await getMyPodRoot()
811
+ if (!podRoot) return null
812
+
813
+ const chatUrl = getSavedMessagesUri(podRoot)
814
+ const authFetch = getAuthFetch()
815
+
816
+ // Check if chat already exists
817
+ try {
818
+ const response = await authFetch(chatUrl, { method: 'HEAD' })
819
+ if (response.ok) {
820
+ return chatUrl // Already exists
821
+ }
822
+ } catch (e) {
823
+ // Doesn't exist, create it
824
+ }
825
+
826
+ // Create the chat
827
+ const now = new Date().toISOString()
828
+ const turtle = `@prefix dct: <http://purl.org/dc/terms/>.
829
+ @prefix meeting: <http://www.w3.org/ns/pim/meeting#>.
830
+ @prefix xsd: <http://www.w3.org/2001/XMLSchema#>.
831
+
832
+ <>
833
+ a meeting:LongChat ;
834
+ dct:title "Saved Messages" ;
835
+ dct:created "${now}"^^xsd:dateTime .
836
+ `
837
+
838
+ try {
839
+ const response = await authFetch(chatUrl, {
840
+ method: 'PUT',
841
+ headers: { 'Content-Type': 'text/turtle' },
842
+ body: turtle
843
+ })
844
+
845
+ if (response.ok) {
846
+ // Set private ACL
847
+ await setPrivateACL(chatUrl)
848
+ console.log('Created Saved Messages chat:', chatUrl)
849
+ return chatUrl
850
+ }
851
+ } catch (e) {
852
+ console.warn('Failed to create Saved Messages chat:', e)
853
+ }
854
+
855
+ return null
856
+ }
857
+
772
858
  // Register chat in user's Type Index
773
859
  async function registerInTypeIndex(chatUrl, isPublic = true) {
774
860
  if (!currentWebId) return
@@ -908,15 +994,37 @@ function showToast(message) {
908
994
 
909
995
  // Make createChat and copyShareLink available globally for chatListPane
910
996
  window.solidChat = { createChat, copyShareLink, getMyPodRoot }
997
+ window.toggleSound = toggleSound
998
+
999
+ // Detect bottom nav bar overlay and set CSS variable
1000
+ function updateBottomOffset() {
1001
+ if (window.visualViewport) {
1002
+ const offset = window.innerHeight - window.visualViewport.height;
1003
+ document.documentElement.style.setProperty('--bottom-offset', `${Math.max(0, offset)}px`);
1004
+ }
1005
+ }
911
1006
 
912
1007
  // Initialize: handle auth redirect first, then render sidebar
913
1008
  async function init() {
914
1009
  // Initialize theme
915
1010
  initTheme()
916
1011
 
917
- // Set initial sound button state
918
- updateSoundButton()
919
- document.getElementById('soundToggle').addEventListener('click', toggleSound)
1012
+ // Set up viewport detection for Android nav bar
1013
+ updateBottomOffset()
1014
+ if (window.visualViewport) {
1015
+ window.visualViewport.addEventListener('resize', updateBottomOffset)
1016
+ }
1017
+ window.addEventListener('resize', updateBottomOffset)
1018
+
1019
+ // Sound button is now created in chatListPane with onclick handler
1020
+
1021
+ // Clear unread count when user returns to tab
1022
+ document.addEventListener('visibilitychange', () => {
1023
+ if (!document.hidden && currentChatUri) {
1024
+ resetUnread(currentChatUri)
1025
+ chatListPane.setActiveChat(currentChatUri)
1026
+ }
1027
+ })
920
1028
 
921
1029
  // Handle auth redirect callback (if returning from IdP)
922
1030
  await handleAuthRedirect()
@@ -944,13 +1052,14 @@ async function init() {
944
1052
  discoverBtn.parentNode.insertBefore(themeSelect.parentNode, discoverBtn)
945
1053
  }
946
1054
 
947
- // Check for ?chat= deep link first, then ?uri= (legacy), then default
1055
+ // Check for ?chat= deep link first, then ?uri= (legacy), then saved room, then default
948
1056
  const deepLinkedChat = await handleDeepLink()
949
1057
  if (deepLinkedChat) {
950
1058
  loadChat(deepLinkedChat)
951
1059
  } else {
952
1060
  const params = new URLSearchParams(window.location.search)
953
- const initialUri = params.get('uri') || DEFAULT_CHAT_URI
1061
+ const savedRoom = localStorage.getItem('solidchat-current-room')
1062
+ const initialUri = params.get('uri') || savedRoom || DEFAULT_CHAT_URI
954
1063
  loadChat(initialUri)
955
1064
  }
956
1065
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "solid-chat",
3
- "version": "0.0.10",
3
+ "version": "0.0.19",
4
4
  "description": "Modern chat panes for Solid pods - longChatPane and chatListPane",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -19,6 +19,45 @@ const MEETING = {
19
19
 
20
20
  // Storage key for localStorage
21
21
  const STORAGE_KEY = 'solidchat-chats'
22
+ const UNREAD_KEY = 'solidchat-unread'
23
+
24
+ // Get unread count for a chat
25
+ function getUnreadCount(uri) {
26
+ try {
27
+ const data = JSON.parse(localStorage.getItem(UNREAD_KEY) || '{}')
28
+ return data[uri] || 0
29
+ } catch {
30
+ return 0
31
+ }
32
+ }
33
+
34
+ // Increment unread count for a chat (call when new message arrives)
35
+ function incrementUnread(uri) {
36
+ try {
37
+ const data = JSON.parse(localStorage.getItem(UNREAD_KEY) || '{}')
38
+ data[uri] = (data[uri] || 0) + 1
39
+ localStorage.setItem(UNREAD_KEY, JSON.stringify(data))
40
+ renderChatList()
41
+ } catch (e) {
42
+ console.warn('Failed to increment unread:', e)
43
+ }
44
+ }
45
+
46
+ // Reset unread count (call when chat is opened)
47
+ function resetUnread(uri) {
48
+ try {
49
+ const data = JSON.parse(localStorage.getItem(UNREAD_KEY) || '{}')
50
+ data[uri] = 0
51
+ localStorage.setItem(UNREAD_KEY, JSON.stringify(data))
52
+ } catch (e) {
53
+ console.warn('Failed to reset unread:', e)
54
+ }
55
+ }
56
+
57
+ // Legacy alias for compatibility
58
+ function setLastRead(uri) {
59
+ resetUnread(uri)
60
+ }
22
61
 
23
62
  // Default global chats
24
63
  const DEFAULT_CHATS = [
@@ -65,6 +104,7 @@ const styles = `
65
104
  background: linear-gradient(135deg, var(--gradient-start) 0%, var(--gradient-end) 100%);
66
105
  color: white;
67
106
  padding: 16px 16px;
107
+ min-height: 76px;
68
108
  display: flex;
69
109
  align-items: center;
70
110
  justify-content: space-between;
@@ -96,10 +136,28 @@ const styles = `
96
136
  background: rgba(255,255,255,0.3);
97
137
  }
98
138
 
139
+ .sound-toggle-btn {
140
+ background: none;
141
+ border: none;
142
+ color: rgba(255,255,255,0.7);
143
+ cursor: pointer;
144
+ padding: 6px;
145
+ display: flex;
146
+ align-items: center;
147
+ justify-content: center;
148
+ border-radius: 50%;
149
+ transition: all 0.2s;
150
+ }
151
+
152
+ .sound-toggle-btn:hover {
153
+ color: white;
154
+ background: rgba(255,255,255,0.15);
155
+ }
156
+
99
157
  .chat-list {
100
158
  flex: 1;
101
159
  overflow-y: auto;
102
- padding: 8px 0;
160
+ padding: 0 0 8px 0;
103
161
  }
104
162
 
105
163
  .chat-item {
@@ -164,6 +222,21 @@ const styles = `
164
222
  flex-shrink: 0;
165
223
  }
166
224
 
225
+ .chat-item-badge {
226
+ background: #e74c3c;
227
+ color: white;
228
+ font-size: 11px;
229
+ font-weight: 600;
230
+ min-width: 18px;
231
+ height: 18px;
232
+ border-radius: 9px;
233
+ display: flex;
234
+ align-items: center;
235
+ justify-content: center;
236
+ padding: 0 5px;
237
+ margin-left: 8px;
238
+ }
239
+
167
240
  .chat-item-preview {
168
241
  font-size: 13px;
169
242
  color: var(--text-muted);
@@ -555,6 +628,15 @@ function renderChatList() {
555
628
  header.appendChild(title)
556
629
  header.appendChild(time)
557
630
 
631
+ // Show unread count badge
632
+ const unreadCount = getUnreadCount(chat.uri)
633
+ if (unreadCount > 0) {
634
+ const badge = dom.createElement('div')
635
+ badge.className = 'chat-item-badge'
636
+ badge.textContent = unreadCount > 99 ? '99+' : unreadCount
637
+ header.appendChild(badge)
638
+ }
639
+
558
640
  const preview = dom.createElement('div')
559
641
  preview.className = 'chat-item-preview'
560
642
  preview.textContent = chat.lastMessage || 'No messages yet'
@@ -577,6 +659,7 @@ function renderChatList() {
577
659
 
578
660
  item.onclick = () => {
579
661
  activeUri = chat.uri
662
+ setLastRead(chat.uri)
580
663
  renderChatList()
581
664
  if (onSelectCallback) {
582
665
  onSelectCallback(chat.uri)
@@ -859,13 +942,33 @@ export const chatListPane = {
859
942
  title.className = 'sidebar-title'
860
943
  title.textContent = 'Chats'
861
944
 
945
+ // Sound toggle button (subtle white bell like Telegram)
946
+ const soundBtn = dom.createElement('button')
947
+ soundBtn.className = 'sound-toggle-btn'
948
+ soundBtn.id = 'soundToggle'
949
+ soundBtn.title = 'Toggle notification sound'
950
+ soundBtn.innerHTML = localStorage.getItem('solidchat-sound') !== 'false'
951
+ ? '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/></svg>'
952
+ : '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/><path d="M3.27 3L2 4.27l2.92 2.92C4.34 8.16 4 9.5 4 11v5l-2 2v1h14.73l2 2L20 19.73 3.27 3z"/></svg>'
953
+ soundBtn.onclick = () => {
954
+ if (window.toggleSound) {
955
+ window.toggleSound()
956
+ }
957
+ }
958
+
862
959
  const addBtn = dom.createElement('button')
863
960
  addBtn.className = 'add-chat-btn'
864
961
  addBtn.textContent = '+'
865
962
  addBtn.title = 'Add or create chat'
866
963
  addBtn.onclick = () => showAddModal(dom, options.webId)
867
964
 
868
- header.appendChild(title)
965
+ // Group title and sound button together (flush)
966
+ const titleGroup = dom.createElement('div')
967
+ titleGroup.style.cssText = 'display: flex; align-items: center; gap: 8px;'
968
+ titleGroup.appendChild(title)
969
+ titleGroup.appendChild(soundBtn)
970
+
971
+ header.appendChild(titleGroup)
869
972
  header.appendChild(addBtn)
870
973
 
871
974
  // Chat list
@@ -931,4 +1034,4 @@ export const chatListPane = {
931
1034
  }
932
1035
 
933
1036
  // Export for use in index.html
934
- export { addChat, removeChat, updateChatPreview }
1037
+ export { addChat, removeChat, updateChatPreview, setLastRead, incrementUnread, resetUnread }
@@ -235,6 +235,7 @@ const styles = `
235
235
  .input-area {
236
236
  background: white;
237
237
  padding: 12px 20px;
238
+ padding-bottom: calc(12px + var(--bottom-offset, 0px));
238
239
  display: flex;
239
240
  align-items: flex-end;
240
241
  gap: 12px;
@@ -695,6 +696,10 @@ function renderMessageContent(dom, content) {
695
696
  img.alt = 'Image'
696
697
  img.loading = 'lazy'
697
698
  img.onclick = () => window.open(part, '_blank')
699
+ img.onload = () => {
700
+ const mc = img.closest('.messages-container')
701
+ if (mc) mc.scrollTop = mc.scrollHeight
702
+ }
698
703
  wrapper.appendChild(img)
699
704
  container.appendChild(wrapper)
700
705
  } else if (VIDEO_EXT.test(part)) {
@@ -1185,10 +1190,44 @@ export const longChatPane = {
1185
1190
  try {
1186
1191
  const authFetch = context.authFetch ? context.authFetch() : fetch
1187
1192
  const doc = subject.doc ? subject.doc() : subject
1188
-
1189
- // Build SPARQL DELETE for all statements about this message
1190
1193
  const msgUri = message.uri
1191
- const deleteQuery = `DELETE WHERE { <${msgUri}> ?p ?o . }`
1194
+
1195
+ // Get all triples about this message from the store
1196
+ const msgNode = store.sym(msgUri)
1197
+ const statements = store.statementsMatching(msgNode, null, null, doc)
1198
+
1199
+ if (statements.length === 0) {
1200
+ throw new Error('No statements found for this message')
1201
+ }
1202
+
1203
+ // Build DELETE DATA with explicit triples (more compatible than DELETE WHERE with variables)
1204
+ const triples = statements.map(st => {
1205
+ const obj = st.object
1206
+ let objStr
1207
+ if (obj.termType === 'NamedNode') {
1208
+ objStr = `<${obj.value}>`
1209
+ } else if (obj.termType === 'Literal') {
1210
+ // Escape special characters in literal value (Turtle escaping)
1211
+ const escaped = obj.value
1212
+ .replace(/\\/g, '\\\\')
1213
+ .replace(/"/g, '\\"')
1214
+ .replace(/\n/g, '\\n')
1215
+ .replace(/\r/g, '\\r')
1216
+ .replace(/\t/g, '\\t')
1217
+ if (obj.datatype && obj.datatype.value !== 'http://www.w3.org/2001/XMLSchema#string') {
1218
+ objStr = `"${escaped}"^^<${obj.datatype.value}>`
1219
+ } else if (obj.language) {
1220
+ objStr = `"${escaped}"@${obj.language}`
1221
+ } else {
1222
+ objStr = `"${escaped}"`
1223
+ }
1224
+ } else {
1225
+ objStr = `"${obj.value}"`
1226
+ }
1227
+ return `<${st.subject.value}> <${st.predicate.value}> ${objStr} .`
1228
+ }).join('\n')
1229
+
1230
+ const deleteQuery = `DELETE DATA {\n${triples}\n}`
1192
1231
 
1193
1232
  const response = await authFetch(doc.value || doc.uri, {
1194
1233
  method: 'PATCH',
@@ -1200,14 +1239,22 @@ export const longChatPane = {
1200
1239
  throw new Error(`Delete failed: ${response.status}`)
1201
1240
  }
1202
1241
 
1242
+ // Show brief success indicator
1243
+ statusEl.textContent = '✓ Deleted'
1244
+ setTimeout(() => { statusEl.textContent = `${messages.length - 1} messages` }, 1000)
1245
+
1246
+ // Remove from local store (prevents ghost re-render on WebSocket refresh)
1247
+ statements.forEach(st => store.remove(st))
1248
+
1203
1249
  // Remove from UI
1204
1250
  rowEl.remove()
1205
1251
  renderedUris.delete(message.uri)
1206
1252
  messages = messages.filter(m => m.uri !== message.uri)
1207
- statusEl.textContent = `${messages.length} messages`
1208
1253
 
1209
1254
  } catch (err) {
1210
1255
  console.error('Delete error:', err)
1256
+ statusEl.textContent = '✗ Delete failed'
1257
+ setTimeout(() => { statusEl.textContent = `${messages.length} messages` }, 2000)
1211
1258
  alert('Failed to delete: ' + err.message)
1212
1259
  }
1213
1260
  }
@@ -1380,7 +1427,7 @@ export const longChatPane = {
1380
1427
  }
1381
1428
 
1382
1429
  // Load messages from store
1383
- async function loadMessages() {
1430
+ async function loadMessages(skipFetch = false) {
1384
1431
  if (isFirstLoad) {
1385
1432
  statusEl.textContent = 'Loading messages...'
1386
1433
  messagesContainer.innerHTML = ''
@@ -1395,9 +1442,11 @@ export const longChatPane = {
1395
1442
  const DCT = ns('http://purl.org/dc/terms/')
1396
1443
  const FOAF = ns('http://xmlns.com/foaf/0.1/')
1397
1444
 
1398
- // Fetch the document
1445
+ // Fetch the document (skip if refresh already loaded fresh data)
1399
1446
  const doc = subject.doc ? subject.doc() : subject
1400
- await store.fetcher.load(doc)
1447
+ if (!skipFetch) {
1448
+ await store.fetcher.load(doc)
1449
+ }
1401
1450
 
1402
1451
  // Get chat title from the subject or document
1403
1452
  const chatNode = subject.uri.includes('#') ? subject : $rdf.sym(subject.uri + '#this')
@@ -1476,6 +1525,17 @@ export const longChatPane = {
1476
1525
  // Find messages that haven't been rendered yet
1477
1526
  const unrenderedMessages = allMessages.filter(m => !renderedUris.has(m.uri))
1478
1527
 
1528
+ // Find messages that were rendered but no longer exist (deleted)
1529
+ const currentUris = new Set(allMessages.map(m => m.uri))
1530
+ const deletedUris = [...renderedUris].filter(uri => !currentUris.has(uri))
1531
+
1532
+ // Remove deleted messages from UI
1533
+ for (const uri of deletedUris) {
1534
+ const el = messagesContainer.querySelector(`[data-uri="${uri}"]`)
1535
+ if (el) el.remove()
1536
+ renderedUris.delete(uri)
1537
+ }
1538
+
1479
1539
  // Render only new messages (or all on first load)
1480
1540
  if (isFirstLoad) {
1481
1541
  if (allMessages.length === 0) {
@@ -1665,8 +1725,29 @@ export const longChatPane = {
1665
1725
  container.refresh = async function() {
1666
1726
  // Re-fetch the document
1667
1727
  const doc = subject.doc ? subject.doc() : subject
1668
- await store.fetcher.load(doc, { force: true })
1669
- await loadMessages()
1728
+ const docUri = doc.uri || doc.value
1729
+
1730
+ // Clear existing statements for this document before reloading
1731
+ const existingStatements = store.statementsMatching(null, null, null, doc)
1732
+ existingStatements.forEach(st => store.remove(st))
1733
+
1734
+ // Fetch with cache-busting to bypass browser cache
1735
+ const authFetch = context.authFetch ? context.authFetch() : fetch
1736
+ const cacheBustUrl = docUri + (docUri.includes('?') ? '&' : '?') + '_t=' + Date.now()
1737
+
1738
+ const response = await authFetch(cacheBustUrl, {
1739
+ headers: { 'Accept': 'text/turtle, application/ld+json, application/rdf+xml' },
1740
+ cache: 'no-store'
1741
+ })
1742
+
1743
+ if (response.ok) {
1744
+ const text = await response.text()
1745
+ const contentType = response.headers.get('content-type') || 'text/turtle'
1746
+ $rdf.parse(text, store, docUri, contentType)
1747
+ }
1748
+
1749
+ // skipFetch=true because we already loaded fresh data above
1750
+ await loadMessages(true)
1670
1751
  }
1671
1752
 
1672
1753
  // Initial load