Skip to content

Commit 7f59ade

Browse files
Drive: long-press to select on mobile; collision-proof Quick-Upload codes
Mobile has no right-click or Ctrl-click, so there was no way to enter selection mode. Now a long-press (touch) toggles an item's selection, and once a selection exists a plain tap toggles too; the synthesized contextmenu/click a long-press fires are de-duped and suppressed, and mouse behaviour stays desktop-standard (Ctrl/Shift/right-click unchanged). Quick-Upload codes are global by necessity — the public /q link routes an upload to its owner by the code alone — so two users can't share one. Blank codes now come from a collision-checked generator (never a 500), and the create is guarded against the check-then-insert race, returning a clean 409 'already in use' either way.
1 parent 36248d1 commit 7f59ade

4 files changed

Lines changed: 145 additions & 37 deletions

File tree

apps/api/src/routes/account.ts

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@
33
* permanently delete your account. Both are scoped strictly to the requesting user.
44
*/
55
import type { FastifyPluginAsync } from 'fastify';
6-
import { passwordConfirmSchema, createQuickCodeSchema, randomCode } from '@opencoperlock/shared';
6+
import { passwordConfirmSchema, createQuickCodeSchema } from '@opencoperlock/shared';
77
import { prisma } from '../db.js';
88
import { parseOr400 } from '../lib/validate.js';
99
import { verifyPassword, hashPassword } from '../services/password.js';
1010
import { remainingRecoveryCodes } from '../services/recovery.js';
1111
import { toPublicFile, toPublicFolder, toPublicShare, toPublicUser, toPublicQuickCode } from '../lib/serialize.js';
1212
import { clearSessionCookie } from '../lib/cookies.js';
1313
import { audit } from '../services/audit.js';
14+
import { generateUniqueQuickCode, isCodeTakenError } from '../services/quickCode.js';
1415

1516
export const accountRoutes: FastifyPluginAsync = async (app) => {
1617
app.addHook('preHandler', app.requireAuth);
@@ -42,25 +43,31 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
4243
}
4344
}
4445

45-
const codeValue = body.code ?? randomCode();
46+
const codeValue = body.code ?? (await generateUniqueQuickCode());
4647
if (body.code) {
4748
const clash = await prisma.quickUploadCode.findUnique({ where: { code: codeValue } });
4849
if (clash) return reply.code(409).send({ error: 'This code is already in use' });
4950
}
5051

51-
const code = await prisma.quickUploadCode.create({
52-
data: {
53-
code: codeValue,
54-
createdById: req.user!.id,
55-
targetFolderId: body.targetFolderId ?? null,
56-
maxBytes: body.maxBytes == null ? null : BigInt(body.maxBytes),
57-
expiresAt: body.expiresAt ?? null,
58-
usageLimit: body.usageLimit ?? null,
59-
passwordHash: body.password ? await hashPassword(body.password) : null,
60-
},
61-
});
62-
await audit(req, 'account.quickcode.create', { target: code.id });
63-
return reply.code(201).send({ code: toPublicQuickCode(code) });
52+
try {
53+
const code = await prisma.quickUploadCode.create({
54+
data: {
55+
code: codeValue,
56+
createdById: req.user!.id,
57+
targetFolderId: body.targetFolderId ?? null,
58+
maxBytes: body.maxBytes == null ? null : BigInt(body.maxBytes),
59+
expiresAt: body.expiresAt ?? null,
60+
usageLimit: body.usageLimit ?? null,
61+
passwordHash: body.password ? await hashPassword(body.password) : null,
62+
},
63+
});
64+
await audit(req, 'account.quickcode.create', { target: code.id });
65+
return reply.code(201).send({ code: toPublicQuickCode(code) });
66+
} catch (err) {
67+
// Lost a race for the same custom code between the check and the insert.
68+
if (isCodeTakenError(err)) return reply.code(409).send({ error: 'This code is already in use' });
69+
throw err;
70+
}
6471
});
6572

