Blur conversation images by default

This commit is contained in:
jjbrunton committed 2026-08-09 07:55:51 +01:00
1 parent 5c01d7d9aa
commit e7c29f91f2
5 files changed
+59 -30

No files matched your search

+13 -4
View File
@@ -8,15 +8,24 @@ export interface OriginPost {
} }
export function OriginPostCard({ post }: { post: OriginPost }) { export function OriginPostCard({ post }: { post: OriginPost }) {
const [isImageRevealed, setIsImageRevealed] = useState(false);
return ( return (
<a aria-label={post.title} className="origin-post-card" href={post.url} rel="noreferrer" target="_blank"> <div className="origin-post-card">
{post.imageUrl ? <img alt={`Source post: ${post.title}`} loading="lazy" src={post.imageUrl} /> : null} {post.imageUrl ? (
<span> <div className="origin-post-image">
<img alt={`Source post: ${post.title}`} className={isImageRevealed ? undefined : 'is-blurred'} loading="lazy" src={post.imageUrl} />
<button aria-pressed={isImageRevealed} onClick={() => setIsImageRevealed((current) => !current)} type="button">
{isImageRevealed ? 'Blur original image' : 'Reveal original image'}
</button>
</div>
) : null}
<a aria-label={post.title} className="origin-post-copy" href={post.url} rel="noreferrer" target="_blank">
<small>Started from {post.subreddit ? `r/${post.subreddit}` : 'Reddit'}</small> <small>Started from {post.subreddit ? `r/${post.subreddit}` : 'Reddit'}</small>
<strong>{post.title}</strong> <strong>{post.title}</strong>
<span>Open original post</span> <span>Open original post</span>
</span>
</a> </a>
</div>
); );
} }
@@ -20,6 +20,8 @@ function mediaPath(localPath: string): string {
} }
function MessageHistory({ conversation }: { conversation: ConversationDetailResponse }) { function MessageHistory({ conversation }: { conversation: ConversationDetailResponse }) {
const [revealedMedia, setRevealedMedia] = useState<Set<string>>(() => new Set());
if (!conversation.messages || conversation.messages.length === 0) { if (!conversation.messages || conversation.messages.length === 0) {
return <div className="conversation-empty-detail">No messages have been loaded for this conversation.</div>; return <div className="conversation-empty-detail">No messages have been loaded for this conversation.</div>;
} }
@@ -38,21 +40,35 @@ function MessageHistory({ conversation }: { conversation: ConversationDetailResp
<p>{message.text}</p> <p>{message.text}</p>
{message.media?.length ? ( {message.media?.length ? (
<div className="conversation-message-images"> <div className="conversation-message-images">
{message.media.map((media) => ( {message.media.map((media) => {
<a const mediaKey = `${message.timestamp}-${media.localPath}`;
aria-label={`View original image ${message.isSelf ? 'sent by you' : `received from ${redditUsernameLabel(conversation.username)}`}`} const sender = message.isSelf ? 'sent by you' : `received from ${redditUsernameLabel(conversation.username)}`;
href={mediaPath(media.localPath)} const isRevealed = revealedMedia.has(mediaKey);
key={`${message.timestamp}-${media.localPath}`}
rel="noreferrer" return (
target="_blank" <div className="conversation-message-image" key={mediaKey}>
>
<img <img
alt={`Image ${message.isSelf ? 'sent by you' : `received from ${redditUsernameLabel(conversation.username)}`}`} alt={`Image ${sender}`}
className={isRevealed ? undefined : 'is-blurred'}
loading="lazy" loading="lazy"
src={mediaPath(media.localPath)} src={mediaPath(media.localPath)}
/> />
</a> <button
))} aria-pressed={isRevealed}
className="conversation-image-reveal"
onClick={() => setRevealedMedia((current) => {
const next = new Set(current);
if (next.has(mediaKey)) next.delete(mediaKey);
else next.add(mediaKey);
return next;
})}
type="button"
>
{isRevealed ? 'Blur conversation image' : 'Reveal conversation image'}
</button>
</div>
);
})}
</div> </div>
) : null} ) : null}
</li> </li>
@@ -62,9 +62,11 @@
.conversation-messages time { color: var(--muted); font-size: .7rem; } .conversation-messages time { color: var(--muted); font-size: .7rem; }
.conversation-messages p { margin: 0; line-height: 1.45; white-space: pre-wrap; } .conversation-messages p { margin: 0; line-height: 1.45; white-space: pre-wrap; }
.conversation-message-images { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(12rem, 100%), 1fr)); gap: .5rem; margin-top: .65rem; } .conversation-message-images { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(12rem, 100%), 1fr)); gap: .5rem; margin-top: .65rem; }
.conversation-message-images a { display: block; border-radius: .6rem; } .conversation-message-image { position: relative; overflow: hidden; border-radius: .6rem; }
.conversation-message-images a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } .conversation-message-images img { display: block; width: 100%; max-width: 28rem; max-height: 32rem; object-fit: contain; background: #08100c; border: 1px solid var(--border); border-radius: .6rem; transition: filter 160ms ease, transform 160ms ease; }
.conversation-message-images img { display: block; width: 100%; max-width: 28rem; max-height: 32rem; object-fit: contain; background: #08100c; border: 1px solid var(--border); border-radius: .6rem; } .conversation-message-images img.is-blurred { filter: blur(24px) brightness(.55); transform: scale(1.12); }
.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-empty-detail { padding: 2rem; color: var(--muted); text-align: center; }
.conversation-composer { display: grid; gap: .65rem; padding: 1rem; border-top: 1px solid var(--border); } .conversation-composer { display: grid; gap: .65rem; padding: 1rem; 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; }
+8 -4
View File
@@ -99,10 +99,14 @@ h2 { margin-bottom: 0; font-size: 1.1rem; }
.origin-post-dialog-copy h2 { margin: 0; font-size: 1.25rem; } .origin-post-dialog-copy h2 { margin: 0; font-size: 1.25rem; }
.origin-post-dialog-copy small { color: var(--muted); } .origin-post-dialog-copy small { color: var(--muted); }
.origin-post-dialog-copy a { justify-self: start; text-decoration: none; } .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; color: inherit; text-decoration: none; } .origin-post-card { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: .8rem; align-items: center; }
.origin-post-card > img { width: 5rem; height: 5rem; object-fit: cover; background: #050907; border-radius: .45rem; } .origin-post-image { position: relative; width: 5rem; height: 5rem; overflow: hidden; background: #050907; border-radius: .45rem; }
.origin-post-card > span { display: grid; gap: .2rem; min-width: 0; } .origin-post-image img { width: 100%; height: 100%; object-fit: cover; transition: filter 160ms ease, transform 160ms ease; }
.origin-post-card small, .origin-post-card span span { color: var(--muted); font-size: .75rem; } .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-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; } .origin-post-card strong { overflow-wrap: anywhere; }
.activity-marker { flex: 0 0 auto; width: .55rem; height: .55rem; margin-top: .3rem; border-radius: 50%; background: var(--muted); } .activity-marker { flex: 0 0 auto; width: .55rem; height: .55rem; margin-top: .3rem; border-radius: 50%; background: var(--muted); }
.activity-marker.sent, .activity-marker.response { background: var(--accent); } .activity-marker.sent, .activity-marker.response { background: var(--accent); }
+6 -8
View File
@@ -142,6 +142,9 @@ describe('ConversationsPage', () => {
'src', 'src',
'https://i.redd.it/source-post.jpg', 'https://i.redd.it/source-post.jpg',
); );
expect(screen.getByRole('img', { name: 'Source post: Looking for a chat' })).toHaveClass('is-blurred');
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');
}); });
it('orders conversations by latest message activity', async () => { it('orders conversations by latest message activity', async () => {
@@ -215,14 +218,9 @@ describe('ConversationsPage', () => {
'src', 'src',
'/media/received%20image.jpg', '/media/received%20image.jpg',
); );
expect(screen.getByRole('link', { name: 'View original image received from u/alice' })).toHaveAttribute( expect(screen.getByRole('img', { name: 'Image received from u/alice' })).toHaveClass('is-blurred');
'href', await user.click(screen.getByRole('button', { name: 'Reveal conversation image' }));
'/media/received%20image.jpg', expect(screen.getByRole('img', { name: 'Image received from u/alice' })).not.toHaveClass('is-blurred');
);
expect(screen.getByRole('link', { name: 'View original image received from u/alice' })).toHaveAttribute(
'target',
'_blank',
);
}); });
it('turns a suggestion into an editable draft and confirms sending without losing failed input', async () => { it('turns a suggestion into an editable draft and confirms sending without losing failed input', async () => {