feat: chat history ui

This commit is contained in:
Shibo Lyu 2024-08-31 04:49:26 +08:00
parent 0582dffa1c
commit aebe100799
16 changed files with 325 additions and 8 deletions

31
package-lock.json generated
View file

@ -12,6 +12,7 @@
"svelte-boring-avatars": "^1.2.6", "svelte-boring-avatars": "^1.2.6",
"tailwind-merge": "^2.5.2", "tailwind-merge": "^2.5.2",
"typewriter-editor": "^0.12.6", "typewriter-editor": "^0.12.6",
"virtua": "^0.33.4",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
@ -5724,6 +5725,36 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/virtua": {
"version": "0.33.7",
"resolved": "https://registry.npmjs.org/virtua/-/virtua-0.33.7.tgz",
"integrity": "sha512-IepZaMD/oeEh/ymTqokeQGLrMuRV25+lizPegxVIhOwqX+dEeV9ml1P57Eosok4qiZaeBeQIbIkF9QZrT+EeRQ==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.14.0",
"react-dom": ">=16.14.0",
"solid-js": ">=1.0",
"svelte": ">=4.0",
"vue": ">=3.2"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
},
"solid-js": {
"optional": true
},
"svelte": {
"optional": true
},
"vue": {
"optional": true
}
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "5.4.2", "version": "5.4.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.2.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.2.tgz",

View file

@ -40,6 +40,7 @@
"svelte-boring-avatars": "^1.2.6", "svelte-boring-avatars": "^1.2.6",
"tailwind-merge": "^2.5.2", "tailwind-merge": "^2.5.2",
"typewriter-editor": "^0.12.6", "typewriter-editor": "^0.12.6",
"virtua": "^0.33.4",
"zod": "^3.23.8" "zod": "^3.23.8"
} }
} }

View file

@ -46,6 +46,8 @@
@layer utilities { @layer utilities {
.rich-text { .rich-text {
@apply prose prose-slate max-w-none dark:prose-invert prose-p:my-0 prose-p:leading-tight prose-code:before:content-[''] prose-code:after:content-['']; @apply prose prose-slate max-w-none dark:prose-invert;
@apply prose-p:my-0 prose-p:leading-tight prose-code:before:content-[''] prose-code:after:content-[''];
@apply [&_span[data-weblah-brt=underline]]:underline;
} }
} }

View file

@ -23,7 +23,7 @@
<p>{placeholder}</p> <p>{placeholder}</p>
</div> </div>
{:then Input} {:then Input}
<svelte:component this={Input} bind:delta class={className} {placeholder}> <svelte:component this={Input} bind:delta {placeholder}>
<slot /> <slot />
</svelte:component> </svelte:component>
{/await} {/await}

View file

