solid-chat 0.0.17 → 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
 
@@ -216,18 +216,18 @@ async function handleAuthRedirect() {
216
216
 
217
217
  if (currentSession.info.isLoggedIn) {
218
218
  currentWebId = currentSession.info.webId
219
- updateAuthUI(true)
219
+ await updateAuthUI(true)
220
220
  } else {
221
- updateAuthUI(false)
221
+ await updateAuthUI(false)
222
222
  }
223
223
  } catch (err) {
224
224
  console.error('Auth redirect error:', err)
225
- updateAuthUI(false)
225
+ await updateAuthUI(false)
226
226
  }
227
227
  }
228
228
 
229
229
  // Update UI based on auth state
230
- function updateAuthUI(isLoggedIn) {
230
+ async function updateAuthUI(isLoggedIn) {
231
231
  if (isLoggedIn && currentWebId) {
232
232
  const shortId = currentWebId.split('//')[1]?.split('/')[0] || currentWebId
233
233
  userStatus.innerHTML = `Logged in as <a href="${currentWebId}" target="_blank">${shortId}</a>`
@@ -237,6 +237,12 @@ function updateAuthUI(isLoggedIn) {
237
237
  // Update avatar with user initial
238
238
  const initial = shortId.charAt(0).toUpperCase()
239
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
+ }
240
246
  } else {
241
247
  userStatus.textContent = 'Not logged in'
242
248
  loginArea.innerHTML = `
@@ -280,7 +286,7 @@ async function handleLogout() {
280
286
  await logout()
281
287
  currentSession = null
282
288
  currentWebId = null
283
- updateAuthUI(false)
289
+ await updateAuthUI(false)
284
290
  } catch (err) {
285
291
  console.error('Logout error:', err)
286
292
  }
@@ -375,7 +381,7 @@ let reconnectTimeout = null
375
381
  let soundEnabled = localStorage.getItem('solidchat-sound') !== 'false'
376
382
 
377
383
  function playNotificationSound() {
378
- if (!soundEnabled || !document.hidden) return
384
+ if (!soundEnabled) return
379
385
 
380
386
  const ctx = new AudioContext()
381
387
 
@@ -492,7 +498,7 @@ async function subscribeWebSocketChannel2023(uri, authFetch) {
492
498
  if (document.hidden) {
493
499
  incrementUnread(uri)
494
500
  }
495
- setTimeout(() => refreshChat(), 500)
501
+ setTimeout(() => refreshChat(), 1500)
496
502
  }
497
503
 
498
504
  currentWebSocket.onclose = () => {
@@ -532,7 +538,7 @@ function connectLegacyWebSocket(updatesVia, uri) {
532
538
  if (document.hidden) {
533
539
  incrementUnread(uri)
534
540
  }
535
- setTimeout(() => refreshChat(), 500)
541
+ setTimeout(() => refreshChat(), 1500)
536
542
  }
537
543
  }
538
544
  }
@@ -645,6 +651,9 @@ async function loadChat(uri) {
645
651
  currentChatUri = uri
646
652
  subscribeToUpdates(uri)
647
653
 
654
+ // Remember current chat for page refresh
655
+ localStorage.setItem('solidchat-current-room', uri)
656
+
648
657
  } catch (err) {
649
658
  console.error('Error loading chat:', err)
650
659
  chatContainer.innerHTML = `
@@ -764,6 +773,88 @@ async function setPublicReadACL(resourceUrl) {
764
773
  }
765
774
  }
766
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
+
767
858
  // Register chat in user's Type Index
768
859
  async function registerInTypeIndex(chatUrl, isPublic = true) {
769
860
  if (!currentWebId) return
@@ -905,11 +996,26 @@ function showToast(message) {
905
996
  window.solidChat = { createChat, copyShareLink, getMyPodRoot }
906
997
  window.toggleSound = toggleSound
907
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
+ }
1006
+
908
1007
  // Initialize: handle auth redirect first, then render sidebar
909
1008
  async function init() {
910
1009
  // Initialize theme
911
1010
  initTheme()
912
1011
 
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
+
913
1019
  // Sound button is now created in chatListPane with onclick handler
914
1020
 
915
1021
  // Clear unread count when user returns to tab
@@ -946,13 +1052,14 @@ async function init() {
946
1052
  discoverBtn.parentNode.insertBefore(themeSelect.parentNode, discoverBtn)
947
1053
  }
948
1054
 
949
- // 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
950
1056
  const deepLinkedChat = await handleDeepLink()
951
1057
  if (deepLinkedChat) {
952
1058
  loadChat(deepLinkedChat)
953
1059
  } else {
954
1060
  const params = new URLSearchParams(window.location.search)
955
- 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
956
1063
  loadChat(initialUri)
957
1064
  }
958
1065
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "solid-chat",
3
- "version": "0.0.17",
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",
@@ -104,6 +104,7 @@ const styles = `
104
104
  background: linear-gradient(135deg, var(--gradient-start) 0%, var(--gradient-end) 100%);
105
105
  color: white;
106
106
  padding: 16px 16px;
107
+ min-height: 76px;
107
108
  display: flex;
108
109
  align-items: center;
109
110
  justify-content: space-between;
@@ -156,7 +157,7 @@ const styles = `
156
157
  .chat-list {
157
158
  flex: 1;
158
159
  overflow-y: auto;
159
- padding: 8px 0;
160
+ padding: 0 0 8px 0;
160
161
  }
161
162
 
162
163
  .chat-item {
@@ -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;
@@ -1189,10 +1190,44 @@ export const longChatPane = {
1189
1190
  try {
1190
1191
  const authFetch = context.authFetch ? context.authFetch() : fetch
1191
1192
  const doc = subject.doc ? subject.doc() : subject
1192
-
1193
- // Build SPARQL DELETE for all statements about this message
1194
1193
  const msgUri = message.uri
1195
- 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}`
1196
1231
 
1197
1232
  const response = await authFetch(doc.value || doc.uri, {
1198
1233
  method: 'PATCH',
@@ -1204,14 +1239,22 @@ export const longChatPane = {
1204
1239
  throw new Error(`Delete failed: ${response.status}`)
1205
1240
  }
1206
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
+
1207
1249
  // Remove from UI
1208
1250
  rowEl.remove()
1209
1251
  renderedUris.delete(message.uri)
1210
1252
  messages = messages.filter(m => m.uri !== message.uri)
1211
- statusEl.textContent = `${messages.length} messages`
1212
1253
 
1213
1254
  } catch (err) {
1214
1255
  console.error('Delete error:', err)
1256
+ statusEl.textContent = '✗ Delete failed'
1257
+ setTimeout(() => { statusEl.textContent = `${messages.length} messages` }, 2000)
1215
1258
  alert('Failed to delete: ' + err.message)
1216
1259
  }
1217
1260
  }
@@ -1384,7 +1427,7 @@ export const longChatPane = {
1384
1427
  }
1385
1428
 
1386
1429
  // Load messages from store
1387
- async function loadMessages() {
1430
+ async function loadMessages(skipFetch = false) {
1388
1431
  if (isFirstLoad) {
1389
1432
  statusEl.textContent = 'Loading messages...'
1390
1433
  messagesContainer.innerHTML = ''
@@ -1399,9 +1442,11 @@ export const longChatPane = {
1399
1442
  const DCT = ns('http://purl.org/dc/terms/')
1400
1443
  const FOAF = ns('http://xmlns.com/foaf/0.1/')
1401
1444
 
1402
- // Fetch the document
1445
+ // Fetch the document (skip if refresh already loaded fresh data)
1403
1446
  const doc = subject.doc ? subject.doc() : subject
1404
- await store.fetcher.load(doc)
1447
+ if (!skipFetch) {
1448
+ await store.fetcher.load(doc)
1449
+ }
1405
1450
 
1406
1451
  // Get chat title from the subject or document
1407
1452
  const chatNode = subject.uri.includes('#') ? subject : $rdf.sym(subject.uri + '#this')
@@ -1480,6 +1525,17 @@ export const longChatPane = {
1480
1525
  // Find messages that haven't been rendered yet
1481
1526
  const unrenderedMessages = allMessages.filter(m => !renderedUris.has(m.uri))
1482
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
+
1483
1539
  // Render only new messages (or all on first load)
1484
1540
  if (isFirstLoad) {
1485
1541
  if (allMessages.length === 0) {
@@ -1669,8 +1725,29 @@ export const longChatPane = {
1669
1725
  container.refresh = async function() {
1670
1726
  // Re-fetch the document
1671
1727
  const doc = subject.doc ? subject.doc() : subject
1672
- await store.fetcher.load(doc, { force: true })
1673
- 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)
1674
1751
  }
1675
1752
 
1676
1753
  // Initial load