solid-chat 0.0.3 → 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/README.md CHANGED
@@ -60,17 +60,14 @@ Modern, decentralized chat app built on the [Solid](https://solidproject.org) pr
60
60
  ## Quick Start
61
61
 
62
62
  ```bash
63
- # Clone the repo
63
+ # Run instantly (no clone needed)
64
+ npx solid-chat
65
+
66
+ # Or clone and run
64
67
  git clone https://github.com/solid-chat/app.git
65
68
  cd app
66
-
67
- # Serve locally (choose one)
68
- npx serve .
69
- # or
70
- npx vite
71
-
72
- # Open in browser
73
- open http://localhost:3000
69
+ npm install
70
+ npm start
74
71
  ```
75
72
 
76
73
  ## Usage
package/bin/cli.js ADDED
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createServer } from 'http'
4
+ import { readFileSync, existsSync, statSync } from 'fs'
5
+ import { join, extname } from 'path'
6
+ import { fileURLToPath } from 'url'
7
+ import { dirname } from 'path'
8
+ import open from 'open'
9
+
10
+ const __filename = fileURLToPath(import.meta.url)
11
+ const __dirname = dirname(__filename)
12
+ const ROOT = join(__dirname, '..')
13
+
14
+ const MIME_TYPES = {
15
+ '.html': 'text/html',
16
+ '.js': 'text/javascript',
17
+ '.mjs': 'text/javascript',
18
+ '.css': 'text/css',
19
+ '.json': 'application/json',
20
+ '.png': 'image/png',
21
+ '.jpg': 'image/jpeg',
22
+ '.svg': 'image/svg+xml',
23
+ '.ico': 'image/x-icon',
24
+ '.woff': 'font/woff',
25
+ '.woff2': 'font/woff2',
26
+ '.ttf': 'font/ttf'
27
+ }
28
+
29
+ const port = parseInt(process.argv[2]) || 3000
30
+
31
+ const server = createServer((req, res) => {
32
+ let filePath = join(ROOT, req.url === '/' ? 'index.html' : req.url)
33
+
34
+ // Security: prevent directory traversal
35
+ if (!filePath.startsWith(ROOT)) {
36
+ res.writeHead(403)
37
+ res.end('Forbidden')
38
+ return
39
+ }
40
+
41
+ // Handle directory requests
42
+ if (existsSync(filePath) && statSync(filePath).isDirectory()) {
43
+ filePath = join(filePath, 'index.html')
44
+ }
45
+
46
+ const ext = extname(filePath).toLowerCase()
47
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream'
48
+
49
+ try {
50
+ const content = readFileSync(filePath)
51
+ res.writeHead(200, { 'Content-Type': contentType })
52
+ res.end(content)
53
+ } catch (err) {
54
+ if (err.code === 'ENOENT') {
55
+ res.writeHead(404)
56
+ res.end('Not Found')
57
+ } else {
58
+ res.writeHead(500)
59
+ res.end('Server Error')
60
+ }
61
+ }
62
+ })
63
+
64
+ server.listen(port, () => {
65
+ const url = `http://localhost:${port}`
66
+ console.log(`
67
+ ╭─────────────────────────────────────╮
68
+ │ │
69
+ │ Solid Chat running at: │
70
+ │ ${url.padEnd(30)}│
71
+ │ │
72
+ │ Press Ctrl+C to stop │
73
+ │ │
74
+ ╰─────────────────────────────────────╯
75
+ `)
76
+ open(url)
77
+ })
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/icons/icon.svg ADDED
@@ -0,0 +1,22 @@
1
+ <svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
2
+ <defs>
3
+ <linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
4
+ <stop offset="0%" style="stop-color:#667eea"/>
5
+ <stop offset="100%" style="stop-color:#9f7aea"/>
6
+ </linearGradient>
7
+ </defs>
8
+
9
+ <!-- Background -->
10
+ <rect width="512" height="512" rx="96" fill="url(#bg)"/>
11
+
12
+ <!-- Chat bubble -->
13
+ <g transform="translate(96, 116)">
14
+ <rect x="0" y="0" width="320" height="220" rx="40" fill="rgba(255,255,255,0.95)"/>
15
+ <polygon points="40,220 40,280 100,220" fill="rgba(255,255,255,0.95)"/>
16
+
17
+ <!-- Three dots -->
18
+ <circle cx="100" cy="110" r="24" fill="#667eea"/>
19
+ <circle cx="160" cy="110" r="24" fill="#7c8fef"/>
20
+ <circle cx="220" cy="110" r="24" fill="#9f7aea"/>
21
+ </g>
22
+ </svg>
package/index.html ADDED
@@ -0,0 +1,1065 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Solid Chat</title>
7
+ <meta name="description" content="Modern, decentralized chat app for Solid pods. Your messages, your control.">
8
+
9
+ <!-- Open Graph / Facebook -->
10
+ <meta property="og:type" content="website">
11
+ <meta property="og:url" content="https://solid-chat.com/app">
12
+ <meta property="og:title" content="Solid Chat App">
13
+ <meta property="og:description" content="Modern, decentralized chat app for Solid pods. Your messages, your control.">
14
+ <meta property="og:image" content="https://solid-chat.com/og-image.png">
15
+
16
+ <!-- Twitter -->
17
+ <meta name="twitter:card" content="summary_large_image">
18
+ <meta name="twitter:url" content="https://solid-chat.com/app">
19
+ <meta name="twitter:title" content="Solid Chat App">
20
+ <meta name="twitter:description" content="Modern, decentralized chat app for Solid pods. Your messages, your control.">
21
+ <meta name="twitter:image" content="https://solid-chat.com/og-image.png">
22
+
23
+ <!-- Additional Meta -->
24
+ <meta name="theme-color" content="#667eea">
25
+ <link rel="canonical" href="https://solid-chat.com/app">
26
+ <link rel="icon" type="image/svg+xml" href="icons/icon.svg">
27
+ <link rel="apple-touch-icon" href="icons/icon-192.png">
28
+ <link rel="manifest" href="manifest.json">
29
+
30
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
31
+ <style>
32
+ :root {
33
+ --gradient-start: #667eea;
34
+ --gradient-end: #9f7aea;
35
+ --bg: #f7f8fc;
36
+ --text: #2d3748;
37
+ --text-muted: #a0aec0;
38
+ --accent: #805ad5;
39
+ }
40
+
41
+ * { box-sizing: border-box; margin: 0; padding: 0; }
42
+
43
+ body {
44
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
45
+ background: var(--bg);
46
+ color: var(--text);
47
+ min-height: 100vh;
48
+ }
49
+
50
+ .app-header {
51
+ background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end));
52
+ color: white;
53
+ padding: 16px 24px;
54
+ box-shadow: 0 4px 20px rgba(102, 126, 234, 0.3);
55
+ display: flex;
56
+ align-items: center;
57
+ gap: 16px;
58
+ }
59
+
60
+ .app-header h1 {
61
+ font-size: 1.25rem;
62
+ font-weight: 600;
63
+ margin-bottom: 2px;
64
+ }
65
+
66
+ .app-header p {
67
+ font-size: 0.8rem;
68
+ opacity: 0.9;
69
+ }
70
+
71
+ .app-header button {
72
+ padding: 6px 14px;
73
+ background: rgba(255,255,255,0.2);
74
+ color: white;
75
+ border: 1px solid rgba(255,255,255,0.3);
76
+ border-radius: 6px;
77
+ font-size: 12px;
78
+ font-weight: 500;
79
+ cursor: pointer;
80
+ transition: background 0.2s;
81
+ }
82
+
83
+ .app-header button:hover {
84
+ background: rgba(255,255,255,0.3);
85
+ }
86
+
87
+ .app-header input {
88
+ padding: 6px 10px;
89
+ border: 1px solid rgba(255,255,255,0.3);
90
+ border-radius: 6px;
91
+ font-size: 12px;
92
+ background: rgba(255,255,255,0.15);
93
+ color: white;
94
+ width: 140px;
95
+ }
96
+
97
+ .app-header input::placeholder {
98
+ color: rgba(255,255,255,0.7);
99
+ }
100
+
101
+ #chatContainer {
102
+ background: white;
103
+ overflow: hidden;
104
+ height: 100%;
105
+ }
106
+
107
+ .placeholder {
108
+ display: flex;
109
+ flex-direction: column;
110
+ align-items: center;
111
+ justify-content: center;
112
+ height: 100%;
113
+ color: var(--text-muted);
114
+ text-align: center;
115
+ }
116
+
117
+ .placeholder-icon {
118
+ font-size: 64px;
119
+ margin-bottom: 16px;
120
+ }
121
+
122
+ .placeholder h2 {
123
+ color: var(--text);
124
+ margin-bottom: 8px;
125
+ }
126
+
127
+ .loading-spinner {
128
+ width: 40px;
129
+ height: 40px;
130
+ border: 3px solid #e0e0e0;
131
+ border-top-color: var(--primary);
132
+ border-radius: 50%;
133
+ animation: spin 0.8s linear infinite;
134
+ margin-bottom: 16px;
135
+ }
136
+
137
+ @keyframes spin {
138
+ to { transform: rotate(360deg); }
139
+ }
140
+
141
+ @keyframes fadeInUp {
142
+ from {
143
+ opacity: 0;
144
+ transform: translate(-50%, 20px);
145
+ }
146
+ to {
147
+ opacity: 1;
148
+ transform: translate(-50%, 0);
149
+ }
150
+ }
151
+
152
+ .error {
153
+ background: #fff3f3;
154
+ border: 1px solid #ffcdd2;
155
+ color: #c62828;
156
+ padding: 16px;
157
+ border-radius: 8px;
158
+ margin-top: 16px;
159
+ }
160
+
161
+ /* App layout with sidebar */
162
+ .app-layout {
163
+ display: flex;
164
+ height: calc(100vh - 68px);
165
+ }
166
+
167
+ .sidebar {
168
+ width: 320px;
169
+ flex-shrink: 0;
170
+ height: 100%;
171
+ overflow: hidden;
172
+ }
173
+
174
+ .main-content {
175
+ flex: 1;
176
+ overflow: hidden;
177
+ background: var(--bg);
178
+ height: 100%;
179
+ }
180
+
181
+ /* Mobile hamburger */
182
+ .mobile-menu-btn {
183
+ display: none;
184
+ width: 40px;
185
+ height: 40px;
186
+ background: rgba(255,255,255,0.2);
187
+ border: none;
188
+ border-radius: 8px;
189
+ color: white;
190
+ font-size: 24px;
191
+ cursor: pointer;
192
+ align-items: center;
193
+ justify-content: center;
194
+ }
195
+
196
+ .sidebar-overlay {
197
+ display: none;
198
+ position: fixed;
199
+ top: 0;
200
+ left: 0;
201
+ right: 0;
202
+ bottom: 0;
203
+ background: rgba(0,0,0,0.5);
204
+ z-index: 99;
205
+ }
206
+
207
+ @media (max-width: 768px) {
208
+ .mobile-menu-btn {
209
+ display: flex;
210
+ }
211
+
212
+ .sidebar {
213
+ position: fixed;
214
+ top: 0;
215
+ left: -320px;
216
+ height: 100vh;
217
+ z-index: 100;
218
+ transition: left 0.3s ease;
219
+ box-shadow: 4px 0 20px rgba(0,0,0,0.2);
220
+ }
221
+
222
+ .sidebar.open {
223
+ left: 0;
224
+ }
225
+
226
+ .sidebar-overlay.open {
227
+ display: block;
228
+ }
229
+
230
+ .app-layout {
231
+ height: calc(100vh - 68px);
232
+ }
233
+
234
+ .main-content {
235
+ width: 100%;
236
+ }
237
+ }
238
+ </style>
239
+ </head>
240
+ <body>
241
+
242
+ <header class="app-header">
243
+ <button class="mobile-menu-btn" id="mobileMenuBtn">☰</button>
244
+ <div>
245
+ <h1>Solid Chat <span id="appVersion" style="font-size: 12px; font-weight: 400; opacity: 0.8;"></span></h1>
246
+ <p>Decentralized messaging for the web</p>
247
+ </div>
248
+ <div id="headerLoginArea" style="margin-left: auto; display: flex; align-items: center; gap: 8px;">
249
+ <button id="soundToggle" title="Toggle notification sound" style="background: none; border: none; font-size: 18px; cursor: pointer; padding: 4px;">🔔</button>
250
+ <span id="userStatus" style="font-size: 13px; opacity: 0.9;">Loading...</span>
251
+ <span id="loginArea"></span>
252
+ </div>
253
+ </header>
254
+
255
+ <div class="sidebar-overlay" id="sidebarOverlay"></div>
256
+
257
+ <div class="app-layout">
258
+ <aside class="sidebar" id="sidebar"></aside>
259
+
260
+ <main class="main-content">
261
+ <div id="chatContainer">
262
+ <div class="placeholder" id="placeholder">
263
+ <div class="placeholder-icon">💬</div>
264
+ <h2>Welcome to Solid Chat</h2>
265
+ <p>Select a chat from the sidebar<br>or add a new one with the + button.</p>
266
+ </div>
267
+ </div>
268
+ </main>
269
+ </div>
270
+
271
+ <!-- Load rdflib and Solid auth -->
272
+ <script src="https://cdn.jsdelivr.net/npm/rdflib@2.2.33/dist/rdflib.min.js"></script>
273
+ <script src="https://cdn.jsdelivr.net/npm/@inrupt/solid-client-authn-browser@2.2.6/dist/solid-client-authn.bundle.js"></script>
274
+
275
+ <script type="module">
276
+ import { longChatPane } from './src/longChatPane.js'
277
+ import { chatListPane, addChat, updateChatPreview } from './src/chatListPane.js'
278
+
279
+ // Wait for libraries to be available
280
+ async function waitForLibraries() {
281
+ return new Promise((resolve) => {
282
+ const check = setInterval(() => {
283
+ if (typeof $rdf !== 'undefined' && typeof solidClientAuthentication !== 'undefined') {
284
+ clearInterval(check)
285
+ resolve()
286
+ }
287
+ }, 50)
288
+ })
289
+ }
290
+
291
+ await waitForLibraries()
292
+
293
+ // Get Solid auth functions
294
+ const { login, logout, handleIncomingRedirect, getDefaultSession } = solidClientAuthentication
295
+
296
+ // DOM elements
297
+ const chatContainer = document.getElementById('chatContainer')
298
+ const placeholder = document.getElementById('placeholder')
299
+ const userStatus = document.getElementById('userStatus')
300
+ const loginArea = document.getElementById('loginArea')
301
+ const sidebar = document.getElementById('sidebar')
302
+ const mobileMenuBtn = document.getElementById('mobileMenuBtn')
303
+ const sidebarOverlay = document.getElementById('sidebarOverlay')
304
+
305
+ // Mobile menu toggle
306
+ mobileMenuBtn.addEventListener('click', () => {
307
+ sidebar.classList.toggle('open')
308
+ sidebarOverlay.classList.toggle('open')
309
+ })
310
+
311
+ sidebarOverlay.addEventListener('click', () => {
312
+ sidebar.classList.remove('open')
313
+ sidebarOverlay.classList.remove('open')
314
+ })
315
+
316
+ // Auth state
317
+ let currentSession = null
318
+ let currentWebId = null
319
+
320
+ // Handle redirect callback from IdP
321
+ async function handleAuthRedirect() {
322
+ userStatus.textContent = 'Checking login...'
323
+
324
+ try {
325
+ await handleIncomingRedirect({ restorePreviousSession: true })
326
+ currentSession = getDefaultSession()
327
+
328
+ if (currentSession.info.isLoggedIn) {
329
+ currentWebId = currentSession.info.webId
330
+ updateAuthUI(true)
331
+ } else {
332
+ updateAuthUI(false)
333
+ }
334
+ } catch (err) {
335
+ console.error('Auth redirect error:', err)
336
+ updateAuthUI(false)
337
+ }
338
+ }
339
+
340
+ // Update UI based on auth state
341
+ function updateAuthUI(isLoggedIn) {
342
+ if (isLoggedIn && currentWebId) {
343
+ const shortId = currentWebId.split('//')[1]?.split('/')[0] || currentWebId
344
+ userStatus.innerHTML = `Logged in as <strong><a href="${currentWebId}" target="_blank" style="color: white; text-decoration: underline; text-underline-offset: 2px;">${shortId}</a></strong>`
345
+ loginArea.innerHTML = `<button id="logoutBtn">Logout</button>`
346
+ document.getElementById('logoutBtn').addEventListener('click', handleLogout)
347
+ } else {
348
+ userStatus.textContent = 'Not logged in'
349
+ loginArea.innerHTML = `
350
+ <input type="text" id="idpInput" placeholder="e.g. solidweb.org" style="padding: 8px 12px; border: 2px solid #e2e8f0; border-radius: 8px; font-size: 13px; width: 160px;">
351
+ <button id="loginBtn">Login</button>
352
+ `
353
+ document.getElementById('loginBtn').addEventListener('click', handleLogin)
354
+ document.getElementById('idpInput').addEventListener('keydown', (e) => {
355
+ if (e.key === 'Enter') handleLogin()
356
+ })
357
+ }
358
+ }
359
+
360
+ // Handle login
361
+ async function handleLogin() {
362
+ const idpInput = document.getElementById('idpInput')
363
+ let idp = idpInput.value.trim() || 'solidweb.org'
364
+
365
+ // Add https if missing
366
+ if (!idp.startsWith('http')) {
367
+ idp = 'https://' + idp
368
+ }
369
+
370
+ userStatus.textContent = 'Redirecting to login...'
371
+
372
+ try {
373
+ await login({
374
+ oidcIssuer: idp,
375
+ redirectUrl: window.location.href,
376
+ clientName: 'Solid Chat'
377
+ })
378
+ } catch (err) {
379
+ console.error('Login error:', err)
380
+ userStatus.textContent = 'Login failed: ' + err.message
381
+ }
382
+ }
383
+
384
+ // Handle logout
385
+ async function handleLogout() {
386
+ try {
387
+ await logout()
388
+ currentSession = null
389
+ currentWebId = null
390
+ updateAuthUI(false)
391
+ } catch (err) {
392
+ console.error('Logout error:', err)
393
+ }
394
+ }
395
+
396
+ // Get authenticated fetch function
397
+ function getAuthFetch() {
398
+ if (currentSession?.info?.isLoggedIn) {
399
+ return currentSession.fetch
400
+ }
401
+ return fetch.bind(window)
402
+ }
403
+
404
+ // Get globals from rdflib with authenticated fetch
405
+ const store = $rdf.graph()
406
+ const fetcher = new $rdf.Fetcher(store, { fetch: getAuthFetch() })
407
+ store.fetcher = fetcher
408
+ store.rdflib = $rdf
409
+
410
+ // Update fetcher when auth changes
411
+ function updateFetcher() {
412
+ store.fetcher = new $rdf.Fetcher(store, { fetch: getAuthFetch() })
413
+ }
414
+
415
+ // Simple updater with authenticated fetch
416
+ store.updater = {
417
+ update: async function(del, ins) {
418
+ const doc = ins[0]?.why || del[0]?.why
419
+ if (!doc) throw new Error('No document to update')
420
+
421
+ // Serialize insertions as N3
422
+ let body = ''
423
+ for (const st of ins) {
424
+ body += `${st.subject.toNT()} ${st.predicate.toNT()} ${st.object.toNT()} .\n`
425
+ }
426
+
427
+ const authFetch = getAuthFetch()
428
+ const response = await authFetch(doc.value, {
429
+ method: 'PATCH',
430
+ headers: {
431
+ 'Content-Type': 'application/sparql-update'
432
+ },
433
+ body: `INSERT DATA { ${body} }`
434
+ })
435
+
436
+ if (!response.ok) {
437
+ throw new Error(`Failed to update: ${response.status}`)
438
+ }
439
+
440
+ // Update local store
441
+ for (const st of ins) {
442
+ store.add(st.subject, st.predicate, st.object, st.why)
443
+ }
444
+ }
445
+ }
446
+
447
+ // Create context for pane
448
+ function createContext(uri) {
449
+ // Use real logged in user, or mock for local test
450
+ const isLocalChat = uri.includes('test/chat.ttl') || uri.startsWith('./')
451
+ let user = null
452
+
453
+ if (currentWebId) {
454
+ user = $rdf.sym(currentWebId)
455
+ } else if (isLocalChat) {
456
+ user = $rdf.sym('https://melvin.solid.social/profile/card#me')
457
+ }
458
+
459
+ // Update fetcher with current auth
460
+ updateFetcher()
461
+
462
+ return {
463
+ dom: document,
464
+ session: {
465
+ store: store,
466
+ logic: {
467
+ authn: {
468
+ currentUser: () => user
469
+ }
470
+ }
471
+ },
472
+ authFetch: getAuthFetch
473
+ }
474
+ }
475
+
476
+ // WebSocket for real-time updates
477
+ let currentWebSocket = null
478
+ let currentChatUri = null
479
+ let reconnectTimeout = null
480
+
481
+ // Notification sound
482
+ let soundEnabled = localStorage.getItem('solidchat-sound') !== 'false'
483
+
484
+ function playNotificationSound() {
485
+ if (!soundEnabled || !document.hidden) return
486
+
487
+ const ctx = new AudioContext()
488
+
489
+ // First tone (D5)
490
+ const osc1 = ctx.createOscillator()
491
+ const gain1 = ctx.createGain()
492
+ osc1.connect(gain1)
493
+ gain1.connect(ctx.destination)
494
+ osc1.frequency.value = 587.33
495
+ gain1.gain.setValueAtTime(0.2, ctx.currentTime)
496
+ gain1.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2)
497
+ osc1.start()
498
+ osc1.stop(ctx.currentTime + 0.2)
499
+
500
+ // Second tone (A5)
501
+ const osc2 = ctx.createOscillator()
502
+ const gain2 = ctx.createGain()
503
+ osc2.connect(gain2)
504
+ gain2.connect(ctx.destination)
505
+ osc2.frequency.value = 880
506
+ gain2.gain.setValueAtTime(0.2, ctx.currentTime + 0.15)
507
+ gain2.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.4)
508
+ osc2.start(ctx.currentTime + 0.15)
509
+ osc2.stop(ctx.currentTime + 0.4)
510
+ }
511
+
512
+ function toggleSound() {
513
+ soundEnabled = !soundEnabled
514
+ localStorage.setItem('solidchat-sound', soundEnabled)
515
+ updateSoundButton()
516
+ }
517
+
518
+ function updateSoundButton() {
519
+ const btn = document.getElementById('soundToggle')
520
+ if (btn) btn.textContent = soundEnabled ? '🔔' : '🔕'
521
+ }
522
+
523
+ // Subscribe to real-time updates for a resource
524
+ async function subscribeToUpdates(uri) {
525
+ // Close existing connection
526
+ if (currentWebSocket) {
527
+ currentWebSocket.close()
528
+ currentWebSocket = null
529
+ }
530
+ if (reconnectTimeout) {
531
+ clearTimeout(reconnectTimeout)
532
+ reconnectTimeout = null
533
+ }
534
+
535
+ const authFetch = getAuthFetch()
536
+ currentChatUri = uri
537
+
538
+ try {
539
+ const url = new URL(uri)
540
+
541
+ // Check if solidcommunity.net - use WebSocketChannel2023
542
+ if (url.host.endsWith('solidcommunity.net')) {
543
+ await subscribeWebSocketChannel2023(uri, authFetch)
544
+ return
545
+ }
546
+
547
+ // Otherwise try legacy Updates-Via
548
+ const response = await authFetch(uri, { method: 'HEAD' })
549
+ const updatesVia = response.headers.get('Updates-Via')
550
+
551
+ if (!updatesVia) {
552
+ console.log('No Updates-Via header, real-time updates not available')
553
+ return
554
+ }
555
+
556
+ console.log('Subscribing to updates via:', updatesVia)
557
+ connectLegacyWebSocket(updatesVia, uri)
558
+
559
+ } catch (err) {
560
+ console.error('Failed to subscribe to updates:', err)
561
+ }
562
+ }
563
+
564
+ // WebSocketChannel2023 (CSS/solidcommunity.net)
565
+ async function subscribeWebSocketChannel2023(uri, authFetch) {
566
+ try {
567
+ const response = await authFetch('https://solidcommunity.net/.notifications/WebSocketChannel2023/', {
568
+ method: 'POST',
569
+ headers: { 'Content-Type': 'application/ld+json' },
570
+ body: JSON.stringify({
571
+ "@context": ["https://www.w3.org/ns/solid/notification/v1"],
572
+ "type": "http://www.w3.org/ns/solid/notifications#WebSocketChannel2023",
573
+ "topic": uri
574
+ })
575
+ })
576
+
577
+ if (!response.ok) {
578
+ console.log('WebSocketChannel2023 subscription failed:', response.status)
579
+ return
580
+ }
581
+
582
+ const { receiveFrom } = await response.json()
583
+ console.log('WebSocketChannel2023 connecting to:', receiveFrom)
584
+
585
+ currentWebSocket = new WebSocket(receiveFrom)
586
+
587
+ currentWebSocket.onopen = () => {
588
+ console.log('WebSocketChannel2023 connected')
589
+ }
590
+
591
+ currentWebSocket.onmessage = (event) => {
592
+ console.log('WebSocketChannel2023 notification:', event.data)
593
+ // Any message means the resource changed
594
+ playNotificationSound()
595
+ setTimeout(() => refreshChat(), 500)
596
+ }
597
+
598
+ currentWebSocket.onclose = () => {
599
+ console.log('WebSocketChannel2023 closed')
600
+ if (currentChatUri === uri) {
601
+ reconnectTimeout = setTimeout(() => subscribeToUpdates(uri), 5000)
602
+ }
603
+ }
604
+
605
+ currentWebSocket.onerror = (err) => {
606
+ console.error('WebSocketChannel2023 error:', err)
607
+ }
608
+
609
+ } catch (err) {
610
+ console.error('WebSocketChannel2023 failed:', err)
611
+ }
612
+ }
613
+
614
+ // Legacy Updates-Via WebSocket (NSS)
615
+ function connectLegacyWebSocket(updatesVia, uri) {
616
+ currentWebSocket = new WebSocket(updatesVia, ['solid-0.1'])
617
+
618
+ currentWebSocket.onopen = () => {
619
+ console.log('Legacy WebSocket connected')
620
+ currentWebSocket.send(`sub ${uri}`)
621
+ }
622
+
623
+ currentWebSocket.onmessage = (event) => {
624
+ const data = event.data
625
+ console.log('Legacy WebSocket message:', data)
626
+
627
+ if (data.startsWith('pub ')) {
628
+ const updatedUri = data.slice(4).trim()
629
+ if (updatedUri === uri || uri.startsWith(updatedUri)) {
630
+ console.log('Chat updated, refreshing...')
631
+ playNotificationSound()
632
+ setTimeout(() => refreshChat(), 500)
633
+ }
634
+ }
635
+ }
636
+
637
+ currentWebSocket.onclose = () => {
638
+ console.log('Legacy WebSocket closed')
639
+ if (currentChatUri === uri) {
640
+ reconnectTimeout = setTimeout(() => subscribeToUpdates(uri), 5000)
641
+ }
642
+ }
643
+
644
+ currentWebSocket.onerror = (err) => {
645
+ console.error('Legacy WebSocket error:', err)
646
+ }
647
+ }
648
+
649
+ // Debounce helper
650
+ function debounce(fn, delay) {
651
+ let timeout
652
+ return function(...args) {
653
+ clearTimeout(timeout)
654
+ timeout = setTimeout(() => fn.apply(this, args), delay)
655
+ }
656
+ }
657
+
658
+ // Current pane element (for incremental refresh)
659
+ let currentPane = null
660
+
661
+ // Refresh current chat without full reload
662
+ async function doRefreshChat() {
663
+ if (!currentChatUri) return
664
+
665
+ // Use incremental refresh if pane supports it
666
+ if (currentPane && currentPane.refresh) {
667
+ try {
668
+ await currentPane.refresh()
669
+ } catch (err) {
670
+ console.error('Error refreshing chat:', err)
671
+ }
672
+ return
673
+ }
674
+
675
+ // Fallback to full re-render
676
+ const uri = currentChatUri
677
+ try {
678
+ const doc = $rdf.sym(uri).doc()
679
+ store.removeMatches(null, null, null, doc)
680
+
681
+ const subject = $rdf.sym(uri)
682
+ const context = createContext(uri)
683
+
684
+ chatContainer.innerHTML = ''
685
+ currentPane = longChatPane.render(subject, context)
686
+ chatContainer.appendChild(currentPane)
687
+
688
+ } catch (err) {
689
+ console.error('Error refreshing chat:', err)
690
+ }
691
+ }
692
+
693
+ // Debounced version - prevents rapid re-renders
694
+ const refreshChat = debounce(doRefreshChat, 300)
695
+
696
+ // Load chat function
697
+ async function loadChat(uri) {
698
+ if (!uri) {
699
+ alert('Please enter a chat URI')
700
+ return
701
+ }
702
+
703
+ // Convert relative URIs to absolute
704
+ if (uri.startsWith('./') || uri.startsWith('../') || !uri.includes('://')) {
705
+ uri = new URL(uri, window.location.href).href
706
+ }
707
+
708
+ // Close mobile sidebar if open
709
+ sidebar.classList.remove('open')
710
+ sidebarOverlay.classList.remove('open')
711
+
712
+ chatContainer.innerHTML = `
713
+ <div class="placeholder">
714
+ <div class="loading-spinner"></div>
715
+ <p>Loading chat...</p>
716
+ </div>
717
+ `
718
+
719
+ try {
720
+ const subject = $rdf.sym(uri)
721
+ const context = createContext(uri)
722
+
723
+ // Clear container and render pane
724
+ chatContainer.innerHTML = ''
725
+ currentPane = null
726
+
727
+ // Check if pane applies
728
+ const label = longChatPane.label(subject, context)
729
+ if (!label) {
730
+ // Force render anyway for demo
731
+ console.log('Pane label returned null, but rendering anyway for demo')
732
+ }
733
+
734
+ currentPane = longChatPane.render(subject, context)
735
+ chatContainer.appendChild(currentPane)
736
+
737
+ // Add to chat list (will also set as active)
738
+ addChat(uri)
739
+ chatListPane.setActiveChat(uri)
740
+
741
+ // Subscribe to real-time updates
742
+ currentChatUri = uri
743
+ subscribeToUpdates(uri)
744
+
745
+ } catch (err) {
746
+ console.error('Error loading chat:', err)
747
+ chatContainer.innerHTML = `
748
+ <div class="placeholder">
749
+ <div class="placeholder-icon">❌</div>
750
+ <h2>Error Loading Chat</h2>
751
+ <p>${err.message}</p>
752
+ </div>
753
+ `
754
+ }
755
+ }
756
+
757
+ // Default global chat
758
+ const DEFAULT_CHAT_URI = 'https://solid-chat.solidweb.org/public/global/chat.ttl'
759
+
760
+ // Get user's pod root from WebID
761
+ async function getMyPodRoot() {
762
+ if (!currentWebId) return null
763
+
764
+ try {
765
+ const profileDoc = $rdf.sym(currentWebId).doc()
766
+ await store.fetcher.load(profileDoc)
767
+
768
+ const PIM = $rdf.Namespace('http://www.w3.org/ns/pim/space#')
769
+ const storage = store.any($rdf.sym(currentWebId), PIM('storage'))
770
+
771
+ if (storage) {
772
+ return storage.value
773
+ }
774
+
775
+ // Fallback: derive from WebID (e.g., https://user.pod/profile/card#me -> https://user.pod/)
776
+ const url = new URL(currentWebId)
777
+ return `${url.protocol}//${url.host}/`
778
+ } catch (e) {
779
+ console.warn('Failed to get pod root:', e)
780
+ // Fallback
781
+ const url = new URL(currentWebId)
782
+ return `${url.protocol}//${url.host}/`
783
+ }
784
+ }
785
+
786
+ // Create a new chat room
787
+ async function createChat(chatUrl, title) {
788
+ // Default title from filename
789
+ if (!title) {
790
+ const filename = chatUrl.split('/').pop().replace('.ttl', '')
791
+ title = filename
792
+ .replace(/[-_]/g, ' ')
793
+ .replace(/\b\w/g, c => c.toUpperCase())
794
+ }
795
+
796
+ const now = new Date().toISOString()
797
+ const turtle = `@prefix dct: <http://purl.org/dc/terms/>.
798
+ @prefix meeting: <http://www.w3.org/ns/pim/meeting#>.
799
+ @prefix xsd: <http://www.w3.org/2001/XMLSchema#>.
800
+
801
+ <>
802
+ a meeting:LongChat ;
803
+ dct:title "${title}" ;
804
+ dct:created "${now}"^^xsd:dateTime .
805
+ `
806
+
807
+ const authFetch = getAuthFetch()
808
+ const response = await authFetch(chatUrl, {
809
+ method: 'PUT',
810
+ headers: { 'Content-Type': 'text/turtle' },
811
+ body: turtle
812
+ })
813
+
814
+ if (!response.ok) {
815
+ throw new Error(`Failed to create chat: ${response.status}`)
816
+ }
817
+
818
+ // Set public ACL and register in Type Index
819
+ const isPublic = chatUrl.includes('/public/')
820
+ if (isPublic) {
821
+ await setPublicReadACL(chatUrl)
822
+ }
823
+
824
+ // Register in Type Index (don't await - do it in background)
825
+ registerInTypeIndex(chatUrl, isPublic).catch(e => {
826
+ console.warn('Type Index registration failed:', e)
827
+ })
828
+
829
+ return { url: chatUrl, title }
830
+ }
831
+
832
+ // Set public read ACL for a resource
833
+ async function setPublicReadACL(resourceUrl) {
834
+ const aclUrl = resourceUrl + '.acl'
835
+ const authFetch = getAuthFetch()
836
+
837
+ const acl = `@prefix acl: <http://www.w3.org/ns/auth/acl#>.
838
+ @prefix foaf: <http://xmlns.com/foaf/0.1/>.
839
+
840
+ <#public>
841
+ a acl:Authorization ;
842
+ acl:agentClass foaf:Agent ;
843
+ acl:accessTo <${resourceUrl}> ;
844
+ acl:mode acl:Read, acl:Append .
845
+
846
+ <#owner>
847
+ a acl:Authorization ;
848
+ acl:agent <${currentWebId}> ;
849
+ acl:accessTo <${resourceUrl}> ;
850
+ acl:mode acl:Read, acl:Write, acl:Control .
851
+ `
852
+
853
+ try {
854
+ await authFetch(aclUrl, {
855
+ method: 'PUT',
856
+ headers: { 'Content-Type': 'text/turtle' },
857
+ body: acl
858
+ })
859
+ } catch (e) {
860
+ console.warn('Failed to set ACL (might already exist):', e)
861
+ }
862
+ }
863
+
864
+ // Register chat in user's Type Index
865
+ async function registerInTypeIndex(chatUrl, isPublic = true) {
866
+ if (!currentWebId) return
867
+
868
+ try {
869
+ // Load profile to find Type Index
870
+ const profileDoc = $rdf.sym(currentWebId).doc()
871
+ await store.fetcher.load(profileDoc)
872
+
873
+ const SOLID = $rdf.Namespace('http://www.w3.org/ns/solid/terms#')
874
+ const typeIndexPred = isPublic ? SOLID('publicTypeIndex') : SOLID('privateTypeIndex')
875
+ const typeIndex = store.any($rdf.sym(currentWebId), typeIndexPred)
876
+
877
+ if (!typeIndex) {
878
+ console.warn('No Type Index found for user')
879
+ return
880
+ }
881
+
882
+ // Check if already registered
883
+ await store.fetcher.load(typeIndex)
884
+ const MEETING = $rdf.Namespace('http://www.w3.org/ns/pim/meeting#')
885
+ const existing = store.statementsMatching(null, SOLID('instance'), $rdf.sym(chatUrl), typeIndex.doc())
886
+ if (existing.length > 0) {
887
+ console.log('Chat already registered in Type Index')
888
+ return
889
+ }
890
+
891
+ // Add registration
892
+ const regId = `#chat-${Date.now()}`
893
+ const insertQuery = `
894
+ INSERT DATA {
895
+ <${regId}> a <http://www.w3.org/ns/solid/terms#TypeRegistration> ;
896
+ <http://www.w3.org/ns/solid/terms#forClass> <http://www.w3.org/ns/pim/meeting#LongChat> ;
897
+ <http://www.w3.org/ns/solid/terms#instance> <${chatUrl}> .
898
+ }
899
+ `
900
+
901
+ const authFetch = getAuthFetch()
902
+ const response = await authFetch(typeIndex.value, {
903
+ method: 'PATCH',
904
+ headers: { 'Content-Type': 'application/sparql-update' },
905
+ body: insertQuery
906
+ })
907
+
908
+ if (response.ok) {
909
+ console.log('Chat registered in Type Index:', chatUrl)
910
+ } else {
911
+ console.warn('Failed to register in Type Index:', response.status)
912
+ }
913
+ } catch (e) {
914
+ console.warn('Failed to register in Type Index:', e)
915
+ }
916
+ }
917
+
918
+ // Handle deep link from URL params
919
+ async function handleDeepLink() {
920
+ const params = new URLSearchParams(window.location.search)
921
+ const chatUrl = params.get('chat')
922
+ const title = params.get('title')
923
+
924
+ if (!chatUrl) return null
925
+
926
+ // Validate URL
927
+ try {
928
+ new URL(chatUrl)
929
+ } catch {
930
+ console.error('Invalid chat URL:', chatUrl)
931
+ return null
932
+ }
933
+
934
+ const authFetch = getAuthFetch()
935
+
936
+ try {
937
+ // Check if chat exists
938
+ const res = await authFetch(chatUrl, { method: 'HEAD' })
939
+
940
+ if (res.ok) {
941
+ // Exists - just return URL to load
942
+ // Clean URL
943
+ history.replaceState({}, '', window.location.pathname)
944
+ return chatUrl
945
+ } else if (res.status === 404) {
946
+ // Doesn't exist - try to create if it's on our pod
947
+ const myPod = await getMyPodRoot()
948
+ if (myPod && chatUrl.startsWith(myPod)) {
949
+ await createChat(chatUrl, title)
950
+ // Clean URL
951
+ history.replaceState({}, '', window.location.pathname)
952
+ return chatUrl
953
+ } else {
954
+ alert('Chat not found. You can only create chats on your own pod.')
955
+ history.replaceState({}, '', window.location.pathname)
956
+ return null
957
+ }
958
+ } else if (res.status === 401 || res.status === 403) {
959
+ // Need to login or no access
960
+ alert('Login required or no access to this chat.')
961
+ history.replaceState({}, '', window.location.pathname)
962
+ return null
963
+ }
964
+ } catch (e) {
965
+ console.error('Error handling deep link:', e)
966
+ }
967
+
968
+ return null
969
+ }
970
+
971
+ // Copy share link to clipboard
972
+ function copyShareLink(uri) {
973
+ const shareUrl = `${window.location.origin}${window.location.pathname}?chat=${encodeURIComponent(uri)}`
974
+ navigator.clipboard.writeText(shareUrl).then(() => {
975
+ // Show toast
976
+ showToast('Link copied to clipboard!')
977
+ }).catch(err => {
978
+ console.error('Failed to copy:', err)
979
+ // Fallback: show URL in alert
980
+ prompt('Copy this link:', shareUrl)
981
+ })
982
+ }
983
+
984
+ // Toast notification
985
+ function showToast(message) {
986
+ const existing = document.querySelector('.toast')
987
+ if (existing) existing.remove()
988
+
989
+ const toast = document.createElement('div')
990
+ toast.className = 'toast'
991
+ toast.textContent = message
992
+ toast.style.cssText = `
993
+ position: fixed;
994
+ bottom: 24px;
995
+ left: 50%;
996
+ transform: translateX(-50%);
997
+ background: #1e1e2e;
998
+ color: white;
999
+ padding: 12px 24px;
1000
+ border-radius: 8px;
1001
+ font-size: 14px;
1002
+ z-index: 9999;
1003
+ animation: fadeInUp 0.3s ease;
1004
+ `
1005
+ document.body.appendChild(toast)
1006
+
1007
+ setTimeout(() => {
1008
+ toast.style.opacity = '0'
1009
+ toast.style.transition = 'opacity 0.3s'
1010
+ setTimeout(() => toast.remove(), 300)
1011
+ }, 2500)
1012
+ }
1013
+
1014
+ // Make createChat and copyShareLink available globally for chatListPane
1015
+ window.solidChat = { createChat, copyShareLink, getMyPodRoot }
1016
+
1017
+ // Initialize: handle auth redirect first, then render sidebar
1018
+ async function init() {
1019
+ // Load and display version
1020
+ fetch('./package.json')
1021
+ .then(r => r.json())
1022
+ .then(pkg => {
1023
+ document.getElementById('appVersion').textContent = `v${pkg.version}`
1024
+ })
1025
+ .catch(() => {})
1026
+
1027
+ // Set initial sound button state
1028
+ updateSoundButton()
1029
+ document.getElementById('soundToggle').addEventListener('click', toggleSound)
1030
+
1031
+ // Handle auth redirect callback (if returning from IdP)
1032
+ await handleAuthRedirect()
1033
+
1034
+ // Create context for sidebar
1035
+ const sidebarContext = {
1036
+ dom: document,
1037
+ session: {
1038
+ store: store,
1039
+ webId: currentWebId
1040
+ }
1041
+ }
1042
+
1043
+ // Render chat list sidebar
1044
+ const sidebarElement = chatListPane.render(sidebarContext, {
1045
+ onSelectChat: loadChat,
1046
+ webId: currentWebId
1047
+ })
1048
+ sidebar.appendChild(sidebarElement)
1049
+
1050
+ // Check for ?chat= deep link first, then ?uri= (legacy), then default
1051
+ const deepLinkedChat = await handleDeepLink()
1052
+ if (deepLinkedChat) {
1053
+ loadChat(deepLinkedChat)
1054
+ } else {
1055
+ const params = new URLSearchParams(window.location.search)
1056
+ const initialUri = params.get('uri') || DEFAULT_CHAT_URI
1057
+ loadChat(initialUri)
1058
+ }
1059
+ }
1060
+
1061
+ init()
1062
+ </script>
1063
+
1064
+ </body>
1065
+ </html>
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,17 +1,24 @@
1
1
  {
2
2
  "name": "solid-chat",
3
- "version": "0.0.3",
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 .",
@@ -41,6 +48,9 @@
41
48
  "homepage": "https://solid-chat.com",
42
49
  "author": "Solid Chat Contributors",
43
50
  "license": "AGPL-3.0",
51
+ "dependencies": {
52
+ "open": "^10.1.0"
53
+ },
44
54
  "devDependencies": {
45
55
  "jsdom": "^27.4.0",
46
56
  "vitest": "^4.0.16"