Files
beavergram/frontend/src/lib/components/search/SearchMessageItem.svelte
T

254 lines
6.0 KiB
Svelte

<script lang="ts">
import { ripple } from "#lib/actions/ripple.js";
import type { SearchHit } from "#lib/api/types.js";
import Avatar from "#lib/components/ui/Avatar.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import { formatFull, formatListDate } from "#lib/format/datetime.js";
import { mediaKindLabel } from "#lib/format/media.js";
import { peerName } from "#lib/format/peer.js";
import { highlight } from "#lib/format/search.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js";
import { peers } from "#lib/stores/peers.svelte.js";
interface Props {
hit: SearchHit;
href: string;
inChat?: boolean;
onclick: () => void;
terms: string[];
}
let { hit, href, inChat = false, onclick, terms }: Props = $props();
const chat = $derived(chats.byId(hit.chat_id));
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
const own = $derived(hit.sender_id !== null && hit.sender_id === ownId);
$effect(() => {
chats.ensure(hit.chat_id);
const ids = [hit.chat_id, hit.sender_id].filter(
(id): id is number => id !== null && id > 0
);
if (ids.length > 0) {
peers.ensure(ids);
}
});
const chatTitle = $derived.by(() => {
if (chat?.title) {
return chat.title;
}
const peer = hit.chat_id > 0 ? peers.get(hit.chat_id) : undefined;
return (
(peer && peerName(peer)) || t("search.chatTitle", { id: hit.chat_id })
);
});
const senderName = $derived.by(() => {
if (hit.sender_id === null) {
return "";
}
if (own) {
return t("search.you");
}
return peerName(peers.get(hit.sender_id) ?? null);
});
const avatarId = $derived(
inChat ? (hit.sender_id ?? hit.chat_id) : hit.chat_id
);
const title = $derived(inChat ? senderName || chatTitle : chatTitle);
const prefix = $derived(
inChat || !senderName || hit.chat_id === hit.sender_id ? "" : senderName
);
const avatarHas = $derived(
avatarId === hit.chat_id
? (chat?.has_avatar ?? false)
: (peers.get(avatarId)?.has_avatar ?? false)
);
const body = $derived.by(() => {
if (hit.source === "version") {
return hit.version_text ?? "";
}
if (hit.source === "stt") {
return hit.extracted_text ?? "";
}
return hit.text ?? "";
});
const kind = $derived(
hit.source === "stt" || !body ? mediaKindLabel(hit.media_kind) : null
);
const segments = $derived(highlight(body, terms));
</script>
<a class="hit" {href} {onclick} use:ripple>
<Avatar
avatar={{ id: avatarId, kind: avatarId > 0 ? "peer" : "chat" }}
colorKey={avatarId}
hasAvatar={avatarHas}
name={title}
size={inChat ? 2.5 : 3}
/>
<div class="body">
<div class="top">
<span class="title">{title}</span>
{#if hit.deleted_at}
<span class="mark deleted" title={formatFull(hit.deleted_at)}>
<Icon name="delete" size="0.875rem" />
{t("search.markDeleted")}
</span>
{:else if hit.edited_at}
<span class="mark" title={formatFull(hit.edited_at)}>
{t("search.markEdited")}
</span>
{/if}
<span class="date" title={formatFull(hit.date)}>
{formatListDate(hit.date)}
</span>
</div>
<div class="snippet">
{#if prefix}
<span class="sender">{prefix}:</span>
{/if}
{#if kind}
<span class="kind">{kind}{body ? ":" : ""}</span>
{/if}
<span
>{#each segments as segment, index (index)}
{#if segment.hit}
<mark>{segment.text}</mark>
{:else}
{segment.text}
{/if}
{/each}</span
>
</div>
{#if hit.source === "version"}
<div
class="note"
title={hit.version_at ? formatFull(hit.version_at) : undefined}
>
<Icon name="edit" size="0.875rem" />
{t("search.version")}
</div>
{/if}
</div>
</a>
<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-radius: 0.625rem;
text-align: start;
text-decoration: none;
color: var(--color-text);
background-color: transparent;
transition: background-color 0.15s ease;
--ripple-color: var(--color-interactive-element-hover);
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
}
.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;
}
.mark {
display: inline-flex;
flex-shrink: 0;
align-items: center;
gap: 0.1875rem;
align-self: center;
font-size: 0.75rem;
font-style: italic;
color: var(--color-text-secondary);
&.deleted {
font-style: normal;
color: var(--color-error);
}
}
.date {
flex-shrink: 0;
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
.snippet {
overflow: hidden;
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--color-text-secondary);
text-overflow: ellipsis;
white-space: nowrap;
}
.sender,
.kind {
color: var(--color-text);
}
.note {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8125rem;
font-style: italic;
color: var(--color-text-secondary);
}
mark {
padding: 0 0.0625rem;
border-radius: 0.25rem;
color: var(--color-text);
background-color: color-mix(in srgb, var(--color-primary) 20%, transparent);
}
</style>