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 +20 -3
- package/index.html +142 -33
- package/package.json +1 -1
- package/src/chatListPane.js +106 -3
- package/src/longChatPane.js +90 -9
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
918
|
-
|
|
919
|
-
|
|
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
|
|
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
package/src/chatListPane.js
CHANGED
|
@@ -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
|
-
|
|
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 }
|
package/src/longChatPane.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1669
|
-
|
|
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
|