feat: web UI chat render, panels, presence + analytics

This commit is contained in:
hh committed 2026-05-31 19:41:37 +02:00
1 parent 75425d1bee
commit ed469ba8dd
83 files changed
+6034 -136

No files matched your search

@@ -0,0 +1,150 @@
<script lang="ts">
import { ripple } from "$lib/actions/ripple";
import type { SearchHit } from "$lib/api/types";
import Avatar from "$lib/components/ui/Avatar.svelte";
import { formatListDate } from "$lib/format/datetime";
import { peerName } from "$lib/format/peer";
import { accounts } from "$lib/stores/accounts.svelte";
import { chats } from "$lib/stores/chats.svelte";
import { peers } from "$lib/stores/peers.svelte";
interface Props {
hit: SearchHit;
onclick: () => void;
}
let { hit, onclick }: Props = $props();
const chat = $derived(chats.byId(hit.chat_id));
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
$effect(() => {
const ids: number[] = [];
if (hit.chat_id > 0) {
ids.push(hit.chat_id);
}
if (hit.sender_id !== null) {
ids.push(hit.sender_id);
}
if (ids.length > 0) {
peers.ensure(ids);
}
});
const title = $derived.by(() => {
if (chat?.title) {
return chat.title;
}
if (hit.chat_id > 0) {
return peerName(peers.get(hit.chat_id) ?? null) || `Chat ${hit.chat_id}`;
}
return `Chat ${hit.chat_id}`;
});
const sender = $derived.by(() => {
if (hit.sender_id === null) {
return "";
}
if (hit.sender_id === ownId) {
return "Вы";
}
return peerName(peers.get(hit.sender_id) ?? null);
});
const snippet = $derived(hit.text ?? hit.extracted_text ?? "");
const avatarKind = $derived(hit.chat_id > 0 ? "peer" : "chat");
</script>
<button type="button" class="hit" use:ripple {onclick}>
<Avatar
name={title}
colorKey={hit.chat_id}
avatar={{ kind: avatarKind, id: hit.chat_id }}
hasAvatar={chat?.has_avatar ?? false}
size={3}
/>
<div class="body">
<div class="top">
<span class="title">{title}</span>
<span class="date">{formatListDate(hit.date)}</span>
</div>
<div class="snippet">
{#if sender}
<span class="sender">{sender}: </span>
{/if}
{snippet}
</div>
</div>
</button>
<style lang="scss">
.hit {
cursor: pointer;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
gap: 0.625rem;
width: 100%;
padding: 0.5625rem 0.5rem;
border: 0;
border-radius: 0.625rem;
text-align: start;
color: var(--color-text);
background-color: transparent;
transition: background-color 0.15s ease;
--ripple-color: var(--color-interactive-element-hover);
&:hover {
background-color: var(--color-chat-hover);
}
}
.body {
display: flex;
overflow: hidden;
flex: 1;
flex-direction: column;
gap: 0.1875rem;
}
.top {
display: flex;
align-items: baseline;
gap: 0.5rem;
}
.title {
overflow: hidden;
flex: 1;
font-size: 1rem;
font-weight: var(--font-weight-medium);
text-overflow: ellipsis;
white-space: nowrap;
}
.date {
flex-shrink: 0;
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.snippet {
overflow: hidden;
display: -webkit-box;
font-size: 0.875rem;
color: var(--color-text-secondary);
text-overflow: ellipsis;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
}
.sender {
color: var(--color-text);
}
</style>