diff --git a/README.md b/README.md index 20cab80..8999490 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ resource. ## Versioning Chat follows the Qortium app versioning standard (QAVS): the current app -version is 2.0.28, where the `2.0` prefix declares the minimum Qortium platform +version is 2.0.29, where the `2.0` prefix declares the minimum Qortium platform level the app is built against (Qortium Home 2) and the last number is the app's own release counter. The build emits a `qortium-app.json` manifest (see `vite.config.ts`) that Qortium Home reads from the published root to show the @@ -171,8 +171,10 @@ matching actions — `OPEN_QDN_RESOURCE_VIEWER` / `SAVE_QDN_RESOURCE` for a public resource, `OPEN_CHAT_ATTACHMENT_VIEWER` / `SAVE_CHAT_ATTACHMENT` for a private attachment — and a private image preview also carries Open/Save buttons of its own, exactly like a file attachment's chip. An inline -`data:` image is not a QDN resource, so its lightbox has no Open/Save; saving -app-held bytes needs a host capability Home does not offer yet. +`data:` image is not a QDN resource, so its lightbox has no Open; since +Chat 2.0.29 it offers Save on hosts that take app-held bytes +(`SAVE_FILE_BYTES`, Home 2.1.0-beta.12 and later), writing the image through +Home's save dialog under a name derived from its caption. ## Group events and moderation diff --git a/package.json b/package.json index 3590b01..79886f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "qortium-chat", - "version": "2.0.28", + "version": "2.0.29", "private": true, "license": "0BSD", "description": "A small QDN chat app for Qortium Home.", diff --git a/src/MessageList.tsx b/src/MessageList.tsx index 9997613..4371962 100644 --- a/src/MessageList.tsx +++ b/src/MessageList.tsx @@ -54,12 +54,14 @@ import { type QdnMediaResource, } from './messageLinks'; import { formatAttachmentSize } from './attachments'; +import { inlineImageToFile, type InlineImageFile } from './inlineImageFile'; import { copyTextToClipboard } from './clipboard'; import { getChatAttachmentStreamUrl, isPrivateAttachmentDescriptor, openChatAttachmentViewer, saveChatAttachment, + saveFileBytes, } from './coreApi'; import { getMessageSenderLabel, @@ -1871,6 +1873,16 @@ export const MessageList = memo(function MessageList({ // A public image embed opens in Home's shared viewer the way a document // does (OPEN_QDN_RESOURCE_VIEWER accepts every non-archive service). + function saveInlineImage(imageNetwork: ChatNetwork, file: InlineImageFile) { + void saveFileBytes( + imageNetwork, + file, + imageNetwork === 'qortal' ? qortalResourceActions : qortiumResourceActions, + ).catch((error) => { + console.warn('Unable to save inline image.', error); + }); + } + function openImageResource(resource: QdnImageResource) { void openQdnImageViewer(resource).catch((error) => { console.warn('Unable to open QDN resource viewer.', error); @@ -2287,9 +2299,18 @@ export const MessageList = memo(function MessageList({ onOpenWebLink && hasResourceAction(network, 'OPEN_EXTERNAL_LINK') ? (url) => onOpenWebLink(network, url) : null, - // Inline (data:) images are not QDN resources, so the - // lightbox carries no Open/Save for them. - openInlineImage: (image) => onOpenImage({ alt: image.alt, name: image.alt, src: image.src }), + // Inline (data:) images are not QDN resources: no Home + // viewer, and Save only where the host takes app-held + // bytes (SAVE_FILE_BYTES, Home 2.1.0-beta.12+). + openInlineImage: (image) => { + const file = hasResourceAction(network, 'SAVE_FILE_BYTES') ? inlineImageToFile(image.src, image.alt) : null; + onOpenImage({ + actions: file ? { onSave: () => saveInlineImage(network, file) } : undefined, + alt: image.alt, + name: image.alt, + src: image.src, + }); + }, }) ) : imageResources.length > 0 ? null : ( diff --git a/src/coreApi.ts b/src/coreApi.ts index cafd61d..b2579f1 100644 --- a/src/coreApi.ts +++ b/src/coreApi.ts @@ -1650,6 +1650,28 @@ export async function getQdnResourceStreamUrl( }); } +// Home 2.1.0-beta.12+: bytes the app already holds (an inline data: image), +// written through Home's native save dialog. The host validates size (≤25 MiB), +// file name (leaf only) and media type; the dialog is the consent. +export async function saveFileBytes( + network: ChatNetwork, + file: { readonly bytesBase64: string; readonly fileName: string; readonly mimeType?: string }, + actions?: QdnAction[], +): Promise<{ canceled: boolean }> { + if (!hasBridgeAction(actions, 'SAVE_FILE_BYTES')) { + throw new Error('Saving a file requires a newer Qortium Home bridge.'); + } + + const raw = await bridgeRequest<{ canceled?: boolean }>(network, { + action: 'SAVE_FILE_BYTES', + bytesBase64: file.bytesBase64, + fileName: file.fileName, + ...(file.mimeType ? { mimeType: file.mimeType } : {}), + }); + + return { canceled: raw?.canceled === true }; +} + export async function saveQdnResource( network: ChatNetwork, coordinate: QdnResourceCoordinate, diff --git a/src/inlineImageFile.test.ts b/src/inlineImageFile.test.ts new file mode 100644 index 0000000..253cd3a --- /dev/null +++ b/src/inlineImageFile.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { inlineImageToFile } from './inlineImageFile'; + +const webp = 'data:image/webp;base64,UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAwA0JaQAA3AA/vuUAAA='; + +describe('inlineImageToFile', () => { + it('splits a validated data: image into bytes, name and type', () => { + expect(inlineImageToFile(webp, 'a cat')).toEqual({ + bytesBase64: 'UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAwA0JaQAA3AA/vuUAAA=', + fileName: 'a cat.webp', + mimeType: 'image/webp', + }); + expect(inlineImageToFile('data:image/jpeg;base64,AAAA', 'photo.jpeg')?.fileName).toBe('photo.jpg'); + expect(inlineImageToFile('data:image/png;base64,AAAA', '')?.fileName).toBe('image.png'); + }); + + it('keeps the name a leaf and bounded', () => { + expect(inlineImageToFile('data:image/png;base64,AAAA', '../../etc/passwd')?.fileName).toBe('.._.._etc_passwd.png'); + expect(inlineImageToFile('data:image/png;base64,AAAA', 'x'.repeat(200))?.fileName).toBe(`${'x'.repeat(60)}.png`); + }); + + it('refuses anything that is not an inline image', () => { + expect(inlineImageToFile('https://example.com/a.png', 'a')).toBeNull(); + expect(inlineImageToFile('data:text/html;base64,AAAA', 'a')).toBeNull(); + expect(inlineImageToFile('data:image/svg+xml;base64,AAAA', 'a')).toBeNull(); + }); +}); diff --git a/src/inlineImageFile.ts b/src/inlineImageFile.ts new file mode 100644 index 0000000..e2fa5f4 --- /dev/null +++ b/src/inlineImageFile.ts @@ -0,0 +1,30 @@ +// An inline message image (`![alt](data:image/…;base64,…)`, richText.ts) as +// the bytes + name Home's SAVE_FILE_BYTES takes. The URI shape was validated +// by the parser (webp/jpeg/png, base64), so this only splits it apart and +// derives a file name from the alt text; null for anything else. +const DATA_IMAGE = /^data:(image\/(webp|jpeg|png));base64,([A-Za-z0-9+/]+={0,2})$/; + +export type InlineImageFile = { + bytesBase64: string; + fileName: string; + mimeType: string; +}; + +export function inlineImageToFile(src: string, alt: string): InlineImageFile | null { + const match = DATA_IMAGE.exec(src); + + if (!match) { + return null; + } + + const extension = match[2] === 'jpeg' ? 'jpg' : match[2]; + const stem = + alt + .trim() + .replace(/[\u0000-\u001f\u007f<>:"/\\|?*]/g, '_') + .replace(/\.[a-z0-9]{1,5}$/i, '') + .replace(/[. ]+$/g, '') + .slice(0, 60) || 'image'; + + return { bytesBase64: match[3], fileName: `${stem}.${extension}`, mimeType: match[1] }; +} diff --git a/src/referenceExamples.ts b/src/referenceExamples.ts index 3868361..501aa95 100644 --- a/src/referenceExamples.ts +++ b/src/referenceExamples.ts @@ -286,6 +286,7 @@ export const BRIDGE_ACTION_ROSTER = { 'OPEN_QDN_MEDIA_PLAYER', 'OPEN_QDN_DOCUMENT_VIEWER', 'SAVE_QDN_RESOURCE', + 'SAVE_FILE_BYTES', 'OPEN_NEW_TAB', 'OPEN_EXTERNAL_LINK', 'FETCH_ACCOUNT_AVATAR',