Add conversation image replacement

This commit is contained in:
jjbrunton committed 2026-08-14 12:56:00 +01:00
1 parent 2500f0a23c
commit 8523873831
18 files changed
+812 -3

No files matched your search

@@ -3,6 +3,9 @@ import {
ConversationsResponseSchema,
ConversationDetailResponseSchema,
ConversationMediaShareOptionsResponseSchema,
ConversationImageReplacementOptionsResponseSchema,
GenerateConversationImageReplacementRequestSchema,
GenerateConversationImageReplacementResponseSchema,
ErrorResponseSchema,
ProcessedPostsResponseSchema,
SendReplyRequestSchema,
@@ -21,6 +24,8 @@ import {
type BulkMarkConversationsReadResponse,
type ConversationDetailResponse,
type ConversationMediaShareOptions,
type ConversationImageReplacementOptions,
type ConversationImageReplacementResult,
type ConversationTriageStatus,
type ProcessedPost,
type SellerScanResponse,
@@ -47,6 +52,8 @@ export interface ConversationApi {
suggestReply(id: string): Promise<string>;
suggestMediaAsk(id: string): Promise<string>;
mediaShareOptions(id: string): Promise<ConversationMediaShareOptions>;
imageReplacementOptions(id: string): Promise<ConversationImageReplacementOptions>;
generateImageReplacement(id: string, sourceAssetId: string, targetId: string): Promise<ConversationImageReplacementResult>;
processedPosts(username: string): Promise<ProcessedPost[]>;
sendReply(id: string, message: string): Promise<SuccessResponse>;
shareMedia(id: string, assetId: string): Promise<SuccessResponse>;
@@ -99,6 +106,23 @@ export function createConversationApi(fetchImplementation?: typeof fetch): Conve
ConversationMediaShareOptionsResponseSchema,
);
},
async imageReplacementOptions(id) {
return parseResponse(
await request(`${conversationPath(id)}/image-replacement-options`),
ConversationImageReplacementOptionsResponseSchema,
);
},
async generateImageReplacement(id, sourceAssetId, targetId) {
const body = GenerateConversationImageReplacementRequestSchema.parse({ sourceAssetId, targetId });
return parseResponse(
await request(`${conversationPath(id)}/generate-image-replacement`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}),
GenerateConversationImageReplacementResponseSchema,
);
},
async processedPosts(username) {
return parseResponse(
await request(`/api/posts/by-author/${encodeURIComponent(username.trim().replace(/^u\//i, ''))}`),
@@ -23,6 +23,7 @@ import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
import { formatDayLabel, formatExactTime, formatTimeOfDay, isSameDay } from './relative-time.js';
import { getStoredComposerDraft, storeComposerDraft } from './composer-drafts.js';
import { SNOOZE_OPTIONS, isUnread, triageStatus } from './conversation-triage.js';
import { ConversationImageReplacement } from './conversation-image-replacement.js';
/** Reddit chat rejects messages longer than roughly this many characters. */
export const REDDIT_CHAT_MAX_MESSAGE_LENGTH = 10_000;
@@ -378,6 +379,7 @@ function ConversationComposer({ conversation, conversationId }: { conversation:
{wasSent ? (
<div aria-label="Reply sent" className="conversation-inline-success" role="status">Reply sent to <RedditUsernameLink username={conversation.username} /></div>
) : null}
<ConversationImageReplacement conversationId={conversationId} />
<details className="conversation-media-share" open={isMediaShareOpen}>
<summary
onClick={(event) => {
@@ -0,0 +1,136 @@
import { useState } from 'react';
import {
useConversationImageReplacementOptionsQuery,
useGenerateConversationImageReplacementMutation,
} from './conversation-queries.js';
export function ConversationImageReplacement({ conversationId }: { conversationId: string }) {
const [isOpen, setIsOpen] = useState(false);
const [areSourcesRevealed, setAreSourcesRevealed] = useState(false);
const [areTargetsRevealed, setAreTargetsRevealed] = useState(false);
const [isResultRevealed, setIsResultRevealed] = useState(false);
const [sourceAssetId, setSourceAssetId] = useState<string | null>(null);
const [targetId, setTargetId] = useState<string | null>(null);
const optionsQuery = useConversationImageReplacementOptionsQuery(conversationId, isOpen);
const generation = useGenerateConversationImageReplacementMutation(conversationId);
const options = optionsQuery.data;
function clearResult() {
generation.reset();
setIsResultRevealed(false);
}
return (
<details className="conversation-image-replacement" open={isOpen}>
<summary
onClick={(event) => {
event.preventDefault();
setIsOpen((current) => !current);
}}
>
Create a green-screen image
</summary>
<p>
Pick a green-screen source from your exchange library, then place the original post or any conversation image into it. Generation only creates a private preview; it does not send anything.
</p>
{optionsQuery.isLoading ? <span role="status">Loading image choices…</span> : null}
{optionsQuery.isError ? <div className="conversation-inline-error" role="alert">{optionsQuery.error.message}</div> : null}
{options?.reason ? <div className="conversation-media-unavailable">{options.reason}</div> : null}
{options?.sources.length ? (
<fieldset>
<legend>1. Green-screen source</legend>
<button onClick={() => setAreSourcesRevealed((current) => !current)} type="button">
{areSourcesRevealed ? 'Blur source images' : 'Reveal source images'}
</button>
<div className="conversation-replacement-options">
{options.sources.map((source) => (
<label className={sourceAssetId === source.id ? 'is-selected' : ''} key={source.id}>
<input
aria-label={`Green-screen source ${source.fileName}`}
checked={sourceAssetId === source.id}
disabled={!areSourcesRevealed}
name="replacement-source"
onChange={() => {
setSourceAssetId(source.id);
clearResult();
}}
type="radio"
/>
<img
alt={`Green-screen source ${source.fileName}`}
className={areSourcesRevealed ? undefined : 'is-blurred'}
src={source.urlPath}
/>
<span>{source.fileName}</span>
</label>
))}
</div>
</fieldset>
) : null}
{options?.targets.length ? (
<fieldset>
<legend>2. Image to place on the green screen</legend>
<button onClick={() => setAreTargetsRevealed((current) => !current)} type="button">
{areTargetsRevealed ? 'Blur conversation choices' : 'Reveal conversation choices'}
</button>
<div className="conversation-replacement-options">
{options.targets.map((target) => (
<label className={targetId === target.id ? 'is-selected' : ''} key={target.id}>
<input
aria-label={target.label}
checked={targetId === target.id}
disabled={!areTargetsRevealed}
name="replacement-target"
onChange={() => {
setTargetId(target.id);
clearResult();
}}
type="radio"
/>
<img
alt={target.label}
className={areTargetsRevealed ? undefined : 'is-blurred'}
src={target.urlPath}
/>
<span>{target.label}</span>
</label>
))}
</div>
</fieldset>
) : null}
<button
className="conversation-primary"
disabled={!sourceAssetId || !targetId || generation.isPending}
onClick={() => {
if (sourceAssetId && targetId) generation.mutate({ sourceAssetId, targetId });
}}
type="button"
>
{generation.isPending ? 'Creating image…' : 'Create image'}
</button>
{generation.isError ? <div className="conversation-inline-error" role="alert">{generation.error.message}</div> : null}
{generation.data ? (
<div className="conversation-replacement-result" role="status">
<strong>Generated preview</strong>
<span>{generation.data.width} × {generation.data.height} JPEG</span>
<div className="conversation-message-image">
<img
alt="Generated green-screen replacement"
className={isResultRevealed ? undefined : 'is-blurred'}
src={generation.data.imageDataUrl}
/>
<button
aria-pressed={isResultRevealed}
className="conversation-image-reveal"
onClick={() => setIsResultRevealed((current) => !current)}
type="button"
>
{isResultRevealed ? 'Blur generated image' : 'Reveal generated image'}
</button>
</div>
<a download={generation.data.fileName} href={generation.data.imageDataUrl}>Download generated image</a>
</div>
) : null}
</details>
);
}
@@ -8,6 +8,7 @@ export const conversationQueryKeys = {
detail: (id: string) => ['conversations', 'detail', id] as const,
processedPosts: (username: string) => ['conversations', 'processed-posts', username] as const,
mediaShareOptions: (id: string) => ['conversations', 'media-share-options', id] as const,
imageReplacementOptions: (id: string) => ['conversations', 'image-replacement-options', id] as const,
};
export const CONVERSATION_REFRESH_INTERVAL_MS = 30_000;
@@ -45,6 +46,21 @@ export function useConversationMediaShareOptionsQuery(id: string, enabled: boole
});
}
export function useConversationImageReplacementOptionsQuery(id: string, enabled: boolean) {
return useQuery({
queryKey: conversationQueryKeys.imageReplacementOptions(id),
queryFn: () => conversationApi.imageReplacementOptions(id),
enabled,
});
}
export function useGenerateConversationImageReplacementMutation(id: string) {
return useMutation({
mutationFn: ({ sourceAssetId, targetId }: { sourceAssetId: string; targetId: string }) =>
conversationApi.generateImageReplacement(id, sourceAssetId, targetId),
});
}
export function useProcessedPostsQuery(username: string) {
return useQuery({
queryKey: conversationQueryKeys.processedPosts(username),
@@ -158,6 +158,26 @@
.conversation-inline-error { color: #ffd1ce; background: #461b18; border: 1px solid #80352f; }
.conversation-inline-success { color: #b9f3ce; background: #163a26; border: 1px solid #28593d; }
.conversation-media-share { display: grid; gap: .7rem; padding: .75rem; background: #0b1410; border: 1px solid var(--border); border-radius: .65rem; }
.conversation-image-replacement { display: grid; gap: .7rem; padding: .75rem; background: #0b1410; border: 1px solid var(--border); border-radius: .65rem; }
.conversation-image-replacement[open] > * + * { margin-top: .7rem; }
.conversation-image-replacement summary { color: #e9f1ec; font-weight: 800; cursor: pointer; }
.conversation-image-replacement > p { margin: 0; color: var(--muted); font-size: .8rem; }
.conversation-image-replacement fieldset { display: grid; gap: .55rem; min-width: 0; padding: .65rem; border: 1px solid var(--border); border-radius: .55rem; }
.conversation-image-replacement legend { padding: 0 .3rem; color: #e9f1ec; font-size: .8rem; font-weight: 800; }
.conversation-image-replacement fieldset > button { justify-self: start; }
.conversation-replacement-options { display: grid; grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); gap: .65rem; }
.conversation-replacement-options label { display: grid; gap: .35rem; padding: .35rem; overflow: hidden; border: 1px solid var(--border); border-radius: .55rem; cursor: pointer; }
.conversation-replacement-options label.is-selected { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.conversation-replacement-options label:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
.conversation-replacement-options input { position: absolute; opacity: 0; pointer-events: none; }
.conversation-replacement-options img { display: block; width: 100%; height: 8rem; object-fit: contain; background: #050907; border-radius: .35rem; transition: filter 160ms ease, transform 160ms ease; }
.conversation-replacement-options img.is-blurred { filter: blur(22px) brightness(.55); transform: scale(1.1); }
.conversation-replacement-options span { color: var(--muted); font-size: .7rem; }
.conversation-replacement-result { display: grid; gap: .45rem; padding: .65rem; color: var(--muted); background: var(--surface-raised); border-radius: .55rem; }
.conversation-replacement-result > strong { color: var(--text); }
.conversation-replacement-result img { display: block; width: 100%; max-height: 55vh; object-fit: contain; background: #050907; border-radius: .55rem; transition: filter 160ms ease; }
.conversation-replacement-result img.is-blurred { filter: blur(24px) brightness(.55); }
.conversation-replacement-result > a { justify-self: start; color: var(--accent); font-weight: 800; }
.conversation-media-share[open] > * + * { margin-top: .7rem; }
.conversation-media-share summary { color: #e9f1ec; font-weight: 800; cursor: pointer; }
.conversation-media-share p, .conversation-media-unavailable { margin: 0; color: var(--muted); font-size: .8rem; }