feat(oekaki): add drawing flow for /i/ (#1144)

* feat(oekaki): add drawing flow for /i/

* fix(oekaki): address review feedback

* fix(oekaki): reset Tegaki edit sessions

* fix(oekaki): block drawing during export

* fix(oekaki): destroy Tegaki on preload errors

* fix(oekaki): preserve drawing on export failure

* fix(oekaki): unlock controls after export failure
This commit is contained in:
Tommaso Casaburi
2026-05-30 15:06:47 +07:00
committed by GitHub
parent 5a0b4909b8
commit 56894700c1
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