437 lines
12 KiB
Svelte
437 lines
12 KiB
Svelte
<script lang="ts">
|
|
import type { MessageView } from "#lib/api/types.js";
|
|
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.js";
|
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
|
import { peers } from "#lib/stores/peers.svelte.js";
|
|
import { toasts } from "#lib/stores/toasts.svelte.js";
|
|
import { ui } from "#lib/stores/ui.svelte.js";
|
|
import { appear } from "#lib/transitions/appear.js";
|
|
import { goto } from "$app/navigation";
|
|
|
|
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>
|