fix: keep conversation history visible

This commit is contained in:
jjbrunton committed 2026-08-14 15:00:45 +01:00
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 {
useConversationImageReplacementOptionsQuery,
useGenerateConversationImageReplacementMutation,
@@ -11,6 +11,7 @@ export function ConversationImageReplacement({ conversationId }: { conversationI
const [isResultRevealed, setIsResultRevealed] = useState(false);
const [sourceAssetId, setSourceAssetId] = useState<string | null>(null);
const [targetId, setTargetId] = useState<string | null>(null);
const targetFieldsetRef = useRef<HTMLFieldSetElement>(null);
const optionsQuery = useConversationImageReplacementOptionsQuery(conversationId, isOpen);
const generation = useGenerateConversationImageReplacementMutation(conversationId);
const options = optionsQuery.data;
@@ -53,6 +54,9 @@ export function ConversationImageReplacement({ conversationId }: { conversationI
onChange={() => {
setSourceAssetId(source.id);
clearResult();
window.requestAnimationFrame(() => {
targetFieldsetRef.current?.scrollIntoView?.({ behavior: 'smooth', block: 'nearest' });
});
}}
type="radio"
/>
@@ -68,7 +72,7 @@ export function ConversationImageReplacement({ conversationId }: { conversationI
</fieldset>
) : null}
{options?.targets.length ? (
<fieldset>
<fieldset ref={targetFieldsetRef}>
<legend>2. Image to place on the green screen</legend>
<button onClick={() => setAreTargetsRevealed((current) => !current)} type="button">
{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:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.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 h3 { margin: 0; }
.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 legend { padding: 0 .3rem; color: #e9f1ec; font-size: .8rem; font-weight: 800; }
.conversation-image-replacement fieldset > button { justify-self: start; }
.conversation-replacement-options { display: grid; grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); gap: .65rem; }
.conversation-replacement-options { 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.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; }
+1 -1
View File
@@ -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.
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.
+10 -1
View File
@@ -7,7 +7,7 @@ import userEvent from '@testing-library/user-event';
import { HttpResponse, http } from 'msw';
import { setupServer } from 'msw/node';
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 { resetComposerDrafts } from '../../apps/dashboard/src/features/conversations/composer-drafts.js';
@@ -83,6 +83,7 @@ const server = setupServer(
http.post('/api/conversations/bulk-read', () =>
HttpResponse.json({ success: true, updated: 1, failed: [] })),
);
const originalScrollIntoView = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollIntoView');
function installConversationList() {
server.use(
@@ -110,6 +111,8 @@ afterEach(() => {
cleanup();
server.resetHandlers();
resetComposerDrafts();
if (originalScrollIntoView) Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', originalScrollIntoView);
else delete HTMLElement.prototype.scrollIntoView;
});
afterAll(() => server.close());
@@ -495,6 +498,11 @@ describe('ConversationsPage', () => {
}),
);
const user = userEvent.setup();
const scrollIntoView = vi.fn();
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: scrollIntoView,
});
renderConversations();
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');
await user.click(screen.getByRole('button', { name: 'Reveal source images' }));
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('radio', { name: 'Original post image' }));
await user.click(screen.getByRole('button', { name: 'Create image' }));