509 lines
14 KiB
Svelte
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>
|