Files
beavergram/frontend/src/lib/components/MessageBubble.svelte
T

435 lines
12 KiB
Svelte

<script lang="ts">
import { goto } from "$app/navigation";
import type { MessageView } from "$lib/api/types";
import Contact from "$lib/components/Contact.svelte";
import EntityText from "$lib/components/EntityText.svelte";
import ForwardHeader from "$lib/components/ForwardHeader.svelte";
import InlineButtons from "$lib/components/InlineButtons.svelte";
import Location from "$lib/components/Location.svelte";
import MediaAlbum from "$lib/components/MediaAlbum.svelte";
import MessageMedia from "$lib/components/MessageMedia.svelte";
import MessageMeta from "$lib/components/MessageMeta.svelte";
import Poll from "$lib/components/Poll.svelte";
import Reactions from "$lib/components/Reactions.svelte";
import ReplyHeader from "$lib/components/ReplyHeader.svelte";
import Avatar from "$lib/components/ui/Avatar.svelte";
import ContextMenu from "$lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "$lib/components/ui/ContextMenuItem.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import WebPage from "$lib/components/WebPage.svelte";
import { accountName, peerColorIndex, peerName } from "$lib/format/peer";
import { accounts } from "$lib/stores/accounts.svelte";
import { peers } from "$lib/stores/peers.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
import { ui } from "$lib/stores/ui.svelte";
import { appear } from "$lib/transitions/appear";
interface Props {
animate: boolean;
firstInGroup: boolean;
highlighted: boolean;
isGroupChat: boolean;
lastInGroup: boolean;
message: MessageView;
onjump: (messageId: number) => void;
onmedia: (index: number) => void;
onversions: () => void;
own: boolean;
}
let {
message,
own,
isGroupChat,
firstInGroup,
lastInGroup,
highlighted,
animate,
onjump,
onmedia,
onversions,
}: Props = $props();
const deleted = $derived(message.deleted_at !== null);
const hasText = $derived(Boolean(message.text));
const special = $derived(
Boolean(
message.web_page || message.poll || message.contact || message.location
)
);
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
const sender = $derived(
message.sender_id === null ? undefined : peers.get(message.sender_id)
);
const senderName = $derived.by(() => {
if (own) {
return accounts.selected ? accountName(accounts.selected) : "You";
}
if (sender) {
return peerName(sender);
}
return message.sender_id === null ? "" : String(message.sender_id);
});
const colorIndex = $derived(peerColorIndex(message.sender_id ?? 0));
const showName = $derived(isGroupChat && !own && firstInGroup);
const avatarId = $derived(own ? ownId : message.sender_id);
const avatarHas = $derived(
own
? ownId !== null && peers.get(ownId)?.has_avatar
: (sender?.has_avatar ?? false)
);
const hasCallbacks = $derived(
message.inline_buttons.some((row) =>
row.some((button) => button.kind === "callback" && button.data)
)
);
const linkCount = $derived.by(() => {
const count = message.entities.filter(
(entity) => entity.type === "url" || entity.type === "text_link"
).length;
if (count > 0) {
return count;
}
return message.web_page ? 1 : 0;
});
function openReactions() {
ui.openMessagePanel("reactions", message.message_id);
}
function openCallbacks() {
ui.openMessagePanel("callbacks", message.message_id);
}
function openLinks() {
ui.openMessagePanel("links", message.message_id);
}
function openAnnotations() {
ui.openMessagePanel("annotations", message.message_id);
}
function jumpToReply() {
// biome-ignore lint/suspicious/noEqualsToNull: optional chaining yields undefined here
if (message.reply?.message_id != null) {
onjump(message.reply.message_id);
}
}
async function copyText() {
await navigator.clipboard.writeText(message.text ?? "");
toasts.success("Текст скопирован");
}
async function copyId() {
await navigator.clipboard.writeText(String(message.message_id));
toasts.success("ID скопирован");
}
function openSenderProfile() {
if (message.sender_id !== null) {
goto(`/app/${message.sender_id}`);
ui.openPanel("profile");
}
}
</script>
<ContextMenu>
{#snippet children({ props })}
<div
{...props}
class="Message"
data-message-id={message.message_id}
class:deleted
class:first-in-group={firstInGroup}
class:highlighted
class:last-in-group={lastInGroup}
class:own
class:with-avatar={isGroupChat}
in:appear={{ disabled: !animate }}
>
{#if isGroupChat}
<div class="avatar-slot">
{#if lastInGroup && avatarId !== null}
<Avatar
avatar={{ id: avatarId, kind: "peer" }}
colorKey={avatarId}
hasAvatar={avatarHas}
name={senderName}
size={2.125}
/>
{/if}
</div>
{/if}
<div class="message-content" class:has-appendix={lastInGroup}>
{#if showName}
<div class="sender-name peer-color-{colorIndex}">{senderName}</div>
{/if}
{#if message.forward}
<ForwardHeader forward={message.forward} />
{/if}
{#if message.reply}
<ReplyHeader {onjump} reply={message.reply} />
{/if}
{#if deleted}
<span class="deleted-tag">
<Icon name="delete" size="0.875rem" />
deleted
</span>
{/if}
{#if message.poll}
<Poll poll={message.poll} />
{/if}
{#if message.contact}
<Contact contact={message.contact} />
{/if}
{#if message.location}
<Location location={message.location} />
{/if}
{#if !special}
{#if message.media.length > 1}
<MediaAlbum
chatId={message.chat_id}
media={message.media}
onopen={onmedia}
{own}
/>
{:else if message.has_media}
<MessageMedia {message} onopen={() => onmedia(0)} {own} />
{/if}
{/if}
{#if hasText}
<div class="text">
<EntityText
entities={message.entities}
{own}
text={message.text ?? ""}
/>
</div>
{:else if !(message.has_media || deleted || special)}
<div class="text empty">(no text)</div>
{/if}
{#if message.web_page}
<WebPage {message} {onmedia} {own} />
{/if}
{#if message.reactions.length}
<Reactions
onclick={openReactions}
{own}
reactions={message.reactions}
/>
{/if}
{#if linkCount > 0}
<button class="detail-chip" onclick={openLinks} type="button">
<Icon name="link" size="0.875rem" />
{linkCount}
{linkCount === 1 ? "ссылка" : "ссылки"}
</button>
{/if}
<MessageMeta {message} {onversions} {own} />
{#if message.inline_buttons.length}
<InlineButtons rows={message.inline_buttons} />
{/if}
{#if hasCallbacks}
<button class="detail-chip" onclick={openCallbacks} type="button">
<Icon name="bot-command" size="0.875rem" />
Callback-данные
</button>
{/if}
{#if lastInGroup}
<svg aria-hidden="true" class="svg-appendix" height="20" width="9">
<path
class="corner"
d="M3 17h6V0c-.193 2.84-.876 5.767-2.05 8.782-.904 2.325-2.446 4.485-4.625 6.48A1 1 0 003 17z"
/>
</svg>
{/if}
</div>
</div>
{/snippet}
{#snippet menu()}
<ContextMenuItem icon="note" onselect={openAnnotations}
>Заметки</ContextMenuItem
>
{#if message.reactions.length}
<ContextMenuItem icon="heart" onselect={openReactions}
>Реакции</ContextMenuItem
>
{/if}
{#if linkCount > 0}
<ContextMenuItem icon="link" onselect={openLinks}>Ссылки</ContextMenuItem>
{/if}
{#if hasCallbacks}
<ContextMenuItem icon="bot-command" onselect={openCallbacks}
>Callback-данные</ContextMenuItem
>
{/if}
{#if message.edited_at}
<ContextMenuItem icon="edit" onselect={onversions}
>Версии</ContextMenuItem
>
{/if}
{#if (message.reply?.message_id ?? null) !== null}
<ContextMenuItem icon="reply" onselect={jumpToReply}
>Перейти к ответу</ContextMenuItem
>
{/if}
{#if hasText}
<ContextMenuItem icon="copy" onselect={copyText}
>Копировать текст</ContextMenuItem
>
{/if}
<ContextMenuItem icon="hashtag" onselect={copyId}
>Копировать ID</ContextMenuItem
>
{#if isGroupChat && !own && message.sender_id !== null}
<ContextMenuItem icon="info" onselect={openSenderProfile}
>Профиль автора</ContextMenuItem
>
{/if}
{/snippet}
</ContextMenu>
<style lang="scss">
.Message {
--background-color: var(--color-background);
--meta-color: var(--color-text-meta);
position: relative;
transform-origin: bottom left;
display: flex;
align-items: flex-end;
gap: 0.4375rem;
margin-bottom: 0.125rem;
&.last-in-group {
margin-bottom: 0.5rem;
}
&.own {
--background-color: var(--color-background-own);
--meta-color: var(--color-message-meta-own);
}
}
.avatar-slot {
flex-shrink: 0;
width: 2.125rem;
}
.message-content {
position: relative;
max-width: min(30rem, 75%);
min-width: 3.5rem;
padding: 0.3125rem 0.5rem 0.375rem;
border-radius: var(--border-radius-messages);
font-size: 1rem;
line-height: 1.3125;
color: var(--color-text);
background-color: var(--background-color);
box-shadow: 0 1px 2px var(--color-default-shadow);
}
.Message.highlighted .message-content {
animation: highlight-flash 1.6s ease;
}
@keyframes highlight-flash {
0%,
60% {
background-color: var(--color-primary-opacity);
}
100% {
background-color: var(--background-color);
}
}
.Message:not(.first-in-group) .message-content {
border-top-left-radius: var(--border-radius-messages-small);
}
.Message:not(.last-in-group) .message-content {
border-bottom-left-radius: var(--border-radius-messages-small);
}
.Message.last-in-group .message-content.has-appendix {
border-bottom-left-radius: 0;
}
.sender-name {
overflow: hidden;
margin-bottom: 0.0625rem;
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
text-overflow: ellipsis;
white-space: nowrap;
}
.text {
overflow-wrap: anywhere;
white-space: pre-wrap;
&.empty {
font-style: italic;
color: var(--color-text-secondary);
}
}
.deleted .text {
color: var(--color-text-secondary);
text-decoration: line-through;
}
.deleted-tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-bottom: 0.25rem;
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
color: var(--color-error);
}
.detail-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
align-self: flex-start;
margin-top: 0.25rem;
padding: 0.125rem 0.5rem 0.125rem 0.375rem;
border: 0;
border-radius: 1rem;
font-family: inherit;
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
cursor: pointer;
background-color: var(--color-message-reaction);
&:hover {
color: var(--color-primary);
}
}
.svg-appendix {
position: absolute;
bottom: -0.0625rem;
left: -0.5rem;
width: 9px;
height: 20px;
}
.svg-appendix .corner {
fill: var(--background-color);
}
</style>