feat: add conversation image previews and sharing

This commit is contained in:
jjbrunton committed 2026-08-09 22:28:41 +01:00
1 parent 488145340d
commit b573b88b08
20 files changed
+634 -26

No files matched your search

+66 -14
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
export interface OriginPost {
title: string;
@@ -35,23 +35,75 @@ function PostOriginMeta({ post }: { post: OriginPost }) {
export function OriginPostCard({ post }: { post: OriginPost }) {
const [isImageRevealed, setIsImageRevealed] = useState(false);
const [isImagePreviewOpen, setIsImagePreviewOpen] = useState(false);
const previewTriggerRef = useRef<HTMLButtonElement>(null);
const previewCloseRef = useRef<HTMLButtonElement>(null);
const closeImagePreview = useCallback(() => {
setIsImagePreviewOpen(false);
queueMicrotask(() => previewTriggerRef.current?.focus());
}, []);
useEffect(() => {
if (!isImagePreviewOpen) return;
previewCloseRef.current?.focus();
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') closeImagePreview();
if (event.key === 'Tab') {
event.preventDefault();
previewCloseRef.current?.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [closeImagePreview, isImagePreviewOpen]);
return (
<div className="origin-post-card">
{post.imageUrl ? (
<div className="origin-post-image">
<img alt={`Source post: ${post.title}`} className={isImageRevealed ? undefined : 'is-blurred'} loading="lazy" src={post.imageUrl} />
<button aria-pressed={isImageRevealed} onClick={() => setIsImageRevealed((current) => !current)} type="button">
{isImageRevealed ? 'Blur original image' : 'Reveal original image'}
</button>
<>
<div className="origin-post-card">
{post.imageUrl ? (
<div className="origin-post-image">
<button
aria-label={`View source image full screen: ${post.title}`}
className="origin-post-image-preview-trigger"
disabled={!isImageRevealed}
onClick={() => setIsImagePreviewOpen(true)}
ref={previewTriggerRef}
type="button"
>
<img alt={`Source post: ${post.title}`} className={isImageRevealed ? undefined : 'is-blurred'} loading="lazy" src={post.imageUrl} />
</button>
<button
aria-pressed={isImageRevealed}
className="origin-post-image-reveal"
onClick={() => setIsImageRevealed((current) => !current)}
type="button"
>
{isImageRevealed ? 'Blur original image' : 'Reveal original image'}
</button>
</div>
) : null}
<a aria-label={post.title} className="origin-post-copy" href={post.url} rel="noreferrer" target="_blank">
<PostOriginMeta post={post} />
<strong>{post.title}</strong>
<span>Open original post</span>
</a>
</div>
{isImagePreviewOpen && post.imageUrl ? (
<div
aria-label={`Source image: ${post.title}`}
aria-modal="true"
className="origin-image-preview-backdrop"
onMouseDown={(event) => { if (event.target === event.currentTarget) closeImagePreview(); }}
role="dialog"
>
<div className="origin-image-preview-content">
<img alt={`Source post: ${post.title}`} src={post.imageUrl} />
<button aria-label="Close source image preview" onClick={closeImagePreview} ref={previewCloseRef} type="button">×</button>
</div>
</div>
) : null}
<a aria-label={post.title} className="origin-post-copy" href={post.url} rel="noreferrer" target="_blank">
<PostOriginMeta post={post} />
<strong>{post.title}</strong>
<span>Open original post</span>
</a>
</div>
</>
);
}
@@ -2,15 +2,18 @@ import type { ZodType } from 'zod';
import {
ConversationsResponseSchema,
ConversationDetailResponseSchema,
ConversationMediaShareOptionsResponseSchema,
ErrorResponseSchema,
ProcessedPostsResponseSchema,
SendReplyRequestSchema,
ShareConversationMediaRequestSchema,
StartConversationRequestSchema,
StartConversationResponseSchema,
SuccessResponseSchema,
SuggestedReplyResponseSchema,
type ActiveChat,
type ConversationDetailResponse,
type ConversationMediaShareOptions,
type ProcessedPost,
type SuccessResponse,
} from '../../../../../packages/api-contracts/src/index.js';
@@ -34,8 +37,10 @@ export interface ConversationApi {
detail(id: string): Promise<ConversationDetailResponse>;
suggestReply(id: string): Promise<string>;
suggestMediaAsk(id: string): Promise<string>;
mediaShareOptions(id: string): Promise<ConversationMediaShareOptions>;
processedPosts(username: string): Promise<ProcessedPost[]>;
sendReply(id: string, message: string): Promise<SuccessResponse>;
shareMedia(id: string, assetId: string): Promise<SuccessResponse>;
startConversation(accountId: string, username: string, message: string): Promise<ActiveChat>;
}
@@ -66,6 +71,12 @@ export function createConversationApi(fetchImplementation?: typeof fetch): Conve
);
return response.suggestedReply;
},
async mediaShareOptions(id) {
return parseResponse(
await request(`${conversationPath(id)}/shareable-media`),
ConversationMediaShareOptionsResponseSchema,
);
},
async processedPosts(username) {
return parseResponse(
await request(`/api/posts/by-author/${encodeURIComponent(username.trim().replace(/^u\//i, ''))}`),
@@ -83,6 +94,17 @@ export function createConversationApi(fetchImplementation?: typeof fetch): Conve
SuccessResponseSchema,
);
},
async shareMedia(id, assetId) {
const body = ShareConversationMediaRequestSchema.parse({ assetId });
return parseResponse(
await request(`${conversationPath(id)}/share-media`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}),
SuccessResponseSchema,
);
},
async startConversation(accountId, username, message) {
const body = StartConversationRequestSchema.parse({
accountId,
@@ -1,9 +1,17 @@
import { useCallback, useRef, useState } from 'react';
import type { ConversationDetailResponse } from '../../../../../packages/api-contracts/src/index.js';
import type { ConversationDetailResponse, MediaLibraryAsset } from '../../../../../packages/api-contracts/src/index.js';
import { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit-username-link.js';
import { OriginPostCard } from '../../components/origin-post.js';
import { useConversationDetailQuery, useSendReplyMutation, useSuggestMediaAskMutation, useSuggestReplyMutation } from './conversation-queries.js';
import {
useConversationDetailQuery,
useConversationMediaShareOptionsQuery,
useSendReplyMutation,
useShareConversationMediaMutation,
useSuggestMediaAskMutation,
useSuggestReplyMutation,
} from './conversation-queries.js';
import { SendConfirmationDialog } from './send-confirmation-dialog.js';
import { MediaShareConfirmationDialog } from './media-share-confirmation-dialog.js';
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
interface ConversationDetailProps {
@@ -81,10 +89,19 @@ function ConversationComposer({ conversation, conversationId }: { conversation:
const [draft, setDraft] = useState('');
const [isConfirming, setIsConfirming] = useState(false);
const [wasSent, setWasSent] = useState(false);
const [selectedAssetId, setSelectedAssetId] = useState<string | null>(null);
const [areSavedImagesRevealed, setAreSavedImagesRevealed] = useState(false);
const [isConfirmingMedia, setIsConfirmingMedia] = useState(false);
const [wasMediaShared, setWasMediaShared] = useState(false);
const sendButtonRef = useRef<HTMLButtonElement>(null);
const mediaShareSummaryRef = useRef<HTMLElement>(null);
const suggestionMutation = useSuggestReplyMutation(conversationId);
const mediaAskMutation = useSuggestMediaAskMutation(conversationId);
const sendMutation = useSendReplyMutation(conversationId);
const mediaOptionsQuery = useConversationMediaShareOptionsQuery(conversationId);
const mediaShareMutation = useShareConversationMediaMutation(conversationId);
const mediaOptions = mediaOptionsQuery.data?.assets ?? [];
const selectedAsset: MediaLibraryAsset | null = mediaOptions.find((asset) => asset.id === selectedAssetId) ?? null;
const canSend = draft.trim().length > 0 && !sendMutation.isPending;
const automationActive = conversation.automation.status === 'active' && conversation.automation.remainingTurns > 0;
@@ -93,6 +110,11 @@ function ConversationComposer({ conversation, conversationId }: { conversation:
queueMicrotask(() => sendButtonRef.current?.focus());
}, []);
const closeMediaConfirmation = useCallback(() => {
setIsConfirmingMedia(false);
queueMicrotask(() => mediaShareSummaryRef.current?.focus());
}, []);
function suggestReply() {
setWasSent(false);
suggestionMutation.mutate(undefined, { onSuccess: (suggestion) => setDraft(suggestion) });
@@ -114,6 +136,17 @@ function ConversationComposer({ conversation, conversationId }: { conversation:
});
}
function shareMedia() {
if (!selectedAsset) return;
mediaShareMutation.mutate(selectedAsset.id, {
onSuccess() {
setSelectedAssetId(null);
setWasMediaShared(true);
},
onSettled: closeMediaConfirmation,
});
}
return (
<section aria-labelledby="reply-composer-heading" className="conversation-composer">
<div className="conversation-section-heading">
@@ -149,6 +182,53 @@ 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}
<details className="conversation-media-share">
<summary ref={mediaShareSummaryRef}>Share a saved image</summary>
<p>Only enabled, unused images matching the latest inbound request are available.</p>
{mediaOptionsQuery.isPending ? <span role="status">Loading saved images…</span> : null}
{mediaOptionsQuery.isError ? <div className="conversation-inline-error" role="alert">{mediaOptionsQuery.error.message}</div> : null}
{mediaOptionsQuery.data?.reason ? <div className="conversation-media-unavailable">{mediaOptionsQuery.data.reason}</div> : null}
{mediaOptions.length > 0 ? (
<>
<button className="conversation-media-reveal" onClick={() => setAreSavedImagesRevealed((current) => !current)} type="button">
{areSavedImagesRevealed ? 'Blur saved images' : 'Reveal saved images'}
</button>
<div aria-label="Saved images available to share" className="conversation-media-options" role="radiogroup">
{mediaOptions.map((asset) => (
<label className={selectedAssetId === asset.id ? 'is-selected' : ''} key={asset.id}>
<input
checked={selectedAssetId === asset.id}
disabled={!areSavedImagesRevealed}
name="conversation-media-asset"
onChange={() => {
setSelectedAssetId(asset.id);
setWasMediaShared(false);
}}
type="radio"
value={asset.id}
/>
<img
alt={`Saved image tagged ${asset.tags.join(', ')}`}
className={areSavedImagesRevealed ? undefined : 'is-blurred'}
src={asset.urlPath}
/>
<span>{asset.tags.join(', ')} · {asset.safety.toUpperCase()}</span>
</label>
))}
</div>
<button
className="conversation-primary"
disabled={!selectedAsset || mediaShareMutation.isPending}
onClick={() => setIsConfirmingMedia(true)}
type="button"
>
Review image share
</button>
</>
) : null}
{mediaShareMutation.isError ? <div className="conversation-inline-error" role="alert">{mediaShareMutation.error.message}</div> : null}
{wasMediaShared ? <div className="conversation-inline-success" role="status">Saved image shared with {redditUsernameLabel(conversation.username)}.</div> : null}
</details>
<div className="conversation-actions">
<span>{draft.length} characters</span>
<button
@@ -170,6 +250,14 @@ function ConversationComposer({ conversation, conversationId }: { conversation:
onConfirm={sendReply}
/>
) : null}
{isConfirmingMedia && selectedAsset ? (
<MediaShareConfirmationDialog
asset={selectedAsset}
isPending={mediaShareMutation.isPending}
onCancel={closeMediaConfirmation}
onConfirm={shareMedia}
/>
) : null}
</section>
);
}
@@ -5,6 +5,7 @@ export const conversationQueryKeys = {
all: ['conversations'] as const,
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,
};
export function useConversationsQuery() {
@@ -27,6 +28,13 @@ export function useSuggestMediaAskMutation(id: string) {
return useMutation({ mutationFn: () => conversationApi.suggestMediaAsk(id) });
}
export function useConversationMediaShareOptionsQuery(id: string) {
return useQuery({
queryKey: conversationQueryKeys.mediaShareOptions(id),
queryFn: () => conversationApi.mediaShareOptions(id),
});
}
export function useProcessedPostsQuery(username: string) {
return useQuery({
queryKey: conversationQueryKeys.processedPosts(username),
@@ -48,6 +56,20 @@ export function useSendReplyMutation(id: string) {
});
}
export function useShareConversationMediaMutation(id: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (assetId: string) => conversationApi.shareMedia(id, assetId),
async onSuccess() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: conversationQueryKeys.all }),
queryClient.invalidateQueries({ queryKey: conversationQueryKeys.detail(id) }),
queryClient.invalidateQueries({ queryKey: conversationQueryKeys.mediaShareOptions(id) }),
]);
},
});
}
export function useStartConversationMutation() {
const queryClient = useQueryClient();
return useMutation({
@@ -78,6 +78,20 @@
.conversation-inline-error, .conversation-inline-success { padding: .65rem .75rem; border-radius: .55rem; }
.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-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; }
.conversation-media-reveal { justify-self: start; }
.conversation-media-options { display: grid; grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); gap: .65rem; }
.conversation-media-options label { display: grid; gap: .35rem; padding: .35rem; overflow: hidden; border: 1px solid var(--border); border-radius: .55rem; cursor: pointer; }
.conversation-media-options label.is-selected { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.conversation-media-options label:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
.conversation-media-options input { position: absolute; opacity: 0; pointer-events: none; }
.conversation-media-options img { display: block; width: 100%; height: 8rem; object-fit: contain; background: #050907; border-radius: .35rem; transition: filter 160ms ease, transform 160ms ease; }
.conversation-media-options img.is-blurred { filter: blur(22px) brightness(.55); transform: scale(1.1); }
.conversation-media-options span { color: var(--muted); font-size: .7rem; text-transform: capitalize; }
.conversation-media-confirmation img { display: block; width: 100%; max-height: 55vh; margin: 1rem 0; object-fit: contain; background: #050907; border-radius: .55rem; }
.conversation-dialog-backdrop { position: fixed; inset: 0; z-index: 70; display: grid; place-items: center; padding: 1rem; background: rgba(0, 0, 0, .74); }
.conversation-dialog { width: min(30rem, 100%); padding: 1.25rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .9rem; box-shadow: 0 2rem 5rem rgba(0, 0, 0, .45); }
.conversation-dialog p { color: var(--muted); }
@@ -0,0 +1,39 @@
import { useEffect, useRef } from 'react';
import type { MediaLibraryAsset } from '../../../../../packages/api-contracts/src/index.js';
interface MediaShareConfirmationDialogProps {
asset: MediaLibraryAsset;
isPending: boolean;
onCancel(): void;
onConfirm(): void;
}
export function MediaShareConfirmationDialog({ asset, isPending, onCancel, onConfirm }: MediaShareConfirmationDialogProps) {
const cancelButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
cancelButtonRef.current?.focus();
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape' && !isPending) onCancel();
}
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isPending, onCancel]);
return (
<div aria-labelledby="media-share-confirmation-title" aria-modal="true" className="conversation-dialog-backdrop" role="dialog">
<div className="conversation-dialog conversation-media-confirmation">
<span className="conversation-eyebrow">Confirm image</span>
<h2 id="media-share-confirmation-title">Share this saved image?</h2>
<p>This sends immediately through Reddit chat, then asks them to reciprocate without pressure.</p>
<img alt={`Saved image tagged ${asset.tags.join(', ')}`} src={asset.urlPath} />
<div className="conversation-actions">
<button disabled={isPending} onClick={onCancel} ref={cancelButtonRef} type="button">Cancel</button>
<button className="conversation-primary" disabled={isPending} onClick={onConfirm} type="button">
{isPending ? 'Sharing…' : 'Confirm image share'}
</button>
</div>
</div>
</div>
);
}
+11 -3
View File
@@ -116,10 +116,18 @@ h2 { margin-bottom: 0; font-size: 1.1rem; }
.origin-post-dialog-copy a { justify-self: start; text-decoration: none; }
.origin-post-card { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: .8rem; align-items: center; }
.origin-post-image { position: relative; width: 5rem; height: 5rem; overflow: hidden; background: #050907; border-radius: .45rem; }
.origin-post-image img { width: 100%; height: 100%; object-fit: cover; transition: filter 160ms ease, transform 160ms ease; }
.origin-post-image-preview-trigger { display: block; width: 100%; height: 100%; min-height: 0; padding: 0; overflow: hidden; background: transparent; border: 0; border-radius: inherit; cursor: zoom-in; }
.origin-post-image-preview-trigger:disabled { cursor: default; }
.origin-post-image-preview-trigger:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.origin-post-image img { display: block; width: 100%; height: 100%; object-fit: cover; transition: filter 160ms ease, transform 160ms ease; }
.origin-post-image img.is-blurred { filter: blur(18px) brightness(.55); transform: scale(1.12); }
.origin-post-image button { position: absolute; right: .2rem; bottom: .2rem; min-height: auto; padding: .2rem .3rem; border: 1px solid rgba(255, 255, 255, .45); border-radius: .3rem; color: white; background: rgba(0, 0, 0, .76); font-size: .58rem; font-weight: 800; }
.origin-post-image button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.origin-post-image .origin-post-image-reveal { position: absolute; right: .2rem; bottom: .2rem; min-height: auto; padding: .2rem .3rem; border: 1px solid rgba(255, 255, 255, .45); border-radius: .3rem; color: white; background: rgba(0, 0, 0, .76); font-size: .58rem; font-weight: 800; }
.origin-post-image-reveal:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.origin-image-preview-backdrop { position: fixed; inset: 0; z-index: 90; display: flex; align-items: center; justify-content: center; padding: 1rem; background: rgba(0, 0, 0, .92); }
.origin-image-preview-content { position: relative; display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
.origin-image-preview-content img { display: block; width: auto; height: auto; max-width: 100%; max-height: 100%; object-fit: contain; }
.origin-image-preview-content button { position: absolute; top: .5rem; right: .5rem; width: 2.75rem; height: 2.75rem; padding: 0; border: 1px solid rgba(255, 255, 255, .5); border-radius: 50%; color: white; background: rgba(0, 0, 0, .7); font-size: 2rem; line-height: 1; cursor: pointer; }
.origin-image-preview-content button:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.origin-post-copy { display: grid; gap: .2rem; min-width: 0; color: inherit; text-decoration: none; }
.origin-post-card small, .origin-post-card span { color: var(--muted); font-size: .75rem; }
.origin-post-card strong { overflow-wrap: anywhere; }