solid-chat 0.0.1 → 0.0.4

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/manifest.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "Solid Chat",
3
+ "short_name": "Solid Chat",
4
+ "description": "Decentralized messaging for the web",
5
+ "start_url": "/app/",
6
+ "display": "standalone",
7
+ "background_color": "#f7f8fc",
8
+ "theme_color": "#667eea",
9
+ "orientation": "portrait-primary",
10
+ "icons": [
11
+ {
12
+ "src": "icons/icon-72.png",
13
+ "sizes": "72x72",
14
+ "type": "image/png"
15
+ },
16
+ {
17
+ "src": "icons/icon-96.png",
18
+ "sizes": "96x96",
19
+ "type": "image/png"
20
+ },
21
+ {
22
+ "src": "icons/icon-128.png",
23
+ "sizes": "128x128",
24
+ "type": "image/png"
25
+ },
26
+ {
27
+ "src": "icons/icon-144.png",
28
+ "sizes": "144x144",
29
+ "type": "image/png"
30
+ },
31
+ {
32
+ "src": "icons/icon-152.png",
33
+ "sizes": "152x152",
34
+ "type": "image/png"
35
+ },
36
+ {
37
+ "src": "icons/icon-192.png",
38
+ "sizes": "192x192",
39
+ "type": "image/png",
40
+ "purpose": "any maskable"
41
+ },
42
+ {
43
+ "src": "icons/icon-384.png",
44
+ "sizes": "384x384",
45
+ "type": "image/png"
46
+ },
47
+ {
48
+ "src": "icons/icon-512.png",
49
+ "sizes": "512x512",
50
+ "type": "image/png",
51
+ "purpose": "any maskable"
52
+ }
53
+ ],
54
+ "categories": ["social", "communication"],
55
+ "lang": "en",
56
+ "dir": "ltr"
57
+ }
package/package.json CHANGED
@@ -1,21 +1,30 @@
1
1
  {
2
2
  "name": "solid-chat",
3
- "version": "0.0.1",
3
+ "version": "0.0.4",
4
4
  "description": "Modern chat panes for Solid pods - longChatPane and chatListPane",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "module": "src/index.js",
8
+ "bin": {
9
+ "solid-chat": "./bin/cli.js"
10
+ },
8
11
  "exports": {
9
12
  ".": "./src/index.js",
10
13
  "./longChatPane": "./src/longChatPane.js",
11
14
  "./chatListPane": "./src/chatListPane.js"
12
15
  },
13
16
  "files": [
14
- "src/"
17
+ "src/",
18
+ "bin/",
19
+ "index.html",
20
+ "icons/",
21
+ "manifest.json"
15
22
  ],
16
23
  "scripts": {
17
24
  "start": "npx serve .",
18
- "dev": "npx serve . -p 8080"
25
+ "dev": "npx serve . -p 8080",
26
+ "test": "vitest run",
27
+ "test:watch": "vitest"
19
28
  },
20
29
  "peerDependencies": {
21
30
  "rdflib": ">=2.2.0"
@@ -38,5 +47,12 @@
38
47
  },
39
48
  "homepage": "https://solid-chat.com",
40
49
  "author": "Solid Chat Contributors",
41
- "license": "AGPL-3.0"
50
+ "license": "AGPL-3.0",
51
+ "dependencies": {
52
+ "open": "^10.1.0"
53
+ },
54
+ "devDependencies": {
55
+ "jsdom": "^27.4.0",
56
+ "vitest": "^4.0.16"
57
+ }
42
58
  }
