mirror of
				https://github.com/Blah-IM/Weblah.git
				synced 2025-10-31 10:01:37 +00:00 
			
		
		
		
	refactor: render messages in sections
This commit is contained in:
		
							parent
							
								
									4be1380d69
								
							
						
					
					
						commit
						c80b2cbf10
					
				
					 8 changed files with 129 additions and 36 deletions
				
			
		|  | @ -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 }; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
| 	)} | 	)} | ||||||
| > | > | ||||||
|  |  | ||||||
|  | @ -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); | ||||||
|  | 	} | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | @ -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 | ||||||
|  |  | ||||||
|  | @ -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="/"> | ||||||
|  |  | ||||||
|  | @ -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> | ||||||
|  |  | ||||||
|  | @ -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' | ||||||
| 			)} | 			)} | ||||||
|  |  | ||||||
|  | @ -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 /> | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 Shibo Lyu
						Shibo Lyu