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

509 lines
14 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 MessageReadItem from "#lib/components/read/MessageReadItem.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 CopyItem from "#lib/components/ui/CopyItem.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 { t } from "#lib/i18n/locale.svelte.js";
import { accounts } from "#lib/stores/accounts.svelte.js";
import { chats } from "#lib/stores/chats.svelte.js";
import { notes } from "#lib/stores/notes.svelte.js";
import { peers } from "#lib/stores/peers.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 senderChat = $derived(
message.sender_id !== null && message.sender_id < 0
? message.sender_id
: null
);
const sender = $derived(
message.sender_id === null || senderChat !== null
? undefined
: peers.get(message.sender_id)
);
const senderName = $derived.by(() => {
if (own) {
return accounts.selected ? accountName(accounts.selected) : t("chat.you");
}
if (senderChat !== null) {
return chats.byId(senderChat)?.title ?? "";
}
if (sender) {
return peerName(sender);
}
return message.sender_id === null ? "" : String(message.sender_id);
});
$effect(() => {
if (senderChat !== null) {
chats.ensure(senderChat);
}
});
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.by(() => {
if (own) {
return ownId !== null && Boolean(peers.get(ownId)?.has_avatar);
}
if (senderChat !== null) {
return chats.byId(senderChat)?.has_avatar ?? false;
}
return 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);
}
}
function messageLink(): string {
return `${location.origin}/app/${message.chat_id}?message=${message.message_id}`;
}
function openSenderProfile() {
if (senderChat === message.chat_id) {
ui.openPanel("profile");
} else if (senderChat !== null) {
goto(`/app/${senderChat}`);
} else if (message.sender_id !== null) {
ui.openPerson(message.chat_id, message.sender_id);
}
}
</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}
<button
aria-label={senderName}
class="sender-avatar"
onclick={openSenderProfile}
type="button"
>
<Avatar
avatar={{
id: avatarId,
kind: senderChat === null ? "peer" : "chat",
}}
colorKey={avatarId}
hasAvatar={avatarHas}
name={senderName}
size={2.125}
/>
</button>
{/if}
</div>
{/if}
<div class="message-content" class:has-appendix={lastInGroup}>
{#if showName}
<button
class="sender-name peer-color-{colorIndex}"
onclick={openSenderProfile}
type="button"
>
{senderName}
</button>
{/if}
{#if message.forward}
<ForwardHeader forward={message.forward} />
{/if}
{#if message.reply}
<ReplyHeader {onjump} reply={message.reply} />
{/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
items={menu}
{message}
onopen={() => onmedia(0)}
{onversions}
{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">{t("feed.noText")}</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" />
{t("thread.links", { count: linkCount })}
</button>
{/if}
<MessageMeta
{message}
noted={notes.has(message)}
onnote={openAnnotations}
{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" />
{t("panel.callbacks")}
</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()}
{#if own}
<MessageReadItem {message} />
{/if}
<ContextMenuItem icon="note" onselect={openAnnotations}
>{t("panel.annotations")}</ContextMenuItem
>
{#if message.reactions.length}
<ContextMenuItem icon="heart" onselect={openReactions}
>{t("panel.reactions")}</ContextMenuItem
>
{/if}
{#if linkCount > 0}
<ContextMenuItem icon="link" onselect={openLinks}
>{t("panel.links")}</ContextMenuItem
>
{/if}
{#if hasCallbacks}
<ContextMenuItem icon="bot-command" onselect={openCallbacks}
>{t("panel.callbacks")}</ContextMenuItem
>
{/if}
{#if message.edited_at}
<ContextMenuItem icon="edit" onselect={onversions}
>{t("panel.versions")}</ContextMenuItem
>
{/if}
{#if (message.reply?.message_id ?? null) !== null}
<ContextMenuItem icon="reply" onselect={jumpToReply}
>{t("thread.toReply")}</ContextMenuItem
>
{/if}
<CopyItem
done={t("chat.menu.linkCopied")}
icon="link"
label={t("chat.menu.copyLink")}
text={messageLink}
/>
{#if hasText}
<CopyItem
done={t("chat.menu.copied")}
icon="copy"
label={t("thread.copyText")}
text={() => message.text ?? ""}
/>
{/if}
<CopyItem
done={t("chat.menu.copied")}
icon="hashtag"
label={t("thread.copyId")}
text={() => String(message.message_id)}
/>
{#if isGroupChat && !own && message.sender_id !== null}
<ContextMenuItem icon="info" onselect={openSenderProfile}
>{t("thread.senderProfile")}</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-avatar,
.sender-name {
cursor: pointer;
padding: 0;
border: 0;
font: inherit;
background: none;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
}
.sender-avatar {
display: block;
border-radius: 50%;
}
.sender-name {
overflow: hidden;
display: block;
max-width: 100%;
margin-bottom: 0.0625rem;
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
text-align: start;
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;
}
.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>