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.
@@ -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
- text.textContent = message.content || ''
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('span')
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
- statusEl.textContent = 'Loading messages...'
638
- messagesContainer.innerHTML = ''
639
- messages = []
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
- messages.push({
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
- messages.sort((a, b) => (a.date || 0) - (b.date || 0))
1450
+ newMessages.sort((a, b) => (a.date || 0) - (b.date || 0))
702
1451
 
703
1452
  // Keep only last 100 messages for performance
704
- if (messages.length > 100) {
705
- messages = messages.slice(-100)
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
- // 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) {
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
- // Scroll to bottom
725
- messagesContainer.scrollTop = messagesContainer.scrollHeight
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
- // 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 => {
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