feat: persist settings and enrich conversation context

This commit is contained in:
jjbrunton committed 2026-08-08 18:18:28 +01:00
1 parent a36723a325
commit 2c100d72b6
84 files changed
+2248 -644

No files matched your search

@@ -44,7 +44,10 @@ export function createConversationApi(fetchImplementation?: typeof fetch): Conve
return {
async list() {
return parseResponse(await request('/api/conversations'), ConversationsResponseSchema);
return parseResponse(
await request('/api/conversations?includeImageMetadata=true'),
ConversationsResponseSchema,
);
},
async detail(id) {
return parseResponse(await request(conversationPath(id)), ConversationDetailResponseSchema);
@@ -1,5 +1,7 @@
import { useCallback, useRef, useState } from 'react';
import type { ConversationDetailResponse } 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 { SendConfirmationDialog } from './send-confirmation-dialog.js';
@@ -12,6 +14,10 @@ function formatMessageTime(timestamp: number): string {
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(timestamp);
}
function mediaPath(localPath: string): string {
return `/media/${localPath.split('/').map(encodeURIComponent).join('/')}`;
}
function MessageHistory({ conversation }: { conversation: ConversationDetailResponse }) {
if (!conversation.messages || conversation.messages.length === 0) {
return <div className="conversation-empty-detail">No messages have been loaded for this conversation.</div>;
@@ -22,10 +28,22 @@ function MessageHistory({ conversation }: { conversation: ConversationDetailResp
{conversation.messages.map((message) => (
<li className={message.isSelf ? 'is-self' : 'is-recipient'} key={`${message.timestamp}-${message.sender}-${message.text}`}>
<div>
<strong>{message.isSelf ? 'You' : `u/${conversation.username}`}</strong>
<strong>{message.isSelf ? 'You' : <RedditUsernameLink username={conversation.username} />}</strong>
<time dateTime={new Date(message.timestamp).toISOString()}>{formatMessageTime(message.timestamp)}</time>
</div>
<p>{message.text}</p>
{message.media?.length ? (
<div className="conversation-message-images">
{message.media.map((media) => (
<img
alt={`Image ${message.isSelf ? 'sent by you' : `received from ${redditUsernameLabel(conversation.username)}`}`}
key={`${message.timestamp}-${media.localPath}`}
loading="lazy"
src={mediaPath(media.localPath)}
/>
))}
</div>
) : null}
</li>
))}
</ol>
@@ -79,7 +97,7 @@ function ConversationComposer({ conversation }: { conversation: ConversationDeta
{mediaAskMutation.isPending ? 'Generating media ask…' : 'Suggest media ask'}
</button>
</div>
<label className="sr-only" htmlFor="conversation-reply">Reply to u/{conversation.username}</label>
<label className="sr-only" htmlFor="conversation-reply">Reply to {redditUsernameLabel(conversation.username)}</label>
<textarea
id="conversation-reply"
onChange={(event) => {
@@ -94,7 +112,7 @@ function ConversationComposer({ conversation }: { conversation: ConversationDeta
{mediaAskMutation.isError ? <div className="conversation-inline-error" role="alert">{mediaAskMutation.error.message}</div> : null}
{sendMutation.isError ? <div className="conversation-inline-error" role="alert">{sendMutation.error.message}</div> : null}
{wasSent ? (
<div aria-label="Reply sent" className="conversation-inline-success" role="status">Reply sent to u/{conversation.username}</div>
<div aria-label="Reply sent" className="conversation-inline-success" role="status">Reply sent to <RedditUsernameLink username={conversation.username} /></div>
) : null}
<div className="conversation-actions">
<span>{draft.length} characters</span>
@@ -143,7 +161,7 @@ export function ConversationDetail({ conversationId, onClose }: ConversationDeta
<header>
<div>
<span className="conversation-eyebrow">Conversation</span>
<h2>u/{conversation.username}</h2>
<h2><RedditUsernameLink username={conversation.username} /></h2>
{conversation.unread ? <span className="conversation-badge">Unread</span> : null}
</div>
<button aria-label="Close conversation" onClick={onClose} type="button">Close</button>
@@ -154,16 +172,14 @@ export function ConversationDetail({ conversationId, onClose }: ConversationDeta
</div>
) : null}
{conversation.triggeringPost ? (
<a
aria-label={conversation.triggeringPost.title ?? `Open triggering post ${conversation.triggeringPost.id}`}
className="triggering-post"
href={conversation.triggeringPost.url}
rel="noreferrer"
target="_blank"
>
<span>Started from {conversation.triggeringPost.subreddit ? `r/${conversation.triggeringPost.subreddit}` : 'Reddit'}</span>
<strong>{conversation.triggeringPost.title ?? conversation.triggeringPost.id}</strong>
</a>
<div className="triggering-post">
<OriginPostCard post={{
title: conversation.triggeringPost.title ?? conversation.triggeringPost.id,
url: conversation.triggeringPost.url,
imageUrl: conversation.triggeringPost.imageUrl,
subreddit: conversation.triggeringPost.subreddit,
}} />
</div>
) : null}
<MessageHistory conversation={conversation} />
<ConversationComposer conversation={conversation} />
@@ -8,7 +8,12 @@ export function ConversationRoute() {
const filterValue = searchParams.get('filter');
const filters: ConversationFilters = {
search: searchParams.get('q') ?? '',
filter: filterValue === 'unread' || filterValue === 'awaiting-reply' ? filterValue : 'all',
filter:
filterValue === 'unread'
|| filterValue === 'awaiting-reply'
|| filterValue === 'received-images'
? filterValue
: 'all',
};
function selectConversation(nextConversationId: string | null) {
@@ -1,12 +1,13 @@
import { useMemo, useState } from 'react';
import type { ActiveChat } from '../../../../../packages/api-contracts/src/index.js';
import { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit-username-link.js';
import { ConversationDetail } from './conversation-detail.js';
import { useConversationsQuery } from './conversation-queries.js';
import { StartConversationForm } from './start-conversation-form.js';
import { ProcessedUserLookup } from './processed-user-lookup.js';
import './conversations.css';
export type ConversationFilter = 'all' | 'unread' | 'awaiting-reply';
export type ConversationFilter = 'all' | 'unread' | 'awaiting-reply' | 'received-images';
export interface ConversationFilters {
search: string;
@@ -27,6 +28,7 @@ function conversationId(conversation: ActiveChat): string {
function matchesFilter(conversation: ActiveChat, filter: ConversationFilter): boolean {
if (filter === 'unread') return conversation.unread;
if (filter === 'awaiting-reply') return conversation.isFromUs;
if (filter === 'received-images') return conversation.hasReceivedImages === true;
return true;
}
@@ -37,17 +39,17 @@ function ConversationListItem({ conversation, isSelected, onSelect }: {
}) {
const state = conversation.unread ? 'Unread' : conversation.isFromUs ? 'Awaiting reply' : 'Up to date';
return (
<li>
<li className={isSelected ? 'conversation-list-item is-selected' : 'conversation-list-item'}>
<button
aria-label={`u/${conversation.username}, ${state}`}
aria-label={`${redditUsernameLabel(conversation.username)}, ${state}${conversation.hasReceivedImages ? ', Image received' : ''}`}
aria-pressed={isSelected}
className={isSelected ? 'conversation-list-item is-selected' : 'conversation-list-item'}
className="conversation-list-target"
onClick={onSelect}
type="button"
>
<span className="conversation-list-heading"><strong>u/{conversation.username}</strong><small>{state}</small></span>
<span>{conversation.lastMessageSnippet || 'No message preview'}</span>
</button>
/>
<span className="conversation-list-heading"><strong><RedditUsernameLink username={conversation.username} /></strong><small>{state}</small></span>
{conversation.hasReceivedImages ? <span className="conversation-image-marker">Image received</span> : null}
<span>{conversation.lastMessageSnippet || 'No message preview'}</span>
</li>
);
}
@@ -121,6 +123,7 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
<option value="all">All</option>
<option value="unread">Unread</option>
<option value="awaiting-reply">Awaiting reply</option>
<option value="received-images">Received images</option>
</select>
</div>
{conversationsQuery.isPending ? (
@@ -33,12 +33,17 @@
.conversation-master, .conversation-detail-panel { min-width: 0; background: var(--surface); border: 1px solid var(--border); border-radius: .9rem; overflow: hidden; }
.conversation-toolbar { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .5rem; padding: .75rem; border-bottom: 1px solid var(--border); }
.conversation-list { display: grid; gap: .35rem; max-height: 46rem; padding: .5rem; margin: 0; overflow: auto; list-style: none; }
.conversation-list-item { display: grid; gap: .4rem; width: 100%; padding: .8rem !important; text-align: left; background: transparent !important; border-color: transparent !important; }
.conversation-list-item:hover { background: var(--surface-raised) !important; }
.conversation-list-item.is-selected { background: var(--accent-dark) !important; border-color: #28593d !important; }
.conversation-list-item { position: relative; display: grid; gap: .4rem; width: 100%; padding: .8rem; text-align: left; background: transparent; border: 1px solid transparent; border-radius: .6rem; }
.conversation-list-item:hover { background: var(--surface-raised); }
.conversation-list-item.is-selected { background: var(--accent-dark); border-color: #28593d; }
.conversation-list-target { position: absolute; inset: 0; z-index: 0; min-height: 0 !important; padding: 0 !important; background: transparent !important; border: 0 !important; }
.conversation-list-target:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.conversation-list-item > span { position: relative; z-index: 1; pointer-events: none; }
.conversation-list-item .reddit-username-link { pointer-events: auto; }
.conversation-list-item > span:last-child { overflow: hidden; color: var(--muted); font-size: .82rem; text-overflow: ellipsis; white-space: nowrap; }
.conversation-list-heading { display: flex; align-items: center; justify-content: space-between; gap: .5rem; }
.conversation-list-heading small { color: var(--accent); }
.conversation-image-marker { justify-self: start; padding: .18rem .45rem; color: #d8e8ff !important; background: #18324d; border: 1px solid #315d85; border-radius: 999px; font-size: .68rem !important; font-weight: 750; }
.conversation-list-state, .conversation-detail-state { display: grid; place-items: center; align-content: center; gap: .5rem; min-height: 16rem; padding: 1.5rem; color: var(--muted); text-align: center; }
.conversation-list-state strong, .conversation-detail-state strong { color: #e9f1ec; }
.conversation-detail { min-height: 32rem; }
@@ -46,8 +51,7 @@
.conversation-detail h2 { display: inline; margin-right: .6rem; }
.conversation-badge { display: inline-flex; padding: .2rem .45rem; color: #aaf0c4; background: #163a26; border-radius: 999px; font-size: .7rem; font-weight: 800; }
.conversation-sync-notice { padding: .75rem 1rem; color: #ffe1a0; background: #2a2111; border-bottom: 1px solid #59451e; }
.triggering-post { display: grid; gap: .2rem; margin: 1rem; padding: .75rem; color: inherit; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .65rem; text-decoration: none; }
.triggering-post span { color: var(--muted); font-size: .75rem; }
.triggering-post { margin: 1rem; padding: .75rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .65rem; }
.conversation-messages { display: grid; gap: .75rem; max-height: 30rem; padding: 1rem; margin: 0; overflow: auto; list-style: none; }
.conversation-messages li { max-width: 85%; padding: .7rem .8rem; border-radius: .8rem; }
.conversation-messages li.is-self { justify-self: end; background: var(--accent-dark); }
@@ -55,6 +59,8 @@
.conversation-messages li > div { display: flex; justify-content: space-between; gap: 1rem; margin-bottom: .35rem; }
.conversation-messages time { color: var(--muted); font-size: .7rem; }
.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 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-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-section-heading, .conversation-actions { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit-username-link.js';
import { useProcessedPostsQuery } from './conversation-queries.js';
export function ProcessedUserLookup() {
@@ -17,8 +18,8 @@ export function ProcessedUserLookup() {
{postsQuery.isPending && username ? <p role="status">Looking up processed posts…</p> : null}
{postsQuery.isError ? <p role="alert">{postsQuery.error.message}</p> : null}
{postsQuery.data ? (
postsQuery.data.length === 0 ? <p>No processed posts found for u/{username}.</p> : (
<ol aria-label={`Processed posts for u/${username}`}>
postsQuery.data.length === 0 ? <p>No processed posts found for <RedditUsernameLink username={username} />.</p> : (
<ol aria-label={`Processed posts for ${redditUsernameLabel(username)}`}>
{postsQuery.data.map((post) => <li key={post.id}><strong>{post.title}</strong><span>r/{post.subreddit} · {post.messageSent ? 'Message sent' : post.error ?? 'Processed'}</span></li>)}
</ol>
)
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
import { useStartConversationMutation } from './conversation-queries.js';
interface StartConversationFormProps {
@@ -53,7 +54,7 @@ export function StartConversationForm({ onStarted }: StartConversationFormProps)
{mutation.isError ? <div className="conversation-inline-error" role="alert">{mutation.error.message}</div> : null}
{successUsername ? (
<div aria-label="Conversation started" className="conversation-inline-success" role="status">
Conversation started with u/{successUsername}
Conversation started with <RedditUsernameLink username={successUsername} />
</div>
) : null}
<button className="conversation-primary" disabled={!canSubmit} type="submit">