design-share 0.2.2 → 0.3.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/bin/design-share.js +8 -1
- package/dist/DesignShare.app/Contents/CodeResources +0 -0
- package/dist/DesignShare.app/Contents/Info.plist +2 -2
- package/dist/DesignShare.app/Contents/MacOS/DesignShare +0 -0
- package/package.json +1 -1
- package/public/app.js +109 -6
- package/public/index.html +4 -0
- package/public/style.css +60 -0
- package/src/server.js +20 -0
- package/src/state.js +80 -1
package/bin/design-share.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
const major = Number(process.versions.node.split('.')[0]);
|
|
3
|
+
if (major < 18) {
|
|
4
|
+
console.error(`\n design-share needs Node 18 or newer. You are on ${process.versions.node}.`);
|
|
5
|
+
console.error(' Update at https://nodejs.org and run npx design-share again.\n');
|
|
6
|
+
process.exit(1);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const { run } = await import('../src/cli.js');
|
|
3
10
|
|
|
4
11
|
run(process.argv.slice(2)).catch((err) => {
|
|
5
12
|
console.error(`\n \x1b[31m✕\x1b[0m ${err.message}`);
|
|
Binary file
|
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
<key>CFBundleIdentifier</key>
|
|
10
10
|
<string>com.design-share.menubar</string>
|
|
11
11
|
<key>CFBundleVersion</key>
|
|
12
|
-
<string>0.
|
|
12
|
+
<string>0.3.0</string>
|
|
13
13
|
<key>CFBundleShortVersionString</key>
|
|
14
|
-
<string>0.
|
|
14
|
+
<string>0.3.0</string>
|
|
15
15
|
<key>CFBundleExecutable</key>
|
|
16
16
|
<string>DesignShare</string>
|
|
17
17
|
<key>CFBundlePackageType</key>
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "design-share",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Live branch previews and pinned comments for design teams. Zero config, no server, git is the backend.",
|
|
5
5
|
"keywords": ["design", "preview", "branches", "collaboration", "cli"],
|
|
6
6
|
"license": "MIT",
|
package/public/app.js
CHANGED
|
@@ -7,8 +7,68 @@ const icons = {
|
|
|
7
7
|
message: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/></svg>',
|
|
8
8
|
check: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>',
|
|
9
9
|
plus: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>',
|
|
10
|
+
eye: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg>',
|
|
10
11
|
};
|
|
11
12
|
|
|
13
|
+
/* ---------- review requests ---------- */
|
|
14
|
+
|
|
15
|
+
function branchRequests(branch) {
|
|
16
|
+
if (!state.board) return [];
|
|
17
|
+
return (state.board.requests || [])
|
|
18
|
+
.filter((r) => r.branch === branch)
|
|
19
|
+
.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function openRequestForMe(branch) {
|
|
23
|
+
const me = state.board.me.slug;
|
|
24
|
+
return branchRequests(branch).find((r) => r.toUser === me && !r.resolvedAt);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function renderRequestButton() {
|
|
28
|
+
const b = state.board;
|
|
29
|
+
const sel = state.selected;
|
|
30
|
+
const btn = el('btn-request');
|
|
31
|
+
const roster = (b.users || []).filter((u) => u.user !== b.me.slug);
|
|
32
|
+
const visible = !!sel && roster.length > 0;
|
|
33
|
+
btn.hidden = !visible;
|
|
34
|
+
if (visible) btn.innerHTML = `${icons.eye} <span>Request review</span>`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function toggleRequestPopover(force) {
|
|
38
|
+
const pop = el('request-popover');
|
|
39
|
+
const show = force !== undefined ? force : pop.hidden;
|
|
40
|
+
if (!show) { pop.hidden = true; return; }
|
|
41
|
+
const b = state.board;
|
|
42
|
+
const sel = state.selected;
|
|
43
|
+
if (!sel) return;
|
|
44
|
+
const roster = (b.users || [])
|
|
45
|
+
.filter((u) => u.user !== b.me.slug)
|
|
46
|
+
.sort((x, y) => (y.lastSeen || 0) - (x.lastSeen || 0));
|
|
47
|
+
pop.innerHTML = '';
|
|
48
|
+
const head = document.createElement('div');
|
|
49
|
+
head.className = 'popover-head';
|
|
50
|
+
head.textContent = `Ask someone to review ${sel.branch}`;
|
|
51
|
+
pop.appendChild(head);
|
|
52
|
+
for (const u of roster) {
|
|
53
|
+
const already = branchRequests(sel.branch).some((r) => r.toUser === u.user && !r.resolvedAt && r.fromUser === b.me.slug);
|
|
54
|
+
const row = document.createElement('button');
|
|
55
|
+
row.className = 'popover-row';
|
|
56
|
+
row.innerHTML = `
|
|
57
|
+
<span class="avatar">${escapeHtml((u.name || u.user).slice(0, 1))}</span>
|
|
58
|
+
<span class="popover-name">${escapeHtml(u.name || u.user)}</span>
|
|
59
|
+
<span class="popover-meta">${already ? 'asked' : timeAgo((u.lastSeen || 0) / 1000)}</span>`;
|
|
60
|
+
row.disabled = already;
|
|
61
|
+
row.onclick = async () => {
|
|
62
|
+
await api('/api/requests', { branch: sel.branch, toUser: u.user, toName: u.name });
|
|
63
|
+
toggleRequestPopover(false);
|
|
64
|
+
toast(`Asked ${u.name || u.user} to review ${sel.branch}`);
|
|
65
|
+
refresh();
|
|
66
|
+
};
|
|
67
|
+
pop.appendChild(row);
|
|
68
|
+
}
|
|
69
|
+
pop.hidden = false;
|
|
70
|
+
}
|
|
71
|
+
|
|
12
72
|
const el = (id) => document.getElementById(id);
|
|
13
73
|
const state = {
|
|
14
74
|
board: null,
|
|
@@ -189,9 +249,11 @@ function branchRow({ user, branch, time, dim, own }) {
|
|
|
189
249
|
|
|
190
250
|
const unresolved = openCount(branch);
|
|
191
251
|
const pr = b.prs && b.prs[branch];
|
|
252
|
+
const askedMe = openRequestForMe(branch);
|
|
192
253
|
btn.innerHTML = `
|
|
193
254
|
<span class="dot ${dotCls}"></span>
|
|
194
255
|
<span class="branch-label">${escapeHtml(branch)}</span>
|
|
256
|
+
${askedMe ? `<span class="badge review" title="${escapeHtml((askedMe.fromName || askedMe.fromUser) + ' asked for your review')}">review</span>` : ''}
|
|
195
257
|
${unresolved ? `<span class="badge">${unresolved}</span>` : ''}
|
|
196
258
|
${pr ? `<span class="pr-link" title="${escapeHtml(`PR #${pr.number}: ${pr.title || ''}`)}">#${pr.number}</span>` : ''}
|
|
197
259
|
<span class="meta">${timeAgo(time)}</span>`;
|
|
@@ -295,6 +357,8 @@ function renderStage() {
|
|
|
295
357
|
el('loading-log').textContent = ps && ps.logTail ? ps.logTail.trim() : '';
|
|
296
358
|
}
|
|
297
359
|
|
|
360
|
+
renderRequestButton();
|
|
361
|
+
|
|
298
362
|
const cm = el('btn-comment-mode');
|
|
299
363
|
cm.innerHTML = `${icons.message} <span>${state.commentMode ? 'Click an element to pin' : 'Comment'}</span>`;
|
|
300
364
|
cm.classList.toggle('active-mode', state.commentMode);
|
|
@@ -353,11 +417,13 @@ window.addEventListener('message', (e) => {
|
|
|
353
417
|
el('composer-text').value = '';
|
|
354
418
|
el('composer-text').focus();
|
|
355
419
|
} else if (d.ds === 'pin-click') {
|
|
356
|
-
state.focusedComment = d.id;
|
|
420
|
+
state.focusedComment = state.focusedComment === d.id ? null : d.id;
|
|
357
421
|
renderComments();
|
|
358
422
|
pushCommentsToFrame();
|
|
359
|
-
|
|
360
|
-
|
|
423
|
+
if (state.focusedComment) {
|
|
424
|
+
const card = document.querySelector(`[data-comment="${d.id}"]`);
|
|
425
|
+
if (card) card.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
426
|
+
}
|
|
361
427
|
}
|
|
362
428
|
});
|
|
363
429
|
|
|
@@ -459,6 +525,34 @@ function renderComments() {
|
|
|
459
525
|
el('comments-count').textContent = open.length ? `${open.length} open` : '';
|
|
460
526
|
|
|
461
527
|
const frag = document.createDocumentFragment();
|
|
528
|
+
|
|
529
|
+
const me = state.board.me.slug;
|
|
530
|
+
for (const r of branchRequests(sel.branch).filter((x) => !x.resolvedAt)) {
|
|
531
|
+
const card = document.createElement('div');
|
|
532
|
+
card.className = 'request-card' + (r.toUser === me ? ' for-me' : '');
|
|
533
|
+
if (r.toUser === me) {
|
|
534
|
+
card.innerHTML = `<span class="request-text"><b>${escapeHtml(r.fromName || r.fromUser)}</b> asked for your review</span>
|
|
535
|
+
<span class="request-meta">${timeAgo(r.createdAt / 1000)}</span>`;
|
|
536
|
+
const done = document.createElement('button');
|
|
537
|
+
done.className = 'btn small';
|
|
538
|
+
done.textContent = 'Mark reviewed';
|
|
539
|
+
done.onclick = async () => { await api(`/api/requests/${r.id}/resolve`, { how: 'done' }); refresh(); };
|
|
540
|
+
card.appendChild(done);
|
|
541
|
+
} else if (r.fromUser === me) {
|
|
542
|
+
card.innerHTML = `<span class="request-text">You asked <b>${escapeHtml(r.toName || r.toUser)}</b> to review</span>
|
|
543
|
+
<span class="request-meta">${timeAgo(r.createdAt / 1000)}</span>`;
|
|
544
|
+
const cancel = document.createElement('button');
|
|
545
|
+
cancel.className = 'link-btn';
|
|
546
|
+
cancel.textContent = 'Cancel';
|
|
547
|
+
cancel.onclick = async () => { await api(`/api/requests/${r.id}/resolve`, { how: 'cancelled' }); refresh(); };
|
|
548
|
+
card.appendChild(cancel);
|
|
549
|
+
} else {
|
|
550
|
+
card.innerHTML = `<span class="request-text"><b>${escapeHtml(r.fromName || r.fromUser)}</b> asked <b>${escapeHtml(r.toName || r.toUser)}</b> to review</span>
|
|
551
|
+
<span class="request-meta">${timeAgo(r.createdAt / 1000)}</span>`;
|
|
552
|
+
}
|
|
553
|
+
frag.appendChild(card);
|
|
554
|
+
}
|
|
555
|
+
|
|
462
556
|
if (!comments.length) {
|
|
463
557
|
const d = document.createElement('div');
|
|
464
558
|
d.className = 'empty-note';
|
|
@@ -539,11 +633,10 @@ function commentCard(c, num) {
|
|
|
539
633
|
|
|
540
634
|
card.onclick = (e) => {
|
|
541
635
|
if (e.target === reply || e.target.tagName === 'BUTTON') return;
|
|
542
|
-
state.focusedComment = c.id;
|
|
636
|
+
state.focusedComment = state.focusedComment === c.id ? null : c.id;
|
|
637
|
+
renderComments();
|
|
543
638
|
renderPins();
|
|
544
639
|
pushCommentsToFrame();
|
|
545
|
-
document.querySelectorAll('.comment-card').forEach((x) => x.classList.remove('focused'));
|
|
546
|
-
card.classList.add('focused');
|
|
547
640
|
};
|
|
548
641
|
return card;
|
|
549
642
|
}
|
|
@@ -576,6 +669,12 @@ document.addEventListener('keydown', (e) => {
|
|
|
576
669
|
if (e.key === 'Escape') {
|
|
577
670
|
if (!el('composer').hidden) closeComposer();
|
|
578
671
|
else if (state.commentMode) { state.commentMode = false; renderStage(); }
|
|
672
|
+
else if (state.focusedComment) {
|
|
673
|
+
state.focusedComment = null;
|
|
674
|
+
renderComments();
|
|
675
|
+
renderPins();
|
|
676
|
+
pushCommentsToFrame();
|
|
677
|
+
}
|
|
579
678
|
}
|
|
580
679
|
});
|
|
581
680
|
|
|
@@ -623,6 +722,10 @@ function parseHash() {
|
|
|
623
722
|
state.railFilter = e.target.value;
|
|
624
723
|
renderRail();
|
|
625
724
|
});
|
|
725
|
+
el('btn-request').onclick = (e) => { e.stopPropagation(); toggleRequestPopover(); };
|
|
726
|
+
document.addEventListener('click', (e) => {
|
|
727
|
+
if (!el('request-popover').hidden && !e.target.closest('.request-wrap')) toggleRequestPopover(false);
|
|
728
|
+
});
|
|
626
729
|
renderViewportSeg();
|
|
627
730
|
await refresh();
|
|
628
731
|
const target = parseHash();
|
package/public/index.html
CHANGED
|
@@ -31,6 +31,10 @@
|
|
|
31
31
|
</div>
|
|
32
32
|
<div class="stage-actions">
|
|
33
33
|
<div class="seg" id="viewport-seg"></div>
|
|
34
|
+
<div class="request-wrap">
|
|
35
|
+
<button class="btn ghost" id="btn-request"></button>
|
|
36
|
+
<div id="request-popover" hidden></div>
|
|
37
|
+
</div>
|
|
34
38
|
<button class="btn" id="btn-comment-mode"></button>
|
|
35
39
|
</div>
|
|
36
40
|
</header>
|
package/public/style.css
CHANGED
|
@@ -135,6 +135,66 @@ button { font: inherit; cursor: pointer; }
|
|
|
135
135
|
}
|
|
136
136
|
.pr-link:hover { color: var(--accent); border-color: var(--accent); }
|
|
137
137
|
|
|
138
|
+
.badge.review { color: var(--amber); border-color: #ecd9b8; background: #fdf8ee; }
|
|
139
|
+
|
|
140
|
+
.request-wrap { position: relative; }
|
|
141
|
+
#request-popover {
|
|
142
|
+
position: absolute;
|
|
143
|
+
top: 32px;
|
|
144
|
+
right: 0;
|
|
145
|
+
width: 240px;
|
|
146
|
+
background: var(--panel);
|
|
147
|
+
border: 1px solid var(--border);
|
|
148
|
+
border-radius: 8px;
|
|
149
|
+
box-shadow: var(--shadow);
|
|
150
|
+
padding: 6px;
|
|
151
|
+
z-index: 20;
|
|
152
|
+
}
|
|
153
|
+
.popover-head {
|
|
154
|
+
font-size: 11px;
|
|
155
|
+
color: var(--text-3);
|
|
156
|
+
padding: 5px 8px 7px;
|
|
157
|
+
border-bottom: 1px solid var(--border);
|
|
158
|
+
margin-bottom: 4px;
|
|
159
|
+
white-space: nowrap;
|
|
160
|
+
overflow: hidden;
|
|
161
|
+
text-overflow: ellipsis;
|
|
162
|
+
}
|
|
163
|
+
.popover-row {
|
|
164
|
+
display: flex;
|
|
165
|
+
align-items: center;
|
|
166
|
+
gap: 8px;
|
|
167
|
+
width: 100%;
|
|
168
|
+
border: 0;
|
|
169
|
+
background: transparent;
|
|
170
|
+
border-radius: var(--radius);
|
|
171
|
+
padding: 6px 8px;
|
|
172
|
+
font-size: 12.5px;
|
|
173
|
+
color: var(--text);
|
|
174
|
+
text-align: left;
|
|
175
|
+
}
|
|
176
|
+
.popover-row:hover:not(:disabled) { background: #f2f2f4; }
|
|
177
|
+
.popover-row:disabled { opacity: 0.5; cursor: default; }
|
|
178
|
+
.popover-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
179
|
+
.popover-meta { font-size: 10.5px; color: var(--text-3); flex: none; }
|
|
180
|
+
|
|
181
|
+
.request-card {
|
|
182
|
+
display: flex;
|
|
183
|
+
align-items: center;
|
|
184
|
+
gap: 8px;
|
|
185
|
+
flex-wrap: wrap;
|
|
186
|
+
border: 1px solid var(--border);
|
|
187
|
+
border-radius: 8px;
|
|
188
|
+
padding: 8px 10px;
|
|
189
|
+
margin-bottom: 8px;
|
|
190
|
+
font-size: 12px;
|
|
191
|
+
color: var(--text-2);
|
|
192
|
+
background: #fafafa;
|
|
193
|
+
}
|
|
194
|
+
.request-card.for-me { border-color: #ecd9b8; background: #fdf8ee; color: var(--text); }
|
|
195
|
+
.request-card .request-text { flex: 1; min-width: 120px; }
|
|
196
|
+
.request-card .request-meta { font-size: 10.5px; color: var(--text-3); }
|
|
197
|
+
|
|
138
198
|
.dot { width: 7px; height: 7px; border-radius: 50%; flex: none; }
|
|
139
199
|
.dot.live { background: var(--green); }
|
|
140
200
|
.dot.idle { background: #d4d4d8; }
|
package/src/server.js
CHANGED
|
@@ -89,6 +89,8 @@ export class DesignShareServer {
|
|
|
89
89
|
heads,
|
|
90
90
|
otherBranches: others,
|
|
91
91
|
comments: Object.values(state.comments || {}),
|
|
92
|
+
users: Object.values(state.users || {}),
|
|
93
|
+
requests: Object.values(state.requests || {}),
|
|
92
94
|
previews: this.previews.statuses(),
|
|
93
95
|
prs: this.prs,
|
|
94
96
|
sync: {
|
|
@@ -145,9 +147,27 @@ export class DesignShareServer {
|
|
|
145
147
|
commit,
|
|
146
148
|
text: String(body.text || '').slice(0, 4000),
|
|
147
149
|
});
|
|
150
|
+
this.store.autoResolveRequests(body.branch);
|
|
148
151
|
this.syncSoon();
|
|
149
152
|
return json(res, 200, { ok: true, id });
|
|
150
153
|
}
|
|
154
|
+
if (req.method === 'POST' && p === '/api/requests') {
|
|
155
|
+
const body = await readBody(req);
|
|
156
|
+
const id = this.store.requestReview({
|
|
157
|
+
branch: String(body.branch || ''),
|
|
158
|
+
toUser: String(body.toUser || ''),
|
|
159
|
+
toName: String(body.toName || body.toUser || ''),
|
|
160
|
+
});
|
|
161
|
+
this.syncSoon();
|
|
162
|
+
return json(res, 200, { ok: true, id });
|
|
163
|
+
}
|
|
164
|
+
const reqResolveMatch = p.match(/^\/api\/requests\/([a-z0-9]+)\/resolve$/);
|
|
165
|
+
if (req.method === 'POST' && reqResolveMatch) {
|
|
166
|
+
const body = await readBody(req);
|
|
167
|
+
this.store.resolveRequest(reqResolveMatch[1], body.how);
|
|
168
|
+
this.syncSoon();
|
|
169
|
+
return json(res, 200, { ok: true });
|
|
170
|
+
}
|
|
151
171
|
const replyMatch = p.match(/^\/api\/comments\/([a-z0-9]+)\/replies$/);
|
|
152
172
|
if (req.method === 'POST' && replyMatch) {
|
|
153
173
|
const body = await readBody(req);
|
package/src/state.js
CHANGED
|
@@ -5,7 +5,7 @@ const REF = 'refs/design-share/state';
|
|
|
5
5
|
const REMOTE_REF = 'refs/design-share/remote';
|
|
6
6
|
|
|
7
7
|
export function emptyState() {
|
|
8
|
-
return { version: 1, shares: {}, comments: {} };
|
|
8
|
+
return { version: 1, shares: {}, comments: {}, users: {}, requests: {} };
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
export function newId() {
|
|
@@ -45,6 +45,18 @@ export function mergeStates(a, b) {
|
|
|
45
45
|
out.comments[k] = ca || cb;
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
|
+
const userKeys = new Set([...Object.keys(a.users || {}), ...Object.keys(b.users || {})]);
|
|
49
|
+
for (const k of userKeys) {
|
|
50
|
+
const ua = (a.users || {})[k];
|
|
51
|
+
const ub = (b.users || {})[k];
|
|
52
|
+
out.users[k] = ua && ub ? ((ua.lastSeen || 0) >= (ub.lastSeen || 0) ? ua : ub) : (ua || ub);
|
|
53
|
+
}
|
|
54
|
+
const reqKeys = new Set([...Object.keys(a.requests || {}), ...Object.keys(b.requests || {})]);
|
|
55
|
+
for (const k of reqKeys) {
|
|
56
|
+
const ra = (a.requests || {})[k];
|
|
57
|
+
const rb = (b.requests || {})[k];
|
|
58
|
+
out.requests[k] = ra && rb ? newer(ra, rb) : (ra || rb);
|
|
59
|
+
}
|
|
48
60
|
return out;
|
|
49
61
|
}
|
|
50
62
|
|
|
@@ -93,8 +105,22 @@ export class StateStore {
|
|
|
93
105
|
await git(this.repoRoot, ['update-ref', REF, commit]);
|
|
94
106
|
}
|
|
95
107
|
|
|
108
|
+
// Presence: mark this user as someone who runs design-share so the
|
|
109
|
+
// review request picker only offers reachable people. Throttled so it
|
|
110
|
+
// does not force a push on every sync cycle.
|
|
111
|
+
stampPresence() {
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
const u = (this.state.users || {})[this.identity.slug];
|
|
114
|
+
if (u && now - (u.lastSeen || 0) < 60 * 60 * 1000) return;
|
|
115
|
+
this.mutate((s) => {
|
|
116
|
+
s.users = s.users || {};
|
|
117
|
+
s.users[this.identity.slug] = { user: this.identity.slug, name: this.identity.name, lastSeen: now };
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
96
121
|
// Fetch remote state, merge, persist locally, push if anything moved.
|
|
97
122
|
async sync() {
|
|
123
|
+
this.stampPresence();
|
|
98
124
|
try {
|
|
99
125
|
let remoteState = null;
|
|
100
126
|
if (this.hasRemote) {
|
|
@@ -175,6 +201,59 @@ export class StateStore {
|
|
|
175
201
|
return id;
|
|
176
202
|
}
|
|
177
203
|
|
|
204
|
+
requestReview({ branch, toUser, toName }) {
|
|
205
|
+
const id = newId();
|
|
206
|
+
const now = Date.now();
|
|
207
|
+
this.mutate((s) => {
|
|
208
|
+
s.requests = s.requests || {};
|
|
209
|
+
// One open request per branch per person: refresh an existing one.
|
|
210
|
+
const existing = Object.values(s.requests).find(
|
|
211
|
+
(r) => r.branch === branch && r.toUser === toUser && !r.resolvedAt,
|
|
212
|
+
);
|
|
213
|
+
const key = existing ? existing.id : id;
|
|
214
|
+
s.requests[key] = {
|
|
215
|
+
id: key,
|
|
216
|
+
branch,
|
|
217
|
+
fromUser: this.identity.slug,
|
|
218
|
+
fromName: this.identity.name,
|
|
219
|
+
toUser,
|
|
220
|
+
toName,
|
|
221
|
+
createdAt: existing ? existing.createdAt : now,
|
|
222
|
+
updatedAt: now,
|
|
223
|
+
resolvedAt: null,
|
|
224
|
+
resolvedBy: null,
|
|
225
|
+
};
|
|
226
|
+
});
|
|
227
|
+
return id;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
resolveRequest(requestId, how) {
|
|
231
|
+
const now = Date.now();
|
|
232
|
+
this.mutate((s) => {
|
|
233
|
+
const r = (s.requests || {})[requestId];
|
|
234
|
+
if (!r) return;
|
|
235
|
+
r.resolvedAt = now;
|
|
236
|
+
r.resolvedBy = this.identity.slug;
|
|
237
|
+
r.resolvedHow = how || 'done';
|
|
238
|
+
r.updatedAt = now;
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// A comment from the requested person is the review happening; close the loop.
|
|
243
|
+
autoResolveRequests(branch) {
|
|
244
|
+
const now = Date.now();
|
|
245
|
+
this.mutate((s) => {
|
|
246
|
+
for (const r of Object.values(s.requests || {})) {
|
|
247
|
+
if (r.branch === branch && r.toUser === this.identity.slug && !r.resolvedAt) {
|
|
248
|
+
r.resolvedAt = now;
|
|
249
|
+
r.resolvedBy = this.identity.slug;
|
|
250
|
+
r.resolvedHow = 'commented';
|
|
251
|
+
r.updatedAt = now;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
178
257
|
reply(commentId, text) {
|
|
179
258
|
const now = Date.now();
|
|
180
259
|
this.mutate((s) => {
|