Add conversation image replacement
This commit is contained in:
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; }
|
||||
|
||||
Reference in new issue
Block a user