Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 f41de57893 fix(translate): no "use the Lotus desktop app" advice inside the desktop app (#349)
CI / Build & Quality Checks (pull_request) Waiting to run
CI / Trigger Desktop Build (pull_request) Blocked by required conditions
CI / Secret scan (gitleaks) (pull_request) Waiting to run
CI / Docker image build & smoke test (pull_request) Blocked by required conditions
CI / Playwright smoke (e2e) (pull_request) Blocked by required conditions
Where the on-device Translator/LanguageDetector APIs are missing,
Settings said to use Chrome/Edge "or the Lotus desktop app", but the
Linux and macOS apps (WebKit) lack them too, and showed the advice to
people already in the app. Word it by platform. (The per-message
Translate action was already hidden where unsupported.)

Fixes #349

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 00:19:48 -04:00
5 changed files with 26 additions and 75 deletions
@@ -108,6 +108,7 @@ import {
TRANSLATE_TARGET_LANGUAGES,
normalizeLang,
isSupportedTargetLang,
translationUnavailableText,
} from '../../../utils/translation/langUtils';
import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine';
import { SequenceCardStyle } from '../styles.css';
@@ -2760,7 +2761,7 @@ function Messages() {
description={
translationSupported
? 'Translate messages written in other languages into this one. Runs entirely on your device — message text never leaves your computer or touches a cloud service.'
: 'On-device translation isn’t available in this browser. Use a Chromium desktop browser (Chrome/Edge 138+) or the Lotus desktop app.'
: translationUnavailableText(isTauriEnv())
}
after={
<SettingsSelect
+2 -31
View File
@@ -1,11 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
formatFileSize,
hasTransparency,
isAnimatedWebp,
isCompressible,
} from './imageCompression';
import { formatFileSize, isCompressible } from './imageCompression';
test('formatFileSize formats bytes below 1KB as B', () => {
assert.equal(formatFileSize(0), '0 B');
@@ -30,38 +25,14 @@ test('formatFileSize formats values in the MB range', () => {
test('isCompressible accepts raster image types', () => {
assert.equal(isCompressible({ type: 'image/png' } as Blob), true);
assert.equal(isCompressible({ type: 'image/jpeg' } as Blob), true);
assert.equal(isCompressible({ type: 'image/gif' } as Blob), true);
assert.equal(isCompressible({ type: 'image/webp' } as Blob), true);
});
test('isCompressible rejects svg, empty type, and non-images', () => {
assert.equal(isCompressible({ type: 'image/svg+xml' } as Blob), false);
// [Gitea #331] usually animated; a canvas keeps one frame
assert.equal(isCompressible({ type: 'image/gif' } as Blob), false);
assert.equal(isCompressible({ type: 'image/apng' } as Blob), false);
assert.equal(isCompressible({ type: '' } as Blob), false);
assert.equal(isCompressible({ type: 'application/pdf' } as Blob), false);
assert.equal(isCompressible({ type: 'text/plain' } as Blob), false);
assert.equal(isCompressible({ type: 'video/mp4' } as Blob), false);
});
const webpHeader = (chunk: string, flags = 0) => {
const b = new Uint8Array(32);
const put = (at: number, str: string) => b.set(new TextEncoder().encode(str), at);
put(0, 'RIFF');
put(8, 'WEBP');
put(12, chunk);
b[20] = flags;
return b;
};
test('isAnimatedWebp: VP8X with the animation flag (#331)', () => {
assert.equal(isAnimatedWebp(webpHeader('VP8X', 0x02)), true);
assert.equal(isAnimatedWebp(webpHeader('VP8X', 0x10)), false, 'alpha flag only');
assert.equal(isAnimatedWebp(webpHeader('VP8 ')), false, 'simple lossy');
assert.equal(isAnimatedWebp(new Uint8Array(8)), false);
});
test('hasTransparency (#331)', () => {
assert.equal(hasTransparency(new Uint8ClampedArray([1, 2, 3, 255, 4, 5, 6, 255])), false);
assert.equal(hasTransparency(new Uint8ClampedArray([1, 2, 3, 255, 0, 0, 0, 0])), true);
});
+5 -43
View File
@@ -8,32 +8,11 @@ export type CompressionResult = {
height: number;
};
// Raster images the browser can render to canvas can be compressed to JPEG.
// SVG is vector and must stay as-is. [Gitea #331] GIF and APNG are usually
// animated, and a canvas keeps only the first frame, so they aren't offered.
// Empty type string (undetected MIME) is excluded.
const NOT_COMPRESSIBLE = new Set(['image/svg+xml', 'image/gif', 'image/apng']);
// Any raster image the browser can render to canvas can be compressed to JPEG.
// SVG is vector and must stay as-is; GIF animation is lost on canvas but static
// frames compress fine. Empty type string (undetected MIME) is excluded.
const isCompressibleType = (type: string): boolean =>
type.startsWith('image/') && !NOT_COMPRESSIBLE.has(type);
/**
* Pure: whether a WebP file is animated. An animated WebP is a VP8X file
* whose header has the animation flag (byte 20, bit 0x02) and an ANIM chunk.
*/
export function isAnimatedWebp(bytes: Uint8Array): boolean {
const ascii = (at: number, len: number) => String.fromCharCode(...bytes.subarray(at, at + len));
if (bytes.length < 21 || ascii(0, 4) !== 'RIFF' || ascii(8, 4) !== 'WEBP') return false;
// Bit 0x02 of the VP8X flags byte (no-bitwise: test it arithmetically).
return ascii(12, 4) === 'VP8X' && Math.floor(bytes[20] / 2) % 2 === 1;
}
/** Pure: whether RGBA pixel data has any pixel that isn't fully opaque. */
export function hasTransparency(rgba: Uint8ClampedArray): boolean {
for (let i = 3; i < rgba.length; i += 4) {
if (rgba[i] !== 255) return true;
}
return false;
}
type.startsWith('image/') && type !== 'image/svg+xml' && type !== '';
/** Returns true if this file can be compressed via canvas (any raster image type). */
export function isCompressible(file: File | Blob): boolean {
@@ -49,8 +28,7 @@ const JPEG_OUTPUT_TYPE = 'image/jpeg';
*
* PNG is skipped entirely: it may carry an alpha channel, and re-encoding to
* JPEG composites transparency onto an opaque (black) background, corrupting the
* image. Any other image with transparent pixels, and animated WebP, are left
* alone for the same reason (#331). Returning null makes callers fall back to uploading the lossless
* image. Returning null makes callers fall back to uploading the lossless
* original. The image is decoded with `imageOrientation: 'from-image'` so any
* EXIF orientation is baked into the pixels instead of being silently dropped.
*/
@@ -61,15 +39,6 @@ export async function compressImage(
if (!isCompressibleType(file.type)) return null;
// Skip PNG (potential alpha) — re-encoding to JPEG would flatten transparency.
if (file.type === 'image/png') return null;
// [Gitea #331] An animated WebP would become a single still frame.
if (file.type === 'image/webp') {
try {
const head = new Uint8Array(await file.slice(0, 32).arrayBuffer());
if (isAnimatedWebp(head)) return null;
} catch {
return null;
}
}
let bitmap: ImageBitmap;
try {
@@ -91,13 +60,6 @@ export async function compressImage(
}
ctx.drawImage(bitmap, 0, 0);
bitmap.close();
// [Gitea #331] JPEG has no alpha: a transparent WebP (sticker, logo) came out
// with a black background. Keep any image with transparency as the original.
try {
if (hasTransparency(ctx.getImageData(0, 0, width, height).data)) return null;
} catch {
return null;
}
return new Promise((resolve) => {
canvas.toBlob(
@@ -1,6 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
translationUnavailableText,
normalizeLang,
sameLanguage,
languageName,
@@ -53,3 +54,9 @@ test('curated list: unique codes, English present', () => {
assert.equal(new Set(codes).size, codes.length);
assert.ok(codes.includes('en'));
});
test('translationUnavailableText: no "use the desktop app" inside the desktop app (#349)', () => {
assert.doesNotMatch(translationUnavailableText(true), /desktop app\.$|Lotus desktop app/);
assert.match(translationUnavailableText(true), /this version of the desktop app/);
assert.match(translationUnavailableText(false), /this browser/);
});
+10
View File
@@ -85,3 +85,13 @@ export function languageName(code: string, uiLocale = 'en'): string {
export function isSupportedTargetLang(code: string): boolean {
return CURATED_NAME.has(normalizeLang(code));
}
/**
* [Gitea #349] Why translation is unavailable, by platform. The desktop app
* on Linux/macOS (WebKit) lacks the on-device APIs too, so it must not send
* people to "the Lotus desktop app" they're already using.
*/
export const translationUnavailableText = (desktopApp: boolean): string =>
desktopApp
? 'On-device translation isn’t available in this version of the desktop app. It works in Chrome or Edge 138+ on a computer.'
: 'On-device translation isn’t available in this browser. Use Chrome or Edge 138+ on a computer.';