Кликабельные ссылки: история фидбека у куратора и цитаты в письмах
Куратор видел адрес в своём фидбеке мёртвым текстом — linkify там не было. В письмах (фидбек, ответы на вопросы и комментарии) quote() только экранировал: кликабельность зависела от почтовика. Теперь ссылки оформлены <a>. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -229,7 +229,7 @@ export default async function SubmissionPage({ params }: Props) {
|
|||||||
{new Date(fb.createdAt).toLocaleString("ru-RU")}
|
{new Date(fb.createdAt).toLocaleString("ru-RU")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm whitespace-pre-wrap mb-2">{fb.text}</p>
|
<p className="text-sm whitespace-pre-wrap mb-2">{linkify(fb.text)}</p>
|
||||||
{fbFiles.length > 0 && (
|
{fbFiles.length > 0 && (
|
||||||
<div className="space-y-1 mt-2">
|
<div className="space-y-1 mt-2">
|
||||||
{fbFiles.map((f) => (
|
{fbFiles.map((f) => (
|
||||||
|
|||||||
@@ -83,4 +83,20 @@ describe("sendCommentReplyEmail", () => {
|
|||||||
expect(html).not.toContain("Отвечает <strong>");
|
expect(html).not.toContain("Отвечает <strong>");
|
||||||
expect(html).toContain("Привет!");
|
expect(html).toContain("Привет!");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("делает ссылки в тексте ответа кликабельными", async () => {
|
||||||
|
await sendCommentReplyEmail(
|
||||||
|
"student@example.com",
|
||||||
|
"Екатерина",
|
||||||
|
"6.3. Холст",
|
||||||
|
"Подарок тут: https://obsidian.second-brain.ru/secret-bonus?a=1&b=2. Пишите!",
|
||||||
|
LESSON_URL
|
||||||
|
);
|
||||||
|
|
||||||
|
const html = sent[0].html;
|
||||||
|
expect(html).toContain(
|
||||||
|
'<a href="https://obsidian.second-brain.ru/secret-bonus?a=1&b=2" target="_blank"'
|
||||||
|
);
|
||||||
|
expect(html).toContain("secret-bonus?a=1&b=2</a>. Пишите!");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+15
-1
@@ -1,5 +1,6 @@
|
|||||||
import { Resend } from "resend";
|
import { Resend } from "resend";
|
||||||
import { getSetting } from "./settings";
|
import { getSetting } from "./settings";
|
||||||
|
import { URL_RE } from "./linkify";
|
||||||
|
|
||||||
function getResend() {
|
function getResend() {
|
||||||
return new Resend(process.env.RESEND_API_KEY);
|
return new Resend(process.env.RESEND_API_KEY);
|
||||||
@@ -97,11 +98,24 @@ function escapeHtml(text: string) {
|
|||||||
.replace(/"/g, """);
|
.replace(/"/g, """);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ссылки в цитате — кликабельные: не все почтовики сами превращают голый адрес в ссылку.
|
||||||
|
// Экранируем по частям, чтобы `&` в адресе не превратился в `&` внутри href дважды.
|
||||||
|
function linkifyHtml(text: string) {
|
||||||
|
return text
|
||||||
|
.split(URL_RE)
|
||||||
|
.map((part, i) =>
|
||||||
|
i % 2 === 1
|
||||||
|
? `<a href="${escapeHtml(part)}" target="_blank" style="color:#323232;text-decoration:underline;word-break:break-all;">${escapeHtml(part)}</a>`
|
||||||
|
: escapeHtml(part)
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
function quote(text: string) {
|
function quote(text: string) {
|
||||||
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:16px 0;">
|
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:16px 0;">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="border-left:3px solid #323232;padding:10px 14px;background-color:#E8F0D8;">
|
<td style="border-left:3px solid #323232;padding:10px 14px;background-color:#E8F0D8;">
|
||||||
<p style="margin:0;font-family:'Courier New',Courier,monospace;font-size:13px;line-height:1.6;color:#323232;">${escapeHtml(text).replace(/\n/g, "<br/>")}</p>
|
<p style="margin:0;font-family:'Courier New',Courier,monospace;font-size:13px;line-height:1.6;color:#323232;">${linkifyHtml(text).replace(/\n/g, "<br/>")}</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>`;
|
</table>`;
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { Fragment, type ReactNode } from "react";
|
import { Fragment, type ReactNode } from "react";
|
||||||
|
|
||||||
const URL_RE = /(https?:\/\/[^\s<>()]+[^\s<>().,;:!?])/g;
|
export const URL_RE = /(https?:\/\/[^\s<>()]+[^\s<>().,;:!?])/g;
|
||||||
|
|
||||||
export function linkify(text: string): ReactNode[] {
|
export function linkify(text: string): ReactNode[] {
|
||||||
if (!text) return [];
|
if (!text) return [];
|
||||||
|
|||||||
Reference in New Issue
Block a user