From 5564b5f54f110a91e9ab1434f196646f8b628756 Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Sun, 3 May 2026 04:35:48 +0100 Subject: [PATCH 1/3] feat: draft/account-recovery (RECOVER + SETPASS) and SASL EXTERNAL Implements the client side of the draft/account-recovery WIP spec we authored at extensions/account-recovery.md, plus opt-in SASL EXTERNAL for cert-based login. * IRCClient.recoverRequest / recoverConfirm / setpass send the new commands. SETPASS uses the IRC trailing-parameter form so passwords may contain spaces (passphrases). No base64. * New typed events RECOVER_NOTE / RECOVER_FAIL / SETPASS_NOTE / SETPASS_FAIL projected from the generic NOTE/FAIL stream so components don't have to filter by command on every render. * PasswordRecoveryModal: three-stage flow (account -> code -> new password). Surfaces the spec's account-existence-ambiguity to the user instead of silently swallowing typos. * ChangePasswordModal: one-shot SETPASS for in-session rotation; only shown while the connection is authenticated. * EditServerModal exposes both modals when the server advertises draft/account-recovery. * SASL EXTERNAL added to the SaslMech union and the AUTHENTICATE state machine; an EXTERNAL-configured server replies '+' to '+' and the TLS cert provides identity. EXTERNAL is only chosen when the user explicitly picks it -- never under "auto", since it requires a device-side client cert. --- src/components/ui/ChangePasswordModal.tsx | 132 +++++++++ src/components/ui/EditServerModal.tsx | 37 +++ src/components/ui/PasswordRecoveryModal.tsx | 284 ++++++++++++++++++++ src/lib/irc/IRCClient.ts | 24 ++ src/lib/irc/handlers/auth.ts | 22 ++ src/store/handlers/auth.ts | 26 +- src/types/index.ts | 7 +- 7 files changed, 527 insertions(+), 5 deletions(-) create mode 100644 src/components/ui/ChangePasswordModal.tsx create mode 100644 src/components/ui/PasswordRecoveryModal.tsx diff --git a/src/components/ui/ChangePasswordModal.tsx b/src/components/ui/ChangePasswordModal.tsx new file mode 100644 index 00000000..fc57e297 --- /dev/null +++ b/src/components/ui/ChangePasswordModal.tsx @@ -0,0 +1,132 @@ +// In-session password rotation via SETPASS (draft/account-recovery). +// +// The user is already SASL-authenticated, so we don't need a recovery +// code -- the server accepts SETPASS on its own when the connection +// holds a SASL session for the target account. + +import type React from "react"; +import { useEffect, useState } from "react"; +import ircClient from "../../lib/ircClient"; + +interface Props { + serverId: string; + onClose: () => void; +} + +export const ChangePasswordModal: React.FC = ({ serverId, onClose }) => { + const [newPass, setNewPass] = useState(""); + const [confirmPass, setConfirmPass] = useState(""); + const [busy, setBusy] = useState(false); + const [info, setInfo] = useState(null); + const [err, setErr] = useState(null); + const [done, setDone] = useState(false); + + useEffect(() => { + const onSetpassNote = (p: { + serverId: string; + code: string; + args: string[]; + }) => { + if (p.serverId !== serverId) return; + if (p.code === "SUCCESS") { + setBusy(false); + setDone(true); + setInfo(p.args.slice(1).join(" ") || "Password updated."); + } + }; + const onSetpassFail = (p: { + serverId: string; + code: string; + message: string; + }) => { + if (p.serverId !== serverId) return; + setBusy(false); + setErr(p.message || `Could not update password: ${p.code}`); + }; + ircClient.on("SETPASS_NOTE", onSetpassNote); + ircClient.on("SETPASS_FAIL", onSetpassFail); + return () => { + ircClient.deleteHook("SETPASS_NOTE", onSetpassNote); + ircClient.deleteHook("SETPASS_FAIL", onSetpassFail); + }; + }, [serverId]); + + const submit = (e: React.FormEvent) => { + e.preventDefault(); + setErr(null); + if (newPass.length < 3) { + setErr("Pick a password (3 characters or more)."); + return; + } + if (newPass !== confirmPass) { + setErr("Passwords don't match."); + return; + } + setBusy(true); + ircClient.setpass(serverId, newPass); + }; + + return ( +
+
+
+

Change password

