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
1 change: 1 addition & 0 deletions src/components/message/MessageItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,7 @@ export const MessageItem = memo((props: MessageItemProps) => {
code={message.standardReplyCode}
message={message.standardReplyMessage}
target={message.standardReplyTarget}
context={message.standardReplyContext}
timestamp={new Date(message.timestamp)}
onIrcLinkClick={onIrcLinkClick}
/>
Expand Down
143 changes: 84 additions & 59 deletions src/components/ui/StandardReplyNotification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,85 +13,110 @@ interface StandardReplyNotificationProps {
code: string;
message: string;
target?: string;
context?: string[];
timestamp: Date;
onIrcLinkClick?: (url: string) => void;
}

// IRCv3 standard-replies: only `<description>` is intended for human display.
// `<command>` and `<code>` are computer-readable; we keep them on the row's
// title attribute so power users can hover for the technical detail.
// `<context>` strings are real-world identifiers (channel, nick, account)
// referenced by the description — those are useful to the user, so we render
// them as small chips alongside the message.
export const StandardReplyNotification: React.FC<
StandardReplyNotificationProps
> = ({ type, command, code, message, target, timestamp, onIrcLinkClick }) => {
const formatTime = (date: Date) => {
return new Intl.DateTimeFormat("en-US", {
> = ({
type,
command,
code,
message,
target,
context,
timestamp,
onIrcLinkClick,
}) => {
const formatTime = (date: Date) =>
new Intl.DateTimeFormat("en-US", {
hour: "2-digit",
minute: "2-digit",
}).format(date);
};

const getIcon = () => {
switch (type) {
case "FAIL":
return <FaTimesCircle className="text-red-500 flex-shrink-0" />;
case "WARN":
return (
<FaExclamationTriangle className="text-yellow-500 flex-shrink-0" />
);
case "NOTE":
return <FaInfoCircle className="text-blue-500 flex-shrink-0" />;
default:
return null;
}
};
const icon =
type === "FAIL" ? (
<FaTimesCircle className="text-red-500 flex-shrink-0" />
) : type === "WARN" ? (
<FaExclamationTriangle className="text-yellow-500 flex-shrink-0" />
) : (
<FaInfoCircle className="text-blue-500 flex-shrink-0" />
);

const getBackgroundColor = () => {
switch (type) {
case "FAIL":
return "bg-red-100 dark:bg-red-950/50 border-red-300 dark:border-red-700";
case "WARN":
return "bg-yellow-100 dark:bg-yellow-950/50 border-yellow-300 dark:border-yellow-700";
case "NOTE":
return "bg-blue-100 dark:bg-blue-950/50 border-blue-300 dark:border-blue-700";
default:
return "bg-gray-100 dark:bg-gray-950/50 border-gray-300 dark:border-gray-700";
}
};
const bg =
type === "FAIL"
? "bg-red-100 dark:bg-red-950/50 border-red-300 dark:border-red-700"
: type === "WARN"
? "bg-yellow-100 dark:bg-yellow-950/50 border-yellow-300 dark:border-yellow-700"
: "bg-blue-100 dark:bg-blue-950/50 border-blue-300 dark:border-blue-700";

const getTextColor = () => {
switch (type) {
case "FAIL":
return "text-red-800 dark:text-red-200";
case "WARN":
return "text-yellow-800 dark:text-yellow-200";
case "NOTE":
return "text-blue-800 dark:text-blue-200";
default:
return "text-gray-800 dark:text-gray-200";
}
};
const textColor =
type === "FAIL"
? "text-red-800 dark:text-red-200"
: type === "WARN"
? "text-yellow-800 dark:text-yellow-200"
: "text-blue-800 dark:text-blue-200";

const htmlContent = processMarkdownInText(
message,
true,
false,
`standard-reply-${command}-${code}-${timestamp.getTime()}`,
);
const chipBg =
type === "FAIL"
? "bg-red-200/60 dark:bg-red-900/60"
: type === "WARN"
? "bg-yellow-200/60 dark:bg-yellow-900/60"
: "bg-blue-200/60 dark:bg-blue-900/60";

// Resolve which context strings to show. Fall back to legacy `target` when
// `context` isn't provided (older callers).
const ctx = context && context.length > 0 ? context : target ? [target] : [];

// Hover tooltip carries the computer-readable bits for power users.
const hoverTitle = `${type} ${command} ${code}${ctx.length ? ` ${ctx.join(" ")}` : ""}`;

const description = message.trim();
const htmlContent = description
? processMarkdownInText(
description,
true,
false,
`standard-reply-${command}-${code}-${timestamp.getTime()}`,
)
: "";

return (
<div
className={`mx-4 my-2 p-3 rounded-lg border ${getBackgroundColor()} shadow-sm`}
className={`mx-4 my-2 p-3 rounded-lg border ${bg} shadow-sm`}
title={hoverTitle}
>
<div className="flex items-start gap-3">
<div className="mt-0.5">{getIcon()}</div>
<div className="mt-0.5">{icon}</div>
<div className="flex-1 min-w-0">
<div className={`text-sm font-medium ${getTextColor()} mb-1`}>
{type} {command} {code}
{target && <span className="font-normal"> • {target}</span>}
</div>
<div className={`text-sm ${getTextColor()} leading-relaxed`}>
<EnhancedLinkWrapper onIrcLinkClick={onIrcLinkClick}>
{htmlContent}
</EnhancedLinkWrapper>
<div
className={`text-sm ${textColor} leading-relaxed break-words [overflow-wrap:anywhere]`}
>
{ctx.map((c) => (
<span
key={c}
className={`inline-block ${chipBg} ${textColor} text-xs font-mono rounded px-1.5 py-0.5 mr-2 align-middle`}
>
{c}
</span>
))}
{htmlContent ? (
<EnhancedLinkWrapper onIrcLinkClick={onIrcLinkClick}>
{htmlContent}
</EnhancedLinkWrapper>
) : (
<span className="opacity-60 italic">(no description)</span>
)}
</div>
<div className={`text-xs ${getTextColor()} opacity-70 mt-2`}>
<div className={`text-xs ${textColor} opacity-60 mt-2`}>
{formatTime(timestamp)}
</div>
</div>
Expand Down
4 changes: 4 additions & 0 deletions src/lib/irc/IRCClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,24 +158,28 @@ export interface EventMap {
command: string;
code: string;
target?: string;
context: string[];
message: string;
};
WARN: EventWithTags & {
command: string;
code: string;
target?: string;
context: string[];
message: string;
};
NOTE: EventWithTags & {
command: string;
code: string;
target?: string;
context: string[];
message: string;
};
SUCCESS: EventWithTags & {
command: string;
code: string;
target?: string;
context: string[];
message: string;
};
REGISTER_SUCCESS: EventWithTags & {
Expand Down
93 changes: 59 additions & 34 deletions src/lib/irc/handlers/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,46 @@ export function handleAuthenticate(
ctx.triggerEvent("AUTHENTICATE", { serverId, param });
}

// IRCv3 standard-replies wire form:
// <prefix> {FAIL,WARN,NOTE,SUCCESS} <command> <code> [<context>...] :<description>
// `description` is the human-readable text, always the trailing param.
// `context` is zero or more identifiers (channel, nick, account) the
// description refers to. `command` and `code` are computer-readable tokens.
function splitStandardReply(
parv: string[],
trailing: string,
): { command: string; code: string; context: string[]; message: string } {
const command = parv[0];
const code = parv[1];
// The parser pushes the trailing param onto parv as its last element when
// present. Pop it back off so context = the strings strictly between code
// and the description.
const hasTrailing = trailing.length > 0;
const ctxEnd = hasTrailing ? parv.length - 1 : parv.length;
const context = parv.slice(2, ctxEnd);
const message = hasTrailing ? trailing : parv.slice(2).join(" ");
return { command, code, context, message };
}

export function handleFail(
ctx: IRCClientContext,
serverId: string,
_source: string,
parv: string[],
mtags: Record<string, string> | undefined,
trailing = "",
): void {
const cmd = parv[0];
const code = parv[1];
const target = parv[2] || undefined;
const message = parv.slice(3).join(" ").substring(1);
const { command, code, context, message } = splitStandardReply(
parv,
trailing,
);
ctx.triggerEvent("FAIL", {
serverId,
mtags,
command: cmd,
command,
code,
target,
target: context[0],
context,
message,
});
}
Expand All @@ -38,17 +61,19 @@ export function handleWarn(
_source: string,
parv: string[],
mtags: Record<string, string> | undefined,
trailing = "",
): void {
const cmd = parv[0];
const code = parv[1];
const target = parv[2] || undefined;
const message = parv.slice(3).join(" ").substring(1);
const { command, code, context, message } = splitStandardReply(
parv,
trailing,
);
ctx.triggerEvent("WARN", {
serverId,
mtags,
command: cmd,
command,
code,
target,
target: context[0],
context,
message,
});
}
Expand All @@ -59,17 +84,19 @@ export function handleNote(
_source: string,
parv: string[],
mtags: Record<string, string> | undefined,
trailing = "",
): void {
const cmd = parv[0];
const code = parv[1];
const target = parv[2] || undefined;
const message = parv.slice(3).join(" ").substring(1);
const { command, code, context, message } = splitStandardReply(
parv,
trailing,
);
ctx.triggerEvent("NOTE", {
serverId,
mtags,
command: cmd,
command,
code,
target,
target: context[0],
context,
message,
});
}
Expand All @@ -80,17 +107,19 @@ export function handleSuccess(
_source: string,
parv: string[],
mtags: Record<string, string> | undefined,
trailing = "",
): void {
const cmd = parv[0];
const code = parv[1];
const target = parv[2] || undefined;
const message = parv.slice(3).join(" ").substring(1);
const { command, code, context, message } = splitStandardReply(
parv,
trailing,
);
ctx.triggerEvent("SUCCESS", {
serverId,
mtags,
command: cmd,
command,
code,
target,
target: context[0],
context,
message,
});
}
Expand All @@ -101,20 +130,16 @@ export function handleRegister(
_source: string,
parv: string[],
mtags: Record<string, string> | undefined,
trailing = "",
): void {
const subcommand = parv[0];
// REGISTER replies: REGISTER {SUCCESS,VERIFICATION_REQUIRED} <account> :<description>
// The description is the trailing param.
const account = parv[1];
const message = trailing || parv.slice(2).join(" ");
if (subcommand === "SUCCESS") {
const account = parv[1];
const message = parv.slice(2).join(" ").substring(1);
ctx.triggerEvent("REGISTER_SUCCESS", {
serverId,
mtags,
account,
message,
});
ctx.triggerEvent("REGISTER_SUCCESS", { serverId, mtags, account, message });
} else if (subcommand === "VERIFICATION_REQUIRED") {
const account = parv[1];
const message = parv.slice(2).join(" ").substring(1);
ctx.triggerEvent("REGISTER_VERIFICATION_REQUIRED", {
serverId,
mtags,
Expand Down
20 changes: 10 additions & 10 deletions src/lib/irc/handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,18 +273,18 @@ export const IRC_DISPATCH: Record<string, HandlerFn> = {
AUTHENTICATE: (ctx, serverId, source, parv, mtags) =>
handleAuthenticate(ctx, serverId, source, parv, mtags),
// FAIL METADATA is a distinct protocol — route to the metadata handler
FAIL: (ctx, serverId, source, parv, mtags) =>
FAIL: (ctx, serverId, source, parv, mtags, trailing) =>
parv[0] === "METADATA"
? handleMetadataFail(ctx, serverId, source, parv, mtags)
: handleFail(ctx, serverId, source, parv, mtags),
WARN: (ctx, serverId, source, parv, mtags) =>
handleWarn(ctx, serverId, source, parv, mtags),
NOTE: (ctx, serverId, source, parv, mtags) =>
handleNote(ctx, serverId, source, parv, mtags),
SUCCESS: (ctx, serverId, source, parv, mtags) =>
handleSuccess(ctx, serverId, source, parv, mtags),
REGISTER: (ctx, serverId, source, parv, mtags) =>
handleRegister(ctx, serverId, source, parv, mtags),
: handleFail(ctx, serverId, source, parv, mtags, trailing),
WARN: (ctx, serverId, source, parv, mtags, trailing) =>
handleWarn(ctx, serverId, source, parv, mtags, trailing),
NOTE: (ctx, serverId, source, parv, mtags, trailing) =>
handleNote(ctx, serverId, source, parv, mtags, trailing),
SUCCESS: (ctx, serverId, source, parv, mtags, trailing) =>
handleSuccess(ctx, serverId, source, parv, mtags, trailing),
REGISTER: (ctx, serverId, source, parv, mtags, trailing) =>
handleRegister(ctx, serverId, source, parv, mtags, trailing),
VERIFY: (ctx, serverId, source, parv, mtags) =>
handleVerify(ctx, serverId, source, parv, mtags),
EXTJWT: (ctx, serverId, source, parv, mtags) =>
Expand Down
Loading
Loading