Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## 0.1.17

- Preserve sanitized formatting, links, tables, remote image references, and matching inline images
when quoting an HTML message in a reply.
- Collapse quoted history behind an ellipsis in the HQBase reader for both HTML and plain-text
messages.
- Show the first and final two messages in longer conversations and place intervening messages
behind a counted divider.

## 0.1.16

- Include the selected message as quoted HTML and plain text in replies so email clients can
Expand Down
168 changes: 110 additions & 58 deletions app/features/messages/conversation-messages.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { Download } from "lucide-react";
import type * as React from "react";
import { Download, MessagesSquare } from "lucide-react";
import * as React from "react";

import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/cn";
import { formatDateTime } from "@/lib/format";
import { MessageHtml } from "./message-html";
import { MessageHtml, PlainTextMessage } from "./message-html";
import type { MessageDetail } from "./types";

export function ConversationMessages({
Expand All @@ -15,64 +16,115 @@ export function ConversationMessages({
compact?: boolean;
messages: MessageDetail[];
}): React.ReactElement {
const hiddenCount = Math.max(0, messages.length - 3);
const threadFingerprint = messages.map((message) => message.id).join(":");
const [expandedThread, setExpandedThread] = React.useState<string | null>(null);
const showMiddle = expandedThread === threadFingerprint;

if (hiddenCount === 0) {
return <div className="divide-y divide-border">{messages.map(renderMessage)}</div>;
}

const first = messages[0];
const middle = messages.slice(1, -2);
const final = messages.slice(-2);
return (
<div className="divide-y divide-border">
{messages.map((message) => {
const timestamp = message.receivedAt ?? message.sentAt ?? message.createdAt;
return (
<article
className={cn("px-4 py-5 sm:px-6 sm:py-6", compact && "px-4 py-4 sm:px-4 sm:py-4")}
key={message.id}
>
<header className="mb-5 flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<div className="break-words text-sm font-medium">{message.fromAddress}</div>
<div className="mt-1 break-words text-xs text-muted-foreground">
to {message.to.join(", ")}
{message.cc.length > 0 ? ` · cc ${message.cc.join(", ")}` : ""}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{message.direction === "outbound" ? (
<Badge className="h-5 px-1.5 text-[10px]" variant="outline">
Sent
</Badge>
) : null}
<time className="font-mono text-[10px] text-muted-foreground">
{formatDateTime(timestamp)}
</time>
</div>
</header>
<div className="max-w-3xl">
{message.htmlAvailable ? (
<MessageHtml message={message} />
) : (
<pre className="whitespace-pre-wrap break-words font-sans text-sm leading-7 text-foreground/90">
{message.textBody || message.snippet}
</pre>
)}
{message.attachments.length > 0 ? (
<>
<Separator className="my-6" />
<div className="flex flex-col gap-2">
<div className="text-xs font-medium text-muted-foreground">Attachments</div>
{message.attachments.map((attachment) => (
<a
className="flex w-fit max-w-full items-center gap-2 rounded-md border bg-card px-3 py-2 text-xs hover:bg-muted"
href={`/api/attachments/${attachment.id}`}
key={attachment.id}
>
<Download className="size-3.5" />
<span className="truncate">{attachment.filename}</span>
</a>
))}
</div>
</>
) : null}
{first ? renderMessage(first) : null}
<ThreadMessagesDivider
count={hiddenCount}
expanded={showMiddle}
onToggle={() =>
setExpandedThread((current) => (current === threadFingerprint ? null : threadFingerprint))
}
/>
{showMiddle ? middle.map(renderMessage) : null}
{final.map(renderMessage)}
</div>
);

function renderMessage(message: MessageDetail): React.ReactElement {
const timestamp = message.receivedAt ?? message.sentAt ?? message.createdAt;
return (
<article
className={cn("px-4 py-5 sm:px-6 sm:py-6", compact && "px-4 py-4 sm:px-4 sm:py-4")}
data-thread-message-id={message.id}
key={message.id}
>
<header className="mb-5 flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<div className="break-words text-sm font-medium">{message.fromAddress}</div>
<div className="mt-1 break-words text-xs text-muted-foreground">
to {message.to.join(", ")}
{message.cc.length > 0 ? ` · cc ${message.cc.join(", ")}` : ""}
</div>
</article>
);
})}
</div>
<div className="flex shrink-0 items-center gap-2">
{message.direction === "outbound" ? (
<Badge className="h-5 px-1.5 text-[10px]" variant="outline">
Sent
</Badge>
) : null}
<time className="font-mono text-[10px] text-muted-foreground">
{formatDateTime(timestamp)}
</time>
</div>
</header>
<div className="max-w-3xl">
{message.htmlAvailable ? (
<MessageHtml message={message} />
) : (
<PlainTextMessage message={message} />
)}
{message.attachments.length > 0 ? (
<>
<Separator className="my-6" />
<div className="flex flex-col gap-2">
<div className="text-xs font-medium text-muted-foreground">Attachments</div>
{message.attachments.map((attachment) => (
<a
className="flex w-fit max-w-full items-center gap-2 rounded-md border bg-card px-3 py-2 text-xs hover:bg-muted"
href={`/api/attachments/${attachment.id}`}
key={attachment.id}
>
<Download className="size-3.5" />
<span className="truncate">{attachment.filename}</span>
</a>
))}
</div>
</>
) : null}
</div>
</article>
);
}
}

function ThreadMessagesDivider({
count,
expanded,
onToggle
}: {
count: number;
expanded: boolean;
onToggle: () => void;
}): React.ReactElement {
const noun = count === 1 ? "message" : "messages";
return (
<div className="flex items-center gap-3 px-4 py-3 sm:px-6" data-thread-messages-control>
<Separator className="flex-1" />
<Button
aria-expanded={expanded}
className="shrink-0 rounded-full"
onClick={onToggle}
size="sm"
type="button"
variant="outline"
>
<MessagesSquare data-icon />
{expanded ? `Hide ${count} ${noun}` : `${count} earlier ${noun}`}
</Button>
<Separator className="flex-1" />
</div>
);
}
96 changes: 91 additions & 5 deletions app/features/messages/message-html.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as React from "react";

import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";

import { getMessageHtml, trustRemoteMediaSender } from "./api";
import { buildEmailHtmlDocument } from "./html-document";
Expand All @@ -19,12 +20,15 @@ export function MessageHtml({ message }: MessageHtmlProps): React.ReactElement {
const [error, setError] = React.useState<string | null>(null);
const [savingTrust, setSavingTrust] = React.useState(false);
const [frameHeight, setFrameHeight] = React.useState(320);
const [quoteExpanded, setQuoteExpanded] = React.useState(false);
const [quoteFrameHeight, setQuoteFrameHeight] = React.useState(240);

React.useEffect(() => {
let active = true;
setHtml(null);
setError(null);
setLoadRemoteImages(false);
setQuoteExpanded(false);
void getMessageHtml(message.id)
.then((result) => {
if (!active) return;
Expand All @@ -50,6 +54,17 @@ export function MessageHtml({ message }: MessageHtmlProps): React.ReactElement {
}),
[html, loadRemoteImages]
);
const renderedQuote = React.useMemo(
() =>
html?.quotedHtml
? buildEmailHtmlDocument({
allowRemoteImages: loadRemoteImages,
html: html.quotedHtml,
origin: window.location.origin
})
: null,
[html, loadRemoteImages]
);

async function loadImages(): Promise<void> {
setLoadingImages(true);
Expand Down Expand Up @@ -82,7 +97,7 @@ export function MessageHtml({ message }: MessageHtmlProps): React.ReactElement {
if (!rendered) {
return (
<>
<PlainText message={message} />
<PlainTextMessage message={message} />
{error && <p className="mt-3 text-xs text-destructive">{error}</p>}
</>
);
Expand Down Expand Up @@ -112,6 +127,53 @@ export function MessageHtml({ message }: MessageHtmlProps): React.ReactElement {
srcDoc={rendered}
title={`Message body: ${message.subject}`}
/>
{renderedQuote ? (
<>
<QuotedContentDivider
expanded={quoteExpanded}
onToggle={() => setQuoteExpanded((expanded) => !expanded)}
/>
{quoteExpanded ? (
<iframe
className="w-full rounded-md border bg-white"
height={quoteFrameHeight}
onLoad={(event) => {
const height = event.currentTarget.contentDocument?.documentElement.scrollHeight;
if (height) setQuoteFrameHeight(Math.max(240, height));
}}
sandbox="allow-popups allow-popups-to-escape-sandbox allow-same-origin"
srcDoc={renderedQuote}
title={`Quoted message history: ${message.subject}`}
/>
) : null}
</>
) : null}
</div>
);
}

export function QuotedContentDivider({
expanded,
onToggle
}: {
expanded: boolean;
onToggle: () => void;
}): React.ReactElement {
return (
<div className="flex items-center gap-2" data-quoted-content-control>
<Separator className="flex-1" />
<Button
aria-expanded={expanded}
aria-label={expanded ? "Hide quoted message history" : "Show quoted message history"}
className="h-6 min-w-10 rounded-full px-2 font-mono tracking-wider"
onClick={onToggle}
size="sm"
type="button"
variant="outline"
>
...
</Button>
<Separator className="flex-1" />
</div>
);
}
Expand Down Expand Up @@ -159,10 +221,34 @@ export function RemoteImagesAlert({
);
}

function PlainText({ message }: MessageHtmlProps): React.ReactElement {
export function PlainTextMessage({ message }: MessageHtmlProps): React.ReactElement {
const [expanded, setExpanded] = React.useState(false);
const content = splitQuotedText(message.textBody || message.snippet);
return (
<pre className="whitespace-pre-wrap break-words font-sans text-sm leading-7 text-foreground/90">
{message.textBody || message.snippet}
</pre>
<div className="flex flex-col gap-4">
<pre className="whitespace-pre-wrap break-words font-sans text-sm leading-7 text-foreground/90">
{content.body}
</pre>
{content.quote ? (
<>
<QuotedContentDivider expanded={expanded} onToggle={() => setExpanded((open) => !open)} />
{expanded ? (
<pre className="whitespace-pre-wrap break-words font-sans text-sm leading-7 text-muted-foreground">
{content.quote}
</pre>
) : null}
</>
) : null}
</div>
);
}

export function splitQuotedText(value: string): { body: string; quote: string | null } {
const match = /\n{1,2}On [^\n]+ wrote:\n>/i.exec(value.replace(/\r\n?/g, "\n"));
if (!match) return { body: value, quote: null };
const normalized = value.replace(/\r\n?/g, "\n");
return {
body: normalized.slice(0, match.index).trimEnd(),
quote: normalized.slice(match.index).trim()
};
}
1 change: 1 addition & 0 deletions app/features/messages/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,6 @@ export type MessageDetail = MessageSummary & {
export type MessageHtml = {
hasRemoteImages: boolean;
html: string;
quotedHtml: string | null;
remoteMediaTrusted: boolean;
};
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hqbase",
"version": "0.1.16",
"version": "0.1.17",
"private": true,
"type": "module",
"packageManager": "pnpm@11.7.0",
Expand Down
20 changes: 20 additions & 0 deletions test/unit/app/messages/conversation-reader.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";

import { InboxPage } from "@/features/inbox/inbox-page";
import { ConversationMessages } from "@/features/messages/conversation-messages";
import { MessageDetail } from "@/features/messages/message-detail";
import { MessageListItem } from "@/features/messages/message-list-item";
import type { MessageDetail as MessageDetailType, MessageSummary } from "@/features/messages/types";
Expand Down Expand Up @@ -125,4 +126,23 @@ describe("conversation reader", () => {
expect(unreadHtml).toContain('aria-label="Unread"');
expect(readHtml).not.toContain('aria-label="Unread"');
});

it("collapses messages between the first and final two behind a counted divider", () => {
const messages = Array.from({ length: 6 }, (_, index) => ({
...firstMessage,
id: `msg_${index + 1}`,
fromAddress: `sender-${index + 1}@example.com`,
textBody: `Message body ${index + 1}`
}));
const html = renderToStaticMarkup(<ConversationMessages messages={messages} />);

expect(html).toContain("Message body 1");
expect(html).not.toContain("Message body 2");
expect(html).not.toContain("Message body 3");
expect(html).not.toContain("Message body 4");
expect(html).toContain("Message body 5");
expect(html).toContain("Message body 6");
expect(html).toContain("3 earlier messages");
expect(html).toContain('aria-expanded="false"');
});
});
Loading