feat(backend,frontend,mcp): saved messages take text, files, voices and archived messages, the one write to telegram
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user