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
@@ -95,6 +95,18 @@ export async function request<T>(
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function requestForm<T>(path: string, form: FormData): Promise<T> {
|
||||
const response = await fetch(`${BASE}${path}`, {
|
||||
body: form,
|
||||
headers: authHeaders(),
|
||||
method: "POST",
|
||||
});
|
||||
if (!response.ok) {
|
||||
return handleError(response);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
function streamUrl(path: string): string {
|
||||
const query = auth.token ? `?token=${encodeURIComponent(auth.token)}` : "";
|
||||
return `${BASE}${path}${query}`;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { request } from "#lib/api/client.js";
|
||||
import { request, requestForm } from "#lib/api/client.js";
|
||||
import type {
|
||||
Account,
|
||||
Annotation,
|
||||
@@ -597,6 +597,47 @@ export function transcribeMedia(
|
||||
});
|
||||
}
|
||||
|
||||
export interface SavedResult {
|
||||
chat_id: number | null;
|
||||
error: string | null;
|
||||
message_id: number | null;
|
||||
mode: "copied" | "forwarded" | "sent" | null;
|
||||
status: "done" | "failed" | "pending";
|
||||
}
|
||||
|
||||
export interface SavedDraft {
|
||||
file?: File;
|
||||
text?: string;
|
||||
voice?: boolean;
|
||||
}
|
||||
|
||||
export function sendToSaved(draft: SavedDraft): Promise<SavedResult> {
|
||||
const form = new FormData();
|
||||
form.set("account_id", String(accounts.selectedId));
|
||||
if (draft.text) {
|
||||
form.set("text", draft.text);
|
||||
}
|
||||
if (draft.file) {
|
||||
form.set("file", draft.file);
|
||||
form.set("voice", String(Boolean(draft.voice)));
|
||||
}
|
||||
return requestForm<SavedResult>("/saved", form);
|
||||
}
|
||||
|
||||
export function forwardToSaved(
|
||||
chatId: number,
|
||||
messageId: number
|
||||
): Promise<SavedResult> {
|
||||
return request<SavedResult>("/saved/forward", {
|
||||
body: {
|
||||
account_id: accounts.selectedId,
|
||||
chat_id: chatId,
|
||||
message_id: messageId,
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export function listAnnotations(
|
||||
options: { chatId?: number; messageId?: number } = {}
|
||||
): Promise<Annotation[]> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { policy } from "#lib/i18n/messages/policy.js";
|
||||
import { presence } from "#lib/i18n/messages/presence.js";
|
||||
import { profile } from "#lib/i18n/messages/profile.js";
|
||||
import { read } from "#lib/i18n/messages/read.js";
|
||||
import { saved } from "#lib/i18n/messages/saved.js";
|
||||
import { search } from "#lib/i18n/messages/search.js";
|
||||
import { service } from "#lib/i18n/messages/service.js";
|
||||
import { settings } from "#lib/i18n/messages/settings.js";
|
||||
@@ -45,6 +46,7 @@ export const messages = {
|
||||
...presence,
|
||||
...profile,
|
||||
...read,
|
||||
...saved,
|
||||
...search,
|
||||
...service,
|
||||
...settings,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Messages } from "#lib/i18n/types.js";
|
||||
|
||||
export const saved = {
|
||||
"saved.asVoice": { en: "As a voice message", ru: "Голосовым сообщением" },
|
||||
"saved.asVoiceHint": {
|
||||
en: "Sent exactly as is, so give it OGG/Opus",
|
||||
ru: "Уходит как есть, нужен OGG/Opus",
|
||||
},
|
||||
"saved.attach": { en: "Attach a file", ru: "Прикрепить файл" },
|
||||
"saved.caption": { en: "Caption", ru: "Подпись" },
|
||||
"saved.copied": {
|
||||
en: "Copy sent to Saved Messages",
|
||||
ru: "Копия в Избранном",
|
||||
},
|
||||
"saved.copy": { en: "Copy to Saved Messages", ru: "Копию в Избранное" },
|
||||
"saved.failed": {
|
||||
en: "Could not send to Saved Messages",
|
||||
ru: "Не удалось отправить в Избранное",
|
||||
},
|
||||
"saved.forward": { en: "To Saved Messages", ru: "В Избранное" },
|
||||
"saved.forwarded": {
|
||||
en: "Forwarded to Saved Messages",
|
||||
ru: "Переслано в Избранное",
|
||||
},
|
||||
"saved.mediaMissing": {
|
||||
en: "The file is not in the archive, there is nothing to copy",
|
||||
ru: "Файла нет в архиве, копировать нечего",
|
||||
},
|
||||
"saved.message": { en: "Message", ru: "Сообщение" },
|
||||
"saved.pending": {
|
||||
en: "Telegram is still sending, it will show up shortly",
|
||||
ru: "Telegram ещё отправляет, сообщение скоро появится",
|
||||
},
|
||||
"saved.send": { en: "Send", ru: "Отправить" },
|
||||
"saved.sendFile": { en: "Send a file", ru: "Отправить файл" },
|
||||
} satisfies Messages;
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
forwardToSaved,
|
||||
type SavedDraft,
|
||||
type SavedResult,
|
||||
sendToSaved,
|
||||
} from "#lib/api/endpoints.js";
|
||||
import { t } from "#lib/i18n/locale.svelte.js";
|
||||
import type { MessageKey } from "#lib/i18n/messages.js";
|
||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||
import { toasts } from "#lib/stores/toasts.svelte.js";
|
||||
|
||||
const REFUSALS: Record<string, MessageKey> = {
|
||||
media_missing: "saved.mediaMissing",
|
||||
};
|
||||
|
||||
function report(result: SavedResult): boolean {
|
||||
if (result.status === "done") {
|
||||
return true;
|
||||
}
|
||||
if (result.status === "pending") {
|
||||
toasts.show(t("saved.pending"));
|
||||
return true;
|
||||
}
|
||||
toasts.error(t(REFUSALS[result.error ?? ""] ?? "saved.failed"));
|
||||
return false;
|
||||
}
|
||||
|
||||
export const savedUi = {
|
||||
get available(): boolean {
|
||||
const account = accounts.selected;
|
||||
return account !== null && !account.is_bot && account.tg_user_id !== null;
|
||||
},
|
||||
|
||||
async forward(chatId: number, messageId: number): Promise<void> {
|
||||
try {
|
||||
const result = await forwardToSaved(chatId, messageId);
|
||||
if (report(result) && result.status === "done") {
|
||||
toasts.show(
|
||||
t(result.mode === "copied" ? "saved.copied" : "saved.forwarded")
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
toasts.error(t("saved.failed"));
|
||||
}
|
||||
},
|
||||
|
||||
isSavedChat(chatId: number): boolean {
|
||||
return this.available && accounts.selected?.tg_user_id === chatId;
|
||||
},
|
||||
|
||||
async send(draft: SavedDraft): Promise<boolean> {
|
||||
try {
|
||||
return report(await sendToSaved(draft));
|
||||
} catch {
|
||||
toasts.error(t("saved.failed"));
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -2,9 +2,11 @@
|
||||
import { untrack } from "svelte";
|
||||
import ChatHeader from "#lib/components/ChatHeader.svelte";
|
||||
import MessageList from "#lib/components/MessageList.svelte";
|
||||
import SavedComposer from "#lib/components/SavedComposer.svelte";
|
||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||
import { chats } from "#lib/stores/chats.svelte.js";
|
||||
import { MARK_PARAM, withoutMessage } from "#lib/stores/feed.svelte.js";
|
||||
import { savedUi } from "#lib/stores/saved.svelte.js";
|
||||
import { ui } from "#lib/stores/ui.svelte.js";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
@@ -44,6 +46,9 @@
|
||||
<div class="chat-body">
|
||||
<MessageList {chatId} />
|
||||
</div>
|
||||
{#if savedUi.isSavedChat(chatId)}
|
||||
<SavedComposer />
|
||||
{/if}
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
|
||||
Reference in new issue
Block a user