diff --git a/apps/dashboard/src/components/origin-post.tsx b/apps/dashboard/src/components/origin-post.tsx index 2423a51..fb1fe61 100644 --- a/apps/dashboard/src/components/origin-post.tsx +++ b/apps/dashboard/src/components/origin-post.tsx @@ -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(null); + const previewCloseRef = useRef(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 ( -
- {post.imageUrl ? ( -
- {`Source - + <> +
+ {post.imageUrl ? ( +
+ + +
+ ) : null} + + + {post.title} + Open original post + +
+ {isImagePreviewOpen && post.imageUrl ? ( +
{ if (event.target === event.currentTarget) closeImagePreview(); }} + role="dialog" + > +
+ {`Source + +
) : null} - - - {post.title} - Open original post - -
+ ); } diff --git a/apps/dashboard/src/features/conversations/conversation-api.ts b/apps/dashboard/src/features/conversations/conversation-api.ts index 275277f..98ecf62 100644 --- a/apps/dashboard/src/features/conversations/conversation-api.ts +++ b/apps/dashboard/src/features/conversations/conversation-api.ts @@ -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; suggestReply(id: string): Promise; suggestMediaAsk(id: string): Promise; + mediaShareOptions(id: string): Promise; processedPosts(username: string): Promise; sendReply(id: string, message: string): Promise; + shareMedia(id: string, assetId: string): Promise; startConversation(accountId: string, username: string, message: string): Promise; } @@ -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, diff --git a/apps/dashboard/src/features/conversations/conversation-detail.tsx b/apps/dashboard/src/features/conversations/conversation-detail.tsx index 4f32a16..c6f8947 100644 --- a/apps/dashboard/src/features/conversations/conversation-detail.tsx +++ b/apps/dashboard/src/features/conversations/conversation-detail.tsx @@ -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(null); + const [areSavedImagesRevealed, setAreSavedImagesRevealed] = useState(false); + const [isConfirmingMedia, setIsConfirmingMedia] = useState(false); + const [wasMediaShared, setWasMediaShared] = useState(false); const sendButtonRef = useRef(null); + const mediaShareSummaryRef = useRef(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 (
@@ -149,6 +182,53 @@ function ConversationComposer({ conversation, conversationId }: { conversation: {wasSent ? (
Reply sent to
) : null} +
+ Share a saved image +

Only enabled, unused images matching the latest inbound request are available.

+ {mediaOptionsQuery.isPending ? Loading saved images… : null} + {mediaOptionsQuery.isError ?
{mediaOptionsQuery.error.message}
: null} + {mediaOptionsQuery.data?.reason ?
{mediaOptionsQuery.data.reason}
: null} + {mediaOptions.length > 0 ? ( + <> + +
+ {mediaOptions.map((asset) => ( + + ))} +
+ + + ) : null} + {mediaShareMutation.isError ?
{mediaShareMutation.error.message}
: null} + {wasMediaShared ?
Saved image shared with {redditUsernameLabel(conversation.username)}.
: null} +
{draft.length} characters
); } diff --git a/apps/dashboard/src/features/conversations/conversation-queries.ts b/apps/dashboard/src/features/conversations/conversation-queries.ts index fc2180f..a10d8eb 100644 --- a/apps/dashboard/src/features/conversations/conversation-queries.ts +++ b/apps/dashboard/src/features/conversations/conversation-queries.ts @@ -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({ diff --git a/apps/dashboard/src/features/conversations/conversations.css b/apps/dashboard/src/features/conversations/conversations.css index 95fd20b..7b0dfea 100644 --- a/apps/dashboard/src/features/conversations/conversations.css +++ b/apps/dashboard/src/features/conversations/conversations.css @@ -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); } diff --git a/apps/dashboard/src/features/conversations/media-share-confirmation-dialog.tsx b/apps/dashboard/src/features/conversations/media-share-confirmation-dialog.tsx new file mode 100644 index 0000000..2814497 --- /dev/null +++ b/apps/dashboard/src/features/conversations/media-share-confirmation-dialog.tsx @@ -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(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 ( +
+
+ Confirm image +

Share this saved image?

+

This sends immediately through Reddit chat, then asks them to reciprocate without pressure.

+ {`Saved +
+ + +
+
+
+ ); +} diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css index 8025e4c..d859bf7 100644 --- a/apps/dashboard/src/styles.css +++ b/apps/dashboard/src/styles.css @@ -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; } diff --git a/docs/api.md b/docs/api.md index cb46daf..6a243cb 100644 --- a/docs/api.md +++ b/docs/api.md @@ -75,6 +75,8 @@ an administrator credential. | `POST` | `/api/conversations/{id}/suggest-reply` | Generate a reply suggestion | admin | yes | | `POST` | `/api/conversations/{id}/suggest-media-ask` | Generate a media request suggestion | admin | yes | | `POST` | `/api/conversations/{id}/send-reply` | Send a conversation reply | admin | yes | +| `GET` | `/api/conversations/{id}/shareable-media` | List exchange-library images allowed for the latest inbound request | read | no | +| `POST` | `/api/conversations/{id}/share-media` | Send a selected exchange-library image | admin | yes | | `POST` | `/api/conversations/start` | Start a new conversation | admin | yes | | `GET` | `/api/media` | List received private media | read | no | | `GET` | `/api/media/sync` | Get media sync state | read | no | diff --git a/docs/data-flow.md b/docs/data-flow.md index bb60c89..676b97e 100644 --- a/docs/data-flow.md +++ b/docs/data-flow.md @@ -104,6 +104,8 @@ For each latest inbound text event, `MediaLibraryExchangeService` detects direct After the normal reply limiter waits, automation refreshes Matrix and rechecks that the same inbound request is still latest and that no image, boundary, seller signal, or other terminal state has appeared. It then uploads the private file to Matrix, sends an `m.image` event with a stable transaction ID, and only after confirmation sends the optional reciprocal text. If the image is confirmed but the text is not, the asset remains consumed and the conversation is handed to the operator rather than risking a duplicate. +The conversation UI also supports an explicitly confirmed manual choice from this same eligible set. Its read endpoint reports request-matched options without exposing filesystem paths. The send endpoint refreshes the conversation inside the owning account's rate limiter, rechecks the latest inbound request and safety boundaries, reserves the selected asset in the same durable ledger, and uses the same Matrix image-plus-reciprocal delivery path. A successful manual share marks automation as user-overridden. + Conversation performance is derived from the complete attributed inbound transcript. Engagement cannot become a prompt-learning example while automation is active, and a later decline, boundary, or upsell removes an earlier example before future prompts load it. ## Dashboard data and events diff --git a/docs/frontend.md b/docs/frontend.md index ab4ad9b..20b0fe1 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -57,10 +57,12 @@ Rules: The login form exchanges the administrator token for an HttpOnly, SameSite=Strict cookie. API clients may use Bearer tokens. Express applies auth before static dashboard assets, APIs, SSE, and `/media`; `/healthz` is the intentional public exception. -Media may be private conversation content. Preserve authenticated URLs, blur/privacy defaults, explicit reveal behavior, safe new-tab links, and no token leakage into query strings or local storage. +Media may be private conversation content. Preserve authenticated URLs, blur/privacy defaults, explicit reveal behavior, safe new-tab links, and no token leakage into query strings or local storage. A conversation's source-post thumbnail becomes a keyboard-accessible full-screen preview only after the operator reveals it; the preview closes by button, backdrop, or Escape and restores focus to its trigger. Exchange-library uploads require an explicit consent checkbox before the raw image request is enabled. Previews remain blurred by default. The management card exposes AI tags, safety level, confidence/review state, enablement, and a two-step delete action. Its progression rail mirrors the actual nonspecific-request selection tiers rather than implying that explicit anatomy requests follow the same sequence. +Conversation reply composers include a collapsed saved-image picker. It lists only server-approved assets for the latest inbound request, keeps thumbnails blurred until revealed, and requires selection plus a dedicated confirmation dialog before sending. The server remains authoritative and may reject a choice if the conversation changes before the rate-limited send executes. + ## Feature structure Each route is a slice under `features//` with page/route components, local styles, and feature-specific helpers. Cross-feature visual/domain components live under `components/`; application lifecycle belongs under `app/`; transport belongs under `api/`. diff --git a/docs/product-and-safety.md b/docs/product-and-safety.md index f105c11..32d7b33 100644 --- a/docs/product-and-safety.md +++ b/docs/product-and-safety.md @@ -57,6 +57,8 @@ Nonspecific requests broaden progressively within one conversation. The first se Reddit candidate eligibility remains the product's adult-entry assumption; this feature adds no separate verification workflow. Existing deterministic underage disclosures and conversation boundaries still stop automation before a library send. +The conversation dashboard may let an operator choose the specific library asset, but it does not waive these rules. The picker exposes only enabled, unused assets that match the latest inbound request and its current safety tier. A manual choice is revalidated after rate-limit waiting, requires explicit send confirmation, and moves the conversation to operator-owned state after a confirmed delivery. + ## Success and analytics Do not collapse all progress into “messages sent.” Useful states include: diff --git a/packages/api-contracts/src/chats.ts b/packages/api-contracts/src/chats.ts index 0e50efd..8d9f5eb 100644 --- a/packages/api-contracts/src/chats.ts +++ b/packages/api-contracts/src/chats.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { ErrorResponseSchema, RejectedActionResponseSchema } from './common.js'; import { MAX_CONFIGURED_AUTOMATED_CONVERSATION_TURNS } from './config.js'; import { RedditAccountOwnerSchema } from './session.js'; +import { MediaLibraryAssetSchema, MediaLibraryTagSchema } from './media.js'; export const ChatMediaSchema = z.strictObject({ originalUrl: z.string(), @@ -91,6 +92,12 @@ export const SuggestedReplyResponseSchema = z.strictObject({ conversationState: z.unknown().optional(), }); export const SendReplyRequestSchema = z.strictObject({ message: z.string().min(1) }); +export const ConversationMediaShareOptionsResponseSchema = z.strictObject({ + requestKind: z.union([MediaLibraryTagSchema, z.literal('generic')]).nullable(), + assets: z.array(MediaLibraryAssetSchema), + reason: z.string().nullable(), +}); +export const ShareConversationMediaRequestSchema = z.strictObject({ assetId: z.string().uuid() }); export const StartConversationRequestSchema = z.strictObject({ accountId: z.string().min(1), username: z.string().min(1), @@ -113,3 +120,4 @@ export const FetchAllChatsResponseSchema = z.strictObject({ export type ActiveChat = z.infer; export type ChatSyncStatus = z.infer; export type ConversationDetailResponse = z.infer; +export type ConversationMediaShareOptions = z.infer; diff --git a/src/services/media-library-exchange.ts b/src/services/media-library-exchange.ts index 97cd523..cf2ea80 100644 --- a/src/services/media-library-exchange.ts +++ b/src/services/media-library-exchange.ts @@ -28,6 +28,11 @@ export type MediaExchangeResult = | { kind: 'retryable'; assetId: string; requestKind: MediaRequestKind } | { kind: 'human-required'; reason: string; remoteSent?: boolean }; +export interface ShareableMediaLibraryAssets { + requestKind: MediaRequestKind | null; + assets: MediaLibraryAsset[]; +} + /** Detects inbound requests, reserves an unused asset, then sends image before reciprocal text. */ export class MediaLibraryExchangeService { constructor( @@ -59,6 +64,35 @@ export class MediaLibraryExchangeService { conversationId: string, incomingKey: string, incomingText: string, + ): Promise { + return this.handleRequest(conversationId, incomingKey, incomingText); + } + + listShareable(conversationId: string, incomingText: string): ShareableMediaLibraryAssets { + const prior = this.repository.listConversationSends(this.accountId, conversationId); + const requestKind = detectMediaRequest(incomingText, prior); + if (!requestKind) return { requestKind: null, assets: [] }; + const used = new Set(prior.map((item) => item.assetId)); + return { + requestKind, + assets: this.pickEligible(this.repository.listEligible(), used, requestKind, prior), + }; + } + + async handleSelected( + conversationId: string, + incomingKey: string, + incomingText: string, + assetId: string, + ): Promise { + return this.handleRequest(conversationId, incomingKey, incomingText, assetId); + } + + private async handleRequest( + conversationId: string, + incomingKey: string, + incomingText: string, + selectedAssetId?: string, ): Promise { const prior = this.repository.listConversationSends(this.accountId, conversationId); const requestKind = detectMediaRequest(incomingText, prior); @@ -66,6 +100,12 @@ export class MediaLibraryExchangeService { if (this.dryRun) return { kind: 'dry-run', requestKind }; let send = this.repository.getSend(this.accountId, conversationId, incomingKey); + if (send && selectedAssetId && send.assetId !== selectedAssetId) { + return { kind: 'human-required', reason: 'A different library image is already reserved for this request.' }; + } + if (send?.status === 'sent' && send.matrixTextEventId === 'unconfirmed') { + return { kind: 'human-required', reason: 'The image was sent, but the reciprocal text was not confirmed.', remoteSent: true }; + } if (send?.status === 'sent') return { kind: 'sent', assetId: send.assetId, requestKind: send.requestKind }; if (send?.status === 'failed' || send?.status === 'superseded') { return { kind: 'human-required', reason: 'A previous media send for this request failed or became uncertain.' }; @@ -73,7 +113,13 @@ export class MediaLibraryExchangeService { if (!send) { const used = new Set(prior.map((item) => item.assetId)); const eligible = this.pickEligible(this.repository.listEligible(), used, requestKind, prior); - for (const asset of shuffled(eligible, this.random)) { + const candidates = selectedAssetId + ? eligible.filter((asset) => asset.id === selectedAssetId) + : shuffled(eligible, this.random); + if (selectedAssetId && candidates.length === 0) { + return { kind: 'human-required', reason: 'That library image is disabled, already used, or does not match the current request.' }; + } + for (const asset of candidates) { send = this.repository.tryReserve(asset.id, this.accountId, conversationId, incomingKey, requestKind) ?? undefined; if (send) break; } diff --git a/src/services/reddit-account-runtime-builder.ts b/src/services/reddit-account-runtime-builder.ts index 8cf30c8..430bdd3 100644 --- a/src/services/reddit-account-runtime-builder.ts +++ b/src/services/reddit-account-runtime-builder.ts @@ -227,7 +227,7 @@ async function buildRuntime( conversationStateRepo, conversationOutcomesRepo, redditUsersRepo, accountContentRepo, activityLogRepo, monitorService, processorService, responseChecker, conversationAutomation, chatService, mediaService, - mediaSyncService, rateLimiter, + mediaSyncService, mediaExchange, rateLimiter, }; const lifecycle = new AccountRuntimeLoop( resources, accountConfig, logger, isBotRunning, recipientDeduplicator, diff --git a/src/services/reddit-account-runtime.ts b/src/services/reddit-account-runtime.ts index 572174f..760c6db 100644 --- a/src/services/reddit-account-runtime.ts +++ b/src/services/reddit-account-runtime.ts @@ -26,6 +26,7 @@ import type { MatrixCredentials } from './chat/matrix/types.js'; import type { ChatService } from './chat/service.js'; import type { MediaService } from './media.js'; import type { MediaSyncService } from './media-sync.js'; +import type { MediaLibraryExchangeService } from './media-library-exchange.js'; import type { MonitorService } from './monitor.js'; import type { ProcessorService } from './processor.js'; import type { ResponseCheckerService } from './response-checker.js'; @@ -76,6 +77,7 @@ export interface RedditAccountRuntimeResources { chatService: ChatService; mediaService: MediaService; mediaSyncService: MediaSyncService; + mediaExchange?: MediaLibraryExchangeService; rateLimiter: RateLimiter; } diff --git a/src/web/api-catalog.ts b/src/web/api-catalog.ts index 3ace9f2..d25bbf9 100644 --- a/src/web/api-catalog.ts +++ b/src/web/api-catalog.ts @@ -83,6 +83,8 @@ export const API_OPERATIONS: readonly ApiOperation[] = [ operation('suggestReply', 'POST', '/api/conversations/{id}/suggest-reply', 'Generate a reply suggestion', ['Conversations']), operation('suggestMediaAsk', 'POST', '/api/conversations/{id}/suggest-media-ask', 'Generate a media request suggestion', ['Conversations']), operation('sendReply', 'POST', '/api/conversations/{id}/send-reply', 'Send a conversation reply', ['Conversations']), + operation('getConversationMediaOptions', 'GET', '/api/conversations/{id}/shareable-media', 'List exchange-library images allowed for the latest inbound request', ['Conversations']), + operation('shareConversationMedia', 'POST', '/api/conversations/{id}/share-media', 'Send a selected exchange-library image', ['Conversations']), operation('startConversation', 'POST', '/api/conversations/start', 'Start a new conversation', ['Conversations']), operation('getMedia', 'GET', '/api/media', 'List received private media', ['Media']), operation('getMediaSync', 'GET', '/api/media/sync', 'Get media sync state', ['Media']), diff --git a/src/web/routes/conversations.ts b/src/web/routes/conversations.ts index cd356a2..732af5f 100644 --- a/src/web/routes/conversations.ts +++ b/src/web/routes/conversations.ts @@ -1,4 +1,5 @@ import { Router } from 'express'; +import { z } from 'zod'; import type { ActiveChat } from '../../services/chat.js'; import type { WebContext } from '../context.js'; import { @@ -8,6 +9,9 @@ import { mergeConversationState, remainingAutomationTurns, } from '../../services/conversation-state.js'; +import type { MediaLibraryAsset } from '../../services/media-library-types.js'; + +const ShareConversationMediaRequestSchema = z.strictObject({ assetId: z.string().uuid() }); type OwnedActiveChat = ActiveChat & { owner?: { accountId: string; redditUsername: string } }; @@ -76,6 +80,38 @@ export function createConversationsRouter(ctx: WebContext): Router { : conversation; } + function mediaShareEligibility( + conversation: ActiveChat, + runtime: NonNullable['get']>>, + ): { incomingKey: string; incomingText: string } | { reason: string } { + if (!conversation.roomId) return { reason: 'This conversation has no Matrix room.' }; + const messages = conversation.messages ?? []; + const latest = messages.at(-1); + if (!latest || latest.isSelf) return { reason: 'The latest message is not an inbound image request.' }; + if (messages.some((message) => !message.isSelf && (message.media?.length ?? 0) > 0)) { + return { reason: 'An image has already been received, so library sharing is disabled.' }; + } + const saved = runtime.resources.conversationStateRepo.get(conversation.roomId); + const state = mergeConversationState( + saved, + buildConversationState(messages.map(({ sender, text, isSelf }) => ({ sender, text, isSelf }))), + ); + if (state.sellerDetected) return { reason: 'Library sharing is disabled for a conversation marked as a seller.' }; + if (state.boundaries.length > 0) return { reason: `Library sharing is disabled after a conversation boundary: ${state.boundaries.join('; ')}` }; + return { + incomingKey: latest.eventId ?? `${latest.timestamp}:${latest.sender}:${latest.text}`, + incomingText: latest.text, + }; + } + + function toMediaLibraryResponse(asset: MediaLibraryAsset) { + const { relativePath, ...publicAsset } = asset; + return { + ...publicAsset, + urlPath: `/library-media/${relativePath.split(/[\\/]/).map(encodeURIComponent).join('/')}`, + }; + } + router.get('/conversations', async (req, res) => { if (!ctx.services.chat) { res.status(503).json({ error: 'Chat service not initialized' }); @@ -419,6 +455,88 @@ export function createConversationsRouter(ctx: WebContext): Router { } }); + router.get('/conversations/:id/shareable-media', async (req, res) => { + try { + const resolved = await findConversation(decodeURIComponent(req.params.id)); + if (!resolved) return res.status(404).json({ error: 'Conversation not found' }); + const exchange = resolved.runtime?.resources.mediaExchange; + if (!resolved.runtime || !exchange) return res.status(503).json({ error: 'Media sharing is not initialized for this account' }); + const eligible = mediaShareEligibility(resolved.conversation, resolved.runtime); + if ('reason' in eligible) return res.json({ requestKind: null, assets: [], reason: eligible.reason }); + const options = exchange.listShareable(resolved.conversation.roomId!, eligible.incomingText); + const reason = !options.requestKind + ? 'The latest inbound message does not ask for an image.' + : options.assets.length === 0 + ? 'No unused enabled library image matches this request.' + : null; + return res.json({ ...options, assets: options.assets.map(toMediaLibraryResponse), reason }); + } catch (error) { + ctx.logger.error('Failed to list shareable conversation media:', error); + return res.status(500).json({ error: 'Failed to list shareable media' }); + } + }); + + router.post('/conversations/:id/share-media', async (req, res) => { + const parsed = ShareConversationMediaRequestSchema.safeParse(req.body); + if (!parsed.success) return res.status(400).json({ error: 'A valid library asset ID is required' }); + try { + const resolved = await findConversation(decodeURIComponent(req.params.id)); + if (!resolved) return res.status(404).json({ error: 'Conversation not found' }); + const runtime = resolved.runtime; + const exchange = runtime?.resources.mediaExchange; + if (!runtime || !exchange) return res.status(503).json({ error: 'Media sharing is not initialized for this account' }); + const chatService = runtime.resources.chatService; + const result = await runtime.resources.rateLimiter.throttle( + async () => { + await chatService.loadConversationDetails(resolved.conversation); + const eligible = mediaShareEligibility(resolved.conversation, runtime); + if ('reason' in eligible) return { kind: 'human-required' as const, reason: eligible.reason }; + return exchange.handleSelected( + resolved.conversation.roomId!, + eligible.incomingKey, + eligible.incomingText, + parsed.data.assetId, + ); + }, + (outcome) => outcome.kind === 'sent' || (outcome.kind === 'human-required' && outcome.remoteSent === true), + ); + + if (result.kind !== 'sent' && !(result.kind === 'human-required' && result.remoteSent === true)) { + const reason = result.kind === 'not-requested' + ? 'The latest inbound message does not ask for an image.' + : result.kind === 'dry-run' + ? 'Dry-run mode prevented the image from being sent.' + : result.kind === 'retryable' + ? 'The image send can be retried later.' + : result.reason; + return res.status(409).json({ error: reason }); + } + + const roomId = resolved.conversation.roomId!; + const state = mergeConversationState( + runtime.resources.conversationStateRepo.get(roomId), + buildConversationState((resolved.conversation.messages ?? []).map(({ sender, text, isSelf }) => ({ sender, text, isSelf }))), + ); + state.automation.status = 'user-overridden'; + state.automation.handoffReason = 'An exchange-library image was manually shared in this conversation.'; + runtime.resources.conversationStateRepo.save(roomId, state); + runtime.resources.activityLogRepo.insert({ + timestamp: Date.now(), type: 'sent', + message: `Shared a library image with u/${resolved.conversation.username}`, + recipient: resolved.conversation.username, + }); + ctx.broadcaster.push('info', `Library image shared with u/${resolved.conversation.username}`); + try { await chatService.refreshTopChats(5); } + catch (error) { ctx.logger.error('Failed to refresh chats after share-media:', error); } + + if (result.kind === 'human-required') return res.status(502).json({ error: result.reason }); + return res.json({ success: true }); + } catch (error) { + ctx.logger.error('Failed to share conversation media:', error); + return res.status(500).json({ error: 'Failed to share library image' }); + } + }); + /** * Start a brand-new DM with a Reddit user and send the opening message. * Body: { username: string, message: string } diff --git a/tests/dashboard/conversations.test.tsx b/tests/dashboard/conversations.test.tsx index b4e7bb7..1f71253 100644 --- a/tests/dashboard/conversations.test.tsx +++ b/tests/dashboard/conversations.test.tsx @@ -59,10 +59,15 @@ function detailFixture(chat = alice) { }; } -const server = setupServer(http.get('/api/accounts', () => HttpResponse.json([{ - accountId: '@t2_bot:reddit.com', redditUsername: 'bot-account', matrixUserId: '@t2_bot:reddit.com', - isRunning: true, status: 'authenticated', createdAt: 1, updatedAt: 2, lastImportedAt: 2, -}]))); +const server = setupServer( + http.get('/api/accounts', () => HttpResponse.json([{ + accountId: '@t2_bot:reddit.com', redditUsername: 'bot-account', matrixUserId: '@t2_bot:reddit.com', + isRunning: true, status: 'authenticated', createdAt: 1, updatedAt: 2, lastImportedAt: 2, + }])), + http.get('/api/conversations/:id/shareable-media', () => HttpResponse.json({ + requestKind: null, assets: [], reason: 'The latest inbound message does not ask for an image.', + })), +); function installConversationList() { server.use( @@ -147,8 +152,20 @@ describe('ConversationsPage', () => { ); expect(screen.getByText('2h ago')).toBeInTheDocument(); expect(screen.getByRole('img', { name: 'Source post: Looking for a chat' })).toHaveClass('is-blurred'); + expect(screen.getByRole('button', { name: 'View source image full screen: Looking for a chat' })).toBeDisabled(); await user.click(screen.getByRole('button', { name: 'Reveal original image' })); expect(screen.getByRole('img', { name: 'Source post: Looking for a chat' })).not.toHaveClass('is-blurred'); + const previewTrigger = screen.getByRole('button', { name: 'View source image full screen: Looking for a chat' }); + await user.click(previewTrigger); + const preview = screen.getByRole('dialog', { name: 'Source image: Looking for a chat' }); + expect(within(preview).getByRole('img', { name: 'Source post: Looking for a chat' })).toHaveAttribute( + 'src', + 'https://i.redd.it/source-post.jpg', + ); + expect(within(preview).getByRole('button', { name: 'Close source image preview' })).toHaveFocus(); + await user.keyboard('{Escape}'); + expect(screen.queryByRole('dialog', { name: 'Source image: Looking for a chat' })).not.toBeInTheDocument(); + expect(previewTrigger).toHaveFocus(); }); it('hides detected sellers by default and reveals marked conversations on request', async () => { @@ -313,6 +330,54 @@ describe('ConversationsPage', () => { expect(await screen.findByRole('textbox', { name: 'Reply to u/alice' })).toHaveValue('Would you like to share a photo?'); }); + it('shares an eligible saved image through an explicit confirmation', async () => { + const asset = { + id: '11111111-1111-4111-8111-111111111111', tags: ['selfie'], safety: 'sfw', + classificationStatus: 'ready', description: 'A selfie', confidence: 0.95, + fileName: 'selfie.jpg', mimeType: 'image/jpeg', sizeBytes: 1024, width: 800, height: 1000, + enabled: true, consentConfirmedAt: 1, createdAt: 1, updatedAt: 1, + sendCount: 0, lastSentAt: null, urlPath: '/library-media/selfie.jpg', + }; + let sharedAssetId: string | null = null; + server.use( + http.get('/api/conversations', () => HttpResponse.json([alice])), + http.get('/api/conversations/room-alice', () => HttpResponse.json({ + ...detailFixture(), + messages: [ + { sender: 'me', text: 'Hello Alice', timestamp: 1, isSelf: true }, + { eventId: '$request', sender: 'alice', text: 'Can you send a selfie?', timestamp: 2, isSelf: false }, + ], + })), + http.get('/api/conversations/room-alice/shareable-media', () => HttpResponse.json({ + requestKind: 'selfie', assets: [asset], reason: null, + })), + http.post('/api/conversations/room-alice/share-media', async ({ request }) => { + const body = await request.json() as { assetId: string }; + sharedAssetId = body.assetId; + return HttpResponse.json({ success: true }); + }), + ); + const user = userEvent.setup(); + renderConversations(); + + await user.click(await screen.findByRole('button', { name: /u\/alice/ })); + await user.click(await screen.findByText('Share a saved image')); + const image = await screen.findByRole('img', { name: 'Saved image tagged selfie' }); + expect(image).toHaveClass('is-blurred'); + await user.click(screen.getByRole('button', { name: 'Reveal saved images' })); + expect(image).not.toHaveClass('is-blurred'); + await user.click(screen.getByRole('radio', { name: /Saved image tagged selfie/ })); + const reviewButton = screen.getByRole('button', { name: 'Review image share' }); + await user.click(reviewButton); + const dialog = screen.getByRole('dialog', { name: 'Share this saved image?' }); + expect(within(dialog).getByRole('img', { name: 'Saved image tagged selfie' })).toHaveAttribute('src', asset.urlPath); + await user.click(within(dialog).getByRole('button', { name: 'Confirm image share' })); + + expect(await screen.findByText(/Saved image shared with u\/alice/)).toBeInTheDocument(); + expect(sharedAssetId).toBe(asset.id); + expect(screen.getByText('Share a saved image')).toHaveFocus(); + }); + it('shows unused turns separately from a safety handoff', async () => { server.use( http.get('/api/conversations', () => HttpResponse.json([alice])), diff --git a/tests/media-library-exchange.test.ts b/tests/media-library-exchange.test.ts index 2d12866..e2e399c 100644 --- a/tests/media-library-exchange.test.ts +++ b/tests/media-library-exchange.test.ts @@ -81,6 +81,54 @@ describe('MediaLibraryExchangeService', () => { expect(chat.sendChatImage).toHaveBeenCalledOnce(); db.close(); }); + + it('lists eligible choices and sends only the operator-selected matching asset', async () => { + const db = createTestDb(); + const repository = new MediaLibraryRepository(db); + await addAsset(repository, directory, 'selfie-one', ['selfie'], 'sfw', 1); + await addAsset(repository, directory, 'selfie-two', ['selfie'], 'sfw', 2); + await addAsset(repository, directory, 'topless', ['topless'], 'nsfw', 3); + const chat = { + sendChatImage: vi.fn().mockResolvedValue({ eventId: '$selected', mxcUri: 'mxc://selected' }), + sendChatMessage: vi.fn().mockResolvedValue(true), + }; + const service = new MediaLibraryExchangeService(repository, chat, logger(), 'account', directory); + + expect(service.listShareable('room', 'send a selfie').assets.map((asset) => asset.id)).toEqual([ + 'selfie-two', 'selfie-one', + ]); + expect(await service.handleSelected('room', '$request', 'send a selfie', 'topless')).toMatchObject({ + kind: 'human-required', + }); + expect(await service.handleSelected('room', '$request', 'send a selfie', 'selfie-one')).toMatchObject({ + kind: 'sent', assetId: 'selfie-one', requestKind: 'selfie', + }); + expect(chat.sendChatImage).toHaveBeenCalledWith( + 'room', expect.objectContaining({ fileName: 'selfie-one.jpg' }), expect.any(String), null, + ); + db.close(); + }); + + it('keeps an image-only partial delivery human-required on later attempts', async () => { + const db = createTestDb(); + const repository = new MediaLibraryRepository(db); + await addAsset(repository, directory, 'selfie', ['selfie'], 'sfw', 1); + const chat = { + sendChatImage: vi.fn().mockResolvedValue({ eventId: '$image', mxcUri: 'mxc://image' }), + sendChatMessage: vi.fn().mockResolvedValue(false), + }; + const service = new MediaLibraryExchangeService(repository, chat, logger(), 'account', directory); + + await expect(service.handleSelected('room', '$request', 'send a selfie', 'selfie')).resolves.toMatchObject({ + kind: 'human-required', remoteSent: true, + }); + await expect(service.handleSelected('room', '$request', 'send a selfie', 'selfie')).resolves.toMatchObject({ + kind: 'human-required', remoteSent: true, + }); + expect(chat.sendChatImage).toHaveBeenCalledOnce(); + expect(chat.sendChatMessage).toHaveBeenCalledOnce(); + db.close(); + }); }); async function addAsset( diff --git a/tests/web/conversations-route.test.ts b/tests/web/conversations-route.test.ts index 94f2add..c384f18 100644 --- a/tests/web/conversations-route.test.ts +++ b/tests/web/conversations-route.test.ts @@ -133,6 +133,72 @@ describe('conversations route', () => { expect(first.resources.activityLogRepo.insert).not.toHaveBeenCalled(); }); + it('lists and sends only request-matched library media through the owning runtime', async () => { + const accountId = '@t2_one:reddit.com'; + const assetId = '11111111-1111-4111-8111-111111111111'; + const conversation = { + username: 'alice', roomName: 'alice', roomId: 'room-one', url: '/room/room-one', + lastMessageTime: 2, lastMessageSnippet: 'send a selfie', isFromUs: false, unread: true, + messages: [ + { sender: 'me', text: 'hello', timestamp: 1, isSelf: true }, + { eventId: '$request', sender: 'alice', text: 'send a selfie', timestamp: 2, isSelf: false }, + ], + }; + const asset = { + id: assetId, tags: ['selfie'], safety: 'sfw', classificationStatus: 'ready', description: 'selfie', confidence: 1, + fileName: 'selfie.jpg', relativePath: 'nested/selfie.jpg', mimeType: 'image/jpeg', sizeBytes: 4, + width: 10, height: 10, enabled: true, consentConfirmedAt: 1, createdAt: 1, updatedAt: 1, + sendCount: 0, lastSentAt: null, + }; + const mediaExchange = { + listShareable: vi.fn().mockReturnValue({ requestKind: 'selfie', assets: [asset] }), + handleSelected: vi.fn().mockResolvedValue({ kind: 'sent', assetId, requestKind: 'selfie' }), + }; + const runtime = { + snapshot: () => ({ state: 'running' }), + resources: { + chatService: { + getCachedConversations: vi.fn().mockResolvedValue([conversation]), + loadConversationDetails: vi.fn().mockResolvedValue(undefined), + refreshTopChats: vi.fn().mockResolvedValue(undefined), + }, + conversationStateRepo: { get: vi.fn(), save: vi.fn() }, + activityLogRepo: { insert: vi.fn() }, + mediaExchange, + rateLimiter: { throttle: vi.fn((work: () => Promise) => work()) }, + }, + }; + const context = { + logger: { error: vi.fn() }, activity: { push: vi.fn() }, broadcaster: { push: vi.fn() }, + services: { + chat: {}, + accountRuntimes: { + list: () => [{ accountId, redditUsername: 'owner-one', state: 'running' }], + get: (requested: string) => requested === accountId ? runtime : null, + }, + }, + } as unknown as WebContext; + const app = express().use(express.json()).use('/api', createConversationsRouter(context)); + const ownedId = `${encodeURIComponent(accountId)}~room-one`; + + const optionsResponse = await request(app, `/api/conversations/${encodeURIComponent(ownedId)}/shareable-media`); + expect(optionsResponse.status).toBe(200); + expect(await optionsResponse.json()).toEqual({ + requestKind: 'selfie', reason: null, + assets: [expect.objectContaining({ id: assetId, urlPath: '/library-media/nested/selfie.jpg' })], + }); + + const sendResponse = await request(app, `/api/conversations/${encodeURIComponent(ownedId)}/share-media`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ assetId }), + }); + expect(sendResponse.status).toBe(200); + expect(await sendResponse.json()).toEqual({ success: true }); + expect(mediaExchange.handleSelected).toHaveBeenCalledWith('room-one', '$request', 'send a selfie', assetId); + expect(runtime.resources.conversationStateRepo.save).toHaveBeenCalledWith('room-one', expect.objectContaining({ + automation: expect.objectContaining({ status: 'user-overridden' }), + })); + }); + it('rejects an unowned conversation ID while account runtimes are active', async () => { const context = { logger: { error: vi.fn() },