feat: persist settings and enrich conversation context
This commit is contained in:
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">
|
||||
|
||||
Reference in new issue
Block a user