From b96bdf7ff3cd22bd81ff093270765d9c7275a6a6 Mon Sep 17 00:00:00 2001
From: Shibo Lyu <hi@lao.sb>
Date: Wed, 19 Mar 2025 01:48:54 +0800
Subject: [PATCH] fix: all svelte diagnostics.

---
 src/lib/chat.ts                               |  2 +-
 .../components/DropdownMenu/Content.svelte    | 34 ++++----
 src/lib/components/RichTextInput.svelte       | 20 ++---
 .../RichTextInput/ClientInput.svelte          |  4 +-
 src/lib/mock/messages.ts                      | 65 ++++++---------
 src/lib/richText.ts                           | 79 +++++++++++++++++++
 .../chats/[server]/[chatId]/+page.svelte      | 31 +++-----
 .../chats/[server]/[chatId]/ChatInput.svelte  | 16 ++--
 .../chats/[server]/[chatId]/ChatPage.svelte   | 11 +--
 src/routes/(internal)/_rich-text/+page.svelte |  4 +-
 10 files changed, 159 insertions(+), 107 deletions(-)
 create mode 100644 src/lib/richText.ts

diff --git a/src/lib/chat.ts b/src/lib/chat.ts
index aeb8d1d..914e3ad 100644
--- a/src/lib/chat.ts
+++ b/src/lib/chat.ts
@@ -1,6 +1,6 @@
 import { derived, readable, type Readable } from 'svelte/store';
 import type { BlahChatServerConnection } from './blah/connection/chatServer';
-import type { BlahRichText } from './richText';
+import type { BlahRichText } from '@blah-im/core/richText';
 import { messageFromBlah, type Chat, type Message, type User } from './types';
 import { BlahError } from './blah/connection/error';
 
diff --git a/src/lib/components/DropdownMenu/Content.svelte b/src/lib/components/DropdownMenu/Content.svelte
index 25159e9..378dee0 100644
--- a/src/lib/components/DropdownMenu/Content.svelte
+++ b/src/lib/components/DropdownMenu/Content.svelte
@@ -4,26 +4,26 @@
 	import { expoOut } from 'svelte/easing';
 	import { scale } from 'svelte/transition';
 