+ +
+ + {!done ? ( +
+

+ Other open sessions for this account will be signed out as part of + the change. Two-factor authentication settings, if any, are left + unchanged. +

+ setNewPass(e.target.value)} + placeholder="New password" + className="w-full px-3 py-2 rounded bg-discord-dark-400 text-white" + autoFocus + /> + setConfirmPass(e.target.value)} + placeholder="Confirm new password" + className="w-full px-3 py-2 rounded bg-discord-dark-400 text-white" + /> + +
+ ) : ( +
+

{info}

+ +
+ )} + + {err &&

{err}

} +
+
+ ); +}; + +export default ChangePasswordModal; diff --git a/src/components/ui/EditServerModal.tsx b/src/components/ui/EditServerModal.tsx index 1f8a0c16..8923b24f 100644 --- a/src/components/ui/EditServerModal.tsx +++ b/src/components/ui/EditServerModal.tsx @@ -3,6 +3,8 @@ import { useState } from "react"; import { FaQuestionCircle, FaTimes } from "react-icons/fa"; import useStore, { loadSavedServers } from "../../store"; import type { ServerConfig } from "../../types"; +import ChangePasswordModal from "./ChangePasswordModal"; +import PasswordRecoveryModal from "./PasswordRecoveryModal"; import { TextInput } from "./TextInput"; interface EditServerModalProps { @@ -53,6 +55,8 @@ export const EditServerModal: React.FC = ({ const [registerAccount, setRegisterAccount] = useState(false); const [registerEmail, setRegisterEmail] = useState(""); const [registerPassword, setRegisterPassword] = useState(""); + const [showRecovery, setShowRecovery] = useState(false); + const [showChangePassword, setShowChangePassword] = useState(false); const [error, setError] = useState(""); @@ -288,6 +292,26 @@ export const EditServerModal: React.FC = ({ )} + {server?.capabilities?.includes("draft/account-recovery") && ( +
+ {server.isConnected && ( + + )} + +
+ )} )} @@ -461,6 +485,19 @@ export const EditServerModal: React.FC = ({ + {showRecovery && ( + setShowRecovery(false)} + /> + )} + {showChangePassword && ( + setShowChangePassword(false)} + /> + )} ); }; diff --git a/src/components/ui/PasswordRecoveryModal.tsx b/src/components/ui/PasswordRecoveryModal.tsx new file mode 100644 index 00000000..1041fe5d --- /dev/null +++ b/src/components/ui/PasswordRecoveryModal.tsx @@ -0,0 +1,284 @@ +// draft/account-recovery: forgot-password flow. +// +// Three-stage modal: +// 1. enter the account name -> server emails a 6-digit code +// 2. enter the code -> server replies NOTE RECOVER VERIFIED, opening +// a setpass-grant on this connection +// 3. enter the new password -> we send SETPASS : +// +// Spec note: the response to RECOVER REQUEST is identical regardless +// of whether the account exists / has a verified email. We surface +// that ambiguity in the UI so the user isn't surprised when an obvious +// typo silently "works". + +import type React from "react"; +import { useEffect, useState } from "react"; +import ircClient from "../../lib/ircClient"; + +interface Props { + serverId: string; + initialAccount?: string; + onClose: () => void; +} + +type Stage = "request" | "confirm" | "setpass" | "done"; + +export const PasswordRecoveryModal: React.FC = ({ + serverId, + initialAccount, + onClose, +}) => { + const [stage, setStage] = useState("request"); + const [account, setAccount] = useState(initialAccount ?? ""); + const [code, setCode] = useState(""); + const [newPass, setNewPass] = useState(""); + const [confirmPass, setConfirmPass] = useState(""); + const [busy, setBusy] = useState(false); + const [info, setInfo] = useState(null); + const [err, setErr] = useState(null); + + // Subscribe to RECOVER + SETPASS events for this server. + useEffect(() => { + const onRecoverNote = (p: { + serverId: string; + code: string; + args: string[]; + }) => { + if (p.serverId !== serverId) return; + if (p.code === "CODE_SENT") { + setBusy(false); + setStage("confirm"); + // args[0] = account; description follows in args[1] but the + // raw NOTE handler stripped the leading ':'. Recompose for UX. + setInfo( + p.args.slice(1).join(" ") || + `If ${p.args[0] ?? "that account"} has a verified email on file, a code has been sent.`, + ); + } else if (p.code === "VERIFIED") { + setBusy(false); + setStage("setpass"); + setInfo(p.args.slice(1).join(" ") || "Code accepted."); + } + }; + const onRecoverFail = (p: { + serverId: string; + code: string; + message: string; + }) => { + if (p.serverId !== serverId) return; + setBusy(false); + setErr(p.message || `Recovery failed: ${p.code}`); + }; + const onSetpassNote = (p: { + serverId: string; + code: string; + args: string[]; + }) => { + if (p.serverId !== serverId) return; + if (p.code === "SUCCESS") { + setBusy(false); + setStage("done"); + setInfo( + p.args.slice(1).join(" ") || "Password updated. You can now log in.", + ); + } + }; + const onSetpassFail = (p: { + serverId: string; + code: string; + message: string; + }) => { + if (p.serverId !== serverId) return; + setBusy(false); + setErr(p.message || `SETPASS failed: ${p.code}`); + }; + ircClient.on("RECOVER_NOTE", onRecoverNote); + ircClient.on("RECOVER_FAIL", onRecoverFail); + ircClient.on("SETPASS_NOTE", onSetpassNote); + ircClient.on("SETPASS_FAIL", onSetpassFail); + return () => { + ircClient.deleteHook("RECOVER_NOTE", onRecoverNote); + ircClient.deleteHook("RECOVER_FAIL", onRecoverFail); + ircClient.deleteHook("SETPASS_NOTE", onSetpassNote); + ircClient.deleteHook("SETPASS_FAIL", onSetpassFail); + }; + }, [serverId]); + + const submitRequest = (e: React.FormEvent) => { + e.preventDefault(); + setErr(null); + setInfo(null); + const acct = account.trim(); + if (!acct) { + setErr("Enter the account name you want to recover."); + return; + } + setBusy(true); + ircClient.recoverRequest(serverId, acct); + }; + + const submitConfirm = (e: React.FormEvent) => { + e.preventDefault(); + setErr(null); + if (!/^\d{6}$/.test(code.trim())) { + setErr("Enter the 6-digit code from your email."); + return; + } + setBusy(true); + ircClient.recoverConfirm(serverId, account.trim(), code.trim()); + }; + + const submitSetpass = (e: React.FormEvent) => { + e.preventDefault(); + setErr(null); + if (newPass.length < 3) { + setErr("Pick a password (3 characters or more)."); + return; + } + if (newPass !== confirmPass) { + setErr("Passwords don't match."); + return; + } + setBusy(true); + ircClient.setpass(serverId, newPass); + }; + + return ( +
+
+
+

+ {stage === "done" ? "Password reset complete" : "Forgot password"} +

