detached da04fc0

feat(aim): classic AIM yellow smileys with picker in rooms and IMs

Typed codes (:-) ;-) :-P O:-) ...) render as the original AIM Yellow
Smiley GIFs from static/smileys (local-only, gitignored), falling back to
Unicode emoji alt text. The formatting toolbar gains an AIM-style smiley
picker that inserts the code.
commit
da04fc0c6c7228747df635b801c574df2c488d13
author
Kit · · 2026-09-27 13:53 UTC
parent
a2d83fb
signature
SSH UnverifiedSHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6Tk The key is unknown
7 files · +192 -4
M .gitignore +3 -0
@@ -34,3 +34,6 @@ docs/plans/
3434 # Local audio samples (copyrighted, never committed)
3535 static/sounds/*
3636 !static/sounds/README.md
37+# AIM smiley GIFs (AOL artwork, never committed)
38+static/smileys/*
39+!static/smileys/README.md
@@ -34,3 +34,6 @@ docs/plans/
34 # Local audio samples (copyrighted, never committed)34 # Local audio samples (copyrighted, never committed)
35 static/sounds/*35 static/sounds/*
36 !static/sounds/README.md36 !static/sounds/README.md
37+# AIM smiley GIFs (AOL artwork, never committed)
38+static/smileys/*
39+!static/smileys/README.md
A src/lib/aim/smileys.ts +74 -0
@@ -0,0 +1,74 @@
1+/**
2+ * AIM "Yellow Smiley" set (AIM 4.x/5.x). Typed codes become the original
3+ * 19×19 GIFs from /static/smileys (local-only, AOL artwork). When a GIF is
4+ * missing the browser shows the img alt text, which is the Unicode emoji.
5+ */
6+export interface Smiley {
7+ file: string;
8+ codes: string[];
9+ label: string;
10+ emoji: string;
11+}
12+
13+export const SMILEYS: Smiley[] = [
14+ { file: '01', codes: [':-)', ':)'], label: 'Content', emoji: '🙂' },
15+ { file: '02', codes: [';-)', ';)'], label: 'Clin d’œil', emoji: '😉' },
16+ { file: '03', codes: [':-(', ':('], label: 'Triste', emoji: '🙁' },
17+ { file: '04', codes: [':-P', ':-p', ':P', ':p'], label: 'Langue', emoji: '😛' },
18+ { file: '05', codes: ['=-O', '=-o'], label: 'Surpris', emoji: '😮' },
19+ { file: '06', codes: [':-*'], label: 'Bisou', emoji: '😘' },
20+ { file: '07', codes: ['>:o'], label: 'Crie', emoji: '😠' },
21+ { file: '08', codes: [':-D', ':D'], label: 'Rire', emoji: '😁' },
22+ { file: '09', codes: [':-$'], label: 'Radin', emoji: '🤑' },
23+ { file: '10', codes: [':-!'], label: 'Gaffe', emoji: '😬' },
24+ { file: '11', codes: [':-['], label: 'Gêné', emoji: '😳' },
25+ { file: '12', codes: ['O:-)'], label: 'Innocent', emoji: '😇' },
26+ { file: '13', codes: [':-\\'], label: 'Indécis', emoji: '😕' },
27+ { file: '14', codes: [":'("], label: 'Pleure', emoji: '😢' },
28+ { file: '15', codes: [':-X', ':-x'], label: 'Bouche cousue', emoji: '🤐' },
29+ { file: '16', codes: ['8-)'], label: 'Cool', emoji: '😎' }
30+];
31+
32+const escape = (text: string) =>
33+ text
34+ .replace(/&/g, '&')
35+ .replace(/</g, '&lt;')
36+ .replace(/>/g, '&gt;')
37+ .replace(/"/g, '&quot;')
38+ .replace(/'/g, '&#039;');
39+
40+// Codes are matched on already-escaped HTML, so match their escaped form.
41+const byEscapedCode = new Map<string, Smiley>();
42+for (const smiley of SMILEYS)
43+ for (const code of smiley.codes) byEscapedCode.set(escape(code), smiley);
44+
45+const pattern = new RegExp(
46+ // Longest first so "O:-)" wins over ":-)"; codes must not touch letters/digits.
47+ `(^|[^\\w])(${[...byEscapedCode.keys()]
48+ .sort((a, b) => b.length - a.length)
49+ .map((c) => c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
50+ .join('|')})(?=$|[^\\w])`,
51+ 'g'
52+);
53+
54+export function smileyImg(smiley: Smiley) {
55+ return `<img class="aim-smiley" src="/smileys/${smiley.file}.gif" alt="${smiley.emoji}" title="${escape(smiley.codes[0])}" width="19" height="19" draggable="false">`;
56+}
57+
58+/**
59+ * Replace smiley codes in escaped HTML text. Only text between tags is
60+ * touched so attributes (style="color: …") are never rewritten.
61+ */
62+export function replaceSmileys(html: string): string {
63+ return html
64+ .split(/(<[^>]*>)/)
65+ .map((part) =>
66+ part.startsWith('<')
67+ ? part
68+ : part.replace(pattern, (_, before: string, code: string) => {
69+ const smiley = byEscapedCode.get(code);
70+ return smiley ? before + smileyImg(smiley) : _;
71+ })
72+ )
73+ .join('');
74+}
@@ -0,0 +1,74 @@
1+/**
2+ * AIM "Yellow Smiley" set (AIM 4.x/5.x). Typed codes become the original
3+ * 19×19 GIFs from /static/smileys (local-only, AOL artwork). When a GIF is
4+ * missing the browser shows the img alt text, which is the Unicode emoji.
5+ */
6+export interface Smiley {
7+ file: string;
8+ codes: string[];
9+ label: string;
10+ emoji: string;
11+}
12+
13+export const SMILEYS: Smiley[] = [
14+ { file: '01', codes: [':-)', ':)'], label: 'Content', emoji: '🙂' },
15+ { file: '02', codes: [';-)', ';)'], label: 'Clin d’œil', emoji: '😉' },
16+ { file: '03', codes: [':-(', ':('], label: 'Triste', emoji: '🙁' },
17+ { file: '04', codes: [':-P', ':-p', ':P', ':p'], label: 'Langue', emoji: '😛' },
18+ { file: '05', codes: ['=-O', '=-o'], label: 'Surpris', emoji: '😮' },
19+ { file: '06', codes: [':-*'], label: 'Bisou', emoji: '😘' },
20+ { file: '07', codes: ['>:o'], label: 'Crie', emoji: '😠' },
21+ { file: '08', codes: [':-D', ':D'], label: 'Rire', emoji: '😁' },
22+ { file: '09', codes: [':-$'], label: 'Radin', emoji: '🤑' },
23+ { file: '10', codes: [':-!'], label: 'Gaffe', emoji: '😬' },
24+ { file: '11', codes: [':-['], label: 'Gêné', emoji: '😳' },
25+ { file: '12', codes: ['O:-)'], label: 'Innocent', emoji: '😇' },
26+ { file: '13', codes: [':-\\'], label: 'Indécis', emoji: '😕' },
27+ { file: '14', codes: [":'("], label: 'Pleure', emoji: '😢' },
28+ { file: '15', codes: [':-X', ':-x'], label: 'Bouche cousue', emoji: '🤐' },
29+ { file: '16', codes: ['8-)'], label: 'Cool', emoji: '😎' }
30+];
31+
32+const escape = (text: string) =>
33+ text
34+ .replace(/&/g, '&amp;')
35+ .replace(/</g, '&lt;')
36+ .replace(/>/g, '&gt;')
37+ .replace(/"/g, '&quot;')
38+ .replace(/'/g, '&#039;');
39+
40+// Codes are matched on already-escaped HTML, so match their escaped form.
41+const byEscapedCode = new Map<string, Smiley>();
42+for (const smiley of SMILEYS)
43+ for (const code of smiley.codes) byEscapedCode.set(escape(code), smiley);
44+
45+const pattern = new RegExp(
46+ // Longest first so "O:-)" wins over ":-)"; codes must not touch letters/digits.
47+ `(^|[^\\w])(${[...byEscapedCode.keys()]
48+ .sort((a, b) => b.length - a.length)
49+ .map((c) => c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
50+ .join('|')})(?=$|[^\\w])`,
51+ 'g'
52+);
53+
54+export function smileyImg(smiley: Smiley) {
55+ return `<img class="aim-smiley" src="/smileys/${smiley.file}.gif" alt="${smiley.emoji}" title="${escape(smiley.codes[0])}" width="19" height="19" draggable="false">`;
56+}
57+
58+/**
59+ * Replace smiley codes in escaped HTML text. Only text between tags is
60+ * touched so attributes (style="color: …") are never rewritten.
61+ */
62+export function replaceSmileys(html: string): string {
63+ return html
64+ .split(/(<[^>]*>)/)
65+ .map((part) =>
66+ part.startsWith('<')
67+ ? part
68+ : part.replace(pattern, (_, before: string, code: string) => {
69+ const smiley = byEscapedCode.get(code);
70+ return smiley ? before + smileyImg(smiley) : _;
71+ })
72+ )
73+ .join('');
74+}
M src/lib/components/chat-room.svelte +7 -0
@@ -1003,6 +1003,12 @@
10031003 }
10041004 }
10051005
1006+ function insertSmiley(code: string) {
1007+ const needsSpace = currentMessage.length > 0 && !currentMessage.endsWith(' ');
1008+ currentMessage = `${currentMessage}${needsSpace ? ' ' : ''}${code} `;
1009+ void tick().then(() => messageInput?.focus());
1010+ }
1011+
10061012 function openSignup() {
10071013 desktop.openLogin('signup');
10081014 }
@@ -1146,6 +1152,7 @@
11461152 bind:style={currentTextStyle}
11471153 compact={true}
11481154 showFontSelector={true}
1155+ onSmiley={insertSmiley}
11491156 />
11501157 </div>
11511158 {/if}
@@ -1003,6 +1003,12 @@
1003 }1003 }
1004 }1004 }
1005 1005
1006+ function insertSmiley(code: string) {
1007+ const needsSpace = currentMessage.length > 0 && !currentMessage.endsWith(' ');
1008+ currentMessage = `${currentMessage}${needsSpace ? ' ' : ''}${code} `;
1009+ void tick().then(() => messageInput?.focus());
1010+ }
1011+
1006 function openSignup() {1012 function openSignup() {
1007 desktop.openLogin('signup');1013 desktop.openLogin('signup');
1008 }1014 }
@@ -1146,6 +1152,7 @@
1146 bind:style={currentTextStyle}1152 bind:style={currentTextStyle}
1147 compact={true}1153 compact={true}
1148 showFontSelector={true}1154 showFontSelector={true}
1155+ onSmiley={insertSmiley}
1149 />1156 />
1150 </div>1157 </div>
1151 {/if}1158 {/if}
M src/lib/components/desktop/im-window.svelte +10 -1
@@ -308,7 +308,16 @@
308308 >
309309 </div>{/if}
310310 </div>
311- <TextFormattingToolbar bind:style={textStyle} compact={true} showFontSelector={true} />
311+ <TextFormattingToolbar
312+ bind:style={textStyle}
313+ compact={true}
314+ showFontSelector={true}
315+ onSmiley={(code) => {
316+ const needsSpace = draft.length > 0 && !draft.endsWith(' ');
317+ draft = `${draft}${needsSpace ? ' ' : ''}${code} `;
318+ document.getElementById(`im-input-${roomId}`)?.focus();
319+ }}
320+ />
312321 <div class="compose field-row">
313322 <label class="sr-only" for={`im-input-${roomId}`}>Message à {otherNickname}</label>
314323 <textarea
@@ -308,7 +308,16 @@
308 >308 >
309 </div>{/if}309 </div>{/if}
310 </div>310 </div>
311- <TextFormattingToolbar bind:style={textStyle} compact={true} showFontSelector={true} />311+ <TextFormattingToolbar
312+ bind:style={textStyle}
313+ compact={true}
314+ showFontSelector={true}
315+ onSmiley={(code) => {
316+ const needsSpace = draft.length > 0 && !draft.endsWith(' ');
317+ draft = `${draft}${needsSpace ? ' ' : ''}${code} `;
318+ document.getElementById(`im-input-${roomId}`)?.focus();
319+ }}
320+ />
312 <div class="compose field-row">321 <div class="compose field-row">
313 <label class="sr-only" for={`im-input-${roomId}`}>Message à {otherNickname}</label>322 <label class="sr-only" for={`im-input-${roomId}`}>Message à {otherNickname}</label>
314 <textarea323 <textarea
M src/lib/components/formatted-message.svelte +12 -2
@@ -4,6 +4,7 @@
44 import { formatText, createGradientText, escapeHtml } from '../utils/text-formatter';
55 import { DEFAULT_TEXT_STYLE } from '../types/text-formatting';
66 import { highlightMentionsPlain } from '../utils/mention';
7+ import { replaceSmileys } from '$lib/aim/smileys';
78
89 // Props
910 let { message, allowFormatting = true } = $props<{
@@ -20,7 +21,7 @@
2021 async function processMessage() {
2122 if (!allowFormatting || !message.hasFormatting) {
2223 // Still apply mention highlighting to plain text messages
23- formattedContent = highlightMentionsPlain(escapeHtml(message.content));
24+ formattedContent = replaceSmileys(highlightMentionsPlain(escapeHtml(message.content)));
2425 return;
2526 }
2627
@@ -52,7 +53,9 @@
5253
5354 // Regular formatted text (no gradient)
5455 const styleWithoutGradient = { ...style, gradient: undefined };
55- formattedContent = await formatText(message.content, styleWithoutGradient, allowFormatting);
56+ formattedContent = replaceSmileys(
57+ await formatText(message.content, styleWithoutGradient, allowFormatting)
58+ );
5659 } catch (error) {
5760 console.error('Message formatting error:', error);
5861 hasError = true;
@@ -180,6 +183,13 @@
180183 {/if}
181184
182185 <style>
186+ .formatted-message :global(.aim-smiley),
187+ :global(.aim-smiley) {
188+ width: 19px;
189+ height: 19px;
190+ vertical-align: -4px;
191+ image-rendering: pixelated;
192+ }
183193 .processing {
184194 color: #666;
185195 font-style: italic;
@@ -4,6 +4,7 @@
4 import { formatText, createGradientText, escapeHtml } from '../utils/text-formatter';4 import { formatText, createGradientText, escapeHtml } from '../utils/text-formatter';
5 import { DEFAULT_TEXT_STYLE } from '../types/text-formatting';5 import { DEFAULT_TEXT_STYLE } from '../types/text-formatting';
6 import { highlightMentionsPlain } from '../utils/mention';6 import { highlightMentionsPlain } from '../utils/mention';
7+ import { replaceSmileys } from '$lib/aim/smileys';
7 8
8 // Props9 // Props
9 let { message, allowFormatting = true } = $props<{10 let { message, allowFormatting = true } = $props<{
@@ -20,7 +21,7 @@
20 async function processMessage() {21 async function processMessage() {
21 if (!allowFormatting || !message.hasFormatting) {22 if (!allowFormatting || !message.hasFormatting) {
22 // Still apply mention highlighting to plain text messages23 // Still apply mention highlighting to plain text messages
23- formattedContent = highlightMentionsPlain(escapeHtml(message.content));24+ formattedContent = replaceSmileys(highlightMentionsPlain(escapeHtml(message.content)));
24 return;25 return;
25 }26 }
26 27
@@ -52,7 +53,9 @@
52 53
53 // Regular formatted text (no gradient)54 // Regular formatted text (no gradient)
54 const styleWithoutGradient = { ...style, gradient: undefined };55 const styleWithoutGradient = { ...style, gradient: undefined };
55- formattedContent = await formatText(message.content, styleWithoutGradient, allowFormatting);56+ formattedContent = replaceSmileys(
57+ await formatText(message.content, styleWithoutGradient, allowFormatting)
58+ );
56 } catch (error) {59 } catch (error) {
57 console.error('Message formatting error:', error);60 console.error('Message formatting error:', error);
58 hasError = true;61 hasError = true;
@@ -180,6 +183,13 @@
180 {/if}183 {/if}
181 184
182 <style>185 <style>
186+ .formatted-message :global(.aim-smiley),
187+ :global(.aim-smiley) {
188+ width: 19px;
189+ height: 19px;
190+ vertical-align: -4px;
191+ image-rendering: pixelated;
192+ }
183 .processing {193 .processing {
184 color: #666;194 color: #666;
185 font-style: italic;195 font-style: italic;
M src/lib/components/text-formatting-toolbar.svelte +76 -1
@@ -6,18 +6,24 @@
66 type FontFamily
77 } from '../types/text-formatting';
88 import ColorPicker256 from './color-picker-256.svelte';
9+ import { SMILEYS } from '$lib/aim/smileys';
910
1011 // Props
1112 let {
1213 style = $bindable(DEFAULT_TEXT_STYLE),
1314 compact = true,
14- showFontSelector = false
15+ showFontSelector = false,
16+ onSmiley
1517 } = $props<{
1618 style?: TextStyle;
1719 compact?: boolean;
1820 showFontSelector?: boolean;
21+ /** When set, shows the AIM smiley picker and inserts the chosen code */
22+ onSmiley?: (code: string) => void;
1923 }>();
2024
25+ let smileyOpen = $state(false);
26+
2127 // State
2228 let currentColor = $state(style.color || '#000000');
2329 let currentGradient = $state(style.gradient);
@@ -132,6 +138,38 @@
132138 </button>
133139 </div>
134140
141+ {#if onSmiley}
142+ <div class="smiley-control">
143+ <button
144+ type="button"
145+ title="Smileys"
146+ aria-haspopup="true"
147+ aria-expanded={smileyOpen}
148+ onclick={() => (smileyOpen = !smileyOpen)}
149+ style="width: 24px; height: 20px; padding: 0; min-width: 0;"
150+ >
151+ <img src="/smileys/01.gif" alt="🙂" width="15" height="15" />
152+ </button>
153+ {#if smileyOpen}
154+ <div class="smiley-picker" role="menu">
155+ {#each SMILEYS as smiley (smiley.file)}
156+ <button
157+ type="button"
158+ role="menuitem"
159+ title="{smiley.label} {smiley.codes[0]}"
160+ onclick={() => {
161+ smileyOpen = false;
162+ onSmiley(smiley.codes[0]);
163+ }}
164+ >
165+ <img src="/smileys/{smiley.file}.gif" alt={smiley.emoji} width="19" height="19" />
166+ </button>
167+ {/each}
168+ </div>
169+ {/if}
170+ </div>
171+ {/if}
172+
135173 <!-- Color controls -->
136174 <div class="color-controls">
137175 <ColorPicker256
@@ -177,6 +215,43 @@
177215 gap: 1px;
178216 }
179217
218+ .smiley-control {
219+ position: relative;
220+ display: flex;
221+ }
222+ .smiley-control img {
223+ image-rendering: pixelated;
224+ }
225+ .smiley-picker {
226+ position: absolute;
227+ bottom: calc(100% + 2px);
228+ left: 0;
229+ z-index: 60;
230+ display: grid;
231+ grid-template-columns: repeat(4, 25px);
232+ gap: 1px;
233+ padding: 3px;
234+ background: #fff;
235+ border: 1px solid #7f9db9;
236+ box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
237+ }
238+ .smiley-picker button {
239+ width: 25px;
240+ height: 25px;
241+ min-width: 0;
242+ min-height: 0;
243+ padding: 0;
244+ background: transparent;
245+ box-shadow: none;
246+ border: 1px solid transparent;
247+ display: flex;
248+ align-items: center;
249+ justify-content: center;
250+ }
251+ .smiley-picker button:hover {
252+ border-color: #316ac5;
253+ background: #e8eefb;
254+ }
180255 .color-controls {
181256 display: flex;
182257 gap: 1px;
@@ -6,18 +6,24 @@
6 type FontFamily6 type FontFamily
7 } from '../types/text-formatting';7 } from '../types/text-formatting';
8 import ColorPicker256 from './color-picker-256.svelte';8 import ColorPicker256 from './color-picker-256.svelte';
9+ import { SMILEYS } from '$lib/aim/smileys';
9 10
10 // Props11 // Props
11 let {12 let {
12 style = $bindable(DEFAULT_TEXT_STYLE),13 style = $bindable(DEFAULT_TEXT_STYLE),
13 compact = true,14 compact = true,
14- showFontSelector = false15+ showFontSelector = false,
16+ onSmiley
15 } = $props<{17 } = $props<{
16 style?: TextStyle;18 style?: TextStyle;
17 compact?: boolean;19 compact?: boolean;
18 showFontSelector?: boolean;20 showFontSelector?: boolean;
21+ /** When set, shows the AIM smiley picker and inserts the chosen code */
22+ onSmiley?: (code: string) => void;
19 }>();23 }>();
20 24
25+ let smileyOpen = $state(false);
26+
21 // State27 // State
22 let currentColor = $state(style.color || '#000000');28 let currentColor = $state(style.color || '#000000');
23 let currentGradient = $state(style.gradient);29 let currentGradient = $state(style.gradient);
@@ -132,6 +138,38 @@
132 </button>138 </button>
133 </div>139 </div>
134 140
141+ {#if onSmiley}
142+ <div class="smiley-control">
143+ <button
144+ type="button"
145+ title="Smileys"
146+ aria-haspopup="true"
147+ aria-expanded={smileyOpen}
148+ onclick={() => (smileyOpen = !smileyOpen)}
149+ style="width: 24px; height: 20px; padding: 0; min-width: 0;"
150+ >
151+ <img src="/smileys/01.gif" alt="🙂" width="15" height="15" />
152+ </button>
153+ {#if smileyOpen}
154+ <div class="smiley-picker" role="menu">
155+ {#each SMILEYS as smiley (smiley.file)}
156+ <button
157+ type="button"
158+ role="menuitem"
159+ title="{smiley.label} {smiley.codes[0]}"
160+ onclick={() => {
161+ smileyOpen = false;
162+ onSmiley(smiley.codes[0]);
163+ }}
164+ >
165+ <img src="/smileys/{smiley.file}.gif" alt={smiley.emoji} width="19" height="19" />
166+ </button>
167+ {/each}
168+ </div>
169+ {/if}
170+ </div>
171+ {/if}
172+
135 <!-- Color controls -->173 <!-- Color controls -->
136 <div class="color-controls">174 <div class="color-controls">
137 <ColorPicker256175 <ColorPicker256
@@ -177,6 +215,43 @@
177 gap: 1px;215 gap: 1px;
178 }216 }
179 217
218+ .smiley-control {
219+ position: relative;
220+ display: flex;
221+ }
222+ .smiley-control img {
223+ image-rendering: pixelated;
224+ }
225+ .smiley-picker {
226+ position: absolute;
227+ bottom: calc(100% + 2px);
228+ left: 0;
229+ z-index: 60;
230+ display: grid;
231+ grid-template-columns: repeat(4, 25px);
232+ gap: 1px;
233+ padding: 3px;
234+ background: #fff;
235+ border: 1px solid #7f9db9;
236+ box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
237+ }
238+ .smiley-picker button {
239+ width: 25px;
240+ height: 25px;
241+ min-width: 0;
242+ min-height: 0;
243+ padding: 0;
244+ background: transparent;
245+ box-shadow: none;
246+ border: 1px solid transparent;
247+ display: flex;
248+ align-items: center;
249+ justify-content: center;
250+ }
251+ .smiley-picker button:hover {
252+ border-color: #316ac5;
253+ background: #e8eefb;
254+ }
180 .color-controls {255 .color-controls {
181 display: flex;256 display: flex;
182 gap: 1px;257 gap: 1px;
A static/smileys/README.md +10 -0
@@ -0,0 +1,10 @@
1+# Smileys AIM (images locales)
2+
3+Ce dossier est ignoré par git (sauf ce fichier) : les smileys jaunes
4+d'AOL Instant Messenger appartiennent à AOL et ne sont pas redistribués.
5+
6+Sans fichier, chaque smiley s'affiche avec son emoji Unicode de secours.
7+Pour les vrais smileys, déposer ici `01.gif` … `16.gif`, tirés du pack
8+« Yellow Smiley » (`0201D201A5`) de l'archive
9+<https://archive.org/details/aol_instant_messenger_smiley_packs>
10+(`smiley_yellow_NN.gif` → `NN.gif`). Codes : `src/lib/aim/smileys.ts`.
@@ -0,0 +1,10 @@
1+# Smileys AIM (images locales)
2+
3+Ce dossier est ignoré par git (sauf ce fichier) : les smileys jaunes
4+d'AOL Instant Messenger appartiennent à AOL et ne sont pas redistribués.
5+
6+Sans fichier, chaque smiley s'affiche avec son emoji Unicode de secours.
7+Pour les vrais smileys, déposer ici `01.gif` … `16.gif`, tirés du pack
8+« Yellow Smiley » (`0201D201A5`) de l'archive
9+<https://archive.org/details/aol_instant_messenger_smiley_packs>
10+(`smiley_yellow_NN.gif` → `NN.gif`). Codes : `src/lib/aim/smileys.ts`.

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