Merge branch 'master' of github.com:bitsocialnet/5chan

This commit is contained in:
Tommaso Casaburi
2026-05-30 15:39:04 +07:00
28 changed files with 2134 additions and 49 deletions
@@ -0,0 +1,370 @@
import * as React from 'react';
import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import OekakiDrawingControls from '../oekaki-drawing-controls';
import { OEKAKI_MOBILE_PORTRAIT_MESSAGE, OEKAKI_WEB_DOWNLOAD_MESSAGE } from '../../../lib/oekaki/oekaki-copy';
import type { UploadedFileResult } from '../../../hooks/use-file-upload';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
const testState = vi.hoisted(() => ({
runtime: 'web' as 'web' | 'electron' | 'android',
loadTegakiMock: vi.fn(),
openMock: vi.fn(),
flattenMock: vi.fn(),
destroyMock: vi.fn(),
onOpenImageLoadedMock: vi.fn(),
}));
vi.mock('../../../lib/media-hosting/show-upload-controls', () => ({
getMediaHostingRuntime: () => testState.runtime,
}));
vi.mock('../../../lib/oekaki/tegaki-loader', () => ({
TEGAKI_DRAWING_FILE_NAME: 'tegaki.png',
loadTegaki: testState.loadTegakiMock,
}));
let container: HTMLDivElement;
let root: Root;
const OriginalImage = globalThis.Image;
interface MockTegakiOpenOptions {
width: number;
height: number;
saveReplay: boolean;
onDone: () => void;
onCancel: () => void;
}
const setViewport = (width: number, height: number) => {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: width });
Object.defineProperty(window, 'innerHeight', { configurable: true, value: height });
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: query === '(max-width: 640px) and (orientation: portrait)' && width <= 640 && height > width,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
};
const createFinishedCanvas = () => {
const canvas = document.createElement('canvas');
Object.defineProperty(canvas, 'toBlob', {
configurable: true,
value: (callback: BlobCallback) => callback(new Blob(['png'], { type: 'image/png' })),
});
return canvas;
};
class MockImage {
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
set src(_value: string) {
queueMicrotask(() => this.onload?.());
}
}
const renderControls = async ({
uploadFile = vi.fn<(file: File) => Promise<UploadedFileResult | null>>().mockResolvedValue(null),
onClearUploadedUrl = vi.fn(),
}: {
uploadFile?: (file: File) => Promise<UploadedFileResult | null>;
onClearUploadedUrl?: (url: string) => void;
} = {}) => {
await act(async () => {
root.render(createElement(OekakiDrawingControls, { uploadFile, onClearUploadedUrl }));
});
return { uploadFile, onClearUploadedUrl };
};
const getButton = (label: string): HTMLButtonElement => {
const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === label);
if (!(button instanceof HTMLButtonElement)) {
throw new Error(`Button ${label} not found`);
}
return button;
};
const clickButton = async (label: string) => {
const button = getButton(label);
await act(async () => {
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
};
const triggerTegakiDone = async () => {
const openOptions = testState.openMock.mock.calls.at(-1)?.[0] as MockTegakiOpenOptions | undefined;
if (!openOptions) {
throw new Error('Tegaki open options not captured');
}
await act(async () => {
openOptions.onDone();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
};
describe('OekakiDrawingControls', () => {
beforeEach(() => {
vi.clearAllMocks();
testState.runtime = 'web';
testState.openMock.mockReset();
testState.flattenMock.mockReset();
testState.destroyMock.mockReset();
testState.onOpenImageLoadedMock.mockReset();
testState.flattenMock.mockReturnValue(createFinishedCanvas());
const tegaki = {
bg: null as HTMLElement | null,
open: testState.openMock,
flatten: testState.flattenMock,
destroy: testState.destroyMock,
onOpenImageLoaded: testState.onOpenImageLoadedMock,
};
testState.openMock.mockImplementation(() => {
tegaki.bg = document.createElement('div');
});
testState.destroyMock.mockImplementation(() => {
tegaki.bg = null;
});
testState.loadTegakiMock.mockResolvedValue(tegaki);
Object.defineProperty(globalThis, 'Image', {
configurable: true,
value: MockImage,
});
Object.defineProperty(globalThis, 'alert', {
configurable: true,
value: vi.fn(),
writable: true,
});
Object.defineProperty(globalThis, 'confirm', {
configurable: true,
value: vi.fn(() => true),
writable: true,
});
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: vi.fn(() => 'blob:tegaki'),
});
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
value: vi.fn(),
});
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined);
setViewport(1024, 768);
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
Object.defineProperty(globalThis, 'Image', {
configurable: true,
value: OriginalImage,
});
vi.restoreAllMocks();
});
it('alerts instead of opening Tegaki on a portrait phone viewport', async () => {
setViewport(390, 844);
await renderControls();
await clickButton('Draw');
expect(globalThis.alert).toHaveBeenCalledWith(OEKAKI_MOBILE_PORTRAIT_MESSAGE);
expect(testState.loadTegakiMock).not.toHaveBeenCalled();
});
it('opens Tegaki on a landscape phone viewport', async () => {
setViewport(844, 390);
await renderControls();
await clickButton('Draw');
expect(globalThis.alert).not.toHaveBeenCalled();
expect(testState.loadTegakiMock).toHaveBeenCalledTimes(1);
expect(testState.openMock).toHaveBeenCalledWith(expect.objectContaining({ width: 400, height: 400, saveReplay: true }));
});
it('keeps Draw disabled while Tegaki is open', async () => {
await renderControls();
await clickButton('Draw');
expect(getButton('Draw').disabled).toBe(true);
await clickButton('Draw');
expect(testState.loadTegakiMock).toHaveBeenCalledTimes(1);
const openOptions = testState.openMock.mock.calls.at(-1)?.[0] as MockTegakiOpenOptions;
await act(async () => {
openOptions.onCancel();
});
expect(testState.destroyMock).toHaveBeenCalledTimes(1);
expect(getButton('Draw').disabled).toBe(false);
});
it('downloads the web drawing only after confirmation', async () => {
await renderControls();
await clickButton('Draw');
await triggerTegakiDone();
expect(globalThis.confirm).toHaveBeenCalledWith(OEKAKI_WEB_DOWNLOAD_MESSAGE);
expect(testState.destroyMock).toHaveBeenCalledTimes(1);
expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledTimes(1);
expect(URL.createObjectURL).toHaveBeenCalledOnce();
});
it('keeps Draw disabled while a finished drawing is still exporting', async () => {
let finishExport: BlobCallback | null = null;
const canvas = document.createElement('canvas');
Object.defineProperty(canvas, 'toBlob', {
configurable: true,
value: (callback: BlobCallback) => {
finishExport = callback;
},
});
testState.flattenMock.mockReturnValue(canvas);
await renderControls();
await clickButton('Draw');
const openOptions = testState.openMock.mock.calls.at(-1)?.[0] as MockTegakiOpenOptions;
await act(async () => {
openOptions.onDone();
await Promise.resolve();
});
expect(getButton('Draw').disabled).toBe(true);
expect(testState.openMock).toHaveBeenCalledTimes(1);
await act(async () => {
finishExport?.(new Blob(['png'], { type: 'image/png' }));
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
expect(getButton('Edit').disabled).toBe(false);
});
it('unlocks the controls when exporting the finished drawing fails', async () => {
const canvas = document.createElement('canvas');
Object.defineProperty(canvas, 'toBlob', {
configurable: true,
value: (callback: BlobCallback) => callback(null),
});
testState.flattenMock.mockReturnValue(canvas);
await renderControls();
await clickButton('Draw');
const openOptions = testState.openMock.mock.calls.at(-1)?.[0] as MockTegakiOpenOptions;
await act(async () => {
openOptions.onDone();
await Promise.resolve();
await Promise.resolve();
});
expect(globalThis.alert).toHaveBeenCalledWith('Could not export drawing');
expect(testState.destroyMock).toHaveBeenCalledTimes(1);
expect(getButton('Draw').disabled).toBe(false);
});
it('does not download the web drawing when confirmation is cancelled', async () => {
Object.defineProperty(globalThis, 'confirm', {
configurable: true,
value: vi.fn(() => false),
});
await renderControls();
await clickButton('Draw');
await triggerTegakiDone();
expect(globalThis.confirm).toHaveBeenCalledWith(OEKAKI_WEB_DOWNLOAD_MESSAGE);
expect(HTMLAnchorElement.prototype.click).not.toHaveBeenCalled();
expect(URL.createObjectURL).not.toHaveBeenCalled();
});
it('clears the uploaded drawing URL when Clear is clicked', async () => {
testState.runtime = 'electron';
const uploadFile = vi.fn<(file: File) => Promise<UploadedFileResult | null>>().mockResolvedValue({
url: 'https://files.example/tegaki.png',
fileName: 'tegaki.png',
});
const onClearUploadedUrl = vi.fn();
await renderControls({ uploadFile, onClearUploadedUrl });
await clickButton('Draw');
await triggerTegakiDone();
await clickButton('Clear');
expect(onClearUploadedUrl).toHaveBeenCalledWith('https://files.example/tegaki.png');
});
it('clears a stale uploaded URL when re-uploading an edited drawing fails', async () => {
testState.runtime = 'electron';
const uploadFile = vi
.fn<(file: File) => Promise<UploadedFileResult | null>>()
.mockResolvedValueOnce({ url: 'https://files.example/first.png', fileName: 'tegaki.png' })
.mockResolvedValueOnce(null);
const onClearUploadedUrl = vi.fn();
await renderControls({ uploadFile, onClearUploadedUrl });
await clickButton('Draw');
await triggerTegakiDone();
await clickButton('Edit');
await triggerTegakiDone();
expect(uploadFile).toHaveBeenCalledTimes(2);
expect(onClearUploadedUrl).toHaveBeenCalledWith('https://files.example/first.png');
});
it('starts edited drawings from a fresh Tegaki session with the saved image loaded', async () => {
testState.runtime = 'electron';
const uploadFile = vi.fn<(file: File) => Promise<UploadedFileResult | null>>().mockResolvedValue({
url: 'https://files.example/tegaki.png',
fileName: 'tegaki.png',
});
await renderControls({ uploadFile });
await clickButton('Draw');
await triggerTegakiDone();
await clickButton('Edit');
expect(testState.openMock).toHaveBeenCalledTimes(2);
expect(testState.destroyMock).toHaveBeenCalledTimes(1);
expect(testState.onOpenImageLoadedMock).toHaveBeenCalledTimes(1);
expect(URL.createObjectURL).toHaveBeenCalledWith(expect.any(File));
});
it('destroys Tegaki when an edited drawing cannot be loaded', async () => {
testState.runtime = 'electron';
const uploadFile = vi.fn<(file: File) => Promise<UploadedFileResult | null>>().mockResolvedValue({
url: 'https://files.example/tegaki.png',
fileName: 'tegaki.png',
});
await renderControls({ uploadFile });
await clickButton('Draw');
await triggerTegakiDone();
testState.onOpenImageLoadedMock.mockImplementationOnce(() => {
throw new Error('Could not restore drawing');
});
await clickButton('Edit');
expect(testState.destroyMock).toHaveBeenCalledTimes(2);
expect(globalThis.alert).toHaveBeenCalledWith('Could not restore drawing');
expect(getButton('Edit').disabled).toBe(false);
});
});
@@ -0,0 +1 @@
export { default } from './oekaki-drawing-controls';
@@ -0,0 +1,33 @@
.controls {
display: inline-flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
font-size: 10pt;
font-weight: normal;
text-transform: none;
}
.controls input.sizeInput {
text-align: center;
width: 30px !important;
}
.replayLabel {
display: inline-flex;
align-items: center;
gap: 2px;
white-space: nowrap;
}
.replayLabel input {
margin: 0;
}
.controls button {
position: static !important;
margin-left: 0 !important;
filter: var(--filter80);
cursor: pointer;
text-transform: capitalize;
}
@@ -0,0 +1,248 @@
import { useRef, useState } from 'react';
import { getMediaHostingRuntime } from '../../lib/media-hosting/show-upload-controls';
import { loadTegaki, TEGAKI_DRAWING_FILE_NAME, type TegakiGlobal } from '../../lib/oekaki/tegaki-loader';
import { OEKAKI_MOBILE_PORTRAIT_MESSAGE, OEKAKI_WEB_DOWNLOAD_MESSAGE } from '../../lib/oekaki/oekaki-copy';
import type { UploadedFileResult } from '../../hooks/use-file-upload';
import styles from './oekaki-drawing-controls.module.css';
const DEFAULT_DIMENSION = '400';
const MIN_DIMENSION = 1;
const MAX_DIMENSION = 2000;
const PNG_MIME_TYPE = 'image/png';
interface OekakiDrawingControlsProps {
disabled?: boolean;
className?: string;
uploadFile: (file: File) => Promise<UploadedFileResult | null>;
onClearUploadedUrl: (url: string) => void;
}
const parseDimension = (value: string): number => {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed)) return Number.parseInt(DEFAULT_DIMENSION, 10);
return Math.min(MAX_DIMENSION, Math.max(MIN_DIMENSION, parsed));
};
const makeDrawingFile = (blob: Blob): File => new File([blob], TEGAKI_DRAWING_FILE_NAME, { type: PNG_MIME_TYPE });
const isPhonePortraitViewport = (): boolean => {
if (typeof window === 'undefined') return false;
if (typeof window.matchMedia === 'function') {
return window.matchMedia('(max-width: 640px) and (orientation: portrait)').matches;
}
return window.innerWidth <= 640 && window.innerHeight > window.innerWidth;
};
const canvasToBlob = (canvas: HTMLCanvasElement): Promise<Blob> =>
new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
return;
}
reject(new Error('Could not export drawing'));
}, PNG_MIME_TYPE);
});
const downloadDrawing = (file: File): void => {
const url = URL.createObjectURL(file);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = TEGAKI_DRAWING_FILE_NAME;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 0);
};
const loadDrawingImage = (file: File | null): Promise<HTMLImageElement | null> =>
new Promise((resolve, reject) => {
if (!file) {
resolve(null);
return;
}
const url = URL.createObjectURL(file);
const image = new Image();
image.onload = () => {
URL.revokeObjectURL(url);
resolve(image);
};
image.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('Could not load drawing'));
};
image.src = url;
});
const destroyTegaki = (tegaki: TegakiGlobal): void => {
if (tegaki.bg && typeof tegaki.destroy === 'function') {
tegaki.destroy();
}
};
const OekakiDrawingControls = ({ disabled = false, className, uploadFile, onClearUploadedUrl }: OekakiDrawingControlsProps) => {
const [width, setWidth] = useState(DEFAULT_DIMENSION);
const [height, setHeight] = useState(DEFAULT_DIMENSION);
const [saveReplay, setSaveReplay] = useState(true);
const [drawingFile, setDrawingFile] = useState<File | null>(null);
const [isOpening, setIsOpening] = useState(false);
const [isTegakiOpen, setIsTegakiOpen] = useState(false);
const [isUploadingDrawing, setIsUploadingDrawing] = useState(false);
const uploadedDrawingUrlRef = useRef<string | null>(null);
const tegakiSessionOpenRef = useRef(false);
const runtime = getMediaHostingRuntime();
const isBusy = disabled || isOpening || isTegakiOpen || isUploadingDrawing;
const hasDrawing = drawingFile !== null;
const closeTegakiSession = () => {
tegakiSessionOpenRef.current = false;
setIsTegakiOpen(false);
};
const handleDrawingFile = async (file: File) => {
const previousUploadedUrl = uploadedDrawingUrlRef.current;
setDrawingFile(file);
if (runtime === 'web') {
uploadedDrawingUrlRef.current = null;
if (previousUploadedUrl) {
onClearUploadedUrl(previousUploadedUrl);
}
if (window.confirm(OEKAKI_WEB_DOWNLOAD_MESSAGE)) {
downloadDrawing(file);
}
return;
}
setIsUploadingDrawing(true);
try {
const result = await uploadFile(file);
if (result?.url) {
uploadedDrawingUrlRef.current = result.url;
return;
}
uploadedDrawingUrlRef.current = null;
if (previousUploadedUrl) {
onClearUploadedUrl(previousUploadedUrl);
}
} catch (error) {
uploadedDrawingUrlRef.current = null;
if (previousUploadedUrl) {
onClearUploadedUrl(previousUploadedUrl);
}
throw error;
} finally {
setIsUploadingDrawing(false);
}
};
const openTegaki = async () => {
if (isBusy || tegakiSessionOpenRef.current) return;
if (isPhonePortraitViewport()) {
window.alert(OEKAKI_MOBILE_PORTRAIT_MESSAGE);
return;
}
setIsOpening(true);
try {
const [tegaki, existingImage] = await Promise.all([loadTegaki(), loadDrawingImage(drawingFile)]);
destroyTegaki(tegaki);
tegakiSessionOpenRef.current = true;
setIsTegakiOpen(true);
tegaki.open({
width: parseDimension(width),
height: parseDimension(height),
saveReplay,
onDone: () => {
const canvas = tegaki.flatten();
setIsUploadingDrawing(true);
void canvasToBlob(canvas)
.then(makeDrawingFile)
.then(async (file) => {
destroyTegaki(tegaki);
closeTegakiSession();
await handleDrawingFile(file);
})
.catch((error) => {
destroyTegaki(tegaki);
closeTegakiSession();
window.alert(error instanceof Error ? error.message : String(error));
})
.finally(() => {
setIsUploadingDrawing(false);
});
},
onCancel: () => {
destroyTegaki(tegaki);
closeTegakiSession();
},
});
if (existingImage && typeof tegaki.onOpenImageLoaded === 'function') {
try {
tegaki.onOpenImageLoaded.call(existingImage);
} catch (error) {
destroyTegaki(tegaki);
throw error;
}
}
} catch (error) {
closeTegakiSession();
window.alert(error instanceof Error ? error.message : String(error));
} finally {
setIsOpening(false);
}
};
const clearDrawing = () => {
const uploadedUrl = uploadedDrawingUrlRef.current;
setDrawingFile(null);
uploadedDrawingUrlRef.current = null;
if (uploadedUrl) {
onClearUploadedUrl(uploadedUrl);
}
};
return (
<div className={`${styles.controls} ${className ?? ''}`}>
<span>Size</span>
<input
className={styles.sizeInput}
type='text'
inputMode='numeric'
aria-label='Oekaki width'
value={width}
disabled={isBusy}
onChange={(event) => setWidth(event.target.value)}
/>
<span>×</span>
<input
className={styles.sizeInput}
type='text'
inputMode='numeric'
aria-label='Oekaki height'
value={height}
disabled={isBusy}
onChange={(event) => setHeight(event.target.value)}
/>
<label className={styles.replayLabel}>
<input
type='checkbox'
aria-label='Replay drawing'
checked={saveReplay}
disabled={isBusy || hasDrawing}
onChange={(event) => setSaveReplay(event.target.checked)}
/>
Replay
</label>
<button type='button' onClick={openTegaki} disabled={isBusy}>
{hasDrawing ? 'Edit' : 'Draw'}
</button>
<button type='button' onClick={clearDrawing} disabled={isBusy || !hasDrawing}>
Clear
</button>
</div>
);
};
export default OekakiDrawingControls;
@@ -4,6 +4,7 @@ import { createRoot, type Root } from 'react-dom/client';
import { Link, MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import PostForm, { LinkTypePreviewer } from '../post-form';
import { OEKAKI_WEB_WARNING_TEXT } from '../../../lib/oekaki/oekaki-copy';
import { POST_OPTIONS_VALIDATION_DELAY_MS } from '../../../lib/utils/post-options-utils';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -24,10 +25,12 @@ const testState = vi.hoisted(() => ({
editedComment: undefined as { commentModeration?: { archived?: boolean }; deleted?: boolean; locked?: boolean; postCid?: string; removed?: boolean } | undefined,
gifFrameStatus: 'idle' as 'idle' | 'ready',
handleUploadMock: vi.fn(),
uploadFileMock: vi.fn(),
isOffline: false,
isOnlineStatusLoading: false,
isUploading: false,
isResolvingExternalQuotes: false,
mediaHostingRuntime: 'web' as 'web' | 'android' | 'electron',
navigateMock: vi.fn(),
offlineTitle: 'offline board',
postIndex: undefined as number | undefined,
@@ -259,6 +262,7 @@ vi.mock('../../../hooks/use-file-upload', () => ({
testState.uploadComplete = onUploadComplete;
return {
handleUpload: testState.handleUploadMock,
uploadFile: testState.uploadFileMock,
isUploading: testState.isUploading,
uploadedFileName: testState.uploadedFileName,
};
@@ -286,8 +290,9 @@ vi.mock('../../../lib/utils/media-utils', () => ({
}));
vi.mock('../../../lib/media-hosting/show-upload-controls', () => ({
getMediaHostingRuntime: () => testState.mediaHostingRuntime,
getShowUploadControls: () => testState.showUploadControls,
isWebRuntime: () => true,
isWebRuntime: () => testState.mediaHostingRuntime === 'web',
}));
vi.mock('../../../stores/use-media-hosting-store', () => ({
@@ -438,6 +443,7 @@ describe('PostForm', () => {
testState.isOnlineStatusLoading = false;
testState.isUploading = false;
testState.isResolvingExternalQuotes = false;
testState.mediaHostingRuntime = 'web';
testState.offlineTitle = 'offline board';
testState.postIndex = undefined;
testState.publishedPostOptions = undefined;
@@ -456,6 +462,7 @@ describe('PostForm', () => {
'traditional-games.bso': { address: 'traditional-games.bso' },
};
testState.handleUploadMock.mockReset();
testState.uploadFileMock.mockReset();
testState.navigateMock.mockReset();
testState.publishPostMock.mockReset();
testState.publishReplyMock.mockReset();
@@ -573,6 +580,43 @@ describe('PostForm', () => {
expect(testState.setPublishPostOptionsMock).toHaveBeenCalledWith({ communityAddress: 'music-posting.eth' });
});
it('shows Oekaki draw controls only on the /i/ board form', async () => {
testState.directories.push({
address: 'oekaki-posting.bso',
directoryCode: 'i',
features: { requirePostLink: true, requirePostLinkIsMedia: true },
title: '/i/ - Oekaki',
});
testState.communities['oekaki-posting.bso'] = { address: 'oekaki-posting.bso' };
testState.resolvedCommunityAddress = 'oekaki-posting.bso';
await renderPostForm('/i');
await clickByText(container, 'start_new_thread');
const table = container.querySelector('table') as HTMLTableElement;
const drawRow = Array.from(table.querySelectorAll('tr')).find((row) => row.textContent?.includes('Size') && row.textContent?.includes('Replay'));
expect(table.textContent).toContain('Size');
expect(table.textContent).toContain('Replay');
expect(Array.from(table.querySelectorAll('span')).some((span) => span.textContent === '×')).toBe(true);
expect(drawRow?.textContent).not.toContain(OEKAKI_WEB_WARNING_TEXT);
expect(Array.from(table.querySelectorAll('button')).some((button) => button.textContent === 'Draw')).toBe(true);
expect((Array.from(table.querySelectorAll('button')).find((button) => button.textContent === 'Clear') as HTMLButtonElement | undefined)?.disabled).toBe(true);
const rulesItems = Array.from(table.querySelectorAll('tr.rules li')).map((item) => item.textContent);
expect(rulesItems).toEqual(['Please read the Rules and FAQ before posting.', OEKAKI_WEB_WARNING_TEXT]);
testState.mediaHostingRuntime = 'electron';
await renderPostForm('/i');
await clickByText(container, 'start_new_thread');
expect(container.textContent).not.toContain(OEKAKI_WEB_WARNING_TEXT);
testState.resolvedCommunityAddress = 'music-posting.eth';
await renderPostForm('/mu');
await clickByText(container, 'start_new_thread');
expect(Array.from(container.querySelectorAll('button')).some((button) => button.textContent === 'Draw')).toBe(false);
});
it('drops stale thread content when board navigation remounts the form before a link-only post', async () => {
await renderNavigablePostForm('/mu');
await clickByText(container, 'start_new_thread');
+38 -2
View File
@@ -36,11 +36,13 @@ import usePublishPost from '../../hooks/use-publish-post';
import usePublishReply from '../../hooks/use-publish-reply';
import { useFileUpload } from '../../hooks/use-file-upload';
import { getShowUploadControls, isWebRuntime } from '../../lib/media-hosting/show-upload-controls';
import { OEKAKI_WEB_WARNING_TEXT } from '../../lib/oekaki/oekaki-copy';
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
import useMediaHostingStore from '../../stores/use-media-hosting-store';
import BoardOfflineAlert from '../board-offline-alert/board-offline-alert';
import BbcodeEditorToolbar, { BbcodePreview } from '../bbcode-editor-toolbar/bbcode-editor-toolbar';
import LoadingEllipsis from '../loading-ellipsis';
import OekakiDrawingControls from '../oekaki-drawing-controls';
import PostOptionsErrorMessage from '../post-options-error-message/post-options-error-message';
import styles from './post-form.module.css';
import capitalize from 'lodash/capitalize';
@@ -141,6 +143,7 @@ interface PostFormFieldsProps {
isUploading: boolean;
uploadedFileName: string | null | undefined;
showUploadControls: boolean;
showOekakiControls: boolean;
showSpoilerForPost: boolean;
showSpoilerForReply: boolean;
isInAllView: boolean;
@@ -158,6 +161,8 @@ interface PostFormFieldsProps {
onPublishReply: () => void;
onPublishPost: () => void;
handleUpload: () => void;
uploadFile: ReturnType<typeof useFileUpload>['uploadFile'];
onOekakiClearUploadedUrl: (url: string) => void;
disableReplyPublish: boolean;
}
@@ -185,6 +190,7 @@ const PostFormFields = ({
isUploading,
uploadedFileName,
showUploadControls,
showOekakiControls,
showSpoilerForPost,
showSpoilerForReply,
isInAllView,
@@ -202,6 +208,8 @@ const PostFormFields = ({
onPublishReply,
onPublishPost,
handleUpload,
uploadFile,
onOekakiClearUploadedUrl,
disableReplyPublish,
}: PostFormFieldsProps) => (
<>
@@ -365,6 +373,14 @@ const PostFormFields = ({
</td>
</tr>
)}
{showOekakiControls && (
<tr>
<td>Draw</td>
<td>
<OekakiDrawingControls disabled={isUploading} uploadFile={uploadFile} onClearUploadedUrl={onOekakiClearUploadedUrl} />
</td>
</tr>
)}
{((isInPostView && showSpoilerForReply) || (!isInPostView && showSpoilerForPost)) && (
<tr className={styles.spoilerButton}>
<td>{capitalize(t('spoiler'))}</td>
@@ -424,6 +440,7 @@ const PostFormFields = ({
}}
/>
</li>
{showOekakiControls && isWebRuntime() ? <li>{OEKAKI_WEB_WARNING_TEXT}</li> : null}
</ul>
</td>
</tr>
@@ -455,6 +472,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
const nonokoRedirectPathRef = useRef<string | null>(null);
const location = useLocation();
const isInPostView = isPostPageView(location.pathname, params);
const isInAllView = isAllView(location.pathname);
const isInModView = isModView(location.pathname);
const isInSubscriptionsView = isSubscriptionsView(location.pathname, useParams());
@@ -466,6 +484,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
const showSpoilerForPost = directoryEntry?.features?.noSpoilers !== true;
const showSpoilerForReply = directoryEntry?.features?.noSpoilerReplies !== true;
const postOptionsDirectoryCode = getPostOptionsDirectoryCode(directoryEntry, location.pathname);
const showOekakiControls = postOptionsDirectoryCode === 'i' || directoryEntry?.directoryCode === 'i';
const requirePostLinkIsMediaFeature = directoryEntry?.features?.requirePostLinkIsMedia;
const requirePostLinkIsMedia = requirePostLinkIsMediaFeature === true || (requirePostLinkIsMediaFeature === undefined && (isInAllView || isInSubscriptionsView));
const flagOptions = getCommentFlagOptionsForDirectory(directoryEntry);
@@ -600,7 +619,6 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
}, [postIndex, pendingPostBoardPath, resetFields, resetPublishPostOptions, navigate]);
// in post page, publish a reply to the post
const isInPostView = isPostPageView(location.pathname, params);
const cid = params?.commentCid || '';
const { isResolvingExternalQuotes, publishReply, publishReplyError, publishReplyStateMessage, resetPublishReplyOptions, replyIndex, setPublishReplyOptions } =
usePublishReply({ cid, communityAddress, postCid });
@@ -707,7 +725,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
}
}, [replyIndex, closeForm, navigate, resetFields]);
const { isUploading, uploadedFileName, handleUpload } = useFileUpload({
const { isUploading, uploadedFileName, handleUpload, uploadFile } = useFileUpload({
onUploadComplete: (uploadedUrl: string) => {
if (uploadedUrl) {
setUrl(uploadedUrl);
@@ -722,6 +740,21 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
}
},
});
const handleOekakiClearUploadedUrl = useCallback(
(uploadedUrl: string) => {
if ((urlRef.current?.value || url) !== uploadedUrl) return;
setUrl('');
if (urlRef.current) {
urlRef.current.value = '';
}
if (isInPostView) {
setPublishReplyOptions({ link: '' });
} else {
setPublishPostOptions({ link: '' });
}
},
[isInPostView, setPublishPostOptions, setPublishReplyOptions, url],
);
const uploadMode = useMediaHostingStore((state) => state.uploadMode);
const showUploadControls = getShowUploadControls(uploadMode, isWebRuntime());
@@ -765,6 +798,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
isUploading={isUploading}
uploadedFileName={uploadedFileName}
showUploadControls={showUploadControls}
showOekakiControls={showOekakiControls}
showSpoilerForPost={showSpoilerForPost}
showSpoilerForReply={showSpoilerForReply}
isInAllView={isInAllView}
@@ -782,6 +816,8 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
onPublishReply={onPublishReply}
onPublishPost={onPublishPost}
handleUpload={handleUpload}
uploadFile={uploadFile}
onOekakiClearUploadedUrl={handleOekakiClearUploadedUrl}
disableReplyPublish={isResolvingExternalQuotes}
/>
</tbody>
@@ -4,6 +4,7 @@ import { createRoot, type Root } from 'react-dom/client';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import ReplyModal from '../reply-modal';
import { OEKAKI_WEB_WARNING_TEXT } from '../../../lib/oekaki/oekaki-copy';
import { POST_OPTIONS_VALIDATION_DELAY_MS } from '../../../lib/utils/post-options-utils';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -20,7 +21,9 @@ const testState = vi.hoisted(() => ({
},
} as Record<string, { address: string; directoryCode?: string; features?: Record<string, unknown>; title?: string }>,
handleUploadMock: vi.fn(),
uploadFileMock: vi.fn(),
isMobile: false,
mediaHostingRuntime: 'web' as 'web' | 'android' | 'electron',
isResolvingExternalQuotes: false,
isUploading: false,
navigateMock: vi.fn(),
@@ -127,8 +130,9 @@ vi.mock('../../../stores/use-reply-modal-store', () => ({
}));
vi.mock('../../../lib/media-hosting/show-upload-controls', () => ({
getMediaHostingRuntime: () => testState.mediaHostingRuntime,
getShowUploadControls: () => testState.showUploadControls,
isWebRuntime: () => true,
isWebRuntime: () => testState.mediaHostingRuntime === 'web',
}));
vi.mock('../../../stores/use-media-hosting-store', () => ({
@@ -185,6 +189,7 @@ vi.mock('../../../hooks/use-file-upload', () => ({
testState.uploadComplete = onUploadComplete;
return {
handleUpload: testState.handleUploadMock,
uploadFile: testState.uploadFileMock,
isUploading: testState.isUploading,
uploadedFileName: testState.uploadedFileName,
};
@@ -352,6 +357,7 @@ describe('ReplyModal', () => {
},
};
testState.handleUploadMock.mockReset();
testState.uploadFileMock.mockReset();
testState.isMobile = false;
testState.isResolvingExternalQuotes = false;
testState.isUploading = false;
@@ -398,6 +404,7 @@ describe('ReplyModal', () => {
testState.uploadComplete = undefined;
testState.uploadedFileName = null;
testState.uploadMode = 'always';
testState.mediaHostingRuntime = 'web';
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
@@ -437,6 +444,30 @@ describe('ReplyModal', () => {
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ displayName: 'Alice' });
});
it('shows Oekaki draw controls on /i/ replies', async () => {
testState.directoryByAddress['oekaki-posting.bso'] = {
address: 'oekaki-posting.bso',
directoryCode: 'i',
features: { requirePostLink: true, requirePostLinkIsMedia: true },
title: '/i/ - Oekaki',
};
testState.communities['oekaki-posting.bso'] = { address: 'oekaki-posting.bso' };
await renderReplyModal('/i/thread/post-1', 'oekaki-posting.bso');
expect(container.textContent).toContain('Size');
expect(container.textContent).toContain('Replay');
expect(Array.from(container.querySelectorAll('span')).some((span) => span.textContent === '×')).toBe(true);
expect(container.textContent).toContain(OEKAKI_WEB_WARNING_TEXT);
expect(Array.from(container.querySelectorAll('button')).some((button) => button.textContent === 'Draw')).toBe(true);
expect((Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'Clear') as HTMLButtonElement | undefined)?.disabled).toBe(true);
testState.mediaHostingRuntime = 'android';
await renderReplyModal('/i/thread/post-1', 'oekaki-posting.bso');
expect(container.textContent).not.toContain(OEKAKI_WEB_WARNING_TEXT);
});
it('shows a flag selector on flag boards and publishes the default geographic request', async () => {
await renderReplyModal('/pol/thread/post-1', 'politically-incorrect.bso');
@@ -120,6 +120,32 @@
width: 130px;
}
.oekakiRow {
display: flex;
align-items: flex-start;
width: 302px;
margin-bottom: 1px;
font-size: 10pt;
}
.oekakiLabel {
display: inline-block;
width: 38px;
padding-top: 3px;
font-weight: bold;
}
.oekakiControls {
width: 264px;
}
.oekakiWarning {
width: 294px;
margin-bottom: 1px;
font-size: 11px;
line-height: 1.2;
}
.offlineBoard {
width: 292px;
font-family: monospace;
+19 -1
View File
@@ -28,9 +28,11 @@ import usePublishReply from '../../hooks/use-publish-reply';
import useIsMobile from '../../hooks/use-is-mobile';
import { useFileUpload } from '../../hooks/use-file-upload';
import { useCommunityField } from '../../hooks/use-stable-community';
import { OEKAKI_WEB_WARNING_TEXT } from '../../lib/oekaki/oekaki-copy';
import BbcodeEditorToolbar, { BbcodePreview } from '../bbcode-editor-toolbar/bbcode-editor-toolbar';
import BoardOfflineAlert from '../board-offline-alert/board-offline-alert';
import LoadingEllipsis from '../loading-ellipsis';
import OekakiDrawingControls from '../oekaki-drawing-controls';
import PostOptionsErrorMessage from '../post-options-error-message/post-options-error-message';
import styles from './reply-modal.module.css';
import capitalize from 'lodash/capitalize';
@@ -63,6 +65,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
const directoryEntry = findDirectoryByAddress(directories, communityAddress);
const showSpoilerForReply = directoryEntry?.features?.noSpoilerReplies !== true;
const postOptionsDirectoryCode = getPostOptionsDirectoryCode(directoryEntry, location.pathname);
const showOekakiControls = postOptionsDirectoryCode === 'i' || directoryEntry?.directoryCode === 'i';
const requirePostLinkIsMediaFeature = directoryEntry?.features?.requirePostLinkIsMedia;
const requirePostLinkIsMedia = requirePostLinkIsMediaFeature === true || (requirePostLinkIsMediaFeature === undefined && (isInAllView || isInSubscriptionsView));
const flagOptions = getCommentFlagOptionsForDirectory(directoryEntry);
@@ -429,7 +432,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
checkContentLengthRef.current(publishContent, t);
}, [showReplyModal, quoteInsertRequestId, quoteInsertNumber, quoteInsertSelectedText, postOptionsDirectoryCode, setPublishReplyOptions, t]);
const { isUploading, uploadedFileName, handleUpload } = useFileUpload({
const { isUploading, uploadedFileName, handleUpload, uploadFile } = useFileUpload({
onUploadComplete: (uploadedUrl: string) => {
if (uploadedUrl) {
setUrl(uploadedUrl);
@@ -440,6 +443,14 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
}
},
});
const handleOekakiClearUploadedUrl = (uploadedUrl: string) => {
if ((urlRef.current?.value || url) !== uploadedUrl) return;
setUrl('');
if (urlRef.current) {
urlRef.current.value = '';
}
setPublishReplyOptions({ link: '' });
};
const uploadMode = useMediaHostingStore((state) => state.uploadMode);
const showUploadControls = getShowUploadControls(uploadMode, isWebRuntime());
const displayedFileName = getPublishURLFilename(url) || uploadedFileName;
@@ -546,6 +557,13 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
}}
/>
</div>
{showOekakiControls && (
<div className={styles.oekakiRow}>
<span className={styles.oekakiLabel}>Draw</span>
<OekakiDrawingControls className={styles.oekakiControls} disabled={isUploading} uploadFile={uploadFile} onClearUploadedUrl={handleOekakiClearUploadedUrl} />
</div>
)}
{showOekakiControls && isWebRuntime() ? <div className={styles.oekakiWarning}>{OEKAKI_WEB_WARNING_TEXT}</div> : null}
{flagOptions.length > 0 && (
<div>
<select
+27 -1
View File
@@ -2,7 +2,7 @@
"title": "5chan directories",
"description": "Directory assignments built from per-directory candidate lists in https://github.com/bitsocialnet/lists/tree/master/5chan-directories",
"createdAt": 1779182014,
"updatedAt": 1779279785,
"updatedAt": 1780054375,
"directories": [
{
"directoryCode": "a",
@@ -458,6 +458,32 @@
}
]
},
{
"directoryCode": "i",
"title": "/i/ - Oekaki",
"description": "Boards competing to host the /i/ directory on 5chan. The highest-scoring board resolves the directory code; if it goes offline, 5chan rotates to the next-highest. Anyone can open a PR on this file to add their board.\n\nhttps://github.com/bitsocialnet/lists/blob/master/5chan-directories/5chan-i-directory.json",
"features": {
"pseudonymityMode": "per-reply",
"safeForWork": false,
"hasFlags": false,
"requirePostLink": true,
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true,
"postsPerPage": 15
},
"createdAt": 1780054375,
"updatedAt": 1780054375,
"boards": [
{
"address": "oekaki-posting.bso",
"publicKey": "12D3KooWNAqMhmHi3hPSbcH27Q7cqBCuN2Mu9HNzs6fB8YX14k4o",
"owner": "plebeius.bso",
"addedAt": 1780054375
}
]
},
{
"directoryCode": "ic",
"title": "/ic/ - Artwork/Critique",
+6
View File
@@ -14,6 +14,12 @@ declare global {
copyToClipboard: (text: string) => Promise<{ success: boolean; error?: string }>;
getPlatform: () => Promise<{ platform: NodeJS.Platform; arch: string; version: string }>;
automateUploadMedia: (options: { provider: ProviderId; filePath: string }) => Promise<{ url: string; provider: ProviderId }>;
automateUploadGeneratedMedia?: (options: {
provider: ProviderId;
fileName: string;
mimeType: string;
bytes: number[];
}) => Promise<{ url: string; provider: ProviderId }>;
downloadAndInstallUpdate?: (options: { url: string; fileName: string }) => Promise<void>;
getPathForFile?: (file: File) => string | null;
};
+72 -1
View File
@@ -20,7 +20,7 @@ vi.mock('@capacitor/core', () => ({
}));
vi.mock('../../plugins/file-uploader', () => ({
default: { pickAndUploadMedia: vi.fn() },
default: { pickAndUploadMedia: vi.fn(), uploadGeneratedMedia: vi.fn() },
}));
vi.mock('../../lib/utils/catbox-utils', () => ({
@@ -211,6 +211,77 @@ describe('useFileUpload', () => {
expect(hook().isUploading).toBe(false);
});
it('uploads a generated file via Electron orchestrator', async () => {
uploadModeRef.value = 'preferred';
preferredProviderRef.value = 'catbox';
vi.mocked(Capacitor.getPlatform).mockReturnValue('web');
window.electronApi = { isElectron: true } as any;
vi.mocked(orchestrateElectronUpload).mockResolvedValue('https://files.catbox.moe/tegaki.png');
const file = new File(['abc'], 'tegaki.png', { type: 'image/png' });
const { onUploadComplete, hook } = mountHook();
let result: Awaited<ReturnType<HookSnapshot['uploadFile']>> | null = null;
await act(async () => {
result = await hook().uploadFile(file);
});
expect(orchestrateElectronUpload).toHaveBeenCalledWith(file, ['catbox']);
expect(result).toEqual({ url: 'https://files.catbox.moe/tegaki.png', fileName: 'tegaki.png' });
expect(onUploadComplete).toHaveBeenCalledWith('https://files.catbox.moe/tegaki.png', 'tegaki.png');
expect(hook().uploadedFileName).toBe('tegaki.png');
expect(hook().isUploading).toBe(false);
});
it('uploads a generated file via Android plugin', async () => {
uploadModeRef.value = 'preferred';
preferredProviderRef.value = 'catbox';
vi.mocked(Capacitor.getPlatform).mockReturnValue('android');
vi.mocked(FileUploader.uploadGeneratedMedia).mockResolvedValue({
url: 'https://files.catbox.moe/android-tegaki.png',
fileName: 'tegaki.png',
provider: 'catbox',
});
const file = new File(['abc'], 'tegaki.png', { type: 'image/png' });
const { onUploadComplete, hook } = mountHook();
let result: Awaited<ReturnType<HookSnapshot['uploadFile']>> | null = null;
await act(async () => {
result = await hook().uploadFile(file);
});
expect(FileUploader.uploadGeneratedMedia).toHaveBeenCalledWith({
providerOrder: ['catbox'],
fileName: 'tegaki.png',
mimeType: 'image/png',
base64: 'YWJj',
});
expect(result).toEqual({ url: 'https://files.catbox.moe/android-tegaki.png', fileName: 'tegaki.png' });
expect(onUploadComplete).toHaveBeenCalledWith('https://files.catbox.moe/android-tegaki.png', 'tegaki.png');
expect(hook().uploadedFileName).toBe('tegaki.png');
});
it('does not upload generated files from web runtime', async () => {
uploadModeRef.value = 'preferred';
preferredProviderRef.value = 'catbox';
vi.mocked(Capacitor.getPlatform).mockReturnValue('web');
window.electronApi = undefined;
const file = new File(['abc'], 'tegaki.png', { type: 'image/png' });
const { onUploadComplete, hook } = mountHook();
let result: Awaited<ReturnType<HookSnapshot['uploadFile']>> | null = null;
await act(async () => {
result = await hook().uploadFile(file);
});
expect(result).toBeNull();
expect(window.alert).toHaveBeenCalledWith('upload_not_supported_web');
expect(FileUploader.uploadGeneratedMedia).not.toHaveBeenCalled();
expect(orchestrateElectronUpload).not.toHaveBeenCalled();
expect(onUploadComplete).not.toHaveBeenCalled();
});
it('silently ignores file selection cancellation', async () => {
uploadModeRef.value = 'preferred';
preferredProviderRef.value = 'catbox';
+109 -34
View File
@@ -27,6 +27,7 @@ const ANDROID_STAGE_MAP: Record<string, UploadAttemptStage> = {
};
const FILE_SELECTION_CANCELLED_ERROR = 'File selection cancelled';
const WEB_UPLOAD_NOT_SUPPORTED_ERROR = 'Web upload is not supported';
const VALID_PROVIDERS: ProviderId[] = ['catbox', 'imgur', 'imgbb'];
@@ -88,6 +89,24 @@ interface UseFileUploadOptions {
onUploadComplete: (url: string, fileName: string) => void;
}
export interface UploadedFileResult {
url: string;
fileName: string;
}
function readFileAsBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(reader.error ?? new Error('Could not read file'));
reader.onload = () => {
const result = typeof reader.result === 'string' ? reader.result : '';
const marker = result.indexOf(',');
resolve(marker >= 0 ? result.slice(marker + 1) : result);
};
reader.readAsDataURL(file);
});
}
function selectFileViaInput(): Promise<File | null> {
return new Promise((resolve) => {
const input = document.createElement('input');
@@ -151,32 +170,97 @@ export function useFileUpload(options: UseFileUploadOptions) {
const [isUploading, setIsUploading] = useState(false);
const [uploadedFileName, setUploadedFileName] = useState<string | null>(null);
const getAvailableProviderOrder = useCallback(async () => {
const runtime = getMediaHostingRuntime();
const supportedOrder = getProviderOrder({ mode: uploadMode, preferredProvider, runtime });
const availability = await ensureProviderAvailability(runtime);
const order = getProviderOrder({ mode: uploadMode, preferredProvider, runtime, availability });
if (order.length === 0) {
if (runtime === 'web') {
throw new Error(WEB_UPLOAD_NOT_SUPPORTED_ERROR);
}
if (supportedOrder.length > 0) {
const message =
uploadMode === 'preferred' && availability[preferredProvider] === 'unavailable'
? `${preferredProvider} is unavailable from this network`
: 'No reachable upload providers are available from this network';
throw new Error(message);
}
throw new Error(`${preferredProvider} is not supported on ${runtime}`);
}
return { runtime, order };
}, [uploadMode, preferredProvider]);
const handleUploadError = useCallback(
(error: unknown) => {
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage === FILE_SELECTION_CANCELLED_ERROR) return;
if (errorMessage === WEB_UPLOAD_NOT_SUPPORTED_ERROR) {
window.alert(t('upload_not_supported_web'));
return;
}
const err = normalizeAndroidRejection(error) as Error & { attempts?: ProviderAttempt[] };
if (err.attempts && err.attempts.length > 0) {
window.alert(formatAggregatedError(err.attempts, t));
} else if (uploadMode === 'preferred') {
window.alert(formatPreferredModeError(errorMessage, t));
} else {
window.alert(`${t('upload_failed')}: ${errorMessage}`);
}
},
[t, uploadMode],
);
const uploadFile = useCallback(
async (file: File): Promise<UploadedFileResult | null> => {
if (uploadMode === 'none') return null;
try {
setIsUploading(true);
setUploadedFileName(null);
const { runtime, order } = await getAvailableProviderOrder();
let result: UploadedFileResult | null = null;
if (runtime === 'android') {
const pluginResult = await FileUploader.uploadGeneratedMedia({
providerOrder: order,
fileName: file.name,
mimeType: file.type || 'application/octet-stream',
base64: await readFileAsBase64(file),
});
result = pluginResult.url ? { url: pluginResult.url, fileName: pluginResult.fileName || file.name } : null;
} else if (runtime === 'electron') {
const url = await orchestrateElectronUpload(file, order);
result = { url, fileName: file.name };
} else {
throw new Error(WEB_UPLOAD_NOT_SUPPORTED_ERROR);
}
if (result?.url) {
setUploadedFileName(result.fileName);
onUploadComplete(result.url, result.fileName);
}
return result;
} catch (error) {
handleUploadError(error);
return null;
} finally {
setIsUploading(false);
}
},
[getAvailableProviderOrder, handleUploadError, onUploadComplete, uploadMode],
);
const handleUpload = useCallback(async () => {
if (uploadMode === 'none') return;
const runtime = getMediaHostingRuntime();
try {
setIsUploading(true);
setUploadedFileName(null);
const supportedOrder = getProviderOrder({ mode: uploadMode, preferredProvider, runtime });
const availability = await ensureProviderAvailability(runtime);
const order = getProviderOrder({ mode: uploadMode, preferredProvider, runtime, availability });
if (order.length === 0) {
if (runtime === 'web') {
window.alert(t('upload_not_supported_web'));
return;
}
if (supportedOrder.length > 0) {
const message =
uploadMode === 'preferred' && availability[preferredProvider] === 'unavailable'
? `${preferredProvider} is unavailable from this network`
: 'No reachable upload providers are available from this network';
throw new Error(message);
}
throw new Error(`${preferredProvider} is not supported on ${runtime}`);
}
const { runtime, order } = await getAvailableProviderOrder();
if (runtime === 'android') {
const result = await FileUploader.pickAndUploadMedia({ providerOrder: order });
if (result.url) {
@@ -186,7 +270,7 @@ export function useFileUpload(options: UseFileUploadOptions) {
return;
}
if (isElectronRuntime()) {
if (runtime === 'electron' || isElectronRuntime()) {
const file = await selectFileViaInput();
if (!file) {
throw new Error(FILE_SELECTION_CANCELLED_ERROR);
@@ -198,27 +282,18 @@ export function useFileUpload(options: UseFileUploadOptions) {
return;
}
window.alert(t('upload_not_supported_web'));
throw new Error(WEB_UPLOAD_NOT_SUPPORTED_ERROR);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage === FILE_SELECTION_CANCELLED_ERROR) return;
const err = normalizeAndroidRejection(error) as Error & { attempts?: ProviderAttempt[] };
if (err.attempts && err.attempts.length > 0) {
window.alert(formatAggregatedError(err.attempts, t));
} else if (uploadMode === 'preferred') {
window.alert(formatPreferredModeError(errorMessage, t));
} else {
window.alert(`${t('upload_failed')}: ${errorMessage}`);
}
handleUploadError(error);
} finally {
setIsUploading(false);
}
}, [onUploadComplete, t, uploadMode, preferredProvider]);
}, [getAvailableProviderOrder, handleUploadError, onUploadComplete, uploadMode]);
return {
isUploading,
uploadedFileName,
handleUpload,
uploadFile,
};
}
@@ -13,6 +13,7 @@ function createElectronApiMock() {
copyToClipboard: vi.fn(async () => ({ success: true })),
getPlatform: vi.fn(async () => ({ platform: 'darwin' as NodeJS.Platform, arch: 'x64', version: 'v20.0.0' })),
automateUploadMedia: vi.fn(async (options: { provider: ProviderId }) => ({ url: 'https://i.imgur.com/abc.png', provider: options.provider })),
automateUploadGeneratedMedia: vi.fn(async (options: { provider: ProviderId }) => ({ url: 'https://i.imgur.com/generated.png', provider: options.provider })),
getPathForFile: vi.fn((): string | null => '/tmp/image.png'),
};
}
@@ -67,6 +68,7 @@ describe('orchestrateElectronUpload', () => {
it('fails with provider attempt details if no file path can be resolved', async () => {
const electronApi = createElectronApiMock();
electronApi.getPathForFile = vi.fn((): string | null => null);
electronApi.automateUploadGeneratedMedia = undefined as unknown as typeof electronApi.automateUploadGeneratedMedia;
window.electronApi = electronApi;
const file = new File(['z'], 'z.png', { type: 'image/png' });
@@ -80,12 +82,30 @@ describe('orchestrateElectronUpload', () => {
};
expect(typedError.message).toBe('All providers failed');
expect(typedError.attempts?.[0]?.provider).toBe('imgur');
expect(typedError.attempts?.[0]?.error).toContain('File path required for Electron automation');
expect(typedError.attempts?.[0]?.error).toContain('File path unavailable and automateUploadGeneratedMedia is not available');
expect(typedError.attempts?.[0]?.elapsedMs).toBeGreaterThanOrEqual(0);
expect(typedError.attempts?.[0]?.stage).toBeDefined();
}
});
it('uses generated media automation when no file path can be resolved', async () => {
const electronApi = createElectronApiMock();
electronApi.getPathForFile = vi.fn((): string | null => null);
window.electronApi = electronApi;
const file = new File(['abc'], 'tegaki.png', { type: 'image/png' });
const url = await orchestrateElectronUpload(file, ['imgur']);
expect(url).toBe('https://i.imgur.com/generated.png');
expect(electronApi.automateUploadGeneratedMedia).toHaveBeenCalledWith({
provider: 'imgur',
fileName: 'tegaki.png',
mimeType: 'image/png',
bytes: [97, 98, 99],
});
expect(electronApi.automateUploadMedia).not.toHaveBeenCalled();
});
it('includes stage and matchedSelectors when provider throws block/file-input errors', async () => {
const electronApi = createElectronApiMock();
electronApi.automateUploadMedia = vi.fn().mockRejectedValue(new Error('No file input found for imgur. Tried: input[type="file"], #upload'));
+19 -2
View File
@@ -56,6 +56,10 @@ function resolveElectronFilePath(file: File): string | null {
return null;
}
async function fileToByteArray(file: File): Promise<number[]> {
return Array.from(new Uint8Array(await file.arrayBuffer()));
}
/**
* Uploads a file via a single provider. Catbox uses the web API;
* imgur/imgbb use Electron automation when available.
@@ -66,8 +70,21 @@ async function uploadViaProvider(provider: ProviderId, file: File): Promise<stri
const fn = typeof window !== 'undefined' && window.electronApi?.automateUploadMedia;
if (fn) {
const filePath = resolveElectronFilePath(file);
if (!filePath) throw new Error('File path required for Electron automation');
const { url } = await fn({ provider, filePath });
if (filePath) {
const { url } = await fn({ provider, filePath });
return url;
}
const generatedFn = window.electronApi?.automateUploadGeneratedMedia;
if (!generatedFn) {
throw new Error('File path unavailable and automateUploadGeneratedMedia is not available');
}
const { url } = await generatedFn({
provider,
fileName: file.name,
mimeType: file.type || 'application/octet-stream',
bytes: await fileToByteArray(file),
});
return url;
}
throw new Error(`Provider ${provider} requires Electron (automateUploadMedia not available)`);
@@ -0,0 +1,54 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { TegakiGlobal } from '../tegaki-loader';
const SCRIPT_SELECTOR = 'script[data-tegaki-oekaki="script"]';
const importFreshLoader = async () => {
vi.resetModules();
return import('../tegaki-loader');
};
const createTegaki = (): TegakiGlobal => ({
open: vi.fn(),
flatten: vi.fn(() => document.createElement('canvas')),
});
describe('loadTegaki', () => {
afterEach(() => {
delete window.Tegaki;
document.head.querySelectorAll('[data-tegaki-oekaki]').forEach((element) => element.remove());
vi.restoreAllMocks();
});
it('rejects immediately when an existing loaded script did not initialize Tegaki', async () => {
const script = document.createElement('script');
script.dataset.tegakiOekaki = 'script';
Object.defineProperty(script, 'readyState', { configurable: true, value: 'complete' });
document.head.appendChild(script);
const { loadTegaki } = await importFreshLoader();
await expect(loadTegaki()).rejects.toThrow('Tegaki did not initialize');
});
it('retries after a transient script load failure', async () => {
const { loadTegaki } = await importFreshLoader();
const firstLoad = loadTegaki();
const firstScript = document.querySelector<HTMLScriptElement>(SCRIPT_SELECTOR);
expect(firstScript).not.toBeNull();
firstScript?.dispatchEvent(new Event('error'));
await expect(firstLoad).rejects.toThrow('Failed to load Tegaki');
expect(document.querySelector(SCRIPT_SELECTOR)).toBeNull();
const secondLoad = loadTegaki();
const secondScript = document.querySelector<HTMLScriptElement>(SCRIPT_SELECTOR);
expect(secondScript).not.toBeNull();
expect(secondScript).not.toBe(firstScript);
const tegaki = createTegaki();
window.Tegaki = tegaki;
secondScript?.dispatchEvent(new Event('load'));
await expect(secondLoad).resolves.toBe(tegaki);
});
});
+7
View File
@@ -0,0 +1,7 @@
export const OEKAKI_WEB_DOWNLOAD_MESSAGE =
'Auto-upload is not available on web because of CORS.\n\nDownload tegaki.png now? Upload it to some site, then paste the direct image link in the Link To File field.';
export const OEKAKI_WEB_WARNING_TEXT =
'Auto-upload is not available on web because of CORS. Download tegaki.png, upload it to some site, then paste the direct image link in Link To File.';
export const OEKAKI_MOBILE_PORTRAIT_MESSAGE = 'Please rotate your device to draw.';
+107
View File
@@ -0,0 +1,107 @@
import { resolveAssetUrl } from '../utils/preload-utils';
export const TEGAKI_DRAWING_FILE_NAME = 'tegaki.png';
interface TegakiOpenOptions {
width: number;
height: number;
saveReplay: boolean;
onDone: () => void;
onCancel: () => void;
}
export interface TegakiGlobal {
bg?: HTMLElement | null;
open: (options: TegakiOpenOptions) => void;
flatten: () => HTMLCanvasElement;
destroy?: () => void;
onOpenImageLoaded?: (this: HTMLImageElement) => void;
}
declare global {
interface Window {
Tegaki?: TegakiGlobal;
}
}
const TEGAKI_ASSET_BASE = 'vendor/tegaki/0.9.4';
const TEGAKI_SCRIPT_URL = resolveAssetUrl(`${TEGAKI_ASSET_BASE}/tegaki.min.js`);
const TEGAKI_STYLESHEET_URL = resolveAssetUrl(`${TEGAKI_ASSET_BASE}/tegaki.css`);
let tegakiLoadPromise: Promise<TegakiGlobal> | null = null;
const ensureTegakiStylesheet = (): void => {
if (document.querySelector('link[data-tegaki-oekaki="stylesheet"]')) {
return;
}
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = TEGAKI_STYLESHEET_URL;
link.dataset.tegakiOekaki = 'stylesheet';
document.head.appendChild(link);
};
const loadTegakiScript = (): Promise<TegakiGlobal> =>
new Promise((resolve, reject) => {
if (window.Tegaki) {
resolve(window.Tegaki);
return;
}
const existingScript = document.querySelector<HTMLScriptElement>('script[data-tegaki-oekaki="script"]');
if (existingScript) {
if (window.Tegaki) {
resolve(window.Tegaki);
return;
}
if (existingScript.dataset.failed === 'true') {
existingScript.remove();
} else {
const readyState = (existingScript as HTMLScriptElement & { readyState?: string }).readyState;
if (existingScript.dataset.loaded === 'true' || readyState === 'complete' || readyState === 'loaded') {
reject(new Error('Tegaki did not initialize'));
return;
}
existingScript.addEventListener('load', () => (window.Tegaki ? resolve(window.Tegaki) : reject(new Error('Tegaki did not initialize'))), { once: true });
existingScript.addEventListener('error', () => reject(new Error('Failed to load Tegaki')), { once: true });
return;
}
}
const script = document.createElement('script');
script.src = TEGAKI_SCRIPT_URL;
script.async = true;
script.dataset.tegakiOekaki = 'script';
script.onload = () => {
script.dataset.loaded = 'true';
if (window.Tegaki) {
resolve(window.Tegaki);
return;
}
reject(new Error('Tegaki did not initialize'));
};
script.onerror = () => {
script.dataset.failed = 'true';
script.remove();
reject(new Error('Failed to load Tegaki'));
};
document.head.appendChild(script);
});
export const loadTegaki = (): Promise<TegakiGlobal> => {
if (window.Tegaki) {
ensureTegakiStylesheet();
return Promise.resolve(window.Tegaki);
}
if (!tegakiLoadPromise) {
ensureTegakiStylesheet();
tegakiLoadPromise = loadTegakiScript().catch((error) => {
tegakiLoadPromise = null;
throw error;
});
}
return tegakiLoadPromise;
};
+1
View File
@@ -82,6 +82,7 @@ const DIRECTORY_CODE_ORDER = [
'wsg',
'diy',
'out',
'i',
'ic',
'mu',
'int',
+8
View File
@@ -5,6 +5,13 @@ interface PickAndUploadMediaOptions {
providerOrder: ProviderId[];
}
interface UploadGeneratedMediaOptions {
providerOrder: ProviderId[];
fileName: string;
mimeType: string;
base64: string;
}
interface PickAndUploadMediaResult {
url: string;
fileName: string;
@@ -14,6 +21,7 @@ interface PickAndUploadMediaResult {
interface FileUploaderPlugin {
pickAndUploadMedia(options?: PickAndUploadMediaOptions): Promise<PickAndUploadMediaResult>;
uploadGeneratedMedia(options: UploadGeneratedMediaOptions): Promise<PickAndUploadMediaResult>;
}
const FileUploader = registerPlugin<FileUploaderPlugin>('FileUploader');