refactor: render messages in sections

This commit is contained in:
Shibo Lyu 2024-09-04 12:59:31 +08:00
parent 4be1380d69
commit c80b2cbf10
8 changed files with 129 additions and 36 deletions

View file

@ -1,7 +1,16 @@
import { readable, type Readable } from 'svelte/store'; import { derived, readable, type Readable } from 'svelte/store';
import type { BlahChatServerConnection } from './blah/connection/chatServer'; import type { BlahChatServerConnection } from './blah/connection/chatServer';
import type { BlahRichText } from './richText'; import type { BlahRichText } from './richText';
import { messageFromBlah, type Chat, type Message } from './types'; import { messageFromBlah, type Chat, type Message, type User } from './types';
const MAX_MESSAGES_PER_SECTION = 10;
const SHOW_TIME_AFTER_SILENCE = 30 * 60 * 1000;
export type MessageSection = {
sender?: User;
messages: Message[];
date?: Date;
};
export function useChat( export function useChat(
server: BlahChatServerConnection, server: BlahChatServerConnection,
@ -9,6 +18,7 @@ export function useChat(
): { ): {
info: Readable<Chat>; info: Readable<Chat>;
messages: Readable<Message[]>; messages: Readable<Message[]>;
sectionedMessages: Readable<MessageSection[]>;
sendMessage: (brt: BlahRichText) => Promise<void>; sendMessage: (brt: BlahRichText) => Promise<void>;
} { } {
const info = readable<Chat>( const info = readable<Chat>(
@ -37,9 +47,41 @@ export function useChat(
return unsubscribe; return unsubscribe;
}); });
const sectionedMessages = derived([messages], ([messages]) => {
const sections: MessageSection[] = [];
let lastMessage: Message | undefined = messages[0];
let currentSection: MessageSection = {
messages: [],
sender: lastMessage?.sender,
date: lastMessage?.date
};
for (const message of messages) {
const reachesMaxMessages = currentSection.messages.length >= MAX_MESSAGES_PER_SECTION;
const senderChanged = message.sender.id !== lastMessage.sender.id;
const silentForTooLong =
message.date.getTime() - lastMessage.date.getTime() > SHOW_TIME_AFTER_SILENCE;
if (reachesMaxMessages || senderChanged || silentForTooLong) {
if (currentSection.messages.length > 0) {
sections.push(currentSection);
}
currentSection = { messages: [], sender: message.sender };
if (silentForTooLong) currentSection.date = message.date;
}
currentSection.messages.push(message);
lastMessage = message;
}
sections.push(currentSection);
console.log(sections);
return sections;
});
const sendMessage = async (brt: BlahRichText) => { const sendMessage = async (brt: BlahRichText) => {
await server.sendMessage(chatId, brt); await server.sendMessage(chatId, brt);
}; };
return { info, messages, sendMessage }; return { info, messages, sectionedMessages, sendMessage };
} }

View file

@ -7,7 +7,7 @@
<div <div
class={tw( class={tw(
'backdrop mx-4 cursor-default rounded-full px-2 py-0.5 text-center backdrop-blur-sm', 'backdrop mx-4 inline-block cursor-default rounded-full px-2 py-0.5 text-center text-sm text-sf-secondary backdrop-blur-sm',
className className
)} )}
> >

View file