+ +
+ + {stage === "request" && ( +
+

+ Enter your account name. We'll email a 6-digit recovery code to + the verified address on file. +

+ setAccount(e.target.value)} + placeholder="Account name" + className="w-full px-3 py-2 rounded bg-discord-dark-400 text-white" + autoFocus + /> + +
+ )} + + {stage === "confirm" && ( +
+

{info}

+

+ Note: the server returns the same response whether or not the + account has a verified email, so a typo will not be flagged here. + If no code arrives within a couple of minutes, double-check the + account name. +

+ setCode(e.target.value.replace(/\D/g, ""))} + placeholder="000000" + className="w-full px-3 py-2 rounded bg-discord-dark-400 text-white tracking-[0.4em] text-center font-mono" + autoFocus + /> +
+ + +
+
+ )} + + {stage === "setpass" && ( +
+

{info}

+

+ You have a few minutes to choose a new password. +

+ setNewPass(e.target.value)} + placeholder="New password" + className="w-full px-3 py-2 rounded bg-discord-dark-400 text-white" + autoFocus + /> + setConfirmPass(e.target.value)} + placeholder="Confirm new password" + className="w-full px-3 py-2 rounded bg-discord-dark-400 text-white" + /> + +
+ )} + + {stage === "done" && ( +
+

{info}

+

+ For security, all existing sessions for this account were signed + out and account-scoped server state was purged. If two-factor + authentication was enabled, it remains enabled — the next login + will still require it. +

+ +
+ )} + + {err &&

{err}

} +
+
+ ); +}; + +export default PasswordRecoveryModal; diff --git a/src/lib/irc/IRCClient.ts b/src/lib/irc/IRCClient.ts index aa6a0173..55eca519 100644 --- a/src/lib/irc/IRCClient.ts +++ b/src/lib/irc/IRCClient.ts @@ -254,6 +254,14 @@ export interface EventMap { code: string; args: string[]; }; + // draft/account-recovery: convenient typed projection of the + // generic NOTE/FAIL events for the RECOVER + SETPASS commands. + // The dispatch in handlers/auth.ts emits these alongside the + // generic NOTE/FAIL. + RECOVER_NOTE: EventWithTags & { code: string; args: string[] }; + RECOVER_FAIL: EventWithTags & { code: string; message: string }; + SETPASS_NOTE: EventWithTags & { code: string; args: string[] }; + SETPASS_FAIL: EventWithTags & { code: string; message: string }; WHOIS_BOT: { serverId: string; nick: string; @@ -1318,6 +1326,22 @@ export class IRCClient implements IRCClientContext { this.sendRaw(serverId, command); } + // draft/account-recovery: forgotten-password flow. + recoverRequest(serverId: string, account: string): void { + this.sendRaw(serverId, `RECOVER REQUEST ${account}`); + } + + recoverConfirm(serverId: string, account: string, code: string): void { + this.sendRaw(serverId, `RECOVER CONFIRM ${account} ${code}`); + } + + // SETPASS lives in the same draft/account-recovery cap. The new + // password is sent as the IRC trailing parameter so it MAY contain + // spaces (for passphrases). No base64 -- the password is UTF-8. + setpass(serverId: string, newPassword: string): void { + this.sendRaw(serverId, `SETPASS :${newPassword}`); + } + // MONITOR commands monitorAdd(serverId: string, targets: string[]): void { const targetsStr = targets.join(","); diff --git a/src/lib/irc/handlers/auth.ts b/src/lib/irc/handlers/auth.ts index 4fa9e892..52b7b6fe 100644 --- a/src/lib/irc/handlers/auth.ts +++ b/src/lib/irc/handlers/auth.ts @@ -30,6 +30,12 @@ export function handleFail( target, message, }); + // draft/account-recovery typed projections so components don't + // have to filter the FAIL stream by command on every render. + if (cmd === "RECOVER") + ctx.triggerEvent("RECOVER_FAIL", { serverId, mtags, code, message }); + else if (cmd === "SETPASS") + ctx.triggerEvent("SETPASS_FAIL", { serverId, mtags, code, message }); } export function handleWarn( @@ -80,6 +86,22 @@ export function handleNote( args: parv.slice(2), }); } + // draft/account-recovery typed projections + if (cmd === "RECOVER") { + ctx.triggerEvent("RECOVER_NOTE", { + serverId, + mtags, + code, + args: parv.slice(2), + }); + } else if (cmd === "SETPASS") { + ctx.triggerEvent("SETPASS_NOTE", { + serverId, + mtags, + code, + args: parv.slice(2), + }); + } } export function handleSuccess( diff --git a/src/store/handlers/auth.ts b/src/store/handlers/auth.ts index b1062885..4c155227 100644 --- a/src/store/handlers/auth.ts +++ b/src/store/handlers/auth.ts @@ -19,7 +19,7 @@ import { normalizeHost } from "../helpers"; import type { AppState } from "../index"; import * as storage from "../localStorage"; -type SaslMech = "PLAIN" | "SCRAM-SHA-256" | "DRAFT-WEBAUTHN-BIO"; +type SaslMech = "PLAIN" | "SCRAM-SHA-256" | "DRAFT-WEBAUTHN-BIO" | "EXTERNAL"; interface SaslSession { mech: SaslMech; @@ -33,8 +33,17 @@ const sessions = new Map(); function chooseMechanism( available: string[], - pref: "auto" | "PLAIN" | "SCRAM-SHA-256" | "DRAFT-WEBAUTHN-BIO" | undefined, + pref: + | "auto" + | "PLAIN" + | "SCRAM-SHA-256" + | "DRAFT-WEBAUTHN-BIO" + | "EXTERNAL" + | undefined, ): SaslMech { + // EXTERNAL is a deliberate user choice (the cert is on this device, + // typically) -- never picked under "auto". + if (pref === "EXTERNAL" && available.includes("EXTERNAL")) return "EXTERNAL"; if (pref === "DRAFT-WEBAUTHN-BIO" && available.includes("DRAFT-WEBAUTHN-BIO")) return "DRAFT-WEBAUTHN-BIO"; if (pref === "PLAIN") return "PLAIN"; @@ -55,10 +64,11 @@ function loadCreds( ? serv.saslAccountName : serv.nickname; const pass = serv.saslPassword ? atob(serv.saslPassword) : undefined; - if (!user || !pass) return null; + // EXTERNAL has no password -- the TLS cert is the proof. + if (!user || (serv.saslMechanism !== "EXTERNAL" && !pass)) return null; const available = ircClient.getSaslMechanisms(serverId); const mech = chooseMechanism(available, serv.saslMechanism); - return { user, pass, mech }; + return { user, pass: pass ?? "", mech }; } function clearSession(serverId: string) { @@ -135,6 +145,14 @@ export function registerAuthHandlers(store: StoreApi): void { } try { + if (session.mech === "EXTERNAL") { + // SASL EXTERNAL: server sends `+` to acknowledge the mechanism, + // we reply with `+` to mean "use the identity already + // established by the TLS cert". No further frames. + if (param === "+") ircClient.sendRaw(serverId, "AUTHENTICATE +"); + return; + } + if (session.mech === "PLAIN") { if (param !== "+") return; if (!session.password) return; diff --git a/src/types/index.ts b/src/types/index.ts index e2835f05..5e15fccc 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -61,7 +61,12 @@ export interface ServerConfig { saslEnabled: boolean; // "auto" prefers SCRAM-SHA-256 when the server advertises it and falls // back to PLAIN, "webauthn" uses DRAFT-WEBAUTHN-BIO directly. - saslMechanism?: "auto" | "PLAIN" | "SCRAM-SHA-256" | "DRAFT-WEBAUTHN-BIO"; + saslMechanism?: + | "auto" + | "PLAIN" + | "SCRAM-SHA-256" + | "DRAFT-WEBAUTHN-BIO" + | "EXTERNAL"; skipLinkSecurityWarning?: boolean; skipLocalhostWarning?: boolean; operUsername?: string; From 81d1af17fa92e3612a7dcd87b7a32d4dfa8d1d2f Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Sun, 3 May 2026 04:46:55 +0100 Subject: [PATCH 2/3] feat: draft/persistence settings panel When the server advertises draft/persistence, the user's account settings tab now gets a tri-state preference: - Stay in channels (ON) -- ghost survives disconnect - Leave on disconnect (OFF) -- clean exit - Use server default (DEFAULT) -- inherit operator setting Plumbing: - IRCClient.persistenceGet / persistenceSet send PERSISTENCE GET and PERSISTENCE SET ON|OFF|DEFAULT. - handlePersistence parses the server's PERSISTENCE STATUS reply and emits a typed PERSISTENCE_STATUS event; the FAIL handler projects PERSISTENCE_FAIL alongside the generic FAIL. - registerAuthHandlers caches both preference + effective state on the Server record, and fires an automatic PERSISTENCE GET 1.5s after CAP ACK so the panel has fresh state when the user opens settings (the spec gates the command on IsLoggedIn, hence the small post-SASL delay). - PersistenceSettingsPanel renders a radio-card list with helper text and an "Currently ON/OFF" badge so the user can reconcile their preference with what the server is actually doing (e.g. preference DEFAULT but the operator default flipped). - The panel is mounted in UserSettings -> Account, gated on the server's CAP set, so non-supporting networks see nothing change. --- .../ui/PersistenceSettingsPanel.tsx | 165 ++++++++++++++++++ src/components/ui/UserSettings.tsx | 4 + src/lib/irc/IRCClient.ts | 18 ++ src/lib/irc/handlers/auth.ts | 23 +++ src/lib/irc/handlers/index.ts | 3 + src/store/handlers/auth.ts | 38 ++++ src/types/index.ts | 6 + 7 files changed, 257 insertions(+) create mode 100644 src/components/ui/PersistenceSettingsPanel.tsx diff --git a/src/components/ui/PersistenceSettingsPanel.tsx b/src/components/ui/PersistenceSettingsPanel.tsx new file mode 100644 index 00000000..436b27ac --- /dev/null +++ b/src/components/ui/PersistenceSettingsPanel.tsx @@ -0,0 +1,165 @@ +// draft/persistence settings panel. +// +// Surfaces a tri-state preference (Always on / Always off / Use server +// default) plus a read-only effective-state badge so the user can +// reconcile their preference with what the server is actually doing +// (e.g. their preference is DEFAULT and the server default flipped). +// +// Mounts only when the connection has acked draft/persistence, so we +// can safely call PERSISTENCE GET on first render without spamming +// servers that don't support it. + +import type React from "react"; +import { useEffect, useState } from "react"; +import ircClient from "../../lib/ircClient"; +import useStore from "../../store"; + +interface Props { + serverId: string; +} + +type Pref = "ON" | "OFF" | "DEFAULT"; + +const OPTIONS: { value: Pref; label: string; helper: string }[] = [ + { + value: "ON", + label: "Stay in channels", + helper: + "When you disconnect, you remain in your channels as a ghost so you can pick up where you left off when you reconnect with the same account.", + }, + { + value: "OFF", + label: "Leave on disconnect", + helper: + "Clean exit when you close the app: you part every channel and the server forgets your session immediately.", + }, + { + value: "DEFAULT", + label: "Use server default", + helper: + "Inherit whatever the network operators have configured. Most networks default to keeping you online.", + }, +]; + +export const PersistenceSettingsPanel: React.FC = ({ serverId }) => { + const server = useStore((s) => s.servers.find((srv) => srv.id === serverId)); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const supported = + server?.capabilities?.includes("draft/persistence") ?? false; + const isConnected = server?.isConnected ?? false; + const preference = server?.persistencePreference ?? "DEFAULT"; + const effective = server?.persistenceEffective; + + // Refresh on mount in case the cap acked before this panel was open + // and the deferred GET has already fired (we still want a current + // read in case the server-wide default has rolled). + useEffect(() => { + if (!supported || !isConnected) return; + ircClient.persistenceGet(serverId); + }, [serverId, supported, isConnected]); + + // Flip busy off when a STATUS update lands. + useEffect(() => { + const onStatus = (p: { serverId: string }) => { + if (p.serverId === serverId) setBusy(false); + }; + const onFail = (p: { serverId: string; code: string; message: string }) => { + if (p.serverId !== serverId) return; + setBusy(false); + setError(p.message || `Server rejected the request (${p.code}).`); + }; + ircClient.on("PERSISTENCE_STATUS", onStatus); + ircClient.on("PERSISTENCE_FAIL", onFail); + return () => { + ircClient.deleteHook("PERSISTENCE_STATUS", onStatus); + ircClient.deleteHook("PERSISTENCE_FAIL", onFail); + }; + }, [serverId]); + + if (!supported) return null; + + const apply = (value: Pref) => { + if (!isConnected || value === preference) return; + setError(null); + setBusy(true); + ircClient.persistenceSet(serverId, value); + }; + + return ( +
+
+
+

