main f6d8405

formatted-message.svelte RawHistoryFolder

1<script lang="ts">
2	import type { EnrichedMessage } from '../types/chat';
3	import type { TextStyle } from '../types/text-formatting';
4	import { formatText, createGradientText, escapeHtml } from '../utils/text-formatter';
5	import { DEFAULT_TEXT_STYLE } from '../types/text-formatting';
6	import { highlightMentionsPlain } from '../utils/mention';
7	import { replaceSmileys } from '$lib/aim/smileys';
8
9	// Props
10	let { message, allowFormatting = true } = $props<{
11		message: EnrichedMessage;
12		allowFormatting?: boolean;
13	}>();
14
15	// State
16	let formattedContent = $state('');
17	let isProcessing = $state(false);
18	let hasError = $state(false);
19
20	// Process message formatting
21	async function processMessage() {
22		if (!allowFormatting || !message.hasFormatting) {
23			// Still apply mention highlighting to plain text messages
24			formattedContent = replaceSmileys(highlightMentionsPlain(escapeHtml(message.content)));
25			return;
26		}
27
28		isProcessing = true;
29		hasError = false;
30
31		try {
32			// Parse style data if available
33			let style: TextStyle = DEFAULT_TEXT_STYLE;
34			if (message.styleData) {
35				try {
36					style = JSON.parse(message.styleData);
37				} catch (parseError) {
38					console.warn('Failed to parse message style data:', parseError);
39				}
40			}
41
42			// Check if this is a gradient message
43			if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) {
44				// Use character-by-character gradient for better compatibility
45				formattedContent = createGradientText(
46					message.content,
47					style.gradient,
48					'gradient-text-static',
49					style
50				);
51				return;
52			}
53
54			// Regular formatted text (no gradient)
55			const styleWithoutGradient = { ...style, gradient: undefined };
56			formattedContent = replaceSmileys(
57				await formatText(message.content, styleWithoutGradient, allowFormatting)
58			);
59		} catch (error) {
60			console.error('Message formatting error:', error);
61			hasError = true;
62			formattedContent = escapeHtml(message.content);
63		} finally {
64			isProcessing = false;
65		}
66	}
67
68	// Process message when it changes
69	$effect(() => {
70		processMessage();
71	});
72
73	// Generate inline styles for the message
74	function getMessageStyles(): string {
75		if (!message.hasFormatting || !message.styleData) {
76			return '';
77		}
78
79		try {
80			const style: TextStyle = JSON.parse(message.styleData);
81			// For gradient messages, we still need some styles but not font-family (use class instead)
82			if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) {
83				// Return only non-color and non-font-family styles for gradient messages
84				const styles: string[] = [];
85				styles.push(`font-size: ${style.fontSize}px`);
86				if (style.bold) styles.push(`font-weight: bold`);
87				if (style.italic) styles.push(`font-style: italic`);
88				const decorations: string[] = [];
89				if (style.underline) decorations.push('underline');
90				if (style.strikethrough) decorations.push('line-through');
91				if (decorations.length > 0) {
92					styles.push(`text-decoration: ${decorations.join(' ')}`);
93				}
94				return styles.join('; ');
95			}
96			// For non-gradient messages, exclude font-family (use class instead)
97			const styles: string[] = [];
98			styles.push(`font-size: ${style.fontSize}px`);
99			if (style.color) styles.push(`color: ${style.color}`);
100			if (style.bold) styles.push(`font-weight: bold`);
101			if (style.italic) styles.push(`font-style: italic`);
102			const decorations: string[] = [];
103			if (style.underline) decorations.push('underline');
104			if (style.strikethrough) decorations.push('line-through');
105			if (decorations.length > 0) {
106				styles.push(`text-decoration: ${decorations.join(' ')}`);
107			}
108			return styles.join('; ');
109		} catch (error) {
110			console.warn('Failed to generate message styles:', error);
111			return '';
112		}
113	}
114
115	// Get CSS classes for the message
116	function getMessageClasses(): string {
117		const classes = ['formatted-message'];
118
119		if (!message.hasFormatting || !message.styleData) {
120			return classes.join(' ');
121		}
122
123		try {
124			const style: TextStyle = JSON.parse(message.styleData);
125
126			// Add font family class
127			classes.push(`retro-font-${style.fontFamily}`);
128
129			// Add style classes
130			if (style.bold) classes.push('style-bold');
131			if (style.italic) classes.push('style-italic');
132			if (style.underline) classes.push('style-underline');
133			if (style.strikethrough) classes.push('style-strikethrough');
134
135			// Add gradient class if applicable
136			if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) {
137				classes.push('gradient-text-static');
138			}
139		} catch (error) {
140			console.warn('Failed to parse message style for classes:', error);
141		}
142
143		return classes.join(' ');
144	}
145</script>
146
147{#if isProcessing}
148	<span class={getMessageClasses()} style={getMessageStyles()}>
149		<span class="processing">...</span>
150	</span>
151{:else if hasError}
152	<span class={getMessageClasses()} style={getMessageStyles()}>
153		<span class="error" title="Formatting error - showing plain text">{message.content}</span>
154	</span>
155{:else}
156	<!-- For gradient messages, render without wrapper to avoid conflicts -->
157	{#if message.hasFormatting && message.styleData}
158		{@const parsedStyle = (() => {
159			try {
160				return JSON.parse(message.styleData);
161			} catch {
162				return null;
163			}
164		})()}
165		{#if parsedStyle?.gradient && Array.isArray(parsedStyle.gradient) && parsedStyle.gradient.length > 1}
166			<!-- Gradient messages are already wrapped properly -->
167			<!-- eslint-disable-next-line svelte/no-at-html-tags -- formattedContent is sanitized by createGradientText -->
168			{@html formattedContent}
169		{:else}
170			<!-- Regular formatted messages -->
171			<span class={getMessageClasses()} style={getMessageStyles()}>
172				<!-- eslint-disable-next-line svelte/no-at-html-tags -- formattedContent is sanitized by formatText -->
173				{@html formattedContent}
174			</span>
175		{/if}
176	{:else}
177		<!-- Plain messages -->
178		<span class={getMessageClasses()}>
179			<!-- eslint-disable-next-line svelte/no-at-html-tags -- formattedContent is sanitized by escapeHtml -->
180			{@html formattedContent}
181		</span>
182	{/if}
183{/if}
184
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	}
193	.processing {
194		color: #666;
195		font-style: italic;
196		animation: pulse 1s infinite;
197	}
198
199	.error {
200		color: #cc0000;
201		border-bottom: 1px dotted #cc0000;
202	}
203
204	@keyframes pulse {
205		0%,
206		100% {
207			opacity: 1;
208		}
209		50% {
210			opacity: 0.5;
211		}
212	}
213
214	/* Ensure formatted messages don't break layout */
215	:global(.formatted-message) {
216		word-break: break-word;
217		overflow-wrap: break-word;
218		hyphens: auto;
219	}
220
221	/* Improve gradient text readability */
222	:global(.formatted-message.gradient-text-static) {
223		font-weight: bold;
224	}
225
226	/* Ensure gradient spans are visible and preserve their colors */
227	:global(.gradient-text-static .gradient-char) {
228		display: inline;
229		opacity: 1 !important;
230		margin: 0 !important;
231		padding: 0 !important;
232		letter-spacing: 0 !important;
233		-webkit-text-fill-color: initial !important;
234	}
235
236	/* Don't override inline styles for gradient chars - removed to let inline styles work */
237
238	/* Remove any blur or gray effects */
239	:global(.formatted-message) {
240		opacity: 1 !important;
241		filter: none !important;
242	}
243
244	/* Don't inherit color for gradient text spans */
245	:global(.formatted-message:not(.gradient-text-static)) {
246		color: inherit;
247	}
248
249	/* Mention token styling - inherits message color/font, just adds bold */
250	:global(.mention-token) {
251		color: inherit;
252		font-weight: bold;
253	}
254
255	/* Ensure accessibility for screen readers */
256	:global(.formatted-message[aria-label]) {
257		position: relative;
258	}
259
260	/* Performance optimization for complex messages */
261	:global(.formatted-message) {
262		contain: layout style;
263	}
264</style>

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