Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,6 @@ yarn-error.log*
# Pre commit
/.husky
tsconfig.tsbuildinfo

# private fork-only notes
/.fork-local
22 changes: 21 additions & 1 deletion src/components/message/MessageActions.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,35 @@
import type React from "react";
import { FaExpand, FaReply, FaTrash } from "react-icons/fa";
import { FaExpand, FaLanguage, FaReply, FaTrash } from "react-icons/fa";
import type { MessageType } from "../../types";
import { MdAddReaction } from "./icons";

interface MessageActionsProps {
message: MessageType;
onReplyClick: () => void;
onReactClick: (buttonElement: Element) => void;
onTranslateClick?: () => void;
onRedactClick?: () => void;
onOpenMedia?: () => void;
canRedact?: boolean;
canReply?: boolean;
canTranslate?: boolean;
canOpenMedia?: boolean;
isTranslating?: boolean;
inline?: boolean;
}

export const MessageActions: React.FC<MessageActionsProps> = ({
message,
onReplyClick,
onReactClick,
onTranslateClick,
onRedactClick,
onOpenMedia,
canRedact = false,
canReply = !!message.msgid,
canTranslate = false,
canOpenMedia = false,
isTranslating = false,
inline = false,
}) => {
return (
Expand Down Expand Up @@ -62,6 +68,20 @@ export const MessageActions: React.FC<MessageActionsProps> = ({
<FaReply className="w-4 h-4" />
</button>
)}
{canTranslate && onTranslateClick && (
<button
type="button"
className="px-2.5 py-1.5 text-sky-300/80 hover:text-sky-200 hover:bg-white/10 transition-colors first:rounded-l-lg last:rounded-r-lg disabled:opacity-60 disabled:cursor-wait"
onClick={onTranslateClick}
title={isTranslating ? "Translating" : "Translate message"}
aria-label={
isTranslating ? "Translating message" : "Translate message"
}
disabled={isTranslating}
>
<FaLanguage className="w-4 h-4" />
</button>
)}
<button
type="button"
className="px-2.5 py-1.5 text-discord-text-muted hover:text-discord-text-normal hover:bg-white/10 transition-colors first:rounded-l-lg last:rounded-r-lg"
Expand Down
208 changes: 197 additions & 11 deletions src/components/message/MessageItem.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,22 @@
import type * as React from "react";
import { memo, useCallback, useMemo, useRef, useState } from "react";
import {
memo,
startTransition,
useCallback,
useMemo,
useRef,
useState,
} from "react";
import { useLongPress } from "../../hooks/useLongPress";
import { useMediaQuery } from "../../hooks/useMediaQuery";
import {
canUseBrowserTranslation,
detectMessageSourceLanguage,
getBrowserTranslationAvailability,
getMessageSourceLanguage,
getPreferredTranslationTargetLanguageFromSetting,
translateWithBrowser,
} from "../../lib/browserTranslation";
import ircClient from "../../lib/ircClient";
import {
isUrlFromFilehost,
Expand Down Expand Up @@ -127,6 +142,13 @@ export function partitionMediaEntries(entries: MediaEntry[]) {
return { extraNullEntries, firstKnownNotAtZero, extraKnownEntries };
}

type TranslationState =
| { status: "idle" }
| { status: "downloading"; progress: number; targetLanguage: string }
| { status: "translating"; targetLanguage: string }
| { status: "translated"; targetLanguage: string; text: string }
| { status: "error"; targetLanguage: string; message: string };

// Theme is set once at startup and does not change while the app is running.
// Reading it per-render via localStorage.getItem is unnecessary synchronous I/O.
const CURRENT_THEME = localStorage.getItem("theme") || "discord";
Expand Down Expand Up @@ -161,29 +183,29 @@ export const MessageItem = memo((props: MessageItemProps) => {
const [messageNeedsCollapsing, setMessageNeedsCollapsing] = useState(false);
const messageRowRef = useRef<HTMLDivElement>(null);
const [sheetOpen, setSheetOpen] = useState(false);
const [translationState, setTranslationState] = useState<TranslationState>({
status: "idle",
});
const longPress = useLongPress({
onLongPress: () => setSheetOpen(true),
});

const handleMessageMouseEnter = () => {
const el = messageRowRef.current;
if (!el) return;
const msgRect = el.getBoundingClientRect();
// Toolbar: bottom-1 (4px from bottom), right-4 (16px from right), ~90px wide, ~32px tall
const toolbarTop = msgRect.bottom - 4 - 32;
const toolbarLeft = msgRect.right - 16 - 90;
const toolbarRight = msgRect.right - 16;
const toolbarBottom = msgRect.bottom - 4;
const toolbar = el.querySelector<HTMLElement>(".message-actions-container");
if (!toolbar) return;
const toolbarRect = toolbar.getBoundingClientRect();

for (const btn of el.querySelectorAll<HTMLElement>(
".copy-button, .inline-copy-button",
)) {
const r = btn.getBoundingClientRect();
const overlaps =
r.right > toolbarLeft &&
r.left < toolbarRight &&
r.bottom > toolbarTop &&
r.top < toolbarBottom;
r.right > toolbarRect.left &&
r.left < toolbarRect.right &&
r.bottom > toolbarRect.top &&
r.top < toolbarRect.bottom;
if (overlaps) btn.classList.add("avoid-toolbar");
}
};
Expand Down Expand Up @@ -336,6 +358,9 @@ export const MessageItem = memo((props: MessageItemProps) => {
const enableMarkdownRendering = useStore(
useCallback((state) => state.globalSettings.enableMarkdownRendering, []),
);
const translationTargetLanguage = useStore(
useCallback((state) => state.globalSettings.translationTargetLanguage, []),
);
const openMedia = useStore(useCallback((state) => state.openMedia, []));
const canRedact =
!isSystem &&
Expand All @@ -344,6 +369,8 @@ export const MessageItem = memo((props: MessageItemProps) => {
!!server?.capabilities?.includes("draft/message-redaction") &&
!!onRedactMessage;
const canReply = !hideReply && message.type === "message";
const canTranslate =
canReply && !!message.content.trim() && canUseBrowserTranslation();

// message.content is already combined for multiline messages by the IRC client
const messageContent = message.content;
Expand Down Expand Up @@ -376,6 +403,22 @@ export const MessageItem = memo((props: MessageItemProps) => {

const theme = CURRENT_THEME;
const username = message.userId;
const translatedHtmlContent = useMemo(() => {
if (translationState.status !== "translated") return null;

return processMarkdownInText(
translationState.text,
showExternalContent,
enableMarkdownRendering,
`${message.id || message.msgid || "msg"}-translated`,
);
}, [
translationState,
showExternalContent,
enableMarkdownRendering,
message.id,
message.msgid,
]);

// Strip IRC formatting codes so URL/image detection works even when the URL
// is wrapped in bold, italic, underline, strikethrough, or color codes.
Expand Down Expand Up @@ -430,6 +473,99 @@ export const MessageItem = memo((props: MessageItemProps) => {
)
: undefined;

const handleTranslateMessage = useCallback(async () => {
if (!messageContent.trim()) return;

const targetLanguage = getPreferredTranslationTargetLanguageFromSetting(
translationTargetLanguage,
);
const sourceLanguage =
getMessageSourceLanguage(message.tags) ??
(await detectMessageSourceLanguage({ text: messageContent }));

if (!sourceLanguage) {
setTranslationState({
status: "error",
targetLanguage,
message: "Could not determine the message language for translation.",
});
return;
}

if (sourceLanguage === targetLanguage) {
setTranslationState({
status: "error",
targetLanguage,
message: "Message already matches your preferred language.",
});
return;
}

const availability = await getBrowserTranslationAvailability({
sourceLanguage,
targetLanguage,
});

if (
availability === "unsupported" ||
availability === "insecure-context" ||
availability === "unavailable"
) {
const errorMessage =
availability === "unsupported"
? "Translation is not supported in this browser."
: availability === "insecure-context"
? "Translation requires a secure context."
: "This language pair is unavailable.";
setTranslationState({
status: "error",
targetLanguage,
message: errorMessage,
});
return;
}

setTranslationState(
availability === "available"
? { status: "translating", targetLanguage }
: { status: "downloading", progress: 0, targetLanguage },
);

try {
const translatedText = await translateWithBrowser({
sourceLanguage,
targetLanguage,
text: messageContent,
onDownloadProgress:
availability === "available"
? undefined
: (progress) => {
setTranslationState({
status: "downloading",
progress,
targetLanguage,
});
},
});

startTransition(() => {
setTranslationState({
status: "translated",
targetLanguage,
text: translatedText,
});
});
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Translation failed.";
setTranslationState({
status: "error",
targetLanguage,
message: errorMessage,
});
}
}, [message.tags, messageContent, translationTargetLanguage]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

// Handle system messages
if (isSystem) {
return <SystemMessage message={message} onIrcLinkClick={onIrcLinkClick} />;
Expand Down Expand Up @@ -842,6 +978,44 @@ export const MessageItem = memo((props: MessageItemProps) => {
messageContent={message.content}
/>
)}

{translationState.status !== "idle" && (
<div className="mt-2 rounded-lg border border-sky-400/20 bg-sky-500/10 px-3 py-2 text-sm text-sky-50">
<div className="text-[11px] font-semibold uppercase tracking-wide text-sky-200/80">
{translationState.status === "translated"
? `Translated to ${translationState.targetLanguage}`
: translationState.status === "downloading"
? `Preparing translation model for ${translationState.targetLanguage}`
: translationState.status === "translating"
? `Translating to ${translationState.targetLanguage}`
: `Translation unavailable for ${translationState.targetLanguage}`}
</div>
{translationState.status === "translated" &&
translatedHtmlContent && (
<EnhancedLinkWrapper onIrcLinkClick={onIrcLinkClick}>
<div className="mt-1 overflow-hidden break-words whitespace-pre-wrap">
{translatedHtmlContent}
</div>
</EnhancedLinkWrapper>
)}
{translationState.status === "downloading" && (
<div className="mt-1 text-sky-100/80">
Download progress:{" "}
{Math.round(translationState.progress * 100)}%
</div>
)}
{translationState.status === "translating" && (
<div className="mt-1 text-sky-100/80">
Working on a local translation.
</div>
)}
{translationState.status === "error" && (
<div className="mt-1 text-amber-100">
{translationState.message}
</div>
)}
</div>
)}
</div>

<ReactionsWithActions
Expand All @@ -850,13 +1024,19 @@ export const MessageItem = memo((props: MessageItemProps) => {
onReactionClick={handleReactionClick}
onReactClick={(el) => onReactClick(message, el)}
onReplyClick={() => setReplyTo(message)}
onTranslateClick={handleTranslateMessage}
onRedactClick={
canRedact ? () => onRedactMessage?.(message) : undefined
}
canRedact={canRedact}
canReply={canReply}
canTranslate={canTranslate}
onOpenMedia={handleOpenMedia}
canOpenMedia={canOpenMedia}
isTranslating={
translationState.status === "downloading" ||
translationState.status === "translating"
}
/>
</div>
</div>
Expand All @@ -878,6 +1058,7 @@ export const MessageItem = memo((props: MessageItemProps) => {
? (el: Element) => onReactClick(message, el)
: undefined
}
onTranslate={canTranslate ? handleTranslateMessage : undefined}
onDelete={
canRedact
? () => {
Expand All @@ -896,8 +1077,13 @@ export const MessageItem = memo((props: MessageItemProps) => {
}
canReply={canReply}
canReact={!!message.msgid}
canTranslate={canTranslate}
canDelete={canRedact}
canOpenMedia={canOpenMedia}
isTranslating={
translationState.status === "downloading" ||
translationState.status === "translating"
}
/>
)}
</div>
Expand Down
Loading