+ Session persistence +

+

+ Stay in your channels even after you disconnect. +

+
+ {effective && ( + + Currently {effective} + + )} +
+ + {!isConnected && ( +

+ Connect to this server to change persistence. +

+ )} + +
+ {OPTIONS.map((opt) => { + const active = preference === opt.value; + return ( + + ); + })} +
+ + {error &&

{error}

} +
+ ); +}; + +export default PersistenceSettingsPanel; diff --git a/src/components/ui/UserSettings.tsx b/src/components/ui/UserSettings.tsx index 4ad0f8c4..f8b173a8 100644 --- a/src/components/ui/UserSettings.tsx +++ b/src/components/ui/UserSettings.tsx @@ -30,6 +30,7 @@ import useStore, { serverSupportsMetadata, } from "../../store"; import AvatarUpload from "./AvatarUpload"; +import PersistenceSettingsPanel from "./PersistenceSettingsPanel"; import { SettingField } from "./settings/SettingRenderer"; import { TextInput } from "./TextInput"; import UserProfileModal from "./UserProfileModal"; @@ -1301,6 +1302,9 @@ export const UserSettings: React.FC = React.memo(() => { return (
+ {/* draft/persistence: shown only when the server advertises it */} + + {/* IRC Operator Authentication */}

IRC Operator

diff --git a/src/lib/irc/IRCClient.ts b/src/lib/irc/IRCClient.ts index 55eca519..ed425e24 100644 --- a/src/lib/irc/IRCClient.ts +++ b/src/lib/irc/IRCClient.ts @@ -262,6 +262,14 @@ export interface EventMap { RECOVER_FAIL: EventWithTags & { code: string; message: string }; SETPASS_NOTE: EventWithTags & { code: string; args: string[] }; SETPASS_FAIL: EventWithTags & { code: string; message: string }; + // draft/persistence: server reply + // `:server PERSISTENCE STATUS ` + // where each is one of ON | OFF | DEFAULT (effective is always ON|OFF). + PERSISTENCE_STATUS: BaseIRCEvent & { + preference: "ON" | "OFF" | "DEFAULT"; + effective: "ON" | "OFF"; + }; + PERSISTENCE_FAIL: EventWithTags & { code: string; message: string }; WHOIS_BOT: { serverId: string; nick: string; @@ -1342,6 +1350,16 @@ export class IRCClient implements IRCClientContext { this.sendRaw(serverId, `SETPASS :${newPassword}`); } + // draft/persistence: read or set the per-account ghost-on-disconnect + // preference. Server responds with PERSISTENCE STATUS. + persistenceGet(serverId: string): void { + this.sendRaw(serverId, "PERSISTENCE GET"); + } + + persistenceSet(serverId: string, value: "ON" | "OFF" | "DEFAULT"): void { + this.sendRaw(serverId, `PERSISTENCE SET ${value}`); + } + // MONITOR commands monitorAdd(serverId: string, targets: string[]): void { const targetsStr = targets.join(","); diff --git a/src/lib/irc/handlers/auth.ts b/src/lib/irc/handlers/auth.ts index 52b7b6fe..d7e4c7a5 100644 --- a/src/lib/irc/handlers/auth.ts +++ b/src/lib/irc/handlers/auth.ts @@ -36,6 +36,9 @@ export function handleFail( ctx.triggerEvent("RECOVER_FAIL", { serverId, mtags, code, message }); else if (cmd === "SETPASS") ctx.triggerEvent("SETPASS_FAIL", { serverId, mtags, code, message }); + // draft/persistence FAIL projection + else if (cmd === "PERSISTENCE") + ctx.triggerEvent("PERSISTENCE_FAIL", { serverId, mtags, code, message }); } export function handleWarn( @@ -212,3 +215,23 @@ export function handleExtjwt( jwtToken, }); } + +// draft/persistence: server reply to PERSISTENCE GET / SET +// :server PERSISTENCE STATUS +// where client-setting is ON | OFF | DEFAULT and effective is ON | OFF. +export function handlePersistence( + ctx: IRCClientContext, + serverId: string, + _source: string, + parv: string[], + _mtags: Record | undefined, +): void { + const sub = parv[0]?.toUpperCase(); + if (sub !== "STATUS") return; + const rawPref = (parv[1] ?? "").toUpperCase(); + const rawEff = (parv[2] ?? "").toUpperCase(); + const preference: "ON" | "OFF" | "DEFAULT" = + rawPref === "ON" || rawPref === "OFF" ? rawPref : "DEFAULT"; + const effective: "ON" | "OFF" = rawEff === "ON" ? "ON" : "OFF"; + ctx.triggerEvent("PERSISTENCE_STATUS", { serverId, preference, effective }); +} diff --git a/src/lib/irc/handlers/index.ts b/src/lib/irc/handlers/index.ts index 48e69c1f..cc01326e 100644 --- a/src/lib/irc/handlers/index.ts +++ b/src/lib/irc/handlers/index.ts @@ -4,6 +4,7 @@ import { handleExtjwt, handleFail, handleNote, + handlePersistence, handleRegister, handleSuccess, handleTwoFA, @@ -291,6 +292,8 @@ export const IRC_DISPATCH: Record = { handleTwoFA(ctx, serverId, source, parv, mtags), EXTJWT: (ctx, serverId, source, parv, mtags) => handleExtjwt(ctx, serverId, source, parv, mtags), + PERSISTENCE: (ctx, serverId, source, parv, mtags) => + handlePersistence(ctx, serverId, source, parv, mtags), "730": (ctx, serverId, source, parv, mtags) => handleMonOnline(ctx, serverId, source, parv, mtags), diff --git a/src/store/handlers/auth.ts b/src/store/handlers/auth.ts index 4c155227..951cb4db 100644 --- a/src/store/handlers/auth.ts +++ b/src/store/handlers/auth.ts @@ -531,4 +531,42 @@ export function registerAuthHandlers(store: StoreApi): void { }); }, ); + + // draft/persistence: cache the server's reported preference + effective + // setting so the UI can render a tri-state toggle without re-querying + // the server every time the panel opens. + ircClient.on("PERSISTENCE_STATUS", ({ serverId, preference, effective }) => { + store.setState((state) => ({ + servers: state.servers.map((server) => + server.id === serverId + ? { + ...server, + persistencePreference: preference, + persistenceEffective: effective, + } + : server, + ), + })); + }); + + // After CAP ACK we know whether the server supports draft/persistence. + // Issue an initial PERSISTENCE GET so the settings panel has fresh + // state by the time the user opens it. We only do this once per + // (serverId, account) login -- the spec gates the command on + // IsLoggedIn, so we wait for the SASL success path to mark the + // session complete. + ircClient.on("CAP_ACKNOWLEDGED", ({ serverId, key }) => { + if (key !== "draft/persistence") return; + // Defer the GET until a tick later so SASL has had a chance to + // complete; the server returns ACCOUNT_REQUIRED otherwise and + // we'd just have to retry. A small delay is fine because the + // user can't open the settings panel before the modal is + // mounted anyway. + setTimeout(() => { + const state = store.getState(); + const server = state.servers.find((s) => s.id === serverId); + if (!server?.isConnected) return; + ircClient.persistenceGet(serverId); + }, 1500); + }); } diff --git a/src/types/index.ts b/src/types/index.ts index 5e15fccc..88d7dfe3 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -46,6 +46,12 @@ export interface Server { jwtToken?: string; // JWT token for filehost authentication isUnrealIRCd?: boolean; // Whether this server is running UnrealIRCd elist?: string; // ELIST ISUPPORT value for extended LIST capabilities + // draft/persistence state (populated from PERSISTENCE STATUS replies). + // `preference` is what the user has explicitly set on this account + // (ON/OFF) or DEFAULT meaning "follow the server-wide default". + // `effective` is what the server is actually doing right now. + persistencePreference?: "ON" | "OFF" | "DEFAULT"; + persistenceEffective?: "ON" | "OFF"; } export interface ServerConfig { From fb842ebbf938393947cc29ba3d592e922a78ac25 Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Sun, 3 May 2026 05:11:27 +0100 Subject: [PATCH 3/3] feat: draft/read-marker (MARKREAD) Cross-session read state: when one of your devices reads up to a particular message, the other devices clear their unread / mention state for that buffer too. - draft/read-marker added to ourCaps so the cap is auto-requested when the server advertises it. - IRCClient.markreadGet / markreadSet send the GET and SET wire forms. SET uses the "timestamp=YYYY-MM-DDThh:mm:ss.sssZ" format the spec requires. - handleMarkread parses ":server MARKREAD {timestamp=ts|*}" into a typed MARKREAD event with timestamp: string | null. - registerReadMarkerHandlers caches the marker on the matching Channel (case-insensitive name match) or PrivateChat (username match). Adds readMarker / readMarkerFetched to the type defs. - selectChannel: when the user opens a channel, send MARKREAD SET with the latest message timestamp in that channel's history. - selectPrivateChat: PMs aren't auto-pushed by the server, so we issue a one-shot MARKREAD GET on first open to pick up any marker another session set earlier. Subsequent re-selections just push the latest timestamp via SET. - MARKREAD_FAIL projection on the FAIL stream so future UI can surface server-side rejections without filtering generic FAIL. --- src/lib/irc/IRCClient.ts | 27 +++++++++++++ src/lib/irc/handlers/auth.ts | 11 ++++++ src/lib/irc/handlers/index.ts | 3 ++ src/lib/irc/handlers/readMarker.ts | 28 ++++++++++++++ src/store/handlers/index.ts | 2 + src/store/handlers/readMarker.ts | 51 +++++++++++++++++++++++++ src/store/index.ts | 61 ++++++++++++++++++++++++++++-- src/types/index.ts | 10 +++++ 8 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 src/lib/irc/handlers/readMarker.ts create mode 100644 src/store/handlers/readMarker.ts diff --git a/src/lib/irc/IRCClient.ts b/src/lib/irc/IRCClient.ts index ed425e24..88fdeada 100644 --- a/src/lib/irc/IRCClient.ts +++ b/src/lib/irc/IRCClient.ts @@ -270,6 +270,18 @@ export interface EventMap { effective: "ON" | "OFF"; }; PERSISTENCE_FAIL: EventWithTags & { code: string; message: string }; + // draft/read-marker: server reply + // `:server MARKREAD {timestamp=|*}`. `timestamp` is null + // when the server reports "*" (no marker on file yet). + MARKREAD: BaseIRCEvent & { + target: string; + timestamp: string | null; + }; + MARKREAD_FAIL: EventWithTags & { + code: string; + target?: string; + message: string; + }; WHOIS_BOT: { serverId: string; nick: string; @@ -495,6 +507,7 @@ export class IRCClient implements IRCClientContext { "invite-notify", "monitor", "extended-monitor", + "draft/read-marker", // Note: unrealircd.org/link-security is informational only, don't request it ]; @@ -1360,6 +1373,20 @@ export class IRCClient implements IRCClientContext { this.sendRaw(serverId, `PERSISTENCE SET ${value}`); } + // draft/read-marker: ask the server for the stored marker for a + // target. Channels are auto-pushed on JOIN, so this is mostly used + // when a PM buffer is opened for the first time. + markreadGet(serverId: string, target: string): void { + this.sendRaw(serverId, `MARKREAD ${target}`); + } + + // draft/read-marker: tell the server the user has read up to + // `timestamp` in `target`. Server clamps to monotonically-increasing + // values and replies with MARKREAD echoing whatever it stored. + markreadSet(serverId: string, target: string, timestamp: string): void { + this.sendRaw(serverId, `MARKREAD ${target} timestamp=${timestamp}`); + } + // MONITOR commands monitorAdd(serverId: string, targets: string[]): void { const targetsStr = targets.join(","); diff --git a/src/lib/irc/handlers/auth.ts b/src/lib/irc/handlers/auth.ts index d7e4c7a5..4557d47e 100644 --- a/src/lib/irc/handlers/auth.ts +++ b/src/lib/irc/handlers/auth.ts @@ -39,6 +39,17 @@ export function handleFail( // draft/persistence FAIL projection else if (cmd === "PERSISTENCE") ctx.triggerEvent("PERSISTENCE_FAIL", { serverId, mtags, code, message }); + // draft/read-marker FAIL projection. The MARKREAD FAIL form has + // an optional in parv[2]; the message is whatever's left. + else if (cmd === "MARKREAD") { + ctx.triggerEvent("MARKREAD_FAIL", { + serverId, + mtags, + code, + target, + message, + }); + } } export function handleWarn( diff --git a/src/lib/irc/handlers/index.ts b/src/lib/irc/handlers/index.ts index cc01326e..9eb0af20 100644 --- a/src/lib/irc/handlers/index.ts +++ b/src/lib/irc/handlers/index.ts @@ -66,6 +66,7 @@ import { handleMonOffline, handleMonOnline, } from "./monitoring"; +import { handleMarkread } from "./readMarker"; import { handleAway, handleChghost, @@ -294,6 +295,8 @@ export const IRC_DISPATCH: Record = { handleExtjwt(ctx, serverId, source, parv, mtags), PERSISTENCE: (ctx, serverId, source, parv, mtags) => handlePersistence(ctx, serverId, source, parv, mtags), + MARKREAD: (ctx, serverId, source, parv, mtags) => + handleMarkread(ctx, serverId, source, parv, mtags), "730": (ctx, serverId, source, parv, mtags) => handleMonOnline(ctx, serverId, source, parv, mtags), diff --git a/src/lib/irc/handlers/readMarker.ts b/src/lib/irc/handlers/readMarker.ts new file mode 100644 index 00000000..c797a48a --- /dev/null +++ b/src/lib/irc/handlers/readMarker.ts @@ -0,0 +1,28 @@ +// draft/read-marker: server replies look like +// :server MARKREAD timestamp=YYYY-MM-DDThh:mm:ss.sssZ +// :server MARKREAD * +// where '*' means "no marker on file yet". We project to a typed +// MARKREAD event with `timestamp: string | null`. + +import type { IRCClientContext } from "../IRCClientContext"; + +const TS_PREFIX = "timestamp="; + +export function handleMarkread( + ctx: IRCClientContext, + serverId: string, + _source: string, + parv: string[], + _mtags: Record | undefined, +): void { + const target = parv[0]; + const tsParam = parv[1] ?? ""; + if (!target) return; + let timestamp: string | null = null; + if (tsParam && tsParam !== "*") { + timestamp = tsParam.startsWith(TS_PREFIX) + ? tsParam.slice(TS_PREFIX.length) + : tsParam; + } + ctx.triggerEvent("MARKREAD", { serverId, target, timestamp }); +} diff --git a/src/store/handlers/index.ts b/src/store/handlers/index.ts index 40c67902..72ff7d98 100644 --- a/src/store/handlers/index.ts +++ b/src/store/handlers/index.ts @@ -6,6 +6,7 @@ import { registerChannelHandlers } from "./channels"; import { registerConnectionHandlers } from "./connection"; import { registerMessageHandlers } from "./messages"; import { registerMetadataHandlers } from "./metadata"; +import { registerReadMarkerHandlers } from "./readMarker"; import { registerTicTacToeHandlers } from "./tictactoe"; import { registerUserHandlers } from "./users"; import { registerWhoisHandlers } from "./whois"; @@ -19,5 +20,6 @@ export function registerAllHandlers(store: StoreApi): void { registerMetadataHandlers(store); registerBatchHandlers(store); registerAuthHandlers(store); + registerReadMarkerHandlers(store); registerTicTacToeHandlers(store); } diff --git a/src/store/handlers/readMarker.ts b/src/store/handlers/readMarker.ts new file mode 100644 index 00000000..bf373973 --- /dev/null +++ b/src/store/handlers/readMarker.ts @@ -0,0 +1,51 @@ +// draft/read-marker: cache the per-target marker on the matching +// Channel / PrivateChat in the Zustand store. The marker is what the +// rest of the UI uses to decide which messages to count as unread, +// and what to clear notifications for. +// +// Channel matches: the target case-insensitively equals the channel +// name. +// PrivateChat matches: the target case-insensitively equals the +// other participant's username. + +import type { StoreApi } from "zustand"; +import ircClient from "../../lib/ircClient"; +import type { AppState } from "../index"; + +function eqIC(a: string, b: string): boolean { + return a.toLowerCase() === b.toLowerCase(); +} + +export function registerReadMarkerHandlers(store: StoreApi): void { + ircClient.on("MARKREAD", ({ serverId, target, timestamp }) => { + store.setState((state) => { + let touched = false; + const updatedServers = state.servers.map((server) => { + if (server.id !== serverId) return server; + let serverTouched = false; + + const channels = server.channels.map((channel) => { + if (!eqIC(channel.name, target)) return channel; + if (channel.readMarker === timestamp) return channel; + serverTouched = true; + return { ...channel, readMarker: timestamp }; + }); + + const privateChats = (server.privateChats || []).map((pc) => { + if (!eqIC(pc.username, target)) return pc; + const same = pc.readMarker === timestamp; + if (same && pc.readMarkerFetched) return pc; + serverTouched = true; + return { ...pc, readMarker: timestamp, readMarkerFetched: true }; + }); + + if (!serverTouched) return server; + touched = true; + return { ...server, channels, privateChats }; + }); + + if (!touched) return {}; + return { servers: updatedServers }; + }); + }); +} diff --git a/src/store/index.ts b/src/store/index.ts index b23a44e0..2f0b2300 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -113,6 +113,35 @@ export const getChannelMessages = (serverId: string, channelId: string) => { return state.messages[key] || []; }; +// draft/read-marker: find the latest message timestamp (across normal +// chat messages -- system / event rows are ignored so reading a "X +// joined" line doesn't move the marker). Returns an ISO 8601 string +// in the spec's format, or null if there's nothing to mark. +const ISO_DROP_END_RX = /Z?$/; +function _toMarkreadIso(d: Date): string { + // The spec mandates "YYYY-MM-DDThh:mm:ss.sssZ" -- exactly the form + // toISOString() emits. + return d.toISOString().replace(ISO_DROP_END_RX, "Z"); +} + +export function getLatestMessageTimestampIso( + serverId: string, + bufferId: string, +): string | null { + const state = useStore.getState(); + const key = `${serverId}-${bufferId}`; + const messages = state.messages[key]; + if (!messages || messages.length === 0) return null; + let latest: number | null = null; + for (const msg of messages) { + if (msg.type !== "message" && msg.type !== undefined) continue; + const t = msg.timestamp ? new Date(msg.timestamp).getTime() : Number.NaN; + if (Number.isNaN(t)) continue; + if (latest === null || t > latest) latest = t; + } + return latest === null ? null : _toMarkreadIso(new Date(latest)); +} + export const findChannelMessageById = ( serverId: string, channelId: string, @@ -1789,6 +1818,16 @@ const useStore = create((set, get) => ({ const channelName = server?.channels.find((c) => c.id === channelId)?.name || null; + // draft/read-marker: tell the server how far we've read so + // our other sessions clear their unread state too. + if ( + channelName && + server?.capabilities?.includes("draft/read-marker") + ) { + const ts = getLatestMessageTimestampIso(serverId, channelId); + if (ts) ircClient.markreadSet(serverId, channelName, ts); + } + // Update unread state in store const updatedServers = state.servers.map((server) => { if (server.id === serverId) { @@ -1979,9 +2018,21 @@ const useStore = create((set, get) => ({ // Mark private chat as read if (serverId && privateChatId) { const server = state.servers.find((s) => s.id === serverId); - const pcUsername = - server?.privateChats?.find((pc) => pc.id === privateChatId) - ?.username || null; + const pc = server?.privateChats?.find((pc) => pc.id === privateChatId); + const pcUsername = pc?.username || null; + + // draft/read-marker: PMs aren't auto-pushed by the server, + // so the first time we open one ask for its stored marker + // (so the unread badge can clear if another device already + // read past it). After that, push the latest timestamp like + // we do for channels. + if (pcUsername && server?.capabilities?.includes("draft/read-marker")) { + if (!pc?.readMarkerFetched) { + ircClient.markreadGet(serverId, pcUsername); + } + const ts = getLatestMessageTimestampIso(serverId, privateChatId); + if (ts) ircClient.markreadSet(serverId, pcUsername, ts); + } const updatedServers = state.servers.map((server) => { if (server.id === serverId) { @@ -1992,6 +2043,10 @@ const useStore = create((set, get) => ({ ...privateChat, unreadCount: 0, isMentioned: false, + // Mark as fetched so we don't spam GETs on every + // re-selection of the same PM. The reply will + // overwrite this with the actual marker. + readMarkerFetched: true, }; } return privateChat; diff --git a/src/types/index.ts b/src/types/index.ts index 88d7dfe3..c7dae368 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -102,6 +102,10 @@ export interface Channel { bans?: Array<{ mask: string; setter: string; timestamp: number }>; invites?: Array<{ mask: string; setter: string; timestamp: number }>; exceptions?: Array<{ mask: string; setter: string; timestamp: number }>; + // draft/read-marker: ISO-8601 timestamp of the latest message the + // user has marked as read in this channel (mirrored across all of + // the user's connected sessions). null = no marker on file yet. + readMarker?: string | null; } export interface PrivateChat { @@ -121,6 +125,12 @@ export interface PrivateChat { isBot?: boolean; // Bot status from WHO/WHOX or message tags isIrcOp?: boolean; // IRC operator status from WHO response (* flag) metadata?: Record; + // draft/read-marker: see Channel.readMarker. + readMarker?: string | null; + // draft/read-marker: have we issued an initial MARKREAD GET for this + // PM yet? PMs are not auto-pushed by the server, so we need to + // explicitly fetch on first open. + readMarkerFetched?: boolean; } export interface Reaction {