solid-chat 0.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.
@@ -0,0 +1,742 @@
1
+ /**
2
+ * Chat List Pane - Sidebar showing user's chats
3
+ *
4
+ * Follows same pattern as longChatPane.js.
5
+ * Uses vanilla JS DOM manipulation.
6
+ */
7
+
8
+ // Namespaces for Type Index discovery
9
+ const SOLID = {
10
+ publicTypeIndex: 'http://www.w3.org/ns/solid/terms#publicTypeIndex',
11
+ privateTypeIndex: 'http://www.w3.org/ns/solid/terms#privateTypeIndex',
12
+ forClass: 'http://www.w3.org/ns/solid/terms#forClass',
13
+ instance: 'http://www.w3.org/ns/solid/terms#instance'
14
+ }
15
+
16
+ const MEETING = {
17
+ LongChat: 'http://www.w3.org/ns/pim/meeting#LongChat'
18
+ }
19
+
20
+ // Storage key for localStorage
21
+ const STORAGE_KEY = 'solidchat-chats'
22
+
23
+ // Default global chat
24
+ const DEFAULT_CHAT = {
25
+ uri: 'https://solid-chat.solidcommunity.net/public/global/chat.ttl',
26
+ title: 'Solid Chat Global',
27
+ lastMessage: 'Welcome to the global chat!',
28
+ timestamp: '2025-12-31T09:00:00Z'
29
+ }
30
+
31
+ // CSS styles
32
+ const styles = `
33
+ .chat-list-pane {
34
+ --gradient-start: #667eea;
35
+ --gradient-end: #9f7aea;
36
+ --bg: #ffffff;
37
+ --bg-hover: #f7f8fc;
38
+ --bg-active: #ede9fe;
39
+ --text: #2d3748;
40
+ --text-secondary: #4a5568;
41
+ --text-muted: #a0aec0;
42
+ --border: #e2e8f0;
43
+
44
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
45
+ display: flex;
46
+ flex-direction: column;
47
+ height: 100%;
48
+ background: var(--bg);
49
+ border-right: 1px solid var(--border);
50
+ }
51
+
52
+ .chat-list-pane * {
53
+ box-sizing: border-box;
54
+ }
55
+
56
+ .sidebar-header {
57
+ background: linear-gradient(135deg, var(--gradient-start) 0%, var(--gradient-end) 100%);
58
+ color: white;
59
+ padding: 16px 16px;
60
+ display: flex;
61
+ align-items: center;
62
+ justify-content: space-between;
63
+ gap: 12px;
64
+ }
65
+
66
+ .sidebar-title {
67
+ font-weight: 600;
68
+ font-size: 18px;
69
+ }
70
+
71
+ .add-chat-btn {
72
+ width: 36px;
73
+ height: 36px;
74
+ border-radius: 50%;
75
+ border: none;
76
+ background: rgba(255,255,255,0.2);
77
+ color: white;
78
+ cursor: pointer;
79
+ display: flex;
80
+ align-items: center;
81
+ justify-content: center;
82
+ transition: background 0.2s;
83
+ font-size: 20px;
84
+ font-weight: 300;
85
+ }
86
+
87
+ .add-chat-btn:hover {
88
+ background: rgba(255,255,255,0.3);
89
+ }
90
+
91
+ .chat-list {
92
+ flex: 1;
93
+ overflow-y: auto;
94
+ padding: 8px 0;
95
+ }
96
+
97
+ .chat-item {
98
+ display: flex;
99
+ align-items: center;
100
+ gap: 12px;
101
+ padding: 12px 16px;
102
+ cursor: pointer;
103
+ transition: background 0.15s;
104
+ border-left: 3px solid transparent;
105
+ }
106
+
107
+ .chat-item:hover {
108
+ background: var(--bg-hover);
109
+ }
110
+
111
+ .chat-item.active {
112
+ background: var(--bg-active);
113
+ border-left-color: var(--gradient-start);
114
+ }
115
+
116
+ .chat-item-avatar {
117
+ width: 48px;
118
+ height: 48px;
119
+ border-radius: 50%;
120
+ background: linear-gradient(135deg, var(--gradient-start) 0%, var(--gradient-end) 100%);
121
+ color: white;
122
+ display: flex;
123
+ align-items: center;
124
+ justify-content: center;
125
+ font-weight: 600;
126
+ font-size: 18px;
127
+ flex-shrink: 0;
128
+ }
129
+
130
+ .chat-item-content {
131
+ flex: 1;
132
+ min-width: 0;
133
+ overflow: hidden;
134
+ }
135
+
136
+ .chat-item-header {
137
+ display: flex;
138
+ justify-content: space-between;
139
+ align-items: baseline;
140
+ gap: 8px;
141
+ margin-bottom: 4px;
142
+ }
143
+
144
+ .chat-item-title {
145
+ font-weight: 500;
146
+ font-size: 15px;
147
+ color: var(--text);
148
+ white-space: nowrap;
149
+ overflow: hidden;
150
+ text-overflow: ellipsis;
151
+ }
152
+
153
+ .chat-item-time {
154
+ font-size: 12px;
155
+ color: var(--text-muted);
156
+ flex-shrink: 0;
157
+ }
158
+
159
+ .chat-item-preview {
160
+ font-size: 13px;
161
+ color: var(--text-muted);
162
+ white-space: nowrap;
163
+ overflow: hidden;
164
+ text-overflow: ellipsis;
165
+ }
166
+
167
+ .sidebar-footer {
168
+ padding: 12px 16px;
169
+ border-top: 1px solid var(--border);
170
+ }
171
+
172
+ .discover-btn {
173
+ width: 100%;
174
+ padding: 10px 16px;
175
+ border: 1px solid var(--border);
176
+ border-radius: 8px;
177
+ background: var(--bg);
178
+ color: var(--text-secondary);
179
+ font-size: 14px;
180
+ font-weight: 500;
181
+ cursor: pointer;
182
+ transition: all 0.2s;
183
+ display: flex;
184
+ align-items: center;
185
+ justify-content: center;
186
+ gap: 8px;
187
+ }
188
+
189
+ .discover-btn:hover {
190
+ background: var(--bg-hover);
191
+ border-color: var(--gradient-start);
192
+ color: var(--gradient-start);
193
+ }
194
+
195
+ .discover-btn:disabled {
196
+ opacity: 0.5;
197
+ cursor: not-allowed;
198
+ }
199
+
200
+ .empty-list {
201
+ flex: 1;
202
+ display: flex;
203
+ flex-direction: column;
204
+ align-items: center;
205
+ justify-content: center;
206
+ padding: 32px 16px;
207
+ text-align: center;
208
+ color: var(--text-muted);
209
+ }
210
+
211
+ .empty-list-icon {
212
+ font-size: 48px;
213
+ margin-bottom: 12px;
214
+ opacity: 0.5;
215
+ }
216
+
217
+ .empty-list-text {
218
+ font-size: 14px;
219
+ line-height: 1.5;
220
+ }
221
+
222
+ /* Add Chat Modal */
223
+ .modal-overlay {
224
+ position: fixed;
225
+ top: 0;
226
+ left: 0;
227
+ right: 0;
228
+ bottom: 0;
229
+ background: rgba(0,0,0,0.5);
230
+ display: flex;
231
+ align-items: center;
232
+ justify-content: center;
233
+ z-index: 1000;
234
+ }
235
+
236
+ .modal {
237
+ background: white;
238
+ border-radius: 16px;
239
+ padding: 24px;
240
+ width: 90%;
241
+ max-width: 400px;
242
+ box-shadow: 0 20px 60px rgba(0,0,0,0.3);
243
+ }
244
+
245
+ .modal-title {
246
+ font-size: 18px;
247
+ font-weight: 600;
248
+ margin-bottom: 16px;
249
+ color: var(--text);
250
+ }
251
+
252
+ .modal-input {
253
+ width: 100%;
254
+ padding: 12px 16px;
255
+ border: 1px solid var(--border);
256
+ border-radius: 8px;
257
+ font-size: 14px;
258
+ font-family: inherit;
259
+ margin-bottom: 16px;
260
+ transition: border-color 0.2s;
261
+ }
262
+
263
+ .modal-input:focus {
264
+ outline: none;
265
+ border-color: var(--gradient-start);
266
+ }
267
+
268
+ .modal-buttons {
269
+ display: flex;
270
+ gap: 12px;
271
+ justify-content: flex-end;
272
+ }
273
+
274
+ .modal-btn {
275
+ padding: 10px 20px;
276
+ border-radius: 8px;
277
+ font-size: 14px;
278
+ font-weight: 500;
279
+ cursor: pointer;
280
+ transition: all 0.2s;
281
+ }
282
+
283
+ .modal-btn-cancel {
284
+ background: var(--bg-hover);
285
+ border: 1px solid var(--border);
286
+ color: var(--text-secondary);
287
+ }
288
+
289
+ .modal-btn-cancel:hover {
290
+ background: var(--border);
291
+ }
292
+
293
+ .modal-btn-add {
294
+ background: linear-gradient(135deg, var(--gradient-start) 0%, var(--gradient-end) 100%);
295
+ border: none;
296
+ color: white;
297
+ }
298
+
299
+ .modal-btn-add:hover {
300
+ transform: translateY(-1px);
301
+ box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
302
+ }
303
+
304
+ .modal-btn-add:disabled {
305
+ opacity: 0.5;
306
+ cursor: not-allowed;
307
+ transform: none;
308
+ box-shadow: none;
309
+ }
310
+
311
+ /* Delete button on chat item */
312
+ .chat-item-delete {
313
+ width: 24px;
314
+ height: 24px;
315
+ border-radius: 50%;
316
+ border: none;
317
+ background: transparent;
318
+ color: var(--text-muted);
319
+ cursor: pointer;
320
+ display: none;
321
+ align-items: center;
322
+ justify-content: center;
323
+ font-size: 16px;
324
+ flex-shrink: 0;
325
+ transition: all 0.2s;
326
+ }
327
+
328
+ .chat-item:hover .chat-item-delete {
329
+ display: flex;
330
+ }
331
+
332
+ .chat-item-delete:hover {
333
+ background: #fee2e2;
334
+ color: #dc2626;
335
+ }
336
+ `
337
+
338
+ // Inject styles once
339
+ let stylesInjected = false
340
+ function injectStyles(dom) {
341
+ if (stylesInjected) return
342
+ const styleEl = dom.createElement('style')
343
+ styleEl.textContent = styles
344
+ dom.head.appendChild(styleEl)
345
+ stylesInjected = true
346
+ }
347
+
348
+ // State
349
+ let chatList = []
350
+ let activeUri = null
351
+ let listContainer = null
352
+ let onSelectCallback = null
353
+ let contextRef = null
354
+
355
+ // Load chat list from localStorage
356
+ function loadChatList() {
357
+ try {
358
+ const stored = localStorage.getItem(STORAGE_KEY)
359
+ if (stored) {
360
+ chatList = JSON.parse(stored)
361
+ }
362
+ } catch (e) {
363
+ console.warn('Failed to load chat list:', e)
364
+ chatList = []
365
+ }
366
+
367
+ // Add default global chat if list is empty
368
+ if (chatList.length === 0) {
369
+ chatList.push({ ...DEFAULT_CHAT })
370
+ saveChatList()
371
+ }
372
+
373
+ // Ensure default chat exists in list
374
+ const hasDefault = chatList.some(c => c.uri === DEFAULT_CHAT.uri)
375
+ if (!hasDefault) {
376
+ chatList.unshift({ ...DEFAULT_CHAT })
377
+ saveChatList()
378
+ }
379
+
380
+ return chatList
381
+ }
382
+
383
+ // Save chat list to localStorage
384
+ function saveChatList() {
385
+ try {
386
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(chatList))
387
+ } catch (e) {
388
+ console.warn('Failed to save chat list:', e)
389
+ }
390
+ }
391
+
392
+ // Add a chat to the list
393
+ function addChat(uri, title, lastMessage = '', timestamp = null) {
394
+ // Check if already exists
395
+ const existing = chatList.find(c => c.uri === uri)
396
+ if (existing) {
397
+ // Update existing
398
+ if (title) existing.title = title
399
+ if (lastMessage) existing.lastMessage = lastMessage
400
+ if (timestamp) existing.timestamp = timestamp
401
+ } else {
402
+ chatList.unshift({
403
+ uri,
404
+ title: title || getTitleFromUri(uri),
405
+ lastMessage,
406
+ timestamp: timestamp || new Date().toISOString()
407
+ })
408
+ }
409
+ saveChatList()
410
+ renderChatList()
411
+ }
412
+
413
+ // Remove a chat from the list
414
+ function removeChat(uri) {
415
+ chatList = chatList.filter(c => c.uri !== uri)
416
+ saveChatList()
417
+ renderChatList()
418
+ }
419
+
420
+ // Update chat preview (called after loading messages)
421
+ function updateChatPreview(uri, lastMessage, timestamp) {
422
+ const chat = chatList.find(c => c.uri === uri)
423
+ if (chat) {
424
+ chat.lastMessage = lastMessage
425
+ chat.timestamp = timestamp
426
+ saveChatList()
427
+ renderChatList()
428
+ }
429
+ }
430
+
431
+ // Get title from URI
432
+ function getTitleFromUri(uri) {
433
+ try {
434
+ const url = new URL(uri)
435
+ const path = url.pathname
436
+ const parts = path.split('/').filter(Boolean)
437
+ const name = parts[parts.length - 1] || parts[parts.length - 2] || 'Chat'
438
+ return decodeURIComponent(name.replace(/\.ttl$/, '').replace(/[-_]/g, ' '))
439
+ } catch {
440
+ return 'Chat'
441
+ }
442
+ }
443
+
444
+ // Format timestamp for display
445
+ function formatTimestamp(timestamp) {
446
+ if (!timestamp) return ''
447
+ const date = new Date(timestamp)
448
+ const now = new Date()
449
+ const isToday = date.toDateString() === now.toDateString()
450
+
451
+ if (isToday) {
452
+ return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false })
453
+ }
454
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
455
+ }
456
+
457
+ // Get initials from title
458
+ function getInitials(title) {
459
+ if (!title) return '?'
460
+ return title.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()
461
+ }
462
+
463
+ // Render the chat list
464
+ function renderChatList() {
465
+ if (!listContainer || !contextRef) return
466
+
467
+ const dom = contextRef.dom
468
+ listContainer.innerHTML = ''
469
+
470
+ if (chatList.length === 0) {
471
+ const empty = dom.createElement('div')
472
+ empty.className = 'empty-list'
473
+ empty.innerHTML = `
474
+ <div class="empty-list-icon">💬</div>
475
+ <div class="empty-list-text">No chats yet.<br>Add one with the + button above.</div>
476
+ `
477
+ listContainer.appendChild(empty)
478
+ return
479
+ }
480
+
481
+ chatList.forEach(chat => {
482
+ const item = dom.createElement('div')
483
+ item.className = 'chat-item' + (chat.uri === activeUri ? ' active' : '')
484
+
485
+ const avatar = dom.createElement('div')
486
+ avatar.className = 'chat-item-avatar'
487
+ avatar.textContent = getInitials(chat.title)
488
+
489
+ const content = dom.createElement('div')
490
+ content.className = 'chat-item-content'
491
+
492
+ const header = dom.createElement('div')
493
+ header.className = 'chat-item-header'
494
+
495
+ const title = dom.createElement('div')
496
+ title.className = 'chat-item-title'
497
+ title.textContent = chat.title
498
+
499
+ const time = dom.createElement('div')
500
+ time.className = 'chat-item-time'
501
+ time.textContent = formatTimestamp(chat.timestamp)
502
+
503
+ header.appendChild(title)
504
+ header.appendChild(time)
505
+
506
+ const preview = dom.createElement('div')
507
+ preview.className = 'chat-item-preview'
508
+ preview.textContent = chat.lastMessage || 'No messages yet'
509
+
510
+ content.appendChild(header)
511
+ content.appendChild(preview)
512
+
513
+ const deleteBtn = dom.createElement('button')
514
+ deleteBtn.className = 'chat-item-delete'
515
+ deleteBtn.textContent = '×'
516
+ deleteBtn.title = 'Remove from list'
517
+ deleteBtn.onclick = (e) => {
518
+ e.stopPropagation()
519
+ removeChat(chat.uri)
520
+ }
521
+
522
+ item.appendChild(avatar)
523
+ item.appendChild(content)
524
+ item.appendChild(deleteBtn)
525
+
526
+ item.onclick = () => {
527
+ activeUri = chat.uri
528
+ renderChatList()
529
+ if (onSelectCallback) {
530
+ onSelectCallback(chat.uri)
531
+ }
532
+ }
533
+
534
+ listContainer.appendChild(item)
535
+ })
536
+ }
537
+
538
+ // Show add chat modal
539
+ function showAddModal(dom) {
540
+ const overlay = dom.createElement('div')
541
+ overlay.className = 'modal-overlay'
542
+
543
+ const modal = dom.createElement('div')
544
+ modal.className = 'modal'
545
+
546
+ modal.innerHTML = `
547
+ <div class="modal-title">Add Chat</div>
548
+ <input type="url" class="modal-input" placeholder="Enter chat URL..." />
549
+ <div class="modal-buttons">
550
+ <button class="modal-btn modal-btn-cancel">Cancel</button>
551
+ <button class="modal-btn modal-btn-add">Add</button>
552
+ </div>
553
+ `
554
+
555
+ overlay.appendChild(modal)
556
+ dom.body.appendChild(overlay)
557
+
558
+ const input = modal.querySelector('.modal-input')
559
+ const cancelBtn = modal.querySelector('.modal-btn-cancel')
560
+ const addBtn = modal.querySelector('.modal-btn-add')
561
+
562
+ input.focus()
563
+
564
+ const close = () => {
565
+ overlay.remove()
566
+ }
567
+
568
+ overlay.onclick = (e) => {
569
+ if (e.target === overlay) close()
570
+ }
571
+
572
+ cancelBtn.onclick = close
573
+
574
+ addBtn.onclick = () => {
575
+ const uri = input.value.trim()
576
+ if (uri) {
577
+ addChat(uri)
578
+ activeUri = uri
579
+ renderChatList()
580
+ if (onSelectCallback) {
581
+ onSelectCallback(uri)
582
+ }
583
+ close()
584
+ }
585
+ }
586
+
587
+ input.onkeydown = (e) => {
588
+ if (e.key === 'Enter') {
589
+ addBtn.click()
590
+ } else if (e.key === 'Escape') {
591
+ close()
592
+ }
593
+ }
594
+ }
595
+
596
+ // Discover chats from Type Index
597
+ async function discoverChats(webId, context) {
598
+ const store = context.session.store
599
+ const $rdf = store.rdflib || globalThis.$rdf
600
+ if (!$rdf || !webId) return []
601
+
602
+ const discovered = []
603
+
604
+ try {
605
+ // Load profile
606
+ const profile = $rdf.sym(webId)
607
+ await store.fetcher.load(profile.doc())
608
+
609
+ const ns = $rdf.Namespace
610
+ const SOLID = ns('http://www.w3.org/ns/solid/terms#')
611
+ const MEETING = ns('http://www.w3.org/ns/pim/meeting#')
612
+
613
+ // Get type indexes
614
+ const publicIndex = store.any(profile, SOLID('publicTypeIndex'))
615
+ const privateIndex = store.any(profile, SOLID('privateTypeIndex'))
616
+
617
+ const indexes = [publicIndex, privateIndex].filter(Boolean)
618
+
619
+ for (const indexUri of indexes) {
620
+ try {
621
+ await store.fetcher.load(indexUri)
622
+
623
+ // Find LongChat registrations
624
+ const registrations = store.statementsMatching(null, SOLID('forClass'), MEETING('LongChat'), indexUri.doc())
625
+
626
+ for (const reg of registrations) {
627
+ const instance = store.any(reg.subject, SOLID('instance'))
628
+ if (instance) {
629
+ discovered.push(instance.value)
630
+ }
631
+ }
632
+ } catch (e) {
633
+ console.warn('Failed to load type index:', indexUri?.value, e)
634
+ }
635
+ }
636
+ } catch (e) {
637
+ console.warn('Failed to discover chats:', e)
638
+ }
639
+
640
+ return discovered
641
+ }
642
+
643
+ // Main pane definition
644
+ export const chatListPane = {
645
+ name: 'chat-list',
646
+
647
+ render: function(context, options = {}) {
648
+ const dom = context.dom
649
+ contextRef = context
650
+ onSelectCallback = options.onSelectChat
651
+
652
+ // Load saved chats
653
+ loadChatList()
654
+
655
+ // Inject styles
656
+ injectStyles(dom)
657
+
658
+ // Container
659
+ const container = dom.createElement('div')
660
+ container.className = 'chat-list-pane'
661
+
662
+ // Header
663
+ const header = dom.createElement('div')
664
+ header.className = 'sidebar-header'
665
+
666
+ const title = dom.createElement('div')
667
+ title.className = 'sidebar-title'
668
+ title.textContent = 'Chats'
669
+
670
+ const addBtn = dom.createElement('button')
671
+ addBtn.className = 'add-chat-btn'
672
+ addBtn.textContent = '+'
673
+ addBtn.title = 'Add chat'
674
+ addBtn.onclick = () => showAddModal(dom)
675
+
676
+ header.appendChild(title)
677
+ header.appendChild(addBtn)
678
+
679
+ // Chat list
680
+ listContainer = dom.createElement('div')
681
+ listContainer.className = 'chat-list'
682
+
683
+ // Footer with discover button
684
+ const footer = dom.createElement('div')
685
+ footer.className = 'sidebar-footer'
686
+
687
+ const discoverBtn = dom.createElement('button')
688
+ discoverBtn.className = 'discover-btn'
689
+ discoverBtn.innerHTML = '🔍 Discover Chats'
690
+
691
+ discoverBtn.onclick = async () => {
692
+ const webId = context.session?.webId || options.webId
693
+ if (!webId) {
694
+ alert('Please login first to discover chats from your pod.')
695
+ return
696
+ }
697
+
698
+ discoverBtn.disabled = true
699
+ discoverBtn.textContent = 'Discovering...'
700
+
701
+ try {
702
+ const discovered = await discoverChats(webId, context)
703
+ if (discovered.length > 0) {
704
+ discovered.forEach(uri => addChat(uri))
705
+ alert(`Found ${discovered.length} chat(s)!`)
706
+ } else {
707
+ alert('No chats found in your Type Index.')
708
+ }
709
+ } catch (e) {
710
+ console.error('Discovery failed:', e)
711
+ alert('Discovery failed. Check console for details.')
712
+ }
713
+
714
+ discoverBtn.disabled = false
715
+ discoverBtn.innerHTML = '🔍 Discover Chats'
716
+ }
717
+
718
+ footer.appendChild(discoverBtn)
719
+
720
+ // Assemble
721
+ container.appendChild(header)
722
+ container.appendChild(listContainer)
723
+ container.appendChild(footer)
724
+
725
+ // Initial render
726
+ renderChatList()
727
+
728
+ return container
729
+ },
730
+
731
+ // Public API
732
+ addChat,
733
+ removeChat,
734
+ updateChatPreview,
735
+ setActiveChat: (uri) => {
736
+ activeUri = uri
737
+ renderChatList()
738
+ }
739
+ }
740
+
741
+ // Export for use in index.html
742
+ export { addChat, updateChatPreview }