6673
app.delete('/quick-codes/:id', async (req, reply) => {

apps/api/src/routes/admin.ts

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@ import type { FastifyPluginAsync } from 'fastify';
22
import {
33
createQuickCodeSchema,
44
createUserSchema,
5-
randomCode,
65
updateSettingsSchema,
76
updateUserSchema,
87
} from '@opencoperlock/shared';
98
import { prisma } from '../db.js';
109
import { parseOr400 } from '../lib/validate.js';
1110
import { hashPassword } from '../services/password.js';
11+
import { generateUniqueQuickCode, isCodeTakenError } from '../services/quickCode.js';
1212
import { toPublicQuickCode, toPublicUser } from '../lib/serialize.js';
1313
import { getGlobalCapBytes, getGlobalUsedBytes } from '../services/quota.js';
1414
import { ensureFastUploadFolder } from '../services/systemFolders.js';
@@ -244,26 +244,31 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
244244
}
245245

246246
// Use the admin's chosen memorable code (already uppercased by the schema), or a
247-
// random one. Reject a custom code that's already taken.
248-
const codeValue = body.code ?? randomCode();
247+
// collision-free generated one. Reject a custom code that's already taken.
248+
const codeValue = body.code ?? (await generateUniqueQuickCode());
249249
if (body.code) {
250250
const clash = await prisma.quickUploadCode.findUnique({ where: { code: codeValue } });
251251
if (clash) return reply.code(409).send({ error: 'This code is already in use' });
252252
}
253253

254-
const code = await prisma.quickUploadCode.create({
255-
data: {
256-
code: codeValue,
257-
createdById: req.user!.id,
258-
targetFolderId: body.targetFolderId ?? null,
259-
maxBytes: body.maxBytes == null ? null : BigInt(body.maxBytes),
260-
expiresAt: body.expiresAt ?? null,
261-
usageLimit: body.usageLimit ?? null,
262-
passwordHash: body.password ? await hashPassword(body.password) : null,
263-
},
264-
});
265-
await audit(req, 'admin.quickcode.create', { target: code.id });
266-
return reply.code(201).send({ code: toPublicQuickCode(code) });
254+
try {
255+
const code = await prisma.quickUploadCode.create({
256+
data: {
257+
code: codeValue,
258+
createdById: req.user!.id,
259+
targetFolderId: body.targetFolderId ?? null,
260+
maxBytes: body.maxBytes == null ? null : BigInt(body.maxBytes),
261+
expiresAt: body.expiresAt ?? null,
262+
usageLimit: body.usageLimit ?? null,
263+
passwordHash: body.password ? await hashPassword(body.password) : null,
264+
},
265+
});
266+
await audit(req, 'admin.quickcode.create', { target: code.id });
267+
return reply.code(201).send({ code: toPublicQuickCode(code) });
268+
} catch (err) {
269+
if (isCodeTakenError(err)) return reply.code(409).send({ error: 'This code is already in use' });
270+
throw err;
271+
}
267272
});
268273