@ -1,10 +1,28 @@
<script lang="ts"> <script lang="ts">
import { Delta, Editor, asRoot } from 'typewriter-editor'; import { Delta, Editor, asRoot, h } from 'typewriter-editor';
export let delta: Delta = new Delta(); export let delta: Delta = new Delta();
export let placeholder: string = ''; export let placeholder: string = '';
const editor = new Editor(); const editor = new Editor();
editor.typeset.formats.add({
name: 'underline',
selector: 'span[data-weblah-brt=underline]',
styleSelector: '[style*="text-decoration:underline"], [style*="text-decoration: underline"]',
commands: (editor) => () => editor.toggleTextFormat({ underline: true }),
shortcuts: 'Mod+U',
render: (attributes, children) => h('span', { 'data-weblah-brt': 'underline' }, children)
});
editor.typeset.formats.add({
name: 'strikethrough',
selector: 's',
styleSelector:
'[style*="text-decoration:line-through"], [style*="text-decoration: line-through"]',
commands: (editor) => () => editor.toggleTextFormat({ strikethrough: true }),
shortcuts: 'Mod+Shift+X',
render: (attributes, children) => h('s', null, children)
});
delta = editor.getDelta(); delta = editor.getDelta();
editor.on('change', () => { editor.on('change', () => {
delta = editor.getDelta(); delta = editor.getDelta();

View file

@ -0,0 +1,28 @@
<script lang="ts">
import type { BlahRichText } from '$lib/richText';
import { tw } from '$lib/tw';
import RichTextSpan from './RichTextRenderer/RichTextSpan.svelte';
export let content: BlahRichText;
let className = '';
export { className as class };
</script>
<div class={tw('rich-text', className)}>
{#each content as block}
<p>
{#each block as span}
{#if typeof span === 'string'}
{#if span === ''}
<br />
{:else}
{span}
{/if}
{:else}
{@const [text, attributes] = span}
<RichTextSpan {text} {attributes} />
{/if}
{/each}
</p>
{/each}
</div>

View file

@ -0,0 +1,53 @@
<script lang="ts">
import type { BlahRichTextSpanAttributes } from '$lib/richText';
// From outside to inside, better align this with the RichTextInput
const renderOrder: (keyof BlahRichTextSpanAttributes)[] = [
'link',
'hashtag',
'b',
'i',
'm',
'u',
's'
];
const tagMap: Partial<Record<keyof BlahRichTextSpanAttributes, string>> = {
s: 's',
b: 'strong',
i: 'em',
m: 'code'
};
const dataAttributeBrtMap: Partial<Record<keyof BlahRichTextSpanAttributes, string>> = {
u: 'underline'
};
export let text: string;
export let attributes: BlahRichTextSpanAttributes;
export let attribute: keyof BlahRichTextSpanAttributes | '' = renderOrder[0];
const nextAttribute = attribute ? (renderOrder[renderOrder.indexOf(attribute) + 1] ?? '') : null;
</script>
{#if attribute === '' || !attributes[attribute]}
{text}
{:else if attribute === 'link'}
<a href={attributes.link} target="_blank">
<svelte:self {...$$props} attribute={nextAttribute} />
</a>
{:else if attribute === 'hashtag'}
<a href={`/search?q=${encodeURIComponent(text)}`}>
<svelte:self {...$$props} attribute={nextAttribute} />
</a>
{:else if tagMap[attribute]}
<svelte:element this={tagMap[attribute]}>
<svelte:self {...$$props} attribute={nextAttribute} />
</svelte:element>
{:else if dataAttributeBrtMap[attribute]}
<span data-weblah-brt={dataAttributeBrtMap[attribute]}>
<svelte:self {...$$props} attribute={nextAttribute} />
</span>
{:else}
<svelte:self {...$$props} attribute={nextAttribute} />
{/if}

63
src/lib/mock/messages.ts Normal file
View file

@ -0,0 +1,63 @@
import type { BlahRichText } from '$lib/richText';
import type { Message } from '$lib/types';
import { getRandomUser } from './users';
const messageContents: BlahRichText[] = [
[['更好的例子可能是link和hashtag不應該共存']],
[['理論上mono是可以BIUS的只是可能不太常見']],
[[['這個是一個link', { link: 'https://google.com' }]]],
[['這是一個', ['#hashtag', { hashtag: true }]]],
[
[
'這是一個',
['link', { link: 'https://google.com' }],
'和一個',
['#hashtag', { hashtag: true }]
]
],
[['可以, 反正我都手写了(']],
[['但我們也可以約定這種entity一定要有plain text fallback']],
[['這樣的話我們就可以在不支援的地方用plain text fallback']],
[['有可能有僅attribute的run']],
[
[
'我现在是约定 text piece 一定非空,也就是说空字符串应该是空数组(但能不能真的这么发言要打个问号)'
]
],
[['确实合并相邻的 run 就可以 canonicalize']],
[['比如我可能不希望往数据库里这么存 json ,还需要考虑检索之类的']],
[['是我蠢了']],
[['我觉得这个问题是因为我们没有定义好什么是一个 run']],
[['目标是如果前端/后端使用和协议不一致的格式存储的话 roundtrip 后一致']],
[
[
'你们这个 canonicalize 真的靠谱吗,,,感觉哪怕跑了一遍以后也会有多个不同 message 视觉效果相同的情况'
]
],
[['稍微有点烦(']],
[
[
[
'奥运会究竟该如何报道?中国媒体的表现真的这么不堪吗?',
{ link: 'https://www.bilibili.com/video/BV1TZ421L7hj/' }
]
],
[
'在视频中,可爸深入分析了中国媒体在巴黎奥运会中的表现,探讨了媒体人员的规模、采访类型、团队构成以及值得称赞与批评的采访案例。文章指出,尽管注册媒体工作者数量庞大,但真正的记者数量相对较少,且面临专业能力不足和流量逻辑冲击等问题。同时,作者强调了媒体在维护国家荣誉和传递奥运精神方面的重要性,呼吁媒体挖掘运动员故事,以建立观众与运动员之间的情感联系。这篇文章为了解当前中国体育媒体的现状和未来发展提供了深刻的见解和反思。'
],
[''],
['---'],
[''],
['非常好的视频。强烈推荐观看。']
],
[['pieces:[], attrs:[] 两者等长。然后判断合并就是 attrs 有没有相邻重复元素']]
];
export function createRandomMessage(): Message {
return {
id: Math.random().toString(),
sender: getRandomUser(),
content: messageContents[Math.floor(Math.random() * messageContents.length)],
date: new Date()
};
}

45
src/lib/mock/users.ts Normal file
View file

@ -0,0 +1,45 @@
import type { User } from '$lib/types';
const users = [
{ id: '1', name: 'Alice' },
{ id: '2', name: 'Bob' },
{ id: '3', name: 'Shibo Lyu' },
{ id: '4', name: 'oxa' },
{ id: '5', name: 'septs' },
{ id: '6', name: '柑橘 12%' },
{ id: '7', name: 'Richard Luo 🐱' },
{ id: '8', name: 'Inno Aiolos' },
{ id: '9', name: 'omo' },
{ id: '10', name: 'Chaoses Ib' },
{ id: '11', name: 'L' },
{ id: '12', name: 'FlyingSky' },
{ id: '13', name: 'Hexagram 喵|🕊️' },
{ id: '14', name: 'Cαmber Kirisame (ver. Rolling)' },
{ id: '15', name: 'lelenext 轩' },
{ id: '16', name: 'Gary です' },
{ id: '17', name: 'Criphc' },
{ id: '18', name: 'Grady Bing' },
{ id: '19', name: 'Thomas Chang' },
{ id: '20', name: '化 生' },
{ id: '21', name: 'Eddy' },
{ id: '22', name: 'Asuna 🍓' },
{ id: '23', name: '♡Eve ♡' },
{ id: '24', name: '你' },
{ id: '25', name: 'G°_ ヤン' },
{ id: '26', name: '302ye' },
{ id: '27', name: 'Chclt' },
{ id: '28', name: 'Kaito ゾ' },
{ id: '29', name: '🈚 only know copy' },
{ id: '30', name: '喵' },
{ id: '31', name: '🦑 没有 premium' },
{ id: '32', name: 'Hut' },
{ id: '33', name: '瑜琳 洛' },
{ id: '34', name: 'LUO Chestnut' },
{ id: '35', name: 'ka' },
{ id: '36', name: 'Cinnamon' },
{ id: '37', name: 'Yves Lelouch' }
] satisfies User[];
export function getRandomUser() {
return users[Math.floor(Math.random() * users.length)];
}

View file

@ -13,7 +13,7 @@ export const blahRichTextSpanAttributesSchema = z.object({
export type BlahRichTextSpanAttributes = z.input<typeof blahRichTextSpanAttributesSchema>; export type BlahRichTextSpanAttributes = z.input<typeof blahRichTextSpanAttributesSchema>;
export const blahRichTextSpanSchema = z.union([ export const blahRichTextSpanSchema = z.union([
z.tuple([z.string()]), z.string(),
z.tuple([z.string(), blahRichTextSpanAttributesSchema]) z.tuple([z.string(), blahRichTextSpanAttributesSchema])
]); ]);
export type BlahRichTextSpan = z.input<typeof blahRichTextSpanSchema>; export type BlahRichTextSpan = z.input<typeof blahRichTextSpanSchema>;
@ -42,7 +42,7 @@ function deltaAttributesToBlahRichTextSpanAttributes(
if (attributes.link) blahRichTextSpanAttributes.link = attributes.link; if (attributes.link) blahRichTextSpanAttributes.link = attributes.link;
if (attributes.underline) blahRichTextSpanAttributes.u = true; if (attributes.underline) blahRichTextSpanAttributes.u = true;
if (attributes.strike) blahRichTextSpanAttributes.s = true; if (attributes.strikethrough) blahRichTextSpanAttributes.s = true;
return isObjectEmpty(blahRichTextSpanAttributes) ? null : blahRichTextSpanAttributes; return isObjectEmpty(blahRichTextSpanAttributes) ? null : blahRichTextSpanAttributes;
} }
@ -57,12 +57,12 @@ export function deltaToBlahRichText(delta: Delta): BlahRichText {
const attributes = deltaAttributesToBlahRichTextSpanAttributes(op.attributes); const attributes = deltaAttributesToBlahRichTextSpanAttributes(op.attributes);
const line = lines.shift(); const line = lines.shift();
block.push(attributes ? [line, attributes] : [line]); block.push(attributes ? [line, attributes] : line);
for (const line of lines) { for (const line of lines) {
blocks.push(block); blocks.push(block);
block = []; block = [];
block.push(attributes ? [line, attributes] : [line]); block.push(attributes ? [line, attributes] : line);
} }
} }

View file

@ -1 +1,3 @@
export * from './chat'; export * from './chat';
export * from './message';
export * from './user';

8
src/lib/types/message.ts Normal file
View file

@ -0,0 +1,8 @@
import type { BlahRichText } from '$lib/richText';
export type Message = {
id: string;
sender: { id: string; name: string };
content: BlahRichText;
date: Date;
};

5
src/lib/types/user.ts Normal file
View file

@ -0,0 +1,5 @@
export type User = {
id: string;
name: string;
profilePictureUrl?: string;
};

View file

@ -1,8 +1,24 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/stores';
import BgPattern from '$lib/components/BgPattern.svelte'; import BgPattern from '$lib/components/BgPattern.svelte';
import { createRandomMessage } from '$lib/mock/messages';
import type { Message } from '$lib/types';
import { onMount } from 'svelte';
import ChatHeader from './ChatHeader.svelte'; import ChatHeader from './ChatHeader.svelte';
import ChatHistory from './ChatHistory.svelte';
import ChatInput from './ChatInput.svelte'; import ChatInput from './ChatInput.svelte';
let messages: Message[] = Array.from({ length: 10 }).map(createRandomMessage);
// onMount(() => {
// const interval = setInterval(
// () => {
// messages = [...messages, createRandomMessage()];
// },
// 3000 + Math.random() * 10000
// );
// return () => clearInterval(interval);
// });
</script> </script>
<div class="flex h-full w-full flex-col justify-stretch"> <div class="flex h-full w-full flex-col justify-stretch">
@ -10,6 +26,8 @@
chat={{ id: 'blah', name: 'Blah IM Interest Group', type: 'group' }} chat={{ id: 'blah', name: 'Blah IM Interest Group', type: 'group' }}
outsideUnreadCount={263723} outsideUnreadCount={263723}
/> />
<BgPattern class="flex-1" pattern="charlieBrown"></BgPattern> <BgPattern class="flex-1" pattern="charlieBrown">
<ChatHistory {messages} mySenderId={'_send'} />
</BgPattern>
<ChatInput /> <ChatInput />
</div> </div>

View file

@ -0,0 +1,17 @@
<script lang="ts">
import { VList } from 'virtua/svelte';
import type { Message } from '$lib/types';
import ChatMessage from './ChatMessage.svelte';
export let messages: Message[] = [];
export let mySenderId: string;
let ref: VList<Message>;
$: ref?.scrollToIndex(messages.length - 1, { align: 'end', smooth: true });
</script>
<VList data={messages} let:item={message} class="size-full pt-2" bind:this={ref}>
<ChatMessage {message} isMyself={mySenderId === message.sender.id} />
</VList>

View file

@ -0,0 +1,26 @@
<script lang="ts">
import RichTextRenderer from '$lib/components/RichTextRenderer.svelte';
import { tw } from '$lib/tw';
import type { Message } from '$lib/types';
import { AvatarBeam } from 'svelte-boring-avatars';
export let message: Message;
export let isMyself: boolean;
</script>
<div class={tw('mb-2 flex items-end gap-2 px-2', isMyself && 'flex-row-reverse')}>
<div>
<AvatarBeam size={30} name={message.sender.name} />
</div>
<div class="relative flex-1">
<div
class="
relative inline-block max-w-[50%] rounded-2xl bg-sb-primary px-3 py-2 shadow-sm ring-1 ring-ss-secondary
before:absolute before:-bottom-[2px] before:-start-5 before:z-0 before:box-content before:h-6 before:w-5 before:rounded-ee-[16px_12px] before:border-e-[10px] before:border-sb-primary before:text-ss-secondary before:drop-shadow-[-1px_0]
after:absolute after:-bottom-[2px] after:-start-5 after:-z-10 after:box-content after:h-6 after:w-5 after:rounded-ee-[16px_12px] after:border-e-[10px] after:text-ss-secondary after:drop-shadow-[0_1px]
"
>
<RichTextRenderer content={message.content} class="z-10" />
</div>
</div>
</div>