@ -7,19 +7,29 @@ export function formatUnreadCount(count: number) {
return unreadCountFormatter.format(count); return unreadCountFormatter.format(count);
} }
const sameDayFormatter = new Intl.DateTimeFormat('default', { const timeOptions: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' };
hour: '2-digit',
minute: '2-digit' const sameDayFormatter = new Intl.DateTimeFormat('default', timeOptions);
});
const sameYearFormatter = new Intl.DateTimeFormat('default', { const sameYearFormatter = new Intl.DateTimeFormat('default', {
month: 'short', month: 'short',
day: 'numeric' day: 'numeric'
}); });
const sameYearWithTimeFormatter = new Intl.DateTimeFormat('default', {
month: 'short',
day: 'numeric',
...timeOptions
});
const otherYearFormatter = new Intl.DateTimeFormat('default', { const otherYearFormatter = new Intl.DateTimeFormat('default', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric' day: 'numeric'
}); });
const otherYearWithTimeFormatter = new Intl.DateTimeFormat('default', {
year: 'numeric',
month: 'short',
day: 'numeric',
...timeOptions
});
const fullDateTimeFormatter = new Intl.DateTimeFormat('default', { const fullDateTimeFormatter = new Intl.DateTimeFormat('default', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
@ -29,9 +39,7 @@ const fullDateTimeFormatter = new Intl.DateTimeFormat('default', {
second: '2-digit' second: '2-digit'
}); });
export const formatMessageDate = (date: Date, full: boolean = false) => { export const formatMessageDate = (date: Date) => {
if (full) return fullDateTimeFormatter.format(date);
const now = new Date(); const now = new Date();
if (date.getFullYear() === now.getFullYear()) { if (date.getFullYear() === now.getFullYear()) {
if (date.getMonth() === now.getMonth() && date.getDate() === now.getDate()) { if (date.getMonth() === now.getMonth() && date.getDate() === now.getDate()) {
@ -43,3 +51,19 @@ export const formatMessageDate = (date: Date, full: boolean = false) => {
return otherYearFormatter.format(date); return otherYearFormatter.format(date);
} }
}; };
export const formatFullMessageDate = (date: Date) => {
return fullDateTimeFormatter.format(date);
};
export const formatMessageSectionDate = (date: Date) => {
const now = new Date();
if (date.getFullYear() === now.getFullYear()) {
if (date.getMonth() === now.getMonth() && date.getDate() === now.getDate()) {
return sameDayFormatter.format(date);
} else {
return sameYearWithTimeFormatter.format(date);
}
} else {
return otherYearWithTimeFormatter.format(date);
}
};

View file

@ -27,8 +27,8 @@
<div class="flex h-full w-full flex-col items-center justify-center"> <div class="flex h-full w-full flex-col items-center justify-center">
{#if server} {#if server}
{@const { info, messages, sendMessage } = useChat(server, roomId)} {@const { info, sectionedMessages, sendMessage } = useChat(server, roomId)}
<ChatPage {info} {messages} on:sendMessage={(e) => sendMessage(e.detail)} /> <ChatPage {info} {sectionedMessages} on:sendMessage={(e) => sendMessage(e.detail)} />
{:else} {:else}
<ServiceMessage> <ServiceMessage>
To view this chat, you need to connect to chat server To view this chat, you need to connect to chat server

View file

@ -31,7 +31,7 @@
<h3 class="truncate text-sm font-semibold">{info.name}</h3> <h3 class="truncate text-sm font-semibold">{info.name}</h3>
</div> </div>
<div class="sm:order-1"> <div class="sm:order-1">
<AvatarBeam size={30} name={info.id} /> <AvatarBeam size={32} name={info.id} />
</div> </div>
<a class="absolute inset-y-0 start-0 hidden w-2 cursor-default sm:block" href="/"> <a class="absolute inset-y-0 start-0 hidden w-2 cursor-default sm:block" href="/">

View file

@ -4,20 +4,47 @@
import type { Message } from '$lib/types'; import type { Message } from '$lib/types';
import ChatMessage from './ChatMessage.svelte'; import ChatMessage from './ChatMessage.svelte';
import { tick } from 'svelte'; import { tick } from 'svelte';
import type { MessageSection } from '$lib/chat';
import { tw } from '$lib/tw';
import { AvatarBeam } from 'svelte-boring-avatars';
import ServiceMessage from '$lib/components/ServiceMessage.svelte';
import { formatMessageDate, formatMessageSectionDate } from '$lib/formatters';
export let messages: Message[] = []; export let sectionedMessages: MessageSection[] = [];
export let mySenderId: string; export let mySenderId: string;
let ref: VList<Message> | undefined; let ref: VList<MessageSection> | undefined;
async function scrollToIndex(index: number, smooth = true) { async function scrollToIndex(index: number, smooth = true) {
await tick(); await tick();
ref?.scrollToIndex(index, { align: 'end', smooth }); ref?.scrollToIndex(index, { align: 'end', smooth });
} }
$: scrollToIndex(messages.length - 1); $: scrollToIndex(sectionedMessages.length - 1);
</script> </script>
<VList data={messages} let:item={message} class="size-full pt-2" bind:this={ref}> <VList data={sectionedMessages} let:item={messageSection} class="size-full pt-2" bind:this={ref}>
<ChatMessage {message} isMyself={mySenderId === message.sender.id} /> {@const isMyself = mySenderId === messageSection.sender?.id}
<div>
{#if messageSection.date}
<div class="py-0.5 text-center">
<ServiceMessage>{formatMessageSectionDate(messageSection.date)}</ServiceMessage>
</div>
{/if}
<div class={tw('flex w-full items-end px-2', isMyself && 'flex-row-reverse')}>
<div class="sticky bottom-1.5 mb-1.5 mt-1 w-8">
<AvatarBeam size={32} name={messageSection.sender?.id} />
</div>
<div class="flex-1">
{#each messageSection.messages as message, idx}
<ChatMessage
{message}
{isMyself}
showBubbleTail={messageSection.messages.length - 1 === idx}
/>
{/each}
</div>
</div>
</div>
</VList> </VList>

View file

@ -2,16 +2,13 @@
import RichTextRenderer from '$lib/components/RichTextRenderer.svelte'; import RichTextRenderer from '$lib/components/RichTextRenderer.svelte';
import { tw } from '$lib/tw'; import { tw } from '$lib/tw';
import type { Message } from '$lib/types'; import type { Message } from '$lib/types';
import { AvatarBeam } from 'svelte-boring-avatars';
export let message: Message; export let message: Message;
export let showBubbleTail: boolean = true;
export let isMyself: boolean; export let isMyself: boolean;
</script> </script>
<div class={tw('mb-2 flex items-end gap-2 px-2', isMyself && 'flex-row-reverse')}> <div class={tw('mb-1.5 flex items-end gap-2 px-2', isMyself && 'flex-row-reverse')}>
<div>
<AvatarBeam size={30} name={message.sender.name} />
</div>
<div <div
class={tw( class={tw(
'relative flex-1', 'relative flex-1',
@ -24,6 +21,7 @@
<div <div
class={tw( class={tw(
`relative inline-block max-w-[85%] rounded-2xl bg-[--weblah-chat-bubble-bg] shadow-sm ring-1 ring-[--weblah-chat-bubble-stroke]`, `relative inline-block max-w-[85%] rounded-2xl bg-[--weblah-chat-bubble-bg] shadow-sm ring-1 ring-[--weblah-chat-bubble-stroke]`,
showBubbleTail && [
// ::before: Fill of chat bubble tail // ::before: Fill of chat bubble tail
'before:absolute before:-bottom-[1px] before:box-content before:h-6 before:w-5 before:border-[--weblah-chat-bubble-bg] before:text-[--weblah-chat-bubble-stroke]', 'before:absolute before:-bottom-[1px] before:box-content before:h-6 before:w-5 before:border-[--weblah-chat-bubble-bg] before:text-[--weblah-chat-bubble-stroke]',
isMyself isMyself
@ -33,7 +31,8 @@
'after:absolute after:-bottom-[1px] after:-z-10 after:box-content after:h-6 after:w-5 after:text-[--weblah-chat-bubble-stroke]', 'after:absolute after:-bottom-[1px] after:-z-10 after:box-content after:h-6 after:w-5 after:text-[--weblah-chat-bubble-stroke]',
isMyself isMyself
? 'after:-end-5 after:rounded-es-[16px_12px] after:border-s-[10px] after:drop-shadow-[0_1px]' ? 'after:-end-5 after:rounded-es-[16px_12px] after:border-s-[10px] after:drop-shadow-[0_1px]'
: `after:-start-5 after:rounded-ee-[16px_12px] after:border-e-[10px] after:drop-shadow-[0_1px]`, : `after:-start-5 after:rounded-ee-[16px_12px] after:border-e-[10px] after:drop-shadow-[0_1px]`
],
'sm:max-w-[70%] lg:max-w-[50%]', 'sm:max-w-[70%] lg:max-w-[50%]',
isMyself && 'text-start' isMyself && 'text-start'
)} )}

View file

@ -9,9 +9,10 @@
import ChatHistory from './ChatHistory.svelte'; import ChatHistory from './ChatHistory.svelte';
import ChatInput from './ChatInput.svelte'; import ChatInput from './ChatInput.svelte';
import type { BlahRichText } from '$lib/richText'; import type { BlahRichText } from '$lib/richText';
import type { MessageSection } from '$lib/chat';
export let info: Readable<Chat>; export let info: Readable<Chat>;
export let messages: Readable<Message[]>; export let sectionedMessages: Readable<MessageSection[]>;
interface $$Events { interface $$Events {
sendMessage: CustomEvent<BlahRichText>; sendMessage: CustomEvent<BlahRichText>;
@ -20,6 +21,6 @@
<ChatHeader info={$info} outsideUnreadCount={263723} /> <ChatHeader info={$info} outsideUnreadCount={263723} />
<BgPattern class="w-full flex-1" pattern="charlieBrown"> <BgPattern class="w-full flex-1" pattern="charlieBrown">
<ChatHistory messages={$messages} mySenderId={$currentKeyPair?.id} /> <ChatHistory sectionedMessages={$sectionedMessages} mySenderId={$currentKeyPair?.id} />
</BgPattern> </BgPattern>
<ChatInput on:sendMessage /> <ChatInput on:sendMessage />