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 { 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,16 +21,18 @@
|
||||||
<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]`,
|
||||||
// ::before: Fill of chat bubble tail
|
showBubbleTail && [
|
||||||
'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: Fill of chat bubble tail
|
||||||
isMyself
|
'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:-end-5 before:rounded-es-[16px_12px] before:border-s-[10px] before:drop-shadow-[1px_0]'
|
isMyself
|
||||||
: `before:-start-5 before:rounded-ee-[16px_12px] before:border-e-[10px] before:drop-shadow-[-1px_0]`,
|
? 'before:-end-5 before:rounded-es-[16px_12px] before:border-s-[10px] before:drop-shadow-[1px_0]'
|
||||||
// ::after: Stroke of chat bubble tail
|
: `before:-start-5 before:rounded-ee-[16px_12px] before:border-e-[10px] before:drop-shadow-[-1px_0]`,
|
||||||
'after:absolute after:-bottom-[1px] after:-z-10 after:box-content after:h-6 after:w-5 after:text-[--weblah-chat-bubble-stroke]',
|
// ::after: Stroke of chat bubble tail
|
||||||
isMyself
|
'after:absolute after:-bottom-[1px] after:-z-10 after:box-content after:h-6 after:w-5 after:text-[--weblah-chat-bubble-stroke]',
|
||||||
? 'after:-end-5 after:rounded-es-[16px_12px] after:border-s-[10px] after:drop-shadow-[0_1px]'
|
isMyself
|
||||||
: `after:-start-5 after:rounded-ee-[16px_12px] after:border-e-[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]`
|
||||||
|
],
|
||||||
'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
Reference in a new issue