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 +6 -9
- package/bin/cli.js +77 -0
- package/icons/icon-128.png +0 -0
- package/icons/icon-144.png +0 -0
- package/icons/icon-152.png +0 -0
- package/icons/icon-192.png +0 -0
- package/icons/icon-384.png +0 -0
- package/icons/icon-512.png +0 -0
- package/icons/icon-72.png +0 -0
- package/icons/icon-96.png +0 -0
- package/icons/icon.svg +22 -0
- package/index.html +1065 -0
- package/manifest.json +57 -0
- package/package.json +12 -2
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
|
-
#
|
|
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
|
-
|
|
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
|
+
"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"
|