@fyresmith/hive-server 2.4.0 → 3.0.0
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 +89 -54
- package/lib/socketHandler.js +38 -891
- package/lib/vaultManager.js +4 -220
- package/lib/yjsServer.js +1 -6
- package/package.json +2 -3
- package/lib/adapterRegistry.js +0 -152
- package/lib/collabProtocol.js +0 -25
- package/lib/collabStore.js +0 -448
- package/lib/discordWebhook.js +0 -81
- package/lib/mentionUtils.js +0 -13
package/lib/socketHandler.js
CHANGED
|
@@ -1,46 +1,24 @@
|
|
|
1
1
|
import { socketMiddleware } from './auth.js';
|
|
2
2
|
import * as vault from './vaultManager.js';
|
|
3
|
-
import { serverAdapterRegistry } from './adapterRegistry.js';
|
|
4
|
-
import { negotiateProtocol, PROTOCOL_V1 } from './collabProtocol.js';
|
|
5
|
-
import { createCollabStore } from './collabStore.js';
|
|
6
|
-
import { parseMentions } from './mentionUtils.js';
|
|
7
|
-
import { sendDiscordWebhook } from './discordWebhook.js';
|
|
8
3
|
|
|
9
4
|
/**
|
|
10
|
-
* presenceByFile: file path
|
|
5
|
+
* presenceByFile: file path → Set of socket IDs
|
|
11
6
|
* @type {Map<string, Set<string>>}
|
|
12
7
|
*/
|
|
13
8
|
const presenceByFile = new Map();
|
|
14
9
|
|
|
15
10
|
/**
|
|
16
|
-
* socketToFiles: socket ID
|
|
11
|
+
* socketToFiles: socket ID → Set of file paths (for cleanup on disconnect)
|
|
17
12
|
* @type {Map<string, Set<string>>}
|
|
18
13
|
*/
|
|
19
14
|
const socketToFiles = new Map();
|
|
20
15
|
|
|
21
16
|
/**
|
|
22
|
-
* userBySocket: socket ID
|
|
17
|
+
* userBySocket: socket ID → user object
|
|
23
18
|
* @type {Map<string, object>}
|
|
24
19
|
*/
|
|
25
20
|
const userBySocket = new Map();
|
|
26
21
|
|
|
27
|
-
/**
|
|
28
|
-
* presenceStateBySocket: socket ID -> transient presence state
|
|
29
|
-
* @type {Map<string, { lastSeenAt: number, protocolVersion: number, activeFile: string|null, cursor: any, viewport: any }>}
|
|
30
|
-
*/
|
|
31
|
-
const presenceStateBySocket = new Map();
|
|
32
|
-
|
|
33
|
-
const collabStore = createCollabStore();
|
|
34
|
-
const SHA256_HEX_RE = /^[a-f0-9]{64}$/;
|
|
35
|
-
|
|
36
|
-
const PRESENCE_STALE_MS = Math.max(
|
|
37
|
-
15000,
|
|
38
|
-
parseInt(process.env.HIVE_PRESENCE_STALE_MS ?? '45000', 10) || 45000,
|
|
39
|
-
);
|
|
40
|
-
const PRESENCE_SWEEP_MS = Math.min(15000, Math.max(5000, Math.trunc(PRESENCE_STALE_MS / 3)));
|
|
41
|
-
|
|
42
|
-
let staleSweepTimer = null;
|
|
43
|
-
|
|
44
22
|
function respond(cb, payload) {
|
|
45
23
|
if (typeof cb === 'function') cb(payload);
|
|
46
24
|
}
|
|
@@ -60,243 +38,17 @@ function normalizeHexColor(color) {
|
|
|
60
38
|
return /^#[0-9a-fA-F]{6}$/.test(trimmed) ? trimmed.toLowerCase() : null;
|
|
61
39
|
}
|
|
62
40
|
|
|
63
|
-
function normalizeExpectedHash(value) {
|
|
64
|
-
if (value === null) return null;
|
|
65
|
-
if (typeof value !== 'string') return undefined;
|
|
66
|
-
const normalized = value.trim().toLowerCase();
|
|
67
|
-
if (!SHA256_HEX_RE.test(normalized)) return undefined;
|
|
68
|
-
return normalized;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function parseHistoryLimit(raw) {
|
|
72
|
-
if (raw === undefined || raw === null) return 50;
|
|
73
|
-
const num = Number(raw);
|
|
74
|
-
if (!Number.isFinite(num)) return 50;
|
|
75
|
-
const int = Math.trunc(num);
|
|
76
|
-
if (int < 1) return 1;
|
|
77
|
-
if (int > 500) return 500;
|
|
78
|
-
return int;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function actorFromUser(user) {
|
|
82
|
-
return {
|
|
83
|
-
id: user?.id ?? null,
|
|
84
|
-
username: user?.username ?? null,
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function getWorkspaceKey(filePath) {
|
|
89
|
-
if (typeof filePath !== 'string' || filePath.length === 0) return 'root';
|
|
90
|
-
const normalized = filePath.replace(/\\/g, '/');
|
|
91
|
-
const [head] = normalized.split('/');
|
|
92
|
-
return head || 'root';
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function normalizeAdapterContent(relPath, content, cb) {
|
|
96
|
-
const adapter = serverAdapterRegistry.getByPath(relPath);
|
|
97
|
-
if (!adapter) return true;
|
|
98
|
-
|
|
99
|
-
const ok = adapter.validateContent(content);
|
|
100
|
-
if (!ok) {
|
|
101
|
-
respond(cb, {
|
|
102
|
-
ok: false,
|
|
103
|
-
code: 'BAD_REQUEST',
|
|
104
|
-
error: `Invalid payload for adapter ${adapter.adapterId}`,
|
|
105
|
-
adapterId: adapter.adapterId,
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
return ok;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function ensureState(socketId) {
|
|
112
|
-
let state = presenceStateBySocket.get(socketId);
|
|
113
|
-
if (!state) {
|
|
114
|
-
state = {
|
|
115
|
-
lastSeenAt: Date.now(),
|
|
116
|
-
protocolVersion: PROTOCOL_V1,
|
|
117
|
-
activeFile: null,
|
|
118
|
-
cursor: null,
|
|
119
|
-
viewport: null,
|
|
120
|
-
};
|
|
121
|
-
presenceStateBySocket.set(socketId, state);
|
|
122
|
-
}
|
|
123
|
-
return state;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function touchPresence(socketId, patch = null) {
|
|
127
|
-
const state = ensureState(socketId);
|
|
128
|
-
state.lastSeenAt = Date.now();
|
|
129
|
-
if (patch && typeof patch === 'object') {
|
|
130
|
-
if (patch.activeFile === null || typeof patch.activeFile === 'string') {
|
|
131
|
-
state.activeFile = patch.activeFile;
|
|
132
|
-
}
|
|
133
|
-
if (patch.cursor !== undefined) {
|
|
134
|
-
state.cursor = patch.cursor;
|
|
135
|
-
}
|
|
136
|
-
if (patch.viewport !== undefined) {
|
|
137
|
-
state.viewport = patch.viewport;
|
|
138
|
-
}
|
|
139
|
-
if (Number.isInteger(patch.protocolVersion)) {
|
|
140
|
-
state.protocolVersion = patch.protocolVersion;
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
presenceStateBySocket.set(socketId, state);
|
|
144
|
-
return state;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function presenceList() {
|
|
148
|
-
const out = [];
|
|
149
|
-
for (const [socketId, user] of userBySocket) {
|
|
150
|
-
const state = presenceStateBySocket.get(socketId);
|
|
151
|
-
const openFiles = [...(socketToFiles.get(socketId) ?? new Set())];
|
|
152
|
-
out.push({
|
|
153
|
-
socketId,
|
|
154
|
-
user,
|
|
155
|
-
openFiles,
|
|
156
|
-
activeFile: state?.activeFile ?? openFiles[0] ?? null,
|
|
157
|
-
cursor: state?.cursor ?? null,
|
|
158
|
-
viewport: state?.viewport ?? null,
|
|
159
|
-
lastSeenAt: state?.lastSeenAt ?? null,
|
|
160
|
-
stale: state ? (Date.now() - state.lastSeenAt) > PRESENCE_STALE_MS : true,
|
|
161
|
-
});
|
|
162
|
-
}
|
|
163
|
-
return out;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
function getSocketsByUserId(userId) {
|
|
167
|
-
const out = [];
|
|
168
|
-
for (const [socketId, user] of userBySocket) {
|
|
169
|
-
if (user?.id === userId) out.push(socketId);
|
|
170
|
-
}
|
|
171
|
-
return out;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function getUsersByMention(mentionUsername) {
|
|
175
|
-
const username = String(mentionUsername).toLowerCase();
|
|
176
|
-
const seen = new Set();
|
|
177
|
-
const users = [];
|
|
178
|
-
|
|
179
|
-
for (const user of userBySocket.values()) {
|
|
180
|
-
if (!user?.id || !user?.username) continue;
|
|
181
|
-
if (String(user.username).toLowerCase() !== username) continue;
|
|
182
|
-
if (seen.has(user.id)) continue;
|
|
183
|
-
seen.add(user.id);
|
|
184
|
-
users.push(user);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
return users;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
async function maybeDeliverNotifyEvent(io, event) {
|
|
191
|
-
const decision = await collabStore.shouldDeliverNotification(event.targetUser.id, event.filePath, event.kind);
|
|
192
|
-
if (!decision.deliver) {
|
|
193
|
-
return;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
const payload = {
|
|
197
|
-
...event,
|
|
198
|
-
mode: decision.mode,
|
|
199
|
-
ts: Date.now(),
|
|
200
|
-
};
|
|
201
|
-
|
|
202
|
-
const targetSockets = getSocketsByUserId(event.targetUser.id);
|
|
203
|
-
for (const socketId of targetSockets) {
|
|
204
|
-
io.to(socketId).emit('collab:notify:event', payload);
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
await sendDiscordWebhook(payload);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
function activityRoomForWorkspace() {
|
|
211
|
-
return 'collab:activity:workspace';
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function activityRoomForFile(filePath) {
|
|
215
|
-
return `collab:activity:file:${filePath}`;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
function publishActivity(io, activity) {
|
|
219
|
-
io.to(activityRoomForWorkspace()).emit('collab:activity:event', { activity });
|
|
220
|
-
if (activity?.filePath) {
|
|
221
|
-
io.to(activityRoomForFile(activity.filePath)).emit('collab:activity:event', { activity });
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
async function recordActivity(io, { type, filePath = null, actor = null, payload = null, groupKey = null }) {
|
|
226
|
-
const activity = await collabStore.recordActivity({
|
|
227
|
-
type,
|
|
228
|
-
filePath,
|
|
229
|
-
actor,
|
|
230
|
-
payload,
|
|
231
|
-
groupKey,
|
|
232
|
-
});
|
|
233
|
-
publishActivity(io, activity);
|
|
234
|
-
return activity;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
async function getCurrentHash(relPath) {
|
|
238
|
-
try {
|
|
239
|
-
const currentContent = await vault.readFile(relPath);
|
|
240
|
-
return vault.hashContent(currentContent);
|
|
241
|
-
} catch (err) {
|
|
242
|
-
if (err && err.code === 'ENOENT') return null;
|
|
243
|
-
throw err;
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
function ensureStaleSweep(io) {
|
|
248
|
-
if (staleSweepTimer) return;
|
|
249
|
-
|
|
250
|
-
staleSweepTimer = setInterval(() => {
|
|
251
|
-
const now = Date.now();
|
|
252
|
-
|
|
253
|
-
for (const [socketId, state] of presenceStateBySocket) {
|
|
254
|
-
if (state.protocolVersion < 2) continue;
|
|
255
|
-
if (now - state.lastSeenAt <= PRESENCE_STALE_MS) continue;
|
|
256
|
-
|
|
257
|
-
const user = userBySocket.get(socketId);
|
|
258
|
-
const openFiles = socketToFiles.get(socketId) ?? new Set();
|
|
259
|
-
|
|
260
|
-
for (const relPath of openFiles) {
|
|
261
|
-
presenceByFile.get(relPath)?.delete(socketId);
|
|
262
|
-
io.emit('presence-file-closed', {
|
|
263
|
-
relPath,
|
|
264
|
-
user,
|
|
265
|
-
stale: true,
|
|
266
|
-
});
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
openFiles.clear();
|
|
270
|
-
socketToFiles.set(socketId, openFiles);
|
|
271
|
-
state.activeFile = null;
|
|
272
|
-
state.cursor = null;
|
|
273
|
-
state.viewport = null;
|
|
274
|
-
state.lastSeenAt = now;
|
|
275
|
-
|
|
276
|
-
io.emit('collab:presence:stale', {
|
|
277
|
-
user,
|
|
278
|
-
socketId,
|
|
279
|
-
ts: now,
|
|
280
|
-
});
|
|
281
|
-
}
|
|
282
|
-
}, PRESENCE_SWEEP_MS);
|
|
283
|
-
|
|
284
|
-
if (typeof staleSweepTimer.unref === 'function') {
|
|
285
|
-
staleSweepTimer.unref();
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
41
|
/**
|
|
290
42
|
* Attach all Socket.IO event handlers.
|
|
291
43
|
*
|
|
292
44
|
* @param {import('socket.io').Server} io
|
|
293
|
-
* @param {() => Set<string>} getActiveRooms
|
|
45
|
+
* @param {() => Set<string>} getActiveRooms - returns encoded docNames currently in Yjs
|
|
294
46
|
* @param {(relPath: string, hash: string, excludeSocketId: string|null) => void} broadcastFileUpdated
|
|
295
47
|
* @param {(relPath: string) => Promise<void>} forceCloseRoom
|
|
296
48
|
*/
|
|
297
49
|
export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCloseRoom) {
|
|
50
|
+
// Auth middleware for every socket connection
|
|
298
51
|
io.use(socketMiddleware);
|
|
299
|
-
ensureStaleSweep(io);
|
|
300
52
|
|
|
301
53
|
io.on('connection', (socket) => {
|
|
302
54
|
const user = socket.user;
|
|
@@ -304,99 +56,14 @@ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCl
|
|
|
304
56
|
|
|
305
57
|
userBySocket.set(socket.id, user);
|
|
306
58
|
socketToFiles.set(socket.id, new Set());
|
|
307
|
-
touchPresence(socket.id);
|
|
308
59
|
|
|
60
|
+
// Notify others this user joined
|
|
309
61
|
socket.broadcast.emit('user-joined', { user });
|
|
310
62
|
|
|
311
|
-
socket.on('collab:hello', (payload, cb) => {
|
|
312
|
-
try {
|
|
313
|
-
const negotiated = negotiateProtocol(payload, serverAdapterRegistry);
|
|
314
|
-
const protocolVersion = negotiated.negotiatedProtocol;
|
|
315
|
-
touchPresence(socket.id, { protocolVersion });
|
|
316
|
-
respond(cb, { ok: true, ...negotiated });
|
|
317
|
-
} catch (err) {
|
|
318
|
-
respond(cb, { ok: false, error: err?.message ?? String(err) });
|
|
319
|
-
}
|
|
320
|
-
});
|
|
321
|
-
|
|
322
|
-
socket.on('collab:presence:heartbeat', (payload, cb) => {
|
|
323
|
-
const relPath = typeof payload?.activeFile === 'string' ? payload.activeFile : null;
|
|
324
|
-
if (relPath && !isAllowedPath(relPath)) {
|
|
325
|
-
rejectPath(cb, relPath);
|
|
326
|
-
return;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
const state = touchPresence(socket.id, {
|
|
330
|
-
activeFile: relPath,
|
|
331
|
-
cursor: payload?.cursor ?? null,
|
|
332
|
-
viewport: payload?.viewport ?? null,
|
|
333
|
-
});
|
|
334
|
-
|
|
335
|
-
socket.broadcast.emit('collab:presence:heartbeat', {
|
|
336
|
-
user,
|
|
337
|
-
location: {
|
|
338
|
-
activeFile: state.activeFile,
|
|
339
|
-
cursor: state.cursor,
|
|
340
|
-
viewport: state.viewport,
|
|
341
|
-
},
|
|
342
|
-
ts: state.lastSeenAt,
|
|
343
|
-
});
|
|
344
|
-
|
|
345
|
-
respond(cb, {
|
|
346
|
-
ok: true,
|
|
347
|
-
lastSeenAt: state.lastSeenAt,
|
|
348
|
-
});
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
socket.on('collab:presence:list', (_payload, cb) => {
|
|
352
|
-
respond(cb, {
|
|
353
|
-
ok: true,
|
|
354
|
-
users: presenceList(),
|
|
355
|
-
});
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
socket.on('collab:presence:jump', (payload, cb) => {
|
|
359
|
-
const userId = payload?.userId;
|
|
360
|
-
if (typeof userId !== 'string' || userId.length === 0) {
|
|
361
|
-
respond(cb, { ok: false, code: 'BAD_REQUEST', error: 'Missing userId' });
|
|
362
|
-
return;
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
let target = null;
|
|
366
|
-
for (const [socketId, currentUser] of userBySocket) {
|
|
367
|
-
if (currentUser?.id !== userId) continue;
|
|
368
|
-
const state = presenceStateBySocket.get(socketId);
|
|
369
|
-
const openFiles = [...(socketToFiles.get(socketId) ?? new Set())];
|
|
370
|
-
const activeFile = state?.activeFile ?? openFiles[0] ?? null;
|
|
371
|
-
if (!activeFile) continue;
|
|
372
|
-
target = {
|
|
373
|
-
user: currentUser,
|
|
374
|
-
location: {
|
|
375
|
-
activeFile,
|
|
376
|
-
cursor: state?.cursor ?? null,
|
|
377
|
-
viewport: state?.viewport ?? null,
|
|
378
|
-
},
|
|
379
|
-
};
|
|
380
|
-
break;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
if (!target) {
|
|
384
|
-
respond(cb, { ok: false, code: 'NOT_FOUND', error: 'Collaborator location unavailable' });
|
|
385
|
-
return;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
respond(cb, {
|
|
389
|
-
ok: true,
|
|
390
|
-
...target,
|
|
391
|
-
});
|
|
392
|
-
});
|
|
393
|
-
|
|
394
63
|
// -----------------------------------------------------------------------
|
|
395
|
-
//
|
|
64
|
+
// vault-sync-request
|
|
396
65
|
// -----------------------------------------------------------------------
|
|
397
|
-
|
|
398
66
|
socket.on('vault-sync-request', async (cb) => {
|
|
399
|
-
touchPresence(socket.id);
|
|
400
67
|
try {
|
|
401
68
|
const manifest = await vault.getManifest();
|
|
402
69
|
respond(cb, { ok: true, manifest });
|
|
@@ -406,8 +73,10 @@ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCl
|
|
|
406
73
|
}
|
|
407
74
|
});
|
|
408
75
|
|
|
76
|
+
// -----------------------------------------------------------------------
|
|
77
|
+
// file-read
|
|
78
|
+
// -----------------------------------------------------------------------
|
|
409
79
|
socket.on('file-read', async (relPath, cb) => {
|
|
410
|
-
touchPresence(socket.id);
|
|
411
80
|
if (!isAllowedPath(relPath)) {
|
|
412
81
|
rejectPath(cb, relPath);
|
|
413
82
|
return;
|
|
@@ -422,56 +91,21 @@ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCl
|
|
|
422
91
|
}
|
|
423
92
|
});
|
|
424
93
|
|
|
94
|
+
// -----------------------------------------------------------------------
|
|
95
|
+
// file-write
|
|
96
|
+
// -----------------------------------------------------------------------
|
|
425
97
|
socket.on('file-write', async (payload, cb) => {
|
|
426
|
-
touchPresence(socket.id);
|
|
427
98
|
const relPath = payload?.relPath;
|
|
428
99
|
const content = payload?.content;
|
|
429
|
-
const expectedHash = normalizeExpectedHash(payload?.expectedHash);
|
|
430
100
|
if (!isAllowedPath(relPath) || typeof content !== 'string') {
|
|
431
101
|
rejectPath(cb, relPath);
|
|
432
102
|
return;
|
|
433
103
|
}
|
|
434
|
-
if (payload?.expectedHash !== undefined && expectedHash === undefined) {
|
|
435
|
-
respond(cb, { ok: false, code: 'BAD_REQUEST', error: 'Invalid expectedHash' });
|
|
436
|
-
return;
|
|
437
|
-
}
|
|
438
|
-
if (!normalizeAdapterContent(relPath, content, cb)) {
|
|
439
|
-
return;
|
|
440
|
-
}
|
|
441
|
-
|
|
442
104
|
try {
|
|
443
|
-
|
|
444
|
-
if (expectedHash !== undefined && expectedHash !== currentHash) {
|
|
445
|
-
respond(cb, {
|
|
446
|
-
ok: false,
|
|
447
|
-
code: 'CONFLICT',
|
|
448
|
-
error: 'Write conflict: file changed on server',
|
|
449
|
-
relPath,
|
|
450
|
-
currentHash,
|
|
451
|
-
});
|
|
452
|
-
return;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
await vault.writeFile(relPath, content, {
|
|
456
|
-
history: {
|
|
457
|
-
action: 'write',
|
|
458
|
-
source: 'socket',
|
|
459
|
-
actor: actorFromUser(user),
|
|
460
|
-
},
|
|
461
|
-
});
|
|
105
|
+
await vault.writeFile(relPath, content);
|
|
462
106
|
const hash = vault.hashContent(content);
|
|
463
107
|
socket.broadcast.emit('file-updated', { relPath, hash, user });
|
|
464
|
-
|
|
465
|
-
respond(cb, { ok: true, hash, previousHash: currentHash });
|
|
466
|
-
|
|
467
|
-
await recordActivity(io, {
|
|
468
|
-
type: 'edit',
|
|
469
|
-
filePath: relPath,
|
|
470
|
-
actor: actorFromUser(user),
|
|
471
|
-
payload: { source: 'socket', adapterId: serverAdapterRegistry.getByPath(relPath)?.adapterId ?? null },
|
|
472
|
-
groupKey: `${user.id}:${relPath}:edit`,
|
|
473
|
-
});
|
|
474
|
-
|
|
108
|
+
respond(cb, { ok: true, hash });
|
|
475
109
|
console.log(`[socket] file-write: ${relPath} by ${user.username}`);
|
|
476
110
|
} catch (err) {
|
|
477
111
|
console.error(`[socket] file-write error (${relPath}):`, err);
|
|
@@ -479,38 +113,20 @@ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCl
|
|
|
479
113
|
}
|
|
480
114
|
});
|
|
481
115
|
|
|
116
|
+
// -----------------------------------------------------------------------
|
|
117
|
+
// file-create
|
|
118
|
+
// -----------------------------------------------------------------------
|
|
482
119
|
socket.on('file-create', async (payload, cb) => {
|
|
483
|
-
touchPresence(socket.id);
|
|
484
120
|
const relPath = payload?.relPath;
|
|
485
121
|
const content = payload?.content;
|
|
486
122
|
if (!isAllowedPath(relPath) || typeof content !== 'string') {
|
|
487
123
|
rejectPath(cb, relPath);
|
|
488
124
|
return;
|
|
489
125
|
}
|
|
490
|
-
if (!normalizeAdapterContent(relPath, content, cb)) {
|
|
491
|
-
return;
|
|
492
|
-
}
|
|
493
|
-
|
|
494
126
|
try {
|
|
495
|
-
await vault.writeFile(relPath, content
|
|
496
|
-
history: {
|
|
497
|
-
action: 'create',
|
|
498
|
-
source: 'socket',
|
|
499
|
-
actor: actorFromUser(user),
|
|
500
|
-
},
|
|
501
|
-
});
|
|
502
|
-
const hash = vault.hashContent(content);
|
|
127
|
+
await vault.writeFile(relPath, content);
|
|
503
128
|
io.emit('file-created', { relPath, user });
|
|
504
|
-
respond(cb, { ok: true
|
|
505
|
-
|
|
506
|
-
await recordActivity(io, {
|
|
507
|
-
type: 'create',
|
|
508
|
-
filePath: relPath,
|
|
509
|
-
actor: actorFromUser(user),
|
|
510
|
-
payload: { source: 'socket' },
|
|
511
|
-
groupKey: `${user.id}:${relPath}:create`,
|
|
512
|
-
});
|
|
513
|
-
|
|
129
|
+
respond(cb, { ok: true });
|
|
514
130
|
console.log(`[socket] file-create: ${relPath} by ${user.username}`);
|
|
515
131
|
} catch (err) {
|
|
516
132
|
console.error(`[socket] file-create error (${relPath}):`, err);
|
|
@@ -518,35 +134,23 @@ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCl
|
|
|
518
134
|
}
|
|
519
135
|
});
|
|
520
136
|
|
|
137
|
+
// -----------------------------------------------------------------------
|
|
138
|
+
// file-delete
|
|
139
|
+
// -----------------------------------------------------------------------
|
|
521
140
|
socket.on('file-delete', async (relPath, cb) => {
|
|
522
|
-
touchPresence(socket.id);
|
|
523
141
|
if (!isAllowedPath(relPath)) {
|
|
524
142
|
rejectPath(cb, relPath);
|
|
525
143
|
return;
|
|
526
144
|
}
|
|
527
145
|
try {
|
|
146
|
+
// Force-close active Yjs room first
|
|
528
147
|
const docName = encodeURIComponent(relPath);
|
|
529
148
|
if (getActiveRooms().has(docName)) {
|
|
530
149
|
await forceCloseRoom(relPath);
|
|
531
150
|
}
|
|
532
|
-
await vault.deleteFile(relPath
|
|
533
|
-
history: {
|
|
534
|
-
action: 'delete',
|
|
535
|
-
source: 'socket',
|
|
536
|
-
actor: actorFromUser(user),
|
|
537
|
-
},
|
|
538
|
-
});
|
|
151
|
+
await vault.deleteFile(relPath);
|
|
539
152
|
io.emit('file-deleted', { relPath, user });
|
|
540
153
|
respond(cb, { ok: true });
|
|
541
|
-
|
|
542
|
-
await recordActivity(io, {
|
|
543
|
-
type: 'delete',
|
|
544
|
-
filePath: relPath,
|
|
545
|
-
actor: actorFromUser(user),
|
|
546
|
-
payload: { source: 'socket' },
|
|
547
|
-
groupKey: `${user.id}:${relPath}:delete`,
|
|
548
|
-
});
|
|
549
|
-
|
|
550
154
|
console.log(`[socket] file-delete: ${relPath} by ${user.username}`);
|
|
551
155
|
} catch (err) {
|
|
552
156
|
console.error(`[socket] file-delete error (${relPath}):`, err);
|
|
@@ -554,8 +158,10 @@ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCl
|
|
|
554
158
|
}
|
|
555
159
|
});
|
|
556
160
|
|
|
161
|
+
// -----------------------------------------------------------------------
|
|
162
|
+
// file-rename
|
|
163
|
+
// -----------------------------------------------------------------------
|
|
557
164
|
socket.on('file-rename', async (payload, cb) => {
|
|
558
|
-
touchPresence(socket.id);
|
|
559
165
|
const oldPath = payload?.oldPath;
|
|
560
166
|
const newPath = payload?.newPath;
|
|
561
167
|
if (!isAllowedPath(oldPath) || !isAllowedPath(newPath)) {
|
|
@@ -563,506 +169,48 @@ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCl
|
|
|
563
169
|
return;
|
|
564
170
|
}
|
|
565
171
|
try {
|
|
172
|
+
// Force-close active Yjs room for old path
|
|
566
173
|
const docName = encodeURIComponent(oldPath);
|
|
567
174
|
if (getActiveRooms().has(docName)) {
|
|
568
175
|
await forceCloseRoom(oldPath);
|
|
569
176
|
}
|
|
570
|
-
await vault.renameFile(oldPath, newPath
|
|
571
|
-
history: {
|
|
572
|
-
source: 'socket',
|
|
573
|
-
actor: actorFromUser(user),
|
|
574
|
-
},
|
|
575
|
-
});
|
|
177
|
+
await vault.renameFile(oldPath, newPath);
|
|
576
178
|
io.emit('file-renamed', { oldPath, newPath, user });
|
|
577
179
|
respond(cb, { ok: true });
|
|
578
|
-
|
|
579
|
-
await recordActivity(io, {
|
|
580
|
-
type: 'rename',
|
|
581
|
-
filePath: newPath,
|
|
582
|
-
actor: actorFromUser(user),
|
|
583
|
-
payload: { oldPath, newPath, source: 'socket' },
|
|
584
|
-
groupKey: `${user.id}:${oldPath}:rename`,
|
|
585
|
-
});
|
|
586
|
-
|
|
587
|
-
console.log(`[socket] file-rename: ${oldPath} -> ${newPath} by ${user.username}`);
|
|
180
|
+
console.log(`[socket] file-rename: ${oldPath} → ${newPath} by ${user.username}`);
|
|
588
181
|
} catch (err) {
|
|
589
182
|
console.error(`[socket] file-rename error (${oldPath}):`, err);
|
|
590
183
|
respond(cb, { ok: false, error: err.message });
|
|
591
184
|
}
|
|
592
185
|
});
|
|
593
186
|
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
if (!isAllowedPath(relPath)) {
|
|
598
|
-
rejectPath(cb, relPath);
|
|
599
|
-
return;
|
|
600
|
-
}
|
|
601
|
-
try {
|
|
602
|
-
const limit = parseHistoryLimit(payload?.limit);
|
|
603
|
-
const versions = await vault.listFileHistory(relPath, limit);
|
|
604
|
-
respond(cb, { ok: true, versions });
|
|
605
|
-
} catch (err) {
|
|
606
|
-
console.error(`[socket] file-history-list error (${relPath}):`, err);
|
|
607
|
-
respond(cb, { ok: false, error: err.message });
|
|
608
|
-
}
|
|
609
|
-
});
|
|
610
|
-
|
|
611
|
-
socket.on('file-history-read', async (payload, cb) => {
|
|
612
|
-
touchPresence(socket.id);
|
|
613
|
-
const relPath = payload?.relPath;
|
|
614
|
-
const versionId = payload?.versionId;
|
|
615
|
-
if (!isAllowedPath(relPath)) {
|
|
616
|
-
rejectPath(cb, relPath);
|
|
617
|
-
return;
|
|
618
|
-
}
|
|
619
|
-
if (typeof versionId !== 'string' || versionId.length === 0) {
|
|
620
|
-
respond(cb, { ok: false, code: 'BAD_REQUEST', error: 'Missing versionId' });
|
|
621
|
-
return;
|
|
622
|
-
}
|
|
623
|
-
try {
|
|
624
|
-
const version = await vault.readFileHistoryVersion(relPath, versionId);
|
|
625
|
-
respond(cb, { ok: true, version });
|
|
626
|
-
} catch (err) {
|
|
627
|
-
console.error(`[socket] file-history-read error (${relPath} @ ${versionId}):`, err);
|
|
628
|
-
respond(cb, { ok: false, error: err.message });
|
|
629
|
-
}
|
|
630
|
-
});
|
|
631
|
-
|
|
187
|
+
// -----------------------------------------------------------------------
|
|
188
|
+
// presence-file-opened
|
|
189
|
+
// -----------------------------------------------------------------------
|
|
632
190
|
socket.on('presence-file-opened', (payload) => {
|
|
633
191
|
const relPath = typeof payload === 'string' ? payload : payload?.relPath;
|
|
634
192
|
const color = normalizeHexColor(typeof payload === 'string' ? null : payload?.color);
|
|
635
193
|
if (!isAllowedPath(relPath)) return;
|
|
636
|
-
|
|
637
194
|
if (!presenceByFile.has(relPath)) presenceByFile.set(relPath, new Set());
|
|
638
195
|
presenceByFile.get(relPath).add(socket.id);
|
|
639
196
|
socketToFiles.get(socket.id)?.add(relPath);
|
|
640
|
-
|
|
641
|
-
touchPresence(socket.id, { activeFile: relPath });
|
|
642
197
|
const presenceUser = color ? { ...user, color } : user;
|
|
643
198
|
socket.broadcast.emit('presence-file-opened', { relPath, user: presenceUser });
|
|
644
199
|
});
|
|
645
200
|
|
|
201
|
+
// -----------------------------------------------------------------------
|
|
202
|
+
// presence-file-closed
|
|
203
|
+
// -----------------------------------------------------------------------
|
|
646
204
|
socket.on('presence-file-closed', (relPath) => {
|
|
647
205
|
if (!isAllowedPath(relPath)) return;
|
|
648
206
|
presenceByFile.get(relPath)?.delete(socket.id);
|
|
649
207
|
socketToFiles.get(socket.id)?.delete(relPath);
|
|
650
|
-
|
|
651
|
-
const state = touchPresence(socket.id);
|
|
652
|
-
if (state.activeFile === relPath) {
|
|
653
|
-
state.activeFile = null;
|
|
654
|
-
}
|
|
655
|
-
|
|
656
208
|
socket.broadcast.emit('presence-file-closed', { relPath, user });
|
|
657
209
|
});
|
|
658
210
|
|
|
659
211
|
// -----------------------------------------------------------------------
|
|
660
|
-
//
|
|
212
|
+
// disconnect
|
|
661
213
|
// -----------------------------------------------------------------------
|
|
662
|
-
|
|
663
|
-
socket.on('collab:thread:list', async (payload, cb) => {
|
|
664
|
-
touchPresence(socket.id);
|
|
665
|
-
const filePath = payload?.filePath;
|
|
666
|
-
if (filePath && !isAllowedPath(filePath)) {
|
|
667
|
-
rejectPath(cb, filePath);
|
|
668
|
-
return;
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
try {
|
|
672
|
-
const threads = await collabStore.listThreads({
|
|
673
|
-
filePath: filePath ?? null,
|
|
674
|
-
status: payload?.status ?? null,
|
|
675
|
-
});
|
|
676
|
-
respond(cb, { ok: true, threads });
|
|
677
|
-
} catch (err) {
|
|
678
|
-
respond(cb, { ok: false, error: err?.message ?? String(err) });
|
|
679
|
-
}
|
|
680
|
-
});
|
|
681
|
-
|
|
682
|
-
socket.on('collab:thread:create', async (payload, cb) => {
|
|
683
|
-
touchPresence(socket.id);
|
|
684
|
-
const filePath = payload?.filePath;
|
|
685
|
-
if (!isAllowedPath(filePath)) {
|
|
686
|
-
rejectPath(cb, filePath);
|
|
687
|
-
return;
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
try {
|
|
691
|
-
const body = typeof payload?.body === 'string' ? payload.body : '';
|
|
692
|
-
const mentions = parseMentions(body);
|
|
693
|
-
const thread = await collabStore.createThread({
|
|
694
|
-
filePath,
|
|
695
|
-
anchor: payload?.anchor ?? null,
|
|
696
|
-
author: actorFromUser(user),
|
|
697
|
-
body,
|
|
698
|
-
mentions,
|
|
699
|
-
});
|
|
700
|
-
|
|
701
|
-
io.emit('collab:thread:created', { thread });
|
|
702
|
-
await recordActivity(io, {
|
|
703
|
-
type: 'comment',
|
|
704
|
-
filePath,
|
|
705
|
-
actor: actorFromUser(user),
|
|
706
|
-
payload: { threadId: thread.threadId, kind: 'thread-created' },
|
|
707
|
-
groupKey: `${user.id}:${filePath}:comment`,
|
|
708
|
-
});
|
|
709
|
-
|
|
710
|
-
if (mentions.length > 0) {
|
|
711
|
-
for (const mention of mentions) {
|
|
712
|
-
const targets = getUsersByMention(mention);
|
|
713
|
-
for (const targetUser of targets) {
|
|
714
|
-
if (targetUser.id === user.id) continue;
|
|
715
|
-
await maybeDeliverNotifyEvent(io, {
|
|
716
|
-
kind: 'mention',
|
|
717
|
-
filePath,
|
|
718
|
-
actor: actorFromUser(user),
|
|
719
|
-
targetUser,
|
|
720
|
-
threadId: thread.threadId,
|
|
721
|
-
body,
|
|
722
|
-
});
|
|
723
|
-
}
|
|
724
|
-
}
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
respond(cb, { ok: true, thread });
|
|
728
|
-
} catch (err) {
|
|
729
|
-
respond(cb, { ok: false, error: err?.message ?? String(err) });
|
|
730
|
-
}
|
|
731
|
-
});
|
|
732
|
-
|
|
733
|
-
socket.on('collab:thread:update', async (payload, cb) => {
|
|
734
|
-
touchPresence(socket.id);
|
|
735
|
-
const threadId = payload?.threadId;
|
|
736
|
-
if (typeof threadId !== 'string' || threadId.length === 0) {
|
|
737
|
-
respond(cb, { ok: false, code: 'BAD_REQUEST', error: 'Missing threadId' });
|
|
738
|
-
return;
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
try {
|
|
742
|
-
const thread = await collabStore.updateThread({
|
|
743
|
-
threadId,
|
|
744
|
-
patch: payload?.patch ?? {},
|
|
745
|
-
});
|
|
746
|
-
if (!thread) {
|
|
747
|
-
respond(cb, { ok: false, code: 'NOT_FOUND', error: 'Thread not found' });
|
|
748
|
-
return;
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
io.emit('collab:thread:updated', { thread });
|
|
752
|
-
await recordActivity(io, {
|
|
753
|
-
type: 'comment',
|
|
754
|
-
filePath: thread.filePath,
|
|
755
|
-
actor: actorFromUser(user),
|
|
756
|
-
payload: { threadId: thread.threadId, kind: 'thread-updated' },
|
|
757
|
-
groupKey: `${user.id}:${thread.filePath}:comment`,
|
|
758
|
-
});
|
|
759
|
-
|
|
760
|
-
respond(cb, { ok: true, thread });
|
|
761
|
-
} catch (err) {
|
|
762
|
-
respond(cb, { ok: false, error: err?.message ?? String(err) });
|
|
763
|
-
}
|
|
764
|
-
});
|
|
765
|
-
|
|
766
|
-
socket.on('collab:thread:delete', async (payload, cb) => {
|
|
767
|
-
touchPresence(socket.id);
|
|
768
|
-
const threadId = payload?.threadId;
|
|
769
|
-
if (typeof threadId !== 'string' || threadId.length === 0) {
|
|
770
|
-
respond(cb, { ok: false, code: 'BAD_REQUEST', error: 'Missing threadId' });
|
|
771
|
-
return;
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
try {
|
|
775
|
-
const thread = await collabStore.deleteThread(threadId);
|
|
776
|
-
if (!thread) {
|
|
777
|
-
respond(cb, { ok: false, code: 'NOT_FOUND', error: 'Thread not found' });
|
|
778
|
-
return;
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
io.emit('collab:thread:deleted', { thread });
|
|
782
|
-
await recordActivity(io, {
|
|
783
|
-
type: 'comment',
|
|
784
|
-
filePath: thread.filePath,
|
|
785
|
-
actor: actorFromUser(user),
|
|
786
|
-
payload: { threadId, kind: 'thread-archived' },
|
|
787
|
-
groupKey: `${user.id}:${thread.filePath}:comment`,
|
|
788
|
-
});
|
|
789
|
-
|
|
790
|
-
respond(cb, { ok: true, thread });
|
|
791
|
-
} catch (err) {
|
|
792
|
-
respond(cb, { ok: false, error: err?.message ?? String(err) });
|
|
793
|
-
}
|
|
794
|
-
});
|
|
795
|
-
|
|
796
|
-
socket.on('collab:comment:create', async (payload, cb) => {
|
|
797
|
-
touchPresence(socket.id);
|
|
798
|
-
const threadId = payload?.threadId;
|
|
799
|
-
const body = typeof payload?.body === 'string' ? payload.body : '';
|
|
800
|
-
if (typeof threadId !== 'string' || threadId.length === 0) {
|
|
801
|
-
respond(cb, { ok: false, code: 'BAD_REQUEST', error: 'Missing threadId' });
|
|
802
|
-
return;
|
|
803
|
-
}
|
|
804
|
-
if (body.trim().length === 0) {
|
|
805
|
-
respond(cb, { ok: false, code: 'BAD_REQUEST', error: 'Missing comment body' });
|
|
806
|
-
return;
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
try {
|
|
810
|
-
const mentions = parseMentions(body);
|
|
811
|
-
const result = await collabStore.createComment({
|
|
812
|
-
threadId,
|
|
813
|
-
author: actorFromUser(user),
|
|
814
|
-
body,
|
|
815
|
-
mentions,
|
|
816
|
-
});
|
|
817
|
-
if (!result) {
|
|
818
|
-
respond(cb, { ok: false, code: 'NOT_FOUND', error: 'Thread not found' });
|
|
819
|
-
return;
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
io.emit('collab:comment:created', {
|
|
823
|
-
threadId,
|
|
824
|
-
comment: result.comment,
|
|
825
|
-
thread: result.thread,
|
|
826
|
-
});
|
|
827
|
-
|
|
828
|
-
await recordActivity(io, {
|
|
829
|
-
type: 'comment',
|
|
830
|
-
filePath: result.thread.filePath,
|
|
831
|
-
actor: actorFromUser(user),
|
|
832
|
-
payload: { threadId, commentId: result.comment.commentId, kind: 'comment-created' },
|
|
833
|
-
groupKey: `${user.id}:${result.thread.filePath}:comment`,
|
|
834
|
-
});
|
|
835
|
-
|
|
836
|
-
if (mentions.length > 0) {
|
|
837
|
-
for (const mention of mentions) {
|
|
838
|
-
const targets = getUsersByMention(mention);
|
|
839
|
-
for (const targetUser of targets) {
|
|
840
|
-
if (targetUser.id === user.id) continue;
|
|
841
|
-
await maybeDeliverNotifyEvent(io, {
|
|
842
|
-
kind: 'mention',
|
|
843
|
-
filePath: result.thread.filePath,
|
|
844
|
-
actor: actorFromUser(user),
|
|
845
|
-
targetUser,
|
|
846
|
-
threadId,
|
|
847
|
-
body,
|
|
848
|
-
});
|
|
849
|
-
}
|
|
850
|
-
}
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
respond(cb, { ok: true, ...result });
|
|
854
|
-
} catch (err) {
|
|
855
|
-
respond(cb, { ok: false, error: err?.message ?? String(err) });
|
|
856
|
-
}
|
|
857
|
-
});
|
|
858
|
-
|
|
859
|
-
socket.on('collab:comment:update', async (payload, cb) => {
|
|
860
|
-
touchPresence(socket.id);
|
|
861
|
-
const threadId = payload?.threadId;
|
|
862
|
-
const commentId = payload?.commentId;
|
|
863
|
-
const body = typeof payload?.body === 'string' ? payload.body : '';
|
|
864
|
-
if (typeof threadId !== 'string' || threadId.length === 0 || typeof commentId !== 'string' || commentId.length === 0) {
|
|
865
|
-
respond(cb, { ok: false, code: 'BAD_REQUEST', error: 'Missing threadId/commentId' });
|
|
866
|
-
return;
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
try {
|
|
870
|
-
const result = await collabStore.updateComment({ threadId, commentId, body });
|
|
871
|
-
if (!result) {
|
|
872
|
-
respond(cb, { ok: false, code: 'NOT_FOUND', error: 'Comment not found' });
|
|
873
|
-
return;
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
io.emit('collab:comment:updated', {
|
|
877
|
-
threadId,
|
|
878
|
-
comment: result.comment,
|
|
879
|
-
thread: result.thread,
|
|
880
|
-
});
|
|
881
|
-
|
|
882
|
-
await recordActivity(io, {
|
|
883
|
-
type: 'comment',
|
|
884
|
-
filePath: result.thread.filePath,
|
|
885
|
-
actor: actorFromUser(user),
|
|
886
|
-
payload: { threadId, commentId, kind: 'comment-updated' },
|
|
887
|
-
groupKey: `${user.id}:${result.thread.filePath}:comment`,
|
|
888
|
-
});
|
|
889
|
-
|
|
890
|
-
respond(cb, { ok: true, ...result });
|
|
891
|
-
} catch (err) {
|
|
892
|
-
respond(cb, { ok: false, error: err?.message ?? String(err) });
|
|
893
|
-
}
|
|
894
|
-
});
|
|
895
|
-
|
|
896
|
-
socket.on('collab:comment:delete', async (payload, cb) => {
|
|
897
|
-
touchPresence(socket.id);
|
|
898
|
-
const threadId = payload?.threadId;
|
|
899
|
-
const commentId = payload?.commentId;
|
|
900
|
-
if (typeof threadId !== 'string' || threadId.length === 0 || typeof commentId !== 'string' || commentId.length === 0) {
|
|
901
|
-
respond(cb, { ok: false, code: 'BAD_REQUEST', error: 'Missing threadId/commentId' });
|
|
902
|
-
return;
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
try {
|
|
906
|
-
const result = await collabStore.deleteComment({ threadId, commentId });
|
|
907
|
-
if (!result) {
|
|
908
|
-
respond(cb, { ok: false, code: 'NOT_FOUND', error: 'Comment not found' });
|
|
909
|
-
return;
|
|
910
|
-
}
|
|
911
|
-
|
|
912
|
-
io.emit('collab:comment:deleted', {
|
|
913
|
-
threadId,
|
|
914
|
-
commentId,
|
|
915
|
-
thread: result.thread,
|
|
916
|
-
});
|
|
917
|
-
|
|
918
|
-
await recordActivity(io, {
|
|
919
|
-
type: 'comment',
|
|
920
|
-
filePath: result.thread.filePath,
|
|
921
|
-
actor: actorFromUser(user),
|
|
922
|
-
payload: { threadId, commentId, kind: 'comment-deleted' },
|
|
923
|
-
groupKey: `${user.id}:${result.thread.filePath}:comment`,
|
|
924
|
-
});
|
|
925
|
-
|
|
926
|
-
respond(cb, { ok: true, ...result });
|
|
927
|
-
} catch (err) {
|
|
928
|
-
respond(cb, { ok: false, error: err?.message ?? String(err) });
|
|
929
|
-
}
|
|
930
|
-
});
|
|
931
|
-
|
|
932
|
-
socket.on('collab:task:set-state', async (payload, cb) => {
|
|
933
|
-
touchPresence(socket.id);
|
|
934
|
-
const threadId = payload?.threadId;
|
|
935
|
-
const status = payload?.status;
|
|
936
|
-
if (typeof threadId !== 'string' || threadId.length === 0 || (status !== 'open' && status !== 'resolved')) {
|
|
937
|
-
respond(cb, { ok: false, code: 'BAD_REQUEST', error: 'Invalid threadId/status' });
|
|
938
|
-
return;
|
|
939
|
-
}
|
|
940
|
-
|
|
941
|
-
try {
|
|
942
|
-
const result = await collabStore.setTaskState({
|
|
943
|
-
threadId,
|
|
944
|
-
status,
|
|
945
|
-
assignee: payload?.assignee ?? null,
|
|
946
|
-
dueAt: payload?.dueAt ?? null,
|
|
947
|
-
});
|
|
948
|
-
if (!result) {
|
|
949
|
-
respond(cb, { ok: false, code: 'NOT_FOUND', error: 'Thread not found' });
|
|
950
|
-
return;
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
io.emit('collab:task:updated', {
|
|
954
|
-
threadId,
|
|
955
|
-
task: result.task,
|
|
956
|
-
thread: result.thread,
|
|
957
|
-
});
|
|
958
|
-
|
|
959
|
-
await recordActivity(io, {
|
|
960
|
-
type: 'task',
|
|
961
|
-
filePath: result.thread.filePath,
|
|
962
|
-
actor: actorFromUser(user),
|
|
963
|
-
payload: { threadId, taskId: result.task.taskId, status },
|
|
964
|
-
groupKey: `${user.id}:${result.thread.filePath}:task`,
|
|
965
|
-
});
|
|
966
|
-
|
|
967
|
-
if (result.task.assignee?.username) {
|
|
968
|
-
const assigneeName = String(result.task.assignee.username).toLowerCase();
|
|
969
|
-
const targets = getUsersByMention(assigneeName);
|
|
970
|
-
for (const targetUser of targets) {
|
|
971
|
-
if (targetUser.id === user.id) continue;
|
|
972
|
-
await maybeDeliverNotifyEvent(io, {
|
|
973
|
-
kind: 'task',
|
|
974
|
-
filePath: result.thread.filePath,
|
|
975
|
-
actor: actorFromUser(user),
|
|
976
|
-
targetUser,
|
|
977
|
-
threadId,
|
|
978
|
-
body: `Task set to ${status}`,
|
|
979
|
-
});
|
|
980
|
-
}
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
respond(cb, { ok: true, ...result });
|
|
984
|
-
} catch (err) {
|
|
985
|
-
respond(cb, { ok: false, error: err?.message ?? String(err) });
|
|
986
|
-
}
|
|
987
|
-
});
|
|
988
|
-
|
|
989
|
-
socket.on('collab:activity:list', async (payload, cb) => {
|
|
990
|
-
touchPresence(socket.id);
|
|
991
|
-
const filePath = payload?.filePath ?? null;
|
|
992
|
-
if (filePath && !isAllowedPath(filePath)) {
|
|
993
|
-
rejectPath(cb, filePath);
|
|
994
|
-
return;
|
|
995
|
-
}
|
|
996
|
-
|
|
997
|
-
try {
|
|
998
|
-
const result = await collabStore.listActivity({
|
|
999
|
-
filePath,
|
|
1000
|
-
types: Array.isArray(payload?.types) ? payload.types : null,
|
|
1001
|
-
limit: payload?.limit,
|
|
1002
|
-
cursor: payload?.cursor ?? null,
|
|
1003
|
-
});
|
|
1004
|
-
respond(cb, { ok: true, ...result });
|
|
1005
|
-
} catch (err) {
|
|
1006
|
-
respond(cb, { ok: false, error: err?.message ?? String(err) });
|
|
1007
|
-
}
|
|
1008
|
-
});
|
|
1009
|
-
|
|
1010
|
-
socket.on('collab:activity:subscribe', (payload, cb) => {
|
|
1011
|
-
touchPresence(socket.id);
|
|
1012
|
-
const scope = payload?.scope === 'file' ? 'file' : 'workspace';
|
|
1013
|
-
const filePath = payload?.filePath ?? null;
|
|
1014
|
-
|
|
1015
|
-
if (scope === 'file') {
|
|
1016
|
-
if (!isAllowedPath(filePath)) {
|
|
1017
|
-
rejectPath(cb, filePath);
|
|
1018
|
-
return;
|
|
1019
|
-
}
|
|
1020
|
-
socket.join(activityRoomForFile(filePath));
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
|
-
socket.join(activityRoomForWorkspace());
|
|
1024
|
-
respond(cb, { ok: true, scope, filePath: filePath ?? null });
|
|
1025
|
-
});
|
|
1026
|
-
|
|
1027
|
-
socket.on('collab:notify:preferences:get', async (_payload, cb) => {
|
|
1028
|
-
touchPresence(socket.id);
|
|
1029
|
-
try {
|
|
1030
|
-
const preferences = await collabStore.getNotifyPreferences(user.id);
|
|
1031
|
-
respond(cb, { ok: true, preferences });
|
|
1032
|
-
} catch (err) {
|
|
1033
|
-
respond(cb, { ok: false, error: err?.message ?? String(err) });
|
|
1034
|
-
}
|
|
1035
|
-
});
|
|
1036
|
-
|
|
1037
|
-
socket.on('collab:notify:preferences:set', async (payload, cb) => {
|
|
1038
|
-
touchPresence(socket.id);
|
|
1039
|
-
const scope = payload?.scope;
|
|
1040
|
-
const key = payload?.key ?? null;
|
|
1041
|
-
const mode = payload?.mode;
|
|
1042
|
-
|
|
1043
|
-
if (scope === 'file' && key && !isAllowedPath(key)) {
|
|
1044
|
-
rejectPath(cb, key);
|
|
1045
|
-
return;
|
|
1046
|
-
}
|
|
1047
|
-
if (scope === 'workspace' && typeof key !== 'string') {
|
|
1048
|
-
respond(cb, { ok: false, code: 'BAD_REQUEST', error: 'workspace scope requires key' });
|
|
1049
|
-
return;
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
try {
|
|
1053
|
-
const preferences = await collabStore.setNotifyPreference({
|
|
1054
|
-
userId: user.id,
|
|
1055
|
-
scope,
|
|
1056
|
-
key,
|
|
1057
|
-
mode,
|
|
1058
|
-
});
|
|
1059
|
-
|
|
1060
|
-
respond(cb, { ok: true, preferences });
|
|
1061
|
-
} catch (err) {
|
|
1062
|
-
respond(cb, { ok: false, error: err?.message ?? String(err) });
|
|
1063
|
-
}
|
|
1064
|
-
});
|
|
1065
|
-
|
|
1066
214
|
socket.on('disconnect', () => {
|
|
1067
215
|
console.log(`[socket] Disconnected: ${user.username} (${socket.id})`);
|
|
1068
216
|
const openFiles = socketToFiles.get(socket.id) ?? new Set();
|
|
@@ -1072,7 +220,6 @@ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCl
|
|
|
1072
220
|
}
|
|
1073
221
|
socketToFiles.delete(socket.id);
|
|
1074
222
|
userBySocket.delete(socket.id);
|
|
1075
|
-
presenceStateBySocket.delete(socket.id);
|
|
1076
223
|
socket.broadcast.emit('user-left', { user });
|
|
1077
224
|
});
|
|
1078
225
|
});
|