-	
-	interface Props {
-		class?: $$Props['class'];
-		children?: import('svelte').Snippet;
-		[key: string]: any
+	interface Props extends DropdownMenuContentProps {
+		class?: string;
 	}
 
 	let { class: className = '', children, ...rest }: Props = $props();
-	
+
+	const fullClassName = tw(
+		'group border-ss-secondary bg-sb-overlay min-w-32 origin-top rounded-lg border p-1 shadow-lg',
+		className
+	);
 </script>
 
-<DropdownMenu.Content
-	class={tw(
-		'group min-w-32 origin-top rounded-lg border border-ss-secondary bg-sb-overlay p-1 shadow-lg',
-		className
-	)}
-	sideOffset={4}
-	transition={scale}
-	transitionConfig={{ start: 0.96, duration: 300, easing: expoOut }}
-	{...rest}
->
-	{@render children?.()}
+<DropdownMenu.Content class={fullClassName} sideOffset={4} forceMount {...rest}>
+	{#snippet child({ wrapperProps, props, open })}
+		{#if open}
+			<div {...wrapperProps}>
+				<div {...props} transition:scale={{ start: 0.96, duration: 300, easing: expoOut }}>
+					{@render children?.()}
+				</div>
+			</div>
+		{/if}
+	{/snippet}
 </DropdownMenu.Content>
diff --git a/src/lib/components/RichTextInput.svelte b/src/lib/components/RichTextInput.svelte
index 3cf738e..0519bf2 100644
--- a/src/lib/components/RichTextInput.svelte
+++ b/src/lib/components/RichTextInput.svelte
@@ -4,27 +4,27 @@
 	import InputFrame from '$lib/components/InputFrame.svelte';
 	import { tw } from '$lib/tw';
 
-
 	interface Props {
-		delta?: Delta | null;
-		plainText?: string | undefined;
+		delta?: Delta;
+		plainText?: string;
 		keyboardSubmitMethod?: 'enter' | 'shiftEnter' | undefined;
+		onKeyboardSubmit?: () => void;
 		placeholder?: string;
-		editor: Editor | undefined;
+		editor?: Editor;
 		class?: string;
 		children?: import('svelte').Snippet;
 	}
 
 	let {
-		delta = $bindable(null),
+		delta = $bindable(undefined),
 		plainText = $bindable(undefined),
 		keyboardSubmitMethod = undefined,
+		onKeyboardSubmit,
 		placeholder = '',
 		editor = $bindable(),
 		class: className = '',
 		children
 	}: Props = $props();
-	
 
 	const loadClientComponent = async () => {
 		if (!browser) return;
@@ -38,16 +38,16 @@
 		<div class="rich-text opacity-50">
 			<p>{placeholder}</p>
 		</div>
-	{:then Input}
-		<Input
+	{:then ClientInput}
+		<ClientInput
 			bind:delta
 			bind:plainText
 			{placeholder}
 			bind:editor
 			{keyboardSubmitMethod}
-			on:keyboardSubmit
+			{onKeyboardSubmit}
 		>
 			{@render children?.()}
-		</Input>
+		</ClientInput>
 	{/await}
 </InputFrame>
diff --git a/src/lib/components/RichTextInput/ClientInput.svelte b/src/lib/components/RichTextInput/ClientInput.svelte
index f562064..d11fd8a 100644
--- a/src/lib/components/RichTextInput/ClientInput.svelte
+++ b/src/lib/components/RichTextInput/ClientInput.svelte
@@ -4,6 +4,7 @@
 
 	interface Props {
 		delta?: Delta;
+		editor?: Editor;
 		plainText?: string | undefined;
 		placeholder?: string;
 		keyboardSubmitMethod?: 'enter' | 'shiftEnter' | undefined;
@@ -13,6 +14,7 @@
 
 	let {
 		delta = $bindable(new Delta()),
+		editor = $bindable(initEditor()),
 		plainText = $bindable(undefined),
 		placeholder = '',
 		keyboardSubmitMethod = undefined,
@@ -20,8 +22,6 @@
 		children
 	}: Props = $props();
 
-	let editor: Editor = $state(initEditor());
-
 	function initEditor() {
 		const modules = keyboardSubmitMethod
 			? {
diff --git a/src/lib/mock/messages.ts b/src/lib/mock/messages.ts
index 1daec44..fee1ece 100644
--- a/src/lib/mock/messages.ts
+++ b/src/lib/mock/messages.ts
@@ -3,58 +3,37 @@ import type { Message, User } from '$lib/types';
 import { getRandomUser } from './users';
 
 const messageContents: BlahRichText[] = [
-	[['更好的例子可能是link和hashtag不應該共存']],
-	[['理論上mono是可以BIUS的,只是可能不太常見']],
-	[[['這個是一個link', { link: 'https://google.com' }]]],
-	[['這是一個', ['#hashtag', { hashtag: true }]]],
+	['更好的例子可能是link和hashtag不應該共存'],
+	['理論上mono是可以BIUS的,只是可能不太常見'],
+	[['這個是一個link', { link: 'https://google.com' }]],
+	['這是一個', ['#hashtag', { tag: true }]],
+	['這是一個', ['link', { link: 'https://google.com' }], '和一個', ['#hashtag', { tag: true }]],
+	['可以, 反正我都手写了('],
+	['但我們也可以約定這種entity一定要有plain text fallback'],
+	['這樣的話,我們就可以在不支援的地方用plain text fallback'],
+	['有可能有僅attribute的run'],
 	[
-		[
-			'這是一個',
-			['link', { link: 'https://google.com' }],
-			'和一個',
-			['#hashtag', { hashtag: true }]
-		]
+		'我现在是约定 text piece 一定非空,也就是说空字符串应该是空数组(但能不能真的这么发言要打个问号)'
 	],
-	[['可以, 反正我都手写了(']],
-	[['但我們也可以約定這種entity一定要有plain text fallback']],
-	[['這樣的話,我們就可以在不支援的地方用plain text fallback']],
-	[['有可能有僅attribute的run']],
+	['确实合并相邻的 run 就可以 canonicalize'],
+	['比如我可能不希望往数据库里这么存 json ,还需要考虑检索之类的'],
+	['是我蠢了'],
+	['我觉得这个问题是因为我们没有定义好什么是一个 run'],
+	['目标是如果前端/后端使用和协议不一致的格式存储的话 roundtrip 后一致'],
 	[
-		[
-			'我现在是约定 text piece 一定非空,也就是说空字符串应该是空数组(但能不能真的这么发言要打个问号)'
-		]
+		'你们这个 canonicalize 真的靠谱吗,,,感觉哪怕跑了一遍以后也会有多个不同 message 视觉效果相同的情况'
 	],
-	[['确实合并相邻的 run 就可以 canonicalize']],
-	[['比如我可能不希望往数据库里这么存 json ,还需要考虑检索之类的']],
-	[['是我蠢了']],
-	[['我觉得这个问题是因为我们没有定义好什么是一个 run']],
-	[['目标是如果前端/后端使用和协议不一致的格式存储的话 roundtrip 后一致']],
+	['稍微有点烦('],
 	[
 		[
-			'你们这个 canonicalize 真的靠谱吗,,,感觉哪怕跑了一遍以后也会有多个不同 message 视觉效果相同的情况'
-		]
-	],
-	[['稍微有点烦(']],
-	[
-		[
-			[
-				'奥运会究竟该如何报道?中国媒体的表现真的这么不堪吗?',
-				{ link: 'https://www.bilibili.com/video/BV1TZ421L7hj/' }
-			]
+			'奥运会究竟该如何报道?中国媒体的表现真的这么不堪吗?',
+			{ link: 'https://www.bilibili.com/video/BV1TZ421L7hj/' }
 		],
-		[
-			'在视频中,可爸深入分析了中国媒体在巴黎奥运会中的表现,探讨了媒体人员的规模、采访类型、团队构成以及值得称赞与批评的采访案例。文章指出,尽管注册媒体工作者数量庞大,但真正的记者数量相对较少,且面临专业能力不足和流量逻辑冲击等问题。同时,作者强调了媒体在维护国家荣誉和传递奥运精神方面的重要性,呼吁媒体挖掘运动员故事,以建立观众与运动员之间的情感联系。这篇文章为了解当前中国体育媒体的现状和未来发展提供了深刻的见解和反思。'
-		],
-		[''],
-		['---'],
-		[''],
-		['非常好的视频。强烈推荐观看。']
+		'\n在视频中,可爸深入分析了中国媒体在巴黎奥运会中的表现,探讨了媒体人员的规模、采访类型、团队构成以及值得称赞与批评的采访案例。文章指出,尽管注册媒体工作者数量庞大,但真正的记者数量相对较少,且面临专业能力不足和流量逻辑冲击等问题。同时,作者强调了媒体在维护国家荣誉和传递奥运精神方面的重要性,呼吁媒体挖掘运动员故事,以建立观众与运动员之间的情感联系。这篇文章为了解当前中国体育媒体的现状和未来发展提供了深刻的见解和反思。\n---\n\n非常好的视频。强烈推荐观看。'
 	],
-	[['pieces:[], attrs:[] 两者等长。然后判断合并就是 attrs 有没有相邻重复元素']],
+	['pieces:[], attrs:[] 两者等长。然后判断合并就是 attrs 有没有相邻重复元素'],
 	[
-		[
-			'记้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎得้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎做้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎ ้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎o้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎v้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎e้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎r้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎f้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎l้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎o้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎w้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎ ้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎h้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎i้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎d้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎d้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎e้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎n้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎'
-		]
+		'记้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎得้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎做้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎ ้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎o้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎v้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎e้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎r้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎f้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎l้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎o้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎w้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎ ้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎h้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎i้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎d้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎d้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎e้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎n้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎้้้้๎๎๎๎'
 	]
 ];
 
diff --git a/src/lib/richText.ts b/src/lib/richText.ts
new file mode 100644
index 0000000..616e1be
--- /dev/null
+++ b/src/lib/richText.ts
@@ -0,0 +1,79 @@
+import type { BlahRichText, BlahRichTextSpanAttributes } from '@blah-im/core/richText';
+import canonicalize from 'canonicalize';
+import type { AttributeMap, Delta } from 'typewriter-editor';
+
+function isObjectEmpty(obj: object) {
+	for (const _ in obj) return false;
+	return true;
+}
+
+function deltaAttributesToBlahRichTextSpanAttributes(
+	attributes?: AttributeMap
+): BlahRichTextSpanAttributes | null {
+	if (!attributes) return null;
+
+	const blahRichTextSpanAttributes: BlahRichTextSpanAttributes = {};
+
+	if (attributes.bold) blahRichTextSpanAttributes.b = true;
+	if (attributes.italic) blahRichTextSpanAttributes.i = true;
+	if (attributes.code) blahRichTextSpanAttributes.m = true;
+	if (attributes.link) blahRichTextSpanAttributes.link = attributes.link;
+
+	if (attributes.underline) blahRichTextSpanAttributes.u = true;
+	if (attributes.strikethrough) blahRichTextSpanAttributes.s = true;
+
+	return isObjectEmpty(blahRichTextSpanAttributes) ? null : blahRichTextSpanAttributes;
+}
+
+export function deltaToBlahRichText(delta: Delta, trim: boolean = true): BlahRichText {
+	const spans: BlahRichText = [];
+
+	let lastText = '';
+	let lastAttributes: BlahRichTextSpanAttributes | null = null;
+	let canonicalizedLastAttributes: string = 'null';
+
+	function commitSpan(trim?: 'start' | 'end'): boolean {
+		const trimmedLastText =
+			trim === 'start' ? lastText.trimStart() : trim === 'end' ? lastText.trimEnd() : lastText;
+		if (trimmedLastText === '') return false;
+		spans.push(lastAttributes === null ? trimmedLastText : [trimmedLastText, lastAttributes]);
+		return true;
+	}
+
+	let isFirstSpan = true;
+	for (const op of delta.ops) {
+		// Not sure in what cases op.insert would not be a string, but let's be safe
+		if (typeof op.insert !== 'string') continue;
+
+		const attributes = deltaAttributesToBlahRichTextSpanAttributes(op.attributes);
+		const canonicalizedAttributes = canonicalize(attributes) ?? 'null';
+
+		if (canonicalizedAttributes === canonicalizedLastAttributes) {
+			lastText += op.insert;
+			continue;
+		}
+
+		const commited = commitSpan(trim && isFirstSpan ? 'start' : undefined);
+		if (commited) isFirstSpan = false;
+
+		lastText = op.insert;
+		lastAttributes = attributes;
+		canonicalizedLastAttributes = canonicalizedAttributes;
+	}
+	const lastCommited = commitSpan(trim ? 'end' : undefined);
+	if (trim && !lastCommited) {
+		// The last segment is empty, so we need to trim the one before it
+		let lastSpan = spans.pop();
+		if (!lastSpan) return spans;
+
+		if (typeof lastSpan === 'string') {
+			lastSpan = lastSpan.trimEnd();
+			if (lastSpan !== '') spans.push(lastSpan);
+		} else {
+			lastSpan[0] = lastSpan[0].trimEnd();
+			if (lastSpan[0] !== '') spans.push(lastSpan);
+		}
+	}
+
+	return spans;
+}
diff --git a/src/routes/(app)/chats/[server]/[chatId]/+page.svelte b/src/routes/(app)/chats/[server]/[chatId]/+page.svelte
index 3db25ef..27aa98d 100644
--- a/src/routes/(app)/chats/[server]/[chatId]/+page.svelte
+++ b/src/routes/(app)/chats/[server]/[chatId]/+page.svelte
@@ -1,36 +1,29 @@
 <script lang="ts">
-	import { run } from 'svelte/legacy';
-
-	import { page } from '$app/stores';
+	import { page } from '$app/state';
 	import { BlahChatServerConnection } from '$lib/blah/connection/chatServer';
-	import { browser } from '$app/environment';
 	import { chatServerConnectionPool } from '$lib/chatServers';
 	import ServiceMessage from '$lib/components/ServiceMessage.svelte';
 	import ChatPage from './ChatPage.svelte';
 	import { useChat } from '$lib/chat';
 
-	let roomId = $derived($page.params.chatId);
+	let roomId = $derived(page.params.chatId);
 
-	let serverEndpoint: string = $state('');
-	run(() => {
-		const endpointString = decodeURIComponent($page.params.server);
-		serverEndpoint = endpointString.startsWith('http')
-			? endpointString
-			: `https://${endpointString}`;
-	});
+	let serverEndpoint: string = $derived(normalizedServerEndpoint(page.params.server));
+	function normalizedServerEndpoint(serverURIComponent: string) {
+		const endpointString = decodeURIComponent(serverURIComponent);
+		return endpointString.startsWith('http') ? endpointString : `https://${endpointString}`;
+	}
 
-	let server: BlahChatServerConnection | null = $state();
-	run(() => {
-		if (browser) {
-			server = chatServerConnectionPool.getConnection(serverEndpoint);
-		}
+	let server: BlahChatServerConnection | null = $state(null);
+	$effect.pre(() => {
+		server = chatServerConnectionPool.getConnection(serverEndpoint);
 	});
 </script>
 
 <div class="flex h-full w-full flex-col items-center justify-center">
 	{#if server}
-		{@const { info, sectionedMessages, sendMessage } = useChat(server, roomId)}
-		<ChatPage {info} {sectionedMessages} on:sendMessage={(e) => sendMessage(e.detail)} />
+		{@const { sendMessage, ...rest } = useChat(server, roomId)}
+		<ChatPage {...rest} onSendMessage={sendMessage} />
 	{:else}
 		<ServiceMessage>
 			To view this chat, you need to connect to chat server
diff --git a/src/routes/(app)/chats/[server]/[chatId]/ChatInput.svelte b/src/routes/(app)/chats/[server]/[chatId]/ChatInput.svelte
index 084e576..3d7a9a4 100644
--- a/src/routes/(app)/chats/[server]/[chatId]/ChatInput.svelte
+++ b/src/routes/(app)/chats/[server]/[chatId]/ChatInput.svelte
@@ -1,17 +1,21 @@
 <script lang="ts">
 	import Button from '$lib/components/Button.svelte';
 	import RichTextInput from '$lib/components/RichTextInput.svelte';
-	import { deltaToBlahRichText, type BlahRichText } from '@blah-im/core/richText';
-	import { createEventDispatcher } from 'svelte';
+	import type { BlahRichText } from '@blah-im/core/richText';
+	import { deltaToBlahRichText } from '$lib/richText';
 	import type { Delta, Editor } from 'typewriter-editor';
 
+	let {
+		onSendMessage
+	}: {
+		onSendMessage: (message: BlahRichText) => void;
+	} = $props();
+
 	let editor: Editor | undefined = $state();
 	let delta: Delta | undefined = $state();
 	let plainText: string = $state('');
 	let form: HTMLFormElement | null = $state(null);
 
-	const dispatch = createEventDispatcher<{ sendMessage: BlahRichText }>();
-
 	function onKeyboardSubmit() {
 		editor?.select(null);
 		form?.requestSubmit();
@@ -23,7 +27,7 @@
 		if (plainText.trim() === '' || !delta) return;
 
 		const brt = deltaToBlahRichText(delta);
-		dispatch('sendMessage', brt);
+		onSendMessage(brt);
 
 		plainText = '';
 	}
@@ -58,7 +62,7 @@
 		placeholder="Message"
 		class="max-h-40 flex-1"
 		keyboardSubmitMethod="enter"
-		on:keyboardSubmit={onKeyboardSubmit}
+		{onKeyboardSubmit}
 	/>
 	<Button class="p-1.5" variant="primary" type="submit">
 		<svg
diff --git a/src/routes/(app)/chats/[server]/[chatId]/ChatPage.svelte b/src/routes/(app)/chats/[server]/[chatId]/ChatPage.svelte
index abe25a8..019ab86 100644
--- a/src/routes/(app)/chats/[server]/[chatId]/ChatPage.svelte
+++ b/src/routes/(app)/chats/[server]/[chatId]/ChatPage.svelte
@@ -1,7 +1,7 @@
 <script lang="ts">
 	import type { Readable } from 'svelte/store';
 
-	import type { Chat, Message } from '$lib/types';
+	import type { Chat } from '$lib/types';
 	import BgPattern from '$lib/components/BgPattern.svelte';
 	import { currentKeyPair } from '$lib/keystore';
 
@@ -14,17 +14,14 @@
 	interface Props {
 		info: Readable<Chat>;
 		sectionedMessages: Readable<MessageSection[]>;
+		onSendMessage: (brt: BlahRichText) => void;
 	}
 
-	let { info, sectionedMessages }: Props = $props();
-
-	interface $$Events {
-		sendMessage: CustomEvent<BlahRichText>;
-	}
+	let { info, sectionedMessages, onSendMessage }: Props = $props();
 </script>
 
 <ChatHeader info={$info} outsideUnreadCount={263723} />
 <BgPattern class="w-full flex-1" pattern="charlieBrown">
 	<ChatHistory sectionedMessages={$sectionedMessages} mySenderId={$currentKeyPair?.id} />
 </BgPattern>
-<ChatInput on:sendMessage />
+<ChatInput {onSendMessage} />
diff --git a/src/routes/(internal)/_rich-text/+page.svelte b/src/routes/(internal)/_rich-text/+page.svelte
index beadc87..77958ff 100644
--- a/src/routes/(internal)/_rich-text/+page.svelte
+++ b/src/routes/(internal)/_rich-text/+page.svelte
@@ -1,9 +1,9 @@
 <script lang="ts">
 	import RichTextInput from '$lib/components/RichTextInput.svelte';
-	import { deltaToBlahRichText } from '@blah-im/core/richText';
+	import { deltaToBlahRichText } from '$lib/richText';
 	import type { Delta } from 'typewriter-editor';
 
-	let delta: Delta = $state();
+	let delta: Delta | undefined = $state();
 
 	let brt = $derived(delta ? deltaToBlahRichText(delta) : null);
 </script>