@@ -20,13 +20,21 @@ const MEETING = {
20
20
  // Storage key for localStorage
21
21
  const STORAGE_KEY = 'solidchat-chats'
22
22
 
23
- // Default global chat
24
- const DEFAULT_CHAT = {
25
- uri: 'https://solid-chat.solidcommunity.net/public/global/chat.ttl',
26
- title: 'Solid Chat Global',
27
- lastMessage: 'Welcome to the global chat!',
28
- timestamp: '2025-12-31T09:00:00Z'
29
- }
23
+ // Default global chats
24
+ const DEFAULT_CHATS = [
25
+ {
26
+ uri: 'https://solid-chat.solidweb.org/public/global/chat.ttl',
27
+ title: 'Solid Chat Global (solidweb.org)',
28
+ lastMessage: 'Welcome! Faster server.',
29
+ timestamp: '2025-12-31T12:00:00Z'
30
+ },
31
+ {
32
+ uri: 'https://solid-chat.solidcommunity.net/public/global/chat.ttl',
33
+ title: 'Solid Chat Global (solidcommunity.net)',
34
+ lastMessage: 'Welcome to the global chat!',
35
+ timestamp: '2025-12-31T09:00:00Z'
36
+ }
37
+ ]
30
38
 
31
39
  // CSS styles
32
40
  const styles = `
@@ -308,6 +316,47 @@ const styles = `
308
316
  box-shadow: none;
309
317
  }
310
318
 
319
+ /* Modal tabs */
320
+ .modal-tabs {
321
+ display: flex;
322
+ gap: 8px;
323
+ margin-bottom: 16px;
324
+ }
325
+
326
+ .modal-tab {
327
+ flex: 1;
328
+ padding: 8px 12px;
329
+ border: 1px solid var(--border);
330
+ border-radius: 6px;
331
+ background: var(--bg);
332
+ color: var(--text-secondary);
333
+ font-size: 13px;
334
+ font-weight: 500;
335
+ cursor: pointer;
336
+ transition: all 0.2s;
337
+ }
338
+
339
+ .modal-tab:hover {
340
+ background: var(--bg-hover);
341
+ }
342
+
343
+ .modal-tab.active {
344
+ background: linear-gradient(135deg, var(--gradient-start) 0%, var(--gradient-end) 100%);
345
+ color: white;
346
+ border-color: transparent;
347
+ }
348
+
349
+ .modal-url-preview {
350
+ font-size: 11px;
351
+ color: var(--text-muted);
352
+ word-break: break-all;
353
+ padding: 8px 12px;
354
+ background: var(--bg-hover);
355
+ border-radius: 6px;
356
+ margin-bottom: 16px;
357
+ display: none;
358
+ }
359
+
311
360
  /* Delete button on chat item */
312
361
  .chat-item-delete {
313
362
  width: 24px;
@@ -364,18 +413,21 @@ function loadChatList() {
364
413
  chatList = []
365
414
  }
366
415
 
367
- // Add default global chat if list is empty
416
+ // Add default global chats if list is empty
368
417
  if (chatList.length === 0) {
369
- chatList.push({ ...DEFAULT_CHAT })
418
+ chatList.push(...DEFAULT_CHATS.map(c => ({ ...c })))
370
419
  saveChatList()
371
420
  }
372
421
 
373
- // Ensure default chat exists in list
374
- const hasDefault = chatList.some(c => c.uri === DEFAULT_CHAT.uri)
375
- if (!hasDefault) {
376
- chatList.unshift({ ...DEFAULT_CHAT })
377
- saveChatList()
422
+ // Ensure all default chats exist in list
423
+ let added = false
424
+ for (const defaultChat of DEFAULT_CHATS) {
425
+ if (!chatList.some(c => c.uri === defaultChat.uri)) {
426
+ chatList.unshift({ ...defaultChat })
427
+ added = true
428
+ }
378
429
  }
430
+ if (added) saveChatList()
379
431
 
380
432
  return chatList
381
433
  }
@@ -535,31 +587,108 @@ function renderChatList() {
535
587
  })
536
588
  }
537
589
 
