diff --git a/app/components/DialogInput/DialogInput.tsx b/app/components/DialogInput/DialogInput.tsx index 9314207..e529a4e 100644 --- a/app/components/DialogInput/DialogInput.tsx +++ b/app/components/DialogInput/DialogInput.tsx @@ -4,7 +4,7 @@ import { Box, Divider, Flex, Menu, Popover, Text, Transition, useComputedColorSc import { IconBarrierBlock, IconCamera, IconDoorExit, IconFile, IconMoodSmile, IconPaperclip, IconSend } from "@tabler/icons-react"; import { useEffect, useRef, useState } from "react"; import { useBlacklist } from "@/app/providers/BlacklistProvider/useBlacklist"; -import { base64ImageToBlurhash, filePrapareForNetworkTransfer, generateRandomKey, imagePrepareForNetworkTransfer } from "@/app/utils/utils"; +import { filePrapareForNetworkTransfer, generateRandomKey, imagePrepareForNetworkTransfer } from "@/app/utils/utils"; import { Attachment, AttachmentType } from "@/app/providers/ProtocolProvider/protocol/packets/packet.message"; import { DialogAttachment } from "../DialogAttachment/DialogAttachment"; import { PacketTyping } from "@/app/providers/ProtocolProvider/protocol/packets/packet.typeing"; @@ -229,7 +229,7 @@ export function DialogInput() { blob: avatars[0].avatar, id: generateRandomKey(8), type: AttachmentType.AVATAR, - preview: await base64ImageToBlurhash(avatars[0].avatar) + preview: "" }]); if(editableDivRef.current){ editableDivRef.current.focus(); @@ -264,11 +264,12 @@ export function DialogInput() { const file = item.getAsFile(); if (file) { const base64Image = await imagePrepareForNetworkTransfer(file); + const attachmentId = generateRandomKey(8); setAttachments([...attachments, { blob: base64Image, - id: generateRandomKey(8), + id: attachmentId, type: AttachmentType.IMAGE, - preview: await base64ImageToBlurhash(base64Image) + preview: "" }]); } if(editableDivRef.current){ @@ -297,9 +298,10 @@ export function DialogInput() { return; } let fileContent = await filePrapareForNetworkTransfer(file); + const attachmentId = generateRandomKey(8); setAttachments([...attachments, { blob: fileContent, - id: generateRandomKey(8), + id: attachmentId, type: AttachmentType.FILE, preview: files[0].size + "::" + files[0].name }]); diff --git a/app/components/MessageAttachments/MessageAvatar.tsx b/app/components/MessageAttachments/MessageAvatar.tsx index 5523635..99b1e01 100644 --- a/app/components/MessageAttachments/MessageAvatar.tsx +++ b/app/components/MessageAttachments/MessageAvatar.tsx @@ -4,12 +4,12 @@ import { AspectRatio, Button, Flex, Paper, Text } from "@mantine/core"; import { IconArrowDown } from "@tabler/icons-react"; import { useEffect, useRef, useState } from "react"; import { AttachmentProps } from "./MessageAttachments"; -import { blurhashToBase64Image } from "@/app/utils/utils"; import { AnimatedRoundedProgress } from "../AnimatedRoundedProgress/AnimatedRoundedProgress"; import { ImageToView } from "@/app/providers/ImageViewerProvider/ImageViewerProvider"; import { DownloadStatus, useAttachment } from "@/app/providers/AttachmentProvider/useAttachment"; import { PopoverLockIconAvatar } from "../PopoverLockIconAvatar/PopoverLockIconAvatar"; import { useRosettaBreakpoints } from "@/app/hooks/useRosettaBreakpoints"; +import { blurhashToBase64Image } from "@/app/workers/image/image"; export function MessageAvatar(props: AttachmentProps) { const colors = useRosettaColors(); @@ -25,10 +25,12 @@ export function MessageAvatar(props: AttachmentProps) { const preview = getPreview(); const [blob, setBlob] = useState(props.attachment.blob); const {lg} = useRosettaBreakpoints(); + const [blurhashPreview, setBlurhashPreview] = useState(""); useEffect(() => { constructBlob(); + constructFromBlurhash(); }, [downloadStatus]); const constructBlob = async () => { @@ -57,6 +59,12 @@ export function MessageAvatar(props: AttachmentProps) { } } + const constructFromBlurhash = async () => { + if (preview.length < 20) return; + const blob = await blurhashToBase64Image(preview, 200, 220); + setBlurhashPreview(blob); + } + return ( @@ -79,7 +87,7 @@ export function MessageAvatar(props: AttachmentProps) { height: 60, borderRadius: '50%', objectFit: 'cover' - }} src={/*block render???*/blurhashToBase64Image(preview, 200, 220)}> + }} src={blurhashPreview == "" ? undefined : blurhashPreview}> )} diff --git a/app/components/MessageAttachments/MessageImage.tsx b/app/components/MessageAttachments/MessageImage.tsx index 5e22afe..e04ea40 100644 --- a/app/components/MessageAttachments/MessageImage.tsx +++ b/app/components/MessageAttachments/MessageImage.tsx @@ -5,10 +5,11 @@ import { AspectRatio, Box, Flex, Overlay, Portal, Text } from "@mantine/core"; import { IconArrowDown, IconCircleX, IconFlameFilled } from "@tabler/icons-react"; import { useEffect, useRef, useState } from "react"; import { AttachmentProps } from "./MessageAttachments"; -import { blurhashToBase64Image, isMessageDeliveredByTime } from "@/app/utils/utils"; +import { isMessageDeliveredByTime } from "@/app/utils/utils"; import { AnimatedRoundedProgress } from "../AnimatedRoundedProgress/AnimatedRoundedProgress"; import { ImageToView } from "@/app/providers/ImageViewerProvider/ImageViewerProvider"; import { DownloadStatus, useAttachment } from "@/app/providers/AttachmentProvider/useAttachment"; +import { blurhashToBase64Image } from "@/app/workers/image/image"; export function MessageImage(props: AttachmentProps) { const colors = useRosettaColors(); @@ -25,9 +26,11 @@ export function MessageImage(props: AttachmentProps) { const preview = getPreview(); const [blob, setBlob] = useState(props.attachment.blob); const [loadedImage, setLoadedImage] = useState(false); + const [blurhashPreview, setBlurhashPreview] = useState(""); useEffect(() => { constructBlob(); + constructFromBlurhash(); }, [downloadStatus]); const constructBlob = async () => { @@ -45,6 +48,12 @@ export function MessageImage(props: AttachmentProps) { open(images, 0); } + const constructFromBlurhash = async () => { + if (preview.length < 20) return; + const blob = await blurhashToBase64Image(preview, 200, 220); + setBlurhashPreview(blob); + } + const onClick = () => { if (downloadStatus == DownloadStatus.DOWNLOADED) { openImageViewer(); @@ -83,7 +92,7 @@ export function MessageImage(props: AttachmentProps) { borderRadius: 8, objectFit: 'cover', border: '1px solid ' + colors.borderColor - }} src={/*block render???*/blurhashToBase64Image(preview, 200, 220)}> + }} src={blurhashPreview == "" ? undefined : blurhashPreview}> )} diff --git a/app/providers/DialogProvider/DialogProvider.tsx b/app/providers/DialogProvider/DialogProvider.tsx index 448edf4..7aed63d 100644 --- a/app/providers/DialogProvider/DialogProvider.tsx +++ b/app/providers/DialogProvider/DialogProvider.tsx @@ -25,6 +25,7 @@ import { useSystemAccounts } from '../SystemAccountsProvider/useSystemAccounts'; import { useDialogsList } from '../DialogListProvider/useDialogsList'; import { useGroups } from './useGroups'; import { useMentions } from '../DialogStateProvider.tsx/useMentions'; +import { base64ImageToBlurhash } from '@/app/workers/image/image'; export interface DialogContextValue { loading: boolean; @@ -923,6 +924,14 @@ export function DialogProvider(props: DialogProviderProps) { }); continue; } + if((attachment.type == AttachmentType.IMAGE + || attachment.type == AttachmentType.AVATAR) && attachment.preview == ""){ + /** + * Загружаем превью blurhash для изображения + */ + const blurhash = await base64ImageToBlurhash(attachment.blob); + attachment.preview = blurhash; + } doTimestampUpdateImMessageWhileAttachmentsSend(attachments); const content = await encodeWithPassword(password, attachment.blob); const upid = attachment.id; diff --git a/app/utils/utils.ts b/app/utils/utils.ts index 34b2d40..8278712 100644 --- a/app/utils/utils.ts +++ b/app/utils/utils.ts @@ -1,6 +1,5 @@ import { MantineColor } from "@mantine/core"; import { MESSAGE_MAX_TIME_TO_DELEVERED_S } from "../constants"; -import { decode, encode } from "blurhash"; export function generateRandomKey(length: number): string { const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; @@ -243,47 +242,3 @@ export function isImage(blob : string) : boolean { } return blob.startsWith('data:image/'); } - -export function blurhashToBase64Image(blurhash: string, width: number, height: number): string { - const pixels = decode(blurhash, width, height); - const canvas = document.createElement('canvas'); - canvas.width = width; - canvas.height = height; - const ctx = canvas.getContext('2d'); - const imageData = ctx?.createImageData(width, height); - if (imageData) { - imageData.data.set(pixels); - ctx?.putImageData(imageData, 0, 0); - return canvas.toDataURL(); - } - return ''; -} - -export function base64ImageToBlurhash(base64Image: string): Promise { - const img = new Image(); - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); - - return new Promise((resolve, reject) => { - img.onload = () => { - canvas.width = img.width; - canvas.height = img.height; - ctx?.drawImage(img, 0, 0); - const imageData = ctx?.getImageData(0, 0, canvas.width, canvas.height); - if (imageData) { - const blurhash = encode( - imageData.data, - imageData.width, - imageData.height, - 4, - 4 - ); - resolve(blurhash); - } else { - reject('Failed to get image data from canvas.'); - } - }; - img.onerror = (error) => reject(error); - img.src = base64Image; - }); -} \ No newline at end of file diff --git a/app/workers/image/image.ts b/app/workers/image/image.ts new file mode 100644 index 0000000..33f3651 --- /dev/null +++ b/app/workers/image/image.ts @@ -0,0 +1,39 @@ +// ...existing code... +const worker = new Worker(new URL('./image.worker.ts', import.meta.url), { type: 'module' }); + +type WorkerReq = + | { id: number; type: 'blurhashToBase64Image'; payload: { blurhash: string; width: number; height: number } } + | { id: number; type: 'base64ImageToBlurhash'; payload: { base64Image: string } }; + +type WorkerRes = + | { id: number; ok: true; data: string } + | { id: number; ok: false; error: string }; + +let seq = 0; +const pending = new Map void>(); + +worker.onmessage = (e: MessageEvent) => { + const res = e.data; + const cb = pending.get(res.id); + if (cb) { + pending.delete(res.id); + cb(res); + } +}; + +function callWorker(req: Omit): Promise { + return new Promise((resolve, reject) => { + const id = ++seq; + pending.set(id, (res) => (res.ok ? resolve(res.data) : reject(res.error))); + worker.postMessage({ ...req, id }); + }); +} + +export function blurhashToBase64Image(blurhash: string, width: number, height: number): Promise { + return callWorker({ type: 'blurhashToBase64Image', payload: { blurhash, width, height } }); +} + +export function base64ImageToBlurhash(base64Image: string): Promise { + return callWorker({ type: 'base64ImageToBlurhash', payload: { base64Image } }); +} +// ...existing code... \ No newline at end of file diff --git a/app/workers/image/image.worker.ts b/app/workers/image/image.worker.ts new file mode 100644 index 0000000..6d5842a --- /dev/null +++ b/app/workers/image/image.worker.ts @@ -0,0 +1,71 @@ +import { decode, encode } from 'blurhash'; + +type Req = + | { id: number; type: 'blurhashToBase64Image'; payload: { blurhash: string; width: number; height: number } } + | { id: number; type: 'base64ImageToBlurhash'; payload: { base64Image: string } }; + +type Res = + | { id: number; ok: true; data: string } + | { id: number; ok: false; error: string }; + +const toBase64 = async (blurhash: string, width: number, height: number): Promise => { + const pixels = decode(blurhash, width, height); + const canvas = new OffscreenCanvas(width, height); + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('No 2d context'); + const imageData = ctx.createImageData(width, height); + imageData.data.set(pixels); + ctx.putImageData(imageData, 0, 0); + const blob = await canvas.convertToBlob({ type: 'image/png' }); + const buf = new Uint8Array(await blob.arrayBuffer()); + let bin = ''; + for (let i = 0; i < buf.length; i++) bin += String.fromCharCode(buf[i]); + return `data:image/png;base64,${btoa(bin)}`; +}; + +const toBlurhash = async (base64Image: string): Promise => { + const src = base64Image?.trim(); + if (!src) throw new Error('Empty image data'); + + const resp = await fetch(src); + const blob = await resp.blob(); + if (!blob.size) throw new Error('Image fetch returned empty blob'); + + const bitmap = await createImageBitmap(blob); + const { width, height } = bitmap; + if (!width || !height) { + bitmap.close(); + throw new Error(`Image has invalid size ${width}x${height}`); + } + + const canvas = new OffscreenCanvas(width, height); + const ctx = canvas.getContext('2d'); + if (!ctx) { + bitmap.close(); + throw new Error('No 2d context'); + } + + ctx.drawImage(bitmap, 0, 0, width, height); + bitmap.close(); + + const imageData = ctx.getImageData(0, 0, width, height); + return encode(imageData.data, imageData.width, imageData.height, 4, 4); +}; + +self.onmessage = async (e: MessageEvent) => { + const { id, type, payload } = e.data; + const reply = (res: Res) => (self as unknown as Worker).postMessage(res); + try { + if (type === 'blurhashToBase64Image') { + const data = await toBase64(payload.blurhash, payload.width, payload.height); + reply({ id, ok: true, data }); + } else if (type === 'base64ImageToBlurhash') { + const data = await toBlurhash(payload.base64Image); + reply({ id, ok: true, data }); + } else { + throw new Error(`Unknown type ${type}`); + } + } catch (err: any) { + reply({ id, ok: false, error: String(err?.message ?? err) }); + } +}; \ No newline at end of file