269274
app.delete('/quick-codes/:id', async (req, reply) => {

apps/api/src/services/quickCode.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* Quick-Upload codes must be globally unique: the public /q endpoint routes an anonymous upload
3+
* to the right user by the code alone, so two users can never share one. A user-chosen code that
4+
* is already taken is rejected (caller returns 409); a blank one gets a generated, collision-free
5+
* code from here.
6+
*/
7+
import { randomCode } from '@opencoperlock/shared';
8+
import { prisma } from '../db.js';
9+
10+
/** A random code guaranteed not to collide with an existing one (widens on the unlikely clash). */
11+
export async function generateUniqueQuickCode(): Promise<string> {
12+
for (let i = 0; i < 8; i += 1) {
13+
const code = randomCode();
14+
const clash = await prisma.quickUploadCode.findUnique({ where: { code } });
15+
if (!clash) return code;
16+
}
17+
// Practically unreachable; a longer code makes a collision astronomically unlikely.
18+
return randomCode(14);
19+
}
20+
21+
/** True when a Prisma error is the unique-constraint violation on the code column. */
22+
export function isCodeTakenError(err: unknown): boolean {
23+
return typeof err === 'object' && err !== null && (err as { code?: string }).code === 'P2002';
24+
}

apps/web/src/app/(app)/drive/page.tsx

Lines changed: 78 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -604,14 +604,69 @@ export default function EspacesPage() {
604604
}
605605

606606
// ── Selection ───────────────────────────────────────────────────────────────
607+
function toggleKey(key: string) {
608+
setSelected((prev) => {
609+
const next = new Set(prev);
610+
if (next.has(key)) next.delete(key);
611+
else next.add(key);
612+
return next;
613+
});
614+
}
615+
616+
// ── Touch long-press selection ───────────────────────────────────────────────
617+
// Mobile has no right-click / Ctrl-click, so a long-press toggles selection (and, once a
618+
// selection exists, a plain tap toggles too). `pressHandledAt` both de-dupes the two events
619+
// a long-press can fire (timer + synthesized contextmenu) and suppresses the click that
620+
// follows it. `pointerType` lets the mouse handlers stay desktop-standard.
621+
const pressTimer = useRef<number | null>(null);
622+
const pressStart = useRef<{ x: number; y: number } | null>(null);
623+
const pointerType = useRef<string>('mouse');
624+
const pressHandledAt = useRef(0);
625+
const clearPress = () => {
626+
if (pressTimer.current) {
627+
clearTimeout(pressTimer.current);
628+
pressTimer.current = null;
629+
}
630+
};
631+
function pressSelect(key: string) {
632+
if (Date.now() - pressHandledAt.current < 700) return; // already handled by the sibling event
633+
pressHandledAt.current = Date.now();
634+
navigator.vibrate?.(15);
635+
toggleKey(key);
636+
setAnchorKey(key);
637+
}
638+
function pressProps(entry: Entry) {
639+
return {
640+
onPointerDown: (e: React.PointerEvent) => {
641+
pointerType.current = e.pointerType;
642+
if (e.pointerType !== 'touch') return;
643+
pressStart.current = { x: e.clientX, y: e.clientY };
644+
clearPress();
645+
pressTimer.current = window.setTimeout(() => {
646+
clearPress();
647+
pressSelect(entry.key);
648+
}, 450);
649+
},
650+
onPointerMove: (e: React.PointerEvent) => {
651+
if (!pressStart.current || !pressTimer.current) return;
652+
if (Math.abs(e.clientX - pressStart.current.x) > 10 || Math.abs(e.clientY - pressStart.current.y) > 10) clearPress();
653+
},
654+
onPointerUp: clearPress,
655+
onPointerCancel: clearPress,
656+
onPointerLeave: clearPress,
657+
};
658+
}
659+
607660
function onEntryClick(e: React.MouseEvent, entry: Entry) {
661+
if (Date.now() - pressHandledAt.current < 700) return; // click synthesized right after a long-press
662+
const touch = pointerType.current === 'touch';
663+
if (touch && selected.size > 0) {
664+
toggleKey(entry.key);
665+
setAnchorKey(entry.key);
666+
return;
667+
}
608668
if (e.metaKey || e.ctrlKey) {
609-
setSelected((prev) => {
610-
const next = new Set(prev);
611-
if (next.has(entry.key)) next.delete(entry.key);
612-
else next.add(entry.key);
613-
return next;
614-
});
669+
toggleKey(entry.key);
615670
setAnchorKey(entry.key);
616671
} else if (e.shiftKey) {
617672
const keys = entries.map((x) => x.key);
@@ -629,6 +684,12 @@ export default function EspacesPage() {
629684
}
630685
}
631686
function onNameClick(e: React.MouseEvent, entry: Entry) {
687+
if (Date.now() - pressHandledAt.current < 700) {
688+
e.stopPropagation();
689+
return;
690+
}
691+
// In touch selection mode, tapping the name toggles too — let the row handle it.
692+
if (pointerType.current === 'touch' && selected.size > 0) return;
632693
if (e.metaKey || e.ctrlKey || e.shiftKey) return; // let the row handle selection
633694
e.stopPropagation();
634695
openEntry(entry);
@@ -1043,6 +1104,12 @@ export default function EspacesPage() {
10431104
if (items.length > 0) setCtxMenu({ x: ev.clientX, y: ev.clientY, items });
10441105
}
10451106
function onEntryContext(ev: React.MouseEvent, e: Entry) {
1107+
// On touch, a long-press fires `contextmenu` — treat it as select, not a desktop menu.
1108+
if (pointerType.current === 'touch') {
1109+
ev.preventDefault();
1110+
pressSelect(e.key);
1111+
return;
1112+
}
10461113
if (selected.has(e.key) && selected.size > 1) {
10471114
openCtx(ev, bulkMenuItems());
10481115
return;
@@ -1342,6 +1409,7 @@ export default function EspacesPage() {
13421409
onClick={(ev) => onEntryClick(ev, e)}
13431410
onDoubleClick={() => openEntry(e)}
13441411
onContextMenu={(ev) => onEntryContext(ev, e)}
1412+
press={pressProps(e)}
13451413
/>
13461414
))}
13471415
</div>
@@ -1368,6 +1436,7 @@ export default function EspacesPage() {
13681436
onClick={(ev) => onEntryClick(ev, e)}
13691437
onDoubleClick={() => openEntry(e)}
13701438
onContextMenu={(ev) => onEntryContext(ev, e)}
1439+
{...pressProps(e)}
13711440
className={`row cursor-default ${selected.has(e.key) ? 'bg-accent/[0.08] ring-1 ring-inset ring-accent/40' : ''}`}
13721441
>
13731442
<button className="flex min-w-0 flex-1 items-center gap-3 text-left" onClick={(ev) => onNameClick(ev, e)}>
@@ -1560,6 +1629,7 @@ function GridCard({
15601629
onClick,
15611630
onDoubleClick,
15621631
onContextMenu,
1632+
press,
15631633
}: {
15641634
iconNode: React.ReactNode;
15651635
name: string;
@@ -1568,9 +1638,11 @@ function GridCard({
15681638
onClick?: (e: React.MouseEvent) => void;
15691639
onDoubleClick?: () => void;
15701640
onContextMenu?: (e: React.MouseEvent) => void;
1641+
press?: React.DOMAttributes<HTMLDivElement>;
15711642
}) {
15721643
return (
15731644
<div
1645+
{...press}
15741646
onClick={onClick}
15751647
onDoubleClick={onDoubleClick}
15761648
onContextMenu={onContextMenu}

0 commit comments

Comments
 (0)