538
- // Show add chat modal
539
- function showAddModal(dom) {
590
+ // Show add/create chat modal
591
+ function showAddModal(dom, webId) {
540
592
  const overlay = dom.createElement('div')
541
593
  overlay.className = 'modal-overlay'
542
594
 
543
595
  const modal = dom.createElement('div')
544
596
  modal.className = 'modal'
545
597
 
546
- modal.innerHTML = `
547
- <div class="modal-title">Add Chat</div>
548
- <input type="url" class="modal-input" placeholder="Enter chat URL..." />
549
- <div class="modal-buttons">
550
- <button class="modal-btn modal-btn-cancel">Cancel</button>
551
- <button class="modal-btn modal-btn-add">Add</button>
552
- </div>
553
- `
598
+ // Check if user is logged in (can create chats)
599
+ const canCreate = !!webId
600
+
601
+ if (canCreate) {
602
+ modal.innerHTML = `
603
+ <div class="modal-title">Add or Create Chat</div>
604
+ <div class="modal-tabs">
605
+ <button class="modal-tab active" data-tab="add">Add Existing</button>
606
+ <button class="modal-tab" data-tab="create">Create New</button>
607
+ </div>
608
+ <div class="modal-tab-content" data-content="add">
609
+ <input type="url" class="modal-input" id="addUrlInput" placeholder="Enter chat URL..." />
610
+ </div>
611
+ <div class="modal-tab-content" data-content="create" style="display: none;">
612
+ <input type="text" class="modal-input" id="createTitleInput" placeholder="Chat title (e.g. Team Standup)" />
613
+ <select class="modal-input" id="createLocationSelect">
614
+ <option value="/public/chats/">Public (anyone with link)</option>
615
+ <option value="/private/chats/">Private (only you)</option>
616
+ </select>
617
+ <div class="modal-url-preview" id="urlPreview"></div>
618
+ </div>
619
+ <div class="modal-buttons">
620
+ <button class="modal-btn modal-btn-cancel">Cancel</button>
621
+ <button class="modal-btn modal-btn-add" id="actionBtn">Add</button>
622
+ </div>
623
+ `
624
+ } else {
625
+ modal.innerHTML = `
626
+ <div class="modal-title">Add Chat</div>
627
+ <input type="url" class="modal-input" id="addUrlInput" placeholder="Enter chat URL..." />
628
+ <p style="font-size: 12px; color: #a0aec0; margin-bottom: 16px;">Login to create new chats on your pod.</p>
629
+ <div class="modal-buttons">
630
+ <button class="modal-btn modal-btn-cancel">Cancel</button>
631
+ <button class="modal-btn modal-btn-add" id="actionBtn">Add</button>
632
+ </div>
633
+ `
634
+ }
554
635
 
555
636
  overlay.appendChild(modal)
556
637
  dom.body.appendChild(overlay)
557
638
 
558
- const input = modal.querySelector('.modal-input')
559
639
  const cancelBtn = modal.querySelector('.modal-btn-cancel')
560
- const addBtn = modal.querySelector('.modal-btn-add')
640
+ const actionBtn = modal.querySelector('#actionBtn')
641
+ const addUrlInput = modal.querySelector('#addUrlInput')
642
+
643
+ let currentTab = 'add'
644
+
645
+ // Tab switching
646
+ if (canCreate) {
647
+ const tabs = modal.querySelectorAll('.modal-tab')
648
+ const createTitleInput = modal.querySelector('#createTitleInput')
649
+ const createLocationSelect = modal.querySelector('#createLocationSelect')
650
+ const urlPreview = modal.querySelector('#urlPreview')
651
+
652
+ // Update URL preview
653
+ const updatePreview = () => {
654
+ const title = createTitleInput.value.trim()
655
+ const location = createLocationSelect.value
656
+ if (title && webId) {
657
+ const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
658
+ const podRoot = getPodRootFromWebId(webId)
659
+ const fullUrl = `${podRoot}${location.slice(1)}${slug}.ttl`
660
+ urlPreview.textContent = fullUrl
661
+ urlPreview.style.display = 'block'
662
+ } else {
663
+ urlPreview.style.display = 'none'
664
+ }
665
+ }
666
+
667
+ createTitleInput.addEventListener('input', updatePreview)
668
+ createLocationSelect.addEventListener('change', updatePreview)
669
+
670
+ tabs.forEach(tab => {
671
+ tab.onclick = () => {
672
+ tabs.forEach(t => t.classList.remove('active'))
673
+ tab.classList.add('active')
674
+ currentTab = tab.dataset.tab
675
+
676
+ modal.querySelectorAll('.modal-tab-content').forEach(c => {
677
+ c.style.display = c.dataset.content === currentTab ? 'block' : 'none'
678
+ })
679
+
680
+ actionBtn.textContent = currentTab === 'add' ? 'Add' : 'Create'
561
681
 
562
- input.focus()
682
+ if (currentTab === 'add') {
683
+ addUrlInput.focus()
684
+ } else {
685
+ createTitleInput.focus()
686
+ }
687
+ }
688
+ })
689
+ }
690
+
691
+ addUrlInput?.focus()
563
692
 
564
693
  const close = () => {
565
694
  overlay.remove()
@@ -571,26 +700,89 @@ function showAddModal(dom) {
571
700
 
572
701
  cancelBtn.onclick = close
573
702
 
574
- addBtn.onclick = () => {
575
- const uri = input.value.trim()
576
- if (uri) {
577
- addChat(uri)
578
- activeUri = uri
579
- renderChatList()
580
- if (onSelectCallback) {
581
- onSelectCallback(uri)
703
+ actionBtn.onclick = async () => {
704
+ if (currentTab === 'add') {
705
+ const uri = addUrlInput.value.trim()
706
+ if (uri) {
707
+ addChat(uri)
708
+ activeUri = uri
709
+ renderChatList()
710
+ if (onSelectCallback) {
711
+ onSelectCallback(uri)
712
+ }
713
+ close()
714
+ }
715
+ } else {
716
+ // Create new chat
717
+ const createTitleInput = modal.querySelector('#createTitleInput')
718
+ const createLocationSelect = modal.querySelector('#createLocationSelect')
719
+
720
+ const title = createTitleInput.value.trim()
721
+ const location = createLocationSelect.value
722
+
723
+ if (!title) {
724
+ createTitleInput.focus()
725
+ return
726
+ }
727
+
728
+ const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
729
+ const podRoot = getPodRootFromWebId(webId)
730
+ const chatUrl = `${podRoot}${location.slice(1)}${slug}.ttl`
731
+
732
+ actionBtn.disabled = true
733
+ actionBtn.textContent = 'Creating...'
734
+
735
+ try {
736
+ // Use the createChat from index.html
737
+ if (window.solidChat?.createChat) {
738
+ await window.solidChat.createChat(chatUrl, title)
739
+ } else {
740
+ throw new Error('createChat not available')
741
+ }
742
+
743
+ addChat(chatUrl, title)
744
+ activeUri = chatUrl
745
+ renderChatList()
746
+
747
+ // Copy share link
748
+ if (window.solidChat?.copyShareLink) {
749
+ window.solidChat.copyShareLink(chatUrl)
750
+ }
751
+
752
+ if (onSelectCallback) {
753
+ onSelectCallback(chatUrl)
754
+ }
755
+ close()
756
+ } catch (e) {
757
+ console.error('Failed to create chat:', e)
758
+ alert('Failed to create chat: ' + e.message)
759
+ actionBtn.disabled = false
760
+ actionBtn.textContent = 'Create'
582
761
  }
583
- close()
584
762
  }
585
763
  }
586
764
 
587
- input.onkeydown = (e) => {
765
+ // Handle Enter key
766
+ const handleKeydown = (e) => {
588
767
  if (e.key === 'Enter') {
589
- addBtn.click()
768
+ actionBtn.click()
590
769
  } else if (e.key === 'Escape') {
591
770
  close()
592
771
  }
593
772
  }
773
+
774
+ addUrlInput?.addEventListener('keydown', handleKeydown)
775
+ modal.querySelector('#createTitleInput')?.addEventListener('keydown', handleKeydown)
776
+ }
777
+
778
+ // Get pod root from WebID (simple extraction)
779
+ function getPodRootFromWebId(webId) {
780
+ try {
781
+ const url = new URL(webId)
782
+ return `${url.protocol}//${url.host}/`
783
+ } catch {
784
+ return ''
785
+ }
594
786
  }
595
787
 
596
788
  // Discover chats from Type Index
@@ -670,8 +862,8 @@ export const chatListPane = {
670
862
  const addBtn = dom.createElement('button')
671
863
  addBtn.className = 'add-chat-btn'
672
864
  addBtn.textContent = '+'
673
- addBtn.title = 'Add chat'
674
- addBtn.onclick = () => showAddModal(dom)
865
+ addBtn.title = 'Add or create chat'
866
+ addBtn.onclick = () => showAddModal(dom, options.webId)
675
867
 
676
868
  header.appendChild(title)
677
869
  header.appendChild(addBtn)
@@ -739,4 +931,4 @@ export const chatListPane = {
739
931
  }
740
932
 
741
933
  // Export for use in index.html
742
- export { addChat, updateChatPreview }
934
+ export { addChat, removeChat, updateChatPreview }
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import { longChatPane, chatListPane, addChat, removeChat } from './index.js'
3
+
4
+ describe('solid-chat exports', () => {
5
+ it('exports longChatPane', () => {
6
+ expect(longChatPane).toBeDefined()
7
+ expect(longChatPane.name).toBe('long-chat')
8
+ expect(typeof longChatPane.render).toBe('function')
9
+ })
10
+
11
+ it('exports chatListPane', () => {
12
+ expect(chatListPane).toBeDefined()
13
+ expect(chatListPane.name).toBe('chat-list')
14
+ expect(typeof chatListPane.render).toBe('function')
15
+ })
16
+
17
+ it('exports addChat and removeChat', () => {
18
+ expect(typeof addChat).toBe('function')
19
+ expect(typeof removeChat).toBe('function')
20
+ })
21
+ })
22
+
23
+ describe('chatListPane', () => {
24
+ beforeEach(() => {
25
+ localStorage.clear()
26
+ })
27
+
28
+ it('renders a container element', () => {
29
+ const context = { dom: document, session: { store: {} } }
30
+ const options = { onSelectChat: () => {} }
31
+ const element = chatListPane.render(context, options)
32
+
33
+ expect(element).toBeInstanceOf(HTMLElement)
34
+ expect(element.className).toBe('chat-list-pane')
35
+ })
36
+
37
+ it('includes default global chat', () => {
38
+ const context = { dom: document, session: { store: {} } }
39
+ const options = { onSelectChat: () => {} }
40
+ const element = chatListPane.render(context, options)
41
+
42
+ expect(element.textContent).toContain('Solid Chat Global')
43
+ })
44
+
45
+ it('addChat persists to localStorage', () => {
46
+ const context = { dom: document, session: { store: {} } }
47
+ chatListPane.render(context, { onSelectChat: () => {} })
48
+
49
+ addChat('https://example.com/chat.ttl', 'Test Chat')
50
+
51
+ const stored = JSON.parse(localStorage.getItem('solidchat-chats'))
52
+ expect(stored.some(c => c.uri === 'https://example.com/chat.ttl')).toBe(true)
53
+ })
54
+
55
+ it('removeChat removes from localStorage', () => {
56
+ const context = { dom: document, session: { store: {} } }
57
+ chatListPane.render(context, { onSelectChat: () => {} })
58
+
59
+ addChat('https://example.com/chat.ttl', 'Test Chat')
60
+ removeChat('https://example.com/chat.ttl')
61
+
62
+ const stored = JSON.parse(localStorage.getItem('solidchat-chats'))
63
+ expect(stored.some(c => c.uri === 'https://example.com/chat.ttl')).toBe(false)
64
+ })
65
+ })
66
+
67
+ describe('longChatPane', () => {
68
+ it('has label function', () => {
69
+ expect(typeof longChatPane.label).toBe('function')
70
+ })
71
+ })