fix: keep conversation history visible
This commit is contained in:
1 parent
712226cd43
commit
b7fcc7db05
4 files changed
+19
-6
No files matched your search
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
useConversationImageReplacementOptionsQuery,
|
useConversationImageReplacementOptionsQuery,
|
||||||
useGenerateConversationImageReplacementMutation,
|
useGenerateConversationImageReplacementMutation,
|
||||||
@@ -11,6 +11,7 @@ export function ConversationImageReplacement({ conversationId }: { conversationI
|
|||||||
const [isResultRevealed, setIsResultRevealed] = useState(false);
|
const [isResultRevealed, setIsResultRevealed] = useState(false);
|
||||||
const [sourceAssetId, setSourceAssetId] = useState<string | null>(null);
|
const [sourceAssetId, setSourceAssetId] = useState<string | null>(null);
|
||||||
const [targetId, setTargetId] = useState<string | null>(null);
|
const [targetId, setTargetId] = useState<string | null>(null);
|
||||||
|
const targetFieldsetRef = useRef<HTMLFieldSetElement>(null);
|
||||||
const optionsQuery = useConversationImageReplacementOptionsQuery(conversationId, isOpen);
|
const optionsQuery = useConversationImageReplacementOptionsQuery(conversationId, isOpen);
|
||||||
const generation = useGenerateConversationImageReplacementMutation(conversationId);
|
const generation = useGenerateConversationImageReplacementMutation(conversationId);
|
||||||
const options = optionsQuery.data;
|
const options = optionsQuery.data;
|
||||||
@@ -53,6 +54,9 @@ export function ConversationImageReplacement({ conversationId }: { conversationI
|
|||||||
onChange={() => {
|
onChange={() => {
|
||||||
setSourceAssetId(source.id);
|
setSourceAssetId(source.id);
|
||||||
clearResult();
|
clearResult();
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
targetFieldsetRef.current?.scrollIntoView?.({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
type="radio"
|
type="radio"
|
||||||
/>
|
/>
|
||||||
@@ -68,7 +72,7 @@ export function ConversationImageReplacement({ conversationId }: { conversationI
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
) : null}
|
) : null}
|
||||||
{options?.targets.length ? (
|
{options?.targets.length ? (
|
||||||
<fieldset>
|
<fieldset ref={targetFieldsetRef}>
|
||||||
<legend>2. Image to place on the green screen</legend>
|
<legend>2. Image to place on the green screen</legend>
|
||||||
<button onClick={() => setAreTargetsRevealed((current) => !current)} type="button">
|
<button onClick={() => setAreTargetsRevealed((current) => !current)} type="button">
|
||||||
{areTargetsRevealed ? 'Blur conversation choices' : 'Reveal conversation choices'}
|
{areTargetsRevealed ? 'Blur conversation choices' : 'Reveal conversation choices'}
|
||||||
|
|||||||
@@ -143,7 +143,7 @@
|
|||||||
.conversation-image-reveal { position: absolute; bottom: .5rem; left: .5rem; min-height: 2rem !important; padding: .35rem .6rem !important; color: white !important; background: rgba(0, 0, 0, .76) !important; border-color: rgba(255, 255, 255, .45) !important; font-size: .75rem; font-weight: 800; }
|
.conversation-image-reveal { position: absolute; bottom: .5rem; left: .5rem; min-height: 2rem !important; padding: .35rem .6rem !important; color: white !important; background: rgba(0, 0, 0, .76) !important; border-color: rgba(255, 255, 255, .45) !important; font-size: .75rem; font-weight: 800; }
|
||||||
.conversation-image-reveal:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
.conversation-image-reveal:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
.conversation-empty-detail { padding: 2rem; color: var(--muted); text-align: center; }
|
.conversation-empty-detail { padding: 2rem; color: var(--muted); text-align: center; }
|
||||||
.conversation-composer { flex: 0 0 auto; display: grid; gap: .65rem; padding: 1rem; overflow-y: auto; border-top: 1px solid var(--border); }
|
.conversation-composer { flex: 0 1 auto; display: grid; gap: .65rem; min-height: 0; max-height: 70%; padding: 1rem; overflow-y: auto; overscroll-behavior-y: contain; scrollbar-gutter: stable; border-top: 1px solid var(--border); }
|
||||||
.conversation-section-heading, .conversation-actions { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
|
.conversation-section-heading, .conversation-actions { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
|
||||||
.conversation-section-heading h3 { margin: 0; }
|
.conversation-section-heading h3 { margin: 0; }
|
||||||
.conversation-actions > span { color: var(--muted); font-size: .75rem; }
|
.conversation-actions > span { color: var(--muted); font-size: .75rem; }
|
||||||
@@ -165,7 +165,7 @@
|
|||||||
.conversation-image-replacement fieldset { display: grid; gap: .55rem; min-width: 0; padding: .65rem; border: 1px solid var(--border); border-radius: .55rem; }
|
.conversation-image-replacement fieldset { display: grid; gap: .55rem; min-width: 0; padding: .65rem; border: 1px solid var(--border); border-radius: .55rem; }
|
||||||
.conversation-image-replacement legend { padding: 0 .3rem; color: #e9f1ec; font-size: .8rem; font-weight: 800; }
|
.conversation-image-replacement legend { padding: 0 .3rem; color: #e9f1ec; font-size: .8rem; font-weight: 800; }
|
||||||
.conversation-image-replacement fieldset > button { justify-self: start; }
|
.conversation-image-replacement fieldset > button { justify-self: start; }
|
||||||
.conversation-replacement-options { display: grid; grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); gap: .65rem; }
|
.conversation-replacement-options { display: grid; grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); gap: .65rem; max-height: 18rem; overflow-y: auto; overscroll-behavior-y: contain; scrollbar-gutter: stable; }
|
||||||
.conversation-replacement-options label { display: grid; gap: .35rem; padding: .35rem; overflow: hidden; border: 1px solid var(--border); border-radius: .55rem; cursor: pointer; }
|
.conversation-replacement-options label { display: grid; gap: .35rem; padding: .35rem; overflow: hidden; border: 1px solid var(--border); border-radius: .55rem; cursor: pointer; }
|
||||||
.conversation-replacement-options label.is-selected { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
.conversation-replacement-options label.is-selected { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
||||||
.conversation-replacement-options label:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
|
.conversation-replacement-options label:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
|||||||
+1
-1
@@ -78,7 +78,7 @@ Exchange-library uploads require an explicit consent checkbox before the raw ima
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
The composer also has a separate green-screen creation panel. Its source picker uses consent-controlled exchange-library assets; its target picker is derived server-side from the triggering post image and the attachments already loaded into that conversation. Source, target, and generated preview are blurred independently. Generation returns an authenticated in-memory JPEG preview/download and never adds it to the library or sends it to Matrix, so delivery remains a separate explicit operator action.
|
The composer also has a separate green-screen creation panel. Its source picker uses consent-controlled exchange-library assets; its target picker is derived server-side from the triggering post image and the attachments already loaded into that conversation. Source, target, and generated preview are blurred independently. Source and target grids have bounded scrolling, the composer shrinks into its own scroll area on shorter viewports, and choosing a source brings the target step into view. Generation returns an authenticated in-memory JPEG preview/download and never adds it to the library or sends it to Matrix, so delivery remains a separate explicit operator action.
|
||||||
|
|
||||||
The conversation inbox assigns every row to exactly one primary workflow queue: **Needs attention**, **Waiting**, **Sellers**, **Snoozed**, **Done**, **Deleted**, or **Archived**. Removed-account history belongs only to Archived, deleted recipients belong only to Deleted, and seller/off-platform outcomes belong only to Sellers, so none can leak into actionable work. Unread state, manual ownership, and received images are independent badges and secondary toggles rather than competing queues. The default queue is Needs attention. Suspended profiles are not treated as deleted.
|
The conversation inbox assigns every row to exactly one primary workflow queue: **Needs attention**, **Waiting**, **Sellers**, **Snoozed**, **Done**, **Deleted**, or **Archived**. Removed-account history belongs only to Archived, deleted recipients belong only to Deleted, and seller/off-platform outcomes belong only to Sellers, so none can leak into actionable work. Unread state, manual ownership, and received images are independent badges and secondary toggles rather than competing queues. The default queue is Needs attention. Suspended profiles are not treated as deleted.
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import userEvent from '@testing-library/user-event';
|
|||||||
import { HttpResponse, http } from 'msw';
|
import { HttpResponse, http } from 'msw';
|
||||||
import { setupServer } from 'msw/node';
|
import { setupServer } from 'msw/node';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
import { ConversationsPage } from '../../apps/dashboard/src/features/conversations/conversations-page.js';
|
import { ConversationsPage } from '../../apps/dashboard/src/features/conversations/conversations-page.js';
|
||||||
import { resetComposerDrafts } from '../../apps/dashboard/src/features/conversations/composer-drafts.js';
|
import { resetComposerDrafts } from '../../apps/dashboard/src/features/conversations/composer-drafts.js';
|
||||||
|
|
||||||
@@ -83,6 +83,7 @@ const server = setupServer(
|
|||||||
http.post('/api/conversations/bulk-read', () =>
|
http.post('/api/conversations/bulk-read', () =>
|
||||||
HttpResponse.json({ success: true, updated: 1, failed: [] })),
|
HttpResponse.json({ success: true, updated: 1, failed: [] })),
|
||||||
);
|
);
|
||||||
|
const originalScrollIntoView = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollIntoView');
|
||||||
|
|
||||||
function installConversationList() {
|
function installConversationList() {
|
||||||
server.use(
|
server.use(
|
||||||
@@ -110,6 +111,8 @@ afterEach(() => {
|
|||||||
cleanup();
|
cleanup();
|
||||||
server.resetHandlers();
|
server.resetHandlers();
|
||||||
resetComposerDrafts();
|
resetComposerDrafts();
|
||||||
|
if (originalScrollIntoView) Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', originalScrollIntoView);
|
||||||
|
else delete HTMLElement.prototype.scrollIntoView;
|
||||||
});
|
});
|
||||||
afterAll(() => server.close());
|
afterAll(() => server.close());
|
||||||
|
|
||||||
@@ -495,6 +498,11 @@ describe('ConversationsPage', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
const scrollIntoView = vi.fn();
|
||||||
|
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
||||||
|
configurable: true,
|
||||||
|
value: scrollIntoView,
|
||||||
|
});
|
||||||
renderConversations();
|
renderConversations();
|
||||||
|
|
||||||
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
|
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
|
||||||
@@ -502,6 +510,7 @@ describe('ConversationsPage', () => {
|
|||||||
expect(await screen.findByRole('img', { name: 'Green-screen source green-phone.jpg' })).toHaveClass('is-blurred');
|
expect(await screen.findByRole('img', { name: 'Green-screen source green-phone.jpg' })).toHaveClass('is-blurred');
|
||||||
await user.click(screen.getByRole('button', { name: 'Reveal source images' }));
|
await user.click(screen.getByRole('button', { name: 'Reveal source images' }));
|
||||||
await user.click(screen.getByRole('radio', { name: /green-phone\.jpg/ }));
|
await user.click(screen.getByRole('radio', { name: /green-phone\.jpg/ }));
|
||||||
|
await waitFor(() => expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'nearest' }));
|
||||||
await user.click(screen.getByRole('button', { name: 'Reveal conversation choices' }));
|
await user.click(screen.getByRole('button', { name: 'Reveal conversation choices' }));
|
||||||
await user.click(screen.getByRole('radio', { name: 'Original post image' }));
|
await user.click(screen.getByRole('radio', { name: 'Original post image' }));
|
||||||
await user.click(screen.getByRole('button', { name: 'Create image' }));
|
await user.click(screen.getByRole('button', { name: 'Create image' }));
|
||||||
|
|||||||
Reference in new issue
Block a user