solid-chat 0.0.1 → 0.0.3
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 +119 -25
- package/package.json +9 -3
- package/src/chatListPane.js +233 -41
- package/src/index.test.js +71 -0
- package/src/longChatPane.js +870 -28
package/src/longChatPane.js
CHANGED
|
@@ -97,6 +97,26 @@ const styles = `
|
|
|
97
97
|
opacity: 0.8;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
.share-btn {
|
|
101
|
+
width: 40px;
|
|
102
|
+
height: 40px;
|
|
103
|
+
border-radius: 50%;
|
|
104
|
+
border: none;
|
|
105
|
+
background: rgba(255,255,255,0.2);
|
|
106
|
+
color: white;
|
|
107
|
+
cursor: pointer;
|
|
108
|
+
display: flex;
|
|
109
|
+
align-items: center;
|
|
110
|
+
justify-content: center;
|
|
111
|
+
font-size: 18px;
|
|
112
|
+
transition: background 0.2s;
|
|
113
|
+
flex-shrink: 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.share-btn:hover {
|
|
117
|
+
background: rgba(255,255,255,0.3);
|
|
118
|
+
}
|
|
119
|
+
|
|
100
120
|
.messages-container {
|
|
101
121
|
flex: 1;
|
|
102
122
|
overflow-y: auto;
|
|
@@ -189,6 +209,11 @@ const styles = `
|
|
|
189
209
|
.message-time {
|
|
190
210
|
font-size: 11px;
|
|
191
211
|
color: var(--text-muted);
|
|
212
|
+
text-decoration: none;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.message-time:hover {
|
|
216
|
+
text-decoration: underline;
|
|
192
217
|
}
|
|
193
218
|
|
|
194
219
|
.message-author {
|
|
@@ -298,6 +323,32 @@ const styles = `
|
|
|
298
323
|
color: var(--text);
|
|
299
324
|
}
|
|
300
325
|
|
|
326
|
+
.upload-btn {
|
|
327
|
+
width: 36px;
|
|
328
|
+
height: 36px;
|
|
329
|
+
border-radius: 50%;
|
|
330
|
+
border: none;
|
|
331
|
+
background: transparent;
|
|
332
|
+
color: var(--text-muted);
|
|
333
|
+
cursor: pointer;
|
|
334
|
+
display: flex;
|
|
335
|
+
align-items: center;
|
|
336
|
+
justify-content: center;
|
|
337
|
+
font-size: 18px;
|
|
338
|
+
transition: background 0.2s, color 0.2s;
|
|
339
|
+
flex-shrink: 0;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
.upload-btn:hover {
|
|
343
|
+
background: #f0f2f8;
|
|
344
|
+
color: var(--text);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
.upload-btn:disabled {
|
|
348
|
+
opacity: 0.5;
|
|
349
|
+
cursor: not-allowed;
|
|
350
|
+
}
|
|
351
|
+
|
|
301
352
|
.emoji-picker {
|
|
302
353
|
position: absolute;
|
|
303
354
|
bottom: 100%;
|
|
@@ -366,6 +417,205 @@ const styles = `
|
|
|
366
417
|
padding: 20px;
|
|
367
418
|
color: var(--text-muted);
|
|
368
419
|
}
|
|
420
|
+
|
|
421
|
+
.message-text a {
|
|
422
|
+
color: var(--accent);
|
|
423
|
+
text-decoration: underline;
|
|
424
|
+
word-break: break-all;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
.message-text a:hover {
|
|
428
|
+
opacity: 0.8;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
.media-wrapper {
|
|
432
|
+
margin: 8px 0;
|
|
433
|
+
max-width: 100%;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
.media-wrapper img {
|
|
437
|
+
max-width: 300px;
|
|
438
|
+
max-height: 300px;
|
|
439
|
+
border-radius: 8px;
|
|
440
|
+
cursor: pointer;
|
|
441
|
+
transition: transform 0.2s;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
.media-wrapper img:hover {
|
|
445
|
+
transform: scale(1.02);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
.media-wrapper video,
|
|
449
|
+
.media-wrapper audio {
|
|
450
|
+
max-width: 300px;
|
|
451
|
+
border-radius: 8px;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
.media-wrapper audio {
|
|
455
|
+
width: 250px;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
.message-text code {
|
|
459
|
+
background: rgba(128, 90, 213, 0.1);
|
|
460
|
+
padding: 2px 5px;
|
|
461
|
+
border-radius: 4px;
|
|
462
|
+
font-family: 'SF Mono', Monaco, 'Courier New', monospace;
|
|
463
|
+
font-size: 13px;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
.message-text pre {
|
|
467
|
+
background: #f4f4f5;
|
|
468
|
+
color: #1e1e2e;
|
|
469
|
+
padding: 12px;
|
|
470
|
+
border-radius: 8px;
|
|
471
|
+
border: 1px solid #e4e4e7;
|
|
472
|
+
overflow-x: auto;
|
|
473
|
+
overflow-y: hidden;
|
|
474
|
+
margin: 8px 0;
|
|
475
|
+
font-family: 'SF Mono', Monaco, 'Courier New', monospace;
|
|
476
|
+
font-size: 13px;
|
|
477
|
+
line-height: 1.4;
|
|
478
|
+
max-width: 100%;
|
|
479
|
+
white-space: pre-wrap;
|
|
480
|
+
word-wrap: break-word;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
.message-text pre code {
|
|
484
|
+
background: none;
|
|
485
|
+
padding: 0;
|
|
486
|
+
color: inherit;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
.message-actions {
|
|
490
|
+
display: none;
|
|
491
|
+
gap: 4px;
|
|
492
|
+
margin-left: 8px;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
.message-row:hover .message-actions {
|
|
496
|
+
display: flex;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
.message-actions button {
|
|
500
|
+
background: transparent;
|
|
501
|
+
border: none;
|
|
502
|
+
cursor: pointer;
|
|
503
|
+
font-size: 12px;
|
|
504
|
+
padding: 2px 4px;
|
|
505
|
+
border-radius: 4px;
|
|
506
|
+
opacity: 0.6;
|
|
507
|
+
transition: opacity 0.2s, background 0.2s;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
.message-actions button:hover {
|
|
511
|
+
opacity: 1;
|
|
512
|
+
background: rgba(0,0,0,0.05);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
.message-edited {
|
|
516
|
+
font-size: 11px;
|
|
517
|
+
color: var(--text-muted);
|
|
518
|
+
font-style: italic;
|
|
519
|
+
margin-left: 6px;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
.edit-textarea {
|
|
523
|
+
width: 100%;
|
|
524
|
+
border: 1px solid var(--accent);
|
|
525
|
+
border-radius: 8px;
|
|
526
|
+
padding: 8px;
|
|
527
|
+
font-family: inherit;
|
|
528
|
+
font-size: 14px;
|
|
529
|
+
resize: none;
|
|
530
|
+
min-height: 40px;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
.edit-actions {
|
|
534
|
+
display: flex;
|
|
535
|
+
gap: 8px;
|
|
536
|
+
margin-top: 8px;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
.edit-actions button {
|
|
540
|
+
padding: 4px 12px;
|
|
541
|
+
border-radius: 6px;
|
|
542
|
+
font-size: 12px;
|
|
543
|
+
cursor: pointer;
|
|
544
|
+
border: none;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
.edit-actions .save-btn {
|
|
548
|
+
background: var(--accent);
|
|
549
|
+
color: white;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
.edit-actions .cancel-btn {
|
|
553
|
+
background: #e2e8f0;
|
|
554
|
+
color: var(--text);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
.reaction-bar {
|
|
558
|
+
display: none;
|
|
559
|
+
gap: 2px;
|
|
560
|
+
margin-top: 6px;
|
|
561
|
+
padding: 4px 6px;
|
|
562
|
+
background: white;
|
|
563
|
+
border-radius: 16px;
|
|
564
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
|
565
|
+
position: absolute;
|
|
566
|
+
bottom: -8px;
|
|
567
|
+
left: 8px;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
.message-row:hover .reaction-bar {
|
|
571
|
+
display: flex;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
.reaction-bar button {
|
|
575
|
+
background: transparent;
|
|
576
|
+
border: none;
|
|
577
|
+
cursor: pointer;
|
|
578
|
+
font-size: 16px;
|
|
579
|
+
padding: 2px 4px;
|
|
580
|
+
border-radius: 4px;
|
|
581
|
+
transition: transform 0.15s, background 0.15s;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
.reaction-bar button:hover {
|
|
585
|
+
transform: scale(1.2);
|
|
586
|
+
background: #f0f2f8;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
.reactions-display {
|
|
590
|
+
display: flex;
|
|
591
|
+
flex-wrap: wrap;
|
|
592
|
+
gap: 4px;
|
|
593
|
+
margin-top: 6px;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
.reaction-chip {
|
|
597
|
+
display: flex;
|
|
598
|
+
align-items: center;
|
|
599
|
+
gap: 3px;
|
|
600
|
+
background: #f0f2f8;
|
|
601
|
+
border-radius: 12px;
|
|
602
|
+
padding: 2px 8px;
|
|
603
|
+
font-size: 13px;
|
|
604
|
+
border: 1px solid #e2e8f0;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
.reaction-chip .emoji {
|
|
608
|
+
font-size: 14px;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
.reaction-chip .count {
|
|
612
|
+
font-size: 12px;
|
|
613
|
+
color: var(--text-secondary);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
.message-bubble {
|
|
617
|
+
position: relative;
|
|
618
|
+
}
|
|
369
619
|
`
|
|
370
620
|
|
|
371
621
|
// Inject styles once
|
|
@@ -385,6 +635,108 @@ function formatTime(date) {
|
|
|
385
635
|
return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false })
|
|
386
636
|
}
|
|
387
637
|
|
|
638
|
+
// URL regex
|
|
639
|
+
const URL_REGEX = /(https?:\/\/[^\s<>"{}|\\^`[\]]+)/gi
|
|
640
|
+
|
|
641
|
+
// Media extensions
|
|
642
|
+
const IMAGE_EXT = /\.(jpg|jpeg|png|gif|webp|svg)(\?.*)?$/i
|
|
643
|
+
const VIDEO_EXT = /\.(mp4|webm|ogg|mov)(\?.*)?$/i
|
|
644
|
+
const AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)(\?.*)?$/i
|
|
645
|
+
|
|
646
|
+
// Preset reactions
|
|
647
|
+
const REACTIONS = ['👍', '❤️', '😂', '😮', '😢', '🎉']
|
|
648
|
+
|
|
649
|
+
// Escape HTML to prevent XSS
|
|
650
|
+
function escapeHtml(text) {
|
|
651
|
+
const div = document.createElement('div')
|
|
652
|
+
div.textContent = text
|
|
653
|
+
return div.innerHTML
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Parse simple markdown: *bold*, _italic_, ~strike~, `code`, ```code blocks```
|
|
657
|
+
function parseMarkdown(text) {
|
|
658
|
+
// Escape HTML first to prevent XSS
|
|
659
|
+
let html = escapeHtml(text)
|
|
660
|
+
|
|
661
|
+
// Code fences (must be first, before inline code)
|
|
662
|
+
html = html.replace(/```\n?([\s\S]*?)\n?```/g, (_, code) => {
|
|
663
|
+
return '<pre><code>' + code.trim() + '</code></pre>'
|
|
664
|
+
})
|
|
665
|
+
// Inline code
|
|
666
|
+
html = html.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
667
|
+
// Bold
|
|
668
|
+
html = html.replace(/\*([^*]+)\*/g, '<strong>$1</strong>')
|
|
669
|
+
// Italic
|
|
670
|
+
html = html.replace(/_([^_]+)_/g, '<em>$1</em>')
|
|
671
|
+
// Strikethrough
|
|
672
|
+
html = html.replace(/~([^~]+)~/g, '<s>$1</s>')
|
|
673
|
+
|
|
674
|
+
return html
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Render message content with links and media
|
|
678
|
+
function renderMessageContent(dom, content) {
|
|
679
|
+
const container = dom.createElement('div')
|
|
680
|
+
|
|
681
|
+
// Split by URLs
|
|
682
|
+
const parts = content.split(URL_REGEX)
|
|
683
|
+
|
|
684
|
+
for (const part of parts) {
|
|
685
|
+
if (URL_REGEX.test(part)) {
|
|
686
|
+
URL_REGEX.lastIndex = 0 // Reset regex state
|
|
687
|
+
|
|
688
|
+
// Check if it's media
|
|
689
|
+
if (IMAGE_EXT.test(part)) {
|
|
690
|
+
// Image
|
|
691
|
+
const wrapper = dom.createElement('div')
|
|
692
|
+
wrapper.className = 'media-wrapper'
|
|
693
|
+
const img = dom.createElement('img')
|
|
694
|
+
img.src = part
|
|
695
|
+
img.alt = 'Image'
|
|
696
|
+
img.loading = 'lazy'
|
|
697
|
+
img.onclick = () => window.open(part, '_blank')
|
|
698
|
+
wrapper.appendChild(img)
|
|
699
|
+
container.appendChild(wrapper)
|
|
700
|
+
} else if (VIDEO_EXT.test(part)) {
|
|
701
|
+
// Video
|
|
702
|
+
const wrapper = dom.createElement('div')
|
|
703
|
+
wrapper.className = 'media-wrapper'
|
|
704
|
+
const video = dom.createElement('video')
|
|
705
|
+
video.src = part
|
|
706
|
+
video.controls = true
|
|
707
|
+
video.preload = 'metadata'
|
|
708
|
+
wrapper.appendChild(video)
|
|
709
|
+
container.appendChild(wrapper)
|
|
710
|
+
} else if (AUDIO_EXT.test(part)) {
|
|
711
|
+
// Audio
|
|
712
|
+
const wrapper = dom.createElement('div')
|
|
713
|
+
wrapper.className = 'media-wrapper'
|
|
714
|
+
const audio = dom.createElement('audio')
|
|
715
|
+
audio.src = part
|
|
716
|
+
audio.controls = true
|
|
717
|
+
audio.preload = 'metadata'
|
|
718
|
+
wrapper.appendChild(audio)
|
|
719
|
+
container.appendChild(wrapper)
|
|
720
|
+
} else {
|
|
721
|
+
// Regular link
|
|
722
|
+
const link = dom.createElement('a')
|
|
723
|
+
link.href = part
|
|
724
|
+
link.textContent = part.length > 50 ? part.slice(0, 50) + '...' : part
|
|
725
|
+
link.target = '_blank'
|
|
726
|
+
link.rel = 'noopener noreferrer'
|
|
727
|
+
container.appendChild(link)
|
|
728
|
+
}
|
|
729
|
+
} else if (part) {
|
|
730
|
+
// Regular text with markdown
|
|
731
|
+
const span = dom.createElement('span')
|
|
732
|
+
span.innerHTML = parseMarkdown(part)
|
|
733
|
+
container.appendChild(span)
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
return container
|
|
738
|
+
}
|
|
739
|
+
|
|
388
740
|
// Get initials from name
|
|
389
741
|
function getInitials(name) {
|
|
390
742
|
if (!name) return '?'
|
|
@@ -420,9 +772,10 @@ async function fetchAvatar(webId, store, $rdf) {
|
|
|
420
772
|
}
|
|
421
773
|
|
|
422
774
|
// Create message element
|
|
423
|
-
function createMessageElement(dom, message, isOwn) {
|
|
775
|
+
function createMessageElement(dom, message, isOwn, callbacks) {
|
|
424
776
|
const row = dom.createElement('div')
|
|
425
777
|
row.className = `message-row ${isOwn ? 'sent' : 'received'}`
|
|
778
|
+
row.dataset.uri = message.uri
|
|
426
779
|
|
|
427
780
|
// Avatar
|
|
428
781
|
const avatar = dom.createElement('div')
|
|
@@ -449,18 +802,89 @@ function createMessageElement(dom, message, isOwn) {
|
|
|
449
802
|
|
|
450
803
|
const text = dom.createElement('div')
|
|
451
804
|
text.className = 'message-text'
|
|
452
|
-
|
|
805
|
+
const contentEl = renderMessageContent(dom, message.content || '')
|
|
806
|
+
text.appendChild(contentEl)
|
|
453
807
|
bubble.appendChild(text)
|
|
454
808
|
|
|
455
809
|
const meta = dom.createElement('div')
|
|
456
810
|
meta.className = 'message-meta'
|
|
457
811
|
|
|
458
|
-
const time = dom.createElement('
|
|
812
|
+
const time = dom.createElement('a')
|
|
459
813
|
time.className = 'message-time'
|
|
460
814
|
time.textContent = formatTime(message.date)
|
|
815
|
+
time.href = message.uri
|
|
816
|
+
time.target = '_blank'
|
|
817
|
+
time.rel = 'noopener'
|
|
818
|
+
time.title = message.uri
|
|
461
819
|
meta.appendChild(time)
|
|
462
820
|
|
|
821
|
+
// Edited indicator
|
|
822
|
+
if (message.edited) {
|
|
823
|
+
const edited = dom.createElement('span')
|
|
824
|
+
edited.className = 'message-edited'
|
|
825
|
+
edited.textContent = '(edited)'
|
|
826
|
+
meta.appendChild(edited)
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// Action buttons for own messages
|
|
830
|
+
if (isOwn && callbacks) {
|
|
831
|
+
const actions = dom.createElement('div')
|
|
832
|
+
actions.className = 'message-actions'
|
|
833
|
+
|
|
834
|
+
const editBtn = dom.createElement('button')
|
|
835
|
+
editBtn.textContent = '✏️'
|
|
836
|
+
editBtn.title = 'Edit'
|
|
837
|
+
editBtn.onclick = (e) => {
|
|
838
|
+
e.stopPropagation()
|
|
839
|
+
callbacks.onEdit(message, row, text)
|
|
840
|
+
}
|
|
841
|
+
actions.appendChild(editBtn)
|
|
842
|
+
|
|
843
|
+
const deleteBtn = dom.createElement('button')
|
|
844
|
+
deleteBtn.textContent = '🗑️'
|
|
845
|
+
deleteBtn.title = 'Delete'
|
|
846
|
+
deleteBtn.onclick = (e) => {
|
|
847
|
+
e.stopPropagation()
|
|
848
|
+
callbacks.onDelete(message, row)
|
|
849
|
+
}
|
|
850
|
+
actions.appendChild(deleteBtn)
|
|
851
|
+
|
|
852
|
+
meta.appendChild(actions)
|
|
853
|
+
}
|
|
854
|
+
|
|
463
855
|
bubble.appendChild(meta)
|
|
856
|
+
|
|
857
|
+
// Reactions display (existing reactions)
|
|
858
|
+
const reactionsDisplay = dom.createElement('div')
|
|
859
|
+
reactionsDisplay.className = 'reactions-display'
|
|
860
|
+
reactionsDisplay.dataset.msgUri = message.uri
|
|
861
|
+
if (message.reactions) {
|
|
862
|
+
for (const [emoji, users] of Object.entries(message.reactions)) {
|
|
863
|
+
const chip = dom.createElement('div')
|
|
864
|
+
chip.className = 'reaction-chip'
|
|
865
|
+
chip.innerHTML = `<span class="emoji">${emoji}</span><span class="count">${users.length}</span>`
|
|
866
|
+
chip.title = users.map(u => u.split('/').pop().split('#')[0]).join(', ')
|
|
867
|
+
reactionsDisplay.appendChild(chip)
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
bubble.appendChild(reactionsDisplay)
|
|
871
|
+
|
|
872
|
+
// Reaction bar (hover to show)
|
|
873
|
+
if (callbacks?.onReact) {
|
|
874
|
+
const reactionBar = dom.createElement('div')
|
|
875
|
+
reactionBar.className = 'reaction-bar'
|
|
876
|
+
for (const emoji of REACTIONS) {
|
|
877
|
+
const btn = dom.createElement('button')
|
|
878
|
+
btn.textContent = emoji
|
|
879
|
+
btn.onclick = (e) => {
|
|
880
|
+
e.stopPropagation()
|
|
881
|
+
callbacks.onReact(message, emoji, reactionsDisplay)
|
|
882
|
+
}
|
|
883
|
+
reactionBar.appendChild(btn)
|
|
884
|
+
}
|
|
885
|
+
bubble.appendChild(reactionBar)
|
|
886
|
+
}
|
|
887
|
+
|
|
464
888
|
row.appendChild(bubble)
|
|
465
889
|
|
|
466
890
|
return row
|
|
@@ -556,6 +980,25 @@ export const longChatPane = {
|
|
|
556
980
|
titleDiv.appendChild(statusEl)
|
|
557
981
|
|
|
558
982
|
header.appendChild(titleDiv)
|
|
983
|
+
|
|
984
|
+
// Share button
|
|
985
|
+
const shareBtn = dom.createElement('button')
|
|
986
|
+
shareBtn.className = 'share-btn'
|
|
987
|
+
shareBtn.textContent = '📋'
|
|
988
|
+
shareBtn.title = 'Copy share link'
|
|
989
|
+
shareBtn.onclick = () => {
|
|
990
|
+
if (window.solidChat?.copyShareLink) {
|
|
991
|
+
window.solidChat.copyShareLink(subject.uri)
|
|
992
|
+
} else {
|
|
993
|
+
// Fallback
|
|
994
|
+
const shareUrl = `${window.location.origin}${window.location.pathname}?chat=${encodeURIComponent(subject.uri)}`
|
|
995
|
+
navigator.clipboard.writeText(shareUrl).catch(() => {
|
|
996
|
+
prompt('Copy this link:', shareUrl)
|
|
997
|
+
})
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
header.appendChild(shareBtn)
|
|
1001
|
+
|
|
559
1002
|
container.appendChild(header)
|
|
560
1003
|
|
|
561
1004
|
// Messages container
|
|
@@ -597,6 +1040,107 @@ export const longChatPane = {
|
|
|
597
1040
|
}
|
|
598
1041
|
inputArea.appendChild(emojiBtn)
|
|
599
1042
|
|
|
1043
|
+
// Upload button
|
|
1044
|
+
const uploadBtn = dom.createElement('button')
|
|
1045
|
+
uploadBtn.className = 'upload-btn'
|
|
1046
|
+
uploadBtn.textContent = '📎'
|
|
1047
|
+
uploadBtn.type = 'button'
|
|
1048
|
+
uploadBtn.title = 'Upload image/file'
|
|
1049
|
+
inputArea.appendChild(uploadBtn)
|
|
1050
|
+
|
|
1051
|
+
// Hidden file input
|
|
1052
|
+
const fileInput = dom.createElement('input')
|
|
1053
|
+
fileInput.type = 'file'
|
|
1054
|
+
fileInput.accept = 'image/*,video/*,audio/*'
|
|
1055
|
+
fileInput.style.display = 'none'
|
|
1056
|
+
inputArea.appendChild(fileInput)
|
|
1057
|
+
|
|
1058
|
+
uploadBtn.onclick = () => fileInput.click()
|
|
1059
|
+
|
|
1060
|
+
// Shared upload function for file input and paste
|
|
1061
|
+
async function uploadFile(file) {
|
|
1062
|
+
// Re-check current user (may have logged in after pane loaded)
|
|
1063
|
+
const authnCheck = context.session?.logic?.authn || globalThis.SolidLogic?.authn
|
|
1064
|
+
const uploadUser = authnCheck?.currentUser()?.value || currentUser
|
|
1065
|
+
|
|
1066
|
+
// Check if logged in
|
|
1067
|
+
if (!uploadUser) {
|
|
1068
|
+
alert('Please log in to upload files')
|
|
1069
|
+
return
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// Disable button during upload
|
|
1073
|
+
uploadBtn.disabled = true
|
|
1074
|
+
uploadBtn.textContent = '⏳'
|
|
1075
|
+
|
|
1076
|
+
try {
|
|
1077
|
+
// Get authenticated fetch from context
|
|
1078
|
+
const authFetch = context.authFetch ? context.authFetch() : fetch
|
|
1079
|
+
|
|
1080
|
+
// Extract pod root from WebID
|
|
1081
|
+
const webIdUrl = new URL(uploadUser)
|
|
1082
|
+
const podRoot = `${webIdUrl.protocol}//${webIdUrl.host}/`
|
|
1083
|
+
|
|
1084
|
+
// Create unique filename with timestamp
|
|
1085
|
+
const timestamp = Date.now()
|
|
1086
|
+
const safeName = (file.name || 'pasted-image.png').replace(/[^a-zA-Z0-9.-]/g, '_')
|
|
1087
|
+
const uploadPath = `${podRoot}public/chat-uploads/${timestamp}-${safeName}`
|
|
1088
|
+
|
|
1089
|
+
// Upload file using authenticated fetch
|
|
1090
|
+
const response = await authFetch(uploadPath, {
|
|
1091
|
+
method: 'PUT',
|
|
1092
|
+
headers: {
|
|
1093
|
+
'Content-Type': file.type || 'application/octet-stream'
|
|
1094
|
+
},
|
|
1095
|
+
body: file
|
|
1096
|
+
})
|
|
1097
|
+
|
|
1098
|
+
if (!response.ok) {
|
|
1099
|
+
// Try to create container first
|
|
1100
|
+
if (response.status === 404 || response.status === 409) {
|
|
1101
|
+
const containerPath = `${podRoot}public/chat-uploads/`
|
|
1102
|
+
await authFetch(containerPath, {
|
|
1103
|
+
method: 'PUT',
|
|
1104
|
+
headers: { 'Content-Type': 'text/turtle' },
|
|
1105
|
+
body: ''
|
|
1106
|
+
})
|
|
1107
|
+
// Retry upload
|
|
1108
|
+
const retry = await authFetch(uploadPath, {
|
|
1109
|
+
method: 'PUT',
|
|
1110
|
+
headers: { 'Content-Type': file.type || 'application/octet-stream' },
|
|
1111
|
+
body: file
|
|
1112
|
+
})
|
|
1113
|
+
if (!retry.ok) {
|
|
1114
|
+
throw new Error(`Upload failed: ${retry.status} ${retry.statusText}`)
|
|
1115
|
+
}
|
|
1116
|
+
} else {
|
|
1117
|
+
throw new Error(`Upload failed: ${response.status} ${response.statusText}`)
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// Insert URL into message input
|
|
1122
|
+
const currentText = input.value
|
|
1123
|
+
input.value = currentText + (currentText ? ' ' : '') + uploadPath
|
|
1124
|
+
input.dispatchEvent(new Event('input'))
|
|
1125
|
+
input.focus()
|
|
1126
|
+
|
|
1127
|
+
} catch (err) {
|
|
1128
|
+
console.error('Upload error:', err)
|
|
1129
|
+
alert('Failed to upload file: ' + err.message)
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// Reset
|
|
1133
|
+
uploadBtn.disabled = false
|
|
1134
|
+
uploadBtn.textContent = '📎'
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// File input handler
|
|
1138
|
+
fileInput.onchange = async () => {
|
|
1139
|
+
const file = fileInput.files[0]
|
|
1140
|
+
if (file) await uploadFile(file)
|
|
1141
|
+
fileInput.value = ''
|
|
1142
|
+
}
|
|
1143
|
+
|
|
600
1144
|
const inputWrapper = dom.createElement('div')
|
|
601
1145
|
inputWrapper.className = 'input-wrapper'
|
|
602
1146
|
|
|
@@ -624,7 +1168,9 @@ export const longChatPane = {
|
|
|
624
1168
|
|
|
625
1169
|
// State
|
|
626
1170
|
let messages = []
|
|
1171
|
+
let renderedUris = new Set()
|
|
627
1172
|
let currentUser = null
|
|
1173
|
+
let isFirstLoad = true
|
|
628
1174
|
|
|
629
1175
|
// Get current user
|
|
630
1176
|
const authn = context.session?.logic?.authn || globalThis.SolidLogic?.authn
|
|
@@ -632,11 +1178,213 @@ export const longChatPane = {
|
|
|
632
1178
|
currentUser = authn.currentUser()?.value
|
|
633
1179
|
}
|
|
634
1180
|
|
|
1181
|
+
// Delete message handler
|
|
1182
|
+
async function handleDelete(message, rowEl) {
|
|
1183
|
+
if (!confirm('Delete this message?')) return
|
|
1184
|
+
|
|
1185
|
+
try {
|
|
1186
|
+
const authFetch = context.authFetch ? context.authFetch() : fetch
|
|
1187
|
+
const doc = subject.doc ? subject.doc() : subject
|
|
1188
|
+
|
|
1189
|
+
// Build SPARQL DELETE for all statements about this message
|
|
1190
|
+
const msgUri = message.uri
|
|
1191
|
+
const deleteQuery = `DELETE WHERE { <${msgUri}> ?p ?o . }`
|
|
1192
|
+
|
|
1193
|
+
const response = await authFetch(doc.value || doc.uri, {
|
|
1194
|
+
method: 'PATCH',
|
|
1195
|
+
headers: { 'Content-Type': 'application/sparql-update' },
|
|
1196
|
+
body: deleteQuery
|
|
1197
|
+
})
|
|
1198
|
+
|
|
1199
|
+
if (!response.ok) {
|
|
1200
|
+
throw new Error(`Delete failed: ${response.status}`)
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// Remove from UI
|
|
1204
|
+
rowEl.remove()
|
|
1205
|
+
renderedUris.delete(message.uri)
|
|
1206
|
+
messages = messages.filter(m => m.uri !== message.uri)
|
|
1207
|
+
statusEl.textContent = `${messages.length} messages`
|
|
1208
|
+
|
|
1209
|
+
} catch (err) {
|
|
1210
|
+
console.error('Delete error:', err)
|
|
1211
|
+
alert('Failed to delete: ' + err.message)
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
// Edit message handler
|
|
1216
|
+
async function handleEdit(message, rowEl, textEl) {
|
|
1217
|
+
// Replace text content with textarea
|
|
1218
|
+
const originalContent = message.content
|
|
1219
|
+
textEl.innerHTML = ''
|
|
1220
|
+
|
|
1221
|
+
const textarea = dom.createElement('textarea')
|
|
1222
|
+
textarea.className = 'edit-textarea'
|
|
1223
|
+
textarea.value = originalContent
|
|
1224
|
+
textEl.appendChild(textarea)
|
|
1225
|
+
|
|
1226
|
+
const actions = dom.createElement('div')
|
|
1227
|
+
actions.className = 'edit-actions'
|
|
1228
|
+
|
|
1229
|
+
const saveBtn = dom.createElement('button')
|
|
1230
|
+
saveBtn.className = 'save-btn'
|
|
1231
|
+
saveBtn.textContent = 'Save'
|
|
1232
|
+
|
|
1233
|
+
const cancelBtn = dom.createElement('button')
|
|
1234
|
+
cancelBtn.className = 'cancel-btn'
|
|
1235
|
+
cancelBtn.textContent = 'Cancel'
|
|
1236
|
+
|
|
1237
|
+
actions.appendChild(saveBtn)
|
|
1238
|
+
actions.appendChild(cancelBtn)
|
|
1239
|
+
textEl.appendChild(actions)
|
|
1240
|
+
|
|
1241
|
+
textarea.focus()
|
|
1242
|
+
textarea.setSelectionRange(textarea.value.length, textarea.value.length)
|
|
1243
|
+
|
|
1244
|
+
// Cancel handler
|
|
1245
|
+
cancelBtn.onclick = () => {
|
|
1246
|
+
textEl.innerHTML = ''
|
|
1247
|
+
textEl.appendChild(renderMessageContent(dom, originalContent))
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// Save handler
|
|
1251
|
+
saveBtn.onclick = async () => {
|
|
1252
|
+
const newContent = textarea.value.trim()
|
|
1253
|
+
if (!newContent || newContent === originalContent) {
|
|
1254
|
+
cancelBtn.onclick()
|
|
1255
|
+
return
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
saveBtn.disabled = true
|
|
1259
|
+
saveBtn.textContent = 'Saving...'
|
|
1260
|
+
|
|
1261
|
+
try {
|
|
1262
|
+
const authFetch = context.authFetch ? context.authFetch() : fetch
|
|
1263
|
+
const doc = subject.doc ? subject.doc() : subject
|
|
1264
|
+
const msgUri = message.uri
|
|
1265
|
+
|
|
1266
|
+
// SPARQL DELETE old content + INSERT new content
|
|
1267
|
+
const ns = $rdf.Namespace
|
|
1268
|
+
const SIOC = ns('http://rdfs.org/sioc/ns#')
|
|
1269
|
+
|
|
1270
|
+
const updateQuery = `
|
|
1271
|
+
DELETE { <${msgUri}> <${SIOC('content').value}> ?content }
|
|
1272
|
+
INSERT { <${msgUri}> <${SIOC('content').value}> ${JSON.stringify(newContent)} }
|
|
1273
|
+
WHERE { <${msgUri}> <${SIOC('content').value}> ?content }
|
|
1274
|
+
`
|
|
1275
|
+
|
|
1276
|
+
const response = await authFetch(doc.value || doc.uri, {
|
|
1277
|
+
method: 'PATCH',
|
|
1278
|
+
headers: { 'Content-Type': 'application/sparql-update' },
|
|
1279
|
+
body: updateQuery
|
|
1280
|
+
})
|
|
1281
|
+
|
|
1282
|
+
if (!response.ok) {
|
|
1283
|
+
throw new Error(`Edit failed: ${response.status}`)
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// Update UI
|
|
1287
|
+
message.content = newContent
|
|
1288
|
+
message.edited = true
|
|
1289
|
+
textEl.innerHTML = ''
|
|
1290
|
+
textEl.appendChild(renderMessageContent(dom, newContent))
|
|
1291
|
+
|
|
1292
|
+
// Add edited indicator if not present
|
|
1293
|
+
const meta = rowEl.querySelector('.message-meta')
|
|
1294
|
+
if (meta && !meta.querySelector('.message-edited')) {
|
|
1295
|
+
const edited = dom.createElement('span')
|
|
1296
|
+
edited.className = 'message-edited'
|
|
1297
|
+
edited.textContent = '(edited)'
|
|
1298
|
+
meta.insertBefore(edited, meta.querySelector('.message-actions'))
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
} catch (err) {
|
|
1302
|
+
console.error('Edit error:', err)
|
|
1303
|
+
alert('Failed to edit: ' + err.message)
|
|
1304
|
+
saveBtn.disabled = false
|
|
1305
|
+
saveBtn.textContent = 'Save'
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// React to message handler
|
|
1311
|
+
async function handleReact(message, emoji, reactionsDisplay) {
|
|
1312
|
+
// Check if logged in
|
|
1313
|
+
const authnCheck = context.session?.logic?.authn || globalThis.SolidLogic?.authn
|
|
1314
|
+
const reactUser = authnCheck?.currentUser()?.value || currentUser
|
|
1315
|
+
|
|
1316
|
+
if (!reactUser) {
|
|
1317
|
+
alert('Please log in to react')
|
|
1318
|
+
return
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
try {
|
|
1322
|
+
const authFetch = context.authFetch ? context.authFetch() : fetch
|
|
1323
|
+
const doc = subject.doc ? subject.doc() : subject
|
|
1324
|
+
|
|
1325
|
+
// Create reaction URI
|
|
1326
|
+
const reactionId = `#reaction-${Date.now()}`
|
|
1327
|
+
const reactionUri = subject.uri + reactionId
|
|
1328
|
+
|
|
1329
|
+
// RDF namespace
|
|
1330
|
+
const SCHEMA = 'http://schema.org/'
|
|
1331
|
+
|
|
1332
|
+
// SPARQL INSERT for reaction
|
|
1333
|
+
const insertQuery = `
|
|
1334
|
+
INSERT DATA {
|
|
1335
|
+
<${reactionUri}> a <${SCHEMA}ReactAction> ;
|
|
1336
|
+
<${SCHEMA}about> <${message.uri}> ;
|
|
1337
|
+
<${SCHEMA}agent> <${reactUser}> ;
|
|
1338
|
+
<${SCHEMA}name> "${emoji}" .
|
|
1339
|
+
}
|
|
1340
|
+
`
|
|
1341
|
+
|
|
1342
|
+
const response = await authFetch(doc.value || doc.uri, {
|
|
1343
|
+
method: 'PATCH',
|
|
1344
|
+
headers: { 'Content-Type': 'application/sparql-update' },
|
|
1345
|
+
body: insertQuery
|
|
1346
|
+
})
|
|
1347
|
+
|
|
1348
|
+
if (!response.ok) {
|
|
1349
|
+
throw new Error(`React failed: ${response.status}`)
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// Update UI - add or increment reaction
|
|
1353
|
+
if (!message.reactions) message.reactions = {}
|
|
1354
|
+
if (!message.reactions[emoji]) message.reactions[emoji] = []
|
|
1355
|
+
if (!message.reactions[emoji].includes(reactUser)) {
|
|
1356
|
+
message.reactions[emoji].push(reactUser)
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
// Re-render reactions display
|
|
1360
|
+
reactionsDisplay.innerHTML = ''
|
|
1361
|
+
for (const [e, users] of Object.entries(message.reactions)) {
|
|
1362
|
+
const chip = dom.createElement('div')
|
|
1363
|
+
chip.className = 'reaction-chip'
|
|
1364
|
+
chip.innerHTML = `<span class="emoji">${e}</span><span class="count">${users.length}</span>`
|
|
1365
|
+
chip.title = users.map(u => u.split('/').pop().split('#')[0]).join(', ')
|
|
1366
|
+
reactionsDisplay.appendChild(chip)
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
} catch (err) {
|
|
1370
|
+
console.error('React error:', err)
|
|
1371
|
+
alert('Failed to react: ' + err.message)
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
// Callbacks for message actions
|
|
1376
|
+
const messageCallbacks = {
|
|
1377
|
+
onEdit: handleEdit,
|
|
1378
|
+
onDelete: handleDelete,
|
|
1379
|
+
onReact: handleReact
|
|
1380
|
+
}
|
|
1381
|
+
|
|
635
1382
|
// Load messages from store
|
|
636
1383
|
async function loadMessages() {
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
1384
|
+
if (isFirstLoad) {
|
|
1385
|
+
statusEl.textContent = 'Loading messages...'
|
|
1386
|
+
messagesContainer.innerHTML = ''
|
|
1387
|
+
}
|
|
640
1388
|
|
|
641
1389
|
try {
|
|
642
1390
|
// Define namespaces
|
|
@@ -665,6 +1413,7 @@ export const longChatPane = {
|
|
|
665
1413
|
|
|
666
1414
|
// Extract all messages with sioc:content from this document
|
|
667
1415
|
const contentStatements = store.statementsMatching(null, SIOC('content'), null, doc)
|
|
1416
|
+
const newMessages = []
|
|
668
1417
|
|
|
669
1418
|
for (const st of contentStatements) {
|
|
670
1419
|
const msgNode = st.subject
|
|
@@ -688,7 +1437,7 @@ export const longChatPane = {
|
|
|
688
1437
|
'Unknown'
|
|
689
1438
|
}
|
|
690
1439
|
|
|
691
|
-
|
|
1440
|
+
newMessages.push({
|
|
692
1441
|
uri: msgNode.value,
|
|
693
1442
|
content,
|
|
694
1443
|
date: date ? new Date(date) : new Date(),
|
|
@@ -698,45 +1447,114 @@ export const longChatPane = {
|
|
|
698
1447
|
}
|
|
699
1448
|
|
|
700
1449
|
// Sort by date
|
|
701
|
-
|
|
1450
|
+
newMessages.sort((a, b) => (a.date || 0) - (b.date || 0))
|
|
702
1451
|
|
|
703
1452
|
// Keep only last 100 messages for performance
|
|
704
|
-
|
|
705
|
-
|
|
1453
|
+
const allMessages = newMessages.slice(-100)
|
|
1454
|
+
|
|
1455
|
+
// Load reactions for messages
|
|
1456
|
+
const SCHEMA = ns('http://schema.org/')
|
|
1457
|
+
const reactionStatements = store.statementsMatching(null, SCHEMA('about'), null, doc)
|
|
1458
|
+
for (const st of reactionStatements) {
|
|
1459
|
+
const reactionNode = st.subject
|
|
1460
|
+
const aboutMsg = st.object.value
|
|
1461
|
+
const emoji = store.any(reactionNode, SCHEMA('name'), null, doc)?.value
|
|
1462
|
+
const agent = store.any(reactionNode, SCHEMA('agent'), null, doc)?.value
|
|
1463
|
+
|
|
1464
|
+
if (emoji && agent) {
|
|
1465
|
+
const msg = allMessages.find(m => m.uri === aboutMsg)
|
|
1466
|
+
if (msg) {
|
|
1467
|
+
if (!msg.reactions) msg.reactions = {}
|
|
1468
|
+
if (!msg.reactions[emoji]) msg.reactions[emoji] = []
|
|
1469
|
+
if (!msg.reactions[emoji].includes(agent)) {
|
|
1470
|
+
msg.reactions[emoji].push(agent)
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
706
1474
|
}
|
|
707
1475
|
|
|
708
|
-
//
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
1476
|
+
// Find messages that haven't been rendered yet
|
|
1477
|
+
const unrenderedMessages = allMessages.filter(m => !renderedUris.has(m.uri))
|
|
1478
|
+
|
|
1479
|
+
// Render only new messages (or all on first load)
|
|
1480
|
+
if (isFirstLoad) {
|
|
1481
|
+
if (allMessages.length === 0) {
|
|
1482
|
+
const empty = dom.createElement('div')
|
|
1483
|
+
empty.className = 'empty-chat'
|
|
1484
|
+
empty.innerHTML = '<div class="empty-chat-icon">💬</div><div>No messages yet</div><div>Be the first to say hello!</div>'
|
|
1485
|
+
messagesContainer.appendChild(empty)
|
|
1486
|
+
} else {
|
|
1487
|
+
for (const msg of allMessages) {
|
|
1488
|
+
const isOwn = currentUser && msg.authorUri === currentUser
|
|
1489
|
+
const el = createMessageElement(dom, msg, isOwn, messageCallbacks)
|
|
1490
|
+
messagesContainer.appendChild(el)
|
|
1491
|
+
renderedUris.add(msg.uri)
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
isFirstLoad = false
|
|
1495
|
+
} else if (unrenderedMessages.length > 0) {
|
|
1496
|
+
// Remove empty state if present
|
|
1497
|
+
const empty = messagesContainer.querySelector('.empty-chat')
|
|
1498
|
+
if (empty) empty.remove()
|
|
1499
|
+
|
|
1500
|
+
// Append only new messages
|
|
1501
|
+
for (const msg of unrenderedMessages) {
|
|
716
1502
|
const isOwn = currentUser && msg.authorUri === currentUser
|
|
717
|
-
const el = createMessageElement(dom, msg, isOwn)
|
|
1503
|
+
const el = createMessageElement(dom, msg, isOwn, messageCallbacks)
|
|
718
1504
|
messagesContainer.appendChild(el)
|
|
1505
|
+
renderedUris.add(msg.uri)
|
|
719
1506
|
}
|
|
720
1507
|
}
|
|
721
1508
|
|
|
1509
|
+
messages = allMessages
|
|
722
1510
|
statusEl.textContent = `${messages.length} messages`
|
|
723
1511
|
|
|
724
|
-
//
|
|
725
|
-
|
|
1512
|
+
// Update reactions for already-rendered messages
|
|
1513
|
+
for (const msg of allMessages) {
|
|
1514
|
+
if (msg.reactions) {
|
|
1515
|
+
const reactionsDisplay = messagesContainer.querySelector(`.reactions-display[data-msg-uri="${msg.uri}"]`)
|
|
1516
|
+
if (reactionsDisplay) {
|
|
1517
|
+
const currentHtml = reactionsDisplay.innerHTML
|
|
1518
|
+
let newHtml = ''
|
|
1519
|
+
for (const [emoji, users] of Object.entries(msg.reactions)) {
|
|
1520
|
+
const title = users.map(u => u.split('/').pop().split('#')[0]).join(', ')
|
|
1521
|
+
newHtml += `<div class="reaction-chip"><span class="emoji">${emoji}</span><span class="count">${users.length}</span></div>`
|
|
1522
|
+
}
|
|
1523
|
+
// Only update if changed (avoid flicker)
|
|
1524
|
+
if (reactionsDisplay.innerHTML !== newHtml) {
|
|
1525
|
+
reactionsDisplay.innerHTML = newHtml
|
|
1526
|
+
// Re-add titles
|
|
1527
|
+
const chips = reactionsDisplay.querySelectorAll('.reaction-chip')
|
|
1528
|
+
let i = 0
|
|
1529
|
+
for (const [emoji, users] of Object.entries(msg.reactions)) {
|
|
1530
|
+
if (chips[i]) chips[i].title = users.map(u => u.split('/').pop().split('#')[0]).join(', ')
|
|
1531
|
+
i++
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
726
1537
|
|
|
727
|
-
//
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
1538
|
+
// Scroll to bottom only if there are new messages
|
|
1539
|
+
if (isFirstLoad || unrenderedMessages.length > 0) {
|
|
1540
|
+
messagesContainer.scrollTop = messagesContainer.scrollHeight
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
// Load avatars in parallel (only for new messages)
|
|
1544
|
+
const newWebIds = [...new Set(unrenderedMessages.map(m => m.authorUri).filter(Boolean))]
|
|
1545
|
+
const uniqueWebIds = isFirstLoad ? [...new Set(messages.map(m => m.authorUri).filter(Boolean))] : newWebIds
|
|
1546
|
+
Promise.all(uniqueWebIds.map(webId =>
|
|
1547
|
+
fetchAvatar(webId, store, $rdf).then(avatarUrl => ({ webId, avatarUrl }))
|
|
1548
|
+
)).then(results => {
|
|
1549
|
+
results.forEach(({ webId, avatarUrl }) => {
|
|
731
1550
|
if (avatarUrl) {
|
|
732
|
-
// Update all avatars for this WebID
|
|
733
1551
|
const avatars = messagesContainer.querySelectorAll(`.message-avatar[data-webid="${webId}"]`)
|
|
734
1552
|
avatars.forEach(el => {
|
|
735
1553
|
el.innerHTML = `<img src="${avatarUrl}" alt="" />`
|
|
736
1554
|
})
|
|
737
1555
|
}
|
|
738
1556
|
})
|
|
739
|
-
}
|
|
1557
|
+
})
|
|
740
1558
|
|
|
741
1559
|
} catch (err) {
|
|
742
1560
|
console.error('Error loading chat:', err)
|
|
@@ -791,10 +1609,12 @@ export const longChatPane = {
|
|
|
791
1609
|
const empty = messagesContainer.querySelector('.empty-chat')
|
|
792
1610
|
if (empty) empty.remove()
|
|
793
1611
|
|
|
794
|
-
const el = createMessageElement(dom, msg, true)
|
|
1612
|
+
const el = createMessageElement(dom, msg, true, messageCallbacks)
|
|
795
1613
|
messagesContainer.appendChild(el)
|
|
796
1614
|
messagesContainer.scrollTop = messagesContainer.scrollHeight
|
|
797
1615
|
|
|
1616
|
+
// Track as rendered to prevent duplicate on refresh
|
|
1617
|
+
renderedUris.add(msg.uri)
|
|
798
1618
|
messages.push(msg)
|
|
799
1619
|
statusEl.textContent = `${messages.length} messages`
|
|
800
1620
|
|
|
@@ -825,8 +1645,30 @@ export const longChatPane = {
|
|
|
825
1645
|
}
|
|
826
1646
|
})
|
|
827
1647
|
|
|
1648
|
+
// Paste image handler
|
|
1649
|
+
input.addEventListener('paste', (e) => {
|
|
1650
|
+
const items = e.clipboardData?.items
|
|
1651
|
+
if (!items) return
|
|
1652
|
+
for (const item of items) {
|
|
1653
|
+
if (item.type.startsWith('image/')) {
|
|
1654
|
+
e.preventDefault()
|
|
1655
|
+
const file = item.getAsFile()
|
|
1656
|
+
if (file) uploadFile(file)
|
|
1657
|
+
break
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
})
|
|
1661
|
+
|
|
828
1662
|
sendBtn.addEventListener('click', sendMessage)
|
|
829
1663
|
|
|
1664
|
+
// Expose refresh method for incremental updates
|
|
1665
|
+
container.refresh = async function() {
|
|
1666
|
+
// Re-fetch the document
|
|
1667
|
+
const doc = subject.doc ? subject.doc() : subject
|
|
1668
|
+
await store.fetcher.load(doc, { force: true })
|
|
1669
|
+
await loadMessages()
|
|
1670
|
+
}
|
|
1671
|
+
|
|
830
1672
|
// Initial load
|
|
831
1673
|
loadMessages()
|
|
832
1674
|
|