detached c7334b4

fix(chat): limit visitors to the last 50 General messages on the server and make history paging members-only

commit
c7334b4bb7a1d1106680e4c13f3aa00dbdab8556
author
Kit · · 2026-09-29 07:11 UTC
parent
dcb7d15
signature
SSH UnverifiedSHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6Tk The key is unknown
4 files · +61 -48
M src/convex/queries.ts +29 -36
@@ -1,4 +1,5 @@
11 import { query } from './_generated/server';
2+import type { QueryCtx } from './_generated/server';
23 import { authQuery } from './auth';
34 import { v } from 'convex/values';
45 import { toPublicUser, assertRoomAccess } from './aim';
@@ -26,9 +27,22 @@ function computeEffectiveStatus(
2627
2728 // ============ PUBLIC QUERIES (no auth required) ============
2829
29-// Public query: Get messages for a chat room (real-time subscription)
30-// Max 100 messages for public/unauthenticated users
31-const PUBLIC_MESSAGE_LIMIT = 100;
30+// Visitors (not signed in) only see the latest messages of the default room.
31+// Keep in sync with PUBLIC_PREVIEW_MESSAGES in src/lib/validation/message.ts.
32+const PUBLIC_MESSAGE_LIMIT = 50;
33+const MAX_AUTH_MESSAGE_LIMIT = 200;
34+
35+// Default room: "General", or the first group room when it does not exist
36+async function findDefaultRoom(ctx: QueryCtx) {
37+ const rooms = await ctx.db.query('chatRooms').collect();
38+ return (
39+ rooms.find((r) => r.name === 'General' && r.type === 'group') ??
40+ rooms.find((r) => r.type === 'group') ??
41+ null
42+ );
43+}
44+
45+// Public query: latest messages of the default room (real-time subscription)
3246
3347 export const getMessagesPublic = query({
3448 args: {
@@ -36,9 +50,9 @@ export const getMessagesPublic = query({
3650 limit: v.optional(v.number())
3751 },
3852 handler: async (ctx, args) => {
39- // Direct (IM) rooms are never public
40- const room = await ctx.db.get(args.roomId);
41- if (!room || room.type === 'direct') return [];
53+ // Only the default room is readable without an account
54+ const defaultRoom = await findDefaultRoom(ctx);
55+ if (!defaultRoom || defaultRoom._id !== args.roomId) return [];
4256
4357 // Enforce max limit and clamp to a safe positive integer
4458 const rawLimit = args.limit ?? PUBLIC_MESSAGE_LIMIT;
@@ -80,20 +94,20 @@ export const getMessagesPublic = query({
8094 }
8195 });
8296
83-// Public query: Get paginated messages for a chat room (older history)
84-export const getMessagesPublicPage = query({
97+// Authenticated query: older history of a room (pagination is members-only)
98+export const getMessagesPage = authQuery({
8599 args: {
86100 roomId: v.id('chatRooms'),
87101 limit: v.optional(v.number()),
88102 beforeTimestamp: v.optional(v.number())
89103 },
90104 handler: async (ctx, args) => {
91- // Direct (IM) rooms are never public
92105 const room = await ctx.db.get(args.roomId);
93- if (!room || room.type === 'direct') return { messages: [], hasMore: false };
106+ if (!room) return { messages: [], hasMore: false };
107+ await assertRoomAccess(ctx, room, ctx.user._id);
94108
95109 const rawLimit = args.limit ?? 50;
96- const limit = Math.max(1, Math.min(PUBLIC_MESSAGE_LIMIT, Math.floor(rawLimit)));
110+ const limit = Math.max(1, Math.min(MAX_AUTH_MESSAGE_LIMIT, Math.floor(rawLimit)));
97111
98112 let query = ctx.db
99113 .query('messages')
@@ -139,30 +153,10 @@ export const getUsersPublic = query({
139153 export const getDefaultRoomPublic = query({
140154 args: {},
141155 handler: async (ctx) => {
142- const rooms = await ctx.db.query('chatRooms').collect();
143- const defaultRoom = rooms.find((r) => r.name === 'General' && r.type === 'group');
144-
145- if (defaultRoom) {
146- return {
147- id: defaultRoom._id,
148- name: defaultRoom.name,
149- type: defaultRoom.type,
150- createdAt: defaultRoom.createdAt
151- };
152- }
153-
154- // Return first group room if General doesn't exist
155- const firstGroup = rooms.find((r) => r.type === 'group');
156- if (firstGroup) {
157- return {
158- id: firstGroup._id,
159- name: firstGroup.name,
160- type: firstGroup.type,
161- createdAt: firstGroup.createdAt
162- };
163- }
164-
165- return null;
156+ const room = await findDefaultRoom(ctx);
157+ return room
158+ ? { id: room._id, name: room.name, type: room.type, createdAt: room.createdAt }
159+ : null;
166160 }
167161 });
168162
@@ -179,7 +173,6 @@ export const getMessages = authQuery({
179173 if (!room) return [];
180174 await assertRoomAccess(ctx, room, ctx.user._id);
181175
182- const MAX_AUTH_MESSAGE_LIMIT = 200;
183176 const rawLimit = args.limit ?? 100;
184177 const limit = Math.max(1, Math.min(MAX_AUTH_MESSAGE_LIMIT, Math.floor(rawLimit)));
185178 const query = ctx.db
@@ -1,4 +1,5 @@
1 import { query } from './_generated/server';1 import { query } from './_generated/server';
2+import type { QueryCtx } from './_generated/server';
2 import { authQuery } from './auth';3 import { authQuery } from './auth';
3 import { v } from 'convex/values';4 import { v } from 'convex/values';
4 import { toPublicUser, assertRoomAccess } from './aim';5 import { toPublicUser, assertRoomAccess } from './aim';
@@ -26,9 +27,22 @@ function computeEffectiveStatus(
26 27
27 // ============ PUBLIC QUERIES (no auth required) ============28 // ============ PUBLIC QUERIES (no auth required) ============
28 29
29-// Public query: Get messages for a chat room (real-time subscription)30+// Visitors (not signed in) only see the latest messages of the default room.
30-// Max 100 messages for public/unauthenticated users31+// Keep in sync with PUBLIC_PREVIEW_MESSAGES in src/lib/validation/message.ts.
31-const PUBLIC_MESSAGE_LIMIT = 100;32+const PUBLIC_MESSAGE_LIMIT = 50;
33+const MAX_AUTH_MESSAGE_LIMIT = 200;
34+
35+// Default room: "General", or the first group room when it does not exist
36+async function findDefaultRoom(ctx: QueryCtx) {
37+ const rooms = await ctx.db.query('chatRooms').collect();
38+ return (
39+ rooms.find((r) => r.name === 'General' && r.type === 'group') ??
40+ rooms.find((r) => r.type === 'group') ??
41+ null
42+ );
43+}
44+
45+// Public query: latest messages of the default room (real-time subscription)
32 46
33 export const getMessagesPublic = query({47 export const getMessagesPublic = query({
34 args: {48 args: {
@@ -36,9 +50,9 @@ export const getMessagesPublic = query({
36 limit: v.optional(v.number())50 limit: v.optional(v.number())
37 },51 },
38 handler: async (ctx, args) => {52 handler: async (ctx, args) => {
39- // Direct (IM) rooms are never public53+ // Only the default room is readable without an account
40- const room = await ctx.db.get(args.roomId);54+ const defaultRoom = await findDefaultRoom(ctx);
41- if (!room || room.type === 'direct') return [];55+ if (!defaultRoom || defaultRoom._id !== args.roomId) return [];
42 56
43 // Enforce max limit and clamp to a safe positive integer57 // Enforce max limit and clamp to a safe positive integer
44 const rawLimit = args.limit ?? PUBLIC_MESSAGE_LIMIT;58 const rawLimit = args.limit ?? PUBLIC_MESSAGE_LIMIT;
@@ -80,20 +94,20 @@ export const getMessagesPublic = query({
80 }94 }
81 });95 });
82 96
83-// Public query: Get paginated messages for a chat room (older history)97+// Authenticated query: older history of a room (pagination is members-only)
84-export const getMessagesPublicPage = query({98+export const getMessagesPage = authQuery({
85 args: {99 args: {
86 roomId: v.id('chatRooms'),100 roomId: v.id('chatRooms'),
87 limit: v.optional(v.number()),101 limit: v.optional(v.number()),
88 beforeTimestamp: v.optional(v.number())102 beforeTimestamp: v.optional(v.number())
89 },103 },
90 handler: async (ctx, args) => {104 handler: async (ctx, args) => {
91- // Direct (IM) rooms are never public
92 const room = await ctx.db.get(args.roomId);105 const room = await ctx.db.get(args.roomId);
93- if (!room || room.type === 'direct') return { messages: [], hasMore: false };106+ if (!room) return { messages: [], hasMore: false };
107+ await assertRoomAccess(ctx, room, ctx.user._id);
94 108
95 const rawLimit = args.limit ?? 50;109 const rawLimit = args.limit ?? 50;
96- const limit = Math.max(1, Math.min(PUBLIC_MESSAGE_LIMIT, Math.floor(rawLimit)));110+ const limit = Math.max(1, Math.min(MAX_AUTH_MESSAGE_LIMIT, Math.floor(rawLimit)));
97 111
98 let query = ctx.db112 let query = ctx.db
99 .query('messages')113 .query('messages')
@@ -139,30 +153,10 @@ export const getUsersPublic = query({
139 export const getDefaultRoomPublic = query({153 export const getDefaultRoomPublic = query({
140 args: {},154 args: {},
141 handler: async (ctx) => {155 handler: async (ctx) => {
142- const rooms = await ctx.db.query('chatRooms').collect();156+ const room = await findDefaultRoom(ctx);
143- const defaultRoom = rooms.find((r) => r.name === 'General' && r.type === 'group');157+ return room
144-158+ ? { id: room._id, name: room.name, type: room.type, createdAt: room.createdAt }
145- if (defaultRoom) {159+ : null;
146- return {
147- id: defaultRoom._id,
148- name: defaultRoom.name,
149- type: defaultRoom.type,
150- createdAt: defaultRoom.createdAt
151- };
152- }
153-
154- // Return first group room if General doesn't exist
155- const firstGroup = rooms.find((r) => r.type === 'group');
156- if (firstGroup) {
157- return {
158- id: firstGroup._id,
159- name: firstGroup.name,
160- type: firstGroup.type,
161- createdAt: firstGroup.createdAt
162- };
163- }
164-
165- return null;
166 }160 }
167 });161 });
168 162
@@ -179,7 +173,6 @@ export const getMessages = authQuery({
179 if (!room) return [];173 if (!room) return [];
180 await assertRoomAccess(ctx, room, ctx.user._id);174 await assertRoomAccess(ctx, room, ctx.user._id);
181 175
182- const MAX_AUTH_MESSAGE_LIMIT = 200;
183 const rawLimit = args.limit ?? 100;176 const rawLimit = args.limit ?? 100;
184 const limit = Math.max(1, Math.min(MAX_AUTH_MESSAGE_LIMIT, Math.floor(rawLimit)));177 const limit = Math.max(1, Math.min(MAX_AUTH_MESSAGE_LIMIT, Math.floor(rawLimit)));
185 const query = ctx.db178 const query = ctx.db
M src/lib/components/chat-room.svelte +23 -10
@@ -13,7 +13,8 @@
1313 import Tooltip from './ui/tooltip.svelte';
1414 import LoadingDots from './ui/loading-dots.svelte';
1515 import { desktop } from '$lib/states/desktop.svelte';
16- import { useAimClient } from '$lib/aim/client';
16+ import { authQuery, tokenHash, useAimClient } from '$lib/aim/client';
17+ import { PUBLIC_PREVIEW_MESSAGES } from '$lib/validation/message';
1718 import { STATUS_LABELS_FR, type AimStatus } from '$lib/types/aim';
1819 import FormattedMessage from './formatted-message.svelte';
1920 import TextFormattingToolbar from './text-formatting-toolbar.svelte';
@@ -62,10 +63,17 @@
6263 (requestedRoomId ?? defaultRoomQuery?.data?.id) as Id<'chatRooms'> | undefined
6364 );
6465
65- // Subscribe to messages - skipped until the room ID is available
66- const messagesQuery = isClient
67- ? useQuery(convexApi.queries.getMessagesPublic, () => (roomId ? { roomId } : 'skip'))
66+ // Subscribe to messages - skipped until the room ID is available.
67+ // Members get the full live feed; visitors only the public preview of the default room.
68+ const memberMessagesQuery = authQuery(convexApi.queries.getMessages, () =>
69+ roomId ? { roomId } : 'skip'
70+ );
71+ const publicMessagesQuery = isClient
72+ ? useQuery(convexApi.queries.getMessagesPublic, () =>
73+ roomId && !tokenHash() ? { roomId } : 'skip'
74+ )
6875 : null;
76+ const messagesQuery = $derived(tokenHash() ? memberMessagesQuery : publicMessagesQuery);
6977
7078 const convexClient = isClient ? useConvexClient() : null;
7179
@@ -270,11 +278,15 @@
270278 // Visible messages (limit for non-logged-in users)
271279 const visibleMessages = $derived.by<EnrichedMessage[]>(() => {
272280 const isLoggedIn = Boolean(currentUser);
273- return isLoggedIn ? allMessages : allMessages.slice(Math.max(0, allMessages.length - 50));
281+ return isLoggedIn
282+ ? allMessages
283+ : allMessages.slice(Math.max(0, allMessages.length - PUBLIC_PREVIEW_MESSAGES));
274284 });
275285
276286 // Show registration prompt for non-logged-in users
277- const showRegistrationPrompt = $derived(!currentUser && allMessages.length > 50);
287+ const showRegistrationPrompt = $derived(
288+ !currentUser && allMessages.length >= PUBLIC_PREVIEW_MESSAGES
289+ );
278290
279291 // Character counter
280292 const CHAR_WARNING_THRESHOLD = Math.floor(MAX_MESSAGE_LENGTH * 0.8);
@@ -960,18 +972,19 @@
960972 hasMoreMessages &&
961973 roomId &&
962974 chatArea &&
963- currentUser &&
964975 convexClient
965976 ) {
977+ const hash = tokenHash();
978+ if (!hash) return;
966979 isLoadingMore = true;
967980 const prevScrollHeight = chatArea.scrollHeight;
968981 const prevScrollTop = chatArea.scrollTop;
969982
970983 try {
971- const fetchLimit = currentUser ? 100 : 50;
972- const data = await convexClient.query(convexApi.queries.getMessagesPublicPage, {
984+ const data = await convexClient.query(convexApi.queries.getMessagesPage, {
985+ tokenHash: hash,
973986 roomId,
974- limit: fetchLimit,
987+ limit: 100,
975988 beforeTimestamp: oldestMessageTimestamp ?? undefined
976989 });
977990
@@ -13,7 +13,8 @@
13 import Tooltip from './ui/tooltip.svelte';13 import Tooltip from './ui/tooltip.svelte';
14 import LoadingDots from './ui/loading-dots.svelte';14 import LoadingDots from './ui/loading-dots.svelte';
15 import { desktop } from '$lib/states/desktop.svelte';15 import { desktop } from '$lib/states/desktop.svelte';
16- import { useAimClient } from '$lib/aim/client';16+ import { authQuery, tokenHash, useAimClient } from '$lib/aim/client';
17+ import { PUBLIC_PREVIEW_MESSAGES } from '$lib/validation/message';
17 import { STATUS_LABELS_FR, type AimStatus } from '$lib/types/aim';18 import { STATUS_LABELS_FR, type AimStatus } from '$lib/types/aim';
18 import FormattedMessage from './formatted-message.svelte';19 import FormattedMessage from './formatted-message.svelte';
19 import TextFormattingToolbar from './text-formatting-toolbar.svelte';20 import TextFormattingToolbar from './text-formatting-toolbar.svelte';
@@ -62,10 +63,17 @@
62 (requestedRoomId ?? defaultRoomQuery?.data?.id) as Id<'chatRooms'> | undefined63 (requestedRoomId ?? defaultRoomQuery?.data?.id) as Id<'chatRooms'> | undefined
63 );64 );
64 65
65- // Subscribe to messages - skipped until the room ID is available66+ // Subscribe to messages - skipped until the room ID is available.
66- const messagesQuery = isClient67+ // Members get the full live feed; visitors only the public preview of the default room.
67- ? useQuery(convexApi.queries.getMessagesPublic, () => (roomId ? { roomId } : 'skip'))68+ const memberMessagesQuery = authQuery(convexApi.queries.getMessages, () =>
69+ roomId ? { roomId } : 'skip'
70+ );
71+ const publicMessagesQuery = isClient
72+ ? useQuery(convexApi.queries.getMessagesPublic, () =>
73+ roomId && !tokenHash() ? { roomId } : 'skip'
74+ )
68 : null;75 : null;
76+ const messagesQuery = $derived(tokenHash() ? memberMessagesQuery : publicMessagesQuery);
69 77
70 const convexClient = isClient ? useConvexClient() : null;78 const convexClient = isClient ? useConvexClient() : null;
71 79
@@ -270,11 +278,15 @@
270 // Visible messages (limit for non-logged-in users)278 // Visible messages (limit for non-logged-in users)
271 const visibleMessages = $derived.by<EnrichedMessage[]>(() => {279 const visibleMessages = $derived.by<EnrichedMessage[]>(() => {
272 const isLoggedIn = Boolean(currentUser);280 const isLoggedIn = Boolean(currentUser);
273- return isLoggedIn ? allMessages : allMessages.slice(Math.max(0, allMessages.length - 50));281+ return isLoggedIn
282+ ? allMessages
283+ : allMessages.slice(Math.max(0, allMessages.length - PUBLIC_PREVIEW_MESSAGES));
274 });284 });
275 285
276 // Show registration prompt for non-logged-in users286 // Show registration prompt for non-logged-in users
277- const showRegistrationPrompt = $derived(!currentUser && allMessages.length > 50);287+ const showRegistrationPrompt = $derived(
288+ !currentUser && allMessages.length >= PUBLIC_PREVIEW_MESSAGES
289+ );
278 290
279 // Character counter291 // Character counter
280 const CHAR_WARNING_THRESHOLD = Math.floor(MAX_MESSAGE_LENGTH * 0.8);292 const CHAR_WARNING_THRESHOLD = Math.floor(MAX_MESSAGE_LENGTH * 0.8);
@@ -960,18 +972,19 @@
960 hasMoreMessages &&972 hasMoreMessages &&
961 roomId &&973 roomId &&
962 chatArea &&974 chatArea &&
963- currentUser &&
964 convexClient975 convexClient
965 ) {976 ) {
977+ const hash = tokenHash();
978+ if (!hash) return;
966 isLoadingMore = true;979 isLoadingMore = true;
967 const prevScrollHeight = chatArea.scrollHeight;980 const prevScrollHeight = chatArea.scrollHeight;
968 const prevScrollTop = chatArea.scrollTop;981 const prevScrollTop = chatArea.scrollTop;
969 982
970 try {983 try {
971- const fetchLimit = currentUser ? 100 : 50;984+ const data = await convexClient.query(convexApi.queries.getMessagesPage, {
972- const data = await convexClient.query(convexApi.queries.getMessagesPublicPage, {985+ tokenHash: hash,
973 roomId,986 roomId,
974- limit: fetchLimit,987+ limit: 100,
975 beforeTimestamp: oldestMessageTimestamp ?? undefined988 beforeTimestamp: oldestMessageTimestamp ?? undefined
976 });989 });
977 990
M src/lib/validation/message.ts +3 -0
@@ -3,6 +3,9 @@ import { z } from 'zod';
33 // Message constraints
44 export const MAX_MESSAGE_LENGTH = 500;
55 export const MIN_MESSAGE_LENGTH = 1;
6+// Visitors see this many latest messages of the default room before the registration prompt.
7+// Keep in sync with PUBLIC_MESSAGE_LIMIT in src/convex/queries.ts.
8+export const PUBLIC_PREVIEW_MESSAGES = 50;
69
710 // Message content schema
811 export const messageContentSchema = z
@@ -3,6 +3,9 @@ import { z } from 'zod';
3 // Message constraints3 // Message constraints
4 export const MAX_MESSAGE_LENGTH = 500;4 export const MAX_MESSAGE_LENGTH = 500;
5 export const MIN_MESSAGE_LENGTH = 1;5 export const MIN_MESSAGE_LENGTH = 1;
6+// Visitors see this many latest messages of the default room before the registration prompt.
7+// Keep in sync with PUBLIC_MESSAGE_LIMIT in src/convex/queries.ts.
8+export const PUBLIC_PREVIEW_MESSAGES = 50;
6 9
7 // Message content schema10 // Message content schema
8 export const messageContentSchema = z11 export const messageContentSchema = z
M src/routes/api/chat/messages/+server.ts +6 -2
@@ -5,7 +5,7 @@ import type { SendMessageResponse, GetMessagesResponse } from '$lib/types/payloa
55 import { error, isHttpError } from '@sveltejs/kit';
66 import { createLogger } from '$lib/utils/logger.server';
77 import { sanitizeStyleData } from '$lib/validation/text-formatting';
8-import { sendMessageSchema } from '$lib/validation/message';
8+import { sendMessageSchema, PUBLIC_PREVIEW_MESSAGES } from '$lib/validation/message';
99 import type { RequestHandler } from './$types';
1010
1111 const log = createLogger('chat-server');
@@ -70,6 +70,10 @@ export const GET: RequestHandler = async ({ url, locals }) => {
7070 throw error(403, 'Not a member of this conversation');
7171 }
7272 }
73+ // Visitors only get a preview of the default room
74+ if (isPublic && roomId !== getDefaultChatRoomId()) {
75+ throw error(403, 'Sign in to read this room');
76+ }
7377
7478 log.debug('Fetching messages from Convex', {
7579 beforeTimestamp,
@@ -90,7 +94,7 @@ export const GET: RequestHandler = async ({ url, locals }) => {
9094 });
9195 }
9296
93- const fetchLimit = 100;
97+ const fetchLimit = isPublic ? PUBLIC_PREVIEW_MESSAGES : 100;
9498
9599 const result = await messages.getByRoom(
96100 roomId,
@@ -5,7 +5,7 @@ import type { SendMessageResponse, GetMessagesResponse } from '$lib/types/payloa
5 import { error, isHttpError } from '@sveltejs/kit';5 import { error, isHttpError } from '@sveltejs/kit';
6 import { createLogger } from '$lib/utils/logger.server';6 import { createLogger } from '$lib/utils/logger.server';
7 import { sanitizeStyleData } from '$lib/validation/text-formatting';7 import { sanitizeStyleData } from '$lib/validation/text-formatting';
8-import { sendMessageSchema } from '$lib/validation/message';8+import { sendMessageSchema, PUBLIC_PREVIEW_MESSAGES } from '$lib/validation/message';
9 import type { RequestHandler } from './$types';9 import type { RequestHandler } from './$types';
10 10
11 const log = createLogger('chat-server');11 const log = createLogger('chat-server');
@@ -70,6 +70,10 @@ export const GET: RequestHandler = async ({ url, locals }) => {
70 throw error(403, 'Not a member of this conversation');70 throw error(403, 'Not a member of this conversation');
71 }71 }
72 }72 }
73+ // Visitors only get a preview of the default room
74+ if (isPublic && roomId !== getDefaultChatRoomId()) {
75+ throw error(403, 'Sign in to read this room');
76+ }
73 77
74 log.debug('Fetching messages from Convex', {78 log.debug('Fetching messages from Convex', {
75 beforeTimestamp,79 beforeTimestamp,
@@ -90,7 +94,7 @@ export const GET: RequestHandler = async ({ url, locals }) => {
90 });94 });
91 }95 }
92 96
93- const fetchLimit = 100;97+ const fetchLimit = isPublic ? PUBLIC_PREVIEW_MESSAGES : 100;
94 98
95 const result = await messages.getByRoom(99 const result = await messages.getByRoom(
96 roomId,100 roomId,

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 8

↑↓ moveenter openesc close