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,837 @@
1
+ /**
2
+ * Long Chat Pane - WhatsApp-style chat interface for Solid
3
+ *
4
+ * Experimental pane inspired by Wave messenger design.
5
+ * Uses vanilla JS DOM manipulation.
6
+ */
7
+
8
+ const CHAT = {
9
+ namespace: 'http://www.w3.org/2007/ont/chat#',
10
+ Message: 'http://www.w3.org/2007/ont/chat#Message',
11
+ Chat: 'http://www.w3.org/2007/ont/chat#Chat'
12
+ }
13
+
14
+ const SIOC = {
15
+ namespace: 'http://rdfs.org/sioc/ns#',
16
+ Post: 'http://rdfs.org/sioc/ns#Post',
17
+ content: 'http://rdfs.org/sioc/ns#content',
18
+ Container: 'http://rdfs.org/sioc/ns#Container'
19
+ }
20
+
21
+ const FLOW = {
22
+ namespace: 'http://www.w3.org/2005/01/wf/flow#',
23
+ message: 'http://www.w3.org/2005/01/wf/flow#message',
24
+ Message: 'http://www.w3.org/2005/01/wf/flow#Message'
25
+ }
26
+
27
+ // CSS styles as a string (will be injected)
28
+ const styles = `
29
+ .long-chat-pane {
30
+ --gradient-start: #667eea;
31
+ --gradient-end: #9f7aea;
32
+ --bg-chat: #f7f8fc;
33
+ --bg-message-in: #ffffff;
34
+ --bg-message-out: linear-gradient(135deg, #e8e4f4 0%, #f0ecf8 100%);
35
+ --text: #2d3748;
36
+ --text-secondary: #4a5568;
37
+ --text-muted: #a0aec0;
38
+ --border: #e2e8f0;
39
+ --accent: #805ad5;
40
+
41
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
42
+ display: flex;
43
+ flex-direction: column;
44
+ height: 100%;
45
+ border: none;
46
+ border-radius: 0;
47
+ overflow: hidden;
48
+ background: var(--bg-chat);
49
+ }
50
+
51
+ .long-chat-pane * {
52
+ box-sizing: border-box;
53
+ }
54
+
55
+ .chat-header {
56
+ background: linear-gradient(135deg, var(--gradient-start) 0%, var(--gradient-end) 100%);
57
+ color: white;
58
+ padding: 16px 20px;
59
+ display: flex;
60
+ align-items: center;
61
+ gap: 14px;
62
+ }
63
+
64
+ .chat-avatar {
65
+ width: 44px;
66
+ height: 44px;
67
+ border-radius: 50%;
68
+ background: rgba(255,255,255,0.2);
69
+ backdrop-filter: blur(4px);
70
+ display: flex;
71
+ align-items: center;
72
+ justify-content: center;
73
+ font-weight: 600;
74
+ font-size: 18px;
75
+ border: 2px solid rgba(255,255,255,0.3);
76
+ }
77
+
78
+ .chat-title {
79
+ flex: 1;
80
+ }
81
+
82
+ .chat-name {
83
+ font-weight: 500;
84
+ font-size: 14px;
85
+ color: white;
86
+ text-decoration: none;
87
+ opacity: 0.95;
88
+ }
89
+
90
+ .chat-name:hover {
91
+ text-decoration: underline;
92
+ opacity: 1;
93
+ }
94
+
95
+ .chat-status {
96
+ font-size: 13px;
97
+ opacity: 0.8;
98
+ }
99
+
100
+ .messages-container {
101
+ flex: 1;
102
+ overflow-y: auto;
103
+ padding: 20px;
104
+ display: flex;
105
+ flex-direction: column;
106
+ gap: 8px;
107
+ background: linear-gradient(180deg, #f7f8fc 0%, #f0f2f8 100%);
108
+ }
109
+
110
+ .message-row {
111
+ display: flex;
112
+ margin-bottom: 2px;
113
+ align-items: flex-end;
114
+ gap: 8px;
115
+ }
116
+
117
+ .message-row.sent {
118
+ justify-content: flex-end;
119
+ }
120
+
121
+ .message-row.received {
122
+ justify-content: flex-start;
123
+ }
124
+
125
+ .message-avatar {
126
+ width: 32px;
127
+ height: 32px;
128
+ border-radius: 50%;
129
+ background: linear-gradient(135deg, var(--gradient-start) 0%, var(--gradient-end) 100%);
130
+ color: white;
131
+ display: flex;
132
+ align-items: center;
133
+ justify-content: center;
134
+ font-size: 12px;
135
+ font-weight: 600;
136
+ flex-shrink: 0;
137
+ overflow: hidden;
138
+ }
139
+
140
+ .message-avatar img {
141
+ width: 100%;
142
+ height: 100%;
143
+ object-fit: cover;
144
+ }
145
+
146
+ .message-row.sent .message-avatar {
147
+ order: 2;
148
+ }
149
+
150
+ .message-bubble {
151
+ max-width: 70%;
152
+ padding: 10px 14px 10px;
153
+ border-radius: 18px;
154
+ position: relative;
155
+ box-shadow: 0 1px 2px rgba(0,0,0,0.08);
156
+ }
157
+
158
+ .message-bubble.sent {
159
+ background: #ede9fe;
160
+ border-bottom-right-radius: 4px;
161
+ }
162
+
163
+ .message-bubble.sent .message-time {
164
+ color: #a0aec0;
165
+ }
166
+
167
+ .message-bubble.received {
168
+ background: var(--bg-message-in);
169
+ border-bottom-left-radius: 4px;
170
+ border: 1px solid #e8e8f0;
171
+ }
172
+
173
+ .message-text {
174
+ font-size: 14.2px;
175
+ line-height: 19px;
176
+ color: var(--text);
177
+ white-space: pre-wrap;
178
+ word-wrap: break-word;
179
+ }
180
+
181
+ .message-meta {
182
+ display: flex;
183
+ align-items: center;
184
+ justify-content: flex-end;
185
+ gap: 4px;
186
+ margin-top: 2px;
187
+ }
188
+
189
+ .message-time {
190
+ font-size: 11px;
191
+ color: var(--text-muted);
192
+ }
193
+
194
+ .message-author {
195
+ font-size: 12px;
196
+ font-weight: 600;
197
+ color: #805ad5;
198
+ margin-bottom: 4px;
199
+ text-decoration: none;
200
+ display: inline-block;
201
+ cursor: pointer;
202
+ transition: color 0.2s;
203
+ }
204
+
205
+ .message-author:hover {
206
+ color: #667eea;
207
+ text-decoration: underline;
208
+ }
209
+
210
+ .input-area {
211
+ background: white;
212
+ padding: 12px 20px;
213
+ display: flex;
214
+ align-items: flex-end;
215
+ gap: 12px;
216
+ border-top: 1px solid #e8e8f0;
217
+ }
218
+
219
+ .input-wrapper {
220
+ flex: 1;
221
+ display: flex;
222
+ align-items: flex-end;
223
+ background: #f7f8fc;
224
+ border-radius: 24px;
225
+ padding: 10px 16px;
226
+ border: 1px solid #e2e8f0;
227
+ transition: border-color 0.2s, box-shadow 0.2s;
228
+ }
229
+
230
+ .input-wrapper:focus-within {
231
+ border-color: #805ad5;
232
+ box-shadow: 0 0 0 3px rgba(128, 90, 213, 0.1);
233
+ }
234
+
235
+ .message-input {
236
+ flex: 1;
237
+ border: none;
238
+ background: transparent;
239
+ font-size: 15px;
240
+ font-family: inherit;
241
+ color: var(--text);
242
+ resize: none;
243
+ max-height: 100px;
244
+ line-height: 20px;
245
+ outline: none;
246
+ }
247
+
248
+ .message-input::placeholder {
249
+ color: var(--text-muted);
250
+ }
251
+
252
+ .send-btn {
253
+ width: 44px;
254
+ height: 44px;
255
+ border-radius: 50%;
256
+ border: none;
257
+ background: linear-gradient(135deg, #667eea 0%, #9f7aea 100%);
258
+ color: white;
259
+ cursor: pointer;
260
+ display: flex;
261
+ align-items: center;
262
+ justify-content: center;
263
+ flex-shrink: 0;
264
+ transition: transform 0.2s, box-shadow 0.2s;
265
+ box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4);
266
+ }
267
+
268
+ .send-btn:hover {
269
+ transform: scale(1.05);
270
+ box-shadow: 0 4px 12px rgba(102, 126, 234, 0.5);
271
+ }
272
+
273
+ .send-btn:disabled {
274
+ background: #e2e8f0;
275
+ box-shadow: none;
276
+ cursor: not-allowed;
277
+ transform: none;
278
+ }
279
+
280
+ .emoji-btn {
281
+ width: 36px;
282
+ height: 36px;
283
+ border-radius: 50%;
284
+ border: none;
285
+ background: transparent;
286
+ color: var(--text-muted);
287
+ cursor: pointer;
288
+ display: flex;
289
+ align-items: center;
290
+ justify-content: center;
291
+ font-size: 20px;
292
+ transition: background 0.2s, color 0.2s;
293
+ flex-shrink: 0;
294
+ }
295
+
296
+ .emoji-btn:hover {
297
+ background: #f0f2f8;
298
+ color: var(--text);
299
+ }
300
+
301
+ .emoji-picker {
302
+ position: absolute;
303
+ bottom: 100%;
304
+ left: 0;
305
+ margin-bottom: 8px;
306
+ background: white;
307
+ border-radius: 12px;
308
+ box-shadow: 0 4px 20px rgba(0,0,0,0.15);
309
+ padding: 8px;
310
+ display: none;
311
+ z-index: 100;
312
+ }
313
+
314
+ .emoji-picker.open {
315
+ display: block;
316
+ }
317
+
318
+ .emoji-grid {
319
+ display: grid;
320
+ grid-template-columns: repeat(8, 1fr);
321
+ gap: 2px;
322
+ }
323
+
324
+ .emoji-grid button {
325
+ width: 32px;
326
+ height: 32px;
327
+ border: none;
328
+ background: transparent;
329
+ font-size: 18px;
330
+ cursor: pointer;
331
+ border-radius: 6px;
332
+ transition: background 0.15s;
333
+ }
334
+
335
+ .emoji-grid button:hover {
336
+ background: #f0f2f8;
337
+ }
338
+
339
+ .input-area {
340
+ position: relative;
341
+ }
342
+
343
+ .send-btn svg {
344
+ width: 20px;
345
+ height: 20px;
346
+ }
347
+
348
+ .empty-chat {
349
+ flex: 1;
350
+ display: flex;
351
+ flex-direction: column;
352
+ align-items: center;
353
+ justify-content: center;
354
+ color: var(--text-muted);
355
+ text-align: center;
356
+ padding: 40px;
357
+ }
358
+
359
+ .empty-chat-icon {
360
+ font-size: 48px;
361
+ margin-bottom: 16px;
362
+ }
363
+
364
+ .loading {
365
+ text-align: center;
366
+ padding: 20px;
367
+ color: var(--text-muted);
368
+ }
369
+ `
370
+
371
+ // Inject styles once
372
+ let stylesInjected = false
373
+ function injectStyles(dom) {
374
+ if (stylesInjected) return
375
+ const styleEl = dom.createElement('style')
376
+ styleEl.textContent = styles
377
+ dom.head.appendChild(styleEl)
378
+ stylesInjected = true
379
+ }
380
+
381
+ // Format timestamp
382
+ function formatTime(date) {
383
+ if (!date) return ''
384
+ const d = new Date(date)
385
+ return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false })
386
+ }
387
+
388
+ // Get initials from name
389
+ function getInitials(name) {
390
+ if (!name) return '?'
391
+ return name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()
392
+ }
393
+
394
+ // Avatar cache
395
+ const avatarCache = new Map()
396
+
397
+ // Fetch avatar from WebID profile
398
+ async function fetchAvatar(webId, store, $rdf) {
399
+ if (!webId) return null
400
+ if (avatarCache.has(webId)) return avatarCache.get(webId)
401
+
402
+ try {
403
+ const profile = $rdf.sym(webId)
404
+ await store.fetcher.load(profile.doc())
405
+
406
+ const ns = $rdf.Namespace
407
+ const FOAF = ns('http://xmlns.com/foaf/0.1/')
408
+ const VCARD = ns('http://www.w3.org/2006/vcard/ns#')
409
+
410
+ const avatar = store.any(profile, FOAF('img'))?.value ||
411
+ store.any(profile, FOAF('depiction'))?.value ||
412
+ store.any(profile, VCARD('hasPhoto'))?.value
413
+
414
+ avatarCache.set(webId, avatar)
415
+ return avatar
416
+ } catch (e) {
417
+ avatarCache.set(webId, null)
418
+ return null
419
+ }
420
+ }
421
+
422
+ // Create message element
423
+ function createMessageElement(dom, message, isOwn) {
424
+ const row = dom.createElement('div')
425
+ row.className = `message-row ${isOwn ? 'sent' : 'received'}`
426
+
427
+ // Avatar
428
+ const avatar = dom.createElement('div')
429
+ avatar.className = 'message-avatar'
430
+ avatar.textContent = getInitials(message.author || '?')
431
+ avatar.dataset.webid = message.authorUri || ''
432
+ row.appendChild(avatar)
433
+
434
+ const bubble = dom.createElement('div')
435
+ bubble.className = `message-bubble ${isOwn ? 'sent' : 'received'}`
436
+
437
+ // Author (for received messages in group chats)
438
+ if (!isOwn && message.author) {
439
+ const author = dom.createElement('a')
440
+ author.className = 'message-author'
441
+ author.textContent = message.author
442
+ if (message.authorUri) {
443
+ author.href = message.authorUri
444
+ author.target = '_blank'
445
+ author.rel = 'noopener'
446
+ }
447
+ bubble.appendChild(author)
448
+ }
449
+
450
+ const text = dom.createElement('div')
451
+ text.className = 'message-text'
452
+ text.textContent = message.content || ''
453
+ bubble.appendChild(text)
454
+
455
+ const meta = dom.createElement('div')
456
+ meta.className = 'message-meta'
457
+
458
+ const time = dom.createElement('span')
459
+ time.className = 'message-time'
460
+ time.textContent = formatTime(message.date)
461
+ meta.appendChild(time)
462
+
463
+ bubble.appendChild(meta)
464
+ row.appendChild(bubble)
465
+
466
+ return row
467
+ }
468
+
469
+ // Main pane definition
470
+ export const longChatPane = {
471
+ icon: 'https://solid.github.io/solid-ui/src/icons/noun_Forum_3572062.svg',
472
+ name: 'long-chat',
473
+
474
+ label: function(subject, context) {
475
+ const dominated = async function() { return false }
476
+ const store = context.session.store
477
+ const dominated2 = async function() { return false }
478
+ const dominated3 = async function() { return false }
479
+
480
+ // Check for chat types
481
+ const dominated4 = async function() { return false }
482
+ const dominated5 = async function() { return false }
483
+
484
+ const dominated6 = async function() { return false }
485
+ const dominated7 = async function() { return false }
486
+
487
+ // Check if it's a chat resource
488
+ const dominated8 = async function() { return false }
489
+ const dominated9 = async function() { return false }
490
+
491
+ const dominated10 = async function() { return false }
492
+ const dominated11 = async function() { return false }
493
+
494
+ const dominated12 = async function() { return false }
495
+ const dominated13 = async function() { return false }
496
+ const dominated14 = async function() { return false }
497
+ const dominated15 = async function() { return false }
498
+
499
+ const dominated16 = async function() { return false }
500
+ const dominated17 = async function() { return false }
501
+ const dominated18 = async function() { return false }
502
+ const $rdf = context.session.store.rdflib || globalThis.$rdf
503
+
504
+ if (!$rdf) return null
505
+
506
+ const ns = $rdf.Namespace
507
+ const RDF = ns('http://www.w3.org/1999/02/22-rdf-syntax-ns#')
508
+ const MEETING = ns('http://www.w3.org/ns/pim/meeting#')
509
+ const FLOW = ns('http://www.w3.org/2005/01/wf/flow#')
510
+
511
+ // Check various chat types
512
+ if (store.holds(subject, RDF('type'), MEETING('LongChat')) ||
513
+ store.holds(subject, RDF('type'), FLOW('Chat')) ||
514
+ subject.uri?.includes('/chat') ||
515
+ subject.uri?.endsWith('.ttl') && subject.uri?.includes('chat')) {
516
+ return 'Long Chat'
517
+ }
518
+
519
+ return null
520
+ },
521
+
522
+ render: function(subject, context, options) {
523
+ const dom = context.dom
524
+ const store = context.session.store
525
+ const $rdf = store.rdflib || globalThis.$rdf
526
+
527
+ injectStyles(dom)
528
+
529
+ // Create main container
530
+ const container = dom.createElement('div')
531
+ container.className = 'long-chat-pane'
532
+
533
+ // Header
534
+ const header = dom.createElement('div')
535
+ header.className = 'chat-header'
536
+
537
+ const avatar = dom.createElement('div')
538
+ avatar.className = 'chat-avatar'
539
+ avatar.textContent = 'C'
540
+ header.appendChild(avatar)
541
+
542
+ const titleDiv = dom.createElement('div')
543
+ titleDiv.className = 'chat-title'
544
+
545
+ const nameEl = dom.createElement('a')
546
+ nameEl.className = 'chat-name'
547
+ nameEl.href = subject.uri
548
+ nameEl.target = '_blank'
549
+ nameEl.rel = 'noopener'
550
+ nameEl.textContent = subject.uri
551
+ titleDiv.appendChild(nameEl)
552
+
553
+ const statusEl = dom.createElement('div')
554
+ statusEl.className = 'chat-status'
555
+ statusEl.textContent = 'Loading...'
556
+ titleDiv.appendChild(statusEl)
557
+
558
+ header.appendChild(titleDiv)
559
+ container.appendChild(header)
560
+
561
+ // Messages container
562
+ const messagesContainer = dom.createElement('div')
563
+ messagesContainer.className = 'messages-container'
564
+ container.appendChild(messagesContainer)
565
+
566
+ // Input area
567
+ const inputArea = dom.createElement('div')
568
+ inputArea.className = 'input-area'
569
+
570
+ // Emoji picker
571
+ const emojiPicker = dom.createElement('div')
572
+ emojiPicker.className = 'emoji-picker'
573
+ const emojiGrid = dom.createElement('div')
574
+ emojiGrid.className = 'emoji-grid'
575
+ const emojis = ['😀','😂','😊','🥰','😎','🤔','😢','😡','👍','👎','❤️','🔥','🎉','✨','💬','👋','🙏','💪','✅','❌','⭐','💡','📌','🚀','☕','🌟','💯','🤝']
576
+ emojis.forEach(e => {
577
+ const btn = dom.createElement('button')
578
+ btn.textContent = e
579
+ btn.type = 'button'
580
+ btn.onclick = () => {
581
+ input.value += e
582
+ input.focus()
583
+ sendBtn.disabled = !input.value.trim()
584
+ }
585
+ emojiGrid.appendChild(btn)
586
+ })
587
+ emojiPicker.appendChild(emojiGrid)
588
+ inputArea.appendChild(emojiPicker)
589
+
590
+ // Emoji button
591
+ const emojiBtn = dom.createElement('button')
592
+ emojiBtn.className = 'emoji-btn'
593
+ emojiBtn.textContent = '😊'
594
+ emojiBtn.type = 'button'
595
+ emojiBtn.onclick = () => {
596
+ emojiPicker.classList.toggle('open')
597
+ }
598
+ inputArea.appendChild(emojiBtn)
599
+
600
+ const inputWrapper = dom.createElement('div')
601
+ inputWrapper.className = 'input-wrapper'
602
+
603
+ const input = dom.createElement('textarea')
604
+ input.className = 'message-input'
605
+ input.placeholder = 'Type a message'
606
+ input.rows = 1
607
+ inputWrapper.appendChild(input)
608
+ inputArea.appendChild(inputWrapper)
609
+
610
+ const sendBtn = dom.createElement('button')
611
+ sendBtn.className = 'send-btn'
612
+ sendBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>'
613
+ sendBtn.disabled = true
614
+ inputArea.appendChild(sendBtn)
615
+
616
+ // Close emoji picker when clicking elsewhere
617
+ dom.addEventListener('click', (e) => {
618
+ if (!emojiPicker.contains(e.target) && e.target !== emojiBtn) {
619
+ emojiPicker.classList.remove('open')
620
+ }
621
+ })
622
+
623
+ container.appendChild(inputArea)
624
+
625
+ // State
626
+ let messages = []
627
+ let currentUser = null
628
+
629
+ // Get current user
630
+ const authn = context.session?.logic?.authn || globalThis.SolidLogic?.authn
631
+ if (authn) {
632
+ currentUser = authn.currentUser()?.value
633
+ }
634
+
635
+ // Load messages from store
636
+ async function loadMessages() {
637
+ statusEl.textContent = 'Loading messages...'
638
+ messagesContainer.innerHTML = ''
639
+ messages = []
640
+
641
+ try {
642
+ // Define namespaces
643
+ const ns = $rdf.Namespace
644
+ const FLOW = ns('http://www.w3.org/2005/01/wf/flow#')
645
+ const SIOC = ns('http://rdfs.org/sioc/ns#')
646
+ const DC = ns('http://purl.org/dc/elements/1.1/')
647
+ const DCT = ns('http://purl.org/dc/terms/')
648
+ const FOAF = ns('http://xmlns.com/foaf/0.1/')
649
+
650
+ // Fetch the document
651
+ const doc = subject.doc ? subject.doc() : subject
652
+ await store.fetcher.load(doc)
653
+
654
+ // Get chat title from the subject or document
655
+ const chatNode = subject.uri.includes('#') ? subject : $rdf.sym(subject.uri + '#this')
656
+ const title = store.any(chatNode, DCT('title'), null, doc)?.value ||
657
+ store.any(chatNode, DC('title'), null, doc)?.value ||
658
+ store.any(subject, DCT('title'), null, doc)?.value ||
659
+ store.any(null, DCT('title'), null, doc)?.value
660
+ if (title) {
661
+ // Show title with URI as subtitle
662
+ nameEl.textContent = title
663
+ nameEl.title = subject.uri // Tooltip shows full URI
664
+ }
665
+
666
+ // Extract all messages with sioc:content from this document
667
+ const contentStatements = store.statementsMatching(null, SIOC('content'), null, doc)
668
+
669
+ for (const st of contentStatements) {
670
+ const msgNode = st.subject
671
+ const content = st.object.value
672
+
673
+ if (!content) continue
674
+
675
+ const date = store.any(msgNode, DCT('created'), null, doc)?.value ||
676
+ store.any(msgNode, DC('created'), null, doc)?.value ||
677
+ store.any(msgNode, DC('date'), null, doc)?.value
678
+
679
+ const maker = store.any(msgNode, FOAF('maker'), null, doc) ||
680
+ store.any(msgNode, DC('author'), null, doc) ||
681
+ store.any(msgNode, DCT('creator'), null, doc)
682
+
683
+ let authorName = null
684
+ if (maker) {
685
+ // Try to get name from loaded profile or use URI fragment
686
+ authorName = store.any(maker, FOAF('name'))?.value ||
687
+ maker.value?.split('//')[1]?.split('.')[0] ||
688
+ 'Unknown'
689
+ }
690
+
691
+ messages.push({
692
+ uri: msgNode.value,
693
+ content,
694
+ date: date ? new Date(date) : new Date(),
695
+ author: authorName,
696
+ authorUri: maker?.value
697
+ })
698
+ }
699
+
700
+ // Sort by date
701
+ messages.sort((a, b) => (a.date || 0) - (b.date || 0))
702
+
703
+ // Keep only last 100 messages for performance
704
+ if (messages.length > 100) {
705
+ messages = messages.slice(-100)
706
+ }
707
+
708
+ // Render messages
709
+ if (messages.length === 0) {
710
+ const empty = dom.createElement('div')
711
+ empty.className = 'empty-chat'
712
+ empty.innerHTML = '<div class="empty-chat-icon">💬</div><div>No messages yet</div><div>Be the first to say hello!</div>'
713
+ messagesContainer.appendChild(empty)
714
+ } else {
715
+ for (const msg of messages) {
716
+ const isOwn = currentUser && msg.authorUri === currentUser
717
+ const el = createMessageElement(dom, msg, isOwn)
718
+ messagesContainer.appendChild(el)
719
+ }
720
+ }
721
+
722
+ statusEl.textContent = `${messages.length} messages`
723
+
724
+ // Scroll to bottom
725
+ messagesContainer.scrollTop = messagesContainer.scrollHeight
726
+
727
+ // Load avatars asynchronously
728
+ const uniqueWebIds = [...new Set(messages.map(m => m.authorUri).filter(Boolean))]
729
+ for (const webId of uniqueWebIds) {
730
+ fetchAvatar(webId, store, $rdf).then(avatarUrl => {
731
+ if (avatarUrl) {
732
+ // Update all avatars for this WebID
733
+ const avatars = messagesContainer.querySelectorAll(`.message-avatar[data-webid="${webId}"]`)
734
+ avatars.forEach(el => {
735
+ el.innerHTML = `<img src="${avatarUrl}" alt="" />`
736
+ })
737
+ }
738
+ })
739
+ }
740
+
741
+ } catch (err) {
742
+ console.error('Error loading chat:', err)
743
+ statusEl.textContent = 'Error loading chat'
744
+ messagesContainer.innerHTML = `<div class="loading">Error: ${err.message}</div>`
745
+ }
746
+ }
747
+
748
+ // Send message
749
+ async function sendMessage() {
750
+ const text = input.value.trim()
751
+ if (!text) return
752
+
753
+ sendBtn.disabled = true
754
+ input.disabled = true
755
+
756
+ try {
757
+ const ns = $rdf.Namespace
758
+ const FLOW = ns('http://www.w3.org/2005/01/wf/flow#')
759
+ const SIOC = ns('http://rdfs.org/sioc/ns#')
760
+ const DCT = ns('http://purl.org/dc/terms/')
761
+ const FOAF = ns('http://xmlns.com/foaf/0.1/')
762
+ const RDF = ns('http://www.w3.org/1999/02/22-rdf-syntax-ns#')
763
+
764
+ const msgId = `#msg-${Date.now()}`
765
+ const msgNode = $rdf.sym(subject.uri + msgId)
766
+ const now = new Date().toISOString()
767
+
768
+ const ins = [
769
+ $rdf.st(subject, FLOW('message'), msgNode, subject.doc()),
770
+ $rdf.st(msgNode, RDF('type'), FLOW('Message'), subject.doc()),
771
+ $rdf.st(msgNode, SIOC('content'), text, subject.doc()),
772
+ $rdf.st(msgNode, DCT('created'), $rdf.lit(now, null, $rdf.sym('http://www.w3.org/2001/XMLSchema#dateTime')), subject.doc())
773
+ ]
774
+
775
+ if (currentUser) {
776
+ ins.push($rdf.st(msgNode, FOAF('maker'), $rdf.sym(currentUser), subject.doc()))
777
+ }
778
+
779
+ await store.updater.update([], ins)
780
+
781
+ // Add to UI immediately
782
+ const msg = {
783
+ uri: msgNode.value,
784
+ content: text,
785
+ date: new Date(now),
786
+ author: 'You',
787
+ authorUri: currentUser
788
+ }
789
+
790
+ // Remove empty state if present
791
+ const empty = messagesContainer.querySelector('.empty-chat')
792
+ if (empty) empty.remove()
793
+
794
+ const el = createMessageElement(dom, msg, true)
795
+ messagesContainer.appendChild(el)
796
+ messagesContainer.scrollTop = messagesContainer.scrollHeight
797
+
798
+ messages.push(msg)
799
+ statusEl.textContent = `${messages.length} messages`
800
+
801
+ input.value = ''
802
+ input.style.height = 'auto'
803
+
804
+ } catch (err) {
805
+ console.error('Error sending message:', err)
806
+ alert('Failed to send message: ' + err.message)
807
+ }
808
+
809
+ sendBtn.disabled = !input.value.trim()
810
+ input.disabled = false
811
+ input.focus()
812
+ }
813
+
814
+ // Event listeners
815
+ input.addEventListener('input', () => {
816
+ sendBtn.disabled = !input.value.trim()
817
+ input.style.height = 'auto'
818
+ input.style.height = Math.min(input.scrollHeight, 100) + 'px'
819
+ })
820
+
821
+ input.addEventListener('keydown', (e) => {
822
+ if (e.key === 'Enter' && !e.shiftKey) {
823
+ e.preventDefault()
824
+ sendMessage()
825
+ }
826
+ })
827
+
828
+ sendBtn.addEventListener('click', sendMessage)
829
+
830
+ // Initial load
831
+ loadMessages()
832
+
833
+ return container
834
+ }
835
+ }
836
+
837
+ export default longChatPane