0 || isClosingBySwipe)}
style={swipeStyle}
+ use:swipeToClose={{
+ canSwipe: () => !viewerOpen && !settingsShown && !dropoutActiveAt && !isClosingBySwipe,
+ onClose: handleCloseChat,
+ onStateChange: (state) => {
+ currentDragX = state.currentDragX;
+ isSwipingChat = state.isSwipingChat;
+ isClosingBySwipe = state.isClosingBySwipe;
+ },
+ }}
on:click|capture={handleClick}
- on:mousedown={handleMouseDown}
- on:touchstart={handleTouchStart}
- on:touchmove={handleTouchMove}
- on:touchend={handleTouchEnd}
- on:touchcancel={handleTouchCancel}
>
@@ -1550,41 +1012,13 @@
/>
{/if}
-
+
{#if $activeMedia}
@@ -1625,40 +1059,39 @@
style={"opacity: " + (allRendered ? "1;" : "0;")}
bind:this={innerList}
>
+
+
+ {#each uniqueMessages as msg (msg.id)}
+
+ {#if visibleMessages[msg.id] || !$messageHeights[msg.id]}
+
+ {#if dateSeparators[msg.id]}
+
+ {/if}
+ openMedia(e.detail.attach)}
+ on:openChat={() => openChat(chat.id, msg.id)}
+ on:openStickerPack={(e) => handleOpenStickerPack(e.detail.sticker)}
+ on:openHistory={(e) => (historyModalMessage = e.detail.msg)}
+ />
+
+ {:else}
+
+ {/if}
+
+ {/each}
-
-
- {#each uniqueMessages as msg (msg.id)}
-
- {#if visibleMessages[msg.id] || !$messageHeights[msg.id]}
-
- {#if dateSeparators[msg.id]}
-
- {/if}
- openMedia(e.detail.attach)}
- on:openChat={() => openChat(chat.id, msg.id)}
- on:openStickerPack={(e) => handleOpenStickerPack(e.detail.sticker)}
- on:openHistory={(e) => (historyModalMessage = e.detail.msg)}
- />
-
- {:else}
-
- {/if}
-
- {/each}
-
-
+
@@ -1710,36 +1143,18 @@
{messages}
{chatSettings}
{botCommands}
+ {decodedMessages}
/>
{/if}
- {#if showScrollDown}
-
-
+
+
@@ -47,15 +67,13 @@
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
- >
+
+
+
+
Внимание: после выхода из аккаунта вы, скорее всего,
не сможете прочесть сообщения из этого чата
@@ -73,7 +91,7 @@
>
Не показывать 5 минут
@@ -84,19 +102,19 @@
.modal-backdrop {
position: fixed;
inset: 0;
- background-color: rgba(0, 0, 0, 0.6);
+ background-color: rgba(0, 0, 0, 0.72);
+ backdrop-filter: blur(8px);
+ -webkit-backdrop-filter: blur(8px);
display: flex;
justify-content: center;
align-items: center;
z-index: 100;
- padding: 15px;
-
- animation: fadeIn 0.3s ease;
+ padding: 16px;
}
.modal-content {
- background-color: #ffffff;
- color: #333;
+ background-color: #1e2025;
+ color: #edf0f5;
border-radius: 16px;
padding: 24px;
width: 100%;
@@ -105,61 +123,75 @@
display: flex;
flex-direction: column;
gap: 16px;
- animation: scaleIn 0.3s ease;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ box-shadow: 0 20px 48px rgba(0, 0, 0, 0.65);
}
.modal-header {
display: flex;
flex-direction: column;
align-items: center;
- gap: 8px;
- color: #2c3e50;
+ gap: 10px;
+ color: #edf0f5;
}
- .modal-header svg {
- stroke: #2c3e50;
+ .icon-wrap {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 52px;
+ height: 52px;
+ border-radius: 50%;
+ background: rgba(34, 197, 94, 0.12);
+ color: #4ade80;
}
.modal-header h2 {
margin: 0;
- font-size: 22px;
+ font-size: 20px;
+ font-weight: 600;
+ color: #f8fafc;
}
.modal-body p {
margin: 0;
- font-size: 16px;
+ font-size: 15px;
line-height: 1.5;
- color: #555;
+ color: #94a3b8;
}
.warning-box {
- background-color: #fffbe6; /* Светло-желтый фон */
- border: 1px solid #ffe58f; /* Желтая рамка */
- border-radius: 8px;
+ background-color: rgba(234, 179, 8, 0.1);
+ border: 1px solid rgba(234, 179, 8, 0.25);
+ border-radius: 10px;
padding: 12px;
- margin-top: 16px;
+ margin-top: 14px;
display: flex;
align-items: center;
text-align: left;
gap: 10px;
}
+
.warning-box svg {
- stroke: #faad14;
+ stroke: #fbbf24;
flex-shrink: 0;
}
+
.warning-box p {
- font-size: 14px;
- color: #5e4f24;
+ font-size: 13px;
+ color: #fef08a;
+ line-height: 1.45;
}
+
.warning-box p strong {
- color: #d46b08;
+ color: #fde047;
}
.modal-actions {
display: flex;
flex-direction: column;
gap: 12px;
- margin-top: 8px;
+ margin-top: 6px;
}
.main-actions {
@@ -170,71 +202,53 @@
.btn {
width: 100%;
padding: 12px 16px;
- font-size: 16px;
+ font-size: 15px;
font-weight: 600;
border-radius: 10px;
- border: 2px solid transparent;
+ border: none;
cursor: pointer;
- transition: all 0.2s ease;
+ transition: all 0.15s ease;
-webkit-tap-highlight-color: transparent;
}
.btn:hover {
- transform: translateY(-2px);
+ transform: translateY(-1px);
}
+
.btn:active {
transform: translateY(0);
- box-shadow: none;
}
.btn.btn-primary {
- background-color: #27ae60; /* зеленый */
- color: white;
+ background-color: #22c55e;
+ color: #ffffff;
}
+
.btn.btn-primary:hover {
- background-color: #2ecc71;
+ background-color: #16a34a;
}
.btn.btn-secondary {
- background-color: #f1f2f6;
- color: #555;
+ background-color: rgba(255, 255, 255, 0.08);
+ color: #e2e8f0;
+ border: 1px solid rgba(255, 255, 255, 0.06);
}
+
.btn.btn-secondary:hover {
- background-color: #dfe4ea;
+ background-color: rgba(255, 255, 255, 0.12);
}
.btn.btn-link {
background: none;
- color: #7f8c8d;
+ color: #64748b;
font-weight: 500;
- font-size: 14px;
- padding: 4px;
+ font-size: 13px;
+ padding: 6px;
}
.btn.btn-link:hover {
- color: #34495e;
+ color: #94a3b8;
text-decoration: underline;
- box-shadow: none;
transform: none;
}
-
- @keyframes fadeIn {
- from {
- opacity: 0;
- }
- to {
- opacity: 1;
- }
- }
-
- @keyframes scaleIn {
- from {
- opacity: 0;
- transform: scale(0.95);
- }
- to {
- opacity: 1;
- transform: scale(1);
- }
- }
diff --git a/src/components/ChatWindow/InlineKeyboard.svelte b/src/components/ChatWindow/InlineKeyboard.svelte
index c5edd21..6ee7ef4 100644
--- a/src/components/ChatWindow/InlineKeyboard.svelte
+++ b/src/components/ChatWindow/InlineKeyboard.svelte
@@ -2,6 +2,7 @@
import { openUrl } from "@tauri-apps/plugin-opener";
import API from "$lib/stores/api";
import { openMiniApp } from "$lib/stores/webapp.js";
+ import { processMaxLink } from "$lib/utils/maxLink.js";
let { chat, msg, attach } = $props();
@@ -52,23 +53,62 @@
}
if (bType === "OPEN_APP") {
- const botId = button.contactId || button.botId || (chat ? chat.id : null);
- const url = button.webApp || button.url;
- const startParam = button.payload || null;
+ const botId =
+ button.contactId ||
+ button.botId ||
+ (chat?.type === "DIALOG" || chat?.type === "private" ? chat.id : null);
+ const rawUrl = button.webApp || button.url;
+ let startParam = button.payload || null;
+ let targetChatId = chat?.id ?? msg?.chatId ?? null;
+
+ if (rawUrl) {
+ try {
+ const parsed = new URL(
+ rawUrl.startsWith("max://")
+ ? rawUrl.replace(/^max:\/\//, "https://max.ru/")
+ : rawUrl
+ );
+ startParam =
+ startParam ||
+ parsed.searchParams.get("startapp") ||
+ parsed.searchParams.get("startApp") ||
+ parsed.searchParams.get("WebAppStartParam") ||
+ parsed.searchParams.get("start_param");
+ const cid = parsed.searchParams.get("chat_id");
+ if (cid) {
+ const parsedCid = Number(cid);
+ if (!Number.isNaN(parsedCid)) {
+ targetChatId = parsedCid;
+ }
+ }
+ } catch {}
+ }
+
if (botId) {
openMiniApp({
botId,
title: button.text || "Мини-приложение",
- url: url && (url.startsWith("http://") || url.startsWith("https://")) ? url : null,
startParam,
- chatId: chat?.id,
+ chatId: targetChatId,
entryPoint: "inline_button",
});
- } else if (url && (url.startsWith("http://") || url.startsWith("https://"))) {
+ return;
+ }
+
+ if (rawUrl) {
+ const handled = await processMaxLink(rawUrl, {
+ currentUserId: null,
+ api: $API,
+ onLaunchApp: (appData) => openMiniApp(appData),
+ });
+ if (handled) return;
+ }
+
+ if (rawUrl && (rawUrl.startsWith("http://") || rawUrl.startsWith("https://"))) {
try {
- await openUrl(url);
+ await openUrl(rawUrl);
} catch {
- window.open(url, "_blank");
+ window.open(rawUrl, "_blank");
}
}
return;
diff --git a/src/components/ChatWindow/MediaViewer.svelte b/src/components/ChatWindow/MediaViewer.svelte
index 06a344e..6bc7bd4 100644
--- a/src/components/ChatWindow/MediaViewer.svelte
+++ b/src/components/ChatWindow/MediaViewer.svelte
@@ -4,7 +4,7 @@
import { save } from "@tauri-apps/plugin-dialog";
import { createEventDispatcher } from "svelte";
- import { getAssetUrl, getProxiedMediaUrl } from "$lib/utils/images";
+ import { getAssetUrl, getProxiedMediaUrl, isProxiedMediaUrl, unwrapProxiedMediaUrl } from "$lib/utils/images";
import { getCurrentAccount } from "$lib/stores/accounts";
import API from "$lib/stores/api";
@@ -37,9 +37,95 @@
const MIN_SCALE = 1;
const MAX_SCALE = 4;
- const SAFE_MIN_SCALE = 1;
-
+ let encryptedVideoSrc = null;
$: currentMedia = allMedia[index];
+ $: effectiveVideoSrc = (currentMedia?.videoId ? videoCache[currentMedia.videoId] : null) || (currentMedia?._type === "VIDEO" ? (getProxiedMediaUrl(currentMedia.localPath || currentMedia.baseUrl) || encryptedVideoSrc) : null);
+
+ $: console.log("[MediaViewer] currentMedia:", {
+ idx: index,
+ type: currentMedia?._type,
+ isEncrypted: currentMedia?.isEncryptedMedia,
+ localPath: currentMedia?.localPath,
+ baseUrl: currentMedia?.baseUrl,
+ effectiveVideoSrc,
+ });
+
+ async function ensureEncryptedMediaUrl(media) {
+ if (!media || !media.isEncryptedMedia) return media?.baseUrl || null;
+ if (media.baseUrl && (media._type === "PHOTO" || isProxiedMediaUrl(media.baseUrl))) {
+ return media.baseUrl;
+ }
+ if (media.localPath) {
+ const url = media._type === "VIDEO" ? getProxiedMediaUrl(media.localPath) : convertFileSrc(media.localPath);
+ media.baseUrl = url;
+ return url;
+ }
+ const fid = media.fileId || media.encryptedAttach?.fileId;
+ if (!fid) return null;
+
+ try {
+ const account = await getCurrentAccount().catch(() => null);
+ const accountId = Number(account?.id || 0);
+ const cached = await tauriInvoke("get_cached_file", {
+ account: accountId,
+ src: `enc_media_${fid}`
+ }).catch(() => null);
+
+ if (cached) {
+ media.localPath = cached;
+ const url = media._type === "VIDEO" ? getProxiedMediaUrl(cached) : convertFileSrc(cached);
+ media.baseUrl = url;
+ console.log("[MediaViewer] Found encrypted media in cache:", fid, "path:", cached, "url:", url);
+ return url;
+ }
+
+ console.log("[MediaViewer] Downloading encrypted media:", fid);
+ const fileRes = await $API.getFileById(chatId, media.messageId, fid);
+ if (fileRes?.url) {
+ const cachedPath = await tauriInvoke("cache_encrypted_media", {
+ account: accountId,
+ chatId: Number(chatId || 0),
+ fileId: Number(fid),
+ src: fileRes.url,
+ password: null,
+ });
+ if (cachedPath) {
+ media.localPath = cachedPath;
+ const url = media._type === "VIDEO" ? getProxiedMediaUrl(cachedPath) : convertFileSrc(cachedPath);
+ media.baseUrl = url;
+ console.log("[MediaViewer] Decrypted and cached media:", fid, "url:", url);
+ return url;
+ }
+ }
+ } catch (e) {
+ console.error("[MediaViewer] Failed to ensureEncryptedMediaUrl:", e);
+ }
+ return null;
+ }
+
+ let resolvingVideo = false;
+ function resolveEncryptedVideo(media) {
+ if (media?._type === "VIDEO" && media?.isEncryptedMedia && !effectiveVideoSrc && !resolvingVideo) {
+ resolvingVideo = true;
+ ensureEncryptedMediaUrl(media).then((url) => {
+ resolvingVideo = false;
+ if (url) {
+ media.baseUrl = url;
+ if (currentMedia === media) {
+ encryptedVideoSrc = getProxiedMediaUrl(url);
+ }
+ }
+ }).catch((e) => {
+ console.error("[MediaViewer] resolveEncryptedVideo error:", e);
+ resolvingVideo = false;
+ });
+ }
+ }
+
+ $: {
+ encryptedVideoSrc = null;
+ resolveEncryptedVideo(currentMedia);
+ }
let resolvedPoster = null;
$: {
@@ -75,6 +161,7 @@
}
async function loadVideo(videoId) {
+ if (!videoId) return;
if (videoCache[videoId] || isLoading) return;
isLoading = true;
try {
@@ -94,8 +181,7 @@
if (!videoUrl && response.HLS) videoUrl = response.HLS;
if (videoUrl) {
- videoCache[videoId] =
- `http://127.0.0.1:11447/${encodeURIComponent(videoUrl)}`;
+ videoCache[videoId] = getProxiedMediaUrl(videoUrl);
videoCache = { ...videoCache };
}
} catch (error) {
@@ -108,13 +194,14 @@
async function togglePlay() {
if (!videoElement) return;
try {
+ console.log("[MediaViewer] togglePlay:", { paused: videoElement.paused, readyState: videoElement.readyState, src: videoElement.src });
if (videoElement.paused) {
await videoElement.play();
} else {
videoElement.pause();
}
} catch (e) {
- console.warn("Play interrupted:", e);
+ console.warn("[MediaViewer] Play interrupted:", e, "src:", videoElement?.src, "readyState:", videoElement?.readyState);
}
}
@@ -126,13 +213,15 @@
duration = currentMedia.duration > 1000 ? currentMedia.duration / 1000 : currentMedia.duration;
}
isMetadataLoaded = duration > 0 && !isNaN(duration);
+ console.log("[MediaViewer] handleSync:", { duration, isMetadataLoaded, src: el?.src });
}
- function handleCanPlay() {
+ function handleCanPlay(e) {
isVideoReady = true;
if (videoElement && (!duration || isNaN(duration))) {
duration = videoElement.duration;
}
+ console.log("[MediaViewer] handleCanPlay:", { duration, isVideoReady, src: e.target?.src });
}
function formatTime(seconds) {
@@ -330,7 +419,12 @@
}
async function load(url) {
- return await getAssetUrl(url);
+ let effective = url;
+ if (!effective && currentMedia?.isEncryptedMedia) {
+ effective = await ensureEncryptedMediaUrl(currentMedia);
+ }
+ if (!effective) return null;
+ return await getAssetUrl(effective);
}
let isDownloadingMedia = false;
@@ -339,10 +433,29 @@
if (isDownloadingMedia || !currentMedia) return;
isDownloadingMedia = true;
try {
+ if (currentMedia.isEncryptedMedia || (currentMedia._type === "PHOTO" && currentMedia.baseUrl) || (currentMedia._type === "VIDEO" && !currentMedia.videoId && currentMedia.baseUrl)) {
+ if (!currentMedia.baseUrl && !currentMedia.localPath) {
+ await ensureEncryptedMediaUrl(currentMedia);
+ }
+ const defaultName = currentMedia.name || (currentMedia._type === "PHOTO" ? `photo_${Date.now()}.jpg` : `video_${Date.now()}.mp4`);
+ const filePath = await save({
+ defaultPath: defaultName,
+ filters: currentMedia._type === "PHOTO"
+ ? [{ name: "Images", extensions: ["jpg", "jpeg", "png", "webp"] }]
+ : [{ name: "Videos", extensions: ["mp4", "webm", "mov"] }],
+ });
+ if (filePath) {
+ const target = currentMedia.localPath || currentMedia.baseUrl;
+ if (target) {
+ await tauriInvoke("download_to_path", { url: target, path: filePath });
+ }
+ }
+ return;
+ }
if (currentMedia._type === "PHOTO") {
const url = currentMedia.baseUrl;
if (!url) return;
- const defaultName = `photo_${Date.now()}.jpg`;
+ const defaultName = currentMedia.name || `photo_${Date.now()}.jpg`;
const filePath = await save({
defaultPath: defaultName,
filters: [{ name: "Images", extensions: ["jpg", "jpeg", "png", "webp"] }],
@@ -351,8 +464,8 @@
await tauriInvoke("download_to_path", { url, path: filePath });
}
} else if (currentMedia._type === "VIDEO") {
- let videoUrl = videoCache[currentMedia.videoId];
- if (!videoUrl) {
+ let videoUrl = videoCache[currentMedia.videoId] || currentMedia.baseUrl;
+ if (!videoUrl && currentMedia.videoId) {
const response = await $API.getVideoById(
chatId,
currentMedia.messageId,
@@ -366,11 +479,11 @@
}
}
if (!videoUrl && response.HLS) videoUrl = response.HLS;
- } else if (videoUrl.startsWith("http://127.0.0.1:11447/")) {
- videoUrl = decodeURIComponent(videoUrl.replace("http://127.0.0.1:11447/", ""));
+ } else if (videoUrl && isProxiedMediaUrl(videoUrl)) {
+ videoUrl = unwrapProxiedMediaUrl(videoUrl);
}
if (!videoUrl) return;
- const defaultName = `video_${Date.now()}.mp4`;
+ const defaultName = currentMedia.name || `video_${Date.now()}.mp4`;
const filePath = await save({
defaultPath: defaultName,
filters: [{ name: "Videos", extensions: ["mp4", "webm", "mov"] }],
@@ -442,15 +555,19 @@
{#await load(currentMedia.baseUrl)}
{:then url}
-

+ {#if url}
+

+ {:else}
+
+ {/if}
{/await}
{:else if currentMedia._type === "VIDEO"}
- {#if videoCache[currentMedia.videoId]}
+ {#if effectiveVideoSrc}
@@ -600,14 +718,14 @@
- {:else}
+ {:else if currentMedia.videoId}