298 lines
8.7 KiB
Svelte
298 lines
8.7 KiB
Svelte
<script lang="ts">
|
|
import { getContext } from 'svelte';
|
|
import type { Writable } from 'svelte/store';
|
|
|
|
type ChatListI18n = {
|
|
t: (key: string, options?: Record<string, unknown>) => string;
|
|
};
|
|
|
|
const i18n: Writable<ChatListI18n> = getContext('i18n');
|
|
|
|
import dayjs from 'dayjs';
|
|
import localizedFormat from 'dayjs/plugin/localizedFormat';
|
|
import { getTimeRange } from '$lib/utils';
|
|
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
|
|
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
|
|
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
|
|
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
|
|
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
|
import Spinner from '$lib/components/common/Spinner.svelte';
|
|
import { chatId, socket } from '$lib/stores';
|
|
|
|
dayjs.extend(localizedFormat);
|
|
|
|
type ChatListItem = {
|
|
id: string;
|
|
title?: string;
|
|
updated_at?: number | null;
|
|
created_at?: number | null;
|
|
last_read_at?: number | null;
|
|
active?: boolean;
|
|
time_range?: string;
|
|
user_id?: string;
|
|
owner_name?: string;
|
|
[key: string]: unknown;
|
|
};
|
|
|
|
export let chats: ChatListItem[] = [];
|
|
|
|
export let chatListLoading = false;
|
|
export let showOwnerInfo = false;
|
|
export let page = 1;
|
|
export let total = 0;
|
|
export let perPage = 10;
|
|
export let orderBy: 'title' | 'updated_at' = 'updated_at';
|
|
export let direction: 'asc' | 'desc' = 'desc';
|
|
export let onPageChange: (page: number) => void | Promise<void> = () => {};
|
|
export let onSort: (key: 'title' | 'updated_at') => void | Promise<void> = () => {};
|
|
|
|
let chatList: ChatListItem[] | null = null;
|
|
let totalPages = 1;
|
|
let pages: (number | 'ellipsis')[] = [];
|
|
|
|
const init = async () => {
|
|
if (chats.length === 0) {
|
|
chatList = [];
|
|
} else {
|
|
chatList = chats.map((chat) => ({
|
|
...chat,
|
|
time_range: getTimeRange(chat.updated_at)
|
|
}));
|
|
}
|
|
};
|
|
|
|
const setSortKey = (key: 'title' | 'updated_at') => {
|
|
onSort(key);
|
|
};
|
|
|
|
const markChatRead = (chat: ChatListItem, unread: boolean) => {
|
|
if (!unread) {
|
|
return;
|
|
}
|
|
|
|
const lastReadAt = Date.now() / 1000;
|
|
chatList = (chatList ?? []).map((item) =>
|
|
item.id === chat.id ? { ...item, last_read_at: lastReadAt } : item
|
|
);
|
|
|
|
$socket?.emit('events:chat', {
|
|
chat_id: chat.id,
|
|
data: { type: 'last_read_at' }
|
|
});
|
|
};
|
|
|
|
const buildPages = (currentPage: number, pageCount: number): (number | 'ellipsis')[] => {
|
|
if (pageCount <= 7) {
|
|
return Array.from({ length: pageCount }, (_, i) => i + 1);
|
|
}
|
|
|
|
const items: (number | 'ellipsis')[] = [1];
|
|
if (currentPage > 3) {
|
|
items.push('ellipsis');
|
|
}
|
|
|
|
const start = Math.max(2, currentPage - 1);
|
|
const end = Math.min(pageCount - 1, currentPage + 1);
|
|
for (let i = start; i <= end; i++) {
|
|
items.push(i);
|
|
}
|
|
|
|
if (currentPage < pageCount - 2) {
|
|
items.push('ellipsis');
|
|
}
|
|
items.push(pageCount);
|
|
|
|
return items;
|
|
};
|
|
|
|
$: if (chats) {
|
|
init();
|
|
}
|
|
|
|
$: totalPages = Math.max(1, Math.ceil(total / perPage));
|
|
$: pages = buildPages(page, totalPages);
|
|
</script>
|
|
|
|
{#if chatList}
|
|
{#if chatList.length > 0}
|
|
<div class="flex text-xs font-normal mb-1 items-center -mr-0.5">
|
|
<button
|
|
class="px-1.5 py-1 cursor-pointer select-none basis-3/5"
|
|
on:click={() => setSortKey('title')}
|
|
>
|
|
<div class="flex gap-1.5 items-center">
|
|
{$i18n.t('Title')}
|
|
|
|
{#if orderBy === 'title'}
|
|
<span class="font-normal"
|
|
>{#if direction === 'asc'}
|
|
<ChevronUp className="size-2" />
|
|
{:else}
|
|
<ChevronDown className="size-2" />
|
|
{/if}
|
|
</span>
|
|
{:else}
|
|
<span class="invisible">
|
|
<ChevronUp className="size-2" />
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
</button>
|
|
<button
|
|
class="px-1.5 py-1 cursor-pointer select-none hidden sm:flex sm:basis-2/5 justify-end"
|
|
on:click={() => setSortKey('updated_at')}
|
|
>
|
|
<div class="flex gap-1.5 items-center">
|
|
{$i18n.t('Updated at')}
|
|
|
|
{#if orderBy === 'updated_at'}
|
|
<span class="font-normal"
|
|
>{#if direction === 'asc'}
|
|
<ChevronUp className="size-2" />
|
|
{:else}
|
|
<ChevronDown className="size-2" />
|
|
{/if}
|
|
</span>
|
|
{:else}
|
|
<span class="invisible">
|
|
<ChevronUp className="size-2" />
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="text-left text-sm w-full mb-3">
|
|
{#if chatList.length === 0}
|
|
<div
|
|
class="text-xs text-gray-500 dark:text-gray-400 text-center px-5 min-h-20 w-full h-full flex justify-center items-center"
|
|
>
|
|
{$i18n.t('No chats found')}
|
|
</div>
|
|
{/if}
|
|
|
|
{#each chatList as chat, idx (chat.id)}
|
|
{@const unread =
|
|
chat.id !== $chatId &&
|
|
!chat.active &&
|
|
(chat.last_read_at == null ||
|
|
(chat.updated_at != null && chat.updated_at > chat.last_read_at))}
|
|
{#if (idx === 0 || (idx > 0 && chat.time_range !== chatList[idx - 1].time_range)) && chat?.time_range}
|
|
<div
|
|
class="w-full text-xs text-gray-500 dark:text-gray-500 font-normal {idx === 0
|
|
? ''
|
|
: 'pt-5'} pb-2 px-2"
|
|
>
|
|
{$i18n.t(chat.time_range)}
|
|
<!-- localisation keys for time_range to be recognized from the i18next parser (so they don't get automatically removed):
|
|
{$i18n.t('Today')}
|
|
{$i18n.t('Yesterday')}
|
|
{$i18n.t('Previous 7 days')}
|
|
{$i18n.t('Previous 30 days')}
|
|
{$i18n.t('January')}
|
|
{$i18n.t('February')}
|
|
{$i18n.t('March')}
|
|
{$i18n.t('April')}
|
|
{$i18n.t('May')}
|
|
{$i18n.t('June')}
|
|
{$i18n.t('July')}
|
|
{$i18n.t('August')}
|
|
{$i18n.t('September')}
|
|
{$i18n.t('October')}
|
|
{$i18n.t('November')}
|
|
{$i18n.t('December')}
|
|
-->
|
|
</div>
|
|
{/if}
|
|
|
|
<a
|
|
class=" w-full flex justify-between items-center rounded-lg text-sm py-2 px-3 hover:bg-gray-50 dark:hover:bg-gray-850"
|
|
draggable="false"
|
|
href={`/c/${chat.id}`}
|
|
on:click={() => markChatRead(chat, unread)}
|
|
>
|
|
<div class="flex min-w-0 items-center w-full sm:basis-3/5">
|
|
{#if chat.active}
|
|
<div class="shrink-0 self-center pr-2">
|
|
<Spinner className="size-3" />
|
|
</div>
|
|
{:else if unread}
|
|
<div class="shrink-0 self-center pr-2.5 flex transition-opacity duration-300">
|
|
<div class="size-1.5 bg-sky-500 rounded-full"></div>
|
|
</div>
|
|
{/if}
|
|
|
|
<div
|
|
class="text-ellipsis line-clamp-1 min-w-0 {unread
|
|
? 'font-normal text-gray-800 dark:text-gray-200'
|
|
: ''}"
|
|
>
|
|
{chat?.title}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="hidden sm:flex sm:basis-2/5 items-center justify-end gap-2">
|
|
<div class=" text-gray-500 dark:text-gray-400 text-xs">
|
|
{dayjs(chat?.updated_at * 1000).calendar()}
|
|
</div>
|
|
|
|
{#if showOwnerInfo && chat.user_id && chat.owner_name}
|
|
<Tooltip content={chat.owner_name}>
|
|
<img
|
|
src="/api/v1/users/{chat.user_id}/profile/image"
|
|
alt=""
|
|
class="size-4 rounded-full shrink-0 object-cover"
|
|
/>
|
|
</Tooltip>
|
|
{/if}
|
|
</div>
|
|
</a>
|
|
{/each}
|
|
|
|
{#if totalPages > 1}
|
|
<div class="flex items-center justify-center gap-1.5 pt-2">
|
|
<button
|
|
class="inline-flex size-7 items-center justify-center rounded-lg text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 disabled:cursor-not-allowed disabled:opacity-25 dark:text-gray-500 dark:hover:bg-gray-850 dark:hover:text-gray-300"
|
|
disabled={chatListLoading || page <= 1}
|
|
aria-label={$i18n.t('Previous page')}
|
|
on:click={() => onPageChange(page - 1)}
|
|
>
|
|
<ChevronLeft className="size-3.5" strokeWidth="2" />
|
|
</button>
|
|
|
|
{#each pages as item, index (item === 'ellipsis' ? `ellipsis-${index}` : item)}
|
|
{#if item === 'ellipsis'}
|
|
<span
|
|
class="inline-flex size-7 items-center justify-center text-xs text-gray-400 select-none dark:text-gray-600"
|
|
>
|
|
...
|
|
</span>
|
|
{:else}
|
|
<button
|
|
class="inline-flex size-7 items-center justify-center rounded-lg text-xs font-medium transition hover:bg-gray-50 hover:text-gray-700 dark:hover:bg-gray-850 dark:hover:text-gray-300 {item ===
|
|
page
|
|
? 'bg-gray-50 text-gray-800 dark:bg-gray-850 dark:text-gray-100'
|
|
: 'text-gray-500 dark:text-gray-500'}"
|
|
aria-label={`Page ${item}`}
|
|
aria-current={item === page ? 'page' : undefined}
|
|
on:click={() => onPageChange(item)}
|
|
>
|
|
{item}
|
|
</button>
|
|
{/if}
|
|
{/each}
|
|
|
|
<button
|
|
class="inline-flex size-7 items-center justify-center rounded-lg text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 disabled:cursor-not-allowed disabled:opacity-25 dark:text-gray-500 dark:hover:bg-gray-850 dark:hover:text-gray-300"
|
|
disabled={chatListLoading || page >= totalPages}
|
|
aria-label={$i18n.t('Next page')}
|
|
on:click={() => onPageChange(page + 1)}
|
|
>
|
|
<ChevronRight className="size-3.5" strokeWidth="2" />
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|