@fyresmith/hive-server 2.4.0 → 3.1.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 +78 -884
- 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,45 +1,29 @@
|
|
|
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
22
|
/**
|
|
28
|
-
*
|
|
29
|
-
* @type {Map<string,
|
|
23
|
+
* claimedFiles: file path → { socketId, userId, username, color }
|
|
24
|
+
* @type {Map<string, object>}
|
|
30
25
|
*/
|
|
31
|
-
const
|
|
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;
|
|
26
|
+
const claimedFiles = new Map();
|
|
43
27
|
|
|
44
28
|
function respond(cb, payload) {
|
|
45
29
|
if (typeof cb === 'function') cb(payload);
|
|
@@ -60,243 +44,17 @@ function normalizeHexColor(color) {
|
|
|
60
44
|
return /^#[0-9a-fA-F]{6}$/.test(trimmed) ? trimmed.toLowerCase() : null;
|
|
61
45
|
}
|
|
62
46
|
|
|
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
47
|
/**
|
|
290
48
|
* Attach all Socket.IO event handlers.
|
|
291
49
|
*
|
|
292
50
|
* @param {import('socket.io').Server} io
|
|
293
|
-
* @param {() => Set<string>} getActiveRooms
|
|
51
|
+
* @param {() => Set<string>} getActiveRooms - returns encoded docNames currently in Yjs
|
|
294
52
|
* @param {(relPath: string, hash: string, excludeSocketId: string|null) => void} broadcastFileUpdated
|
|
295
53
|
* @param {(relPath: string) => Promise<void>} forceCloseRoom
|
|
296
54
|
*/
|
|
297
55
|
export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCloseRoom) {
|
|
56
|
+
// Auth middleware for every socket connection
|
|
298
57
|
io.use(socketMiddleware);
|
|
299
|
-
ensureStaleSweep(io);
|
|
300
58
|
|
|
301
59
|
io.on('connection', (socket) => {
|
|
302
60
|
const user = socket.user;
|
|
@@ -304,99 +62,14 @@ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCl
|
|
|
304
62
|
|
|
305
63
|
userBySocket.set(socket.id, user);
|
|
306
64
|
socketToFiles.set(socket.id, new Set());
|
|
307
|
-
touchPresence(socket.id);
|
|
308
65
|
|
|
66
|
+
// Notify others this user joined
|
|
309
67
|
socket.broadcast.emit('user-joined', { user });
|
|
310
68
|
|
|
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
69
|
// -----------------------------------------------------------------------
|
|
395
|
-
//
|
|
70
|
+
// vault-sync-request
|
|
396
71
|
// -----------------------------------------------------------------------
|
|
397
|
-
|
|
398
72
|
socket.on('vault-sync-request', async (cb) => {
|
|
399
|
-
touchPresence(socket.id);
|
|
400
73
|
try {
|
|
401
74
|
const manifest = await vault.getManifest();
|
|
402
75
|
respond(cb, { ok: true, manifest });
|
|
@@ -406,8 +79,10 @@ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCl
|
|
|
406
79
|
}
|
|
407
80
|
});
|
|
408
81
|
|
|
82
|
+
// -----------------------------------------------------------------------
|
|
83
|
+
// file-read
|
|
84
|
+
// -----------------------------------------------------------------------
|
|
409
85
|
socket.on('file-read', async (relPath, cb) => {
|
|
410
|
-
touchPresence(socket.id);
|
|
411
86
|
if (!isAllowedPath(relPath)) {
|
|
412
87
|
rejectPath(cb, relPath);
|
|
413
88
|
return;
|
|
@@ -422,56 +97,21 @@ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCl
|
|
|
422
97
|
}
|
|
423
98
|
});
|
|
424
99
|
|
|
100
|
+
// -----------------------------------------------------------------------
|
|
101
|
+
// file-write
|
|
102
|
+
// -----------------------------------------------------------------------
|
|
425
103
|
socket.on('file-write', async (payload, cb) => {
|
|
426
|
-
touchPresence(socket.id);
|
|
427
104
|
const relPath = payload?.relPath;
|
|
428
105
|
const content = payload?.content;
|
|
429
|
-
const expectedHash = normalizeExpectedHash(payload?.expectedHash);
|
|
430
106
|
if (!isAllowedPath(relPath) || typeof content !== 'string') {
|
|
431
107
|
rejectPath(cb, relPath);
|
|
432
108
|
return;
|
|
433
109
|
}
|
|
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
110
|
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
|
-
});
|
|
111
|
+
await vault.writeFile(relPath, content);
|
|
462
112
|
const hash = vault.hashContent(content);
|
|
463
113
|
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
|
-
|
|
114
|
+
respond(cb, { ok: true, hash });
|
|
475
115
|
console.log(`[socket] file-write: ${relPath} by ${user.username}`);
|
|
476
116
|
} catch (err) {
|
|
477
117
|
console.error(`[socket] file-write error (${relPath}):`, err);
|
|
@@ -479,38 +119,20 @@ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCl
|
|
|
479
119
|
}
|
|
480
120
|
});
|
|
481
121
|
|
|
122
|
+
// -----------------------------------------------------------------------
|
|
123
|
+
// file-create
|
|
124
|
+
// -----------------------------------------------------------------------
|
|
482
125
|
socket.on('file-create', async (payload, cb) => {
|
|
483
|
-
touchPresence(socket.id);
|
|
484
126
|
const relPath = payload?.relPath;
|
|
485
127
|
const content = payload?.content;
|
|
486
128
|
if (!isAllowedPath(relPath) || typeof content !== 'string') {
|
|
487
129
|
rejectPath(cb, relPath);
|
|
488
130
|
return;
|
|
489
131
|
}
|
|
490
|
-
if (!normalizeAdapterContent(relPath, content, cb)) {
|
|
491
|
-
return;
|
|
492
|
-
}
|
|
493
|
-
|
|
494
132
|
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);
|
|
133
|
+
await vault.writeFile(relPath, content);
|
|
503
134
|
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
|
-
|
|
135
|
+
respond(cb, { ok: true });
|
|
514
136
|
console.log(`[socket] file-create: ${relPath} by ${user.username}`);
|
|
515
137
|
} catch (err) {
|
|
516
138
|
console.error(`[socket] file-create error (${relPath}):`, err);
|
|
@@ -518,35 +140,23 @@ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCl
|
|
|
518
140
|
}
|
|
519
141
|
});
|
|
520
142
|
|
|
143
|
+
// -----------------------------------------------------------------------
|
|
144
|
+
// file-delete
|
|
145
|
+
// -----------------------------------------------------------------------
|
|
521
146
|
socket.on('file-delete', async (relPath, cb) => {
|
|
522
|
-
touchPresence(socket.id);
|
|
523
147
|
if (!isAllowedPath(relPath)) {
|
|
524
148
|
rejectPath(cb, relPath);
|
|
525
149
|
return;
|
|
526
150
|
}
|
|
527
151
|
try {
|
|
152
|
+
// Force-close active Yjs room first
|
|
528
153
|
const docName = encodeURIComponent(relPath);
|
|
529
154
|
if (getActiveRooms().has(docName)) {
|
|
530
155
|
await forceCloseRoom(relPath);
|
|
531
156
|
}
|
|
532
|
-
await vault.deleteFile(relPath
|
|
533
|
-
history: {
|
|
534
|
-
action: 'delete',
|
|
535
|
-
source: 'socket',
|
|
536
|
-
actor: actorFromUser(user),
|
|
537
|
-
},
|
|
538
|
-
});
|
|
157
|
+
await vault.deleteFile(relPath);
|
|
539
158
|
io.emit('file-deleted', { relPath, user });
|
|
540
159
|
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
160
|
console.log(`[socket] file-delete: ${relPath} by ${user.username}`);
|
|
551
161
|
} catch (err) {
|
|
552
162
|
console.error(`[socket] file-delete error (${relPath}):`, err);
|
|
@@ -554,8 +164,10 @@ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCl
|
|
|
554
164
|
}
|
|
555
165
|
});
|
|
556
166
|
|
|
167
|
+
// -----------------------------------------------------------------------
|
|
168
|
+
// file-rename
|
|
169
|
+
// -----------------------------------------------------------------------
|
|
557
170
|
socket.on('file-rename', async (payload, cb) => {
|
|
558
|
-
touchPresence(socket.id);
|
|
559
171
|
const oldPath = payload?.oldPath;
|
|
560
172
|
const newPath = payload?.newPath;
|
|
561
173
|
if (!isAllowedPath(oldPath) || !isAllowedPath(newPath)) {
|
|
@@ -563,506 +175,80 @@ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCl
|
|
|
563
175
|
return;
|
|
564
176
|
}
|
|
565
177
|
try {
|
|
178
|
+
// Force-close active Yjs room for old path
|
|
566
179
|
const docName = encodeURIComponent(oldPath);
|
|
567
180
|
if (getActiveRooms().has(docName)) {
|
|
568
181
|
await forceCloseRoom(oldPath);
|
|
569
182
|
}
|
|
570
|
-
await vault.renameFile(oldPath, newPath
|
|
571
|
-
history: {
|
|
572
|
-
source: 'socket',
|
|
573
|
-
actor: actorFromUser(user),
|
|
574
|
-
},
|
|
575
|
-
});
|
|
183
|
+
await vault.renameFile(oldPath, newPath);
|
|
576
184
|
io.emit('file-renamed', { oldPath, newPath, user });
|
|
577
185
|
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}`);
|
|
186
|
+
console.log(`[socket] file-rename: ${oldPath} → ${newPath} by ${user.username}`);
|
|
588
187
|
} catch (err) {
|
|
589
188
|
console.error(`[socket] file-rename error (${oldPath}):`, err);
|
|
590
189
|
respond(cb, { ok: false, error: err.message });
|
|
591
190
|
}
|
|
592
191
|
});
|
|
593
192
|
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
-
}
|
|
193
|
+
// -----------------------------------------------------------------------
|
|
194
|
+
// file-claim
|
|
195
|
+
// -----------------------------------------------------------------------
|
|
196
|
+
socket.on('file-claim', ({ relPath } = {}, cb) => {
|
|
197
|
+
if (!isAllowedPath(relPath)) return respond(cb, { ok: false, error: 'Not allowed' });
|
|
198
|
+
const claimUser = userBySocket.get(socket.id);
|
|
199
|
+
if (!claimUser) return;
|
|
200
|
+
claimedFiles.set(relPath, { socketId: socket.id, ...claimUser });
|
|
201
|
+
io.emit('file-claimed', { relPath, user: claimUser });
|
|
202
|
+
respond(cb, { ok: true });
|
|
609
203
|
});
|
|
610
204
|
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
}
|
|
205
|
+
// -----------------------------------------------------------------------
|
|
206
|
+
// file-unclaim
|
|
207
|
+
// -----------------------------------------------------------------------
|
|
208
|
+
socket.on('file-unclaim', ({ relPath } = {}, cb) => {
|
|
209
|
+
const claim = claimedFiles.get(relPath);
|
|
210
|
+
if (claim?.socketId !== socket.id) return respond(cb, { ok: false, error: 'Not your claim' });
|
|
211
|
+
claimedFiles.delete(relPath);
|
|
212
|
+
io.emit('file-unclaimed', { relPath, userId: userBySocket.get(socket.id)?.id });
|
|
213
|
+
respond(cb, { ok: true });
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// -----------------------------------------------------------------------
|
|
217
|
+
// user-status-changed
|
|
218
|
+
// -----------------------------------------------------------------------
|
|
219
|
+
socket.on('user-status-changed', ({ status } = {}) => {
|
|
220
|
+
const statusUser = userBySocket.get(socket.id);
|
|
221
|
+
if (!statusUser) return;
|
|
222
|
+
socket.broadcast.emit('user-status-changed', { userId: statusUser.id, status });
|
|
630
223
|
});
|
|
631
224
|
|
|
225
|
+
// -----------------------------------------------------------------------
|
|
226
|
+
// presence-file-opened
|
|
227
|
+
// -----------------------------------------------------------------------
|
|
632
228
|
socket.on('presence-file-opened', (payload) => {
|
|
633
229
|
const relPath = typeof payload === 'string' ? payload : payload?.relPath;
|
|
634
230
|
const color = normalizeHexColor(typeof payload === 'string' ? null : payload?.color);
|
|
635
231
|
if (!isAllowedPath(relPath)) return;
|
|
636
|
-
|
|
637
232
|
if (!presenceByFile.has(relPath)) presenceByFile.set(relPath, new Set());
|
|
638
233
|
presenceByFile.get(relPath).add(socket.id);
|
|
639
234
|
socketToFiles.get(socket.id)?.add(relPath);
|
|
640
|
-
|
|
641
|
-
touchPresence(socket.id, { activeFile: relPath });
|
|
642
235
|
const presenceUser = color ? { ...user, color } : user;
|
|
643
236
|
socket.broadcast.emit('presence-file-opened', { relPath, user: presenceUser });
|
|
644
237
|
});
|
|
645
238
|
|
|
239
|
+
// -----------------------------------------------------------------------
|
|
240
|
+
// presence-file-closed
|
|
241
|
+
// -----------------------------------------------------------------------
|
|
646
242
|
socket.on('presence-file-closed', (relPath) => {
|
|
647
243
|
if (!isAllowedPath(relPath)) return;
|
|
648
244
|
presenceByFile.get(relPath)?.delete(socket.id);
|
|
649
245
|
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
246
|
socket.broadcast.emit('presence-file-closed', { relPath, user });
|
|
657
247
|
});
|
|
658
248
|
|
|
659
249
|
// -----------------------------------------------------------------------
|
|
660
|
-
//
|
|
250
|
+
// disconnect
|
|
661
251
|
// -----------------------------------------------------------------------
|
|
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
252
|
socket.on('disconnect', () => {
|
|
1067
253
|
console.log(`[socket] Disconnected: ${user.username} (${socket.id})`);
|
|
1068
254
|
const openFiles = socketToFiles.get(socket.id) ?? new Set();
|
|
@@ -1071,8 +257,16 @@ export function attachHandlers(io, getActiveRooms, broadcastFileUpdated, forceCl
|
|
|
1071
257
|
socket.broadcast.emit('presence-file-closed', { relPath, user });
|
|
1072
258
|
}
|
|
1073
259
|
socketToFiles.delete(socket.id);
|
|
260
|
+
|
|
261
|
+
// Release all file claims held by this socket
|
|
262
|
+
for (const [relPath, claim] of claimedFiles) {
|
|
263
|
+
if (claim.socketId === socket.id) {
|
|
264
|
+
claimedFiles.delete(relPath);
|
|
265
|
+
io.emit('file-unclaimed', { relPath, userId: claim.id });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
1074
269
|
userBySocket.delete(socket.id);
|
|
1075
|
-
presenceStateBySocket.delete(socket.id);
|
|
1076
270
|
socket.broadcast.emit('user-left', { user });
|
|
1077
271
|
});
|
|
1078
272
|
});
|