mirror of
https://github.com/Blah-IM/Weblah.git
synced 2025-05-01 00:31:08 +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 { 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(
|
||||
server: BlahChatServerConnection,
|
||||
|
@ -9,6 +18,7 @@ export function useChat(
|
|||
): {
|
||||
info: Readable<Chat>;
|
||||
messages: Readable<Message[]>;
|
||||
sectionedMessages: Readable<MessageSection[]>;
|
||||
sendMessage: (brt: BlahRichText) => Promise<void>;
|
||||
} {
|
||||
const info = readable<Chat>(
|
||||
|
@ -37,9 +47,41 @@ export function useChat(
|
|||
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) => {
|
||||
await server.sendMessage(chatId, brt);
|
||||
};
|
||||
|
||||
return { info, messages, sendMessage };
|
||||
return { info, messages, sectionedMessages, sendMessage };
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
<div
|
||||
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
|
||||
)}
|
||||
>
|
||||
|
|
|
@ -7,19 +7,29 @@ export function formatUnreadCount(count: number) {
|
|||
return unreadCountFormatter.format(count);
|
||||
}
|
||||
|
||||
const sameDayFormatter = new Intl.DateTimeFormat('default', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
const timeOptions: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' };
|
||||
|
||||
const sameDayFormatter = new Intl.DateTimeFormat('default', timeOptions);
|
||||
const sameYearFormatter = new Intl.DateTimeFormat('default', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
const sameYearWithTimeFormatter = new Intl.DateTimeFormat('default', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
...timeOptions
|
||||
});
|
||||
const otherYearFormatter = new Intl.DateTimeFormat('default', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
const otherYearWithTimeFormatter = new Intl.DateTimeFormat('default', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
...timeOptions
|
||||
});
|
||||
const fullDateTimeFormatter = new Intl.DateTimeFormat('default', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
|
@ -29,9 +39,7 @@ const fullDateTimeFormatter = new Intl.DateTimeFormat('default', {
|
|||
second: '2-digit'
|
||||
});
|
||||
|
||||
export const formatMessageDate = (date: Date, full: boolean = false) => {
|
||||
if (full) return fullDateTimeFormatter.format(date);
|
||||
|
||||
export const formatMessageDate = (date: Date) => {
|
||||
const now = new Date();
|
||||
if (date.getFullYear() === now.getFullYear()) {
|
||||
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);
|
||||
}
|
||||
};
|
||||
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">
|
||||
{#if server}
|
||||
{@const { info, messages, sendMessage } = useChat(server, roomId)}
|
||||
<ChatPage {info} {messages} on:sendMessage={(e) => sendMessage(e.detail)} />
|
||||
{@const { info, sectionedMessages, sendMessage } = useChat(server, roomId)}
|
||||
<ChatPage {info} {sectionedMessages} on:sendMessage={(e) => sendMessage(e.detail)} />
|
||||
{:else}
|
||||
<ServiceMessage>
|
||||
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>
|
||||
</div>
|
||||
<div class="sm:order-1">
|
||||
<AvatarBeam size={30} name={info.id} />
|
||||
<AvatarBeam size={32} name={info.id} />
|
||||
</div>
|
||||
|
||||
<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 ChatMessage from './ChatMessage.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;
|
||||
|
||||
let ref: VList<Message> | undefined;
|
||||
let ref: VList<MessageSection> | undefined;
|
||||
|
||||
async function scrollToIndex(index: number, smooth = true) {
|
||||
await tick();
|
||||
ref?.scrollToIndex(index, { align: 'end', smooth });
|
||||
}
|
||||
|
||||
$: scrollToIndex(messages.length - 1);
|
||||
$: scrollToIndex(sectionedMessages.length - 1);
|
||||
</script>
|
||||
|
||||
<VList data={messages} let:item={message} class="size-full pt-2" bind:this={ref}>
|
||||
<ChatMessage {message} isMyself={mySenderId === message.sender.id} />
|
||||
<VList data={sectionedMessages} let:item={messageSection} class="size-full pt-2" bind:this={ref}>
|
||||
{@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>
|
||||
|
|
|
@ -2,16 +2,13 @@
|
|||
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 showBubbleTail: boolean = true;
|
||||
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={tw('mb-1.5 flex items-end gap-2 px-2', isMyself && 'flex-row-reverse')}>
|
||||
<div
|
||||
class={tw(
|
||||
'relative flex-1',
|
||||
|
@ -24,6 +21,7 @@
|
|||
<div
|
||||
class={tw(
|
||||
`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: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
|
||||
|
@ -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]',
|
||||
isMyself
|
||||
? '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%]',
|
||||
isMyself && 'text-start'
|
||||
)}
|
||||
|
|
|
@ -9,9 +9,10 @@
|
|||
import ChatHistory from './ChatHistory.svelte';
|
||||
import ChatInput from './ChatInput.svelte';
|
||||
import type { BlahRichText } from '$lib/richText';
|
||||
import type { MessageSection } from '$lib/chat';
|
||||
|
||||
export let info: Readable<Chat>;
|
||||
export let messages: Readable<Message[]>;
|
||||
export let sectionedMessages: Readable<MessageSection[]>;
|
||||
|
||||
interface $$Events {
|
||||
sendMessage: CustomEvent<BlahRichText>;
|
||||
|
@ -20,6 +21,6 @@
|
|||
|
||||
<ChatHeader info={$info} outsideUnreadCount={263723} />
|
||||
<BgPattern class="w-full flex-1" pattern="charlieBrown">
|
||||
<ChatHistory messages={$messages} mySenderId={$currentKeyPair?.id} />
|
||||
<ChatHistory sectionedMessages={$sectionedMessages} mySenderId={$currentKeyPair?.id} />
|
||||
</BgPattern>
|
||||
<ChatInput on:sendMessage />
|
||||
|
|
Loading…
Add table
Reference in a new issue