Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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.",
Expand Down
27 changes: 24 additions & 3 deletions src/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 : (
<span className="message__body-placeholder">
Expand Down
22 changes: 22 additions & 0 deletions src/coreApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
27 changes: 27 additions & 0 deletions src/inlineImageFile.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
30 changes: 30 additions & 0 deletions src/inlineImageFile.ts
Original file line number Diff line number Diff line change
@@ -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] };
}
1 change: 1 addition & 0 deletions src/referenceExamples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down