feat: add conversation image previews and sharing
This commit is contained in:
1 parent
488145340d
commit
b573b88b08
20 files changed
+620
-12
No files matched your search
@@ -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,13 +35,50 @@ 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">
|
||||
<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 aria-pressed={isImageRevealed} onClick={() => setIsImageRevealed((current) => !current)} type="button">
|
||||
</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>
|
||||
@@ -52,6 +89,21 @@ export function OriginPostCard({ post }: { post: OriginPost }) {
|
||||
<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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-1
@@ -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/<name>/` 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/`.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<typeof ActiveChatSchema>;
|
||||
export type ChatSyncStatus = z.infer<typeof ChatSyncStatusSchema>;
|
||||
export type ConversationDetailResponse = z.infer<typeof ConversationDetailResponseSchema>;
|
||||
export type ConversationMediaShareOptions = z.infer<typeof ConversationMediaShareOptionsResponseSchema>;
|
||||
@@ -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<MediaExchangeResult> {
|
||||
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<MediaExchangeResult> {
|
||||
return this.handleRequest(conversationId, incomingKey, incomingText, assetId);
|
||||
}
|
||||
|
||||
private async handleRequest(
|
||||
conversationId: string,
|
||||
incomingKey: string,
|
||||
incomingText: string,
|
||||
selectedAssetId?: string,
|
||||
): Promise<MediaExchangeResult> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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']),
|
||||
|
||||
@@ -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<ReturnType<NonNullable<WebContext['services']['accountRuntimes']>['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 }
|
||||
|
||||
@@ -59,10 +59,15 @@ function detailFixture(chat = alice) {
|
||||
};
|
||||
}
|
||||
|
||||
const server = setupServer(http.get('/api/accounts', () => HttpResponse.json([{
|
||||
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])),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<unknown>) => 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() },
|
||||
|
||||
Reference in new issue
Block a user