feat(backend,frontend,mcp): saved messages take text, files, voices and archived messages, the one write to telegram

This commit is contained in:
hh committed 2026-10-28 01:53:48 +00:00
1 parent 2dd4e589c3
commit 58b4346166
22 files changed
+1091 -35

No files matched your search

@@ -25,6 +25,7 @@
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 { savedUi } from "#lib/stores/saved.svelte.js";
import { ui } from "#lib/stores/ui.svelte.js";
import { appear } from "#lib/transitions/appear.js";
import { goto } from "$app/navigation";
@@ -331,6 +332,15 @@
>{t("thread.toReply")}</ContextMenuItem
>
{/if}
{#if savedUi.available && !savedUi.isSavedChat(message.chat_id)}
<ContextMenuItem
icon="forward"
onselect={() => savedUi.forward(message.chat_id, message.message_id)}
>{message.deleted_at || message.is_self_destruct
? t("saved.copy")
: t("saved.forward")}</ContextMenuItem
>
{/if}
<CopyItem
done={t("chat.menu.linkCopied")}
icon="link"
@@ -0,0 +1,438 @@
<script lang="ts">
import { Dialog } from "bits-ui";
import Button from "#lib/components/ui/Button.svelte";
import Icon from "#lib/components/ui/Icon.svelte";
import { formatBytes } from "#lib/format/media.js";
import { t } from "#lib/i18n/locale.svelte.js";
import { savedUi } from "#lib/stores/saved.svelte.js";
const VOICE_NAME = /\.(ogg|oga|opus)$/i;
const MAX_INPUT_HEIGHT = 240;
let text = $state("");
let sending = $state(false);
let file = $state<File | null>(null);
let caption = $state("");
let voice = $state(false);
let picker = $state<HTMLInputElement>();
let input = $state<HTMLTextAreaElement>();
const dialogOpen = $derived(file !== null);
const isAudio = $derived(
file !== null &&
(file.type.startsWith("audio/") || VOICE_NAME.test(file.name))
);
const preview = $derived(
file?.type.startsWith("image/") ? URL.createObjectURL(file) : null
);
$effect(() => {
const url = preview;
return () => {
if (url) {
URL.revokeObjectURL(url);
}
};
});
function fit() {
if (!input) {
return;
}
input.style.height = "auto";
input.style.height = `${Math.min(input.scrollHeight, MAX_INPUT_HEIGHT)}px`;
}
async function sendText() {
const message = text.trim();
if (!message || sending) {
return;
}
sending = true;
if (await savedUi.send({ text: message })) {
text = "";
requestAnimationFrame(fit);
}
sending = false;
}
async function sendFile() {
if (!file || sending) {
return;
}
sending = true;
const sent = await savedUi.send({
file,
text: caption.trim(),
voice: isAudio && voice,
});
sending = false;
if (sent) {
file = null;
caption = "";
}
}
function onkeydown(event: KeyboardEvent) {
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault();
sendText();
}
}
function onpick() {
const picked = picker?.files?.[0] ?? null;
if (picker) {
picker.value = "";
}
if (!picked) {
return;
}
file = picked;
caption = text.trim();
text = "";
voice = VOICE_NAME.test(picked.name) || picked.type === "audio/ogg";
requestAnimationFrame(fit);
}
function onOpenChange(open: boolean) {
if (!(open || sending)) {
if (!text) {
text = caption;
}
file = null;
caption = "";
requestAnimationFrame(fit);
}
}
</script>
<div class="SavedComposer">
<div class="field">
<textarea
aria-label={t("saved.message")}
oninput={fit}
{onkeydown}
placeholder={t("saved.message")}
rows="1"
bind:this={input}
bind:value={text}
></textarea>
<button
aria-label={t("saved.attach")}
class="attach"
onclick={() => picker?.click()}
type="button"
>
<Icon name="attach" size="1.5rem" />
</button>
<input hidden onchange={onpick} type="file" bind:this={picker}>
</div>
<Button
aria-label={t("saved.send")}
class="send"
disabled={!text.trim()}
loading={sending && !file}
onclick={sendText}
round
>
<Icon name="send" size="1.5rem" />
</Button>
</div>
<Dialog.Root {onOpenChange} open={dialogOpen}>
<Dialog.Portal>
<Dialog.Overlay class="dialog-overlay" />
<Dialog.Content class="dialog-content">
<header class="dialog-head">
<Dialog.Title class="dialog-title">{t("saved.sendFile")}</Dialog.Title>
<Dialog.Close aria-label={t("common.close")} class="dialog-close">
<Icon name="close" size="1.25rem" />
</Dialog.Close>
</header>
<div class="dialog-body">
{#if preview}
<img alt={file?.name} class="preview" src={preview}>
{/if}
<div class="file">
<span class="tile">
<Icon name={isAudio ? "microphone" : "document"} size="1.5rem" />
</span>
<span class="meta">
<span class="name">{file?.name}</span>
<span class="size">{formatBytes(file?.size ?? null)}</span>
</span>
</div>
<textarea
aria-label={t("saved.caption")}
class="caption"
placeholder={t("saved.caption")}
rows="2"
bind:value={caption}
></textarea>
{#if isAudio}
<button
aria-checked={voice}
class="toggle-row"
onclick={() => {
voice = !voice;
}}
role="switch"
type="button"
>
<span class="label">
<span>{t("saved.asVoice")}</span>
<small>{t("saved.asVoiceHint")}</small>
</span>
<span class="switch" class:on={voice}>
<span class="knob"></span>
</span>
</button>
{/if}
</div>
<div class="dialog-actions">
<Dialog.Close>
{#snippet child({
props,
})}
<Button {...props} pill variant="text">
{t("common.cancel")}
</Button>
{/snippet}
</Dialog.Close>
<Button disabled={sending} loading={sending} onclick={sendFile} pill>
{t("saved.send")}
</Button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<style lang="scss">
.SavedComposer {
display: flex;
flex-shrink: 0;
align-items: flex-end;
gap: 0.5rem;
padding: 0.5rem max(1rem, calc((100% - var(--messages-container-width)) / 2))
1.25rem;
:global(.send) {
flex-shrink: 0;
width: 3.5rem;
height: 3.5rem;
}
}
.field {
display: flex;
flex: 1;
align-items: flex-end;
min-width: 0;
min-height: 3.5rem;
padding: 0 0.5rem 0 1rem;
border-radius: var(--border-radius-messages);
background-color: var(--color-background);
box-shadow: 0 1px 2px var(--color-default-shadow);
}
textarea {
resize: none;
flex: 1;
min-width: 0;
margin: 0;
padding: 1rem 0;
border: 0;
font: inherit;
font-size: 1rem;
line-height: 1.375;
color: var(--color-text);
background: transparent;
outline: none;
&::placeholder {
color: var(--color-placeholders);
}
}
.attach {
cursor: pointer;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
margin-bottom: 0.5rem;
border: 0;
border-radius: 50%;
color: var(--color-composer-button);
background: transparent;
transition: color 0.15s ease;
&:focus-visible {
outline: 2px solid var(--color-primary);
}
@media (hover: hover) {
&:hover {
color: var(--color-primary);
}
}
}
.preview {
display: block;
max-width: 100%;
max-height: 16rem;
margin: 0 auto 1rem;
border-radius: var(--border-radius-default-small);
object-fit: contain;
}
.file {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.tile {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
border-radius: 50%;
color: var(--color-white);
background-color: var(--color-primary);
}
.meta {
display: flex;
flex-direction: column;
min-width: 0;
}
.name {
overflow: hidden;
font-size: 0.9375rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.size {
font-size: 0.875rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
.caption {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid var(--color-borders-input);
border-radius: var(--border-radius-default);
&:focus {
border-color: var(--color-primary);
box-shadow: inset 0 0 0 1px var(--color-primary);
}
}
.toggle-row {
cursor: pointer;
display: flex;
align-items: center;
gap: 1rem;
width: calc(100% + 2.5rem);
margin: 0.75rem -1.25rem 0;
padding: 0.5rem 1.25rem;
border: 0;
font-size: 0.9375rem;
text-align: start;
color: var(--color-text);
background-color: transparent;
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background-color: var(--color-chat-hover);
}
}
}
.label {
display: flex;
flex: 1;
flex-direction: column;
small {
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
}
.switch {
flex-shrink: 0;
display: flex;
align-items: center;
width: 2.25rem;
height: 1.375rem;
padding: 0.125rem;
border-radius: 0.6875rem;
background-color: var(--color-borders);
transition: background-color 0.15s ease;
&.on {
background-color: var(--color-primary);
}
}
.knob {
width: 1.125rem;
height: 1.125rem;
border-radius: 50%;
background-color: var(--color-white);
transition: transform 0.15s ease;
.on & {
transform: translateX(0.875rem);
}
}
@media (pointer: coarse) {
.SavedComposer {
padding-bottom: max(0.75rem, env(safe-area-inset-bottom));
}
}
</style>
@@ -88,7 +88,7 @@
padding: 0.1875rem 0;
&.own {
--active: var(--color-white);
--active: var(--color-accent-own);
--toggle-fg: var(--color-background-own);
}
}
@@ -106,8 +106,8 @@
{/if}
{:else}
<span class="blank"></span>
<span class="play">
<Icon name={waiting ? "timer" : "download"} size="2.25rem" />
<span class="play fetch">
<Icon name={waiting ? "timer" : "download"} size="1.75rem" />
</span>
{/if}
<span class="badge">
@@ -246,6 +246,13 @@
color: var(--color-white);
filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.5));
&.fetch {
padding: 0.875rem;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.45);
filter: none;
}
}
.badge {
@@ -169,11 +169,11 @@
--transcribe-bg: var(--color-primary-tint);
&.own {
--active: var(--color-white);
--inactive: rgba(255, 255, 255, 0.45);
--active: var(--color-accent-own);
--inactive: color-mix(in srgb, var(--color-accent-own) 40%, transparent);
--toggle-fg: var(--color-background-own);
--voice-meta: rgba(255, 255, 255, 0.75);
--transcribe-bg: rgba(255, 255, 255, 0.2);
--voice-meta: var(--color-message-meta-own);
--transcribe-bg: var(--color-voice-transcribe-button-own);
}
}