diff --git a/src/components/layout/ChatArea.tsx b/src/components/layout/ChatArea.tsx index 4a998498..138d44ed 100644 --- a/src/components/layout/ChatArea.tsx +++ b/src/components/layout/ChatArea.tsx @@ -41,6 +41,7 @@ import { MessageItem } from "../message/MessageItem"; import { MessageReply } from "../message/MessageReply"; import AutocompleteDropdown from "../ui/AutocompleteDropdown"; import BlankPage from "../ui/BlankPage"; +import { BouncerNetworksPanel } from "../ui/BouncerNetworksPanel"; import ChannelSettingsModal from "../ui/ChannelSettingsModal"; import ColorPicker from "../ui/ColorPicker"; import EmojiAutocompleteDropdown from "../ui/EmojiAutocompleteDropdown"; @@ -301,6 +302,7 @@ export const ChatArea: React.FC<{ ); const servers = useStore((state) => state.servers); + const bouncers = useStore((state) => state.bouncers); const ui = useStore((state) => state.ui); const globalSettings = useStore((state) => state.globalSettings); const messages = useStore((state) => state.messages); @@ -2045,11 +2047,17 @@ export const ChatArea: React.FC<{ {selectedServer && !selectedChannel && !selectedPrivateChat && - selectedChannelId !== "server-notices" && ( + selectedChannelId !== "server-notices" && + (bouncers[selectedServer.id]?.supported && + !selectedServer.bouncerNetid ? ( +
+ +
+ ) : (
- )} + ))} {!selectedServer && } {/* Keep-alive channel message lists — last 3 channels stay in DOM with diff --git a/src/components/ui/BouncerNetworkForm.tsx b/src/components/ui/BouncerNetworkForm.tsx new file mode 100644 index 00000000..57ca0ef1 --- /dev/null +++ b/src/components/ui/BouncerNetworkForm.tsx @@ -0,0 +1,324 @@ +import { Trans, useLingui } from "@lingui/react/macro"; +import type React from "react"; +import { useEffect, useMemo, useState } from "react"; +import { FaCheck, FaTimes, FaTrash } from "react-icons/fa"; +import { BOUNCER_READ_ONLY_ATTRIBUTES } from "../../lib/bouncerAttrs"; +import { TextInput } from "./TextInput"; + +export interface BouncerFormValues { + name: string; + host: string; + port: string; + tls: boolean; + nickname: string; + username: string; + realname: string; + pass: string; +} + +const EMPTY: BouncerFormValues = { + name: "", + host: "", + port: "", + tls: true, + nickname: "", + username: "", + realname: "", + pass: "", +}; + +export function attrsToValues( + attrs: Record, +): BouncerFormValues { + return { + name: attrs.name ?? "", + host: attrs.host ?? "", + port: attrs.port ?? "", + tls: attrs.tls !== "0", // default: enabled + nickname: attrs.nickname ?? "", + username: attrs.username ?? "", + realname: attrs.realname ?? "", + pass: attrs.pass ?? "", + }; +} + +// Reduce form state down to only attributes that differ from the +// originals. Empty strings are sent so server can clear values. +export function valuesToAttrs( + values: BouncerFormValues, + original?: Record, +): Record { + const out: Record = {}; + const setIfChanged = (key: keyof BouncerFormValues, wireKey: string) => { + if (BOUNCER_READ_ONLY_ATTRIBUTES.has(wireKey)) return; + const v = values[key]; + const cur = typeof v === "boolean" ? (v ? "1" : "0") : v; + if (original) { + const origCur = original[wireKey] ?? ""; + const norm = wireKey === "tls" && origCur === "" ? "1" : origCur; + if (norm === cur) return; + } + out[wireKey] = cur; + }; + setIfChanged("name", "name"); + setIfChanged("host", "host"); + setIfChanged("port", "port"); + setIfChanged("tls", "tls"); + setIfChanged("nickname", "nickname"); + setIfChanged("username", "username"); + setIfChanged("realname", "realname"); + setIfChanged("pass", "pass"); + return out; +} + +interface BouncerNetworkFormProps { + initial?: Record; + errorAttribute?: string; + errorMessage?: string; + isSaving?: boolean; + isDeleting?: boolean; + onSave: (attrs: Record) => void; + onDelete?: () => void; + onCancel: () => void; +} + +const Field: React.FC<{ + label: React.ReactNode; + error?: string; + children: React.ReactNode; + span?: 1 | 2; +}> = ({ label, error, children, span = 1 }) => ( + +); + +const inputClass = (hasError: boolean) => + `w-full px-2.5 py-1.5 rounded bg-discord-dark-400 text-discord-text-normal text-sm outline-none transition-colors border ${ + hasError + ? "border-red-500 focus:border-red-400" + : "border-transparent focus:border-primary" + }`; + +export const BouncerNetworkForm: React.FC = ({ + initial, + errorAttribute, + errorMessage, + isSaving, + isDeleting, + onSave, + onDelete, + onCancel, +}) => { + const { t } = useLingui(); + const [values, setValues] = useState(() => + initial ? attrsToValues(initial) : EMPTY, + ); + const [confirmDelete, setConfirmDelete] = useState(false); + useEffect(() => { + setValues(initial ? attrsToValues(initial) : EMPTY); + setConfirmDelete(false); + }, [initial]); + + const isEdit = !!initial; + const canSave = useMemo(() => { + if (!values.host.trim()) return false; + if (isEdit) { + const diff = valuesToAttrs(values, initial); + return Object.keys(diff).length > 0; + } + return true; + }, [values, initial, isEdit]); + + const submit = (e: React.FormEvent) => { + e.preventDefault(); + if (!canSave || isSaving) return; + onSave(valuesToAttrs(values, initial)); + }; + + const fieldError = (attr: string) => + errorAttribute === attr ? errorMessage : undefined; + + return ( +
+
+ Network Name} + error={fieldError("name")} + span={2} + > + setValues((s) => ({ ...s, name: e.target.value }))} + placeholder={t`Libera Chat`} + className={inputClass(!!fieldError("name"))} + data-testid="bouncer-form-name" + /> + + Host} error={fieldError("host")} span={2}> + setValues((s) => ({ ...s, host: e.target.value }))} + placeholder="irc.libera.chat" + required + className={inputClass(!!fieldError("host"))} + data-testid="bouncer-form-host" + /> + + Port} error={fieldError("port")}> + setValues((s) => ({ ...s, port: e.target.value }))} + placeholder={values.tls ? "6697" : "6667"} + className={inputClass(!!fieldError("port"))} + data-testid="bouncer-form-port" + /> + + Transport}> + + + Nickname} error={fieldError("nickname")}> + + setValues((s) => ({ ...s, nickname: e.target.value })) + } + placeholder={t`(inherit)`} + className={inputClass(!!fieldError("nickname"))} + /> + + Username} error={fieldError("username")}> + + setValues((s) => ({ ...s, username: e.target.value })) + } + placeholder={t`(inherit)`} + className={inputClass(!!fieldError("username"))} + /> + + Real Name} + error={fieldError("realname")} + span={2} + > + + setValues((s) => ({ ...s, realname: e.target.value })) + } + placeholder={t`(inherit)`} + className={inputClass(!!fieldError("realname"))} + /> + + Server Password (PASS)} + error={fieldError("pass")} + span={2} + > + setValues((s) => ({ ...s, pass: e.target.value }))} + placeholder={isEdit ? t`(unchanged)` : ""} + className={inputClass(!!fieldError("pass"))} + /> + +
+ + {errorMessage && !errorAttribute && ( +
+ {errorMessage} +
+ )} + +
+
+ {isEdit && + onDelete && + (confirmDelete ? ( +
+ + Delete this network? + + + +
+ ) : ( + + ))} +
+
+ + +
+
+
+ ); +}; diff --git a/src/components/ui/BouncerNetworksPanel.tsx b/src/components/ui/BouncerNetworksPanel.tsx new file mode 100644 index 00000000..09b9a8c4 --- /dev/null +++ b/src/components/ui/BouncerNetworksPanel.tsx @@ -0,0 +1,366 @@ +import { Trans, useLingui } from "@lingui/react/macro"; +import type React from "react"; +import { useEffect, useMemo, useState } from "react"; +import { + FaArrowLeft, + FaArrowRight, + FaExclamationCircle, + FaLayerGroup, + FaPencilAlt, + FaPlay, + FaPlus, +} from "react-icons/fa"; +import { v5 as uuidv5 } from "uuid"; +import useStore from "../../store"; +import type { BouncerNetwork } from "../../types"; +import { BouncerNetworkForm } from "./BouncerNetworkForm"; + +interface BouncerNetworksPanelProps { + bouncerServerId: string; +} + +type Mode = + | { kind: "list" } + | { kind: "add" } + | { kind: "edit"; netid: string }; + +const STATE_COPY: Record< + string, + { label: string; dotClass: string; pulse?: boolean } +> = { + connected: { label: "Connected", dotClass: "bg-green-400" }, + connecting: { label: "Connecting…", dotClass: "bg-yellow-400", pulse: true }, + disconnected: { label: "Disconnected", dotClass: "bg-discord-text-muted" }, +}; + +// Must mirror CHANNEL_NAMESPACE in src/store/index.ts. The bouncerConnectNetwork +// action derives child server ids by hashing (parentId, netid) under this +// namespace; we recompute it here so the row can tell whether a child binding +// already exists. +const CHILD_NAMESPACE = "6ba7b810-9dad-11d1-80b4-00c04fd430c8"; + +function rankNetwork(n: BouncerNetwork): number { + const s = n.attributes.state; + if (s === "connected") return 0; + if (s === "connecting") return 1; + if (s === "disconnected") return 2; + return 3; +} + +export const BouncerNetworksPanel: React.FC = ({ + bouncerServerId, +}) => { + const { t } = useLingui(); + const bouncer = useStore((s) => s.bouncers[bouncerServerId]); + const server = useStore((s) => + s.servers.find((srv) => srv.id === bouncerServerId), + ); + const servers = useStore((s) => s.servers); + const bouncerAddNetwork = useStore((s) => s.bouncerAddNetwork); + const bouncerChangeNetwork = useStore((s) => s.bouncerChangeNetwork); + const bouncerDelNetwork = useStore((s) => s.bouncerDelNetwork); + const bouncerConnectNetwork = useStore((s) => s.bouncerConnectNetwork); + const selectServer = useStore((s) => s.selectServer); + + const [mode, setMode] = useState({ kind: "list" }); + const [pendingFor, setPendingFor] = useState(null); + const [confirmedSuccessFor, setConfirmedSuccessFor] = useState( + null, + ); + + const networks = useMemo(() => { + if (!bouncer) return []; + return Object.values(bouncer.networks).sort((a, b) => { + const ra = rankNetwork(a); + const rb = rankNetwork(b); + if (ra !== rb) return ra - rb; + const na = a.attributes.name || a.netid; + const nb = b.attributes.name || b.netid; + return na.localeCompare(nb); + }); + }, [bouncer]); + + // Briefly highlight a row after its ADD/CHANGE/DEL has been acked. + useEffect(() => { + if (!confirmedSuccessFor) return; + const t = setTimeout(() => setConfirmedSuccessFor(null), 1400); + return () => clearTimeout(t); + }, [confirmedSuccessFor]); + + // Close the inline form after a brief optimistic delay if no error + // surfaced -- soju doesn't ack ADDNETWORK explicitly, but a missing + // FAIL within 500ms is a strong signal it succeeded. + useEffect(() => { + if (!bouncer || !pendingFor) return; + const timer = setTimeout(() => { + if (!bouncer.lastError) { + if (pendingFor !== "*") setConfirmedSuccessFor(pendingFor); + setMode({ kind: "list" }); + setPendingFor(null); + } + }, 500); + return () => clearTimeout(timer); + }, [bouncer, pendingFor]); + + const lastError = bouncer?.lastError; + const errorForForm = useMemo(() => { + if (!lastError || !pendingFor) return undefined; + if (pendingFor === "*" && lastError.subcommand !== "ADDNETWORK") + return undefined; + if ( + pendingFor !== "*" && + lastError.netid && + lastError.netid !== pendingFor && + lastError.netid !== "*" + ) + return undefined; + return lastError; + }, [lastError, pendingFor]); + + const onSubmitAdd = (attrs: Record) => { + setPendingFor("*"); + bouncerAddNetwork(bouncerServerId, attrs); + }; + const onSubmitEdit = (netid: string, attrs: Record) => { + setPendingFor(netid); + bouncerChangeNetwork(bouncerServerId, netid, attrs); + }; + const onDelete = (netid: string) => { + setPendingFor(netid); + bouncerDelNetwork(bouncerServerId, netid); + }; + const onConnectOrOpen = async (netid: string) => { + const childId = uuidv5(`${bouncerServerId}:${netid}`, CHILD_NAMESPACE); + const existing = servers.find((s) => s.id === childId); + if (existing) { + selectServer(childId, { clearSelection: true }); + return; + } + const result = await bouncerConnectNetwork(bouncerServerId, netid); + // bouncerConnectNetwork seeds an in-memory Server with the computed + // childId before resolving, so this select call lands on the new row. + selectServer(childId, { clearSelection: true }); + return result; + }; + + const editingNetwork = + mode.kind === "edit" ? bouncer?.networks[mode.netid] : undefined; + + return ( +
+
+ {mode.kind !== "list" ? ( + + ) : ( + + + + )} +
+

+ {mode.kind === "add" ? ( + Add Network + ) : mode.kind === "edit" ? ( + + Edit {editingNetwork?.attributes.name || editingNetwork?.netid} + + ) : ( + Networks on {server?.name ?? bouncerServerId} + )} +

+ {mode.kind === "list" && ( +

+ {networks.length === 0 ? ( + No upstream networks yet. + ) : ( + + {networks.length} network + {networks.length === 1 ? "" : "s"} — pick one to join + + )} +

+ )} +
+ {mode.kind === "list" && networks.length > 0 && ( + + )} +
+ +
+ {mode.kind === "list" && ( +
+ {!bouncer?.listed && networks.length === 0 ? ( +
+
+

+ Loading networks from your bouncer… +

+
+ ) : networks.length === 0 ? ( +
+ +
+ + Your bouncer doesn't have any networks yet. Add one to get + started. + +
+ +
+ ) : ( +
    + {networks.map((net) => { + const stateKey = net.attributes.state ?? "disconnected"; + const visual = + STATE_COPY[stateKey] ?? STATE_COPY.disconnected; + const isHighlighted = confirmedSuccessFor === net.netid; + const childId = uuidv5( + `${bouncerServerId}:${net.netid}`, + CHILD_NAMESPACE, + ); + const childServer = servers.find((s) => s.id === childId); + const childOpen = !!childServer; + return ( +
  • + + + {visual.pulse && ( + + )} + +
    +
    + + {net.attributes.name || net.netid} + + + {visual.label} + +
    +
    + {net.attributes.host || no host set} + {net.attributes.port ? `:${net.attributes.port}` : ""} + {net.attributes.nickname + ? ` · ${net.attributes.nickname}` + : ""} +
    + {net.attributes.error && ( +
    + + + {net.attributes.error} + +
    + )} +
    + + +
  • + ); + })} +
+ )} +
+ )} + + {mode.kind === "add" && ( + { + setMode({ kind: "list" }); + setPendingFor(null); + }} + /> + )} + + {mode.kind === "edit" && editingNetwork && ( + onSubmitEdit(editingNetwork.netid, attrs)} + onDelete={() => onDelete(editingNetwork.netid)} + onCancel={() => { + setMode({ kind: "list" }); + setPendingFor(null); + }} + /> + )} +
+
+ ); +}; + +export default BouncerNetworksPanel; diff --git a/src/components/ui/LinkSecurityWarningModal.tsx b/src/components/ui/LinkSecurityWarningModal.tsx index 3240d6dd..b70ae9b6 100644 --- a/src/components/ui/LinkSecurityWarningModal.tsx +++ b/src/components/ui/LinkSecurityWarningModal.tsx @@ -122,7 +122,7 @@ const SingleWarningModal: React.FC = ({ removeWarning(); // Resume connection by sending CAP END - ircClient.sendRaw(serverId, "CAP END"); + ircClient.sendCapEnd(serverId); ircClient.userOnConnect(serverId); }; diff --git a/src/lib/irc/IRCClient.ts b/src/lib/irc/IRCClient.ts index d5051d6e..9ac494e9 100644 --- a/src/lib/irc/IRCClient.ts +++ b/src/lib/irc/IRCClient.ts @@ -532,6 +532,11 @@ export class IRCClient implements IRCClientContext { new Map(); private pendingConnections: Map> = new Map(); private pendingCapReqs: Map = new Map(); // Track how many CAP REQ batches are pending ACK + // soju.im/bouncer-networks BIND: when a serverId is mapped here, the + // next CAP END for that server is preceded by a `BOUNCER BIND ` + // line so the connection lands inside the named upstream network's + // session rather than the bouncer's control channel. + private pendingBouncerBind: Map = new Map(); capNegotiationComplete: Map = new Map(); // Track if CAP negotiation is complete private reconnectionAttempts: Map = new Map(); // Track reconnection attempts per server reconnectionTimeouts: Map = new Map(); // Track reconnection timeouts per server @@ -641,7 +646,11 @@ export class IRCClient implements IRCClientContext { serverId?: string, oauthBearerEnabled?: boolean, ): Promise { - const connectionKey = `${host}:${port}`; + // Bouncer child connections share host:port with the control + // connection (and with each other). Scope the pending-connection + // dedup by serverId when one is provided so each child gets its + // own promise rather than landing on a sibling's. + const connectionKey = serverId ?? `${host}:${port}`; // Check if there's already a pending connection to this server const existingConnection = this.pendingConnections.get(connectionKey); @@ -1517,6 +1526,24 @@ export class IRCClient implements IRCClientContext { bouncerBind(serverId: string, netid: string): void { this.sendRaw(serverId, `BOUNCER BIND ${netid}`); } + // Mark a server connection as a bouncer-child for the upcoming CAP + // negotiation. The next sendCapEnd() will emit `BOUNCER BIND ` + // immediately before `CAP END`. Call this BEFORE the WS opens (or at + // least before CAP LS arrives) so we never miss the bind window. + setPendingBouncerBind(serverId: string, netid: string): void { + this.pendingBouncerBind.set(serverId, netid); + } + // Centralised CAP END sender. All call sites should use this instead + // of raw sendRaw("CAP END") so the BIND-before-end invariant is + // enforced in one place. + sendCapEnd(serverId: string): void { + const netid = this.pendingBouncerBind.get(serverId); + if (netid) { + this.pendingBouncerBind.delete(serverId); + this.sendRaw(serverId, `BOUNCER BIND ${netid}`); + } + this.sendRaw(serverId, "CAP END"); + } bouncerAddNetwork(serverId: string, encodedAttrs: string): void { this.sendRaw(serverId, `BOUNCER ADDNETWORK ${encodedAttrs}`); } @@ -1916,7 +1943,7 @@ export class IRCClient implements IRCClientContext { console.log( `[CAP TIMEOUT] Timeout reached for ${serverId}, ending CAP negotiation`, ); - this.sendRaw(serverId, "CAP END"); + this.sendCapEnd(serverId); this.capNegotiationComplete.set(serverId, true); this.userOnConnect(serverId); } @@ -1931,7 +1958,7 @@ export class IRCClient implements IRCClientContext { console.log( `[CAP LS] No capabilities to request for ${serverId}, ending CAP negotiation`, ); - this.sendRaw(serverId, "CAP END"); + this.sendCapEnd(serverId); this.capNegotiationComplete.set(serverId, true); this.userOnConnect(serverId); } @@ -2003,7 +2030,7 @@ export class IRCClient implements IRCClientContext { ); } else { // No SASL or SASL not acknowledged - complete CAP negotiation now - this.sendRaw(serverId, "CAP END"); + this.sendCapEnd(serverId); this.capNegotiationComplete.set(serverId, true); this.userOnConnect(serverId); } diff --git a/src/lib/irc/IRCClientContext.ts b/src/lib/irc/IRCClientContext.ts index ce8418c2..6b281688 100644 --- a/src/lib/irc/IRCClientContext.ts +++ b/src/lib/irc/IRCClientContext.ts @@ -35,6 +35,10 @@ export interface IRCClientContext { // Public methods sendRaw(serverId: string, command: string): void; + // CAP END that first emits a queued `BOUNCER BIND ` if this + // serverId was marked as a bouncer child. Centralised so the + // bind-before-end invariant lives in one place. + sendCapEnd(serverId: string): void; triggerEvent(event: K, data: EventMap[K]): void; // Private methods exposed for handlers diff --git a/src/lib/irc/handlers/connection.ts b/src/lib/irc/handlers/connection.ts index 8f567f5c..1cf11de4 100644 --- a/src/lib/irc/handlers/connection.ts +++ b/src/lib/irc/handlers/connection.ts @@ -173,7 +173,7 @@ export function handleCap( else if (subcommand === "ACK") { ctx.onCapAck(serverId, caps); } else if (subcommand === "NAK") { - ctx.sendRaw(serverId, "CAP END"); + ctx.sendCapEnd(serverId); ctx.capNegotiationComplete.set(serverId, true); } else if (subcommand === "NEW") ctx.onCapNew(serverId, caps); else if (subcommand === "DEL") ctx.onCapDel(serverId, caps); @@ -198,7 +198,7 @@ export function handleSaslSuccess( _source: string, _parv: string[], ): void { - ctx.sendRaw(serverId, "CAP END"); + ctx.sendCapEnd(serverId); ctx.capNegotiationComplete.set(serverId, true); ctx.userOnConnect(serverId); } @@ -209,7 +209,7 @@ export function handleSaslFailure( _source: string, _parv: string[], ): void { - ctx.sendRaw(serverId, "CAP END"); + ctx.sendCapEnd(serverId); ctx.capNegotiationComplete.set(serverId, true); ctx.userOnConnect(serverId); } diff --git a/src/locales/cs/messages.mjs b/src/locales/cs/messages.mjs index e4f5a162..ef9118c7 100644 --- a/src/locales/cs/messages.mjs +++ b/src/locales/cs/messages.mjs @@ -1 +1 @@ -/*eslint-disable*/export const messages=JSON.parse("{\"+5VMrz\":[\"Neplatný formát vzoru. Použijte formát nick!user@host (jsou povoleny zástupné znaky *)\"],\"+6NQQA\":[\"Obecný podpůrný kanál\"],\"+6NyRG\":[\"Klient\"],\"+K0AvT\":[\"Odpojit\"],\"+cyFdH\":[\"Výchozí zpráva při označení nepřítomnosti\"],\"+mVPqU\":[\"Zobrazovat Markdown formátování ve zprávách\"],\"+vqCJH\":[\"Uživatelské jméno vašeho účtu pro ověření\"],\"+yPBXI\":[\"Vybrat soubor\"],\"+zy2Nq\":[\"Typ\"],\"/09cao\":[\"Nízká bezpečnost připojení (Úroveň \",[\"securityLevel\"],\")\"],\"/3BQ4J\":[\"Uživatelé mimo kanál nemohou odesílat zprávy do něj\"],\"/6BzZF\":[\"Přepnout seznam členů\"],\"/TNOPk\":[\"Uživatel je nepřítomen\"],\"/XQgft\":[\"Objevovat\"],\"/cF7Rs\":[\"Hlasitost\"],\"/dqduX\":[\"Další stránka\"],\"/fc3q4\":[\"Veškerý obsah\"],\"/kISDh\":[\"Povolit zvuky upozornění\"],\"/n04sB\":[\"Kill\"],\"/rTz0M\":[\"Zvuk\"],\"/rfkZe\":[\"Přehrávat zvuky pro zmínky a zprávy\"],\"0/0ZGA\":[\"Maska názvu kanálu\"],\"0D6j7U\":[\"Zjistit více o vlastních pravidlech →\"],\"0XsHcR\":[\"Vyhodit uživatele\"],\"0ZpE//\":[\"Seřadit podle uživatelů\"],\"0bEPwz\":[\"Nastavit nepřítomnost\"],\"0dGkPt\":[\"Rozbalit seznam kanálů\"],\"0gS7M5\":[\"Zobrazované jméno\"],\"0kS+M8\":[\"PříkladSÍŤ\"],\"0rgoY7\":[\"Připojovat se pouze k serverům, které si vyberete\"],\"0wdd7X\":[\"Připojit se\"],\"0wkVYx\":[\"Soukromé zprávy\"],\"111uHX\":[\"Náhled odkazu\"],\"196EG4\":[\"Smazat soukromý chat\"],\"1DSr1i\":[\"Zaregistrovat účet\"],\"1O/24y\":[\"Přepnout seznam kanálů\"],\"1VPJJ2\":[\"Varování o externím odkazu\"],\"1ZC/dv\":[\"Žádné nepřečtené zmínky ani zprávy\"],\"1pO1zi\":[\"Název serveru je povinný\"],\"1uwfzQ\":[\"Zobrazit téma kanálu\"],\"268g7c\":[\"Zadejte zobrazované jméno\"],\"2FOFq1\":[\"Operátoři serveru v síti by potenciálně mohli číst vaše zprávy\"],\"2FYpfJ\":[\"Více\"],\"2HF1Y2\":[[\"inviter\"],\" pozval \",[\"target\"],\" k připojení do \",[\"channel\"]],\"2I70QL\":[\"Zobrazit informace o profilu uživatele\"],\"2QYdmE\":[\"Uživatelé:\"],\"2QpEjG\":[\"odešel\"],\"2YE223\":[\"Zpráva #\",[\"0\"],\" (Enter pro nový řádek, Shift+Enter pro odeslání)\"],\"2bimFY\":[\"Použít heslo serveru\"],\"2iTmdZ\":[\"Místní úložiště:\"],\"2odkwe\":[\"Přísný - agresivnější ochrana\"],\"2uDhbA\":[\"Zadejte uživatelské jméno pro pozvání\"],\"2ygf/L\":[\"← Zpět\"],\"2zEgxj\":[\"Hledat GIFy...\"],\"3RdPhl\":[\"Přejmenovat kanál\"],\"3THokf\":[\"Uživatel s hlasem\"],\"3TSz9S\":[\"Minimalizovat\"],\"3jBDvM\":[\"Zobrazovaný název kanálu\"],\"3ryuFU\":[\"Volitelné zprávy o pádu pro zlepšení aplikace\"],\"3uBF/8\":[\"Zavřít prohlížeč\"],\"3uwW8F\":[\"https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"3xf8Kz\":[\"Zadejte název účtu...\"],\"4/Rr0R\":[\"Pozvat uživatele do aktuálního kanálu\"],\"4EZrJN\":[\"Pravidla\"],\"4JJtW9\":[\"#přetečení\"],\"4NqeT4\":[\"Profil floodingu (+F)\"],\"4RZQRK\":[\"Co teď děláš?\"],\"4hfTrB\":[\"Přezdívka\"],\"4n99LO\":[\"Již v \",[\"0\"]],\"4t6vMV\":[\"Automaticky přepnout na jeden řádek pro krátké zprávy\"],\"4vsHmf\":[\"Čas (min)\"],\"5+INAX\":[\"Zvýrazňovat zprávy, které vás zmiňují\"],\"5R5Pv/\":[\"Jméno operátora\"],\"678PKt\":[\"Název sítě\"],\"6Aih4U\":[\"Offline\"],\"6CO3WE\":[\"Heslo nutné pro vstup do kanálu. Nechte prázdné pro odstranění klíče.\"],\"6HhMs3\":[\"Zpráva při odpojení\"],\"6V3Ea3\":[\"Zkopírováno\"],\"6lGV3K\":[\"Zobrazit méně\"],\"6yFOEi\":[\"Zadejte heslo opera...\"],\"7+IHTZ\":[\"Žádný soubor nevybrán\"],\"73hrRi\":[\"nick!user@host (např. spam*!*@*, *!*@badhost.com)\"],\"7QkKyN\":[\"Odeslat soukromou zprávu\"],\"7U1W7c\":[\"Velmi uvolněný\"],\"7Y1YQj\":[\"Skutečné jméno:\"],\"7YHArF\":[\"— otevřít v prohlížeči\"],\"7fjnVl\":[\"Hledat uživatele...\"],\"7jL88x\":[\"Smazat tuto zprávu? Tuto akci nelze vrátit zpět.\"],\"7nGhhM\":[\"Na co myslíte?\"],\"7sEpu1\":[\"Členové — \",[\"0\"]],\"7sNhEz\":[\"Uživatelské jméno\"],\"8H0Q+x\":[\"Zjistit více o profilech →\"],\"8Phu0A\":[\"Zobrazovat, když uživatelé mění přezdívky\"],\"8XTG9e\":[\"Zadejte heslo operátora\"],\"8XsV2J\":[\"Zkusit odeslat znovu\"],\"8ZsakT\":[\"Heslo\"],\"8kR84m\":[\"Chystáte se otevřít externí odkaz:\"],\"8lCgih\":[\"Odebrat pravidlo\"],\"8p/xVT\":[[\"0\",\"plural\",{\"one\":[[\"1\"]],\"other\":[[\"2\"]]}]],\"8wRzac\":[[\"joinCount\",\"plural\",{\"one\":[\"připojil se\"],\"few\":[\"připojil se \",[\"joinCount\"],\"×\"],\"many\":[\"připojil se \",[\"joinCount\"],\"×\"],\"other\":[\"připojil se \",[\"joinCount\"],\"×\"]}]],\"9BMLnJ\":[\"Znovu připojit k serveru\"],\"9OEgyT\":[\"Přidat reakci\"],\"9PQ8m2\":[\"G-Line (globální ban)\"],\"9Qs99X\":[\"E-mail:\"],\"9QupBP\":[\"Odebrat vzor\"],\"9bG48P\":[\"Odesílání\"],\"9f5f0u\":[\"Otázky ohledně soukromí? Kontaktujte nás:\"],\"9unqs3\":[\"Nepřítomen:\"],\"9v3hwv\":[\"Nebyly nalezeny žádné servery.\"],\"9zb2WA\":[\"Připojování\"],\"A1taO8\":[\"Hledat\"],\"A2adVi\":[\"Odesílat oznámení o psaní\"],\"A9Rhec\":[\"Název kanálu\"],\"AWOSPo\":[\"Přiblížit\"],\"AXSpEQ\":[\"Operátor při připojení\"],\"AeXO77\":[\"Účet\"],\"AhNP40\":[\"Přetočit\"],\"Ai2U7L\":[\"Hostitel\"],\"AjBQnf\":[\"Změněna přezdívka\"],\"AmXVh6\":[\"https://example.com/avatar.png\"],\"AnRu/j\":[\"Zrušit odpověď\"],\"ApSx0O\":[\"Nalezeno \",[\"0\"],\" zpráv odpovídajících \\\"\",[\"searchQuery\"],\"\\\"\"],\"AxPAXW\":[\"Žádné výsledky nenalezeny\"],\"AyNqAB\":[\"Zobrazit všechny události serveru v chatu\"],\"B/QqGw\":[\"Pryč od klávesnice\"],\"B8AaMI\":[\"Toto pole je povinné\"],\"BA2c49\":[\"Server nepodporuje pokročilé filtrování LIST\"],\"BDKt3I\":[[\"0\"],\", \",[\"1\"],\", \",[\"2\"],\" a \",[\"3\"],\" dalších píší...\"],\"BGul2A\":[\"Máte neuložené změny. Opravdu chcete zavřít bez uložení?\"],\"BIf9fi\":[\"Vaše stavová zpráva\"],\"BZz3md\":[\"Vaše osobní webová stránka\"],\"Bgm/H7\":[\"Povolit zadávání více řádků textu\"],\"BiQIl1\":[\"Připnout tuto soukromou konverzaci\"],\"BlNZZ2\":[\"Klikněte pro přechod na zprávu\"],\"Bowq3c\":[\"Téma kanálu mohou měnit pouze operátoři\"],\"Btozzp\":[\"Platnost tohoto obrázku vypršela\"],\"Bycfjm\":[\"Celkem: \",[\"0\"]],\"C6IBQc\":[\"Kopírovat celý JSON\"],\"C9L9wL\":[\"Sběr dat\"],\"CDq4wC\":[\"Moderovat uživatele\"],\"CHVRxG\":[\"Zpráva @\",[\"0\"],\" (Shift+Enter pro nový řádek)\"],\"CN9zdR\":[\"Jméno a heslo operátora jsou povinné\"],\"CW3sYa\":[\"Přidat reakci \",[\"emoji\"]],\"CaAkqd\":[\"Zobrazit odchody\"],\"CbvaYj\":[\"Ban podle přezdívky\"],\"CcK+Ft\":[\"PDF\"],\"Ce8q3L\":[\"Vybrat kanál\"],\"CsekCi\":[\"Normální\"],\"D+NlUC\":[\"Systém\"],\"D28t6+\":[\"se připojil a odpojil\"],\"DB8zMK\":[\"Použít\"],\"DBcWHr\":[\"Vlastní soubor zvuku oznámení\"],\"DTy9Xw\":[\"Náhledy médií\"],\"Dj4pSr\":[\"Zvolte bezpečné heslo\"],\"Du+zn+\":[\"Hledám...\"],\"Du2T2f\":[\"Nastavení nenalezeno\"],\"DwsSVQ\":[\"Použít filtry a obnovit\"],\"E3W/zd\":[\"Výchozí přezdívka\"],\"E6nRW7\":[\"Kopírovat URL\"],\"E703RG\":[\"Režimy:\"],\"EAeu1Z\":[\"Odeslat pozvánku\"],\"EFKJQT\":[\"Nastavení\"],\"EGPQBv\":[\"Vlastní pravidla floodingu (+f)\"],\"ELik0r\":[\"Zobrazit úplné zásady ochrany soukromí\"],\"EPbeC2\":[\"Zobrazit nebo upravit téma kanálu\"],\"EQCDNT\":[\"Zadejte uživatelské jméno opera...\"],\"EUvulZ\":[\"Nalezena 1 zpráva odpovídající \\\"\",[\"searchQuery\"],\"\\\"\"],\"EatZYJ\":[\"Další obrázek\"],\"EdQY6l\":[\"Žádné\"],\"EnqLYU\":[\"Hledat servery...\"],\"F0OKMc\":[\"Upravit server\"],\"F6Int2\":[\"Povolit zvýraznění\"],\"FDoLyE\":[\"Max. uživatelů\"],\"FUU/hZ\":[\"Kontrolujte, kolik externích médií se načítá v chatu.\"],\"Fdp03t\":[\"zap\"],\"FfPWR0\":[\"Modální okno\"],\"FjkaiT\":[\"Oddálit\"],\"FlqOE9\":[\"Co to znamená:\"],\"FolHNl\":[\"Spravujte svůj účet a ověřování\"],\"Fp2Dif\":[\"Opustit server\"],\"G5KmCc\":[\"GZ-Line (globální Z-Line)\"],\"GDs0lz\":[\"<0>Riziko: Citlivé informace (zprávy, soukromé konverzace, přihlašovací údaje) mohou být přístupné správcům sítě nebo útočníkům mezi IRC servery.\"],\"GR+2I3\":[\"Přidat masku pozvánky (např. nick!*@*, *!*@host.com)\"],\"GRLyMU\":[\"Zavřít vyskočená serverová oznámení\"],\"GlHnXw\":[\"Změna přezdívky se nezdařila: \",[\"error\"],\" \",[\"0\"]],\"GswZF3\":[\"Náhled:\"],\"GtmO8/\":[\"od\"],\"GtuHUQ\":[\"Přejmenovat tento kanál na serveru. Nový název uvidí všichni uživatelé.\"],\"GuGfFX\":[\"Přepnout hledání\"],\"GxkJXS\":[\"Nahrávám...\"],\"GzbwnK\":[\"Připojil se ke kanálu\"],\"GzsUDB\":[\"Rozšířený profil\"],\"H/PnT8\":[\"Vložit emoji\"],\"H6Izzl\":[\"Váš preferovaný kód barvy\"],\"H9jIv+\":[\"Zobrazit připojení/odchody\"],\"HAKBY9\":[\"Nahrát soubory\"],\"HdE1If\":[\"Kanál\"],\"Hk4AW9\":[\"Vaše preferované zobrazované jméno\"],\"HmHDk7\":[\"Vybrat člena\"],\"HrQzPU\":[\"Kanály na \",[\"networkName\"]],\"I2tXQ5\":[\"Zpráva @\",[\"0\"],\" (Enter pro nový řádek, Shift+Enter pro odeslání)\"],\"I6bw/h\":[\"Zabanovat uživatele\"],\"I92Z+b\":[\"Povolit upozornění\"],\"I9D72S\":[\"Opravdu chcete tuto zprávu smazat? Tuto akci nelze vrátit zpět.\"],\"IA+1wo\":[\"Zobrazovat, když jsou uživatelé vyhozeni z kanálů\"],\"IDwkJx\":[\"IRC operátor\"],\"ILlU+s\":[\"Info:\"],\"IUwGEM\":[\"Uložit změny\"],\"IVeGK6\":[[\"0\"],\", \",[\"1\"],\" a \",[\"2\"],\" píší...\"],\"IgrLD/\":[\"Pauza\"],\"Im6JED\":[\"ŠEPOT\"],\"ImOQa9\":[\"Odpovědět\"],\"IoHMnl\":[\"Maximální hodnota je \",[\"0\"]],\"IvMj+0\":[\"Op\"],\"J28zul\":[\"Připojování...\"],\"J5T9NW\":[\"Informace o uživateli\"],\"J8Y5+z\":[\"Jejda! Síť se rozdělila! ⚠️\"],\"JBHkBA\":[\"Opustil kanál\"],\"JCwL0Q\":[\"Zadejte důvod (volitelné)\"],\"JFciKP\":[\"Přepnout\"],\"JXGkhG\":[\"Změnit název kanálu (pouze operátoři)\"],\"JcD7qf\":[\"Více akcí\"],\"JdkA+c\":[\"Tajný (+s)\"],\"Jmu12l\":[\"Kanály serveru\"],\"JvQ++s\":[\"Povolit Markdown\"],\"K2jwh/\":[\"Data WHOIS nejsou k dispozici\"],\"KAXSwC\":[\"Hlas\"],\"KDfTdX\":[\"Smazat zprávu\"],\"KKBlUU\":[\"Vložit\"],\"KM0pLb\":[\"Vítejte v kanálu!\"],\"KR6W2h\":[\"Přestat ignorovat uživatele\"],\"KV+Bi1\":[\"Pouze na pozvání (+i)\"],\"KdCtwE\":[\"Kolik sekund sledovat floodingovou aktivitu před resetováním čítačů\"],\"Kkezga\":[\"Heslo serveru\"],\"KsiQ/8\":[\"Uživatelé musí být pozváni k připojení do kanálu\"],\"L+gB/D\":[\"Informace o kanálu\"],\"LC1a7n\":[\"IRC server oznámil, že jeho meziservery mají nízkou úroveň zabezpečení. To znamená, že když jsou vaše zprávy přeposílány mezi IRC servery v síti, nemusí být správně šifrovány nebo SSL/TLS certifikáty nemusí být správně ověřovány.\"],\"LNfLR5\":[\"Zobrazit vykopnutí\"],\"LQb0W/\":[\"Zobrazit všechny události\"],\"LU7/yA\":[\"Alternativní název pro zobrazení v rozhraní. Může obsahovat mezery, emoji a speciální znaky. Skutečný název kanálu (\",[\"channelName\"],\") bude nadále používán pro IRC příkazy.\"],\"LUb9O7\":[\"Je vyžadován platný port serveru\"],\"Lb+BUl\":[\"https://example.com/avatar.jpg\"],\"LcET2C\":[\"Zásady ochrany soukromí\"],\"LcuSDR\":[\"Spravujte informace profilu a metadata\"],\"LqLS9B\":[\"Zobrazit změny přezdívek\"],\"LsDQt2\":[\"Nastavení kanálu\"],\"LtI9AS\":[\"Vlastník\"],\"LuNhhL\":[\"reagoval na tuto zprávu\"],\"M/AZNG\":[\"URL vašeho avatara\"],\"M/WIer\":[\"Odeslat zprávu\"],\"M8er/5\":[\"Název:\"],\"MHk+7g\":[\"Předchozí obrázek\"],\"MRorGe\":[\"Soukromá zpráva uživateli\"],\"MVbSGP\":[\"Časové okno (sekundy)\"],\"MkpcsT\":[\"Vaše zprávy a nastavení jsou uloženy lokálně na vašem zařízení\"],\"N/hDSy\":[\"Označit jako bot - obvykle 'on' nebo prázdné\"],\"N7TQbE\":[\"Pozvat uživatele do \",[\"channelName\"]],\"NCca/o\":[\"Zadejte výchozí přezdívku...\"],\"Nqs6B9\":[\"Zobrazuje veškerá externí média. Libovolná URL může způsobit požadavek na neznámý server.\"],\"Nt+9O7\":[\"Použít WebSocket místo surového TCP\"],\"NxIHzc\":[\"Odpojit uživatele\"],\"O+v/cL\":[\"Procházet všechny kanály na serveru\"],\"ODwSCk\":[\"Odeslat GIF\"],\"OGQ5kK\":[\"Konfigurovat zvuky upozornění a zvýraznění\"],\"OIPt1Z\":[\"Zobrazit nebo skrýt boční panel se seznamem členů\"],\"OKSNq/\":[\"Velmi přísný\"],\"ONWvwQ\":[\"Nahrát\"],\"OVKoQO\":[\"Heslo vašeho účtu pro ověření\"],\"ObsidianIRC - Bringing IRC to the future\":[\"ObsidianIRC - Přinášíme IRC do budoucnosti\"],\"OhCpra\":[\"Nastavit téma…\"],\"OkltoQ\":[\"Zabanovat \",[\"username\"],\" podle přezdívky (zabrání opětovnému připojení se stejným nickem)\"],\"P+t/Te\":[\"Žádné další údaje\"],\"P42Wcc\":[\"Bezpečné\"],\"PD38l0\":[\"Náhled avatara kanálu\"],\"PD9mEt\":[\"Napište zprávu...\"],\"PPqfdA\":[\"Otevřít nastavení konfigurace kanálu\"],\"PSCjfZ\":[\"Téma, které bude zobrazeno pro tento kanál. Téma mohou vidět všichni uživatelé.\"],\"PZCecv\":[\"Náhled PDF\"],\"PeLgsC\":[[\"c\",\"plural\",{\"one\":[\"1×\"],\"few\":[[\"c\"],\"×\"],\"many\":[[\"c\"],\"×\"],\"other\":[[\"c\"],\"×\"]}]],\"PguS2C\":[\"Přidat masku výjimky (např. nick!*@*, *!*@host.com)\"],\"Pil5Ty\":[\"Zobrazeno \",[\"displayedChannelsCount\"],\" z \",[\"0\"],\" kanálů\"],\"PqhVlJ\":[\"Zabanovat uživatele (podle masky hostitele)\"],\"Q+chwU\":[\"Uživatelské jméno:\"],\"Q6hhn8\":[\"Předvolby\"],\"QF4a34\":[\"Zadejte prosím uživatelské jméno\"],\"QGqSZ2\":[\"Barva a formátování\"],\"QJQd1J\":[\"Upravit profil\"],\"QSzGDE\":[\"Nečinný\"],\"QUlny5\":[\"Vítejte v \",[\"0\"],\"!\"],\"Qoq+GP\":[\"Číst více\"],\"QuSkCF\":[\"Filtrovat kanály...\"],\"QwUrDZ\":[\"změnil téma na: \",[\"topic\"]],\"R0UH07\":[\"Obrázek \",[\"0\"],\" z \",[\"1\"]],\"R7SsBE\":[\"Ztlumit\"],\"R8rf1X\":[\"Klikněte pro nastavení tématu\"],\"RArB3D\":[\"byl vyhozen z \",[\"channelName\"],\" uživatelem \",[\"username\"]],\"RI3cWd\":[\"Objevte svět IRC s ObsidianIRC\"],\"RMMaN5\":[\"Moderovaný (+m)\"],\"RWw9Lg\":[\"Zavřít okno\"],\"RZ2BuZ\":[\"Registrace účtu \",[\"account\"],\" vyžaduje ověření: \",[\"message\"]],\"RySp6q\":[\"Skrýt komentáře\"],\"SPKQTd\":[\"Přezdívka je povinná\"],\"SPVjfj\":[\"Výchozí bude 'bez důvodu', pokud ponecháte prázdné\"],\"SQKPvQ\":[\"Pozvat uživatele\"],\"SkZcl+\":[\"Vyberte předdefinovaný profil ochrany před floodem. Tyto profily poskytují vyvážená nastavení ochrany pro různé případy použití.\"],\"Slr+3C\":[\"Min. uživatelů\"],\"Spnlre\":[\"Pozval jste \",[\"target\"],\" k připojení do \",[\"channel\"]],\"T/ckN5\":[\"Otevřít v prohlížeči\"],\"T91vKp\":[\"Přehrát\"],\"TV2Wdu\":[\"Zjistěte, jak nakládáme s vašimi daty a chráníme vaše soukromí.\"],\"TgFpwD\":[\"Používám...\"],\"TkzSFB\":[\"Žádné změny\"],\"TtserG\":[\"Zadejte skutečné jméno\"],\"Ttz9J1\":[\"Zadejte heslo...\"],\"Tz0i8g\":[\"Nastavení\"],\"U3pytU\":[\"Admin\"],\"UDb2YD\":[\"React\"],\"UE4KO5\":[\"*kanál*\"],\"UGT5vp\":[\"Uložit nastavení\"],\"UV5hLB\":[\"Nenalezeny žádné zákazy\"],\"Uaj3Nd\":[\"Stavové zprávy\"],\"Ue3uny\":[\"Výchozí (bez profilu)\"],\"UkARhe\":[\"Normální - standardní ochrana\"],\"Umn7Cj\":[\"Zatím žádné komentáře. Buďte první!\"],\"UtUIRh\":[[\"0\"],\" starších zpráv\"],\"UwzP+U\":[\"Zabezpečené připojení\"],\"V0/A4O\":[\"Vlastník kanálu\"],\"V4qgxE\":[\"Vytvořeno před (min. zpět)\"],\"V8yTm6\":[\"Vymazat hledání\"],\"VJMMyz\":[\"ObsidianIRC - Přinášíme IRC do budoucnosti\"],\"VJScHU\":[\"Důvod\"],\"VLsmVV\":[\"Ztlumit upozornění\"],\"VbyRUy\":[\"Komentáře\"],\"Vmx0mQ\":[\"Nastaveno:\"],\"VqnIZz\":[\"Zobrazit naše zásady ochrany soukromí a práci s daty\"],\"VrMygG\":[\"Minimální délka je \",[\"0\"]],\"VrnTui\":[\"Vaše zájmena, zobrazená ve vašem profilu\"],\"W8E3qn\":[\"Ověřený účet\"],\"WAakm9\":[\"Smazat kanál\"],\"WFxTHC\":[\"Přidat masku banu (např. nick!*@*, *!*@host.com)\"],\"WN1g9F\":[\"Hostitel serveru je povinný\"],\"WRYdXW\":[\"Pozice zvuku\"],\"WUOH5B\":[\"Ignorovat uživatele\"],\"WWEXnZ\":[[\"0\",\"plural\",{\"one\":[\"Zobrazit 1 další položku\"],\"few\":[\"Zobrazit \",[\"1\"],\" další položky\"],\"many\":[\"Zobrazit \",[\"1\"],\" dalších položek\"],\"other\":[\"Zobrazit \",[\"1\"],\" dalších položek\"]}]],\"Weq9zb\":[\"Obecné\"],\"Wfj7Sk\":[\"Ztlumit nebo zapnout zvuky upozornění\"],\"Wm7gbG\":[\"GitHub:\"],\"WyeHWY\":[\"*spam*\"],\"WzMCru\":[\"Profil uživatele\"],\"X6S3lt\":[\"Hledat nastavení, kanály, servery...\"],\"XEHan5\":[\"Přesto pokračovat\"],\"XI1+wb\":[\"Neplatný formát\"],\"XIXeuC\":[\"Zpráva @\",[\"0\"]],\"XMS+k4\":[\"Začít soukromou zprávu\"],\"XWgxXq\":[\"Album\"],\"Xd7+IT\":[\"Odepnout soukromou konverzaci\"],\"Xm/s+u\":[\"Zobrazení\"],\"Xp2n93\":[\"Zobrazuje média z důvěryhodného file hostu vašeho serveru. Nejsou prováděny žádné požadavky na externí služby.\"],\"XvjC4F\":[\"Ukládám...\"],\"Y/qryO\":[\"Nebyly nalezeni žádní uživatelé odpovídající vašemu vyhledávání\"],\"YAqRpI\":[\"Registrace účtu \",[\"account\"],\" proběhla úspěšně: \",[\"message\"]],\"YEfzvP\":[\"Chráněné téma (+t)\"],\"YQOn6a\":[\"Sbalit seznam členů\"],\"YRCoE9\":[\"Operátor kanálu\"],\"YURQaF\":[\"Zobrazit profil\"],\"YdBSvr\":[\"Ovládat zobrazení médií a externího obsahu\"],\"Yj6U3V\":[\"Bez centrálního serveru:\"],\"YjvpGx\":[\"Zájmena\"],\"YqH4l4\":[\"Bez klíče\"],\"YyUPpV\":[\"Účet:\"],\"ZJSWfw\":[\"Zpráva zobrazená při odpojení od serveru\"],\"ZR1dJ4\":[\"Pozvánky\"],\"ZdWg0V\":[\"Otevřít v prohlížeči\"],\"ZhRBbl\":[\"Hledat zprávy…\"],\"Zmcu3y\":[\"Pokročilé filtry\"],\"a2/8e5\":[\"Téma nastaveno po (min)\"],\"aHKcKc\":[\"Předchozí stránka\"],\"aJTbXX\":[\"Heslo operátora\"],\"aQryQv\":[\"Vzor již existuje\"],\"aW9pLN\":[\"Maximální počet uživatelů povolených v kanálu. Nechte prázdné pro žádný limit.\"],\"ah4fmZ\":[\"Zobrazuje také náhledy z YouTube, Vimeo, SoundCloud a podobných známých služeb.\"],\"aifXak\":[\"V tomto kanálu nejsou žádná média\"],\"ap2zBz\":[\"Uvolněný\"],\"az8lvo\":[\"Vypnuto\"],\"azXSNo\":[\"Rozbalit seznam členů\"],\"azdliB\":[\"Přihlásit se k účtu\"],\"b26wlF\":[\"ona/její\"],\"bD/+Ei\":[\"Přísný\"],\"bQ6BJn\":[\"Nakonfigurujte podrobná pravidla ochrany proti floodingu. Každé pravidlo určuje, jaký typ aktivity sledovat a jakou akci provést při překročení prahů.\"],\"beV7+y\":[\"Uživatel obdrží pozvánku k připojení do \",[\"channelName\"],\".\"],\"bk84cH\":[\"Zpráva o nepřítomnosti\"],\"bkHdLj\":[\"Přidat IRC server\"],\"bmQLn5\":[\"Přidat pravidlo\"],\"bwRvnp\":[\"Akce\"],\"c8+EVZ\":[\"Ověřený účet\"],\"cGYUlD\":[\"Nejsou načteny žádné náhledy médií.\"],\"cLF98o\":[\"Zobrazit komentáře (\",[\"commentCount\"],\")\"],\"cLKIDO\":[\"Žádní uživatelé nejsou k dispozici\"],\"cSgpoS\":[\"Připnout soukromou konverzaci\"],\"cde3ce\":[\"Zpráva <0>\",[\"0\"],\"\"],\"chQsxg\":[\"Kopírovat formátovaný výstup\"],\"cl/A5J\":[\"Vítejte v \",[\"__DEFAULT_IRC_SERVER_NAME__\"],\"!\"],\"cnGeoo\":[\"Smazat\"],\"coPLXT\":[\"Neukládáme vaši IRC komunikaci na našich serverech\"],\"crYH/6\":[\"Přehrávač SoundCloud\"],\"d3sis4\":[\"Přidat server\"],\"d9aN5k\":[\"Odebrat \",[\"username\"],\" z kanálu\"],\"dEgA5A\":[\"Zrušit\"],\"dGi1We\":[\"Odepnout tuto soukromou konverzaci\"],\"dJVuyC\":[\"opustil \",[\"channelName\"],\" (\",[\"reason\"],\")\"],\"dMtLDE\":[\"do\"],\"dXqxlh\":[\"<0>⚠️ Bezpečnostní riziko! Toto připojení může být zranitelné vůči odposlechu nebo útokům man-in-the-middle.\"],\"da9Q/R\":[\"Změněny módy kanálu\"],\"dhJN3N\":[\"Zobrazit komentáře\"],\"dj2xTE\":[\"Odmítnout oznámení\"],\"dpCzmC\":[\"Nastavení ochrany proti floodingu\"],\"e9dQpT\":[\"Chcete otevřít tento odkaz v nové záložce?\"],\"ePK91l\":[\"Upravit\"],\"eYBDuB\":[\"Nahrajte obrázek nebo zadejte URL s volitelnou substitucí \",[\"size\"],\" pro dynamické velikosti\"],\"edBbee\":[\"Zabanovat \",[\"username\"],\" podle masky hostitele (zabrání opětovnému připojení ze stejné IP/hostitele)\"],\"ekfzWq\":[\"Nastavení uživatele\"],\"elPDWs\":[\"Přizpůsobte si IRC klienta\"],\"eu2osY\":[\"<0>💡 Doporučení: Pokračujte pouze pokud důvěřujete tomuto serveru a rozumíte rizikům. Vyhněte se sdílení citlivých informací nebo hesel přes toto připojení.\"],\"euEhbr\":[\"Klikněte pro připojení k \",[\"channel\"]],\"ez3vLd\":[\"Povolit víceřádkové zadávání\"],\"f0J5Ki\":[\"Komunikace mezi servery může používat nešifrovaná připojení\"],\"f9BHJk\":[\"Varovat uživatele\"],\"fDOLLd\":[\"Nebyly nalezeny žádné kanály.\"],\"ffzDkB\":[\"Anonymní analytika:\"],\"fq1GF9\":[\"Zobrazit při odpojení uživatelů ze serveru\"],\"gEF57C\":[\"Tento server podporuje pouze jeden typ připojení\"],\"gJuLUI\":[\"Seznam ignorovaných\"],\"gNzMrk\":[\"Aktuální avatar\"],\"gjPWyO\":[\"Zadejte přezdívku...\"],\"gz6UQ3\":[\"Maximalizovat\"],\"h6razj\":[\"Maska vyloučení názvu kanálu\"],\"hG6jnw\":[\"Téma není nastaveno\"],\"hG89Ed\":[\"Obrázek\"],\"hZ6znB\":[\"Port\"],\"ha+Bz5\":[\"např. 100:1440\"],\"hehnjM\":[\"Množství\"],\"hzdLuQ\":[\"Mluvit mohou pouze uživatelé s hlasem nebo vyšší hodností\"],\"i0qMbr\":[\"Domů\"],\"iDNBZe\":[\"Oznámení\"],\"iH8pgl\":[\"Zpět\"],\"iL9SZg\":[\"Zabanovat uživatele (podle přezdívky)\"],\"iNt+3c\":[\"Zpět na obrázek\"],\"iQvi+a\":[\"Neupozorňovat mě na nízkou bezpečnost připojení pro tento server\"],\"iSLIjg\":[\"Připojit\"],\"iWXkHH\":[\"Polooperátor\"],\"iZeTtp\":[\"Hostitel serveru\"],\"idD8Ev\":[\"Uloženo\"],\"iivqkW\":[\"Přihlášen\"],\"ij+Elv\":[\"Náhled obrázku\"],\"ilIWp7\":[\"Přepnout oznámení\"],\"iuaqvB\":[\"Použijte * pro zástupné znaky. Příklady: baduser!*@*, *!*@spammer.com, troll*!*@*\"],\"ixkTse\":[\"Bot\"],\"j2DGR0\":[\"Ban podle masky hostitele\"],\"jA4uoI\":[\"Téma:\"],\"jLXxGK\":[\"https://example.com\"],\"jPSk57\":[\"Důvod (volitelné)\"],\"jUV7CU\":[\"Nahrát avatar\"],\"jW5Uwh\":[\"Kontrolujte načítání externích médií. Vypnuto / Bezpečné / Důvěryhodné zdroje / Veškerý obsah.\"],\"jXzms5\":[\"Možnosti přílohy\"],\"jZlrte\":[\"Barva\"],\"jfC/xh\":[\"Kontakt\"],\"jywMpv\":[\"#nový-název-kanálu\"],\"k112DD\":[\"Načíst starší zprávy\"],\"k3ID0F\":[\"Filtrovat členy…\"],\"k65gsE\":[\"Podrobný přehled\"],\"k7Zgob\":[\"Zrušit připojení\"],\"kAVx5h\":[\"Nenalezeny žádné pozvánky\"],\"kCLEPU\":[\"Připojeno k\"],\"kF5LKb\":[\"Ignorované vzory:\"],\"kGeOx/\":[\"Připojit se k \",[\"0\"]],\"kITKr8\":[\"Načítám režimy kanálu...\"],\"kPpPsw\":[\"Jste IRC operátor\"],\"kWJmRL\":[\"Vy\"],\"kfcRb0\":[\"Avatar\"],\"kjMqSj\":[\"Kopírovat JSON\"],\"krViRy\":[\"Klikněte pro kopírování jako JSON\"],\"ks71ra\":[\"Výjimky\"],\"kw4lRv\":[\"Polooperátor kanálu\"],\"kxgIRq\":[\"Vyberte nebo přidejte kanál pro začátek.\"],\"ky6dWe\":[\"Náhled avatara\"],\"l+GxCv\":[\"Načítám kanály...\"],\"l+IUVW\":[\"Ověření účtu \",[\"account\"],\" proběhlo úspěšně: \",[\"message\"]],\"l/siQz\":[[\"reconnectCount\",\"plural\",{\"one\":[\"znovu se připojil\"],\"few\":[\"znovu se připojil \",[\"reconnectCount\"],\"×\"],\"many\":[\"znovu se připojil \",[\"reconnectCount\"],\"×\"],\"other\":[\"znovu se připojil \",[\"reconnectCount\"],\"×\"]}]],\"l5jmzx\":[[\"0\"],\" a \",[\"1\"],\" píší...\"],\"lHy8N5\":[\"Načítám více kanálů...\"],\"lbpf14\":[\"Připojit se k \",[\"value\"]],\"lfFsZ4\":[\"Kanály\"],\"lkNdiH\":[\"Název účtu\"],\"ln500L\":[\"ObsidianIRC\"],\"lnCMdg\":[\"Nahrát obrázek\"],\"loQxaJ\":[\"Jsem zpět\"],\"lvfaxv\":[\"DOMŮ\"],\"m16xKo\":[\"Přidat\"],\"m8flAk\":[\"Náhled (ještě nenahrán)\"],\"mEPxTp\":[\"<0>⚠️ Buďte opatrní! Otevírejte pouze odkazy z důvěryhodných zdrojů. Škodlivé odkazy mohou ohrozit vaši bezpečnost nebo soukromí.\"],\"mHGdhG\":[\"Informace o serveru\"],\"mHS8lb\":[\"Zpráva #\",[\"0\"]],\"mMYBD9\":[\"Široký - širší rozsah ochrany\"],\"mTGsPd\":[\"Téma kanálu\"],\"mU8j6O\":[\"Žádné externí zprávy (+n)\"],\"mZp8FL\":[\"Automatický návrat na jeden řádek\"],\"mdQu8G\":[\"VašePřezdívka\"],\"miSSBQ\":[\"Komentáře (\",[\"commentCount\"],\")\"],\"mvyLSy\":[\"Uživatel je ověřen\"],\"mwtcGl\":[\"Zavřít komentáře\"],\"mzI/c+\":[\"Stáhnout\"],\"n3fGRk\":[\"nastaveno \",[\"0\"]],\"nE9jsU\":[\"Uvolněný - méně agresivní ochrana\"],\"nNflMD\":[\"Opustit kanál\"],\"nPXkBi\":[\"Načítám data WHOIS...\"],\"nQnxxF\":[\"Zpráva #\",[\"0\"],\" (Shift+Enter pro nový řádek)\"],\"nWMRxa\":[\"Odepnout\"],\"nkC032\":[\"Žádný profil floodingu\"],\"o69z4d\":[\"Odeslat varovnou zprávu uživateli \",[\"username\"]],\"o9ylQi\":[\"Hledejte GIFy pro začátek\"],\"oFGkER\":[\"Oznámení serveru\"],\"oOi11l\":[\"Přejít dolů\"],\"oQEzQR\":[\"Nová DM\"],\"oXOSPE\":[\"Online\"],\"oal760\":[\"Útoky man-in-the-middle na serverová připojení jsou možné\"],\"oeqmmJ\":[\"Důvěryhodné zdroje\"],\"ovBPCi\":[\"Výchozí\"],\"p0Z69r\":[\"Vzor nemůže být prázdný\"],\"p1KgtK\":[\"Nepodařilo se načíst zvuk\"],\"p59pEv\":[\"Další podrobnosti\"],\"p7sRI6\":[\"Informovat ostatní, když píšete\"],\"pBm1od\":[\"Tajný kanál\"],\"pNmiXx\":[\"Vaše výchozí přezdívka pro všechny servery\"],\"pUUo9G\":[\"Hostname:\"],\"pVGPmz\":[\"Heslo účtu\"],\"peNE68\":[\"Trvalý\"],\"plhHQt\":[\"Žádná data\"],\"pm6+q5\":[\"Bezpečnostní upozornění\"],\"pn5qSs\":[\"Další informace\"],\"q0cR4S\":[\"je nyní znám jako **\",[\"newNick\"],\"**\"],\"qFcunY\":[\"Kanál se nebude zobrazovat v příkazech LIST nebo NAMES\"],\"qLpTm/\":[\"Odebrat reakci \",[\"emoji\"]],\"qVkGWK\":[\"Připnout\"],\"qY8wNa\":[\"Domovská stránka\"],\"qb0xJ7\":[\"Použijte zástupné znaky: * odpovídá libovolné sekvenci, ? odpovídá libovolnému jednomu znaku. Příklady: nick!*@*, *!*@host.com, *!*user@*\"],\"qhzpRq\":[\"Klíč kanálu (+k)\"],\"qtoOYG\":[\"Bez omezení\"],\"r1W2AS\":[\"Obrázek z file hostu\"],\"rIPR2O\":[\"Téma nastaveno před (min)\"],\"rMMSYo\":[\"Maximální délka je \",[\"0\"]],\"rWtzQe\":[\"Síť se rozdělila a znovu připojila. ✅\"],\"rYG2u6\":[\"Prosím čekejte...\"],\"rdUucN\":[\"Náhled\"],\"rjGI/Q\":[\"Soukromí\"],\"rk8iDX\":[\"Načítám GIFy...\"],\"rn6SBY\":[\"Zrušit ztlumení\"],\"s/UKqq\":[\"Byl vykopnut z kanálu\"],\"s8cATI\":[\"se připojil k \",[\"channelName\"]],\"sCO9ue\":[\"Připojení k <0>\",[\"serverName\"],\" má následující bezpečnostní problémy:\"],\"sGH11W\":[\"Server\"],\"sHI1H+\":[\"je nyní znám jako **\",[\"newNick\"],\"**\"],\"sJyV04\":[[\"inviter\"],\" vás pozval k připojení do \",[\"channel\"]],\"sby+1/\":[\"Klikněte pro kopírování\"],\"sfN25C\":[\"Vaše skutečné nebo celé jméno\"],\"sliuzR\":[\"Otevřít odkaz\"],\"sqrO9R\":[\"Vlastní zmínky\"],\"sr6RdJ\":[\"Víceřádkové na Shift+Enter\"],\"swrCpB\":[\"Kanál byl přejmenován z \",[\"oldName\"],\" na \",[\"newName\"],\" uživatelem \",[\"user\"],[\"0\"]],\"sxkWRg\":[\"Pokročilé\"],\"t/YqKh\":[\"Odebrat\"],\"t47eHD\":[\"Váš jedinečný identifikátor na tomto serveru\"],\"tAkAh0\":[\"URL s volitelnou substitucí \",[\"size\"],\" pro dynamické velikosti. Příklad: https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"tXLJS3\":[\"Zobrazit nebo skrýt boční panel se seznamem kanálů\"],\"tfDRzk\":[\"Uložit\"],\"tiBsJk\":[\"opustil \",[\"channelName\"]],\"tt4/UD\":[\"se odpojil (\",[\"reason\"],\")\"],\"u0TcnO\":[\"Přezdívka {nick} je již používána, zkouším s {newNick}\"],\"u0a8B4\":[\"Ověřit jako IRC operátor pro administrativní přístup\"],\"u0rWFU\":[\"Vytvořeno po (min. zpět)\"],\"u72w3t\":[\"Uživatelé a vzory k ignorování\"],\"u7jc2L\":[\"se odpojil\"],\"uAQUqI\":[\"Stav\"],\"uB85T3\":[\"Uložení selhalo: \",[\"msg\"]],\"uV3DOL\":[\"G-Line\"],\"uW3lLI\":[\"IRC servery:\"],\"usSSr/\":[\"Úroveň přiblížení\"],\"v7uvcf\":[\"Software:\"],\"vE8kb+\":[\"Použijte Shift+Enter pro nový řádek (Enter odešle)\"],\"vERlcd\":[\"Profil\"],\"vK0RL8\":[\"Bez tématu\"],\"vSJd18\":[\"Video\"],\"vXIe7J\":[\"Jazyk\"],\"vaHYxN\":[\"Skutečné jméno\"],\"vhjbKr\":[\"Nepřítomen\"],\"w4NYox\":[\"klient \",[\"title\"]],\"w8xQRx\":[\"Neplatná hodnota\"],\"wFjjxZ\":[\"byl vyhozen z \",[\"channelName\"],\" uživatelem \",[\"username\"],\" (\",[\"reason\"],\")\"],\"wGjaGl\":[\"Nenalezeny žádné výjimky zákazu\"],\"wPrGnM\":[\"Správce kanálu\"],\"wRkP2d\":[\"GIF\"],\"wbm86v\":[\"Zobrazovat, když uživatelé vstupují nebo opouštějí kanály\"],\"whqZ9r\":[\"Další slova nebo fráze ke zvýraznění\"],\"wm7RV4\":[\"Zvuk oznámení\"],\"wz/Yoq\":[\"Vaše zprávy mohou být zachyceny při přeposílání mezi servery\"],\"xCJdfg\":[\"Vymazat\"],\"xUHRTR\":[\"Automaticky ověřit jako operátor při připojení\"],\"xWHwwQ\":[\"Bany\"],\"xYilR2\":[\"Média\"],\"xceQrO\":[\"Jsou podporovány pouze zabezpečené websocket připojení\"],\"xdtXa+\":[\"název-kanálu\"],\"xfXC7q\":[\"Textové kanály\"],\"xlCYOE\":[\"Načítám více zpráv...\"],\"xlhswE\":[\"Minimální hodnota je \",[\"0\"]],\"xq97Ci\":[\"Přidat slovo nebo frázi...\"],\"xuRqRq\":[\"Limit klientů (+l)\"],\"xwF+7J\":[[\"0\"],\" píše...\"],\"yNeucF\":[\"Tento server nepodporuje rozšířená metadata profilu (rozšíření IRCv3 METADATA). Další pole jako avatar, zobrazované jméno a stav nejsou k dispozici.\"],\"yPlrca\":[\"Avatar kanálu\"],\"yQE2r9\":[\"Načítání\"],\"ySU+JY\":[\"your@email.com\"],\"yTX1Rt\":[\"Uživatelské jméno operátora\"],\"yYOzWD\":[\"logy\"],\"yfx9Re\":[\"Heslo IRC operátora\"],\"ygCKqB\":[\"Zastavit\"],\"ymDxJx\":[\"Uživatelské jméno IRC operátora\"],\"yrpRsQ\":[\"Seřadit podle názvu\"],\"yz7wBu\":[\"Zavřít\"],\"zJw+jA\":[\"nastavuje režim: \",[\"0\"]],\"zebeLu\":[\"Zadejte uživatelské jméno operátora\"],\"zpr0Bw\":[\"GZ-Line\"]}"); \ No newline at end of file +/*eslint-disable*/export const messages=JSON.parse("{\"+5VMrz\":[\"Neplatný formát vzoru. Použijte formát nick!user@host (jsou povoleny zástupné znaky *)\"],\"+6NQQA\":[\"Obecný podpůrný kanál\"],\"+6NyRG\":[\"Klient\"],\"+K0AvT\":[\"Odpojit\"],\"+cyFdH\":[\"Výchozí zpráva při označení nepřítomnosti\"],\"+mVPqU\":[\"Zobrazovat Markdown formátování ve zprávách\"],\"+vqCJH\":[\"Uživatelské jméno vašeho účtu pro ověření\"],\"+yPBXI\":[\"Vybrat soubor\"],\"+zy2Nq\":[\"Typ\"],\"/09cao\":[\"Nízká bezpečnost připojení (Úroveň \",[\"securityLevel\"],\")\"],\"/3BQ4J\":[\"Uživatelé mimo kanál nemohou odesílat zprávy do něj\"],\"/6BzZF\":[\"Přepnout seznam členů\"],\"/TNOPk\":[\"Uživatel je nepřítomen\"],\"/XQgft\":[\"Objevovat\"],\"/cF7Rs\":[\"Hlasitost\"],\"/dqduX\":[\"Další stránka\"],\"/fc3q4\":[\"Veškerý obsah\"],\"/kISDh\":[\"Povolit zvuky upozornění\"],\"/n04sB\":[\"Kill\"],\"/rTz0M\":[\"Zvuk\"],\"/rfkZe\":[\"Přehrávat zvuky pro zmínky a zprávy\"],\"0/0ZGA\":[\"Maska názvu kanálu\"],\"0D6j7U\":[\"Zjistit více o vlastních pravidlech →\"],\"0XsHcR\":[\"Vyhodit uživatele\"],\"0ZpE//\":[\"Seřadit podle uživatelů\"],\"0bEPwz\":[\"Nastavit nepřítomnost\"],\"0dGkPt\":[\"Rozbalit seznam kanálů\"],\"0gS7M5\":[\"Zobrazované jméno\"],\"0kS+M8\":[\"PříkladSÍŤ\"],\"0rgoY7\":[\"Připojovat se pouze k serverům, které si vyberete\"],\"0wdd7X\":[\"Připojit se\"],\"0wkVYx\":[\"Soukromé zprávy\"],\"111uHX\":[\"Náhled odkazu\"],\"196EG4\":[\"Smazat soukromý chat\"],\"1DSr1i\":[\"Zaregistrovat účet\"],\"1O/24y\":[\"Přepnout seznam kanálů\"],\"1TNIig\":[\"Open\"],\"1VPJJ2\":[\"Varování o externím odkazu\"],\"1ZC/dv\":[\"Žádné nepřečtené zmínky ani zprávy\"],\"1pO1zi\":[\"Název serveru je povinný\"],\"1uwfzQ\":[\"Zobrazit téma kanálu\"],\"268g7c\":[\"Zadejte zobrazované jméno\"],\"2FOFq1\":[\"Operátoři serveru v síti by potenciálně mohli číst vaše zprávy\"],\"2FYpfJ\":[\"Více\"],\"2HF1Y2\":[[\"inviter\"],\" pozval \",[\"target\"],\" k připojení do \",[\"channel\"]],\"2I70QL\":[\"Zobrazit informace o profilu uživatele\"],\"2QYdmE\":[\"Uživatelé:\"],\"2QpEjG\":[\"odešel\"],\"2YE223\":[\"Zpráva #\",[\"0\"],\" (Enter pro nový řádek, Shift+Enter pro odeslání)\"],\"2bimFY\":[\"Použít heslo serveru\"],\"2iTmdZ\":[\"Místní úložiště:\"],\"2odkwe\":[\"Přísný - agresivnější ochrana\"],\"2uDhbA\":[\"Zadejte uživatelské jméno pro pozvání\"],\"2ygf/L\":[\"← Zpět\"],\"2zEgxj\":[\"Hledat GIFy...\"],\"3RdPhl\":[\"Přejmenovat kanál\"],\"3THokf\":[\"Uživatel s hlasem\"],\"3TSz9S\":[\"Minimalizovat\"],\"3jBDvM\":[\"Zobrazovaný název kanálu\"],\"3ryuFU\":[\"Volitelné zprávy o pádu pro zlepšení aplikace\"],\"3uBF/8\":[\"Zavřít prohlížeč\"],\"3uwW8F\":[\"https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"3xf8Kz\":[\"Zadejte název účtu...\"],\"4/Rr0R\":[\"Pozvat uživatele do aktuálního kanálu\"],\"4EZrJN\":[\"Pravidla\"],\"4JJtW9\":[\"#přetečení\"],\"4NqeT4\":[\"Profil floodingu (+F)\"],\"4RZQRK\":[\"Co teď děláš?\"],\"4hfTrB\":[\"Přezdívka\"],\"4n99LO\":[\"Již v \",[\"0\"]],\"4t6vMV\":[\"Automaticky přepnout na jeden řádek pro krátké zprávy\"],\"4vsHmf\":[\"Čas (min)\"],\"4x/Axu\":[\"Váš bouncer zatím nemá žádné sítě. Přidejte jednu pro začátek.\"],\"5+INAX\":[\"Zvýrazňovat zprávy, které vás zmiňují\"],\"5R5Pv/\":[\"Jméno operátora\"],\"678PKt\":[\"Název sítě\"],\"6Aih4U\":[\"Offline\"],\"6CO3WE\":[\"Heslo nutné pro vstup do kanálu. Nechte prázdné pro odstranění klíče.\"],\"6HhMs3\":[\"Zpráva při odpojení\"],\"6V3Ea3\":[\"Zkopírováno\"],\"6lGV3K\":[\"Zobrazit méně\"],\"6yFOEi\":[\"Zadejte heslo opera...\"],\"7+IHTZ\":[\"Žádný soubor nevybrán\"],\"73fnil\":[\"TLS\"],\"73hrRi\":[\"nick!user@host (např. spam*!*@*, *!*@badhost.com)\"],\"7QkKyN\":[\"Odeslat soukromou zprávu\"],\"7U1W7c\":[\"Velmi uvolněný\"],\"7Y1YQj\":[\"Skutečné jméno:\"],\"7YHArF\":[\"— otevřít v prohlížeči\"],\"7fjnVl\":[\"Hledat uživatele...\"],\"7jL88x\":[\"Smazat tuto zprávu? Tuto akci nelze vrátit zpět.\"],\"7nGhhM\":[\"Na co myslíte?\"],\"7sEpu1\":[\"Členové — \",[\"0\"]],\"7sNhEz\":[\"Uživatelské jméno\"],\"8H0Q+x\":[\"Zjistit více o profilech →\"],\"8Phu0A\":[\"Zobrazovat, když uživatelé mění přezdívky\"],\"8XTG9e\":[\"Zadejte heslo operátora\"],\"8XsV2J\":[\"Zkusit odeslat znovu\"],\"8ZsakT\":[\"Heslo\"],\"8kR84m\":[\"Chystáte se otevřít externí odkaz:\"],\"8lCgih\":[\"Odebrat pravidlo\"],\"8p/xVT\":[[\"0\",\"plural\",{\"one\":[[\"1\"]],\"other\":[[\"2\"]]}]],\"8wRzac\":[[\"joinCount\",\"plural\",{\"one\":[\"připojil se\"],\"few\":[\"připojil se \",[\"joinCount\"],\"×\"],\"many\":[\"připojil se \",[\"joinCount\"],\"×\"],\"other\":[\"připojil se \",[\"joinCount\"],\"×\"]}]],\"9BMLnJ\":[\"Znovu připojit k serveru\"],\"9OEgyT\":[\"Přidat reakci\"],\"9PQ8m2\":[\"G-Line (globální ban)\"],\"9Qs99X\":[\"E-mail:\"],\"9QupBP\":[\"Odebrat vzor\"],\"9W7tl5\":[\"(beze změny)\"],\"9bG48P\":[\"Odesílání\"],\"9f5f0u\":[\"Otázky ohledně soukromí? Kontaktujte nás:\"],\"9iweoP\":[\"Sítě na \",[\"0\"]],\"9unqs3\":[\"Nepřítomen:\"],\"9v3hwv\":[\"Nebyly nalezeny žádné servery.\"],\"9zb2WA\":[\"Připojování\"],\"A1taO8\":[\"Hledat\"],\"A2adVi\":[\"Odesílat oznámení o psaní\"],\"A9Rhec\":[\"Název kanálu\"],\"AWOSPo\":[\"Přiblížit\"],\"AXSpEQ\":[\"Operátor při připojení\"],\"AeXO77\":[\"Účet\"],\"AhNP40\":[\"Přetočit\"],\"Ai2U7L\":[\"Hostitel\"],\"AjBQnf\":[\"Změněna přezdívka\"],\"AmXVh6\":[\"https://example.com/avatar.png\"],\"AnRu/j\":[\"Zrušit odpověď\"],\"ApSx0O\":[\"Nalezeno \",[\"0\"],\" zpráv odpovídajících \\\"\",[\"searchQuery\"],\"\\\"\"],\"AxPAXW\":[\"Žádné výsledky nenalezeny\"],\"AyNqAB\":[\"Zobrazit všechny události serveru v chatu\"],\"B/QqGw\":[\"Pryč od klávesnice\"],\"B0sB2k\":[\"Prostý text\"],\"B8AaMI\":[\"Toto pole je povinné\"],\"BA2c49\":[\"Server nepodporuje pokročilé filtrování LIST\"],\"BDKt3I\":[[\"0\"],\", \",[\"1\"],\", \",[\"2\"],\" a \",[\"3\"],\" dalších píší...\"],\"BGul2A\":[\"Máte neuložené změny. Opravdu chcete zavřít bez uložení?\"],\"BIf9fi\":[\"Vaše stavová zpráva\"],\"BZz3md\":[\"Vaše osobní webová stránka\"],\"Bgm/H7\":[\"Povolit zadávání více řádků textu\"],\"BiQIl1\":[\"Připnout tuto soukromou konverzaci\"],\"BlNZZ2\":[\"Klikněte pro přechod na zprávu\"],\"Bowq3c\":[\"Téma kanálu mohou měnit pouze operátoři\"],\"Btozzp\":[\"Platnost tohoto obrázku vypršela\"],\"Bycfjm\":[\"Celkem: \",[\"0\"]],\"C6IBQc\":[\"Kopírovat celý JSON\"],\"C9L9wL\":[\"Sběr dat\"],\"CDq4wC\":[\"Moderovat uživatele\"],\"CHVRxG\":[\"Zpráva @\",[\"0\"],\" (Shift+Enter pro nový řádek)\"],\"CN9zdR\":[\"Jméno a heslo operátora jsou povinné\"],\"CW3sYa\":[\"Přidat reakci \",[\"emoji\"]],\"CaAkqd\":[\"Zobrazit odchody\"],\"CbvaYj\":[\"Ban podle přezdívky\"],\"CcK+Ft\":[\"PDF\"],\"Ce8q3L\":[\"Vybrat kanál\"],\"CsekCi\":[\"Normální\"],\"D+NlUC\":[\"Systém\"],\"D28t6+\":[\"se připojil a odpojil\"],\"DB8zMK\":[\"Použít\"],\"DBcWHr\":[\"Vlastní soubor zvuku oznámení\"],\"DTy9Xw\":[\"Náhledy médií\"],\"Dj4pSr\":[\"Zvolte bezpečné heslo\"],\"Du+zn+\":[\"Hledám...\"],\"Du2T2f\":[\"Nastavení nenalezeno\"],\"DwsSVQ\":[\"Použít filtry a obnovit\"],\"E3W/zd\":[\"Výchozí přezdívka\"],\"E6nRW7\":[\"Kopírovat URL\"],\"E703RG\":[\"Režimy:\"],\"EAeu1Z\":[\"Odeslat pozvánku\"],\"EFKJQT\":[\"Nastavení\"],\"EGPQBv\":[\"Vlastní pravidla floodingu (+f)\"],\"ELik0r\":[\"Zobrazit úplné zásady ochrany soukromí\"],\"EPbeC2\":[\"Zobrazit nebo upravit téma kanálu\"],\"EQCDNT\":[\"Zadejte uživatelské jméno opera...\"],\"EUvulZ\":[\"Nalezena 1 zpráva odpovídající \\\"\",[\"searchQuery\"],\"\\\"\"],\"EatZYJ\":[\"Další obrázek\"],\"EdQY6l\":[\"Žádné\"],\"EnqLYU\":[\"Hledat servery...\"],\"F0OKMc\":[\"Upravit server\"],\"F6Int2\":[\"Povolit zvýraznění\"],\"FDoLyE\":[\"Max. uživatelů\"],\"FUU/hZ\":[\"Kontrolujte, kolik externích médií se načítá v chatu.\"],\"Fdp03t\":[\"zap\"],\"FfPWR0\":[\"Modální okno\"],\"FjkaiT\":[\"Oddálit\"],\"FlqOE9\":[\"Co to znamená:\"],\"FolHNl\":[\"Spravujte svůj účet a ověřování\"],\"Fp2Dif\":[\"Opustit server\"],\"G5KmCc\":[\"GZ-Line (globální Z-Line)\"],\"GDs0lz\":[\"<0>Riziko: Citlivé informace (zprávy, soukromé konverzace, přihlašovací údaje) mohou být přístupné správcům sítě nebo útočníkům mezi IRC servery.\"],\"GR+2I3\":[\"Přidat masku pozvánky (např. nick!*@*, *!*@host.com)\"],\"GRLyMU\":[\"Zavřít vyskočená serverová oznámení\"],\"GlHnXw\":[\"Změna přezdívky se nezdařila: \",[\"error\"],\" \",[\"0\"]],\"GswZF3\":[\"Náhled:\"],\"GtmO8/\":[\"od\"],\"GtuHUQ\":[\"Přejmenovat tento kanál na serveru. Nový název uvidí všichni uživatelé.\"],\"GuGfFX\":[\"Přepnout hledání\"],\"GxkJXS\":[\"Nahrávám...\"],\"GzbwnK\":[\"Připojil se ke kanálu\"],\"GzsUDB\":[\"Rozšířený profil\"],\"H/PnT8\":[\"Vložit emoji\"],\"H6Izzl\":[\"Váš preferovaný kód barvy\"],\"H9jIv+\":[\"Zobrazit připojení/odchody\"],\"HAKBY9\":[\"Nahrát soubory\"],\"HdE1If\":[\"Kanál\"],\"Hk4AW9\":[\"Vaše preferované zobrazované jméno\"],\"HmHDk7\":[\"Vybrat člena\"],\"HrQzPU\":[\"Kanály na \",[\"networkName\"]],\"I2tXQ5\":[\"Zpráva @\",[\"0\"],\" (Enter pro nový řádek, Shift+Enter pro odeslání)\"],\"I6bw/h\":[\"Zabanovat uživatele\"],\"I92Z+b\":[\"Povolit upozornění\"],\"I9D72S\":[\"Opravdu chcete tuto zprávu smazat? Tuto akci nelze vrátit zpět.\"],\"IA+1wo\":[\"Zobrazovat, když jsou uživatelé vyhozeni z kanálů\"],\"IDwkJx\":[\"IRC operátor\"],\"ILlU+s\":[\"Info:\"],\"IUwGEM\":[\"Uložit změny\"],\"IVeGK6\":[[\"0\"],\", \",[\"1\"],\" a \",[\"2\"],\" píší...\"],\"IgrLD/\":[\"Pauza\"],\"Im6JED\":[\"ŠEPOT\"],\"ImOQa9\":[\"Odpovědět\"],\"IoHMnl\":[\"Maximální hodnota je \",[\"0\"]],\"IvMj+0\":[\"Op\"],\"J28zul\":[\"Připojování...\"],\"J5T9NW\":[\"Informace o uživateli\"],\"J8Y5+z\":[\"Jejda! Síť se rozdělila! ⚠️\"],\"JBHkBA\":[\"Opustil kanál\"],\"JCwL0Q\":[\"Zadejte důvod (volitelné)\"],\"JFciKP\":[\"Přepnout\"],\"JXGkhG\":[\"Změnit název kanálu (pouze operátoři)\"],\"JcD7qf\":[\"Více akcí\"],\"JdkA+c\":[\"Tajný (+s)\"],\"Jmu12l\":[\"Kanály serveru\"],\"JvQ++s\":[\"Povolit Markdown\"],\"K2jwh/\":[\"Data WHOIS nejsou k dispozici\"],\"KAXSwC\":[\"Hlas\"],\"KDfTdX\":[\"Smazat zprávu\"],\"KKBlUU\":[\"Vložit\"],\"KM0pLb\":[\"Vítejte v kanálu!\"],\"KR6W2h\":[\"Přestat ignorovat uživatele\"],\"KV+Bi1\":[\"Pouze na pozvání (+i)\"],\"KdCtwE\":[\"Kolik sekund sledovat floodingovou aktivitu před resetováním čítačů\"],\"Kkezga\":[\"Heslo serveru\"],\"KsiQ/8\":[\"Uživatelé musí být pozváni k připojení do kanálu\"],\"L+gB/D\":[\"Informace o kanálu\"],\"LC1a7n\":[\"IRC server oznámil, že jeho meziservery mají nízkou úroveň zabezpečení. To znamená, že když jsou vaše zprávy přeposílány mezi IRC servery v síti, nemusí být správně šifrovány nebo SSL/TLS certifikáty nemusí být správně ověřovány.\"],\"LNfLR5\":[\"Zobrazit vykopnutí\"],\"LP+1Z7\":[\"Přidat síť\"],\"LQb0W/\":[\"Zobrazit všechny události\"],\"LU7/yA\":[\"Alternativní název pro zobrazení v rozhraní. Může obsahovat mezery, emoji a speciální znaky. Skutečný název kanálu (\",[\"channelName\"],\") bude nadále používán pro IRC příkazy.\"],\"LUb9O7\":[\"Je vyžadován platný port serveru\"],\"Lb+BUl\":[\"https://example.com/avatar.jpg\"],\"LcET2C\":[\"Zásady ochrany soukromí\"],\"LcuSDR\":[\"Spravujte informace profilu a metadata\"],\"LqLS9B\":[\"Zobrazit změny přezdívek\"],\"LsDQt2\":[\"Nastavení kanálu\"],\"LtI9AS\":[\"Vlastník\"],\"LuNhhL\":[\"reagoval na tuto zprávu\"],\"M/AZNG\":[\"URL vašeho avatara\"],\"M/WIer\":[\"Odeslat zprávu\"],\"M8er/5\":[\"Název:\"],\"MHk+7g\":[\"Předchozí obrázek\"],\"MRorGe\":[\"Soukromá zpráva uživateli\"],\"MVbSGP\":[\"Časové okno (sekundy)\"],\"MkpcsT\":[\"Vaše zprávy a nastavení jsou uloženy lokálně na vašem zařízení\"],\"MzPdC2\":[\"Heslo serveru (PASS)\"],\"N/hDSy\":[\"Označit jako bot - obvykle 'on' nebo prázdné\"],\"N6j2JH\":[\"Upravit \",[\"0\"]],\"N7TQbE\":[\"Pozvat uživatele do \",[\"channelName\"]],\"NCca/o\":[\"Zadejte výchozí přezdívku...\"],\"Nqs6B9\":[\"Zobrazuje veškerá externí média. Libovolná URL může způsobit požadavek na neznámý server.\"],\"Nt+9O7\":[\"Použít WebSocket místo surového TCP\"],\"NxIHzc\":[\"Odpojit uživatele\"],\"O+v/cL\":[\"Procházet všechny kanály na serveru\"],\"OCGpR4\":[\"(zdědit)\"],\"ODwSCk\":[\"Odeslat GIF\"],\"OGQ5kK\":[\"Konfigurovat zvuky upozornění a zvýraznění\"],\"OIPt1Z\":[\"Zobrazit nebo skrýt boční panel se seznamem členů\"],\"OKSNq/\":[\"Velmi přísný\"],\"ONWvwQ\":[\"Nahrát\"],\"OVKoQO\":[\"Heslo vašeho účtu pro ověření\"],\"ObsidianIRC - Bringing IRC to the future\":[\"ObsidianIRC - Přinášíme IRC do budoucnosti\"],\"OhCpra\":[\"Nastavit téma…\"],\"OkltoQ\":[\"Zabanovat \",[\"username\"],\" podle přezdívky (zabrání opětovnému připojení se stejným nickem)\"],\"P+t/Te\":[\"Žádné další údaje\"],\"P42Wcc\":[\"Bezpečné\"],\"PD38l0\":[\"Náhled avatara kanálu\"],\"PD9mEt\":[\"Napište zprávu...\"],\"PPqfdA\":[\"Otevřít nastavení konfigurace kanálu\"],\"PSCjfZ\":[\"Téma, které bude zobrazeno pro tento kanál. Téma mohou vidět všichni uživatelé.\"],\"PZCecv\":[\"Náhled PDF\"],\"PeLgsC\":[[\"c\",\"plural\",{\"one\":[\"1×\"],\"few\":[[\"c\"],\"×\"],\"many\":[[\"c\"],\"×\"],\"other\":[[\"c\"],\"×\"]}]],\"PguS2C\":[\"Přidat masku výjimky (např. nick!*@*, *!*@host.com)\"],\"Pil5Ty\":[\"Zobrazeno \",[\"displayedChannelsCount\"],\" z \",[\"0\"],\" kanálů\"],\"PqhVlJ\":[\"Zabanovat uživatele (podle masky hostitele)\"],\"Q+chwU\":[\"Uživatelské jméno:\"],\"Q3v9Wc\":[\"Ano, smazat\"],\"Q6hhn8\":[\"Předvolby\"],\"QF4a34\":[\"Zadejte prosím uživatelské jméno\"],\"QGqSZ2\":[\"Barva a formátování\"],\"QJQd1J\":[\"Upravit profil\"],\"QSzGDE\":[\"Nečinný\"],\"QUlny5\":[\"Vítejte v \",[\"0\"],\"!\"],\"Qoq+GP\":[\"Číst více\"],\"QuSkCF\":[\"Filtrovat kanály...\"],\"QwUrDZ\":[\"změnil téma na: \",[\"topic\"]],\"R0UH07\":[\"Obrázek \",[\"0\"],\" z \",[\"1\"]],\"R7SsBE\":[\"Ztlumit\"],\"R8rf1X\":[\"Klikněte pro nastavení tématu\"],\"RArB3D\":[\"byl vyhozen z \",[\"channelName\"],\" uživatelem \",[\"username\"]],\"RI3cWd\":[\"Objevte svět IRC s ObsidianIRC\"],\"RMMaN5\":[\"Moderovaný (+m)\"],\"RWw9Lg\":[\"Zavřít okno\"],\"RZ2BuZ\":[\"Registrace účtu \",[\"account\"],\" vyžaduje ověření: \",[\"message\"]],\"RySp6q\":[\"Skrýt komentáře\"],\"S5Togi\":[\"Načítání sítí z vašeho bounceru…\"],\"SPKQTd\":[\"Přezdívka je povinná\"],\"SPVjfj\":[\"Výchozí bude 'bez důvodu', pokud ponecháte prázdné\"],\"SQKPvQ\":[\"Pozvat uživatele\"],\"STmlpb\":[\"Back to network list\"],\"SkZcl+\":[\"Vyberte předdefinovaný profil ochrany před floodem. Tyto profily poskytují vyvážená nastavení ochrany pro různé případy použití.\"],\"Slr+3C\":[\"Min. uživatelů\"],\"Spnlre\":[\"Pozval jste \",[\"target\"],\" k připojení do \",[\"channel\"]],\"T/ckN5\":[\"Otevřít v prohlížeči\"],\"T91vKp\":[\"Přehrát\"],\"TV2Wdu\":[\"Zjistěte, jak nakládáme s vašimi daty a chráníme vaše soukromí.\"],\"TgFpwD\":[\"Používám...\"],\"TkzSFB\":[\"Žádné změny\"],\"TtserG\":[\"Zadejte skutečné jméno\"],\"Ttz9J1\":[\"Zadejte heslo...\"],\"Tz0i8g\":[\"Nastavení\"],\"U3pytU\":[\"Admin\"],\"UDb2YD\":[\"React\"],\"UE4KO5\":[\"*kanál*\"],\"UGT5vp\":[\"Uložit nastavení\"],\"UV5hLB\":[\"Nenalezeny žádné zákazy\"],\"Uaj3Nd\":[\"Stavové zprávy\"],\"Ue3uny\":[\"Výchozí (bez profilu)\"],\"UkARhe\":[\"Normální - standardní ochrana\"],\"Umn7Cj\":[\"Zatím žádné komentáře. Buďte první!\"],\"UtUIRh\":[[\"0\"],\" starších zpráv\"],\"UwzP+U\":[\"Zabezpečené připojení\"],\"V0/A4O\":[\"Vlastník kanálu\"],\"V4qgxE\":[\"Vytvořeno před (min. zpět)\"],\"V8yTm6\":[\"Vymazat hledání\"],\"VJMMyz\":[\"ObsidianIRC - Přinášíme IRC do budoucnosti\"],\"VJScHU\":[\"Důvod\"],\"VLsmVV\":[\"Ztlumit upozornění\"],\"VbyRUy\":[\"Komentáře\"],\"Vmx0mQ\":[\"Nastaveno:\"],\"VqnIZz\":[\"Zobrazit naše zásady ochrany soukromí a práci s daty\"],\"VrMygG\":[\"Minimální délka je \",[\"0\"]],\"VrnTui\":[\"Vaše zájmena, zobrazená ve vašem profilu\"],\"W8E3qn\":[\"Ověřený účet\"],\"WAakm9\":[\"Smazat kanál\"],\"WFxTHC\":[\"Přidat masku banu (např. nick!*@*, *!*@host.com)\"],\"WN1g9F\":[\"Hostitel serveru je povinný\"],\"WRYdXW\":[\"Pozice zvuku\"],\"WUOH5B\":[\"Ignorovat uživatele\"],\"WWEXnZ\":[[\"0\",\"plural\",{\"one\":[\"Zobrazit 1 další položku\"],\"few\":[\"Zobrazit \",[\"1\"],\" další položky\"],\"many\":[\"Zobrazit \",[\"1\"],\" dalších položek\"],\"other\":[\"Zobrazit \",[\"1\"],\" dalších položek\"]}]],\"Weq9zb\":[\"Obecné\"],\"Wfj7Sk\":[\"Ztlumit nebo zapnout zvuky upozornění\"],\"Wm7gbG\":[\"GitHub:\"],\"WyeHWY\":[\"*spam*\"],\"WzMCru\":[\"Profil uživatele\"],\"X6S3lt\":[\"Hledat nastavení, kanály, servery...\"],\"XEHan5\":[\"Přesto pokračovat\"],\"XI1+wb\":[\"Neplatný formát\"],\"XIXeuC\":[\"Zpráva @\",[\"0\"]],\"XMS+k4\":[\"Začít soukromou zprávu\"],\"XWgxXq\":[\"Album\"],\"Xd7+IT\":[\"Odepnout soukromou konverzaci\"],\"Xm/s+u\":[\"Zobrazení\"],\"Xp2n93\":[\"Zobrazuje média z důvěryhodného file hostu vašeho serveru. Nejsou prováděny žádné požadavky na externí služby.\"],\"XvjC4F\":[\"Ukládám...\"],\"Y/qryO\":[\"Nebyly nalezeni žádní uživatelé odpovídající vašemu vyhledávání\"],\"YAqRpI\":[\"Registrace účtu \",[\"account\"],\" proběhla úspěšně: \",[\"message\"]],\"YEfzvP\":[\"Chráněné téma (+t)\"],\"YQOn6a\":[\"Sbalit seznam členů\"],\"YRCoE9\":[\"Operátor kanálu\"],\"YURQaF\":[\"Zobrazit profil\"],\"YdBSvr\":[\"Ovládat zobrazení médií a externího obsahu\"],\"Yj6U3V\":[\"Bez centrálního serveru:\"],\"YjvpGx\":[\"Zájmena\"],\"YqH4l4\":[\"Bez klíče\"],\"YyUPpV\":[\"Účet:\"],\"ZJSWfw\":[\"Zpráva zobrazená při odpojení od serveru\"],\"ZR1dJ4\":[\"Pozvánky\"],\"ZdWg0V\":[\"Otevřít v prohlížeči\"],\"ZhRBbl\":[\"Hledat zprávy…\"],\"Zmcu3y\":[\"Pokročilé filtry\"],\"a2/8e5\":[\"Téma nastaveno po (min)\"],\"aHKcKc\":[\"Předchozí stránka\"],\"aJTbXX\":[\"Heslo operátora\"],\"aQryQv\":[\"Vzor již existuje\"],\"aW9pLN\":[\"Maximální počet uživatelů povolených v kanálu. Nechte prázdné pro žádný limit.\"],\"ah4fmZ\":[\"Zobrazuje také náhledy z YouTube, Vimeo, SoundCloud a podobných známých služeb.\"],\"aifXak\":[\"V tomto kanálu nejsou žádná média\"],\"ap2zBz\":[\"Uvolněný\"],\"az8lvo\":[\"Vypnuto\"],\"azXSNo\":[\"Rozbalit seznam členů\"],\"azdliB\":[\"Přihlásit se k účtu\"],\"b26wlF\":[\"ona/její\"],\"bD/+Ei\":[\"Přísný\"],\"bQ6BJn\":[\"Nakonfigurujte podrobná pravidla ochrany proti floodingu. Každé pravidlo určuje, jaký typ aktivity sledovat a jakou akci provést při překročení prahů.\"],\"beV7+y\":[\"Uživatel obdrží pozvánku k připojení do \",[\"channelName\"],\".\"],\"bk84cH\":[\"Zpráva o nepřítomnosti\"],\"bkHdLj\":[\"Přidat IRC server\"],\"bmQLn5\":[\"Přidat pravidlo\"],\"bv4cFj\":[\"Přenos\"],\"bwRvnp\":[\"Akce\"],\"c8+EVZ\":[\"Ověřený účet\"],\"cGYUlD\":[\"Nejsou načteny žádné náhledy médií.\"],\"cLF98o\":[\"Zobrazit komentáře (\",[\"commentCount\"],\")\"],\"cLKIDO\":[\"Žádní uživatelé nejsou k dispozici\"],\"cSgpoS\":[\"Připnout soukromou konverzaci\"],\"cde3ce\":[\"Zpráva <0>\",[\"0\"],\"\"],\"chQsxg\":[\"Kopírovat formátovaný výstup\"],\"cl/A5J\":[\"Vítejte v \",[\"__DEFAULT_IRC_SERVER_NAME__\"],\"!\"],\"cnGeoo\":[\"Smazat\"],\"coPLXT\":[\"Neukládáme vaši IRC komunikaci na našich serverech\"],\"crYH/6\":[\"Přehrávač SoundCloud\"],\"cv5DQb\":[\"není nastaven hostitel\"],\"d3sis4\":[\"Přidat server\"],\"d9aN5k\":[\"Odebrat \",[\"username\"],\" z kanálu\"],\"dEgA5A\":[\"Zrušit\"],\"dGi1We\":[\"Odepnout tuto soukromou konverzaci\"],\"dJVuyC\":[\"opustil \",[\"channelName\"],\" (\",[\"reason\"],\")\"],\"dMtLDE\":[\"do\"],\"dXqxlh\":[\"<0>⚠️ Bezpečnostní riziko! Toto připojení může být zranitelné vůči odposlechu nebo útokům man-in-the-middle.\"],\"da9Q/R\":[\"Změněny módy kanálu\"],\"dhJN3N\":[\"Zobrazit komentáře\"],\"dj2xTE\":[\"Odmítnout oznámení\"],\"dpCzmC\":[\"Nastavení ochrany proti floodingu\"],\"e9dQpT\":[\"Chcete otevřít tento odkaz v nové záložce?\"],\"ePK91l\":[\"Upravit\"],\"eYBDuB\":[\"Nahrajte obrázek nebo zadejte URL s volitelnou substitucí \",[\"size\"],\" pro dynamické velikosti\"],\"edBbee\":[\"Zabanovat \",[\"username\"],\" podle masky hostitele (zabrání opětovnému připojení ze stejné IP/hostitele)\"],\"ekfzWq\":[\"Nastavení uživatele\"],\"elPDWs\":[\"Přizpůsobte si IRC klienta\"],\"eu2osY\":[\"<0>💡 Doporučení: Pokračujte pouze pokud důvěřujete tomuto serveru a rozumíte rizikům. Vyhněte se sdílení citlivých informací nebo hesel přes toto připojení.\"],\"euEhbr\":[\"Klikněte pro připojení k \",[\"channel\"]],\"ez3vLd\":[\"Povolit víceřádkové zadávání\"],\"f0J5Ki\":[\"Komunikace mezi servery může používat nešifrovaná připojení\"],\"f9BHJk\":[\"Varovat uživatele\"],\"fDOLLd\":[\"Nebyly nalezeny žádné kanály.\"],\"ffzDkB\":[\"Anonymní analytika:\"],\"fq1GF9\":[\"Zobrazit při odpojení uživatelů ze serveru\"],\"gEF57C\":[\"Tento server podporuje pouze jeden typ připojení\"],\"gJuLUI\":[\"Seznam ignorovaných\"],\"gNzMrk\":[\"Aktuální avatar\"],\"gjPWyO\":[\"Zadejte přezdívku...\"],\"gz6UQ3\":[\"Maximalizovat\"],\"h6/IMX\":[\"Přidejte svou první síť\"],\"h6razj\":[\"Maska vyloučení názvu kanálu\"],\"hG6jnw\":[\"Téma není nastaveno\"],\"hG89Ed\":[\"Obrázek\"],\"hZ6znB\":[\"Port\"],\"ha+Bz5\":[\"např. 100:1440\"],\"hehnjM\":[\"Množství\"],\"hzdLuQ\":[\"Mluvit mohou pouze uživatelé s hlasem nebo vyšší hodností\"],\"i0qMbr\":[\"Domů\"],\"iDNBZe\":[\"Oznámení\"],\"iH8pgl\":[\"Zpět\"],\"iL9SZg\":[\"Zabanovat uživatele (podle přezdívky)\"],\"iNt+3c\":[\"Zpět na obrázek\"],\"iQvi+a\":[\"Neupozorňovat mě na nízkou bezpečnost připojení pro tento server\"],\"iSLIjg\":[\"Připojit\"],\"iWXkHH\":[\"Polooperátor\"],\"iZeTtp\":[\"Hostitel serveru\"],\"idD8Ev\":[\"Uloženo\"],\"iivqkW\":[\"Přihlášen\"],\"ij+Elv\":[\"Náhled obrázku\"],\"ilIWp7\":[\"Přepnout oznámení\"],\"iuaqvB\":[\"Použijte * pro zástupné znaky. Příklady: baduser!*@*, *!*@spammer.com, troll*!*@*\"],\"ixkTse\":[\"Bot\"],\"j2DGR0\":[\"Ban podle masky hostitele\"],\"jA4uoI\":[\"Téma:\"],\"jLXxGK\":[\"https://example.com\"],\"jPSk57\":[\"Důvod (volitelné)\"],\"jUV7CU\":[\"Nahrát avatar\"],\"jW5Uwh\":[\"Kontrolujte načítání externích médií. Vypnuto / Bezpečné / Důvěryhodné zdroje / Veškerý obsah.\"],\"jXzms5\":[\"Možnosti přílohy\"],\"jZlrte\":[\"Barva\"],\"jfC/xh\":[\"Kontakt\"],\"jywMpv\":[\"#nový-název-kanálu\"],\"k112DD\":[\"Načíst starší zprávy\"],\"k3ID0F\":[\"Filtrovat členy…\"],\"k65gsE\":[\"Podrobný přehled\"],\"k7Zgob\":[\"Zrušit připojení\"],\"kAVx5h\":[\"Nenalezeny žádné pozvánky\"],\"kCLEPU\":[\"Připojeno k\"],\"kF5LKb\":[\"Ignorované vzory:\"],\"kGeOx/\":[\"Připojit se k \",[\"0\"]],\"kITKr8\":[\"Načítám režimy kanálu...\"],\"kPpPsw\":[\"Jste IRC operátor\"],\"kWJmRL\":[\"Vy\"],\"kfcRb0\":[\"Avatar\"],\"kjMqSj\":[\"Kopírovat JSON\"],\"krViRy\":[\"Klikněte pro kopírování jako JSON\"],\"ks71ra\":[\"Výjimky\"],\"kw4lRv\":[\"Polooperátor kanálu\"],\"kxgIRq\":[\"Vyberte nebo přidejte kanál pro začátek.\"],\"ky6dWe\":[\"Náhled avatara\"],\"l+GxCv\":[\"Načítám kanály...\"],\"l+IUVW\":[\"Ověření účtu \",[\"account\"],\" proběhlo úspěšně: \",[\"message\"]],\"l/siQz\":[[\"reconnectCount\",\"plural\",{\"one\":[\"znovu se připojil\"],\"few\":[\"znovu se připojil \",[\"reconnectCount\"],\"×\"],\"many\":[\"znovu se připojil \",[\"reconnectCount\"],\"×\"],\"other\":[\"znovu se připojil \",[\"reconnectCount\"],\"×\"]}]],\"l5jmzx\":[[\"0\"],\" a \",[\"1\"],\" píší...\"],\"lHy8N5\":[\"Načítám více kanálů...\"],\"lbpf14\":[\"Připojit se k \",[\"value\"]],\"lfFsZ4\":[\"Kanály\"],\"lkNdiH\":[\"Název účtu\"],\"ln500L\":[\"ObsidianIRC\"],\"lnCMdg\":[\"Nahrát obrázek\"],\"loQxaJ\":[\"Jsem zpět\"],\"lvfaxv\":[\"DOMŮ\"],\"m0oxpP\":[\"Libera Chat\"],\"m16xKo\":[\"Přidat\"],\"m8flAk\":[\"Náhled (ještě nenahrán)\"],\"mEPxTp\":[\"<0>⚠️ Buďte opatrní! Otevírejte pouze odkazy z důvěryhodných zdrojů. Škodlivé odkazy mohou ohrozit vaši bezpečnost nebo soukromí.\"],\"mHGdhG\":[\"Informace o serveru\"],\"mHS8lb\":[\"Zpráva #\",[\"0\"]],\"mMYBD9\":[\"Široký - širší rozsah ochrany\"],\"mTGsPd\":[\"Téma kanálu\"],\"mU8j6O\":[\"Žádné externí zprávy (+n)\"],\"mZp8FL\":[\"Automatický návrat na jeden řádek\"],\"mdQu8G\":[\"VašePřezdívka\"],\"miSSBQ\":[\"Komentáře (\",[\"commentCount\"],\")\"],\"mvyLSy\":[\"Uživatel je ověřen\"],\"mwtcGl\":[\"Zavřít komentáře\"],\"myL0MR\":[\"Smazat tuto síť?\"],\"mzI/c+\":[\"Stáhnout\"],\"n3fGRk\":[\"nastaveno \",[\"0\"]],\"nE9jsU\":[\"Uvolněný - méně agresivní ochrana\"],\"nNflMD\":[\"Opustit kanál\"],\"nPXkBi\":[\"Načítám data WHOIS...\"],\"nQnxxF\":[\"Zpráva #\",[\"0\"],\" (Shift+Enter pro nový řádek)\"],\"nWMRxa\":[\"Odepnout\"],\"nkC032\":[\"Žádný profil floodingu\"],\"o69z4d\":[\"Odeslat varovnou zprávu uživateli \",[\"username\"]],\"o9ylQi\":[\"Hledejte GIFy pro začátek\"],\"oFGkER\":[\"Oznámení serveru\"],\"oOi11l\":[\"Přejít dolů\"],\"oQEzQR\":[\"Nová DM\"],\"oXOSPE\":[\"Online\"],\"oal760\":[\"Útoky man-in-the-middle na serverová připojení jsou možné\"],\"oeqmmJ\":[\"Důvěryhodné zdroje\"],\"ovBPCi\":[\"Výchozí\"],\"p0Z69r\":[\"Vzor nemůže být prázdný\"],\"p1KgtK\":[\"Nepodařilo se načíst zvuk\"],\"p59pEv\":[\"Další podrobnosti\"],\"p7sRI6\":[\"Informovat ostatní, když píšete\"],\"pBm1od\":[\"Tajný kanál\"],\"pNmiXx\":[\"Vaše výchozí přezdívka pro všechny servery\"],\"pUUo9G\":[\"Hostname:\"],\"pVGPmz\":[\"Heslo účtu\"],\"peNE68\":[\"Trvalý\"],\"plhHQt\":[\"Žádná data\"],\"pm6+q5\":[\"Bezpečnostní upozornění\"],\"pn5qSs\":[\"Další informace\"],\"q0cR4S\":[\"je nyní znám jako **\",[\"newNick\"],\"**\"],\"qFcunY\":[\"Kanál se nebude zobrazovat v příkazech LIST nebo NAMES\"],\"qLpTm/\":[\"Odebrat reakci \",[\"emoji\"]],\"qVkGWK\":[\"Připnout\"],\"qY8wNa\":[\"Domovská stránka\"],\"qb0xJ7\":[\"Použijte zástupné znaky: * odpovídá libovolné sekvenci, ? odpovídá libovolnému jednomu znaku. Příklady: nick!*@*, *!*@host.com, *!*user@*\"],\"qhzpRq\":[\"Klíč kanálu (+k)\"],\"qtoOYG\":[\"Bez omezení\"],\"r1W2AS\":[\"Obrázek z file hostu\"],\"rIPR2O\":[\"Téma nastaveno před (min)\"],\"rMMSYo\":[\"Maximální délka je \",[\"0\"]],\"rWtzQe\":[\"Síť se rozdělila a znovu připojila. ✅\"],\"rYG2u6\":[\"Prosím čekejte...\"],\"rdUucN\":[\"Náhled\"],\"rjGI/Q\":[\"Soukromí\"],\"rk8iDX\":[\"Načítám GIFy...\"],\"rn6SBY\":[\"Zrušit ztlumení\"],\"s/UKqq\":[\"Byl vykopnut z kanálu\"],\"s8cATI\":[\"se připojil k \",[\"channelName\"]],\"sCO9ue\":[\"Připojení k <0>\",[\"serverName\"],\" má následující bezpečnostní problémy:\"],\"sGH11W\":[\"Server\"],\"sHI1H+\":[\"je nyní znám jako **\",[\"newNick\"],\"**\"],\"sJyV04\":[[\"inviter\"],\" vás pozval k připojení do \",[\"channel\"]],\"sUBSbK\":[\"Zatím žádné nadřazené sítě.\"],\"sby+1/\":[\"Klikněte pro kopírování\"],\"sfN25C\":[\"Vaše skutečné nebo celé jméno\"],\"sliuzR\":[\"Otevřít odkaz\"],\"sqrO9R\":[\"Vlastní zmínky\"],\"sr6RdJ\":[\"Víceřádkové na Shift+Enter\"],\"swrCpB\":[\"Kanál byl přejmenován z \",[\"oldName\"],\" na \",[\"newName\"],\" uživatelem \",[\"user\"],[\"0\"]],\"sxkWRg\":[\"Pokročilé\"],\"t/YqKh\":[\"Odebrat\"],\"t47eHD\":[\"Váš jedinečný identifikátor na tomto serveru\"],\"tAkAh0\":[\"URL s volitelnou substitucí \",[\"size\"],\" pro dynamické velikosti. Příklad: https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"tXLJS3\":[\"Zobrazit nebo skrýt boční panel se seznamem kanálů\"],\"tfDRzk\":[\"Uložit\"],\"tiBsJk\":[\"opustil \",[\"channelName\"]],\"tt4/UD\":[\"se odpojil (\",[\"reason\"],\")\"],\"u0TcnO\":[\"Přezdívka {nick} je již používána, zkouším s {newNick}\"],\"u0a8B4\":[\"Ověřit jako IRC operátor pro administrativní přístup\"],\"u0rWFU\":[\"Vytvořeno po (min. zpět)\"],\"u72w3t\":[\"Uživatelé a vzory k ignorování\"],\"u7jc2L\":[\"se odpojil\"],\"uAQUqI\":[\"Stav\"],\"uB85T3\":[\"Uložení selhalo: \",[\"msg\"]],\"uV3DOL\":[\"G-Line\"],\"uW3lLI\":[\"IRC servery:\"],\"usSSr/\":[\"Úroveň přiblížení\"],\"v7uvcf\":[\"Software:\"],\"vE8kb+\":[\"Použijte Shift+Enter pro nový řádek (Enter odešle)\"],\"vERlcd\":[\"Profil\"],\"vK0RL8\":[\"Bez tématu\"],\"vSJd18\":[\"Video\"],\"vXIe7J\":[\"Jazyk\"],\"vaHYxN\":[\"Skutečné jméno\"],\"vhjbKr\":[\"Nepřítomen\"],\"w/nogd\":[[\"0\"],\" network\",[\"1\"],\" — pick one to join\"],\"w4NYox\":[\"klient \",[\"title\"]],\"w8xQRx\":[\"Neplatná hodnota\"],\"wFjjxZ\":[\"byl vyhozen z \",[\"channelName\"],\" uživatelem \",[\"username\"],\" (\",[\"reason\"],\")\"],\"wGjaGl\":[\"Nenalezeny žádné výjimky zákazu\"],\"wPrGnM\":[\"Správce kanálu\"],\"wRkP2d\":[\"GIF\"],\"wbm86v\":[\"Zobrazovat, když uživatelé vstupují nebo opouštějí kanály\"],\"whqZ9r\":[\"Další slova nebo fráze ke zvýraznění\"],\"wm7RV4\":[\"Zvuk oznámení\"],\"wz/Yoq\":[\"Vaše zprávy mohou být zachyceny při přeposílání mezi servery\"],\"xCJdfg\":[\"Vymazat\"],\"xUHRTR\":[\"Automaticky ověřit jako operátor při připojení\"],\"xWHwwQ\":[\"Bany\"],\"xYilR2\":[\"Média\"],\"xceQrO\":[\"Jsou podporovány pouze zabezpečené websocket připojení\"],\"xdtXa+\":[\"název-kanálu\"],\"xfXC7q\":[\"Textové kanály\"],\"xlCYOE\":[\"Načítám více zpráv...\"],\"xlhswE\":[\"Minimální hodnota je \",[\"0\"]],\"xq97Ci\":[\"Přidat slovo nebo frázi...\"],\"xuRqRq\":[\"Limit klientů (+l)\"],\"xwF+7J\":[[\"0\"],\" píše...\"],\"yJztBY\":[\"Smazat síť\"],\"yNeucF\":[\"Tento server nepodporuje rozšířená metadata profilu (rozšíření IRCv3 METADATA). Další pole jako avatar, zobrazované jméno a stav nejsou k dispozici.\"],\"yPlrca\":[\"Avatar kanálu\"],\"yQE2r9\":[\"Načítání\"],\"ySU+JY\":[\"your@email.com\"],\"yTX1Rt\":[\"Uživatelské jméno operátora\"],\"yYOzWD\":[\"logy\"],\"yfx9Re\":[\"Heslo IRC operátora\"],\"ygCKqB\":[\"Zastavit\"],\"ymDxJx\":[\"Uživatelské jméno IRC operátora\"],\"yrpRsQ\":[\"Seřadit podle názvu\"],\"yz7wBu\":[\"Zavřít\"],\"zJw+jA\":[\"nastavuje režim: \",[\"0\"]],\"zebeLu\":[\"Zadejte uživatelské jméno operátora\"],\"zpr0Bw\":[\"GZ-Line\"]}"); \ No newline at end of file diff --git a/src/locales/cs/messages.po b/src/locales/cs/messages.po index b037ff1c..1de37e6b 100644 --- a/src/locales/cs/messages.po +++ b/src/locales/cs/messages.po @@ -22,6 +22,16 @@ msgstr "ObsidianIRC - Přinášíme IRC do budoucnosti" msgid "— open in viewer" msgstr "— otevřít v prohlížeči" +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx +msgid "(inherit)" +msgstr "(zdědit)" + +#: src/components/ui/BouncerNetworkForm.tsx +msgid "(unchanged)" +msgstr "(beze změny)" + #. placeholder {0}: filteredMessages.length #. placeholder {1}: import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import type * as React from "react"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { SCROLL_TOLERANCE, useScrollToBottom, } from "../../hooks/useScrollToBottom"; import { groupConsecutiveEvents } from "../../lib/eventGrouping"; import ircClient from "../../lib/ircClient"; import useStore from "../../store"; import type { Message as MessageType } from "../../types"; import { CollapsedEventMessage } from "../message/CollapsedEventMessage"; import { MessageItem } from "../message/MessageItem"; import LoadingSpinner from "../ui/LoadingSpinner"; import { ScrollToBottomButton } from "../ui/ScrollToBottomButton"; export const DEFAULT_VISIBLE_MESSAGE_COUNT = 100; // Stable empty array — prevents selector from returning a new [] on every render // when the channel has no messages yet (undefined ?? [] would create a new ref each time). const EMPTY_MESSAGES: import("../../types").Message[] = []; export interface ChannelMessageListHandle { setAtBottom: () => void; scrollToBottom: () => void; getScrollState: () => { scrollTop: number; isAtBottom: boolean; visibleCount: number; }; } interface ChannelMessageListProps { channelKey: string; serverId: string; channelId: string | null; privateChatId: string | null; isActive: boolean; searchQuery: string; isMemberListVisible: boolean; onReply: (msg: MessageType | null) => void; onUsernameContextMenu: ( e: React.MouseEvent, username: string, serverId: string, channelId: string, avatarEl?: Element | null, ) => void; onIrcLinkClick: (url: string) => void; onReactClick: (msg: MessageType, el: Element) => void; onReactionUnreact: (emoji: string, msg: MessageType) => void; onOpenReactionModal: ( msg: MessageType, position: { x: number; y: number }, ) => void; onDirectReaction: (emoji: string, msg: MessageType) => void; onRedactMessage: (msg: MessageType) => void; onOpenProfile: (username: string) => void; joinChannel: (serverId: string, channelName: string) => void; onClearSearch: () => void; highlightedMessageId?: string; // undefined = first visit; null = was at bottom; object = restore to saved position initialScrollState?: { scrollTop: number; visibleCount: number } | null; } export const ChannelMessageList = forwardRef< ChannelMessageListHandle, ChannelMessageListProps >( ( { channelKey, serverId, channelId, privateChatId, isActive, searchQuery, isMemberListVisible, onReply, onUsernameContextMenu, onIrcLinkClick, onReactClick, onReactionUnreact, onOpenReactionModal, onDirectReaction, onRedactMessage, onOpenProfile, joinChannel, onClearSearch, highlightedMessageId, initialScrollState, }, ref, ) => { const { t } = useLingui(); const [visibleMessageCount, setVisibleMessageCount] = useState( initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT, ); // Ref mirror so getScrollState closure always reads the current value without needing it as a dep. const visibleMessageCountRef = useRef(visibleMessageCount); visibleMessageCountRef.current = visibleMessageCount; // Distinguishes initial join (full-screen spinner) from subsequent "load more" (button spinner). const [isFetchingMore, setIsFetchingMore] = useState(false); const isFetchingMoreRef = useRef(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const messagesInnerRef = useRef(null); // prev scrollHeight for prepend delta-correction. const prevScrollHeightRef = useRef(0); // Ref mirror of isScrolledUp — lets useLayoutEffect closures read current value // without listing isScrolledUp as a dep (which would re-run effects on every scroll). const isScrolledUpRef = useRef(false); const prevFilteredLengthRef = useRef(0); const prevFirstMsgIdRef = useRef(null); // Set by the window-growth layoutEffect (or button handler) when a true prepend is detected. // Consumed by the delta-correction layoutEffect one render later (after visibleCount grows). // Using a flag instead of tracking displayedMessages[0]?.id because slice(-N) slides the // window on every bottom append, changing displayedMessages[0] even for non-prepend renders. const pendingPrependRef = useRef(false); // Shared scrollHeight baseline between the delta-correction layout effect and the inner // ResizeObserver. When scrollTop is corrected after a prepend, we update this so the RO's // "was at bottom" check is not fooled by the adjusted scrollTop vs its stale prevSH. const resizeObserverPrevSHRef = useRef(0); const channelMessages = useStore( useCallback( (state) => state.messages[channelKey] ?? EMPTY_MESSAGES, [channelKey], ), ); const servers = useStore((state) => state.servers); const mobileViewActiveColumn = useStore( (state) => state.ui.mobileViewActiveColumn, ); const channel = useMemo( () => channelId ? (servers .find((s) => s.id === serverId) ?.channels.find((c) => c.id === channelId) ?? null) : null, [servers, serverId, channelId], ); const { isScrolledUp, wasAtBottomRef, scrollToBottom } = useScrollToBottom( messagesContainerRef, messagesEndRef, { channelId: `${channelId || privateChatId}-${isMemberListVisible}` }, ); // Snapshot of the last known scroll position captured while the container was visible. // getScrollState() reads this instead of the live DOM because React commits display:none // before running cleanup effects, collapsing scrollTop/scrollHeight/clientHeight to 0. const lastScrollTopRef = useRef(initialScrollState?.scrollTop ?? 0); useEffect(() => { const container = messagesContainerRef.current; if (!container) return; const onScroll = () => { if (container.clientHeight > 0) lastScrollTopRef.current = container.scrollTop; }; container.addEventListener("scroll", onScroll, { passive: true }); return () => container.removeEventListener("scroll", onScroll); }, []); // Restore scroll position when a keep-alive channel transitions from hidden to visible. // display:none may reset scrollTop to 0; lastScrollTopRef was captured while visible. const prevActiveRef = useRef(isActive); useLayoutEffect(() => { if (isActive && !prevActiveRef.current) { const container = messagesContainerRef.current; if (container && lastScrollTopRef.current > 0) { container.scrollTop = lastScrollTopRef.current; } } prevActiveRef.current = isActive; }, [isActive]); useImperativeHandle(ref, () => ({ setAtBottom: () => { wasAtBottomRef.current = true; }, scrollToBottom, getScrollState: () => ({ scrollTop: lastScrollTopRef.current, isAtBottom: wasAtBottomRef.current, visibleCount: visibleMessageCountRef.current, }), })); const filteredMessages = useMemo(() => { if (!searchQuery.trim()) return channelMessages; const query = searchQuery.toLowerCase(); return channelMessages.filter( (msg) => msg.content.toLowerCase().includes(query) || msg.userId.toLowerCase().includes(query), ); }, [channelMessages, searchQuery]); useEffect(() => { isScrolledUpRef.current = isScrolledUp; // When the user returns to the bottom, shrink the window back to the base so // slice(-N) resumes trimming old messages from the top (memory optimization). // Only shrink if we grew above the base — preserves a sub-default saved visibleCount. if (!isScrolledUp) { setVisibleMessageCount((prev) => prev > DEFAULT_VISIBLE_MESSAGE_COUNT ? DEFAULT_VISIBLE_MESSAGE_COUNT : prev, ); } }, [isScrolledUp]); // Reset ref-tracked windowing state when switching channels. // visibleMessageCount is NOT reset here — useState(initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT) // already initializes it correctly on mount, and this effect runs once on mount for the // same channelKey (each instance is bound to exactly one channel by the parent key={}). // biome-ignore lint/correctness/useExhaustiveDependencies: intentional full reset on channel change useEffect(() => { prevFilteredLengthRef.current = 0; prevFirstMsgIdRef.current = null; prevScrollHeightRef.current = 0; pendingPrependRef.current = false; resizeObserverPrevSHRef.current = 0; }, [channelKey]); const displayedMessages = useMemo(() => { if (searchQuery.trim()) return filteredMessages; return filteredMessages.slice(-visibleMessageCount); }, [filteredMessages, visibleMessageCount, searchQuery]); const locallyHidden = filteredMessages.length > displayedMessages.length; const serverHasMore = channel?.hasMoreHistory === true; const hasMoreMessages = locallyHidden || serverHasMore; const eventGroups = useMemo( () => groupConsecutiveEvents(displayedMessages), [displayedMessages], ); const isLoadingHistory = channel?.isLoadingHistory ?? false; // Scroll to bottom on initial mount, unless a saved position was passed in. // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount only useEffect(() => { const container = messagesContainerRef.current; if (!container) return; if (initialScrollState) { container.scrollTop = initialScrollState.scrollTop; lastScrollTopRef.current = initialScrollState.scrollTop; wasAtBottomRef.current = false; } else { container.scrollTop = container.scrollHeight; lastScrollTopRef.current = container.scrollHeight; wasAtBottomRef.current = true; } }, []); // Scroll to bottom after initial join history loads; clear fetch spinner at batch end. const wasLoadingHistoryRef = useRef(false); // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is stable via useCallback; refs and setters are stable useLayoutEffect(() => { if (wasLoadingHistoryRef.current && !isLoadingHistory) { if (isFetchingMoreRef.current) { // delta correction for scroll position is handled by useLayoutEffect([displayedMessages]) isFetchingMoreRef.current = false; setIsFetchingMore(false); } else { scrollToBottom(); wasAtBottomRef.current = true; } } wasLoadingHistoryRef.current = isLoadingHistory; }, [isLoadingHistory]); // When older messages are prepended, grow the window so they enter displayedMessages. // When new messages arrive at the bottom while the user is scrolled up, also grow the // window to keep the current top messages visible — slice(-N) otherwise slides the // window forward and hides them, incrementing the "N older messages" counter on every // incoming message. Only let the slice trim from the top when the user is at the bottom // (where auto-scroll handles keeping them current). useLayoutEffect(() => { const newLength = filteredMessages.length; const newFirstId = filteredMessages[0]?.id ?? null; const delta = newLength - prevFilteredLengthRef.current; if (prevFilteredLengthRef.current > 0 && delta > 0) { if (newFirstId !== prevFirstMsgIdRef.current) { // Messages prepended (load-more): signal delta-correction to compensate scrollTop. pendingPrependRef.current = true; setVisibleMessageCount((prev) => prev + delta); } else if (isScrolledUpRef.current) { // Messages appended at bottom while user is scrolled up reading history. // Expand the window to prevent top messages from dropping out of the slice. setVisibleMessageCount((prev) => prev + delta); } } prevFilteredLengthRef.current = newLength; prevFirstMsgIdRef.current = newFirstId; }, [filteredMessages]); // Compensate scrollTop when content is prepended above the viewport. // biome-ignore lint/correctness/useExhaustiveDependencies: runs on every displayedMessages render to capture the resulting scrollHeight; refs are stable useLayoutEffect(() => { const container = messagesContainerRef.current; if (!container) return; // Skip while container is display:none — scrollHeight collapses to 0 and would // poison prevScrollHeightRef, causing a huge spurious delta on the next visible render. if (container.clientHeight === 0) return; const prevHeight = prevScrollHeightRef.current; const newHeight = container.scrollHeight; // Only correct when a true load-more prepend happened (flag set by the window-growth // layoutEffect or button handler). Bottom appends slide the slice(-N) window which also // changes displayedMessages[0] — ID-comparison can't distinguish the two cases. const wasPrepend = pendingPrependRef.current; // Only consume the flag when scrollHeight actually changed — the server-side load-more // path goes through two renders: Render A (filteredMessages grows, visibleCount unchanged, // same displayedMessages content, same scrollHeight) then Render B (visibleCount grows, // new messages enter displayedMessages, scrollHeight grows). The flag must survive Render A // so it's still set when Render B fires the actual correction. if (wasPrepend && newHeight !== prevHeight) { pendingPrependRef.current = false; } if ( isScrolledUpRef.current && prevHeight > 0 && newHeight > prevHeight && wasPrepend ) { const delta = newHeight - prevHeight; container.scrollTop += delta; resizeObserverPrevSHRef.current = newHeight; } prevScrollHeightRef.current = newHeight; }, [displayedMessages]); // Re-stick to bottom when inner message content grows (media/audio previews loading). // Uses prevScrollHeight instead of wasAtBottomRef to avoid stale-flag race where the // ref is true while the user is actively scrolling up. // When the container width changes (member list toggle, window resize), text reflows // and scrollHeight changes; preserve proportional scroll position for scrolled-up users. // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is a stable ref useEffect(() => { const container = messagesContainerRef.current; const inner = messagesInnerRef.current; if (!inner || !container) return; resizeObserverPrevSHRef.current = container.scrollHeight; let prevClientWidth = container.clientWidth; const observer = new ResizeObserver(() => { if (container.clientHeight === 0) return; // Effect may re-initialize while container is display:none (ref=0). // Re-seed with current dimensions and skip — no reliable "was at bottom" data. if (resizeObserverPrevSHRef.current === 0) { resizeObserverPrevSHRef.current = container.scrollHeight; prevClientWidth = container.clientWidth; return; } const currentClientWidth = container.clientWidth; const widthChanged = currentClientWidth !== prevClientWidth; prevClientWidth = currentClientWidth; const prevSH = resizeObserverPrevSHRef.current; const wasAtPrevBottom = container.scrollTop + container.clientHeight >= prevSH - SCROLL_TOLERANCE; resizeObserverPrevSHRef.current = container.scrollHeight; if (wasAtPrevBottom) { scrollToBottom(); } else if (widthChanged && prevSH > 0) { const ratio = container.scrollTop / prevSH; container.scrollTop = Math.round(ratio * container.scrollHeight); } }); observer.observe(inner); return () => observer.disconnect(); }, [isLoadingHistory, channelId, privateChatId]); // Auto-scroll on new messages — skip when this channel is hidden (display:none). // biome-ignore lint/correctness/useExhaustiveDependencies: only scroll when messages change, not when isActive changes useEffect(() => { if (!isActive) return; const isNarrowView = window.matchMedia("(max-width: 768px)").matches; const isChatVisible = !isNarrowView || mobileViewActiveColumn === "chatView"; if (wasAtBottomRef.current && isChatVisible) { scrollToBottom(); } }, [displayedMessages, mobileViewActiveColumn, scrollToBottom, isActive]); return ( <>
{isLoadingHistory && !isFetchingMore ? (
) : (
{hasMoreMessages && !searchQuery && (
)} {searchQuery && (
{plural(filteredMessages.length, { one: t`Found 1 message matching "${searchQuery}"`, other: t`Found ${filteredMessages.length} messages matching "${searchQuery}"`, })}
)} {eventGroups.map((group) => { if (group.type === "eventGroup") { const firstId = group.messages[0]?.id || ""; const lastId = group.messages[group.messages.length - 1]?.id || ""; const groupKey = `group-${firstId}-${lastId}`; return ( ); } const message = group.messages[0]; const originalIndex = channelMessages.findIndex( (m) => m.id === message.id, ); const previousMessage = channelMessages[originalIndex - 1]; const showHeader = !previousMessage || previousMessage.type !== "message" || previousMessage.userId !== message.userId || new Date(message.timestamp).getTime() - new Date(previousMessage.timestamp).getTime() > 5 * 60 * 1000; return ( ); })}
)}
); }, ); ChannelMessageList.displayName = "ChannelMessageList"; // Wrap with memo so hidden keep-alive channels skip re-renders when their props // haven't changed (e.g. when messageText changes in the input — the only thing // that changes on typing is local state inside ChatArea, not the props we pass here). export const MemoChannelMessageList = memo(ChannelMessageList); #. placeholder {2}: import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import type * as React from "react"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { SCROLL_TOLERANCE, useScrollToBottom, } from "../../hooks/useScrollToBottom"; import { groupConsecutiveEvents } from "../../lib/eventGrouping"; import ircClient from "../../lib/ircClient"; import useStore from "../../store"; import type { Message as MessageType } from "../../types"; import { CollapsedEventMessage } from "../message/CollapsedEventMessage"; import { MessageItem } from "../message/MessageItem"; import LoadingSpinner from "../ui/LoadingSpinner"; import { ScrollToBottomButton } from "../ui/ScrollToBottomButton"; export const DEFAULT_VISIBLE_MESSAGE_COUNT = 100; // Stable empty array — prevents selector from returning a new [] on every render // when the channel has no messages yet (undefined ?? [] would create a new ref each time). const EMPTY_MESSAGES: import("../../types").Message[] = []; export interface ChannelMessageListHandle { setAtBottom: () => void; scrollToBottom: () => void; getScrollState: () => { scrollTop: number; isAtBottom: boolean; visibleCount: number; }; } interface ChannelMessageListProps { channelKey: string; serverId: string; channelId: string | null; privateChatId: string | null; isActive: boolean; searchQuery: string; isMemberListVisible: boolean; onReply: (msg: MessageType | null) => void; onUsernameContextMenu: ( e: React.MouseEvent, username: string, serverId: string, channelId: string, avatarEl?: Element | null, ) => void; onIrcLinkClick: (url: string) => void; onReactClick: (msg: MessageType, el: Element) => void; onReactionUnreact: (emoji: string, msg: MessageType) => void; onOpenReactionModal: ( msg: MessageType, position: { x: number; y: number }, ) => void; onDirectReaction: (emoji: string, msg: MessageType) => void; onRedactMessage: (msg: MessageType) => void; onOpenProfile: (username: string) => void; joinChannel: (serverId: string, channelName: string) => void; onClearSearch: () => void; highlightedMessageId?: string; // undefined = first visit; null = was at bottom; object = restore to saved position initialScrollState?: { scrollTop: number; visibleCount: number } | null; } export const ChannelMessageList = forwardRef< ChannelMessageListHandle, ChannelMessageListProps >( ( { channelKey, serverId, channelId, privateChatId, isActive, searchQuery, isMemberListVisible, onReply, onUsernameContextMenu, onIrcLinkClick, onReactClick, onReactionUnreact, onOpenReactionModal, onDirectReaction, onRedactMessage, onOpenProfile, joinChannel, onClearSearch, highlightedMessageId, initialScrollState, }, ref, ) => { const { t } = useLingui(); const [visibleMessageCount, setVisibleMessageCount] = useState( initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT, ); // Ref mirror so getScrollState closure always reads the current value without needing it as a dep. const visibleMessageCountRef = useRef(visibleMessageCount); visibleMessageCountRef.current = visibleMessageCount; // Distinguishes initial join (full-screen spinner) from subsequent "load more" (button spinner). const [isFetchingMore, setIsFetchingMore] = useState(false); const isFetchingMoreRef = useRef(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const messagesInnerRef = useRef(null); // prev scrollHeight for prepend delta-correction. const prevScrollHeightRef = useRef(0); // Ref mirror of isScrolledUp — lets useLayoutEffect closures read current value // without listing isScrolledUp as a dep (which would re-run effects on every scroll). const isScrolledUpRef = useRef(false); const prevFilteredLengthRef = useRef(0); const prevFirstMsgIdRef = useRef(null); // Set by the window-growth layoutEffect (or button handler) when a true prepend is detected. // Consumed by the delta-correction layoutEffect one render later (after visibleCount grows). // Using a flag instead of tracking displayedMessages[0]?.id because slice(-N) slides the // window on every bottom append, changing displayedMessages[0] even for non-prepend renders. const pendingPrependRef = useRef(false); // Shared scrollHeight baseline between the delta-correction layout effect and the inner // ResizeObserver. When scrollTop is corrected after a prepend, we update this so the RO's // "was at bottom" check is not fooled by the adjusted scrollTop vs its stale prevSH. const resizeObserverPrevSHRef = useRef(0); const channelMessages = useStore( useCallback( (state) => state.messages[channelKey] ?? EMPTY_MESSAGES, [channelKey], ), ); const servers = useStore((state) => state.servers); const mobileViewActiveColumn = useStore( (state) => state.ui.mobileViewActiveColumn, ); const channel = useMemo( () => channelId ? (servers .find((s) => s.id === serverId) ?.channels.find((c) => c.id === channelId) ?? null) : null, [servers, serverId, channelId], ); const { isScrolledUp, wasAtBottomRef, scrollToBottom } = useScrollToBottom( messagesContainerRef, messagesEndRef, { channelId: `${channelId || privateChatId}-${isMemberListVisible}` }, ); // Snapshot of the last known scroll position captured while the container was visible. // getScrollState() reads this instead of the live DOM because React commits display:none // before running cleanup effects, collapsing scrollTop/scrollHeight/clientHeight to 0. const lastScrollTopRef = useRef(initialScrollState?.scrollTop ?? 0); useEffect(() => { const container = messagesContainerRef.current; if (!container) return; const onScroll = () => { if (container.clientHeight > 0) lastScrollTopRef.current = container.scrollTop; }; container.addEventListener("scroll", onScroll, { passive: true }); return () => container.removeEventListener("scroll", onScroll); }, []); // Restore scroll position when a keep-alive channel transitions from hidden to visible. // display:none may reset scrollTop to 0; lastScrollTopRef was captured while visible. const prevActiveRef = useRef(isActive); useLayoutEffect(() => { if (isActive && !prevActiveRef.current) { const container = messagesContainerRef.current; if (container && lastScrollTopRef.current > 0) { container.scrollTop = lastScrollTopRef.current; } } prevActiveRef.current = isActive; }, [isActive]); useImperativeHandle(ref, () => ({ setAtBottom: () => { wasAtBottomRef.current = true; }, scrollToBottom, getScrollState: () => ({ scrollTop: lastScrollTopRef.current, isAtBottom: wasAtBottomRef.current, visibleCount: visibleMessageCountRef.current, }), })); const filteredMessages = useMemo(() => { if (!searchQuery.trim()) return channelMessages; const query = searchQuery.toLowerCase(); return channelMessages.filter( (msg) => msg.content.toLowerCase().includes(query) || msg.userId.toLowerCase().includes(query), ); }, [channelMessages, searchQuery]); useEffect(() => { isScrolledUpRef.current = isScrolledUp; // When the user returns to the bottom, shrink the window back to the base so // slice(-N) resumes trimming old messages from the top (memory optimization). // Only shrink if we grew above the base — preserves a sub-default saved visibleCount. if (!isScrolledUp) { setVisibleMessageCount((prev) => prev > DEFAULT_VISIBLE_MESSAGE_COUNT ? DEFAULT_VISIBLE_MESSAGE_COUNT : prev, ); } }, [isScrolledUp]); // Reset ref-tracked windowing state when switching channels. // visibleMessageCount is NOT reset here — useState(initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT) // already initializes it correctly on mount, and this effect runs once on mount for the // same channelKey (each instance is bound to exactly one channel by the parent key={}). // biome-ignore lint/correctness/useExhaustiveDependencies: intentional full reset on channel change useEffect(() => { prevFilteredLengthRef.current = 0; prevFirstMsgIdRef.current = null; prevScrollHeightRef.current = 0; pendingPrependRef.current = false; resizeObserverPrevSHRef.current = 0; }, [channelKey]); const displayedMessages = useMemo(() => { if (searchQuery.trim()) return filteredMessages; return filteredMessages.slice(-visibleMessageCount); }, [filteredMessages, visibleMessageCount, searchQuery]); const locallyHidden = filteredMessages.length > displayedMessages.length; const serverHasMore = channel?.hasMoreHistory === true; const hasMoreMessages = locallyHidden || serverHasMore; const eventGroups = useMemo( () => groupConsecutiveEvents(displayedMessages), [displayedMessages], ); const isLoadingHistory = channel?.isLoadingHistory ?? false; // Scroll to bottom on initial mount, unless a saved position was passed in. // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount only useEffect(() => { const container = messagesContainerRef.current; if (!container) return; if (initialScrollState) { container.scrollTop = initialScrollState.scrollTop; lastScrollTopRef.current = initialScrollState.scrollTop; wasAtBottomRef.current = false; } else { container.scrollTop = container.scrollHeight; lastScrollTopRef.current = container.scrollHeight; wasAtBottomRef.current = true; } }, []); // Scroll to bottom after initial join history loads; clear fetch spinner at batch end. const wasLoadingHistoryRef = useRef(false); // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is stable via useCallback; refs and setters are stable useLayoutEffect(() => { if (wasLoadingHistoryRef.current && !isLoadingHistory) { if (isFetchingMoreRef.current) { // delta correction for scroll position is handled by useLayoutEffect([displayedMessages]) isFetchingMoreRef.current = false; setIsFetchingMore(false); } else { scrollToBottom(); wasAtBottomRef.current = true; } } wasLoadingHistoryRef.current = isLoadingHistory; }, [isLoadingHistory]); // When older messages are prepended, grow the window so they enter displayedMessages. // When new messages arrive at the bottom while the user is scrolled up, also grow the // window to keep the current top messages visible — slice(-N) otherwise slides the // window forward and hides them, incrementing the "N older messages" counter on every // incoming message. Only let the slice trim from the top when the user is at the bottom // (where auto-scroll handles keeping them current). useLayoutEffect(() => { const newLength = filteredMessages.length; const newFirstId = filteredMessages[0]?.id ?? null; const delta = newLength - prevFilteredLengthRef.current; if (prevFilteredLengthRef.current > 0 && delta > 0) { if (newFirstId !== prevFirstMsgIdRef.current) { // Messages prepended (load-more): signal delta-correction to compensate scrollTop. pendingPrependRef.current = true; setVisibleMessageCount((prev) => prev + delta); } else if (isScrolledUpRef.current) { // Messages appended at bottom while user is scrolled up reading history. // Expand the window to prevent top messages from dropping out of the slice. setVisibleMessageCount((prev) => prev + delta); } } prevFilteredLengthRef.current = newLength; prevFirstMsgIdRef.current = newFirstId; }, [filteredMessages]); // Compensate scrollTop when content is prepended above the viewport. // biome-ignore lint/correctness/useExhaustiveDependencies: runs on every displayedMessages render to capture the resulting scrollHeight; refs are stable useLayoutEffect(() => { const container = messagesContainerRef.current; if (!container) return; // Skip while container is display:none — scrollHeight collapses to 0 and would // poison prevScrollHeightRef, causing a huge spurious delta on the next visible render. if (container.clientHeight === 0) return; const prevHeight = prevScrollHeightRef.current; const newHeight = container.scrollHeight; // Only correct when a true load-more prepend happened (flag set by the window-growth // layoutEffect or button handler). Bottom appends slide the slice(-N) window which also // changes displayedMessages[0] — ID-comparison can't distinguish the two cases. const wasPrepend = pendingPrependRef.current; // Only consume the flag when scrollHeight actually changed — the server-side load-more // path goes through two renders: Render A (filteredMessages grows, visibleCount unchanged, // same displayedMessages content, same scrollHeight) then Render B (visibleCount grows, // new messages enter displayedMessages, scrollHeight grows). The flag must survive Render A // so it's still set when Render B fires the actual correction. if (wasPrepend && newHeight !== prevHeight) { pendingPrependRef.current = false; } if ( isScrolledUpRef.current && prevHeight > 0 && newHeight > prevHeight && wasPrepend ) { const delta = newHeight - prevHeight; container.scrollTop += delta; resizeObserverPrevSHRef.current = newHeight; } prevScrollHeightRef.current = newHeight; }, [displayedMessages]); // Re-stick to bottom when inner message content grows (media/audio previews loading). // Uses prevScrollHeight instead of wasAtBottomRef to avoid stale-flag race where the // ref is true while the user is actively scrolling up. // When the container width changes (member list toggle, window resize), text reflows // and scrollHeight changes; preserve proportional scroll position for scrolled-up users. // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is a stable ref useEffect(() => { const container = messagesContainerRef.current; const inner = messagesInnerRef.current; if (!inner || !container) return; resizeObserverPrevSHRef.current = container.scrollHeight; let prevClientWidth = container.clientWidth; const observer = new ResizeObserver(() => { if (container.clientHeight === 0) return; // Effect may re-initialize while container is display:none (ref=0). // Re-seed with current dimensions and skip — no reliable "was at bottom" data. if (resizeObserverPrevSHRef.current === 0) { resizeObserverPrevSHRef.current = container.scrollHeight; prevClientWidth = container.clientWidth; return; } const currentClientWidth = container.clientWidth; const widthChanged = currentClientWidth !== prevClientWidth; prevClientWidth = currentClientWidth; const prevSH = resizeObserverPrevSHRef.current; const wasAtPrevBottom = container.scrollTop + container.clientHeight >= prevSH - SCROLL_TOLERANCE; resizeObserverPrevSHRef.current = container.scrollHeight; if (wasAtPrevBottom) { scrollToBottom(); } else if (widthChanged && prevSH > 0) { const ratio = container.scrollTop / prevSH; container.scrollTop = Math.round(ratio * container.scrollHeight); } }); observer.observe(inner); return () => observer.disconnect(); }, [isLoadingHistory, channelId, privateChatId]); // Auto-scroll on new messages — skip when this channel is hidden (display:none). // biome-ignore lint/correctness/useExhaustiveDependencies: only scroll when messages change, not when isActive changes useEffect(() => { if (!isActive) return; const isNarrowView = window.matchMedia("(max-width: 768px)").matches; const isChatVisible = !isNarrowView || mobileViewActiveColumn === "chatView"; if (wasAtBottomRef.current && isChatVisible) { scrollToBottom(); } }, [displayedMessages, mobileViewActiveColumn, scrollToBottom, isActive]); return ( <>
{isLoadingHistory && !isFetchingMore ? (
) : (
{hasMoreMessages && !searchQuery && (
)} {searchQuery && (
{plural(filteredMessages.length, { one: t`Found 1 message matching "${searchQuery}"`, other: t`Found ${filteredMessages.length} messages matching "${searchQuery}"`, })}
)} {eventGroups.map((group) => { if (group.type === "eventGroup") { const firstId = group.messages[0]?.id || ""; const lastId = group.messages[group.messages.length - 1]?.id || ""; const groupKey = `group-${firstId}-${lastId}`; return ( ); } const message = group.messages[0]; const originalIndex = channelMessages.findIndex( (m) => m.id === message.id, ); const previousMessage = channelMessages[originalIndex - 1]; const showHeader = !previousMessage || previousMessage.type !== "message" || previousMessage.userId !== message.userId || new Date(message.timestamp).getTime() - new Date(previousMessage.timestamp).getTime() > 5 * 60 * 1000; return ( ); })}
)}
); }, ); ChannelMessageList.displayName = "ChannelMessageList"; // Wrap with memo so hidden keep-alive channels skip re-renders when their props // haven't changed (e.g. when messageText changes in the input — the only thing // that changes on typing is local state inside ChatArea, not the props we pass here). export const MemoChannelMessageList = memo(ChannelMessageList); @@ -46,6 +56,12 @@ msgstr "{0} a {1} píší..." msgid "{0} is typing..." msgstr "{0} píše..." +#. placeholder {0}: networks.length +#. placeholder {1}: networks.length === 1 ? "" : "s" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "{0} network{1} — pick one to join" +msgstr "" + #. placeholder {0}: filteredMessages.length - displayedMessages.length #: src/components/layout/ChannelMessageList.tsx msgid "{0} older messages" @@ -186,6 +202,12 @@ msgstr "Přidat masku pozvánky (např. nick!*@*, *!*@host.com)" msgid "Add IRC Server" msgstr "Přidat IRC server" +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworksPanel.tsx +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Add Network" +msgstr "Přidat síť" + #: src/components/message/MessageActions.tsx #: src/components/message/MessageReactions.tsx #: src/components/message/MessageReactions.tsx @@ -205,6 +227,10 @@ msgstr "Přidat pravidlo" msgid "Add Server" msgstr "Přidat server" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Add your first network" +msgstr "Přidejte svou první síť" + #: src/components/message/JsonLogMessage.tsx msgid "Additional Details" msgstr "Další podrobnosti" @@ -358,6 +384,10 @@ msgstr "Zpět" msgid "Back to image" msgstr "Zpět na obrázek" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Back to network list" +msgstr "" + #: src/components/ui/ModerationModal.tsx msgid "Ban {username} by hostmask (prevents them from rejoining from the same IP/host)" msgstr "Zabanovat {username} podle masky hostitele (zabrání opětovnému připojení ze stejné IP/hostitele)" @@ -405,6 +435,8 @@ msgstr "Procházet všechny kanály na serveru" #: src/components/ui/AddPrivateChatModal.tsx #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/ChannelSettingsModal.tsx #: src/components/ui/ExternalLinkWarningModal.tsx #: src/components/ui/FloodSettingsModal.tsx @@ -640,6 +672,7 @@ msgid "Configure notification sounds and highlights" msgstr "Konfigurovat zvuky upozornění a zvýraznění" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworksPanel.tsx msgid "Connect" msgstr "Připojit" @@ -759,6 +792,10 @@ msgstr "Smazat kanál" msgid "Delete message" msgstr "Smazat zprávu" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Delete network" +msgstr "Smazat síť" + #: src/components/layout/ChannelList.tsx msgid "Delete Private Chat" msgstr "Smazat soukromý chat" @@ -767,6 +804,10 @@ msgstr "Smazat soukromý chat" msgid "Delete this message? This cannot be undone." msgstr "Smazat tuto zprávu? Tuto akci nelze vrátit zpět." +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Delete this network?" +msgstr "Smazat tuto síť?" + #: src/components/layout/ServerList.tsx #: src/components/mobile/ServerBottomSheet.tsx msgid "Disconnect" @@ -830,10 +871,16 @@ msgstr "Stáhnout" msgid "e.g., 100:1440" msgstr "např. 100:1440" +#: src/components/ui/BouncerNetworksPanel.tsx #: src/components/ui/ChannelSettingsModal.tsx msgid "Edit" msgstr "Upravit" +#. placeholder {0}: editingNetwork?.attributes.name || editingNetwork?.netid +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Edit {0}" +msgstr "Upravit {0}" + #: src/components/ui/UserProfileModal.tsx msgid "Edit Profile" msgstr "Upravit profil" @@ -1057,6 +1104,7 @@ msgstr "DOMŮ" msgid "Homepage" msgstr "Domovská stránka" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserProfileModal.tsx msgid "Host" msgstr "Hostitel" @@ -1271,6 +1319,10 @@ msgstr "Opustil kanál" msgid "Let others know when you are typing" msgstr "Informovat ostatní, když píšete" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Libera Chat" +msgstr "Libera Chat" + #: src/components/message/LinkPreview.tsx msgid "Link preview" msgstr "Náhled odkazu" @@ -1299,6 +1351,10 @@ msgstr "Načítám GIFy..." msgid "Loading more channels..." msgstr "Načítám více kanálů..." +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Loading networks from your bouncer…" +msgstr "Načítání sítí z vašeho bounceru…" + #: src/components/ui/UserProfileModal.tsx msgid "Loading WHOIS data..." msgstr "Načítám data WHOIS..." @@ -1486,9 +1542,15 @@ msgid "Name:" msgstr "Název:" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx msgid "Network Name" msgstr "Název sítě" +#. placeholder {0}: server?.name ?? bouncerServerId +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Networks on {0}" +msgstr "Sítě na {0}" + #: src/components/ui/QuickActions.tsx msgid "New DM" msgstr "Nová DM" @@ -1511,6 +1573,7 @@ msgid "nick!user@host (e.g., spam*!*@*, *!*@badhost.com)" msgstr "nick!user@host (např. spam*!*@*, *!*@badhost.com)" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserSettings.tsx #: src/lib/settings/definitions/allSettings.ts msgid "Nickname" @@ -1570,6 +1633,10 @@ msgstr "Žádný soubor nevybrán" msgid "No flood profile" msgstr "Žádný profil floodingu" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "no host set" +msgstr "není nastaven hostitel" + #: src/components/ui/ChannelSettingsModal.tsx msgid "No invitations found" msgstr "Nenalezeny žádné pozvánky" @@ -1610,6 +1677,10 @@ msgstr "Téma není nastaveno" msgid "No unread mentions or messages" msgstr "Žádné nepřečtené zmínky ani zprávy" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "No upstream networks yet." +msgstr "Zatím žádné nadřazené sítě." + #: src/components/ui/AddPrivateChatModal.tsx msgid "No users available" msgstr "Žádní uživatelé nejsou k dispozici" @@ -1696,6 +1767,10 @@ msgstr "Jejda! Síť se rozdělila! ⚠️" msgid "Op" msgstr "Op" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Open" +msgstr "" + #: src/components/ui/QuickActions/uiActionConfig.tsx msgid "Open channel configuration settings" msgstr "Otevřít nastavení konfigurace kanálu" @@ -1799,6 +1874,10 @@ msgstr "Připnout soukromou konverzaci" msgid "Pin this private message conversation" msgstr "Připnout tuto soukromou konverzaci" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Plaintext" +msgstr "Prostý text" + #: src/components/message/MediaPreview.tsx #: src/components/message/MediaPreview.tsx #: src/components/message/MediaPreview.tsx @@ -1827,6 +1906,7 @@ msgid "PM User" msgstr "Soukromá zpráva uživateli" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx msgid "Port" msgstr "Port" @@ -1918,6 +1998,7 @@ msgstr "reagoval na tuto zprávu" msgid "Read more" msgstr "Číst více" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserProfileModal.tsx #: src/components/ui/UserSettings.tsx #: src/lib/settings/definitions/allSettings.ts @@ -2002,6 +2083,7 @@ msgstr "Pravidla" msgid "Safe" msgstr "Bezpečné" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/ChannelSettingsModal.tsx #: src/components/ui/TopicModal.tsx #: src/components/ui/UserSettings.tsx @@ -2183,6 +2265,10 @@ msgstr "Operátoři serveru v síti by potenciálně mohli číst vaše zprávy" msgid "Server Password" msgstr "Heslo serveru" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Server Password (PASS)" +msgstr "Heslo serveru (PASS)" + #: src/components/ui/LinkSecurityWarningModal.tsx msgid "Server-to-server communication may use unencrypted connections" msgstr "Komunikace mezi servery může používat nešifrovaná připojení" @@ -2378,6 +2464,10 @@ msgstr "Čas (min)" msgid "Time Window (seconds)" msgstr "Časové okno (sekundy)" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "TLS" +msgstr "TLS" + #: src/components/message/WhisperMessage.tsx #: src/components/message/WhisperMessage.tsx msgid "to" @@ -2426,6 +2516,10 @@ msgstr "Téma:" msgid "Total: {0}" msgstr "Celkem: {0}" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Transport" +msgstr "Přenos" + #: src/components/ui/UserSettings.tsx msgid "Trusted Sources" msgstr "Důvěryhodné zdroje" @@ -2536,6 +2630,7 @@ msgstr "Profil uživatele" msgid "User Settings" msgstr "Nastavení uživatele" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/InviteUserModal.tsx #: src/components/ui/ModerationModal.tsx msgid "Username" @@ -2683,6 +2778,10 @@ msgstr "Široký - širší rozsah ochrany" msgid "Will default to 'no reason' if left empty" msgstr "Výchozí bude 'bez důvodu', pokud ponecháte prázdné" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Yes, delete" +msgstr "Ano, smazat" + #: src/components/message/CollapsedEventMessage.tsx #: src/components/message/EventMessage.tsx msgid "You" @@ -2713,6 +2812,10 @@ msgstr "Heslo vašeho účtu pro ověření" msgid "Your account username for authentication" msgstr "Uživatelské jméno vašeho účtu pro ověření" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Your bouncer doesn't have any networks yet. Add one to get started." +msgstr "Váš bouncer zatím nemá žádné sítě. Přidejte jednu pro začátek." + #: src/lib/settings/definitions/allSettings.ts msgid "Your default nickname for all servers" msgstr "Vaše výchozí přezdívka pro všechny servery" diff --git a/src/locales/de/messages.mjs b/src/locales/de/messages.mjs index 54c075be..c4643565 100644 --- a/src/locales/de/messages.mjs +++ b/src/locales/de/messages.mjs @@ -1 +1 @@ -/*eslint-disable*/export const messages=JSON.parse("{\"+5VMrz\":[\"Ungültiges Musterformat. Verwenden Sie nick!user@host (Platzhalter * erlaubt)\"],\"+6NQQA\":[\"Allgemeiner Support-Kanal\"],\"+6NyRG\":[\"Client\"],\"+K0AvT\":[\"Trennen\"],\"+cyFdH\":[\"Standardnachricht beim Als-abwesend-markieren\"],\"+mVPqU\":[\"Markdown-Formatierung in Nachrichten rendern\"],\"+vqCJH\":[\"Ihr Kontobenutzername zur Authentifizierung\"],\"+yPBXI\":[\"Datei auswählen\"],\"+zy2Nq\":[\"Typ\"],\"/09cao\":[\"Geringe Verbindungssicherheit (Stufe \",[\"securityLevel\"],\")\"],\"/3BQ4J\":[\"Externe Benutzer können keine Nachrichten senden\"],\"/6BzZF\":[\"Mitgliederliste umschalten\"],\"/TNOPk\":[\"Benutzer ist abwesend\"],\"/XQgft\":[\"Entdecken\"],\"/cF7Rs\":[\"Lautstärke\"],\"/dqduX\":[\"Nächste Seite\"],\"/fc3q4\":[\"Alle Inhalte\"],\"/kISDh\":[\"Benachrichtigungstöne aktivieren\"],\"/n04sB\":[\"Kill\"],\"/rTz0M\":[\"Audio\"],\"/rfkZe\":[\"Töne bei Erwähnungen und Nachrichten abspielen\"],\"0/0ZGA\":[\"Kanalname-Maske\"],\"0D6j7U\":[\"Mehr über benutzerdefinierte Regeln erfahren →\"],\"0XsHcR\":[\"Benutzer rauswerfen\"],\"0ZpE//\":[\"Nach Benutzern sortieren\"],\"0bEPwz\":[\"Als abwesend setzen\"],\"0dGkPt\":[\"Kanalliste ausklappen\"],\"0gS7M5\":[\"Anzeigename\"],\"0kS+M8\":[\"BeispielNET\"],\"0rgoY7\":[\"Nur mit ausgewählten Servern verbinden\"],\"0wdd7X\":[\"Beitreten\"],\"0wkVYx\":[\"Privatnachrichten\"],\"111uHX\":[\"Link-Vorschau\"],\"196EG4\":[\"Privatnachricht löschen\"],\"1DSr1i\":[\"Konto registrieren\"],\"1O/24y\":[\"Kanalliste umschalten\"],\"1VPJJ2\":[\"Warnung: Externer Link\"],\"1ZC/dv\":[\"Keine ungelesenen Erwähnungen oder Nachrichten\"],\"1pO1zi\":[\"Servername ist erforderlich\"],\"1uwfzQ\":[\"Kanalthema anzeigen\"],\"268g7c\":[\"Anzeigenamen eingeben\"],\"2FOFq1\":[\"Server-Operatoren im Netzwerk könnten deine Nachrichten lesen\"],\"2FYpfJ\":[\"Mehr\"],\"2HF1Y2\":[[\"inviter\"],\" hat \",[\"target\"],\" eingeladen, \",[\"channel\"],\" beizutreten\"],\"2I70QL\":[\"Benutzerprofilinformationen anzeigen\"],\"2QYdmE\":[\"Benutzer:\"],\"2QpEjG\":[\"hat verlassen\"],\"2YE223\":[\"Nachricht an #\",[\"0\"],\" (Enter für neue Zeile, Shift+Enter zum Senden)\"],\"2bimFY\":[\"Server-Passwort verwenden\"],\"2iTmdZ\":[\"Lokaler Speicher:\"],\"2odkwe\":[\"Streng – Aggressiverer Schutz\"],\"2uDhbA\":[\"Benutzername zum Einladen eingeben\"],\"2ygf/L\":[\"← Zurück\"],\"2zEgxj\":[\"GIFs suchen...\"],\"3RdPhl\":[\"Kanal umbenennen\"],\"3THokf\":[\"Benutzer mit Sprachrecht\"],\"3TSz9S\":[\"Minimieren\"],\"3jBDvM\":[\"Kanal-Anzeigename\"],\"3ryuFU\":[\"Optionale Absturzberichte zur App-Verbesserung\"],\"3uBF/8\":[\"Ansicht schließen\"],\"3uwW8F\":[\"https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"3xf8Kz\":[\"Kontoname eingeben...\"],\"4/Rr0R\":[\"Benutzer in den aktuellen Kanal einladen\"],\"4EZrJN\":[\"Regeln\"],\"4JJtW9\":[\"#overflow\"],\"4NqeT4\":[\"Flood-Profil (+F)\"],\"4RZQRK\":[\"Was machst du gerade?\"],\"4hfTrB\":[\"Nickname\"],\"4n99LO\":[\"Bereits in \",[\"0\"]],\"4t6vMV\":[\"Kurze Nachrichten automatisch einzeilig darstellen\"],\"4vsHmf\":[\"Zeit (Min)\"],\"5+INAX\":[\"Nachrichten hervorheben, die Sie erwähnen\"],\"5R5Pv/\":[\"Oper Name\"],\"678PKt\":[\"Netzwerkname\"],\"6Aih4U\":[\"Offline\"],\"6CO3WE\":[\"Passwort zum Beitreten erforderlich. Leer lassen, um den Schlüssel zu entfernen.\"],\"6HhMs3\":[\"Abgangsnachricht\"],\"6V3Ea3\":[\"Kopiert\"],\"6lGV3K\":[\"Weniger anzeigen\"],\"6yFOEi\":[\"Oper-Passwort eingeben...\"],\"7+IHTZ\":[\"Keine Datei ausgewählt\"],\"73hrRi\":[\"nick!user@host (z.B. spam*!*@*, *!*@badhost.com)\"],\"7QkKyN\":[\"Privatnachricht senden\"],\"7U1W7c\":[\"Sehr locker\"],\"7Y1YQj\":[\"Echter Name:\"],\"7YHArF\":[\"— im Viewer öffnen\"],\"7fjnVl\":[\"Benutzer suchen...\"],\"7jL88x\":[\"Diese Nachricht löschen? Dies kann nicht rückgängig gemacht werden.\"],\"7nGhhM\":[\"Was denkst du gerade?\"],\"7sEpu1\":[\"Mitglieder — \",[\"0\"]],\"7sNhEz\":[\"Benutzername\"],\"8H0Q+x\":[\"Mehr über Profile erfahren →\"],\"8Phu0A\":[\"Anzeigen, wenn Benutzer ihren Nickname ändern\"],\"8XTG9e\":[\"oper-Passwort eingeben\"],\"8XsV2J\":[\"Erneut senden\"],\"8ZsakT\":[\"Passwort\"],\"8kR84m\":[\"Du bist dabei, einen externen Link zu öffnen:\"],\"8lCgih\":[\"Regel entfernen\"],\"8p/xVT\":[[\"0\",\"plural\",{\"one\":[[\"1\"]],\"other\":[[\"2\"]]}]],\"8wRzac\":[[\"joinCount\",\"plural\",{\"one\":[\"beigetreten\"],\"other\":[[\"joinCount\"],\"-mal beigetreten\"]}]],\"9BMLnJ\":[\"Erneut mit Server verbinden\"],\"9OEgyT\":[\"Reaktion hinzufügen\"],\"9PQ8m2\":[\"G-Line (globaler Ban)\"],\"9Qs99X\":[\"E-Mail:\"],\"9QupBP\":[\"Muster entfernen\"],\"9bG48P\":[\"Wird gesendet\"],\"9f5f0u\":[\"Fragen zum Datenschutz? Kontaktieren Sie uns:\"],\"9unqs3\":[\"Abwesend:\"],\"9v3hwv\":[\"Keine Server gefunden.\"],\"9zb2WA\":[\"Verbinden...\"],\"A1taO8\":[\"Suchen\"],\"A2adVi\":[\"Tipp-Benachrichtigungen senden\"],\"A9Rhec\":[\"Kanalname\"],\"AWOSPo\":[\"Vergrößern\"],\"AXSpEQ\":[\"Oper beim Verbinden\"],\"AeXO77\":[\"Konto\"],\"AhNP40\":[\"Vor-/Zurückspulen\"],\"Ai2U7L\":[\"Host\"],\"AjBQnf\":[\"Nickname geändert\"],\"AmXVh6\":[\"https://example.com/avatar.png\"],\"AnRu/j\":[\"Antwort abbrechen\"],\"ApSx0O\":[[\"0\"],\" Nachrichten gefunden, die zu \\\"\",[\"searchQuery\"],\"\\\" passen\"],\"AxPAXW\":[\"Keine Ergebnisse gefunden\"],\"AyNqAB\":[\"Alle Serverereignisse im Chat anzeigen\"],\"B/QqGw\":[\"Nicht am Rechner\"],\"B8AaMI\":[\"Dieses Feld ist erforderlich\"],\"BA2c49\":[\"Server unterstützt keine erweiterte LIST-Filterung\"],\"BDKt3I\":[[\"0\"],\", \",[\"1\"],\", \",[\"2\"],\" und \",[\"3\"],\" weitere tippen...\"],\"BGul2A\":[\"Du hast ungespeicherte Änderungen. Möchtest du wirklich schließen, ohne zu speichern?\"],\"BIf9fi\":[\"Ihre Statusnachricht\"],\"BZz3md\":[\"Ihre persönliche Website\"],\"Bgm/H7\":[\"Mehrzeilige Texteingabe erlauben\"],\"BiQIl1\":[\"Dieses Privatgespräch anheften\"],\"BlNZZ2\":[\"Klicken, um zur Nachricht zu springen\"],\"Bowq3c\":[\"Nur Operatoren können das Kanalthema ändern\"],\"Btozzp\":[\"Dieses Bild ist abgelaufen\"],\"Bycfjm\":[\"Gesamt: \",[\"0\"]],\"C6IBQc\":[\"Gesamtes JSON kopieren\"],\"C9L9wL\":[\"Datenerfassung\"],\"CDq4wC\":[\"Benutzer moderieren\"],\"CHVRxG\":[\"Nachricht an @\",[\"0\"],\" (Shift+Enter für neue Zeile)\"],\"CN9zdR\":[\"Oper-Name und Passwort sind erforderlich\"],\"CW3sYa\":[\"Reaktion \",[\"emoji\"],\" hinzufügen\"],\"CaAkqd\":[\"Verbindungstrennungen anzeigen\"],\"CbvaYj\":[\"Nach Nickname sperren\"],\"CcK+Ft\":[\"PDF\"],\"Ce8q3L\":[\"Kanal auswählen\"],\"CsekCi\":[\"Normal\"],\"D+NlUC\":[\"System\"],\"D28t6+\":[\"ist beigetreten und gegangen\"],\"DB8zMK\":[\"Anwenden\"],\"DBcWHr\":[\"Benutzerdefinierte Benachrichtigungstondatei\"],\"DTy9Xw\":[\"Medienvorschauen\"],\"Dj4pSr\":[\"Sicheres Passwort wählen\"],\"Du+zn+\":[\"Suche...\"],\"Du2T2f\":[\"Einstellung nicht gefunden\"],\"DwsSVQ\":[\"Filter anwenden & Aktualisieren\"],\"E3W/zd\":[\"Standard-Nickname\"],\"E6nRW7\":[\"URL kopieren\"],\"E703RG\":[\"Modi:\"],\"EAeu1Z\":[\"Einladung senden\"],\"EFKJQT\":[\"Einstellung\"],\"EGPQBv\":[\"Benutzerdefinierte Flood-Regeln (+f)\"],\"ELik0r\":[\"Vollständige Datenschutzrichtlinie anzeigen\"],\"EPbeC2\":[\"Kanalthema anzeigen oder bearbeiten\"],\"EQCDNT\":[\"Oper-Benutzernamen eingeben...\"],\"EUvulZ\":[\"1 Nachricht gefunden, die zu \\\"\",[\"searchQuery\"],\"\\\" passt\"],\"EatZYJ\":[\"Nächstes Bild\"],\"EdQY6l\":[\"Keine\"],\"EnqLYU\":[\"Server suchen...\"],\"F0OKMc\":[\"Server bearbeiten\"],\"F6Int2\":[\"Hervorhebungen aktivieren\"],\"FDoLyE\":[\"Max. Benutzer\"],\"FUU/hZ\":[\"Steuert, wie viele externe Medien im Chat geladen werden.\"],\"Fdp03t\":[\"an\"],\"FfPWR0\":[\"Modal\"],\"FjkaiT\":[\"Verkleinern\"],\"FlqOE9\":[\"Was das bedeutet:\"],\"FolHNl\":[\"Konto und Authentifizierung verwalten\"],\"Fp2Dif\":[\"Den Server verlassen\"],\"G5KmCc\":[\"GZ-Line (globale Z-Line)\"],\"GDs0lz\":[\"<0>Risiko: Sensible Informationen (Nachrichten, private Gespräche, Authentifizierungsdaten) könnten Netzwerkadministratoren oder Angreifern zwischen IRC-Servern zugänglich sein.\"],\"GR+2I3\":[\"Einladungs-Maske hinzufügen (z.B. nick!*@*, *!*@host.com)\"],\"GRLyMU\":[\"Server-Hinweise schließen\"],\"GlHnXw\":[\"Nicknamewechsel fehlgeschlagen: \",[\"error\"],\" \",[\"0\"]],\"GswZF3\":[\"Vorschau:\"],\"GtmO8/\":[\"von\"],\"GtuHUQ\":[\"Diesen Kanal auf dem Server umbenennen. Alle Benutzer sehen den neuen Namen.\"],\"GuGfFX\":[\"Suche umschalten\"],\"GxkJXS\":[\"Wird hochgeladen...\"],\"GzbwnK\":[\"Dem Kanal beigetreten\"],\"GzsUDB\":[\"Erweitertes Profil\"],\"H/PnT8\":[\"Emoji einfügen\"],\"H6Izzl\":[\"Ihr bevorzugter Farbcode\"],\"H9jIv+\":[\"Beitritte/Abgänge anzeigen\"],\"HAKBY9\":[\"Dateien hochladen\"],\"HdE1If\":[\"Kanal\"],\"Hk4AW9\":[\"Ihr bevorzugter Anzeigename\"],\"HmHDk7\":[\"Mitglied auswählen\"],\"HrQzPU\":[\"Kanäle auf \",[\"networkName\"]],\"I2tXQ5\":[\"Nachricht an @\",[\"0\"],\" (Enter für neue Zeile, Shift+Enter zum Senden)\"],\"I6bw/h\":[\"Benutzer sperren\"],\"I92Z+b\":[\"Benachrichtigungen aktivieren\"],\"I9D72S\":[\"Bist du sicher, dass du diese Nachricht löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.\"],\"IA+1wo\":[\"Anzeigen, wenn Benutzer aus Kanälen gekickt werden\"],\"IDwkJx\":[\"IRC Operator\"],\"ILlU+s\":[\"Info:\"],\"IUwGEM\":[\"Änderungen speichern\"],\"IVeGK6\":[[\"0\"],\", \",[\"1\"],\" und \",[\"2\"],\" tippen...\"],\"IgrLD/\":[\"Pause\"],\"Im6JED\":[\"WHISPER\"],\"ImOQa9\":[\"Antworten\"],\"IoHMnl\":[\"Maximalwert ist \",[\"0\"]],\"IvMj+0\":[\"Op\"],\"J28zul\":[\"Verbinde...\"],\"J5T9NW\":[\"Benutzerinformationen\"],\"J8Y5+z\":[\"Ups! Netz-Split! ⚠️\"],\"JBHkBA\":[\"Den Kanal verlassen\"],\"JCwL0Q\":[\"Grund eingeben (optional)\"],\"JFciKP\":[\"Umschalten\"],\"JXGkhG\":[\"Kanalnamen ändern (nur Operatoren)\"],\"JcD7qf\":[\"Weitere Aktionen\"],\"JdkA+c\":[\"Geheim (+s)\"],\"Jmu12l\":[\"Serverkanäle\"],\"JvQ++s\":[\"Markdown aktivieren\"],\"K2jwh/\":[\"Keine WHOIS-Daten verfügbar\"],\"KAXSwC\":[\"Voice\"],\"KDfTdX\":[\"Nachricht löschen\"],\"KKBlUU\":[\"Einbetten\"],\"KM0pLb\":[\"Willkommen im Kanal!\"],\"KR6W2h\":[\"Benutzer nicht mehr ignorieren\"],\"KV+Bi1\":[\"Nur auf Einladung (+i)\"],\"KdCtwE\":[\"Wie viele Sekunden Flood-Aktivität überwacht wird, bevor die Zähler zurückgesetzt werden\"],\"Kkezga\":[\"Server-Passwort\"],\"KsiQ/8\":[\"Benutzer müssen eingeladen werden\"],\"L+gB/D\":[\"Kanalinformationen\"],\"LC1a7n\":[\"Der IRC-Server hat gemeldet, dass seine Server-zu-Server-Verbindungen ein niedriges Sicherheitsniveau aufweisen. Das bedeutet, dass deine Nachrichten beim Weiterleiten zwischen IRC-Servern im Netzwerk möglicherweise nicht ordnungsgemäß verschlüsselt sind oder die SSL/TLS-Zertifikate nicht korrekt validiert werden.\"],\"LNfLR5\":[\"Kicks anzeigen\"],\"LQb0W/\":[\"Alle Ereignisse anzeigen\"],\"LU7/yA\":[\"Alternativer Anzeigename. Kann Leerzeichen, Emojis und Sonderzeichen enthalten. Der echte Kanalname (\",[\"channelName\"],\") wird weiterhin für IRC-Befehle verwendet.\"],\"LUb9O7\":[\"Ein gültiger Server-Port ist erforderlich\"],\"Lb+BUl\":[\"https://example.com/avatar.jpg\"],\"LcET2C\":[\"Datenschutzrichtlinie\"],\"LcuSDR\":[\"Profilinformationen und Metadaten verwalten\"],\"LqLS9B\":[\"Nickwechsel anzeigen\"],\"LsDQt2\":[\"Kanaleinstellungen\"],\"LtI9AS\":[\"Eigentümer\"],\"LuNhhL\":[\"hat auf diese Nachricht reagiert\"],\"M/AZNG\":[\"URL zu Ihrem Avatar-Bild\"],\"M/WIer\":[\"Nachricht senden\"],\"M8er/5\":[\"Name:\"],\"MHk+7g\":[\"Vorheriges Bild\"],\"MRorGe\":[\"Benutzer anschreiben\"],\"MVbSGP\":[\"Zeitfenster (Sekunden)\"],\"MkpcsT\":[\"Ihre Nachrichten und Einstellungen werden lokal gespeichert\"],\"N/hDSy\":[\"Als Bot markieren – normalerweise 'on' oder leer\"],\"N7TQbE\":[\"Benutzer zu \",[\"channelName\"],\" einladen\"],\"NCca/o\":[\"Standard-Spitznamen eingeben...\"],\"Nqs6B9\":[\"Zeigt alle externen Medien. Jede URL kann eine Anfrage an einen unbekannten Server auslösen.\"],\"Nt+9O7\":[\"WebSocket statt rohem TCP verwenden\"],\"NxIHzc\":[\"Benutzer trennen\"],\"O+v/cL\":[\"Alle Kanäle auf dem Server durchsuchen\"],\"ODwSCk\":[\"GIF senden\"],\"OGQ5kK\":[\"Benachrichtigungstöne und Hervorhebungen konfigurieren\"],\"OIPt1Z\":[\"Seitenleiste der Mitgliederliste ein- oder ausblenden\"],\"OKSNq/\":[\"Sehr streng\"],\"ONWvwQ\":[\"Hochladen\"],\"OVKoQO\":[\"Ihr Kontopasswort zur Authentifizierung\"],\"ObsidianIRC - Bringing IRC to the future\":[\"ObsidianIRC - IRC in die Zukunft bringen\"],\"OhCpra\":[\"Thema setzen…\"],\"OkltoQ\":[[\"username\"],\" per Nickname sperren (verhindert erneutes Beitreten mit demselben Nick)\"],\"P+t/Te\":[\"Keine weiteren Daten\"],\"P42Wcc\":[\"Sicher\"],\"PD38l0\":[\"Kanal-Avatar-Vorschau\"],\"PD9mEt\":[\"Nachricht eingeben...\"],\"PPqfdA\":[\"Kanaleinstellungen öffnen\"],\"PSCjfZ\":[\"Das Thema für diesen Kanal. Alle Benutzer können es sehen.\"],\"PZCecv\":[\"PDF-Vorschau\"],\"PeLgsC\":[[\"c\",\"plural\",{\"one\":[\"1 Mal\"],\"other\":[[\"c\"],\" Mal\"]}]],\"PguS2C\":[\"Ausnahme-Maske hinzufügen (z.B. nick!*@*, *!*@host.com)\"],\"Pil5Ty\":[[\"displayedChannelsCount\"],\" von \",[\"0\"],\" Kanälen angezeigt\"],\"PqhVlJ\":[\"Benutzer sperren (per Hostmask)\"],\"Q+chwU\":[\"Benutzername:\"],\"Q6hhn8\":[\"Einstellungen\"],\"QF4a34\":[\"Bitte gib einen Benutzernamen ein\"],\"QGqSZ2\":[\"Farbe & Formatierung\"],\"QJQd1J\":[\"Profil bearbeiten\"],\"QSzGDE\":[\"Inaktiv\"],\"QUlny5\":[\"Willkommen bei \",[\"0\"],\"!\"],\"Qoq+GP\":[\"Mehr lesen\"],\"QuSkCF\":[\"Kanäle filtern...\"],\"QwUrDZ\":[\"hat das Thema geändert zu: \",[\"topic\"]],\"R0UH07\":[\"Bild \",[\"0\"],\" von \",[\"1\"]],\"R7SsBE\":[\"Stumm schalten\"],\"R8rf1X\":[\"Klicken, um das Thema zu setzen\"],\"RArB3D\":[\"wurde von \",[\"username\"],\" aus \",[\"channelName\"],\" gekickt\"],\"RI3cWd\":[\"Entdecke die Welt von IRC mit ObsidianIRC\"],\"RMMaN5\":[\"Moderiert (+m)\"],\"RWw9Lg\":[\"Fenster schließen\"],\"RZ2BuZ\":[\"Kontoregistrierung für \",[\"account\"],\" erfordert Verifizierung: \",[\"message\"]],\"RySp6q\":[\"Kommentare ausblenden\"],\"SPKQTd\":[\"Nickname ist erforderlich\"],\"SPVjfj\":[\"Standardmäßig 'kein Grund', wenn leer gelassen\"],\"SQKPvQ\":[\"Benutzer einladen\"],\"SkZcl+\":[\"Wähle ein vordefiniertes Flood-Schutzprofil. Diese Profile bieten ausgewogene Schutzeinstellungen für verschiedene Anwendungsfälle.\"],\"Slr+3C\":[\"Min. Benutzer\"],\"Spnlre\":[\"Du hast \",[\"target\"],\" eingeladen, \",[\"channel\"],\" beizutreten\"],\"T/ckN5\":[\"Im Viewer öffnen\"],\"T91vKp\":[\"Abspielen\"],\"TV2Wdu\":[\"Erfahren Sie, wie wir Ihre Daten verwalten und Ihre Privatsphäre schützen.\"],\"TgFpwD\":[\"Wird angewendet...\"],\"TkzSFB\":[\"Keine Änderungen\"],\"TtserG\":[\"Echten Namen eingeben\"],\"Ttz9J1\":[\"Passwort eingeben...\"],\"Tz0i8g\":[\"Einstellungen\"],\"U3pytU\":[\"Admin\"],\"UDb2YD\":[\"Reagieren\"],\"UE4KO5\":[\"*channel*\"],\"UGT5vp\":[\"Einstellungen speichern\"],\"UV5hLB\":[\"Keine Sperren gefunden\"],\"Uaj3Nd\":[\"Statusnachrichten\"],\"Ue3uny\":[\"Standard (kein Profil)\"],\"UkARhe\":[\"Normal – Standardschutz\"],\"Umn7Cj\":[\"Noch keine Kommentare. Sei der Erste!\"],\"UtUIRh\":[[\"0\"],\" ältere Nachrichten\"],\"UwzP+U\":[\"Sichere Verbindung\"],\"V0/A4O\":[\"Kanalbesitzer\"],\"V4qgxE\":[\"Erstellt vor (Min.)\"],\"V8yTm6\":[\"Suche löschen\"],\"VJMMyz\":[\"ObsidianIRC - IRC in die Zukunft bringen\"],\"VJScHU\":[\"Grund\"],\"VLsmVV\":[\"Benachrichtigungen stummschalten\"],\"VbyRUy\":[\"Kommentare\"],\"Vmx0mQ\":[\"Gesetzt von:\"],\"VqnIZz\":[\"Datenschutzrichtlinie und Datenpraktiken anzeigen\"],\"VrMygG\":[\"Mindestlänge ist \",[\"0\"]],\"VrnTui\":[\"Ihre Pronomen, im Profil angezeigt\"],\"W8E3qn\":[\"Authentifiziertes Konto\"],\"WAakm9\":[\"Kanal löschen\"],\"WFxTHC\":[\"Bann-Maske hinzufügen (z.B. nick!*@*, *!*@host.com)\"],\"WN1g9F\":[\"Server-Host ist erforderlich\"],\"WRYdXW\":[\"Audioposition\"],\"WUOH5B\":[\"Benutzer ignorieren\"],\"WWEXnZ\":[[\"0\",\"plural\",{\"one\":[\"1 weiteres Element anzeigen\"],\"other\":[[\"1\"],\" weitere Elemente anzeigen\"]}]],\"Weq9zb\":[\"Allgemein\"],\"Wfj7Sk\":[\"Benachrichtigungstöne stummschalten oder aktivieren\"],\"Wm7gbG\":[\"GitHub:\"],\"WyeHWY\":[\"*spam*\"],\"WzMCru\":[\"Benutzerprofil\"],\"X6S3lt\":[\"Einstellungen, Kanäle, Server suchen...\"],\"XEHan5\":[\"Trotzdem fortfahren\"],\"XI1+wb\":[\"Ungültiges Format\"],\"XIXeuC\":[\"Nachricht an @\",[\"0\"]],\"XMS+k4\":[\"Privatnachricht starten\"],\"XWgxXq\":[\"Album\"],\"Xd7+IT\":[\"Privatnachricht loslösen\"],\"Xm/s+u\":[\"Anzeige\"],\"Xp2n93\":[\"Zeigt Medien vom vertrauenswürdigen Datei-Host deines Servers. Es werden keine Anfragen an externe Dienste gestellt.\"],\"XvjC4F\":[\"Wird gespeichert...\"],\"Y/qryO\":[\"Keine Benutzer gefunden, die deiner Suche entsprechen\"],\"YAqRpI\":[\"Kontoregistrierung für \",[\"account\"],\" erfolgreich: \",[\"message\"]],\"YEfzvP\":[\"Geschütztes Thema (+t)\"],\"YQOn6a\":[\"Mitgliederliste einklappen\"],\"YRCoE9\":[\"Kanal-Operator\"],\"YURQaF\":[\"Profil anzeigen\"],\"YdBSvr\":[\"Medienanzeige und externe Inhalte steuern\"],\"Yj6U3V\":[\"Kein zentraler Server:\"],\"YjvpGx\":[\"Pronomen\"],\"YqH4l4\":[\"Kein Schlüssel\"],\"YyUPpV\":[\"Konto:\"],\"ZJSWfw\":[\"Nachricht beim Trennen vom Server\"],\"ZR1dJ4\":[\"Einladungen\"],\"ZdWg0V\":[\"Im Browser öffnen\"],\"ZhRBbl\":[\"Nachrichten suchen…\"],\"Zmcu3y\":[\"Erweiterte Filter\"],\"a2/8e5\":[\"Thema gesetzt nach (Min.)\"],\"aHKcKc\":[\"Vorherige Seite\"],\"aJTbXX\":[\"Oper Password\"],\"aQryQv\":[\"Muster existiert bereits\"],\"aW9pLN\":[\"Maximale Anzahl der zugelassenen Benutzer. Leer lassen für kein Limit.\"],\"ah4fmZ\":[\"Zeigt auch Vorschauen von YouTube, Vimeo, SoundCloud und ähnlichen bekannten Diensten.\"],\"aifXak\":[\"Keine Medien in diesem Kanal\"],\"ap2zBz\":[\"Locker\"],\"az8lvo\":[\"Aus\"],\"azXSNo\":[\"Mitgliederliste ausklappen\"],\"azdliB\":[\"Bei einem Konto anmelden\"],\"b26wlF\":[\"sie/ihr\"],\"bD/+Ei\":[\"Streng\"],\"bQ6BJn\":[\"Detaillierte Flood-Schutzregeln konfigurieren. Jede Regel legt fest, welche Aktivitäten überwacht werden sollen und welche Maßnahmen bei Überschreitung der Schwellenwerte ergriffen werden.\"],\"beV7+y\":[\"Der Benutzer erhält eine Einladung, \",[\"channelName\"],\" beizutreten.\"],\"bk84cH\":[\"Abwesenheitsnachricht\"],\"bkHdLj\":[\"IRC-Server hinzufügen\"],\"bmQLn5\":[\"Regel hinzufügen\"],\"bwRvnp\":[\"Aktion\"],\"c8+EVZ\":[\"Verifiziertes Konto\"],\"cGYUlD\":[\"Es werden keine Medienvorschauen geladen.\"],\"cLF98o\":[\"Kommentare anzeigen (\",[\"commentCount\"],\")\"],\"cLKIDO\":[\"Keine Benutzer verfügbar\"],\"cSgpoS\":[\"Privatnachricht anheften\"],\"cde3ce\":[\"Nachricht an <0>\",[\"0\"],\"\"],\"chQsxg\":[\"Formatierte Ausgabe kopieren\"],\"cl/A5J\":[\"Willkommen bei \",[\"__DEFAULT_IRC_SERVER_NAME__\"],\"!\"],\"cnGeoo\":[\"Löschen\"],\"coPLXT\":[\"Wir speichern Ihre IRC-Kommunikation nicht auf unseren Servern\"],\"crYH/6\":[\"SoundCloud-Player\"],\"d3sis4\":[\"Server hinzufügen\"],\"d9aN5k\":[[\"username\"],\" aus dem Kanal entfernen\"],\"dEgA5A\":[\"Abbrechen\"],\"dGi1We\":[\"Dieses Privatgespräch loslösen\"],\"dJVuyC\":[\"hat \",[\"channelName\"],\" verlassen (\",[\"reason\"],\")\"],\"dMtLDE\":[\"an\"],\"dXqxlh\":[\"<0>⚠️ Sicherheitsrisiko! Diese Verbindung könnte anfällig für Abhören oder Man-in-the-Middle-Angriffe sein.\"],\"da9Q/R\":[\"Kanalmodi geändert\"],\"dhJN3N\":[\"Kommentare anzeigen\"],\"dj2xTE\":[\"Benachrichtigung schließen\"],\"dpCzmC\":[\"Flood-Schutz-Einstellungen\"],\"e9dQpT\":[\"Möchtest du diesen Link in einem neuen Tab öffnen?\"],\"ePK91l\":[\"Bearbeiten\"],\"eYBDuB\":[\"Bild hochladen oder URL mit optionaler \",[\"size\"],\"-Substitution angeben\"],\"edBbee\":[[\"username\"],\" per hostmask sperren (verhindert erneutes Beitreten von derselben IP/Host)\"],\"ekfzWq\":[\"Benutzereinstellungen\"],\"elPDWs\":[\"IRC-Client-Erfahrung anpassen\"],\"eu2osY\":[\"<0>💡 Empfehlung: Fahre nur fort, wenn du diesem Server vertraust und die Risiken kennst. Teile keine sensiblen Informationen oder Passwörter über diese Verbindung.\"],\"euEhbr\":[\"Klicke, um \",[\"channel\"],\" beizutreten\"],\"ez3vLd\":[\"Mehrzeilige Eingabe aktivieren\"],\"f0J5Ki\":[\"Die Server-zu-Server-Kommunikation verwendet möglicherweise unverschlüsselte Verbindungen\"],\"f9BHJk\":[\"Benutzer warnen\"],\"fDOLLd\":[\"Keine Kanäle gefunden.\"],\"ffzDkB\":[\"Anonyme Analysen:\"],\"fq1GF9\":[\"Anzeigen, wenn Benutzer die Verbindung trennen\"],\"gEF57C\":[\"Dieser Server unterstützt nur einen Verbindungstyp\"],\"gJuLUI\":[\"Ignorierliste\"],\"gNzMrk\":[\"Aktueller Avatar\"],\"gjPWyO\":[\"Spitznamen eingeben...\"],\"gz6UQ3\":[\"Maximieren\"],\"h6razj\":[\"Kanalname-Maske ausschließen\"],\"hG6jnw\":[\"Kein Thema gesetzt\"],\"hG89Ed\":[\"Bild\"],\"hZ6znB\":[\"Port\"],\"ha+Bz5\":[\"z.B. 100:1440\"],\"hehnjM\":[\"Anzahl\"],\"hzdLuQ\":[\"Nur Benutzer mit Voice oder höher können sprechen\"],\"i0qMbr\":[\"Startseite\"],\"iDNBZe\":[\"Benachrichtigungen\"],\"iH8pgl\":[\"Zurück\"],\"iL9SZg\":[\"Benutzer sperren (per Nickname)\"],\"iNt+3c\":[\"Zurück zum Bild\"],\"iQvi+a\":[\"Nicht mehr vor geringer Verbindungssicherheit für diesen Server warnen\"],\"iSLIjg\":[\"Verbinden\"],\"iWXkHH\":[\"Halfop\"],\"iZeTtp\":[\"Server-Host\"],\"idD8Ev\":[\"Gespeichert\"],\"iivqkW\":[\"Angemeldet seit\"],\"ij+Elv\":[\"Bildvorschau\"],\"ilIWp7\":[\"Benachrichtigungen umschalten\"],\"iuaqvB\":[\"* als Platzhalter verwenden. Beispiele: baduser!*@*, *!*@spammer.com, troll*!*@*\"],\"ixkTse\":[\"Bot\"],\"j2DGR0\":[\"Nach Hostmaske sperren\"],\"jA4uoI\":[\"Thema:\"],\"jLXxGK\":[\"https://example.com\"],\"jPSk57\":[\"Grund (optional)\"],\"jUV7CU\":[\"Avatar hochladen\"],\"jW5Uwh\":[\"Steuert, wie viele externe Medien geladen werden. Aus / Sicher / Vertrauenswürdige / Alle Inhalte.\"],\"jXzms5\":[\"Anhangsoptionen\"],\"jZlrte\":[\"Farbe\"],\"jfC/xh\":[\"Kontakt\"],\"jywMpv\":[\"#new-channel-name\"],\"k112DD\":[\"Ältere Nachrichten laden\"],\"k3ID0F\":[\"Mitglieder filtern…\"],\"k65gsE\":[\"Vertieft ansehen\"],\"k7Zgob\":[\"Verbindung abbrechen\"],\"kAVx5h\":[\"Keine Einladungen gefunden\"],\"kCLEPU\":[\"Verbunden mit\"],\"kF5LKb\":[\"Ignorierte Muster:\"],\"kGeOx/\":[[\"0\"],\" beitreten\"],\"kITKr8\":[\"Kanal-Modi werden geladen...\"],\"kPpPsw\":[\"Du bist ein IRC Operator\"],\"kWJmRL\":[\"Du\"],\"kfcRb0\":[\"Avatar\"],\"kjMqSj\":[\"JSON kopieren\"],\"krViRy\":[\"Klicken zum Kopieren als JSON\"],\"ks71ra\":[\"Ausnahmen\"],\"kw4lRv\":[\"Kanal-Halboperator\"],\"kxgIRq\":[\"Kanal auswählen oder hinzufügen, um zu beginnen.\"],\"ky6dWe\":[\"Avatar-Vorschau\"],\"l+GxCv\":[\"Kanäle werden geladen...\"],\"l+IUVW\":[\"Kontoverifizierung für \",[\"account\"],\" erfolgreich: \",[\"message\"]],\"l/siQz\":[[\"reconnectCount\",\"plural\",{\"one\":[\"erneut verbunden\"],\"other\":[[\"reconnectCount\"],\"-mal erneut verbunden\"]}]],\"l5jmzx\":[[\"0\"],\" und \",[\"1\"],\" tippen...\"],\"lHy8N5\":[\"Weitere Kanäle werden geladen...\"],\"lbpf14\":[[\"value\"],\" beitreten\"],\"lfFsZ4\":[\"Kanäle\"],\"lkNdiH\":[\"Kontoname\"],\"ln500L\":[\"ObsidianIRC\"],\"lnCMdg\":[\"Bild hochladen\"],\"loQxaJ\":[\"Ich bin zurück\"],\"lvfaxv\":[\"STARTSEITE\"],\"m16xKo\":[\"Hinzufügen\"],\"m8flAk\":[\"Vorschau (noch nicht hochgeladen)\"],\"mEPxTp\":[\"<0>⚠️ Vorsicht! Öffne nur Links aus vertrauenswürdigen Quellen. Bösartige Links können deine Sicherheit oder Privatsphäre gefährden.\"],\"mHGdhG\":[\"Serverinformationen\"],\"mHS8lb\":[\"Nachricht an #\",[\"0\"]],\"mMYBD9\":[\"Weit – Breiterer Schutzbereich\"],\"mTGsPd\":[\"Kanalthema\"],\"mU8j6O\":[\"Keine externen Nachrichten (+n)\"],\"mZp8FL\":[\"Automatisch auf einzeilig wechseln\"],\"mdQu8G\":[\"DeinNickname\"],\"miSSBQ\":[\"Kommentare (\",[\"commentCount\"],\")\"],\"mvyLSy\":[\"Benutzer ist authentifiziert\"],\"mwtcGl\":[\"Kommentare schließen\"],\"mzI/c+\":[\"Herunterladen\"],\"n3fGRk\":[\"gesetzt von \",[\"0\"]],\"nE9jsU\":[\"Entspannt – Weniger aggressiver Schutz\"],\"nNflMD\":[\"Kanal verlassen\"],\"nPXkBi\":[\"WHOIS-Daten werden geladen...\"],\"nQnxxF\":[\"Nachricht an #\",[\"0\"],\" (Shift+Enter für neue Zeile)\"],\"nWMRxa\":[\"Loslösen\"],\"nkC032\":[\"Kein Flood-Profil\"],\"o69z4d\":[\"Warnmeldung an \",[\"username\"],\" senden\"],\"o9ylQi\":[\"GIFs suchen, um zu beginnen\"],\"oFGkER\":[\"Server-Hinweise\"],\"oOi11l\":[\"Nach unten scrollen\"],\"oQEzQR\":[\"Neue Direktnachricht\"],\"oXOSPE\":[\"Online\"],\"oal760\":[\"Man-in-the-Middle-Angriffe auf Server-Verbindungen sind möglich\"],\"oeqmmJ\":[\"Vertrauenswürdige Quellen\"],\"ovBPCi\":[\"Standard\"],\"p0Z69r\":[\"Muster darf nicht leer sein\"],\"p1KgtK\":[\"Audio konnte nicht geladen werden\"],\"p59pEv\":[\"Weitere Details\"],\"p7sRI6\":[\"Anderen mitteilen, wenn Sie tippen\"],\"pBm1od\":[\"Geheimer Kanal\"],\"pNmiXx\":[\"Ihr Standard-Nickname für alle Server\"],\"pUUo9G\":[\"Hostname:\"],\"pVGPmz\":[\"Kontopasswort\"],\"peNE68\":[\"Dauerhaft\"],\"plhHQt\":[\"Keine Daten\"],\"pm6+q5\":[\"Sicherheitswarnung\"],\"pn5qSs\":[\"Weitere Informationen\"],\"q0cR4S\":[\"ist jetzt bekannt als **\",[\"newNick\"],\"**\"],\"qFcunY\":[\"Kanal erscheint nicht in LIST- oder NAMES-Befehlen\"],\"qLpTm/\":[\"Reaktion \",[\"emoji\"],\" entfernen\"],\"qVkGWK\":[\"Anheften\"],\"qY8wNa\":[\"Homepage\"],\"qb0xJ7\":[\"Platzhalter: * beliebige Zeichen, ? ein einzelnes Zeichen. Beispiele: nick!*@*, *!*@host.com, *!*user@*\"],\"qhzpRq\":[\"Kanalschlüssel (+k)\"],\"qtoOYG\":[\"Kein Limit\"],\"r1W2AS\":[\"Dateiserver-Bild\"],\"rIPR2O\":[\"Thema gesetzt vor (Min.)\"],\"rMMSYo\":[\"Maximale Länge ist \",[\"0\"]],\"rWtzQe\":[\"Das Netzwerk hat sich geteilt und wieder verbunden. ✅\"],\"rYG2u6\":[\"Bitte warten...\"],\"rdUucN\":[\"Vorschau\"],\"rjGI/Q\":[\"Datenschutz\"],\"rk8iDX\":[\"GIFs werden geladen...\"],\"rn6SBY\":[\"Ton einschalten\"],\"s/UKqq\":[\"Wurde aus dem Kanal geworfen\"],\"s8cATI\":[\"ist \",[\"channelName\"],\" beigetreten\"],\"sCO9ue\":[\"Die Verbindung zu <0>\",[\"serverName\"],\" hat folgende Sicherheitsbedenken:\"],\"sGH11W\":[\"Server\"],\"sHI1H+\":[\"ist jetzt bekannt als **\",[\"newNick\"],\"**\"],\"sJyV04\":[[\"inviter\"],\" hat dich eingeladen, \",[\"channel\"],\" beizutreten\"],\"sby+1/\":[\"Zum Kopieren klicken\"],\"sfN25C\":[\"Ihr echter oder vollständiger Name\"],\"sliuzR\":[\"Link öffnen\"],\"sqrO9R\":[\"Benutzerdefinierte Erwähnungen\"],\"sr6RdJ\":[\"Mehrzeilig mit Shift+Enter\"],\"swrCpB\":[\"Der Kanal wurde von \",[\"oldName\"],\" in \",[\"newName\"],\" umbenannt von \",[\"user\"],[\"0\"]],\"sxkWRg\":[\"Erweitert\"],\"t/YqKh\":[\"Entfernen\"],\"t47eHD\":[\"Ihr eindeutiger Bezeichner auf diesem Server\"],\"tAkAh0\":[\"URL mit optionaler \",[\"size\"],\"-Substitution. Beispiel: https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"tXLJS3\":[\"Seitenleiste der Kanalliste ein- oder ausblenden\"],\"tfDRzk\":[\"Speichern\"],\"tiBsJk\":[\"hat \",[\"channelName\"],\" verlassen\"],\"tt4/UD\":[\"hat sich abgemeldet (\",[\"reason\"],\")\"],\"u0TcnO\":[\"Nickname {nick} bereits vergeben, versuche es mit {newNick}\"],\"u0a8B4\":[\"Als IRC-Operator für Verwaltungszugriff authentifizieren\"],\"u0rWFU\":[\"Erstellt nach (Min.)\"],\"u72w3t\":[\"Zu ignorierende Benutzer und Muster\"],\"u7jc2L\":[\"hat sich abgemeldet\"],\"uAQUqI\":[\"Status\"],\"uB85T3\":[\"Speichern fehlgeschlagen: \",[\"msg\"]],\"uV3DOL\":[\"G-Line\"],\"uW3lLI\":[\"IRC-Server:\"],\"usSSr/\":[\"Zoomstufe\"],\"v7uvcf\":[\"Software:\"],\"vE8kb+\":[\"Shift+Enter für neue Zeilen (Enter sendet)\"],\"vERlcd\":[\"Profil\"],\"vK0RL8\":[\"Kein Thema\"],\"vSJd18\":[\"Video\"],\"vXIe7J\":[\"Sprache\"],\"vaHYxN\":[\"Echter Name\"],\"vhjbKr\":[\"Abwesend\"],\"w4NYox\":[[\"title\"],\" Client\"],\"w8xQRx\":[\"Ungültiger Wert\"],\"wFjjxZ\":[\"wurde von \",[\"username\"],\" aus \",[\"channelName\"],\" gekickt (\",[\"reason\"],\")\"],\"wGjaGl\":[\"Keine Bann-Ausnahmen gefunden\"],\"wPrGnM\":[\"Kanal-Administrator\"],\"wRkP2d\":[\"GIF\"],\"wbm86v\":[\"Anzeigen, wenn Benutzer Kanäle betreten oder verlassen\"],\"whqZ9r\":[\"Weitere Wörter oder Phrasen zum Hervorheben\"],\"wm7RV4\":[\"Benachrichtigungston\"],\"wz/Yoq\":[\"Deine Nachrichten könnten abgefangen werden, wenn sie zwischen Servern weitergeleitet werden\"],\"xCJdfg\":[\"Leeren\"],\"xUHRTR\":[\"Beim Verbinden automatisch als Operator authentifizieren\"],\"xWHwwQ\":[\"Sperren\"],\"xYilR2\":[\"Medien\"],\"xceQrO\":[\"Nur sichere Websockets werden unterstützt\"],\"xdtXa+\":[\"Kanalname\"],\"xfXC7q\":[\"Textkanäle\"],\"xlCYOE\":[\"Weitere Nachrichten werden geladen...\"],\"xlhswE\":[\"Mindestwert ist \",[\"0\"]],\"xq97Ci\":[\"Wort oder Phrase hinzufügen...\"],\"xuRqRq\":[\"Client-Limit (+l)\"],\"xwF+7J\":[[\"0\"],\" tippt...\"],\"yNeucF\":[\"Dieser Server unterstützt keine erweiterten Profilmetadaten (IRCv3 METADATA). Felder wie Avatar, Anzeigename und Status sind nicht verfügbar.\"],\"yPlrca\":[\"Kanal-Avatar\"],\"yQE2r9\":[\"Laden\"],\"ySU+JY\":[\"deine@email.de\"],\"yTX1Rt\":[\"Oper-Benutzername\"],\"yYOzWD\":[\"Protokolle\"],\"yfx9Re\":[\"IRC-Operatorpasswort\"],\"ygCKqB\":[\"Stopp\"],\"ymDxJx\":[\"IRC-Operatorbenutzername\"],\"yrpRsQ\":[\"Nach Name sortieren\"],\"yz7wBu\":[\"Schließen\"],\"zJw+jA\":[\"setzt Modus: \",[\"0\"]],\"zebeLu\":[\"oper-Benutzername eingeben\"],\"zpr0Bw\":[\"GZ-Line\"]}"); \ No newline at end of file +/*eslint-disable*/export const messages=JSON.parse("{\"+5VMrz\":[\"Ungültiges Musterformat. Verwenden Sie nick!user@host (Platzhalter * erlaubt)\"],\"+6NQQA\":[\"Allgemeiner Support-Kanal\"],\"+6NyRG\":[\"Client\"],\"+K0AvT\":[\"Trennen\"],\"+cyFdH\":[\"Standardnachricht beim Als-abwesend-markieren\"],\"+mVPqU\":[\"Markdown-Formatierung in Nachrichten rendern\"],\"+vqCJH\":[\"Ihr Kontobenutzername zur Authentifizierung\"],\"+yPBXI\":[\"Datei auswählen\"],\"+zy2Nq\":[\"Typ\"],\"/09cao\":[\"Geringe Verbindungssicherheit (Stufe \",[\"securityLevel\"],\")\"],\"/3BQ4J\":[\"Externe Benutzer können keine Nachrichten senden\"],\"/6BzZF\":[\"Mitgliederliste umschalten\"],\"/TNOPk\":[\"Benutzer ist abwesend\"],\"/XQgft\":[\"Entdecken\"],\"/cF7Rs\":[\"Lautstärke\"],\"/dqduX\":[\"Nächste Seite\"],\"/fc3q4\":[\"Alle Inhalte\"],\"/kISDh\":[\"Benachrichtigungstöne aktivieren\"],\"/n04sB\":[\"Kill\"],\"/rTz0M\":[\"Audio\"],\"/rfkZe\":[\"Töne bei Erwähnungen und Nachrichten abspielen\"],\"0/0ZGA\":[\"Kanalname-Maske\"],\"0D6j7U\":[\"Mehr über benutzerdefinierte Regeln erfahren →\"],\"0XsHcR\":[\"Benutzer rauswerfen\"],\"0ZpE//\":[\"Nach Benutzern sortieren\"],\"0bEPwz\":[\"Als abwesend setzen\"],\"0dGkPt\":[\"Kanalliste ausklappen\"],\"0gS7M5\":[\"Anzeigename\"],\"0kS+M8\":[\"BeispielNET\"],\"0rgoY7\":[\"Nur mit ausgewählten Servern verbinden\"],\"0wdd7X\":[\"Beitreten\"],\"0wkVYx\":[\"Privatnachrichten\"],\"111uHX\":[\"Link-Vorschau\"],\"196EG4\":[\"Privatnachricht löschen\"],\"1DSr1i\":[\"Konto registrieren\"],\"1O/24y\":[\"Kanalliste umschalten\"],\"1TNIig\":[\"Open\"],\"1VPJJ2\":[\"Warnung: Externer Link\"],\"1ZC/dv\":[\"Keine ungelesenen Erwähnungen oder Nachrichten\"],\"1pO1zi\":[\"Servername ist erforderlich\"],\"1uwfzQ\":[\"Kanalthema anzeigen\"],\"268g7c\":[\"Anzeigenamen eingeben\"],\"2FOFq1\":[\"Server-Operatoren im Netzwerk könnten deine Nachrichten lesen\"],\"2FYpfJ\":[\"Mehr\"],\"2HF1Y2\":[[\"inviter\"],\" hat \",[\"target\"],\" eingeladen, \",[\"channel\"],\" beizutreten\"],\"2I70QL\":[\"Benutzerprofilinformationen anzeigen\"],\"2QYdmE\":[\"Benutzer:\"],\"2QpEjG\":[\"hat verlassen\"],\"2YE223\":[\"Nachricht an #\",[\"0\"],\" (Enter für neue Zeile, Shift+Enter zum Senden)\"],\"2bimFY\":[\"Server-Passwort verwenden\"],\"2iTmdZ\":[\"Lokaler Speicher:\"],\"2odkwe\":[\"Streng – Aggressiverer Schutz\"],\"2uDhbA\":[\"Benutzername zum Einladen eingeben\"],\"2ygf/L\":[\"← Zurück\"],\"2zEgxj\":[\"GIFs suchen...\"],\"3RdPhl\":[\"Kanal umbenennen\"],\"3THokf\":[\"Benutzer mit Sprachrecht\"],\"3TSz9S\":[\"Minimieren\"],\"3jBDvM\":[\"Kanal-Anzeigename\"],\"3ryuFU\":[\"Optionale Absturzberichte zur App-Verbesserung\"],\"3uBF/8\":[\"Ansicht schließen\"],\"3uwW8F\":[\"https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"3xf8Kz\":[\"Kontoname eingeben...\"],\"4/Rr0R\":[\"Benutzer in den aktuellen Kanal einladen\"],\"4EZrJN\":[\"Regeln\"],\"4JJtW9\":[\"#overflow\"],\"4NqeT4\":[\"Flood-Profil (+F)\"],\"4RZQRK\":[\"Was machst du gerade?\"],\"4hfTrB\":[\"Nickname\"],\"4n99LO\":[\"Bereits in \",[\"0\"]],\"4t6vMV\":[\"Kurze Nachrichten automatisch einzeilig darstellen\"],\"4vsHmf\":[\"Zeit (Min)\"],\"4x/Axu\":[\"Dein Bouncer hat noch keine Netzwerke. Füge eines hinzu, um zu starten.\"],\"5+INAX\":[\"Nachrichten hervorheben, die Sie erwähnen\"],\"5R5Pv/\":[\"Oper Name\"],\"678PKt\":[\"Netzwerkname\"],\"6Aih4U\":[\"Offline\"],\"6CO3WE\":[\"Passwort zum Beitreten erforderlich. Leer lassen, um den Schlüssel zu entfernen.\"],\"6HhMs3\":[\"Abgangsnachricht\"],\"6V3Ea3\":[\"Kopiert\"],\"6lGV3K\":[\"Weniger anzeigen\"],\"6yFOEi\":[\"Oper-Passwort eingeben...\"],\"7+IHTZ\":[\"Keine Datei ausgewählt\"],\"73fnil\":[\"TLS\"],\"73hrRi\":[\"nick!user@host (z.B. spam*!*@*, *!*@badhost.com)\"],\"7QkKyN\":[\"Privatnachricht senden\"],\"7U1W7c\":[\"Sehr locker\"],\"7Y1YQj\":[\"Echter Name:\"],\"7YHArF\":[\"— im Viewer öffnen\"],\"7fjnVl\":[\"Benutzer suchen...\"],\"7jL88x\":[\"Diese Nachricht löschen? Dies kann nicht rückgängig gemacht werden.\"],\"7nGhhM\":[\"Was denkst du gerade?\"],\"7sEpu1\":[\"Mitglieder — \",[\"0\"]],\"7sNhEz\":[\"Benutzername\"],\"8H0Q+x\":[\"Mehr über Profile erfahren →\"],\"8Phu0A\":[\"Anzeigen, wenn Benutzer ihren Nickname ändern\"],\"8XTG9e\":[\"oper-Passwort eingeben\"],\"8XsV2J\":[\"Erneut senden\"],\"8ZsakT\":[\"Passwort\"],\"8kR84m\":[\"Du bist dabei, einen externen Link zu öffnen:\"],\"8lCgih\":[\"Regel entfernen\"],\"8p/xVT\":[[\"0\",\"plural\",{\"one\":[[\"1\"]],\"other\":[[\"2\"]]}]],\"8wRzac\":[[\"joinCount\",\"plural\",{\"one\":[\"beigetreten\"],\"other\":[[\"joinCount\"],\"-mal beigetreten\"]}]],\"9BMLnJ\":[\"Erneut mit Server verbinden\"],\"9OEgyT\":[\"Reaktion hinzufügen\"],\"9PQ8m2\":[\"G-Line (globaler Ban)\"],\"9Qs99X\":[\"E-Mail:\"],\"9QupBP\":[\"Muster entfernen\"],\"9W7tl5\":[\"(unverändert)\"],\"9bG48P\":[\"Wird gesendet\"],\"9f5f0u\":[\"Fragen zum Datenschutz? Kontaktieren Sie uns:\"],\"9iweoP\":[\"Netzwerke auf \",[\"0\"]],\"9unqs3\":[\"Abwesend:\"],\"9v3hwv\":[\"Keine Server gefunden.\"],\"9zb2WA\":[\"Verbinden...\"],\"A1taO8\":[\"Suchen\"],\"A2adVi\":[\"Tipp-Benachrichtigungen senden\"],\"A9Rhec\":[\"Kanalname\"],\"AWOSPo\":[\"Vergrößern\"],\"AXSpEQ\":[\"Oper beim Verbinden\"],\"AeXO77\":[\"Konto\"],\"AhNP40\":[\"Vor-/Zurückspulen\"],\"Ai2U7L\":[\"Host\"],\"AjBQnf\":[\"Nickname geändert\"],\"AmXVh6\":[\"https://example.com/avatar.png\"],\"AnRu/j\":[\"Antwort abbrechen\"],\"ApSx0O\":[[\"0\"],\" Nachrichten gefunden, die zu \\\"\",[\"searchQuery\"],\"\\\" passen\"],\"AxPAXW\":[\"Keine Ergebnisse gefunden\"],\"AyNqAB\":[\"Alle Serverereignisse im Chat anzeigen\"],\"B/QqGw\":[\"Nicht am Rechner\"],\"B0sB2k\":[\"Klartext\"],\"B8AaMI\":[\"Dieses Feld ist erforderlich\"],\"BA2c49\":[\"Server unterstützt keine erweiterte LIST-Filterung\"],\"BDKt3I\":[[\"0\"],\", \",[\"1\"],\", \",[\"2\"],\" und \",[\"3\"],\" weitere tippen...\"],\"BGul2A\":[\"Du hast ungespeicherte Änderungen. Möchtest du wirklich schließen, ohne zu speichern?\"],\"BIf9fi\":[\"Ihre Statusnachricht\"],\"BZz3md\":[\"Ihre persönliche Website\"],\"Bgm/H7\":[\"Mehrzeilige Texteingabe erlauben\"],\"BiQIl1\":[\"Dieses Privatgespräch anheften\"],\"BlNZZ2\":[\"Klicken, um zur Nachricht zu springen\"],\"Bowq3c\":[\"Nur Operatoren können das Kanalthema ändern\"],\"Btozzp\":[\"Dieses Bild ist abgelaufen\"],\"Bycfjm\":[\"Gesamt: \",[\"0\"]],\"C6IBQc\":[\"Gesamtes JSON kopieren\"],\"C9L9wL\":[\"Datenerfassung\"],\"CDq4wC\":[\"Benutzer moderieren\"],\"CHVRxG\":[\"Nachricht an @\",[\"0\"],\" (Shift+Enter für neue Zeile)\"],\"CN9zdR\":[\"Oper-Name und Passwort sind erforderlich\"],\"CW3sYa\":[\"Reaktion \",[\"emoji\"],\" hinzufügen\"],\"CaAkqd\":[\"Verbindungstrennungen anzeigen\"],\"CbvaYj\":[\"Nach Nickname sperren\"],\"CcK+Ft\":[\"PDF\"],\"Ce8q3L\":[\"Kanal auswählen\"],\"CsekCi\":[\"Normal\"],\"D+NlUC\":[\"System\"],\"D28t6+\":[\"ist beigetreten und gegangen\"],\"DB8zMK\":[\"Anwenden\"],\"DBcWHr\":[\"Benutzerdefinierte Benachrichtigungstondatei\"],\"DTy9Xw\":[\"Medienvorschauen\"],\"Dj4pSr\":[\"Sicheres Passwort wählen\"],\"Du+zn+\":[\"Suche...\"],\"Du2T2f\":[\"Einstellung nicht gefunden\"],\"DwsSVQ\":[\"Filter anwenden & Aktualisieren\"],\"E3W/zd\":[\"Standard-Nickname\"],\"E6nRW7\":[\"URL kopieren\"],\"E703RG\":[\"Modi:\"],\"EAeu1Z\":[\"Einladung senden\"],\"EFKJQT\":[\"Einstellung\"],\"EGPQBv\":[\"Benutzerdefinierte Flood-Regeln (+f)\"],\"ELik0r\":[\"Vollständige Datenschutzrichtlinie anzeigen\"],\"EPbeC2\":[\"Kanalthema anzeigen oder bearbeiten\"],\"EQCDNT\":[\"Oper-Benutzernamen eingeben...\"],\"EUvulZ\":[\"1 Nachricht gefunden, die zu \\\"\",[\"searchQuery\"],\"\\\" passt\"],\"EatZYJ\":[\"Nächstes Bild\"],\"EdQY6l\":[\"Keine\"],\"EnqLYU\":[\"Server suchen...\"],\"F0OKMc\":[\"Server bearbeiten\"],\"F6Int2\":[\"Hervorhebungen aktivieren\"],\"FDoLyE\":[\"Max. Benutzer\"],\"FUU/hZ\":[\"Steuert, wie viele externe Medien im Chat geladen werden.\"],\"Fdp03t\":[\"an\"],\"FfPWR0\":[\"Modal\"],\"FjkaiT\":[\"Verkleinern\"],\"FlqOE9\":[\"Was das bedeutet:\"],\"FolHNl\":[\"Konto und Authentifizierung verwalten\"],\"Fp2Dif\":[\"Den Server verlassen\"],\"G5KmCc\":[\"GZ-Line (globale Z-Line)\"],\"GDs0lz\":[\"<0>Risiko: Sensible Informationen (Nachrichten, private Gespräche, Authentifizierungsdaten) könnten Netzwerkadministratoren oder Angreifern zwischen IRC-Servern zugänglich sein.\"],\"GR+2I3\":[\"Einladungs-Maske hinzufügen (z.B. nick!*@*, *!*@host.com)\"],\"GRLyMU\":[\"Server-Hinweise schließen\"],\"GlHnXw\":[\"Nicknamewechsel fehlgeschlagen: \",[\"error\"],\" \",[\"0\"]],\"GswZF3\":[\"Vorschau:\"],\"GtmO8/\":[\"von\"],\"GtuHUQ\":[\"Diesen Kanal auf dem Server umbenennen. Alle Benutzer sehen den neuen Namen.\"],\"GuGfFX\":[\"Suche umschalten\"],\"GxkJXS\":[\"Wird hochgeladen...\"],\"GzbwnK\":[\"Dem Kanal beigetreten\"],\"GzsUDB\":[\"Erweitertes Profil\"],\"H/PnT8\":[\"Emoji einfügen\"],\"H6Izzl\":[\"Ihr bevorzugter Farbcode\"],\"H9jIv+\":[\"Beitritte/Abgänge anzeigen\"],\"HAKBY9\":[\"Dateien hochladen\"],\"HdE1If\":[\"Kanal\"],\"Hk4AW9\":[\"Ihr bevorzugter Anzeigename\"],\"HmHDk7\":[\"Mitglied auswählen\"],\"HrQzPU\":[\"Kanäle auf \",[\"networkName\"]],\"I2tXQ5\":[\"Nachricht an @\",[\"0\"],\" (Enter für neue Zeile, Shift+Enter zum Senden)\"],\"I6bw/h\":[\"Benutzer sperren\"],\"I92Z+b\":[\"Benachrichtigungen aktivieren\"],\"I9D72S\":[\"Bist du sicher, dass du diese Nachricht löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.\"],\"IA+1wo\":[\"Anzeigen, wenn Benutzer aus Kanälen gekickt werden\"],\"IDwkJx\":[\"IRC Operator\"],\"ILlU+s\":[\"Info:\"],\"IUwGEM\":[\"Änderungen speichern\"],\"IVeGK6\":[[\"0\"],\", \",[\"1\"],\" und \",[\"2\"],\" tippen...\"],\"IgrLD/\":[\"Pause\"],\"Im6JED\":[\"WHISPER\"],\"ImOQa9\":[\"Antworten\"],\"IoHMnl\":[\"Maximalwert ist \",[\"0\"]],\"IvMj+0\":[\"Op\"],\"J28zul\":[\"Verbinde...\"],\"J5T9NW\":[\"Benutzerinformationen\"],\"J8Y5+z\":[\"Ups! Netz-Split! ⚠️\"],\"JBHkBA\":[\"Den Kanal verlassen\"],\"JCwL0Q\":[\"Grund eingeben (optional)\"],\"JFciKP\":[\"Umschalten\"],\"JXGkhG\":[\"Kanalnamen ändern (nur Operatoren)\"],\"JcD7qf\":[\"Weitere Aktionen\"],\"JdkA+c\":[\"Geheim (+s)\"],\"Jmu12l\":[\"Serverkanäle\"],\"JvQ++s\":[\"Markdown aktivieren\"],\"K2jwh/\":[\"Keine WHOIS-Daten verfügbar\"],\"KAXSwC\":[\"Voice\"],\"KDfTdX\":[\"Nachricht löschen\"],\"KKBlUU\":[\"Einbetten\"],\"KM0pLb\":[\"Willkommen im Kanal!\"],\"KR6W2h\":[\"Benutzer nicht mehr ignorieren\"],\"KV+Bi1\":[\"Nur auf Einladung (+i)\"],\"KdCtwE\":[\"Wie viele Sekunden Flood-Aktivität überwacht wird, bevor die Zähler zurückgesetzt werden\"],\"Kkezga\":[\"Server-Passwort\"],\"KsiQ/8\":[\"Benutzer müssen eingeladen werden\"],\"L+gB/D\":[\"Kanalinformationen\"],\"LC1a7n\":[\"Der IRC-Server hat gemeldet, dass seine Server-zu-Server-Verbindungen ein niedriges Sicherheitsniveau aufweisen. Das bedeutet, dass deine Nachrichten beim Weiterleiten zwischen IRC-Servern im Netzwerk möglicherweise nicht ordnungsgemäß verschlüsselt sind oder die SSL/TLS-Zertifikate nicht korrekt validiert werden.\"],\"LNfLR5\":[\"Kicks anzeigen\"],\"LP+1Z7\":[\"Netzwerk hinzufügen\"],\"LQb0W/\":[\"Alle Ereignisse anzeigen\"],\"LU7/yA\":[\"Alternativer Anzeigename. Kann Leerzeichen, Emojis und Sonderzeichen enthalten. Der echte Kanalname (\",[\"channelName\"],\") wird weiterhin für IRC-Befehle verwendet.\"],\"LUb9O7\":[\"Ein gültiger Server-Port ist erforderlich\"],\"Lb+BUl\":[\"https://example.com/avatar.jpg\"],\"LcET2C\":[\"Datenschutzrichtlinie\"],\"LcuSDR\":[\"Profilinformationen und Metadaten verwalten\"],\"LqLS9B\":[\"Nickwechsel anzeigen\"],\"LsDQt2\":[\"Kanaleinstellungen\"],\"LtI9AS\":[\"Eigentümer\"],\"LuNhhL\":[\"hat auf diese Nachricht reagiert\"],\"M/AZNG\":[\"URL zu Ihrem Avatar-Bild\"],\"M/WIer\":[\"Nachricht senden\"],\"M8er/5\":[\"Name:\"],\"MHk+7g\":[\"Vorheriges Bild\"],\"MRorGe\":[\"Benutzer anschreiben\"],\"MVbSGP\":[\"Zeitfenster (Sekunden)\"],\"MkpcsT\":[\"Ihre Nachrichten und Einstellungen werden lokal gespeichert\"],\"MzPdC2\":[\"Serverpasswort (PASS)\"],\"N/hDSy\":[\"Als Bot markieren – normalerweise 'on' oder leer\"],\"N6j2JH\":[[\"0\"],\" bearbeiten\"],\"N7TQbE\":[\"Benutzer zu \",[\"channelName\"],\" einladen\"],\"NCca/o\":[\"Standard-Spitznamen eingeben...\"],\"Nqs6B9\":[\"Zeigt alle externen Medien. Jede URL kann eine Anfrage an einen unbekannten Server auslösen.\"],\"Nt+9O7\":[\"WebSocket statt rohem TCP verwenden\"],\"NxIHzc\":[\"Benutzer trennen\"],\"O+v/cL\":[\"Alle Kanäle auf dem Server durchsuchen\"],\"OCGpR4\":[\"(übernehmen)\"],\"ODwSCk\":[\"GIF senden\"],\"OGQ5kK\":[\"Benachrichtigungstöne und Hervorhebungen konfigurieren\"],\"OIPt1Z\":[\"Seitenleiste der Mitgliederliste ein- oder ausblenden\"],\"OKSNq/\":[\"Sehr streng\"],\"ONWvwQ\":[\"Hochladen\"],\"OVKoQO\":[\"Ihr Kontopasswort zur Authentifizierung\"],\"ObsidianIRC - Bringing IRC to the future\":[\"ObsidianIRC - IRC in die Zukunft bringen\"],\"OhCpra\":[\"Thema setzen…\"],\"OkltoQ\":[[\"username\"],\" per Nickname sperren (verhindert erneutes Beitreten mit demselben Nick)\"],\"P+t/Te\":[\"Keine weiteren Daten\"],\"P42Wcc\":[\"Sicher\"],\"PD38l0\":[\"Kanal-Avatar-Vorschau\"],\"PD9mEt\":[\"Nachricht eingeben...\"],\"PPqfdA\":[\"Kanaleinstellungen öffnen\"],\"PSCjfZ\":[\"Das Thema für diesen Kanal. Alle Benutzer können es sehen.\"],\"PZCecv\":[\"PDF-Vorschau\"],\"PeLgsC\":[[\"c\",\"plural\",{\"one\":[\"1 Mal\"],\"other\":[[\"c\"],\" Mal\"]}]],\"PguS2C\":[\"Ausnahme-Maske hinzufügen (z.B. nick!*@*, *!*@host.com)\"],\"Pil5Ty\":[[\"displayedChannelsCount\"],\" von \",[\"0\"],\" Kanälen angezeigt\"],\"PqhVlJ\":[\"Benutzer sperren (per Hostmask)\"],\"Q+chwU\":[\"Benutzername:\"],\"Q3v9Wc\":[\"Ja, löschen\"],\"Q6hhn8\":[\"Einstellungen\"],\"QF4a34\":[\"Bitte gib einen Benutzernamen ein\"],\"QGqSZ2\":[\"Farbe & Formatierung\"],\"QJQd1J\":[\"Profil bearbeiten\"],\"QSzGDE\":[\"Inaktiv\"],\"QUlny5\":[\"Willkommen bei \",[\"0\"],\"!\"],\"Qoq+GP\":[\"Mehr lesen\"],\"QuSkCF\":[\"Kanäle filtern...\"],\"QwUrDZ\":[\"hat das Thema geändert zu: \",[\"topic\"]],\"R0UH07\":[\"Bild \",[\"0\"],\" von \",[\"1\"]],\"R7SsBE\":[\"Stumm schalten\"],\"R8rf1X\":[\"Klicken, um das Thema zu setzen\"],\"RArB3D\":[\"wurde von \",[\"username\"],\" aus \",[\"channelName\"],\" gekickt\"],\"RI3cWd\":[\"Entdecke die Welt von IRC mit ObsidianIRC\"],\"RMMaN5\":[\"Moderiert (+m)\"],\"RWw9Lg\":[\"Fenster schließen\"],\"RZ2BuZ\":[\"Kontoregistrierung für \",[\"account\"],\" erfordert Verifizierung: \",[\"message\"]],\"RySp6q\":[\"Kommentare ausblenden\"],\"S5Togi\":[\"Netzwerke werden von deinem Bouncer geladen…\"],\"SPKQTd\":[\"Nickname ist erforderlich\"],\"SPVjfj\":[\"Standardmäßig 'kein Grund', wenn leer gelassen\"],\"SQKPvQ\":[\"Benutzer einladen\"],\"STmlpb\":[\"Back to network list\"],\"SkZcl+\":[\"Wähle ein vordefiniertes Flood-Schutzprofil. Diese Profile bieten ausgewogene Schutzeinstellungen für verschiedene Anwendungsfälle.\"],\"Slr+3C\":[\"Min. Benutzer\"],\"Spnlre\":[\"Du hast \",[\"target\"],\" eingeladen, \",[\"channel\"],\" beizutreten\"],\"T/ckN5\":[\"Im Viewer öffnen\"],\"T91vKp\":[\"Abspielen\"],\"TV2Wdu\":[\"Erfahren Sie, wie wir Ihre Daten verwalten und Ihre Privatsphäre schützen.\"],\"TgFpwD\":[\"Wird angewendet...\"],\"TkzSFB\":[\"Keine Änderungen\"],\"TtserG\":[\"Echten Namen eingeben\"],\"Ttz9J1\":[\"Passwort eingeben...\"],\"Tz0i8g\":[\"Einstellungen\"],\"U3pytU\":[\"Admin\"],\"UDb2YD\":[\"Reagieren\"],\"UE4KO5\":[\"*channel*\"],\"UGT5vp\":[\"Einstellungen speichern\"],\"UV5hLB\":[\"Keine Sperren gefunden\"],\"Uaj3Nd\":[\"Statusnachrichten\"],\"Ue3uny\":[\"Standard (kein Profil)\"],\"UkARhe\":[\"Normal – Standardschutz\"],\"Umn7Cj\":[\"Noch keine Kommentare. Sei der Erste!\"],\"UtUIRh\":[[\"0\"],\" ältere Nachrichten\"],\"UwzP+U\":[\"Sichere Verbindung\"],\"V0/A4O\":[\"Kanalbesitzer\"],\"V4qgxE\":[\"Erstellt vor (Min.)\"],\"V8yTm6\":[\"Suche löschen\"],\"VJMMyz\":[\"ObsidianIRC - IRC in die Zukunft bringen\"],\"VJScHU\":[\"Grund\"],\"VLsmVV\":[\"Benachrichtigungen stummschalten\"],\"VbyRUy\":[\"Kommentare\"],\"Vmx0mQ\":[\"Gesetzt von:\"],\"VqnIZz\":[\"Datenschutzrichtlinie und Datenpraktiken anzeigen\"],\"VrMygG\":[\"Mindestlänge ist \",[\"0\"]],\"VrnTui\":[\"Ihre Pronomen, im Profil angezeigt\"],\"W8E3qn\":[\"Authentifiziertes Konto\"],\"WAakm9\":[\"Kanal löschen\"],\"WFxTHC\":[\"Bann-Maske hinzufügen (z.B. nick!*@*, *!*@host.com)\"],\"WN1g9F\":[\"Server-Host ist erforderlich\"],\"WRYdXW\":[\"Audioposition\"],\"WUOH5B\":[\"Benutzer ignorieren\"],\"WWEXnZ\":[[\"0\",\"plural\",{\"one\":[\"1 weiteres Element anzeigen\"],\"other\":[[\"1\"],\" weitere Elemente anzeigen\"]}]],\"Weq9zb\":[\"Allgemein\"],\"Wfj7Sk\":[\"Benachrichtigungstöne stummschalten oder aktivieren\"],\"Wm7gbG\":[\"GitHub:\"],\"WyeHWY\":[\"*spam*\"],\"WzMCru\":[\"Benutzerprofil\"],\"X6S3lt\":[\"Einstellungen, Kanäle, Server suchen...\"],\"XEHan5\":[\"Trotzdem fortfahren\"],\"XI1+wb\":[\"Ungültiges Format\"],\"XIXeuC\":[\"Nachricht an @\",[\"0\"]],\"XMS+k4\":[\"Privatnachricht starten\"],\"XWgxXq\":[\"Album\"],\"Xd7+IT\":[\"Privatnachricht loslösen\"],\"Xm/s+u\":[\"Anzeige\"],\"Xp2n93\":[\"Zeigt Medien vom vertrauenswürdigen Datei-Host deines Servers. Es werden keine Anfragen an externe Dienste gestellt.\"],\"XvjC4F\":[\"Wird gespeichert...\"],\"Y/qryO\":[\"Keine Benutzer gefunden, die deiner Suche entsprechen\"],\"YAqRpI\":[\"Kontoregistrierung für \",[\"account\"],\" erfolgreich: \",[\"message\"]],\"YEfzvP\":[\"Geschütztes Thema (+t)\"],\"YQOn6a\":[\"Mitgliederliste einklappen\"],\"YRCoE9\":[\"Kanal-Operator\"],\"YURQaF\":[\"Profil anzeigen\"],\"YdBSvr\":[\"Medienanzeige und externe Inhalte steuern\"],\"Yj6U3V\":[\"Kein zentraler Server:\"],\"YjvpGx\":[\"Pronomen\"],\"YqH4l4\":[\"Kein Schlüssel\"],\"YyUPpV\":[\"Konto:\"],\"ZJSWfw\":[\"Nachricht beim Trennen vom Server\"],\"ZR1dJ4\":[\"Einladungen\"],\"ZdWg0V\":[\"Im Browser öffnen\"],\"ZhRBbl\":[\"Nachrichten suchen…\"],\"Zmcu3y\":[\"Erweiterte Filter\"],\"a2/8e5\":[\"Thema gesetzt nach (Min.)\"],\"aHKcKc\":[\"Vorherige Seite\"],\"aJTbXX\":[\"Oper Password\"],\"aQryQv\":[\"Muster existiert bereits\"],\"aW9pLN\":[\"Maximale Anzahl der zugelassenen Benutzer. Leer lassen für kein Limit.\"],\"ah4fmZ\":[\"Zeigt auch Vorschauen von YouTube, Vimeo, SoundCloud und ähnlichen bekannten Diensten.\"],\"aifXak\":[\"Keine Medien in diesem Kanal\"],\"ap2zBz\":[\"Locker\"],\"az8lvo\":[\"Aus\"],\"azXSNo\":[\"Mitgliederliste ausklappen\"],\"azdliB\":[\"Bei einem Konto anmelden\"],\"b26wlF\":[\"sie/ihr\"],\"bD/+Ei\":[\"Streng\"],\"bQ6BJn\":[\"Detaillierte Flood-Schutzregeln konfigurieren. Jede Regel legt fest, welche Aktivitäten überwacht werden sollen und welche Maßnahmen bei Überschreitung der Schwellenwerte ergriffen werden.\"],\"beV7+y\":[\"Der Benutzer erhält eine Einladung, \",[\"channelName\"],\" beizutreten.\"],\"bk84cH\":[\"Abwesenheitsnachricht\"],\"bkHdLj\":[\"IRC-Server hinzufügen\"],\"bmQLn5\":[\"Regel hinzufügen\"],\"bv4cFj\":[\"Transport\"],\"bwRvnp\":[\"Aktion\"],\"c8+EVZ\":[\"Verifiziertes Konto\"],\"cGYUlD\":[\"Es werden keine Medienvorschauen geladen.\"],\"cLF98o\":[\"Kommentare anzeigen (\",[\"commentCount\"],\")\"],\"cLKIDO\":[\"Keine Benutzer verfügbar\"],\"cSgpoS\":[\"Privatnachricht anheften\"],\"cde3ce\":[\"Nachricht an <0>\",[\"0\"],\"\"],\"chQsxg\":[\"Formatierte Ausgabe kopieren\"],\"cl/A5J\":[\"Willkommen bei \",[\"__DEFAULT_IRC_SERVER_NAME__\"],\"!\"],\"cnGeoo\":[\"Löschen\"],\"coPLXT\":[\"Wir speichern Ihre IRC-Kommunikation nicht auf unseren Servern\"],\"crYH/6\":[\"SoundCloud-Player\"],\"cv5DQb\":[\"kein Host festgelegt\"],\"d3sis4\":[\"Server hinzufügen\"],\"d9aN5k\":[[\"username\"],\" aus dem Kanal entfernen\"],\"dEgA5A\":[\"Abbrechen\"],\"dGi1We\":[\"Dieses Privatgespräch loslösen\"],\"dJVuyC\":[\"hat \",[\"channelName\"],\" verlassen (\",[\"reason\"],\")\"],\"dMtLDE\":[\"an\"],\"dXqxlh\":[\"<0>⚠️ Sicherheitsrisiko! Diese Verbindung könnte anfällig für Abhören oder Man-in-the-Middle-Angriffe sein.\"],\"da9Q/R\":[\"Kanalmodi geändert\"],\"dhJN3N\":[\"Kommentare anzeigen\"],\"dj2xTE\":[\"Benachrichtigung schließen\"],\"dpCzmC\":[\"Flood-Schutz-Einstellungen\"],\"e9dQpT\":[\"Möchtest du diesen Link in einem neuen Tab öffnen?\"],\"ePK91l\":[\"Bearbeiten\"],\"eYBDuB\":[\"Bild hochladen oder URL mit optionaler \",[\"size\"],\"-Substitution angeben\"],\"edBbee\":[[\"username\"],\" per hostmask sperren (verhindert erneutes Beitreten von derselben IP/Host)\"],\"ekfzWq\":[\"Benutzereinstellungen\"],\"elPDWs\":[\"IRC-Client-Erfahrung anpassen\"],\"eu2osY\":[\"<0>💡 Empfehlung: Fahre nur fort, wenn du diesem Server vertraust und die Risiken kennst. Teile keine sensiblen Informationen oder Passwörter über diese Verbindung.\"],\"euEhbr\":[\"Klicke, um \",[\"channel\"],\" beizutreten\"],\"ez3vLd\":[\"Mehrzeilige Eingabe aktivieren\"],\"f0J5Ki\":[\"Die Server-zu-Server-Kommunikation verwendet möglicherweise unverschlüsselte Verbindungen\"],\"f9BHJk\":[\"Benutzer warnen\"],\"fDOLLd\":[\"Keine Kanäle gefunden.\"],\"ffzDkB\":[\"Anonyme Analysen:\"],\"fq1GF9\":[\"Anzeigen, wenn Benutzer die Verbindung trennen\"],\"gEF57C\":[\"Dieser Server unterstützt nur einen Verbindungstyp\"],\"gJuLUI\":[\"Ignorierliste\"],\"gNzMrk\":[\"Aktueller Avatar\"],\"gjPWyO\":[\"Spitznamen eingeben...\"],\"gz6UQ3\":[\"Maximieren\"],\"h6/IMX\":[\"Erstes Netzwerk hinzufügen\"],\"h6razj\":[\"Kanalname-Maske ausschließen\"],\"hG6jnw\":[\"Kein Thema gesetzt\"],\"hG89Ed\":[\"Bild\"],\"hZ6znB\":[\"Port\"],\"ha+Bz5\":[\"z.B. 100:1440\"],\"hehnjM\":[\"Anzahl\"],\"hzdLuQ\":[\"Nur Benutzer mit Voice oder höher können sprechen\"],\"i0qMbr\":[\"Startseite\"],\"iDNBZe\":[\"Benachrichtigungen\"],\"iH8pgl\":[\"Zurück\"],\"iL9SZg\":[\"Benutzer sperren (per Nickname)\"],\"iNt+3c\":[\"Zurück zum Bild\"],\"iQvi+a\":[\"Nicht mehr vor geringer Verbindungssicherheit für diesen Server warnen\"],\"iSLIjg\":[\"Verbinden\"],\"iWXkHH\":[\"Halfop\"],\"iZeTtp\":[\"Server-Host\"],\"idD8Ev\":[\"Gespeichert\"],\"iivqkW\":[\"Angemeldet seit\"],\"ij+Elv\":[\"Bildvorschau\"],\"ilIWp7\":[\"Benachrichtigungen umschalten\"],\"iuaqvB\":[\"* als Platzhalter verwenden. Beispiele: baduser!*@*, *!*@spammer.com, troll*!*@*\"],\"ixkTse\":[\"Bot\"],\"j2DGR0\":[\"Nach Hostmaske sperren\"],\"jA4uoI\":[\"Thema:\"],\"jLXxGK\":[\"https://example.com\"],\"jPSk57\":[\"Grund (optional)\"],\"jUV7CU\":[\"Avatar hochladen\"],\"jW5Uwh\":[\"Steuert, wie viele externe Medien geladen werden. Aus / Sicher / Vertrauenswürdige / Alle Inhalte.\"],\"jXzms5\":[\"Anhangsoptionen\"],\"jZlrte\":[\"Farbe\"],\"jfC/xh\":[\"Kontakt\"],\"jywMpv\":[\"#new-channel-name\"],\"k112DD\":[\"Ältere Nachrichten laden\"],\"k3ID0F\":[\"Mitglieder filtern…\"],\"k65gsE\":[\"Vertieft ansehen\"],\"k7Zgob\":[\"Verbindung abbrechen\"],\"kAVx5h\":[\"Keine Einladungen gefunden\"],\"kCLEPU\":[\"Verbunden mit\"],\"kF5LKb\":[\"Ignorierte Muster:\"],\"kGeOx/\":[[\"0\"],\" beitreten\"],\"kITKr8\":[\"Kanal-Modi werden geladen...\"],\"kPpPsw\":[\"Du bist ein IRC Operator\"],\"kWJmRL\":[\"Du\"],\"kfcRb0\":[\"Avatar\"],\"kjMqSj\":[\"JSON kopieren\"],\"krViRy\":[\"Klicken zum Kopieren als JSON\"],\"ks71ra\":[\"Ausnahmen\"],\"kw4lRv\":[\"Kanal-Halboperator\"],\"kxgIRq\":[\"Kanal auswählen oder hinzufügen, um zu beginnen.\"],\"ky6dWe\":[\"Avatar-Vorschau\"],\"l+GxCv\":[\"Kanäle werden geladen...\"],\"l+IUVW\":[\"Kontoverifizierung für \",[\"account\"],\" erfolgreich: \",[\"message\"]],\"l/siQz\":[[\"reconnectCount\",\"plural\",{\"one\":[\"erneut verbunden\"],\"other\":[[\"reconnectCount\"],\"-mal erneut verbunden\"]}]],\"l5jmzx\":[[\"0\"],\" und \",[\"1\"],\" tippen...\"],\"lHy8N5\":[\"Weitere Kanäle werden geladen...\"],\"lbpf14\":[[\"value\"],\" beitreten\"],\"lfFsZ4\":[\"Kanäle\"],\"lkNdiH\":[\"Kontoname\"],\"ln500L\":[\"ObsidianIRC\"],\"lnCMdg\":[\"Bild hochladen\"],\"loQxaJ\":[\"Ich bin zurück\"],\"lvfaxv\":[\"STARTSEITE\"],\"m0oxpP\":[\"Libera Chat\"],\"m16xKo\":[\"Hinzufügen\"],\"m8flAk\":[\"Vorschau (noch nicht hochgeladen)\"],\"mEPxTp\":[\"<0>⚠️ Vorsicht! Öffne nur Links aus vertrauenswürdigen Quellen. Bösartige Links können deine Sicherheit oder Privatsphäre gefährden.\"],\"mHGdhG\":[\"Serverinformationen\"],\"mHS8lb\":[\"Nachricht an #\",[\"0\"]],\"mMYBD9\":[\"Weit – Breiterer Schutzbereich\"],\"mTGsPd\":[\"Kanalthema\"],\"mU8j6O\":[\"Keine externen Nachrichten (+n)\"],\"mZp8FL\":[\"Automatisch auf einzeilig wechseln\"],\"mdQu8G\":[\"DeinNickname\"],\"miSSBQ\":[\"Kommentare (\",[\"commentCount\"],\")\"],\"mvyLSy\":[\"Benutzer ist authentifiziert\"],\"mwtcGl\":[\"Kommentare schließen\"],\"myL0MR\":[\"Dieses Netzwerk löschen?\"],\"mzI/c+\":[\"Herunterladen\"],\"n3fGRk\":[\"gesetzt von \",[\"0\"]],\"nE9jsU\":[\"Entspannt – Weniger aggressiver Schutz\"],\"nNflMD\":[\"Kanal verlassen\"],\"nPXkBi\":[\"WHOIS-Daten werden geladen...\"],\"nQnxxF\":[\"Nachricht an #\",[\"0\"],\" (Shift+Enter für neue Zeile)\"],\"nWMRxa\":[\"Loslösen\"],\"nkC032\":[\"Kein Flood-Profil\"],\"o69z4d\":[\"Warnmeldung an \",[\"username\"],\" senden\"],\"o9ylQi\":[\"GIFs suchen, um zu beginnen\"],\"oFGkER\":[\"Server-Hinweise\"],\"oOi11l\":[\"Nach unten scrollen\"],\"oQEzQR\":[\"Neue Direktnachricht\"],\"oXOSPE\":[\"Online\"],\"oal760\":[\"Man-in-the-Middle-Angriffe auf Server-Verbindungen sind möglich\"],\"oeqmmJ\":[\"Vertrauenswürdige Quellen\"],\"ovBPCi\":[\"Standard\"],\"p0Z69r\":[\"Muster darf nicht leer sein\"],\"p1KgtK\":[\"Audio konnte nicht geladen werden\"],\"p59pEv\":[\"Weitere Details\"],\"p7sRI6\":[\"Anderen mitteilen, wenn Sie tippen\"],\"pBm1od\":[\"Geheimer Kanal\"],\"pNmiXx\":[\"Ihr Standard-Nickname für alle Server\"],\"pUUo9G\":[\"Hostname:\"],\"pVGPmz\":[\"Kontopasswort\"],\"peNE68\":[\"Dauerhaft\"],\"plhHQt\":[\"Keine Daten\"],\"pm6+q5\":[\"Sicherheitswarnung\"],\"pn5qSs\":[\"Weitere Informationen\"],\"q0cR4S\":[\"ist jetzt bekannt als **\",[\"newNick\"],\"**\"],\"qFcunY\":[\"Kanal erscheint nicht in LIST- oder NAMES-Befehlen\"],\"qLpTm/\":[\"Reaktion \",[\"emoji\"],\" entfernen\"],\"qVkGWK\":[\"Anheften\"],\"qY8wNa\":[\"Homepage\"],\"qb0xJ7\":[\"Platzhalter: * beliebige Zeichen, ? ein einzelnes Zeichen. Beispiele: nick!*@*, *!*@host.com, *!*user@*\"],\"qhzpRq\":[\"Kanalschlüssel (+k)\"],\"qtoOYG\":[\"Kein Limit\"],\"r1W2AS\":[\"Dateiserver-Bild\"],\"rIPR2O\":[\"Thema gesetzt vor (Min.)\"],\"rMMSYo\":[\"Maximale Länge ist \",[\"0\"]],\"rWtzQe\":[\"Das Netzwerk hat sich geteilt und wieder verbunden. ✅\"],\"rYG2u6\":[\"Bitte warten...\"],\"rdUucN\":[\"Vorschau\"],\"rjGI/Q\":[\"Datenschutz\"],\"rk8iDX\":[\"GIFs werden geladen...\"],\"rn6SBY\":[\"Ton einschalten\"],\"s/UKqq\":[\"Wurde aus dem Kanal geworfen\"],\"s8cATI\":[\"ist \",[\"channelName\"],\" beigetreten\"],\"sCO9ue\":[\"Die Verbindung zu <0>\",[\"serverName\"],\" hat folgende Sicherheitsbedenken:\"],\"sGH11W\":[\"Server\"],\"sHI1H+\":[\"ist jetzt bekannt als **\",[\"newNick\"],\"**\"],\"sJyV04\":[[\"inviter\"],\" hat dich eingeladen, \",[\"channel\"],\" beizutreten\"],\"sUBSbK\":[\"Noch keine Upstream-Netzwerke.\"],\"sby+1/\":[\"Zum Kopieren klicken\"],\"sfN25C\":[\"Ihr echter oder vollständiger Name\"],\"sliuzR\":[\"Link öffnen\"],\"sqrO9R\":[\"Benutzerdefinierte Erwähnungen\"],\"sr6RdJ\":[\"Mehrzeilig mit Shift+Enter\"],\"swrCpB\":[\"Der Kanal wurde von \",[\"oldName\"],\" in \",[\"newName\"],\" umbenannt von \",[\"user\"],[\"0\"]],\"sxkWRg\":[\"Erweitert\"],\"t/YqKh\":[\"Entfernen\"],\"t47eHD\":[\"Ihr eindeutiger Bezeichner auf diesem Server\"],\"tAkAh0\":[\"URL mit optionaler \",[\"size\"],\"-Substitution. Beispiel: https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"tXLJS3\":[\"Seitenleiste der Kanalliste ein- oder ausblenden\"],\"tfDRzk\":[\"Speichern\"],\"tiBsJk\":[\"hat \",[\"channelName\"],\" verlassen\"],\"tt4/UD\":[\"hat sich abgemeldet (\",[\"reason\"],\")\"],\"u0TcnO\":[\"Nickname {nick} bereits vergeben, versuche es mit {newNick}\"],\"u0a8B4\":[\"Als IRC-Operator für Verwaltungszugriff authentifizieren\"],\"u0rWFU\":[\"Erstellt nach (Min.)\"],\"u72w3t\":[\"Zu ignorierende Benutzer und Muster\"],\"u7jc2L\":[\"hat sich abgemeldet\"],\"uAQUqI\":[\"Status\"],\"uB85T3\":[\"Speichern fehlgeschlagen: \",[\"msg\"]],\"uV3DOL\":[\"G-Line\"],\"uW3lLI\":[\"IRC-Server:\"],\"usSSr/\":[\"Zoomstufe\"],\"v7uvcf\":[\"Software:\"],\"vE8kb+\":[\"Shift+Enter für neue Zeilen (Enter sendet)\"],\"vERlcd\":[\"Profil\"],\"vK0RL8\":[\"Kein Thema\"],\"vSJd18\":[\"Video\"],\"vXIe7J\":[\"Sprache\"],\"vaHYxN\":[\"Echter Name\"],\"vhjbKr\":[\"Abwesend\"],\"w/nogd\":[[\"0\"],\" network\",[\"1\"],\" — pick one to join\"],\"w4NYox\":[[\"title\"],\" Client\"],\"w8xQRx\":[\"Ungültiger Wert\"],\"wFjjxZ\":[\"wurde von \",[\"username\"],\" aus \",[\"channelName\"],\" gekickt (\",[\"reason\"],\")\"],\"wGjaGl\":[\"Keine Bann-Ausnahmen gefunden\"],\"wPrGnM\":[\"Kanal-Administrator\"],\"wRkP2d\":[\"GIF\"],\"wbm86v\":[\"Anzeigen, wenn Benutzer Kanäle betreten oder verlassen\"],\"whqZ9r\":[\"Weitere Wörter oder Phrasen zum Hervorheben\"],\"wm7RV4\":[\"Benachrichtigungston\"],\"wz/Yoq\":[\"Deine Nachrichten könnten abgefangen werden, wenn sie zwischen Servern weitergeleitet werden\"],\"xCJdfg\":[\"Leeren\"],\"xUHRTR\":[\"Beim Verbinden automatisch als Operator authentifizieren\"],\"xWHwwQ\":[\"Sperren\"],\"xYilR2\":[\"Medien\"],\"xceQrO\":[\"Nur sichere Websockets werden unterstützt\"],\"xdtXa+\":[\"Kanalname\"],\"xfXC7q\":[\"Textkanäle\"],\"xlCYOE\":[\"Weitere Nachrichten werden geladen...\"],\"xlhswE\":[\"Mindestwert ist \",[\"0\"]],\"xq97Ci\":[\"Wort oder Phrase hinzufügen...\"],\"xuRqRq\":[\"Client-Limit (+l)\"],\"xwF+7J\":[[\"0\"],\" tippt...\"],\"yJztBY\":[\"Netzwerk löschen\"],\"yNeucF\":[\"Dieser Server unterstützt keine erweiterten Profilmetadaten (IRCv3 METADATA). Felder wie Avatar, Anzeigename und Status sind nicht verfügbar.\"],\"yPlrca\":[\"Kanal-Avatar\"],\"yQE2r9\":[\"Laden\"],\"ySU+JY\":[\"deine@email.de\"],\"yTX1Rt\":[\"Oper-Benutzername\"],\"yYOzWD\":[\"Protokolle\"],\"yfx9Re\":[\"IRC-Operatorpasswort\"],\"ygCKqB\":[\"Stopp\"],\"ymDxJx\":[\"IRC-Operatorbenutzername\"],\"yrpRsQ\":[\"Nach Name sortieren\"],\"yz7wBu\":[\"Schließen\"],\"zJw+jA\":[\"setzt Modus: \",[\"0\"]],\"zebeLu\":[\"oper-Benutzername eingeben\"],\"zpr0Bw\":[\"GZ-Line\"]}"); \ No newline at end of file diff --git a/src/locales/de/messages.po b/src/locales/de/messages.po index a70d30e8..e0bd8ea2 100644 --- a/src/locales/de/messages.po +++ b/src/locales/de/messages.po @@ -22,6 +22,16 @@ msgstr "ObsidianIRC - IRC in die Zukunft bringen" msgid "— open in viewer" msgstr "— im Viewer öffnen" +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx +msgid "(inherit)" +msgstr "(übernehmen)" + +#: src/components/ui/BouncerNetworkForm.tsx +msgid "(unchanged)" +msgstr "(unverändert)" + #. placeholder {0}: filteredMessages.length #. placeholder {1}: import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import type * as React from "react"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { SCROLL_TOLERANCE, useScrollToBottom, } from "../../hooks/useScrollToBottom"; import { groupConsecutiveEvents } from "../../lib/eventGrouping"; import ircClient from "../../lib/ircClient"; import useStore from "../../store"; import type { Message as MessageType } from "../../types"; import { CollapsedEventMessage } from "../message/CollapsedEventMessage"; import { MessageItem } from "../message/MessageItem"; import LoadingSpinner from "../ui/LoadingSpinner"; import { ScrollToBottomButton } from "../ui/ScrollToBottomButton"; export const DEFAULT_VISIBLE_MESSAGE_COUNT = 100; // Stable empty array — prevents selector from returning a new [] on every render // when the channel has no messages yet (undefined ?? [] would create a new ref each time). const EMPTY_MESSAGES: import("../../types").Message[] = []; export interface ChannelMessageListHandle { setAtBottom: () => void; scrollToBottom: () => void; getScrollState: () => { scrollTop: number; isAtBottom: boolean; visibleCount: number; }; } interface ChannelMessageListProps { channelKey: string; serverId: string; channelId: string | null; privateChatId: string | null; isActive: boolean; searchQuery: string; isMemberListVisible: boolean; onReply: (msg: MessageType | null) => void; onUsernameContextMenu: ( e: React.MouseEvent, username: string, serverId: string, channelId: string, avatarEl?: Element | null, ) => void; onIrcLinkClick: (url: string) => void; onReactClick: (msg: MessageType, el: Element) => void; onReactionUnreact: (emoji: string, msg: MessageType) => void; onOpenReactionModal: ( msg: MessageType, position: { x: number; y: number }, ) => void; onDirectReaction: (emoji: string, msg: MessageType) => void; onRedactMessage: (msg: MessageType) => void; onOpenProfile: (username: string) => void; joinChannel: (serverId: string, channelName: string) => void; onClearSearch: () => void; highlightedMessageId?: string; // undefined = first visit; null = was at bottom; object = restore to saved position initialScrollState?: { scrollTop: number; visibleCount: number } | null; } export const ChannelMessageList = forwardRef< ChannelMessageListHandle, ChannelMessageListProps >( ( { channelKey, serverId, channelId, privateChatId, isActive, searchQuery, isMemberListVisible, onReply, onUsernameContextMenu, onIrcLinkClick, onReactClick, onReactionUnreact, onOpenReactionModal, onDirectReaction, onRedactMessage, onOpenProfile, joinChannel, onClearSearch, highlightedMessageId, initialScrollState, }, ref, ) => { const { t } = useLingui(); const [visibleMessageCount, setVisibleMessageCount] = useState( initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT, ); // Ref mirror so getScrollState closure always reads the current value without needing it as a dep. const visibleMessageCountRef = useRef(visibleMessageCount); visibleMessageCountRef.current = visibleMessageCount; // Distinguishes initial join (full-screen spinner) from subsequent "load more" (button spinner). const [isFetchingMore, setIsFetchingMore] = useState(false); const isFetchingMoreRef = useRef(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const messagesInnerRef = useRef(null); // prev scrollHeight for prepend delta-correction. const prevScrollHeightRef = useRef(0); // Ref mirror of isScrolledUp — lets useLayoutEffect closures read current value // without listing isScrolledUp as a dep (which would re-run effects on every scroll). const isScrolledUpRef = useRef(false); const prevFilteredLengthRef = useRef(0); const prevFirstMsgIdRef = useRef(null); // Set by the window-growth layoutEffect (or button handler) when a true prepend is detected. // Consumed by the delta-correction layoutEffect one render later (after visibleCount grows). // Using a flag instead of tracking displayedMessages[0]?.id because slice(-N) slides the // window on every bottom append, changing displayedMessages[0] even for non-prepend renders. const pendingPrependRef = useRef(false); // Shared scrollHeight baseline between the delta-correction layout effect and the inner // ResizeObserver. When scrollTop is corrected after a prepend, we update this so the RO's // "was at bottom" check is not fooled by the adjusted scrollTop vs its stale prevSH. const resizeObserverPrevSHRef = useRef(0); const channelMessages = useStore( useCallback( (state) => state.messages[channelKey] ?? EMPTY_MESSAGES, [channelKey], ), ); const servers = useStore((state) => state.servers); const mobileViewActiveColumn = useStore( (state) => state.ui.mobileViewActiveColumn, ); const channel = useMemo( () => channelId ? (servers .find((s) => s.id === serverId) ?.channels.find((c) => c.id === channelId) ?? null) : null, [servers, serverId, channelId], ); const { isScrolledUp, wasAtBottomRef, scrollToBottom } = useScrollToBottom( messagesContainerRef, messagesEndRef, { channelId: `${channelId || privateChatId}-${isMemberListVisible}` }, ); // Snapshot of the last known scroll position captured while the container was visible. // getScrollState() reads this instead of the live DOM because React commits display:none // before running cleanup effects, collapsing scrollTop/scrollHeight/clientHeight to 0. const lastScrollTopRef = useRef(initialScrollState?.scrollTop ?? 0); useEffect(() => { const container = messagesContainerRef.current; if (!container) return; const onScroll = () => { if (container.clientHeight > 0) lastScrollTopRef.current = container.scrollTop; }; container.addEventListener("scroll", onScroll, { passive: true }); return () => container.removeEventListener("scroll", onScroll); }, []); // Restore scroll position when a keep-alive channel transitions from hidden to visible. // display:none may reset scrollTop to 0; lastScrollTopRef was captured while visible. const prevActiveRef = useRef(isActive); useLayoutEffect(() => { if (isActive && !prevActiveRef.current) { const container = messagesContainerRef.current; if (container && lastScrollTopRef.current > 0) { container.scrollTop = lastScrollTopRef.current; } } prevActiveRef.current = isActive; }, [isActive]); useImperativeHandle(ref, () => ({ setAtBottom: () => { wasAtBottomRef.current = true; }, scrollToBottom, getScrollState: () => ({ scrollTop: lastScrollTopRef.current, isAtBottom: wasAtBottomRef.current, visibleCount: visibleMessageCountRef.current, }), })); const filteredMessages = useMemo(() => { if (!searchQuery.trim()) return channelMessages; const query = searchQuery.toLowerCase(); return channelMessages.filter( (msg) => msg.content.toLowerCase().includes(query) || msg.userId.toLowerCase().includes(query), ); }, [channelMessages, searchQuery]); useEffect(() => { isScrolledUpRef.current = isScrolledUp; // When the user returns to the bottom, shrink the window back to the base so // slice(-N) resumes trimming old messages from the top (memory optimization). // Only shrink if we grew above the base — preserves a sub-default saved visibleCount. if (!isScrolledUp) { setVisibleMessageCount((prev) => prev > DEFAULT_VISIBLE_MESSAGE_COUNT ? DEFAULT_VISIBLE_MESSAGE_COUNT : prev, ); } }, [isScrolledUp]); // Reset ref-tracked windowing state when switching channels. // visibleMessageCount is NOT reset here — useState(initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT) // already initializes it correctly on mount, and this effect runs once on mount for the // same channelKey (each instance is bound to exactly one channel by the parent key={}). // biome-ignore lint/correctness/useExhaustiveDependencies: intentional full reset on channel change useEffect(() => { prevFilteredLengthRef.current = 0; prevFirstMsgIdRef.current = null; prevScrollHeightRef.current = 0; pendingPrependRef.current = false; resizeObserverPrevSHRef.current = 0; }, [channelKey]); const displayedMessages = useMemo(() => { if (searchQuery.trim()) return filteredMessages; return filteredMessages.slice(-visibleMessageCount); }, [filteredMessages, visibleMessageCount, searchQuery]); const locallyHidden = filteredMessages.length > displayedMessages.length; const serverHasMore = channel?.hasMoreHistory === true; const hasMoreMessages = locallyHidden || serverHasMore; const eventGroups = useMemo( () => groupConsecutiveEvents(displayedMessages), [displayedMessages], ); const isLoadingHistory = channel?.isLoadingHistory ?? false; // Scroll to bottom on initial mount, unless a saved position was passed in. // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount only useEffect(() => { const container = messagesContainerRef.current; if (!container) return; if (initialScrollState) { container.scrollTop = initialScrollState.scrollTop; lastScrollTopRef.current = initialScrollState.scrollTop; wasAtBottomRef.current = false; } else { container.scrollTop = container.scrollHeight; lastScrollTopRef.current = container.scrollHeight; wasAtBottomRef.current = true; } }, []); // Scroll to bottom after initial join history loads; clear fetch spinner at batch end. const wasLoadingHistoryRef = useRef(false); // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is stable via useCallback; refs and setters are stable useLayoutEffect(() => { if (wasLoadingHistoryRef.current && !isLoadingHistory) { if (isFetchingMoreRef.current) { // delta correction for scroll position is handled by useLayoutEffect([displayedMessages]) isFetchingMoreRef.current = false; setIsFetchingMore(false); } else { scrollToBottom(); wasAtBottomRef.current = true; } } wasLoadingHistoryRef.current = isLoadingHistory; }, [isLoadingHistory]); // When older messages are prepended, grow the window so they enter displayedMessages. // When new messages arrive at the bottom while the user is scrolled up, also grow the // window to keep the current top messages visible — slice(-N) otherwise slides the // window forward and hides them, incrementing the "N older messages" counter on every // incoming message. Only let the slice trim from the top when the user is at the bottom // (where auto-scroll handles keeping them current). useLayoutEffect(() => { const newLength = filteredMessages.length; const newFirstId = filteredMessages[0]?.id ?? null; const delta = newLength - prevFilteredLengthRef.current; if (prevFilteredLengthRef.current > 0 && delta > 0) { if (newFirstId !== prevFirstMsgIdRef.current) { // Messages prepended (load-more): signal delta-correction to compensate scrollTop. pendingPrependRef.current = true; setVisibleMessageCount((prev) => prev + delta); } else if (isScrolledUpRef.current) { // Messages appended at bottom while user is scrolled up reading history. // Expand the window to prevent top messages from dropping out of the slice. setVisibleMessageCount((prev) => prev + delta); } } prevFilteredLengthRef.current = newLength; prevFirstMsgIdRef.current = newFirstId; }, [filteredMessages]); // Compensate scrollTop when content is prepended above the viewport. // biome-ignore lint/correctness/useExhaustiveDependencies: runs on every displayedMessages render to capture the resulting scrollHeight; refs are stable useLayoutEffect(() => { const container = messagesContainerRef.current; if (!container) return; // Skip while container is display:none — scrollHeight collapses to 0 and would // poison prevScrollHeightRef, causing a huge spurious delta on the next visible render. if (container.clientHeight === 0) return; const prevHeight = prevScrollHeightRef.current; const newHeight = container.scrollHeight; // Only correct when a true load-more prepend happened (flag set by the window-growth // layoutEffect or button handler). Bottom appends slide the slice(-N) window which also // changes displayedMessages[0] — ID-comparison can't distinguish the two cases. const wasPrepend = pendingPrependRef.current; // Only consume the flag when scrollHeight actually changed — the server-side load-more // path goes through two renders: Render A (filteredMessages grows, visibleCount unchanged, // same displayedMessages content, same scrollHeight) then Render B (visibleCount grows, // new messages enter displayedMessages, scrollHeight grows). The flag must survive Render A // so it's still set when Render B fires the actual correction. if (wasPrepend && newHeight !== prevHeight) { pendingPrependRef.current = false; } if ( isScrolledUpRef.current && prevHeight > 0 && newHeight > prevHeight && wasPrepend ) { const delta = newHeight - prevHeight; container.scrollTop += delta; resizeObserverPrevSHRef.current = newHeight; } prevScrollHeightRef.current = newHeight; }, [displayedMessages]); // Re-stick to bottom when inner message content grows (media/audio previews loading). // Uses prevScrollHeight instead of wasAtBottomRef to avoid stale-flag race where the // ref is true while the user is actively scrolling up. // When the container width changes (member list toggle, window resize), text reflows // and scrollHeight changes; preserve proportional scroll position for scrolled-up users. // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is a stable ref useEffect(() => { const container = messagesContainerRef.current; const inner = messagesInnerRef.current; if (!inner || !container) return; resizeObserverPrevSHRef.current = container.scrollHeight; let prevClientWidth = container.clientWidth; const observer = new ResizeObserver(() => { if (container.clientHeight === 0) return; // Effect may re-initialize while container is display:none (ref=0). // Re-seed with current dimensions and skip — no reliable "was at bottom" data. if (resizeObserverPrevSHRef.current === 0) { resizeObserverPrevSHRef.current = container.scrollHeight; prevClientWidth = container.clientWidth; return; } const currentClientWidth = container.clientWidth; const widthChanged = currentClientWidth !== prevClientWidth; prevClientWidth = currentClientWidth; const prevSH = resizeObserverPrevSHRef.current; const wasAtPrevBottom = container.scrollTop + container.clientHeight >= prevSH - SCROLL_TOLERANCE; resizeObserverPrevSHRef.current = container.scrollHeight; if (wasAtPrevBottom) { scrollToBottom(); } else if (widthChanged && prevSH > 0) { const ratio = container.scrollTop / prevSH; container.scrollTop = Math.round(ratio * container.scrollHeight); } }); observer.observe(inner); return () => observer.disconnect(); }, [isLoadingHistory, channelId, privateChatId]); // Auto-scroll on new messages — skip when this channel is hidden (display:none). // biome-ignore lint/correctness/useExhaustiveDependencies: only scroll when messages change, not when isActive changes useEffect(() => { if (!isActive) return; const isNarrowView = window.matchMedia("(max-width: 768px)").matches; const isChatVisible = !isNarrowView || mobileViewActiveColumn === "chatView"; if (wasAtBottomRef.current && isChatVisible) { scrollToBottom(); } }, [displayedMessages, mobileViewActiveColumn, scrollToBottom, isActive]); return ( <>
{isLoadingHistory && !isFetchingMore ? (
) : (
{hasMoreMessages && !searchQuery && (
)} {searchQuery && (
{plural(filteredMessages.length, { one: t`Found 1 message matching "${searchQuery}"`, other: t`Found ${filteredMessages.length} messages matching "${searchQuery}"`, })}
)} {eventGroups.map((group) => { if (group.type === "eventGroup") { const firstId = group.messages[0]?.id || ""; const lastId = group.messages[group.messages.length - 1]?.id || ""; const groupKey = `group-${firstId}-${lastId}`; return ( ); } const message = group.messages[0]; const originalIndex = channelMessages.findIndex( (m) => m.id === message.id, ); const previousMessage = channelMessages[originalIndex - 1]; const showHeader = !previousMessage || previousMessage.type !== "message" || previousMessage.userId !== message.userId || new Date(message.timestamp).getTime() - new Date(previousMessage.timestamp).getTime() > 5 * 60 * 1000; return ( ); })}
)}
); }, ); ChannelMessageList.displayName = "ChannelMessageList"; // Wrap with memo so hidden keep-alive channels skip re-renders when their props // haven't changed (e.g. when messageText changes in the input — the only thing // that changes on typing is local state inside ChatArea, not the props we pass here). export const MemoChannelMessageList = memo(ChannelMessageList); #. placeholder {2}: import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import type * as React from "react"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { SCROLL_TOLERANCE, useScrollToBottom, } from "../../hooks/useScrollToBottom"; import { groupConsecutiveEvents } from "../../lib/eventGrouping"; import ircClient from "../../lib/ircClient"; import useStore from "../../store"; import type { Message as MessageType } from "../../types"; import { CollapsedEventMessage } from "../message/CollapsedEventMessage"; import { MessageItem } from "../message/MessageItem"; import LoadingSpinner from "../ui/LoadingSpinner"; import { ScrollToBottomButton } from "../ui/ScrollToBottomButton"; export const DEFAULT_VISIBLE_MESSAGE_COUNT = 100; // Stable empty array — prevents selector from returning a new [] on every render // when the channel has no messages yet (undefined ?? [] would create a new ref each time). const EMPTY_MESSAGES: import("../../types").Message[] = []; export interface ChannelMessageListHandle { setAtBottom: () => void; scrollToBottom: () => void; getScrollState: () => { scrollTop: number; isAtBottom: boolean; visibleCount: number; }; } interface ChannelMessageListProps { channelKey: string; serverId: string; channelId: string | null; privateChatId: string | null; isActive: boolean; searchQuery: string; isMemberListVisible: boolean; onReply: (msg: MessageType | null) => void; onUsernameContextMenu: ( e: React.MouseEvent, username: string, serverId: string, channelId: string, avatarEl?: Element | null, ) => void; onIrcLinkClick: (url: string) => void; onReactClick: (msg: MessageType, el: Element) => void; onReactionUnreact: (emoji: string, msg: MessageType) => void; onOpenReactionModal: ( msg: MessageType, position: { x: number; y: number }, ) => void; onDirectReaction: (emoji: string, msg: MessageType) => void; onRedactMessage: (msg: MessageType) => void; onOpenProfile: (username: string) => void; joinChannel: (serverId: string, channelName: string) => void; onClearSearch: () => void; highlightedMessageId?: string; // undefined = first visit; null = was at bottom; object = restore to saved position initialScrollState?: { scrollTop: number; visibleCount: number } | null; } export const ChannelMessageList = forwardRef< ChannelMessageListHandle, ChannelMessageListProps >( ( { channelKey, serverId, channelId, privateChatId, isActive, searchQuery, isMemberListVisible, onReply, onUsernameContextMenu, onIrcLinkClick, onReactClick, onReactionUnreact, onOpenReactionModal, onDirectReaction, onRedactMessage, onOpenProfile, joinChannel, onClearSearch, highlightedMessageId, initialScrollState, }, ref, ) => { const { t } = useLingui(); const [visibleMessageCount, setVisibleMessageCount] = useState( initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT, ); // Ref mirror so getScrollState closure always reads the current value without needing it as a dep. const visibleMessageCountRef = useRef(visibleMessageCount); visibleMessageCountRef.current = visibleMessageCount; // Distinguishes initial join (full-screen spinner) from subsequent "load more" (button spinner). const [isFetchingMore, setIsFetchingMore] = useState(false); const isFetchingMoreRef = useRef(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const messagesInnerRef = useRef(null); // prev scrollHeight for prepend delta-correction. const prevScrollHeightRef = useRef(0); // Ref mirror of isScrolledUp — lets useLayoutEffect closures read current value // without listing isScrolledUp as a dep (which would re-run effects on every scroll). const isScrolledUpRef = useRef(false); const prevFilteredLengthRef = useRef(0); const prevFirstMsgIdRef = useRef(null); // Set by the window-growth layoutEffect (or button handler) when a true prepend is detected. // Consumed by the delta-correction layoutEffect one render later (after visibleCount grows). // Using a flag instead of tracking displayedMessages[0]?.id because slice(-N) slides the // window on every bottom append, changing displayedMessages[0] even for non-prepend renders. const pendingPrependRef = useRef(false); // Shared scrollHeight baseline between the delta-correction layout effect and the inner // ResizeObserver. When scrollTop is corrected after a prepend, we update this so the RO's // "was at bottom" check is not fooled by the adjusted scrollTop vs its stale prevSH. const resizeObserverPrevSHRef = useRef(0); const channelMessages = useStore( useCallback( (state) => state.messages[channelKey] ?? EMPTY_MESSAGES, [channelKey], ), ); const servers = useStore((state) => state.servers); const mobileViewActiveColumn = useStore( (state) => state.ui.mobileViewActiveColumn, ); const channel = useMemo( () => channelId ? (servers .find((s) => s.id === serverId) ?.channels.find((c) => c.id === channelId) ?? null) : null, [servers, serverId, channelId], ); const { isScrolledUp, wasAtBottomRef, scrollToBottom } = useScrollToBottom( messagesContainerRef, messagesEndRef, { channelId: `${channelId || privateChatId}-${isMemberListVisible}` }, ); // Snapshot of the last known scroll position captured while the container was visible. // getScrollState() reads this instead of the live DOM because React commits display:none // before running cleanup effects, collapsing scrollTop/scrollHeight/clientHeight to 0. const lastScrollTopRef = useRef(initialScrollState?.scrollTop ?? 0); useEffect(() => { const container = messagesContainerRef.current; if (!container) return; const onScroll = () => { if (container.clientHeight > 0) lastScrollTopRef.current = container.scrollTop; }; container.addEventListener("scroll", onScroll, { passive: true }); return () => container.removeEventListener("scroll", onScroll); }, []); // Restore scroll position when a keep-alive channel transitions from hidden to visible. // display:none may reset scrollTop to 0; lastScrollTopRef was captured while visible. const prevActiveRef = useRef(isActive); useLayoutEffect(() => { if (isActive && !prevActiveRef.current) { const container = messagesContainerRef.current; if (container && lastScrollTopRef.current > 0) { container.scrollTop = lastScrollTopRef.current; } } prevActiveRef.current = isActive; }, [isActive]); useImperativeHandle(ref, () => ({ setAtBottom: () => { wasAtBottomRef.current = true; }, scrollToBottom, getScrollState: () => ({ scrollTop: lastScrollTopRef.current, isAtBottom: wasAtBottomRef.current, visibleCount: visibleMessageCountRef.current, }), })); const filteredMessages = useMemo(() => { if (!searchQuery.trim()) return channelMessages; const query = searchQuery.toLowerCase(); return channelMessages.filter( (msg) => msg.content.toLowerCase().includes(query) || msg.userId.toLowerCase().includes(query), ); }, [channelMessages, searchQuery]); useEffect(() => { isScrolledUpRef.current = isScrolledUp; // When the user returns to the bottom, shrink the window back to the base so // slice(-N) resumes trimming old messages from the top (memory optimization). // Only shrink if we grew above the base — preserves a sub-default saved visibleCount. if (!isScrolledUp) { setVisibleMessageCount((prev) => prev > DEFAULT_VISIBLE_MESSAGE_COUNT ? DEFAULT_VISIBLE_MESSAGE_COUNT : prev, ); } }, [isScrolledUp]); // Reset ref-tracked windowing state when switching channels. // visibleMessageCount is NOT reset here — useState(initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT) // already initializes it correctly on mount, and this effect runs once on mount for the // same channelKey (each instance is bound to exactly one channel by the parent key={}). // biome-ignore lint/correctness/useExhaustiveDependencies: intentional full reset on channel change useEffect(() => { prevFilteredLengthRef.current = 0; prevFirstMsgIdRef.current = null; prevScrollHeightRef.current = 0; pendingPrependRef.current = false; resizeObserverPrevSHRef.current = 0; }, [channelKey]); const displayedMessages = useMemo(() => { if (searchQuery.trim()) return filteredMessages; return filteredMessages.slice(-visibleMessageCount); }, [filteredMessages, visibleMessageCount, searchQuery]); const locallyHidden = filteredMessages.length > displayedMessages.length; const serverHasMore = channel?.hasMoreHistory === true; const hasMoreMessages = locallyHidden || serverHasMore; const eventGroups = useMemo( () => groupConsecutiveEvents(displayedMessages), [displayedMessages], ); const isLoadingHistory = channel?.isLoadingHistory ?? false; // Scroll to bottom on initial mount, unless a saved position was passed in. // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount only useEffect(() => { const container = messagesContainerRef.current; if (!container) return; if (initialScrollState) { container.scrollTop = initialScrollState.scrollTop; lastScrollTopRef.current = initialScrollState.scrollTop; wasAtBottomRef.current = false; } else { container.scrollTop = container.scrollHeight; lastScrollTopRef.current = container.scrollHeight; wasAtBottomRef.current = true; } }, []); // Scroll to bottom after initial join history loads; clear fetch spinner at batch end. const wasLoadingHistoryRef = useRef(false); // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is stable via useCallback; refs and setters are stable useLayoutEffect(() => { if (wasLoadingHistoryRef.current && !isLoadingHistory) { if (isFetchingMoreRef.current) { // delta correction for scroll position is handled by useLayoutEffect([displayedMessages]) isFetchingMoreRef.current = false; setIsFetchingMore(false); } else { scrollToBottom(); wasAtBottomRef.current = true; } } wasLoadingHistoryRef.current = isLoadingHistory; }, [isLoadingHistory]); // When older messages are prepended, grow the window so they enter displayedMessages. // When new messages arrive at the bottom while the user is scrolled up, also grow the // window to keep the current top messages visible — slice(-N) otherwise slides the // window forward and hides them, incrementing the "N older messages" counter on every // incoming message. Only let the slice trim from the top when the user is at the bottom // (where auto-scroll handles keeping them current). useLayoutEffect(() => { const newLength = filteredMessages.length; const newFirstId = filteredMessages[0]?.id ?? null; const delta = newLength - prevFilteredLengthRef.current; if (prevFilteredLengthRef.current > 0 && delta > 0) { if (newFirstId !== prevFirstMsgIdRef.current) { // Messages prepended (load-more): signal delta-correction to compensate scrollTop. pendingPrependRef.current = true; setVisibleMessageCount((prev) => prev + delta); } else if (isScrolledUpRef.current) { // Messages appended at bottom while user is scrolled up reading history. // Expand the window to prevent top messages from dropping out of the slice. setVisibleMessageCount((prev) => prev + delta); } } prevFilteredLengthRef.current = newLength; prevFirstMsgIdRef.current = newFirstId; }, [filteredMessages]); // Compensate scrollTop when content is prepended above the viewport. // biome-ignore lint/correctness/useExhaustiveDependencies: runs on every displayedMessages render to capture the resulting scrollHeight; refs are stable useLayoutEffect(() => { const container = messagesContainerRef.current; if (!container) return; // Skip while container is display:none — scrollHeight collapses to 0 and would // poison prevScrollHeightRef, causing a huge spurious delta on the next visible render. if (container.clientHeight === 0) return; const prevHeight = prevScrollHeightRef.current; const newHeight = container.scrollHeight; // Only correct when a true load-more prepend happened (flag set by the window-growth // layoutEffect or button handler). Bottom appends slide the slice(-N) window which also // changes displayedMessages[0] — ID-comparison can't distinguish the two cases. const wasPrepend = pendingPrependRef.current; // Only consume the flag when scrollHeight actually changed — the server-side load-more // path goes through two renders: Render A (filteredMessages grows, visibleCount unchanged, // same displayedMessages content, same scrollHeight) then Render B (visibleCount grows, // new messages enter displayedMessages, scrollHeight grows). The flag must survive Render A // so it's still set when Render B fires the actual correction. if (wasPrepend && newHeight !== prevHeight) { pendingPrependRef.current = false; } if ( isScrolledUpRef.current && prevHeight > 0 && newHeight > prevHeight && wasPrepend ) { const delta = newHeight - prevHeight; container.scrollTop += delta; resizeObserverPrevSHRef.current = newHeight; } prevScrollHeightRef.current = newHeight; }, [displayedMessages]); // Re-stick to bottom when inner message content grows (media/audio previews loading). // Uses prevScrollHeight instead of wasAtBottomRef to avoid stale-flag race where the // ref is true while the user is actively scrolling up. // When the container width changes (member list toggle, window resize), text reflows // and scrollHeight changes; preserve proportional scroll position for scrolled-up users. // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is a stable ref useEffect(() => { const container = messagesContainerRef.current; const inner = messagesInnerRef.current; if (!inner || !container) return; resizeObserverPrevSHRef.current = container.scrollHeight; let prevClientWidth = container.clientWidth; const observer = new ResizeObserver(() => { if (container.clientHeight === 0) return; // Effect may re-initialize while container is display:none (ref=0). // Re-seed with current dimensions and skip — no reliable "was at bottom" data. if (resizeObserverPrevSHRef.current === 0) { resizeObserverPrevSHRef.current = container.scrollHeight; prevClientWidth = container.clientWidth; return; } const currentClientWidth = container.clientWidth; const widthChanged = currentClientWidth !== prevClientWidth; prevClientWidth = currentClientWidth; const prevSH = resizeObserverPrevSHRef.current; const wasAtPrevBottom = container.scrollTop + container.clientHeight >= prevSH - SCROLL_TOLERANCE; resizeObserverPrevSHRef.current = container.scrollHeight; if (wasAtPrevBottom) { scrollToBottom(); } else if (widthChanged && prevSH > 0) { const ratio = container.scrollTop / prevSH; container.scrollTop = Math.round(ratio * container.scrollHeight); } }); observer.observe(inner); return () => observer.disconnect(); }, [isLoadingHistory, channelId, privateChatId]); // Auto-scroll on new messages — skip when this channel is hidden (display:none). // biome-ignore lint/correctness/useExhaustiveDependencies: only scroll when messages change, not when isActive changes useEffect(() => { if (!isActive) return; const isNarrowView = window.matchMedia("(max-width: 768px)").matches; const isChatVisible = !isNarrowView || mobileViewActiveColumn === "chatView"; if (wasAtBottomRef.current && isChatVisible) { scrollToBottom(); } }, [displayedMessages, mobileViewActiveColumn, scrollToBottom, isActive]); return ( <>
{isLoadingHistory && !isFetchingMore ? (
) : (
{hasMoreMessages && !searchQuery && (
)} {searchQuery && (
{plural(filteredMessages.length, { one: t`Found 1 message matching "${searchQuery}"`, other: t`Found ${filteredMessages.length} messages matching "${searchQuery}"`, })}
)} {eventGroups.map((group) => { if (group.type === "eventGroup") { const firstId = group.messages[0]?.id || ""; const lastId = group.messages[group.messages.length - 1]?.id || ""; const groupKey = `group-${firstId}-${lastId}`; return ( ); } const message = group.messages[0]; const originalIndex = channelMessages.findIndex( (m) => m.id === message.id, ); const previousMessage = channelMessages[originalIndex - 1]; const showHeader = !previousMessage || previousMessage.type !== "message" || previousMessage.userId !== message.userId || new Date(message.timestamp).getTime() - new Date(previousMessage.timestamp).getTime() > 5 * 60 * 1000; return ( ); })}
)}
); }, ); ChannelMessageList.displayName = "ChannelMessageList"; // Wrap with memo so hidden keep-alive channels skip re-renders when their props // haven't changed (e.g. when messageText changes in the input — the only thing // that changes on typing is local state inside ChatArea, not the props we pass here). export const MemoChannelMessageList = memo(ChannelMessageList); @@ -46,6 +56,12 @@ msgstr "{0} und {1} tippen..." msgid "{0} is typing..." msgstr "{0} tippt..." +#. placeholder {0}: networks.length +#. placeholder {1}: networks.length === 1 ? "" : "s" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "{0} network{1} — pick one to join" +msgstr "" + #. placeholder {0}: filteredMessages.length - displayedMessages.length #: src/components/layout/ChannelMessageList.tsx msgid "{0} older messages" @@ -186,6 +202,12 @@ msgstr "Einladungs-Maske hinzufügen (z.B. nick!*@*, *!*@host.com)" msgid "Add IRC Server" msgstr "IRC-Server hinzufügen" +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworksPanel.tsx +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Add Network" +msgstr "Netzwerk hinzufügen" + #: src/components/message/MessageActions.tsx #: src/components/message/MessageReactions.tsx #: src/components/message/MessageReactions.tsx @@ -205,6 +227,10 @@ msgstr "Regel hinzufügen" msgid "Add Server" msgstr "Server hinzufügen" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Add your first network" +msgstr "Erstes Netzwerk hinzufügen" + #: src/components/message/JsonLogMessage.tsx msgid "Additional Details" msgstr "Weitere Details" @@ -358,6 +384,10 @@ msgstr "Zurück" msgid "Back to image" msgstr "Zurück zum Bild" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Back to network list" +msgstr "" + #: src/components/ui/ModerationModal.tsx msgid "Ban {username} by hostmask (prevents them from rejoining from the same IP/host)" msgstr "{username} per hostmask sperren (verhindert erneutes Beitreten von derselben IP/Host)" @@ -405,6 +435,8 @@ msgstr "Alle Kanäle auf dem Server durchsuchen" #: src/components/ui/AddPrivateChatModal.tsx #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/ChannelSettingsModal.tsx #: src/components/ui/ExternalLinkWarningModal.tsx #: src/components/ui/FloodSettingsModal.tsx @@ -640,6 +672,7 @@ msgid "Configure notification sounds and highlights" msgstr "Benachrichtigungstöne und Hervorhebungen konfigurieren" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworksPanel.tsx msgid "Connect" msgstr "Verbinden" @@ -759,6 +792,10 @@ msgstr "Kanal löschen" msgid "Delete message" msgstr "Nachricht löschen" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Delete network" +msgstr "Netzwerk löschen" + #: src/components/layout/ChannelList.tsx msgid "Delete Private Chat" msgstr "Privatnachricht löschen" @@ -767,6 +804,10 @@ msgstr "Privatnachricht löschen" msgid "Delete this message? This cannot be undone." msgstr "Diese Nachricht löschen? Dies kann nicht rückgängig gemacht werden." +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Delete this network?" +msgstr "Dieses Netzwerk löschen?" + #: src/components/layout/ServerList.tsx #: src/components/mobile/ServerBottomSheet.tsx msgid "Disconnect" @@ -830,10 +871,16 @@ msgstr "Herunterladen" msgid "e.g., 100:1440" msgstr "z.B. 100:1440" +#: src/components/ui/BouncerNetworksPanel.tsx #: src/components/ui/ChannelSettingsModal.tsx msgid "Edit" msgstr "Bearbeiten" +#. placeholder {0}: editingNetwork?.attributes.name || editingNetwork?.netid +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Edit {0}" +msgstr "{0} bearbeiten" + #: src/components/ui/UserProfileModal.tsx msgid "Edit Profile" msgstr "Profil bearbeiten" @@ -1057,6 +1104,7 @@ msgstr "STARTSEITE" msgid "Homepage" msgstr "Homepage" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserProfileModal.tsx msgid "Host" msgstr "Host" @@ -1271,6 +1319,10 @@ msgstr "Den Kanal verlassen" msgid "Let others know when you are typing" msgstr "Anderen mitteilen, wenn Sie tippen" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Libera Chat" +msgstr "Libera Chat" + #: src/components/message/LinkPreview.tsx msgid "Link preview" msgstr "Link-Vorschau" @@ -1299,6 +1351,10 @@ msgstr "GIFs werden geladen..." msgid "Loading more channels..." msgstr "Weitere Kanäle werden geladen..." +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Loading networks from your bouncer…" +msgstr "Netzwerke werden von deinem Bouncer geladen…" + #: src/components/ui/UserProfileModal.tsx msgid "Loading WHOIS data..." msgstr "WHOIS-Daten werden geladen..." @@ -1486,9 +1542,15 @@ msgid "Name:" msgstr "Name:" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx msgid "Network Name" msgstr "Netzwerkname" +#. placeholder {0}: server?.name ?? bouncerServerId +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Networks on {0}" +msgstr "Netzwerke auf {0}" + #: src/components/ui/QuickActions.tsx msgid "New DM" msgstr "Neue Direktnachricht" @@ -1511,6 +1573,7 @@ msgid "nick!user@host (e.g., spam*!*@*, *!*@badhost.com)" msgstr "nick!user@host (z.B. spam*!*@*, *!*@badhost.com)" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserSettings.tsx #: src/lib/settings/definitions/allSettings.ts msgid "Nickname" @@ -1570,6 +1633,10 @@ msgstr "Keine Datei ausgewählt" msgid "No flood profile" msgstr "Kein Flood-Profil" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "no host set" +msgstr "kein Host festgelegt" + #: src/components/ui/ChannelSettingsModal.tsx msgid "No invitations found" msgstr "Keine Einladungen gefunden" @@ -1610,6 +1677,10 @@ msgstr "Kein Thema gesetzt" msgid "No unread mentions or messages" msgstr "Keine ungelesenen Erwähnungen oder Nachrichten" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "No upstream networks yet." +msgstr "Noch keine Upstream-Netzwerke." + #: src/components/ui/AddPrivateChatModal.tsx msgid "No users available" msgstr "Keine Benutzer verfügbar" @@ -1696,6 +1767,10 @@ msgstr "Ups! Netz-Split! ⚠️" msgid "Op" msgstr "Op" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Open" +msgstr "" + #: src/components/ui/QuickActions/uiActionConfig.tsx msgid "Open channel configuration settings" msgstr "Kanaleinstellungen öffnen" @@ -1799,6 +1874,10 @@ msgstr "Privatnachricht anheften" msgid "Pin this private message conversation" msgstr "Dieses Privatgespräch anheften" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Plaintext" +msgstr "Klartext" + #: src/components/message/MediaPreview.tsx #: src/components/message/MediaPreview.tsx #: src/components/message/MediaPreview.tsx @@ -1827,6 +1906,7 @@ msgid "PM User" msgstr "Benutzer anschreiben" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx msgid "Port" msgstr "Port" @@ -1918,6 +1998,7 @@ msgstr "hat auf diese Nachricht reagiert" msgid "Read more" msgstr "Mehr lesen" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserProfileModal.tsx #: src/components/ui/UserSettings.tsx #: src/lib/settings/definitions/allSettings.ts @@ -2002,6 +2083,7 @@ msgstr "Regeln" msgid "Safe" msgstr "Sicher" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/ChannelSettingsModal.tsx #: src/components/ui/TopicModal.tsx #: src/components/ui/UserSettings.tsx @@ -2183,6 +2265,10 @@ msgstr "Server-Operatoren im Netzwerk könnten deine Nachrichten lesen" msgid "Server Password" msgstr "Server-Passwort" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Server Password (PASS)" +msgstr "Serverpasswort (PASS)" + #: src/components/ui/LinkSecurityWarningModal.tsx msgid "Server-to-server communication may use unencrypted connections" msgstr "Die Server-zu-Server-Kommunikation verwendet möglicherweise unverschlüsselte Verbindungen" @@ -2378,6 +2464,10 @@ msgstr "Zeit (Min)" msgid "Time Window (seconds)" msgstr "Zeitfenster (Sekunden)" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "TLS" +msgstr "TLS" + #: src/components/message/WhisperMessage.tsx #: src/components/message/WhisperMessage.tsx msgid "to" @@ -2426,6 +2516,10 @@ msgstr "Thema:" msgid "Total: {0}" msgstr "Gesamt: {0}" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Transport" +msgstr "Transport" + #: src/components/ui/UserSettings.tsx msgid "Trusted Sources" msgstr "Vertrauenswürdige Quellen" @@ -2536,6 +2630,7 @@ msgstr "Benutzerprofil" msgid "User Settings" msgstr "Benutzereinstellungen" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/InviteUserModal.tsx #: src/components/ui/ModerationModal.tsx msgid "Username" @@ -2683,6 +2778,10 @@ msgstr "Weit – Breiterer Schutzbereich" msgid "Will default to 'no reason' if left empty" msgstr "Standardmäßig 'kein Grund', wenn leer gelassen" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Yes, delete" +msgstr "Ja, löschen" + #: src/components/message/CollapsedEventMessage.tsx #: src/components/message/EventMessage.tsx msgid "You" @@ -2713,6 +2812,10 @@ msgstr "Ihr Kontopasswort zur Authentifizierung" msgid "Your account username for authentication" msgstr "Ihr Kontobenutzername zur Authentifizierung" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Your bouncer doesn't have any networks yet. Add one to get started." +msgstr "Dein Bouncer hat noch keine Netzwerke. Füge eines hinzu, um zu starten." + #: src/lib/settings/definitions/allSettings.ts msgid "Your default nickname for all servers" msgstr "Ihr Standard-Nickname für alle Server" diff --git a/src/locales/en/messages.mjs b/src/locales/en/messages.mjs index 2214a41a..30bf0f91 100644 --- a/src/locales/en/messages.mjs +++ b/src/locales/en/messages.mjs @@ -1 +1 @@ -/*eslint-disable*/export const messages=JSON.parse("{\"+5VMrz\":[\"Invalid pattern format. Use nick!user@host format (wildcards * allowed)\"],\"+6NQQA\":[\"General Support Channel\"],\"+6NyRG\":[\"Client\"],\"+K0AvT\":[\"Disconnect\"],\"+cyFdH\":[\"Default message when marking yourself as away\"],\"+mVPqU\":[\"Render markdown formatting in messages\"],\"+vqCJH\":[\"Your account username for authentication\"],\"+yPBXI\":[\"Choose file\"],\"+zy2Nq\":[\"Type\"],\"/09cao\":[\"Low Link Security (Level \",[\"securityLevel\"],\")\"],\"/3BQ4J\":[\"Users outside the channel cannot send messages to it\"],\"/6BzZF\":[\"Toggle Member List\"],\"/TNOPk\":[\"User is away\"],\"/XQgft\":[\"Discover\"],\"/cF7Rs\":[\"Volume\"],\"/dqduX\":[\"Next page\"],\"/fc3q4\":[\"All Content\"],\"/kISDh\":[\"Enable Notification Sounds\"],\"/n04sB\":[\"Kill\"],\"/rTz0M\":[\"Audio\"],\"/rfkZe\":[\"Play sounds for mentions and messages\"],\"0/0ZGA\":[\"Channel Name Mask\"],\"0D6j7U\":[\"Learn more about custom rules →\"],\"0XsHcR\":[\"Kick User\"],\"0ZpE//\":[\"Sort by Users\"],\"0bEPwz\":[\"Set Away\"],\"0dGkPt\":[\"Expand channel list\"],\"0gS7M5\":[\"Display Name\"],\"0kS+M8\":[\"ExampleNET\"],\"0rgoY7\":[\"Only connect to servers you choose\"],\"0wdd7X\":[\"Join\"],\"0wkVYx\":[\"Private Messages\"],\"111uHX\":[\"Link preview\"],\"196EG4\":[\"Delete Private Chat\"],\"1DSr1i\":[\"Register for an account\"],\"1O/24y\":[\"Toggle Channel List\"],\"1VPJJ2\":[\"External Link Warning\"],\"1ZC/dv\":[\"No unread mentions or messages\"],\"1pO1zi\":[\"Server name is required\"],\"1uwfzQ\":[\"View Channel Topic\"],\"268g7c\":[\"Enter display name\"],\"2FOFq1\":[\"Server operators on the network could potentially read your messages\"],\"2FYpfJ\":[\"More\"],\"2HF1Y2\":[[\"inviter\"],\" has invited \",[\"target\"],\" to join \",[\"channel\"]],\"2I70QL\":[\"View user profile information\"],\"2QYdmE\":[\"Users:\"],\"2QpEjG\":[\"left\"],\"2YE223\":[\"Message #\",[\"0\"],\" (Enter for new line, Shift+Enter to send)\"],\"2bimFY\":[\"Use server password\"],\"2iTmdZ\":[\"Local Storage:\"],\"2odkwe\":[\"Strict - More aggressive protection\"],\"2uDhbA\":[\"Enter username to invite\"],\"2ygf/L\":[\"← Back\"],\"2zEgxj\":[\"Search GIFs...\"],\"3RdPhl\":[\"Rename Channel\"],\"3THokf\":[\"Voiced User\"],\"3TSz9S\":[\"Minimize\"],\"3jBDvM\":[\"Channel Display Name\"],\"3ryuFU\":[\"Optional crash reports to improve the app\"],\"3uBF/8\":[\"Close viewer\"],\"3uwW8F\":[\"https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"3xf8Kz\":[\"Enter account name...\"],\"4/Rr0R\":[\"Invite a user to the current channel\"],\"4EZrJN\":[\"Rules\"],\"4JJtW9\":[\"#overflow\"],\"4NqeT4\":[\"Flood Profile (+F)\"],\"4RZQRK\":[\"What are you up to?\"],\"4hfTrB\":[\"Nickname\"],\"4n99LO\":[\"Already in \",[\"0\"]],\"4t6vMV\":[\"Automatically switch to single line for short messages\"],\"4vsHmf\":[\"Time (min)\"],\"5+INAX\":[\"Highlight messages that mention you\"],\"5R5Pv/\":[\"Oper Name\"],\"678PKt\":[\"Network Name\"],\"6Aih4U\":[\"Offline\"],\"6CO3WE\":[\"Password required to join the channel. Leave empty to remove the key.\"],\"6HhMs3\":[\"Quit Message\"],\"6V3Ea3\":[\"Copied\"],\"6lGV3K\":[\"Show less\"],\"6yFOEi\":[\"Enter oper password...\"],\"7+IHTZ\":[\"No file chosen\"],\"73hrRi\":[\"nick!user@host (e.g., spam*!*@*, *!*@badhost.com)\"],\"7QkKyN\":[\"Send private message\"],\"7U1W7c\":[\"Very Relaxed\"],\"7Y1YQj\":[\"Realname:\"],\"7YHArF\":[\"— open in viewer\"],\"7fjnVl\":[\"Search users...\"],\"7jL88x\":[\"Delete this message? This cannot be undone.\"],\"7nGhhM\":[\"What's on your mind?\"],\"7sEpu1\":[\"Members — \",[\"0\"]],\"7sNhEz\":[\"Username\"],\"8H0Q+x\":[\"Learn more about profiles →\"],\"8Phu0A\":[\"Display when users change their nickname\"],\"8XTG9e\":[\"Enter oper password\"],\"8XsV2J\":[\"Retry sending\"],\"8ZsakT\":[\"Password\"],\"8kR84m\":[\"You are about to open an external link:\"],\"8lCgih\":[\"Remove Rule\"],\"8p/xVT\":[[\"0\",\"plural\",{\"one\":[[\"1\"]],\"other\":[[\"2\"]]}]],\"8wRzac\":[[\"joinCount\",\"plural\",{\"one\":[\"joined\"],\"other\":[\"joined \",[\"joinCount\"],\" times\"]}]],\"9BMLnJ\":[\"Reconnect to server\"],\"9OEgyT\":[\"Add reaction\"],\"9PQ8m2\":[\"G-Line (Global Ban)\"],\"9Qs99X\":[\"Email:\"],\"9QupBP\":[\"Remove pattern\"],\"9bG48P\":[\"Sending\"],\"9f5f0u\":[\"Questions about privacy? Contact us:\"],\"9unqs3\":[\"Away:\"],\"9v3hwv\":[\"No servers found.\"],\"9zb2WA\":[\"Connecting\"],\"A1taO8\":[\"Search\"],\"A2adVi\":[\"Send Typing Notifications\"],\"A9Rhec\":[\"Channel Name\"],\"AWOSPo\":[\"Zoom in\"],\"AXSpEQ\":[\"Oper on Connect\"],\"AeXO77\":[\"Account\"],\"AhNP40\":[\"Seek\"],\"Ai2U7L\":[\"Host\"],\"AjBQnf\":[\"Changed nickname\"],\"AmXVh6\":[\"https://example.com/avatar.png\"],\"AnRu/j\":[\"Cancel reply\"],\"ApSx0O\":[\"Found \",[\"0\"],\" messages matching \\\"\",[\"searchQuery\"],\"\\\"\"],\"AxPAXW\":[\"No results found\"],\"AyNqAB\":[\"Display all server events in chat\"],\"B/QqGw\":[\"Away from keyboard\"],\"B8AaMI\":[\"This field is required\"],\"BA2c49\":[\"Server doesn't support advanced LIST filtering\"],\"BDKt3I\":[[\"0\"],\", \",[\"1\"],\", \",[\"2\"],\" and \",[\"3\"],\" others are typing...\"],\"BGul2A\":[\"You have unsaved changes. Are you sure you want to close without saving?\"],\"BIf9fi\":[\"Your status message\"],\"BZz3md\":[\"Your personal website\"],\"Bgm/H7\":[\"Allow entering multiple lines of text\"],\"BiQIl1\":[\"Pin this private message conversation\"],\"BlNZZ2\":[\"Click to jump to message\"],\"Bowq3c\":[\"Only operators can change the channel topic\"],\"Btozzp\":[\"This image has expired\"],\"Bycfjm\":[\"Total: \",[\"0\"]],\"C6IBQc\":[\"Copy entire JSON\"],\"C9L9wL\":[\"Data Collection\"],\"CDq4wC\":[\"Moderate User\"],\"CHVRxG\":[\"Message @\",[\"0\"],\" (Shift+Enter for new line)\"],\"CN9zdR\":[\"Oper name and password are required\"],\"CW3sYa\":[\"Add reaction \",[\"emoji\"]],\"CaAkqd\":[\"Show Quits\"],\"CbvaYj\":[\"Ban by Nickname\"],\"CcK+Ft\":[\"PDF\"],\"Ce8q3L\":[\"Select a channel\"],\"CsekCi\":[\"Normal\"],\"D+NlUC\":[\"System\"],\"D28t6+\":[\"joined and quit\"],\"DB8zMK\":[\"Apply\"],\"DBcWHr\":[\"Custom notification sound file\"],\"DTy9Xw\":[\"Media Previews\"],\"Dj4pSr\":[\"Choose a secure password\"],\"Du+zn+\":[\"Searching...\"],\"Du2T2f\":[\"Setting not found\"],\"DwsSVQ\":[\"Apply Filters & Refresh\"],\"E3W/zd\":[\"Default Nickname\"],\"E6nRW7\":[\"Copy URL\"],\"E703RG\":[\"Modes:\"],\"EAeu1Z\":[\"Send Invite\"],\"EFKJQT\":[\"Setting\"],\"EGPQBv\":[\"Custom Flood Rules (+f)\"],\"ELik0r\":[\"View Full Privacy Policy\"],\"EPbeC2\":[\"View or edit the channel topic\"],\"EQCDNT\":[\"Enter oper username...\"],\"EUvulZ\":[\"Found 1 message matching \\\"\",[\"searchQuery\"],\"\\\"\"],\"EatZYJ\":[\"Next image\"],\"EdQY6l\":[\"None\"],\"EnqLYU\":[\"Search servers...\"],\"F0OKMc\":[\"Edit Server\"],\"F6Int2\":[\"Enable Highlights\"],\"FDoLyE\":[\"Max Users\"],\"FUU/hZ\":[\"Control how much external media is loaded in chat.\"],\"Fdp03t\":[\"on\"],\"FfPWR0\":[\"Modal\"],\"FjkaiT\":[\"Zoom out\"],\"FlqOE9\":[\"What this means:\"],\"FolHNl\":[\"Manage your account and authentication\"],\"Fp2Dif\":[\"Quit the server\"],\"G5KmCc\":[\"GZ-Line (Global Z-Line)\"],\"GDs0lz\":[\"<0>Risk: Sensitive information (messages, private conversations, authentication details) could be exposed to network administrators or attackers positioned between IRC servers.\"],\"GR+2I3\":[\"Add invitation mask (e.g., nick!*@*, *!*@host.com)\"],\"GRLyMU\":[\"Close popped out server notices\"],\"GlHnXw\":[\"Nick change failed: \",[\"error\"],\" \",[\"0\"]],\"GswZF3\":[\"Preview:\"],\"GtmO8/\":[\"from\"],\"GtuHUQ\":[\"Rename this channel on the server. All users will see the new name.\"],\"GuGfFX\":[\"Toggle search\"],\"GxkJXS\":[\"Uploading...\"],\"GzbwnK\":[\"Joined the channel\"],\"GzsUDB\":[\"Extended Profile\"],\"H/PnT8\":[\"Insert emoji\"],\"H6Izzl\":[\"Your preferred color code\"],\"H9jIv+\":[\"Show Joins/Parts\"],\"HAKBY9\":[\"Upload Files\"],\"HdE1If\":[\"Channel\"],\"Hk4AW9\":[\"Your preferred display name\"],\"HmHDk7\":[\"Select Member\"],\"HrQzPU\":[\"Channels on \",[\"networkName\"]],\"I2tXQ5\":[\"Message @\",[\"0\"],\" (Enter for new line, Shift+Enter to send)\"],\"I6bw/h\":[\"Ban User\"],\"I92Z+b\":[\"Enable notifications\"],\"I9D72S\":[\"Are you sure you want to delete this message? This action cannot be undone.\"],\"IA+1wo\":[\"Display when users are kicked from channels\"],\"IDwkJx\":[\"IRC Operator\"],\"ILlU+s\":[\"Info:\"],\"IUwGEM\":[\"Save Changes\"],\"IVeGK6\":[[\"0\"],\", \",[\"1\"],\" and \",[\"2\"],\" are typing...\"],\"IgrLD/\":[\"Pause\"],\"Im6JED\":[\"WHISPER\"],\"ImOQa9\":[\"Reply\"],\"IoHMnl\":[\"Maximum value is \",[\"0\"]],\"IvMj+0\":[\"Op\"],\"J28zul\":[\"Connecting...\"],\"J5T9NW\":[\"User Information\"],\"J8Y5+z\":[\"Oops! The net split! ⚠️\"],\"JBHkBA\":[\"Left the channel\"],\"JCwL0Q\":[\"Enter reason (optional)\"],\"JFciKP\":[\"Toggle\"],\"JXGkhG\":[\"Change the channel name (operators only)\"],\"JcD7qf\":[\"More actions\"],\"JdkA+c\":[\"Secret (+s)\"],\"Jmu12l\":[\"Server Channels\"],\"JvQ++s\":[\"Enable Markdown\"],\"K2jwh/\":[\"No WHOIS data available\"],\"KAXSwC\":[\"Voice\"],\"KDfTdX\":[\"Delete message\"],\"KKBlUU\":[\"Embed\"],\"KM0pLb\":[\"Welcome to the channel!\"],\"KR6W2h\":[\"Unignore User\"],\"KV+Bi1\":[\"Invite-Only (+i)\"],\"KdCtwE\":[\"How many seconds to monitor for flood activity before resetting counters\"],\"Kkezga\":[\"Server Password\"],\"KsiQ/8\":[\"Users must be invited to join the channel\"],\"L+gB/D\":[\"Channel Information\"],\"LC1a7n\":[\"The IRC server has reported that its server-to-server links have a low security level. This means that when your messages are relayed between IRC servers in the network, they may not be properly encrypted or the SSL/TLS certificates may not be validated correctly.\"],\"LNfLR5\":[\"Show Kicks\"],\"LQb0W/\":[\"Show All Events\"],\"LU7/yA\":[\"Alternative name for display in the UI. May contain spaces, emoji, and special characters. The real channel name (\",[\"channelName\"],\") will still be used for IRC commands.\"],\"LUb9O7\":[\"Valid server port is required\"],\"Lb+BUl\":[\"https://example.com/avatar.jpg\"],\"LcET2C\":[\"Privacy Policy\"],\"LcuSDR\":[\"Manage your profile information and metadata\"],\"LqLS9B\":[\"Show Nick Changes\"],\"LsDQt2\":[\"Channel Settings\"],\"LtI9AS\":[\"Owner\"],\"LuNhhL\":[\"reacted to this message\"],\"M/AZNG\":[\"URL to your avatar image\"],\"M/WIer\":[\"Send Message\"],\"M8er/5\":[\"Name:\"],\"MHk+7g\":[\"Previous image\"],\"MRorGe\":[\"PM User\"],\"MVbSGP\":[\"Time Window (seconds)\"],\"MkpcsT\":[\"Your messages and settings are stored locally on your device\"],\"N/hDSy\":[\"Mark as bot - usually 'on' or empty\"],\"N7TQbE\":[\"Invite User to \",[\"channelName\"]],\"NCca/o\":[\"Enter default nickname...\"],\"Nqs6B9\":[\"Shows all external media. Any URL may cause a request to an unknown server.\"],\"Nt+9O7\":[\"Use WebSocket instead of raw TCP\"],\"NxIHzc\":[\"Kill User\"],\"O+v/cL\":[\"Browse all channels on the server\"],\"ODwSCk\":[\"Send a GIF\"],\"OGQ5kK\":[\"Configure notification sounds and highlights\"],\"OIPt1Z\":[\"Show or hide the member list sidebar\"],\"OKSNq/\":[\"Very Strict\"],\"ONWvwQ\":[\"Upload\"],\"OVKoQO\":[\"Your account password for authentication\"],\"ObsidianIRC - Bringing IRC to the future\":[\"ObsidianIRC - Bringing IRC to the future\"],\"OhCpra\":[\"Set a topic…\"],\"OkltoQ\":[\"Ban \",[\"username\"],\" by nickname (prevents them from rejoining with the same nick)\"],\"P+t/Te\":[\"No additional data\"],\"P42Wcc\":[\"Safe\"],\"PD38l0\":[\"Channel avatar preview\"],\"PD9mEt\":[\"Type a message...\"],\"PPqfdA\":[\"Open channel configuration settings\"],\"PSCjfZ\":[\"The topic that will be displayed for this channel. All users can see the topic.\"],\"PZCecv\":[\"PDF preview\"],\"PeLgsC\":[[\"c\",\"plural\",{\"one\":[\"1 time\"],\"other\":[[\"c\"],\" times\"]}]],\"PguS2C\":[\"Add exception mask (e.g., nick!*@*, *!*@host.com)\"],\"Pil5Ty\":[\"Showing \",[\"displayedChannelsCount\"],\" of \",[\"0\"],\" channels\"],\"PqhVlJ\":[\"Ban User (by Hostmask)\"],\"Q+chwU\":[\"Username:\"],\"Q6hhn8\":[\"Preferences\"],\"QF4a34\":[\"Please enter a username\"],\"QGqSZ2\":[\"Color & Formatting\"],\"QJQd1J\":[\"Edit Profile\"],\"QSzGDE\":[\"Idle\"],\"QUlny5\":[\"Welcome to \",[\"0\"],\"!\"],\"Qoq+GP\":[\"Read more\"],\"QuSkCF\":[\"Filter channels...\"],\"QwUrDZ\":[\"changed the topic to: \",[\"topic\"]],\"R0UH07\":[\"Image \",[\"0\"],\" of \",[\"1\"]],\"R7SsBE\":[\"Mute\"],\"R8rf1X\":[\"Click to set topic\"],\"RArB3D\":[\"was kicked from \",[\"channelName\"],\" by \",[\"username\"]],\"RI3cWd\":[\"Discover the world of IRC with ObsidianIRC\"],\"RMMaN5\":[\"Moderated (+m)\"],\"RWw9Lg\":[\"Close modal\"],\"RZ2BuZ\":[\"Account registration for \",[\"account\"],\" requires verification: \",[\"message\"]],\"RySp6q\":[\"Hide comments\"],\"SPKQTd\":[\"Nickname is required\"],\"SPVjfj\":[\"Will default to 'no reason' if left empty\"],\"SQKPvQ\":[\"Invite User\"],\"SkZcl+\":[\"Choose a predefined flood protection profile. These profiles provide balanced protection settings for different use cases.\"],\"Slr+3C\":[\"Min Users\"],\"Spnlre\":[\"You invited \",[\"target\"],\" to join \",[\"channel\"]],\"T/ckN5\":[\"Open in viewer\"],\"T91vKp\":[\"Play\"],\"TV2Wdu\":[\"Learn how we handle your data and protect your privacy.\"],\"TgFpwD\":[\"Applying...\"],\"TkzSFB\":[\"No Changes\"],\"TtserG\":[\"Enter real name\"],\"Ttz9J1\":[\"Enter password...\"],\"Tz0i8g\":[\"Settings\"],\"U3pytU\":[\"Admin\"],\"UDb2YD\":[\"React\"],\"UE4KO5\":[\"*channel*\"],\"UGT5vp\":[\"Save Settings\"],\"UV5hLB\":[\"No bans found\"],\"Uaj3Nd\":[\"Status Messages\"],\"Ue3uny\":[\"Default (no profile)\"],\"UkARhe\":[\"Normal - Standard protection\"],\"Umn7Cj\":[\"No comments yet. Be the first!\"],\"UtUIRh\":[[\"0\"],\" older messages\"],\"UwzP+U\":[\"Secure Connection\"],\"V0/A4O\":[\"Channel Owner\"],\"V4qgxE\":[\"Created Before (min ago)\"],\"V8yTm6\":[\"Clear search\"],\"VJMMyz\":[\"ObsidianIRC - Bringing IRC to the future\"],\"VJScHU\":[\"Reason\"],\"VLsmVV\":[\"Mute notifications\"],\"VbyRUy\":[\"Comments\"],\"Vmx0mQ\":[\"Set by:\"],\"VqnIZz\":[\"View our privacy policy and data practices\"],\"VrMygG\":[\"Minimum length is \",[\"0\"]],\"VrnTui\":[\"Your pronouns, shown in your profile\"],\"W8E3qn\":[\"Authenticated Account\"],\"WAakm9\":[\"Delete Channel\"],\"WFxTHC\":[\"Add ban mask (e.g., nick!*@*, *!*@host.com)\"],\"WN1g9F\":[\"Server host is required\"],\"WRYdXW\":[\"Audio position\"],\"WUOH5B\":[\"Ignore User\"],\"WWEXnZ\":[[\"0\",\"plural\",{\"one\":[\"Show 1 more item\"],\"other\":[\"Show \",[\"1\"],\" more items\"]}]],\"Weq9zb\":[\"General\"],\"Wfj7Sk\":[\"Mute or unmute notification sounds\"],\"Wm7gbG\":[\"GitHub:\"],\"WyeHWY\":[\"*spam*\"],\"WzMCru\":[\"User Profile\"],\"X6S3lt\":[\"Search settings, channels, servers...\"],\"XEHan5\":[\"Continue Anyway\"],\"XI1+wb\":[\"Invalid format\"],\"XIXeuC\":[\"Message @\",[\"0\"]],\"XMS+k4\":[\"Start Private Message\"],\"XWgxXq\":[\"Album\"],\"Xd7+IT\":[\"Unpin Private Chat\"],\"Xm/s+u\":[\"Display\"],\"Xp2n93\":[\"Shows media from your server's trusted file host. No requests are made to external services.\"],\"XvjC4F\":[\"Saving...\"],\"Y/qryO\":[\"No users found matching your search\"],\"YAqRpI\":[\"Account registration successful for \",[\"account\"],\": \",[\"message\"]],\"YEfzvP\":[\"Protected Topic (+t)\"],\"YQOn6a\":[\"Collapse member list\"],\"YRCoE9\":[\"Channel Operator\"],\"YURQaF\":[\"View Profile\"],\"YdBSvr\":[\"Control media display and external content\"],\"Yj6U3V\":[\"No Central Server:\"],\"YjvpGx\":[\"Pronouns\"],\"YqH4l4\":[\"No key\"],\"YyUPpV\":[\"Account:\"],\"ZJSWfw\":[\"Message shown when you disconnect from the server\"],\"ZR1dJ4\":[\"Invitations\"],\"ZdWg0V\":[\"Open in browser\"],\"ZhRBbl\":[\"Search messages…\"],\"Zmcu3y\":[\"Advanced Filters\"],\"a2/8e5\":[\"Topic Set After (min ago)\"],\"aHKcKc\":[\"Previous page\"],\"aJTbXX\":[\"Oper Password\"],\"aQryQv\":[\"Pattern already exists\"],\"aW9pLN\":[\"Maximum number of users allowed in the channel. Leave empty for no limit.\"],\"ah4fmZ\":[\"Also shows previews from YouTube, Vimeo, SoundCloud, and similar known services.\"],\"aifXak\":[\"No media in this channel\"],\"ap2zBz\":[\"Relaxed\"],\"az8lvo\":[\"Off\"],\"azXSNo\":[\"Expand member list\"],\"azdliB\":[\"Login to an account\"],\"b26wlF\":[\"she/her\"],\"bD/+Ei\":[\"Strict\"],\"bQ6BJn\":[\"Configure detailed flood protection rules. Each rule specifies what type of activity to monitor and what action to take when thresholds are exceeded.\"],\"beV7+y\":[\"The user will receive an invitation to join \",[\"channelName\"],\".\"],\"bk84cH\":[\"Away Message\"],\"bkHdLj\":[\"Add IRC Server\"],\"bmQLn5\":[\"Add Rule\"],\"bwRvnp\":[\"Action\"],\"c8+EVZ\":[\"Verified account\"],\"cGYUlD\":[\"No media previews are loaded.\"],\"cLF98o\":[\"Show comments (\",[\"commentCount\"],\")\"],\"cLKIDO\":[\"No users available\"],\"cSgpoS\":[\"Pin Private Chat\"],\"cde3ce\":[\"Message <0>\",[\"0\"],\"\"],\"chQsxg\":[\"Copy formatted output\"],\"cl/A5J\":[\"Welcome to \",[\"__DEFAULT_IRC_SERVER_NAME__\"],\"!\"],\"cnGeoo\":[\"Delete\"],\"coPLXT\":[\"We don't store your IRC communications on our servers\"],\"crYH/6\":[\"SoundCloud player\"],\"d3sis4\":[\"Add Server\"],\"d9aN5k\":[\"Remove \",[\"username\"],\" from the channel\"],\"dEgA5A\":[\"Cancel\"],\"dGi1We\":[\"Unpin this private message conversation\"],\"dJVuyC\":[\"left \",[\"channelName\"],\" (\",[\"reason\"],\")\"],\"dMtLDE\":[\"to\"],\"dXqxlh\":[\"<0>⚠️ Security Risk! This connection may be vulnerable to interception or man-in-the-middle attacks.\"],\"da9Q/R\":[\"Changed channel modes\"],\"dhJN3N\":[\"Show comments\"],\"dj2xTE\":[\"Dismiss notification\"],\"dpCzmC\":[\"Flood Protection Settings\"],\"e9dQpT\":[\"Do you want to open this link in a new tab?\"],\"ePK91l\":[\"Edit\"],\"eYBDuB\":[\"Upload an image or provide a URL with optional \",[\"size\"],\" substitution for dynamic sizing\"],\"edBbee\":[\"Ban \",[\"username\"],\" by hostmask (prevents them from rejoining from the same IP/host)\"],\"ekfzWq\":[\"User Settings\"],\"elPDWs\":[\"Customize your IRC client experience\"],\"eu2osY\":[\"<0>💡 Recommendation: Only proceed if you trust this server and understand the risks. Avoid sharing sensitive information or passwords over this connection.\"],\"euEhbr\":[\"Click to join \",[\"channel\"]],\"ez3vLd\":[\"Enable Multiline Input\"],\"f0J5Ki\":[\"Server-to-server communication may use unencrypted connections\"],\"f9BHJk\":[\"Warn User\"],\"fDOLLd\":[\"No channels found.\"],\"ffzDkB\":[\"Anonymous Analytics:\"],\"fq1GF9\":[\"Display when users disconnect from server\"],\"gEF57C\":[\"This server only supports one connection type\"],\"gJuLUI\":[\"Ignore List\"],\"gNzMrk\":[\"Current avatar\"],\"gjPWyO\":[\"Enter nickname...\"],\"gz6UQ3\":[\"Maximize\"],\"h6razj\":[\"Exclude Channel Name Mask\"],\"hG6jnw\":[\"No topic set\"],\"hG89Ed\":[\"Image\"],\"hZ6znB\":[\"Port\"],\"ha+Bz5\":[\"e.g., 100:1440\"],\"hehnjM\":[\"Amount\"],\"hzdLuQ\":[\"Only users with voice or higher can speak\"],\"i0qMbr\":[\"Home\"],\"iDNBZe\":[\"Notifications\"],\"iH8pgl\":[\"Back\"],\"iL9SZg\":[\"Ban User (by Nickname)\"],\"iNt+3c\":[\"Back to image\"],\"iQvi+a\":[\"Don't warn me about low link security for this server\"],\"iSLIjg\":[\"Connect\"],\"iWXkHH\":[\"Halfop\"],\"iZeTtp\":[\"Server Host\"],\"idD8Ev\":[\"Saved\"],\"iivqkW\":[\"Signed On\"],\"ij+Elv\":[\"Image preview\"],\"ilIWp7\":[\"Toggle Notifications\"],\"iuaqvB\":[\"Use * for wildcards. Examples: baduser!*@*, *!*@spammer.com, troll*!*@*\"],\"ixkTse\":[\"Bot\"],\"j2DGR0\":[\"Ban by Hostmask\"],\"jA4uoI\":[\"Topic:\"],\"jLXxGK\":[\"https://example.com\"],\"jPSk57\":[\"Reason (optional)\"],\"jUV7CU\":[\"Upload Avatar\"],\"jW5Uwh\":[\"Control how much external media is loaded. Off / Safe / Trusted Sources / All Content.\"],\"jXzms5\":[\"Attachment options\"],\"jZlrte\":[\"Color\"],\"jfC/xh\":[\"Contact\"],\"jywMpv\":[\"#new-channel-name\"],\"k112DD\":[\"Load older messages\"],\"k3ID0F\":[\"Filter members…\"],\"k65gsE\":[\"Deep dive\"],\"k7Zgob\":[\"Cancel Connection\"],\"kAVx5h\":[\"No invitations found\"],\"kCLEPU\":[\"Connected To\"],\"kF5LKb\":[\"Ignored patterns:\"],\"kGeOx/\":[\"Join \",[\"0\"]],\"kITKr8\":[\"Loading channel modes...\"],\"kPpPsw\":[\"You are an IRC Operator\"],\"kWJmRL\":[\"You\"],\"kfcRb0\":[\"Avatar\"],\"kjMqSj\":[\"Copy JSON\"],\"krViRy\":[\"Click to copy as JSON\"],\"ks71ra\":[\"Exceptions\"],\"kw4lRv\":[\"Channel Half Operator\"],\"kxgIRq\":[\"Select or add a channel to get started.\"],\"ky6dWe\":[\"Avatar preview\"],\"l+GxCv\":[\"Loading channels...\"],\"l+IUVW\":[\"Account verification successful for \",[\"account\"],\": \",[\"message\"]],\"l/siQz\":[[\"reconnectCount\",\"plural\",{\"one\":[\"reconnected\"],\"other\":[\"reconnected \",[\"reconnectCount\"],\" times\"]}]],\"l5jmzx\":[[\"0\"],\" and \",[\"1\"],\" are typing...\"],\"lHy8N5\":[\"Loading more channels...\"],\"lbpf14\":[\"Join \",[\"value\"]],\"lfFsZ4\":[\"Channels\"],\"lkNdiH\":[\"Account Name\"],\"ln500L\":[\"ObsidianIRC\"],\"lnCMdg\":[\"Upload Image\"],\"loQxaJ\":[\"I'm Back\"],\"lvfaxv\":[\"HOME\"],\"m16xKo\":[\"Add\"],\"m8flAk\":[\"Preview (not yet uploaded)\"],\"mEPxTp\":[\"<0>⚠️ Be careful! Only open links from trusted sources. Malicious links can compromise your security or privacy.\"],\"mHGdhG\":[\"Server Information\"],\"mHS8lb\":[\"Message #\",[\"0\"]],\"mMYBD9\":[\"Wide - Broader protection scope\"],\"mTGsPd\":[\"Channel Topic\"],\"mU8j6O\":[\"No External Messages (+n)\"],\"mZp8FL\":[\"Auto Fallback to Single Line\"],\"mdQu8G\":[\"YourNickname\"],\"miSSBQ\":[\"Comments (\",[\"commentCount\"],\")\"],\"mvyLSy\":[\"User is authenticated\"],\"mwtcGl\":[\"Close comments\"],\"mzI/c+\":[\"Download\"],\"n3fGRk\":[\"set by \",[\"0\"]],\"nE9jsU\":[\"Relaxed - Less aggressive protection\"],\"nNflMD\":[\"Leave channel\"],\"nPXkBi\":[\"Loading WHOIS data...\"],\"nQnxxF\":[\"Message #\",[\"0\"],\" (Shift+Enter for new line)\"],\"nWMRxa\":[\"Unpin\"],\"nkC032\":[\"No flood profile\"],\"o69z4d\":[\"Send a warning message to \",[\"username\"]],\"o9ylQi\":[\"Search for GIFs to get started\"],\"oFGkER\":[\"Server Notices\"],\"oOi11l\":[\"Scroll to bottom\"],\"oQEzQR\":[\"New DM\"],\"oXOSPE\":[\"Online\"],\"oal760\":[\"Man-in-the-middle attacks on server links are possible\"],\"oeqmmJ\":[\"Trusted Sources\"],\"ovBPCi\":[\"Default\"],\"p0Z69r\":[\"Pattern cannot be empty\"],\"p1KgtK\":[\"Failed to load audio\"],\"p59pEv\":[\"Additional Details\"],\"p7sRI6\":[\"Let others know when you are typing\"],\"pBm1od\":[\"Secret channel\"],\"pNmiXx\":[\"Your default nickname for all servers\"],\"pUUo9G\":[\"Hostname:\"],\"pVGPmz\":[\"Account Password\"],\"peNE68\":[\"Permanent\"],\"plhHQt\":[\"No data\"],\"pm6+q5\":[\"Security Warning\"],\"pn5qSs\":[\"Additional Information\"],\"q0cR4S\":[\"are now known as **\",[\"newNick\"],\"**\"],\"qFcunY\":[\"Channel won't appear in LIST or NAMES commands\"],\"qLpTm/\":[\"Remove reaction \",[\"emoji\"]],\"qVkGWK\":[\"Pin\"],\"qY8wNa\":[\"Homepage\"],\"qb0xJ7\":[\"Use wildcards: * matches any sequence, ? matches any single character. Examples: nick!*@*, *!*@host.com, *!*user@*\"],\"qhzpRq\":[\"Channel Key (+k)\"],\"qtoOYG\":[\"No limit\"],\"r1W2AS\":[\"Filehost image\"],\"rIPR2O\":[\"Topic Set Before (min ago)\"],\"rMMSYo\":[\"Maximum length is \",[\"0\"]],\"rWtzQe\":[\"The network split and rejoined. ✅\"],\"rYG2u6\":[\"Please wait...\"],\"rdUucN\":[\"Preview\"],\"rjGI/Q\":[\"Privacy\"],\"rk8iDX\":[\"Loading GIFs...\"],\"rn6SBY\":[\"Unmute\"],\"s/UKqq\":[\"Was kicked from the channel\"],\"s8cATI\":[\"joined \",[\"channelName\"]],\"sCO9ue\":[\"The connection to <0>\",[\"serverName\"],\" has the following security concerns:\"],\"sGH11W\":[\"Server\"],\"sHI1H+\":[\"is now known as **\",[\"newNick\"],\"**\"],\"sJyV04\":[[\"inviter\"],\" has invited you to join \",[\"channel\"]],\"sby+1/\":[\"Click to copy\"],\"sfN25C\":[\"Your real or full name\"],\"sliuzR\":[\"Open Link\"],\"sqrO9R\":[\"Custom Mentions\"],\"sr6RdJ\":[\"Multiline on Shift+Enter\"],\"swrCpB\":[\"Channel has been renamed from \",[\"oldName\"],\" to \",[\"newName\"],\" by \",[\"user\"],[\"0\"]],\"sxkWRg\":[\"Advanced\"],\"t/YqKh\":[\"Remove\"],\"t47eHD\":[\"Your unique identifier on this server\"],\"tAkAh0\":[\"URL with optional \",[\"size\"],\" substitution for dynamic sizing. Example: https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"tXLJS3\":[\"Show or hide the channel list sidebar\"],\"tfDRzk\":[\"Save\"],\"tiBsJk\":[\"left \",[\"channelName\"]],\"tt4/UD\":[\"quit (\",[\"reason\"],\")\"],\"u0TcnO\":[\"Nickname {nick} already in use, retrying with {newNick}\"],\"u0a8B4\":[\"Authenticate as an IRC Operator for administrative access\"],\"u0rWFU\":[\"Created After (min ago)\"],\"u72w3t\":[\"Users and patterns to ignore\"],\"u7jc2L\":[\"quit\"],\"uAQUqI\":[\"Status\"],\"uB85T3\":[\"Save failed: \",[\"msg\"]],\"uV3DOL\":[\"G-Line\"],\"uW3lLI\":[\"IRC Servers:\"],\"usSSr/\":[\"Zoom level\"],\"v7uvcf\":[\"Software:\"],\"vE8kb+\":[\"Use Shift+Enter for new lines (Enter sends)\"],\"vERlcd\":[\"Profile\"],\"vK0RL8\":[\"No topic\"],\"vSJd18\":[\"Video\"],\"vXIe7J\":[\"Language\"],\"vaHYxN\":[\"Real Name\"],\"vhjbKr\":[\"Away\"],\"w4NYox\":[[\"title\"],\" Client\"],\"w8xQRx\":[\"Invalid value\"],\"wFjjxZ\":[\"was kicked from \",[\"channelName\"],\" by \",[\"username\"],\" (\",[\"reason\"],\")\"],\"wGjaGl\":[\"No ban exceptions found\"],\"wPrGnM\":[\"Channel Admin\"],\"wRkP2d\":[\"GIF\"],\"wbm86v\":[\"Display when users join or leave channels\"],\"whqZ9r\":[\"Additional words or phrases to highlight\"],\"wm7RV4\":[\"Notification Sound\"],\"wz/Yoq\":[\"Your messages could be intercepted when relayed between servers\"],\"xCJdfg\":[\"Clear\"],\"xUHRTR\":[\"Automatically authenticate as operator on connect\"],\"xWHwwQ\":[\"Bans\"],\"xYilR2\":[\"Media\"],\"xceQrO\":[\"Only secure websockets are supported\"],\"xdtXa+\":[\"channel-name\"],\"xfXC7q\":[\"Text Channels\"],\"xlCYOE\":[\"Getting more messages...\"],\"xlhswE\":[\"Minimum value is \",[\"0\"]],\"xq97Ci\":[\"Add a word or phrase...\"],\"xuRqRq\":[\"Client Limit (+l)\"],\"xwF+7J\":[[\"0\"],\" is typing...\"],\"yNeucF\":[\"This server does not support extended profile metadata (IRCv3 METADATA extension). Additional fields like avatar, display name, and status are not available.\"],\"yPlrca\":[\"Channel Avatar\"],\"yQE2r9\":[\"Loading\"],\"ySU+JY\":[\"your@email.com\"],\"yTX1Rt\":[\"Oper Username\"],\"yYOzWD\":[\"logs\"],\"yfx9Re\":[\"IRC operator password\"],\"ygCKqB\":[\"Stop\"],\"ymDxJx\":[\"IRC operator username\"],\"yrpRsQ\":[\"Sort by Name\"],\"yz7wBu\":[\"Close\"],\"zJw+jA\":[\"sets mode: \",[\"0\"]],\"zebeLu\":[\"Enter oper username\"],\"zpr0Bw\":[\"GZ-Line\"]}"); \ No newline at end of file +/*eslint-disable*/export const messages=JSON.parse("{\"+5VMrz\":[\"Invalid pattern format. Use nick!user@host format (wildcards * allowed)\"],\"+6NQQA\":[\"General Support Channel\"],\"+6NyRG\":[\"Client\"],\"+K0AvT\":[\"Disconnect\"],\"+cyFdH\":[\"Default message when marking yourself as away\"],\"+mVPqU\":[\"Render markdown formatting in messages\"],\"+vqCJH\":[\"Your account username for authentication\"],\"+yPBXI\":[\"Choose file\"],\"+zy2Nq\":[\"Type\"],\"/09cao\":[\"Low Link Security (Level \",[\"securityLevel\"],\")\"],\"/3BQ4J\":[\"Users outside the channel cannot send messages to it\"],\"/6BzZF\":[\"Toggle Member List\"],\"/TNOPk\":[\"User is away\"],\"/XQgft\":[\"Discover\"],\"/cF7Rs\":[\"Volume\"],\"/dqduX\":[\"Next page\"],\"/fc3q4\":[\"All Content\"],\"/kISDh\":[\"Enable Notification Sounds\"],\"/n04sB\":[\"Kill\"],\"/rTz0M\":[\"Audio\"],\"/rfkZe\":[\"Play sounds for mentions and messages\"],\"0/0ZGA\":[\"Channel Name Mask\"],\"0D6j7U\":[\"Learn more about custom rules →\"],\"0XsHcR\":[\"Kick User\"],\"0ZpE//\":[\"Sort by Users\"],\"0bEPwz\":[\"Set Away\"],\"0dGkPt\":[\"Expand channel list\"],\"0gS7M5\":[\"Display Name\"],\"0kS+M8\":[\"ExampleNET\"],\"0rgoY7\":[\"Only connect to servers you choose\"],\"0wdd7X\":[\"Join\"],\"0wkVYx\":[\"Private Messages\"],\"111uHX\":[\"Link preview\"],\"196EG4\":[\"Delete Private Chat\"],\"1DSr1i\":[\"Register for an account\"],\"1O/24y\":[\"Toggle Channel List\"],\"1TNIig\":[\"Open\"],\"1VPJJ2\":[\"External Link Warning\"],\"1ZC/dv\":[\"No unread mentions or messages\"],\"1pO1zi\":[\"Server name is required\"],\"1uwfzQ\":[\"View Channel Topic\"],\"268g7c\":[\"Enter display name\"],\"2FOFq1\":[\"Server operators on the network could potentially read your messages\"],\"2FYpfJ\":[\"More\"],\"2HF1Y2\":[[\"inviter\"],\" has invited \",[\"target\"],\" to join \",[\"channel\"]],\"2I70QL\":[\"View user profile information\"],\"2QYdmE\":[\"Users:\"],\"2QpEjG\":[\"left\"],\"2YE223\":[\"Message #\",[\"0\"],\" (Enter for new line, Shift+Enter to send)\"],\"2bimFY\":[\"Use server password\"],\"2iTmdZ\":[\"Local Storage:\"],\"2odkwe\":[\"Strict - More aggressive protection\"],\"2uDhbA\":[\"Enter username to invite\"],\"2ygf/L\":[\"← Back\"],\"2zEgxj\":[\"Search GIFs...\"],\"3RdPhl\":[\"Rename Channel\"],\"3THokf\":[\"Voiced User\"],\"3TSz9S\":[\"Minimize\"],\"3jBDvM\":[\"Channel Display Name\"],\"3ryuFU\":[\"Optional crash reports to improve the app\"],\"3uBF/8\":[\"Close viewer\"],\"3uwW8F\":[\"https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"3xf8Kz\":[\"Enter account name...\"],\"4/Rr0R\":[\"Invite a user to the current channel\"],\"4EZrJN\":[\"Rules\"],\"4JJtW9\":[\"#overflow\"],\"4NqeT4\":[\"Flood Profile (+F)\"],\"4RZQRK\":[\"What are you up to?\"],\"4hfTrB\":[\"Nickname\"],\"4n99LO\":[\"Already in \",[\"0\"]],\"4t6vMV\":[\"Automatically switch to single line for short messages\"],\"4vsHmf\":[\"Time (min)\"],\"4x/Axu\":[\"Your bouncer doesn't have any networks yet. Add one to get started.\"],\"5+INAX\":[\"Highlight messages that mention you\"],\"5R5Pv/\":[\"Oper Name\"],\"678PKt\":[\"Network Name\"],\"6Aih4U\":[\"Offline\"],\"6CO3WE\":[\"Password required to join the channel. Leave empty to remove the key.\"],\"6HhMs3\":[\"Quit Message\"],\"6V3Ea3\":[\"Copied\"],\"6lGV3K\":[\"Show less\"],\"6yFOEi\":[\"Enter oper password...\"],\"7+IHTZ\":[\"No file chosen\"],\"73fnil\":[\"TLS\"],\"73hrRi\":[\"nick!user@host (e.g., spam*!*@*, *!*@badhost.com)\"],\"7QkKyN\":[\"Send private message\"],\"7U1W7c\":[\"Very Relaxed\"],\"7Y1YQj\":[\"Realname:\"],\"7YHArF\":[\"— open in viewer\"],\"7fjnVl\":[\"Search users...\"],\"7jL88x\":[\"Delete this message? This cannot be undone.\"],\"7nGhhM\":[\"What's on your mind?\"],\"7sEpu1\":[\"Members — \",[\"0\"]],\"7sNhEz\":[\"Username\"],\"8H0Q+x\":[\"Learn more about profiles →\"],\"8Phu0A\":[\"Display when users change their nickname\"],\"8XTG9e\":[\"Enter oper password\"],\"8XsV2J\":[\"Retry sending\"],\"8ZsakT\":[\"Password\"],\"8kR84m\":[\"You are about to open an external link:\"],\"8lCgih\":[\"Remove Rule\"],\"8p/xVT\":[[\"0\",\"plural\",{\"one\":[[\"1\"]],\"other\":[[\"2\"]]}]],\"8wRzac\":[[\"joinCount\",\"plural\",{\"one\":[\"joined\"],\"other\":[\"joined \",[\"joinCount\"],\" times\"]}]],\"9BMLnJ\":[\"Reconnect to server\"],\"9OEgyT\":[\"Add reaction\"],\"9PQ8m2\":[\"G-Line (Global Ban)\"],\"9Qs99X\":[\"Email:\"],\"9QupBP\":[\"Remove pattern\"],\"9W7tl5\":[\"(unchanged)\"],\"9bG48P\":[\"Sending\"],\"9f5f0u\":[\"Questions about privacy? Contact us:\"],\"9iweoP\":[\"Networks on \",[\"0\"]],\"9unqs3\":[\"Away:\"],\"9v3hwv\":[\"No servers found.\"],\"9zb2WA\":[\"Connecting\"],\"A1taO8\":[\"Search\"],\"A2adVi\":[\"Send Typing Notifications\"],\"A9Rhec\":[\"Channel Name\"],\"AWOSPo\":[\"Zoom in\"],\"AXSpEQ\":[\"Oper on Connect\"],\"AeXO77\":[\"Account\"],\"AhNP40\":[\"Seek\"],\"Ai2U7L\":[\"Host\"],\"AjBQnf\":[\"Changed nickname\"],\"AmXVh6\":[\"https://example.com/avatar.png\"],\"AnRu/j\":[\"Cancel reply\"],\"ApSx0O\":[\"Found \",[\"0\"],\" messages matching \\\"\",[\"searchQuery\"],\"\\\"\"],\"AxPAXW\":[\"No results found\"],\"AyNqAB\":[\"Display all server events in chat\"],\"B/QqGw\":[\"Away from keyboard\"],\"B0sB2k\":[\"Plaintext\"],\"B8AaMI\":[\"This field is required\"],\"BA2c49\":[\"Server doesn't support advanced LIST filtering\"],\"BDKt3I\":[[\"0\"],\", \",[\"1\"],\", \",[\"2\"],\" and \",[\"3\"],\" others are typing...\"],\"BGul2A\":[\"You have unsaved changes. Are you sure you want to close without saving?\"],\"BIf9fi\":[\"Your status message\"],\"BZz3md\":[\"Your personal website\"],\"Bgm/H7\":[\"Allow entering multiple lines of text\"],\"BiQIl1\":[\"Pin this private message conversation\"],\"BlNZZ2\":[\"Click to jump to message\"],\"Bowq3c\":[\"Only operators can change the channel topic\"],\"Btozzp\":[\"This image has expired\"],\"Bycfjm\":[\"Total: \",[\"0\"]],\"C6IBQc\":[\"Copy entire JSON\"],\"C9L9wL\":[\"Data Collection\"],\"CDq4wC\":[\"Moderate User\"],\"CHVRxG\":[\"Message @\",[\"0\"],\" (Shift+Enter for new line)\"],\"CN9zdR\":[\"Oper name and password are required\"],\"CW3sYa\":[\"Add reaction \",[\"emoji\"]],\"CaAkqd\":[\"Show Quits\"],\"CbvaYj\":[\"Ban by Nickname\"],\"CcK+Ft\":[\"PDF\"],\"Ce8q3L\":[\"Select a channel\"],\"CsekCi\":[\"Normal\"],\"D+NlUC\":[\"System\"],\"D28t6+\":[\"joined and quit\"],\"DB8zMK\":[\"Apply\"],\"DBcWHr\":[\"Custom notification sound file\"],\"DTy9Xw\":[\"Media Previews\"],\"Dj4pSr\":[\"Choose a secure password\"],\"Du+zn+\":[\"Searching...\"],\"Du2T2f\":[\"Setting not found\"],\"DwsSVQ\":[\"Apply Filters & Refresh\"],\"E3W/zd\":[\"Default Nickname\"],\"E6nRW7\":[\"Copy URL\"],\"E703RG\":[\"Modes:\"],\"EAeu1Z\":[\"Send Invite\"],\"EFKJQT\":[\"Setting\"],\"EGPQBv\":[\"Custom Flood Rules (+f)\"],\"ELik0r\":[\"View Full Privacy Policy\"],\"EPbeC2\":[\"View or edit the channel topic\"],\"EQCDNT\":[\"Enter oper username...\"],\"EUvulZ\":[\"Found 1 message matching \\\"\",[\"searchQuery\"],\"\\\"\"],\"EatZYJ\":[\"Next image\"],\"EdQY6l\":[\"None\"],\"EnqLYU\":[\"Search servers...\"],\"F0OKMc\":[\"Edit Server\"],\"F6Int2\":[\"Enable Highlights\"],\"FDoLyE\":[\"Max Users\"],\"FUU/hZ\":[\"Control how much external media is loaded in chat.\"],\"Fdp03t\":[\"on\"],\"FfPWR0\":[\"Modal\"],\"FjkaiT\":[\"Zoom out\"],\"FlqOE9\":[\"What this means:\"],\"FolHNl\":[\"Manage your account and authentication\"],\"Fp2Dif\":[\"Quit the server\"],\"G5KmCc\":[\"GZ-Line (Global Z-Line)\"],\"GDs0lz\":[\"<0>Risk: Sensitive information (messages, private conversations, authentication details) could be exposed to network administrators or attackers positioned between IRC servers.\"],\"GR+2I3\":[\"Add invitation mask (e.g., nick!*@*, *!*@host.com)\"],\"GRLyMU\":[\"Close popped out server notices\"],\"GlHnXw\":[\"Nick change failed: \",[\"error\"],\" \",[\"0\"]],\"GswZF3\":[\"Preview:\"],\"GtmO8/\":[\"from\"],\"GtuHUQ\":[\"Rename this channel on the server. All users will see the new name.\"],\"GuGfFX\":[\"Toggle search\"],\"GxkJXS\":[\"Uploading...\"],\"GzbwnK\":[\"Joined the channel\"],\"GzsUDB\":[\"Extended Profile\"],\"H/PnT8\":[\"Insert emoji\"],\"H6Izzl\":[\"Your preferred color code\"],\"H9jIv+\":[\"Show Joins/Parts\"],\"HAKBY9\":[\"Upload Files\"],\"HdE1If\":[\"Channel\"],\"Hk4AW9\":[\"Your preferred display name\"],\"HmHDk7\":[\"Select Member\"],\"HrQzPU\":[\"Channels on \",[\"networkName\"]],\"I2tXQ5\":[\"Message @\",[\"0\"],\" (Enter for new line, Shift+Enter to send)\"],\"I6bw/h\":[\"Ban User\"],\"I92Z+b\":[\"Enable notifications\"],\"I9D72S\":[\"Are you sure you want to delete this message? This action cannot be undone.\"],\"IA+1wo\":[\"Display when users are kicked from channels\"],\"IDwkJx\":[\"IRC Operator\"],\"ILlU+s\":[\"Info:\"],\"IUwGEM\":[\"Save Changes\"],\"IVeGK6\":[[\"0\"],\", \",[\"1\"],\" and \",[\"2\"],\" are typing...\"],\"IgrLD/\":[\"Pause\"],\"Im6JED\":[\"WHISPER\"],\"ImOQa9\":[\"Reply\"],\"IoHMnl\":[\"Maximum value is \",[\"0\"]],\"IvMj+0\":[\"Op\"],\"J28zul\":[\"Connecting...\"],\"J5T9NW\":[\"User Information\"],\"J8Y5+z\":[\"Oops! The net split! ⚠️\"],\"JBHkBA\":[\"Left the channel\"],\"JCwL0Q\":[\"Enter reason (optional)\"],\"JFciKP\":[\"Toggle\"],\"JXGkhG\":[\"Change the channel name (operators only)\"],\"JcD7qf\":[\"More actions\"],\"JdkA+c\":[\"Secret (+s)\"],\"Jmu12l\":[\"Server Channels\"],\"JvQ++s\":[\"Enable Markdown\"],\"K2jwh/\":[\"No WHOIS data available\"],\"KAXSwC\":[\"Voice\"],\"KDfTdX\":[\"Delete message\"],\"KKBlUU\":[\"Embed\"],\"KM0pLb\":[\"Welcome to the channel!\"],\"KR6W2h\":[\"Unignore User\"],\"KV+Bi1\":[\"Invite-Only (+i)\"],\"KdCtwE\":[\"How many seconds to monitor for flood activity before resetting counters\"],\"Kkezga\":[\"Server Password\"],\"KsiQ/8\":[\"Users must be invited to join the channel\"],\"L+gB/D\":[\"Channel Information\"],\"LC1a7n\":[\"The IRC server has reported that its server-to-server links have a low security level. This means that when your messages are relayed between IRC servers in the network, they may not be properly encrypted or the SSL/TLS certificates may not be validated correctly.\"],\"LNfLR5\":[\"Show Kicks\"],\"LP+1Z7\":[\"Add Network\"],\"LQb0W/\":[\"Show All Events\"],\"LU7/yA\":[\"Alternative name for display in the UI. May contain spaces, emoji, and special characters. The real channel name (\",[\"channelName\"],\") will still be used for IRC commands.\"],\"LUb9O7\":[\"Valid server port is required\"],\"Lb+BUl\":[\"https://example.com/avatar.jpg\"],\"LcET2C\":[\"Privacy Policy\"],\"LcuSDR\":[\"Manage your profile information and metadata\"],\"LqLS9B\":[\"Show Nick Changes\"],\"LsDQt2\":[\"Channel Settings\"],\"LtI9AS\":[\"Owner\"],\"LuNhhL\":[\"reacted to this message\"],\"M/AZNG\":[\"URL to your avatar image\"],\"M/WIer\":[\"Send Message\"],\"M8er/5\":[\"Name:\"],\"MHk+7g\":[\"Previous image\"],\"MRorGe\":[\"PM User\"],\"MVbSGP\":[\"Time Window (seconds)\"],\"MkpcsT\":[\"Your messages and settings are stored locally on your device\"],\"MzPdC2\":[\"Server Password (PASS)\"],\"N/hDSy\":[\"Mark as bot - usually 'on' or empty\"],\"N6j2JH\":[\"Edit \",[\"0\"]],\"N7TQbE\":[\"Invite User to \",[\"channelName\"]],\"NCca/o\":[\"Enter default nickname...\"],\"Nqs6B9\":[\"Shows all external media. Any URL may cause a request to an unknown server.\"],\"Nt+9O7\":[\"Use WebSocket instead of raw TCP\"],\"NxIHzc\":[\"Kill User\"],\"O+v/cL\":[\"Browse all channels on the server\"],\"OCGpR4\":[\"(inherit)\"],\"ODwSCk\":[\"Send a GIF\"],\"OGQ5kK\":[\"Configure notification sounds and highlights\"],\"OIPt1Z\":[\"Show or hide the member list sidebar\"],\"OKSNq/\":[\"Very Strict\"],\"ONWvwQ\":[\"Upload\"],\"OVKoQO\":[\"Your account password for authentication\"],\"ObsidianIRC - Bringing IRC to the future\":[\"ObsidianIRC - Bringing IRC to the future\"],\"OhCpra\":[\"Set a topic…\"],\"OkltoQ\":[\"Ban \",[\"username\"],\" by nickname (prevents them from rejoining with the same nick)\"],\"P+t/Te\":[\"No additional data\"],\"P42Wcc\":[\"Safe\"],\"PD38l0\":[\"Channel avatar preview\"],\"PD9mEt\":[\"Type a message...\"],\"PPqfdA\":[\"Open channel configuration settings\"],\"PSCjfZ\":[\"The topic that will be displayed for this channel. All users can see the topic.\"],\"PZCecv\":[\"PDF preview\"],\"PeLgsC\":[[\"c\",\"plural\",{\"one\":[\"1 time\"],\"other\":[[\"c\"],\" times\"]}]],\"PguS2C\":[\"Add exception mask (e.g., nick!*@*, *!*@host.com)\"],\"Pil5Ty\":[\"Showing \",[\"displayedChannelsCount\"],\" of \",[\"0\"],\" channels\"],\"PqhVlJ\":[\"Ban User (by Hostmask)\"],\"Q+chwU\":[\"Username:\"],\"Q3v9Wc\":[\"Yes, delete\"],\"Q6hhn8\":[\"Preferences\"],\"QF4a34\":[\"Please enter a username\"],\"QGqSZ2\":[\"Color & Formatting\"],\"QJQd1J\":[\"Edit Profile\"],\"QSzGDE\":[\"Idle\"],\"QUlny5\":[\"Welcome to \",[\"0\"],\"!\"],\"Qoq+GP\":[\"Read more\"],\"QuSkCF\":[\"Filter channels...\"],\"QwUrDZ\":[\"changed the topic to: \",[\"topic\"]],\"R0UH07\":[\"Image \",[\"0\"],\" of \",[\"1\"]],\"R7SsBE\":[\"Mute\"],\"R8rf1X\":[\"Click to set topic\"],\"RArB3D\":[\"was kicked from \",[\"channelName\"],\" by \",[\"username\"]],\"RI3cWd\":[\"Discover the world of IRC with ObsidianIRC\"],\"RMMaN5\":[\"Moderated (+m)\"],\"RWw9Lg\":[\"Close modal\"],\"RZ2BuZ\":[\"Account registration for \",[\"account\"],\" requires verification: \",[\"message\"]],\"RySp6q\":[\"Hide comments\"],\"S5Togi\":[\"Loading networks from your bouncer…\"],\"SPKQTd\":[\"Nickname is required\"],\"SPVjfj\":[\"Will default to 'no reason' if left empty\"],\"SQKPvQ\":[\"Invite User\"],\"STmlpb\":[\"Back to network list\"],\"SkZcl+\":[\"Choose a predefined flood protection profile. These profiles provide balanced protection settings for different use cases.\"],\"Slr+3C\":[\"Min Users\"],\"Spnlre\":[\"You invited \",[\"target\"],\" to join \",[\"channel\"]],\"T/ckN5\":[\"Open in viewer\"],\"T91vKp\":[\"Play\"],\"TV2Wdu\":[\"Learn how we handle your data and protect your privacy.\"],\"TgFpwD\":[\"Applying...\"],\"TkzSFB\":[\"No Changes\"],\"TtserG\":[\"Enter real name\"],\"Ttz9J1\":[\"Enter password...\"],\"Tz0i8g\":[\"Settings\"],\"U3pytU\":[\"Admin\"],\"UDb2YD\":[\"React\"],\"UE4KO5\":[\"*channel*\"],\"UGT5vp\":[\"Save Settings\"],\"UV5hLB\":[\"No bans found\"],\"Uaj3Nd\":[\"Status Messages\"],\"Ue3uny\":[\"Default (no profile)\"],\"UkARhe\":[\"Normal - Standard protection\"],\"Umn7Cj\":[\"No comments yet. Be the first!\"],\"UtUIRh\":[[\"0\"],\" older messages\"],\"UwzP+U\":[\"Secure Connection\"],\"V0/A4O\":[\"Channel Owner\"],\"V4qgxE\":[\"Created Before (min ago)\"],\"V8yTm6\":[\"Clear search\"],\"VJMMyz\":[\"ObsidianIRC - Bringing IRC to the future\"],\"VJScHU\":[\"Reason\"],\"VLsmVV\":[\"Mute notifications\"],\"VbyRUy\":[\"Comments\"],\"Vmx0mQ\":[\"Set by:\"],\"VqnIZz\":[\"View our privacy policy and data practices\"],\"VrMygG\":[\"Minimum length is \",[\"0\"]],\"VrnTui\":[\"Your pronouns, shown in your profile\"],\"W8E3qn\":[\"Authenticated Account\"],\"WAakm9\":[\"Delete Channel\"],\"WFxTHC\":[\"Add ban mask (e.g., nick!*@*, *!*@host.com)\"],\"WN1g9F\":[\"Server host is required\"],\"WRYdXW\":[\"Audio position\"],\"WUOH5B\":[\"Ignore User\"],\"WWEXnZ\":[[\"0\",\"plural\",{\"one\":[\"Show 1 more item\"],\"other\":[\"Show \",[\"1\"],\" more items\"]}]],\"Weq9zb\":[\"General\"],\"Wfj7Sk\":[\"Mute or unmute notification sounds\"],\"Wm7gbG\":[\"GitHub:\"],\"WyeHWY\":[\"*spam*\"],\"WzMCru\":[\"User Profile\"],\"X6S3lt\":[\"Search settings, channels, servers...\"],\"XEHan5\":[\"Continue Anyway\"],\"XI1+wb\":[\"Invalid format\"],\"XIXeuC\":[\"Message @\",[\"0\"]],\"XMS+k4\":[\"Start Private Message\"],\"XWgxXq\":[\"Album\"],\"Xd7+IT\":[\"Unpin Private Chat\"],\"Xm/s+u\":[\"Display\"],\"Xp2n93\":[\"Shows media from your server's trusted file host. No requests are made to external services.\"],\"XvjC4F\":[\"Saving...\"],\"Y/qryO\":[\"No users found matching your search\"],\"YAqRpI\":[\"Account registration successful for \",[\"account\"],\": \",[\"message\"]],\"YEfzvP\":[\"Protected Topic (+t)\"],\"YQOn6a\":[\"Collapse member list\"],\"YRCoE9\":[\"Channel Operator\"],\"YURQaF\":[\"View Profile\"],\"YdBSvr\":[\"Control media display and external content\"],\"Yj6U3V\":[\"No Central Server:\"],\"YjvpGx\":[\"Pronouns\"],\"YqH4l4\":[\"No key\"],\"YyUPpV\":[\"Account:\"],\"ZJSWfw\":[\"Message shown when you disconnect from the server\"],\"ZR1dJ4\":[\"Invitations\"],\"ZdWg0V\":[\"Open in browser\"],\"ZhRBbl\":[\"Search messages…\"],\"Zmcu3y\":[\"Advanced Filters\"],\"a2/8e5\":[\"Topic Set After (min ago)\"],\"aHKcKc\":[\"Previous page\"],\"aJTbXX\":[\"Oper Password\"],\"aQryQv\":[\"Pattern already exists\"],\"aW9pLN\":[\"Maximum number of users allowed in the channel. Leave empty for no limit.\"],\"ah4fmZ\":[\"Also shows previews from YouTube, Vimeo, SoundCloud, and similar known services.\"],\"aifXak\":[\"No media in this channel\"],\"ap2zBz\":[\"Relaxed\"],\"az8lvo\":[\"Off\"],\"azXSNo\":[\"Expand member list\"],\"azdliB\":[\"Login to an account\"],\"b26wlF\":[\"she/her\"],\"bD/+Ei\":[\"Strict\"],\"bQ6BJn\":[\"Configure detailed flood protection rules. Each rule specifies what type of activity to monitor and what action to take when thresholds are exceeded.\"],\"beV7+y\":[\"The user will receive an invitation to join \",[\"channelName\"],\".\"],\"bk84cH\":[\"Away Message\"],\"bkHdLj\":[\"Add IRC Server\"],\"bmQLn5\":[\"Add Rule\"],\"bv4cFj\":[\"Transport\"],\"bwRvnp\":[\"Action\"],\"c8+EVZ\":[\"Verified account\"],\"cGYUlD\":[\"No media previews are loaded.\"],\"cLF98o\":[\"Show comments (\",[\"commentCount\"],\")\"],\"cLKIDO\":[\"No users available\"],\"cSgpoS\":[\"Pin Private Chat\"],\"cde3ce\":[\"Message <0>\",[\"0\"],\"\"],\"chQsxg\":[\"Copy formatted output\"],\"cl/A5J\":[\"Welcome to \",[\"__DEFAULT_IRC_SERVER_NAME__\"],\"!\"],\"cnGeoo\":[\"Delete\"],\"coPLXT\":[\"We don't store your IRC communications on our servers\"],\"crYH/6\":[\"SoundCloud player\"],\"cv5DQb\":[\"no host set\"],\"d3sis4\":[\"Add Server\"],\"d9aN5k\":[\"Remove \",[\"username\"],\" from the channel\"],\"dEgA5A\":[\"Cancel\"],\"dGi1We\":[\"Unpin this private message conversation\"],\"dJVuyC\":[\"left \",[\"channelName\"],\" (\",[\"reason\"],\")\"],\"dMtLDE\":[\"to\"],\"dXqxlh\":[\"<0>⚠️ Security Risk! This connection may be vulnerable to interception or man-in-the-middle attacks.\"],\"da9Q/R\":[\"Changed channel modes\"],\"dhJN3N\":[\"Show comments\"],\"dj2xTE\":[\"Dismiss notification\"],\"dpCzmC\":[\"Flood Protection Settings\"],\"e9dQpT\":[\"Do you want to open this link in a new tab?\"],\"ePK91l\":[\"Edit\"],\"eYBDuB\":[\"Upload an image or provide a URL with optional \",[\"size\"],\" substitution for dynamic sizing\"],\"edBbee\":[\"Ban \",[\"username\"],\" by hostmask (prevents them from rejoining from the same IP/host)\"],\"ekfzWq\":[\"User Settings\"],\"elPDWs\":[\"Customize your IRC client experience\"],\"eu2osY\":[\"<0>💡 Recommendation: Only proceed if you trust this server and understand the risks. Avoid sharing sensitive information or passwords over this connection.\"],\"euEhbr\":[\"Click to join \",[\"channel\"]],\"ez3vLd\":[\"Enable Multiline Input\"],\"f0J5Ki\":[\"Server-to-server communication may use unencrypted connections\"],\"f9BHJk\":[\"Warn User\"],\"fDOLLd\":[\"No channels found.\"],\"ffzDkB\":[\"Anonymous Analytics:\"],\"fq1GF9\":[\"Display when users disconnect from server\"],\"gEF57C\":[\"This server only supports one connection type\"],\"gJuLUI\":[\"Ignore List\"],\"gNzMrk\":[\"Current avatar\"],\"gjPWyO\":[\"Enter nickname...\"],\"gz6UQ3\":[\"Maximize\"],\"h6/IMX\":[\"Add your first network\"],\"h6razj\":[\"Exclude Channel Name Mask\"],\"hG6jnw\":[\"No topic set\"],\"hG89Ed\":[\"Image\"],\"hZ6znB\":[\"Port\"],\"ha+Bz5\":[\"e.g., 100:1440\"],\"hehnjM\":[\"Amount\"],\"hzdLuQ\":[\"Only users with voice or higher can speak\"],\"i0qMbr\":[\"Home\"],\"iDNBZe\":[\"Notifications\"],\"iH8pgl\":[\"Back\"],\"iL9SZg\":[\"Ban User (by Nickname)\"],\"iNt+3c\":[\"Back to image\"],\"iQvi+a\":[\"Don't warn me about low link security for this server\"],\"iSLIjg\":[\"Connect\"],\"iWXkHH\":[\"Halfop\"],\"iZeTtp\":[\"Server Host\"],\"idD8Ev\":[\"Saved\"],\"iivqkW\":[\"Signed On\"],\"ij+Elv\":[\"Image preview\"],\"ilIWp7\":[\"Toggle Notifications\"],\"iuaqvB\":[\"Use * for wildcards. Examples: baduser!*@*, *!*@spammer.com, troll*!*@*\"],\"ixkTse\":[\"Bot\"],\"j2DGR0\":[\"Ban by Hostmask\"],\"jA4uoI\":[\"Topic:\"],\"jLXxGK\":[\"https://example.com\"],\"jPSk57\":[\"Reason (optional)\"],\"jUV7CU\":[\"Upload Avatar\"],\"jW5Uwh\":[\"Control how much external media is loaded. Off / Safe / Trusted Sources / All Content.\"],\"jXzms5\":[\"Attachment options\"],\"jZlrte\":[\"Color\"],\"jfC/xh\":[\"Contact\"],\"jywMpv\":[\"#new-channel-name\"],\"k112DD\":[\"Load older messages\"],\"k3ID0F\":[\"Filter members…\"],\"k65gsE\":[\"Deep dive\"],\"k7Zgob\":[\"Cancel Connection\"],\"kAVx5h\":[\"No invitations found\"],\"kCLEPU\":[\"Connected To\"],\"kF5LKb\":[\"Ignored patterns:\"],\"kGeOx/\":[\"Join \",[\"0\"]],\"kITKr8\":[\"Loading channel modes...\"],\"kPpPsw\":[\"You are an IRC Operator\"],\"kWJmRL\":[\"You\"],\"kfcRb0\":[\"Avatar\"],\"kjMqSj\":[\"Copy JSON\"],\"krViRy\":[\"Click to copy as JSON\"],\"ks71ra\":[\"Exceptions\"],\"kw4lRv\":[\"Channel Half Operator\"],\"kxgIRq\":[\"Select or add a channel to get started.\"],\"ky6dWe\":[\"Avatar preview\"],\"l+GxCv\":[\"Loading channels...\"],\"l+IUVW\":[\"Account verification successful for \",[\"account\"],\": \",[\"message\"]],\"l/siQz\":[[\"reconnectCount\",\"plural\",{\"one\":[\"reconnected\"],\"other\":[\"reconnected \",[\"reconnectCount\"],\" times\"]}]],\"l5jmzx\":[[\"0\"],\" and \",[\"1\"],\" are typing...\"],\"lHy8N5\":[\"Loading more channels...\"],\"lbpf14\":[\"Join \",[\"value\"]],\"lfFsZ4\":[\"Channels\"],\"lkNdiH\":[\"Account Name\"],\"ln500L\":[\"ObsidianIRC\"],\"lnCMdg\":[\"Upload Image\"],\"loQxaJ\":[\"I'm Back\"],\"lvfaxv\":[\"HOME\"],\"m0oxpP\":[\"Libera Chat\"],\"m16xKo\":[\"Add\"],\"m8flAk\":[\"Preview (not yet uploaded)\"],\"mEPxTp\":[\"<0>⚠️ Be careful! Only open links from trusted sources. Malicious links can compromise your security or privacy.\"],\"mHGdhG\":[\"Server Information\"],\"mHS8lb\":[\"Message #\",[\"0\"]],\"mMYBD9\":[\"Wide - Broader protection scope\"],\"mTGsPd\":[\"Channel Topic\"],\"mU8j6O\":[\"No External Messages (+n)\"],\"mZp8FL\":[\"Auto Fallback to Single Line\"],\"mdQu8G\":[\"YourNickname\"],\"miSSBQ\":[\"Comments (\",[\"commentCount\"],\")\"],\"mvyLSy\":[\"User is authenticated\"],\"mwtcGl\":[\"Close comments\"],\"myL0MR\":[\"Delete this network?\"],\"mzI/c+\":[\"Download\"],\"n3fGRk\":[\"set by \",[\"0\"]],\"nE9jsU\":[\"Relaxed - Less aggressive protection\"],\"nNflMD\":[\"Leave channel\"],\"nPXkBi\":[\"Loading WHOIS data...\"],\"nQnxxF\":[\"Message #\",[\"0\"],\" (Shift+Enter for new line)\"],\"nWMRxa\":[\"Unpin\"],\"nkC032\":[\"No flood profile\"],\"o69z4d\":[\"Send a warning message to \",[\"username\"]],\"o9ylQi\":[\"Search for GIFs to get started\"],\"oFGkER\":[\"Server Notices\"],\"oOi11l\":[\"Scroll to bottom\"],\"oQEzQR\":[\"New DM\"],\"oXOSPE\":[\"Online\"],\"oal760\":[\"Man-in-the-middle attacks on server links are possible\"],\"oeqmmJ\":[\"Trusted Sources\"],\"ovBPCi\":[\"Default\"],\"p0Z69r\":[\"Pattern cannot be empty\"],\"p1KgtK\":[\"Failed to load audio\"],\"p59pEv\":[\"Additional Details\"],\"p7sRI6\":[\"Let others know when you are typing\"],\"pBm1od\":[\"Secret channel\"],\"pNmiXx\":[\"Your default nickname for all servers\"],\"pUUo9G\":[\"Hostname:\"],\"pVGPmz\":[\"Account Password\"],\"peNE68\":[\"Permanent\"],\"plhHQt\":[\"No data\"],\"pm6+q5\":[\"Security Warning\"],\"pn5qSs\":[\"Additional Information\"],\"q0cR4S\":[\"are now known as **\",[\"newNick\"],\"**\"],\"qFcunY\":[\"Channel won't appear in LIST or NAMES commands\"],\"qLpTm/\":[\"Remove reaction \",[\"emoji\"]],\"qVkGWK\":[\"Pin\"],\"qY8wNa\":[\"Homepage\"],\"qb0xJ7\":[\"Use wildcards: * matches any sequence, ? matches any single character. Examples: nick!*@*, *!*@host.com, *!*user@*\"],\"qhzpRq\":[\"Channel Key (+k)\"],\"qtoOYG\":[\"No limit\"],\"r1W2AS\":[\"Filehost image\"],\"rIPR2O\":[\"Topic Set Before (min ago)\"],\"rMMSYo\":[\"Maximum length is \",[\"0\"]],\"rWtzQe\":[\"The network split and rejoined. ✅\"],\"rYG2u6\":[\"Please wait...\"],\"rdUucN\":[\"Preview\"],\"rjGI/Q\":[\"Privacy\"],\"rk8iDX\":[\"Loading GIFs...\"],\"rn6SBY\":[\"Unmute\"],\"s/UKqq\":[\"Was kicked from the channel\"],\"s8cATI\":[\"joined \",[\"channelName\"]],\"sCO9ue\":[\"The connection to <0>\",[\"serverName\"],\" has the following security concerns:\"],\"sGH11W\":[\"Server\"],\"sHI1H+\":[\"is now known as **\",[\"newNick\"],\"**\"],\"sJyV04\":[[\"inviter\"],\" has invited you to join \",[\"channel\"]],\"sUBSbK\":[\"No upstream networks yet.\"],\"sby+1/\":[\"Click to copy\"],\"sfN25C\":[\"Your real or full name\"],\"sliuzR\":[\"Open Link\"],\"sqrO9R\":[\"Custom Mentions\"],\"sr6RdJ\":[\"Multiline on Shift+Enter\"],\"swrCpB\":[\"Channel has been renamed from \",[\"oldName\"],\" to \",[\"newName\"],\" by \",[\"user\"],[\"0\"]],\"sxkWRg\":[\"Advanced\"],\"t/YqKh\":[\"Remove\"],\"t47eHD\":[\"Your unique identifier on this server\"],\"tAkAh0\":[\"URL with optional \",[\"size\"],\" substitution for dynamic sizing. Example: https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"tXLJS3\":[\"Show or hide the channel list sidebar\"],\"tfDRzk\":[\"Save\"],\"tiBsJk\":[\"left \",[\"channelName\"]],\"tt4/UD\":[\"quit (\",[\"reason\"],\")\"],\"u0TcnO\":[\"Nickname {nick} already in use, retrying with {newNick}\"],\"u0a8B4\":[\"Authenticate as an IRC Operator for administrative access\"],\"u0rWFU\":[\"Created After (min ago)\"],\"u72w3t\":[\"Users and patterns to ignore\"],\"u7jc2L\":[\"quit\"],\"uAQUqI\":[\"Status\"],\"uB85T3\":[\"Save failed: \",[\"msg\"]],\"uV3DOL\":[\"G-Line\"],\"uW3lLI\":[\"IRC Servers:\"],\"usSSr/\":[\"Zoom level\"],\"v7uvcf\":[\"Software:\"],\"vE8kb+\":[\"Use Shift+Enter for new lines (Enter sends)\"],\"vERlcd\":[\"Profile\"],\"vK0RL8\":[\"No topic\"],\"vSJd18\":[\"Video\"],\"vXIe7J\":[\"Language\"],\"vaHYxN\":[\"Real Name\"],\"vhjbKr\":[\"Away\"],\"w/nogd\":[[\"0\"],\" network\",[\"1\"],\" — pick one to join\"],\"w4NYox\":[[\"title\"],\" Client\"],\"w8xQRx\":[\"Invalid value\"],\"wFjjxZ\":[\"was kicked from \",[\"channelName\"],\" by \",[\"username\"],\" (\",[\"reason\"],\")\"],\"wGjaGl\":[\"No ban exceptions found\"],\"wPrGnM\":[\"Channel Admin\"],\"wRkP2d\":[\"GIF\"],\"wbm86v\":[\"Display when users join or leave channels\"],\"whqZ9r\":[\"Additional words or phrases to highlight\"],\"wm7RV4\":[\"Notification Sound\"],\"wz/Yoq\":[\"Your messages could be intercepted when relayed between servers\"],\"xCJdfg\":[\"Clear\"],\"xUHRTR\":[\"Automatically authenticate as operator on connect\"],\"xWHwwQ\":[\"Bans\"],\"xYilR2\":[\"Media\"],\"xceQrO\":[\"Only secure websockets are supported\"],\"xdtXa+\":[\"channel-name\"],\"xfXC7q\":[\"Text Channels\"],\"xlCYOE\":[\"Getting more messages...\"],\"xlhswE\":[\"Minimum value is \",[\"0\"]],\"xq97Ci\":[\"Add a word or phrase...\"],\"xuRqRq\":[\"Client Limit (+l)\"],\"xwF+7J\":[[\"0\"],\" is typing...\"],\"yJztBY\":[\"Delete network\"],\"yNeucF\":[\"This server does not support extended profile metadata (IRCv3 METADATA extension). Additional fields like avatar, display name, and status are not available.\"],\"yPlrca\":[\"Channel Avatar\"],\"yQE2r9\":[\"Loading\"],\"ySU+JY\":[\"your@email.com\"],\"yTX1Rt\":[\"Oper Username\"],\"yYOzWD\":[\"logs\"],\"yfx9Re\":[\"IRC operator password\"],\"ygCKqB\":[\"Stop\"],\"ymDxJx\":[\"IRC operator username\"],\"yrpRsQ\":[\"Sort by Name\"],\"yz7wBu\":[\"Close\"],\"zJw+jA\":[\"sets mode: \",[\"0\"]],\"zebeLu\":[\"Enter oper username\"],\"zpr0Bw\":[\"GZ-Line\"]}"); \ No newline at end of file diff --git a/src/locales/en/messages.po b/src/locales/en/messages.po index 586d1705..fda159be 100644 --- a/src/locales/en/messages.po +++ b/src/locales/en/messages.po @@ -21,6 +21,16 @@ msgstr "ObsidianIRC - Bringing IRC to the future" msgid "— open in viewer" msgstr "— open in viewer" +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx +msgid "(inherit)" +msgstr "(inherit)" + +#: src/components/ui/BouncerNetworkForm.tsx +msgid "(unchanged)" +msgstr "(unchanged)" + #. placeholder {0}: filteredMessages.length #. placeholder {1}: import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import type * as React from "react"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { SCROLL_TOLERANCE, useScrollToBottom, } from "../../hooks/useScrollToBottom"; import { groupConsecutiveEvents } from "../../lib/eventGrouping"; import ircClient from "../../lib/ircClient"; import useStore from "../../store"; import type { Message as MessageType } from "../../types"; import { CollapsedEventMessage } from "../message/CollapsedEventMessage"; import { MessageItem } from "../message/MessageItem"; import LoadingSpinner from "../ui/LoadingSpinner"; import { ScrollToBottomButton } from "../ui/ScrollToBottomButton"; export const DEFAULT_VISIBLE_MESSAGE_COUNT = 100; // Stable empty array — prevents selector from returning a new [] on every render // when the channel has no messages yet (undefined ?? [] would create a new ref each time). const EMPTY_MESSAGES: import("../../types").Message[] = []; export interface ChannelMessageListHandle { setAtBottom: () => void; scrollToBottom: () => void; getScrollState: () => { scrollTop: number; isAtBottom: boolean; visibleCount: number; }; } interface ChannelMessageListProps { channelKey: string; serverId: string; channelId: string | null; privateChatId: string | null; isActive: boolean; searchQuery: string; isMemberListVisible: boolean; onReply: (msg: MessageType | null) => void; onUsernameContextMenu: ( e: React.MouseEvent, username: string, serverId: string, channelId: string, avatarEl?: Element | null, ) => void; onIrcLinkClick: (url: string) => void; onReactClick: (msg: MessageType, el: Element) => void; onReactionUnreact: (emoji: string, msg: MessageType) => void; onOpenReactionModal: ( msg: MessageType, position: { x: number; y: number }, ) => void; onDirectReaction: (emoji: string, msg: MessageType) => void; onRedactMessage: (msg: MessageType) => void; onOpenProfile: (username: string) => void; joinChannel: (serverId: string, channelName: string) => void; onClearSearch: () => void; highlightedMessageId?: string; // undefined = first visit; null = was at bottom; object = restore to saved position initialScrollState?: { scrollTop: number; visibleCount: number } | null; } export const ChannelMessageList = forwardRef< ChannelMessageListHandle, ChannelMessageListProps >( ( { channelKey, serverId, channelId, privateChatId, isActive, searchQuery, isMemberListVisible, onReply, onUsernameContextMenu, onIrcLinkClick, onReactClick, onReactionUnreact, onOpenReactionModal, onDirectReaction, onRedactMessage, onOpenProfile, joinChannel, onClearSearch, highlightedMessageId, initialScrollState, }, ref, ) => { const { t } = useLingui(); const [visibleMessageCount, setVisibleMessageCount] = useState( initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT, ); // Ref mirror so getScrollState closure always reads the current value without needing it as a dep. const visibleMessageCountRef = useRef(visibleMessageCount); visibleMessageCountRef.current = visibleMessageCount; // Distinguishes initial join (full-screen spinner) from subsequent "load more" (button spinner). const [isFetchingMore, setIsFetchingMore] = useState(false); const isFetchingMoreRef = useRef(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const messagesInnerRef = useRef(null); // prev scrollHeight for prepend delta-correction. const prevScrollHeightRef = useRef(0); // Ref mirror of isScrolledUp — lets useLayoutEffect closures read current value // without listing isScrolledUp as a dep (which would re-run effects on every scroll). const isScrolledUpRef = useRef(false); const prevFilteredLengthRef = useRef(0); const prevFirstMsgIdRef = useRef(null); // Set by the window-growth layoutEffect (or button handler) when a true prepend is detected. // Consumed by the delta-correction layoutEffect one render later (after visibleCount grows). // Using a flag instead of tracking displayedMessages[0]?.id because slice(-N) slides the // window on every bottom append, changing displayedMessages[0] even for non-prepend renders. const pendingPrependRef = useRef(false); // Shared scrollHeight baseline between the delta-correction layout effect and the inner // ResizeObserver. When scrollTop is corrected after a prepend, we update this so the RO's // "was at bottom" check is not fooled by the adjusted scrollTop vs its stale prevSH. const resizeObserverPrevSHRef = useRef(0); const channelMessages = useStore( useCallback( (state) => state.messages[channelKey] ?? EMPTY_MESSAGES, [channelKey], ), ); const servers = useStore((state) => state.servers); const mobileViewActiveColumn = useStore( (state) => state.ui.mobileViewActiveColumn, ); const channel = useMemo( () => channelId ? (servers .find((s) => s.id === serverId) ?.channels.find((c) => c.id === channelId) ?? null) : null, [servers, serverId, channelId], ); const { isScrolledUp, wasAtBottomRef, scrollToBottom } = useScrollToBottom( messagesContainerRef, messagesEndRef, { channelId: `${channelId || privateChatId}-${isMemberListVisible}` }, ); // Snapshot of the last known scroll position captured while the container was visible. // getScrollState() reads this instead of the live DOM because React commits display:none // before running cleanup effects, collapsing scrollTop/scrollHeight/clientHeight to 0. const lastScrollTopRef = useRef(initialScrollState?.scrollTop ?? 0); useEffect(() => { const container = messagesContainerRef.current; if (!container) return; const onScroll = () => { if (container.clientHeight > 0) lastScrollTopRef.current = container.scrollTop; }; container.addEventListener("scroll", onScroll, { passive: true }); return () => container.removeEventListener("scroll", onScroll); }, []); // Restore scroll position when a keep-alive channel transitions from hidden to visible. // display:none may reset scrollTop to 0; lastScrollTopRef was captured while visible. const prevActiveRef = useRef(isActive); useLayoutEffect(() => { if (isActive && !prevActiveRef.current) { const container = messagesContainerRef.current; if (container && lastScrollTopRef.current > 0) { container.scrollTop = lastScrollTopRef.current; } } prevActiveRef.current = isActive; }, [isActive]); useImperativeHandle(ref, () => ({ setAtBottom: () => { wasAtBottomRef.current = true; }, scrollToBottom, getScrollState: () => ({ scrollTop: lastScrollTopRef.current, isAtBottom: wasAtBottomRef.current, visibleCount: visibleMessageCountRef.current, }), })); const filteredMessages = useMemo(() => { if (!searchQuery.trim()) return channelMessages; const query = searchQuery.toLowerCase(); return channelMessages.filter( (msg) => msg.content.toLowerCase().includes(query) || msg.userId.toLowerCase().includes(query), ); }, [channelMessages, searchQuery]); useEffect(() => { isScrolledUpRef.current = isScrolledUp; // When the user returns to the bottom, shrink the window back to the base so // slice(-N) resumes trimming old messages from the top (memory optimization). // Only shrink if we grew above the base — preserves a sub-default saved visibleCount. if (!isScrolledUp) { setVisibleMessageCount((prev) => prev > DEFAULT_VISIBLE_MESSAGE_COUNT ? DEFAULT_VISIBLE_MESSAGE_COUNT : prev, ); } }, [isScrolledUp]); // Reset ref-tracked windowing state when switching channels. // visibleMessageCount is NOT reset here — useState(initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT) // already initializes it correctly on mount, and this effect runs once on mount for the // same channelKey (each instance is bound to exactly one channel by the parent key={}). // biome-ignore lint/correctness/useExhaustiveDependencies: intentional full reset on channel change useEffect(() => { prevFilteredLengthRef.current = 0; prevFirstMsgIdRef.current = null; prevScrollHeightRef.current = 0; pendingPrependRef.current = false; resizeObserverPrevSHRef.current = 0; }, [channelKey]); const displayedMessages = useMemo(() => { if (searchQuery.trim()) return filteredMessages; return filteredMessages.slice(-visibleMessageCount); }, [filteredMessages, visibleMessageCount, searchQuery]); const locallyHidden = filteredMessages.length > displayedMessages.length; const serverHasMore = channel?.hasMoreHistory === true; const hasMoreMessages = locallyHidden || serverHasMore; const eventGroups = useMemo( () => groupConsecutiveEvents(displayedMessages), [displayedMessages], ); const isLoadingHistory = channel?.isLoadingHistory ?? false; // Scroll to bottom on initial mount, unless a saved position was passed in. // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount only useEffect(() => { const container = messagesContainerRef.current; if (!container) return; if (initialScrollState) { container.scrollTop = initialScrollState.scrollTop; lastScrollTopRef.current = initialScrollState.scrollTop; wasAtBottomRef.current = false; } else { container.scrollTop = container.scrollHeight; lastScrollTopRef.current = container.scrollHeight; wasAtBottomRef.current = true; } }, []); // Scroll to bottom after initial join history loads; clear fetch spinner at batch end. const wasLoadingHistoryRef = useRef(false); // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is stable via useCallback; refs and setters are stable useLayoutEffect(() => { if (wasLoadingHistoryRef.current && !isLoadingHistory) { if (isFetchingMoreRef.current) { // delta correction for scroll position is handled by useLayoutEffect([displayedMessages]) isFetchingMoreRef.current = false; setIsFetchingMore(false); } else { scrollToBottom(); wasAtBottomRef.current = true; } } wasLoadingHistoryRef.current = isLoadingHistory; }, [isLoadingHistory]); // When older messages are prepended, grow the window so they enter displayedMessages. // When new messages arrive at the bottom while the user is scrolled up, also grow the // window to keep the current top messages visible — slice(-N) otherwise slides the // window forward and hides them, incrementing the "N older messages" counter on every // incoming message. Only let the slice trim from the top when the user is at the bottom // (where auto-scroll handles keeping them current). useLayoutEffect(() => { const newLength = filteredMessages.length; const newFirstId = filteredMessages[0]?.id ?? null; const delta = newLength - prevFilteredLengthRef.current; if (prevFilteredLengthRef.current > 0 && delta > 0) { if (newFirstId !== prevFirstMsgIdRef.current) { // Messages prepended (load-more): signal delta-correction to compensate scrollTop. pendingPrependRef.current = true; setVisibleMessageCount((prev) => prev + delta); } else if (isScrolledUpRef.current) { // Messages appended at bottom while user is scrolled up reading history. // Expand the window to prevent top messages from dropping out of the slice. setVisibleMessageCount((prev) => prev + delta); } } prevFilteredLengthRef.current = newLength; prevFirstMsgIdRef.current = newFirstId; }, [filteredMessages]); // Compensate scrollTop when content is prepended above the viewport. // biome-ignore lint/correctness/useExhaustiveDependencies: runs on every displayedMessages render to capture the resulting scrollHeight; refs are stable useLayoutEffect(() => { const container = messagesContainerRef.current; if (!container) return; // Skip while container is display:none — scrollHeight collapses to 0 and would // poison prevScrollHeightRef, causing a huge spurious delta on the next visible render. if (container.clientHeight === 0) return; const prevHeight = prevScrollHeightRef.current; const newHeight = container.scrollHeight; // Only correct when a true load-more prepend happened (flag set by the window-growth // layoutEffect or button handler). Bottom appends slide the slice(-N) window which also // changes displayedMessages[0] — ID-comparison can't distinguish the two cases. const wasPrepend = pendingPrependRef.current; // Only consume the flag when scrollHeight actually changed — the server-side load-more // path goes through two renders: Render A (filteredMessages grows, visibleCount unchanged, // same displayedMessages content, same scrollHeight) then Render B (visibleCount grows, // new messages enter displayedMessages, scrollHeight grows). The flag must survive Render A // so it's still set when Render B fires the actual correction. if (wasPrepend && newHeight !== prevHeight) { pendingPrependRef.current = false; } if ( isScrolledUpRef.current && prevHeight > 0 && newHeight > prevHeight && wasPrepend ) { const delta = newHeight - prevHeight; container.scrollTop += delta; resizeObserverPrevSHRef.current = newHeight; } prevScrollHeightRef.current = newHeight; }, [displayedMessages]); // Re-stick to bottom when inner message content grows (media/audio previews loading). // Uses prevScrollHeight instead of wasAtBottomRef to avoid stale-flag race where the // ref is true while the user is actively scrolling up. // When the container width changes (member list toggle, window resize), text reflows // and scrollHeight changes; preserve proportional scroll position for scrolled-up users. // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is a stable ref useEffect(() => { const container = messagesContainerRef.current; const inner = messagesInnerRef.current; if (!inner || !container) return; resizeObserverPrevSHRef.current = container.scrollHeight; let prevClientWidth = container.clientWidth; const observer = new ResizeObserver(() => { if (container.clientHeight === 0) return; // Effect may re-initialize while container is display:none (ref=0). // Re-seed with current dimensions and skip — no reliable "was at bottom" data. if (resizeObserverPrevSHRef.current === 0) { resizeObserverPrevSHRef.current = container.scrollHeight; prevClientWidth = container.clientWidth; return; } const currentClientWidth = container.clientWidth; const widthChanged = currentClientWidth !== prevClientWidth; prevClientWidth = currentClientWidth; const prevSH = resizeObserverPrevSHRef.current; const wasAtPrevBottom = container.scrollTop + container.clientHeight >= prevSH - SCROLL_TOLERANCE; resizeObserverPrevSHRef.current = container.scrollHeight; if (wasAtPrevBottom) { scrollToBottom(); } else if (widthChanged && prevSH > 0) { const ratio = container.scrollTop / prevSH; container.scrollTop = Math.round(ratio * container.scrollHeight); } }); observer.observe(inner); return () => observer.disconnect(); }, [isLoadingHistory, channelId, privateChatId]); // Auto-scroll on new messages — skip when this channel is hidden (display:none). // biome-ignore lint/correctness/useExhaustiveDependencies: only scroll when messages change, not when isActive changes useEffect(() => { if (!isActive) return; const isNarrowView = window.matchMedia("(max-width: 768px)").matches; const isChatVisible = !isNarrowView || mobileViewActiveColumn === "chatView"; if (wasAtBottomRef.current && isChatVisible) { scrollToBottom(); } }, [displayedMessages, mobileViewActiveColumn, scrollToBottom, isActive]); return ( <>
{isLoadingHistory && !isFetchingMore ? (
) : (
{hasMoreMessages && !searchQuery && (
)} {searchQuery && (
{plural(filteredMessages.length, { one: t`Found 1 message matching "${searchQuery}"`, other: t`Found ${filteredMessages.length} messages matching "${searchQuery}"`, })}
)} {eventGroups.map((group) => { if (group.type === "eventGroup") { const firstId = group.messages[0]?.id || ""; const lastId = group.messages[group.messages.length - 1]?.id || ""; const groupKey = `group-${firstId}-${lastId}`; return ( ); } const message = group.messages[0]; const originalIndex = channelMessages.findIndex( (m) => m.id === message.id, ); const previousMessage = channelMessages[originalIndex - 1]; const showHeader = !previousMessage || previousMessage.type !== "message" || previousMessage.userId !== message.userId || new Date(message.timestamp).getTime() - new Date(previousMessage.timestamp).getTime() > 5 * 60 * 1000; return ( ); })}
)}
); }, ); ChannelMessageList.displayName = "ChannelMessageList"; // Wrap with memo so hidden keep-alive channels skip re-renders when their props // haven't changed (e.g. when messageText changes in the input — the only thing // that changes on typing is local state inside ChatArea, not the props we pass here). export const MemoChannelMessageList = memo(ChannelMessageList); #. placeholder {2}: import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import type * as React from "react"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { SCROLL_TOLERANCE, useScrollToBottom, } from "../../hooks/useScrollToBottom"; import { groupConsecutiveEvents } from "../../lib/eventGrouping"; import ircClient from "../../lib/ircClient"; import useStore from "../../store"; import type { Message as MessageType } from "../../types"; import { CollapsedEventMessage } from "../message/CollapsedEventMessage"; import { MessageItem } from "../message/MessageItem"; import LoadingSpinner from "../ui/LoadingSpinner"; import { ScrollToBottomButton } from "../ui/ScrollToBottomButton"; export const DEFAULT_VISIBLE_MESSAGE_COUNT = 100; // Stable empty array — prevents selector from returning a new [] on every render // when the channel has no messages yet (undefined ?? [] would create a new ref each time). const EMPTY_MESSAGES: import("../../types").Message[] = []; export interface ChannelMessageListHandle { setAtBottom: () => void; scrollToBottom: () => void; getScrollState: () => { scrollTop: number; isAtBottom: boolean; visibleCount: number; }; } interface ChannelMessageListProps { channelKey: string; serverId: string; channelId: string | null; privateChatId: string | null; isActive: boolean; searchQuery: string; isMemberListVisible: boolean; onReply: (msg: MessageType | null) => void; onUsernameContextMenu: ( e: React.MouseEvent, username: string, serverId: string, channelId: string, avatarEl?: Element | null, ) => void; onIrcLinkClick: (url: string) => void; onReactClick: (msg: MessageType, el: Element) => void; onReactionUnreact: (emoji: string, msg: MessageType) => void; onOpenReactionModal: ( msg: MessageType, position: { x: number; y: number }, ) => void; onDirectReaction: (emoji: string, msg: MessageType) => void; onRedactMessage: (msg: MessageType) => void; onOpenProfile: (username: string) => void; joinChannel: (serverId: string, channelName: string) => void; onClearSearch: () => void; highlightedMessageId?: string; // undefined = first visit; null = was at bottom; object = restore to saved position initialScrollState?: { scrollTop: number; visibleCount: number } | null; } export const ChannelMessageList = forwardRef< ChannelMessageListHandle, ChannelMessageListProps >( ( { channelKey, serverId, channelId, privateChatId, isActive, searchQuery, isMemberListVisible, onReply, onUsernameContextMenu, onIrcLinkClick, onReactClick, onReactionUnreact, onOpenReactionModal, onDirectReaction, onRedactMessage, onOpenProfile, joinChannel, onClearSearch, highlightedMessageId, initialScrollState, }, ref, ) => { const { t } = useLingui(); const [visibleMessageCount, setVisibleMessageCount] = useState( initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT, ); // Ref mirror so getScrollState closure always reads the current value without needing it as a dep. const visibleMessageCountRef = useRef(visibleMessageCount); visibleMessageCountRef.current = visibleMessageCount; // Distinguishes initial join (full-screen spinner) from subsequent "load more" (button spinner). const [isFetchingMore, setIsFetchingMore] = useState(false); const isFetchingMoreRef = useRef(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const messagesInnerRef = useRef(null); // prev scrollHeight for prepend delta-correction. const prevScrollHeightRef = useRef(0); // Ref mirror of isScrolledUp — lets useLayoutEffect closures read current value // without listing isScrolledUp as a dep (which would re-run effects on every scroll). const isScrolledUpRef = useRef(false); const prevFilteredLengthRef = useRef(0); const prevFirstMsgIdRef = useRef(null); // Set by the window-growth layoutEffect (or button handler) when a true prepend is detected. // Consumed by the delta-correction layoutEffect one render later (after visibleCount grows). // Using a flag instead of tracking displayedMessages[0]?.id because slice(-N) slides the // window on every bottom append, changing displayedMessages[0] even for non-prepend renders. const pendingPrependRef = useRef(false); // Shared scrollHeight baseline between the delta-correction layout effect and the inner // ResizeObserver. When scrollTop is corrected after a prepend, we update this so the RO's // "was at bottom" check is not fooled by the adjusted scrollTop vs its stale prevSH. const resizeObserverPrevSHRef = useRef(0); const channelMessages = useStore( useCallback( (state) => state.messages[channelKey] ?? EMPTY_MESSAGES, [channelKey], ), ); const servers = useStore((state) => state.servers); const mobileViewActiveColumn = useStore( (state) => state.ui.mobileViewActiveColumn, ); const channel = useMemo( () => channelId ? (servers .find((s) => s.id === serverId) ?.channels.find((c) => c.id === channelId) ?? null) : null, [servers, serverId, channelId], ); const { isScrolledUp, wasAtBottomRef, scrollToBottom } = useScrollToBottom( messagesContainerRef, messagesEndRef, { channelId: `${channelId || privateChatId}-${isMemberListVisible}` }, ); // Snapshot of the last known scroll position captured while the container was visible. // getScrollState() reads this instead of the live DOM because React commits display:none // before running cleanup effects, collapsing scrollTop/scrollHeight/clientHeight to 0. const lastScrollTopRef = useRef(initialScrollState?.scrollTop ?? 0); useEffect(() => { const container = messagesContainerRef.current; if (!container) return; const onScroll = () => { if (container.clientHeight > 0) lastScrollTopRef.current = container.scrollTop; }; container.addEventListener("scroll", onScroll, { passive: true }); return () => container.removeEventListener("scroll", onScroll); }, []); // Restore scroll position when a keep-alive channel transitions from hidden to visible. // display:none may reset scrollTop to 0; lastScrollTopRef was captured while visible. const prevActiveRef = useRef(isActive); useLayoutEffect(() => { if (isActive && !prevActiveRef.current) { const container = messagesContainerRef.current; if (container && lastScrollTopRef.current > 0) { container.scrollTop = lastScrollTopRef.current; } } prevActiveRef.current = isActive; }, [isActive]); useImperativeHandle(ref, () => ({ setAtBottom: () => { wasAtBottomRef.current = true; }, scrollToBottom, getScrollState: () => ({ scrollTop: lastScrollTopRef.current, isAtBottom: wasAtBottomRef.current, visibleCount: visibleMessageCountRef.current, }), })); const filteredMessages = useMemo(() => { if (!searchQuery.trim()) return channelMessages; const query = searchQuery.toLowerCase(); return channelMessages.filter( (msg) => msg.content.toLowerCase().includes(query) || msg.userId.toLowerCase().includes(query), ); }, [channelMessages, searchQuery]); useEffect(() => { isScrolledUpRef.current = isScrolledUp; // When the user returns to the bottom, shrink the window back to the base so // slice(-N) resumes trimming old messages from the top (memory optimization). // Only shrink if we grew above the base — preserves a sub-default saved visibleCount. if (!isScrolledUp) { setVisibleMessageCount((prev) => prev > DEFAULT_VISIBLE_MESSAGE_COUNT ? DEFAULT_VISIBLE_MESSAGE_COUNT : prev, ); } }, [isScrolledUp]); // Reset ref-tracked windowing state when switching channels. // visibleMessageCount is NOT reset here — useState(initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT) // already initializes it correctly on mount, and this effect runs once on mount for the // same channelKey (each instance is bound to exactly one channel by the parent key={}). // biome-ignore lint/correctness/useExhaustiveDependencies: intentional full reset on channel change useEffect(() => { prevFilteredLengthRef.current = 0; prevFirstMsgIdRef.current = null; prevScrollHeightRef.current = 0; pendingPrependRef.current = false; resizeObserverPrevSHRef.current = 0; }, [channelKey]); const displayedMessages = useMemo(() => { if (searchQuery.trim()) return filteredMessages; return filteredMessages.slice(-visibleMessageCount); }, [filteredMessages, visibleMessageCount, searchQuery]); const locallyHidden = filteredMessages.length > displayedMessages.length; const serverHasMore = channel?.hasMoreHistory === true; const hasMoreMessages = locallyHidden || serverHasMore; const eventGroups = useMemo( () => groupConsecutiveEvents(displayedMessages), [displayedMessages], ); const isLoadingHistory = channel?.isLoadingHistory ?? false; // Scroll to bottom on initial mount, unless a saved position was passed in. // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount only useEffect(() => { const container = messagesContainerRef.current; if (!container) return; if (initialScrollState) { container.scrollTop = initialScrollState.scrollTop; lastScrollTopRef.current = initialScrollState.scrollTop; wasAtBottomRef.current = false; } else { container.scrollTop = container.scrollHeight; lastScrollTopRef.current = container.scrollHeight; wasAtBottomRef.current = true; } }, []); // Scroll to bottom after initial join history loads; clear fetch spinner at batch end. const wasLoadingHistoryRef = useRef(false); // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is stable via useCallback; refs and setters are stable useLayoutEffect(() => { if (wasLoadingHistoryRef.current && !isLoadingHistory) { if (isFetchingMoreRef.current) { // delta correction for scroll position is handled by useLayoutEffect([displayedMessages]) isFetchingMoreRef.current = false; setIsFetchingMore(false); } else { scrollToBottom(); wasAtBottomRef.current = true; } } wasLoadingHistoryRef.current = isLoadingHistory; }, [isLoadingHistory]); // When older messages are prepended, grow the window so they enter displayedMessages. // When new messages arrive at the bottom while the user is scrolled up, also grow the // window to keep the current top messages visible — slice(-N) otherwise slides the // window forward and hides them, incrementing the "N older messages" counter on every // incoming message. Only let the slice trim from the top when the user is at the bottom // (where auto-scroll handles keeping them current). useLayoutEffect(() => { const newLength = filteredMessages.length; const newFirstId = filteredMessages[0]?.id ?? null; const delta = newLength - prevFilteredLengthRef.current; if (prevFilteredLengthRef.current > 0 && delta > 0) { if (newFirstId !== prevFirstMsgIdRef.current) { // Messages prepended (load-more): signal delta-correction to compensate scrollTop. pendingPrependRef.current = true; setVisibleMessageCount((prev) => prev + delta); } else if (isScrolledUpRef.current) { // Messages appended at bottom while user is scrolled up reading history. // Expand the window to prevent top messages from dropping out of the slice. setVisibleMessageCount((prev) => prev + delta); } } prevFilteredLengthRef.current = newLength; prevFirstMsgIdRef.current = newFirstId; }, [filteredMessages]); // Compensate scrollTop when content is prepended above the viewport. // biome-ignore lint/correctness/useExhaustiveDependencies: runs on every displayedMessages render to capture the resulting scrollHeight; refs are stable useLayoutEffect(() => { const container = messagesContainerRef.current; if (!container) return; // Skip while container is display:none — scrollHeight collapses to 0 and would // poison prevScrollHeightRef, causing a huge spurious delta on the next visible render. if (container.clientHeight === 0) return; const prevHeight = prevScrollHeightRef.current; const newHeight = container.scrollHeight; // Only correct when a true load-more prepend happened (flag set by the window-growth // layoutEffect or button handler). Bottom appends slide the slice(-N) window which also // changes displayedMessages[0] — ID-comparison can't distinguish the two cases. const wasPrepend = pendingPrependRef.current; // Only consume the flag when scrollHeight actually changed — the server-side load-more // path goes through two renders: Render A (filteredMessages grows, visibleCount unchanged, // same displayedMessages content, same scrollHeight) then Render B (visibleCount grows, // new messages enter displayedMessages, scrollHeight grows). The flag must survive Render A // so it's still set when Render B fires the actual correction. if (wasPrepend && newHeight !== prevHeight) { pendingPrependRef.current = false; } if ( isScrolledUpRef.current && prevHeight > 0 && newHeight > prevHeight && wasPrepend ) { const delta = newHeight - prevHeight; container.scrollTop += delta; resizeObserverPrevSHRef.current = newHeight; } prevScrollHeightRef.current = newHeight; }, [displayedMessages]); // Re-stick to bottom when inner message content grows (media/audio previews loading). // Uses prevScrollHeight instead of wasAtBottomRef to avoid stale-flag race where the // ref is true while the user is actively scrolling up. // When the container width changes (member list toggle, window resize), text reflows // and scrollHeight changes; preserve proportional scroll position for scrolled-up users. // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is a stable ref useEffect(() => { const container = messagesContainerRef.current; const inner = messagesInnerRef.current; if (!inner || !container) return; resizeObserverPrevSHRef.current = container.scrollHeight; let prevClientWidth = container.clientWidth; const observer = new ResizeObserver(() => { if (container.clientHeight === 0) return; // Effect may re-initialize while container is display:none (ref=0). // Re-seed with current dimensions and skip — no reliable "was at bottom" data. if (resizeObserverPrevSHRef.current === 0) { resizeObserverPrevSHRef.current = container.scrollHeight; prevClientWidth = container.clientWidth; return; } const currentClientWidth = container.clientWidth; const widthChanged = currentClientWidth !== prevClientWidth; prevClientWidth = currentClientWidth; const prevSH = resizeObserverPrevSHRef.current; const wasAtPrevBottom = container.scrollTop + container.clientHeight >= prevSH - SCROLL_TOLERANCE; resizeObserverPrevSHRef.current = container.scrollHeight; if (wasAtPrevBottom) { scrollToBottom(); } else if (widthChanged && prevSH > 0) { const ratio = container.scrollTop / prevSH; container.scrollTop = Math.round(ratio * container.scrollHeight); } }); observer.observe(inner); return () => observer.disconnect(); }, [isLoadingHistory, channelId, privateChatId]); // Auto-scroll on new messages — skip when this channel is hidden (display:none). // biome-ignore lint/correctness/useExhaustiveDependencies: only scroll when messages change, not when isActive changes useEffect(() => { if (!isActive) return; const isNarrowView = window.matchMedia("(max-width: 768px)").matches; const isChatVisible = !isNarrowView || mobileViewActiveColumn === "chatView"; if (wasAtBottomRef.current && isChatVisible) { scrollToBottom(); } }, [displayedMessages, mobileViewActiveColumn, scrollToBottom, isActive]); return ( <>
{isLoadingHistory && !isFetchingMore ? (
) : (
{hasMoreMessages && !searchQuery && (
)} {searchQuery && (
{plural(filteredMessages.length, { one: t`Found 1 message matching "${searchQuery}"`, other: t`Found ${filteredMessages.length} messages matching "${searchQuery}"`, })}
)} {eventGroups.map((group) => { if (group.type === "eventGroup") { const firstId = group.messages[0]?.id || ""; const lastId = group.messages[group.messages.length - 1]?.id || ""; const groupKey = `group-${firstId}-${lastId}`; return ( ); } const message = group.messages[0]; const originalIndex = channelMessages.findIndex( (m) => m.id === message.id, ); const previousMessage = channelMessages[originalIndex - 1]; const showHeader = !previousMessage || previousMessage.type !== "message" || previousMessage.userId !== message.userId || new Date(message.timestamp).getTime() - new Date(previousMessage.timestamp).getTime() > 5 * 60 * 1000; return ( ); })}
)}
); }, ); ChannelMessageList.displayName = "ChannelMessageList"; // Wrap with memo so hidden keep-alive channels skip re-renders when their props // haven't changed (e.g. when messageText changes in the input — the only thing // that changes on typing is local state inside ChatArea, not the props we pass here). export const MemoChannelMessageList = memo(ChannelMessageList); @@ -45,6 +55,12 @@ msgstr "{0} and {1} are typing..." msgid "{0} is typing..." msgstr "{0} is typing..." +#. placeholder {0}: networks.length +#. placeholder {1}: networks.length === 1 ? "" : "s" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "{0} network{1} — pick one to join" +msgstr "{0} network{1} — pick one to join" + #. placeholder {0}: filteredMessages.length - displayedMessages.length #: src/components/layout/ChannelMessageList.tsx msgid "{0} older messages" @@ -185,6 +201,12 @@ msgstr "Add invitation mask (e.g., nick!*@*, *!*@host.com)" msgid "Add IRC Server" msgstr "Add IRC Server" +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworksPanel.tsx +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Add Network" +msgstr "Add Network" + #: src/components/message/MessageActions.tsx #: src/components/message/MessageReactions.tsx #: src/components/message/MessageReactions.tsx @@ -204,6 +226,10 @@ msgstr "Add Rule" msgid "Add Server" msgstr "Add Server" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Add your first network" +msgstr "Add your first network" + #: src/components/message/JsonLogMessage.tsx msgid "Additional Details" msgstr "Additional Details" @@ -357,6 +383,10 @@ msgstr "Back" msgid "Back to image" msgstr "Back to image" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Back to network list" +msgstr "Back to network list" + #: src/components/ui/ModerationModal.tsx msgid "Ban {username} by hostmask (prevents them from rejoining from the same IP/host)" msgstr "Ban {username} by hostmask (prevents them from rejoining from the same IP/host)" @@ -404,6 +434,8 @@ msgstr "Browse all channels on the server" #: src/components/ui/AddPrivateChatModal.tsx #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/ChannelSettingsModal.tsx #: src/components/ui/ExternalLinkWarningModal.tsx #: src/components/ui/FloodSettingsModal.tsx @@ -639,6 +671,7 @@ msgid "Configure notification sounds and highlights" msgstr "Configure notification sounds and highlights" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworksPanel.tsx msgid "Connect" msgstr "Connect" @@ -758,6 +791,10 @@ msgstr "Delete Channel" msgid "Delete message" msgstr "Delete message" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Delete network" +msgstr "Delete network" + #: src/components/layout/ChannelList.tsx msgid "Delete Private Chat" msgstr "Delete Private Chat" @@ -766,6 +803,10 @@ msgstr "Delete Private Chat" msgid "Delete this message? This cannot be undone." msgstr "Delete this message? This cannot be undone." +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Delete this network?" +msgstr "Delete this network?" + #: src/components/layout/ServerList.tsx #: src/components/mobile/ServerBottomSheet.tsx msgid "Disconnect" @@ -829,10 +870,16 @@ msgstr "Download" msgid "e.g., 100:1440" msgstr "e.g., 100:1440" +#: src/components/ui/BouncerNetworksPanel.tsx #: src/components/ui/ChannelSettingsModal.tsx msgid "Edit" msgstr "Edit" +#. placeholder {0}: editingNetwork?.attributes.name || editingNetwork?.netid +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Edit {0}" +msgstr "Edit {0}" + #: src/components/ui/UserProfileModal.tsx msgid "Edit Profile" msgstr "Edit Profile" @@ -1056,6 +1103,7 @@ msgstr "HOME" msgid "Homepage" msgstr "Homepage" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserProfileModal.tsx msgid "Host" msgstr "Host" @@ -1270,6 +1318,10 @@ msgstr "Left the channel" msgid "Let others know when you are typing" msgstr "Let others know when you are typing" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Libera Chat" +msgstr "Libera Chat" + #: src/components/message/LinkPreview.tsx msgid "Link preview" msgstr "Link preview" @@ -1298,6 +1350,10 @@ msgstr "Loading GIFs..." msgid "Loading more channels..." msgstr "Loading more channels..." +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Loading networks from your bouncer…" +msgstr "Loading networks from your bouncer…" + #: src/components/ui/UserProfileModal.tsx msgid "Loading WHOIS data..." msgstr "Loading WHOIS data..." @@ -1485,9 +1541,15 @@ msgid "Name:" msgstr "Name:" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx msgid "Network Name" msgstr "Network Name" +#. placeholder {0}: server?.name ?? bouncerServerId +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Networks on {0}" +msgstr "Networks on {0}" + #: src/components/ui/QuickActions.tsx msgid "New DM" msgstr "New DM" @@ -1510,6 +1572,7 @@ msgid "nick!user@host (e.g., spam*!*@*, *!*@badhost.com)" msgstr "nick!user@host (e.g., spam*!*@*, *!*@badhost.com)" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserSettings.tsx #: src/lib/settings/definitions/allSettings.ts msgid "Nickname" @@ -1569,6 +1632,10 @@ msgstr "No file chosen" msgid "No flood profile" msgstr "No flood profile" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "no host set" +msgstr "no host set" + #: src/components/ui/ChannelSettingsModal.tsx msgid "No invitations found" msgstr "No invitations found" @@ -1609,6 +1676,10 @@ msgstr "No topic set" msgid "No unread mentions or messages" msgstr "No unread mentions or messages" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "No upstream networks yet." +msgstr "No upstream networks yet." + #: src/components/ui/AddPrivateChatModal.tsx msgid "No users available" msgstr "No users available" @@ -1695,6 +1766,10 @@ msgstr "Oops! The net split! ⚠️" msgid "Op" msgstr "Op" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Open" +msgstr "Open" + #: src/components/ui/QuickActions/uiActionConfig.tsx msgid "Open channel configuration settings" msgstr "Open channel configuration settings" @@ -1798,6 +1873,10 @@ msgstr "Pin Private Chat" msgid "Pin this private message conversation" msgstr "Pin this private message conversation" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Plaintext" +msgstr "Plaintext" + #: src/components/message/MediaPreview.tsx #: src/components/message/MediaPreview.tsx #: src/components/message/MediaPreview.tsx @@ -1826,6 +1905,7 @@ msgid "PM User" msgstr "PM User" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx msgid "Port" msgstr "Port" @@ -1917,6 +1997,7 @@ msgstr "reacted to this message" msgid "Read more" msgstr "Read more" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserProfileModal.tsx #: src/components/ui/UserSettings.tsx #: src/lib/settings/definitions/allSettings.ts @@ -2001,6 +2082,7 @@ msgstr "Rules" msgid "Safe" msgstr "Safe" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/ChannelSettingsModal.tsx #: src/components/ui/TopicModal.tsx #: src/components/ui/UserSettings.tsx @@ -2182,6 +2264,10 @@ msgstr "Server operators on the network could potentially read your messages" msgid "Server Password" msgstr "Server Password" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Server Password (PASS)" +msgstr "Server Password (PASS)" + #: src/components/ui/LinkSecurityWarningModal.tsx msgid "Server-to-server communication may use unencrypted connections" msgstr "Server-to-server communication may use unencrypted connections" @@ -2377,6 +2463,10 @@ msgstr "Time (min)" msgid "Time Window (seconds)" msgstr "Time Window (seconds)" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "TLS" +msgstr "TLS" + #: src/components/message/WhisperMessage.tsx #: src/components/message/WhisperMessage.tsx msgid "to" @@ -2425,6 +2515,10 @@ msgstr "Topic:" msgid "Total: {0}" msgstr "Total: {0}" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Transport" +msgstr "Transport" + #: src/components/ui/UserSettings.tsx msgid "Trusted Sources" msgstr "Trusted Sources" @@ -2535,6 +2629,7 @@ msgstr "User Profile" msgid "User Settings" msgstr "User Settings" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/InviteUserModal.tsx #: src/components/ui/ModerationModal.tsx msgid "Username" @@ -2682,6 +2777,10 @@ msgstr "Wide - Broader protection scope" msgid "Will default to 'no reason' if left empty" msgstr "Will default to 'no reason' if left empty" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Yes, delete" +msgstr "Yes, delete" + #: src/components/message/CollapsedEventMessage.tsx #: src/components/message/EventMessage.tsx msgid "You" @@ -2712,6 +2811,10 @@ msgstr "Your account password for authentication" msgid "Your account username for authentication" msgstr "Your account username for authentication" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Your bouncer doesn't have any networks yet. Add one to get started." +msgstr "Your bouncer doesn't have any networks yet. Add one to get started." + #: src/lib/settings/definitions/allSettings.ts msgid "Your default nickname for all servers" msgstr "Your default nickname for all servers" diff --git a/src/locales/es/messages.mjs b/src/locales/es/messages.mjs index 12ce85e6..bef5900e 100644 --- a/src/locales/es/messages.mjs +++ b/src/locales/es/messages.mjs @@ -1 +1 @@ -/*eslint-disable*/export const messages=JSON.parse("{\"+5VMrz\":[\"Formato de patrón inválido. Usa el formato nick!user@host (se permiten comodines *)\"],\"+6NQQA\":[\"Canal de soporte general\"],\"+6NyRG\":[\"Cliente\"],\"+K0AvT\":[\"Desconectar\"],\"+cyFdH\":[\"Mensaje predeterminado al marcarse como ausente\"],\"+mVPqU\":[\"Renderizar formato Markdown en mensajes\"],\"+vqCJH\":[\"Tu nombre de usuario de cuenta para autenticación\"],\"+yPBXI\":[\"Elegir archivo\"],\"+zy2Nq\":[\"Tipo\"],\"/09cao\":[\"Baja seguridad de enlace (Nivel \",[\"securityLevel\"],\")\"],\"/3BQ4J\":[\"Los usuarios fuera del canal no pueden enviar mensajes a él\"],\"/6BzZF\":[\"Alternar lista de miembros\"],\"/TNOPk\":[\"El usuario está ausente\"],\"/XQgft\":[\"Explorar\"],\"/cF7Rs\":[\"Volumen\"],\"/dqduX\":[\"Página siguiente\"],\"/fc3q4\":[\"Todo el contenido\"],\"/kISDh\":[\"Activar sonidos de notificación\"],\"/n04sB\":[\"Kill\"],\"/rTz0M\":[\"Audio\"],\"/rfkZe\":[\"Reproducir sonidos para menciones y mensajes\"],\"0/0ZGA\":[\"Máscara de nombre de canal\"],\"0D6j7U\":[\"Más información sobre reglas personalizadas →\"],\"0XsHcR\":[\"Expulsar usuario\"],\"0ZpE//\":[\"Ordenar por usuarios\"],\"0bEPwz\":[\"Marcar como ausente\"],\"0dGkPt\":[\"Expandir lista de canales\"],\"0gS7M5\":[\"Nombre a mostrar\"],\"0kS+M8\":[\"EjemploRED\"],\"0rgoY7\":[\"Solo te conectas a los servidores que eliges\"],\"0wdd7X\":[\"Unirse\"],\"0wkVYx\":[\"Mensajes privados\"],\"111uHX\":[\"Vista previa del enlace\"],\"196EG4\":[\"Eliminar chat privado\"],\"1DSr1i\":[\"Registrar una cuenta\"],\"1O/24y\":[\"Alternar lista de canales\"],\"1VPJJ2\":[\"Advertencia de enlace externo\"],\"1ZC/dv\":[\"Sin menciones ni mensajes sin leer\"],\"1pO1zi\":[\"El nombre del servidor es obligatorio\"],\"1uwfzQ\":[\"Ver tema del canal\"],\"268g7c\":[\"Ingresa el nombre a mostrar\"],\"2FOFq1\":[\"Los operadores del servidor en la red podrían leer tus mensajes\"],\"2FYpfJ\":[\"Más\"],\"2HF1Y2\":[[\"inviter\"],\" ha invitado a \",[\"target\"],\" a unirse a \",[\"channel\"]],\"2I70QL\":[\"Ver información del perfil de usuario\"],\"2QYdmE\":[\"Usuarios:\"],\"2QpEjG\":[\"salió\"],\"2YE223\":[\"Mensaje en #\",[\"0\"],\" (Intro para nueva línea, Mayús+Intro para enviar)\"],\"2bimFY\":[\"Usar contraseña del servidor\"],\"2iTmdZ\":[\"Almacenamiento local:\"],\"2odkwe\":[\"Estricto – Protección más agresiva\"],\"2uDhbA\":[\"Ingresa el nombre de usuario a invitar\"],\"2ygf/L\":[\"← Atrás\"],\"2zEgxj\":[\"Buscar GIFs...\"],\"3RdPhl\":[\"Renombrar canal\"],\"3THokf\":[\"Usuario con voz\"],\"3TSz9S\":[\"Minimizar\"],\"3jBDvM\":[\"Nombre a mostrar del canal\"],\"3ryuFU\":[\"Informes de errores opcionales para mejorar la app\"],\"3uBF/8\":[\"Cerrar visor\"],\"3uwW8F\":[\"https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"3xf8Kz\":[\"Ingresa nombre de cuenta...\"],\"4/Rr0R\":[\"Invitar a un usuario al canal actual\"],\"4EZrJN\":[\"Reglas\"],\"4JJtW9\":[\"#overflow\"],\"4NqeT4\":[\"Perfil de flood (+F)\"],\"4RZQRK\":[\"¿Qué estás haciendo?\"],\"4hfTrB\":[\"Apodo\"],\"4n99LO\":[\"Ya en \",[\"0\"]],\"4t6vMV\":[\"Cambiar automáticamente a línea única para mensajes cortos\"],\"4vsHmf\":[\"Tiempo (min)\"],\"5+INAX\":[\"Resaltar mensajes que te mencionan\"],\"5R5Pv/\":[\"Nombre de oper\"],\"678PKt\":[\"Nombre de red\"],\"6Aih4U\":[\"Desconectado\"],\"6CO3WE\":[\"Contraseña requerida para unirse al canal. Deja vacío para eliminar la clave.\"],\"6HhMs3\":[\"Mensaje de salida\"],\"6V3Ea3\":[\"Copiado\"],\"6lGV3K\":[\"Mostrar menos\"],\"6yFOEi\":[\"Ingresa contraseña de oper...\"],\"7+IHTZ\":[\"Ningún archivo elegido\"],\"73hrRi\":[\"nick!user@host (ej., spam*!*@*, *!*@badhost.com)\"],\"7QkKyN\":[\"Enviar mensaje privado\"],\"7U1W7c\":[\"Muy relajado\"],\"7Y1YQj\":[\"Nombre real:\"],\"7YHArF\":[\"— abrir en visor\"],\"7fjnVl\":[\"Buscar usuarios...\"],\"7jL88x\":[\"¿Eliminar este mensaje? Esta acción no se puede deshacer.\"],\"7nGhhM\":[\"¿Qué piensas?\"],\"7sEpu1\":[\"Miembros — \",[\"0\"]],\"7sNhEz\":[\"Nombre de usuario\"],\"8H0Q+x\":[\"Más información sobre perfiles →\"],\"8Phu0A\":[\"Mostrar cuando los usuarios cambian su apodo\"],\"8XTG9e\":[\"Ingresa la contraseña de oper\"],\"8XsV2J\":[\"Reintentar envío\"],\"8ZsakT\":[\"Contraseña\"],\"8kR84m\":[\"Estás a punto de abrir un enlace externo:\"],\"8lCgih\":[\"Eliminar regla\"],\"8p/xVT\":[[\"0\",\"plural\",{\"one\":[[\"1\"]],\"other\":[[\"2\"]]}]],\"8wRzac\":[[\"joinCount\",\"plural\",{\"one\":[\"se unió\"],\"other\":[\"se unió \",[\"joinCount\"],\" veces\"]}]],\"9BMLnJ\":[\"Reconectar al servidor\"],\"9OEgyT\":[\"Agregar reacción\"],\"9PQ8m2\":[\"G-Line (ban global)\"],\"9Qs99X\":[\"Correo electrónico:\"],\"9QupBP\":[\"Eliminar patrón\"],\"9bG48P\":[\"Enviando\"],\"9f5f0u\":[\"¿Preguntas sobre privacidad? Contáctanos:\"],\"9unqs3\":[\"Ausente:\"],\"9v3hwv\":[\"No se encontraron servidores.\"],\"9zb2WA\":[\"Conectando\"],\"A1taO8\":[\"Buscar\"],\"A2adVi\":[\"Enviar notificaciones de escritura\"],\"A9Rhec\":[\"Nombre del canal\"],\"AWOSPo\":[\"Acercar\"],\"AXSpEQ\":[\"Oper al conectar\"],\"AeXO77\":[\"Cuenta\"],\"AhNP40\":[\"Buscar posición\"],\"Ai2U7L\":[\"Host\"],\"AjBQnf\":[\"Apodo cambiado\"],\"AmXVh6\":[\"https://example.com/avatar.png\"],\"AnRu/j\":[\"Cancelar respuesta\"],\"ApSx0O\":[\"Se encontraron \",[\"0\"],\" mensajes que coinciden con \\\"\",[\"searchQuery\"],\"\\\"\"],\"AxPAXW\":[\"No se encontraron resultados\"],\"AyNqAB\":[\"Mostrar todos los eventos del servidor en el chat\"],\"B/QqGw\":[\"Alejado del teclado\"],\"B8AaMI\":[\"Este campo es obligatorio\"],\"BA2c49\":[\"El servidor no admite filtrado avanzado de LIST\"],\"BDKt3I\":[[\"0\"],\", \",[\"1\"],\", \",[\"2\"],\" y \",[\"3\"],\" más están escribiendo...\"],\"BGul2A\":[\"Tienes cambios sin guardar. ¿Seguro que deseas cerrar sin guardar?\"],\"BIf9fi\":[\"Tu mensaje de estado\"],\"BZz3md\":[\"Tu sitio web personal\"],\"Bgm/H7\":[\"Permitir ingresar múltiples líneas de texto\"],\"BiQIl1\":[\"Fijar esta conversación de mensaje privado\"],\"BlNZZ2\":[\"Haz clic para ir al mensaje\"],\"Bowq3c\":[\"Solo los operadores pueden cambiar el tema del canal\"],\"Btozzp\":[\"Esta imagen ha expirado\"],\"Bycfjm\":[\"Total: \",[\"0\"]],\"C6IBQc\":[\"Copiar JSON completo\"],\"C9L9wL\":[\"Recopilación de datos\"],\"CDq4wC\":[\"Moderar usuario\"],\"CHVRxG\":[\"Mensaje a @\",[\"0\"],\" (Mayús+Intro para nueva línea)\"],\"CN9zdR\":[\"El nombre y la contraseña de oper son obligatorios\"],\"CW3sYa\":[\"Agregar reacción \",[\"emoji\"]],\"CaAkqd\":[\"Mostrar desconexiones\"],\"CbvaYj\":[\"Banear por apodo\"],\"CcK+Ft\":[\"PDF\"],\"Ce8q3L\":[\"Selecciona un canal\"],\"CsekCi\":[\"Normal\"],\"D+NlUC\":[\"Sistema\"],\"D28t6+\":[\"se unió y salió\"],\"DB8zMK\":[\"Aplicar\"],\"DBcWHr\":[\"Archivo de sonido de notificación personalizado\"],\"DTy9Xw\":[\"Vistas previas de medios\"],\"Dj4pSr\":[\"Elige una contraseña segura\"],\"Du+zn+\":[\"Buscando...\"],\"Du2T2f\":[\"Ajuste no encontrado\"],\"DwsSVQ\":[\"Aplicar filtros y actualizar\"],\"E3W/zd\":[\"Apodo predeterminado\"],\"E6nRW7\":[\"Copiar URL\"],\"E703RG\":[\"Modos:\"],\"EAeu1Z\":[\"Enviar invitación\"],\"EFKJQT\":[\"Ajuste\"],\"EGPQBv\":[\"Reglas de flood personalizadas (+f)\"],\"ELik0r\":[\"Ver política de privacidad completa\"],\"EPbeC2\":[\"Ver o editar el tema del canal\"],\"EQCDNT\":[\"Ingresa nombre de usuario oper...\"],\"EUvulZ\":[\"Se encontró 1 mensaje que coincide con \\\"\",[\"searchQuery\"],\"\\\"\"],\"EatZYJ\":[\"Imagen siguiente\"],\"EdQY6l\":[\"Ninguno\"],\"EnqLYU\":[\"Buscar servidores...\"],\"F0OKMc\":[\"Editar servidor\"],\"F6Int2\":[\"Activar resaltados\"],\"FDoLyE\":[\"Máximo de usuarios\"],\"FUU/hZ\":[\"Controla cuántos medios externos se cargan en el chat.\"],\"Fdp03t\":[\"activado\"],\"FfPWR0\":[\"Modal\"],\"FjkaiT\":[\"Alejar\"],\"FlqOE9\":[\"Qué significa esto:\"],\"FolHNl\":[\"Administra tu cuenta y autenticación\"],\"Fp2Dif\":[\"Salió del servidor\"],\"G5KmCc\":[\"GZ-Line (Z-Line global)\"],\"GDs0lz\":[\"<0>Riesgo: La información sensible (mensajes, conversaciones privadas, datos de autenticación) podría quedar expuesta a administradores de red o atacantes situados entre los servidores IRC.\"],\"GR+2I3\":[\"Agregar máscara de invitación (p. ej., nick!*@*, *!*@host.com)\"],\"GRLyMU\":[\"Cerrar avisos del servidor desplegados\"],\"GlHnXw\":[\"Cambio de apodo fallido: \",[\"error\"],\" \",[\"0\"]],\"GswZF3\":[\"Vista previa:\"],\"GtmO8/\":[\"de\"],\"GtuHUQ\":[\"Renombrar este canal en el servidor. Todos los usuarios verán el nuevo nombre.\"],\"GuGfFX\":[\"Alternar búsqueda\"],\"GxkJXS\":[\"Subiendo...\"],\"GzbwnK\":[\"Se unió al canal\"],\"GzsUDB\":[\"Perfil extendido\"],\"H/PnT8\":[\"Insertar emoji\"],\"H6Izzl\":[\"Tu código de color preferido\"],\"H9jIv+\":[\"Mostrar entradas/salidas\"],\"HAKBY9\":[\"Subir archivos\"],\"HdE1If\":[\"Canal\"],\"Hk4AW9\":[\"Tu nombre de visualización preferido\"],\"HmHDk7\":[\"Seleccionar miembro\"],\"HrQzPU\":[\"Canales en \",[\"networkName\"]],\"I2tXQ5\":[\"Mensaje a @\",[\"0\"],\" (Intro para nueva línea, Mayús+Intro para enviar)\"],\"I6bw/h\":[\"Banear usuario\"],\"I92Z+b\":[\"Activar notificaciones\"],\"I9D72S\":[\"¿Estás seguro de que deseas eliminar este mensaje? Esta acción no se puede deshacer.\"],\"IA+1wo\":[\"Mostrar cuando los usuarios son expulsados de los canales\"],\"IDwkJx\":[\"IRC Operator\"],\"ILlU+s\":[\"Info:\"],\"IUwGEM\":[\"Guardar cambios\"],\"IVeGK6\":[[\"0\"],\", \",[\"1\"],\" y \",[\"2\"],\" están escribiendo...\"],\"IgrLD/\":[\"Pausar\"],\"Im6JED\":[\"WHISPER\"],\"ImOQa9\":[\"Responder\"],\"IoHMnl\":[\"El valor máximo es \",[\"0\"]],\"IvMj+0\":[\"Op\"],\"J28zul\":[\"Conectando...\"],\"J5T9NW\":[\"Información del usuario\"],\"J8Y5+z\":[\"¡Vaya! ¡División de red! ⚠️\"],\"JBHkBA\":[\"Abandonó el canal\"],\"JCwL0Q\":[\"Ingresa un motivo (opcional)\"],\"JFciKP\":[\"Alternar\"],\"JXGkhG\":[\"Cambiar el nombre del canal (solo operadores)\"],\"JcD7qf\":[\"Más acciones\"],\"JdkA+c\":[\"Secreto (+s)\"],\"Jmu12l\":[\"Canales del servidor\"],\"JvQ++s\":[\"Activar Markdown\"],\"K2jwh/\":[\"No hay datos WHOIS disponibles\"],\"KAXSwC\":[\"Voz\"],\"KDfTdX\":[\"Eliminar mensaje\"],\"KKBlUU\":[\"Insertar\"],\"KM0pLb\":[\"¡Bienvenido al canal!\"],\"KR6W2h\":[\"Dejar de ignorar usuario\"],\"KV+Bi1\":[\"Solo por invitación (+i)\"],\"KdCtwE\":[\"Cuántos segundos monitorear la actividad de flood antes de restablecer los contadores\"],\"Kkezga\":[\"Contraseña del servidor\"],\"KsiQ/8\":[\"Los usuarios deben ser invitados para unirse al canal\"],\"L+gB/D\":[\"Información del canal\"],\"LC1a7n\":[\"El servidor IRC ha informado que sus enlaces entre servidores tienen un nivel de seguridad bajo. Esto significa que cuando tus mensajes se retransmiten entre servidores IRC en la red, es posible que no estén correctamente cifrados o que los certificados SSL/TLS no se validen correctamente.\"],\"LNfLR5\":[\"Mostrar expulsiones\"],\"LQb0W/\":[\"Mostrar todos los eventos\"],\"LU7/yA\":[\"Nombre alternativo para mostrar en la interfaz. Puede contener espacios, emoji y caracteres especiales. El nombre real del canal (\",[\"channelName\"],\") seguirá usándose para los comandos IRC.\"],\"LUb9O7\":[\"Se requiere un puerto de servidor válido\"],\"Lb+BUl\":[\"https://example.com/avatar.jpg\"],\"LcET2C\":[\"Política de privacidad\"],\"LcuSDR\":[\"Administra la información y metadatos de tu perfil\"],\"LqLS9B\":[\"Mostrar cambios de apodo\"],\"LsDQt2\":[\"Configuración del canal\"],\"LtI9AS\":[\"Propietario\"],\"LuNhhL\":[\"reaccionó a este mensaje\"],\"M/AZNG\":[\"URL de tu imagen de avatar\"],\"M/WIer\":[\"Enviar mensaje\"],\"M8er/5\":[\"Nombre:\"],\"MHk+7g\":[\"Imagen anterior\"],\"MRorGe\":[\"MP al usuario\"],\"MVbSGP\":[\"Ventana de tiempo (segundos)\"],\"MkpcsT\":[\"Tus mensajes y ajustes se almacenan localmente en tu dispositivo\"],\"N/hDSy\":[\"Marcar como bot, normalmente 'on' o vacío\"],\"N7TQbE\":[\"Invitar usuario a \",[\"channelName\"]],\"NCca/o\":[\"Ingresa apodo predeterminado...\"],\"Nqs6B9\":[\"Muestra todos los medios externos. Cualquier URL puede generar una solicitud a un servidor desconocido.\"],\"Nt+9O7\":[\"Usar WebSocket en lugar de TCP sin procesar\"],\"NxIHzc\":[\"Expulsar usuario\"],\"O+v/cL\":[\"Ver todos los canales del servidor\"],\"ODwSCk\":[\"Enviar un GIF\"],\"OGQ5kK\":[\"Configurar sonidos de notificación y resaltados\"],\"OIPt1Z\":[\"Mostrar u ocultar la barra lateral de miembros\"],\"OKSNq/\":[\"Muy estricto\"],\"ONWvwQ\":[\"Subir\"],\"OVKoQO\":[\"Tu contraseña de cuenta para autenticación\"],\"ObsidianIRC - Bringing IRC to the future\":[\"ObsidianIRC - Llevando IRC al futuro\"],\"OhCpra\":[\"Establece un tema…\"],\"OkltoQ\":[\"Banear a \",[\"username\"],\" por apodo (impide que vuelva a unirse con el mismo nick)\"],\"P+t/Te\":[\"Sin datos adicionales\"],\"P42Wcc\":[\"Seguro\"],\"PD38l0\":[\"Vista previa del avatar del canal\"],\"PD9mEt\":[\"Escribe un mensaje...\"],\"PPqfdA\":[\"Abrir configuración del canal\"],\"PSCjfZ\":[\"El tema que se mostrará para este canal. Todos los usuarios pueden ver el tema.\"],\"PZCecv\":[\"Vista previa de PDF\"],\"PeLgsC\":[[\"c\",\"plural\",{\"one\":[\"1 vez\"],\"other\":[[\"c\"],\" veces\"]}]],\"PguS2C\":[\"Agregar máscara de excepción (p. ej., nick!*@*, *!*@host.com)\"],\"Pil5Ty\":[\"Mostrando \",[\"displayedChannelsCount\"],\" de \",[\"0\"],\" canales\"],\"PqhVlJ\":[\"Banear usuario (por hostmask)\"],\"Q+chwU\":[\"Nombre de usuario:\"],\"Q6hhn8\":[\"Preferencias\"],\"QF4a34\":[\"Por favor, introduce un nombre de usuario\"],\"QGqSZ2\":[\"Color y formato\"],\"QJQd1J\":[\"Editar perfil\"],\"QSzGDE\":[\"Inactivo\"],\"QUlny5\":[\"¡Bienvenido a \",[\"0\"],\"!\"],\"Qoq+GP\":[\"Leer más\"],\"QuSkCF\":[\"Filtrar canales...\"],\"QwUrDZ\":[\"cambió el tema a: \",[\"topic\"]],\"R0UH07\":[\"Imagen \",[\"0\"],\" de \",[\"1\"]],\"R7SsBE\":[\"Silenciar\"],\"R8rf1X\":[\"Haz clic para establecer el tema\"],\"RArB3D\":[\"fue expulsado de \",[\"channelName\"],\" por \",[\"username\"]],\"RI3cWd\":[\"Descubre el mundo de IRC con ObsidianIRC\"],\"RMMaN5\":[\"Moderado (+m)\"],\"RWw9Lg\":[\"Cerrar ventana\"],\"RZ2BuZ\":[\"Se requiere verificación para el registro de la cuenta \",[\"account\"],\": \",[\"message\"]],\"RySp6q\":[\"Ocultar comentarios\"],\"SPKQTd\":[\"El apodo es obligatorio\"],\"SPVjfj\":[\"Por defecto será 'sin motivo' si se deja vacío\"],\"SQKPvQ\":[\"Invitar usuario\"],\"SkZcl+\":[\"Elige un perfil de protección contra flood predefinido. Estos perfiles ofrecen configuraciones de protección equilibradas para diferentes casos de uso.\"],\"Slr+3C\":[\"Mínimo de usuarios\"],\"Spnlre\":[\"Has invitado a \",[\"target\"],\" a unirse a \",[\"channel\"]],\"T/ckN5\":[\"Abrir en el visor\"],\"T91vKp\":[\"Reproducir\"],\"TV2Wdu\":[\"Conoce cómo gestionamos tus datos y protegemos tu privacidad.\"],\"TgFpwD\":[\"Aplicando...\"],\"TkzSFB\":[\"Sin cambios\"],\"TtserG\":[\"Ingresa el nombre real\"],\"Ttz9J1\":[\"Ingresa contraseña...\"],\"Tz0i8g\":[\"Ajustes\"],\"U3pytU\":[\"Admin\"],\"UDb2YD\":[\"Reaccionar\"],\"UE4KO5\":[\"*channel*\"],\"UGT5vp\":[\"Guardar configuración\"],\"UV5hLB\":[\"No se encontraron bans\"],\"Uaj3Nd\":[\"Mensajes de estado\"],\"Ue3uny\":[\"Predeterminado (sin perfil)\"],\"UkARhe\":[\"Normal – Protección estándar\"],\"Umn7Cj\":[\"Aún no hay comentarios. ¡Sé el primero!\"],\"UtUIRh\":[[\"0\"],\" mensajes anteriores\"],\"UwzP+U\":[\"Conexión segura\"],\"V0/A4O\":[\"Propietario del canal\"],\"V4qgxE\":[\"Creado hace menos de (min)\"],\"V8yTm6\":[\"Limpiar búsqueda\"],\"VJMMyz\":[\"ObsidianIRC - Llevando IRC al futuro\"],\"VJScHU\":[\"Motivo\"],\"VLsmVV\":[\"Silenciar notificaciones\"],\"VbyRUy\":[\"Comentarios\"],\"Vmx0mQ\":[\"Establecido por:\"],\"VqnIZz\":[\"Ver nuestra política de privacidad y prácticas de datos\"],\"VrMygG\":[\"La longitud mínima es \",[\"0\"]],\"VrnTui\":[\"Tus pronombres, mostrados en tu perfil\"],\"W8E3qn\":[\"Cuenta autenticada\"],\"WAakm9\":[\"Eliminar canal\"],\"WFxTHC\":[\"Agregar máscara de ban (p. ej., nick!*@*, *!*@host.com)\"],\"WN1g9F\":[\"El host del servidor es obligatorio\"],\"WRYdXW\":[\"Posición del audio\"],\"WUOH5B\":[\"Ignorar usuario\"],\"WWEXnZ\":[[\"0\",\"plural\",{\"one\":[\"Mostrar 1 elemento más\"],\"other\":[\"Mostrar \",[\"1\"],\" elementos más\"]}]],\"Weq9zb\":[\"General\"],\"Wfj7Sk\":[\"Silenciar o activar los sonidos de notificación\"],\"Wm7gbG\":[\"GitHub:\"],\"WyeHWY\":[\"*spam*\"],\"WzMCru\":[\"Perfil de usuario\"],\"X6S3lt\":[\"Buscar ajustes, canales, servidores...\"],\"XEHan5\":[\"Continuar de todos modos\"],\"XI1+wb\":[\"Formato no válido\"],\"XIXeuC\":[\"Mensaje a @\",[\"0\"]],\"XMS+k4\":[\"Iniciar mensaje privado\"],\"XWgxXq\":[\"Álbum\"],\"Xd7+IT\":[\"Desfijar chat privado\"],\"Xm/s+u\":[\"Pantalla\"],\"Xp2n93\":[\"Muestra medios desde el host de archivos de confianza de tu servidor. No se realizan solicitudes a servicios externos.\"],\"XvjC4F\":[\"Guardando...\"],\"Y/qryO\":[\"No se encontraron usuarios que coincidan con tu búsqueda\"],\"YAqRpI\":[\"Registro de cuenta exitoso para \",[\"account\"],\": \",[\"message\"]],\"YEfzvP\":[\"Tema protegido (+t)\"],\"YQOn6a\":[\"Contraer lista de miembros\"],\"YRCoE9\":[\"Operador del canal\"],\"YURQaF\":[\"Ver perfil\"],\"YdBSvr\":[\"Controlar la visualización de medios y contenido externo\"],\"Yj6U3V\":[\"Sin servidor central:\"],\"YjvpGx\":[\"Pronombres\"],\"YqH4l4\":[\"Sin clave\"],\"YyUPpV\":[\"Cuenta:\"],\"ZJSWfw\":[\"Mensaje al desconectarse del servidor\"],\"ZR1dJ4\":[\"Invitaciones\"],\"ZdWg0V\":[\"Abrir en el navegador\"],\"ZhRBbl\":[\"Buscar mensajes…\"],\"Zmcu3y\":[\"Filtros avanzados\"],\"a2/8e5\":[\"Tema establecido hace más de (min)\"],\"aHKcKc\":[\"Página anterior\"],\"aJTbXX\":[\"Contraseña de oper\"],\"aQryQv\":[\"El patrón ya existe\"],\"aW9pLN\":[\"Número máximo de usuarios permitidos en el canal. Deja vacío para no establecer límite.\"],\"ah4fmZ\":[\"También muestra vistas previas de YouTube, Vimeo, SoundCloud y servicios conocidos similares.\"],\"aifXak\":[\"No hay medios en este canal\"],\"ap2zBz\":[\"Relajado\"],\"az8lvo\":[\"Desactivado\"],\"azXSNo\":[\"Expandir lista de miembros\"],\"azdliB\":[\"Iniciar sesión en una cuenta\"],\"b26wlF\":[\"ella/la\"],\"bD/+Ei\":[\"Estricto\"],\"bQ6BJn\":[\"Configura reglas detalladas de protección contra flood. Cada regla especifica qué tipo de actividad monitorear y qué acción tomar cuando se superan los umbrales.\"],\"beV7+y\":[\"El usuario recibirá una invitación para unirse a \",[\"channelName\"],\".\"],\"bk84cH\":[\"Mensaje de ausencia\"],\"bkHdLj\":[\"Agregar servidor IRC\"],\"bmQLn5\":[\"Añadir regla\"],\"bwRvnp\":[\"Acción\"],\"c8+EVZ\":[\"Cuenta verificada\"],\"cGYUlD\":[\"No se carga ninguna vista previa de medios.\"],\"cLF98o\":[\"Mostrar comentarios (\",[\"commentCount\"],\")\"],\"cLKIDO\":[\"No hay usuarios disponibles\"],\"cSgpoS\":[\"Fijar chat privado\"],\"cde3ce\":[\"Mensaje a <0>\",[\"0\"],\"\"],\"chQsxg\":[\"Copiar salida formateada\"],\"cl/A5J\":[\"¡Bienvenido a \",[\"__DEFAULT_IRC_SERVER_NAME__\"],\"!\"],\"cnGeoo\":[\"Eliminar\"],\"coPLXT\":[\"No almacenamos tus comunicaciones IRC en nuestros servidores\"],\"crYH/6\":[\"Reproductor de SoundCloud\"],\"d3sis4\":[\"Agregar servidor\"],\"d9aN5k\":[\"Eliminar a \",[\"username\"],\" del canal\"],\"dEgA5A\":[\"Cancelar\"],\"dGi1We\":[\"Desfijar esta conversación de mensaje privado\"],\"dJVuyC\":[\"salió de \",[\"channelName\"],\" (\",[\"reason\"],\")\"],\"dMtLDE\":[\"a\"],\"dXqxlh\":[\"<0>⚠️ ¡Riesgo de seguridad! Esta conexión puede ser vulnerable a intercepciones o ataques de intermediario.\"],\"da9Q/R\":[\"Modos del canal cambiados\"],\"dhJN3N\":[\"Mostrar comentarios\"],\"dj2xTE\":[\"Descartar notificación\"],\"dpCzmC\":[\"Configuración de protección contra flood\"],\"e9dQpT\":[\"¿Deseas abrir este enlace en una nueva pestaña?\"],\"ePK91l\":[\"Editar\"],\"eYBDuB\":[\"Sube una imagen o proporciona una URL con sustitución opcional de \",[\"size\"],\" para tamaño dinámico\"],\"edBbee\":[\"Banear a \",[\"username\"],\" por hostmask (impide que vuelva a unirse desde la misma IP/host)\"],\"ekfzWq\":[\"Configuración de usuario\"],\"elPDWs\":[\"Personaliza tu experiencia con el cliente IRC\"],\"eu2osY\":[\"<0>💡 Recomendación: Continúa solo si confías en este servidor y entiendes los riesgos. Evita compartir información sensible o contraseñas a través de esta conexión.\"],\"euEhbr\":[\"Haz clic para unirte a \",[\"channel\"]],\"ez3vLd\":[\"Activar entrada multilínea\"],\"f0J5Ki\":[\"La comunicación entre servidores puede usar conexiones sin cifrar\"],\"f9BHJk\":[\"Advertir al usuario\"],\"fDOLLd\":[\"No se encontraron canales.\"],\"ffzDkB\":[\"Analíticas anónimas:\"],\"fq1GF9\":[\"Mostrar cuando los usuarios se desconectan del servidor\"],\"gEF57C\":[\"Este servidor solo admite un tipo de conexión\"],\"gJuLUI\":[\"Lista de ignorados\"],\"gNzMrk\":[\"Avatar actual\"],\"gjPWyO\":[\"Ingresa tu apodo...\"],\"gz6UQ3\":[\"Maximizar\"],\"h6razj\":[\"Excluir máscara de nombre de canal\"],\"hG6jnw\":[\"Sin tema establecido\"],\"hG89Ed\":[\"Imagen\"],\"hZ6znB\":[\"Puerto\"],\"ha+Bz5\":[\"ej., 100:1440\"],\"hehnjM\":[\"Cantidad\"],\"hzdLuQ\":[\"Solo los usuarios con Voice o superior pueden hablar\"],\"i0qMbr\":[\"Inicio\"],\"iDNBZe\":[\"Notificaciones\"],\"iH8pgl\":[\"Atrás\"],\"iL9SZg\":[\"Banear usuario (por apodo)\"],\"iNt+3c\":[\"Volver a la imagen\"],\"iQvi+a\":[\"No advertirme sobre la baja seguridad de enlaces para este servidor\"],\"iSLIjg\":[\"Conectar\"],\"iWXkHH\":[\"Halfop\"],\"iZeTtp\":[\"Host del servidor\"],\"idD8Ev\":[\"Guardado\"],\"iivqkW\":[\"Conectado desde\"],\"ij+Elv\":[\"Vista previa de imagen\"],\"ilIWp7\":[\"Alternar notificaciones\"],\"iuaqvB\":[\"Usa * como comodín. Ejemplos: baduser!*@*, *!*@spammer.com, troll*!*@*\"],\"ixkTse\":[\"Bot\"],\"j2DGR0\":[\"Banear por hostmask\"],\"jA4uoI\":[\"Tema:\"],\"jLXxGK\":[\"https://example.com\"],\"jPSk57\":[\"Motivo (opcional)\"],\"jUV7CU\":[\"Subir avatar\"],\"jW5Uwh\":[\"Controla cuántos medios externos se cargan. Desactivado / Seguro / Fuentes confiables / Todo el contenido.\"],\"jXzms5\":[\"Opciones de adjunto\"],\"jZlrte\":[\"Color\"],\"jfC/xh\":[\"Contacto\"],\"jywMpv\":[\"#new-channel-name\"],\"k112DD\":[\"Cargar mensajes anteriores\"],\"k3ID0F\":[\"Filtrar miembros…\"],\"k65gsE\":[\"Ver en detalle\"],\"k7Zgob\":[\"Cancelar conexión\"],\"kAVx5h\":[\"No se encontraron invitaciones\"],\"kCLEPU\":[\"Conectado a\"],\"kF5LKb\":[\"Patrones ignorados:\"],\"kGeOx/\":[\"Unirse a \",[\"0\"]],\"kITKr8\":[\"Cargando modos del canal...\"],\"kPpPsw\":[\"Eres un IRC Operator\"],\"kWJmRL\":[\"Tú\"],\"kfcRb0\":[\"Avatar\"],\"kjMqSj\":[\"Copiar JSON\"],\"krViRy\":[\"Clic para copiar como JSON\"],\"ks71ra\":[\"Excepciones\"],\"kw4lRv\":[\"Medio operador del canal\"],\"kxgIRq\":[\"Selecciona o agrega un canal para comenzar.\"],\"ky6dWe\":[\"Vista previa del avatar\"],\"l+GxCv\":[\"Cargando canales...\"],\"l+IUVW\":[\"Verificación de cuenta exitosa para \",[\"account\"],\": \",[\"message\"]],\"l/siQz\":[[\"reconnectCount\",\"plural\",{\"one\":[\"se reconectó\"],\"other\":[\"se reconectó \",[\"reconnectCount\"],\" veces\"]}]],\"l5jmzx\":[[\"0\"],\" y \",[\"1\"],\" están escribiendo...\"],\"lHy8N5\":[\"Cargando más canales...\"],\"lbpf14\":[\"Unirse a \",[\"value\"]],\"lfFsZ4\":[\"Canales\"],\"lkNdiH\":[\"Nombre de cuenta\"],\"ln500L\":[\"ObsidianIRC\"],\"lnCMdg\":[\"Subir imagen\"],\"loQxaJ\":[\"He vuelto\"],\"lvfaxv\":[\"INICIO\"],\"m16xKo\":[\"Agregar\"],\"m8flAk\":[\"Vista previa (aún no subido)\"],\"mEPxTp\":[\"<0>⚠️ ¡Ten cuidado! Solo abre enlaces de fuentes de confianza. Los enlaces maliciosos pueden comprometer tu seguridad o privacidad.\"],\"mHGdhG\":[\"Información del servidor\"],\"mHS8lb\":[\"Mensaje en #\",[\"0\"]],\"mMYBD9\":[\"Amplio – Ámbito de protección más amplio\"],\"mTGsPd\":[\"Tema del canal\"],\"mU8j6O\":[\"Sin mensajes externos (+n)\"],\"mZp8FL\":[\"Retorno automático a línea única\"],\"mdQu8G\":[\"TuApodo\"],\"miSSBQ\":[\"Comentarios (\",[\"commentCount\"],\")\"],\"mvyLSy\":[\"El usuario está autenticado\"],\"mwtcGl\":[\"Cerrar comentarios\"],\"mzI/c+\":[\"Descargar\"],\"n3fGRk\":[\"establecido por \",[\"0\"]],\"nE9jsU\":[\"Relajado – Protección menos agresiva\"],\"nNflMD\":[\"Salir del canal\"],\"nPXkBi\":[\"Cargando datos WHOIS...\"],\"nQnxxF\":[\"Mensaje en #\",[\"0\"],\" (Mayús+Intro para nueva línea)\"],\"nWMRxa\":[\"Desfijar\"],\"nkC032\":[\"Sin perfil de flood\"],\"o69z4d\":[\"Enviar un mensaje de advertencia a \",[\"username\"]],\"o9ylQi\":[\"Busca GIFs para empezar\"],\"oFGkER\":[\"Avisos del servidor\"],\"oOi11l\":[\"Ir al final\"],\"oQEzQR\":[\"Nuevo mensaje directo\"],\"oXOSPE\":[\"En línea\"],\"oal760\":[\"Son posibles ataques de intermediario en los enlaces del servidor\"],\"oeqmmJ\":[\"Fuentes de confianza\"],\"ovBPCi\":[\"Predeterminado\"],\"p0Z69r\":[\"El patrón no puede estar vacío\"],\"p1KgtK\":[\"Error al cargar el audio\"],\"p59pEv\":[\"Detalles adicionales\"],\"p7sRI6\":[\"Avisar a otros cuando estás escribiendo\"],\"pBm1od\":[\"Canal secreto\"],\"pNmiXx\":[\"Tu apodo predeterminado para todos los servidores\"],\"pUUo9G\":[\"Hostname:\"],\"pVGPmz\":[\"Contraseña de la cuenta\"],\"peNE68\":[\"Permanente\"],\"plhHQt\":[\"Sin datos\"],\"pm6+q5\":[\"Advertencia de seguridad\"],\"pn5qSs\":[\"Información adicional\"],\"q0cR4S\":[\"ahora se llama **\",[\"newNick\"],\"**\"],\"qFcunY\":[\"El canal no aparecerá en los comandos LIST ni NAMES\"],\"qLpTm/\":[\"Eliminar reacción \",[\"emoji\"]],\"qVkGWK\":[\"Fijar\"],\"qY8wNa\":[\"Página de inicio\"],\"qb0xJ7\":[\"Usa comodines: * coincide con cualquier secuencia, ? coincide con cualquier carácter individual. Ejemplos: nick!*@*, *!*@host.com, *!*user@*\"],\"qhzpRq\":[\"Clave del canal (+k)\"],\"qtoOYG\":[\"Sin límite\"],\"r1W2AS\":[\"Imagen del servidor de archivos\"],\"rIPR2O\":[\"Tema establecido hace menos de (min)\"],\"rMMSYo\":[\"La longitud máxima es \",[\"0\"]],\"rWtzQe\":[\"La red se dividió y se reconectó. ✅\"],\"rYG2u6\":[\"Por favor espera...\"],\"rdUucN\":[\"Vista previa\"],\"rjGI/Q\":[\"Privacidad\"],\"rk8iDX\":[\"Cargando GIFs...\"],\"rn6SBY\":[\"Activar sonido\"],\"s/UKqq\":[\"Fue expulsado del canal\"],\"s8cATI\":[\"se unió a \",[\"channelName\"]],\"sCO9ue\":[\"La conexión a <0>\",[\"serverName\"],\" presenta los siguientes problemas de seguridad:\"],\"sGH11W\":[\"Servidor\"],\"sHI1H+\":[\"ahora se llama **\",[\"newNick\"],\"**\"],\"sJyV04\":[[\"inviter\"],\" te ha invitado a unirte a \",[\"channel\"]],\"sby+1/\":[\"Haz clic para copiar\"],\"sfN25C\":[\"Tu nombre real o completo\"],\"sliuzR\":[\"Abrir enlace\"],\"sqrO9R\":[\"Menciones personalizadas\"],\"sr6RdJ\":[\"Multilínea con Shift+Enter\"],\"swrCpB\":[\"El canal ha sido renombrado de \",[\"oldName\"],\" a \",[\"newName\"],\" por \",[\"user\"],[\"0\"]],\"sxkWRg\":[\"Avanzado\"],\"t/YqKh\":[\"Eliminar\"],\"t47eHD\":[\"Tu identificador único en este servidor\"],\"tAkAh0\":[\"URL con sustitución opcional de \",[\"size\"],\" para tamaño dinámico. Ejemplo: https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"tXLJS3\":[\"Mostrar u ocultar la barra lateral de canales\"],\"tfDRzk\":[\"Guardar\"],\"tiBsJk\":[\"salió de \",[\"channelName\"]],\"tt4/UD\":[\"salió (\",[\"reason\"],\")\"],\"u0TcnO\":[\"El apodo {nick} ya está en uso, reintentando con {newNick}\"],\"u0a8B4\":[\"Autenticarse como operador IRC para acceso administrativo\"],\"u0rWFU\":[\"Creado hace más de (min)\"],\"u72w3t\":[\"Usuarios y patrones a ignorar\"],\"u7jc2L\":[\"salió\"],\"uAQUqI\":[\"Estado\"],\"uB85T3\":[\"Error al guardar: \",[\"msg\"]],\"uV3DOL\":[\"G-Line\"],\"uW3lLI\":[\"Servidores IRC:\"],\"usSSr/\":[\"Nivel de zoom\"],\"v7uvcf\":[\"Software:\"],\"vE8kb+\":[\"Shift+Enter para nuevas líneas (Enter envía)\"],\"vERlcd\":[\"Perfil\"],\"vK0RL8\":[\"Sin tema\"],\"vSJd18\":[\"Video\"],\"vXIe7J\":[\"Idioma\"],\"vaHYxN\":[\"Nombre real\"],\"vhjbKr\":[\"Ausente\"],\"w4NYox\":[\"cliente \",[\"title\"]],\"w8xQRx\":[\"Valor no válido\"],\"wFjjxZ\":[\"fue expulsado de \",[\"channelName\"],\" por \",[\"username\"],\" (\",[\"reason\"],\")\"],\"wGjaGl\":[\"No se encontraron excepciones de ban\"],\"wPrGnM\":[\"Administrador del canal\"],\"wRkP2d\":[\"GIF\"],\"wbm86v\":[\"Mostrar cuando los usuarios entran o salen de canales\"],\"whqZ9r\":[\"Palabras o frases adicionales para resaltar\"],\"wm7RV4\":[\"Sonido de notificación\"],\"wz/Yoq\":[\"Tus mensajes podrían ser interceptados al retransmitirse entre servidores\"],\"xCJdfg\":[\"Limpiar\"],\"xUHRTR\":[\"Autenticarse automáticamente como operador al conectar\"],\"xWHwwQ\":[\"Bans\"],\"xYilR2\":[\"Medios\"],\"xceQrO\":[\"Solo se admiten websockets seguros\"],\"xdtXa+\":[\"nombre-del-canal\"],\"xfXC7q\":[\"Canales de texto\"],\"xlCYOE\":[\"Cargando más mensajes...\"],\"xlhswE\":[\"El valor mínimo es \",[\"0\"]],\"xq97Ci\":[\"Agregar una palabra o frase...\"],\"xuRqRq\":[\"Límite de usuarios (+l)\"],\"xwF+7J\":[[\"0\"],\" está escribiendo...\"],\"yNeucF\":[\"Este servidor no admite metadatos de perfil extendido (extensión IRCv3 METADATA). Los campos adicionales como avatar, nombre a mostrar y estado no están disponibles.\"],\"yPlrca\":[\"Avatar del canal\"],\"yQE2r9\":[\"Cargando\"],\"ySU+JY\":[\"tu@correo.com\"],\"yTX1Rt\":[\"Nombre de usuario Oper\"],\"yYOzWD\":[\"registros\"],\"yfx9Re\":[\"Contraseña de operador IRC\"],\"ygCKqB\":[\"Detener\"],\"ymDxJx\":[\"Nombre de usuario de operador IRC\"],\"yrpRsQ\":[\"Ordenar por nombre\"],\"yz7wBu\":[\"Cerrar\"],\"zJw+jA\":[\"establece modo: \",[\"0\"]],\"zebeLu\":[\"Ingresa el nombre de usuario de oper\"],\"zpr0Bw\":[\"GZ-Line\"]}"); \ No newline at end of file +/*eslint-disable*/export const messages=JSON.parse("{\"+5VMrz\":[\"Formato de patrón inválido. Usa el formato nick!user@host (se permiten comodines *)\"],\"+6NQQA\":[\"Canal de soporte general\"],\"+6NyRG\":[\"Cliente\"],\"+K0AvT\":[\"Desconectar\"],\"+cyFdH\":[\"Mensaje predeterminado al marcarse como ausente\"],\"+mVPqU\":[\"Renderizar formato Markdown en mensajes\"],\"+vqCJH\":[\"Tu nombre de usuario de cuenta para autenticación\"],\"+yPBXI\":[\"Elegir archivo\"],\"+zy2Nq\":[\"Tipo\"],\"/09cao\":[\"Baja seguridad de enlace (Nivel \",[\"securityLevel\"],\")\"],\"/3BQ4J\":[\"Los usuarios fuera del canal no pueden enviar mensajes a él\"],\"/6BzZF\":[\"Alternar lista de miembros\"],\"/TNOPk\":[\"El usuario está ausente\"],\"/XQgft\":[\"Explorar\"],\"/cF7Rs\":[\"Volumen\"],\"/dqduX\":[\"Página siguiente\"],\"/fc3q4\":[\"Todo el contenido\"],\"/kISDh\":[\"Activar sonidos de notificación\"],\"/n04sB\":[\"Kill\"],\"/rTz0M\":[\"Audio\"],\"/rfkZe\":[\"Reproducir sonidos para menciones y mensajes\"],\"0/0ZGA\":[\"Máscara de nombre de canal\"],\"0D6j7U\":[\"Más información sobre reglas personalizadas →\"],\"0XsHcR\":[\"Expulsar usuario\"],\"0ZpE//\":[\"Ordenar por usuarios\"],\"0bEPwz\":[\"Marcar como ausente\"],\"0dGkPt\":[\"Expandir lista de canales\"],\"0gS7M5\":[\"Nombre a mostrar\"],\"0kS+M8\":[\"EjemploRED\"],\"0rgoY7\":[\"Solo te conectas a los servidores que eliges\"],\"0wdd7X\":[\"Unirse\"],\"0wkVYx\":[\"Mensajes privados\"],\"111uHX\":[\"Vista previa del enlace\"],\"196EG4\":[\"Eliminar chat privado\"],\"1DSr1i\":[\"Registrar una cuenta\"],\"1O/24y\":[\"Alternar lista de canales\"],\"1TNIig\":[\"Open\"],\"1VPJJ2\":[\"Advertencia de enlace externo\"],\"1ZC/dv\":[\"Sin menciones ni mensajes sin leer\"],\"1pO1zi\":[\"El nombre del servidor es obligatorio\"],\"1uwfzQ\":[\"Ver tema del canal\"],\"268g7c\":[\"Ingresa el nombre a mostrar\"],\"2FOFq1\":[\"Los operadores del servidor en la red podrían leer tus mensajes\"],\"2FYpfJ\":[\"Más\"],\"2HF1Y2\":[[\"inviter\"],\" ha invitado a \",[\"target\"],\" a unirse a \",[\"channel\"]],\"2I70QL\":[\"Ver información del perfil de usuario\"],\"2QYdmE\":[\"Usuarios:\"],\"2QpEjG\":[\"salió\"],\"2YE223\":[\"Mensaje en #\",[\"0\"],\" (Intro para nueva línea, Mayús+Intro para enviar)\"],\"2bimFY\":[\"Usar contraseña del servidor\"],\"2iTmdZ\":[\"Almacenamiento local:\"],\"2odkwe\":[\"Estricto – Protección más agresiva\"],\"2uDhbA\":[\"Ingresa el nombre de usuario a invitar\"],\"2ygf/L\":[\"← Atrás\"],\"2zEgxj\":[\"Buscar GIFs...\"],\"3RdPhl\":[\"Renombrar canal\"],\"3THokf\":[\"Usuario con voz\"],\"3TSz9S\":[\"Minimizar\"],\"3jBDvM\":[\"Nombre a mostrar del canal\"],\"3ryuFU\":[\"Informes de errores opcionales para mejorar la app\"],\"3uBF/8\":[\"Cerrar visor\"],\"3uwW8F\":[\"https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"3xf8Kz\":[\"Ingresa nombre de cuenta...\"],\"4/Rr0R\":[\"Invitar a un usuario al canal actual\"],\"4EZrJN\":[\"Reglas\"],\"4JJtW9\":[\"#overflow\"],\"4NqeT4\":[\"Perfil de flood (+F)\"],\"4RZQRK\":[\"¿Qué estás haciendo?\"],\"4hfTrB\":[\"Apodo\"],\"4n99LO\":[\"Ya en \",[\"0\"]],\"4t6vMV\":[\"Cambiar automáticamente a línea única para mensajes cortos\"],\"4vsHmf\":[\"Tiempo (min)\"],\"4x/Axu\":[\"Tu bouncer aún no tiene ninguna red. Agrega una para empezar.\"],\"5+INAX\":[\"Resaltar mensajes que te mencionan\"],\"5R5Pv/\":[\"Nombre de oper\"],\"678PKt\":[\"Nombre de red\"],\"6Aih4U\":[\"Desconectado\"],\"6CO3WE\":[\"Contraseña requerida para unirse al canal. Deja vacío para eliminar la clave.\"],\"6HhMs3\":[\"Mensaje de salida\"],\"6V3Ea3\":[\"Copiado\"],\"6lGV3K\":[\"Mostrar menos\"],\"6yFOEi\":[\"Ingresa contraseña de oper...\"],\"7+IHTZ\":[\"Ningún archivo elegido\"],\"73fnil\":[\"TLS\"],\"73hrRi\":[\"nick!user@host (ej., spam*!*@*, *!*@badhost.com)\"],\"7QkKyN\":[\"Enviar mensaje privado\"],\"7U1W7c\":[\"Muy relajado\"],\"7Y1YQj\":[\"Nombre real:\"],\"7YHArF\":[\"— abrir en visor\"],\"7fjnVl\":[\"Buscar usuarios...\"],\"7jL88x\":[\"¿Eliminar este mensaje? Esta acción no se puede deshacer.\"],\"7nGhhM\":[\"¿Qué piensas?\"],\"7sEpu1\":[\"Miembros — \",[\"0\"]],\"7sNhEz\":[\"Nombre de usuario\"],\"8H0Q+x\":[\"Más información sobre perfiles →\"],\"8Phu0A\":[\"Mostrar cuando los usuarios cambian su apodo\"],\"8XTG9e\":[\"Ingresa la contraseña de oper\"],\"8XsV2J\":[\"Reintentar envío\"],\"8ZsakT\":[\"Contraseña\"],\"8kR84m\":[\"Estás a punto de abrir un enlace externo:\"],\"8lCgih\":[\"Eliminar regla\"],\"8p/xVT\":[[\"0\",\"plural\",{\"one\":[[\"1\"]],\"other\":[[\"2\"]]}]],\"8wRzac\":[[\"joinCount\",\"plural\",{\"one\":[\"se unió\"],\"other\":[\"se unió \",[\"joinCount\"],\" veces\"]}]],\"9BMLnJ\":[\"Reconectar al servidor\"],\"9OEgyT\":[\"Agregar reacción\"],\"9PQ8m2\":[\"G-Line (ban global)\"],\"9Qs99X\":[\"Correo electrónico:\"],\"9QupBP\":[\"Eliminar patrón\"],\"9W7tl5\":[\"(sin cambios)\"],\"9bG48P\":[\"Enviando\"],\"9f5f0u\":[\"¿Preguntas sobre privacidad? Contáctanos:\"],\"9iweoP\":[\"Redes en \",[\"0\"]],\"9unqs3\":[\"Ausente:\"],\"9v3hwv\":[\"No se encontraron servidores.\"],\"9zb2WA\":[\"Conectando\"],\"A1taO8\":[\"Buscar\"],\"A2adVi\":[\"Enviar notificaciones de escritura\"],\"A9Rhec\":[\"Nombre del canal\"],\"AWOSPo\":[\"Acercar\"],\"AXSpEQ\":[\"Oper al conectar\"],\"AeXO77\":[\"Cuenta\"],\"AhNP40\":[\"Buscar posición\"],\"Ai2U7L\":[\"Host\"],\"AjBQnf\":[\"Apodo cambiado\"],\"AmXVh6\":[\"https://example.com/avatar.png\"],\"AnRu/j\":[\"Cancelar respuesta\"],\"ApSx0O\":[\"Se encontraron \",[\"0\"],\" mensajes que coinciden con \\\"\",[\"searchQuery\"],\"\\\"\"],\"AxPAXW\":[\"No se encontraron resultados\"],\"AyNqAB\":[\"Mostrar todos los eventos del servidor en el chat\"],\"B/QqGw\":[\"Alejado del teclado\"],\"B0sB2k\":[\"Texto plano\"],\"B8AaMI\":[\"Este campo es obligatorio\"],\"BA2c49\":[\"El servidor no admite filtrado avanzado de LIST\"],\"BDKt3I\":[[\"0\"],\", \",[\"1\"],\", \",[\"2\"],\" y \",[\"3\"],\" más están escribiendo...\"],\"BGul2A\":[\"Tienes cambios sin guardar. ¿Seguro que deseas cerrar sin guardar?\"],\"BIf9fi\":[\"Tu mensaje de estado\"],\"BZz3md\":[\"Tu sitio web personal\"],\"Bgm/H7\":[\"Permitir ingresar múltiples líneas de texto\"],\"BiQIl1\":[\"Fijar esta conversación de mensaje privado\"],\"BlNZZ2\":[\"Haz clic para ir al mensaje\"],\"Bowq3c\":[\"Solo los operadores pueden cambiar el tema del canal\"],\"Btozzp\":[\"Esta imagen ha expirado\"],\"Bycfjm\":[\"Total: \",[\"0\"]],\"C6IBQc\":[\"Copiar JSON completo\"],\"C9L9wL\":[\"Recopilación de datos\"],\"CDq4wC\":[\"Moderar usuario\"],\"CHVRxG\":[\"Mensaje a @\",[\"0\"],\" (Mayús+Intro para nueva línea)\"],\"CN9zdR\":[\"El nombre y la contraseña de oper son obligatorios\"],\"CW3sYa\":[\"Agregar reacción \",[\"emoji\"]],\"CaAkqd\":[\"Mostrar desconexiones\"],\"CbvaYj\":[\"Banear por apodo\"],\"CcK+Ft\":[\"PDF\"],\"Ce8q3L\":[\"Selecciona un canal\"],\"CsekCi\":[\"Normal\"],\"D+NlUC\":[\"Sistema\"],\"D28t6+\":[\"se unió y salió\"],\"DB8zMK\":[\"Aplicar\"],\"DBcWHr\":[\"Archivo de sonido de notificación personalizado\"],\"DTy9Xw\":[\"Vistas previas de medios\"],\"Dj4pSr\":[\"Elige una contraseña segura\"],\"Du+zn+\":[\"Buscando...\"],\"Du2T2f\":[\"Ajuste no encontrado\"],\"DwsSVQ\":[\"Aplicar filtros y actualizar\"],\"E3W/zd\":[\"Apodo predeterminado\"],\"E6nRW7\":[\"Copiar URL\"],\"E703RG\":[\"Modos:\"],\"EAeu1Z\":[\"Enviar invitación\"],\"EFKJQT\":[\"Ajuste\"],\"EGPQBv\":[\"Reglas de flood personalizadas (+f)\"],\"ELik0r\":[\"Ver política de privacidad completa\"],\"EPbeC2\":[\"Ver o editar el tema del canal\"],\"EQCDNT\":[\"Ingresa nombre de usuario oper...\"],\"EUvulZ\":[\"Se encontró 1 mensaje que coincide con \\\"\",[\"searchQuery\"],\"\\\"\"],\"EatZYJ\":[\"Imagen siguiente\"],\"EdQY6l\":[\"Ninguno\"],\"EnqLYU\":[\"Buscar servidores...\"],\"F0OKMc\":[\"Editar servidor\"],\"F6Int2\":[\"Activar resaltados\"],\"FDoLyE\":[\"Máximo de usuarios\"],\"FUU/hZ\":[\"Controla cuántos medios externos se cargan en el chat.\"],\"Fdp03t\":[\"activado\"],\"FfPWR0\":[\"Modal\"],\"FjkaiT\":[\"Alejar\"],\"FlqOE9\":[\"Qué significa esto:\"],\"FolHNl\":[\"Administra tu cuenta y autenticación\"],\"Fp2Dif\":[\"Salió del servidor\"],\"G5KmCc\":[\"GZ-Line (Z-Line global)\"],\"GDs0lz\":[\"<0>Riesgo: La información sensible (mensajes, conversaciones privadas, datos de autenticación) podría quedar expuesta a administradores de red o atacantes situados entre los servidores IRC.\"],\"GR+2I3\":[\"Agregar máscara de invitación (p. ej., nick!*@*, *!*@host.com)\"],\"GRLyMU\":[\"Cerrar avisos del servidor desplegados\"],\"GlHnXw\":[\"Cambio de apodo fallido: \",[\"error\"],\" \",[\"0\"]],\"GswZF3\":[\"Vista previa:\"],\"GtmO8/\":[\"de\"],\"GtuHUQ\":[\"Renombrar este canal en el servidor. Todos los usuarios verán el nuevo nombre.\"],\"GuGfFX\":[\"Alternar búsqueda\"],\"GxkJXS\":[\"Subiendo...\"],\"GzbwnK\":[\"Se unió al canal\"],\"GzsUDB\":[\"Perfil extendido\"],\"H/PnT8\":[\"Insertar emoji\"],\"H6Izzl\":[\"Tu código de color preferido\"],\"H9jIv+\":[\"Mostrar entradas/salidas\"],\"HAKBY9\":[\"Subir archivos\"],\"HdE1If\":[\"Canal\"],\"Hk4AW9\":[\"Tu nombre de visualización preferido\"],\"HmHDk7\":[\"Seleccionar miembro\"],\"HrQzPU\":[\"Canales en \",[\"networkName\"]],\"I2tXQ5\":[\"Mensaje a @\",[\"0\"],\" (Intro para nueva línea, Mayús+Intro para enviar)\"],\"I6bw/h\":[\"Banear usuario\"],\"I92Z+b\":[\"Activar notificaciones\"],\"I9D72S\":[\"¿Estás seguro de que deseas eliminar este mensaje? Esta acción no se puede deshacer.\"],\"IA+1wo\":[\"Mostrar cuando los usuarios son expulsados de los canales\"],\"IDwkJx\":[\"IRC Operator\"],\"ILlU+s\":[\"Info:\"],\"IUwGEM\":[\"Guardar cambios\"],\"IVeGK6\":[[\"0\"],\", \",[\"1\"],\" y \",[\"2\"],\" están escribiendo...\"],\"IgrLD/\":[\"Pausar\"],\"Im6JED\":[\"WHISPER\"],\"ImOQa9\":[\"Responder\"],\"IoHMnl\":[\"El valor máximo es \",[\"0\"]],\"IvMj+0\":[\"Op\"],\"J28zul\":[\"Conectando...\"],\"J5T9NW\":[\"Información del usuario\"],\"J8Y5+z\":[\"¡Vaya! ¡División de red! ⚠️\"],\"JBHkBA\":[\"Abandonó el canal\"],\"JCwL0Q\":[\"Ingresa un motivo (opcional)\"],\"JFciKP\":[\"Alternar\"],\"JXGkhG\":[\"Cambiar el nombre del canal (solo operadores)\"],\"JcD7qf\":[\"Más acciones\"],\"JdkA+c\":[\"Secreto (+s)\"],\"Jmu12l\":[\"Canales del servidor\"],\"JvQ++s\":[\"Activar Markdown\"],\"K2jwh/\":[\"No hay datos WHOIS disponibles\"],\"KAXSwC\":[\"Voz\"],\"KDfTdX\":[\"Eliminar mensaje\"],\"KKBlUU\":[\"Insertar\"],\"KM0pLb\":[\"¡Bienvenido al canal!\"],\"KR6W2h\":[\"Dejar de ignorar usuario\"],\"KV+Bi1\":[\"Solo por invitación (+i)\"],\"KdCtwE\":[\"Cuántos segundos monitorear la actividad de flood antes de restablecer los contadores\"],\"Kkezga\":[\"Contraseña del servidor\"],\"KsiQ/8\":[\"Los usuarios deben ser invitados para unirse al canal\"],\"L+gB/D\":[\"Información del canal\"],\"LC1a7n\":[\"El servidor IRC ha informado que sus enlaces entre servidores tienen un nivel de seguridad bajo. Esto significa que cuando tus mensajes se retransmiten entre servidores IRC en la red, es posible que no estén correctamente cifrados o que los certificados SSL/TLS no se validen correctamente.\"],\"LNfLR5\":[\"Mostrar expulsiones\"],\"LP+1Z7\":[\"Agregar red\"],\"LQb0W/\":[\"Mostrar todos los eventos\"],\"LU7/yA\":[\"Nombre alternativo para mostrar en la interfaz. Puede contener espacios, emoji y caracteres especiales. El nombre real del canal (\",[\"channelName\"],\") seguirá usándose para los comandos IRC.\"],\"LUb9O7\":[\"Se requiere un puerto de servidor válido\"],\"Lb+BUl\":[\"https://example.com/avatar.jpg\"],\"LcET2C\":[\"Política de privacidad\"],\"LcuSDR\":[\"Administra la información y metadatos de tu perfil\"],\"LqLS9B\":[\"Mostrar cambios de apodo\"],\"LsDQt2\":[\"Configuración del canal\"],\"LtI9AS\":[\"Propietario\"],\"LuNhhL\":[\"reaccionó a este mensaje\"],\"M/AZNG\":[\"URL de tu imagen de avatar\"],\"M/WIer\":[\"Enviar mensaje\"],\"M8er/5\":[\"Nombre:\"],\"MHk+7g\":[\"Imagen anterior\"],\"MRorGe\":[\"MP al usuario\"],\"MVbSGP\":[\"Ventana de tiempo (segundos)\"],\"MkpcsT\":[\"Tus mensajes y ajustes se almacenan localmente en tu dispositivo\"],\"MzPdC2\":[\"Contraseña del servidor (PASS)\"],\"N/hDSy\":[\"Marcar como bot, normalmente 'on' o vacío\"],\"N6j2JH\":[\"Editar \",[\"0\"]],\"N7TQbE\":[\"Invitar usuario a \",[\"channelName\"]],\"NCca/o\":[\"Ingresa apodo predeterminado...\"],\"Nqs6B9\":[\"Muestra todos los medios externos. Cualquier URL puede generar una solicitud a un servidor desconocido.\"],\"Nt+9O7\":[\"Usar WebSocket en lugar de TCP sin procesar\"],\"NxIHzc\":[\"Expulsar usuario\"],\"O+v/cL\":[\"Ver todos los canales del servidor\"],\"OCGpR4\":[\"(heredar)\"],\"ODwSCk\":[\"Enviar un GIF\"],\"OGQ5kK\":[\"Configurar sonidos de notificación y resaltados\"],\"OIPt1Z\":[\"Mostrar u ocultar la barra lateral de miembros\"],\"OKSNq/\":[\"Muy estricto\"],\"ONWvwQ\":[\"Subir\"],\"OVKoQO\":[\"Tu contraseña de cuenta para autenticación\"],\"ObsidianIRC - Bringing IRC to the future\":[\"ObsidianIRC - Llevando IRC al futuro\"],\"OhCpra\":[\"Establece un tema…\"],\"OkltoQ\":[\"Banear a \",[\"username\"],\" por apodo (impide que vuelva a unirse con el mismo nick)\"],\"P+t/Te\":[\"Sin datos adicionales\"],\"P42Wcc\":[\"Seguro\"],\"PD38l0\":[\"Vista previa del avatar del canal\"],\"PD9mEt\":[\"Escribe un mensaje...\"],\"PPqfdA\":[\"Abrir configuración del canal\"],\"PSCjfZ\":[\"El tema que se mostrará para este canal. Todos los usuarios pueden ver el tema.\"],\"PZCecv\":[\"Vista previa de PDF\"],\"PeLgsC\":[[\"c\",\"plural\",{\"one\":[\"1 vez\"],\"other\":[[\"c\"],\" veces\"]}]],\"PguS2C\":[\"Agregar máscara de excepción (p. ej., nick!*@*, *!*@host.com)\"],\"Pil5Ty\":[\"Mostrando \",[\"displayedChannelsCount\"],\" de \",[\"0\"],\" canales\"],\"PqhVlJ\":[\"Banear usuario (por hostmask)\"],\"Q+chwU\":[\"Nombre de usuario:\"],\"Q3v9Wc\":[\"Sí, eliminar\"],\"Q6hhn8\":[\"Preferencias\"],\"QF4a34\":[\"Por favor, introduce un nombre de usuario\"],\"QGqSZ2\":[\"Color y formato\"],\"QJQd1J\":[\"Editar perfil\"],\"QSzGDE\":[\"Inactivo\"],\"QUlny5\":[\"¡Bienvenido a \",[\"0\"],\"!\"],\"Qoq+GP\":[\"Leer más\"],\"QuSkCF\":[\"Filtrar canales...\"],\"QwUrDZ\":[\"cambió el tema a: \",[\"topic\"]],\"R0UH07\":[\"Imagen \",[\"0\"],\" de \",[\"1\"]],\"R7SsBE\":[\"Silenciar\"],\"R8rf1X\":[\"Haz clic para establecer el tema\"],\"RArB3D\":[\"fue expulsado de \",[\"channelName\"],\" por \",[\"username\"]],\"RI3cWd\":[\"Descubre el mundo de IRC con ObsidianIRC\"],\"RMMaN5\":[\"Moderado (+m)\"],\"RWw9Lg\":[\"Cerrar ventana\"],\"RZ2BuZ\":[\"Se requiere verificación para el registro de la cuenta \",[\"account\"],\": \",[\"message\"]],\"RySp6q\":[\"Ocultar comentarios\"],\"S5Togi\":[\"Cargando redes desde tu bouncer…\"],\"SPKQTd\":[\"El apodo es obligatorio\"],\"SPVjfj\":[\"Por defecto será 'sin motivo' si se deja vacío\"],\"SQKPvQ\":[\"Invitar usuario\"],\"STmlpb\":[\"Back to network list\"],\"SkZcl+\":[\"Elige un perfil de protección contra flood predefinido. Estos perfiles ofrecen configuraciones de protección equilibradas para diferentes casos de uso.\"],\"Slr+3C\":[\"Mínimo de usuarios\"],\"Spnlre\":[\"Has invitado a \",[\"target\"],\" a unirse a \",[\"channel\"]],\"T/ckN5\":[\"Abrir en el visor\"],\"T91vKp\":[\"Reproducir\"],\"TV2Wdu\":[\"Conoce cómo gestionamos tus datos y protegemos tu privacidad.\"],\"TgFpwD\":[\"Aplicando...\"],\"TkzSFB\":[\"Sin cambios\"],\"TtserG\":[\"Ingresa el nombre real\"],\"Ttz9J1\":[\"Ingresa contraseña...\"],\"Tz0i8g\":[\"Ajustes\"],\"U3pytU\":[\"Admin\"],\"UDb2YD\":[\"Reaccionar\"],\"UE4KO5\":[\"*channel*\"],\"UGT5vp\":[\"Guardar configuración\"],\"UV5hLB\":[\"No se encontraron bans\"],\"Uaj3Nd\":[\"Mensajes de estado\"],\"Ue3uny\":[\"Predeterminado (sin perfil)\"],\"UkARhe\":[\"Normal – Protección estándar\"],\"Umn7Cj\":[\"Aún no hay comentarios. ¡Sé el primero!\"],\"UtUIRh\":[[\"0\"],\" mensajes anteriores\"],\"UwzP+U\":[\"Conexión segura\"],\"V0/A4O\":[\"Propietario del canal\"],\"V4qgxE\":[\"Creado hace menos de (min)\"],\"V8yTm6\":[\"Limpiar búsqueda\"],\"VJMMyz\":[\"ObsidianIRC - Llevando IRC al futuro\"],\"VJScHU\":[\"Motivo\"],\"VLsmVV\":[\"Silenciar notificaciones\"],\"VbyRUy\":[\"Comentarios\"],\"Vmx0mQ\":[\"Establecido por:\"],\"VqnIZz\":[\"Ver nuestra política de privacidad y prácticas de datos\"],\"VrMygG\":[\"La longitud mínima es \",[\"0\"]],\"VrnTui\":[\"Tus pronombres, mostrados en tu perfil\"],\"W8E3qn\":[\"Cuenta autenticada\"],\"WAakm9\":[\"Eliminar canal\"],\"WFxTHC\":[\"Agregar máscara de ban (p. ej., nick!*@*, *!*@host.com)\"],\"WN1g9F\":[\"El host del servidor es obligatorio\"],\"WRYdXW\":[\"Posición del audio\"],\"WUOH5B\":[\"Ignorar usuario\"],\"WWEXnZ\":[[\"0\",\"plural\",{\"one\":[\"Mostrar 1 elemento más\"],\"other\":[\"Mostrar \",[\"1\"],\" elementos más\"]}]],\"Weq9zb\":[\"General\"],\"Wfj7Sk\":[\"Silenciar o activar los sonidos de notificación\"],\"Wm7gbG\":[\"GitHub:\"],\"WyeHWY\":[\"*spam*\"],\"WzMCru\":[\"Perfil de usuario\"],\"X6S3lt\":[\"Buscar ajustes, canales, servidores...\"],\"XEHan5\":[\"Continuar de todos modos\"],\"XI1+wb\":[\"Formato no válido\"],\"XIXeuC\":[\"Mensaje a @\",[\"0\"]],\"XMS+k4\":[\"Iniciar mensaje privado\"],\"XWgxXq\":[\"Álbum\"],\"Xd7+IT\":[\"Desfijar chat privado\"],\"Xm/s+u\":[\"Pantalla\"],\"Xp2n93\":[\"Muestra medios desde el host de archivos de confianza de tu servidor. No se realizan solicitudes a servicios externos.\"],\"XvjC4F\":[\"Guardando...\"],\"Y/qryO\":[\"No se encontraron usuarios que coincidan con tu búsqueda\"],\"YAqRpI\":[\"Registro de cuenta exitoso para \",[\"account\"],\": \",[\"message\"]],\"YEfzvP\":[\"Tema protegido (+t)\"],\"YQOn6a\":[\"Contraer lista de miembros\"],\"YRCoE9\":[\"Operador del canal\"],\"YURQaF\":[\"Ver perfil\"],\"YdBSvr\":[\"Controlar la visualización de medios y contenido externo\"],\"Yj6U3V\":[\"Sin servidor central:\"],\"YjvpGx\":[\"Pronombres\"],\"YqH4l4\":[\"Sin clave\"],\"YyUPpV\":[\"Cuenta:\"],\"ZJSWfw\":[\"Mensaje al desconectarse del servidor\"],\"ZR1dJ4\":[\"Invitaciones\"],\"ZdWg0V\":[\"Abrir en el navegador\"],\"ZhRBbl\":[\"Buscar mensajes…\"],\"Zmcu3y\":[\"Filtros avanzados\"],\"a2/8e5\":[\"Tema establecido hace más de (min)\"],\"aHKcKc\":[\"Página anterior\"],\"aJTbXX\":[\"Contraseña de oper\"],\"aQryQv\":[\"El patrón ya existe\"],\"aW9pLN\":[\"Número máximo de usuarios permitidos en el canal. Deja vacío para no establecer límite.\"],\"ah4fmZ\":[\"También muestra vistas previas de YouTube, Vimeo, SoundCloud y servicios conocidos similares.\"],\"aifXak\":[\"No hay medios en este canal\"],\"ap2zBz\":[\"Relajado\"],\"az8lvo\":[\"Desactivado\"],\"azXSNo\":[\"Expandir lista de miembros\"],\"azdliB\":[\"Iniciar sesión en una cuenta\"],\"b26wlF\":[\"ella/la\"],\"bD/+Ei\":[\"Estricto\"],\"bQ6BJn\":[\"Configura reglas detalladas de protección contra flood. Cada regla especifica qué tipo de actividad monitorear y qué acción tomar cuando se superan los umbrales.\"],\"beV7+y\":[\"El usuario recibirá una invitación para unirse a \",[\"channelName\"],\".\"],\"bk84cH\":[\"Mensaje de ausencia\"],\"bkHdLj\":[\"Agregar servidor IRC\"],\"bmQLn5\":[\"Añadir regla\"],\"bv4cFj\":[\"Transporte\"],\"bwRvnp\":[\"Acción\"],\"c8+EVZ\":[\"Cuenta verificada\"],\"cGYUlD\":[\"No se carga ninguna vista previa de medios.\"],\"cLF98o\":[\"Mostrar comentarios (\",[\"commentCount\"],\")\"],\"cLKIDO\":[\"No hay usuarios disponibles\"],\"cSgpoS\":[\"Fijar chat privado\"],\"cde3ce\":[\"Mensaje a <0>\",[\"0\"],\"\"],\"chQsxg\":[\"Copiar salida formateada\"],\"cl/A5J\":[\"¡Bienvenido a \",[\"__DEFAULT_IRC_SERVER_NAME__\"],\"!\"],\"cnGeoo\":[\"Eliminar\"],\"coPLXT\":[\"No almacenamos tus comunicaciones IRC en nuestros servidores\"],\"crYH/6\":[\"Reproductor de SoundCloud\"],\"cv5DQb\":[\"sin host configurado\"],\"d3sis4\":[\"Agregar servidor\"],\"d9aN5k\":[\"Eliminar a \",[\"username\"],\" del canal\"],\"dEgA5A\":[\"Cancelar\"],\"dGi1We\":[\"Desfijar esta conversación de mensaje privado\"],\"dJVuyC\":[\"salió de \",[\"channelName\"],\" (\",[\"reason\"],\")\"],\"dMtLDE\":[\"a\"],\"dXqxlh\":[\"<0>⚠️ ¡Riesgo de seguridad! Esta conexión puede ser vulnerable a intercepciones o ataques de intermediario.\"],\"da9Q/R\":[\"Modos del canal cambiados\"],\"dhJN3N\":[\"Mostrar comentarios\"],\"dj2xTE\":[\"Descartar notificación\"],\"dpCzmC\":[\"Configuración de protección contra flood\"],\"e9dQpT\":[\"¿Deseas abrir este enlace en una nueva pestaña?\"],\"ePK91l\":[\"Editar\"],\"eYBDuB\":[\"Sube una imagen o proporciona una URL con sustitución opcional de \",[\"size\"],\" para tamaño dinámico\"],\"edBbee\":[\"Banear a \",[\"username\"],\" por hostmask (impide que vuelva a unirse desde la misma IP/host)\"],\"ekfzWq\":[\"Configuración de usuario\"],\"elPDWs\":[\"Personaliza tu experiencia con el cliente IRC\"],\"eu2osY\":[\"<0>💡 Recomendación: Continúa solo si confías en este servidor y entiendes los riesgos. Evita compartir información sensible o contraseñas a través de esta conexión.\"],\"euEhbr\":[\"Haz clic para unirte a \",[\"channel\"]],\"ez3vLd\":[\"Activar entrada multilínea\"],\"f0J5Ki\":[\"La comunicación entre servidores puede usar conexiones sin cifrar\"],\"f9BHJk\":[\"Advertir al usuario\"],\"fDOLLd\":[\"No se encontraron canales.\"],\"ffzDkB\":[\"Analíticas anónimas:\"],\"fq1GF9\":[\"Mostrar cuando los usuarios se desconectan del servidor\"],\"gEF57C\":[\"Este servidor solo admite un tipo de conexión\"],\"gJuLUI\":[\"Lista de ignorados\"],\"gNzMrk\":[\"Avatar actual\"],\"gjPWyO\":[\"Ingresa tu apodo...\"],\"gz6UQ3\":[\"Maximizar\"],\"h6/IMX\":[\"Agrega tu primera red\"],\"h6razj\":[\"Excluir máscara de nombre de canal\"],\"hG6jnw\":[\"Sin tema establecido\"],\"hG89Ed\":[\"Imagen\"],\"hZ6znB\":[\"Puerto\"],\"ha+Bz5\":[\"ej., 100:1440\"],\"hehnjM\":[\"Cantidad\"],\"hzdLuQ\":[\"Solo los usuarios con Voice o superior pueden hablar\"],\"i0qMbr\":[\"Inicio\"],\"iDNBZe\":[\"Notificaciones\"],\"iH8pgl\":[\"Atrás\"],\"iL9SZg\":[\"Banear usuario (por apodo)\"],\"iNt+3c\":[\"Volver a la imagen\"],\"iQvi+a\":[\"No advertirme sobre la baja seguridad de enlaces para este servidor\"],\"iSLIjg\":[\"Conectar\"],\"iWXkHH\":[\"Halfop\"],\"iZeTtp\":[\"Host del servidor\"],\"idD8Ev\":[\"Guardado\"],\"iivqkW\":[\"Conectado desde\"],\"ij+Elv\":[\"Vista previa de imagen\"],\"ilIWp7\":[\"Alternar notificaciones\"],\"iuaqvB\":[\"Usa * como comodín. Ejemplos: baduser!*@*, *!*@spammer.com, troll*!*@*\"],\"ixkTse\":[\"Bot\"],\"j2DGR0\":[\"Banear por hostmask\"],\"jA4uoI\":[\"Tema:\"],\"jLXxGK\":[\"https://example.com\"],\"jPSk57\":[\"Motivo (opcional)\"],\"jUV7CU\":[\"Subir avatar\"],\"jW5Uwh\":[\"Controla cuántos medios externos se cargan. Desactivado / Seguro / Fuentes confiables / Todo el contenido.\"],\"jXzms5\":[\"Opciones de adjunto\"],\"jZlrte\":[\"Color\"],\"jfC/xh\":[\"Contacto\"],\"jywMpv\":[\"#new-channel-name\"],\"k112DD\":[\"Cargar mensajes anteriores\"],\"k3ID0F\":[\"Filtrar miembros…\"],\"k65gsE\":[\"Ver en detalle\"],\"k7Zgob\":[\"Cancelar conexión\"],\"kAVx5h\":[\"No se encontraron invitaciones\"],\"kCLEPU\":[\"Conectado a\"],\"kF5LKb\":[\"Patrones ignorados:\"],\"kGeOx/\":[\"Unirse a \",[\"0\"]],\"kITKr8\":[\"Cargando modos del canal...\"],\"kPpPsw\":[\"Eres un IRC Operator\"],\"kWJmRL\":[\"Tú\"],\"kfcRb0\":[\"Avatar\"],\"kjMqSj\":[\"Copiar JSON\"],\"krViRy\":[\"Clic para copiar como JSON\"],\"ks71ra\":[\"Excepciones\"],\"kw4lRv\":[\"Medio operador del canal\"],\"kxgIRq\":[\"Selecciona o agrega un canal para comenzar.\"],\"ky6dWe\":[\"Vista previa del avatar\"],\"l+GxCv\":[\"Cargando canales...\"],\"l+IUVW\":[\"Verificación de cuenta exitosa para \",[\"account\"],\": \",[\"message\"]],\"l/siQz\":[[\"reconnectCount\",\"plural\",{\"one\":[\"se reconectó\"],\"other\":[\"se reconectó \",[\"reconnectCount\"],\" veces\"]}]],\"l5jmzx\":[[\"0\"],\" y \",[\"1\"],\" están escribiendo...\"],\"lHy8N5\":[\"Cargando más canales...\"],\"lbpf14\":[\"Unirse a \",[\"value\"]],\"lfFsZ4\":[\"Canales\"],\"lkNdiH\":[\"Nombre de cuenta\"],\"ln500L\":[\"ObsidianIRC\"],\"lnCMdg\":[\"Subir imagen\"],\"loQxaJ\":[\"He vuelto\"],\"lvfaxv\":[\"INICIO\"],\"m0oxpP\":[\"Libera Chat\"],\"m16xKo\":[\"Agregar\"],\"m8flAk\":[\"Vista previa (aún no subido)\"],\"mEPxTp\":[\"<0>⚠️ ¡Ten cuidado! Solo abre enlaces de fuentes de confianza. Los enlaces maliciosos pueden comprometer tu seguridad o privacidad.\"],\"mHGdhG\":[\"Información del servidor\"],\"mHS8lb\":[\"Mensaje en #\",[\"0\"]],\"mMYBD9\":[\"Amplio – Ámbito de protección más amplio\"],\"mTGsPd\":[\"Tema del canal\"],\"mU8j6O\":[\"Sin mensajes externos (+n)\"],\"mZp8FL\":[\"Retorno automático a línea única\"],\"mdQu8G\":[\"TuApodo\"],\"miSSBQ\":[\"Comentarios (\",[\"commentCount\"],\")\"],\"mvyLSy\":[\"El usuario está autenticado\"],\"mwtcGl\":[\"Cerrar comentarios\"],\"myL0MR\":[\"¿Eliminar esta red?\"],\"mzI/c+\":[\"Descargar\"],\"n3fGRk\":[\"establecido por \",[\"0\"]],\"nE9jsU\":[\"Relajado – Protección menos agresiva\"],\"nNflMD\":[\"Salir del canal\"],\"nPXkBi\":[\"Cargando datos WHOIS...\"],\"nQnxxF\":[\"Mensaje en #\",[\"0\"],\" (Mayús+Intro para nueva línea)\"],\"nWMRxa\":[\"Desfijar\"],\"nkC032\":[\"Sin perfil de flood\"],\"o69z4d\":[\"Enviar un mensaje de advertencia a \",[\"username\"]],\"o9ylQi\":[\"Busca GIFs para empezar\"],\"oFGkER\":[\"Avisos del servidor\"],\"oOi11l\":[\"Ir al final\"],\"oQEzQR\":[\"Nuevo mensaje directo\"],\"oXOSPE\":[\"En línea\"],\"oal760\":[\"Son posibles ataques de intermediario en los enlaces del servidor\"],\"oeqmmJ\":[\"Fuentes de confianza\"],\"ovBPCi\":[\"Predeterminado\"],\"p0Z69r\":[\"El patrón no puede estar vacío\"],\"p1KgtK\":[\"Error al cargar el audio\"],\"p59pEv\":[\"Detalles adicionales\"],\"p7sRI6\":[\"Avisar a otros cuando estás escribiendo\"],\"pBm1od\":[\"Canal secreto\"],\"pNmiXx\":[\"Tu apodo predeterminado para todos los servidores\"],\"pUUo9G\":[\"Hostname:\"],\"pVGPmz\":[\"Contraseña de la cuenta\"],\"peNE68\":[\"Permanente\"],\"plhHQt\":[\"Sin datos\"],\"pm6+q5\":[\"Advertencia de seguridad\"],\"pn5qSs\":[\"Información adicional\"],\"q0cR4S\":[\"ahora se llama **\",[\"newNick\"],\"**\"],\"qFcunY\":[\"El canal no aparecerá en los comandos LIST ni NAMES\"],\"qLpTm/\":[\"Eliminar reacción \",[\"emoji\"]],\"qVkGWK\":[\"Fijar\"],\"qY8wNa\":[\"Página de inicio\"],\"qb0xJ7\":[\"Usa comodines: * coincide con cualquier secuencia, ? coincide con cualquier carácter individual. Ejemplos: nick!*@*, *!*@host.com, *!*user@*\"],\"qhzpRq\":[\"Clave del canal (+k)\"],\"qtoOYG\":[\"Sin límite\"],\"r1W2AS\":[\"Imagen del servidor de archivos\"],\"rIPR2O\":[\"Tema establecido hace menos de (min)\"],\"rMMSYo\":[\"La longitud máxima es \",[\"0\"]],\"rWtzQe\":[\"La red se dividió y se reconectó. ✅\"],\"rYG2u6\":[\"Por favor espera...\"],\"rdUucN\":[\"Vista previa\"],\"rjGI/Q\":[\"Privacidad\"],\"rk8iDX\":[\"Cargando GIFs...\"],\"rn6SBY\":[\"Activar sonido\"],\"s/UKqq\":[\"Fue expulsado del canal\"],\"s8cATI\":[\"se unió a \",[\"channelName\"]],\"sCO9ue\":[\"La conexión a <0>\",[\"serverName\"],\" presenta los siguientes problemas de seguridad:\"],\"sGH11W\":[\"Servidor\"],\"sHI1H+\":[\"ahora se llama **\",[\"newNick\"],\"**\"],\"sJyV04\":[[\"inviter\"],\" te ha invitado a unirte a \",[\"channel\"]],\"sUBSbK\":[\"Aún no hay redes ascendentes.\"],\"sby+1/\":[\"Haz clic para copiar\"],\"sfN25C\":[\"Tu nombre real o completo\"],\"sliuzR\":[\"Abrir enlace\"],\"sqrO9R\":[\"Menciones personalizadas\"],\"sr6RdJ\":[\"Multilínea con Shift+Enter\"],\"swrCpB\":[\"El canal ha sido renombrado de \",[\"oldName\"],\" a \",[\"newName\"],\" por \",[\"user\"],[\"0\"]],\"sxkWRg\":[\"Avanzado\"],\"t/YqKh\":[\"Eliminar\"],\"t47eHD\":[\"Tu identificador único en este servidor\"],\"tAkAh0\":[\"URL con sustitución opcional de \",[\"size\"],\" para tamaño dinámico. Ejemplo: https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"tXLJS3\":[\"Mostrar u ocultar la barra lateral de canales\"],\"tfDRzk\":[\"Guardar\"],\"tiBsJk\":[\"salió de \",[\"channelName\"]],\"tt4/UD\":[\"salió (\",[\"reason\"],\")\"],\"u0TcnO\":[\"El apodo {nick} ya está en uso, reintentando con {newNick}\"],\"u0a8B4\":[\"Autenticarse como operador IRC para acceso administrativo\"],\"u0rWFU\":[\"Creado hace más de (min)\"],\"u72w3t\":[\"Usuarios y patrones a ignorar\"],\"u7jc2L\":[\"salió\"],\"uAQUqI\":[\"Estado\"],\"uB85T3\":[\"Error al guardar: \",[\"msg\"]],\"uV3DOL\":[\"G-Line\"],\"uW3lLI\":[\"Servidores IRC:\"],\"usSSr/\":[\"Nivel de zoom\"],\"v7uvcf\":[\"Software:\"],\"vE8kb+\":[\"Shift+Enter para nuevas líneas (Enter envía)\"],\"vERlcd\":[\"Perfil\"],\"vK0RL8\":[\"Sin tema\"],\"vSJd18\":[\"Video\"],\"vXIe7J\":[\"Idioma\"],\"vaHYxN\":[\"Nombre real\"],\"vhjbKr\":[\"Ausente\"],\"w/nogd\":[[\"0\"],\" network\",[\"1\"],\" — pick one to join\"],\"w4NYox\":[\"cliente \",[\"title\"]],\"w8xQRx\":[\"Valor no válido\"],\"wFjjxZ\":[\"fue expulsado de \",[\"channelName\"],\" por \",[\"username\"],\" (\",[\"reason\"],\")\"],\"wGjaGl\":[\"No se encontraron excepciones de ban\"],\"wPrGnM\":[\"Administrador del canal\"],\"wRkP2d\":[\"GIF\"],\"wbm86v\":[\"Mostrar cuando los usuarios entran o salen de canales\"],\"whqZ9r\":[\"Palabras o frases adicionales para resaltar\"],\"wm7RV4\":[\"Sonido de notificación\"],\"wz/Yoq\":[\"Tus mensajes podrían ser interceptados al retransmitirse entre servidores\"],\"xCJdfg\":[\"Limpiar\"],\"xUHRTR\":[\"Autenticarse automáticamente como operador al conectar\"],\"xWHwwQ\":[\"Bans\"],\"xYilR2\":[\"Medios\"],\"xceQrO\":[\"Solo se admiten websockets seguros\"],\"xdtXa+\":[\"nombre-del-canal\"],\"xfXC7q\":[\"Canales de texto\"],\"xlCYOE\":[\"Cargando más mensajes...\"],\"xlhswE\":[\"El valor mínimo es \",[\"0\"]],\"xq97Ci\":[\"Agregar una palabra o frase...\"],\"xuRqRq\":[\"Límite de usuarios (+l)\"],\"xwF+7J\":[[\"0\"],\" está escribiendo...\"],\"yJztBY\":[\"Eliminar red\"],\"yNeucF\":[\"Este servidor no admite metadatos de perfil extendido (extensión IRCv3 METADATA). Los campos adicionales como avatar, nombre a mostrar y estado no están disponibles.\"],\"yPlrca\":[\"Avatar del canal\"],\"yQE2r9\":[\"Cargando\"],\"ySU+JY\":[\"tu@correo.com\"],\"yTX1Rt\":[\"Nombre de usuario Oper\"],\"yYOzWD\":[\"registros\"],\"yfx9Re\":[\"Contraseña de operador IRC\"],\"ygCKqB\":[\"Detener\"],\"ymDxJx\":[\"Nombre de usuario de operador IRC\"],\"yrpRsQ\":[\"Ordenar por nombre\"],\"yz7wBu\":[\"Cerrar\"],\"zJw+jA\":[\"establece modo: \",[\"0\"]],\"zebeLu\":[\"Ingresa el nombre de usuario de oper\"],\"zpr0Bw\":[\"GZ-Line\"]}"); \ No newline at end of file diff --git a/src/locales/es/messages.po b/src/locales/es/messages.po index f6a98cbb..cd4208b5 100644 --- a/src/locales/es/messages.po +++ b/src/locales/es/messages.po @@ -22,6 +22,16 @@ msgstr "ObsidianIRC - Llevando IRC al futuro" msgid "— open in viewer" msgstr "— abrir en visor" +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx +msgid "(inherit)" +msgstr "(heredar)" + +#: src/components/ui/BouncerNetworkForm.tsx +msgid "(unchanged)" +msgstr "(sin cambios)" + #. placeholder {0}: filteredMessages.length #. placeholder {1}: import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import type * as React from "react"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { SCROLL_TOLERANCE, useScrollToBottom, } from "../../hooks/useScrollToBottom"; import { groupConsecutiveEvents } from "../../lib/eventGrouping"; import ircClient from "../../lib/ircClient"; import useStore from "../../store"; import type { Message as MessageType } from "../../types"; import { CollapsedEventMessage } from "../message/CollapsedEventMessage"; import { MessageItem } from "../message/MessageItem"; import LoadingSpinner from "../ui/LoadingSpinner"; import { ScrollToBottomButton } from "../ui/ScrollToBottomButton"; export const DEFAULT_VISIBLE_MESSAGE_COUNT = 100; // Stable empty array — prevents selector from returning a new [] on every render // when the channel has no messages yet (undefined ?? [] would create a new ref each time). const EMPTY_MESSAGES: import("../../types").Message[] = []; export interface ChannelMessageListHandle { setAtBottom: () => void; scrollToBottom: () => void; getScrollState: () => { scrollTop: number; isAtBottom: boolean; visibleCount: number; }; } interface ChannelMessageListProps { channelKey: string; serverId: string; channelId: string | null; privateChatId: string | null; isActive: boolean; searchQuery: string; isMemberListVisible: boolean; onReply: (msg: MessageType | null) => void; onUsernameContextMenu: ( e: React.MouseEvent, username: string, serverId: string, channelId: string, avatarEl?: Element | null, ) => void; onIrcLinkClick: (url: string) => void; onReactClick: (msg: MessageType, el: Element) => void; onReactionUnreact: (emoji: string, msg: MessageType) => void; onOpenReactionModal: ( msg: MessageType, position: { x: number; y: number }, ) => void; onDirectReaction: (emoji: string, msg: MessageType) => void; onRedactMessage: (msg: MessageType) => void; onOpenProfile: (username: string) => void; joinChannel: (serverId: string, channelName: string) => void; onClearSearch: () => void; highlightedMessageId?: string; // undefined = first visit; null = was at bottom; object = restore to saved position initialScrollState?: { scrollTop: number; visibleCount: number } | null; } export const ChannelMessageList = forwardRef< ChannelMessageListHandle, ChannelMessageListProps >( ( { channelKey, serverId, channelId, privateChatId, isActive, searchQuery, isMemberListVisible, onReply, onUsernameContextMenu, onIrcLinkClick, onReactClick, onReactionUnreact, onOpenReactionModal, onDirectReaction, onRedactMessage, onOpenProfile, joinChannel, onClearSearch, highlightedMessageId, initialScrollState, }, ref, ) => { const { t } = useLingui(); const [visibleMessageCount, setVisibleMessageCount] = useState( initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT, ); // Ref mirror so getScrollState closure always reads the current value without needing it as a dep. const visibleMessageCountRef = useRef(visibleMessageCount); visibleMessageCountRef.current = visibleMessageCount; // Distinguishes initial join (full-screen spinner) from subsequent "load more" (button spinner). const [isFetchingMore, setIsFetchingMore] = useState(false); const isFetchingMoreRef = useRef(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const messagesInnerRef = useRef(null); // prev scrollHeight for prepend delta-correction. const prevScrollHeightRef = useRef(0); // Ref mirror of isScrolledUp — lets useLayoutEffect closures read current value // without listing isScrolledUp as a dep (which would re-run effects on every scroll). const isScrolledUpRef = useRef(false); const prevFilteredLengthRef = useRef(0); const prevFirstMsgIdRef = useRef(null); // Set by the window-growth layoutEffect (or button handler) when a true prepend is detected. // Consumed by the delta-correction layoutEffect one render later (after visibleCount grows). // Using a flag instead of tracking displayedMessages[0]?.id because slice(-N) slides the // window on every bottom append, changing displayedMessages[0] even for non-prepend renders. const pendingPrependRef = useRef(false); // Shared scrollHeight baseline between the delta-correction layout effect and the inner // ResizeObserver. When scrollTop is corrected after a prepend, we update this so the RO's // "was at bottom" check is not fooled by the adjusted scrollTop vs its stale prevSH. const resizeObserverPrevSHRef = useRef(0); const channelMessages = useStore( useCallback( (state) => state.messages[channelKey] ?? EMPTY_MESSAGES, [channelKey], ), ); const servers = useStore((state) => state.servers); const mobileViewActiveColumn = useStore( (state) => state.ui.mobileViewActiveColumn, ); const channel = useMemo( () => channelId ? (servers .find((s) => s.id === serverId) ?.channels.find((c) => c.id === channelId) ?? null) : null, [servers, serverId, channelId], ); const { isScrolledUp, wasAtBottomRef, scrollToBottom } = useScrollToBottom( messagesContainerRef, messagesEndRef, { channelId: `${channelId || privateChatId}-${isMemberListVisible}` }, ); // Snapshot of the last known scroll position captured while the container was visible. // getScrollState() reads this instead of the live DOM because React commits display:none // before running cleanup effects, collapsing scrollTop/scrollHeight/clientHeight to 0. const lastScrollTopRef = useRef(initialScrollState?.scrollTop ?? 0); useEffect(() => { const container = messagesContainerRef.current; if (!container) return; const onScroll = () => { if (container.clientHeight > 0) lastScrollTopRef.current = container.scrollTop; }; container.addEventListener("scroll", onScroll, { passive: true }); return () => container.removeEventListener("scroll", onScroll); }, []); // Restore scroll position when a keep-alive channel transitions from hidden to visible. // display:none may reset scrollTop to 0; lastScrollTopRef was captured while visible. const prevActiveRef = useRef(isActive); useLayoutEffect(() => { if (isActive && !prevActiveRef.current) { const container = messagesContainerRef.current; if (container && lastScrollTopRef.current > 0) { container.scrollTop = lastScrollTopRef.current; } } prevActiveRef.current = isActive; }, [isActive]); useImperativeHandle(ref, () => ({ setAtBottom: () => { wasAtBottomRef.current = true; }, scrollToBottom, getScrollState: () => ({ scrollTop: lastScrollTopRef.current, isAtBottom: wasAtBottomRef.current, visibleCount: visibleMessageCountRef.current, }), })); const filteredMessages = useMemo(() => { if (!searchQuery.trim()) return channelMessages; const query = searchQuery.toLowerCase(); return channelMessages.filter( (msg) => msg.content.toLowerCase().includes(query) || msg.userId.toLowerCase().includes(query), ); }, [channelMessages, searchQuery]); useEffect(() => { isScrolledUpRef.current = isScrolledUp; // When the user returns to the bottom, shrink the window back to the base so // slice(-N) resumes trimming old messages from the top (memory optimization). // Only shrink if we grew above the base — preserves a sub-default saved visibleCount. if (!isScrolledUp) { setVisibleMessageCount((prev) => prev > DEFAULT_VISIBLE_MESSAGE_COUNT ? DEFAULT_VISIBLE_MESSAGE_COUNT : prev, ); } }, [isScrolledUp]); // Reset ref-tracked windowing state when switching channels. // visibleMessageCount is NOT reset here — useState(initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT) // already initializes it correctly on mount, and this effect runs once on mount for the // same channelKey (each instance is bound to exactly one channel by the parent key={}). // biome-ignore lint/correctness/useExhaustiveDependencies: intentional full reset on channel change useEffect(() => { prevFilteredLengthRef.current = 0; prevFirstMsgIdRef.current = null; prevScrollHeightRef.current = 0; pendingPrependRef.current = false; resizeObserverPrevSHRef.current = 0; }, [channelKey]); const displayedMessages = useMemo(() => { if (searchQuery.trim()) return filteredMessages; return filteredMessages.slice(-visibleMessageCount); }, [filteredMessages, visibleMessageCount, searchQuery]); const locallyHidden = filteredMessages.length > displayedMessages.length; const serverHasMore = channel?.hasMoreHistory === true; const hasMoreMessages = locallyHidden || serverHasMore; const eventGroups = useMemo( () => groupConsecutiveEvents(displayedMessages), [displayedMessages], ); const isLoadingHistory = channel?.isLoadingHistory ?? false; // Scroll to bottom on initial mount, unless a saved position was passed in. // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount only useEffect(() => { const container = messagesContainerRef.current; if (!container) return; if (initialScrollState) { container.scrollTop = initialScrollState.scrollTop; lastScrollTopRef.current = initialScrollState.scrollTop; wasAtBottomRef.current = false; } else { container.scrollTop = container.scrollHeight; lastScrollTopRef.current = container.scrollHeight; wasAtBottomRef.current = true; } }, []); // Scroll to bottom after initial join history loads; clear fetch spinner at batch end. const wasLoadingHistoryRef = useRef(false); // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is stable via useCallback; refs and setters are stable useLayoutEffect(() => { if (wasLoadingHistoryRef.current && !isLoadingHistory) { if (isFetchingMoreRef.current) { // delta correction for scroll position is handled by useLayoutEffect([displayedMessages]) isFetchingMoreRef.current = false; setIsFetchingMore(false); } else { scrollToBottom(); wasAtBottomRef.current = true; } } wasLoadingHistoryRef.current = isLoadingHistory; }, [isLoadingHistory]); // When older messages are prepended, grow the window so they enter displayedMessages. // When new messages arrive at the bottom while the user is scrolled up, also grow the // window to keep the current top messages visible — slice(-N) otherwise slides the // window forward and hides them, incrementing the "N older messages" counter on every // incoming message. Only let the slice trim from the top when the user is at the bottom // (where auto-scroll handles keeping them current). useLayoutEffect(() => { const newLength = filteredMessages.length; const newFirstId = filteredMessages[0]?.id ?? null; const delta = newLength - prevFilteredLengthRef.current; if (prevFilteredLengthRef.current > 0 && delta > 0) { if (newFirstId !== prevFirstMsgIdRef.current) { // Messages prepended (load-more): signal delta-correction to compensate scrollTop. pendingPrependRef.current = true; setVisibleMessageCount((prev) => prev + delta); } else if (isScrolledUpRef.current) { // Messages appended at bottom while user is scrolled up reading history. // Expand the window to prevent top messages from dropping out of the slice. setVisibleMessageCount((prev) => prev + delta); } } prevFilteredLengthRef.current = newLength; prevFirstMsgIdRef.current = newFirstId; }, [filteredMessages]); // Compensate scrollTop when content is prepended above the viewport. // biome-ignore lint/correctness/useExhaustiveDependencies: runs on every displayedMessages render to capture the resulting scrollHeight; refs are stable useLayoutEffect(() => { const container = messagesContainerRef.current; if (!container) return; // Skip while container is display:none — scrollHeight collapses to 0 and would // poison prevScrollHeightRef, causing a huge spurious delta on the next visible render. if (container.clientHeight === 0) return; const prevHeight = prevScrollHeightRef.current; const newHeight = container.scrollHeight; // Only correct when a true load-more prepend happened (flag set by the window-growth // layoutEffect or button handler). Bottom appends slide the slice(-N) window which also // changes displayedMessages[0] — ID-comparison can't distinguish the two cases. const wasPrepend = pendingPrependRef.current; // Only consume the flag when scrollHeight actually changed — the server-side load-more // path goes through two renders: Render A (filteredMessages grows, visibleCount unchanged, // same displayedMessages content, same scrollHeight) then Render B (visibleCount grows, // new messages enter displayedMessages, scrollHeight grows). The flag must survive Render A // so it's still set when Render B fires the actual correction. if (wasPrepend && newHeight !== prevHeight) { pendingPrependRef.current = false; } if ( isScrolledUpRef.current && prevHeight > 0 && newHeight > prevHeight && wasPrepend ) { const delta = newHeight - prevHeight; container.scrollTop += delta; resizeObserverPrevSHRef.current = newHeight; } prevScrollHeightRef.current = newHeight; }, [displayedMessages]); // Re-stick to bottom when inner message content grows (media/audio previews loading). // Uses prevScrollHeight instead of wasAtBottomRef to avoid stale-flag race where the // ref is true while the user is actively scrolling up. // When the container width changes (member list toggle, window resize), text reflows // and scrollHeight changes; preserve proportional scroll position for scrolled-up users. // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is a stable ref useEffect(() => { const container = messagesContainerRef.current; const inner = messagesInnerRef.current; if (!inner || !container) return; resizeObserverPrevSHRef.current = container.scrollHeight; let prevClientWidth = container.clientWidth; const observer = new ResizeObserver(() => { if (container.clientHeight === 0) return; // Effect may re-initialize while container is display:none (ref=0). // Re-seed with current dimensions and skip — no reliable "was at bottom" data. if (resizeObserverPrevSHRef.current === 0) { resizeObserverPrevSHRef.current = container.scrollHeight; prevClientWidth = container.clientWidth; return; } const currentClientWidth = container.clientWidth; const widthChanged = currentClientWidth !== prevClientWidth; prevClientWidth = currentClientWidth; const prevSH = resizeObserverPrevSHRef.current; const wasAtPrevBottom = container.scrollTop + container.clientHeight >= prevSH - SCROLL_TOLERANCE; resizeObserverPrevSHRef.current = container.scrollHeight; if (wasAtPrevBottom) { scrollToBottom(); } else if (widthChanged && prevSH > 0) { const ratio = container.scrollTop / prevSH; container.scrollTop = Math.round(ratio * container.scrollHeight); } }); observer.observe(inner); return () => observer.disconnect(); }, [isLoadingHistory, channelId, privateChatId]); // Auto-scroll on new messages — skip when this channel is hidden (display:none). // biome-ignore lint/correctness/useExhaustiveDependencies: only scroll when messages change, not when isActive changes useEffect(() => { if (!isActive) return; const isNarrowView = window.matchMedia("(max-width: 768px)").matches; const isChatVisible = !isNarrowView || mobileViewActiveColumn === "chatView"; if (wasAtBottomRef.current && isChatVisible) { scrollToBottom(); } }, [displayedMessages, mobileViewActiveColumn, scrollToBottom, isActive]); return ( <>
{isLoadingHistory && !isFetchingMore ? (
) : (
{hasMoreMessages && !searchQuery && (
)} {searchQuery && (
{plural(filteredMessages.length, { one: t`Found 1 message matching "${searchQuery}"`, other: t`Found ${filteredMessages.length} messages matching "${searchQuery}"`, })}
)} {eventGroups.map((group) => { if (group.type === "eventGroup") { const firstId = group.messages[0]?.id || ""; const lastId = group.messages[group.messages.length - 1]?.id || ""; const groupKey = `group-${firstId}-${lastId}`; return ( ); } const message = group.messages[0]; const originalIndex = channelMessages.findIndex( (m) => m.id === message.id, ); const previousMessage = channelMessages[originalIndex - 1]; const showHeader = !previousMessage || previousMessage.type !== "message" || previousMessage.userId !== message.userId || new Date(message.timestamp).getTime() - new Date(previousMessage.timestamp).getTime() > 5 * 60 * 1000; return ( ); })}
)}
); }, ); ChannelMessageList.displayName = "ChannelMessageList"; // Wrap with memo so hidden keep-alive channels skip re-renders when their props // haven't changed (e.g. when messageText changes in the input — the only thing // that changes on typing is local state inside ChatArea, not the props we pass here). export const MemoChannelMessageList = memo(ChannelMessageList); #. placeholder {2}: import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import type * as React from "react"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { SCROLL_TOLERANCE, useScrollToBottom, } from "../../hooks/useScrollToBottom"; import { groupConsecutiveEvents } from "../../lib/eventGrouping"; import ircClient from "../../lib/ircClient"; import useStore from "../../store"; import type { Message as MessageType } from "../../types"; import { CollapsedEventMessage } from "../message/CollapsedEventMessage"; import { MessageItem } from "../message/MessageItem"; import LoadingSpinner from "../ui/LoadingSpinner"; import { ScrollToBottomButton } from "../ui/ScrollToBottomButton"; export const DEFAULT_VISIBLE_MESSAGE_COUNT = 100; // Stable empty array — prevents selector from returning a new [] on every render // when the channel has no messages yet (undefined ?? [] would create a new ref each time). const EMPTY_MESSAGES: import("../../types").Message[] = []; export interface ChannelMessageListHandle { setAtBottom: () => void; scrollToBottom: () => void; getScrollState: () => { scrollTop: number; isAtBottom: boolean; visibleCount: number; }; } interface ChannelMessageListProps { channelKey: string; serverId: string; channelId: string | null; privateChatId: string | null; isActive: boolean; searchQuery: string; isMemberListVisible: boolean; onReply: (msg: MessageType | null) => void; onUsernameContextMenu: ( e: React.MouseEvent, username: string, serverId: string, channelId: string, avatarEl?: Element | null, ) => void; onIrcLinkClick: (url: string) => void; onReactClick: (msg: MessageType, el: Element) => void; onReactionUnreact: (emoji: string, msg: MessageType) => void; onOpenReactionModal: ( msg: MessageType, position: { x: number; y: number }, ) => void; onDirectReaction: (emoji: string, msg: MessageType) => void; onRedactMessage: (msg: MessageType) => void; onOpenProfile: (username: string) => void; joinChannel: (serverId: string, channelName: string) => void; onClearSearch: () => void; highlightedMessageId?: string; // undefined = first visit; null = was at bottom; object = restore to saved position initialScrollState?: { scrollTop: number; visibleCount: number } | null; } export const ChannelMessageList = forwardRef< ChannelMessageListHandle, ChannelMessageListProps >( ( { channelKey, serverId, channelId, privateChatId, isActive, searchQuery, isMemberListVisible, onReply, onUsernameContextMenu, onIrcLinkClick, onReactClick, onReactionUnreact, onOpenReactionModal, onDirectReaction, onRedactMessage, onOpenProfile, joinChannel, onClearSearch, highlightedMessageId, initialScrollState, }, ref, ) => { const { t } = useLingui(); const [visibleMessageCount, setVisibleMessageCount] = useState( initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT, ); // Ref mirror so getScrollState closure always reads the current value without needing it as a dep. const visibleMessageCountRef = useRef(visibleMessageCount); visibleMessageCountRef.current = visibleMessageCount; // Distinguishes initial join (full-screen spinner) from subsequent "load more" (button spinner). const [isFetchingMore, setIsFetchingMore] = useState(false); const isFetchingMoreRef = useRef(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const messagesInnerRef = useRef(null); // prev scrollHeight for prepend delta-correction. const prevScrollHeightRef = useRef(0); // Ref mirror of isScrolledUp — lets useLayoutEffect closures read current value // without listing isScrolledUp as a dep (which would re-run effects on every scroll). const isScrolledUpRef = useRef(false); const prevFilteredLengthRef = useRef(0); const prevFirstMsgIdRef = useRef(null); // Set by the window-growth layoutEffect (or button handler) when a true prepend is detected. // Consumed by the delta-correction layoutEffect one render later (after visibleCount grows). // Using a flag instead of tracking displayedMessages[0]?.id because slice(-N) slides the // window on every bottom append, changing displayedMessages[0] even for non-prepend renders. const pendingPrependRef = useRef(false); // Shared scrollHeight baseline between the delta-correction layout effect and the inner // ResizeObserver. When scrollTop is corrected after a prepend, we update this so the RO's // "was at bottom" check is not fooled by the adjusted scrollTop vs its stale prevSH. const resizeObserverPrevSHRef = useRef(0); const channelMessages = useStore( useCallback( (state) => state.messages[channelKey] ?? EMPTY_MESSAGES, [channelKey], ), ); const servers = useStore((state) => state.servers); const mobileViewActiveColumn = useStore( (state) => state.ui.mobileViewActiveColumn, ); const channel = useMemo( () => channelId ? (servers .find((s) => s.id === serverId) ?.channels.find((c) => c.id === channelId) ?? null) : null, [servers, serverId, channelId], ); const { isScrolledUp, wasAtBottomRef, scrollToBottom } = useScrollToBottom( messagesContainerRef, messagesEndRef, { channelId: `${channelId || privateChatId}-${isMemberListVisible}` }, ); // Snapshot of the last known scroll position captured while the container was visible. // getScrollState() reads this instead of the live DOM because React commits display:none // before running cleanup effects, collapsing scrollTop/scrollHeight/clientHeight to 0. const lastScrollTopRef = useRef(initialScrollState?.scrollTop ?? 0); useEffect(() => { const container = messagesContainerRef.current; if (!container) return; const onScroll = () => { if (container.clientHeight > 0) lastScrollTopRef.current = container.scrollTop; }; container.addEventListener("scroll", onScroll, { passive: true }); return () => container.removeEventListener("scroll", onScroll); }, []); // Restore scroll position when a keep-alive channel transitions from hidden to visible. // display:none may reset scrollTop to 0; lastScrollTopRef was captured while visible. const prevActiveRef = useRef(isActive); useLayoutEffect(() => { if (isActive && !prevActiveRef.current) { const container = messagesContainerRef.current; if (container && lastScrollTopRef.current > 0) { container.scrollTop = lastScrollTopRef.current; } } prevActiveRef.current = isActive; }, [isActive]); useImperativeHandle(ref, () => ({ setAtBottom: () => { wasAtBottomRef.current = true; }, scrollToBottom, getScrollState: () => ({ scrollTop: lastScrollTopRef.current, isAtBottom: wasAtBottomRef.current, visibleCount: visibleMessageCountRef.current, }), })); const filteredMessages = useMemo(() => { if (!searchQuery.trim()) return channelMessages; const query = searchQuery.toLowerCase(); return channelMessages.filter( (msg) => msg.content.toLowerCase().includes(query) || msg.userId.toLowerCase().includes(query), ); }, [channelMessages, searchQuery]); useEffect(() => { isScrolledUpRef.current = isScrolledUp; // When the user returns to the bottom, shrink the window back to the base so // slice(-N) resumes trimming old messages from the top (memory optimization). // Only shrink if we grew above the base — preserves a sub-default saved visibleCount. if (!isScrolledUp) { setVisibleMessageCount((prev) => prev > DEFAULT_VISIBLE_MESSAGE_COUNT ? DEFAULT_VISIBLE_MESSAGE_COUNT : prev, ); } }, [isScrolledUp]); // Reset ref-tracked windowing state when switching channels. // visibleMessageCount is NOT reset here — useState(initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT) // already initializes it correctly on mount, and this effect runs once on mount for the // same channelKey (each instance is bound to exactly one channel by the parent key={}). // biome-ignore lint/correctness/useExhaustiveDependencies: intentional full reset on channel change useEffect(() => { prevFilteredLengthRef.current = 0; prevFirstMsgIdRef.current = null; prevScrollHeightRef.current = 0; pendingPrependRef.current = false; resizeObserverPrevSHRef.current = 0; }, [channelKey]); const displayedMessages = useMemo(() => { if (searchQuery.trim()) return filteredMessages; return filteredMessages.slice(-visibleMessageCount); }, [filteredMessages, visibleMessageCount, searchQuery]); const locallyHidden = filteredMessages.length > displayedMessages.length; const serverHasMore = channel?.hasMoreHistory === true; const hasMoreMessages = locallyHidden || serverHasMore; const eventGroups = useMemo( () => groupConsecutiveEvents(displayedMessages), [displayedMessages], ); const isLoadingHistory = channel?.isLoadingHistory ?? false; // Scroll to bottom on initial mount, unless a saved position was passed in. // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount only useEffect(() => { const container = messagesContainerRef.current; if (!container) return; if (initialScrollState) { container.scrollTop = initialScrollState.scrollTop; lastScrollTopRef.current = initialScrollState.scrollTop; wasAtBottomRef.current = false; } else { container.scrollTop = container.scrollHeight; lastScrollTopRef.current = container.scrollHeight; wasAtBottomRef.current = true; } }, []); // Scroll to bottom after initial join history loads; clear fetch spinner at batch end. const wasLoadingHistoryRef = useRef(false); // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is stable via useCallback; refs and setters are stable useLayoutEffect(() => { if (wasLoadingHistoryRef.current && !isLoadingHistory) { if (isFetchingMoreRef.current) { // delta correction for scroll position is handled by useLayoutEffect([displayedMessages]) isFetchingMoreRef.current = false; setIsFetchingMore(false); } else { scrollToBottom(); wasAtBottomRef.current = true; } } wasLoadingHistoryRef.current = isLoadingHistory; }, [isLoadingHistory]); // When older messages are prepended, grow the window so they enter displayedMessages. // When new messages arrive at the bottom while the user is scrolled up, also grow the // window to keep the current top messages visible — slice(-N) otherwise slides the // window forward and hides them, incrementing the "N older messages" counter on every // incoming message. Only let the slice trim from the top when the user is at the bottom // (where auto-scroll handles keeping them current). useLayoutEffect(() => { const newLength = filteredMessages.length; const newFirstId = filteredMessages[0]?.id ?? null; const delta = newLength - prevFilteredLengthRef.current; if (prevFilteredLengthRef.current > 0 && delta > 0) { if (newFirstId !== prevFirstMsgIdRef.current) { // Messages prepended (load-more): signal delta-correction to compensate scrollTop. pendingPrependRef.current = true; setVisibleMessageCount((prev) => prev + delta); } else if (isScrolledUpRef.current) { // Messages appended at bottom while user is scrolled up reading history. // Expand the window to prevent top messages from dropping out of the slice. setVisibleMessageCount((prev) => prev + delta); } } prevFilteredLengthRef.current = newLength; prevFirstMsgIdRef.current = newFirstId; }, [filteredMessages]); // Compensate scrollTop when content is prepended above the viewport. // biome-ignore lint/correctness/useExhaustiveDependencies: runs on every displayedMessages render to capture the resulting scrollHeight; refs are stable useLayoutEffect(() => { const container = messagesContainerRef.current; if (!container) return; // Skip while container is display:none — scrollHeight collapses to 0 and would // poison prevScrollHeightRef, causing a huge spurious delta on the next visible render. if (container.clientHeight === 0) return; const prevHeight = prevScrollHeightRef.current; const newHeight = container.scrollHeight; // Only correct when a true load-more prepend happened (flag set by the window-growth // layoutEffect or button handler). Bottom appends slide the slice(-N) window which also // changes displayedMessages[0] — ID-comparison can't distinguish the two cases. const wasPrepend = pendingPrependRef.current; // Only consume the flag when scrollHeight actually changed — the server-side load-more // path goes through two renders: Render A (filteredMessages grows, visibleCount unchanged, // same displayedMessages content, same scrollHeight) then Render B (visibleCount grows, // new messages enter displayedMessages, scrollHeight grows). The flag must survive Render A // so it's still set when Render B fires the actual correction. if (wasPrepend && newHeight !== prevHeight) { pendingPrependRef.current = false; } if ( isScrolledUpRef.current && prevHeight > 0 && newHeight > prevHeight && wasPrepend ) { const delta = newHeight - prevHeight; container.scrollTop += delta; resizeObserverPrevSHRef.current = newHeight; } prevScrollHeightRef.current = newHeight; }, [displayedMessages]); // Re-stick to bottom when inner message content grows (media/audio previews loading). // Uses prevScrollHeight instead of wasAtBottomRef to avoid stale-flag race where the // ref is true while the user is actively scrolling up. // When the container width changes (member list toggle, window resize), text reflows // and scrollHeight changes; preserve proportional scroll position for scrolled-up users. // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is a stable ref useEffect(() => { const container = messagesContainerRef.current; const inner = messagesInnerRef.current; if (!inner || !container) return; resizeObserverPrevSHRef.current = container.scrollHeight; let prevClientWidth = container.clientWidth; const observer = new ResizeObserver(() => { if (container.clientHeight === 0) return; // Effect may re-initialize while container is display:none (ref=0). // Re-seed with current dimensions and skip — no reliable "was at bottom" data. if (resizeObserverPrevSHRef.current === 0) { resizeObserverPrevSHRef.current = container.scrollHeight; prevClientWidth = container.clientWidth; return; } const currentClientWidth = container.clientWidth; const widthChanged = currentClientWidth !== prevClientWidth; prevClientWidth = currentClientWidth; const prevSH = resizeObserverPrevSHRef.current; const wasAtPrevBottom = container.scrollTop + container.clientHeight >= prevSH - SCROLL_TOLERANCE; resizeObserverPrevSHRef.current = container.scrollHeight; if (wasAtPrevBottom) { scrollToBottom(); } else if (widthChanged && prevSH > 0) { const ratio = container.scrollTop / prevSH; container.scrollTop = Math.round(ratio * container.scrollHeight); } }); observer.observe(inner); return () => observer.disconnect(); }, [isLoadingHistory, channelId, privateChatId]); // Auto-scroll on new messages — skip when this channel is hidden (display:none). // biome-ignore lint/correctness/useExhaustiveDependencies: only scroll when messages change, not when isActive changes useEffect(() => { if (!isActive) return; const isNarrowView = window.matchMedia("(max-width: 768px)").matches; const isChatVisible = !isNarrowView || mobileViewActiveColumn === "chatView"; if (wasAtBottomRef.current && isChatVisible) { scrollToBottom(); } }, [displayedMessages, mobileViewActiveColumn, scrollToBottom, isActive]); return ( <>
{isLoadingHistory && !isFetchingMore ? (
) : (
{hasMoreMessages && !searchQuery && (
)} {searchQuery && (
{plural(filteredMessages.length, { one: t`Found 1 message matching "${searchQuery}"`, other: t`Found ${filteredMessages.length} messages matching "${searchQuery}"`, })}
)} {eventGroups.map((group) => { if (group.type === "eventGroup") { const firstId = group.messages[0]?.id || ""; const lastId = group.messages[group.messages.length - 1]?.id || ""; const groupKey = `group-${firstId}-${lastId}`; return ( ); } const message = group.messages[0]; const originalIndex = channelMessages.findIndex( (m) => m.id === message.id, ); const previousMessage = channelMessages[originalIndex - 1]; const showHeader = !previousMessage || previousMessage.type !== "message" || previousMessage.userId !== message.userId || new Date(message.timestamp).getTime() - new Date(previousMessage.timestamp).getTime() > 5 * 60 * 1000; return ( ); })}
)}
); }, ); ChannelMessageList.displayName = "ChannelMessageList"; // Wrap with memo so hidden keep-alive channels skip re-renders when their props // haven't changed (e.g. when messageText changes in the input — the only thing // that changes on typing is local state inside ChatArea, not the props we pass here). export const MemoChannelMessageList = memo(ChannelMessageList); @@ -46,6 +56,12 @@ msgstr "{0} y {1} están escribiendo..." msgid "{0} is typing..." msgstr "{0} está escribiendo..." +#. placeholder {0}: networks.length +#. placeholder {1}: networks.length === 1 ? "" : "s" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "{0} network{1} — pick one to join" +msgstr "" + #. placeholder {0}: filteredMessages.length - displayedMessages.length #: src/components/layout/ChannelMessageList.tsx msgid "{0} older messages" @@ -186,6 +202,12 @@ msgstr "Agregar máscara de invitación (p. ej., nick!*@*, *!*@host.com)" msgid "Add IRC Server" msgstr "Agregar servidor IRC" +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworksPanel.tsx +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Add Network" +msgstr "Agregar red" + #: src/components/message/MessageActions.tsx #: src/components/message/MessageReactions.tsx #: src/components/message/MessageReactions.tsx @@ -205,6 +227,10 @@ msgstr "Añadir regla" msgid "Add Server" msgstr "Agregar servidor" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Add your first network" +msgstr "Agrega tu primera red" + #: src/components/message/JsonLogMessage.tsx msgid "Additional Details" msgstr "Detalles adicionales" @@ -358,6 +384,10 @@ msgstr "Atrás" msgid "Back to image" msgstr "Volver a la imagen" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Back to network list" +msgstr "" + #: src/components/ui/ModerationModal.tsx msgid "Ban {username} by hostmask (prevents them from rejoining from the same IP/host)" msgstr "Banear a {username} por hostmask (impide que vuelva a unirse desde la misma IP/host)" @@ -405,6 +435,8 @@ msgstr "Ver todos los canales del servidor" #: src/components/ui/AddPrivateChatModal.tsx #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/ChannelSettingsModal.tsx #: src/components/ui/ExternalLinkWarningModal.tsx #: src/components/ui/FloodSettingsModal.tsx @@ -640,6 +672,7 @@ msgid "Configure notification sounds and highlights" msgstr "Configurar sonidos de notificación y resaltados" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworksPanel.tsx msgid "Connect" msgstr "Conectar" @@ -759,6 +792,10 @@ msgstr "Eliminar canal" msgid "Delete message" msgstr "Eliminar mensaje" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Delete network" +msgstr "Eliminar red" + #: src/components/layout/ChannelList.tsx msgid "Delete Private Chat" msgstr "Eliminar chat privado" @@ -767,6 +804,10 @@ msgstr "Eliminar chat privado" msgid "Delete this message? This cannot be undone." msgstr "¿Eliminar este mensaje? Esta acción no se puede deshacer." +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Delete this network?" +msgstr "¿Eliminar esta red?" + #: src/components/layout/ServerList.tsx #: src/components/mobile/ServerBottomSheet.tsx msgid "Disconnect" @@ -830,10 +871,16 @@ msgstr "Descargar" msgid "e.g., 100:1440" msgstr "ej., 100:1440" +#: src/components/ui/BouncerNetworksPanel.tsx #: src/components/ui/ChannelSettingsModal.tsx msgid "Edit" msgstr "Editar" +#. placeholder {0}: editingNetwork?.attributes.name || editingNetwork?.netid +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Edit {0}" +msgstr "Editar {0}" + #: src/components/ui/UserProfileModal.tsx msgid "Edit Profile" msgstr "Editar perfil" @@ -1057,6 +1104,7 @@ msgstr "INICIO" msgid "Homepage" msgstr "Página de inicio" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserProfileModal.tsx msgid "Host" msgstr "Host" @@ -1271,6 +1319,10 @@ msgstr "Abandonó el canal" msgid "Let others know when you are typing" msgstr "Avisar a otros cuando estás escribiendo" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Libera Chat" +msgstr "Libera Chat" + #: src/components/message/LinkPreview.tsx msgid "Link preview" msgstr "Vista previa del enlace" @@ -1299,6 +1351,10 @@ msgstr "Cargando GIFs..." msgid "Loading more channels..." msgstr "Cargando más canales..." +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Loading networks from your bouncer…" +msgstr "Cargando redes desde tu bouncer…" + #: src/components/ui/UserProfileModal.tsx msgid "Loading WHOIS data..." msgstr "Cargando datos WHOIS..." @@ -1486,9 +1542,15 @@ msgid "Name:" msgstr "Nombre:" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx msgid "Network Name" msgstr "Nombre de red" +#. placeholder {0}: server?.name ?? bouncerServerId +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Networks on {0}" +msgstr "Redes en {0}" + #: src/components/ui/QuickActions.tsx msgid "New DM" msgstr "Nuevo mensaje directo" @@ -1511,6 +1573,7 @@ msgid "nick!user@host (e.g., spam*!*@*, *!*@badhost.com)" msgstr "nick!user@host (ej., spam*!*@*, *!*@badhost.com)" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserSettings.tsx #: src/lib/settings/definitions/allSettings.ts msgid "Nickname" @@ -1570,6 +1633,10 @@ msgstr "Ningún archivo elegido" msgid "No flood profile" msgstr "Sin perfil de flood" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "no host set" +msgstr "sin host configurado" + #: src/components/ui/ChannelSettingsModal.tsx msgid "No invitations found" msgstr "No se encontraron invitaciones" @@ -1610,6 +1677,10 @@ msgstr "Sin tema establecido" msgid "No unread mentions or messages" msgstr "Sin menciones ni mensajes sin leer" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "No upstream networks yet." +msgstr "Aún no hay redes ascendentes." + #: src/components/ui/AddPrivateChatModal.tsx msgid "No users available" msgstr "No hay usuarios disponibles" @@ -1696,6 +1767,10 @@ msgstr "¡Vaya! ¡División de red! ⚠️" msgid "Op" msgstr "Op" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Open" +msgstr "" + #: src/components/ui/QuickActions/uiActionConfig.tsx msgid "Open channel configuration settings" msgstr "Abrir configuración del canal" @@ -1799,6 +1874,10 @@ msgstr "Fijar chat privado" msgid "Pin this private message conversation" msgstr "Fijar esta conversación de mensaje privado" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Plaintext" +msgstr "Texto plano" + #: src/components/message/MediaPreview.tsx #: src/components/message/MediaPreview.tsx #: src/components/message/MediaPreview.tsx @@ -1827,6 +1906,7 @@ msgid "PM User" msgstr "MP al usuario" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx msgid "Port" msgstr "Puerto" @@ -1918,6 +1998,7 @@ msgstr "reaccionó a este mensaje" msgid "Read more" msgstr "Leer más" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserProfileModal.tsx #: src/components/ui/UserSettings.tsx #: src/lib/settings/definitions/allSettings.ts @@ -2002,6 +2083,7 @@ msgstr "Reglas" msgid "Safe" msgstr "Seguro" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/ChannelSettingsModal.tsx #: src/components/ui/TopicModal.tsx #: src/components/ui/UserSettings.tsx @@ -2183,6 +2265,10 @@ msgstr "Los operadores del servidor en la red podrían leer tus mensajes" msgid "Server Password" msgstr "Contraseña del servidor" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Server Password (PASS)" +msgstr "Contraseña del servidor (PASS)" + #: src/components/ui/LinkSecurityWarningModal.tsx msgid "Server-to-server communication may use unencrypted connections" msgstr "La comunicación entre servidores puede usar conexiones sin cifrar" @@ -2378,6 +2464,10 @@ msgstr "Tiempo (min)" msgid "Time Window (seconds)" msgstr "Ventana de tiempo (segundos)" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "TLS" +msgstr "TLS" + #: src/components/message/WhisperMessage.tsx #: src/components/message/WhisperMessage.tsx msgid "to" @@ -2426,6 +2516,10 @@ msgstr "Tema:" msgid "Total: {0}" msgstr "Total: {0}" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Transport" +msgstr "Transporte" + #: src/components/ui/UserSettings.tsx msgid "Trusted Sources" msgstr "Fuentes de confianza" @@ -2536,6 +2630,7 @@ msgstr "Perfil de usuario" msgid "User Settings" msgstr "Configuración de usuario" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/InviteUserModal.tsx #: src/components/ui/ModerationModal.tsx msgid "Username" @@ -2683,6 +2778,10 @@ msgstr "Amplio – Ámbito de protección más amplio" msgid "Will default to 'no reason' if left empty" msgstr "Por defecto será 'sin motivo' si se deja vacío" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Yes, delete" +msgstr "Sí, eliminar" + #: src/components/message/CollapsedEventMessage.tsx #: src/components/message/EventMessage.tsx msgid "You" @@ -2713,6 +2812,10 @@ msgstr "Tu contraseña de cuenta para autenticación" msgid "Your account username for authentication" msgstr "Tu nombre de usuario de cuenta para autenticación" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Your bouncer doesn't have any networks yet. Add one to get started." +msgstr "Tu bouncer aún no tiene ninguna red. Agrega una para empezar." + #: src/lib/settings/definitions/allSettings.ts msgid "Your default nickname for all servers" msgstr "Tu apodo predeterminado para todos los servidores" diff --git a/src/locales/fi/messages.mjs b/src/locales/fi/messages.mjs index 050c8f34..e97bc1d6 100644 --- a/src/locales/fi/messages.mjs +++ b/src/locales/fi/messages.mjs @@ -1 +1 @@ -/*eslint-disable*/export const messages=JSON.parse("{\"+5VMrz\":[\"Virheellinen mallimuoto. Käytä nick!käyttäjä@isäntä-muotoa (jokerimerkit * sallittu)\"],\"+6NQQA\":[\"Yleinen tukikanava\"],\"+6NyRG\":[\"Asiakasohjelma\"],\"+K0AvT\":[\"Katkaise yhteys\"],\"+cyFdH\":[\"Oletusviesti poissaoloilmoitukselle\"],\"+mVPqU\":[\"Näytä Markdown-muotoilu viesteissä\"],\"+vqCJH\":[\"Tilin käyttäjänimi tunnistautumista varten\"],\"+yPBXI\":[\"Valitse tiedosto\"],\"+zy2Nq\":[\"Tyyppi\"],\"/09cao\":[\"Heikko linkkiturvallisuus (taso \",[\"securityLevel\"],\")\"],\"/3BQ4J\":[\"Kanavan ulkopuoliset käyttäjät eivät voi lähettää viestejä sille\"],\"/6BzZF\":[\"Näytä/piilota jäsenlista\"],\"/TNOPk\":[\"Käyttäjä on poissa\"],\"/XQgft\":[\"Selaa\"],\"/cF7Rs\":[\"Äänenvoimakkuus\"],\"/dqduX\":[\"Seuraava sivu\"],\"/fc3q4\":[\"Kaikki sisältö\"],\"/kISDh\":[\"Ota ilmoitusäänet käyttöön\"],\"/n04sB\":[\"Kill\"],\"/rTz0M\":[\"Ääni\"],\"/rfkZe\":[\"Toista ääniä maininnoista ja viesteistä\"],\"0/0ZGA\":[\"Kanavan nimimaski\"],\"0D6j7U\":[\"Lue lisää mukautetuista säännöistä →\"],\"0XsHcR\":[\"Poista käyttäjä\"],\"0ZpE//\":[\"Lajittele käyttäjämäärän mukaan\"],\"0bEPwz\":[\"Aseta poissa\"],\"0dGkPt\":[\"Laajenna kanavaluettelo\"],\"0gS7M5\":[\"Näyttönimi\"],\"0kS+M8\":[\"EsimerkKi\"],\"0rgoY7\":[\"Yhdistä vain valitsemiisi palvelimiin\"],\"0wdd7X\":[\"Liity\"],\"0wkVYx\":[\"Yksityisviestit\"],\"111uHX\":[\"Linkin esikatselu\"],\"196EG4\":[\"Poista yksityiskeskustelu\"],\"1DSr1i\":[\"Rekisteröidy tilille\"],\"1O/24y\":[\"Näytä/piilota kanavaluettelo\"],\"1VPJJ2\":[\"Ulkoisen linkin varoitus\"],\"1ZC/dv\":[\"Ei lukemattomia mainintoja tai viestejä\"],\"1pO1zi\":[\"Palvelimen nimi on pakollinen\"],\"1uwfzQ\":[\"Näytä kanavan aihe\"],\"268g7c\":[\"Syötä näyttönimi\"],\"2FOFq1\":[\"Verkon palvelinoperaattorit voisivat mahdollisesti lukea viestisi\"],\"2FYpfJ\":[\"Lisää\"],\"2HF1Y2\":[[\"inviter\"],\" kutsui \",[\"target\"],\" liittymään kanavalle \",[\"channel\"]],\"2I70QL\":[\"Näytä käyttäjäprofiilitiedot\"],\"2QYdmE\":[\"Käyttäjät:\"],\"2QpEjG\":[\"poistui\"],\"2YE223\":[\"Viesti #\",[\"0\"],\" (Enter = uusi rivi, Shift+Enter = lähetä)\"],\"2bimFY\":[\"Käytä palvelimen salasanaa\"],\"2iTmdZ\":[\"Paikallinen tallennustila:\"],\"2odkwe\":[\"Tiukka – aggressiivisempi suojaus\"],\"2uDhbA\":[\"Syötä kutsuttavan käyttäjänimi\"],\"2ygf/L\":[\"← Takaisin\"],\"2zEgxj\":[\"Hae GIF-kuvia...\"],\"3RdPhl\":[\"Nimeä kanava uudelleen\"],\"3THokf\":[\"Voice-käyttäjä\"],\"3TSz9S\":[\"Pienennä\"],\"3jBDvM\":[\"Kanavan näyttönimi\"],\"3ryuFU\":[\"Valinnaiset kaatumisraportit sovelluksen parantamiseksi\"],\"3uBF/8\":[\"Sulje katseluohjelma\"],\"3uwW8F\":[\"https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"3xf8Kz\":[\"Syötä tilin nimi...\"],\"4/Rr0R\":[\"Kutsu käyttäjä nykyiselle kanavalle\"],\"4EZrJN\":[\"Säännöt\"],\"4JJtW9\":[\"#overflow\"],\"4NqeT4\":[\"Tulvasuojausprofiili (+F)\"],\"4RZQRK\":[\"Mitä olet tekemässä?\"],\"4hfTrB\":[\"Nimimerkki\"],\"4n99LO\":[\"Jo kanavalla \",[\"0\"]],\"4t6vMV\":[\"Vaihda automaattisesti yksiriviseen lyhyille viesteille\"],\"4vsHmf\":[\"Aika (min)\"],\"5+INAX\":[\"Korosta viestit, joissa mainitaan sinut\"],\"5R5Pv/\":[\"Oper-nimi\"],\"678PKt\":[\"Verkon nimi\"],\"6Aih4U\":[\"Poissa verkosta\"],\"6CO3WE\":[\"Salasana vaaditaan kanavalle liittymiseen. Jätä tyhjäksi poistaaksesi avaimen.\"],\"6HhMs3\":[\"Lähtöviesti\"],\"6V3Ea3\":[\"Kopioitu\"],\"6lGV3K\":[\"Näytä vähemmän\"],\"6yFOEi\":[\"Syötä oper-salasana...\"],\"7+IHTZ\":[\"Ei valittua tiedostoa\"],\"73hrRi\":[\"nick!käyttäjä@isäntä (esim. spam*!*@*, *!*@badhost.com)\"],\"7QkKyN\":[\"Lähetä yksityisviesti\"],\"7U1W7c\":[\"Hyvin löysä\"],\"7Y1YQj\":[\"Oikea nimi:\"],\"7YHArF\":[\"— avaa katseluohjelmassa\"],\"7fjnVl\":[\"Hae käyttäjiä...\"],\"7jL88x\":[\"Poistetaanko tämä viesti? Toimintoa ei voi kumota.\"],\"7nGhhM\":[\"Mitä ajattelet?\"],\"7sEpu1\":[\"Jäsenet — \",[\"0\"]],\"7sNhEz\":[\"Käyttäjänimi\"],\"8H0Q+x\":[\"Lue lisää profiileista →\"],\"8Phu0A\":[\"Näytä kun käyttäjät vaihtavat nimimerkkinsä\"],\"8XTG9e\":[\"Syötä oper-salasana\"],\"8XsV2J\":[\"Yritä lähettää uudelleen\"],\"8ZsakT\":[\"Salasana\"],\"8kR84m\":[\"Olet avaamassa ulkoista linkkiä:\"],\"8lCgih\":[\"Poista sääntö\"],\"8p/xVT\":[[\"0\",\"plural\",{\"one\":[[\"1\"]],\"other\":[[\"2\"]]}]],\"8wRzac\":[[\"joinCount\",\"plural\",{\"one\":[\"liittyi\"],\"other\":[\"liittyi \",[\"joinCount\"],\" kertaa\"]}]],\"9BMLnJ\":[\"Yhdistä uudelleen palvelimeen\"],\"9OEgyT\":[\"Lisää reaktio\"],\"9PQ8m2\":[\"G-Line (maailmanlaajuinen esto)\"],\"9Qs99X\":[\"Sähköposti:\"],\"9QupBP\":[\"Poista malli\"],\"9bG48P\":[\"Lähetetään\"],\"9f5f0u\":[\"Yksityisyyteen liittyviä kysymyksiä? Ota yhteyttä:\"],\"9unqs3\":[\"Poissa:\"],\"9v3hwv\":[\"Palvelimia ei löydetty.\"],\"9zb2WA\":[\"Yhdistetään\"],\"A1taO8\":[\"Hae\"],\"A2adVi\":[\"Lähetä kirjoitusilmoituksia\"],\"A9Rhec\":[\"Kanavan nimi\"],\"AWOSPo\":[\"Lähennä\"],\"AXSpEQ\":[\"Oper yhdistäessä\"],\"AeXO77\":[\"Tili\"],\"AhNP40\":[\"Selaa\"],\"Ai2U7L\":[\"Isäntä\"],\"AjBQnf\":[\"Vaihtoi nimimerkin\"],\"AmXVh6\":[\"https://example.com/avatar.png\"],\"AnRu/j\":[\"Peruuta vastaus\"],\"ApSx0O\":[\"Löydettiin \",[\"0\"],\" viestiä, jotka vastaavat \\\"\",[\"searchQuery\"],\"\\\"\"],\"AxPAXW\":[\"Tuloksia ei löydetty\"],\"AyNqAB\":[\"Näytä kaikki palvelintapahtumat chatissa\"],\"B/QqGw\":[\"Poissa näppäimistöltä\"],\"B8AaMI\":[\"Tämä kenttä on pakollinen\"],\"BA2c49\":[\"Palvelin ei tue kehittynyttä LIST-suodatusta\"],\"BDKt3I\":[[\"0\"],\", \",[\"1\"],\", \",[\"2\"],\" ja \",[\"3\"],\" muuta kirjoittavat...\"],\"BGul2A\":[\"Sinulla on tallentamattomia muutoksia. Haluatko varmasti sulkea tallentamatta?\"],\"BIf9fi\":[\"Tilaviestisi\"],\"BZz3md\":[\"Henkilökohtainen verkkosivustosi\"],\"Bgm/H7\":[\"Salli monirivisyöttö\"],\"BiQIl1\":[\"Kiinnitä tämä yksityisviesteistä\"],\"BlNZZ2\":[\"Siirry viestiin napsauttamalla\"],\"Bowq3c\":[\"Vain operaattorit voivat muuttaa kanavan aihetta\"],\"Btozzp\":[\"Tämä kuva on vanhentunut\"],\"Bycfjm\":[\"Yhteensä: \",[\"0\"]],\"C6IBQc\":[\"Kopioi koko JSON\"],\"C9L9wL\":[\"Tiedonkeruu\"],\"CDq4wC\":[\"Moderoi käyttäjää\"],\"CHVRxG\":[\"Viesti @\",[\"0\"],\" (Shift+Enter = uusi rivi)\"],\"CN9zdR\":[\"Oper-nimi ja salasana ovat pakollisia\"],\"CW3sYa\":[\"Lisää reaktio \",[\"emoji\"]],\"CaAkqd\":[\"Näytä poistumisviestit\"],\"CbvaYj\":[\"Estä nimimerkin perusteella\"],\"CcK+Ft\":[\"PDF\"],\"Ce8q3L\":[\"Valitse kanava\"],\"CsekCi\":[\"Normaali\"],\"D+NlUC\":[\"Järjestelmä\"],\"D28t6+\":[\"liittyi ja poistui\"],\"DB8zMK\":[\"Käytä\"],\"DBcWHr\":[\"Mukautettu ilmoitusäänitiedosto\"],\"DTy9Xw\":[\"Median esikatselut\"],\"Dj4pSr\":[\"Valitse turvallinen salasana\"],\"Du+zn+\":[\"Haetaan...\"],\"Du2T2f\":[\"Asetusta ei löydetty\"],\"DwsSVQ\":[\"Käytä suodattimet ja päivitä\"],\"E3W/zd\":[\"Oletusnimimerkki\"],\"E6nRW7\":[\"Kopioi URL\"],\"E703RG\":[\"Tilat:\"],\"EAeu1Z\":[\"Lähetä kutsu\"],\"EFKJQT\":[\"Asetus\"],\"EGPQBv\":[\"Mukautetut tulvasäännöt (+f)\"],\"ELik0r\":[\"Lue koko tietosuojakäytäntö\"],\"EPbeC2\":[\"Näytä tai muokkaa kanavan aihetta\"],\"EQCDNT\":[\"Syötä oper-käyttäjätunnus...\"],\"EUvulZ\":[\"Löydettiin 1 viesti, joka vastaa \\\"\",[\"searchQuery\"],\"\\\"\"],\"EatZYJ\":[\"Seuraava kuva\"],\"EdQY6l\":[\"Ei mitään\"],\"EnqLYU\":[\"Hae palvelimia...\"],\"F0OKMc\":[\"Muokkaa palvelinta\"],\"F6Int2\":[\"Ota korostukset käyttöön\"],\"FDoLyE\":[\"Enimmäiskäyttäjämäärä\"],\"FUU/hZ\":[\"Hallitsee, kuinka paljon ulkoista mediaa ladataan chatissa.\"],\"Fdp03t\":[\"päälle\"],\"FfPWR0\":[\"Ikkuna\"],\"FjkaiT\":[\"Loitonna\"],\"FlqOE9\":[\"Mitä tämä tarkoittaa:\"],\"FolHNl\":[\"Hallinnoi tiliäsi ja tunnistautumista\"],\"Fp2Dif\":[\"Poistui palvelimelta\"],\"G5KmCc\":[\"GZ-Line (maailmanlaajuinen Z-Line)\"],\"GDs0lz\":[\"<0>Riski: Arkaluonteiset tiedot (viestit, yksityiskeskustelut, tunnistautumistiedot) voisivat paljastua verkon ylläpitäjille tai hyökkääjille, jotka sijaitsevat IRC-palvelimien välissä.\"],\"GR+2I3\":[\"Lisää kutsumask (esim. nick!*@*, *!*@host.com)\"],\"GRLyMU\":[\"Sulje irrotettu palvelintiedotteet-näkymä\"],\"GlHnXw\":[\"Nimen vaihto epäonnistui: \",[\"error\"],\" \",[\"0\"]],\"GswZF3\":[\"Esikatselu:\"],\"GtmO8/\":[\"lähettäjä\"],\"GtuHUQ\":[\"Nimeä tämä kanava uudelleen palvelimella. Kaikki käyttäjät näkevät uuden nimen.\"],\"GuGfFX\":[\"Näytä/piilota haku\"],\"GxkJXS\":[\"Ladataan...\"],\"GzbwnK\":[\"Liittyi kanavalle\"],\"GzsUDB\":[\"Laajennettu profiili\"],\"H/PnT8\":[\"Lisää emoji\"],\"H6Izzl\":[\"Suosikki värikoodisi\"],\"H9jIv+\":[\"Näytä liittymiset/poistumiset\"],\"HAKBY9\":[\"Lataa tiedostoja\"],\"HdE1If\":[\"Kanava\"],\"Hk4AW9\":[\"Suosikki näyttönimesi\"],\"HmHDk7\":[\"Valitse jäsen\"],\"HrQzPU\":[\"Kanavat verkossa \",[\"networkName\"]],\"I2tXQ5\":[\"Viesti @\",[\"0\"],\" (Enter = uusi rivi, Shift+Enter = lähetä)\"],\"I6bw/h\":[\"Estä käyttäjä\"],\"I92Z+b\":[\"Ota ilmoitukset käyttöön\"],\"I9D72S\":[\"Haluatko varmasti poistaa tämän viestin? Tätä toimintoa ei voi kumota.\"],\"IA+1wo\":[\"Näytä kun käyttäjiä poistetaan kanavilta\"],\"IDwkJx\":[\"IRC-operaattori\"],\"ILlU+s\":[\"Tiedot:\"],\"IUwGEM\":[\"Tallenna muutokset\"],\"IVeGK6\":[[\"0\"],\", \",[\"1\"],\" ja \",[\"2\"],\" kirjoittavat...\"],\"IgrLD/\":[\"Tauko\"],\"Im6JED\":[\"WHISPER\"],\"ImOQa9\":[\"Vastaa\"],\"IoHMnl\":[\"Enimmäisarvo on \",[\"0\"]],\"IvMj+0\":[\"Op\"],\"J28zul\":[\"Yhdistetään...\"],\"J5T9NW\":[\"Käyttäjätiedot\"],\"J8Y5+z\":[\"Hups! Verkon jako! ⚠️\"],\"JBHkBA\":[\"Poistui kanavalta\"],\"JCwL0Q\":[\"Syötä syy (valinnainen)\"],\"JFciKP\":[\"Vaihda\"],\"JXGkhG\":[\"Muuta kanavan nimeä (vain operaattorit)\"],\"JcD7qf\":[\"Lisää toimintoja\"],\"JdkA+c\":[\"Salainen (+s)\"],\"Jmu12l\":[\"Palvelimen kanavat\"],\"JvQ++s\":[\"Ota Markdown käyttöön\"],\"K2jwh/\":[\"WHOIS-tietoja ei saatavilla\"],\"KAXSwC\":[\"Voice\"],\"KDfTdX\":[\"Poista viesti\"],\"KKBlUU\":[\"Upotus\"],\"KM0pLb\":[\"Tervetuloa kanavalle!\"],\"KR6W2h\":[\"Poista esto käyttäjältä\"],\"KV+Bi1\":[\"Vain kutsulla (+i)\"],\"KdCtwE\":[\"Kuinka monta sekuntia tulvatoimintaa seurataan ennen laskurien nollaamista\"],\"Kkezga\":[\"Palvelimen salasana\"],\"KsiQ/8\":[\"Käyttäjät täytyy kutsua kanavalle\"],\"L+gB/D\":[\"Kanavan tiedot\"],\"LC1a7n\":[\"IRC-palvelin on ilmoittanut, että sen palvelimien väliset linkit ovat heikosti suojattuja. Tämä tarkoittaa, että verkossa välitettäviä viestejäsi ei välttämättä salata asianmukaisesti tai SSL/TLS-sertifikaatteja ei tarkisteta oikein.\"],\"LNfLR5\":[\"Näytä poistamiset\"],\"LQb0W/\":[\"Näytä kaikki tapahtumat\"],\"LU7/yA\":[\"Vaihtoehtoinen nimi käyttöliittymässä näytettäväksi. Voi sisältää välilyöntejä, emojeja ja erikoismerkkejä. Todellista kanavan nimeä (\",[\"channelName\"],\") käytetään edelleen IRC-komennoissa.\"],\"LUb9O7\":[\"Kelvollinen palvelinportti on pakollinen\"],\"Lb+BUl\":[\"https://example.com/avatar.jpg\"],\"LcET2C\":[\"Tietosuojakäytäntö\"],\"LcuSDR\":[\"Hallinnoi profiilitietojasi ja metatietoja\"],\"LqLS9B\":[\"Näytä nimimerkinvaihdot\"],\"LsDQt2\":[\"Kanavan asetukset\"],\"LtI9AS\":[\"Omistaja\"],\"LuNhhL\":[\"reagoi tähän viestiin\"],\"M/AZNG\":[\"URL avatar-kuvaasi\"],\"M/WIer\":[\"Lähetä viesti\"],\"M8er/5\":[\"Nimi:\"],\"MHk+7g\":[\"Edellinen kuva\"],\"MRorGe\":[\"Lähetä viesti käyttäjälle\"],\"MVbSGP\":[\"Aikaikkuna (sekuntia)\"],\"MkpcsT\":[\"Viestisi ja asetuksesi tallennetaan paikallisesti laitteellesi\"],\"N/hDSy\":[\"Merkitse botiksi – yleensä 'on' tai tyhjä\"],\"N7TQbE\":[\"Kutsu käyttäjä kanavalle \",[\"channelName\"]],\"NCca/o\":[\"Syötä oletusnimimerkki...\"],\"Nqs6B9\":[\"Näyttää kaiken ulkoisen median. Mikä tahansa URL voi aiheuttaa yhteyspyynnön tuntemattomalle palvelimelle.\"],\"Nt+9O7\":[\"Käytä WebSocket-yhteyttä TCP:n sijaan\"],\"NxIHzc\":[\"Katkaise yhteys\"],\"O+v/cL\":[\"Selaa kaikkia palvelimen kanavia\"],\"ODwSCk\":[\"Lähetä GIF\"],\"OGQ5kK\":[\"Määritä ilmoitusäänet ja korostukset\"],\"OIPt1Z\":[\"Näytä tai piilota jäsenlistan sivupalkki\"],\"OKSNq/\":[\"Hyvin tiukka\"],\"ONWvwQ\":[\"Lähetä\"],\"OVKoQO\":[\"Tilin salasana tunnistautumista varten\"],\"ObsidianIRC - Bringing IRC to the future\":[\"ObsidianIRC - Viedään IRC tulevaisuuteen\"],\"OhCpra\":[\"Aseta aihe…\"],\"OkltoQ\":[\"Estä \",[\"username\"],\" nimimerkin perusteella (estää liittymisen samalla nimimerkillä)\"],\"P+t/Te\":[\"Ei lisätietoja\"],\"P42Wcc\":[\"Turvallinen\"],\"PD38l0\":[\"Kanavan avatarin esikatselu\"],\"PD9mEt\":[\"Kirjoita viesti...\"],\"PPqfdA\":[\"Avaa kanavan asetukset\"],\"PSCjfZ\":[\"Aihe, joka näytetään tälle kanavalle. Kaikki käyttäjät voivat nähdä aiheen.\"],\"PZCecv\":[\"PDF-esikatselu\"],\"PeLgsC\":[[\"c\",\"plural\",{\"one\":[\"1 kerta\"],\"other\":[[\"c\"],\" kertaa\"]}]],\"PguS2C\":[\"Lisää poikkeusmaski (esim. nick!*@*, *!*@host.com)\"],\"Pil5Ty\":[\"Näytetään \",[\"displayedChannelsCount\"],\"/\",[\"0\"],\" kanavaa\"],\"PqhVlJ\":[\"Estä käyttäjä (hostmaskin perusteella)\"],\"Q+chwU\":[\"Käyttäjätunnus:\"],\"Q6hhn8\":[\"Asetukset\"],\"QF4a34\":[\"Syötä käyttäjänimi\"],\"QGqSZ2\":[\"Väri ja muotoilu\"],\"QJQd1J\":[\"Muokkaa profiilia\"],\"QSzGDE\":[\"Toimeton\"],\"QUlny5\":[\"Tervetuloa palvelimeen \",[\"0\"],\"!\"],\"Qoq+GP\":[\"Lue lisää\"],\"QuSkCF\":[\"Suodata kanavia...\"],\"QwUrDZ\":[\"vaihtoi aiheen: \",[\"topic\"]],\"R0UH07\":[\"Kuva \",[\"0\"],\"/\",[\"1\"]],\"R7SsBE\":[\"Mykistä\"],\"R8rf1X\":[\"Aseta aihe napsauttamalla\"],\"RArB3D\":[\"potkittiin kanavalta \",[\"channelName\"],\" käyttäjän \",[\"username\"],\" toimesta\"],\"RI3cWd\":[\"Tutustu IRC:n maailmaan ObsidianIRC:llä\"],\"RMMaN5\":[\"Moderoitu (+m)\"],\"RWw9Lg\":[\"Sulje ikkuna\"],\"RZ2BuZ\":[\"Tilin \",[\"account\"],\" rekisteröinti vaatii vahvistuksen: \",[\"message\"]],\"RySp6q\":[\"Piilota kommentit\"],\"SPKQTd\":[\"Nimimerkki on pakollinen\"],\"SPVjfj\":[\"Oletusarvo on 'ei syytä', jos jätetään tyhjäksi\"],\"SQKPvQ\":[\"Kutsu käyttäjä\"],\"SkZcl+\":[\"Valitse valmis tulvasuojausprofiili. Nämä profiilit tarjoavat tasapainoisia suojausasetuksia eri käyttötarkoituksiin.\"],\"Slr+3C\":[\"Vähimmäiskäyttäjämäärä\"],\"Spnlre\":[\"Kutsuit \",[\"target\"],\" liittymään kanavalle \",[\"channel\"]],\"T/ckN5\":[\"Avaa katseluohjelmassa\"],\"T91vKp\":[\"Toista\"],\"TV2Wdu\":[\"Lue, miten käsittelemme tietojasi ja suojelemme yksityisyyttäsi.\"],\"TgFpwD\":[\"Käytetään...\"],\"TkzSFB\":[\"Ei muutoksia\"],\"TtserG\":[\"Syötä oikea nimi\"],\"Ttz9J1\":[\"Syötä salasana...\"],\"Tz0i8g\":[\"Asetukset\"],\"U3pytU\":[\"Admin\"],\"UDb2YD\":[\"Reagoi\"],\"UE4KO5\":[\"*kanava*\"],\"UGT5vp\":[\"Tallenna asetukset\"],\"UV5hLB\":[\"Estoja ei löydetty\"],\"Uaj3Nd\":[\"Tilaviestit\"],\"Ue3uny\":[\"Oletus (ei profiilia)\"],\"UkARhe\":[\"Normaali – tavallinen suojaus\"],\"Umn7Cj\":[\"Ei vielä kommentteja. Ole ensimmäinen!\"],\"UtUIRh\":[[\"0\"],\" vanhempaa viestiä\"],\"UwzP+U\":[\"Suojattu yhteys\"],\"V0/A4O\":[\"Kanavan omistaja\"],\"V4qgxE\":[\"Luotu aiemmin kuin (min sitten)\"],\"V8yTm6\":[\"Tyhjennä haku\"],\"VJMMyz\":[\"ObsidianIRC – IRC:n tulevaisuuteen\"],\"VJScHU\":[\"Syy\"],\"VLsmVV\":[\"Mykistä ilmoitukset\"],\"VbyRUy\":[\"Kommentit\"],\"Vmx0mQ\":[\"Asettanut:\"],\"VqnIZz\":[\"Lue tietosuojakäytäntömme ja tiedonkäsittelytapamme\"],\"VrMygG\":[\"Vähimmäispituus on \",[\"0\"]],\"VrnTui\":[\"Pronominisi, näytetään profiilissasi\"],\"W8E3qn\":[\"Tunnistautunut tili\"],\"WAakm9\":[\"Poista kanava\"],\"WFxTHC\":[\"Lisää porttikieltomaski (esim. nick!*@*, *!*@host.com)\"],\"WN1g9F\":[\"Palvelimen osoite on pakollinen\"],\"WRYdXW\":[\"Äänentoistoasento\"],\"WUOH5B\":[\"Estä käyttäjä\"],\"WWEXnZ\":[[\"0\",\"plural\",{\"one\":[\"Näytä 1 kohde lisää\"],\"other\":[\"Näytä \",[\"1\"],\" kohdetta lisää\"]}]],\"Weq9zb\":[\"Yleiset\"],\"Wfj7Sk\":[\"Mykistä tai poista mykistys ilmoitusäänistä\"],\"Wm7gbG\":[\"GitHub:\"],\"WyeHWY\":[\"*spam*\"],\"WzMCru\":[\"Käyttäjäprofiili\"],\"X6S3lt\":[\"Hae asetuksia, kanavia, palvelimia...\"],\"XEHan5\":[\"Jatka silti\"],\"XI1+wb\":[\"Virheellinen muoto\"],\"XIXeuC\":[\"Viesti @\",[\"0\"]],\"XMS+k4\":[\"Aloita yksityiskeskustelu\"],\"XWgxXq\":[\"Albumi\"],\"Xd7+IT\":[\"Irrota yksityiskeskustelu\"],\"Xm/s+u\":[\"Näyttö\"],\"Xp2n93\":[\"Näyttää mediaa palvelimesi luotetusta tiedostoisännästä. Ulkoisille palveluille ei lähetetä pyyntöjä.\"],\"XvjC4F\":[\"Tallennetaan...\"],\"Y/qryO\":[\"Hakua vastaavia käyttäjiä ei löydetty\"],\"YAqRpI\":[\"Tilin \",[\"account\"],\" rekisteröinti onnistui: \",[\"message\"]],\"YEfzvP\":[\"Suojattu aihe (+t)\"],\"YQOn6a\":[\"Pienennä jäsenlista\"],\"YRCoE9\":[\"Kanavan operaattori\"],\"YURQaF\":[\"Näytä profiili\"],\"YdBSvr\":[\"Hallinnoi median näyttöä ja ulkoista sisältöä\"],\"Yj6U3V\":[\"Ei keskuspalvelinta:\"],\"YjvpGx\":[\"Pronominit\"],\"YqH4l4\":[\"Ei avainta\"],\"YyUPpV\":[\"Tili:\"],\"ZJSWfw\":[\"Viesti, joka näytetään katkaistessasi yhteyden palvelimeen\"],\"ZR1dJ4\":[\"Kutsut\"],\"ZdWg0V\":[\"Avaa selaimessa\"],\"ZhRBbl\":[\"Hae viestejä…\"],\"Zmcu3y\":[\"Lisäsuodattimet\"],\"a2/8e5\":[\"Aihe asetettu myöhemmin kuin (min sitten)\"],\"aHKcKc\":[\"Edellinen sivu\"],\"aJTbXX\":[\"Oper-salasana\"],\"aQryQv\":[\"Malli on jo olemassa\"],\"aW9pLN\":[\"Kanavalla sallittu enimmäiskäyttäjämäärä. Jätä tyhjäksi, jos rajaa ei haluta.\"],\"ah4fmZ\":[\"Näyttää myös esikatselut YouTubesta, Vimeosta, SoundCloudista ja vastaavista tunnetuista palveluista.\"],\"aifXak\":[\"Tällä kanavalla ei ole mediaa\"],\"ap2zBz\":[\"Löysä\"],\"az8lvo\":[\"Pois\"],\"azXSNo\":[\"Laajenna jäsenlista\"],\"azdliB\":[\"Kirjaudu tilille\"],\"b26wlF\":[\"hän/hänen\"],\"bD/+Ei\":[\"Tiukka\"],\"bQ6BJn\":[\"Määritä yksityiskohtaiset tulvasuojaussäännöt. Kukin sääntö määrittää, mitä toimintaa seurataan ja mitä tehdään kun raja-arvot ylitetään.\"],\"beV7+y\":[\"Käyttäjä saa kutsun liittyä kanavalle \",[\"channelName\"],\".\"],\"bk84cH\":[\"Poissaoloviesti\"],\"bkHdLj\":[\"Lisää IRC-palvelin\"],\"bmQLn5\":[\"Lisää sääntö\"],\"bwRvnp\":[\"Toiminto\"],\"c8+EVZ\":[\"Vahvistettu tili\"],\"cGYUlD\":[\"Median esikatseluja ei ladata.\"],\"cLF98o\":[\"Näytä kommentit (\",[\"commentCount\"],\")\"],\"cLKIDO\":[\"Ei käyttäjiä saatavilla\"],\"cSgpoS\":[\"Kiinnitä yksityiskeskustelu\"],\"cde3ce\":[\"Viesti <0>\",[\"0\"],\"\"],\"chQsxg\":[\"Kopioi muotoiltu tuloste\"],\"cl/A5J\":[\"Tervetuloa palvelimeen \",[\"__DEFAULT_IRC_SERVER_NAME__\"],\"!\"],\"cnGeoo\":[\"Poista\"],\"coPLXT\":[\"Emme tallenna IRC-viestintääsi palvelimillemme\"],\"crYH/6\":[\"SoundCloud-soitin\"],\"d3sis4\":[\"Lisää palvelin\"],\"d9aN5k\":[\"Poista \",[\"username\"],\" kanavalta\"],\"dEgA5A\":[\"Peruuta\"],\"dGi1We\":[\"Irrota tämä yksityisviestiketju\"],\"dJVuyC\":[\"poistui kanavalta \",[\"channelName\"],\" (\",[\"reason\"],\")\"],\"dMtLDE\":[\"vastaanottaja\"],\"dXqxlh\":[\"<0>⚠️ Tietoturvariski! Tämä yhteys voi olla alttiina salakuuntelulle tai välimieshyökkäyksille.\"],\"da9Q/R\":[\"Muutti kanavan asetuksia\"],\"dhJN3N\":[\"Näytä kommentit\"],\"dj2xTE\":[\"Hylkää ilmoitus\"],\"dpCzmC\":[\"Tulvasuojausasetukset\"],\"e9dQpT\":[\"Haluatko avata tämän linkin uudessa välilehdessä?\"],\"ePK91l\":[\"Muokkaa\"],\"eYBDuB\":[\"Lataa kuva tai anna URL, jossa voi käyttää valinnaista \",[\"size\"],\"-muuttujaa dynaamiseen kokoon\"],\"edBbee\":[\"Estä \",[\"username\"],\" hostmaskin perusteella (estää liittymisen samasta IP-osoitteesta/hostista)\"],\"ekfzWq\":[\"Käyttäjäasetukset\"],\"elPDWs\":[\"Mukauta IRC-asiakasohjelmaasi\"],\"eu2osY\":[\"<0>💡 Suositus: Jatka vain jos luotat tähän palvelimeen ja ymmärrät riskit. Vältä arkaluonteisten tietojen tai salasanojen jakamista tämän yhteyden kautta.\"],\"euEhbr\":[\"Liity kanavalle \",[\"channel\"],\" napsauttamalla\"],\"ez3vLd\":[\"Ota monirivisyöttö käyttöön\"],\"f0J5Ki\":[\"Palvelimien välinen viestintä voi käyttää salaamattomia yhteyksiä\"],\"f9BHJk\":[\"Varoita käyttäjää\"],\"fDOLLd\":[\"Kanavia ei löydetty.\"],\"ffzDkB\":[\"Anonyymi analytiikka:\"],\"fq1GF9\":[\"Näytä kun käyttäjät katkaisevat yhteyden palvelimeen\"],\"gEF57C\":[\"Tämä palvelin tukee vain yhtä yhteystyyppiä\"],\"gJuLUI\":[\"Estolistа\"],\"gNzMrk\":[\"Nykyinen avatar\"],\"gjPWyO\":[\"Syötä nimimerkki...\"],\"gz6UQ3\":[\"Suurenna\"],\"h6razj\":[\"Sulje pois kanavan nimimaski\"],\"hG6jnw\":[\"Aihetta ei ole asetettu\"],\"hG89Ed\":[\"Kuva\"],\"hZ6znB\":[\"Portti\"],\"ha+Bz5\":[\"esim. 100:1440\"],\"hehnjM\":[\"Määrä\"],\"hzdLuQ\":[\"Vain käyttäjät, joilla on voice tai korkeampi, voivat puhua\"],\"i0qMbr\":[\"Koti\"],\"iDNBZe\":[\"Ilmoitukset\"],\"iH8pgl\":[\"Takaisin\"],\"iL9SZg\":[\"Estä käyttäjä (nimimerkin perusteella)\"],\"iNt+3c\":[\"Takaisin kuvaan\"],\"iQvi+a\":[\"Älä varoita minua tämän palvelimen heikosta linkkiturvallisuudesta\"],\"iSLIjg\":[\"Yhdistä\"],\"iWXkHH\":[\"Halfop\"],\"iZeTtp\":[\"Palvelimen osoite\"],\"idD8Ev\":[\"Tallennettu\"],\"iivqkW\":[\"Kirjautunut\"],\"ij+Elv\":[\"Kuvan esikatselu\"],\"ilIWp7\":[\"Näytä/piilota ilmoitukset\"],\"iuaqvB\":[\"Käytä * jokerimerkkinä. Esimerkkejä: baduser!*@*, *!*@spammer.com, troll*!*@*\"],\"ixkTse\":[\"Botti\"],\"j2DGR0\":[\"Estä hostmaskin perusteella\"],\"jA4uoI\":[\"Aihe:\"],\"jLXxGK\":[\"https://example.com\"],\"jPSk57\":[\"Syy (valinnainen)\"],\"jUV7CU\":[\"Lataa avatar\"],\"jW5Uwh\":[\"Hallinnoi ulkoisen median lataamista. Pois / Turvallinen / Luotetut lähteet / Kaikki sisältö.\"],\"jXzms5\":[\"Liitteet-valinnat\"],\"jZlrte\":[\"Väri\"],\"jfC/xh\":[\"Yhteystiedot\"],\"jywMpv\":[\"#uusi-kanavan-nimi\"],\"k112DD\":[\"Lataa vanhempia viestejä\"],\"k3ID0F\":[\"Suodata jäseniä…\"],\"k65gsE\":[\"Syvempi tarkastelu\"],\"k7Zgob\":[\"Peruuta yhteys\"],\"kAVx5h\":[\"Kutsuja ei löydetty\"],\"kCLEPU\":[\"Yhdistetty palvelimeen\"],\"kF5LKb\":[\"Estetyt mallit:\"],\"kGeOx/\":[\"Liity kanavalle \",[\"0\"]],\"kITKr8\":[\"Ladataan kanavan tiloja...\"],\"kPpPsw\":[\"Olet IRC-operaattori\"],\"kWJmRL\":[\"Sinä\"],\"kfcRb0\":[\"Avatar\"],\"kjMqSj\":[\"Kopioi JSON\"],\"krViRy\":[\"Napsauta kopioidaksesi JSON-muodossa\"],\"ks71ra\":[\"Poikkeukset\"],\"kw4lRv\":[\"Kanavan puolioperaattori\"],\"kxgIRq\":[\"Valitse kanava tai lisää uusi päästäksesi alkuun.\"],\"ky6dWe\":[\"Avatarin esikatselu\"],\"l+GxCv\":[\"Ladataan kanavia...\"],\"l+IUVW\":[\"Tilin \",[\"account\"],\" vahvistus onnistui: \",[\"message\"]],\"l/siQz\":[[\"reconnectCount\",\"plural\",{\"one\":[\"yhdisti uudelleen\"],\"other\":[\"yhdisti uudelleen \",[\"reconnectCount\"],\" kertaa\"]}]],\"l5jmzx\":[[\"0\"],\" ja \",[\"1\"],\" kirjoittavat...\"],\"lHy8N5\":[\"Ladataan lisää kanavia...\"],\"lbpf14\":[\"Liity kanavaan \",[\"value\"]],\"lfFsZ4\":[\"Kanavat\"],\"lkNdiH\":[\"Tilin nimi\"],\"ln500L\":[\"ObsidianIRC\"],\"lnCMdg\":[\"Lataa kuva\"],\"loQxaJ\":[\"Olen takaisin\"],\"lvfaxv\":[\"KOTI\"],\"m16xKo\":[\"Lisää\"],\"m8flAk\":[\"Esikatselu (ei vielä lähetetty)\"],\"mEPxTp\":[\"<0>⚠️ Ole varovainen! Avaa linkkejä vain luotetuista lähteistä. Haitalliset linkit voivat vaarantaa tietoturvasi tai yksityisyytesi.\"],\"mHGdhG\":[\"Palvelimen tiedot\"],\"mHS8lb\":[\"Viesti #\",[\"0\"]],\"mMYBD9\":[\"Laaja – laajempi suojauslaajuus\"],\"mTGsPd\":[\"Kanavan aihe\"],\"mU8j6O\":[\"Ei ulkoisia viestejä (+n)\"],\"mZp8FL\":[\"Automaattinen palautuminen yksiriviseksi\"],\"mdQu8G\":[\"SinunNimimerkkisi\"],\"miSSBQ\":[\"Kommentit (\",[\"commentCount\"],\")\"],\"mvyLSy\":[\"Käyttäjä on tunnistautunut\"],\"mwtcGl\":[\"Sulje kommentit\"],\"mzI/c+\":[\"Lataa\"],\"n3fGRk\":[\"asettaja: \",[\"0\"]],\"nE9jsU\":[\"Löysä – vähemmän aggressiivinen suojaus\"],\"nNflMD\":[\"Poistu kanavalta\"],\"nPXkBi\":[\"Ladataan WHOIS-tietoja...\"],\"nQnxxF\":[\"Viesti #\",[\"0\"],\" (Shift+Enter = uusi rivi)\"],\"nWMRxa\":[\"Irrota kiinnitys\"],\"nkC032\":[\"Ei tulvasuojausprofiilia\"],\"o69z4d\":[\"Lähetä varoitusviesti käyttäjälle \",[\"username\"]],\"o9ylQi\":[\"Hae GIF-kuvia aloittaaksesi\"],\"oFGkER\":[\"Palvelintiedotteet\"],\"oOi11l\":[\"Siirry loppuun\"],\"oQEzQR\":[\"Uusi DM\"],\"oXOSPE\":[\"Verkossa\"],\"oal760\":[\"Välimieshyökkäykset palvelinlinkeissä ovat mahdollisia\"],\"oeqmmJ\":[\"Luotetut lähteet\"],\"ovBPCi\":[\"Oletus\"],\"p0Z69r\":[\"Malli ei voi olla tyhjä\"],\"p1KgtK\":[\"Äänen lataaminen epäonnistui\"],\"p59pEv\":[\"Lisätiedot\"],\"p7sRI6\":[\"Ilmoita muille, kun kirjoitat\"],\"pBm1od\":[\"Salainen kanava\"],\"pNmiXx\":[\"Oletusnimimerkkisi kaikille palvelimille\"],\"pUUo9G\":[\"Isäntänimi:\"],\"pVGPmz\":[\"Tilin salasana\"],\"peNE68\":[\"Pysyvä\"],\"plhHQt\":[\"Ei tietoja\"],\"pm6+q5\":[\"Tietoturvavaroitus\"],\"pn5qSs\":[\"Lisätiedot\"],\"q0cR4S\":[\"on nyt tunnettu nimellä **\",[\"newNick\"],\"**\"],\"qFcunY\":[\"Kanava ei näy LIST- tai NAMES-komennoissa\"],\"qLpTm/\":[\"Poista reaktio \",[\"emoji\"]],\"qVkGWK\":[\"Kiinnitä\"],\"qY8wNa\":[\"Kotisivu\"],\"qb0xJ7\":[\"Käytä jokerimerkkejä: * vastaa mitä tahansa merkkijonoa, ? vastaa yhtä merkkiä. Esimerkkejä: nick!*@*, *!*@host.com, *!*user@*\"],\"qhzpRq\":[\"Kanavan avain (+k)\"],\"qtoOYG\":[\"Ei rajaa\"],\"r1W2AS\":[\"Tiedostopalvelimen kuva\"],\"rIPR2O\":[\"Aihe asetettu aiemmin kuin (min sitten)\"],\"rMMSYo\":[\"Enimmäispituus on \",[\"0\"]],\"rWtzQe\":[\"Verkko jakautui ja yhdistyi uudelleen. ✅\"],\"rYG2u6\":[\"Odota hetki...\"],\"rdUucN\":[\"Esikatselu\"],\"rjGI/Q\":[\"Yksityisyys\"],\"rk8iDX\":[\"Ladataan GIF-kuvia...\"],\"rn6SBY\":[\"Poista mykistys\"],\"s/UKqq\":[\"Poistettiin kanavalta\"],\"s8cATI\":[\"liittyi kanavalle \",[\"channelName\"]],\"sCO9ue\":[\"Yhteydessä palvelimeen <0>\",[\"serverName\"],\" on seuraavia tietoturvaongelmia:\"],\"sGH11W\":[\"Palvelin\"],\"sHI1H+\":[\"on nyt tunnettu nimellä **\",[\"newNick\"],\"**\"],\"sJyV04\":[[\"inviter\"],\" kutsui sinut liittymään kanavalle \",[\"channel\"]],\"sby+1/\":[\"Kopioi napsauttamalla\"],\"sfN25C\":[\"Oikea nimesi tai koko nimesi\"],\"sliuzR\":[\"Avaa linkki\"],\"sqrO9R\":[\"Mukautetut maininnat\"],\"sr6RdJ\":[\"Monirivinen Shift+Enter-painikkeella\"],\"swrCpB\":[\"Kanava on nimetty uudelleen nimestä \",[\"oldName\"],\" nimeen \",[\"newName\"],\" käyttäjän \",[\"user\"],\" toimesta\",[\"0\"]],\"sxkWRg\":[\"Lisäasetukset\"],\"t/YqKh\":[\"Poista\"],\"t47eHD\":[\"Yksilöllinen tunnuksesi tällä palvelimella\"],\"tAkAh0\":[\"URL, jossa valinnainen \",[\"size\"],\"-muuttuja dynaamista kokoa varten. Esimerkki: https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"tXLJS3\":[\"Näytä tai piilota kanavaluettelon sivupalkki\"],\"tfDRzk\":[\"Tallenna\"],\"tiBsJk\":[\"poistui kanavalta \",[\"channelName\"]],\"tt4/UD\":[\"poistui (\",[\"reason\"],\")\"],\"u0TcnO\":[\"Nimimerkki {nick} on jo käytössä, yritetään uudelleen nimellä {newNick}\"],\"u0a8B4\":[\"Tunnistaudu IRC-operaattoriksi ylläpito-oikeuksia varten\"],\"u0rWFU\":[\"Luotu myöhemmin kuin (min sitten)\"],\"u72w3t\":[\"Estettävät käyttäjät ja mallit\"],\"u7jc2L\":[\"poistui\"],\"uAQUqI\":[\"Tila\"],\"uB85T3\":[\"Tallennus epäonnistui: \",[\"msg\"]],\"uV3DOL\":[\"G-Line\"],\"uW3lLI\":[\"IRC-palvelimet:\"],\"usSSr/\":[\"Zoomaustaso\"],\"v7uvcf\":[\"Ohjelmisto:\"],\"vE8kb+\":[\"Käytä Shift+Enter uudelle riville (Enter lähettää)\"],\"vERlcd\":[\"Profiili\"],\"vK0RL8\":[\"Ei aihetta\"],\"vSJd18\":[\"Video\"],\"vXIe7J\":[\"Kieli\"],\"vaHYxN\":[\"Oikea nimi\"],\"vhjbKr\":[\"Poissa\"],\"w4NYox\":[[\"title\"],\" asiakasohjelma\"],\"w8xQRx\":[\"Virheellinen arvo\"],\"wFjjxZ\":[\"potkittiin kanavalta \",[\"channelName\"],\" käyttäjän \",[\"username\"],\" toimesta (\",[\"reason\"],\")\"],\"wGjaGl\":[\"Porttikieltopoikkeuksia ei löydetty\"],\"wPrGnM\":[\"Kanavan ylläpitäjä\"],\"wRkP2d\":[\"GIF\"],\"wbm86v\":[\"Näytä kun käyttäjät liittyvät kanavalle tai poistuvat\"],\"whqZ9r\":[\"Lisäsanat tai -lauseet korostettavaksi\"],\"wm7RV4\":[\"Ilmoitusääni\"],\"wz/Yoq\":[\"Viestisi voidaan siepata palvelimien välillä välitettäessä\"],\"xCJdfg\":[\"Tyhjennä\"],\"xUHRTR\":[\"Tunnistaudu automaattisesti operaattoriksi yhdistäessä\"],\"xWHwwQ\":[\"Estot\"],\"xYilR2\":[\"Media\"],\"xceQrO\":[\"Vain suojatut WebSocket-yhteydet ovat tuettuja\"],\"xdtXa+\":[\"kanavan-nimi\"],\"xfXC7q\":[\"Tekstikanavat\"],\"xlCYOE\":[\"Haetaan lisää viestejä...\"],\"xlhswE\":[\"Vähimmäisarvo on \",[\"0\"]],\"xq97Ci\":[\"Lisää sana tai lause...\"],\"xuRqRq\":[\"Käyttäjäraja (+l)\"],\"xwF+7J\":[[\"0\"],\" kirjoittaa...\"],\"yNeucF\":[\"Tämä palvelin ei tue laajennettua profiilimetadataa (IRCv3 METADATA -laajennus). Lisäkentät kuten avatar, näyttönimi ja tila eivät ole käytettävissä.\"],\"yPlrca\":[\"Kanavan avatar\"],\"yQE2r9\":[\"Ladataan\"],\"ySU+JY\":[\"sinun@sahkoposti.fi\"],\"yTX1Rt\":[\"Oper-käyttäjänimi\"],\"yYOzWD\":[\"lokit\"],\"yfx9Re\":[\"IRC-operaattorin salasana\"],\"ygCKqB\":[\"Pysäytä\"],\"ymDxJx\":[\"IRC-operaattorin käyttäjänimi\"],\"yrpRsQ\":[\"Lajittele nimen mukaan\"],\"yz7wBu\":[\"Sulje\"],\"zJw+jA\":[\"asettaa tilan: \",[\"0\"]],\"zebeLu\":[\"Syötä oper-käyttäjänimi\"],\"zpr0Bw\":[\"GZ-Line\"]}"); \ No newline at end of file +/*eslint-disable*/export const messages=JSON.parse("{\"+5VMrz\":[\"Virheellinen mallimuoto. Käytä nick!käyttäjä@isäntä-muotoa (jokerimerkit * sallittu)\"],\"+6NQQA\":[\"Yleinen tukikanava\"],\"+6NyRG\":[\"Asiakasohjelma\"],\"+K0AvT\":[\"Katkaise yhteys\"],\"+cyFdH\":[\"Oletusviesti poissaoloilmoitukselle\"],\"+mVPqU\":[\"Näytä Markdown-muotoilu viesteissä\"],\"+vqCJH\":[\"Tilin käyttäjänimi tunnistautumista varten\"],\"+yPBXI\":[\"Valitse tiedosto\"],\"+zy2Nq\":[\"Tyyppi\"],\"/09cao\":[\"Heikko linkkiturvallisuus (taso \",[\"securityLevel\"],\")\"],\"/3BQ4J\":[\"Kanavan ulkopuoliset käyttäjät eivät voi lähettää viestejä sille\"],\"/6BzZF\":[\"Näytä/piilota jäsenlista\"],\"/TNOPk\":[\"Käyttäjä on poissa\"],\"/XQgft\":[\"Selaa\"],\"/cF7Rs\":[\"Äänenvoimakkuus\"],\"/dqduX\":[\"Seuraava sivu\"],\"/fc3q4\":[\"Kaikki sisältö\"],\"/kISDh\":[\"Ota ilmoitusäänet käyttöön\"],\"/n04sB\":[\"Kill\"],\"/rTz0M\":[\"Ääni\"],\"/rfkZe\":[\"Toista ääniä maininnoista ja viesteistä\"],\"0/0ZGA\":[\"Kanavan nimimaski\"],\"0D6j7U\":[\"Lue lisää mukautetuista säännöistä →\"],\"0XsHcR\":[\"Poista käyttäjä\"],\"0ZpE//\":[\"Lajittele käyttäjämäärän mukaan\"],\"0bEPwz\":[\"Aseta poissa\"],\"0dGkPt\":[\"Laajenna kanavaluettelo\"],\"0gS7M5\":[\"Näyttönimi\"],\"0kS+M8\":[\"EsimerkKi\"],\"0rgoY7\":[\"Yhdistä vain valitsemiisi palvelimiin\"],\"0wdd7X\":[\"Liity\"],\"0wkVYx\":[\"Yksityisviestit\"],\"111uHX\":[\"Linkin esikatselu\"],\"196EG4\":[\"Poista yksityiskeskustelu\"],\"1DSr1i\":[\"Rekisteröidy tilille\"],\"1O/24y\":[\"Näytä/piilota kanavaluettelo\"],\"1TNIig\":[\"Open\"],\"1VPJJ2\":[\"Ulkoisen linkin varoitus\"],\"1ZC/dv\":[\"Ei lukemattomia mainintoja tai viestejä\"],\"1pO1zi\":[\"Palvelimen nimi on pakollinen\"],\"1uwfzQ\":[\"Näytä kanavan aihe\"],\"268g7c\":[\"Syötä näyttönimi\"],\"2FOFq1\":[\"Verkon palvelinoperaattorit voisivat mahdollisesti lukea viestisi\"],\"2FYpfJ\":[\"Lisää\"],\"2HF1Y2\":[[\"inviter\"],\" kutsui \",[\"target\"],\" liittymään kanavalle \",[\"channel\"]],\"2I70QL\":[\"Näytä käyttäjäprofiilitiedot\"],\"2QYdmE\":[\"Käyttäjät:\"],\"2QpEjG\":[\"poistui\"],\"2YE223\":[\"Viesti #\",[\"0\"],\" (Enter = uusi rivi, Shift+Enter = lähetä)\"],\"2bimFY\":[\"Käytä palvelimen salasanaa\"],\"2iTmdZ\":[\"Paikallinen tallennustila:\"],\"2odkwe\":[\"Tiukka – aggressiivisempi suojaus\"],\"2uDhbA\":[\"Syötä kutsuttavan käyttäjänimi\"],\"2ygf/L\":[\"← Takaisin\"],\"2zEgxj\":[\"Hae GIF-kuvia...\"],\"3RdPhl\":[\"Nimeä kanava uudelleen\"],\"3THokf\":[\"Voice-käyttäjä\"],\"3TSz9S\":[\"Pienennä\"],\"3jBDvM\":[\"Kanavan näyttönimi\"],\"3ryuFU\":[\"Valinnaiset kaatumisraportit sovelluksen parantamiseksi\"],\"3uBF/8\":[\"Sulje katseluohjelma\"],\"3uwW8F\":[\"https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"3xf8Kz\":[\"Syötä tilin nimi...\"],\"4/Rr0R\":[\"Kutsu käyttäjä nykyiselle kanavalle\"],\"4EZrJN\":[\"Säännöt\"],\"4JJtW9\":[\"#overflow\"],\"4NqeT4\":[\"Tulvasuojausprofiili (+F)\"],\"4RZQRK\":[\"Mitä olet tekemässä?\"],\"4hfTrB\":[\"Nimimerkki\"],\"4n99LO\":[\"Jo kanavalla \",[\"0\"]],\"4t6vMV\":[\"Vaihda automaattisesti yksiriviseen lyhyille viesteille\"],\"4vsHmf\":[\"Aika (min)\"],\"4x/Axu\":[\"Bouncerissasi ei ole vielä verkkoja. Lisää yksi päästäksesi alkuun.\"],\"5+INAX\":[\"Korosta viestit, joissa mainitaan sinut\"],\"5R5Pv/\":[\"Oper-nimi\"],\"678PKt\":[\"Verkon nimi\"],\"6Aih4U\":[\"Poissa verkosta\"],\"6CO3WE\":[\"Salasana vaaditaan kanavalle liittymiseen. Jätä tyhjäksi poistaaksesi avaimen.\"],\"6HhMs3\":[\"Lähtöviesti\"],\"6V3Ea3\":[\"Kopioitu\"],\"6lGV3K\":[\"Näytä vähemmän\"],\"6yFOEi\":[\"Syötä oper-salasana...\"],\"7+IHTZ\":[\"Ei valittua tiedostoa\"],\"73fnil\":[\"TLS\"],\"73hrRi\":[\"nick!käyttäjä@isäntä (esim. spam*!*@*, *!*@badhost.com)\"],\"7QkKyN\":[\"Lähetä yksityisviesti\"],\"7U1W7c\":[\"Hyvin löysä\"],\"7Y1YQj\":[\"Oikea nimi:\"],\"7YHArF\":[\"— avaa katseluohjelmassa\"],\"7fjnVl\":[\"Hae käyttäjiä...\"],\"7jL88x\":[\"Poistetaanko tämä viesti? Toimintoa ei voi kumota.\"],\"7nGhhM\":[\"Mitä ajattelet?\"],\"7sEpu1\":[\"Jäsenet — \",[\"0\"]],\"7sNhEz\":[\"Käyttäjänimi\"],\"8H0Q+x\":[\"Lue lisää profiileista →\"],\"8Phu0A\":[\"Näytä kun käyttäjät vaihtavat nimimerkkinsä\"],\"8XTG9e\":[\"Syötä oper-salasana\"],\"8XsV2J\":[\"Yritä lähettää uudelleen\"],\"8ZsakT\":[\"Salasana\"],\"8kR84m\":[\"Olet avaamassa ulkoista linkkiä:\"],\"8lCgih\":[\"Poista sääntö\"],\"8p/xVT\":[[\"0\",\"plural\",{\"one\":[[\"1\"]],\"other\":[[\"2\"]]}]],\"8wRzac\":[[\"joinCount\",\"plural\",{\"one\":[\"liittyi\"],\"other\":[\"liittyi \",[\"joinCount\"],\" kertaa\"]}]],\"9BMLnJ\":[\"Yhdistä uudelleen palvelimeen\"],\"9OEgyT\":[\"Lisää reaktio\"],\"9PQ8m2\":[\"G-Line (maailmanlaajuinen esto)\"],\"9Qs99X\":[\"Sähköposti:\"],\"9QupBP\":[\"Poista malli\"],\"9W7tl5\":[\"(muuttumaton)\"],\"9bG48P\":[\"Lähetetään\"],\"9f5f0u\":[\"Yksityisyyteen liittyviä kysymyksiä? Ota yhteyttä:\"],\"9iweoP\":[\"Verkot palvelimella \",[\"0\"]],\"9unqs3\":[\"Poissa:\"],\"9v3hwv\":[\"Palvelimia ei löydetty.\"],\"9zb2WA\":[\"Yhdistetään\"],\"A1taO8\":[\"Hae\"],\"A2adVi\":[\"Lähetä kirjoitusilmoituksia\"],\"A9Rhec\":[\"Kanavan nimi\"],\"AWOSPo\":[\"Lähennä\"],\"AXSpEQ\":[\"Oper yhdistäessä\"],\"AeXO77\":[\"Tili\"],\"AhNP40\":[\"Selaa\"],\"Ai2U7L\":[\"Isäntä\"],\"AjBQnf\":[\"Vaihtoi nimimerkin\"],\"AmXVh6\":[\"https://example.com/avatar.png\"],\"AnRu/j\":[\"Peruuta vastaus\"],\"ApSx0O\":[\"Löydettiin \",[\"0\"],\" viestiä, jotka vastaavat \\\"\",[\"searchQuery\"],\"\\\"\"],\"AxPAXW\":[\"Tuloksia ei löydetty\"],\"AyNqAB\":[\"Näytä kaikki palvelintapahtumat chatissa\"],\"B/QqGw\":[\"Poissa näppäimistöltä\"],\"B0sB2k\":[\"Salaamaton\"],\"B8AaMI\":[\"Tämä kenttä on pakollinen\"],\"BA2c49\":[\"Palvelin ei tue kehittynyttä LIST-suodatusta\"],\"BDKt3I\":[[\"0\"],\", \",[\"1\"],\", \",[\"2\"],\" ja \",[\"3\"],\" muuta kirjoittavat...\"],\"BGul2A\":[\"Sinulla on tallentamattomia muutoksia. Haluatko varmasti sulkea tallentamatta?\"],\"BIf9fi\":[\"Tilaviestisi\"],\"BZz3md\":[\"Henkilökohtainen verkkosivustosi\"],\"Bgm/H7\":[\"Salli monirivisyöttö\"],\"BiQIl1\":[\"Kiinnitä tämä yksityisviesteistä\"],\"BlNZZ2\":[\"Siirry viestiin napsauttamalla\"],\"Bowq3c\":[\"Vain operaattorit voivat muuttaa kanavan aihetta\"],\"Btozzp\":[\"Tämä kuva on vanhentunut\"],\"Bycfjm\":[\"Yhteensä: \",[\"0\"]],\"C6IBQc\":[\"Kopioi koko JSON\"],\"C9L9wL\":[\"Tiedonkeruu\"],\"CDq4wC\":[\"Moderoi käyttäjää\"],\"CHVRxG\":[\"Viesti @\",[\"0\"],\" (Shift+Enter = uusi rivi)\"],\"CN9zdR\":[\"Oper-nimi ja salasana ovat pakollisia\"],\"CW3sYa\":[\"Lisää reaktio \",[\"emoji\"]],\"CaAkqd\":[\"Näytä poistumisviestit\"],\"CbvaYj\":[\"Estä nimimerkin perusteella\"],\"CcK+Ft\":[\"PDF\"],\"Ce8q3L\":[\"Valitse kanava\"],\"CsekCi\":[\"Normaali\"],\"D+NlUC\":[\"Järjestelmä\"],\"D28t6+\":[\"liittyi ja poistui\"],\"DB8zMK\":[\"Käytä\"],\"DBcWHr\":[\"Mukautettu ilmoitusäänitiedosto\"],\"DTy9Xw\":[\"Median esikatselut\"],\"Dj4pSr\":[\"Valitse turvallinen salasana\"],\"Du+zn+\":[\"Haetaan...\"],\"Du2T2f\":[\"Asetusta ei löydetty\"],\"DwsSVQ\":[\"Käytä suodattimet ja päivitä\"],\"E3W/zd\":[\"Oletusnimimerkki\"],\"E6nRW7\":[\"Kopioi URL\"],\"E703RG\":[\"Tilat:\"],\"EAeu1Z\":[\"Lähetä kutsu\"],\"EFKJQT\":[\"Asetus\"],\"EGPQBv\":[\"Mukautetut tulvasäännöt (+f)\"],\"ELik0r\":[\"Lue koko tietosuojakäytäntö\"],\"EPbeC2\":[\"Näytä tai muokkaa kanavan aihetta\"],\"EQCDNT\":[\"Syötä oper-käyttäjätunnus...\"],\"EUvulZ\":[\"Löydettiin 1 viesti, joka vastaa \\\"\",[\"searchQuery\"],\"\\\"\"],\"EatZYJ\":[\"Seuraava kuva\"],\"EdQY6l\":[\"Ei mitään\"],\"EnqLYU\":[\"Hae palvelimia...\"],\"F0OKMc\":[\"Muokkaa palvelinta\"],\"F6Int2\":[\"Ota korostukset käyttöön\"],\"FDoLyE\":[\"Enimmäiskäyttäjämäärä\"],\"FUU/hZ\":[\"Hallitsee, kuinka paljon ulkoista mediaa ladataan chatissa.\"],\"Fdp03t\":[\"päälle\"],\"FfPWR0\":[\"Ikkuna\"],\"FjkaiT\":[\"Loitonna\"],\"FlqOE9\":[\"Mitä tämä tarkoittaa:\"],\"FolHNl\":[\"Hallinnoi tiliäsi ja tunnistautumista\"],\"Fp2Dif\":[\"Poistui palvelimelta\"],\"G5KmCc\":[\"GZ-Line (maailmanlaajuinen Z-Line)\"],\"GDs0lz\":[\"<0>Riski: Arkaluonteiset tiedot (viestit, yksityiskeskustelut, tunnistautumistiedot) voisivat paljastua verkon ylläpitäjille tai hyökkääjille, jotka sijaitsevat IRC-palvelimien välissä.\"],\"GR+2I3\":[\"Lisää kutsumask (esim. nick!*@*, *!*@host.com)\"],\"GRLyMU\":[\"Sulje irrotettu palvelintiedotteet-näkymä\"],\"GlHnXw\":[\"Nimen vaihto epäonnistui: \",[\"error\"],\" \",[\"0\"]],\"GswZF3\":[\"Esikatselu:\"],\"GtmO8/\":[\"lähettäjä\"],\"GtuHUQ\":[\"Nimeä tämä kanava uudelleen palvelimella. Kaikki käyttäjät näkevät uuden nimen.\"],\"GuGfFX\":[\"Näytä/piilota haku\"],\"GxkJXS\":[\"Ladataan...\"],\"GzbwnK\":[\"Liittyi kanavalle\"],\"GzsUDB\":[\"Laajennettu profiili\"],\"H/PnT8\":[\"Lisää emoji\"],\"H6Izzl\":[\"Suosikki värikoodisi\"],\"H9jIv+\":[\"Näytä liittymiset/poistumiset\"],\"HAKBY9\":[\"Lataa tiedostoja\"],\"HdE1If\":[\"Kanava\"],\"Hk4AW9\":[\"Suosikki näyttönimesi\"],\"HmHDk7\":[\"Valitse jäsen\"],\"HrQzPU\":[\"Kanavat verkossa \",[\"networkName\"]],\"I2tXQ5\":[\"Viesti @\",[\"0\"],\" (Enter = uusi rivi, Shift+Enter = lähetä)\"],\"I6bw/h\":[\"Estä käyttäjä\"],\"I92Z+b\":[\"Ota ilmoitukset käyttöön\"],\"I9D72S\":[\"Haluatko varmasti poistaa tämän viestin? Tätä toimintoa ei voi kumota.\"],\"IA+1wo\":[\"Näytä kun käyttäjiä poistetaan kanavilta\"],\"IDwkJx\":[\"IRC-operaattori\"],\"ILlU+s\":[\"Tiedot:\"],\"IUwGEM\":[\"Tallenna muutokset\"],\"IVeGK6\":[[\"0\"],\", \",[\"1\"],\" ja \",[\"2\"],\" kirjoittavat...\"],\"IgrLD/\":[\"Tauko\"],\"Im6JED\":[\"WHISPER\"],\"ImOQa9\":[\"Vastaa\"],\"IoHMnl\":[\"Enimmäisarvo on \",[\"0\"]],\"IvMj+0\":[\"Op\"],\"J28zul\":[\"Yhdistetään...\"],\"J5T9NW\":[\"Käyttäjätiedot\"],\"J8Y5+z\":[\"Hups! Verkon jako! ⚠️\"],\"JBHkBA\":[\"Poistui kanavalta\"],\"JCwL0Q\":[\"Syötä syy (valinnainen)\"],\"JFciKP\":[\"Vaihda\"],\"JXGkhG\":[\"Muuta kanavan nimeä (vain operaattorit)\"],\"JcD7qf\":[\"Lisää toimintoja\"],\"JdkA+c\":[\"Salainen (+s)\"],\"Jmu12l\":[\"Palvelimen kanavat\"],\"JvQ++s\":[\"Ota Markdown käyttöön\"],\"K2jwh/\":[\"WHOIS-tietoja ei saatavilla\"],\"KAXSwC\":[\"Voice\"],\"KDfTdX\":[\"Poista viesti\"],\"KKBlUU\":[\"Upotus\"],\"KM0pLb\":[\"Tervetuloa kanavalle!\"],\"KR6W2h\":[\"Poista esto käyttäjältä\"],\"KV+Bi1\":[\"Vain kutsulla (+i)\"],\"KdCtwE\":[\"Kuinka monta sekuntia tulvatoimintaa seurataan ennen laskurien nollaamista\"],\"Kkezga\":[\"Palvelimen salasana\"],\"KsiQ/8\":[\"Käyttäjät täytyy kutsua kanavalle\"],\"L+gB/D\":[\"Kanavan tiedot\"],\"LC1a7n\":[\"IRC-palvelin on ilmoittanut, että sen palvelimien väliset linkit ovat heikosti suojattuja. Tämä tarkoittaa, että verkossa välitettäviä viestejäsi ei välttämättä salata asianmukaisesti tai SSL/TLS-sertifikaatteja ei tarkisteta oikein.\"],\"LNfLR5\":[\"Näytä poistamiset\"],\"LP+1Z7\":[\"Lisää verkko\"],\"LQb0W/\":[\"Näytä kaikki tapahtumat\"],\"LU7/yA\":[\"Vaihtoehtoinen nimi käyttöliittymässä näytettäväksi. Voi sisältää välilyöntejä, emojeja ja erikoismerkkejä. Todellista kanavan nimeä (\",[\"channelName\"],\") käytetään edelleen IRC-komennoissa.\"],\"LUb9O7\":[\"Kelvollinen palvelinportti on pakollinen\"],\"Lb+BUl\":[\"https://example.com/avatar.jpg\"],\"LcET2C\":[\"Tietosuojakäytäntö\"],\"LcuSDR\":[\"Hallinnoi profiilitietojasi ja metatietoja\"],\"LqLS9B\":[\"Näytä nimimerkinvaihdot\"],\"LsDQt2\":[\"Kanavan asetukset\"],\"LtI9AS\":[\"Omistaja\"],\"LuNhhL\":[\"reagoi tähän viestiin\"],\"M/AZNG\":[\"URL avatar-kuvaasi\"],\"M/WIer\":[\"Lähetä viesti\"],\"M8er/5\":[\"Nimi:\"],\"MHk+7g\":[\"Edellinen kuva\"],\"MRorGe\":[\"Lähetä viesti käyttäjälle\"],\"MVbSGP\":[\"Aikaikkuna (sekuntia)\"],\"MkpcsT\":[\"Viestisi ja asetuksesi tallennetaan paikallisesti laitteellesi\"],\"MzPdC2\":[\"Palvelimen salasana (PASS)\"],\"N/hDSy\":[\"Merkitse botiksi – yleensä 'on' tai tyhjä\"],\"N6j2JH\":[\"Muokkaa: \",[\"0\"]],\"N7TQbE\":[\"Kutsu käyttäjä kanavalle \",[\"channelName\"]],\"NCca/o\":[\"Syötä oletusnimimerkki...\"],\"Nqs6B9\":[\"Näyttää kaiken ulkoisen median. Mikä tahansa URL voi aiheuttaa yhteyspyynnön tuntemattomalle palvelimelle.\"],\"Nt+9O7\":[\"Käytä WebSocket-yhteyttä TCP:n sijaan\"],\"NxIHzc\":[\"Katkaise yhteys\"],\"O+v/cL\":[\"Selaa kaikkia palvelimen kanavia\"],\"OCGpR4\":[\"(peritty)\"],\"ODwSCk\":[\"Lähetä GIF\"],\"OGQ5kK\":[\"Määritä ilmoitusäänet ja korostukset\"],\"OIPt1Z\":[\"Näytä tai piilota jäsenlistan sivupalkki\"],\"OKSNq/\":[\"Hyvin tiukka\"],\"ONWvwQ\":[\"Lähetä\"],\"OVKoQO\":[\"Tilin salasana tunnistautumista varten\"],\"ObsidianIRC - Bringing IRC to the future\":[\"ObsidianIRC - Viedään IRC tulevaisuuteen\"],\"OhCpra\":[\"Aseta aihe…\"],\"OkltoQ\":[\"Estä \",[\"username\"],\" nimimerkin perusteella (estää liittymisen samalla nimimerkillä)\"],\"P+t/Te\":[\"Ei lisätietoja\"],\"P42Wcc\":[\"Turvallinen\"],\"PD38l0\":[\"Kanavan avatarin esikatselu\"],\"PD9mEt\":[\"Kirjoita viesti...\"],\"PPqfdA\":[\"Avaa kanavan asetukset\"],\"PSCjfZ\":[\"Aihe, joka näytetään tälle kanavalle. Kaikki käyttäjät voivat nähdä aiheen.\"],\"PZCecv\":[\"PDF-esikatselu\"],\"PeLgsC\":[[\"c\",\"plural\",{\"one\":[\"1 kerta\"],\"other\":[[\"c\"],\" kertaa\"]}]],\"PguS2C\":[\"Lisää poikkeusmaski (esim. nick!*@*, *!*@host.com)\"],\"Pil5Ty\":[\"Näytetään \",[\"displayedChannelsCount\"],\"/\",[\"0\"],\" kanavaa\"],\"PqhVlJ\":[\"Estä käyttäjä (hostmaskin perusteella)\"],\"Q+chwU\":[\"Käyttäjätunnus:\"],\"Q3v9Wc\":[\"Kyllä, poista\"],\"Q6hhn8\":[\"Asetukset\"],\"QF4a34\":[\"Syötä käyttäjänimi\"],\"QGqSZ2\":[\"Väri ja muotoilu\"],\"QJQd1J\":[\"Muokkaa profiilia\"],\"QSzGDE\":[\"Toimeton\"],\"QUlny5\":[\"Tervetuloa palvelimeen \",[\"0\"],\"!\"],\"Qoq+GP\":[\"Lue lisää\"],\"QuSkCF\":[\"Suodata kanavia...\"],\"QwUrDZ\":[\"vaihtoi aiheen: \",[\"topic\"]],\"R0UH07\":[\"Kuva \",[\"0\"],\"/\",[\"1\"]],\"R7SsBE\":[\"Mykistä\"],\"R8rf1X\":[\"Aseta aihe napsauttamalla\"],\"RArB3D\":[\"potkittiin kanavalta \",[\"channelName\"],\" käyttäjän \",[\"username\"],\" toimesta\"],\"RI3cWd\":[\"Tutustu IRC:n maailmaan ObsidianIRC:llä\"],\"RMMaN5\":[\"Moderoitu (+m)\"],\"RWw9Lg\":[\"Sulje ikkuna\"],\"RZ2BuZ\":[\"Tilin \",[\"account\"],\" rekisteröinti vaatii vahvistuksen: \",[\"message\"]],\"RySp6q\":[\"Piilota kommentit\"],\"S5Togi\":[\"Ladataan verkkoja bouncerista…\"],\"SPKQTd\":[\"Nimimerkki on pakollinen\"],\"SPVjfj\":[\"Oletusarvo on 'ei syytä', jos jätetään tyhjäksi\"],\"SQKPvQ\":[\"Kutsu käyttäjä\"],\"STmlpb\":[\"Back to network list\"],\"SkZcl+\":[\"Valitse valmis tulvasuojausprofiili. Nämä profiilit tarjoavat tasapainoisia suojausasetuksia eri käyttötarkoituksiin.\"],\"Slr+3C\":[\"Vähimmäiskäyttäjämäärä\"],\"Spnlre\":[\"Kutsuit \",[\"target\"],\" liittymään kanavalle \",[\"channel\"]],\"T/ckN5\":[\"Avaa katseluohjelmassa\"],\"T91vKp\":[\"Toista\"],\"TV2Wdu\":[\"Lue, miten käsittelemme tietojasi ja suojelemme yksityisyyttäsi.\"],\"TgFpwD\":[\"Käytetään...\"],\"TkzSFB\":[\"Ei muutoksia\"],\"TtserG\":[\"Syötä oikea nimi\"],\"Ttz9J1\":[\"Syötä salasana...\"],\"Tz0i8g\":[\"Asetukset\"],\"U3pytU\":[\"Admin\"],\"UDb2YD\":[\"Reagoi\"],\"UE4KO5\":[\"*kanava*\"],\"UGT5vp\":[\"Tallenna asetukset\"],\"UV5hLB\":[\"Estoja ei löydetty\"],\"Uaj3Nd\":[\"Tilaviestit\"],\"Ue3uny\":[\"Oletus (ei profiilia)\"],\"UkARhe\":[\"Normaali – tavallinen suojaus\"],\"Umn7Cj\":[\"Ei vielä kommentteja. Ole ensimmäinen!\"],\"UtUIRh\":[[\"0\"],\" vanhempaa viestiä\"],\"UwzP+U\":[\"Suojattu yhteys\"],\"V0/A4O\":[\"Kanavan omistaja\"],\"V4qgxE\":[\"Luotu aiemmin kuin (min sitten)\"],\"V8yTm6\":[\"Tyhjennä haku\"],\"VJMMyz\":[\"ObsidianIRC – IRC:n tulevaisuuteen\"],\"VJScHU\":[\"Syy\"],\"VLsmVV\":[\"Mykistä ilmoitukset\"],\"VbyRUy\":[\"Kommentit\"],\"Vmx0mQ\":[\"Asettanut:\"],\"VqnIZz\":[\"Lue tietosuojakäytäntömme ja tiedonkäsittelytapamme\"],\"VrMygG\":[\"Vähimmäispituus on \",[\"0\"]],\"VrnTui\":[\"Pronominisi, näytetään profiilissasi\"],\"W8E3qn\":[\"Tunnistautunut tili\"],\"WAakm9\":[\"Poista kanava\"],\"WFxTHC\":[\"Lisää porttikieltomaski (esim. nick!*@*, *!*@host.com)\"],\"WN1g9F\":[\"Palvelimen osoite on pakollinen\"],\"WRYdXW\":[\"Äänentoistoasento\"],\"WUOH5B\":[\"Estä käyttäjä\"],\"WWEXnZ\":[[\"0\",\"plural\",{\"one\":[\"Näytä 1 kohde lisää\"],\"other\":[\"Näytä \",[\"1\"],\" kohdetta lisää\"]}]],\"Weq9zb\":[\"Yleiset\"],\"Wfj7Sk\":[\"Mykistä tai poista mykistys ilmoitusäänistä\"],\"Wm7gbG\":[\"GitHub:\"],\"WyeHWY\":[\"*spam*\"],\"WzMCru\":[\"Käyttäjäprofiili\"],\"X6S3lt\":[\"Hae asetuksia, kanavia, palvelimia...\"],\"XEHan5\":[\"Jatka silti\"],\"XI1+wb\":[\"Virheellinen muoto\"],\"XIXeuC\":[\"Viesti @\",[\"0\"]],\"XMS+k4\":[\"Aloita yksityiskeskustelu\"],\"XWgxXq\":[\"Albumi\"],\"Xd7+IT\":[\"Irrota yksityiskeskustelu\"],\"Xm/s+u\":[\"Näyttö\"],\"Xp2n93\":[\"Näyttää mediaa palvelimesi luotetusta tiedostoisännästä. Ulkoisille palveluille ei lähetetä pyyntöjä.\"],\"XvjC4F\":[\"Tallennetaan...\"],\"Y/qryO\":[\"Hakua vastaavia käyttäjiä ei löydetty\"],\"YAqRpI\":[\"Tilin \",[\"account\"],\" rekisteröinti onnistui: \",[\"message\"]],\"YEfzvP\":[\"Suojattu aihe (+t)\"],\"YQOn6a\":[\"Pienennä jäsenlista\"],\"YRCoE9\":[\"Kanavan operaattori\"],\"YURQaF\":[\"Näytä profiili\"],\"YdBSvr\":[\"Hallinnoi median näyttöä ja ulkoista sisältöä\"],\"Yj6U3V\":[\"Ei keskuspalvelinta:\"],\"YjvpGx\":[\"Pronominit\"],\"YqH4l4\":[\"Ei avainta\"],\"YyUPpV\":[\"Tili:\"],\"ZJSWfw\":[\"Viesti, joka näytetään katkaistessasi yhteyden palvelimeen\"],\"ZR1dJ4\":[\"Kutsut\"],\"ZdWg0V\":[\"Avaa selaimessa\"],\"ZhRBbl\":[\"Hae viestejä…\"],\"Zmcu3y\":[\"Lisäsuodattimet\"],\"a2/8e5\":[\"Aihe asetettu myöhemmin kuin (min sitten)\"],\"aHKcKc\":[\"Edellinen sivu\"],\"aJTbXX\":[\"Oper-salasana\"],\"aQryQv\":[\"Malli on jo olemassa\"],\"aW9pLN\":[\"Kanavalla sallittu enimmäiskäyttäjämäärä. Jätä tyhjäksi, jos rajaa ei haluta.\"],\"ah4fmZ\":[\"Näyttää myös esikatselut YouTubesta, Vimeosta, SoundCloudista ja vastaavista tunnetuista palveluista.\"],\"aifXak\":[\"Tällä kanavalla ei ole mediaa\"],\"ap2zBz\":[\"Löysä\"],\"az8lvo\":[\"Pois\"],\"azXSNo\":[\"Laajenna jäsenlista\"],\"azdliB\":[\"Kirjaudu tilille\"],\"b26wlF\":[\"hän/hänen\"],\"bD/+Ei\":[\"Tiukka\"],\"bQ6BJn\":[\"Määritä yksityiskohtaiset tulvasuojaussäännöt. Kukin sääntö määrittää, mitä toimintaa seurataan ja mitä tehdään kun raja-arvot ylitetään.\"],\"beV7+y\":[\"Käyttäjä saa kutsun liittyä kanavalle \",[\"channelName\"],\".\"],\"bk84cH\":[\"Poissaoloviesti\"],\"bkHdLj\":[\"Lisää IRC-palvelin\"],\"bmQLn5\":[\"Lisää sääntö\"],\"bv4cFj\":[\"Siirtoprotokolla\"],\"bwRvnp\":[\"Toiminto\"],\"c8+EVZ\":[\"Vahvistettu tili\"],\"cGYUlD\":[\"Median esikatseluja ei ladata.\"],\"cLF98o\":[\"Näytä kommentit (\",[\"commentCount\"],\")\"],\"cLKIDO\":[\"Ei käyttäjiä saatavilla\"],\"cSgpoS\":[\"Kiinnitä yksityiskeskustelu\"],\"cde3ce\":[\"Viesti <0>\",[\"0\"],\"\"],\"chQsxg\":[\"Kopioi muotoiltu tuloste\"],\"cl/A5J\":[\"Tervetuloa palvelimeen \",[\"__DEFAULT_IRC_SERVER_NAME__\"],\"!\"],\"cnGeoo\":[\"Poista\"],\"coPLXT\":[\"Emme tallenna IRC-viestintääsi palvelimillemme\"],\"crYH/6\":[\"SoundCloud-soitin\"],\"cv5DQb\":[\"isäntää ei asetettu\"],\"d3sis4\":[\"Lisää palvelin\"],\"d9aN5k\":[\"Poista \",[\"username\"],\" kanavalta\"],\"dEgA5A\":[\"Peruuta\"],\"dGi1We\":[\"Irrota tämä yksityisviestiketju\"],\"dJVuyC\":[\"poistui kanavalta \",[\"channelName\"],\" (\",[\"reason\"],\")\"],\"dMtLDE\":[\"vastaanottaja\"],\"dXqxlh\":[\"<0>⚠️ Tietoturvariski! Tämä yhteys voi olla alttiina salakuuntelulle tai välimieshyökkäyksille.\"],\"da9Q/R\":[\"Muutti kanavan asetuksia\"],\"dhJN3N\":[\"Näytä kommentit\"],\"dj2xTE\":[\"Hylkää ilmoitus\"],\"dpCzmC\":[\"Tulvasuojausasetukset\"],\"e9dQpT\":[\"Haluatko avata tämän linkin uudessa välilehdessä?\"],\"ePK91l\":[\"Muokkaa\"],\"eYBDuB\":[\"Lataa kuva tai anna URL, jossa voi käyttää valinnaista \",[\"size\"],\"-muuttujaa dynaamiseen kokoon\"],\"edBbee\":[\"Estä \",[\"username\"],\" hostmaskin perusteella (estää liittymisen samasta IP-osoitteesta/hostista)\"],\"ekfzWq\":[\"Käyttäjäasetukset\"],\"elPDWs\":[\"Mukauta IRC-asiakasohjelmaasi\"],\"eu2osY\":[\"<0>💡 Suositus: Jatka vain jos luotat tähän palvelimeen ja ymmärrät riskit. Vältä arkaluonteisten tietojen tai salasanojen jakamista tämän yhteyden kautta.\"],\"euEhbr\":[\"Liity kanavalle \",[\"channel\"],\" napsauttamalla\"],\"ez3vLd\":[\"Ota monirivisyöttö käyttöön\"],\"f0J5Ki\":[\"Palvelimien välinen viestintä voi käyttää salaamattomia yhteyksiä\"],\"f9BHJk\":[\"Varoita käyttäjää\"],\"fDOLLd\":[\"Kanavia ei löydetty.\"],\"ffzDkB\":[\"Anonyymi analytiikka:\"],\"fq1GF9\":[\"Näytä kun käyttäjät katkaisevat yhteyden palvelimeen\"],\"gEF57C\":[\"Tämä palvelin tukee vain yhtä yhteystyyppiä\"],\"gJuLUI\":[\"Estolistа\"],\"gNzMrk\":[\"Nykyinen avatar\"],\"gjPWyO\":[\"Syötä nimimerkki...\"],\"gz6UQ3\":[\"Suurenna\"],\"h6/IMX\":[\"Lisää ensimmäinen verkkosi\"],\"h6razj\":[\"Sulje pois kanavan nimimaski\"],\"hG6jnw\":[\"Aihetta ei ole asetettu\"],\"hG89Ed\":[\"Kuva\"],\"hZ6znB\":[\"Portti\"],\"ha+Bz5\":[\"esim. 100:1440\"],\"hehnjM\":[\"Määrä\"],\"hzdLuQ\":[\"Vain käyttäjät, joilla on voice tai korkeampi, voivat puhua\"],\"i0qMbr\":[\"Koti\"],\"iDNBZe\":[\"Ilmoitukset\"],\"iH8pgl\":[\"Takaisin\"],\"iL9SZg\":[\"Estä käyttäjä (nimimerkin perusteella)\"],\"iNt+3c\":[\"Takaisin kuvaan\"],\"iQvi+a\":[\"Älä varoita minua tämän palvelimen heikosta linkkiturvallisuudesta\"],\"iSLIjg\":[\"Yhdistä\"],\"iWXkHH\":[\"Halfop\"],\"iZeTtp\":[\"Palvelimen osoite\"],\"idD8Ev\":[\"Tallennettu\"],\"iivqkW\":[\"Kirjautunut\"],\"ij+Elv\":[\"Kuvan esikatselu\"],\"ilIWp7\":[\"Näytä/piilota ilmoitukset\"],\"iuaqvB\":[\"Käytä * jokerimerkkinä. Esimerkkejä: baduser!*@*, *!*@spammer.com, troll*!*@*\"],\"ixkTse\":[\"Botti\"],\"j2DGR0\":[\"Estä hostmaskin perusteella\"],\"jA4uoI\":[\"Aihe:\"],\"jLXxGK\":[\"https://example.com\"],\"jPSk57\":[\"Syy (valinnainen)\"],\"jUV7CU\":[\"Lataa avatar\"],\"jW5Uwh\":[\"Hallinnoi ulkoisen median lataamista. Pois / Turvallinen / Luotetut lähteet / Kaikki sisältö.\"],\"jXzms5\":[\"Liitteet-valinnat\"],\"jZlrte\":[\"Väri\"],\"jfC/xh\":[\"Yhteystiedot\"],\"jywMpv\":[\"#uusi-kanavan-nimi\"],\"k112DD\":[\"Lataa vanhempia viestejä\"],\"k3ID0F\":[\"Suodata jäseniä…\"],\"k65gsE\":[\"Syvempi tarkastelu\"],\"k7Zgob\":[\"Peruuta yhteys\"],\"kAVx5h\":[\"Kutsuja ei löydetty\"],\"kCLEPU\":[\"Yhdistetty palvelimeen\"],\"kF5LKb\":[\"Estetyt mallit:\"],\"kGeOx/\":[\"Liity kanavalle \",[\"0\"]],\"kITKr8\":[\"Ladataan kanavan tiloja...\"],\"kPpPsw\":[\"Olet IRC-operaattori\"],\"kWJmRL\":[\"Sinä\"],\"kfcRb0\":[\"Avatar\"],\"kjMqSj\":[\"Kopioi JSON\"],\"krViRy\":[\"Napsauta kopioidaksesi JSON-muodossa\"],\"ks71ra\":[\"Poikkeukset\"],\"kw4lRv\":[\"Kanavan puolioperaattori\"],\"kxgIRq\":[\"Valitse kanava tai lisää uusi päästäksesi alkuun.\"],\"ky6dWe\":[\"Avatarin esikatselu\"],\"l+GxCv\":[\"Ladataan kanavia...\"],\"l+IUVW\":[\"Tilin \",[\"account\"],\" vahvistus onnistui: \",[\"message\"]],\"l/siQz\":[[\"reconnectCount\",\"plural\",{\"one\":[\"yhdisti uudelleen\"],\"other\":[\"yhdisti uudelleen \",[\"reconnectCount\"],\" kertaa\"]}]],\"l5jmzx\":[[\"0\"],\" ja \",[\"1\"],\" kirjoittavat...\"],\"lHy8N5\":[\"Ladataan lisää kanavia...\"],\"lbpf14\":[\"Liity kanavaan \",[\"value\"]],\"lfFsZ4\":[\"Kanavat\"],\"lkNdiH\":[\"Tilin nimi\"],\"ln500L\":[\"ObsidianIRC\"],\"lnCMdg\":[\"Lataa kuva\"],\"loQxaJ\":[\"Olen takaisin\"],\"lvfaxv\":[\"KOTI\"],\"m0oxpP\":[\"Libera Chat\"],\"m16xKo\":[\"Lisää\"],\"m8flAk\":[\"Esikatselu (ei vielä lähetetty)\"],\"mEPxTp\":[\"<0>⚠️ Ole varovainen! Avaa linkkejä vain luotetuista lähteistä. Haitalliset linkit voivat vaarantaa tietoturvasi tai yksityisyytesi.\"],\"mHGdhG\":[\"Palvelimen tiedot\"],\"mHS8lb\":[\"Viesti #\",[\"0\"]],\"mMYBD9\":[\"Laaja – laajempi suojauslaajuus\"],\"mTGsPd\":[\"Kanavan aihe\"],\"mU8j6O\":[\"Ei ulkoisia viestejä (+n)\"],\"mZp8FL\":[\"Automaattinen palautuminen yksiriviseksi\"],\"mdQu8G\":[\"SinunNimimerkkisi\"],\"miSSBQ\":[\"Kommentit (\",[\"commentCount\"],\")\"],\"mvyLSy\":[\"Käyttäjä on tunnistautunut\"],\"mwtcGl\":[\"Sulje kommentit\"],\"myL0MR\":[\"Poistetaanko tämä verkko?\"],\"mzI/c+\":[\"Lataa\"],\"n3fGRk\":[\"asettaja: \",[\"0\"]],\"nE9jsU\":[\"Löysä – vähemmän aggressiivinen suojaus\"],\"nNflMD\":[\"Poistu kanavalta\"],\"nPXkBi\":[\"Ladataan WHOIS-tietoja...\"],\"nQnxxF\":[\"Viesti #\",[\"0\"],\" (Shift+Enter = uusi rivi)\"],\"nWMRxa\":[\"Irrota kiinnitys\"],\"nkC032\":[\"Ei tulvasuojausprofiilia\"],\"o69z4d\":[\"Lähetä varoitusviesti käyttäjälle \",[\"username\"]],\"o9ylQi\":[\"Hae GIF-kuvia aloittaaksesi\"],\"oFGkER\":[\"Palvelintiedotteet\"],\"oOi11l\":[\"Siirry loppuun\"],\"oQEzQR\":[\"Uusi DM\"],\"oXOSPE\":[\"Verkossa\"],\"oal760\":[\"Välimieshyökkäykset palvelinlinkeissä ovat mahdollisia\"],\"oeqmmJ\":[\"Luotetut lähteet\"],\"ovBPCi\":[\"Oletus\"],\"p0Z69r\":[\"Malli ei voi olla tyhjä\"],\"p1KgtK\":[\"Äänen lataaminen epäonnistui\"],\"p59pEv\":[\"Lisätiedot\"],\"p7sRI6\":[\"Ilmoita muille, kun kirjoitat\"],\"pBm1od\":[\"Salainen kanava\"],\"pNmiXx\":[\"Oletusnimimerkkisi kaikille palvelimille\"],\"pUUo9G\":[\"Isäntänimi:\"],\"pVGPmz\":[\"Tilin salasana\"],\"peNE68\":[\"Pysyvä\"],\"plhHQt\":[\"Ei tietoja\"],\"pm6+q5\":[\"Tietoturvavaroitus\"],\"pn5qSs\":[\"Lisätiedot\"],\"q0cR4S\":[\"on nyt tunnettu nimellä **\",[\"newNick\"],\"**\"],\"qFcunY\":[\"Kanava ei näy LIST- tai NAMES-komennoissa\"],\"qLpTm/\":[\"Poista reaktio \",[\"emoji\"]],\"qVkGWK\":[\"Kiinnitä\"],\"qY8wNa\":[\"Kotisivu\"],\"qb0xJ7\":[\"Käytä jokerimerkkejä: * vastaa mitä tahansa merkkijonoa, ? vastaa yhtä merkkiä. Esimerkkejä: nick!*@*, *!*@host.com, *!*user@*\"],\"qhzpRq\":[\"Kanavan avain (+k)\"],\"qtoOYG\":[\"Ei rajaa\"],\"r1W2AS\":[\"Tiedostopalvelimen kuva\"],\"rIPR2O\":[\"Aihe asetettu aiemmin kuin (min sitten)\"],\"rMMSYo\":[\"Enimmäispituus on \",[\"0\"]],\"rWtzQe\":[\"Verkko jakautui ja yhdistyi uudelleen. ✅\"],\"rYG2u6\":[\"Odota hetki...\"],\"rdUucN\":[\"Esikatselu\"],\"rjGI/Q\":[\"Yksityisyys\"],\"rk8iDX\":[\"Ladataan GIF-kuvia...\"],\"rn6SBY\":[\"Poista mykistys\"],\"s/UKqq\":[\"Poistettiin kanavalta\"],\"s8cATI\":[\"liittyi kanavalle \",[\"channelName\"]],\"sCO9ue\":[\"Yhteydessä palvelimeen <0>\",[\"serverName\"],\" on seuraavia tietoturvaongelmia:\"],\"sGH11W\":[\"Palvelin\"],\"sHI1H+\":[\"on nyt tunnettu nimellä **\",[\"newNick\"],\"**\"],\"sJyV04\":[[\"inviter\"],\" kutsui sinut liittymään kanavalle \",[\"channel\"]],\"sUBSbK\":[\"Ei vielä ylävirran verkkoja.\"],\"sby+1/\":[\"Kopioi napsauttamalla\"],\"sfN25C\":[\"Oikea nimesi tai koko nimesi\"],\"sliuzR\":[\"Avaa linkki\"],\"sqrO9R\":[\"Mukautetut maininnat\"],\"sr6RdJ\":[\"Monirivinen Shift+Enter-painikkeella\"],\"swrCpB\":[\"Kanava on nimetty uudelleen nimestä \",[\"oldName\"],\" nimeen \",[\"newName\"],\" käyttäjän \",[\"user\"],\" toimesta\",[\"0\"]],\"sxkWRg\":[\"Lisäasetukset\"],\"t/YqKh\":[\"Poista\"],\"t47eHD\":[\"Yksilöllinen tunnuksesi tällä palvelimella\"],\"tAkAh0\":[\"URL, jossa valinnainen \",[\"size\"],\"-muuttuja dynaamista kokoa varten. Esimerkki: https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"tXLJS3\":[\"Näytä tai piilota kanavaluettelon sivupalkki\"],\"tfDRzk\":[\"Tallenna\"],\"tiBsJk\":[\"poistui kanavalta \",[\"channelName\"]],\"tt4/UD\":[\"poistui (\",[\"reason\"],\")\"],\"u0TcnO\":[\"Nimimerkki {nick} on jo käytössä, yritetään uudelleen nimellä {newNick}\"],\"u0a8B4\":[\"Tunnistaudu IRC-operaattoriksi ylläpito-oikeuksia varten\"],\"u0rWFU\":[\"Luotu myöhemmin kuin (min sitten)\"],\"u72w3t\":[\"Estettävät käyttäjät ja mallit\"],\"u7jc2L\":[\"poistui\"],\"uAQUqI\":[\"Tila\"],\"uB85T3\":[\"Tallennus epäonnistui: \",[\"msg\"]],\"uV3DOL\":[\"G-Line\"],\"uW3lLI\":[\"IRC-palvelimet:\"],\"usSSr/\":[\"Zoomaustaso\"],\"v7uvcf\":[\"Ohjelmisto:\"],\"vE8kb+\":[\"Käytä Shift+Enter uudelle riville (Enter lähettää)\"],\"vERlcd\":[\"Profiili\"],\"vK0RL8\":[\"Ei aihetta\"],\"vSJd18\":[\"Video\"],\"vXIe7J\":[\"Kieli\"],\"vaHYxN\":[\"Oikea nimi\"],\"vhjbKr\":[\"Poissa\"],\"w/nogd\":[[\"0\"],\" network\",[\"1\"],\" — pick one to join\"],\"w4NYox\":[[\"title\"],\" asiakasohjelma\"],\"w8xQRx\":[\"Virheellinen arvo\"],\"wFjjxZ\":[\"potkittiin kanavalta \",[\"channelName\"],\" käyttäjän \",[\"username\"],\" toimesta (\",[\"reason\"],\")\"],\"wGjaGl\":[\"Porttikieltopoikkeuksia ei löydetty\"],\"wPrGnM\":[\"Kanavan ylläpitäjä\"],\"wRkP2d\":[\"GIF\"],\"wbm86v\":[\"Näytä kun käyttäjät liittyvät kanavalle tai poistuvat\"],\"whqZ9r\":[\"Lisäsanat tai -lauseet korostettavaksi\"],\"wm7RV4\":[\"Ilmoitusääni\"],\"wz/Yoq\":[\"Viestisi voidaan siepata palvelimien välillä välitettäessä\"],\"xCJdfg\":[\"Tyhjennä\"],\"xUHRTR\":[\"Tunnistaudu automaattisesti operaattoriksi yhdistäessä\"],\"xWHwwQ\":[\"Estot\"],\"xYilR2\":[\"Media\"],\"xceQrO\":[\"Vain suojatut WebSocket-yhteydet ovat tuettuja\"],\"xdtXa+\":[\"kanavan-nimi\"],\"xfXC7q\":[\"Tekstikanavat\"],\"xlCYOE\":[\"Haetaan lisää viestejä...\"],\"xlhswE\":[\"Vähimmäisarvo on \",[\"0\"]],\"xq97Ci\":[\"Lisää sana tai lause...\"],\"xuRqRq\":[\"Käyttäjäraja (+l)\"],\"xwF+7J\":[[\"0\"],\" kirjoittaa...\"],\"yJztBY\":[\"Poista verkko\"],\"yNeucF\":[\"Tämä palvelin ei tue laajennettua profiilimetadataa (IRCv3 METADATA -laajennus). Lisäkentät kuten avatar, näyttönimi ja tila eivät ole käytettävissä.\"],\"yPlrca\":[\"Kanavan avatar\"],\"yQE2r9\":[\"Ladataan\"],\"ySU+JY\":[\"sinun@sahkoposti.fi\"],\"yTX1Rt\":[\"Oper-käyttäjänimi\"],\"yYOzWD\":[\"lokit\"],\"yfx9Re\":[\"IRC-operaattorin salasana\"],\"ygCKqB\":[\"Pysäytä\"],\"ymDxJx\":[\"IRC-operaattorin käyttäjänimi\"],\"yrpRsQ\":[\"Lajittele nimen mukaan\"],\"yz7wBu\":[\"Sulje\"],\"zJw+jA\":[\"asettaa tilan: \",[\"0\"]],\"zebeLu\":[\"Syötä oper-käyttäjänimi\"],\"zpr0Bw\":[\"GZ-Line\"]}"); \ No newline at end of file diff --git a/src/locales/fi/messages.po b/src/locales/fi/messages.po index fd09ade2..57ad6aac 100644 --- a/src/locales/fi/messages.po +++ b/src/locales/fi/messages.po @@ -22,6 +22,16 @@ msgstr "ObsidianIRC - Viedään IRC tulevaisuuteen" msgid "— open in viewer" msgstr "— avaa katseluohjelmassa" +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx +msgid "(inherit)" +msgstr "(peritty)" + +#: src/components/ui/BouncerNetworkForm.tsx +msgid "(unchanged)" +msgstr "(muuttumaton)" + #. placeholder {0}: filteredMessages.length #. placeholder {1}: import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import type * as React from "react"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { SCROLL_TOLERANCE, useScrollToBottom, } from "../../hooks/useScrollToBottom"; import { groupConsecutiveEvents } from "../../lib/eventGrouping"; import ircClient from "../../lib/ircClient"; import useStore from "../../store"; import type { Message as MessageType } from "../../types"; import { CollapsedEventMessage } from "../message/CollapsedEventMessage"; import { MessageItem } from "../message/MessageItem"; import LoadingSpinner from "../ui/LoadingSpinner"; import { ScrollToBottomButton } from "../ui/ScrollToBottomButton"; export const DEFAULT_VISIBLE_MESSAGE_COUNT = 100; // Stable empty array — prevents selector from returning a new [] on every render // when the channel has no messages yet (undefined ?? [] would create a new ref each time). const EMPTY_MESSAGES: import("../../types").Message[] = []; export interface ChannelMessageListHandle { setAtBottom: () => void; scrollToBottom: () => void; getScrollState: () => { scrollTop: number; isAtBottom: boolean; visibleCount: number; }; } interface ChannelMessageListProps { channelKey: string; serverId: string; channelId: string | null; privateChatId: string | null; isActive: boolean; searchQuery: string; isMemberListVisible: boolean; onReply: (msg: MessageType | null) => void; onUsernameContextMenu: ( e: React.MouseEvent, username: string, serverId: string, channelId: string, avatarEl?: Element | null, ) => void; onIrcLinkClick: (url: string) => void; onReactClick: (msg: MessageType, el: Element) => void; onReactionUnreact: (emoji: string, msg: MessageType) => void; onOpenReactionModal: ( msg: MessageType, position: { x: number; y: number }, ) => void; onDirectReaction: (emoji: string, msg: MessageType) => void; onRedactMessage: (msg: MessageType) => void; onOpenProfile: (username: string) => void; joinChannel: (serverId: string, channelName: string) => void; onClearSearch: () => void; highlightedMessageId?: string; // undefined = first visit; null = was at bottom; object = restore to saved position initialScrollState?: { scrollTop: number; visibleCount: number } | null; } export const ChannelMessageList = forwardRef< ChannelMessageListHandle, ChannelMessageListProps >( ( { channelKey, serverId, channelId, privateChatId, isActive, searchQuery, isMemberListVisible, onReply, onUsernameContextMenu, onIrcLinkClick, onReactClick, onReactionUnreact, onOpenReactionModal, onDirectReaction, onRedactMessage, onOpenProfile, joinChannel, onClearSearch, highlightedMessageId, initialScrollState, }, ref, ) => { const { t } = useLingui(); const [visibleMessageCount, setVisibleMessageCount] = useState( initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT, ); // Ref mirror so getScrollState closure always reads the current value without needing it as a dep. const visibleMessageCountRef = useRef(visibleMessageCount); visibleMessageCountRef.current = visibleMessageCount; // Distinguishes initial join (full-screen spinner) from subsequent "load more" (button spinner). const [isFetchingMore, setIsFetchingMore] = useState(false); const isFetchingMoreRef = useRef(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const messagesInnerRef = useRef(null); // prev scrollHeight for prepend delta-correction. const prevScrollHeightRef = useRef(0); // Ref mirror of isScrolledUp — lets useLayoutEffect closures read current value // without listing isScrolledUp as a dep (which would re-run effects on every scroll). const isScrolledUpRef = useRef(false); const prevFilteredLengthRef = useRef(0); const prevFirstMsgIdRef = useRef(null); // Set by the window-growth layoutEffect (or button handler) when a true prepend is detected. // Consumed by the delta-correction layoutEffect one render later (after visibleCount grows). // Using a flag instead of tracking displayedMessages[0]?.id because slice(-N) slides the // window on every bottom append, changing displayedMessages[0] even for non-prepend renders. const pendingPrependRef = useRef(false); // Shared scrollHeight baseline between the delta-correction layout effect and the inner // ResizeObserver. When scrollTop is corrected after a prepend, we update this so the RO's // "was at bottom" check is not fooled by the adjusted scrollTop vs its stale prevSH. const resizeObserverPrevSHRef = useRef(0); const channelMessages = useStore( useCallback( (state) => state.messages[channelKey] ?? EMPTY_MESSAGES, [channelKey], ), ); const servers = useStore((state) => state.servers); const mobileViewActiveColumn = useStore( (state) => state.ui.mobileViewActiveColumn, ); const channel = useMemo( () => channelId ? (servers .find((s) => s.id === serverId) ?.channels.find((c) => c.id === channelId) ?? null) : null, [servers, serverId, channelId], ); const { isScrolledUp, wasAtBottomRef, scrollToBottom } = useScrollToBottom( messagesContainerRef, messagesEndRef, { channelId: `${channelId || privateChatId}-${isMemberListVisible}` }, ); // Snapshot of the last known scroll position captured while the container was visible. // getScrollState() reads this instead of the live DOM because React commits display:none // before running cleanup effects, collapsing scrollTop/scrollHeight/clientHeight to 0. const lastScrollTopRef = useRef(initialScrollState?.scrollTop ?? 0); useEffect(() => { const container = messagesContainerRef.current; if (!container) return; const onScroll = () => { if (container.clientHeight > 0) lastScrollTopRef.current = container.scrollTop; }; container.addEventListener("scroll", onScroll, { passive: true }); return () => container.removeEventListener("scroll", onScroll); }, []); // Restore scroll position when a keep-alive channel transitions from hidden to visible. // display:none may reset scrollTop to 0; lastScrollTopRef was captured while visible. const prevActiveRef = useRef(isActive); useLayoutEffect(() => { if (isActive && !prevActiveRef.current) { const container = messagesContainerRef.current; if (container && lastScrollTopRef.current > 0) { container.scrollTop = lastScrollTopRef.current; } } prevActiveRef.current = isActive; }, [isActive]); useImperativeHandle(ref, () => ({ setAtBottom: () => { wasAtBottomRef.current = true; }, scrollToBottom, getScrollState: () => ({ scrollTop: lastScrollTopRef.current, isAtBottom: wasAtBottomRef.current, visibleCount: visibleMessageCountRef.current, }), })); const filteredMessages = useMemo(() => { if (!searchQuery.trim()) return channelMessages; const query = searchQuery.toLowerCase(); return channelMessages.filter( (msg) => msg.content.toLowerCase().includes(query) || msg.userId.toLowerCase().includes(query), ); }, [channelMessages, searchQuery]); useEffect(() => { isScrolledUpRef.current = isScrolledUp; // When the user returns to the bottom, shrink the window back to the base so // slice(-N) resumes trimming old messages from the top (memory optimization). // Only shrink if we grew above the base — preserves a sub-default saved visibleCount. if (!isScrolledUp) { setVisibleMessageCount((prev) => prev > DEFAULT_VISIBLE_MESSAGE_COUNT ? DEFAULT_VISIBLE_MESSAGE_COUNT : prev, ); } }, [isScrolledUp]); // Reset ref-tracked windowing state when switching channels. // visibleMessageCount is NOT reset here — useState(initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT) // already initializes it correctly on mount, and this effect runs once on mount for the // same channelKey (each instance is bound to exactly one channel by the parent key={}). // biome-ignore lint/correctness/useExhaustiveDependencies: intentional full reset on channel change useEffect(() => { prevFilteredLengthRef.current = 0; prevFirstMsgIdRef.current = null; prevScrollHeightRef.current = 0; pendingPrependRef.current = false; resizeObserverPrevSHRef.current = 0; }, [channelKey]); const displayedMessages = useMemo(() => { if (searchQuery.trim()) return filteredMessages; return filteredMessages.slice(-visibleMessageCount); }, [filteredMessages, visibleMessageCount, searchQuery]); const locallyHidden = filteredMessages.length > displayedMessages.length; const serverHasMore = channel?.hasMoreHistory === true; const hasMoreMessages = locallyHidden || serverHasMore; const eventGroups = useMemo( () => groupConsecutiveEvents(displayedMessages), [displayedMessages], ); const isLoadingHistory = channel?.isLoadingHistory ?? false; // Scroll to bottom on initial mount, unless a saved position was passed in. // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount only useEffect(() => { const container = messagesContainerRef.current; if (!container) return; if (initialScrollState) { container.scrollTop = initialScrollState.scrollTop; lastScrollTopRef.current = initialScrollState.scrollTop; wasAtBottomRef.current = false; } else { container.scrollTop = container.scrollHeight; lastScrollTopRef.current = container.scrollHeight; wasAtBottomRef.current = true; } }, []); // Scroll to bottom after initial join history loads; clear fetch spinner at batch end. const wasLoadingHistoryRef = useRef(false); // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is stable via useCallback; refs and setters are stable useLayoutEffect(() => { if (wasLoadingHistoryRef.current && !isLoadingHistory) { if (isFetchingMoreRef.current) { // delta correction for scroll position is handled by useLayoutEffect([displayedMessages]) isFetchingMoreRef.current = false; setIsFetchingMore(false); } else { scrollToBottom(); wasAtBottomRef.current = true; } } wasLoadingHistoryRef.current = isLoadingHistory; }, [isLoadingHistory]); // When older messages are prepended, grow the window so they enter displayedMessages. // When new messages arrive at the bottom while the user is scrolled up, also grow the // window to keep the current top messages visible — slice(-N) otherwise slides the // window forward and hides them, incrementing the "N older messages" counter on every // incoming message. Only let the slice trim from the top when the user is at the bottom // (where auto-scroll handles keeping them current). useLayoutEffect(() => { const newLength = filteredMessages.length; const newFirstId = filteredMessages[0]?.id ?? null; const delta = newLength - prevFilteredLengthRef.current; if (prevFilteredLengthRef.current > 0 && delta > 0) { if (newFirstId !== prevFirstMsgIdRef.current) { // Messages prepended (load-more): signal delta-correction to compensate scrollTop. pendingPrependRef.current = true; setVisibleMessageCount((prev) => prev + delta); } else if (isScrolledUpRef.current) { // Messages appended at bottom while user is scrolled up reading history. // Expand the window to prevent top messages from dropping out of the slice. setVisibleMessageCount((prev) => prev + delta); } } prevFilteredLengthRef.current = newLength; prevFirstMsgIdRef.current = newFirstId; }, [filteredMessages]); // Compensate scrollTop when content is prepended above the viewport. // biome-ignore lint/correctness/useExhaustiveDependencies: runs on every displayedMessages render to capture the resulting scrollHeight; refs are stable useLayoutEffect(() => { const container = messagesContainerRef.current; if (!container) return; // Skip while container is display:none — scrollHeight collapses to 0 and would // poison prevScrollHeightRef, causing a huge spurious delta on the next visible render. if (container.clientHeight === 0) return; const prevHeight = prevScrollHeightRef.current; const newHeight = container.scrollHeight; // Only correct when a true load-more prepend happened (flag set by the window-growth // layoutEffect or button handler). Bottom appends slide the slice(-N) window which also // changes displayedMessages[0] — ID-comparison can't distinguish the two cases. const wasPrepend = pendingPrependRef.current; // Only consume the flag when scrollHeight actually changed — the server-side load-more // path goes through two renders: Render A (filteredMessages grows, visibleCount unchanged, // same displayedMessages content, same scrollHeight) then Render B (visibleCount grows, // new messages enter displayedMessages, scrollHeight grows). The flag must survive Render A // so it's still set when Render B fires the actual correction. if (wasPrepend && newHeight !== prevHeight) { pendingPrependRef.current = false; } if ( isScrolledUpRef.current && prevHeight > 0 && newHeight > prevHeight && wasPrepend ) { const delta = newHeight - prevHeight; container.scrollTop += delta; resizeObserverPrevSHRef.current = newHeight; } prevScrollHeightRef.current = newHeight; }, [displayedMessages]); // Re-stick to bottom when inner message content grows (media/audio previews loading). // Uses prevScrollHeight instead of wasAtBottomRef to avoid stale-flag race where the // ref is true while the user is actively scrolling up. // When the container width changes (member list toggle, window resize), text reflows // and scrollHeight changes; preserve proportional scroll position for scrolled-up users. // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is a stable ref useEffect(() => { const container = messagesContainerRef.current; const inner = messagesInnerRef.current; if (!inner || !container) return; resizeObserverPrevSHRef.current = container.scrollHeight; let prevClientWidth = container.clientWidth; const observer = new ResizeObserver(() => { if (container.clientHeight === 0) return; // Effect may re-initialize while container is display:none (ref=0). // Re-seed with current dimensions and skip — no reliable "was at bottom" data. if (resizeObserverPrevSHRef.current === 0) { resizeObserverPrevSHRef.current = container.scrollHeight; prevClientWidth = container.clientWidth; return; } const currentClientWidth = container.clientWidth; const widthChanged = currentClientWidth !== prevClientWidth; prevClientWidth = currentClientWidth; const prevSH = resizeObserverPrevSHRef.current; const wasAtPrevBottom = container.scrollTop + container.clientHeight >= prevSH - SCROLL_TOLERANCE; resizeObserverPrevSHRef.current = container.scrollHeight; if (wasAtPrevBottom) { scrollToBottom(); } else if (widthChanged && prevSH > 0) { const ratio = container.scrollTop / prevSH; container.scrollTop = Math.round(ratio * container.scrollHeight); } }); observer.observe(inner); return () => observer.disconnect(); }, [isLoadingHistory, channelId, privateChatId]); // Auto-scroll on new messages — skip when this channel is hidden (display:none). // biome-ignore lint/correctness/useExhaustiveDependencies: only scroll when messages change, not when isActive changes useEffect(() => { if (!isActive) return; const isNarrowView = window.matchMedia("(max-width: 768px)").matches; const isChatVisible = !isNarrowView || mobileViewActiveColumn === "chatView"; if (wasAtBottomRef.current && isChatVisible) { scrollToBottom(); } }, [displayedMessages, mobileViewActiveColumn, scrollToBottom, isActive]); return ( <>
{isLoadingHistory && !isFetchingMore ? (
) : (
{hasMoreMessages && !searchQuery && (
)} {searchQuery && (
{plural(filteredMessages.length, { one: t`Found 1 message matching "${searchQuery}"`, other: t`Found ${filteredMessages.length} messages matching "${searchQuery}"`, })}
)} {eventGroups.map((group) => { if (group.type === "eventGroup") { const firstId = group.messages[0]?.id || ""; const lastId = group.messages[group.messages.length - 1]?.id || ""; const groupKey = `group-${firstId}-${lastId}`; return ( ); } const message = group.messages[0]; const originalIndex = channelMessages.findIndex( (m) => m.id === message.id, ); const previousMessage = channelMessages[originalIndex - 1]; const showHeader = !previousMessage || previousMessage.type !== "message" || previousMessage.userId !== message.userId || new Date(message.timestamp).getTime() - new Date(previousMessage.timestamp).getTime() > 5 * 60 * 1000; return ( ); })}
)}
); }, ); ChannelMessageList.displayName = "ChannelMessageList"; // Wrap with memo so hidden keep-alive channels skip re-renders when their props // haven't changed (e.g. when messageText changes in the input — the only thing // that changes on typing is local state inside ChatArea, not the props we pass here). export const MemoChannelMessageList = memo(ChannelMessageList); #. placeholder {2}: import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import type * as React from "react"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { SCROLL_TOLERANCE, useScrollToBottom, } from "../../hooks/useScrollToBottom"; import { groupConsecutiveEvents } from "../../lib/eventGrouping"; import ircClient from "../../lib/ircClient"; import useStore from "../../store"; import type { Message as MessageType } from "../../types"; import { CollapsedEventMessage } from "../message/CollapsedEventMessage"; import { MessageItem } from "../message/MessageItem"; import LoadingSpinner from "../ui/LoadingSpinner"; import { ScrollToBottomButton } from "../ui/ScrollToBottomButton"; export const DEFAULT_VISIBLE_MESSAGE_COUNT = 100; // Stable empty array — prevents selector from returning a new [] on every render // when the channel has no messages yet (undefined ?? [] would create a new ref each time). const EMPTY_MESSAGES: import("../../types").Message[] = []; export interface ChannelMessageListHandle { setAtBottom: () => void; scrollToBottom: () => void; getScrollState: () => { scrollTop: number; isAtBottom: boolean; visibleCount: number; }; } interface ChannelMessageListProps { channelKey: string; serverId: string; channelId: string | null; privateChatId: string | null; isActive: boolean; searchQuery: string; isMemberListVisible: boolean; onReply: (msg: MessageType | null) => void; onUsernameContextMenu: ( e: React.MouseEvent, username: string, serverId: string, channelId: string, avatarEl?: Element | null, ) => void; onIrcLinkClick: (url: string) => void; onReactClick: (msg: MessageType, el: Element) => void; onReactionUnreact: (emoji: string, msg: MessageType) => void; onOpenReactionModal: ( msg: MessageType, position: { x: number; y: number }, ) => void; onDirectReaction: (emoji: string, msg: MessageType) => void; onRedactMessage: (msg: MessageType) => void; onOpenProfile: (username: string) => void; joinChannel: (serverId: string, channelName: string) => void; onClearSearch: () => void; highlightedMessageId?: string; // undefined = first visit; null = was at bottom; object = restore to saved position initialScrollState?: { scrollTop: number; visibleCount: number } | null; } export const ChannelMessageList = forwardRef< ChannelMessageListHandle, ChannelMessageListProps >( ( { channelKey, serverId, channelId, privateChatId, isActive, searchQuery, isMemberListVisible, onReply, onUsernameContextMenu, onIrcLinkClick, onReactClick, onReactionUnreact, onOpenReactionModal, onDirectReaction, onRedactMessage, onOpenProfile, joinChannel, onClearSearch, highlightedMessageId, initialScrollState, }, ref, ) => { const { t } = useLingui(); const [visibleMessageCount, setVisibleMessageCount] = useState( initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT, ); // Ref mirror so getScrollState closure always reads the current value without needing it as a dep. const visibleMessageCountRef = useRef(visibleMessageCount); visibleMessageCountRef.current = visibleMessageCount; // Distinguishes initial join (full-screen spinner) from subsequent "load more" (button spinner). const [isFetchingMore, setIsFetchingMore] = useState(false); const isFetchingMoreRef = useRef(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const messagesInnerRef = useRef(null); // prev scrollHeight for prepend delta-correction. const prevScrollHeightRef = useRef(0); // Ref mirror of isScrolledUp — lets useLayoutEffect closures read current value // without listing isScrolledUp as a dep (which would re-run effects on every scroll). const isScrolledUpRef = useRef(false); const prevFilteredLengthRef = useRef(0); const prevFirstMsgIdRef = useRef(null); // Set by the window-growth layoutEffect (or button handler) when a true prepend is detected. // Consumed by the delta-correction layoutEffect one render later (after visibleCount grows). // Using a flag instead of tracking displayedMessages[0]?.id because slice(-N) slides the // window on every bottom append, changing displayedMessages[0] even for non-prepend renders. const pendingPrependRef = useRef(false); // Shared scrollHeight baseline between the delta-correction layout effect and the inner // ResizeObserver. When scrollTop is corrected after a prepend, we update this so the RO's // "was at bottom" check is not fooled by the adjusted scrollTop vs its stale prevSH. const resizeObserverPrevSHRef = useRef(0); const channelMessages = useStore( useCallback( (state) => state.messages[channelKey] ?? EMPTY_MESSAGES, [channelKey], ), ); const servers = useStore((state) => state.servers); const mobileViewActiveColumn = useStore( (state) => state.ui.mobileViewActiveColumn, ); const channel = useMemo( () => channelId ? (servers .find((s) => s.id === serverId) ?.channels.find((c) => c.id === channelId) ?? null) : null, [servers, serverId, channelId], ); const { isScrolledUp, wasAtBottomRef, scrollToBottom } = useScrollToBottom( messagesContainerRef, messagesEndRef, { channelId: `${channelId || privateChatId}-${isMemberListVisible}` }, ); // Snapshot of the last known scroll position captured while the container was visible. // getScrollState() reads this instead of the live DOM because React commits display:none // before running cleanup effects, collapsing scrollTop/scrollHeight/clientHeight to 0. const lastScrollTopRef = useRef(initialScrollState?.scrollTop ?? 0); useEffect(() => { const container = messagesContainerRef.current; if (!container) return; const onScroll = () => { if (container.clientHeight > 0) lastScrollTopRef.current = container.scrollTop; }; container.addEventListener("scroll", onScroll, { passive: true }); return () => container.removeEventListener("scroll", onScroll); }, []); // Restore scroll position when a keep-alive channel transitions from hidden to visible. // display:none may reset scrollTop to 0; lastScrollTopRef was captured while visible. const prevActiveRef = useRef(isActive); useLayoutEffect(() => { if (isActive && !prevActiveRef.current) { const container = messagesContainerRef.current; if (container && lastScrollTopRef.current > 0) { container.scrollTop = lastScrollTopRef.current; } } prevActiveRef.current = isActive; }, [isActive]); useImperativeHandle(ref, () => ({ setAtBottom: () => { wasAtBottomRef.current = true; }, scrollToBottom, getScrollState: () => ({ scrollTop: lastScrollTopRef.current, isAtBottom: wasAtBottomRef.current, visibleCount: visibleMessageCountRef.current, }), })); const filteredMessages = useMemo(() => { if (!searchQuery.trim()) return channelMessages; const query = searchQuery.toLowerCase(); return channelMessages.filter( (msg) => msg.content.toLowerCase().includes(query) || msg.userId.toLowerCase().includes(query), ); }, [channelMessages, searchQuery]); useEffect(() => { isScrolledUpRef.current = isScrolledUp; // When the user returns to the bottom, shrink the window back to the base so // slice(-N) resumes trimming old messages from the top (memory optimization). // Only shrink if we grew above the base — preserves a sub-default saved visibleCount. if (!isScrolledUp) { setVisibleMessageCount((prev) => prev > DEFAULT_VISIBLE_MESSAGE_COUNT ? DEFAULT_VISIBLE_MESSAGE_COUNT : prev, ); } }, [isScrolledUp]); // Reset ref-tracked windowing state when switching channels. // visibleMessageCount is NOT reset here — useState(initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT) // already initializes it correctly on mount, and this effect runs once on mount for the // same channelKey (each instance is bound to exactly one channel by the parent key={}). // biome-ignore lint/correctness/useExhaustiveDependencies: intentional full reset on channel change useEffect(() => { prevFilteredLengthRef.current = 0; prevFirstMsgIdRef.current = null; prevScrollHeightRef.current = 0; pendingPrependRef.current = false; resizeObserverPrevSHRef.current = 0; }, [channelKey]); const displayedMessages = useMemo(() => { if (searchQuery.trim()) return filteredMessages; return filteredMessages.slice(-visibleMessageCount); }, [filteredMessages, visibleMessageCount, searchQuery]); const locallyHidden = filteredMessages.length > displayedMessages.length; const serverHasMore = channel?.hasMoreHistory === true; const hasMoreMessages = locallyHidden || serverHasMore; const eventGroups = useMemo( () => groupConsecutiveEvents(displayedMessages), [displayedMessages], ); const isLoadingHistory = channel?.isLoadingHistory ?? false; // Scroll to bottom on initial mount, unless a saved position was passed in. // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount only useEffect(() => { const container = messagesContainerRef.current; if (!container) return; if (initialScrollState) { container.scrollTop = initialScrollState.scrollTop; lastScrollTopRef.current = initialScrollState.scrollTop; wasAtBottomRef.current = false; } else { container.scrollTop = container.scrollHeight; lastScrollTopRef.current = container.scrollHeight; wasAtBottomRef.current = true; } }, []); // Scroll to bottom after initial join history loads; clear fetch spinner at batch end. const wasLoadingHistoryRef = useRef(false); // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is stable via useCallback; refs and setters are stable useLayoutEffect(() => { if (wasLoadingHistoryRef.current && !isLoadingHistory) { if (isFetchingMoreRef.current) { // delta correction for scroll position is handled by useLayoutEffect([displayedMessages]) isFetchingMoreRef.current = false; setIsFetchingMore(false); } else { scrollToBottom(); wasAtBottomRef.current = true; } } wasLoadingHistoryRef.current = isLoadingHistory; }, [isLoadingHistory]); // When older messages are prepended, grow the window so they enter displayedMessages. // When new messages arrive at the bottom while the user is scrolled up, also grow the // window to keep the current top messages visible — slice(-N) otherwise slides the // window forward and hides them, incrementing the "N older messages" counter on every // incoming message. Only let the slice trim from the top when the user is at the bottom // (where auto-scroll handles keeping them current). useLayoutEffect(() => { const newLength = filteredMessages.length; const newFirstId = filteredMessages[0]?.id ?? null; const delta = newLength - prevFilteredLengthRef.current; if (prevFilteredLengthRef.current > 0 && delta > 0) { if (newFirstId !== prevFirstMsgIdRef.current) { // Messages prepended (load-more): signal delta-correction to compensate scrollTop. pendingPrependRef.current = true; setVisibleMessageCount((prev) => prev + delta); } else if (isScrolledUpRef.current) { // Messages appended at bottom while user is scrolled up reading history. // Expand the window to prevent top messages from dropping out of the slice. setVisibleMessageCount((prev) => prev + delta); } } prevFilteredLengthRef.current = newLength; prevFirstMsgIdRef.current = newFirstId; }, [filteredMessages]); // Compensate scrollTop when content is prepended above the viewport. // biome-ignore lint/correctness/useExhaustiveDependencies: runs on every displayedMessages render to capture the resulting scrollHeight; refs are stable useLayoutEffect(() => { const container = messagesContainerRef.current; if (!container) return; // Skip while container is display:none — scrollHeight collapses to 0 and would // poison prevScrollHeightRef, causing a huge spurious delta on the next visible render. if (container.clientHeight === 0) return; const prevHeight = prevScrollHeightRef.current; const newHeight = container.scrollHeight; // Only correct when a true load-more prepend happened (flag set by the window-growth // layoutEffect or button handler). Bottom appends slide the slice(-N) window which also // changes displayedMessages[0] — ID-comparison can't distinguish the two cases. const wasPrepend = pendingPrependRef.current; // Only consume the flag when scrollHeight actually changed — the server-side load-more // path goes through two renders: Render A (filteredMessages grows, visibleCount unchanged, // same displayedMessages content, same scrollHeight) then Render B (visibleCount grows, // new messages enter displayedMessages, scrollHeight grows). The flag must survive Render A // so it's still set when Render B fires the actual correction. if (wasPrepend && newHeight !== prevHeight) { pendingPrependRef.current = false; } if ( isScrolledUpRef.current && prevHeight > 0 && newHeight > prevHeight && wasPrepend ) { const delta = newHeight - prevHeight; container.scrollTop += delta; resizeObserverPrevSHRef.current = newHeight; } prevScrollHeightRef.current = newHeight; }, [displayedMessages]); // Re-stick to bottom when inner message content grows (media/audio previews loading). // Uses prevScrollHeight instead of wasAtBottomRef to avoid stale-flag race where the // ref is true while the user is actively scrolling up. // When the container width changes (member list toggle, window resize), text reflows // and scrollHeight changes; preserve proportional scroll position for scrolled-up users. // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is a stable ref useEffect(() => { const container = messagesContainerRef.current; const inner = messagesInnerRef.current; if (!inner || !container) return; resizeObserverPrevSHRef.current = container.scrollHeight; let prevClientWidth = container.clientWidth; const observer = new ResizeObserver(() => { if (container.clientHeight === 0) return; // Effect may re-initialize while container is display:none (ref=0). // Re-seed with current dimensions and skip — no reliable "was at bottom" data. if (resizeObserverPrevSHRef.current === 0) { resizeObserverPrevSHRef.current = container.scrollHeight; prevClientWidth = container.clientWidth; return; } const currentClientWidth = container.clientWidth; const widthChanged = currentClientWidth !== prevClientWidth; prevClientWidth = currentClientWidth; const prevSH = resizeObserverPrevSHRef.current; const wasAtPrevBottom = container.scrollTop + container.clientHeight >= prevSH - SCROLL_TOLERANCE; resizeObserverPrevSHRef.current = container.scrollHeight; if (wasAtPrevBottom) { scrollToBottom(); } else if (widthChanged && prevSH > 0) { const ratio = container.scrollTop / prevSH; container.scrollTop = Math.round(ratio * container.scrollHeight); } }); observer.observe(inner); return () => observer.disconnect(); }, [isLoadingHistory, channelId, privateChatId]); // Auto-scroll on new messages — skip when this channel is hidden (display:none). // biome-ignore lint/correctness/useExhaustiveDependencies: only scroll when messages change, not when isActive changes useEffect(() => { if (!isActive) return; const isNarrowView = window.matchMedia("(max-width: 768px)").matches; const isChatVisible = !isNarrowView || mobileViewActiveColumn === "chatView"; if (wasAtBottomRef.current && isChatVisible) { scrollToBottom(); } }, [displayedMessages, mobileViewActiveColumn, scrollToBottom, isActive]); return ( <>
{isLoadingHistory && !isFetchingMore ? (
) : (
{hasMoreMessages && !searchQuery && (
)} {searchQuery && (
{plural(filteredMessages.length, { one: t`Found 1 message matching "${searchQuery}"`, other: t`Found ${filteredMessages.length} messages matching "${searchQuery}"`, })}
)} {eventGroups.map((group) => { if (group.type === "eventGroup") { const firstId = group.messages[0]?.id || ""; const lastId = group.messages[group.messages.length - 1]?.id || ""; const groupKey = `group-${firstId}-${lastId}`; return ( ); } const message = group.messages[0]; const originalIndex = channelMessages.findIndex( (m) => m.id === message.id, ); const previousMessage = channelMessages[originalIndex - 1]; const showHeader = !previousMessage || previousMessage.type !== "message" || previousMessage.userId !== message.userId || new Date(message.timestamp).getTime() - new Date(previousMessage.timestamp).getTime() > 5 * 60 * 1000; return ( ); })}
)}
); }, ); ChannelMessageList.displayName = "ChannelMessageList"; // Wrap with memo so hidden keep-alive channels skip re-renders when their props // haven't changed (e.g. when messageText changes in the input — the only thing // that changes on typing is local state inside ChatArea, not the props we pass here). export const MemoChannelMessageList = memo(ChannelMessageList); @@ -46,6 +56,12 @@ msgstr "{0} ja {1} kirjoittavat..." msgid "{0} is typing..." msgstr "{0} kirjoittaa..." +#. placeholder {0}: networks.length +#. placeholder {1}: networks.length === 1 ? "" : "s" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "{0} network{1} — pick one to join" +msgstr "" + #. placeholder {0}: filteredMessages.length - displayedMessages.length #: src/components/layout/ChannelMessageList.tsx msgid "{0} older messages" @@ -186,6 +202,12 @@ msgstr "Lisää kutsumask (esim. nick!*@*, *!*@host.com)" msgid "Add IRC Server" msgstr "Lisää IRC-palvelin" +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworksPanel.tsx +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Add Network" +msgstr "Lisää verkko" + #: src/components/message/MessageActions.tsx #: src/components/message/MessageReactions.tsx #: src/components/message/MessageReactions.tsx @@ -205,6 +227,10 @@ msgstr "Lisää sääntö" msgid "Add Server" msgstr "Lisää palvelin" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Add your first network" +msgstr "Lisää ensimmäinen verkkosi" + #: src/components/message/JsonLogMessage.tsx msgid "Additional Details" msgstr "Lisätiedot" @@ -358,6 +384,10 @@ msgstr "Takaisin" msgid "Back to image" msgstr "Takaisin kuvaan" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Back to network list" +msgstr "" + #: src/components/ui/ModerationModal.tsx msgid "Ban {username} by hostmask (prevents them from rejoining from the same IP/host)" msgstr "Estä {username} hostmaskin perusteella (estää liittymisen samasta IP-osoitteesta/hostista)" @@ -405,6 +435,8 @@ msgstr "Selaa kaikkia palvelimen kanavia" #: src/components/ui/AddPrivateChatModal.tsx #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/ChannelSettingsModal.tsx #: src/components/ui/ExternalLinkWarningModal.tsx #: src/components/ui/FloodSettingsModal.tsx @@ -640,6 +672,7 @@ msgid "Configure notification sounds and highlights" msgstr "Määritä ilmoitusäänet ja korostukset" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworksPanel.tsx msgid "Connect" msgstr "Yhdistä" @@ -759,6 +792,10 @@ msgstr "Poista kanava" msgid "Delete message" msgstr "Poista viesti" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Delete network" +msgstr "Poista verkko" + #: src/components/layout/ChannelList.tsx msgid "Delete Private Chat" msgstr "Poista yksityiskeskustelu" @@ -767,6 +804,10 @@ msgstr "Poista yksityiskeskustelu" msgid "Delete this message? This cannot be undone." msgstr "Poistetaanko tämä viesti? Toimintoa ei voi kumota." +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Delete this network?" +msgstr "Poistetaanko tämä verkko?" + #: src/components/layout/ServerList.tsx #: src/components/mobile/ServerBottomSheet.tsx msgid "Disconnect" @@ -830,10 +871,16 @@ msgstr "Lataa" msgid "e.g., 100:1440" msgstr "esim. 100:1440" +#: src/components/ui/BouncerNetworksPanel.tsx #: src/components/ui/ChannelSettingsModal.tsx msgid "Edit" msgstr "Muokkaa" +#. placeholder {0}: editingNetwork?.attributes.name || editingNetwork?.netid +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Edit {0}" +msgstr "Muokkaa: {0}" + #: src/components/ui/UserProfileModal.tsx msgid "Edit Profile" msgstr "Muokkaa profiilia" @@ -1057,6 +1104,7 @@ msgstr "KOTI" msgid "Homepage" msgstr "Kotisivu" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserProfileModal.tsx msgid "Host" msgstr "Isäntä" @@ -1271,6 +1319,10 @@ msgstr "Poistui kanavalta" msgid "Let others know when you are typing" msgstr "Ilmoita muille, kun kirjoitat" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Libera Chat" +msgstr "Libera Chat" + #: src/components/message/LinkPreview.tsx msgid "Link preview" msgstr "Linkin esikatselu" @@ -1299,6 +1351,10 @@ msgstr "Ladataan GIF-kuvia..." msgid "Loading more channels..." msgstr "Ladataan lisää kanavia..." +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Loading networks from your bouncer…" +msgstr "Ladataan verkkoja bouncerista…" + #: src/components/ui/UserProfileModal.tsx msgid "Loading WHOIS data..." msgstr "Ladataan WHOIS-tietoja..." @@ -1486,9 +1542,15 @@ msgid "Name:" msgstr "Nimi:" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx msgid "Network Name" msgstr "Verkon nimi" +#. placeholder {0}: server?.name ?? bouncerServerId +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Networks on {0}" +msgstr "Verkot palvelimella {0}" + #: src/components/ui/QuickActions.tsx msgid "New DM" msgstr "Uusi DM" @@ -1511,6 +1573,7 @@ msgid "nick!user@host (e.g., spam*!*@*, *!*@badhost.com)" msgstr "nick!käyttäjä@isäntä (esim. spam*!*@*, *!*@badhost.com)" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserSettings.tsx #: src/lib/settings/definitions/allSettings.ts msgid "Nickname" @@ -1570,6 +1633,10 @@ msgstr "Ei valittua tiedostoa" msgid "No flood profile" msgstr "Ei tulvasuojausprofiilia" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "no host set" +msgstr "isäntää ei asetettu" + #: src/components/ui/ChannelSettingsModal.tsx msgid "No invitations found" msgstr "Kutsuja ei löydetty" @@ -1610,6 +1677,10 @@ msgstr "Aihetta ei ole asetettu" msgid "No unread mentions or messages" msgstr "Ei lukemattomia mainintoja tai viestejä" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "No upstream networks yet." +msgstr "Ei vielä ylävirran verkkoja." + #: src/components/ui/AddPrivateChatModal.tsx msgid "No users available" msgstr "Ei käyttäjiä saatavilla" @@ -1696,6 +1767,10 @@ msgstr "Hups! Verkon jako! ⚠️" msgid "Op" msgstr "Op" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Open" +msgstr "" + #: src/components/ui/QuickActions/uiActionConfig.tsx msgid "Open channel configuration settings" msgstr "Avaa kanavan asetukset" @@ -1799,6 +1874,10 @@ msgstr "Kiinnitä yksityiskeskustelu" msgid "Pin this private message conversation" msgstr "Kiinnitä tämä yksityisviesteistä" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Plaintext" +msgstr "Salaamaton" + #: src/components/message/MediaPreview.tsx #: src/components/message/MediaPreview.tsx #: src/components/message/MediaPreview.tsx @@ -1827,6 +1906,7 @@ msgid "PM User" msgstr "Lähetä viesti käyttäjälle" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx msgid "Port" msgstr "Portti" @@ -1918,6 +1998,7 @@ msgstr "reagoi tähän viestiin" msgid "Read more" msgstr "Lue lisää" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserProfileModal.tsx #: src/components/ui/UserSettings.tsx #: src/lib/settings/definitions/allSettings.ts @@ -2002,6 +2083,7 @@ msgstr "Säännöt" msgid "Safe" msgstr "Turvallinen" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/ChannelSettingsModal.tsx #: src/components/ui/TopicModal.tsx #: src/components/ui/UserSettings.tsx @@ -2183,6 +2265,10 @@ msgstr "Verkon palvelinoperaattorit voisivat mahdollisesti lukea viestisi" msgid "Server Password" msgstr "Palvelimen salasana" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Server Password (PASS)" +msgstr "Palvelimen salasana (PASS)" + #: src/components/ui/LinkSecurityWarningModal.tsx msgid "Server-to-server communication may use unencrypted connections" msgstr "Palvelimien välinen viestintä voi käyttää salaamattomia yhteyksiä" @@ -2378,6 +2464,10 @@ msgstr "Aika (min)" msgid "Time Window (seconds)" msgstr "Aikaikkuna (sekuntia)" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "TLS" +msgstr "TLS" + #: src/components/message/WhisperMessage.tsx #: src/components/message/WhisperMessage.tsx msgid "to" @@ -2426,6 +2516,10 @@ msgstr "Aihe:" msgid "Total: {0}" msgstr "Yhteensä: {0}" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Transport" +msgstr "Siirtoprotokolla" + #: src/components/ui/UserSettings.tsx msgid "Trusted Sources" msgstr "Luotetut lähteet" @@ -2536,6 +2630,7 @@ msgstr "Käyttäjäprofiili" msgid "User Settings" msgstr "Käyttäjäasetukset" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/InviteUserModal.tsx #: src/components/ui/ModerationModal.tsx msgid "Username" @@ -2683,6 +2778,10 @@ msgstr "Laaja – laajempi suojauslaajuus" msgid "Will default to 'no reason' if left empty" msgstr "Oletusarvo on 'ei syytä', jos jätetään tyhjäksi" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Yes, delete" +msgstr "Kyllä, poista" + #: src/components/message/CollapsedEventMessage.tsx #: src/components/message/EventMessage.tsx msgid "You" @@ -2713,6 +2812,10 @@ msgstr "Tilin salasana tunnistautumista varten" msgid "Your account username for authentication" msgstr "Tilin käyttäjänimi tunnistautumista varten" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Your bouncer doesn't have any networks yet. Add one to get started." +msgstr "Bouncerissasi ei ole vielä verkkoja. Lisää yksi päästäksesi alkuun." + #: src/lib/settings/definitions/allSettings.ts msgid "Your default nickname for all servers" msgstr "Oletusnimimerkkisi kaikille palvelimille" diff --git a/src/locales/fr/messages.mjs b/src/locales/fr/messages.mjs index 52363311..4a077b02 100644 --- a/src/locales/fr/messages.mjs +++ b/src/locales/fr/messages.mjs @@ -1 +1 @@ -/*eslint-disable*/export const messages=JSON.parse("{\"+5VMrz\":[\"Format de modèle invalide. Utilisez le format nick!user@host (jokers * autorisés)\"],\"+6NQQA\":[\"Canal d'assistance générale\"],\"+6NyRG\":[\"Client\"],\"+K0AvT\":[\"Déconnecter\"],\"+cyFdH\":[\"Message par défaut pour le statut absent\"],\"+mVPqU\":[\"Afficher le formatage Markdown dans les messages\"],\"+vqCJH\":[\"Votre nom d'utilisateur de compte pour l'authentification\"],\"+yPBXI\":[\"Choisir un fichier\"],\"+zy2Nq\":[\"Type\"],\"/09cao\":[\"Faible sécurité du lien (niveau \",[\"securityLevel\"],\")\"],\"/3BQ4J\":[\"Les utilisateurs extérieurs ne peuvent pas envoyer de messages\"],\"/6BzZF\":[\"Afficher/masquer la liste des membres\"],\"/TNOPk\":[\"L'utilisateur est absent\"],\"/XQgft\":[\"Découvrir\"],\"/cF7Rs\":[\"Volume\"],\"/dqduX\":[\"Page suivante\"],\"/fc3q4\":[\"Tout le contenu\"],\"/kISDh\":[\"Activer les sons de notification\"],\"/n04sB\":[\"Kill\"],\"/rTz0M\":[\"Audio\"],\"/rfkZe\":[\"Jouer des sons pour les mentions et messages\"],\"0/0ZGA\":[\"Masque du nom de salon\"],\"0D6j7U\":[\"En savoir plus sur les règles personnalisées →\"],\"0XsHcR\":[\"Expulser l'utilisateur\"],\"0ZpE//\":[\"Trier par utilisateurs\"],\"0bEPwz\":[\"Se mettre absent\"],\"0dGkPt\":[\"Développer la liste des canaux\"],\"0gS7M5\":[\"Nom d'affichage\"],\"0kS+M8\":[\"ExempleRÉSEAU\"],\"0rgoY7\":[\"Se connecter uniquement aux serveurs choisis\"],\"0wdd7X\":[\"Rejoindre\"],\"0wkVYx\":[\"Messages privés\"],\"111uHX\":[\"Aperçu du lien\"],\"196EG4\":[\"Supprimer la conversation privée\"],\"1DSr1i\":[\"Créer un compte\"],\"1O/24y\":[\"Afficher/masquer la liste des canaux\"],\"1VPJJ2\":[\"Avertissement de lien externe\"],\"1ZC/dv\":[\"Aucune mention ou message non lu\"],\"1pO1zi\":[\"Le nom du serveur est requis\"],\"1uwfzQ\":[\"Voir le sujet du canal\"],\"268g7c\":[\"Saisir le nom d'affichage\"],\"2FOFq1\":[\"Les opérateurs du réseau pourraient potentiellement lire vos messages\"],\"2FYpfJ\":[\"Plus\"],\"2HF1Y2\":[[\"inviter\"],\" a invité \",[\"target\"],\" à rejoindre \",[\"channel\"]],\"2I70QL\":[\"Voir les informations du profil utilisateur\"],\"2QYdmE\":[\"Utilisateurs :\"],\"2QpEjG\":[\"a quitté\"],\"2YE223\":[\"Message dans #\",[\"0\"],\" (Entrée pour nouvelle ligne, Maj+Entrée pour envoyer)\"],\"2bimFY\":[\"Utiliser le mot de passe du serveur\"],\"2iTmdZ\":[\"Stockage local :\"],\"2odkwe\":[\"Strict – Protection plus agressive\"],\"2uDhbA\":[\"Saisir le nom d'utilisateur à inviter\"],\"2ygf/L\":[\"← Retour\"],\"2zEgxj\":[\"Rechercher des GIFs...\"],\"3RdPhl\":[\"Renommer le canal\"],\"3THokf\":[\"Utilisateur avec droit de parole\"],\"3TSz9S\":[\"Réduire\"],\"3jBDvM\":[\"Nom d'affichage du salon\"],\"3ryuFU\":[\"Rapports de plantage optionnels pour améliorer l'application\"],\"3uBF/8\":[\"Fermer le visualiseur\"],\"3uwW8F\":[\"https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"3xf8Kz\":[\"Entrez le nom du compte...\"],\"4/Rr0R\":[\"Inviter un utilisateur dans le canal actuel\"],\"4EZrJN\":[\"Règles\"],\"4JJtW9\":[\"#overflow\"],\"4NqeT4\":[\"Profil de flood (+F)\"],\"4RZQRK\":[\"Qu'est-ce que vous faites ?\"],\"4hfTrB\":[\"Pseudo\"],\"4n99LO\":[\"Déjà dans \",[\"0\"]],\"4t6vMV\":[\"Passer automatiquement en mode ligne unique pour les messages courts\"],\"4vsHmf\":[\"Temps (min)\"],\"5+INAX\":[\"Surligner les messages qui vous mentionnent\"],\"5R5Pv/\":[\"Nom Oper\"],\"678PKt\":[\"Nom du réseau\"],\"6Aih4U\":[\"Hors ligne\"],\"6CO3WE\":[\"Mot de passe requis pour rejoindre le salon. Laissez vide pour supprimer la clé.\"],\"6HhMs3\":[\"Message de déconnexion\"],\"6V3Ea3\":[\"Copié\"],\"6lGV3K\":[\"Afficher moins\"],\"6yFOEi\":[\"Entrez le mot de passe oper...\"],\"7+IHTZ\":[\"Aucun fichier choisi\"],\"73hrRi\":[\"nick!user@host (ex. : spam*!*@*, *!*@badhost.com)\"],\"7QkKyN\":[\"Envoyer un message privé\"],\"7U1W7c\":[\"Très détendu\"],\"7Y1YQj\":[\"Nom réel :\"],\"7YHArF\":[\"— ouvrir dans le visualiseur\"],\"7fjnVl\":[\"Rechercher des utilisateurs...\"],\"7jL88x\":[\"Supprimer ce message ? Cette action est irréversible.\"],\"7nGhhM\":[\"À quoi pensez-vous ?\"],\"7sEpu1\":[\"Membres — \",[\"0\"]],\"7sNhEz\":[\"Nom d'utilisateur\"],\"8H0Q+x\":[\"En savoir plus sur les profils →\"],\"8Phu0A\":[\"Afficher quand des utilisateurs changent de pseudo\"],\"8XTG9e\":[\"Saisir le mot de passe oper\"],\"8XsV2J\":[\"Réessayer l'envoi\"],\"8ZsakT\":[\"Mot de passe\"],\"8kR84m\":[\"Vous êtes sur le point d'ouvrir un lien externe :\"],\"8lCgih\":[\"Supprimer la règle\"],\"8p/xVT\":[[\"0\",\"plural\",{\"one\":[[\"1\"]],\"other\":[[\"2\"]]}]],\"8wRzac\":[[\"joinCount\",\"plural\",{\"one\":[\"a rejoint\"],\"other\":[\"a rejoint \",[\"joinCount\"],\" fois\"]}]],\"9BMLnJ\":[\"Se reconnecter au serveur\"],\"9OEgyT\":[\"Ajouter une réaction\"],\"9PQ8m2\":[\"G-Line (bannissement global)\"],\"9Qs99X\":[\"E-mail :\"],\"9QupBP\":[\"Supprimer le motif\"],\"9bG48P\":[\"Envoi en cours\"],\"9f5f0u\":[\"Questions sur la confidentialité ? Contactez-nous :\"],\"9unqs3\":[\"Absent :\"],\"9v3hwv\":[\"Aucun serveur trouvé.\"],\"9zb2WA\":[\"Connexion en cours\"],\"A1taO8\":[\"Rechercher\"],\"A2adVi\":[\"Envoyer des notifications de frappe\"],\"A9Rhec\":[\"Nom du salon\"],\"AWOSPo\":[\"Zoomer\"],\"AXSpEQ\":[\"Oper à la connexion\"],\"AeXO77\":[\"Compte\"],\"AhNP40\":[\"Avancer\"],\"Ai2U7L\":[\"Hôte\"],\"AjBQnf\":[\"Pseudo modifié\"],\"AmXVh6\":[\"https://example.com/avatar.png\"],\"AnRu/j\":[\"Annuler la réponse\"],\"ApSx0O\":[[\"0\"],\" messages trouvés correspondant à \\\"\",[\"searchQuery\"],\"\\\"\"],\"AxPAXW\":[\"Aucun résultat trouvé\"],\"AyNqAB\":[\"Afficher tous les événements serveur dans le chat\"],\"B/QqGw\":[\"Absent du clavier\"],\"B8AaMI\":[\"Ce champ est obligatoire\"],\"BA2c49\":[\"Le serveur ne supporte pas le filtrage LIST avancé\"],\"BDKt3I\":[[\"0\"],\", \",[\"1\"],\", \",[\"2\"],\" et \",[\"3\"],\" autres sont en train d'écrire...\"],\"BGul2A\":[\"Vous avez des modifications non enregistrées. Voulez-vous vraiment fermer sans enregistrer ?\"],\"BIf9fi\":[\"Votre message de statut\"],\"BZz3md\":[\"Votre site web personnel\"],\"Bgm/H7\":[\"Permettre la saisie sur plusieurs lignes\"],\"BiQIl1\":[\"Épingler cette conversation privée\"],\"BlNZZ2\":[\"Cliquez pour aller au message\"],\"Bowq3c\":[\"Seuls les opérateurs peuvent modifier le sujet\"],\"Btozzp\":[\"Cette image a expiré\"],\"Bycfjm\":[\"Total : \",[\"0\"]],\"C6IBQc\":[\"Copier le JSON complet\"],\"C9L9wL\":[\"Collecte de données\"],\"CDq4wC\":[\"Modérer l'utilisateur\"],\"CHVRxG\":[\"Message à @\",[\"0\"],\" (Maj+Entrée pour nouvelle ligne)\"],\"CN9zdR\":[\"Le nom oper et le mot de passe sont requis\"],\"CW3sYa\":[\"Ajouter la réaction \",[\"emoji\"]],\"CaAkqd\":[\"Afficher les déconnexions\"],\"CbvaYj\":[\"Bannir par pseudo\"],\"CcK+Ft\":[\"PDF\"],\"Ce8q3L\":[\"Sélectionner un canal\"],\"CsekCi\":[\"Normal\"],\"D+NlUC\":[\"Système\"],\"D28t6+\":[\"a rejoint et quitté\"],\"DB8zMK\":[\"Appliquer\"],\"DBcWHr\":[\"Fichier son de notification personnalisé\"],\"DTy9Xw\":[\"Aperçus des médias\"],\"Dj4pSr\":[\"Choisissez un mot de passe sécurisé\"],\"Du+zn+\":[\"Recherche...\"],\"Du2T2f\":[\"Paramètre introuvable\"],\"DwsSVQ\":[\"Appliquer les filtres & Actualiser\"],\"E3W/zd\":[\"Pseudo par défaut\"],\"E6nRW7\":[\"Copier l'URL\"],\"E703RG\":[\"Modes :\"],\"EAeu1Z\":[\"Envoyer l'invitation\"],\"EFKJQT\":[\"Paramètre\"],\"EGPQBv\":[\"Règles de flood personnalisées (+f)\"],\"ELik0r\":[\"Voir la politique de confidentialité complète\"],\"EPbeC2\":[\"Voir ou modifier le sujet du canal\"],\"EQCDNT\":[\"Entrez le nom d'utilisateur oper...\"],\"EUvulZ\":[\"1 message trouvé correspondant à \\\"\",[\"searchQuery\"],\"\\\"\"],\"EatZYJ\":[\"Image suivante\"],\"EdQY6l\":[\"Aucun\"],\"EnqLYU\":[\"Rechercher des serveurs...\"],\"F0OKMc\":[\"Modifier le serveur\"],\"F6Int2\":[\"Activer les surlignages\"],\"FDoLyE\":[\"Utilisateurs max.\"],\"FUU/hZ\":[\"Contrôle la quantité de médias externes chargés dans le chat.\"],\"Fdp03t\":[\"activé\"],\"FfPWR0\":[\"Modal\"],\"FjkaiT\":[\"Dézoomer\"],\"FlqOE9\":[\"Ce que cela signifie :\"],\"FolHNl\":[\"Gérez votre compte et l'authentification\"],\"Fp2Dif\":[\"A quitté le serveur\"],\"G5KmCc\":[\"GZ-Line (Z-Line globale)\"],\"GDs0lz\":[\"<0>Risque : Des informations sensibles (messages, conversations privées, identifiants de connexion) pourraient être exposées aux administrateurs réseau ou à des attaquants positionnés entre les serveurs IRC.\"],\"GR+2I3\":[\"Ajouter un masque d'invitation (ex. nick!*@*, *!*@host.com)\"],\"GRLyMU\":[\"Fermer les notifications serveur détachées\"],\"GlHnXw\":[\"Échec du changement de pseudo: \",[\"error\"],\" \",[\"0\"]],\"GswZF3\":[\"Aperçu :\"],\"GtmO8/\":[\"de\"],\"GtuHUQ\":[\"Renommer ce salon sur le serveur. Tous les utilisateurs verront le nouveau nom.\"],\"GuGfFX\":[\"Activer/désactiver la recherche\"],\"GxkJXS\":[\"Téléversement...\"],\"GzbwnK\":[\"A rejoint le canal\"],\"GzsUDB\":[\"Profil étendu\"],\"H/PnT8\":[\"Insérer un emoji\"],\"H6Izzl\":[\"Votre code couleur préféré\"],\"H9jIv+\":[\"Afficher les entrées/sorties\"],\"HAKBY9\":[\"Télécharger des fichiers\"],\"HdE1If\":[\"Canal\"],\"Hk4AW9\":[\"Votre nom d'affichage préféré\"],\"HmHDk7\":[\"Sélectionner un membre\"],\"HrQzPU\":[\"Canaux sur \",[\"networkName\"]],\"I2tXQ5\":[\"Message à @\",[\"0\"],\" (Entrée pour nouvelle ligne, Maj+Entrée pour envoyer)\"],\"I6bw/h\":[\"Bannir l'utilisateur\"],\"I92Z+b\":[\"Activer les notifications\"],\"I9D72S\":[\"Êtes-vous sûr de vouloir supprimer ce message ? Cette action est irréversible.\"],\"IA+1wo\":[\"Afficher quand des utilisateurs sont expulsés des salons\"],\"IDwkJx\":[\"IRC Operator\"],\"ILlU+s\":[\"Info :\"],\"IUwGEM\":[\"Enregistrer les modifications\"],\"IVeGK6\":[[\"0\"],\", \",[\"1\"],\" et \",[\"2\"],\" sont en train d'écrire...\"],\"IgrLD/\":[\"Pause\"],\"Im6JED\":[\"WHISPER\"],\"ImOQa9\":[\"Répondre\"],\"IoHMnl\":[\"La valeur maximale est \",[\"0\"]],\"IvMj+0\":[\"Op\"],\"J28zul\":[\"Connexion en cours...\"],\"J5T9NW\":[\"Informations utilisateur\"],\"J8Y5+z\":[\"Oups ! La réseau s'est divisé ! ⚠️\"],\"JBHkBA\":[\"A quitté le canal\"],\"JCwL0Q\":[\"Saisir une raison (facultatif)\"],\"JFciKP\":[\"Basculer\"],\"JXGkhG\":[\"Changer le nom du canal (opérateurs uniquement)\"],\"JcD7qf\":[\"Plus d'actions\"],\"JdkA+c\":[\"Secret (+s)\"],\"Jmu12l\":[\"Canaux du serveur\"],\"JvQ++s\":[\"Activer le Markdown\"],\"K2jwh/\":[\"Aucune donnée WHOIS disponible\"],\"KAXSwC\":[\"Voice\"],\"KDfTdX\":[\"Supprimer le message\"],\"KKBlUU\":[\"Intégrer\"],\"KM0pLb\":[\"Bienvenue dans le canal !\"],\"KR6W2h\":[\"Ne plus ignorer l'utilisateur\"],\"KV+Bi1\":[\"Sur invitation uniquement (+i)\"],\"KdCtwE\":[\"Nombre de secondes de surveillance de l'activité de flood avant la réinitialisation des compteurs\"],\"Kkezga\":[\"Mot de passe du serveur\"],\"KsiQ/8\":[\"Les utilisateurs doivent être invités pour rejoindre le salon\"],\"L+gB/D\":[\"Informations sur le salon\"],\"LC1a7n\":[\"Le serveur IRC a signalé que ses liens entre serveurs ont un faible niveau de sécurité. Cela signifie que lorsque vos messages sont relayés entre les serveurs IRC du réseau, ils peuvent ne pas être correctement chiffrés ou les certificats SSL/TLS peuvent ne pas être validés correctement.\"],\"LNfLR5\":[\"Afficher les expulsions\"],\"LQb0W/\":[\"Afficher tous les événements\"],\"LU7/yA\":[\"Nom alternatif pour l'affichage. Peut contenir des espaces, emojis et caractères spéciaux. Le vrai nom (\",[\"channelName\"],\") sera toujours utilisé pour les commandes IRC.\"],\"LUb9O7\":[\"Un port de serveur valide est requis\"],\"Lb+BUl\":[\"https://example.com/avatar.jpg\"],\"LcET2C\":[\"Politique de confidentialité\"],\"LcuSDR\":[\"Gérez les informations de votre profil et vos métadonnées\"],\"LqLS9B\":[\"Afficher les changements de pseudo\"],\"LsDQt2\":[\"Paramètres du canal\"],\"LtI9AS\":[\"Propriétaire\"],\"LuNhhL\":[\"a réagi à ce message\"],\"M/AZNG\":[\"URL de votre image d'avatar\"],\"M/WIer\":[\"Envoyer un message\"],\"M8er/5\":[\"Nom :\"],\"MHk+7g\":[\"Image précédente\"],\"MRorGe\":[\"MP à l'utilisateur\"],\"MVbSGP\":[\"Fenêtre temporelle (secondes)\"],\"MkpcsT\":[\"Vos messages et paramètres sont stockés localement sur votre appareil\"],\"N/hDSy\":[\"Marquer comme bot, généralement 'on' ou vide\"],\"N7TQbE\":[\"Inviter un utilisateur dans \",[\"channelName\"]],\"NCca/o\":[\"Entrez le pseudo par défaut...\"],\"Nqs6B9\":[\"Affiche tous les médias externes. Toute URL peut déclencher une requête vers un serveur inconnu.\"],\"Nt+9O7\":[\"Utiliser WebSocket au lieu de TCP brut\"],\"NxIHzc\":[\"Expulser l'utilisateur\"],\"O+v/cL\":[\"Parcourir tous les canaux du serveur\"],\"ODwSCk\":[\"Envoyer un GIF\"],\"OGQ5kK\":[\"Configurer les sons de notification et les mises en évidence\"],\"OIPt1Z\":[\"Afficher ou masquer la barre latérale de la liste des membres\"],\"OKSNq/\":[\"Très strict\"],\"ONWvwQ\":[\"Téléverser\"],\"OVKoQO\":[\"Votre mot de passe de compte pour l'authentification\"],\"ObsidianIRC - Bringing IRC to the future\":[\"ObsidianIRC - Amener IRC vers le futur\"],\"OhCpra\":[\"Définir un sujet…\"],\"OkltoQ\":[\"Bannir \",[\"username\"],\" par pseudo (l'empêche de rejoindre avec le même pseudo)\"],\"P+t/Te\":[\"Aucune donnée supplémentaire\"],\"P42Wcc\":[\"Sécurisé\"],\"PD38l0\":[\"Aperçu de l'avatar du canal\"],\"PD9mEt\":[\"Saisir un message...\"],\"PPqfdA\":[\"Ouvrir les paramètres de configuration du canal\"],\"PSCjfZ\":[\"Le sujet affiché pour ce salon. Tous les utilisateurs peuvent le voir.\"],\"PZCecv\":[\"Aperçu PDF\"],\"PeLgsC\":[[\"c\",\"plural\",{\"one\":[\"1 fois\"],\"other\":[[\"c\"],\" fois\"]}]],\"PguS2C\":[\"Ajouter un masque d'exception (ex. nick!*@*, *!*@host.com)\"],\"Pil5Ty\":[\"Affichage de \",[\"displayedChannelsCount\"],\" sur \",[\"0\"],\" canaux\"],\"PqhVlJ\":[\"Bannir l'utilisateur (par hostmask)\"],\"Q+chwU\":[\"Nom d'utilisateur :\"],\"Q6hhn8\":[\"Préférences\"],\"QF4a34\":[\"Veuillez saisir un nom d'utilisateur\"],\"QGqSZ2\":[\"Couleur et mise en forme\"],\"QJQd1J\":[\"Modifier le profil\"],\"QSzGDE\":[\"Inactif\"],\"QUlny5\":[\"Bienvenue sur \",[\"0\"],\" !\"],\"Qoq+GP\":[\"Lire la suite\"],\"QuSkCF\":[\"Filtrer les canaux...\"],\"QwUrDZ\":[\"a changé le sujet en : \",[\"topic\"]],\"R0UH07\":[\"Image \",[\"0\"],\" sur \",[\"1\"]],\"R7SsBE\":[\"Couper le son\"],\"R8rf1X\":[\"Cliquez pour définir le sujet\"],\"RArB3D\":[\"a été expulsé de \",[\"channelName\"],\" par \",[\"username\"]],\"RI3cWd\":[\"Découvrez le monde de l'IRC avec ObsidianIRC\"],\"RMMaN5\":[\"Modéré (+m)\"],\"RWw9Lg\":[\"Fermer la fenêtre\"],\"RZ2BuZ\":[\"L'enregistrement du compte \",[\"account\"],\" nécessite une vérification : \",[\"message\"]],\"RySp6q\":[\"Masquer les commentaires\"],\"SPKQTd\":[\"Le pseudo est requis\"],\"SPVjfj\":[\"Par défaut « aucune raison » si laissé vide\"],\"SQKPvQ\":[\"Inviter un utilisateur\"],\"SkZcl+\":[\"Choisissez un profil de protection contre le flood prédéfini. Ces profils offrent des paramètres de protection équilibrés pour différents cas d'usage.\"],\"Slr+3C\":[\"Utilisateurs min.\"],\"Spnlre\":[\"Vous avez invité \",[\"target\"],\" à rejoindre \",[\"channel\"]],\"T/ckN5\":[\"Ouvrir dans le visualiseur\"],\"T91vKp\":[\"Lire\"],\"TV2Wdu\":[\"Découvrez comment nous gérons vos données et protégeons votre vie privée.\"],\"TgFpwD\":[\"Application en cours...\"],\"TkzSFB\":[\"Aucune modification\"],\"TtserG\":[\"Saisir le vrai nom\"],\"Ttz9J1\":[\"Entrez le mot de passe...\"],\"Tz0i8g\":[\"Paramètres\"],\"U3pytU\":[\"Admin\"],\"UDb2YD\":[\"Réagir\"],\"UE4KO5\":[\"*channel*\"],\"UGT5vp\":[\"Enregistrer les paramètres\"],\"UV5hLB\":[\"Aucun bannissement trouvé\"],\"Uaj3Nd\":[\"Messages de statut\"],\"Ue3uny\":[\"Par défaut (aucun profil)\"],\"UkARhe\":[\"Normal – Protection standard\"],\"Umn7Cj\":[\"Pas encore de commentaires. Soyez le premier !\"],\"UtUIRh\":[[\"0\"],\" anciens messages\"],\"UwzP+U\":[\"Connexion sécurisée\"],\"V0/A4O\":[\"Propriétaire du canal\"],\"V4qgxE\":[\"Créé avant (min)\"],\"V8yTm6\":[\"Effacer la recherche\"],\"VJMMyz\":[\"ObsidianIRC - L'IRC vers le futur\"],\"VJScHU\":[\"Raison\"],\"VLsmVV\":[\"Couper les notifications\"],\"VbyRUy\":[\"Commentaires\"],\"Vmx0mQ\":[\"Défini par :\"],\"VqnIZz\":[\"Consulter notre politique de confidentialité et nos pratiques en matière de données\"],\"VrMygG\":[\"La longueur minimale est \",[\"0\"]],\"VrnTui\":[\"Vos pronoms, affichés dans votre profil\"],\"W8E3qn\":[\"Compte authentifié\"],\"WAakm9\":[\"Supprimer le canal\"],\"WFxTHC\":[\"Ajouter un masque de bannissement (ex. nick!*@*, *!*@host.com)\"],\"WN1g9F\":[\"L'hôte du serveur est requis\"],\"WRYdXW\":[\"Position audio\"],\"WUOH5B\":[\"Ignorer l'utilisateur\"],\"WWEXnZ\":[[\"0\",\"plural\",{\"one\":[\"Afficher 1 élément de plus\"],\"other\":[\"Afficher \",[\"1\"],\" éléments de plus\"]}]],\"Weq9zb\":[\"Général\"],\"Wfj7Sk\":[\"Activer ou désactiver les sons de notification\"],\"Wm7gbG\":[\"GitHub :\"],\"WyeHWY\":[\"*spam*\"],\"WzMCru\":[\"Profil de l'utilisateur\"],\"X6S3lt\":[\"Rechercher des paramètres, canaux, serveurs...\"],\"XEHan5\":[\"Continuer quand même\"],\"XI1+wb\":[\"Format invalide\"],\"XIXeuC\":[\"Message à @\",[\"0\"]],\"XMS+k4\":[\"Démarrer un message privé\"],\"XWgxXq\":[\"Album\"],\"Xd7+IT\":[\"Désépingler la conversation privée\"],\"Xm/s+u\":[\"Affichage\"],\"Xp2n93\":[\"Affiche les médias provenant de l'hébergeur de fichiers de confiance de votre serveur. Aucune requête n'est envoyée à des services externes.\"],\"XvjC4F\":[\"Enregistrement...\"],\"Y/qryO\":[\"Aucun utilisateur ne correspond à votre recherche\"],\"YAqRpI\":[\"Enregistrement du compte réussi pour \",[\"account\"],\" : \",[\"message\"]],\"YEfzvP\":[\"Sujet protégé (+t)\"],\"YQOn6a\":[\"Réduire la liste des membres\"],\"YRCoE9\":[\"Opérateur du canal\"],\"YURQaF\":[\"Voir le profil\"],\"YdBSvr\":[\"Contrôler l'affichage des médias et du contenu externe\"],\"Yj6U3V\":[\"Pas de serveur central :\"],\"YjvpGx\":[\"Pronoms\"],\"YqH4l4\":[\"Aucune clé\"],\"YyUPpV\":[\"Compte :\"],\"ZJSWfw\":[\"Message affiché lors de la déconnexion du serveur\"],\"ZR1dJ4\":[\"Invitations\"],\"ZdWg0V\":[\"Ouvrir dans le navigateur\"],\"ZhRBbl\":[\"Rechercher des messages…\"],\"Zmcu3y\":[\"Filtres avancés\"],\"a2/8e5\":[\"Sujet défini après (min)\"],\"aHKcKc\":[\"Page précédente\"],\"aJTbXX\":[\"Mot de passe Oper\"],\"aQryQv\":[\"Le modèle existe déjà\"],\"aW9pLN\":[\"Nombre maximum d'utilisateurs autorisés. Laissez vide pour aucune limite.\"],\"ah4fmZ\":[\"Affiche également des aperçus de YouTube, Vimeo, SoundCloud et autres services connus.\"],\"aifXak\":[\"Aucun média dans ce salon\"],\"ap2zBz\":[\"Détendu\"],\"az8lvo\":[\"Désactivé\"],\"azXSNo\":[\"Développer la liste des membres\"],\"azdliB\":[\"Se connecter à un compte\"],\"b26wlF\":[\"elle/la\"],\"bD/+Ei\":[\"Strict\"],\"bQ6BJn\":[\"Configurez des règles détaillées de protection contre le flood. Chaque règle précise le type d'activité à surveiller et l'action à prendre lorsque les seuils sont dépassés.\"],\"beV7+y\":[\"L'utilisateur recevra une invitation à rejoindre \",[\"channelName\"],\".\"],\"bk84cH\":[\"Message d'absence\"],\"bkHdLj\":[\"Ajouter un serveur IRC\"],\"bmQLn5\":[\"Ajouter une règle\"],\"bwRvnp\":[\"Action\"],\"c8+EVZ\":[\"Compte vérifié\"],\"cGYUlD\":[\"Aucun aperçu de média n'est chargé.\"],\"cLF98o\":[\"Afficher les commentaires (\",[\"commentCount\"],\")\"],\"cLKIDO\":[\"Aucun utilisateur disponible\"],\"cSgpoS\":[\"Épingler la conversation privée\"],\"cde3ce\":[\"Message <0>\",[\"0\"],\"\"],\"chQsxg\":[\"Copier la sortie formatée\"],\"cl/A5J\":[\"Bienvenue sur \",[\"__DEFAULT_IRC_SERVER_NAME__\"],\" !\"],\"cnGeoo\":[\"Supprimer\"],\"coPLXT\":[\"Nous ne stockons pas vos communications IRC sur nos serveurs\"],\"crYH/6\":[\"Lecteur SoundCloud\"],\"d3sis4\":[\"Ajouter un serveur\"],\"d9aN5k\":[\"Retirer \",[\"username\"],\" du canal\"],\"dEgA5A\":[\"Annuler\"],\"dGi1We\":[\"Désépingler cette conversation privée\"],\"dJVuyC\":[\"a quitté \",[\"channelName\"],\" (\",[\"reason\"],\")\"],\"dMtLDE\":[\"à\"],\"dXqxlh\":[\"<0>⚠️ Risque de sécurité ! Cette connexion peut être vulnérable à l'interception ou aux attaques de type man-in-the-middle.\"],\"da9Q/R\":[\"Modes du canal modifiés\"],\"dhJN3N\":[\"Afficher les commentaires\"],\"dj2xTE\":[\"Ignorer la notification\"],\"dpCzmC\":[\"Paramètres de protection contre le flood\"],\"e9dQpT\":[\"Voulez-vous ouvrir ce lien dans un nouvel onglet ?\"],\"ePK91l\":[\"Modifier\"],\"eYBDuB\":[\"Téléverser une image ou fournir une URL avec substitution optionnelle \",[\"size\"]],\"edBbee\":[\"Bannir \",[\"username\"],\" par hostmask (l'empêche de rejoindre depuis la même adresse IP/hôte)\"],\"ekfzWq\":[\"Paramètres utilisateur\"],\"elPDWs\":[\"Personnalisez votre expérience du client IRC\"],\"eu2osY\":[\"<0>💡 Recommandation : Ne continuez que si vous faites confiance à ce serveur et que vous comprenez les risques. Évitez de partager des informations sensibles ou des mots de passe via cette connexion.\"],\"euEhbr\":[\"Cliquez pour rejoindre \",[\"channel\"]],\"ez3vLd\":[\"Activer la saisie multiligne\"],\"f0J5Ki\":[\"Les communications entre serveurs peuvent utiliser des connexions non chiffrées\"],\"f9BHJk\":[\"Avertir l'utilisateur\"],\"fDOLLd\":[\"Aucun canal trouvé.\"],\"ffzDkB\":[\"Analyses anonymes :\"],\"fq1GF9\":[\"Afficher quand des utilisateurs se déconnectent du serveur\"],\"gEF57C\":[\"Ce serveur ne prend en charge qu'un seul type de connexion\"],\"gJuLUI\":[\"Liste d'ignorés\"],\"gNzMrk\":[\"Avatar actuel\"],\"gjPWyO\":[\"Entrez votre pseudo...\"],\"gz6UQ3\":[\"Agrandir\"],\"h6razj\":[\"Exclure le masque de nom de salon\"],\"hG6jnw\":[\"Aucun sujet défini\"],\"hG89Ed\":[\"Image\"],\"hZ6znB\":[\"Port\"],\"ha+Bz5\":[\"ex. : 100:1440\"],\"hehnjM\":[\"Quantité\"],\"hzdLuQ\":[\"Seuls les utilisateurs avec voice ou plus peuvent parler\"],\"i0qMbr\":[\"Accueil\"],\"iDNBZe\":[\"Notifications\"],\"iH8pgl\":[\"Retour\"],\"iL9SZg\":[\"Bannir l'utilisateur (par pseudo)\"],\"iNt+3c\":[\"Retour à l'image\"],\"iQvi+a\":[\"Ne plus m'avertir de la faible sécurité des liens pour ce serveur\"],\"iSLIjg\":[\"Connecter\"],\"iWXkHH\":[\"Halfop\"],\"iZeTtp\":[\"Hôte du serveur\"],\"idD8Ev\":[\"Enregistré\"],\"iivqkW\":[\"Connecté depuis\"],\"ij+Elv\":[\"Aperçu de l'image\"],\"ilIWp7\":[\"Activer/désactiver les notifications\"],\"iuaqvB\":[\"Utilisez * comme joker. Exemples : baduser!*@*, *!*@spammer.com, troll*!*@*\"],\"ixkTse\":[\"Bot\"],\"j2DGR0\":[\"Bannir par masque d'hôte\"],\"jA4uoI\":[\"Sujet :\"],\"jLXxGK\":[\"https://example.com\"],\"jPSk57\":[\"Raison (facultatif)\"],\"jUV7CU\":[\"Téléverser un avatar\"],\"jW5Uwh\":[\"Contrôle la quantité de médias externes chargés. Désactivé / Sûr / Sources fiables / Tout le contenu.\"],\"jXzms5\":[\"Options de pièce jointe\"],\"jZlrte\":[\"Couleur\"],\"jfC/xh\":[\"Contact\"],\"jywMpv\":[\"#new-channel-name\"],\"k112DD\":[\"Charger les anciens messages\"],\"k3ID0F\":[\"Filtrer les membres…\"],\"k65gsE\":[\"Analyse approfondie\"],\"k7Zgob\":[\"Annuler la connexion\"],\"kAVx5h\":[\"Aucune invitation trouvée\"],\"kCLEPU\":[\"Connecté à\"],\"kF5LKb\":[\"Modèles ignorés :\"],\"kGeOx/\":[\"Rejoindre \",[\"0\"]],\"kITKr8\":[\"Chargement des modes du salon...\"],\"kPpPsw\":[\"Vous êtes un IRC Operator\"],\"kWJmRL\":[\"Vous\"],\"kfcRb0\":[\"Avatar\"],\"kjMqSj\":[\"Copier JSON\"],\"krViRy\":[\"Cliquer pour copier en JSON\"],\"ks71ra\":[\"Exceptions\"],\"kw4lRv\":[\"Semi-opérateur du canal\"],\"kxgIRq\":[\"Sélectionnez ou ajoutez un canal pour commencer.\"],\"ky6dWe\":[\"Aperçu de l'avatar\"],\"l+GxCv\":[\"Chargement des canaux...\"],\"l+IUVW\":[\"Vérification du compte réussie pour \",[\"account\"],\" : \",[\"message\"]],\"l/siQz\":[[\"reconnectCount\",\"plural\",{\"one\":[\"s'est reconnecté\"],\"other\":[\"s'est reconnecté \",[\"reconnectCount\"],\" fois\"]}]],\"l5jmzx\":[[\"0\"],\" et \",[\"1\"],\" sont en train d'écrire...\"],\"lHy8N5\":[\"Chargement de canaux supplémentaires...\"],\"lbpf14\":[\"Rejoindre \",[\"value\"]],\"lfFsZ4\":[\"Canaux\"],\"lkNdiH\":[\"Nom de compte\"],\"ln500L\":[\"ObsidianIRC\"],\"lnCMdg\":[\"Téléverser une image\"],\"loQxaJ\":[\"Je suis de retour\"],\"lvfaxv\":[\"ACCUEIL\"],\"m16xKo\":[\"Ajouter\"],\"m8flAk\":[\"Aperçu (pas encore envoyé)\"],\"mEPxTp\":[\"<0>⚠️ Attention ! N'ouvrez que des liens provenant de sources fiables. Des liens malveillants peuvent compromettre votre sécurité ou votre vie privée.\"],\"mHGdhG\":[\"Informations sur le serveur\"],\"mHS8lb\":[\"Message dans #\",[\"0\"]],\"mMYBD9\":[\"Large – Portée de protection étendue\"],\"mTGsPd\":[\"Sujet du salon\"],\"mU8j6O\":[\"Pas de messages externes (+n)\"],\"mZp8FL\":[\"Retour automatique à une seule ligne\"],\"mdQu8G\":[\"VotrePseudo\"],\"miSSBQ\":[\"Commentaires (\",[\"commentCount\"],\")\"],\"mvyLSy\":[\"L'utilisateur est authentifié\"],\"mwtcGl\":[\"Fermer les commentaires\"],\"mzI/c+\":[\"Télécharger\"],\"n3fGRk\":[\"défini par \",[\"0\"]],\"nE9jsU\":[\"Détendu – Protection moins agressive\"],\"nNflMD\":[\"Quitter le canal\"],\"nPXkBi\":[\"Chargement des données WHOIS...\"],\"nQnxxF\":[\"Message dans #\",[\"0\"],\" (Maj+Entrée pour nouvelle ligne)\"],\"nWMRxa\":[\"Désépingler\"],\"nkC032\":[\"Aucun profil anti-flood\"],\"o69z4d\":[\"Envoyer un message d'avertissement à \",[\"username\"]],\"o9ylQi\":[\"Recherchez des GIFs pour commencer\"],\"oFGkER\":[\"Avis du serveur\"],\"oOi11l\":[\"Défiler vers le bas\"],\"oQEzQR\":[\"Nouveau message privé\"],\"oXOSPE\":[\"En ligne\"],\"oal760\":[\"Des attaques man-in-the-middle sur les liens serveur sont possibles\"],\"oeqmmJ\":[\"Sources de confiance\"],\"ovBPCi\":[\"Par défaut\"],\"p0Z69r\":[\"Le modèle ne peut pas être vide\"],\"p1KgtK\":[\"Échec du chargement audio\"],\"p59pEv\":[\"Détails supplémentaires\"],\"p7sRI6\":[\"Informer les autres que vous écrivez\"],\"pBm1od\":[\"Canal secret\"],\"pNmiXx\":[\"Votre pseudo par défaut pour tous les serveurs\"],\"pUUo9G\":[\"Nom d'hôte :\"],\"pVGPmz\":[\"Mot de passe du compte\"],\"peNE68\":[\"Permanent\"],\"plhHQt\":[\"Aucune donnée\"],\"pm6+q5\":[\"Avertissement de sécurité\"],\"pn5qSs\":[\"Informations supplémentaires\"],\"q0cR4S\":[\"est maintenant connu sous le nom de **\",[\"newNick\"],\"**\"],\"qFcunY\":[\"Le salon n'apparaîtra pas dans les commandes LIST ou NAMES\"],\"qLpTm/\":[\"Supprimer la réaction \",[\"emoji\"]],\"qVkGWK\":[\"Épingler\"],\"qY8wNa\":[\"Page d'accueil\"],\"qb0xJ7\":[\"Jokers : * correspond à toute séquence, ? à un seul caractère. Exemples : nick!*@*, *!*@host.com, *!*user@*\"],\"qhzpRq\":[\"Clé du salon (+k)\"],\"qtoOYG\":[\"Aucune limite\"],\"r1W2AS\":[\"Image hébergée\"],\"rIPR2O\":[\"Sujet défini avant (min)\"],\"rMMSYo\":[\"La longueur maximale est \",[\"0\"]],\"rWtzQe\":[\"Le réseau s'est divisé et reconnecté. ✅\"],\"rYG2u6\":[\"Veuillez patienter...\"],\"rdUucN\":[\"Aperçu\"],\"rjGI/Q\":[\"Confidentialité\"],\"rk8iDX\":[\"Chargement des GIFs...\"],\"rn6SBY\":[\"Rétablir le son\"],\"s/UKqq\":[\"A été expulsé du canal\"],\"s8cATI\":[\"a rejoint \",[\"channelName\"]],\"sCO9ue\":[\"La connexion à <0>\",[\"serverName\"],\" présente les problèmes de sécurité suivants :\"],\"sGH11W\":[\"Serveur\"],\"sHI1H+\":[\"est maintenant connu sous le nom de **\",[\"newNick\"],\"**\"],\"sJyV04\":[[\"inviter\"],\" vous a invité à rejoindre \",[\"channel\"]],\"sby+1/\":[\"Cliquer pour copier\"],\"sfN25C\":[\"Votre nom réel ou complet\"],\"sliuzR\":[\"Ouvrir le lien\"],\"sqrO9R\":[\"Mentions personnalisées\"],\"sr6RdJ\":[\"Multiligne avec Shift+Entrée\"],\"swrCpB\":[\"Le canal a été renommé de \",[\"oldName\"],\" en \",[\"newName\"],\" par \",[\"user\"],[\"0\"]],\"sxkWRg\":[\"Avancé\"],\"t/YqKh\":[\"Supprimer\"],\"t47eHD\":[\"Votre identifiant unique sur ce serveur\"],\"tAkAh0\":[\"URL avec substitution optionnelle \",[\"size\"],\". Exemple : https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"tXLJS3\":[\"Afficher ou masquer la barre latérale de la liste des canaux\"],\"tfDRzk\":[\"Enregistrer\"],\"tiBsJk\":[\"a quitté \",[\"channelName\"]],\"tt4/UD\":[\"a quitté (\",[\"reason\"],\")\"],\"u0TcnO\":[\"Le pseudo {nick} est déjà utilisé, nouvel essai avec {newNick}\"],\"u0a8B4\":[\"S'authentifier en tant qu'opérateur IRC pour l'accès administratif\"],\"u0rWFU\":[\"Créé après (min)\"],\"u72w3t\":[\"Utilisateurs et modèles à ignorer\"],\"u7jc2L\":[\"a quitté\"],\"uAQUqI\":[\"Statut\"],\"uB85T3\":[\"Échec de l'enregistrement : \",[\"msg\"]],\"uV3DOL\":[\"G-Line\"],\"uW3lLI\":[\"Serveurs IRC :\"],\"usSSr/\":[\"Niveau de zoom\"],\"v7uvcf\":[\"Logiciel :\"],\"vE8kb+\":[\"Shift+Entrée pour les nouvelles lignes (Entrée envoie)\"],\"vERlcd\":[\"Profil\"],\"vK0RL8\":[\"Pas de sujet\"],\"vSJd18\":[\"Vidéo\"],\"vXIe7J\":[\"Langue\"],\"vaHYxN\":[\"Vrai nom\"],\"vhjbKr\":[\"Absent\"],\"w4NYox\":[\"client \",[\"title\"]],\"w8xQRx\":[\"Valeur invalide\"],\"wFjjxZ\":[\"a été expulsé de \",[\"channelName\"],\" par \",[\"username\"],\" (\",[\"reason\"],\")\"],\"wGjaGl\":[\"Aucune exception de bannissement trouvée\"],\"wPrGnM\":[\"Administrateur du canal\"],\"wRkP2d\":[\"GIF\"],\"wbm86v\":[\"Afficher quand des utilisateurs rejoignent ou quittent des salons\"],\"whqZ9r\":[\"Mots ou phrases supplémentaires à surligner\"],\"wm7RV4\":[\"Son de notification\"],\"wz/Yoq\":[\"Vos messages pourraient être interceptés lors du relais entre serveurs\"],\"xCJdfg\":[\"Effacer\"],\"xUHRTR\":[\"S'authentifier automatiquement comme opérateur à la connexion\"],\"xWHwwQ\":[\"Bannissements\"],\"xYilR2\":[\"Médias\"],\"xceQrO\":[\"Seuls les websockets sécurisés sont pris en charge\"],\"xdtXa+\":[\"nom-du-salon\"],\"xfXC7q\":[\"Salons textuels\"],\"xlCYOE\":[\"Chargement des messages...\"],\"xlhswE\":[\"La valeur minimale est \",[\"0\"]],\"xq97Ci\":[\"Ajouter un mot ou une expression...\"],\"xuRqRq\":[\"Limite de clients (+l)\"],\"xwF+7J\":[[\"0\"],\" est en train d'écrire...\"],\"yNeucF\":[\"Ce serveur ne supporte pas les métadonnées de profil étendues (extension IRCv3 METADATA). Les champs comme l'avatar, le nom d'affichage et le statut ne sont pas disponibles.\"],\"yPlrca\":[\"Avatar du salon\"],\"yQE2r9\":[\"Chargement\"],\"ySU+JY\":[\"votre@email.com\"],\"yTX1Rt\":[\"Nom d'utilisateur opérateur\"],\"yYOzWD\":[\"journaux\"],\"yfx9Re\":[\"Mot de passe opérateur IRC\"],\"ygCKqB\":[\"Arrêter\"],\"ymDxJx\":[\"Nom d'utilisateur opérateur IRC\"],\"yrpRsQ\":[\"Trier par nom\"],\"yz7wBu\":[\"Fermer\"],\"zJw+jA\":[\"définit le mode : \",[\"0\"]],\"zebeLu\":[\"Saisir le nom d'utilisateur oper\"],\"zpr0Bw\":[\"GZ-Line\"]}"); \ No newline at end of file +/*eslint-disable*/export const messages=JSON.parse("{\"+5VMrz\":[\"Format de modèle invalide. Utilisez le format nick!user@host (jokers * autorisés)\"],\"+6NQQA\":[\"Canal d'assistance générale\"],\"+6NyRG\":[\"Client\"],\"+K0AvT\":[\"Déconnecter\"],\"+cyFdH\":[\"Message par défaut pour le statut absent\"],\"+mVPqU\":[\"Afficher le formatage Markdown dans les messages\"],\"+vqCJH\":[\"Votre nom d'utilisateur de compte pour l'authentification\"],\"+yPBXI\":[\"Choisir un fichier\"],\"+zy2Nq\":[\"Type\"],\"/09cao\":[\"Faible sécurité du lien (niveau \",[\"securityLevel\"],\")\"],\"/3BQ4J\":[\"Les utilisateurs extérieurs ne peuvent pas envoyer de messages\"],\"/6BzZF\":[\"Afficher/masquer la liste des membres\"],\"/TNOPk\":[\"L'utilisateur est absent\"],\"/XQgft\":[\"Découvrir\"],\"/cF7Rs\":[\"Volume\"],\"/dqduX\":[\"Page suivante\"],\"/fc3q4\":[\"Tout le contenu\"],\"/kISDh\":[\"Activer les sons de notification\"],\"/n04sB\":[\"Kill\"],\"/rTz0M\":[\"Audio\"],\"/rfkZe\":[\"Jouer des sons pour les mentions et messages\"],\"0/0ZGA\":[\"Masque du nom de salon\"],\"0D6j7U\":[\"En savoir plus sur les règles personnalisées →\"],\"0XsHcR\":[\"Expulser l'utilisateur\"],\"0ZpE//\":[\"Trier par utilisateurs\"],\"0bEPwz\":[\"Se mettre absent\"],\"0dGkPt\":[\"Développer la liste des canaux\"],\"0gS7M5\":[\"Nom d'affichage\"],\"0kS+M8\":[\"ExempleRÉSEAU\"],\"0rgoY7\":[\"Se connecter uniquement aux serveurs choisis\"],\"0wdd7X\":[\"Rejoindre\"],\"0wkVYx\":[\"Messages privés\"],\"111uHX\":[\"Aperçu du lien\"],\"196EG4\":[\"Supprimer la conversation privée\"],\"1DSr1i\":[\"Créer un compte\"],\"1O/24y\":[\"Afficher/masquer la liste des canaux\"],\"1TNIig\":[\"Open\"],\"1VPJJ2\":[\"Avertissement de lien externe\"],\"1ZC/dv\":[\"Aucune mention ou message non lu\"],\"1pO1zi\":[\"Le nom du serveur est requis\"],\"1uwfzQ\":[\"Voir le sujet du canal\"],\"268g7c\":[\"Saisir le nom d'affichage\"],\"2FOFq1\":[\"Les opérateurs du réseau pourraient potentiellement lire vos messages\"],\"2FYpfJ\":[\"Plus\"],\"2HF1Y2\":[[\"inviter\"],\" a invité \",[\"target\"],\" à rejoindre \",[\"channel\"]],\"2I70QL\":[\"Voir les informations du profil utilisateur\"],\"2QYdmE\":[\"Utilisateurs :\"],\"2QpEjG\":[\"a quitté\"],\"2YE223\":[\"Message dans #\",[\"0\"],\" (Entrée pour nouvelle ligne, Maj+Entrée pour envoyer)\"],\"2bimFY\":[\"Utiliser le mot de passe du serveur\"],\"2iTmdZ\":[\"Stockage local :\"],\"2odkwe\":[\"Strict – Protection plus agressive\"],\"2uDhbA\":[\"Saisir le nom d'utilisateur à inviter\"],\"2ygf/L\":[\"← Retour\"],\"2zEgxj\":[\"Rechercher des GIFs...\"],\"3RdPhl\":[\"Renommer le canal\"],\"3THokf\":[\"Utilisateur avec droit de parole\"],\"3TSz9S\":[\"Réduire\"],\"3jBDvM\":[\"Nom d'affichage du salon\"],\"3ryuFU\":[\"Rapports de plantage optionnels pour améliorer l'application\"],\"3uBF/8\":[\"Fermer le visualiseur\"],\"3uwW8F\":[\"https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"3xf8Kz\":[\"Entrez le nom du compte...\"],\"4/Rr0R\":[\"Inviter un utilisateur dans le canal actuel\"],\"4EZrJN\":[\"Règles\"],\"4JJtW9\":[\"#overflow\"],\"4NqeT4\":[\"Profil de flood (+F)\"],\"4RZQRK\":[\"Qu'est-ce que vous faites ?\"],\"4hfTrB\":[\"Pseudo\"],\"4n99LO\":[\"Déjà dans \",[\"0\"]],\"4t6vMV\":[\"Passer automatiquement en mode ligne unique pour les messages courts\"],\"4vsHmf\":[\"Temps (min)\"],\"4x/Axu\":[\"Votre bouncer n'a aucun réseau pour le moment. Ajoutez-en un pour commencer.\"],\"5+INAX\":[\"Surligner les messages qui vous mentionnent\"],\"5R5Pv/\":[\"Nom Oper\"],\"678PKt\":[\"Nom du réseau\"],\"6Aih4U\":[\"Hors ligne\"],\"6CO3WE\":[\"Mot de passe requis pour rejoindre le salon. Laissez vide pour supprimer la clé.\"],\"6HhMs3\":[\"Message de déconnexion\"],\"6V3Ea3\":[\"Copié\"],\"6lGV3K\":[\"Afficher moins\"],\"6yFOEi\":[\"Entrez le mot de passe oper...\"],\"7+IHTZ\":[\"Aucun fichier choisi\"],\"73fnil\":[\"TLS\"],\"73hrRi\":[\"nick!user@host (ex. : spam*!*@*, *!*@badhost.com)\"],\"7QkKyN\":[\"Envoyer un message privé\"],\"7U1W7c\":[\"Très détendu\"],\"7Y1YQj\":[\"Nom réel :\"],\"7YHArF\":[\"— ouvrir dans le visualiseur\"],\"7fjnVl\":[\"Rechercher des utilisateurs...\"],\"7jL88x\":[\"Supprimer ce message ? Cette action est irréversible.\"],\"7nGhhM\":[\"À quoi pensez-vous ?\"],\"7sEpu1\":[\"Membres — \",[\"0\"]],\"7sNhEz\":[\"Nom d'utilisateur\"],\"8H0Q+x\":[\"En savoir plus sur les profils →\"],\"8Phu0A\":[\"Afficher quand des utilisateurs changent de pseudo\"],\"8XTG9e\":[\"Saisir le mot de passe oper\"],\"8XsV2J\":[\"Réessayer l'envoi\"],\"8ZsakT\":[\"Mot de passe\"],\"8kR84m\":[\"Vous êtes sur le point d'ouvrir un lien externe :\"],\"8lCgih\":[\"Supprimer la règle\"],\"8p/xVT\":[[\"0\",\"plural\",{\"one\":[[\"1\"]],\"other\":[[\"2\"]]}]],\"8wRzac\":[[\"joinCount\",\"plural\",{\"one\":[\"a rejoint\"],\"other\":[\"a rejoint \",[\"joinCount\"],\" fois\"]}]],\"9BMLnJ\":[\"Se reconnecter au serveur\"],\"9OEgyT\":[\"Ajouter une réaction\"],\"9PQ8m2\":[\"G-Line (bannissement global)\"],\"9Qs99X\":[\"E-mail :\"],\"9QupBP\":[\"Supprimer le motif\"],\"9W7tl5\":[\"(inchangé)\"],\"9bG48P\":[\"Envoi en cours\"],\"9f5f0u\":[\"Questions sur la confidentialité ? Contactez-nous :\"],\"9iweoP\":[\"Réseaux sur \",[\"0\"]],\"9unqs3\":[\"Absent :\"],\"9v3hwv\":[\"Aucun serveur trouvé.\"],\"9zb2WA\":[\"Connexion en cours\"],\"A1taO8\":[\"Rechercher\"],\"A2adVi\":[\"Envoyer des notifications de frappe\"],\"A9Rhec\":[\"Nom du salon\"],\"AWOSPo\":[\"Zoomer\"],\"AXSpEQ\":[\"Oper à la connexion\"],\"AeXO77\":[\"Compte\"],\"AhNP40\":[\"Avancer\"],\"Ai2U7L\":[\"Hôte\"],\"AjBQnf\":[\"Pseudo modifié\"],\"AmXVh6\":[\"https://example.com/avatar.png\"],\"AnRu/j\":[\"Annuler la réponse\"],\"ApSx0O\":[[\"0\"],\" messages trouvés correspondant à \\\"\",[\"searchQuery\"],\"\\\"\"],\"AxPAXW\":[\"Aucun résultat trouvé\"],\"AyNqAB\":[\"Afficher tous les événements serveur dans le chat\"],\"B/QqGw\":[\"Absent du clavier\"],\"B0sB2k\":[\"Texte en clair\"],\"B8AaMI\":[\"Ce champ est obligatoire\"],\"BA2c49\":[\"Le serveur ne supporte pas le filtrage LIST avancé\"],\"BDKt3I\":[[\"0\"],\", \",[\"1\"],\", \",[\"2\"],\" et \",[\"3\"],\" autres sont en train d'écrire...\"],\"BGul2A\":[\"Vous avez des modifications non enregistrées. Voulez-vous vraiment fermer sans enregistrer ?\"],\"BIf9fi\":[\"Votre message de statut\"],\"BZz3md\":[\"Votre site web personnel\"],\"Bgm/H7\":[\"Permettre la saisie sur plusieurs lignes\"],\"BiQIl1\":[\"Épingler cette conversation privée\"],\"BlNZZ2\":[\"Cliquez pour aller au message\"],\"Bowq3c\":[\"Seuls les opérateurs peuvent modifier le sujet\"],\"Btozzp\":[\"Cette image a expiré\"],\"Bycfjm\":[\"Total : \",[\"0\"]],\"C6IBQc\":[\"Copier le JSON complet\"],\"C9L9wL\":[\"Collecte de données\"],\"CDq4wC\":[\"Modérer l'utilisateur\"],\"CHVRxG\":[\"Message à @\",[\"0\"],\" (Maj+Entrée pour nouvelle ligne)\"],\"CN9zdR\":[\"Le nom oper et le mot de passe sont requis\"],\"CW3sYa\":[\"Ajouter la réaction \",[\"emoji\"]],\"CaAkqd\":[\"Afficher les déconnexions\"],\"CbvaYj\":[\"Bannir par pseudo\"],\"CcK+Ft\":[\"PDF\"],\"Ce8q3L\":[\"Sélectionner un canal\"],\"CsekCi\":[\"Normal\"],\"D+NlUC\":[\"Système\"],\"D28t6+\":[\"a rejoint et quitté\"],\"DB8zMK\":[\"Appliquer\"],\"DBcWHr\":[\"Fichier son de notification personnalisé\"],\"DTy9Xw\":[\"Aperçus des médias\"],\"Dj4pSr\":[\"Choisissez un mot de passe sécurisé\"],\"Du+zn+\":[\"Recherche...\"],\"Du2T2f\":[\"Paramètre introuvable\"],\"DwsSVQ\":[\"Appliquer les filtres & Actualiser\"],\"E3W/zd\":[\"Pseudo par défaut\"],\"E6nRW7\":[\"Copier l'URL\"],\"E703RG\":[\"Modes :\"],\"EAeu1Z\":[\"Envoyer l'invitation\"],\"EFKJQT\":[\"Paramètre\"],\"EGPQBv\":[\"Règles de flood personnalisées (+f)\"],\"ELik0r\":[\"Voir la politique de confidentialité complète\"],\"EPbeC2\":[\"Voir ou modifier le sujet du canal\"],\"EQCDNT\":[\"Entrez le nom d'utilisateur oper...\"],\"EUvulZ\":[\"1 message trouvé correspondant à \\\"\",[\"searchQuery\"],\"\\\"\"],\"EatZYJ\":[\"Image suivante\"],\"EdQY6l\":[\"Aucun\"],\"EnqLYU\":[\"Rechercher des serveurs...\"],\"F0OKMc\":[\"Modifier le serveur\"],\"F6Int2\":[\"Activer les surlignages\"],\"FDoLyE\":[\"Utilisateurs max.\"],\"FUU/hZ\":[\"Contrôle la quantité de médias externes chargés dans le chat.\"],\"Fdp03t\":[\"activé\"],\"FfPWR0\":[\"Modal\"],\"FjkaiT\":[\"Dézoomer\"],\"FlqOE9\":[\"Ce que cela signifie :\"],\"FolHNl\":[\"Gérez votre compte et l'authentification\"],\"Fp2Dif\":[\"A quitté le serveur\"],\"G5KmCc\":[\"GZ-Line (Z-Line globale)\"],\"GDs0lz\":[\"<0>Risque : Des informations sensibles (messages, conversations privées, identifiants de connexion) pourraient être exposées aux administrateurs réseau ou à des attaquants positionnés entre les serveurs IRC.\"],\"GR+2I3\":[\"Ajouter un masque d'invitation (ex. nick!*@*, *!*@host.com)\"],\"GRLyMU\":[\"Fermer les notifications serveur détachées\"],\"GlHnXw\":[\"Échec du changement de pseudo: \",[\"error\"],\" \",[\"0\"]],\"GswZF3\":[\"Aperçu :\"],\"GtmO8/\":[\"de\"],\"GtuHUQ\":[\"Renommer ce salon sur le serveur. Tous les utilisateurs verront le nouveau nom.\"],\"GuGfFX\":[\"Activer/désactiver la recherche\"],\"GxkJXS\":[\"Téléversement...\"],\"GzbwnK\":[\"A rejoint le canal\"],\"GzsUDB\":[\"Profil étendu\"],\"H/PnT8\":[\"Insérer un emoji\"],\"H6Izzl\":[\"Votre code couleur préféré\"],\"H9jIv+\":[\"Afficher les entrées/sorties\"],\"HAKBY9\":[\"Télécharger des fichiers\"],\"HdE1If\":[\"Canal\"],\"Hk4AW9\":[\"Votre nom d'affichage préféré\"],\"HmHDk7\":[\"Sélectionner un membre\"],\"HrQzPU\":[\"Canaux sur \",[\"networkName\"]],\"I2tXQ5\":[\"Message à @\",[\"0\"],\" (Entrée pour nouvelle ligne, Maj+Entrée pour envoyer)\"],\"I6bw/h\":[\"Bannir l'utilisateur\"],\"I92Z+b\":[\"Activer les notifications\"],\"I9D72S\":[\"Êtes-vous sûr de vouloir supprimer ce message ? Cette action est irréversible.\"],\"IA+1wo\":[\"Afficher quand des utilisateurs sont expulsés des salons\"],\"IDwkJx\":[\"IRC Operator\"],\"ILlU+s\":[\"Info :\"],\"IUwGEM\":[\"Enregistrer les modifications\"],\"IVeGK6\":[[\"0\"],\", \",[\"1\"],\" et \",[\"2\"],\" sont en train d'écrire...\"],\"IgrLD/\":[\"Pause\"],\"Im6JED\":[\"WHISPER\"],\"ImOQa9\":[\"Répondre\"],\"IoHMnl\":[\"La valeur maximale est \",[\"0\"]],\"IvMj+0\":[\"Op\"],\"J28zul\":[\"Connexion en cours...\"],\"J5T9NW\":[\"Informations utilisateur\"],\"J8Y5+z\":[\"Oups ! La réseau s'est divisé ! ⚠️\"],\"JBHkBA\":[\"A quitté le canal\"],\"JCwL0Q\":[\"Saisir une raison (facultatif)\"],\"JFciKP\":[\"Basculer\"],\"JXGkhG\":[\"Changer le nom du canal (opérateurs uniquement)\"],\"JcD7qf\":[\"Plus d'actions\"],\"JdkA+c\":[\"Secret (+s)\"],\"Jmu12l\":[\"Canaux du serveur\"],\"JvQ++s\":[\"Activer le Markdown\"],\"K2jwh/\":[\"Aucune donnée WHOIS disponible\"],\"KAXSwC\":[\"Voice\"],\"KDfTdX\":[\"Supprimer le message\"],\"KKBlUU\":[\"Intégrer\"],\"KM0pLb\":[\"Bienvenue dans le canal !\"],\"KR6W2h\":[\"Ne plus ignorer l'utilisateur\"],\"KV+Bi1\":[\"Sur invitation uniquement (+i)\"],\"KdCtwE\":[\"Nombre de secondes de surveillance de l'activité de flood avant la réinitialisation des compteurs\"],\"Kkezga\":[\"Mot de passe du serveur\"],\"KsiQ/8\":[\"Les utilisateurs doivent être invités pour rejoindre le salon\"],\"L+gB/D\":[\"Informations sur le salon\"],\"LC1a7n\":[\"Le serveur IRC a signalé que ses liens entre serveurs ont un faible niveau de sécurité. Cela signifie que lorsque vos messages sont relayés entre les serveurs IRC du réseau, ils peuvent ne pas être correctement chiffrés ou les certificats SSL/TLS peuvent ne pas être validés correctement.\"],\"LNfLR5\":[\"Afficher les expulsions\"],\"LP+1Z7\":[\"Ajouter un réseau\"],\"LQb0W/\":[\"Afficher tous les événements\"],\"LU7/yA\":[\"Nom alternatif pour l'affichage. Peut contenir des espaces, emojis et caractères spéciaux. Le vrai nom (\",[\"channelName\"],\") sera toujours utilisé pour les commandes IRC.\"],\"LUb9O7\":[\"Un port de serveur valide est requis\"],\"Lb+BUl\":[\"https://example.com/avatar.jpg\"],\"LcET2C\":[\"Politique de confidentialité\"],\"LcuSDR\":[\"Gérez les informations de votre profil et vos métadonnées\"],\"LqLS9B\":[\"Afficher les changements de pseudo\"],\"LsDQt2\":[\"Paramètres du canal\"],\"LtI9AS\":[\"Propriétaire\"],\"LuNhhL\":[\"a réagi à ce message\"],\"M/AZNG\":[\"URL de votre image d'avatar\"],\"M/WIer\":[\"Envoyer un message\"],\"M8er/5\":[\"Nom :\"],\"MHk+7g\":[\"Image précédente\"],\"MRorGe\":[\"MP à l'utilisateur\"],\"MVbSGP\":[\"Fenêtre temporelle (secondes)\"],\"MkpcsT\":[\"Vos messages et paramètres sont stockés localement sur votre appareil\"],\"MzPdC2\":[\"Mot de passe du serveur (PASS)\"],\"N/hDSy\":[\"Marquer comme bot, généralement 'on' ou vide\"],\"N6j2JH\":[\"Modifier \",[\"0\"]],\"N7TQbE\":[\"Inviter un utilisateur dans \",[\"channelName\"]],\"NCca/o\":[\"Entrez le pseudo par défaut...\"],\"Nqs6B9\":[\"Affiche tous les médias externes. Toute URL peut déclencher une requête vers un serveur inconnu.\"],\"Nt+9O7\":[\"Utiliser WebSocket au lieu de TCP brut\"],\"NxIHzc\":[\"Expulser l'utilisateur\"],\"O+v/cL\":[\"Parcourir tous les canaux du serveur\"],\"OCGpR4\":[\"(hériter)\"],\"ODwSCk\":[\"Envoyer un GIF\"],\"OGQ5kK\":[\"Configurer les sons de notification et les mises en évidence\"],\"OIPt1Z\":[\"Afficher ou masquer la barre latérale de la liste des membres\"],\"OKSNq/\":[\"Très strict\"],\"ONWvwQ\":[\"Téléverser\"],\"OVKoQO\":[\"Votre mot de passe de compte pour l'authentification\"],\"ObsidianIRC - Bringing IRC to the future\":[\"ObsidianIRC - Amener IRC vers le futur\"],\"OhCpra\":[\"Définir un sujet…\"],\"OkltoQ\":[\"Bannir \",[\"username\"],\" par pseudo (l'empêche de rejoindre avec le même pseudo)\"],\"P+t/Te\":[\"Aucune donnée supplémentaire\"],\"P42Wcc\":[\"Sécurisé\"],\"PD38l0\":[\"Aperçu de l'avatar du canal\"],\"PD9mEt\":[\"Saisir un message...\"],\"PPqfdA\":[\"Ouvrir les paramètres de configuration du canal\"],\"PSCjfZ\":[\"Le sujet affiché pour ce salon. Tous les utilisateurs peuvent le voir.\"],\"PZCecv\":[\"Aperçu PDF\"],\"PeLgsC\":[[\"c\",\"plural\",{\"one\":[\"1 fois\"],\"other\":[[\"c\"],\" fois\"]}]],\"PguS2C\":[\"Ajouter un masque d'exception (ex. nick!*@*, *!*@host.com)\"],\"Pil5Ty\":[\"Affichage de \",[\"displayedChannelsCount\"],\" sur \",[\"0\"],\" canaux\"],\"PqhVlJ\":[\"Bannir l'utilisateur (par hostmask)\"],\"Q+chwU\":[\"Nom d'utilisateur :\"],\"Q3v9Wc\":[\"Oui, supprimer\"],\"Q6hhn8\":[\"Préférences\"],\"QF4a34\":[\"Veuillez saisir un nom d'utilisateur\"],\"QGqSZ2\":[\"Couleur et mise en forme\"],\"QJQd1J\":[\"Modifier le profil\"],\"QSzGDE\":[\"Inactif\"],\"QUlny5\":[\"Bienvenue sur \",[\"0\"],\" !\"],\"Qoq+GP\":[\"Lire la suite\"],\"QuSkCF\":[\"Filtrer les canaux...\"],\"QwUrDZ\":[\"a changé le sujet en : \",[\"topic\"]],\"R0UH07\":[\"Image \",[\"0\"],\" sur \",[\"1\"]],\"R7SsBE\":[\"Couper le son\"],\"R8rf1X\":[\"Cliquez pour définir le sujet\"],\"RArB3D\":[\"a été expulsé de \",[\"channelName\"],\" par \",[\"username\"]],\"RI3cWd\":[\"Découvrez le monde de l'IRC avec ObsidianIRC\"],\"RMMaN5\":[\"Modéré (+m)\"],\"RWw9Lg\":[\"Fermer la fenêtre\"],\"RZ2BuZ\":[\"L'enregistrement du compte \",[\"account\"],\" nécessite une vérification : \",[\"message\"]],\"RySp6q\":[\"Masquer les commentaires\"],\"S5Togi\":[\"Chargement des réseaux de votre bouncer…\"],\"SPKQTd\":[\"Le pseudo est requis\"],\"SPVjfj\":[\"Par défaut « aucune raison » si laissé vide\"],\"SQKPvQ\":[\"Inviter un utilisateur\"],\"STmlpb\":[\"Back to network list\"],\"SkZcl+\":[\"Choisissez un profil de protection contre le flood prédéfini. Ces profils offrent des paramètres de protection équilibrés pour différents cas d'usage.\"],\"Slr+3C\":[\"Utilisateurs min.\"],\"Spnlre\":[\"Vous avez invité \",[\"target\"],\" à rejoindre \",[\"channel\"]],\"T/ckN5\":[\"Ouvrir dans le visualiseur\"],\"T91vKp\":[\"Lire\"],\"TV2Wdu\":[\"Découvrez comment nous gérons vos données et protégeons votre vie privée.\"],\"TgFpwD\":[\"Application en cours...\"],\"TkzSFB\":[\"Aucune modification\"],\"TtserG\":[\"Saisir le vrai nom\"],\"Ttz9J1\":[\"Entrez le mot de passe...\"],\"Tz0i8g\":[\"Paramètres\"],\"U3pytU\":[\"Admin\"],\"UDb2YD\":[\"Réagir\"],\"UE4KO5\":[\"*channel*\"],\"UGT5vp\":[\"Enregistrer les paramètres\"],\"UV5hLB\":[\"Aucun bannissement trouvé\"],\"Uaj3Nd\":[\"Messages de statut\"],\"Ue3uny\":[\"Par défaut (aucun profil)\"],\"UkARhe\":[\"Normal – Protection standard\"],\"Umn7Cj\":[\"Pas encore de commentaires. Soyez le premier !\"],\"UtUIRh\":[[\"0\"],\" anciens messages\"],\"UwzP+U\":[\"Connexion sécurisée\"],\"V0/A4O\":[\"Propriétaire du canal\"],\"V4qgxE\":[\"Créé avant (min)\"],\"V8yTm6\":[\"Effacer la recherche\"],\"VJMMyz\":[\"ObsidianIRC - L'IRC vers le futur\"],\"VJScHU\":[\"Raison\"],\"VLsmVV\":[\"Couper les notifications\"],\"VbyRUy\":[\"Commentaires\"],\"Vmx0mQ\":[\"Défini par :\"],\"VqnIZz\":[\"Consulter notre politique de confidentialité et nos pratiques en matière de données\"],\"VrMygG\":[\"La longueur minimale est \",[\"0\"]],\"VrnTui\":[\"Vos pronoms, affichés dans votre profil\"],\"W8E3qn\":[\"Compte authentifié\"],\"WAakm9\":[\"Supprimer le canal\"],\"WFxTHC\":[\"Ajouter un masque de bannissement (ex. nick!*@*, *!*@host.com)\"],\"WN1g9F\":[\"L'hôte du serveur est requis\"],\"WRYdXW\":[\"Position audio\"],\"WUOH5B\":[\"Ignorer l'utilisateur\"],\"WWEXnZ\":[[\"0\",\"plural\",{\"one\":[\"Afficher 1 élément de plus\"],\"other\":[\"Afficher \",[\"1\"],\" éléments de plus\"]}]],\"Weq9zb\":[\"Général\"],\"Wfj7Sk\":[\"Activer ou désactiver les sons de notification\"],\"Wm7gbG\":[\"GitHub :\"],\"WyeHWY\":[\"*spam*\"],\"WzMCru\":[\"Profil de l'utilisateur\"],\"X6S3lt\":[\"Rechercher des paramètres, canaux, serveurs...\"],\"XEHan5\":[\"Continuer quand même\"],\"XI1+wb\":[\"Format invalide\"],\"XIXeuC\":[\"Message à @\",[\"0\"]],\"XMS+k4\":[\"Démarrer un message privé\"],\"XWgxXq\":[\"Album\"],\"Xd7+IT\":[\"Désépingler la conversation privée\"],\"Xm/s+u\":[\"Affichage\"],\"Xp2n93\":[\"Affiche les médias provenant de l'hébergeur de fichiers de confiance de votre serveur. Aucune requête n'est envoyée à des services externes.\"],\"XvjC4F\":[\"Enregistrement...\"],\"Y/qryO\":[\"Aucun utilisateur ne correspond à votre recherche\"],\"YAqRpI\":[\"Enregistrement du compte réussi pour \",[\"account\"],\" : \",[\"message\"]],\"YEfzvP\":[\"Sujet protégé (+t)\"],\"YQOn6a\":[\"Réduire la liste des membres\"],\"YRCoE9\":[\"Opérateur du canal\"],\"YURQaF\":[\"Voir le profil\"],\"YdBSvr\":[\"Contrôler l'affichage des médias et du contenu externe\"],\"Yj6U3V\":[\"Pas de serveur central :\"],\"YjvpGx\":[\"Pronoms\"],\"YqH4l4\":[\"Aucune clé\"],\"YyUPpV\":[\"Compte :\"],\"ZJSWfw\":[\"Message affiché lors de la déconnexion du serveur\"],\"ZR1dJ4\":[\"Invitations\"],\"ZdWg0V\":[\"Ouvrir dans le navigateur\"],\"ZhRBbl\":[\"Rechercher des messages…\"],\"Zmcu3y\":[\"Filtres avancés\"],\"a2/8e5\":[\"Sujet défini après (min)\"],\"aHKcKc\":[\"Page précédente\"],\"aJTbXX\":[\"Mot de passe Oper\"],\"aQryQv\":[\"Le modèle existe déjà\"],\"aW9pLN\":[\"Nombre maximum d'utilisateurs autorisés. Laissez vide pour aucune limite.\"],\"ah4fmZ\":[\"Affiche également des aperçus de YouTube, Vimeo, SoundCloud et autres services connus.\"],\"aifXak\":[\"Aucun média dans ce salon\"],\"ap2zBz\":[\"Détendu\"],\"az8lvo\":[\"Désactivé\"],\"azXSNo\":[\"Développer la liste des membres\"],\"azdliB\":[\"Se connecter à un compte\"],\"b26wlF\":[\"elle/la\"],\"bD/+Ei\":[\"Strict\"],\"bQ6BJn\":[\"Configurez des règles détaillées de protection contre le flood. Chaque règle précise le type d'activité à surveiller et l'action à prendre lorsque les seuils sont dépassés.\"],\"beV7+y\":[\"L'utilisateur recevra une invitation à rejoindre \",[\"channelName\"],\".\"],\"bk84cH\":[\"Message d'absence\"],\"bkHdLj\":[\"Ajouter un serveur IRC\"],\"bmQLn5\":[\"Ajouter une règle\"],\"bv4cFj\":[\"Transport\"],\"bwRvnp\":[\"Action\"],\"c8+EVZ\":[\"Compte vérifié\"],\"cGYUlD\":[\"Aucun aperçu de média n'est chargé.\"],\"cLF98o\":[\"Afficher les commentaires (\",[\"commentCount\"],\")\"],\"cLKIDO\":[\"Aucun utilisateur disponible\"],\"cSgpoS\":[\"Épingler la conversation privée\"],\"cde3ce\":[\"Message <0>\",[\"0\"],\"\"],\"chQsxg\":[\"Copier la sortie formatée\"],\"cl/A5J\":[\"Bienvenue sur \",[\"__DEFAULT_IRC_SERVER_NAME__\"],\" !\"],\"cnGeoo\":[\"Supprimer\"],\"coPLXT\":[\"Nous ne stockons pas vos communications IRC sur nos serveurs\"],\"crYH/6\":[\"Lecteur SoundCloud\"],\"cv5DQb\":[\"aucun hôte défini\"],\"d3sis4\":[\"Ajouter un serveur\"],\"d9aN5k\":[\"Retirer \",[\"username\"],\" du canal\"],\"dEgA5A\":[\"Annuler\"],\"dGi1We\":[\"Désépingler cette conversation privée\"],\"dJVuyC\":[\"a quitté \",[\"channelName\"],\" (\",[\"reason\"],\")\"],\"dMtLDE\":[\"à\"],\"dXqxlh\":[\"<0>⚠️ Risque de sécurité ! Cette connexion peut être vulnérable à l'interception ou aux attaques de type man-in-the-middle.\"],\"da9Q/R\":[\"Modes du canal modifiés\"],\"dhJN3N\":[\"Afficher les commentaires\"],\"dj2xTE\":[\"Ignorer la notification\"],\"dpCzmC\":[\"Paramètres de protection contre le flood\"],\"e9dQpT\":[\"Voulez-vous ouvrir ce lien dans un nouvel onglet ?\"],\"ePK91l\":[\"Modifier\"],\"eYBDuB\":[\"Téléverser une image ou fournir une URL avec substitution optionnelle \",[\"size\"]],\"edBbee\":[\"Bannir \",[\"username\"],\" par hostmask (l'empêche de rejoindre depuis la même adresse IP/hôte)\"],\"ekfzWq\":[\"Paramètres utilisateur\"],\"elPDWs\":[\"Personnalisez votre expérience du client IRC\"],\"eu2osY\":[\"<0>💡 Recommandation : Ne continuez que si vous faites confiance à ce serveur et que vous comprenez les risques. Évitez de partager des informations sensibles ou des mots de passe via cette connexion.\"],\"euEhbr\":[\"Cliquez pour rejoindre \",[\"channel\"]],\"ez3vLd\":[\"Activer la saisie multiligne\"],\"f0J5Ki\":[\"Les communications entre serveurs peuvent utiliser des connexions non chiffrées\"],\"f9BHJk\":[\"Avertir l'utilisateur\"],\"fDOLLd\":[\"Aucun canal trouvé.\"],\"ffzDkB\":[\"Analyses anonymes :\"],\"fq1GF9\":[\"Afficher quand des utilisateurs se déconnectent du serveur\"],\"gEF57C\":[\"Ce serveur ne prend en charge qu'un seul type de connexion\"],\"gJuLUI\":[\"Liste d'ignorés\"],\"gNzMrk\":[\"Avatar actuel\"],\"gjPWyO\":[\"Entrez votre pseudo...\"],\"gz6UQ3\":[\"Agrandir\"],\"h6/IMX\":[\"Ajoutez votre premier réseau\"],\"h6razj\":[\"Exclure le masque de nom de salon\"],\"hG6jnw\":[\"Aucun sujet défini\"],\"hG89Ed\":[\"Image\"],\"hZ6znB\":[\"Port\"],\"ha+Bz5\":[\"ex. : 100:1440\"],\"hehnjM\":[\"Quantité\"],\"hzdLuQ\":[\"Seuls les utilisateurs avec voice ou plus peuvent parler\"],\"i0qMbr\":[\"Accueil\"],\"iDNBZe\":[\"Notifications\"],\"iH8pgl\":[\"Retour\"],\"iL9SZg\":[\"Bannir l'utilisateur (par pseudo)\"],\"iNt+3c\":[\"Retour à l'image\"],\"iQvi+a\":[\"Ne plus m'avertir de la faible sécurité des liens pour ce serveur\"],\"iSLIjg\":[\"Connecter\"],\"iWXkHH\":[\"Halfop\"],\"iZeTtp\":[\"Hôte du serveur\"],\"idD8Ev\":[\"Enregistré\"],\"iivqkW\":[\"Connecté depuis\"],\"ij+Elv\":[\"Aperçu de l'image\"],\"ilIWp7\":[\"Activer/désactiver les notifications\"],\"iuaqvB\":[\"Utilisez * comme joker. Exemples : baduser!*@*, *!*@spammer.com, troll*!*@*\"],\"ixkTse\":[\"Bot\"],\"j2DGR0\":[\"Bannir par masque d'hôte\"],\"jA4uoI\":[\"Sujet :\"],\"jLXxGK\":[\"https://example.com\"],\"jPSk57\":[\"Raison (facultatif)\"],\"jUV7CU\":[\"Téléverser un avatar\"],\"jW5Uwh\":[\"Contrôle la quantité de médias externes chargés. Désactivé / Sûr / Sources fiables / Tout le contenu.\"],\"jXzms5\":[\"Options de pièce jointe\"],\"jZlrte\":[\"Couleur\"],\"jfC/xh\":[\"Contact\"],\"jywMpv\":[\"#new-channel-name\"],\"k112DD\":[\"Charger les anciens messages\"],\"k3ID0F\":[\"Filtrer les membres…\"],\"k65gsE\":[\"Analyse approfondie\"],\"k7Zgob\":[\"Annuler la connexion\"],\"kAVx5h\":[\"Aucune invitation trouvée\"],\"kCLEPU\":[\"Connecté à\"],\"kF5LKb\":[\"Modèles ignorés :\"],\"kGeOx/\":[\"Rejoindre \",[\"0\"]],\"kITKr8\":[\"Chargement des modes du salon...\"],\"kPpPsw\":[\"Vous êtes un IRC Operator\"],\"kWJmRL\":[\"Vous\"],\"kfcRb0\":[\"Avatar\"],\"kjMqSj\":[\"Copier JSON\"],\"krViRy\":[\"Cliquer pour copier en JSON\"],\"ks71ra\":[\"Exceptions\"],\"kw4lRv\":[\"Semi-opérateur du canal\"],\"kxgIRq\":[\"Sélectionnez ou ajoutez un canal pour commencer.\"],\"ky6dWe\":[\"Aperçu de l'avatar\"],\"l+GxCv\":[\"Chargement des canaux...\"],\"l+IUVW\":[\"Vérification du compte réussie pour \",[\"account\"],\" : \",[\"message\"]],\"l/siQz\":[[\"reconnectCount\",\"plural\",{\"one\":[\"s'est reconnecté\"],\"other\":[\"s'est reconnecté \",[\"reconnectCount\"],\" fois\"]}]],\"l5jmzx\":[[\"0\"],\" et \",[\"1\"],\" sont en train d'écrire...\"],\"lHy8N5\":[\"Chargement de canaux supplémentaires...\"],\"lbpf14\":[\"Rejoindre \",[\"value\"]],\"lfFsZ4\":[\"Canaux\"],\"lkNdiH\":[\"Nom de compte\"],\"ln500L\":[\"ObsidianIRC\"],\"lnCMdg\":[\"Téléverser une image\"],\"loQxaJ\":[\"Je suis de retour\"],\"lvfaxv\":[\"ACCUEIL\"],\"m0oxpP\":[\"Libera Chat\"],\"m16xKo\":[\"Ajouter\"],\"m8flAk\":[\"Aperçu (pas encore envoyé)\"],\"mEPxTp\":[\"<0>⚠️ Attention ! N'ouvrez que des liens provenant de sources fiables. Des liens malveillants peuvent compromettre votre sécurité ou votre vie privée.\"],\"mHGdhG\":[\"Informations sur le serveur\"],\"mHS8lb\":[\"Message dans #\",[\"0\"]],\"mMYBD9\":[\"Large – Portée de protection étendue\"],\"mTGsPd\":[\"Sujet du salon\"],\"mU8j6O\":[\"Pas de messages externes (+n)\"],\"mZp8FL\":[\"Retour automatique à une seule ligne\"],\"mdQu8G\":[\"VotrePseudo\"],\"miSSBQ\":[\"Commentaires (\",[\"commentCount\"],\")\"],\"mvyLSy\":[\"L'utilisateur est authentifié\"],\"mwtcGl\":[\"Fermer les commentaires\"],\"myL0MR\":[\"Supprimer ce réseau ?\"],\"mzI/c+\":[\"Télécharger\"],\"n3fGRk\":[\"défini par \",[\"0\"]],\"nE9jsU\":[\"Détendu – Protection moins agressive\"],\"nNflMD\":[\"Quitter le canal\"],\"nPXkBi\":[\"Chargement des données WHOIS...\"],\"nQnxxF\":[\"Message dans #\",[\"0\"],\" (Maj+Entrée pour nouvelle ligne)\"],\"nWMRxa\":[\"Désépingler\"],\"nkC032\":[\"Aucun profil anti-flood\"],\"o69z4d\":[\"Envoyer un message d'avertissement à \",[\"username\"]],\"o9ylQi\":[\"Recherchez des GIFs pour commencer\"],\"oFGkER\":[\"Avis du serveur\"],\"oOi11l\":[\"Défiler vers le bas\"],\"oQEzQR\":[\"Nouveau message privé\"],\"oXOSPE\":[\"En ligne\"],\"oal760\":[\"Des attaques man-in-the-middle sur les liens serveur sont possibles\"],\"oeqmmJ\":[\"Sources de confiance\"],\"ovBPCi\":[\"Par défaut\"],\"p0Z69r\":[\"Le modèle ne peut pas être vide\"],\"p1KgtK\":[\"Échec du chargement audio\"],\"p59pEv\":[\"Détails supplémentaires\"],\"p7sRI6\":[\"Informer les autres que vous écrivez\"],\"pBm1od\":[\"Canal secret\"],\"pNmiXx\":[\"Votre pseudo par défaut pour tous les serveurs\"],\"pUUo9G\":[\"Nom d'hôte :\"],\"pVGPmz\":[\"Mot de passe du compte\"],\"peNE68\":[\"Permanent\"],\"plhHQt\":[\"Aucune donnée\"],\"pm6+q5\":[\"Avertissement de sécurité\"],\"pn5qSs\":[\"Informations supplémentaires\"],\"q0cR4S\":[\"est maintenant connu sous le nom de **\",[\"newNick\"],\"**\"],\"qFcunY\":[\"Le salon n'apparaîtra pas dans les commandes LIST ou NAMES\"],\"qLpTm/\":[\"Supprimer la réaction \",[\"emoji\"]],\"qVkGWK\":[\"Épingler\"],\"qY8wNa\":[\"Page d'accueil\"],\"qb0xJ7\":[\"Jokers : * correspond à toute séquence, ? à un seul caractère. Exemples : nick!*@*, *!*@host.com, *!*user@*\"],\"qhzpRq\":[\"Clé du salon (+k)\"],\"qtoOYG\":[\"Aucune limite\"],\"r1W2AS\":[\"Image hébergée\"],\"rIPR2O\":[\"Sujet défini avant (min)\"],\"rMMSYo\":[\"La longueur maximale est \",[\"0\"]],\"rWtzQe\":[\"Le réseau s'est divisé et reconnecté. ✅\"],\"rYG2u6\":[\"Veuillez patienter...\"],\"rdUucN\":[\"Aperçu\"],\"rjGI/Q\":[\"Confidentialité\"],\"rk8iDX\":[\"Chargement des GIFs...\"],\"rn6SBY\":[\"Rétablir le son\"],\"s/UKqq\":[\"A été expulsé du canal\"],\"s8cATI\":[\"a rejoint \",[\"channelName\"]],\"sCO9ue\":[\"La connexion à <0>\",[\"serverName\"],\" présente les problèmes de sécurité suivants :\"],\"sGH11W\":[\"Serveur\"],\"sHI1H+\":[\"est maintenant connu sous le nom de **\",[\"newNick\"],\"**\"],\"sJyV04\":[[\"inviter\"],\" vous a invité à rejoindre \",[\"channel\"]],\"sUBSbK\":[\"Aucun réseau en amont pour le moment.\"],\"sby+1/\":[\"Cliquer pour copier\"],\"sfN25C\":[\"Votre nom réel ou complet\"],\"sliuzR\":[\"Ouvrir le lien\"],\"sqrO9R\":[\"Mentions personnalisées\"],\"sr6RdJ\":[\"Multiligne avec Shift+Entrée\"],\"swrCpB\":[\"Le canal a été renommé de \",[\"oldName\"],\" en \",[\"newName\"],\" par \",[\"user\"],[\"0\"]],\"sxkWRg\":[\"Avancé\"],\"t/YqKh\":[\"Supprimer\"],\"t47eHD\":[\"Votre identifiant unique sur ce serveur\"],\"tAkAh0\":[\"URL avec substitution optionnelle \",[\"size\"],\". Exemple : https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"tXLJS3\":[\"Afficher ou masquer la barre latérale de la liste des canaux\"],\"tfDRzk\":[\"Enregistrer\"],\"tiBsJk\":[\"a quitté \",[\"channelName\"]],\"tt4/UD\":[\"a quitté (\",[\"reason\"],\")\"],\"u0TcnO\":[\"Le pseudo {nick} est déjà utilisé, nouvel essai avec {newNick}\"],\"u0a8B4\":[\"S'authentifier en tant qu'opérateur IRC pour l'accès administratif\"],\"u0rWFU\":[\"Créé après (min)\"],\"u72w3t\":[\"Utilisateurs et modèles à ignorer\"],\"u7jc2L\":[\"a quitté\"],\"uAQUqI\":[\"Statut\"],\"uB85T3\":[\"Échec de l'enregistrement : \",[\"msg\"]],\"uV3DOL\":[\"G-Line\"],\"uW3lLI\":[\"Serveurs IRC :\"],\"usSSr/\":[\"Niveau de zoom\"],\"v7uvcf\":[\"Logiciel :\"],\"vE8kb+\":[\"Shift+Entrée pour les nouvelles lignes (Entrée envoie)\"],\"vERlcd\":[\"Profil\"],\"vK0RL8\":[\"Pas de sujet\"],\"vSJd18\":[\"Vidéo\"],\"vXIe7J\":[\"Langue\"],\"vaHYxN\":[\"Vrai nom\"],\"vhjbKr\":[\"Absent\"],\"w/nogd\":[[\"0\"],\" network\",[\"1\"],\" — pick one to join\"],\"w4NYox\":[\"client \",[\"title\"]],\"w8xQRx\":[\"Valeur invalide\"],\"wFjjxZ\":[\"a été expulsé de \",[\"channelName\"],\" par \",[\"username\"],\" (\",[\"reason\"],\")\"],\"wGjaGl\":[\"Aucune exception de bannissement trouvée\"],\"wPrGnM\":[\"Administrateur du canal\"],\"wRkP2d\":[\"GIF\"],\"wbm86v\":[\"Afficher quand des utilisateurs rejoignent ou quittent des salons\"],\"whqZ9r\":[\"Mots ou phrases supplémentaires à surligner\"],\"wm7RV4\":[\"Son de notification\"],\"wz/Yoq\":[\"Vos messages pourraient être interceptés lors du relais entre serveurs\"],\"xCJdfg\":[\"Effacer\"],\"xUHRTR\":[\"S'authentifier automatiquement comme opérateur à la connexion\"],\"xWHwwQ\":[\"Bannissements\"],\"xYilR2\":[\"Médias\"],\"xceQrO\":[\"Seuls les websockets sécurisés sont pris en charge\"],\"xdtXa+\":[\"nom-du-salon\"],\"xfXC7q\":[\"Salons textuels\"],\"xlCYOE\":[\"Chargement des messages...\"],\"xlhswE\":[\"La valeur minimale est \",[\"0\"]],\"xq97Ci\":[\"Ajouter un mot ou une expression...\"],\"xuRqRq\":[\"Limite de clients (+l)\"],\"xwF+7J\":[[\"0\"],\" est en train d'écrire...\"],\"yJztBY\":[\"Supprimer le réseau\"],\"yNeucF\":[\"Ce serveur ne supporte pas les métadonnées de profil étendues (extension IRCv3 METADATA). Les champs comme l'avatar, le nom d'affichage et le statut ne sont pas disponibles.\"],\"yPlrca\":[\"Avatar du salon\"],\"yQE2r9\":[\"Chargement\"],\"ySU+JY\":[\"votre@email.com\"],\"yTX1Rt\":[\"Nom d'utilisateur opérateur\"],\"yYOzWD\":[\"journaux\"],\"yfx9Re\":[\"Mot de passe opérateur IRC\"],\"ygCKqB\":[\"Arrêter\"],\"ymDxJx\":[\"Nom d'utilisateur opérateur IRC\"],\"yrpRsQ\":[\"Trier par nom\"],\"yz7wBu\":[\"Fermer\"],\"zJw+jA\":[\"définit le mode : \",[\"0\"]],\"zebeLu\":[\"Saisir le nom d'utilisateur oper\"],\"zpr0Bw\":[\"GZ-Line\"]}"); \ No newline at end of file diff --git a/src/locales/fr/messages.po b/src/locales/fr/messages.po index 2a203509..f41c4d72 100644 --- a/src/locales/fr/messages.po +++ b/src/locales/fr/messages.po @@ -22,6 +22,16 @@ msgstr "ObsidianIRC - Amener IRC vers le futur" msgid "— open in viewer" msgstr "— ouvrir dans le visualiseur" +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx +msgid "(inherit)" +msgstr "(hériter)" + +#: src/components/ui/BouncerNetworkForm.tsx +msgid "(unchanged)" +msgstr "(inchangé)" + #. placeholder {0}: filteredMessages.length #. placeholder {1}: import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import type * as React from "react"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { SCROLL_TOLERANCE, useScrollToBottom, } from "../../hooks/useScrollToBottom"; import { groupConsecutiveEvents } from "../../lib/eventGrouping"; import ircClient from "../../lib/ircClient"; import useStore from "../../store"; import type { Message as MessageType } from "../../types"; import { CollapsedEventMessage } from "../message/CollapsedEventMessage"; import { MessageItem } from "../message/MessageItem"; import LoadingSpinner from "../ui/LoadingSpinner"; import { ScrollToBottomButton } from "../ui/ScrollToBottomButton"; export const DEFAULT_VISIBLE_MESSAGE_COUNT = 100; // Stable empty array — prevents selector from returning a new [] on every render // when the channel has no messages yet (undefined ?? [] would create a new ref each time). const EMPTY_MESSAGES: import("../../types").Message[] = []; export interface ChannelMessageListHandle { setAtBottom: () => void; scrollToBottom: () => void; getScrollState: () => { scrollTop: number; isAtBottom: boolean; visibleCount: number; }; } interface ChannelMessageListProps { channelKey: string; serverId: string; channelId: string | null; privateChatId: string | null; isActive: boolean; searchQuery: string; isMemberListVisible: boolean; onReply: (msg: MessageType | null) => void; onUsernameContextMenu: ( e: React.MouseEvent, username: string, serverId: string, channelId: string, avatarEl?: Element | null, ) => void; onIrcLinkClick: (url: string) => void; onReactClick: (msg: MessageType, el: Element) => void; onReactionUnreact: (emoji: string, msg: MessageType) => void; onOpenReactionModal: ( msg: MessageType, position: { x: number; y: number }, ) => void; onDirectReaction: (emoji: string, msg: MessageType) => void; onRedactMessage: (msg: MessageType) => void; onOpenProfile: (username: string) => void; joinChannel: (serverId: string, channelName: string) => void; onClearSearch: () => void; highlightedMessageId?: string; // undefined = first visit; null = was at bottom; object = restore to saved position initialScrollState?: { scrollTop: number; visibleCount: number } | null; } export const ChannelMessageList = forwardRef< ChannelMessageListHandle, ChannelMessageListProps >( ( { channelKey, serverId, channelId, privateChatId, isActive, searchQuery, isMemberListVisible, onReply, onUsernameContextMenu, onIrcLinkClick, onReactClick, onReactionUnreact, onOpenReactionModal, onDirectReaction, onRedactMessage, onOpenProfile, joinChannel, onClearSearch, highlightedMessageId, initialScrollState, }, ref, ) => { const { t } = useLingui(); const [visibleMessageCount, setVisibleMessageCount] = useState( initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT, ); // Ref mirror so getScrollState closure always reads the current value without needing it as a dep. const visibleMessageCountRef = useRef(visibleMessageCount); visibleMessageCountRef.current = visibleMessageCount; // Distinguishes initial join (full-screen spinner) from subsequent "load more" (button spinner). const [isFetchingMore, setIsFetchingMore] = useState(false); const isFetchingMoreRef = useRef(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const messagesInnerRef = useRef(null); // prev scrollHeight for prepend delta-correction. const prevScrollHeightRef = useRef(0); // Ref mirror of isScrolledUp — lets useLayoutEffect closures read current value // without listing isScrolledUp as a dep (which would re-run effects on every scroll). const isScrolledUpRef = useRef(false); const prevFilteredLengthRef = useRef(0); const prevFirstMsgIdRef = useRef(null); // Set by the window-growth layoutEffect (or button handler) when a true prepend is detected. // Consumed by the delta-correction layoutEffect one render later (after visibleCount grows). // Using a flag instead of tracking displayedMessages[0]?.id because slice(-N) slides the // window on every bottom append, changing displayedMessages[0] even for non-prepend renders. const pendingPrependRef = useRef(false); // Shared scrollHeight baseline between the delta-correction layout effect and the inner // ResizeObserver. When scrollTop is corrected after a prepend, we update this so the RO's // "was at bottom" check is not fooled by the adjusted scrollTop vs its stale prevSH. const resizeObserverPrevSHRef = useRef(0); const channelMessages = useStore( useCallback( (state) => state.messages[channelKey] ?? EMPTY_MESSAGES, [channelKey], ), ); const servers = useStore((state) => state.servers); const mobileViewActiveColumn = useStore( (state) => state.ui.mobileViewActiveColumn, ); const channel = useMemo( () => channelId ? (servers .find((s) => s.id === serverId) ?.channels.find((c) => c.id === channelId) ?? null) : null, [servers, serverId, channelId], ); const { isScrolledUp, wasAtBottomRef, scrollToBottom } = useScrollToBottom( messagesContainerRef, messagesEndRef, { channelId: `${channelId || privateChatId}-${isMemberListVisible}` }, ); // Snapshot of the last known scroll position captured while the container was visible. // getScrollState() reads this instead of the live DOM because React commits display:none // before running cleanup effects, collapsing scrollTop/scrollHeight/clientHeight to 0. const lastScrollTopRef = useRef(initialScrollState?.scrollTop ?? 0); useEffect(() => { const container = messagesContainerRef.current; if (!container) return; const onScroll = () => { if (container.clientHeight > 0) lastScrollTopRef.current = container.scrollTop; }; container.addEventListener("scroll", onScroll, { passive: true }); return () => container.removeEventListener("scroll", onScroll); }, []); // Restore scroll position when a keep-alive channel transitions from hidden to visible. // display:none may reset scrollTop to 0; lastScrollTopRef was captured while visible. const prevActiveRef = useRef(isActive); useLayoutEffect(() => { if (isActive && !prevActiveRef.current) { const container = messagesContainerRef.current; if (container && lastScrollTopRef.current > 0) { container.scrollTop = lastScrollTopRef.current; } } prevActiveRef.current = isActive; }, [isActive]); useImperativeHandle(ref, () => ({ setAtBottom: () => { wasAtBottomRef.current = true; }, scrollToBottom, getScrollState: () => ({ scrollTop: lastScrollTopRef.current, isAtBottom: wasAtBottomRef.current, visibleCount: visibleMessageCountRef.current, }), })); const filteredMessages = useMemo(() => { if (!searchQuery.trim()) return channelMessages; const query = searchQuery.toLowerCase(); return channelMessages.filter( (msg) => msg.content.toLowerCase().includes(query) || msg.userId.toLowerCase().includes(query), ); }, [channelMessages, searchQuery]); useEffect(() => { isScrolledUpRef.current = isScrolledUp; // When the user returns to the bottom, shrink the window back to the base so // slice(-N) resumes trimming old messages from the top (memory optimization). // Only shrink if we grew above the base — preserves a sub-default saved visibleCount. if (!isScrolledUp) { setVisibleMessageCount((prev) => prev > DEFAULT_VISIBLE_MESSAGE_COUNT ? DEFAULT_VISIBLE_MESSAGE_COUNT : prev, ); } }, [isScrolledUp]); // Reset ref-tracked windowing state when switching channels. // visibleMessageCount is NOT reset here — useState(initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT) // already initializes it correctly on mount, and this effect runs once on mount for the // same channelKey (each instance is bound to exactly one channel by the parent key={}). // biome-ignore lint/correctness/useExhaustiveDependencies: intentional full reset on channel change useEffect(() => { prevFilteredLengthRef.current = 0; prevFirstMsgIdRef.current = null; prevScrollHeightRef.current = 0; pendingPrependRef.current = false; resizeObserverPrevSHRef.current = 0; }, [channelKey]); const displayedMessages = useMemo(() => { if (searchQuery.trim()) return filteredMessages; return filteredMessages.slice(-visibleMessageCount); }, [filteredMessages, visibleMessageCount, searchQuery]); const locallyHidden = filteredMessages.length > displayedMessages.length; const serverHasMore = channel?.hasMoreHistory === true; const hasMoreMessages = locallyHidden || serverHasMore; const eventGroups = useMemo( () => groupConsecutiveEvents(displayedMessages), [displayedMessages], ); const isLoadingHistory = channel?.isLoadingHistory ?? false; // Scroll to bottom on initial mount, unless a saved position was passed in. // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount only useEffect(() => { const container = messagesContainerRef.current; if (!container) return; if (initialScrollState) { container.scrollTop = initialScrollState.scrollTop; lastScrollTopRef.current = initialScrollState.scrollTop; wasAtBottomRef.current = false; } else { container.scrollTop = container.scrollHeight; lastScrollTopRef.current = container.scrollHeight; wasAtBottomRef.current = true; } }, []); // Scroll to bottom after initial join history loads; clear fetch spinner at batch end. const wasLoadingHistoryRef = useRef(false); // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is stable via useCallback; refs and setters are stable useLayoutEffect(() => { if (wasLoadingHistoryRef.current && !isLoadingHistory) { if (isFetchingMoreRef.current) { // delta correction for scroll position is handled by useLayoutEffect([displayedMessages]) isFetchingMoreRef.current = false; setIsFetchingMore(false); } else { scrollToBottom(); wasAtBottomRef.current = true; } } wasLoadingHistoryRef.current = isLoadingHistory; }, [isLoadingHistory]); // When older messages are prepended, grow the window so they enter displayedMessages. // When new messages arrive at the bottom while the user is scrolled up, also grow the // window to keep the current top messages visible — slice(-N) otherwise slides the // window forward and hides them, incrementing the "N older messages" counter on every // incoming message. Only let the slice trim from the top when the user is at the bottom // (where auto-scroll handles keeping them current). useLayoutEffect(() => { const newLength = filteredMessages.length; const newFirstId = filteredMessages[0]?.id ?? null; const delta = newLength - prevFilteredLengthRef.current; if (prevFilteredLengthRef.current > 0 && delta > 0) { if (newFirstId !== prevFirstMsgIdRef.current) { // Messages prepended (load-more): signal delta-correction to compensate scrollTop. pendingPrependRef.current = true; setVisibleMessageCount((prev) => prev + delta); } else if (isScrolledUpRef.current) { // Messages appended at bottom while user is scrolled up reading history. // Expand the window to prevent top messages from dropping out of the slice. setVisibleMessageCount((prev) => prev + delta); } } prevFilteredLengthRef.current = newLength; prevFirstMsgIdRef.current = newFirstId; }, [filteredMessages]); // Compensate scrollTop when content is prepended above the viewport. // biome-ignore lint/correctness/useExhaustiveDependencies: runs on every displayedMessages render to capture the resulting scrollHeight; refs are stable useLayoutEffect(() => { const container = messagesContainerRef.current; if (!container) return; // Skip while container is display:none — scrollHeight collapses to 0 and would // poison prevScrollHeightRef, causing a huge spurious delta on the next visible render. if (container.clientHeight === 0) return; const prevHeight = prevScrollHeightRef.current; const newHeight = container.scrollHeight; // Only correct when a true load-more prepend happened (flag set by the window-growth // layoutEffect or button handler). Bottom appends slide the slice(-N) window which also // changes displayedMessages[0] — ID-comparison can't distinguish the two cases. const wasPrepend = pendingPrependRef.current; // Only consume the flag when scrollHeight actually changed — the server-side load-more // path goes through two renders: Render A (filteredMessages grows, visibleCount unchanged, // same displayedMessages content, same scrollHeight) then Render B (visibleCount grows, // new messages enter displayedMessages, scrollHeight grows). The flag must survive Render A // so it's still set when Render B fires the actual correction. if (wasPrepend && newHeight !== prevHeight) { pendingPrependRef.current = false; } if ( isScrolledUpRef.current && prevHeight > 0 && newHeight > prevHeight && wasPrepend ) { const delta = newHeight - prevHeight; container.scrollTop += delta; resizeObserverPrevSHRef.current = newHeight; } prevScrollHeightRef.current = newHeight; }, [displayedMessages]); // Re-stick to bottom when inner message content grows (media/audio previews loading). // Uses prevScrollHeight instead of wasAtBottomRef to avoid stale-flag race where the // ref is true while the user is actively scrolling up. // When the container width changes (member list toggle, window resize), text reflows // and scrollHeight changes; preserve proportional scroll position for scrolled-up users. // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is a stable ref useEffect(() => { const container = messagesContainerRef.current; const inner = messagesInnerRef.current; if (!inner || !container) return; resizeObserverPrevSHRef.current = container.scrollHeight; let prevClientWidth = container.clientWidth; const observer = new ResizeObserver(() => { if (container.clientHeight === 0) return; // Effect may re-initialize while container is display:none (ref=0). // Re-seed with current dimensions and skip — no reliable "was at bottom" data. if (resizeObserverPrevSHRef.current === 0) { resizeObserverPrevSHRef.current = container.scrollHeight; prevClientWidth = container.clientWidth; return; } const currentClientWidth = container.clientWidth; const widthChanged = currentClientWidth !== prevClientWidth; prevClientWidth = currentClientWidth; const prevSH = resizeObserverPrevSHRef.current; const wasAtPrevBottom = container.scrollTop + container.clientHeight >= prevSH - SCROLL_TOLERANCE; resizeObserverPrevSHRef.current = container.scrollHeight; if (wasAtPrevBottom) { scrollToBottom(); } else if (widthChanged && prevSH > 0) { const ratio = container.scrollTop / prevSH; container.scrollTop = Math.round(ratio * container.scrollHeight); } }); observer.observe(inner); return () => observer.disconnect(); }, [isLoadingHistory, channelId, privateChatId]); // Auto-scroll on new messages — skip when this channel is hidden (display:none). // biome-ignore lint/correctness/useExhaustiveDependencies: only scroll when messages change, not when isActive changes useEffect(() => { if (!isActive) return; const isNarrowView = window.matchMedia("(max-width: 768px)").matches; const isChatVisible = !isNarrowView || mobileViewActiveColumn === "chatView"; if (wasAtBottomRef.current && isChatVisible) { scrollToBottom(); } }, [displayedMessages, mobileViewActiveColumn, scrollToBottom, isActive]); return ( <>
{isLoadingHistory && !isFetchingMore ? (
) : (
{hasMoreMessages && !searchQuery && (
)} {searchQuery && (
{plural(filteredMessages.length, { one: t`Found 1 message matching "${searchQuery}"`, other: t`Found ${filteredMessages.length} messages matching "${searchQuery}"`, })}
)} {eventGroups.map((group) => { if (group.type === "eventGroup") { const firstId = group.messages[0]?.id || ""; const lastId = group.messages[group.messages.length - 1]?.id || ""; const groupKey = `group-${firstId}-${lastId}`; return ( ); } const message = group.messages[0]; const originalIndex = channelMessages.findIndex( (m) => m.id === message.id, ); const previousMessage = channelMessages[originalIndex - 1]; const showHeader = !previousMessage || previousMessage.type !== "message" || previousMessage.userId !== message.userId || new Date(message.timestamp).getTime() - new Date(previousMessage.timestamp).getTime() > 5 * 60 * 1000; return ( ); })}
)}
); }, ); ChannelMessageList.displayName = "ChannelMessageList"; // Wrap with memo so hidden keep-alive channels skip re-renders when their props // haven't changed (e.g. when messageText changes in the input — the only thing // that changes on typing is local state inside ChatArea, not the props we pass here). export const MemoChannelMessageList = memo(ChannelMessageList); #. placeholder {2}: import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import type * as React from "react"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { SCROLL_TOLERANCE, useScrollToBottom, } from "../../hooks/useScrollToBottom"; import { groupConsecutiveEvents } from "../../lib/eventGrouping"; import ircClient from "../../lib/ircClient"; import useStore from "../../store"; import type { Message as MessageType } from "../../types"; import { CollapsedEventMessage } from "../message/CollapsedEventMessage"; import { MessageItem } from "../message/MessageItem"; import LoadingSpinner from "../ui/LoadingSpinner"; import { ScrollToBottomButton } from "../ui/ScrollToBottomButton"; export const DEFAULT_VISIBLE_MESSAGE_COUNT = 100; // Stable empty array — prevents selector from returning a new [] on every render // when the channel has no messages yet (undefined ?? [] would create a new ref each time). const EMPTY_MESSAGES: import("../../types").Message[] = []; export interface ChannelMessageListHandle { setAtBottom: () => void; scrollToBottom: () => void; getScrollState: () => { scrollTop: number; isAtBottom: boolean; visibleCount: number; }; } interface ChannelMessageListProps { channelKey: string; serverId: string; channelId: string | null; privateChatId: string | null; isActive: boolean; searchQuery: string; isMemberListVisible: boolean; onReply: (msg: MessageType | null) => void; onUsernameContextMenu: ( e: React.MouseEvent, username: string, serverId: string, channelId: string, avatarEl?: Element | null, ) => void; onIrcLinkClick: (url: string) => void; onReactClick: (msg: MessageType, el: Element) => void; onReactionUnreact: (emoji: string, msg: MessageType) => void; onOpenReactionModal: ( msg: MessageType, position: { x: number; y: number }, ) => void; onDirectReaction: (emoji: string, msg: MessageType) => void; onRedactMessage: (msg: MessageType) => void; onOpenProfile: (username: string) => void; joinChannel: (serverId: string, channelName: string) => void; onClearSearch: () => void; highlightedMessageId?: string; // undefined = first visit; null = was at bottom; object = restore to saved position initialScrollState?: { scrollTop: number; visibleCount: number } | null; } export const ChannelMessageList = forwardRef< ChannelMessageListHandle, ChannelMessageListProps >( ( { channelKey, serverId, channelId, privateChatId, isActive, searchQuery, isMemberListVisible, onReply, onUsernameContextMenu, onIrcLinkClick, onReactClick, onReactionUnreact, onOpenReactionModal, onDirectReaction, onRedactMessage, onOpenProfile, joinChannel, onClearSearch, highlightedMessageId, initialScrollState, }, ref, ) => { const { t } = useLingui(); const [visibleMessageCount, setVisibleMessageCount] = useState( initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT, ); // Ref mirror so getScrollState closure always reads the current value without needing it as a dep. const visibleMessageCountRef = useRef(visibleMessageCount); visibleMessageCountRef.current = visibleMessageCount; // Distinguishes initial join (full-screen spinner) from subsequent "load more" (button spinner). const [isFetchingMore, setIsFetchingMore] = useState(false); const isFetchingMoreRef = useRef(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const messagesInnerRef = useRef(null); // prev scrollHeight for prepend delta-correction. const prevScrollHeightRef = useRef(0); // Ref mirror of isScrolledUp — lets useLayoutEffect closures read current value // without listing isScrolledUp as a dep (which would re-run effects on every scroll). const isScrolledUpRef = useRef(false); const prevFilteredLengthRef = useRef(0); const prevFirstMsgIdRef = useRef(null); // Set by the window-growth layoutEffect (or button handler) when a true prepend is detected. // Consumed by the delta-correction layoutEffect one render later (after visibleCount grows). // Using a flag instead of tracking displayedMessages[0]?.id because slice(-N) slides the // window on every bottom append, changing displayedMessages[0] even for non-prepend renders. const pendingPrependRef = useRef(false); // Shared scrollHeight baseline between the delta-correction layout effect and the inner // ResizeObserver. When scrollTop is corrected after a prepend, we update this so the RO's // "was at bottom" check is not fooled by the adjusted scrollTop vs its stale prevSH. const resizeObserverPrevSHRef = useRef(0); const channelMessages = useStore( useCallback( (state) => state.messages[channelKey] ?? EMPTY_MESSAGES, [channelKey], ), ); const servers = useStore((state) => state.servers); const mobileViewActiveColumn = useStore( (state) => state.ui.mobileViewActiveColumn, ); const channel = useMemo( () => channelId ? (servers .find((s) => s.id === serverId) ?.channels.find((c) => c.id === channelId) ?? null) : null, [servers, serverId, channelId], ); const { isScrolledUp, wasAtBottomRef, scrollToBottom } = useScrollToBottom( messagesContainerRef, messagesEndRef, { channelId: `${channelId || privateChatId}-${isMemberListVisible}` }, ); // Snapshot of the last known scroll position captured while the container was visible. // getScrollState() reads this instead of the live DOM because React commits display:none // before running cleanup effects, collapsing scrollTop/scrollHeight/clientHeight to 0. const lastScrollTopRef = useRef(initialScrollState?.scrollTop ?? 0); useEffect(() => { const container = messagesContainerRef.current; if (!container) return; const onScroll = () => { if (container.clientHeight > 0) lastScrollTopRef.current = container.scrollTop; }; container.addEventListener("scroll", onScroll, { passive: true }); return () => container.removeEventListener("scroll", onScroll); }, []); // Restore scroll position when a keep-alive channel transitions from hidden to visible. // display:none may reset scrollTop to 0; lastScrollTopRef was captured while visible. const prevActiveRef = useRef(isActive); useLayoutEffect(() => { if (isActive && !prevActiveRef.current) { const container = messagesContainerRef.current; if (container && lastScrollTopRef.current > 0) { container.scrollTop = lastScrollTopRef.current; } } prevActiveRef.current = isActive; }, [isActive]); useImperativeHandle(ref, () => ({ setAtBottom: () => { wasAtBottomRef.current = true; }, scrollToBottom, getScrollState: () => ({ scrollTop: lastScrollTopRef.current, isAtBottom: wasAtBottomRef.current, visibleCount: visibleMessageCountRef.current, }), })); const filteredMessages = useMemo(() => { if (!searchQuery.trim()) return channelMessages; const query = searchQuery.toLowerCase(); return channelMessages.filter( (msg) => msg.content.toLowerCase().includes(query) || msg.userId.toLowerCase().includes(query), ); }, [channelMessages, searchQuery]); useEffect(() => { isScrolledUpRef.current = isScrolledUp; // When the user returns to the bottom, shrink the window back to the base so // slice(-N) resumes trimming old messages from the top (memory optimization). // Only shrink if we grew above the base — preserves a sub-default saved visibleCount. if (!isScrolledUp) { setVisibleMessageCount((prev) => prev > DEFAULT_VISIBLE_MESSAGE_COUNT ? DEFAULT_VISIBLE_MESSAGE_COUNT : prev, ); } }, [isScrolledUp]); // Reset ref-tracked windowing state when switching channels. // visibleMessageCount is NOT reset here — useState(initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT) // already initializes it correctly on mount, and this effect runs once on mount for the // same channelKey (each instance is bound to exactly one channel by the parent key={}). // biome-ignore lint/correctness/useExhaustiveDependencies: intentional full reset on channel change useEffect(() => { prevFilteredLengthRef.current = 0; prevFirstMsgIdRef.current = null; prevScrollHeightRef.current = 0; pendingPrependRef.current = false; resizeObserverPrevSHRef.current = 0; }, [channelKey]); const displayedMessages = useMemo(() => { if (searchQuery.trim()) return filteredMessages; return filteredMessages.slice(-visibleMessageCount); }, [filteredMessages, visibleMessageCount, searchQuery]); const locallyHidden = filteredMessages.length > displayedMessages.length; const serverHasMore = channel?.hasMoreHistory === true; const hasMoreMessages = locallyHidden || serverHasMore; const eventGroups = useMemo( () => groupConsecutiveEvents(displayedMessages), [displayedMessages], ); const isLoadingHistory = channel?.isLoadingHistory ?? false; // Scroll to bottom on initial mount, unless a saved position was passed in. // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount only useEffect(() => { const container = messagesContainerRef.current; if (!container) return; if (initialScrollState) { container.scrollTop = initialScrollState.scrollTop; lastScrollTopRef.current = initialScrollState.scrollTop; wasAtBottomRef.current = false; } else { container.scrollTop = container.scrollHeight; lastScrollTopRef.current = container.scrollHeight; wasAtBottomRef.current = true; } }, []); // Scroll to bottom after initial join history loads; clear fetch spinner at batch end. const wasLoadingHistoryRef = useRef(false); // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is stable via useCallback; refs and setters are stable useLayoutEffect(() => { if (wasLoadingHistoryRef.current && !isLoadingHistory) { if (isFetchingMoreRef.current) { // delta correction for scroll position is handled by useLayoutEffect([displayedMessages]) isFetchingMoreRef.current = false; setIsFetchingMore(false); } else { scrollToBottom(); wasAtBottomRef.current = true; } } wasLoadingHistoryRef.current = isLoadingHistory; }, [isLoadingHistory]); // When older messages are prepended, grow the window so they enter displayedMessages. // When new messages arrive at the bottom while the user is scrolled up, also grow the // window to keep the current top messages visible — slice(-N) otherwise slides the // window forward and hides them, incrementing the "N older messages" counter on every // incoming message. Only let the slice trim from the top when the user is at the bottom // (where auto-scroll handles keeping them current). useLayoutEffect(() => { const newLength = filteredMessages.length; const newFirstId = filteredMessages[0]?.id ?? null; const delta = newLength - prevFilteredLengthRef.current; if (prevFilteredLengthRef.current > 0 && delta > 0) { if (newFirstId !== prevFirstMsgIdRef.current) { // Messages prepended (load-more): signal delta-correction to compensate scrollTop. pendingPrependRef.current = true; setVisibleMessageCount((prev) => prev + delta); } else if (isScrolledUpRef.current) { // Messages appended at bottom while user is scrolled up reading history. // Expand the window to prevent top messages from dropping out of the slice. setVisibleMessageCount((prev) => prev + delta); } } prevFilteredLengthRef.current = newLength; prevFirstMsgIdRef.current = newFirstId; }, [filteredMessages]); // Compensate scrollTop when content is prepended above the viewport. // biome-ignore lint/correctness/useExhaustiveDependencies: runs on every displayedMessages render to capture the resulting scrollHeight; refs are stable useLayoutEffect(() => { const container = messagesContainerRef.current; if (!container) return; // Skip while container is display:none — scrollHeight collapses to 0 and would // poison prevScrollHeightRef, causing a huge spurious delta on the next visible render. if (container.clientHeight === 0) return; const prevHeight = prevScrollHeightRef.current; const newHeight = container.scrollHeight; // Only correct when a true load-more prepend happened (flag set by the window-growth // layoutEffect or button handler). Bottom appends slide the slice(-N) window which also // changes displayedMessages[0] — ID-comparison can't distinguish the two cases. const wasPrepend = pendingPrependRef.current; // Only consume the flag when scrollHeight actually changed — the server-side load-more // path goes through two renders: Render A (filteredMessages grows, visibleCount unchanged, // same displayedMessages content, same scrollHeight) then Render B (visibleCount grows, // new messages enter displayedMessages, scrollHeight grows). The flag must survive Render A // so it's still set when Render B fires the actual correction. if (wasPrepend && newHeight !== prevHeight) { pendingPrependRef.current = false; } if ( isScrolledUpRef.current && prevHeight > 0 && newHeight > prevHeight && wasPrepend ) { const delta = newHeight - prevHeight; container.scrollTop += delta; resizeObserverPrevSHRef.current = newHeight; } prevScrollHeightRef.current = newHeight; }, [displayedMessages]); // Re-stick to bottom when inner message content grows (media/audio previews loading). // Uses prevScrollHeight instead of wasAtBottomRef to avoid stale-flag race where the // ref is true while the user is actively scrolling up. // When the container width changes (member list toggle, window resize), text reflows // and scrollHeight changes; preserve proportional scroll position for scrolled-up users. // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is a stable ref useEffect(() => { const container = messagesContainerRef.current; const inner = messagesInnerRef.current; if (!inner || !container) return; resizeObserverPrevSHRef.current = container.scrollHeight; let prevClientWidth = container.clientWidth; const observer = new ResizeObserver(() => { if (container.clientHeight === 0) return; // Effect may re-initialize while container is display:none (ref=0). // Re-seed with current dimensions and skip — no reliable "was at bottom" data. if (resizeObserverPrevSHRef.current === 0) { resizeObserverPrevSHRef.current = container.scrollHeight; prevClientWidth = container.clientWidth; return; } const currentClientWidth = container.clientWidth; const widthChanged = currentClientWidth !== prevClientWidth; prevClientWidth = currentClientWidth; const prevSH = resizeObserverPrevSHRef.current; const wasAtPrevBottom = container.scrollTop + container.clientHeight >= prevSH - SCROLL_TOLERANCE; resizeObserverPrevSHRef.current = container.scrollHeight; if (wasAtPrevBottom) { scrollToBottom(); } else if (widthChanged && prevSH > 0) { const ratio = container.scrollTop / prevSH; container.scrollTop = Math.round(ratio * container.scrollHeight); } }); observer.observe(inner); return () => observer.disconnect(); }, [isLoadingHistory, channelId, privateChatId]); // Auto-scroll on new messages — skip when this channel is hidden (display:none). // biome-ignore lint/correctness/useExhaustiveDependencies: only scroll when messages change, not when isActive changes useEffect(() => { if (!isActive) return; const isNarrowView = window.matchMedia("(max-width: 768px)").matches; const isChatVisible = !isNarrowView || mobileViewActiveColumn === "chatView"; if (wasAtBottomRef.current && isChatVisible) { scrollToBottom(); } }, [displayedMessages, mobileViewActiveColumn, scrollToBottom, isActive]); return ( <>
{isLoadingHistory && !isFetchingMore ? (
) : (
{hasMoreMessages && !searchQuery && (
)} {searchQuery && (
{plural(filteredMessages.length, { one: t`Found 1 message matching "${searchQuery}"`, other: t`Found ${filteredMessages.length} messages matching "${searchQuery}"`, })}
)} {eventGroups.map((group) => { if (group.type === "eventGroup") { const firstId = group.messages[0]?.id || ""; const lastId = group.messages[group.messages.length - 1]?.id || ""; const groupKey = `group-${firstId}-${lastId}`; return ( ); } const message = group.messages[0]; const originalIndex = channelMessages.findIndex( (m) => m.id === message.id, ); const previousMessage = channelMessages[originalIndex - 1]; const showHeader = !previousMessage || previousMessage.type !== "message" || previousMessage.userId !== message.userId || new Date(message.timestamp).getTime() - new Date(previousMessage.timestamp).getTime() > 5 * 60 * 1000; return ( ); })}
)}
); }, ); ChannelMessageList.displayName = "ChannelMessageList"; // Wrap with memo so hidden keep-alive channels skip re-renders when their props // haven't changed (e.g. when messageText changes in the input — the only thing // that changes on typing is local state inside ChatArea, not the props we pass here). export const MemoChannelMessageList = memo(ChannelMessageList); @@ -46,6 +56,12 @@ msgstr "{0} et {1} sont en train d'écrire..." msgid "{0} is typing..." msgstr "{0} est en train d'écrire..." +#. placeholder {0}: networks.length +#. placeholder {1}: networks.length === 1 ? "" : "s" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "{0} network{1} — pick one to join" +msgstr "" + #. placeholder {0}: filteredMessages.length - displayedMessages.length #: src/components/layout/ChannelMessageList.tsx msgid "{0} older messages" @@ -186,6 +202,12 @@ msgstr "Ajouter un masque d'invitation (ex. nick!*@*, *!*@host.com)" msgid "Add IRC Server" msgstr "Ajouter un serveur IRC" +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworksPanel.tsx +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Add Network" +msgstr "Ajouter un réseau" + #: src/components/message/MessageActions.tsx #: src/components/message/MessageReactions.tsx #: src/components/message/MessageReactions.tsx @@ -205,6 +227,10 @@ msgstr "Ajouter une règle" msgid "Add Server" msgstr "Ajouter un serveur" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Add your first network" +msgstr "Ajoutez votre premier réseau" + #: src/components/message/JsonLogMessage.tsx msgid "Additional Details" msgstr "Détails supplémentaires" @@ -358,6 +384,10 @@ msgstr "Retour" msgid "Back to image" msgstr "Retour à l'image" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Back to network list" +msgstr "" + #: src/components/ui/ModerationModal.tsx msgid "Ban {username} by hostmask (prevents them from rejoining from the same IP/host)" msgstr "Bannir {username} par hostmask (l'empêche de rejoindre depuis la même adresse IP/hôte)" @@ -405,6 +435,8 @@ msgstr "Parcourir tous les canaux du serveur" #: src/components/ui/AddPrivateChatModal.tsx #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/ChannelSettingsModal.tsx #: src/components/ui/ExternalLinkWarningModal.tsx #: src/components/ui/FloodSettingsModal.tsx @@ -640,6 +672,7 @@ msgid "Configure notification sounds and highlights" msgstr "Configurer les sons de notification et les mises en évidence" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworksPanel.tsx msgid "Connect" msgstr "Connecter" @@ -759,6 +792,10 @@ msgstr "Supprimer le canal" msgid "Delete message" msgstr "Supprimer le message" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Delete network" +msgstr "Supprimer le réseau" + #: src/components/layout/ChannelList.tsx msgid "Delete Private Chat" msgstr "Supprimer la conversation privée" @@ -767,6 +804,10 @@ msgstr "Supprimer la conversation privée" msgid "Delete this message? This cannot be undone." msgstr "Supprimer ce message ? Cette action est irréversible." +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Delete this network?" +msgstr "Supprimer ce réseau ?" + #: src/components/layout/ServerList.tsx #: src/components/mobile/ServerBottomSheet.tsx msgid "Disconnect" @@ -830,10 +871,16 @@ msgstr "Télécharger" msgid "e.g., 100:1440" msgstr "ex. : 100:1440" +#: src/components/ui/BouncerNetworksPanel.tsx #: src/components/ui/ChannelSettingsModal.tsx msgid "Edit" msgstr "Modifier" +#. placeholder {0}: editingNetwork?.attributes.name || editingNetwork?.netid +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Edit {0}" +msgstr "Modifier {0}" + #: src/components/ui/UserProfileModal.tsx msgid "Edit Profile" msgstr "Modifier le profil" @@ -1057,6 +1104,7 @@ msgstr "ACCUEIL" msgid "Homepage" msgstr "Page d'accueil" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserProfileModal.tsx msgid "Host" msgstr "Hôte" @@ -1271,6 +1319,10 @@ msgstr "A quitté le canal" msgid "Let others know when you are typing" msgstr "Informer les autres que vous écrivez" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Libera Chat" +msgstr "Libera Chat" + #: src/components/message/LinkPreview.tsx msgid "Link preview" msgstr "Aperçu du lien" @@ -1299,6 +1351,10 @@ msgstr "Chargement des GIFs..." msgid "Loading more channels..." msgstr "Chargement de canaux supplémentaires..." +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Loading networks from your bouncer…" +msgstr "Chargement des réseaux de votre bouncer…" + #: src/components/ui/UserProfileModal.tsx msgid "Loading WHOIS data..." msgstr "Chargement des données WHOIS..." @@ -1486,9 +1542,15 @@ msgid "Name:" msgstr "Nom :" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx msgid "Network Name" msgstr "Nom du réseau" +#. placeholder {0}: server?.name ?? bouncerServerId +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Networks on {0}" +msgstr "Réseaux sur {0}" + #: src/components/ui/QuickActions.tsx msgid "New DM" msgstr "Nouveau message privé" @@ -1511,6 +1573,7 @@ msgid "nick!user@host (e.g., spam*!*@*, *!*@badhost.com)" msgstr "nick!user@host (ex. : spam*!*@*, *!*@badhost.com)" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserSettings.tsx #: src/lib/settings/definitions/allSettings.ts msgid "Nickname" @@ -1570,6 +1633,10 @@ msgstr "Aucun fichier choisi" msgid "No flood profile" msgstr "Aucun profil anti-flood" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "no host set" +msgstr "aucun hôte défini" + #: src/components/ui/ChannelSettingsModal.tsx msgid "No invitations found" msgstr "Aucune invitation trouvée" @@ -1610,6 +1677,10 @@ msgstr "Aucun sujet défini" msgid "No unread mentions or messages" msgstr "Aucune mention ou message non lu" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "No upstream networks yet." +msgstr "Aucun réseau en amont pour le moment." + #: src/components/ui/AddPrivateChatModal.tsx msgid "No users available" msgstr "Aucun utilisateur disponible" @@ -1696,6 +1767,10 @@ msgstr "Oups ! La réseau s'est divisé ! ⚠️" msgid "Op" msgstr "Op" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Open" +msgstr "" + #: src/components/ui/QuickActions/uiActionConfig.tsx msgid "Open channel configuration settings" msgstr "Ouvrir les paramètres de configuration du canal" @@ -1799,6 +1874,10 @@ msgstr "Épingler la conversation privée" msgid "Pin this private message conversation" msgstr "Épingler cette conversation privée" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Plaintext" +msgstr "Texte en clair" + #: src/components/message/MediaPreview.tsx #: src/components/message/MediaPreview.tsx #: src/components/message/MediaPreview.tsx @@ -1827,6 +1906,7 @@ msgid "PM User" msgstr "MP à l'utilisateur" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx msgid "Port" msgstr "Port" @@ -1918,6 +1998,7 @@ msgstr "a réagi à ce message" msgid "Read more" msgstr "Lire la suite" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserProfileModal.tsx #: src/components/ui/UserSettings.tsx #: src/lib/settings/definitions/allSettings.ts @@ -2002,6 +2083,7 @@ msgstr "Règles" msgid "Safe" msgstr "Sécurisé" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/ChannelSettingsModal.tsx #: src/components/ui/TopicModal.tsx #: src/components/ui/UserSettings.tsx @@ -2183,6 +2265,10 @@ msgstr "Les opérateurs du réseau pourraient potentiellement lire vos messages" msgid "Server Password" msgstr "Mot de passe du serveur" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Server Password (PASS)" +msgstr "Mot de passe du serveur (PASS)" + #: src/components/ui/LinkSecurityWarningModal.tsx msgid "Server-to-server communication may use unencrypted connections" msgstr "Les communications entre serveurs peuvent utiliser des connexions non chiffrées" @@ -2378,6 +2464,10 @@ msgstr "Temps (min)" msgid "Time Window (seconds)" msgstr "Fenêtre temporelle (secondes)" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "TLS" +msgstr "TLS" + #: src/components/message/WhisperMessage.tsx #: src/components/message/WhisperMessage.tsx msgid "to" @@ -2426,6 +2516,10 @@ msgstr "Sujet :" msgid "Total: {0}" msgstr "Total : {0}" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Transport" +msgstr "Transport" + #: src/components/ui/UserSettings.tsx msgid "Trusted Sources" msgstr "Sources de confiance" @@ -2536,6 +2630,7 @@ msgstr "Profil de l'utilisateur" msgid "User Settings" msgstr "Paramètres utilisateur" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/InviteUserModal.tsx #: src/components/ui/ModerationModal.tsx msgid "Username" @@ -2683,6 +2778,10 @@ msgstr "Large – Portée de protection étendue" msgid "Will default to 'no reason' if left empty" msgstr "Par défaut « aucune raison » si laissé vide" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Yes, delete" +msgstr "Oui, supprimer" + #: src/components/message/CollapsedEventMessage.tsx #: src/components/message/EventMessage.tsx msgid "You" @@ -2713,6 +2812,10 @@ msgstr "Votre mot de passe de compte pour l'authentification" msgid "Your account username for authentication" msgstr "Votre nom d'utilisateur de compte pour l'authentification" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Your bouncer doesn't have any networks yet. Add one to get started." +msgstr "Votre bouncer n'a aucun réseau pour le moment. Ajoutez-en un pour commencer." + #: src/lib/settings/definitions/allSettings.ts msgid "Your default nickname for all servers" msgstr "Votre pseudo par défaut pour tous les serveurs" diff --git a/src/locales/it/messages.mjs b/src/locales/it/messages.mjs index d384afd2..f5a282f4 100644 --- a/src/locales/it/messages.mjs +++ b/src/locales/it/messages.mjs @@ -1 +1 @@ -/*eslint-disable*/export const messages=JSON.parse("{\"+5VMrz\":[\"Formato pattern non valido. Usa il formato nick!user@host (wildcards * consentiti)\"],\"+6NQQA\":[\"Canale di supporto generale\"],\"+6NyRG\":[\"Client\"],\"+K0AvT\":[\"Disconnetti\"],\"+cyFdH\":[\"Messaggio predefinito quando ci si segna come assenti\"],\"+mVPqU\":[\"Mostra la formattazione Markdown nei messaggi\"],\"+vqCJH\":[\"Il tuo nome utente account per l'autenticazione\"],\"+yPBXI\":[\"Scegli file\"],\"+zy2Nq\":[\"Tipo\"],\"/09cao\":[\"Sicurezza link bassa (Livello \",[\"securityLevel\"],\")\"],\"/3BQ4J\":[\"Gli utenti esterni non possono inviare messaggi\"],\"/6BzZF\":[\"Attiva/Disattiva lista membri\"],\"/TNOPk\":[\"L'utente è assente\"],\"/XQgft\":[\"Scopri\"],\"/cF7Rs\":[\"Volume\"],\"/dqduX\":[\"Pagina successiva\"],\"/fc3q4\":[\"Tutto il contenuto\"],\"/kISDh\":[\"Abilita suoni di notifica\"],\"/n04sB\":[\"Kill\"],\"/rTz0M\":[\"Audio\"],\"/rfkZe\":[\"Riproduci suoni per menzioni e messaggi\"],\"0/0ZGA\":[\"Maschera nome canale\"],\"0D6j7U\":[\"Scopri di più sulle regole personalizzate →\"],\"0XsHcR\":[\"Espelli utente\"],\"0ZpE//\":[\"Ordina per utenti\"],\"0bEPwz\":[\"Imposta assente\"],\"0dGkPt\":[\"Espandi lista canali\"],\"0gS7M5\":[\"Nome visualizzato\"],\"0kS+M8\":[\"EsempioRET\"],\"0rgoY7\":[\"Connettiti solo ai server che scegli\"],\"0wdd7X\":[\"Entra\"],\"0wkVYx\":[\"Messaggi privati\"],\"111uHX\":[\"Anteprima link\"],\"196EG4\":[\"Elimina chat privata\"],\"1DSr1i\":[\"Registra un account\"],\"1O/24y\":[\"Attiva/Disattiva lista canali\"],\"1VPJJ2\":[\"Avviso link esterno\"],\"1ZC/dv\":[\"Nessuna menzione o messaggio non letto\"],\"1pO1zi\":[\"Il nome del server è obbligatorio\"],\"1uwfzQ\":[\"Visualizza topic del canale\"],\"268g7c\":[\"Inserisci nome visualizzato\"],\"2FOFq1\":[\"Gli operatori del server sulla rete potrebbero leggere i tuoi messaggi\"],\"2FYpfJ\":[\"Altro\"],\"2HF1Y2\":[[\"inviter\"],\" ha invitato \",[\"target\"],\" a unirsi a \",[\"channel\"]],\"2I70QL\":[\"Visualizza informazioni profilo utente\"],\"2QYdmE\":[\"Utenti:\"],\"2QpEjG\":[\"è uscito\"],\"2YE223\":[\"Messaggio in #\",[\"0\"],\" (Invio per nuova riga, Shift+Invio per inviare)\"],\"2bimFY\":[\"Usa password del server\"],\"2iTmdZ\":[\"Archiviazione locale:\"],\"2odkwe\":[\"Rigoroso – Protezione più aggressiva\"],\"2uDhbA\":[\"Inserisci il nome utente da invitare\"],\"2ygf/L\":[\"← Indietro\"],\"2zEgxj\":[\"Cerca GIF...\"],\"3RdPhl\":[\"Rinomina canale\"],\"3THokf\":[\"Utente con diritto di parola\"],\"3TSz9S\":[\"Minimizza\"],\"3jBDvM\":[\"Nome visualizzato del canale\"],\"3ryuFU\":[\"Segnalazioni di crash opzionali per migliorare l'app\"],\"3uBF/8\":[\"Chiudi visualizzatore\"],\"3uwW8F\":[\"https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"3xf8Kz\":[\"Inserisci nome account...\"],\"4/Rr0R\":[\"Invita un utente nel canale corrente\"],\"4EZrJN\":[\"Regole\"],\"4JJtW9\":[\"#overflow\"],\"4NqeT4\":[\"Profilo flood (+F)\"],\"4RZQRK\":[\"Cosa stai facendo?\"],\"4hfTrB\":[\"Nickname\"],\"4n99LO\":[\"Già in \",[\"0\"]],\"4t6vMV\":[\"Passa automaticamente a riga singola per messaggi brevi\"],\"4vsHmf\":[\"Tempo (min)\"],\"5+INAX\":[\"Evidenzia i messaggi che ti menzionano\"],\"5R5Pv/\":[\"Nome oper\"],\"678PKt\":[\"Nome rete\"],\"6Aih4U\":[\"Non in linea\"],\"6CO3WE\":[\"Password richiesta per entrare. Lascia vuoto per rimuovere la chiave.\"],\"6HhMs3\":[\"Messaggio di uscita\"],\"6V3Ea3\":[\"Copiato\"],\"6lGV3K\":[\"Mostra meno\"],\"6yFOEi\":[\"Inserisci password oper...\"],\"7+IHTZ\":[\"Nessun file scelto\"],\"73hrRi\":[\"nick!user@host (es., spam*!*@*, *!*@badhost.com)\"],\"7QkKyN\":[\"Invia messaggio privato\"],\"7U1W7c\":[\"Molto rilassato\"],\"7Y1YQj\":[\"Nome reale:\"],\"7YHArF\":[\"— apri nel visualizzatore\"],\"7fjnVl\":[\"Cerca utenti...\"],\"7jL88x\":[\"Eliminare questo messaggio? Questa azione non può essere annullata.\"],\"7nGhhM\":[\"A cosa stai pensando?\"],\"7sEpu1\":[\"Membri — \",[\"0\"]],\"7sNhEz\":[\"Nome utente\"],\"8H0Q+x\":[\"Scopri di più sui profili →\"],\"8Phu0A\":[\"Mostra quando gli utenti cambiano soprannome\"],\"8XTG9e\":[\"Inserisci password oper\"],\"8XsV2J\":[\"Riprova invio\"],\"8ZsakT\":[\"Password\"],\"8kR84m\":[\"Stai per aprire un link esterno:\"],\"8lCgih\":[\"Rimuovi regola\"],\"8p/xVT\":[[\"0\",\"plural\",{\"one\":[[\"1\"]],\"other\":[[\"2\"]]}]],\"8wRzac\":[[\"joinCount\",\"plural\",{\"one\":[\"si è unito\"],\"other\":[\"si è unito \",[\"joinCount\"],\" volte\"]}]],\"9BMLnJ\":[\"Riconnetti al server\"],\"9OEgyT\":[\"Aggiungi reazione\"],\"9PQ8m2\":[\"G-Line (ban globale)\"],\"9Qs99X\":[\"Email:\"],\"9QupBP\":[\"Rimuovi pattern\"],\"9bG48P\":[\"Invio in corso\"],\"9f5f0u\":[\"Domande sulla privacy? Contattaci:\"],\"9unqs3\":[\"Assente:\"],\"9v3hwv\":[\"Nessun server trovato.\"],\"9zb2WA\":[\"Connessione in corso\"],\"A1taO8\":[\"Cerca\"],\"A2adVi\":[\"Invia notifiche di digitazione\"],\"A9Rhec\":[\"Nome canale\"],\"AWOSPo\":[\"Ingrandisci\"],\"AXSpEQ\":[\"Oper alla connessione\"],\"AeXO77\":[\"Account\"],\"AhNP40\":[\"Cerca posizione\"],\"Ai2U7L\":[\"Host\"],\"AjBQnf\":[\"Nickname cambiato\"],\"AmXVh6\":[\"https://example.com/avatar.png\"],\"AnRu/j\":[\"Annulla risposta\"],\"ApSx0O\":[\"Trovati \",[\"0\"],\" messaggi corrispondenti a \\\"\",[\"searchQuery\"],\"\\\"\"],\"AxPAXW\":[\"Nessun risultato trovato\"],\"AyNqAB\":[\"Mostra tutti gli eventi del server in chat\"],\"B/QqGw\":[\"Lontano dalla tastiera\"],\"B8AaMI\":[\"Questo campo è obbligatorio\"],\"BA2c49\":[\"Il server non supporta il filtro LIST avanzato\"],\"BDKt3I\":[[\"0\"],\", \",[\"1\"],\", \",[\"2\"],\" e altri \",[\"3\"],\" stanno scrivendo...\"],\"BGul2A\":[\"Hai modifiche non salvate. Sei sicuro di voler chiudere senza salvare?\"],\"BIf9fi\":[\"Il tuo messaggio di stato\"],\"BZz3md\":[\"Il tuo sito web personale\"],\"Bgm/H7\":[\"Consenti l'inserimento di più righe di testo\"],\"BiQIl1\":[\"Fissa questa conversazione privata\"],\"BlNZZ2\":[\"Clicca per andare al messaggio\"],\"Bowq3c\":[\"Solo gli operatori possono cambiare l'argomento\"],\"Btozzp\":[\"Questa immagine è scaduta\"],\"Bycfjm\":[\"Totale: \",[\"0\"]],\"C6IBQc\":[\"Copia JSON completo\"],\"C9L9wL\":[\"Raccolta dati\"],\"CDq4wC\":[\"Modera utente\"],\"CHVRxG\":[\"Messaggio a @\",[\"0\"],\" (Shift+Invio per nuova riga)\"],\"CN9zdR\":[\"Nome oper e password sono obbligatori\"],\"CW3sYa\":[\"Aggiungi reazione \",[\"emoji\"]],\"CaAkqd\":[\"Mostra disconnessioni\"],\"CbvaYj\":[\"Banna per nickname\"],\"CcK+Ft\":[\"PDF\"],\"Ce8q3L\":[\"Seleziona un canale\"],\"CsekCi\":[\"Normale\"],\"D+NlUC\":[\"Sistema\"],\"D28t6+\":[\"è entrato e uscito\"],\"DB8zMK\":[\"Applica\"],\"DBcWHr\":[\"File audio di notifica personalizzato\"],\"DTy9Xw\":[\"Anteprime multimediali\"],\"Dj4pSr\":[\"Scegli una password sicura\"],\"Du+zn+\":[\"Ricerca...\"],\"Du2T2f\":[\"Impostazione non trovata\"],\"DwsSVQ\":[\"Applica filtri e aggiorna\"],\"E3W/zd\":[\"Soprannome predefinito\"],\"E6nRW7\":[\"Copia URL\"],\"E703RG\":[\"Modi:\"],\"EAeu1Z\":[\"Invia invito\"],\"EFKJQT\":[\"Impostazione\"],\"EGPQBv\":[\"Regole flood personalizzate (+f)\"],\"ELik0r\":[\"Vedi l'informativa completa sulla privacy\"],\"EPbeC2\":[\"Visualizza o modifica il topic del canale\"],\"EQCDNT\":[\"Inserisci nome utente oper...\"],\"EUvulZ\":[\"Trovato 1 messaggio corrispondente a \\\"\",[\"searchQuery\"],\"\\\"\"],\"EatZYJ\":[\"Immagine successiva\"],\"EdQY6l\":[\"Nessuno\"],\"EnqLYU\":[\"Cerca server...\"],\"F0OKMc\":[\"Modifica server\"],\"F6Int2\":[\"Abilita evidenziazioni\"],\"FDoLyE\":[\"Utenti max.\"],\"FUU/hZ\":[\"Controlla quanti media esterni vengono caricati nella chat.\"],\"Fdp03t\":[\"attivo\"],\"FfPWR0\":[\"Finestra\"],\"FjkaiT\":[\"Riduci\"],\"FlqOE9\":[\"Cosa significa:\"],\"FolHNl\":[\"Gestisci il tuo account e l'autenticazione\"],\"Fp2Dif\":[\"Uscito dal server\"],\"G5KmCc\":[\"GZ-Line (Z-Line globale)\"],\"GDs0lz\":[\"<0>Rischio: Informazioni sensibili (messaggi, conversazioni private, dati di autenticazione) potrebbero essere esposte ad amministratori di rete o attaccanti posizionati tra i server IRC.\"],\"GR+2I3\":[\"Aggiungi maschera di invito (es. nick!*@*, *!*@host.com)\"],\"GRLyMU\":[\"Chiudi avvisi server in finestra separata\"],\"GlHnXw\":[\"Cambio nick fallito: \",[\"error\"],\" \",[\"0\"]],\"GswZF3\":[\"Anteprima:\"],\"GtmO8/\":[\"da\"],\"GtuHUQ\":[\"Rinomina questo canale sul server. Tutti gli utenti vedranno il nuovo nome.\"],\"GuGfFX\":[\"Attiva/Disattiva ricerca\"],\"GxkJXS\":[\"Caricamento...\"],\"GzbwnK\":[\"È entrato nel canale\"],\"GzsUDB\":[\"Profilo esteso\"],\"H/PnT8\":[\"Inserisci emoji\"],\"H6Izzl\":[\"Il tuo codice colore preferito\"],\"H9jIv+\":[\"Mostra entrate/uscite\"],\"HAKBY9\":[\"Carica file\"],\"HdE1If\":[\"Canale\"],\"Hk4AW9\":[\"Il tuo nome visualizzato preferito\"],\"HmHDk7\":[\"Seleziona membro\"],\"HrQzPU\":[\"Canali su \",[\"networkName\"]],\"I2tXQ5\":[\"Messaggio a @\",[\"0\"],\" (Invio per nuova riga, Shift+Invio per inviare)\"],\"I6bw/h\":[\"Banna utente\"],\"I92Z+b\":[\"Abilita notifiche\"],\"I9D72S\":[\"Sei sicuro di voler eliminare questo messaggio? Questa azione non può essere annullata.\"],\"IA+1wo\":[\"Mostra quando gli utenti vengono espulsi dai canali\"],\"IDwkJx\":[\"IRC Operator\"],\"ILlU+s\":[\"Info:\"],\"IUwGEM\":[\"Salva modifiche\"],\"IVeGK6\":[[\"0\"],\", \",[\"1\"],\" e \",[\"2\"],\" stanno scrivendo...\"],\"IgrLD/\":[\"Pausa\"],\"Im6JED\":[\"WHISPER\"],\"ImOQa9\":[\"Rispondi\"],\"IoHMnl\":[\"Il valore massimo è \",[\"0\"]],\"IvMj+0\":[\"Op\"],\"J28zul\":[\"Connessione...\"],\"J5T9NW\":[\"Informazioni utente\"],\"J8Y5+z\":[\"Ops! La rete si è divisa! ⚠️\"],\"JBHkBA\":[\"Ha lasciato il canale\"],\"JCwL0Q\":[\"Inserisci motivo (opzionale)\"],\"JFciKP\":[\"Attiva/Disattiva\"],\"JXGkhG\":[\"Cambia il nome del canale (solo operatori)\"],\"JcD7qf\":[\"Altre azioni\"],\"JdkA+c\":[\"Segreto (+s)\"],\"Jmu12l\":[\"Canali del server\"],\"JvQ++s\":[\"Abilita Markdown\"],\"K2jwh/\":[\"Nessun dato WHOIS disponibile\"],\"KAXSwC\":[\"Voice\"],\"KDfTdX\":[\"Elimina messaggio\"],\"KKBlUU\":[\"Incorpora\"],\"KM0pLb\":[\"Benvenuto nel canale!\"],\"KR6W2h\":[\"Smetti di ignorare utente\"],\"KV+Bi1\":[\"Solo su invito (+i)\"],\"KdCtwE\":[\"Quanti secondi monitorare l'attività flood prima di reimpostare i contatori\"],\"Kkezga\":[\"Password del server\"],\"KsiQ/8\":[\"Gli utenti devono essere invitati per entrare\"],\"L+gB/D\":[\"Informazioni sul canale\"],\"LC1a7n\":[\"Il server IRC ha segnalato che i suoi collegamenti tra server hanno un basso livello di sicurezza. Ciò significa che quando i tuoi messaggi vengono instradati tra i server IRC nella rete, potrebbero non essere correttamente cifrati o i certificati SSL/TLS potrebbero non essere validati correttamente.\"],\"LNfLR5\":[\"Mostra espulsioni\"],\"LQb0W/\":[\"Mostra tutti gli eventi\"],\"LU7/yA\":[\"Nome alternativo per la visualizzazione. Può contenere spazi, emoji e caratteri speciali. Il nome reale (\",[\"channelName\"],\") verrà comunque usato per i comandi IRC.\"],\"LUb9O7\":[\"È richiesta una porta del server valida\"],\"Lb+BUl\":[\"https://example.com/avatar.jpg\"],\"LcET2C\":[\"Informativa sulla privacy\"],\"LcuSDR\":[\"Gestisci le informazioni del tuo profilo e i metadati\"],\"LqLS9B\":[\"Mostra cambi di soprannome\"],\"LsDQt2\":[\"Impostazioni canale\"],\"LtI9AS\":[\"Proprietario\"],\"LuNhhL\":[\"ha reagito a questo messaggio\"],\"M/AZNG\":[\"URL dell'immagine del tuo avatar\"],\"M/WIer\":[\"Invia messaggio\"],\"M8er/5\":[\"Nome:\"],\"MHk+7g\":[\"Immagine precedente\"],\"MRorGe\":[\"Messaggio privato\"],\"MVbSGP\":[\"Finestra temporale (secondi)\"],\"MkpcsT\":[\"I tuoi messaggi e impostazioni sono archiviati localmente sul tuo dispositivo\"],\"N/hDSy\":[\"Segna come bot, di solito 'on' o vuoto\"],\"N7TQbE\":[\"Invita utente in \",[\"channelName\"]],\"NCca/o\":[\"Inserisci nickname predefinito...\"],\"Nqs6B9\":[\"Mostra tutti i media esterni. Qualsiasi URL potrebbe causare una richiesta a un server sconosciuto.\"],\"Nt+9O7\":[\"Usa WebSocket invece di TCP grezzo\"],\"NxIHzc\":[\"Espelli utente\"],\"O+v/cL\":[\"Sfoglia tutti i canali del server\"],\"ODwSCk\":[\"Invia un GIF\"],\"OGQ5kK\":[\"Configura suoni di notifica ed evidenziazioni\"],\"OIPt1Z\":[\"Mostra o nascondi la barra laterale dei membri\"],\"OKSNq/\":[\"Molto rigido\"],\"ONWvwQ\":[\"Carica\"],\"OVKoQO\":[\"La tua password account per l'autenticazione\"],\"ObsidianIRC - Bringing IRC to the future\":[\"ObsidianIRC - Portare IRC nel futuro\"],\"OhCpra\":[\"Imposta un topic…\"],\"OkltoQ\":[\"Banna \",[\"username\"],\" per nickname (impedisce di rientrare con lo stesso nick)\"],\"P+t/Te\":[\"Nessun dato aggiuntivo\"],\"P42Wcc\":[\"Sicuro\"],\"PD38l0\":[\"Anteprima avatar canale\"],\"PD9mEt\":[\"Scrivi un messaggio...\"],\"PPqfdA\":[\"Apri impostazioni configurazione canale\"],\"PSCjfZ\":[\"L'argomento visualizzato per questo canale. Tutti gli utenti possono vederlo.\"],\"PZCecv\":[\"Anteprima PDF\"],\"PeLgsC\":[[\"c\",\"plural\",{\"one\":[\"1 volta\"],\"other\":[[\"c\"],\" volte\"]}]],\"PguS2C\":[\"Aggiungi maschera di eccezione (es. nick!*@*, *!*@host.com)\"],\"Pil5Ty\":[\"Visualizzazione di \",[\"displayedChannelsCount\"],\" su \",[\"0\"],\" canali\"],\"PqhVlJ\":[\"Banna utente (per hostmask)\"],\"Q+chwU\":[\"Nome utente:\"],\"Q6hhn8\":[\"Preferenze\"],\"QF4a34\":[\"Inserisci un nome utente\"],\"QGqSZ2\":[\"Colore e formattazione\"],\"QJQd1J\":[\"Modifica profilo\"],\"QSzGDE\":[\"Inattivo\"],\"QUlny5\":[\"Benvenuto su \",[\"0\"],\"!\"],\"Qoq+GP\":[\"Leggi di più\"],\"QuSkCF\":[\"Filtra canali...\"],\"QwUrDZ\":[\"ha cambiato il topic in: \",[\"topic\"]],\"R0UH07\":[\"Immagine \",[\"0\"],\" di \",[\"1\"]],\"R7SsBE\":[\"Disattiva audio\"],\"R8rf1X\":[\"Clicca per impostare il topic\"],\"RArB3D\":[\"è stato espulso da \",[\"channelName\"],\" da \",[\"username\"]],\"RI3cWd\":[\"Scopri il mondo di IRC con ObsidianIRC\"],\"RMMaN5\":[\"Moderato (+m)\"],\"RWw9Lg\":[\"Chiudi finestra\"],\"RZ2BuZ\":[\"La registrazione dell'account \",[\"account\"],\" richiede verifica: \",[\"message\"]],\"RySp6q\":[\"Nascondi commenti\"],\"SPKQTd\":[\"Il nickname è obbligatorio\"],\"SPVjfj\":[\"Il valore predefinito sarà 'nessun motivo' se lasciato vuoto\"],\"SQKPvQ\":[\"Invita utente\"],\"SkZcl+\":[\"Scegli un profilo di protezione flood predefinito. Questi profili forniscono impostazioni di protezione bilanciate per diversi casi d'uso.\"],\"Slr+3C\":[\"Utenti min.\"],\"Spnlre\":[\"Hai invitato \",[\"target\"],\" a unirsi a \",[\"channel\"]],\"T/ckN5\":[\"Apri nel visualizzatore\"],\"T91vKp\":[\"Riproduci\"],\"TV2Wdu\":[\"Scopri come gestiamo i tuoi dati e proteggiamo la tua privacy.\"],\"TgFpwD\":[\"Applicazione...\"],\"TkzSFB\":[\"Nessuna modifica\"],\"TtserG\":[\"Inserisci nome reale\"],\"Ttz9J1\":[\"Inserisci password...\"],\"Tz0i8g\":[\"Impostazioni\"],\"U3pytU\":[\"Admin\"],\"UDb2YD\":[\"Reagisci\"],\"UE4KO5\":[\"*channel*\"],\"UGT5vp\":[\"Salva impostazioni\"],\"UV5hLB\":[\"Nessun ban trovato\"],\"Uaj3Nd\":[\"Messaggi di stato\"],\"Ue3uny\":[\"Predefinito (nessun profilo)\"],\"UkARhe\":[\"Normale – Protezione standard\"],\"Umn7Cj\":[\"Ancora nessun commento. Sii il primo!\"],\"UtUIRh\":[[\"0\"],\" messaggi precedenti\"],\"UwzP+U\":[\"Connessione sicura\"],\"V0/A4O\":[\"Proprietario del canale\"],\"V4qgxE\":[\"Creato prima (min fa)\"],\"V8yTm6\":[\"Cancella ricerca\"],\"VJMMyz\":[\"ObsidianIRC - Portare IRC nel futuro\"],\"VJScHU\":[\"Motivo\"],\"VLsmVV\":[\"Silenzia notifiche\"],\"VbyRUy\":[\"Commenti\"],\"Vmx0mQ\":[\"Impostato da:\"],\"VqnIZz\":[\"Visualizza la nostra informativa sulla privacy e le pratiche sui dati\"],\"VrMygG\":[\"La lunghezza minima è \",[\"0\"]],\"VrnTui\":[\"I tuoi pronomi, mostrati nel profilo\"],\"W8E3qn\":[\"Account autenticato\"],\"WAakm9\":[\"Elimina canale\"],\"WFxTHC\":[\"Aggiungi maschera di ban (es. nick!*@*, *!*@host.com)\"],\"WN1g9F\":[\"L'host del server è obbligatorio\"],\"WRYdXW\":[\"Posizione audio\"],\"WUOH5B\":[\"Ignora utente\"],\"WWEXnZ\":[[\"0\",\"plural\",{\"one\":[\"Mostra 1 altro elemento\"],\"other\":[\"Mostra \",[\"1\"],\" altri elementi\"]}]],\"Weq9zb\":[\"Generale\"],\"Wfj7Sk\":[\"Attiva o disattiva i suoni delle notifiche\"],\"Wm7gbG\":[\"GitHub:\"],\"WyeHWY\":[\"*spam*\"],\"WzMCru\":[\"Profilo utente\"],\"X6S3lt\":[\"Cerca impostazioni, canali, server...\"],\"XEHan5\":[\"Continua comunque\"],\"XI1+wb\":[\"Formato non valido\"],\"XIXeuC\":[\"Messaggio a @\",[\"0\"]],\"XMS+k4\":[\"Avvia messaggio privato\"],\"XWgxXq\":[\"Album\"],\"Xd7+IT\":[\"Rimuovi fissaggio chat privata\"],\"Xm/s+u\":[\"Visualizzazione\"],\"Xp2n93\":[\"Mostra media dall'host di file attendibile del tuo server. Nessuna richiesta viene inviata a servizi esterni.\"],\"XvjC4F\":[\"Salvataggio...\"],\"Y/qryO\":[\"Nessun utente trovato corrispondente alla ricerca\"],\"YAqRpI\":[\"Registrazione dell'account \",[\"account\"],\" riuscita: \",[\"message\"]],\"YEfzvP\":[\"Argomento protetto (+t)\"],\"YQOn6a\":[\"Comprimi lista membri\"],\"YRCoE9\":[\"Operatore del canale\"],\"YURQaF\":[\"Vedi profilo\"],\"YdBSvr\":[\"Controlla la visualizzazione dei media e dei contenuti esterni\"],\"Yj6U3V\":[\"Nessun server centrale:\"],\"YjvpGx\":[\"Pronomi\"],\"YqH4l4\":[\"Nessuna chiave\"],\"YyUPpV\":[\"Account:\"],\"ZJSWfw\":[\"Messaggio mostrato alla disconnessione dal server\"],\"ZR1dJ4\":[\"Inviti\"],\"ZdWg0V\":[\"Apri nel browser\"],\"ZhRBbl\":[\"Cerca messaggi…\"],\"Zmcu3y\":[\"Filtri avanzati\"],\"a2/8e5\":[\"Argomento impostato dopo (min fa)\"],\"aHKcKc\":[\"Pagina precedente\"],\"aJTbXX\":[\"Password oper\"],\"aQryQv\":[\"Il pattern esiste già\"],\"aW9pLN\":[\"Numero massimo di utenti nel canale. Lascia vuoto per nessun limite.\"],\"ah4fmZ\":[\"Mostra anche anteprime da YouTube, Vimeo, SoundCloud e servizi noti simili.\"],\"aifXak\":[\"Nessun media in questo canale\"],\"ap2zBz\":[\"Rilassato\"],\"az8lvo\":[\"Disattivato\"],\"azXSNo\":[\"Espandi lista membri\"],\"azdliB\":[\"Accedi a un account\"],\"b26wlF\":[\"lei/la\"],\"bD/+Ei\":[\"Rigido\"],\"bQ6BJn\":[\"Configura regole dettagliate di protezione flood. Ogni regola specifica il tipo di attività da monitorare e l'azione da intraprendere quando le soglie vengono superate.\"],\"beV7+y\":[\"L'utente riceverà un invito per unirsi a \",[\"channelName\"],\".\"],\"bk84cH\":[\"Messaggio di assenza\"],\"bkHdLj\":[\"Aggiungi server IRC\"],\"bmQLn5\":[\"Aggiungi regola\"],\"bwRvnp\":[\"Azione\"],\"c8+EVZ\":[\"Account verificato\"],\"cGYUlD\":[\"Nessuna anteprima media caricata.\"],\"cLF98o\":[\"Mostra commenti (\",[\"commentCount\"],\")\"],\"cLKIDO\":[\"Nessun utente disponibile\"],\"cSgpoS\":[\"Fissa chat privata\"],\"cde3ce\":[\"Messaggio a <0>\",[\"0\"],\"\"],\"chQsxg\":[\"Copia output formattato\"],\"cl/A5J\":[\"Benvenuto su \",[\"__DEFAULT_IRC_SERVER_NAME__\"],\"!\"],\"cnGeoo\":[\"Elimina\"],\"coPLXT\":[\"Non archiviamo le tue comunicazioni IRC sui nostri server\"],\"crYH/6\":[\"Player SoundCloud\"],\"d3sis4\":[\"Aggiungi server\"],\"d9aN5k\":[\"Rimuovi \",[\"username\"],\" dal canale\"],\"dEgA5A\":[\"Annulla\"],\"dGi1We\":[\"Rimuovi il fissaggio di questa conversazione privata\"],\"dJVuyC\":[\"ha lasciato \",[\"channelName\"],\" (\",[\"reason\"],\")\"],\"dMtLDE\":[\"a\"],\"dXqxlh\":[\"<0>⚠️ Rischio di sicurezza! Questa connessione potrebbe essere vulnerabile a intercettazioni o attacchi man-in-the-middle.\"],\"da9Q/R\":[\"Modalità canale modificate\"],\"dhJN3N\":[\"Mostra commenti\"],\"dj2xTE\":[\"Ignora notifica\"],\"dpCzmC\":[\"Impostazioni protezione flood\"],\"e9dQpT\":[\"Vuoi aprire questo link in una nuova scheda?\"],\"ePK91l\":[\"Modifica\"],\"eYBDuB\":[\"Carica un'immagine o fornisci un URL con sostituzione opzionale \",[\"size\"]],\"edBbee\":[\"Banna \",[\"username\"],\" per hostmask (impedisce di rientrare dallo stesso IP/host)\"],\"ekfzWq\":[\"Impostazioni utente\"],\"elPDWs\":[\"Personalizza la tua esperienza con il client IRC\"],\"eu2osY\":[\"<0>💡 Raccomandazione: Procedi solo se ti fidi di questo server e comprendi i rischi. Evita di condividere informazioni sensibili o password su questa connessione.\"],\"euEhbr\":[\"Clicca per unirti a \",[\"channel\"]],\"ez3vLd\":[\"Abilita input multiriga\"],\"f0J5Ki\":[\"Le comunicazioni tra server potrebbero usare connessioni non cifrate\"],\"f9BHJk\":[\"Avvisa utente\"],\"fDOLLd\":[\"Nessun canale trovato.\"],\"ffzDkB\":[\"Analisi anonime:\"],\"fq1GF9\":[\"Mostra quando gli utenti si disconnettono dal server\"],\"gEF57C\":[\"Questo server supporta solo un tipo di connessione\"],\"gJuLUI\":[\"Lista di ignorati\"],\"gNzMrk\":[\"Avatar attuale\"],\"gjPWyO\":[\"Inserisci nickname...\"],\"gz6UQ3\":[\"Massimizza\"],\"h6razj\":[\"Escludi maschera nome canale\"],\"hG6jnw\":[\"Nessun topic impostato\"],\"hG89Ed\":[\"Immagine\"],\"hZ6znB\":[\"Porta\"],\"ha+Bz5\":[\"es., 100:1440\"],\"hehnjM\":[\"Quantità\"],\"hzdLuQ\":[\"Solo gli utenti con voice o superiore possono parlare\"],\"i0qMbr\":[\"Home\"],\"iDNBZe\":[\"Notifiche\"],\"iH8pgl\":[\"Indietro\"],\"iL9SZg\":[\"Banna utente (per nickname)\"],\"iNt+3c\":[\"Torna all'immagine\"],\"iQvi+a\":[\"Non avvisarmi sulla bassa sicurezza del link per questo server\"],\"iSLIjg\":[\"Connetti\"],\"iWXkHH\":[\"Halfop\"],\"iZeTtp\":[\"Host del server\"],\"idD8Ev\":[\"Salvato\"],\"iivqkW\":[\"Connesso dal\"],\"ij+Elv\":[\"Anteprima immagine\"],\"ilIWp7\":[\"Attiva/Disattiva notifiche\"],\"iuaqvB\":[\"Usa * come wildcard. Esempi: baduser!*@*, *!*@spammer.com, troll*!*@*\"],\"ixkTse\":[\"Bot\"],\"j2DGR0\":[\"Banna per maschera host\"],\"jA4uoI\":[\"Argomento:\"],\"jLXxGK\":[\"https://example.com\"],\"jPSk57\":[\"Motivo (opzionale)\"],\"jUV7CU\":[\"Carica avatar\"],\"jW5Uwh\":[\"Controlla quanti media esterni vengono caricati. Disattivato / Sicuro / Fonti affidabili / Tutto il contenuto.\"],\"jXzms5\":[\"Opzioni allegato\"],\"jZlrte\":[\"Colore\"],\"jfC/xh\":[\"Contatti\"],\"jywMpv\":[\"#new-channel-name\"],\"k112DD\":[\"Carica messaggi precedenti\"],\"k3ID0F\":[\"Filtra membri…\"],\"k65gsE\":[\"Analisi approfondita\"],\"k7Zgob\":[\"Annulla connessione\"],\"kAVx5h\":[\"Nessun invito trovato\"],\"kCLEPU\":[\"Connesso a\"],\"kF5LKb\":[\"Pattern ignorati:\"],\"kGeOx/\":[\"Unisciti a \",[\"0\"]],\"kITKr8\":[\"Caricamento modalità canale...\"],\"kPpPsw\":[\"Sei un IRC Operator\"],\"kWJmRL\":[\"Tu\"],\"kfcRb0\":[\"Avatar\"],\"kjMqSj\":[\"Copia JSON\"],\"krViRy\":[\"Clicca per copiare come JSON\"],\"ks71ra\":[\"Eccezioni\"],\"kw4lRv\":[\"Semi-operatore del canale\"],\"kxgIRq\":[\"Seleziona o aggiungi un canale per iniziare.\"],\"ky6dWe\":[\"Anteprima avatar\"],\"l+GxCv\":[\"Caricamento canali...\"],\"l+IUVW\":[\"Verifica dell'account \",[\"account\"],\" riuscita: \",[\"message\"]],\"l/siQz\":[[\"reconnectCount\",\"plural\",{\"one\":[\"si è riconnesso\"],\"other\":[\"si è riconnesso \",[\"reconnectCount\"],\" volte\"]}]],\"l5jmzx\":[[\"0\"],\" e \",[\"1\"],\" stanno scrivendo...\"],\"lHy8N5\":[\"Caricamento altri canali...\"],\"lbpf14\":[\"Entra in \",[\"value\"]],\"lfFsZ4\":[\"Canali\"],\"lkNdiH\":[\"Nome account\"],\"ln500L\":[\"ObsidianIRC\"],\"lnCMdg\":[\"Carica immagine\"],\"loQxaJ\":[\"Sono tornato\"],\"lvfaxv\":[\"HOME\"],\"m16xKo\":[\"Aggiungi\"],\"m8flAk\":[\"Anteprima (non ancora caricata)\"],\"mEPxTp\":[\"<0>⚠️ Attenzione! Apri solo link da fonti attendibili. I link malevoli possono compromettere la tua sicurezza o privacy.\"],\"mHGdhG\":[\"Informazioni sul server\"],\"mHS8lb\":[\"Messaggio in #\",[\"0\"]],\"mMYBD9\":[\"Ampio – Portata di protezione estesa\"],\"mTGsPd\":[\"Argomento del canale\"],\"mU8j6O\":[\"Nessun messaggio esterno (+n)\"],\"mZp8FL\":[\"Ritorno automatico alla singola riga\"],\"mdQu8G\":[\"IlTuoNickname\"],\"miSSBQ\":[\"Commenti (\",[\"commentCount\"],\")\"],\"mvyLSy\":[\"Utente autenticato\"],\"mwtcGl\":[\"Chiudi commenti\"],\"mzI/c+\":[\"Scarica\"],\"n3fGRk\":[\"impostato da \",[\"0\"]],\"nE9jsU\":[\"Rilassato – Protezione meno aggressiva\"],\"nNflMD\":[\"Abbandona canale\"],\"nPXkBi\":[\"Caricamento dati WHOIS...\"],\"nQnxxF\":[\"Messaggio in #\",[\"0\"],\" (Shift+Invio per nuova riga)\"],\"nWMRxa\":[\"Rimuovi fissaggio\"],\"nkC032\":[\"Nessun profilo flood\"],\"o69z4d\":[\"Invia un messaggio di avviso a \",[\"username\"]],\"o9ylQi\":[\"Cerca GIF per iniziare\"],\"oFGkER\":[\"Avvisi del server\"],\"oOi11l\":[\"Vai in fondo\"],\"oQEzQR\":[\"Nuovo messaggio privato\"],\"oXOSPE\":[\"In linea\"],\"oal760\":[\"Sono possibili attacchi man-in-the-middle sui link del server\"],\"oeqmmJ\":[\"Fonti attendibili\"],\"ovBPCi\":[\"Predefinito\"],\"p0Z69r\":[\"Il pattern non può essere vuoto\"],\"p1KgtK\":[\"Caricamento audio non riuscito\"],\"p59pEv\":[\"Dettagli aggiuntivi\"],\"p7sRI6\":[\"Avvisa gli altri quando stai scrivendo\"],\"pBm1od\":[\"Canale segreto\"],\"pNmiXx\":[\"Il tuo soprannome predefinito per tutti i server\"],\"pUUo9G\":[\"Hostname:\"],\"pVGPmz\":[\"Password account\"],\"peNE68\":[\"Permanente\"],\"plhHQt\":[\"Nessun dato\"],\"pm6+q5\":[\"Avviso di sicurezza\"],\"pn5qSs\":[\"Informazioni aggiuntive\"],\"q0cR4S\":[\"ora è conosciuto come **\",[\"newNick\"],\"**\"],\"qFcunY\":[\"Il canale non apparirà nei comandi LIST o NAMES\"],\"qLpTm/\":[\"Rimuovi reazione \",[\"emoji\"]],\"qVkGWK\":[\"Fissa\"],\"qY8wNa\":[\"Homepage\"],\"qb0xJ7\":[\"Wildcard: * corrisponde a qualsiasi sequenza, ? a un singolo carattere. Esempi: nick!*@*, *!*@host.com, *!*user@*\"],\"qhzpRq\":[\"Chiave canale (+k)\"],\"qtoOYG\":[\"Nessun limite\"],\"r1W2AS\":[\"Immagine dal filehost\"],\"rIPR2O\":[\"Argomento impostato prima (min fa)\"],\"rMMSYo\":[\"La lunghezza massima è \",[\"0\"]],\"rWtzQe\":[\"La rete si è divisa e riconnessa. ✅\"],\"rYG2u6\":[\"Attendere...\"],\"rdUucN\":[\"Anteprima\"],\"rjGI/Q\":[\"Privacy\"],\"rk8iDX\":[\"Caricamento GIF...\"],\"rn6SBY\":[\"Attiva audio\"],\"s/UKqq\":[\"È stato espulso dal canale\"],\"s8cATI\":[\"si è unito a \",[\"channelName\"]],\"sCO9ue\":[\"La connessione a <0>\",[\"serverName\"],\" presenta le seguenti problematiche di sicurezza:\"],\"sGH11W\":[\"Server\"],\"sHI1H+\":[\"ora è conosciuto come **\",[\"newNick\"],\"**\"],\"sJyV04\":[[\"inviter\"],\" ti ha invitato a unirti a \",[\"channel\"]],\"sby+1/\":[\"Clicca per copiare\"],\"sfN25C\":[\"Il tuo nome reale o completo\"],\"sliuzR\":[\"Apri link\"],\"sqrO9R\":[\"Menzioni personalizzate\"],\"sr6RdJ\":[\"Multiriga con Shift+Invio\"],\"swrCpB\":[\"Il canale è stato rinominato da \",[\"oldName\"],\" a \",[\"newName\"],\" da \",[\"user\"],[\"0\"]],\"sxkWRg\":[\"Avanzato\"],\"t/YqKh\":[\"Rimuovi\"],\"t47eHD\":[\"Il tuo identificatore unico su questo server\"],\"tAkAh0\":[\"URL con sostituzione opzionale \",[\"size\"],\". Esempio: https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"tXLJS3\":[\"Mostra o nascondi la barra laterale dei canali\"],\"tfDRzk\":[\"Salva\"],\"tiBsJk\":[\"ha lasciato \",[\"channelName\"]],\"tt4/UD\":[\"ha abbandonato (\",[\"reason\"],\")\"],\"u0TcnO\":[\"Il nick {nick} è già in uso, nuovo tentativo con {newNick}\"],\"u0a8B4\":[\"Autenticarsi come operatore IRC per l'accesso amministrativo\"],\"u0rWFU\":[\"Creato dopo (min fa)\"],\"u72w3t\":[\"Utenti e modelli da ignorare\"],\"u7jc2L\":[\"ha abbandonato\"],\"uAQUqI\":[\"Stato\"],\"uB85T3\":[\"Salvataggio fallito: \",[\"msg\"]],\"uV3DOL\":[\"G-Line\"],\"uW3lLI\":[\"Server IRC:\"],\"usSSr/\":[\"Livello zoom\"],\"v7uvcf\":[\"Software:\"],\"vE8kb+\":[\"Shift+Invio per le nuove righe (Invio invia)\"],\"vERlcd\":[\"Profilo\"],\"vK0RL8\":[\"Nessun argomento\"],\"vSJd18\":[\"Video\"],\"vXIe7J\":[\"Lingua\"],\"vaHYxN\":[\"Nome reale\"],\"vhjbKr\":[\"Assente\"],\"w4NYox\":[\"client \",[\"title\"]],\"w8xQRx\":[\"Valore non valido\"],\"wFjjxZ\":[\"è stato espulso da \",[\"channelName\"],\" da \",[\"username\"],\" (\",[\"reason\"],\")\"],\"wGjaGl\":[\"Nessuna eccezione di ban trovata\"],\"wPrGnM\":[\"Amministratore del canale\"],\"wRkP2d\":[\"GIF\"],\"wbm86v\":[\"Mostra quando gli utenti entrano o escono dai canali\"],\"whqZ9r\":[\"Parole o frasi aggiuntive da evidenziare\"],\"wm7RV4\":[\"Suono di notifica\"],\"wz/Yoq\":[\"I tuoi messaggi potrebbero essere intercettati durante l'instradamento tra server\"],\"xCJdfg\":[\"Cancella\"],\"xUHRTR\":[\"Autentica automaticamente come operatore alla connessione\"],\"xWHwwQ\":[\"Ban\"],\"xYilR2\":[\"Media\"],\"xceQrO\":[\"Sono supportati solo websocket sicuri\"],\"xdtXa+\":[\"nome-canale\"],\"xfXC7q\":[\"Canali testuali\"],\"xlCYOE\":[\"Caricamento messaggi...\"],\"xlhswE\":[\"Il valore minimo è \",[\"0\"]],\"xq97Ci\":[\"Aggiungi una parola o frase...\"],\"xuRqRq\":[\"Limite client (+l)\"],\"xwF+7J\":[[\"0\"],\" sta scrivendo...\"],\"yNeucF\":[\"Questo server non supporta i metadati del profilo esteso (estensione IRCv3 METADATA). Campi come avatar, nome visualizzato e stato non sono disponibili.\"],\"yPlrca\":[\"Avatar del canale\"],\"yQE2r9\":[\"Caricamento\"],\"ySU+JY\":[\"tuo@email.com\"],\"yTX1Rt\":[\"Nome utente operatore\"],\"yYOzWD\":[\"log\"],\"yfx9Re\":[\"Password operatore IRC\"],\"ygCKqB\":[\"Stop\"],\"ymDxJx\":[\"Nome utente operatore IRC\"],\"yrpRsQ\":[\"Ordina per nome\"],\"yz7wBu\":[\"Chiudi\"],\"zJw+jA\":[\"imposta modalità: \",[\"0\"]],\"zebeLu\":[\"Inserisci nome utente oper\"],\"zpr0Bw\":[\"GZ-Line\"]}"); \ No newline at end of file +/*eslint-disable*/export const messages=JSON.parse("{\"+5VMrz\":[\"Formato pattern non valido. Usa il formato nick!user@host (wildcards * consentiti)\"],\"+6NQQA\":[\"Canale di supporto generale\"],\"+6NyRG\":[\"Client\"],\"+K0AvT\":[\"Disconnetti\"],\"+cyFdH\":[\"Messaggio predefinito quando ci si segna come assenti\"],\"+mVPqU\":[\"Mostra la formattazione Markdown nei messaggi\"],\"+vqCJH\":[\"Il tuo nome utente account per l'autenticazione\"],\"+yPBXI\":[\"Scegli file\"],\"+zy2Nq\":[\"Tipo\"],\"/09cao\":[\"Sicurezza link bassa (Livello \",[\"securityLevel\"],\")\"],\"/3BQ4J\":[\"Gli utenti esterni non possono inviare messaggi\"],\"/6BzZF\":[\"Attiva/Disattiva lista membri\"],\"/TNOPk\":[\"L'utente è assente\"],\"/XQgft\":[\"Scopri\"],\"/cF7Rs\":[\"Volume\"],\"/dqduX\":[\"Pagina successiva\"],\"/fc3q4\":[\"Tutto il contenuto\"],\"/kISDh\":[\"Abilita suoni di notifica\"],\"/n04sB\":[\"Kill\"],\"/rTz0M\":[\"Audio\"],\"/rfkZe\":[\"Riproduci suoni per menzioni e messaggi\"],\"0/0ZGA\":[\"Maschera nome canale\"],\"0D6j7U\":[\"Scopri di più sulle regole personalizzate →\"],\"0XsHcR\":[\"Espelli utente\"],\"0ZpE//\":[\"Ordina per utenti\"],\"0bEPwz\":[\"Imposta assente\"],\"0dGkPt\":[\"Espandi lista canali\"],\"0gS7M5\":[\"Nome visualizzato\"],\"0kS+M8\":[\"EsempioRET\"],\"0rgoY7\":[\"Connettiti solo ai server che scegli\"],\"0wdd7X\":[\"Entra\"],\"0wkVYx\":[\"Messaggi privati\"],\"111uHX\":[\"Anteprima link\"],\"196EG4\":[\"Elimina chat privata\"],\"1DSr1i\":[\"Registra un account\"],\"1O/24y\":[\"Attiva/Disattiva lista canali\"],\"1TNIig\":[\"Open\"],\"1VPJJ2\":[\"Avviso link esterno\"],\"1ZC/dv\":[\"Nessuna menzione o messaggio non letto\"],\"1pO1zi\":[\"Il nome del server è obbligatorio\"],\"1uwfzQ\":[\"Visualizza topic del canale\"],\"268g7c\":[\"Inserisci nome visualizzato\"],\"2FOFq1\":[\"Gli operatori del server sulla rete potrebbero leggere i tuoi messaggi\"],\"2FYpfJ\":[\"Altro\"],\"2HF1Y2\":[[\"inviter\"],\" ha invitato \",[\"target\"],\" a unirsi a \",[\"channel\"]],\"2I70QL\":[\"Visualizza informazioni profilo utente\"],\"2QYdmE\":[\"Utenti:\"],\"2QpEjG\":[\"è uscito\"],\"2YE223\":[\"Messaggio in #\",[\"0\"],\" (Invio per nuova riga, Shift+Invio per inviare)\"],\"2bimFY\":[\"Usa password del server\"],\"2iTmdZ\":[\"Archiviazione locale:\"],\"2odkwe\":[\"Rigoroso – Protezione più aggressiva\"],\"2uDhbA\":[\"Inserisci il nome utente da invitare\"],\"2ygf/L\":[\"← Indietro\"],\"2zEgxj\":[\"Cerca GIF...\"],\"3RdPhl\":[\"Rinomina canale\"],\"3THokf\":[\"Utente con diritto di parola\"],\"3TSz9S\":[\"Minimizza\"],\"3jBDvM\":[\"Nome visualizzato del canale\"],\"3ryuFU\":[\"Segnalazioni di crash opzionali per migliorare l'app\"],\"3uBF/8\":[\"Chiudi visualizzatore\"],\"3uwW8F\":[\"https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"3xf8Kz\":[\"Inserisci nome account...\"],\"4/Rr0R\":[\"Invita un utente nel canale corrente\"],\"4EZrJN\":[\"Regole\"],\"4JJtW9\":[\"#overflow\"],\"4NqeT4\":[\"Profilo flood (+F)\"],\"4RZQRK\":[\"Cosa stai facendo?\"],\"4hfTrB\":[\"Nickname\"],\"4n99LO\":[\"Già in \",[\"0\"]],\"4t6vMV\":[\"Passa automaticamente a riga singola per messaggi brevi\"],\"4vsHmf\":[\"Tempo (min)\"],\"4x/Axu\":[\"Il tuo bouncer non ha ancora nessuna rete. Aggiungine una per iniziare.\"],\"5+INAX\":[\"Evidenzia i messaggi che ti menzionano\"],\"5R5Pv/\":[\"Nome oper\"],\"678PKt\":[\"Nome rete\"],\"6Aih4U\":[\"Non in linea\"],\"6CO3WE\":[\"Password richiesta per entrare. Lascia vuoto per rimuovere la chiave.\"],\"6HhMs3\":[\"Messaggio di uscita\"],\"6V3Ea3\":[\"Copiato\"],\"6lGV3K\":[\"Mostra meno\"],\"6yFOEi\":[\"Inserisci password oper...\"],\"7+IHTZ\":[\"Nessun file scelto\"],\"73fnil\":[\"TLS\"],\"73hrRi\":[\"nick!user@host (es., spam*!*@*, *!*@badhost.com)\"],\"7QkKyN\":[\"Invia messaggio privato\"],\"7U1W7c\":[\"Molto rilassato\"],\"7Y1YQj\":[\"Nome reale:\"],\"7YHArF\":[\"— apri nel visualizzatore\"],\"7fjnVl\":[\"Cerca utenti...\"],\"7jL88x\":[\"Eliminare questo messaggio? Questa azione non può essere annullata.\"],\"7nGhhM\":[\"A cosa stai pensando?\"],\"7sEpu1\":[\"Membri — \",[\"0\"]],\"7sNhEz\":[\"Nome utente\"],\"8H0Q+x\":[\"Scopri di più sui profili →\"],\"8Phu0A\":[\"Mostra quando gli utenti cambiano soprannome\"],\"8XTG9e\":[\"Inserisci password oper\"],\"8XsV2J\":[\"Riprova invio\"],\"8ZsakT\":[\"Password\"],\"8kR84m\":[\"Stai per aprire un link esterno:\"],\"8lCgih\":[\"Rimuovi regola\"],\"8p/xVT\":[[\"0\",\"plural\",{\"one\":[[\"1\"]],\"other\":[[\"2\"]]}]],\"8wRzac\":[[\"joinCount\",\"plural\",{\"one\":[\"si è unito\"],\"other\":[\"si è unito \",[\"joinCount\"],\" volte\"]}]],\"9BMLnJ\":[\"Riconnetti al server\"],\"9OEgyT\":[\"Aggiungi reazione\"],\"9PQ8m2\":[\"G-Line (ban globale)\"],\"9Qs99X\":[\"Email:\"],\"9QupBP\":[\"Rimuovi pattern\"],\"9W7tl5\":[\"(invariato)\"],\"9bG48P\":[\"Invio in corso\"],\"9f5f0u\":[\"Domande sulla privacy? Contattaci:\"],\"9iweoP\":[\"Reti su \",[\"0\"]],\"9unqs3\":[\"Assente:\"],\"9v3hwv\":[\"Nessun server trovato.\"],\"9zb2WA\":[\"Connessione in corso\"],\"A1taO8\":[\"Cerca\"],\"A2adVi\":[\"Invia notifiche di digitazione\"],\"A9Rhec\":[\"Nome canale\"],\"AWOSPo\":[\"Ingrandisci\"],\"AXSpEQ\":[\"Oper alla connessione\"],\"AeXO77\":[\"Account\"],\"AhNP40\":[\"Cerca posizione\"],\"Ai2U7L\":[\"Host\"],\"AjBQnf\":[\"Nickname cambiato\"],\"AmXVh6\":[\"https://example.com/avatar.png\"],\"AnRu/j\":[\"Annulla risposta\"],\"ApSx0O\":[\"Trovati \",[\"0\"],\" messaggi corrispondenti a \\\"\",[\"searchQuery\"],\"\\\"\"],\"AxPAXW\":[\"Nessun risultato trovato\"],\"AyNqAB\":[\"Mostra tutti gli eventi del server in chat\"],\"B/QqGw\":[\"Lontano dalla tastiera\"],\"B0sB2k\":[\"Testo in chiaro\"],\"B8AaMI\":[\"Questo campo è obbligatorio\"],\"BA2c49\":[\"Il server non supporta il filtro LIST avanzato\"],\"BDKt3I\":[[\"0\"],\", \",[\"1\"],\", \",[\"2\"],\" e altri \",[\"3\"],\" stanno scrivendo...\"],\"BGul2A\":[\"Hai modifiche non salvate. Sei sicuro di voler chiudere senza salvare?\"],\"BIf9fi\":[\"Il tuo messaggio di stato\"],\"BZz3md\":[\"Il tuo sito web personale\"],\"Bgm/H7\":[\"Consenti l'inserimento di più righe di testo\"],\"BiQIl1\":[\"Fissa questa conversazione privata\"],\"BlNZZ2\":[\"Clicca per andare al messaggio\"],\"Bowq3c\":[\"Solo gli operatori possono cambiare l'argomento\"],\"Btozzp\":[\"Questa immagine è scaduta\"],\"Bycfjm\":[\"Totale: \",[\"0\"]],\"C6IBQc\":[\"Copia JSON completo\"],\"C9L9wL\":[\"Raccolta dati\"],\"CDq4wC\":[\"Modera utente\"],\"CHVRxG\":[\"Messaggio a @\",[\"0\"],\" (Shift+Invio per nuova riga)\"],\"CN9zdR\":[\"Nome oper e password sono obbligatori\"],\"CW3sYa\":[\"Aggiungi reazione \",[\"emoji\"]],\"CaAkqd\":[\"Mostra disconnessioni\"],\"CbvaYj\":[\"Banna per nickname\"],\"CcK+Ft\":[\"PDF\"],\"Ce8q3L\":[\"Seleziona un canale\"],\"CsekCi\":[\"Normale\"],\"D+NlUC\":[\"Sistema\"],\"D28t6+\":[\"è entrato e uscito\"],\"DB8zMK\":[\"Applica\"],\"DBcWHr\":[\"File audio di notifica personalizzato\"],\"DTy9Xw\":[\"Anteprime multimediali\"],\"Dj4pSr\":[\"Scegli una password sicura\"],\"Du+zn+\":[\"Ricerca...\"],\"Du2T2f\":[\"Impostazione non trovata\"],\"DwsSVQ\":[\"Applica filtri e aggiorna\"],\"E3W/zd\":[\"Soprannome predefinito\"],\"E6nRW7\":[\"Copia URL\"],\"E703RG\":[\"Modi:\"],\"EAeu1Z\":[\"Invia invito\"],\"EFKJQT\":[\"Impostazione\"],\"EGPQBv\":[\"Regole flood personalizzate (+f)\"],\"ELik0r\":[\"Vedi l'informativa completa sulla privacy\"],\"EPbeC2\":[\"Visualizza o modifica il topic del canale\"],\"EQCDNT\":[\"Inserisci nome utente oper...\"],\"EUvulZ\":[\"Trovato 1 messaggio corrispondente a \\\"\",[\"searchQuery\"],\"\\\"\"],\"EatZYJ\":[\"Immagine successiva\"],\"EdQY6l\":[\"Nessuno\"],\"EnqLYU\":[\"Cerca server...\"],\"F0OKMc\":[\"Modifica server\"],\"F6Int2\":[\"Abilita evidenziazioni\"],\"FDoLyE\":[\"Utenti max.\"],\"FUU/hZ\":[\"Controlla quanti media esterni vengono caricati nella chat.\"],\"Fdp03t\":[\"attivo\"],\"FfPWR0\":[\"Finestra\"],\"FjkaiT\":[\"Riduci\"],\"FlqOE9\":[\"Cosa significa:\"],\"FolHNl\":[\"Gestisci il tuo account e l'autenticazione\"],\"Fp2Dif\":[\"Uscito dal server\"],\"G5KmCc\":[\"GZ-Line (Z-Line globale)\"],\"GDs0lz\":[\"<0>Rischio: Informazioni sensibili (messaggi, conversazioni private, dati di autenticazione) potrebbero essere esposte ad amministratori di rete o attaccanti posizionati tra i server IRC.\"],\"GR+2I3\":[\"Aggiungi maschera di invito (es. nick!*@*, *!*@host.com)\"],\"GRLyMU\":[\"Chiudi avvisi server in finestra separata\"],\"GlHnXw\":[\"Cambio nick fallito: \",[\"error\"],\" \",[\"0\"]],\"GswZF3\":[\"Anteprima:\"],\"GtmO8/\":[\"da\"],\"GtuHUQ\":[\"Rinomina questo canale sul server. Tutti gli utenti vedranno il nuovo nome.\"],\"GuGfFX\":[\"Attiva/Disattiva ricerca\"],\"GxkJXS\":[\"Caricamento...\"],\"GzbwnK\":[\"È entrato nel canale\"],\"GzsUDB\":[\"Profilo esteso\"],\"H/PnT8\":[\"Inserisci emoji\"],\"H6Izzl\":[\"Il tuo codice colore preferito\"],\"H9jIv+\":[\"Mostra entrate/uscite\"],\"HAKBY9\":[\"Carica file\"],\"HdE1If\":[\"Canale\"],\"Hk4AW9\":[\"Il tuo nome visualizzato preferito\"],\"HmHDk7\":[\"Seleziona membro\"],\"HrQzPU\":[\"Canali su \",[\"networkName\"]],\"I2tXQ5\":[\"Messaggio a @\",[\"0\"],\" (Invio per nuova riga, Shift+Invio per inviare)\"],\"I6bw/h\":[\"Banna utente\"],\"I92Z+b\":[\"Abilita notifiche\"],\"I9D72S\":[\"Sei sicuro di voler eliminare questo messaggio? Questa azione non può essere annullata.\"],\"IA+1wo\":[\"Mostra quando gli utenti vengono espulsi dai canali\"],\"IDwkJx\":[\"IRC Operator\"],\"ILlU+s\":[\"Info:\"],\"IUwGEM\":[\"Salva modifiche\"],\"IVeGK6\":[[\"0\"],\", \",[\"1\"],\" e \",[\"2\"],\" stanno scrivendo...\"],\"IgrLD/\":[\"Pausa\"],\"Im6JED\":[\"WHISPER\"],\"ImOQa9\":[\"Rispondi\"],\"IoHMnl\":[\"Il valore massimo è \",[\"0\"]],\"IvMj+0\":[\"Op\"],\"J28zul\":[\"Connessione...\"],\"J5T9NW\":[\"Informazioni utente\"],\"J8Y5+z\":[\"Ops! La rete si è divisa! ⚠️\"],\"JBHkBA\":[\"Ha lasciato il canale\"],\"JCwL0Q\":[\"Inserisci motivo (opzionale)\"],\"JFciKP\":[\"Attiva/Disattiva\"],\"JXGkhG\":[\"Cambia il nome del canale (solo operatori)\"],\"JcD7qf\":[\"Altre azioni\"],\"JdkA+c\":[\"Segreto (+s)\"],\"Jmu12l\":[\"Canali del server\"],\"JvQ++s\":[\"Abilita Markdown\"],\"K2jwh/\":[\"Nessun dato WHOIS disponibile\"],\"KAXSwC\":[\"Voice\"],\"KDfTdX\":[\"Elimina messaggio\"],\"KKBlUU\":[\"Incorpora\"],\"KM0pLb\":[\"Benvenuto nel canale!\"],\"KR6W2h\":[\"Smetti di ignorare utente\"],\"KV+Bi1\":[\"Solo su invito (+i)\"],\"KdCtwE\":[\"Quanti secondi monitorare l'attività flood prima di reimpostare i contatori\"],\"Kkezga\":[\"Password del server\"],\"KsiQ/8\":[\"Gli utenti devono essere invitati per entrare\"],\"L+gB/D\":[\"Informazioni sul canale\"],\"LC1a7n\":[\"Il server IRC ha segnalato che i suoi collegamenti tra server hanno un basso livello di sicurezza. Ciò significa che quando i tuoi messaggi vengono instradati tra i server IRC nella rete, potrebbero non essere correttamente cifrati o i certificati SSL/TLS potrebbero non essere validati correttamente.\"],\"LNfLR5\":[\"Mostra espulsioni\"],\"LP+1Z7\":[\"Aggiungi rete\"],\"LQb0W/\":[\"Mostra tutti gli eventi\"],\"LU7/yA\":[\"Nome alternativo per la visualizzazione. Può contenere spazi, emoji e caratteri speciali. Il nome reale (\",[\"channelName\"],\") verrà comunque usato per i comandi IRC.\"],\"LUb9O7\":[\"È richiesta una porta del server valida\"],\"Lb+BUl\":[\"https://example.com/avatar.jpg\"],\"LcET2C\":[\"Informativa sulla privacy\"],\"LcuSDR\":[\"Gestisci le informazioni del tuo profilo e i metadati\"],\"LqLS9B\":[\"Mostra cambi di soprannome\"],\"LsDQt2\":[\"Impostazioni canale\"],\"LtI9AS\":[\"Proprietario\"],\"LuNhhL\":[\"ha reagito a questo messaggio\"],\"M/AZNG\":[\"URL dell'immagine del tuo avatar\"],\"M/WIer\":[\"Invia messaggio\"],\"M8er/5\":[\"Nome:\"],\"MHk+7g\":[\"Immagine precedente\"],\"MRorGe\":[\"Messaggio privato\"],\"MVbSGP\":[\"Finestra temporale (secondi)\"],\"MkpcsT\":[\"I tuoi messaggi e impostazioni sono archiviati localmente sul tuo dispositivo\"],\"MzPdC2\":[\"Password del server (PASS)\"],\"N/hDSy\":[\"Segna come bot, di solito 'on' o vuoto\"],\"N6j2JH\":[\"Modifica \",[\"0\"]],\"N7TQbE\":[\"Invita utente in \",[\"channelName\"]],\"NCca/o\":[\"Inserisci nickname predefinito...\"],\"Nqs6B9\":[\"Mostra tutti i media esterni. Qualsiasi URL potrebbe causare una richiesta a un server sconosciuto.\"],\"Nt+9O7\":[\"Usa WebSocket invece di TCP grezzo\"],\"NxIHzc\":[\"Espelli utente\"],\"O+v/cL\":[\"Sfoglia tutti i canali del server\"],\"OCGpR4\":[\"(eredita)\"],\"ODwSCk\":[\"Invia un GIF\"],\"OGQ5kK\":[\"Configura suoni di notifica ed evidenziazioni\"],\"OIPt1Z\":[\"Mostra o nascondi la barra laterale dei membri\"],\"OKSNq/\":[\"Molto rigido\"],\"ONWvwQ\":[\"Carica\"],\"OVKoQO\":[\"La tua password account per l'autenticazione\"],\"ObsidianIRC - Bringing IRC to the future\":[\"ObsidianIRC - Portare IRC nel futuro\"],\"OhCpra\":[\"Imposta un topic…\"],\"OkltoQ\":[\"Banna \",[\"username\"],\" per nickname (impedisce di rientrare con lo stesso nick)\"],\"P+t/Te\":[\"Nessun dato aggiuntivo\"],\"P42Wcc\":[\"Sicuro\"],\"PD38l0\":[\"Anteprima avatar canale\"],\"PD9mEt\":[\"Scrivi un messaggio...\"],\"PPqfdA\":[\"Apri impostazioni configurazione canale\"],\"PSCjfZ\":[\"L'argomento visualizzato per questo canale. Tutti gli utenti possono vederlo.\"],\"PZCecv\":[\"Anteprima PDF\"],\"PeLgsC\":[[\"c\",\"plural\",{\"one\":[\"1 volta\"],\"other\":[[\"c\"],\" volte\"]}]],\"PguS2C\":[\"Aggiungi maschera di eccezione (es. nick!*@*, *!*@host.com)\"],\"Pil5Ty\":[\"Visualizzazione di \",[\"displayedChannelsCount\"],\" su \",[\"0\"],\" canali\"],\"PqhVlJ\":[\"Banna utente (per hostmask)\"],\"Q+chwU\":[\"Nome utente:\"],\"Q3v9Wc\":[\"Sì, elimina\"],\"Q6hhn8\":[\"Preferenze\"],\"QF4a34\":[\"Inserisci un nome utente\"],\"QGqSZ2\":[\"Colore e formattazione\"],\"QJQd1J\":[\"Modifica profilo\"],\"QSzGDE\":[\"Inattivo\"],\"QUlny5\":[\"Benvenuto su \",[\"0\"],\"!\"],\"Qoq+GP\":[\"Leggi di più\"],\"QuSkCF\":[\"Filtra canali...\"],\"QwUrDZ\":[\"ha cambiato il topic in: \",[\"topic\"]],\"R0UH07\":[\"Immagine \",[\"0\"],\" di \",[\"1\"]],\"R7SsBE\":[\"Disattiva audio\"],\"R8rf1X\":[\"Clicca per impostare il topic\"],\"RArB3D\":[\"è stato espulso da \",[\"channelName\"],\" da \",[\"username\"]],\"RI3cWd\":[\"Scopri il mondo di IRC con ObsidianIRC\"],\"RMMaN5\":[\"Moderato (+m)\"],\"RWw9Lg\":[\"Chiudi finestra\"],\"RZ2BuZ\":[\"La registrazione dell'account \",[\"account\"],\" richiede verifica: \",[\"message\"]],\"RySp6q\":[\"Nascondi commenti\"],\"S5Togi\":[\"Caricamento delle reti dal tuo bouncer…\"],\"SPKQTd\":[\"Il nickname è obbligatorio\"],\"SPVjfj\":[\"Il valore predefinito sarà 'nessun motivo' se lasciato vuoto\"],\"SQKPvQ\":[\"Invita utente\"],\"STmlpb\":[\"Back to network list\"],\"SkZcl+\":[\"Scegli un profilo di protezione flood predefinito. Questi profili forniscono impostazioni di protezione bilanciate per diversi casi d'uso.\"],\"Slr+3C\":[\"Utenti min.\"],\"Spnlre\":[\"Hai invitato \",[\"target\"],\" a unirsi a \",[\"channel\"]],\"T/ckN5\":[\"Apri nel visualizzatore\"],\"T91vKp\":[\"Riproduci\"],\"TV2Wdu\":[\"Scopri come gestiamo i tuoi dati e proteggiamo la tua privacy.\"],\"TgFpwD\":[\"Applicazione...\"],\"TkzSFB\":[\"Nessuna modifica\"],\"TtserG\":[\"Inserisci nome reale\"],\"Ttz9J1\":[\"Inserisci password...\"],\"Tz0i8g\":[\"Impostazioni\"],\"U3pytU\":[\"Admin\"],\"UDb2YD\":[\"Reagisci\"],\"UE4KO5\":[\"*channel*\"],\"UGT5vp\":[\"Salva impostazioni\"],\"UV5hLB\":[\"Nessun ban trovato\"],\"Uaj3Nd\":[\"Messaggi di stato\"],\"Ue3uny\":[\"Predefinito (nessun profilo)\"],\"UkARhe\":[\"Normale – Protezione standard\"],\"Umn7Cj\":[\"Ancora nessun commento. Sii il primo!\"],\"UtUIRh\":[[\"0\"],\" messaggi precedenti\"],\"UwzP+U\":[\"Connessione sicura\"],\"V0/A4O\":[\"Proprietario del canale\"],\"V4qgxE\":[\"Creato prima (min fa)\"],\"V8yTm6\":[\"Cancella ricerca\"],\"VJMMyz\":[\"ObsidianIRC - Portare IRC nel futuro\"],\"VJScHU\":[\"Motivo\"],\"VLsmVV\":[\"Silenzia notifiche\"],\"VbyRUy\":[\"Commenti\"],\"Vmx0mQ\":[\"Impostato da:\"],\"VqnIZz\":[\"Visualizza la nostra informativa sulla privacy e le pratiche sui dati\"],\"VrMygG\":[\"La lunghezza minima è \",[\"0\"]],\"VrnTui\":[\"I tuoi pronomi, mostrati nel profilo\"],\"W8E3qn\":[\"Account autenticato\"],\"WAakm9\":[\"Elimina canale\"],\"WFxTHC\":[\"Aggiungi maschera di ban (es. nick!*@*, *!*@host.com)\"],\"WN1g9F\":[\"L'host del server è obbligatorio\"],\"WRYdXW\":[\"Posizione audio\"],\"WUOH5B\":[\"Ignora utente\"],\"WWEXnZ\":[[\"0\",\"plural\",{\"one\":[\"Mostra 1 altro elemento\"],\"other\":[\"Mostra \",[\"1\"],\" altri elementi\"]}]],\"Weq9zb\":[\"Generale\"],\"Wfj7Sk\":[\"Attiva o disattiva i suoni delle notifiche\"],\"Wm7gbG\":[\"GitHub:\"],\"WyeHWY\":[\"*spam*\"],\"WzMCru\":[\"Profilo utente\"],\"X6S3lt\":[\"Cerca impostazioni, canali, server...\"],\"XEHan5\":[\"Continua comunque\"],\"XI1+wb\":[\"Formato non valido\"],\"XIXeuC\":[\"Messaggio a @\",[\"0\"]],\"XMS+k4\":[\"Avvia messaggio privato\"],\"XWgxXq\":[\"Album\"],\"Xd7+IT\":[\"Rimuovi fissaggio chat privata\"],\"Xm/s+u\":[\"Visualizzazione\"],\"Xp2n93\":[\"Mostra media dall'host di file attendibile del tuo server. Nessuna richiesta viene inviata a servizi esterni.\"],\"XvjC4F\":[\"Salvataggio...\"],\"Y/qryO\":[\"Nessun utente trovato corrispondente alla ricerca\"],\"YAqRpI\":[\"Registrazione dell'account \",[\"account\"],\" riuscita: \",[\"message\"]],\"YEfzvP\":[\"Argomento protetto (+t)\"],\"YQOn6a\":[\"Comprimi lista membri\"],\"YRCoE9\":[\"Operatore del canale\"],\"YURQaF\":[\"Vedi profilo\"],\"YdBSvr\":[\"Controlla la visualizzazione dei media e dei contenuti esterni\"],\"Yj6U3V\":[\"Nessun server centrale:\"],\"YjvpGx\":[\"Pronomi\"],\"YqH4l4\":[\"Nessuna chiave\"],\"YyUPpV\":[\"Account:\"],\"ZJSWfw\":[\"Messaggio mostrato alla disconnessione dal server\"],\"ZR1dJ4\":[\"Inviti\"],\"ZdWg0V\":[\"Apri nel browser\"],\"ZhRBbl\":[\"Cerca messaggi…\"],\"Zmcu3y\":[\"Filtri avanzati\"],\"a2/8e5\":[\"Argomento impostato dopo (min fa)\"],\"aHKcKc\":[\"Pagina precedente\"],\"aJTbXX\":[\"Password oper\"],\"aQryQv\":[\"Il pattern esiste già\"],\"aW9pLN\":[\"Numero massimo di utenti nel canale. Lascia vuoto per nessun limite.\"],\"ah4fmZ\":[\"Mostra anche anteprime da YouTube, Vimeo, SoundCloud e servizi noti simili.\"],\"aifXak\":[\"Nessun media in questo canale\"],\"ap2zBz\":[\"Rilassato\"],\"az8lvo\":[\"Disattivato\"],\"azXSNo\":[\"Espandi lista membri\"],\"azdliB\":[\"Accedi a un account\"],\"b26wlF\":[\"lei/la\"],\"bD/+Ei\":[\"Rigido\"],\"bQ6BJn\":[\"Configura regole dettagliate di protezione flood. Ogni regola specifica il tipo di attività da monitorare e l'azione da intraprendere quando le soglie vengono superate.\"],\"beV7+y\":[\"L'utente riceverà un invito per unirsi a \",[\"channelName\"],\".\"],\"bk84cH\":[\"Messaggio di assenza\"],\"bkHdLj\":[\"Aggiungi server IRC\"],\"bmQLn5\":[\"Aggiungi regola\"],\"bv4cFj\":[\"Trasporto\"],\"bwRvnp\":[\"Azione\"],\"c8+EVZ\":[\"Account verificato\"],\"cGYUlD\":[\"Nessuna anteprima media caricata.\"],\"cLF98o\":[\"Mostra commenti (\",[\"commentCount\"],\")\"],\"cLKIDO\":[\"Nessun utente disponibile\"],\"cSgpoS\":[\"Fissa chat privata\"],\"cde3ce\":[\"Messaggio a <0>\",[\"0\"],\"\"],\"chQsxg\":[\"Copia output formattato\"],\"cl/A5J\":[\"Benvenuto su \",[\"__DEFAULT_IRC_SERVER_NAME__\"],\"!\"],\"cnGeoo\":[\"Elimina\"],\"coPLXT\":[\"Non archiviamo le tue comunicazioni IRC sui nostri server\"],\"crYH/6\":[\"Player SoundCloud\"],\"cv5DQb\":[\"nessun host impostato\"],\"d3sis4\":[\"Aggiungi server\"],\"d9aN5k\":[\"Rimuovi \",[\"username\"],\" dal canale\"],\"dEgA5A\":[\"Annulla\"],\"dGi1We\":[\"Rimuovi il fissaggio di questa conversazione privata\"],\"dJVuyC\":[\"ha lasciato \",[\"channelName\"],\" (\",[\"reason\"],\")\"],\"dMtLDE\":[\"a\"],\"dXqxlh\":[\"<0>⚠️ Rischio di sicurezza! Questa connessione potrebbe essere vulnerabile a intercettazioni o attacchi man-in-the-middle.\"],\"da9Q/R\":[\"Modalità canale modificate\"],\"dhJN3N\":[\"Mostra commenti\"],\"dj2xTE\":[\"Ignora notifica\"],\"dpCzmC\":[\"Impostazioni protezione flood\"],\"e9dQpT\":[\"Vuoi aprire questo link in una nuova scheda?\"],\"ePK91l\":[\"Modifica\"],\"eYBDuB\":[\"Carica un'immagine o fornisci un URL con sostituzione opzionale \",[\"size\"]],\"edBbee\":[\"Banna \",[\"username\"],\" per hostmask (impedisce di rientrare dallo stesso IP/host)\"],\"ekfzWq\":[\"Impostazioni utente\"],\"elPDWs\":[\"Personalizza la tua esperienza con il client IRC\"],\"eu2osY\":[\"<0>💡 Raccomandazione: Procedi solo se ti fidi di questo server e comprendi i rischi. Evita di condividere informazioni sensibili o password su questa connessione.\"],\"euEhbr\":[\"Clicca per unirti a \",[\"channel\"]],\"ez3vLd\":[\"Abilita input multiriga\"],\"f0J5Ki\":[\"Le comunicazioni tra server potrebbero usare connessioni non cifrate\"],\"f9BHJk\":[\"Avvisa utente\"],\"fDOLLd\":[\"Nessun canale trovato.\"],\"ffzDkB\":[\"Analisi anonime:\"],\"fq1GF9\":[\"Mostra quando gli utenti si disconnettono dal server\"],\"gEF57C\":[\"Questo server supporta solo un tipo di connessione\"],\"gJuLUI\":[\"Lista di ignorati\"],\"gNzMrk\":[\"Avatar attuale\"],\"gjPWyO\":[\"Inserisci nickname...\"],\"gz6UQ3\":[\"Massimizza\"],\"h6/IMX\":[\"Aggiungi la tua prima rete\"],\"h6razj\":[\"Escludi maschera nome canale\"],\"hG6jnw\":[\"Nessun topic impostato\"],\"hG89Ed\":[\"Immagine\"],\"hZ6znB\":[\"Porta\"],\"ha+Bz5\":[\"es., 100:1440\"],\"hehnjM\":[\"Quantità\"],\"hzdLuQ\":[\"Solo gli utenti con voice o superiore possono parlare\"],\"i0qMbr\":[\"Home\"],\"iDNBZe\":[\"Notifiche\"],\"iH8pgl\":[\"Indietro\"],\"iL9SZg\":[\"Banna utente (per nickname)\"],\"iNt+3c\":[\"Torna all'immagine\"],\"iQvi+a\":[\"Non avvisarmi sulla bassa sicurezza del link per questo server\"],\"iSLIjg\":[\"Connetti\"],\"iWXkHH\":[\"Halfop\"],\"iZeTtp\":[\"Host del server\"],\"idD8Ev\":[\"Salvato\"],\"iivqkW\":[\"Connesso dal\"],\"ij+Elv\":[\"Anteprima immagine\"],\"ilIWp7\":[\"Attiva/Disattiva notifiche\"],\"iuaqvB\":[\"Usa * come wildcard. Esempi: baduser!*@*, *!*@spammer.com, troll*!*@*\"],\"ixkTse\":[\"Bot\"],\"j2DGR0\":[\"Banna per maschera host\"],\"jA4uoI\":[\"Argomento:\"],\"jLXxGK\":[\"https://example.com\"],\"jPSk57\":[\"Motivo (opzionale)\"],\"jUV7CU\":[\"Carica avatar\"],\"jW5Uwh\":[\"Controlla quanti media esterni vengono caricati. Disattivato / Sicuro / Fonti affidabili / Tutto il contenuto.\"],\"jXzms5\":[\"Opzioni allegato\"],\"jZlrte\":[\"Colore\"],\"jfC/xh\":[\"Contatti\"],\"jywMpv\":[\"#new-channel-name\"],\"k112DD\":[\"Carica messaggi precedenti\"],\"k3ID0F\":[\"Filtra membri…\"],\"k65gsE\":[\"Analisi approfondita\"],\"k7Zgob\":[\"Annulla connessione\"],\"kAVx5h\":[\"Nessun invito trovato\"],\"kCLEPU\":[\"Connesso a\"],\"kF5LKb\":[\"Pattern ignorati:\"],\"kGeOx/\":[\"Unisciti a \",[\"0\"]],\"kITKr8\":[\"Caricamento modalità canale...\"],\"kPpPsw\":[\"Sei un IRC Operator\"],\"kWJmRL\":[\"Tu\"],\"kfcRb0\":[\"Avatar\"],\"kjMqSj\":[\"Copia JSON\"],\"krViRy\":[\"Clicca per copiare come JSON\"],\"ks71ra\":[\"Eccezioni\"],\"kw4lRv\":[\"Semi-operatore del canale\"],\"kxgIRq\":[\"Seleziona o aggiungi un canale per iniziare.\"],\"ky6dWe\":[\"Anteprima avatar\"],\"l+GxCv\":[\"Caricamento canali...\"],\"l+IUVW\":[\"Verifica dell'account \",[\"account\"],\" riuscita: \",[\"message\"]],\"l/siQz\":[[\"reconnectCount\",\"plural\",{\"one\":[\"si è riconnesso\"],\"other\":[\"si è riconnesso \",[\"reconnectCount\"],\" volte\"]}]],\"l5jmzx\":[[\"0\"],\" e \",[\"1\"],\" stanno scrivendo...\"],\"lHy8N5\":[\"Caricamento altri canali...\"],\"lbpf14\":[\"Entra in \",[\"value\"]],\"lfFsZ4\":[\"Canali\"],\"lkNdiH\":[\"Nome account\"],\"ln500L\":[\"ObsidianIRC\"],\"lnCMdg\":[\"Carica immagine\"],\"loQxaJ\":[\"Sono tornato\"],\"lvfaxv\":[\"HOME\"],\"m0oxpP\":[\"Libera Chat\"],\"m16xKo\":[\"Aggiungi\"],\"m8flAk\":[\"Anteprima (non ancora caricata)\"],\"mEPxTp\":[\"<0>⚠️ Attenzione! Apri solo link da fonti attendibili. I link malevoli possono compromettere la tua sicurezza o privacy.\"],\"mHGdhG\":[\"Informazioni sul server\"],\"mHS8lb\":[\"Messaggio in #\",[\"0\"]],\"mMYBD9\":[\"Ampio – Portata di protezione estesa\"],\"mTGsPd\":[\"Argomento del canale\"],\"mU8j6O\":[\"Nessun messaggio esterno (+n)\"],\"mZp8FL\":[\"Ritorno automatico alla singola riga\"],\"mdQu8G\":[\"IlTuoNickname\"],\"miSSBQ\":[\"Commenti (\",[\"commentCount\"],\")\"],\"mvyLSy\":[\"Utente autenticato\"],\"mwtcGl\":[\"Chiudi commenti\"],\"myL0MR\":[\"Eliminare questa rete?\"],\"mzI/c+\":[\"Scarica\"],\"n3fGRk\":[\"impostato da \",[\"0\"]],\"nE9jsU\":[\"Rilassato – Protezione meno aggressiva\"],\"nNflMD\":[\"Abbandona canale\"],\"nPXkBi\":[\"Caricamento dati WHOIS...\"],\"nQnxxF\":[\"Messaggio in #\",[\"0\"],\" (Shift+Invio per nuova riga)\"],\"nWMRxa\":[\"Rimuovi fissaggio\"],\"nkC032\":[\"Nessun profilo flood\"],\"o69z4d\":[\"Invia un messaggio di avviso a \",[\"username\"]],\"o9ylQi\":[\"Cerca GIF per iniziare\"],\"oFGkER\":[\"Avvisi del server\"],\"oOi11l\":[\"Vai in fondo\"],\"oQEzQR\":[\"Nuovo messaggio privato\"],\"oXOSPE\":[\"In linea\"],\"oal760\":[\"Sono possibili attacchi man-in-the-middle sui link del server\"],\"oeqmmJ\":[\"Fonti attendibili\"],\"ovBPCi\":[\"Predefinito\"],\"p0Z69r\":[\"Il pattern non può essere vuoto\"],\"p1KgtK\":[\"Caricamento audio non riuscito\"],\"p59pEv\":[\"Dettagli aggiuntivi\"],\"p7sRI6\":[\"Avvisa gli altri quando stai scrivendo\"],\"pBm1od\":[\"Canale segreto\"],\"pNmiXx\":[\"Il tuo soprannome predefinito per tutti i server\"],\"pUUo9G\":[\"Hostname:\"],\"pVGPmz\":[\"Password account\"],\"peNE68\":[\"Permanente\"],\"plhHQt\":[\"Nessun dato\"],\"pm6+q5\":[\"Avviso di sicurezza\"],\"pn5qSs\":[\"Informazioni aggiuntive\"],\"q0cR4S\":[\"ora è conosciuto come **\",[\"newNick\"],\"**\"],\"qFcunY\":[\"Il canale non apparirà nei comandi LIST o NAMES\"],\"qLpTm/\":[\"Rimuovi reazione \",[\"emoji\"]],\"qVkGWK\":[\"Fissa\"],\"qY8wNa\":[\"Homepage\"],\"qb0xJ7\":[\"Wildcard: * corrisponde a qualsiasi sequenza, ? a un singolo carattere. Esempi: nick!*@*, *!*@host.com, *!*user@*\"],\"qhzpRq\":[\"Chiave canale (+k)\"],\"qtoOYG\":[\"Nessun limite\"],\"r1W2AS\":[\"Immagine dal filehost\"],\"rIPR2O\":[\"Argomento impostato prima (min fa)\"],\"rMMSYo\":[\"La lunghezza massima è \",[\"0\"]],\"rWtzQe\":[\"La rete si è divisa e riconnessa. ✅\"],\"rYG2u6\":[\"Attendere...\"],\"rdUucN\":[\"Anteprima\"],\"rjGI/Q\":[\"Privacy\"],\"rk8iDX\":[\"Caricamento GIF...\"],\"rn6SBY\":[\"Attiva audio\"],\"s/UKqq\":[\"È stato espulso dal canale\"],\"s8cATI\":[\"si è unito a \",[\"channelName\"]],\"sCO9ue\":[\"La connessione a <0>\",[\"serverName\"],\" presenta le seguenti problematiche di sicurezza:\"],\"sGH11W\":[\"Server\"],\"sHI1H+\":[\"ora è conosciuto come **\",[\"newNick\"],\"**\"],\"sJyV04\":[[\"inviter\"],\" ti ha invitato a unirti a \",[\"channel\"]],\"sUBSbK\":[\"Nessuna rete upstream ancora.\"],\"sby+1/\":[\"Clicca per copiare\"],\"sfN25C\":[\"Il tuo nome reale o completo\"],\"sliuzR\":[\"Apri link\"],\"sqrO9R\":[\"Menzioni personalizzate\"],\"sr6RdJ\":[\"Multiriga con Shift+Invio\"],\"swrCpB\":[\"Il canale è stato rinominato da \",[\"oldName\"],\" a \",[\"newName\"],\" da \",[\"user\"],[\"0\"]],\"sxkWRg\":[\"Avanzato\"],\"t/YqKh\":[\"Rimuovi\"],\"t47eHD\":[\"Il tuo identificatore unico su questo server\"],\"tAkAh0\":[\"URL con sostituzione opzionale \",[\"size\"],\". Esempio: https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"tXLJS3\":[\"Mostra o nascondi la barra laterale dei canali\"],\"tfDRzk\":[\"Salva\"],\"tiBsJk\":[\"ha lasciato \",[\"channelName\"]],\"tt4/UD\":[\"ha abbandonato (\",[\"reason\"],\")\"],\"u0TcnO\":[\"Il nick {nick} è già in uso, nuovo tentativo con {newNick}\"],\"u0a8B4\":[\"Autenticarsi come operatore IRC per l'accesso amministrativo\"],\"u0rWFU\":[\"Creato dopo (min fa)\"],\"u72w3t\":[\"Utenti e modelli da ignorare\"],\"u7jc2L\":[\"ha abbandonato\"],\"uAQUqI\":[\"Stato\"],\"uB85T3\":[\"Salvataggio fallito: \",[\"msg\"]],\"uV3DOL\":[\"G-Line\"],\"uW3lLI\":[\"Server IRC:\"],\"usSSr/\":[\"Livello zoom\"],\"v7uvcf\":[\"Software:\"],\"vE8kb+\":[\"Shift+Invio per le nuove righe (Invio invia)\"],\"vERlcd\":[\"Profilo\"],\"vK0RL8\":[\"Nessun argomento\"],\"vSJd18\":[\"Video\"],\"vXIe7J\":[\"Lingua\"],\"vaHYxN\":[\"Nome reale\"],\"vhjbKr\":[\"Assente\"],\"w/nogd\":[[\"0\"],\" network\",[\"1\"],\" — pick one to join\"],\"w4NYox\":[\"client \",[\"title\"]],\"w8xQRx\":[\"Valore non valido\"],\"wFjjxZ\":[\"è stato espulso da \",[\"channelName\"],\" da \",[\"username\"],\" (\",[\"reason\"],\")\"],\"wGjaGl\":[\"Nessuna eccezione di ban trovata\"],\"wPrGnM\":[\"Amministratore del canale\"],\"wRkP2d\":[\"GIF\"],\"wbm86v\":[\"Mostra quando gli utenti entrano o escono dai canali\"],\"whqZ9r\":[\"Parole o frasi aggiuntive da evidenziare\"],\"wm7RV4\":[\"Suono di notifica\"],\"wz/Yoq\":[\"I tuoi messaggi potrebbero essere intercettati durante l'instradamento tra server\"],\"xCJdfg\":[\"Cancella\"],\"xUHRTR\":[\"Autentica automaticamente come operatore alla connessione\"],\"xWHwwQ\":[\"Ban\"],\"xYilR2\":[\"Media\"],\"xceQrO\":[\"Sono supportati solo websocket sicuri\"],\"xdtXa+\":[\"nome-canale\"],\"xfXC7q\":[\"Canali testuali\"],\"xlCYOE\":[\"Caricamento messaggi...\"],\"xlhswE\":[\"Il valore minimo è \",[\"0\"]],\"xq97Ci\":[\"Aggiungi una parola o frase...\"],\"xuRqRq\":[\"Limite client (+l)\"],\"xwF+7J\":[[\"0\"],\" sta scrivendo...\"],\"yJztBY\":[\"Elimina rete\"],\"yNeucF\":[\"Questo server non supporta i metadati del profilo esteso (estensione IRCv3 METADATA). Campi come avatar, nome visualizzato e stato non sono disponibili.\"],\"yPlrca\":[\"Avatar del canale\"],\"yQE2r9\":[\"Caricamento\"],\"ySU+JY\":[\"tuo@email.com\"],\"yTX1Rt\":[\"Nome utente operatore\"],\"yYOzWD\":[\"log\"],\"yfx9Re\":[\"Password operatore IRC\"],\"ygCKqB\":[\"Stop\"],\"ymDxJx\":[\"Nome utente operatore IRC\"],\"yrpRsQ\":[\"Ordina per nome\"],\"yz7wBu\":[\"Chiudi\"],\"zJw+jA\":[\"imposta modalità: \",[\"0\"]],\"zebeLu\":[\"Inserisci nome utente oper\"],\"zpr0Bw\":[\"GZ-Line\"]}"); \ No newline at end of file diff --git a/src/locales/it/messages.po b/src/locales/it/messages.po index bb18256f..b40edc24 100644 --- a/src/locales/it/messages.po +++ b/src/locales/it/messages.po @@ -22,6 +22,16 @@ msgstr "ObsidianIRC - Portare IRC nel futuro" msgid "— open in viewer" msgstr "— apri nel visualizzatore" +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx +msgid "(inherit)" +msgstr "(eredita)" + +#: src/components/ui/BouncerNetworkForm.tsx +msgid "(unchanged)" +msgstr "(invariato)" + #. placeholder {0}: filteredMessages.length #. placeholder {1}: import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import type * as React from "react"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { SCROLL_TOLERANCE, useScrollToBottom, } from "../../hooks/useScrollToBottom"; import { groupConsecutiveEvents } from "../../lib/eventGrouping"; import ircClient from "../../lib/ircClient"; import useStore from "../../store"; import type { Message as MessageType } from "../../types"; import { CollapsedEventMessage } from "../message/CollapsedEventMessage"; import { MessageItem } from "../message/MessageItem"; import LoadingSpinner from "../ui/LoadingSpinner"; import { ScrollToBottomButton } from "../ui/ScrollToBottomButton"; export const DEFAULT_VISIBLE_MESSAGE_COUNT = 100; // Stable empty array — prevents selector from returning a new [] on every render // when the channel has no messages yet (undefined ?? [] would create a new ref each time). const EMPTY_MESSAGES: import("../../types").Message[] = []; export interface ChannelMessageListHandle { setAtBottom: () => void; scrollToBottom: () => void; getScrollState: () => { scrollTop: number; isAtBottom: boolean; visibleCount: number; }; } interface ChannelMessageListProps { channelKey: string; serverId: string; channelId: string | null; privateChatId: string | null; isActive: boolean; searchQuery: string; isMemberListVisible: boolean; onReply: (msg: MessageType | null) => void; onUsernameContextMenu: ( e: React.MouseEvent, username: string, serverId: string, channelId: string, avatarEl?: Element | null, ) => void; onIrcLinkClick: (url: string) => void; onReactClick: (msg: MessageType, el: Element) => void; onReactionUnreact: (emoji: string, msg: MessageType) => void; onOpenReactionModal: ( msg: MessageType, position: { x: number; y: number }, ) => void; onDirectReaction: (emoji: string, msg: MessageType) => void; onRedactMessage: (msg: MessageType) => void; onOpenProfile: (username: string) => void; joinChannel: (serverId: string, channelName: string) => void; onClearSearch: () => void; highlightedMessageId?: string; // undefined = first visit; null = was at bottom; object = restore to saved position initialScrollState?: { scrollTop: number; visibleCount: number } | null; } export const ChannelMessageList = forwardRef< ChannelMessageListHandle, ChannelMessageListProps >( ( { channelKey, serverId, channelId, privateChatId, isActive, searchQuery, isMemberListVisible, onReply, onUsernameContextMenu, onIrcLinkClick, onReactClick, onReactionUnreact, onOpenReactionModal, onDirectReaction, onRedactMessage, onOpenProfile, joinChannel, onClearSearch, highlightedMessageId, initialScrollState, }, ref, ) => { const { t } = useLingui(); const [visibleMessageCount, setVisibleMessageCount] = useState( initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT, ); // Ref mirror so getScrollState closure always reads the current value without needing it as a dep. const visibleMessageCountRef = useRef(visibleMessageCount); visibleMessageCountRef.current = visibleMessageCount; // Distinguishes initial join (full-screen spinner) from subsequent "load more" (button spinner). const [isFetchingMore, setIsFetchingMore] = useState(false); const isFetchingMoreRef = useRef(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const messagesInnerRef = useRef(null); // prev scrollHeight for prepend delta-correction. const prevScrollHeightRef = useRef(0); // Ref mirror of isScrolledUp — lets useLayoutEffect closures read current value // without listing isScrolledUp as a dep (which would re-run effects on every scroll). const isScrolledUpRef = useRef(false); const prevFilteredLengthRef = useRef(0); const prevFirstMsgIdRef = useRef(null); // Set by the window-growth layoutEffect (or button handler) when a true prepend is detected. // Consumed by the delta-correction layoutEffect one render later (after visibleCount grows). // Using a flag instead of tracking displayedMessages[0]?.id because slice(-N) slides the // window on every bottom append, changing displayedMessages[0] even for non-prepend renders. const pendingPrependRef = useRef(false); // Shared scrollHeight baseline between the delta-correction layout effect and the inner // ResizeObserver. When scrollTop is corrected after a prepend, we update this so the RO's // "was at bottom" check is not fooled by the adjusted scrollTop vs its stale prevSH. const resizeObserverPrevSHRef = useRef(0); const channelMessages = useStore( useCallback( (state) => state.messages[channelKey] ?? EMPTY_MESSAGES, [channelKey], ), ); const servers = useStore((state) => state.servers); const mobileViewActiveColumn = useStore( (state) => state.ui.mobileViewActiveColumn, ); const channel = useMemo( () => channelId ? (servers .find((s) => s.id === serverId) ?.channels.find((c) => c.id === channelId) ?? null) : null, [servers, serverId, channelId], ); const { isScrolledUp, wasAtBottomRef, scrollToBottom } = useScrollToBottom( messagesContainerRef, messagesEndRef, { channelId: `${channelId || privateChatId}-${isMemberListVisible}` }, ); // Snapshot of the last known scroll position captured while the container was visible. // getScrollState() reads this instead of the live DOM because React commits display:none // before running cleanup effects, collapsing scrollTop/scrollHeight/clientHeight to 0. const lastScrollTopRef = useRef(initialScrollState?.scrollTop ?? 0); useEffect(() => { const container = messagesContainerRef.current; if (!container) return; const onScroll = () => { if (container.clientHeight > 0) lastScrollTopRef.current = container.scrollTop; }; container.addEventListener("scroll", onScroll, { passive: true }); return () => container.removeEventListener("scroll", onScroll); }, []); // Restore scroll position when a keep-alive channel transitions from hidden to visible. // display:none may reset scrollTop to 0; lastScrollTopRef was captured while visible. const prevActiveRef = useRef(isActive); useLayoutEffect(() => { if (isActive && !prevActiveRef.current) { const container = messagesContainerRef.current; if (container && lastScrollTopRef.current > 0) { container.scrollTop = lastScrollTopRef.current; } } prevActiveRef.current = isActive; }, [isActive]); useImperativeHandle(ref, () => ({ setAtBottom: () => { wasAtBottomRef.current = true; }, scrollToBottom, getScrollState: () => ({ scrollTop: lastScrollTopRef.current, isAtBottom: wasAtBottomRef.current, visibleCount: visibleMessageCountRef.current, }), })); const filteredMessages = useMemo(() => { if (!searchQuery.trim()) return channelMessages; const query = searchQuery.toLowerCase(); return channelMessages.filter( (msg) => msg.content.toLowerCase().includes(query) || msg.userId.toLowerCase().includes(query), ); }, [channelMessages, searchQuery]); useEffect(() => { isScrolledUpRef.current = isScrolledUp; // When the user returns to the bottom, shrink the window back to the base so // slice(-N) resumes trimming old messages from the top (memory optimization). // Only shrink if we grew above the base — preserves a sub-default saved visibleCount. if (!isScrolledUp) { setVisibleMessageCount((prev) => prev > DEFAULT_VISIBLE_MESSAGE_COUNT ? DEFAULT_VISIBLE_MESSAGE_COUNT : prev, ); } }, [isScrolledUp]); // Reset ref-tracked windowing state when switching channels. // visibleMessageCount is NOT reset here — useState(initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT) // already initializes it correctly on mount, and this effect runs once on mount for the // same channelKey (each instance is bound to exactly one channel by the parent key={}). // biome-ignore lint/correctness/useExhaustiveDependencies: intentional full reset on channel change useEffect(() => { prevFilteredLengthRef.current = 0; prevFirstMsgIdRef.current = null; prevScrollHeightRef.current = 0; pendingPrependRef.current = false; resizeObserverPrevSHRef.current = 0; }, [channelKey]); const displayedMessages = useMemo(() => { if (searchQuery.trim()) return filteredMessages; return filteredMessages.slice(-visibleMessageCount); }, [filteredMessages, visibleMessageCount, searchQuery]); const locallyHidden = filteredMessages.length > displayedMessages.length; const serverHasMore = channel?.hasMoreHistory === true; const hasMoreMessages = locallyHidden || serverHasMore; const eventGroups = useMemo( () => groupConsecutiveEvents(displayedMessages), [displayedMessages], ); const isLoadingHistory = channel?.isLoadingHistory ?? false; // Scroll to bottom on initial mount, unless a saved position was passed in. // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount only useEffect(() => { const container = messagesContainerRef.current; if (!container) return; if (initialScrollState) { container.scrollTop = initialScrollState.scrollTop; lastScrollTopRef.current = initialScrollState.scrollTop; wasAtBottomRef.current = false; } else { container.scrollTop = container.scrollHeight; lastScrollTopRef.current = container.scrollHeight; wasAtBottomRef.current = true; } }, []); // Scroll to bottom after initial join history loads; clear fetch spinner at batch end. const wasLoadingHistoryRef = useRef(false); // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is stable via useCallback; refs and setters are stable useLayoutEffect(() => { if (wasLoadingHistoryRef.current && !isLoadingHistory) { if (isFetchingMoreRef.current) { // delta correction for scroll position is handled by useLayoutEffect([displayedMessages]) isFetchingMoreRef.current = false; setIsFetchingMore(false); } else { scrollToBottom(); wasAtBottomRef.current = true; } } wasLoadingHistoryRef.current = isLoadingHistory; }, [isLoadingHistory]); // When older messages are prepended, grow the window so they enter displayedMessages. // When new messages arrive at the bottom while the user is scrolled up, also grow the // window to keep the current top messages visible — slice(-N) otherwise slides the // window forward and hides them, incrementing the "N older messages" counter on every // incoming message. Only let the slice trim from the top when the user is at the bottom // (where auto-scroll handles keeping them current). useLayoutEffect(() => { const newLength = filteredMessages.length; const newFirstId = filteredMessages[0]?.id ?? null; const delta = newLength - prevFilteredLengthRef.current; if (prevFilteredLengthRef.current > 0 && delta > 0) { if (newFirstId !== prevFirstMsgIdRef.current) { // Messages prepended (load-more): signal delta-correction to compensate scrollTop. pendingPrependRef.current = true; setVisibleMessageCount((prev) => prev + delta); } else if (isScrolledUpRef.current) { // Messages appended at bottom while user is scrolled up reading history. // Expand the window to prevent top messages from dropping out of the slice. setVisibleMessageCount((prev) => prev + delta); } } prevFilteredLengthRef.current = newLength; prevFirstMsgIdRef.current = newFirstId; }, [filteredMessages]); // Compensate scrollTop when content is prepended above the viewport. // biome-ignore lint/correctness/useExhaustiveDependencies: runs on every displayedMessages render to capture the resulting scrollHeight; refs are stable useLayoutEffect(() => { const container = messagesContainerRef.current; if (!container) return; // Skip while container is display:none — scrollHeight collapses to 0 and would // poison prevScrollHeightRef, causing a huge spurious delta on the next visible render. if (container.clientHeight === 0) return; const prevHeight = prevScrollHeightRef.current; const newHeight = container.scrollHeight; // Only correct when a true load-more prepend happened (flag set by the window-growth // layoutEffect or button handler). Bottom appends slide the slice(-N) window which also // changes displayedMessages[0] — ID-comparison can't distinguish the two cases. const wasPrepend = pendingPrependRef.current; // Only consume the flag when scrollHeight actually changed — the server-side load-more // path goes through two renders: Render A (filteredMessages grows, visibleCount unchanged, // same displayedMessages content, same scrollHeight) then Render B (visibleCount grows, // new messages enter displayedMessages, scrollHeight grows). The flag must survive Render A // so it's still set when Render B fires the actual correction. if (wasPrepend && newHeight !== prevHeight) { pendingPrependRef.current = false; } if ( isScrolledUpRef.current && prevHeight > 0 && newHeight > prevHeight && wasPrepend ) { const delta = newHeight - prevHeight; container.scrollTop += delta; resizeObserverPrevSHRef.current = newHeight; } prevScrollHeightRef.current = newHeight; }, [displayedMessages]); // Re-stick to bottom when inner message content grows (media/audio previews loading). // Uses prevScrollHeight instead of wasAtBottomRef to avoid stale-flag race where the // ref is true while the user is actively scrolling up. // When the container width changes (member list toggle, window resize), text reflows // and scrollHeight changes; preserve proportional scroll position for scrolled-up users. // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is a stable ref useEffect(() => { const container = messagesContainerRef.current; const inner = messagesInnerRef.current; if (!inner || !container) return; resizeObserverPrevSHRef.current = container.scrollHeight; let prevClientWidth = container.clientWidth; const observer = new ResizeObserver(() => { if (container.clientHeight === 0) return; // Effect may re-initialize while container is display:none (ref=0). // Re-seed with current dimensions and skip — no reliable "was at bottom" data. if (resizeObserverPrevSHRef.current === 0) { resizeObserverPrevSHRef.current = container.scrollHeight; prevClientWidth = container.clientWidth; return; } const currentClientWidth = container.clientWidth; const widthChanged = currentClientWidth !== prevClientWidth; prevClientWidth = currentClientWidth; const prevSH = resizeObserverPrevSHRef.current; const wasAtPrevBottom = container.scrollTop + container.clientHeight >= prevSH - SCROLL_TOLERANCE; resizeObserverPrevSHRef.current = container.scrollHeight; if (wasAtPrevBottom) { scrollToBottom(); } else if (widthChanged && prevSH > 0) { const ratio = container.scrollTop / prevSH; container.scrollTop = Math.round(ratio * container.scrollHeight); } }); observer.observe(inner); return () => observer.disconnect(); }, [isLoadingHistory, channelId, privateChatId]); // Auto-scroll on new messages — skip when this channel is hidden (display:none). // biome-ignore lint/correctness/useExhaustiveDependencies: only scroll when messages change, not when isActive changes useEffect(() => { if (!isActive) return; const isNarrowView = window.matchMedia("(max-width: 768px)").matches; const isChatVisible = !isNarrowView || mobileViewActiveColumn === "chatView"; if (wasAtBottomRef.current && isChatVisible) { scrollToBottom(); } }, [displayedMessages, mobileViewActiveColumn, scrollToBottom, isActive]); return ( <>
{isLoadingHistory && !isFetchingMore ? (
) : (
{hasMoreMessages && !searchQuery && (
)} {searchQuery && (
{plural(filteredMessages.length, { one: t`Found 1 message matching "${searchQuery}"`, other: t`Found ${filteredMessages.length} messages matching "${searchQuery}"`, })}
)} {eventGroups.map((group) => { if (group.type === "eventGroup") { const firstId = group.messages[0]?.id || ""; const lastId = group.messages[group.messages.length - 1]?.id || ""; const groupKey = `group-${firstId}-${lastId}`; return ( ); } const message = group.messages[0]; const originalIndex = channelMessages.findIndex( (m) => m.id === message.id, ); const previousMessage = channelMessages[originalIndex - 1]; const showHeader = !previousMessage || previousMessage.type !== "message" || previousMessage.userId !== message.userId || new Date(message.timestamp).getTime() - new Date(previousMessage.timestamp).getTime() > 5 * 60 * 1000; return ( ); })}
)}
); }, ); ChannelMessageList.displayName = "ChannelMessageList"; // Wrap with memo so hidden keep-alive channels skip re-renders when their props // haven't changed (e.g. when messageText changes in the input — the only thing // that changes on typing is local state inside ChatArea, not the props we pass here). export const MemoChannelMessageList = memo(ChannelMessageList); #. placeholder {2}: import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import type * as React from "react"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { SCROLL_TOLERANCE, useScrollToBottom, } from "../../hooks/useScrollToBottom"; import { groupConsecutiveEvents } from "../../lib/eventGrouping"; import ircClient from "../../lib/ircClient"; import useStore from "../../store"; import type { Message as MessageType } from "../../types"; import { CollapsedEventMessage } from "../message/CollapsedEventMessage"; import { MessageItem } from "../message/MessageItem"; import LoadingSpinner from "../ui/LoadingSpinner"; import { ScrollToBottomButton } from "../ui/ScrollToBottomButton"; export const DEFAULT_VISIBLE_MESSAGE_COUNT = 100; // Stable empty array — prevents selector from returning a new [] on every render // when the channel has no messages yet (undefined ?? [] would create a new ref each time). const EMPTY_MESSAGES: import("../../types").Message[] = []; export interface ChannelMessageListHandle { setAtBottom: () => void; scrollToBottom: () => void; getScrollState: () => { scrollTop: number; isAtBottom: boolean; visibleCount: number; }; } interface ChannelMessageListProps { channelKey: string; serverId: string; channelId: string | null; privateChatId: string | null; isActive: boolean; searchQuery: string; isMemberListVisible: boolean; onReply: (msg: MessageType | null) => void; onUsernameContextMenu: ( e: React.MouseEvent, username: string, serverId: string, channelId: string, avatarEl?: Element | null, ) => void; onIrcLinkClick: (url: string) => void; onReactClick: (msg: MessageType, el: Element) => void; onReactionUnreact: (emoji: string, msg: MessageType) => void; onOpenReactionModal: ( msg: MessageType, position: { x: number; y: number }, ) => void; onDirectReaction: (emoji: string, msg: MessageType) => void; onRedactMessage: (msg: MessageType) => void; onOpenProfile: (username: string) => void; joinChannel: (serverId: string, channelName: string) => void; onClearSearch: () => void; highlightedMessageId?: string; // undefined = first visit; null = was at bottom; object = restore to saved position initialScrollState?: { scrollTop: number; visibleCount: number } | null; } export const ChannelMessageList = forwardRef< ChannelMessageListHandle, ChannelMessageListProps >( ( { channelKey, serverId, channelId, privateChatId, isActive, searchQuery, isMemberListVisible, onReply, onUsernameContextMenu, onIrcLinkClick, onReactClick, onReactionUnreact, onOpenReactionModal, onDirectReaction, onRedactMessage, onOpenProfile, joinChannel, onClearSearch, highlightedMessageId, initialScrollState, }, ref, ) => { const { t } = useLingui(); const [visibleMessageCount, setVisibleMessageCount] = useState( initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT, ); // Ref mirror so getScrollState closure always reads the current value without needing it as a dep. const visibleMessageCountRef = useRef(visibleMessageCount); visibleMessageCountRef.current = visibleMessageCount; // Distinguishes initial join (full-screen spinner) from subsequent "load more" (button spinner). const [isFetchingMore, setIsFetchingMore] = useState(false); const isFetchingMoreRef = useRef(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const messagesInnerRef = useRef(null); // prev scrollHeight for prepend delta-correction. const prevScrollHeightRef = useRef(0); // Ref mirror of isScrolledUp — lets useLayoutEffect closures read current value // without listing isScrolledUp as a dep (which would re-run effects on every scroll). const isScrolledUpRef = useRef(false); const prevFilteredLengthRef = useRef(0); const prevFirstMsgIdRef = useRef(null); // Set by the window-growth layoutEffect (or button handler) when a true prepend is detected. // Consumed by the delta-correction layoutEffect one render later (after visibleCount grows). // Using a flag instead of tracking displayedMessages[0]?.id because slice(-N) slides the // window on every bottom append, changing displayedMessages[0] even for non-prepend renders. const pendingPrependRef = useRef(false); // Shared scrollHeight baseline between the delta-correction layout effect and the inner // ResizeObserver. When scrollTop is corrected after a prepend, we update this so the RO's // "was at bottom" check is not fooled by the adjusted scrollTop vs its stale prevSH. const resizeObserverPrevSHRef = useRef(0); const channelMessages = useStore( useCallback( (state) => state.messages[channelKey] ?? EMPTY_MESSAGES, [channelKey], ), ); const servers = useStore((state) => state.servers); const mobileViewActiveColumn = useStore( (state) => state.ui.mobileViewActiveColumn, ); const channel = useMemo( () => channelId ? (servers .find((s) => s.id === serverId) ?.channels.find((c) => c.id === channelId) ?? null) : null, [servers, serverId, channelId], ); const { isScrolledUp, wasAtBottomRef, scrollToBottom } = useScrollToBottom( messagesContainerRef, messagesEndRef, { channelId: `${channelId || privateChatId}-${isMemberListVisible}` }, ); // Snapshot of the last known scroll position captured while the container was visible. // getScrollState() reads this instead of the live DOM because React commits display:none // before running cleanup effects, collapsing scrollTop/scrollHeight/clientHeight to 0. const lastScrollTopRef = useRef(initialScrollState?.scrollTop ?? 0); useEffect(() => { const container = messagesContainerRef.current; if (!container) return; const onScroll = () => { if (container.clientHeight > 0) lastScrollTopRef.current = container.scrollTop; }; container.addEventListener("scroll", onScroll, { passive: true }); return () => container.removeEventListener("scroll", onScroll); }, []); // Restore scroll position when a keep-alive channel transitions from hidden to visible. // display:none may reset scrollTop to 0; lastScrollTopRef was captured while visible. const prevActiveRef = useRef(isActive); useLayoutEffect(() => { if (isActive && !prevActiveRef.current) { const container = messagesContainerRef.current; if (container && lastScrollTopRef.current > 0) { container.scrollTop = lastScrollTopRef.current; } } prevActiveRef.current = isActive; }, [isActive]); useImperativeHandle(ref, () => ({ setAtBottom: () => { wasAtBottomRef.current = true; }, scrollToBottom, getScrollState: () => ({ scrollTop: lastScrollTopRef.current, isAtBottom: wasAtBottomRef.current, visibleCount: visibleMessageCountRef.current, }), })); const filteredMessages = useMemo(() => { if (!searchQuery.trim()) return channelMessages; const query = searchQuery.toLowerCase(); return channelMessages.filter( (msg) => msg.content.toLowerCase().includes(query) || msg.userId.toLowerCase().includes(query), ); }, [channelMessages, searchQuery]); useEffect(() => { isScrolledUpRef.current = isScrolledUp; // When the user returns to the bottom, shrink the window back to the base so // slice(-N) resumes trimming old messages from the top (memory optimization). // Only shrink if we grew above the base — preserves a sub-default saved visibleCount. if (!isScrolledUp) { setVisibleMessageCount((prev) => prev > DEFAULT_VISIBLE_MESSAGE_COUNT ? DEFAULT_VISIBLE_MESSAGE_COUNT : prev, ); } }, [isScrolledUp]); // Reset ref-tracked windowing state when switching channels. // visibleMessageCount is NOT reset here — useState(initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT) // already initializes it correctly on mount, and this effect runs once on mount for the // same channelKey (each instance is bound to exactly one channel by the parent key={}). // biome-ignore lint/correctness/useExhaustiveDependencies: intentional full reset on channel change useEffect(() => { prevFilteredLengthRef.current = 0; prevFirstMsgIdRef.current = null; prevScrollHeightRef.current = 0; pendingPrependRef.current = false; resizeObserverPrevSHRef.current = 0; }, [channelKey]); const displayedMessages = useMemo(() => { if (searchQuery.trim()) return filteredMessages; return filteredMessages.slice(-visibleMessageCount); }, [filteredMessages, visibleMessageCount, searchQuery]); const locallyHidden = filteredMessages.length > displayedMessages.length; const serverHasMore = channel?.hasMoreHistory === true; const hasMoreMessages = locallyHidden || serverHasMore; const eventGroups = useMemo( () => groupConsecutiveEvents(displayedMessages), [displayedMessages], ); const isLoadingHistory = channel?.isLoadingHistory ?? false; // Scroll to bottom on initial mount, unless a saved position was passed in. // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount only useEffect(() => { const container = messagesContainerRef.current; if (!container) return; if (initialScrollState) { container.scrollTop = initialScrollState.scrollTop; lastScrollTopRef.current = initialScrollState.scrollTop; wasAtBottomRef.current = false; } else { container.scrollTop = container.scrollHeight; lastScrollTopRef.current = container.scrollHeight; wasAtBottomRef.current = true; } }, []); // Scroll to bottom after initial join history loads; clear fetch spinner at batch end. const wasLoadingHistoryRef = useRef(false); // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is stable via useCallback; refs and setters are stable useLayoutEffect(() => { if (wasLoadingHistoryRef.current && !isLoadingHistory) { if (isFetchingMoreRef.current) { // delta correction for scroll position is handled by useLayoutEffect([displayedMessages]) isFetchingMoreRef.current = false; setIsFetchingMore(false); } else { scrollToBottom(); wasAtBottomRef.current = true; } } wasLoadingHistoryRef.current = isLoadingHistory; }, [isLoadingHistory]); // When older messages are prepended, grow the window so they enter displayedMessages. // When new messages arrive at the bottom while the user is scrolled up, also grow the // window to keep the current top messages visible — slice(-N) otherwise slides the // window forward and hides them, incrementing the "N older messages" counter on every // incoming message. Only let the slice trim from the top when the user is at the bottom // (where auto-scroll handles keeping them current). useLayoutEffect(() => { const newLength = filteredMessages.length; const newFirstId = filteredMessages[0]?.id ?? null; const delta = newLength - prevFilteredLengthRef.current; if (prevFilteredLengthRef.current > 0 && delta > 0) { if (newFirstId !== prevFirstMsgIdRef.current) { // Messages prepended (load-more): signal delta-correction to compensate scrollTop. pendingPrependRef.current = true; setVisibleMessageCount((prev) => prev + delta); } else if (isScrolledUpRef.current) { // Messages appended at bottom while user is scrolled up reading history. // Expand the window to prevent top messages from dropping out of the slice. setVisibleMessageCount((prev) => prev + delta); } } prevFilteredLengthRef.current = newLength; prevFirstMsgIdRef.current = newFirstId; }, [filteredMessages]); // Compensate scrollTop when content is prepended above the viewport. // biome-ignore lint/correctness/useExhaustiveDependencies: runs on every displayedMessages render to capture the resulting scrollHeight; refs are stable useLayoutEffect(() => { const container = messagesContainerRef.current; if (!container) return; // Skip while container is display:none — scrollHeight collapses to 0 and would // poison prevScrollHeightRef, causing a huge spurious delta on the next visible render. if (container.clientHeight === 0) return; const prevHeight = prevScrollHeightRef.current; const newHeight = container.scrollHeight; // Only correct when a true load-more prepend happened (flag set by the window-growth // layoutEffect or button handler). Bottom appends slide the slice(-N) window which also // changes displayedMessages[0] — ID-comparison can't distinguish the two cases. const wasPrepend = pendingPrependRef.current; // Only consume the flag when scrollHeight actually changed — the server-side load-more // path goes through two renders: Render A (filteredMessages grows, visibleCount unchanged, // same displayedMessages content, same scrollHeight) then Render B (visibleCount grows, // new messages enter displayedMessages, scrollHeight grows). The flag must survive Render A // so it's still set when Render B fires the actual correction. if (wasPrepend && newHeight !== prevHeight) { pendingPrependRef.current = false; } if ( isScrolledUpRef.current && prevHeight > 0 && newHeight > prevHeight && wasPrepend ) { const delta = newHeight - prevHeight; container.scrollTop += delta; resizeObserverPrevSHRef.current = newHeight; } prevScrollHeightRef.current = newHeight; }, [displayedMessages]); // Re-stick to bottom when inner message content grows (media/audio previews loading). // Uses prevScrollHeight instead of wasAtBottomRef to avoid stale-flag race where the // ref is true while the user is actively scrolling up. // When the container width changes (member list toggle, window resize), text reflows // and scrollHeight changes; preserve proportional scroll position for scrolled-up users. // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is a stable ref useEffect(() => { const container = messagesContainerRef.current; const inner = messagesInnerRef.current; if (!inner || !container) return; resizeObserverPrevSHRef.current = container.scrollHeight; let prevClientWidth = container.clientWidth; const observer = new ResizeObserver(() => { if (container.clientHeight === 0) return; // Effect may re-initialize while container is display:none (ref=0). // Re-seed with current dimensions and skip — no reliable "was at bottom" data. if (resizeObserverPrevSHRef.current === 0) { resizeObserverPrevSHRef.current = container.scrollHeight; prevClientWidth = container.clientWidth; return; } const currentClientWidth = container.clientWidth; const widthChanged = currentClientWidth !== prevClientWidth; prevClientWidth = currentClientWidth; const prevSH = resizeObserverPrevSHRef.current; const wasAtPrevBottom = container.scrollTop + container.clientHeight >= prevSH - SCROLL_TOLERANCE; resizeObserverPrevSHRef.current = container.scrollHeight; if (wasAtPrevBottom) { scrollToBottom(); } else if (widthChanged && prevSH > 0) { const ratio = container.scrollTop / prevSH; container.scrollTop = Math.round(ratio * container.scrollHeight); } }); observer.observe(inner); return () => observer.disconnect(); }, [isLoadingHistory, channelId, privateChatId]); // Auto-scroll on new messages — skip when this channel is hidden (display:none). // biome-ignore lint/correctness/useExhaustiveDependencies: only scroll when messages change, not when isActive changes useEffect(() => { if (!isActive) return; const isNarrowView = window.matchMedia("(max-width: 768px)").matches; const isChatVisible = !isNarrowView || mobileViewActiveColumn === "chatView"; if (wasAtBottomRef.current && isChatVisible) { scrollToBottom(); } }, [displayedMessages, mobileViewActiveColumn, scrollToBottom, isActive]); return ( <>
{isLoadingHistory && !isFetchingMore ? (
) : (
{hasMoreMessages && !searchQuery && (
)} {searchQuery && (
{plural(filteredMessages.length, { one: t`Found 1 message matching "${searchQuery}"`, other: t`Found ${filteredMessages.length} messages matching "${searchQuery}"`, })}
)} {eventGroups.map((group) => { if (group.type === "eventGroup") { const firstId = group.messages[0]?.id || ""; const lastId = group.messages[group.messages.length - 1]?.id || ""; const groupKey = `group-${firstId}-${lastId}`; return ( ); } const message = group.messages[0]; const originalIndex = channelMessages.findIndex( (m) => m.id === message.id, ); const previousMessage = channelMessages[originalIndex - 1]; const showHeader = !previousMessage || previousMessage.type !== "message" || previousMessage.userId !== message.userId || new Date(message.timestamp).getTime() - new Date(previousMessage.timestamp).getTime() > 5 * 60 * 1000; return ( ); })}
)}
); }, ); ChannelMessageList.displayName = "ChannelMessageList"; // Wrap with memo so hidden keep-alive channels skip re-renders when their props // haven't changed (e.g. when messageText changes in the input — the only thing // that changes on typing is local state inside ChatArea, not the props we pass here). export const MemoChannelMessageList = memo(ChannelMessageList); @@ -46,6 +56,12 @@ msgstr "{0} e {1} stanno scrivendo..." msgid "{0} is typing..." msgstr "{0} sta scrivendo..." +#. placeholder {0}: networks.length +#. placeholder {1}: networks.length === 1 ? "" : "s" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "{0} network{1} — pick one to join" +msgstr "" + #. placeholder {0}: filteredMessages.length - displayedMessages.length #: src/components/layout/ChannelMessageList.tsx msgid "{0} older messages" @@ -186,6 +202,12 @@ msgstr "Aggiungi maschera di invito (es. nick!*@*, *!*@host.com)" msgid "Add IRC Server" msgstr "Aggiungi server IRC" +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworksPanel.tsx +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Add Network" +msgstr "Aggiungi rete" + #: src/components/message/MessageActions.tsx #: src/components/message/MessageReactions.tsx #: src/components/message/MessageReactions.tsx @@ -205,6 +227,10 @@ msgstr "Aggiungi regola" msgid "Add Server" msgstr "Aggiungi server" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Add your first network" +msgstr "Aggiungi la tua prima rete" + #: src/components/message/JsonLogMessage.tsx msgid "Additional Details" msgstr "Dettagli aggiuntivi" @@ -358,6 +384,10 @@ msgstr "Indietro" msgid "Back to image" msgstr "Torna all'immagine" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Back to network list" +msgstr "" + #: src/components/ui/ModerationModal.tsx msgid "Ban {username} by hostmask (prevents them from rejoining from the same IP/host)" msgstr "Banna {username} per hostmask (impedisce di rientrare dallo stesso IP/host)" @@ -405,6 +435,8 @@ msgstr "Sfoglia tutti i canali del server" #: src/components/ui/AddPrivateChatModal.tsx #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/ChannelSettingsModal.tsx #: src/components/ui/ExternalLinkWarningModal.tsx #: src/components/ui/FloodSettingsModal.tsx @@ -640,6 +672,7 @@ msgid "Configure notification sounds and highlights" msgstr "Configura suoni di notifica ed evidenziazioni" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworksPanel.tsx msgid "Connect" msgstr "Connetti" @@ -759,6 +792,10 @@ msgstr "Elimina canale" msgid "Delete message" msgstr "Elimina messaggio" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Delete network" +msgstr "Elimina rete" + #: src/components/layout/ChannelList.tsx msgid "Delete Private Chat" msgstr "Elimina chat privata" @@ -767,6 +804,10 @@ msgstr "Elimina chat privata" msgid "Delete this message? This cannot be undone." msgstr "Eliminare questo messaggio? Questa azione non può essere annullata." +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Delete this network?" +msgstr "Eliminare questa rete?" + #: src/components/layout/ServerList.tsx #: src/components/mobile/ServerBottomSheet.tsx msgid "Disconnect" @@ -830,10 +871,16 @@ msgstr "Scarica" msgid "e.g., 100:1440" msgstr "es., 100:1440" +#: src/components/ui/BouncerNetworksPanel.tsx #: src/components/ui/ChannelSettingsModal.tsx msgid "Edit" msgstr "Modifica" +#. placeholder {0}: editingNetwork?.attributes.name || editingNetwork?.netid +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Edit {0}" +msgstr "Modifica {0}" + #: src/components/ui/UserProfileModal.tsx msgid "Edit Profile" msgstr "Modifica profilo" @@ -1057,6 +1104,7 @@ msgstr "HOME" msgid "Homepage" msgstr "Homepage" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserProfileModal.tsx msgid "Host" msgstr "Host" @@ -1271,6 +1319,10 @@ msgstr "Ha lasciato il canale" msgid "Let others know when you are typing" msgstr "Avvisa gli altri quando stai scrivendo" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Libera Chat" +msgstr "Libera Chat" + #: src/components/message/LinkPreview.tsx msgid "Link preview" msgstr "Anteprima link" @@ -1299,6 +1351,10 @@ msgstr "Caricamento GIF..." msgid "Loading more channels..." msgstr "Caricamento altri canali..." +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Loading networks from your bouncer…" +msgstr "Caricamento delle reti dal tuo bouncer…" + #: src/components/ui/UserProfileModal.tsx msgid "Loading WHOIS data..." msgstr "Caricamento dati WHOIS..." @@ -1486,9 +1542,15 @@ msgid "Name:" msgstr "Nome:" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx msgid "Network Name" msgstr "Nome rete" +#. placeholder {0}: server?.name ?? bouncerServerId +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Networks on {0}" +msgstr "Reti su {0}" + #: src/components/ui/QuickActions.tsx msgid "New DM" msgstr "Nuovo messaggio privato" @@ -1511,6 +1573,7 @@ msgid "nick!user@host (e.g., spam*!*@*, *!*@badhost.com)" msgstr "nick!user@host (es., spam*!*@*, *!*@badhost.com)" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserSettings.tsx #: src/lib/settings/definitions/allSettings.ts msgid "Nickname" @@ -1570,6 +1633,10 @@ msgstr "Nessun file scelto" msgid "No flood profile" msgstr "Nessun profilo flood" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "no host set" +msgstr "nessun host impostato" + #: src/components/ui/ChannelSettingsModal.tsx msgid "No invitations found" msgstr "Nessun invito trovato" @@ -1610,6 +1677,10 @@ msgstr "Nessun topic impostato" msgid "No unread mentions or messages" msgstr "Nessuna menzione o messaggio non letto" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "No upstream networks yet." +msgstr "Nessuna rete upstream ancora." + #: src/components/ui/AddPrivateChatModal.tsx msgid "No users available" msgstr "Nessun utente disponibile" @@ -1696,6 +1767,10 @@ msgstr "Ops! La rete si è divisa! ⚠️" msgid "Op" msgstr "Op" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Open" +msgstr "" + #: src/components/ui/QuickActions/uiActionConfig.tsx msgid "Open channel configuration settings" msgstr "Apri impostazioni configurazione canale" @@ -1799,6 +1874,10 @@ msgstr "Fissa chat privata" msgid "Pin this private message conversation" msgstr "Fissa questa conversazione privata" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Plaintext" +msgstr "Testo in chiaro" + #: src/components/message/MediaPreview.tsx #: src/components/message/MediaPreview.tsx #: src/components/message/MediaPreview.tsx @@ -1827,6 +1906,7 @@ msgid "PM User" msgstr "Messaggio privato" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx msgid "Port" msgstr "Porta" @@ -1918,6 +1998,7 @@ msgstr "ha reagito a questo messaggio" msgid "Read more" msgstr "Leggi di più" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserProfileModal.tsx #: src/components/ui/UserSettings.tsx #: src/lib/settings/definitions/allSettings.ts @@ -2002,6 +2083,7 @@ msgstr "Regole" msgid "Safe" msgstr "Sicuro" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/ChannelSettingsModal.tsx #: src/components/ui/TopicModal.tsx #: src/components/ui/UserSettings.tsx @@ -2183,6 +2265,10 @@ msgstr "Gli operatori del server sulla rete potrebbero leggere i tuoi messaggi" msgid "Server Password" msgstr "Password del server" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Server Password (PASS)" +msgstr "Password del server (PASS)" + #: src/components/ui/LinkSecurityWarningModal.tsx msgid "Server-to-server communication may use unencrypted connections" msgstr "Le comunicazioni tra server potrebbero usare connessioni non cifrate" @@ -2378,6 +2464,10 @@ msgstr "Tempo (min)" msgid "Time Window (seconds)" msgstr "Finestra temporale (secondi)" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "TLS" +msgstr "TLS" + #: src/components/message/WhisperMessage.tsx #: src/components/message/WhisperMessage.tsx msgid "to" @@ -2426,6 +2516,10 @@ msgstr "Argomento:" msgid "Total: {0}" msgstr "Totale: {0}" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Transport" +msgstr "Trasporto" + #: src/components/ui/UserSettings.tsx msgid "Trusted Sources" msgstr "Fonti attendibili" @@ -2536,6 +2630,7 @@ msgstr "Profilo utente" msgid "User Settings" msgstr "Impostazioni utente" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/InviteUserModal.tsx #: src/components/ui/ModerationModal.tsx msgid "Username" @@ -2683,6 +2778,10 @@ msgstr "Ampio – Portata di protezione estesa" msgid "Will default to 'no reason' if left empty" msgstr "Il valore predefinito sarà 'nessun motivo' se lasciato vuoto" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Yes, delete" +msgstr "Sì, elimina" + #: src/components/message/CollapsedEventMessage.tsx #: src/components/message/EventMessage.tsx msgid "You" @@ -2713,6 +2812,10 @@ msgstr "La tua password account per l'autenticazione" msgid "Your account username for authentication" msgstr "Il tuo nome utente account per l'autenticazione" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Your bouncer doesn't have any networks yet. Add one to get started." +msgstr "Il tuo bouncer non ha ancora nessuna rete. Aggiungine una per iniziare." + #: src/lib/settings/definitions/allSettings.ts msgid "Your default nickname for all servers" msgstr "Il tuo soprannome predefinito per tutti i server" diff --git a/src/locales/ja/messages.mjs b/src/locales/ja/messages.mjs index 018c12eb..dd9cc04a 100644 --- a/src/locales/ja/messages.mjs +++ b/src/locales/ja/messages.mjs @@ -1 +1 @@ -/*eslint-disable*/export const messages=JSON.parse("{\"+5VMrz\":[\"無効なパターン形式です。nick!user@host 形式を使用してください(ワイルドカード * 使用可)\"],\"+6NQQA\":[\"一般サポートチャンネル\"],\"+6NyRG\":[\"クライアント\"],\"+K0AvT\":[\"切断\"],\"+cyFdH\":[\"離席時に表示するデフォルトメッセージ\"],\"+mVPqU\":[\"メッセージ内のMarkdown書式を表示する\"],\"+vqCJH\":[\"認証用のアカウントユーザー名\"],\"+yPBXI\":[\"ファイルを選択\"],\"+zy2Nq\":[\"種類\"],\"/09cao\":[\"リンクセキュリティが低い(レベル \",[\"securityLevel\"],\")\"],\"/3BQ4J\":[\"チャンネル外のユーザーはメッセージを送信できません\"],\"/6BzZF\":[\"メンバーリストを切り替え\"],\"/TNOPk\":[\"ユーザーは離席中です\"],\"/XQgft\":[\"探す\"],\"/cF7Rs\":[\"音量\"],\"/dqduX\":[\"次のページ\"],\"/fc3q4\":[\"すべてのコンテンツ\"],\"/kISDh\":[\"通知音を有効にする\"],\"/n04sB\":[\"Kill\"],\"/rTz0M\":[\"音声\"],\"/rfkZe\":[\"メンションやメッセージに対してサウンドを再生する\"],\"0/0ZGA\":[\"チャンネル名マスク\"],\"0D6j7U\":[\"カスタムルールについて詳しく →\"],\"0XsHcR\":[\"ユーザーをキック\"],\"0ZpE//\":[\"ユーザー数順で並び替え\"],\"0bEPwz\":[\"離席中に設定\"],\"0dGkPt\":[\"チャンネルリストを展開\"],\"0gS7M5\":[\"表示名\"],\"0kS+M8\":[\"サンプルNET\"],\"0rgoY7\":[\"自分で選んだサーバーにのみ接続します\"],\"0wdd7X\":[\"参加\"],\"0wkVYx\":[\"プライベートメッセージ\"],\"111uHX\":[\"リンクプレビュー\"],\"196EG4\":[\"プライベートチャットを削除\"],\"1DSr1i\":[\"アカウントを登録する\"],\"1O/24y\":[\"チャンネルリストを切り替え\"],\"1VPJJ2\":[\"外部リンクの警告\"],\"1ZC/dv\":[\"未読のメンションやメッセージはありません\"],\"1pO1zi\":[\"サーバー名は必須です\"],\"1uwfzQ\":[\"チャンネルトピックを表示\"],\"268g7c\":[\"表示名を入力\"],\"2FOFq1\":[\"ネットワーク上のサーバーオペレーターがメッセージを閲覧できる可能性があります\"],\"2FYpfJ\":[\"詳細\"],\"2HF1Y2\":[[\"inviter\"],\" が \",[\"target\"],\" を \",[\"channel\"],\" に招待しました\"],\"2I70QL\":[\"ユーザープロフィール情報を表示する\"],\"2QYdmE\":[\"ユーザー:\"],\"2QpEjG\":[\"退出しました\"],\"2YE223\":[\"#\",[\"0\"],\" へメッセージ(Enterで改行、Shift+Enterで送信)\"],\"2bimFY\":[\"サーバーパスワードを使用する\"],\"2iTmdZ\":[\"ローカルストレージ:\"],\"2odkwe\":[\"厳格 — より積極的な保護\"],\"2uDhbA\":[\"招待するユーザー名を入力\"],\"2ygf/L\":[\"← 戻る\"],\"2zEgxj\":[\"GIFを検索...\"],\"3RdPhl\":[\"チャンネル名を変更\"],\"3THokf\":[\"Voiceユーザー\"],\"3TSz9S\":[\"最小化\"],\"3jBDvM\":[\"チャンネル表示名\"],\"3ryuFU\":[\"アプリ改善のための任意のクラッシュレポート\"],\"3uBF/8\":[\"ビューアを閉じる\"],\"3uwW8F\":[\"https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"3xf8Kz\":[\"アカウント名を入力...\"],\"4/Rr0R\":[\"現在のチャンネルにユーザーを招待する\"],\"4EZrJN\":[\"ルール\"],\"4JJtW9\":[\"#overflow\"],\"4NqeT4\":[\"フラッドプロファイル (+F)\"],\"4RZQRK\":[\"今何してるの?\"],\"4hfTrB\":[\"ニックネーム\"],\"4n99LO\":[\"すでに \",[\"0\"],\" にいます\"],\"4t6vMV\":[\"短いメッセージの場合は自動的に1行入力に切り替える\"],\"4vsHmf\":[\"時間(分)\"],\"5+INAX\":[\"自分へのメンションを含むメッセージをハイライトする\"],\"5R5Pv/\":[\"Oper名\"],\"678PKt\":[\"ネットワーク名\"],\"6Aih4U\":[\"オフライン\"],\"6CO3WE\":[\"チャンネルに参加するために必要なパスワード。キーを削除するには空欄にしてください。\"],\"6HhMs3\":[\"退出メッセージ\"],\"6V3Ea3\":[\"コピーしました\"],\"6lGV3K\":[\"折りたたむ\"],\"6yFOEi\":[\"oper パスワードを入力...\"],\"7+IHTZ\":[\"ファイルが選択されていません\"],\"73hrRi\":[\"nick!user@host(例:spam*!*@*、*!*@badhost.com)\"],\"7QkKyN\":[\"プライベートメッセージを送信\"],\"7U1W7c\":[\"とても緩め\"],\"7Y1YQj\":[\"本名:\"],\"7YHArF\":[\"— ビューアで開く\"],\"7fjnVl\":[\"ユーザーを検索...\"],\"7jL88x\":[\"このメッセージを削除しますか?この操作は元に戻せません。\"],\"7nGhhM\":[\"今どんな気分ですか?\"],\"7sEpu1\":[\"メンバー — \",[\"0\"]],\"7sNhEz\":[\"ユーザー名\"],\"8H0Q+x\":[\"プロファイルについて詳しく →\"],\"8Phu0A\":[\"ユーザーがニックネームを変更したときに表示する\"],\"8XTG9e\":[\"Operパスワードを入力\"],\"8XsV2J\":[\"再送信\"],\"8ZsakT\":[\"パスワード\"],\"8kR84m\":[\"外部リンクを開こうとしています:\"],\"8lCgih\":[\"ルールを削除\"],\"8p/xVT\":[[\"0\",\"plural\",{\"one\":[[\"1\"]],\"other\":[[\"2\"]]}]],\"8wRzac\":[[\"joinCount\",\"plural\",{\"other\":[[\"joinCount\"],\"回 参加した\"]}]],\"9BMLnJ\":[\"サーバーに再接続\"],\"9OEgyT\":[\"リアクションを追加\"],\"9PQ8m2\":[\"G-Line(グローバルBAN)\"],\"9Qs99X\":[\"メール:\"],\"9QupBP\":[\"パターンを削除\"],\"9bG48P\":[\"送信中\"],\"9f5f0u\":[\"プライバシーに関するご質問はこちら:\"],\"9unqs3\":[\"退席中:\"],\"9v3hwv\":[\"サーバーが見つかりません。\"],\"9zb2WA\":[\"接続中\"],\"A1taO8\":[\"検索\"],\"A2adVi\":[\"入力中通知を送信する\"],\"A9Rhec\":[\"チャンネル名\"],\"AWOSPo\":[\"ズームイン\"],\"AXSpEQ\":[\"接続時にOperになる\"],\"AeXO77\":[\"アカウント\"],\"AhNP40\":[\"シーク\"],\"Ai2U7L\":[\"ホスト\"],\"AjBQnf\":[\"ニックネームを変更しました\"],\"AmXVh6\":[\"https://example.com/avatar.png\"],\"AnRu/j\":[\"返信をキャンセル\"],\"ApSx0O\":[\"\\\"\",[\"searchQuery\"],\"\\\"に一致するメッセージが\",[\"0\"],\"件見つかりました\"],\"AxPAXW\":[\"結果が見つかりません\"],\"AyNqAB\":[\"すべてのサーバーイベントをチャットに表示する\"],\"B/QqGw\":[\"席を外しています\"],\"B8AaMI\":[\"この項目は必須です\"],\"BA2c49\":[\"このサーバーは高度なLISTフィルタリングをサポートしていません\"],\"BDKt3I\":[[\"0\"],\"、\",[\"1\"],\"、\",[\"2\"],\" と他 \",[\"3\"],\" 人が入力中...\"],\"BGul2A\":[\"保存されていない変更があります。保存せずに閉じてもよいですか?\"],\"BIf9fi\":[\"ステータスメッセージ\"],\"BZz3md\":[\"個人ウェブサイト\"],\"Bgm/H7\":[\"複数行のテキスト入力を許可する\"],\"BiQIl1\":[\"このプライベートメッセージの会話をピン留めする\"],\"BlNZZ2\":[\"クリックしてメッセージに移動\"],\"Bowq3c\":[\"オペレーターのみがチャンネルトピックを変更できます\"],\"Btozzp\":[\"この画像の有効期限が切れています\"],\"Bycfjm\":[\"合計:\",[\"0\"]],\"C6IBQc\":[\"JSON全体をコピー\"],\"C9L9wL\":[\"データ収集\"],\"CDq4wC\":[\"ユーザーをモデレート\"],\"CHVRxG\":[\"@\",[\"0\"],\" へメッセージ(Shift+Enterで改行)\"],\"CN9zdR\":[\"Oper名とパスワードは必須です\"],\"CW3sYa\":[\"リアクション \",[\"emoji\"],\" を追加\"],\"CaAkqd\":[\"退出を表示\"],\"CbvaYj\":[\"ニックネームでBAN\"],\"CcK+Ft\":[\"PDF\"],\"Ce8q3L\":[\"チャンネルを選択\"],\"CsekCi\":[\"通常\"],\"D+NlUC\":[\"システム\"],\"D28t6+\":[\"参加して退出しました\"],\"DB8zMK\":[\"適用\"],\"DBcWHr\":[\"カスタム通知音ファイル\"],\"DTy9Xw\":[\"メディアプレビュー\"],\"Dj4pSr\":[\"安全なパスワードを選択してください\"],\"Du+zn+\":[\"検索中...\"],\"Du2T2f\":[\"設定が見つかりません\"],\"DwsSVQ\":[\"フィルターを適用して更新\"],\"E3W/zd\":[\"デフォルトニックネーム\"],\"E6nRW7\":[\"URLをコピー\"],\"E703RG\":[\"モード:\"],\"EAeu1Z\":[\"招待を送信\"],\"EFKJQT\":[\"設定\"],\"EGPQBv\":[\"カスタムフラッドルール (+f)\"],\"ELik0r\":[\"プライバシーポリシー全文を見る\"],\"EPbeC2\":[\"チャンネルトピックを表示または編集する\"],\"EQCDNT\":[\"oper ユーザー名を入力...\"],\"EUvulZ\":[\"\\\"\",[\"searchQuery\"],\"\\\"に一致するメッセージが1件見つかりました\"],\"EatZYJ\":[\"次の画像\"],\"EdQY6l\":[\"なし\"],\"EnqLYU\":[\"サーバーを検索...\"],\"F0OKMc\":[\"サーバーを編集\"],\"F6Int2\":[\"ハイライトを有効にする\"],\"FDoLyE\":[\"最大ユーザー数\"],\"FUU/hZ\":[\"チャットで読み込む外部メディアの量を制御します。\"],\"Fdp03t\":[\"オン\"],\"FfPWR0\":[\"モーダル\"],\"FjkaiT\":[\"ズームアウト\"],\"FlqOE9\":[\"これが意味すること:\"],\"FolHNl\":[\"アカウントと認証を管理する\"],\"Fp2Dif\":[\"サーバーを退出しました\"],\"G5KmCc\":[\"GZ-Line(グローバルZ-Line)\"],\"GDs0lz\":[\"<0>リスク: 機密情報(メッセージ、プライベート会話、認証情報)が、IRCサーバー間に位置するネットワーク管理者や攻撃者に露出する可能性があります。\"],\"GR+2I3\":[\"招待マスクを追加(例:nick!*@*、*!*@host.com)\"],\"GRLyMU\":[\"ポップアウトしたサーバー通知を閉じる\"],\"GlHnXw\":[\"ニックネームの変更に失敗しました: \",[\"error\"],\" \",[\"0\"]],\"GswZF3\":[\"プレビュー:\"],\"GtmO8/\":[\"から\"],\"GtuHUQ\":[\"サーバー上でこのチャンネルの名前を変更します。すべてのユーザーに新しい名前が表示されます。\"],\"GuGfFX\":[\"検索を切り替え\"],\"GxkJXS\":[\"アップロード中...\"],\"GzbwnK\":[\"チャンネルに参加しました\"],\"GzsUDB\":[\"拡張プロフィール\"],\"H/PnT8\":[\"絵文字を挿入\"],\"H6Izzl\":[\"お好みのカラーコード\"],\"H9jIv+\":[\"参加/退出を表示\"],\"HAKBY9\":[\"ファイルをアップロード\"],\"HdE1If\":[\"チャンネル\"],\"Hk4AW9\":[\"お好みの表示名\"],\"HmHDk7\":[\"メンバーを選択\"],\"HrQzPU\":[[\"networkName\"],\" のチャンネル\"],\"I2tXQ5\":[\"@\",[\"0\"],\" へメッセージ(Enterで改行、Shift+Enterで送信)\"],\"I6bw/h\":[\"ユーザーをBAN\"],\"I92Z+b\":[\"通知を有効にする\"],\"I9D72S\":[\"このメッセージを削除してもよいですか?この操作は元に戻せません。\"],\"IA+1wo\":[\"ユーザーがチャンネルからキックされたときに表示する\"],\"IDwkJx\":[\"IRC Operator\"],\"ILlU+s\":[\"情報:\"],\"IUwGEM\":[\"変更を保存\"],\"IVeGK6\":[[\"0\"],\"、\",[\"1\"],\"、\",[\"2\"],\" が入力中...\"],\"IgrLD/\":[\"一時停止\"],\"Im6JED\":[\"WHISPER\"],\"ImOQa9\":[\"返信\"],\"IoHMnl\":[\"最大値は \",[\"0\"],\" です\"],\"IvMj+0\":[\"Op\"],\"J28zul\":[\"接続中...\"],\"J5T9NW\":[\"ユーザー情報\"],\"J8Y5+z\":[\"おっと!ネットワーク分割が発生しました!⚠️\"],\"JBHkBA\":[\"チャンネルを退出しました\"],\"JCwL0Q\":[\"理由を入力(任意)\"],\"JFciKP\":[\"切り替え\"],\"JXGkhG\":[\"チャンネル名を変更する(オペレーターのみ)\"],\"JcD7qf\":[\"その他のアクション\"],\"JdkA+c\":[\"シークレット (+s)\"],\"Jmu12l\":[\"サーバーチャンネル\"],\"JvQ++s\":[\"Markdownを有効にする\"],\"K2jwh/\":[\"WHOISデータがありません\"],\"KAXSwC\":[\"Voice\"],\"KDfTdX\":[\"メッセージを削除\"],\"KKBlUU\":[\"埋め込み\"],\"KM0pLb\":[\"チャンネルへようこそ!\"],\"KR6W2h\":[\"無視を解除\"],\"KV+Bi1\":[\"招待制 (+i)\"],\"KdCtwE\":[\"カウンターをリセットするまでフラッドアクティビティを監視する秒数\"],\"Kkezga\":[\"サーバーパスワード\"],\"KsiQ/8\":[\"ユーザーはチャンネルに参加するために招待が必要です\"],\"L+gB/D\":[\"チャンネル情報\"],\"LC1a7n\":[\"IRCサーバーは、サーバー間リンクのセキュリティレベルが低いと報告しています。これは、ネットワーク内のIRCサーバー間でメッセージが中継される際に、適切に暗号化されていないか、SSL/TLS証明書が正しく検証されない可能性があることを意味します。\"],\"LNfLR5\":[\"キックを表示\"],\"LQb0W/\":[\"すべてのイベントを表示\"],\"LU7/yA\":[\"UI上で表示するための別名です。スペース、絵文字、特殊文字を含めることができます。実際のチャンネル名(\",[\"channelName\"],\")は引き続きIRCコマンドで使用されます。\"],\"LUb9O7\":[\"有効なサーバーポートが必要です\"],\"Lb+BUl\":[\"https://example.com/avatar.jpg\"],\"LcET2C\":[\"プライバシーポリシー\"],\"LcuSDR\":[\"プロフィール情報とメタデータを管理する\"],\"LqLS9B\":[\"ニックネーム変更を表示\"],\"LsDQt2\":[\"チャンネル設定\"],\"LtI9AS\":[\"オーナー\"],\"LuNhhL\":[\"このメッセージにリアクションしました\"],\"M/AZNG\":[\"アバター画像のURL\"],\"M/WIer\":[\"メッセージを送信\"],\"M8er/5\":[\"名前:\"],\"MHk+7g\":[\"前の画像\"],\"MRorGe\":[\"DMを送る\"],\"MVbSGP\":[\"時間ウィンドウ(秒)\"],\"MkpcsT\":[\"メッセージと設定はデバイス上にローカルで保存されます\"],\"N/hDSy\":[\"ボットとしてマーク — 通常は「on」または空欄\"],\"N7TQbE\":[[\"channelName\"],\" にユーザーを招待\"],\"NCca/o\":[\"デフォルトニックネームを入力...\"],\"Nqs6B9\":[\"すべての外部メディアを表示します。URLが不明なサーバーへのリクエストを引き起こす場合があります。\"],\"Nt+9O7\":[\"生のTCPの代わりにWebSocketを使用する\"],\"NxIHzc\":[\"ユーザーを切断\"],\"O+v/cL\":[\"サーバー上のすべてのチャンネルを一覧表示\"],\"ODwSCk\":[\"GIFを送信\"],\"OGQ5kK\":[\"通知音とハイライトを設定する\"],\"OIPt1Z\":[\"メンバーリストのサイドバーを表示/非表示にする\"],\"OKSNq/\":[\"とても厳格\"],\"ONWvwQ\":[\"アップロード\"],\"OVKoQO\":[\"認証用のアカウントパスワード\"],\"ObsidianIRC - Bringing IRC to the future\":[\"ObsidianIRC - IRCを未来へ\"],\"OhCpra\":[\"トピックを設定…\"],\"OkltoQ\":[[\"username\"],\" をニックネームでBANする(同じニックネームでの再参加を防止)\"],\"P+t/Te\":[\"追加データなし\"],\"P42Wcc\":[\"安全\"],\"PD38l0\":[\"チャンネルアバタープレビュー\"],\"PD9mEt\":[\"メッセージを入力...\"],\"PPqfdA\":[\"チャンネル設定を開く\"],\"PSCjfZ\":[\"このチャンネルに表示されるトピックです。すべてのユーザーがトピックを閲覧できます。\"],\"PZCecv\":[\"PDFプレビュー\"],\"PeLgsC\":[[\"c\",\"plural\",{\"other\":[[\"c\"],\"回\"]}]],\"PguS2C\":[\"例外マスクを追加(例:nick!*@*、*!*@host.com)\"],\"Pil5Ty\":[[\"0\"],\" 件中 \",[\"displayedChannelsCount\"],\" 件を表示中\"],\"PqhVlJ\":[\"ユーザーをBAN(ホストマスク)\"],\"Q+chwU\":[\"ユーザー名:\"],\"Q6hhn8\":[\"設定\"],\"QF4a34\":[\"ユーザー名を入力してください\"],\"QGqSZ2\":[\"カラーと書式設定\"],\"QJQd1J\":[\"プロフィールを編集\"],\"QSzGDE\":[\"アイドル\"],\"QUlny5\":[[\"0\"],\" へようこそ!\"],\"Qoq+GP\":[\"もっと読む\"],\"QuSkCF\":[\"チャンネルをフィルター...\"],\"QwUrDZ\":[\"トピックを変更しました: \",[\"topic\"]],\"R0UH07\":[[\"1\"],\"枚中\",[\"0\"],\"枚目の画像\"],\"R7SsBE\":[\"ミュート\"],\"R8rf1X\":[\"クリックしてトピックを設定\"],\"RArB3D\":[[\"username\"],\" によって \",[\"channelName\"],\" からキックされました\"],\"RI3cWd\":[\"ObsidianIRCでIRCの世界を探索しよう\"],\"RMMaN5\":[\"モデレート制 (+m)\"],\"RWw9Lg\":[\"モーダルを閉じる\"],\"RZ2BuZ\":[[\"account\"],\" のアカウント登録には確認が必要です: \",[\"message\"]],\"RySp6q\":[\"コメントを非表示\"],\"SPKQTd\":[\"ニックネームは必須です\"],\"SPVjfj\":[\"空欄の場合は「理由なし」がデフォルトになります\"],\"SQKPvQ\":[\"ユーザーを招待\"],\"SkZcl+\":[\"定義済みのフラッド保護プロファイルを選択してください。これらのプロファイルは、さまざまなユースケースに対してバランスの取れた保護設定を提供します。\"],\"Slr+3C\":[\"最小ユーザー数\"],\"Spnlre\":[[\"target\"],\" を \",[\"channel\"],\" に招待しました\"],\"T/ckN5\":[\"ビューアで開く\"],\"T91vKp\":[\"再生\"],\"TV2Wdu\":[\"データの取り扱いとプライバシー保護について詳しく見る。\"],\"TgFpwD\":[\"適用中...\"],\"TkzSFB\":[\"変更なし\"],\"TtserG\":[\"本名を入力\"],\"Ttz9J1\":[\"パスワードを入力...\"],\"Tz0i8g\":[\"設定\"],\"U3pytU\":[\"管理者\"],\"UDb2YD\":[\"リアクション\"],\"UE4KO5\":[\"*channel*\"],\"UGT5vp\":[\"設定を保存\"],\"UV5hLB\":[\"BANが見つかりません\"],\"Uaj3Nd\":[\"ステータスメッセージ\"],\"Ue3uny\":[\"デフォルト(プロファイルなし)\"],\"UkARhe\":[\"通常 — 標準的な保護\"],\"Umn7Cj\":[\"まだコメントはありません。最初のコメントを投稿しましょう!\"],\"UtUIRh\":[[\"0\"],\" 件の古いメッセージ\"],\"UwzP+U\":[\"セキュア接続\"],\"V0/A4O\":[\"チャンネルオーナー\"],\"V4qgxE\":[\"作成日時(以前、分前)\"],\"V8yTm6\":[\"検索をクリア\"],\"VJMMyz\":[\"ObsidianIRC — IRCを未来へ\"],\"VJScHU\":[\"理由\"],\"VLsmVV\":[\"通知をミュート\"],\"VbyRUy\":[\"コメント\"],\"Vmx0mQ\":[\"設定者:\"],\"VqnIZz\":[\"プライバシーポリシーとデータ取り扱い方針を見る\"],\"VrMygG\":[\"最小文字数は \",[\"0\"],\" 文字です\"],\"VrnTui\":[\"プロフィールに表示される代名詞\"],\"W8E3qn\":[\"認証済みアカウント\"],\"WAakm9\":[\"チャンネルを削除\"],\"WFxTHC\":[\"BANマスクを追加(例:nick!*@*、*!*@host.com)\"],\"WN1g9F\":[\"サーバーホストは必須です\"],\"WRYdXW\":[\"音声の再生位置\"],\"WUOH5B\":[\"ユーザーを無視\"],\"WWEXnZ\":[[\"0\",\"plural\",{\"other\":[\"さらに \",[\"1\"],\" 件表示\"]}]],\"Weq9zb\":[\"一般\"],\"Wfj7Sk\":[\"通知音をミュートまたはミュート解除する\"],\"Wm7gbG\":[\"GitHub:\"],\"WyeHWY\":[\"*spam*\"],\"WzMCru\":[\"ユーザープロフィール\"],\"X6S3lt\":[\"設定、チャンネル、サーバーを検索...\"],\"XEHan5\":[\"このまま続行\"],\"XI1+wb\":[\"無効な形式\"],\"XIXeuC\":[\"@\",[\"0\"],\" へメッセージ\"],\"XMS+k4\":[\"プライベートメッセージを開始\"],\"XWgxXq\":[\"アルバム\"],\"Xd7+IT\":[\"プライベートチャットのピン留めを解除\"],\"Xm/s+u\":[\"表示\"],\"Xp2n93\":[\"サーバーの信頼済みファイルホストからのメディアを表示します。外部サービスへのリクエストは発生しません。\"],\"XvjC4F\":[\"保存中...\"],\"Y/qryO\":[\"検索に一致するユーザーが見つかりません\"],\"YAqRpI\":[[\"account\"],\" のアカウント登録に成功しました: \",[\"message\"]],\"YEfzvP\":[\"トピック保護 (+t)\"],\"YQOn6a\":[\"メンバーリストを折りたたむ\"],\"YRCoE9\":[\"チャンネルオペレーター\"],\"YURQaF\":[\"プロフィールを表示\"],\"YdBSvr\":[\"メディア表示と外部コンテンツを制御する\"],\"Yj6U3V\":[\"中央サーバーなし:\"],\"YjvpGx\":[\"代名詞\"],\"YqH4l4\":[\"キーなし\"],\"YyUPpV\":[\"アカウント:\"],\"ZJSWfw\":[\"サーバーから切断したときに表示されるメッセージ\"],\"ZR1dJ4\":[\"招待\"],\"ZdWg0V\":[\"ブラウザで開く\"],\"ZhRBbl\":[\"メッセージを検索…\"],\"Zmcu3y\":[\"詳細フィルター\"],\"a2/8e5\":[\"トピック設定日時(以降、分前)\"],\"aHKcKc\":[\"前のページ\"],\"aJTbXX\":[\"Operパスワード\"],\"aQryQv\":[\"パターンはすでに存在します\"],\"aW9pLN\":[\"チャンネルに参加できる最大ユーザー数。制限なしの場合は空欄にしてください。\"],\"ah4fmZ\":[\"YouTube、Vimeo、SoundCloudなどの既知のサービスのプレビューも表示します。\"],\"aifXak\":[\"このチャンネルにはメディアがありません\"],\"ap2zBz\":[\"緩め\"],\"az8lvo\":[\"オフ\"],\"azXSNo\":[\"メンバーリストを展開\"],\"azdliB\":[\"アカウントにログイン\"],\"b26wlF\":[\"彼女/彼女の\"],\"bD/+Ei\":[\"厳格\"],\"bQ6BJn\":[\"詳細なフラッド保護ルールを設定します。各ルールは、監視するアクティビティの種類と、しきい値を超えた場合に実行するアクションを指定します。\"],\"beV7+y\":[\"ユーザーは \",[\"channelName\"],\" への参加招待を受け取ります。\"],\"bk84cH\":[\"離席メッセージ\"],\"bkHdLj\":[\"IRCサーバーを追加\"],\"bmQLn5\":[\"ルールを追加\"],\"bwRvnp\":[\"アクション\"],\"c8+EVZ\":[\"認証済みアカウント\"],\"cGYUlD\":[\"メディアプレビューは読み込まれません。\"],\"cLF98o\":[\"コメントを表示 (\",[\"commentCount\"],\")\"],\"cLKIDO\":[\"利用可能なユーザーがいません\"],\"cSgpoS\":[\"プライベートチャットをピン留め\"],\"cde3ce\":[\"<0>\",[\"0\"],\" へメッセージ\"],\"chQsxg\":[\"フォーマット済み出力をコピー\"],\"cl/A5J\":[[\"__DEFAULT_IRC_SERVER_NAME__\"],\" へようこそ!\"],\"cnGeoo\":[\"削除\"],\"coPLXT\":[\"IRC通信はサーバーに保存されません\"],\"crYH/6\":[\"SoundCloudプレーヤー\"],\"d3sis4\":[\"サーバーを追加\"],\"d9aN5k\":[[\"username\"],\" をチャンネルから削除する\"],\"dEgA5A\":[\"キャンセル\"],\"dGi1We\":[\"このプライベートメッセージの会話のピン留めを解除する\"],\"dJVuyC\":[[\"channelName\"],\" を退出しました (\",[\"reason\"],\")\"],\"dMtLDE\":[\"宛て\"],\"dXqxlh\":[\"<0>⚠️ セキュリティリスク! この接続は傍受や中間者攻撃に対して脆弱な可能性があります。\"],\"da9Q/R\":[\"チャンネルモードを変更しました\"],\"dhJN3N\":[\"コメントを表示\"],\"dj2xTE\":[\"通知を閉じる\"],\"dpCzmC\":[\"フラッド保護設定\"],\"e9dQpT\":[\"このリンクを新しいタブで開きますか?\"],\"ePK91l\":[\"編集\"],\"eYBDuB\":[\"画像をアップロードするか、動的サイズ変換のための \",[\"size\"],\" 置換を含むURLを入力してください\"],\"edBbee\":[[\"username\"],\" をホストマスクでBANする(同じIP/ホストからの再参加を防止)\"],\"ekfzWq\":[\"ユーザー設定\"],\"elPDWs\":[\"IRCクライアントの使い心地をカスタマイズする\"],\"eu2osY\":[\"<0>💡 推奨事項: このサーバーを信頼し、リスクを理解している場合のみ続行してください。この接続で機密情報やパスワードを共有しないようにしてください。\"],\"euEhbr\":[\"クリックして \",[\"channel\"],\" に参加\"],\"ez3vLd\":[\"複数行入力を有効にする\"],\"f0J5Ki\":[\"サーバー間通信に暗号化されていない接続が使用される可能性があります\"],\"f9BHJk\":[\"ユーザーに警告\"],\"fDOLLd\":[\"チャンネルが見つかりません。\"],\"ffzDkB\":[\"匿名分析:\"],\"fq1GF9\":[\"ユーザーがサーバーから切断したときに表示\"],\"gEF57C\":[\"このサーバーは1種類の接続タイプのみサポートしています\"],\"gJuLUI\":[\"無視リスト\"],\"gNzMrk\":[\"現在のアバター\"],\"gjPWyO\":[\"ニックネームを入力...\"],\"gz6UQ3\":[\"最大化\"],\"h6razj\":[\"チャンネル名マスクを除外\"],\"hG6jnw\":[\"トピックが設定されていません\"],\"hG89Ed\":[\"画像\"],\"hZ6znB\":[\"ポート\"],\"ha+Bz5\":[\"例:100:1440\"],\"hehnjM\":[\"数量\"],\"hzdLuQ\":[\"Voice以上の権限を持つユーザーのみ発言できます\"],\"i0qMbr\":[\"ホーム\"],\"iDNBZe\":[\"通知\"],\"iH8pgl\":[\"戻る\"],\"iL9SZg\":[\"ユーザーをBAN(ニックネーム)\"],\"iNt+3c\":[\"画像に戻る\"],\"iQvi+a\":[\"このサーバーのリンクセキュリティが低い場合に警告しない\"],\"iSLIjg\":[\"接続\"],\"iWXkHH\":[\"Halfop\"],\"iZeTtp\":[\"サーバーホスト\"],\"idD8Ev\":[\"保存済み\"],\"iivqkW\":[\"サインイン日時\"],\"ij+Elv\":[\"画像プレビュー\"],\"ilIWp7\":[\"通知を切り替え\"],\"iuaqvB\":[\"ワイルドカードには * を使用してください。例:baduser!*@*、*!*@spammer.com、troll*!*@*\"],\"ixkTse\":[\"ボット\"],\"j2DGR0\":[\"ホストマスクでBAN\"],\"jA4uoI\":[\"トピック:\"],\"jLXxGK\":[\"https://example.com\"],\"jPSk57\":[\"理由(任意)\"],\"jUV7CU\":[\"アバターをアップロード\"],\"jW5Uwh\":[\"外部メディアの読み込み範囲を制御します。オフ/安全/信頼できるソース/すべてのコンテンツ。\"],\"jXzms5\":[\"添付オプション\"],\"jZlrte\":[\"カラー\"],\"jfC/xh\":[\"連絡先\"],\"jywMpv\":[\"#new-channel-name\"],\"k112DD\":[\"古いメッセージを読み込む\"],\"k3ID0F\":[\"メンバーをフィルター…\"],\"k65gsE\":[\"詳細を見る\"],\"k7Zgob\":[\"接続をキャンセル\"],\"kAVx5h\":[\"招待が見つかりません\"],\"kCLEPU\":[\"接続先\"],\"kF5LKb\":[\"無視パターン:\"],\"kGeOx/\":[[\"0\"],\" に参加\"],\"kITKr8\":[\"チャンネルモードを読み込み中...\"],\"kPpPsw\":[\"あなたはIRC Operatorです\"],\"kWJmRL\":[\"あなた\"],\"kfcRb0\":[\"アバター\"],\"kjMqSj\":[\"JSONをコピー\"],\"krViRy\":[\"JSONとしてコピーするにはクリック\"],\"ks71ra\":[\"例外\"],\"kw4lRv\":[\"チャンネルハーフオペレーター\"],\"kxgIRq\":[\"チャンネルを選択または追加して始めましょう。\"],\"ky6dWe\":[\"アバタープレビュー\"],\"l+GxCv\":[\"チャンネルを読み込み中...\"],\"l+IUVW\":[[\"account\"],\" のアカウント確認に成功しました: \",[\"message\"]],\"l/siQz\":[[\"reconnectCount\",\"plural\",{\"other\":[[\"reconnectCount\"],\"回 再接続した\"]}]],\"l5jmzx\":[[\"0\"],\" と \",[\"1\"],\" が入力中...\"],\"lHy8N5\":[\"さらにチャンネルを読み込み中...\"],\"lbpf14\":[[\"value\"],\"に参加\"],\"lfFsZ4\":[\"チャンネル\"],\"lkNdiH\":[\"アカウント名\"],\"ln500L\":[\"ObsidianIRC\"],\"lnCMdg\":[\"画像をアップロード\"],\"loQxaJ\":[\"戻りました\"],\"lvfaxv\":[\"ホーム\"],\"m16xKo\":[\"追加\"],\"m8flAk\":[\"プレビュー(未アップロード)\"],\"mEPxTp\":[\"<0>⚠️ 注意! 信頼できる送信元のリンクのみ開いてください。悪意のあるリンクはセキュリティやプライバシーを侵害する恐れがあります。\"],\"mHGdhG\":[\"サーバー情報\"],\"mHS8lb\":[\"#\",[\"0\"],\" へメッセージ\"],\"mMYBD9\":[\"広め — より広範な保護範囲\"],\"mTGsPd\":[\"チャンネルトピック\"],\"mU8j6O\":[\"外部メッセージ禁止 (+n)\"],\"mZp8FL\":[\"自動的に1行入力に切り替え\"],\"mdQu8G\":[\"YourNickname\"],\"miSSBQ\":[\"コメント (\",[\"commentCount\"],\")\"],\"mvyLSy\":[\"ユーザーは認証済みです\"],\"mwtcGl\":[\"コメントを閉じる\"],\"mzI/c+\":[\"ダウンロード\"],\"n3fGRk\":[[\"0\"],\" が設定\"],\"nE9jsU\":[\"緩め — 控えめな保護\"],\"nNflMD\":[\"チャンネルを退出\"],\"nPXkBi\":[\"WHOISデータを読み込み中...\"],\"nQnxxF\":[\"#\",[\"0\"],\" へメッセージ(Shift+Enterで改行)\"],\"nWMRxa\":[\"ピン留めを解除\"],\"nkC032\":[\"フラッドプロファイルなし\"],\"o69z4d\":[[\"username\"],\" に警告メッセージを送る\"],\"o9ylQi\":[\"GIFを検索して始めましょう\"],\"oFGkER\":[\"サーバー通知\"],\"oOi11l\":[\"最下部にスクロール\"],\"oQEzQR\":[\"新しいDM\"],\"oXOSPE\":[\"オンライン\"],\"oal760\":[\"サーバーリンクへの中間者攻撃が可能な状態です\"],\"oeqmmJ\":[\"信頼できるソース\"],\"ovBPCi\":[\"デフォルト\"],\"p0Z69r\":[\"パターンを空にすることはできません\"],\"p1KgtK\":[\"音声の読み込みに失敗しました\"],\"p59pEv\":[\"詳細情報\"],\"p7sRI6\":[\"入力中であることを他のユーザーに知らせる\"],\"pBm1od\":[\"シークレットチャンネル\"],\"pNmiXx\":[\"すべてのサーバーで使用するデフォルトのニックネーム\"],\"pUUo9G\":[\"ホスト名:\"],\"pVGPmz\":[\"アカウントパスワード\"],\"peNE68\":[\"永続的\"],\"plhHQt\":[\"データなし\"],\"pm6+q5\":[\"セキュリティ警告\"],\"pn5qSs\":[\"追加情報\"],\"q0cR4S\":[\"は現在 **\",[\"newNick\"],\"** として知られています\"],\"qFcunY\":[\"LIST または NAMES コマンドにチャンネルが表示されません\"],\"qLpTm/\":[\"リアクション \",[\"emoji\"],\" を削除\"],\"qVkGWK\":[\"ピン留め\"],\"qY8wNa\":[\"ホームページ\"],\"qb0xJ7\":[\"ワイルドカードを使用してください:* は任意の文字列、? は任意の1文字に一致します。例:nick!*@*、*!*@host.com、*!*user@*\"],\"qhzpRq\":[\"チャンネルキー (+k)\"],\"qtoOYG\":[\"制限なし\"],\"r1W2AS\":[\"ファイルホスト画像\"],\"rIPR2O\":[\"トピック設定日時(以前、分前)\"],\"rMMSYo\":[\"最大文字数は \",[\"0\"],\" 文字です\"],\"rWtzQe\":[\"ネットワークが分割され、再接続されました。✅\"],\"rYG2u6\":[\"しばらくお待ちください...\"],\"rdUucN\":[\"プレビュー\"],\"rjGI/Q\":[\"プライバシー\"],\"rk8iDX\":[\"GIFを読み込み中...\"],\"rn6SBY\":[\"ミュート解除\"],\"s/UKqq\":[\"チャンネルからキックされました\"],\"s8cATI\":[[\"channelName\"],\" に参加しました\"],\"sCO9ue\":[\"<0>\",[\"serverName\"],\" への接続には次のセキュリティ上の懸念事項があります:\"],\"sGH11W\":[\"サーバー\"],\"sHI1H+\":[\"は現在 **\",[\"newNick\"],\"** として知られています\"],\"sJyV04\":[[\"inviter\"],\" があなたを \",[\"channel\"],\" に招待しました\"],\"sby+1/\":[\"クリックしてコピー\"],\"sfN25C\":[\"本名またはフルネーム\"],\"sliuzR\":[\"リンクを開く\"],\"sqrO9R\":[\"カスタムメンション\"],\"sr6RdJ\":[\"Shift+Enterで複数行入力\"],\"swrCpB\":[[\"user\"],\" がチャンネルを \",[\"oldName\"],\" から \",[\"newName\"],\" に変更しました\",[\"0\"]],\"sxkWRg\":[\"詳細設定\"],\"t/YqKh\":[\"削除\"],\"t47eHD\":[\"このサーバーでの固有識別子\"],\"tAkAh0\":[\"動的サイズ変換のための \",[\"size\"],\" 置換を含むURL。例:https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"tXLJS3\":[\"チャンネルリストのサイドバーを表示/非表示にする\"],\"tfDRzk\":[\"保存\"],\"tiBsJk\":[[\"channelName\"],\" を退出しました\"],\"tt4/UD\":[\"退出しました (\",[\"reason\"],\")\"],\"u0TcnO\":[\"ニックネーム {nick} は既に使用中です。{newNick} で再試行します\"],\"u0a8B4\":[\"管理者アクセスのためにIRC Operatorとして認証する\"],\"u0rWFU\":[\"作成日時(以降、分前)\"],\"u72w3t\":[\"無視するユーザーとパターン\"],\"u7jc2L\":[\"退出しました\"],\"uAQUqI\":[\"ステータス\"],\"uB85T3\":[\"保存失敗:\",[\"msg\"]],\"uV3DOL\":[\"G-Line\"],\"uW3lLI\":[\"IRCサーバー:\"],\"usSSr/\":[\"ズームレベル\"],\"v7uvcf\":[\"ソフトウェア:\"],\"vE8kb+\":[\"Shift+Enterで改行(Enterで送信)\"],\"vERlcd\":[\"プロフィール\"],\"vK0RL8\":[\"トピックなし\"],\"vSJd18\":[\"動画\"],\"vXIe7J\":[\"言語\"],\"vaHYxN\":[\"本名\"],\"vhjbKr\":[\"離席中\"],\"w4NYox\":[[\"title\"],\" クライアント\"],\"w8xQRx\":[\"無効な値\"],\"wFjjxZ\":[[\"username\"],\" によって \",[\"channelName\"],\" からキックされました (\",[\"reason\"],\")\"],\"wGjaGl\":[\"BAN例外が見つかりません\"],\"wPrGnM\":[\"チャンネル管理者\"],\"wRkP2d\":[\"GIF\"],\"wbm86v\":[\"ユーザーがチャンネルに参加・退出したときに表示する\"],\"whqZ9r\":[\"ハイライトする追加の単語またはフレーズ\"],\"wm7RV4\":[\"通知音\"],\"wz/Yoq\":[\"サーバー間で中継される際にメッセージが傍受される可能性があります\"],\"xCJdfg\":[\"クリア\"],\"xUHRTR\":[\"接続時に自動的にOperatorとして認証する\"],\"xWHwwQ\":[\"BAN一覧\"],\"xYilR2\":[\"メディア\"],\"xceQrO\":[\"安全なWebSocketのみサポートされています\"],\"xdtXa+\":[\"チャンネル名\"],\"xfXC7q\":[\"テキストチャンネル\"],\"xlCYOE\":[\"メッセージを取得中...\"],\"xlhswE\":[\"最小値は \",[\"0\"],\" です\"],\"xq97Ci\":[\"単語またはフレーズを追加...\"],\"xuRqRq\":[\"クライアント制限 (+l)\"],\"xwF+7J\":[[\"0\"],\" が入力中...\"],\"yNeucF\":[\"このサーバーは拡張プロフィールメタデータ(IRCv3 METADATA拡張)をサポートしていません。アバター、表示名、ステータスなどの追加フィールドは利用できません。\"],\"yPlrca\":[\"チャンネルアバター\"],\"yQE2r9\":[\"読み込み中\"],\"ySU+JY\":[\"your@email.com\"],\"yTX1Rt\":[\"Operユーザー名\"],\"yYOzWD\":[\"ログ\"],\"yfx9Re\":[\"IRC Operatorパスワード\"],\"ygCKqB\":[\"停止\"],\"ymDxJx\":[\"IRC Operatorユーザー名\"],\"yrpRsQ\":[\"名前順で並び替え\"],\"yz7wBu\":[\"閉じる\"],\"zJw+jA\":[\"モードを設定: \",[\"0\"]],\"zebeLu\":[\"Operユーザー名を入力\"],\"zpr0Bw\":[\"GZ-Line\"]}"); \ No newline at end of file +/*eslint-disable*/export const messages=JSON.parse("{\"+5VMrz\":[\"無効なパターン形式です。nick!user@host 形式を使用してください(ワイルドカード * 使用可)\"],\"+6NQQA\":[\"一般サポートチャンネル\"],\"+6NyRG\":[\"クライアント\"],\"+K0AvT\":[\"切断\"],\"+cyFdH\":[\"離席時に表示するデフォルトメッセージ\"],\"+mVPqU\":[\"メッセージ内のMarkdown書式を表示する\"],\"+vqCJH\":[\"認証用のアカウントユーザー名\"],\"+yPBXI\":[\"ファイルを選択\"],\"+zy2Nq\":[\"種類\"],\"/09cao\":[\"リンクセキュリティが低い(レベル \",[\"securityLevel\"],\")\"],\"/3BQ4J\":[\"チャンネル外のユーザーはメッセージを送信できません\"],\"/6BzZF\":[\"メンバーリストを切り替え\"],\"/TNOPk\":[\"ユーザーは離席中です\"],\"/XQgft\":[\"探す\"],\"/cF7Rs\":[\"音量\"],\"/dqduX\":[\"次のページ\"],\"/fc3q4\":[\"すべてのコンテンツ\"],\"/kISDh\":[\"通知音を有効にする\"],\"/n04sB\":[\"Kill\"],\"/rTz0M\":[\"音声\"],\"/rfkZe\":[\"メンションやメッセージに対してサウンドを再生する\"],\"0/0ZGA\":[\"チャンネル名マスク\"],\"0D6j7U\":[\"カスタムルールについて詳しく →\"],\"0XsHcR\":[\"ユーザーをキック\"],\"0ZpE//\":[\"ユーザー数順で並び替え\"],\"0bEPwz\":[\"離席中に設定\"],\"0dGkPt\":[\"チャンネルリストを展開\"],\"0gS7M5\":[\"表示名\"],\"0kS+M8\":[\"サンプルNET\"],\"0rgoY7\":[\"自分で選んだサーバーにのみ接続します\"],\"0wdd7X\":[\"参加\"],\"0wkVYx\":[\"プライベートメッセージ\"],\"111uHX\":[\"リンクプレビュー\"],\"196EG4\":[\"プライベートチャットを削除\"],\"1DSr1i\":[\"アカウントを登録する\"],\"1O/24y\":[\"チャンネルリストを切り替え\"],\"1TNIig\":[\"Open\"],\"1VPJJ2\":[\"外部リンクの警告\"],\"1ZC/dv\":[\"未読のメンションやメッセージはありません\"],\"1pO1zi\":[\"サーバー名は必須です\"],\"1uwfzQ\":[\"チャンネルトピックを表示\"],\"268g7c\":[\"表示名を入力\"],\"2FOFq1\":[\"ネットワーク上のサーバーオペレーターがメッセージを閲覧できる可能性があります\"],\"2FYpfJ\":[\"詳細\"],\"2HF1Y2\":[[\"inviter\"],\" が \",[\"target\"],\" を \",[\"channel\"],\" に招待しました\"],\"2I70QL\":[\"ユーザープロフィール情報を表示する\"],\"2QYdmE\":[\"ユーザー:\"],\"2QpEjG\":[\"退出しました\"],\"2YE223\":[\"#\",[\"0\"],\" へメッセージ(Enterで改行、Shift+Enterで送信)\"],\"2bimFY\":[\"サーバーパスワードを使用する\"],\"2iTmdZ\":[\"ローカルストレージ:\"],\"2odkwe\":[\"厳格 — より積極的な保護\"],\"2uDhbA\":[\"招待するユーザー名を入力\"],\"2ygf/L\":[\"← 戻る\"],\"2zEgxj\":[\"GIFを検索...\"],\"3RdPhl\":[\"チャンネル名を変更\"],\"3THokf\":[\"Voiceユーザー\"],\"3TSz9S\":[\"最小化\"],\"3jBDvM\":[\"チャンネル表示名\"],\"3ryuFU\":[\"アプリ改善のための任意のクラッシュレポート\"],\"3uBF/8\":[\"ビューアを閉じる\"],\"3uwW8F\":[\"https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"3xf8Kz\":[\"アカウント名を入力...\"],\"4/Rr0R\":[\"現在のチャンネルにユーザーを招待する\"],\"4EZrJN\":[\"ルール\"],\"4JJtW9\":[\"#overflow\"],\"4NqeT4\":[\"フラッドプロファイル (+F)\"],\"4RZQRK\":[\"今何してるの?\"],\"4hfTrB\":[\"ニックネーム\"],\"4n99LO\":[\"すでに \",[\"0\"],\" にいます\"],\"4t6vMV\":[\"短いメッセージの場合は自動的に1行入力に切り替える\"],\"4vsHmf\":[\"時間(分)\"],\"4x/Axu\":[\"バウンサーにはまだネットワークがありません。最初のネットワークを追加して始めましょう。\"],\"5+INAX\":[\"自分へのメンションを含むメッセージをハイライトする\"],\"5R5Pv/\":[\"Oper名\"],\"678PKt\":[\"ネットワーク名\"],\"6Aih4U\":[\"オフライン\"],\"6CO3WE\":[\"チャンネルに参加するために必要なパスワード。キーを削除するには空欄にしてください。\"],\"6HhMs3\":[\"退出メッセージ\"],\"6V3Ea3\":[\"コピーしました\"],\"6lGV3K\":[\"折りたたむ\"],\"6yFOEi\":[\"oper パスワードを入力...\"],\"7+IHTZ\":[\"ファイルが選択されていません\"],\"73fnil\":[\"TLS\"],\"73hrRi\":[\"nick!user@host(例:spam*!*@*、*!*@badhost.com)\"],\"7QkKyN\":[\"プライベートメッセージを送信\"],\"7U1W7c\":[\"とても緩め\"],\"7Y1YQj\":[\"本名:\"],\"7YHArF\":[\"— ビューアで開く\"],\"7fjnVl\":[\"ユーザーを検索...\"],\"7jL88x\":[\"このメッセージを削除しますか?この操作は元に戻せません。\"],\"7nGhhM\":[\"今どんな気分ですか?\"],\"7sEpu1\":[\"メンバー — \",[\"0\"]],\"7sNhEz\":[\"ユーザー名\"],\"8H0Q+x\":[\"プロファイルについて詳しく →\"],\"8Phu0A\":[\"ユーザーがニックネームを変更したときに表示する\"],\"8XTG9e\":[\"Operパスワードを入力\"],\"8XsV2J\":[\"再送信\"],\"8ZsakT\":[\"パスワード\"],\"8kR84m\":[\"外部リンクを開こうとしています:\"],\"8lCgih\":[\"ルールを削除\"],\"8p/xVT\":[[\"0\",\"plural\",{\"one\":[[\"1\"]],\"other\":[[\"2\"]]}]],\"8wRzac\":[[\"joinCount\",\"plural\",{\"other\":[[\"joinCount\"],\"回 参加した\"]}]],\"9BMLnJ\":[\"サーバーに再接続\"],\"9OEgyT\":[\"リアクションを追加\"],\"9PQ8m2\":[\"G-Line(グローバルBAN)\"],\"9Qs99X\":[\"メール:\"],\"9QupBP\":[\"パターンを削除\"],\"9W7tl5\":[\"(変更なし)\"],\"9bG48P\":[\"送信中\"],\"9f5f0u\":[\"プライバシーに関するご質問はこちら:\"],\"9iweoP\":[[\"0\"],\" のネットワーク\"],\"9unqs3\":[\"退席中:\"],\"9v3hwv\":[\"サーバーが見つかりません。\"],\"9zb2WA\":[\"接続中\"],\"A1taO8\":[\"検索\"],\"A2adVi\":[\"入力中通知を送信する\"],\"A9Rhec\":[\"チャンネル名\"],\"AWOSPo\":[\"ズームイン\"],\"AXSpEQ\":[\"接続時にOperになる\"],\"AeXO77\":[\"アカウント\"],\"AhNP40\":[\"シーク\"],\"Ai2U7L\":[\"ホスト\"],\"AjBQnf\":[\"ニックネームを変更しました\"],\"AmXVh6\":[\"https://example.com/avatar.png\"],\"AnRu/j\":[\"返信をキャンセル\"],\"ApSx0O\":[\"\\\"\",[\"searchQuery\"],\"\\\"に一致するメッセージが\",[\"0\"],\"件見つかりました\"],\"AxPAXW\":[\"結果が見つかりません\"],\"AyNqAB\":[\"すべてのサーバーイベントをチャットに表示する\"],\"B/QqGw\":[\"席を外しています\"],\"B0sB2k\":[\"平文\"],\"B8AaMI\":[\"この項目は必須です\"],\"BA2c49\":[\"このサーバーは高度なLISTフィルタリングをサポートしていません\"],\"BDKt3I\":[[\"0\"],\"、\",[\"1\"],\"、\",[\"2\"],\" と他 \",[\"3\"],\" 人が入力中...\"],\"BGul2A\":[\"保存されていない変更があります。保存せずに閉じてもよいですか?\"],\"BIf9fi\":[\"ステータスメッセージ\"],\"BZz3md\":[\"個人ウェブサイト\"],\"Bgm/H7\":[\"複数行のテキスト入力を許可する\"],\"BiQIl1\":[\"このプライベートメッセージの会話をピン留めする\"],\"BlNZZ2\":[\"クリックしてメッセージに移動\"],\"Bowq3c\":[\"オペレーターのみがチャンネルトピックを変更できます\"],\"Btozzp\":[\"この画像の有効期限が切れています\"],\"Bycfjm\":[\"合計:\",[\"0\"]],\"C6IBQc\":[\"JSON全体をコピー\"],\"C9L9wL\":[\"データ収集\"],\"CDq4wC\":[\"ユーザーをモデレート\"],\"CHVRxG\":[\"@\",[\"0\"],\" へメッセージ(Shift+Enterで改行)\"],\"CN9zdR\":[\"Oper名とパスワードは必須です\"],\"CW3sYa\":[\"リアクション \",[\"emoji\"],\" を追加\"],\"CaAkqd\":[\"退出を表示\"],\"CbvaYj\":[\"ニックネームでBAN\"],\"CcK+Ft\":[\"PDF\"],\"Ce8q3L\":[\"チャンネルを選択\"],\"CsekCi\":[\"通常\"],\"D+NlUC\":[\"システム\"],\"D28t6+\":[\"参加して退出しました\"],\"DB8zMK\":[\"適用\"],\"DBcWHr\":[\"カスタム通知音ファイル\"],\"DTy9Xw\":[\"メディアプレビュー\"],\"Dj4pSr\":[\"安全なパスワードを選択してください\"],\"Du+zn+\":[\"検索中...\"],\"Du2T2f\":[\"設定が見つかりません\"],\"DwsSVQ\":[\"フィルターを適用して更新\"],\"E3W/zd\":[\"デフォルトニックネーム\"],\"E6nRW7\":[\"URLをコピー\"],\"E703RG\":[\"モード:\"],\"EAeu1Z\":[\"招待を送信\"],\"EFKJQT\":[\"設定\"],\"EGPQBv\":[\"カスタムフラッドルール (+f)\"],\"ELik0r\":[\"プライバシーポリシー全文を見る\"],\"EPbeC2\":[\"チャンネルトピックを表示または編集する\"],\"EQCDNT\":[\"oper ユーザー名を入力...\"],\"EUvulZ\":[\"\\\"\",[\"searchQuery\"],\"\\\"に一致するメッセージが1件見つかりました\"],\"EatZYJ\":[\"次の画像\"],\"EdQY6l\":[\"なし\"],\"EnqLYU\":[\"サーバーを検索...\"],\"F0OKMc\":[\"サーバーを編集\"],\"F6Int2\":[\"ハイライトを有効にする\"],\"FDoLyE\":[\"最大ユーザー数\"],\"FUU/hZ\":[\"チャットで読み込む外部メディアの量を制御します。\"],\"Fdp03t\":[\"オン\"],\"FfPWR0\":[\"モーダル\"],\"FjkaiT\":[\"ズームアウト\"],\"FlqOE9\":[\"これが意味すること:\"],\"FolHNl\":[\"アカウントと認証を管理する\"],\"Fp2Dif\":[\"サーバーを退出しました\"],\"G5KmCc\":[\"GZ-Line(グローバルZ-Line)\"],\"GDs0lz\":[\"<0>リスク: 機密情報(メッセージ、プライベート会話、認証情報)が、IRCサーバー間に位置するネットワーク管理者や攻撃者に露出する可能性があります。\"],\"GR+2I3\":[\"招待マスクを追加(例:nick!*@*、*!*@host.com)\"],\"GRLyMU\":[\"ポップアウトしたサーバー通知を閉じる\"],\"GlHnXw\":[\"ニックネームの変更に失敗しました: \",[\"error\"],\" \",[\"0\"]],\"GswZF3\":[\"プレビュー:\"],\"GtmO8/\":[\"から\"],\"GtuHUQ\":[\"サーバー上でこのチャンネルの名前を変更します。すべてのユーザーに新しい名前が表示されます。\"],\"GuGfFX\":[\"検索を切り替え\"],\"GxkJXS\":[\"アップロード中...\"],\"GzbwnK\":[\"チャンネルに参加しました\"],\"GzsUDB\":[\"拡張プロフィール\"],\"H/PnT8\":[\"絵文字を挿入\"],\"H6Izzl\":[\"お好みのカラーコード\"],\"H9jIv+\":[\"参加/退出を表示\"],\"HAKBY9\":[\"ファイルをアップロード\"],\"HdE1If\":[\"チャンネル\"],\"Hk4AW9\":[\"お好みの表示名\"],\"HmHDk7\":[\"メンバーを選択\"],\"HrQzPU\":[[\"networkName\"],\" のチャンネル\"],\"I2tXQ5\":[\"@\",[\"0\"],\" へメッセージ(Enterで改行、Shift+Enterで送信)\"],\"I6bw/h\":[\"ユーザーをBAN\"],\"I92Z+b\":[\"通知を有効にする\"],\"I9D72S\":[\"このメッセージを削除してもよいですか?この操作は元に戻せません。\"],\"IA+1wo\":[\"ユーザーがチャンネルからキックされたときに表示する\"],\"IDwkJx\":[\"IRC Operator\"],\"ILlU+s\":[\"情報:\"],\"IUwGEM\":[\"変更を保存\"],\"IVeGK6\":[[\"0\"],\"、\",[\"1\"],\"、\",[\"2\"],\" が入力中...\"],\"IgrLD/\":[\"一時停止\"],\"Im6JED\":[\"WHISPER\"],\"ImOQa9\":[\"返信\"],\"IoHMnl\":[\"最大値は \",[\"0\"],\" です\"],\"IvMj+0\":[\"Op\"],\"J28zul\":[\"接続中...\"],\"J5T9NW\":[\"ユーザー情報\"],\"J8Y5+z\":[\"おっと!ネットワーク分割が発生しました!⚠️\"],\"JBHkBA\":[\"チャンネルを退出しました\"],\"JCwL0Q\":[\"理由を入力(任意)\"],\"JFciKP\":[\"切り替え\"],\"JXGkhG\":[\"チャンネル名を変更する(オペレーターのみ)\"],\"JcD7qf\":[\"その他のアクション\"],\"JdkA+c\":[\"シークレット (+s)\"],\"Jmu12l\":[\"サーバーチャンネル\"],\"JvQ++s\":[\"Markdownを有効にする\"],\"K2jwh/\":[\"WHOISデータがありません\"],\"KAXSwC\":[\"Voice\"],\"KDfTdX\":[\"メッセージを削除\"],\"KKBlUU\":[\"埋め込み\"],\"KM0pLb\":[\"チャンネルへようこそ!\"],\"KR6W2h\":[\"無視を解除\"],\"KV+Bi1\":[\"招待制 (+i)\"],\"KdCtwE\":[\"カウンターをリセットするまでフラッドアクティビティを監視する秒数\"],\"Kkezga\":[\"サーバーパスワード\"],\"KsiQ/8\":[\"ユーザーはチャンネルに参加するために招待が必要です\"],\"L+gB/D\":[\"チャンネル情報\"],\"LC1a7n\":[\"IRCサーバーは、サーバー間リンクのセキュリティレベルが低いと報告しています。これは、ネットワーク内のIRCサーバー間でメッセージが中継される際に、適切に暗号化されていないか、SSL/TLS証明書が正しく検証されない可能性があることを意味します。\"],\"LNfLR5\":[\"キックを表示\"],\"LP+1Z7\":[\"ネットワークを追加\"],\"LQb0W/\":[\"すべてのイベントを表示\"],\"LU7/yA\":[\"UI上で表示するための別名です。スペース、絵文字、特殊文字を含めることができます。実際のチャンネル名(\",[\"channelName\"],\")は引き続きIRCコマンドで使用されます。\"],\"LUb9O7\":[\"有効なサーバーポートが必要です\"],\"Lb+BUl\":[\"https://example.com/avatar.jpg\"],\"LcET2C\":[\"プライバシーポリシー\"],\"LcuSDR\":[\"プロフィール情報とメタデータを管理する\"],\"LqLS9B\":[\"ニックネーム変更を表示\"],\"LsDQt2\":[\"チャンネル設定\"],\"LtI9AS\":[\"オーナー\"],\"LuNhhL\":[\"このメッセージにリアクションしました\"],\"M/AZNG\":[\"アバター画像のURL\"],\"M/WIer\":[\"メッセージを送信\"],\"M8er/5\":[\"名前:\"],\"MHk+7g\":[\"前の画像\"],\"MRorGe\":[\"DMを送る\"],\"MVbSGP\":[\"時間ウィンドウ(秒)\"],\"MkpcsT\":[\"メッセージと設定はデバイス上にローカルで保存されます\"],\"MzPdC2\":[\"サーバーパスワード (PASS)\"],\"N/hDSy\":[\"ボットとしてマーク — 通常は「on」または空欄\"],\"N6j2JH\":[[\"0\"],\" を編集\"],\"N7TQbE\":[[\"channelName\"],\" にユーザーを招待\"],\"NCca/o\":[\"デフォルトニックネームを入力...\"],\"Nqs6B9\":[\"すべての外部メディアを表示します。URLが不明なサーバーへのリクエストを引き起こす場合があります。\"],\"Nt+9O7\":[\"生のTCPの代わりにWebSocketを使用する\"],\"NxIHzc\":[\"ユーザーを切断\"],\"O+v/cL\":[\"サーバー上のすべてのチャンネルを一覧表示\"],\"OCGpR4\":[\"(継承)\"],\"ODwSCk\":[\"GIFを送信\"],\"OGQ5kK\":[\"通知音とハイライトを設定する\"],\"OIPt1Z\":[\"メンバーリストのサイドバーを表示/非表示にする\"],\"OKSNq/\":[\"とても厳格\"],\"ONWvwQ\":[\"アップロード\"],\"OVKoQO\":[\"認証用のアカウントパスワード\"],\"ObsidianIRC - Bringing IRC to the future\":[\"ObsidianIRC - IRCを未来へ\"],\"OhCpra\":[\"トピックを設定…\"],\"OkltoQ\":[[\"username\"],\" をニックネームでBANする(同じニックネームでの再参加を防止)\"],\"P+t/Te\":[\"追加データなし\"],\"P42Wcc\":[\"安全\"],\"PD38l0\":[\"チャンネルアバタープレビュー\"],\"PD9mEt\":[\"メッセージを入力...\"],\"PPqfdA\":[\"チャンネル設定を開く\"],\"PSCjfZ\":[\"このチャンネルに表示されるトピックです。すべてのユーザーがトピックを閲覧できます。\"],\"PZCecv\":[\"PDFプレビュー\"],\"PeLgsC\":[[\"c\",\"plural\",{\"other\":[[\"c\"],\"回\"]}]],\"PguS2C\":[\"例外マスクを追加(例:nick!*@*、*!*@host.com)\"],\"Pil5Ty\":[[\"0\"],\" 件中 \",[\"displayedChannelsCount\"],\" 件を表示中\"],\"PqhVlJ\":[\"ユーザーをBAN(ホストマスク)\"],\"Q+chwU\":[\"ユーザー名:\"],\"Q3v9Wc\":[\"はい、削除します\"],\"Q6hhn8\":[\"設定\"],\"QF4a34\":[\"ユーザー名を入力してください\"],\"QGqSZ2\":[\"カラーと書式設定\"],\"QJQd1J\":[\"プロフィールを編集\"],\"QSzGDE\":[\"アイドル\"],\"QUlny5\":[[\"0\"],\" へようこそ!\"],\"Qoq+GP\":[\"もっと読む\"],\"QuSkCF\":[\"チャンネルをフィルター...\"],\"QwUrDZ\":[\"トピックを変更しました: \",[\"topic\"]],\"R0UH07\":[[\"1\"],\"枚中\",[\"0\"],\"枚目の画像\"],\"R7SsBE\":[\"ミュート\"],\"R8rf1X\":[\"クリックしてトピックを設定\"],\"RArB3D\":[[\"username\"],\" によって \",[\"channelName\"],\" からキックされました\"],\"RI3cWd\":[\"ObsidianIRCでIRCの世界を探索しよう\"],\"RMMaN5\":[\"モデレート制 (+m)\"],\"RWw9Lg\":[\"モーダルを閉じる\"],\"RZ2BuZ\":[[\"account\"],\" のアカウント登録には確認が必要です: \",[\"message\"]],\"RySp6q\":[\"コメントを非表示\"],\"S5Togi\":[\"バウンサーからネットワークを読み込み中…\"],\"SPKQTd\":[\"ニックネームは必須です\"],\"SPVjfj\":[\"空欄の場合は「理由なし」がデフォルトになります\"],\"SQKPvQ\":[\"ユーザーを招待\"],\"STmlpb\":[\"Back to network list\"],\"SkZcl+\":[\"定義済みのフラッド保護プロファイルを選択してください。これらのプロファイルは、さまざまなユースケースに対してバランスの取れた保護設定を提供します。\"],\"Slr+3C\":[\"最小ユーザー数\"],\"Spnlre\":[[\"target\"],\" を \",[\"channel\"],\" に招待しました\"],\"T/ckN5\":[\"ビューアで開く\"],\"T91vKp\":[\"再生\"],\"TV2Wdu\":[\"データの取り扱いとプライバシー保護について詳しく見る。\"],\"TgFpwD\":[\"適用中...\"],\"TkzSFB\":[\"変更なし\"],\"TtserG\":[\"本名を入力\"],\"Ttz9J1\":[\"パスワードを入力...\"],\"Tz0i8g\":[\"設定\"],\"U3pytU\":[\"管理者\"],\"UDb2YD\":[\"リアクション\"],\"UE4KO5\":[\"*channel*\"],\"UGT5vp\":[\"設定を保存\"],\"UV5hLB\":[\"BANが見つかりません\"],\"Uaj3Nd\":[\"ステータスメッセージ\"],\"Ue3uny\":[\"デフォルト(プロファイルなし)\"],\"UkARhe\":[\"通常 — 標準的な保護\"],\"Umn7Cj\":[\"まだコメントはありません。最初のコメントを投稿しましょう!\"],\"UtUIRh\":[[\"0\"],\" 件の古いメッセージ\"],\"UwzP+U\":[\"セキュア接続\"],\"V0/A4O\":[\"チャンネルオーナー\"],\"V4qgxE\":[\"作成日時(以前、分前)\"],\"V8yTm6\":[\"検索をクリア\"],\"VJMMyz\":[\"ObsidianIRC — IRCを未来へ\"],\"VJScHU\":[\"理由\"],\"VLsmVV\":[\"通知をミュート\"],\"VbyRUy\":[\"コメント\"],\"Vmx0mQ\":[\"設定者:\"],\"VqnIZz\":[\"プライバシーポリシーとデータ取り扱い方針を見る\"],\"VrMygG\":[\"最小文字数は \",[\"0\"],\" 文字です\"],\"VrnTui\":[\"プロフィールに表示される代名詞\"],\"W8E3qn\":[\"認証済みアカウント\"],\"WAakm9\":[\"チャンネルを削除\"],\"WFxTHC\":[\"BANマスクを追加(例:nick!*@*、*!*@host.com)\"],\"WN1g9F\":[\"サーバーホストは必須です\"],\"WRYdXW\":[\"音声の再生位置\"],\"WUOH5B\":[\"ユーザーを無視\"],\"WWEXnZ\":[[\"0\",\"plural\",{\"other\":[\"さらに \",[\"1\"],\" 件表示\"]}]],\"Weq9zb\":[\"一般\"],\"Wfj7Sk\":[\"通知音をミュートまたはミュート解除する\"],\"Wm7gbG\":[\"GitHub:\"],\"WyeHWY\":[\"*spam*\"],\"WzMCru\":[\"ユーザープロフィール\"],\"X6S3lt\":[\"設定、チャンネル、サーバーを検索...\"],\"XEHan5\":[\"このまま続行\"],\"XI1+wb\":[\"無効な形式\"],\"XIXeuC\":[\"@\",[\"0\"],\" へメッセージ\"],\"XMS+k4\":[\"プライベートメッセージを開始\"],\"XWgxXq\":[\"アルバム\"],\"Xd7+IT\":[\"プライベートチャットのピン留めを解除\"],\"Xm/s+u\":[\"表示\"],\"Xp2n93\":[\"サーバーの信頼済みファイルホストからのメディアを表示します。外部サービスへのリクエストは発生しません。\"],\"XvjC4F\":[\"保存中...\"],\"Y/qryO\":[\"検索に一致するユーザーが見つかりません\"],\"YAqRpI\":[[\"account\"],\" のアカウント登録に成功しました: \",[\"message\"]],\"YEfzvP\":[\"トピック保護 (+t)\"],\"YQOn6a\":[\"メンバーリストを折りたたむ\"],\"YRCoE9\":[\"チャンネルオペレーター\"],\"YURQaF\":[\"プロフィールを表示\"],\"YdBSvr\":[\"メディア表示と外部コンテンツを制御する\"],\"Yj6U3V\":[\"中央サーバーなし:\"],\"YjvpGx\":[\"代名詞\"],\"YqH4l4\":[\"キーなし\"],\"YyUPpV\":[\"アカウント:\"],\"ZJSWfw\":[\"サーバーから切断したときに表示されるメッセージ\"],\"ZR1dJ4\":[\"招待\"],\"ZdWg0V\":[\"ブラウザで開く\"],\"ZhRBbl\":[\"メッセージを検索…\"],\"Zmcu3y\":[\"詳細フィルター\"],\"a2/8e5\":[\"トピック設定日時(以降、分前)\"],\"aHKcKc\":[\"前のページ\"],\"aJTbXX\":[\"Operパスワード\"],\"aQryQv\":[\"パターンはすでに存在します\"],\"aW9pLN\":[\"チャンネルに参加できる最大ユーザー数。制限なしの場合は空欄にしてください。\"],\"ah4fmZ\":[\"YouTube、Vimeo、SoundCloudなどの既知のサービスのプレビューも表示します。\"],\"aifXak\":[\"このチャンネルにはメディアがありません\"],\"ap2zBz\":[\"緩め\"],\"az8lvo\":[\"オフ\"],\"azXSNo\":[\"メンバーリストを展開\"],\"azdliB\":[\"アカウントにログイン\"],\"b26wlF\":[\"彼女/彼女の\"],\"bD/+Ei\":[\"厳格\"],\"bQ6BJn\":[\"詳細なフラッド保護ルールを設定します。各ルールは、監視するアクティビティの種類と、しきい値を超えた場合に実行するアクションを指定します。\"],\"beV7+y\":[\"ユーザーは \",[\"channelName\"],\" への参加招待を受け取ります。\"],\"bk84cH\":[\"離席メッセージ\"],\"bkHdLj\":[\"IRCサーバーを追加\"],\"bmQLn5\":[\"ルールを追加\"],\"bv4cFj\":[\"トランスポート\"],\"bwRvnp\":[\"アクション\"],\"c8+EVZ\":[\"認証済みアカウント\"],\"cGYUlD\":[\"メディアプレビューは読み込まれません。\"],\"cLF98o\":[\"コメントを表示 (\",[\"commentCount\"],\")\"],\"cLKIDO\":[\"利用可能なユーザーがいません\"],\"cSgpoS\":[\"プライベートチャットをピン留め\"],\"cde3ce\":[\"<0>\",[\"0\"],\" へメッセージ\"],\"chQsxg\":[\"フォーマット済み出力をコピー\"],\"cl/A5J\":[[\"__DEFAULT_IRC_SERVER_NAME__\"],\" へようこそ!\"],\"cnGeoo\":[\"削除\"],\"coPLXT\":[\"IRC通信はサーバーに保存されません\"],\"crYH/6\":[\"SoundCloudプレーヤー\"],\"cv5DQb\":[\"ホスト未設定\"],\"d3sis4\":[\"サーバーを追加\"],\"d9aN5k\":[[\"username\"],\" をチャンネルから削除する\"],\"dEgA5A\":[\"キャンセル\"],\"dGi1We\":[\"このプライベートメッセージの会話のピン留めを解除する\"],\"dJVuyC\":[[\"channelName\"],\" を退出しました (\",[\"reason\"],\")\"],\"dMtLDE\":[\"宛て\"],\"dXqxlh\":[\"<0>⚠️ セキュリティリスク! この接続は傍受や中間者攻撃に対して脆弱な可能性があります。\"],\"da9Q/R\":[\"チャンネルモードを変更しました\"],\"dhJN3N\":[\"コメントを表示\"],\"dj2xTE\":[\"通知を閉じる\"],\"dpCzmC\":[\"フラッド保護設定\"],\"e9dQpT\":[\"このリンクを新しいタブで開きますか?\"],\"ePK91l\":[\"編集\"],\"eYBDuB\":[\"画像をアップロードするか、動的サイズ変換のための \",[\"size\"],\" 置換を含むURLを入力してください\"],\"edBbee\":[[\"username\"],\" をホストマスクでBANする(同じIP/ホストからの再参加を防止)\"],\"ekfzWq\":[\"ユーザー設定\"],\"elPDWs\":[\"IRCクライアントの使い心地をカスタマイズする\"],\"eu2osY\":[\"<0>💡 推奨事項: このサーバーを信頼し、リスクを理解している場合のみ続行してください。この接続で機密情報やパスワードを共有しないようにしてください。\"],\"euEhbr\":[\"クリックして \",[\"channel\"],\" に参加\"],\"ez3vLd\":[\"複数行入力を有効にする\"],\"f0J5Ki\":[\"サーバー間通信に暗号化されていない接続が使用される可能性があります\"],\"f9BHJk\":[\"ユーザーに警告\"],\"fDOLLd\":[\"チャンネルが見つかりません。\"],\"ffzDkB\":[\"匿名分析:\"],\"fq1GF9\":[\"ユーザーがサーバーから切断したときに表示\"],\"gEF57C\":[\"このサーバーは1種類の接続タイプのみサポートしています\"],\"gJuLUI\":[\"無視リスト\"],\"gNzMrk\":[\"現在のアバター\"],\"gjPWyO\":[\"ニックネームを入力...\"],\"gz6UQ3\":[\"最大化\"],\"h6/IMX\":[\"最初のネットワークを追加\"],\"h6razj\":[\"チャンネル名マスクを除外\"],\"hG6jnw\":[\"トピックが設定されていません\"],\"hG89Ed\":[\"画像\"],\"hZ6znB\":[\"ポート\"],\"ha+Bz5\":[\"例:100:1440\"],\"hehnjM\":[\"数量\"],\"hzdLuQ\":[\"Voice以上の権限を持つユーザーのみ発言できます\"],\"i0qMbr\":[\"ホーム\"],\"iDNBZe\":[\"通知\"],\"iH8pgl\":[\"戻る\"],\"iL9SZg\":[\"ユーザーをBAN(ニックネーム)\"],\"iNt+3c\":[\"画像に戻る\"],\"iQvi+a\":[\"このサーバーのリンクセキュリティが低い場合に警告しない\"],\"iSLIjg\":[\"接続\"],\"iWXkHH\":[\"Halfop\"],\"iZeTtp\":[\"サーバーホスト\"],\"idD8Ev\":[\"保存済み\"],\"iivqkW\":[\"サインイン日時\"],\"ij+Elv\":[\"画像プレビュー\"],\"ilIWp7\":[\"通知を切り替え\"],\"iuaqvB\":[\"ワイルドカードには * を使用してください。例:baduser!*@*、*!*@spammer.com、troll*!*@*\"],\"ixkTse\":[\"ボット\"],\"j2DGR0\":[\"ホストマスクでBAN\"],\"jA4uoI\":[\"トピック:\"],\"jLXxGK\":[\"https://example.com\"],\"jPSk57\":[\"理由(任意)\"],\"jUV7CU\":[\"アバターをアップロード\"],\"jW5Uwh\":[\"外部メディアの読み込み範囲を制御します。オフ/安全/信頼できるソース/すべてのコンテンツ。\"],\"jXzms5\":[\"添付オプション\"],\"jZlrte\":[\"カラー\"],\"jfC/xh\":[\"連絡先\"],\"jywMpv\":[\"#new-channel-name\"],\"k112DD\":[\"古いメッセージを読み込む\"],\"k3ID0F\":[\"メンバーをフィルター…\"],\"k65gsE\":[\"詳細を見る\"],\"k7Zgob\":[\"接続をキャンセル\"],\"kAVx5h\":[\"招待が見つかりません\"],\"kCLEPU\":[\"接続先\"],\"kF5LKb\":[\"無視パターン:\"],\"kGeOx/\":[[\"0\"],\" に参加\"],\"kITKr8\":[\"チャンネルモードを読み込み中...\"],\"kPpPsw\":[\"あなたはIRC Operatorです\"],\"kWJmRL\":[\"あなた\"],\"kfcRb0\":[\"アバター\"],\"kjMqSj\":[\"JSONをコピー\"],\"krViRy\":[\"JSONとしてコピーするにはクリック\"],\"ks71ra\":[\"例外\"],\"kw4lRv\":[\"チャンネルハーフオペレーター\"],\"kxgIRq\":[\"チャンネルを選択または追加して始めましょう。\"],\"ky6dWe\":[\"アバタープレビュー\"],\"l+GxCv\":[\"チャンネルを読み込み中...\"],\"l+IUVW\":[[\"account\"],\" のアカウント確認に成功しました: \",[\"message\"]],\"l/siQz\":[[\"reconnectCount\",\"plural\",{\"other\":[[\"reconnectCount\"],\"回 再接続した\"]}]],\"l5jmzx\":[[\"0\"],\" と \",[\"1\"],\" が入力中...\"],\"lHy8N5\":[\"さらにチャンネルを読み込み中...\"],\"lbpf14\":[[\"value\"],\"に参加\"],\"lfFsZ4\":[\"チャンネル\"],\"lkNdiH\":[\"アカウント名\"],\"ln500L\":[\"ObsidianIRC\"],\"lnCMdg\":[\"画像をアップロード\"],\"loQxaJ\":[\"戻りました\"],\"lvfaxv\":[\"ホーム\"],\"m0oxpP\":[\"Libera Chat\"],\"m16xKo\":[\"追加\"],\"m8flAk\":[\"プレビュー(未アップロード)\"],\"mEPxTp\":[\"<0>⚠️ 注意! 信頼できる送信元のリンクのみ開いてください。悪意のあるリンクはセキュリティやプライバシーを侵害する恐れがあります。\"],\"mHGdhG\":[\"サーバー情報\"],\"mHS8lb\":[\"#\",[\"0\"],\" へメッセージ\"],\"mMYBD9\":[\"広め — より広範な保護範囲\"],\"mTGsPd\":[\"チャンネルトピック\"],\"mU8j6O\":[\"外部メッセージ禁止 (+n)\"],\"mZp8FL\":[\"自動的に1行入力に切り替え\"],\"mdQu8G\":[\"YourNickname\"],\"miSSBQ\":[\"コメント (\",[\"commentCount\"],\")\"],\"mvyLSy\":[\"ユーザーは認証済みです\"],\"mwtcGl\":[\"コメントを閉じる\"],\"myL0MR\":[\"このネットワークを削除しますか?\"],\"mzI/c+\":[\"ダウンロード\"],\"n3fGRk\":[[\"0\"],\" が設定\"],\"nE9jsU\":[\"緩め — 控えめな保護\"],\"nNflMD\":[\"チャンネルを退出\"],\"nPXkBi\":[\"WHOISデータを読み込み中...\"],\"nQnxxF\":[\"#\",[\"0\"],\" へメッセージ(Shift+Enterで改行)\"],\"nWMRxa\":[\"ピン留めを解除\"],\"nkC032\":[\"フラッドプロファイルなし\"],\"o69z4d\":[[\"username\"],\" に警告メッセージを送る\"],\"o9ylQi\":[\"GIFを検索して始めましょう\"],\"oFGkER\":[\"サーバー通知\"],\"oOi11l\":[\"最下部にスクロール\"],\"oQEzQR\":[\"新しいDM\"],\"oXOSPE\":[\"オンライン\"],\"oal760\":[\"サーバーリンクへの中間者攻撃が可能な状態です\"],\"oeqmmJ\":[\"信頼できるソース\"],\"ovBPCi\":[\"デフォルト\"],\"p0Z69r\":[\"パターンを空にすることはできません\"],\"p1KgtK\":[\"音声の読み込みに失敗しました\"],\"p59pEv\":[\"詳細情報\"],\"p7sRI6\":[\"入力中であることを他のユーザーに知らせる\"],\"pBm1od\":[\"シークレットチャンネル\"],\"pNmiXx\":[\"すべてのサーバーで使用するデフォルトのニックネーム\"],\"pUUo9G\":[\"ホスト名:\"],\"pVGPmz\":[\"アカウントパスワード\"],\"peNE68\":[\"永続的\"],\"plhHQt\":[\"データなし\"],\"pm6+q5\":[\"セキュリティ警告\"],\"pn5qSs\":[\"追加情報\"],\"q0cR4S\":[\"は現在 **\",[\"newNick\"],\"** として知られています\"],\"qFcunY\":[\"LIST または NAMES コマンドにチャンネルが表示されません\"],\"qLpTm/\":[\"リアクション \",[\"emoji\"],\" を削除\"],\"qVkGWK\":[\"ピン留め\"],\"qY8wNa\":[\"ホームページ\"],\"qb0xJ7\":[\"ワイルドカードを使用してください:* は任意の文字列、? は任意の1文字に一致します。例:nick!*@*、*!*@host.com、*!*user@*\"],\"qhzpRq\":[\"チャンネルキー (+k)\"],\"qtoOYG\":[\"制限なし\"],\"r1W2AS\":[\"ファイルホスト画像\"],\"rIPR2O\":[\"トピック設定日時(以前、分前)\"],\"rMMSYo\":[\"最大文字数は \",[\"0\"],\" 文字です\"],\"rWtzQe\":[\"ネットワークが分割され、再接続されました。✅\"],\"rYG2u6\":[\"しばらくお待ちください...\"],\"rdUucN\":[\"プレビュー\"],\"rjGI/Q\":[\"プライバシー\"],\"rk8iDX\":[\"GIFを読み込み中...\"],\"rn6SBY\":[\"ミュート解除\"],\"s/UKqq\":[\"チャンネルからキックされました\"],\"s8cATI\":[[\"channelName\"],\" に参加しました\"],\"sCO9ue\":[\"<0>\",[\"serverName\"],\" への接続には次のセキュリティ上の懸念事項があります:\"],\"sGH11W\":[\"サーバー\"],\"sHI1H+\":[\"は現在 **\",[\"newNick\"],\"** として知られています\"],\"sJyV04\":[[\"inviter\"],\" があなたを \",[\"channel\"],\" に招待しました\"],\"sUBSbK\":[\"アップストリームネットワークがまだありません。\"],\"sby+1/\":[\"クリックしてコピー\"],\"sfN25C\":[\"本名またはフルネーム\"],\"sliuzR\":[\"リンクを開く\"],\"sqrO9R\":[\"カスタムメンション\"],\"sr6RdJ\":[\"Shift+Enterで複数行入力\"],\"swrCpB\":[[\"user\"],\" がチャンネルを \",[\"oldName\"],\" から \",[\"newName\"],\" に変更しました\",[\"0\"]],\"sxkWRg\":[\"詳細設定\"],\"t/YqKh\":[\"削除\"],\"t47eHD\":[\"このサーバーでの固有識別子\"],\"tAkAh0\":[\"動的サイズ変換のための \",[\"size\"],\" 置換を含むURL。例:https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"tXLJS3\":[\"チャンネルリストのサイドバーを表示/非表示にする\"],\"tfDRzk\":[\"保存\"],\"tiBsJk\":[[\"channelName\"],\" を退出しました\"],\"tt4/UD\":[\"退出しました (\",[\"reason\"],\")\"],\"u0TcnO\":[\"ニックネーム {nick} は既に使用中です。{newNick} で再試行します\"],\"u0a8B4\":[\"管理者アクセスのためにIRC Operatorとして認証する\"],\"u0rWFU\":[\"作成日時(以降、分前)\"],\"u72w3t\":[\"無視するユーザーとパターン\"],\"u7jc2L\":[\"退出しました\"],\"uAQUqI\":[\"ステータス\"],\"uB85T3\":[\"保存失敗:\",[\"msg\"]],\"uV3DOL\":[\"G-Line\"],\"uW3lLI\":[\"IRCサーバー:\"],\"usSSr/\":[\"ズームレベル\"],\"v7uvcf\":[\"ソフトウェア:\"],\"vE8kb+\":[\"Shift+Enterで改行(Enterで送信)\"],\"vERlcd\":[\"プロフィール\"],\"vK0RL8\":[\"トピックなし\"],\"vSJd18\":[\"動画\"],\"vXIe7J\":[\"言語\"],\"vaHYxN\":[\"本名\"],\"vhjbKr\":[\"離席中\"],\"w/nogd\":[[\"0\"],\" network\",[\"1\"],\" — pick one to join\"],\"w4NYox\":[[\"title\"],\" クライアント\"],\"w8xQRx\":[\"無効な値\"],\"wFjjxZ\":[[\"username\"],\" によって \",[\"channelName\"],\" からキックされました (\",[\"reason\"],\")\"],\"wGjaGl\":[\"BAN例外が見つかりません\"],\"wPrGnM\":[\"チャンネル管理者\"],\"wRkP2d\":[\"GIF\"],\"wbm86v\":[\"ユーザーがチャンネルに参加・退出したときに表示する\"],\"whqZ9r\":[\"ハイライトする追加の単語またはフレーズ\"],\"wm7RV4\":[\"通知音\"],\"wz/Yoq\":[\"サーバー間で中継される際にメッセージが傍受される可能性があります\"],\"xCJdfg\":[\"クリア\"],\"xUHRTR\":[\"接続時に自動的にOperatorとして認証する\"],\"xWHwwQ\":[\"BAN一覧\"],\"xYilR2\":[\"メディア\"],\"xceQrO\":[\"安全なWebSocketのみサポートされています\"],\"xdtXa+\":[\"チャンネル名\"],\"xfXC7q\":[\"テキストチャンネル\"],\"xlCYOE\":[\"メッセージを取得中...\"],\"xlhswE\":[\"最小値は \",[\"0\"],\" です\"],\"xq97Ci\":[\"単語またはフレーズを追加...\"],\"xuRqRq\":[\"クライアント制限 (+l)\"],\"xwF+7J\":[[\"0\"],\" が入力中...\"],\"yJztBY\":[\"ネットワークを削除\"],\"yNeucF\":[\"このサーバーは拡張プロフィールメタデータ(IRCv3 METADATA拡張)をサポートしていません。アバター、表示名、ステータスなどの追加フィールドは利用できません。\"],\"yPlrca\":[\"チャンネルアバター\"],\"yQE2r9\":[\"読み込み中\"],\"ySU+JY\":[\"your@email.com\"],\"yTX1Rt\":[\"Operユーザー名\"],\"yYOzWD\":[\"ログ\"],\"yfx9Re\":[\"IRC Operatorパスワード\"],\"ygCKqB\":[\"停止\"],\"ymDxJx\":[\"IRC Operatorユーザー名\"],\"yrpRsQ\":[\"名前順で並び替え\"],\"yz7wBu\":[\"閉じる\"],\"zJw+jA\":[\"モードを設定: \",[\"0\"]],\"zebeLu\":[\"Operユーザー名を入力\"],\"zpr0Bw\":[\"GZ-Line\"]}"); \ No newline at end of file diff --git a/src/locales/ja/messages.po b/src/locales/ja/messages.po index 16890076..9c1b4e3d 100644 --- a/src/locales/ja/messages.po +++ b/src/locales/ja/messages.po @@ -22,6 +22,16 @@ msgstr "ObsidianIRC - IRCを未来へ" msgid "— open in viewer" msgstr "— ビューアで開く" +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx +msgid "(inherit)" +msgstr "(継承)" + +#: src/components/ui/BouncerNetworkForm.tsx +msgid "(unchanged)" +msgstr "(変更なし)" + #. placeholder {0}: filteredMessages.length #. placeholder {1}: import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import type * as React from "react"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { SCROLL_TOLERANCE, useScrollToBottom, } from "../../hooks/useScrollToBottom"; import { groupConsecutiveEvents } from "../../lib/eventGrouping"; import ircClient from "../../lib/ircClient"; import useStore from "../../store"; import type { Message as MessageType } from "../../types"; import { CollapsedEventMessage } from "../message/CollapsedEventMessage"; import { MessageItem } from "../message/MessageItem"; import LoadingSpinner from "../ui/LoadingSpinner"; import { ScrollToBottomButton } from "../ui/ScrollToBottomButton"; export const DEFAULT_VISIBLE_MESSAGE_COUNT = 100; // Stable empty array — prevents selector from returning a new [] on every render // when the channel has no messages yet (undefined ?? [] would create a new ref each time). const EMPTY_MESSAGES: import("../../types").Message[] = []; export interface ChannelMessageListHandle { setAtBottom: () => void; scrollToBottom: () => void; getScrollState: () => { scrollTop: number; isAtBottom: boolean; visibleCount: number; }; } interface ChannelMessageListProps { channelKey: string; serverId: string; channelId: string | null; privateChatId: string | null; isActive: boolean; searchQuery: string; isMemberListVisible: boolean; onReply: (msg: MessageType | null) => void; onUsernameContextMenu: ( e: React.MouseEvent, username: string, serverId: string, channelId: string, avatarEl?: Element | null, ) => void; onIrcLinkClick: (url: string) => void; onReactClick: (msg: MessageType, el: Element) => void; onReactionUnreact: (emoji: string, msg: MessageType) => void; onOpenReactionModal: ( msg: MessageType, position: { x: number; y: number }, ) => void; onDirectReaction: (emoji: string, msg: MessageType) => void; onRedactMessage: (msg: MessageType) => void; onOpenProfile: (username: string) => void; joinChannel: (serverId: string, channelName: string) => void; onClearSearch: () => void; highlightedMessageId?: string; // undefined = first visit; null = was at bottom; object = restore to saved position initialScrollState?: { scrollTop: number; visibleCount: number } | null; } export const ChannelMessageList = forwardRef< ChannelMessageListHandle, ChannelMessageListProps >( ( { channelKey, serverId, channelId, privateChatId, isActive, searchQuery, isMemberListVisible, onReply, onUsernameContextMenu, onIrcLinkClick, onReactClick, onReactionUnreact, onOpenReactionModal, onDirectReaction, onRedactMessage, onOpenProfile, joinChannel, onClearSearch, highlightedMessageId, initialScrollState, }, ref, ) => { const { t } = useLingui(); const [visibleMessageCount, setVisibleMessageCount] = useState( initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT, ); // Ref mirror so getScrollState closure always reads the current value without needing it as a dep. const visibleMessageCountRef = useRef(visibleMessageCount); visibleMessageCountRef.current = visibleMessageCount; // Distinguishes initial join (full-screen spinner) from subsequent "load more" (button spinner). const [isFetchingMore, setIsFetchingMore] = useState(false); const isFetchingMoreRef = useRef(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const messagesInnerRef = useRef(null); // prev scrollHeight for prepend delta-correction. const prevScrollHeightRef = useRef(0); // Ref mirror of isScrolledUp — lets useLayoutEffect closures read current value // without listing isScrolledUp as a dep (which would re-run effects on every scroll). const isScrolledUpRef = useRef(false); const prevFilteredLengthRef = useRef(0); const prevFirstMsgIdRef = useRef(null); // Set by the window-growth layoutEffect (or button handler) when a true prepend is detected. // Consumed by the delta-correction layoutEffect one render later (after visibleCount grows). // Using a flag instead of tracking displayedMessages[0]?.id because slice(-N) slides the // window on every bottom append, changing displayedMessages[0] even for non-prepend renders. const pendingPrependRef = useRef(false); // Shared scrollHeight baseline between the delta-correction layout effect and the inner // ResizeObserver. When scrollTop is corrected after a prepend, we update this so the RO's // "was at bottom" check is not fooled by the adjusted scrollTop vs its stale prevSH. const resizeObserverPrevSHRef = useRef(0); const channelMessages = useStore( useCallback( (state) => state.messages[channelKey] ?? EMPTY_MESSAGES, [channelKey], ), ); const servers = useStore((state) => state.servers); const mobileViewActiveColumn = useStore( (state) => state.ui.mobileViewActiveColumn, ); const channel = useMemo( () => channelId ? (servers .find((s) => s.id === serverId) ?.channels.find((c) => c.id === channelId) ?? null) : null, [servers, serverId, channelId], ); const { isScrolledUp, wasAtBottomRef, scrollToBottom } = useScrollToBottom( messagesContainerRef, messagesEndRef, { channelId: `${channelId || privateChatId}-${isMemberListVisible}` }, ); // Snapshot of the last known scroll position captured while the container was visible. // getScrollState() reads this instead of the live DOM because React commits display:none // before running cleanup effects, collapsing scrollTop/scrollHeight/clientHeight to 0. const lastScrollTopRef = useRef(initialScrollState?.scrollTop ?? 0); useEffect(() => { const container = messagesContainerRef.current; if (!container) return; const onScroll = () => { if (container.clientHeight > 0) lastScrollTopRef.current = container.scrollTop; }; container.addEventListener("scroll", onScroll, { passive: true }); return () => container.removeEventListener("scroll", onScroll); }, []); // Restore scroll position when a keep-alive channel transitions from hidden to visible. // display:none may reset scrollTop to 0; lastScrollTopRef was captured while visible. const prevActiveRef = useRef(isActive); useLayoutEffect(() => { if (isActive && !prevActiveRef.current) { const container = messagesContainerRef.current; if (container && lastScrollTopRef.current > 0) { container.scrollTop = lastScrollTopRef.current; } } prevActiveRef.current = isActive; }, [isActive]); useImperativeHandle(ref, () => ({ setAtBottom: () => { wasAtBottomRef.current = true; }, scrollToBottom, getScrollState: () => ({ scrollTop: lastScrollTopRef.current, isAtBottom: wasAtBottomRef.current, visibleCount: visibleMessageCountRef.current, }), })); const filteredMessages = useMemo(() => { if (!searchQuery.trim()) return channelMessages; const query = searchQuery.toLowerCase(); return channelMessages.filter( (msg) => msg.content.toLowerCase().includes(query) || msg.userId.toLowerCase().includes(query), ); }, [channelMessages, searchQuery]); useEffect(() => { isScrolledUpRef.current = isScrolledUp; // When the user returns to the bottom, shrink the window back to the base so // slice(-N) resumes trimming old messages from the top (memory optimization). // Only shrink if we grew above the base — preserves a sub-default saved visibleCount. if (!isScrolledUp) { setVisibleMessageCount((prev) => prev > DEFAULT_VISIBLE_MESSAGE_COUNT ? DEFAULT_VISIBLE_MESSAGE_COUNT : prev, ); } }, [isScrolledUp]); // Reset ref-tracked windowing state when switching channels. // visibleMessageCount is NOT reset here — useState(initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT) // already initializes it correctly on mount, and this effect runs once on mount for the // same channelKey (each instance is bound to exactly one channel by the parent key={}). // biome-ignore lint/correctness/useExhaustiveDependencies: intentional full reset on channel change useEffect(() => { prevFilteredLengthRef.current = 0; prevFirstMsgIdRef.current = null; prevScrollHeightRef.current = 0; pendingPrependRef.current = false; resizeObserverPrevSHRef.current = 0; }, [channelKey]); const displayedMessages = useMemo(() => { if (searchQuery.trim()) return filteredMessages; return filteredMessages.slice(-visibleMessageCount); }, [filteredMessages, visibleMessageCount, searchQuery]); const locallyHidden = filteredMessages.length > displayedMessages.length; const serverHasMore = channel?.hasMoreHistory === true; const hasMoreMessages = locallyHidden || serverHasMore; const eventGroups = useMemo( () => groupConsecutiveEvents(displayedMessages), [displayedMessages], ); const isLoadingHistory = channel?.isLoadingHistory ?? false; // Scroll to bottom on initial mount, unless a saved position was passed in. // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount only useEffect(() => { const container = messagesContainerRef.current; if (!container) return; if (initialScrollState) { container.scrollTop = initialScrollState.scrollTop; lastScrollTopRef.current = initialScrollState.scrollTop; wasAtBottomRef.current = false; } else { container.scrollTop = container.scrollHeight; lastScrollTopRef.current = container.scrollHeight; wasAtBottomRef.current = true; } }, []); // Scroll to bottom after initial join history loads; clear fetch spinner at batch end. const wasLoadingHistoryRef = useRef(false); // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is stable via useCallback; refs and setters are stable useLayoutEffect(() => { if (wasLoadingHistoryRef.current && !isLoadingHistory) { if (isFetchingMoreRef.current) { // delta correction for scroll position is handled by useLayoutEffect([displayedMessages]) isFetchingMoreRef.current = false; setIsFetchingMore(false); } else { scrollToBottom(); wasAtBottomRef.current = true; } } wasLoadingHistoryRef.current = isLoadingHistory; }, [isLoadingHistory]); // When older messages are prepended, grow the window so they enter displayedMessages. // When new messages arrive at the bottom while the user is scrolled up, also grow the // window to keep the current top messages visible — slice(-N) otherwise slides the // window forward and hides them, incrementing the "N older messages" counter on every // incoming message. Only let the slice trim from the top when the user is at the bottom // (where auto-scroll handles keeping them current). useLayoutEffect(() => { const newLength = filteredMessages.length; const newFirstId = filteredMessages[0]?.id ?? null; const delta = newLength - prevFilteredLengthRef.current; if (prevFilteredLengthRef.current > 0 && delta > 0) { if (newFirstId !== prevFirstMsgIdRef.current) { // Messages prepended (load-more): signal delta-correction to compensate scrollTop. pendingPrependRef.current = true; setVisibleMessageCount((prev) => prev + delta); } else if (isScrolledUpRef.current) { // Messages appended at bottom while user is scrolled up reading history. // Expand the window to prevent top messages from dropping out of the slice. setVisibleMessageCount((prev) => prev + delta); } } prevFilteredLengthRef.current = newLength; prevFirstMsgIdRef.current = newFirstId; }, [filteredMessages]); // Compensate scrollTop when content is prepended above the viewport. // biome-ignore lint/correctness/useExhaustiveDependencies: runs on every displayedMessages render to capture the resulting scrollHeight; refs are stable useLayoutEffect(() => { const container = messagesContainerRef.current; if (!container) return; // Skip while container is display:none — scrollHeight collapses to 0 and would // poison prevScrollHeightRef, causing a huge spurious delta on the next visible render. if (container.clientHeight === 0) return; const prevHeight = prevScrollHeightRef.current; const newHeight = container.scrollHeight; // Only correct when a true load-more prepend happened (flag set by the window-growth // layoutEffect or button handler). Bottom appends slide the slice(-N) window which also // changes displayedMessages[0] — ID-comparison can't distinguish the two cases. const wasPrepend = pendingPrependRef.current; // Only consume the flag when scrollHeight actually changed — the server-side load-more // path goes through two renders: Render A (filteredMessages grows, visibleCount unchanged, // same displayedMessages content, same scrollHeight) then Render B (visibleCount grows, // new messages enter displayedMessages, scrollHeight grows). The flag must survive Render A // so it's still set when Render B fires the actual correction. if (wasPrepend && newHeight !== prevHeight) { pendingPrependRef.current = false; } if ( isScrolledUpRef.current && prevHeight > 0 && newHeight > prevHeight && wasPrepend ) { const delta = newHeight - prevHeight; container.scrollTop += delta; resizeObserverPrevSHRef.current = newHeight; } prevScrollHeightRef.current = newHeight; }, [displayedMessages]); // Re-stick to bottom when inner message content grows (media/audio previews loading). // Uses prevScrollHeight instead of wasAtBottomRef to avoid stale-flag race where the // ref is true while the user is actively scrolling up. // When the container width changes (member list toggle, window resize), text reflows // and scrollHeight changes; preserve proportional scroll position for scrolled-up users. // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is a stable ref useEffect(() => { const container = messagesContainerRef.current; const inner = messagesInnerRef.current; if (!inner || !container) return; resizeObserverPrevSHRef.current = container.scrollHeight; let prevClientWidth = container.clientWidth; const observer = new ResizeObserver(() => { if (container.clientHeight === 0) return; // Effect may re-initialize while container is display:none (ref=0). // Re-seed with current dimensions and skip — no reliable "was at bottom" data. if (resizeObserverPrevSHRef.current === 0) { resizeObserverPrevSHRef.current = container.scrollHeight; prevClientWidth = container.clientWidth; return; } const currentClientWidth = container.clientWidth; const widthChanged = currentClientWidth !== prevClientWidth; prevClientWidth = currentClientWidth; const prevSH = resizeObserverPrevSHRef.current; const wasAtPrevBottom = container.scrollTop + container.clientHeight >= prevSH - SCROLL_TOLERANCE; resizeObserverPrevSHRef.current = container.scrollHeight; if (wasAtPrevBottom) { scrollToBottom(); } else if (widthChanged && prevSH > 0) { const ratio = container.scrollTop / prevSH; container.scrollTop = Math.round(ratio * container.scrollHeight); } }); observer.observe(inner); return () => observer.disconnect(); }, [isLoadingHistory, channelId, privateChatId]); // Auto-scroll on new messages — skip when this channel is hidden (display:none). // biome-ignore lint/correctness/useExhaustiveDependencies: only scroll when messages change, not when isActive changes useEffect(() => { if (!isActive) return; const isNarrowView = window.matchMedia("(max-width: 768px)").matches; const isChatVisible = !isNarrowView || mobileViewActiveColumn === "chatView"; if (wasAtBottomRef.current && isChatVisible) { scrollToBottom(); } }, [displayedMessages, mobileViewActiveColumn, scrollToBottom, isActive]); return ( <>
{isLoadingHistory && !isFetchingMore ? (
) : (
{hasMoreMessages && !searchQuery && (
)} {searchQuery && (
{plural(filteredMessages.length, { one: t`Found 1 message matching "${searchQuery}"`, other: t`Found ${filteredMessages.length} messages matching "${searchQuery}"`, })}
)} {eventGroups.map((group) => { if (group.type === "eventGroup") { const firstId = group.messages[0]?.id || ""; const lastId = group.messages[group.messages.length - 1]?.id || ""; const groupKey = `group-${firstId}-${lastId}`; return ( ); } const message = group.messages[0]; const originalIndex = channelMessages.findIndex( (m) => m.id === message.id, ); const previousMessage = channelMessages[originalIndex - 1]; const showHeader = !previousMessage || previousMessage.type !== "message" || previousMessage.userId !== message.userId || new Date(message.timestamp).getTime() - new Date(previousMessage.timestamp).getTime() > 5 * 60 * 1000; return ( ); })}
)}
); }, ); ChannelMessageList.displayName = "ChannelMessageList"; // Wrap with memo so hidden keep-alive channels skip re-renders when their props // haven't changed (e.g. when messageText changes in the input — the only thing // that changes on typing is local state inside ChatArea, not the props we pass here). export const MemoChannelMessageList = memo(ChannelMessageList); #. placeholder {2}: import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import type * as React from "react"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { SCROLL_TOLERANCE, useScrollToBottom, } from "../../hooks/useScrollToBottom"; import { groupConsecutiveEvents } from "../../lib/eventGrouping"; import ircClient from "../../lib/ircClient"; import useStore from "../../store"; import type { Message as MessageType } from "../../types"; import { CollapsedEventMessage } from "../message/CollapsedEventMessage"; import { MessageItem } from "../message/MessageItem"; import LoadingSpinner from "../ui/LoadingSpinner"; import { ScrollToBottomButton } from "../ui/ScrollToBottomButton"; export const DEFAULT_VISIBLE_MESSAGE_COUNT = 100; // Stable empty array — prevents selector from returning a new [] on every render // when the channel has no messages yet (undefined ?? [] would create a new ref each time). const EMPTY_MESSAGES: import("../../types").Message[] = []; export interface ChannelMessageListHandle { setAtBottom: () => void; scrollToBottom: () => void; getScrollState: () => { scrollTop: number; isAtBottom: boolean; visibleCount: number; }; } interface ChannelMessageListProps { channelKey: string; serverId: string; channelId: string | null; privateChatId: string | null; isActive: boolean; searchQuery: string; isMemberListVisible: boolean; onReply: (msg: MessageType | null) => void; onUsernameContextMenu: ( e: React.MouseEvent, username: string, serverId: string, channelId: string, avatarEl?: Element | null, ) => void; onIrcLinkClick: (url: string) => void; onReactClick: (msg: MessageType, el: Element) => void; onReactionUnreact: (emoji: string, msg: MessageType) => void; onOpenReactionModal: ( msg: MessageType, position: { x: number; y: number }, ) => void; onDirectReaction: (emoji: string, msg: MessageType) => void; onRedactMessage: (msg: MessageType) => void; onOpenProfile: (username: string) => void; joinChannel: (serverId: string, channelName: string) => void; onClearSearch: () => void; highlightedMessageId?: string; // undefined = first visit; null = was at bottom; object = restore to saved position initialScrollState?: { scrollTop: number; visibleCount: number } | null; } export const ChannelMessageList = forwardRef< ChannelMessageListHandle, ChannelMessageListProps >( ( { channelKey, serverId, channelId, privateChatId, isActive, searchQuery, isMemberListVisible, onReply, onUsernameContextMenu, onIrcLinkClick, onReactClick, onReactionUnreact, onOpenReactionModal, onDirectReaction, onRedactMessage, onOpenProfile, joinChannel, onClearSearch, highlightedMessageId, initialScrollState, }, ref, ) => { const { t } = useLingui(); const [visibleMessageCount, setVisibleMessageCount] = useState( initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT, ); // Ref mirror so getScrollState closure always reads the current value without needing it as a dep. const visibleMessageCountRef = useRef(visibleMessageCount); visibleMessageCountRef.current = visibleMessageCount; // Distinguishes initial join (full-screen spinner) from subsequent "load more" (button spinner). const [isFetchingMore, setIsFetchingMore] = useState(false); const isFetchingMoreRef = useRef(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const messagesInnerRef = useRef(null); // prev scrollHeight for prepend delta-correction. const prevScrollHeightRef = useRef(0); // Ref mirror of isScrolledUp — lets useLayoutEffect closures read current value // without listing isScrolledUp as a dep (which would re-run effects on every scroll). const isScrolledUpRef = useRef(false); const prevFilteredLengthRef = useRef(0); const prevFirstMsgIdRef = useRef(null); // Set by the window-growth layoutEffect (or button handler) when a true prepend is detected. // Consumed by the delta-correction layoutEffect one render later (after visibleCount grows). // Using a flag instead of tracking displayedMessages[0]?.id because slice(-N) slides the // window on every bottom append, changing displayedMessages[0] even for non-prepend renders. const pendingPrependRef = useRef(false); // Shared scrollHeight baseline between the delta-correction layout effect and the inner // ResizeObserver. When scrollTop is corrected after a prepend, we update this so the RO's // "was at bottom" check is not fooled by the adjusted scrollTop vs its stale prevSH. const resizeObserverPrevSHRef = useRef(0); const channelMessages = useStore( useCallback( (state) => state.messages[channelKey] ?? EMPTY_MESSAGES, [channelKey], ), ); const servers = useStore((state) => state.servers); const mobileViewActiveColumn = useStore( (state) => state.ui.mobileViewActiveColumn, ); const channel = useMemo( () => channelId ? (servers .find((s) => s.id === serverId) ?.channels.find((c) => c.id === channelId) ?? null) : null, [servers, serverId, channelId], ); const { isScrolledUp, wasAtBottomRef, scrollToBottom } = useScrollToBottom( messagesContainerRef, messagesEndRef, { channelId: `${channelId || privateChatId}-${isMemberListVisible}` }, ); // Snapshot of the last known scroll position captured while the container was visible. // getScrollState() reads this instead of the live DOM because React commits display:none // before running cleanup effects, collapsing scrollTop/scrollHeight/clientHeight to 0. const lastScrollTopRef = useRef(initialScrollState?.scrollTop ?? 0); useEffect(() => { const container = messagesContainerRef.current; if (!container) return; const onScroll = () => { if (container.clientHeight > 0) lastScrollTopRef.current = container.scrollTop; }; container.addEventListener("scroll", onScroll, { passive: true }); return () => container.removeEventListener("scroll", onScroll); }, []); // Restore scroll position when a keep-alive channel transitions from hidden to visible. // display:none may reset scrollTop to 0; lastScrollTopRef was captured while visible. const prevActiveRef = useRef(isActive); useLayoutEffect(() => { if (isActive && !prevActiveRef.current) { const container = messagesContainerRef.current; if (container && lastScrollTopRef.current > 0) { container.scrollTop = lastScrollTopRef.current; } } prevActiveRef.current = isActive; }, [isActive]); useImperativeHandle(ref, () => ({ setAtBottom: () => { wasAtBottomRef.current = true; }, scrollToBottom, getScrollState: () => ({ scrollTop: lastScrollTopRef.current, isAtBottom: wasAtBottomRef.current, visibleCount: visibleMessageCountRef.current, }), })); const filteredMessages = useMemo(() => { if (!searchQuery.trim()) return channelMessages; const query = searchQuery.toLowerCase(); return channelMessages.filter( (msg) => msg.content.toLowerCase().includes(query) || msg.userId.toLowerCase().includes(query), ); }, [channelMessages, searchQuery]); useEffect(() => { isScrolledUpRef.current = isScrolledUp; // When the user returns to the bottom, shrink the window back to the base so // slice(-N) resumes trimming old messages from the top (memory optimization). // Only shrink if we grew above the base — preserves a sub-default saved visibleCount. if (!isScrolledUp) { setVisibleMessageCount((prev) => prev > DEFAULT_VISIBLE_MESSAGE_COUNT ? DEFAULT_VISIBLE_MESSAGE_COUNT : prev, ); } }, [isScrolledUp]); // Reset ref-tracked windowing state when switching channels. // visibleMessageCount is NOT reset here — useState(initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT) // already initializes it correctly on mount, and this effect runs once on mount for the // same channelKey (each instance is bound to exactly one channel by the parent key={}). // biome-ignore lint/correctness/useExhaustiveDependencies: intentional full reset on channel change useEffect(() => { prevFilteredLengthRef.current = 0; prevFirstMsgIdRef.current = null; prevScrollHeightRef.current = 0; pendingPrependRef.current = false; resizeObserverPrevSHRef.current = 0; }, [channelKey]); const displayedMessages = useMemo(() => { if (searchQuery.trim()) return filteredMessages; return filteredMessages.slice(-visibleMessageCount); }, [filteredMessages, visibleMessageCount, searchQuery]); const locallyHidden = filteredMessages.length > displayedMessages.length; const serverHasMore = channel?.hasMoreHistory === true; const hasMoreMessages = locallyHidden || serverHasMore; const eventGroups = useMemo( () => groupConsecutiveEvents(displayedMessages), [displayedMessages], ); const isLoadingHistory = channel?.isLoadingHistory ?? false; // Scroll to bottom on initial mount, unless a saved position was passed in. // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount only useEffect(() => { const container = messagesContainerRef.current; if (!container) return; if (initialScrollState) { container.scrollTop = initialScrollState.scrollTop; lastScrollTopRef.current = initialScrollState.scrollTop; wasAtBottomRef.current = false; } else { container.scrollTop = container.scrollHeight; lastScrollTopRef.current = container.scrollHeight; wasAtBottomRef.current = true; } }, []); // Scroll to bottom after initial join history loads; clear fetch spinner at batch end. const wasLoadingHistoryRef = useRef(false); // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is stable via useCallback; refs and setters are stable useLayoutEffect(() => { if (wasLoadingHistoryRef.current && !isLoadingHistory) { if (isFetchingMoreRef.current) { // delta correction for scroll position is handled by useLayoutEffect([displayedMessages]) isFetchingMoreRef.current = false; setIsFetchingMore(false); } else { scrollToBottom(); wasAtBottomRef.current = true; } } wasLoadingHistoryRef.current = isLoadingHistory; }, [isLoadingHistory]); // When older messages are prepended, grow the window so they enter displayedMessages. // When new messages arrive at the bottom while the user is scrolled up, also grow the // window to keep the current top messages visible — slice(-N) otherwise slides the // window forward and hides them, incrementing the "N older messages" counter on every // incoming message. Only let the slice trim from the top when the user is at the bottom // (where auto-scroll handles keeping them current). useLayoutEffect(() => { const newLength = filteredMessages.length; const newFirstId = filteredMessages[0]?.id ?? null; const delta = newLength - prevFilteredLengthRef.current; if (prevFilteredLengthRef.current > 0 && delta > 0) { if (newFirstId !== prevFirstMsgIdRef.current) { // Messages prepended (load-more): signal delta-correction to compensate scrollTop. pendingPrependRef.current = true; setVisibleMessageCount((prev) => prev + delta); } else if (isScrolledUpRef.current) { // Messages appended at bottom while user is scrolled up reading history. // Expand the window to prevent top messages from dropping out of the slice. setVisibleMessageCount((prev) => prev + delta); } } prevFilteredLengthRef.current = newLength; prevFirstMsgIdRef.current = newFirstId; }, [filteredMessages]); // Compensate scrollTop when content is prepended above the viewport. // biome-ignore lint/correctness/useExhaustiveDependencies: runs on every displayedMessages render to capture the resulting scrollHeight; refs are stable useLayoutEffect(() => { const container = messagesContainerRef.current; if (!container) return; // Skip while container is display:none — scrollHeight collapses to 0 and would // poison prevScrollHeightRef, causing a huge spurious delta on the next visible render. if (container.clientHeight === 0) return; const prevHeight = prevScrollHeightRef.current; const newHeight = container.scrollHeight; // Only correct when a true load-more prepend happened (flag set by the window-growth // layoutEffect or button handler). Bottom appends slide the slice(-N) window which also // changes displayedMessages[0] — ID-comparison can't distinguish the two cases. const wasPrepend = pendingPrependRef.current; // Only consume the flag when scrollHeight actually changed — the server-side load-more // path goes through two renders: Render A (filteredMessages grows, visibleCount unchanged, // same displayedMessages content, same scrollHeight) then Render B (visibleCount grows, // new messages enter displayedMessages, scrollHeight grows). The flag must survive Render A // so it's still set when Render B fires the actual correction. if (wasPrepend && newHeight !== prevHeight) { pendingPrependRef.current = false; } if ( isScrolledUpRef.current && prevHeight > 0 && newHeight > prevHeight && wasPrepend ) { const delta = newHeight - prevHeight; container.scrollTop += delta; resizeObserverPrevSHRef.current = newHeight; } prevScrollHeightRef.current = newHeight; }, [displayedMessages]); // Re-stick to bottom when inner message content grows (media/audio previews loading). // Uses prevScrollHeight instead of wasAtBottomRef to avoid stale-flag race where the // ref is true while the user is actively scrolling up. // When the container width changes (member list toggle, window resize), text reflows // and scrollHeight changes; preserve proportional scroll position for scrolled-up users. // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is a stable ref useEffect(() => { const container = messagesContainerRef.current; const inner = messagesInnerRef.current; if (!inner || !container) return; resizeObserverPrevSHRef.current = container.scrollHeight; let prevClientWidth = container.clientWidth; const observer = new ResizeObserver(() => { if (container.clientHeight === 0) return; // Effect may re-initialize while container is display:none (ref=0). // Re-seed with current dimensions and skip — no reliable "was at bottom" data. if (resizeObserverPrevSHRef.current === 0) { resizeObserverPrevSHRef.current = container.scrollHeight; prevClientWidth = container.clientWidth; return; } const currentClientWidth = container.clientWidth; const widthChanged = currentClientWidth !== prevClientWidth; prevClientWidth = currentClientWidth; const prevSH = resizeObserverPrevSHRef.current; const wasAtPrevBottom = container.scrollTop + container.clientHeight >= prevSH - SCROLL_TOLERANCE; resizeObserverPrevSHRef.current = container.scrollHeight; if (wasAtPrevBottom) { scrollToBottom(); } else if (widthChanged && prevSH > 0) { const ratio = container.scrollTop / prevSH; container.scrollTop = Math.round(ratio * container.scrollHeight); } }); observer.observe(inner); return () => observer.disconnect(); }, [isLoadingHistory, channelId, privateChatId]); // Auto-scroll on new messages — skip when this channel is hidden (display:none). // biome-ignore lint/correctness/useExhaustiveDependencies: only scroll when messages change, not when isActive changes useEffect(() => { if (!isActive) return; const isNarrowView = window.matchMedia("(max-width: 768px)").matches; const isChatVisible = !isNarrowView || mobileViewActiveColumn === "chatView"; if (wasAtBottomRef.current && isChatVisible) { scrollToBottom(); } }, [displayedMessages, mobileViewActiveColumn, scrollToBottom, isActive]); return ( <>
{isLoadingHistory && !isFetchingMore ? (
) : (
{hasMoreMessages && !searchQuery && (
)} {searchQuery && (
{plural(filteredMessages.length, { one: t`Found 1 message matching "${searchQuery}"`, other: t`Found ${filteredMessages.length} messages matching "${searchQuery}"`, })}
)} {eventGroups.map((group) => { if (group.type === "eventGroup") { const firstId = group.messages[0]?.id || ""; const lastId = group.messages[group.messages.length - 1]?.id || ""; const groupKey = `group-${firstId}-${lastId}`; return ( ); } const message = group.messages[0]; const originalIndex = channelMessages.findIndex( (m) => m.id === message.id, ); const previousMessage = channelMessages[originalIndex - 1]; const showHeader = !previousMessage || previousMessage.type !== "message" || previousMessage.userId !== message.userId || new Date(message.timestamp).getTime() - new Date(previousMessage.timestamp).getTime() > 5 * 60 * 1000; return ( ); })}
)}
); }, ); ChannelMessageList.displayName = "ChannelMessageList"; // Wrap with memo so hidden keep-alive channels skip re-renders when their props // haven't changed (e.g. when messageText changes in the input — the only thing // that changes on typing is local state inside ChatArea, not the props we pass here). export const MemoChannelMessageList = memo(ChannelMessageList); @@ -46,6 +56,12 @@ msgstr "{0} と {1} が入力中..." msgid "{0} is typing..." msgstr "{0} が入力中..." +#. placeholder {0}: networks.length +#. placeholder {1}: networks.length === 1 ? "" : "s" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "{0} network{1} — pick one to join" +msgstr "" + #. placeholder {0}: filteredMessages.length - displayedMessages.length #: src/components/layout/ChannelMessageList.tsx msgid "{0} older messages" @@ -186,6 +202,12 @@ msgstr "招待マスクを追加(例:nick!*@*、*!*@host.com)" msgid "Add IRC Server" msgstr "IRCサーバーを追加" +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworksPanel.tsx +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Add Network" +msgstr "ネットワークを追加" + #: src/components/message/MessageActions.tsx #: src/components/message/MessageReactions.tsx #: src/components/message/MessageReactions.tsx @@ -205,6 +227,10 @@ msgstr "ルールを追加" msgid "Add Server" msgstr "サーバーを追加" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Add your first network" +msgstr "最初のネットワークを追加" + #: src/components/message/JsonLogMessage.tsx msgid "Additional Details" msgstr "詳細情報" @@ -358,6 +384,10 @@ msgstr "戻る" msgid "Back to image" msgstr "画像に戻る" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Back to network list" +msgstr "" + #: src/components/ui/ModerationModal.tsx msgid "Ban {username} by hostmask (prevents them from rejoining from the same IP/host)" msgstr "{username} をホストマスクでBANする(同じIP/ホストからの再参加を防止)" @@ -405,6 +435,8 @@ msgstr "サーバー上のすべてのチャンネルを一覧表示" #: src/components/ui/AddPrivateChatModal.tsx #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/ChannelSettingsModal.tsx #: src/components/ui/ExternalLinkWarningModal.tsx #: src/components/ui/FloodSettingsModal.tsx @@ -640,6 +672,7 @@ msgid "Configure notification sounds and highlights" msgstr "通知音とハイライトを設定する" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworksPanel.tsx msgid "Connect" msgstr "接続" @@ -759,6 +792,10 @@ msgstr "チャンネルを削除" msgid "Delete message" msgstr "メッセージを削除" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Delete network" +msgstr "ネットワークを削除" + #: src/components/layout/ChannelList.tsx msgid "Delete Private Chat" msgstr "プライベートチャットを削除" @@ -767,6 +804,10 @@ msgstr "プライベートチャットを削除" msgid "Delete this message? This cannot be undone." msgstr "このメッセージを削除しますか?この操作は元に戻せません。" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Delete this network?" +msgstr "このネットワークを削除しますか?" + #: src/components/layout/ServerList.tsx #: src/components/mobile/ServerBottomSheet.tsx msgid "Disconnect" @@ -830,10 +871,16 @@ msgstr "ダウンロード" msgid "e.g., 100:1440" msgstr "例:100:1440" +#: src/components/ui/BouncerNetworksPanel.tsx #: src/components/ui/ChannelSettingsModal.tsx msgid "Edit" msgstr "編集" +#. placeholder {0}: editingNetwork?.attributes.name || editingNetwork?.netid +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Edit {0}" +msgstr "{0} を編集" + #: src/components/ui/UserProfileModal.tsx msgid "Edit Profile" msgstr "プロフィールを編集" @@ -1057,6 +1104,7 @@ msgstr "ホーム" msgid "Homepage" msgstr "ホームページ" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserProfileModal.tsx msgid "Host" msgstr "ホスト" @@ -1271,6 +1319,10 @@ msgstr "チャンネルを退出しました" msgid "Let others know when you are typing" msgstr "入力中であることを他のユーザーに知らせる" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Libera Chat" +msgstr "Libera Chat" + #: src/components/message/LinkPreview.tsx msgid "Link preview" msgstr "リンクプレビュー" @@ -1299,6 +1351,10 @@ msgstr "GIFを読み込み中..." msgid "Loading more channels..." msgstr "さらにチャンネルを読み込み中..." +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Loading networks from your bouncer…" +msgstr "バウンサーからネットワークを読み込み中…" + #: src/components/ui/UserProfileModal.tsx msgid "Loading WHOIS data..." msgstr "WHOISデータを読み込み中..." @@ -1486,9 +1542,15 @@ msgid "Name:" msgstr "名前:" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx msgid "Network Name" msgstr "ネットワーク名" +#. placeholder {0}: server?.name ?? bouncerServerId +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Networks on {0}" +msgstr "{0} のネットワーク" + #: src/components/ui/QuickActions.tsx msgid "New DM" msgstr "新しいDM" @@ -1511,6 +1573,7 @@ msgid "nick!user@host (e.g., spam*!*@*, *!*@badhost.com)" msgstr "nick!user@host(例:spam*!*@*、*!*@badhost.com)" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserSettings.tsx #: src/lib/settings/definitions/allSettings.ts msgid "Nickname" @@ -1570,6 +1633,10 @@ msgstr "ファイルが選択されていません" msgid "No flood profile" msgstr "フラッドプロファイルなし" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "no host set" +msgstr "ホスト未設定" + #: src/components/ui/ChannelSettingsModal.tsx msgid "No invitations found" msgstr "招待が見つかりません" @@ -1610,6 +1677,10 @@ msgstr "トピックが設定されていません" msgid "No unread mentions or messages" msgstr "未読のメンションやメッセージはありません" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "No upstream networks yet." +msgstr "アップストリームネットワークがまだありません。" + #: src/components/ui/AddPrivateChatModal.tsx msgid "No users available" msgstr "利用可能なユーザーがいません" @@ -1696,6 +1767,10 @@ msgstr "おっと!ネットワーク分割が発生しました!⚠️" msgid "Op" msgstr "Op" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Open" +msgstr "" + #: src/components/ui/QuickActions/uiActionConfig.tsx msgid "Open channel configuration settings" msgstr "チャンネル設定を開く" @@ -1799,6 +1874,10 @@ msgstr "プライベートチャットをピン留め" msgid "Pin this private message conversation" msgstr "このプライベートメッセージの会話をピン留めする" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Plaintext" +msgstr "平文" + #: src/components/message/MediaPreview.tsx #: src/components/message/MediaPreview.tsx #: src/components/message/MediaPreview.tsx @@ -1827,6 +1906,7 @@ msgid "PM User" msgstr "DMを送る" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx msgid "Port" msgstr "ポート" @@ -1918,6 +1998,7 @@ msgstr "このメッセージにリアクションしました" msgid "Read more" msgstr "もっと読む" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserProfileModal.tsx #: src/components/ui/UserSettings.tsx #: src/lib/settings/definitions/allSettings.ts @@ -2002,6 +2083,7 @@ msgstr "ルール" msgid "Safe" msgstr "安全" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/ChannelSettingsModal.tsx #: src/components/ui/TopicModal.tsx #: src/components/ui/UserSettings.tsx @@ -2183,6 +2265,10 @@ msgstr "ネットワーク上のサーバーオペレーターがメッセージ msgid "Server Password" msgstr "サーバーパスワード" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Server Password (PASS)" +msgstr "サーバーパスワード (PASS)" + #: src/components/ui/LinkSecurityWarningModal.tsx msgid "Server-to-server communication may use unencrypted connections" msgstr "サーバー間通信に暗号化されていない接続が使用される可能性があります" @@ -2378,6 +2464,10 @@ msgstr "時間(分)" msgid "Time Window (seconds)" msgstr "時間ウィンドウ(秒)" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "TLS" +msgstr "TLS" + #: src/components/message/WhisperMessage.tsx #: src/components/message/WhisperMessage.tsx msgid "to" @@ -2426,6 +2516,10 @@ msgstr "トピック:" msgid "Total: {0}" msgstr "合計:{0}" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Transport" +msgstr "トランスポート" + #: src/components/ui/UserSettings.tsx msgid "Trusted Sources" msgstr "信頼できるソース" @@ -2536,6 +2630,7 @@ msgstr "ユーザープロフィール" msgid "User Settings" msgstr "ユーザー設定" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/InviteUserModal.tsx #: src/components/ui/ModerationModal.tsx msgid "Username" @@ -2683,6 +2778,10 @@ msgstr "広め — より広範な保護範囲" msgid "Will default to 'no reason' if left empty" msgstr "空欄の場合は「理由なし」がデフォルトになります" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Yes, delete" +msgstr "はい、削除します" + #: src/components/message/CollapsedEventMessage.tsx #: src/components/message/EventMessage.tsx msgid "You" @@ -2713,6 +2812,10 @@ msgstr "認証用のアカウントパスワード" msgid "Your account username for authentication" msgstr "認証用のアカウントユーザー名" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Your bouncer doesn't have any networks yet. Add one to get started." +msgstr "バウンサーにはまだネットワークがありません。最初のネットワークを追加して始めましょう。" + #: src/lib/settings/definitions/allSettings.ts msgid "Your default nickname for all servers" msgstr "すべてのサーバーで使用するデフォルトのニックネーム" diff --git a/src/locales/ko/messages.mjs b/src/locales/ko/messages.mjs index 30ffcffc..599136eb 100644 --- a/src/locales/ko/messages.mjs +++ b/src/locales/ko/messages.mjs @@ -1 +1 @@ -/*eslint-disable*/export const messages=JSON.parse("{\"+5VMrz\":[\"잘못된 패턴 형식입니다. nick!user@host 형식을 사용하세요 (와일드카드 * 허용)\"],\"+6NQQA\":[\"일반 지원 채널\"],\"+6NyRG\":[\"클라이언트\"],\"+K0AvT\":[\"연결 끊기\"],\"+cyFdH\":[\"자리 비움 설정 시 기본 메시지\"],\"+mVPqU\":[\"메시지에서 마크다운 서식 렌더링\"],\"+vqCJH\":[\"인증을 위한 계정 사용자 이름\"],\"+yPBXI\":[\"파일 선택\"],\"+zy2Nq\":[\"유형\"],\"/09cao\":[\"낮은 링크 보안 (레벨 \",[\"securityLevel\"],\")\"],\"/3BQ4J\":[\"채널 외부 사용자는 메시지를 보낼 수 없습니다\"],\"/6BzZF\":[\"멤버 목록 전환\"],\"/TNOPk\":[\"자리 비움 중\"],\"/XQgft\":[\"채널 탐색\"],\"/cF7Rs\":[\"볼륨\"],\"/dqduX\":[\"다음 페이지\"],\"/fc3q4\":[\"모든 콘텐츠\"],\"/kISDh\":[\"알림 소리 활성화\"],\"/n04sB\":[\"Kill\"],\"/rTz0M\":[\"오디오\"],\"/rfkZe\":[\"멘션 및 메시지에 소리 재생\"],\"0/0ZGA\":[\"채널 이름 마스크\"],\"0D6j7U\":[\"사용자 정의 규칙에 대해 더 알아보기 →\"],\"0XsHcR\":[\"사용자 추방\"],\"0ZpE//\":[\"사용자 수순 정렬\"],\"0bEPwz\":[\"자리 비움 설정\"],\"0dGkPt\":[\"채널 목록 펼치기\"],\"0gS7M5\":[\"표시 이름\"],\"0kS+M8\":[\"예시네트워크\"],\"0rgoY7\":[\"직접 선택한 서버에만 연결합니다\"],\"0wdd7X\":[\"참여\"],\"0wkVYx\":[\"비공개 메시지\"],\"111uHX\":[\"링크 미리보기\"],\"196EG4\":[\"비공개 채팅 삭제\"],\"1DSr1i\":[\"계정 등록\"],\"1O/24y\":[\"채널 목록 전환\"],\"1VPJJ2\":[\"외부 링크 경고\"],\"1ZC/dv\":[\"읽지 않은 멘션이나 메시지가 없습니다\"],\"1pO1zi\":[\"서버 이름은 필수입니다\"],\"1uwfzQ\":[\"채널 주제 보기\"],\"268g7c\":[\"표시 이름 입력\"],\"2FOFq1\":[\"네트워크 서버 운영자가 메시지를 읽을 수 있습니다\"],\"2FYpfJ\":[\"더 보기\"],\"2HF1Y2\":[[\"inviter\"],\"이(가) \",[\"target\"],\"을(를) \",[\"channel\"],\"에 초대했습니다\"],\"2I70QL\":[\"사용자 프로필 정보 보기\"],\"2QYdmE\":[\"사용자:\"],\"2QpEjG\":[\"퇴장했습니다\"],\"2YE223\":[\"#\",[\"0\"],\"에 메시지 (Enter로 줄 바꿈, Shift+Enter로 전송)\"],\"2bimFY\":[\"서버 비밀번호 사용\"],\"2iTmdZ\":[\"로컬 저장소:\"],\"2odkwe\":[\"엄격 - 더 강력한 보호\"],\"2uDhbA\":[\"초대할 사용자 이름 입력\"],\"2ygf/L\":[\"← 뒤로\"],\"2zEgxj\":[\"GIF 검색...\"],\"3RdPhl\":[\"채널 이름 변경\"],\"3THokf\":[\"Voice 사용자\"],\"3TSz9S\":[\"최소화\"],\"3jBDvM\":[\"채널 표시 이름\"],\"3ryuFU\":[\"앱 개선을 위한 선택적 충돌 보고서\"],\"3uBF/8\":[\"뷰어 닫기\"],\"3uwW8F\":[\"https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"3xf8Kz\":[\"계정 이름 입력...\"],\"4/Rr0R\":[\"현재 채널에 사용자 초대\"],\"4EZrJN\":[\"규칙\"],\"4JJtW9\":[\"#overflow\"],\"4NqeT4\":[\"플러드 프로필 (+F)\"],\"4RZQRK\":[\"지금 뭐 하세요?\"],\"4hfTrB\":[\"닉네임\"],\"4n99LO\":[\"이미 \",[\"0\"],\"에 있음\"],\"4t6vMV\":[\"짧은 메시지의 경우 자동으로 한 줄 모드로 전환\"],\"4vsHmf\":[\"시간 (분)\"],\"5+INAX\":[\"나를 멘션한 메시지 강조 표시\"],\"5R5Pv/\":[\"Oper 이름\"],\"678PKt\":[\"네트워크 이름\"],\"6Aih4U\":[\"오프라인\"],\"6CO3WE\":[\"채널 참여에 필요한 비밀번호입니다. 키를 제거하려면 비워두세요.\"],\"6HhMs3\":[\"QUIT 메시지\"],\"6V3Ea3\":[\"복사됨\"],\"6lGV3K\":[\"접기\"],\"6yFOEi\":[\"oper 비밀번호 입력...\"],\"7+IHTZ\":[\"파일 선택 안 함\"],\"73hrRi\":[\"nick!user@host (예: spam*!*@*, *!*@badhost.com)\"],\"7QkKyN\":[\"비공개 메시지 보내기\"],\"7U1W7c\":[\"매우 완화\"],\"7Y1YQj\":[\"실명:\"],\"7YHArF\":[\"— 뷰어에서 열기\"],\"7fjnVl\":[\"사용자 검색...\"],\"7jL88x\":[\"이 메시지를 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.\"],\"7nGhhM\":[\"무슨 생각을 하고 계신가요?\"],\"7sEpu1\":[\"멤버 — \",[\"0\"],\"명\"],\"7sNhEz\":[\"사용자 이름\"],\"8H0Q+x\":[\"프로필에 대해 더 알아보기 →\"],\"8Phu0A\":[\"사용자가 닉네임을 변경할 때 표시\"],\"8XTG9e\":[\"oper 비밀번호 입력\"],\"8XsV2J\":[\"다시 보내기\"],\"8ZsakT\":[\"비밀번호\"],\"8kR84m\":[\"외부 링크를 열려고 합니다:\"],\"8lCgih\":[\"규칙 제거\"],\"8p/xVT\":[[\"0\",\"plural\",{\"one\":[[\"1\"]],\"other\":[[\"2\"]]}]],\"8wRzac\":[[\"joinCount\",\"plural\",{\"other\":[[\"joinCount\"],\"번 참가했음\"]}]],\"9BMLnJ\":[\"서버에 재연결\"],\"9OEgyT\":[\"반응 추가\"],\"9PQ8m2\":[\"G-Line (글로벌 밴)\"],\"9Qs99X\":[\"이메일:\"],\"9QupBP\":[\"패턴 제거\"],\"9bG48P\":[\"보내는 중\"],\"9f5f0u\":[\"개인정보 보호에 관한 문의사항이 있으신가요? 문의처:\"],\"9unqs3\":[\"자리비움:\"],\"9v3hwv\":[\"서버를 찾을 수 없습니다.\"],\"9zb2WA\":[\"연결 중\"],\"A1taO8\":[\"검색\"],\"A2adVi\":[\"입력 중 알림 보내기\"],\"A9Rhec\":[\"채널 이름\"],\"AWOSPo\":[\"확대\"],\"AXSpEQ\":[\"연결 시 Oper 인증\"],\"AeXO77\":[\"계정\"],\"AhNP40\":[\"탐색\"],\"Ai2U7L\":[\"호스트\"],\"AjBQnf\":[\"닉네임 변경됨\"],\"AmXVh6\":[\"https://example.com/avatar.png\"],\"AnRu/j\":[\"답장 취소\"],\"ApSx0O\":[\"\\\"\",[\"searchQuery\"],\"\\\"와 일치하는 메시지 \",[\"0\"],\"개 발견\"],\"AxPAXW\":[\"결과를 찾을 수 없습니다\"],\"AyNqAB\":[\"채팅에 모든 서버 이벤트 표시\"],\"B/QqGw\":[\"자리 비움 (AFK)\"],\"B8AaMI\":[\"이 필드는 필수입니다\"],\"BA2c49\":[\"서버가 고급 LIST 필터링을 지원하지 않습니다\"],\"BDKt3I\":[[\"0\"],\"님, \",[\"1\"],\"님, \",[\"2\"],\"님 외 \",[\"3\"],\"명이 입력 중...\"],\"BGul2A\":[\"저장되지 않은 변경 사항이 있습니다. 저장하지 않고 닫으시겠습니까?\"],\"BIf9fi\":[\"상태 메시지\"],\"BZz3md\":[\"개인 웹사이트\"],\"Bgm/H7\":[\"여러 줄 텍스트 입력 허용\"],\"BiQIl1\":[\"이 비공개 메시지 대화 고정\"],\"BlNZZ2\":[\"클릭하여 메시지로 이동\"],\"Bowq3c\":[\"운영자만 채널 주제를 변경할 수 있음\"],\"Btozzp\":[\"이 이미지가 만료되었습니다\"],\"Bycfjm\":[\"전체: \",[\"0\"]],\"C6IBQc\":[\"전체 JSON 복사\"],\"C9L9wL\":[\"데이터 수집\"],\"CDq4wC\":[\"사용자 제재\"],\"CHVRxG\":[\"@\",[\"0\"],\"에게 메시지 (Shift+Enter로 줄 바꿈)\"],\"CN9zdR\":[\"Oper 이름과 비밀번호는 필수입니다\"],\"CW3sYa\":[[\"emoji\"],\" 반응 추가\"],\"CaAkqd\":[\"QUIT 표시\"],\"CbvaYj\":[\"닉네임으로 차단\"],\"CcK+Ft\":[\"PDF\"],\"Ce8q3L\":[\"채널 선택\"],\"CsekCi\":[\"기본\"],\"D+NlUC\":[\"시스템\"],\"D28t6+\":[\"참여했다가 퇴장했습니다\"],\"DB8zMK\":[\"적용\"],\"DBcWHr\":[\"사용자 정의 알림 소리 파일\"],\"DTy9Xw\":[\"미디어 미리보기\"],\"Dj4pSr\":[\"안전한 비밀번호를 선택하세요\"],\"Du+zn+\":[\"검색 중...\"],\"Du2T2f\":[\"설정을 찾을 수 없습니다\"],\"DwsSVQ\":[\"필터 적용 및 새로 고침\"],\"E3W/zd\":[\"기본 닉네임\"],\"E6nRW7\":[\"URL 복사\"],\"E703RG\":[\"모드:\"],\"EAeu1Z\":[\"초대 보내기\"],\"EFKJQT\":[\"설정\"],\"EGPQBv\":[\"사용자 정의 플러드 규칙 (+f)\"],\"ELik0r\":[\"전체 개인정보 처리방침 보기\"],\"EPbeC2\":[\"채널 주제 보기 또는 편집\"],\"EQCDNT\":[\"oper 사용자 이름 입력...\"],\"EUvulZ\":[\"\\\"\",[\"searchQuery\"],\"\\\"와 일치하는 메시지 1개 발견\"],\"EatZYJ\":[\"다음 이미지\"],\"EdQY6l\":[\"없음\"],\"EnqLYU\":[\"서버 검색...\"],\"F0OKMc\":[\"서버 편집\"],\"F6Int2\":[\"강조 표시 활성화\"],\"FDoLyE\":[\"최대 사용자 수\"],\"FUU/hZ\":[\"채팅에서 로드할 외부 미디어의 범위를 제어하세요.\"],\"Fdp03t\":[\"켜기\"],\"FfPWR0\":[\"모달\"],\"FjkaiT\":[\"축소\"],\"FlqOE9\":[\"이것이 의미하는 바:\"],\"FolHNl\":[\"계정 및 인증 관리\"],\"Fp2Dif\":[\"서버에서 나갔습니다\"],\"G5KmCc\":[\"GZ-Line (글로벌 Z-Line)\"],\"GDs0lz\":[\"<0>위험: 민감한 정보(메시지, 비공개 대화, 인증 정보)가 네트워크 관리자나 IRC 서버 간에 위치한 공격자에게 노출될 수 있습니다.\"],\"GR+2I3\":[\"초대 마스크 추가 (예: nick!*@*, *!*@host.com)\"],\"GRLyMU\":[\"팝업 서버 알림 닫기\"],\"GlHnXw\":[\"닉네임 변경 실패: \",[\"error\"],\" \",[\"0\"]],\"GswZF3\":[\"미리보기:\"],\"GtmO8/\":[\"보낸 사람\"],\"GtuHUQ\":[\"서버에서 이 채널의 이름을 변경합니다. 모든 사용자에게 새 이름이 표시됩니다.\"],\"GuGfFX\":[\"검색 전환\"],\"GxkJXS\":[\"업로드 중...\"],\"GzbwnK\":[\"채널에 참여했습니다\"],\"GzsUDB\":[\"확장 프로필\"],\"H/PnT8\":[\"이모지 삽입\"],\"H6Izzl\":[\"선호하는 색상 코드\"],\"H9jIv+\":[\"입장/퇴장 표시\"],\"HAKBY9\":[\"파일 업로드\"],\"HdE1If\":[\"채널\"],\"Hk4AW9\":[\"선호하는 표시 이름\"],\"HmHDk7\":[\"멤버 선택\"],\"HrQzPU\":[[\"networkName\"],\"의 채널\"],\"I2tXQ5\":[\"@\",[\"0\"],\"에게 메시지 (Enter로 줄 바꿈, Shift+Enter로 전송)\"],\"I6bw/h\":[\"사용자 차단\"],\"I92Z+b\":[\"알림 활성화\"],\"I9D72S\":[\"이 메시지를 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.\"],\"IA+1wo\":[\"사용자가 채널에서 추방될 때 표시\"],\"IDwkJx\":[\"IRC Operator\"],\"ILlU+s\":[\"정보:\"],\"IUwGEM\":[\"변경 사항 저장\"],\"IVeGK6\":[[\"0\"],\"님, \",[\"1\"],\"님, \",[\"2\"],\"님이 입력 중...\"],\"IgrLD/\":[\"일시 정지\"],\"Im6JED\":[\"WHISPER\"],\"ImOQa9\":[\"답장\"],\"IoHMnl\":[\"최대값은 \",[\"0\"],\"입니다\"],\"IvMj+0\":[\"Op\"],\"J28zul\":[\"연결 중...\"],\"J5T9NW\":[\"사용자 정보\"],\"J8Y5+z\":[\"이런! 네트워크 분리! ⚠️\"],\"JBHkBA\":[\"채널을 나갔습니다\"],\"JCwL0Q\":[\"사유 입력 (선택 사항)\"],\"JFciKP\":[\"전환\"],\"JXGkhG\":[\"채널 이름 변경 (운영자 전용)\"],\"JcD7qf\":[\"추가 작업\"],\"JdkA+c\":[\"비밀 채널 (+s)\"],\"Jmu12l\":[\"서버 채널\"],\"JvQ++s\":[\"마크다운 활성화\"],\"K2jwh/\":[\"WHOIS 데이터를 사용할 수 없습니다\"],\"KAXSwC\":[\"Voice\"],\"KDfTdX\":[\"메시지 삭제\"],\"KKBlUU\":[\"임베드\"],\"KM0pLb\":[\"채널에 오신 것을 환영합니다!\"],\"KR6W2h\":[\"사용자 무시 해제\"],\"KV+Bi1\":[\"초대 전용 (+i)\"],\"KdCtwE\":[\"카운터를 초기화하기 전에 플러드 활동을 모니터링할 초 단위 시간\"],\"Kkezga\":[\"서버 비밀번호\"],\"KsiQ/8\":[\"채널 참여를 위해 초대가 필요합니다\"],\"L+gB/D\":[\"채널 정보\"],\"LC1a7n\":[\"IRC 서버에서 서버 간 링크의 보안 수준이 낮다고 보고했습니다. 즉, 메시지가 네트워크의 IRC 서버 간에 전달될 때 적절히 암호화되지 않거나 SSL/TLS 인증서가 올바르게 검증되지 않을 수 있습니다.\"],\"LNfLR5\":[\"추방 표시\"],\"LQb0W/\":[\"모든 이벤트 표시\"],\"LU7/yA\":[\"UI에 표시할 대체 이름입니다. 공백, 이모지, 특수 문자를 포함할 수 있습니다. IRC 명령에는 실제 채널 이름(\",[\"channelName\"],\")이 사용됩니다.\"],\"LUb9O7\":[\"올바른 서버 포트가 필요합니다\"],\"Lb+BUl\":[\"https://example.com/avatar.jpg\"],\"LcET2C\":[\"개인정보 처리방침\"],\"LcuSDR\":[\"프로필 정보 및 메타데이터 관리\"],\"LqLS9B\":[\"닉네임 변경 표시\"],\"LsDQt2\":[\"채널 설정\"],\"LtI9AS\":[\"소유자\"],\"LuNhhL\":[\"이 메시지에 반응했습니다\"],\"M/AZNG\":[\"아바타 이미지의 URL\"],\"M/WIer\":[\"메시지 보내기\"],\"M8er/5\":[\"이름:\"],\"MHk+7g\":[\"이전 이미지\"],\"MRorGe\":[\"사용자에게 PM\"],\"MVbSGP\":[\"시간 창 (초)\"],\"MkpcsT\":[\"메시지와 설정은 기기에 로컬로 저장됩니다\"],\"N/hDSy\":[\"봇으로 표시 - 보통 'on' 또는 비워두기\"],\"N7TQbE\":[[\"channelName\"],\"에 사용자 초대\"],\"NCca/o\":[\"기본 닉네임 입력...\"],\"Nqs6B9\":[\"모든 외부 미디어를 표시합니다. 모든 URL이 알 수 없는 서버에 요청을 보낼 수 있습니다.\"],\"Nt+9O7\":[\"원시 TCP 대신 WebSocket 사용\"],\"NxIHzc\":[\"사용자 연결 끊기\"],\"O+v/cL\":[\"서버의 모든 채널 검색\"],\"ODwSCk\":[\"GIF 보내기\"],\"OGQ5kK\":[\"알림 소리 및 강조 표시 설정\"],\"OIPt1Z\":[\"멤버 목록 사이드바 표시 또는 숨기기\"],\"OKSNq/\":[\"매우 엄격\"],\"ONWvwQ\":[\"업로드\"],\"OVKoQO\":[\"인증을 위한 계정 비밀번호\"],\"ObsidianIRC - Bringing IRC to the future\":[\"ObsidianIRC - IRC를 미래로\"],\"OhCpra\":[\"주제 설정…\"],\"OkltoQ\":[\"닉네임으로 \",[\"username\"],\" 차단 (동일 닉으로 재입장 방지)\"],\"P+t/Te\":[\"추가 데이터 없음\"],\"P42Wcc\":[\"안전\"],\"PD38l0\":[\"채널 아바타 미리보기\"],\"PD9mEt\":[\"메시지를 입력하세요...\"],\"PPqfdA\":[\"채널 구성 설정 열기\"],\"PSCjfZ\":[\"이 채널에 표시될 주제입니다. 모든 사용자가 주제를 볼 수 있습니다.\"],\"PZCecv\":[\"PDF 미리보기\"],\"PeLgsC\":[[\"c\",\"plural\",{\"other\":[[\"c\"],\"번\"]}]],\"PguS2C\":[\"예외 마스크 추가 (예: nick!*@*, *!*@host.com)\"],\"Pil5Ty\":[[\"0\"],\"개 채널 중 \",[\"displayedChannelsCount\"],\"개 표시\"],\"PqhVlJ\":[\"사용자 차단 (호스트마스크)\"],\"Q+chwU\":[\"사용자명:\"],\"Q6hhn8\":[\"환경설정\"],\"QF4a34\":[\"사용자 이름을 입력하세요\"],\"QGqSZ2\":[\"색상 및 서식\"],\"QJQd1J\":[\"프로필 편집\"],\"QSzGDE\":[\"유휴\"],\"QUlny5\":[[\"0\"],\"에 오신 것을 환영합니다!\"],\"Qoq+GP\":[\"더 보기\"],\"QuSkCF\":[\"채널 필터링...\"],\"QwUrDZ\":[\"주제를 변경했습니다: \",[\"topic\"]],\"R0UH07\":[[\"1\"],\"개 중 \",[\"0\"],\"번째 이미지\"],\"R7SsBE\":[\"음소거\"],\"R8rf1X\":[\"클릭하여 주제 설정\"],\"RArB3D\":[[\"username\"],\"에 의해 \",[\"channelName\"],\"에서 추방당했습니다\"],\"RI3cWd\":[\"ObsidianIRC와 함께 IRC의 세계를 탐험하세요\"],\"RMMaN5\":[\"발언권 제한 (+m)\"],\"RWw9Lg\":[\"모달 닫기\"],\"RZ2BuZ\":[[\"account\"],\" 계정 등록에 인증이 필요합니다: \",[\"message\"]],\"RySp6q\":[\"댓글 숨기기\"],\"SPKQTd\":[\"닉네임은 필수입니다\"],\"SPVjfj\":[\"비워두면 기본값인 '사유 없음'이 사용됩니다\"],\"SQKPvQ\":[\"사용자 초대\"],\"SkZcl+\":[\"미리 정의된 플러드 방지 프로필을 선택하세요. 각 프로필은 다양한 사용 사례에 맞게 균형 잡힌 보호 설정을 제공합니다.\"],\"Slr+3C\":[\"최소 사용자 수\"],\"Spnlre\":[[\"target\"],\"을(를) \",[\"channel\"],\"에 초대했습니다\"],\"T/ckN5\":[\"뷰어에서 열기\"],\"T91vKp\":[\"재생\"],\"TV2Wdu\":[\"데이터 처리 방식 및 개인정보 보호 방법을 알아보세요.\"],\"TgFpwD\":[\"적용 중...\"],\"TkzSFB\":[\"변경 사항 없음\"],\"TtserG\":[\"실명 입력\"],\"Ttz9J1\":[\"비밀번호 입력...\"],\"Tz0i8g\":[\"설정\"],\"U3pytU\":[\"관리자\"],\"UDb2YD\":[\"반응\"],\"UE4KO5\":[\"*channel*\"],\"UGT5vp\":[\"설정 저장\"],\"UV5hLB\":[\"차단된 사용자가 없습니다\"],\"Uaj3Nd\":[\"상태 메시지\"],\"Ue3uny\":[\"기본값 (프로필 없음)\"],\"UkARhe\":[\"기본 - 표준 보호\"],\"Umn7Cj\":[\"아직 댓글이 없습니다. 첫 번째 댓글을 남겨보세요!\"],\"UtUIRh\":[\"이전 메시지 \",[\"0\"],\"개\"],\"UwzP+U\":[\"보안 연결\"],\"V0/A4O\":[\"채널 소유자\"],\"V4qgxE\":[\"생성 시각 이전 (분 전)\"],\"V8yTm6\":[\"검색 지우기\"],\"VJMMyz\":[\"ObsidianIRC - IRC를 미래로\"],\"VJScHU\":[\"사유\"],\"VLsmVV\":[\"알림 음소거\"],\"VbyRUy\":[\"댓글\"],\"Vmx0mQ\":[\"설정자:\"],\"VqnIZz\":[\"개인정보 처리방침 및 데이터 관행 보기\"],\"VrMygG\":[\"최소 길이는 \",[\"0\"],\"자입니다\"],\"VrnTui\":[\"프로필에 표시되는 대명사\"],\"W8E3qn\":[\"인증된 계정\"],\"WAakm9\":[\"채널 삭제\"],\"WFxTHC\":[\"차단 마스크 추가 (예: nick!*@*, *!*@host.com)\"],\"WN1g9F\":[\"서버 호스트는 필수입니다\"],\"WRYdXW\":[\"오디오 재생 위치\"],\"WUOH5B\":[\"사용자 무시\"],\"WWEXnZ\":[[\"0\",\"plural\",{\"other\":[[\"1\"],\"개 더 보기\"]}]],\"Weq9zb\":[\"일반\"],\"Wfj7Sk\":[\"알림 소리 음소거 또는 해제\"],\"Wm7gbG\":[\"GitHub:\"],\"WyeHWY\":[\"*spam*\"],\"WzMCru\":[\"사용자 프로필\"],\"X6S3lt\":[\"설정, 채널, 서버 검색...\"],\"XEHan5\":[\"계속 진행\"],\"XI1+wb\":[\"잘못된 형식\"],\"XIXeuC\":[\"@\",[\"0\"],\"에게 메시지 보내기\"],\"XMS+k4\":[\"비공개 메시지 시작\"],\"XWgxXq\":[\"앨범\"],\"Xd7+IT\":[\"비공개 채팅 고정 해제\"],\"Xm/s+u\":[\"화면 표시\"],\"Xp2n93\":[\"서버의 신뢰할 수 있는 파일 호스트의 미디어를 표시합니다. 외부 서비스에 요청이 전송되지 않습니다.\"],\"XvjC4F\":[\"저장 중...\"],\"Y/qryO\":[\"검색 결과와 일치하는 사용자가 없습니다\"],\"YAqRpI\":[[\"account\"],\" 계정 등록 성공: \",[\"message\"]],\"YEfzvP\":[\"주제 보호 (+t)\"],\"YQOn6a\":[\"멤버 목록 접기\"],\"YRCoE9\":[\"채널 Op\"],\"YURQaF\":[\"프로필 보기\"],\"YdBSvr\":[\"미디어 표시 및 외부 콘텐츠 제어\"],\"Yj6U3V\":[\"중앙 서버 없음:\"],\"YjvpGx\":[\"대명사\"],\"YqH4l4\":[\"키 없음\"],\"YyUPpV\":[\"계정:\"],\"ZJSWfw\":[\"서버 연결 종료 시 표시할 메시지\"],\"ZR1dJ4\":[\"초대\"],\"ZdWg0V\":[\"브라우저에서 열기\"],\"ZhRBbl\":[\"메시지 검색…\"],\"Zmcu3y\":[\"고급 필터\"],\"a2/8e5\":[\"주제 설정 이후 (분 전)\"],\"aHKcKc\":[\"이전 페이지\"],\"aJTbXX\":[\"Oper 비밀번호\"],\"aQryQv\":[\"이미 존재하는 패턴입니다\"],\"aW9pLN\":[\"채널에 허용되는 최대 사용자 수입니다. 제한 없이 두려면 비워두세요.\"],\"ah4fmZ\":[\"YouTube, Vimeo, SoundCloud 등 알려진 서비스의 미리보기도 표시합니다.\"],\"aifXak\":[\"이 채널에 미디어가 없습니다\"],\"ap2zBz\":[\"완화\"],\"az8lvo\":[\"끄기\"],\"azXSNo\":[\"멤버 목록 펼치기\"],\"azdliB\":[\"계정에 로그인\"],\"b26wlF\":[\"그녀/그녀의\"],\"bD/+Ei\":[\"엄격\"],\"bQ6BJn\":[\"상세한 플러드 방지 규칙을 설정하세요. 각 규칙은 모니터링할 활동 유형과 임계값 초과 시 취할 조치를 지정합니다.\"],\"beV7+y\":[\"사용자가 \",[\"channelName\"],\" 참여 초대를 받게 됩니다.\"],\"bk84cH\":[\"자리 비움 메시지\"],\"bkHdLj\":[\"IRC 서버 추가\"],\"bmQLn5\":[\"규칙 추가\"],\"bwRvnp\":[\"작업\"],\"c8+EVZ\":[\"인증된 계정\"],\"cGYUlD\":[\"미디어 미리보기가 로드되지 않습니다.\"],\"cLF98o\":[\"댓글 보기 (\",[\"commentCount\"],\")\"],\"cLKIDO\":[\"사용 가능한 사용자가 없습니다\"],\"cSgpoS\":[\"비공개 채팅 고정\"],\"cde3ce\":[\"<0>\",[\"0\"],\"에게 메시지\"],\"chQsxg\":[\"형식화된 출력 복사\"],\"cl/A5J\":[[\"__DEFAULT_IRC_SERVER_NAME__\"],\"에 오신 것을 환영합니다!\"],\"cnGeoo\":[\"삭제\"],\"coPLXT\":[\"IRC 통신 내용은 서버에 저장되지 않습니다\"],\"crYH/6\":[\"SoundCloud 플레이어\"],\"d3sis4\":[\"서버 추가\"],\"d9aN5k\":[\"채널에서 \",[\"username\"],\" 제거\"],\"dEgA5A\":[\"취소\"],\"dGi1We\":[\"이 비공개 메시지 대화 고정 해제\"],\"dJVuyC\":[[\"channelName\"],\"에서 나갔습니다 (\",[\"reason\"],\")\"],\"dMtLDE\":[\"받는 사람\"],\"dXqxlh\":[\"<0>⚠️ 보안 위험! 이 연결은 도청 또는 중간자 공격에 취약할 수 있습니다.\"],\"da9Q/R\":[\"채널 모드 변경됨\"],\"dhJN3N\":[\"댓글 보기\"],\"dj2xTE\":[\"알림 닫기\"],\"dpCzmC\":[\"플러드 방지 설정\"],\"e9dQpT\":[\"이 링크를 새 탭에서 여시겠습니까?\"],\"ePK91l\":[\"편집\"],\"eYBDuB\":[\"이미지를 업로드하거나 동적 크기 조정을 위해 \",[\"size\"],\" 대체가 있는 URL을 제공하세요\"],\"edBbee\":[\"호스트마스크로 \",[\"username\"],\" 차단 (동일 IP/호스트에서 재입장 방지)\"],\"ekfzWq\":[\"사용자 설정\"],\"elPDWs\":[\"IRC 클라이언트 환경을 맞춤 설정하세요\"],\"eu2osY\":[\"<0>💡 권장사항: 이 서버를 신뢰하고 위험을 충분히 이해한 경우에만 계속하세요. 이 연결을 통해 민감한 정보나 비밀번호를 공유하지 마세요.\"],\"euEhbr\":[[\"channel\"],\"에 참여하려면 클릭\"],\"ez3vLd\":[\"여러 줄 입력 활성화\"],\"f0J5Ki\":[\"서버 간 통신에 암호화되지 않은 연결이 사용될 수 있습니다\"],\"f9BHJk\":[\"사용자 경고\"],\"fDOLLd\":[\"채널을 찾을 수 없습니다.\"],\"ffzDkB\":[\"익명 분석:\"],\"fq1GF9\":[\"사용자가 서버에서 연결을 끊을 때 표시\"],\"gEF57C\":[\"이 서버는 하나의 연결 유형만 지원합니다\"],\"gJuLUI\":[\"무시 목록\"],\"gNzMrk\":[\"현재 아바타\"],\"gjPWyO\":[\"닉네임 입력...\"],\"gz6UQ3\":[\"최대화\"],\"h6razj\":[\"채널 이름 마스크 제외\"],\"hG6jnw\":[\"설정된 주제 없음\"],\"hG89Ed\":[\"이미지\"],\"hZ6znB\":[\"포트\"],\"ha+Bz5\":[\"예: 100:1440\"],\"hehnjM\":[\"횟수\"],\"hzdLuQ\":[\"Voice 이상의 권한을 가진 사용자만 발언 가능\"],\"i0qMbr\":[\"홈\"],\"iDNBZe\":[\"알림\"],\"iH8pgl\":[\"뒤로\"],\"iL9SZg\":[\"사용자 차단 (닉네임)\"],\"iNt+3c\":[\"이미지로 돌아가기\"],\"iQvi+a\":[\"이 서버의 낮은 링크 보안에 대해 다시 경고하지 않음\"],\"iSLIjg\":[\"연결\"],\"iWXkHH\":[\"Halfop\"],\"iZeTtp\":[\"서버 호스트\"],\"idD8Ev\":[\"저장됨\"],\"iivqkW\":[\"접속 시각\"],\"ij+Elv\":[\"이미지 미리보기\"],\"ilIWp7\":[\"알림 전환\"],\"iuaqvB\":[\"와일드카드로 *를 사용하세요. 예: baduser!*@*, *!*@spammer.com, troll*!*@*\"],\"ixkTse\":[\"봇\"],\"j2DGR0\":[\"호스트마스크로 차단\"],\"jA4uoI\":[\"주제:\"],\"jLXxGK\":[\"https://example.com\"],\"jPSk57\":[\"사유 (선택 사항)\"],\"jUV7CU\":[\"아바타 업로드\"],\"jW5Uwh\":[\"로드할 외부 미디어의 범위를 제어합니다. 끄기 / 안전 / 신뢰할 수 있는 출처 / 모든 콘텐츠.\"],\"jXzms5\":[\"첨부 옵션\"],\"jZlrte\":[\"색상\"],\"jfC/xh\":[\"연락처\"],\"jywMpv\":[\"#new-channel-name\"],\"k112DD\":[\"이전 메시지 불러오기\"],\"k3ID0F\":[\"멤버 필터링…\"],\"k65gsE\":[\"자세히 보기\"],\"k7Zgob\":[\"연결 취소\"],\"kAVx5h\":[\"초대가 없습니다\"],\"kCLEPU\":[\"연결된 서버\"],\"kF5LKb\":[\"무시된 패턴:\"],\"kGeOx/\":[[\"0\"],\" 참가\"],\"kITKr8\":[\"채널 모드 불러오는 중...\"],\"kPpPsw\":[\"당신은 IRC Operator입니다\"],\"kWJmRL\":[\"나\"],\"kfcRb0\":[\"아바타\"],\"kjMqSj\":[\"JSON 복사\"],\"krViRy\":[\"JSON으로 복사하려면 클릭\"],\"ks71ra\":[\"예외\"],\"kw4lRv\":[\"채널 Halfop\"],\"kxgIRq\":[\"시작하려면 채널을 선택하거나 추가하세요.\"],\"ky6dWe\":[\"아바타 미리보기\"],\"l+GxCv\":[\"채널 불러오는 중...\"],\"l+IUVW\":[[\"account\"],\" 계정 인증 성공: \",[\"message\"]],\"l/siQz\":[[\"reconnectCount\",\"plural\",{\"other\":[[\"reconnectCount\"],\"번 재연결됨\"]}]],\"l5jmzx\":[[\"0\"],\"님과 \",[\"1\"],\"님이 입력 중...\"],\"lHy8N5\":[\"채널 더 불러오는 중...\"],\"lbpf14\":[[\"value\"],\" 참여\"],\"lfFsZ4\":[\"채널\"],\"lkNdiH\":[\"계정 이름\"],\"ln500L\":[\"ObsidianIRC\"],\"lnCMdg\":[\"이미지 업로드\"],\"loQxaJ\":[\"돌아왔습니다\"],\"lvfaxv\":[\"홈\"],\"m16xKo\":[\"추가\"],\"m8flAk\":[\"미리보기 (아직 업로드되지 않음)\"],\"mEPxTp\":[\"<0>⚠️ 주의하세요! 신뢰할 수 있는 출처의 링크만 여세요. 악성 링크는 보안이나 개인정보를 침해할 수 있습니다.\"],\"mHGdhG\":[\"서버 정보\"],\"mHS8lb\":[\"#\",[\"0\"],\"에 메시지 보내기\"],\"mMYBD9\":[\"광역 - 더 넓은 보호 범위\"],\"mTGsPd\":[\"채널 주제\"],\"mU8j6O\":[\"외부 메시지 차단 (+n)\"],\"mZp8FL\":[\"자동으로 한 줄 모드로 전환\"],\"mdQu8G\":[\"YourNickname\"],\"miSSBQ\":[\"댓글 (\",[\"commentCount\"],\")\"],\"mvyLSy\":[\"인증된 사용자\"],\"mwtcGl\":[\"댓글 닫기\"],\"mzI/c+\":[\"다운로드\"],\"n3fGRk\":[[\"0\"],\"이(가) 설정\"],\"nE9jsU\":[\"완화 - 덜 공격적인 보호\"],\"nNflMD\":[\"채널 나가기\"],\"nPXkBi\":[\"WHOIS 데이터 불러오는 중...\"],\"nQnxxF\":[\"#\",[\"0\"],\"에 메시지 (Shift+Enter로 줄 바꿈)\"],\"nWMRxa\":[\"고정 해제\"],\"nkC032\":[\"플러드 프로필 없음\"],\"o69z4d\":[[\"username\"],\"에게 경고 메시지 보내기\"],\"o9ylQi\":[\"GIF를 검색하여 시작하세요\"],\"oFGkER\":[\"서버 알림\"],\"oOi11l\":[\"맨 아래로 스크롤\"],\"oQEzQR\":[\"새 DM\"],\"oXOSPE\":[\"온라인\"],\"oal760\":[\"서버 링크에 대한 중간자 공격이 가능합니다\"],\"oeqmmJ\":[\"신뢰할 수 있는 출처\"],\"ovBPCi\":[\"기본값\"],\"p0Z69r\":[\"패턴은 비워둘 수 없습니다\"],\"p1KgtK\":[\"오디오를 불러오지 못했습니다\"],\"p59pEv\":[\"추가 세부정보\"],\"p7sRI6\":[\"입력 중임을 다른 사람에게 알림\"],\"pBm1od\":[\"비밀 채널\"],\"pNmiXx\":[\"모든 서버에 사용할 기본 닉네임\"],\"pUUo9G\":[\"호스트명:\"],\"pVGPmz\":[\"계정 비밀번호\"],\"peNE68\":[\"영구\"],\"plhHQt\":[\"데이터 없음\"],\"pm6+q5\":[\"보안 경고\"],\"pn5qSs\":[\"추가 정보\"],\"q0cR4S\":[\"이제 **\",[\"newNick\"],\"**(으)로 알려져 있습니다\"],\"qFcunY\":[\"LIST 또는 NAMES 명령에 채널이 표시되지 않음\"],\"qLpTm/\":[[\"emoji\"],\" 반응 제거\"],\"qVkGWK\":[\"고정\"],\"qY8wNa\":[\"홈페이지\"],\"qb0xJ7\":[\"와일드카드 사용: *는 임의의 문자열, ?는 임의의 단일 문자. 예: nick!*@*, *!*@host.com, *!*user@*\"],\"qhzpRq\":[\"채널 키 (+k)\"],\"qtoOYG\":[\"제한 없음\"],\"r1W2AS\":[\"파일 호스트 이미지\"],\"rIPR2O\":[\"주제 설정 이전 (분 전)\"],\"rMMSYo\":[\"최대 길이는 \",[\"0\"],\"자입니다\"],\"rWtzQe\":[\"네트워크가 분리되었다가 재연결되었습니다. ✅\"],\"rYG2u6\":[\"잠시만 기다려 주세요...\"],\"rdUucN\":[\"미리보기\"],\"rjGI/Q\":[\"개인정보 보호\"],\"rk8iDX\":[\"GIF 불러오는 중...\"],\"rn6SBY\":[\"음소거 해제\"],\"s/UKqq\":[\"채널에서 추방되었습니다\"],\"s8cATI\":[[\"channelName\"],\"에 참가했습니다\"],\"sCO9ue\":[\"<0>\",[\"serverName\"],\"에 대한 연결에 다음과 같은 보안 문제가 있습니다:\"],\"sGH11W\":[\"서버\"],\"sHI1H+\":[\"이제 **\",[\"newNick\"],\"**(으)로 알려져 있습니다\"],\"sJyV04\":[[\"inviter\"],\"이(가) 당신을 \",[\"channel\"],\"에 초대했습니다\"],\"sby+1/\":[\"클릭하여 복사\"],\"sfN25C\":[\"실명 또는 전체 이름\"],\"sliuzR\":[\"링크 열기\"],\"sqrO9R\":[\"사용자 정의 멘션\"],\"sr6RdJ\":[\"Shift+Enter로 여러 줄 입력\"],\"swrCpB\":[[\"user\"],\"이(가) 채널을 \",[\"oldName\"],\"에서 \",[\"newName\"],\"(으)로 이름을 변경했습니다\",[\"0\"]],\"sxkWRg\":[\"고급\"],\"t/YqKh\":[\"제거\"],\"t47eHD\":[\"이 서버에서의 고유 식별자\"],\"tAkAh0\":[\"동적 크기 조정을 위한 \",[\"size\"],\" 대체가 있는 URL. 예: https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"tXLJS3\":[\"채널 목록 사이드바 표시 또는 숨기기\"],\"tfDRzk\":[\"저장\"],\"tiBsJk\":[[\"channelName\"],\"에서 나갔습니다\"],\"tt4/UD\":[\"서버를 나갔습니다 (\",[\"reason\"],\")\"],\"u0TcnO\":[\"닉네임 {nick}이(가) 이미 사용 중입니다. {newNick}(으)로 다시 시도합니다\"],\"u0a8B4\":[\"관리 권한을 위해 IRC Operator로 인증\"],\"u0rWFU\":[\"생성 시각 이후 (분 전)\"],\"u72w3t\":[\"무시할 사용자 및 패턴\"],\"u7jc2L\":[\"서버를 나갔습니다\"],\"uAQUqI\":[\"상태\"],\"uB85T3\":[\"저장 실패: \",[\"msg\"]],\"uV3DOL\":[\"G-Line\"],\"uW3lLI\":[\"IRC 서버:\"],\"usSSr/\":[\"확대/축소 수준\"],\"v7uvcf\":[\"소프트웨어:\"],\"vE8kb+\":[\"줄 바꿈은 Shift+Enter (Enter로 전송)\"],\"vERlcd\":[\"프로필\"],\"vK0RL8\":[\"주제 없음\"],\"vSJd18\":[\"동영상\"],\"vXIe7J\":[\"언어\"],\"vaHYxN\":[\"실명\"],\"vhjbKr\":[\"자리 비움\"],\"w4NYox\":[[\"title\"],\" 클라이언트\"],\"w8xQRx\":[\"잘못된 값\"],\"wFjjxZ\":[[\"username\"],\"에 의해 \",[\"channelName\"],\"에서 추방당했습니다 (\",[\"reason\"],\")\"],\"wGjaGl\":[\"차단 예외가 없습니다\"],\"wPrGnM\":[\"채널 관리자\"],\"wRkP2d\":[\"GIF\"],\"wbm86v\":[\"사용자가 채널에 입장하거나 퇴장할 때 표시\"],\"whqZ9r\":[\"강조할 추가 단어 또는 문구\"],\"wm7RV4\":[\"알림 소리\"],\"wz/Yoq\":[\"서버 간 전달 시 메시지가 도청될 수 있습니다\"],\"xCJdfg\":[\"지우기\"],\"xUHRTR\":[\"연결 시 자동으로 operator로 인증\"],\"xWHwwQ\":[\"차단 목록\"],\"xYilR2\":[\"미디어\"],\"xceQrO\":[\"보안 웹소켓만 지원됩니다\"],\"xdtXa+\":[\"채널-이름\"],\"xfXC7q\":[\"텍스트 채널\"],\"xlCYOE\":[\"메시지를 더 불러오는 중...\"],\"xlhswE\":[\"최솟값은 \",[\"0\"],\"입니다\"],\"xq97Ci\":[\"단어나 문구 추가...\"],\"xuRqRq\":[\"클라이언트 제한 (+l)\"],\"xwF+7J\":[[\"0\"],\"님이 입력 중...\"],\"yNeucF\":[\"이 서버는 확장 프로필 메타데이터(IRCv3 METADATA 확장)를 지원하지 않습니다. 아바타, 표시 이름, 상태 등의 추가 필드를 사용할 수 없습니다.\"],\"yPlrca\":[\"채널 아바타\"],\"yQE2r9\":[\"로딩 중\"],\"ySU+JY\":[\"your@email.com\"],\"yTX1Rt\":[\"Oper 사용자 이름\"],\"yYOzWD\":[\"로그\"],\"yfx9Re\":[\"IRC operator 비밀번호\"],\"ygCKqB\":[\"정지\"],\"ymDxJx\":[\"IRC operator 사용자 이름\"],\"yrpRsQ\":[\"이름순 정렬\"],\"yz7wBu\":[\"닫기\"],\"zJw+jA\":[\"모드 설정: \",[\"0\"]],\"zebeLu\":[\"oper 사용자 이름 입력\"],\"zpr0Bw\":[\"GZ-Line\"]}"); \ No newline at end of file +/*eslint-disable*/export const messages=JSON.parse("{\"+5VMrz\":[\"잘못된 패턴 형식입니다. nick!user@host 형식을 사용하세요 (와일드카드 * 허용)\"],\"+6NQQA\":[\"일반 지원 채널\"],\"+6NyRG\":[\"클라이언트\"],\"+K0AvT\":[\"연결 끊기\"],\"+cyFdH\":[\"자리 비움 설정 시 기본 메시지\"],\"+mVPqU\":[\"메시지에서 마크다운 서식 렌더링\"],\"+vqCJH\":[\"인증을 위한 계정 사용자 이름\"],\"+yPBXI\":[\"파일 선택\"],\"+zy2Nq\":[\"유형\"],\"/09cao\":[\"낮은 링크 보안 (레벨 \",[\"securityLevel\"],\")\"],\"/3BQ4J\":[\"채널 외부 사용자는 메시지를 보낼 수 없습니다\"],\"/6BzZF\":[\"멤버 목록 전환\"],\"/TNOPk\":[\"자리 비움 중\"],\"/XQgft\":[\"채널 탐색\"],\"/cF7Rs\":[\"볼륨\"],\"/dqduX\":[\"다음 페이지\"],\"/fc3q4\":[\"모든 콘텐츠\"],\"/kISDh\":[\"알림 소리 활성화\"],\"/n04sB\":[\"Kill\"],\"/rTz0M\":[\"오디오\"],\"/rfkZe\":[\"멘션 및 메시지에 소리 재생\"],\"0/0ZGA\":[\"채널 이름 마스크\"],\"0D6j7U\":[\"사용자 정의 규칙에 대해 더 알아보기 →\"],\"0XsHcR\":[\"사용자 추방\"],\"0ZpE//\":[\"사용자 수순 정렬\"],\"0bEPwz\":[\"자리 비움 설정\"],\"0dGkPt\":[\"채널 목록 펼치기\"],\"0gS7M5\":[\"표시 이름\"],\"0kS+M8\":[\"예시네트워크\"],\"0rgoY7\":[\"직접 선택한 서버에만 연결합니다\"],\"0wdd7X\":[\"참여\"],\"0wkVYx\":[\"비공개 메시지\"],\"111uHX\":[\"링크 미리보기\"],\"196EG4\":[\"비공개 채팅 삭제\"],\"1DSr1i\":[\"계정 등록\"],\"1O/24y\":[\"채널 목록 전환\"],\"1TNIig\":[\"Open\"],\"1VPJJ2\":[\"외부 링크 경고\"],\"1ZC/dv\":[\"읽지 않은 멘션이나 메시지가 없습니다\"],\"1pO1zi\":[\"서버 이름은 필수입니다\"],\"1uwfzQ\":[\"채널 주제 보기\"],\"268g7c\":[\"표시 이름 입력\"],\"2FOFq1\":[\"네트워크 서버 운영자가 메시지를 읽을 수 있습니다\"],\"2FYpfJ\":[\"더 보기\"],\"2HF1Y2\":[[\"inviter\"],\"이(가) \",[\"target\"],\"을(를) \",[\"channel\"],\"에 초대했습니다\"],\"2I70QL\":[\"사용자 프로필 정보 보기\"],\"2QYdmE\":[\"사용자:\"],\"2QpEjG\":[\"퇴장했습니다\"],\"2YE223\":[\"#\",[\"0\"],\"에 메시지 (Enter로 줄 바꿈, Shift+Enter로 전송)\"],\"2bimFY\":[\"서버 비밀번호 사용\"],\"2iTmdZ\":[\"로컬 저장소:\"],\"2odkwe\":[\"엄격 - 더 강력한 보호\"],\"2uDhbA\":[\"초대할 사용자 이름 입력\"],\"2ygf/L\":[\"← 뒤로\"],\"2zEgxj\":[\"GIF 검색...\"],\"3RdPhl\":[\"채널 이름 변경\"],\"3THokf\":[\"Voice 사용자\"],\"3TSz9S\":[\"최소화\"],\"3jBDvM\":[\"채널 표시 이름\"],\"3ryuFU\":[\"앱 개선을 위한 선택적 충돌 보고서\"],\"3uBF/8\":[\"뷰어 닫기\"],\"3uwW8F\":[\"https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"3xf8Kz\":[\"계정 이름 입력...\"],\"4/Rr0R\":[\"현재 채널에 사용자 초대\"],\"4EZrJN\":[\"규칙\"],\"4JJtW9\":[\"#overflow\"],\"4NqeT4\":[\"플러드 프로필 (+F)\"],\"4RZQRK\":[\"지금 뭐 하세요?\"],\"4hfTrB\":[\"닉네임\"],\"4n99LO\":[\"이미 \",[\"0\"],\"에 있음\"],\"4t6vMV\":[\"짧은 메시지의 경우 자동으로 한 줄 모드로 전환\"],\"4vsHmf\":[\"시간 (분)\"],\"4x/Axu\":[\"바운서에 아직 네트워크가 없습니다. 시작하려면 하나를 추가하세요.\"],\"5+INAX\":[\"나를 멘션한 메시지 강조 표시\"],\"5R5Pv/\":[\"Oper 이름\"],\"678PKt\":[\"네트워크 이름\"],\"6Aih4U\":[\"오프라인\"],\"6CO3WE\":[\"채널 참여에 필요한 비밀번호입니다. 키를 제거하려면 비워두세요.\"],\"6HhMs3\":[\"QUIT 메시지\"],\"6V3Ea3\":[\"복사됨\"],\"6lGV3K\":[\"접기\"],\"6yFOEi\":[\"oper 비밀번호 입력...\"],\"7+IHTZ\":[\"파일 선택 안 함\"],\"73fnil\":[\"TLS\"],\"73hrRi\":[\"nick!user@host (예: spam*!*@*, *!*@badhost.com)\"],\"7QkKyN\":[\"비공개 메시지 보내기\"],\"7U1W7c\":[\"매우 완화\"],\"7Y1YQj\":[\"실명:\"],\"7YHArF\":[\"— 뷰어에서 열기\"],\"7fjnVl\":[\"사용자 검색...\"],\"7jL88x\":[\"이 메시지를 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.\"],\"7nGhhM\":[\"무슨 생각을 하고 계신가요?\"],\"7sEpu1\":[\"멤버 — \",[\"0\"],\"명\"],\"7sNhEz\":[\"사용자 이름\"],\"8H0Q+x\":[\"프로필에 대해 더 알아보기 →\"],\"8Phu0A\":[\"사용자가 닉네임을 변경할 때 표시\"],\"8XTG9e\":[\"oper 비밀번호 입력\"],\"8XsV2J\":[\"다시 보내기\"],\"8ZsakT\":[\"비밀번호\"],\"8kR84m\":[\"외부 링크를 열려고 합니다:\"],\"8lCgih\":[\"규칙 제거\"],\"8p/xVT\":[[\"0\",\"plural\",{\"one\":[[\"1\"]],\"other\":[[\"2\"]]}]],\"8wRzac\":[[\"joinCount\",\"plural\",{\"other\":[[\"joinCount\"],\"번 참가했음\"]}]],\"9BMLnJ\":[\"서버에 재연결\"],\"9OEgyT\":[\"반응 추가\"],\"9PQ8m2\":[\"G-Line (글로벌 밴)\"],\"9Qs99X\":[\"이메일:\"],\"9QupBP\":[\"패턴 제거\"],\"9W7tl5\":[\"(변경 없음)\"],\"9bG48P\":[\"보내는 중\"],\"9f5f0u\":[\"개인정보 보호에 관한 문의사항이 있으신가요? 문의처:\"],\"9iweoP\":[[\"0\"],\"의 네트워크\"],\"9unqs3\":[\"자리비움:\"],\"9v3hwv\":[\"서버를 찾을 수 없습니다.\"],\"9zb2WA\":[\"연결 중\"],\"A1taO8\":[\"검색\"],\"A2adVi\":[\"입력 중 알림 보내기\"],\"A9Rhec\":[\"채널 이름\"],\"AWOSPo\":[\"확대\"],\"AXSpEQ\":[\"연결 시 Oper 인증\"],\"AeXO77\":[\"계정\"],\"AhNP40\":[\"탐색\"],\"Ai2U7L\":[\"호스트\"],\"AjBQnf\":[\"닉네임 변경됨\"],\"AmXVh6\":[\"https://example.com/avatar.png\"],\"AnRu/j\":[\"답장 취소\"],\"ApSx0O\":[\"\\\"\",[\"searchQuery\"],\"\\\"와 일치하는 메시지 \",[\"0\"],\"개 발견\"],\"AxPAXW\":[\"결과를 찾을 수 없습니다\"],\"AyNqAB\":[\"채팅에 모든 서버 이벤트 표시\"],\"B/QqGw\":[\"자리 비움 (AFK)\"],\"B0sB2k\":[\"평문\"],\"B8AaMI\":[\"이 필드는 필수입니다\"],\"BA2c49\":[\"서버가 고급 LIST 필터링을 지원하지 않습니다\"],\"BDKt3I\":[[\"0\"],\"님, \",[\"1\"],\"님, \",[\"2\"],\"님 외 \",[\"3\"],\"명이 입력 중...\"],\"BGul2A\":[\"저장되지 않은 변경 사항이 있습니다. 저장하지 않고 닫으시겠습니까?\"],\"BIf9fi\":[\"상태 메시지\"],\"BZz3md\":[\"개인 웹사이트\"],\"Bgm/H7\":[\"여러 줄 텍스트 입력 허용\"],\"BiQIl1\":[\"이 비공개 메시지 대화 고정\"],\"BlNZZ2\":[\"클릭하여 메시지로 이동\"],\"Bowq3c\":[\"운영자만 채널 주제를 변경할 수 있음\"],\"Btozzp\":[\"이 이미지가 만료되었습니다\"],\"Bycfjm\":[\"전체: \",[\"0\"]],\"C6IBQc\":[\"전체 JSON 복사\"],\"C9L9wL\":[\"데이터 수집\"],\"CDq4wC\":[\"사용자 제재\"],\"CHVRxG\":[\"@\",[\"0\"],\"에게 메시지 (Shift+Enter로 줄 바꿈)\"],\"CN9zdR\":[\"Oper 이름과 비밀번호는 필수입니다\"],\"CW3sYa\":[[\"emoji\"],\" 반응 추가\"],\"CaAkqd\":[\"QUIT 표시\"],\"CbvaYj\":[\"닉네임으로 차단\"],\"CcK+Ft\":[\"PDF\"],\"Ce8q3L\":[\"채널 선택\"],\"CsekCi\":[\"기본\"],\"D+NlUC\":[\"시스템\"],\"D28t6+\":[\"참여했다가 퇴장했습니다\"],\"DB8zMK\":[\"적용\"],\"DBcWHr\":[\"사용자 정의 알림 소리 파일\"],\"DTy9Xw\":[\"미디어 미리보기\"],\"Dj4pSr\":[\"안전한 비밀번호를 선택하세요\"],\"Du+zn+\":[\"검색 중...\"],\"Du2T2f\":[\"설정을 찾을 수 없습니다\"],\"DwsSVQ\":[\"필터 적용 및 새로 고침\"],\"E3W/zd\":[\"기본 닉네임\"],\"E6nRW7\":[\"URL 복사\"],\"E703RG\":[\"모드:\"],\"EAeu1Z\":[\"초대 보내기\"],\"EFKJQT\":[\"설정\"],\"EGPQBv\":[\"사용자 정의 플러드 규칙 (+f)\"],\"ELik0r\":[\"전체 개인정보 처리방침 보기\"],\"EPbeC2\":[\"채널 주제 보기 또는 편집\"],\"EQCDNT\":[\"oper 사용자 이름 입력...\"],\"EUvulZ\":[\"\\\"\",[\"searchQuery\"],\"\\\"와 일치하는 메시지 1개 발견\"],\"EatZYJ\":[\"다음 이미지\"],\"EdQY6l\":[\"없음\"],\"EnqLYU\":[\"서버 검색...\"],\"F0OKMc\":[\"서버 편집\"],\"F6Int2\":[\"강조 표시 활성화\"],\"FDoLyE\":[\"최대 사용자 수\"],\"FUU/hZ\":[\"채팅에서 로드할 외부 미디어의 범위를 제어하세요.\"],\"Fdp03t\":[\"켜기\"],\"FfPWR0\":[\"모달\"],\"FjkaiT\":[\"축소\"],\"FlqOE9\":[\"이것이 의미하는 바:\"],\"FolHNl\":[\"계정 및 인증 관리\"],\"Fp2Dif\":[\"서버에서 나갔습니다\"],\"G5KmCc\":[\"GZ-Line (글로벌 Z-Line)\"],\"GDs0lz\":[\"<0>위험: 민감한 정보(메시지, 비공개 대화, 인증 정보)가 네트워크 관리자나 IRC 서버 간에 위치한 공격자에게 노출될 수 있습니다.\"],\"GR+2I3\":[\"초대 마스크 추가 (예: nick!*@*, *!*@host.com)\"],\"GRLyMU\":[\"팝업 서버 알림 닫기\"],\"GlHnXw\":[\"닉네임 변경 실패: \",[\"error\"],\" \",[\"0\"]],\"GswZF3\":[\"미리보기:\"],\"GtmO8/\":[\"보낸 사람\"],\"GtuHUQ\":[\"서버에서 이 채널의 이름을 변경합니다. 모든 사용자에게 새 이름이 표시됩니다.\"],\"GuGfFX\":[\"검색 전환\"],\"GxkJXS\":[\"업로드 중...\"],\"GzbwnK\":[\"채널에 참여했습니다\"],\"GzsUDB\":[\"확장 프로필\"],\"H/PnT8\":[\"이모지 삽입\"],\"H6Izzl\":[\"선호하는 색상 코드\"],\"H9jIv+\":[\"입장/퇴장 표시\"],\"HAKBY9\":[\"파일 업로드\"],\"HdE1If\":[\"채널\"],\"Hk4AW9\":[\"선호하는 표시 이름\"],\"HmHDk7\":[\"멤버 선택\"],\"HrQzPU\":[[\"networkName\"],\"의 채널\"],\"I2tXQ5\":[\"@\",[\"0\"],\"에게 메시지 (Enter로 줄 바꿈, Shift+Enter로 전송)\"],\"I6bw/h\":[\"사용자 차단\"],\"I92Z+b\":[\"알림 활성화\"],\"I9D72S\":[\"이 메시지를 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.\"],\"IA+1wo\":[\"사용자가 채널에서 추방될 때 표시\"],\"IDwkJx\":[\"IRC Operator\"],\"ILlU+s\":[\"정보:\"],\"IUwGEM\":[\"변경 사항 저장\"],\"IVeGK6\":[[\"0\"],\"님, \",[\"1\"],\"님, \",[\"2\"],\"님이 입력 중...\"],\"IgrLD/\":[\"일시 정지\"],\"Im6JED\":[\"WHISPER\"],\"ImOQa9\":[\"답장\"],\"IoHMnl\":[\"최대값은 \",[\"0\"],\"입니다\"],\"IvMj+0\":[\"Op\"],\"J28zul\":[\"연결 중...\"],\"J5T9NW\":[\"사용자 정보\"],\"J8Y5+z\":[\"이런! 네트워크 분리! ⚠️\"],\"JBHkBA\":[\"채널을 나갔습니다\"],\"JCwL0Q\":[\"사유 입력 (선택 사항)\"],\"JFciKP\":[\"전환\"],\"JXGkhG\":[\"채널 이름 변경 (운영자 전용)\"],\"JcD7qf\":[\"추가 작업\"],\"JdkA+c\":[\"비밀 채널 (+s)\"],\"Jmu12l\":[\"서버 채널\"],\"JvQ++s\":[\"마크다운 활성화\"],\"K2jwh/\":[\"WHOIS 데이터를 사용할 수 없습니다\"],\"KAXSwC\":[\"Voice\"],\"KDfTdX\":[\"메시지 삭제\"],\"KKBlUU\":[\"임베드\"],\"KM0pLb\":[\"채널에 오신 것을 환영합니다!\"],\"KR6W2h\":[\"사용자 무시 해제\"],\"KV+Bi1\":[\"초대 전용 (+i)\"],\"KdCtwE\":[\"카운터를 초기화하기 전에 플러드 활동을 모니터링할 초 단위 시간\"],\"Kkezga\":[\"서버 비밀번호\"],\"KsiQ/8\":[\"채널 참여를 위해 초대가 필요합니다\"],\"L+gB/D\":[\"채널 정보\"],\"LC1a7n\":[\"IRC 서버에서 서버 간 링크의 보안 수준이 낮다고 보고했습니다. 즉, 메시지가 네트워크의 IRC 서버 간에 전달될 때 적절히 암호화되지 않거나 SSL/TLS 인증서가 올바르게 검증되지 않을 수 있습니다.\"],\"LNfLR5\":[\"추방 표시\"],\"LP+1Z7\":[\"네트워크 추가\"],\"LQb0W/\":[\"모든 이벤트 표시\"],\"LU7/yA\":[\"UI에 표시할 대체 이름입니다. 공백, 이모지, 특수 문자를 포함할 수 있습니다. IRC 명령에는 실제 채널 이름(\",[\"channelName\"],\")이 사용됩니다.\"],\"LUb9O7\":[\"올바른 서버 포트가 필요합니다\"],\"Lb+BUl\":[\"https://example.com/avatar.jpg\"],\"LcET2C\":[\"개인정보 처리방침\"],\"LcuSDR\":[\"프로필 정보 및 메타데이터 관리\"],\"LqLS9B\":[\"닉네임 변경 표시\"],\"LsDQt2\":[\"채널 설정\"],\"LtI9AS\":[\"소유자\"],\"LuNhhL\":[\"이 메시지에 반응했습니다\"],\"M/AZNG\":[\"아바타 이미지의 URL\"],\"M/WIer\":[\"메시지 보내기\"],\"M8er/5\":[\"이름:\"],\"MHk+7g\":[\"이전 이미지\"],\"MRorGe\":[\"사용자에게 PM\"],\"MVbSGP\":[\"시간 창 (초)\"],\"MkpcsT\":[\"메시지와 설정은 기기에 로컬로 저장됩니다\"],\"MzPdC2\":[\"서버 비밀번호 (PASS)\"],\"N/hDSy\":[\"봇으로 표시 - 보통 'on' 또는 비워두기\"],\"N6j2JH\":[[\"0\"],\" 편집\"],\"N7TQbE\":[[\"channelName\"],\"에 사용자 초대\"],\"NCca/o\":[\"기본 닉네임 입력...\"],\"Nqs6B9\":[\"모든 외부 미디어를 표시합니다. 모든 URL이 알 수 없는 서버에 요청을 보낼 수 있습니다.\"],\"Nt+9O7\":[\"원시 TCP 대신 WebSocket 사용\"],\"NxIHzc\":[\"사용자 연결 끊기\"],\"O+v/cL\":[\"서버의 모든 채널 검색\"],\"OCGpR4\":[\"(상속)\"],\"ODwSCk\":[\"GIF 보내기\"],\"OGQ5kK\":[\"알림 소리 및 강조 표시 설정\"],\"OIPt1Z\":[\"멤버 목록 사이드바 표시 또는 숨기기\"],\"OKSNq/\":[\"매우 엄격\"],\"ONWvwQ\":[\"업로드\"],\"OVKoQO\":[\"인증을 위한 계정 비밀번호\"],\"ObsidianIRC - Bringing IRC to the future\":[\"ObsidianIRC - IRC를 미래로\"],\"OhCpra\":[\"주제 설정…\"],\"OkltoQ\":[\"닉네임으로 \",[\"username\"],\" 차단 (동일 닉으로 재입장 방지)\"],\"P+t/Te\":[\"추가 데이터 없음\"],\"P42Wcc\":[\"안전\"],\"PD38l0\":[\"채널 아바타 미리보기\"],\"PD9mEt\":[\"메시지를 입력하세요...\"],\"PPqfdA\":[\"채널 구성 설정 열기\"],\"PSCjfZ\":[\"이 채널에 표시될 주제입니다. 모든 사용자가 주제를 볼 수 있습니다.\"],\"PZCecv\":[\"PDF 미리보기\"],\"PeLgsC\":[[\"c\",\"plural\",{\"other\":[[\"c\"],\"번\"]}]],\"PguS2C\":[\"예외 마스크 추가 (예: nick!*@*, *!*@host.com)\"],\"Pil5Ty\":[[\"0\"],\"개 채널 중 \",[\"displayedChannelsCount\"],\"개 표시\"],\"PqhVlJ\":[\"사용자 차단 (호스트마스크)\"],\"Q+chwU\":[\"사용자명:\"],\"Q3v9Wc\":[\"예, 삭제\"],\"Q6hhn8\":[\"환경설정\"],\"QF4a34\":[\"사용자 이름을 입력하세요\"],\"QGqSZ2\":[\"색상 및 서식\"],\"QJQd1J\":[\"프로필 편집\"],\"QSzGDE\":[\"유휴\"],\"QUlny5\":[[\"0\"],\"에 오신 것을 환영합니다!\"],\"Qoq+GP\":[\"더 보기\"],\"QuSkCF\":[\"채널 필터링...\"],\"QwUrDZ\":[\"주제를 변경했습니다: \",[\"topic\"]],\"R0UH07\":[[\"1\"],\"개 중 \",[\"0\"],\"번째 이미지\"],\"R7SsBE\":[\"음소거\"],\"R8rf1X\":[\"클릭하여 주제 설정\"],\"RArB3D\":[[\"username\"],\"에 의해 \",[\"channelName\"],\"에서 추방당했습니다\"],\"RI3cWd\":[\"ObsidianIRC와 함께 IRC의 세계를 탐험하세요\"],\"RMMaN5\":[\"발언권 제한 (+m)\"],\"RWw9Lg\":[\"모달 닫기\"],\"RZ2BuZ\":[[\"account\"],\" 계정 등록에 인증이 필요합니다: \",[\"message\"]],\"RySp6q\":[\"댓글 숨기기\"],\"S5Togi\":[\"바운서에서 네트워크 불러오는 중…\"],\"SPKQTd\":[\"닉네임은 필수입니다\"],\"SPVjfj\":[\"비워두면 기본값인 '사유 없음'이 사용됩니다\"],\"SQKPvQ\":[\"사용자 초대\"],\"STmlpb\":[\"Back to network list\"],\"SkZcl+\":[\"미리 정의된 플러드 방지 프로필을 선택하세요. 각 프로필은 다양한 사용 사례에 맞게 균형 잡힌 보호 설정을 제공합니다.\"],\"Slr+3C\":[\"최소 사용자 수\"],\"Spnlre\":[[\"target\"],\"을(를) \",[\"channel\"],\"에 초대했습니다\"],\"T/ckN5\":[\"뷰어에서 열기\"],\"T91vKp\":[\"재생\"],\"TV2Wdu\":[\"데이터 처리 방식 및 개인정보 보호 방법을 알아보세요.\"],\"TgFpwD\":[\"적용 중...\"],\"TkzSFB\":[\"변경 사항 없음\"],\"TtserG\":[\"실명 입력\"],\"Ttz9J1\":[\"비밀번호 입력...\"],\"Tz0i8g\":[\"설정\"],\"U3pytU\":[\"관리자\"],\"UDb2YD\":[\"반응\"],\"UE4KO5\":[\"*channel*\"],\"UGT5vp\":[\"설정 저장\"],\"UV5hLB\":[\"차단된 사용자가 없습니다\"],\"Uaj3Nd\":[\"상태 메시지\"],\"Ue3uny\":[\"기본값 (프로필 없음)\"],\"UkARhe\":[\"기본 - 표준 보호\"],\"Umn7Cj\":[\"아직 댓글이 없습니다. 첫 번째 댓글을 남겨보세요!\"],\"UtUIRh\":[\"이전 메시지 \",[\"0\"],\"개\"],\"UwzP+U\":[\"보안 연결\"],\"V0/A4O\":[\"채널 소유자\"],\"V4qgxE\":[\"생성 시각 이전 (분 전)\"],\"V8yTm6\":[\"검색 지우기\"],\"VJMMyz\":[\"ObsidianIRC - IRC를 미래로\"],\"VJScHU\":[\"사유\"],\"VLsmVV\":[\"알림 음소거\"],\"VbyRUy\":[\"댓글\"],\"Vmx0mQ\":[\"설정자:\"],\"VqnIZz\":[\"개인정보 처리방침 및 데이터 관행 보기\"],\"VrMygG\":[\"최소 길이는 \",[\"0\"],\"자입니다\"],\"VrnTui\":[\"프로필에 표시되는 대명사\"],\"W8E3qn\":[\"인증된 계정\"],\"WAakm9\":[\"채널 삭제\"],\"WFxTHC\":[\"차단 마스크 추가 (예: nick!*@*, *!*@host.com)\"],\"WN1g9F\":[\"서버 호스트는 필수입니다\"],\"WRYdXW\":[\"오디오 재생 위치\"],\"WUOH5B\":[\"사용자 무시\"],\"WWEXnZ\":[[\"0\",\"plural\",{\"other\":[[\"1\"],\"개 더 보기\"]}]],\"Weq9zb\":[\"일반\"],\"Wfj7Sk\":[\"알림 소리 음소거 또는 해제\"],\"Wm7gbG\":[\"GitHub:\"],\"WyeHWY\":[\"*spam*\"],\"WzMCru\":[\"사용자 프로필\"],\"X6S3lt\":[\"설정, 채널, 서버 검색...\"],\"XEHan5\":[\"계속 진행\"],\"XI1+wb\":[\"잘못된 형식\"],\"XIXeuC\":[\"@\",[\"0\"],\"에게 메시지 보내기\"],\"XMS+k4\":[\"비공개 메시지 시작\"],\"XWgxXq\":[\"앨범\"],\"Xd7+IT\":[\"비공개 채팅 고정 해제\"],\"Xm/s+u\":[\"화면 표시\"],\"Xp2n93\":[\"서버의 신뢰할 수 있는 파일 호스트의 미디어를 표시합니다. 외부 서비스에 요청이 전송되지 않습니다.\"],\"XvjC4F\":[\"저장 중...\"],\"Y/qryO\":[\"검색 결과와 일치하는 사용자가 없습니다\"],\"YAqRpI\":[[\"account\"],\" 계정 등록 성공: \",[\"message\"]],\"YEfzvP\":[\"주제 보호 (+t)\"],\"YQOn6a\":[\"멤버 목록 접기\"],\"YRCoE9\":[\"채널 Op\"],\"YURQaF\":[\"프로필 보기\"],\"YdBSvr\":[\"미디어 표시 및 외부 콘텐츠 제어\"],\"Yj6U3V\":[\"중앙 서버 없음:\"],\"YjvpGx\":[\"대명사\"],\"YqH4l4\":[\"키 없음\"],\"YyUPpV\":[\"계정:\"],\"ZJSWfw\":[\"서버 연결 종료 시 표시할 메시지\"],\"ZR1dJ4\":[\"초대\"],\"ZdWg0V\":[\"브라우저에서 열기\"],\"ZhRBbl\":[\"메시지 검색…\"],\"Zmcu3y\":[\"고급 필터\"],\"a2/8e5\":[\"주제 설정 이후 (분 전)\"],\"aHKcKc\":[\"이전 페이지\"],\"aJTbXX\":[\"Oper 비밀번호\"],\"aQryQv\":[\"이미 존재하는 패턴입니다\"],\"aW9pLN\":[\"채널에 허용되는 최대 사용자 수입니다. 제한 없이 두려면 비워두세요.\"],\"ah4fmZ\":[\"YouTube, Vimeo, SoundCloud 등 알려진 서비스의 미리보기도 표시합니다.\"],\"aifXak\":[\"이 채널에 미디어가 없습니다\"],\"ap2zBz\":[\"완화\"],\"az8lvo\":[\"끄기\"],\"azXSNo\":[\"멤버 목록 펼치기\"],\"azdliB\":[\"계정에 로그인\"],\"b26wlF\":[\"그녀/그녀의\"],\"bD/+Ei\":[\"엄격\"],\"bQ6BJn\":[\"상세한 플러드 방지 규칙을 설정하세요. 각 규칙은 모니터링할 활동 유형과 임계값 초과 시 취할 조치를 지정합니다.\"],\"beV7+y\":[\"사용자가 \",[\"channelName\"],\" 참여 초대를 받게 됩니다.\"],\"bk84cH\":[\"자리 비움 메시지\"],\"bkHdLj\":[\"IRC 서버 추가\"],\"bmQLn5\":[\"규칙 추가\"],\"bv4cFj\":[\"전송 방식\"],\"bwRvnp\":[\"작업\"],\"c8+EVZ\":[\"인증된 계정\"],\"cGYUlD\":[\"미디어 미리보기가 로드되지 않습니다.\"],\"cLF98o\":[\"댓글 보기 (\",[\"commentCount\"],\")\"],\"cLKIDO\":[\"사용 가능한 사용자가 없습니다\"],\"cSgpoS\":[\"비공개 채팅 고정\"],\"cde3ce\":[\"<0>\",[\"0\"],\"에게 메시지\"],\"chQsxg\":[\"형식화된 출력 복사\"],\"cl/A5J\":[[\"__DEFAULT_IRC_SERVER_NAME__\"],\"에 오신 것을 환영합니다!\"],\"cnGeoo\":[\"삭제\"],\"coPLXT\":[\"IRC 통신 내용은 서버에 저장되지 않습니다\"],\"crYH/6\":[\"SoundCloud 플레이어\"],\"cv5DQb\":[\"호스트가 설정되지 않음\"],\"d3sis4\":[\"서버 추가\"],\"d9aN5k\":[\"채널에서 \",[\"username\"],\" 제거\"],\"dEgA5A\":[\"취소\"],\"dGi1We\":[\"이 비공개 메시지 대화 고정 해제\"],\"dJVuyC\":[[\"channelName\"],\"에서 나갔습니다 (\",[\"reason\"],\")\"],\"dMtLDE\":[\"받는 사람\"],\"dXqxlh\":[\"<0>⚠️ 보안 위험! 이 연결은 도청 또는 중간자 공격에 취약할 수 있습니다.\"],\"da9Q/R\":[\"채널 모드 변경됨\"],\"dhJN3N\":[\"댓글 보기\"],\"dj2xTE\":[\"알림 닫기\"],\"dpCzmC\":[\"플러드 방지 설정\"],\"e9dQpT\":[\"이 링크를 새 탭에서 여시겠습니까?\"],\"ePK91l\":[\"편집\"],\"eYBDuB\":[\"이미지를 업로드하거나 동적 크기 조정을 위해 \",[\"size\"],\" 대체가 있는 URL을 제공하세요\"],\"edBbee\":[\"호스트마스크로 \",[\"username\"],\" 차단 (동일 IP/호스트에서 재입장 방지)\"],\"ekfzWq\":[\"사용자 설정\"],\"elPDWs\":[\"IRC 클라이언트 환경을 맞춤 설정하세요\"],\"eu2osY\":[\"<0>💡 권장사항: 이 서버를 신뢰하고 위험을 충분히 이해한 경우에만 계속하세요. 이 연결을 통해 민감한 정보나 비밀번호를 공유하지 마세요.\"],\"euEhbr\":[[\"channel\"],\"에 참여하려면 클릭\"],\"ez3vLd\":[\"여러 줄 입력 활성화\"],\"f0J5Ki\":[\"서버 간 통신에 암호화되지 않은 연결이 사용될 수 있습니다\"],\"f9BHJk\":[\"사용자 경고\"],\"fDOLLd\":[\"채널을 찾을 수 없습니다.\"],\"ffzDkB\":[\"익명 분석:\"],\"fq1GF9\":[\"사용자가 서버에서 연결을 끊을 때 표시\"],\"gEF57C\":[\"이 서버는 하나의 연결 유형만 지원합니다\"],\"gJuLUI\":[\"무시 목록\"],\"gNzMrk\":[\"현재 아바타\"],\"gjPWyO\":[\"닉네임 입력...\"],\"gz6UQ3\":[\"최대화\"],\"h6/IMX\":[\"첫 번째 네트워크 추가\"],\"h6razj\":[\"채널 이름 마스크 제외\"],\"hG6jnw\":[\"설정된 주제 없음\"],\"hG89Ed\":[\"이미지\"],\"hZ6znB\":[\"포트\"],\"ha+Bz5\":[\"예: 100:1440\"],\"hehnjM\":[\"횟수\"],\"hzdLuQ\":[\"Voice 이상의 권한을 가진 사용자만 발언 가능\"],\"i0qMbr\":[\"홈\"],\"iDNBZe\":[\"알림\"],\"iH8pgl\":[\"뒤로\"],\"iL9SZg\":[\"사용자 차단 (닉네임)\"],\"iNt+3c\":[\"이미지로 돌아가기\"],\"iQvi+a\":[\"이 서버의 낮은 링크 보안에 대해 다시 경고하지 않음\"],\"iSLIjg\":[\"연결\"],\"iWXkHH\":[\"Halfop\"],\"iZeTtp\":[\"서버 호스트\"],\"idD8Ev\":[\"저장됨\"],\"iivqkW\":[\"접속 시각\"],\"ij+Elv\":[\"이미지 미리보기\"],\"ilIWp7\":[\"알림 전환\"],\"iuaqvB\":[\"와일드카드로 *를 사용하세요. 예: baduser!*@*, *!*@spammer.com, troll*!*@*\"],\"ixkTse\":[\"봇\"],\"j2DGR0\":[\"호스트마스크로 차단\"],\"jA4uoI\":[\"주제:\"],\"jLXxGK\":[\"https://example.com\"],\"jPSk57\":[\"사유 (선택 사항)\"],\"jUV7CU\":[\"아바타 업로드\"],\"jW5Uwh\":[\"로드할 외부 미디어의 범위를 제어합니다. 끄기 / 안전 / 신뢰할 수 있는 출처 / 모든 콘텐츠.\"],\"jXzms5\":[\"첨부 옵션\"],\"jZlrte\":[\"색상\"],\"jfC/xh\":[\"연락처\"],\"jywMpv\":[\"#new-channel-name\"],\"k112DD\":[\"이전 메시지 불러오기\"],\"k3ID0F\":[\"멤버 필터링…\"],\"k65gsE\":[\"자세히 보기\"],\"k7Zgob\":[\"연결 취소\"],\"kAVx5h\":[\"초대가 없습니다\"],\"kCLEPU\":[\"연결된 서버\"],\"kF5LKb\":[\"무시된 패턴:\"],\"kGeOx/\":[[\"0\"],\" 참가\"],\"kITKr8\":[\"채널 모드 불러오는 중...\"],\"kPpPsw\":[\"당신은 IRC Operator입니다\"],\"kWJmRL\":[\"나\"],\"kfcRb0\":[\"아바타\"],\"kjMqSj\":[\"JSON 복사\"],\"krViRy\":[\"JSON으로 복사하려면 클릭\"],\"ks71ra\":[\"예외\"],\"kw4lRv\":[\"채널 Halfop\"],\"kxgIRq\":[\"시작하려면 채널을 선택하거나 추가하세요.\"],\"ky6dWe\":[\"아바타 미리보기\"],\"l+GxCv\":[\"채널 불러오는 중...\"],\"l+IUVW\":[[\"account\"],\" 계정 인증 성공: \",[\"message\"]],\"l/siQz\":[[\"reconnectCount\",\"plural\",{\"other\":[[\"reconnectCount\"],\"번 재연결됨\"]}]],\"l5jmzx\":[[\"0\"],\"님과 \",[\"1\"],\"님이 입력 중...\"],\"lHy8N5\":[\"채널 더 불러오는 중...\"],\"lbpf14\":[[\"value\"],\" 참여\"],\"lfFsZ4\":[\"채널\"],\"lkNdiH\":[\"계정 이름\"],\"ln500L\":[\"ObsidianIRC\"],\"lnCMdg\":[\"이미지 업로드\"],\"loQxaJ\":[\"돌아왔습니다\"],\"lvfaxv\":[\"홈\"],\"m0oxpP\":[\"Libera Chat\"],\"m16xKo\":[\"추가\"],\"m8flAk\":[\"미리보기 (아직 업로드되지 않음)\"],\"mEPxTp\":[\"<0>⚠️ 주의하세요! 신뢰할 수 있는 출처의 링크만 여세요. 악성 링크는 보안이나 개인정보를 침해할 수 있습니다.\"],\"mHGdhG\":[\"서버 정보\"],\"mHS8lb\":[\"#\",[\"0\"],\"에 메시지 보내기\"],\"mMYBD9\":[\"광역 - 더 넓은 보호 범위\"],\"mTGsPd\":[\"채널 주제\"],\"mU8j6O\":[\"외부 메시지 차단 (+n)\"],\"mZp8FL\":[\"자동으로 한 줄 모드로 전환\"],\"mdQu8G\":[\"YourNickname\"],\"miSSBQ\":[\"댓글 (\",[\"commentCount\"],\")\"],\"mvyLSy\":[\"인증된 사용자\"],\"mwtcGl\":[\"댓글 닫기\"],\"myL0MR\":[\"이 네트워크를 삭제하시겠습니까?\"],\"mzI/c+\":[\"다운로드\"],\"n3fGRk\":[[\"0\"],\"이(가) 설정\"],\"nE9jsU\":[\"완화 - 덜 공격적인 보호\"],\"nNflMD\":[\"채널 나가기\"],\"nPXkBi\":[\"WHOIS 데이터 불러오는 중...\"],\"nQnxxF\":[\"#\",[\"0\"],\"에 메시지 (Shift+Enter로 줄 바꿈)\"],\"nWMRxa\":[\"고정 해제\"],\"nkC032\":[\"플러드 프로필 없음\"],\"o69z4d\":[[\"username\"],\"에게 경고 메시지 보내기\"],\"o9ylQi\":[\"GIF를 검색하여 시작하세요\"],\"oFGkER\":[\"서버 알림\"],\"oOi11l\":[\"맨 아래로 스크롤\"],\"oQEzQR\":[\"새 DM\"],\"oXOSPE\":[\"온라인\"],\"oal760\":[\"서버 링크에 대한 중간자 공격이 가능합니다\"],\"oeqmmJ\":[\"신뢰할 수 있는 출처\"],\"ovBPCi\":[\"기본값\"],\"p0Z69r\":[\"패턴은 비워둘 수 없습니다\"],\"p1KgtK\":[\"오디오를 불러오지 못했습니다\"],\"p59pEv\":[\"추가 세부정보\"],\"p7sRI6\":[\"입력 중임을 다른 사람에게 알림\"],\"pBm1od\":[\"비밀 채널\"],\"pNmiXx\":[\"모든 서버에 사용할 기본 닉네임\"],\"pUUo9G\":[\"호스트명:\"],\"pVGPmz\":[\"계정 비밀번호\"],\"peNE68\":[\"영구\"],\"plhHQt\":[\"데이터 없음\"],\"pm6+q5\":[\"보안 경고\"],\"pn5qSs\":[\"추가 정보\"],\"q0cR4S\":[\"이제 **\",[\"newNick\"],\"**(으)로 알려져 있습니다\"],\"qFcunY\":[\"LIST 또는 NAMES 명령에 채널이 표시되지 않음\"],\"qLpTm/\":[[\"emoji\"],\" 반응 제거\"],\"qVkGWK\":[\"고정\"],\"qY8wNa\":[\"홈페이지\"],\"qb0xJ7\":[\"와일드카드 사용: *는 임의의 문자열, ?는 임의의 단일 문자. 예: nick!*@*, *!*@host.com, *!*user@*\"],\"qhzpRq\":[\"채널 키 (+k)\"],\"qtoOYG\":[\"제한 없음\"],\"r1W2AS\":[\"파일 호스트 이미지\"],\"rIPR2O\":[\"주제 설정 이전 (분 전)\"],\"rMMSYo\":[\"최대 길이는 \",[\"0\"],\"자입니다\"],\"rWtzQe\":[\"네트워크가 분리되었다가 재연결되었습니다. ✅\"],\"rYG2u6\":[\"잠시만 기다려 주세요...\"],\"rdUucN\":[\"미리보기\"],\"rjGI/Q\":[\"개인정보 보호\"],\"rk8iDX\":[\"GIF 불러오는 중...\"],\"rn6SBY\":[\"음소거 해제\"],\"s/UKqq\":[\"채널에서 추방되었습니다\"],\"s8cATI\":[[\"channelName\"],\"에 참가했습니다\"],\"sCO9ue\":[\"<0>\",[\"serverName\"],\"에 대한 연결에 다음과 같은 보안 문제가 있습니다:\"],\"sGH11W\":[\"서버\"],\"sHI1H+\":[\"이제 **\",[\"newNick\"],\"**(으)로 알려져 있습니다\"],\"sJyV04\":[[\"inviter\"],\"이(가) 당신을 \",[\"channel\"],\"에 초대했습니다\"],\"sUBSbK\":[\"아직 업스트림 네트워크가 없습니다.\"],\"sby+1/\":[\"클릭하여 복사\"],\"sfN25C\":[\"실명 또는 전체 이름\"],\"sliuzR\":[\"링크 열기\"],\"sqrO9R\":[\"사용자 정의 멘션\"],\"sr6RdJ\":[\"Shift+Enter로 여러 줄 입력\"],\"swrCpB\":[[\"user\"],\"이(가) 채널을 \",[\"oldName\"],\"에서 \",[\"newName\"],\"(으)로 이름을 변경했습니다\",[\"0\"]],\"sxkWRg\":[\"고급\"],\"t/YqKh\":[\"제거\"],\"t47eHD\":[\"이 서버에서의 고유 식별자\"],\"tAkAh0\":[\"동적 크기 조정을 위한 \",[\"size\"],\" 대체가 있는 URL. 예: https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"tXLJS3\":[\"채널 목록 사이드바 표시 또는 숨기기\"],\"tfDRzk\":[\"저장\"],\"tiBsJk\":[[\"channelName\"],\"에서 나갔습니다\"],\"tt4/UD\":[\"서버를 나갔습니다 (\",[\"reason\"],\")\"],\"u0TcnO\":[\"닉네임 {nick}이(가) 이미 사용 중입니다. {newNick}(으)로 다시 시도합니다\"],\"u0a8B4\":[\"관리 권한을 위해 IRC Operator로 인증\"],\"u0rWFU\":[\"생성 시각 이후 (분 전)\"],\"u72w3t\":[\"무시할 사용자 및 패턴\"],\"u7jc2L\":[\"서버를 나갔습니다\"],\"uAQUqI\":[\"상태\"],\"uB85T3\":[\"저장 실패: \",[\"msg\"]],\"uV3DOL\":[\"G-Line\"],\"uW3lLI\":[\"IRC 서버:\"],\"usSSr/\":[\"확대/축소 수준\"],\"v7uvcf\":[\"소프트웨어:\"],\"vE8kb+\":[\"줄 바꿈은 Shift+Enter (Enter로 전송)\"],\"vERlcd\":[\"프로필\"],\"vK0RL8\":[\"주제 없음\"],\"vSJd18\":[\"동영상\"],\"vXIe7J\":[\"언어\"],\"vaHYxN\":[\"실명\"],\"vhjbKr\":[\"자리 비움\"],\"w/nogd\":[[\"0\"],\" network\",[\"1\"],\" — pick one to join\"],\"w4NYox\":[[\"title\"],\" 클라이언트\"],\"w8xQRx\":[\"잘못된 값\"],\"wFjjxZ\":[[\"username\"],\"에 의해 \",[\"channelName\"],\"에서 추방당했습니다 (\",[\"reason\"],\")\"],\"wGjaGl\":[\"차단 예외가 없습니다\"],\"wPrGnM\":[\"채널 관리자\"],\"wRkP2d\":[\"GIF\"],\"wbm86v\":[\"사용자가 채널에 입장하거나 퇴장할 때 표시\"],\"whqZ9r\":[\"강조할 추가 단어 또는 문구\"],\"wm7RV4\":[\"알림 소리\"],\"wz/Yoq\":[\"서버 간 전달 시 메시지가 도청될 수 있습니다\"],\"xCJdfg\":[\"지우기\"],\"xUHRTR\":[\"연결 시 자동으로 operator로 인증\"],\"xWHwwQ\":[\"차단 목록\"],\"xYilR2\":[\"미디어\"],\"xceQrO\":[\"보안 웹소켓만 지원됩니다\"],\"xdtXa+\":[\"채널-이름\"],\"xfXC7q\":[\"텍스트 채널\"],\"xlCYOE\":[\"메시지를 더 불러오는 중...\"],\"xlhswE\":[\"최솟값은 \",[\"0\"],\"입니다\"],\"xq97Ci\":[\"단어나 문구 추가...\"],\"xuRqRq\":[\"클라이언트 제한 (+l)\"],\"xwF+7J\":[[\"0\"],\"님이 입력 중...\"],\"yJztBY\":[\"네트워크 삭제\"],\"yNeucF\":[\"이 서버는 확장 프로필 메타데이터(IRCv3 METADATA 확장)를 지원하지 않습니다. 아바타, 표시 이름, 상태 등의 추가 필드를 사용할 수 없습니다.\"],\"yPlrca\":[\"채널 아바타\"],\"yQE2r9\":[\"로딩 중\"],\"ySU+JY\":[\"your@email.com\"],\"yTX1Rt\":[\"Oper 사용자 이름\"],\"yYOzWD\":[\"로그\"],\"yfx9Re\":[\"IRC operator 비밀번호\"],\"ygCKqB\":[\"정지\"],\"ymDxJx\":[\"IRC operator 사용자 이름\"],\"yrpRsQ\":[\"이름순 정렬\"],\"yz7wBu\":[\"닫기\"],\"zJw+jA\":[\"모드 설정: \",[\"0\"]],\"zebeLu\":[\"oper 사용자 이름 입력\"],\"zpr0Bw\":[\"GZ-Line\"]}"); \ No newline at end of file diff --git a/src/locales/ko/messages.po b/src/locales/ko/messages.po index ff0ac637..55aa9fe9 100644 --- a/src/locales/ko/messages.po +++ b/src/locales/ko/messages.po @@ -22,6 +22,16 @@ msgstr "ObsidianIRC - IRC를 미래로" msgid "— open in viewer" msgstr "— 뷰어에서 열기" +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx +msgid "(inherit)" +msgstr "(상속)" + +#: src/components/ui/BouncerNetworkForm.tsx +msgid "(unchanged)" +msgstr "(변경 없음)" + #. placeholder {0}: filteredMessages.length #. placeholder {1}: import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import type * as React from "react"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { SCROLL_TOLERANCE, useScrollToBottom, } from "../../hooks/useScrollToBottom"; import { groupConsecutiveEvents } from "../../lib/eventGrouping"; import ircClient from "../../lib/ircClient"; import useStore from "../../store"; import type { Message as MessageType } from "../../types"; import { CollapsedEventMessage } from "../message/CollapsedEventMessage"; import { MessageItem } from "../message/MessageItem"; import LoadingSpinner from "../ui/LoadingSpinner"; import { ScrollToBottomButton } from "../ui/ScrollToBottomButton"; export const DEFAULT_VISIBLE_MESSAGE_COUNT = 100; // Stable empty array — prevents selector from returning a new [] on every render // when the channel has no messages yet (undefined ?? [] would create a new ref each time). const EMPTY_MESSAGES: import("../../types").Message[] = []; export interface ChannelMessageListHandle { setAtBottom: () => void; scrollToBottom: () => void; getScrollState: () => { scrollTop: number; isAtBottom: boolean; visibleCount: number; }; } interface ChannelMessageListProps { channelKey: string; serverId: string; channelId: string | null; privateChatId: string | null; isActive: boolean; searchQuery: string; isMemberListVisible: boolean; onReply: (msg: MessageType | null) => void; onUsernameContextMenu: ( e: React.MouseEvent, username: string, serverId: string, channelId: string, avatarEl?: Element | null, ) => void; onIrcLinkClick: (url: string) => void; onReactClick: (msg: MessageType, el: Element) => void; onReactionUnreact: (emoji: string, msg: MessageType) => void; onOpenReactionModal: ( msg: MessageType, position: { x: number; y: number }, ) => void; onDirectReaction: (emoji: string, msg: MessageType) => void; onRedactMessage: (msg: MessageType) => void; onOpenProfile: (username: string) => void; joinChannel: (serverId: string, channelName: string) => void; onClearSearch: () => void; highlightedMessageId?: string; // undefined = first visit; null = was at bottom; object = restore to saved position initialScrollState?: { scrollTop: number; visibleCount: number } | null; } export const ChannelMessageList = forwardRef< ChannelMessageListHandle, ChannelMessageListProps >( ( { channelKey, serverId, channelId, privateChatId, isActive, searchQuery, isMemberListVisible, onReply, onUsernameContextMenu, onIrcLinkClick, onReactClick, onReactionUnreact, onOpenReactionModal, onDirectReaction, onRedactMessage, onOpenProfile, joinChannel, onClearSearch, highlightedMessageId, initialScrollState, }, ref, ) => { const { t } = useLingui(); const [visibleMessageCount, setVisibleMessageCount] = useState( initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT, ); // Ref mirror so getScrollState closure always reads the current value without needing it as a dep. const visibleMessageCountRef = useRef(visibleMessageCount); visibleMessageCountRef.current = visibleMessageCount; // Distinguishes initial join (full-screen spinner) from subsequent "load more" (button spinner). const [isFetchingMore, setIsFetchingMore] = useState(false); const isFetchingMoreRef = useRef(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const messagesInnerRef = useRef(null); // prev scrollHeight for prepend delta-correction. const prevScrollHeightRef = useRef(0); // Ref mirror of isScrolledUp — lets useLayoutEffect closures read current value // without listing isScrolledUp as a dep (which would re-run effects on every scroll). const isScrolledUpRef = useRef(false); const prevFilteredLengthRef = useRef(0); const prevFirstMsgIdRef = useRef(null); // Set by the window-growth layoutEffect (or button handler) when a true prepend is detected. // Consumed by the delta-correction layoutEffect one render later (after visibleCount grows). // Using a flag instead of tracking displayedMessages[0]?.id because slice(-N) slides the // window on every bottom append, changing displayedMessages[0] even for non-prepend renders. const pendingPrependRef = useRef(false); // Shared scrollHeight baseline between the delta-correction layout effect and the inner // ResizeObserver. When scrollTop is corrected after a prepend, we update this so the RO's // "was at bottom" check is not fooled by the adjusted scrollTop vs its stale prevSH. const resizeObserverPrevSHRef = useRef(0); const channelMessages = useStore( useCallback( (state) => state.messages[channelKey] ?? EMPTY_MESSAGES, [channelKey], ), ); const servers = useStore((state) => state.servers); const mobileViewActiveColumn = useStore( (state) => state.ui.mobileViewActiveColumn, ); const channel = useMemo( () => channelId ? (servers .find((s) => s.id === serverId) ?.channels.find((c) => c.id === channelId) ?? null) : null, [servers, serverId, channelId], ); const { isScrolledUp, wasAtBottomRef, scrollToBottom } = useScrollToBottom( messagesContainerRef, messagesEndRef, { channelId: `${channelId || privateChatId}-${isMemberListVisible}` }, ); // Snapshot of the last known scroll position captured while the container was visible. // getScrollState() reads this instead of the live DOM because React commits display:none // before running cleanup effects, collapsing scrollTop/scrollHeight/clientHeight to 0. const lastScrollTopRef = useRef(initialScrollState?.scrollTop ?? 0); useEffect(() => { const container = messagesContainerRef.current; if (!container) return; const onScroll = () => { if (container.clientHeight > 0) lastScrollTopRef.current = container.scrollTop; }; container.addEventListener("scroll", onScroll, { passive: true }); return () => container.removeEventListener("scroll", onScroll); }, []); // Restore scroll position when a keep-alive channel transitions from hidden to visible. // display:none may reset scrollTop to 0; lastScrollTopRef was captured while visible. const prevActiveRef = useRef(isActive); useLayoutEffect(() => { if (isActive && !prevActiveRef.current) { const container = messagesContainerRef.current; if (container && lastScrollTopRef.current > 0) { container.scrollTop = lastScrollTopRef.current; } } prevActiveRef.current = isActive; }, [isActive]); useImperativeHandle(ref, () => ({ setAtBottom: () => { wasAtBottomRef.current = true; }, scrollToBottom, getScrollState: () => ({ scrollTop: lastScrollTopRef.current, isAtBottom: wasAtBottomRef.current, visibleCount: visibleMessageCountRef.current, }), })); const filteredMessages = useMemo(() => { if (!searchQuery.trim()) return channelMessages; const query = searchQuery.toLowerCase(); return channelMessages.filter( (msg) => msg.content.toLowerCase().includes(query) || msg.userId.toLowerCase().includes(query), ); }, [channelMessages, searchQuery]); useEffect(() => { isScrolledUpRef.current = isScrolledUp; // When the user returns to the bottom, shrink the window back to the base so // slice(-N) resumes trimming old messages from the top (memory optimization). // Only shrink if we grew above the base — preserves a sub-default saved visibleCount. if (!isScrolledUp) { setVisibleMessageCount((prev) => prev > DEFAULT_VISIBLE_MESSAGE_COUNT ? DEFAULT_VISIBLE_MESSAGE_COUNT : prev, ); } }, [isScrolledUp]); // Reset ref-tracked windowing state when switching channels. // visibleMessageCount is NOT reset here — useState(initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT) // already initializes it correctly on mount, and this effect runs once on mount for the // same channelKey (each instance is bound to exactly one channel by the parent key={}). // biome-ignore lint/correctness/useExhaustiveDependencies: intentional full reset on channel change useEffect(() => { prevFilteredLengthRef.current = 0; prevFirstMsgIdRef.current = null; prevScrollHeightRef.current = 0; pendingPrependRef.current = false; resizeObserverPrevSHRef.current = 0; }, [channelKey]); const displayedMessages = useMemo(() => { if (searchQuery.trim()) return filteredMessages; return filteredMessages.slice(-visibleMessageCount); }, [filteredMessages, visibleMessageCount, searchQuery]); const locallyHidden = filteredMessages.length > displayedMessages.length; const serverHasMore = channel?.hasMoreHistory === true; const hasMoreMessages = locallyHidden || serverHasMore; const eventGroups = useMemo( () => groupConsecutiveEvents(displayedMessages), [displayedMessages], ); const isLoadingHistory = channel?.isLoadingHistory ?? false; // Scroll to bottom on initial mount, unless a saved position was passed in. // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount only useEffect(() => { const container = messagesContainerRef.current; if (!container) return; if (initialScrollState) { container.scrollTop = initialScrollState.scrollTop; lastScrollTopRef.current = initialScrollState.scrollTop; wasAtBottomRef.current = false; } else { container.scrollTop = container.scrollHeight; lastScrollTopRef.current = container.scrollHeight; wasAtBottomRef.current = true; } }, []); // Scroll to bottom after initial join history loads; clear fetch spinner at batch end. const wasLoadingHistoryRef = useRef(false); // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is stable via useCallback; refs and setters are stable useLayoutEffect(() => { if (wasLoadingHistoryRef.current && !isLoadingHistory) { if (isFetchingMoreRef.current) { // delta correction for scroll position is handled by useLayoutEffect([displayedMessages]) isFetchingMoreRef.current = false; setIsFetchingMore(false); } else { scrollToBottom(); wasAtBottomRef.current = true; } } wasLoadingHistoryRef.current = isLoadingHistory; }, [isLoadingHistory]); // When older messages are prepended, grow the window so they enter displayedMessages. // When new messages arrive at the bottom while the user is scrolled up, also grow the // window to keep the current top messages visible — slice(-N) otherwise slides the // window forward and hides them, incrementing the "N older messages" counter on every // incoming message. Only let the slice trim from the top when the user is at the bottom // (where auto-scroll handles keeping them current). useLayoutEffect(() => { const newLength = filteredMessages.length; const newFirstId = filteredMessages[0]?.id ?? null; const delta = newLength - prevFilteredLengthRef.current; if (prevFilteredLengthRef.current > 0 && delta > 0) { if (newFirstId !== prevFirstMsgIdRef.current) { // Messages prepended (load-more): signal delta-correction to compensate scrollTop. pendingPrependRef.current = true; setVisibleMessageCount((prev) => prev + delta); } else if (isScrolledUpRef.current) { // Messages appended at bottom while user is scrolled up reading history. // Expand the window to prevent top messages from dropping out of the slice. setVisibleMessageCount((prev) => prev + delta); } } prevFilteredLengthRef.current = newLength; prevFirstMsgIdRef.current = newFirstId; }, [filteredMessages]); // Compensate scrollTop when content is prepended above the viewport. // biome-ignore lint/correctness/useExhaustiveDependencies: runs on every displayedMessages render to capture the resulting scrollHeight; refs are stable useLayoutEffect(() => { const container = messagesContainerRef.current; if (!container) return; // Skip while container is display:none — scrollHeight collapses to 0 and would // poison prevScrollHeightRef, causing a huge spurious delta on the next visible render. if (container.clientHeight === 0) return; const prevHeight = prevScrollHeightRef.current; const newHeight = container.scrollHeight; // Only correct when a true load-more prepend happened (flag set by the window-growth // layoutEffect or button handler). Bottom appends slide the slice(-N) window which also // changes displayedMessages[0] — ID-comparison can't distinguish the two cases. const wasPrepend = pendingPrependRef.current; // Only consume the flag when scrollHeight actually changed — the server-side load-more // path goes through two renders: Render A (filteredMessages grows, visibleCount unchanged, // same displayedMessages content, same scrollHeight) then Render B (visibleCount grows, // new messages enter displayedMessages, scrollHeight grows). The flag must survive Render A // so it's still set when Render B fires the actual correction. if (wasPrepend && newHeight !== prevHeight) { pendingPrependRef.current = false; } if ( isScrolledUpRef.current && prevHeight > 0 && newHeight > prevHeight && wasPrepend ) { const delta = newHeight - prevHeight; container.scrollTop += delta; resizeObserverPrevSHRef.current = newHeight; } prevScrollHeightRef.current = newHeight; }, [displayedMessages]); // Re-stick to bottom when inner message content grows (media/audio previews loading). // Uses prevScrollHeight instead of wasAtBottomRef to avoid stale-flag race where the // ref is true while the user is actively scrolling up. // When the container width changes (member list toggle, window resize), text reflows // and scrollHeight changes; preserve proportional scroll position for scrolled-up users. // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is a stable ref useEffect(() => { const container = messagesContainerRef.current; const inner = messagesInnerRef.current; if (!inner || !container) return; resizeObserverPrevSHRef.current = container.scrollHeight; let prevClientWidth = container.clientWidth; const observer = new ResizeObserver(() => { if (container.clientHeight === 0) return; // Effect may re-initialize while container is display:none (ref=0). // Re-seed with current dimensions and skip — no reliable "was at bottom" data. if (resizeObserverPrevSHRef.current === 0) { resizeObserverPrevSHRef.current = container.scrollHeight; prevClientWidth = container.clientWidth; return; } const currentClientWidth = container.clientWidth; const widthChanged = currentClientWidth !== prevClientWidth; prevClientWidth = currentClientWidth; const prevSH = resizeObserverPrevSHRef.current; const wasAtPrevBottom = container.scrollTop + container.clientHeight >= prevSH - SCROLL_TOLERANCE; resizeObserverPrevSHRef.current = container.scrollHeight; if (wasAtPrevBottom) { scrollToBottom(); } else if (widthChanged && prevSH > 0) { const ratio = container.scrollTop / prevSH; container.scrollTop = Math.round(ratio * container.scrollHeight); } }); observer.observe(inner); return () => observer.disconnect(); }, [isLoadingHistory, channelId, privateChatId]); // Auto-scroll on new messages — skip when this channel is hidden (display:none). // biome-ignore lint/correctness/useExhaustiveDependencies: only scroll when messages change, not when isActive changes useEffect(() => { if (!isActive) return; const isNarrowView = window.matchMedia("(max-width: 768px)").matches; const isChatVisible = !isNarrowView || mobileViewActiveColumn === "chatView"; if (wasAtBottomRef.current && isChatVisible) { scrollToBottom(); } }, [displayedMessages, mobileViewActiveColumn, scrollToBottom, isActive]); return ( <>
{isLoadingHistory && !isFetchingMore ? (
) : (
{hasMoreMessages && !searchQuery && (
)} {searchQuery && (
{plural(filteredMessages.length, { one: t`Found 1 message matching "${searchQuery}"`, other: t`Found ${filteredMessages.length} messages matching "${searchQuery}"`, })}
)} {eventGroups.map((group) => { if (group.type === "eventGroup") { const firstId = group.messages[0]?.id || ""; const lastId = group.messages[group.messages.length - 1]?.id || ""; const groupKey = `group-${firstId}-${lastId}`; return ( ); } const message = group.messages[0]; const originalIndex = channelMessages.findIndex( (m) => m.id === message.id, ); const previousMessage = channelMessages[originalIndex - 1]; const showHeader = !previousMessage || previousMessage.type !== "message" || previousMessage.userId !== message.userId || new Date(message.timestamp).getTime() - new Date(previousMessage.timestamp).getTime() > 5 * 60 * 1000; return ( ); })}
)}
); }, ); ChannelMessageList.displayName = "ChannelMessageList"; // Wrap with memo so hidden keep-alive channels skip re-renders when their props // haven't changed (e.g. when messageText changes in the input — the only thing // that changes on typing is local state inside ChatArea, not the props we pass here). export const MemoChannelMessageList = memo(ChannelMessageList); #. placeholder {2}: import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import type * as React from "react"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { SCROLL_TOLERANCE, useScrollToBottom, } from "../../hooks/useScrollToBottom"; import { groupConsecutiveEvents } from "../../lib/eventGrouping"; import ircClient from "../../lib/ircClient"; import useStore from "../../store"; import type { Message as MessageType } from "../../types"; import { CollapsedEventMessage } from "../message/CollapsedEventMessage"; import { MessageItem } from "../message/MessageItem"; import LoadingSpinner from "../ui/LoadingSpinner"; import { ScrollToBottomButton } from "../ui/ScrollToBottomButton"; export const DEFAULT_VISIBLE_MESSAGE_COUNT = 100; // Stable empty array — prevents selector from returning a new [] on every render // when the channel has no messages yet (undefined ?? [] would create a new ref each time). const EMPTY_MESSAGES: import("../../types").Message[] = []; export interface ChannelMessageListHandle { setAtBottom: () => void; scrollToBottom: () => void; getScrollState: () => { scrollTop: number; isAtBottom: boolean; visibleCount: number; }; } interface ChannelMessageListProps { channelKey: string; serverId: string; channelId: string | null; privateChatId: string | null; isActive: boolean; searchQuery: string; isMemberListVisible: boolean; onReply: (msg: MessageType | null) => void; onUsernameContextMenu: ( e: React.MouseEvent, username: string, serverId: string, channelId: string, avatarEl?: Element | null, ) => void; onIrcLinkClick: (url: string) => void; onReactClick: (msg: MessageType, el: Element) => void; onReactionUnreact: (emoji: string, msg: MessageType) => void; onOpenReactionModal: ( msg: MessageType, position: { x: number; y: number }, ) => void; onDirectReaction: (emoji: string, msg: MessageType) => void; onRedactMessage: (msg: MessageType) => void; onOpenProfile: (username: string) => void; joinChannel: (serverId: string, channelName: string) => void; onClearSearch: () => void; highlightedMessageId?: string; // undefined = first visit; null = was at bottom; object = restore to saved position initialScrollState?: { scrollTop: number; visibleCount: number } | null; } export const ChannelMessageList = forwardRef< ChannelMessageListHandle, ChannelMessageListProps >( ( { channelKey, serverId, channelId, privateChatId, isActive, searchQuery, isMemberListVisible, onReply, onUsernameContextMenu, onIrcLinkClick, onReactClick, onReactionUnreact, onOpenReactionModal, onDirectReaction, onRedactMessage, onOpenProfile, joinChannel, onClearSearch, highlightedMessageId, initialScrollState, }, ref, ) => { const { t } = useLingui(); const [visibleMessageCount, setVisibleMessageCount] = useState( initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT, ); // Ref mirror so getScrollState closure always reads the current value without needing it as a dep. const visibleMessageCountRef = useRef(visibleMessageCount); visibleMessageCountRef.current = visibleMessageCount; // Distinguishes initial join (full-screen spinner) from subsequent "load more" (button spinner). const [isFetchingMore, setIsFetchingMore] = useState(false); const isFetchingMoreRef = useRef(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const messagesInnerRef = useRef(null); // prev scrollHeight for prepend delta-correction. const prevScrollHeightRef = useRef(0); // Ref mirror of isScrolledUp — lets useLayoutEffect closures read current value // without listing isScrolledUp as a dep (which would re-run effects on every scroll). const isScrolledUpRef = useRef(false); const prevFilteredLengthRef = useRef(0); const prevFirstMsgIdRef = useRef(null); // Set by the window-growth layoutEffect (or button handler) when a true prepend is detected. // Consumed by the delta-correction layoutEffect one render later (after visibleCount grows). // Using a flag instead of tracking displayedMessages[0]?.id because slice(-N) slides the // window on every bottom append, changing displayedMessages[0] even for non-prepend renders. const pendingPrependRef = useRef(false); // Shared scrollHeight baseline between the delta-correction layout effect and the inner // ResizeObserver. When scrollTop is corrected after a prepend, we update this so the RO's // "was at bottom" check is not fooled by the adjusted scrollTop vs its stale prevSH. const resizeObserverPrevSHRef = useRef(0); const channelMessages = useStore( useCallback( (state) => state.messages[channelKey] ?? EMPTY_MESSAGES, [channelKey], ), ); const servers = useStore((state) => state.servers); const mobileViewActiveColumn = useStore( (state) => state.ui.mobileViewActiveColumn, ); const channel = useMemo( () => channelId ? (servers .find((s) => s.id === serverId) ?.channels.find((c) => c.id === channelId) ?? null) : null, [servers, serverId, channelId], ); const { isScrolledUp, wasAtBottomRef, scrollToBottom } = useScrollToBottom( messagesContainerRef, messagesEndRef, { channelId: `${channelId || privateChatId}-${isMemberListVisible}` }, ); // Snapshot of the last known scroll position captured while the container was visible. // getScrollState() reads this instead of the live DOM because React commits display:none // before running cleanup effects, collapsing scrollTop/scrollHeight/clientHeight to 0. const lastScrollTopRef = useRef(initialScrollState?.scrollTop ?? 0); useEffect(() => { const container = messagesContainerRef.current; if (!container) return; const onScroll = () => { if (container.clientHeight > 0) lastScrollTopRef.current = container.scrollTop; }; container.addEventListener("scroll", onScroll, { passive: true }); return () => container.removeEventListener("scroll", onScroll); }, []); // Restore scroll position when a keep-alive channel transitions from hidden to visible. // display:none may reset scrollTop to 0; lastScrollTopRef was captured while visible. const prevActiveRef = useRef(isActive); useLayoutEffect(() => { if (isActive && !prevActiveRef.current) { const container = messagesContainerRef.current; if (container && lastScrollTopRef.current > 0) { container.scrollTop = lastScrollTopRef.current; } } prevActiveRef.current = isActive; }, [isActive]); useImperativeHandle(ref, () => ({ setAtBottom: () => { wasAtBottomRef.current = true; }, scrollToBottom, getScrollState: () => ({ scrollTop: lastScrollTopRef.current, isAtBottom: wasAtBottomRef.current, visibleCount: visibleMessageCountRef.current, }), })); const filteredMessages = useMemo(() => { if (!searchQuery.trim()) return channelMessages; const query = searchQuery.toLowerCase(); return channelMessages.filter( (msg) => msg.content.toLowerCase().includes(query) || msg.userId.toLowerCase().includes(query), ); }, [channelMessages, searchQuery]); useEffect(() => { isScrolledUpRef.current = isScrolledUp; // When the user returns to the bottom, shrink the window back to the base so // slice(-N) resumes trimming old messages from the top (memory optimization). // Only shrink if we grew above the base — preserves a sub-default saved visibleCount. if (!isScrolledUp) { setVisibleMessageCount((prev) => prev > DEFAULT_VISIBLE_MESSAGE_COUNT ? DEFAULT_VISIBLE_MESSAGE_COUNT : prev, ); } }, [isScrolledUp]); // Reset ref-tracked windowing state when switching channels. // visibleMessageCount is NOT reset here — useState(initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT) // already initializes it correctly on mount, and this effect runs once on mount for the // same channelKey (each instance is bound to exactly one channel by the parent key={}). // biome-ignore lint/correctness/useExhaustiveDependencies: intentional full reset on channel change useEffect(() => { prevFilteredLengthRef.current = 0; prevFirstMsgIdRef.current = null; prevScrollHeightRef.current = 0; pendingPrependRef.current = false; resizeObserverPrevSHRef.current = 0; }, [channelKey]); const displayedMessages = useMemo(() => { if (searchQuery.trim()) return filteredMessages; return filteredMessages.slice(-visibleMessageCount); }, [filteredMessages, visibleMessageCount, searchQuery]); const locallyHidden = filteredMessages.length > displayedMessages.length; const serverHasMore = channel?.hasMoreHistory === true; const hasMoreMessages = locallyHidden || serverHasMore; const eventGroups = useMemo( () => groupConsecutiveEvents(displayedMessages), [displayedMessages], ); const isLoadingHistory = channel?.isLoadingHistory ?? false; // Scroll to bottom on initial mount, unless a saved position was passed in. // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount only useEffect(() => { const container = messagesContainerRef.current; if (!container) return; if (initialScrollState) { container.scrollTop = initialScrollState.scrollTop; lastScrollTopRef.current = initialScrollState.scrollTop; wasAtBottomRef.current = false; } else { container.scrollTop = container.scrollHeight; lastScrollTopRef.current = container.scrollHeight; wasAtBottomRef.current = true; } }, []); // Scroll to bottom after initial join history loads; clear fetch spinner at batch end. const wasLoadingHistoryRef = useRef(false); // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is stable via useCallback; refs and setters are stable useLayoutEffect(() => { if (wasLoadingHistoryRef.current && !isLoadingHistory) { if (isFetchingMoreRef.current) { // delta correction for scroll position is handled by useLayoutEffect([displayedMessages]) isFetchingMoreRef.current = false; setIsFetchingMore(false); } else { scrollToBottom(); wasAtBottomRef.current = true; } } wasLoadingHistoryRef.current = isLoadingHistory; }, [isLoadingHistory]); // When older messages are prepended, grow the window so they enter displayedMessages. // When new messages arrive at the bottom while the user is scrolled up, also grow the // window to keep the current top messages visible — slice(-N) otherwise slides the // window forward and hides them, incrementing the "N older messages" counter on every // incoming message. Only let the slice trim from the top when the user is at the bottom // (where auto-scroll handles keeping them current). useLayoutEffect(() => { const newLength = filteredMessages.length; const newFirstId = filteredMessages[0]?.id ?? null; const delta = newLength - prevFilteredLengthRef.current; if (prevFilteredLengthRef.current > 0 && delta > 0) { if (newFirstId !== prevFirstMsgIdRef.current) { // Messages prepended (load-more): signal delta-correction to compensate scrollTop. pendingPrependRef.current = true; setVisibleMessageCount((prev) => prev + delta); } else if (isScrolledUpRef.current) { // Messages appended at bottom while user is scrolled up reading history. // Expand the window to prevent top messages from dropping out of the slice. setVisibleMessageCount((prev) => prev + delta); } } prevFilteredLengthRef.current = newLength; prevFirstMsgIdRef.current = newFirstId; }, [filteredMessages]); // Compensate scrollTop when content is prepended above the viewport. // biome-ignore lint/correctness/useExhaustiveDependencies: runs on every displayedMessages render to capture the resulting scrollHeight; refs are stable useLayoutEffect(() => { const container = messagesContainerRef.current; if (!container) return; // Skip while container is display:none — scrollHeight collapses to 0 and would // poison prevScrollHeightRef, causing a huge spurious delta on the next visible render. if (container.clientHeight === 0) return; const prevHeight = prevScrollHeightRef.current; const newHeight = container.scrollHeight; // Only correct when a true load-more prepend happened (flag set by the window-growth // layoutEffect or button handler). Bottom appends slide the slice(-N) window which also // changes displayedMessages[0] — ID-comparison can't distinguish the two cases. const wasPrepend = pendingPrependRef.current; // Only consume the flag when scrollHeight actually changed — the server-side load-more // path goes through two renders: Render A (filteredMessages grows, visibleCount unchanged, // same displayedMessages content, same scrollHeight) then Render B (visibleCount grows, // new messages enter displayedMessages, scrollHeight grows). The flag must survive Render A // so it's still set when Render B fires the actual correction. if (wasPrepend && newHeight !== prevHeight) { pendingPrependRef.current = false; } if ( isScrolledUpRef.current && prevHeight > 0 && newHeight > prevHeight && wasPrepend ) { const delta = newHeight - prevHeight; container.scrollTop += delta; resizeObserverPrevSHRef.current = newHeight; } prevScrollHeightRef.current = newHeight; }, [displayedMessages]); // Re-stick to bottom when inner message content grows (media/audio previews loading). // Uses prevScrollHeight instead of wasAtBottomRef to avoid stale-flag race where the // ref is true while the user is actively scrolling up. // When the container width changes (member list toggle, window resize), text reflows // and scrollHeight changes; preserve proportional scroll position for scrolled-up users. // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is a stable ref useEffect(() => { const container = messagesContainerRef.current; const inner = messagesInnerRef.current; if (!inner || !container) return; resizeObserverPrevSHRef.current = container.scrollHeight; let prevClientWidth = container.clientWidth; const observer = new ResizeObserver(() => { if (container.clientHeight === 0) return; // Effect may re-initialize while container is display:none (ref=0). // Re-seed with current dimensions and skip — no reliable "was at bottom" data. if (resizeObserverPrevSHRef.current === 0) { resizeObserverPrevSHRef.current = container.scrollHeight; prevClientWidth = container.clientWidth; return; } const currentClientWidth = container.clientWidth; const widthChanged = currentClientWidth !== prevClientWidth; prevClientWidth = currentClientWidth; const prevSH = resizeObserverPrevSHRef.current; const wasAtPrevBottom = container.scrollTop + container.clientHeight >= prevSH - SCROLL_TOLERANCE; resizeObserverPrevSHRef.current = container.scrollHeight; if (wasAtPrevBottom) { scrollToBottom(); } else if (widthChanged && prevSH > 0) { const ratio = container.scrollTop / prevSH; container.scrollTop = Math.round(ratio * container.scrollHeight); } }); observer.observe(inner); return () => observer.disconnect(); }, [isLoadingHistory, channelId, privateChatId]); // Auto-scroll on new messages — skip when this channel is hidden (display:none). // biome-ignore lint/correctness/useExhaustiveDependencies: only scroll when messages change, not when isActive changes useEffect(() => { if (!isActive) return; const isNarrowView = window.matchMedia("(max-width: 768px)").matches; const isChatVisible = !isNarrowView || mobileViewActiveColumn === "chatView"; if (wasAtBottomRef.current && isChatVisible) { scrollToBottom(); } }, [displayedMessages, mobileViewActiveColumn, scrollToBottom, isActive]); return ( <>
{isLoadingHistory && !isFetchingMore ? (
) : (
{hasMoreMessages && !searchQuery && (
)} {searchQuery && (
{plural(filteredMessages.length, { one: t`Found 1 message matching "${searchQuery}"`, other: t`Found ${filteredMessages.length} messages matching "${searchQuery}"`, })}
)} {eventGroups.map((group) => { if (group.type === "eventGroup") { const firstId = group.messages[0]?.id || ""; const lastId = group.messages[group.messages.length - 1]?.id || ""; const groupKey = `group-${firstId}-${lastId}`; return ( ); } const message = group.messages[0]; const originalIndex = channelMessages.findIndex( (m) => m.id === message.id, ); const previousMessage = channelMessages[originalIndex - 1]; const showHeader = !previousMessage || previousMessage.type !== "message" || previousMessage.userId !== message.userId || new Date(message.timestamp).getTime() - new Date(previousMessage.timestamp).getTime() > 5 * 60 * 1000; return ( ); })}
)}
); }, ); ChannelMessageList.displayName = "ChannelMessageList"; // Wrap with memo so hidden keep-alive channels skip re-renders when their props // haven't changed (e.g. when messageText changes in the input — the only thing // that changes on typing is local state inside ChatArea, not the props we pass here). export const MemoChannelMessageList = memo(ChannelMessageList); @@ -46,6 +56,12 @@ msgstr "{0}님과 {1}님이 입력 중..." msgid "{0} is typing..." msgstr "{0}님이 입력 중..." +#. placeholder {0}: networks.length +#. placeholder {1}: networks.length === 1 ? "" : "s" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "{0} network{1} — pick one to join" +msgstr "" + #. placeholder {0}: filteredMessages.length - displayedMessages.length #: src/components/layout/ChannelMessageList.tsx msgid "{0} older messages" @@ -186,6 +202,12 @@ msgstr "초대 마스크 추가 (예: nick!*@*, *!*@host.com)" msgid "Add IRC Server" msgstr "IRC 서버 추가" +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworksPanel.tsx +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Add Network" +msgstr "네트워크 추가" + #: src/components/message/MessageActions.tsx #: src/components/message/MessageReactions.tsx #: src/components/message/MessageReactions.tsx @@ -205,6 +227,10 @@ msgstr "규칙 추가" msgid "Add Server" msgstr "서버 추가" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Add your first network" +msgstr "첫 번째 네트워크 추가" + #: src/components/message/JsonLogMessage.tsx msgid "Additional Details" msgstr "추가 세부정보" @@ -358,6 +384,10 @@ msgstr "뒤로" msgid "Back to image" msgstr "이미지로 돌아가기" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Back to network list" +msgstr "" + #: src/components/ui/ModerationModal.tsx msgid "Ban {username} by hostmask (prevents them from rejoining from the same IP/host)" msgstr "호스트마스크로 {username} 차단 (동일 IP/호스트에서 재입장 방지)" @@ -405,6 +435,8 @@ msgstr "서버의 모든 채널 검색" #: src/components/ui/AddPrivateChatModal.tsx #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/ChannelSettingsModal.tsx #: src/components/ui/ExternalLinkWarningModal.tsx #: src/components/ui/FloodSettingsModal.tsx @@ -640,6 +672,7 @@ msgid "Configure notification sounds and highlights" msgstr "알림 소리 및 강조 표시 설정" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworksPanel.tsx msgid "Connect" msgstr "연결" @@ -759,6 +792,10 @@ msgstr "채널 삭제" msgid "Delete message" msgstr "메시지 삭제" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Delete network" +msgstr "네트워크 삭제" + #: src/components/layout/ChannelList.tsx msgid "Delete Private Chat" msgstr "비공개 채팅 삭제" @@ -767,6 +804,10 @@ msgstr "비공개 채팅 삭제" msgid "Delete this message? This cannot be undone." msgstr "이 메시지를 삭제하시겠습니까? 이 작업은 취소할 수 없습니다." +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Delete this network?" +msgstr "이 네트워크를 삭제하시겠습니까?" + #: src/components/layout/ServerList.tsx #: src/components/mobile/ServerBottomSheet.tsx msgid "Disconnect" @@ -830,10 +871,16 @@ msgstr "다운로드" msgid "e.g., 100:1440" msgstr "예: 100:1440" +#: src/components/ui/BouncerNetworksPanel.tsx #: src/components/ui/ChannelSettingsModal.tsx msgid "Edit" msgstr "편집" +#. placeholder {0}: editingNetwork?.attributes.name || editingNetwork?.netid +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Edit {0}" +msgstr "{0} 편집" + #: src/components/ui/UserProfileModal.tsx msgid "Edit Profile" msgstr "프로필 편집" @@ -1057,6 +1104,7 @@ msgstr "홈" msgid "Homepage" msgstr "홈페이지" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserProfileModal.tsx msgid "Host" msgstr "호스트" @@ -1271,6 +1319,10 @@ msgstr "채널을 나갔습니다" msgid "Let others know when you are typing" msgstr "입력 중임을 다른 사람에게 알림" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Libera Chat" +msgstr "Libera Chat" + #: src/components/message/LinkPreview.tsx msgid "Link preview" msgstr "링크 미리보기" @@ -1299,6 +1351,10 @@ msgstr "GIF 불러오는 중..." msgid "Loading more channels..." msgstr "채널 더 불러오는 중..." +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Loading networks from your bouncer…" +msgstr "바운서에서 네트워크 불러오는 중…" + #: src/components/ui/UserProfileModal.tsx msgid "Loading WHOIS data..." msgstr "WHOIS 데이터 불러오는 중..." @@ -1486,9 +1542,15 @@ msgid "Name:" msgstr "이름:" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx msgid "Network Name" msgstr "네트워크 이름" +#. placeholder {0}: server?.name ?? bouncerServerId +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Networks on {0}" +msgstr "{0}의 네트워크" + #: src/components/ui/QuickActions.tsx msgid "New DM" msgstr "새 DM" @@ -1511,6 +1573,7 @@ msgid "nick!user@host (e.g., spam*!*@*, *!*@badhost.com)" msgstr "nick!user@host (예: spam*!*@*, *!*@badhost.com)" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserSettings.tsx #: src/lib/settings/definitions/allSettings.ts msgid "Nickname" @@ -1570,6 +1633,10 @@ msgstr "파일 선택 안 함" msgid "No flood profile" msgstr "플러드 프로필 없음" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "no host set" +msgstr "호스트가 설정되지 않음" + #: src/components/ui/ChannelSettingsModal.tsx msgid "No invitations found" msgstr "초대가 없습니다" @@ -1610,6 +1677,10 @@ msgstr "설정된 주제 없음" msgid "No unread mentions or messages" msgstr "읽지 않은 멘션이나 메시지가 없습니다" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "No upstream networks yet." +msgstr "아직 업스트림 네트워크가 없습니다." + #: src/components/ui/AddPrivateChatModal.tsx msgid "No users available" msgstr "사용 가능한 사용자가 없습니다" @@ -1696,6 +1767,10 @@ msgstr "이런! 네트워크 분리! ⚠️" msgid "Op" msgstr "Op" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Open" +msgstr "" + #: src/components/ui/QuickActions/uiActionConfig.tsx msgid "Open channel configuration settings" msgstr "채널 구성 설정 열기" @@ -1799,6 +1874,10 @@ msgstr "비공개 채팅 고정" msgid "Pin this private message conversation" msgstr "이 비공개 메시지 대화 고정" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Plaintext" +msgstr "평문" + #: src/components/message/MediaPreview.tsx #: src/components/message/MediaPreview.tsx #: src/components/message/MediaPreview.tsx @@ -1827,6 +1906,7 @@ msgid "PM User" msgstr "사용자에게 PM" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx msgid "Port" msgstr "포트" @@ -1918,6 +1998,7 @@ msgstr "이 메시지에 반응했습니다" msgid "Read more" msgstr "더 보기" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserProfileModal.tsx #: src/components/ui/UserSettings.tsx #: src/lib/settings/definitions/allSettings.ts @@ -2002,6 +2083,7 @@ msgstr "규칙" msgid "Safe" msgstr "안전" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/ChannelSettingsModal.tsx #: src/components/ui/TopicModal.tsx #: src/components/ui/UserSettings.tsx @@ -2183,6 +2265,10 @@ msgstr "네트워크 서버 운영자가 메시지를 읽을 수 있습니다" msgid "Server Password" msgstr "서버 비밀번호" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Server Password (PASS)" +msgstr "서버 비밀번호 (PASS)" + #: src/components/ui/LinkSecurityWarningModal.tsx msgid "Server-to-server communication may use unencrypted connections" msgstr "서버 간 통신에 암호화되지 않은 연결이 사용될 수 있습니다" @@ -2378,6 +2464,10 @@ msgstr "시간 (분)" msgid "Time Window (seconds)" msgstr "시간 창 (초)" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "TLS" +msgstr "TLS" + #: src/components/message/WhisperMessage.tsx #: src/components/message/WhisperMessage.tsx msgid "to" @@ -2426,6 +2516,10 @@ msgstr "주제:" msgid "Total: {0}" msgstr "전체: {0}" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Transport" +msgstr "전송 방식" + #: src/components/ui/UserSettings.tsx msgid "Trusted Sources" msgstr "신뢰할 수 있는 출처" @@ -2536,6 +2630,7 @@ msgstr "사용자 프로필" msgid "User Settings" msgstr "사용자 설정" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/InviteUserModal.tsx #: src/components/ui/ModerationModal.tsx msgid "Username" @@ -2683,6 +2778,10 @@ msgstr "광역 - 더 넓은 보호 범위" msgid "Will default to 'no reason' if left empty" msgstr "비워두면 기본값인 '사유 없음'이 사용됩니다" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Yes, delete" +msgstr "예, 삭제" + #: src/components/message/CollapsedEventMessage.tsx #: src/components/message/EventMessage.tsx msgid "You" @@ -2713,6 +2812,10 @@ msgstr "인증을 위한 계정 비밀번호" msgid "Your account username for authentication" msgstr "인증을 위한 계정 사용자 이름" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Your bouncer doesn't have any networks yet. Add one to get started." +msgstr "바운서에 아직 네트워크가 없습니다. 시작하려면 하나를 추가하세요." + #: src/lib/settings/definitions/allSettings.ts msgid "Your default nickname for all servers" msgstr "모든 서버에 사용할 기본 닉네임" diff --git a/src/locales/nl/messages.mjs b/src/locales/nl/messages.mjs index f06bc15e..b8c86adc 100644 --- a/src/locales/nl/messages.mjs +++ b/src/locales/nl/messages.mjs @@ -1 +1 @@ -/*eslint-disable*/export const messages=JSON.parse("{\"+5VMrz\":[\"Ongeldig patroonformaat. Gebruik het formaat nick!gebruiker@host (jokerteken * toegestaan)\"],\"+6NQQA\":[\"Algemeen ondersteuningskanaal\"],\"+6NyRG\":[\"Client\"],\"+K0AvT\":[\"Verbreken\"],\"+cyFdH\":[\"Standaardbericht wanneer je jezelf als afwezig markeert\"],\"+mVPqU\":[\"Markdown-opmaak in berichten weergeven\"],\"+vqCJH\":[\"Je accountgebruikersnaam voor authenticatie\"],\"+yPBXI\":[\"Bestand kiezen\"],\"+zy2Nq\":[\"Type\"],\"/09cao\":[\"Lage verbindingsbeveiliging (niveau \",[\"securityLevel\"],\")\"],\"/3BQ4J\":[\"Gebruikers buiten het kanaal kunnen er geen berichten naar sturen\"],\"/6BzZF\":[\"Ledenlijst aan/uit\"],\"/TNOPk\":[\"Gebruiker is afwezig\"],\"/XQgft\":[\"Ontdekken\"],\"/cF7Rs\":[\"Volume\"],\"/dqduX\":[\"Volgende pagina\"],\"/fc3q4\":[\"Alle inhoud\"],\"/kISDh\":[\"Meldingsgeluiden inschakelen\"],\"/n04sB\":[\"Kill\"],\"/rTz0M\":[\"Audio\"],\"/rfkZe\":[\"Geluiden afspelen voor vermeldingen en berichten\"],\"0/0ZGA\":[\"Kanaalnaammasker\"],\"0D6j7U\":[\"Meer informatie over aangepaste regels →\"],\"0XsHcR\":[\"Gebruiker verwijderen\"],\"0ZpE//\":[\"Sorteren op gebruikers\"],\"0bEPwz\":[\"Afwezig instellen\"],\"0dGkPt\":[\"Kanaallijst uitvouwen\"],\"0gS7M5\":[\"Weergavenaam\"],\"0kS+M8\":[\"VoorbeeldNET\"],\"0rgoY7\":[\"Alleen verbinden met servers die jij kiest\"],\"0wdd7X\":[\"Deelnemen\"],\"0wkVYx\":[\"Privéberichten\"],\"111uHX\":[\"Linkvoorbeeldweergave\"],\"196EG4\":[\"Privégesprek verwijderen\"],\"1DSr1i\":[\"Registreren voor een account\"],\"1O/24y\":[\"Kanaallijst aan/uit\"],\"1VPJJ2\":[\"Waarschuwing externe link\"],\"1ZC/dv\":[\"Geen ongelezen vermeldingen of berichten\"],\"1pO1zi\":[\"Servernaam is vereist\"],\"1uwfzQ\":[\"Kanaalonderwerp bekijken\"],\"268g7c\":[\"Weergavenaam invoeren\"],\"2FOFq1\":[\"Serveroperators op het netwerk kunnen mogelijk je berichten lezen\"],\"2FYpfJ\":[\"Meer\"],\"2HF1Y2\":[[\"inviter\"],\" heeft \",[\"target\"],\" uitgenodigd om deel te nemen aan \",[\"channel\"]],\"2I70QL\":[\"Gebruikersprofielinformatie bekijken\"],\"2QYdmE\":[\"Gebruikers:\"],\"2QpEjG\":[\"heeft verlaten\"],\"2YE223\":[\"Bericht in #\",[\"0\"],\" (Enter voor nieuwe regel, Shift+Enter om te verzenden)\"],\"2bimFY\":[\"Serverwachtwoord gebruiken\"],\"2iTmdZ\":[\"Lokale opslag:\"],\"2odkwe\":[\"Streng — Agressievere beveiliging\"],\"2uDhbA\":[\"Gebruikersnaam invoeren om uit te nodigen\"],\"2ygf/L\":[\"← Terug\"],\"2zEgxj\":[\"GIF's zoeken...\"],\"3RdPhl\":[\"Kanaal hernoemen\"],\"3THokf\":[\"Gebruiker met voice\"],\"3TSz9S\":[\"Minimaliseren\"],\"3jBDvM\":[\"Weergavenaam van kanaal\"],\"3ryuFU\":[\"Optionele crashrapporten om de app te verbeteren\"],\"3uBF/8\":[\"Viewer sluiten\"],\"3uwW8F\":[\"https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"3xf8Kz\":[\"Voer accountnaam in...\"],\"4/Rr0R\":[\"Een gebruiker uitnodigen voor het huidige kanaal\"],\"4EZrJN\":[\"Regels\"],\"4JJtW9\":[\"#overflow\"],\"4NqeT4\":[\"Floodprofiel (+F)\"],\"4RZQRK\":[\"Wat ben je aan het doen?\"],\"4hfTrB\":[\"Nickname\"],\"4n99LO\":[\"Al in \",[\"0\"]],\"4t6vMV\":[\"Automatisch overschakelen naar één regel voor korte berichten\"],\"4vsHmf\":[\"Tijd (min)\"],\"5+INAX\":[\"Berichten markeren die jou vermelden\"],\"5R5Pv/\":[\"Oper-naam\"],\"678PKt\":[\"Netwerknaam\"],\"6Aih4U\":[\"Offline\"],\"6CO3WE\":[\"Wachtwoord vereist om het kanaal te betreden. Laat leeg om de sleutel te verwijderen.\"],\"6HhMs3\":[\"Afsluitbericht\"],\"6V3Ea3\":[\"Gekopieerd\"],\"6lGV3K\":[\"Minder weergeven\"],\"6yFOEi\":[\"Voer oper-wachtwoord in...\"],\"7+IHTZ\":[\"Geen bestand gekozen\"],\"73hrRi\":[\"nick!gebruiker@host (bijv. spam*!*@*, *!*@slechtehost.com)\"],\"7QkKyN\":[\"Privébericht sturen\"],\"7U1W7c\":[\"Zeer ontspannen\"],\"7Y1YQj\":[\"Echte naam:\"],\"7YHArF\":[\"— openen in viewer\"],\"7fjnVl\":[\"Gebruikers zoeken...\"],\"7jL88x\":[\"Dit bericht verwijderen? Dit kan niet ongedaan worden gemaakt.\"],\"7nGhhM\":[\"Waar denk je aan?\"],\"7sEpu1\":[\"Leden — \",[\"0\"]],\"7sNhEz\":[\"Gebruikersnaam\"],\"8H0Q+x\":[\"Meer informatie over profielen →\"],\"8Phu0A\":[\"Weergeven wanneer gebruikers hun nickname wijzigen\"],\"8XTG9e\":[\"Oper-wachtwoord invoeren\"],\"8XsV2J\":[\"Opnieuw verzenden\"],\"8ZsakT\":[\"Wachtwoord\"],\"8kR84m\":[\"Je staat op het punt een externe link te openen:\"],\"8lCgih\":[\"Regel verwijderen\"],\"8p/xVT\":[[\"0\",\"plural\",{\"one\":[[\"1\"]],\"other\":[[\"2\"]]}]],\"8wRzac\":[[\"joinCount\",\"plural\",{\"one\":[\"deed mee\"],\"other\":[\"deed \",[\"joinCount\"],\" keer mee\"]}]],\"9BMLnJ\":[\"Opnieuw verbinden met server\"],\"9OEgyT\":[\"Reactie toevoegen\"],\"9PQ8m2\":[\"G-Line (globale ban)\"],\"9Qs99X\":[\"E-mail:\"],\"9QupBP\":[\"Patroon verwijderen\"],\"9bG48P\":[\"Bezig met verzenden\"],\"9f5f0u\":[\"Vragen over privacy? Neem contact met ons op:\"],\"9unqs3\":[\"Afwezig:\"],\"9v3hwv\":[\"Geen servers gevonden.\"],\"9zb2WA\":[\"Verbinding maken\"],\"A1taO8\":[\"Zoeken\"],\"A2adVi\":[\"Typemeldingen verzenden\"],\"A9Rhec\":[\"Kanaalnaam\"],\"AWOSPo\":[\"Inzoomen\"],\"AXSpEQ\":[\"Oper bij verbinding\"],\"AeXO77\":[\"Account\"],\"AhNP40\":[\"Spoelen\"],\"Ai2U7L\":[\"Host\"],\"AjBQnf\":[\"Nickname gewijzigd\"],\"AmXVh6\":[\"https://example.com/avatar.png\"],\"AnRu/j\":[\"Antwoord annuleren\"],\"ApSx0O\":[[\"0\"],\" berichten gevonden die overeenkomen met \\\"\",[\"searchQuery\"],\"\\\"\"],\"AxPAXW\":[\"Geen resultaten gevonden\"],\"AyNqAB\":[\"Alle servergebeurtenissen in de chat weergeven\"],\"B/QqGw\":[\"Niet achter het toetsenbord\"],\"B8AaMI\":[\"Dit veld is vereist\"],\"BA2c49\":[\"Server ondersteunt geen geavanceerde LIST-filtering\"],\"BDKt3I\":[[\"0\"],\", \",[\"1\"],\", \",[\"2\"],\" en \",[\"3\"],\" anderen typen...\"],\"BGul2A\":[\"Je hebt niet-opgeslagen wijzigingen. Weet je zeker dat je wilt sluiten zonder op te slaan?\"],\"BIf9fi\":[\"Je statusbericht\"],\"BZz3md\":[\"Je persoonlijke website\"],\"Bgm/H7\":[\"Meerdere tekstregels invoeren toestaan\"],\"BiQIl1\":[\"Dit privéberichtgesprek vastmaken\"],\"BlNZZ2\":[\"Klik om naar bericht te springen\"],\"Bowq3c\":[\"Alleen operators kunnen het kanaalonderwerp wijzigen\"],\"Btozzp\":[\"Deze afbeelding is verlopen\"],\"Bycfjm\":[\"Totaal: \",[\"0\"]],\"C6IBQc\":[\"Kopieer volledige JSON\"],\"C9L9wL\":[\"Gegevensverzameling\"],\"CDq4wC\":[\"Gebruiker modereren\"],\"CHVRxG\":[\"Bericht aan @\",[\"0\"],\" (Shift+Enter voor nieuwe regel)\"],\"CN9zdR\":[\"Oper-naam en wachtwoord zijn vereist\"],\"CW3sYa\":[\"Reactie toevoegen \",[\"emoji\"]],\"CaAkqd\":[\"Afmeldingen weergeven\"],\"CbvaYj\":[\"Bannen via nickname\"],\"CcK+Ft\":[\"PDF\"],\"Ce8q3L\":[\"Selecteer een kanaal\"],\"CsekCi\":[\"Normaal\"],\"D+NlUC\":[\"Systeem\"],\"D28t6+\":[\"is toegetreden en vertrokken\"],\"DB8zMK\":[\"Toepassen\"],\"DBcWHr\":[\"Aangepast meldingsgeluidsbestand\"],\"DTy9Xw\":[\"Mediavoorbeeldweergaven\"],\"Dj4pSr\":[\"Kies een veilig wachtwoord\"],\"Du+zn+\":[\"Zoeken...\"],\"Du2T2f\":[\"Instelling niet gevonden\"],\"DwsSVQ\":[\"Filters toepassen en vernieuwen\"],\"E3W/zd\":[\"Standaard nickname\"],\"E6nRW7\":[\"URL kopiëren\"],\"E703RG\":[\"Modi:\"],\"EAeu1Z\":[\"Uitnodiging verzenden\"],\"EFKJQT\":[\"Instelling\"],\"EGPQBv\":[\"Aangepaste floodregels (+f)\"],\"ELik0r\":[\"Volledig privacybeleid bekijken\"],\"EPbeC2\":[\"Kanaalonderwerp bekijken of bewerken\"],\"EQCDNT\":[\"Voer oper-gebruikersnaam in...\"],\"EUvulZ\":[\"1 bericht gevonden dat overeenkomt met \\\"\",[\"searchQuery\"],\"\\\"\"],\"EatZYJ\":[\"Volgende afbeelding\"],\"EdQY6l\":[\"Geen\"],\"EnqLYU\":[\"Servers zoeken...\"],\"F0OKMc\":[\"Server bewerken\"],\"F6Int2\":[\"Markeringen inschakelen\"],\"FDoLyE\":[\"Max. gebruikers\"],\"FUU/hZ\":[\"Bepaalt hoeveel externe media in de chat worden geladen.\"],\"Fdp03t\":[\"aan\"],\"FfPWR0\":[\"Venster\"],\"FjkaiT\":[\"Uitzoomen\"],\"FlqOE9\":[\"Wat dit betekent:\"],\"FolHNl\":[\"Je account en authenticatie beheren\"],\"Fp2Dif\":[\"De server verlaten\"],\"G5KmCc\":[\"GZ-Line (globale Z-Line)\"],\"GDs0lz\":[\"<0>Risico: Gevoelige informatie (berichten, privégesprekken, authenticatiegegevens) kan worden blootgesteld aan netwerkbeheerders of aanvallers tussen IRC-servers.\"],\"GR+2I3\":[\"Uitnodigingsmasker toevoegen (bijv. nick!*@*, *!*@host.com)\"],\"GRLyMU\":[\"Losgekoppelde serverberichten sluiten\"],\"GlHnXw\":[\"Nickname wijziging mislukt: \",[\"error\"],\" \",[\"0\"]],\"GswZF3\":[\"Voorbeeld:\"],\"GtmO8/\":[\"van\"],\"GtuHUQ\":[\"Dit kanaal op de server hernoemen. Alle gebruikers zien de nieuwe naam.\"],\"GuGfFX\":[\"Zoeken aan/uit\"],\"GxkJXS\":[\"Uploaden...\"],\"GzbwnK\":[\"Het kanaal betreden\"],\"GzsUDB\":[\"Uitgebreid profiel\"],\"H/PnT8\":[\"Emoji invoegen\"],\"H6Izzl\":[\"Je voorkeurkleurcode\"],\"H9jIv+\":[\"Aanmeldingen/vertrekken weergeven\"],\"HAKBY9\":[\"Bestanden uploaden\"],\"HdE1If\":[\"Kanaal\"],\"Hk4AW9\":[\"Je voorkeurweergavenaam\"],\"HmHDk7\":[\"Lid selecteren\"],\"HrQzPU\":[\"Kanalen op \",[\"networkName\"]],\"I2tXQ5\":[\"Bericht aan @\",[\"0\"],\" (Enter voor nieuwe regel, Shift+Enter om te verzenden)\"],\"I6bw/h\":[\"Gebruiker bannen\"],\"I92Z+b\":[\"Meldingen inschakelen\"],\"I9D72S\":[\"Weet je zeker dat je dit bericht wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.\"],\"IA+1wo\":[\"Weergeven wanneer gebruikers uit kanalen worden verwijderd\"],\"IDwkJx\":[\"IRC Operator\"],\"ILlU+s\":[\"Info:\"],\"IUwGEM\":[\"Wijzigingen opslaan\"],\"IVeGK6\":[[\"0\"],\", \",[\"1\"],\" en \",[\"2\"],\" zijn aan het typen...\"],\"IgrLD/\":[\"Pauzeren\"],\"Im6JED\":[\"WHISPER\"],\"ImOQa9\":[\"Beantwoorden\"],\"IoHMnl\":[\"Maximale waarde is \",[\"0\"]],\"IvMj+0\":[\"Op\"],\"J28zul\":[\"Verbinding maken...\"],\"J5T9NW\":[\"Gebruikersinformatie\"],\"J8Y5+z\":[\"Oeps! Netwerksplitsing! ⚠️\"],\"JBHkBA\":[\"Het kanaal verlaten\"],\"JCwL0Q\":[\"Reden invoeren (optioneel)\"],\"JFciKP\":[\"Aan/uit\"],\"JXGkhG\":[\"Kanaalnaam wijzigen (alleen operators)\"],\"JcD7qf\":[\"Meer acties\"],\"JdkA+c\":[\"Geheim (+s)\"],\"Jmu12l\":[\"Serverkanalen\"],\"JvQ++s\":[\"Markdown inschakelen\"],\"K2jwh/\":[\"Geen WHOIS-gegevens beschikbaar\"],\"KAXSwC\":[\"Voice\"],\"KDfTdX\":[\"Bericht verwijderen\"],\"KKBlUU\":[\"Insluiten\"],\"KM0pLb\":[\"Welkom in het kanaal!\"],\"KR6W2h\":[\"Gebruiker niet meer negeren\"],\"KV+Bi1\":[\"Alleen op uitnodiging (+i)\"],\"KdCtwE\":[\"Hoeveel seconden floodactiviteit bewaken voordat tellers worden gereset\"],\"Kkezga\":[\"Serverwachtwoord\"],\"KsiQ/8\":[\"Gebruikers moeten worden uitgenodigd om het kanaal te betreden\"],\"L+gB/D\":[\"Kanaalinformatie\"],\"LC1a7n\":[\"De IRC-server heeft gemeld dat de server-naar-serververbindingen een laag beveiligingsniveau hebben. Dit betekent dat wanneer je berichten worden doorgegeven tussen IRC-servers in het netwerk, ze mogelijk niet correct worden versleuteld of dat de SSL/TLS-certificaten niet correct worden gevalideerd.\"],\"LNfLR5\":[\"Kicks weergeven\"],\"LQb0W/\":[\"Alle gebeurtenissen weergeven\"],\"LU7/yA\":[\"Alternatieve naam voor weergave in de interface. Mag spaties, emoji en speciale tekens bevatten. De echte kanaalnaam (\",[\"channelName\"],\") wordt nog steeds gebruikt voor IRC-opdrachten.\"],\"LUb9O7\":[\"Een geldige serverpoort is vereist\"],\"Lb+BUl\":[\"https://example.com/avatar.jpg\"],\"LcET2C\":[\"Privacybeleid\"],\"LcuSDR\":[\"Je profielgegevens en metadata beheren\"],\"LqLS9B\":[\"Nicknamewijzigingen weergeven\"],\"LsDQt2\":[\"Kanaalinstellingen\"],\"LtI9AS\":[\"Eigenaar\"],\"LuNhhL\":[\"reageerde op dit bericht\"],\"M/AZNG\":[\"URL naar je avatarafbeelding\"],\"M/WIer\":[\"Bericht verzenden\"],\"M8er/5\":[\"Naam:\"],\"MHk+7g\":[\"Vorige afbeelding\"],\"MRorGe\":[\"Gebruiker een PM sturen\"],\"MVbSGP\":[\"Tijdvenster (seconden)\"],\"MkpcsT\":[\"Je berichten en instellingen worden lokaal op je apparaat opgeslagen\"],\"N/hDSy\":[\"Markeren als bot — gewoonlijk 'aan' of leeg\"],\"N7TQbE\":[\"Gebruiker uitnodigen voor \",[\"channelName\"]],\"NCca/o\":[\"Voer standaard bijnaam in...\"],\"Nqs6B9\":[\"Toont alle externe media. Elke URL kan een verzoek naar een onbekende server veroorzaken.\"],\"Nt+9O7\":[\"WebSocket gebruiken in plaats van raw TCP\"],\"NxIHzc\":[\"Gebruiker verbreken\"],\"O+v/cL\":[\"Alle kanalen op de server bekijken\"],\"ODwSCk\":[\"Een GIF verzenden\"],\"OGQ5kK\":[\"Meldingsgeluiden en markeringen instellen\"],\"OIPt1Z\":[\"Zijbalk met ledenlijst weergeven of verbergen\"],\"OKSNq/\":[\"Zeer streng\"],\"ONWvwQ\":[\"Uploaden\"],\"OVKoQO\":[\"Je accountwachtwoord voor authenticatie\"],\"ObsidianIRC - Bringing IRC to the future\":[\"ObsidianIRC - IRC naar de toekomst brengen\"],\"OhCpra\":[\"Een onderwerp instellen…\"],\"OkltoQ\":[[\"username\"],\" bannen via nickname (voorkomt dat ze opnieuw deelnemen met dezelfde nick)\"],\"P+t/Te\":[\"Geen aanvullende gegevens\"],\"P42Wcc\":[\"Veilig\"],\"PD38l0\":[\"Kanaalavatar voorbeeldweergave\"],\"PD9mEt\":[\"Typ een bericht...\"],\"PPqfdA\":[\"Kanaelconfiguratie-instellingen openen\"],\"PSCjfZ\":[\"Het onderwerp dat voor dit kanaal wordt weergegeven. Alle gebruikers kunnen het onderwerp zien.\"],\"PZCecv\":[\"PDF-voorbeeld\"],\"PeLgsC\":[[\"c\",\"plural\",{\"one\":[\"1 keer\"],\"other\":[[\"c\"],\" keer\"]}]],\"PguS2C\":[\"Uitzonderingsmasker toevoegen (bijv. nick!*@*, *!*@host.com)\"],\"Pil5Ty\":[[\"displayedChannelsCount\"],\" van \",[\"0\"],\" kanalen weergegeven\"],\"PqhVlJ\":[\"Gebruiker bannen (via hostmasker)\"],\"Q+chwU\":[\"Gebruikersnaam:\"],\"Q6hhn8\":[\"Voorkeuren\"],\"QF4a34\":[\"Voer een gebruikersnaam in\"],\"QGqSZ2\":[\"Kleur en opmaak\"],\"QJQd1J\":[\"Profiel bewerken\"],\"QSzGDE\":[\"Inactief\"],\"QUlny5\":[\"Welkom bij \",[\"0\"],\"!\"],\"Qoq+GP\":[\"Meer lezen\"],\"QuSkCF\":[\"Kanalen filteren...\"],\"QwUrDZ\":[\"heeft het onderwerp gewijzigd naar: \",[\"topic\"]],\"R0UH07\":[\"Afbeelding \",[\"0\"],\" van \",[\"1\"]],\"R7SsBE\":[\"Dempen\"],\"R8rf1X\":[\"Klik om onderwerp in te stellen\"],\"RArB3D\":[\"werd gekickt uit \",[\"channelName\"],\" door \",[\"username\"]],\"RI3cWd\":[\"Ontdek de wereld van IRC met ObsidianIRC\"],\"RMMaN5\":[\"Gemodereerd (+m)\"],\"RWw9Lg\":[\"Venster sluiten\"],\"RZ2BuZ\":[\"Accountregistratie voor \",[\"account\"],\" vereist verificatie: \",[\"message\"]],\"RySp6q\":[\"Reacties verbergen\"],\"SPKQTd\":[\"Nickname is vereist\"],\"SPVjfj\":[\"Standaard 'geen reden' als leeggelaten\"],\"SQKPvQ\":[\"Gebruiker uitnodigen\"],\"SkZcl+\":[\"Kies een vooraf ingesteld floodbeveililingsprofiel. Deze profielen bieden evenwichtige beveiligingsinstellingen voor verschillende toepassingen.\"],\"Slr+3C\":[\"Min. gebruikers\"],\"Spnlre\":[\"Je hebt \",[\"target\"],\" uitgenodigd om deel te nemen aan \",[\"channel\"]],\"T/ckN5\":[\"Openen in viewer\"],\"T91vKp\":[\"Afspelen\"],\"TV2Wdu\":[\"Lees hoe we met je gegevens omgaan en je privacy beschermen.\"],\"TgFpwD\":[\"Toepassen...\"],\"TkzSFB\":[\"Geen wijzigingen\"],\"TtserG\":[\"Echte naam invoeren\"],\"Ttz9J1\":[\"Voer wachtwoord in...\"],\"Tz0i8g\":[\"Instellingen\"],\"U3pytU\":[\"Admin\"],\"UDb2YD\":[\"Reageren\"],\"UE4KO5\":[\"*kanaal*\"],\"UGT5vp\":[\"Instellingen opslaan\"],\"UV5hLB\":[\"Geen bannen gevonden\"],\"Uaj3Nd\":[\"Statusberichten\"],\"Ue3uny\":[\"Standaard (geen profiel)\"],\"UkARhe\":[\"Normaal — Standaardbeveiliging\"],\"Umn7Cj\":[\"Nog geen reacties. Wees de eerste!\"],\"UtUIRh\":[[\"0\"],\" oudere berichten\"],\"UwzP+U\":[\"Beveiligde verbinding\"],\"V0/A4O\":[\"Kanaaleigenaar\"],\"V4qgxE\":[\"Aangemaakt voor (min geleden)\"],\"V8yTm6\":[\"Zoekopdracht wissen\"],\"VJMMyz\":[\"ObsidianIRC — IRC de toekomst in\"],\"VJScHU\":[\"Reden\"],\"VLsmVV\":[\"Meldingen dempen\"],\"VbyRUy\":[\"Reacties\"],\"Vmx0mQ\":[\"Ingesteld door:\"],\"VqnIZz\":[\"Ons privacybeleid en gegevenspraktijken bekijken\"],\"VrMygG\":[\"Minimale lengte is \",[\"0\"]],\"VrnTui\":[\"Je voornaamwoorden, weergegeven in je profiel\"],\"W8E3qn\":[\"Geverifieerd account\"],\"WAakm9\":[\"Kanaal verwijderen\"],\"WFxTHC\":[\"Banmasker toevoegen (bijv. nick!*@*, *!*@host.com)\"],\"WN1g9F\":[\"Serverhost is vereist\"],\"WRYdXW\":[\"Audiopositie\"],\"WUOH5B\":[\"Gebruiker negeren\"],\"WWEXnZ\":[[\"0\",\"plural\",{\"one\":[\"Toon 1 item meer\"],\"other\":[\"Toon \",[\"1\"],\" items meer\"]}]],\"Weq9zb\":[\"Algemeen\"],\"Wfj7Sk\":[\"Meldingsgeluiden dempen of activeren\"],\"Wm7gbG\":[\"GitHub:\"],\"WyeHWY\":[\"*spam*\"],\"WzMCru\":[\"Gebruikersprofiel\"],\"X6S3lt\":[\"Instellingen, kanalen, servers zoeken...\"],\"XEHan5\":[\"Toch doorgaan\"],\"XI1+wb\":[\"Ongeldig formaat\"],\"XIXeuC\":[\"Bericht aan @\",[\"0\"]],\"XMS+k4\":[\"Privébericht starten\"],\"XWgxXq\":[\"Album\"],\"Xd7+IT\":[\"Privégesprek losmaken\"],\"Xm/s+u\":[\"Weergave\"],\"Xp2n93\":[\"Toont media van de vertrouwde bestandshost van je server. Er worden geen verzoeken gedaan aan externe diensten.\"],\"XvjC4F\":[\"Opslaan...\"],\"Y/qryO\":[\"Geen gebruikers gevonden die overeenkomen met je zoekopdracht\"],\"YAqRpI\":[\"Accountregistratie voor \",[\"account\"],\" geslaagd: \",[\"message\"]],\"YEfzvP\":[\"Beveiligd onderwerp (+t)\"],\"YQOn6a\":[\"Ledenlijst inklappen\"],\"YRCoE9\":[\"Kanaaloperator\"],\"YURQaF\":[\"Profiel bekijken\"],\"YdBSvr\":[\"Mediaweergave en externe inhoud beheren\"],\"Yj6U3V\":[\"Geen centrale server:\"],\"YjvpGx\":[\"Voornaamwoorden\"],\"YqH4l4\":[\"Geen sleutel\"],\"YyUPpV\":[\"Account:\"],\"ZJSWfw\":[\"Bericht dat wordt weergegeven wanneer je de verbinding met de server verbreekt\"],\"ZR1dJ4\":[\"Uitnodigingen\"],\"ZdWg0V\":[\"Openen in browser\"],\"ZhRBbl\":[\"Berichten zoeken…\"],\"Zmcu3y\":[\"Geavanceerde filters\"],\"a2/8e5\":[\"Onderwerp ingesteld na (min geleden)\"],\"aHKcKc\":[\"Vorige pagina\"],\"aJTbXX\":[\"Oper-wachtwoord\"],\"aQryQv\":[\"Patroon bestaat al\"],\"aW9pLN\":[\"Maximaal aantal toegestane gebruikers in het kanaal. Laat leeg voor geen limiet.\"],\"ah4fmZ\":[\"Toont ook voorbeeldweergaven van YouTube, Vimeo, SoundCloud en vergelijkbare bekende diensten.\"],\"aifXak\":[\"Geen media in dit kanaal\"],\"ap2zBz\":[\"Ontspannen\"],\"az8lvo\":[\"Uit\"],\"azXSNo\":[\"Ledenlijst uitvouwen\"],\"azdliB\":[\"Aanmelden bij een account\"],\"b26wlF\":[\"zij/haar\"],\"bD/+Ei\":[\"Streng\"],\"bQ6BJn\":[\"Stel gedetailleerde floodbeveiligingsregels in. Elke regel bepaalt welk type activiteit wordt bewaakt en welke actie wordt ondernomen als drempelwaarden worden overschreden.\"],\"beV7+y\":[\"De gebruiker ontvangt een uitnodiging om deel te nemen aan \",[\"channelName\"],\".\"],\"bk84cH\":[\"Afwezigheidsbericht\"],\"bkHdLj\":[\"IRC-server toevoegen\"],\"bmQLn5\":[\"Regel toevoegen\"],\"bwRvnp\":[\"Actie\"],\"c8+EVZ\":[\"Geverifieerd account\"],\"cGYUlD\":[\"Er worden geen mediavoorbeeldweergaven geladen.\"],\"cLF98o\":[\"Reacties tonen (\",[\"commentCount\"],\")\"],\"cLKIDO\":[\"Geen gebruikers beschikbaar\"],\"cSgpoS\":[\"Privégesprek vastmaken\"],\"cde3ce\":[\"Bericht aan <0>\",[\"0\"],\"\"],\"chQsxg\":[\"Kopieer opgemaakte uitvoer\"],\"cl/A5J\":[\"Welkom bij \",[\"__DEFAULT_IRC_SERVER_NAME__\"],\"!\"],\"cnGeoo\":[\"Verwijderen\"],\"coPLXT\":[\"We slaan je IRC-communicatie niet op onze servers op\"],\"crYH/6\":[\"SoundCloud-speler\"],\"d3sis4\":[\"Server toevoegen\"],\"d9aN5k\":[[\"username\"],\" uit het kanaal verwijderen\"],\"dEgA5A\":[\"Annuleren\"],\"dGi1We\":[\"Dit privéberichtgesprek losmaken\"],\"dJVuyC\":[\"heeft \",[\"channelName\"],\" verlaten (\",[\"reason\"],\")\"],\"dMtLDE\":[\"aan\"],\"dXqxlh\":[\"<0>⚠️ Beveiligingsrisico! Deze verbinding kan kwetsbaar zijn voor onderschepping of man-in-the-middle-aanvallen.\"],\"da9Q/R\":[\"Kanaalmodi gewijzigd\"],\"dhJN3N\":[\"Reacties tonen\"],\"dj2xTE\":[\"Melding sluiten\"],\"dpCzmC\":[\"Floodbeveiligingsinstellingen\"],\"e9dQpT\":[\"Wil je deze link in een nieuw tabblad openen?\"],\"ePK91l\":[\"Bewerken\"],\"eYBDuB\":[\"Upload een afbeelding of geef een URL op met optionele \",[\"size\"],\"-vervanging voor dynamische grootte\"],\"edBbee\":[[\"username\"],\" bannen via hostmasker (voorkomt dat ze opnieuw deelnemen via hetzelfde IP/host)\"],\"ekfzWq\":[\"Gebruikersinstellingen\"],\"elPDWs\":[\"Pas je IRC-clientervaring aan\"],\"eu2osY\":[\"<0>💡 Aanbeveling: Ga alleen verder als je deze server vertrouwt en de risico's begrijpt. Deel geen gevoelige informatie of wachtwoorden via deze verbinding.\"],\"euEhbr\":[\"Klik om deel te nemen aan \",[\"channel\"]],\"ez3vLd\":[\"Meerdere regels invoer inschakelen\"],\"f0J5Ki\":[\"Server-naar-servercommunicatie kan niet-versleutelde verbindingen gebruiken\"],\"f9BHJk\":[\"Gebruiker waarschuwen\"],\"fDOLLd\":[\"Geen kanalen gevonden.\"],\"ffzDkB\":[\"Anonieme analyses:\"],\"fq1GF9\":[\"Weergeven wanneer gebruikers de verbinding met de server verbreken\"],\"gEF57C\":[\"Deze server ondersteunt slechts één verbindingstype\"],\"gJuLUI\":[\"Negeerlijst\"],\"gNzMrk\":[\"Huidige avatar\"],\"gjPWyO\":[\"Voer bijnaam in...\"],\"gz6UQ3\":[\"Maximaliseren\"],\"h6razj\":[\"Kanaalnaammasker uitsluiten\"],\"hG6jnw\":[\"Geen onderwerp ingesteld\"],\"hG89Ed\":[\"Afbeelding\"],\"hZ6znB\":[\"Poort\"],\"ha+Bz5\":[\"bijv. 100:1440\"],\"hehnjM\":[\"Aantal\"],\"hzdLuQ\":[\"Alleen gebruikers met voice of hoger kunnen spreken\"],\"i0qMbr\":[\"Start\"],\"iDNBZe\":[\"Meldingen\"],\"iH8pgl\":[\"Terug\"],\"iL9SZg\":[\"Gebruiker bannen (via nickname)\"],\"iNt+3c\":[\"Terug naar afbeelding\"],\"iQvi+a\":[\"Niet meer waarschuwen over lage verbindingsbeveiliging voor deze server\"],\"iSLIjg\":[\"Verbinden\"],\"iWXkHH\":[\"Halfop\"],\"iZeTtp\":[\"Serverhost\"],\"idD8Ev\":[\"Opgeslagen\"],\"iivqkW\":[\"Aangemeld op\"],\"ij+Elv\":[\"Afbeeldingsvoorbeeldweergave\"],\"ilIWp7\":[\"Meldingen aan/uit\"],\"iuaqvB\":[\"Gebruik * voor jokertekens. Voorbeelden: slechtegebruiker!*@*, *!*@spammer.com, trol*!*@*\"],\"ixkTse\":[\"Bot\"],\"j2DGR0\":[\"Bannen via hostmasker\"],\"jA4uoI\":[\"Onderwerp:\"],\"jLXxGK\":[\"https://example.com\"],\"jPSk57\":[\"Reden (optioneel)\"],\"jUV7CU\":[\"Avatar uploaden\"],\"jW5Uwh\":[\"Bepaal hoeveel externe media worden geladen. Uit / Veilig / Vertrouwde bronnen / Alle inhoud.\"],\"jXzms5\":[\"Bijlageopties\"],\"jZlrte\":[\"Kleur\"],\"jfC/xh\":[\"Contact\"],\"jywMpv\":[\"#nieuwe-kanaalnaam\"],\"k112DD\":[\"Oudere berichten laden\"],\"k3ID0F\":[\"Leden filteren…\"],\"k65gsE\":[\"Diepgaande analyse\"],\"k7Zgob\":[\"Verbinding annuleren\"],\"kAVx5h\":[\"Geen uitnodigingen gevonden\"],\"kCLEPU\":[\"Verbonden met\"],\"kF5LKb\":[\"Genegeerde patronen:\"],\"kGeOx/\":[\"Deelnemen aan \",[\"0\"]],\"kITKr8\":[\"Kanaalmodi laden...\"],\"kPpPsw\":[\"Je bent een IRC Operator\"],\"kWJmRL\":[\"Jij\"],\"kfcRb0\":[\"Avatar\"],\"kjMqSj\":[\"Kopieer JSON\"],\"krViRy\":[\"Klik om te kopiëren als JSON\"],\"ks71ra\":[\"Uitzonderingen\"],\"kw4lRv\":[\"Kanaal half-operator\"],\"kxgIRq\":[\"Selecteer of voeg een kanaal toe om te beginnen.\"],\"ky6dWe\":[\"Avatarvoorbeeldweergave\"],\"l+GxCv\":[\"Kanalen laden...\"],\"l+IUVW\":[\"Accountverificatie voor \",[\"account\"],\" geslaagd: \",[\"message\"]],\"l/siQz\":[[\"reconnectCount\",\"plural\",{\"one\":[\"opnieuw verbonden\"],\"other\":[[\"reconnectCount\"],\" keer opnieuw verbonden\"]}]],\"l5jmzx\":[[\"0\"],\" en \",[\"1\"],\" zijn aan het typen...\"],\"lHy8N5\":[\"Meer kanalen laden...\"],\"lbpf14\":[\"Deelnemen aan \",[\"value\"]],\"lfFsZ4\":[\"Kanalen\"],\"lkNdiH\":[\"Accountnaam\"],\"ln500L\":[\"ObsidianIRC\"],\"lnCMdg\":[\"Afbeelding uploaden\"],\"loQxaJ\":[\"Ik ben terug\"],\"lvfaxv\":[\"START\"],\"m16xKo\":[\"Toevoegen\"],\"m8flAk\":[\"Voorbeeld (nog niet geüpload)\"],\"mEPxTp\":[\"<0>⚠️ Wees voorzichtig! Open alleen links van vertrouwde bronnen. Kwaadaardige links kunnen je beveiliging of privacy in gevaar brengen.\"],\"mHGdhG\":[\"Serverinformatie\"],\"mHS8lb\":[\"Bericht in #\",[\"0\"]],\"mMYBD9\":[\"Breed — Ruimere beveiligingsscope\"],\"mTGsPd\":[\"Kanaalonderwerp\"],\"mU8j6O\":[\"Geen externe berichten (+n)\"],\"mZp8FL\":[\"Automatisch terugvallen op één regel\"],\"mdQu8G\":[\"JouwNickname\"],\"miSSBQ\":[\"Reacties (\",[\"commentCount\"],\")\"],\"mvyLSy\":[\"Gebruiker is geverifieerd\"],\"mwtcGl\":[\"Reacties sluiten\"],\"mzI/c+\":[\"Downloaden\"],\"n3fGRk\":[\"ingesteld door \",[\"0\"]],\"nE9jsU\":[\"Ontspannen — Minder agressieve beveiliging\"],\"nNflMD\":[\"Kanaal verlaten\"],\"nPXkBi\":[\"WHOIS-gegevens laden...\"],\"nQnxxF\":[\"Bericht in #\",[\"0\"],\" (Shift+Enter voor nieuwe regel)\"],\"nWMRxa\":[\"Losmaken\"],\"nkC032\":[\"Geen floodprofiel\"],\"o69z4d\":[\"Een waarschuwingsbericht sturen naar \",[\"username\"]],\"o9ylQi\":[\"Zoek naar GIF's om te beginnen\"],\"oFGkER\":[\"Serverberichten\"],\"oOi11l\":[\"Naar beneden scrollen\"],\"oQEzQR\":[\"Nieuw DM\"],\"oXOSPE\":[\"Online\"],\"oal760\":[\"Man-in-the-middle-aanvallen op serververbindingen zijn mogelijk\"],\"oeqmmJ\":[\"Vertrouwde bronnen\"],\"ovBPCi\":[\"Standaard\"],\"p0Z69r\":[\"Patroon kan niet leeg zijn\"],\"p1KgtK\":[\"Audio laden mislukt\"],\"p59pEv\":[\"Extra details\"],\"p7sRI6\":[\"Laat anderen weten wanneer je typt\"],\"pBm1od\":[\"Geheim kanaal\"],\"pNmiXx\":[\"Je standaard nickname voor alle servers\"],\"pUUo9G\":[\"Hostnaam:\"],\"pVGPmz\":[\"Accountwachtwoord\"],\"peNE68\":[\"Permanent\"],\"plhHQt\":[\"Geen gegevens\"],\"pm6+q5\":[\"Beveiligingswaarschuwing\"],\"pn5qSs\":[\"Aanvullende informatie\"],\"q0cR4S\":[\"is nu bekend als **\",[\"newNick\"],\"**\"],\"qFcunY\":[\"Kanaal verschijnt niet in LIST- of NAMES-opdrachten\"],\"qLpTm/\":[\"Reactie \",[\"emoji\"],\" verwijderen\"],\"qVkGWK\":[\"Vastmaken\"],\"qY8wNa\":[\"Startpagina\"],\"qb0xJ7\":[\"Gebruik jokertekens: * komt overeen met een reeks, ? met één teken. Voorbeelden: nick!*@*, *!*@host.com, *!*gebruiker@*\"],\"qhzpRq\":[\"Kanaalsleutel (+k)\"],\"qtoOYG\":[\"Geen limiet\"],\"r1W2AS\":[\"Afbeelding van bestandshost\"],\"rIPR2O\":[\"Onderwerp ingesteld voor (min geleden)\"],\"rMMSYo\":[\"Maximale lengte is \",[\"0\"]],\"rWtzQe\":[\"Het netwerk splitste en herverbond. ✅\"],\"rYG2u6\":[\"Even wachten...\"],\"rdUucN\":[\"Voorbeeld\"],\"rjGI/Q\":[\"Privacy\"],\"rk8iDX\":[\"GIF's laden...\"],\"rn6SBY\":[\"Dempen opheffen\"],\"s/UKqq\":[\"Uit het kanaal verwijderd\"],\"s8cATI\":[\"heeft \",[\"channelName\"],\" betreden\"],\"sCO9ue\":[\"De verbinding met <0>\",[\"serverName\"],\" heeft de volgende beveiligingsproblemen:\"],\"sGH11W\":[\"Server\"],\"sHI1H+\":[\"is nu bekend als **\",[\"newNick\"],\"**\"],\"sJyV04\":[[\"inviter\"],\" heeft je uitgenodigd om deel te nemen aan \",[\"channel\"]],\"sby+1/\":[\"Klik om te kopiëren\"],\"sfN25C\":[\"Je echte of volledige naam\"],\"sliuzR\":[\"Link openen\"],\"sqrO9R\":[\"Aangepaste vermeldingen\"],\"sr6RdJ\":[\"Meerdere regels met Shift+Enter\"],\"swrCpB\":[\"Het kanaal is hernoemd van \",[\"oldName\"],\" naar \",[\"newName\"],\" door \",[\"user\"],[\"0\"]],\"sxkWRg\":[\"Geavanceerd\"],\"t/YqKh\":[\"Verwijderen\"],\"t47eHD\":[\"Je unieke identificatie op deze server\"],\"tAkAh0\":[\"URL met optionele \",[\"size\"],\"-vervanging voor dynamische grootte. Voorbeeld: https://voorbeeld.com/avatar/\",[\"size\"],\"/kanaal.jpg\"],\"tXLJS3\":[\"Zijbalk met kanaallijst weergeven of verbergen\"],\"tfDRzk\":[\"Opslaan\"],\"tiBsJk\":[\"heeft \",[\"channelName\"],\" verlaten\"],\"tt4/UD\":[\"verliet de server (\",[\"reason\"],\")\"],\"u0TcnO\":[\"Nickname {nick} is al in gebruik, probeer opnieuw met {newNick}\"],\"u0a8B4\":[\"Authenticeren als IRC Operator voor beheerderstoegang\"],\"u0rWFU\":[\"Aangemaakt na (min geleden)\"],\"u72w3t\":[\"Gebruikers en patronen om te negeren\"],\"u7jc2L\":[\"verliet de server\"],\"uAQUqI\":[\"Status\"],\"uB85T3\":[\"Opslaan mislukt: \",[\"msg\"]],\"uV3DOL\":[\"G-Line\"],\"uW3lLI\":[\"IRC-servers:\"],\"usSSr/\":[\"Zoomniveau\"],\"v7uvcf\":[\"Software:\"],\"vE8kb+\":[\"Gebruik Shift+Enter voor nieuwe regels (Enter verstuurt)\"],\"vERlcd\":[\"Profiel\"],\"vK0RL8\":[\"Geen onderwerp\"],\"vSJd18\":[\"Video\"],\"vXIe7J\":[\"Taal\"],\"vaHYxN\":[\"Echte naam\"],\"vhjbKr\":[\"Afwezig\"],\"w4NYox\":[[\"title\"],\" client\"],\"w8xQRx\":[\"Ongeldige waarde\"],\"wFjjxZ\":[\"werd gekickt uit \",[\"channelName\"],\" door \",[\"username\"],\" (\",[\"reason\"],\")\"],\"wGjaGl\":[\"Geen uitzonderingen op bannen gevonden\"],\"wPrGnM\":[\"Kanaalbeheerder\"],\"wRkP2d\":[\"GIF\"],\"wbm86v\":[\"Weergeven wanneer gebruikers kanalen betreden of verlaten\"],\"whqZ9r\":[\"Extra woorden of zinnen om te markeren\"],\"wm7RV4\":[\"Meldingsgeluid\"],\"wz/Yoq\":[\"Je berichten kunnen worden onderschept wanneer ze tussen servers worden doorgegeven\"],\"xCJdfg\":[\"Wissen\"],\"xUHRTR\":[\"Automatisch als operator authenticeren bij verbinden\"],\"xWHwwQ\":[\"Bannen\"],\"xYilR2\":[\"Media\"],\"xceQrO\":[\"Alleen beveiligde WebSockets worden ondersteund\"],\"xdtXa+\":[\"kanaalnaam\"],\"xfXC7q\":[\"Tekstkanalen\"],\"xlCYOE\":[\"Meer berichten ophalen...\"],\"xlhswE\":[\"Minimale waarde is \",[\"0\"]],\"xq97Ci\":[\"Voeg een woord of zin toe...\"],\"xuRqRq\":[\"Clientlimiet (+l)\"],\"xwF+7J\":[[\"0\"],\" typt...\"],\"yNeucF\":[\"Deze server ondersteunt geen uitgebreide profielmetadata (IRCv3 METADATA-extensie). Extra velden zoals avatar, weergavenaam en status zijn niet beschikbaar.\"],\"yPlrca\":[\"Kanaalavatar\"],\"yQE2r9\":[\"Laden\"],\"ySU+JY\":[\"jouw@email.com\"],\"yTX1Rt\":[\"Oper-gebruikersnaam\"],\"yYOzWD\":[\"logboeken\"],\"yfx9Re\":[\"IRC operator-wachtwoord\"],\"ygCKqB\":[\"Stoppen\"],\"ymDxJx\":[\"IRC operator-gebruikersnaam\"],\"yrpRsQ\":[\"Sorteren op naam\"],\"yz7wBu\":[\"Sluiten\"],\"zJw+jA\":[\"stelt modus in: \",[\"0\"]],\"zebeLu\":[\"Oper-gebruikersnaam invoeren\"],\"zpr0Bw\":[\"GZ-Line\"]}"); \ No newline at end of file +/*eslint-disable*/export const messages=JSON.parse("{\"+5VMrz\":[\"Ongeldig patroonformaat. Gebruik het formaat nick!gebruiker@host (jokerteken * toegestaan)\"],\"+6NQQA\":[\"Algemeen ondersteuningskanaal\"],\"+6NyRG\":[\"Client\"],\"+K0AvT\":[\"Verbreken\"],\"+cyFdH\":[\"Standaardbericht wanneer je jezelf als afwezig markeert\"],\"+mVPqU\":[\"Markdown-opmaak in berichten weergeven\"],\"+vqCJH\":[\"Je accountgebruikersnaam voor authenticatie\"],\"+yPBXI\":[\"Bestand kiezen\"],\"+zy2Nq\":[\"Type\"],\"/09cao\":[\"Lage verbindingsbeveiliging (niveau \",[\"securityLevel\"],\")\"],\"/3BQ4J\":[\"Gebruikers buiten het kanaal kunnen er geen berichten naar sturen\"],\"/6BzZF\":[\"Ledenlijst aan/uit\"],\"/TNOPk\":[\"Gebruiker is afwezig\"],\"/XQgft\":[\"Ontdekken\"],\"/cF7Rs\":[\"Volume\"],\"/dqduX\":[\"Volgende pagina\"],\"/fc3q4\":[\"Alle inhoud\"],\"/kISDh\":[\"Meldingsgeluiden inschakelen\"],\"/n04sB\":[\"Kill\"],\"/rTz0M\":[\"Audio\"],\"/rfkZe\":[\"Geluiden afspelen voor vermeldingen en berichten\"],\"0/0ZGA\":[\"Kanaalnaammasker\"],\"0D6j7U\":[\"Meer informatie over aangepaste regels →\"],\"0XsHcR\":[\"Gebruiker verwijderen\"],\"0ZpE//\":[\"Sorteren op gebruikers\"],\"0bEPwz\":[\"Afwezig instellen\"],\"0dGkPt\":[\"Kanaallijst uitvouwen\"],\"0gS7M5\":[\"Weergavenaam\"],\"0kS+M8\":[\"VoorbeeldNET\"],\"0rgoY7\":[\"Alleen verbinden met servers die jij kiest\"],\"0wdd7X\":[\"Deelnemen\"],\"0wkVYx\":[\"Privéberichten\"],\"111uHX\":[\"Linkvoorbeeldweergave\"],\"196EG4\":[\"Privégesprek verwijderen\"],\"1DSr1i\":[\"Registreren voor een account\"],\"1O/24y\":[\"Kanaallijst aan/uit\"],\"1TNIig\":[\"Open\"],\"1VPJJ2\":[\"Waarschuwing externe link\"],\"1ZC/dv\":[\"Geen ongelezen vermeldingen of berichten\"],\"1pO1zi\":[\"Servernaam is vereist\"],\"1uwfzQ\":[\"Kanaalonderwerp bekijken\"],\"268g7c\":[\"Weergavenaam invoeren\"],\"2FOFq1\":[\"Serveroperators op het netwerk kunnen mogelijk je berichten lezen\"],\"2FYpfJ\":[\"Meer\"],\"2HF1Y2\":[[\"inviter\"],\" heeft \",[\"target\"],\" uitgenodigd om deel te nemen aan \",[\"channel\"]],\"2I70QL\":[\"Gebruikersprofielinformatie bekijken\"],\"2QYdmE\":[\"Gebruikers:\"],\"2QpEjG\":[\"heeft verlaten\"],\"2YE223\":[\"Bericht in #\",[\"0\"],\" (Enter voor nieuwe regel, Shift+Enter om te verzenden)\"],\"2bimFY\":[\"Serverwachtwoord gebruiken\"],\"2iTmdZ\":[\"Lokale opslag:\"],\"2odkwe\":[\"Streng — Agressievere beveiliging\"],\"2uDhbA\":[\"Gebruikersnaam invoeren om uit te nodigen\"],\"2ygf/L\":[\"← Terug\"],\"2zEgxj\":[\"GIF's zoeken...\"],\"3RdPhl\":[\"Kanaal hernoemen\"],\"3THokf\":[\"Gebruiker met voice\"],\"3TSz9S\":[\"Minimaliseren\"],\"3jBDvM\":[\"Weergavenaam van kanaal\"],\"3ryuFU\":[\"Optionele crashrapporten om de app te verbeteren\"],\"3uBF/8\":[\"Viewer sluiten\"],\"3uwW8F\":[\"https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"3xf8Kz\":[\"Voer accountnaam in...\"],\"4/Rr0R\":[\"Een gebruiker uitnodigen voor het huidige kanaal\"],\"4EZrJN\":[\"Regels\"],\"4JJtW9\":[\"#overflow\"],\"4NqeT4\":[\"Floodprofiel (+F)\"],\"4RZQRK\":[\"Wat ben je aan het doen?\"],\"4hfTrB\":[\"Nickname\"],\"4n99LO\":[\"Al in \",[\"0\"]],\"4t6vMV\":[\"Automatisch overschakelen naar één regel voor korte berichten\"],\"4vsHmf\":[\"Tijd (min)\"],\"4x/Axu\":[\"Je bouncer heeft nog geen netwerken. Voeg er een toe om te beginnen.\"],\"5+INAX\":[\"Berichten markeren die jou vermelden\"],\"5R5Pv/\":[\"Oper-naam\"],\"678PKt\":[\"Netwerknaam\"],\"6Aih4U\":[\"Offline\"],\"6CO3WE\":[\"Wachtwoord vereist om het kanaal te betreden. Laat leeg om de sleutel te verwijderen.\"],\"6HhMs3\":[\"Afsluitbericht\"],\"6V3Ea3\":[\"Gekopieerd\"],\"6lGV3K\":[\"Minder weergeven\"],\"6yFOEi\":[\"Voer oper-wachtwoord in...\"],\"7+IHTZ\":[\"Geen bestand gekozen\"],\"73fnil\":[\"TLS\"],\"73hrRi\":[\"nick!gebruiker@host (bijv. spam*!*@*, *!*@slechtehost.com)\"],\"7QkKyN\":[\"Privébericht sturen\"],\"7U1W7c\":[\"Zeer ontspannen\"],\"7Y1YQj\":[\"Echte naam:\"],\"7YHArF\":[\"— openen in viewer\"],\"7fjnVl\":[\"Gebruikers zoeken...\"],\"7jL88x\":[\"Dit bericht verwijderen? Dit kan niet ongedaan worden gemaakt.\"],\"7nGhhM\":[\"Waar denk je aan?\"],\"7sEpu1\":[\"Leden — \",[\"0\"]],\"7sNhEz\":[\"Gebruikersnaam\"],\"8H0Q+x\":[\"Meer informatie over profielen →\"],\"8Phu0A\":[\"Weergeven wanneer gebruikers hun nickname wijzigen\"],\"8XTG9e\":[\"Oper-wachtwoord invoeren\"],\"8XsV2J\":[\"Opnieuw verzenden\"],\"8ZsakT\":[\"Wachtwoord\"],\"8kR84m\":[\"Je staat op het punt een externe link te openen:\"],\"8lCgih\":[\"Regel verwijderen\"],\"8p/xVT\":[[\"0\",\"plural\",{\"one\":[[\"1\"]],\"other\":[[\"2\"]]}]],\"8wRzac\":[[\"joinCount\",\"plural\",{\"one\":[\"deed mee\"],\"other\":[\"deed \",[\"joinCount\"],\" keer mee\"]}]],\"9BMLnJ\":[\"Opnieuw verbinden met server\"],\"9OEgyT\":[\"Reactie toevoegen\"],\"9PQ8m2\":[\"G-Line (globale ban)\"],\"9Qs99X\":[\"E-mail:\"],\"9QupBP\":[\"Patroon verwijderen\"],\"9W7tl5\":[\"(ongewijzigd)\"],\"9bG48P\":[\"Bezig met verzenden\"],\"9f5f0u\":[\"Vragen over privacy? Neem contact met ons op:\"],\"9iweoP\":[\"Netwerken op \",[\"0\"]],\"9unqs3\":[\"Afwezig:\"],\"9v3hwv\":[\"Geen servers gevonden.\"],\"9zb2WA\":[\"Verbinding maken\"],\"A1taO8\":[\"Zoeken\"],\"A2adVi\":[\"Typemeldingen verzenden\"],\"A9Rhec\":[\"Kanaalnaam\"],\"AWOSPo\":[\"Inzoomen\"],\"AXSpEQ\":[\"Oper bij verbinding\"],\"AeXO77\":[\"Account\"],\"AhNP40\":[\"Spoelen\"],\"Ai2U7L\":[\"Host\"],\"AjBQnf\":[\"Nickname gewijzigd\"],\"AmXVh6\":[\"https://example.com/avatar.png\"],\"AnRu/j\":[\"Antwoord annuleren\"],\"ApSx0O\":[[\"0\"],\" berichten gevonden die overeenkomen met \\\"\",[\"searchQuery\"],\"\\\"\"],\"AxPAXW\":[\"Geen resultaten gevonden\"],\"AyNqAB\":[\"Alle servergebeurtenissen in de chat weergeven\"],\"B/QqGw\":[\"Niet achter het toetsenbord\"],\"B0sB2k\":[\"Platte tekst\"],\"B8AaMI\":[\"Dit veld is vereist\"],\"BA2c49\":[\"Server ondersteunt geen geavanceerde LIST-filtering\"],\"BDKt3I\":[[\"0\"],\", \",[\"1\"],\", \",[\"2\"],\" en \",[\"3\"],\" anderen typen...\"],\"BGul2A\":[\"Je hebt niet-opgeslagen wijzigingen. Weet je zeker dat je wilt sluiten zonder op te slaan?\"],\"BIf9fi\":[\"Je statusbericht\"],\"BZz3md\":[\"Je persoonlijke website\"],\"Bgm/H7\":[\"Meerdere tekstregels invoeren toestaan\"],\"BiQIl1\":[\"Dit privéberichtgesprek vastmaken\"],\"BlNZZ2\":[\"Klik om naar bericht te springen\"],\"Bowq3c\":[\"Alleen operators kunnen het kanaalonderwerp wijzigen\"],\"Btozzp\":[\"Deze afbeelding is verlopen\"],\"Bycfjm\":[\"Totaal: \",[\"0\"]],\"C6IBQc\":[\"Kopieer volledige JSON\"],\"C9L9wL\":[\"Gegevensverzameling\"],\"CDq4wC\":[\"Gebruiker modereren\"],\"CHVRxG\":[\"Bericht aan @\",[\"0\"],\" (Shift+Enter voor nieuwe regel)\"],\"CN9zdR\":[\"Oper-naam en wachtwoord zijn vereist\"],\"CW3sYa\":[\"Reactie toevoegen \",[\"emoji\"]],\"CaAkqd\":[\"Afmeldingen weergeven\"],\"CbvaYj\":[\"Bannen via nickname\"],\"CcK+Ft\":[\"PDF\"],\"Ce8q3L\":[\"Selecteer een kanaal\"],\"CsekCi\":[\"Normaal\"],\"D+NlUC\":[\"Systeem\"],\"D28t6+\":[\"is toegetreden en vertrokken\"],\"DB8zMK\":[\"Toepassen\"],\"DBcWHr\":[\"Aangepast meldingsgeluidsbestand\"],\"DTy9Xw\":[\"Mediavoorbeeldweergaven\"],\"Dj4pSr\":[\"Kies een veilig wachtwoord\"],\"Du+zn+\":[\"Zoeken...\"],\"Du2T2f\":[\"Instelling niet gevonden\"],\"DwsSVQ\":[\"Filters toepassen en vernieuwen\"],\"E3W/zd\":[\"Standaard nickname\"],\"E6nRW7\":[\"URL kopiëren\"],\"E703RG\":[\"Modi:\"],\"EAeu1Z\":[\"Uitnodiging verzenden\"],\"EFKJQT\":[\"Instelling\"],\"EGPQBv\":[\"Aangepaste floodregels (+f)\"],\"ELik0r\":[\"Volledig privacybeleid bekijken\"],\"EPbeC2\":[\"Kanaalonderwerp bekijken of bewerken\"],\"EQCDNT\":[\"Voer oper-gebruikersnaam in...\"],\"EUvulZ\":[\"1 bericht gevonden dat overeenkomt met \\\"\",[\"searchQuery\"],\"\\\"\"],\"EatZYJ\":[\"Volgende afbeelding\"],\"EdQY6l\":[\"Geen\"],\"EnqLYU\":[\"Servers zoeken...\"],\"F0OKMc\":[\"Server bewerken\"],\"F6Int2\":[\"Markeringen inschakelen\"],\"FDoLyE\":[\"Max. gebruikers\"],\"FUU/hZ\":[\"Bepaalt hoeveel externe media in de chat worden geladen.\"],\"Fdp03t\":[\"aan\"],\"FfPWR0\":[\"Venster\"],\"FjkaiT\":[\"Uitzoomen\"],\"FlqOE9\":[\"Wat dit betekent:\"],\"FolHNl\":[\"Je account en authenticatie beheren\"],\"Fp2Dif\":[\"De server verlaten\"],\"G5KmCc\":[\"GZ-Line (globale Z-Line)\"],\"GDs0lz\":[\"<0>Risico: Gevoelige informatie (berichten, privégesprekken, authenticatiegegevens) kan worden blootgesteld aan netwerkbeheerders of aanvallers tussen IRC-servers.\"],\"GR+2I3\":[\"Uitnodigingsmasker toevoegen (bijv. nick!*@*, *!*@host.com)\"],\"GRLyMU\":[\"Losgekoppelde serverberichten sluiten\"],\"GlHnXw\":[\"Nickname wijziging mislukt: \",[\"error\"],\" \",[\"0\"]],\"GswZF3\":[\"Voorbeeld:\"],\"GtmO8/\":[\"van\"],\"GtuHUQ\":[\"Dit kanaal op de server hernoemen. Alle gebruikers zien de nieuwe naam.\"],\"GuGfFX\":[\"Zoeken aan/uit\"],\"GxkJXS\":[\"Uploaden...\"],\"GzbwnK\":[\"Het kanaal betreden\"],\"GzsUDB\":[\"Uitgebreid profiel\"],\"H/PnT8\":[\"Emoji invoegen\"],\"H6Izzl\":[\"Je voorkeurkleurcode\"],\"H9jIv+\":[\"Aanmeldingen/vertrekken weergeven\"],\"HAKBY9\":[\"Bestanden uploaden\"],\"HdE1If\":[\"Kanaal\"],\"Hk4AW9\":[\"Je voorkeurweergavenaam\"],\"HmHDk7\":[\"Lid selecteren\"],\"HrQzPU\":[\"Kanalen op \",[\"networkName\"]],\"I2tXQ5\":[\"Bericht aan @\",[\"0\"],\" (Enter voor nieuwe regel, Shift+Enter om te verzenden)\"],\"I6bw/h\":[\"Gebruiker bannen\"],\"I92Z+b\":[\"Meldingen inschakelen\"],\"I9D72S\":[\"Weet je zeker dat je dit bericht wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.\"],\"IA+1wo\":[\"Weergeven wanneer gebruikers uit kanalen worden verwijderd\"],\"IDwkJx\":[\"IRC Operator\"],\"ILlU+s\":[\"Info:\"],\"IUwGEM\":[\"Wijzigingen opslaan\"],\"IVeGK6\":[[\"0\"],\", \",[\"1\"],\" en \",[\"2\"],\" zijn aan het typen...\"],\"IgrLD/\":[\"Pauzeren\"],\"Im6JED\":[\"WHISPER\"],\"ImOQa9\":[\"Beantwoorden\"],\"IoHMnl\":[\"Maximale waarde is \",[\"0\"]],\"IvMj+0\":[\"Op\"],\"J28zul\":[\"Verbinding maken...\"],\"J5T9NW\":[\"Gebruikersinformatie\"],\"J8Y5+z\":[\"Oeps! Netwerksplitsing! ⚠️\"],\"JBHkBA\":[\"Het kanaal verlaten\"],\"JCwL0Q\":[\"Reden invoeren (optioneel)\"],\"JFciKP\":[\"Aan/uit\"],\"JXGkhG\":[\"Kanaalnaam wijzigen (alleen operators)\"],\"JcD7qf\":[\"Meer acties\"],\"JdkA+c\":[\"Geheim (+s)\"],\"Jmu12l\":[\"Serverkanalen\"],\"JvQ++s\":[\"Markdown inschakelen\"],\"K2jwh/\":[\"Geen WHOIS-gegevens beschikbaar\"],\"KAXSwC\":[\"Voice\"],\"KDfTdX\":[\"Bericht verwijderen\"],\"KKBlUU\":[\"Insluiten\"],\"KM0pLb\":[\"Welkom in het kanaal!\"],\"KR6W2h\":[\"Gebruiker niet meer negeren\"],\"KV+Bi1\":[\"Alleen op uitnodiging (+i)\"],\"KdCtwE\":[\"Hoeveel seconden floodactiviteit bewaken voordat tellers worden gereset\"],\"Kkezga\":[\"Serverwachtwoord\"],\"KsiQ/8\":[\"Gebruikers moeten worden uitgenodigd om het kanaal te betreden\"],\"L+gB/D\":[\"Kanaalinformatie\"],\"LC1a7n\":[\"De IRC-server heeft gemeld dat de server-naar-serververbindingen een laag beveiligingsniveau hebben. Dit betekent dat wanneer je berichten worden doorgegeven tussen IRC-servers in het netwerk, ze mogelijk niet correct worden versleuteld of dat de SSL/TLS-certificaten niet correct worden gevalideerd.\"],\"LNfLR5\":[\"Kicks weergeven\"],\"LP+1Z7\":[\"Netwerk toevoegen\"],\"LQb0W/\":[\"Alle gebeurtenissen weergeven\"],\"LU7/yA\":[\"Alternatieve naam voor weergave in de interface. Mag spaties, emoji en speciale tekens bevatten. De echte kanaalnaam (\",[\"channelName\"],\") wordt nog steeds gebruikt voor IRC-opdrachten.\"],\"LUb9O7\":[\"Een geldige serverpoort is vereist\"],\"Lb+BUl\":[\"https://example.com/avatar.jpg\"],\"LcET2C\":[\"Privacybeleid\"],\"LcuSDR\":[\"Je profielgegevens en metadata beheren\"],\"LqLS9B\":[\"Nicknamewijzigingen weergeven\"],\"LsDQt2\":[\"Kanaalinstellingen\"],\"LtI9AS\":[\"Eigenaar\"],\"LuNhhL\":[\"reageerde op dit bericht\"],\"M/AZNG\":[\"URL naar je avatarafbeelding\"],\"M/WIer\":[\"Bericht verzenden\"],\"M8er/5\":[\"Naam:\"],\"MHk+7g\":[\"Vorige afbeelding\"],\"MRorGe\":[\"Gebruiker een PM sturen\"],\"MVbSGP\":[\"Tijdvenster (seconden)\"],\"MkpcsT\":[\"Je berichten en instellingen worden lokaal op je apparaat opgeslagen\"],\"MzPdC2\":[\"Serverwachtwoord (PASS)\"],\"N/hDSy\":[\"Markeren als bot — gewoonlijk 'aan' of leeg\"],\"N6j2JH\":[[\"0\"],\" bewerken\"],\"N7TQbE\":[\"Gebruiker uitnodigen voor \",[\"channelName\"]],\"NCca/o\":[\"Voer standaard bijnaam in...\"],\"Nqs6B9\":[\"Toont alle externe media. Elke URL kan een verzoek naar een onbekende server veroorzaken.\"],\"Nt+9O7\":[\"WebSocket gebruiken in plaats van raw TCP\"],\"NxIHzc\":[\"Gebruiker verbreken\"],\"O+v/cL\":[\"Alle kanalen op de server bekijken\"],\"OCGpR4\":[\"(overerven)\"],\"ODwSCk\":[\"Een GIF verzenden\"],\"OGQ5kK\":[\"Meldingsgeluiden en markeringen instellen\"],\"OIPt1Z\":[\"Zijbalk met ledenlijst weergeven of verbergen\"],\"OKSNq/\":[\"Zeer streng\"],\"ONWvwQ\":[\"Uploaden\"],\"OVKoQO\":[\"Je accountwachtwoord voor authenticatie\"],\"ObsidianIRC - Bringing IRC to the future\":[\"ObsidianIRC - IRC naar de toekomst brengen\"],\"OhCpra\":[\"Een onderwerp instellen…\"],\"OkltoQ\":[[\"username\"],\" bannen via nickname (voorkomt dat ze opnieuw deelnemen met dezelfde nick)\"],\"P+t/Te\":[\"Geen aanvullende gegevens\"],\"P42Wcc\":[\"Veilig\"],\"PD38l0\":[\"Kanaalavatar voorbeeldweergave\"],\"PD9mEt\":[\"Typ een bericht...\"],\"PPqfdA\":[\"Kanaelconfiguratie-instellingen openen\"],\"PSCjfZ\":[\"Het onderwerp dat voor dit kanaal wordt weergegeven. Alle gebruikers kunnen het onderwerp zien.\"],\"PZCecv\":[\"PDF-voorbeeld\"],\"PeLgsC\":[[\"c\",\"plural\",{\"one\":[\"1 keer\"],\"other\":[[\"c\"],\" keer\"]}]],\"PguS2C\":[\"Uitzonderingsmasker toevoegen (bijv. nick!*@*, *!*@host.com)\"],\"Pil5Ty\":[[\"displayedChannelsCount\"],\" van \",[\"0\"],\" kanalen weergegeven\"],\"PqhVlJ\":[\"Gebruiker bannen (via hostmasker)\"],\"Q+chwU\":[\"Gebruikersnaam:\"],\"Q3v9Wc\":[\"Ja, verwijderen\"],\"Q6hhn8\":[\"Voorkeuren\"],\"QF4a34\":[\"Voer een gebruikersnaam in\"],\"QGqSZ2\":[\"Kleur en opmaak\"],\"QJQd1J\":[\"Profiel bewerken\"],\"QSzGDE\":[\"Inactief\"],\"QUlny5\":[\"Welkom bij \",[\"0\"],\"!\"],\"Qoq+GP\":[\"Meer lezen\"],\"QuSkCF\":[\"Kanalen filteren...\"],\"QwUrDZ\":[\"heeft het onderwerp gewijzigd naar: \",[\"topic\"]],\"R0UH07\":[\"Afbeelding \",[\"0\"],\" van \",[\"1\"]],\"R7SsBE\":[\"Dempen\"],\"R8rf1X\":[\"Klik om onderwerp in te stellen\"],\"RArB3D\":[\"werd gekickt uit \",[\"channelName\"],\" door \",[\"username\"]],\"RI3cWd\":[\"Ontdek de wereld van IRC met ObsidianIRC\"],\"RMMaN5\":[\"Gemodereerd (+m)\"],\"RWw9Lg\":[\"Venster sluiten\"],\"RZ2BuZ\":[\"Accountregistratie voor \",[\"account\"],\" vereist verificatie: \",[\"message\"]],\"RySp6q\":[\"Reacties verbergen\"],\"S5Togi\":[\"Netwerken laden van je bouncer…\"],\"SPKQTd\":[\"Nickname is vereist\"],\"SPVjfj\":[\"Standaard 'geen reden' als leeggelaten\"],\"SQKPvQ\":[\"Gebruiker uitnodigen\"],\"STmlpb\":[\"Back to network list\"],\"SkZcl+\":[\"Kies een vooraf ingesteld floodbeveililingsprofiel. Deze profielen bieden evenwichtige beveiligingsinstellingen voor verschillende toepassingen.\"],\"Slr+3C\":[\"Min. gebruikers\"],\"Spnlre\":[\"Je hebt \",[\"target\"],\" uitgenodigd om deel te nemen aan \",[\"channel\"]],\"T/ckN5\":[\"Openen in viewer\"],\"T91vKp\":[\"Afspelen\"],\"TV2Wdu\":[\"Lees hoe we met je gegevens omgaan en je privacy beschermen.\"],\"TgFpwD\":[\"Toepassen...\"],\"TkzSFB\":[\"Geen wijzigingen\"],\"TtserG\":[\"Echte naam invoeren\"],\"Ttz9J1\":[\"Voer wachtwoord in...\"],\"Tz0i8g\":[\"Instellingen\"],\"U3pytU\":[\"Admin\"],\"UDb2YD\":[\"Reageren\"],\"UE4KO5\":[\"*kanaal*\"],\"UGT5vp\":[\"Instellingen opslaan\"],\"UV5hLB\":[\"Geen bannen gevonden\"],\"Uaj3Nd\":[\"Statusberichten\"],\"Ue3uny\":[\"Standaard (geen profiel)\"],\"UkARhe\":[\"Normaal — Standaardbeveiliging\"],\"Umn7Cj\":[\"Nog geen reacties. Wees de eerste!\"],\"UtUIRh\":[[\"0\"],\" oudere berichten\"],\"UwzP+U\":[\"Beveiligde verbinding\"],\"V0/A4O\":[\"Kanaaleigenaar\"],\"V4qgxE\":[\"Aangemaakt voor (min geleden)\"],\"V8yTm6\":[\"Zoekopdracht wissen\"],\"VJMMyz\":[\"ObsidianIRC — IRC de toekomst in\"],\"VJScHU\":[\"Reden\"],\"VLsmVV\":[\"Meldingen dempen\"],\"VbyRUy\":[\"Reacties\"],\"Vmx0mQ\":[\"Ingesteld door:\"],\"VqnIZz\":[\"Ons privacybeleid en gegevenspraktijken bekijken\"],\"VrMygG\":[\"Minimale lengte is \",[\"0\"]],\"VrnTui\":[\"Je voornaamwoorden, weergegeven in je profiel\"],\"W8E3qn\":[\"Geverifieerd account\"],\"WAakm9\":[\"Kanaal verwijderen\"],\"WFxTHC\":[\"Banmasker toevoegen (bijv. nick!*@*, *!*@host.com)\"],\"WN1g9F\":[\"Serverhost is vereist\"],\"WRYdXW\":[\"Audiopositie\"],\"WUOH5B\":[\"Gebruiker negeren\"],\"WWEXnZ\":[[\"0\",\"plural\",{\"one\":[\"Toon 1 item meer\"],\"other\":[\"Toon \",[\"1\"],\" items meer\"]}]],\"Weq9zb\":[\"Algemeen\"],\"Wfj7Sk\":[\"Meldingsgeluiden dempen of activeren\"],\"Wm7gbG\":[\"GitHub:\"],\"WyeHWY\":[\"*spam*\"],\"WzMCru\":[\"Gebruikersprofiel\"],\"X6S3lt\":[\"Instellingen, kanalen, servers zoeken...\"],\"XEHan5\":[\"Toch doorgaan\"],\"XI1+wb\":[\"Ongeldig formaat\"],\"XIXeuC\":[\"Bericht aan @\",[\"0\"]],\"XMS+k4\":[\"Privébericht starten\"],\"XWgxXq\":[\"Album\"],\"Xd7+IT\":[\"Privégesprek losmaken\"],\"Xm/s+u\":[\"Weergave\"],\"Xp2n93\":[\"Toont media van de vertrouwde bestandshost van je server. Er worden geen verzoeken gedaan aan externe diensten.\"],\"XvjC4F\":[\"Opslaan...\"],\"Y/qryO\":[\"Geen gebruikers gevonden die overeenkomen met je zoekopdracht\"],\"YAqRpI\":[\"Accountregistratie voor \",[\"account\"],\" geslaagd: \",[\"message\"]],\"YEfzvP\":[\"Beveiligd onderwerp (+t)\"],\"YQOn6a\":[\"Ledenlijst inklappen\"],\"YRCoE9\":[\"Kanaaloperator\"],\"YURQaF\":[\"Profiel bekijken\"],\"YdBSvr\":[\"Mediaweergave en externe inhoud beheren\"],\"Yj6U3V\":[\"Geen centrale server:\"],\"YjvpGx\":[\"Voornaamwoorden\"],\"YqH4l4\":[\"Geen sleutel\"],\"YyUPpV\":[\"Account:\"],\"ZJSWfw\":[\"Bericht dat wordt weergegeven wanneer je de verbinding met de server verbreekt\"],\"ZR1dJ4\":[\"Uitnodigingen\"],\"ZdWg0V\":[\"Openen in browser\"],\"ZhRBbl\":[\"Berichten zoeken…\"],\"Zmcu3y\":[\"Geavanceerde filters\"],\"a2/8e5\":[\"Onderwerp ingesteld na (min geleden)\"],\"aHKcKc\":[\"Vorige pagina\"],\"aJTbXX\":[\"Oper-wachtwoord\"],\"aQryQv\":[\"Patroon bestaat al\"],\"aW9pLN\":[\"Maximaal aantal toegestane gebruikers in het kanaal. Laat leeg voor geen limiet.\"],\"ah4fmZ\":[\"Toont ook voorbeeldweergaven van YouTube, Vimeo, SoundCloud en vergelijkbare bekende diensten.\"],\"aifXak\":[\"Geen media in dit kanaal\"],\"ap2zBz\":[\"Ontspannen\"],\"az8lvo\":[\"Uit\"],\"azXSNo\":[\"Ledenlijst uitvouwen\"],\"azdliB\":[\"Aanmelden bij een account\"],\"b26wlF\":[\"zij/haar\"],\"bD/+Ei\":[\"Streng\"],\"bQ6BJn\":[\"Stel gedetailleerde floodbeveiligingsregels in. Elke regel bepaalt welk type activiteit wordt bewaakt en welke actie wordt ondernomen als drempelwaarden worden overschreden.\"],\"beV7+y\":[\"De gebruiker ontvangt een uitnodiging om deel te nemen aan \",[\"channelName\"],\".\"],\"bk84cH\":[\"Afwezigheidsbericht\"],\"bkHdLj\":[\"IRC-server toevoegen\"],\"bmQLn5\":[\"Regel toevoegen\"],\"bv4cFj\":[\"Transport\"],\"bwRvnp\":[\"Actie\"],\"c8+EVZ\":[\"Geverifieerd account\"],\"cGYUlD\":[\"Er worden geen mediavoorbeeldweergaven geladen.\"],\"cLF98o\":[\"Reacties tonen (\",[\"commentCount\"],\")\"],\"cLKIDO\":[\"Geen gebruikers beschikbaar\"],\"cSgpoS\":[\"Privégesprek vastmaken\"],\"cde3ce\":[\"Bericht aan <0>\",[\"0\"],\"\"],\"chQsxg\":[\"Kopieer opgemaakte uitvoer\"],\"cl/A5J\":[\"Welkom bij \",[\"__DEFAULT_IRC_SERVER_NAME__\"],\"!\"],\"cnGeoo\":[\"Verwijderen\"],\"coPLXT\":[\"We slaan je IRC-communicatie niet op onze servers op\"],\"crYH/6\":[\"SoundCloud-speler\"],\"cv5DQb\":[\"geen host ingesteld\"],\"d3sis4\":[\"Server toevoegen\"],\"d9aN5k\":[[\"username\"],\" uit het kanaal verwijderen\"],\"dEgA5A\":[\"Annuleren\"],\"dGi1We\":[\"Dit privéberichtgesprek losmaken\"],\"dJVuyC\":[\"heeft \",[\"channelName\"],\" verlaten (\",[\"reason\"],\")\"],\"dMtLDE\":[\"aan\"],\"dXqxlh\":[\"<0>⚠️ Beveiligingsrisico! Deze verbinding kan kwetsbaar zijn voor onderschepping of man-in-the-middle-aanvallen.\"],\"da9Q/R\":[\"Kanaalmodi gewijzigd\"],\"dhJN3N\":[\"Reacties tonen\"],\"dj2xTE\":[\"Melding sluiten\"],\"dpCzmC\":[\"Floodbeveiligingsinstellingen\"],\"e9dQpT\":[\"Wil je deze link in een nieuw tabblad openen?\"],\"ePK91l\":[\"Bewerken\"],\"eYBDuB\":[\"Upload een afbeelding of geef een URL op met optionele \",[\"size\"],\"-vervanging voor dynamische grootte\"],\"edBbee\":[[\"username\"],\" bannen via hostmasker (voorkomt dat ze opnieuw deelnemen via hetzelfde IP/host)\"],\"ekfzWq\":[\"Gebruikersinstellingen\"],\"elPDWs\":[\"Pas je IRC-clientervaring aan\"],\"eu2osY\":[\"<0>💡 Aanbeveling: Ga alleen verder als je deze server vertrouwt en de risico's begrijpt. Deel geen gevoelige informatie of wachtwoorden via deze verbinding.\"],\"euEhbr\":[\"Klik om deel te nemen aan \",[\"channel\"]],\"ez3vLd\":[\"Meerdere regels invoer inschakelen\"],\"f0J5Ki\":[\"Server-naar-servercommunicatie kan niet-versleutelde verbindingen gebruiken\"],\"f9BHJk\":[\"Gebruiker waarschuwen\"],\"fDOLLd\":[\"Geen kanalen gevonden.\"],\"ffzDkB\":[\"Anonieme analyses:\"],\"fq1GF9\":[\"Weergeven wanneer gebruikers de verbinding met de server verbreken\"],\"gEF57C\":[\"Deze server ondersteunt slechts één verbindingstype\"],\"gJuLUI\":[\"Negeerlijst\"],\"gNzMrk\":[\"Huidige avatar\"],\"gjPWyO\":[\"Voer bijnaam in...\"],\"gz6UQ3\":[\"Maximaliseren\"],\"h6/IMX\":[\"Voeg je eerste netwerk toe\"],\"h6razj\":[\"Kanaalnaammasker uitsluiten\"],\"hG6jnw\":[\"Geen onderwerp ingesteld\"],\"hG89Ed\":[\"Afbeelding\"],\"hZ6znB\":[\"Poort\"],\"ha+Bz5\":[\"bijv. 100:1440\"],\"hehnjM\":[\"Aantal\"],\"hzdLuQ\":[\"Alleen gebruikers met voice of hoger kunnen spreken\"],\"i0qMbr\":[\"Start\"],\"iDNBZe\":[\"Meldingen\"],\"iH8pgl\":[\"Terug\"],\"iL9SZg\":[\"Gebruiker bannen (via nickname)\"],\"iNt+3c\":[\"Terug naar afbeelding\"],\"iQvi+a\":[\"Niet meer waarschuwen over lage verbindingsbeveiliging voor deze server\"],\"iSLIjg\":[\"Verbinden\"],\"iWXkHH\":[\"Halfop\"],\"iZeTtp\":[\"Serverhost\"],\"idD8Ev\":[\"Opgeslagen\"],\"iivqkW\":[\"Aangemeld op\"],\"ij+Elv\":[\"Afbeeldingsvoorbeeldweergave\"],\"ilIWp7\":[\"Meldingen aan/uit\"],\"iuaqvB\":[\"Gebruik * voor jokertekens. Voorbeelden: slechtegebruiker!*@*, *!*@spammer.com, trol*!*@*\"],\"ixkTse\":[\"Bot\"],\"j2DGR0\":[\"Bannen via hostmasker\"],\"jA4uoI\":[\"Onderwerp:\"],\"jLXxGK\":[\"https://example.com\"],\"jPSk57\":[\"Reden (optioneel)\"],\"jUV7CU\":[\"Avatar uploaden\"],\"jW5Uwh\":[\"Bepaal hoeveel externe media worden geladen. Uit / Veilig / Vertrouwde bronnen / Alle inhoud.\"],\"jXzms5\":[\"Bijlageopties\"],\"jZlrte\":[\"Kleur\"],\"jfC/xh\":[\"Contact\"],\"jywMpv\":[\"#nieuwe-kanaalnaam\"],\"k112DD\":[\"Oudere berichten laden\"],\"k3ID0F\":[\"Leden filteren…\"],\"k65gsE\":[\"Diepgaande analyse\"],\"k7Zgob\":[\"Verbinding annuleren\"],\"kAVx5h\":[\"Geen uitnodigingen gevonden\"],\"kCLEPU\":[\"Verbonden met\"],\"kF5LKb\":[\"Genegeerde patronen:\"],\"kGeOx/\":[\"Deelnemen aan \",[\"0\"]],\"kITKr8\":[\"Kanaalmodi laden...\"],\"kPpPsw\":[\"Je bent een IRC Operator\"],\"kWJmRL\":[\"Jij\"],\"kfcRb0\":[\"Avatar\"],\"kjMqSj\":[\"Kopieer JSON\"],\"krViRy\":[\"Klik om te kopiëren als JSON\"],\"ks71ra\":[\"Uitzonderingen\"],\"kw4lRv\":[\"Kanaal half-operator\"],\"kxgIRq\":[\"Selecteer of voeg een kanaal toe om te beginnen.\"],\"ky6dWe\":[\"Avatarvoorbeeldweergave\"],\"l+GxCv\":[\"Kanalen laden...\"],\"l+IUVW\":[\"Accountverificatie voor \",[\"account\"],\" geslaagd: \",[\"message\"]],\"l/siQz\":[[\"reconnectCount\",\"plural\",{\"one\":[\"opnieuw verbonden\"],\"other\":[[\"reconnectCount\"],\" keer opnieuw verbonden\"]}]],\"l5jmzx\":[[\"0\"],\" en \",[\"1\"],\" zijn aan het typen...\"],\"lHy8N5\":[\"Meer kanalen laden...\"],\"lbpf14\":[\"Deelnemen aan \",[\"value\"]],\"lfFsZ4\":[\"Kanalen\"],\"lkNdiH\":[\"Accountnaam\"],\"ln500L\":[\"ObsidianIRC\"],\"lnCMdg\":[\"Afbeelding uploaden\"],\"loQxaJ\":[\"Ik ben terug\"],\"lvfaxv\":[\"START\"],\"m0oxpP\":[\"Libera Chat\"],\"m16xKo\":[\"Toevoegen\"],\"m8flAk\":[\"Voorbeeld (nog niet geüpload)\"],\"mEPxTp\":[\"<0>⚠️ Wees voorzichtig! Open alleen links van vertrouwde bronnen. Kwaadaardige links kunnen je beveiliging of privacy in gevaar brengen.\"],\"mHGdhG\":[\"Serverinformatie\"],\"mHS8lb\":[\"Bericht in #\",[\"0\"]],\"mMYBD9\":[\"Breed — Ruimere beveiligingsscope\"],\"mTGsPd\":[\"Kanaalonderwerp\"],\"mU8j6O\":[\"Geen externe berichten (+n)\"],\"mZp8FL\":[\"Automatisch terugvallen op één regel\"],\"mdQu8G\":[\"JouwNickname\"],\"miSSBQ\":[\"Reacties (\",[\"commentCount\"],\")\"],\"mvyLSy\":[\"Gebruiker is geverifieerd\"],\"mwtcGl\":[\"Reacties sluiten\"],\"myL0MR\":[\"Dit netwerk verwijderen?\"],\"mzI/c+\":[\"Downloaden\"],\"n3fGRk\":[\"ingesteld door \",[\"0\"]],\"nE9jsU\":[\"Ontspannen — Minder agressieve beveiliging\"],\"nNflMD\":[\"Kanaal verlaten\"],\"nPXkBi\":[\"WHOIS-gegevens laden...\"],\"nQnxxF\":[\"Bericht in #\",[\"0\"],\" (Shift+Enter voor nieuwe regel)\"],\"nWMRxa\":[\"Losmaken\"],\"nkC032\":[\"Geen floodprofiel\"],\"o69z4d\":[\"Een waarschuwingsbericht sturen naar \",[\"username\"]],\"o9ylQi\":[\"Zoek naar GIF's om te beginnen\"],\"oFGkER\":[\"Serverberichten\"],\"oOi11l\":[\"Naar beneden scrollen\"],\"oQEzQR\":[\"Nieuw DM\"],\"oXOSPE\":[\"Online\"],\"oal760\":[\"Man-in-the-middle-aanvallen op serververbindingen zijn mogelijk\"],\"oeqmmJ\":[\"Vertrouwde bronnen\"],\"ovBPCi\":[\"Standaard\"],\"p0Z69r\":[\"Patroon kan niet leeg zijn\"],\"p1KgtK\":[\"Audio laden mislukt\"],\"p59pEv\":[\"Extra details\"],\"p7sRI6\":[\"Laat anderen weten wanneer je typt\"],\"pBm1od\":[\"Geheim kanaal\"],\"pNmiXx\":[\"Je standaard nickname voor alle servers\"],\"pUUo9G\":[\"Hostnaam:\"],\"pVGPmz\":[\"Accountwachtwoord\"],\"peNE68\":[\"Permanent\"],\"plhHQt\":[\"Geen gegevens\"],\"pm6+q5\":[\"Beveiligingswaarschuwing\"],\"pn5qSs\":[\"Aanvullende informatie\"],\"q0cR4S\":[\"is nu bekend als **\",[\"newNick\"],\"**\"],\"qFcunY\":[\"Kanaal verschijnt niet in LIST- of NAMES-opdrachten\"],\"qLpTm/\":[\"Reactie \",[\"emoji\"],\" verwijderen\"],\"qVkGWK\":[\"Vastmaken\"],\"qY8wNa\":[\"Startpagina\"],\"qb0xJ7\":[\"Gebruik jokertekens: * komt overeen met een reeks, ? met één teken. Voorbeelden: nick!*@*, *!*@host.com, *!*gebruiker@*\"],\"qhzpRq\":[\"Kanaalsleutel (+k)\"],\"qtoOYG\":[\"Geen limiet\"],\"r1W2AS\":[\"Afbeelding van bestandshost\"],\"rIPR2O\":[\"Onderwerp ingesteld voor (min geleden)\"],\"rMMSYo\":[\"Maximale lengte is \",[\"0\"]],\"rWtzQe\":[\"Het netwerk splitste en herverbond. ✅\"],\"rYG2u6\":[\"Even wachten...\"],\"rdUucN\":[\"Voorbeeld\"],\"rjGI/Q\":[\"Privacy\"],\"rk8iDX\":[\"GIF's laden...\"],\"rn6SBY\":[\"Dempen opheffen\"],\"s/UKqq\":[\"Uit het kanaal verwijderd\"],\"s8cATI\":[\"heeft \",[\"channelName\"],\" betreden\"],\"sCO9ue\":[\"De verbinding met <0>\",[\"serverName\"],\" heeft de volgende beveiligingsproblemen:\"],\"sGH11W\":[\"Server\"],\"sHI1H+\":[\"is nu bekend als **\",[\"newNick\"],\"**\"],\"sJyV04\":[[\"inviter\"],\" heeft je uitgenodigd om deel te nemen aan \",[\"channel\"]],\"sUBSbK\":[\"Nog geen upstream-netwerken.\"],\"sby+1/\":[\"Klik om te kopiëren\"],\"sfN25C\":[\"Je echte of volledige naam\"],\"sliuzR\":[\"Link openen\"],\"sqrO9R\":[\"Aangepaste vermeldingen\"],\"sr6RdJ\":[\"Meerdere regels met Shift+Enter\"],\"swrCpB\":[\"Het kanaal is hernoemd van \",[\"oldName\"],\" naar \",[\"newName\"],\" door \",[\"user\"],[\"0\"]],\"sxkWRg\":[\"Geavanceerd\"],\"t/YqKh\":[\"Verwijderen\"],\"t47eHD\":[\"Je unieke identificatie op deze server\"],\"tAkAh0\":[\"URL met optionele \",[\"size\"],\"-vervanging voor dynamische grootte. Voorbeeld: https://voorbeeld.com/avatar/\",[\"size\"],\"/kanaal.jpg\"],\"tXLJS3\":[\"Zijbalk met kanaallijst weergeven of verbergen\"],\"tfDRzk\":[\"Opslaan\"],\"tiBsJk\":[\"heeft \",[\"channelName\"],\" verlaten\"],\"tt4/UD\":[\"verliet de server (\",[\"reason\"],\")\"],\"u0TcnO\":[\"Nickname {nick} is al in gebruik, probeer opnieuw met {newNick}\"],\"u0a8B4\":[\"Authenticeren als IRC Operator voor beheerderstoegang\"],\"u0rWFU\":[\"Aangemaakt na (min geleden)\"],\"u72w3t\":[\"Gebruikers en patronen om te negeren\"],\"u7jc2L\":[\"verliet de server\"],\"uAQUqI\":[\"Status\"],\"uB85T3\":[\"Opslaan mislukt: \",[\"msg\"]],\"uV3DOL\":[\"G-Line\"],\"uW3lLI\":[\"IRC-servers:\"],\"usSSr/\":[\"Zoomniveau\"],\"v7uvcf\":[\"Software:\"],\"vE8kb+\":[\"Gebruik Shift+Enter voor nieuwe regels (Enter verstuurt)\"],\"vERlcd\":[\"Profiel\"],\"vK0RL8\":[\"Geen onderwerp\"],\"vSJd18\":[\"Video\"],\"vXIe7J\":[\"Taal\"],\"vaHYxN\":[\"Echte naam\"],\"vhjbKr\":[\"Afwezig\"],\"w/nogd\":[[\"0\"],\" network\",[\"1\"],\" — pick one to join\"],\"w4NYox\":[[\"title\"],\" client\"],\"w8xQRx\":[\"Ongeldige waarde\"],\"wFjjxZ\":[\"werd gekickt uit \",[\"channelName\"],\" door \",[\"username\"],\" (\",[\"reason\"],\")\"],\"wGjaGl\":[\"Geen uitzonderingen op bannen gevonden\"],\"wPrGnM\":[\"Kanaalbeheerder\"],\"wRkP2d\":[\"GIF\"],\"wbm86v\":[\"Weergeven wanneer gebruikers kanalen betreden of verlaten\"],\"whqZ9r\":[\"Extra woorden of zinnen om te markeren\"],\"wm7RV4\":[\"Meldingsgeluid\"],\"wz/Yoq\":[\"Je berichten kunnen worden onderschept wanneer ze tussen servers worden doorgegeven\"],\"xCJdfg\":[\"Wissen\"],\"xUHRTR\":[\"Automatisch als operator authenticeren bij verbinden\"],\"xWHwwQ\":[\"Bannen\"],\"xYilR2\":[\"Media\"],\"xceQrO\":[\"Alleen beveiligde WebSockets worden ondersteund\"],\"xdtXa+\":[\"kanaalnaam\"],\"xfXC7q\":[\"Tekstkanalen\"],\"xlCYOE\":[\"Meer berichten ophalen...\"],\"xlhswE\":[\"Minimale waarde is \",[\"0\"]],\"xq97Ci\":[\"Voeg een woord of zin toe...\"],\"xuRqRq\":[\"Clientlimiet (+l)\"],\"xwF+7J\":[[\"0\"],\" typt...\"],\"yJztBY\":[\"Netwerk verwijderen\"],\"yNeucF\":[\"Deze server ondersteunt geen uitgebreide profielmetadata (IRCv3 METADATA-extensie). Extra velden zoals avatar, weergavenaam en status zijn niet beschikbaar.\"],\"yPlrca\":[\"Kanaalavatar\"],\"yQE2r9\":[\"Laden\"],\"ySU+JY\":[\"jouw@email.com\"],\"yTX1Rt\":[\"Oper-gebruikersnaam\"],\"yYOzWD\":[\"logboeken\"],\"yfx9Re\":[\"IRC operator-wachtwoord\"],\"ygCKqB\":[\"Stoppen\"],\"ymDxJx\":[\"IRC operator-gebruikersnaam\"],\"yrpRsQ\":[\"Sorteren op naam\"],\"yz7wBu\":[\"Sluiten\"],\"zJw+jA\":[\"stelt modus in: \",[\"0\"]],\"zebeLu\":[\"Oper-gebruikersnaam invoeren\"],\"zpr0Bw\":[\"GZ-Line\"]}"); \ No newline at end of file diff --git a/src/locales/nl/messages.po b/src/locales/nl/messages.po index d53dce71..dff1ddb1 100644 --- a/src/locales/nl/messages.po +++ b/src/locales/nl/messages.po @@ -22,6 +22,16 @@ msgstr "ObsidianIRC - IRC naar de toekomst brengen" msgid "— open in viewer" msgstr "— openen in viewer" +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx +msgid "(inherit)" +msgstr "(overerven)" + +#: src/components/ui/BouncerNetworkForm.tsx +msgid "(unchanged)" +msgstr "(ongewijzigd)" + #. placeholder {0}: filteredMessages.length #. placeholder {1}: import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import type * as React from "react"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { SCROLL_TOLERANCE, useScrollToBottom, } from "../../hooks/useScrollToBottom"; import { groupConsecutiveEvents } from "../../lib/eventGrouping"; import ircClient from "../../lib/ircClient"; import useStore from "../../store"; import type { Message as MessageType } from "../../types"; import { CollapsedEventMessage } from "../message/CollapsedEventMessage"; import { MessageItem } from "../message/MessageItem"; import LoadingSpinner from "../ui/LoadingSpinner"; import { ScrollToBottomButton } from "../ui/ScrollToBottomButton"; export const DEFAULT_VISIBLE_MESSAGE_COUNT = 100; // Stable empty array — prevents selector from returning a new [] on every render // when the channel has no messages yet (undefined ?? [] would create a new ref each time). const EMPTY_MESSAGES: import("../../types").Message[] = []; export interface ChannelMessageListHandle { setAtBottom: () => void; scrollToBottom: () => void; getScrollState: () => { scrollTop: number; isAtBottom: boolean; visibleCount: number; }; } interface ChannelMessageListProps { channelKey: string; serverId: string; channelId: string | null; privateChatId: string | null; isActive: boolean; searchQuery: string; isMemberListVisible: boolean; onReply: (msg: MessageType | null) => void; onUsernameContextMenu: ( e: React.MouseEvent, username: string, serverId: string, channelId: string, avatarEl?: Element | null, ) => void; onIrcLinkClick: (url: string) => void; onReactClick: (msg: MessageType, el: Element) => void; onReactionUnreact: (emoji: string, msg: MessageType) => void; onOpenReactionModal: ( msg: MessageType, position: { x: number; y: number }, ) => void; onDirectReaction: (emoji: string, msg: MessageType) => void; onRedactMessage: (msg: MessageType) => void; onOpenProfile: (username: string) => void; joinChannel: (serverId: string, channelName: string) => void; onClearSearch: () => void; highlightedMessageId?: string; // undefined = first visit; null = was at bottom; object = restore to saved position initialScrollState?: { scrollTop: number; visibleCount: number } | null; } export const ChannelMessageList = forwardRef< ChannelMessageListHandle, ChannelMessageListProps >( ( { channelKey, serverId, channelId, privateChatId, isActive, searchQuery, isMemberListVisible, onReply, onUsernameContextMenu, onIrcLinkClick, onReactClick, onReactionUnreact, onOpenReactionModal, onDirectReaction, onRedactMessage, onOpenProfile, joinChannel, onClearSearch, highlightedMessageId, initialScrollState, }, ref, ) => { const { t } = useLingui(); const [visibleMessageCount, setVisibleMessageCount] = useState( initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT, ); // Ref mirror so getScrollState closure always reads the current value without needing it as a dep. const visibleMessageCountRef = useRef(visibleMessageCount); visibleMessageCountRef.current = visibleMessageCount; // Distinguishes initial join (full-screen spinner) from subsequent "load more" (button spinner). const [isFetchingMore, setIsFetchingMore] = useState(false); const isFetchingMoreRef = useRef(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const messagesInnerRef = useRef(null); // prev scrollHeight for prepend delta-correction. const prevScrollHeightRef = useRef(0); // Ref mirror of isScrolledUp — lets useLayoutEffect closures read current value // without listing isScrolledUp as a dep (which would re-run effects on every scroll). const isScrolledUpRef = useRef(false); const prevFilteredLengthRef = useRef(0); const prevFirstMsgIdRef = useRef(null); // Set by the window-growth layoutEffect (or button handler) when a true prepend is detected. // Consumed by the delta-correction layoutEffect one render later (after visibleCount grows). // Using a flag instead of tracking displayedMessages[0]?.id because slice(-N) slides the // window on every bottom append, changing displayedMessages[0] even for non-prepend renders. const pendingPrependRef = useRef(false); // Shared scrollHeight baseline between the delta-correction layout effect and the inner // ResizeObserver. When scrollTop is corrected after a prepend, we update this so the RO's // "was at bottom" check is not fooled by the adjusted scrollTop vs its stale prevSH. const resizeObserverPrevSHRef = useRef(0); const channelMessages = useStore( useCallback( (state) => state.messages[channelKey] ?? EMPTY_MESSAGES, [channelKey], ), ); const servers = useStore((state) => state.servers); const mobileViewActiveColumn = useStore( (state) => state.ui.mobileViewActiveColumn, ); const channel = useMemo( () => channelId ? (servers .find((s) => s.id === serverId) ?.channels.find((c) => c.id === channelId) ?? null) : null, [servers, serverId, channelId], ); const { isScrolledUp, wasAtBottomRef, scrollToBottom } = useScrollToBottom( messagesContainerRef, messagesEndRef, { channelId: `${channelId || privateChatId}-${isMemberListVisible}` }, ); // Snapshot of the last known scroll position captured while the container was visible. // getScrollState() reads this instead of the live DOM because React commits display:none // before running cleanup effects, collapsing scrollTop/scrollHeight/clientHeight to 0. const lastScrollTopRef = useRef(initialScrollState?.scrollTop ?? 0); useEffect(() => { const container = messagesContainerRef.current; if (!container) return; const onScroll = () => { if (container.clientHeight > 0) lastScrollTopRef.current = container.scrollTop; }; container.addEventListener("scroll", onScroll, { passive: true }); return () => container.removeEventListener("scroll", onScroll); }, []); // Restore scroll position when a keep-alive channel transitions from hidden to visible. // display:none may reset scrollTop to 0; lastScrollTopRef was captured while visible. const prevActiveRef = useRef(isActive); useLayoutEffect(() => { if (isActive && !prevActiveRef.current) { const container = messagesContainerRef.current; if (container && lastScrollTopRef.current > 0) { container.scrollTop = lastScrollTopRef.current; } } prevActiveRef.current = isActive; }, [isActive]); useImperativeHandle(ref, () => ({ setAtBottom: () => { wasAtBottomRef.current = true; }, scrollToBottom, getScrollState: () => ({ scrollTop: lastScrollTopRef.current, isAtBottom: wasAtBottomRef.current, visibleCount: visibleMessageCountRef.current, }), })); const filteredMessages = useMemo(() => { if (!searchQuery.trim()) return channelMessages; const query = searchQuery.toLowerCase(); return channelMessages.filter( (msg) => msg.content.toLowerCase().includes(query) || msg.userId.toLowerCase().includes(query), ); }, [channelMessages, searchQuery]); useEffect(() => { isScrolledUpRef.current = isScrolledUp; // When the user returns to the bottom, shrink the window back to the base so // slice(-N) resumes trimming old messages from the top (memory optimization). // Only shrink if we grew above the base — preserves a sub-default saved visibleCount. if (!isScrolledUp) { setVisibleMessageCount((prev) => prev > DEFAULT_VISIBLE_MESSAGE_COUNT ? DEFAULT_VISIBLE_MESSAGE_COUNT : prev, ); } }, [isScrolledUp]); // Reset ref-tracked windowing state when switching channels. // visibleMessageCount is NOT reset here — useState(initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT) // already initializes it correctly on mount, and this effect runs once on mount for the // same channelKey (each instance is bound to exactly one channel by the parent key={}). // biome-ignore lint/correctness/useExhaustiveDependencies: intentional full reset on channel change useEffect(() => { prevFilteredLengthRef.current = 0; prevFirstMsgIdRef.current = null; prevScrollHeightRef.current = 0; pendingPrependRef.current = false; resizeObserverPrevSHRef.current = 0; }, [channelKey]); const displayedMessages = useMemo(() => { if (searchQuery.trim()) return filteredMessages; return filteredMessages.slice(-visibleMessageCount); }, [filteredMessages, visibleMessageCount, searchQuery]); const locallyHidden = filteredMessages.length > displayedMessages.length; const serverHasMore = channel?.hasMoreHistory === true; const hasMoreMessages = locallyHidden || serverHasMore; const eventGroups = useMemo( () => groupConsecutiveEvents(displayedMessages), [displayedMessages], ); const isLoadingHistory = channel?.isLoadingHistory ?? false; // Scroll to bottom on initial mount, unless a saved position was passed in. // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount only useEffect(() => { const container = messagesContainerRef.current; if (!container) return; if (initialScrollState) { container.scrollTop = initialScrollState.scrollTop; lastScrollTopRef.current = initialScrollState.scrollTop; wasAtBottomRef.current = false; } else { container.scrollTop = container.scrollHeight; lastScrollTopRef.current = container.scrollHeight; wasAtBottomRef.current = true; } }, []); // Scroll to bottom after initial join history loads; clear fetch spinner at batch end. const wasLoadingHistoryRef = useRef(false); // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is stable via useCallback; refs and setters are stable useLayoutEffect(() => { if (wasLoadingHistoryRef.current && !isLoadingHistory) { if (isFetchingMoreRef.current) { // delta correction for scroll position is handled by useLayoutEffect([displayedMessages]) isFetchingMoreRef.current = false; setIsFetchingMore(false); } else { scrollToBottom(); wasAtBottomRef.current = true; } } wasLoadingHistoryRef.current = isLoadingHistory; }, [isLoadingHistory]); // When older messages are prepended, grow the window so they enter displayedMessages. // When new messages arrive at the bottom while the user is scrolled up, also grow the // window to keep the current top messages visible — slice(-N) otherwise slides the // window forward and hides them, incrementing the "N older messages" counter on every // incoming message. Only let the slice trim from the top when the user is at the bottom // (where auto-scroll handles keeping them current). useLayoutEffect(() => { const newLength = filteredMessages.length; const newFirstId = filteredMessages[0]?.id ?? null; const delta = newLength - prevFilteredLengthRef.current; if (prevFilteredLengthRef.current > 0 && delta > 0) { if (newFirstId !== prevFirstMsgIdRef.current) { // Messages prepended (load-more): signal delta-correction to compensate scrollTop. pendingPrependRef.current = true; setVisibleMessageCount((prev) => prev + delta); } else if (isScrolledUpRef.current) { // Messages appended at bottom while user is scrolled up reading history. // Expand the window to prevent top messages from dropping out of the slice. setVisibleMessageCount((prev) => prev + delta); } } prevFilteredLengthRef.current = newLength; prevFirstMsgIdRef.current = newFirstId; }, [filteredMessages]); // Compensate scrollTop when content is prepended above the viewport. // biome-ignore lint/correctness/useExhaustiveDependencies: runs on every displayedMessages render to capture the resulting scrollHeight; refs are stable useLayoutEffect(() => { const container = messagesContainerRef.current; if (!container) return; // Skip while container is display:none — scrollHeight collapses to 0 and would // poison prevScrollHeightRef, causing a huge spurious delta on the next visible render. if (container.clientHeight === 0) return; const prevHeight = prevScrollHeightRef.current; const newHeight = container.scrollHeight; // Only correct when a true load-more prepend happened (flag set by the window-growth // layoutEffect or button handler). Bottom appends slide the slice(-N) window which also // changes displayedMessages[0] — ID-comparison can't distinguish the two cases. const wasPrepend = pendingPrependRef.current; // Only consume the flag when scrollHeight actually changed — the server-side load-more // path goes through two renders: Render A (filteredMessages grows, visibleCount unchanged, // same displayedMessages content, same scrollHeight) then Render B (visibleCount grows, // new messages enter displayedMessages, scrollHeight grows). The flag must survive Render A // so it's still set when Render B fires the actual correction. if (wasPrepend && newHeight !== prevHeight) { pendingPrependRef.current = false; } if ( isScrolledUpRef.current && prevHeight > 0 && newHeight > prevHeight && wasPrepend ) { const delta = newHeight - prevHeight; container.scrollTop += delta; resizeObserverPrevSHRef.current = newHeight; } prevScrollHeightRef.current = newHeight; }, [displayedMessages]); // Re-stick to bottom when inner message content grows (media/audio previews loading). // Uses prevScrollHeight instead of wasAtBottomRef to avoid stale-flag race where the // ref is true while the user is actively scrolling up. // When the container width changes (member list toggle, window resize), text reflows // and scrollHeight changes; preserve proportional scroll position for scrolled-up users. // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is a stable ref useEffect(() => { const container = messagesContainerRef.current; const inner = messagesInnerRef.current; if (!inner || !container) return; resizeObserverPrevSHRef.current = container.scrollHeight; let prevClientWidth = container.clientWidth; const observer = new ResizeObserver(() => { if (container.clientHeight === 0) return; // Effect may re-initialize while container is display:none (ref=0). // Re-seed with current dimensions and skip — no reliable "was at bottom" data. if (resizeObserverPrevSHRef.current === 0) { resizeObserverPrevSHRef.current = container.scrollHeight; prevClientWidth = container.clientWidth; return; } const currentClientWidth = container.clientWidth; const widthChanged = currentClientWidth !== prevClientWidth; prevClientWidth = currentClientWidth; const prevSH = resizeObserverPrevSHRef.current; const wasAtPrevBottom = container.scrollTop + container.clientHeight >= prevSH - SCROLL_TOLERANCE; resizeObserverPrevSHRef.current = container.scrollHeight; if (wasAtPrevBottom) { scrollToBottom(); } else if (widthChanged && prevSH > 0) { const ratio = container.scrollTop / prevSH; container.scrollTop = Math.round(ratio * container.scrollHeight); } }); observer.observe(inner); return () => observer.disconnect(); }, [isLoadingHistory, channelId, privateChatId]); // Auto-scroll on new messages — skip when this channel is hidden (display:none). // biome-ignore lint/correctness/useExhaustiveDependencies: only scroll when messages change, not when isActive changes useEffect(() => { if (!isActive) return; const isNarrowView = window.matchMedia("(max-width: 768px)").matches; const isChatVisible = !isNarrowView || mobileViewActiveColumn === "chatView"; if (wasAtBottomRef.current && isChatVisible) { scrollToBottom(); } }, [displayedMessages, mobileViewActiveColumn, scrollToBottom, isActive]); return ( <>
{isLoadingHistory && !isFetchingMore ? (
) : (
{hasMoreMessages && !searchQuery && (
)} {searchQuery && (
{plural(filteredMessages.length, { one: t`Found 1 message matching "${searchQuery}"`, other: t`Found ${filteredMessages.length} messages matching "${searchQuery}"`, })}
)} {eventGroups.map((group) => { if (group.type === "eventGroup") { const firstId = group.messages[0]?.id || ""; const lastId = group.messages[group.messages.length - 1]?.id || ""; const groupKey = `group-${firstId}-${lastId}`; return ( ); } const message = group.messages[0]; const originalIndex = channelMessages.findIndex( (m) => m.id === message.id, ); const previousMessage = channelMessages[originalIndex - 1]; const showHeader = !previousMessage || previousMessage.type !== "message" || previousMessage.userId !== message.userId || new Date(message.timestamp).getTime() - new Date(previousMessage.timestamp).getTime() > 5 * 60 * 1000; return ( ); })}
)}
); }, ); ChannelMessageList.displayName = "ChannelMessageList"; // Wrap with memo so hidden keep-alive channels skip re-renders when their props // haven't changed (e.g. when messageText changes in the input — the only thing // that changes on typing is local state inside ChatArea, not the props we pass here). export const MemoChannelMessageList = memo(ChannelMessageList); #. placeholder {2}: import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import type * as React from "react"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { SCROLL_TOLERANCE, useScrollToBottom, } from "../../hooks/useScrollToBottom"; import { groupConsecutiveEvents } from "../../lib/eventGrouping"; import ircClient from "../../lib/ircClient"; import useStore from "../../store"; import type { Message as MessageType } from "../../types"; import { CollapsedEventMessage } from "../message/CollapsedEventMessage"; import { MessageItem } from "../message/MessageItem"; import LoadingSpinner from "../ui/LoadingSpinner"; import { ScrollToBottomButton } from "../ui/ScrollToBottomButton"; export const DEFAULT_VISIBLE_MESSAGE_COUNT = 100; // Stable empty array — prevents selector from returning a new [] on every render // when the channel has no messages yet (undefined ?? [] would create a new ref each time). const EMPTY_MESSAGES: import("../../types").Message[] = []; export interface ChannelMessageListHandle { setAtBottom: () => void; scrollToBottom: () => void; getScrollState: () => { scrollTop: number; isAtBottom: boolean; visibleCount: number; }; } interface ChannelMessageListProps { channelKey: string; serverId: string; channelId: string | null; privateChatId: string | null; isActive: boolean; searchQuery: string; isMemberListVisible: boolean; onReply: (msg: MessageType | null) => void; onUsernameContextMenu: ( e: React.MouseEvent, username: string, serverId: string, channelId: string, avatarEl?: Element | null, ) => void; onIrcLinkClick: (url: string) => void; onReactClick: (msg: MessageType, el: Element) => void; onReactionUnreact: (emoji: string, msg: MessageType) => void; onOpenReactionModal: ( msg: MessageType, position: { x: number; y: number }, ) => void; onDirectReaction: (emoji: string, msg: MessageType) => void; onRedactMessage: (msg: MessageType) => void; onOpenProfile: (username: string) => void; joinChannel: (serverId: string, channelName: string) => void; onClearSearch: () => void; highlightedMessageId?: string; // undefined = first visit; null = was at bottom; object = restore to saved position initialScrollState?: { scrollTop: number; visibleCount: number } | null; } export const ChannelMessageList = forwardRef< ChannelMessageListHandle, ChannelMessageListProps >( ( { channelKey, serverId, channelId, privateChatId, isActive, searchQuery, isMemberListVisible, onReply, onUsernameContextMenu, onIrcLinkClick, onReactClick, onReactionUnreact, onOpenReactionModal, onDirectReaction, onRedactMessage, onOpenProfile, joinChannel, onClearSearch, highlightedMessageId, initialScrollState, }, ref, ) => { const { t } = useLingui(); const [visibleMessageCount, setVisibleMessageCount] = useState( initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT, ); // Ref mirror so getScrollState closure always reads the current value without needing it as a dep. const visibleMessageCountRef = useRef(visibleMessageCount); visibleMessageCountRef.current = visibleMessageCount; // Distinguishes initial join (full-screen spinner) from subsequent "load more" (button spinner). const [isFetchingMore, setIsFetchingMore] = useState(false); const isFetchingMoreRef = useRef(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const messagesInnerRef = useRef(null); // prev scrollHeight for prepend delta-correction. const prevScrollHeightRef = useRef(0); // Ref mirror of isScrolledUp — lets useLayoutEffect closures read current value // without listing isScrolledUp as a dep (which would re-run effects on every scroll). const isScrolledUpRef = useRef(false); const prevFilteredLengthRef = useRef(0); const prevFirstMsgIdRef = useRef(null); // Set by the window-growth layoutEffect (or button handler) when a true prepend is detected. // Consumed by the delta-correction layoutEffect one render later (after visibleCount grows). // Using a flag instead of tracking displayedMessages[0]?.id because slice(-N) slides the // window on every bottom append, changing displayedMessages[0] even for non-prepend renders. const pendingPrependRef = useRef(false); // Shared scrollHeight baseline between the delta-correction layout effect and the inner // ResizeObserver. When scrollTop is corrected after a prepend, we update this so the RO's // "was at bottom" check is not fooled by the adjusted scrollTop vs its stale prevSH. const resizeObserverPrevSHRef = useRef(0); const channelMessages = useStore( useCallback( (state) => state.messages[channelKey] ?? EMPTY_MESSAGES, [channelKey], ), ); const servers = useStore((state) => state.servers); const mobileViewActiveColumn = useStore( (state) => state.ui.mobileViewActiveColumn, ); const channel = useMemo( () => channelId ? (servers .find((s) => s.id === serverId) ?.channels.find((c) => c.id === channelId) ?? null) : null, [servers, serverId, channelId], ); const { isScrolledUp, wasAtBottomRef, scrollToBottom } = useScrollToBottom( messagesContainerRef, messagesEndRef, { channelId: `${channelId || privateChatId}-${isMemberListVisible}` }, ); // Snapshot of the last known scroll position captured while the container was visible. // getScrollState() reads this instead of the live DOM because React commits display:none // before running cleanup effects, collapsing scrollTop/scrollHeight/clientHeight to 0. const lastScrollTopRef = useRef(initialScrollState?.scrollTop ?? 0); useEffect(() => { const container = messagesContainerRef.current; if (!container) return; const onScroll = () => { if (container.clientHeight > 0) lastScrollTopRef.current = container.scrollTop; }; container.addEventListener("scroll", onScroll, { passive: true }); return () => container.removeEventListener("scroll", onScroll); }, []); // Restore scroll position when a keep-alive channel transitions from hidden to visible. // display:none may reset scrollTop to 0; lastScrollTopRef was captured while visible. const prevActiveRef = useRef(isActive); useLayoutEffect(() => { if (isActive && !prevActiveRef.current) { const container = messagesContainerRef.current; if (container && lastScrollTopRef.current > 0) { container.scrollTop = lastScrollTopRef.current; } } prevActiveRef.current = isActive; }, [isActive]); useImperativeHandle(ref, () => ({ setAtBottom: () => { wasAtBottomRef.current = true; }, scrollToBottom, getScrollState: () => ({ scrollTop: lastScrollTopRef.current, isAtBottom: wasAtBottomRef.current, visibleCount: visibleMessageCountRef.current, }), })); const filteredMessages = useMemo(() => { if (!searchQuery.trim()) return channelMessages; const query = searchQuery.toLowerCase(); return channelMessages.filter( (msg) => msg.content.toLowerCase().includes(query) || msg.userId.toLowerCase().includes(query), ); }, [channelMessages, searchQuery]); useEffect(() => { isScrolledUpRef.current = isScrolledUp; // When the user returns to the bottom, shrink the window back to the base so // slice(-N) resumes trimming old messages from the top (memory optimization). // Only shrink if we grew above the base — preserves a sub-default saved visibleCount. if (!isScrolledUp) { setVisibleMessageCount((prev) => prev > DEFAULT_VISIBLE_MESSAGE_COUNT ? DEFAULT_VISIBLE_MESSAGE_COUNT : prev, ); } }, [isScrolledUp]); // Reset ref-tracked windowing state when switching channels. // visibleMessageCount is NOT reset here — useState(initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT) // already initializes it correctly on mount, and this effect runs once on mount for the // same channelKey (each instance is bound to exactly one channel by the parent key={}). // biome-ignore lint/correctness/useExhaustiveDependencies: intentional full reset on channel change useEffect(() => { prevFilteredLengthRef.current = 0; prevFirstMsgIdRef.current = null; prevScrollHeightRef.current = 0; pendingPrependRef.current = false; resizeObserverPrevSHRef.current = 0; }, [channelKey]); const displayedMessages = useMemo(() => { if (searchQuery.trim()) return filteredMessages; return filteredMessages.slice(-visibleMessageCount); }, [filteredMessages, visibleMessageCount, searchQuery]); const locallyHidden = filteredMessages.length > displayedMessages.length; const serverHasMore = channel?.hasMoreHistory === true; const hasMoreMessages = locallyHidden || serverHasMore; const eventGroups = useMemo( () => groupConsecutiveEvents(displayedMessages), [displayedMessages], ); const isLoadingHistory = channel?.isLoadingHistory ?? false; // Scroll to bottom on initial mount, unless a saved position was passed in. // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount only useEffect(() => { const container = messagesContainerRef.current; if (!container) return; if (initialScrollState) { container.scrollTop = initialScrollState.scrollTop; lastScrollTopRef.current = initialScrollState.scrollTop; wasAtBottomRef.current = false; } else { container.scrollTop = container.scrollHeight; lastScrollTopRef.current = container.scrollHeight; wasAtBottomRef.current = true; } }, []); // Scroll to bottom after initial join history loads; clear fetch spinner at batch end. const wasLoadingHistoryRef = useRef(false); // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is stable via useCallback; refs and setters are stable useLayoutEffect(() => { if (wasLoadingHistoryRef.current && !isLoadingHistory) { if (isFetchingMoreRef.current) { // delta correction for scroll position is handled by useLayoutEffect([displayedMessages]) isFetchingMoreRef.current = false; setIsFetchingMore(false); } else { scrollToBottom(); wasAtBottomRef.current = true; } } wasLoadingHistoryRef.current = isLoadingHistory; }, [isLoadingHistory]); // When older messages are prepended, grow the window so they enter displayedMessages. // When new messages arrive at the bottom while the user is scrolled up, also grow the // window to keep the current top messages visible — slice(-N) otherwise slides the // window forward and hides them, incrementing the "N older messages" counter on every // incoming message. Only let the slice trim from the top when the user is at the bottom // (where auto-scroll handles keeping them current). useLayoutEffect(() => { const newLength = filteredMessages.length; const newFirstId = filteredMessages[0]?.id ?? null; const delta = newLength - prevFilteredLengthRef.current; if (prevFilteredLengthRef.current > 0 && delta > 0) { if (newFirstId !== prevFirstMsgIdRef.current) { // Messages prepended (load-more): signal delta-correction to compensate scrollTop. pendingPrependRef.current = true; setVisibleMessageCount((prev) => prev + delta); } else if (isScrolledUpRef.current) { // Messages appended at bottom while user is scrolled up reading history. // Expand the window to prevent top messages from dropping out of the slice. setVisibleMessageCount((prev) => prev + delta); } } prevFilteredLengthRef.current = newLength; prevFirstMsgIdRef.current = newFirstId; }, [filteredMessages]); // Compensate scrollTop when content is prepended above the viewport. // biome-ignore lint/correctness/useExhaustiveDependencies: runs on every displayedMessages render to capture the resulting scrollHeight; refs are stable useLayoutEffect(() => { const container = messagesContainerRef.current; if (!container) return; // Skip while container is display:none — scrollHeight collapses to 0 and would // poison prevScrollHeightRef, causing a huge spurious delta on the next visible render. if (container.clientHeight === 0) return; const prevHeight = prevScrollHeightRef.current; const newHeight = container.scrollHeight; // Only correct when a true load-more prepend happened (flag set by the window-growth // layoutEffect or button handler). Bottom appends slide the slice(-N) window which also // changes displayedMessages[0] — ID-comparison can't distinguish the two cases. const wasPrepend = pendingPrependRef.current; // Only consume the flag when scrollHeight actually changed — the server-side load-more // path goes through two renders: Render A (filteredMessages grows, visibleCount unchanged, // same displayedMessages content, same scrollHeight) then Render B (visibleCount grows, // new messages enter displayedMessages, scrollHeight grows). The flag must survive Render A // so it's still set when Render B fires the actual correction. if (wasPrepend && newHeight !== prevHeight) { pendingPrependRef.current = false; } if ( isScrolledUpRef.current && prevHeight > 0 && newHeight > prevHeight && wasPrepend ) { const delta = newHeight - prevHeight; container.scrollTop += delta; resizeObserverPrevSHRef.current = newHeight; } prevScrollHeightRef.current = newHeight; }, [displayedMessages]); // Re-stick to bottom when inner message content grows (media/audio previews loading). // Uses prevScrollHeight instead of wasAtBottomRef to avoid stale-flag race where the // ref is true while the user is actively scrolling up. // When the container width changes (member list toggle, window resize), text reflows // and scrollHeight changes; preserve proportional scroll position for scrolled-up users. // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is a stable ref useEffect(() => { const container = messagesContainerRef.current; const inner = messagesInnerRef.current; if (!inner || !container) return; resizeObserverPrevSHRef.current = container.scrollHeight; let prevClientWidth = container.clientWidth; const observer = new ResizeObserver(() => { if (container.clientHeight === 0) return; // Effect may re-initialize while container is display:none (ref=0). // Re-seed with current dimensions and skip — no reliable "was at bottom" data. if (resizeObserverPrevSHRef.current === 0) { resizeObserverPrevSHRef.current = container.scrollHeight; prevClientWidth = container.clientWidth; return; } const currentClientWidth = container.clientWidth; const widthChanged = currentClientWidth !== prevClientWidth; prevClientWidth = currentClientWidth; const prevSH = resizeObserverPrevSHRef.current; const wasAtPrevBottom = container.scrollTop + container.clientHeight >= prevSH - SCROLL_TOLERANCE; resizeObserverPrevSHRef.current = container.scrollHeight; if (wasAtPrevBottom) { scrollToBottom(); } else if (widthChanged && prevSH > 0) { const ratio = container.scrollTop / prevSH; container.scrollTop = Math.round(ratio * container.scrollHeight); } }); observer.observe(inner); return () => observer.disconnect(); }, [isLoadingHistory, channelId, privateChatId]); // Auto-scroll on new messages — skip when this channel is hidden (display:none). // biome-ignore lint/correctness/useExhaustiveDependencies: only scroll when messages change, not when isActive changes useEffect(() => { if (!isActive) return; const isNarrowView = window.matchMedia("(max-width: 768px)").matches; const isChatVisible = !isNarrowView || mobileViewActiveColumn === "chatView"; if (wasAtBottomRef.current && isChatVisible) { scrollToBottom(); } }, [displayedMessages, mobileViewActiveColumn, scrollToBottom, isActive]); return ( <>
{isLoadingHistory && !isFetchingMore ? (
) : (
{hasMoreMessages && !searchQuery && (
)} {searchQuery && (
{plural(filteredMessages.length, { one: t`Found 1 message matching "${searchQuery}"`, other: t`Found ${filteredMessages.length} messages matching "${searchQuery}"`, })}
)} {eventGroups.map((group) => { if (group.type === "eventGroup") { const firstId = group.messages[0]?.id || ""; const lastId = group.messages[group.messages.length - 1]?.id || ""; const groupKey = `group-${firstId}-${lastId}`; return ( ); } const message = group.messages[0]; const originalIndex = channelMessages.findIndex( (m) => m.id === message.id, ); const previousMessage = channelMessages[originalIndex - 1]; const showHeader = !previousMessage || previousMessage.type !== "message" || previousMessage.userId !== message.userId || new Date(message.timestamp).getTime() - new Date(previousMessage.timestamp).getTime() > 5 * 60 * 1000; return ( ); })}
)}
); }, ); ChannelMessageList.displayName = "ChannelMessageList"; // Wrap with memo so hidden keep-alive channels skip re-renders when their props // haven't changed (e.g. when messageText changes in the input — the only thing // that changes on typing is local state inside ChatArea, not the props we pass here). export const MemoChannelMessageList = memo(ChannelMessageList); @@ -46,6 +56,12 @@ msgstr "{0} en {1} zijn aan het typen..." msgid "{0} is typing..." msgstr "{0} typt..." +#. placeholder {0}: networks.length +#. placeholder {1}: networks.length === 1 ? "" : "s" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "{0} network{1} — pick one to join" +msgstr "" + #. placeholder {0}: filteredMessages.length - displayedMessages.length #: src/components/layout/ChannelMessageList.tsx msgid "{0} older messages" @@ -186,6 +202,12 @@ msgstr "Uitnodigingsmasker toevoegen (bijv. nick!*@*, *!*@host.com)" msgid "Add IRC Server" msgstr "IRC-server toevoegen" +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworksPanel.tsx +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Add Network" +msgstr "Netwerk toevoegen" + #: src/components/message/MessageActions.tsx #: src/components/message/MessageReactions.tsx #: src/components/message/MessageReactions.tsx @@ -205,6 +227,10 @@ msgstr "Regel toevoegen" msgid "Add Server" msgstr "Server toevoegen" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Add your first network" +msgstr "Voeg je eerste netwerk toe" + #: src/components/message/JsonLogMessage.tsx msgid "Additional Details" msgstr "Extra details" @@ -358,6 +384,10 @@ msgstr "Terug" msgid "Back to image" msgstr "Terug naar afbeelding" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Back to network list" +msgstr "" + #: src/components/ui/ModerationModal.tsx msgid "Ban {username} by hostmask (prevents them from rejoining from the same IP/host)" msgstr "{username} bannen via hostmasker (voorkomt dat ze opnieuw deelnemen via hetzelfde IP/host)" @@ -405,6 +435,8 @@ msgstr "Alle kanalen op de server bekijken" #: src/components/ui/AddPrivateChatModal.tsx #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/ChannelSettingsModal.tsx #: src/components/ui/ExternalLinkWarningModal.tsx #: src/components/ui/FloodSettingsModal.tsx @@ -640,6 +672,7 @@ msgid "Configure notification sounds and highlights" msgstr "Meldingsgeluiden en markeringen instellen" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworksPanel.tsx msgid "Connect" msgstr "Verbinden" @@ -759,6 +792,10 @@ msgstr "Kanaal verwijderen" msgid "Delete message" msgstr "Bericht verwijderen" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Delete network" +msgstr "Netwerk verwijderen" + #: src/components/layout/ChannelList.tsx msgid "Delete Private Chat" msgstr "Privégesprek verwijderen" @@ -767,6 +804,10 @@ msgstr "Privégesprek verwijderen" msgid "Delete this message? This cannot be undone." msgstr "Dit bericht verwijderen? Dit kan niet ongedaan worden gemaakt." +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Delete this network?" +msgstr "Dit netwerk verwijderen?" + #: src/components/layout/ServerList.tsx #: src/components/mobile/ServerBottomSheet.tsx msgid "Disconnect" @@ -830,10 +871,16 @@ msgstr "Downloaden" msgid "e.g., 100:1440" msgstr "bijv. 100:1440" +#: src/components/ui/BouncerNetworksPanel.tsx #: src/components/ui/ChannelSettingsModal.tsx msgid "Edit" msgstr "Bewerken" +#. placeholder {0}: editingNetwork?.attributes.name || editingNetwork?.netid +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Edit {0}" +msgstr "{0} bewerken" + #: src/components/ui/UserProfileModal.tsx msgid "Edit Profile" msgstr "Profiel bewerken" @@ -1057,6 +1104,7 @@ msgstr "START" msgid "Homepage" msgstr "Startpagina" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserProfileModal.tsx msgid "Host" msgstr "Host" @@ -1271,6 +1319,10 @@ msgstr "Het kanaal verlaten" msgid "Let others know when you are typing" msgstr "Laat anderen weten wanneer je typt" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Libera Chat" +msgstr "Libera Chat" + #: src/components/message/LinkPreview.tsx msgid "Link preview" msgstr "Linkvoorbeeldweergave" @@ -1299,6 +1351,10 @@ msgstr "GIF's laden..." msgid "Loading more channels..." msgstr "Meer kanalen laden..." +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Loading networks from your bouncer…" +msgstr "Netwerken laden van je bouncer…" + #: src/components/ui/UserProfileModal.tsx msgid "Loading WHOIS data..." msgstr "WHOIS-gegevens laden..." @@ -1486,9 +1542,15 @@ msgid "Name:" msgstr "Naam:" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx msgid "Network Name" msgstr "Netwerknaam" +#. placeholder {0}: server?.name ?? bouncerServerId +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Networks on {0}" +msgstr "Netwerken op {0}" + #: src/components/ui/QuickActions.tsx msgid "New DM" msgstr "Nieuw DM" @@ -1511,6 +1573,7 @@ msgid "nick!user@host (e.g., spam*!*@*, *!*@badhost.com)" msgstr "nick!gebruiker@host (bijv. spam*!*@*, *!*@slechtehost.com)" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserSettings.tsx #: src/lib/settings/definitions/allSettings.ts msgid "Nickname" @@ -1570,6 +1633,10 @@ msgstr "Geen bestand gekozen" msgid "No flood profile" msgstr "Geen floodprofiel" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "no host set" +msgstr "geen host ingesteld" + #: src/components/ui/ChannelSettingsModal.tsx msgid "No invitations found" msgstr "Geen uitnodigingen gevonden" @@ -1610,6 +1677,10 @@ msgstr "Geen onderwerp ingesteld" msgid "No unread mentions or messages" msgstr "Geen ongelezen vermeldingen of berichten" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "No upstream networks yet." +msgstr "Nog geen upstream-netwerken." + #: src/components/ui/AddPrivateChatModal.tsx msgid "No users available" msgstr "Geen gebruikers beschikbaar" @@ -1696,6 +1767,10 @@ msgstr "Oeps! Netwerksplitsing! ⚠️" msgid "Op" msgstr "Op" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Open" +msgstr "" + #: src/components/ui/QuickActions/uiActionConfig.tsx msgid "Open channel configuration settings" msgstr "Kanaelconfiguratie-instellingen openen" @@ -1799,6 +1874,10 @@ msgstr "Privégesprek vastmaken" msgid "Pin this private message conversation" msgstr "Dit privéberichtgesprek vastmaken" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Plaintext" +msgstr "Platte tekst" + #: src/components/message/MediaPreview.tsx #: src/components/message/MediaPreview.tsx #: src/components/message/MediaPreview.tsx @@ -1827,6 +1906,7 @@ msgid "PM User" msgstr "Gebruiker een PM sturen" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx msgid "Port" msgstr "Poort" @@ -1918,6 +1998,7 @@ msgstr "reageerde op dit bericht" msgid "Read more" msgstr "Meer lezen" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserProfileModal.tsx #: src/components/ui/UserSettings.tsx #: src/lib/settings/definitions/allSettings.ts @@ -2002,6 +2083,7 @@ msgstr "Regels" msgid "Safe" msgstr "Veilig" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/ChannelSettingsModal.tsx #: src/components/ui/TopicModal.tsx #: src/components/ui/UserSettings.tsx @@ -2183,6 +2265,10 @@ msgstr "Serveroperators op het netwerk kunnen mogelijk je berichten lezen" msgid "Server Password" msgstr "Serverwachtwoord" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Server Password (PASS)" +msgstr "Serverwachtwoord (PASS)" + #: src/components/ui/LinkSecurityWarningModal.tsx msgid "Server-to-server communication may use unencrypted connections" msgstr "Server-naar-servercommunicatie kan niet-versleutelde verbindingen gebruiken" @@ -2378,6 +2464,10 @@ msgstr "Tijd (min)" msgid "Time Window (seconds)" msgstr "Tijdvenster (seconden)" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "TLS" +msgstr "TLS" + #: src/components/message/WhisperMessage.tsx #: src/components/message/WhisperMessage.tsx msgid "to" @@ -2426,6 +2516,10 @@ msgstr "Onderwerp:" msgid "Total: {0}" msgstr "Totaal: {0}" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Transport" +msgstr "Transport" + #: src/components/ui/UserSettings.tsx msgid "Trusted Sources" msgstr "Vertrouwde bronnen" @@ -2536,6 +2630,7 @@ msgstr "Gebruikersprofiel" msgid "User Settings" msgstr "Gebruikersinstellingen" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/InviteUserModal.tsx #: src/components/ui/ModerationModal.tsx msgid "Username" @@ -2683,6 +2778,10 @@ msgstr "Breed — Ruimere beveiligingsscope" msgid "Will default to 'no reason' if left empty" msgstr "Standaard 'geen reden' als leeggelaten" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Yes, delete" +msgstr "Ja, verwijderen" + #: src/components/message/CollapsedEventMessage.tsx #: src/components/message/EventMessage.tsx msgid "You" @@ -2713,6 +2812,10 @@ msgstr "Je accountwachtwoord voor authenticatie" msgid "Your account username for authentication" msgstr "Je accountgebruikersnaam voor authenticatie" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Your bouncer doesn't have any networks yet. Add one to get started." +msgstr "Je bouncer heeft nog geen netwerken. Voeg er een toe om te beginnen." + #: src/lib/settings/definitions/allSettings.ts msgid "Your default nickname for all servers" msgstr "Je standaard nickname voor alle servers" diff --git a/src/locales/pl/messages.mjs b/src/locales/pl/messages.mjs index fa98c514..b9fdc0e0 100644 --- a/src/locales/pl/messages.mjs +++ b/src/locales/pl/messages.mjs @@ -1 +1 @@ -/*eslint-disable*/export const messages=JSON.parse("{\"+5VMrz\":[\"Nieprawidłowy format wzorca. Użyj formatu nick!user@host (dozwolone symbole wieloznaczne *)\"],\"+6NQQA\":[\"Ogólny kanał wsparcia\"],\"+6NyRG\":[\"Klient\"],\"+K0AvT\":[\"Rozłącz\"],\"+cyFdH\":[\"Domyślna wiadomość przy ustawianiu statusu nieobecności\"],\"+mVPqU\":[\"Renderuj formatowanie Markdown w wiadomościach\"],\"+vqCJH\":[\"Nazwa użytkownika Twojego konta do uwierzytelniania\"],\"+yPBXI\":[\"Wybierz plik\"],\"+zy2Nq\":[\"Typ\"],\"/09cao\":[\"Niskie bezpieczeństwo połączenia (poziom \",[\"securityLevel\"],\")\"],\"/3BQ4J\":[\"Użytkownicy spoza kanału nie mogą wysyłać do niego wiadomości\"],\"/6BzZF\":[\"Przełącz listę członków\"],\"/TNOPk\":[\"Użytkownik jest nieobecny\"],\"/XQgft\":[\"Odkryj\"],\"/cF7Rs\":[\"Głośność\"],\"/dqduX\":[\"Następna strona\"],\"/fc3q4\":[\"Wszystkie treści\"],\"/kISDh\":[\"Włącz dźwięki powiadomień\"],\"/n04sB\":[\"Kill\"],\"/rTz0M\":[\"Audio\"],\"/rfkZe\":[\"Odtwarzaj dźwięki dla wzmianek i wiadomości\"],\"0/0ZGA\":[\"Maska nazwy kanału\"],\"0D6j7U\":[\"Dowiedz się więcej o niestandardowych regułach →\"],\"0XsHcR\":[\"Wyrzuć użytkownika\"],\"0ZpE//\":[\"Sortuj według użytkowników\"],\"0bEPwz\":[\"Ustaw nieobecność\"],\"0dGkPt\":[\"Rozwiń listę kanałów\"],\"0gS7M5\":[\"Wyświetlana nazwa\"],\"0kS+M8\":[\"PrzykładSIEĆ\"],\"0rgoY7\":[\"Łącz się tylko z serwerami, które wybierzesz\"],\"0wdd7X\":[\"Dołącz\"],\"0wkVYx\":[\"Wiadomości prywatne\"],\"111uHX\":[\"Podgląd linku\"],\"196EG4\":[\"Usuń prywatną rozmowę\"],\"1DSr1i\":[\"Zarejestruj konto\"],\"1O/24y\":[\"Przełącz listę kanałów\"],\"1VPJJ2\":[\"Ostrzeżenie o zewnętrznym linku\"],\"1ZC/dv\":[\"Brak nieprzeczytanych wzmianek lub wiadomości\"],\"1pO1zi\":[\"Nazwa serwera jest wymagana\"],\"1uwfzQ\":[\"Zobacz temat kanału\"],\"268g7c\":[\"Wpisz wyświetlaną nazwę\"],\"2FOFq1\":[\"Operatorzy serwerów w sieci mogą potencjalnie odczytywać Twoje wiadomości\"],\"2FYpfJ\":[\"Więcej\"],\"2HF1Y2\":[[\"inviter\"],\" zaprosił \",[\"target\"],\" do dołączenia do \",[\"channel\"]],\"2I70QL\":[\"Zobacz informacje o profilu użytkownika\"],\"2QYdmE\":[\"Użytkownicy:\"],\"2QpEjG\":[\"wyszedł\"],\"2YE223\":[\"Wiadomość na #\",[\"0\"],\" (Enter – nowa linia, Shift+Enter – wyślij)\"],\"2bimFY\":[\"Użyj hasła serwera\"],\"2iTmdZ\":[\"Pamięć lokalna:\"],\"2odkwe\":[\"Rygorystyczny – bardziej agresywna ochrona\"],\"2uDhbA\":[\"Wpisz nazwę użytkownika do zaproszenia\"],\"2ygf/L\":[\"← Wróć\"],\"2zEgxj\":[\"Szukaj GIFów...\"],\"3RdPhl\":[\"Zmień nazwę kanału\"],\"3THokf\":[\"Użytkownik z głosem\"],\"3TSz9S\":[\"Minimalizuj\"],\"3jBDvM\":[\"Wyświetlana nazwa kanału\"],\"3ryuFU\":[\"Opcjonalne raporty o błędach w celu ulepszenia aplikacji\"],\"3uBF/8\":[\"Zamknij przeglądarkę\"],\"3uwW8F\":[\"https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"3xf8Kz\":[\"Wprowadź nazwę konta...\"],\"4/Rr0R\":[\"Zaproś użytkownika do bieżącego kanału\"],\"4EZrJN\":[\"Reguły\"],\"4JJtW9\":[\"#przepełnienie\"],\"4NqeT4\":[\"Profil floodu (+F)\"],\"4RZQRK\":[\"Co teraz robisz?\"],\"4hfTrB\":[\"Nick\"],\"4n99LO\":[\"Już w \",[\"0\"]],\"4t6vMV\":[\"Automatycznie przełącz na jedną linię dla krótkich wiadomości\"],\"4vsHmf\":[\"Czas (min)\"],\"5+INAX\":[\"Podświetlaj wiadomości, w których jesteś wzmiankowany\"],\"5R5Pv/\":[\"Nazwa operatora\"],\"678PKt\":[\"Nazwa sieci\"],\"6Aih4U\":[\"Offline\"],\"6CO3WE\":[\"Hasło wymagane do dołączenia do kanału. Pozostaw puste, aby usunąć klucz.\"],\"6HhMs3\":[\"Wiadomość pożegnalna\"],\"6V3Ea3\":[\"Skopiowano\"],\"6lGV3K\":[\"Pokaż mniej\"],\"6yFOEi\":[\"Wprowadź hasło opera...\"],\"7+IHTZ\":[\"Nie wybrano pliku\"],\"73hrRi\":[\"nick!user@host (np. spam*!*@*, *!*@badhost.com)\"],\"7QkKyN\":[\"Wyślij wiadomość prywatną\"],\"7U1W7c\":[\"Bardzo łagodny\"],\"7Y1YQj\":[\"Imię i nazwisko:\"],\"7YHArF\":[\"— otwórz w przeglądarce\"],\"7fjnVl\":[\"Szukaj użytkowników...\"],\"7jL88x\":[\"Usunąć tę wiadomość? Tej operacji nie można cofnąć.\"],\"7nGhhM\":[\"Co masz na myśli?\"],\"7sEpu1\":[\"Członkowie — \",[\"0\"]],\"7sNhEz\":[\"Nazwa użytkownika\"],\"8H0Q+x\":[\"Dowiedz się więcej o profilach →\"],\"8Phu0A\":[\"Wyświetlaj gdy użytkownicy zmieniają nick\"],\"8XTG9e\":[\"Wpisz hasło operatora\"],\"8XsV2J\":[\"Spróbuj wysłać ponownie\"],\"8ZsakT\":[\"Hasło\"],\"8kR84m\":[\"Zamierzasz otworzyć zewnętrzny link:\"],\"8lCgih\":[\"Usuń regułę\"],\"8p/xVT\":[[\"0\",\"plural\",{\"one\":[[\"1\"]],\"other\":[[\"2\"]]}]],\"8wRzac\":[[\"joinCount\",\"plural\",{\"one\":[\"dołączył\"],\"few\":[\"dołączył \",[\"joinCount\"],\" razy\"],\"many\":[\"dołączył \",[\"joinCount\"],\" razy\"],\"other\":[\"dołączył \",[\"joinCount\"],\" razy\"]}]],\"9BMLnJ\":[\"Ponownie połącz z serwerem\"],\"9OEgyT\":[\"Dodaj reakcję\"],\"9PQ8m2\":[\"G-Line (globalny ban)\"],\"9Qs99X\":[\"E-mail:\"],\"9QupBP\":[\"Usuń wzorzec\"],\"9bG48P\":[\"Wysyłanie\"],\"9f5f0u\":[\"Pytania dotyczące prywatności? Skontaktuj się z nami:\"],\"9unqs3\":[\"Nieobecny:\"],\"9v3hwv\":[\"Nie znaleziono serwerów.\"],\"9zb2WA\":[\"Łączenie\"],\"A1taO8\":[\"Szukaj\"],\"A2adVi\":[\"Wysyłaj powiadomienia o pisaniu\"],\"A9Rhec\":[\"Nazwa kanału\"],\"AWOSPo\":[\"Powiększ\"],\"AXSpEQ\":[\"Operator przy połączeniu\"],\"AeXO77\":[\"Konto\"],\"AhNP40\":[\"Przewijaj\"],\"Ai2U7L\":[\"Host\"],\"AjBQnf\":[\"Zmieniono nick\"],\"AmXVh6\":[\"https://example.com/avatar.png\"],\"AnRu/j\":[\"Anuluj odpowiedź\"],\"ApSx0O\":[\"Znaleziono \",[\"0\"],\" wiadomości pasujących do \\\"\",[\"searchQuery\"],\"\\\"\"],\"AxPAXW\":[\"Nie znaleziono wyników\"],\"AyNqAB\":[\"Wyświetl wszystkie zdarzenia serwera w czacie\"],\"B/QqGw\":[\"Z dala od klawiatury\"],\"B8AaMI\":[\"To pole jest wymagane\"],\"BA2c49\":[\"Serwer nie obsługuje zaawansowanego filtrowania LIST\"],\"BDKt3I\":[[\"0\"],\", \",[\"1\"],\", \",[\"2\"],\" i \",[\"3\"],\" innych pisze...\"],\"BGul2A\":[\"Masz niezapisane zmiany. Czy na pewno chcesz zamknąć bez zapisywania?\"],\"BIf9fi\":[\"Twoja wiadomość statusu\"],\"BZz3md\":[\"Twoja strona internetowa\"],\"Bgm/H7\":[\"Zezwól na wpisywanie wielu linii tekstu\"],\"BiQIl1\":[\"Przypnij tę prywatną rozmowę\"],\"BlNZZ2\":[\"Kliknij, aby przejść do wiadomości\"],\"Bowq3c\":[\"Tylko operatorzy mogą zmieniać temat kanału\"],\"Btozzp\":[\"Ten obraz wygasł\"],\"Bycfjm\":[\"Łącznie: \",[\"0\"]],\"C6IBQc\":[\"Kopiuj cały JSON\"],\"C9L9wL\":[\"Zbieranie danych\"],\"CDq4wC\":[\"Moderuj użytkownika\"],\"CHVRxG\":[\"Wiadomość do @\",[\"0\"],\" (Shift+Enter – nowa linia)\"],\"CN9zdR\":[\"Nazwa operatora i hasło są wymagane\"],\"CW3sYa\":[\"Dodaj reakcję \",[\"emoji\"]],\"CaAkqd\":[\"Pokaż rozłączenia\"],\"CbvaYj\":[\"Zablokuj po nicku\"],\"CcK+Ft\":[\"PDF\"],\"Ce8q3L\":[\"Wybierz kanał\"],\"CsekCi\":[\"Normalny\"],\"D+NlUC\":[\"System\"],\"D28t6+\":[\"dołączył i wyszedł\"],\"DB8zMK\":[\"Zastosuj\"],\"DBcWHr\":[\"Niestandardowy plik dźwięku powiadomień\"],\"DTy9Xw\":[\"Podglądy mediów\"],\"Dj4pSr\":[\"Wybierz bezpieczne hasło\"],\"Du+zn+\":[\"Wyszukiwanie...\"],\"Du2T2f\":[\"Nie znaleziono ustawienia\"],\"DwsSVQ\":[\"Zastosuj filtry i odśwież\"],\"E3W/zd\":[\"Domyślny nick\"],\"E6nRW7\":[\"Kopiuj URL\"],\"E703RG\":[\"Tryby:\"],\"EAeu1Z\":[\"Wyślij zaproszenie\"],\"EFKJQT\":[\"Ustawienie\"],\"EGPQBv\":[\"Niestandardowe reguły floodu (+f)\"],\"ELik0r\":[\"Zobacz pełną politykę prywatności\"],\"EPbeC2\":[\"Zobacz lub edytuj temat kanału\"],\"EQCDNT\":[\"Wprowadź nazwę użytkownika opera...\"],\"EUvulZ\":[\"Znaleziono 1 wiadomość pasującą do \\\"\",[\"searchQuery\"],\"\\\"\"],\"EatZYJ\":[\"Następny obraz\"],\"EdQY6l\":[\"Brak\"],\"EnqLYU\":[\"Szukaj serwerów...\"],\"F0OKMc\":[\"Edytuj serwer\"],\"F6Int2\":[\"Włącz podświetlenia\"],\"FDoLyE\":[\"Maks. użytkowników\"],\"FUU/hZ\":[\"Kontroluje ilość zewnętrznych mediów ładowanych na czacie.\"],\"Fdp03t\":[\"wł\"],\"FfPWR0\":[\"Okno\"],\"FjkaiT\":[\"Pomniejsz\"],\"FlqOE9\":[\"Co to oznacza:\"],\"FolHNl\":[\"Zarządzaj swoim kontem i uwierzytelnianiem\"],\"Fp2Dif\":[\"Opuścił serwer\"],\"G5KmCc\":[\"GZ-Line (globalna Z-Line)\"],\"GDs0lz\":[\"<0>Ryzyko: Poufne informacje (wiadomości, prywatne rozmowy, dane uwierzytelniające) mogą być widoczne dla administratorów sieci lub atakujących znajdujących się między serwerami IRC.\"],\"GR+2I3\":[\"Dodaj maskę zaproszenia (np. nick!*@*, *!*@host.com)\"],\"GRLyMU\":[\"Zamknij wyskakujące powiadomienia serwera\"],\"GlHnXw\":[\"Zmiana nicku nie powiodła się: \",[\"error\"],\" \",[\"0\"]],\"GswZF3\":[\"Podgląd:\"],\"GtmO8/\":[\"od\"],\"GtuHUQ\":[\"Zmień nazwę tego kanału na serwerze. Wszyscy użytkownicy zobaczą nową nazwę.\"],\"GuGfFX\":[\"Przełącz wyszukiwanie\"],\"GxkJXS\":[\"Przesyłanie...\"],\"GzbwnK\":[\"Dołączył do kanału\"],\"GzsUDB\":[\"Rozszerzony profil\"],\"H/PnT8\":[\"Wstaw emoji\"],\"H6Izzl\":[\"Twój preferowany kod koloru\"],\"H9jIv+\":[\"Pokaż dołączenia/odejścia\"],\"HAKBY9\":[\"Prześlij pliki\"],\"HdE1If\":[\"Kanał\"],\"Hk4AW9\":[\"Twoja preferowana wyświetlana nazwa\"],\"HmHDk7\":[\"Wybierz członka\"],\"HrQzPU\":[\"Kanały na \",[\"networkName\"]],\"I2tXQ5\":[\"Wiadomość do @\",[\"0\"],\" (Enter – nowa linia, Shift+Enter – wyślij)\"],\"I6bw/h\":[\"Zablokuj użytkownika\"],\"I92Z+b\":[\"Włącz powiadomienia\"],\"I9D72S\":[\"Czy na pewno chcesz usunąć tę wiadomość? Tej operacji nie można cofnąć.\"],\"IA+1wo\":[\"Wyświetlaj gdy użytkownicy są wyrzucani z kanałów\"],\"IDwkJx\":[\"Operator IRC\"],\"ILlU+s\":[\"Info:\"],\"IUwGEM\":[\"Zapisz zmiany\"],\"IVeGK6\":[[\"0\"],\", \",[\"1\"],\" i \",[\"2\"],\" piszą...\"],\"IgrLD/\":[\"Pauza\"],\"Im6JED\":[\"WHISPER\"],\"ImOQa9\":[\"Odpowiedz\"],\"IoHMnl\":[\"Maksymalna wartość wynosi \",[\"0\"]],\"IvMj+0\":[\"Op\"],\"J28zul\":[\"Łączenie...\"],\"J5T9NW\":[\"Informacje o użytkowniku\"],\"J8Y5+z\":[\"Ups! Podział sieci! ⚠️\"],\"JBHkBA\":[\"Opuścił kanał\"],\"JCwL0Q\":[\"Wpisz powód (opcjonalnie)\"],\"JFciKP\":[\"Przełącz\"],\"JXGkhG\":[\"Zmień nazwę kanału (tylko dla operatorów)\"],\"JcD7qf\":[\"Więcej akcji\"],\"JdkA+c\":[\"Tajny (+s)\"],\"Jmu12l\":[\"Kanały serwera\"],\"JvQ++s\":[\"Włącz Markdown\"],\"K2jwh/\":[\"Brak dostępnych danych WHOIS\"],\"KAXSwC\":[\"Voice\"],\"KDfTdX\":[\"Usuń wiadomość\"],\"KKBlUU\":[\"Osadź\"],\"KM0pLb\":[\"Witamy na kanale!\"],\"KR6W2h\":[\"Przestań ignorować użytkownika\"],\"KV+Bi1\":[\"Tylko na zaproszenie (+i)\"],\"KdCtwE\":[\"Ile sekund monitorować aktywność floodowania przed zresetowaniem liczników\"],\"Kkezga\":[\"Hasło serwera\"],\"KsiQ/8\":[\"Użytkownicy muszą być zaproszeni, aby dołączyć do kanału\"],\"L+gB/D\":[\"Informacje o kanale\"],\"LC1a7n\":[\"Serwer IRC zgłosił, że jego połączenia między serwerami mają niski poziom bezpieczeństwa. Oznacza to, że gdy Twoje wiadomości są przekazywane między serwerami IRC w sieci, mogą nie być właściwie szyfrowane lub certyfikaty SSL/TLS mogą nie być poprawnie weryfikowane.\"],\"LNfLR5\":[\"Pokaż wyrzucenia\"],\"LQb0W/\":[\"Pokaż wszystkie zdarzenia\"],\"LU7/yA\":[\"Alternatywna nazwa wyświetlana w interfejsie. Może zawierać spacje, emoji i znaki specjalne. Prawdziwa nazwa kanału (\",[\"channelName\"],\") nadal będzie używana w poleceniach IRC.\"],\"LUb9O7\":[\"Wymagany jest prawidłowy port serwera\"],\"Lb+BUl\":[\"https://example.com/avatar.jpg\"],\"LcET2C\":[\"Polityka prywatności\"],\"LcuSDR\":[\"Zarządzaj informacjami w profilu i metadanymi\"],\"LqLS9B\":[\"Pokaż zmiany nicku\"],\"LsDQt2\":[\"Ustawienia kanału\"],\"LtI9AS\":[\"Właściciel\"],\"LuNhhL\":[\"zareagował na tę wiadomość\"],\"M/AZNG\":[\"URL do obrazu Twojego awatara\"],\"M/WIer\":[\"Wyślij wiadomość\"],\"M8er/5\":[\"Nazwa:\"],\"MHk+7g\":[\"Poprzedni obraz\"],\"MRorGe\":[\"Wyślij wiadomość prywatną\"],\"MVbSGP\":[\"Okno czasowe (sekundy)\"],\"MkpcsT\":[\"Twoje wiadomości i ustawienia są przechowywane lokalnie na Twoim urządzeniu\"],\"N/hDSy\":[\"Oznacz jako bota – zazwyczaj 'on' lub puste\"],\"N7TQbE\":[\"Zaproś użytkownika do \",[\"channelName\"]],\"NCca/o\":[\"Wprowadź domyślny pseudonim...\"],\"Nqs6B9\":[\"Wyświetla wszystkie zewnętrzne media. Każdy URL może spowodować żądanie do nieznanego serwera.\"],\"Nt+9O7\":[\"Użyj WebSocket zamiast surowego TCP\"],\"NxIHzc\":[\"Rozłącz użytkownika\"],\"O+v/cL\":[\"Przeglądaj wszystkie kanały na serwerze\"],\"ODwSCk\":[\"Wyślij GIF\"],\"OGQ5kK\":[\"Konfiguruj dźwięki powiadomień i podświetlenia\"],\"OIPt1Z\":[\"Pokaż lub ukryj panel listy członków\"],\"OKSNq/\":[\"Bardzo rygorystyczny\"],\"ONWvwQ\":[\"Prześlij\"],\"OVKoQO\":[\"Hasło Twojego konta do uwierzytelniania\"],\"ObsidianIRC - Bringing IRC to the future\":[\"ObsidianIRC - Przenosimy IRC w przyszłość\"],\"OhCpra\":[\"Ustaw temat…\"],\"OkltoQ\":[\"Zablokuj \",[\"username\"],\" po nicku (uniemożliwia ponowne dołączenie z tym samym nickiem)\"],\"P+t/Te\":[\"Brak dodatkowych danych\"],\"P42Wcc\":[\"Bezpieczne\"],\"PD38l0\":[\"Podgląd awatara kanału\"],\"PD9mEt\":[\"Wpisz wiadomość...\"],\"PPqfdA\":[\"Otwórz ustawienia konfiguracji kanału\"],\"PSCjfZ\":[\"Temat, który będzie wyświetlany dla tego kanału. Wszyscy użytkownicy mogą zobaczyć temat.\"],\"PZCecv\":[\"Podgląd PDF\"],\"PeLgsC\":[[\"c\",\"plural\",{\"one\":[\"1 raz\"],\"few\":[[\"c\"],\" razy\"],\"many\":[[\"c\"],\" razy\"],\"other\":[[\"c\"],\" razy\"]}]],\"PguS2C\":[\"Dodaj maskę wyjątku (np. nick!*@*, *!*@host.com)\"],\"Pil5Ty\":[\"Wyświetlanie \",[\"displayedChannelsCount\"],\" z \",[\"0\"],\" kanałów\"],\"PqhVlJ\":[\"Zablokuj użytkownika (po hostmasce)\"],\"Q+chwU\":[\"Nazwa użytkownika:\"],\"Q6hhn8\":[\"Preferencje\"],\"QF4a34\":[\"Proszę podać nazwę użytkownika\"],\"QGqSZ2\":[\"Kolor i formatowanie\"],\"QJQd1J\":[\"Edytuj profil\"],\"QSzGDE\":[\"Bezczynny\"],\"QUlny5\":[\"Witamy na \",[\"0\"],\"!\"],\"Qoq+GP\":[\"Czytaj więcej\"],\"QuSkCF\":[\"Filtruj kanały...\"],\"QwUrDZ\":[\"zmienił temat na: \",[\"topic\"]],\"R0UH07\":[\"Obraz \",[\"0\"],\" z \",[\"1\"]],\"R7SsBE\":[\"Wycisz\"],\"R8rf1X\":[\"Kliknij, aby ustawić temat\"],\"RArB3D\":[\"został wyrzucony z \",[\"channelName\"],\" przez \",[\"username\"]],\"RI3cWd\":[\"Odkryj świat IRC z ObsidianIRC\"],\"RMMaN5\":[\"Moderowany (+m)\"],\"RWw9Lg\":[\"Zamknij okno\"],\"RZ2BuZ\":[\"Rejestracja konta \",[\"account\"],\" wymaga weryfikacji: \",[\"message\"]],\"RySp6q\":[\"Ukryj komentarze\"],\"SPKQTd\":[\"Nick jest wymagany\"],\"SPVjfj\":[\"Domyślnie 'brak powodu', jeśli pozostawione puste\"],\"SQKPvQ\":[\"Zaproś użytkownika\"],\"SkZcl+\":[\"Wybierz wstępnie zdefiniowany profil ochrony przed floodem. Profile te oferują zrównoważone ustawienia ochrony dla różnych przypadków użycia.\"],\"Slr+3C\":[\"Min. użytkowników\"],\"Spnlre\":[\"Zaprosiłeś \",[\"target\"],\" do dołączenia do \",[\"channel\"]],\"T/ckN5\":[\"Otwórz w przeglądarce mediów\"],\"T91vKp\":[\"Odtwórz\"],\"TV2Wdu\":[\"Dowiedz się, jak przetwarzamy Twoje dane i chronimy Twoją prywatność.\"],\"TgFpwD\":[\"Stosowanie...\"],\"TkzSFB\":[\"Brak zmian\"],\"TtserG\":[\"Wpisz prawdziwe imię i nazwisko\"],\"Ttz9J1\":[\"Wprowadź hasło...\"],\"Tz0i8g\":[\"Ustawienia\"],\"U3pytU\":[\"Administrator\"],\"UDb2YD\":[\"Zareaguj\"],\"UE4KO5\":[\"*kanał*\"],\"UGT5vp\":[\"Zapisz ustawienia\"],\"UV5hLB\":[\"Nie znaleziono żadnych banów\"],\"Uaj3Nd\":[\"Wiadomości statusu\"],\"Ue3uny\":[\"Domyślne (brak profilu)\"],\"UkARhe\":[\"Normalny – standardowa ochrona\"],\"Umn7Cj\":[\"Brak komentarzy. Bądź pierwszy!\"],\"UtUIRh\":[[\"0\"],\" starszych wiadomości\"],\"UwzP+U\":[\"Bezpieczne połączenie\"],\"V0/A4O\":[\"Właściciel kanału\"],\"V4qgxE\":[\"Utworzone przed (min temu)\"],\"V8yTm6\":[\"Wyczyść wyszukiwanie\"],\"VJMMyz\":[\"ObsidianIRC – IRC w nowoczesnym wydaniu\"],\"VJScHU\":[\"Powód\"],\"VLsmVV\":[\"Wycisz powiadomienia\"],\"VbyRUy\":[\"Komentarze\"],\"Vmx0mQ\":[\"Ustawione przez:\"],\"VqnIZz\":[\"Zobacz naszą politykę prywatności i zasady przetwarzania danych\"],\"VrMygG\":[\"Minimalna długość wynosi \",[\"0\"]],\"VrnTui\":[\"Twoje zaimki, widoczne w profilu\"],\"W8E3qn\":[\"Uwierzytelnione konto\"],\"WAakm9\":[\"Usuń kanał\"],\"WFxTHC\":[\"Dodaj maskę bana (np. nick!*@*, *!*@host.com)\"],\"WN1g9F\":[\"Host serwera jest wymagany\"],\"WRYdXW\":[\"Pozycja audio\"],\"WUOH5B\":[\"Ignoruj użytkownika\"],\"WWEXnZ\":[[\"0\",\"plural\",{\"one\":[\"Pokaż 1 więcej element\"],\"few\":[\"Pokaż \",[\"1\"],\" więcej elementów\"],\"many\":[\"Pokaż \",[\"1\"],\" więcej elementów\"],\"other\":[\"Pokaż \",[\"1\"],\" więcej elementów\"]}]],\"Weq9zb\":[\"Ogólne\"],\"Wfj7Sk\":[\"Wycisz lub odcisz dźwięki powiadomień\"],\"Wm7gbG\":[\"GitHub:\"],\"WyeHWY\":[\"*spam*\"],\"WzMCru\":[\"Profil użytkownika\"],\"X6S3lt\":[\"Szukaj ustawień, kanałów, serwerów...\"],\"XEHan5\":[\"Kontynuuj mimo to\"],\"XI1+wb\":[\"Nieprawidłowy format\"],\"XIXeuC\":[\"Wiadomość do @\",[\"0\"]],\"XMS+k4\":[\"Rozpocznij prywatną rozmowę\"],\"XWgxXq\":[\"Album\"],\"Xd7+IT\":[\"Odepnij prywatną rozmowę\"],\"Xm/s+u\":[\"Wyświetlanie\"],\"Xp2n93\":[\"Wyświetla media z zaufanego hosta plików Twojego serwera. Żadne żądania nie są wysyłane do zewnętrznych serwisów.\"],\"XvjC4F\":[\"Zapisywanie...\"],\"Y/qryO\":[\"Nie znaleziono użytkowników pasujących do wyszukiwania\"],\"YAqRpI\":[\"Rejestracja konta \",[\"account\"],\" powiodła się: \",[\"message\"]],\"YEfzvP\":[\"Chroniony temat (+t)\"],\"YQOn6a\":[\"Zwiń listę członków\"],\"YRCoE9\":[\"Operator kanału\"],\"YURQaF\":[\"Zobacz profil\"],\"YdBSvr\":[\"Kontroluj wyświetlanie mediów i zewnętrznych treści\"],\"Yj6U3V\":[\"Brak centralnego serwera:\"],\"YjvpGx\":[\"Zaimki\"],\"YqH4l4\":[\"Brak klucza\"],\"YyUPpV\":[\"Konto:\"],\"ZJSWfw\":[\"Wiadomość wyświetlana przy rozłączeniu z serwera\"],\"ZR1dJ4\":[\"Zaproszenia\"],\"ZdWg0V\":[\"Otwórz w przeglądarce\"],\"ZhRBbl\":[\"Szukaj wiadomości…\"],\"Zmcu3y\":[\"Zaawansowane filtry\"],\"a2/8e5\":[\"Temat ustawiony po (min temu)\"],\"aHKcKc\":[\"Poprzednia strona\"],\"aJTbXX\":[\"Hasło operatora\"],\"aQryQv\":[\"Wzorzec już istnieje\"],\"aW9pLN\":[\"Maksymalna liczba użytkowników dozwolona na kanale. Pozostaw puste, aby nie było limitu.\"],\"ah4fmZ\":[\"Wyświetla również podglądy z YouTube, Vimeo, SoundCloud i podobnych znanych serwisów.\"],\"aifXak\":[\"Brak mediów na tym kanale\"],\"ap2zBz\":[\"Łagodny\"],\"az8lvo\":[\"Wyłączone\"],\"azXSNo\":[\"Rozwiń listę członków\"],\"azdliB\":[\"Zaloguj się na konto\"],\"b26wlF\":[\"ona/jej\"],\"bD/+Ei\":[\"Rygorystyczny\"],\"bQ6BJn\":[\"Konfiguruj szczegółowe reguły ochrony przed floodem. Każda reguła określa, jaki rodzaj aktywności monitorować i jakie działanie podjąć po przekroczeniu progów.\"],\"beV7+y\":[\"Użytkownik otrzyma zaproszenie do dołączenia do \",[\"channelName\"],\".\"],\"bk84cH\":[\"Wiadomość o nieobecności\"],\"bkHdLj\":[\"Dodaj serwer IRC\"],\"bmQLn5\":[\"Dodaj regułę\"],\"bwRvnp\":[\"Akcja\"],\"c8+EVZ\":[\"Zweryfikowane konto\"],\"cGYUlD\":[\"Nie wczytano żadnych podglądów mediów.\"],\"cLF98o\":[\"Pokaż komentarze (\",[\"commentCount\"],\")\"],\"cLKIDO\":[\"Brak dostępnych użytkowników\"],\"cSgpoS\":[\"Przypnij prywatną rozmowę\"],\"cde3ce\":[\"Wiadomość do <0>\",[\"0\"],\"\"],\"chQsxg\":[\"Kopiuj sformatowane wyjście\"],\"cl/A5J\":[\"Witamy na \",[\"__DEFAULT_IRC_SERVER_NAME__\"],\"!\"],\"cnGeoo\":[\"Usuń\"],\"coPLXT\":[\"Nie przechowujemy Twoich komunikatów IRC na naszych serwerach\"],\"crYH/6\":[\"Odtwarzacz SoundCloud\"],\"d3sis4\":[\"Dodaj serwer\"],\"d9aN5k\":[\"Usuń \",[\"username\"],\" z kanału\"],\"dEgA5A\":[\"Anuluj\"],\"dGi1We\":[\"Odepnij tę prywatną rozmowę\"],\"dJVuyC\":[\"opuścił \",[\"channelName\"],\" (\",[\"reason\"],\")\"],\"dMtLDE\":[\"do\"],\"dXqxlh\":[\"<0>⚠️ Zagrożenie bezpieczeństwa! To połączenie może być podatne na przechwycenie lub ataki typu man-in-the-middle.\"],\"da9Q/R\":[\"Zmieniono tryby kanału\"],\"dhJN3N\":[\"Pokaż komentarze\"],\"dj2xTE\":[\"Odrzuć powiadomienie\"],\"dpCzmC\":[\"Ustawienia ochrony przed floodem\"],\"e9dQpT\":[\"Czy chcesz otworzyć ten link w nowej karcie?\"],\"ePK91l\":[\"Edytuj\"],\"eYBDuB\":[\"Prześlij obraz lub podaj URL z opcjonalnym podstawieniem \",[\"size\"],\" dla dynamicznego rozmiaru\"],\"edBbee\":[\"Zablokuj \",[\"username\"],\" po hostmasce (uniemożliwia ponowne dołączenie z tego samego IP/hosta)\"],\"ekfzWq\":[\"Ustawienia użytkownika\"],\"elPDWs\":[\"Dostosuj swoje doświadczenie z klientem IRC\"],\"eu2osY\":[\"<0>💡 Zalecenie: Kontynuuj tylko jeśli ufasz temu serwerowi i rozumiesz ryzyko. Unikaj udostępniania poufnych informacji lub haseł przez to połączenie.\"],\"euEhbr\":[\"Kliknij, aby dołączyć do \",[\"channel\"]],\"ez3vLd\":[\"Włącz wieloliniowe wprowadzanie\"],\"f0J5Ki\":[\"Komunikacja między serwerami może używać nieszyfrowanych połączeń\"],\"f9BHJk\":[\"Ostrzeż użytkownika\"],\"fDOLLd\":[\"Nie znaleziono kanałów.\"],\"ffzDkB\":[\"Anonimowa analityka:\"],\"fq1GF9\":[\"Wyświetlaj gdy użytkownicy rozłączają się z serwera\"],\"gEF57C\":[\"Ten serwer obsługuje tylko jeden typ połączenia\"],\"gJuLUI\":[\"Lista ignorowanych\"],\"gNzMrk\":[\"Bieżący awatar\"],\"gjPWyO\":[\"Wprowadź pseudonim...\"],\"gz6UQ3\":[\"Maksymalizuj\"],\"h6razj\":[\"Wyklucz maskę nazwy kanału\"],\"hG6jnw\":[\"Nie ustawiono tematu\"],\"hG89Ed\":[\"Obraz\"],\"hZ6znB\":[\"Port\"],\"ha+Bz5\":[\"np. 100:1440\"],\"hehnjM\":[\"Ilość\"],\"hzdLuQ\":[\"Tylko użytkownicy z głosem lub wyżej mogą mówić\"],\"i0qMbr\":[\"Strona główna\"],\"iDNBZe\":[\"Powiadomienia\"],\"iH8pgl\":[\"Wróć\"],\"iL9SZg\":[\"Zablokuj użytkownika (po nicku)\"],\"iNt+3c\":[\"Wróć do obrazu\"],\"iQvi+a\":[\"Nie ostrzegaj mnie o niskim poziomie bezpieczeństwa połączeń dla tego serwera\"],\"iSLIjg\":[\"Połącz\"],\"iWXkHH\":[\"Halfop\"],\"iZeTtp\":[\"Host serwera\"],\"idD8Ev\":[\"Zapisano\"],\"iivqkW\":[\"Zalogowany od\"],\"ij+Elv\":[\"Podgląd obrazu\"],\"ilIWp7\":[\"Przełącz powiadomienia\"],\"iuaqvB\":[\"Użyj * jako symbolu wieloznacznego. Przykłady: baduser!*@*, *!*@spammer.com, troll*!*@*\"],\"ixkTse\":[\"Bot\"],\"j2DGR0\":[\"Zablokuj po hostmasce\"],\"jA4uoI\":[\"Temat:\"],\"jLXxGK\":[\"https://example.com\"],\"jPSk57\":[\"Powód (opcjonalnie)\"],\"jUV7CU\":[\"Prześlij awatar\"],\"jW5Uwh\":[\"Kontroluj ilość wczytywanego zewnętrznego medium. Wyłączone / Bezpieczne / Zaufane źródła / Wszystkie treści.\"],\"jXzms5\":[\"Opcje załącznika\"],\"jZlrte\":[\"Kolor\"],\"jfC/xh\":[\"Kontakt\"],\"jywMpv\":[\"#nowa-nazwa-kanału\"],\"k112DD\":[\"Wczytaj starsze wiadomości\"],\"k3ID0F\":[\"Filtruj członków…\"],\"k65gsE\":[\"Szczegóły\"],\"k7Zgob\":[\"Anuluj połączenie\"],\"kAVx5h\":[\"Nie znaleziono zaproszeń\"],\"kCLEPU\":[\"Połączony z\"],\"kF5LKb\":[\"Ignorowane wzorce:\"],\"kGeOx/\":[\"Dołącz do \",[\"0\"]],\"kITKr8\":[\"Wczytywanie trybów kanału...\"],\"kPpPsw\":[\"Jesteś operatorem IRC\"],\"kWJmRL\":[\"Ty\"],\"kfcRb0\":[\"Awatar\"],\"kjMqSj\":[\"Kopiuj JSON\"],\"krViRy\":[\"Kliknij, aby skopiować jako JSON\"],\"ks71ra\":[\"Wyjątki\"],\"kw4lRv\":[\"Pół-operator kanału\"],\"kxgIRq\":[\"Wybierz lub dodaj kanał, aby zacząć.\"],\"ky6dWe\":[\"Podgląd awatara\"],\"l+GxCv\":[\"Wczytywanie kanałów...\"],\"l+IUVW\":[\"Weryfikacja konta \",[\"account\"],\" powiodła się: \",[\"message\"]],\"l/siQz\":[[\"reconnectCount\",\"plural\",{\"one\":[\"ponownie połączył\"],\"few\":[\"ponownie połączył \",[\"reconnectCount\"],\" razy\"],\"many\":[\"ponownie połączył \",[\"reconnectCount\"],\" razy\"],\"other\":[\"ponownie połączył \",[\"reconnectCount\"],\" razy\"]}]],\"l5jmzx\":[[\"0\"],\" i \",[\"1\"],\" piszą...\"],\"lHy8N5\":[\"Wczytywanie kolejnych kanałów...\"],\"lbpf14\":[\"Dołącz do \",[\"value\"]],\"lfFsZ4\":[\"Kanały\"],\"lkNdiH\":[\"Nazwa konta\"],\"ln500L\":[\"ObsidianIRC\"],\"lnCMdg\":[\"Prześlij obraz\"],\"loQxaJ\":[\"Wróciłem\"],\"lvfaxv\":[\"STRONA GŁÓWNA\"],\"m16xKo\":[\"Dodaj\"],\"m8flAk\":[\"Podgląd (jeszcze nie przesłany)\"],\"mEPxTp\":[\"<0>⚠️ Uwaga! Otwieraj tylko linki z zaufanych źródeł. Złośliwe linki mogą narazić Twoje bezpieczeństwo lub prywatność.\"],\"mHGdhG\":[\"Informacje o serwerze\"],\"mHS8lb\":[\"Wiadomość na #\",[\"0\"]],\"mMYBD9\":[\"Szeroki – szerszy zakres ochrony\"],\"mTGsPd\":[\"Temat kanału\"],\"mU8j6O\":[\"Brak zewnętrznych wiadomości (+n)\"],\"mZp8FL\":[\"Automatyczny powrót do jednej linii\"],\"mdQu8G\":[\"TwójNick\"],\"miSSBQ\":[\"Komentarze (\",[\"commentCount\"],\")\"],\"mvyLSy\":[\"Użytkownik jest uwierzytelniony\"],\"mwtcGl\":[\"Zamknij komentarze\"],\"mzI/c+\":[\"Pobierz\"],\"n3fGRk\":[\"ustawione przez \",[\"0\"]],\"nE9jsU\":[\"Łagodny – mniej agresywna ochrona\"],\"nNflMD\":[\"Opuść kanał\"],\"nPXkBi\":[\"Wczytywanie danych WHOIS...\"],\"nQnxxF\":[\"Wiadomość na #\",[\"0\"],\" (Shift+Enter – nowa linia)\"],\"nWMRxa\":[\"Odepnij\"],\"nkC032\":[\"Brak profilu floodu\"],\"o69z4d\":[\"Wyślij wiadomość ostrzegawczą do \",[\"username\"]],\"o9ylQi\":[\"Wyszukaj GIFy, aby rozpocząć\"],\"oFGkER\":[\"Powiadomienia serwera\"],\"oOi11l\":[\"Przewiń na dół\"],\"oQEzQR\":[\"Nowy DM\"],\"oXOSPE\":[\"Online\"],\"oal760\":[\"Ataki man-in-the-middle na połączenia serwera są możliwe\"],\"oeqmmJ\":[\"Zaufane źródła\"],\"ovBPCi\":[\"Domyślne\"],\"p0Z69r\":[\"Wzorzec nie może być pusty\"],\"p1KgtK\":[\"Nie udało się załadować audio\"],\"p59pEv\":[\"Dodatkowe szczegóły\"],\"p7sRI6\":[\"Informuj innych, gdy piszesz\"],\"pBm1od\":[\"Tajny kanał\"],\"pNmiXx\":[\"Twój domyślny nick dla wszystkich serwerów\"],\"pUUo9G\":[\"Hostname:\"],\"pVGPmz\":[\"Hasło konta\"],\"peNE68\":[\"Stały\"],\"plhHQt\":[\"Brak danych\"],\"pm6+q5\":[\"Ostrzeżenie o bezpieczeństwie\"],\"pn5qSs\":[\"Dodatkowe informacje\"],\"q0cR4S\":[\"jest teraz znany jako **\",[\"newNick\"],\"**\"],\"qFcunY\":[\"Kanał nie będzie widoczny w poleceniach LIST ani NAMES\"],\"qLpTm/\":[\"Usuń reakcję \",[\"emoji\"]],\"qVkGWK\":[\"Przypnij\"],\"qY8wNa\":[\"Strona internetowa\"],\"qb0xJ7\":[\"Użyj symboli wieloznacznych: * pasuje do dowolnej sekwencji, ? pasuje do dowolnego pojedynczego znaku. Przykłady: nick!*@*, *!*@host.com, *!*user@*\"],\"qhzpRq\":[\"Klucz kanału (+k)\"],\"qtoOYG\":[\"Brak limitu\"],\"r1W2AS\":[\"Obraz z serwera plików\"],\"rIPR2O\":[\"Temat ustawiony przed (min temu)\"],\"rMMSYo\":[\"Maksymalna długość wynosi \",[\"0\"]],\"rWtzQe\":[\"Sieć rozdzieliła się i ponownie połączyła. ✅\"],\"rYG2u6\":[\"Proszę czekać...\"],\"rdUucN\":[\"Podgląd\"],\"rjGI/Q\":[\"Prywatność\"],\"rk8iDX\":[\"Wczytywanie GIFów...\"],\"rn6SBY\":[\"Odcisz\"],\"s/UKqq\":[\"Został wyrzucony z kanału\"],\"s8cATI\":[\"dołączył do \",[\"channelName\"]],\"sCO9ue\":[\"Połączenie z <0>\",[\"serverName\"],\" ma następujące problemy z bezpieczeństwem:\"],\"sGH11W\":[\"Serwer\"],\"sHI1H+\":[\"jest teraz znany jako **\",[\"newNick\"],\"**\"],\"sJyV04\":[[\"inviter\"],\" zaprosił cię do dołączenia do \",[\"channel\"]],\"sby+1/\":[\"Kliknij, aby skopiować\"],\"sfN25C\":[\"Twoje prawdziwe imię i nazwisko\"],\"sliuzR\":[\"Otwórz link\"],\"sqrO9R\":[\"Niestandardowe wzmianki\"],\"sr6RdJ\":[\"Wieloliniowy przy Shift+Enter\"],\"swrCpB\":[\"Kanał został przemianowany z \",[\"oldName\"],\" na \",[\"newName\"],\" przez \",[\"user\"],[\"0\"]],\"sxkWRg\":[\"Zaawansowane\"],\"t/YqKh\":[\"Usuń\"],\"t47eHD\":[\"Twój unikalny identyfikator na tym serwerze\"],\"tAkAh0\":[\"URL z opcjonalnym podstawieniem \",[\"size\"],\" dla dynamicznego rozmiaru. Przykład: https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"tXLJS3\":[\"Pokaż lub ukryj panel listy kanałów\"],\"tfDRzk\":[\"Zapisz\"],\"tiBsJk\":[\"opuścił \",[\"channelName\"]],\"tt4/UD\":[\"wyszedł (\",[\"reason\"],\")\"],\"u0TcnO\":[\"Nick {nick} jest już używany, ponawiam z {newNick}\"],\"u0a8B4\":[\"Uwierzytelnij się jako operator IRC, aby uzyskać dostęp administracyjny\"],\"u0rWFU\":[\"Utworzone po (min temu)\"],\"u72w3t\":[\"Użytkownicy i wzorce do ignorowania\"],\"u7jc2L\":[\"wyszedł\"],\"uAQUqI\":[\"Status\"],\"uB85T3\":[\"Zapis nieudany: \",[\"msg\"]],\"uV3DOL\":[\"G-Line\"],\"uW3lLI\":[\"Serwery IRC:\"],\"usSSr/\":[\"Poziom powiększenia\"],\"v7uvcf\":[\"Oprogramowanie:\"],\"vE8kb+\":[\"Użyj Shift+Enter dla nowych linii (Enter wysyła)\"],\"vERlcd\":[\"Profil\"],\"vK0RL8\":[\"Brak tematu\"],\"vSJd18\":[\"Wideo\"],\"vXIe7J\":[\"Język\"],\"vaHYxN\":[\"Prawdziwe imię i nazwisko\"],\"vhjbKr\":[\"Nieobecny\"],\"w4NYox\":[\"klient \",[\"title\"]],\"w8xQRx\":[\"Nieprawidłowa wartość\"],\"wFjjxZ\":[\"został wyrzucony z \",[\"channelName\"],\" przez \",[\"username\"],\" (\",[\"reason\"],\")\"],\"wGjaGl\":[\"Nie znaleziono wyjątków od bana\"],\"wPrGnM\":[\"Administrator kanału\"],\"wRkP2d\":[\"GIF\"],\"wbm86v\":[\"Wyświetlaj gdy użytkownicy dołączają lub opuszczają kanały\"],\"whqZ9r\":[\"Dodatkowe słowa lub frazy do podświetlenia\"],\"wm7RV4\":[\"Dźwięk powiadomienia\"],\"wz/Yoq\":[\"Twoje wiadomości mogą zostać przechwycone podczas przekazywania między serwerami\"],\"xCJdfg\":[\"Wyczyść\"],\"xUHRTR\":[\"Automatycznie uwierzytelniaj jako operator przy połączeniu\"],\"xWHwwQ\":[\"Blokady\"],\"xYilR2\":[\"Media\"],\"xceQrO\":[\"Obsługiwane są tylko bezpieczne WebSocket\"],\"xdtXa+\":[\"nazwa-kanału\"],\"xfXC7q\":[\"Kanały tekstowe\"],\"xlCYOE\":[\"Pobieranie kolejnych wiadomości...\"],\"xlhswE\":[\"Minimalna wartość wynosi \",[\"0\"]],\"xq97Ci\":[\"Dodaj słowo lub frazę...\"],\"xuRqRq\":[\"Limit klientów (+l)\"],\"xwF+7J\":[[\"0\"],\" pisze...\"],\"yNeucF\":[\"Ten serwer nie obsługuje rozszerzonych metadanych profilu (rozszerzenie IRCv3 METADATA). Dodatkowe pola, takie jak awatar, wyświetlana nazwa i status, nie są dostępne.\"],\"yPlrca\":[\"Awatar kanału\"],\"yQE2r9\":[\"Ładowanie\"],\"ySU+JY\":[\"twoj@email.com\"],\"yTX1Rt\":[\"Nazwa użytkownika operatora\"],\"yYOzWD\":[\"logi\"],\"yfx9Re\":[\"Hasło operatora IRC\"],\"ygCKqB\":[\"Zatrzymaj\"],\"ymDxJx\":[\"Nazwa użytkownika operatora IRC\"],\"yrpRsQ\":[\"Sortuj według nazwy\"],\"yz7wBu\":[\"Zamknij\"],\"zJw+jA\":[\"ustawia tryb: \",[\"0\"]],\"zebeLu\":[\"Wpisz nazwę użytkownika operatora\"],\"zpr0Bw\":[\"GZ-Line\"]}"); \ No newline at end of file +/*eslint-disable*/export const messages=JSON.parse("{\"+5VMrz\":[\"Nieprawidłowy format wzorca. Użyj formatu nick!user@host (dozwolone symbole wieloznaczne *)\"],\"+6NQQA\":[\"Ogólny kanał wsparcia\"],\"+6NyRG\":[\"Klient\"],\"+K0AvT\":[\"Rozłącz\"],\"+cyFdH\":[\"Domyślna wiadomość przy ustawianiu statusu nieobecności\"],\"+mVPqU\":[\"Renderuj formatowanie Markdown w wiadomościach\"],\"+vqCJH\":[\"Nazwa użytkownika Twojego konta do uwierzytelniania\"],\"+yPBXI\":[\"Wybierz plik\"],\"+zy2Nq\":[\"Typ\"],\"/09cao\":[\"Niskie bezpieczeństwo połączenia (poziom \",[\"securityLevel\"],\")\"],\"/3BQ4J\":[\"Użytkownicy spoza kanału nie mogą wysyłać do niego wiadomości\"],\"/6BzZF\":[\"Przełącz listę członków\"],\"/TNOPk\":[\"Użytkownik jest nieobecny\"],\"/XQgft\":[\"Odkryj\"],\"/cF7Rs\":[\"Głośność\"],\"/dqduX\":[\"Następna strona\"],\"/fc3q4\":[\"Wszystkie treści\"],\"/kISDh\":[\"Włącz dźwięki powiadomień\"],\"/n04sB\":[\"Kill\"],\"/rTz0M\":[\"Audio\"],\"/rfkZe\":[\"Odtwarzaj dźwięki dla wzmianek i wiadomości\"],\"0/0ZGA\":[\"Maska nazwy kanału\"],\"0D6j7U\":[\"Dowiedz się więcej o niestandardowych regułach →\"],\"0XsHcR\":[\"Wyrzuć użytkownika\"],\"0ZpE//\":[\"Sortuj według użytkowników\"],\"0bEPwz\":[\"Ustaw nieobecność\"],\"0dGkPt\":[\"Rozwiń listę kanałów\"],\"0gS7M5\":[\"Wyświetlana nazwa\"],\"0kS+M8\":[\"PrzykładSIEĆ\"],\"0rgoY7\":[\"Łącz się tylko z serwerami, które wybierzesz\"],\"0wdd7X\":[\"Dołącz\"],\"0wkVYx\":[\"Wiadomości prywatne\"],\"111uHX\":[\"Podgląd linku\"],\"196EG4\":[\"Usuń prywatną rozmowę\"],\"1DSr1i\":[\"Zarejestruj konto\"],\"1O/24y\":[\"Przełącz listę kanałów\"],\"1TNIig\":[\"Open\"],\"1VPJJ2\":[\"Ostrzeżenie o zewnętrznym linku\"],\"1ZC/dv\":[\"Brak nieprzeczytanych wzmianek lub wiadomości\"],\"1pO1zi\":[\"Nazwa serwera jest wymagana\"],\"1uwfzQ\":[\"Zobacz temat kanału\"],\"268g7c\":[\"Wpisz wyświetlaną nazwę\"],\"2FOFq1\":[\"Operatorzy serwerów w sieci mogą potencjalnie odczytywać Twoje wiadomości\"],\"2FYpfJ\":[\"Więcej\"],\"2HF1Y2\":[[\"inviter\"],\" zaprosił \",[\"target\"],\" do dołączenia do \",[\"channel\"]],\"2I70QL\":[\"Zobacz informacje o profilu użytkownika\"],\"2QYdmE\":[\"Użytkownicy:\"],\"2QpEjG\":[\"wyszedł\"],\"2YE223\":[\"Wiadomość na #\",[\"0\"],\" (Enter – nowa linia, Shift+Enter – wyślij)\"],\"2bimFY\":[\"Użyj hasła serwera\"],\"2iTmdZ\":[\"Pamięć lokalna:\"],\"2odkwe\":[\"Rygorystyczny – bardziej agresywna ochrona\"],\"2uDhbA\":[\"Wpisz nazwę użytkownika do zaproszenia\"],\"2ygf/L\":[\"← Wróć\"],\"2zEgxj\":[\"Szukaj GIFów...\"],\"3RdPhl\":[\"Zmień nazwę kanału\"],\"3THokf\":[\"Użytkownik z głosem\"],\"3TSz9S\":[\"Minimalizuj\"],\"3jBDvM\":[\"Wyświetlana nazwa kanału\"],\"3ryuFU\":[\"Opcjonalne raporty o błędach w celu ulepszenia aplikacji\"],\"3uBF/8\":[\"Zamknij przeglądarkę\"],\"3uwW8F\":[\"https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"3xf8Kz\":[\"Wprowadź nazwę konta...\"],\"4/Rr0R\":[\"Zaproś użytkownika do bieżącego kanału\"],\"4EZrJN\":[\"Reguły\"],\"4JJtW9\":[\"#przepełnienie\"],\"4NqeT4\":[\"Profil floodu (+F)\"],\"4RZQRK\":[\"Co teraz robisz?\"],\"4hfTrB\":[\"Nick\"],\"4n99LO\":[\"Już w \",[\"0\"]],\"4t6vMV\":[\"Automatycznie przełącz na jedną linię dla krótkich wiadomości\"],\"4vsHmf\":[\"Czas (min)\"],\"4x/Axu\":[\"Twój bouncer nie ma jeszcze żadnych sieci. Dodaj jedną, aby zacząć.\"],\"5+INAX\":[\"Podświetlaj wiadomości, w których jesteś wzmiankowany\"],\"5R5Pv/\":[\"Nazwa operatora\"],\"678PKt\":[\"Nazwa sieci\"],\"6Aih4U\":[\"Offline\"],\"6CO3WE\":[\"Hasło wymagane do dołączenia do kanału. Pozostaw puste, aby usunąć klucz.\"],\"6HhMs3\":[\"Wiadomość pożegnalna\"],\"6V3Ea3\":[\"Skopiowano\"],\"6lGV3K\":[\"Pokaż mniej\"],\"6yFOEi\":[\"Wprowadź hasło opera...\"],\"7+IHTZ\":[\"Nie wybrano pliku\"],\"73fnil\":[\"TLS\"],\"73hrRi\":[\"nick!user@host (np. spam*!*@*, *!*@badhost.com)\"],\"7QkKyN\":[\"Wyślij wiadomość prywatną\"],\"7U1W7c\":[\"Bardzo łagodny\"],\"7Y1YQj\":[\"Imię i nazwisko:\"],\"7YHArF\":[\"— otwórz w przeglądarce\"],\"7fjnVl\":[\"Szukaj użytkowników...\"],\"7jL88x\":[\"Usunąć tę wiadomość? Tej operacji nie można cofnąć.\"],\"7nGhhM\":[\"Co masz na myśli?\"],\"7sEpu1\":[\"Członkowie — \",[\"0\"]],\"7sNhEz\":[\"Nazwa użytkownika\"],\"8H0Q+x\":[\"Dowiedz się więcej o profilach →\"],\"8Phu0A\":[\"Wyświetlaj gdy użytkownicy zmieniają nick\"],\"8XTG9e\":[\"Wpisz hasło operatora\"],\"8XsV2J\":[\"Spróbuj wysłać ponownie\"],\"8ZsakT\":[\"Hasło\"],\"8kR84m\":[\"Zamierzasz otworzyć zewnętrzny link:\"],\"8lCgih\":[\"Usuń regułę\"],\"8p/xVT\":[[\"0\",\"plural\",{\"one\":[[\"1\"]],\"other\":[[\"2\"]]}]],\"8wRzac\":[[\"joinCount\",\"plural\",{\"one\":[\"dołączył\"],\"few\":[\"dołączył \",[\"joinCount\"],\" razy\"],\"many\":[\"dołączył \",[\"joinCount\"],\" razy\"],\"other\":[\"dołączył \",[\"joinCount\"],\" razy\"]}]],\"9BMLnJ\":[\"Ponownie połącz z serwerem\"],\"9OEgyT\":[\"Dodaj reakcję\"],\"9PQ8m2\":[\"G-Line (globalny ban)\"],\"9Qs99X\":[\"E-mail:\"],\"9QupBP\":[\"Usuń wzorzec\"],\"9W7tl5\":[\"(bez zmian)\"],\"9bG48P\":[\"Wysyłanie\"],\"9f5f0u\":[\"Pytania dotyczące prywatności? Skontaktuj się z nami:\"],\"9iweoP\":[\"Sieci na \",[\"0\"]],\"9unqs3\":[\"Nieobecny:\"],\"9v3hwv\":[\"Nie znaleziono serwerów.\"],\"9zb2WA\":[\"Łączenie\"],\"A1taO8\":[\"Szukaj\"],\"A2adVi\":[\"Wysyłaj powiadomienia o pisaniu\"],\"A9Rhec\":[\"Nazwa kanału\"],\"AWOSPo\":[\"Powiększ\"],\"AXSpEQ\":[\"Operator przy połączeniu\"],\"AeXO77\":[\"Konto\"],\"AhNP40\":[\"Przewijaj\"],\"Ai2U7L\":[\"Host\"],\"AjBQnf\":[\"Zmieniono nick\"],\"AmXVh6\":[\"https://example.com/avatar.png\"],\"AnRu/j\":[\"Anuluj odpowiedź\"],\"ApSx0O\":[\"Znaleziono \",[\"0\"],\" wiadomości pasujących do \\\"\",[\"searchQuery\"],\"\\\"\"],\"AxPAXW\":[\"Nie znaleziono wyników\"],\"AyNqAB\":[\"Wyświetl wszystkie zdarzenia serwera w czacie\"],\"B/QqGw\":[\"Z dala od klawiatury\"],\"B0sB2k\":[\"Tekst jawny\"],\"B8AaMI\":[\"To pole jest wymagane\"],\"BA2c49\":[\"Serwer nie obsługuje zaawansowanego filtrowania LIST\"],\"BDKt3I\":[[\"0\"],\", \",[\"1\"],\", \",[\"2\"],\" i \",[\"3\"],\" innych pisze...\"],\"BGul2A\":[\"Masz niezapisane zmiany. Czy na pewno chcesz zamknąć bez zapisywania?\"],\"BIf9fi\":[\"Twoja wiadomość statusu\"],\"BZz3md\":[\"Twoja strona internetowa\"],\"Bgm/H7\":[\"Zezwól na wpisywanie wielu linii tekstu\"],\"BiQIl1\":[\"Przypnij tę prywatną rozmowę\"],\"BlNZZ2\":[\"Kliknij, aby przejść do wiadomości\"],\"Bowq3c\":[\"Tylko operatorzy mogą zmieniać temat kanału\"],\"Btozzp\":[\"Ten obraz wygasł\"],\"Bycfjm\":[\"Łącznie: \",[\"0\"]],\"C6IBQc\":[\"Kopiuj cały JSON\"],\"C9L9wL\":[\"Zbieranie danych\"],\"CDq4wC\":[\"Moderuj użytkownika\"],\"CHVRxG\":[\"Wiadomość do @\",[\"0\"],\" (Shift+Enter – nowa linia)\"],\"CN9zdR\":[\"Nazwa operatora i hasło są wymagane\"],\"CW3sYa\":[\"Dodaj reakcję \",[\"emoji\"]],\"CaAkqd\":[\"Pokaż rozłączenia\"],\"CbvaYj\":[\"Zablokuj po nicku\"],\"CcK+Ft\":[\"PDF\"],\"Ce8q3L\":[\"Wybierz kanał\"],\"CsekCi\":[\"Normalny\"],\"D+NlUC\":[\"System\"],\"D28t6+\":[\"dołączył i wyszedł\"],\"DB8zMK\":[\"Zastosuj\"],\"DBcWHr\":[\"Niestandardowy plik dźwięku powiadomień\"],\"DTy9Xw\":[\"Podglądy mediów\"],\"Dj4pSr\":[\"Wybierz bezpieczne hasło\"],\"Du+zn+\":[\"Wyszukiwanie...\"],\"Du2T2f\":[\"Nie znaleziono ustawienia\"],\"DwsSVQ\":[\"Zastosuj filtry i odśwież\"],\"E3W/zd\":[\"Domyślny nick\"],\"E6nRW7\":[\"Kopiuj URL\"],\"E703RG\":[\"Tryby:\"],\"EAeu1Z\":[\"Wyślij zaproszenie\"],\"EFKJQT\":[\"Ustawienie\"],\"EGPQBv\":[\"Niestandardowe reguły floodu (+f)\"],\"ELik0r\":[\"Zobacz pełną politykę prywatności\"],\"EPbeC2\":[\"Zobacz lub edytuj temat kanału\"],\"EQCDNT\":[\"Wprowadź nazwę użytkownika opera...\"],\"EUvulZ\":[\"Znaleziono 1 wiadomość pasującą do \\\"\",[\"searchQuery\"],\"\\\"\"],\"EatZYJ\":[\"Następny obraz\"],\"EdQY6l\":[\"Brak\"],\"EnqLYU\":[\"Szukaj serwerów...\"],\"F0OKMc\":[\"Edytuj serwer\"],\"F6Int2\":[\"Włącz podświetlenia\"],\"FDoLyE\":[\"Maks. użytkowników\"],\"FUU/hZ\":[\"Kontroluje ilość zewnętrznych mediów ładowanych na czacie.\"],\"Fdp03t\":[\"wł\"],\"FfPWR0\":[\"Okno\"],\"FjkaiT\":[\"Pomniejsz\"],\"FlqOE9\":[\"Co to oznacza:\"],\"FolHNl\":[\"Zarządzaj swoim kontem i uwierzytelnianiem\"],\"Fp2Dif\":[\"Opuścił serwer\"],\"G5KmCc\":[\"GZ-Line (globalna Z-Line)\"],\"GDs0lz\":[\"<0>Ryzyko: Poufne informacje (wiadomości, prywatne rozmowy, dane uwierzytelniające) mogą być widoczne dla administratorów sieci lub atakujących znajdujących się między serwerami IRC.\"],\"GR+2I3\":[\"Dodaj maskę zaproszenia (np. nick!*@*, *!*@host.com)\"],\"GRLyMU\":[\"Zamknij wyskakujące powiadomienia serwera\"],\"GlHnXw\":[\"Zmiana nicku nie powiodła się: \",[\"error\"],\" \",[\"0\"]],\"GswZF3\":[\"Podgląd:\"],\"GtmO8/\":[\"od\"],\"GtuHUQ\":[\"Zmień nazwę tego kanału na serwerze. Wszyscy użytkownicy zobaczą nową nazwę.\"],\"GuGfFX\":[\"Przełącz wyszukiwanie\"],\"GxkJXS\":[\"Przesyłanie...\"],\"GzbwnK\":[\"Dołączył do kanału\"],\"GzsUDB\":[\"Rozszerzony profil\"],\"H/PnT8\":[\"Wstaw emoji\"],\"H6Izzl\":[\"Twój preferowany kod koloru\"],\"H9jIv+\":[\"Pokaż dołączenia/odejścia\"],\"HAKBY9\":[\"Prześlij pliki\"],\"HdE1If\":[\"Kanał\"],\"Hk4AW9\":[\"Twoja preferowana wyświetlana nazwa\"],\"HmHDk7\":[\"Wybierz członka\"],\"HrQzPU\":[\"Kanały na \",[\"networkName\"]],\"I2tXQ5\":[\"Wiadomość do @\",[\"0\"],\" (Enter – nowa linia, Shift+Enter – wyślij)\"],\"I6bw/h\":[\"Zablokuj użytkownika\"],\"I92Z+b\":[\"Włącz powiadomienia\"],\"I9D72S\":[\"Czy na pewno chcesz usunąć tę wiadomość? Tej operacji nie można cofnąć.\"],\"IA+1wo\":[\"Wyświetlaj gdy użytkownicy są wyrzucani z kanałów\"],\"IDwkJx\":[\"Operator IRC\"],\"ILlU+s\":[\"Info:\"],\"IUwGEM\":[\"Zapisz zmiany\"],\"IVeGK6\":[[\"0\"],\", \",[\"1\"],\" i \",[\"2\"],\" piszą...\"],\"IgrLD/\":[\"Pauza\"],\"Im6JED\":[\"WHISPER\"],\"ImOQa9\":[\"Odpowiedz\"],\"IoHMnl\":[\"Maksymalna wartość wynosi \",[\"0\"]],\"IvMj+0\":[\"Op\"],\"J28zul\":[\"Łączenie...\"],\"J5T9NW\":[\"Informacje o użytkowniku\"],\"J8Y5+z\":[\"Ups! Podział sieci! ⚠️\"],\"JBHkBA\":[\"Opuścił kanał\"],\"JCwL0Q\":[\"Wpisz powód (opcjonalnie)\"],\"JFciKP\":[\"Przełącz\"],\"JXGkhG\":[\"Zmień nazwę kanału (tylko dla operatorów)\"],\"JcD7qf\":[\"Więcej akcji\"],\"JdkA+c\":[\"Tajny (+s)\"],\"Jmu12l\":[\"Kanały serwera\"],\"JvQ++s\":[\"Włącz Markdown\"],\"K2jwh/\":[\"Brak dostępnych danych WHOIS\"],\"KAXSwC\":[\"Voice\"],\"KDfTdX\":[\"Usuń wiadomość\"],\"KKBlUU\":[\"Osadź\"],\"KM0pLb\":[\"Witamy na kanale!\"],\"KR6W2h\":[\"Przestań ignorować użytkownika\"],\"KV+Bi1\":[\"Tylko na zaproszenie (+i)\"],\"KdCtwE\":[\"Ile sekund monitorować aktywność floodowania przed zresetowaniem liczników\"],\"Kkezga\":[\"Hasło serwera\"],\"KsiQ/8\":[\"Użytkownicy muszą być zaproszeni, aby dołączyć do kanału\"],\"L+gB/D\":[\"Informacje o kanale\"],\"LC1a7n\":[\"Serwer IRC zgłosił, że jego połączenia między serwerami mają niski poziom bezpieczeństwa. Oznacza to, że gdy Twoje wiadomości są przekazywane między serwerami IRC w sieci, mogą nie być właściwie szyfrowane lub certyfikaty SSL/TLS mogą nie być poprawnie weryfikowane.\"],\"LNfLR5\":[\"Pokaż wyrzucenia\"],\"LP+1Z7\":[\"Dodaj sieć\"],\"LQb0W/\":[\"Pokaż wszystkie zdarzenia\"],\"LU7/yA\":[\"Alternatywna nazwa wyświetlana w interfejsie. Może zawierać spacje, emoji i znaki specjalne. Prawdziwa nazwa kanału (\",[\"channelName\"],\") nadal będzie używana w poleceniach IRC.\"],\"LUb9O7\":[\"Wymagany jest prawidłowy port serwera\"],\"Lb+BUl\":[\"https://example.com/avatar.jpg\"],\"LcET2C\":[\"Polityka prywatności\"],\"LcuSDR\":[\"Zarządzaj informacjami w profilu i metadanymi\"],\"LqLS9B\":[\"Pokaż zmiany nicku\"],\"LsDQt2\":[\"Ustawienia kanału\"],\"LtI9AS\":[\"Właściciel\"],\"LuNhhL\":[\"zareagował na tę wiadomość\"],\"M/AZNG\":[\"URL do obrazu Twojego awatara\"],\"M/WIer\":[\"Wyślij wiadomość\"],\"M8er/5\":[\"Nazwa:\"],\"MHk+7g\":[\"Poprzedni obraz\"],\"MRorGe\":[\"Wyślij wiadomość prywatną\"],\"MVbSGP\":[\"Okno czasowe (sekundy)\"],\"MkpcsT\":[\"Twoje wiadomości i ustawienia są przechowywane lokalnie na Twoim urządzeniu\"],\"MzPdC2\":[\"Hasło serwera (PASS)\"],\"N/hDSy\":[\"Oznacz jako bota – zazwyczaj 'on' lub puste\"],\"N6j2JH\":[\"Edytuj \",[\"0\"]],\"N7TQbE\":[\"Zaproś użytkownika do \",[\"channelName\"]],\"NCca/o\":[\"Wprowadź domyślny pseudonim...\"],\"Nqs6B9\":[\"Wyświetla wszystkie zewnętrzne media. Każdy URL może spowodować żądanie do nieznanego serwera.\"],\"Nt+9O7\":[\"Użyj WebSocket zamiast surowego TCP\"],\"NxIHzc\":[\"Rozłącz użytkownika\"],\"O+v/cL\":[\"Przeglądaj wszystkie kanały na serwerze\"],\"OCGpR4\":[\"(dziedzicz)\"],\"ODwSCk\":[\"Wyślij GIF\"],\"OGQ5kK\":[\"Konfiguruj dźwięki powiadomień i podświetlenia\"],\"OIPt1Z\":[\"Pokaż lub ukryj panel listy członków\"],\"OKSNq/\":[\"Bardzo rygorystyczny\"],\"ONWvwQ\":[\"Prześlij\"],\"OVKoQO\":[\"Hasło Twojego konta do uwierzytelniania\"],\"ObsidianIRC - Bringing IRC to the future\":[\"ObsidianIRC - Przenosimy IRC w przyszłość\"],\"OhCpra\":[\"Ustaw temat…\"],\"OkltoQ\":[\"Zablokuj \",[\"username\"],\" po nicku (uniemożliwia ponowne dołączenie z tym samym nickiem)\"],\"P+t/Te\":[\"Brak dodatkowych danych\"],\"P42Wcc\":[\"Bezpieczne\"],\"PD38l0\":[\"Podgląd awatara kanału\"],\"PD9mEt\":[\"Wpisz wiadomość...\"],\"PPqfdA\":[\"Otwórz ustawienia konfiguracji kanału\"],\"PSCjfZ\":[\"Temat, który będzie wyświetlany dla tego kanału. Wszyscy użytkownicy mogą zobaczyć temat.\"],\"PZCecv\":[\"Podgląd PDF\"],\"PeLgsC\":[[\"c\",\"plural\",{\"one\":[\"1 raz\"],\"few\":[[\"c\"],\" razy\"],\"many\":[[\"c\"],\" razy\"],\"other\":[[\"c\"],\" razy\"]}]],\"PguS2C\":[\"Dodaj maskę wyjątku (np. nick!*@*, *!*@host.com)\"],\"Pil5Ty\":[\"Wyświetlanie \",[\"displayedChannelsCount\"],\" z \",[\"0\"],\" kanałów\"],\"PqhVlJ\":[\"Zablokuj użytkownika (po hostmasce)\"],\"Q+chwU\":[\"Nazwa użytkownika:\"],\"Q3v9Wc\":[\"Tak, usuń\"],\"Q6hhn8\":[\"Preferencje\"],\"QF4a34\":[\"Proszę podać nazwę użytkownika\"],\"QGqSZ2\":[\"Kolor i formatowanie\"],\"QJQd1J\":[\"Edytuj profil\"],\"QSzGDE\":[\"Bezczynny\"],\"QUlny5\":[\"Witamy na \",[\"0\"],\"!\"],\"Qoq+GP\":[\"Czytaj więcej\"],\"QuSkCF\":[\"Filtruj kanały...\"],\"QwUrDZ\":[\"zmienił temat na: \",[\"topic\"]],\"R0UH07\":[\"Obraz \",[\"0\"],\" z \",[\"1\"]],\"R7SsBE\":[\"Wycisz\"],\"R8rf1X\":[\"Kliknij, aby ustawić temat\"],\"RArB3D\":[\"został wyrzucony z \",[\"channelName\"],\" przez \",[\"username\"]],\"RI3cWd\":[\"Odkryj świat IRC z ObsidianIRC\"],\"RMMaN5\":[\"Moderowany (+m)\"],\"RWw9Lg\":[\"Zamknij okno\"],\"RZ2BuZ\":[\"Rejestracja konta \",[\"account\"],\" wymaga weryfikacji: \",[\"message\"]],\"RySp6q\":[\"Ukryj komentarze\"],\"S5Togi\":[\"Wczytywanie sieci z Twojego bouncera…\"],\"SPKQTd\":[\"Nick jest wymagany\"],\"SPVjfj\":[\"Domyślnie 'brak powodu', jeśli pozostawione puste\"],\"SQKPvQ\":[\"Zaproś użytkownika\"],\"STmlpb\":[\"Back to network list\"],\"SkZcl+\":[\"Wybierz wstępnie zdefiniowany profil ochrony przed floodem. Profile te oferują zrównoważone ustawienia ochrony dla różnych przypadków użycia.\"],\"Slr+3C\":[\"Min. użytkowników\"],\"Spnlre\":[\"Zaprosiłeś \",[\"target\"],\" do dołączenia do \",[\"channel\"]],\"T/ckN5\":[\"Otwórz w przeglądarce mediów\"],\"T91vKp\":[\"Odtwórz\"],\"TV2Wdu\":[\"Dowiedz się, jak przetwarzamy Twoje dane i chronimy Twoją prywatność.\"],\"TgFpwD\":[\"Stosowanie...\"],\"TkzSFB\":[\"Brak zmian\"],\"TtserG\":[\"Wpisz prawdziwe imię i nazwisko\"],\"Ttz9J1\":[\"Wprowadź hasło...\"],\"Tz0i8g\":[\"Ustawienia\"],\"U3pytU\":[\"Administrator\"],\"UDb2YD\":[\"Zareaguj\"],\"UE4KO5\":[\"*kanał*\"],\"UGT5vp\":[\"Zapisz ustawienia\"],\"UV5hLB\":[\"Nie znaleziono żadnych banów\"],\"Uaj3Nd\":[\"Wiadomości statusu\"],\"Ue3uny\":[\"Domyślne (brak profilu)\"],\"UkARhe\":[\"Normalny – standardowa ochrona\"],\"Umn7Cj\":[\"Brak komentarzy. Bądź pierwszy!\"],\"UtUIRh\":[[\"0\"],\" starszych wiadomości\"],\"UwzP+U\":[\"Bezpieczne połączenie\"],\"V0/A4O\":[\"Właściciel kanału\"],\"V4qgxE\":[\"Utworzone przed (min temu)\"],\"V8yTm6\":[\"Wyczyść wyszukiwanie\"],\"VJMMyz\":[\"ObsidianIRC – IRC w nowoczesnym wydaniu\"],\"VJScHU\":[\"Powód\"],\"VLsmVV\":[\"Wycisz powiadomienia\"],\"VbyRUy\":[\"Komentarze\"],\"Vmx0mQ\":[\"Ustawione przez:\"],\"VqnIZz\":[\"Zobacz naszą politykę prywatności i zasady przetwarzania danych\"],\"VrMygG\":[\"Minimalna długość wynosi \",[\"0\"]],\"VrnTui\":[\"Twoje zaimki, widoczne w profilu\"],\"W8E3qn\":[\"Uwierzytelnione konto\"],\"WAakm9\":[\"Usuń kanał\"],\"WFxTHC\":[\"Dodaj maskę bana (np. nick!*@*, *!*@host.com)\"],\"WN1g9F\":[\"Host serwera jest wymagany\"],\"WRYdXW\":[\"Pozycja audio\"],\"WUOH5B\":[\"Ignoruj użytkownika\"],\"WWEXnZ\":[[\"0\",\"plural\",{\"one\":[\"Pokaż 1 więcej element\"],\"few\":[\"Pokaż \",[\"1\"],\" więcej elementów\"],\"many\":[\"Pokaż \",[\"1\"],\" więcej elementów\"],\"other\":[\"Pokaż \",[\"1\"],\" więcej elementów\"]}]],\"Weq9zb\":[\"Ogólne\"],\"Wfj7Sk\":[\"Wycisz lub odcisz dźwięki powiadomień\"],\"Wm7gbG\":[\"GitHub:\"],\"WyeHWY\":[\"*spam*\"],\"WzMCru\":[\"Profil użytkownika\"],\"X6S3lt\":[\"Szukaj ustawień, kanałów, serwerów...\"],\"XEHan5\":[\"Kontynuuj mimo to\"],\"XI1+wb\":[\"Nieprawidłowy format\"],\"XIXeuC\":[\"Wiadomość do @\",[\"0\"]],\"XMS+k4\":[\"Rozpocznij prywatną rozmowę\"],\"XWgxXq\":[\"Album\"],\"Xd7+IT\":[\"Odepnij prywatną rozmowę\"],\"Xm/s+u\":[\"Wyświetlanie\"],\"Xp2n93\":[\"Wyświetla media z zaufanego hosta plików Twojego serwera. Żadne żądania nie są wysyłane do zewnętrznych serwisów.\"],\"XvjC4F\":[\"Zapisywanie...\"],\"Y/qryO\":[\"Nie znaleziono użytkowników pasujących do wyszukiwania\"],\"YAqRpI\":[\"Rejestracja konta \",[\"account\"],\" powiodła się: \",[\"message\"]],\"YEfzvP\":[\"Chroniony temat (+t)\"],\"YQOn6a\":[\"Zwiń listę członków\"],\"YRCoE9\":[\"Operator kanału\"],\"YURQaF\":[\"Zobacz profil\"],\"YdBSvr\":[\"Kontroluj wyświetlanie mediów i zewnętrznych treści\"],\"Yj6U3V\":[\"Brak centralnego serwera:\"],\"YjvpGx\":[\"Zaimki\"],\"YqH4l4\":[\"Brak klucza\"],\"YyUPpV\":[\"Konto:\"],\"ZJSWfw\":[\"Wiadomość wyświetlana przy rozłączeniu z serwera\"],\"ZR1dJ4\":[\"Zaproszenia\"],\"ZdWg0V\":[\"Otwórz w przeglądarce\"],\"ZhRBbl\":[\"Szukaj wiadomości…\"],\"Zmcu3y\":[\"Zaawansowane filtry\"],\"a2/8e5\":[\"Temat ustawiony po (min temu)\"],\"aHKcKc\":[\"Poprzednia strona\"],\"aJTbXX\":[\"Hasło operatora\"],\"aQryQv\":[\"Wzorzec już istnieje\"],\"aW9pLN\":[\"Maksymalna liczba użytkowników dozwolona na kanale. Pozostaw puste, aby nie było limitu.\"],\"ah4fmZ\":[\"Wyświetla również podglądy z YouTube, Vimeo, SoundCloud i podobnych znanych serwisów.\"],\"aifXak\":[\"Brak mediów na tym kanale\"],\"ap2zBz\":[\"Łagodny\"],\"az8lvo\":[\"Wyłączone\"],\"azXSNo\":[\"Rozwiń listę członków\"],\"azdliB\":[\"Zaloguj się na konto\"],\"b26wlF\":[\"ona/jej\"],\"bD/+Ei\":[\"Rygorystyczny\"],\"bQ6BJn\":[\"Konfiguruj szczegółowe reguły ochrony przed floodem. Każda reguła określa, jaki rodzaj aktywności monitorować i jakie działanie podjąć po przekroczeniu progów.\"],\"beV7+y\":[\"Użytkownik otrzyma zaproszenie do dołączenia do \",[\"channelName\"],\".\"],\"bk84cH\":[\"Wiadomość o nieobecności\"],\"bkHdLj\":[\"Dodaj serwer IRC\"],\"bmQLn5\":[\"Dodaj regułę\"],\"bv4cFj\":[\"Transport\"],\"bwRvnp\":[\"Akcja\"],\"c8+EVZ\":[\"Zweryfikowane konto\"],\"cGYUlD\":[\"Nie wczytano żadnych podglądów mediów.\"],\"cLF98o\":[\"Pokaż komentarze (\",[\"commentCount\"],\")\"],\"cLKIDO\":[\"Brak dostępnych użytkowników\"],\"cSgpoS\":[\"Przypnij prywatną rozmowę\"],\"cde3ce\":[\"Wiadomość do <0>\",[\"0\"],\"\"],\"chQsxg\":[\"Kopiuj sformatowane wyjście\"],\"cl/A5J\":[\"Witamy na \",[\"__DEFAULT_IRC_SERVER_NAME__\"],\"!\"],\"cnGeoo\":[\"Usuń\"],\"coPLXT\":[\"Nie przechowujemy Twoich komunikatów IRC na naszych serwerach\"],\"crYH/6\":[\"Odtwarzacz SoundCloud\"],\"cv5DQb\":[\"nie ustawiono hosta\"],\"d3sis4\":[\"Dodaj serwer\"],\"d9aN5k\":[\"Usuń \",[\"username\"],\" z kanału\"],\"dEgA5A\":[\"Anuluj\"],\"dGi1We\":[\"Odepnij tę prywatną rozmowę\"],\"dJVuyC\":[\"opuścił \",[\"channelName\"],\" (\",[\"reason\"],\")\"],\"dMtLDE\":[\"do\"],\"dXqxlh\":[\"<0>⚠️ Zagrożenie bezpieczeństwa! To połączenie może być podatne na przechwycenie lub ataki typu man-in-the-middle.\"],\"da9Q/R\":[\"Zmieniono tryby kanału\"],\"dhJN3N\":[\"Pokaż komentarze\"],\"dj2xTE\":[\"Odrzuć powiadomienie\"],\"dpCzmC\":[\"Ustawienia ochrony przed floodem\"],\"e9dQpT\":[\"Czy chcesz otworzyć ten link w nowej karcie?\"],\"ePK91l\":[\"Edytuj\"],\"eYBDuB\":[\"Prześlij obraz lub podaj URL z opcjonalnym podstawieniem \",[\"size\"],\" dla dynamicznego rozmiaru\"],\"edBbee\":[\"Zablokuj \",[\"username\"],\" po hostmasce (uniemożliwia ponowne dołączenie z tego samego IP/hosta)\"],\"ekfzWq\":[\"Ustawienia użytkownika\"],\"elPDWs\":[\"Dostosuj swoje doświadczenie z klientem IRC\"],\"eu2osY\":[\"<0>💡 Zalecenie: Kontynuuj tylko jeśli ufasz temu serwerowi i rozumiesz ryzyko. Unikaj udostępniania poufnych informacji lub haseł przez to połączenie.\"],\"euEhbr\":[\"Kliknij, aby dołączyć do \",[\"channel\"]],\"ez3vLd\":[\"Włącz wieloliniowe wprowadzanie\"],\"f0J5Ki\":[\"Komunikacja między serwerami może używać nieszyfrowanych połączeń\"],\"f9BHJk\":[\"Ostrzeż użytkownika\"],\"fDOLLd\":[\"Nie znaleziono kanałów.\"],\"ffzDkB\":[\"Anonimowa analityka:\"],\"fq1GF9\":[\"Wyświetlaj gdy użytkownicy rozłączają się z serwera\"],\"gEF57C\":[\"Ten serwer obsługuje tylko jeden typ połączenia\"],\"gJuLUI\":[\"Lista ignorowanych\"],\"gNzMrk\":[\"Bieżący awatar\"],\"gjPWyO\":[\"Wprowadź pseudonim...\"],\"gz6UQ3\":[\"Maksymalizuj\"],\"h6/IMX\":[\"Dodaj swoją pierwszą sieć\"],\"h6razj\":[\"Wyklucz maskę nazwy kanału\"],\"hG6jnw\":[\"Nie ustawiono tematu\"],\"hG89Ed\":[\"Obraz\"],\"hZ6znB\":[\"Port\"],\"ha+Bz5\":[\"np. 100:1440\"],\"hehnjM\":[\"Ilość\"],\"hzdLuQ\":[\"Tylko użytkownicy z głosem lub wyżej mogą mówić\"],\"i0qMbr\":[\"Strona główna\"],\"iDNBZe\":[\"Powiadomienia\"],\"iH8pgl\":[\"Wróć\"],\"iL9SZg\":[\"Zablokuj użytkownika (po nicku)\"],\"iNt+3c\":[\"Wróć do obrazu\"],\"iQvi+a\":[\"Nie ostrzegaj mnie o niskim poziomie bezpieczeństwa połączeń dla tego serwera\"],\"iSLIjg\":[\"Połącz\"],\"iWXkHH\":[\"Halfop\"],\"iZeTtp\":[\"Host serwera\"],\"idD8Ev\":[\"Zapisano\"],\"iivqkW\":[\"Zalogowany od\"],\"ij+Elv\":[\"Podgląd obrazu\"],\"ilIWp7\":[\"Przełącz powiadomienia\"],\"iuaqvB\":[\"Użyj * jako symbolu wieloznacznego. Przykłady: baduser!*@*, *!*@spammer.com, troll*!*@*\"],\"ixkTse\":[\"Bot\"],\"j2DGR0\":[\"Zablokuj po hostmasce\"],\"jA4uoI\":[\"Temat:\"],\"jLXxGK\":[\"https://example.com\"],\"jPSk57\":[\"Powód (opcjonalnie)\"],\"jUV7CU\":[\"Prześlij awatar\"],\"jW5Uwh\":[\"Kontroluj ilość wczytywanego zewnętrznego medium. Wyłączone / Bezpieczne / Zaufane źródła / Wszystkie treści.\"],\"jXzms5\":[\"Opcje załącznika\"],\"jZlrte\":[\"Kolor\"],\"jfC/xh\":[\"Kontakt\"],\"jywMpv\":[\"#nowa-nazwa-kanału\"],\"k112DD\":[\"Wczytaj starsze wiadomości\"],\"k3ID0F\":[\"Filtruj członków…\"],\"k65gsE\":[\"Szczegóły\"],\"k7Zgob\":[\"Anuluj połączenie\"],\"kAVx5h\":[\"Nie znaleziono zaproszeń\"],\"kCLEPU\":[\"Połączony z\"],\"kF5LKb\":[\"Ignorowane wzorce:\"],\"kGeOx/\":[\"Dołącz do \",[\"0\"]],\"kITKr8\":[\"Wczytywanie trybów kanału...\"],\"kPpPsw\":[\"Jesteś operatorem IRC\"],\"kWJmRL\":[\"Ty\"],\"kfcRb0\":[\"Awatar\"],\"kjMqSj\":[\"Kopiuj JSON\"],\"krViRy\":[\"Kliknij, aby skopiować jako JSON\"],\"ks71ra\":[\"Wyjątki\"],\"kw4lRv\":[\"Pół-operator kanału\"],\"kxgIRq\":[\"Wybierz lub dodaj kanał, aby zacząć.\"],\"ky6dWe\":[\"Podgląd awatara\"],\"l+GxCv\":[\"Wczytywanie kanałów...\"],\"l+IUVW\":[\"Weryfikacja konta \",[\"account\"],\" powiodła się: \",[\"message\"]],\"l/siQz\":[[\"reconnectCount\",\"plural\",{\"one\":[\"ponownie połączył\"],\"few\":[\"ponownie połączył \",[\"reconnectCount\"],\" razy\"],\"many\":[\"ponownie połączył \",[\"reconnectCount\"],\" razy\"],\"other\":[\"ponownie połączył \",[\"reconnectCount\"],\" razy\"]}]],\"l5jmzx\":[[\"0\"],\" i \",[\"1\"],\" piszą...\"],\"lHy8N5\":[\"Wczytywanie kolejnych kanałów...\"],\"lbpf14\":[\"Dołącz do \",[\"value\"]],\"lfFsZ4\":[\"Kanały\"],\"lkNdiH\":[\"Nazwa konta\"],\"ln500L\":[\"ObsidianIRC\"],\"lnCMdg\":[\"Prześlij obraz\"],\"loQxaJ\":[\"Wróciłem\"],\"lvfaxv\":[\"STRONA GŁÓWNA\"],\"m0oxpP\":[\"Libera Chat\"],\"m16xKo\":[\"Dodaj\"],\"m8flAk\":[\"Podgląd (jeszcze nie przesłany)\"],\"mEPxTp\":[\"<0>⚠️ Uwaga! Otwieraj tylko linki z zaufanych źródeł. Złośliwe linki mogą narazić Twoje bezpieczeństwo lub prywatność.\"],\"mHGdhG\":[\"Informacje o serwerze\"],\"mHS8lb\":[\"Wiadomość na #\",[\"0\"]],\"mMYBD9\":[\"Szeroki – szerszy zakres ochrony\"],\"mTGsPd\":[\"Temat kanału\"],\"mU8j6O\":[\"Brak zewnętrznych wiadomości (+n)\"],\"mZp8FL\":[\"Automatyczny powrót do jednej linii\"],\"mdQu8G\":[\"TwójNick\"],\"miSSBQ\":[\"Komentarze (\",[\"commentCount\"],\")\"],\"mvyLSy\":[\"Użytkownik jest uwierzytelniony\"],\"mwtcGl\":[\"Zamknij komentarze\"],\"myL0MR\":[\"Usunąć tę sieć?\"],\"mzI/c+\":[\"Pobierz\"],\"n3fGRk\":[\"ustawione przez \",[\"0\"]],\"nE9jsU\":[\"Łagodny – mniej agresywna ochrona\"],\"nNflMD\":[\"Opuść kanał\"],\"nPXkBi\":[\"Wczytywanie danych WHOIS...\"],\"nQnxxF\":[\"Wiadomość na #\",[\"0\"],\" (Shift+Enter – nowa linia)\"],\"nWMRxa\":[\"Odepnij\"],\"nkC032\":[\"Brak profilu floodu\"],\"o69z4d\":[\"Wyślij wiadomość ostrzegawczą do \",[\"username\"]],\"o9ylQi\":[\"Wyszukaj GIFy, aby rozpocząć\"],\"oFGkER\":[\"Powiadomienia serwera\"],\"oOi11l\":[\"Przewiń na dół\"],\"oQEzQR\":[\"Nowy DM\"],\"oXOSPE\":[\"Online\"],\"oal760\":[\"Ataki man-in-the-middle na połączenia serwera są możliwe\"],\"oeqmmJ\":[\"Zaufane źródła\"],\"ovBPCi\":[\"Domyślne\"],\"p0Z69r\":[\"Wzorzec nie może być pusty\"],\"p1KgtK\":[\"Nie udało się załadować audio\"],\"p59pEv\":[\"Dodatkowe szczegóły\"],\"p7sRI6\":[\"Informuj innych, gdy piszesz\"],\"pBm1od\":[\"Tajny kanał\"],\"pNmiXx\":[\"Twój domyślny nick dla wszystkich serwerów\"],\"pUUo9G\":[\"Hostname:\"],\"pVGPmz\":[\"Hasło konta\"],\"peNE68\":[\"Stały\"],\"plhHQt\":[\"Brak danych\"],\"pm6+q5\":[\"Ostrzeżenie o bezpieczeństwie\"],\"pn5qSs\":[\"Dodatkowe informacje\"],\"q0cR4S\":[\"jest teraz znany jako **\",[\"newNick\"],\"**\"],\"qFcunY\":[\"Kanał nie będzie widoczny w poleceniach LIST ani NAMES\"],\"qLpTm/\":[\"Usuń reakcję \",[\"emoji\"]],\"qVkGWK\":[\"Przypnij\"],\"qY8wNa\":[\"Strona internetowa\"],\"qb0xJ7\":[\"Użyj symboli wieloznacznych: * pasuje do dowolnej sekwencji, ? pasuje do dowolnego pojedynczego znaku. Przykłady: nick!*@*, *!*@host.com, *!*user@*\"],\"qhzpRq\":[\"Klucz kanału (+k)\"],\"qtoOYG\":[\"Brak limitu\"],\"r1W2AS\":[\"Obraz z serwera plików\"],\"rIPR2O\":[\"Temat ustawiony przed (min temu)\"],\"rMMSYo\":[\"Maksymalna długość wynosi \",[\"0\"]],\"rWtzQe\":[\"Sieć rozdzieliła się i ponownie połączyła. ✅\"],\"rYG2u6\":[\"Proszę czekać...\"],\"rdUucN\":[\"Podgląd\"],\"rjGI/Q\":[\"Prywatność\"],\"rk8iDX\":[\"Wczytywanie GIFów...\"],\"rn6SBY\":[\"Odcisz\"],\"s/UKqq\":[\"Został wyrzucony z kanału\"],\"s8cATI\":[\"dołączył do \",[\"channelName\"]],\"sCO9ue\":[\"Połączenie z <0>\",[\"serverName\"],\" ma następujące problemy z bezpieczeństwem:\"],\"sGH11W\":[\"Serwer\"],\"sHI1H+\":[\"jest teraz znany jako **\",[\"newNick\"],\"**\"],\"sJyV04\":[[\"inviter\"],\" zaprosił cię do dołączenia do \",[\"channel\"]],\"sUBSbK\":[\"Brak sieci nadrzędnych.\"],\"sby+1/\":[\"Kliknij, aby skopiować\"],\"sfN25C\":[\"Twoje prawdziwe imię i nazwisko\"],\"sliuzR\":[\"Otwórz link\"],\"sqrO9R\":[\"Niestandardowe wzmianki\"],\"sr6RdJ\":[\"Wieloliniowy przy Shift+Enter\"],\"swrCpB\":[\"Kanał został przemianowany z \",[\"oldName\"],\" na \",[\"newName\"],\" przez \",[\"user\"],[\"0\"]],\"sxkWRg\":[\"Zaawansowane\"],\"t/YqKh\":[\"Usuń\"],\"t47eHD\":[\"Twój unikalny identyfikator na tym serwerze\"],\"tAkAh0\":[\"URL z opcjonalnym podstawieniem \",[\"size\"],\" dla dynamicznego rozmiaru. Przykład: https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"tXLJS3\":[\"Pokaż lub ukryj panel listy kanałów\"],\"tfDRzk\":[\"Zapisz\"],\"tiBsJk\":[\"opuścił \",[\"channelName\"]],\"tt4/UD\":[\"wyszedł (\",[\"reason\"],\")\"],\"u0TcnO\":[\"Nick {nick} jest już używany, ponawiam z {newNick}\"],\"u0a8B4\":[\"Uwierzytelnij się jako operator IRC, aby uzyskać dostęp administracyjny\"],\"u0rWFU\":[\"Utworzone po (min temu)\"],\"u72w3t\":[\"Użytkownicy i wzorce do ignorowania\"],\"u7jc2L\":[\"wyszedł\"],\"uAQUqI\":[\"Status\"],\"uB85T3\":[\"Zapis nieudany: \",[\"msg\"]],\"uV3DOL\":[\"G-Line\"],\"uW3lLI\":[\"Serwery IRC:\"],\"usSSr/\":[\"Poziom powiększenia\"],\"v7uvcf\":[\"Oprogramowanie:\"],\"vE8kb+\":[\"Użyj Shift+Enter dla nowych linii (Enter wysyła)\"],\"vERlcd\":[\"Profil\"],\"vK0RL8\":[\"Brak tematu\"],\"vSJd18\":[\"Wideo\"],\"vXIe7J\":[\"Język\"],\"vaHYxN\":[\"Prawdziwe imię i nazwisko\"],\"vhjbKr\":[\"Nieobecny\"],\"w/nogd\":[[\"0\"],\" network\",[\"1\"],\" — pick one to join\"],\"w4NYox\":[\"klient \",[\"title\"]],\"w8xQRx\":[\"Nieprawidłowa wartość\"],\"wFjjxZ\":[\"został wyrzucony z \",[\"channelName\"],\" przez \",[\"username\"],\" (\",[\"reason\"],\")\"],\"wGjaGl\":[\"Nie znaleziono wyjątków od bana\"],\"wPrGnM\":[\"Administrator kanału\"],\"wRkP2d\":[\"GIF\"],\"wbm86v\":[\"Wyświetlaj gdy użytkownicy dołączają lub opuszczają kanały\"],\"whqZ9r\":[\"Dodatkowe słowa lub frazy do podświetlenia\"],\"wm7RV4\":[\"Dźwięk powiadomienia\"],\"wz/Yoq\":[\"Twoje wiadomości mogą zostać przechwycone podczas przekazywania między serwerami\"],\"xCJdfg\":[\"Wyczyść\"],\"xUHRTR\":[\"Automatycznie uwierzytelniaj jako operator przy połączeniu\"],\"xWHwwQ\":[\"Blokady\"],\"xYilR2\":[\"Media\"],\"xceQrO\":[\"Obsługiwane są tylko bezpieczne WebSocket\"],\"xdtXa+\":[\"nazwa-kanału\"],\"xfXC7q\":[\"Kanały tekstowe\"],\"xlCYOE\":[\"Pobieranie kolejnych wiadomości...\"],\"xlhswE\":[\"Minimalna wartość wynosi \",[\"0\"]],\"xq97Ci\":[\"Dodaj słowo lub frazę...\"],\"xuRqRq\":[\"Limit klientów (+l)\"],\"xwF+7J\":[[\"0\"],\" pisze...\"],\"yJztBY\":[\"Usuń sieć\"],\"yNeucF\":[\"Ten serwer nie obsługuje rozszerzonych metadanych profilu (rozszerzenie IRCv3 METADATA). Dodatkowe pola, takie jak awatar, wyświetlana nazwa i status, nie są dostępne.\"],\"yPlrca\":[\"Awatar kanału\"],\"yQE2r9\":[\"Ładowanie\"],\"ySU+JY\":[\"twoj@email.com\"],\"yTX1Rt\":[\"Nazwa użytkownika operatora\"],\"yYOzWD\":[\"logi\"],\"yfx9Re\":[\"Hasło operatora IRC\"],\"ygCKqB\":[\"Zatrzymaj\"],\"ymDxJx\":[\"Nazwa użytkownika operatora IRC\"],\"yrpRsQ\":[\"Sortuj według nazwy\"],\"yz7wBu\":[\"Zamknij\"],\"zJw+jA\":[\"ustawia tryb: \",[\"0\"]],\"zebeLu\":[\"Wpisz nazwę użytkownika operatora\"],\"zpr0Bw\":[\"GZ-Line\"]}"); \ No newline at end of file diff --git a/src/locales/pl/messages.po b/src/locales/pl/messages.po index e8939fb5..e49a4bd9 100644 --- a/src/locales/pl/messages.po +++ b/src/locales/pl/messages.po @@ -22,6 +22,16 @@ msgstr "ObsidianIRC - Przenosimy IRC w przyszłość" msgid "— open in viewer" msgstr "— otwórz w przeglądarce" +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx +msgid "(inherit)" +msgstr "(dziedzicz)" + +#: src/components/ui/BouncerNetworkForm.tsx +msgid "(unchanged)" +msgstr "(bez zmian)" + #. placeholder {0}: filteredMessages.length #. placeholder {1}: import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import type * as React from "react"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { SCROLL_TOLERANCE, useScrollToBottom, } from "../../hooks/useScrollToBottom"; import { groupConsecutiveEvents } from "../../lib/eventGrouping"; import ircClient from "../../lib/ircClient"; import useStore from "../../store"; import type { Message as MessageType } from "../../types"; import { CollapsedEventMessage } from "../message/CollapsedEventMessage"; import { MessageItem } from "../message/MessageItem"; import LoadingSpinner from "../ui/LoadingSpinner"; import { ScrollToBottomButton } from "../ui/ScrollToBottomButton"; export const DEFAULT_VISIBLE_MESSAGE_COUNT = 100; // Stable empty array — prevents selector from returning a new [] on every render // when the channel has no messages yet (undefined ?? [] would create a new ref each time). const EMPTY_MESSAGES: import("../../types").Message[] = []; export interface ChannelMessageListHandle { setAtBottom: () => void; scrollToBottom: () => void; getScrollState: () => { scrollTop: number; isAtBottom: boolean; visibleCount: number; }; } interface ChannelMessageListProps { channelKey: string; serverId: string; channelId: string | null; privateChatId: string | null; isActive: boolean; searchQuery: string; isMemberListVisible: boolean; onReply: (msg: MessageType | null) => void; onUsernameContextMenu: ( e: React.MouseEvent, username: string, serverId: string, channelId: string, avatarEl?: Element | null, ) => void; onIrcLinkClick: (url: string) => void; onReactClick: (msg: MessageType, el: Element) => void; onReactionUnreact: (emoji: string, msg: MessageType) => void; onOpenReactionModal: ( msg: MessageType, position: { x: number; y: number }, ) => void; onDirectReaction: (emoji: string, msg: MessageType) => void; onRedactMessage: (msg: MessageType) => void; onOpenProfile: (username: string) => void; joinChannel: (serverId: string, channelName: string) => void; onClearSearch: () => void; highlightedMessageId?: string; // undefined = first visit; null = was at bottom; object = restore to saved position initialScrollState?: { scrollTop: number; visibleCount: number } | null; } export const ChannelMessageList = forwardRef< ChannelMessageListHandle, ChannelMessageListProps >( ( { channelKey, serverId, channelId, privateChatId, isActive, searchQuery, isMemberListVisible, onReply, onUsernameContextMenu, onIrcLinkClick, onReactClick, onReactionUnreact, onOpenReactionModal, onDirectReaction, onRedactMessage, onOpenProfile, joinChannel, onClearSearch, highlightedMessageId, initialScrollState, }, ref, ) => { const { t } = useLingui(); const [visibleMessageCount, setVisibleMessageCount] = useState( initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT, ); // Ref mirror so getScrollState closure always reads the current value without needing it as a dep. const visibleMessageCountRef = useRef(visibleMessageCount); visibleMessageCountRef.current = visibleMessageCount; // Distinguishes initial join (full-screen spinner) from subsequent "load more" (button spinner). const [isFetchingMore, setIsFetchingMore] = useState(false); const isFetchingMoreRef = useRef(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const messagesInnerRef = useRef(null); // prev scrollHeight for prepend delta-correction. const prevScrollHeightRef = useRef(0); // Ref mirror of isScrolledUp — lets useLayoutEffect closures read current value // without listing isScrolledUp as a dep (which would re-run effects on every scroll). const isScrolledUpRef = useRef(false); const prevFilteredLengthRef = useRef(0); const prevFirstMsgIdRef = useRef(null); // Set by the window-growth layoutEffect (or button handler) when a true prepend is detected. // Consumed by the delta-correction layoutEffect one render later (after visibleCount grows). // Using a flag instead of tracking displayedMessages[0]?.id because slice(-N) slides the // window on every bottom append, changing displayedMessages[0] even for non-prepend renders. const pendingPrependRef = useRef(false); // Shared scrollHeight baseline between the delta-correction layout effect and the inner // ResizeObserver. When scrollTop is corrected after a prepend, we update this so the RO's // "was at bottom" check is not fooled by the adjusted scrollTop vs its stale prevSH. const resizeObserverPrevSHRef = useRef(0); const channelMessages = useStore( useCallback( (state) => state.messages[channelKey] ?? EMPTY_MESSAGES, [channelKey], ), ); const servers = useStore((state) => state.servers); const mobileViewActiveColumn = useStore( (state) => state.ui.mobileViewActiveColumn, ); const channel = useMemo( () => channelId ? (servers .find((s) => s.id === serverId) ?.channels.find((c) => c.id === channelId) ?? null) : null, [servers, serverId, channelId], ); const { isScrolledUp, wasAtBottomRef, scrollToBottom } = useScrollToBottom( messagesContainerRef, messagesEndRef, { channelId: `${channelId || privateChatId}-${isMemberListVisible}` }, ); // Snapshot of the last known scroll position captured while the container was visible. // getScrollState() reads this instead of the live DOM because React commits display:none // before running cleanup effects, collapsing scrollTop/scrollHeight/clientHeight to 0. const lastScrollTopRef = useRef(initialScrollState?.scrollTop ?? 0); useEffect(() => { const container = messagesContainerRef.current; if (!container) return; const onScroll = () => { if (container.clientHeight > 0) lastScrollTopRef.current = container.scrollTop; }; container.addEventListener("scroll", onScroll, { passive: true }); return () => container.removeEventListener("scroll", onScroll); }, []); // Restore scroll position when a keep-alive channel transitions from hidden to visible. // display:none may reset scrollTop to 0; lastScrollTopRef was captured while visible. const prevActiveRef = useRef(isActive); useLayoutEffect(() => { if (isActive && !prevActiveRef.current) { const container = messagesContainerRef.current; if (container && lastScrollTopRef.current > 0) { container.scrollTop = lastScrollTopRef.current; } } prevActiveRef.current = isActive; }, [isActive]); useImperativeHandle(ref, () => ({ setAtBottom: () => { wasAtBottomRef.current = true; }, scrollToBottom, getScrollState: () => ({ scrollTop: lastScrollTopRef.current, isAtBottom: wasAtBottomRef.current, visibleCount: visibleMessageCountRef.current, }), })); const filteredMessages = useMemo(() => { if (!searchQuery.trim()) return channelMessages; const query = searchQuery.toLowerCase(); return channelMessages.filter( (msg) => msg.content.toLowerCase().includes(query) || msg.userId.toLowerCase().includes(query), ); }, [channelMessages, searchQuery]); useEffect(() => { isScrolledUpRef.current = isScrolledUp; // When the user returns to the bottom, shrink the window back to the base so // slice(-N) resumes trimming old messages from the top (memory optimization). // Only shrink if we grew above the base — preserves a sub-default saved visibleCount. if (!isScrolledUp) { setVisibleMessageCount((prev) => prev > DEFAULT_VISIBLE_MESSAGE_COUNT ? DEFAULT_VISIBLE_MESSAGE_COUNT : prev, ); } }, [isScrolledUp]); // Reset ref-tracked windowing state when switching channels. // visibleMessageCount is NOT reset here — useState(initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT) // already initializes it correctly on mount, and this effect runs once on mount for the // same channelKey (each instance is bound to exactly one channel by the parent key={}). // biome-ignore lint/correctness/useExhaustiveDependencies: intentional full reset on channel change useEffect(() => { prevFilteredLengthRef.current = 0; prevFirstMsgIdRef.current = null; prevScrollHeightRef.current = 0; pendingPrependRef.current = false; resizeObserverPrevSHRef.current = 0; }, [channelKey]); const displayedMessages = useMemo(() => { if (searchQuery.trim()) return filteredMessages; return filteredMessages.slice(-visibleMessageCount); }, [filteredMessages, visibleMessageCount, searchQuery]); const locallyHidden = filteredMessages.length > displayedMessages.length; const serverHasMore = channel?.hasMoreHistory === true; const hasMoreMessages = locallyHidden || serverHasMore; const eventGroups = useMemo( () => groupConsecutiveEvents(displayedMessages), [displayedMessages], ); const isLoadingHistory = channel?.isLoadingHistory ?? false; // Scroll to bottom on initial mount, unless a saved position was passed in. // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount only useEffect(() => { const container = messagesContainerRef.current; if (!container) return; if (initialScrollState) { container.scrollTop = initialScrollState.scrollTop; lastScrollTopRef.current = initialScrollState.scrollTop; wasAtBottomRef.current = false; } else { container.scrollTop = container.scrollHeight; lastScrollTopRef.current = container.scrollHeight; wasAtBottomRef.current = true; } }, []); // Scroll to bottom after initial join history loads; clear fetch spinner at batch end. const wasLoadingHistoryRef = useRef(false); // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is stable via useCallback; refs and setters are stable useLayoutEffect(() => { if (wasLoadingHistoryRef.current && !isLoadingHistory) { if (isFetchingMoreRef.current) { // delta correction for scroll position is handled by useLayoutEffect([displayedMessages]) isFetchingMoreRef.current = false; setIsFetchingMore(false); } else { scrollToBottom(); wasAtBottomRef.current = true; } } wasLoadingHistoryRef.current = isLoadingHistory; }, [isLoadingHistory]); // When older messages are prepended, grow the window so they enter displayedMessages. // When new messages arrive at the bottom while the user is scrolled up, also grow the // window to keep the current top messages visible — slice(-N) otherwise slides the // window forward and hides them, incrementing the "N older messages" counter on every // incoming message. Only let the slice trim from the top when the user is at the bottom // (where auto-scroll handles keeping them current). useLayoutEffect(() => { const newLength = filteredMessages.length; const newFirstId = filteredMessages[0]?.id ?? null; const delta = newLength - prevFilteredLengthRef.current; if (prevFilteredLengthRef.current > 0 && delta > 0) { if (newFirstId !== prevFirstMsgIdRef.current) { // Messages prepended (load-more): signal delta-correction to compensate scrollTop. pendingPrependRef.current = true; setVisibleMessageCount((prev) => prev + delta); } else if (isScrolledUpRef.current) { // Messages appended at bottom while user is scrolled up reading history. // Expand the window to prevent top messages from dropping out of the slice. setVisibleMessageCount((prev) => prev + delta); } } prevFilteredLengthRef.current = newLength; prevFirstMsgIdRef.current = newFirstId; }, [filteredMessages]); // Compensate scrollTop when content is prepended above the viewport. // biome-ignore lint/correctness/useExhaustiveDependencies: runs on every displayedMessages render to capture the resulting scrollHeight; refs are stable useLayoutEffect(() => { const container = messagesContainerRef.current; if (!container) return; // Skip while container is display:none — scrollHeight collapses to 0 and would // poison prevScrollHeightRef, causing a huge spurious delta on the next visible render. if (container.clientHeight === 0) return; const prevHeight = prevScrollHeightRef.current; const newHeight = container.scrollHeight; // Only correct when a true load-more prepend happened (flag set by the window-growth // layoutEffect or button handler). Bottom appends slide the slice(-N) window which also // changes displayedMessages[0] — ID-comparison can't distinguish the two cases. const wasPrepend = pendingPrependRef.current; // Only consume the flag when scrollHeight actually changed — the server-side load-more // path goes through two renders: Render A (filteredMessages grows, visibleCount unchanged, // same displayedMessages content, same scrollHeight) then Render B (visibleCount grows, // new messages enter displayedMessages, scrollHeight grows). The flag must survive Render A // so it's still set when Render B fires the actual correction. if (wasPrepend && newHeight !== prevHeight) { pendingPrependRef.current = false; } if ( isScrolledUpRef.current && prevHeight > 0 && newHeight > prevHeight && wasPrepend ) { const delta = newHeight - prevHeight; container.scrollTop += delta; resizeObserverPrevSHRef.current = newHeight; } prevScrollHeightRef.current = newHeight; }, [displayedMessages]); // Re-stick to bottom when inner message content grows (media/audio previews loading). // Uses prevScrollHeight instead of wasAtBottomRef to avoid stale-flag race where the // ref is true while the user is actively scrolling up. // When the container width changes (member list toggle, window resize), text reflows // and scrollHeight changes; preserve proportional scroll position for scrolled-up users. // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is a stable ref useEffect(() => { const container = messagesContainerRef.current; const inner = messagesInnerRef.current; if (!inner || !container) return; resizeObserverPrevSHRef.current = container.scrollHeight; let prevClientWidth = container.clientWidth; const observer = new ResizeObserver(() => { if (container.clientHeight === 0) return; // Effect may re-initialize while container is display:none (ref=0). // Re-seed with current dimensions and skip — no reliable "was at bottom" data. if (resizeObserverPrevSHRef.current === 0) { resizeObserverPrevSHRef.current = container.scrollHeight; prevClientWidth = container.clientWidth; return; } const currentClientWidth = container.clientWidth; const widthChanged = currentClientWidth !== prevClientWidth; prevClientWidth = currentClientWidth; const prevSH = resizeObserverPrevSHRef.current; const wasAtPrevBottom = container.scrollTop + container.clientHeight >= prevSH - SCROLL_TOLERANCE; resizeObserverPrevSHRef.current = container.scrollHeight; if (wasAtPrevBottom) { scrollToBottom(); } else if (widthChanged && prevSH > 0) { const ratio = container.scrollTop / prevSH; container.scrollTop = Math.round(ratio * container.scrollHeight); } }); observer.observe(inner); return () => observer.disconnect(); }, [isLoadingHistory, channelId, privateChatId]); // Auto-scroll on new messages — skip when this channel is hidden (display:none). // biome-ignore lint/correctness/useExhaustiveDependencies: only scroll when messages change, not when isActive changes useEffect(() => { if (!isActive) return; const isNarrowView = window.matchMedia("(max-width: 768px)").matches; const isChatVisible = !isNarrowView || mobileViewActiveColumn === "chatView"; if (wasAtBottomRef.current && isChatVisible) { scrollToBottom(); } }, [displayedMessages, mobileViewActiveColumn, scrollToBottom, isActive]); return ( <>
{isLoadingHistory && !isFetchingMore ? (
) : (
{hasMoreMessages && !searchQuery && (
)} {searchQuery && (
{plural(filteredMessages.length, { one: t`Found 1 message matching "${searchQuery}"`, other: t`Found ${filteredMessages.length} messages matching "${searchQuery}"`, })}
)} {eventGroups.map((group) => { if (group.type === "eventGroup") { const firstId = group.messages[0]?.id || ""; const lastId = group.messages[group.messages.length - 1]?.id || ""; const groupKey = `group-${firstId}-${lastId}`; return ( ); } const message = group.messages[0]; const originalIndex = channelMessages.findIndex( (m) => m.id === message.id, ); const previousMessage = channelMessages[originalIndex - 1]; const showHeader = !previousMessage || previousMessage.type !== "message" || previousMessage.userId !== message.userId || new Date(message.timestamp).getTime() - new Date(previousMessage.timestamp).getTime() > 5 * 60 * 1000; return ( ); })}
)}
); }, ); ChannelMessageList.displayName = "ChannelMessageList"; // Wrap with memo so hidden keep-alive channels skip re-renders when their props // haven't changed (e.g. when messageText changes in the input — the only thing // that changes on typing is local state inside ChatArea, not the props we pass here). export const MemoChannelMessageList = memo(ChannelMessageList); #. placeholder {2}: import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import type * as React from "react"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { SCROLL_TOLERANCE, useScrollToBottom, } from "../../hooks/useScrollToBottom"; import { groupConsecutiveEvents } from "../../lib/eventGrouping"; import ircClient from "../../lib/ircClient"; import useStore from "../../store"; import type { Message as MessageType } from "../../types"; import { CollapsedEventMessage } from "../message/CollapsedEventMessage"; import { MessageItem } from "../message/MessageItem"; import LoadingSpinner from "../ui/LoadingSpinner"; import { ScrollToBottomButton } from "../ui/ScrollToBottomButton"; export const DEFAULT_VISIBLE_MESSAGE_COUNT = 100; // Stable empty array — prevents selector from returning a new [] on every render // when the channel has no messages yet (undefined ?? [] would create a new ref each time). const EMPTY_MESSAGES: import("../../types").Message[] = []; export interface ChannelMessageListHandle { setAtBottom: () => void; scrollToBottom: () => void; getScrollState: () => { scrollTop: number; isAtBottom: boolean; visibleCount: number; }; } interface ChannelMessageListProps { channelKey: string; serverId: string; channelId: string | null; privateChatId: string | null; isActive: boolean; searchQuery: string; isMemberListVisible: boolean; onReply: (msg: MessageType | null) => void; onUsernameContextMenu: ( e: React.MouseEvent, username: string, serverId: string, channelId: string, avatarEl?: Element | null, ) => void; onIrcLinkClick: (url: string) => void; onReactClick: (msg: MessageType, el: Element) => void; onReactionUnreact: (emoji: string, msg: MessageType) => void; onOpenReactionModal: ( msg: MessageType, position: { x: number; y: number }, ) => void; onDirectReaction: (emoji: string, msg: MessageType) => void; onRedactMessage: (msg: MessageType) => void; onOpenProfile: (username: string) => void; joinChannel: (serverId: string, channelName: string) => void; onClearSearch: () => void; highlightedMessageId?: string; // undefined = first visit; null = was at bottom; object = restore to saved position initialScrollState?: { scrollTop: number; visibleCount: number } | null; } export const ChannelMessageList = forwardRef< ChannelMessageListHandle, ChannelMessageListProps >( ( { channelKey, serverId, channelId, privateChatId, isActive, searchQuery, isMemberListVisible, onReply, onUsernameContextMenu, onIrcLinkClick, onReactClick, onReactionUnreact, onOpenReactionModal, onDirectReaction, onRedactMessage, onOpenProfile, joinChannel, onClearSearch, highlightedMessageId, initialScrollState, }, ref, ) => { const { t } = useLingui(); const [visibleMessageCount, setVisibleMessageCount] = useState( initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT, ); // Ref mirror so getScrollState closure always reads the current value without needing it as a dep. const visibleMessageCountRef = useRef(visibleMessageCount); visibleMessageCountRef.current = visibleMessageCount; // Distinguishes initial join (full-screen spinner) from subsequent "load more" (button spinner). const [isFetchingMore, setIsFetchingMore] = useState(false); const isFetchingMoreRef = useRef(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const messagesInnerRef = useRef(null); // prev scrollHeight for prepend delta-correction. const prevScrollHeightRef = useRef(0); // Ref mirror of isScrolledUp — lets useLayoutEffect closures read current value // without listing isScrolledUp as a dep (which would re-run effects on every scroll). const isScrolledUpRef = useRef(false); const prevFilteredLengthRef = useRef(0); const prevFirstMsgIdRef = useRef(null); // Set by the window-growth layoutEffect (or button handler) when a true prepend is detected. // Consumed by the delta-correction layoutEffect one render later (after visibleCount grows). // Using a flag instead of tracking displayedMessages[0]?.id because slice(-N) slides the // window on every bottom append, changing displayedMessages[0] even for non-prepend renders. const pendingPrependRef = useRef(false); // Shared scrollHeight baseline between the delta-correction layout effect and the inner // ResizeObserver. When scrollTop is corrected after a prepend, we update this so the RO's // "was at bottom" check is not fooled by the adjusted scrollTop vs its stale prevSH. const resizeObserverPrevSHRef = useRef(0); const channelMessages = useStore( useCallback( (state) => state.messages[channelKey] ?? EMPTY_MESSAGES, [channelKey], ), ); const servers = useStore((state) => state.servers); const mobileViewActiveColumn = useStore( (state) => state.ui.mobileViewActiveColumn, ); const channel = useMemo( () => channelId ? (servers .find((s) => s.id === serverId) ?.channels.find((c) => c.id === channelId) ?? null) : null, [servers, serverId, channelId], ); const { isScrolledUp, wasAtBottomRef, scrollToBottom } = useScrollToBottom( messagesContainerRef, messagesEndRef, { channelId: `${channelId || privateChatId}-${isMemberListVisible}` }, ); // Snapshot of the last known scroll position captured while the container was visible. // getScrollState() reads this instead of the live DOM because React commits display:none // before running cleanup effects, collapsing scrollTop/scrollHeight/clientHeight to 0. const lastScrollTopRef = useRef(initialScrollState?.scrollTop ?? 0); useEffect(() => { const container = messagesContainerRef.current; if (!container) return; const onScroll = () => { if (container.clientHeight > 0) lastScrollTopRef.current = container.scrollTop; }; container.addEventListener("scroll", onScroll, { passive: true }); return () => container.removeEventListener("scroll", onScroll); }, []); // Restore scroll position when a keep-alive channel transitions from hidden to visible. // display:none may reset scrollTop to 0; lastScrollTopRef was captured while visible. const prevActiveRef = useRef(isActive); useLayoutEffect(() => { if (isActive && !prevActiveRef.current) { const container = messagesContainerRef.current; if (container && lastScrollTopRef.current > 0) { container.scrollTop = lastScrollTopRef.current; } } prevActiveRef.current = isActive; }, [isActive]); useImperativeHandle(ref, () => ({ setAtBottom: () => { wasAtBottomRef.current = true; }, scrollToBottom, getScrollState: () => ({ scrollTop: lastScrollTopRef.current, isAtBottom: wasAtBottomRef.current, visibleCount: visibleMessageCountRef.current, }), })); const filteredMessages = useMemo(() => { if (!searchQuery.trim()) return channelMessages; const query = searchQuery.toLowerCase(); return channelMessages.filter( (msg) => msg.content.toLowerCase().includes(query) || msg.userId.toLowerCase().includes(query), ); }, [channelMessages, searchQuery]); useEffect(() => { isScrolledUpRef.current = isScrolledUp; // When the user returns to the bottom, shrink the window back to the base so // slice(-N) resumes trimming old messages from the top (memory optimization). // Only shrink if we grew above the base — preserves a sub-default saved visibleCount. if (!isScrolledUp) { setVisibleMessageCount((prev) => prev > DEFAULT_VISIBLE_MESSAGE_COUNT ? DEFAULT_VISIBLE_MESSAGE_COUNT : prev, ); } }, [isScrolledUp]); // Reset ref-tracked windowing state when switching channels. // visibleMessageCount is NOT reset here — useState(initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT) // already initializes it correctly on mount, and this effect runs once on mount for the // same channelKey (each instance is bound to exactly one channel by the parent key={}). // biome-ignore lint/correctness/useExhaustiveDependencies: intentional full reset on channel change useEffect(() => { prevFilteredLengthRef.current = 0; prevFirstMsgIdRef.current = null; prevScrollHeightRef.current = 0; pendingPrependRef.current = false; resizeObserverPrevSHRef.current = 0; }, [channelKey]); const displayedMessages = useMemo(() => { if (searchQuery.trim()) return filteredMessages; return filteredMessages.slice(-visibleMessageCount); }, [filteredMessages, visibleMessageCount, searchQuery]); const locallyHidden = filteredMessages.length > displayedMessages.length; const serverHasMore = channel?.hasMoreHistory === true; const hasMoreMessages = locallyHidden || serverHasMore; const eventGroups = useMemo( () => groupConsecutiveEvents(displayedMessages), [displayedMessages], ); const isLoadingHistory = channel?.isLoadingHistory ?? false; // Scroll to bottom on initial mount, unless a saved position was passed in. // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount only useEffect(() => { const container = messagesContainerRef.current; if (!container) return; if (initialScrollState) { container.scrollTop = initialScrollState.scrollTop; lastScrollTopRef.current = initialScrollState.scrollTop; wasAtBottomRef.current = false; } else { container.scrollTop = container.scrollHeight; lastScrollTopRef.current = container.scrollHeight; wasAtBottomRef.current = true; } }, []); // Scroll to bottom after initial join history loads; clear fetch spinner at batch end. const wasLoadingHistoryRef = useRef(false); // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is stable via useCallback; refs and setters are stable useLayoutEffect(() => { if (wasLoadingHistoryRef.current && !isLoadingHistory) { if (isFetchingMoreRef.current) { // delta correction for scroll position is handled by useLayoutEffect([displayedMessages]) isFetchingMoreRef.current = false; setIsFetchingMore(false); } else { scrollToBottom(); wasAtBottomRef.current = true; } } wasLoadingHistoryRef.current = isLoadingHistory; }, [isLoadingHistory]); // When older messages are prepended, grow the window so they enter displayedMessages. // When new messages arrive at the bottom while the user is scrolled up, also grow the // window to keep the current top messages visible — slice(-N) otherwise slides the // window forward and hides them, incrementing the "N older messages" counter on every // incoming message. Only let the slice trim from the top when the user is at the bottom // (where auto-scroll handles keeping them current). useLayoutEffect(() => { const newLength = filteredMessages.length; const newFirstId = filteredMessages[0]?.id ?? null; const delta = newLength - prevFilteredLengthRef.current; if (prevFilteredLengthRef.current > 0 && delta > 0) { if (newFirstId !== prevFirstMsgIdRef.current) { // Messages prepended (load-more): signal delta-correction to compensate scrollTop. pendingPrependRef.current = true; setVisibleMessageCount((prev) => prev + delta); } else if (isScrolledUpRef.current) { // Messages appended at bottom while user is scrolled up reading history. // Expand the window to prevent top messages from dropping out of the slice. setVisibleMessageCount((prev) => prev + delta); } } prevFilteredLengthRef.current = newLength; prevFirstMsgIdRef.current = newFirstId; }, [filteredMessages]); // Compensate scrollTop when content is prepended above the viewport. // biome-ignore lint/correctness/useExhaustiveDependencies: runs on every displayedMessages render to capture the resulting scrollHeight; refs are stable useLayoutEffect(() => { const container = messagesContainerRef.current; if (!container) return; // Skip while container is display:none — scrollHeight collapses to 0 and would // poison prevScrollHeightRef, causing a huge spurious delta on the next visible render. if (container.clientHeight === 0) return; const prevHeight = prevScrollHeightRef.current; const newHeight = container.scrollHeight; // Only correct when a true load-more prepend happened (flag set by the window-growth // layoutEffect or button handler). Bottom appends slide the slice(-N) window which also // changes displayedMessages[0] — ID-comparison can't distinguish the two cases. const wasPrepend = pendingPrependRef.current; // Only consume the flag when scrollHeight actually changed — the server-side load-more // path goes through two renders: Render A (filteredMessages grows, visibleCount unchanged, // same displayedMessages content, same scrollHeight) then Render B (visibleCount grows, // new messages enter displayedMessages, scrollHeight grows). The flag must survive Render A // so it's still set when Render B fires the actual correction. if (wasPrepend && newHeight !== prevHeight) { pendingPrependRef.current = false; } if ( isScrolledUpRef.current && prevHeight > 0 && newHeight > prevHeight && wasPrepend ) { const delta = newHeight - prevHeight; container.scrollTop += delta; resizeObserverPrevSHRef.current = newHeight; } prevScrollHeightRef.current = newHeight; }, [displayedMessages]); // Re-stick to bottom when inner message content grows (media/audio previews loading). // Uses prevScrollHeight instead of wasAtBottomRef to avoid stale-flag race where the // ref is true while the user is actively scrolling up. // When the container width changes (member list toggle, window resize), text reflows // and scrollHeight changes; preserve proportional scroll position for scrolled-up users. // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is a stable ref useEffect(() => { const container = messagesContainerRef.current; const inner = messagesInnerRef.current; if (!inner || !container) return; resizeObserverPrevSHRef.current = container.scrollHeight; let prevClientWidth = container.clientWidth; const observer = new ResizeObserver(() => { if (container.clientHeight === 0) return; // Effect may re-initialize while container is display:none (ref=0). // Re-seed with current dimensions and skip — no reliable "was at bottom" data. if (resizeObserverPrevSHRef.current === 0) { resizeObserverPrevSHRef.current = container.scrollHeight; prevClientWidth = container.clientWidth; return; } const currentClientWidth = container.clientWidth; const widthChanged = currentClientWidth !== prevClientWidth; prevClientWidth = currentClientWidth; const prevSH = resizeObserverPrevSHRef.current; const wasAtPrevBottom = container.scrollTop + container.clientHeight >= prevSH - SCROLL_TOLERANCE; resizeObserverPrevSHRef.current = container.scrollHeight; if (wasAtPrevBottom) { scrollToBottom(); } else if (widthChanged && prevSH > 0) { const ratio = container.scrollTop / prevSH; container.scrollTop = Math.round(ratio * container.scrollHeight); } }); observer.observe(inner); return () => observer.disconnect(); }, [isLoadingHistory, channelId, privateChatId]); // Auto-scroll on new messages — skip when this channel is hidden (display:none). // biome-ignore lint/correctness/useExhaustiveDependencies: only scroll when messages change, not when isActive changes useEffect(() => { if (!isActive) return; const isNarrowView = window.matchMedia("(max-width: 768px)").matches; const isChatVisible = !isNarrowView || mobileViewActiveColumn === "chatView"; if (wasAtBottomRef.current && isChatVisible) { scrollToBottom(); } }, [displayedMessages, mobileViewActiveColumn, scrollToBottom, isActive]); return ( <>
{isLoadingHistory && !isFetchingMore ? (
) : (
{hasMoreMessages && !searchQuery && (
)} {searchQuery && (
{plural(filteredMessages.length, { one: t`Found 1 message matching "${searchQuery}"`, other: t`Found ${filteredMessages.length} messages matching "${searchQuery}"`, })}
)} {eventGroups.map((group) => { if (group.type === "eventGroup") { const firstId = group.messages[0]?.id || ""; const lastId = group.messages[group.messages.length - 1]?.id || ""; const groupKey = `group-${firstId}-${lastId}`; return ( ); } const message = group.messages[0]; const originalIndex = channelMessages.findIndex( (m) => m.id === message.id, ); const previousMessage = channelMessages[originalIndex - 1]; const showHeader = !previousMessage || previousMessage.type !== "message" || previousMessage.userId !== message.userId || new Date(message.timestamp).getTime() - new Date(previousMessage.timestamp).getTime() > 5 * 60 * 1000; return ( ); })}
)}
); }, ); ChannelMessageList.displayName = "ChannelMessageList"; // Wrap with memo so hidden keep-alive channels skip re-renders when their props // haven't changed (e.g. when messageText changes in the input — the only thing // that changes on typing is local state inside ChatArea, not the props we pass here). export const MemoChannelMessageList = memo(ChannelMessageList); @@ -46,6 +56,12 @@ msgstr "{0} i {1} piszą..." msgid "{0} is typing..." msgstr "{0} pisze..." +#. placeholder {0}: networks.length +#. placeholder {1}: networks.length === 1 ? "" : "s" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "{0} network{1} — pick one to join" +msgstr "" + #. placeholder {0}: filteredMessages.length - displayedMessages.length #: src/components/layout/ChannelMessageList.tsx msgid "{0} older messages" @@ -186,6 +202,12 @@ msgstr "Dodaj maskę zaproszenia (np. nick!*@*, *!*@host.com)" msgid "Add IRC Server" msgstr "Dodaj serwer IRC" +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworksPanel.tsx +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Add Network" +msgstr "Dodaj sieć" + #: src/components/message/MessageActions.tsx #: src/components/message/MessageReactions.tsx #: src/components/message/MessageReactions.tsx @@ -205,6 +227,10 @@ msgstr "Dodaj regułę" msgid "Add Server" msgstr "Dodaj serwer" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Add your first network" +msgstr "Dodaj swoją pierwszą sieć" + #: src/components/message/JsonLogMessage.tsx msgid "Additional Details" msgstr "Dodatkowe szczegóły" @@ -358,6 +384,10 @@ msgstr "Wróć" msgid "Back to image" msgstr "Wróć do obrazu" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Back to network list" +msgstr "" + #: src/components/ui/ModerationModal.tsx msgid "Ban {username} by hostmask (prevents them from rejoining from the same IP/host)" msgstr "Zablokuj {username} po hostmasce (uniemożliwia ponowne dołączenie z tego samego IP/hosta)" @@ -405,6 +435,8 @@ msgstr "Przeglądaj wszystkie kanały na serwerze" #: src/components/ui/AddPrivateChatModal.tsx #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/ChannelSettingsModal.tsx #: src/components/ui/ExternalLinkWarningModal.tsx #: src/components/ui/FloodSettingsModal.tsx @@ -640,6 +672,7 @@ msgid "Configure notification sounds and highlights" msgstr "Konfiguruj dźwięki powiadomień i podświetlenia" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworksPanel.tsx msgid "Connect" msgstr "Połącz" @@ -759,6 +792,10 @@ msgstr "Usuń kanał" msgid "Delete message" msgstr "Usuń wiadomość" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Delete network" +msgstr "Usuń sieć" + #: src/components/layout/ChannelList.tsx msgid "Delete Private Chat" msgstr "Usuń prywatną rozmowę" @@ -767,6 +804,10 @@ msgstr "Usuń prywatną rozmowę" msgid "Delete this message? This cannot be undone." msgstr "Usunąć tę wiadomość? Tej operacji nie można cofnąć." +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Delete this network?" +msgstr "Usunąć tę sieć?" + #: src/components/layout/ServerList.tsx #: src/components/mobile/ServerBottomSheet.tsx msgid "Disconnect" @@ -830,10 +871,16 @@ msgstr "Pobierz" msgid "e.g., 100:1440" msgstr "np. 100:1440" +#: src/components/ui/BouncerNetworksPanel.tsx #: src/components/ui/ChannelSettingsModal.tsx msgid "Edit" msgstr "Edytuj" +#. placeholder {0}: editingNetwork?.attributes.name || editingNetwork?.netid +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Edit {0}" +msgstr "Edytuj {0}" + #: src/components/ui/UserProfileModal.tsx msgid "Edit Profile" msgstr "Edytuj profil" @@ -1057,6 +1104,7 @@ msgstr "STRONA GŁÓWNA" msgid "Homepage" msgstr "Strona internetowa" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserProfileModal.tsx msgid "Host" msgstr "Host" @@ -1271,6 +1319,10 @@ msgstr "Opuścił kanał" msgid "Let others know when you are typing" msgstr "Informuj innych, gdy piszesz" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Libera Chat" +msgstr "Libera Chat" + #: src/components/message/LinkPreview.tsx msgid "Link preview" msgstr "Podgląd linku" @@ -1299,6 +1351,10 @@ msgstr "Wczytywanie GIFów..." msgid "Loading more channels..." msgstr "Wczytywanie kolejnych kanałów..." +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Loading networks from your bouncer…" +msgstr "Wczytywanie sieci z Twojego bouncera…" + #: src/components/ui/UserProfileModal.tsx msgid "Loading WHOIS data..." msgstr "Wczytywanie danych WHOIS..." @@ -1486,9 +1542,15 @@ msgid "Name:" msgstr "Nazwa:" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx msgid "Network Name" msgstr "Nazwa sieci" +#. placeholder {0}: server?.name ?? bouncerServerId +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Networks on {0}" +msgstr "Sieci na {0}" + #: src/components/ui/QuickActions.tsx msgid "New DM" msgstr "Nowy DM" @@ -1511,6 +1573,7 @@ msgid "nick!user@host (e.g., spam*!*@*, *!*@badhost.com)" msgstr "nick!user@host (np. spam*!*@*, *!*@badhost.com)" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserSettings.tsx #: src/lib/settings/definitions/allSettings.ts msgid "Nickname" @@ -1570,6 +1633,10 @@ msgstr "Nie wybrano pliku" msgid "No flood profile" msgstr "Brak profilu floodu" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "no host set" +msgstr "nie ustawiono hosta" + #: src/components/ui/ChannelSettingsModal.tsx msgid "No invitations found" msgstr "Nie znaleziono zaproszeń" @@ -1610,6 +1677,10 @@ msgstr "Nie ustawiono tematu" msgid "No unread mentions or messages" msgstr "Brak nieprzeczytanych wzmianek lub wiadomości" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "No upstream networks yet." +msgstr "Brak sieci nadrzędnych." + #: src/components/ui/AddPrivateChatModal.tsx msgid "No users available" msgstr "Brak dostępnych użytkowników" @@ -1696,6 +1767,10 @@ msgstr "Ups! Podział sieci! ⚠️" msgid "Op" msgstr "Op" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Open" +msgstr "" + #: src/components/ui/QuickActions/uiActionConfig.tsx msgid "Open channel configuration settings" msgstr "Otwórz ustawienia konfiguracji kanału" @@ -1799,6 +1874,10 @@ msgstr "Przypnij prywatną rozmowę" msgid "Pin this private message conversation" msgstr "Przypnij tę prywatną rozmowę" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Plaintext" +msgstr "Tekst jawny" + #: src/components/message/MediaPreview.tsx #: src/components/message/MediaPreview.tsx #: src/components/message/MediaPreview.tsx @@ -1827,6 +1906,7 @@ msgid "PM User" msgstr "Wyślij wiadomość prywatną" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx msgid "Port" msgstr "Port" @@ -1918,6 +1998,7 @@ msgstr "zareagował na tę wiadomość" msgid "Read more" msgstr "Czytaj więcej" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserProfileModal.tsx #: src/components/ui/UserSettings.tsx #: src/lib/settings/definitions/allSettings.ts @@ -2002,6 +2083,7 @@ msgstr "Reguły" msgid "Safe" msgstr "Bezpieczne" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/ChannelSettingsModal.tsx #: src/components/ui/TopicModal.tsx #: src/components/ui/UserSettings.tsx @@ -2183,6 +2265,10 @@ msgstr "Operatorzy serwerów w sieci mogą potencjalnie odczytywać Twoje wiadom msgid "Server Password" msgstr "Hasło serwera" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Server Password (PASS)" +msgstr "Hasło serwera (PASS)" + #: src/components/ui/LinkSecurityWarningModal.tsx msgid "Server-to-server communication may use unencrypted connections" msgstr "Komunikacja między serwerami może używać nieszyfrowanych połączeń" @@ -2378,6 +2464,10 @@ msgstr "Czas (min)" msgid "Time Window (seconds)" msgstr "Okno czasowe (sekundy)" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "TLS" +msgstr "TLS" + #: src/components/message/WhisperMessage.tsx #: src/components/message/WhisperMessage.tsx msgid "to" @@ -2426,6 +2516,10 @@ msgstr "Temat:" msgid "Total: {0}" msgstr "Łącznie: {0}" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Transport" +msgstr "Transport" + #: src/components/ui/UserSettings.tsx msgid "Trusted Sources" msgstr "Zaufane źródła" @@ -2536,6 +2630,7 @@ msgstr "Profil użytkownika" msgid "User Settings" msgstr "Ustawienia użytkownika" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/InviteUserModal.tsx #: src/components/ui/ModerationModal.tsx msgid "Username" @@ -2683,6 +2778,10 @@ msgstr "Szeroki – szerszy zakres ochrony" msgid "Will default to 'no reason' if left empty" msgstr "Domyślnie 'brak powodu', jeśli pozostawione puste" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Yes, delete" +msgstr "Tak, usuń" + #: src/components/message/CollapsedEventMessage.tsx #: src/components/message/EventMessage.tsx msgid "You" @@ -2713,6 +2812,10 @@ msgstr "Hasło Twojego konta do uwierzytelniania" msgid "Your account username for authentication" msgstr "Nazwa użytkownika Twojego konta do uwierzytelniania" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Your bouncer doesn't have any networks yet. Add one to get started." +msgstr "Twój bouncer nie ma jeszcze żadnych sieci. Dodaj jedną, aby zacząć." + #: src/lib/settings/definitions/allSettings.ts msgid "Your default nickname for all servers" msgstr "Twój domyślny nick dla wszystkich serwerów" diff --git a/src/locales/pt/messages.mjs b/src/locales/pt/messages.mjs index 6ab538f7..bdc37b8d 100644 --- a/src/locales/pt/messages.mjs +++ b/src/locales/pt/messages.mjs @@ -1 +1 @@ -/*eslint-disable*/export const messages=JSON.parse("{\"+5VMrz\":[\"Formato de padrão inválido. Use o formato nick!user@host (curingas * permitidos)\"],\"+6NQQA\":[\"Canal de Suporte Geral\"],\"+6NyRG\":[\"Cliente\"],\"+K0AvT\":[\"Desconectar\"],\"+cyFdH\":[\"Mensagem padrão ao marcar-se como ausente\"],\"+mVPqU\":[\"Renderizar formatação Markdown nas mensagens\"],\"+vqCJH\":[\"Seu nome de usuário de conta para autenticação\"],\"+yPBXI\":[\"Escolher arquivo\"],\"+zy2Nq\":[\"Tipo\"],\"/09cao\":[\"Baixa Segurança de Link (Nível \",[\"securityLevel\"],\")\"],\"/3BQ4J\":[\"Usuários fora do canal não podem enviar mensagens\"],\"/6BzZF\":[\"Alternar Lista de Membros\"],\"/TNOPk\":[\"O usuário está ausente\"],\"/XQgft\":[\"Descobrir\"],\"/cF7Rs\":[\"Volume\"],\"/dqduX\":[\"Próxima página\"],\"/fc3q4\":[\"Todo o Conteúdo\"],\"/kISDh\":[\"Ativar sons de notificação\"],\"/n04sB\":[\"Kill\"],\"/rTz0M\":[\"Áudio\"],\"/rfkZe\":[\"Reproduzir sons para menções e mensagens\"],\"0/0ZGA\":[\"Máscara do nome do canal\"],\"0D6j7U\":[\"Saiba mais sobre regras personalizadas →\"],\"0XsHcR\":[\"Expulsar Usuário\"],\"0ZpE//\":[\"Ordenar por usuários\"],\"0bEPwz\":[\"Definir como Ausente\"],\"0dGkPt\":[\"Expandir lista de canais\"],\"0gS7M5\":[\"Nome de exibição\"],\"0kS+M8\":[\"ExemploREDE\"],\"0rgoY7\":[\"Conectar apenas a servidores que você escolher\"],\"0wdd7X\":[\"Entrar\"],\"0wkVYx\":[\"Mensagens privadas\"],\"111uHX\":[\"Visualização do link\"],\"196EG4\":[\"Excluir Conversa Privada\"],\"1DSr1i\":[\"Registrar uma conta\"],\"1O/24y\":[\"Alternar Lista de Canais\"],\"1VPJJ2\":[\"Aviso de Link Externo\"],\"1ZC/dv\":[\"Nenhuma menção ou mensagem não lida\"],\"1pO1zi\":[\"Nome do servidor é obrigatório\"],\"1uwfzQ\":[\"Ver Tópico do Canal\"],\"268g7c\":[\"Digite o nome de exibição\"],\"2FOFq1\":[\"Operadores de servidor na rede podem potencialmente ler suas mensagens\"],\"2FYpfJ\":[\"Mais\"],\"2HF1Y2\":[[\"inviter\"],\" convidou \",[\"target\"],\" para entrar em \",[\"channel\"]],\"2I70QL\":[\"Ver informações do perfil do usuário\"],\"2QYdmE\":[\"Usuários:\"],\"2QpEjG\":[\"saiu\"],\"2YE223\":[\"Mensagem #\",[\"0\"],\" (Enter para nova linha, Shift+Enter para enviar)\"],\"2bimFY\":[\"Usar senha do servidor\"],\"2iTmdZ\":[\"Armazenamento local:\"],\"2odkwe\":[\"Estrito – Proteção mais agressiva\"],\"2uDhbA\":[\"Digite o nome de usuário para convidar\"],\"2ygf/L\":[\"← Voltar\"],\"2zEgxj\":[\"Pesquisar GIFs...\"],\"3RdPhl\":[\"Renomear Canal\"],\"3THokf\":[\"Usuário com voz\"],\"3TSz9S\":[\"Minimizar\"],\"3jBDvM\":[\"Nome de exibição do canal\"],\"3ryuFU\":[\"Relatórios de falha opcionais para melhorar o app\"],\"3uBF/8\":[\"Fechar visualizador\"],\"3uwW8F\":[\"https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"3xf8Kz\":[\"Inserir nome da conta...\"],\"4/Rr0R\":[\"Convidar um usuário para o canal atual\"],\"4EZrJN\":[\"Regras\"],\"4JJtW9\":[\"#overflow\"],\"4NqeT4\":[\"Perfil de flood (+F)\"],\"4RZQRK\":[\"O que você está fazendo?\"],\"4hfTrB\":[\"Apelido\"],\"4n99LO\":[\"Já em \",[\"0\"]],\"4t6vMV\":[\"Mudar automaticamente para linha única em mensagens curtas\"],\"4vsHmf\":[\"Tempo (min)\"],\"5+INAX\":[\"Destacar mensagens que mencionam você\"],\"5R5Pv/\":[\"Nome Oper\"],\"678PKt\":[\"Nome da Rede\"],\"6Aih4U\":[\"Offline\"],\"6CO3WE\":[\"Senha necessária para entrar no canal. Deixe vazio para remover a chave.\"],\"6HhMs3\":[\"Mensagem de saída\"],\"6V3Ea3\":[\"Copiado\"],\"6lGV3K\":[\"Mostrar menos\"],\"6yFOEi\":[\"Inserir senha do oper...\"],\"7+IHTZ\":[\"Nenhum arquivo escolhido\"],\"73hrRi\":[\"nick!user@host (ex.: spam*!*@*, *!*@badhost.com)\"],\"7QkKyN\":[\"Enviar mensagem privada\"],\"7U1W7c\":[\"Muito Relaxado\"],\"7Y1YQj\":[\"Nome real:\"],\"7YHArF\":[\"— abrir no visualizador\"],\"7fjnVl\":[\"Pesquisar usuários...\"],\"7jL88x\":[\"Excluir esta mensagem? Esta ação não pode ser desfeita.\"],\"7nGhhM\":[\"O que você está pensando?\"],\"7sEpu1\":[\"Membros — \",[\"0\"]],\"7sNhEz\":[\"Nome de usuário\"],\"8H0Q+x\":[\"Saiba mais sobre perfis →\"],\"8Phu0A\":[\"Exibir quando usuários mudam seu apelido\"],\"8XTG9e\":[\"Digite a senha oper\"],\"8XsV2J\":[\"Tentar enviar novamente\"],\"8ZsakT\":[\"Senha\"],\"8kR84m\":[\"Você está prestes a abrir um link externo:\"],\"8lCgih\":[\"Remover regra\"],\"8p/xVT\":[[\"0\",\"plural\",{\"one\":[[\"1\"]],\"other\":[[\"2\"]]}]],\"8wRzac\":[[\"joinCount\",\"plural\",{\"one\":[\"entrou\"],\"other\":[\"entrou \",[\"joinCount\"],\" vezes\"]}]],\"9BMLnJ\":[\"Reconectar ao servidor\"],\"9OEgyT\":[\"Adicionar reação\"],\"9PQ8m2\":[\"G-Line (ban global)\"],\"9Qs99X\":[\"E-mail:\"],\"9QupBP\":[\"Remover padrão\"],\"9bG48P\":[\"Enviando\"],\"9f5f0u\":[\"Dúvidas sobre privacidade? Entre em contato:\"],\"9unqs3\":[\"Ausente:\"],\"9v3hwv\":[\"Nenhum servidor encontrado.\"],\"9zb2WA\":[\"Conectando\"],\"A1taO8\":[\"Pesquisar\"],\"A2adVi\":[\"Enviar notificações de digitação\"],\"A9Rhec\":[\"Nome do canal\"],\"AWOSPo\":[\"Ampliar\"],\"AXSpEQ\":[\"Oper ao conectar\"],\"AeXO77\":[\"Conta\"],\"AhNP40\":[\"Avançar\"],\"Ai2U7L\":[\"Host\"],\"AjBQnf\":[\"Apelido alterado\"],\"AmXVh6\":[\"https://example.com/avatar.png\"],\"AnRu/j\":[\"Cancelar resposta\"],\"ApSx0O\":[\"Encontradas \",[\"0\"],\" mensagens correspondentes a \\\"\",[\"searchQuery\"],\"\\\"\"],\"AxPAXW\":[\"Nenhum resultado encontrado\"],\"AyNqAB\":[\"Exibir todos os eventos do servidor no chat\"],\"B/QqGw\":[\"Longe do teclado\"],\"B8AaMI\":[\"Este campo é obrigatório\"],\"BA2c49\":[\"O servidor não suporta filtragem avançada de LIST\"],\"BDKt3I\":[[\"0\"],\", \",[\"1\"],\", \",[\"2\"],\" e mais \",[\"3\"],\" estão digitando...\"],\"BGul2A\":[\"Você tem alterações não salvas. Tem certeza que deseja fechar sem salvar?\"],\"BIf9fi\":[\"Sua mensagem de status\"],\"BZz3md\":[\"Seu site pessoal\"],\"Bgm/H7\":[\"Permitir inserção de múltiplas linhas de texto\"],\"BiQIl1\":[\"Fixar esta conversa de mensagem privada\"],\"BlNZZ2\":[\"Clique para ir à mensagem\"],\"Bowq3c\":[\"Apenas operadores podem alterar o tópico\"],\"Btozzp\":[\"Esta imagem expirou\"],\"Bycfjm\":[\"Total: \",[\"0\"]],\"C6IBQc\":[\"Copiar JSON completo\"],\"C9L9wL\":[\"Coleta de dados\"],\"CDq4wC\":[\"Moderar Usuário\"],\"CHVRxG\":[\"Mensagem @\",[\"0\"],\" (Shift+Enter para nova linha)\"],\"CN9zdR\":[\"Nome oper e senha são obrigatórios\"],\"CW3sYa\":[\"Adicionar reação \",[\"emoji\"]],\"CaAkqd\":[\"Mostrar desconexões\"],\"CbvaYj\":[\"Banir por apelido\"],\"CcK+Ft\":[\"PDF\"],\"Ce8q3L\":[\"Selecionar um canal\"],\"CsekCi\":[\"Normal\"],\"D+NlUC\":[\"Sistema\"],\"D28t6+\":[\"entrou e saiu\"],\"DB8zMK\":[\"Aplicar\"],\"DBcWHr\":[\"Arquivo de som de notificação personalizado\"],\"DTy9Xw\":[\"Pré-visualizações de mídia\"],\"Dj4pSr\":[\"Escolha uma senha segura\"],\"Du+zn+\":[\"Pesquisando...\"],\"Du2T2f\":[\"Configuração não encontrada\"],\"DwsSVQ\":[\"Aplicar filtros e atualizar\"],\"E3W/zd\":[\"Apelido padrão\"],\"E6nRW7\":[\"Copiar URL\"],\"E703RG\":[\"Modos:\"],\"EAeu1Z\":[\"Enviar convite\"],\"EFKJQT\":[\"Configuração\"],\"EGPQBv\":[\"Regras de flood personalizadas (+f)\"],\"ELik0r\":[\"Ver política de privacidade completa\"],\"EPbeC2\":[\"Ver ou editar o tópico do canal\"],\"EQCDNT\":[\"Inserir nome de usuário oper...\"],\"EUvulZ\":[\"Encontrada 1 mensagem correspondente a \\\"\",[\"searchQuery\"],\"\\\"\"],\"EatZYJ\":[\"Próxima imagem\"],\"EdQY6l\":[\"Nenhum\"],\"EnqLYU\":[\"Pesquisar servidores...\"],\"F0OKMc\":[\"Editar Servidor\"],\"F6Int2\":[\"Ativar destaques\"],\"FDoLyE\":[\"Usuários máx.\"],\"FUU/hZ\":[\"Controla quanto conteúdo de mídia externa é carregado no chat.\"],\"Fdp03t\":[\"ativo\"],\"FfPWR0\":[\"Modal\"],\"FjkaiT\":[\"Reduzir\"],\"FlqOE9\":[\"O que isso significa:\"],\"FolHNl\":[\"Gerenciar sua conta e autenticação\"],\"Fp2Dif\":[\"Saiu do servidor\"],\"G5KmCc\":[\"GZ-Line (Z-Line global)\"],\"GDs0lz\":[\"<0>Risco: Informações sensíveis (mensagens, conversas privadas, dados de autenticação) podem ser expostas a administradores de rede ou atacantes posicionados entre servidores IRC.\"],\"GR+2I3\":[\"Adicionar máscara de convite (ex.: nick!*@*, *!*@host.com)\"],\"GRLyMU\":[\"Fechar avisos do servidor em destaque\"],\"GlHnXw\":[\"Falha ao alterar apelido: \",[\"error\"],\" \",[\"0\"]],\"GswZF3\":[\"Prévia:\"],\"GtmO8/\":[\"de\"],\"GtuHUQ\":[\"Renomear este canal no servidor. Todos os usuários verão o novo nome.\"],\"GuGfFX\":[\"Alternar pesquisa\"],\"GxkJXS\":[\"Enviando...\"],\"GzbwnK\":[\"Entrou no canal\"],\"GzsUDB\":[\"Perfil estendido\"],\"H/PnT8\":[\"Inserir emoji\"],\"H6Izzl\":[\"Seu código de cor preferido\"],\"H9jIv+\":[\"Mostrar entradas/saídas\"],\"HAKBY9\":[\"Enviar ficheiros\"],\"HdE1If\":[\"Canal\"],\"Hk4AW9\":[\"Seu nome de exibição preferido\"],\"HmHDk7\":[\"Selecionar Membro\"],\"HrQzPU\":[\"Canais em \",[\"networkName\"]],\"I2tXQ5\":[\"Mensagem @\",[\"0\"],\" (Enter para nova linha, Shift+Enter para enviar)\"],\"I6bw/h\":[\"Banir usuário\"],\"I92Z+b\":[\"Ativar notificações\"],\"I9D72S\":[\"Tem certeza que deseja excluir esta mensagem? Esta ação não pode ser desfeita.\"],\"IA+1wo\":[\"Exibir quando usuários são expulsos de canais\"],\"IDwkJx\":[\"IRC Operator\"],\"ILlU+s\":[\"Info:\"],\"IUwGEM\":[\"Salvar Alterações\"],\"IVeGK6\":[[\"0\"],\", \",[\"1\"],\" e \",[\"2\"],\" estão digitando...\"],\"IgrLD/\":[\"Pausar\"],\"Im6JED\":[\"WHISPER\"],\"ImOQa9\":[\"Responder\"],\"IoHMnl\":[\"O valor máximo é \",[\"0\"]],\"IvMj+0\":[\"Op\"],\"J28zul\":[\"Conectando...\"],\"J5T9NW\":[\"Informações do usuário\"],\"J8Y5+z\":[\"Ops! Divisão de rede! ⚠️\"],\"JBHkBA\":[\"Saiu do canal\"],\"JCwL0Q\":[\"Digite o motivo (opcional)\"],\"JFciKP\":[\"Alternar\"],\"JXGkhG\":[\"Alterar o nome do canal (apenas operadores)\"],\"JcD7qf\":[\"Mais ações\"],\"JdkA+c\":[\"Secreto (+s)\"],\"Jmu12l\":[\"Canais do Servidor\"],\"JvQ++s\":[\"Ativar Markdown\"],\"K2jwh/\":[\"Nenhum dado WHOIS disponível\"],\"KAXSwC\":[\"Voz\"],\"KDfTdX\":[\"Excluir mensagem\"],\"KKBlUU\":[\"Incorporar\"],\"KM0pLb\":[\"Bem-vindo ao canal!\"],\"KR6W2h\":[\"Deixar de ignorar usuário\"],\"KV+Bi1\":[\"Apenas por convite (+i)\"],\"KdCtwE\":[\"Quantos segundos monitorar a atividade de flood antes de redefinir os contadores\"],\"Kkezga\":[\"Senha do Servidor\"],\"KsiQ/8\":[\"Os usuários devem ser convidados para entrar no canal\"],\"L+gB/D\":[\"Informações do canal\"],\"LC1a7n\":[\"O servidor IRC relatou que seus links servidor a servidor têm um nível de segurança baixo. Isso significa que quando suas mensagens são retransmitidas entre servidores IRC na rede, elas podem não estar devidamente criptografadas ou os certificados SSL/TLS podem não ser validados corretamente.\"],\"LNfLR5\":[\"Mostrar expulsões\"],\"LQb0W/\":[\"Mostrar todos os eventos\"],\"LU7/yA\":[\"Nome alternativo para exibição. Pode conter espaços, emojis e caracteres especiais. O nome real (\",[\"channelName\"],\") ainda será usado para comandos IRC.\"],\"LUb9O7\":[\"Porta de servidor válida é obrigatória\"],\"Lb+BUl\":[\"https://example.com/avatar.jpg\"],\"LcET2C\":[\"Política de privacidade\"],\"LcuSDR\":[\"Gerenciar suas informações de perfil e metadados\"],\"LqLS9B\":[\"Mostrar mudanças de apelido\"],\"LsDQt2\":[\"Configurações do Canal\"],\"LtI9AS\":[\"Dono\"],\"LuNhhL\":[\"reagiu a esta mensagem\"],\"M/AZNG\":[\"URL da sua imagem de avatar\"],\"M/WIer\":[\"Enviar mensagem\"],\"M8er/5\":[\"Nome:\"],\"MHk+7g\":[\"Imagem anterior\"],\"MRorGe\":[\"Mensagem Privada\"],\"MVbSGP\":[\"Janela de tempo (segundos)\"],\"MkpcsT\":[\"Suas mensagens e configurações são armazenadas localmente\"],\"N/hDSy\":[\"Marcar como bot, geralmente 'on' ou vazio\"],\"N7TQbE\":[\"Convidar usuário para \",[\"channelName\"]],\"NCca/o\":[\"Inserir apelido padrão...\"],\"Nqs6B9\":[\"Exibe toda a mídia externa. Qualquer URL pode causar uma requisição a um servidor desconhecido.\"],\"Nt+9O7\":[\"Usar WebSocket em vez de TCP bruto\"],\"NxIHzc\":[\"Expulsar usuário\"],\"O+v/cL\":[\"Navegar por todos os canais do servidor\"],\"ODwSCk\":[\"Enviar um GIF\"],\"OGQ5kK\":[\"Configurar sons de notificação e destaques\"],\"OIPt1Z\":[\"Mostrar ou ocultar a barra lateral de membros\"],\"OKSNq/\":[\"Muito Estrito\"],\"ONWvwQ\":[\"Carregar\"],\"OVKoQO\":[\"Sua senha de conta para autenticação\"],\"ObsidianIRC - Bringing IRC to the future\":[\"ObsidianIRC - Levando o IRC para o futuro\"],\"OhCpra\":[\"Definir um tópico…\"],\"OkltoQ\":[\"Banir \",[\"username\"],\" por apelido (impede que entre novamente com o mesmo nick)\"],\"P+t/Te\":[\"Sem dados adicionais\"],\"P42Wcc\":[\"Seguro\"],\"PD38l0\":[\"Visualização do avatar do canal\"],\"PD9mEt\":[\"Digite uma mensagem...\"],\"PPqfdA\":[\"Abrir configurações do canal\"],\"PSCjfZ\":[\"O tópico exibido para este canal. Todos os usuários podem ver.\"],\"PZCecv\":[\"Pré-visualização de PDF\"],\"PeLgsC\":[[\"c\",\"plural\",{\"one\":[\"1 vez\"],\"other\":[[\"c\"],\" vezes\"]}]],\"PguS2C\":[\"Adicionar máscara de exceção (ex.: nick!*@*, *!*@host.com)\"],\"Pil5Ty\":[\"Mostrando \",[\"displayedChannelsCount\"],\" de \",[\"0\"],\" canais\"],\"PqhVlJ\":[\"Banir Usuário (por Hostmask)\"],\"Q+chwU\":[\"Nome de usuário:\"],\"Q6hhn8\":[\"Preferências\"],\"QF4a34\":[\"Por favor, insira um nome de usuário\"],\"QGqSZ2\":[\"Cor e Formatação\"],\"QJQd1J\":[\"Editar perfil\"],\"QSzGDE\":[\"Ocioso\"],\"QUlny5\":[\"Bem-vindo ao \",[\"0\"],\"!\"],\"Qoq+GP\":[\"Ler mais\"],\"QuSkCF\":[\"Filtrar canais...\"],\"QwUrDZ\":[\"alterou o tópico para: \",[\"topic\"]],\"R0UH07\":[\"Imagem \",[\"0\"],\" de \",[\"1\"]],\"R7SsBE\":[\"Silenciar\"],\"R8rf1X\":[\"Clique para definir o tópico\"],\"RArB3D\":[\"foi expulso de \",[\"channelName\"],\" por \",[\"username\"]],\"RI3cWd\":[\"Descubra o mundo do IRC com o ObsidianIRC\"],\"RMMaN5\":[\"Moderado (+m)\"],\"RWw9Lg\":[\"Fechar janela\"],\"RZ2BuZ\":[\"O registro da conta \",[\"account\"],\" requer verificação: \",[\"message\"]],\"RySp6q\":[\"Ocultar comentários\"],\"SPKQTd\":[\"Apelido é obrigatório\"],\"SPVjfj\":[\"Será definido como 'sem motivo' se deixado em branco\"],\"SQKPvQ\":[\"Convidar Usuário\"],\"SkZcl+\":[\"Escolha um perfil de proteção contra flood predefinido. Estes perfis fornecem configurações de proteção equilibradas para diferentes casos de uso.\"],\"Slr+3C\":[\"Usuários mín.\"],\"Spnlre\":[\"Você convidou \",[\"target\"],\" para entrar em \",[\"channel\"]],\"T/ckN5\":[\"Abrir no visualizador\"],\"T91vKp\":[\"Reproduzir\"],\"TV2Wdu\":[\"Saiba como tratamos seus dados e protegemos sua privacidade.\"],\"TgFpwD\":[\"Aplicando...\"],\"TkzSFB\":[\"Sem Alterações\"],\"TtserG\":[\"Digite o nome real\"],\"Ttz9J1\":[\"Inserir senha...\"],\"Tz0i8g\":[\"Configurações\"],\"U3pytU\":[\"Admin\"],\"UDb2YD\":[\"Reagir\"],\"UE4KO5\":[\"*channel*\"],\"UGT5vp\":[\"Salvar configurações\"],\"UV5hLB\":[\"Nenhum banimento encontrado\"],\"Uaj3Nd\":[\"Mensagens de Status\"],\"Ue3uny\":[\"Padrão (sem perfil)\"],\"UkARhe\":[\"Normal – Proteção padrão\"],\"Umn7Cj\":[\"Ainda sem comentários. Seja o primeiro!\"],\"UtUIRh\":[[\"0\"],\" mensagens antigas\"],\"UwzP+U\":[\"Conexão segura\"],\"V0/A4O\":[\"Proprietário do canal\"],\"V4qgxE\":[\"Criado antes (min atrás)\"],\"V8yTm6\":[\"Limpar pesquisa\"],\"VJMMyz\":[\"ObsidianIRC - Levando o IRC ao futuro\"],\"VJScHU\":[\"Motivo\"],\"VLsmVV\":[\"Silenciar notificações\"],\"VbyRUy\":[\"Comentários\"],\"Vmx0mQ\":[\"Definido por:\"],\"VqnIZz\":[\"Ver nossa política de privacidade e práticas de dados\"],\"VrMygG\":[\"O comprimento mínimo é \",[\"0\"]],\"VrnTui\":[\"Seus pronomes, exibidos no seu perfil\"],\"W8E3qn\":[\"Conta autenticada\"],\"WAakm9\":[\"Excluir Canal\"],\"WFxTHC\":[\"Adicionar máscara de banimento (ex.: nick!*@*, *!*@host.com)\"],\"WN1g9F\":[\"Host do servidor é obrigatório\"],\"WRYdXW\":[\"Posição do áudio\"],\"WUOH5B\":[\"Ignorar usuário\"],\"WWEXnZ\":[[\"0\",\"plural\",{\"one\":[\"Mostrar 1 item a mais\"],\"other\":[\"Mostrar \",[\"1\"],\" itens a mais\"]}]],\"Weq9zb\":[\"Geral\"],\"Wfj7Sk\":[\"Silenciar ou ativar sons de notificação\"],\"Wm7gbG\":[\"GitHub:\"],\"WyeHWY\":[\"*spam*\"],\"WzMCru\":[\"Perfil do Usuário\"],\"X6S3lt\":[\"Pesquisar configurações, canais, servidores...\"],\"XEHan5\":[\"Continuar Assim Mesmo\"],\"XI1+wb\":[\"Formato inválido\"],\"XIXeuC\":[\"Mensagem @\",[\"0\"]],\"XMS+k4\":[\"Iniciar Mensagem Privada\"],\"XWgxXq\":[\"Álbum\"],\"Xd7+IT\":[\"Desafixar Conversa Privada\"],\"Xm/s+u\":[\"Exibição\"],\"Xp2n93\":[\"Exibe mídia do host de arquivos confiável do seu servidor. Nenhuma requisição é feita a serviços externos.\"],\"XvjC4F\":[\"Salvando...\"],\"Y/qryO\":[\"Nenhum usuário encontrado para sua pesquisa\"],\"YAqRpI\":[\"Registro da conta \",[\"account\"],\" bem-sucedido: \",[\"message\"]],\"YEfzvP\":[\"Tópico protegido (+t)\"],\"YQOn6a\":[\"Recolher lista de membros\"],\"YRCoE9\":[\"Operador do canal\"],\"YURQaF\":[\"Ver perfil\"],\"YdBSvr\":[\"Controlar exibição de mídia e conteúdo externo\"],\"Yj6U3V\":[\"Sem servidor central:\"],\"YjvpGx\":[\"Pronomes\"],\"YqH4l4\":[\"Sem chave\"],\"YyUPpV\":[\"Conta:\"],\"ZJSWfw\":[\"Mensagem exibida ao desconectar do servidor\"],\"ZR1dJ4\":[\"Convites\"],\"ZdWg0V\":[\"Abrir no navegador\"],\"ZhRBbl\":[\"Pesquisar mensagens…\"],\"Zmcu3y\":[\"Filtros avançados\"],\"a2/8e5\":[\"Tópico definido após (min atrás)\"],\"aHKcKc\":[\"Página anterior\"],\"aJTbXX\":[\"Senha Oper\"],\"aQryQv\":[\"O padrão já existe\"],\"aW9pLN\":[\"Número máximo de usuários permitidos. Deixe vazio para sem limite.\"],\"ah4fmZ\":[\"Também exibe visualizações do YouTube, Vimeo, SoundCloud e serviços conhecidos similares.\"],\"aifXak\":[\"Nenhuma mídia neste canal\"],\"ap2zBz\":[\"Relaxado\"],\"az8lvo\":[\"Desligado\"],\"azXSNo\":[\"Expandir lista de membros\"],\"azdliB\":[\"Entrar em uma conta\"],\"b26wlF\":[\"ela/dela\"],\"bD/+Ei\":[\"Estrito\"],\"bQ6BJn\":[\"Configure regras detalhadas de proteção contra flood. Cada regra especifica que tipo de atividade monitorar e que ação tomar quando os limites são excedidos.\"],\"beV7+y\":[\"O usuário receberá um convite para entrar em \",[\"channelName\"],\".\"],\"bk84cH\":[\"Mensagem de ausência\"],\"bkHdLj\":[\"Adicionar servidor IRC\"],\"bmQLn5\":[\"Adicionar regra\"],\"bwRvnp\":[\"Ação\"],\"c8+EVZ\":[\"Conta verificada\"],\"cGYUlD\":[\"Nenhuma visualização de mídia é carregada.\"],\"cLF98o\":[\"Mostrar comentários (\",[\"commentCount\"],\")\"],\"cLKIDO\":[\"Nenhum usuário disponível\"],\"cSgpoS\":[\"Fixar Conversa Privada\"],\"cde3ce\":[\"Mensagem <0>\",[\"0\"],\"\"],\"chQsxg\":[\"Copiar saída formatada\"],\"cl/A5J\":[\"Bem-vindo ao \",[\"__DEFAULT_IRC_SERVER_NAME__\"],\"!\"],\"cnGeoo\":[\"Excluir\"],\"coPLXT\":[\"Não armazenamos suas comunicações IRC em nossos servidores\"],\"crYH/6\":[\"Player do SoundCloud\"],\"d3sis4\":[\"Adicionar Servidor\"],\"d9aN5k\":[\"Remover \",[\"username\"],\" do canal\"],\"dEgA5A\":[\"Cancelar\"],\"dGi1We\":[\"Desafixar esta conversa de mensagem privada\"],\"dJVuyC\":[\"saiu de \",[\"channelName\"],\" (\",[\"reason\"],\")\"],\"dMtLDE\":[\"para\"],\"dXqxlh\":[\"<0>⚠️ Risco de Segurança! Esta conexão pode ser vulnerável a interceptação ou ataques man-in-the-middle.\"],\"da9Q/R\":[\"Modos do canal alterados\"],\"dhJN3N\":[\"Mostrar comentários\"],\"dj2xTE\":[\"Dispensar notificação\"],\"dpCzmC\":[\"Configurações de proteção contra flood\"],\"e9dQpT\":[\"Deseja abrir este link em uma nova aba?\"],\"ePK91l\":[\"Editar\"],\"eYBDuB\":[\"Faça upload de uma imagem ou forneça uma URL com substituição opcional \",[\"size\"]],\"edBbee\":[\"Banir \",[\"username\"],\" por hostmask (impede que entre novamente pelo mesmo IP/host)\"],\"ekfzWq\":[\"Configurações do usuário\"],\"elPDWs\":[\"Personalize sua experiência no cliente IRC\"],\"eu2osY\":[\"<0>💡 Recomendação: Prossiga apenas se você confiar neste servidor e compreender os riscos. Evite compartilhar informações sensíveis ou senhas nesta conexão.\"],\"euEhbr\":[\"Clique para entrar em \",[\"channel\"]],\"ez3vLd\":[\"Ativar entrada multilinha\"],\"f0J5Ki\":[\"A comunicação servidor a servidor pode usar conexões não criptografadas\"],\"f9BHJk\":[\"Avisar Usuário\"],\"fDOLLd\":[\"Nenhum canal encontrado.\"],\"ffzDkB\":[\"Análises anônimas:\"],\"fq1GF9\":[\"Exibir quando usuários se desconectam do servidor\"],\"gEF57C\":[\"Este servidor suporta apenas um tipo de conexão\"],\"gJuLUI\":[\"Lista de ignorados\"],\"gNzMrk\":[\"Avatar atual\"],\"gjPWyO\":[\"Inserir apelido...\"],\"gz6UQ3\":[\"Maximizar\"],\"h6razj\":[\"Excluir máscara do nome do canal\"],\"hG6jnw\":[\"Nenhum tópico definido\"],\"hG89Ed\":[\"Imagem\"],\"hZ6znB\":[\"Porta\"],\"ha+Bz5\":[\"ex.: 100:1440\"],\"hehnjM\":[\"Quantidade\"],\"hzdLuQ\":[\"Apenas usuários com voz ou superior podem falar\"],\"i0qMbr\":[\"Início\"],\"iDNBZe\":[\"Notificações\"],\"iH8pgl\":[\"Voltar\"],\"iL9SZg\":[\"Banir Usuário (por Apelido)\"],\"iNt+3c\":[\"Voltar para a imagem\"],\"iQvi+a\":[\"Não me avisar sobre baixa segurança de link para este servidor\"],\"iSLIjg\":[\"Conectar\"],\"iWXkHH\":[\"Halfop\"],\"iZeTtp\":[\"Host do Servidor\"],\"idD8Ev\":[\"Salvo\"],\"iivqkW\":[\"Conectado em\"],\"ij+Elv\":[\"Visualização da imagem\"],\"ilIWp7\":[\"Alternar Notificações\"],\"iuaqvB\":[\"Use * para curingas. Exemplos: baduser!*@*, *!*@spammer.com, troll*!*@*\"],\"ixkTse\":[\"Bot\"],\"j2DGR0\":[\"Banir por máscara de host\"],\"jA4uoI\":[\"Tópico:\"],\"jLXxGK\":[\"https://example.com\"],\"jPSk57\":[\"Motivo (opcional)\"],\"jUV7CU\":[\"Fazer upload do avatar\"],\"jW5Uwh\":[\"Controla quanto conteúdo de mídia externa é carregado. Desativado / Seguro / Fontes confiáveis / Todo conteúdo.\"],\"jXzms5\":[\"Opções de anexo\"],\"jZlrte\":[\"Cor\"],\"jfC/xh\":[\"Contato\"],\"jywMpv\":[\"#new-channel-name\"],\"k112DD\":[\"Carregar mensagens antigas\"],\"k3ID0F\":[\"Filtrar membros…\"],\"k65gsE\":[\"Mergulho profundo\"],\"k7Zgob\":[\"Cancelar Conexão\"],\"kAVx5h\":[\"Nenhum convite encontrado\"],\"kCLEPU\":[\"Conectado a\"],\"kF5LKb\":[\"Padrões ignorados:\"],\"kGeOx/\":[\"Entrar em \",[\"0\"]],\"kITKr8\":[\"Carregando modos do canal...\"],\"kPpPsw\":[\"Você é um IRC Operator\"],\"kWJmRL\":[\"Você\"],\"kfcRb0\":[\"Avatar\"],\"kjMqSj\":[\"Copiar JSON\"],\"krViRy\":[\"Clique para copiar como JSON\"],\"ks71ra\":[\"Exceções\"],\"kw4lRv\":[\"Meio operador do canal\"],\"kxgIRq\":[\"Selecione ou adicione um canal para começar.\"],\"ky6dWe\":[\"Visualização do avatar\"],\"l+GxCv\":[\"Carregando canais...\"],\"l+IUVW\":[\"Verificação da conta \",[\"account\"],\" bem-sucedida: \",[\"message\"]],\"l/siQz\":[[\"reconnectCount\",\"plural\",{\"one\":[\"reconectou\"],\"other\":[\"reconectou \",[\"reconnectCount\"],\" vezes\"]}]],\"l5jmzx\":[[\"0\"],\" e \",[\"1\"],\" estão digitando...\"],\"lHy8N5\":[\"Carregando mais canais...\"],\"lbpf14\":[\"Entrar em \",[\"value\"]],\"lfFsZ4\":[\"Canais\"],\"lkNdiH\":[\"Nome da conta\"],\"ln500L\":[\"ObsidianIRC\"],\"lnCMdg\":[\"Enviar Imagem\"],\"loQxaJ\":[\"Estou de Volta\"],\"lvfaxv\":[\"INÍCIO\"],\"m16xKo\":[\"Adicionar\"],\"m8flAk\":[\"Prévia (ainda não enviado)\"],\"mEPxTp\":[\"<0>⚠️ Cuidado! Abra apenas links de fontes confiáveis. Links maliciosos podem comprometer sua segurança ou privacidade.\"],\"mHGdhG\":[\"Informações do servidor\"],\"mHS8lb\":[\"Mensagem #\",[\"0\"]],\"mMYBD9\":[\"Amplo – Escopo de proteção mais amplo\"],\"mTGsPd\":[\"Tópico do canal\"],\"mU8j6O\":[\"Sem mensagens externas (+n)\"],\"mZp8FL\":[\"Retorno automático para linha única\"],\"mdQu8G\":[\"SeuApelido\"],\"miSSBQ\":[\"Comentários (\",[\"commentCount\"],\")\"],\"mvyLSy\":[\"Usuário autenticado\"],\"mwtcGl\":[\"Fechar comentários\"],\"mzI/c+\":[\"Baixar\"],\"n3fGRk\":[\"definido por \",[\"0\"]],\"nE9jsU\":[\"Relaxado – Proteção menos agressiva\"],\"nNflMD\":[\"Sair do canal\"],\"nPXkBi\":[\"Carregando dados WHOIS...\"],\"nQnxxF\":[\"Mensagem #\",[\"0\"],\" (Shift+Enter para nova linha)\"],\"nWMRxa\":[\"Desafixar\"],\"nkC032\":[\"Sem perfil de flood\"],\"o69z4d\":[\"Enviar uma mensagem de aviso para \",[\"username\"]],\"o9ylQi\":[\"Pesquise GIFs para começar\"],\"oFGkER\":[\"Avisos do Servidor\"],\"oOi11l\":[\"Rolar para o fim\"],\"oQEzQR\":[\"Nova mensagem direta\"],\"oXOSPE\":[\"Online\"],\"oal760\":[\"Ataques man-in-the-middle em links de servidor são possíveis\"],\"oeqmmJ\":[\"Fontes Confiáveis\"],\"ovBPCi\":[\"Padrão\"],\"p0Z69r\":[\"O padrão não pode estar vazio\"],\"p1KgtK\":[\"Falha ao carregar áudio\"],\"p59pEv\":[\"Detalhes adicionais\"],\"p7sRI6\":[\"Avisar outros quando você está digitando\"],\"pBm1od\":[\"Canal secreto\"],\"pNmiXx\":[\"Seu apelido padrão para todos os servidores\"],\"pUUo9G\":[\"Hostname:\"],\"pVGPmz\":[\"Senha da conta\"],\"peNE68\":[\"Permanente\"],\"plhHQt\":[\"Sem dados\"],\"pm6+q5\":[\"Aviso de Segurança\"],\"pn5qSs\":[\"Informações adicionais\"],\"q0cR4S\":[\"agora é conhecido como **\",[\"newNick\"],\"**\"],\"qFcunY\":[\"O canal não aparecerá nos comandos LIST ou NAMES\"],\"qLpTm/\":[\"Remover reação \",[\"emoji\"]],\"qVkGWK\":[\"Fixar\"],\"qY8wNa\":[\"Página inicial\"],\"qb0xJ7\":[\"Curingas: * corresponde a qualquer sequência, ? a um único caractere. Exemplos: nick!*@*, *!*@host.com, *!*user@*\"],\"qhzpRq\":[\"Chave do canal (+k)\"],\"qtoOYG\":[\"Sem limite\"],\"r1W2AS\":[\"Imagem do servidor de arquivos\"],\"rIPR2O\":[\"Tópico definido antes (min atrás)\"],\"rMMSYo\":[\"O comprimento máximo é \",[\"0\"]],\"rWtzQe\":[\"A rede se dividiu e reconectou. ✅\"],\"rYG2u6\":[\"Aguarde...\"],\"rdUucN\":[\"Visualização\"],\"rjGI/Q\":[\"Privacidade\"],\"rk8iDX\":[\"Carregando GIFs...\"],\"rn6SBY\":[\"Ativar som\"],\"s/UKqq\":[\"Foi expulso do canal\"],\"s8cATI\":[\"entrou em \",[\"channelName\"]],\"sCO9ue\":[\"A conexão com <0>\",[\"serverName\"],\" apresenta as seguintes preocupações de segurança:\"],\"sGH11W\":[\"Servidor\"],\"sHI1H+\":[\"agora é conhecido como **\",[\"newNick\"],\"**\"],\"sJyV04\":[[\"inviter\"],\" convidou você para entrar em \",[\"channel\"]],\"sby+1/\":[\"Clique para copiar\"],\"sfN25C\":[\"Seu nome real ou completo\"],\"sliuzR\":[\"Abrir Link\"],\"sqrO9R\":[\"Menções personalizadas\"],\"sr6RdJ\":[\"Multilinha com Shift+Enter\"],\"swrCpB\":[\"O canal foi renomeado de \",[\"oldName\"],\" para \",[\"newName\"],\" por \",[\"user\"],[\"0\"]],\"sxkWRg\":[\"Avançado\"],\"t/YqKh\":[\"Remover\"],\"t47eHD\":[\"Seu identificador único neste servidor\"],\"tAkAh0\":[\"URL com substituição opcional \",[\"size\"],\". Exemplo: https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"tXLJS3\":[\"Mostrar ou ocultar a barra lateral de canais\"],\"tfDRzk\":[\"Salvar\"],\"tiBsJk\":[\"saiu de \",[\"channelName\"]],\"tt4/UD\":[\"saiu (\",[\"reason\"],\")\"],\"u0TcnO\":[\"O apelido {nick} já está em uso, tentando com {newNick}\"],\"u0a8B4\":[\"Autenticar como operador IRC para acesso administrativo\"],\"u0rWFU\":[\"Criado após (min atrás)\"],\"u72w3t\":[\"Usuários e padrões a ignorar\"],\"u7jc2L\":[\"saiu\"],\"uAQUqI\":[\"Status\"],\"uB85T3\":[\"Falha ao salvar: \",[\"msg\"]],\"uV3DOL\":[\"G-Line\"],\"uW3lLI\":[\"Servidores IRC:\"],\"usSSr/\":[\"Nível de zoom\"],\"v7uvcf\":[\"Software:\"],\"vE8kb+\":[\"Shift+Enter para novas linhas (Enter envia)\"],\"vERlcd\":[\"Perfil\"],\"vK0RL8\":[\"Sem tópico\"],\"vSJd18\":[\"Vídeo\"],\"vXIe7J\":[\"Idioma\"],\"vaHYxN\":[\"Nome real\"],\"vhjbKr\":[\"Ausente\"],\"w4NYox\":[\"cliente \",[\"title\"]],\"w8xQRx\":[\"Valor inválido\"],\"wFjjxZ\":[\"foi expulso de \",[\"channelName\"],\" por \",[\"username\"],\" (\",[\"reason\"],\")\"],\"wGjaGl\":[\"Nenhuma exceção de banimento encontrada\"],\"wPrGnM\":[\"Administrador do canal\"],\"wRkP2d\":[\"GIF\"],\"wbm86v\":[\"Exibir quando usuários entram ou saem de canais\"],\"whqZ9r\":[\"Palavras ou frases adicionais para destacar\"],\"wm7RV4\":[\"Som de notificação\"],\"wz/Yoq\":[\"Suas mensagens podem ser interceptadas ao serem retransmitidas entre servidores\"],\"xCJdfg\":[\"Limpar\"],\"xUHRTR\":[\"Autenticar automaticamente como operador ao conectar\"],\"xWHwwQ\":[\"Banimentos\"],\"xYilR2\":[\"Mídia\"],\"xceQrO\":[\"Apenas websockets seguros são suportados\"],\"xdtXa+\":[\"nome-do-canal\"],\"xfXC7q\":[\"Canais de texto\"],\"xlCYOE\":[\"Carregando mais mensagens...\"],\"xlhswE\":[\"O valor mínimo é \",[\"0\"]],\"xq97Ci\":[\"Adicionar uma palavra ou frase...\"],\"xuRqRq\":[\"Limite de clientes (+l)\"],\"xwF+7J\":[[\"0\"],\" está digitando...\"],\"yNeucF\":[\"Este servidor não suporta metadados de perfil estendidos (extensão IRCv3 METADATA). Campos como avatar, nome de exibição e status não estão disponíveis.\"],\"yPlrca\":[\"Avatar do canal\"],\"yQE2r9\":[\"Carregando\"],\"ySU+JY\":[\"seu@email.com\"],\"yTX1Rt\":[\"Nome de usuário Oper\"],\"yYOzWD\":[\"logs\"],\"yfx9Re\":[\"Senha de operador IRC\"],\"ygCKqB\":[\"Parar\"],\"ymDxJx\":[\"Nome de usuário de operador IRC\"],\"yrpRsQ\":[\"Ordenar por nome\"],\"yz7wBu\":[\"Fechar\"],\"zJw+jA\":[\"define modo: \",[\"0\"]],\"zebeLu\":[\"Digite o nome de usuário oper\"],\"zpr0Bw\":[\"GZ-Line\"]}"); \ No newline at end of file +/*eslint-disable*/export const messages=JSON.parse("{\"+5VMrz\":[\"Formato de padrão inválido. Use o formato nick!user@host (curingas * permitidos)\"],\"+6NQQA\":[\"Canal de Suporte Geral\"],\"+6NyRG\":[\"Cliente\"],\"+K0AvT\":[\"Desconectar\"],\"+cyFdH\":[\"Mensagem padrão ao marcar-se como ausente\"],\"+mVPqU\":[\"Renderizar formatação Markdown nas mensagens\"],\"+vqCJH\":[\"Seu nome de usuário de conta para autenticação\"],\"+yPBXI\":[\"Escolher arquivo\"],\"+zy2Nq\":[\"Tipo\"],\"/09cao\":[\"Baixa Segurança de Link (Nível \",[\"securityLevel\"],\")\"],\"/3BQ4J\":[\"Usuários fora do canal não podem enviar mensagens\"],\"/6BzZF\":[\"Alternar Lista de Membros\"],\"/TNOPk\":[\"O usuário está ausente\"],\"/XQgft\":[\"Descobrir\"],\"/cF7Rs\":[\"Volume\"],\"/dqduX\":[\"Próxima página\"],\"/fc3q4\":[\"Todo o Conteúdo\"],\"/kISDh\":[\"Ativar sons de notificação\"],\"/n04sB\":[\"Kill\"],\"/rTz0M\":[\"Áudio\"],\"/rfkZe\":[\"Reproduzir sons para menções e mensagens\"],\"0/0ZGA\":[\"Máscara do nome do canal\"],\"0D6j7U\":[\"Saiba mais sobre regras personalizadas →\"],\"0XsHcR\":[\"Expulsar Usuário\"],\"0ZpE//\":[\"Ordenar por usuários\"],\"0bEPwz\":[\"Definir como Ausente\"],\"0dGkPt\":[\"Expandir lista de canais\"],\"0gS7M5\":[\"Nome de exibição\"],\"0kS+M8\":[\"ExemploREDE\"],\"0rgoY7\":[\"Conectar apenas a servidores que você escolher\"],\"0wdd7X\":[\"Entrar\"],\"0wkVYx\":[\"Mensagens privadas\"],\"111uHX\":[\"Visualização do link\"],\"196EG4\":[\"Excluir Conversa Privada\"],\"1DSr1i\":[\"Registrar uma conta\"],\"1O/24y\":[\"Alternar Lista de Canais\"],\"1TNIig\":[\"Open\"],\"1VPJJ2\":[\"Aviso de Link Externo\"],\"1ZC/dv\":[\"Nenhuma menção ou mensagem não lida\"],\"1pO1zi\":[\"Nome do servidor é obrigatório\"],\"1uwfzQ\":[\"Ver Tópico do Canal\"],\"268g7c\":[\"Digite o nome de exibição\"],\"2FOFq1\":[\"Operadores de servidor na rede podem potencialmente ler suas mensagens\"],\"2FYpfJ\":[\"Mais\"],\"2HF1Y2\":[[\"inviter\"],\" convidou \",[\"target\"],\" para entrar em \",[\"channel\"]],\"2I70QL\":[\"Ver informações do perfil do usuário\"],\"2QYdmE\":[\"Usuários:\"],\"2QpEjG\":[\"saiu\"],\"2YE223\":[\"Mensagem #\",[\"0\"],\" (Enter para nova linha, Shift+Enter para enviar)\"],\"2bimFY\":[\"Usar senha do servidor\"],\"2iTmdZ\":[\"Armazenamento local:\"],\"2odkwe\":[\"Estrito – Proteção mais agressiva\"],\"2uDhbA\":[\"Digite o nome de usuário para convidar\"],\"2ygf/L\":[\"← Voltar\"],\"2zEgxj\":[\"Pesquisar GIFs...\"],\"3RdPhl\":[\"Renomear Canal\"],\"3THokf\":[\"Usuário com voz\"],\"3TSz9S\":[\"Minimizar\"],\"3jBDvM\":[\"Nome de exibição do canal\"],\"3ryuFU\":[\"Relatórios de falha opcionais para melhorar o app\"],\"3uBF/8\":[\"Fechar visualizador\"],\"3uwW8F\":[\"https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"3xf8Kz\":[\"Inserir nome da conta...\"],\"4/Rr0R\":[\"Convidar um usuário para o canal atual\"],\"4EZrJN\":[\"Regras\"],\"4JJtW9\":[\"#overflow\"],\"4NqeT4\":[\"Perfil de flood (+F)\"],\"4RZQRK\":[\"O que você está fazendo?\"],\"4hfTrB\":[\"Apelido\"],\"4n99LO\":[\"Já em \",[\"0\"]],\"4t6vMV\":[\"Mudar automaticamente para linha única em mensagens curtas\"],\"4vsHmf\":[\"Tempo (min)\"],\"4x/Axu\":[\"Seu bouncer ainda não possui redes. Adicione uma para começar.\"],\"5+INAX\":[\"Destacar mensagens que mencionam você\"],\"5R5Pv/\":[\"Nome Oper\"],\"678PKt\":[\"Nome da Rede\"],\"6Aih4U\":[\"Offline\"],\"6CO3WE\":[\"Senha necessária para entrar no canal. Deixe vazio para remover a chave.\"],\"6HhMs3\":[\"Mensagem de saída\"],\"6V3Ea3\":[\"Copiado\"],\"6lGV3K\":[\"Mostrar menos\"],\"6yFOEi\":[\"Inserir senha do oper...\"],\"7+IHTZ\":[\"Nenhum arquivo escolhido\"],\"73fnil\":[\"TLS\"],\"73hrRi\":[\"nick!user@host (ex.: spam*!*@*, *!*@badhost.com)\"],\"7QkKyN\":[\"Enviar mensagem privada\"],\"7U1W7c\":[\"Muito Relaxado\"],\"7Y1YQj\":[\"Nome real:\"],\"7YHArF\":[\"— abrir no visualizador\"],\"7fjnVl\":[\"Pesquisar usuários...\"],\"7jL88x\":[\"Excluir esta mensagem? Esta ação não pode ser desfeita.\"],\"7nGhhM\":[\"O que você está pensando?\"],\"7sEpu1\":[\"Membros — \",[\"0\"]],\"7sNhEz\":[\"Nome de usuário\"],\"8H0Q+x\":[\"Saiba mais sobre perfis →\"],\"8Phu0A\":[\"Exibir quando usuários mudam seu apelido\"],\"8XTG9e\":[\"Digite a senha oper\"],\"8XsV2J\":[\"Tentar enviar novamente\"],\"8ZsakT\":[\"Senha\"],\"8kR84m\":[\"Você está prestes a abrir um link externo:\"],\"8lCgih\":[\"Remover regra\"],\"8p/xVT\":[[\"0\",\"plural\",{\"one\":[[\"1\"]],\"other\":[[\"2\"]]}]],\"8wRzac\":[[\"joinCount\",\"plural\",{\"one\":[\"entrou\"],\"other\":[\"entrou \",[\"joinCount\"],\" vezes\"]}]],\"9BMLnJ\":[\"Reconectar ao servidor\"],\"9OEgyT\":[\"Adicionar reação\"],\"9PQ8m2\":[\"G-Line (ban global)\"],\"9Qs99X\":[\"E-mail:\"],\"9QupBP\":[\"Remover padrão\"],\"9W7tl5\":[\"(inalterado)\"],\"9bG48P\":[\"Enviando\"],\"9f5f0u\":[\"Dúvidas sobre privacidade? Entre em contato:\"],\"9iweoP\":[\"Redes em \",[\"0\"]],\"9unqs3\":[\"Ausente:\"],\"9v3hwv\":[\"Nenhum servidor encontrado.\"],\"9zb2WA\":[\"Conectando\"],\"A1taO8\":[\"Pesquisar\"],\"A2adVi\":[\"Enviar notificações de digitação\"],\"A9Rhec\":[\"Nome do canal\"],\"AWOSPo\":[\"Ampliar\"],\"AXSpEQ\":[\"Oper ao conectar\"],\"AeXO77\":[\"Conta\"],\"AhNP40\":[\"Avançar\"],\"Ai2U7L\":[\"Host\"],\"AjBQnf\":[\"Apelido alterado\"],\"AmXVh6\":[\"https://example.com/avatar.png\"],\"AnRu/j\":[\"Cancelar resposta\"],\"ApSx0O\":[\"Encontradas \",[\"0\"],\" mensagens correspondentes a \\\"\",[\"searchQuery\"],\"\\\"\"],\"AxPAXW\":[\"Nenhum resultado encontrado\"],\"AyNqAB\":[\"Exibir todos os eventos do servidor no chat\"],\"B/QqGw\":[\"Longe do teclado\"],\"B0sB2k\":[\"Texto puro\"],\"B8AaMI\":[\"Este campo é obrigatório\"],\"BA2c49\":[\"O servidor não suporta filtragem avançada de LIST\"],\"BDKt3I\":[[\"0\"],\", \",[\"1\"],\", \",[\"2\"],\" e mais \",[\"3\"],\" estão digitando...\"],\"BGul2A\":[\"Você tem alterações não salvas. Tem certeza que deseja fechar sem salvar?\"],\"BIf9fi\":[\"Sua mensagem de status\"],\"BZz3md\":[\"Seu site pessoal\"],\"Bgm/H7\":[\"Permitir inserção de múltiplas linhas de texto\"],\"BiQIl1\":[\"Fixar esta conversa de mensagem privada\"],\"BlNZZ2\":[\"Clique para ir à mensagem\"],\"Bowq3c\":[\"Apenas operadores podem alterar o tópico\"],\"Btozzp\":[\"Esta imagem expirou\"],\"Bycfjm\":[\"Total: \",[\"0\"]],\"C6IBQc\":[\"Copiar JSON completo\"],\"C9L9wL\":[\"Coleta de dados\"],\"CDq4wC\":[\"Moderar Usuário\"],\"CHVRxG\":[\"Mensagem @\",[\"0\"],\" (Shift+Enter para nova linha)\"],\"CN9zdR\":[\"Nome oper e senha são obrigatórios\"],\"CW3sYa\":[\"Adicionar reação \",[\"emoji\"]],\"CaAkqd\":[\"Mostrar desconexões\"],\"CbvaYj\":[\"Banir por apelido\"],\"CcK+Ft\":[\"PDF\"],\"Ce8q3L\":[\"Selecionar um canal\"],\"CsekCi\":[\"Normal\"],\"D+NlUC\":[\"Sistema\"],\"D28t6+\":[\"entrou e saiu\"],\"DB8zMK\":[\"Aplicar\"],\"DBcWHr\":[\"Arquivo de som de notificação personalizado\"],\"DTy9Xw\":[\"Pré-visualizações de mídia\"],\"Dj4pSr\":[\"Escolha uma senha segura\"],\"Du+zn+\":[\"Pesquisando...\"],\"Du2T2f\":[\"Configuração não encontrada\"],\"DwsSVQ\":[\"Aplicar filtros e atualizar\"],\"E3W/zd\":[\"Apelido padrão\"],\"E6nRW7\":[\"Copiar URL\"],\"E703RG\":[\"Modos:\"],\"EAeu1Z\":[\"Enviar convite\"],\"EFKJQT\":[\"Configuração\"],\"EGPQBv\":[\"Regras de flood personalizadas (+f)\"],\"ELik0r\":[\"Ver política de privacidade completa\"],\"EPbeC2\":[\"Ver ou editar o tópico do canal\"],\"EQCDNT\":[\"Inserir nome de usuário oper...\"],\"EUvulZ\":[\"Encontrada 1 mensagem correspondente a \\\"\",[\"searchQuery\"],\"\\\"\"],\"EatZYJ\":[\"Próxima imagem\"],\"EdQY6l\":[\"Nenhum\"],\"EnqLYU\":[\"Pesquisar servidores...\"],\"F0OKMc\":[\"Editar Servidor\"],\"F6Int2\":[\"Ativar destaques\"],\"FDoLyE\":[\"Usuários máx.\"],\"FUU/hZ\":[\"Controla quanto conteúdo de mídia externa é carregado no chat.\"],\"Fdp03t\":[\"ativo\"],\"FfPWR0\":[\"Modal\"],\"FjkaiT\":[\"Reduzir\"],\"FlqOE9\":[\"O que isso significa:\"],\"FolHNl\":[\"Gerenciar sua conta e autenticação\"],\"Fp2Dif\":[\"Saiu do servidor\"],\"G5KmCc\":[\"GZ-Line (Z-Line global)\"],\"GDs0lz\":[\"<0>Risco: Informações sensíveis (mensagens, conversas privadas, dados de autenticação) podem ser expostas a administradores de rede ou atacantes posicionados entre servidores IRC.\"],\"GR+2I3\":[\"Adicionar máscara de convite (ex.: nick!*@*, *!*@host.com)\"],\"GRLyMU\":[\"Fechar avisos do servidor em destaque\"],\"GlHnXw\":[\"Falha ao alterar apelido: \",[\"error\"],\" \",[\"0\"]],\"GswZF3\":[\"Prévia:\"],\"GtmO8/\":[\"de\"],\"GtuHUQ\":[\"Renomear este canal no servidor. Todos os usuários verão o novo nome.\"],\"GuGfFX\":[\"Alternar pesquisa\"],\"GxkJXS\":[\"Enviando...\"],\"GzbwnK\":[\"Entrou no canal\"],\"GzsUDB\":[\"Perfil estendido\"],\"H/PnT8\":[\"Inserir emoji\"],\"H6Izzl\":[\"Seu código de cor preferido\"],\"H9jIv+\":[\"Mostrar entradas/saídas\"],\"HAKBY9\":[\"Enviar ficheiros\"],\"HdE1If\":[\"Canal\"],\"Hk4AW9\":[\"Seu nome de exibição preferido\"],\"HmHDk7\":[\"Selecionar Membro\"],\"HrQzPU\":[\"Canais em \",[\"networkName\"]],\"I2tXQ5\":[\"Mensagem @\",[\"0\"],\" (Enter para nova linha, Shift+Enter para enviar)\"],\"I6bw/h\":[\"Banir usuário\"],\"I92Z+b\":[\"Ativar notificações\"],\"I9D72S\":[\"Tem certeza que deseja excluir esta mensagem? Esta ação não pode ser desfeita.\"],\"IA+1wo\":[\"Exibir quando usuários são expulsos de canais\"],\"IDwkJx\":[\"IRC Operator\"],\"ILlU+s\":[\"Info:\"],\"IUwGEM\":[\"Salvar Alterações\"],\"IVeGK6\":[[\"0\"],\", \",[\"1\"],\" e \",[\"2\"],\" estão digitando...\"],\"IgrLD/\":[\"Pausar\"],\"Im6JED\":[\"WHISPER\"],\"ImOQa9\":[\"Responder\"],\"IoHMnl\":[\"O valor máximo é \",[\"0\"]],\"IvMj+0\":[\"Op\"],\"J28zul\":[\"Conectando...\"],\"J5T9NW\":[\"Informações do usuário\"],\"J8Y5+z\":[\"Ops! Divisão de rede! ⚠️\"],\"JBHkBA\":[\"Saiu do canal\"],\"JCwL0Q\":[\"Digite o motivo (opcional)\"],\"JFciKP\":[\"Alternar\"],\"JXGkhG\":[\"Alterar o nome do canal (apenas operadores)\"],\"JcD7qf\":[\"Mais ações\"],\"JdkA+c\":[\"Secreto (+s)\"],\"Jmu12l\":[\"Canais do Servidor\"],\"JvQ++s\":[\"Ativar Markdown\"],\"K2jwh/\":[\"Nenhum dado WHOIS disponível\"],\"KAXSwC\":[\"Voz\"],\"KDfTdX\":[\"Excluir mensagem\"],\"KKBlUU\":[\"Incorporar\"],\"KM0pLb\":[\"Bem-vindo ao canal!\"],\"KR6W2h\":[\"Deixar de ignorar usuário\"],\"KV+Bi1\":[\"Apenas por convite (+i)\"],\"KdCtwE\":[\"Quantos segundos monitorar a atividade de flood antes de redefinir os contadores\"],\"Kkezga\":[\"Senha do Servidor\"],\"KsiQ/8\":[\"Os usuários devem ser convidados para entrar no canal\"],\"L+gB/D\":[\"Informações do canal\"],\"LC1a7n\":[\"O servidor IRC relatou que seus links servidor a servidor têm um nível de segurança baixo. Isso significa que quando suas mensagens são retransmitidas entre servidores IRC na rede, elas podem não estar devidamente criptografadas ou os certificados SSL/TLS podem não ser validados corretamente.\"],\"LNfLR5\":[\"Mostrar expulsões\"],\"LP+1Z7\":[\"Adicionar rede\"],\"LQb0W/\":[\"Mostrar todos os eventos\"],\"LU7/yA\":[\"Nome alternativo para exibição. Pode conter espaços, emojis e caracteres especiais. O nome real (\",[\"channelName\"],\") ainda será usado para comandos IRC.\"],\"LUb9O7\":[\"Porta de servidor válida é obrigatória\"],\"Lb+BUl\":[\"https://example.com/avatar.jpg\"],\"LcET2C\":[\"Política de privacidade\"],\"LcuSDR\":[\"Gerenciar suas informações de perfil e metadados\"],\"LqLS9B\":[\"Mostrar mudanças de apelido\"],\"LsDQt2\":[\"Configurações do Canal\"],\"LtI9AS\":[\"Dono\"],\"LuNhhL\":[\"reagiu a esta mensagem\"],\"M/AZNG\":[\"URL da sua imagem de avatar\"],\"M/WIer\":[\"Enviar mensagem\"],\"M8er/5\":[\"Nome:\"],\"MHk+7g\":[\"Imagem anterior\"],\"MRorGe\":[\"Mensagem Privada\"],\"MVbSGP\":[\"Janela de tempo (segundos)\"],\"MkpcsT\":[\"Suas mensagens e configurações são armazenadas localmente\"],\"MzPdC2\":[\"Senha do servidor (PASS)\"],\"N/hDSy\":[\"Marcar como bot, geralmente 'on' ou vazio\"],\"N6j2JH\":[\"Editar \",[\"0\"]],\"N7TQbE\":[\"Convidar usuário para \",[\"channelName\"]],\"NCca/o\":[\"Inserir apelido padrão...\"],\"Nqs6B9\":[\"Exibe toda a mídia externa. Qualquer URL pode causar uma requisição a um servidor desconhecido.\"],\"Nt+9O7\":[\"Usar WebSocket em vez de TCP bruto\"],\"NxIHzc\":[\"Expulsar usuário\"],\"O+v/cL\":[\"Navegar por todos os canais do servidor\"],\"OCGpR4\":[\"(herdar)\"],\"ODwSCk\":[\"Enviar um GIF\"],\"OGQ5kK\":[\"Configurar sons de notificação e destaques\"],\"OIPt1Z\":[\"Mostrar ou ocultar a barra lateral de membros\"],\"OKSNq/\":[\"Muito Estrito\"],\"ONWvwQ\":[\"Carregar\"],\"OVKoQO\":[\"Sua senha de conta para autenticação\"],\"ObsidianIRC - Bringing IRC to the future\":[\"ObsidianIRC - Levando o IRC para o futuro\"],\"OhCpra\":[\"Definir um tópico…\"],\"OkltoQ\":[\"Banir \",[\"username\"],\" por apelido (impede que entre novamente com o mesmo nick)\"],\"P+t/Te\":[\"Sem dados adicionais\"],\"P42Wcc\":[\"Seguro\"],\"PD38l0\":[\"Visualização do avatar do canal\"],\"PD9mEt\":[\"Digite uma mensagem...\"],\"PPqfdA\":[\"Abrir configurações do canal\"],\"PSCjfZ\":[\"O tópico exibido para este canal. Todos os usuários podem ver.\"],\"PZCecv\":[\"Pré-visualização de PDF\"],\"PeLgsC\":[[\"c\",\"plural\",{\"one\":[\"1 vez\"],\"other\":[[\"c\"],\" vezes\"]}]],\"PguS2C\":[\"Adicionar máscara de exceção (ex.: nick!*@*, *!*@host.com)\"],\"Pil5Ty\":[\"Mostrando \",[\"displayedChannelsCount\"],\" de \",[\"0\"],\" canais\"],\"PqhVlJ\":[\"Banir Usuário (por Hostmask)\"],\"Q+chwU\":[\"Nome de usuário:\"],\"Q3v9Wc\":[\"Sim, excluir\"],\"Q6hhn8\":[\"Preferências\"],\"QF4a34\":[\"Por favor, insira um nome de usuário\"],\"QGqSZ2\":[\"Cor e Formatação\"],\"QJQd1J\":[\"Editar perfil\"],\"QSzGDE\":[\"Ocioso\"],\"QUlny5\":[\"Bem-vindo ao \",[\"0\"],\"!\"],\"Qoq+GP\":[\"Ler mais\"],\"QuSkCF\":[\"Filtrar canais...\"],\"QwUrDZ\":[\"alterou o tópico para: \",[\"topic\"]],\"R0UH07\":[\"Imagem \",[\"0\"],\" de \",[\"1\"]],\"R7SsBE\":[\"Silenciar\"],\"R8rf1X\":[\"Clique para definir o tópico\"],\"RArB3D\":[\"foi expulso de \",[\"channelName\"],\" por \",[\"username\"]],\"RI3cWd\":[\"Descubra o mundo do IRC com o ObsidianIRC\"],\"RMMaN5\":[\"Moderado (+m)\"],\"RWw9Lg\":[\"Fechar janela\"],\"RZ2BuZ\":[\"O registro da conta \",[\"account\"],\" requer verificação: \",[\"message\"]],\"RySp6q\":[\"Ocultar comentários\"],\"S5Togi\":[\"Carregando redes do seu bouncer…\"],\"SPKQTd\":[\"Apelido é obrigatório\"],\"SPVjfj\":[\"Será definido como 'sem motivo' se deixado em branco\"],\"SQKPvQ\":[\"Convidar Usuário\"],\"STmlpb\":[\"Back to network list\"],\"SkZcl+\":[\"Escolha um perfil de proteção contra flood predefinido. Estes perfis fornecem configurações de proteção equilibradas para diferentes casos de uso.\"],\"Slr+3C\":[\"Usuários mín.\"],\"Spnlre\":[\"Você convidou \",[\"target\"],\" para entrar em \",[\"channel\"]],\"T/ckN5\":[\"Abrir no visualizador\"],\"T91vKp\":[\"Reproduzir\"],\"TV2Wdu\":[\"Saiba como tratamos seus dados e protegemos sua privacidade.\"],\"TgFpwD\":[\"Aplicando...\"],\"TkzSFB\":[\"Sem Alterações\"],\"TtserG\":[\"Digite o nome real\"],\"Ttz9J1\":[\"Inserir senha...\"],\"Tz0i8g\":[\"Configurações\"],\"U3pytU\":[\"Admin\"],\"UDb2YD\":[\"Reagir\"],\"UE4KO5\":[\"*channel*\"],\"UGT5vp\":[\"Salvar configurações\"],\"UV5hLB\":[\"Nenhum banimento encontrado\"],\"Uaj3Nd\":[\"Mensagens de Status\"],\"Ue3uny\":[\"Padrão (sem perfil)\"],\"UkARhe\":[\"Normal – Proteção padrão\"],\"Umn7Cj\":[\"Ainda sem comentários. Seja o primeiro!\"],\"UtUIRh\":[[\"0\"],\" mensagens antigas\"],\"UwzP+U\":[\"Conexão segura\"],\"V0/A4O\":[\"Proprietário do canal\"],\"V4qgxE\":[\"Criado antes (min atrás)\"],\"V8yTm6\":[\"Limpar pesquisa\"],\"VJMMyz\":[\"ObsidianIRC - Levando o IRC ao futuro\"],\"VJScHU\":[\"Motivo\"],\"VLsmVV\":[\"Silenciar notificações\"],\"VbyRUy\":[\"Comentários\"],\"Vmx0mQ\":[\"Definido por:\"],\"VqnIZz\":[\"Ver nossa política de privacidade e práticas de dados\"],\"VrMygG\":[\"O comprimento mínimo é \",[\"0\"]],\"VrnTui\":[\"Seus pronomes, exibidos no seu perfil\"],\"W8E3qn\":[\"Conta autenticada\"],\"WAakm9\":[\"Excluir Canal\"],\"WFxTHC\":[\"Adicionar máscara de banimento (ex.: nick!*@*, *!*@host.com)\"],\"WN1g9F\":[\"Host do servidor é obrigatório\"],\"WRYdXW\":[\"Posição do áudio\"],\"WUOH5B\":[\"Ignorar usuário\"],\"WWEXnZ\":[[\"0\",\"plural\",{\"one\":[\"Mostrar 1 item a mais\"],\"other\":[\"Mostrar \",[\"1\"],\" itens a mais\"]}]],\"Weq9zb\":[\"Geral\"],\"Wfj7Sk\":[\"Silenciar ou ativar sons de notificação\"],\"Wm7gbG\":[\"GitHub:\"],\"WyeHWY\":[\"*spam*\"],\"WzMCru\":[\"Perfil do Usuário\"],\"X6S3lt\":[\"Pesquisar configurações, canais, servidores...\"],\"XEHan5\":[\"Continuar Assim Mesmo\"],\"XI1+wb\":[\"Formato inválido\"],\"XIXeuC\":[\"Mensagem @\",[\"0\"]],\"XMS+k4\":[\"Iniciar Mensagem Privada\"],\"XWgxXq\":[\"Álbum\"],\"Xd7+IT\":[\"Desafixar Conversa Privada\"],\"Xm/s+u\":[\"Exibição\"],\"Xp2n93\":[\"Exibe mídia do host de arquivos confiável do seu servidor. Nenhuma requisição é feita a serviços externos.\"],\"XvjC4F\":[\"Salvando...\"],\"Y/qryO\":[\"Nenhum usuário encontrado para sua pesquisa\"],\"YAqRpI\":[\"Registro da conta \",[\"account\"],\" bem-sucedido: \",[\"message\"]],\"YEfzvP\":[\"Tópico protegido (+t)\"],\"YQOn6a\":[\"Recolher lista de membros\"],\"YRCoE9\":[\"Operador do canal\"],\"YURQaF\":[\"Ver perfil\"],\"YdBSvr\":[\"Controlar exibição de mídia e conteúdo externo\"],\"Yj6U3V\":[\"Sem servidor central:\"],\"YjvpGx\":[\"Pronomes\"],\"YqH4l4\":[\"Sem chave\"],\"YyUPpV\":[\"Conta:\"],\"ZJSWfw\":[\"Mensagem exibida ao desconectar do servidor\"],\"ZR1dJ4\":[\"Convites\"],\"ZdWg0V\":[\"Abrir no navegador\"],\"ZhRBbl\":[\"Pesquisar mensagens…\"],\"Zmcu3y\":[\"Filtros avançados\"],\"a2/8e5\":[\"Tópico definido após (min atrás)\"],\"aHKcKc\":[\"Página anterior\"],\"aJTbXX\":[\"Senha Oper\"],\"aQryQv\":[\"O padrão já existe\"],\"aW9pLN\":[\"Número máximo de usuários permitidos. Deixe vazio para sem limite.\"],\"ah4fmZ\":[\"Também exibe visualizações do YouTube, Vimeo, SoundCloud e serviços conhecidos similares.\"],\"aifXak\":[\"Nenhuma mídia neste canal\"],\"ap2zBz\":[\"Relaxado\"],\"az8lvo\":[\"Desligado\"],\"azXSNo\":[\"Expandir lista de membros\"],\"azdliB\":[\"Entrar em uma conta\"],\"b26wlF\":[\"ela/dela\"],\"bD/+Ei\":[\"Estrito\"],\"bQ6BJn\":[\"Configure regras detalhadas de proteção contra flood. Cada regra especifica que tipo de atividade monitorar e que ação tomar quando os limites são excedidos.\"],\"beV7+y\":[\"O usuário receberá um convite para entrar em \",[\"channelName\"],\".\"],\"bk84cH\":[\"Mensagem de ausência\"],\"bkHdLj\":[\"Adicionar servidor IRC\"],\"bmQLn5\":[\"Adicionar regra\"],\"bv4cFj\":[\"Transporte\"],\"bwRvnp\":[\"Ação\"],\"c8+EVZ\":[\"Conta verificada\"],\"cGYUlD\":[\"Nenhuma visualização de mídia é carregada.\"],\"cLF98o\":[\"Mostrar comentários (\",[\"commentCount\"],\")\"],\"cLKIDO\":[\"Nenhum usuário disponível\"],\"cSgpoS\":[\"Fixar Conversa Privada\"],\"cde3ce\":[\"Mensagem <0>\",[\"0\"],\"\"],\"chQsxg\":[\"Copiar saída formatada\"],\"cl/A5J\":[\"Bem-vindo ao \",[\"__DEFAULT_IRC_SERVER_NAME__\"],\"!\"],\"cnGeoo\":[\"Excluir\"],\"coPLXT\":[\"Não armazenamos suas comunicações IRC em nossos servidores\"],\"crYH/6\":[\"Player do SoundCloud\"],\"cv5DQb\":[\"nenhum host definido\"],\"d3sis4\":[\"Adicionar Servidor\"],\"d9aN5k\":[\"Remover \",[\"username\"],\" do canal\"],\"dEgA5A\":[\"Cancelar\"],\"dGi1We\":[\"Desafixar esta conversa de mensagem privada\"],\"dJVuyC\":[\"saiu de \",[\"channelName\"],\" (\",[\"reason\"],\")\"],\"dMtLDE\":[\"para\"],\"dXqxlh\":[\"<0>⚠️ Risco de Segurança! Esta conexão pode ser vulnerável a interceptação ou ataques man-in-the-middle.\"],\"da9Q/R\":[\"Modos do canal alterados\"],\"dhJN3N\":[\"Mostrar comentários\"],\"dj2xTE\":[\"Dispensar notificação\"],\"dpCzmC\":[\"Configurações de proteção contra flood\"],\"e9dQpT\":[\"Deseja abrir este link em uma nova aba?\"],\"ePK91l\":[\"Editar\"],\"eYBDuB\":[\"Faça upload de uma imagem ou forneça uma URL com substituição opcional \",[\"size\"]],\"edBbee\":[\"Banir \",[\"username\"],\" por hostmask (impede que entre novamente pelo mesmo IP/host)\"],\"ekfzWq\":[\"Configurações do usuário\"],\"elPDWs\":[\"Personalize sua experiência no cliente IRC\"],\"eu2osY\":[\"<0>💡 Recomendação: Prossiga apenas se você confiar neste servidor e compreender os riscos. Evite compartilhar informações sensíveis ou senhas nesta conexão.\"],\"euEhbr\":[\"Clique para entrar em \",[\"channel\"]],\"ez3vLd\":[\"Ativar entrada multilinha\"],\"f0J5Ki\":[\"A comunicação servidor a servidor pode usar conexões não criptografadas\"],\"f9BHJk\":[\"Avisar Usuário\"],\"fDOLLd\":[\"Nenhum canal encontrado.\"],\"ffzDkB\":[\"Análises anônimas:\"],\"fq1GF9\":[\"Exibir quando usuários se desconectam do servidor\"],\"gEF57C\":[\"Este servidor suporta apenas um tipo de conexão\"],\"gJuLUI\":[\"Lista de ignorados\"],\"gNzMrk\":[\"Avatar atual\"],\"gjPWyO\":[\"Inserir apelido...\"],\"gz6UQ3\":[\"Maximizar\"],\"h6/IMX\":[\"Adicione sua primeira rede\"],\"h6razj\":[\"Excluir máscara do nome do canal\"],\"hG6jnw\":[\"Nenhum tópico definido\"],\"hG89Ed\":[\"Imagem\"],\"hZ6znB\":[\"Porta\"],\"ha+Bz5\":[\"ex.: 100:1440\"],\"hehnjM\":[\"Quantidade\"],\"hzdLuQ\":[\"Apenas usuários com voz ou superior podem falar\"],\"i0qMbr\":[\"Início\"],\"iDNBZe\":[\"Notificações\"],\"iH8pgl\":[\"Voltar\"],\"iL9SZg\":[\"Banir Usuário (por Apelido)\"],\"iNt+3c\":[\"Voltar para a imagem\"],\"iQvi+a\":[\"Não me avisar sobre baixa segurança de link para este servidor\"],\"iSLIjg\":[\"Conectar\"],\"iWXkHH\":[\"Halfop\"],\"iZeTtp\":[\"Host do Servidor\"],\"idD8Ev\":[\"Salvo\"],\"iivqkW\":[\"Conectado em\"],\"ij+Elv\":[\"Visualização da imagem\"],\"ilIWp7\":[\"Alternar Notificações\"],\"iuaqvB\":[\"Use * para curingas. Exemplos: baduser!*@*, *!*@spammer.com, troll*!*@*\"],\"ixkTse\":[\"Bot\"],\"j2DGR0\":[\"Banir por máscara de host\"],\"jA4uoI\":[\"Tópico:\"],\"jLXxGK\":[\"https://example.com\"],\"jPSk57\":[\"Motivo (opcional)\"],\"jUV7CU\":[\"Fazer upload do avatar\"],\"jW5Uwh\":[\"Controla quanto conteúdo de mídia externa é carregado. Desativado / Seguro / Fontes confiáveis / Todo conteúdo.\"],\"jXzms5\":[\"Opções de anexo\"],\"jZlrte\":[\"Cor\"],\"jfC/xh\":[\"Contato\"],\"jywMpv\":[\"#new-channel-name\"],\"k112DD\":[\"Carregar mensagens antigas\"],\"k3ID0F\":[\"Filtrar membros…\"],\"k65gsE\":[\"Mergulho profundo\"],\"k7Zgob\":[\"Cancelar Conexão\"],\"kAVx5h\":[\"Nenhum convite encontrado\"],\"kCLEPU\":[\"Conectado a\"],\"kF5LKb\":[\"Padrões ignorados:\"],\"kGeOx/\":[\"Entrar em \",[\"0\"]],\"kITKr8\":[\"Carregando modos do canal...\"],\"kPpPsw\":[\"Você é um IRC Operator\"],\"kWJmRL\":[\"Você\"],\"kfcRb0\":[\"Avatar\"],\"kjMqSj\":[\"Copiar JSON\"],\"krViRy\":[\"Clique para copiar como JSON\"],\"ks71ra\":[\"Exceções\"],\"kw4lRv\":[\"Meio operador do canal\"],\"kxgIRq\":[\"Selecione ou adicione um canal para começar.\"],\"ky6dWe\":[\"Visualização do avatar\"],\"l+GxCv\":[\"Carregando canais...\"],\"l+IUVW\":[\"Verificação da conta \",[\"account\"],\" bem-sucedida: \",[\"message\"]],\"l/siQz\":[[\"reconnectCount\",\"plural\",{\"one\":[\"reconectou\"],\"other\":[\"reconectou \",[\"reconnectCount\"],\" vezes\"]}]],\"l5jmzx\":[[\"0\"],\" e \",[\"1\"],\" estão digitando...\"],\"lHy8N5\":[\"Carregando mais canais...\"],\"lbpf14\":[\"Entrar em \",[\"value\"]],\"lfFsZ4\":[\"Canais\"],\"lkNdiH\":[\"Nome da conta\"],\"ln500L\":[\"ObsidianIRC\"],\"lnCMdg\":[\"Enviar Imagem\"],\"loQxaJ\":[\"Estou de Volta\"],\"lvfaxv\":[\"INÍCIO\"],\"m0oxpP\":[\"Libera Chat\"],\"m16xKo\":[\"Adicionar\"],\"m8flAk\":[\"Prévia (ainda não enviado)\"],\"mEPxTp\":[\"<0>⚠️ Cuidado! Abra apenas links de fontes confiáveis. Links maliciosos podem comprometer sua segurança ou privacidade.\"],\"mHGdhG\":[\"Informações do servidor\"],\"mHS8lb\":[\"Mensagem #\",[\"0\"]],\"mMYBD9\":[\"Amplo – Escopo de proteção mais amplo\"],\"mTGsPd\":[\"Tópico do canal\"],\"mU8j6O\":[\"Sem mensagens externas (+n)\"],\"mZp8FL\":[\"Retorno automático para linha única\"],\"mdQu8G\":[\"SeuApelido\"],\"miSSBQ\":[\"Comentários (\",[\"commentCount\"],\")\"],\"mvyLSy\":[\"Usuário autenticado\"],\"mwtcGl\":[\"Fechar comentários\"],\"myL0MR\":[\"Excluir esta rede?\"],\"mzI/c+\":[\"Baixar\"],\"n3fGRk\":[\"definido por \",[\"0\"]],\"nE9jsU\":[\"Relaxado – Proteção menos agressiva\"],\"nNflMD\":[\"Sair do canal\"],\"nPXkBi\":[\"Carregando dados WHOIS...\"],\"nQnxxF\":[\"Mensagem #\",[\"0\"],\" (Shift+Enter para nova linha)\"],\"nWMRxa\":[\"Desafixar\"],\"nkC032\":[\"Sem perfil de flood\"],\"o69z4d\":[\"Enviar uma mensagem de aviso para \",[\"username\"]],\"o9ylQi\":[\"Pesquise GIFs para começar\"],\"oFGkER\":[\"Avisos do Servidor\"],\"oOi11l\":[\"Rolar para o fim\"],\"oQEzQR\":[\"Nova mensagem direta\"],\"oXOSPE\":[\"Online\"],\"oal760\":[\"Ataques man-in-the-middle em links de servidor são possíveis\"],\"oeqmmJ\":[\"Fontes Confiáveis\"],\"ovBPCi\":[\"Padrão\"],\"p0Z69r\":[\"O padrão não pode estar vazio\"],\"p1KgtK\":[\"Falha ao carregar áudio\"],\"p59pEv\":[\"Detalhes adicionais\"],\"p7sRI6\":[\"Avisar outros quando você está digitando\"],\"pBm1od\":[\"Canal secreto\"],\"pNmiXx\":[\"Seu apelido padrão para todos os servidores\"],\"pUUo9G\":[\"Hostname:\"],\"pVGPmz\":[\"Senha da conta\"],\"peNE68\":[\"Permanente\"],\"plhHQt\":[\"Sem dados\"],\"pm6+q5\":[\"Aviso de Segurança\"],\"pn5qSs\":[\"Informações adicionais\"],\"q0cR4S\":[\"agora é conhecido como **\",[\"newNick\"],\"**\"],\"qFcunY\":[\"O canal não aparecerá nos comandos LIST ou NAMES\"],\"qLpTm/\":[\"Remover reação \",[\"emoji\"]],\"qVkGWK\":[\"Fixar\"],\"qY8wNa\":[\"Página inicial\"],\"qb0xJ7\":[\"Curingas: * corresponde a qualquer sequência, ? a um único caractere. Exemplos: nick!*@*, *!*@host.com, *!*user@*\"],\"qhzpRq\":[\"Chave do canal (+k)\"],\"qtoOYG\":[\"Sem limite\"],\"r1W2AS\":[\"Imagem do servidor de arquivos\"],\"rIPR2O\":[\"Tópico definido antes (min atrás)\"],\"rMMSYo\":[\"O comprimento máximo é \",[\"0\"]],\"rWtzQe\":[\"A rede se dividiu e reconectou. ✅\"],\"rYG2u6\":[\"Aguarde...\"],\"rdUucN\":[\"Visualização\"],\"rjGI/Q\":[\"Privacidade\"],\"rk8iDX\":[\"Carregando GIFs...\"],\"rn6SBY\":[\"Ativar som\"],\"s/UKqq\":[\"Foi expulso do canal\"],\"s8cATI\":[\"entrou em \",[\"channelName\"]],\"sCO9ue\":[\"A conexão com <0>\",[\"serverName\"],\" apresenta as seguintes preocupações de segurança:\"],\"sGH11W\":[\"Servidor\"],\"sHI1H+\":[\"agora é conhecido como **\",[\"newNick\"],\"**\"],\"sJyV04\":[[\"inviter\"],\" convidou você para entrar em \",[\"channel\"]],\"sUBSbK\":[\"Nenhuma rede upstream ainda.\"],\"sby+1/\":[\"Clique para copiar\"],\"sfN25C\":[\"Seu nome real ou completo\"],\"sliuzR\":[\"Abrir Link\"],\"sqrO9R\":[\"Menções personalizadas\"],\"sr6RdJ\":[\"Multilinha com Shift+Enter\"],\"swrCpB\":[\"O canal foi renomeado de \",[\"oldName\"],\" para \",[\"newName\"],\" por \",[\"user\"],[\"0\"]],\"sxkWRg\":[\"Avançado\"],\"t/YqKh\":[\"Remover\"],\"t47eHD\":[\"Seu identificador único neste servidor\"],\"tAkAh0\":[\"URL com substituição opcional \",[\"size\"],\". Exemplo: https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"tXLJS3\":[\"Mostrar ou ocultar a barra lateral de canais\"],\"tfDRzk\":[\"Salvar\"],\"tiBsJk\":[\"saiu de \",[\"channelName\"]],\"tt4/UD\":[\"saiu (\",[\"reason\"],\")\"],\"u0TcnO\":[\"O apelido {nick} já está em uso, tentando com {newNick}\"],\"u0a8B4\":[\"Autenticar como operador IRC para acesso administrativo\"],\"u0rWFU\":[\"Criado após (min atrás)\"],\"u72w3t\":[\"Usuários e padrões a ignorar\"],\"u7jc2L\":[\"saiu\"],\"uAQUqI\":[\"Status\"],\"uB85T3\":[\"Falha ao salvar: \",[\"msg\"]],\"uV3DOL\":[\"G-Line\"],\"uW3lLI\":[\"Servidores IRC:\"],\"usSSr/\":[\"Nível de zoom\"],\"v7uvcf\":[\"Software:\"],\"vE8kb+\":[\"Shift+Enter para novas linhas (Enter envia)\"],\"vERlcd\":[\"Perfil\"],\"vK0RL8\":[\"Sem tópico\"],\"vSJd18\":[\"Vídeo\"],\"vXIe7J\":[\"Idioma\"],\"vaHYxN\":[\"Nome real\"],\"vhjbKr\":[\"Ausente\"],\"w/nogd\":[[\"0\"],\" network\",[\"1\"],\" — pick one to join\"],\"w4NYox\":[\"cliente \",[\"title\"]],\"w8xQRx\":[\"Valor inválido\"],\"wFjjxZ\":[\"foi expulso de \",[\"channelName\"],\" por \",[\"username\"],\" (\",[\"reason\"],\")\"],\"wGjaGl\":[\"Nenhuma exceção de banimento encontrada\"],\"wPrGnM\":[\"Administrador do canal\"],\"wRkP2d\":[\"GIF\"],\"wbm86v\":[\"Exibir quando usuários entram ou saem de canais\"],\"whqZ9r\":[\"Palavras ou frases adicionais para destacar\"],\"wm7RV4\":[\"Som de notificação\"],\"wz/Yoq\":[\"Suas mensagens podem ser interceptadas ao serem retransmitidas entre servidores\"],\"xCJdfg\":[\"Limpar\"],\"xUHRTR\":[\"Autenticar automaticamente como operador ao conectar\"],\"xWHwwQ\":[\"Banimentos\"],\"xYilR2\":[\"Mídia\"],\"xceQrO\":[\"Apenas websockets seguros são suportados\"],\"xdtXa+\":[\"nome-do-canal\"],\"xfXC7q\":[\"Canais de texto\"],\"xlCYOE\":[\"Carregando mais mensagens...\"],\"xlhswE\":[\"O valor mínimo é \",[\"0\"]],\"xq97Ci\":[\"Adicionar uma palavra ou frase...\"],\"xuRqRq\":[\"Limite de clientes (+l)\"],\"xwF+7J\":[[\"0\"],\" está digitando...\"],\"yJztBY\":[\"Excluir rede\"],\"yNeucF\":[\"Este servidor não suporta metadados de perfil estendidos (extensão IRCv3 METADATA). Campos como avatar, nome de exibição e status não estão disponíveis.\"],\"yPlrca\":[\"Avatar do canal\"],\"yQE2r9\":[\"Carregando\"],\"ySU+JY\":[\"seu@email.com\"],\"yTX1Rt\":[\"Nome de usuário Oper\"],\"yYOzWD\":[\"logs\"],\"yfx9Re\":[\"Senha de operador IRC\"],\"ygCKqB\":[\"Parar\"],\"ymDxJx\":[\"Nome de usuário de operador IRC\"],\"yrpRsQ\":[\"Ordenar por nome\"],\"yz7wBu\":[\"Fechar\"],\"zJw+jA\":[\"define modo: \",[\"0\"]],\"zebeLu\":[\"Digite o nome de usuário oper\"],\"zpr0Bw\":[\"GZ-Line\"]}"); \ No newline at end of file diff --git a/src/locales/pt/messages.po b/src/locales/pt/messages.po index 09eadb64..f8554f9b 100644 --- a/src/locales/pt/messages.po +++ b/src/locales/pt/messages.po @@ -22,6 +22,16 @@ msgstr "ObsidianIRC - Levando o IRC para o futuro" msgid "— open in viewer" msgstr "— abrir no visualizador" +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx +msgid "(inherit)" +msgstr "(herdar)" + +#: src/components/ui/BouncerNetworkForm.tsx +msgid "(unchanged)" +msgstr "(inalterado)" + #. placeholder {0}: filteredMessages.length #. placeholder {1}: import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import type * as React from "react"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { SCROLL_TOLERANCE, useScrollToBottom, } from "../../hooks/useScrollToBottom"; import { groupConsecutiveEvents } from "../../lib/eventGrouping"; import ircClient from "../../lib/ircClient"; import useStore from "../../store"; import type { Message as MessageType } from "../../types"; import { CollapsedEventMessage } from "../message/CollapsedEventMessage"; import { MessageItem } from "../message/MessageItem"; import LoadingSpinner from "../ui/LoadingSpinner"; import { ScrollToBottomButton } from "../ui/ScrollToBottomButton"; export const DEFAULT_VISIBLE_MESSAGE_COUNT = 100; // Stable empty array — prevents selector from returning a new [] on every render // when the channel has no messages yet (undefined ?? [] would create a new ref each time). const EMPTY_MESSAGES: import("../../types").Message[] = []; export interface ChannelMessageListHandle { setAtBottom: () => void; scrollToBottom: () => void; getScrollState: () => { scrollTop: number; isAtBottom: boolean; visibleCount: number; }; } interface ChannelMessageListProps { channelKey: string; serverId: string; channelId: string | null; privateChatId: string | null; isActive: boolean; searchQuery: string; isMemberListVisible: boolean; onReply: (msg: MessageType | null) => void; onUsernameContextMenu: ( e: React.MouseEvent, username: string, serverId: string, channelId: string, avatarEl?: Element | null, ) => void; onIrcLinkClick: (url: string) => void; onReactClick: (msg: MessageType, el: Element) => void; onReactionUnreact: (emoji: string, msg: MessageType) => void; onOpenReactionModal: ( msg: MessageType, position: { x: number; y: number }, ) => void; onDirectReaction: (emoji: string, msg: MessageType) => void; onRedactMessage: (msg: MessageType) => void; onOpenProfile: (username: string) => void; joinChannel: (serverId: string, channelName: string) => void; onClearSearch: () => void; highlightedMessageId?: string; // undefined = first visit; null = was at bottom; object = restore to saved position initialScrollState?: { scrollTop: number; visibleCount: number } | null; } export const ChannelMessageList = forwardRef< ChannelMessageListHandle, ChannelMessageListProps >( ( { channelKey, serverId, channelId, privateChatId, isActive, searchQuery, isMemberListVisible, onReply, onUsernameContextMenu, onIrcLinkClick, onReactClick, onReactionUnreact, onOpenReactionModal, onDirectReaction, onRedactMessage, onOpenProfile, joinChannel, onClearSearch, highlightedMessageId, initialScrollState, }, ref, ) => { const { t } = useLingui(); const [visibleMessageCount, setVisibleMessageCount] = useState( initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT, ); // Ref mirror so getScrollState closure always reads the current value without needing it as a dep. const visibleMessageCountRef = useRef(visibleMessageCount); visibleMessageCountRef.current = visibleMessageCount; // Distinguishes initial join (full-screen spinner) from subsequent "load more" (button spinner). const [isFetchingMore, setIsFetchingMore] = useState(false); const isFetchingMoreRef = useRef(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const messagesInnerRef = useRef(null); // prev scrollHeight for prepend delta-correction. const prevScrollHeightRef = useRef(0); // Ref mirror of isScrolledUp — lets useLayoutEffect closures read current value // without listing isScrolledUp as a dep (which would re-run effects on every scroll). const isScrolledUpRef = useRef(false); const prevFilteredLengthRef = useRef(0); const prevFirstMsgIdRef = useRef(null); // Set by the window-growth layoutEffect (or button handler) when a true prepend is detected. // Consumed by the delta-correction layoutEffect one render later (after visibleCount grows). // Using a flag instead of tracking displayedMessages[0]?.id because slice(-N) slides the // window on every bottom append, changing displayedMessages[0] even for non-prepend renders. const pendingPrependRef = useRef(false); // Shared scrollHeight baseline between the delta-correction layout effect and the inner // ResizeObserver. When scrollTop is corrected after a prepend, we update this so the RO's // "was at bottom" check is not fooled by the adjusted scrollTop vs its stale prevSH. const resizeObserverPrevSHRef = useRef(0); const channelMessages = useStore( useCallback( (state) => state.messages[channelKey] ?? EMPTY_MESSAGES, [channelKey], ), ); const servers = useStore((state) => state.servers); const mobileViewActiveColumn = useStore( (state) => state.ui.mobileViewActiveColumn, ); const channel = useMemo( () => channelId ? (servers .find((s) => s.id === serverId) ?.channels.find((c) => c.id === channelId) ?? null) : null, [servers, serverId, channelId], ); const { isScrolledUp, wasAtBottomRef, scrollToBottom } = useScrollToBottom( messagesContainerRef, messagesEndRef, { channelId: `${channelId || privateChatId}-${isMemberListVisible}` }, ); // Snapshot of the last known scroll position captured while the container was visible. // getScrollState() reads this instead of the live DOM because React commits display:none // before running cleanup effects, collapsing scrollTop/scrollHeight/clientHeight to 0. const lastScrollTopRef = useRef(initialScrollState?.scrollTop ?? 0); useEffect(() => { const container = messagesContainerRef.current; if (!container) return; const onScroll = () => { if (container.clientHeight > 0) lastScrollTopRef.current = container.scrollTop; }; container.addEventListener("scroll", onScroll, { passive: true }); return () => container.removeEventListener("scroll", onScroll); }, []); // Restore scroll position when a keep-alive channel transitions from hidden to visible. // display:none may reset scrollTop to 0; lastScrollTopRef was captured while visible. const prevActiveRef = useRef(isActive); useLayoutEffect(() => { if (isActive && !prevActiveRef.current) { const container = messagesContainerRef.current; if (container && lastScrollTopRef.current > 0) { container.scrollTop = lastScrollTopRef.current; } } prevActiveRef.current = isActive; }, [isActive]); useImperativeHandle(ref, () => ({ setAtBottom: () => { wasAtBottomRef.current = true; }, scrollToBottom, getScrollState: () => ({ scrollTop: lastScrollTopRef.current, isAtBottom: wasAtBottomRef.current, visibleCount: visibleMessageCountRef.current, }), })); const filteredMessages = useMemo(() => { if (!searchQuery.trim()) return channelMessages; const query = searchQuery.toLowerCase(); return channelMessages.filter( (msg) => msg.content.toLowerCase().includes(query) || msg.userId.toLowerCase().includes(query), ); }, [channelMessages, searchQuery]); useEffect(() => { isScrolledUpRef.current = isScrolledUp; // When the user returns to the bottom, shrink the window back to the base so // slice(-N) resumes trimming old messages from the top (memory optimization). // Only shrink if we grew above the base — preserves a sub-default saved visibleCount. if (!isScrolledUp) { setVisibleMessageCount((prev) => prev > DEFAULT_VISIBLE_MESSAGE_COUNT ? DEFAULT_VISIBLE_MESSAGE_COUNT : prev, ); } }, [isScrolledUp]); // Reset ref-tracked windowing state when switching channels. // visibleMessageCount is NOT reset here — useState(initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT) // already initializes it correctly on mount, and this effect runs once on mount for the // same channelKey (each instance is bound to exactly one channel by the parent key={}). // biome-ignore lint/correctness/useExhaustiveDependencies: intentional full reset on channel change useEffect(() => { prevFilteredLengthRef.current = 0; prevFirstMsgIdRef.current = null; prevScrollHeightRef.current = 0; pendingPrependRef.current = false; resizeObserverPrevSHRef.current = 0; }, [channelKey]); const displayedMessages = useMemo(() => { if (searchQuery.trim()) return filteredMessages; return filteredMessages.slice(-visibleMessageCount); }, [filteredMessages, visibleMessageCount, searchQuery]); const locallyHidden = filteredMessages.length > displayedMessages.length; const serverHasMore = channel?.hasMoreHistory === true; const hasMoreMessages = locallyHidden || serverHasMore; const eventGroups = useMemo( () => groupConsecutiveEvents(displayedMessages), [displayedMessages], ); const isLoadingHistory = channel?.isLoadingHistory ?? false; // Scroll to bottom on initial mount, unless a saved position was passed in. // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount only useEffect(() => { const container = messagesContainerRef.current; if (!container) return; if (initialScrollState) { container.scrollTop = initialScrollState.scrollTop; lastScrollTopRef.current = initialScrollState.scrollTop; wasAtBottomRef.current = false; } else { container.scrollTop = container.scrollHeight; lastScrollTopRef.current = container.scrollHeight; wasAtBottomRef.current = true; } }, []); // Scroll to bottom after initial join history loads; clear fetch spinner at batch end. const wasLoadingHistoryRef = useRef(false); // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is stable via useCallback; refs and setters are stable useLayoutEffect(() => { if (wasLoadingHistoryRef.current && !isLoadingHistory) { if (isFetchingMoreRef.current) { // delta correction for scroll position is handled by useLayoutEffect([displayedMessages]) isFetchingMoreRef.current = false; setIsFetchingMore(false); } else { scrollToBottom(); wasAtBottomRef.current = true; } } wasLoadingHistoryRef.current = isLoadingHistory; }, [isLoadingHistory]); // When older messages are prepended, grow the window so they enter displayedMessages. // When new messages arrive at the bottom while the user is scrolled up, also grow the // window to keep the current top messages visible — slice(-N) otherwise slides the // window forward and hides them, incrementing the "N older messages" counter on every // incoming message. Only let the slice trim from the top when the user is at the bottom // (where auto-scroll handles keeping them current). useLayoutEffect(() => { const newLength = filteredMessages.length; const newFirstId = filteredMessages[0]?.id ?? null; const delta = newLength - prevFilteredLengthRef.current; if (prevFilteredLengthRef.current > 0 && delta > 0) { if (newFirstId !== prevFirstMsgIdRef.current) { // Messages prepended (load-more): signal delta-correction to compensate scrollTop. pendingPrependRef.current = true; setVisibleMessageCount((prev) => prev + delta); } else if (isScrolledUpRef.current) { // Messages appended at bottom while user is scrolled up reading history. // Expand the window to prevent top messages from dropping out of the slice. setVisibleMessageCount((prev) => prev + delta); } } prevFilteredLengthRef.current = newLength; prevFirstMsgIdRef.current = newFirstId; }, [filteredMessages]); // Compensate scrollTop when content is prepended above the viewport. // biome-ignore lint/correctness/useExhaustiveDependencies: runs on every displayedMessages render to capture the resulting scrollHeight; refs are stable useLayoutEffect(() => { const container = messagesContainerRef.current; if (!container) return; // Skip while container is display:none — scrollHeight collapses to 0 and would // poison prevScrollHeightRef, causing a huge spurious delta on the next visible render. if (container.clientHeight === 0) return; const prevHeight = prevScrollHeightRef.current; const newHeight = container.scrollHeight; // Only correct when a true load-more prepend happened (flag set by the window-growth // layoutEffect or button handler). Bottom appends slide the slice(-N) window which also // changes displayedMessages[0] — ID-comparison can't distinguish the two cases. const wasPrepend = pendingPrependRef.current; // Only consume the flag when scrollHeight actually changed — the server-side load-more // path goes through two renders: Render A (filteredMessages grows, visibleCount unchanged, // same displayedMessages content, same scrollHeight) then Render B (visibleCount grows, // new messages enter displayedMessages, scrollHeight grows). The flag must survive Render A // so it's still set when Render B fires the actual correction. if (wasPrepend && newHeight !== prevHeight) { pendingPrependRef.current = false; } if ( isScrolledUpRef.current && prevHeight > 0 && newHeight > prevHeight && wasPrepend ) { const delta = newHeight - prevHeight; container.scrollTop += delta; resizeObserverPrevSHRef.current = newHeight; } prevScrollHeightRef.current = newHeight; }, [displayedMessages]); // Re-stick to bottom when inner message content grows (media/audio previews loading). // Uses prevScrollHeight instead of wasAtBottomRef to avoid stale-flag race where the // ref is true while the user is actively scrolling up. // When the container width changes (member list toggle, window resize), text reflows // and scrollHeight changes; preserve proportional scroll position for scrolled-up users. // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is a stable ref useEffect(() => { const container = messagesContainerRef.current; const inner = messagesInnerRef.current; if (!inner || !container) return; resizeObserverPrevSHRef.current = container.scrollHeight; let prevClientWidth = container.clientWidth; const observer = new ResizeObserver(() => { if (container.clientHeight === 0) return; // Effect may re-initialize while container is display:none (ref=0). // Re-seed with current dimensions and skip — no reliable "was at bottom" data. if (resizeObserverPrevSHRef.current === 0) { resizeObserverPrevSHRef.current = container.scrollHeight; prevClientWidth = container.clientWidth; return; } const currentClientWidth = container.clientWidth; const widthChanged = currentClientWidth !== prevClientWidth; prevClientWidth = currentClientWidth; const prevSH = resizeObserverPrevSHRef.current; const wasAtPrevBottom = container.scrollTop + container.clientHeight >= prevSH - SCROLL_TOLERANCE; resizeObserverPrevSHRef.current = container.scrollHeight; if (wasAtPrevBottom) { scrollToBottom(); } else if (widthChanged && prevSH > 0) { const ratio = container.scrollTop / prevSH; container.scrollTop = Math.round(ratio * container.scrollHeight); } }); observer.observe(inner); return () => observer.disconnect(); }, [isLoadingHistory, channelId, privateChatId]); // Auto-scroll on new messages — skip when this channel is hidden (display:none). // biome-ignore lint/correctness/useExhaustiveDependencies: only scroll when messages change, not when isActive changes useEffect(() => { if (!isActive) return; const isNarrowView = window.matchMedia("(max-width: 768px)").matches; const isChatVisible = !isNarrowView || mobileViewActiveColumn === "chatView"; if (wasAtBottomRef.current && isChatVisible) { scrollToBottom(); } }, [displayedMessages, mobileViewActiveColumn, scrollToBottom, isActive]); return ( <>
{isLoadingHistory && !isFetchingMore ? (
) : (
{hasMoreMessages && !searchQuery && (
)} {searchQuery && (
{plural(filteredMessages.length, { one: t`Found 1 message matching "${searchQuery}"`, other: t`Found ${filteredMessages.length} messages matching "${searchQuery}"`, })}
)} {eventGroups.map((group) => { if (group.type === "eventGroup") { const firstId = group.messages[0]?.id || ""; const lastId = group.messages[group.messages.length - 1]?.id || ""; const groupKey = `group-${firstId}-${lastId}`; return ( ); } const message = group.messages[0]; const originalIndex = channelMessages.findIndex( (m) => m.id === message.id, ); const previousMessage = channelMessages[originalIndex - 1]; const showHeader = !previousMessage || previousMessage.type !== "message" || previousMessage.userId !== message.userId || new Date(message.timestamp).getTime() - new Date(previousMessage.timestamp).getTime() > 5 * 60 * 1000; return ( ); })}
)}
); }, ); ChannelMessageList.displayName = "ChannelMessageList"; // Wrap with memo so hidden keep-alive channels skip re-renders when their props // haven't changed (e.g. when messageText changes in the input — the only thing // that changes on typing is local state inside ChatArea, not the props we pass here). export const MemoChannelMessageList = memo(ChannelMessageList); #. placeholder {2}: import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import type * as React from "react"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { SCROLL_TOLERANCE, useScrollToBottom, } from "../../hooks/useScrollToBottom"; import { groupConsecutiveEvents } from "../../lib/eventGrouping"; import ircClient from "../../lib/ircClient"; import useStore from "../../store"; import type { Message as MessageType } from "../../types"; import { CollapsedEventMessage } from "../message/CollapsedEventMessage"; import { MessageItem } from "../message/MessageItem"; import LoadingSpinner from "../ui/LoadingSpinner"; import { ScrollToBottomButton } from "../ui/ScrollToBottomButton"; export const DEFAULT_VISIBLE_MESSAGE_COUNT = 100; // Stable empty array — prevents selector from returning a new [] on every render // when the channel has no messages yet (undefined ?? [] would create a new ref each time). const EMPTY_MESSAGES: import("../../types").Message[] = []; export interface ChannelMessageListHandle { setAtBottom: () => void; scrollToBottom: () => void; getScrollState: () => { scrollTop: number; isAtBottom: boolean; visibleCount: number; }; } interface ChannelMessageListProps { channelKey: string; serverId: string; channelId: string | null; privateChatId: string | null; isActive: boolean; searchQuery: string; isMemberListVisible: boolean; onReply: (msg: MessageType | null) => void; onUsernameContextMenu: ( e: React.MouseEvent, username: string, serverId: string, channelId: string, avatarEl?: Element | null, ) => void; onIrcLinkClick: (url: string) => void; onReactClick: (msg: MessageType, el: Element) => void; onReactionUnreact: (emoji: string, msg: MessageType) => void; onOpenReactionModal: ( msg: MessageType, position: { x: number; y: number }, ) => void; onDirectReaction: (emoji: string, msg: MessageType) => void; onRedactMessage: (msg: MessageType) => void; onOpenProfile: (username: string) => void; joinChannel: (serverId: string, channelName: string) => void; onClearSearch: () => void; highlightedMessageId?: string; // undefined = first visit; null = was at bottom; object = restore to saved position initialScrollState?: { scrollTop: number; visibleCount: number } | null; } export const ChannelMessageList = forwardRef< ChannelMessageListHandle, ChannelMessageListProps >( ( { channelKey, serverId, channelId, privateChatId, isActive, searchQuery, isMemberListVisible, onReply, onUsernameContextMenu, onIrcLinkClick, onReactClick, onReactionUnreact, onOpenReactionModal, onDirectReaction, onRedactMessage, onOpenProfile, joinChannel, onClearSearch, highlightedMessageId, initialScrollState, }, ref, ) => { const { t } = useLingui(); const [visibleMessageCount, setVisibleMessageCount] = useState( initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT, ); // Ref mirror so getScrollState closure always reads the current value without needing it as a dep. const visibleMessageCountRef = useRef(visibleMessageCount); visibleMessageCountRef.current = visibleMessageCount; // Distinguishes initial join (full-screen spinner) from subsequent "load more" (button spinner). const [isFetchingMore, setIsFetchingMore] = useState(false); const isFetchingMoreRef = useRef(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const messagesInnerRef = useRef(null); // prev scrollHeight for prepend delta-correction. const prevScrollHeightRef = useRef(0); // Ref mirror of isScrolledUp — lets useLayoutEffect closures read current value // without listing isScrolledUp as a dep (which would re-run effects on every scroll). const isScrolledUpRef = useRef(false); const prevFilteredLengthRef = useRef(0); const prevFirstMsgIdRef = useRef(null); // Set by the window-growth layoutEffect (or button handler) when a true prepend is detected. // Consumed by the delta-correction layoutEffect one render later (after visibleCount grows). // Using a flag instead of tracking displayedMessages[0]?.id because slice(-N) slides the // window on every bottom append, changing displayedMessages[0] even for non-prepend renders. const pendingPrependRef = useRef(false); // Shared scrollHeight baseline between the delta-correction layout effect and the inner // ResizeObserver. When scrollTop is corrected after a prepend, we update this so the RO's // "was at bottom" check is not fooled by the adjusted scrollTop vs its stale prevSH. const resizeObserverPrevSHRef = useRef(0); const channelMessages = useStore( useCallback( (state) => state.messages[channelKey] ?? EMPTY_MESSAGES, [channelKey], ), ); const servers = useStore((state) => state.servers); const mobileViewActiveColumn = useStore( (state) => state.ui.mobileViewActiveColumn, ); const channel = useMemo( () => channelId ? (servers .find((s) => s.id === serverId) ?.channels.find((c) => c.id === channelId) ?? null) : null, [servers, serverId, channelId], ); const { isScrolledUp, wasAtBottomRef, scrollToBottom } = useScrollToBottom( messagesContainerRef, messagesEndRef, { channelId: `${channelId || privateChatId}-${isMemberListVisible}` }, ); // Snapshot of the last known scroll position captured while the container was visible. // getScrollState() reads this instead of the live DOM because React commits display:none // before running cleanup effects, collapsing scrollTop/scrollHeight/clientHeight to 0. const lastScrollTopRef = useRef(initialScrollState?.scrollTop ?? 0); useEffect(() => { const container = messagesContainerRef.current; if (!container) return; const onScroll = () => { if (container.clientHeight > 0) lastScrollTopRef.current = container.scrollTop; }; container.addEventListener("scroll", onScroll, { passive: true }); return () => container.removeEventListener("scroll", onScroll); }, []); // Restore scroll position when a keep-alive channel transitions from hidden to visible. // display:none may reset scrollTop to 0; lastScrollTopRef was captured while visible. const prevActiveRef = useRef(isActive); useLayoutEffect(() => { if (isActive && !prevActiveRef.current) { const container = messagesContainerRef.current; if (container && lastScrollTopRef.current > 0) { container.scrollTop = lastScrollTopRef.current; } } prevActiveRef.current = isActive; }, [isActive]); useImperativeHandle(ref, () => ({ setAtBottom: () => { wasAtBottomRef.current = true; }, scrollToBottom, getScrollState: () => ({ scrollTop: lastScrollTopRef.current, isAtBottom: wasAtBottomRef.current, visibleCount: visibleMessageCountRef.current, }), })); const filteredMessages = useMemo(() => { if (!searchQuery.trim()) return channelMessages; const query = searchQuery.toLowerCase(); return channelMessages.filter( (msg) => msg.content.toLowerCase().includes(query) || msg.userId.toLowerCase().includes(query), ); }, [channelMessages, searchQuery]); useEffect(() => { isScrolledUpRef.current = isScrolledUp; // When the user returns to the bottom, shrink the window back to the base so // slice(-N) resumes trimming old messages from the top (memory optimization). // Only shrink if we grew above the base — preserves a sub-default saved visibleCount. if (!isScrolledUp) { setVisibleMessageCount((prev) => prev > DEFAULT_VISIBLE_MESSAGE_COUNT ? DEFAULT_VISIBLE_MESSAGE_COUNT : prev, ); } }, [isScrolledUp]); // Reset ref-tracked windowing state when switching channels. // visibleMessageCount is NOT reset here — useState(initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT) // already initializes it correctly on mount, and this effect runs once on mount for the // same channelKey (each instance is bound to exactly one channel by the parent key={}). // biome-ignore lint/correctness/useExhaustiveDependencies: intentional full reset on channel change useEffect(() => { prevFilteredLengthRef.current = 0; prevFirstMsgIdRef.current = null; prevScrollHeightRef.current = 0; pendingPrependRef.current = false; resizeObserverPrevSHRef.current = 0; }, [channelKey]); const displayedMessages = useMemo(() => { if (searchQuery.trim()) return filteredMessages; return filteredMessages.slice(-visibleMessageCount); }, [filteredMessages, visibleMessageCount, searchQuery]); const locallyHidden = filteredMessages.length > displayedMessages.length; const serverHasMore = channel?.hasMoreHistory === true; const hasMoreMessages = locallyHidden || serverHasMore; const eventGroups = useMemo( () => groupConsecutiveEvents(displayedMessages), [displayedMessages], ); const isLoadingHistory = channel?.isLoadingHistory ?? false; // Scroll to bottom on initial mount, unless a saved position was passed in. // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount only useEffect(() => { const container = messagesContainerRef.current; if (!container) return; if (initialScrollState) { container.scrollTop = initialScrollState.scrollTop; lastScrollTopRef.current = initialScrollState.scrollTop; wasAtBottomRef.current = false; } else { container.scrollTop = container.scrollHeight; lastScrollTopRef.current = container.scrollHeight; wasAtBottomRef.current = true; } }, []); // Scroll to bottom after initial join history loads; clear fetch spinner at batch end. const wasLoadingHistoryRef = useRef(false); // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is stable via useCallback; refs and setters are stable useLayoutEffect(() => { if (wasLoadingHistoryRef.current && !isLoadingHistory) { if (isFetchingMoreRef.current) { // delta correction for scroll position is handled by useLayoutEffect([displayedMessages]) isFetchingMoreRef.current = false; setIsFetchingMore(false); } else { scrollToBottom(); wasAtBottomRef.current = true; } } wasLoadingHistoryRef.current = isLoadingHistory; }, [isLoadingHistory]); // When older messages are prepended, grow the window so they enter displayedMessages. // When new messages arrive at the bottom while the user is scrolled up, also grow the // window to keep the current top messages visible — slice(-N) otherwise slides the // window forward and hides them, incrementing the "N older messages" counter on every // incoming message. Only let the slice trim from the top when the user is at the bottom // (where auto-scroll handles keeping them current). useLayoutEffect(() => { const newLength = filteredMessages.length; const newFirstId = filteredMessages[0]?.id ?? null; const delta = newLength - prevFilteredLengthRef.current; if (prevFilteredLengthRef.current > 0 && delta > 0) { if (newFirstId !== prevFirstMsgIdRef.current) { // Messages prepended (load-more): signal delta-correction to compensate scrollTop. pendingPrependRef.current = true; setVisibleMessageCount((prev) => prev + delta); } else if (isScrolledUpRef.current) { // Messages appended at bottom while user is scrolled up reading history. // Expand the window to prevent top messages from dropping out of the slice. setVisibleMessageCount((prev) => prev + delta); } } prevFilteredLengthRef.current = newLength; prevFirstMsgIdRef.current = newFirstId; }, [filteredMessages]); // Compensate scrollTop when content is prepended above the viewport. // biome-ignore lint/correctness/useExhaustiveDependencies: runs on every displayedMessages render to capture the resulting scrollHeight; refs are stable useLayoutEffect(() => { const container = messagesContainerRef.current; if (!container) return; // Skip while container is display:none — scrollHeight collapses to 0 and would // poison prevScrollHeightRef, causing a huge spurious delta on the next visible render. if (container.clientHeight === 0) return; const prevHeight = prevScrollHeightRef.current; const newHeight = container.scrollHeight; // Only correct when a true load-more prepend happened (flag set by the window-growth // layoutEffect or button handler). Bottom appends slide the slice(-N) window which also // changes displayedMessages[0] — ID-comparison can't distinguish the two cases. const wasPrepend = pendingPrependRef.current; // Only consume the flag when scrollHeight actually changed — the server-side load-more // path goes through two renders: Render A (filteredMessages grows, visibleCount unchanged, // same displayedMessages content, same scrollHeight) then Render B (visibleCount grows, // new messages enter displayedMessages, scrollHeight grows). The flag must survive Render A // so it's still set when Render B fires the actual correction. if (wasPrepend && newHeight !== prevHeight) { pendingPrependRef.current = false; } if ( isScrolledUpRef.current && prevHeight > 0 && newHeight > prevHeight && wasPrepend ) { const delta = newHeight - prevHeight; container.scrollTop += delta; resizeObserverPrevSHRef.current = newHeight; } prevScrollHeightRef.current = newHeight; }, [displayedMessages]); // Re-stick to bottom when inner message content grows (media/audio previews loading). // Uses prevScrollHeight instead of wasAtBottomRef to avoid stale-flag race where the // ref is true while the user is actively scrolling up. // When the container width changes (member list toggle, window resize), text reflows // and scrollHeight changes; preserve proportional scroll position for scrolled-up users. // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is a stable ref useEffect(() => { const container = messagesContainerRef.current; const inner = messagesInnerRef.current; if (!inner || !container) return; resizeObserverPrevSHRef.current = container.scrollHeight; let prevClientWidth = container.clientWidth; const observer = new ResizeObserver(() => { if (container.clientHeight === 0) return; // Effect may re-initialize while container is display:none (ref=0). // Re-seed with current dimensions and skip — no reliable "was at bottom" data. if (resizeObserverPrevSHRef.current === 0) { resizeObserverPrevSHRef.current = container.scrollHeight; prevClientWidth = container.clientWidth; return; } const currentClientWidth = container.clientWidth; const widthChanged = currentClientWidth !== prevClientWidth; prevClientWidth = currentClientWidth; const prevSH = resizeObserverPrevSHRef.current; const wasAtPrevBottom = container.scrollTop + container.clientHeight >= prevSH - SCROLL_TOLERANCE; resizeObserverPrevSHRef.current = container.scrollHeight; if (wasAtPrevBottom) { scrollToBottom(); } else if (widthChanged && prevSH > 0) { const ratio = container.scrollTop / prevSH; container.scrollTop = Math.round(ratio * container.scrollHeight); } }); observer.observe(inner); return () => observer.disconnect(); }, [isLoadingHistory, channelId, privateChatId]); // Auto-scroll on new messages — skip when this channel is hidden (display:none). // biome-ignore lint/correctness/useExhaustiveDependencies: only scroll when messages change, not when isActive changes useEffect(() => { if (!isActive) return; const isNarrowView = window.matchMedia("(max-width: 768px)").matches; const isChatVisible = !isNarrowView || mobileViewActiveColumn === "chatView"; if (wasAtBottomRef.current && isChatVisible) { scrollToBottom(); } }, [displayedMessages, mobileViewActiveColumn, scrollToBottom, isActive]); return ( <>
{isLoadingHistory && !isFetchingMore ? (
) : (
{hasMoreMessages && !searchQuery && (
)} {searchQuery && (
{plural(filteredMessages.length, { one: t`Found 1 message matching "${searchQuery}"`, other: t`Found ${filteredMessages.length} messages matching "${searchQuery}"`, })}
)} {eventGroups.map((group) => { if (group.type === "eventGroup") { const firstId = group.messages[0]?.id || ""; const lastId = group.messages[group.messages.length - 1]?.id || ""; const groupKey = `group-${firstId}-${lastId}`; return ( ); } const message = group.messages[0]; const originalIndex = channelMessages.findIndex( (m) => m.id === message.id, ); const previousMessage = channelMessages[originalIndex - 1]; const showHeader = !previousMessage || previousMessage.type !== "message" || previousMessage.userId !== message.userId || new Date(message.timestamp).getTime() - new Date(previousMessage.timestamp).getTime() > 5 * 60 * 1000; return ( ); })}
)}
); }, ); ChannelMessageList.displayName = "ChannelMessageList"; // Wrap with memo so hidden keep-alive channels skip re-renders when their props // haven't changed (e.g. when messageText changes in the input — the only thing // that changes on typing is local state inside ChatArea, not the props we pass here). export const MemoChannelMessageList = memo(ChannelMessageList); @@ -46,6 +56,12 @@ msgstr "{0} e {1} estão digitando..." msgid "{0} is typing..." msgstr "{0} está digitando..." +#. placeholder {0}: networks.length +#. placeholder {1}: networks.length === 1 ? "" : "s" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "{0} network{1} — pick one to join" +msgstr "" + #. placeholder {0}: filteredMessages.length - displayedMessages.length #: src/components/layout/ChannelMessageList.tsx msgid "{0} older messages" @@ -186,6 +202,12 @@ msgstr "Adicionar máscara de convite (ex.: nick!*@*, *!*@host.com)" msgid "Add IRC Server" msgstr "Adicionar servidor IRC" +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworksPanel.tsx +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Add Network" +msgstr "Adicionar rede" + #: src/components/message/MessageActions.tsx #: src/components/message/MessageReactions.tsx #: src/components/message/MessageReactions.tsx @@ -205,6 +227,10 @@ msgstr "Adicionar regra" msgid "Add Server" msgstr "Adicionar Servidor" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Add your first network" +msgstr "Adicione sua primeira rede" + #: src/components/message/JsonLogMessage.tsx msgid "Additional Details" msgstr "Detalhes adicionais" @@ -358,6 +384,10 @@ msgstr "Voltar" msgid "Back to image" msgstr "Voltar para a imagem" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Back to network list" +msgstr "" + #: src/components/ui/ModerationModal.tsx msgid "Ban {username} by hostmask (prevents them from rejoining from the same IP/host)" msgstr "Banir {username} por hostmask (impede que entre novamente pelo mesmo IP/host)" @@ -405,6 +435,8 @@ msgstr "Navegar por todos os canais do servidor" #: src/components/ui/AddPrivateChatModal.tsx #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/ChannelSettingsModal.tsx #: src/components/ui/ExternalLinkWarningModal.tsx #: src/components/ui/FloodSettingsModal.tsx @@ -640,6 +672,7 @@ msgid "Configure notification sounds and highlights" msgstr "Configurar sons de notificação e destaques" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworksPanel.tsx msgid "Connect" msgstr "Conectar" @@ -759,6 +792,10 @@ msgstr "Excluir Canal" msgid "Delete message" msgstr "Excluir mensagem" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Delete network" +msgstr "Excluir rede" + #: src/components/layout/ChannelList.tsx msgid "Delete Private Chat" msgstr "Excluir Conversa Privada" @@ -767,6 +804,10 @@ msgstr "Excluir Conversa Privada" msgid "Delete this message? This cannot be undone." msgstr "Excluir esta mensagem? Esta ação não pode ser desfeita." +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Delete this network?" +msgstr "Excluir esta rede?" + #: src/components/layout/ServerList.tsx #: src/components/mobile/ServerBottomSheet.tsx msgid "Disconnect" @@ -830,10 +871,16 @@ msgstr "Baixar" msgid "e.g., 100:1440" msgstr "ex.: 100:1440" +#: src/components/ui/BouncerNetworksPanel.tsx #: src/components/ui/ChannelSettingsModal.tsx msgid "Edit" msgstr "Editar" +#. placeholder {0}: editingNetwork?.attributes.name || editingNetwork?.netid +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Edit {0}" +msgstr "Editar {0}" + #: src/components/ui/UserProfileModal.tsx msgid "Edit Profile" msgstr "Editar perfil" @@ -1057,6 +1104,7 @@ msgstr "INÍCIO" msgid "Homepage" msgstr "Página inicial" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserProfileModal.tsx msgid "Host" msgstr "Host" @@ -1271,6 +1319,10 @@ msgstr "Saiu do canal" msgid "Let others know when you are typing" msgstr "Avisar outros quando você está digitando" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Libera Chat" +msgstr "Libera Chat" + #: src/components/message/LinkPreview.tsx msgid "Link preview" msgstr "Visualização do link" @@ -1299,6 +1351,10 @@ msgstr "Carregando GIFs..." msgid "Loading more channels..." msgstr "Carregando mais canais..." +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Loading networks from your bouncer…" +msgstr "Carregando redes do seu bouncer…" + #: src/components/ui/UserProfileModal.tsx msgid "Loading WHOIS data..." msgstr "Carregando dados WHOIS..." @@ -1486,9 +1542,15 @@ msgid "Name:" msgstr "Nome:" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx msgid "Network Name" msgstr "Nome da Rede" +#. placeholder {0}: server?.name ?? bouncerServerId +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Networks on {0}" +msgstr "Redes em {0}" + #: src/components/ui/QuickActions.tsx msgid "New DM" msgstr "Nova mensagem direta" @@ -1511,6 +1573,7 @@ msgid "nick!user@host (e.g., spam*!*@*, *!*@badhost.com)" msgstr "nick!user@host (ex.: spam*!*@*, *!*@badhost.com)" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserSettings.tsx #: src/lib/settings/definitions/allSettings.ts msgid "Nickname" @@ -1570,6 +1633,10 @@ msgstr "Nenhum arquivo escolhido" msgid "No flood profile" msgstr "Sem perfil de flood" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "no host set" +msgstr "nenhum host definido" + #: src/components/ui/ChannelSettingsModal.tsx msgid "No invitations found" msgstr "Nenhum convite encontrado" @@ -1610,6 +1677,10 @@ msgstr "Nenhum tópico definido" msgid "No unread mentions or messages" msgstr "Nenhuma menção ou mensagem não lida" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "No upstream networks yet." +msgstr "Nenhuma rede upstream ainda." + #: src/components/ui/AddPrivateChatModal.tsx msgid "No users available" msgstr "Nenhum usuário disponível" @@ -1696,6 +1767,10 @@ msgstr "Ops! Divisão de rede! ⚠️" msgid "Op" msgstr "Op" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Open" +msgstr "" + #: src/components/ui/QuickActions/uiActionConfig.tsx msgid "Open channel configuration settings" msgstr "Abrir configurações do canal" @@ -1799,6 +1874,10 @@ msgstr "Fixar Conversa Privada" msgid "Pin this private message conversation" msgstr "Fixar esta conversa de mensagem privada" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Plaintext" +msgstr "Texto puro" + #: src/components/message/MediaPreview.tsx #: src/components/message/MediaPreview.tsx #: src/components/message/MediaPreview.tsx @@ -1827,6 +1906,7 @@ msgid "PM User" msgstr "Mensagem Privada" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx msgid "Port" msgstr "Porta" @@ -1918,6 +1998,7 @@ msgstr "reagiu a esta mensagem" msgid "Read more" msgstr "Ler mais" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserProfileModal.tsx #: src/components/ui/UserSettings.tsx #: src/lib/settings/definitions/allSettings.ts @@ -2002,6 +2083,7 @@ msgstr "Regras" msgid "Safe" msgstr "Seguro" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/ChannelSettingsModal.tsx #: src/components/ui/TopicModal.tsx #: src/components/ui/UserSettings.tsx @@ -2183,6 +2265,10 @@ msgstr "Operadores de servidor na rede podem potencialmente ler suas mensagens" msgid "Server Password" msgstr "Senha do Servidor" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Server Password (PASS)" +msgstr "Senha do servidor (PASS)" + #: src/components/ui/LinkSecurityWarningModal.tsx msgid "Server-to-server communication may use unencrypted connections" msgstr "A comunicação servidor a servidor pode usar conexões não criptografadas" @@ -2378,6 +2464,10 @@ msgstr "Tempo (min)" msgid "Time Window (seconds)" msgstr "Janela de tempo (segundos)" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "TLS" +msgstr "TLS" + #: src/components/message/WhisperMessage.tsx #: src/components/message/WhisperMessage.tsx msgid "to" @@ -2426,6 +2516,10 @@ msgstr "Tópico:" msgid "Total: {0}" msgstr "Total: {0}" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Transport" +msgstr "Transporte" + #: src/components/ui/UserSettings.tsx msgid "Trusted Sources" msgstr "Fontes Confiáveis" @@ -2536,6 +2630,7 @@ msgstr "Perfil do Usuário" msgid "User Settings" msgstr "Configurações do usuário" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/InviteUserModal.tsx #: src/components/ui/ModerationModal.tsx msgid "Username" @@ -2683,6 +2778,10 @@ msgstr "Amplo – Escopo de proteção mais amplo" msgid "Will default to 'no reason' if left empty" msgstr "Será definido como 'sem motivo' se deixado em branco" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Yes, delete" +msgstr "Sim, excluir" + #: src/components/message/CollapsedEventMessage.tsx #: src/components/message/EventMessage.tsx msgid "You" @@ -2713,6 +2812,10 @@ msgstr "Sua senha de conta para autenticação" msgid "Your account username for authentication" msgstr "Seu nome de usuário de conta para autenticação" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Your bouncer doesn't have any networks yet. Add one to get started." +msgstr "Seu bouncer ainda não possui redes. Adicione uma para começar." + #: src/lib/settings/definitions/allSettings.ts msgid "Your default nickname for all servers" msgstr "Seu apelido padrão para todos os servidores" diff --git a/src/locales/ro/messages.mjs b/src/locales/ro/messages.mjs index b78e7abd..4ff8a5a9 100644 --- a/src/locales/ro/messages.mjs +++ b/src/locales/ro/messages.mjs @@ -1 +1 @@ -/*eslint-disable*/export const messages=JSON.parse("{\"+5VMrz\":[\"Format model invalid. Folosiți formatul nick!user@host (caractere wildcard * permise)\"],\"+6NQQA\":[\"Canal general de suport\"],\"+6NyRG\":[\"Client\"],\"+K0AvT\":[\"Deconectează\"],\"+cyFdH\":[\"Mesaj implicit când vă marcați ca absent\"],\"+mVPqU\":[\"Afișați formatarea Markdown în mesaje\"],\"+vqCJH\":[\"Numele de utilizator al contului dvs. pentru autentificare\"],\"+yPBXI\":[\"Alege fișier\"],\"+zy2Nq\":[\"Tip\"],\"/09cao\":[\"Securitate scăzută a legăturii (Nivel \",[\"securityLevel\"],\")\"],\"/3BQ4J\":[\"Utilizatorii din afara canalului nu pot trimite mesaje\"],\"/6BzZF\":[\"Comută lista de membri\"],\"/TNOPk\":[\"Utilizatorul este absent\"],\"/XQgft\":[\"Descoperă\"],\"/cF7Rs\":[\"Volum\"],\"/dqduX\":[\"Pagina următoare\"],\"/fc3q4\":[\"Tot conținutul\"],\"/kISDh\":[\"Activați sunetele de notificare\"],\"/n04sB\":[\"Kill\"],\"/rTz0M\":[\"Audio\"],\"/rfkZe\":[\"Redați sunete pentru mențiuni și mesaje\"],\"0/0ZGA\":[\"Mască nume canal\"],\"0D6j7U\":[\"Aflați mai multe despre regulile personalizate →\"],\"0XsHcR\":[\"Dă afară utilizatorul\"],\"0ZpE//\":[\"Sortare după utilizatori\"],\"0bEPwz\":[\"Setează ca absent\"],\"0dGkPt\":[\"Extinde lista de canale\"],\"0gS7M5\":[\"Nume afișat\"],\"0kS+M8\":[\"ExempluRET\"],\"0rgoY7\":[\"Conectați-vă doar la serverele pe care le alegeți\"],\"0wdd7X\":[\"Alătură-te\"],\"0wkVYx\":[\"Mesaje private\"],\"111uHX\":[\"Previzualizare link\"],\"196EG4\":[\"Șterge conversația privată\"],\"1DSr1i\":[\"Înregistrează un cont\"],\"1O/24y\":[\"Comută lista de canale\"],\"1VPJJ2\":[\"Avertisment link extern\"],\"1ZC/dv\":[\"Nicio mențiune sau mesaj necitit\"],\"1pO1zi\":[\"Numele serverului este obligatoriu\"],\"1uwfzQ\":[\"Vezi subiectul canalului\"],\"268g7c\":[\"Introdu numele afișat\"],\"2FOFq1\":[\"Operatorii de server din rețea pot citi mesajele tale\"],\"2FYpfJ\":[\"Mai mult\"],\"2HF1Y2\":[[\"inviter\"],\" l-a invitat pe \",[\"target\"],\" să se alăture la \",[\"channel\"]],\"2I70QL\":[\"Vezi informațiile profilului utilizatorului\"],\"2QYdmE\":[\"Utilizatori:\"],\"2QpEjG\":[\"a ieșit\"],\"2YE223\":[\"Mesaj #\",[\"0\"],\" (Enter pentru linie nouă, Shift+Enter pentru trimitere)\"],\"2bimFY\":[\"Folosește parola serverului\"],\"2iTmdZ\":[\"Stocare locală:\"],\"2odkwe\":[\"Strict – Protecție mai agresivă\"],\"2uDhbA\":[\"Introdu numele de utilizator de invitat\"],\"2ygf/L\":[\"← Înapoi\"],\"2zEgxj\":[\"Caută GIF-uri...\"],\"3RdPhl\":[\"Redenumește canalul\"],\"3THokf\":[\"Utilizator cu drept de vorbire\"],\"3TSz9S\":[\"Minimizează\"],\"3jBDvM\":[\"Nume afișat canal\"],\"3ryuFU\":[\"Rapoarte opționale de erori pentru îmbunătățirea aplicației\"],\"3uBF/8\":[\"Închide vizualizatorul\"],\"3uwW8F\":[\"https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"3xf8Kz\":[\"Introduceți numele contului...\"],\"4/Rr0R\":[\"Invită un utilizator în canalul curent\"],\"4EZrJN\":[\"Reguli\"],\"4JJtW9\":[\"#overflow\"],\"4NqeT4\":[\"Profil flood (+F)\"],\"4RZQRK\":[\"Ce faci?\"],\"4hfTrB\":[\"Poreclă\"],\"4n99LO\":[\"Deja în \",[\"0\"]],\"4t6vMV\":[\"Comutare automată la linie unică pentru mesaje scurte\"],\"4vsHmf\":[\"Timp (min)\"],\"5+INAX\":[\"Evidențiați mesajele care vă menționează\"],\"5R5Pv/\":[\"Nume oper\"],\"678PKt\":[\"Nume rețea\"],\"6Aih4U\":[\"Deconectat\"],\"6CO3WE\":[\"Parolă necesară pentru a intra în canal. Lăsați gol pentru a elimina cheia.\"],\"6HhMs3\":[\"Mesaj de ieșire\"],\"6V3Ea3\":[\"Copiat\"],\"6lGV3K\":[\"Arată mai puțin\"],\"6yFOEi\":[\"Introduceți parola oper...\"],\"7+IHTZ\":[\"Niciun fișier ales\"],\"73hrRi\":[\"nick!user@host (ex., spam*!*@*, *!*@badhost.com)\"],\"7QkKyN\":[\"Trimite mesaj privat\"],\"7U1W7c\":[\"Foarte relaxat\"],\"7Y1YQj\":[\"Nume real:\"],\"7YHArF\":[\"— deschide în vizualizator\"],\"7fjnVl\":[\"Caută utilizatori...\"],\"7jL88x\":[\"Ștergeți acest mesaj? Această acțiune nu poate fi anulată.\"],\"7nGhhM\":[\"La ce te gândești?\"],\"7sEpu1\":[\"Membri — \",[\"0\"]],\"7sNhEz\":[\"Nume de utilizator\"],\"8H0Q+x\":[\"Aflați mai multe despre profiluri →\"],\"8Phu0A\":[\"Afișează când utilizatorii își schimbă pseudonimul\"],\"8XTG9e\":[\"Introdu parola oper\"],\"8XsV2J\":[\"Reîncearcă trimiterea\"],\"8ZsakT\":[\"Parolă\"],\"8kR84m\":[\"Ești pe cale să deschizi un link extern:\"],\"8lCgih\":[\"Eliminați regula\"],\"8p/xVT\":[[\"0\",\"plural\",{\"one\":[[\"1\"]],\"other\":[[\"2\"]]}]],\"8wRzac\":[[\"joinCount\",\"plural\",{\"one\":[\"s-a alăturat\"],\"few\":[\"s-a alăturat de \",[\"joinCount\"],\" ori\"],\"other\":[\"s-a alăturat de \",[\"joinCount\"],\" ori\"]}]],\"9BMLnJ\":[\"Reconectează-te la server\"],\"9OEgyT\":[\"Adaugă reacție\"],\"9PQ8m2\":[\"G-Line (ban global)\"],\"9Qs99X\":[\"Email:\"],\"9QupBP\":[\"Elimină șablon\"],\"9bG48P\":[\"Se trimite\"],\"9f5f0u\":[\"Întrebări despre confidențialitate? Contactați-ne:\"],\"9unqs3\":[\"Absent:\"],\"9v3hwv\":[\"Niciun server găsit.\"],\"9zb2WA\":[\"Se conectează\"],\"A1taO8\":[\"Caută\"],\"A2adVi\":[\"Trimiteți notificări de tastare\"],\"A9Rhec\":[\"Nume canal\"],\"AWOSPo\":[\"Mărește\"],\"AXSpEQ\":[\"Oper la conectare\"],\"AeXO77\":[\"Cont\"],\"AhNP40\":[\"Derulare\"],\"Ai2U7L\":[\"Gazdă\"],\"AjBQnf\":[\"Poreclă schimbată\"],\"AmXVh6\":[\"https://example.com/avatar.png\"],\"AnRu/j\":[\"Anulează răspunsul\"],\"ApSx0O\":[\"S-au găsit \",[\"0\"],\" mesaje care corespund cu \\\"\",[\"searchQuery\"],\"\\\"\"],\"AxPAXW\":[\"Niciun rezultat găsit\"],\"AyNqAB\":[\"Afișează toate evenimentele serverului în chat\"],\"B/QqGw\":[\"Departe de tastatură\"],\"B8AaMI\":[\"Acest câmp este obligatoriu\"],\"BA2c49\":[\"Serverul nu acceptă filtrarea avansată LIST\"],\"BDKt3I\":[[\"0\"],\", \",[\"1\"],\", \",[\"2\"],\" și alți \",[\"3\"],\" scriu...\"],\"BGul2A\":[\"Ai modificări nesalvate. Ești sigur că vrei să închizi fără să salvezi?\"],\"BIf9fi\":[\"Mesajul dvs. de stare\"],\"BZz3md\":[\"Site-ul dvs. web personal\"],\"Bgm/H7\":[\"Permite introducerea mai multor rânduri de text\"],\"BiQIl1\":[\"Fixează această conversație privată\"],\"BlNZZ2\":[\"Click pentru a sări la mesaj\"],\"Bowq3c\":[\"Doar operatorii pot schimba subiectul canalului\"],\"Btozzp\":[\"Această imagine a expirat\"],\"Bycfjm\":[\"Total: \",[\"0\"]],\"C6IBQc\":[\"Copiați JSON complet\"],\"C9L9wL\":[\"Colectare de date\"],\"CDq4wC\":[\"Moderează utilizatorul\"],\"CHVRxG\":[\"Mesaj @\",[\"0\"],\" (Shift+Enter pentru linie nouă)\"],\"CN9zdR\":[\"Numele oper și parola sunt obligatorii\"],\"CW3sYa\":[\"Adaugă reacția \",[\"emoji\"]],\"CaAkqd\":[\"Afișați deconectările\"],\"CbvaYj\":[\"Banare după poreclă\"],\"CcK+Ft\":[\"PDF\"],\"Ce8q3L\":[\"Selectează un canal\"],\"CsekCi\":[\"Normal\"],\"D+NlUC\":[\"Sistem\"],\"D28t6+\":[\"s-a alăturat și a ieșit\"],\"DB8zMK\":[\"Aplică\"],\"DBcWHr\":[\"Fișier audio de notificare personalizat\"],\"DTy9Xw\":[\"Previzualizări media\"],\"Dj4pSr\":[\"Alege o parolă sigură\"],\"Du+zn+\":[\"Se caută...\"],\"Du2T2f\":[\"Setarea nu a fost găsită\"],\"DwsSVQ\":[\"Aplică filtrele și actualizează\"],\"E3W/zd\":[\"Pseudonim implicit\"],\"E6nRW7\":[\"Copiază URL\"],\"E703RG\":[\"Moduri:\"],\"EAeu1Z\":[\"Trimiteți invitația\"],\"EFKJQT\":[\"Setare\"],\"EGPQBv\":[\"Reguli flood personalizate (+f)\"],\"ELik0r\":[\"Vizualizați politica completă de confidențialitate\"],\"EPbeC2\":[\"Vezi sau editează subiectul canalului\"],\"EQCDNT\":[\"Introduceți numele de utilizator oper...\"],\"EUvulZ\":[\"S-a găsit 1 mesaj care corespunde cu \\\"\",[\"searchQuery\"],\"\\\"\"],\"EatZYJ\":[\"Imaginea următoare\"],\"EdQY6l\":[\"Niciunul\"],\"EnqLYU\":[\"Caută servere...\"],\"F0OKMc\":[\"Editează server\"],\"F6Int2\":[\"Activați evidențierile\"],\"FDoLyE\":[\"Utilizatori max.\"],\"FUU/hZ\":[\"Controlează câte conținuturi media externe sunt încărcate în chat.\"],\"Fdp03t\":[\"activ\"],\"FfPWR0\":[\"Modal\"],\"FjkaiT\":[\"Micșorează\"],\"FlqOE9\":[\"Ce înseamnă aceasta:\"],\"FolHNl\":[\"Gestionează-ți contul și autentificarea\"],\"Fp2Dif\":[\"A ieșit de pe server\"],\"G5KmCc\":[\"GZ-Line (Z-Line globală)\"],\"GDs0lz\":[\"<0>Risc: Informațiile sensibile (mesaje, conversații private, date de autentificare) pot fi expuse administratorilor de rețea sau atacatorilor poziționați între serverele IRC.\"],\"GR+2I3\":[\"Adaugă mască de invitație (ex. nick!*@*, *!*@host.com)\"],\"GRLyMU\":[\"Închide notificările server detașate\"],\"GlHnXw\":[\"Schimbarea poreclelei a eșuat: \",[\"error\"],\" \",[\"0\"]],\"GswZF3\":[\"Previzualizare:\"],\"GtmO8/\":[\"de la\"],\"GtuHUQ\":[\"Redenumiți acest canal pe server. Toți utilizatorii vor vedea noul nume.\"],\"GuGfFX\":[\"Comută căutarea\"],\"GxkJXS\":[\"Se încarcă...\"],\"GzbwnK\":[\"S-a alăturat canalului\"],\"GzsUDB\":[\"Profil extins\"],\"H/PnT8\":[\"Inserează emoji\"],\"H6Izzl\":[\"Codul dvs. de culoare preferat\"],\"H9jIv+\":[\"Afișați intrări/ieșiri\"],\"HAKBY9\":[\"Încărcați fișiere\"],\"HdE1If\":[\"Canal\"],\"Hk4AW9\":[\"Numele dvs. de afișare preferat\"],\"HmHDk7\":[\"Selectează un membru\"],\"HrQzPU\":[\"Canale pe \",[\"networkName\"]],\"I2tXQ5\":[\"Mesaj @\",[\"0\"],\" (Enter pentru linie nouă, Shift+Enter pentru trimitere)\"],\"I6bw/h\":[\"Banează utilizatorul\"],\"I92Z+b\":[\"Activează notificările\"],\"I9D72S\":[\"Sigur doriți să ștergeți acest mesaj? Această acțiune nu poate fi anulată.\"],\"IA+1wo\":[\"Afișează când utilizatorii sunt expulzați din canale\"],\"IDwkJx\":[\"Operator IRC\"],\"ILlU+s\":[\"Info:\"],\"IUwGEM\":[\"Salvează modificările\"],\"IVeGK6\":[[\"0\"],\", \",[\"1\"],\" și \",[\"2\"],\" scriu...\"],\"IgrLD/\":[\"Pauză\"],\"Im6JED\":[\"WHISPER\"],\"ImOQa9\":[\"Răspunde\"],\"IoHMnl\":[\"Valoarea maximă este \",[\"0\"]],\"IvMj+0\":[\"Op\"],\"J28zul\":[\"Se conectează...\"],\"J5T9NW\":[\"Informații utilizator\"],\"J8Y5+z\":[\"Oops! Rețeaua s-a împărțit! ⚠️\"],\"JBHkBA\":[\"A părăsit canalul\"],\"JCwL0Q\":[\"Introdu motivul (opțional)\"],\"JFciKP\":[\"Comută\"],\"JXGkhG\":[\"Schimbă numele canalului (numai operatori)\"],\"JcD7qf\":[\"Mai multe acțiuni\"],\"JdkA+c\":[\"Secret (+s)\"],\"Jmu12l\":[\"Canale server\"],\"JvQ++s\":[\"Activați Markdown\"],\"K2jwh/\":[\"Nu există date WHOIS disponibile\"],\"KAXSwC\":[\"Voice\"],\"KDfTdX\":[\"Șterge mesaj\"],\"KKBlUU\":[\"Încorporare\"],\"KM0pLb\":[\"Bine ai venit în canal!\"],\"KR6W2h\":[\"Nu mai ignora utilizatorul\"],\"KV+Bi1\":[\"Doar pe invitație (+i)\"],\"KdCtwE\":[\"Câte secunde se monitorizează activitatea flood înainte de resetarea contoarelor\"],\"Kkezga\":[\"Parolă server\"],\"KsiQ/8\":[\"Utilizatorii trebuie invitați pentru a intra în canal\"],\"L+gB/D\":[\"Informații canal\"],\"LC1a7n\":[\"Serverul IRC a raportat că legăturile sale server-la-server au un nivel scăzut de securitate. Aceasta înseamnă că atunci când mesajele tale sunt transmise între serverele IRC din rețea, este posibil ca acestea să nu fie criptate corespunzător sau certificatele SSL/TLS să nu fie validate corect.\"],\"LNfLR5\":[\"Afișați expulzările\"],\"LQb0W/\":[\"Afișați toate evenimentele\"],\"LU7/yA\":[\"Nume alternativ pentru afișaj. Poate conține spații, emoji și caractere speciale. Numele real (\",[\"channelName\"],\") va fi folosit în continuare pentru comenzile IRC.\"],\"LUb9O7\":[\"Este necesar un port de server valid\"],\"Lb+BUl\":[\"https://example.com/avatar.jpg\"],\"LcET2C\":[\"Politică de confidențialitate\"],\"LcuSDR\":[\"Gestionează informațiile profilului și metadatele\"],\"LqLS9B\":[\"Afișați schimbările de pseudonim\"],\"LsDQt2\":[\"Setări canal\"],\"LtI9AS\":[\"Proprietar\"],\"LuNhhL\":[\"a reacționat la acest mesaj\"],\"M/AZNG\":[\"URL-ul imaginii dvs. de avatar\"],\"M/WIer\":[\"Trimite mesaj\"],\"M8er/5\":[\"Nume:\"],\"MHk+7g\":[\"Imaginea anterioară\"],\"MRorGe\":[\"Mesaj privat\"],\"MVbSGP\":[\"Fereastră de timp (secunde)\"],\"MkpcsT\":[\"Mesajele și setările dvs. sunt stocate local pe dispozitivul dvs.\"],\"N/hDSy\":[\"Marcați ca bot, de obicei 'on' sau gol\"],\"N7TQbE\":[\"Invitați utilizatorul în \",[\"channelName\"]],\"NCca/o\":[\"Introduceți porecla implicită...\"],\"Nqs6B9\":[\"Afișează toate conținuturile externe. Orice URL poate genera o solicitare către un server necunoscut.\"],\"Nt+9O7\":[\"Utilizați WebSocket în loc de TCP brut\"],\"NxIHzc\":[\"Expulzați utilizatorul\"],\"O+v/cL\":[\"Răsfoiește toate canalele de pe server\"],\"ODwSCk\":[\"Trimite un GIF\"],\"OGQ5kK\":[\"Configurează sunetele de notificare și evidențierile\"],\"OIPt1Z\":[\"Afișează sau ascunde bara laterală cu lista de membri\"],\"OKSNq/\":[\"Foarte strict\"],\"ONWvwQ\":[\"Încărcați\"],\"OVKoQO\":[\"Parola contului dvs. pentru autentificare\"],\"ObsidianIRC - Bringing IRC to the future\":[\"ObsidianIRC - Aducem IRC în viitor\"],\"OhCpra\":[\"Setează un subiect…\"],\"OkltoQ\":[\"Banează \",[\"username\"],\" după poreclă (împiedică reconectarea cu același nick)\"],\"P+t/Te\":[\"Nicio dată suplimentară\"],\"P42Wcc\":[\"Sigur\"],\"PD38l0\":[\"Previzualizare avatar canal\"],\"PD9mEt\":[\"Scrie un mesaj...\"],\"PPqfdA\":[\"Deschide setările de configurare ale canalului\"],\"PSCjfZ\":[\"Subiectul afișat pentru acest canal. Toți utilizatorii îl pot vedea.\"],\"PZCecv\":[\"Previzualizare PDF\"],\"PeLgsC\":[[\"c\",\"plural\",{\"one\":[\"1 dată\"],\"few\":[[\"c\"],\" ori\"],\"other\":[[\"c\"],\" ori\"]}]],\"PguS2C\":[\"Adaugă mască de excepție (ex. nick!*@*, *!*@host.com)\"],\"Pil5Ty\":[\"Se afișează \",[\"displayedChannelsCount\"],\" din \",[\"0\"],\" canale\"],\"PqhVlJ\":[\"Banează utilizator (după hostmask)\"],\"Q+chwU\":[\"Nume utilizator:\"],\"Q6hhn8\":[\"Preferințe\"],\"QF4a34\":[\"Introduceți un nume de utilizator\"],\"QGqSZ2\":[\"Culoare și formatare\"],\"QJQd1J\":[\"Editați profilul\"],\"QSzGDE\":[\"Inactiv\"],\"QUlny5\":[\"Bine ai venit la \",[\"0\"],\"!\"],\"Qoq+GP\":[\"Citește mai mult\"],\"QuSkCF\":[\"Filtrează canale...\"],\"QwUrDZ\":[\"a schimbat subiectul la: \",[\"topic\"]],\"R0UH07\":[\"Imaginea \",[\"0\"],\" din \",[\"1\"]],\"R7SsBE\":[\"Dezactivare sunet\"],\"R8rf1X\":[\"Click pentru a seta subiectul\"],\"RArB3D\":[\"a fost dat afară din \",[\"channelName\"],\" de \",[\"username\"]],\"RI3cWd\":[\"Descoperă lumea IRC cu ObsidianIRC\"],\"RMMaN5\":[\"Moderat (+m)\"],\"RWw9Lg\":[\"Închide fereastra\"],\"RZ2BuZ\":[\"Înregistrarea contului \",[\"account\"],\" necesită verificare: \",[\"message\"]],\"RySp6q\":[\"Ascundeți comentariile\"],\"SPKQTd\":[\"Porecla este obligatorie\"],\"SPVjfj\":[\"Va fi implicit „niciun motiv\\\" dacă este lăsat gol\"],\"SQKPvQ\":[\"Invită utilizator\"],\"SkZcl+\":[\"Alegeți un profil de protecție flood predefinit. Aceste profiluri oferă setări de protecție echilibrate pentru diferite cazuri de utilizare.\"],\"Slr+3C\":[\"Utilizatori min.\"],\"Spnlre\":[\"L-ai invitat pe \",[\"target\"],\" să se alăture la \",[\"channel\"]],\"T/ckN5\":[\"Deschide în vizualizator\"],\"T91vKp\":[\"Redare\"],\"TV2Wdu\":[\"Aflați cum gestionăm datele dvs. și vă protejăm confidențialitatea.\"],\"TgFpwD\":[\"Se aplică...\"],\"TkzSFB\":[\"Nicio modificare\"],\"TtserG\":[\"Introdu numele real\"],\"Ttz9J1\":[\"Introduceți parola...\"],\"Tz0i8g\":[\"Setări\"],\"U3pytU\":[\"Admin\"],\"UDb2YD\":[\"Reacționează\"],\"UE4KO5\":[\"*channel*\"],\"UGT5vp\":[\"Salvați setările\"],\"UV5hLB\":[\"Nu s-au găsit banuri\"],\"Uaj3Nd\":[\"Mesaje de stare\"],\"Ue3uny\":[\"Implicit (fără profil)\"],\"UkARhe\":[\"Normal – Protecție standard\"],\"Umn7Cj\":[\"Niciun comentariu. Fiți primul!\"],\"UtUIRh\":[[\"0\"],\" mesaje mai vechi\"],\"UwzP+U\":[\"Conexiune securizată\"],\"V0/A4O\":[\"Proprietar de canal\"],\"V4qgxE\":[\"Creat înainte (min în urmă)\"],\"V8yTm6\":[\"Șterge căutarea\"],\"VJMMyz\":[\"ObsidianIRC - Aducând IRC în viitor\"],\"VJScHU\":[\"Motiv\"],\"VLsmVV\":[\"Dezactivează notificările\"],\"VbyRUy\":[\"Comentarii\"],\"Vmx0mQ\":[\"Setat de:\"],\"VqnIZz\":[\"Vezi politica noastră de confidențialitate și practicile privind datele\"],\"VrMygG\":[\"Lungimea minimă este \",[\"0\"]],\"VrnTui\":[\"Pronumele dvs., afișate în profil\"],\"W8E3qn\":[\"Cont autentificat\"],\"WAakm9\":[\"Șterge canal\"],\"WFxTHC\":[\"Adaugă mască de banare (ex. nick!*@*, *!*@host.com)\"],\"WN1g9F\":[\"Adresa serverului este obligatorie\"],\"WRYdXW\":[\"Poziție audio\"],\"WUOH5B\":[\"Ignoră utilizatorul\"],\"WWEXnZ\":[[\"0\",\"plural\",{\"one\":[\"Arată 1 element în plus\"],\"few\":[\"Arată \",[\"1\"],\" elemente în plus\"],\"other\":[\"Arată \",[\"1\"],\" de elemente în plus\"]}]],\"Weq9zb\":[\"General\"],\"Wfj7Sk\":[\"Activează sau dezactivează sunetele de notificare\"],\"Wm7gbG\":[\"GitHub:\"],\"WyeHWY\":[\"*spam*\"],\"WzMCru\":[\"Profil utilizator\"],\"X6S3lt\":[\"Caută setări, canale, servere...\"],\"XEHan5\":[\"Continuă oricum\"],\"XI1+wb\":[\"Format invalid\"],\"XIXeuC\":[\"Mesaj @\",[\"0\"]],\"XMS+k4\":[\"Începe un mesaj privat\"],\"XWgxXq\":[\"Album\"],\"Xd7+IT\":[\"Anulează fixarea conversației private\"],\"Xm/s+u\":[\"Afișare\"],\"Xp2n93\":[\"Afișează media de pe gazda de fișiere de încredere a serverului. Nu se fac solicitări către servicii externe.\"],\"XvjC4F\":[\"Se salvează...\"],\"Y/qryO\":[\"Niciun utilizator găsit care să corespundă căutării\"],\"YAqRpI\":[\"Înregistrarea contului \",[\"account\"],\" a reușit: \",[\"message\"]],\"YEfzvP\":[\"Subiect protejat (+t)\"],\"YQOn6a\":[\"Restrânge lista de membri\"],\"YRCoE9\":[\"Operator de canal\"],\"YURQaF\":[\"Vizualizați profilul\"],\"YdBSvr\":[\"Controlează afișarea media și conținutul extern\"],\"Yj6U3V\":[\"Fără server central:\"],\"YjvpGx\":[\"Pronume\"],\"YqH4l4\":[\"Nicio cheie\"],\"YyUPpV\":[\"Cont:\"],\"ZJSWfw\":[\"Mesaj afișat la deconectarea de la server\"],\"ZR1dJ4\":[\"Invitații\"],\"ZdWg0V\":[\"Deschide în browser\"],\"ZhRBbl\":[\"Caută mesaje…\"],\"Zmcu3y\":[\"Filtre avansate\"],\"a2/8e5\":[\"Subiect setat după (min în urmă)\"],\"aHKcKc\":[\"Pagina anterioară\"],\"aJTbXX\":[\"Parolă oper\"],\"aQryQv\":[\"Modelul există deja\"],\"aW9pLN\":[\"Numărul maxim de utilizatori permis în canal. Lăsați gol pentru fără limită.\"],\"ah4fmZ\":[\"Afișează și previzualizări de pe YouTube, Vimeo, SoundCloud și servicii similare cunoscute.\"],\"aifXak\":[\"Niciun fișier media în acest canal\"],\"ap2zBz\":[\"Relaxat\"],\"az8lvo\":[\"Oprit\"],\"azXSNo\":[\"Extinde lista de membri\"],\"azdliB\":[\"Conectează-te la un cont\"],\"b26wlF\":[\"ea/ei\"],\"bD/+Ei\":[\"Strict\"],\"bQ6BJn\":[\"Configurați reguli detaliate de protecție flood. Fiecare regulă specifică tipul de activitate de monitorizat și acțiunea de întreprins când pragurile sunt depășite.\"],\"beV7+y\":[\"Utilizatorul va primi o invitație să se alăture \",[\"channelName\"],\".\"],\"bk84cH\":[\"Mesaj de absență\"],\"bkHdLj\":[\"Adaugă server IRC\"],\"bmQLn5\":[\"Adaugă regulă\"],\"bwRvnp\":[\"Acțiune\"],\"c8+EVZ\":[\"Cont verificat\"],\"cGYUlD\":[\"Nu sunt încărcate previzualizări media.\"],\"cLF98o\":[\"Afișați comentariile (\",[\"commentCount\"],\")\"],\"cLKIDO\":[\"Niciun utilizator disponibil\"],\"cSgpoS\":[\"Fixează conversația privată\"],\"cde3ce\":[\"Mesaj <0>\",[\"0\"],\"\"],\"chQsxg\":[\"Copiați ieșirea formatată\"],\"cl/A5J\":[\"Bine ai venit la \",[\"__DEFAULT_IRC_SERVER_NAME__\"],\"!\"],\"cnGeoo\":[\"Șterge\"],\"coPLXT\":[\"Nu stocăm comunicațiile dvs. IRC pe serverele noastre\"],\"crYH/6\":[\"Player SoundCloud\"],\"d3sis4\":[\"Adaugă server\"],\"d9aN5k\":[\"Elimină \",[\"username\"],\" din canal\"],\"dEgA5A\":[\"Anulează\"],\"dGi1We\":[\"Anulează fixarea acestei conversații private\"],\"dJVuyC\":[\"a părăsit \",[\"channelName\"],\" (\",[\"reason\"],\")\"],\"dMtLDE\":[\"către\"],\"dXqxlh\":[\"<0>⚠️ Risc de securitate! Această conexiune poate fi vulnerabilă la interceptare sau atacuri de tip man-in-the-middle.\"],\"da9Q/R\":[\"Moduri canal modificate\"],\"dhJN3N\":[\"Afișați comentariile\"],\"dj2xTE\":[\"Respinge notificarea\"],\"dpCzmC\":[\"Setări de protecție flood\"],\"e9dQpT\":[\"Dorești să deschizi acest link într-o filă nouă?\"],\"ePK91l\":[\"Editează\"],\"eYBDuB\":[\"Încărcați o imagine sau furnizați o adresă URL cu înlocuire opțională \",[\"size\"]],\"edBbee\":[\"Banează \",[\"username\"],\" după hostmask (împiedică reconectarea de pe același IP/host)\"],\"ekfzWq\":[\"Setări utilizator\"],\"elPDWs\":[\"Personalizează experiența clientului IRC\"],\"eu2osY\":[\"<0>💡 Recomandare: Continuați doar dacă aveți încredere în acest server și înțelegeți riscurile. Evitați să partajați informații sensibile sau parole prin această conexiune.\"],\"euEhbr\":[\"Faceți clic pentru a vă alătura \",[\"channel\"]],\"ez3vLd\":[\"Activați introducerea pe mai multe rânduri\"],\"f0J5Ki\":[\"Comunicarea server-la-server poate folosi conexiuni necriptate\"],\"f9BHJk\":[\"Avertizează utilizatorul\"],\"fDOLLd\":[\"Nu s-au găsit canale.\"],\"ffzDkB\":[\"Analize anonime:\"],\"fq1GF9\":[\"Afișează când utilizatorii se deconectează de la server\"],\"gEF57C\":[\"Acest server acceptă doar un tip de conexiune\"],\"gJuLUI\":[\"Listă de ignorați\"],\"gNzMrk\":[\"Avatar curent\"],\"gjPWyO\":[\"Introduceți porecla...\"],\"gz6UQ3\":[\"Maximizează\"],\"h6razj\":[\"Excludeți masca numelui canalului\"],\"hG6jnw\":[\"Niciun subiect setat\"],\"hG89Ed\":[\"Imagine\"],\"hZ6znB\":[\"Port\"],\"ha+Bz5\":[\"ex., 100:1440\"],\"hehnjM\":[\"Cantitate\"],\"hzdLuQ\":[\"Doar utilizatorii cu voice sau mai mult pot vorbi\"],\"i0qMbr\":[\"Acasă\"],\"iDNBZe\":[\"Notificări\"],\"iH8pgl\":[\"Înapoi\"],\"iL9SZg\":[\"Banează utilizator (după poreclă)\"],\"iNt+3c\":[\"Înapoi la imagine\"],\"iQvi+a\":[\"Nu mă avertiza despre securitatea scăzută a legăturilor pentru acest server\"],\"iSLIjg\":[\"Conectare\"],\"iWXkHH\":[\"Halfop\"],\"iZeTtp\":[\"Adresă server\"],\"idD8Ev\":[\"Salvat\"],\"iivqkW\":[\"Conectat la\"],\"ij+Elv\":[\"Previzualizare imagine\"],\"ilIWp7\":[\"Comută notificările\"],\"iuaqvB\":[\"Folosiți * pentru wildcard. Exemple: baduser!*@*, *!*@spammer.com, troll*!*@*\"],\"ixkTse\":[\"Bot\"],\"j2DGR0\":[\"Banare după mască gazdă\"],\"jA4uoI\":[\"Subiect:\"],\"jLXxGK\":[\"https://example.com\"],\"jPSk57\":[\"Motiv (opțional)\"],\"jUV7CU\":[\"Încarcă avatar\"],\"jW5Uwh\":[\"Controlează câtă media externă se încarcă. Dezactivat / Sigur / Surse de încredere / Tot conținutul.\"],\"jXzms5\":[\"Opțiuni atașament\"],\"jZlrte\":[\"Culoare\"],\"jfC/xh\":[\"Contact\"],\"jywMpv\":[\"#new-channel-name\"],\"k112DD\":[\"Încarcă mesaje mai vechi\"],\"k3ID0F\":[\"Filtrează membri…\"],\"k65gsE\":[\"Detalii\"],\"k7Zgob\":[\"Anulează conexiunea\"],\"kAVx5h\":[\"Nu s-au găsit invitații\"],\"kCLEPU\":[\"Conectat la\"],\"kF5LKb\":[\"Modele ignorate:\"],\"kGeOx/\":[\"Alătură-te la \",[\"0\"]],\"kITKr8\":[\"Se încarcă modurile canalului...\"],\"kPpPsw\":[\"Ești un Operator IRC\"],\"kWJmRL\":[\"Tu\"],\"kfcRb0\":[\"Avatar\"],\"kjMqSj\":[\"Copiați JSON\"],\"krViRy\":[\"Clic pentru copiere ca JSON\"],\"ks71ra\":[\"Excepții\"],\"kw4lRv\":[\"Semi-operator de canal\"],\"kxgIRq\":[\"Selectează sau adaugă un canal pentru a începe.\"],\"ky6dWe\":[\"Previzualizare avatar\"],\"l+GxCv\":[\"Se încarcă canalele...\"],\"l+IUVW\":[\"Verificarea contului \",[\"account\"],\" a reușit: \",[\"message\"]],\"l/siQz\":[[\"reconnectCount\",\"plural\",{\"one\":[\"s-a reconectat\"],\"few\":[\"s-a reconectat de \",[\"reconnectCount\"],\" ori\"],\"other\":[\"s-a reconectat de \",[\"reconnectCount\"],\" ori\"]}]],\"l5jmzx\":[[\"0\"],\" și \",[\"1\"],\" scriu...\"],\"lHy8N5\":[\"Se încarcă mai multe canale...\"],\"lbpf14\":[\"Intrați în \",[\"value\"]],\"lfFsZ4\":[\"Canale\"],\"lkNdiH\":[\"Nume cont\"],\"ln500L\":[\"ObsidianIRC\"],\"lnCMdg\":[\"Încarcă imagine\"],\"loQxaJ\":[\"M-am întors\"],\"lvfaxv\":[\"ACASĂ\"],\"m16xKo\":[\"Adaugă\"],\"m8flAk\":[\"Previzualizare (neîncărcat încă)\"],\"mEPxTp\":[\"<0>⚠️ Atenție! Deschide numai linkuri din surse de încredere. Linkurile malițioase îți pot compromite securitatea sau confidențialitatea.\"],\"mHGdhG\":[\"Informații server\"],\"mHS8lb\":[\"Mesaj #\",[\"0\"]],\"mMYBD9\":[\"Larg – Domeniu de protecție mai amplu\"],\"mTGsPd\":[\"Subiect canal\"],\"mU8j6O\":[\"Fără mesaje externe (+n)\"],\"mZp8FL\":[\"Revenire automată la o singură linie\"],\"mdQu8G\":[\"NumeleTău\"],\"miSSBQ\":[\"Comentarii (\",[\"commentCount\"],\")\"],\"mvyLSy\":[\"Utilizatorul este autentificat\"],\"mwtcGl\":[\"Închide comentariile\"],\"mzI/c+\":[\"Descarcă\"],\"n3fGRk\":[\"setat de \",[\"0\"]],\"nE9jsU\":[\"Relaxat – Protecție mai puțin agresivă\"],\"nNflMD\":[\"Părăsește canalul\"],\"nPXkBi\":[\"Se încarcă datele WHOIS...\"],\"nQnxxF\":[\"Mesaj #\",[\"0\"],\" (Shift+Enter pentru linie nouă)\"],\"nWMRxa\":[\"Anulează fixarea\"],\"nkC032\":[\"Niciun profil anti-flood\"],\"o69z4d\":[\"Trimite un mesaj de avertizare către \",[\"username\"]],\"o9ylQi\":[\"Căutați GIF-uri pentru a începe\"],\"oFGkER\":[\"Notificări server\"],\"oOi11l\":[\"Derulează în jos\"],\"oQEzQR\":[\"Mesaj direct nou\"],\"oXOSPE\":[\"Conectat\"],\"oal760\":[\"Atacurile man-in-the-middle asupra legăturilor de server sunt posibile\"],\"oeqmmJ\":[\"Surse de încredere\"],\"ovBPCi\":[\"Implicit\"],\"p0Z69r\":[\"Modelul nu poate fi gol\"],\"p1KgtK\":[\"Eroare la încărcarea audio\"],\"p59pEv\":[\"Detalii suplimentare\"],\"p7sRI6\":[\"Anunțați ceilalți când scrieți\"],\"pBm1od\":[\"Canal secret\"],\"pNmiXx\":[\"Pseudonimul dvs. implicit pentru toate serverele\"],\"pUUo9G\":[\"Hostname:\"],\"pVGPmz\":[\"Parolă cont\"],\"peNE68\":[\"Permanent\"],\"plhHQt\":[\"Fără date\"],\"pm6+q5\":[\"Avertisment de securitate\"],\"pn5qSs\":[\"Informații suplimentare\"],\"q0cR4S\":[\"acum este cunoscut ca **\",[\"newNick\"],\"**\"],\"qFcunY\":[\"Canalul nu va apărea în comenzile LIST sau NAMES\"],\"qLpTm/\":[\"Elimină reacția \",[\"emoji\"]],\"qVkGWK\":[\"Fixează\"],\"qY8wNa\":[\"Pagină principală\"],\"qb0xJ7\":[\"Wildcard: * se potrivește oricărei secvențe, ? unui singur caracter. Exemple: nick!*@*, *!*@host.com, *!*user@*\"],\"qhzpRq\":[\"Cheie canal (+k)\"],\"qtoOYG\":[\"Nicio limită\"],\"r1W2AS\":[\"Imagine filehost\"],\"rIPR2O\":[\"Subiect setat înainte (min în urmă)\"],\"rMMSYo\":[\"Lungimea maximă este \",[\"0\"]],\"rWtzQe\":[\"Rețeaua s-a împărțit și s-a reconectat. ✅\"],\"rYG2u6\":[\"Vă rugăm așteptați...\"],\"rdUucN\":[\"Previzualizare\"],\"rjGI/Q\":[\"Confidențialitate\"],\"rk8iDX\":[\"Se încarcă GIF-urile...\"],\"rn6SBY\":[\"Activare sunet\"],\"s/UKqq\":[\"A fost dat afară din canal\"],\"s8cATI\":[\"s-a alăturat la \",[\"channelName\"]],\"sCO9ue\":[\"Conexiunea la <0>\",[\"serverName\"],\" prezintă următoarele probleme de securitate:\"],\"sGH11W\":[\"Server\"],\"sHI1H+\":[\"acum este cunoscut ca **\",[\"newNick\"],\"**\"],\"sJyV04\":[[\"inviter\"],\" te-a invitat să te alături la \",[\"channel\"]],\"sby+1/\":[\"Faceți clic pentru a copia\"],\"sfN25C\":[\"Numele dvs. real sau complet\"],\"sliuzR\":[\"Deschide linkul\"],\"sqrO9R\":[\"Mențiuni personalizate\"],\"sr6RdJ\":[\"Multilinie cu Shift+Enter\"],\"swrCpB\":[\"Canalul a fost redenumit din \",[\"oldName\"],\" în \",[\"newName\"],\" de \",[\"user\"],[\"0\"]],\"sxkWRg\":[\"Avansat\"],\"t/YqKh\":[\"Elimină\"],\"t47eHD\":[\"Identificatorul dvs. unic pe acest server\"],\"tAkAh0\":[\"URL cu înlocuire opțională \",[\"size\"],\". Exemplu: https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"tXLJS3\":[\"Afișează sau ascunde bara laterală cu lista de canale\"],\"tfDRzk\":[\"Salvează\"],\"tiBsJk\":[\"a părăsit \",[\"channelName\"]],\"tt4/UD\":[\"a ieșit (\",[\"reason\"],\")\"],\"u0TcnO\":[\"Porecla {nick} este deja utilizată, reîncercare cu {newNick}\"],\"u0a8B4\":[\"Autentificați-vă ca operator IRC pentru acces administrativ\"],\"u0rWFU\":[\"Creat după (min în urmă)\"],\"u72w3t\":[\"Utilizatori și modele de ignorat\"],\"u7jc2L\":[\"a ieșit\"],\"uAQUqI\":[\"Stare\"],\"uB85T3\":[\"Salvare eșuată: \",[\"msg\"]],\"uV3DOL\":[\"G-Line\"],\"uW3lLI\":[\"Servere IRC:\"],\"usSSr/\":[\"Nivel de zoom\"],\"v7uvcf\":[\"Software:\"],\"vE8kb+\":[\"Shift+Enter pentru rânduri noi (Enter trimite)\"],\"vERlcd\":[\"Profil\"],\"vK0RL8\":[\"Fără subiect\"],\"vSJd18\":[\"Video\"],\"vXIe7J\":[\"Limbă\"],\"vaHYxN\":[\"Nume real\"],\"vhjbKr\":[\"Absent\"],\"w4NYox\":[\"client \",[\"title\"]],\"w8xQRx\":[\"Valoare invalidă\"],\"wFjjxZ\":[\"a fost dat afară din \",[\"channelName\"],\" de \",[\"username\"],\" (\",[\"reason\"],\")\"],\"wGjaGl\":[\"Nu s-au găsit excepții de banare\"],\"wPrGnM\":[\"Administrator de canal\"],\"wRkP2d\":[\"GIF\"],\"wbm86v\":[\"Afișează când utilizatorii intră sau ies din canale\"],\"whqZ9r\":[\"Cuvinte sau fraze suplimentare de evidențiat\"],\"wm7RV4\":[\"Sunet de notificare\"],\"wz/Yoq\":[\"Mesajele tale pot fi interceptate când sunt transmise între servere\"],\"xCJdfg\":[\"Șterge\"],\"xUHRTR\":[\"Autentificare automată ca operator la conectare\"],\"xWHwwQ\":[\"Banuri\"],\"xYilR2\":[\"Media\"],\"xceQrO\":[\"Sunt acceptate numai websocket-uri securizate\"],\"xdtXa+\":[\"nume-canal\"],\"xfXC7q\":[\"Canale text\"],\"xlCYOE\":[\"Se încarcă mai multe mesaje...\"],\"xlhswE\":[\"Valoarea minimă este \",[\"0\"]],\"xq97Ci\":[\"Adaugă un cuvânt sau o expresie...\"],\"xuRqRq\":[\"Limită clienți (+l)\"],\"xwF+7J\":[[\"0\"],\" scrie...\"],\"yNeucF\":[\"Acest server nu acceptă metadate extinse de profil (extensia IRCv3 METADATA). Câmpurile precum avatar, nume afișat și stare nu sunt disponibile.\"],\"yPlrca\":[\"Avatar canal\"],\"yQE2r9\":[\"Se încarcă\"],\"ySU+JY\":[\"your@email.com\"],\"yTX1Rt\":[\"Nume utilizator operator\"],\"yYOzWD\":[\"jurnale\"],\"yfx9Re\":[\"Parola operatorului IRC\"],\"ygCKqB\":[\"Oprește\"],\"ymDxJx\":[\"Numele de utilizator al operatorului IRC\"],\"yrpRsQ\":[\"Sortare după nume\"],\"yz7wBu\":[\"Închide\"],\"zJw+jA\":[\"setează modul: \",[\"0\"]],\"zebeLu\":[\"Introdu numele de utilizator oper\"],\"zpr0Bw\":[\"GZ-Line\"]}"); \ No newline at end of file +/*eslint-disable*/export const messages=JSON.parse("{\"+5VMrz\":[\"Format model invalid. Folosiți formatul nick!user@host (caractere wildcard * permise)\"],\"+6NQQA\":[\"Canal general de suport\"],\"+6NyRG\":[\"Client\"],\"+K0AvT\":[\"Deconectează\"],\"+cyFdH\":[\"Mesaj implicit când vă marcați ca absent\"],\"+mVPqU\":[\"Afișați formatarea Markdown în mesaje\"],\"+vqCJH\":[\"Numele de utilizator al contului dvs. pentru autentificare\"],\"+yPBXI\":[\"Alege fișier\"],\"+zy2Nq\":[\"Tip\"],\"/09cao\":[\"Securitate scăzută a legăturii (Nivel \",[\"securityLevel\"],\")\"],\"/3BQ4J\":[\"Utilizatorii din afara canalului nu pot trimite mesaje\"],\"/6BzZF\":[\"Comută lista de membri\"],\"/TNOPk\":[\"Utilizatorul este absent\"],\"/XQgft\":[\"Descoperă\"],\"/cF7Rs\":[\"Volum\"],\"/dqduX\":[\"Pagina următoare\"],\"/fc3q4\":[\"Tot conținutul\"],\"/kISDh\":[\"Activați sunetele de notificare\"],\"/n04sB\":[\"Kill\"],\"/rTz0M\":[\"Audio\"],\"/rfkZe\":[\"Redați sunete pentru mențiuni și mesaje\"],\"0/0ZGA\":[\"Mască nume canal\"],\"0D6j7U\":[\"Aflați mai multe despre regulile personalizate →\"],\"0XsHcR\":[\"Dă afară utilizatorul\"],\"0ZpE//\":[\"Sortare după utilizatori\"],\"0bEPwz\":[\"Setează ca absent\"],\"0dGkPt\":[\"Extinde lista de canale\"],\"0gS7M5\":[\"Nume afișat\"],\"0kS+M8\":[\"ExempluRET\"],\"0rgoY7\":[\"Conectați-vă doar la serverele pe care le alegeți\"],\"0wdd7X\":[\"Alătură-te\"],\"0wkVYx\":[\"Mesaje private\"],\"111uHX\":[\"Previzualizare link\"],\"196EG4\":[\"Șterge conversația privată\"],\"1DSr1i\":[\"Înregistrează un cont\"],\"1O/24y\":[\"Comută lista de canale\"],\"1TNIig\":[\"Open\"],\"1VPJJ2\":[\"Avertisment link extern\"],\"1ZC/dv\":[\"Nicio mențiune sau mesaj necitit\"],\"1pO1zi\":[\"Numele serverului este obligatoriu\"],\"1uwfzQ\":[\"Vezi subiectul canalului\"],\"268g7c\":[\"Introdu numele afișat\"],\"2FOFq1\":[\"Operatorii de server din rețea pot citi mesajele tale\"],\"2FYpfJ\":[\"Mai mult\"],\"2HF1Y2\":[[\"inviter\"],\" l-a invitat pe \",[\"target\"],\" să se alăture la \",[\"channel\"]],\"2I70QL\":[\"Vezi informațiile profilului utilizatorului\"],\"2QYdmE\":[\"Utilizatori:\"],\"2QpEjG\":[\"a ieșit\"],\"2YE223\":[\"Mesaj #\",[\"0\"],\" (Enter pentru linie nouă, Shift+Enter pentru trimitere)\"],\"2bimFY\":[\"Folosește parola serverului\"],\"2iTmdZ\":[\"Stocare locală:\"],\"2odkwe\":[\"Strict – Protecție mai agresivă\"],\"2uDhbA\":[\"Introdu numele de utilizator de invitat\"],\"2ygf/L\":[\"← Înapoi\"],\"2zEgxj\":[\"Caută GIF-uri...\"],\"3RdPhl\":[\"Redenumește canalul\"],\"3THokf\":[\"Utilizator cu drept de vorbire\"],\"3TSz9S\":[\"Minimizează\"],\"3jBDvM\":[\"Nume afișat canal\"],\"3ryuFU\":[\"Rapoarte opționale de erori pentru îmbunătățirea aplicației\"],\"3uBF/8\":[\"Închide vizualizatorul\"],\"3uwW8F\":[\"https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"3xf8Kz\":[\"Introduceți numele contului...\"],\"4/Rr0R\":[\"Invită un utilizator în canalul curent\"],\"4EZrJN\":[\"Reguli\"],\"4JJtW9\":[\"#overflow\"],\"4NqeT4\":[\"Profil flood (+F)\"],\"4RZQRK\":[\"Ce faci?\"],\"4hfTrB\":[\"Poreclă\"],\"4n99LO\":[\"Deja în \",[\"0\"]],\"4t6vMV\":[\"Comutare automată la linie unică pentru mesaje scurte\"],\"4vsHmf\":[\"Timp (min)\"],\"4x/Axu\":[\"Bouncer-ul tău nu are încă nicio rețea. Adaugă una pentru a începe.\"],\"5+INAX\":[\"Evidențiați mesajele care vă menționează\"],\"5R5Pv/\":[\"Nume oper\"],\"678PKt\":[\"Nume rețea\"],\"6Aih4U\":[\"Deconectat\"],\"6CO3WE\":[\"Parolă necesară pentru a intra în canal. Lăsați gol pentru a elimina cheia.\"],\"6HhMs3\":[\"Mesaj de ieșire\"],\"6V3Ea3\":[\"Copiat\"],\"6lGV3K\":[\"Arată mai puțin\"],\"6yFOEi\":[\"Introduceți parola oper...\"],\"7+IHTZ\":[\"Niciun fișier ales\"],\"73fnil\":[\"TLS\"],\"73hrRi\":[\"nick!user@host (ex., spam*!*@*, *!*@badhost.com)\"],\"7QkKyN\":[\"Trimite mesaj privat\"],\"7U1W7c\":[\"Foarte relaxat\"],\"7Y1YQj\":[\"Nume real:\"],\"7YHArF\":[\"— deschide în vizualizator\"],\"7fjnVl\":[\"Caută utilizatori...\"],\"7jL88x\":[\"Ștergeți acest mesaj? Această acțiune nu poate fi anulată.\"],\"7nGhhM\":[\"La ce te gândești?\"],\"7sEpu1\":[\"Membri — \",[\"0\"]],\"7sNhEz\":[\"Nume de utilizator\"],\"8H0Q+x\":[\"Aflați mai multe despre profiluri →\"],\"8Phu0A\":[\"Afișează când utilizatorii își schimbă pseudonimul\"],\"8XTG9e\":[\"Introdu parola oper\"],\"8XsV2J\":[\"Reîncearcă trimiterea\"],\"8ZsakT\":[\"Parolă\"],\"8kR84m\":[\"Ești pe cale să deschizi un link extern:\"],\"8lCgih\":[\"Eliminați regula\"],\"8p/xVT\":[[\"0\",\"plural\",{\"one\":[[\"1\"]],\"other\":[[\"2\"]]}]],\"8wRzac\":[[\"joinCount\",\"plural\",{\"one\":[\"s-a alăturat\"],\"few\":[\"s-a alăturat de \",[\"joinCount\"],\" ori\"],\"other\":[\"s-a alăturat de \",[\"joinCount\"],\" ori\"]}]],\"9BMLnJ\":[\"Reconectează-te la server\"],\"9OEgyT\":[\"Adaugă reacție\"],\"9PQ8m2\":[\"G-Line (ban global)\"],\"9Qs99X\":[\"Email:\"],\"9QupBP\":[\"Elimină șablon\"],\"9W7tl5\":[\"(nemodificat)\"],\"9bG48P\":[\"Se trimite\"],\"9f5f0u\":[\"Întrebări despre confidențialitate? Contactați-ne:\"],\"9iweoP\":[\"Rețele pe \",[\"0\"]],\"9unqs3\":[\"Absent:\"],\"9v3hwv\":[\"Niciun server găsit.\"],\"9zb2WA\":[\"Se conectează\"],\"A1taO8\":[\"Caută\"],\"A2adVi\":[\"Trimiteți notificări de tastare\"],\"A9Rhec\":[\"Nume canal\"],\"AWOSPo\":[\"Mărește\"],\"AXSpEQ\":[\"Oper la conectare\"],\"AeXO77\":[\"Cont\"],\"AhNP40\":[\"Derulare\"],\"Ai2U7L\":[\"Gazdă\"],\"AjBQnf\":[\"Poreclă schimbată\"],\"AmXVh6\":[\"https://example.com/avatar.png\"],\"AnRu/j\":[\"Anulează răspunsul\"],\"ApSx0O\":[\"S-au găsit \",[\"0\"],\" mesaje care corespund cu \\\"\",[\"searchQuery\"],\"\\\"\"],\"AxPAXW\":[\"Niciun rezultat găsit\"],\"AyNqAB\":[\"Afișează toate evenimentele serverului în chat\"],\"B/QqGw\":[\"Departe de tastatură\"],\"B0sB2k\":[\"Text simplu\"],\"B8AaMI\":[\"Acest câmp este obligatoriu\"],\"BA2c49\":[\"Serverul nu acceptă filtrarea avansată LIST\"],\"BDKt3I\":[[\"0\"],\", \",[\"1\"],\", \",[\"2\"],\" și alți \",[\"3\"],\" scriu...\"],\"BGul2A\":[\"Ai modificări nesalvate. Ești sigur că vrei să închizi fără să salvezi?\"],\"BIf9fi\":[\"Mesajul dvs. de stare\"],\"BZz3md\":[\"Site-ul dvs. web personal\"],\"Bgm/H7\":[\"Permite introducerea mai multor rânduri de text\"],\"BiQIl1\":[\"Fixează această conversație privată\"],\"BlNZZ2\":[\"Click pentru a sări la mesaj\"],\"Bowq3c\":[\"Doar operatorii pot schimba subiectul canalului\"],\"Btozzp\":[\"Această imagine a expirat\"],\"Bycfjm\":[\"Total: \",[\"0\"]],\"C6IBQc\":[\"Copiați JSON complet\"],\"C9L9wL\":[\"Colectare de date\"],\"CDq4wC\":[\"Moderează utilizatorul\"],\"CHVRxG\":[\"Mesaj @\",[\"0\"],\" (Shift+Enter pentru linie nouă)\"],\"CN9zdR\":[\"Numele oper și parola sunt obligatorii\"],\"CW3sYa\":[\"Adaugă reacția \",[\"emoji\"]],\"CaAkqd\":[\"Afișați deconectările\"],\"CbvaYj\":[\"Banare după poreclă\"],\"CcK+Ft\":[\"PDF\"],\"Ce8q3L\":[\"Selectează un canal\"],\"CsekCi\":[\"Normal\"],\"D+NlUC\":[\"Sistem\"],\"D28t6+\":[\"s-a alăturat și a ieșit\"],\"DB8zMK\":[\"Aplică\"],\"DBcWHr\":[\"Fișier audio de notificare personalizat\"],\"DTy9Xw\":[\"Previzualizări media\"],\"Dj4pSr\":[\"Alege o parolă sigură\"],\"Du+zn+\":[\"Se caută...\"],\"Du2T2f\":[\"Setarea nu a fost găsită\"],\"DwsSVQ\":[\"Aplică filtrele și actualizează\"],\"E3W/zd\":[\"Pseudonim implicit\"],\"E6nRW7\":[\"Copiază URL\"],\"E703RG\":[\"Moduri:\"],\"EAeu1Z\":[\"Trimiteți invitația\"],\"EFKJQT\":[\"Setare\"],\"EGPQBv\":[\"Reguli flood personalizate (+f)\"],\"ELik0r\":[\"Vizualizați politica completă de confidențialitate\"],\"EPbeC2\":[\"Vezi sau editează subiectul canalului\"],\"EQCDNT\":[\"Introduceți numele de utilizator oper...\"],\"EUvulZ\":[\"S-a găsit 1 mesaj care corespunde cu \\\"\",[\"searchQuery\"],\"\\\"\"],\"EatZYJ\":[\"Imaginea următoare\"],\"EdQY6l\":[\"Niciunul\"],\"EnqLYU\":[\"Caută servere...\"],\"F0OKMc\":[\"Editează server\"],\"F6Int2\":[\"Activați evidențierile\"],\"FDoLyE\":[\"Utilizatori max.\"],\"FUU/hZ\":[\"Controlează câte conținuturi media externe sunt încărcate în chat.\"],\"Fdp03t\":[\"activ\"],\"FfPWR0\":[\"Modal\"],\"FjkaiT\":[\"Micșorează\"],\"FlqOE9\":[\"Ce înseamnă aceasta:\"],\"FolHNl\":[\"Gestionează-ți contul și autentificarea\"],\"Fp2Dif\":[\"A ieșit de pe server\"],\"G5KmCc\":[\"GZ-Line (Z-Line globală)\"],\"GDs0lz\":[\"<0>Risc: Informațiile sensibile (mesaje, conversații private, date de autentificare) pot fi expuse administratorilor de rețea sau atacatorilor poziționați între serverele IRC.\"],\"GR+2I3\":[\"Adaugă mască de invitație (ex. nick!*@*, *!*@host.com)\"],\"GRLyMU\":[\"Închide notificările server detașate\"],\"GlHnXw\":[\"Schimbarea poreclelei a eșuat: \",[\"error\"],\" \",[\"0\"]],\"GswZF3\":[\"Previzualizare:\"],\"GtmO8/\":[\"de la\"],\"GtuHUQ\":[\"Redenumiți acest canal pe server. Toți utilizatorii vor vedea noul nume.\"],\"GuGfFX\":[\"Comută căutarea\"],\"GxkJXS\":[\"Se încarcă...\"],\"GzbwnK\":[\"S-a alăturat canalului\"],\"GzsUDB\":[\"Profil extins\"],\"H/PnT8\":[\"Inserează emoji\"],\"H6Izzl\":[\"Codul dvs. de culoare preferat\"],\"H9jIv+\":[\"Afișați intrări/ieșiri\"],\"HAKBY9\":[\"Încărcați fișiere\"],\"HdE1If\":[\"Canal\"],\"Hk4AW9\":[\"Numele dvs. de afișare preferat\"],\"HmHDk7\":[\"Selectează un membru\"],\"HrQzPU\":[\"Canale pe \",[\"networkName\"]],\"I2tXQ5\":[\"Mesaj @\",[\"0\"],\" (Enter pentru linie nouă, Shift+Enter pentru trimitere)\"],\"I6bw/h\":[\"Banează utilizatorul\"],\"I92Z+b\":[\"Activează notificările\"],\"I9D72S\":[\"Sigur doriți să ștergeți acest mesaj? Această acțiune nu poate fi anulată.\"],\"IA+1wo\":[\"Afișează când utilizatorii sunt expulzați din canale\"],\"IDwkJx\":[\"Operator IRC\"],\"ILlU+s\":[\"Info:\"],\"IUwGEM\":[\"Salvează modificările\"],\"IVeGK6\":[[\"0\"],\", \",[\"1\"],\" și \",[\"2\"],\" scriu...\"],\"IgrLD/\":[\"Pauză\"],\"Im6JED\":[\"WHISPER\"],\"ImOQa9\":[\"Răspunde\"],\"IoHMnl\":[\"Valoarea maximă este \",[\"0\"]],\"IvMj+0\":[\"Op\"],\"J28zul\":[\"Se conectează...\"],\"J5T9NW\":[\"Informații utilizator\"],\"J8Y5+z\":[\"Oops! Rețeaua s-a împărțit! ⚠️\"],\"JBHkBA\":[\"A părăsit canalul\"],\"JCwL0Q\":[\"Introdu motivul (opțional)\"],\"JFciKP\":[\"Comută\"],\"JXGkhG\":[\"Schimbă numele canalului (numai operatori)\"],\"JcD7qf\":[\"Mai multe acțiuni\"],\"JdkA+c\":[\"Secret (+s)\"],\"Jmu12l\":[\"Canale server\"],\"JvQ++s\":[\"Activați Markdown\"],\"K2jwh/\":[\"Nu există date WHOIS disponibile\"],\"KAXSwC\":[\"Voice\"],\"KDfTdX\":[\"Șterge mesaj\"],\"KKBlUU\":[\"Încorporare\"],\"KM0pLb\":[\"Bine ai venit în canal!\"],\"KR6W2h\":[\"Nu mai ignora utilizatorul\"],\"KV+Bi1\":[\"Doar pe invitație (+i)\"],\"KdCtwE\":[\"Câte secunde se monitorizează activitatea flood înainte de resetarea contoarelor\"],\"Kkezga\":[\"Parolă server\"],\"KsiQ/8\":[\"Utilizatorii trebuie invitați pentru a intra în canal\"],\"L+gB/D\":[\"Informații canal\"],\"LC1a7n\":[\"Serverul IRC a raportat că legăturile sale server-la-server au un nivel scăzut de securitate. Aceasta înseamnă că atunci când mesajele tale sunt transmise între serverele IRC din rețea, este posibil ca acestea să nu fie criptate corespunzător sau certificatele SSL/TLS să nu fie validate corect.\"],\"LNfLR5\":[\"Afișați expulzările\"],\"LP+1Z7\":[\"Adaugă rețea\"],\"LQb0W/\":[\"Afișați toate evenimentele\"],\"LU7/yA\":[\"Nume alternativ pentru afișaj. Poate conține spații, emoji și caractere speciale. Numele real (\",[\"channelName\"],\") va fi folosit în continuare pentru comenzile IRC.\"],\"LUb9O7\":[\"Este necesar un port de server valid\"],\"Lb+BUl\":[\"https://example.com/avatar.jpg\"],\"LcET2C\":[\"Politică de confidențialitate\"],\"LcuSDR\":[\"Gestionează informațiile profilului și metadatele\"],\"LqLS9B\":[\"Afișați schimbările de pseudonim\"],\"LsDQt2\":[\"Setări canal\"],\"LtI9AS\":[\"Proprietar\"],\"LuNhhL\":[\"a reacționat la acest mesaj\"],\"M/AZNG\":[\"URL-ul imaginii dvs. de avatar\"],\"M/WIer\":[\"Trimite mesaj\"],\"M8er/5\":[\"Nume:\"],\"MHk+7g\":[\"Imaginea anterioară\"],\"MRorGe\":[\"Mesaj privat\"],\"MVbSGP\":[\"Fereastră de timp (secunde)\"],\"MkpcsT\":[\"Mesajele și setările dvs. sunt stocate local pe dispozitivul dvs.\"],\"MzPdC2\":[\"Parolă server (PASS)\"],\"N/hDSy\":[\"Marcați ca bot, de obicei 'on' sau gol\"],\"N6j2JH\":[\"Editează \",[\"0\"]],\"N7TQbE\":[\"Invitați utilizatorul în \",[\"channelName\"]],\"NCca/o\":[\"Introduceți porecla implicită...\"],\"Nqs6B9\":[\"Afișează toate conținuturile externe. Orice URL poate genera o solicitare către un server necunoscut.\"],\"Nt+9O7\":[\"Utilizați WebSocket în loc de TCP brut\"],\"NxIHzc\":[\"Expulzați utilizatorul\"],\"O+v/cL\":[\"Răsfoiește toate canalele de pe server\"],\"OCGpR4\":[\"(moștenește)\"],\"ODwSCk\":[\"Trimite un GIF\"],\"OGQ5kK\":[\"Configurează sunetele de notificare și evidențierile\"],\"OIPt1Z\":[\"Afișează sau ascunde bara laterală cu lista de membri\"],\"OKSNq/\":[\"Foarte strict\"],\"ONWvwQ\":[\"Încărcați\"],\"OVKoQO\":[\"Parola contului dvs. pentru autentificare\"],\"ObsidianIRC - Bringing IRC to the future\":[\"ObsidianIRC - Aducem IRC în viitor\"],\"OhCpra\":[\"Setează un subiect…\"],\"OkltoQ\":[\"Banează \",[\"username\"],\" după poreclă (împiedică reconectarea cu același nick)\"],\"P+t/Te\":[\"Nicio dată suplimentară\"],\"P42Wcc\":[\"Sigur\"],\"PD38l0\":[\"Previzualizare avatar canal\"],\"PD9mEt\":[\"Scrie un mesaj...\"],\"PPqfdA\":[\"Deschide setările de configurare ale canalului\"],\"PSCjfZ\":[\"Subiectul afișat pentru acest canal. Toți utilizatorii îl pot vedea.\"],\"PZCecv\":[\"Previzualizare PDF\"],\"PeLgsC\":[[\"c\",\"plural\",{\"one\":[\"1 dată\"],\"few\":[[\"c\"],\" ori\"],\"other\":[[\"c\"],\" ori\"]}]],\"PguS2C\":[\"Adaugă mască de excepție (ex. nick!*@*, *!*@host.com)\"],\"Pil5Ty\":[\"Se afișează \",[\"displayedChannelsCount\"],\" din \",[\"0\"],\" canale\"],\"PqhVlJ\":[\"Banează utilizator (după hostmask)\"],\"Q+chwU\":[\"Nume utilizator:\"],\"Q3v9Wc\":[\"Da, șterge\"],\"Q6hhn8\":[\"Preferințe\"],\"QF4a34\":[\"Introduceți un nume de utilizator\"],\"QGqSZ2\":[\"Culoare și formatare\"],\"QJQd1J\":[\"Editați profilul\"],\"QSzGDE\":[\"Inactiv\"],\"QUlny5\":[\"Bine ai venit la \",[\"0\"],\"!\"],\"Qoq+GP\":[\"Citește mai mult\"],\"QuSkCF\":[\"Filtrează canale...\"],\"QwUrDZ\":[\"a schimbat subiectul la: \",[\"topic\"]],\"R0UH07\":[\"Imaginea \",[\"0\"],\" din \",[\"1\"]],\"R7SsBE\":[\"Dezactivare sunet\"],\"R8rf1X\":[\"Click pentru a seta subiectul\"],\"RArB3D\":[\"a fost dat afară din \",[\"channelName\"],\" de \",[\"username\"]],\"RI3cWd\":[\"Descoperă lumea IRC cu ObsidianIRC\"],\"RMMaN5\":[\"Moderat (+m)\"],\"RWw9Lg\":[\"Închide fereastra\"],\"RZ2BuZ\":[\"Înregistrarea contului \",[\"account\"],\" necesită verificare: \",[\"message\"]],\"RySp6q\":[\"Ascundeți comentariile\"],\"S5Togi\":[\"Se încarcă rețelele de pe bouncer-ul tău…\"],\"SPKQTd\":[\"Porecla este obligatorie\"],\"SPVjfj\":[\"Va fi implicit „niciun motiv\\\" dacă este lăsat gol\"],\"SQKPvQ\":[\"Invită utilizator\"],\"STmlpb\":[\"Back to network list\"],\"SkZcl+\":[\"Alegeți un profil de protecție flood predefinit. Aceste profiluri oferă setări de protecție echilibrate pentru diferite cazuri de utilizare.\"],\"Slr+3C\":[\"Utilizatori min.\"],\"Spnlre\":[\"L-ai invitat pe \",[\"target\"],\" să se alăture la \",[\"channel\"]],\"T/ckN5\":[\"Deschide în vizualizator\"],\"T91vKp\":[\"Redare\"],\"TV2Wdu\":[\"Aflați cum gestionăm datele dvs. și vă protejăm confidențialitatea.\"],\"TgFpwD\":[\"Se aplică...\"],\"TkzSFB\":[\"Nicio modificare\"],\"TtserG\":[\"Introdu numele real\"],\"Ttz9J1\":[\"Introduceți parola...\"],\"Tz0i8g\":[\"Setări\"],\"U3pytU\":[\"Admin\"],\"UDb2YD\":[\"Reacționează\"],\"UE4KO5\":[\"*channel*\"],\"UGT5vp\":[\"Salvați setările\"],\"UV5hLB\":[\"Nu s-au găsit banuri\"],\"Uaj3Nd\":[\"Mesaje de stare\"],\"Ue3uny\":[\"Implicit (fără profil)\"],\"UkARhe\":[\"Normal – Protecție standard\"],\"Umn7Cj\":[\"Niciun comentariu. Fiți primul!\"],\"UtUIRh\":[[\"0\"],\" mesaje mai vechi\"],\"UwzP+U\":[\"Conexiune securizată\"],\"V0/A4O\":[\"Proprietar de canal\"],\"V4qgxE\":[\"Creat înainte (min în urmă)\"],\"V8yTm6\":[\"Șterge căutarea\"],\"VJMMyz\":[\"ObsidianIRC - Aducând IRC în viitor\"],\"VJScHU\":[\"Motiv\"],\"VLsmVV\":[\"Dezactivează notificările\"],\"VbyRUy\":[\"Comentarii\"],\"Vmx0mQ\":[\"Setat de:\"],\"VqnIZz\":[\"Vezi politica noastră de confidențialitate și practicile privind datele\"],\"VrMygG\":[\"Lungimea minimă este \",[\"0\"]],\"VrnTui\":[\"Pronumele dvs., afișate în profil\"],\"W8E3qn\":[\"Cont autentificat\"],\"WAakm9\":[\"Șterge canal\"],\"WFxTHC\":[\"Adaugă mască de banare (ex. nick!*@*, *!*@host.com)\"],\"WN1g9F\":[\"Adresa serverului este obligatorie\"],\"WRYdXW\":[\"Poziție audio\"],\"WUOH5B\":[\"Ignoră utilizatorul\"],\"WWEXnZ\":[[\"0\",\"plural\",{\"one\":[\"Arată 1 element în plus\"],\"few\":[\"Arată \",[\"1\"],\" elemente în plus\"],\"other\":[\"Arată \",[\"1\"],\" de elemente în plus\"]}]],\"Weq9zb\":[\"General\"],\"Wfj7Sk\":[\"Activează sau dezactivează sunetele de notificare\"],\"Wm7gbG\":[\"GitHub:\"],\"WyeHWY\":[\"*spam*\"],\"WzMCru\":[\"Profil utilizator\"],\"X6S3lt\":[\"Caută setări, canale, servere...\"],\"XEHan5\":[\"Continuă oricum\"],\"XI1+wb\":[\"Format invalid\"],\"XIXeuC\":[\"Mesaj @\",[\"0\"]],\"XMS+k4\":[\"Începe un mesaj privat\"],\"XWgxXq\":[\"Album\"],\"Xd7+IT\":[\"Anulează fixarea conversației private\"],\"Xm/s+u\":[\"Afișare\"],\"Xp2n93\":[\"Afișează media de pe gazda de fișiere de încredere a serverului. Nu se fac solicitări către servicii externe.\"],\"XvjC4F\":[\"Se salvează...\"],\"Y/qryO\":[\"Niciun utilizator găsit care să corespundă căutării\"],\"YAqRpI\":[\"Înregistrarea contului \",[\"account\"],\" a reușit: \",[\"message\"]],\"YEfzvP\":[\"Subiect protejat (+t)\"],\"YQOn6a\":[\"Restrânge lista de membri\"],\"YRCoE9\":[\"Operator de canal\"],\"YURQaF\":[\"Vizualizați profilul\"],\"YdBSvr\":[\"Controlează afișarea media și conținutul extern\"],\"Yj6U3V\":[\"Fără server central:\"],\"YjvpGx\":[\"Pronume\"],\"YqH4l4\":[\"Nicio cheie\"],\"YyUPpV\":[\"Cont:\"],\"ZJSWfw\":[\"Mesaj afișat la deconectarea de la server\"],\"ZR1dJ4\":[\"Invitații\"],\"ZdWg0V\":[\"Deschide în browser\"],\"ZhRBbl\":[\"Caută mesaje…\"],\"Zmcu3y\":[\"Filtre avansate\"],\"a2/8e5\":[\"Subiect setat după (min în urmă)\"],\"aHKcKc\":[\"Pagina anterioară\"],\"aJTbXX\":[\"Parolă oper\"],\"aQryQv\":[\"Modelul există deja\"],\"aW9pLN\":[\"Numărul maxim de utilizatori permis în canal. Lăsați gol pentru fără limită.\"],\"ah4fmZ\":[\"Afișează și previzualizări de pe YouTube, Vimeo, SoundCloud și servicii similare cunoscute.\"],\"aifXak\":[\"Niciun fișier media în acest canal\"],\"ap2zBz\":[\"Relaxat\"],\"az8lvo\":[\"Oprit\"],\"azXSNo\":[\"Extinde lista de membri\"],\"azdliB\":[\"Conectează-te la un cont\"],\"b26wlF\":[\"ea/ei\"],\"bD/+Ei\":[\"Strict\"],\"bQ6BJn\":[\"Configurați reguli detaliate de protecție flood. Fiecare regulă specifică tipul de activitate de monitorizat și acțiunea de întreprins când pragurile sunt depășite.\"],\"beV7+y\":[\"Utilizatorul va primi o invitație să se alăture \",[\"channelName\"],\".\"],\"bk84cH\":[\"Mesaj de absență\"],\"bkHdLj\":[\"Adaugă server IRC\"],\"bmQLn5\":[\"Adaugă regulă\"],\"bv4cFj\":[\"Transport\"],\"bwRvnp\":[\"Acțiune\"],\"c8+EVZ\":[\"Cont verificat\"],\"cGYUlD\":[\"Nu sunt încărcate previzualizări media.\"],\"cLF98o\":[\"Afișați comentariile (\",[\"commentCount\"],\")\"],\"cLKIDO\":[\"Niciun utilizator disponibil\"],\"cSgpoS\":[\"Fixează conversația privată\"],\"cde3ce\":[\"Mesaj <0>\",[\"0\"],\"\"],\"chQsxg\":[\"Copiați ieșirea formatată\"],\"cl/A5J\":[\"Bine ai venit la \",[\"__DEFAULT_IRC_SERVER_NAME__\"],\"!\"],\"cnGeoo\":[\"Șterge\"],\"coPLXT\":[\"Nu stocăm comunicațiile dvs. IRC pe serverele noastre\"],\"crYH/6\":[\"Player SoundCloud\"],\"cv5DQb\":[\"nicio gazdă setată\"],\"d3sis4\":[\"Adaugă server\"],\"d9aN5k\":[\"Elimină \",[\"username\"],\" din canal\"],\"dEgA5A\":[\"Anulează\"],\"dGi1We\":[\"Anulează fixarea acestei conversații private\"],\"dJVuyC\":[\"a părăsit \",[\"channelName\"],\" (\",[\"reason\"],\")\"],\"dMtLDE\":[\"către\"],\"dXqxlh\":[\"<0>⚠️ Risc de securitate! Această conexiune poate fi vulnerabilă la interceptare sau atacuri de tip man-in-the-middle.\"],\"da9Q/R\":[\"Moduri canal modificate\"],\"dhJN3N\":[\"Afișați comentariile\"],\"dj2xTE\":[\"Respinge notificarea\"],\"dpCzmC\":[\"Setări de protecție flood\"],\"e9dQpT\":[\"Dorești să deschizi acest link într-o filă nouă?\"],\"ePK91l\":[\"Editează\"],\"eYBDuB\":[\"Încărcați o imagine sau furnizați o adresă URL cu înlocuire opțională \",[\"size\"]],\"edBbee\":[\"Banează \",[\"username\"],\" după hostmask (împiedică reconectarea de pe același IP/host)\"],\"ekfzWq\":[\"Setări utilizator\"],\"elPDWs\":[\"Personalizează experiența clientului IRC\"],\"eu2osY\":[\"<0>💡 Recomandare: Continuați doar dacă aveți încredere în acest server și înțelegeți riscurile. Evitați să partajați informații sensibile sau parole prin această conexiune.\"],\"euEhbr\":[\"Faceți clic pentru a vă alătura \",[\"channel\"]],\"ez3vLd\":[\"Activați introducerea pe mai multe rânduri\"],\"f0J5Ki\":[\"Comunicarea server-la-server poate folosi conexiuni necriptate\"],\"f9BHJk\":[\"Avertizează utilizatorul\"],\"fDOLLd\":[\"Nu s-au găsit canale.\"],\"ffzDkB\":[\"Analize anonime:\"],\"fq1GF9\":[\"Afișează când utilizatorii se deconectează de la server\"],\"gEF57C\":[\"Acest server acceptă doar un tip de conexiune\"],\"gJuLUI\":[\"Listă de ignorați\"],\"gNzMrk\":[\"Avatar curent\"],\"gjPWyO\":[\"Introduceți porecla...\"],\"gz6UQ3\":[\"Maximizează\"],\"h6/IMX\":[\"Adaugă prima ta rețea\"],\"h6razj\":[\"Excludeți masca numelui canalului\"],\"hG6jnw\":[\"Niciun subiect setat\"],\"hG89Ed\":[\"Imagine\"],\"hZ6znB\":[\"Port\"],\"ha+Bz5\":[\"ex., 100:1440\"],\"hehnjM\":[\"Cantitate\"],\"hzdLuQ\":[\"Doar utilizatorii cu voice sau mai mult pot vorbi\"],\"i0qMbr\":[\"Acasă\"],\"iDNBZe\":[\"Notificări\"],\"iH8pgl\":[\"Înapoi\"],\"iL9SZg\":[\"Banează utilizator (după poreclă)\"],\"iNt+3c\":[\"Înapoi la imagine\"],\"iQvi+a\":[\"Nu mă avertiza despre securitatea scăzută a legăturilor pentru acest server\"],\"iSLIjg\":[\"Conectare\"],\"iWXkHH\":[\"Halfop\"],\"iZeTtp\":[\"Adresă server\"],\"idD8Ev\":[\"Salvat\"],\"iivqkW\":[\"Conectat la\"],\"ij+Elv\":[\"Previzualizare imagine\"],\"ilIWp7\":[\"Comută notificările\"],\"iuaqvB\":[\"Folosiți * pentru wildcard. Exemple: baduser!*@*, *!*@spammer.com, troll*!*@*\"],\"ixkTse\":[\"Bot\"],\"j2DGR0\":[\"Banare după mască gazdă\"],\"jA4uoI\":[\"Subiect:\"],\"jLXxGK\":[\"https://example.com\"],\"jPSk57\":[\"Motiv (opțional)\"],\"jUV7CU\":[\"Încarcă avatar\"],\"jW5Uwh\":[\"Controlează câtă media externă se încarcă. Dezactivat / Sigur / Surse de încredere / Tot conținutul.\"],\"jXzms5\":[\"Opțiuni atașament\"],\"jZlrte\":[\"Culoare\"],\"jfC/xh\":[\"Contact\"],\"jywMpv\":[\"#new-channel-name\"],\"k112DD\":[\"Încarcă mesaje mai vechi\"],\"k3ID0F\":[\"Filtrează membri…\"],\"k65gsE\":[\"Detalii\"],\"k7Zgob\":[\"Anulează conexiunea\"],\"kAVx5h\":[\"Nu s-au găsit invitații\"],\"kCLEPU\":[\"Conectat la\"],\"kF5LKb\":[\"Modele ignorate:\"],\"kGeOx/\":[\"Alătură-te la \",[\"0\"]],\"kITKr8\":[\"Se încarcă modurile canalului...\"],\"kPpPsw\":[\"Ești un Operator IRC\"],\"kWJmRL\":[\"Tu\"],\"kfcRb0\":[\"Avatar\"],\"kjMqSj\":[\"Copiați JSON\"],\"krViRy\":[\"Clic pentru copiere ca JSON\"],\"ks71ra\":[\"Excepții\"],\"kw4lRv\":[\"Semi-operator de canal\"],\"kxgIRq\":[\"Selectează sau adaugă un canal pentru a începe.\"],\"ky6dWe\":[\"Previzualizare avatar\"],\"l+GxCv\":[\"Se încarcă canalele...\"],\"l+IUVW\":[\"Verificarea contului \",[\"account\"],\" a reușit: \",[\"message\"]],\"l/siQz\":[[\"reconnectCount\",\"plural\",{\"one\":[\"s-a reconectat\"],\"few\":[\"s-a reconectat de \",[\"reconnectCount\"],\" ori\"],\"other\":[\"s-a reconectat de \",[\"reconnectCount\"],\" ori\"]}]],\"l5jmzx\":[[\"0\"],\" și \",[\"1\"],\" scriu...\"],\"lHy8N5\":[\"Se încarcă mai multe canale...\"],\"lbpf14\":[\"Intrați în \",[\"value\"]],\"lfFsZ4\":[\"Canale\"],\"lkNdiH\":[\"Nume cont\"],\"ln500L\":[\"ObsidianIRC\"],\"lnCMdg\":[\"Încarcă imagine\"],\"loQxaJ\":[\"M-am întors\"],\"lvfaxv\":[\"ACASĂ\"],\"m0oxpP\":[\"Libera Chat\"],\"m16xKo\":[\"Adaugă\"],\"m8flAk\":[\"Previzualizare (neîncărcat încă)\"],\"mEPxTp\":[\"<0>⚠️ Atenție! Deschide numai linkuri din surse de încredere. Linkurile malițioase îți pot compromite securitatea sau confidențialitatea.\"],\"mHGdhG\":[\"Informații server\"],\"mHS8lb\":[\"Mesaj #\",[\"0\"]],\"mMYBD9\":[\"Larg – Domeniu de protecție mai amplu\"],\"mTGsPd\":[\"Subiect canal\"],\"mU8j6O\":[\"Fără mesaje externe (+n)\"],\"mZp8FL\":[\"Revenire automată la o singură linie\"],\"mdQu8G\":[\"NumeleTău\"],\"miSSBQ\":[\"Comentarii (\",[\"commentCount\"],\")\"],\"mvyLSy\":[\"Utilizatorul este autentificat\"],\"mwtcGl\":[\"Închide comentariile\"],\"myL0MR\":[\"Ștergeți această rețea?\"],\"mzI/c+\":[\"Descarcă\"],\"n3fGRk\":[\"setat de \",[\"0\"]],\"nE9jsU\":[\"Relaxat – Protecție mai puțin agresivă\"],\"nNflMD\":[\"Părăsește canalul\"],\"nPXkBi\":[\"Se încarcă datele WHOIS...\"],\"nQnxxF\":[\"Mesaj #\",[\"0\"],\" (Shift+Enter pentru linie nouă)\"],\"nWMRxa\":[\"Anulează fixarea\"],\"nkC032\":[\"Niciun profil anti-flood\"],\"o69z4d\":[\"Trimite un mesaj de avertizare către \",[\"username\"]],\"o9ylQi\":[\"Căutați GIF-uri pentru a începe\"],\"oFGkER\":[\"Notificări server\"],\"oOi11l\":[\"Derulează în jos\"],\"oQEzQR\":[\"Mesaj direct nou\"],\"oXOSPE\":[\"Conectat\"],\"oal760\":[\"Atacurile man-in-the-middle asupra legăturilor de server sunt posibile\"],\"oeqmmJ\":[\"Surse de încredere\"],\"ovBPCi\":[\"Implicit\"],\"p0Z69r\":[\"Modelul nu poate fi gol\"],\"p1KgtK\":[\"Eroare la încărcarea audio\"],\"p59pEv\":[\"Detalii suplimentare\"],\"p7sRI6\":[\"Anunțați ceilalți când scrieți\"],\"pBm1od\":[\"Canal secret\"],\"pNmiXx\":[\"Pseudonimul dvs. implicit pentru toate serverele\"],\"pUUo9G\":[\"Hostname:\"],\"pVGPmz\":[\"Parolă cont\"],\"peNE68\":[\"Permanent\"],\"plhHQt\":[\"Fără date\"],\"pm6+q5\":[\"Avertisment de securitate\"],\"pn5qSs\":[\"Informații suplimentare\"],\"q0cR4S\":[\"acum este cunoscut ca **\",[\"newNick\"],\"**\"],\"qFcunY\":[\"Canalul nu va apărea în comenzile LIST sau NAMES\"],\"qLpTm/\":[\"Elimină reacția \",[\"emoji\"]],\"qVkGWK\":[\"Fixează\"],\"qY8wNa\":[\"Pagină principală\"],\"qb0xJ7\":[\"Wildcard: * se potrivește oricărei secvențe, ? unui singur caracter. Exemple: nick!*@*, *!*@host.com, *!*user@*\"],\"qhzpRq\":[\"Cheie canal (+k)\"],\"qtoOYG\":[\"Nicio limită\"],\"r1W2AS\":[\"Imagine filehost\"],\"rIPR2O\":[\"Subiect setat înainte (min în urmă)\"],\"rMMSYo\":[\"Lungimea maximă este \",[\"0\"]],\"rWtzQe\":[\"Rețeaua s-a împărțit și s-a reconectat. ✅\"],\"rYG2u6\":[\"Vă rugăm așteptați...\"],\"rdUucN\":[\"Previzualizare\"],\"rjGI/Q\":[\"Confidențialitate\"],\"rk8iDX\":[\"Se încarcă GIF-urile...\"],\"rn6SBY\":[\"Activare sunet\"],\"s/UKqq\":[\"A fost dat afară din canal\"],\"s8cATI\":[\"s-a alăturat la \",[\"channelName\"]],\"sCO9ue\":[\"Conexiunea la <0>\",[\"serverName\"],\" prezintă următoarele probleme de securitate:\"],\"sGH11W\":[\"Server\"],\"sHI1H+\":[\"acum este cunoscut ca **\",[\"newNick\"],\"**\"],\"sJyV04\":[[\"inviter\"],\" te-a invitat să te alături la \",[\"channel\"]],\"sUBSbK\":[\"Încă nu există rețele upstream.\"],\"sby+1/\":[\"Faceți clic pentru a copia\"],\"sfN25C\":[\"Numele dvs. real sau complet\"],\"sliuzR\":[\"Deschide linkul\"],\"sqrO9R\":[\"Mențiuni personalizate\"],\"sr6RdJ\":[\"Multilinie cu Shift+Enter\"],\"swrCpB\":[\"Canalul a fost redenumit din \",[\"oldName\"],\" în \",[\"newName\"],\" de \",[\"user\"],[\"0\"]],\"sxkWRg\":[\"Avansat\"],\"t/YqKh\":[\"Elimină\"],\"t47eHD\":[\"Identificatorul dvs. unic pe acest server\"],\"tAkAh0\":[\"URL cu înlocuire opțională \",[\"size\"],\". Exemplu: https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"tXLJS3\":[\"Afișează sau ascunde bara laterală cu lista de canale\"],\"tfDRzk\":[\"Salvează\"],\"tiBsJk\":[\"a părăsit \",[\"channelName\"]],\"tt4/UD\":[\"a ieșit (\",[\"reason\"],\")\"],\"u0TcnO\":[\"Porecla {nick} este deja utilizată, reîncercare cu {newNick}\"],\"u0a8B4\":[\"Autentificați-vă ca operator IRC pentru acces administrativ\"],\"u0rWFU\":[\"Creat după (min în urmă)\"],\"u72w3t\":[\"Utilizatori și modele de ignorat\"],\"u7jc2L\":[\"a ieșit\"],\"uAQUqI\":[\"Stare\"],\"uB85T3\":[\"Salvare eșuată: \",[\"msg\"]],\"uV3DOL\":[\"G-Line\"],\"uW3lLI\":[\"Servere IRC:\"],\"usSSr/\":[\"Nivel de zoom\"],\"v7uvcf\":[\"Software:\"],\"vE8kb+\":[\"Shift+Enter pentru rânduri noi (Enter trimite)\"],\"vERlcd\":[\"Profil\"],\"vK0RL8\":[\"Fără subiect\"],\"vSJd18\":[\"Video\"],\"vXIe7J\":[\"Limbă\"],\"vaHYxN\":[\"Nume real\"],\"vhjbKr\":[\"Absent\"],\"w/nogd\":[[\"0\"],\" network\",[\"1\"],\" — pick one to join\"],\"w4NYox\":[\"client \",[\"title\"]],\"w8xQRx\":[\"Valoare invalidă\"],\"wFjjxZ\":[\"a fost dat afară din \",[\"channelName\"],\" de \",[\"username\"],\" (\",[\"reason\"],\")\"],\"wGjaGl\":[\"Nu s-au găsit excepții de banare\"],\"wPrGnM\":[\"Administrator de canal\"],\"wRkP2d\":[\"GIF\"],\"wbm86v\":[\"Afișează când utilizatorii intră sau ies din canale\"],\"whqZ9r\":[\"Cuvinte sau fraze suplimentare de evidențiat\"],\"wm7RV4\":[\"Sunet de notificare\"],\"wz/Yoq\":[\"Mesajele tale pot fi interceptate când sunt transmise între servere\"],\"xCJdfg\":[\"Șterge\"],\"xUHRTR\":[\"Autentificare automată ca operator la conectare\"],\"xWHwwQ\":[\"Banuri\"],\"xYilR2\":[\"Media\"],\"xceQrO\":[\"Sunt acceptate numai websocket-uri securizate\"],\"xdtXa+\":[\"nume-canal\"],\"xfXC7q\":[\"Canale text\"],\"xlCYOE\":[\"Se încarcă mai multe mesaje...\"],\"xlhswE\":[\"Valoarea minimă este \",[\"0\"]],\"xq97Ci\":[\"Adaugă un cuvânt sau o expresie...\"],\"xuRqRq\":[\"Limită clienți (+l)\"],\"xwF+7J\":[[\"0\"],\" scrie...\"],\"yJztBY\":[\"Șterge rețeaua\"],\"yNeucF\":[\"Acest server nu acceptă metadate extinse de profil (extensia IRCv3 METADATA). Câmpurile precum avatar, nume afișat și stare nu sunt disponibile.\"],\"yPlrca\":[\"Avatar canal\"],\"yQE2r9\":[\"Se încarcă\"],\"ySU+JY\":[\"your@email.com\"],\"yTX1Rt\":[\"Nume utilizator operator\"],\"yYOzWD\":[\"jurnale\"],\"yfx9Re\":[\"Parola operatorului IRC\"],\"ygCKqB\":[\"Oprește\"],\"ymDxJx\":[\"Numele de utilizator al operatorului IRC\"],\"yrpRsQ\":[\"Sortare după nume\"],\"yz7wBu\":[\"Închide\"],\"zJw+jA\":[\"setează modul: \",[\"0\"]],\"zebeLu\":[\"Introdu numele de utilizator oper\"],\"zpr0Bw\":[\"GZ-Line\"]}"); \ No newline at end of file diff --git a/src/locales/ro/messages.po b/src/locales/ro/messages.po index 4f7eab59..f01907fe 100644 --- a/src/locales/ro/messages.po +++ b/src/locales/ro/messages.po @@ -22,6 +22,16 @@ msgstr "ObsidianIRC - Aducem IRC în viitor" msgid "— open in viewer" msgstr "— deschide în vizualizator" +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx +msgid "(inherit)" +msgstr "(moștenește)" + +#: src/components/ui/BouncerNetworkForm.tsx +msgid "(unchanged)" +msgstr "(nemodificat)" + #. placeholder {0}: filteredMessages.length #. placeholder {1}: import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import type * as React from "react"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { SCROLL_TOLERANCE, useScrollToBottom, } from "../../hooks/useScrollToBottom"; import { groupConsecutiveEvents } from "../../lib/eventGrouping"; import ircClient from "../../lib/ircClient"; import useStore from "../../store"; import type { Message as MessageType } from "../../types"; import { CollapsedEventMessage } from "../message/CollapsedEventMessage"; import { MessageItem } from "../message/MessageItem"; import LoadingSpinner from "../ui/LoadingSpinner"; import { ScrollToBottomButton } from "../ui/ScrollToBottomButton"; export const DEFAULT_VISIBLE_MESSAGE_COUNT = 100; // Stable empty array — prevents selector from returning a new [] on every render // when the channel has no messages yet (undefined ?? [] would create a new ref each time). const EMPTY_MESSAGES: import("../../types").Message[] = []; export interface ChannelMessageListHandle { setAtBottom: () => void; scrollToBottom: () => void; getScrollState: () => { scrollTop: number; isAtBottom: boolean; visibleCount: number; }; } interface ChannelMessageListProps { channelKey: string; serverId: string; channelId: string | null; privateChatId: string | null; isActive: boolean; searchQuery: string; isMemberListVisible: boolean; onReply: (msg: MessageType | null) => void; onUsernameContextMenu: ( e: React.MouseEvent, username: string, serverId: string, channelId: string, avatarEl?: Element | null, ) => void; onIrcLinkClick: (url: string) => void; onReactClick: (msg: MessageType, el: Element) => void; onReactionUnreact: (emoji: string, msg: MessageType) => void; onOpenReactionModal: ( msg: MessageType, position: { x: number; y: number }, ) => void; onDirectReaction: (emoji: string, msg: MessageType) => void; onRedactMessage: (msg: MessageType) => void; onOpenProfile: (username: string) => void; joinChannel: (serverId: string, channelName: string) => void; onClearSearch: () => void; highlightedMessageId?: string; // undefined = first visit; null = was at bottom; object = restore to saved position initialScrollState?: { scrollTop: number; visibleCount: number } | null; } export const ChannelMessageList = forwardRef< ChannelMessageListHandle, ChannelMessageListProps >( ( { channelKey, serverId, channelId, privateChatId, isActive, searchQuery, isMemberListVisible, onReply, onUsernameContextMenu, onIrcLinkClick, onReactClick, onReactionUnreact, onOpenReactionModal, onDirectReaction, onRedactMessage, onOpenProfile, joinChannel, onClearSearch, highlightedMessageId, initialScrollState, }, ref, ) => { const { t } = useLingui(); const [visibleMessageCount, setVisibleMessageCount] = useState( initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT, ); // Ref mirror so getScrollState closure always reads the current value without needing it as a dep. const visibleMessageCountRef = useRef(visibleMessageCount); visibleMessageCountRef.current = visibleMessageCount; // Distinguishes initial join (full-screen spinner) from subsequent "load more" (button spinner). const [isFetchingMore, setIsFetchingMore] = useState(false); const isFetchingMoreRef = useRef(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const messagesInnerRef = useRef(null); // prev scrollHeight for prepend delta-correction. const prevScrollHeightRef = useRef(0); // Ref mirror of isScrolledUp — lets useLayoutEffect closures read current value // without listing isScrolledUp as a dep (which would re-run effects on every scroll). const isScrolledUpRef = useRef(false); const prevFilteredLengthRef = useRef(0); const prevFirstMsgIdRef = useRef(null); // Set by the window-growth layoutEffect (or button handler) when a true prepend is detected. // Consumed by the delta-correction layoutEffect one render later (after visibleCount grows). // Using a flag instead of tracking displayedMessages[0]?.id because slice(-N) slides the // window on every bottom append, changing displayedMessages[0] even for non-prepend renders. const pendingPrependRef = useRef(false); // Shared scrollHeight baseline between the delta-correction layout effect and the inner // ResizeObserver. When scrollTop is corrected after a prepend, we update this so the RO's // "was at bottom" check is not fooled by the adjusted scrollTop vs its stale prevSH. const resizeObserverPrevSHRef = useRef(0); const channelMessages = useStore( useCallback( (state) => state.messages[channelKey] ?? EMPTY_MESSAGES, [channelKey], ), ); const servers = useStore((state) => state.servers); const mobileViewActiveColumn = useStore( (state) => state.ui.mobileViewActiveColumn, ); const channel = useMemo( () => channelId ? (servers .find((s) => s.id === serverId) ?.channels.find((c) => c.id === channelId) ?? null) : null, [servers, serverId, channelId], ); const { isScrolledUp, wasAtBottomRef, scrollToBottom } = useScrollToBottom( messagesContainerRef, messagesEndRef, { channelId: `${channelId || privateChatId}-${isMemberListVisible}` }, ); // Snapshot of the last known scroll position captured while the container was visible. // getScrollState() reads this instead of the live DOM because React commits display:none // before running cleanup effects, collapsing scrollTop/scrollHeight/clientHeight to 0. const lastScrollTopRef = useRef(initialScrollState?.scrollTop ?? 0); useEffect(() => { const container = messagesContainerRef.current; if (!container) return; const onScroll = () => { if (container.clientHeight > 0) lastScrollTopRef.current = container.scrollTop; }; container.addEventListener("scroll", onScroll, { passive: true }); return () => container.removeEventListener("scroll", onScroll); }, []); // Restore scroll position when a keep-alive channel transitions from hidden to visible. // display:none may reset scrollTop to 0; lastScrollTopRef was captured while visible. const prevActiveRef = useRef(isActive); useLayoutEffect(() => { if (isActive && !prevActiveRef.current) { const container = messagesContainerRef.current; if (container && lastScrollTopRef.current > 0) { container.scrollTop = lastScrollTopRef.current; } } prevActiveRef.current = isActive; }, [isActive]); useImperativeHandle(ref, () => ({ setAtBottom: () => { wasAtBottomRef.current = true; }, scrollToBottom, getScrollState: () => ({ scrollTop: lastScrollTopRef.current, isAtBottom: wasAtBottomRef.current, visibleCount: visibleMessageCountRef.current, }), })); const filteredMessages = useMemo(() => { if (!searchQuery.trim()) return channelMessages; const query = searchQuery.toLowerCase(); return channelMessages.filter( (msg) => msg.content.toLowerCase().includes(query) || msg.userId.toLowerCase().includes(query), ); }, [channelMessages, searchQuery]); useEffect(() => { isScrolledUpRef.current = isScrolledUp; // When the user returns to the bottom, shrink the window back to the base so // slice(-N) resumes trimming old messages from the top (memory optimization). // Only shrink if we grew above the base — preserves a sub-default saved visibleCount. if (!isScrolledUp) { setVisibleMessageCount((prev) => prev > DEFAULT_VISIBLE_MESSAGE_COUNT ? DEFAULT_VISIBLE_MESSAGE_COUNT : prev, ); } }, [isScrolledUp]); // Reset ref-tracked windowing state when switching channels. // visibleMessageCount is NOT reset here — useState(initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT) // already initializes it correctly on mount, and this effect runs once on mount for the // same channelKey (each instance is bound to exactly one channel by the parent key={}). // biome-ignore lint/correctness/useExhaustiveDependencies: intentional full reset on channel change useEffect(() => { prevFilteredLengthRef.current = 0; prevFirstMsgIdRef.current = null; prevScrollHeightRef.current = 0; pendingPrependRef.current = false; resizeObserverPrevSHRef.current = 0; }, [channelKey]); const displayedMessages = useMemo(() => { if (searchQuery.trim()) return filteredMessages; return filteredMessages.slice(-visibleMessageCount); }, [filteredMessages, visibleMessageCount, searchQuery]); const locallyHidden = filteredMessages.length > displayedMessages.length; const serverHasMore = channel?.hasMoreHistory === true; const hasMoreMessages = locallyHidden || serverHasMore; const eventGroups = useMemo( () => groupConsecutiveEvents(displayedMessages), [displayedMessages], ); const isLoadingHistory = channel?.isLoadingHistory ?? false; // Scroll to bottom on initial mount, unless a saved position was passed in. // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount only useEffect(() => { const container = messagesContainerRef.current; if (!container) return; if (initialScrollState) { container.scrollTop = initialScrollState.scrollTop; lastScrollTopRef.current = initialScrollState.scrollTop; wasAtBottomRef.current = false; } else { container.scrollTop = container.scrollHeight; lastScrollTopRef.current = container.scrollHeight; wasAtBottomRef.current = true; } }, []); // Scroll to bottom after initial join history loads; clear fetch spinner at batch end. const wasLoadingHistoryRef = useRef(false); // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is stable via useCallback; refs and setters are stable useLayoutEffect(() => { if (wasLoadingHistoryRef.current && !isLoadingHistory) { if (isFetchingMoreRef.current) { // delta correction for scroll position is handled by useLayoutEffect([displayedMessages]) isFetchingMoreRef.current = false; setIsFetchingMore(false); } else { scrollToBottom(); wasAtBottomRef.current = true; } } wasLoadingHistoryRef.current = isLoadingHistory; }, [isLoadingHistory]); // When older messages are prepended, grow the window so they enter displayedMessages. // When new messages arrive at the bottom while the user is scrolled up, also grow the // window to keep the current top messages visible — slice(-N) otherwise slides the // window forward and hides them, incrementing the "N older messages" counter on every // incoming message. Only let the slice trim from the top when the user is at the bottom // (where auto-scroll handles keeping them current). useLayoutEffect(() => { const newLength = filteredMessages.length; const newFirstId = filteredMessages[0]?.id ?? null; const delta = newLength - prevFilteredLengthRef.current; if (prevFilteredLengthRef.current > 0 && delta > 0) { if (newFirstId !== prevFirstMsgIdRef.current) { // Messages prepended (load-more): signal delta-correction to compensate scrollTop. pendingPrependRef.current = true; setVisibleMessageCount((prev) => prev + delta); } else if (isScrolledUpRef.current) { // Messages appended at bottom while user is scrolled up reading history. // Expand the window to prevent top messages from dropping out of the slice. setVisibleMessageCount((prev) => prev + delta); } } prevFilteredLengthRef.current = newLength; prevFirstMsgIdRef.current = newFirstId; }, [filteredMessages]); // Compensate scrollTop when content is prepended above the viewport. // biome-ignore lint/correctness/useExhaustiveDependencies: runs on every displayedMessages render to capture the resulting scrollHeight; refs are stable useLayoutEffect(() => { const container = messagesContainerRef.current; if (!container) return; // Skip while container is display:none — scrollHeight collapses to 0 and would // poison prevScrollHeightRef, causing a huge spurious delta on the next visible render. if (container.clientHeight === 0) return; const prevHeight = prevScrollHeightRef.current; const newHeight = container.scrollHeight; // Only correct when a true load-more prepend happened (flag set by the window-growth // layoutEffect or button handler). Bottom appends slide the slice(-N) window which also // changes displayedMessages[0] — ID-comparison can't distinguish the two cases. const wasPrepend = pendingPrependRef.current; // Only consume the flag when scrollHeight actually changed — the server-side load-more // path goes through two renders: Render A (filteredMessages grows, visibleCount unchanged, // same displayedMessages content, same scrollHeight) then Render B (visibleCount grows, // new messages enter displayedMessages, scrollHeight grows). The flag must survive Render A // so it's still set when Render B fires the actual correction. if (wasPrepend && newHeight !== prevHeight) { pendingPrependRef.current = false; } if ( isScrolledUpRef.current && prevHeight > 0 && newHeight > prevHeight && wasPrepend ) { const delta = newHeight - prevHeight; container.scrollTop += delta; resizeObserverPrevSHRef.current = newHeight; } prevScrollHeightRef.current = newHeight; }, [displayedMessages]); // Re-stick to bottom when inner message content grows (media/audio previews loading). // Uses prevScrollHeight instead of wasAtBottomRef to avoid stale-flag race where the // ref is true while the user is actively scrolling up. // When the container width changes (member list toggle, window resize), text reflows // and scrollHeight changes; preserve proportional scroll position for scrolled-up users. // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is a stable ref useEffect(() => { const container = messagesContainerRef.current; const inner = messagesInnerRef.current; if (!inner || !container) return; resizeObserverPrevSHRef.current = container.scrollHeight; let prevClientWidth = container.clientWidth; const observer = new ResizeObserver(() => { if (container.clientHeight === 0) return; // Effect may re-initialize while container is display:none (ref=0). // Re-seed with current dimensions and skip — no reliable "was at bottom" data. if (resizeObserverPrevSHRef.current === 0) { resizeObserverPrevSHRef.current = container.scrollHeight; prevClientWidth = container.clientWidth; return; } const currentClientWidth = container.clientWidth; const widthChanged = currentClientWidth !== prevClientWidth; prevClientWidth = currentClientWidth; const prevSH = resizeObserverPrevSHRef.current; const wasAtPrevBottom = container.scrollTop + container.clientHeight >= prevSH - SCROLL_TOLERANCE; resizeObserverPrevSHRef.current = container.scrollHeight; if (wasAtPrevBottom) { scrollToBottom(); } else if (widthChanged && prevSH > 0) { const ratio = container.scrollTop / prevSH; container.scrollTop = Math.round(ratio * container.scrollHeight); } }); observer.observe(inner); return () => observer.disconnect(); }, [isLoadingHistory, channelId, privateChatId]); // Auto-scroll on new messages — skip when this channel is hidden (display:none). // biome-ignore lint/correctness/useExhaustiveDependencies: only scroll when messages change, not when isActive changes useEffect(() => { if (!isActive) return; const isNarrowView = window.matchMedia("(max-width: 768px)").matches; const isChatVisible = !isNarrowView || mobileViewActiveColumn === "chatView"; if (wasAtBottomRef.current && isChatVisible) { scrollToBottom(); } }, [displayedMessages, mobileViewActiveColumn, scrollToBottom, isActive]); return ( <>
{isLoadingHistory && !isFetchingMore ? (
) : (
{hasMoreMessages && !searchQuery && (
)} {searchQuery && (
{plural(filteredMessages.length, { one: t`Found 1 message matching "${searchQuery}"`, other: t`Found ${filteredMessages.length} messages matching "${searchQuery}"`, })}
)} {eventGroups.map((group) => { if (group.type === "eventGroup") { const firstId = group.messages[0]?.id || ""; const lastId = group.messages[group.messages.length - 1]?.id || ""; const groupKey = `group-${firstId}-${lastId}`; return ( ); } const message = group.messages[0]; const originalIndex = channelMessages.findIndex( (m) => m.id === message.id, ); const previousMessage = channelMessages[originalIndex - 1]; const showHeader = !previousMessage || previousMessage.type !== "message" || previousMessage.userId !== message.userId || new Date(message.timestamp).getTime() - new Date(previousMessage.timestamp).getTime() > 5 * 60 * 1000; return ( ); })}
)}
); }, ); ChannelMessageList.displayName = "ChannelMessageList"; // Wrap with memo so hidden keep-alive channels skip re-renders when their props // haven't changed (e.g. when messageText changes in the input — the only thing // that changes on typing is local state inside ChatArea, not the props we pass here). export const MemoChannelMessageList = memo(ChannelMessageList); #. placeholder {2}: import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import type * as React from "react"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { SCROLL_TOLERANCE, useScrollToBottom, } from "../../hooks/useScrollToBottom"; import { groupConsecutiveEvents } from "../../lib/eventGrouping"; import ircClient from "../../lib/ircClient"; import useStore from "../../store"; import type { Message as MessageType } from "../../types"; import { CollapsedEventMessage } from "../message/CollapsedEventMessage"; import { MessageItem } from "../message/MessageItem"; import LoadingSpinner from "../ui/LoadingSpinner"; import { ScrollToBottomButton } from "../ui/ScrollToBottomButton"; export const DEFAULT_VISIBLE_MESSAGE_COUNT = 100; // Stable empty array — prevents selector from returning a new [] on every render // when the channel has no messages yet (undefined ?? [] would create a new ref each time). const EMPTY_MESSAGES: import("../../types").Message[] = []; export interface ChannelMessageListHandle { setAtBottom: () => void; scrollToBottom: () => void; getScrollState: () => { scrollTop: number; isAtBottom: boolean; visibleCount: number; }; } interface ChannelMessageListProps { channelKey: string; serverId: string; channelId: string | null; privateChatId: string | null; isActive: boolean; searchQuery: string; isMemberListVisible: boolean; onReply: (msg: MessageType | null) => void; onUsernameContextMenu: ( e: React.MouseEvent, username: string, serverId: string, channelId: string, avatarEl?: Element | null, ) => void; onIrcLinkClick: (url: string) => void; onReactClick: (msg: MessageType, el: Element) => void; onReactionUnreact: (emoji: string, msg: MessageType) => void; onOpenReactionModal: ( msg: MessageType, position: { x: number; y: number }, ) => void; onDirectReaction: (emoji: string, msg: MessageType) => void; onRedactMessage: (msg: MessageType) => void; onOpenProfile: (username: string) => void; joinChannel: (serverId: string, channelName: string) => void; onClearSearch: () => void; highlightedMessageId?: string; // undefined = first visit; null = was at bottom; object = restore to saved position initialScrollState?: { scrollTop: number; visibleCount: number } | null; } export const ChannelMessageList = forwardRef< ChannelMessageListHandle, ChannelMessageListProps >( ( { channelKey, serverId, channelId, privateChatId, isActive, searchQuery, isMemberListVisible, onReply, onUsernameContextMenu, onIrcLinkClick, onReactClick, onReactionUnreact, onOpenReactionModal, onDirectReaction, onRedactMessage, onOpenProfile, joinChannel, onClearSearch, highlightedMessageId, initialScrollState, }, ref, ) => { const { t } = useLingui(); const [visibleMessageCount, setVisibleMessageCount] = useState( initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT, ); // Ref mirror so getScrollState closure always reads the current value without needing it as a dep. const visibleMessageCountRef = useRef(visibleMessageCount); visibleMessageCountRef.current = visibleMessageCount; // Distinguishes initial join (full-screen spinner) from subsequent "load more" (button spinner). const [isFetchingMore, setIsFetchingMore] = useState(false); const isFetchingMoreRef = useRef(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const messagesInnerRef = useRef(null); // prev scrollHeight for prepend delta-correction. const prevScrollHeightRef = useRef(0); // Ref mirror of isScrolledUp — lets useLayoutEffect closures read current value // without listing isScrolledUp as a dep (which would re-run effects on every scroll). const isScrolledUpRef = useRef(false); const prevFilteredLengthRef = useRef(0); const prevFirstMsgIdRef = useRef(null); // Set by the window-growth layoutEffect (or button handler) when a true prepend is detected. // Consumed by the delta-correction layoutEffect one render later (after visibleCount grows). // Using a flag instead of tracking displayedMessages[0]?.id because slice(-N) slides the // window on every bottom append, changing displayedMessages[0] even for non-prepend renders. const pendingPrependRef = useRef(false); // Shared scrollHeight baseline between the delta-correction layout effect and the inner // ResizeObserver. When scrollTop is corrected after a prepend, we update this so the RO's // "was at bottom" check is not fooled by the adjusted scrollTop vs its stale prevSH. const resizeObserverPrevSHRef = useRef(0); const channelMessages = useStore( useCallback( (state) => state.messages[channelKey] ?? EMPTY_MESSAGES, [channelKey], ), ); const servers = useStore((state) => state.servers); const mobileViewActiveColumn = useStore( (state) => state.ui.mobileViewActiveColumn, ); const channel = useMemo( () => channelId ? (servers .find((s) => s.id === serverId) ?.channels.find((c) => c.id === channelId) ?? null) : null, [servers, serverId, channelId], ); const { isScrolledUp, wasAtBottomRef, scrollToBottom } = useScrollToBottom( messagesContainerRef, messagesEndRef, { channelId: `${channelId || privateChatId}-${isMemberListVisible}` }, ); // Snapshot of the last known scroll position captured while the container was visible. // getScrollState() reads this instead of the live DOM because React commits display:none // before running cleanup effects, collapsing scrollTop/scrollHeight/clientHeight to 0. const lastScrollTopRef = useRef(initialScrollState?.scrollTop ?? 0); useEffect(() => { const container = messagesContainerRef.current; if (!container) return; const onScroll = () => { if (container.clientHeight > 0) lastScrollTopRef.current = container.scrollTop; }; container.addEventListener("scroll", onScroll, { passive: true }); return () => container.removeEventListener("scroll", onScroll); }, []); // Restore scroll position when a keep-alive channel transitions from hidden to visible. // display:none may reset scrollTop to 0; lastScrollTopRef was captured while visible. const prevActiveRef = useRef(isActive); useLayoutEffect(() => { if (isActive && !prevActiveRef.current) { const container = messagesContainerRef.current; if (container && lastScrollTopRef.current > 0) { container.scrollTop = lastScrollTopRef.current; } } prevActiveRef.current = isActive; }, [isActive]); useImperativeHandle(ref, () => ({ setAtBottom: () => { wasAtBottomRef.current = true; }, scrollToBottom, getScrollState: () => ({ scrollTop: lastScrollTopRef.current, isAtBottom: wasAtBottomRef.current, visibleCount: visibleMessageCountRef.current, }), })); const filteredMessages = useMemo(() => { if (!searchQuery.trim()) return channelMessages; const query = searchQuery.toLowerCase(); return channelMessages.filter( (msg) => msg.content.toLowerCase().includes(query) || msg.userId.toLowerCase().includes(query), ); }, [channelMessages, searchQuery]); useEffect(() => { isScrolledUpRef.current = isScrolledUp; // When the user returns to the bottom, shrink the window back to the base so // slice(-N) resumes trimming old messages from the top (memory optimization). // Only shrink if we grew above the base — preserves a sub-default saved visibleCount. if (!isScrolledUp) { setVisibleMessageCount((prev) => prev > DEFAULT_VISIBLE_MESSAGE_COUNT ? DEFAULT_VISIBLE_MESSAGE_COUNT : prev, ); } }, [isScrolledUp]); // Reset ref-tracked windowing state when switching channels. // visibleMessageCount is NOT reset here — useState(initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT) // already initializes it correctly on mount, and this effect runs once on mount for the // same channelKey (each instance is bound to exactly one channel by the parent key={}). // biome-ignore lint/correctness/useExhaustiveDependencies: intentional full reset on channel change useEffect(() => { prevFilteredLengthRef.current = 0; prevFirstMsgIdRef.current = null; prevScrollHeightRef.current = 0; pendingPrependRef.current = false; resizeObserverPrevSHRef.current = 0; }, [channelKey]); const displayedMessages = useMemo(() => { if (searchQuery.trim()) return filteredMessages; return filteredMessages.slice(-visibleMessageCount); }, [filteredMessages, visibleMessageCount, searchQuery]); const locallyHidden = filteredMessages.length > displayedMessages.length; const serverHasMore = channel?.hasMoreHistory === true; const hasMoreMessages = locallyHidden || serverHasMore; const eventGroups = useMemo( () => groupConsecutiveEvents(displayedMessages), [displayedMessages], ); const isLoadingHistory = channel?.isLoadingHistory ?? false; // Scroll to bottom on initial mount, unless a saved position was passed in. // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount only useEffect(() => { const container = messagesContainerRef.current; if (!container) return; if (initialScrollState) { container.scrollTop = initialScrollState.scrollTop; lastScrollTopRef.current = initialScrollState.scrollTop; wasAtBottomRef.current = false; } else { container.scrollTop = container.scrollHeight; lastScrollTopRef.current = container.scrollHeight; wasAtBottomRef.current = true; } }, []); // Scroll to bottom after initial join history loads; clear fetch spinner at batch end. const wasLoadingHistoryRef = useRef(false); // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is stable via useCallback; refs and setters are stable useLayoutEffect(() => { if (wasLoadingHistoryRef.current && !isLoadingHistory) { if (isFetchingMoreRef.current) { // delta correction for scroll position is handled by useLayoutEffect([displayedMessages]) isFetchingMoreRef.current = false; setIsFetchingMore(false); } else { scrollToBottom(); wasAtBottomRef.current = true; } } wasLoadingHistoryRef.current = isLoadingHistory; }, [isLoadingHistory]); // When older messages are prepended, grow the window so they enter displayedMessages. // When new messages arrive at the bottom while the user is scrolled up, also grow the // window to keep the current top messages visible — slice(-N) otherwise slides the // window forward and hides them, incrementing the "N older messages" counter on every // incoming message. Only let the slice trim from the top when the user is at the bottom // (where auto-scroll handles keeping them current). useLayoutEffect(() => { const newLength = filteredMessages.length; const newFirstId = filteredMessages[0]?.id ?? null; const delta = newLength - prevFilteredLengthRef.current; if (prevFilteredLengthRef.current > 0 && delta > 0) { if (newFirstId !== prevFirstMsgIdRef.current) { // Messages prepended (load-more): signal delta-correction to compensate scrollTop. pendingPrependRef.current = true; setVisibleMessageCount((prev) => prev + delta); } else if (isScrolledUpRef.current) { // Messages appended at bottom while user is scrolled up reading history. // Expand the window to prevent top messages from dropping out of the slice. setVisibleMessageCount((prev) => prev + delta); } } prevFilteredLengthRef.current = newLength; prevFirstMsgIdRef.current = newFirstId; }, [filteredMessages]); // Compensate scrollTop when content is prepended above the viewport. // biome-ignore lint/correctness/useExhaustiveDependencies: runs on every displayedMessages render to capture the resulting scrollHeight; refs are stable useLayoutEffect(() => { const container = messagesContainerRef.current; if (!container) return; // Skip while container is display:none — scrollHeight collapses to 0 and would // poison prevScrollHeightRef, causing a huge spurious delta on the next visible render. if (container.clientHeight === 0) return; const prevHeight = prevScrollHeightRef.current; const newHeight = container.scrollHeight; // Only correct when a true load-more prepend happened (flag set by the window-growth // layoutEffect or button handler). Bottom appends slide the slice(-N) window which also // changes displayedMessages[0] — ID-comparison can't distinguish the two cases. const wasPrepend = pendingPrependRef.current; // Only consume the flag when scrollHeight actually changed — the server-side load-more // path goes through two renders: Render A (filteredMessages grows, visibleCount unchanged, // same displayedMessages content, same scrollHeight) then Render B (visibleCount grows, // new messages enter displayedMessages, scrollHeight grows). The flag must survive Render A // so it's still set when Render B fires the actual correction. if (wasPrepend && newHeight !== prevHeight) { pendingPrependRef.current = false; } if ( isScrolledUpRef.current && prevHeight > 0 && newHeight > prevHeight && wasPrepend ) { const delta = newHeight - prevHeight; container.scrollTop += delta; resizeObserverPrevSHRef.current = newHeight; } prevScrollHeightRef.current = newHeight; }, [displayedMessages]); // Re-stick to bottom when inner message content grows (media/audio previews loading). // Uses prevScrollHeight instead of wasAtBottomRef to avoid stale-flag race where the // ref is true while the user is actively scrolling up. // When the container width changes (member list toggle, window resize), text reflows // and scrollHeight changes; preserve proportional scroll position for scrolled-up users. // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is a stable ref useEffect(() => { const container = messagesContainerRef.current; const inner = messagesInnerRef.current; if (!inner || !container) return; resizeObserverPrevSHRef.current = container.scrollHeight; let prevClientWidth = container.clientWidth; const observer = new ResizeObserver(() => { if (container.clientHeight === 0) return; // Effect may re-initialize while container is display:none (ref=0). // Re-seed with current dimensions and skip — no reliable "was at bottom" data. if (resizeObserverPrevSHRef.current === 0) { resizeObserverPrevSHRef.current = container.scrollHeight; prevClientWidth = container.clientWidth; return; } const currentClientWidth = container.clientWidth; const widthChanged = currentClientWidth !== prevClientWidth; prevClientWidth = currentClientWidth; const prevSH = resizeObserverPrevSHRef.current; const wasAtPrevBottom = container.scrollTop + container.clientHeight >= prevSH - SCROLL_TOLERANCE; resizeObserverPrevSHRef.current = container.scrollHeight; if (wasAtPrevBottom) { scrollToBottom(); } else if (widthChanged && prevSH > 0) { const ratio = container.scrollTop / prevSH; container.scrollTop = Math.round(ratio * container.scrollHeight); } }); observer.observe(inner); return () => observer.disconnect(); }, [isLoadingHistory, channelId, privateChatId]); // Auto-scroll on new messages — skip when this channel is hidden (display:none). // biome-ignore lint/correctness/useExhaustiveDependencies: only scroll when messages change, not when isActive changes useEffect(() => { if (!isActive) return; const isNarrowView = window.matchMedia("(max-width: 768px)").matches; const isChatVisible = !isNarrowView || mobileViewActiveColumn === "chatView"; if (wasAtBottomRef.current && isChatVisible) { scrollToBottom(); } }, [displayedMessages, mobileViewActiveColumn, scrollToBottom, isActive]); return ( <>
{isLoadingHistory && !isFetchingMore ? (
) : (
{hasMoreMessages && !searchQuery && (
)} {searchQuery && (
{plural(filteredMessages.length, { one: t`Found 1 message matching "${searchQuery}"`, other: t`Found ${filteredMessages.length} messages matching "${searchQuery}"`, })}
)} {eventGroups.map((group) => { if (group.type === "eventGroup") { const firstId = group.messages[0]?.id || ""; const lastId = group.messages[group.messages.length - 1]?.id || ""; const groupKey = `group-${firstId}-${lastId}`; return ( ); } const message = group.messages[0]; const originalIndex = channelMessages.findIndex( (m) => m.id === message.id, ); const previousMessage = channelMessages[originalIndex - 1]; const showHeader = !previousMessage || previousMessage.type !== "message" || previousMessage.userId !== message.userId || new Date(message.timestamp).getTime() - new Date(previousMessage.timestamp).getTime() > 5 * 60 * 1000; return ( ); })}
)}
); }, ); ChannelMessageList.displayName = "ChannelMessageList"; // Wrap with memo so hidden keep-alive channels skip re-renders when their props // haven't changed (e.g. when messageText changes in the input — the only thing // that changes on typing is local state inside ChatArea, not the props we pass here). export const MemoChannelMessageList = memo(ChannelMessageList); @@ -46,6 +56,12 @@ msgstr "{0} și {1} scriu..." msgid "{0} is typing..." msgstr "{0} scrie..." +#. placeholder {0}: networks.length +#. placeholder {1}: networks.length === 1 ? "" : "s" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "{0} network{1} — pick one to join" +msgstr "" + #. placeholder {0}: filteredMessages.length - displayedMessages.length #: src/components/layout/ChannelMessageList.tsx msgid "{0} older messages" @@ -186,6 +202,12 @@ msgstr "Adaugă mască de invitație (ex. nick!*@*, *!*@host.com)" msgid "Add IRC Server" msgstr "Adaugă server IRC" +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworksPanel.tsx +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Add Network" +msgstr "Adaugă rețea" + #: src/components/message/MessageActions.tsx #: src/components/message/MessageReactions.tsx #: src/components/message/MessageReactions.tsx @@ -205,6 +227,10 @@ msgstr "Adaugă regulă" msgid "Add Server" msgstr "Adaugă server" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Add your first network" +msgstr "Adaugă prima ta rețea" + #: src/components/message/JsonLogMessage.tsx msgid "Additional Details" msgstr "Detalii suplimentare" @@ -358,6 +384,10 @@ msgstr "Înapoi" msgid "Back to image" msgstr "Înapoi la imagine" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Back to network list" +msgstr "" + #: src/components/ui/ModerationModal.tsx msgid "Ban {username} by hostmask (prevents them from rejoining from the same IP/host)" msgstr "Banează {username} după hostmask (împiedică reconectarea de pe același IP/host)" @@ -405,6 +435,8 @@ msgstr "Răsfoiește toate canalele de pe server" #: src/components/ui/AddPrivateChatModal.tsx #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/ChannelSettingsModal.tsx #: src/components/ui/ExternalLinkWarningModal.tsx #: src/components/ui/FloodSettingsModal.tsx @@ -640,6 +672,7 @@ msgid "Configure notification sounds and highlights" msgstr "Configurează sunetele de notificare și evidențierile" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworksPanel.tsx msgid "Connect" msgstr "Conectare" @@ -759,6 +792,10 @@ msgstr "Șterge canal" msgid "Delete message" msgstr "Șterge mesaj" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Delete network" +msgstr "Șterge rețeaua" + #: src/components/layout/ChannelList.tsx msgid "Delete Private Chat" msgstr "Șterge conversația privată" @@ -767,6 +804,10 @@ msgstr "Șterge conversația privată" msgid "Delete this message? This cannot be undone." msgstr "Ștergeți acest mesaj? Această acțiune nu poate fi anulată." +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Delete this network?" +msgstr "Ștergeți această rețea?" + #: src/components/layout/ServerList.tsx #: src/components/mobile/ServerBottomSheet.tsx msgid "Disconnect" @@ -830,10 +871,16 @@ msgstr "Descarcă" msgid "e.g., 100:1440" msgstr "ex., 100:1440" +#: src/components/ui/BouncerNetworksPanel.tsx #: src/components/ui/ChannelSettingsModal.tsx msgid "Edit" msgstr "Editează" +#. placeholder {0}: editingNetwork?.attributes.name || editingNetwork?.netid +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Edit {0}" +msgstr "Editează {0}" + #: src/components/ui/UserProfileModal.tsx msgid "Edit Profile" msgstr "Editați profilul" @@ -1057,6 +1104,7 @@ msgstr "ACASĂ" msgid "Homepage" msgstr "Pagină principală" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserProfileModal.tsx msgid "Host" msgstr "Gazdă" @@ -1271,6 +1319,10 @@ msgstr "A părăsit canalul" msgid "Let others know when you are typing" msgstr "Anunțați ceilalți când scrieți" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Libera Chat" +msgstr "Libera Chat" + #: src/components/message/LinkPreview.tsx msgid "Link preview" msgstr "Previzualizare link" @@ -1299,6 +1351,10 @@ msgstr "Se încarcă GIF-urile..." msgid "Loading more channels..." msgstr "Se încarcă mai multe canale..." +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Loading networks from your bouncer…" +msgstr "Se încarcă rețelele de pe bouncer-ul tău…" + #: src/components/ui/UserProfileModal.tsx msgid "Loading WHOIS data..." msgstr "Se încarcă datele WHOIS..." @@ -1486,9 +1542,15 @@ msgid "Name:" msgstr "Nume:" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx msgid "Network Name" msgstr "Nume rețea" +#. placeholder {0}: server?.name ?? bouncerServerId +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Networks on {0}" +msgstr "Rețele pe {0}" + #: src/components/ui/QuickActions.tsx msgid "New DM" msgstr "Mesaj direct nou" @@ -1511,6 +1573,7 @@ msgid "nick!user@host (e.g., spam*!*@*, *!*@badhost.com)" msgstr "nick!user@host (ex., spam*!*@*, *!*@badhost.com)" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserSettings.tsx #: src/lib/settings/definitions/allSettings.ts msgid "Nickname" @@ -1570,6 +1633,10 @@ msgstr "Niciun fișier ales" msgid "No flood profile" msgstr "Niciun profil anti-flood" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "no host set" +msgstr "nicio gazdă setată" + #: src/components/ui/ChannelSettingsModal.tsx msgid "No invitations found" msgstr "Nu s-au găsit invitații" @@ -1610,6 +1677,10 @@ msgstr "Niciun subiect setat" msgid "No unread mentions or messages" msgstr "Nicio mențiune sau mesaj necitit" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "No upstream networks yet." +msgstr "Încă nu există rețele upstream." + #: src/components/ui/AddPrivateChatModal.tsx msgid "No users available" msgstr "Niciun utilizator disponibil" @@ -1696,6 +1767,10 @@ msgstr "Oops! Rețeaua s-a împărțit! ⚠️" msgid "Op" msgstr "Op" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Open" +msgstr "" + #: src/components/ui/QuickActions/uiActionConfig.tsx msgid "Open channel configuration settings" msgstr "Deschide setările de configurare ale canalului" @@ -1799,6 +1874,10 @@ msgstr "Fixează conversația privată" msgid "Pin this private message conversation" msgstr "Fixează această conversație privată" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Plaintext" +msgstr "Text simplu" + #: src/components/message/MediaPreview.tsx #: src/components/message/MediaPreview.tsx #: src/components/message/MediaPreview.tsx @@ -1827,6 +1906,7 @@ msgid "PM User" msgstr "Mesaj privat" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx msgid "Port" msgstr "Port" @@ -1918,6 +1998,7 @@ msgstr "a reacționat la acest mesaj" msgid "Read more" msgstr "Citește mai mult" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserProfileModal.tsx #: src/components/ui/UserSettings.tsx #: src/lib/settings/definitions/allSettings.ts @@ -2002,6 +2083,7 @@ msgstr "Reguli" msgid "Safe" msgstr "Sigur" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/ChannelSettingsModal.tsx #: src/components/ui/TopicModal.tsx #: src/components/ui/UserSettings.tsx @@ -2183,6 +2265,10 @@ msgstr "Operatorii de server din rețea pot citi mesajele tale" msgid "Server Password" msgstr "Parolă server" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Server Password (PASS)" +msgstr "Parolă server (PASS)" + #: src/components/ui/LinkSecurityWarningModal.tsx msgid "Server-to-server communication may use unencrypted connections" msgstr "Comunicarea server-la-server poate folosi conexiuni necriptate" @@ -2378,6 +2464,10 @@ msgstr "Timp (min)" msgid "Time Window (seconds)" msgstr "Fereastră de timp (secunde)" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "TLS" +msgstr "TLS" + #: src/components/message/WhisperMessage.tsx #: src/components/message/WhisperMessage.tsx msgid "to" @@ -2426,6 +2516,10 @@ msgstr "Subiect:" msgid "Total: {0}" msgstr "Total: {0}" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Transport" +msgstr "Transport" + #: src/components/ui/UserSettings.tsx msgid "Trusted Sources" msgstr "Surse de încredere" @@ -2536,6 +2630,7 @@ msgstr "Profil utilizator" msgid "User Settings" msgstr "Setări utilizator" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/InviteUserModal.tsx #: src/components/ui/ModerationModal.tsx msgid "Username" @@ -2683,6 +2778,10 @@ msgstr "Larg – Domeniu de protecție mai amplu" msgid "Will default to 'no reason' if left empty" msgstr "Va fi implicit „niciun motiv\" dacă este lăsat gol" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Yes, delete" +msgstr "Da, șterge" + #: src/components/message/CollapsedEventMessage.tsx #: src/components/message/EventMessage.tsx msgid "You" @@ -2713,6 +2812,10 @@ msgstr "Parola contului dvs. pentru autentificare" msgid "Your account username for authentication" msgstr "Numele de utilizator al contului dvs. pentru autentificare" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Your bouncer doesn't have any networks yet. Add one to get started." +msgstr "Bouncer-ul tău nu are încă nicio rețea. Adaugă una pentru a începe." + #: src/lib/settings/definitions/allSettings.ts msgid "Your default nickname for all servers" msgstr "Pseudonimul dvs. implicit pentru toate serverele" diff --git a/src/locales/ru/messages.mjs b/src/locales/ru/messages.mjs index 4aaff28b..36e5b19b 100644 --- a/src/locales/ru/messages.mjs +++ b/src/locales/ru/messages.mjs @@ -1 +1 @@ -/*eslint-disable*/export const messages=JSON.parse("{\"+5VMrz\":[\"Неверный формат шаблона. Используйте формат nick!user@host (допускаются маски *)\"],\"+6NQQA\":[\"Канал общей поддержки\"],\"+6NyRG\":[\"Клиент\"],\"+K0AvT\":[\"Отключиться\"],\"+cyFdH\":[\"Сообщение по умолчанию при переходе в режим отсутствия\"],\"+mVPqU\":[\"Отображать форматирование Markdown в сообщениях\"],\"+vqCJH\":[\"Имя пользователя вашего аккаунта для аутентификации\"],\"+yPBXI\":[\"Выбрать файл\"],\"+zy2Nq\":[\"Тип\"],\"/09cao\":[\"Низкий уровень безопасности соединения (уровень \",[\"securityLevel\"],\")\"],\"/3BQ4J\":[\"Пользователи вне канала не могут отправлять в него сообщения\"],\"/6BzZF\":[\"Показать/скрыть список участников\"],\"/TNOPk\":[\"Пользователь отсутствует\"],\"/XQgft\":[\"Обзор\"],\"/cF7Rs\":[\"Громкость\"],\"/dqduX\":[\"Следующая страница\"],\"/fc3q4\":[\"Весь контент\"],\"/kISDh\":[\"Включить звуки уведомлений\"],\"/n04sB\":[\"Kill\"],\"/rTz0M\":[\"Аудио\"],\"/rfkZe\":[\"Воспроизводить звуки для упоминаний и сообщений\"],\"0/0ZGA\":[\"Маска имени канала\"],\"0D6j7U\":[\"Подробнее о пользовательских правилах →\"],\"0XsHcR\":[\"Исключить пользователя\"],\"0ZpE//\":[\"Сортировать по пользователям\"],\"0bEPwz\":[\"Отметиться как отсутствующий\"],\"0dGkPt\":[\"Развернуть список каналов\"],\"0gS7M5\":[\"Отображаемое имя\"],\"0kS+M8\":[\"ПримерНЕТ\"],\"0rgoY7\":[\"Подключайтесь только к выбранным вами серверам\"],\"0wdd7X\":[\"Войти\"],\"0wkVYx\":[\"Личные сообщения\"],\"111uHX\":[\"Предпросмотр ссылки\"],\"196EG4\":[\"Удалить личную переписку\"],\"1DSr1i\":[\"Зарегистрировать аккаунт\"],\"1O/24y\":[\"Показать/скрыть список каналов\"],\"1VPJJ2\":[\"Предупреждение о внешней ссылке\"],\"1ZC/dv\":[\"Нет непрочитанных упоминаний или сообщений\"],\"1pO1zi\":[\"Необходимо указать имя сервера\"],\"1uwfzQ\":[\"Просмотреть тему канала\"],\"268g7c\":[\"Введите отображаемое имя\"],\"2FOFq1\":[\"Операторы серверов сети потенциально могут читать ваши сообщения\"],\"2FYpfJ\":[\"Ещё\"],\"2HF1Y2\":[[\"inviter\"],\" пригласил \",[\"target\"],\" присоединиться к \",[\"channel\"]],\"2I70QL\":[\"Просмотреть информацию профиля пользователя\"],\"2QYdmE\":[\"Пользователи:\"],\"2QpEjG\":[\"вышел\"],\"2YE223\":[\"Сообщение #\",[\"0\"],\" (Enter — новая строка, Shift+Enter — отправить)\"],\"2bimFY\":[\"Использовать пароль сервера\"],\"2iTmdZ\":[\"Локальное хранилище:\"],\"2odkwe\":[\"Строгий — более агрессивная защита\"],\"2uDhbA\":[\"Введите имя пользователя для приглашения\"],\"2ygf/L\":[\"← Назад\"],\"2zEgxj\":[\"Поиск GIF...\"],\"3RdPhl\":[\"Переименовать канал\"],\"3THokf\":[\"Пользователь с голосом\"],\"3TSz9S\":[\"Свернуть\"],\"3jBDvM\":[\"Отображаемое имя канала\"],\"3ryuFU\":[\"Необязательные отчёты о сбоях для улучшения приложения\"],\"3uBF/8\":[\"Закрыть просмотрщик\"],\"3uwW8F\":[\"https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"3xf8Kz\":[\"Введите имя аккаунта...\"],\"4/Rr0R\":[\"Пригласить пользователя в текущий канал\"],\"4EZrJN\":[\"Правила\"],\"4JJtW9\":[\"#overflow\"],\"4NqeT4\":[\"Профиль флуда (+F)\"],\"4RZQRK\":[\"Чем ты занимаешься?\"],\"4hfTrB\":[\"Никнейм\"],\"4n99LO\":[\"Уже в \",[\"0\"]],\"4t6vMV\":[\"Автоматически переключаться на однострочный режим для коротких сообщений\"],\"4vsHmf\":[\"Время (мин)\"],\"5+INAX\":[\"Выделять сообщения, в которых упоминается ваш никнейм\"],\"5R5Pv/\":[\"Имя оператора\"],\"678PKt\":[\"Название сети\"],\"6Aih4U\":[\"Не в сети\"],\"6CO3WE\":[\"Пароль для входа в канал. Оставьте пустым, чтобы удалить ключ.\"],\"6HhMs3\":[\"Сообщение при выходе\"],\"6V3Ea3\":[\"Скопировано\"],\"6lGV3K\":[\"Свернуть\"],\"6yFOEi\":[\"Введите пароль опера...\"],\"7+IHTZ\":[\"Файл не выбран\"],\"73hrRi\":[\"nick!user@host (например: spam*!*@*, *!*@badhost.com)\"],\"7QkKyN\":[\"Отправить личное сообщение\"],\"7U1W7c\":[\"Очень мягкий\"],\"7Y1YQj\":[\"Имя:\"],\"7YHArF\":[\"— открыть в просмотрщике\"],\"7fjnVl\":[\"Поиск пользователей...\"],\"7jL88x\":[\"Удалить это сообщение? Это действие нельзя отменить.\"],\"7nGhhM\":[\"О чём вы думаете?\"],\"7sEpu1\":[\"Участники — \",[\"0\"]],\"7sNhEz\":[\"Имя пользователя\"],\"8H0Q+x\":[\"Подробнее о профилях →\"],\"8Phu0A\":[\"Показывать, когда пользователи меняют никнейм\"],\"8XTG9e\":[\"Введите пароль оператора\"],\"8XsV2J\":[\"Повторить отправку\"],\"8ZsakT\":[\"Пароль\"],\"8kR84m\":[\"Вы собираетесь открыть внешнюю ссылку:\"],\"8lCgih\":[\"Удалить правило\"],\"8p/xVT\":[[\"0\",\"plural\",{\"one\":[[\"1\"]],\"other\":[[\"2\"]]}]],\"8wRzac\":[[\"joinCount\",\"plural\",{\"one\":[\"присоединился\"],\"few\":[\"присоединился \",[\"joinCount\"],\" раза\"],\"many\":[\"присоединился \",[\"joinCount\"],\" раз\"],\"other\":[\"присоединился \",[\"joinCount\"],\" раза\"]}]],\"9BMLnJ\":[\"Переподключиться к серверу\"],\"9OEgyT\":[\"Добавить реакцию\"],\"9PQ8m2\":[\"G-Line (глобальный бан)\"],\"9Qs99X\":[\"Email:\"],\"9QupBP\":[\"Удалить шаблон\"],\"9bG48P\":[\"Отправка\"],\"9f5f0u\":[\"Вопросы о конфиденциальности? Свяжитесь с нами:\"],\"9unqs3\":[\"Отсутствие:\"],\"9v3hwv\":[\"Серверы не найдены.\"],\"9zb2WA\":[\"Подключение\"],\"A1taO8\":[\"Поиск\"],\"A2adVi\":[\"Отправлять уведомления о наборе текста\"],\"A9Rhec\":[\"Имя канала\"],\"AWOSPo\":[\"Увеличить\"],\"AXSpEQ\":[\"Войти как оператор при подключении\"],\"AeXO77\":[\"Аккаунт\"],\"AhNP40\":[\"Перемотка\"],\"Ai2U7L\":[\"Хост\"],\"AjBQnf\":[\"Изменил никнейм\"],\"AmXVh6\":[\"https://example.com/avatar.png\"],\"AnRu/j\":[\"Отменить ответ\"],\"ApSx0O\":[\"Найдено \",[\"0\"],\" сообщений, соответствующих \\\"\",[\"searchQuery\"],\"\\\"\"],\"AxPAXW\":[\"Результаты не найдены\"],\"AyNqAB\":[\"Отображать все события сервера в чате\"],\"B/QqGw\":[\"Отошёл от клавиатуры\"],\"B8AaMI\":[\"Это поле обязательно для заполнения\"],\"BA2c49\":[\"Сервер не поддерживает расширенную фильтрацию LIST\"],\"BDKt3I\":[[\"0\"],\", \",[\"1\"],\", \",[\"2\"],\" и ещё \",[\"3\"],\" печатают...\"],\"BGul2A\":[\"У вас есть несохранённые изменения. Вы уверены, что хотите закрыть без сохранения?\"],\"BIf9fi\":[\"Ваше статусное сообщение\"],\"BZz3md\":[\"Ваш личный сайт\"],\"Bgm/H7\":[\"Разрешить ввод нескольких строк текста\"],\"BiQIl1\":[\"Закрепить эту личную переписку\"],\"BlNZZ2\":[\"Нажмите, чтобы перейти к сообщению\"],\"Bowq3c\":[\"Только операторы могут изменять тему канала\"],\"Btozzp\":[\"Срок действия этого изображения истёк\"],\"Bycfjm\":[\"Всего: \",[\"0\"]],\"C6IBQc\":[\"Копировать весь JSON\"],\"C9L9wL\":[\"Сбор данных\"],\"CDq4wC\":[\"Модерировать пользователя\"],\"CHVRxG\":[\"Сообщение @\",[\"0\"],\" (Shift+Enter — новая строка)\"],\"CN9zdR\":[\"Необходимо указать имя и пароль оператора\"],\"CW3sYa\":[\"Добавить реакцию \",[\"emoji\"]],\"CaAkqd\":[\"Показывать выходы\"],\"CbvaYj\":[\"Забанить по никнейму\"],\"CcK+Ft\":[\"PDF\"],\"Ce8q3L\":[\"Выберите канал\"],\"CsekCi\":[\"Обычный\"],\"D+NlUC\":[\"Система\"],\"D28t6+\":[\"подключился и отключился\"],\"DB8zMK\":[\"Применить\"],\"DBcWHr\":[\"Пользовательский файл звука уведомления\"],\"DTy9Xw\":[\"Предпросмотр медиа\"],\"Dj4pSr\":[\"Выберите надёжный пароль\"],\"Du+zn+\":[\"Поиск...\"],\"Du2T2f\":[\"Настройка не найдена\"],\"DwsSVQ\":[\"Применить фильтры и обновить\"],\"E3W/zd\":[\"Никнейм по умолчанию\"],\"E6nRW7\":[\"Копировать URL\"],\"E703RG\":[\"Режимы:\"],\"EAeu1Z\":[\"Отправить приглашение\"],\"EFKJQT\":[\"Настройка\"],\"EGPQBv\":[\"Пользовательские правила флуда (+f)\"],\"ELik0r\":[\"Просмотреть полную политику конфиденциальности\"],\"EPbeC2\":[\"Просмотреть или изменить тему канала\"],\"EQCDNT\":[\"Введите имя пользователя опера...\"],\"EUvulZ\":[\"Найдено 1 сообщение, соответствующее \\\"\",[\"searchQuery\"],\"\\\"\"],\"EatZYJ\":[\"Следующее изображение\"],\"EdQY6l\":[\"Нет\"],\"EnqLYU\":[\"Поиск серверов...\"],\"F0OKMc\":[\"Редактировать сервер\"],\"F6Int2\":[\"Включить выделения\"],\"FDoLyE\":[\"Макс. пользователей\"],\"FUU/hZ\":[\"Управляет количеством внешних медиафайлов, загружаемых в чат.\"],\"Fdp03t\":[\"вкл\"],\"FfPWR0\":[\"Диалог\"],\"FjkaiT\":[\"Уменьшить\"],\"FlqOE9\":[\"Что это означает:\"],\"FolHNl\":[\"Управление аккаунтом и аутентификацией\"],\"Fp2Dif\":[\"Покинул сервер\"],\"G5KmCc\":[\"GZ-Line (глобальная Z-Line)\"],\"GDs0lz\":[\"<0>Риск: Конфиденциальная информация (сообщения, личные переписки, данные аутентификации) может быть раскрыта сетевым администраторам или злоумышленникам, находящимся между IRC-серверами.\"],\"GR+2I3\":[\"Добавить маску приглашения (например: nick!*@*, *!*@host.com)\"],\"GRLyMU\":[\"Закрыть всплывающие уведомления сервера\"],\"GlHnXw\":[\"Смена ника не удалась: \",[\"error\"],\" \",[\"0\"]],\"GswZF3\":[\"Предпросмотр:\"],\"GtmO8/\":[\"от\"],\"GtuHUQ\":[\"Переименовать этот канал на сервере. Все пользователи увидят новое имя.\"],\"GuGfFX\":[\"Включить/выключить поиск\"],\"GxkJXS\":[\"Загрузка...\"],\"GzbwnK\":[\"Присоединился к каналу\"],\"GzsUDB\":[\"Расширенный профиль\"],\"H/PnT8\":[\"Вставить эмодзи\"],\"H6Izzl\":[\"Ваш предпочтительный цветовой код\"],\"H9jIv+\":[\"Показывать входы/выходы\"],\"HAKBY9\":[\"Загрузить файлы\"],\"HdE1If\":[\"Канал\"],\"Hk4AW9\":[\"Ваше предпочтительное отображаемое имя\"],\"HmHDk7\":[\"Выбрать участника\"],\"HrQzPU\":[\"Каналы на \",[\"networkName\"]],\"I2tXQ5\":[\"Сообщение @\",[\"0\"],\" (Enter — новая строка, Shift+Enter — отправить)\"],\"I6bw/h\":[\"Забанить пользователя\"],\"I92Z+b\":[\"Включить уведомления\"],\"I9D72S\":[\"Вы уверены, что хотите удалить это сообщение? Это действие нельзя отменить.\"],\"IA+1wo\":[\"Показывать, когда пользователей исключают из каналов\"],\"IDwkJx\":[\"IRC Operator\"],\"ILlU+s\":[\"Инфо:\"],\"IUwGEM\":[\"Сохранить изменения\"],\"IVeGK6\":[[\"0\"],\", \",[\"1\"],\" и \",[\"2\"],\" печатают...\"],\"IgrLD/\":[\"Пауза\"],\"Im6JED\":[\"WHISPER\"],\"ImOQa9\":[\"Ответить\"],\"IoHMnl\":[\"Максимальное значение: \",[\"0\"]],\"IvMj+0\":[\"Op\"],\"J28zul\":[\"Подключение...\"],\"J5T9NW\":[\"Информация о пользователе\"],\"J8Y5+z\":[\"Упс! Разрыв сети! ⚠️\"],\"JBHkBA\":[\"Покинул канал\"],\"JCwL0Q\":[\"Укажите причину (необязательно)\"],\"JFciKP\":[\"Переключить\"],\"JXGkhG\":[\"Изменить имя канала (только для операторов)\"],\"JcD7qf\":[\"Другие действия\"],\"JdkA+c\":[\"Секретный (+s)\"],\"Jmu12l\":[\"Каналы сервера\"],\"JvQ++s\":[\"Включить Markdown\"],\"K2jwh/\":[\"Данные WHOIS недоступны\"],\"KAXSwC\":[\"Voice\"],\"KDfTdX\":[\"Удалить сообщение\"],\"KKBlUU\":[\"Встроить\"],\"KM0pLb\":[\"Добро пожаловать в канал!\"],\"KR6W2h\":[\"Перестать игнорировать пользователя\"],\"KV+Bi1\":[\"Только по приглашению (+i)\"],\"KdCtwE\":[\"Сколько секунд отслеживать флуд-активность до сброса счётчиков\"],\"Kkezga\":[\"Пароль сервера\"],\"KsiQ/8\":[\"Для входа в канал необходимо приглашение\"],\"L+gB/D\":[\"Информация о канале\"],\"LC1a7n\":[\"IRC-сервер сообщил о низком уровне безопасности межсерверных соединений. Это означает, что при передаче ваших сообщений между IRC-серверами сети они могут быть недостаточно зашифрованы или SSL/TLS-сертификаты могут не проверяться должным образом.\"],\"LNfLR5\":[\"Показывать исключения\"],\"LQb0W/\":[\"Показывать все события\"],\"LU7/yA\":[\"Альтернативное имя для отображения в интерфейсе. Может содержать пробелы, эмодзи и специальные символы. Настоящее имя канала (\",[\"channelName\"],\") по-прежнему будет использоваться для IRC-команд.\"],\"LUb9O7\":[\"Необходимо указать корректный порт сервера\"],\"Lb+BUl\":[\"https://example.com/avatar.jpg\"],\"LcET2C\":[\"Политика конфиденциальности\"],\"LcuSDR\":[\"Управление информацией профиля и метаданными\"],\"LqLS9B\":[\"Показывать смену никнейма\"],\"LsDQt2\":[\"Настройки канала\"],\"LtI9AS\":[\"Владелец\"],\"LuNhhL\":[\"отреагировал на это сообщение\"],\"M/AZNG\":[\"URL вашего аватара\"],\"M/WIer\":[\"Отправить сообщение\"],\"M8er/5\":[\"Имя:\"],\"MHk+7g\":[\"Предыдущее изображение\"],\"MRorGe\":[\"Написать в личку\"],\"MVbSGP\":[\"Временное окно (секунды)\"],\"MkpcsT\":[\"Ваши сообщения и настройки хранятся локально на вашем устройстве\"],\"N/hDSy\":[\"Пометить как бота — обычно «on» или пусто\"],\"N7TQbE\":[\"Пригласить пользователя в \",[\"channelName\"]],\"NCca/o\":[\"Введите ник по умолчанию...\"],\"Nqs6B9\":[\"Показывает весь внешний медиаконтент. Любой URL может вызвать запрос к неизвестному серверу.\"],\"Nt+9O7\":[\"Использовать WebSocket вместо обычного TCP\"],\"NxIHzc\":[\"Отключить пользователя\"],\"O+v/cL\":[\"Просмотреть все каналы на сервере\"],\"ODwSCk\":[\"Отправить GIF\"],\"OGQ5kK\":[\"Настройка звуков уведомлений и выделений\"],\"OIPt1Z\":[\"Показать или скрыть боковую панель списка участников\"],\"OKSNq/\":[\"Очень строгий\"],\"ONWvwQ\":[\"Загрузить\"],\"OVKoQO\":[\"Пароль вашего аккаунта для аутентификации\"],\"ObsidianIRC - Bringing IRC to the future\":[\"ObsidianIRC - Перенося IRC в будущее\"],\"OhCpra\":[\"Задать тему…\"],\"OkltoQ\":[\"Забанить \",[\"username\"],\" по никнейму (запрещает переподключение с тем же ником)\"],\"P+t/Te\":[\"Нет дополнительных данных\"],\"P42Wcc\":[\"Безопасно\"],\"PD38l0\":[\"Предпросмотр аватара канала\"],\"PD9mEt\":[\"Введите сообщение...\"],\"PPqfdA\":[\"Открыть настройки конфигурации канала\"],\"PSCjfZ\":[\"Тема, которая будет отображаться для этого канала. Тему видят все пользователи.\"],\"PZCecv\":[\"Предпросмотр PDF\"],\"PeLgsC\":[[\"c\",\"plural\",{\"one\":[\"1 раз\"],\"few\":[[\"c\"],\" раза\"],\"many\":[[\"c\"],\" раз\"],\"other\":[[\"c\"],\" раза\"]}]],\"PguS2C\":[\"Добавить маску исключения (например: nick!*@*, *!*@host.com)\"],\"Pil5Ty\":[\"Показано \",[\"displayedChannelsCount\"],\" из \",[\"0\"],\" каналов\"],\"PqhVlJ\":[\"Забанить пользователя (по hostmask)\"],\"Q+chwU\":[\"Имя пользователя:\"],\"Q6hhn8\":[\"Настройки\"],\"QF4a34\":[\"Введите имя пользователя\"],\"QGqSZ2\":[\"Цвет и форматирование\"],\"QJQd1J\":[\"Редактировать профиль\"],\"QSzGDE\":[\"Не активен\"],\"QUlny5\":[\"Добро пожаловать на \",[\"0\"],\"!\"],\"Qoq+GP\":[\"Читать далее\"],\"QuSkCF\":[\"Фильтр каналов...\"],\"QwUrDZ\":[\"изменил тему на: \",[\"topic\"]],\"R0UH07\":[\"Изображение \",[\"0\"],\" из \",[\"1\"]],\"R7SsBE\":[\"Выкл. звук\"],\"R8rf1X\":[\"Нажмите, чтобы задать тему\"],\"RArB3D\":[\"был кикнут из \",[\"channelName\"],\" пользователем \",[\"username\"]],\"RI3cWd\":[\"Откройте мир IRC вместе с ObsidianIRC\"],\"RMMaN5\":[\"Модерируемый (+m)\"],\"RWw9Lg\":[\"Закрыть диалог\"],\"RZ2BuZ\":[\"Регистрация аккаунта \",[\"account\"],\" требует подтверждения: \",[\"message\"]],\"RySp6q\":[\"Скрыть комментарии\"],\"SPKQTd\":[\"Необходимо указать никнейм\"],\"SPVjfj\":[\"Если оставить пустым, будет использоваться «без причины»\"],\"SQKPvQ\":[\"Пригласить пользователя\"],\"SkZcl+\":[\"Выберите заранее заданный профиль защиты от флуда. Эти профили предоставляют сбалансированные настройки защиты для различных сценариев использования.\"],\"Slr+3C\":[\"Мин. пользователей\"],\"Spnlre\":[\"Вы пригласили \",[\"target\"],\" присоединиться к \",[\"channel\"]],\"T/ckN5\":[\"Открыть в просмотрщике\"],\"T91vKp\":[\"Воспроизвести\"],\"TV2Wdu\":[\"Узнайте, как мы обрабатываем ваши данные и защищаем вашу конфиденциальность.\"],\"TgFpwD\":[\"Применяется...\"],\"TkzSFB\":[\"Нет изменений\"],\"TtserG\":[\"Введите настоящее имя\"],\"Ttz9J1\":[\"Введите пароль...\"],\"Tz0i8g\":[\"Настройки\"],\"U3pytU\":[\"Администратор\"],\"UDb2YD\":[\"Реакция\"],\"UE4KO5\":[\"*channel*\"],\"UGT5vp\":[\"Сохранить настройки\"],\"UV5hLB\":[\"Баны не найдены\"],\"Uaj3Nd\":[\"Статусные сообщения\"],\"Ue3uny\":[\"По умолчанию (без профиля)\"],\"UkARhe\":[\"Обычный — стандартная защита\"],\"Umn7Cj\":[\"Комментариев пока нет. Будьте первым!\"],\"UtUIRh\":[[\"0\"],\" старых сообщений\"],\"UwzP+U\":[\"Защищённое соединение\"],\"V0/A4O\":[\"Владелец канала\"],\"V4qgxE\":[\"Создан до (мин назад)\"],\"V8yTm6\":[\"Очистить поиск\"],\"VJMMyz\":[\"ObsidianIRC — IRC будущего\"],\"VJScHU\":[\"Причина\"],\"VLsmVV\":[\"Отключить уведомления\"],\"VbyRUy\":[\"Комментарии\"],\"Vmx0mQ\":[\"Установлено:\"],\"VqnIZz\":[\"Ознакомьтесь с нашей политикой конфиденциальности и практикой обработки данных\"],\"VrMygG\":[\"Минимальная длина: \",[\"0\"]],\"VrnTui\":[\"Ваши местоимения, отображаемые в профиле\"],\"W8E3qn\":[\"Аутентифицированный аккаунт\"],\"WAakm9\":[\"Удалить канал\"],\"WFxTHC\":[\"Добавить маску бана (например: nick!*@*, *!*@host.com)\"],\"WN1g9F\":[\"Необходимо указать хост сервера\"],\"WRYdXW\":[\"Позиция в аудио\"],\"WUOH5B\":[\"Игнорировать пользователя\"],\"WWEXnZ\":[[\"0\",\"plural\",{\"one\":[\"Показать ещё 1 элемент\"],\"few\":[\"Показать ещё \",[\"1\"],\" элемента\"],\"many\":[\"Показать ещё \",[\"1\"],\" элементов\"],\"other\":[\"Показать ещё \",[\"1\"],\" элемента\"]}]],\"Weq9zb\":[\"Основное\"],\"Wfj7Sk\":[\"Включить или отключить звуки уведомлений\"],\"Wm7gbG\":[\"GitHub:\"],\"WyeHWY\":[\"*spam*\"],\"WzMCru\":[\"Профиль пользователя\"],\"X6S3lt\":[\"Поиск настроек, каналов, серверов...\"],\"XEHan5\":[\"Всё равно продолжить\"],\"XI1+wb\":[\"Неверный формат\"],\"XIXeuC\":[\"Сообщение @\",[\"0\"]],\"XMS+k4\":[\"Начать личный чат\"],\"XWgxXq\":[\"Альбом\"],\"Xd7+IT\":[\"Открепить личный чат\"],\"Xm/s+u\":[\"Отображение\"],\"Xp2n93\":[\"Показывает медиафайлы с доверенного файлового хоста вашего сервера. Запросы к внешним сервисам не выполняются.\"],\"XvjC4F\":[\"Сохранение...\"],\"Y/qryO\":[\"Пользователи по вашему запросу не найдены\"],\"YAqRpI\":[\"Регистрация аккаунта \",[\"account\"],\" успешна: \",[\"message\"]],\"YEfzvP\":[\"Защищённая тема (+t)\"],\"YQOn6a\":[\"Свернуть список участников\"],\"YRCoE9\":[\"Оператор канала\"],\"YURQaF\":[\"Просмотреть профиль\"],\"YdBSvr\":[\"Управление отображением медиа и внешнего контента\"],\"Yj6U3V\":[\"Нет центрального сервера:\"],\"YjvpGx\":[\"Местоимения\"],\"YqH4l4\":[\"Без ключа\"],\"YyUPpV\":[\"Аккаунт:\"],\"ZJSWfw\":[\"Сообщение, отображаемое при отключении от сервера\"],\"ZR1dJ4\":[\"Приглашения\"],\"ZdWg0V\":[\"Открыть в браузере\"],\"ZhRBbl\":[\"Поиск сообщений…\"],\"Zmcu3y\":[\"Расширенные фильтры\"],\"a2/8e5\":[\"Тема установлена после (мин назад)\"],\"aHKcKc\":[\"Предыдущая страница\"],\"aJTbXX\":[\"Пароль оператора\"],\"aQryQv\":[\"Такой шаблон уже существует\"],\"aW9pLN\":[\"Максимальное количество пользователей в канале. Оставьте пустым для снятия ограничения.\"],\"ah4fmZ\":[\"Также показывает превью с YouTube, Vimeo, SoundCloud и других известных сервисов.\"],\"aifXak\":[\"В этом канале нет медиафайлов\"],\"ap2zBz\":[\"Мягкий\"],\"az8lvo\":[\"Выкл.\"],\"azXSNo\":[\"Развернуть список участников\"],\"azdliB\":[\"Войти в аккаунт\"],\"b26wlF\":[\"она/её\"],\"bD/+Ei\":[\"Строгий\"],\"bQ6BJn\":[\"Настройте подробные правила защиты от флуда. Каждое правило задаёт тип активности для мониторинга и действие при превышении порога.\"],\"beV7+y\":[\"Пользователь получит приглашение вступить в \",[\"channelName\"],\".\"],\"bk84cH\":[\"Сообщение об отсутствии\"],\"bkHdLj\":[\"Добавить IRC-сервер\"],\"bmQLn5\":[\"Добавить правило\"],\"bwRvnp\":[\"Действие\"],\"c8+EVZ\":[\"Верифицированный аккаунт\"],\"cGYUlD\":[\"Предпросмотр медиа не загружается.\"],\"cLF98o\":[\"Показать комментарии (\",[\"commentCount\"],\")\"],\"cLKIDO\":[\"Нет доступных пользователей\"],\"cSgpoS\":[\"Закрепить личный чат\"],\"cde3ce\":[\"Написать <0>\",[\"0\"],\"\"],\"chQsxg\":[\"Копировать форматированный вывод\"],\"cl/A5J\":[\"Добро пожаловать на \",[\"__DEFAULT_IRC_SERVER_NAME__\"],\"!\"],\"cnGeoo\":[\"Удалить\"],\"coPLXT\":[\"Мы не храним ваши IRC-переписки на наших серверах\"],\"crYH/6\":[\"Плеер SoundCloud\"],\"d3sis4\":[\"Добавить сервер\"],\"d9aN5k\":[\"Удалить \",[\"username\"],\" из канала\"],\"dEgA5A\":[\"Отмена\"],\"dGi1We\":[\"Открепить эту личную переписку\"],\"dJVuyC\":[\"покинул \",[\"channelName\"],\" (\",[\"reason\"],\")\"],\"dMtLDE\":[\"кому\"],\"dXqxlh\":[\"<0>⚠️ Угроза безопасности! Это соединение может быть уязвимо для перехвата или атак типа «человек посередине».\"],\"da9Q/R\":[\"Изменил режимы канала\"],\"dhJN3N\":[\"Показать комментарии\"],\"dj2xTE\":[\"Закрыть уведомление\"],\"dpCzmC\":[\"Настройки защиты от флуда\"],\"e9dQpT\":[\"Открыть эту ссылку в новой вкладке?\"],\"ePK91l\":[\"Изменить\"],\"eYBDuB\":[\"Загрузите изображение или укажите URL с необязательной подстановкой \",[\"size\"],\" для динамического масштабирования\"],\"edBbee\":[\"Забанить \",[\"username\"],\" по hostmask (запрещает переподключение с того же IP/хоста)\"],\"ekfzWq\":[\"Настройки пользователя\"],\"elPDWs\":[\"Настройте IRC-клиент под себя\"],\"eu2osY\":[\"<0>💡 Рекомендация: Продолжайте только если вы доверяете этому серверу и понимаете риски. Избегайте передачи конфиденциальной информации или паролей через это соединение.\"],\"euEhbr\":[\"Нажмите, чтобы войти в \",[\"channel\"]],\"ez3vLd\":[\"Включить многострочный ввод\"],\"f0J5Ki\":[\"Межсерверное взаимодействие может использовать незашифрованные соединения\"],\"f9BHJk\":[\"Предупредить пользователя\"],\"fDOLLd\":[\"Каналы не найдены.\"],\"ffzDkB\":[\"Анонимная аналитика:\"],\"fq1GF9\":[\"Показывать, когда пользователи отключаются от сервера\"],\"gEF57C\":[\"Этот сервер поддерживает только один тип подключения\"],\"gJuLUI\":[\"Список игнорирования\"],\"gNzMrk\":[\"Текущий аватар\"],\"gjPWyO\":[\"Введите ник...\"],\"gz6UQ3\":[\"Развернуть\"],\"h6razj\":[\"Исключить маску имени канала\"],\"hG6jnw\":[\"Тема не задана\"],\"hG89Ed\":[\"Изображение\"],\"hZ6znB\":[\"Порт\"],\"ha+Bz5\":[\"например: 100:1440\"],\"hehnjM\":[\"Количество\"],\"hzdLuQ\":[\"Говорить могут только пользователи с голосом или выше\"],\"i0qMbr\":[\"Главная\"],\"iDNBZe\":[\"Уведомления\"],\"iH8pgl\":[\"Назад\"],\"iL9SZg\":[\"Забанить пользователя (по никнейму)\"],\"iNt+3c\":[\"Вернуться к изображению\"],\"iQvi+a\":[\"Не предупреждать меня о низком уровне безопасности соединений для этого сервера\"],\"iSLIjg\":[\"Подключиться\"],\"iWXkHH\":[\"Halfop\"],\"iZeTtp\":[\"Хост сервера\"],\"idD8Ev\":[\"Сохранено\"],\"iivqkW\":[\"В сети с\"],\"ij+Elv\":[\"Предпросмотр изображения\"],\"ilIWp7\":[\"Включить/выключить уведомления\"],\"iuaqvB\":[\"Используйте * в качестве маски. Примеры: baduser!*@*, *!*@spammer.com, troll*!*@*\"],\"ixkTse\":[\"Бот\"],\"j2DGR0\":[\"Забанить по hostmask\"],\"jA4uoI\":[\"Тема:\"],\"jLXxGK\":[\"https://example.com\"],\"jPSk57\":[\"Причина (необязательно)\"],\"jUV7CU\":[\"Загрузить аватар\"],\"jW5Uwh\":[\"Управление загрузкой внешних медиафайлов. Выкл / Безопасно / Доверенные источники / Весь контент.\"],\"jXzms5\":[\"Параметры вложения\"],\"jZlrte\":[\"Цвет\"],\"jfC/xh\":[\"Контакт\"],\"jywMpv\":[\"#new-channel-name\"],\"k112DD\":[\"Загрузить старые сообщения\"],\"k3ID0F\":[\"Фильтр участников…\"],\"k65gsE\":[\"Подробнее\"],\"k7Zgob\":[\"Отменить подключение\"],\"kAVx5h\":[\"Приглашения не найдены\"],\"kCLEPU\":[\"Подключён к\"],\"kF5LKb\":[\"Игнорируемые шаблоны:\"],\"kGeOx/\":[\"Присоединиться к \",[\"0\"]],\"kITKr8\":[\"Загрузка режимов канала...\"],\"kPpPsw\":[\"Вы являетесь IRC-оператором\"],\"kWJmRL\":[\"ты\"],\"kfcRb0\":[\"Аватар\"],\"kjMqSj\":[\"Копировать JSON\"],\"krViRy\":[\"Нажмите для копирования как JSON\"],\"ks71ra\":[\"Исключения\"],\"kw4lRv\":[\"Полуоператор канала\"],\"kxgIRq\":[\"Выберите или добавьте канал для начала.\"],\"ky6dWe\":[\"Предпросмотр аватара\"],\"l+GxCv\":[\"Загрузка каналов...\"],\"l+IUVW\":[\"Верификация аккаунта \",[\"account\"],\" успешна: \",[\"message\"]],\"l/siQz\":[[\"reconnectCount\",\"plural\",{\"one\":[\"переподключился\"],\"few\":[\"переподключился \",[\"reconnectCount\"],\" раза\"],\"many\":[\"переподключился \",[\"reconnectCount\"],\" раз\"],\"other\":[\"переподключился \",[\"reconnectCount\"],\" раза\"]}]],\"l5jmzx\":[[\"0\"],\" и \",[\"1\"],\" печатают...\"],\"lHy8N5\":[\"Загрузка дополнительных каналов...\"],\"lbpf14\":[\"Войти в \",[\"value\"]],\"lfFsZ4\":[\"Каналы\"],\"lkNdiH\":[\"Имя аккаунта\"],\"ln500L\":[\"ObsidianIRC\"],\"lnCMdg\":[\"Загрузить изображение\"],\"loQxaJ\":[\"Я вернулся\"],\"lvfaxv\":[\"ГЛАВНАЯ\"],\"m16xKo\":[\"Добавить\"],\"m8flAk\":[\"Предпросмотр (ещё не загружено)\"],\"mEPxTp\":[\"<0>⚠️ Будьте осторожны! Открывайте ссылки только из доверенных источников. Вредоносные ссылки могут угрожать вашей безопасности или конфиденциальности.\"],\"mHGdhG\":[\"Информация о сервере\"],\"mHS8lb\":[\"Сообщение #\",[\"0\"]],\"mMYBD9\":[\"Широкий — более широкая область защиты\"],\"mTGsPd\":[\"Тема канала\"],\"mU8j6O\":[\"Без внешних сообщений (+n)\"],\"mZp8FL\":[\"Автоматический возврат к однострочному режиму\"],\"mdQu8G\":[\"YourNickname\"],\"miSSBQ\":[\"Комментарии (\",[\"commentCount\"],\")\"],\"mvyLSy\":[\"Пользователь аутентифицирован\"],\"mwtcGl\":[\"Закрыть комментарии\"],\"mzI/c+\":[\"Скачать\"],\"n3fGRk\":[\"установил \",[\"0\"]],\"nE9jsU\":[\"Мягкий — менее строгая защита\"],\"nNflMD\":[\"Покинуть канал\"],\"nPXkBi\":[\"Загрузка данных WHOIS...\"],\"nQnxxF\":[\"Сообщение #\",[\"0\"],\" (Shift+Enter — новая строка)\"],\"nWMRxa\":[\"Открепить\"],\"nkC032\":[\"Без профиля флуда\"],\"o69z4d\":[\"Отправить предупреждение пользователю \",[\"username\"]],\"o9ylQi\":[\"Найдите GIF для начала\"],\"oFGkER\":[\"Уведомления сервера\"],\"oOi11l\":[\"Прокрутить вниз\"],\"oQEzQR\":[\"Новое DM\"],\"oXOSPE\":[\"В сети\"],\"oal760\":[\"Возможны атаки типа «человек посередине» на межсерверные соединения\"],\"oeqmmJ\":[\"Доверенные источники\"],\"ovBPCi\":[\"По умолчанию\"],\"p0Z69r\":[\"Шаблон не может быть пустым\"],\"p1KgtK\":[\"Не удалось загрузить аудио\"],\"p59pEv\":[\"Подробности\"],\"p7sRI6\":[\"Сообщать другим, когда вы печатаете\"],\"pBm1od\":[\"Секретный канал\"],\"pNmiXx\":[\"Ваш никнейм по умолчанию для всех серверов\"],\"pUUo9G\":[\"Хост:\"],\"pVGPmz\":[\"Пароль аккаунта\"],\"peNE68\":[\"Навсегда\"],\"plhHQt\":[\"Нет данных\"],\"pm6+q5\":[\"Предупреждение безопасности\"],\"pn5qSs\":[\"Дополнительная информация\"],\"q0cR4S\":[\"теперь известен как **\",[\"newNick\"],\"**\"],\"qFcunY\":[\"Канал не будет отображаться в командах LIST и NAMES\"],\"qLpTm/\":[\"Убрать реакцию \",[\"emoji\"]],\"qVkGWK\":[\"Закрепить\"],\"qY8wNa\":[\"Сайт\"],\"qb0xJ7\":[\"Используйте маски: * соответствует любой последовательности, ? — любому одному символу. Примеры: nick!*@*, *!*@host.com, *!*user@*\"],\"qhzpRq\":[\"Ключ канала (+k)\"],\"qtoOYG\":[\"Без ограничений\"],\"r1W2AS\":[\"Изображение с файлового хостинга\"],\"rIPR2O\":[\"Тема установлена до (мин назад)\"],\"rMMSYo\":[\"Максимальная длина: \",[\"0\"]],\"rWtzQe\":[\"Сеть разделилась и воссоединилась. ✅\"],\"rYG2u6\":[\"Пожалуйста, подождите...\"],\"rdUucN\":[\"Предпросмотр\"],\"rjGI/Q\":[\"Конфиденциальность\"],\"rk8iDX\":[\"Загрузка GIF...\"],\"rn6SBY\":[\"Вкл. звук\"],\"s/UKqq\":[\"Был исключён из канала\"],\"s8cATI\":[\"присоединился к \",[\"channelName\"]],\"sCO9ue\":[\"Соединение с <0>\",[\"serverName\"],\" имеет следующие проблемы безопасности:\"],\"sGH11W\":[\"Сервер\"],\"sHI1H+\":[\"теперь известен как **\",[\"newNick\"],\"**\"],\"sJyV04\":[[\"inviter\"],\" пригласил вас присоединиться к \",[\"channel\"]],\"sby+1/\":[\"Нажмите, чтобы скопировать\"],\"sfN25C\":[\"Ваше настоящее или полное имя\"],\"sliuzR\":[\"Открыть ссылку\"],\"sqrO9R\":[\"Пользовательские упоминания\"],\"sr6RdJ\":[\"Многострочный режим по Shift+Enter\"],\"swrCpB\":[\"Канал был переименован с \",[\"oldName\"],\" на \",[\"newName\"],\" пользователем \",[\"user\"],[\"0\"]],\"sxkWRg\":[\"Дополнительно\"],\"t/YqKh\":[\"Удалить\"],\"t47eHD\":[\"Ваш уникальный идентификатор на этом сервере\"],\"tAkAh0\":[\"URL с необязательной подстановкой \",[\"size\"],\" для динамического масштабирования. Пример: https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"tXLJS3\":[\"Показать или скрыть боковую панель списка каналов\"],\"tfDRzk\":[\"Сохранить\"],\"tiBsJk\":[\"покинул \",[\"channelName\"]],\"tt4/UD\":[\"вышел (\",[\"reason\"],\")\"],\"u0TcnO\":[\"Ник {nick} уже используется, повторная попытка с {newNick}\"],\"u0a8B4\":[\"Аутентифицироваться как IRC-оператор для административного доступа\"],\"u0rWFU\":[\"Создан после (мин назад)\"],\"u72w3t\":[\"Пользователи и шаблоны для игнорирования\"],\"u7jc2L\":[\"вышел\"],\"uAQUqI\":[\"Статус\"],\"uB85T3\":[\"Ошибка сохранения: \",[\"msg\"]],\"uV3DOL\":[\"G-Line\"],\"uW3lLI\":[\"IRC-серверы:\"],\"usSSr/\":[\"Масштаб\"],\"v7uvcf\":[\"Программа:\"],\"vE8kb+\":[\"Shift+Enter для новой строки (Enter отправляет)\"],\"vERlcd\":[\"Профиль\"],\"vK0RL8\":[\"Без темы\"],\"vSJd18\":[\"Видео\"],\"vXIe7J\":[\"Язык\"],\"vaHYxN\":[\"Настоящее имя\"],\"vhjbKr\":[\"Отсутствую\"],\"w4NYox\":[\"клиент \",[\"title\"]],\"w8xQRx\":[\"Неверное значение\"],\"wFjjxZ\":[\"был кикнут из \",[\"channelName\"],\" пользователем \",[\"username\"],\" (\",[\"reason\"],\")\"],\"wGjaGl\":[\"Исключения из банов не найдены\"],\"wPrGnM\":[\"Администратор канала\"],\"wRkP2d\":[\"GIF\"],\"wbm86v\":[\"Показывать, когда пользователи входят в каналы или покидают их\"],\"whqZ9r\":[\"Дополнительные слова или фразы для выделения\"],\"wm7RV4\":[\"Звук уведомления\"],\"wz/Yoq\":[\"Ваши сообщения могут быть перехвачены при передаче между серверами\"],\"xCJdfg\":[\"Очистить\"],\"xUHRTR\":[\"Автоматически аутентифицироваться как оператор при подключении\"],\"xWHwwQ\":[\"Баны\"],\"xYilR2\":[\"Медиа\"],\"xceQrO\":[\"Поддерживаются только защищённые WebSocket-соединения\"],\"xdtXa+\":[\"имя-канала\"],\"xfXC7q\":[\"Текстовые каналы\"],\"xlCYOE\":[\"Загрузка сообщений...\"],\"xlhswE\":[\"Минимальное значение: \",[\"0\"]],\"xq97Ci\":[\"Добавить слово или фразу...\"],\"xuRqRq\":[\"Лимит пользователей (+l)\"],\"xwF+7J\":[[\"0\"],\" печатает...\"],\"yNeucF\":[\"Этот сервер не поддерживает расширенные метаданные профиля (расширение IRCv3 METADATA). Дополнительные поля, такие как аватар, отображаемое имя и статус, недоступны.\"],\"yPlrca\":[\"Аватар канала\"],\"yQE2r9\":[\"Загрузка\"],\"ySU+JY\":[\"your@email.com\"],\"yTX1Rt\":[\"Имя пользователя оператора\"],\"yYOzWD\":[\"логи\"],\"yfx9Re\":[\"Пароль IRC-оператора\"],\"ygCKqB\":[\"Стоп\"],\"ymDxJx\":[\"Имя пользователя IRC-оператора\"],\"yrpRsQ\":[\"Сортировать по имени\"],\"yz7wBu\":[\"Закрыть\"],\"zJw+jA\":[\"устанавливает режим: \",[\"0\"]],\"zebeLu\":[\"Введите имя пользователя оператора\"],\"zpr0Bw\":[\"GZ-Line\"]}"); \ No newline at end of file +/*eslint-disable*/export const messages=JSON.parse("{\"+5VMrz\":[\"Неверный формат шаблона. Используйте формат nick!user@host (допускаются маски *)\"],\"+6NQQA\":[\"Канал общей поддержки\"],\"+6NyRG\":[\"Клиент\"],\"+K0AvT\":[\"Отключиться\"],\"+cyFdH\":[\"Сообщение по умолчанию при переходе в режим отсутствия\"],\"+mVPqU\":[\"Отображать форматирование Markdown в сообщениях\"],\"+vqCJH\":[\"Имя пользователя вашего аккаунта для аутентификации\"],\"+yPBXI\":[\"Выбрать файл\"],\"+zy2Nq\":[\"Тип\"],\"/09cao\":[\"Низкий уровень безопасности соединения (уровень \",[\"securityLevel\"],\")\"],\"/3BQ4J\":[\"Пользователи вне канала не могут отправлять в него сообщения\"],\"/6BzZF\":[\"Показать/скрыть список участников\"],\"/TNOPk\":[\"Пользователь отсутствует\"],\"/XQgft\":[\"Обзор\"],\"/cF7Rs\":[\"Громкость\"],\"/dqduX\":[\"Следующая страница\"],\"/fc3q4\":[\"Весь контент\"],\"/kISDh\":[\"Включить звуки уведомлений\"],\"/n04sB\":[\"Kill\"],\"/rTz0M\":[\"Аудио\"],\"/rfkZe\":[\"Воспроизводить звуки для упоминаний и сообщений\"],\"0/0ZGA\":[\"Маска имени канала\"],\"0D6j7U\":[\"Подробнее о пользовательских правилах →\"],\"0XsHcR\":[\"Исключить пользователя\"],\"0ZpE//\":[\"Сортировать по пользователям\"],\"0bEPwz\":[\"Отметиться как отсутствующий\"],\"0dGkPt\":[\"Развернуть список каналов\"],\"0gS7M5\":[\"Отображаемое имя\"],\"0kS+M8\":[\"ПримерНЕТ\"],\"0rgoY7\":[\"Подключайтесь только к выбранным вами серверам\"],\"0wdd7X\":[\"Войти\"],\"0wkVYx\":[\"Личные сообщения\"],\"111uHX\":[\"Предпросмотр ссылки\"],\"196EG4\":[\"Удалить личную переписку\"],\"1DSr1i\":[\"Зарегистрировать аккаунт\"],\"1O/24y\":[\"Показать/скрыть список каналов\"],\"1TNIig\":[\"Open\"],\"1VPJJ2\":[\"Предупреждение о внешней ссылке\"],\"1ZC/dv\":[\"Нет непрочитанных упоминаний или сообщений\"],\"1pO1zi\":[\"Необходимо указать имя сервера\"],\"1uwfzQ\":[\"Просмотреть тему канала\"],\"268g7c\":[\"Введите отображаемое имя\"],\"2FOFq1\":[\"Операторы серверов сети потенциально могут читать ваши сообщения\"],\"2FYpfJ\":[\"Ещё\"],\"2HF1Y2\":[[\"inviter\"],\" пригласил \",[\"target\"],\" присоединиться к \",[\"channel\"]],\"2I70QL\":[\"Просмотреть информацию профиля пользователя\"],\"2QYdmE\":[\"Пользователи:\"],\"2QpEjG\":[\"вышел\"],\"2YE223\":[\"Сообщение #\",[\"0\"],\" (Enter — новая строка, Shift+Enter — отправить)\"],\"2bimFY\":[\"Использовать пароль сервера\"],\"2iTmdZ\":[\"Локальное хранилище:\"],\"2odkwe\":[\"Строгий — более агрессивная защита\"],\"2uDhbA\":[\"Введите имя пользователя для приглашения\"],\"2ygf/L\":[\"← Назад\"],\"2zEgxj\":[\"Поиск GIF...\"],\"3RdPhl\":[\"Переименовать канал\"],\"3THokf\":[\"Пользователь с голосом\"],\"3TSz9S\":[\"Свернуть\"],\"3jBDvM\":[\"Отображаемое имя канала\"],\"3ryuFU\":[\"Необязательные отчёты о сбоях для улучшения приложения\"],\"3uBF/8\":[\"Закрыть просмотрщик\"],\"3uwW8F\":[\"https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"3xf8Kz\":[\"Введите имя аккаунта...\"],\"4/Rr0R\":[\"Пригласить пользователя в текущий канал\"],\"4EZrJN\":[\"Правила\"],\"4JJtW9\":[\"#overflow\"],\"4NqeT4\":[\"Профиль флуда (+F)\"],\"4RZQRK\":[\"Чем ты занимаешься?\"],\"4hfTrB\":[\"Никнейм\"],\"4n99LO\":[\"Уже в \",[\"0\"]],\"4t6vMV\":[\"Автоматически переключаться на однострочный режим для коротких сообщений\"],\"4vsHmf\":[\"Время (мин)\"],\"4x/Axu\":[\"У вашего баунсера ещё нет сетей. Добавьте одну, чтобы начать.\"],\"5+INAX\":[\"Выделять сообщения, в которых упоминается ваш никнейм\"],\"5R5Pv/\":[\"Имя оператора\"],\"678PKt\":[\"Название сети\"],\"6Aih4U\":[\"Не в сети\"],\"6CO3WE\":[\"Пароль для входа в канал. Оставьте пустым, чтобы удалить ключ.\"],\"6HhMs3\":[\"Сообщение при выходе\"],\"6V3Ea3\":[\"Скопировано\"],\"6lGV3K\":[\"Свернуть\"],\"6yFOEi\":[\"Введите пароль опера...\"],\"7+IHTZ\":[\"Файл не выбран\"],\"73fnil\":[\"TLS\"],\"73hrRi\":[\"nick!user@host (например: spam*!*@*, *!*@badhost.com)\"],\"7QkKyN\":[\"Отправить личное сообщение\"],\"7U1W7c\":[\"Очень мягкий\"],\"7Y1YQj\":[\"Имя:\"],\"7YHArF\":[\"— открыть в просмотрщике\"],\"7fjnVl\":[\"Поиск пользователей...\"],\"7jL88x\":[\"Удалить это сообщение? Это действие нельзя отменить.\"],\"7nGhhM\":[\"О чём вы думаете?\"],\"7sEpu1\":[\"Участники — \",[\"0\"]],\"7sNhEz\":[\"Имя пользователя\"],\"8H0Q+x\":[\"Подробнее о профилях →\"],\"8Phu0A\":[\"Показывать, когда пользователи меняют никнейм\"],\"8XTG9e\":[\"Введите пароль оператора\"],\"8XsV2J\":[\"Повторить отправку\"],\"8ZsakT\":[\"Пароль\"],\"8kR84m\":[\"Вы собираетесь открыть внешнюю ссылку:\"],\"8lCgih\":[\"Удалить правило\"],\"8p/xVT\":[[\"0\",\"plural\",{\"one\":[[\"1\"]],\"other\":[[\"2\"]]}]],\"8wRzac\":[[\"joinCount\",\"plural\",{\"one\":[\"присоединился\"],\"few\":[\"присоединился \",[\"joinCount\"],\" раза\"],\"many\":[\"присоединился \",[\"joinCount\"],\" раз\"],\"other\":[\"присоединился \",[\"joinCount\"],\" раза\"]}]],\"9BMLnJ\":[\"Переподключиться к серверу\"],\"9OEgyT\":[\"Добавить реакцию\"],\"9PQ8m2\":[\"G-Line (глобальный бан)\"],\"9Qs99X\":[\"Email:\"],\"9QupBP\":[\"Удалить шаблон\"],\"9W7tl5\":[\"(без изменений)\"],\"9bG48P\":[\"Отправка\"],\"9f5f0u\":[\"Вопросы о конфиденциальности? Свяжитесь с нами:\"],\"9iweoP\":[\"Сети на \",[\"0\"]],\"9unqs3\":[\"Отсутствие:\"],\"9v3hwv\":[\"Серверы не найдены.\"],\"9zb2WA\":[\"Подключение\"],\"A1taO8\":[\"Поиск\"],\"A2adVi\":[\"Отправлять уведомления о наборе текста\"],\"A9Rhec\":[\"Имя канала\"],\"AWOSPo\":[\"Увеличить\"],\"AXSpEQ\":[\"Войти как оператор при подключении\"],\"AeXO77\":[\"Аккаунт\"],\"AhNP40\":[\"Перемотка\"],\"Ai2U7L\":[\"Хост\"],\"AjBQnf\":[\"Изменил никнейм\"],\"AmXVh6\":[\"https://example.com/avatar.png\"],\"AnRu/j\":[\"Отменить ответ\"],\"ApSx0O\":[\"Найдено \",[\"0\"],\" сообщений, соответствующих \\\"\",[\"searchQuery\"],\"\\\"\"],\"AxPAXW\":[\"Результаты не найдены\"],\"AyNqAB\":[\"Отображать все события сервера в чате\"],\"B/QqGw\":[\"Отошёл от клавиатуры\"],\"B0sB2k\":[\"Открытый текст\"],\"B8AaMI\":[\"Это поле обязательно для заполнения\"],\"BA2c49\":[\"Сервер не поддерживает расширенную фильтрацию LIST\"],\"BDKt3I\":[[\"0\"],\", \",[\"1\"],\", \",[\"2\"],\" и ещё \",[\"3\"],\" печатают...\"],\"BGul2A\":[\"У вас есть несохранённые изменения. Вы уверены, что хотите закрыть без сохранения?\"],\"BIf9fi\":[\"Ваше статусное сообщение\"],\"BZz3md\":[\"Ваш личный сайт\"],\"Bgm/H7\":[\"Разрешить ввод нескольких строк текста\"],\"BiQIl1\":[\"Закрепить эту личную переписку\"],\"BlNZZ2\":[\"Нажмите, чтобы перейти к сообщению\"],\"Bowq3c\":[\"Только операторы могут изменять тему канала\"],\"Btozzp\":[\"Срок действия этого изображения истёк\"],\"Bycfjm\":[\"Всего: \",[\"0\"]],\"C6IBQc\":[\"Копировать весь JSON\"],\"C9L9wL\":[\"Сбор данных\"],\"CDq4wC\":[\"Модерировать пользователя\"],\"CHVRxG\":[\"Сообщение @\",[\"0\"],\" (Shift+Enter — новая строка)\"],\"CN9zdR\":[\"Необходимо указать имя и пароль оператора\"],\"CW3sYa\":[\"Добавить реакцию \",[\"emoji\"]],\"CaAkqd\":[\"Показывать выходы\"],\"CbvaYj\":[\"Забанить по никнейму\"],\"CcK+Ft\":[\"PDF\"],\"Ce8q3L\":[\"Выберите канал\"],\"CsekCi\":[\"Обычный\"],\"D+NlUC\":[\"Система\"],\"D28t6+\":[\"подключился и отключился\"],\"DB8zMK\":[\"Применить\"],\"DBcWHr\":[\"Пользовательский файл звука уведомления\"],\"DTy9Xw\":[\"Предпросмотр медиа\"],\"Dj4pSr\":[\"Выберите надёжный пароль\"],\"Du+zn+\":[\"Поиск...\"],\"Du2T2f\":[\"Настройка не найдена\"],\"DwsSVQ\":[\"Применить фильтры и обновить\"],\"E3W/zd\":[\"Никнейм по умолчанию\"],\"E6nRW7\":[\"Копировать URL\"],\"E703RG\":[\"Режимы:\"],\"EAeu1Z\":[\"Отправить приглашение\"],\"EFKJQT\":[\"Настройка\"],\"EGPQBv\":[\"Пользовательские правила флуда (+f)\"],\"ELik0r\":[\"Просмотреть полную политику конфиденциальности\"],\"EPbeC2\":[\"Просмотреть или изменить тему канала\"],\"EQCDNT\":[\"Введите имя пользователя опера...\"],\"EUvulZ\":[\"Найдено 1 сообщение, соответствующее \\\"\",[\"searchQuery\"],\"\\\"\"],\"EatZYJ\":[\"Следующее изображение\"],\"EdQY6l\":[\"Нет\"],\"EnqLYU\":[\"Поиск серверов...\"],\"F0OKMc\":[\"Редактировать сервер\"],\"F6Int2\":[\"Включить выделения\"],\"FDoLyE\":[\"Макс. пользователей\"],\"FUU/hZ\":[\"Управляет количеством внешних медиафайлов, загружаемых в чат.\"],\"Fdp03t\":[\"вкл\"],\"FfPWR0\":[\"Диалог\"],\"FjkaiT\":[\"Уменьшить\"],\"FlqOE9\":[\"Что это означает:\"],\"FolHNl\":[\"Управление аккаунтом и аутентификацией\"],\"Fp2Dif\":[\"Покинул сервер\"],\"G5KmCc\":[\"GZ-Line (глобальная Z-Line)\"],\"GDs0lz\":[\"<0>Риск: Конфиденциальная информация (сообщения, личные переписки, данные аутентификации) может быть раскрыта сетевым администраторам или злоумышленникам, находящимся между IRC-серверами.\"],\"GR+2I3\":[\"Добавить маску приглашения (например: nick!*@*, *!*@host.com)\"],\"GRLyMU\":[\"Закрыть всплывающие уведомления сервера\"],\"GlHnXw\":[\"Смена ника не удалась: \",[\"error\"],\" \",[\"0\"]],\"GswZF3\":[\"Предпросмотр:\"],\"GtmO8/\":[\"от\"],\"GtuHUQ\":[\"Переименовать этот канал на сервере. Все пользователи увидят новое имя.\"],\"GuGfFX\":[\"Включить/выключить поиск\"],\"GxkJXS\":[\"Загрузка...\"],\"GzbwnK\":[\"Присоединился к каналу\"],\"GzsUDB\":[\"Расширенный профиль\"],\"H/PnT8\":[\"Вставить эмодзи\"],\"H6Izzl\":[\"Ваш предпочтительный цветовой код\"],\"H9jIv+\":[\"Показывать входы/выходы\"],\"HAKBY9\":[\"Загрузить файлы\"],\"HdE1If\":[\"Канал\"],\"Hk4AW9\":[\"Ваше предпочтительное отображаемое имя\"],\"HmHDk7\":[\"Выбрать участника\"],\"HrQzPU\":[\"Каналы на \",[\"networkName\"]],\"I2tXQ5\":[\"Сообщение @\",[\"0\"],\" (Enter — новая строка, Shift+Enter — отправить)\"],\"I6bw/h\":[\"Забанить пользователя\"],\"I92Z+b\":[\"Включить уведомления\"],\"I9D72S\":[\"Вы уверены, что хотите удалить это сообщение? Это действие нельзя отменить.\"],\"IA+1wo\":[\"Показывать, когда пользователей исключают из каналов\"],\"IDwkJx\":[\"IRC Operator\"],\"ILlU+s\":[\"Инфо:\"],\"IUwGEM\":[\"Сохранить изменения\"],\"IVeGK6\":[[\"0\"],\", \",[\"1\"],\" и \",[\"2\"],\" печатают...\"],\"IgrLD/\":[\"Пауза\"],\"Im6JED\":[\"WHISPER\"],\"ImOQa9\":[\"Ответить\"],\"IoHMnl\":[\"Максимальное значение: \",[\"0\"]],\"IvMj+0\":[\"Op\"],\"J28zul\":[\"Подключение...\"],\"J5T9NW\":[\"Информация о пользователе\"],\"J8Y5+z\":[\"Упс! Разрыв сети! ⚠️\"],\"JBHkBA\":[\"Покинул канал\"],\"JCwL0Q\":[\"Укажите причину (необязательно)\"],\"JFciKP\":[\"Переключить\"],\"JXGkhG\":[\"Изменить имя канала (только для операторов)\"],\"JcD7qf\":[\"Другие действия\"],\"JdkA+c\":[\"Секретный (+s)\"],\"Jmu12l\":[\"Каналы сервера\"],\"JvQ++s\":[\"Включить Markdown\"],\"K2jwh/\":[\"Данные WHOIS недоступны\"],\"KAXSwC\":[\"Voice\"],\"KDfTdX\":[\"Удалить сообщение\"],\"KKBlUU\":[\"Встроить\"],\"KM0pLb\":[\"Добро пожаловать в канал!\"],\"KR6W2h\":[\"Перестать игнорировать пользователя\"],\"KV+Bi1\":[\"Только по приглашению (+i)\"],\"KdCtwE\":[\"Сколько секунд отслеживать флуд-активность до сброса счётчиков\"],\"Kkezga\":[\"Пароль сервера\"],\"KsiQ/8\":[\"Для входа в канал необходимо приглашение\"],\"L+gB/D\":[\"Информация о канале\"],\"LC1a7n\":[\"IRC-сервер сообщил о низком уровне безопасности межсерверных соединений. Это означает, что при передаче ваших сообщений между IRC-серверами сети они могут быть недостаточно зашифрованы или SSL/TLS-сертификаты могут не проверяться должным образом.\"],\"LNfLR5\":[\"Показывать исключения\"],\"LP+1Z7\":[\"Добавить сеть\"],\"LQb0W/\":[\"Показывать все события\"],\"LU7/yA\":[\"Альтернативное имя для отображения в интерфейсе. Может содержать пробелы, эмодзи и специальные символы. Настоящее имя канала (\",[\"channelName\"],\") по-прежнему будет использоваться для IRC-команд.\"],\"LUb9O7\":[\"Необходимо указать корректный порт сервера\"],\"Lb+BUl\":[\"https://example.com/avatar.jpg\"],\"LcET2C\":[\"Политика конфиденциальности\"],\"LcuSDR\":[\"Управление информацией профиля и метаданными\"],\"LqLS9B\":[\"Показывать смену никнейма\"],\"LsDQt2\":[\"Настройки канала\"],\"LtI9AS\":[\"Владелец\"],\"LuNhhL\":[\"отреагировал на это сообщение\"],\"M/AZNG\":[\"URL вашего аватара\"],\"M/WIer\":[\"Отправить сообщение\"],\"M8er/5\":[\"Имя:\"],\"MHk+7g\":[\"Предыдущее изображение\"],\"MRorGe\":[\"Написать в личку\"],\"MVbSGP\":[\"Временное окно (секунды)\"],\"MkpcsT\":[\"Ваши сообщения и настройки хранятся локально на вашем устройстве\"],\"MzPdC2\":[\"Пароль сервера (PASS)\"],\"N/hDSy\":[\"Пометить как бота — обычно «on» или пусто\"],\"N6j2JH\":[\"Изменить \",[\"0\"]],\"N7TQbE\":[\"Пригласить пользователя в \",[\"channelName\"]],\"NCca/o\":[\"Введите ник по умолчанию...\"],\"Nqs6B9\":[\"Показывает весь внешний медиаконтент. Любой URL может вызвать запрос к неизвестному серверу.\"],\"Nt+9O7\":[\"Использовать WebSocket вместо обычного TCP\"],\"NxIHzc\":[\"Отключить пользователя\"],\"O+v/cL\":[\"Просмотреть все каналы на сервере\"],\"OCGpR4\":[\"(наследовать)\"],\"ODwSCk\":[\"Отправить GIF\"],\"OGQ5kK\":[\"Настройка звуков уведомлений и выделений\"],\"OIPt1Z\":[\"Показать или скрыть боковую панель списка участников\"],\"OKSNq/\":[\"Очень строгий\"],\"ONWvwQ\":[\"Загрузить\"],\"OVKoQO\":[\"Пароль вашего аккаунта для аутентификации\"],\"ObsidianIRC - Bringing IRC to the future\":[\"ObsidianIRC - Перенося IRC в будущее\"],\"OhCpra\":[\"Задать тему…\"],\"OkltoQ\":[\"Забанить \",[\"username\"],\" по никнейму (запрещает переподключение с тем же ником)\"],\"P+t/Te\":[\"Нет дополнительных данных\"],\"P42Wcc\":[\"Безопасно\"],\"PD38l0\":[\"Предпросмотр аватара канала\"],\"PD9mEt\":[\"Введите сообщение...\"],\"PPqfdA\":[\"Открыть настройки конфигурации канала\"],\"PSCjfZ\":[\"Тема, которая будет отображаться для этого канала. Тему видят все пользователи.\"],\"PZCecv\":[\"Предпросмотр PDF\"],\"PeLgsC\":[[\"c\",\"plural\",{\"one\":[\"1 раз\"],\"few\":[[\"c\"],\" раза\"],\"many\":[[\"c\"],\" раз\"],\"other\":[[\"c\"],\" раза\"]}]],\"PguS2C\":[\"Добавить маску исключения (например: nick!*@*, *!*@host.com)\"],\"Pil5Ty\":[\"Показано \",[\"displayedChannelsCount\"],\" из \",[\"0\"],\" каналов\"],\"PqhVlJ\":[\"Забанить пользователя (по hostmask)\"],\"Q+chwU\":[\"Имя пользователя:\"],\"Q3v9Wc\":[\"Да, удалить\"],\"Q6hhn8\":[\"Настройки\"],\"QF4a34\":[\"Введите имя пользователя\"],\"QGqSZ2\":[\"Цвет и форматирование\"],\"QJQd1J\":[\"Редактировать профиль\"],\"QSzGDE\":[\"Не активен\"],\"QUlny5\":[\"Добро пожаловать на \",[\"0\"],\"!\"],\"Qoq+GP\":[\"Читать далее\"],\"QuSkCF\":[\"Фильтр каналов...\"],\"QwUrDZ\":[\"изменил тему на: \",[\"topic\"]],\"R0UH07\":[\"Изображение \",[\"0\"],\" из \",[\"1\"]],\"R7SsBE\":[\"Выкл. звук\"],\"R8rf1X\":[\"Нажмите, чтобы задать тему\"],\"RArB3D\":[\"был кикнут из \",[\"channelName\"],\" пользователем \",[\"username\"]],\"RI3cWd\":[\"Откройте мир IRC вместе с ObsidianIRC\"],\"RMMaN5\":[\"Модерируемый (+m)\"],\"RWw9Lg\":[\"Закрыть диалог\"],\"RZ2BuZ\":[\"Регистрация аккаунта \",[\"account\"],\" требует подтверждения: \",[\"message\"]],\"RySp6q\":[\"Скрыть комментарии\"],\"S5Togi\":[\"Загрузка сетей с вашего баунсера…\"],\"SPKQTd\":[\"Необходимо указать никнейм\"],\"SPVjfj\":[\"Если оставить пустым, будет использоваться «без причины»\"],\"SQKPvQ\":[\"Пригласить пользователя\"],\"STmlpb\":[\"Back to network list\"],\"SkZcl+\":[\"Выберите заранее заданный профиль защиты от флуда. Эти профили предоставляют сбалансированные настройки защиты для различных сценариев использования.\"],\"Slr+3C\":[\"Мин. пользователей\"],\"Spnlre\":[\"Вы пригласили \",[\"target\"],\" присоединиться к \",[\"channel\"]],\"T/ckN5\":[\"Открыть в просмотрщике\"],\"T91vKp\":[\"Воспроизвести\"],\"TV2Wdu\":[\"Узнайте, как мы обрабатываем ваши данные и защищаем вашу конфиденциальность.\"],\"TgFpwD\":[\"Применяется...\"],\"TkzSFB\":[\"Нет изменений\"],\"TtserG\":[\"Введите настоящее имя\"],\"Ttz9J1\":[\"Введите пароль...\"],\"Tz0i8g\":[\"Настройки\"],\"U3pytU\":[\"Администратор\"],\"UDb2YD\":[\"Реакция\"],\"UE4KO5\":[\"*channel*\"],\"UGT5vp\":[\"Сохранить настройки\"],\"UV5hLB\":[\"Баны не найдены\"],\"Uaj3Nd\":[\"Статусные сообщения\"],\"Ue3uny\":[\"По умолчанию (без профиля)\"],\"UkARhe\":[\"Обычный — стандартная защита\"],\"Umn7Cj\":[\"Комментариев пока нет. Будьте первым!\"],\"UtUIRh\":[[\"0\"],\" старых сообщений\"],\"UwzP+U\":[\"Защищённое соединение\"],\"V0/A4O\":[\"Владелец канала\"],\"V4qgxE\":[\"Создан до (мин назад)\"],\"V8yTm6\":[\"Очистить поиск\"],\"VJMMyz\":[\"ObsidianIRC — IRC будущего\"],\"VJScHU\":[\"Причина\"],\"VLsmVV\":[\"Отключить уведомления\"],\"VbyRUy\":[\"Комментарии\"],\"Vmx0mQ\":[\"Установлено:\"],\"VqnIZz\":[\"Ознакомьтесь с нашей политикой конфиденциальности и практикой обработки данных\"],\"VrMygG\":[\"Минимальная длина: \",[\"0\"]],\"VrnTui\":[\"Ваши местоимения, отображаемые в профиле\"],\"W8E3qn\":[\"Аутентифицированный аккаунт\"],\"WAakm9\":[\"Удалить канал\"],\"WFxTHC\":[\"Добавить маску бана (например: nick!*@*, *!*@host.com)\"],\"WN1g9F\":[\"Необходимо указать хост сервера\"],\"WRYdXW\":[\"Позиция в аудио\"],\"WUOH5B\":[\"Игнорировать пользователя\"],\"WWEXnZ\":[[\"0\",\"plural\",{\"one\":[\"Показать ещё 1 элемент\"],\"few\":[\"Показать ещё \",[\"1\"],\" элемента\"],\"many\":[\"Показать ещё \",[\"1\"],\" элементов\"],\"other\":[\"Показать ещё \",[\"1\"],\" элемента\"]}]],\"Weq9zb\":[\"Основное\"],\"Wfj7Sk\":[\"Включить или отключить звуки уведомлений\"],\"Wm7gbG\":[\"GitHub:\"],\"WyeHWY\":[\"*spam*\"],\"WzMCru\":[\"Профиль пользователя\"],\"X6S3lt\":[\"Поиск настроек, каналов, серверов...\"],\"XEHan5\":[\"Всё равно продолжить\"],\"XI1+wb\":[\"Неверный формат\"],\"XIXeuC\":[\"Сообщение @\",[\"0\"]],\"XMS+k4\":[\"Начать личный чат\"],\"XWgxXq\":[\"Альбом\"],\"Xd7+IT\":[\"Открепить личный чат\"],\"Xm/s+u\":[\"Отображение\"],\"Xp2n93\":[\"Показывает медиафайлы с доверенного файлового хоста вашего сервера. Запросы к внешним сервисам не выполняются.\"],\"XvjC4F\":[\"Сохранение...\"],\"Y/qryO\":[\"Пользователи по вашему запросу не найдены\"],\"YAqRpI\":[\"Регистрация аккаунта \",[\"account\"],\" успешна: \",[\"message\"]],\"YEfzvP\":[\"Защищённая тема (+t)\"],\"YQOn6a\":[\"Свернуть список участников\"],\"YRCoE9\":[\"Оператор канала\"],\"YURQaF\":[\"Просмотреть профиль\"],\"YdBSvr\":[\"Управление отображением медиа и внешнего контента\"],\"Yj6U3V\":[\"Нет центрального сервера:\"],\"YjvpGx\":[\"Местоимения\"],\"YqH4l4\":[\"Без ключа\"],\"YyUPpV\":[\"Аккаунт:\"],\"ZJSWfw\":[\"Сообщение, отображаемое при отключении от сервера\"],\"ZR1dJ4\":[\"Приглашения\"],\"ZdWg0V\":[\"Открыть в браузере\"],\"ZhRBbl\":[\"Поиск сообщений…\"],\"Zmcu3y\":[\"Расширенные фильтры\"],\"a2/8e5\":[\"Тема установлена после (мин назад)\"],\"aHKcKc\":[\"Предыдущая страница\"],\"aJTbXX\":[\"Пароль оператора\"],\"aQryQv\":[\"Такой шаблон уже существует\"],\"aW9pLN\":[\"Максимальное количество пользователей в канале. Оставьте пустым для снятия ограничения.\"],\"ah4fmZ\":[\"Также показывает превью с YouTube, Vimeo, SoundCloud и других известных сервисов.\"],\"aifXak\":[\"В этом канале нет медиафайлов\"],\"ap2zBz\":[\"Мягкий\"],\"az8lvo\":[\"Выкл.\"],\"azXSNo\":[\"Развернуть список участников\"],\"azdliB\":[\"Войти в аккаунт\"],\"b26wlF\":[\"она/её\"],\"bD/+Ei\":[\"Строгий\"],\"bQ6BJn\":[\"Настройте подробные правила защиты от флуда. Каждое правило задаёт тип активности для мониторинга и действие при превышении порога.\"],\"beV7+y\":[\"Пользователь получит приглашение вступить в \",[\"channelName\"],\".\"],\"bk84cH\":[\"Сообщение об отсутствии\"],\"bkHdLj\":[\"Добавить IRC-сервер\"],\"bmQLn5\":[\"Добавить правило\"],\"bv4cFj\":[\"Транспорт\"],\"bwRvnp\":[\"Действие\"],\"c8+EVZ\":[\"Верифицированный аккаунт\"],\"cGYUlD\":[\"Предпросмотр медиа не загружается.\"],\"cLF98o\":[\"Показать комментарии (\",[\"commentCount\"],\")\"],\"cLKIDO\":[\"Нет доступных пользователей\"],\"cSgpoS\":[\"Закрепить личный чат\"],\"cde3ce\":[\"Написать <0>\",[\"0\"],\"\"],\"chQsxg\":[\"Копировать форматированный вывод\"],\"cl/A5J\":[\"Добро пожаловать на \",[\"__DEFAULT_IRC_SERVER_NAME__\"],\"!\"],\"cnGeoo\":[\"Удалить\"],\"coPLXT\":[\"Мы не храним ваши IRC-переписки на наших серверах\"],\"crYH/6\":[\"Плеер SoundCloud\"],\"cv5DQb\":[\"хост не задан\"],\"d3sis4\":[\"Добавить сервер\"],\"d9aN5k\":[\"Удалить \",[\"username\"],\" из канала\"],\"dEgA5A\":[\"Отмена\"],\"dGi1We\":[\"Открепить эту личную переписку\"],\"dJVuyC\":[\"покинул \",[\"channelName\"],\" (\",[\"reason\"],\")\"],\"dMtLDE\":[\"кому\"],\"dXqxlh\":[\"<0>⚠️ Угроза безопасности! Это соединение может быть уязвимо для перехвата или атак типа «человек посередине».\"],\"da9Q/R\":[\"Изменил режимы канала\"],\"dhJN3N\":[\"Показать комментарии\"],\"dj2xTE\":[\"Закрыть уведомление\"],\"dpCzmC\":[\"Настройки защиты от флуда\"],\"e9dQpT\":[\"Открыть эту ссылку в новой вкладке?\"],\"ePK91l\":[\"Изменить\"],\"eYBDuB\":[\"Загрузите изображение или укажите URL с необязательной подстановкой \",[\"size\"],\" для динамического масштабирования\"],\"edBbee\":[\"Забанить \",[\"username\"],\" по hostmask (запрещает переподключение с того же IP/хоста)\"],\"ekfzWq\":[\"Настройки пользователя\"],\"elPDWs\":[\"Настройте IRC-клиент под себя\"],\"eu2osY\":[\"<0>💡 Рекомендация: Продолжайте только если вы доверяете этому серверу и понимаете риски. Избегайте передачи конфиденциальной информации или паролей через это соединение.\"],\"euEhbr\":[\"Нажмите, чтобы войти в \",[\"channel\"]],\"ez3vLd\":[\"Включить многострочный ввод\"],\"f0J5Ki\":[\"Межсерверное взаимодействие может использовать незашифрованные соединения\"],\"f9BHJk\":[\"Предупредить пользователя\"],\"fDOLLd\":[\"Каналы не найдены.\"],\"ffzDkB\":[\"Анонимная аналитика:\"],\"fq1GF9\":[\"Показывать, когда пользователи отключаются от сервера\"],\"gEF57C\":[\"Этот сервер поддерживает только один тип подключения\"],\"gJuLUI\":[\"Список игнорирования\"],\"gNzMrk\":[\"Текущий аватар\"],\"gjPWyO\":[\"Введите ник...\"],\"gz6UQ3\":[\"Развернуть\"],\"h6/IMX\":[\"Добавьте вашу первую сеть\"],\"h6razj\":[\"Исключить маску имени канала\"],\"hG6jnw\":[\"Тема не задана\"],\"hG89Ed\":[\"Изображение\"],\"hZ6znB\":[\"Порт\"],\"ha+Bz5\":[\"например: 100:1440\"],\"hehnjM\":[\"Количество\"],\"hzdLuQ\":[\"Говорить могут только пользователи с голосом или выше\"],\"i0qMbr\":[\"Главная\"],\"iDNBZe\":[\"Уведомления\"],\"iH8pgl\":[\"Назад\"],\"iL9SZg\":[\"Забанить пользователя (по никнейму)\"],\"iNt+3c\":[\"Вернуться к изображению\"],\"iQvi+a\":[\"Не предупреждать меня о низком уровне безопасности соединений для этого сервера\"],\"iSLIjg\":[\"Подключиться\"],\"iWXkHH\":[\"Halfop\"],\"iZeTtp\":[\"Хост сервера\"],\"idD8Ev\":[\"Сохранено\"],\"iivqkW\":[\"В сети с\"],\"ij+Elv\":[\"Предпросмотр изображения\"],\"ilIWp7\":[\"Включить/выключить уведомления\"],\"iuaqvB\":[\"Используйте * в качестве маски. Примеры: baduser!*@*, *!*@spammer.com, troll*!*@*\"],\"ixkTse\":[\"Бот\"],\"j2DGR0\":[\"Забанить по hostmask\"],\"jA4uoI\":[\"Тема:\"],\"jLXxGK\":[\"https://example.com\"],\"jPSk57\":[\"Причина (необязательно)\"],\"jUV7CU\":[\"Загрузить аватар\"],\"jW5Uwh\":[\"Управление загрузкой внешних медиафайлов. Выкл / Безопасно / Доверенные источники / Весь контент.\"],\"jXzms5\":[\"Параметры вложения\"],\"jZlrte\":[\"Цвет\"],\"jfC/xh\":[\"Контакт\"],\"jywMpv\":[\"#new-channel-name\"],\"k112DD\":[\"Загрузить старые сообщения\"],\"k3ID0F\":[\"Фильтр участников…\"],\"k65gsE\":[\"Подробнее\"],\"k7Zgob\":[\"Отменить подключение\"],\"kAVx5h\":[\"Приглашения не найдены\"],\"kCLEPU\":[\"Подключён к\"],\"kF5LKb\":[\"Игнорируемые шаблоны:\"],\"kGeOx/\":[\"Присоединиться к \",[\"0\"]],\"kITKr8\":[\"Загрузка режимов канала...\"],\"kPpPsw\":[\"Вы являетесь IRC-оператором\"],\"kWJmRL\":[\"ты\"],\"kfcRb0\":[\"Аватар\"],\"kjMqSj\":[\"Копировать JSON\"],\"krViRy\":[\"Нажмите для копирования как JSON\"],\"ks71ra\":[\"Исключения\"],\"kw4lRv\":[\"Полуоператор канала\"],\"kxgIRq\":[\"Выберите или добавьте канал для начала.\"],\"ky6dWe\":[\"Предпросмотр аватара\"],\"l+GxCv\":[\"Загрузка каналов...\"],\"l+IUVW\":[\"Верификация аккаунта \",[\"account\"],\" успешна: \",[\"message\"]],\"l/siQz\":[[\"reconnectCount\",\"plural\",{\"one\":[\"переподключился\"],\"few\":[\"переподключился \",[\"reconnectCount\"],\" раза\"],\"many\":[\"переподключился \",[\"reconnectCount\"],\" раз\"],\"other\":[\"переподключился \",[\"reconnectCount\"],\" раза\"]}]],\"l5jmzx\":[[\"0\"],\" и \",[\"1\"],\" печатают...\"],\"lHy8N5\":[\"Загрузка дополнительных каналов...\"],\"lbpf14\":[\"Войти в \",[\"value\"]],\"lfFsZ4\":[\"Каналы\"],\"lkNdiH\":[\"Имя аккаунта\"],\"ln500L\":[\"ObsidianIRC\"],\"lnCMdg\":[\"Загрузить изображение\"],\"loQxaJ\":[\"Я вернулся\"],\"lvfaxv\":[\"ГЛАВНАЯ\"],\"m0oxpP\":[\"Libera Chat\"],\"m16xKo\":[\"Добавить\"],\"m8flAk\":[\"Предпросмотр (ещё не загружено)\"],\"mEPxTp\":[\"<0>⚠️ Будьте осторожны! Открывайте ссылки только из доверенных источников. Вредоносные ссылки могут угрожать вашей безопасности или конфиденциальности.\"],\"mHGdhG\":[\"Информация о сервере\"],\"mHS8lb\":[\"Сообщение #\",[\"0\"]],\"mMYBD9\":[\"Широкий — более широкая область защиты\"],\"mTGsPd\":[\"Тема канала\"],\"mU8j6O\":[\"Без внешних сообщений (+n)\"],\"mZp8FL\":[\"Автоматический возврат к однострочному режиму\"],\"mdQu8G\":[\"YourNickname\"],\"miSSBQ\":[\"Комментарии (\",[\"commentCount\"],\")\"],\"mvyLSy\":[\"Пользователь аутентифицирован\"],\"mwtcGl\":[\"Закрыть комментарии\"],\"myL0MR\":[\"Удалить эту сеть?\"],\"mzI/c+\":[\"Скачать\"],\"n3fGRk\":[\"установил \",[\"0\"]],\"nE9jsU\":[\"Мягкий — менее строгая защита\"],\"nNflMD\":[\"Покинуть канал\"],\"nPXkBi\":[\"Загрузка данных WHOIS...\"],\"nQnxxF\":[\"Сообщение #\",[\"0\"],\" (Shift+Enter — новая строка)\"],\"nWMRxa\":[\"Открепить\"],\"nkC032\":[\"Без профиля флуда\"],\"o69z4d\":[\"Отправить предупреждение пользователю \",[\"username\"]],\"o9ylQi\":[\"Найдите GIF для начала\"],\"oFGkER\":[\"Уведомления сервера\"],\"oOi11l\":[\"Прокрутить вниз\"],\"oQEzQR\":[\"Новое DM\"],\"oXOSPE\":[\"В сети\"],\"oal760\":[\"Возможны атаки типа «человек посередине» на межсерверные соединения\"],\"oeqmmJ\":[\"Доверенные источники\"],\"ovBPCi\":[\"По умолчанию\"],\"p0Z69r\":[\"Шаблон не может быть пустым\"],\"p1KgtK\":[\"Не удалось загрузить аудио\"],\"p59pEv\":[\"Подробности\"],\"p7sRI6\":[\"Сообщать другим, когда вы печатаете\"],\"pBm1od\":[\"Секретный канал\"],\"pNmiXx\":[\"Ваш никнейм по умолчанию для всех серверов\"],\"pUUo9G\":[\"Хост:\"],\"pVGPmz\":[\"Пароль аккаунта\"],\"peNE68\":[\"Навсегда\"],\"plhHQt\":[\"Нет данных\"],\"pm6+q5\":[\"Предупреждение безопасности\"],\"pn5qSs\":[\"Дополнительная информация\"],\"q0cR4S\":[\"теперь известен как **\",[\"newNick\"],\"**\"],\"qFcunY\":[\"Канал не будет отображаться в командах LIST и NAMES\"],\"qLpTm/\":[\"Убрать реакцию \",[\"emoji\"]],\"qVkGWK\":[\"Закрепить\"],\"qY8wNa\":[\"Сайт\"],\"qb0xJ7\":[\"Используйте маски: * соответствует любой последовательности, ? — любому одному символу. Примеры: nick!*@*, *!*@host.com, *!*user@*\"],\"qhzpRq\":[\"Ключ канала (+k)\"],\"qtoOYG\":[\"Без ограничений\"],\"r1W2AS\":[\"Изображение с файлового хостинга\"],\"rIPR2O\":[\"Тема установлена до (мин назад)\"],\"rMMSYo\":[\"Максимальная длина: \",[\"0\"]],\"rWtzQe\":[\"Сеть разделилась и воссоединилась. ✅\"],\"rYG2u6\":[\"Пожалуйста, подождите...\"],\"rdUucN\":[\"Предпросмотр\"],\"rjGI/Q\":[\"Конфиденциальность\"],\"rk8iDX\":[\"Загрузка GIF...\"],\"rn6SBY\":[\"Вкл. звук\"],\"s/UKqq\":[\"Был исключён из канала\"],\"s8cATI\":[\"присоединился к \",[\"channelName\"]],\"sCO9ue\":[\"Соединение с <0>\",[\"serverName\"],\" имеет следующие проблемы безопасности:\"],\"sGH11W\":[\"Сервер\"],\"sHI1H+\":[\"теперь известен как **\",[\"newNick\"],\"**\"],\"sJyV04\":[[\"inviter\"],\" пригласил вас присоединиться к \",[\"channel\"]],\"sUBSbK\":[\"Пока нет вышестоящих сетей.\"],\"sby+1/\":[\"Нажмите, чтобы скопировать\"],\"sfN25C\":[\"Ваше настоящее или полное имя\"],\"sliuzR\":[\"Открыть ссылку\"],\"sqrO9R\":[\"Пользовательские упоминания\"],\"sr6RdJ\":[\"Многострочный режим по Shift+Enter\"],\"swrCpB\":[\"Канал был переименован с \",[\"oldName\"],\" на \",[\"newName\"],\" пользователем \",[\"user\"],[\"0\"]],\"sxkWRg\":[\"Дополнительно\"],\"t/YqKh\":[\"Удалить\"],\"t47eHD\":[\"Ваш уникальный идентификатор на этом сервере\"],\"tAkAh0\":[\"URL с необязательной подстановкой \",[\"size\"],\" для динамического масштабирования. Пример: https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"tXLJS3\":[\"Показать или скрыть боковую панель списка каналов\"],\"tfDRzk\":[\"Сохранить\"],\"tiBsJk\":[\"покинул \",[\"channelName\"]],\"tt4/UD\":[\"вышел (\",[\"reason\"],\")\"],\"u0TcnO\":[\"Ник {nick} уже используется, повторная попытка с {newNick}\"],\"u0a8B4\":[\"Аутентифицироваться как IRC-оператор для административного доступа\"],\"u0rWFU\":[\"Создан после (мин назад)\"],\"u72w3t\":[\"Пользователи и шаблоны для игнорирования\"],\"u7jc2L\":[\"вышел\"],\"uAQUqI\":[\"Статус\"],\"uB85T3\":[\"Ошибка сохранения: \",[\"msg\"]],\"uV3DOL\":[\"G-Line\"],\"uW3lLI\":[\"IRC-серверы:\"],\"usSSr/\":[\"Масштаб\"],\"v7uvcf\":[\"Программа:\"],\"vE8kb+\":[\"Shift+Enter для новой строки (Enter отправляет)\"],\"vERlcd\":[\"Профиль\"],\"vK0RL8\":[\"Без темы\"],\"vSJd18\":[\"Видео\"],\"vXIe7J\":[\"Язык\"],\"vaHYxN\":[\"Настоящее имя\"],\"vhjbKr\":[\"Отсутствую\"],\"w/nogd\":[[\"0\"],\" network\",[\"1\"],\" — pick one to join\"],\"w4NYox\":[\"клиент \",[\"title\"]],\"w8xQRx\":[\"Неверное значение\"],\"wFjjxZ\":[\"был кикнут из \",[\"channelName\"],\" пользователем \",[\"username\"],\" (\",[\"reason\"],\")\"],\"wGjaGl\":[\"Исключения из банов не найдены\"],\"wPrGnM\":[\"Администратор канала\"],\"wRkP2d\":[\"GIF\"],\"wbm86v\":[\"Показывать, когда пользователи входят в каналы или покидают их\"],\"whqZ9r\":[\"Дополнительные слова или фразы для выделения\"],\"wm7RV4\":[\"Звук уведомления\"],\"wz/Yoq\":[\"Ваши сообщения могут быть перехвачены при передаче между серверами\"],\"xCJdfg\":[\"Очистить\"],\"xUHRTR\":[\"Автоматически аутентифицироваться как оператор при подключении\"],\"xWHwwQ\":[\"Баны\"],\"xYilR2\":[\"Медиа\"],\"xceQrO\":[\"Поддерживаются только защищённые WebSocket-соединения\"],\"xdtXa+\":[\"имя-канала\"],\"xfXC7q\":[\"Текстовые каналы\"],\"xlCYOE\":[\"Загрузка сообщений...\"],\"xlhswE\":[\"Минимальное значение: \",[\"0\"]],\"xq97Ci\":[\"Добавить слово или фразу...\"],\"xuRqRq\":[\"Лимит пользователей (+l)\"],\"xwF+7J\":[[\"0\"],\" печатает...\"],\"yJztBY\":[\"Удалить сеть\"],\"yNeucF\":[\"Этот сервер не поддерживает расширенные метаданные профиля (расширение IRCv3 METADATA). Дополнительные поля, такие как аватар, отображаемое имя и статус, недоступны.\"],\"yPlrca\":[\"Аватар канала\"],\"yQE2r9\":[\"Загрузка\"],\"ySU+JY\":[\"your@email.com\"],\"yTX1Rt\":[\"Имя пользователя оператора\"],\"yYOzWD\":[\"логи\"],\"yfx9Re\":[\"Пароль IRC-оператора\"],\"ygCKqB\":[\"Стоп\"],\"ymDxJx\":[\"Имя пользователя IRC-оператора\"],\"yrpRsQ\":[\"Сортировать по имени\"],\"yz7wBu\":[\"Закрыть\"],\"zJw+jA\":[\"устанавливает режим: \",[\"0\"]],\"zebeLu\":[\"Введите имя пользователя оператора\"],\"zpr0Bw\":[\"GZ-Line\"]}"); \ No newline at end of file diff --git a/src/locales/ru/messages.po b/src/locales/ru/messages.po index 0276ec38..211233e9 100644 --- a/src/locales/ru/messages.po +++ b/src/locales/ru/messages.po @@ -22,6 +22,16 @@ msgstr "ObsidianIRC - Перенося IRC в будущее" msgid "— open in viewer" msgstr "— открыть в просмотрщике" +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx +msgid "(inherit)" +msgstr "(наследовать)" + +#: src/components/ui/BouncerNetworkForm.tsx +msgid "(unchanged)" +msgstr "(без изменений)" + #. placeholder {0}: filteredMessages.length #. placeholder {1}: import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import type * as React from "react"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { SCROLL_TOLERANCE, useScrollToBottom, } from "../../hooks/useScrollToBottom"; import { groupConsecutiveEvents } from "../../lib/eventGrouping"; import ircClient from "../../lib/ircClient"; import useStore from "../../store"; import type { Message as MessageType } from "../../types"; import { CollapsedEventMessage } from "../message/CollapsedEventMessage"; import { MessageItem } from "../message/MessageItem"; import LoadingSpinner from "../ui/LoadingSpinner"; import { ScrollToBottomButton } from "../ui/ScrollToBottomButton"; export const DEFAULT_VISIBLE_MESSAGE_COUNT = 100; // Stable empty array — prevents selector from returning a new [] on every render // when the channel has no messages yet (undefined ?? [] would create a new ref each time). const EMPTY_MESSAGES: import("../../types").Message[] = []; export interface ChannelMessageListHandle { setAtBottom: () => void; scrollToBottom: () => void; getScrollState: () => { scrollTop: number; isAtBottom: boolean; visibleCount: number; }; } interface ChannelMessageListProps { channelKey: string; serverId: string; channelId: string | null; privateChatId: string | null; isActive: boolean; searchQuery: string; isMemberListVisible: boolean; onReply: (msg: MessageType | null) => void; onUsernameContextMenu: ( e: React.MouseEvent, username: string, serverId: string, channelId: string, avatarEl?: Element | null, ) => void; onIrcLinkClick: (url: string) => void; onReactClick: (msg: MessageType, el: Element) => void; onReactionUnreact: (emoji: string, msg: MessageType) => void; onOpenReactionModal: ( msg: MessageType, position: { x: number; y: number }, ) => void; onDirectReaction: (emoji: string, msg: MessageType) => void; onRedactMessage: (msg: MessageType) => void; onOpenProfile: (username: string) => void; joinChannel: (serverId: string, channelName: string) => void; onClearSearch: () => void; highlightedMessageId?: string; // undefined = first visit; null = was at bottom; object = restore to saved position initialScrollState?: { scrollTop: number; visibleCount: number } | null; } export const ChannelMessageList = forwardRef< ChannelMessageListHandle, ChannelMessageListProps >( ( { channelKey, serverId, channelId, privateChatId, isActive, searchQuery, isMemberListVisible, onReply, onUsernameContextMenu, onIrcLinkClick, onReactClick, onReactionUnreact, onOpenReactionModal, onDirectReaction, onRedactMessage, onOpenProfile, joinChannel, onClearSearch, highlightedMessageId, initialScrollState, }, ref, ) => { const { t } = useLingui(); const [visibleMessageCount, setVisibleMessageCount] = useState( initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT, ); // Ref mirror so getScrollState closure always reads the current value without needing it as a dep. const visibleMessageCountRef = useRef(visibleMessageCount); visibleMessageCountRef.current = visibleMessageCount; // Distinguishes initial join (full-screen spinner) from subsequent "load more" (button spinner). const [isFetchingMore, setIsFetchingMore] = useState(false); const isFetchingMoreRef = useRef(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const messagesInnerRef = useRef(null); // prev scrollHeight for prepend delta-correction. const prevScrollHeightRef = useRef(0); // Ref mirror of isScrolledUp — lets useLayoutEffect closures read current value // without listing isScrolledUp as a dep (which would re-run effects on every scroll). const isScrolledUpRef = useRef(false); const prevFilteredLengthRef = useRef(0); const prevFirstMsgIdRef = useRef(null); // Set by the window-growth layoutEffect (or button handler) when a true prepend is detected. // Consumed by the delta-correction layoutEffect one render later (after visibleCount grows). // Using a flag instead of tracking displayedMessages[0]?.id because slice(-N) slides the // window on every bottom append, changing displayedMessages[0] even for non-prepend renders. const pendingPrependRef = useRef(false); // Shared scrollHeight baseline between the delta-correction layout effect and the inner // ResizeObserver. When scrollTop is corrected after a prepend, we update this so the RO's // "was at bottom" check is not fooled by the adjusted scrollTop vs its stale prevSH. const resizeObserverPrevSHRef = useRef(0); const channelMessages = useStore( useCallback( (state) => state.messages[channelKey] ?? EMPTY_MESSAGES, [channelKey], ), ); const servers = useStore((state) => state.servers); const mobileViewActiveColumn = useStore( (state) => state.ui.mobileViewActiveColumn, ); const channel = useMemo( () => channelId ? (servers .find((s) => s.id === serverId) ?.channels.find((c) => c.id === channelId) ?? null) : null, [servers, serverId, channelId], ); const { isScrolledUp, wasAtBottomRef, scrollToBottom } = useScrollToBottom( messagesContainerRef, messagesEndRef, { channelId: `${channelId || privateChatId}-${isMemberListVisible}` }, ); // Snapshot of the last known scroll position captured while the container was visible. // getScrollState() reads this instead of the live DOM because React commits display:none // before running cleanup effects, collapsing scrollTop/scrollHeight/clientHeight to 0. const lastScrollTopRef = useRef(initialScrollState?.scrollTop ?? 0); useEffect(() => { const container = messagesContainerRef.current; if (!container) return; const onScroll = () => { if (container.clientHeight > 0) lastScrollTopRef.current = container.scrollTop; }; container.addEventListener("scroll", onScroll, { passive: true }); return () => container.removeEventListener("scroll", onScroll); }, []); // Restore scroll position when a keep-alive channel transitions from hidden to visible. // display:none may reset scrollTop to 0; lastScrollTopRef was captured while visible. const prevActiveRef = useRef(isActive); useLayoutEffect(() => { if (isActive && !prevActiveRef.current) { const container = messagesContainerRef.current; if (container && lastScrollTopRef.current > 0) { container.scrollTop = lastScrollTopRef.current; } } prevActiveRef.current = isActive; }, [isActive]); useImperativeHandle(ref, () => ({ setAtBottom: () => { wasAtBottomRef.current = true; }, scrollToBottom, getScrollState: () => ({ scrollTop: lastScrollTopRef.current, isAtBottom: wasAtBottomRef.current, visibleCount: visibleMessageCountRef.current, }), })); const filteredMessages = useMemo(() => { if (!searchQuery.trim()) return channelMessages; const query = searchQuery.toLowerCase(); return channelMessages.filter( (msg) => msg.content.toLowerCase().includes(query) || msg.userId.toLowerCase().includes(query), ); }, [channelMessages, searchQuery]); useEffect(() => { isScrolledUpRef.current = isScrolledUp; // When the user returns to the bottom, shrink the window back to the base so // slice(-N) resumes trimming old messages from the top (memory optimization). // Only shrink if we grew above the base — preserves a sub-default saved visibleCount. if (!isScrolledUp) { setVisibleMessageCount((prev) => prev > DEFAULT_VISIBLE_MESSAGE_COUNT ? DEFAULT_VISIBLE_MESSAGE_COUNT : prev, ); } }, [isScrolledUp]); // Reset ref-tracked windowing state when switching channels. // visibleMessageCount is NOT reset here — useState(initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT) // already initializes it correctly on mount, and this effect runs once on mount for the // same channelKey (each instance is bound to exactly one channel by the parent key={}). // biome-ignore lint/correctness/useExhaustiveDependencies: intentional full reset on channel change useEffect(() => { prevFilteredLengthRef.current = 0; prevFirstMsgIdRef.current = null; prevScrollHeightRef.current = 0; pendingPrependRef.current = false; resizeObserverPrevSHRef.current = 0; }, [channelKey]); const displayedMessages = useMemo(() => { if (searchQuery.trim()) return filteredMessages; return filteredMessages.slice(-visibleMessageCount); }, [filteredMessages, visibleMessageCount, searchQuery]); const locallyHidden = filteredMessages.length > displayedMessages.length; const serverHasMore = channel?.hasMoreHistory === true; const hasMoreMessages = locallyHidden || serverHasMore; const eventGroups = useMemo( () => groupConsecutiveEvents(displayedMessages), [displayedMessages], ); const isLoadingHistory = channel?.isLoadingHistory ?? false; // Scroll to bottom on initial mount, unless a saved position was passed in. // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount only useEffect(() => { const container = messagesContainerRef.current; if (!container) return; if (initialScrollState) { container.scrollTop = initialScrollState.scrollTop; lastScrollTopRef.current = initialScrollState.scrollTop; wasAtBottomRef.current = false; } else { container.scrollTop = container.scrollHeight; lastScrollTopRef.current = container.scrollHeight; wasAtBottomRef.current = true; } }, []); // Scroll to bottom after initial join history loads; clear fetch spinner at batch end. const wasLoadingHistoryRef = useRef(false); // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is stable via useCallback; refs and setters are stable useLayoutEffect(() => { if (wasLoadingHistoryRef.current && !isLoadingHistory) { if (isFetchingMoreRef.current) { // delta correction for scroll position is handled by useLayoutEffect([displayedMessages]) isFetchingMoreRef.current = false; setIsFetchingMore(false); } else { scrollToBottom(); wasAtBottomRef.current = true; } } wasLoadingHistoryRef.current = isLoadingHistory; }, [isLoadingHistory]); // When older messages are prepended, grow the window so they enter displayedMessages. // When new messages arrive at the bottom while the user is scrolled up, also grow the // window to keep the current top messages visible — slice(-N) otherwise slides the // window forward and hides them, incrementing the "N older messages" counter on every // incoming message. Only let the slice trim from the top when the user is at the bottom // (where auto-scroll handles keeping them current). useLayoutEffect(() => { const newLength = filteredMessages.length; const newFirstId = filteredMessages[0]?.id ?? null; const delta = newLength - prevFilteredLengthRef.current; if (prevFilteredLengthRef.current > 0 && delta > 0) { if (newFirstId !== prevFirstMsgIdRef.current) { // Messages prepended (load-more): signal delta-correction to compensate scrollTop. pendingPrependRef.current = true; setVisibleMessageCount((prev) => prev + delta); } else if (isScrolledUpRef.current) { // Messages appended at bottom while user is scrolled up reading history. // Expand the window to prevent top messages from dropping out of the slice. setVisibleMessageCount((prev) => prev + delta); } } prevFilteredLengthRef.current = newLength; prevFirstMsgIdRef.current = newFirstId; }, [filteredMessages]); // Compensate scrollTop when content is prepended above the viewport. // biome-ignore lint/correctness/useExhaustiveDependencies: runs on every displayedMessages render to capture the resulting scrollHeight; refs are stable useLayoutEffect(() => { const container = messagesContainerRef.current; if (!container) return; // Skip while container is display:none — scrollHeight collapses to 0 and would // poison prevScrollHeightRef, causing a huge spurious delta on the next visible render. if (container.clientHeight === 0) return; const prevHeight = prevScrollHeightRef.current; const newHeight = container.scrollHeight; // Only correct when a true load-more prepend happened (flag set by the window-growth // layoutEffect or button handler). Bottom appends slide the slice(-N) window which also // changes displayedMessages[0] — ID-comparison can't distinguish the two cases. const wasPrepend = pendingPrependRef.current; // Only consume the flag when scrollHeight actually changed — the server-side load-more // path goes through two renders: Render A (filteredMessages grows, visibleCount unchanged, // same displayedMessages content, same scrollHeight) then Render B (visibleCount grows, // new messages enter displayedMessages, scrollHeight grows). The flag must survive Render A // so it's still set when Render B fires the actual correction. if (wasPrepend && newHeight !== prevHeight) { pendingPrependRef.current = false; } if ( isScrolledUpRef.current && prevHeight > 0 && newHeight > prevHeight && wasPrepend ) { const delta = newHeight - prevHeight; container.scrollTop += delta; resizeObserverPrevSHRef.current = newHeight; } prevScrollHeightRef.current = newHeight; }, [displayedMessages]); // Re-stick to bottom when inner message content grows (media/audio previews loading). // Uses prevScrollHeight instead of wasAtBottomRef to avoid stale-flag race where the // ref is true while the user is actively scrolling up. // When the container width changes (member list toggle, window resize), text reflows // and scrollHeight changes; preserve proportional scroll position for scrolled-up users. // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is a stable ref useEffect(() => { const container = messagesContainerRef.current; const inner = messagesInnerRef.current; if (!inner || !container) return; resizeObserverPrevSHRef.current = container.scrollHeight; let prevClientWidth = container.clientWidth; const observer = new ResizeObserver(() => { if (container.clientHeight === 0) return; // Effect may re-initialize while container is display:none (ref=0). // Re-seed with current dimensions and skip — no reliable "was at bottom" data. if (resizeObserverPrevSHRef.current === 0) { resizeObserverPrevSHRef.current = container.scrollHeight; prevClientWidth = container.clientWidth; return; } const currentClientWidth = container.clientWidth; const widthChanged = currentClientWidth !== prevClientWidth; prevClientWidth = currentClientWidth; const prevSH = resizeObserverPrevSHRef.current; const wasAtPrevBottom = container.scrollTop + container.clientHeight >= prevSH - SCROLL_TOLERANCE; resizeObserverPrevSHRef.current = container.scrollHeight; if (wasAtPrevBottom) { scrollToBottom(); } else if (widthChanged && prevSH > 0) { const ratio = container.scrollTop / prevSH; container.scrollTop = Math.round(ratio * container.scrollHeight); } }); observer.observe(inner); return () => observer.disconnect(); }, [isLoadingHistory, channelId, privateChatId]); // Auto-scroll on new messages — skip when this channel is hidden (display:none). // biome-ignore lint/correctness/useExhaustiveDependencies: only scroll when messages change, not when isActive changes useEffect(() => { if (!isActive) return; const isNarrowView = window.matchMedia("(max-width: 768px)").matches; const isChatVisible = !isNarrowView || mobileViewActiveColumn === "chatView"; if (wasAtBottomRef.current && isChatVisible) { scrollToBottom(); } }, [displayedMessages, mobileViewActiveColumn, scrollToBottom, isActive]); return ( <>
{isLoadingHistory && !isFetchingMore ? (
) : (
{hasMoreMessages && !searchQuery && (
)} {searchQuery && (
{plural(filteredMessages.length, { one: t`Found 1 message matching "${searchQuery}"`, other: t`Found ${filteredMessages.length} messages matching "${searchQuery}"`, })}
)} {eventGroups.map((group) => { if (group.type === "eventGroup") { const firstId = group.messages[0]?.id || ""; const lastId = group.messages[group.messages.length - 1]?.id || ""; const groupKey = `group-${firstId}-${lastId}`; return ( ); } const message = group.messages[0]; const originalIndex = channelMessages.findIndex( (m) => m.id === message.id, ); const previousMessage = channelMessages[originalIndex - 1]; const showHeader = !previousMessage || previousMessage.type !== "message" || previousMessage.userId !== message.userId || new Date(message.timestamp).getTime() - new Date(previousMessage.timestamp).getTime() > 5 * 60 * 1000; return ( ); })}
)}
); }, ); ChannelMessageList.displayName = "ChannelMessageList"; // Wrap with memo so hidden keep-alive channels skip re-renders when their props // haven't changed (e.g. when messageText changes in the input — the only thing // that changes on typing is local state inside ChatArea, not the props we pass here). export const MemoChannelMessageList = memo(ChannelMessageList); #. placeholder {2}: import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import type * as React from "react"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { SCROLL_TOLERANCE, useScrollToBottom, } from "../../hooks/useScrollToBottom"; import { groupConsecutiveEvents } from "../../lib/eventGrouping"; import ircClient from "../../lib/ircClient"; import useStore from "../../store"; import type { Message as MessageType } from "../../types"; import { CollapsedEventMessage } from "../message/CollapsedEventMessage"; import { MessageItem } from "../message/MessageItem"; import LoadingSpinner from "../ui/LoadingSpinner"; import { ScrollToBottomButton } from "../ui/ScrollToBottomButton"; export const DEFAULT_VISIBLE_MESSAGE_COUNT = 100; // Stable empty array — prevents selector from returning a new [] on every render // when the channel has no messages yet (undefined ?? [] would create a new ref each time). const EMPTY_MESSAGES: import("../../types").Message[] = []; export interface ChannelMessageListHandle { setAtBottom: () => void; scrollToBottom: () => void; getScrollState: () => { scrollTop: number; isAtBottom: boolean; visibleCount: number; }; } interface ChannelMessageListProps { channelKey: string; serverId: string; channelId: string | null; privateChatId: string | null; isActive: boolean; searchQuery: string; isMemberListVisible: boolean; onReply: (msg: MessageType | null) => void; onUsernameContextMenu: ( e: React.MouseEvent, username: string, serverId: string, channelId: string, avatarEl?: Element | null, ) => void; onIrcLinkClick: (url: string) => void; onReactClick: (msg: MessageType, el: Element) => void; onReactionUnreact: (emoji: string, msg: MessageType) => void; onOpenReactionModal: ( msg: MessageType, position: { x: number; y: number }, ) => void; onDirectReaction: (emoji: string, msg: MessageType) => void; onRedactMessage: (msg: MessageType) => void; onOpenProfile: (username: string) => void; joinChannel: (serverId: string, channelName: string) => void; onClearSearch: () => void; highlightedMessageId?: string; // undefined = first visit; null = was at bottom; object = restore to saved position initialScrollState?: { scrollTop: number; visibleCount: number } | null; } export const ChannelMessageList = forwardRef< ChannelMessageListHandle, ChannelMessageListProps >( ( { channelKey, serverId, channelId, privateChatId, isActive, searchQuery, isMemberListVisible, onReply, onUsernameContextMenu, onIrcLinkClick, onReactClick, onReactionUnreact, onOpenReactionModal, onDirectReaction, onRedactMessage, onOpenProfile, joinChannel, onClearSearch, highlightedMessageId, initialScrollState, }, ref, ) => { const { t } = useLingui(); const [visibleMessageCount, setVisibleMessageCount] = useState( initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT, ); // Ref mirror so getScrollState closure always reads the current value without needing it as a dep. const visibleMessageCountRef = useRef(visibleMessageCount); visibleMessageCountRef.current = visibleMessageCount; // Distinguishes initial join (full-screen spinner) from subsequent "load more" (button spinner). const [isFetchingMore, setIsFetchingMore] = useState(false); const isFetchingMoreRef = useRef(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const messagesInnerRef = useRef(null); // prev scrollHeight for prepend delta-correction. const prevScrollHeightRef = useRef(0); // Ref mirror of isScrolledUp — lets useLayoutEffect closures read current value // without listing isScrolledUp as a dep (which would re-run effects on every scroll). const isScrolledUpRef = useRef(false); const prevFilteredLengthRef = useRef(0); const prevFirstMsgIdRef = useRef(null); // Set by the window-growth layoutEffect (or button handler) when a true prepend is detected. // Consumed by the delta-correction layoutEffect one render later (after visibleCount grows). // Using a flag instead of tracking displayedMessages[0]?.id because slice(-N) slides the // window on every bottom append, changing displayedMessages[0] even for non-prepend renders. const pendingPrependRef = useRef(false); // Shared scrollHeight baseline between the delta-correction layout effect and the inner // ResizeObserver. When scrollTop is corrected after a prepend, we update this so the RO's // "was at bottom" check is not fooled by the adjusted scrollTop vs its stale prevSH. const resizeObserverPrevSHRef = useRef(0); const channelMessages = useStore( useCallback( (state) => state.messages[channelKey] ?? EMPTY_MESSAGES, [channelKey], ), ); const servers = useStore((state) => state.servers); const mobileViewActiveColumn = useStore( (state) => state.ui.mobileViewActiveColumn, ); const channel = useMemo( () => channelId ? (servers .find((s) => s.id === serverId) ?.channels.find((c) => c.id === channelId) ?? null) : null, [servers, serverId, channelId], ); const { isScrolledUp, wasAtBottomRef, scrollToBottom } = useScrollToBottom( messagesContainerRef, messagesEndRef, { channelId: `${channelId || privateChatId}-${isMemberListVisible}` }, ); // Snapshot of the last known scroll position captured while the container was visible. // getScrollState() reads this instead of the live DOM because React commits display:none // before running cleanup effects, collapsing scrollTop/scrollHeight/clientHeight to 0. const lastScrollTopRef = useRef(initialScrollState?.scrollTop ?? 0); useEffect(() => { const container = messagesContainerRef.current; if (!container) return; const onScroll = () => { if (container.clientHeight > 0) lastScrollTopRef.current = container.scrollTop; }; container.addEventListener("scroll", onScroll, { passive: true }); return () => container.removeEventListener("scroll", onScroll); }, []); // Restore scroll position when a keep-alive channel transitions from hidden to visible. // display:none may reset scrollTop to 0; lastScrollTopRef was captured while visible. const prevActiveRef = useRef(isActive); useLayoutEffect(() => { if (isActive && !prevActiveRef.current) { const container = messagesContainerRef.current; if (container && lastScrollTopRef.current > 0) { container.scrollTop = lastScrollTopRef.current; } } prevActiveRef.current = isActive; }, [isActive]); useImperativeHandle(ref, () => ({ setAtBottom: () => { wasAtBottomRef.current = true; }, scrollToBottom, getScrollState: () => ({ scrollTop: lastScrollTopRef.current, isAtBottom: wasAtBottomRef.current, visibleCount: visibleMessageCountRef.current, }), })); const filteredMessages = useMemo(() => { if (!searchQuery.trim()) return channelMessages; const query = searchQuery.toLowerCase(); return channelMessages.filter( (msg) => msg.content.toLowerCase().includes(query) || msg.userId.toLowerCase().includes(query), ); }, [channelMessages, searchQuery]); useEffect(() => { isScrolledUpRef.current = isScrolledUp; // When the user returns to the bottom, shrink the window back to the base so // slice(-N) resumes trimming old messages from the top (memory optimization). // Only shrink if we grew above the base — preserves a sub-default saved visibleCount. if (!isScrolledUp) { setVisibleMessageCount((prev) => prev > DEFAULT_VISIBLE_MESSAGE_COUNT ? DEFAULT_VISIBLE_MESSAGE_COUNT : prev, ); } }, [isScrolledUp]); // Reset ref-tracked windowing state when switching channels. // visibleMessageCount is NOT reset here — useState(initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT) // already initializes it correctly on mount, and this effect runs once on mount for the // same channelKey (each instance is bound to exactly one channel by the parent key={}). // biome-ignore lint/correctness/useExhaustiveDependencies: intentional full reset on channel change useEffect(() => { prevFilteredLengthRef.current = 0; prevFirstMsgIdRef.current = null; prevScrollHeightRef.current = 0; pendingPrependRef.current = false; resizeObserverPrevSHRef.current = 0; }, [channelKey]); const displayedMessages = useMemo(() => { if (searchQuery.trim()) return filteredMessages; return filteredMessages.slice(-visibleMessageCount); }, [filteredMessages, visibleMessageCount, searchQuery]); const locallyHidden = filteredMessages.length > displayedMessages.length; const serverHasMore = channel?.hasMoreHistory === true; const hasMoreMessages = locallyHidden || serverHasMore; const eventGroups = useMemo( () => groupConsecutiveEvents(displayedMessages), [displayedMessages], ); const isLoadingHistory = channel?.isLoadingHistory ?? false; // Scroll to bottom on initial mount, unless a saved position was passed in. // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount only useEffect(() => { const container = messagesContainerRef.current; if (!container) return; if (initialScrollState) { container.scrollTop = initialScrollState.scrollTop; lastScrollTopRef.current = initialScrollState.scrollTop; wasAtBottomRef.current = false; } else { container.scrollTop = container.scrollHeight; lastScrollTopRef.current = container.scrollHeight; wasAtBottomRef.current = true; } }, []); // Scroll to bottom after initial join history loads; clear fetch spinner at batch end. const wasLoadingHistoryRef = useRef(false); // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is stable via useCallback; refs and setters are stable useLayoutEffect(() => { if (wasLoadingHistoryRef.current && !isLoadingHistory) { if (isFetchingMoreRef.current) { // delta correction for scroll position is handled by useLayoutEffect([displayedMessages]) isFetchingMoreRef.current = false; setIsFetchingMore(false); } else { scrollToBottom(); wasAtBottomRef.current = true; } } wasLoadingHistoryRef.current = isLoadingHistory; }, [isLoadingHistory]); // When older messages are prepended, grow the window so they enter displayedMessages. // When new messages arrive at the bottom while the user is scrolled up, also grow the // window to keep the current top messages visible — slice(-N) otherwise slides the // window forward and hides them, incrementing the "N older messages" counter on every // incoming message. Only let the slice trim from the top when the user is at the bottom // (where auto-scroll handles keeping them current). useLayoutEffect(() => { const newLength = filteredMessages.length; const newFirstId = filteredMessages[0]?.id ?? null; const delta = newLength - prevFilteredLengthRef.current; if (prevFilteredLengthRef.current > 0 && delta > 0) { if (newFirstId !== prevFirstMsgIdRef.current) { // Messages prepended (load-more): signal delta-correction to compensate scrollTop. pendingPrependRef.current = true; setVisibleMessageCount((prev) => prev + delta); } else if (isScrolledUpRef.current) { // Messages appended at bottom while user is scrolled up reading history. // Expand the window to prevent top messages from dropping out of the slice. setVisibleMessageCount((prev) => prev + delta); } } prevFilteredLengthRef.current = newLength; prevFirstMsgIdRef.current = newFirstId; }, [filteredMessages]); // Compensate scrollTop when content is prepended above the viewport. // biome-ignore lint/correctness/useExhaustiveDependencies: runs on every displayedMessages render to capture the resulting scrollHeight; refs are stable useLayoutEffect(() => { const container = messagesContainerRef.current; if (!container) return; // Skip while container is display:none — scrollHeight collapses to 0 and would // poison prevScrollHeightRef, causing a huge spurious delta on the next visible render. if (container.clientHeight === 0) return; const prevHeight = prevScrollHeightRef.current; const newHeight = container.scrollHeight; // Only correct when a true load-more prepend happened (flag set by the window-growth // layoutEffect or button handler). Bottom appends slide the slice(-N) window which also // changes displayedMessages[0] — ID-comparison can't distinguish the two cases. const wasPrepend = pendingPrependRef.current; // Only consume the flag when scrollHeight actually changed — the server-side load-more // path goes through two renders: Render A (filteredMessages grows, visibleCount unchanged, // same displayedMessages content, same scrollHeight) then Render B (visibleCount grows, // new messages enter displayedMessages, scrollHeight grows). The flag must survive Render A // so it's still set when Render B fires the actual correction. if (wasPrepend && newHeight !== prevHeight) { pendingPrependRef.current = false; } if ( isScrolledUpRef.current && prevHeight > 0 && newHeight > prevHeight && wasPrepend ) { const delta = newHeight - prevHeight; container.scrollTop += delta; resizeObserverPrevSHRef.current = newHeight; } prevScrollHeightRef.current = newHeight; }, [displayedMessages]); // Re-stick to bottom when inner message content grows (media/audio previews loading). // Uses prevScrollHeight instead of wasAtBottomRef to avoid stale-flag race where the // ref is true while the user is actively scrolling up. // When the container width changes (member list toggle, window resize), text reflows // and scrollHeight changes; preserve proportional scroll position for scrolled-up users. // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is a stable ref useEffect(() => { const container = messagesContainerRef.current; const inner = messagesInnerRef.current; if (!inner || !container) return; resizeObserverPrevSHRef.current = container.scrollHeight; let prevClientWidth = container.clientWidth; const observer = new ResizeObserver(() => { if (container.clientHeight === 0) return; // Effect may re-initialize while container is display:none (ref=0). // Re-seed with current dimensions and skip — no reliable "was at bottom" data. if (resizeObserverPrevSHRef.current === 0) { resizeObserverPrevSHRef.current = container.scrollHeight; prevClientWidth = container.clientWidth; return; } const currentClientWidth = container.clientWidth; const widthChanged = currentClientWidth !== prevClientWidth; prevClientWidth = currentClientWidth; const prevSH = resizeObserverPrevSHRef.current; const wasAtPrevBottom = container.scrollTop + container.clientHeight >= prevSH - SCROLL_TOLERANCE; resizeObserverPrevSHRef.current = container.scrollHeight; if (wasAtPrevBottom) { scrollToBottom(); } else if (widthChanged && prevSH > 0) { const ratio = container.scrollTop / prevSH; container.scrollTop = Math.round(ratio * container.scrollHeight); } }); observer.observe(inner); return () => observer.disconnect(); }, [isLoadingHistory, channelId, privateChatId]); // Auto-scroll on new messages — skip when this channel is hidden (display:none). // biome-ignore lint/correctness/useExhaustiveDependencies: only scroll when messages change, not when isActive changes useEffect(() => { if (!isActive) return; const isNarrowView = window.matchMedia("(max-width: 768px)").matches; const isChatVisible = !isNarrowView || mobileViewActiveColumn === "chatView"; if (wasAtBottomRef.current && isChatVisible) { scrollToBottom(); } }, [displayedMessages, mobileViewActiveColumn, scrollToBottom, isActive]); return ( <>
{isLoadingHistory && !isFetchingMore ? (
) : (
{hasMoreMessages && !searchQuery && (
)} {searchQuery && (
{plural(filteredMessages.length, { one: t`Found 1 message matching "${searchQuery}"`, other: t`Found ${filteredMessages.length} messages matching "${searchQuery}"`, })}
)} {eventGroups.map((group) => { if (group.type === "eventGroup") { const firstId = group.messages[0]?.id || ""; const lastId = group.messages[group.messages.length - 1]?.id || ""; const groupKey = `group-${firstId}-${lastId}`; return ( ); } const message = group.messages[0]; const originalIndex = channelMessages.findIndex( (m) => m.id === message.id, ); const previousMessage = channelMessages[originalIndex - 1]; const showHeader = !previousMessage || previousMessage.type !== "message" || previousMessage.userId !== message.userId || new Date(message.timestamp).getTime() - new Date(previousMessage.timestamp).getTime() > 5 * 60 * 1000; return ( ); })}
)}
); }, ); ChannelMessageList.displayName = "ChannelMessageList"; // Wrap with memo so hidden keep-alive channels skip re-renders when their props // haven't changed (e.g. when messageText changes in the input — the only thing // that changes on typing is local state inside ChatArea, not the props we pass here). export const MemoChannelMessageList = memo(ChannelMessageList); @@ -46,6 +56,12 @@ msgstr "{0} и {1} печатают..." msgid "{0} is typing..." msgstr "{0} печатает..." +#. placeholder {0}: networks.length +#. placeholder {1}: networks.length === 1 ? "" : "s" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "{0} network{1} — pick one to join" +msgstr "" + #. placeholder {0}: filteredMessages.length - displayedMessages.length #: src/components/layout/ChannelMessageList.tsx msgid "{0} older messages" @@ -186,6 +202,12 @@ msgstr "Добавить маску приглашения (например: ni msgid "Add IRC Server" msgstr "Добавить IRC-сервер" +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworksPanel.tsx +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Add Network" +msgstr "Добавить сеть" + #: src/components/message/MessageActions.tsx #: src/components/message/MessageReactions.tsx #: src/components/message/MessageReactions.tsx @@ -205,6 +227,10 @@ msgstr "Добавить правило" msgid "Add Server" msgstr "Добавить сервер" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Add your first network" +msgstr "Добавьте вашу первую сеть" + #: src/components/message/JsonLogMessage.tsx msgid "Additional Details" msgstr "Подробности" @@ -358,6 +384,10 @@ msgstr "Назад" msgid "Back to image" msgstr "Вернуться к изображению" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Back to network list" +msgstr "" + #: src/components/ui/ModerationModal.tsx msgid "Ban {username} by hostmask (prevents them from rejoining from the same IP/host)" msgstr "Забанить {username} по hostmask (запрещает переподключение с того же IP/хоста)" @@ -405,6 +435,8 @@ msgstr "Просмотреть все каналы на сервере" #: src/components/ui/AddPrivateChatModal.tsx #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/ChannelSettingsModal.tsx #: src/components/ui/ExternalLinkWarningModal.tsx #: src/components/ui/FloodSettingsModal.tsx @@ -640,6 +672,7 @@ msgid "Configure notification sounds and highlights" msgstr "Настройка звуков уведомлений и выделений" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworksPanel.tsx msgid "Connect" msgstr "Подключиться" @@ -759,6 +792,10 @@ msgstr "Удалить канал" msgid "Delete message" msgstr "Удалить сообщение" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Delete network" +msgstr "Удалить сеть" + #: src/components/layout/ChannelList.tsx msgid "Delete Private Chat" msgstr "Удалить личную переписку" @@ -767,6 +804,10 @@ msgstr "Удалить личную переписку" msgid "Delete this message? This cannot be undone." msgstr "Удалить это сообщение? Это действие нельзя отменить." +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Delete this network?" +msgstr "Удалить эту сеть?" + #: src/components/layout/ServerList.tsx #: src/components/mobile/ServerBottomSheet.tsx msgid "Disconnect" @@ -830,10 +871,16 @@ msgstr "Скачать" msgid "e.g., 100:1440" msgstr "например: 100:1440" +#: src/components/ui/BouncerNetworksPanel.tsx #: src/components/ui/ChannelSettingsModal.tsx msgid "Edit" msgstr "Изменить" +#. placeholder {0}: editingNetwork?.attributes.name || editingNetwork?.netid +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Edit {0}" +msgstr "Изменить {0}" + #: src/components/ui/UserProfileModal.tsx msgid "Edit Profile" msgstr "Редактировать профиль" @@ -1057,6 +1104,7 @@ msgstr "ГЛАВНАЯ" msgid "Homepage" msgstr "Сайт" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserProfileModal.tsx msgid "Host" msgstr "Хост" @@ -1271,6 +1319,10 @@ msgstr "Покинул канал" msgid "Let others know when you are typing" msgstr "Сообщать другим, когда вы печатаете" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Libera Chat" +msgstr "Libera Chat" + #: src/components/message/LinkPreview.tsx msgid "Link preview" msgstr "Предпросмотр ссылки" @@ -1299,6 +1351,10 @@ msgstr "Загрузка GIF..." msgid "Loading more channels..." msgstr "Загрузка дополнительных каналов..." +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Loading networks from your bouncer…" +msgstr "Загрузка сетей с вашего баунсера…" + #: src/components/ui/UserProfileModal.tsx msgid "Loading WHOIS data..." msgstr "Загрузка данных WHOIS..." @@ -1486,9 +1542,15 @@ msgid "Name:" msgstr "Имя:" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx msgid "Network Name" msgstr "Название сети" +#. placeholder {0}: server?.name ?? bouncerServerId +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Networks on {0}" +msgstr "Сети на {0}" + #: src/components/ui/QuickActions.tsx msgid "New DM" msgstr "Новое DM" @@ -1511,6 +1573,7 @@ msgid "nick!user@host (e.g., spam*!*@*, *!*@badhost.com)" msgstr "nick!user@host (например: spam*!*@*, *!*@badhost.com)" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserSettings.tsx #: src/lib/settings/definitions/allSettings.ts msgid "Nickname" @@ -1570,6 +1633,10 @@ msgstr "Файл не выбран" msgid "No flood profile" msgstr "Без профиля флуда" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "no host set" +msgstr "хост не задан" + #: src/components/ui/ChannelSettingsModal.tsx msgid "No invitations found" msgstr "Приглашения не найдены" @@ -1610,6 +1677,10 @@ msgstr "Тема не задана" msgid "No unread mentions or messages" msgstr "Нет непрочитанных упоминаний или сообщений" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "No upstream networks yet." +msgstr "Пока нет вышестоящих сетей." + #: src/components/ui/AddPrivateChatModal.tsx msgid "No users available" msgstr "Нет доступных пользователей" @@ -1696,6 +1767,10 @@ msgstr "Упс! Разрыв сети! ⚠️" msgid "Op" msgstr "Op" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Open" +msgstr "" + #: src/components/ui/QuickActions/uiActionConfig.tsx msgid "Open channel configuration settings" msgstr "Открыть настройки конфигурации канала" @@ -1799,6 +1874,10 @@ msgstr "Закрепить личный чат" msgid "Pin this private message conversation" msgstr "Закрепить эту личную переписку" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Plaintext" +msgstr "Открытый текст" + #: src/components/message/MediaPreview.tsx #: src/components/message/MediaPreview.tsx #: src/components/message/MediaPreview.tsx @@ -1827,6 +1906,7 @@ msgid "PM User" msgstr "Написать в личку" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx msgid "Port" msgstr "Порт" @@ -1918,6 +1998,7 @@ msgstr "отреагировал на это сообщение" msgid "Read more" msgstr "Читать далее" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserProfileModal.tsx #: src/components/ui/UserSettings.tsx #: src/lib/settings/definitions/allSettings.ts @@ -2002,6 +2083,7 @@ msgstr "Правила" msgid "Safe" msgstr "Безопасно" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/ChannelSettingsModal.tsx #: src/components/ui/TopicModal.tsx #: src/components/ui/UserSettings.tsx @@ -2183,6 +2265,10 @@ msgstr "Операторы серверов сети потенциально м msgid "Server Password" msgstr "Пароль сервера" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Server Password (PASS)" +msgstr "Пароль сервера (PASS)" + #: src/components/ui/LinkSecurityWarningModal.tsx msgid "Server-to-server communication may use unencrypted connections" msgstr "Межсерверное взаимодействие может использовать незашифрованные соединения" @@ -2378,6 +2464,10 @@ msgstr "Время (мин)" msgid "Time Window (seconds)" msgstr "Временное окно (секунды)" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "TLS" +msgstr "TLS" + #: src/components/message/WhisperMessage.tsx #: src/components/message/WhisperMessage.tsx msgid "to" @@ -2426,6 +2516,10 @@ msgstr "Тема:" msgid "Total: {0}" msgstr "Всего: {0}" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Transport" +msgstr "Транспорт" + #: src/components/ui/UserSettings.tsx msgid "Trusted Sources" msgstr "Доверенные источники" @@ -2536,6 +2630,7 @@ msgstr "Профиль пользователя" msgid "User Settings" msgstr "Настройки пользователя" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/InviteUserModal.tsx #: src/components/ui/ModerationModal.tsx msgid "Username" @@ -2683,6 +2778,10 @@ msgstr "Широкий — более широкая область защиты msgid "Will default to 'no reason' if left empty" msgstr "Если оставить пустым, будет использоваться «без причины»" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Yes, delete" +msgstr "Да, удалить" + #: src/components/message/CollapsedEventMessage.tsx #: src/components/message/EventMessage.tsx msgid "You" @@ -2713,6 +2812,10 @@ msgstr "Пароль вашего аккаунта для аутентифика msgid "Your account username for authentication" msgstr "Имя пользователя вашего аккаунта для аутентификации" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Your bouncer doesn't have any networks yet. Add one to get started." +msgstr "У вашего баунсера ещё нет сетей. Добавьте одну, чтобы начать." + #: src/lib/settings/definitions/allSettings.ts msgid "Your default nickname for all servers" msgstr "Ваш никнейм по умолчанию для всех серверов" diff --git a/src/locales/sv/messages.mjs b/src/locales/sv/messages.mjs index 764efee3..ae06199a 100644 --- a/src/locales/sv/messages.mjs +++ b/src/locales/sv/messages.mjs @@ -1 +1 @@ -/*eslint-disable*/export const messages=JSON.parse("{\"+5VMrz\":[\"Ogiltigt mönsterformat. Använd formatet nick!user@host (jokertecken * tillåts)\"],\"+6NQQA\":[\"Allmän supportkanal\"],\"+6NyRG\":[\"Klient\"],\"+K0AvT\":[\"Koppla från\"],\"+cyFdH\":[\"Standardmeddelande när du markerar dig som borta\"],\"+mVPqU\":[\"Rendera markdown-formatering i meddelanden\"],\"+vqCJH\":[\"Ditt kontoanvändarnamn för autentisering\"],\"+yPBXI\":[\"Välj fil\"],\"+zy2Nq\":[\"Typ\"],\"/09cao\":[\"Låg länksäkerhet (nivå \",[\"securityLevel\"],\")\"],\"/3BQ4J\":[\"Användare utanför kanalen kan inte skicka meddelanden till den\"],\"/6BzZF\":[\"Växla medlemslista\"],\"/TNOPk\":[\"Användaren är borta\"],\"/XQgft\":[\"Utforska\"],\"/cF7Rs\":[\"Volym\"],\"/dqduX\":[\"Nästa sida\"],\"/fc3q4\":[\"Allt innehåll\"],\"/kISDh\":[\"Aktivera aviseringsljud\"],\"/n04sB\":[\"Kill\"],\"/rTz0M\":[\"Ljud\"],\"/rfkZe\":[\"Spela upp ljud för omnämnanden och meddelanden\"],\"0/0ZGA\":[\"Kanalnamns-mask\"],\"0D6j7U\":[\"Läs mer om anpassade regler →\"],\"0XsHcR\":[\"Sparka ut användare\"],\"0ZpE//\":[\"Sortera efter användare\"],\"0bEPwz\":[\"Ange borta\"],\"0dGkPt\":[\"Expandera kanallistan\"],\"0gS7M5\":[\"Visningsnamn\"],\"0kS+M8\":[\"ExempelNÄT\"],\"0rgoY7\":[\"Anslut bara till servrar du väljer\"],\"0wdd7X\":[\"Gå med\"],\"0wkVYx\":[\"Privata meddelanden\"],\"111uHX\":[\"Länkförhandsvisning\"],\"196EG4\":[\"Ta bort privatchatt\"],\"1DSr1i\":[\"Registrera ett konto\"],\"1O/24y\":[\"Växla kanallista\"],\"1VPJJ2\":[\"Varning för extern länk\"],\"1ZC/dv\":[\"Inga olästa omnämnanden eller meddelanden\"],\"1pO1zi\":[\"Servernamn krävs\"],\"1uwfzQ\":[\"Visa kanalämne\"],\"268g7c\":[\"Ange visningsnamn\"],\"2FOFq1\":[\"Serveroperatörer på nätverket kan potentiellt läsa dina meddelanden\"],\"2FYpfJ\":[\"Mer\"],\"2HF1Y2\":[[\"inviter\"],\" bjöd in \",[\"target\"],\" att gå med i \",[\"channel\"]],\"2I70QL\":[\"Visa användarprofilinformation\"],\"2QYdmE\":[\"Användare:\"],\"2QpEjG\":[\"lämnade\"],\"2YE223\":[\"Meddelande #\",[\"0\"],\" (Enter för ny rad, Shift+Enter för att skicka)\"],\"2bimFY\":[\"Använd serverlösenord\"],\"2iTmdZ\":[\"Lokal lagring:\"],\"2odkwe\":[\"Strikt – mer aggressivt skydd\"],\"2uDhbA\":[\"Ange användarnamn att bjuda in\"],\"2ygf/L\":[\"← Tillbaka\"],\"2zEgxj\":[\"Sök GIF:ar...\"],\"3RdPhl\":[\"Byt namn på kanal\"],\"3THokf\":[\"Voice-användare\"],\"3TSz9S\":[\"Minimera\"],\"3jBDvM\":[\"Kanalens visningsnamn\"],\"3ryuFU\":[\"Valfria kraschrapporter för att förbättra appen\"],\"3uBF/8\":[\"Stäng visaren\"],\"3uwW8F\":[\"https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"3xf8Kz\":[\"Ange kontonamn...\"],\"4/Rr0R\":[\"Bjud in en användare till den aktuella kanalen\"],\"4EZrJN\":[\"Regler\"],\"4JJtW9\":[\"#overflow\"],\"4NqeT4\":[\"Flödesprofil (+F)\"],\"4RZQRK\":[\"Vad håller du på med?\"],\"4hfTrB\":[\"Nick\"],\"4n99LO\":[\"Redan i \",[\"0\"]],\"4t6vMV\":[\"Växla automatiskt till enkel rad för korta meddelanden\"],\"4vsHmf\":[\"Tid (min)\"],\"5+INAX\":[\"Markera meddelanden som nämner dig\"],\"5R5Pv/\":[\"Oper-namn\"],\"678PKt\":[\"Nätverksnamn\"],\"6Aih4U\":[\"Offline\"],\"6CO3WE\":[\"Lösenord krävs för att gå med i kanalen. Lämna tomt för att ta bort nyckeln.\"],\"6HhMs3\":[\"Quit-meddelande\"],\"6V3Ea3\":[\"Kopierat\"],\"6lGV3K\":[\"Visa mindre\"],\"6yFOEi\":[\"Ange oper-lösenord...\"],\"7+IHTZ\":[\"Ingen fil vald\"],\"73hrRi\":[\"nick!user@host (t.ex. spam*!*@*, *!*@badhost.com)\"],\"7QkKyN\":[\"Skicka privat meddelande\"],\"7U1W7c\":[\"Mycket avslappnat\"],\"7Y1YQj\":[\"Riktigt namn:\"],\"7YHArF\":[\"— öppna i visaren\"],\"7fjnVl\":[\"Sök användare...\"],\"7jL88x\":[\"Ta bort det här meddelandet? Det kan inte ångras.\"],\"7nGhhM\":[\"Vad tänker du på?\"],\"7sEpu1\":[\"Medlemmar — \",[\"0\"]],\"7sNhEz\":[\"Användarnamn\"],\"8H0Q+x\":[\"Läs mer om profiler →\"],\"8Phu0A\":[\"Visa när användare byter nick\"],\"8XTG9e\":[\"Ange oper-lösenord\"],\"8XsV2J\":[\"Försök skicka igen\"],\"8ZsakT\":[\"Lösenord\"],\"8kR84m\":[\"Du håller på att öppna en extern länk:\"],\"8lCgih\":[\"Ta bort regel\"],\"8p/xVT\":[[\"0\",\"plural\",{\"one\":[[\"1\"]],\"other\":[[\"2\"]]}]],\"8wRzac\":[[\"joinCount\",\"plural\",{\"one\":[\"gick med\"],\"other\":[\"gick med \",[\"joinCount\"],\" gånger\"]}]],\"9BMLnJ\":[\"Återanslut till server\"],\"9OEgyT\":[\"Lägg till reaktion\"],\"9PQ8m2\":[\"G-Line (globalt ban)\"],\"9Qs99X\":[\"E-post:\"],\"9QupBP\":[\"Ta bort mönster\"],\"9bG48P\":[\"Skickar\"],\"9f5f0u\":[\"Frågor om integritet? Kontakta oss:\"],\"9unqs3\":[\"Frånvarande:\"],\"9v3hwv\":[\"Inga servrar hittades.\"],\"9zb2WA\":[\"Ansluter\"],\"A1taO8\":[\"Sök\"],\"A2adVi\":[\"Skicka skriv-aviseringar\"],\"A9Rhec\":[\"Kanalnamn\"],\"AWOSPo\":[\"Zooma in\"],\"AXSpEQ\":[\"Oper vid anslutning\"],\"AeXO77\":[\"Konto\"],\"AhNP40\":[\"Sök position\"],\"Ai2U7L\":[\"Host\"],\"AjBQnf\":[\"Bytte nick\"],\"AmXVh6\":[\"https://example.com/avatar.png\"],\"AnRu/j\":[\"Avbryt svar\"],\"ApSx0O\":[\"Hittade \",[\"0\"],\" meddelanden som matchar \\\"\",[\"searchQuery\"],\"\\\"\"],\"AxPAXW\":[\"Inga resultat hittades\"],\"AyNqAB\":[\"Visa alla serverhändelser i chatten\"],\"B/QqGw\":[\"Borta från tangentbordet\"],\"B8AaMI\":[\"Det här fältet krävs\"],\"BA2c49\":[\"Servern stöder inte avancerad LIST-filtrering\"],\"BDKt3I\":[[\"0\"],\", \",[\"1\"],\", \",[\"2\"],\" och \",[\"3\"],\" andra skriver...\"],\"BGul2A\":[\"Du har osparade ändringar. Är du säker på att du vill stänga utan att spara?\"],\"BIf9fi\":[\"Ditt statusmeddelande\"],\"BZz3md\":[\"Din personliga webbplats\"],\"Bgm/H7\":[\"Tillåt att skriva flera rader text\"],\"BiQIl1\":[\"Fäst den här privata meddelandekonversationen\"],\"BlNZZ2\":[\"Klicka för att hoppa till meddelandet\"],\"Bowq3c\":[\"Endast operatörer kan ändra kanalämnet\"],\"Btozzp\":[\"Den här bilden har gått ut\"],\"Bycfjm\":[\"Totalt: \",[\"0\"]],\"C6IBQc\":[\"Kopiera hela JSON\"],\"C9L9wL\":[\"Datainsamling\"],\"CDq4wC\":[\"Moderera användare\"],\"CHVRxG\":[\"Meddelande @\",[\"0\"],\" (Shift+Enter för ny rad)\"],\"CN9zdR\":[\"Oper-namn och lösenord krävs\"],\"CW3sYa\":[\"Lägg till reaktion \",[\"emoji\"]],\"CaAkqd\":[\"Visa quit-meddelanden\"],\"CbvaYj\":[\"Banna via nick\"],\"CcK+Ft\":[\"PDF\"],\"Ce8q3L\":[\"Välj en kanal\"],\"CsekCi\":[\"Normal\"],\"D+NlUC\":[\"System\"],\"D28t6+\":[\"gick med och lämnade\"],\"DB8zMK\":[\"Tillämpa\"],\"DBcWHr\":[\"Anpassad aviseringsljudfil\"],\"DTy9Xw\":[\"Medieförhandsvisningar\"],\"Dj4pSr\":[\"Välj ett säkert lösenord\"],\"Du+zn+\":[\"Söker...\"],\"Du2T2f\":[\"Inställningen hittades inte\"],\"DwsSVQ\":[\"Tillämpa filter och uppdatera\"],\"E3W/zd\":[\"Standard-nick\"],\"E6nRW7\":[\"Kopiera URL\"],\"E703RG\":[\"Lägen:\"],\"EAeu1Z\":[\"Skicka inbjudan\"],\"EFKJQT\":[\"Inställning\"],\"EGPQBv\":[\"Anpassade flödesregler (+f)\"],\"ELik0r\":[\"Visa fullständig integritetspolicy\"],\"EPbeC2\":[\"Visa eller redigera kanalämnet\"],\"EQCDNT\":[\"Ange oper-användarnamn...\"],\"EUvulZ\":[\"Hittade 1 meddelande som matchar \\\"\",[\"searchQuery\"],\"\\\"\"],\"EatZYJ\":[\"Nästa bild\"],\"EdQY6l\":[\"Ingen\"],\"EnqLYU\":[\"Sök servrar...\"],\"F0OKMc\":[\"Redigera server\"],\"F6Int2\":[\"Aktivera markeringar\"],\"FDoLyE\":[\"Max användare\"],\"FUU/hZ\":[\"Styr hur mycket externt media som laddas i chatten.\"],\"Fdp03t\":[\"på\"],\"FfPWR0\":[\"Dialog\"],\"FjkaiT\":[\"Zooma ut\"],\"FlqOE9\":[\"Vad detta innebär:\"],\"FolHNl\":[\"Hantera ditt konto och autentisering\"],\"Fp2Dif\":[\"Lämnade servern\"],\"G5KmCc\":[\"GZ-Line (global Z-Line)\"],\"GDs0lz\":[\"<0>Risk: Känslig information (meddelanden, privata konversationer, autentiseringsuppgifter) kan exponeras för nätverksadministratörer eller angripare som befinner sig mellan IRC-servrar.\"],\"GR+2I3\":[\"Lägg till inbjudningsmask (t.ex. nick!*@*, *!*@host.com)\"],\"GRLyMU\":[\"Stäng utfällt servermeddelanden-fönster\"],\"GlHnXw\":[\"Namnbyte misslyckades: \",[\"error\"],\" \",[\"0\"]],\"GswZF3\":[\"Förhandsvisning:\"],\"GtmO8/\":[\"från\"],\"GtuHUQ\":[\"Byt namn på den här kanalen på servern. Alla användare ser det nya namnet.\"],\"GuGfFX\":[\"Växla sökning\"],\"GxkJXS\":[\"Laddar upp...\"],\"GzbwnK\":[\"Gick med i kanalen\"],\"GzsUDB\":[\"Utökad profil\"],\"H/PnT8\":[\"Infoga emoji\"],\"H6Izzl\":[\"Din föredragna färgkod\"],\"H9jIv+\":[\"Visa join/part\"],\"HAKBY9\":[\"Ladda upp filer\"],\"HdE1If\":[\"Kanal\"],\"Hk4AW9\":[\"Ditt föredragna visningsnamn\"],\"HmHDk7\":[\"Välj medlem\"],\"HrQzPU\":[\"Kanaler på \",[\"networkName\"]],\"I2tXQ5\":[\"Meddelande @\",[\"0\"],\" (Enter för ny rad, Shift+Enter för att skicka)\"],\"I6bw/h\":[\"Banna användare\"],\"I92Z+b\":[\"Aktivera aviseringar\"],\"I9D72S\":[\"Är du säker på att du vill ta bort det här meddelandet? Den här åtgärden kan inte ångras.\"],\"IA+1wo\":[\"Visa när användare sparkats ut från kanaler\"],\"IDwkJx\":[\"IRC-operatör\"],\"ILlU+s\":[\"Info:\"],\"IUwGEM\":[\"Spara ändringar\"],\"IVeGK6\":[[\"0\"],\", \",[\"1\"],\" och \",[\"2\"],\" skriver...\"],\"IgrLD/\":[\"Pausa\"],\"Im6JED\":[\"VISKNING\"],\"ImOQa9\":[\"Svara\"],\"IoHMnl\":[\"Maximalt värde är \",[\"0\"]],\"IvMj+0\":[\"Op\"],\"J28zul\":[\"Ansluter...\"],\"J5T9NW\":[\"Användarinformation\"],\"J8Y5+z\":[\"Hoppsan! Nätverksuppdelning! ⚠️\"],\"JBHkBA\":[\"Lämnade kanalen\"],\"JCwL0Q\":[\"Ange anledning (valfritt)\"],\"JFciKP\":[\"Växla\"],\"JXGkhG\":[\"Ändra kanalnamnet (endast operatörer)\"],\"JcD7qf\":[\"Fler åtgärder\"],\"JdkA+c\":[\"Hemlig (+s)\"],\"Jmu12l\":[\"Serverkanaler\"],\"JvQ++s\":[\"Aktivera Markdown\"],\"K2jwh/\":[\"Ingen WHOIS-data tillgänglig\"],\"KAXSwC\":[\"Voice\"],\"KDfTdX\":[\"Ta bort meddelande\"],\"KKBlUU\":[\"Inbäddning\"],\"KM0pLb\":[\"Välkommen till kanalen!\"],\"KR6W2h\":[\"Sluta ignorera användare\"],\"KV+Bi1\":[\"Endast inbjudna (+i)\"],\"KdCtwE\":[\"Hur många sekunder flödaktivitet ska övervakas innan räknarna återställs\"],\"Kkezga\":[\"Serverlösenord\"],\"KsiQ/8\":[\"Användare måste bjudas in för att gå med i kanalen\"],\"L+gB/D\":[\"Kanalinformation\"],\"LC1a7n\":[\"IRC-servern har rapporterat att dess server-till-server-länkar har en låg säkerhetsnivå. Det innebär att när dina meddelanden vidarebefordras mellan IRC-servrar i nätverket kanske de inte krypteras ordentligt eller att SSL/TLS-certifikaten inte valideras korrekt.\"],\"LNfLR5\":[\"Visa utsparkningar\"],\"LQb0W/\":[\"Visa alla händelser\"],\"LU7/yA\":[\"Alternativt namn för visning i gränssnittet. Får innehålla mellanslag, emoji och specialtecken. Det riktiga kanalnamnet (\",[\"channelName\"],\") används fortfarande för IRC-kommandon.\"],\"LUb9O7\":[\"Giltig serverport krävs\"],\"Lb+BUl\":[\"https://example.com/avatar.jpg\"],\"LcET2C\":[\"Integritetspolicy\"],\"LcuSDR\":[\"Hantera din profilinformation och metadata\"],\"LqLS9B\":[\"Visa nickbyten\"],\"LsDQt2\":[\"Kanalinställningar\"],\"LtI9AS\":[\"Ägare\"],\"LuNhhL\":[\"reagerade på det här meddelandet\"],\"M/AZNG\":[\"URL till din avatarbild\"],\"M/WIer\":[\"Skicka meddelande\"],\"M8er/5\":[\"Namn:\"],\"MHk+7g\":[\"Föregående bild\"],\"MRorGe\":[\"PM-användare\"],\"MVbSGP\":[\"Tidsfönster (sekunder)\"],\"MkpcsT\":[\"Dina meddelanden och inställningar lagras lokalt på din enhet\"],\"N/hDSy\":[\"Markera som bot – vanligtvis 'on' eller tomt\"],\"N7TQbE\":[\"Bjud in användare till \",[\"channelName\"]],\"NCca/o\":[\"Ange standardsmeknamn...\"],\"Nqs6B9\":[\"Visar allt externt media. Valfri URL kan orsaka en begäran till en okänd server.\"],\"Nt+9O7\":[\"Använd WebSocket istället för råa TCP\"],\"NxIHzc\":[\"Koppla från användare\"],\"O+v/cL\":[\"Bläddra bland alla kanaler på servern\"],\"ODwSCk\":[\"Skicka en GIF\"],\"OGQ5kK\":[\"Konfigurera aviseringsljud och markeringar\"],\"OIPt1Z\":[\"Visa eller dölj medlemslistans sidopanel\"],\"OKSNq/\":[\"Mycket strikt\"],\"ONWvwQ\":[\"Ladda upp\"],\"OVKoQO\":[\"Ditt kontolösenord för autentisering\"],\"ObsidianIRC - Bringing IRC to the future\":[\"ObsidianIRC - För IRC in i framtiden\"],\"OhCpra\":[\"Ange ett ämne…\"],\"OkltoQ\":[\"Banna \",[\"username\"],\" via nick (förhindrar återanslutning med samma nick)\"],\"P+t/Te\":[\"Inga ytterligare data\"],\"P42Wcc\":[\"Säkert\"],\"PD38l0\":[\"Förhandsvisning av kanalavatar\"],\"PD9mEt\":[\"Skriv ett meddelande...\"],\"PPqfdA\":[\"Öppna kanalens konfigurationsinställningar\"],\"PSCjfZ\":[\"Ämnet som visas för den här kanalen. Alla användare kan se ämnet.\"],\"PZCecv\":[\"PDF-förhandsgranskning\"],\"PeLgsC\":[[\"c\",\"plural\",{\"one\":[\"1 gång\"],\"other\":[[\"c\"],\" gånger\"]}]],\"PguS2C\":[\"Lägg till undantagsmask (t.ex. nick!*@*, *!*@host.com)\"],\"Pil5Ty\":[\"Visar \",[\"displayedChannelsCount\"],\" av \",[\"0\"],\" kanaler\"],\"PqhVlJ\":[\"Banna användare (via hostmask)\"],\"Q+chwU\":[\"Användarnamn:\"],\"Q6hhn8\":[\"Inställningar\"],\"QF4a34\":[\"Ange ett användarnamn\"],\"QGqSZ2\":[\"Färg och formatering\"],\"QJQd1J\":[\"Redigera profil\"],\"QSzGDE\":[\"Inaktiv\"],\"QUlny5\":[\"Välkommen till \",[\"0\"],\"!\"],\"Qoq+GP\":[\"Läs mer\"],\"QuSkCF\":[\"Filtrera kanaler...\"],\"QwUrDZ\":[\"ändrade ämnet till: \",[\"topic\"]],\"R0UH07\":[\"Bild \",[\"0\"],\" av \",[\"1\"]],\"R7SsBE\":[\"Tysta\"],\"R8rf1X\":[\"Klicka för att ange ämne\"],\"RArB3D\":[\"kastades ut från \",[\"channelName\"],\" av \",[\"username\"]],\"RI3cWd\":[\"Utforska IRC-världen med ObsidianIRC\"],\"RMMaN5\":[\"Modererad (+m)\"],\"RWw9Lg\":[\"Stäng dialog\"],\"RZ2BuZ\":[\"Kontoregistrering för \",[\"account\"],\" kräver verifiering: \",[\"message\"]],\"RySp6q\":[\"Dölj kommentarer\"],\"SPKQTd\":[\"Nick krävs\"],\"SPVjfj\":[\"Standardvärdet är 'ingen anledning' om det lämnas tomt\"],\"SQKPvQ\":[\"Bjud in användare\"],\"SkZcl+\":[\"Välj en fördefinierad flödeskyddsprofil. Dessa profiler ger balanserade skyddsinställningar för olika användningsfall.\"],\"Slr+3C\":[\"Min användare\"],\"Spnlre\":[\"Du bjöd in \",[\"target\"],\" att gå med i \",[\"channel\"]],\"T/ckN5\":[\"Öppna i visaren\"],\"T91vKp\":[\"Spela upp\"],\"TV2Wdu\":[\"Läs om hur vi hanterar dina uppgifter och skyddar din integritet.\"],\"TgFpwD\":[\"Tillämpar...\"],\"TkzSFB\":[\"Inga ändringar\"],\"TtserG\":[\"Ange riktigt namn\"],\"Ttz9J1\":[\"Ange lösenord...\"],\"Tz0i8g\":[\"Inställningar\"],\"U3pytU\":[\"Admin\"],\"UDb2YD\":[\"Reagera\"],\"UE4KO5\":[\"*kanal*\"],\"UGT5vp\":[\"Spara inställningar\"],\"UV5hLB\":[\"Inga banningar hittades\"],\"Uaj3Nd\":[\"Statusmeddelanden\"],\"Ue3uny\":[\"Standard (ingen profil)\"],\"UkARhe\":[\"Normal – standardskydd\"],\"Umn7Cj\":[\"Inga kommentarer ännu. Var den första!\"],\"UtUIRh\":[[\"0\"],\" äldre meddelanden\"],\"UwzP+U\":[\"Säker anslutning\"],\"V0/A4O\":[\"Kanalägare\"],\"V4qgxE\":[\"Skapad före (min sedan)\"],\"V8yTm6\":[\"Rensa sökning\"],\"VJMMyz\":[\"ObsidianIRC - För IRC in i framtiden\"],\"VJScHU\":[\"Anledning\"],\"VLsmVV\":[\"Tysta aviseringar\"],\"VbyRUy\":[\"Kommentarer\"],\"Vmx0mQ\":[\"Satt av:\"],\"VqnIZz\":[\"Visa vår integritetspolicy och datapraxis\"],\"VrMygG\":[\"Minimal längd är \",[\"0\"]],\"VrnTui\":[\"Dina pronomen, visas i din profil\"],\"W8E3qn\":[\"Autentiserat konto\"],\"WAakm9\":[\"Ta bort kanal\"],\"WFxTHC\":[\"Lägg till banmask (t.ex. nick!*@*, *!*@host.com)\"],\"WN1g9F\":[\"Serveradress krävs\"],\"WRYdXW\":[\"Ljudposition\"],\"WUOH5B\":[\"Ignorera användare\"],\"WWEXnZ\":[[\"0\",\"plural\",{\"one\":[\"Visa 1 till\"],\"other\":[\"Visa \",[\"1\"],\" till\"]}]],\"Weq9zb\":[\"Allmänt\"],\"Wfj7Sk\":[\"Tysta eller aktivera aviseringsljud\"],\"Wm7gbG\":[\"GitHub:\"],\"WyeHWY\":[\"*skräp*\"],\"WzMCru\":[\"Användarprofil\"],\"X6S3lt\":[\"Sök inställningar, kanaler, servrar...\"],\"XEHan5\":[\"Fortsätt ändå\"],\"XI1+wb\":[\"Ogiltigt format\"],\"XIXeuC\":[\"Meddelande @\",[\"0\"]],\"XMS+k4\":[\"Starta privat meddelande\"],\"XWgxXq\":[\"Album\"],\"Xd7+IT\":[\"Lossa privatchatt\"],\"Xm/s+u\":[\"Visning\"],\"Xp2n93\":[\"Visar media från serverns betrodda filvärd. Inga begäranden görs till externa tjänster.\"],\"XvjC4F\":[\"Sparar...\"],\"Y/qryO\":[\"Inga användare hittades som matchar din sökning\"],\"YAqRpI\":[\"Kontoregistrering för \",[\"account\"],\" lyckades: \",[\"message\"]],\"YEfzvP\":[\"Skyddat ämne (+t)\"],\"YQOn6a\":[\"Dölj medlemslistan\"],\"YRCoE9\":[\"Kanaloperatör\"],\"YURQaF\":[\"Visa profil\"],\"YdBSvr\":[\"Styr medievisning och externt innehåll\"],\"Yj6U3V\":[\"Ingen central server:\"],\"YjvpGx\":[\"Pronomen\"],\"YqH4l4\":[\"Ingen nyckel\"],\"YyUPpV\":[\"Konto:\"],\"ZJSWfw\":[\"Meddelande som visas när du kopplar från servern\"],\"ZR1dJ4\":[\"Inbjudningar\"],\"ZdWg0V\":[\"Öppna i webbläsare\"],\"ZhRBbl\":[\"Sök meddelanden…\"],\"Zmcu3y\":[\"Avancerade filter\"],\"a2/8e5\":[\"Ämne angivet efter (min sedan)\"],\"aHKcKc\":[\"Föregående sida\"],\"aJTbXX\":[\"Oper-lösenord\"],\"aQryQv\":[\"Mönstret finns redan\"],\"aW9pLN\":[\"Maximalt antal användare som tillåts i kanalen. Lämna tomt för ingen gräns.\"],\"ah4fmZ\":[\"Visar också förhandsvisningar från YouTube, Vimeo, SoundCloud och liknande kända tjänster.\"],\"aifXak\":[\"Inget media i den här kanalen\"],\"ap2zBz\":[\"Avslappnat\"],\"az8lvo\":[\"Av\"],\"azXSNo\":[\"Expandera medlemslistan\"],\"azdliB\":[\"Logga in på ett konto\"],\"b26wlF\":[\"hon/hennes\"],\"bD/+Ei\":[\"Strikt\"],\"bQ6BJn\":[\"Konfigurera detaljerade flödeskyddsregler. Varje regel anger vilken typ av aktivitet som ska övervakas och vilken åtgärd som ska vidtas när gränser överskrids.\"],\"beV7+y\":[\"Användaren får en inbjudan att gå med i \",[\"channelName\"],\".\"],\"bk84cH\":[\"Borta-meddelande\"],\"bkHdLj\":[\"Lägg till IRC-server\"],\"bmQLn5\":[\"Lägg till regel\"],\"bwRvnp\":[\"Åtgärd\"],\"c8+EVZ\":[\"Verifierat konto\"],\"cGYUlD\":[\"Inga medieförhandsvisningar laddas.\"],\"cLF98o\":[\"Visa kommentarer (\",[\"commentCount\"],\")\"],\"cLKIDO\":[\"Inga användare tillgängliga\"],\"cSgpoS\":[\"Fäst privatchatt\"],\"cde3ce\":[\"Meddelande <0>\",[\"0\"],\"\"],\"chQsxg\":[\"Kopiera formaterad utdata\"],\"cl/A5J\":[\"Välkommen till \",[\"__DEFAULT_IRC_SERVER_NAME__\"],\"!\"],\"cnGeoo\":[\"Ta bort\"],\"coPLXT\":[\"Vi lagrar inte dina IRC-kommunikationer på våra servrar\"],\"crYH/6\":[\"SoundCloud-spelare\"],\"d3sis4\":[\"Lägg till server\"],\"d9aN5k\":[\"Ta bort \",[\"username\"],\" från kanalen\"],\"dEgA5A\":[\"Avbryt\"],\"dGi1We\":[\"Lossa den här privata meddelandekonversationen\"],\"dJVuyC\":[\"lämnade \",[\"channelName\"],\" (\",[\"reason\"],\")\"],\"dMtLDE\":[\"till\"],\"dXqxlh\":[\"<0>⚠️ Säkerhetsrisk! Den här anslutningen kan vara sårbar för avlyssning eller man-in-the-middle-attacker.\"],\"da9Q/R\":[\"Ändrade kanallägen\"],\"dhJN3N\":[\"Visa kommentarer\"],\"dj2xTE\":[\"Stäng avisering\"],\"dpCzmC\":[\"Inställningar för flödeskydd\"],\"e9dQpT\":[\"Vill du öppna den här länken i en ny flik?\"],\"ePK91l\":[\"Redigera\"],\"eYBDuB\":[\"Ladda upp en bild eller ange en URL med valfri \",[\"size\"],\"-ersättning för dynamisk storleksanpassning\"],\"edBbee\":[\"Banna \",[\"username\"],\" via hostmask (förhindrar återanslutning från samma IP/host)\"],\"ekfzWq\":[\"Användarinställningar\"],\"elPDWs\":[\"Anpassa din IRC-klientupplevelse\"],\"eu2osY\":[\"<0>💡 Rekommendation: Fortsätt bara om du litar på den här servern och förstår riskerna. Undvik att dela känslig information eller lösenord via den här anslutningen.\"],\"euEhbr\":[\"Klicka för att gå med i \",[\"channel\"]],\"ez3vLd\":[\"Aktivera flerrads-inmatning\"],\"f0J5Ki\":[\"Server-till-server-kommunikation kan använda okrypterade anslutningar\"],\"f9BHJk\":[\"Varna användare\"],\"fDOLLd\":[\"Inga kanaler hittades.\"],\"ffzDkB\":[\"Anonym analys:\"],\"fq1GF9\":[\"Visa när användare kopplar från servern\"],\"gEF57C\":[\"Den här servern stöder bara en anslutningstyp\"],\"gJuLUI\":[\"Ignorera-lista\"],\"gNzMrk\":[\"Nuvarande avatar\"],\"gjPWyO\":[\"Ange smeknamn...\"],\"gz6UQ3\":[\"Maximera\"],\"h6razj\":[\"Uteslut kanalnamns-mask\"],\"hG6jnw\":[\"Inget ämne angivet\"],\"hG89Ed\":[\"Bild\"],\"hZ6znB\":[\"Port\"],\"ha+Bz5\":[\"t.ex. 100:1440\"],\"hehnjM\":[\"Mängd\"],\"hzdLuQ\":[\"Endast användare med Voice eller högre kan tala\"],\"i0qMbr\":[\"Hem\"],\"iDNBZe\":[\"Aviseringar\"],\"iH8pgl\":[\"Tillbaka\"],\"iL9SZg\":[\"Banna användare (via nick)\"],\"iNt+3c\":[\"Tillbaka till bild\"],\"iQvi+a\":[\"Varna mig inte om låg länksäkerhet för den här servern\"],\"iSLIjg\":[\"Anslut\"],\"iWXkHH\":[\"Halfop\"],\"iZeTtp\":[\"Serveradress\"],\"idD8Ev\":[\"Sparat\"],\"iivqkW\":[\"Inloggad sedan\"],\"ij+Elv\":[\"Bildförhandsvisning\"],\"ilIWp7\":[\"Växla aviseringar\"],\"iuaqvB\":[\"Använd * som jokertecken. Exempel: baduser!*@*, *!*@spammer.com, troll*!*@*\"],\"ixkTse\":[\"Bot\"],\"j2DGR0\":[\"Banna via hostmask\"],\"jA4uoI\":[\"Ämne:\"],\"jLXxGK\":[\"https://example.com\"],\"jPSk57\":[\"Anledning (valfritt)\"],\"jUV7CU\":[\"Ladda upp avatar\"],\"jW5Uwh\":[\"Styr hur mycket externt media som laddas. Av / Säkert / Betrodda källor / Allt innehåll.\"],\"jXzms5\":[\"Bilagealternativ\"],\"jZlrte\":[\"Färg\"],\"jfC/xh\":[\"Kontakt\"],\"jywMpv\":[\"#nytt-kanalnamn\"],\"k112DD\":[\"Ladda äldre meddelanden\"],\"k3ID0F\":[\"Filtrera medlemmar…\"],\"k65gsE\":[\"Fördjupning\"],\"k7Zgob\":[\"Avbryt anslutning\"],\"kAVx5h\":[\"Inga inbjudningar hittades\"],\"kCLEPU\":[\"Ansluten till\"],\"kF5LKb\":[\"Ignorerade mönster:\"],\"kGeOx/\":[\"Gå med i \",[\"0\"]],\"kITKr8\":[\"Laddar kanallägen...\"],\"kPpPsw\":[\"Du är en IRC-operatör\"],\"kWJmRL\":[\"Du\"],\"kfcRb0\":[\"Avatar\"],\"kjMqSj\":[\"Kopiera JSON\"],\"krViRy\":[\"Klicka för att kopiera som JSON\"],\"ks71ra\":[\"Undantag\"],\"kw4lRv\":[\"Kanal-halvoperatör\"],\"kxgIRq\":[\"Välj eller lägg till en kanal för att komma igång.\"],\"ky6dWe\":[\"Förhandsvisning av avatar\"],\"l+GxCv\":[\"Laddar kanaler...\"],\"l+IUVW\":[\"Kontoverifiering för \",[\"account\"],\" lyckades: \",[\"message\"]],\"l/siQz\":[[\"reconnectCount\",\"plural\",{\"one\":[\"återanslöt\"],\"other\":[\"återanslöt \",[\"reconnectCount\"],\" gånger\"]}]],\"l5jmzx\":[[\"0\"],\" och \",[\"1\"],\" skriver...\"],\"lHy8N5\":[\"Laddar fler kanaler...\"],\"lbpf14\":[\"Gå med i \",[\"value\"]],\"lfFsZ4\":[\"Kanaler\"],\"lkNdiH\":[\"Kontonamn\"],\"ln500L\":[\"ObsidianIRC\"],\"lnCMdg\":[\"Ladda upp bild\"],\"loQxaJ\":[\"Jag är tillbaka\"],\"lvfaxv\":[\"HEM\"],\"m16xKo\":[\"Lägg till\"],\"m8flAk\":[\"Förhandsvisning (ej uppladdad ännu)\"],\"mEPxTp\":[\"<0>⚠️ Var försiktig! Öppna bara länkar från betrodda källor. Skadliga länkar kan äventyra din säkerhet eller integritet.\"],\"mHGdhG\":[\"Serverinformation\"],\"mHS8lb\":[\"Meddelande #\",[\"0\"]],\"mMYBD9\":[\"Brett – bredare skyddsomfång\"],\"mTGsPd\":[\"Kanalämne\"],\"mU8j6O\":[\"Inga externa meddelanden (+n)\"],\"mZp8FL\":[\"Automatisk återgång till enkel rad\"],\"mdQu8G\":[\"DittNick\"],\"miSSBQ\":[\"Kommentarer (\",[\"commentCount\"],\")\"],\"mvyLSy\":[\"Användaren är autentiserad\"],\"mwtcGl\":[\"Stäng kommentarer\"],\"mzI/c+\":[\"Ladda ned\"],\"n3fGRk\":[\"angett av \",[\"0\"]],\"nE9jsU\":[\"Avslappnat – mindre aggressivt skydd\"],\"nNflMD\":[\"Lämna kanal\"],\"nPXkBi\":[\"Laddar WHOIS-data...\"],\"nQnxxF\":[\"Meddelande #\",[\"0\"],\" (Shift+Enter för ny rad)\"],\"nWMRxa\":[\"Lossa\"],\"nkC032\":[\"Ingen flödesprofil\"],\"o69z4d\":[\"Skicka ett varningsmeddelande till \",[\"username\"]],\"o9ylQi\":[\"Sök efter GIF:ar för att komma igång\"],\"oFGkER\":[\"Servermeddelanden\"],\"oOi11l\":[\"Scrolla till botten\"],\"oQEzQR\":[\"Nytt DM\"],\"oXOSPE\":[\"Online\"],\"oal760\":[\"Man-in-the-middle-attacker på serverlänkar är möjliga\"],\"oeqmmJ\":[\"Betrodda källor\"],\"ovBPCi\":[\"Standard\"],\"p0Z69r\":[\"Mönstret kan inte vara tomt\"],\"p1KgtK\":[\"Det gick inte att läsa in ljud\"],\"p59pEv\":[\"Ytterligare detaljer\"],\"p7sRI6\":[\"Låt andra se när du skriver\"],\"pBm1od\":[\"Hemlig kanal\"],\"pNmiXx\":[\"Ditt standard-nick för alla servrar\"],\"pUUo9G\":[\"Värdnamn:\"],\"pVGPmz\":[\"Kontolösenord\"],\"peNE68\":[\"Permanent\"],\"plhHQt\":[\"Inga data\"],\"pm6+q5\":[\"Säkerhetsvarning\"],\"pn5qSs\":[\"Ytterligare information\"],\"q0cR4S\":[\"är nu känd som **\",[\"newNick\"],\"**\"],\"qFcunY\":[\"Kanalen visas inte i LIST- eller NAMES-kommandon\"],\"qLpTm/\":[\"Ta bort reaktion \",[\"emoji\"]],\"qVkGWK\":[\"Fäst\"],\"qY8wNa\":[\"Hemsida\"],\"qb0xJ7\":[\"Använd jokertecken: * matchar valfri sekvens, ? matchar valfritt enskilt tecken. Exempel: nick!*@*, *!*@host.com, *!*user@*\"],\"qhzpRq\":[\"Kanalnyckel (+k)\"],\"qtoOYG\":[\"Ingen gräns\"],\"r1W2AS\":[\"Filserverbild\"],\"rIPR2O\":[\"Ämne angivet före (min sedan)\"],\"rMMSYo\":[\"Maximal längd är \",[\"0\"]],\"rWtzQe\":[\"Nätverket splittrades och återanslöts. ✅\"],\"rYG2u6\":[\"Vänta...\"],\"rdUucN\":[\"Förhandsvisning\"],\"rjGI/Q\":[\"Integritet\"],\"rk8iDX\":[\"Laddar GIF:ar...\"],\"rn6SBY\":[\"Sluta tysta\"],\"s/UKqq\":[\"Sparkades ut från kanalen\"],\"s8cATI\":[\"gick med i \",[\"channelName\"]],\"sCO9ue\":[\"Anslutningen till <0>\",[\"serverName\"],\" har följande säkerhetsproblem:\"],\"sGH11W\":[\"Server\"],\"sHI1H+\":[\"är nu känd som **\",[\"newNick\"],\"**\"],\"sJyV04\":[[\"inviter\"],\" bjöd in dig att gå med i \",[\"channel\"]],\"sby+1/\":[\"Klicka för att kopiera\"],\"sfN25C\":[\"Ditt riktiga eller fullständiga namn\"],\"sliuzR\":[\"Öppna länk\"],\"sqrO9R\":[\"Anpassade omnämnanden\"],\"sr6RdJ\":[\"Flerrad med Shift+Enter\"],\"swrCpB\":[\"Kanalen har döpts om från \",[\"oldName\"],\" till \",[\"newName\"],\" av \",[\"user\"],[\"0\"]],\"sxkWRg\":[\"Avancerat\"],\"t/YqKh\":[\"Ta bort\"],\"t47eHD\":[\"Din unika identifierare på den här servern\"],\"tAkAh0\":[\"URL med valfri \",[\"size\"],\"-ersättning för dynamisk storleksanpassning. Exempel: https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"tXLJS3\":[\"Visa eller dölj kanallistans sidopanel\"],\"tfDRzk\":[\"Spara\"],\"tiBsJk\":[\"lämnade \",[\"channelName\"]],\"tt4/UD\":[\"lämnade (\",[\"reason\"],\")\"],\"u0TcnO\":[\"Smeknamnet {nick} används redan, försöker med {newNick}\"],\"u0a8B4\":[\"Autentisera som IRC-operatör för administrativ åtkomst\"],\"u0rWFU\":[\"Skapad efter (min sedan)\"],\"u72w3t\":[\"Användare och mönster att ignorera\"],\"u7jc2L\":[\"lämnade\"],\"uAQUqI\":[\"Status\"],\"uB85T3\":[\"Sparning misslyckades: \",[\"msg\"]],\"uV3DOL\":[\"G-Line\"],\"uW3lLI\":[\"IRC-servrar:\"],\"usSSr/\":[\"Zoomnivå\"],\"v7uvcf\":[\"Programvara:\"],\"vE8kb+\":[\"Använd Shift+Enter för nya rader (Enter skickar)\"],\"vERlcd\":[\"Profil\"],\"vK0RL8\":[\"Inget ämne\"],\"vSJd18\":[\"Video\"],\"vXIe7J\":[\"Språk\"],\"vaHYxN\":[\"Riktigt namn\"],\"vhjbKr\":[\"Borta\"],\"w4NYox\":[[\"title\"],\" klient\"],\"w8xQRx\":[\"Ogiltigt värde\"],\"wFjjxZ\":[\"kastades ut från \",[\"channelName\"],\" av \",[\"username\"],\" (\",[\"reason\"],\")\"],\"wGjaGl\":[\"Inga banundantag hittades\"],\"wPrGnM\":[\"Kanaladmin\"],\"wRkP2d\":[\"GIF\"],\"wbm86v\":[\"Visa när användare går med i eller lämnar kanaler\"],\"whqZ9r\":[\"Ytterligare ord eller fraser att markera\"],\"wm7RV4\":[\"Aviseringsljud\"],\"wz/Yoq\":[\"Dina meddelanden kan avlyssnas när de vidarebefordras mellan servrar\"],\"xCJdfg\":[\"Rensa\"],\"xUHRTR\":[\"Autentisera automatiskt som operatör vid anslutning\"],\"xWHwwQ\":[\"Banningar\"],\"xYilR2\":[\"Media\"],\"xceQrO\":[\"Endast säkra websockets stöds\"],\"xdtXa+\":[\"kanalnamn\"],\"xfXC7q\":[\"Textkanaler\"],\"xlCYOE\":[\"Hämtar fler meddelanden...\"],\"xlhswE\":[\"Minimalt värde är \",[\"0\"]],\"xq97Ci\":[\"Lägg till ett ord eller en fras...\"],\"xuRqRq\":[\"Klientgräns (+l)\"],\"xwF+7J\":[[\"0\"],\" skriver...\"],\"yNeucF\":[\"Den här servern stöder inte utökad profilmetadata (IRCv3 METADATA-tillägget). Ytterligare fält som avatar, visningsnamn och status är inte tillgängliga.\"],\"yPlrca\":[\"Kanalavatar\"],\"yQE2r9\":[\"Laddar\"],\"ySU+JY\":[\"din@epost.se\"],\"yTX1Rt\":[\"Oper-användarnamn\"],\"yYOzWD\":[\"loggar\"],\"yfx9Re\":[\"IRC-operatörslösenord\"],\"ygCKqB\":[\"Stoppa\"],\"ymDxJx\":[\"IRC-operatörens användarnamn\"],\"yrpRsQ\":[\"Sortera efter namn\"],\"yz7wBu\":[\"Stäng\"],\"zJw+jA\":[\"anger läge: \",[\"0\"]],\"zebeLu\":[\"Ange oper-användarnamn\"],\"zpr0Bw\":[\"GZ-Line\"]}"); \ No newline at end of file +/*eslint-disable*/export const messages=JSON.parse("{\"+5VMrz\":[\"Ogiltigt mönsterformat. Använd formatet nick!user@host (jokertecken * tillåts)\"],\"+6NQQA\":[\"Allmän supportkanal\"],\"+6NyRG\":[\"Klient\"],\"+K0AvT\":[\"Koppla från\"],\"+cyFdH\":[\"Standardmeddelande när du markerar dig som borta\"],\"+mVPqU\":[\"Rendera markdown-formatering i meddelanden\"],\"+vqCJH\":[\"Ditt kontoanvändarnamn för autentisering\"],\"+yPBXI\":[\"Välj fil\"],\"+zy2Nq\":[\"Typ\"],\"/09cao\":[\"Låg länksäkerhet (nivå \",[\"securityLevel\"],\")\"],\"/3BQ4J\":[\"Användare utanför kanalen kan inte skicka meddelanden till den\"],\"/6BzZF\":[\"Växla medlemslista\"],\"/TNOPk\":[\"Användaren är borta\"],\"/XQgft\":[\"Utforska\"],\"/cF7Rs\":[\"Volym\"],\"/dqduX\":[\"Nästa sida\"],\"/fc3q4\":[\"Allt innehåll\"],\"/kISDh\":[\"Aktivera aviseringsljud\"],\"/n04sB\":[\"Kill\"],\"/rTz0M\":[\"Ljud\"],\"/rfkZe\":[\"Spela upp ljud för omnämnanden och meddelanden\"],\"0/0ZGA\":[\"Kanalnamns-mask\"],\"0D6j7U\":[\"Läs mer om anpassade regler →\"],\"0XsHcR\":[\"Sparka ut användare\"],\"0ZpE//\":[\"Sortera efter användare\"],\"0bEPwz\":[\"Ange borta\"],\"0dGkPt\":[\"Expandera kanallistan\"],\"0gS7M5\":[\"Visningsnamn\"],\"0kS+M8\":[\"ExempelNÄT\"],\"0rgoY7\":[\"Anslut bara till servrar du väljer\"],\"0wdd7X\":[\"Gå med\"],\"0wkVYx\":[\"Privata meddelanden\"],\"111uHX\":[\"Länkförhandsvisning\"],\"196EG4\":[\"Ta bort privatchatt\"],\"1DSr1i\":[\"Registrera ett konto\"],\"1O/24y\":[\"Växla kanallista\"],\"1TNIig\":[\"Open\"],\"1VPJJ2\":[\"Varning för extern länk\"],\"1ZC/dv\":[\"Inga olästa omnämnanden eller meddelanden\"],\"1pO1zi\":[\"Servernamn krävs\"],\"1uwfzQ\":[\"Visa kanalämne\"],\"268g7c\":[\"Ange visningsnamn\"],\"2FOFq1\":[\"Serveroperatörer på nätverket kan potentiellt läsa dina meddelanden\"],\"2FYpfJ\":[\"Mer\"],\"2HF1Y2\":[[\"inviter\"],\" bjöd in \",[\"target\"],\" att gå med i \",[\"channel\"]],\"2I70QL\":[\"Visa användarprofilinformation\"],\"2QYdmE\":[\"Användare:\"],\"2QpEjG\":[\"lämnade\"],\"2YE223\":[\"Meddelande #\",[\"0\"],\" (Enter för ny rad, Shift+Enter för att skicka)\"],\"2bimFY\":[\"Använd serverlösenord\"],\"2iTmdZ\":[\"Lokal lagring:\"],\"2odkwe\":[\"Strikt – mer aggressivt skydd\"],\"2uDhbA\":[\"Ange användarnamn att bjuda in\"],\"2ygf/L\":[\"← Tillbaka\"],\"2zEgxj\":[\"Sök GIF:ar...\"],\"3RdPhl\":[\"Byt namn på kanal\"],\"3THokf\":[\"Voice-användare\"],\"3TSz9S\":[\"Minimera\"],\"3jBDvM\":[\"Kanalens visningsnamn\"],\"3ryuFU\":[\"Valfria kraschrapporter för att förbättra appen\"],\"3uBF/8\":[\"Stäng visaren\"],\"3uwW8F\":[\"https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"3xf8Kz\":[\"Ange kontonamn...\"],\"4/Rr0R\":[\"Bjud in en användare till den aktuella kanalen\"],\"4EZrJN\":[\"Regler\"],\"4JJtW9\":[\"#overflow\"],\"4NqeT4\":[\"Flödesprofil (+F)\"],\"4RZQRK\":[\"Vad håller du på med?\"],\"4hfTrB\":[\"Nick\"],\"4n99LO\":[\"Redan i \",[\"0\"]],\"4t6vMV\":[\"Växla automatiskt till enkel rad för korta meddelanden\"],\"4vsHmf\":[\"Tid (min)\"],\"4x/Axu\":[\"Din bouncer har inga nätverk ännu. Lägg till ett för att komma igång.\"],\"5+INAX\":[\"Markera meddelanden som nämner dig\"],\"5R5Pv/\":[\"Oper-namn\"],\"678PKt\":[\"Nätverksnamn\"],\"6Aih4U\":[\"Offline\"],\"6CO3WE\":[\"Lösenord krävs för att gå med i kanalen. Lämna tomt för att ta bort nyckeln.\"],\"6HhMs3\":[\"Quit-meddelande\"],\"6V3Ea3\":[\"Kopierat\"],\"6lGV3K\":[\"Visa mindre\"],\"6yFOEi\":[\"Ange oper-lösenord...\"],\"7+IHTZ\":[\"Ingen fil vald\"],\"73fnil\":[\"TLS\"],\"73hrRi\":[\"nick!user@host (t.ex. spam*!*@*, *!*@badhost.com)\"],\"7QkKyN\":[\"Skicka privat meddelande\"],\"7U1W7c\":[\"Mycket avslappnat\"],\"7Y1YQj\":[\"Riktigt namn:\"],\"7YHArF\":[\"— öppna i visaren\"],\"7fjnVl\":[\"Sök användare...\"],\"7jL88x\":[\"Ta bort det här meddelandet? Det kan inte ångras.\"],\"7nGhhM\":[\"Vad tänker du på?\"],\"7sEpu1\":[\"Medlemmar — \",[\"0\"]],\"7sNhEz\":[\"Användarnamn\"],\"8H0Q+x\":[\"Läs mer om profiler →\"],\"8Phu0A\":[\"Visa när användare byter nick\"],\"8XTG9e\":[\"Ange oper-lösenord\"],\"8XsV2J\":[\"Försök skicka igen\"],\"8ZsakT\":[\"Lösenord\"],\"8kR84m\":[\"Du håller på att öppna en extern länk:\"],\"8lCgih\":[\"Ta bort regel\"],\"8p/xVT\":[[\"0\",\"plural\",{\"one\":[[\"1\"]],\"other\":[[\"2\"]]}]],\"8wRzac\":[[\"joinCount\",\"plural\",{\"one\":[\"gick med\"],\"other\":[\"gick med \",[\"joinCount\"],\" gånger\"]}]],\"9BMLnJ\":[\"Återanslut till server\"],\"9OEgyT\":[\"Lägg till reaktion\"],\"9PQ8m2\":[\"G-Line (globalt ban)\"],\"9Qs99X\":[\"E-post:\"],\"9QupBP\":[\"Ta bort mönster\"],\"9W7tl5\":[\"(oförändrad)\"],\"9bG48P\":[\"Skickar\"],\"9f5f0u\":[\"Frågor om integritet? Kontakta oss:\"],\"9iweoP\":[\"Nätverk på \",[\"0\"]],\"9unqs3\":[\"Frånvarande:\"],\"9v3hwv\":[\"Inga servrar hittades.\"],\"9zb2WA\":[\"Ansluter\"],\"A1taO8\":[\"Sök\"],\"A2adVi\":[\"Skicka skriv-aviseringar\"],\"A9Rhec\":[\"Kanalnamn\"],\"AWOSPo\":[\"Zooma in\"],\"AXSpEQ\":[\"Oper vid anslutning\"],\"AeXO77\":[\"Konto\"],\"AhNP40\":[\"Sök position\"],\"Ai2U7L\":[\"Host\"],\"AjBQnf\":[\"Bytte nick\"],\"AmXVh6\":[\"https://example.com/avatar.png\"],\"AnRu/j\":[\"Avbryt svar\"],\"ApSx0O\":[\"Hittade \",[\"0\"],\" meddelanden som matchar \\\"\",[\"searchQuery\"],\"\\\"\"],\"AxPAXW\":[\"Inga resultat hittades\"],\"AyNqAB\":[\"Visa alla serverhändelser i chatten\"],\"B/QqGw\":[\"Borta från tangentbordet\"],\"B0sB2k\":[\"Klartext\"],\"B8AaMI\":[\"Det här fältet krävs\"],\"BA2c49\":[\"Servern stöder inte avancerad LIST-filtrering\"],\"BDKt3I\":[[\"0\"],\", \",[\"1\"],\", \",[\"2\"],\" och \",[\"3\"],\" andra skriver...\"],\"BGul2A\":[\"Du har osparade ändringar. Är du säker på att du vill stänga utan att spara?\"],\"BIf9fi\":[\"Ditt statusmeddelande\"],\"BZz3md\":[\"Din personliga webbplats\"],\"Bgm/H7\":[\"Tillåt att skriva flera rader text\"],\"BiQIl1\":[\"Fäst den här privata meddelandekonversationen\"],\"BlNZZ2\":[\"Klicka för att hoppa till meddelandet\"],\"Bowq3c\":[\"Endast operatörer kan ändra kanalämnet\"],\"Btozzp\":[\"Den här bilden har gått ut\"],\"Bycfjm\":[\"Totalt: \",[\"0\"]],\"C6IBQc\":[\"Kopiera hela JSON\"],\"C9L9wL\":[\"Datainsamling\"],\"CDq4wC\":[\"Moderera användare\"],\"CHVRxG\":[\"Meddelande @\",[\"0\"],\" (Shift+Enter för ny rad)\"],\"CN9zdR\":[\"Oper-namn och lösenord krävs\"],\"CW3sYa\":[\"Lägg till reaktion \",[\"emoji\"]],\"CaAkqd\":[\"Visa quit-meddelanden\"],\"CbvaYj\":[\"Banna via nick\"],\"CcK+Ft\":[\"PDF\"],\"Ce8q3L\":[\"Välj en kanal\"],\"CsekCi\":[\"Normal\"],\"D+NlUC\":[\"System\"],\"D28t6+\":[\"gick med och lämnade\"],\"DB8zMK\":[\"Tillämpa\"],\"DBcWHr\":[\"Anpassad aviseringsljudfil\"],\"DTy9Xw\":[\"Medieförhandsvisningar\"],\"Dj4pSr\":[\"Välj ett säkert lösenord\"],\"Du+zn+\":[\"Söker...\"],\"Du2T2f\":[\"Inställningen hittades inte\"],\"DwsSVQ\":[\"Tillämpa filter och uppdatera\"],\"E3W/zd\":[\"Standard-nick\"],\"E6nRW7\":[\"Kopiera URL\"],\"E703RG\":[\"Lägen:\"],\"EAeu1Z\":[\"Skicka inbjudan\"],\"EFKJQT\":[\"Inställning\"],\"EGPQBv\":[\"Anpassade flödesregler (+f)\"],\"ELik0r\":[\"Visa fullständig integritetspolicy\"],\"EPbeC2\":[\"Visa eller redigera kanalämnet\"],\"EQCDNT\":[\"Ange oper-användarnamn...\"],\"EUvulZ\":[\"Hittade 1 meddelande som matchar \\\"\",[\"searchQuery\"],\"\\\"\"],\"EatZYJ\":[\"Nästa bild\"],\"EdQY6l\":[\"Ingen\"],\"EnqLYU\":[\"Sök servrar...\"],\"F0OKMc\":[\"Redigera server\"],\"F6Int2\":[\"Aktivera markeringar\"],\"FDoLyE\":[\"Max användare\"],\"FUU/hZ\":[\"Styr hur mycket externt media som laddas i chatten.\"],\"Fdp03t\":[\"på\"],\"FfPWR0\":[\"Dialog\"],\"FjkaiT\":[\"Zooma ut\"],\"FlqOE9\":[\"Vad detta innebär:\"],\"FolHNl\":[\"Hantera ditt konto och autentisering\"],\"Fp2Dif\":[\"Lämnade servern\"],\"G5KmCc\":[\"GZ-Line (global Z-Line)\"],\"GDs0lz\":[\"<0>Risk: Känslig information (meddelanden, privata konversationer, autentiseringsuppgifter) kan exponeras för nätverksadministratörer eller angripare som befinner sig mellan IRC-servrar.\"],\"GR+2I3\":[\"Lägg till inbjudningsmask (t.ex. nick!*@*, *!*@host.com)\"],\"GRLyMU\":[\"Stäng utfällt servermeddelanden-fönster\"],\"GlHnXw\":[\"Namnbyte misslyckades: \",[\"error\"],\" \",[\"0\"]],\"GswZF3\":[\"Förhandsvisning:\"],\"GtmO8/\":[\"från\"],\"GtuHUQ\":[\"Byt namn på den här kanalen på servern. Alla användare ser det nya namnet.\"],\"GuGfFX\":[\"Växla sökning\"],\"GxkJXS\":[\"Laddar upp...\"],\"GzbwnK\":[\"Gick med i kanalen\"],\"GzsUDB\":[\"Utökad profil\"],\"H/PnT8\":[\"Infoga emoji\"],\"H6Izzl\":[\"Din föredragna färgkod\"],\"H9jIv+\":[\"Visa join/part\"],\"HAKBY9\":[\"Ladda upp filer\"],\"HdE1If\":[\"Kanal\"],\"Hk4AW9\":[\"Ditt föredragna visningsnamn\"],\"HmHDk7\":[\"Välj medlem\"],\"HrQzPU\":[\"Kanaler på \",[\"networkName\"]],\"I2tXQ5\":[\"Meddelande @\",[\"0\"],\" (Enter för ny rad, Shift+Enter för att skicka)\"],\"I6bw/h\":[\"Banna användare\"],\"I92Z+b\":[\"Aktivera aviseringar\"],\"I9D72S\":[\"Är du säker på att du vill ta bort det här meddelandet? Den här åtgärden kan inte ångras.\"],\"IA+1wo\":[\"Visa när användare sparkats ut från kanaler\"],\"IDwkJx\":[\"IRC-operatör\"],\"ILlU+s\":[\"Info:\"],\"IUwGEM\":[\"Spara ändringar\"],\"IVeGK6\":[[\"0\"],\", \",[\"1\"],\" och \",[\"2\"],\" skriver...\"],\"IgrLD/\":[\"Pausa\"],\"Im6JED\":[\"VISKNING\"],\"ImOQa9\":[\"Svara\"],\"IoHMnl\":[\"Maximalt värde är \",[\"0\"]],\"IvMj+0\":[\"Op\"],\"J28zul\":[\"Ansluter...\"],\"J5T9NW\":[\"Användarinformation\"],\"J8Y5+z\":[\"Hoppsan! Nätverksuppdelning! ⚠️\"],\"JBHkBA\":[\"Lämnade kanalen\"],\"JCwL0Q\":[\"Ange anledning (valfritt)\"],\"JFciKP\":[\"Växla\"],\"JXGkhG\":[\"Ändra kanalnamnet (endast operatörer)\"],\"JcD7qf\":[\"Fler åtgärder\"],\"JdkA+c\":[\"Hemlig (+s)\"],\"Jmu12l\":[\"Serverkanaler\"],\"JvQ++s\":[\"Aktivera Markdown\"],\"K2jwh/\":[\"Ingen WHOIS-data tillgänglig\"],\"KAXSwC\":[\"Voice\"],\"KDfTdX\":[\"Ta bort meddelande\"],\"KKBlUU\":[\"Inbäddning\"],\"KM0pLb\":[\"Välkommen till kanalen!\"],\"KR6W2h\":[\"Sluta ignorera användare\"],\"KV+Bi1\":[\"Endast inbjudna (+i)\"],\"KdCtwE\":[\"Hur många sekunder flödaktivitet ska övervakas innan räknarna återställs\"],\"Kkezga\":[\"Serverlösenord\"],\"KsiQ/8\":[\"Användare måste bjudas in för att gå med i kanalen\"],\"L+gB/D\":[\"Kanalinformation\"],\"LC1a7n\":[\"IRC-servern har rapporterat att dess server-till-server-länkar har en låg säkerhetsnivå. Det innebär att när dina meddelanden vidarebefordras mellan IRC-servrar i nätverket kanske de inte krypteras ordentligt eller att SSL/TLS-certifikaten inte valideras korrekt.\"],\"LNfLR5\":[\"Visa utsparkningar\"],\"LP+1Z7\":[\"Lägg till nätverk\"],\"LQb0W/\":[\"Visa alla händelser\"],\"LU7/yA\":[\"Alternativt namn för visning i gränssnittet. Får innehålla mellanslag, emoji och specialtecken. Det riktiga kanalnamnet (\",[\"channelName\"],\") används fortfarande för IRC-kommandon.\"],\"LUb9O7\":[\"Giltig serverport krävs\"],\"Lb+BUl\":[\"https://example.com/avatar.jpg\"],\"LcET2C\":[\"Integritetspolicy\"],\"LcuSDR\":[\"Hantera din profilinformation och metadata\"],\"LqLS9B\":[\"Visa nickbyten\"],\"LsDQt2\":[\"Kanalinställningar\"],\"LtI9AS\":[\"Ägare\"],\"LuNhhL\":[\"reagerade på det här meddelandet\"],\"M/AZNG\":[\"URL till din avatarbild\"],\"M/WIer\":[\"Skicka meddelande\"],\"M8er/5\":[\"Namn:\"],\"MHk+7g\":[\"Föregående bild\"],\"MRorGe\":[\"PM-användare\"],\"MVbSGP\":[\"Tidsfönster (sekunder)\"],\"MkpcsT\":[\"Dina meddelanden och inställningar lagras lokalt på din enhet\"],\"MzPdC2\":[\"Serverlösenord (PASS)\"],\"N/hDSy\":[\"Markera som bot – vanligtvis 'on' eller tomt\"],\"N6j2JH\":[\"Redigera \",[\"0\"]],\"N7TQbE\":[\"Bjud in användare till \",[\"channelName\"]],\"NCca/o\":[\"Ange standardsmeknamn...\"],\"Nqs6B9\":[\"Visar allt externt media. Valfri URL kan orsaka en begäran till en okänd server.\"],\"Nt+9O7\":[\"Använd WebSocket istället för råa TCP\"],\"NxIHzc\":[\"Koppla från användare\"],\"O+v/cL\":[\"Bläddra bland alla kanaler på servern\"],\"OCGpR4\":[\"(ärv)\"],\"ODwSCk\":[\"Skicka en GIF\"],\"OGQ5kK\":[\"Konfigurera aviseringsljud och markeringar\"],\"OIPt1Z\":[\"Visa eller dölj medlemslistans sidopanel\"],\"OKSNq/\":[\"Mycket strikt\"],\"ONWvwQ\":[\"Ladda upp\"],\"OVKoQO\":[\"Ditt kontolösenord för autentisering\"],\"ObsidianIRC - Bringing IRC to the future\":[\"ObsidianIRC - För IRC in i framtiden\"],\"OhCpra\":[\"Ange ett ämne…\"],\"OkltoQ\":[\"Banna \",[\"username\"],\" via nick (förhindrar återanslutning med samma nick)\"],\"P+t/Te\":[\"Inga ytterligare data\"],\"P42Wcc\":[\"Säkert\"],\"PD38l0\":[\"Förhandsvisning av kanalavatar\"],\"PD9mEt\":[\"Skriv ett meddelande...\"],\"PPqfdA\":[\"Öppna kanalens konfigurationsinställningar\"],\"PSCjfZ\":[\"Ämnet som visas för den här kanalen. Alla användare kan se ämnet.\"],\"PZCecv\":[\"PDF-förhandsgranskning\"],\"PeLgsC\":[[\"c\",\"plural\",{\"one\":[\"1 gång\"],\"other\":[[\"c\"],\" gånger\"]}]],\"PguS2C\":[\"Lägg till undantagsmask (t.ex. nick!*@*, *!*@host.com)\"],\"Pil5Ty\":[\"Visar \",[\"displayedChannelsCount\"],\" av \",[\"0\"],\" kanaler\"],\"PqhVlJ\":[\"Banna användare (via hostmask)\"],\"Q+chwU\":[\"Användarnamn:\"],\"Q3v9Wc\":[\"Ja, ta bort\"],\"Q6hhn8\":[\"Inställningar\"],\"QF4a34\":[\"Ange ett användarnamn\"],\"QGqSZ2\":[\"Färg och formatering\"],\"QJQd1J\":[\"Redigera profil\"],\"QSzGDE\":[\"Inaktiv\"],\"QUlny5\":[\"Välkommen till \",[\"0\"],\"!\"],\"Qoq+GP\":[\"Läs mer\"],\"QuSkCF\":[\"Filtrera kanaler...\"],\"QwUrDZ\":[\"ändrade ämnet till: \",[\"topic\"]],\"R0UH07\":[\"Bild \",[\"0\"],\" av \",[\"1\"]],\"R7SsBE\":[\"Tysta\"],\"R8rf1X\":[\"Klicka för att ange ämne\"],\"RArB3D\":[\"kastades ut från \",[\"channelName\"],\" av \",[\"username\"]],\"RI3cWd\":[\"Utforska IRC-världen med ObsidianIRC\"],\"RMMaN5\":[\"Modererad (+m)\"],\"RWw9Lg\":[\"Stäng dialog\"],\"RZ2BuZ\":[\"Kontoregistrering för \",[\"account\"],\" kräver verifiering: \",[\"message\"]],\"RySp6q\":[\"Dölj kommentarer\"],\"S5Togi\":[\"Laddar nätverk från din bouncer…\"],\"SPKQTd\":[\"Nick krävs\"],\"SPVjfj\":[\"Standardvärdet är 'ingen anledning' om det lämnas tomt\"],\"SQKPvQ\":[\"Bjud in användare\"],\"STmlpb\":[\"Back to network list\"],\"SkZcl+\":[\"Välj en fördefinierad flödeskyddsprofil. Dessa profiler ger balanserade skyddsinställningar för olika användningsfall.\"],\"Slr+3C\":[\"Min användare\"],\"Spnlre\":[\"Du bjöd in \",[\"target\"],\" att gå med i \",[\"channel\"]],\"T/ckN5\":[\"Öppna i visaren\"],\"T91vKp\":[\"Spela upp\"],\"TV2Wdu\":[\"Läs om hur vi hanterar dina uppgifter och skyddar din integritet.\"],\"TgFpwD\":[\"Tillämpar...\"],\"TkzSFB\":[\"Inga ändringar\"],\"TtserG\":[\"Ange riktigt namn\"],\"Ttz9J1\":[\"Ange lösenord...\"],\"Tz0i8g\":[\"Inställningar\"],\"U3pytU\":[\"Admin\"],\"UDb2YD\":[\"Reagera\"],\"UE4KO5\":[\"*kanal*\"],\"UGT5vp\":[\"Spara inställningar\"],\"UV5hLB\":[\"Inga banningar hittades\"],\"Uaj3Nd\":[\"Statusmeddelanden\"],\"Ue3uny\":[\"Standard (ingen profil)\"],\"UkARhe\":[\"Normal – standardskydd\"],\"Umn7Cj\":[\"Inga kommentarer ännu. Var den första!\"],\"UtUIRh\":[[\"0\"],\" äldre meddelanden\"],\"UwzP+U\":[\"Säker anslutning\"],\"V0/A4O\":[\"Kanalägare\"],\"V4qgxE\":[\"Skapad före (min sedan)\"],\"V8yTm6\":[\"Rensa sökning\"],\"VJMMyz\":[\"ObsidianIRC - För IRC in i framtiden\"],\"VJScHU\":[\"Anledning\"],\"VLsmVV\":[\"Tysta aviseringar\"],\"VbyRUy\":[\"Kommentarer\"],\"Vmx0mQ\":[\"Satt av:\"],\"VqnIZz\":[\"Visa vår integritetspolicy och datapraxis\"],\"VrMygG\":[\"Minimal längd är \",[\"0\"]],\"VrnTui\":[\"Dina pronomen, visas i din profil\"],\"W8E3qn\":[\"Autentiserat konto\"],\"WAakm9\":[\"Ta bort kanal\"],\"WFxTHC\":[\"Lägg till banmask (t.ex. nick!*@*, *!*@host.com)\"],\"WN1g9F\":[\"Serveradress krävs\"],\"WRYdXW\":[\"Ljudposition\"],\"WUOH5B\":[\"Ignorera användare\"],\"WWEXnZ\":[[\"0\",\"plural\",{\"one\":[\"Visa 1 till\"],\"other\":[\"Visa \",[\"1\"],\" till\"]}]],\"Weq9zb\":[\"Allmänt\"],\"Wfj7Sk\":[\"Tysta eller aktivera aviseringsljud\"],\"Wm7gbG\":[\"GitHub:\"],\"WyeHWY\":[\"*skräp*\"],\"WzMCru\":[\"Användarprofil\"],\"X6S3lt\":[\"Sök inställningar, kanaler, servrar...\"],\"XEHan5\":[\"Fortsätt ändå\"],\"XI1+wb\":[\"Ogiltigt format\"],\"XIXeuC\":[\"Meddelande @\",[\"0\"]],\"XMS+k4\":[\"Starta privat meddelande\"],\"XWgxXq\":[\"Album\"],\"Xd7+IT\":[\"Lossa privatchatt\"],\"Xm/s+u\":[\"Visning\"],\"Xp2n93\":[\"Visar media från serverns betrodda filvärd. Inga begäranden görs till externa tjänster.\"],\"XvjC4F\":[\"Sparar...\"],\"Y/qryO\":[\"Inga användare hittades som matchar din sökning\"],\"YAqRpI\":[\"Kontoregistrering för \",[\"account\"],\" lyckades: \",[\"message\"]],\"YEfzvP\":[\"Skyddat ämne (+t)\"],\"YQOn6a\":[\"Dölj medlemslistan\"],\"YRCoE9\":[\"Kanaloperatör\"],\"YURQaF\":[\"Visa profil\"],\"YdBSvr\":[\"Styr medievisning och externt innehåll\"],\"Yj6U3V\":[\"Ingen central server:\"],\"YjvpGx\":[\"Pronomen\"],\"YqH4l4\":[\"Ingen nyckel\"],\"YyUPpV\":[\"Konto:\"],\"ZJSWfw\":[\"Meddelande som visas när du kopplar från servern\"],\"ZR1dJ4\":[\"Inbjudningar\"],\"ZdWg0V\":[\"Öppna i webbläsare\"],\"ZhRBbl\":[\"Sök meddelanden…\"],\"Zmcu3y\":[\"Avancerade filter\"],\"a2/8e5\":[\"Ämne angivet efter (min sedan)\"],\"aHKcKc\":[\"Föregående sida\"],\"aJTbXX\":[\"Oper-lösenord\"],\"aQryQv\":[\"Mönstret finns redan\"],\"aW9pLN\":[\"Maximalt antal användare som tillåts i kanalen. Lämna tomt för ingen gräns.\"],\"ah4fmZ\":[\"Visar också förhandsvisningar från YouTube, Vimeo, SoundCloud och liknande kända tjänster.\"],\"aifXak\":[\"Inget media i den här kanalen\"],\"ap2zBz\":[\"Avslappnat\"],\"az8lvo\":[\"Av\"],\"azXSNo\":[\"Expandera medlemslistan\"],\"azdliB\":[\"Logga in på ett konto\"],\"b26wlF\":[\"hon/hennes\"],\"bD/+Ei\":[\"Strikt\"],\"bQ6BJn\":[\"Konfigurera detaljerade flödeskyddsregler. Varje regel anger vilken typ av aktivitet som ska övervakas och vilken åtgärd som ska vidtas när gränser överskrids.\"],\"beV7+y\":[\"Användaren får en inbjudan att gå med i \",[\"channelName\"],\".\"],\"bk84cH\":[\"Borta-meddelande\"],\"bkHdLj\":[\"Lägg till IRC-server\"],\"bmQLn5\":[\"Lägg till regel\"],\"bv4cFj\":[\"Transport\"],\"bwRvnp\":[\"Åtgärd\"],\"c8+EVZ\":[\"Verifierat konto\"],\"cGYUlD\":[\"Inga medieförhandsvisningar laddas.\"],\"cLF98o\":[\"Visa kommentarer (\",[\"commentCount\"],\")\"],\"cLKIDO\":[\"Inga användare tillgängliga\"],\"cSgpoS\":[\"Fäst privatchatt\"],\"cde3ce\":[\"Meddelande <0>\",[\"0\"],\"\"],\"chQsxg\":[\"Kopiera formaterad utdata\"],\"cl/A5J\":[\"Välkommen till \",[\"__DEFAULT_IRC_SERVER_NAME__\"],\"!\"],\"cnGeoo\":[\"Ta bort\"],\"coPLXT\":[\"Vi lagrar inte dina IRC-kommunikationer på våra servrar\"],\"crYH/6\":[\"SoundCloud-spelare\"],\"cv5DQb\":[\"ingen värd angiven\"],\"d3sis4\":[\"Lägg till server\"],\"d9aN5k\":[\"Ta bort \",[\"username\"],\" från kanalen\"],\"dEgA5A\":[\"Avbryt\"],\"dGi1We\":[\"Lossa den här privata meddelandekonversationen\"],\"dJVuyC\":[\"lämnade \",[\"channelName\"],\" (\",[\"reason\"],\")\"],\"dMtLDE\":[\"till\"],\"dXqxlh\":[\"<0>⚠️ Säkerhetsrisk! Den här anslutningen kan vara sårbar för avlyssning eller man-in-the-middle-attacker.\"],\"da9Q/R\":[\"Ändrade kanallägen\"],\"dhJN3N\":[\"Visa kommentarer\"],\"dj2xTE\":[\"Stäng avisering\"],\"dpCzmC\":[\"Inställningar för flödeskydd\"],\"e9dQpT\":[\"Vill du öppna den här länken i en ny flik?\"],\"ePK91l\":[\"Redigera\"],\"eYBDuB\":[\"Ladda upp en bild eller ange en URL med valfri \",[\"size\"],\"-ersättning för dynamisk storleksanpassning\"],\"edBbee\":[\"Banna \",[\"username\"],\" via hostmask (förhindrar återanslutning från samma IP/host)\"],\"ekfzWq\":[\"Användarinställningar\"],\"elPDWs\":[\"Anpassa din IRC-klientupplevelse\"],\"eu2osY\":[\"<0>💡 Rekommendation: Fortsätt bara om du litar på den här servern och förstår riskerna. Undvik att dela känslig information eller lösenord via den här anslutningen.\"],\"euEhbr\":[\"Klicka för att gå med i \",[\"channel\"]],\"ez3vLd\":[\"Aktivera flerrads-inmatning\"],\"f0J5Ki\":[\"Server-till-server-kommunikation kan använda okrypterade anslutningar\"],\"f9BHJk\":[\"Varna användare\"],\"fDOLLd\":[\"Inga kanaler hittades.\"],\"ffzDkB\":[\"Anonym analys:\"],\"fq1GF9\":[\"Visa när användare kopplar från servern\"],\"gEF57C\":[\"Den här servern stöder bara en anslutningstyp\"],\"gJuLUI\":[\"Ignorera-lista\"],\"gNzMrk\":[\"Nuvarande avatar\"],\"gjPWyO\":[\"Ange smeknamn...\"],\"gz6UQ3\":[\"Maximera\"],\"h6/IMX\":[\"Lägg till ditt första nätverk\"],\"h6razj\":[\"Uteslut kanalnamns-mask\"],\"hG6jnw\":[\"Inget ämne angivet\"],\"hG89Ed\":[\"Bild\"],\"hZ6znB\":[\"Port\"],\"ha+Bz5\":[\"t.ex. 100:1440\"],\"hehnjM\":[\"Mängd\"],\"hzdLuQ\":[\"Endast användare med Voice eller högre kan tala\"],\"i0qMbr\":[\"Hem\"],\"iDNBZe\":[\"Aviseringar\"],\"iH8pgl\":[\"Tillbaka\"],\"iL9SZg\":[\"Banna användare (via nick)\"],\"iNt+3c\":[\"Tillbaka till bild\"],\"iQvi+a\":[\"Varna mig inte om låg länksäkerhet för den här servern\"],\"iSLIjg\":[\"Anslut\"],\"iWXkHH\":[\"Halfop\"],\"iZeTtp\":[\"Serveradress\"],\"idD8Ev\":[\"Sparat\"],\"iivqkW\":[\"Inloggad sedan\"],\"ij+Elv\":[\"Bildförhandsvisning\"],\"ilIWp7\":[\"Växla aviseringar\"],\"iuaqvB\":[\"Använd * som jokertecken. Exempel: baduser!*@*, *!*@spammer.com, troll*!*@*\"],\"ixkTse\":[\"Bot\"],\"j2DGR0\":[\"Banna via hostmask\"],\"jA4uoI\":[\"Ämne:\"],\"jLXxGK\":[\"https://example.com\"],\"jPSk57\":[\"Anledning (valfritt)\"],\"jUV7CU\":[\"Ladda upp avatar\"],\"jW5Uwh\":[\"Styr hur mycket externt media som laddas. Av / Säkert / Betrodda källor / Allt innehåll.\"],\"jXzms5\":[\"Bilagealternativ\"],\"jZlrte\":[\"Färg\"],\"jfC/xh\":[\"Kontakt\"],\"jywMpv\":[\"#nytt-kanalnamn\"],\"k112DD\":[\"Ladda äldre meddelanden\"],\"k3ID0F\":[\"Filtrera medlemmar…\"],\"k65gsE\":[\"Fördjupning\"],\"k7Zgob\":[\"Avbryt anslutning\"],\"kAVx5h\":[\"Inga inbjudningar hittades\"],\"kCLEPU\":[\"Ansluten till\"],\"kF5LKb\":[\"Ignorerade mönster:\"],\"kGeOx/\":[\"Gå med i \",[\"0\"]],\"kITKr8\":[\"Laddar kanallägen...\"],\"kPpPsw\":[\"Du är en IRC-operatör\"],\"kWJmRL\":[\"Du\"],\"kfcRb0\":[\"Avatar\"],\"kjMqSj\":[\"Kopiera JSON\"],\"krViRy\":[\"Klicka för att kopiera som JSON\"],\"ks71ra\":[\"Undantag\"],\"kw4lRv\":[\"Kanal-halvoperatör\"],\"kxgIRq\":[\"Välj eller lägg till en kanal för att komma igång.\"],\"ky6dWe\":[\"Förhandsvisning av avatar\"],\"l+GxCv\":[\"Laddar kanaler...\"],\"l+IUVW\":[\"Kontoverifiering för \",[\"account\"],\" lyckades: \",[\"message\"]],\"l/siQz\":[[\"reconnectCount\",\"plural\",{\"one\":[\"återanslöt\"],\"other\":[\"återanslöt \",[\"reconnectCount\"],\" gånger\"]}]],\"l5jmzx\":[[\"0\"],\" och \",[\"1\"],\" skriver...\"],\"lHy8N5\":[\"Laddar fler kanaler...\"],\"lbpf14\":[\"Gå med i \",[\"value\"]],\"lfFsZ4\":[\"Kanaler\"],\"lkNdiH\":[\"Kontonamn\"],\"ln500L\":[\"ObsidianIRC\"],\"lnCMdg\":[\"Ladda upp bild\"],\"loQxaJ\":[\"Jag är tillbaka\"],\"lvfaxv\":[\"HEM\"],\"m0oxpP\":[\"Libera Chat\"],\"m16xKo\":[\"Lägg till\"],\"m8flAk\":[\"Förhandsvisning (ej uppladdad ännu)\"],\"mEPxTp\":[\"<0>⚠️ Var försiktig! Öppna bara länkar från betrodda källor. Skadliga länkar kan äventyra din säkerhet eller integritet.\"],\"mHGdhG\":[\"Serverinformation\"],\"mHS8lb\":[\"Meddelande #\",[\"0\"]],\"mMYBD9\":[\"Brett – bredare skyddsomfång\"],\"mTGsPd\":[\"Kanalämne\"],\"mU8j6O\":[\"Inga externa meddelanden (+n)\"],\"mZp8FL\":[\"Automatisk återgång till enkel rad\"],\"mdQu8G\":[\"DittNick\"],\"miSSBQ\":[\"Kommentarer (\",[\"commentCount\"],\")\"],\"mvyLSy\":[\"Användaren är autentiserad\"],\"mwtcGl\":[\"Stäng kommentarer\"],\"myL0MR\":[\"Ta bort det här nätverket?\"],\"mzI/c+\":[\"Ladda ned\"],\"n3fGRk\":[\"angett av \",[\"0\"]],\"nE9jsU\":[\"Avslappnat – mindre aggressivt skydd\"],\"nNflMD\":[\"Lämna kanal\"],\"nPXkBi\":[\"Laddar WHOIS-data...\"],\"nQnxxF\":[\"Meddelande #\",[\"0\"],\" (Shift+Enter för ny rad)\"],\"nWMRxa\":[\"Lossa\"],\"nkC032\":[\"Ingen flödesprofil\"],\"o69z4d\":[\"Skicka ett varningsmeddelande till \",[\"username\"]],\"o9ylQi\":[\"Sök efter GIF:ar för att komma igång\"],\"oFGkER\":[\"Servermeddelanden\"],\"oOi11l\":[\"Scrolla till botten\"],\"oQEzQR\":[\"Nytt DM\"],\"oXOSPE\":[\"Online\"],\"oal760\":[\"Man-in-the-middle-attacker på serverlänkar är möjliga\"],\"oeqmmJ\":[\"Betrodda källor\"],\"ovBPCi\":[\"Standard\"],\"p0Z69r\":[\"Mönstret kan inte vara tomt\"],\"p1KgtK\":[\"Det gick inte att läsa in ljud\"],\"p59pEv\":[\"Ytterligare detaljer\"],\"p7sRI6\":[\"Låt andra se när du skriver\"],\"pBm1od\":[\"Hemlig kanal\"],\"pNmiXx\":[\"Ditt standard-nick för alla servrar\"],\"pUUo9G\":[\"Värdnamn:\"],\"pVGPmz\":[\"Kontolösenord\"],\"peNE68\":[\"Permanent\"],\"plhHQt\":[\"Inga data\"],\"pm6+q5\":[\"Säkerhetsvarning\"],\"pn5qSs\":[\"Ytterligare information\"],\"q0cR4S\":[\"är nu känd som **\",[\"newNick\"],\"**\"],\"qFcunY\":[\"Kanalen visas inte i LIST- eller NAMES-kommandon\"],\"qLpTm/\":[\"Ta bort reaktion \",[\"emoji\"]],\"qVkGWK\":[\"Fäst\"],\"qY8wNa\":[\"Hemsida\"],\"qb0xJ7\":[\"Använd jokertecken: * matchar valfri sekvens, ? matchar valfritt enskilt tecken. Exempel: nick!*@*, *!*@host.com, *!*user@*\"],\"qhzpRq\":[\"Kanalnyckel (+k)\"],\"qtoOYG\":[\"Ingen gräns\"],\"r1W2AS\":[\"Filserverbild\"],\"rIPR2O\":[\"Ämne angivet före (min sedan)\"],\"rMMSYo\":[\"Maximal längd är \",[\"0\"]],\"rWtzQe\":[\"Nätverket splittrades och återanslöts. ✅\"],\"rYG2u6\":[\"Vänta...\"],\"rdUucN\":[\"Förhandsvisning\"],\"rjGI/Q\":[\"Integritet\"],\"rk8iDX\":[\"Laddar GIF:ar...\"],\"rn6SBY\":[\"Sluta tysta\"],\"s/UKqq\":[\"Sparkades ut från kanalen\"],\"s8cATI\":[\"gick med i \",[\"channelName\"]],\"sCO9ue\":[\"Anslutningen till <0>\",[\"serverName\"],\" har följande säkerhetsproblem:\"],\"sGH11W\":[\"Server\"],\"sHI1H+\":[\"är nu känd som **\",[\"newNick\"],\"**\"],\"sJyV04\":[[\"inviter\"],\" bjöd in dig att gå med i \",[\"channel\"]],\"sUBSbK\":[\"Inga uppströmsnätverk ännu.\"],\"sby+1/\":[\"Klicka för att kopiera\"],\"sfN25C\":[\"Ditt riktiga eller fullständiga namn\"],\"sliuzR\":[\"Öppna länk\"],\"sqrO9R\":[\"Anpassade omnämnanden\"],\"sr6RdJ\":[\"Flerrad med Shift+Enter\"],\"swrCpB\":[\"Kanalen har döpts om från \",[\"oldName\"],\" till \",[\"newName\"],\" av \",[\"user\"],[\"0\"]],\"sxkWRg\":[\"Avancerat\"],\"t/YqKh\":[\"Ta bort\"],\"t47eHD\":[\"Din unika identifierare på den här servern\"],\"tAkAh0\":[\"URL med valfri \",[\"size\"],\"-ersättning för dynamisk storleksanpassning. Exempel: https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"tXLJS3\":[\"Visa eller dölj kanallistans sidopanel\"],\"tfDRzk\":[\"Spara\"],\"tiBsJk\":[\"lämnade \",[\"channelName\"]],\"tt4/UD\":[\"lämnade (\",[\"reason\"],\")\"],\"u0TcnO\":[\"Smeknamnet {nick} används redan, försöker med {newNick}\"],\"u0a8B4\":[\"Autentisera som IRC-operatör för administrativ åtkomst\"],\"u0rWFU\":[\"Skapad efter (min sedan)\"],\"u72w3t\":[\"Användare och mönster att ignorera\"],\"u7jc2L\":[\"lämnade\"],\"uAQUqI\":[\"Status\"],\"uB85T3\":[\"Sparning misslyckades: \",[\"msg\"]],\"uV3DOL\":[\"G-Line\"],\"uW3lLI\":[\"IRC-servrar:\"],\"usSSr/\":[\"Zoomnivå\"],\"v7uvcf\":[\"Programvara:\"],\"vE8kb+\":[\"Använd Shift+Enter för nya rader (Enter skickar)\"],\"vERlcd\":[\"Profil\"],\"vK0RL8\":[\"Inget ämne\"],\"vSJd18\":[\"Video\"],\"vXIe7J\":[\"Språk\"],\"vaHYxN\":[\"Riktigt namn\"],\"vhjbKr\":[\"Borta\"],\"w/nogd\":[[\"0\"],\" network\",[\"1\"],\" — pick one to join\"],\"w4NYox\":[[\"title\"],\" klient\"],\"w8xQRx\":[\"Ogiltigt värde\"],\"wFjjxZ\":[\"kastades ut från \",[\"channelName\"],\" av \",[\"username\"],\" (\",[\"reason\"],\")\"],\"wGjaGl\":[\"Inga banundantag hittades\"],\"wPrGnM\":[\"Kanaladmin\"],\"wRkP2d\":[\"GIF\"],\"wbm86v\":[\"Visa när användare går med i eller lämnar kanaler\"],\"whqZ9r\":[\"Ytterligare ord eller fraser att markera\"],\"wm7RV4\":[\"Aviseringsljud\"],\"wz/Yoq\":[\"Dina meddelanden kan avlyssnas när de vidarebefordras mellan servrar\"],\"xCJdfg\":[\"Rensa\"],\"xUHRTR\":[\"Autentisera automatiskt som operatör vid anslutning\"],\"xWHwwQ\":[\"Banningar\"],\"xYilR2\":[\"Media\"],\"xceQrO\":[\"Endast säkra websockets stöds\"],\"xdtXa+\":[\"kanalnamn\"],\"xfXC7q\":[\"Textkanaler\"],\"xlCYOE\":[\"Hämtar fler meddelanden...\"],\"xlhswE\":[\"Minimalt värde är \",[\"0\"]],\"xq97Ci\":[\"Lägg till ett ord eller en fras...\"],\"xuRqRq\":[\"Klientgräns (+l)\"],\"xwF+7J\":[[\"0\"],\" skriver...\"],\"yJztBY\":[\"Ta bort nätverk\"],\"yNeucF\":[\"Den här servern stöder inte utökad profilmetadata (IRCv3 METADATA-tillägget). Ytterligare fält som avatar, visningsnamn och status är inte tillgängliga.\"],\"yPlrca\":[\"Kanalavatar\"],\"yQE2r9\":[\"Laddar\"],\"ySU+JY\":[\"din@epost.se\"],\"yTX1Rt\":[\"Oper-användarnamn\"],\"yYOzWD\":[\"loggar\"],\"yfx9Re\":[\"IRC-operatörslösenord\"],\"ygCKqB\":[\"Stoppa\"],\"ymDxJx\":[\"IRC-operatörens användarnamn\"],\"yrpRsQ\":[\"Sortera efter namn\"],\"yz7wBu\":[\"Stäng\"],\"zJw+jA\":[\"anger läge: \",[\"0\"]],\"zebeLu\":[\"Ange oper-användarnamn\"],\"zpr0Bw\":[\"GZ-Line\"]}"); \ No newline at end of file diff --git a/src/locales/sv/messages.po b/src/locales/sv/messages.po index 59a0d338..584c624b 100644 --- a/src/locales/sv/messages.po +++ b/src/locales/sv/messages.po @@ -22,6 +22,16 @@ msgstr "ObsidianIRC - För IRC in i framtiden" msgid "— open in viewer" msgstr "— öppna i visaren" +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx +msgid "(inherit)" +msgstr "(ärv)" + +#: src/components/ui/BouncerNetworkForm.tsx +msgid "(unchanged)" +msgstr "(oförändrad)" + #. placeholder {0}: filteredMessages.length #. placeholder {1}: import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import type * as React from "react"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { SCROLL_TOLERANCE, useScrollToBottom, } from "../../hooks/useScrollToBottom"; import { groupConsecutiveEvents } from "../../lib/eventGrouping"; import ircClient from "../../lib/ircClient"; import useStore from "../../store"; import type { Message as MessageType } from "../../types"; import { CollapsedEventMessage } from "../message/CollapsedEventMessage"; import { MessageItem } from "../message/MessageItem"; import LoadingSpinner from "../ui/LoadingSpinner"; import { ScrollToBottomButton } from "../ui/ScrollToBottomButton"; export const DEFAULT_VISIBLE_MESSAGE_COUNT = 100; // Stable empty array — prevents selector from returning a new [] on every render // when the channel has no messages yet (undefined ?? [] would create a new ref each time). const EMPTY_MESSAGES: import("../../types").Message[] = []; export interface ChannelMessageListHandle { setAtBottom: () => void; scrollToBottom: () => void; getScrollState: () => { scrollTop: number; isAtBottom: boolean; visibleCount: number; }; } interface ChannelMessageListProps { channelKey: string; serverId: string; channelId: string | null; privateChatId: string | null; isActive: boolean; searchQuery: string; isMemberListVisible: boolean; onReply: (msg: MessageType | null) => void; onUsernameContextMenu: ( e: React.MouseEvent, username: string, serverId: string, channelId: string, avatarEl?: Element | null, ) => void; onIrcLinkClick: (url: string) => void; onReactClick: (msg: MessageType, el: Element) => void; onReactionUnreact: (emoji: string, msg: MessageType) => void; onOpenReactionModal: ( msg: MessageType, position: { x: number; y: number }, ) => void; onDirectReaction: (emoji: string, msg: MessageType) => void; onRedactMessage: (msg: MessageType) => void; onOpenProfile: (username: string) => void; joinChannel: (serverId: string, channelName: string) => void; onClearSearch: () => void; highlightedMessageId?: string; // undefined = first visit; null = was at bottom; object = restore to saved position initialScrollState?: { scrollTop: number; visibleCount: number } | null; } export const ChannelMessageList = forwardRef< ChannelMessageListHandle, ChannelMessageListProps >( ( { channelKey, serverId, channelId, privateChatId, isActive, searchQuery, isMemberListVisible, onReply, onUsernameContextMenu, onIrcLinkClick, onReactClick, onReactionUnreact, onOpenReactionModal, onDirectReaction, onRedactMessage, onOpenProfile, joinChannel, onClearSearch, highlightedMessageId, initialScrollState, }, ref, ) => { const { t } = useLingui(); const [visibleMessageCount, setVisibleMessageCount] = useState( initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT, ); // Ref mirror so getScrollState closure always reads the current value without needing it as a dep. const visibleMessageCountRef = useRef(visibleMessageCount); visibleMessageCountRef.current = visibleMessageCount; // Distinguishes initial join (full-screen spinner) from subsequent "load more" (button spinner). const [isFetchingMore, setIsFetchingMore] = useState(false); const isFetchingMoreRef = useRef(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const messagesInnerRef = useRef(null); // prev scrollHeight for prepend delta-correction. const prevScrollHeightRef = useRef(0); // Ref mirror of isScrolledUp — lets useLayoutEffect closures read current value // without listing isScrolledUp as a dep (which would re-run effects on every scroll). const isScrolledUpRef = useRef(false); const prevFilteredLengthRef = useRef(0); const prevFirstMsgIdRef = useRef(null); // Set by the window-growth layoutEffect (or button handler) when a true prepend is detected. // Consumed by the delta-correction layoutEffect one render later (after visibleCount grows). // Using a flag instead of tracking displayedMessages[0]?.id because slice(-N) slides the // window on every bottom append, changing displayedMessages[0] even for non-prepend renders. const pendingPrependRef = useRef(false); // Shared scrollHeight baseline between the delta-correction layout effect and the inner // ResizeObserver. When scrollTop is corrected after a prepend, we update this so the RO's // "was at bottom" check is not fooled by the adjusted scrollTop vs its stale prevSH. const resizeObserverPrevSHRef = useRef(0); const channelMessages = useStore( useCallback( (state) => state.messages[channelKey] ?? EMPTY_MESSAGES, [channelKey], ), ); const servers = useStore((state) => state.servers); const mobileViewActiveColumn = useStore( (state) => state.ui.mobileViewActiveColumn, ); const channel = useMemo( () => channelId ? (servers .find((s) => s.id === serverId) ?.channels.find((c) => c.id === channelId) ?? null) : null, [servers, serverId, channelId], ); const { isScrolledUp, wasAtBottomRef, scrollToBottom } = useScrollToBottom( messagesContainerRef, messagesEndRef, { channelId: `${channelId || privateChatId}-${isMemberListVisible}` }, ); // Snapshot of the last known scroll position captured while the container was visible. // getScrollState() reads this instead of the live DOM because React commits display:none // before running cleanup effects, collapsing scrollTop/scrollHeight/clientHeight to 0. const lastScrollTopRef = useRef(initialScrollState?.scrollTop ?? 0); useEffect(() => { const container = messagesContainerRef.current; if (!container) return; const onScroll = () => { if (container.clientHeight > 0) lastScrollTopRef.current = container.scrollTop; }; container.addEventListener("scroll", onScroll, { passive: true }); return () => container.removeEventListener("scroll", onScroll); }, []); // Restore scroll position when a keep-alive channel transitions from hidden to visible. // display:none may reset scrollTop to 0; lastScrollTopRef was captured while visible. const prevActiveRef = useRef(isActive); useLayoutEffect(() => { if (isActive && !prevActiveRef.current) { const container = messagesContainerRef.current; if (container && lastScrollTopRef.current > 0) { container.scrollTop = lastScrollTopRef.current; } } prevActiveRef.current = isActive; }, [isActive]); useImperativeHandle(ref, () => ({ setAtBottom: () => { wasAtBottomRef.current = true; }, scrollToBottom, getScrollState: () => ({ scrollTop: lastScrollTopRef.current, isAtBottom: wasAtBottomRef.current, visibleCount: visibleMessageCountRef.current, }), })); const filteredMessages = useMemo(() => { if (!searchQuery.trim()) return channelMessages; const query = searchQuery.toLowerCase(); return channelMessages.filter( (msg) => msg.content.toLowerCase().includes(query) || msg.userId.toLowerCase().includes(query), ); }, [channelMessages, searchQuery]); useEffect(() => { isScrolledUpRef.current = isScrolledUp; // When the user returns to the bottom, shrink the window back to the base so // slice(-N) resumes trimming old messages from the top (memory optimization). // Only shrink if we grew above the base — preserves a sub-default saved visibleCount. if (!isScrolledUp) { setVisibleMessageCount((prev) => prev > DEFAULT_VISIBLE_MESSAGE_COUNT ? DEFAULT_VISIBLE_MESSAGE_COUNT : prev, ); } }, [isScrolledUp]); // Reset ref-tracked windowing state when switching channels. // visibleMessageCount is NOT reset here — useState(initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT) // already initializes it correctly on mount, and this effect runs once on mount for the // same channelKey (each instance is bound to exactly one channel by the parent key={}). // biome-ignore lint/correctness/useExhaustiveDependencies: intentional full reset on channel change useEffect(() => { prevFilteredLengthRef.current = 0; prevFirstMsgIdRef.current = null; prevScrollHeightRef.current = 0; pendingPrependRef.current = false; resizeObserverPrevSHRef.current = 0; }, [channelKey]); const displayedMessages = useMemo(() => { if (searchQuery.trim()) return filteredMessages; return filteredMessages.slice(-visibleMessageCount); }, [filteredMessages, visibleMessageCount, searchQuery]); const locallyHidden = filteredMessages.length > displayedMessages.length; const serverHasMore = channel?.hasMoreHistory === true; const hasMoreMessages = locallyHidden || serverHasMore; const eventGroups = useMemo( () => groupConsecutiveEvents(displayedMessages), [displayedMessages], ); const isLoadingHistory = channel?.isLoadingHistory ?? false; // Scroll to bottom on initial mount, unless a saved position was passed in. // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount only useEffect(() => { const container = messagesContainerRef.current; if (!container) return; if (initialScrollState) { container.scrollTop = initialScrollState.scrollTop; lastScrollTopRef.current = initialScrollState.scrollTop; wasAtBottomRef.current = false; } else { container.scrollTop = container.scrollHeight; lastScrollTopRef.current = container.scrollHeight; wasAtBottomRef.current = true; } }, []); // Scroll to bottom after initial join history loads; clear fetch spinner at batch end. const wasLoadingHistoryRef = useRef(false); // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is stable via useCallback; refs and setters are stable useLayoutEffect(() => { if (wasLoadingHistoryRef.current && !isLoadingHistory) { if (isFetchingMoreRef.current) { // delta correction for scroll position is handled by useLayoutEffect([displayedMessages]) isFetchingMoreRef.current = false; setIsFetchingMore(false); } else { scrollToBottom(); wasAtBottomRef.current = true; } } wasLoadingHistoryRef.current = isLoadingHistory; }, [isLoadingHistory]); // When older messages are prepended, grow the window so they enter displayedMessages. // When new messages arrive at the bottom while the user is scrolled up, also grow the // window to keep the current top messages visible — slice(-N) otherwise slides the // window forward and hides them, incrementing the "N older messages" counter on every // incoming message. Only let the slice trim from the top when the user is at the bottom // (where auto-scroll handles keeping them current). useLayoutEffect(() => { const newLength = filteredMessages.length; const newFirstId = filteredMessages[0]?.id ?? null; const delta = newLength - prevFilteredLengthRef.current; if (prevFilteredLengthRef.current > 0 && delta > 0) { if (newFirstId !== prevFirstMsgIdRef.current) { // Messages prepended (load-more): signal delta-correction to compensate scrollTop. pendingPrependRef.current = true; setVisibleMessageCount((prev) => prev + delta); } else if (isScrolledUpRef.current) { // Messages appended at bottom while user is scrolled up reading history. // Expand the window to prevent top messages from dropping out of the slice. setVisibleMessageCount((prev) => prev + delta); } } prevFilteredLengthRef.current = newLength; prevFirstMsgIdRef.current = newFirstId; }, [filteredMessages]); // Compensate scrollTop when content is prepended above the viewport. // biome-ignore lint/correctness/useExhaustiveDependencies: runs on every displayedMessages render to capture the resulting scrollHeight; refs are stable useLayoutEffect(() => { const container = messagesContainerRef.current; if (!container) return; // Skip while container is display:none — scrollHeight collapses to 0 and would // poison prevScrollHeightRef, causing a huge spurious delta on the next visible render. if (container.clientHeight === 0) return; const prevHeight = prevScrollHeightRef.current; const newHeight = container.scrollHeight; // Only correct when a true load-more prepend happened (flag set by the window-growth // layoutEffect or button handler). Bottom appends slide the slice(-N) window which also // changes displayedMessages[0] — ID-comparison can't distinguish the two cases. const wasPrepend = pendingPrependRef.current; // Only consume the flag when scrollHeight actually changed — the server-side load-more // path goes through two renders: Render A (filteredMessages grows, visibleCount unchanged, // same displayedMessages content, same scrollHeight) then Render B (visibleCount grows, // new messages enter displayedMessages, scrollHeight grows). The flag must survive Render A // so it's still set when Render B fires the actual correction. if (wasPrepend && newHeight !== prevHeight) { pendingPrependRef.current = false; } if ( isScrolledUpRef.current && prevHeight > 0 && newHeight > prevHeight && wasPrepend ) { const delta = newHeight - prevHeight; container.scrollTop += delta; resizeObserverPrevSHRef.current = newHeight; } prevScrollHeightRef.current = newHeight; }, [displayedMessages]); // Re-stick to bottom when inner message content grows (media/audio previews loading). // Uses prevScrollHeight instead of wasAtBottomRef to avoid stale-flag race where the // ref is true while the user is actively scrolling up. // When the container width changes (member list toggle, window resize), text reflows // and scrollHeight changes; preserve proportional scroll position for scrolled-up users. // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is a stable ref useEffect(() => { const container = messagesContainerRef.current; const inner = messagesInnerRef.current; if (!inner || !container) return; resizeObserverPrevSHRef.current = container.scrollHeight; let prevClientWidth = container.clientWidth; const observer = new ResizeObserver(() => { if (container.clientHeight === 0) return; // Effect may re-initialize while container is display:none (ref=0). // Re-seed with current dimensions and skip — no reliable "was at bottom" data. if (resizeObserverPrevSHRef.current === 0) { resizeObserverPrevSHRef.current = container.scrollHeight; prevClientWidth = container.clientWidth; return; } const currentClientWidth = container.clientWidth; const widthChanged = currentClientWidth !== prevClientWidth; prevClientWidth = currentClientWidth; const prevSH = resizeObserverPrevSHRef.current; const wasAtPrevBottom = container.scrollTop + container.clientHeight >= prevSH - SCROLL_TOLERANCE; resizeObserverPrevSHRef.current = container.scrollHeight; if (wasAtPrevBottom) { scrollToBottom(); } else if (widthChanged && prevSH > 0) { const ratio = container.scrollTop / prevSH; container.scrollTop = Math.round(ratio * container.scrollHeight); } }); observer.observe(inner); return () => observer.disconnect(); }, [isLoadingHistory, channelId, privateChatId]); // Auto-scroll on new messages — skip when this channel is hidden (display:none). // biome-ignore lint/correctness/useExhaustiveDependencies: only scroll when messages change, not when isActive changes useEffect(() => { if (!isActive) return; const isNarrowView = window.matchMedia("(max-width: 768px)").matches; const isChatVisible = !isNarrowView || mobileViewActiveColumn === "chatView"; if (wasAtBottomRef.current && isChatVisible) { scrollToBottom(); } }, [displayedMessages, mobileViewActiveColumn, scrollToBottom, isActive]); return ( <>
{isLoadingHistory && !isFetchingMore ? (
) : (
{hasMoreMessages && !searchQuery && (
)} {searchQuery && (
{plural(filteredMessages.length, { one: t`Found 1 message matching "${searchQuery}"`, other: t`Found ${filteredMessages.length} messages matching "${searchQuery}"`, })}
)} {eventGroups.map((group) => { if (group.type === "eventGroup") { const firstId = group.messages[0]?.id || ""; const lastId = group.messages[group.messages.length - 1]?.id || ""; const groupKey = `group-${firstId}-${lastId}`; return ( ); } const message = group.messages[0]; const originalIndex = channelMessages.findIndex( (m) => m.id === message.id, ); const previousMessage = channelMessages[originalIndex - 1]; const showHeader = !previousMessage || previousMessage.type !== "message" || previousMessage.userId !== message.userId || new Date(message.timestamp).getTime() - new Date(previousMessage.timestamp).getTime() > 5 * 60 * 1000; return ( ); })}
)}
); }, ); ChannelMessageList.displayName = "ChannelMessageList"; // Wrap with memo so hidden keep-alive channels skip re-renders when their props // haven't changed (e.g. when messageText changes in the input — the only thing // that changes on typing is local state inside ChatArea, not the props we pass here). export const MemoChannelMessageList = memo(ChannelMessageList); #. placeholder {2}: import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import type * as React from "react"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { SCROLL_TOLERANCE, useScrollToBottom, } from "../../hooks/useScrollToBottom"; import { groupConsecutiveEvents } from "../../lib/eventGrouping"; import ircClient from "../../lib/ircClient"; import useStore from "../../store"; import type { Message as MessageType } from "../../types"; import { CollapsedEventMessage } from "../message/CollapsedEventMessage"; import { MessageItem } from "../message/MessageItem"; import LoadingSpinner from "../ui/LoadingSpinner"; import { ScrollToBottomButton } from "../ui/ScrollToBottomButton"; export const DEFAULT_VISIBLE_MESSAGE_COUNT = 100; // Stable empty array — prevents selector from returning a new [] on every render // when the channel has no messages yet (undefined ?? [] would create a new ref each time). const EMPTY_MESSAGES: import("../../types").Message[] = []; export interface ChannelMessageListHandle { setAtBottom: () => void; scrollToBottom: () => void; getScrollState: () => { scrollTop: number; isAtBottom: boolean; visibleCount: number; }; } interface ChannelMessageListProps { channelKey: string; serverId: string; channelId: string | null; privateChatId: string | null; isActive: boolean; searchQuery: string; isMemberListVisible: boolean; onReply: (msg: MessageType | null) => void; onUsernameContextMenu: ( e: React.MouseEvent, username: string, serverId: string, channelId: string, avatarEl?: Element | null, ) => void; onIrcLinkClick: (url: string) => void; onReactClick: (msg: MessageType, el: Element) => void; onReactionUnreact: (emoji: string, msg: MessageType) => void; onOpenReactionModal: ( msg: MessageType, position: { x: number; y: number }, ) => void; onDirectReaction: (emoji: string, msg: MessageType) => void; onRedactMessage: (msg: MessageType) => void; onOpenProfile: (username: string) => void; joinChannel: (serverId: string, channelName: string) => void; onClearSearch: () => void; highlightedMessageId?: string; // undefined = first visit; null = was at bottom; object = restore to saved position initialScrollState?: { scrollTop: number; visibleCount: number } | null; } export const ChannelMessageList = forwardRef< ChannelMessageListHandle, ChannelMessageListProps >( ( { channelKey, serverId, channelId, privateChatId, isActive, searchQuery, isMemberListVisible, onReply, onUsernameContextMenu, onIrcLinkClick, onReactClick, onReactionUnreact, onOpenReactionModal, onDirectReaction, onRedactMessage, onOpenProfile, joinChannel, onClearSearch, highlightedMessageId, initialScrollState, }, ref, ) => { const { t } = useLingui(); const [visibleMessageCount, setVisibleMessageCount] = useState( initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT, ); // Ref mirror so getScrollState closure always reads the current value without needing it as a dep. const visibleMessageCountRef = useRef(visibleMessageCount); visibleMessageCountRef.current = visibleMessageCount; // Distinguishes initial join (full-screen spinner) from subsequent "load more" (button spinner). const [isFetchingMore, setIsFetchingMore] = useState(false); const isFetchingMoreRef = useRef(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const messagesInnerRef = useRef(null); // prev scrollHeight for prepend delta-correction. const prevScrollHeightRef = useRef(0); // Ref mirror of isScrolledUp — lets useLayoutEffect closures read current value // without listing isScrolledUp as a dep (which would re-run effects on every scroll). const isScrolledUpRef = useRef(false); const prevFilteredLengthRef = useRef(0); const prevFirstMsgIdRef = useRef(null); // Set by the window-growth layoutEffect (or button handler) when a true prepend is detected. // Consumed by the delta-correction layoutEffect one render later (after visibleCount grows). // Using a flag instead of tracking displayedMessages[0]?.id because slice(-N) slides the // window on every bottom append, changing displayedMessages[0] even for non-prepend renders. const pendingPrependRef = useRef(false); // Shared scrollHeight baseline between the delta-correction layout effect and the inner // ResizeObserver. When scrollTop is corrected after a prepend, we update this so the RO's // "was at bottom" check is not fooled by the adjusted scrollTop vs its stale prevSH. const resizeObserverPrevSHRef = useRef(0); const channelMessages = useStore( useCallback( (state) => state.messages[channelKey] ?? EMPTY_MESSAGES, [channelKey], ), ); const servers = useStore((state) => state.servers); const mobileViewActiveColumn = useStore( (state) => state.ui.mobileViewActiveColumn, ); const channel = useMemo( () => channelId ? (servers .find((s) => s.id === serverId) ?.channels.find((c) => c.id === channelId) ?? null) : null, [servers, serverId, channelId], ); const { isScrolledUp, wasAtBottomRef, scrollToBottom } = useScrollToBottom( messagesContainerRef, messagesEndRef, { channelId: `${channelId || privateChatId}-${isMemberListVisible}` }, ); // Snapshot of the last known scroll position captured while the container was visible. // getScrollState() reads this instead of the live DOM because React commits display:none // before running cleanup effects, collapsing scrollTop/scrollHeight/clientHeight to 0. const lastScrollTopRef = useRef(initialScrollState?.scrollTop ?? 0); useEffect(() => { const container = messagesContainerRef.current; if (!container) return; const onScroll = () => { if (container.clientHeight > 0) lastScrollTopRef.current = container.scrollTop; }; container.addEventListener("scroll", onScroll, { passive: true }); return () => container.removeEventListener("scroll", onScroll); }, []); // Restore scroll position when a keep-alive channel transitions from hidden to visible. // display:none may reset scrollTop to 0; lastScrollTopRef was captured while visible. const prevActiveRef = useRef(isActive); useLayoutEffect(() => { if (isActive && !prevActiveRef.current) { const container = messagesContainerRef.current; if (container && lastScrollTopRef.current > 0) { container.scrollTop = lastScrollTopRef.current; } } prevActiveRef.current = isActive; }, [isActive]); useImperativeHandle(ref, () => ({ setAtBottom: () => { wasAtBottomRef.current = true; }, scrollToBottom, getScrollState: () => ({ scrollTop: lastScrollTopRef.current, isAtBottom: wasAtBottomRef.current, visibleCount: visibleMessageCountRef.current, }), })); const filteredMessages = useMemo(() => { if (!searchQuery.trim()) return channelMessages; const query = searchQuery.toLowerCase(); return channelMessages.filter( (msg) => msg.content.toLowerCase().includes(query) || msg.userId.toLowerCase().includes(query), ); }, [channelMessages, searchQuery]); useEffect(() => { isScrolledUpRef.current = isScrolledUp; // When the user returns to the bottom, shrink the window back to the base so // slice(-N) resumes trimming old messages from the top (memory optimization). // Only shrink if we grew above the base — preserves a sub-default saved visibleCount. if (!isScrolledUp) { setVisibleMessageCount((prev) => prev > DEFAULT_VISIBLE_MESSAGE_COUNT ? DEFAULT_VISIBLE_MESSAGE_COUNT : prev, ); } }, [isScrolledUp]); // Reset ref-tracked windowing state when switching channels. // visibleMessageCount is NOT reset here — useState(initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT) // already initializes it correctly on mount, and this effect runs once on mount for the // same channelKey (each instance is bound to exactly one channel by the parent key={}). // biome-ignore lint/correctness/useExhaustiveDependencies: intentional full reset on channel change useEffect(() => { prevFilteredLengthRef.current = 0; prevFirstMsgIdRef.current = null; prevScrollHeightRef.current = 0; pendingPrependRef.current = false; resizeObserverPrevSHRef.current = 0; }, [channelKey]); const displayedMessages = useMemo(() => { if (searchQuery.trim()) return filteredMessages; return filteredMessages.slice(-visibleMessageCount); }, [filteredMessages, visibleMessageCount, searchQuery]); const locallyHidden = filteredMessages.length > displayedMessages.length; const serverHasMore = channel?.hasMoreHistory === true; const hasMoreMessages = locallyHidden || serverHasMore; const eventGroups = useMemo( () => groupConsecutiveEvents(displayedMessages), [displayedMessages], ); const isLoadingHistory = channel?.isLoadingHistory ?? false; // Scroll to bottom on initial mount, unless a saved position was passed in. // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount only useEffect(() => { const container = messagesContainerRef.current; if (!container) return; if (initialScrollState) { container.scrollTop = initialScrollState.scrollTop; lastScrollTopRef.current = initialScrollState.scrollTop; wasAtBottomRef.current = false; } else { container.scrollTop = container.scrollHeight; lastScrollTopRef.current = container.scrollHeight; wasAtBottomRef.current = true; } }, []); // Scroll to bottom after initial join history loads; clear fetch spinner at batch end. const wasLoadingHistoryRef = useRef(false); // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is stable via useCallback; refs and setters are stable useLayoutEffect(() => { if (wasLoadingHistoryRef.current && !isLoadingHistory) { if (isFetchingMoreRef.current) { // delta correction for scroll position is handled by useLayoutEffect([displayedMessages]) isFetchingMoreRef.current = false; setIsFetchingMore(false); } else { scrollToBottom(); wasAtBottomRef.current = true; } } wasLoadingHistoryRef.current = isLoadingHistory; }, [isLoadingHistory]); // When older messages are prepended, grow the window so they enter displayedMessages. // When new messages arrive at the bottom while the user is scrolled up, also grow the // window to keep the current top messages visible — slice(-N) otherwise slides the // window forward and hides them, incrementing the "N older messages" counter on every // incoming message. Only let the slice trim from the top when the user is at the bottom // (where auto-scroll handles keeping them current). useLayoutEffect(() => { const newLength = filteredMessages.length; const newFirstId = filteredMessages[0]?.id ?? null; const delta = newLength - prevFilteredLengthRef.current; if (prevFilteredLengthRef.current > 0 && delta > 0) { if (newFirstId !== prevFirstMsgIdRef.current) { // Messages prepended (load-more): signal delta-correction to compensate scrollTop. pendingPrependRef.current = true; setVisibleMessageCount((prev) => prev + delta); } else if (isScrolledUpRef.current) { // Messages appended at bottom while user is scrolled up reading history. // Expand the window to prevent top messages from dropping out of the slice. setVisibleMessageCount((prev) => prev + delta); } } prevFilteredLengthRef.current = newLength; prevFirstMsgIdRef.current = newFirstId; }, [filteredMessages]); // Compensate scrollTop when content is prepended above the viewport. // biome-ignore lint/correctness/useExhaustiveDependencies: runs on every displayedMessages render to capture the resulting scrollHeight; refs are stable useLayoutEffect(() => { const container = messagesContainerRef.current; if (!container) return; // Skip while container is display:none — scrollHeight collapses to 0 and would // poison prevScrollHeightRef, causing a huge spurious delta on the next visible render. if (container.clientHeight === 0) return; const prevHeight = prevScrollHeightRef.current; const newHeight = container.scrollHeight; // Only correct when a true load-more prepend happened (flag set by the window-growth // layoutEffect or button handler). Bottom appends slide the slice(-N) window which also // changes displayedMessages[0] — ID-comparison can't distinguish the two cases. const wasPrepend = pendingPrependRef.current; // Only consume the flag when scrollHeight actually changed — the server-side load-more // path goes through two renders: Render A (filteredMessages grows, visibleCount unchanged, // same displayedMessages content, same scrollHeight) then Render B (visibleCount grows, // new messages enter displayedMessages, scrollHeight grows). The flag must survive Render A // so it's still set when Render B fires the actual correction. if (wasPrepend && newHeight !== prevHeight) { pendingPrependRef.current = false; } if ( isScrolledUpRef.current && prevHeight > 0 && newHeight > prevHeight && wasPrepend ) { const delta = newHeight - prevHeight; container.scrollTop += delta; resizeObserverPrevSHRef.current = newHeight; } prevScrollHeightRef.current = newHeight; }, [displayedMessages]); // Re-stick to bottom when inner message content grows (media/audio previews loading). // Uses prevScrollHeight instead of wasAtBottomRef to avoid stale-flag race where the // ref is true while the user is actively scrolling up. // When the container width changes (member list toggle, window resize), text reflows // and scrollHeight changes; preserve proportional scroll position for scrolled-up users. // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is a stable ref useEffect(() => { const container = messagesContainerRef.current; const inner = messagesInnerRef.current; if (!inner || !container) return; resizeObserverPrevSHRef.current = container.scrollHeight; let prevClientWidth = container.clientWidth; const observer = new ResizeObserver(() => { if (container.clientHeight === 0) return; // Effect may re-initialize while container is display:none (ref=0). // Re-seed with current dimensions and skip — no reliable "was at bottom" data. if (resizeObserverPrevSHRef.current === 0) { resizeObserverPrevSHRef.current = container.scrollHeight; prevClientWidth = container.clientWidth; return; } const currentClientWidth = container.clientWidth; const widthChanged = currentClientWidth !== prevClientWidth; prevClientWidth = currentClientWidth; const prevSH = resizeObserverPrevSHRef.current; const wasAtPrevBottom = container.scrollTop + container.clientHeight >= prevSH - SCROLL_TOLERANCE; resizeObserverPrevSHRef.current = container.scrollHeight; if (wasAtPrevBottom) { scrollToBottom(); } else if (widthChanged && prevSH > 0) { const ratio = container.scrollTop / prevSH; container.scrollTop = Math.round(ratio * container.scrollHeight); } }); observer.observe(inner); return () => observer.disconnect(); }, [isLoadingHistory, channelId, privateChatId]); // Auto-scroll on new messages — skip when this channel is hidden (display:none). // biome-ignore lint/correctness/useExhaustiveDependencies: only scroll when messages change, not when isActive changes useEffect(() => { if (!isActive) return; const isNarrowView = window.matchMedia("(max-width: 768px)").matches; const isChatVisible = !isNarrowView || mobileViewActiveColumn === "chatView"; if (wasAtBottomRef.current && isChatVisible) { scrollToBottom(); } }, [displayedMessages, mobileViewActiveColumn, scrollToBottom, isActive]); return ( <>
{isLoadingHistory && !isFetchingMore ? (
) : (
{hasMoreMessages && !searchQuery && (
)} {searchQuery && (
{plural(filteredMessages.length, { one: t`Found 1 message matching "${searchQuery}"`, other: t`Found ${filteredMessages.length} messages matching "${searchQuery}"`, })}
)} {eventGroups.map((group) => { if (group.type === "eventGroup") { const firstId = group.messages[0]?.id || ""; const lastId = group.messages[group.messages.length - 1]?.id || ""; const groupKey = `group-${firstId}-${lastId}`; return ( ); } const message = group.messages[0]; const originalIndex = channelMessages.findIndex( (m) => m.id === message.id, ); const previousMessage = channelMessages[originalIndex - 1]; const showHeader = !previousMessage || previousMessage.type !== "message" || previousMessage.userId !== message.userId || new Date(message.timestamp).getTime() - new Date(previousMessage.timestamp).getTime() > 5 * 60 * 1000; return ( ); })}
)}
); }, ); ChannelMessageList.displayName = "ChannelMessageList"; // Wrap with memo so hidden keep-alive channels skip re-renders when their props // haven't changed (e.g. when messageText changes in the input — the only thing // that changes on typing is local state inside ChatArea, not the props we pass here). export const MemoChannelMessageList = memo(ChannelMessageList); @@ -46,6 +56,12 @@ msgstr "{0} och {1} skriver..." msgid "{0} is typing..." msgstr "{0} skriver..." +#. placeholder {0}: networks.length +#. placeholder {1}: networks.length === 1 ? "" : "s" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "{0} network{1} — pick one to join" +msgstr "" + #. placeholder {0}: filteredMessages.length - displayedMessages.length #: src/components/layout/ChannelMessageList.tsx msgid "{0} older messages" @@ -186,6 +202,12 @@ msgstr "Lägg till inbjudningsmask (t.ex. nick!*@*, *!*@host.com)" msgid "Add IRC Server" msgstr "Lägg till IRC-server" +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworksPanel.tsx +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Add Network" +msgstr "Lägg till nätverk" + #: src/components/message/MessageActions.tsx #: src/components/message/MessageReactions.tsx #: src/components/message/MessageReactions.tsx @@ -205,6 +227,10 @@ msgstr "Lägg till regel" msgid "Add Server" msgstr "Lägg till server" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Add your first network" +msgstr "Lägg till ditt första nätverk" + #: src/components/message/JsonLogMessage.tsx msgid "Additional Details" msgstr "Ytterligare detaljer" @@ -358,6 +384,10 @@ msgstr "Tillbaka" msgid "Back to image" msgstr "Tillbaka till bild" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Back to network list" +msgstr "" + #: src/components/ui/ModerationModal.tsx msgid "Ban {username} by hostmask (prevents them from rejoining from the same IP/host)" msgstr "Banna {username} via hostmask (förhindrar återanslutning från samma IP/host)" @@ -405,6 +435,8 @@ msgstr "Bläddra bland alla kanaler på servern" #: src/components/ui/AddPrivateChatModal.tsx #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/ChannelSettingsModal.tsx #: src/components/ui/ExternalLinkWarningModal.tsx #: src/components/ui/FloodSettingsModal.tsx @@ -640,6 +672,7 @@ msgid "Configure notification sounds and highlights" msgstr "Konfigurera aviseringsljud och markeringar" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworksPanel.tsx msgid "Connect" msgstr "Anslut" @@ -759,6 +792,10 @@ msgstr "Ta bort kanal" msgid "Delete message" msgstr "Ta bort meddelande" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Delete network" +msgstr "Ta bort nätverk" + #: src/components/layout/ChannelList.tsx msgid "Delete Private Chat" msgstr "Ta bort privatchatt" @@ -767,6 +804,10 @@ msgstr "Ta bort privatchatt" msgid "Delete this message? This cannot be undone." msgstr "Ta bort det här meddelandet? Det kan inte ångras." +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Delete this network?" +msgstr "Ta bort det här nätverket?" + #: src/components/layout/ServerList.tsx #: src/components/mobile/ServerBottomSheet.tsx msgid "Disconnect" @@ -830,10 +871,16 @@ msgstr "Ladda ned" msgid "e.g., 100:1440" msgstr "t.ex. 100:1440" +#: src/components/ui/BouncerNetworksPanel.tsx #: src/components/ui/ChannelSettingsModal.tsx msgid "Edit" msgstr "Redigera" +#. placeholder {0}: editingNetwork?.attributes.name || editingNetwork?.netid +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Edit {0}" +msgstr "Redigera {0}" + #: src/components/ui/UserProfileModal.tsx msgid "Edit Profile" msgstr "Redigera profil" @@ -1057,6 +1104,7 @@ msgstr "HEM" msgid "Homepage" msgstr "Hemsida" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserProfileModal.tsx msgid "Host" msgstr "Host" @@ -1271,6 +1319,10 @@ msgstr "Lämnade kanalen" msgid "Let others know when you are typing" msgstr "Låt andra se när du skriver" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Libera Chat" +msgstr "Libera Chat" + #: src/components/message/LinkPreview.tsx msgid "Link preview" msgstr "Länkförhandsvisning" @@ -1299,6 +1351,10 @@ msgstr "Laddar GIF:ar..." msgid "Loading more channels..." msgstr "Laddar fler kanaler..." +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Loading networks from your bouncer…" +msgstr "Laddar nätverk från din bouncer…" + #: src/components/ui/UserProfileModal.tsx msgid "Loading WHOIS data..." msgstr "Laddar WHOIS-data..." @@ -1486,9 +1542,15 @@ msgid "Name:" msgstr "Namn:" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx msgid "Network Name" msgstr "Nätverksnamn" +#. placeholder {0}: server?.name ?? bouncerServerId +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Networks on {0}" +msgstr "Nätverk på {0}" + #: src/components/ui/QuickActions.tsx msgid "New DM" msgstr "Nytt DM" @@ -1511,6 +1573,7 @@ msgid "nick!user@host (e.g., spam*!*@*, *!*@badhost.com)" msgstr "nick!user@host (t.ex. spam*!*@*, *!*@badhost.com)" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserSettings.tsx #: src/lib/settings/definitions/allSettings.ts msgid "Nickname" @@ -1570,6 +1633,10 @@ msgstr "Ingen fil vald" msgid "No flood profile" msgstr "Ingen flödesprofil" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "no host set" +msgstr "ingen värd angiven" + #: src/components/ui/ChannelSettingsModal.tsx msgid "No invitations found" msgstr "Inga inbjudningar hittades" @@ -1610,6 +1677,10 @@ msgstr "Inget ämne angivet" msgid "No unread mentions or messages" msgstr "Inga olästa omnämnanden eller meddelanden" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "No upstream networks yet." +msgstr "Inga uppströmsnätverk ännu." + #: src/components/ui/AddPrivateChatModal.tsx msgid "No users available" msgstr "Inga användare tillgängliga" @@ -1696,6 +1767,10 @@ msgstr "Hoppsan! Nätverksuppdelning! ⚠️" msgid "Op" msgstr "Op" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Open" +msgstr "" + #: src/components/ui/QuickActions/uiActionConfig.tsx msgid "Open channel configuration settings" msgstr "Öppna kanalens konfigurationsinställningar" @@ -1799,6 +1874,10 @@ msgstr "Fäst privatchatt" msgid "Pin this private message conversation" msgstr "Fäst den här privata meddelandekonversationen" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Plaintext" +msgstr "Klartext" + #: src/components/message/MediaPreview.tsx #: src/components/message/MediaPreview.tsx #: src/components/message/MediaPreview.tsx @@ -1827,6 +1906,7 @@ msgid "PM User" msgstr "PM-användare" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx msgid "Port" msgstr "Port" @@ -1918,6 +1998,7 @@ msgstr "reagerade på det här meddelandet" msgid "Read more" msgstr "Läs mer" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserProfileModal.tsx #: src/components/ui/UserSettings.tsx #: src/lib/settings/definitions/allSettings.ts @@ -2002,6 +2083,7 @@ msgstr "Regler" msgid "Safe" msgstr "Säkert" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/ChannelSettingsModal.tsx #: src/components/ui/TopicModal.tsx #: src/components/ui/UserSettings.tsx @@ -2183,6 +2265,10 @@ msgstr "Serveroperatörer på nätverket kan potentiellt läsa dina meddelanden" msgid "Server Password" msgstr "Serverlösenord" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Server Password (PASS)" +msgstr "Serverlösenord (PASS)" + #: src/components/ui/LinkSecurityWarningModal.tsx msgid "Server-to-server communication may use unencrypted connections" msgstr "Server-till-server-kommunikation kan använda okrypterade anslutningar" @@ -2378,6 +2464,10 @@ msgstr "Tid (min)" msgid "Time Window (seconds)" msgstr "Tidsfönster (sekunder)" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "TLS" +msgstr "TLS" + #: src/components/message/WhisperMessage.tsx #: src/components/message/WhisperMessage.tsx msgid "to" @@ -2426,6 +2516,10 @@ msgstr "Ämne:" msgid "Total: {0}" msgstr "Totalt: {0}" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Transport" +msgstr "Transport" + #: src/components/ui/UserSettings.tsx msgid "Trusted Sources" msgstr "Betrodda källor" @@ -2536,6 +2630,7 @@ msgstr "Användarprofil" msgid "User Settings" msgstr "Användarinställningar" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/InviteUserModal.tsx #: src/components/ui/ModerationModal.tsx msgid "Username" @@ -2683,6 +2778,10 @@ msgstr "Brett – bredare skyddsomfång" msgid "Will default to 'no reason' if left empty" msgstr "Standardvärdet är 'ingen anledning' om det lämnas tomt" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Yes, delete" +msgstr "Ja, ta bort" + #: src/components/message/CollapsedEventMessage.tsx #: src/components/message/EventMessage.tsx msgid "You" @@ -2713,6 +2812,10 @@ msgstr "Ditt kontolösenord för autentisering" msgid "Your account username for authentication" msgstr "Ditt kontoanvändarnamn för autentisering" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Your bouncer doesn't have any networks yet. Add one to get started." +msgstr "Din bouncer har inga nätverk ännu. Lägg till ett för att komma igång." + #: src/lib/settings/definitions/allSettings.ts msgid "Your default nickname for all servers" msgstr "Ditt standard-nick för alla servrar" diff --git a/src/locales/tr/messages.mjs b/src/locales/tr/messages.mjs index 58ac964f..5e0d437e 100644 --- a/src/locales/tr/messages.mjs +++ b/src/locales/tr/messages.mjs @@ -1 +1 @@ -/*eslint-disable*/export const messages=JSON.parse("{\"+5VMrz\":[\"Geçersiz desen biçimi. nick!user@host biçimini kullanın (joker karakter * kullanılabilir)\"],\"+6NQQA\":[\"Genel Destek Kanalı\"],\"+6NyRG\":[\"İstemci\"],\"+K0AvT\":[\"Bağlantıyı Kes\"],\"+cyFdH\":[\"Uzakta olarak işaretlendiğinde gösterilecek varsayılan mesaj\"],\"+mVPqU\":[\"Mesajlarda markdown biçimlendirmesini işle\"],\"+vqCJH\":[\"Kimlik doğrulama için hesap kullanıcı adınız\"],\"+yPBXI\":[\"Dosya seç\"],\"+zy2Nq\":[\"Tür\"],\"/09cao\":[\"Düşük Bağlantı Güvenliği (Seviye \",[\"securityLevel\"],\")\"],\"/3BQ4J\":[\"Kanal dışındaki kullanıcılar kanala mesaj gönderemez\"],\"/6BzZF\":[\"Üye Listesini Aç/Kapat\"],\"/TNOPk\":[\"Kullanıcı uzakta\"],\"/XQgft\":[\"Keşfet\"],\"/cF7Rs\":[\"Ses Seviyesi\"],\"/dqduX\":[\"Sonraki sayfa\"],\"/fc3q4\":[\"Tüm İçerik\"],\"/kISDh\":[\"Bildirim Seslerini Etkinleştir\"],\"/n04sB\":[\"Kill\"],\"/rTz0M\":[\"Ses\"],\"/rfkZe\":[\"Bahisler ve mesajlar için ses çal\"],\"0/0ZGA\":[\"Kanal Adı Maskesi\"],\"0D6j7U\":[\"Özel kurallar hakkında daha fazla bilgi →\"],\"0XsHcR\":[\"Kullanıcıyı At\"],\"0ZpE//\":[\"Kullanıcıya Göre Sırala\"],\"0bEPwz\":[\"Uzakta Olarak İşaretle\"],\"0dGkPt\":[\"Kanal listesini genişlet\"],\"0gS7M5\":[\"Görünen Ad\"],\"0kS+M8\":[\"ÖrnekAĞ\"],\"0rgoY7\":[\"Yalnızca seçtiğiniz sunuculara bağlanın\"],\"0wdd7X\":[\"Katıl\"],\"0wkVYx\":[\"Özel Mesajlar\"],\"111uHX\":[\"Bağlantı önizlemesi\"],\"196EG4\":[\"Özel Sohbeti Sil\"],\"1DSr1i\":[\"Hesap kaydı oluştur\"],\"1O/24y\":[\"Kanal Listesini Aç/Kapat\"],\"1VPJJ2\":[\"Harici Bağlantı Uyarısı\"],\"1ZC/dv\":[\"Okunmamış bahis veya mesaj yok\"],\"1pO1zi\":[\"Sunucu adı gereklidir\"],\"1uwfzQ\":[\"Kanal Konusunu Görüntüle\"],\"268g7c\":[\"Görünen adı girin\"],\"2FOFq1\":[\"Ağdaki sunucu operatörleri mesajlarınızı okuyabilir\"],\"2FYpfJ\":[\"Daha fazla\"],\"2HF1Y2\":[[\"inviter\"],\", \",[\"target\"],\" kişisini \",[\"channel\"],\" kanalına davet etti\"],\"2I70QL\":[\"Kullanıcı profil bilgilerini görüntüle\"],\"2QYdmE\":[\"Kullanıcılar:\"],\"2QpEjG\":[\"ayrıldı\"],\"2YE223\":[\"#\",[\"0\"],\" kanalına mesaj (yeni satır için Enter, göndermek için Shift+Enter)\"],\"2bimFY\":[\"Sunucu şifresini kullan\"],\"2iTmdZ\":[\"Yerel Depolama:\"],\"2odkwe\":[\"Katı - Daha agresif koruma\"],\"2uDhbA\":[\"Davet edilecek kullanıcı adını girin\"],\"2ygf/L\":[\"← Geri\"],\"2zEgxj\":[\"GIF ara...\"],\"3RdPhl\":[\"Kanalı Yeniden Adlandır\"],\"3THokf\":[\"Sesli Kullanıcı\"],\"3TSz9S\":[\"Küçült\"],\"3jBDvM\":[\"Kanal Görünen Adı\"],\"3ryuFU\":[\"Uygulamayı geliştirmek için isteğe bağlı çökme raporları\"],\"3uBF/8\":[\"Görüntüleyiciyi kapat\"],\"3uwW8F\":[\"https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"3xf8Kz\":[\"Hesap adını girin...\"],\"4/Rr0R\":[\"Mevcut kanala bir kullanıcı davet et\"],\"4EZrJN\":[\"Kurallar\"],\"4JJtW9\":[\"#overflow\"],\"4NqeT4\":[\"Flood Profili (+F)\"],\"4RZQRK\":[\"Ne yapıyorsun?\"],\"4hfTrB\":[\"Takma Ad\"],\"4n99LO\":[[\"0\"],\" kanalında zaten var\"],\"4t6vMV\":[\"Kısa mesajlar için otomatik olarak tek satıra geç\"],\"4vsHmf\":[\"Süre (dk)\"],\"5+INAX\":[\"Sizi bahseden mesajları vurgula\"],\"5R5Pv/\":[\"Oper Adı\"],\"678PKt\":[\"Ağ Adı\"],\"6Aih4U\":[\"Çevrimdışı\"],\"6CO3WE\":[\"Kanala katılmak için şifre gerekli. Anahtarı kaldırmak için boş bırakın.\"],\"6HhMs3\":[\"Ayrılma Mesajı\"],\"6V3Ea3\":[\"Kopyalandı\"],\"6lGV3K\":[\"Daha az göster\"],\"6yFOEi\":[\"Oper şifresini girin...\"],\"7+IHTZ\":[\"Dosya seçilmedi\"],\"73hrRi\":[\"nick!user@host (örn. spam*!*@*, *!*@badhost.com)\"],\"7QkKyN\":[\"Özel mesaj gönder\"],\"7U1W7c\":[\"Çok Rahat\"],\"7Y1YQj\":[\"Gerçek ad:\"],\"7YHArF\":[\"— görüntüleyicide aç\"],\"7fjnVl\":[\"Kullanıcı ara...\"],\"7jL88x\":[\"Bu mesaj silinsin mi? Bu işlem geri alınamaz.\"],\"7nGhhM\":[\"Aklınızda ne var?\"],\"7sEpu1\":[\"Üyeler — \",[\"0\"]],\"7sNhEz\":[\"Kullanıcı Adı\"],\"8H0Q+x\":[\"Profiller hakkında daha fazla bilgi →\"],\"8Phu0A\":[\"Kullanıcılar takma adını değiştirdiğinde göster\"],\"8XTG9e\":[\"Oper şifresini girin\"],\"8XsV2J\":[\"Yeniden gönder\"],\"8ZsakT\":[\"Şifre\"],\"8kR84m\":[\"Harici bir bağlantı açmak üzeresiniz:\"],\"8lCgih\":[\"Kuralı Kaldır\"],\"8p/xVT\":[[\"0\",\"plural\",{\"one\":[[\"1\"]],\"other\":[[\"2\"]]}]],\"8wRzac\":[[\"joinCount\",\"plural\",{\"one\":[\"katıldı\"],\"other\":[[\"joinCount\"],\" kez katıldı\"]}]],\"9BMLnJ\":[\"Sunucuya yeniden bağlan\"],\"9OEgyT\":[\"Tepki ekle\"],\"9PQ8m2\":[\"G-Line (global yasak)\"],\"9Qs99X\":[\"E-posta:\"],\"9QupBP\":[\"Deseni kaldır\"],\"9bG48P\":[\"Gönderiliyor\"],\"9f5f0u\":[\"Gizlilik hakkında sorularınız mı var? Bize ulaşın:\"],\"9unqs3\":[\"Uzakta:\"],\"9v3hwv\":[\"Sunucu bulunamadı.\"],\"9zb2WA\":[\"Bağlanıyor\"],\"A1taO8\":[\"Ara\"],\"A2adVi\":[\"Yazıyor Bildirimleri Gönder\"],\"A9Rhec\":[\"Kanal Adı\"],\"AWOSPo\":[\"Yakınlaştır\"],\"AXSpEQ\":[\"Bağlanırken Oper Ol\"],\"AeXO77\":[\"Hesap\"],\"AhNP40\":[\"Konuma git\"],\"Ai2U7L\":[\"Host\"],\"AjBQnf\":[\"Takma ad değiştirildi\"],\"AmXVh6\":[\"https://example.com/avatar.png\"],\"AnRu/j\":[\"Yanıtı iptal et\"],\"ApSx0O\":[\"\\\"\",[\"searchQuery\"],\"\\\" ile eşleşen \",[\"0\"],\" mesaj bulundu\"],\"AxPAXW\":[\"Sonuç bulunamadı\"],\"AyNqAB\":[\"Tüm sunucu olaylarını sohbette göster\"],\"B/QqGw\":[\"Klavyeden uzakta\"],\"B8AaMI\":[\"Bu alan zorunludur\"],\"BA2c49\":[\"Sunucu gelişmiş LIST filtrelemesini desteklemiyor\"],\"BDKt3I\":[[\"0\"],\", \",[\"1\"],\", \",[\"2\"],\" ve \",[\"3\"],\" kişi daha yazıyor...\"],\"BGul2A\":[\"Kaydedilmemiş değişiklikleriniz var. Kaydetmeden kapatmak istediğinizden emin misiniz?\"],\"BIf9fi\":[\"Durum mesajınız\"],\"BZz3md\":[\"Kişisel web siteniz\"],\"Bgm/H7\":[\"Çok satırlı metin girişine izin ver\"],\"BiQIl1\":[\"Bu özel mesaj konuşmasını sabitle\"],\"BlNZZ2\":[\"Mesaja gitmek için tıklayın\"],\"Bowq3c\":[\"Yalnızca operatörler kanal konusunu değiştirebilir\"],\"Btozzp\":[\"Bu görüntünün süresi doldu\"],\"Bycfjm\":[\"Toplam: \",[\"0\"]],\"C6IBQc\":[\"Tüm JSON'u kopyala\"],\"C9L9wL\":[\"Veri Toplama\"],\"CDq4wC\":[\"Kullanıcıyı Yönet\"],\"CHVRxG\":[\"@\",[\"0\"],\"'a mesaj (yeni satır için Shift+Enter)\"],\"CN9zdR\":[\"Oper adı ve şifresi gereklidir\"],\"CW3sYa\":[[\"emoji\"],\" tepkisi ekle\"],\"CaAkqd\":[\"Ayrılmaları Göster\"],\"CbvaYj\":[\"Takma Adıyla Yasakla\"],\"CcK+Ft\":[\"PDF\"],\"Ce8q3L\":[\"Bir kanal seçin\"],\"CsekCi\":[\"Normal\"],\"D+NlUC\":[\"Sistem\"],\"D28t6+\":[\"katıldı ve ayrıldı\"],\"DB8zMK\":[\"Uygula\"],\"DBcWHr\":[\"Özel bildirim sesi dosyası\"],\"DTy9Xw\":[\"Medya Önizlemeleri\"],\"Dj4pSr\":[\"Güvenli bir şifre seçin\"],\"Du+zn+\":[\"Aranıyor...\"],\"Du2T2f\":[\"Ayar bulunamadı\"],\"DwsSVQ\":[\"Filtreleri Uygula ve Yenile\"],\"E3W/zd\":[\"Varsayılan Takma Ad\"],\"E6nRW7\":[\"URL'yi Kopyala\"],\"E703RG\":[\"Modlar:\"],\"EAeu1Z\":[\"Davet Gönder\"],\"EFKJQT\":[\"Ayar\"],\"EGPQBv\":[\"Özel Flood Kuralları (+f)\"],\"ELik0r\":[\"Tam Gizlilik Politikasını Görüntüle\"],\"EPbeC2\":[\"Kanal konusunu görüntüle veya düzenle\"],\"EQCDNT\":[\"Oper kullanıcı adını girin...\"],\"EUvulZ\":[\"\\\"\",[\"searchQuery\"],\"\\\" ile eşleşen 1 mesaj bulundu\"],\"EatZYJ\":[\"Sonraki görüntü\"],\"EdQY6l\":[\"Yok\"],\"EnqLYU\":[\"Sunucu ara...\"],\"F0OKMc\":[\"Sunucuyu Düzenle\"],\"F6Int2\":[\"Vurgulamayı Etkinleştir\"],\"FDoLyE\":[\"Maks. Kullanıcı\"],\"FUU/hZ\":[\"Sohbette ne kadar harici medyanın yükleneceğini denetleyin.\"],\"Fdp03t\":[\"açık\"],\"FfPWR0\":[\"Pencere\"],\"FjkaiT\":[\"Uzaklaştır\"],\"FlqOE9\":[\"Bu ne anlama geliyor:\"],\"FolHNl\":[\"Hesabınızı ve kimlik doğrulamayı yönetin\"],\"Fp2Dif\":[\"Sunucudan ayrıldı\"],\"G5KmCc\":[\"GZ-Line (global Z-Line)\"],\"GDs0lz\":[\"<0>Risk: Hassas bilgiler (mesajlar, özel konuşmalar, kimlik doğrulama bilgileri) ağ yöneticilerine veya IRC sunucuları arasına konumlanmış saldırganlara açık olabilir.\"],\"GR+2I3\":[\"Davet maskesi ekle (örn. nick!*@*, *!*@host.com)\"],\"GRLyMU\":[\"Açılır sunucu bildirimlerini kapat\"],\"GlHnXw\":[\"Takma ad değişikliği başarısız: \",[\"error\"],\" \",[\"0\"]],\"GswZF3\":[\"Önizleme:\"],\"GtmO8/\":[\"kimden\"],\"GtuHUQ\":[\"Bu kanalı sunucuda yeniden adlandır. Tüm kullanıcılar yeni adı görecek.\"],\"GuGfFX\":[\"Aramayı aç/kapat\"],\"GxkJXS\":[\"Yükleniyor...\"],\"GzbwnK\":[\"Kanala katıldı\"],\"GzsUDB\":[\"Genişletilmiş Profil\"],\"H/PnT8\":[\"Emoji ekle\"],\"H6Izzl\":[\"Tercih ettiğiniz renk kodu\"],\"H9jIv+\":[\"Katılma/Ayrılma Göster\"],\"HAKBY9\":[\"Dosyaları yükle\"],\"HdE1If\":[\"Kanal\"],\"Hk4AW9\":[\"Tercih ettiğiniz görünen ad\"],\"HmHDk7\":[\"Üye Seç\"],\"HrQzPU\":[[\"networkName\"],\" üzerindeki kanallar\"],\"I2tXQ5\":[\"@\",[\"0\"],\"'a mesaj (yeni satır için Enter, göndermek için Shift+Enter)\"],\"I6bw/h\":[\"Kullanıcıyı Yasakla\"],\"I92Z+b\":[\"Bildirimleri etkinleştir\"],\"I9D72S\":[\"Bu mesajı silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.\"],\"IA+1wo\":[\"Kullanıcılar kanaldan atıldığında göster\"],\"IDwkJx\":[\"IRC Operatörü\"],\"ILlU+s\":[\"Bilgi:\"],\"IUwGEM\":[\"Değişiklikleri Kaydet\"],\"IVeGK6\":[[\"0\"],\", \",[\"1\"],\" ve \",[\"2\"],\" yazıyor...\"],\"IgrLD/\":[\"Duraklat\"],\"Im6JED\":[\"FISISALTI\"],\"ImOQa9\":[\"Yanıtla\"],\"IoHMnl\":[\"Maksimum değer \",[\"0\"]],\"IvMj+0\":[\"Op\"],\"J28zul\":[\"Bağlanıyor...\"],\"J5T9NW\":[\"Kullanıcı Bilgileri\"],\"J8Y5+z\":[\"Eyvah! Ağ bölündü! ⚠️\"],\"JBHkBA\":[\"Kanaldan ayrıldı\"],\"JCwL0Q\":[\"Neden girin (isteğe bağlı)\"],\"JFciKP\":[\"Değiştir\"],\"JXGkhG\":[\"Kanal adını değiştir (yalnızca operatörler)\"],\"JcD7qf\":[\"Daha fazla işlem\"],\"JdkA+c\":[\"Gizli (+s)\"],\"Jmu12l\":[\"Sunucu Kanalları\"],\"JvQ++s\":[\"Markdown'ı Etkinleştir\"],\"K2jwh/\":[\"WHOIS verisi mevcut değil\"],\"KAXSwC\":[\"Voice\"],\"KDfTdX\":[\"Mesajı sil\"],\"KKBlUU\":[\"Gömülü\"],\"KM0pLb\":[\"Kanala hoş geldiniz!\"],\"KR6W2h\":[\"Kullanıcının Engelini Kaldır\"],\"KV+Bi1\":[\"Yalnızca Davetli (+i)\"],\"KdCtwE\":[\"Sayaçları sıfırlamadan önce flood etkinliğinin kaç saniye izleneceği\"],\"Kkezga\":[\"Sunucu Şifresi\"],\"KsiQ/8\":[\"Kullanıcıların kanala katılmak için davet edilmesi gerekir\"],\"L+gB/D\":[\"Kanal bilgisi\"],\"LC1a7n\":[\"IRC sunucusu, sunucular arası bağlantılarının düşük güvenlik seviyesine sahip olduğunu bildirdi. Bu, mesajlarınız ağdaki IRC sunucuları arasında iletilirken düzgün şifrelenmeyebileceği veya SSL/TLS sertifikalarının doğru şekilde doğrulanmayabileceği anlamına gelir.\"],\"LNfLR5\":[\"Atmaları Göster\"],\"LQb0W/\":[\"Tüm Olayları Göster\"],\"LU7/yA\":[\"Arayüzde gösterilecek alternatif ad. Boşluk, emoji ve özel karakter içerebilir. IRC komutlarında gerçek kanal adı (\",[\"channelName\"],\") kullanılmaya devam eder.\"],\"LUb9O7\":[\"Geçerli bir sunucu portu gereklidir\"],\"Lb+BUl\":[\"https://example.com/avatar.jpg\"],\"LcET2C\":[\"Gizlilik Politikası\"],\"LcuSDR\":[\"Profil bilgilerinizi ve meta verilerinizi yönetin\"],\"LqLS9B\":[\"Takma Ad Değişikliklerini Göster\"],\"LsDQt2\":[\"Kanal Ayarları\"],\"LtI9AS\":[\"Sahip\"],\"LuNhhL\":[\"bu mesaja tepki verdi\"],\"M/AZNG\":[\"Avatar görüntünüzün URL'si\"],\"M/WIer\":[\"Mesaj Gönder\"],\"M8er/5\":[\"Ad:\"],\"MHk+7g\":[\"Önceki görüntü\"],\"MRorGe\":[\"Kullanıcıya PM Gönder\"],\"MVbSGP\":[\"Zaman Penceresi (saniye)\"],\"MkpcsT\":[\"Mesajlarınız ve ayarlarınız cihazınızda yerel olarak saklanır\"],\"N/hDSy\":[\"Bot olarak işaretle - genellikle 'on' veya boş\"],\"N7TQbE\":[[\"channelName\"],\" kanalına Kullanıcı Davet Et\"],\"NCca/o\":[\"Varsayılan takma adı girin...\"],\"Nqs6B9\":[\"Tüm harici medyayı gösterir. Herhangi bir URL bilinmeyen bir sunucuya istek gönderebilir.\"],\"Nt+9O7\":[\"Ham TCP yerine WebSocket kullan\"],\"NxIHzc\":[\"Kullanıcıyı at\"],\"O+v/cL\":[\"Sunucudaki tüm kanalları görüntüle\"],\"ODwSCk\":[\"GIF gönder\"],\"OGQ5kK\":[\"Bildirim seslerini ve vurguları yapılandır\"],\"OIPt1Z\":[\"Üye listesi kenar çubuğunu göster veya gizle\"],\"OKSNq/\":[\"Çok Katı\"],\"ONWvwQ\":[\"Yükle\"],\"OVKoQO\":[\"Kimlik doğrulama için hesap şifreniz\"],\"ObsidianIRC - Bringing IRC to the future\":[\"ObsidianIRC - IRC'yi geleceğe taşıyor\"],\"OhCpra\":[\"Konu belirle…\"],\"OkltoQ\":[[\"username\"],\" kullanıcısını takma adıyla yasakla (aynı takma adla yeniden katılmasını engeller)\"],\"P+t/Te\":[\"Ek veri yok\"],\"P42Wcc\":[\"Güvenli\"],\"PD38l0\":[\"Kanal avatarı önizlemesi\"],\"PD9mEt\":[\"Mesaj yazın...\"],\"PPqfdA\":[\"Kanal yapılandırma ayarlarını aç\"],\"PSCjfZ\":[\"Bu kanal için görüntülenecek konu. Tüm kullanıcılar konuyu görebilir.\"],\"PZCecv\":[\"PDF önizleme\"],\"PeLgsC\":[[\"c\",\"plural\",{\"one\":[\"1 kez\"],\"other\":[[\"c\"],\" kez\"]}]],\"PguS2C\":[\"İstisna maskesi ekle (örn. nick!*@*, *!*@host.com)\"],\"Pil5Ty\":[[\"0\"],\" kanaldan \",[\"displayedChannelsCount\"],\" tanesi gösteriliyor\"],\"PqhVlJ\":[\"Kullanıcıyı Yasakla (Host Maskesiyle)\"],\"Q+chwU\":[\"Kullanıcı adı:\"],\"Q6hhn8\":[\"Tercihler\"],\"QF4a34\":[\"Lütfen bir kullanıcı adı girin\"],\"QGqSZ2\":[\"Renk ve Biçimlendirme\"],\"QJQd1J\":[\"Profili Düzenle\"],\"QSzGDE\":[\"Boşta\"],\"QUlny5\":[[\"0\"],\"'a hoş geldiniz!\"],\"Qoq+GP\":[\"Devamını oku\"],\"QuSkCF\":[\"Kanalları filtrele...\"],\"QwUrDZ\":[\"konuyu şu şekilde değiştirdi: \",[\"topic\"]],\"R0UH07\":[[\"1\"],\" görselinden \",[\"0\"],\". görsel\"],\"R7SsBE\":[\"Sessize Al\"],\"R8rf1X\":[\"Konu belirlemek için tıklayın\"],\"RArB3D\":[[\"channelName\"],\" kanalından \",[\"username\"],\" tarafından atıldı\"],\"RI3cWd\":[\"ObsidianIRC ile IRC dünyasını keşfedin\"],\"RMMaN5\":[\"Moderasyonlu (+m)\"],\"RWw9Lg\":[\"Pencereyi kapat\"],\"RZ2BuZ\":[[\"account\"],\" hesap kaydı doğrulama gerektiriyor: \",[\"message\"]],\"RySp6q\":[\"Yorumları gizle\"],\"SPKQTd\":[\"Takma ad gereklidir\"],\"SPVjfj\":[\"Boş bırakılırsa varsayılan olarak 'neden yok' kullanılır\"],\"SQKPvQ\":[\"Kullanıcı Davet Et\"],\"SkZcl+\":[\"Önceden tanımlanmış bir flood koruma profili seçin. Bu profiller, farklı kullanım durumları için dengeli koruma ayarları sunar.\"],\"Slr+3C\":[\"Min. Kullanıcı\"],\"Spnlre\":[[\"target\"],\" kişisini \",[\"channel\"],\" kanalına davet ettiniz\"],\"T/ckN5\":[\"Görüntüleyicide aç\"],\"T91vKp\":[\"Oynat\"],\"TV2Wdu\":[\"Verilerinizi nasıl işlediğimizi ve gizliliğinizi nasıl koruduğumuzu öğrenin.\"],\"TgFpwD\":[\"Uygulanıyor...\"],\"TkzSFB\":[\"Değişiklik Yok\"],\"TtserG\":[\"Gerçek adı girin\"],\"Ttz9J1\":[\"Şifreyi girin...\"],\"Tz0i8g\":[\"Ayarlar\"],\"U3pytU\":[\"Yönetici\"],\"UDb2YD\":[\"Tepki Ver\"],\"UE4KO5\":[\"*kanal*\"],\"UGT5vp\":[\"Ayarları Kaydet\"],\"UV5hLB\":[\"Yasak bulunamadı\"],\"Uaj3Nd\":[\"Durum Mesajları\"],\"Ue3uny\":[\"Varsayılan (profil yok)\"],\"UkARhe\":[\"Normal - Standart koruma\"],\"Umn7Cj\":[\"Henüz yorum yok. İlk sen ol!\"],\"UtUIRh\":[[\"0\"],\" eski mesaj\"],\"UwzP+U\":[\"Güvenli Bağlantı\"],\"V0/A4O\":[\"Kanal Sahibi\"],\"V4qgxE\":[\"Şu kadar dakika önce önce oluşturulan\"],\"V8yTm6\":[\"Aramayı temizle\"],\"VJMMyz\":[\"ObsidianIRC - IRC'yi geleceğe taşıyor\"],\"VJScHU\":[\"Neden\"],\"VLsmVV\":[\"Bildirimleri sessize al\"],\"VbyRUy\":[\"Yorumlar\"],\"Vmx0mQ\":[\"Ayarlayan:\"],\"VqnIZz\":[\"Gizlilik politikamızı ve veri uygulamalarımızı görüntüleyin\"],\"VrMygG\":[\"Minimum uzunluk \",[\"0\"]],\"VrnTui\":[\"Profilinizde gösterilen zamirleriniz\"],\"W8E3qn\":[\"Doğrulanmış Hesap\"],\"WAakm9\":[\"Kanalı Sil\"],\"WFxTHC\":[\"Yasaklama maskesi ekle (örn. nick!*@*, *!*@host.com)\"],\"WN1g9F\":[\"Sunucu hostu gereklidir\"],\"WRYdXW\":[\"Ses konumu\"],\"WUOH5B\":[\"Kullanıcıyı Engelle\"],\"WWEXnZ\":[[\"0\",\"plural\",{\"one\":[\"1 öğe daha göster\"],\"other\":[[\"1\"],\" öğe daha göster\"]}]],\"Weq9zb\":[\"Genel\"],\"Wfj7Sk\":[\"Bildirim seslerini sessize al veya aç\"],\"Wm7gbG\":[\"GitHub:\"],\"WyeHWY\":[\"*spam*\"],\"WzMCru\":[\"Kullanıcı Profili\"],\"X6S3lt\":[\"Ayarlar, kanallar, sunucular arayın...\"],\"XEHan5\":[\"Yine de Devam Et\"],\"XI1+wb\":[\"Geçersiz biçim\"],\"XIXeuC\":[\"@\",[\"0\"],\"'a mesaj\"],\"XMS+k4\":[\"Özel Mesaj Başlat\"],\"XWgxXq\":[\"Albüm\"],\"Xd7+IT\":[\"Özel Sohbetin Sabitlemesini Kaldır\"],\"Xm/s+u\":[\"Görünüm\"],\"Xp2n93\":[\"Sunucunuzun güvenilir dosya hostundan medya gösterir. Harici hizmetlere istek gönderilmez.\"],\"XvjC4F\":[\"Kaydediliyor...\"],\"Y/qryO\":[\"Aramanızla eşleşen kullanıcı bulunamadı\"],\"YAqRpI\":[[\"account\"],\" hesap kaydı başarılı: \",[\"message\"]],\"YEfzvP\":[\"Korumalı Konu (+t)\"],\"YQOn6a\":[\"Üye listesini daralt\"],\"YRCoE9\":[\"Kanal Operatörü\"],\"YURQaF\":[\"Profili Görüntüle\"],\"YdBSvr\":[\"Medya gösterimini ve harici içeriği denetleyin\"],\"Yj6U3V\":[\"Merkezi Sunucu Yok:\"],\"YjvpGx\":[\"Zamirler\"],\"YqH4l4\":[\"Anahtar yok\"],\"YyUPpV\":[\"Hesap:\"],\"ZJSWfw\":[\"Sunucudan ayrıldığınızda gösterilen mesaj\"],\"ZR1dJ4\":[\"Davetler\"],\"ZdWg0V\":[\"Tarayıcıda aç\"],\"ZhRBbl\":[\"Mesajlarda ara…\"],\"Zmcu3y\":[\"Gelişmiş Filtreler\"],\"a2/8e5\":[\"Şu kadar dakika önce sonra konu belirlenen\"],\"aHKcKc\":[\"Önceki sayfa\"],\"aJTbXX\":[\"Oper Şifresi\"],\"aQryQv\":[\"Desen zaten mevcut\"],\"aW9pLN\":[\"Kanalda izin verilen maksimum kullanıcı sayısı. Sınır olmaması için boş bırakın.\"],\"ah4fmZ\":[\"YouTube, Vimeo, SoundCloud ve benzeri bilinen hizmetlerden önizlemeler de gösterir.\"],\"aifXak\":[\"Bu kanalda medya yok\"],\"ap2zBz\":[\"Rahat\"],\"az8lvo\":[\"Kapalı\"],\"azXSNo\":[\"Üye listesini genişlet\"],\"azdliB\":[\"Bir hesaba giriş yap\"],\"b26wlF\":[\"o/onun\"],\"bD/+Ei\":[\"Katı\"],\"bQ6BJn\":[\"Ayrıntılı flood koruma kurallarını yapılandırın. Her kural, hangi etkinlik türünün izleneceğini ve eşikler aşıldığında hangi işlemin yapılacağını belirtir.\"],\"beV7+y\":[\"Kullanıcı \",[\"channelName\"],\" kanalına katılmak için davet alacak.\"],\"bk84cH\":[\"Uzakta Mesajı\"],\"bkHdLj\":[\"IRC Sunucusu Ekle\"],\"bmQLn5\":[\"Kural Ekle\"],\"bwRvnp\":[\"İşlem\"],\"c8+EVZ\":[\"Doğrulanmış hesap\"],\"cGYUlD\":[\"Medya önizlemesi yüklenmiyor.\"],\"cLF98o\":[\"Yorumları göster (\",[\"commentCount\"],\")\"],\"cLKIDO\":[\"Kullanılabilir kullanıcı yok\"],\"cSgpoS\":[\"Özel Sohbeti Sabitle\"],\"cde3ce\":[\"<0>\",[\"0\"],\"'a mesaj\"],\"chQsxg\":[\"Biçimlendirilmiş çıktıyı kopyala\"],\"cl/A5J\":[[\"__DEFAULT_IRC_SERVER_NAME__\"],\"'a hoş geldiniz!\"],\"cnGeoo\":[\"Sil\"],\"coPLXT\":[\"IRC iletişimlerinizi sunucularımızda saklamıyoruz\"],\"crYH/6\":[\"SoundCloud oynatıcı\"],\"d3sis4\":[\"Sunucu Ekle\"],\"d9aN5k\":[[\"username\"],\" kullanıcısını kanaldan kaldır\"],\"dEgA5A\":[\"İptal\"],\"dGi1We\":[\"Bu özel mesaj konuşmasının sabitlemesini kaldır\"],\"dJVuyC\":[[\"channelName\"],\" kanalından ayrıldı (\",[\"reason\"],\")\"],\"dMtLDE\":[\"kime\"],\"dXqxlh\":[\"<0>⚠️ Güvenlik Riski! Bu bağlantı, araya girme veya ortadaki adam saldırılarına karşı savunmasız olabilir.\"],\"da9Q/R\":[\"Kanal modları değiştirildi\"],\"dhJN3N\":[\"Yorumları göster\"],\"dj2xTE\":[\"Bildirimi kapat\"],\"dpCzmC\":[\"Flood Koruma Ayarları\"],\"e9dQpT\":[\"Bu bağlantıyı yeni sekmede açmak istiyor musunuz?\"],\"ePK91l\":[\"Düzenle\"],\"eYBDuB\":[\"Bir görüntü yükleyin veya dinamik boyutlandırma için isteğe bağlı \",[\"size\"],\" değişkeni içeren bir URL sağlayın\"],\"edBbee\":[[\"username\"],\" kullanıcısını host maskesiyle yasakla (aynı IP/host'tan yeniden katılmasını engeller)\"],\"ekfzWq\":[\"Kullanıcı Ayarları\"],\"elPDWs\":[\"IRC istemci deneyiminizi özelleştirin\"],\"eu2osY\":[\"<0>💡 Öneri: Yalnızca bu sunucuya güveniyorsanız ve risklerin farkındaysanız devam edin. Bu bağlantı üzerinden hassas bilgi veya şifre paylaşmaktan kaçının.\"],\"euEhbr\":[[\"channel\"],\" kanalına katılmak için tıklayın\"],\"ez3vLd\":[\"Çok Satırlı Girişi Etkinleştir\"],\"f0J5Ki\":[\"Sunucular arası iletişim şifrelenmemiş bağlantılar kullanıyor olabilir\"],\"f9BHJk\":[\"Kullanıcıyı Uyar\"],\"fDOLLd\":[\"Kanal bulunamadı.\"],\"ffzDkB\":[\"Anonim Analitik:\"],\"fq1GF9\":[\"Kullanıcılar sunucudan ayrıldığında göster\"],\"gEF57C\":[\"Bu sunucu yalnızca bir bağlantı türünü destekliyor\"],\"gJuLUI\":[\"Engelleme Listesi\"],\"gNzMrk\":[\"Mevcut avatar\"],\"gjPWyO\":[\"Takma adı girin...\"],\"gz6UQ3\":[\"Büyüt\"],\"h6razj\":[\"Kanal Adı Maskesini Hariç Tut\"],\"hG6jnw\":[\"Konu belirlenmemiş\"],\"hG89Ed\":[\"Görüntü\"],\"hZ6znB\":[\"Port\"],\"ha+Bz5\":[\"örn. 100:1440\"],\"hehnjM\":[\"Miktar\"],\"hzdLuQ\":[\"Yalnızca Voice veya daha yüksek yetkiye sahip kullanıcılar konuşabilir\"],\"i0qMbr\":[\"Ana Sayfa\"],\"iDNBZe\":[\"Bildirimler\"],\"iH8pgl\":[\"Geri\"],\"iL9SZg\":[\"Kullanıcıyı Yasakla (Takma Adıyla)\"],\"iNt+3c\":[\"Görüntüye geri dön\"],\"iQvi+a\":[\"Bu sunucu için düşük bağlantı güvenliği konusunda uyarma\"],\"iSLIjg\":[\"Bağlan\"],\"iWXkHH\":[\"Halfop\"],\"iZeTtp\":[\"Sunucu Hostu\"],\"idD8Ev\":[\"Kaydedildi\"],\"iivqkW\":[\"Giriş Yapıldı\"],\"ij+Elv\":[\"Görüntü önizlemesi\"],\"ilIWp7\":[\"Bildirimleri Aç/Kapat\"],\"iuaqvB\":[\"Joker karakter için * kullanın. Örnekler: baduser!*@*, *!*@spammer.com, troll*!*@*\"],\"ixkTse\":[\"Bot\"],\"j2DGR0\":[\"Host Maskesiyle Yasakla\"],\"jA4uoI\":[\"Konu:\"],\"jLXxGK\":[\"https://example.com\"],\"jPSk57\":[\"Neden (isteğe bağlı)\"],\"jUV7CU\":[\"Avatar Yükle\"],\"jW5Uwh\":[\"Ne kadar harici medya yükleneceğini denetleyin. Kapalı / Güvenli / Güvenilir Kaynaklar / Tüm İçerik.\"],\"jXzms5\":[\"Ek seçenekleri\"],\"jZlrte\":[\"Renk\"],\"jfC/xh\":[\"İletişim\"],\"jywMpv\":[\"#yeni-kanal-adı\"],\"k112DD\":[\"Eski mesajları yükle\"],\"k3ID0F\":[\"Üyeleri filtrele…\"],\"k65gsE\":[\"Derinlemesine incele\"],\"k7Zgob\":[\"Bağlantıyı İptal Et\"],\"kAVx5h\":[\"Davet bulunamadı\"],\"kCLEPU\":[\"Bağlı Olduğu Sunucu\"],\"kF5LKb\":[\"Engellenen desenler:\"],\"kGeOx/\":[[\"0\"],\" kanalına katıl\"],\"kITKr8\":[\"Kanal modları yükleniyor...\"],\"kPpPsw\":[\"IRC Operatörüsünüz\"],\"kWJmRL\":[\"Siz\"],\"kfcRb0\":[\"Avatar\"],\"kjMqSj\":[\"JSON kopyala\"],\"krViRy\":[\"JSON olarak kopyalamak için tıklayın\"],\"ks71ra\":[\"İstisnalar\"],\"kw4lRv\":[\"Kanal Yarı Operatörü\"],\"kxgIRq\":[\"Başlamak için bir kanal seçin veya ekleyin.\"],\"ky6dWe\":[\"Avatar önizlemesi\"],\"l+GxCv\":[\"Kanallar yükleniyor...\"],\"l+IUVW\":[[\"account\"],\" hesap doğrulaması başarılı: \",[\"message\"]],\"l/siQz\":[[\"reconnectCount\",\"plural\",{\"one\":[\"yeniden bağlandı\"],\"other\":[[\"reconnectCount\"],\" kez yeniden bağlandı\"]}]],\"l5jmzx\":[[\"0\"],\" ve \",[\"1\"],\" yazıyor...\"],\"lHy8N5\":[\"Daha fazla kanal yükleniyor...\"],\"lbpf14\":[[\"value\"],\" kanalına katıl\"],\"lfFsZ4\":[\"Kanallar\"],\"lkNdiH\":[\"Hesap Adı\"],\"ln500L\":[\"ObsidianIRC\"],\"lnCMdg\":[\"Görüntü Yükle\"],\"loQxaJ\":[\"Geri Döndüm\"],\"lvfaxv\":[\"ANA SAYFA\"],\"m16xKo\":[\"Ekle\"],\"m8flAk\":[\"Önizleme (henüz yüklenmedi)\"],\"mEPxTp\":[\"<0>⚠️ Dikkatli olun! Yalnızca güvenilir kaynaklardan gelen bağlantıları açın. Kötü amaçlı bağlantılar güvenliğinizi veya gizliliğinizi tehlikeye atabilir.\"],\"mHGdhG\":[\"Sunucu bilgisi\"],\"mHS8lb\":[\"#\",[\"0\"],\" kanalına mesaj\"],\"mMYBD9\":[\"Geniş - Daha kapsamlı koruma alanı\"],\"mTGsPd\":[\"Kanal Konusu\"],\"mU8j6O\":[\"Harici Mesaj Yok (+n)\"],\"mZp8FL\":[\"Otomatik Tek Satıra Dön\"],\"mdQu8G\":[\"TakmaAdınız\"],\"miSSBQ\":[\"Yorumlar (\",[\"commentCount\"],\")\"],\"mvyLSy\":[\"Kullanıcı kimliği doğrulandı\"],\"mwtcGl\":[\"Yorumları kapat\"],\"mzI/c+\":[\"İndir\"],\"n3fGRk\":[[\"0\"],\" tarafından ayarlandı\"],\"nE9jsU\":[\"Rahat - Daha az agresif koruma\"],\"nNflMD\":[\"Kanaldan ayrıl\"],\"nPXkBi\":[\"WHOIS verisi yükleniyor...\"],\"nQnxxF\":[\"#\",[\"0\"],\" kanalına mesaj (yeni satır için Shift+Enter)\"],\"nWMRxa\":[\"Sabitlemeyi Kaldır\"],\"nkC032\":[\"Flood profili yok\"],\"o69z4d\":[[\"username\"],\" kullanıcısına uyarı mesajı gönder\"],\"o9ylQi\":[\"Başlamak için GIF arayın\"],\"oFGkER\":[\"Sunucu Bildirimleri\"],\"oOi11l\":[\"En alta kaydır\"],\"oQEzQR\":[\"Yeni DM\"],\"oXOSPE\":[\"Çevrimiçi\"],\"oal760\":[\"Sunucu bağlantılarında ortadaki adam saldırıları mümkün\"],\"oeqmmJ\":[\"Güvenilir Kaynaklar\"],\"ovBPCi\":[\"Varsayılan\"],\"p0Z69r\":[\"Desen boş olamaz\"],\"p1KgtK\":[\"Ses yüklenemedi\"],\"p59pEv\":[\"Ek ayrıntılar\"],\"p7sRI6\":[\"Yazarken diğerlerini bilgilendir\"],\"pBm1od\":[\"Gizli kanal\"],\"pNmiXx\":[\"Tüm sunucular için varsayılan takma adınız\"],\"pUUo9G\":[\"Ana makine adı:\"],\"pVGPmz\":[\"Hesap Şifresi\"],\"peNE68\":[\"Kalıcı\"],\"plhHQt\":[\"Veri yok\"],\"pm6+q5\":[\"Güvenlik Uyarısı\"],\"pn5qSs\":[\"Ek Bilgiler\"],\"q0cR4S\":[\"artık **\",[\"newNick\"],\"** olarak bilinmektedir\"],\"qFcunY\":[\"Kanal LIST veya NAMES komutlarında görünmeyecek\"],\"qLpTm/\":[[\"emoji\"],\" tepkisini kaldır\"],\"qVkGWK\":[\"Sabitle\"],\"qY8wNa\":[\"Ana Sayfa\"],\"qb0xJ7\":[\"Joker karakter kullanın: * herhangi bir diziyle eşleşir, ? herhangi bir tek karakterle eşleşir. Örnekler: nick!*@*, *!*@host.com, *!*user@*\"],\"qhzpRq\":[\"Kanal Anahtarı (+k)\"],\"qtoOYG\":[\"Sınır yok\"],\"r1W2AS\":[\"Dosya sunucu görseli\"],\"rIPR2O\":[\"Şu kadar dakika önce önce konu belirlenen\"],\"rMMSYo\":[\"Maksimum uzunluk \",[\"0\"]],\"rWtzQe\":[\"Ağ bölündü ve yeniden birleşti. ✅\"],\"rYG2u6\":[\"Lütfen bekleyin...\"],\"rdUucN\":[\"Önizleme\"],\"rjGI/Q\":[\"Gizlilik\"],\"rk8iDX\":[\"GIF'ler yükleniyor...\"],\"rn6SBY\":[\"Sesi Aç\"],\"s/UKqq\":[\"Kanaldan atıldı\"],\"s8cATI\":[[\"channelName\"],\" kanalına katıldı\"],\"sCO9ue\":[\"<0>\",[\"serverName\"],\" bağlantısında aşağıdaki güvenlik endişeleri bulunmaktadır:\"],\"sGH11W\":[\"Sunucu\"],\"sHI1H+\":[\"artık **\",[\"newNick\"],\"** olarak bilinmektedir\"],\"sJyV04\":[[\"inviter\"],\", sizi \",[\"channel\"],\" kanalına davet etti\"],\"sby+1/\":[\"Kopyalamak için tıklayın\"],\"sfN25C\":[\"Gerçek veya tam adınız\"],\"sliuzR\":[\"Bağlantıyı Aç\"],\"sqrO9R\":[\"Özel Bahsediler\"],\"sr6RdJ\":[\"Shift+Enter ile çok satır\"],\"swrCpB\":[\"Kanal, \",[\"user\"],\" tarafından \",[\"oldName\"],\" yerine \",[\"newName\"],\" olarak yeniden adlandırıldı\",[\"0\"]],\"sxkWRg\":[\"Gelişmiş\"],\"t/YqKh\":[\"Kaldır\"],\"t47eHD\":[\"Bu sunucudaki benzersiz tanımlayıcınız\"],\"tAkAh0\":[\"Dinamik boyutlandırma için isteğe bağlı \",[\"size\"],\" değişkeni içeren URL. Örnek: https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"tXLJS3\":[\"Kanal listesi kenar çubuğunu göster veya gizle\"],\"tfDRzk\":[\"Kaydet\"],\"tiBsJk\":[[\"channelName\"],\" kanalından ayrıldı\"],\"tt4/UD\":[\"ayrıldı (\",[\"reason\"],\")\"],\"u0TcnO\":[\"{nick} takma adı zaten kullanımda, {newNick} ile yeniden deneniyor\"],\"u0a8B4\":[\"Yönetici erişimi için IRC Operatörü olarak kimlik doğrula\"],\"u0rWFU\":[\"Şu kadar dakika önce sonra oluşturulan\"],\"u72w3t\":[\"Engellenecek kullanıcılar ve desenler\"],\"u7jc2L\":[\"ayrıldı\"],\"uAQUqI\":[\"Durum\"],\"uB85T3\":[\"Kaydetme başarısız: \",[\"msg\"]],\"uV3DOL\":[\"G-Line\"],\"uW3lLI\":[\"IRC Sunucuları:\"],\"usSSr/\":[\"Yakınlaştırma seviyesi\"],\"v7uvcf\":[\"Yazılım:\"],\"vE8kb+\":[\"Yeni satır için Shift+Enter kullanın (Enter gönderir)\"],\"vERlcd\":[\"Profil\"],\"vK0RL8\":[\"Konu yok\"],\"vSJd18\":[\"Video\"],\"vXIe7J\":[\"Dil\"],\"vaHYxN\":[\"Gerçek Ad\"],\"vhjbKr\":[\"Uzakta\"],\"w4NYox\":[[\"title\"],\" istemcisi\"],\"w8xQRx\":[\"Geçersiz değer\"],\"wFjjxZ\":[[\"channelName\"],\" kanalından \",[\"username\"],\" tarafından atıldı (\",[\"reason\"],\")\"],\"wGjaGl\":[\"Yasak istisnası bulunamadı\"],\"wPrGnM\":[\"Kanal Yöneticisi\"],\"wRkP2d\":[\"GIF\"],\"wbm86v\":[\"Kullanıcılar kanala katıldığında veya ayrıldığında göster\"],\"whqZ9r\":[\"Vurgulanacak ek kelimeler veya ifadeler\"],\"wm7RV4\":[\"Bildirim Sesi\"],\"wz/Yoq\":[\"Mesajlarınız sunucular arasında iletilirken ele geçirilebilir\"],\"xCJdfg\":[\"Temizle\"],\"xUHRTR\":[\"Bağlanırken otomatik olarak operatör kimliği doğrula\"],\"xWHwwQ\":[\"Yasaklar\"],\"xYilR2\":[\"Medya\"],\"xceQrO\":[\"Yalnızca güvenli websocket'ler desteklenmektedir\"],\"xdtXa+\":[\"kanal-adı\"],\"xfXC7q\":[\"Metin Kanalları\"],\"xlCYOE\":[\"Daha fazla mesaj alınıyor...\"],\"xlhswE\":[\"Minimum değer \",[\"0\"]],\"xq97Ci\":[\"Kelime veya ifade ekle...\"],\"xuRqRq\":[\"İstemci Sınırı (+l)\"],\"xwF+7J\":[[\"0\"],\" yazıyor...\"],\"yNeucF\":[\"Bu sunucu genişletilmiş profil meta verilerini (IRCv3 METADATA uzantısı) desteklemiyor. Avatar, görünen ad ve durum gibi ek alanlar mevcut değil.\"],\"yPlrca\":[\"Kanal Avatarı\"],\"yQE2r9\":[\"Yükleniyor\"],\"ySU+JY\":[\"eposta@adresiniz.com\"],\"yTX1Rt\":[\"Oper Kullanıcı Adı\"],\"yYOzWD\":[\"günlükler\"],\"yfx9Re\":[\"IRC operatör şifresi\"],\"ygCKqB\":[\"Durdur\"],\"ymDxJx\":[\"IRC operatör kullanıcı adı\"],\"yrpRsQ\":[\"Ada Göre Sırala\"],\"yz7wBu\":[\"Kapat\"],\"zJw+jA\":[\"modu ayarlar: \",[\"0\"]],\"zebeLu\":[\"Oper kullanıcı adını girin\"],\"zpr0Bw\":[\"GZ-Line\"]}"); \ No newline at end of file +/*eslint-disable*/export const messages=JSON.parse("{\"+5VMrz\":[\"Geçersiz desen biçimi. nick!user@host biçimini kullanın (joker karakter * kullanılabilir)\"],\"+6NQQA\":[\"Genel Destek Kanalı\"],\"+6NyRG\":[\"İstemci\"],\"+K0AvT\":[\"Bağlantıyı Kes\"],\"+cyFdH\":[\"Uzakta olarak işaretlendiğinde gösterilecek varsayılan mesaj\"],\"+mVPqU\":[\"Mesajlarda markdown biçimlendirmesini işle\"],\"+vqCJH\":[\"Kimlik doğrulama için hesap kullanıcı adınız\"],\"+yPBXI\":[\"Dosya seç\"],\"+zy2Nq\":[\"Tür\"],\"/09cao\":[\"Düşük Bağlantı Güvenliği (Seviye \",[\"securityLevel\"],\")\"],\"/3BQ4J\":[\"Kanal dışındaki kullanıcılar kanala mesaj gönderemez\"],\"/6BzZF\":[\"Üye Listesini Aç/Kapat\"],\"/TNOPk\":[\"Kullanıcı uzakta\"],\"/XQgft\":[\"Keşfet\"],\"/cF7Rs\":[\"Ses Seviyesi\"],\"/dqduX\":[\"Sonraki sayfa\"],\"/fc3q4\":[\"Tüm İçerik\"],\"/kISDh\":[\"Bildirim Seslerini Etkinleştir\"],\"/n04sB\":[\"Kill\"],\"/rTz0M\":[\"Ses\"],\"/rfkZe\":[\"Bahisler ve mesajlar için ses çal\"],\"0/0ZGA\":[\"Kanal Adı Maskesi\"],\"0D6j7U\":[\"Özel kurallar hakkında daha fazla bilgi →\"],\"0XsHcR\":[\"Kullanıcıyı At\"],\"0ZpE//\":[\"Kullanıcıya Göre Sırala\"],\"0bEPwz\":[\"Uzakta Olarak İşaretle\"],\"0dGkPt\":[\"Kanal listesini genişlet\"],\"0gS7M5\":[\"Görünen Ad\"],\"0kS+M8\":[\"ÖrnekAĞ\"],\"0rgoY7\":[\"Yalnızca seçtiğiniz sunuculara bağlanın\"],\"0wdd7X\":[\"Katıl\"],\"0wkVYx\":[\"Özel Mesajlar\"],\"111uHX\":[\"Bağlantı önizlemesi\"],\"196EG4\":[\"Özel Sohbeti Sil\"],\"1DSr1i\":[\"Hesap kaydı oluştur\"],\"1O/24y\":[\"Kanal Listesini Aç/Kapat\"],\"1TNIig\":[\"Open\"],\"1VPJJ2\":[\"Harici Bağlantı Uyarısı\"],\"1ZC/dv\":[\"Okunmamış bahis veya mesaj yok\"],\"1pO1zi\":[\"Sunucu adı gereklidir\"],\"1uwfzQ\":[\"Kanal Konusunu Görüntüle\"],\"268g7c\":[\"Görünen adı girin\"],\"2FOFq1\":[\"Ağdaki sunucu operatörleri mesajlarınızı okuyabilir\"],\"2FYpfJ\":[\"Daha fazla\"],\"2HF1Y2\":[[\"inviter\"],\", \",[\"target\"],\" kişisini \",[\"channel\"],\" kanalına davet etti\"],\"2I70QL\":[\"Kullanıcı profil bilgilerini görüntüle\"],\"2QYdmE\":[\"Kullanıcılar:\"],\"2QpEjG\":[\"ayrıldı\"],\"2YE223\":[\"#\",[\"0\"],\" kanalına mesaj (yeni satır için Enter, göndermek için Shift+Enter)\"],\"2bimFY\":[\"Sunucu şifresini kullan\"],\"2iTmdZ\":[\"Yerel Depolama:\"],\"2odkwe\":[\"Katı - Daha agresif koruma\"],\"2uDhbA\":[\"Davet edilecek kullanıcı adını girin\"],\"2ygf/L\":[\"← Geri\"],\"2zEgxj\":[\"GIF ara...\"],\"3RdPhl\":[\"Kanalı Yeniden Adlandır\"],\"3THokf\":[\"Sesli Kullanıcı\"],\"3TSz9S\":[\"Küçült\"],\"3jBDvM\":[\"Kanal Görünen Adı\"],\"3ryuFU\":[\"Uygulamayı geliştirmek için isteğe bağlı çökme raporları\"],\"3uBF/8\":[\"Görüntüleyiciyi kapat\"],\"3uwW8F\":[\"https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"3xf8Kz\":[\"Hesap adını girin...\"],\"4/Rr0R\":[\"Mevcut kanala bir kullanıcı davet et\"],\"4EZrJN\":[\"Kurallar\"],\"4JJtW9\":[\"#overflow\"],\"4NqeT4\":[\"Flood Profili (+F)\"],\"4RZQRK\":[\"Ne yapıyorsun?\"],\"4hfTrB\":[\"Takma Ad\"],\"4n99LO\":[[\"0\"],\" kanalında zaten var\"],\"4t6vMV\":[\"Kısa mesajlar için otomatik olarak tek satıra geç\"],\"4vsHmf\":[\"Süre (dk)\"],\"4x/Axu\":[\"Bouncer'ınızda henüz ağ yok. Başlamak için bir tane ekleyin.\"],\"5+INAX\":[\"Sizi bahseden mesajları vurgula\"],\"5R5Pv/\":[\"Oper Adı\"],\"678PKt\":[\"Ağ Adı\"],\"6Aih4U\":[\"Çevrimdışı\"],\"6CO3WE\":[\"Kanala katılmak için şifre gerekli. Anahtarı kaldırmak için boş bırakın.\"],\"6HhMs3\":[\"Ayrılma Mesajı\"],\"6V3Ea3\":[\"Kopyalandı\"],\"6lGV3K\":[\"Daha az göster\"],\"6yFOEi\":[\"Oper şifresini girin...\"],\"7+IHTZ\":[\"Dosya seçilmedi\"],\"73fnil\":[\"TLS\"],\"73hrRi\":[\"nick!user@host (örn. spam*!*@*, *!*@badhost.com)\"],\"7QkKyN\":[\"Özel mesaj gönder\"],\"7U1W7c\":[\"Çok Rahat\"],\"7Y1YQj\":[\"Gerçek ad:\"],\"7YHArF\":[\"— görüntüleyicide aç\"],\"7fjnVl\":[\"Kullanıcı ara...\"],\"7jL88x\":[\"Bu mesaj silinsin mi? Bu işlem geri alınamaz.\"],\"7nGhhM\":[\"Aklınızda ne var?\"],\"7sEpu1\":[\"Üyeler — \",[\"0\"]],\"7sNhEz\":[\"Kullanıcı Adı\"],\"8H0Q+x\":[\"Profiller hakkında daha fazla bilgi →\"],\"8Phu0A\":[\"Kullanıcılar takma adını değiştirdiğinde göster\"],\"8XTG9e\":[\"Oper şifresini girin\"],\"8XsV2J\":[\"Yeniden gönder\"],\"8ZsakT\":[\"Şifre\"],\"8kR84m\":[\"Harici bir bağlantı açmak üzeresiniz:\"],\"8lCgih\":[\"Kuralı Kaldır\"],\"8p/xVT\":[[\"0\",\"plural\",{\"one\":[[\"1\"]],\"other\":[[\"2\"]]}]],\"8wRzac\":[[\"joinCount\",\"plural\",{\"one\":[\"katıldı\"],\"other\":[[\"joinCount\"],\" kez katıldı\"]}]],\"9BMLnJ\":[\"Sunucuya yeniden bağlan\"],\"9OEgyT\":[\"Tepki ekle\"],\"9PQ8m2\":[\"G-Line (global yasak)\"],\"9Qs99X\":[\"E-posta:\"],\"9QupBP\":[\"Deseni kaldır\"],\"9W7tl5\":[\"(değiştirilmedi)\"],\"9bG48P\":[\"Gönderiliyor\"],\"9f5f0u\":[\"Gizlilik hakkında sorularınız mı var? Bize ulaşın:\"],\"9iweoP\":[[\"0\"],\" üzerindeki ağlar\"],\"9unqs3\":[\"Uzakta:\"],\"9v3hwv\":[\"Sunucu bulunamadı.\"],\"9zb2WA\":[\"Bağlanıyor\"],\"A1taO8\":[\"Ara\"],\"A2adVi\":[\"Yazıyor Bildirimleri Gönder\"],\"A9Rhec\":[\"Kanal Adı\"],\"AWOSPo\":[\"Yakınlaştır\"],\"AXSpEQ\":[\"Bağlanırken Oper Ol\"],\"AeXO77\":[\"Hesap\"],\"AhNP40\":[\"Konuma git\"],\"Ai2U7L\":[\"Host\"],\"AjBQnf\":[\"Takma ad değiştirildi\"],\"AmXVh6\":[\"https://example.com/avatar.png\"],\"AnRu/j\":[\"Yanıtı iptal et\"],\"ApSx0O\":[\"\\\"\",[\"searchQuery\"],\"\\\" ile eşleşen \",[\"0\"],\" mesaj bulundu\"],\"AxPAXW\":[\"Sonuç bulunamadı\"],\"AyNqAB\":[\"Tüm sunucu olaylarını sohbette göster\"],\"B/QqGw\":[\"Klavyeden uzakta\"],\"B0sB2k\":[\"Düz metin\"],\"B8AaMI\":[\"Bu alan zorunludur\"],\"BA2c49\":[\"Sunucu gelişmiş LIST filtrelemesini desteklemiyor\"],\"BDKt3I\":[[\"0\"],\", \",[\"1\"],\", \",[\"2\"],\" ve \",[\"3\"],\" kişi daha yazıyor...\"],\"BGul2A\":[\"Kaydedilmemiş değişiklikleriniz var. Kaydetmeden kapatmak istediğinizden emin misiniz?\"],\"BIf9fi\":[\"Durum mesajınız\"],\"BZz3md\":[\"Kişisel web siteniz\"],\"Bgm/H7\":[\"Çok satırlı metin girişine izin ver\"],\"BiQIl1\":[\"Bu özel mesaj konuşmasını sabitle\"],\"BlNZZ2\":[\"Mesaja gitmek için tıklayın\"],\"Bowq3c\":[\"Yalnızca operatörler kanal konusunu değiştirebilir\"],\"Btozzp\":[\"Bu görüntünün süresi doldu\"],\"Bycfjm\":[\"Toplam: \",[\"0\"]],\"C6IBQc\":[\"Tüm JSON'u kopyala\"],\"C9L9wL\":[\"Veri Toplama\"],\"CDq4wC\":[\"Kullanıcıyı Yönet\"],\"CHVRxG\":[\"@\",[\"0\"],\"'a mesaj (yeni satır için Shift+Enter)\"],\"CN9zdR\":[\"Oper adı ve şifresi gereklidir\"],\"CW3sYa\":[[\"emoji\"],\" tepkisi ekle\"],\"CaAkqd\":[\"Ayrılmaları Göster\"],\"CbvaYj\":[\"Takma Adıyla Yasakla\"],\"CcK+Ft\":[\"PDF\"],\"Ce8q3L\":[\"Bir kanal seçin\"],\"CsekCi\":[\"Normal\"],\"D+NlUC\":[\"Sistem\"],\"D28t6+\":[\"katıldı ve ayrıldı\"],\"DB8zMK\":[\"Uygula\"],\"DBcWHr\":[\"Özel bildirim sesi dosyası\"],\"DTy9Xw\":[\"Medya Önizlemeleri\"],\"Dj4pSr\":[\"Güvenli bir şifre seçin\"],\"Du+zn+\":[\"Aranıyor...\"],\"Du2T2f\":[\"Ayar bulunamadı\"],\"DwsSVQ\":[\"Filtreleri Uygula ve Yenile\"],\"E3W/zd\":[\"Varsayılan Takma Ad\"],\"E6nRW7\":[\"URL'yi Kopyala\"],\"E703RG\":[\"Modlar:\"],\"EAeu1Z\":[\"Davet Gönder\"],\"EFKJQT\":[\"Ayar\"],\"EGPQBv\":[\"Özel Flood Kuralları (+f)\"],\"ELik0r\":[\"Tam Gizlilik Politikasını Görüntüle\"],\"EPbeC2\":[\"Kanal konusunu görüntüle veya düzenle\"],\"EQCDNT\":[\"Oper kullanıcı adını girin...\"],\"EUvulZ\":[\"\\\"\",[\"searchQuery\"],\"\\\" ile eşleşen 1 mesaj bulundu\"],\"EatZYJ\":[\"Sonraki görüntü\"],\"EdQY6l\":[\"Yok\"],\"EnqLYU\":[\"Sunucu ara...\"],\"F0OKMc\":[\"Sunucuyu Düzenle\"],\"F6Int2\":[\"Vurgulamayı Etkinleştir\"],\"FDoLyE\":[\"Maks. Kullanıcı\"],\"FUU/hZ\":[\"Sohbette ne kadar harici medyanın yükleneceğini denetleyin.\"],\"Fdp03t\":[\"açık\"],\"FfPWR0\":[\"Pencere\"],\"FjkaiT\":[\"Uzaklaştır\"],\"FlqOE9\":[\"Bu ne anlama geliyor:\"],\"FolHNl\":[\"Hesabınızı ve kimlik doğrulamayı yönetin\"],\"Fp2Dif\":[\"Sunucudan ayrıldı\"],\"G5KmCc\":[\"GZ-Line (global Z-Line)\"],\"GDs0lz\":[\"<0>Risk: Hassas bilgiler (mesajlar, özel konuşmalar, kimlik doğrulama bilgileri) ağ yöneticilerine veya IRC sunucuları arasına konumlanmış saldırganlara açık olabilir.\"],\"GR+2I3\":[\"Davet maskesi ekle (örn. nick!*@*, *!*@host.com)\"],\"GRLyMU\":[\"Açılır sunucu bildirimlerini kapat\"],\"GlHnXw\":[\"Takma ad değişikliği başarısız: \",[\"error\"],\" \",[\"0\"]],\"GswZF3\":[\"Önizleme:\"],\"GtmO8/\":[\"kimden\"],\"GtuHUQ\":[\"Bu kanalı sunucuda yeniden adlandır. Tüm kullanıcılar yeni adı görecek.\"],\"GuGfFX\":[\"Aramayı aç/kapat\"],\"GxkJXS\":[\"Yükleniyor...\"],\"GzbwnK\":[\"Kanala katıldı\"],\"GzsUDB\":[\"Genişletilmiş Profil\"],\"H/PnT8\":[\"Emoji ekle\"],\"H6Izzl\":[\"Tercih ettiğiniz renk kodu\"],\"H9jIv+\":[\"Katılma/Ayrılma Göster\"],\"HAKBY9\":[\"Dosyaları yükle\"],\"HdE1If\":[\"Kanal\"],\"Hk4AW9\":[\"Tercih ettiğiniz görünen ad\"],\"HmHDk7\":[\"Üye Seç\"],\"HrQzPU\":[[\"networkName\"],\" üzerindeki kanallar\"],\"I2tXQ5\":[\"@\",[\"0\"],\"'a mesaj (yeni satır için Enter, göndermek için Shift+Enter)\"],\"I6bw/h\":[\"Kullanıcıyı Yasakla\"],\"I92Z+b\":[\"Bildirimleri etkinleştir\"],\"I9D72S\":[\"Bu mesajı silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.\"],\"IA+1wo\":[\"Kullanıcılar kanaldan atıldığında göster\"],\"IDwkJx\":[\"IRC Operatörü\"],\"ILlU+s\":[\"Bilgi:\"],\"IUwGEM\":[\"Değişiklikleri Kaydet\"],\"IVeGK6\":[[\"0\"],\", \",[\"1\"],\" ve \",[\"2\"],\" yazıyor...\"],\"IgrLD/\":[\"Duraklat\"],\"Im6JED\":[\"FISISALTI\"],\"ImOQa9\":[\"Yanıtla\"],\"IoHMnl\":[\"Maksimum değer \",[\"0\"]],\"IvMj+0\":[\"Op\"],\"J28zul\":[\"Bağlanıyor...\"],\"J5T9NW\":[\"Kullanıcı Bilgileri\"],\"J8Y5+z\":[\"Eyvah! Ağ bölündü! ⚠️\"],\"JBHkBA\":[\"Kanaldan ayrıldı\"],\"JCwL0Q\":[\"Neden girin (isteğe bağlı)\"],\"JFciKP\":[\"Değiştir\"],\"JXGkhG\":[\"Kanal adını değiştir (yalnızca operatörler)\"],\"JcD7qf\":[\"Daha fazla işlem\"],\"JdkA+c\":[\"Gizli (+s)\"],\"Jmu12l\":[\"Sunucu Kanalları\"],\"JvQ++s\":[\"Markdown'ı Etkinleştir\"],\"K2jwh/\":[\"WHOIS verisi mevcut değil\"],\"KAXSwC\":[\"Voice\"],\"KDfTdX\":[\"Mesajı sil\"],\"KKBlUU\":[\"Gömülü\"],\"KM0pLb\":[\"Kanala hoş geldiniz!\"],\"KR6W2h\":[\"Kullanıcının Engelini Kaldır\"],\"KV+Bi1\":[\"Yalnızca Davetli (+i)\"],\"KdCtwE\":[\"Sayaçları sıfırlamadan önce flood etkinliğinin kaç saniye izleneceği\"],\"Kkezga\":[\"Sunucu Şifresi\"],\"KsiQ/8\":[\"Kullanıcıların kanala katılmak için davet edilmesi gerekir\"],\"L+gB/D\":[\"Kanal bilgisi\"],\"LC1a7n\":[\"IRC sunucusu, sunucular arası bağlantılarının düşük güvenlik seviyesine sahip olduğunu bildirdi. Bu, mesajlarınız ağdaki IRC sunucuları arasında iletilirken düzgün şifrelenmeyebileceği veya SSL/TLS sertifikalarının doğru şekilde doğrulanmayabileceği anlamına gelir.\"],\"LNfLR5\":[\"Atmaları Göster\"],\"LP+1Z7\":[\"Ağ Ekle\"],\"LQb0W/\":[\"Tüm Olayları Göster\"],\"LU7/yA\":[\"Arayüzde gösterilecek alternatif ad. Boşluk, emoji ve özel karakter içerebilir. IRC komutlarında gerçek kanal adı (\",[\"channelName\"],\") kullanılmaya devam eder.\"],\"LUb9O7\":[\"Geçerli bir sunucu portu gereklidir\"],\"Lb+BUl\":[\"https://example.com/avatar.jpg\"],\"LcET2C\":[\"Gizlilik Politikası\"],\"LcuSDR\":[\"Profil bilgilerinizi ve meta verilerinizi yönetin\"],\"LqLS9B\":[\"Takma Ad Değişikliklerini Göster\"],\"LsDQt2\":[\"Kanal Ayarları\"],\"LtI9AS\":[\"Sahip\"],\"LuNhhL\":[\"bu mesaja tepki verdi\"],\"M/AZNG\":[\"Avatar görüntünüzün URL'si\"],\"M/WIer\":[\"Mesaj Gönder\"],\"M8er/5\":[\"Ad:\"],\"MHk+7g\":[\"Önceki görüntü\"],\"MRorGe\":[\"Kullanıcıya PM Gönder\"],\"MVbSGP\":[\"Zaman Penceresi (saniye)\"],\"MkpcsT\":[\"Mesajlarınız ve ayarlarınız cihazınızda yerel olarak saklanır\"],\"MzPdC2\":[\"Sunucu Şifresi (PASS)\"],\"N/hDSy\":[\"Bot olarak işaretle - genellikle 'on' veya boş\"],\"N6j2JH\":[[\"0\"],\" ögesini düzenle\"],\"N7TQbE\":[[\"channelName\"],\" kanalına Kullanıcı Davet Et\"],\"NCca/o\":[\"Varsayılan takma adı girin...\"],\"Nqs6B9\":[\"Tüm harici medyayı gösterir. Herhangi bir URL bilinmeyen bir sunucuya istek gönderebilir.\"],\"Nt+9O7\":[\"Ham TCP yerine WebSocket kullan\"],\"NxIHzc\":[\"Kullanıcıyı at\"],\"O+v/cL\":[\"Sunucudaki tüm kanalları görüntüle\"],\"OCGpR4\":[\"(devral)\"],\"ODwSCk\":[\"GIF gönder\"],\"OGQ5kK\":[\"Bildirim seslerini ve vurguları yapılandır\"],\"OIPt1Z\":[\"Üye listesi kenar çubuğunu göster veya gizle\"],\"OKSNq/\":[\"Çok Katı\"],\"ONWvwQ\":[\"Yükle\"],\"OVKoQO\":[\"Kimlik doğrulama için hesap şifreniz\"],\"ObsidianIRC - Bringing IRC to the future\":[\"ObsidianIRC - IRC'yi geleceğe taşıyor\"],\"OhCpra\":[\"Konu belirle…\"],\"OkltoQ\":[[\"username\"],\" kullanıcısını takma adıyla yasakla (aynı takma adla yeniden katılmasını engeller)\"],\"P+t/Te\":[\"Ek veri yok\"],\"P42Wcc\":[\"Güvenli\"],\"PD38l0\":[\"Kanal avatarı önizlemesi\"],\"PD9mEt\":[\"Mesaj yazın...\"],\"PPqfdA\":[\"Kanal yapılandırma ayarlarını aç\"],\"PSCjfZ\":[\"Bu kanal için görüntülenecek konu. Tüm kullanıcılar konuyu görebilir.\"],\"PZCecv\":[\"PDF önizleme\"],\"PeLgsC\":[[\"c\",\"plural\",{\"one\":[\"1 kez\"],\"other\":[[\"c\"],\" kez\"]}]],\"PguS2C\":[\"İstisna maskesi ekle (örn. nick!*@*, *!*@host.com)\"],\"Pil5Ty\":[[\"0\"],\" kanaldan \",[\"displayedChannelsCount\"],\" tanesi gösteriliyor\"],\"PqhVlJ\":[\"Kullanıcıyı Yasakla (Host Maskesiyle)\"],\"Q+chwU\":[\"Kullanıcı adı:\"],\"Q3v9Wc\":[\"Evet, sil\"],\"Q6hhn8\":[\"Tercihler\"],\"QF4a34\":[\"Lütfen bir kullanıcı adı girin\"],\"QGqSZ2\":[\"Renk ve Biçimlendirme\"],\"QJQd1J\":[\"Profili Düzenle\"],\"QSzGDE\":[\"Boşta\"],\"QUlny5\":[[\"0\"],\"'a hoş geldiniz!\"],\"Qoq+GP\":[\"Devamını oku\"],\"QuSkCF\":[\"Kanalları filtrele...\"],\"QwUrDZ\":[\"konuyu şu şekilde değiştirdi: \",[\"topic\"]],\"R0UH07\":[[\"1\"],\" görselinden \",[\"0\"],\". görsel\"],\"R7SsBE\":[\"Sessize Al\"],\"R8rf1X\":[\"Konu belirlemek için tıklayın\"],\"RArB3D\":[[\"channelName\"],\" kanalından \",[\"username\"],\" tarafından atıldı\"],\"RI3cWd\":[\"ObsidianIRC ile IRC dünyasını keşfedin\"],\"RMMaN5\":[\"Moderasyonlu (+m)\"],\"RWw9Lg\":[\"Pencereyi kapat\"],\"RZ2BuZ\":[[\"account\"],\" hesap kaydı doğrulama gerektiriyor: \",[\"message\"]],\"RySp6q\":[\"Yorumları gizle\"],\"S5Togi\":[\"Bouncer'ınızdan ağlar yükleniyor…\"],\"SPKQTd\":[\"Takma ad gereklidir\"],\"SPVjfj\":[\"Boş bırakılırsa varsayılan olarak 'neden yok' kullanılır\"],\"SQKPvQ\":[\"Kullanıcı Davet Et\"],\"STmlpb\":[\"Back to network list\"],\"SkZcl+\":[\"Önceden tanımlanmış bir flood koruma profili seçin. Bu profiller, farklı kullanım durumları için dengeli koruma ayarları sunar.\"],\"Slr+3C\":[\"Min. Kullanıcı\"],\"Spnlre\":[[\"target\"],\" kişisini \",[\"channel\"],\" kanalına davet ettiniz\"],\"T/ckN5\":[\"Görüntüleyicide aç\"],\"T91vKp\":[\"Oynat\"],\"TV2Wdu\":[\"Verilerinizi nasıl işlediğimizi ve gizliliğinizi nasıl koruduğumuzu öğrenin.\"],\"TgFpwD\":[\"Uygulanıyor...\"],\"TkzSFB\":[\"Değişiklik Yok\"],\"TtserG\":[\"Gerçek adı girin\"],\"Ttz9J1\":[\"Şifreyi girin...\"],\"Tz0i8g\":[\"Ayarlar\"],\"U3pytU\":[\"Yönetici\"],\"UDb2YD\":[\"Tepki Ver\"],\"UE4KO5\":[\"*kanal*\"],\"UGT5vp\":[\"Ayarları Kaydet\"],\"UV5hLB\":[\"Yasak bulunamadı\"],\"Uaj3Nd\":[\"Durum Mesajları\"],\"Ue3uny\":[\"Varsayılan (profil yok)\"],\"UkARhe\":[\"Normal - Standart koruma\"],\"Umn7Cj\":[\"Henüz yorum yok. İlk sen ol!\"],\"UtUIRh\":[[\"0\"],\" eski mesaj\"],\"UwzP+U\":[\"Güvenli Bağlantı\"],\"V0/A4O\":[\"Kanal Sahibi\"],\"V4qgxE\":[\"Şu kadar dakika önce önce oluşturulan\"],\"V8yTm6\":[\"Aramayı temizle\"],\"VJMMyz\":[\"ObsidianIRC - IRC'yi geleceğe taşıyor\"],\"VJScHU\":[\"Neden\"],\"VLsmVV\":[\"Bildirimleri sessize al\"],\"VbyRUy\":[\"Yorumlar\"],\"Vmx0mQ\":[\"Ayarlayan:\"],\"VqnIZz\":[\"Gizlilik politikamızı ve veri uygulamalarımızı görüntüleyin\"],\"VrMygG\":[\"Minimum uzunluk \",[\"0\"]],\"VrnTui\":[\"Profilinizde gösterilen zamirleriniz\"],\"W8E3qn\":[\"Doğrulanmış Hesap\"],\"WAakm9\":[\"Kanalı Sil\"],\"WFxTHC\":[\"Yasaklama maskesi ekle (örn. nick!*@*, *!*@host.com)\"],\"WN1g9F\":[\"Sunucu hostu gereklidir\"],\"WRYdXW\":[\"Ses konumu\"],\"WUOH5B\":[\"Kullanıcıyı Engelle\"],\"WWEXnZ\":[[\"0\",\"plural\",{\"one\":[\"1 öğe daha göster\"],\"other\":[[\"1\"],\" öğe daha göster\"]}]],\"Weq9zb\":[\"Genel\"],\"Wfj7Sk\":[\"Bildirim seslerini sessize al veya aç\"],\"Wm7gbG\":[\"GitHub:\"],\"WyeHWY\":[\"*spam*\"],\"WzMCru\":[\"Kullanıcı Profili\"],\"X6S3lt\":[\"Ayarlar, kanallar, sunucular arayın...\"],\"XEHan5\":[\"Yine de Devam Et\"],\"XI1+wb\":[\"Geçersiz biçim\"],\"XIXeuC\":[\"@\",[\"0\"],\"'a mesaj\"],\"XMS+k4\":[\"Özel Mesaj Başlat\"],\"XWgxXq\":[\"Albüm\"],\"Xd7+IT\":[\"Özel Sohbetin Sabitlemesini Kaldır\"],\"Xm/s+u\":[\"Görünüm\"],\"Xp2n93\":[\"Sunucunuzun güvenilir dosya hostundan medya gösterir. Harici hizmetlere istek gönderilmez.\"],\"XvjC4F\":[\"Kaydediliyor...\"],\"Y/qryO\":[\"Aramanızla eşleşen kullanıcı bulunamadı\"],\"YAqRpI\":[[\"account\"],\" hesap kaydı başarılı: \",[\"message\"]],\"YEfzvP\":[\"Korumalı Konu (+t)\"],\"YQOn6a\":[\"Üye listesini daralt\"],\"YRCoE9\":[\"Kanal Operatörü\"],\"YURQaF\":[\"Profili Görüntüle\"],\"YdBSvr\":[\"Medya gösterimini ve harici içeriği denetleyin\"],\"Yj6U3V\":[\"Merkezi Sunucu Yok:\"],\"YjvpGx\":[\"Zamirler\"],\"YqH4l4\":[\"Anahtar yok\"],\"YyUPpV\":[\"Hesap:\"],\"ZJSWfw\":[\"Sunucudan ayrıldığınızda gösterilen mesaj\"],\"ZR1dJ4\":[\"Davetler\"],\"ZdWg0V\":[\"Tarayıcıda aç\"],\"ZhRBbl\":[\"Mesajlarda ara…\"],\"Zmcu3y\":[\"Gelişmiş Filtreler\"],\"a2/8e5\":[\"Şu kadar dakika önce sonra konu belirlenen\"],\"aHKcKc\":[\"Önceki sayfa\"],\"aJTbXX\":[\"Oper Şifresi\"],\"aQryQv\":[\"Desen zaten mevcut\"],\"aW9pLN\":[\"Kanalda izin verilen maksimum kullanıcı sayısı. Sınır olmaması için boş bırakın.\"],\"ah4fmZ\":[\"YouTube, Vimeo, SoundCloud ve benzeri bilinen hizmetlerden önizlemeler de gösterir.\"],\"aifXak\":[\"Bu kanalda medya yok\"],\"ap2zBz\":[\"Rahat\"],\"az8lvo\":[\"Kapalı\"],\"azXSNo\":[\"Üye listesini genişlet\"],\"azdliB\":[\"Bir hesaba giriş yap\"],\"b26wlF\":[\"o/onun\"],\"bD/+Ei\":[\"Katı\"],\"bQ6BJn\":[\"Ayrıntılı flood koruma kurallarını yapılandırın. Her kural, hangi etkinlik türünün izleneceğini ve eşikler aşıldığında hangi işlemin yapılacağını belirtir.\"],\"beV7+y\":[\"Kullanıcı \",[\"channelName\"],\" kanalına katılmak için davet alacak.\"],\"bk84cH\":[\"Uzakta Mesajı\"],\"bkHdLj\":[\"IRC Sunucusu Ekle\"],\"bmQLn5\":[\"Kural Ekle\"],\"bv4cFj\":[\"Taşıma\"],\"bwRvnp\":[\"İşlem\"],\"c8+EVZ\":[\"Doğrulanmış hesap\"],\"cGYUlD\":[\"Medya önizlemesi yüklenmiyor.\"],\"cLF98o\":[\"Yorumları göster (\",[\"commentCount\"],\")\"],\"cLKIDO\":[\"Kullanılabilir kullanıcı yok\"],\"cSgpoS\":[\"Özel Sohbeti Sabitle\"],\"cde3ce\":[\"<0>\",[\"0\"],\"'a mesaj\"],\"chQsxg\":[\"Biçimlendirilmiş çıktıyı kopyala\"],\"cl/A5J\":[[\"__DEFAULT_IRC_SERVER_NAME__\"],\"'a hoş geldiniz!\"],\"cnGeoo\":[\"Sil\"],\"coPLXT\":[\"IRC iletişimlerinizi sunucularımızda saklamıyoruz\"],\"crYH/6\":[\"SoundCloud oynatıcı\"],\"cv5DQb\":[\"ana makine ayarlanmadı\"],\"d3sis4\":[\"Sunucu Ekle\"],\"d9aN5k\":[[\"username\"],\" kullanıcısını kanaldan kaldır\"],\"dEgA5A\":[\"İptal\"],\"dGi1We\":[\"Bu özel mesaj konuşmasının sabitlemesini kaldır\"],\"dJVuyC\":[[\"channelName\"],\" kanalından ayrıldı (\",[\"reason\"],\")\"],\"dMtLDE\":[\"kime\"],\"dXqxlh\":[\"<0>⚠️ Güvenlik Riski! Bu bağlantı, araya girme veya ortadaki adam saldırılarına karşı savunmasız olabilir.\"],\"da9Q/R\":[\"Kanal modları değiştirildi\"],\"dhJN3N\":[\"Yorumları göster\"],\"dj2xTE\":[\"Bildirimi kapat\"],\"dpCzmC\":[\"Flood Koruma Ayarları\"],\"e9dQpT\":[\"Bu bağlantıyı yeni sekmede açmak istiyor musunuz?\"],\"ePK91l\":[\"Düzenle\"],\"eYBDuB\":[\"Bir görüntü yükleyin veya dinamik boyutlandırma için isteğe bağlı \",[\"size\"],\" değişkeni içeren bir URL sağlayın\"],\"edBbee\":[[\"username\"],\" kullanıcısını host maskesiyle yasakla (aynı IP/host'tan yeniden katılmasını engeller)\"],\"ekfzWq\":[\"Kullanıcı Ayarları\"],\"elPDWs\":[\"IRC istemci deneyiminizi özelleştirin\"],\"eu2osY\":[\"<0>💡 Öneri: Yalnızca bu sunucuya güveniyorsanız ve risklerin farkındaysanız devam edin. Bu bağlantı üzerinden hassas bilgi veya şifre paylaşmaktan kaçının.\"],\"euEhbr\":[[\"channel\"],\" kanalına katılmak için tıklayın\"],\"ez3vLd\":[\"Çok Satırlı Girişi Etkinleştir\"],\"f0J5Ki\":[\"Sunucular arası iletişim şifrelenmemiş bağlantılar kullanıyor olabilir\"],\"f9BHJk\":[\"Kullanıcıyı Uyar\"],\"fDOLLd\":[\"Kanal bulunamadı.\"],\"ffzDkB\":[\"Anonim Analitik:\"],\"fq1GF9\":[\"Kullanıcılar sunucudan ayrıldığında göster\"],\"gEF57C\":[\"Bu sunucu yalnızca bir bağlantı türünü destekliyor\"],\"gJuLUI\":[\"Engelleme Listesi\"],\"gNzMrk\":[\"Mevcut avatar\"],\"gjPWyO\":[\"Takma adı girin...\"],\"gz6UQ3\":[\"Büyüt\"],\"h6/IMX\":[\"İlk ağınızı ekleyin\"],\"h6razj\":[\"Kanal Adı Maskesini Hariç Tut\"],\"hG6jnw\":[\"Konu belirlenmemiş\"],\"hG89Ed\":[\"Görüntü\"],\"hZ6znB\":[\"Port\"],\"ha+Bz5\":[\"örn. 100:1440\"],\"hehnjM\":[\"Miktar\"],\"hzdLuQ\":[\"Yalnızca Voice veya daha yüksek yetkiye sahip kullanıcılar konuşabilir\"],\"i0qMbr\":[\"Ana Sayfa\"],\"iDNBZe\":[\"Bildirimler\"],\"iH8pgl\":[\"Geri\"],\"iL9SZg\":[\"Kullanıcıyı Yasakla (Takma Adıyla)\"],\"iNt+3c\":[\"Görüntüye geri dön\"],\"iQvi+a\":[\"Bu sunucu için düşük bağlantı güvenliği konusunda uyarma\"],\"iSLIjg\":[\"Bağlan\"],\"iWXkHH\":[\"Halfop\"],\"iZeTtp\":[\"Sunucu Hostu\"],\"idD8Ev\":[\"Kaydedildi\"],\"iivqkW\":[\"Giriş Yapıldı\"],\"ij+Elv\":[\"Görüntü önizlemesi\"],\"ilIWp7\":[\"Bildirimleri Aç/Kapat\"],\"iuaqvB\":[\"Joker karakter için * kullanın. Örnekler: baduser!*@*, *!*@spammer.com, troll*!*@*\"],\"ixkTse\":[\"Bot\"],\"j2DGR0\":[\"Host Maskesiyle Yasakla\"],\"jA4uoI\":[\"Konu:\"],\"jLXxGK\":[\"https://example.com\"],\"jPSk57\":[\"Neden (isteğe bağlı)\"],\"jUV7CU\":[\"Avatar Yükle\"],\"jW5Uwh\":[\"Ne kadar harici medya yükleneceğini denetleyin. Kapalı / Güvenli / Güvenilir Kaynaklar / Tüm İçerik.\"],\"jXzms5\":[\"Ek seçenekleri\"],\"jZlrte\":[\"Renk\"],\"jfC/xh\":[\"İletişim\"],\"jywMpv\":[\"#yeni-kanal-adı\"],\"k112DD\":[\"Eski mesajları yükle\"],\"k3ID0F\":[\"Üyeleri filtrele…\"],\"k65gsE\":[\"Derinlemesine incele\"],\"k7Zgob\":[\"Bağlantıyı İptal Et\"],\"kAVx5h\":[\"Davet bulunamadı\"],\"kCLEPU\":[\"Bağlı Olduğu Sunucu\"],\"kF5LKb\":[\"Engellenen desenler:\"],\"kGeOx/\":[[\"0\"],\" kanalına katıl\"],\"kITKr8\":[\"Kanal modları yükleniyor...\"],\"kPpPsw\":[\"IRC Operatörüsünüz\"],\"kWJmRL\":[\"Siz\"],\"kfcRb0\":[\"Avatar\"],\"kjMqSj\":[\"JSON kopyala\"],\"krViRy\":[\"JSON olarak kopyalamak için tıklayın\"],\"ks71ra\":[\"İstisnalar\"],\"kw4lRv\":[\"Kanal Yarı Operatörü\"],\"kxgIRq\":[\"Başlamak için bir kanal seçin veya ekleyin.\"],\"ky6dWe\":[\"Avatar önizlemesi\"],\"l+GxCv\":[\"Kanallar yükleniyor...\"],\"l+IUVW\":[[\"account\"],\" hesap doğrulaması başarılı: \",[\"message\"]],\"l/siQz\":[[\"reconnectCount\",\"plural\",{\"one\":[\"yeniden bağlandı\"],\"other\":[[\"reconnectCount\"],\" kez yeniden bağlandı\"]}]],\"l5jmzx\":[[\"0\"],\" ve \",[\"1\"],\" yazıyor...\"],\"lHy8N5\":[\"Daha fazla kanal yükleniyor...\"],\"lbpf14\":[[\"value\"],\" kanalına katıl\"],\"lfFsZ4\":[\"Kanallar\"],\"lkNdiH\":[\"Hesap Adı\"],\"ln500L\":[\"ObsidianIRC\"],\"lnCMdg\":[\"Görüntü Yükle\"],\"loQxaJ\":[\"Geri Döndüm\"],\"lvfaxv\":[\"ANA SAYFA\"],\"m0oxpP\":[\"Libera Chat\"],\"m16xKo\":[\"Ekle\"],\"m8flAk\":[\"Önizleme (henüz yüklenmedi)\"],\"mEPxTp\":[\"<0>⚠️ Dikkatli olun! Yalnızca güvenilir kaynaklardan gelen bağlantıları açın. Kötü amaçlı bağlantılar güvenliğinizi veya gizliliğinizi tehlikeye atabilir.\"],\"mHGdhG\":[\"Sunucu bilgisi\"],\"mHS8lb\":[\"#\",[\"0\"],\" kanalına mesaj\"],\"mMYBD9\":[\"Geniş - Daha kapsamlı koruma alanı\"],\"mTGsPd\":[\"Kanal Konusu\"],\"mU8j6O\":[\"Harici Mesaj Yok (+n)\"],\"mZp8FL\":[\"Otomatik Tek Satıra Dön\"],\"mdQu8G\":[\"TakmaAdınız\"],\"miSSBQ\":[\"Yorumlar (\",[\"commentCount\"],\")\"],\"mvyLSy\":[\"Kullanıcı kimliği doğrulandı\"],\"mwtcGl\":[\"Yorumları kapat\"],\"myL0MR\":[\"Bu ağ silinsin mi?\"],\"mzI/c+\":[\"İndir\"],\"n3fGRk\":[[\"0\"],\" tarafından ayarlandı\"],\"nE9jsU\":[\"Rahat - Daha az agresif koruma\"],\"nNflMD\":[\"Kanaldan ayrıl\"],\"nPXkBi\":[\"WHOIS verisi yükleniyor...\"],\"nQnxxF\":[\"#\",[\"0\"],\" kanalına mesaj (yeni satır için Shift+Enter)\"],\"nWMRxa\":[\"Sabitlemeyi Kaldır\"],\"nkC032\":[\"Flood profili yok\"],\"o69z4d\":[[\"username\"],\" kullanıcısına uyarı mesajı gönder\"],\"o9ylQi\":[\"Başlamak için GIF arayın\"],\"oFGkER\":[\"Sunucu Bildirimleri\"],\"oOi11l\":[\"En alta kaydır\"],\"oQEzQR\":[\"Yeni DM\"],\"oXOSPE\":[\"Çevrimiçi\"],\"oal760\":[\"Sunucu bağlantılarında ortadaki adam saldırıları mümkün\"],\"oeqmmJ\":[\"Güvenilir Kaynaklar\"],\"ovBPCi\":[\"Varsayılan\"],\"p0Z69r\":[\"Desen boş olamaz\"],\"p1KgtK\":[\"Ses yüklenemedi\"],\"p59pEv\":[\"Ek ayrıntılar\"],\"p7sRI6\":[\"Yazarken diğerlerini bilgilendir\"],\"pBm1od\":[\"Gizli kanal\"],\"pNmiXx\":[\"Tüm sunucular için varsayılan takma adınız\"],\"pUUo9G\":[\"Ana makine adı:\"],\"pVGPmz\":[\"Hesap Şifresi\"],\"peNE68\":[\"Kalıcı\"],\"plhHQt\":[\"Veri yok\"],\"pm6+q5\":[\"Güvenlik Uyarısı\"],\"pn5qSs\":[\"Ek Bilgiler\"],\"q0cR4S\":[\"artık **\",[\"newNick\"],\"** olarak bilinmektedir\"],\"qFcunY\":[\"Kanal LIST veya NAMES komutlarında görünmeyecek\"],\"qLpTm/\":[[\"emoji\"],\" tepkisini kaldır\"],\"qVkGWK\":[\"Sabitle\"],\"qY8wNa\":[\"Ana Sayfa\"],\"qb0xJ7\":[\"Joker karakter kullanın: * herhangi bir diziyle eşleşir, ? herhangi bir tek karakterle eşleşir. Örnekler: nick!*@*, *!*@host.com, *!*user@*\"],\"qhzpRq\":[\"Kanal Anahtarı (+k)\"],\"qtoOYG\":[\"Sınır yok\"],\"r1W2AS\":[\"Dosya sunucu görseli\"],\"rIPR2O\":[\"Şu kadar dakika önce önce konu belirlenen\"],\"rMMSYo\":[\"Maksimum uzunluk \",[\"0\"]],\"rWtzQe\":[\"Ağ bölündü ve yeniden birleşti. ✅\"],\"rYG2u6\":[\"Lütfen bekleyin...\"],\"rdUucN\":[\"Önizleme\"],\"rjGI/Q\":[\"Gizlilik\"],\"rk8iDX\":[\"GIF'ler yükleniyor...\"],\"rn6SBY\":[\"Sesi Aç\"],\"s/UKqq\":[\"Kanaldan atıldı\"],\"s8cATI\":[[\"channelName\"],\" kanalına katıldı\"],\"sCO9ue\":[\"<0>\",[\"serverName\"],\" bağlantısında aşağıdaki güvenlik endişeleri bulunmaktadır:\"],\"sGH11W\":[\"Sunucu\"],\"sHI1H+\":[\"artık **\",[\"newNick\"],\"** olarak bilinmektedir\"],\"sJyV04\":[[\"inviter\"],\", sizi \",[\"channel\"],\" kanalına davet etti\"],\"sUBSbK\":[\"Henüz yukarı akış ağı yok.\"],\"sby+1/\":[\"Kopyalamak için tıklayın\"],\"sfN25C\":[\"Gerçek veya tam adınız\"],\"sliuzR\":[\"Bağlantıyı Aç\"],\"sqrO9R\":[\"Özel Bahsediler\"],\"sr6RdJ\":[\"Shift+Enter ile çok satır\"],\"swrCpB\":[\"Kanal, \",[\"user\"],\" tarafından \",[\"oldName\"],\" yerine \",[\"newName\"],\" olarak yeniden adlandırıldı\",[\"0\"]],\"sxkWRg\":[\"Gelişmiş\"],\"t/YqKh\":[\"Kaldır\"],\"t47eHD\":[\"Bu sunucudaki benzersiz tanımlayıcınız\"],\"tAkAh0\":[\"Dinamik boyutlandırma için isteğe bağlı \",[\"size\"],\" değişkeni içeren URL. Örnek: https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"tXLJS3\":[\"Kanal listesi kenar çubuğunu göster veya gizle\"],\"tfDRzk\":[\"Kaydet\"],\"tiBsJk\":[[\"channelName\"],\" kanalından ayrıldı\"],\"tt4/UD\":[\"ayrıldı (\",[\"reason\"],\")\"],\"u0TcnO\":[\"{nick} takma adı zaten kullanımda, {newNick} ile yeniden deneniyor\"],\"u0a8B4\":[\"Yönetici erişimi için IRC Operatörü olarak kimlik doğrula\"],\"u0rWFU\":[\"Şu kadar dakika önce sonra oluşturulan\"],\"u72w3t\":[\"Engellenecek kullanıcılar ve desenler\"],\"u7jc2L\":[\"ayrıldı\"],\"uAQUqI\":[\"Durum\"],\"uB85T3\":[\"Kaydetme başarısız: \",[\"msg\"]],\"uV3DOL\":[\"G-Line\"],\"uW3lLI\":[\"IRC Sunucuları:\"],\"usSSr/\":[\"Yakınlaştırma seviyesi\"],\"v7uvcf\":[\"Yazılım:\"],\"vE8kb+\":[\"Yeni satır için Shift+Enter kullanın (Enter gönderir)\"],\"vERlcd\":[\"Profil\"],\"vK0RL8\":[\"Konu yok\"],\"vSJd18\":[\"Video\"],\"vXIe7J\":[\"Dil\"],\"vaHYxN\":[\"Gerçek Ad\"],\"vhjbKr\":[\"Uzakta\"],\"w/nogd\":[[\"0\"],\" network\",[\"1\"],\" — pick one to join\"],\"w4NYox\":[[\"title\"],\" istemcisi\"],\"w8xQRx\":[\"Geçersiz değer\"],\"wFjjxZ\":[[\"channelName\"],\" kanalından \",[\"username\"],\" tarafından atıldı (\",[\"reason\"],\")\"],\"wGjaGl\":[\"Yasak istisnası bulunamadı\"],\"wPrGnM\":[\"Kanal Yöneticisi\"],\"wRkP2d\":[\"GIF\"],\"wbm86v\":[\"Kullanıcılar kanala katıldığında veya ayrıldığında göster\"],\"whqZ9r\":[\"Vurgulanacak ek kelimeler veya ifadeler\"],\"wm7RV4\":[\"Bildirim Sesi\"],\"wz/Yoq\":[\"Mesajlarınız sunucular arasında iletilirken ele geçirilebilir\"],\"xCJdfg\":[\"Temizle\"],\"xUHRTR\":[\"Bağlanırken otomatik olarak operatör kimliği doğrula\"],\"xWHwwQ\":[\"Yasaklar\"],\"xYilR2\":[\"Medya\"],\"xceQrO\":[\"Yalnızca güvenli websocket'ler desteklenmektedir\"],\"xdtXa+\":[\"kanal-adı\"],\"xfXC7q\":[\"Metin Kanalları\"],\"xlCYOE\":[\"Daha fazla mesaj alınıyor...\"],\"xlhswE\":[\"Minimum değer \",[\"0\"]],\"xq97Ci\":[\"Kelime veya ifade ekle...\"],\"xuRqRq\":[\"İstemci Sınırı (+l)\"],\"xwF+7J\":[[\"0\"],\" yazıyor...\"],\"yJztBY\":[\"Ağı sil\"],\"yNeucF\":[\"Bu sunucu genişletilmiş profil meta verilerini (IRCv3 METADATA uzantısı) desteklemiyor. Avatar, görünen ad ve durum gibi ek alanlar mevcut değil.\"],\"yPlrca\":[\"Kanal Avatarı\"],\"yQE2r9\":[\"Yükleniyor\"],\"ySU+JY\":[\"eposta@adresiniz.com\"],\"yTX1Rt\":[\"Oper Kullanıcı Adı\"],\"yYOzWD\":[\"günlükler\"],\"yfx9Re\":[\"IRC operatör şifresi\"],\"ygCKqB\":[\"Durdur\"],\"ymDxJx\":[\"IRC operatör kullanıcı adı\"],\"yrpRsQ\":[\"Ada Göre Sırala\"],\"yz7wBu\":[\"Kapat\"],\"zJw+jA\":[\"modu ayarlar: \",[\"0\"]],\"zebeLu\":[\"Oper kullanıcı adını girin\"],\"zpr0Bw\":[\"GZ-Line\"]}"); \ No newline at end of file diff --git a/src/locales/tr/messages.po b/src/locales/tr/messages.po index 6a44c482..69c17e86 100644 --- a/src/locales/tr/messages.po +++ b/src/locales/tr/messages.po @@ -22,6 +22,16 @@ msgstr "ObsidianIRC - IRC'yi geleceğe taşıyor" msgid "— open in viewer" msgstr "— görüntüleyicide aç" +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx +msgid "(inherit)" +msgstr "(devral)" + +#: src/components/ui/BouncerNetworkForm.tsx +msgid "(unchanged)" +msgstr "(değiştirilmedi)" + #. placeholder {0}: filteredMessages.length #. placeholder {1}: import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import type * as React from "react"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { SCROLL_TOLERANCE, useScrollToBottom, } from "../../hooks/useScrollToBottom"; import { groupConsecutiveEvents } from "../../lib/eventGrouping"; import ircClient from "../../lib/ircClient"; import useStore from "../../store"; import type { Message as MessageType } from "../../types"; import { CollapsedEventMessage } from "../message/CollapsedEventMessage"; import { MessageItem } from "../message/MessageItem"; import LoadingSpinner from "../ui/LoadingSpinner"; import { ScrollToBottomButton } from "../ui/ScrollToBottomButton"; export const DEFAULT_VISIBLE_MESSAGE_COUNT = 100; // Stable empty array — prevents selector from returning a new [] on every render // when the channel has no messages yet (undefined ?? [] would create a new ref each time). const EMPTY_MESSAGES: import("../../types").Message[] = []; export interface ChannelMessageListHandle { setAtBottom: () => void; scrollToBottom: () => void; getScrollState: () => { scrollTop: number; isAtBottom: boolean; visibleCount: number; }; } interface ChannelMessageListProps { channelKey: string; serverId: string; channelId: string | null; privateChatId: string | null; isActive: boolean; searchQuery: string; isMemberListVisible: boolean; onReply: (msg: MessageType | null) => void; onUsernameContextMenu: ( e: React.MouseEvent, username: string, serverId: string, channelId: string, avatarEl?: Element | null, ) => void; onIrcLinkClick: (url: string) => void; onReactClick: (msg: MessageType, el: Element) => void; onReactionUnreact: (emoji: string, msg: MessageType) => void; onOpenReactionModal: ( msg: MessageType, position: { x: number; y: number }, ) => void; onDirectReaction: (emoji: string, msg: MessageType) => void; onRedactMessage: (msg: MessageType) => void; onOpenProfile: (username: string) => void; joinChannel: (serverId: string, channelName: string) => void; onClearSearch: () => void; highlightedMessageId?: string; // undefined = first visit; null = was at bottom; object = restore to saved position initialScrollState?: { scrollTop: number; visibleCount: number } | null; } export const ChannelMessageList = forwardRef< ChannelMessageListHandle, ChannelMessageListProps >( ( { channelKey, serverId, channelId, privateChatId, isActive, searchQuery, isMemberListVisible, onReply, onUsernameContextMenu, onIrcLinkClick, onReactClick, onReactionUnreact, onOpenReactionModal, onDirectReaction, onRedactMessage, onOpenProfile, joinChannel, onClearSearch, highlightedMessageId, initialScrollState, }, ref, ) => { const { t } = useLingui(); const [visibleMessageCount, setVisibleMessageCount] = useState( initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT, ); // Ref mirror so getScrollState closure always reads the current value without needing it as a dep. const visibleMessageCountRef = useRef(visibleMessageCount); visibleMessageCountRef.current = visibleMessageCount; // Distinguishes initial join (full-screen spinner) from subsequent "load more" (button spinner). const [isFetchingMore, setIsFetchingMore] = useState(false); const isFetchingMoreRef = useRef(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const messagesInnerRef = useRef(null); // prev scrollHeight for prepend delta-correction. const prevScrollHeightRef = useRef(0); // Ref mirror of isScrolledUp — lets useLayoutEffect closures read current value // without listing isScrolledUp as a dep (which would re-run effects on every scroll). const isScrolledUpRef = useRef(false); const prevFilteredLengthRef = useRef(0); const prevFirstMsgIdRef = useRef(null); // Set by the window-growth layoutEffect (or button handler) when a true prepend is detected. // Consumed by the delta-correction layoutEffect one render later (after visibleCount grows). // Using a flag instead of tracking displayedMessages[0]?.id because slice(-N) slides the // window on every bottom append, changing displayedMessages[0] even for non-prepend renders. const pendingPrependRef = useRef(false); // Shared scrollHeight baseline between the delta-correction layout effect and the inner // ResizeObserver. When scrollTop is corrected after a prepend, we update this so the RO's // "was at bottom" check is not fooled by the adjusted scrollTop vs its stale prevSH. const resizeObserverPrevSHRef = useRef(0); const channelMessages = useStore( useCallback( (state) => state.messages[channelKey] ?? EMPTY_MESSAGES, [channelKey], ), ); const servers = useStore((state) => state.servers); const mobileViewActiveColumn = useStore( (state) => state.ui.mobileViewActiveColumn, ); const channel = useMemo( () => channelId ? (servers .find((s) => s.id === serverId) ?.channels.find((c) => c.id === channelId) ?? null) : null, [servers, serverId, channelId], ); const { isScrolledUp, wasAtBottomRef, scrollToBottom } = useScrollToBottom( messagesContainerRef, messagesEndRef, { channelId: `${channelId || privateChatId}-${isMemberListVisible}` }, ); // Snapshot of the last known scroll position captured while the container was visible. // getScrollState() reads this instead of the live DOM because React commits display:none // before running cleanup effects, collapsing scrollTop/scrollHeight/clientHeight to 0. const lastScrollTopRef = useRef(initialScrollState?.scrollTop ?? 0); useEffect(() => { const container = messagesContainerRef.current; if (!container) return; const onScroll = () => { if (container.clientHeight > 0) lastScrollTopRef.current = container.scrollTop; }; container.addEventListener("scroll", onScroll, { passive: true }); return () => container.removeEventListener("scroll", onScroll); }, []); // Restore scroll position when a keep-alive channel transitions from hidden to visible. // display:none may reset scrollTop to 0; lastScrollTopRef was captured while visible. const prevActiveRef = useRef(isActive); useLayoutEffect(() => { if (isActive && !prevActiveRef.current) { const container = messagesContainerRef.current; if (container && lastScrollTopRef.current > 0) { container.scrollTop = lastScrollTopRef.current; } } prevActiveRef.current = isActive; }, [isActive]); useImperativeHandle(ref, () => ({ setAtBottom: () => { wasAtBottomRef.current = true; }, scrollToBottom, getScrollState: () => ({ scrollTop: lastScrollTopRef.current, isAtBottom: wasAtBottomRef.current, visibleCount: visibleMessageCountRef.current, }), })); const filteredMessages = useMemo(() => { if (!searchQuery.trim()) return channelMessages; const query = searchQuery.toLowerCase(); return channelMessages.filter( (msg) => msg.content.toLowerCase().includes(query) || msg.userId.toLowerCase().includes(query), ); }, [channelMessages, searchQuery]); useEffect(() => { isScrolledUpRef.current = isScrolledUp; // When the user returns to the bottom, shrink the window back to the base so // slice(-N) resumes trimming old messages from the top (memory optimization). // Only shrink if we grew above the base — preserves a sub-default saved visibleCount. if (!isScrolledUp) { setVisibleMessageCount((prev) => prev > DEFAULT_VISIBLE_MESSAGE_COUNT ? DEFAULT_VISIBLE_MESSAGE_COUNT : prev, ); } }, [isScrolledUp]); // Reset ref-tracked windowing state when switching channels. // visibleMessageCount is NOT reset here — useState(initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT) // already initializes it correctly on mount, and this effect runs once on mount for the // same channelKey (each instance is bound to exactly one channel by the parent key={}). // biome-ignore lint/correctness/useExhaustiveDependencies: intentional full reset on channel change useEffect(() => { prevFilteredLengthRef.current = 0; prevFirstMsgIdRef.current = null; prevScrollHeightRef.current = 0; pendingPrependRef.current = false; resizeObserverPrevSHRef.current = 0; }, [channelKey]); const displayedMessages = useMemo(() => { if (searchQuery.trim()) return filteredMessages; return filteredMessages.slice(-visibleMessageCount); }, [filteredMessages, visibleMessageCount, searchQuery]); const locallyHidden = filteredMessages.length > displayedMessages.length; const serverHasMore = channel?.hasMoreHistory === true; const hasMoreMessages = locallyHidden || serverHasMore; const eventGroups = useMemo( () => groupConsecutiveEvents(displayedMessages), [displayedMessages], ); const isLoadingHistory = channel?.isLoadingHistory ?? false; // Scroll to bottom on initial mount, unless a saved position was passed in. // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount only useEffect(() => { const container = messagesContainerRef.current; if (!container) return; if (initialScrollState) { container.scrollTop = initialScrollState.scrollTop; lastScrollTopRef.current = initialScrollState.scrollTop; wasAtBottomRef.current = false; } else { container.scrollTop = container.scrollHeight; lastScrollTopRef.current = container.scrollHeight; wasAtBottomRef.current = true; } }, []); // Scroll to bottom after initial join history loads; clear fetch spinner at batch end. const wasLoadingHistoryRef = useRef(false); // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is stable via useCallback; refs and setters are stable useLayoutEffect(() => { if (wasLoadingHistoryRef.current && !isLoadingHistory) { if (isFetchingMoreRef.current) { // delta correction for scroll position is handled by useLayoutEffect([displayedMessages]) isFetchingMoreRef.current = false; setIsFetchingMore(false); } else { scrollToBottom(); wasAtBottomRef.current = true; } } wasLoadingHistoryRef.current = isLoadingHistory; }, [isLoadingHistory]); // When older messages are prepended, grow the window so they enter displayedMessages. // When new messages arrive at the bottom while the user is scrolled up, also grow the // window to keep the current top messages visible — slice(-N) otherwise slides the // window forward and hides them, incrementing the "N older messages" counter on every // incoming message. Only let the slice trim from the top when the user is at the bottom // (where auto-scroll handles keeping them current). useLayoutEffect(() => { const newLength = filteredMessages.length; const newFirstId = filteredMessages[0]?.id ?? null; const delta = newLength - prevFilteredLengthRef.current; if (prevFilteredLengthRef.current > 0 && delta > 0) { if (newFirstId !== prevFirstMsgIdRef.current) { // Messages prepended (load-more): signal delta-correction to compensate scrollTop. pendingPrependRef.current = true; setVisibleMessageCount((prev) => prev + delta); } else if (isScrolledUpRef.current) { // Messages appended at bottom while user is scrolled up reading history. // Expand the window to prevent top messages from dropping out of the slice. setVisibleMessageCount((prev) => prev + delta); } } prevFilteredLengthRef.current = newLength; prevFirstMsgIdRef.current = newFirstId; }, [filteredMessages]); // Compensate scrollTop when content is prepended above the viewport. // biome-ignore lint/correctness/useExhaustiveDependencies: runs on every displayedMessages render to capture the resulting scrollHeight; refs are stable useLayoutEffect(() => { const container = messagesContainerRef.current; if (!container) return; // Skip while container is display:none — scrollHeight collapses to 0 and would // poison prevScrollHeightRef, causing a huge spurious delta on the next visible render. if (container.clientHeight === 0) return; const prevHeight = prevScrollHeightRef.current; const newHeight = container.scrollHeight; // Only correct when a true load-more prepend happened (flag set by the window-growth // layoutEffect or button handler). Bottom appends slide the slice(-N) window which also // changes displayedMessages[0] — ID-comparison can't distinguish the two cases. const wasPrepend = pendingPrependRef.current; // Only consume the flag when scrollHeight actually changed — the server-side load-more // path goes through two renders: Render A (filteredMessages grows, visibleCount unchanged, // same displayedMessages content, same scrollHeight) then Render B (visibleCount grows, // new messages enter displayedMessages, scrollHeight grows). The flag must survive Render A // so it's still set when Render B fires the actual correction. if (wasPrepend && newHeight !== prevHeight) { pendingPrependRef.current = false; } if ( isScrolledUpRef.current && prevHeight > 0 && newHeight > prevHeight && wasPrepend ) { const delta = newHeight - prevHeight; container.scrollTop += delta; resizeObserverPrevSHRef.current = newHeight; } prevScrollHeightRef.current = newHeight; }, [displayedMessages]); // Re-stick to bottom when inner message content grows (media/audio previews loading). // Uses prevScrollHeight instead of wasAtBottomRef to avoid stale-flag race where the // ref is true while the user is actively scrolling up. // When the container width changes (member list toggle, window resize), text reflows // and scrollHeight changes; preserve proportional scroll position for scrolled-up users. // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is a stable ref useEffect(() => { const container = messagesContainerRef.current; const inner = messagesInnerRef.current; if (!inner || !container) return; resizeObserverPrevSHRef.current = container.scrollHeight; let prevClientWidth = container.clientWidth; const observer = new ResizeObserver(() => { if (container.clientHeight === 0) return; // Effect may re-initialize while container is display:none (ref=0). // Re-seed with current dimensions and skip — no reliable "was at bottom" data. if (resizeObserverPrevSHRef.current === 0) { resizeObserverPrevSHRef.current = container.scrollHeight; prevClientWidth = container.clientWidth; return; } const currentClientWidth = container.clientWidth; const widthChanged = currentClientWidth !== prevClientWidth; prevClientWidth = currentClientWidth; const prevSH = resizeObserverPrevSHRef.current; const wasAtPrevBottom = container.scrollTop + container.clientHeight >= prevSH - SCROLL_TOLERANCE; resizeObserverPrevSHRef.current = container.scrollHeight; if (wasAtPrevBottom) { scrollToBottom(); } else if (widthChanged && prevSH > 0) { const ratio = container.scrollTop / prevSH; container.scrollTop = Math.round(ratio * container.scrollHeight); } }); observer.observe(inner); return () => observer.disconnect(); }, [isLoadingHistory, channelId, privateChatId]); // Auto-scroll on new messages — skip when this channel is hidden (display:none). // biome-ignore lint/correctness/useExhaustiveDependencies: only scroll when messages change, not when isActive changes useEffect(() => { if (!isActive) return; const isNarrowView = window.matchMedia("(max-width: 768px)").matches; const isChatVisible = !isNarrowView || mobileViewActiveColumn === "chatView"; if (wasAtBottomRef.current && isChatVisible) { scrollToBottom(); } }, [displayedMessages, mobileViewActiveColumn, scrollToBottom, isActive]); return ( <>
{isLoadingHistory && !isFetchingMore ? (
) : (
{hasMoreMessages && !searchQuery && (
)} {searchQuery && (
{plural(filteredMessages.length, { one: t`Found 1 message matching "${searchQuery}"`, other: t`Found ${filteredMessages.length} messages matching "${searchQuery}"`, })}
)} {eventGroups.map((group) => { if (group.type === "eventGroup") { const firstId = group.messages[0]?.id || ""; const lastId = group.messages[group.messages.length - 1]?.id || ""; const groupKey = `group-${firstId}-${lastId}`; return ( ); } const message = group.messages[0]; const originalIndex = channelMessages.findIndex( (m) => m.id === message.id, ); const previousMessage = channelMessages[originalIndex - 1]; const showHeader = !previousMessage || previousMessage.type !== "message" || previousMessage.userId !== message.userId || new Date(message.timestamp).getTime() - new Date(previousMessage.timestamp).getTime() > 5 * 60 * 1000; return ( ); })}
)}
); }, ); ChannelMessageList.displayName = "ChannelMessageList"; // Wrap with memo so hidden keep-alive channels skip re-renders when their props // haven't changed (e.g. when messageText changes in the input — the only thing // that changes on typing is local state inside ChatArea, not the props we pass here). export const MemoChannelMessageList = memo(ChannelMessageList); #. placeholder {2}: import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import type * as React from "react"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { SCROLL_TOLERANCE, useScrollToBottom, } from "../../hooks/useScrollToBottom"; import { groupConsecutiveEvents } from "../../lib/eventGrouping"; import ircClient from "../../lib/ircClient"; import useStore from "../../store"; import type { Message as MessageType } from "../../types"; import { CollapsedEventMessage } from "../message/CollapsedEventMessage"; import { MessageItem } from "../message/MessageItem"; import LoadingSpinner from "../ui/LoadingSpinner"; import { ScrollToBottomButton } from "../ui/ScrollToBottomButton"; export const DEFAULT_VISIBLE_MESSAGE_COUNT = 100; // Stable empty array — prevents selector from returning a new [] on every render // when the channel has no messages yet (undefined ?? [] would create a new ref each time). const EMPTY_MESSAGES: import("../../types").Message[] = []; export interface ChannelMessageListHandle { setAtBottom: () => void; scrollToBottom: () => void; getScrollState: () => { scrollTop: number; isAtBottom: boolean; visibleCount: number; }; } interface ChannelMessageListProps { channelKey: string; serverId: string; channelId: string | null; privateChatId: string | null; isActive: boolean; searchQuery: string; isMemberListVisible: boolean; onReply: (msg: MessageType | null) => void; onUsernameContextMenu: ( e: React.MouseEvent, username: string, serverId: string, channelId: string, avatarEl?: Element | null, ) => void; onIrcLinkClick: (url: string) => void; onReactClick: (msg: MessageType, el: Element) => void; onReactionUnreact: (emoji: string, msg: MessageType) => void; onOpenReactionModal: ( msg: MessageType, position: { x: number; y: number }, ) => void; onDirectReaction: (emoji: string, msg: MessageType) => void; onRedactMessage: (msg: MessageType) => void; onOpenProfile: (username: string) => void; joinChannel: (serverId: string, channelName: string) => void; onClearSearch: () => void; highlightedMessageId?: string; // undefined = first visit; null = was at bottom; object = restore to saved position initialScrollState?: { scrollTop: number; visibleCount: number } | null; } export const ChannelMessageList = forwardRef< ChannelMessageListHandle, ChannelMessageListProps >( ( { channelKey, serverId, channelId, privateChatId, isActive, searchQuery, isMemberListVisible, onReply, onUsernameContextMenu, onIrcLinkClick, onReactClick, onReactionUnreact, onOpenReactionModal, onDirectReaction, onRedactMessage, onOpenProfile, joinChannel, onClearSearch, highlightedMessageId, initialScrollState, }, ref, ) => { const { t } = useLingui(); const [visibleMessageCount, setVisibleMessageCount] = useState( initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT, ); // Ref mirror so getScrollState closure always reads the current value without needing it as a dep. const visibleMessageCountRef = useRef(visibleMessageCount); visibleMessageCountRef.current = visibleMessageCount; // Distinguishes initial join (full-screen spinner) from subsequent "load more" (button spinner). const [isFetchingMore, setIsFetchingMore] = useState(false); const isFetchingMoreRef = useRef(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const messagesInnerRef = useRef(null); // prev scrollHeight for prepend delta-correction. const prevScrollHeightRef = useRef(0); // Ref mirror of isScrolledUp — lets useLayoutEffect closures read current value // without listing isScrolledUp as a dep (which would re-run effects on every scroll). const isScrolledUpRef = useRef(false); const prevFilteredLengthRef = useRef(0); const prevFirstMsgIdRef = useRef(null); // Set by the window-growth layoutEffect (or button handler) when a true prepend is detected. // Consumed by the delta-correction layoutEffect one render later (after visibleCount grows). // Using a flag instead of tracking displayedMessages[0]?.id because slice(-N) slides the // window on every bottom append, changing displayedMessages[0] even for non-prepend renders. const pendingPrependRef = useRef(false); // Shared scrollHeight baseline between the delta-correction layout effect and the inner // ResizeObserver. When scrollTop is corrected after a prepend, we update this so the RO's // "was at bottom" check is not fooled by the adjusted scrollTop vs its stale prevSH. const resizeObserverPrevSHRef = useRef(0); const channelMessages = useStore( useCallback( (state) => state.messages[channelKey] ?? EMPTY_MESSAGES, [channelKey], ), ); const servers = useStore((state) => state.servers); const mobileViewActiveColumn = useStore( (state) => state.ui.mobileViewActiveColumn, ); const channel = useMemo( () => channelId ? (servers .find((s) => s.id === serverId) ?.channels.find((c) => c.id === channelId) ?? null) : null, [servers, serverId, channelId], ); const { isScrolledUp, wasAtBottomRef, scrollToBottom } = useScrollToBottom( messagesContainerRef, messagesEndRef, { channelId: `${channelId || privateChatId}-${isMemberListVisible}` }, ); // Snapshot of the last known scroll position captured while the container was visible. // getScrollState() reads this instead of the live DOM because React commits display:none // before running cleanup effects, collapsing scrollTop/scrollHeight/clientHeight to 0. const lastScrollTopRef = useRef(initialScrollState?.scrollTop ?? 0); useEffect(() => { const container = messagesContainerRef.current; if (!container) return; const onScroll = () => { if (container.clientHeight > 0) lastScrollTopRef.current = container.scrollTop; }; container.addEventListener("scroll", onScroll, { passive: true }); return () => container.removeEventListener("scroll", onScroll); }, []); // Restore scroll position when a keep-alive channel transitions from hidden to visible. // display:none may reset scrollTop to 0; lastScrollTopRef was captured while visible. const prevActiveRef = useRef(isActive); useLayoutEffect(() => { if (isActive && !prevActiveRef.current) { const container = messagesContainerRef.current; if (container && lastScrollTopRef.current > 0) { container.scrollTop = lastScrollTopRef.current; } } prevActiveRef.current = isActive; }, [isActive]); useImperativeHandle(ref, () => ({ setAtBottom: () => { wasAtBottomRef.current = true; }, scrollToBottom, getScrollState: () => ({ scrollTop: lastScrollTopRef.current, isAtBottom: wasAtBottomRef.current, visibleCount: visibleMessageCountRef.current, }), })); const filteredMessages = useMemo(() => { if (!searchQuery.trim()) return channelMessages; const query = searchQuery.toLowerCase(); return channelMessages.filter( (msg) => msg.content.toLowerCase().includes(query) || msg.userId.toLowerCase().includes(query), ); }, [channelMessages, searchQuery]); useEffect(() => { isScrolledUpRef.current = isScrolledUp; // When the user returns to the bottom, shrink the window back to the base so // slice(-N) resumes trimming old messages from the top (memory optimization). // Only shrink if we grew above the base — preserves a sub-default saved visibleCount. if (!isScrolledUp) { setVisibleMessageCount((prev) => prev > DEFAULT_VISIBLE_MESSAGE_COUNT ? DEFAULT_VISIBLE_MESSAGE_COUNT : prev, ); } }, [isScrolledUp]); // Reset ref-tracked windowing state when switching channels. // visibleMessageCount is NOT reset here — useState(initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT) // already initializes it correctly on mount, and this effect runs once on mount for the // same channelKey (each instance is bound to exactly one channel by the parent key={}). // biome-ignore lint/correctness/useExhaustiveDependencies: intentional full reset on channel change useEffect(() => { prevFilteredLengthRef.current = 0; prevFirstMsgIdRef.current = null; prevScrollHeightRef.current = 0; pendingPrependRef.current = false; resizeObserverPrevSHRef.current = 0; }, [channelKey]); const displayedMessages = useMemo(() => { if (searchQuery.trim()) return filteredMessages; return filteredMessages.slice(-visibleMessageCount); }, [filteredMessages, visibleMessageCount, searchQuery]); const locallyHidden = filteredMessages.length > displayedMessages.length; const serverHasMore = channel?.hasMoreHistory === true; const hasMoreMessages = locallyHidden || serverHasMore; const eventGroups = useMemo( () => groupConsecutiveEvents(displayedMessages), [displayedMessages], ); const isLoadingHistory = channel?.isLoadingHistory ?? false; // Scroll to bottom on initial mount, unless a saved position was passed in. // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount only useEffect(() => { const container = messagesContainerRef.current; if (!container) return; if (initialScrollState) { container.scrollTop = initialScrollState.scrollTop; lastScrollTopRef.current = initialScrollState.scrollTop; wasAtBottomRef.current = false; } else { container.scrollTop = container.scrollHeight; lastScrollTopRef.current = container.scrollHeight; wasAtBottomRef.current = true; } }, []); // Scroll to bottom after initial join history loads; clear fetch spinner at batch end. const wasLoadingHistoryRef = useRef(false); // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is stable via useCallback; refs and setters are stable useLayoutEffect(() => { if (wasLoadingHistoryRef.current && !isLoadingHistory) { if (isFetchingMoreRef.current) { // delta correction for scroll position is handled by useLayoutEffect([displayedMessages]) isFetchingMoreRef.current = false; setIsFetchingMore(false); } else { scrollToBottom(); wasAtBottomRef.current = true; } } wasLoadingHistoryRef.current = isLoadingHistory; }, [isLoadingHistory]); // When older messages are prepended, grow the window so they enter displayedMessages. // When new messages arrive at the bottom while the user is scrolled up, also grow the // window to keep the current top messages visible — slice(-N) otherwise slides the // window forward and hides them, incrementing the "N older messages" counter on every // incoming message. Only let the slice trim from the top when the user is at the bottom // (where auto-scroll handles keeping them current). useLayoutEffect(() => { const newLength = filteredMessages.length; const newFirstId = filteredMessages[0]?.id ?? null; const delta = newLength - prevFilteredLengthRef.current; if (prevFilteredLengthRef.current > 0 && delta > 0) { if (newFirstId !== prevFirstMsgIdRef.current) { // Messages prepended (load-more): signal delta-correction to compensate scrollTop. pendingPrependRef.current = true; setVisibleMessageCount((prev) => prev + delta); } else if (isScrolledUpRef.current) { // Messages appended at bottom while user is scrolled up reading history. // Expand the window to prevent top messages from dropping out of the slice. setVisibleMessageCount((prev) => prev + delta); } } prevFilteredLengthRef.current = newLength; prevFirstMsgIdRef.current = newFirstId; }, [filteredMessages]); // Compensate scrollTop when content is prepended above the viewport. // biome-ignore lint/correctness/useExhaustiveDependencies: runs on every displayedMessages render to capture the resulting scrollHeight; refs are stable useLayoutEffect(() => { const container = messagesContainerRef.current; if (!container) return; // Skip while container is display:none — scrollHeight collapses to 0 and would // poison prevScrollHeightRef, causing a huge spurious delta on the next visible render. if (container.clientHeight === 0) return; const prevHeight = prevScrollHeightRef.current; const newHeight = container.scrollHeight; // Only correct when a true load-more prepend happened (flag set by the window-growth // layoutEffect or button handler). Bottom appends slide the slice(-N) window which also // changes displayedMessages[0] — ID-comparison can't distinguish the two cases. const wasPrepend = pendingPrependRef.current; // Only consume the flag when scrollHeight actually changed — the server-side load-more // path goes through two renders: Render A (filteredMessages grows, visibleCount unchanged, // same displayedMessages content, same scrollHeight) then Render B (visibleCount grows, // new messages enter displayedMessages, scrollHeight grows). The flag must survive Render A // so it's still set when Render B fires the actual correction. if (wasPrepend && newHeight !== prevHeight) { pendingPrependRef.current = false; } if ( isScrolledUpRef.current && prevHeight > 0 && newHeight > prevHeight && wasPrepend ) { const delta = newHeight - prevHeight; container.scrollTop += delta; resizeObserverPrevSHRef.current = newHeight; } prevScrollHeightRef.current = newHeight; }, [displayedMessages]); // Re-stick to bottom when inner message content grows (media/audio previews loading). // Uses prevScrollHeight instead of wasAtBottomRef to avoid stale-flag race where the // ref is true while the user is actively scrolling up. // When the container width changes (member list toggle, window resize), text reflows // and scrollHeight changes; preserve proportional scroll position for scrolled-up users. // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is a stable ref useEffect(() => { const container = messagesContainerRef.current; const inner = messagesInnerRef.current; if (!inner || !container) return; resizeObserverPrevSHRef.current = container.scrollHeight; let prevClientWidth = container.clientWidth; const observer = new ResizeObserver(() => { if (container.clientHeight === 0) return; // Effect may re-initialize while container is display:none (ref=0). // Re-seed with current dimensions and skip — no reliable "was at bottom" data. if (resizeObserverPrevSHRef.current === 0) { resizeObserverPrevSHRef.current = container.scrollHeight; prevClientWidth = container.clientWidth; return; } const currentClientWidth = container.clientWidth; const widthChanged = currentClientWidth !== prevClientWidth; prevClientWidth = currentClientWidth; const prevSH = resizeObserverPrevSHRef.current; const wasAtPrevBottom = container.scrollTop + container.clientHeight >= prevSH - SCROLL_TOLERANCE; resizeObserverPrevSHRef.current = container.scrollHeight; if (wasAtPrevBottom) { scrollToBottom(); } else if (widthChanged && prevSH > 0) { const ratio = container.scrollTop / prevSH; container.scrollTop = Math.round(ratio * container.scrollHeight); } }); observer.observe(inner); return () => observer.disconnect(); }, [isLoadingHistory, channelId, privateChatId]); // Auto-scroll on new messages — skip when this channel is hidden (display:none). // biome-ignore lint/correctness/useExhaustiveDependencies: only scroll when messages change, not when isActive changes useEffect(() => { if (!isActive) return; const isNarrowView = window.matchMedia("(max-width: 768px)").matches; const isChatVisible = !isNarrowView || mobileViewActiveColumn === "chatView"; if (wasAtBottomRef.current && isChatVisible) { scrollToBottom(); } }, [displayedMessages, mobileViewActiveColumn, scrollToBottom, isActive]); return ( <>
{isLoadingHistory && !isFetchingMore ? (
) : (
{hasMoreMessages && !searchQuery && (
)} {searchQuery && (
{plural(filteredMessages.length, { one: t`Found 1 message matching "${searchQuery}"`, other: t`Found ${filteredMessages.length} messages matching "${searchQuery}"`, })}
)} {eventGroups.map((group) => { if (group.type === "eventGroup") { const firstId = group.messages[0]?.id || ""; const lastId = group.messages[group.messages.length - 1]?.id || ""; const groupKey = `group-${firstId}-${lastId}`; return ( ); } const message = group.messages[0]; const originalIndex = channelMessages.findIndex( (m) => m.id === message.id, ); const previousMessage = channelMessages[originalIndex - 1]; const showHeader = !previousMessage || previousMessage.type !== "message" || previousMessage.userId !== message.userId || new Date(message.timestamp).getTime() - new Date(previousMessage.timestamp).getTime() > 5 * 60 * 1000; return ( ); })}
)}
); }, ); ChannelMessageList.displayName = "ChannelMessageList"; // Wrap with memo so hidden keep-alive channels skip re-renders when their props // haven't changed (e.g. when messageText changes in the input — the only thing // that changes on typing is local state inside ChatArea, not the props we pass here). export const MemoChannelMessageList = memo(ChannelMessageList); @@ -46,6 +56,12 @@ msgstr "{0} ve {1} yazıyor..." msgid "{0} is typing..." msgstr "{0} yazıyor..." +#. placeholder {0}: networks.length +#. placeholder {1}: networks.length === 1 ? "" : "s" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "{0} network{1} — pick one to join" +msgstr "" + #. placeholder {0}: filteredMessages.length - displayedMessages.length #: src/components/layout/ChannelMessageList.tsx msgid "{0} older messages" @@ -186,6 +202,12 @@ msgstr "Davet maskesi ekle (örn. nick!*@*, *!*@host.com)" msgid "Add IRC Server" msgstr "IRC Sunucusu Ekle" +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworksPanel.tsx +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Add Network" +msgstr "Ağ Ekle" + #: src/components/message/MessageActions.tsx #: src/components/message/MessageReactions.tsx #: src/components/message/MessageReactions.tsx @@ -205,6 +227,10 @@ msgstr "Kural Ekle" msgid "Add Server" msgstr "Sunucu Ekle" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Add your first network" +msgstr "İlk ağınızı ekleyin" + #: src/components/message/JsonLogMessage.tsx msgid "Additional Details" msgstr "Ek ayrıntılar" @@ -358,6 +384,10 @@ msgstr "Geri" msgid "Back to image" msgstr "Görüntüye geri dön" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Back to network list" +msgstr "" + #: src/components/ui/ModerationModal.tsx msgid "Ban {username} by hostmask (prevents them from rejoining from the same IP/host)" msgstr "{username} kullanıcısını host maskesiyle yasakla (aynı IP/host'tan yeniden katılmasını engeller)" @@ -405,6 +435,8 @@ msgstr "Sunucudaki tüm kanalları görüntüle" #: src/components/ui/AddPrivateChatModal.tsx #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/ChannelSettingsModal.tsx #: src/components/ui/ExternalLinkWarningModal.tsx #: src/components/ui/FloodSettingsModal.tsx @@ -640,6 +672,7 @@ msgid "Configure notification sounds and highlights" msgstr "Bildirim seslerini ve vurguları yapılandır" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworksPanel.tsx msgid "Connect" msgstr "Bağlan" @@ -759,6 +792,10 @@ msgstr "Kanalı Sil" msgid "Delete message" msgstr "Mesajı sil" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Delete network" +msgstr "Ağı sil" + #: src/components/layout/ChannelList.tsx msgid "Delete Private Chat" msgstr "Özel Sohbeti Sil" @@ -767,6 +804,10 @@ msgstr "Özel Sohbeti Sil" msgid "Delete this message? This cannot be undone." msgstr "Bu mesaj silinsin mi? Bu işlem geri alınamaz." +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Delete this network?" +msgstr "Bu ağ silinsin mi?" + #: src/components/layout/ServerList.tsx #: src/components/mobile/ServerBottomSheet.tsx msgid "Disconnect" @@ -830,10 +871,16 @@ msgstr "İndir" msgid "e.g., 100:1440" msgstr "örn. 100:1440" +#: src/components/ui/BouncerNetworksPanel.tsx #: src/components/ui/ChannelSettingsModal.tsx msgid "Edit" msgstr "Düzenle" +#. placeholder {0}: editingNetwork?.attributes.name || editingNetwork?.netid +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Edit {0}" +msgstr "{0} ögesini düzenle" + #: src/components/ui/UserProfileModal.tsx msgid "Edit Profile" msgstr "Profili Düzenle" @@ -1057,6 +1104,7 @@ msgstr "ANA SAYFA" msgid "Homepage" msgstr "Ana Sayfa" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserProfileModal.tsx msgid "Host" msgstr "Host" @@ -1271,6 +1319,10 @@ msgstr "Kanaldan ayrıldı" msgid "Let others know when you are typing" msgstr "Yazarken diğerlerini bilgilendir" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Libera Chat" +msgstr "Libera Chat" + #: src/components/message/LinkPreview.tsx msgid "Link preview" msgstr "Bağlantı önizlemesi" @@ -1299,6 +1351,10 @@ msgstr "GIF'ler yükleniyor..." msgid "Loading more channels..." msgstr "Daha fazla kanal yükleniyor..." +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Loading networks from your bouncer…" +msgstr "Bouncer'ınızdan ağlar yükleniyor…" + #: src/components/ui/UserProfileModal.tsx msgid "Loading WHOIS data..." msgstr "WHOIS verisi yükleniyor..." @@ -1486,9 +1542,15 @@ msgid "Name:" msgstr "Ad:" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx msgid "Network Name" msgstr "Ağ Adı" +#. placeholder {0}: server?.name ?? bouncerServerId +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Networks on {0}" +msgstr "{0} üzerindeki ağlar" + #: src/components/ui/QuickActions.tsx msgid "New DM" msgstr "Yeni DM" @@ -1511,6 +1573,7 @@ msgid "nick!user@host (e.g., spam*!*@*, *!*@badhost.com)" msgstr "nick!user@host (örn. spam*!*@*, *!*@badhost.com)" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserSettings.tsx #: src/lib/settings/definitions/allSettings.ts msgid "Nickname" @@ -1570,6 +1633,10 @@ msgstr "Dosya seçilmedi" msgid "No flood profile" msgstr "Flood profili yok" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "no host set" +msgstr "ana makine ayarlanmadı" + #: src/components/ui/ChannelSettingsModal.tsx msgid "No invitations found" msgstr "Davet bulunamadı" @@ -1610,6 +1677,10 @@ msgstr "Konu belirlenmemiş" msgid "No unread mentions or messages" msgstr "Okunmamış bahis veya mesaj yok" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "No upstream networks yet." +msgstr "Henüz yukarı akış ağı yok." + #: src/components/ui/AddPrivateChatModal.tsx msgid "No users available" msgstr "Kullanılabilir kullanıcı yok" @@ -1696,6 +1767,10 @@ msgstr "Eyvah! Ağ bölündü! ⚠️" msgid "Op" msgstr "Op" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Open" +msgstr "" + #: src/components/ui/QuickActions/uiActionConfig.tsx msgid "Open channel configuration settings" msgstr "Kanal yapılandırma ayarlarını aç" @@ -1799,6 +1874,10 @@ msgstr "Özel Sohbeti Sabitle" msgid "Pin this private message conversation" msgstr "Bu özel mesaj konuşmasını sabitle" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Plaintext" +msgstr "Düz metin" + #: src/components/message/MediaPreview.tsx #: src/components/message/MediaPreview.tsx #: src/components/message/MediaPreview.tsx @@ -1827,6 +1906,7 @@ msgid "PM User" msgstr "Kullanıcıya PM Gönder" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx msgid "Port" msgstr "Port" @@ -1918,6 +1998,7 @@ msgstr "bu mesaja tepki verdi" msgid "Read more" msgstr "Devamını oku" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserProfileModal.tsx #: src/components/ui/UserSettings.tsx #: src/lib/settings/definitions/allSettings.ts @@ -2002,6 +2083,7 @@ msgstr "Kurallar" msgid "Safe" msgstr "Güvenli" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/ChannelSettingsModal.tsx #: src/components/ui/TopicModal.tsx #: src/components/ui/UserSettings.tsx @@ -2183,6 +2265,10 @@ msgstr "Ağdaki sunucu operatörleri mesajlarınızı okuyabilir" msgid "Server Password" msgstr "Sunucu Şifresi" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Server Password (PASS)" +msgstr "Sunucu Şifresi (PASS)" + #: src/components/ui/LinkSecurityWarningModal.tsx msgid "Server-to-server communication may use unencrypted connections" msgstr "Sunucular arası iletişim şifrelenmemiş bağlantılar kullanıyor olabilir" @@ -2378,6 +2464,10 @@ msgstr "Süre (dk)" msgid "Time Window (seconds)" msgstr "Zaman Penceresi (saniye)" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "TLS" +msgstr "TLS" + #: src/components/message/WhisperMessage.tsx #: src/components/message/WhisperMessage.tsx msgid "to" @@ -2426,6 +2516,10 @@ msgstr "Konu:" msgid "Total: {0}" msgstr "Toplam: {0}" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Transport" +msgstr "Taşıma" + #: src/components/ui/UserSettings.tsx msgid "Trusted Sources" msgstr "Güvenilir Kaynaklar" @@ -2536,6 +2630,7 @@ msgstr "Kullanıcı Profili" msgid "User Settings" msgstr "Kullanıcı Ayarları" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/InviteUserModal.tsx #: src/components/ui/ModerationModal.tsx msgid "Username" @@ -2683,6 +2778,10 @@ msgstr "Geniş - Daha kapsamlı koruma alanı" msgid "Will default to 'no reason' if left empty" msgstr "Boş bırakılırsa varsayılan olarak 'neden yok' kullanılır" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Yes, delete" +msgstr "Evet, sil" + #: src/components/message/CollapsedEventMessage.tsx #: src/components/message/EventMessage.tsx msgid "You" @@ -2713,6 +2812,10 @@ msgstr "Kimlik doğrulama için hesap şifreniz" msgid "Your account username for authentication" msgstr "Kimlik doğrulama için hesap kullanıcı adınız" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Your bouncer doesn't have any networks yet. Add one to get started." +msgstr "Bouncer'ınızda henüz ağ yok. Başlamak için bir tane ekleyin." + #: src/lib/settings/definitions/allSettings.ts msgid "Your default nickname for all servers" msgstr "Tüm sunucular için varsayılan takma adınız" diff --git a/src/locales/uk/messages.mjs b/src/locales/uk/messages.mjs index 506174aa..7f50a840 100644 --- a/src/locales/uk/messages.mjs +++ b/src/locales/uk/messages.mjs @@ -1 +1 @@ -/*eslint-disable*/export const messages=JSON.parse("{\"+5VMrz\":[\"Недійсний формат шаблону. Використовуйте формат nick!user@host (дозволені символи * як шаблони)\"],\"+6NQQA\":[\"Загальний канал підтримки\"],\"+6NyRG\":[\"Клієнт\"],\"+K0AvT\":[\"Від'єднатися\"],\"+cyFdH\":[\"Стандартне повідомлення при позначенні себе відсутнім\"],\"+mVPqU\":[\"Відображати Markdown-форматування у повідомленнях\"],\"+vqCJH\":[\"Ім'я користувача вашого акаунту для автентифікації\"],\"+yPBXI\":[\"Вибрати файл\"],\"+zy2Nq\":[\"Тип\"],\"/09cao\":[\"Низький рівень безпеки з'єднання (Рівень \",[\"securityLevel\"],\")\"],\"/3BQ4J\":[\"Користувачі за межами каналу не можуть надсилати до нього повідомлення\"],\"/6BzZF\":[\"Перемкнути список учасників\"],\"/TNOPk\":[\"Користувач відсутній\"],\"/XQgft\":[\"Дослідити\"],\"/cF7Rs\":[\"Гучність\"],\"/dqduX\":[\"Наступна сторінка\"],\"/fc3q4\":[\"Весь вміст\"],\"/kISDh\":[\"Увімкнути звуки сповіщень\"],\"/n04sB\":[\"Kill\"],\"/rTz0M\":[\"Аудіо\"],\"/rfkZe\":[\"Відтворювати звуки для згадок і повідомлень\"],\"0/0ZGA\":[\"Маска назви каналу\"],\"0D6j7U\":[\"Дізнатися більше про власні правила →\"],\"0XsHcR\":[\"Вигнати користувача\"],\"0ZpE//\":[\"Сортувати за кількістю користувачів\"],\"0bEPwz\":[\"Встановити статус «Відсутній»\"],\"0dGkPt\":[\"Розгорнути список каналів\"],\"0gS7M5\":[\"Відображуване ім'я\"],\"0kS+M8\":[\"ПрикладМЕРЕЖА\"],\"0rgoY7\":[\"Підключатися лише до обраних вами серверів\"],\"0wdd7X\":[\"Приєднатися\"],\"0wkVYx\":[\"Приватні повідомлення\"],\"111uHX\":[\"Попередній перегляд посилання\"],\"196EG4\":[\"Видалити приватний чат\"],\"1DSr1i\":[\"Зареєструвати акаунт\"],\"1O/24y\":[\"Перемкнути список каналів\"],\"1VPJJ2\":[\"Попередження про зовнішнє посилання\"],\"1ZC/dv\":[\"Немає непрочитаних згадувань або повідомлень\"],\"1pO1zi\":[\"Назва сервера обов'язкова\"],\"1uwfzQ\":[\"Переглянути тему каналу\"],\"268g7c\":[\"Введіть відображуване ім'я\"],\"2FOFq1\":[\"Оператори сервера в мережі можуть потенційно читати ваші повідомлення\"],\"2FYpfJ\":[\"Більше\"],\"2HF1Y2\":[[\"inviter\"],\" запросив \",[\"target\"],\" приєднатися до \",[\"channel\"]],\"2I70QL\":[\"Переглянути інформацію профілю користувача\"],\"2QYdmE\":[\"Користувачі:\"],\"2QpEjG\":[\"вийшов\"],\"2YE223\":[\"Повідомлення #\",[\"0\"],\" (Enter для нового рядка, Shift+Enter для відправки)\"],\"2bimFY\":[\"Використовувати пароль сервера\"],\"2iTmdZ\":[\"Локальне сховище:\"],\"2odkwe\":[\"Строгий - більш агресивний захист\"],\"2uDhbA\":[\"Введіть ім'я користувача для запрошення\"],\"2ygf/L\":[\"← Назад\"],\"2zEgxj\":[\"Пошук GIF...\"],\"3RdPhl\":[\"Перейменувати канал\"],\"3THokf\":[\"Користувач з голосом\"],\"3TSz9S\":[\"Згорнути\"],\"3jBDvM\":[\"Відображувана назва каналу\"],\"3ryuFU\":[\"Необов'язкові звіти про збої для покращення додатку\"],\"3uBF/8\":[\"Закрити переглядач\"],\"3uwW8F\":[\"https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"3xf8Kz\":[\"Введіть ім'я акаунту...\"],\"4/Rr0R\":[\"Запросити користувача до поточного каналу\"],\"4EZrJN\":[\"Правила\"],\"4JJtW9\":[\"#переповнення\"],\"4NqeT4\":[\"Профіль флуду (+F)\"],\"4RZQRK\":[\"Що ти зараз робиш?\"],\"4hfTrB\":[\"Псевдонім\"],\"4n99LO\":[\"Вже в \",[\"0\"]],\"4t6vMV\":[\"Автоматично перемикатися на один рядок для коротких повідомлень\"],\"4vsHmf\":[\"Час (хв)\"],\"5+INAX\":[\"Виділяти повідомлення, що вас згадують\"],\"5R5Pv/\":[\"Ім'я оператора\"],\"678PKt\":[\"Назва мережі\"],\"6Aih4U\":[\"Офлайн\"],\"6CO3WE\":[\"Пароль для входу до каналу. Залиште порожнім для видалення ключа.\"],\"6HhMs3\":[\"Повідомлення про вихід\"],\"6V3Ea3\":[\"Скопійовано\"],\"6lGV3K\":[\"Показати менше\"],\"6yFOEi\":[\"Введіть пароль оператора...\"],\"7+IHTZ\":[\"Файл не вибрано\"],\"73hrRi\":[\"nick!user@host (напр., spam*!*@*, *!*@badhost.com)\"],\"7QkKyN\":[\"Надіслати приватне повідомлення\"],\"7U1W7c\":[\"Дуже розслаблений\"],\"7Y1YQj\":[\"Справжнє ім'я:\"],\"7YHArF\":[\"— відкрити у переглядачі\"],\"7fjnVl\":[\"Пошук користувачів...\"],\"7jL88x\":[\"Видалити це повідомлення? Цю дію неможливо скасувати.\"],\"7nGhhM\":[\"Що у вас на думці?\"],\"7sEpu1\":[\"Учасники — \",[\"0\"]],\"7sNhEz\":[\"Ім'я користувача\"],\"8H0Q+x\":[\"Дізнатися більше про профілі →\"],\"8Phu0A\":[\"Відображати, коли користувачі змінюють псевдонім\"],\"8XTG9e\":[\"Введіть пароль оператора\"],\"8XsV2J\":[\"Повторити надсилання\"],\"8ZsakT\":[\"Пароль\"],\"8kR84m\":[\"Ви збираєтеся відкрити зовнішнє посилання:\"],\"8lCgih\":[\"Видалити правило\"],\"8p/xVT\":[[\"0\",\"plural\",{\"one\":[[\"1\"]],\"other\":[[\"2\"]]}]],\"8wRzac\":[[\"joinCount\",\"plural\",{\"one\":[\"приєднався\"],\"few\":[\"приєднався \",[\"joinCount\"],\" рази\"],\"many\":[\"приєднався \",[\"joinCount\"],\" разів\"],\"other\":[\"приєднався \",[\"joinCount\"],\" рази\"]}]],\"9BMLnJ\":[\"Перепідключитися до сервера\"],\"9OEgyT\":[\"Додати реакцію\"],\"9PQ8m2\":[\"G-Line (глобальний бан)\"],\"9Qs99X\":[\"Електронна пошта:\"],\"9QupBP\":[\"Видалити шаблон\"],\"9bG48P\":[\"Надсилання\"],\"9f5f0u\":[\"Питання про конфіденційність? Зв'яжіться з нами:\"],\"9unqs3\":[\"Відсутність:\"],\"9v3hwv\":[\"Сервери не знайдено.\"],\"9zb2WA\":[\"Підключення\"],\"A1taO8\":[\"Пошук\"],\"A2adVi\":[\"Надсилати сповіщення про введення\"],\"A9Rhec\":[\"Назва каналу\"],\"AWOSPo\":[\"Збільшити\"],\"AXSpEQ\":[\"Оператор при підключенні\"],\"AeXO77\":[\"Акаунт\"],\"AhNP40\":[\"Перемотати\"],\"Ai2U7L\":[\"Хост\"],\"AjBQnf\":[\"Змінено псевдонім\"],\"AmXVh6\":[\"https://example.com/avatar.png\"],\"AnRu/j\":[\"Скасувати відповідь\"],\"ApSx0O\":[\"Знайдено \",[\"0\"],\" повідомлень, що відповідають \\\"\",[\"searchQuery\"],\"\\\"\"],\"AxPAXW\":[\"Результатів не знайдено\"],\"AyNqAB\":[\"Відображати всі події сервера в чаті\"],\"B/QqGw\":[\"Відійшов від клавіатури\"],\"B8AaMI\":[\"Це поле обов'язкове\"],\"BA2c49\":[\"Сервер не підтримує розширену фільтрацію LIST\"],\"BDKt3I\":[[\"0\"],\", \",[\"1\"],\", \",[\"2\"],\" та ще \",[\"3\"],\" пишуть...\"],\"BGul2A\":[\"У вас є незбережені зміни. Ви впевнені, що хочете закрити без збереження?\"],\"BIf9fi\":[\"Ваше статусне повідомлення\"],\"BZz3md\":[\"Ваш особистий веб-сайт\"],\"Bgm/H7\":[\"Дозволити введення кількох рядків тексту\"],\"BiQIl1\":[\"Закріпити цю приватну розмову\"],\"BlNZZ2\":[\"Натисніть, щоб перейти до повідомлення\"],\"Bowq3c\":[\"Тільки оператори можуть змінювати тему каналу\"],\"Btozzp\":[\"Термін дії цього зображення минув\"],\"Bycfjm\":[\"Всього: \",[\"0\"]],\"C6IBQc\":[\"Скопіювати весь JSON\"],\"C9L9wL\":[\"Збір даних\"],\"CDq4wC\":[\"Помірний режим для користувача\"],\"CHVRxG\":[\"Повідомлення @\",[\"0\"],\" (Shift+Enter для нового рядка)\"],\"CN9zdR\":[\"Ім'я та пароль оператора обов'язкові\"],\"CW3sYa\":[\"Додати реакцію \",[\"emoji\"]],\"CaAkqd\":[\"Показати виходи\"],\"CbvaYj\":[\"Блокування за псевдонімом\"],\"CcK+Ft\":[\"PDF\"],\"Ce8q3L\":[\"Вибрати канал\"],\"CsekCi\":[\"Нормальний\"],\"D+NlUC\":[\"Система\"],\"D28t6+\":[\"приєднався та вийшов\"],\"DB8zMK\":[\"Застосувати\"],\"DBcWHr\":[\"Власний файл звуку сповіщення\"],\"DTy9Xw\":[\"Попередній перегляд медіа\"],\"Dj4pSr\":[\"Виберіть надійний пароль\"],\"Du+zn+\":[\"Пошук...\"],\"Du2T2f\":[\"Налаштування не знайдено\"],\"DwsSVQ\":[\"Застосувати фільтри та оновити\"],\"E3W/zd\":[\"Стандартний псевдонім\"],\"E6nRW7\":[\"Копіювати URL\"],\"E703RG\":[\"Режими:\"],\"EAeu1Z\":[\"Надіслати запрошення\"],\"EFKJQT\":[\"Налаштування\"],\"EGPQBv\":[\"Власні правила флуду (+f)\"],\"ELik0r\":[\"Переглянути повну політику конфіденційності\"],\"EPbeC2\":[\"Переглянути або редагувати тему каналу\"],\"EQCDNT\":[\"Введіть ім'я користувача оператора...\"],\"EUvulZ\":[\"Знайдено 1 повідомлення, що відповідає \\\"\",[\"searchQuery\"],\"\\\"\"],\"EatZYJ\":[\"Наступне зображення\"],\"EdQY6l\":[\"Немає\"],\"EnqLYU\":[\"Пошук серверів...\"],\"F0OKMc\":[\"Редагувати сервер\"],\"F6Int2\":[\"Увімкнути виділення\"],\"FDoLyE\":[\"Макс. користувачів\"],\"FUU/hZ\":[\"Контролюйте, скільки зовнішніх медіа завантажується у чаті.\"],\"Fdp03t\":[\"увімк\"],\"FfPWR0\":[\"Модальне вікно\"],\"FjkaiT\":[\"Зменшити\"],\"FlqOE9\":[\"Що це означає:\"],\"FolHNl\":[\"Керуйте своїм обліковим записом та автентифікацією\"],\"Fp2Dif\":[\"Вийти з сервера\"],\"G5KmCc\":[\"GZ-Line (глобальна Z-Line)\"],\"GDs0lz\":[\"<0>Ризик: Конфіденційна інформація (повідомлення, приватні розмови, дані автентифікації) може бути доступна мережевим адміністраторам або зловмисникам між IRC-серверами.\"],\"GR+2I3\":[\"Додати маску запрошення (напр., nick!*@*, *!*@host.com)\"],\"GRLyMU\":[\"Закрити виспливаючі сповіщення сервера\"],\"GlHnXw\":[\"Зміна псевдоніму не вдалась: \",[\"error\"],\" \",[\"0\"]],\"GswZF3\":[\"Попередній перегляд:\"],\"GtmO8/\":[\"від\"],\"GtuHUQ\":[\"Перейменувати цей канал на сервері. Усі користувачі побачать нову назву.\"],\"GuGfFX\":[\"Перемкнути пошук\"],\"GxkJXS\":[\"Завантаження...\"],\"GzbwnK\":[\"Приєднався до каналу\"],\"GzsUDB\":[\"Розширений профіль\"],\"H/PnT8\":[\"Вставити емодзі\"],\"H6Izzl\":[\"Ваш бажаний код кольору\"],\"H9jIv+\":[\"Показати входи/виходи\"],\"HAKBY9\":[\"Завантажити файли\"],\"HdE1If\":[\"Канал\"],\"Hk4AW9\":[\"Ваше бажане відображуване ім'я\"],\"HmHDk7\":[\"Вибрати учасника\"],\"HrQzPU\":[\"Канали на \",[\"networkName\"]],\"I2tXQ5\":[\"Повідомлення @\",[\"0\"],\" (Enter для нового рядка, Shift+Enter для відправки)\"],\"I6bw/h\":[\"Заблокувати користувача\"],\"I92Z+b\":[\"Увімкнути сповіщення\"],\"I9D72S\":[\"Ви впевнені, що хочете видалити це повідомлення? Цю дію неможливо скасувати.\"],\"IA+1wo\":[\"Відображати, коли користувачів виганяють з каналів\"],\"IDwkJx\":[\"IRC оператор\"],\"ILlU+s\":[\"Інфо:\"],\"IUwGEM\":[\"Зберегти зміни\"],\"IVeGK6\":[[\"0\"],\", \",[\"1\"],\" та \",[\"2\"],\" пишуть...\"],\"IgrLD/\":[\"Пауза\"],\"Im6JED\":[\"ШЕПІТ\"],\"ImOQa9\":[\"Відповісти\"],\"IoHMnl\":[\"Максимальне значення: \",[\"0\"]],\"IvMj+0\":[\"Оп\"],\"J28zul\":[\"Підключення...\"],\"J5T9NW\":[\"Інформація про користувача\"],\"J8Y5+z\":[\"Ой! Розрив мережі! ⚠️\"],\"JBHkBA\":[\"Покинув канал\"],\"JCwL0Q\":[\"Введіть причину (необов'язково)\"],\"JFciKP\":[\"Перемкнути\"],\"JXGkhG\":[\"Змінити назву каналу (лише оператори)\"],\"JcD7qf\":[\"Більше дій\"],\"JdkA+c\":[\"Секретний (+s)\"],\"Jmu12l\":[\"Канали сервера\"],\"JvQ++s\":[\"Увімкнути Markdown\"],\"K2jwh/\":[\"Дані WHOIS недоступні\"],\"KAXSwC\":[\"Голос\"],\"KDfTdX\":[\"Видалити повідомлення\"],\"KKBlUU\":[\"Вбудувати\"],\"KM0pLb\":[\"Ласкаво просимо до каналу!\"],\"KR6W2h\":[\"Скасувати ігнорування\"],\"KV+Bi1\":[\"Тільки за запрошенням (+i)\"],\"KdCtwE\":[\"Скільки секунд відстежувати активність флуду перед скиданням лічильників\"],\"Kkezga\":[\"Пароль сервера\"],\"KsiQ/8\":[\"Користувачі повинні бути запрошені для входу до каналу\"],\"L+gB/D\":[\"Інформація про канал\"],\"LC1a7n\":[\"IRC-сервер повідомив, що зв'язки між серверами мають низький рівень безпеки. Це означає, що коли ваші повідомлення передаються між IRC-серверами в мережі, вони можуть бути не належним чином зашифровані або SSL/TLS-сертифікати можуть не перевірятися правильно.\"],\"LNfLR5\":[\"Показати виключення\"],\"LQb0W/\":[\"Показати всі події\"],\"LU7/yA\":[\"Альтернативна назва для відображення в інтерфейсі. Може містити пробіли, емодзі та спеціальні символи. Справжня назва каналу (\",[\"channelName\"],\") використовуватиметься для IRC-команд.\"],\"LUb9O7\":[\"Потрібен дійсний порт сервера\"],\"Lb+BUl\":[\"https://example.com/avatar.jpg\"],\"LcET2C\":[\"Політика конфіденційності\"],\"LcuSDR\":[\"Керуйте інформацією профілю та метаданими\"],\"LqLS9B\":[\"Показати зміни псевдоніма\"],\"LsDQt2\":[\"Налаштування каналу\"],\"LtI9AS\":[\"Власник\"],\"LuNhhL\":[\"відреагував на це повідомлення\"],\"M/AZNG\":[\"URL зображення вашого аватара\"],\"M/WIer\":[\"Надіслати повідомлення\"],\"M8er/5\":[\"Ім'я:\"],\"MHk+7g\":[\"Попереднє зображення\"],\"MRorGe\":[\"ПП користувачу\"],\"MVbSGP\":[\"Часове вікно (секунди)\"],\"MkpcsT\":[\"Ваші повідомлення та налаштування зберігаються локально на вашому пристрої\"],\"N/hDSy\":[\"Позначити як бот - зазвичай 'on' або порожньо\"],\"N7TQbE\":[\"Запросити користувача до \",[\"channelName\"]],\"NCca/o\":[\"Введіть стандартний нік...\"],\"Nqs6B9\":[\"Показує всі зовнішні медіа. Будь-яка URL може спричинити запит до невідомого сервера.\"],\"Nt+9O7\":[\"Використовувати WebSocket замість звичайного TCP\"],\"NxIHzc\":[\"Відключити користувача\"],\"O+v/cL\":[\"Переглянути всі канали на сервері\"],\"ODwSCk\":[\"Надіслати GIF\"],\"OGQ5kK\":[\"Налаштувати звуки сповіщень та виділення\"],\"OIPt1Z\":[\"Показати або приховати бічну панель зі списком учасників\"],\"OKSNq/\":[\"Дуже строгий\"],\"ONWvwQ\":[\"Завантажити\"],\"OVKoQO\":[\"Пароль вашого акаунту для автентифікації\"],\"ObsidianIRC - Bringing IRC to the future\":[\"ObsidianIRC - Привносимо IRC у майбутнє\"],\"OhCpra\":[\"Встановити тему…\"],\"OkltoQ\":[\"Заблокувати \",[\"username\"],\" за псевдонімом (запобігає повторному входу з тим же ніком)\"],\"P+t/Te\":[\"Немає додаткових даних\"],\"P42Wcc\":[\"Безпечно\"],\"PD38l0\":[\"Попередній перегляд аватара каналу\"],\"PD9mEt\":[\"Введіть повідомлення...\"],\"PPqfdA\":[\"Відкрити налаштування конфігурації каналу\"],\"PSCjfZ\":[\"Тема, яка відображатиметься для цього каналу. Усі користувачі можуть бачити тему.\"],\"PZCecv\":[\"Попередній перегляд PDF\"],\"PeLgsC\":[[\"c\",\"plural\",{\"one\":[\"1 раз\"],\"few\":[[\"c\"],\" рази\"],\"many\":[[\"c\"],\" разів\"],\"other\":[[\"c\"],\" рази\"]}]],\"PguS2C\":[\"Додати маску винятку (напр., nick!*@*, *!*@host.com)\"],\"Pil5Ty\":[\"Показано \",[\"displayedChannelsCount\"],\" із \",[\"0\"],\" каналів\"],\"PqhVlJ\":[\"Заблокувати користувача (за маскою хоста)\"],\"Q+chwU\":[\"Ім'я користувача:\"],\"Q6hhn8\":[\"Налаштування\"],\"QF4a34\":[\"Будь ласка, введіть ім'я користувача\"],\"QGqSZ2\":[\"Колір і форматування\"],\"QJQd1J\":[\"Редагувати профіль\"],\"QSzGDE\":[\"Бездіяльний\"],\"QUlny5\":[\"Ласкаво просимо до \",[\"0\"],\"!\"],\"Qoq+GP\":[\"Читати далі\"],\"QuSkCF\":[\"Фільтрувати канали...\"],\"QwUrDZ\":[\"змінив тему на: \",[\"topic\"]],\"R0UH07\":[\"Зображення \",[\"0\"],\" з \",[\"1\"]],\"R7SsBE\":[\"Вимкнути звук\"],\"R8rf1X\":[\"Натисніть для встановлення теми\"],\"RArB3D\":[\"був кікнутий з \",[\"channelName\"],\" користувачем \",[\"username\"]],\"RI3cWd\":[\"Відкрийте світ IRC з ObsidianIRC\"],\"RMMaN5\":[\"Модерований (+m)\"],\"RWw9Lg\":[\"Закрити вікно\"],\"RZ2BuZ\":[\"Реєстрація облікового запису \",[\"account\"],\" потребує підтвердження: \",[\"message\"]],\"RySp6q\":[\"Приховати коментарі\"],\"SPKQTd\":[\"Псевдонім обов'язковий\"],\"SPVjfj\":[\"За замовчуванням буде 'без причини', якщо залишити порожнім\"],\"SQKPvQ\":[\"Запросити користувача\"],\"SkZcl+\":[\"Виберіть готовий профіль захисту від флуду. Ці профілі надають збалансовані налаштування захисту для різних випадків використання.\"],\"Slr+3C\":[\"Мін. користувачів\"],\"Spnlre\":[\"Ви запросили \",[\"target\"],\" приєднатися до \",[\"channel\"]],\"T/ckN5\":[\"Відкрити у переглядачі\"],\"T91vKp\":[\"Відтворити\"],\"TV2Wdu\":[\"Дізнайтесь, як ми обробляємо ваші дані та захищаємо вашу конфіденційність.\"],\"TgFpwD\":[\"Застосовується...\"],\"TkzSFB\":[\"Без змін\"],\"TtserG\":[\"Введіть справжнє ім'я\"],\"Ttz9J1\":[\"Введіть пароль...\"],\"Tz0i8g\":[\"Налаштування\"],\"U3pytU\":[\"Адмін\"],\"UDb2YD\":[\"React\"],\"UE4KO5\":[\"*канал*\"],\"UGT5vp\":[\"Зберегти налаштування\"],\"UV5hLB\":[\"Заборон не знайдено\"],\"Uaj3Nd\":[\"Статусні повідомлення\"],\"Ue3uny\":[\"За замовчуванням (без профілю)\"],\"UkARhe\":[\"Нормальний - стандартний захист\"],\"Umn7Cj\":[\"Коментарів ще немає. Будьте першим!\"],\"UtUIRh\":[[\"0\"],\" старіших повідомлень\"],\"UwzP+U\":[\"Безпечне з'єднання\"],\"V0/A4O\":[\"Власник каналу\"],\"V4qgxE\":[\"Створено до (хв тому)\"],\"V8yTm6\":[\"Очистити пошук\"],\"VJMMyz\":[\"ObsidianIRC - Привносимо IRC у майбутнє\"],\"VJScHU\":[\"Причина\"],\"VLsmVV\":[\"Вимкнути сповіщення\"],\"VbyRUy\":[\"Коментарі\"],\"Vmx0mQ\":[\"Встановлено:\"],\"VqnIZz\":[\"Переглянути нашу політику конфіденційності та практики роботи з даними\"],\"VrMygG\":[\"Мінімальна довжина: \",[\"0\"]],\"VrnTui\":[\"Ваші займенники, відображені у вашому профілі\"],\"W8E3qn\":[\"Автентифікований акаунт\"],\"WAakm9\":[\"Видалити канал\"],\"WFxTHC\":[\"Додати маску бану (напр., nick!*@*, *!*@host.com)\"],\"WN1g9F\":[\"Хост сервера обов'язковий\"],\"WRYdXW\":[\"Позиція аудіо\"],\"WUOH5B\":[\"Ігнорувати користувача\"],\"WWEXnZ\":[[\"0\",\"plural\",{\"one\":[\"Показати ще 1 елемент\"],\"few\":[\"Показати ще \",[\"1\"],\" елементи\"],\"many\":[\"Показати ще \",[\"1\"],\" елементів\"],\"other\":[\"Показати ще \",[\"1\"],\" елементи\"]}]],\"Weq9zb\":[\"Загальне\"],\"Wfj7Sk\":[\"Вимкнути або увімкнути звуки сповіщень\"],\"Wm7gbG\":[\"GitHub:\"],\"WyeHWY\":[\"*спам*\"],\"WzMCru\":[\"Профіль користувача\"],\"X6S3lt\":[\"Пошук налаштувань, каналів, серверів...\"],\"XEHan5\":[\"Продовжити все одно\"],\"XI1+wb\":[\"Недійсний формат\"],\"XIXeuC\":[\"Повідомлення @\",[\"0\"]],\"XMS+k4\":[\"Почати приватне повідомлення\"],\"XWgxXq\":[\"Альбом\"],\"Xd7+IT\":[\"Відкріпити приватну розмову\"],\"Xm/s+u\":[\"Дисплей\"],\"Xp2n93\":[\"Показує медіа з надійного файлового хоста вашого сервера. Запити до зовнішніх сервісів не здійснюються.\"],\"XvjC4F\":[\"Збереження...\"],\"Y/qryO\":[\"Користувачів за вашим запитом не знайдено\"],\"YAqRpI\":[\"Реєстрація облікового запису \",[\"account\"],\" успішна: \",[\"message\"]],\"YEfzvP\":[\"Захищена тема (+t)\"],\"YQOn6a\":[\"Згорнути список учасників\"],\"YRCoE9\":[\"Оператор каналу\"],\"YURQaF\":[\"Переглянути профіль\"],\"YdBSvr\":[\"Керувати відображенням медіа та зовнішнього вмісту\"],\"Yj6U3V\":[\"Без центрального сервера:\"],\"YjvpGx\":[\"Займенники\"],\"YqH4l4\":[\"Без ключа\"],\"YyUPpV\":[\"Акаунт:\"],\"ZJSWfw\":[\"Повідомлення при від'єднанні від сервера\"],\"ZR1dJ4\":[\"Запрошення\"],\"ZdWg0V\":[\"Відкрити в браузері\"],\"ZhRBbl\":[\"Пошук повідомлень…\"],\"Zmcu3y\":[\"Розширені фільтри\"],\"a2/8e5\":[\"Тему встановлено після (хв тому)\"],\"aHKcKc\":[\"Попередня сторінка\"],\"aJTbXX\":[\"Пароль оператора\"],\"aQryQv\":[\"Шаблон вже існує\"],\"aW9pLN\":[\"Максимальна кількість користувачів у каналі. Залиште порожнім для відсутності обмежень.\"],\"ah4fmZ\":[\"Також показує попередній перегляд з YouTube, Vimeo, SoundCloud та подібних відомих сервісів.\"],\"aifXak\":[\"Немає медіа у цьому каналі\"],\"ap2zBz\":[\"Розслаблений\"],\"az8lvo\":[\"Вимк\"],\"azXSNo\":[\"Розгорнути список учасників\"],\"azdliB\":[\"Увійти в акаунт\"],\"b26wlF\":[\"вона/її\"],\"bD/+Ei\":[\"Строгий\"],\"bQ6BJn\":[\"Налаштуйте детальні правила захисту від флуду. Кожне правило вказує, яку активність відстежувати і які дії вживати при перевищенні порогів.\"],\"beV7+y\":[\"Користувач отримає запрошення приєднатися до \",[\"channelName\"],\".\"],\"bk84cH\":[\"Повідомлення про відсутність\"],\"bkHdLj\":[\"Додати IRC-сервер\"],\"bmQLn5\":[\"Додати правило\"],\"bwRvnp\":[\"Дія\"],\"c8+EVZ\":[\"Підтверджений акаунт\"],\"cGYUlD\":[\"Медіа-перегляди не завантажені.\"],\"cLF98o\":[\"Показати коментарі (\",[\"commentCount\"],\")\"],\"cLKIDO\":[\"Користувачів немає\"],\"cSgpoS\":[\"Закріпити приватну розмову\"],\"cde3ce\":[\"Повідомлення <0>\",[\"0\"],\"\"],\"chQsxg\":[\"Скопіювати форматований вивід\"],\"cl/A5J\":[\"Ласкаво просимо до \",[\"__DEFAULT_IRC_SERVER_NAME__\"],\"!\"],\"cnGeoo\":[\"Видалити\"],\"coPLXT\":[\"Ми не зберігаємо ваші IRC-комунікації на наших серверах\"],\"crYH/6\":[\"Програвач SoundCloud\"],\"d3sis4\":[\"Додати сервер\"],\"d9aN5k\":[\"Видалити \",[\"username\"],\" з каналу\"],\"dEgA5A\":[\"Скасувати\"],\"dGi1We\":[\"Відкріпити цю приватну розмову\"],\"dJVuyC\":[\"покинув \",[\"channelName\"],\" (\",[\"reason\"],\")\"],\"dMtLDE\":[\"до\"],\"dXqxlh\":[\"<0>⚠️ Загроза безпеці! Це з'єднання може бути вразливим до перехоплення або атак «людина посередині».\"],\"da9Q/R\":[\"Змінено режими каналу\"],\"dhJN3N\":[\"Показати коментарі\"],\"dj2xTE\":[\"Відхилити сповіщення\"],\"dpCzmC\":[\"Налаштування захисту від флуду\"],\"e9dQpT\":[\"Бажаєте відкрити це посилання в новій вкладці?\"],\"ePK91l\":[\"Редагувати\"],\"eYBDuB\":[\"Завантажте зображення або вкажіть URL з необов'язковою підстановкою \",[\"size\"],\" для динамічного розміру\"],\"edBbee\":[\"Заблокувати \",[\"username\"],\" за маскою хоста (запобігає повторному входу з тієї ж IP/хоста)\"],\"ekfzWq\":[\"Налаштування користувача\"],\"elPDWs\":[\"Налаштуйте свій IRC-клієнт\"],\"eu2osY\":[\"<0>💡 Рекомендація: Продовжуйте лише якщо довіряєте цьому серверу і розумієте ризики. Уникайте передачі конфіденційної інформації або паролів через це з'єднання.\"],\"euEhbr\":[\"Натисніть, щоб приєднатися до \",[\"channel\"]],\"ez3vLd\":[\"Увімкнути багаторядкове введення\"],\"f0J5Ki\":[\"Зв'язок між серверами може використовувати незашифровані з'єднання\"],\"f9BHJk\":[\"Попередити користувача\"],\"fDOLLd\":[\"Канали не знайдено.\"],\"ffzDkB\":[\"Анонімна аналітика:\"],\"fq1GF9\":[\"Відображати, коли користувачі від'єднуються від сервера\"],\"gEF57C\":[\"Цей сервер підтримує лише один тип з'єднання\"],\"gJuLUI\":[\"Список ігнорування\"],\"gNzMrk\":[\"Поточний аватар\"],\"gjPWyO\":[\"Введіть нік...\"],\"gz6UQ3\":[\"Розгорнути\"],\"h6razj\":[\"Маска виключення назви каналу\"],\"hG6jnw\":[\"Тема не встановлена\"],\"hG89Ed\":[\"Зображення\"],\"hZ6znB\":[\"Порт\"],\"ha+Bz5\":[\"напр., 100:1440\"],\"hehnjM\":[\"Кількість\"],\"hzdLuQ\":[\"Говорити можуть лише користувачі з голосом або вище\"],\"i0qMbr\":[\"Головна\"],\"iDNBZe\":[\"Сповіщення\"],\"iH8pgl\":[\"Назад\"],\"iL9SZg\":[\"Заблокувати користувача (за псевдонімом)\"],\"iNt+3c\":[\"Назад до зображення\"],\"iQvi+a\":[\"Не попереджати мене про низький рівень безпеки для цього сервера\"],\"iSLIjg\":[\"Підключитися\"],\"iWXkHH\":[\"Напів-оператор\"],\"iZeTtp\":[\"Хост сервера\"],\"idD8Ev\":[\"Збережено\"],\"iivqkW\":[\"Час входу\"],\"ij+Elv\":[\"Попередній перегляд зображення\"],\"ilIWp7\":[\"Перемкнути сповіщення\"],\"iuaqvB\":[\"Використовуйте * для шаблонів. Приклади: baduser!*@*, *!*@spammer.com, troll*!*@*\"],\"ixkTse\":[\"Бот\"],\"j2DGR0\":[\"Блокування за маскою хоста\"],\"jA4uoI\":[\"Тема:\"],\"jLXxGK\":[\"https://example.com\"],\"jPSk57\":[\"Причина (необов'язково)\"],\"jUV7CU\":[\"Завантажити аватар\"],\"jW5Uwh\":[\"Контролюйте завантаження зовнішніх медіа. Вимк / Безпечно / Надійні джерела / Весь вміст.\"],\"jXzms5\":[\"Параметри вкладення\"],\"jZlrte\":[\"Колір\"],\"jfC/xh\":[\"Контакт\"],\"jywMpv\":[\"#нова-назва-каналу\"],\"k112DD\":[\"Завантажити старіші повідомлення\"],\"k3ID0F\":[\"Фільтрувати учасників…\"],\"k65gsE\":[\"Детальний огляд\"],\"k7Zgob\":[\"Скасувати підключення\"],\"kAVx5h\":[\"Запрошень не знайдено\"],\"kCLEPU\":[\"Підключено до\"],\"kF5LKb\":[\"Ігноровані шаблони:\"],\"kGeOx/\":[\"Приєднатися до \",[\"0\"]],\"kITKr8\":[\"Завантаження режимів каналу...\"],\"kPpPsw\":[\"Ви є IRC-оператором\"],\"kWJmRL\":[\"Ви\"],\"kfcRb0\":[\"Аватар\"],\"kjMqSj\":[\"Скопіювати JSON\"],\"krViRy\":[\"Натисніть для копіювання як JSON\"],\"ks71ra\":[\"Винятки\"],\"kw4lRv\":[\"Напів-оператор каналу\"],\"kxgIRq\":[\"Виберіть або додайте канал для початку.\"],\"ky6dWe\":[\"Попередній перегляд аватара\"],\"l+GxCv\":[\"Завантаження каналів...\"],\"l+IUVW\":[\"Верифікація облікового запису \",[\"account\"],\" успішна: \",[\"message\"]],\"l/siQz\":[[\"reconnectCount\",\"plural\",{\"one\":[\"перепідключився\"],\"few\":[\"перепідключився \",[\"reconnectCount\"],\" рази\"],\"many\":[\"перепідключився \",[\"reconnectCount\"],\" разів\"],\"other\":[\"перепідключився \",[\"reconnectCount\"],\" рази\"]}]],\"l5jmzx\":[[\"0\"],\" та \",[\"1\"],\" пишуть...\"],\"lHy8N5\":[\"Завантаження більше каналів...\"],\"lbpf14\":[\"Приєднатися до \",[\"value\"]],\"lfFsZ4\":[\"Канали\"],\"lkNdiH\":[\"Ім'я акаунту\"],\"ln500L\":[\"ObsidianIRC\"],\"lnCMdg\":[\"Завантажити зображення\"],\"loQxaJ\":[\"Я повернувся\"],\"lvfaxv\":[\"ГОЛОВНА\"],\"m16xKo\":[\"Додати\"],\"m8flAk\":[\"Попередній перегляд (ще не завантажено)\"],\"mEPxTp\":[\"<0>⚠️ Будьте обережні! Відкривайте лише посилання з надійних джерел. Шкідливі посилання можуть порушити вашу безпеку або конфіденційність.\"],\"mHGdhG\":[\"Інформація про сервер\"],\"mHS8lb\":[\"Повідомлення #\",[\"0\"]],\"mMYBD9\":[\"Широкий - ширша область захисту\"],\"mTGsPd\":[\"Тема каналу\"],\"mU8j6O\":[\"Без зовнішніх повідомлень (+n)\"],\"mZp8FL\":[\"Автоматичне повернення до одного рядка\"],\"mdQu8G\":[\"ВашПсевдонім\"],\"miSSBQ\":[\"Коментарі (\",[\"commentCount\"],\")\"],\"mvyLSy\":[\"Користувач автентифікований\"],\"mwtcGl\":[\"Закрити коментарі\"],\"mzI/c+\":[\"Завантажити\"],\"n3fGRk\":[\"встановлено \",[\"0\"]],\"nE9jsU\":[\"Розслаблений - менш агресивний захист\"],\"nNflMD\":[\"Покинути канал\"],\"nPXkBi\":[\"Завантаження даних WHOIS...\"],\"nQnxxF\":[\"Повідомлення #\",[\"0\"],\" (Shift+Enter для нового рядка)\"],\"nWMRxa\":[\"Відкріпити\"],\"nkC032\":[\"Без профілю флуду\"],\"o69z4d\":[\"Надіслати попереджувальне повідомлення \",[\"username\"]],\"o9ylQi\":[\"Знайдіть GIF для початку\"],\"oFGkER\":[\"Повідомлення сервера\"],\"oOi11l\":[\"Прокрутити вниз\"],\"oQEzQR\":[\"Нове DM\"],\"oXOSPE\":[\"Онлайн\"],\"oal760\":[\"Можливі атаки «людина посередині» на з'єднання сервера\"],\"oeqmmJ\":[\"Надійні джерела\"],\"ovBPCi\":[\"За замовчуванням\"],\"p0Z69r\":[\"Шаблон не може бути порожнім\"],\"p1KgtK\":[\"Не вдалося завантажити аудіо\"],\"p59pEv\":[\"Детальніше\"],\"p7sRI6\":[\"Повідомляти інших, коли ви друкуєте\"],\"pBm1od\":[\"Секретний канал\"],\"pNmiXx\":[\"Ваш псевдонім за замовчуванням для всіх серверів\"],\"pUUo9G\":[\"Хост:\"],\"pVGPmz\":[\"Пароль акаунту\"],\"peNE68\":[\"Постійний\"],\"plhHQt\":[\"Немає даних\"],\"pm6+q5\":[\"Попередження про безпеку\"],\"pn5qSs\":[\"Додаткова інформація\"],\"q0cR4S\":[\"тепер відомий як **\",[\"newNick\"],\"**\"],\"qFcunY\":[\"Канал не відображатиметься у командах LIST або NAMES\"],\"qLpTm/\":[\"Видалити реакцію \",[\"emoji\"]],\"qVkGWK\":[\"Закріпити\"],\"qY8wNa\":[\"Головна сторінка\"],\"qb0xJ7\":[\"Використовуйте шаблони: * відповідає будь-якій послідовності, ? відповідає будь-якому одному символу. Приклади: nick!*@*, *!*@host.com, *!*user@*\"],\"qhzpRq\":[\"Ключ каналу (+k)\"],\"qtoOYG\":[\"Без обмежень\"],\"r1W2AS\":[\"Зображення з файлового хостингу\"],\"rIPR2O\":[\"Тему встановлено до (хв тому)\"],\"rMMSYo\":[\"Максимальна довжина: \",[\"0\"]],\"rWtzQe\":[\"Мережа розділилась і возз'єдналась. ✅\"],\"rYG2u6\":[\"Будь ласка, зачекайте...\"],\"rdUucN\":[\"Попередній перегляд\"],\"rjGI/Q\":[\"Конфіденційність\"],\"rk8iDX\":[\"Завантаження GIF...\"],\"rn6SBY\":[\"Увімкнути звук\"],\"s/UKqq\":[\"Виключено з каналу\"],\"s8cATI\":[\"приєднався до \",[\"channelName\"]],\"sCO9ue\":[\"З'єднання з <0>\",[\"serverName\"],\" має такі проблеми безпеки:\"],\"sGH11W\":[\"Сервер\"],\"sHI1H+\":[\"тепер відомий як **\",[\"newNick\"],\"**\"],\"sJyV04\":[[\"inviter\"],\" запросив вас приєднатися до \",[\"channel\"]],\"sby+1/\":[\"Натисніть для копіювання\"],\"sfN25C\":[\"Ваше справжнє або повне ім'я\"],\"sliuzR\":[\"Відкрити посилання\"],\"sqrO9R\":[\"Власні згадки\"],\"sr6RdJ\":[\"Багаторядковий на Shift+Enter\"],\"swrCpB\":[\"Канал перейменовано з \",[\"oldName\"],\" на \",[\"newName\"],\" користувачем \",[\"user\"],[\"0\"]],\"sxkWRg\":[\"Додаткові\"],\"t/YqKh\":[\"Видалити\"],\"t47eHD\":[\"Ваш унікальний ідентифікатор на цьому сервері\"],\"tAkAh0\":[\"URL з необов'язковою підстановкою \",[\"size\"],\" для динамічного розміру. Приклад: https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"tXLJS3\":[\"Показати або приховати бічну панель зі списком каналів\"],\"tfDRzk\":[\"Зберегти\"],\"tiBsJk\":[\"покинув \",[\"channelName\"]],\"tt4/UD\":[\"вийшов (\",[\"reason\"],\")\"],\"u0TcnO\":[\"Псевдонім {nick} вже використовується, повторна спроба з {newNick}\"],\"u0a8B4\":[\"Автентифікуватися як IRC-оператор для адміністративного доступу\"],\"u0rWFU\":[\"Створено після (хв тому)\"],\"u72w3t\":[\"Користувачі та шаблони для ігнорування\"],\"u7jc2L\":[\"вийшов\"],\"uAQUqI\":[\"Статус\"],\"uB85T3\":[\"Помилка збереження: \",[\"msg\"]],\"uV3DOL\":[\"G-Line\"],\"uW3lLI\":[\"IRC сервери:\"],\"usSSr/\":[\"Рівень масштабу\"],\"v7uvcf\":[\"Програма:\"],\"vE8kb+\":[\"Використовуйте Shift+Enter для нового рядка (Enter надсилає)\"],\"vERlcd\":[\"Профіль\"],\"vK0RL8\":[\"Без теми\"],\"vSJd18\":[\"Відео\"],\"vXIe7J\":[\"Мова\"],\"vaHYxN\":[\"Справжнє ім'я\"],\"vhjbKr\":[\"Відсутній\"],\"w4NYox\":[\"клієнт \",[\"title\"]],\"w8xQRx\":[\"Недійсне значення\"],\"wFjjxZ\":[\"був кікнутий з \",[\"channelName\"],\" користувачем \",[\"username\"],\" (\",[\"reason\"],\")\"],\"wGjaGl\":[\"Винятків з заборон не знайдено\"],\"wPrGnM\":[\"Адміністратор каналу\"],\"wRkP2d\":[\"GIF\"],\"wbm86v\":[\"Відображати, коли користувачі входять або виходять з каналів\"],\"whqZ9r\":[\"Додаткові слова або фрази для виділення\"],\"wm7RV4\":[\"Звук сповіщення\"],\"wz/Yoq\":[\"Ваші повідомлення можуть бути перехоплені при передачі між серверами\"],\"xCJdfg\":[\"Очистити\"],\"xUHRTR\":[\"Автоматично автентифікуватися як оператор при підключенні\"],\"xWHwwQ\":[\"Блокування\"],\"xYilR2\":[\"Медіа\"],\"xceQrO\":[\"Підтримуються лише захищені websocket-з'єднання\"],\"xdtXa+\":[\"назва-каналу\"],\"xfXC7q\":[\"Текстові канали\"],\"xlCYOE\":[\"Отримання більше повідомлень...\"],\"xlhswE\":[\"Мінімальне значення: \",[\"0\"]],\"xq97Ci\":[\"Додати слово або фразу...\"],\"xuRqRq\":[\"Ліміт клієнтів (+l)\"],\"xwF+7J\":[[\"0\"],\" пише...\"],\"yNeucF\":[\"Цей сервер не підтримує розширені метадані профілю (розширення IRCv3 METADATA). Додаткові поля, такі як аватар, відображуване ім'я та статус, недоступні.\"],\"yPlrca\":[\"Аватар каналу\"],\"yQE2r9\":[\"Завантаження\"],\"ySU+JY\":[\"your@email.com\"],\"yTX1Rt\":[\"Ім'я оператора\"],\"yYOzWD\":[\"логи\"],\"yfx9Re\":[\"Пароль IRC оператора\"],\"ygCKqB\":[\"Зупинити\"],\"ymDxJx\":[\"Ім'я IRC оператора\"],\"yrpRsQ\":[\"Сортувати за назвою\"],\"yz7wBu\":[\"Закрити\"],\"zJw+jA\":[\"встановлює режим: \",[\"0\"]],\"zebeLu\":[\"Введіть ім'я оператора\"],\"zpr0Bw\":[\"GZ-Line\"]}"); \ No newline at end of file +/*eslint-disable*/export const messages=JSON.parse("{\"+5VMrz\":[\"Недійсний формат шаблону. Використовуйте формат nick!user@host (дозволені символи * як шаблони)\"],\"+6NQQA\":[\"Загальний канал підтримки\"],\"+6NyRG\":[\"Клієнт\"],\"+K0AvT\":[\"Від'єднатися\"],\"+cyFdH\":[\"Стандартне повідомлення при позначенні себе відсутнім\"],\"+mVPqU\":[\"Відображати Markdown-форматування у повідомленнях\"],\"+vqCJH\":[\"Ім'я користувача вашого акаунту для автентифікації\"],\"+yPBXI\":[\"Вибрати файл\"],\"+zy2Nq\":[\"Тип\"],\"/09cao\":[\"Низький рівень безпеки з'єднання (Рівень \",[\"securityLevel\"],\")\"],\"/3BQ4J\":[\"Користувачі за межами каналу не можуть надсилати до нього повідомлення\"],\"/6BzZF\":[\"Перемкнути список учасників\"],\"/TNOPk\":[\"Користувач відсутній\"],\"/XQgft\":[\"Дослідити\"],\"/cF7Rs\":[\"Гучність\"],\"/dqduX\":[\"Наступна сторінка\"],\"/fc3q4\":[\"Весь вміст\"],\"/kISDh\":[\"Увімкнути звуки сповіщень\"],\"/n04sB\":[\"Kill\"],\"/rTz0M\":[\"Аудіо\"],\"/rfkZe\":[\"Відтворювати звуки для згадок і повідомлень\"],\"0/0ZGA\":[\"Маска назви каналу\"],\"0D6j7U\":[\"Дізнатися більше про власні правила →\"],\"0XsHcR\":[\"Вигнати користувача\"],\"0ZpE//\":[\"Сортувати за кількістю користувачів\"],\"0bEPwz\":[\"Встановити статус «Відсутній»\"],\"0dGkPt\":[\"Розгорнути список каналів\"],\"0gS7M5\":[\"Відображуване ім'я\"],\"0kS+M8\":[\"ПрикладМЕРЕЖА\"],\"0rgoY7\":[\"Підключатися лише до обраних вами серверів\"],\"0wdd7X\":[\"Приєднатися\"],\"0wkVYx\":[\"Приватні повідомлення\"],\"111uHX\":[\"Попередній перегляд посилання\"],\"196EG4\":[\"Видалити приватний чат\"],\"1DSr1i\":[\"Зареєструвати акаунт\"],\"1O/24y\":[\"Перемкнути список каналів\"],\"1TNIig\":[\"Open\"],\"1VPJJ2\":[\"Попередження про зовнішнє посилання\"],\"1ZC/dv\":[\"Немає непрочитаних згадувань або повідомлень\"],\"1pO1zi\":[\"Назва сервера обов'язкова\"],\"1uwfzQ\":[\"Переглянути тему каналу\"],\"268g7c\":[\"Введіть відображуване ім'я\"],\"2FOFq1\":[\"Оператори сервера в мережі можуть потенційно читати ваші повідомлення\"],\"2FYpfJ\":[\"Більше\"],\"2HF1Y2\":[[\"inviter\"],\" запросив \",[\"target\"],\" приєднатися до \",[\"channel\"]],\"2I70QL\":[\"Переглянути інформацію профілю користувача\"],\"2QYdmE\":[\"Користувачі:\"],\"2QpEjG\":[\"вийшов\"],\"2YE223\":[\"Повідомлення #\",[\"0\"],\" (Enter для нового рядка, Shift+Enter для відправки)\"],\"2bimFY\":[\"Використовувати пароль сервера\"],\"2iTmdZ\":[\"Локальне сховище:\"],\"2odkwe\":[\"Строгий - більш агресивний захист\"],\"2uDhbA\":[\"Введіть ім'я користувача для запрошення\"],\"2ygf/L\":[\"← Назад\"],\"2zEgxj\":[\"Пошук GIF...\"],\"3RdPhl\":[\"Перейменувати канал\"],\"3THokf\":[\"Користувач з голосом\"],\"3TSz9S\":[\"Згорнути\"],\"3jBDvM\":[\"Відображувана назва каналу\"],\"3ryuFU\":[\"Необов'язкові звіти про збої для покращення додатку\"],\"3uBF/8\":[\"Закрити переглядач\"],\"3uwW8F\":[\"https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"3xf8Kz\":[\"Введіть ім'я акаунту...\"],\"4/Rr0R\":[\"Запросити користувача до поточного каналу\"],\"4EZrJN\":[\"Правила\"],\"4JJtW9\":[\"#переповнення\"],\"4NqeT4\":[\"Профіль флуду (+F)\"],\"4RZQRK\":[\"Що ти зараз робиш?\"],\"4hfTrB\":[\"Псевдонім\"],\"4n99LO\":[\"Вже в \",[\"0\"]],\"4t6vMV\":[\"Автоматично перемикатися на один рядок для коротких повідомлень\"],\"4vsHmf\":[\"Час (хв)\"],\"4x/Axu\":[\"Ваш баунсер ще не має жодних мереж. Додайте одну, щоб почати.\"],\"5+INAX\":[\"Виділяти повідомлення, що вас згадують\"],\"5R5Pv/\":[\"Ім'я оператора\"],\"678PKt\":[\"Назва мережі\"],\"6Aih4U\":[\"Офлайн\"],\"6CO3WE\":[\"Пароль для входу до каналу. Залиште порожнім для видалення ключа.\"],\"6HhMs3\":[\"Повідомлення про вихід\"],\"6V3Ea3\":[\"Скопійовано\"],\"6lGV3K\":[\"Показати менше\"],\"6yFOEi\":[\"Введіть пароль оператора...\"],\"7+IHTZ\":[\"Файл не вибрано\"],\"73fnil\":[\"TLS\"],\"73hrRi\":[\"nick!user@host (напр., spam*!*@*, *!*@badhost.com)\"],\"7QkKyN\":[\"Надіслати приватне повідомлення\"],\"7U1W7c\":[\"Дуже розслаблений\"],\"7Y1YQj\":[\"Справжнє ім'я:\"],\"7YHArF\":[\"— відкрити у переглядачі\"],\"7fjnVl\":[\"Пошук користувачів...\"],\"7jL88x\":[\"Видалити це повідомлення? Цю дію неможливо скасувати.\"],\"7nGhhM\":[\"Що у вас на думці?\"],\"7sEpu1\":[\"Учасники — \",[\"0\"]],\"7sNhEz\":[\"Ім'я користувача\"],\"8H0Q+x\":[\"Дізнатися більше про профілі →\"],\"8Phu0A\":[\"Відображати, коли користувачі змінюють псевдонім\"],\"8XTG9e\":[\"Введіть пароль оператора\"],\"8XsV2J\":[\"Повторити надсилання\"],\"8ZsakT\":[\"Пароль\"],\"8kR84m\":[\"Ви збираєтеся відкрити зовнішнє посилання:\"],\"8lCgih\":[\"Видалити правило\"],\"8p/xVT\":[[\"0\",\"plural\",{\"one\":[[\"1\"]],\"other\":[[\"2\"]]}]],\"8wRzac\":[[\"joinCount\",\"plural\",{\"one\":[\"приєднався\"],\"few\":[\"приєднався \",[\"joinCount\"],\" рази\"],\"many\":[\"приєднався \",[\"joinCount\"],\" разів\"],\"other\":[\"приєднався \",[\"joinCount\"],\" рази\"]}]],\"9BMLnJ\":[\"Перепідключитися до сервера\"],\"9OEgyT\":[\"Додати реакцію\"],\"9PQ8m2\":[\"G-Line (глобальний бан)\"],\"9Qs99X\":[\"Електронна пошта:\"],\"9QupBP\":[\"Видалити шаблон\"],\"9W7tl5\":[\"(без змін)\"],\"9bG48P\":[\"Надсилання\"],\"9f5f0u\":[\"Питання про конфіденційність? Зв'яжіться з нами:\"],\"9iweoP\":[\"Мережі на \",[\"0\"]],\"9unqs3\":[\"Відсутність:\"],\"9v3hwv\":[\"Сервери не знайдено.\"],\"9zb2WA\":[\"Підключення\"],\"A1taO8\":[\"Пошук\"],\"A2adVi\":[\"Надсилати сповіщення про введення\"],\"A9Rhec\":[\"Назва каналу\"],\"AWOSPo\":[\"Збільшити\"],\"AXSpEQ\":[\"Оператор при підключенні\"],\"AeXO77\":[\"Акаунт\"],\"AhNP40\":[\"Перемотати\"],\"Ai2U7L\":[\"Хост\"],\"AjBQnf\":[\"Змінено псевдонім\"],\"AmXVh6\":[\"https://example.com/avatar.png\"],\"AnRu/j\":[\"Скасувати відповідь\"],\"ApSx0O\":[\"Знайдено \",[\"0\"],\" повідомлень, що відповідають \\\"\",[\"searchQuery\"],\"\\\"\"],\"AxPAXW\":[\"Результатів не знайдено\"],\"AyNqAB\":[\"Відображати всі події сервера в чаті\"],\"B/QqGw\":[\"Відійшов від клавіатури\"],\"B0sB2k\":[\"Незашифровано\"],\"B8AaMI\":[\"Це поле обов'язкове\"],\"BA2c49\":[\"Сервер не підтримує розширену фільтрацію LIST\"],\"BDKt3I\":[[\"0\"],\", \",[\"1\"],\", \",[\"2\"],\" та ще \",[\"3\"],\" пишуть...\"],\"BGul2A\":[\"У вас є незбережені зміни. Ви впевнені, що хочете закрити без збереження?\"],\"BIf9fi\":[\"Ваше статусне повідомлення\"],\"BZz3md\":[\"Ваш особистий веб-сайт\"],\"Bgm/H7\":[\"Дозволити введення кількох рядків тексту\"],\"BiQIl1\":[\"Закріпити цю приватну розмову\"],\"BlNZZ2\":[\"Натисніть, щоб перейти до повідомлення\"],\"Bowq3c\":[\"Тільки оператори можуть змінювати тему каналу\"],\"Btozzp\":[\"Термін дії цього зображення минув\"],\"Bycfjm\":[\"Всього: \",[\"0\"]],\"C6IBQc\":[\"Скопіювати весь JSON\"],\"C9L9wL\":[\"Збір даних\"],\"CDq4wC\":[\"Помірний режим для користувача\"],\"CHVRxG\":[\"Повідомлення @\",[\"0\"],\" (Shift+Enter для нового рядка)\"],\"CN9zdR\":[\"Ім'я та пароль оператора обов'язкові\"],\"CW3sYa\":[\"Додати реакцію \",[\"emoji\"]],\"CaAkqd\":[\"Показати виходи\"],\"CbvaYj\":[\"Блокування за псевдонімом\"],\"CcK+Ft\":[\"PDF\"],\"Ce8q3L\":[\"Вибрати канал\"],\"CsekCi\":[\"Нормальний\"],\"D+NlUC\":[\"Система\"],\"D28t6+\":[\"приєднався та вийшов\"],\"DB8zMK\":[\"Застосувати\"],\"DBcWHr\":[\"Власний файл звуку сповіщення\"],\"DTy9Xw\":[\"Попередній перегляд медіа\"],\"Dj4pSr\":[\"Виберіть надійний пароль\"],\"Du+zn+\":[\"Пошук...\"],\"Du2T2f\":[\"Налаштування не знайдено\"],\"DwsSVQ\":[\"Застосувати фільтри та оновити\"],\"E3W/zd\":[\"Стандартний псевдонім\"],\"E6nRW7\":[\"Копіювати URL\"],\"E703RG\":[\"Режими:\"],\"EAeu1Z\":[\"Надіслати запрошення\"],\"EFKJQT\":[\"Налаштування\"],\"EGPQBv\":[\"Власні правила флуду (+f)\"],\"ELik0r\":[\"Переглянути повну політику конфіденційності\"],\"EPbeC2\":[\"Переглянути або редагувати тему каналу\"],\"EQCDNT\":[\"Введіть ім'я користувача оператора...\"],\"EUvulZ\":[\"Знайдено 1 повідомлення, що відповідає \\\"\",[\"searchQuery\"],\"\\\"\"],\"EatZYJ\":[\"Наступне зображення\"],\"EdQY6l\":[\"Немає\"],\"EnqLYU\":[\"Пошук серверів...\"],\"F0OKMc\":[\"Редагувати сервер\"],\"F6Int2\":[\"Увімкнути виділення\"],\"FDoLyE\":[\"Макс. користувачів\"],\"FUU/hZ\":[\"Контролюйте, скільки зовнішніх медіа завантажується у чаті.\"],\"Fdp03t\":[\"увімк\"],\"FfPWR0\":[\"Модальне вікно\"],\"FjkaiT\":[\"Зменшити\"],\"FlqOE9\":[\"Що це означає:\"],\"FolHNl\":[\"Керуйте своїм обліковим записом та автентифікацією\"],\"Fp2Dif\":[\"Вийти з сервера\"],\"G5KmCc\":[\"GZ-Line (глобальна Z-Line)\"],\"GDs0lz\":[\"<0>Ризик: Конфіденційна інформація (повідомлення, приватні розмови, дані автентифікації) може бути доступна мережевим адміністраторам або зловмисникам між IRC-серверами.\"],\"GR+2I3\":[\"Додати маску запрошення (напр., nick!*@*, *!*@host.com)\"],\"GRLyMU\":[\"Закрити виспливаючі сповіщення сервера\"],\"GlHnXw\":[\"Зміна псевдоніму не вдалась: \",[\"error\"],\" \",[\"0\"]],\"GswZF3\":[\"Попередній перегляд:\"],\"GtmO8/\":[\"від\"],\"GtuHUQ\":[\"Перейменувати цей канал на сервері. Усі користувачі побачать нову назву.\"],\"GuGfFX\":[\"Перемкнути пошук\"],\"GxkJXS\":[\"Завантаження...\"],\"GzbwnK\":[\"Приєднався до каналу\"],\"GzsUDB\":[\"Розширений профіль\"],\"H/PnT8\":[\"Вставити емодзі\"],\"H6Izzl\":[\"Ваш бажаний код кольору\"],\"H9jIv+\":[\"Показати входи/виходи\"],\"HAKBY9\":[\"Завантажити файли\"],\"HdE1If\":[\"Канал\"],\"Hk4AW9\":[\"Ваше бажане відображуване ім'я\"],\"HmHDk7\":[\"Вибрати учасника\"],\"HrQzPU\":[\"Канали на \",[\"networkName\"]],\"I2tXQ5\":[\"Повідомлення @\",[\"0\"],\" (Enter для нового рядка, Shift+Enter для відправки)\"],\"I6bw/h\":[\"Заблокувати користувача\"],\"I92Z+b\":[\"Увімкнути сповіщення\"],\"I9D72S\":[\"Ви впевнені, що хочете видалити це повідомлення? Цю дію неможливо скасувати.\"],\"IA+1wo\":[\"Відображати, коли користувачів виганяють з каналів\"],\"IDwkJx\":[\"IRC оператор\"],\"ILlU+s\":[\"Інфо:\"],\"IUwGEM\":[\"Зберегти зміни\"],\"IVeGK6\":[[\"0\"],\", \",[\"1\"],\" та \",[\"2\"],\" пишуть...\"],\"IgrLD/\":[\"Пауза\"],\"Im6JED\":[\"ШЕПІТ\"],\"ImOQa9\":[\"Відповісти\"],\"IoHMnl\":[\"Максимальне значення: \",[\"0\"]],\"IvMj+0\":[\"Оп\"],\"J28zul\":[\"Підключення...\"],\"J5T9NW\":[\"Інформація про користувача\"],\"J8Y5+z\":[\"Ой! Розрив мережі! ⚠️\"],\"JBHkBA\":[\"Покинув канал\"],\"JCwL0Q\":[\"Введіть причину (необов'язково)\"],\"JFciKP\":[\"Перемкнути\"],\"JXGkhG\":[\"Змінити назву каналу (лише оператори)\"],\"JcD7qf\":[\"Більше дій\"],\"JdkA+c\":[\"Секретний (+s)\"],\"Jmu12l\":[\"Канали сервера\"],\"JvQ++s\":[\"Увімкнути Markdown\"],\"K2jwh/\":[\"Дані WHOIS недоступні\"],\"KAXSwC\":[\"Голос\"],\"KDfTdX\":[\"Видалити повідомлення\"],\"KKBlUU\":[\"Вбудувати\"],\"KM0pLb\":[\"Ласкаво просимо до каналу!\"],\"KR6W2h\":[\"Скасувати ігнорування\"],\"KV+Bi1\":[\"Тільки за запрошенням (+i)\"],\"KdCtwE\":[\"Скільки секунд відстежувати активність флуду перед скиданням лічильників\"],\"Kkezga\":[\"Пароль сервера\"],\"KsiQ/8\":[\"Користувачі повинні бути запрошені для входу до каналу\"],\"L+gB/D\":[\"Інформація про канал\"],\"LC1a7n\":[\"IRC-сервер повідомив, що зв'язки між серверами мають низький рівень безпеки. Це означає, що коли ваші повідомлення передаються між IRC-серверами в мережі, вони можуть бути не належним чином зашифровані або SSL/TLS-сертифікати можуть не перевірятися правильно.\"],\"LNfLR5\":[\"Показати виключення\"],\"LP+1Z7\":[\"Додати мережу\"],\"LQb0W/\":[\"Показати всі події\"],\"LU7/yA\":[\"Альтернативна назва для відображення в інтерфейсі. Може містити пробіли, емодзі та спеціальні символи. Справжня назва каналу (\",[\"channelName\"],\") використовуватиметься для IRC-команд.\"],\"LUb9O7\":[\"Потрібен дійсний порт сервера\"],\"Lb+BUl\":[\"https://example.com/avatar.jpg\"],\"LcET2C\":[\"Політика конфіденційності\"],\"LcuSDR\":[\"Керуйте інформацією профілю та метаданими\"],\"LqLS9B\":[\"Показати зміни псевдоніма\"],\"LsDQt2\":[\"Налаштування каналу\"],\"LtI9AS\":[\"Власник\"],\"LuNhhL\":[\"відреагував на це повідомлення\"],\"M/AZNG\":[\"URL зображення вашого аватара\"],\"M/WIer\":[\"Надіслати повідомлення\"],\"M8er/5\":[\"Ім'я:\"],\"MHk+7g\":[\"Попереднє зображення\"],\"MRorGe\":[\"ПП користувачу\"],\"MVbSGP\":[\"Часове вікно (секунди)\"],\"MkpcsT\":[\"Ваші повідомлення та налаштування зберігаються локально на вашому пристрої\"],\"MzPdC2\":[\"Пароль сервера (PASS)\"],\"N/hDSy\":[\"Позначити як бот - зазвичай 'on' або порожньо\"],\"N6j2JH\":[\"Редагувати \",[\"0\"]],\"N7TQbE\":[\"Запросити користувача до \",[\"channelName\"]],\"NCca/o\":[\"Введіть стандартний нік...\"],\"Nqs6B9\":[\"Показує всі зовнішні медіа. Будь-яка URL може спричинити запит до невідомого сервера.\"],\"Nt+9O7\":[\"Використовувати WebSocket замість звичайного TCP\"],\"NxIHzc\":[\"Відключити користувача\"],\"O+v/cL\":[\"Переглянути всі канали на сервері\"],\"OCGpR4\":[\"(успадковано)\"],\"ODwSCk\":[\"Надіслати GIF\"],\"OGQ5kK\":[\"Налаштувати звуки сповіщень та виділення\"],\"OIPt1Z\":[\"Показати або приховати бічну панель зі списком учасників\"],\"OKSNq/\":[\"Дуже строгий\"],\"ONWvwQ\":[\"Завантажити\"],\"OVKoQO\":[\"Пароль вашого акаунту для автентифікації\"],\"ObsidianIRC - Bringing IRC to the future\":[\"ObsidianIRC - Привносимо IRC у майбутнє\"],\"OhCpra\":[\"Встановити тему…\"],\"OkltoQ\":[\"Заблокувати \",[\"username\"],\" за псевдонімом (запобігає повторному входу з тим же ніком)\"],\"P+t/Te\":[\"Немає додаткових даних\"],\"P42Wcc\":[\"Безпечно\"],\"PD38l0\":[\"Попередній перегляд аватара каналу\"],\"PD9mEt\":[\"Введіть повідомлення...\"],\"PPqfdA\":[\"Відкрити налаштування конфігурації каналу\"],\"PSCjfZ\":[\"Тема, яка відображатиметься для цього каналу. Усі користувачі можуть бачити тему.\"],\"PZCecv\":[\"Попередній перегляд PDF\"],\"PeLgsC\":[[\"c\",\"plural\",{\"one\":[\"1 раз\"],\"few\":[[\"c\"],\" рази\"],\"many\":[[\"c\"],\" разів\"],\"other\":[[\"c\"],\" рази\"]}]],\"PguS2C\":[\"Додати маску винятку (напр., nick!*@*, *!*@host.com)\"],\"Pil5Ty\":[\"Показано \",[\"displayedChannelsCount\"],\" із \",[\"0\"],\" каналів\"],\"PqhVlJ\":[\"Заблокувати користувача (за маскою хоста)\"],\"Q+chwU\":[\"Ім'я користувача:\"],\"Q3v9Wc\":[\"Так, видалити\"],\"Q6hhn8\":[\"Налаштування\"],\"QF4a34\":[\"Будь ласка, введіть ім'я користувача\"],\"QGqSZ2\":[\"Колір і форматування\"],\"QJQd1J\":[\"Редагувати профіль\"],\"QSzGDE\":[\"Бездіяльний\"],\"QUlny5\":[\"Ласкаво просимо до \",[\"0\"],\"!\"],\"Qoq+GP\":[\"Читати далі\"],\"QuSkCF\":[\"Фільтрувати канали...\"],\"QwUrDZ\":[\"змінив тему на: \",[\"topic\"]],\"R0UH07\":[\"Зображення \",[\"0\"],\" з \",[\"1\"]],\"R7SsBE\":[\"Вимкнути звук\"],\"R8rf1X\":[\"Натисніть для встановлення теми\"],\"RArB3D\":[\"був кікнутий з \",[\"channelName\"],\" користувачем \",[\"username\"]],\"RI3cWd\":[\"Відкрийте світ IRC з ObsidianIRC\"],\"RMMaN5\":[\"Модерований (+m)\"],\"RWw9Lg\":[\"Закрити вікно\"],\"RZ2BuZ\":[\"Реєстрація облікового запису \",[\"account\"],\" потребує підтвердження: \",[\"message\"]],\"RySp6q\":[\"Приховати коментарі\"],\"S5Togi\":[\"Завантаження мереж з вашого баунсера…\"],\"SPKQTd\":[\"Псевдонім обов'язковий\"],\"SPVjfj\":[\"За замовчуванням буде 'без причини', якщо залишити порожнім\"],\"SQKPvQ\":[\"Запросити користувача\"],\"STmlpb\":[\"Back to network list\"],\"SkZcl+\":[\"Виберіть готовий профіль захисту від флуду. Ці профілі надають збалансовані налаштування захисту для різних випадків використання.\"],\"Slr+3C\":[\"Мін. користувачів\"],\"Spnlre\":[\"Ви запросили \",[\"target\"],\" приєднатися до \",[\"channel\"]],\"T/ckN5\":[\"Відкрити у переглядачі\"],\"T91vKp\":[\"Відтворити\"],\"TV2Wdu\":[\"Дізнайтесь, як ми обробляємо ваші дані та захищаємо вашу конфіденційність.\"],\"TgFpwD\":[\"Застосовується...\"],\"TkzSFB\":[\"Без змін\"],\"TtserG\":[\"Введіть справжнє ім'я\"],\"Ttz9J1\":[\"Введіть пароль...\"],\"Tz0i8g\":[\"Налаштування\"],\"U3pytU\":[\"Адмін\"],\"UDb2YD\":[\"React\"],\"UE4KO5\":[\"*канал*\"],\"UGT5vp\":[\"Зберегти налаштування\"],\"UV5hLB\":[\"Заборон не знайдено\"],\"Uaj3Nd\":[\"Статусні повідомлення\"],\"Ue3uny\":[\"За замовчуванням (без профілю)\"],\"UkARhe\":[\"Нормальний - стандартний захист\"],\"Umn7Cj\":[\"Коментарів ще немає. Будьте першим!\"],\"UtUIRh\":[[\"0\"],\" старіших повідомлень\"],\"UwzP+U\":[\"Безпечне з'єднання\"],\"V0/A4O\":[\"Власник каналу\"],\"V4qgxE\":[\"Створено до (хв тому)\"],\"V8yTm6\":[\"Очистити пошук\"],\"VJMMyz\":[\"ObsidianIRC - Привносимо IRC у майбутнє\"],\"VJScHU\":[\"Причина\"],\"VLsmVV\":[\"Вимкнути сповіщення\"],\"VbyRUy\":[\"Коментарі\"],\"Vmx0mQ\":[\"Встановлено:\"],\"VqnIZz\":[\"Переглянути нашу політику конфіденційності та практики роботи з даними\"],\"VrMygG\":[\"Мінімальна довжина: \",[\"0\"]],\"VrnTui\":[\"Ваші займенники, відображені у вашому профілі\"],\"W8E3qn\":[\"Автентифікований акаунт\"],\"WAakm9\":[\"Видалити канал\"],\"WFxTHC\":[\"Додати маску бану (напр., nick!*@*, *!*@host.com)\"],\"WN1g9F\":[\"Хост сервера обов'язковий\"],\"WRYdXW\":[\"Позиція аудіо\"],\"WUOH5B\":[\"Ігнорувати користувача\"],\"WWEXnZ\":[[\"0\",\"plural\",{\"one\":[\"Показати ще 1 елемент\"],\"few\":[\"Показати ще \",[\"1\"],\" елементи\"],\"many\":[\"Показати ще \",[\"1\"],\" елементів\"],\"other\":[\"Показати ще \",[\"1\"],\" елементи\"]}]],\"Weq9zb\":[\"Загальне\"],\"Wfj7Sk\":[\"Вимкнути або увімкнути звуки сповіщень\"],\"Wm7gbG\":[\"GitHub:\"],\"WyeHWY\":[\"*спам*\"],\"WzMCru\":[\"Профіль користувача\"],\"X6S3lt\":[\"Пошук налаштувань, каналів, серверів...\"],\"XEHan5\":[\"Продовжити все одно\"],\"XI1+wb\":[\"Недійсний формат\"],\"XIXeuC\":[\"Повідомлення @\",[\"0\"]],\"XMS+k4\":[\"Почати приватне повідомлення\"],\"XWgxXq\":[\"Альбом\"],\"Xd7+IT\":[\"Відкріпити приватну розмову\"],\"Xm/s+u\":[\"Дисплей\"],\"Xp2n93\":[\"Показує медіа з надійного файлового хоста вашого сервера. Запити до зовнішніх сервісів не здійснюються.\"],\"XvjC4F\":[\"Збереження...\"],\"Y/qryO\":[\"Користувачів за вашим запитом не знайдено\"],\"YAqRpI\":[\"Реєстрація облікового запису \",[\"account\"],\" успішна: \",[\"message\"]],\"YEfzvP\":[\"Захищена тема (+t)\"],\"YQOn6a\":[\"Згорнути список учасників\"],\"YRCoE9\":[\"Оператор каналу\"],\"YURQaF\":[\"Переглянути профіль\"],\"YdBSvr\":[\"Керувати відображенням медіа та зовнішнього вмісту\"],\"Yj6U3V\":[\"Без центрального сервера:\"],\"YjvpGx\":[\"Займенники\"],\"YqH4l4\":[\"Без ключа\"],\"YyUPpV\":[\"Акаунт:\"],\"ZJSWfw\":[\"Повідомлення при від'єднанні від сервера\"],\"ZR1dJ4\":[\"Запрошення\"],\"ZdWg0V\":[\"Відкрити в браузері\"],\"ZhRBbl\":[\"Пошук повідомлень…\"],\"Zmcu3y\":[\"Розширені фільтри\"],\"a2/8e5\":[\"Тему встановлено після (хв тому)\"],\"aHKcKc\":[\"Попередня сторінка\"],\"aJTbXX\":[\"Пароль оператора\"],\"aQryQv\":[\"Шаблон вже існує\"],\"aW9pLN\":[\"Максимальна кількість користувачів у каналі. Залиште порожнім для відсутності обмежень.\"],\"ah4fmZ\":[\"Також показує попередній перегляд з YouTube, Vimeo, SoundCloud та подібних відомих сервісів.\"],\"aifXak\":[\"Немає медіа у цьому каналі\"],\"ap2zBz\":[\"Розслаблений\"],\"az8lvo\":[\"Вимк\"],\"azXSNo\":[\"Розгорнути список учасників\"],\"azdliB\":[\"Увійти в акаунт\"],\"b26wlF\":[\"вона/її\"],\"bD/+Ei\":[\"Строгий\"],\"bQ6BJn\":[\"Налаштуйте детальні правила захисту від флуду. Кожне правило вказує, яку активність відстежувати і які дії вживати при перевищенні порогів.\"],\"beV7+y\":[\"Користувач отримає запрошення приєднатися до \",[\"channelName\"],\".\"],\"bk84cH\":[\"Повідомлення про відсутність\"],\"bkHdLj\":[\"Додати IRC-сервер\"],\"bmQLn5\":[\"Додати правило\"],\"bv4cFj\":[\"Транспорт\"],\"bwRvnp\":[\"Дія\"],\"c8+EVZ\":[\"Підтверджений акаунт\"],\"cGYUlD\":[\"Медіа-перегляди не завантажені.\"],\"cLF98o\":[\"Показати коментарі (\",[\"commentCount\"],\")\"],\"cLKIDO\":[\"Користувачів немає\"],\"cSgpoS\":[\"Закріпити приватну розмову\"],\"cde3ce\":[\"Повідомлення <0>\",[\"0\"],\"\"],\"chQsxg\":[\"Скопіювати форматований вивід\"],\"cl/A5J\":[\"Ласкаво просимо до \",[\"__DEFAULT_IRC_SERVER_NAME__\"],\"!\"],\"cnGeoo\":[\"Видалити\"],\"coPLXT\":[\"Ми не зберігаємо ваші IRC-комунікації на наших серверах\"],\"crYH/6\":[\"Програвач SoundCloud\"],\"cv5DQb\":[\"хост не вказано\"],\"d3sis4\":[\"Додати сервер\"],\"d9aN5k\":[\"Видалити \",[\"username\"],\" з каналу\"],\"dEgA5A\":[\"Скасувати\"],\"dGi1We\":[\"Відкріпити цю приватну розмову\"],\"dJVuyC\":[\"покинув \",[\"channelName\"],\" (\",[\"reason\"],\")\"],\"dMtLDE\":[\"до\"],\"dXqxlh\":[\"<0>⚠️ Загроза безпеці! Це з'єднання може бути вразливим до перехоплення або атак «людина посередині».\"],\"da9Q/R\":[\"Змінено режими каналу\"],\"dhJN3N\":[\"Показати коментарі\"],\"dj2xTE\":[\"Відхилити сповіщення\"],\"dpCzmC\":[\"Налаштування захисту від флуду\"],\"e9dQpT\":[\"Бажаєте відкрити це посилання в новій вкладці?\"],\"ePK91l\":[\"Редагувати\"],\"eYBDuB\":[\"Завантажте зображення або вкажіть URL з необов'язковою підстановкою \",[\"size\"],\" для динамічного розміру\"],\"edBbee\":[\"Заблокувати \",[\"username\"],\" за маскою хоста (запобігає повторному входу з тієї ж IP/хоста)\"],\"ekfzWq\":[\"Налаштування користувача\"],\"elPDWs\":[\"Налаштуйте свій IRC-клієнт\"],\"eu2osY\":[\"<0>💡 Рекомендація: Продовжуйте лише якщо довіряєте цьому серверу і розумієте ризики. Уникайте передачі конфіденційної інформації або паролів через це з'єднання.\"],\"euEhbr\":[\"Натисніть, щоб приєднатися до \",[\"channel\"]],\"ez3vLd\":[\"Увімкнути багаторядкове введення\"],\"f0J5Ki\":[\"Зв'язок між серверами може використовувати незашифровані з'єднання\"],\"f9BHJk\":[\"Попередити користувача\"],\"fDOLLd\":[\"Канали не знайдено.\"],\"ffzDkB\":[\"Анонімна аналітика:\"],\"fq1GF9\":[\"Відображати, коли користувачі від'єднуються від сервера\"],\"gEF57C\":[\"Цей сервер підтримує лише один тип з'єднання\"],\"gJuLUI\":[\"Список ігнорування\"],\"gNzMrk\":[\"Поточний аватар\"],\"gjPWyO\":[\"Введіть нік...\"],\"gz6UQ3\":[\"Розгорнути\"],\"h6/IMX\":[\"Додайте свою першу мережу\"],\"h6razj\":[\"Маска виключення назви каналу\"],\"hG6jnw\":[\"Тема не встановлена\"],\"hG89Ed\":[\"Зображення\"],\"hZ6znB\":[\"Порт\"],\"ha+Bz5\":[\"напр., 100:1440\"],\"hehnjM\":[\"Кількість\"],\"hzdLuQ\":[\"Говорити можуть лише користувачі з голосом або вище\"],\"i0qMbr\":[\"Головна\"],\"iDNBZe\":[\"Сповіщення\"],\"iH8pgl\":[\"Назад\"],\"iL9SZg\":[\"Заблокувати користувача (за псевдонімом)\"],\"iNt+3c\":[\"Назад до зображення\"],\"iQvi+a\":[\"Не попереджати мене про низький рівень безпеки для цього сервера\"],\"iSLIjg\":[\"Підключитися\"],\"iWXkHH\":[\"Напів-оператор\"],\"iZeTtp\":[\"Хост сервера\"],\"idD8Ev\":[\"Збережено\"],\"iivqkW\":[\"Час входу\"],\"ij+Elv\":[\"Попередній перегляд зображення\"],\"ilIWp7\":[\"Перемкнути сповіщення\"],\"iuaqvB\":[\"Використовуйте * для шаблонів. Приклади: baduser!*@*, *!*@spammer.com, troll*!*@*\"],\"ixkTse\":[\"Бот\"],\"j2DGR0\":[\"Блокування за маскою хоста\"],\"jA4uoI\":[\"Тема:\"],\"jLXxGK\":[\"https://example.com\"],\"jPSk57\":[\"Причина (необов'язково)\"],\"jUV7CU\":[\"Завантажити аватар\"],\"jW5Uwh\":[\"Контролюйте завантаження зовнішніх медіа. Вимк / Безпечно / Надійні джерела / Весь вміст.\"],\"jXzms5\":[\"Параметри вкладення\"],\"jZlrte\":[\"Колір\"],\"jfC/xh\":[\"Контакт\"],\"jywMpv\":[\"#нова-назва-каналу\"],\"k112DD\":[\"Завантажити старіші повідомлення\"],\"k3ID0F\":[\"Фільтрувати учасників…\"],\"k65gsE\":[\"Детальний огляд\"],\"k7Zgob\":[\"Скасувати підключення\"],\"kAVx5h\":[\"Запрошень не знайдено\"],\"kCLEPU\":[\"Підключено до\"],\"kF5LKb\":[\"Ігноровані шаблони:\"],\"kGeOx/\":[\"Приєднатися до \",[\"0\"]],\"kITKr8\":[\"Завантаження режимів каналу...\"],\"kPpPsw\":[\"Ви є IRC-оператором\"],\"kWJmRL\":[\"Ви\"],\"kfcRb0\":[\"Аватар\"],\"kjMqSj\":[\"Скопіювати JSON\"],\"krViRy\":[\"Натисніть для копіювання як JSON\"],\"ks71ra\":[\"Винятки\"],\"kw4lRv\":[\"Напів-оператор каналу\"],\"kxgIRq\":[\"Виберіть або додайте канал для початку.\"],\"ky6dWe\":[\"Попередній перегляд аватара\"],\"l+GxCv\":[\"Завантаження каналів...\"],\"l+IUVW\":[\"Верифікація облікового запису \",[\"account\"],\" успішна: \",[\"message\"]],\"l/siQz\":[[\"reconnectCount\",\"plural\",{\"one\":[\"перепідключився\"],\"few\":[\"перепідключився \",[\"reconnectCount\"],\" рази\"],\"many\":[\"перепідключився \",[\"reconnectCount\"],\" разів\"],\"other\":[\"перепідключився \",[\"reconnectCount\"],\" рази\"]}]],\"l5jmzx\":[[\"0\"],\" та \",[\"1\"],\" пишуть...\"],\"lHy8N5\":[\"Завантаження більше каналів...\"],\"lbpf14\":[\"Приєднатися до \",[\"value\"]],\"lfFsZ4\":[\"Канали\"],\"lkNdiH\":[\"Ім'я акаунту\"],\"ln500L\":[\"ObsidianIRC\"],\"lnCMdg\":[\"Завантажити зображення\"],\"loQxaJ\":[\"Я повернувся\"],\"lvfaxv\":[\"ГОЛОВНА\"],\"m0oxpP\":[\"Libera Chat\"],\"m16xKo\":[\"Додати\"],\"m8flAk\":[\"Попередній перегляд (ще не завантажено)\"],\"mEPxTp\":[\"<0>⚠️ Будьте обережні! Відкривайте лише посилання з надійних джерел. Шкідливі посилання можуть порушити вашу безпеку або конфіденційність.\"],\"mHGdhG\":[\"Інформація про сервер\"],\"mHS8lb\":[\"Повідомлення #\",[\"0\"]],\"mMYBD9\":[\"Широкий - ширша область захисту\"],\"mTGsPd\":[\"Тема каналу\"],\"mU8j6O\":[\"Без зовнішніх повідомлень (+n)\"],\"mZp8FL\":[\"Автоматичне повернення до одного рядка\"],\"mdQu8G\":[\"ВашПсевдонім\"],\"miSSBQ\":[\"Коментарі (\",[\"commentCount\"],\")\"],\"mvyLSy\":[\"Користувач автентифікований\"],\"mwtcGl\":[\"Закрити коментарі\"],\"myL0MR\":[\"Видалити цю мережу?\"],\"mzI/c+\":[\"Завантажити\"],\"n3fGRk\":[\"встановлено \",[\"0\"]],\"nE9jsU\":[\"Розслаблений - менш агресивний захист\"],\"nNflMD\":[\"Покинути канал\"],\"nPXkBi\":[\"Завантаження даних WHOIS...\"],\"nQnxxF\":[\"Повідомлення #\",[\"0\"],\" (Shift+Enter для нового рядка)\"],\"nWMRxa\":[\"Відкріпити\"],\"nkC032\":[\"Без профілю флуду\"],\"o69z4d\":[\"Надіслати попереджувальне повідомлення \",[\"username\"]],\"o9ylQi\":[\"Знайдіть GIF для початку\"],\"oFGkER\":[\"Повідомлення сервера\"],\"oOi11l\":[\"Прокрутити вниз\"],\"oQEzQR\":[\"Нове DM\"],\"oXOSPE\":[\"Онлайн\"],\"oal760\":[\"Можливі атаки «людина посередині» на з'єднання сервера\"],\"oeqmmJ\":[\"Надійні джерела\"],\"ovBPCi\":[\"За замовчуванням\"],\"p0Z69r\":[\"Шаблон не може бути порожнім\"],\"p1KgtK\":[\"Не вдалося завантажити аудіо\"],\"p59pEv\":[\"Детальніше\"],\"p7sRI6\":[\"Повідомляти інших, коли ви друкуєте\"],\"pBm1od\":[\"Секретний канал\"],\"pNmiXx\":[\"Ваш псевдонім за замовчуванням для всіх серверів\"],\"pUUo9G\":[\"Хост:\"],\"pVGPmz\":[\"Пароль акаунту\"],\"peNE68\":[\"Постійний\"],\"plhHQt\":[\"Немає даних\"],\"pm6+q5\":[\"Попередження про безпеку\"],\"pn5qSs\":[\"Додаткова інформація\"],\"q0cR4S\":[\"тепер відомий як **\",[\"newNick\"],\"**\"],\"qFcunY\":[\"Канал не відображатиметься у командах LIST або NAMES\"],\"qLpTm/\":[\"Видалити реакцію \",[\"emoji\"]],\"qVkGWK\":[\"Закріпити\"],\"qY8wNa\":[\"Головна сторінка\"],\"qb0xJ7\":[\"Використовуйте шаблони: * відповідає будь-якій послідовності, ? відповідає будь-якому одному символу. Приклади: nick!*@*, *!*@host.com, *!*user@*\"],\"qhzpRq\":[\"Ключ каналу (+k)\"],\"qtoOYG\":[\"Без обмежень\"],\"r1W2AS\":[\"Зображення з файлового хостингу\"],\"rIPR2O\":[\"Тему встановлено до (хв тому)\"],\"rMMSYo\":[\"Максимальна довжина: \",[\"0\"]],\"rWtzQe\":[\"Мережа розділилась і возз'єдналась. ✅\"],\"rYG2u6\":[\"Будь ласка, зачекайте...\"],\"rdUucN\":[\"Попередній перегляд\"],\"rjGI/Q\":[\"Конфіденційність\"],\"rk8iDX\":[\"Завантаження GIF...\"],\"rn6SBY\":[\"Увімкнути звук\"],\"s/UKqq\":[\"Виключено з каналу\"],\"s8cATI\":[\"приєднався до \",[\"channelName\"]],\"sCO9ue\":[\"З'єднання з <0>\",[\"serverName\"],\" має такі проблеми безпеки:\"],\"sGH11W\":[\"Сервер\"],\"sHI1H+\":[\"тепер відомий як **\",[\"newNick\"],\"**\"],\"sJyV04\":[[\"inviter\"],\" запросив вас приєднатися до \",[\"channel\"]],\"sUBSbK\":[\"Поки що немає висхідних мереж.\"],\"sby+1/\":[\"Натисніть для копіювання\"],\"sfN25C\":[\"Ваше справжнє або повне ім'я\"],\"sliuzR\":[\"Відкрити посилання\"],\"sqrO9R\":[\"Власні згадки\"],\"sr6RdJ\":[\"Багаторядковий на Shift+Enter\"],\"swrCpB\":[\"Канал перейменовано з \",[\"oldName\"],\" на \",[\"newName\"],\" користувачем \",[\"user\"],[\"0\"]],\"sxkWRg\":[\"Додаткові\"],\"t/YqKh\":[\"Видалити\"],\"t47eHD\":[\"Ваш унікальний ідентифікатор на цьому сервері\"],\"tAkAh0\":[\"URL з необов'язковою підстановкою \",[\"size\"],\" для динамічного розміру. Приклад: https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"tXLJS3\":[\"Показати або приховати бічну панель зі списком каналів\"],\"tfDRzk\":[\"Зберегти\"],\"tiBsJk\":[\"покинув \",[\"channelName\"]],\"tt4/UD\":[\"вийшов (\",[\"reason\"],\")\"],\"u0TcnO\":[\"Псевдонім {nick} вже використовується, повторна спроба з {newNick}\"],\"u0a8B4\":[\"Автентифікуватися як IRC-оператор для адміністративного доступу\"],\"u0rWFU\":[\"Створено після (хв тому)\"],\"u72w3t\":[\"Користувачі та шаблони для ігнорування\"],\"u7jc2L\":[\"вийшов\"],\"uAQUqI\":[\"Статус\"],\"uB85T3\":[\"Помилка збереження: \",[\"msg\"]],\"uV3DOL\":[\"G-Line\"],\"uW3lLI\":[\"IRC сервери:\"],\"usSSr/\":[\"Рівень масштабу\"],\"v7uvcf\":[\"Програма:\"],\"vE8kb+\":[\"Використовуйте Shift+Enter для нового рядка (Enter надсилає)\"],\"vERlcd\":[\"Профіль\"],\"vK0RL8\":[\"Без теми\"],\"vSJd18\":[\"Відео\"],\"vXIe7J\":[\"Мова\"],\"vaHYxN\":[\"Справжнє ім'я\"],\"vhjbKr\":[\"Відсутній\"],\"w/nogd\":[[\"0\"],\" network\",[\"1\"],\" — pick one to join\"],\"w4NYox\":[\"клієнт \",[\"title\"]],\"w8xQRx\":[\"Недійсне значення\"],\"wFjjxZ\":[\"був кікнутий з \",[\"channelName\"],\" користувачем \",[\"username\"],\" (\",[\"reason\"],\")\"],\"wGjaGl\":[\"Винятків з заборон не знайдено\"],\"wPrGnM\":[\"Адміністратор каналу\"],\"wRkP2d\":[\"GIF\"],\"wbm86v\":[\"Відображати, коли користувачі входять або виходять з каналів\"],\"whqZ9r\":[\"Додаткові слова або фрази для виділення\"],\"wm7RV4\":[\"Звук сповіщення\"],\"wz/Yoq\":[\"Ваші повідомлення можуть бути перехоплені при передачі між серверами\"],\"xCJdfg\":[\"Очистити\"],\"xUHRTR\":[\"Автоматично автентифікуватися як оператор при підключенні\"],\"xWHwwQ\":[\"Блокування\"],\"xYilR2\":[\"Медіа\"],\"xceQrO\":[\"Підтримуються лише захищені websocket-з'єднання\"],\"xdtXa+\":[\"назва-каналу\"],\"xfXC7q\":[\"Текстові канали\"],\"xlCYOE\":[\"Отримання більше повідомлень...\"],\"xlhswE\":[\"Мінімальне значення: \",[\"0\"]],\"xq97Ci\":[\"Додати слово або фразу...\"],\"xuRqRq\":[\"Ліміт клієнтів (+l)\"],\"xwF+7J\":[[\"0\"],\" пише...\"],\"yJztBY\":[\"Видалити мережу\"],\"yNeucF\":[\"Цей сервер не підтримує розширені метадані профілю (розширення IRCv3 METADATA). Додаткові поля, такі як аватар, відображуване ім'я та статус, недоступні.\"],\"yPlrca\":[\"Аватар каналу\"],\"yQE2r9\":[\"Завантаження\"],\"ySU+JY\":[\"your@email.com\"],\"yTX1Rt\":[\"Ім'я оператора\"],\"yYOzWD\":[\"логи\"],\"yfx9Re\":[\"Пароль IRC оператора\"],\"ygCKqB\":[\"Зупинити\"],\"ymDxJx\":[\"Ім'я IRC оператора\"],\"yrpRsQ\":[\"Сортувати за назвою\"],\"yz7wBu\":[\"Закрити\"],\"zJw+jA\":[\"встановлює режим: \",[\"0\"]],\"zebeLu\":[\"Введіть ім'я оператора\"],\"zpr0Bw\":[\"GZ-Line\"]}"); \ No newline at end of file diff --git a/src/locales/uk/messages.po b/src/locales/uk/messages.po index 4934bade..175b91a7 100644 --- a/src/locales/uk/messages.po +++ b/src/locales/uk/messages.po @@ -22,6 +22,16 @@ msgstr "ObsidianIRC - Привносимо IRC у майбутнє" msgid "— open in viewer" msgstr "— відкрити у переглядачі" +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx +msgid "(inherit)" +msgstr "(успадковано)" + +#: src/components/ui/BouncerNetworkForm.tsx +msgid "(unchanged)" +msgstr "(без змін)" + #. placeholder {0}: filteredMessages.length #. placeholder {1}: import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import type * as React from "react"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { SCROLL_TOLERANCE, useScrollToBottom, } from "../../hooks/useScrollToBottom"; import { groupConsecutiveEvents } from "../../lib/eventGrouping"; import ircClient from "../../lib/ircClient"; import useStore from "../../store"; import type { Message as MessageType } from "../../types"; import { CollapsedEventMessage } from "../message/CollapsedEventMessage"; import { MessageItem } from "../message/MessageItem"; import LoadingSpinner from "../ui/LoadingSpinner"; import { ScrollToBottomButton } from "../ui/ScrollToBottomButton"; export const DEFAULT_VISIBLE_MESSAGE_COUNT = 100; // Stable empty array — prevents selector from returning a new [] on every render // when the channel has no messages yet (undefined ?? [] would create a new ref each time). const EMPTY_MESSAGES: import("../../types").Message[] = []; export interface ChannelMessageListHandle { setAtBottom: () => void; scrollToBottom: () => void; getScrollState: () => { scrollTop: number; isAtBottom: boolean; visibleCount: number; }; } interface ChannelMessageListProps { channelKey: string; serverId: string; channelId: string | null; privateChatId: string | null; isActive: boolean; searchQuery: string; isMemberListVisible: boolean; onReply: (msg: MessageType | null) => void; onUsernameContextMenu: ( e: React.MouseEvent, username: string, serverId: string, channelId: string, avatarEl?: Element | null, ) => void; onIrcLinkClick: (url: string) => void; onReactClick: (msg: MessageType, el: Element) => void; onReactionUnreact: (emoji: string, msg: MessageType) => void; onOpenReactionModal: ( msg: MessageType, position: { x: number; y: number }, ) => void; onDirectReaction: (emoji: string, msg: MessageType) => void; onRedactMessage: (msg: MessageType) => void; onOpenProfile: (username: string) => void; joinChannel: (serverId: string, channelName: string) => void; onClearSearch: () => void; highlightedMessageId?: string; // undefined = first visit; null = was at bottom; object = restore to saved position initialScrollState?: { scrollTop: number; visibleCount: number } | null; } export const ChannelMessageList = forwardRef< ChannelMessageListHandle, ChannelMessageListProps >( ( { channelKey, serverId, channelId, privateChatId, isActive, searchQuery, isMemberListVisible, onReply, onUsernameContextMenu, onIrcLinkClick, onReactClick, onReactionUnreact, onOpenReactionModal, onDirectReaction, onRedactMessage, onOpenProfile, joinChannel, onClearSearch, highlightedMessageId, initialScrollState, }, ref, ) => { const { t } = useLingui(); const [visibleMessageCount, setVisibleMessageCount] = useState( initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT, ); // Ref mirror so getScrollState closure always reads the current value without needing it as a dep. const visibleMessageCountRef = useRef(visibleMessageCount); visibleMessageCountRef.current = visibleMessageCount; // Distinguishes initial join (full-screen spinner) from subsequent "load more" (button spinner). const [isFetchingMore, setIsFetchingMore] = useState(false); const isFetchingMoreRef = useRef(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const messagesInnerRef = useRef(null); // prev scrollHeight for prepend delta-correction. const prevScrollHeightRef = useRef(0); // Ref mirror of isScrolledUp — lets useLayoutEffect closures read current value // without listing isScrolledUp as a dep (which would re-run effects on every scroll). const isScrolledUpRef = useRef(false); const prevFilteredLengthRef = useRef(0); const prevFirstMsgIdRef = useRef(null); // Set by the window-growth layoutEffect (or button handler) when a true prepend is detected. // Consumed by the delta-correction layoutEffect one render later (after visibleCount grows). // Using a flag instead of tracking displayedMessages[0]?.id because slice(-N) slides the // window on every bottom append, changing displayedMessages[0] even for non-prepend renders. const pendingPrependRef = useRef(false); // Shared scrollHeight baseline between the delta-correction layout effect and the inner // ResizeObserver. When scrollTop is corrected after a prepend, we update this so the RO's // "was at bottom" check is not fooled by the adjusted scrollTop vs its stale prevSH. const resizeObserverPrevSHRef = useRef(0); const channelMessages = useStore( useCallback( (state) => state.messages[channelKey] ?? EMPTY_MESSAGES, [channelKey], ), ); const servers = useStore((state) => state.servers); const mobileViewActiveColumn = useStore( (state) => state.ui.mobileViewActiveColumn, ); const channel = useMemo( () => channelId ? (servers .find((s) => s.id === serverId) ?.channels.find((c) => c.id === channelId) ?? null) : null, [servers, serverId, channelId], ); const { isScrolledUp, wasAtBottomRef, scrollToBottom } = useScrollToBottom( messagesContainerRef, messagesEndRef, { channelId: `${channelId || privateChatId}-${isMemberListVisible}` }, ); // Snapshot of the last known scroll position captured while the container was visible. // getScrollState() reads this instead of the live DOM because React commits display:none // before running cleanup effects, collapsing scrollTop/scrollHeight/clientHeight to 0. const lastScrollTopRef = useRef(initialScrollState?.scrollTop ?? 0); useEffect(() => { const container = messagesContainerRef.current; if (!container) return; const onScroll = () => { if (container.clientHeight > 0) lastScrollTopRef.current = container.scrollTop; }; container.addEventListener("scroll", onScroll, { passive: true }); return () => container.removeEventListener("scroll", onScroll); }, []); // Restore scroll position when a keep-alive channel transitions from hidden to visible. // display:none may reset scrollTop to 0; lastScrollTopRef was captured while visible. const prevActiveRef = useRef(isActive); useLayoutEffect(() => { if (isActive && !prevActiveRef.current) { const container = messagesContainerRef.current; if (container && lastScrollTopRef.current > 0) { container.scrollTop = lastScrollTopRef.current; } } prevActiveRef.current = isActive; }, [isActive]); useImperativeHandle(ref, () => ({ setAtBottom: () => { wasAtBottomRef.current = true; }, scrollToBottom, getScrollState: () => ({ scrollTop: lastScrollTopRef.current, isAtBottom: wasAtBottomRef.current, visibleCount: visibleMessageCountRef.current, }), })); const filteredMessages = useMemo(() => { if (!searchQuery.trim()) return channelMessages; const query = searchQuery.toLowerCase(); return channelMessages.filter( (msg) => msg.content.toLowerCase().includes(query) || msg.userId.toLowerCase().includes(query), ); }, [channelMessages, searchQuery]); useEffect(() => { isScrolledUpRef.current = isScrolledUp; // When the user returns to the bottom, shrink the window back to the base so // slice(-N) resumes trimming old messages from the top (memory optimization). // Only shrink if we grew above the base — preserves a sub-default saved visibleCount. if (!isScrolledUp) { setVisibleMessageCount((prev) => prev > DEFAULT_VISIBLE_MESSAGE_COUNT ? DEFAULT_VISIBLE_MESSAGE_COUNT : prev, ); } }, [isScrolledUp]); // Reset ref-tracked windowing state when switching channels. // visibleMessageCount is NOT reset here — useState(initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT) // already initializes it correctly on mount, and this effect runs once on mount for the // same channelKey (each instance is bound to exactly one channel by the parent key={}). // biome-ignore lint/correctness/useExhaustiveDependencies: intentional full reset on channel change useEffect(() => { prevFilteredLengthRef.current = 0; prevFirstMsgIdRef.current = null; prevScrollHeightRef.current = 0; pendingPrependRef.current = false; resizeObserverPrevSHRef.current = 0; }, [channelKey]); const displayedMessages = useMemo(() => { if (searchQuery.trim()) return filteredMessages; return filteredMessages.slice(-visibleMessageCount); }, [filteredMessages, visibleMessageCount, searchQuery]); const locallyHidden = filteredMessages.length > displayedMessages.length; const serverHasMore = channel?.hasMoreHistory === true; const hasMoreMessages = locallyHidden || serverHasMore; const eventGroups = useMemo( () => groupConsecutiveEvents(displayedMessages), [displayedMessages], ); const isLoadingHistory = channel?.isLoadingHistory ?? false; // Scroll to bottom on initial mount, unless a saved position was passed in. // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount only useEffect(() => { const container = messagesContainerRef.current; if (!container) return; if (initialScrollState) { container.scrollTop = initialScrollState.scrollTop; lastScrollTopRef.current = initialScrollState.scrollTop; wasAtBottomRef.current = false; } else { container.scrollTop = container.scrollHeight; lastScrollTopRef.current = container.scrollHeight; wasAtBottomRef.current = true; } }, []); // Scroll to bottom after initial join history loads; clear fetch spinner at batch end. const wasLoadingHistoryRef = useRef(false); // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is stable via useCallback; refs and setters are stable useLayoutEffect(() => { if (wasLoadingHistoryRef.current && !isLoadingHistory) { if (isFetchingMoreRef.current) { // delta correction for scroll position is handled by useLayoutEffect([displayedMessages]) isFetchingMoreRef.current = false; setIsFetchingMore(false); } else { scrollToBottom(); wasAtBottomRef.current = true; } } wasLoadingHistoryRef.current = isLoadingHistory; }, [isLoadingHistory]); // When older messages are prepended, grow the window so they enter displayedMessages. // When new messages arrive at the bottom while the user is scrolled up, also grow the // window to keep the current top messages visible — slice(-N) otherwise slides the // window forward and hides them, incrementing the "N older messages" counter on every // incoming message. Only let the slice trim from the top when the user is at the bottom // (where auto-scroll handles keeping them current). useLayoutEffect(() => { const newLength = filteredMessages.length; const newFirstId = filteredMessages[0]?.id ?? null; const delta = newLength - prevFilteredLengthRef.current; if (prevFilteredLengthRef.current > 0 && delta > 0) { if (newFirstId !== prevFirstMsgIdRef.current) { // Messages prepended (load-more): signal delta-correction to compensate scrollTop. pendingPrependRef.current = true; setVisibleMessageCount((prev) => prev + delta); } else if (isScrolledUpRef.current) { // Messages appended at bottom while user is scrolled up reading history. // Expand the window to prevent top messages from dropping out of the slice. setVisibleMessageCount((prev) => prev + delta); } } prevFilteredLengthRef.current = newLength; prevFirstMsgIdRef.current = newFirstId; }, [filteredMessages]); // Compensate scrollTop when content is prepended above the viewport. // biome-ignore lint/correctness/useExhaustiveDependencies: runs on every displayedMessages render to capture the resulting scrollHeight; refs are stable useLayoutEffect(() => { const container = messagesContainerRef.current; if (!container) return; // Skip while container is display:none — scrollHeight collapses to 0 and would // poison prevScrollHeightRef, causing a huge spurious delta on the next visible render. if (container.clientHeight === 0) return; const prevHeight = prevScrollHeightRef.current; const newHeight = container.scrollHeight; // Only correct when a true load-more prepend happened (flag set by the window-growth // layoutEffect or button handler). Bottom appends slide the slice(-N) window which also // changes displayedMessages[0] — ID-comparison can't distinguish the two cases. const wasPrepend = pendingPrependRef.current; // Only consume the flag when scrollHeight actually changed — the server-side load-more // path goes through two renders: Render A (filteredMessages grows, visibleCount unchanged, // same displayedMessages content, same scrollHeight) then Render B (visibleCount grows, // new messages enter displayedMessages, scrollHeight grows). The flag must survive Render A // so it's still set when Render B fires the actual correction. if (wasPrepend && newHeight !== prevHeight) { pendingPrependRef.current = false; } if ( isScrolledUpRef.current && prevHeight > 0 && newHeight > prevHeight && wasPrepend ) { const delta = newHeight - prevHeight; container.scrollTop += delta; resizeObserverPrevSHRef.current = newHeight; } prevScrollHeightRef.current = newHeight; }, [displayedMessages]); // Re-stick to bottom when inner message content grows (media/audio previews loading). // Uses prevScrollHeight instead of wasAtBottomRef to avoid stale-flag race where the // ref is true while the user is actively scrolling up. // When the container width changes (member list toggle, window resize), text reflows // and scrollHeight changes; preserve proportional scroll position for scrolled-up users. // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is a stable ref useEffect(() => { const container = messagesContainerRef.current; const inner = messagesInnerRef.current; if (!inner || !container) return; resizeObserverPrevSHRef.current = container.scrollHeight; let prevClientWidth = container.clientWidth; const observer = new ResizeObserver(() => { if (container.clientHeight === 0) return; // Effect may re-initialize while container is display:none (ref=0). // Re-seed with current dimensions and skip — no reliable "was at bottom" data. if (resizeObserverPrevSHRef.current === 0) { resizeObserverPrevSHRef.current = container.scrollHeight; prevClientWidth = container.clientWidth; return; } const currentClientWidth = container.clientWidth; const widthChanged = currentClientWidth !== prevClientWidth; prevClientWidth = currentClientWidth; const prevSH = resizeObserverPrevSHRef.current; const wasAtPrevBottom = container.scrollTop + container.clientHeight >= prevSH - SCROLL_TOLERANCE; resizeObserverPrevSHRef.current = container.scrollHeight; if (wasAtPrevBottom) { scrollToBottom(); } else if (widthChanged && prevSH > 0) { const ratio = container.scrollTop / prevSH; container.scrollTop = Math.round(ratio * container.scrollHeight); } }); observer.observe(inner); return () => observer.disconnect(); }, [isLoadingHistory, channelId, privateChatId]); // Auto-scroll on new messages — skip when this channel is hidden (display:none). // biome-ignore lint/correctness/useExhaustiveDependencies: only scroll when messages change, not when isActive changes useEffect(() => { if (!isActive) return; const isNarrowView = window.matchMedia("(max-width: 768px)").matches; const isChatVisible = !isNarrowView || mobileViewActiveColumn === "chatView"; if (wasAtBottomRef.current && isChatVisible) { scrollToBottom(); } }, [displayedMessages, mobileViewActiveColumn, scrollToBottom, isActive]); return ( <>
{isLoadingHistory && !isFetchingMore ? (
) : (
{hasMoreMessages && !searchQuery && (
)} {searchQuery && (
{plural(filteredMessages.length, { one: t`Found 1 message matching "${searchQuery}"`, other: t`Found ${filteredMessages.length} messages matching "${searchQuery}"`, })}
)} {eventGroups.map((group) => { if (group.type === "eventGroup") { const firstId = group.messages[0]?.id || ""; const lastId = group.messages[group.messages.length - 1]?.id || ""; const groupKey = `group-${firstId}-${lastId}`; return ( ); } const message = group.messages[0]; const originalIndex = channelMessages.findIndex( (m) => m.id === message.id, ); const previousMessage = channelMessages[originalIndex - 1]; const showHeader = !previousMessage || previousMessage.type !== "message" || previousMessage.userId !== message.userId || new Date(message.timestamp).getTime() - new Date(previousMessage.timestamp).getTime() > 5 * 60 * 1000; return ( ); })}
)}
); }, ); ChannelMessageList.displayName = "ChannelMessageList"; // Wrap with memo so hidden keep-alive channels skip re-renders when their props // haven't changed (e.g. when messageText changes in the input — the only thing // that changes on typing is local state inside ChatArea, not the props we pass here). export const MemoChannelMessageList = memo(ChannelMessageList); #. placeholder {2}: import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import type * as React from "react"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { SCROLL_TOLERANCE, useScrollToBottom, } from "../../hooks/useScrollToBottom"; import { groupConsecutiveEvents } from "../../lib/eventGrouping"; import ircClient from "../../lib/ircClient"; import useStore from "../../store"; import type { Message as MessageType } from "../../types"; import { CollapsedEventMessage } from "../message/CollapsedEventMessage"; import { MessageItem } from "../message/MessageItem"; import LoadingSpinner from "../ui/LoadingSpinner"; import { ScrollToBottomButton } from "../ui/ScrollToBottomButton"; export const DEFAULT_VISIBLE_MESSAGE_COUNT = 100; // Stable empty array — prevents selector from returning a new [] on every render // when the channel has no messages yet (undefined ?? [] would create a new ref each time). const EMPTY_MESSAGES: import("../../types").Message[] = []; export interface ChannelMessageListHandle { setAtBottom: () => void; scrollToBottom: () => void; getScrollState: () => { scrollTop: number; isAtBottom: boolean; visibleCount: number; }; } interface ChannelMessageListProps { channelKey: string; serverId: string; channelId: string | null; privateChatId: string | null; isActive: boolean; searchQuery: string; isMemberListVisible: boolean; onReply: (msg: MessageType | null) => void; onUsernameContextMenu: ( e: React.MouseEvent, username: string, serverId: string, channelId: string, avatarEl?: Element | null, ) => void; onIrcLinkClick: (url: string) => void; onReactClick: (msg: MessageType, el: Element) => void; onReactionUnreact: (emoji: string, msg: MessageType) => void; onOpenReactionModal: ( msg: MessageType, position: { x: number; y: number }, ) => void; onDirectReaction: (emoji: string, msg: MessageType) => void; onRedactMessage: (msg: MessageType) => void; onOpenProfile: (username: string) => void; joinChannel: (serverId: string, channelName: string) => void; onClearSearch: () => void; highlightedMessageId?: string; // undefined = first visit; null = was at bottom; object = restore to saved position initialScrollState?: { scrollTop: number; visibleCount: number } | null; } export const ChannelMessageList = forwardRef< ChannelMessageListHandle, ChannelMessageListProps >( ( { channelKey, serverId, channelId, privateChatId, isActive, searchQuery, isMemberListVisible, onReply, onUsernameContextMenu, onIrcLinkClick, onReactClick, onReactionUnreact, onOpenReactionModal, onDirectReaction, onRedactMessage, onOpenProfile, joinChannel, onClearSearch, highlightedMessageId, initialScrollState, }, ref, ) => { const { t } = useLingui(); const [visibleMessageCount, setVisibleMessageCount] = useState( initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT, ); // Ref mirror so getScrollState closure always reads the current value without needing it as a dep. const visibleMessageCountRef = useRef(visibleMessageCount); visibleMessageCountRef.current = visibleMessageCount; // Distinguishes initial join (full-screen spinner) from subsequent "load more" (button spinner). const [isFetchingMore, setIsFetchingMore] = useState(false); const isFetchingMoreRef = useRef(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const messagesInnerRef = useRef(null); // prev scrollHeight for prepend delta-correction. const prevScrollHeightRef = useRef(0); // Ref mirror of isScrolledUp — lets useLayoutEffect closures read current value // without listing isScrolledUp as a dep (which would re-run effects on every scroll). const isScrolledUpRef = useRef(false); const prevFilteredLengthRef = useRef(0); const prevFirstMsgIdRef = useRef(null); // Set by the window-growth layoutEffect (or button handler) when a true prepend is detected. // Consumed by the delta-correction layoutEffect one render later (after visibleCount grows). // Using a flag instead of tracking displayedMessages[0]?.id because slice(-N) slides the // window on every bottom append, changing displayedMessages[0] even for non-prepend renders. const pendingPrependRef = useRef(false); // Shared scrollHeight baseline between the delta-correction layout effect and the inner // ResizeObserver. When scrollTop is corrected after a prepend, we update this so the RO's // "was at bottom" check is not fooled by the adjusted scrollTop vs its stale prevSH. const resizeObserverPrevSHRef = useRef(0); const channelMessages = useStore( useCallback( (state) => state.messages[channelKey] ?? EMPTY_MESSAGES, [channelKey], ), ); const servers = useStore((state) => state.servers); const mobileViewActiveColumn = useStore( (state) => state.ui.mobileViewActiveColumn, ); const channel = useMemo( () => channelId ? (servers .find((s) => s.id === serverId) ?.channels.find((c) => c.id === channelId) ?? null) : null, [servers, serverId, channelId], ); const { isScrolledUp, wasAtBottomRef, scrollToBottom } = useScrollToBottom( messagesContainerRef, messagesEndRef, { channelId: `${channelId || privateChatId}-${isMemberListVisible}` }, ); // Snapshot of the last known scroll position captured while the container was visible. // getScrollState() reads this instead of the live DOM because React commits display:none // before running cleanup effects, collapsing scrollTop/scrollHeight/clientHeight to 0. const lastScrollTopRef = useRef(initialScrollState?.scrollTop ?? 0); useEffect(() => { const container = messagesContainerRef.current; if (!container) return; const onScroll = () => { if (container.clientHeight > 0) lastScrollTopRef.current = container.scrollTop; }; container.addEventListener("scroll", onScroll, { passive: true }); return () => container.removeEventListener("scroll", onScroll); }, []); // Restore scroll position when a keep-alive channel transitions from hidden to visible. // display:none may reset scrollTop to 0; lastScrollTopRef was captured while visible. const prevActiveRef = useRef(isActive); useLayoutEffect(() => { if (isActive && !prevActiveRef.current) { const container = messagesContainerRef.current; if (container && lastScrollTopRef.current > 0) { container.scrollTop = lastScrollTopRef.current; } } prevActiveRef.current = isActive; }, [isActive]); useImperativeHandle(ref, () => ({ setAtBottom: () => { wasAtBottomRef.current = true; }, scrollToBottom, getScrollState: () => ({ scrollTop: lastScrollTopRef.current, isAtBottom: wasAtBottomRef.current, visibleCount: visibleMessageCountRef.current, }), })); const filteredMessages = useMemo(() => { if (!searchQuery.trim()) return channelMessages; const query = searchQuery.toLowerCase(); return channelMessages.filter( (msg) => msg.content.toLowerCase().includes(query) || msg.userId.toLowerCase().includes(query), ); }, [channelMessages, searchQuery]); useEffect(() => { isScrolledUpRef.current = isScrolledUp; // When the user returns to the bottom, shrink the window back to the base so // slice(-N) resumes trimming old messages from the top (memory optimization). // Only shrink if we grew above the base — preserves a sub-default saved visibleCount. if (!isScrolledUp) { setVisibleMessageCount((prev) => prev > DEFAULT_VISIBLE_MESSAGE_COUNT ? DEFAULT_VISIBLE_MESSAGE_COUNT : prev, ); } }, [isScrolledUp]); // Reset ref-tracked windowing state when switching channels. // visibleMessageCount is NOT reset here — useState(initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT) // already initializes it correctly on mount, and this effect runs once on mount for the // same channelKey (each instance is bound to exactly one channel by the parent key={}). // biome-ignore lint/correctness/useExhaustiveDependencies: intentional full reset on channel change useEffect(() => { prevFilteredLengthRef.current = 0; prevFirstMsgIdRef.current = null; prevScrollHeightRef.current = 0; pendingPrependRef.current = false; resizeObserverPrevSHRef.current = 0; }, [channelKey]); const displayedMessages = useMemo(() => { if (searchQuery.trim()) return filteredMessages; return filteredMessages.slice(-visibleMessageCount); }, [filteredMessages, visibleMessageCount, searchQuery]); const locallyHidden = filteredMessages.length > displayedMessages.length; const serverHasMore = channel?.hasMoreHistory === true; const hasMoreMessages = locallyHidden || serverHasMore; const eventGroups = useMemo( () => groupConsecutiveEvents(displayedMessages), [displayedMessages], ); const isLoadingHistory = channel?.isLoadingHistory ?? false; // Scroll to bottom on initial mount, unless a saved position was passed in. // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount only useEffect(() => { const container = messagesContainerRef.current; if (!container) return; if (initialScrollState) { container.scrollTop = initialScrollState.scrollTop; lastScrollTopRef.current = initialScrollState.scrollTop; wasAtBottomRef.current = false; } else { container.scrollTop = container.scrollHeight; lastScrollTopRef.current = container.scrollHeight; wasAtBottomRef.current = true; } }, []); // Scroll to bottom after initial join history loads; clear fetch spinner at batch end. const wasLoadingHistoryRef = useRef(false); // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is stable via useCallback; refs and setters are stable useLayoutEffect(() => { if (wasLoadingHistoryRef.current && !isLoadingHistory) { if (isFetchingMoreRef.current) { // delta correction for scroll position is handled by useLayoutEffect([displayedMessages]) isFetchingMoreRef.current = false; setIsFetchingMore(false); } else { scrollToBottom(); wasAtBottomRef.current = true; } } wasLoadingHistoryRef.current = isLoadingHistory; }, [isLoadingHistory]); // When older messages are prepended, grow the window so they enter displayedMessages. // When new messages arrive at the bottom while the user is scrolled up, also grow the // window to keep the current top messages visible — slice(-N) otherwise slides the // window forward and hides them, incrementing the "N older messages" counter on every // incoming message. Only let the slice trim from the top when the user is at the bottom // (where auto-scroll handles keeping them current). useLayoutEffect(() => { const newLength = filteredMessages.length; const newFirstId = filteredMessages[0]?.id ?? null; const delta = newLength - prevFilteredLengthRef.current; if (prevFilteredLengthRef.current > 0 && delta > 0) { if (newFirstId !== prevFirstMsgIdRef.current) { // Messages prepended (load-more): signal delta-correction to compensate scrollTop. pendingPrependRef.current = true; setVisibleMessageCount((prev) => prev + delta); } else if (isScrolledUpRef.current) { // Messages appended at bottom while user is scrolled up reading history. // Expand the window to prevent top messages from dropping out of the slice. setVisibleMessageCount((prev) => prev + delta); } } prevFilteredLengthRef.current = newLength; prevFirstMsgIdRef.current = newFirstId; }, [filteredMessages]); // Compensate scrollTop when content is prepended above the viewport. // biome-ignore lint/correctness/useExhaustiveDependencies: runs on every displayedMessages render to capture the resulting scrollHeight; refs are stable useLayoutEffect(() => { const container = messagesContainerRef.current; if (!container) return; // Skip while container is display:none — scrollHeight collapses to 0 and would // poison prevScrollHeightRef, causing a huge spurious delta on the next visible render. if (container.clientHeight === 0) return; const prevHeight = prevScrollHeightRef.current; const newHeight = container.scrollHeight; // Only correct when a true load-more prepend happened (flag set by the window-growth // layoutEffect or button handler). Bottom appends slide the slice(-N) window which also // changes displayedMessages[0] — ID-comparison can't distinguish the two cases. const wasPrepend = pendingPrependRef.current; // Only consume the flag when scrollHeight actually changed — the server-side load-more // path goes through two renders: Render A (filteredMessages grows, visibleCount unchanged, // same displayedMessages content, same scrollHeight) then Render B (visibleCount grows, // new messages enter displayedMessages, scrollHeight grows). The flag must survive Render A // so it's still set when Render B fires the actual correction. if (wasPrepend && newHeight !== prevHeight) { pendingPrependRef.current = false; } if ( isScrolledUpRef.current && prevHeight > 0 && newHeight > prevHeight && wasPrepend ) { const delta = newHeight - prevHeight; container.scrollTop += delta; resizeObserverPrevSHRef.current = newHeight; } prevScrollHeightRef.current = newHeight; }, [displayedMessages]); // Re-stick to bottom when inner message content grows (media/audio previews loading). // Uses prevScrollHeight instead of wasAtBottomRef to avoid stale-flag race where the // ref is true while the user is actively scrolling up. // When the container width changes (member list toggle, window resize), text reflows // and scrollHeight changes; preserve proportional scroll position for scrolled-up users. // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is a stable ref useEffect(() => { const container = messagesContainerRef.current; const inner = messagesInnerRef.current; if (!inner || !container) return; resizeObserverPrevSHRef.current = container.scrollHeight; let prevClientWidth = container.clientWidth; const observer = new ResizeObserver(() => { if (container.clientHeight === 0) return; // Effect may re-initialize while container is display:none (ref=0). // Re-seed with current dimensions and skip — no reliable "was at bottom" data. if (resizeObserverPrevSHRef.current === 0) { resizeObserverPrevSHRef.current = container.scrollHeight; prevClientWidth = container.clientWidth; return; } const currentClientWidth = container.clientWidth; const widthChanged = currentClientWidth !== prevClientWidth; prevClientWidth = currentClientWidth; const prevSH = resizeObserverPrevSHRef.current; const wasAtPrevBottom = container.scrollTop + container.clientHeight >= prevSH - SCROLL_TOLERANCE; resizeObserverPrevSHRef.current = container.scrollHeight; if (wasAtPrevBottom) { scrollToBottom(); } else if (widthChanged && prevSH > 0) { const ratio = container.scrollTop / prevSH; container.scrollTop = Math.round(ratio * container.scrollHeight); } }); observer.observe(inner); return () => observer.disconnect(); }, [isLoadingHistory, channelId, privateChatId]); // Auto-scroll on new messages — skip when this channel is hidden (display:none). // biome-ignore lint/correctness/useExhaustiveDependencies: only scroll when messages change, not when isActive changes useEffect(() => { if (!isActive) return; const isNarrowView = window.matchMedia("(max-width: 768px)").matches; const isChatVisible = !isNarrowView || mobileViewActiveColumn === "chatView"; if (wasAtBottomRef.current && isChatVisible) { scrollToBottom(); } }, [displayedMessages, mobileViewActiveColumn, scrollToBottom, isActive]); return ( <>
{isLoadingHistory && !isFetchingMore ? (
) : (
{hasMoreMessages && !searchQuery && (
)} {searchQuery && (
{plural(filteredMessages.length, { one: t`Found 1 message matching "${searchQuery}"`, other: t`Found ${filteredMessages.length} messages matching "${searchQuery}"`, })}
)} {eventGroups.map((group) => { if (group.type === "eventGroup") { const firstId = group.messages[0]?.id || ""; const lastId = group.messages[group.messages.length - 1]?.id || ""; const groupKey = `group-${firstId}-${lastId}`; return ( ); } const message = group.messages[0]; const originalIndex = channelMessages.findIndex( (m) => m.id === message.id, ); const previousMessage = channelMessages[originalIndex - 1]; const showHeader = !previousMessage || previousMessage.type !== "message" || previousMessage.userId !== message.userId || new Date(message.timestamp).getTime() - new Date(previousMessage.timestamp).getTime() > 5 * 60 * 1000; return ( ); })}
)}
); }, ); ChannelMessageList.displayName = "ChannelMessageList"; // Wrap with memo so hidden keep-alive channels skip re-renders when their props // haven't changed (e.g. when messageText changes in the input — the only thing // that changes on typing is local state inside ChatArea, not the props we pass here). export const MemoChannelMessageList = memo(ChannelMessageList); @@ -46,6 +56,12 @@ msgstr "{0} та {1} пишуть..." msgid "{0} is typing..." msgstr "{0} пише..." +#. placeholder {0}: networks.length +#. placeholder {1}: networks.length === 1 ? "" : "s" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "{0} network{1} — pick one to join" +msgstr "" + #. placeholder {0}: filteredMessages.length - displayedMessages.length #: src/components/layout/ChannelMessageList.tsx msgid "{0} older messages" @@ -186,6 +202,12 @@ msgstr "Додати маску запрошення (напр., nick!*@*, *!*@h msgid "Add IRC Server" msgstr "Додати IRC-сервер" +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworksPanel.tsx +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Add Network" +msgstr "Додати мережу" + #: src/components/message/MessageActions.tsx #: src/components/message/MessageReactions.tsx #: src/components/message/MessageReactions.tsx @@ -205,6 +227,10 @@ msgstr "Додати правило" msgid "Add Server" msgstr "Додати сервер" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Add your first network" +msgstr "Додайте свою першу мережу" + #: src/components/message/JsonLogMessage.tsx msgid "Additional Details" msgstr "Детальніше" @@ -358,6 +384,10 @@ msgstr "Назад" msgid "Back to image" msgstr "Назад до зображення" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Back to network list" +msgstr "" + #: src/components/ui/ModerationModal.tsx msgid "Ban {username} by hostmask (prevents them from rejoining from the same IP/host)" msgstr "Заблокувати {username} за маскою хоста (запобігає повторному входу з тієї ж IP/хоста)" @@ -405,6 +435,8 @@ msgstr "Переглянути всі канали на сервері" #: src/components/ui/AddPrivateChatModal.tsx #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/ChannelSettingsModal.tsx #: src/components/ui/ExternalLinkWarningModal.tsx #: src/components/ui/FloodSettingsModal.tsx @@ -640,6 +672,7 @@ msgid "Configure notification sounds and highlights" msgstr "Налаштувати звуки сповіщень та виділення" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworksPanel.tsx msgid "Connect" msgstr "Підключитися" @@ -759,6 +792,10 @@ msgstr "Видалити канал" msgid "Delete message" msgstr "Видалити повідомлення" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Delete network" +msgstr "Видалити мережу" + #: src/components/layout/ChannelList.tsx msgid "Delete Private Chat" msgstr "Видалити приватний чат" @@ -767,6 +804,10 @@ msgstr "Видалити приватний чат" msgid "Delete this message? This cannot be undone." msgstr "Видалити це повідомлення? Цю дію неможливо скасувати." +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Delete this network?" +msgstr "Видалити цю мережу?" + #: src/components/layout/ServerList.tsx #: src/components/mobile/ServerBottomSheet.tsx msgid "Disconnect" @@ -830,10 +871,16 @@ msgstr "Завантажити" msgid "e.g., 100:1440" msgstr "напр., 100:1440" +#: src/components/ui/BouncerNetworksPanel.tsx #: src/components/ui/ChannelSettingsModal.tsx msgid "Edit" msgstr "Редагувати" +#. placeholder {0}: editingNetwork?.attributes.name || editingNetwork?.netid +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Edit {0}" +msgstr "Редагувати {0}" + #: src/components/ui/UserProfileModal.tsx msgid "Edit Profile" msgstr "Редагувати профіль" @@ -1057,6 +1104,7 @@ msgstr "ГОЛОВНА" msgid "Homepage" msgstr "Головна сторінка" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserProfileModal.tsx msgid "Host" msgstr "Хост" @@ -1271,6 +1319,10 @@ msgstr "Покинув канал" msgid "Let others know when you are typing" msgstr "Повідомляти інших, коли ви друкуєте" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Libera Chat" +msgstr "Libera Chat" + #: src/components/message/LinkPreview.tsx msgid "Link preview" msgstr "Попередній перегляд посилання" @@ -1299,6 +1351,10 @@ msgstr "Завантаження GIF..." msgid "Loading more channels..." msgstr "Завантаження більше каналів..." +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Loading networks from your bouncer…" +msgstr "Завантаження мереж з вашого баунсера…" + #: src/components/ui/UserProfileModal.tsx msgid "Loading WHOIS data..." msgstr "Завантаження даних WHOIS..." @@ -1486,9 +1542,15 @@ msgid "Name:" msgstr "Ім'я:" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx msgid "Network Name" msgstr "Назва мережі" +#. placeholder {0}: server?.name ?? bouncerServerId +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Networks on {0}" +msgstr "Мережі на {0}" + #: src/components/ui/QuickActions.tsx msgid "New DM" msgstr "Нове DM" @@ -1511,6 +1573,7 @@ msgid "nick!user@host (e.g., spam*!*@*, *!*@badhost.com)" msgstr "nick!user@host (напр., spam*!*@*, *!*@badhost.com)" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserSettings.tsx #: src/lib/settings/definitions/allSettings.ts msgid "Nickname" @@ -1570,6 +1633,10 @@ msgstr "Файл не вибрано" msgid "No flood profile" msgstr "Без профілю флуду" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "no host set" +msgstr "хост не вказано" + #: src/components/ui/ChannelSettingsModal.tsx msgid "No invitations found" msgstr "Запрошень не знайдено" @@ -1610,6 +1677,10 @@ msgstr "Тема не встановлена" msgid "No unread mentions or messages" msgstr "Немає непрочитаних згадувань або повідомлень" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "No upstream networks yet." +msgstr "Поки що немає висхідних мереж." + #: src/components/ui/AddPrivateChatModal.tsx msgid "No users available" msgstr "Користувачів немає" @@ -1696,6 +1767,10 @@ msgstr "Ой! Розрив мережі! ⚠️" msgid "Op" msgstr "Оп" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Open" +msgstr "" + #: src/components/ui/QuickActions/uiActionConfig.tsx msgid "Open channel configuration settings" msgstr "Відкрити налаштування конфігурації каналу" @@ -1799,6 +1874,10 @@ msgstr "Закріпити приватну розмову" msgid "Pin this private message conversation" msgstr "Закріпити цю приватну розмову" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Plaintext" +msgstr "Незашифровано" + #: src/components/message/MediaPreview.tsx #: src/components/message/MediaPreview.tsx #: src/components/message/MediaPreview.tsx @@ -1827,6 +1906,7 @@ msgid "PM User" msgstr "ПП користувачу" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx msgid "Port" msgstr "Порт" @@ -1918,6 +1998,7 @@ msgstr "відреагував на це повідомлення" msgid "Read more" msgstr "Читати далі" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserProfileModal.tsx #: src/components/ui/UserSettings.tsx #: src/lib/settings/definitions/allSettings.ts @@ -2002,6 +2083,7 @@ msgstr "Правила" msgid "Safe" msgstr "Безпечно" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/ChannelSettingsModal.tsx #: src/components/ui/TopicModal.tsx #: src/components/ui/UserSettings.tsx @@ -2183,6 +2265,10 @@ msgstr "Оператори сервера в мережі можуть поте msgid "Server Password" msgstr "Пароль сервера" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Server Password (PASS)" +msgstr "Пароль сервера (PASS)" + #: src/components/ui/LinkSecurityWarningModal.tsx msgid "Server-to-server communication may use unencrypted connections" msgstr "Зв'язок між серверами може використовувати незашифровані з'єднання" @@ -2378,6 +2464,10 @@ msgstr "Час (хв)" msgid "Time Window (seconds)" msgstr "Часове вікно (секунди)" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "TLS" +msgstr "TLS" + #: src/components/message/WhisperMessage.tsx #: src/components/message/WhisperMessage.tsx msgid "to" @@ -2426,6 +2516,10 @@ msgstr "Тема:" msgid "Total: {0}" msgstr "Всього: {0}" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Transport" +msgstr "Транспорт" + #: src/components/ui/UserSettings.tsx msgid "Trusted Sources" msgstr "Надійні джерела" @@ -2536,6 +2630,7 @@ msgstr "Профіль користувача" msgid "User Settings" msgstr "Налаштування користувача" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/InviteUserModal.tsx #: src/components/ui/ModerationModal.tsx msgid "Username" @@ -2683,6 +2778,10 @@ msgstr "Широкий - ширша область захисту" msgid "Will default to 'no reason' if left empty" msgstr "За замовчуванням буде 'без причини', якщо залишити порожнім" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Yes, delete" +msgstr "Так, видалити" + #: src/components/message/CollapsedEventMessage.tsx #: src/components/message/EventMessage.tsx msgid "You" @@ -2713,6 +2812,10 @@ msgstr "Пароль вашого акаунту для автентифікац msgid "Your account username for authentication" msgstr "Ім'я користувача вашого акаунту для автентифікації" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Your bouncer doesn't have any networks yet. Add one to get started." +msgstr "Ваш баунсер ще не має жодних мереж. Додайте одну, щоб почати." + #: src/lib/settings/definitions/allSettings.ts msgid "Your default nickname for all servers" msgstr "Ваш псевдонім за замовчуванням для всіх серверів" diff --git a/src/locales/zh-TW/messages.mjs b/src/locales/zh-TW/messages.mjs index 53906bfc..d4c07d21 100644 --- a/src/locales/zh-TW/messages.mjs +++ b/src/locales/zh-TW/messages.mjs @@ -1 +1 @@ -/*eslint-disable*/export const messages=JSON.parse("{\"+5VMrz\":[\"無效的模式格式,請使用 nick!user@host 格式(允許萬用字元 *)\"],\"+6NQQA\":[\"通用支持频道\"],\"+6NyRG\":[\"客戶端\"],\"+K0AvT\":[\"断开连接\"],\"+cyFdH\":[\"標記為離開時的預設訊息\"],\"+mVPqU\":[\"在訊息中渲染 Markdown 格式\"],\"+vqCJH\":[\"您的帳戶認證使用者名稱\"],\"+yPBXI\":[\"選擇檔案\"],\"+zy2Nq\":[\"類型\"],\"/09cao\":[\"链路安全级别较低(级别 \",[\"securityLevel\"],\")\"],\"/3BQ4J\":[\"頻道外的使用者無法傳送訊息\"],\"/6BzZF\":[\"切换成员列表\"],\"/TNOPk\":[\"使用者已離開\"],\"/XQgft\":[\"探索\"],\"/cF7Rs\":[\"音量\"],\"/dqduX\":[\"下一页\"],\"/fc3q4\":[\"所有内容\"],\"/kISDh\":[\"啟用通知聲音\"],\"/n04sB\":[\"踢出\"],\"/rTz0M\":[\"音訊\"],\"/rfkZe\":[\"提及和訊息時播放聲音\"],\"0/0ZGA\":[\"頻道名稱遮罩\"],\"0D6j7U\":[\"了解更多自訂規則 →\"],\"0XsHcR\":[\"踢出用户\"],\"0ZpE//\":[\"依使用者數排序\"],\"0bEPwz\":[\"设置为离开\"],\"0dGkPt\":[\"展开频道列表\"],\"0gS7M5\":[\"显示名称\"],\"0kS+M8\":[\"範例網路\"],\"0rgoY7\":[\"僅連線至您選擇的伺服器\"],\"0wdd7X\":[\"加入\"],\"0wkVYx\":[\"私人訊息\"],\"111uHX\":[\"链接预览\"],\"196EG4\":[\"删除私聊\"],\"1DSr1i\":[\"注册账户\"],\"1O/24y\":[\"切换频道列表\"],\"1VPJJ2\":[\"外部链接警告\"],\"1ZC/dv\":[\"沒有未讀提及或訊息\"],\"1pO1zi\":[\"服务器名称为必填项\"],\"1uwfzQ\":[\"查看频道主题\"],\"268g7c\":[\"输入显示名称\"],\"2FOFq1\":[\"网络上的服务器管理员可能读取您的消息\"],\"2FYpfJ\":[\"更多\"],\"2HF1Y2\":[[\"inviter\"],\" 邀請了 \",[\"target\"],\" 加入 \",[\"channel\"]],\"2I70QL\":[\"查看用户资料信息\"],\"2QYdmE\":[\"用戶:\"],\"2QpEjG\":[\"已離開\"],\"2YE223\":[\"发消息到 #\",[\"0\"],\"(Enter 换行,Shift+Enter 发送)\"],\"2bimFY\":[\"使用服务器密码\"],\"2iTmdZ\":[\"本機儲存:\"],\"2odkwe\":[\"嚴格 – 更強的保護\"],\"2uDhbA\":[\"输入要邀请的用户名\"],\"2ygf/L\":[\"← 返回\"],\"2zEgxj\":[\"搜索 GIF...\"],\"3RdPhl\":[\"重命名频道\"],\"3THokf\":[\"有發言權的使用者\"],\"3TSz9S\":[\"最小化\"],\"3jBDvM\":[\"頻道顯示名稱\"],\"3ryuFU\":[\"可選的當機報告以改善應用程式\"],\"3uBF/8\":[\"关闭查看器\"],\"3uwW8F\":[\"https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"3xf8Kz\":[\"輸入帳戶名稱...\"],\"4/Rr0R\":[\"邀请用户加入当前频道\"],\"4EZrJN\":[\"規則\"],\"4JJtW9\":[\"#overflow\"],\"4NqeT4\":[\"洪水設定檔 (+F)\"],\"4RZQRK\":[\"你在做什麼?\"],\"4hfTrB\":[\"昵称\"],\"4n99LO\":[\"已在 \",[\"0\"]],\"4t6vMV\":[\"短訊息自動切換至單行\"],\"4vsHmf\":[\"時間(分鐘)\"],\"5+INAX\":[\"醒目提示提及您的訊息\"],\"5R5Pv/\":[\"Oper 用户名\"],\"678PKt\":[\"网络名称\"],\"6Aih4U\":[\"离线\"],\"6CO3WE\":[\"加入頻道需要密碼,留空可移除金鑰。\"],\"6HhMs3\":[\"退出消息\"],\"6V3Ea3\":[\"已复制\"],\"6lGV3K\":[\"收起\"],\"6yFOEi\":[\"輸入 oper 密碼...\"],\"7+IHTZ\":[\"未選擇檔案\"],\"73hrRi\":[\"nick!user@host(例如:spam*!*@*, *!*@badhost.com)\"],\"7QkKyN\":[\"发送私信\"],\"7U1W7c\":[\"非常宽松\"],\"7Y1YQj\":[\"真實姓名:\"],\"7YHArF\":[\"— 在檢視器中開啟\"],\"7fjnVl\":[\"搜索用户...\"],\"7jL88x\":[\"刪除此訊息?此操作無法復原。\"],\"7nGhhM\":[\"您在想什么?\"],\"7sEpu1\":[\"成员 — \",[\"0\"]],\"7sNhEz\":[\"用户名\"],\"8H0Q+x\":[\"了解更多設定檔 →\"],\"8Phu0A\":[\"顯示使用者更改暱稱的事件\"],\"8XTG9e\":[\"输入 oper 密码\"],\"8XsV2J\":[\"重新傳送\"],\"8ZsakT\":[\"密码\"],\"8kR84m\":[\"您即将打开一个外部链接:\"],\"8lCgih\":[\"移除規則\"],\"8p/xVT\":[[\"0\",\"plural\",{\"one\":[[\"1\"]],\"other\":[[\"2\"]]}]],\"8wRzac\":[[\"joinCount\",\"plural\",{\"other\":[\"已加入 \",[\"joinCount\"],\" 次\"]}]],\"9BMLnJ\":[\"重新连接服务器\"],\"9OEgyT\":[\"添加表情\"],\"9PQ8m2\":[\"G-Line(全域封禁)\"],\"9Qs99X\":[\"電子郵件:\"],\"9QupBP\":[\"删除规则\"],\"9bG48P\":[\"傳送中\"],\"9f5f0u\":[\"有隱私問題?請聯絡我們:\"],\"9unqs3\":[\"離開:\"],\"9v3hwv\":[\"未找到服务器。\"],\"9zb2WA\":[\"连接中\"],\"A1taO8\":[\"搜索\"],\"A2adVi\":[\"傳送正在輸入通知\"],\"A9Rhec\":[\"頻道名稱\"],\"AWOSPo\":[\"放大\"],\"AXSpEQ\":[\"連線時獲取 Oper\"],\"AeXO77\":[\"账户\"],\"AhNP40\":[\"跳转\"],\"Ai2U7L\":[\"主機\"],\"AjBQnf\":[\"已更改昵称\"],\"AmXVh6\":[\"https://example.com/avatar.png\"],\"AnRu/j\":[\"取消回复\"],\"ApSx0O\":[\"找到 \",[\"0\"],\" 則符合「\",[\"searchQuery\"],\"」的訊息\"],\"AxPAXW\":[\"未找到結果\"],\"AyNqAB\":[\"在聊天中顯示所有伺服器事件\"],\"B/QqGw\":[\"暂时离开\"],\"B8AaMI\":[\"此欄位為必填\"],\"BA2c49\":[\"伺服器不支援進階 LIST 篩選\"],\"BDKt3I\":[[\"0\"],\"、\",[\"1\"],\"、\",[\"2\"],\" 以及另外 \",[\"3\"],\" 人正在输入...\"],\"BGul2A\":[\"您有未保存的更改。确定要关闭而不保存吗?\"],\"BIf9fi\":[\"您的狀態訊息\"],\"BZz3md\":[\"您的個人網站\"],\"Bgm/H7\":[\"允許輸入多行文字\"],\"BiQIl1\":[\"置顶此私信对话\"],\"BlNZZ2\":[\"点击跳转到消息\"],\"Bowq3c\":[\"只有管理員可以更改頻道主題\"],\"Btozzp\":[\"此图片已过期\"],\"Bycfjm\":[\"總計:\",[\"0\"]],\"C6IBQc\":[\"複製完整 JSON\"],\"C9L9wL\":[\"資料收集\"],\"CDq4wC\":[\"管理用户\"],\"CHVRxG\":[\"发消息给 @\",[\"0\"],\"(Shift+Enter 换行)\"],\"CN9zdR\":[\"Oper 用户名和密码为必填项\"],\"CW3sYa\":[\"添加表情 \",[\"emoji\"]],\"CaAkqd\":[\"顯示退出事件\"],\"CbvaYj\":[\"依暱稱封禁\"],\"CcK+Ft\":[\"PDF\"],\"Ce8q3L\":[\"选择频道\"],\"CsekCi\":[\"普通\"],\"D+NlUC\":[\"系统\"],\"D28t6+\":[\"已加入並退出\"],\"DB8zMK\":[\"套用\"],\"DBcWHr\":[\"自訂通知音效檔\"],\"DTy9Xw\":[\"媒體預覽\"],\"Dj4pSr\":[\"选择一个安全密码\"],\"Du+zn+\":[\"搜尋中...\"],\"Du2T2f\":[\"未找到設定\"],\"DwsSVQ\":[\"套用篩選並重新整理\"],\"E3W/zd\":[\"預設暱稱\"],\"E6nRW7\":[\"复制链接\"],\"E703RG\":[\"模式:\"],\"EAeu1Z\":[\"傳送邀請\"],\"EFKJQT\":[\"設定\"],\"EGPQBv\":[\"自訂洪水規則 (+f)\"],\"ELik0r\":[\"查看完整隱私權政策\"],\"EPbeC2\":[\"查看或编辑频道主题\"],\"EQCDNT\":[\"輸入 oper 用戶名...\"],\"EUvulZ\":[\"找到 1 則符合「\",[\"searchQuery\"],\"」的訊息\"],\"EatZYJ\":[\"下一张图片\"],\"EdQY6l\":[\"無\"],\"EnqLYU\":[\"搜索服务器...\"],\"F0OKMc\":[\"编辑服务器\"],\"F6Int2\":[\"啟用醒目提示\"],\"FDoLyE\":[\"最多使用者數\"],\"FUU/hZ\":[\"控制聊天中載入的外部媒體數量。\"],\"Fdp03t\":[\"開啟\"],\"FfPWR0\":[\"弹窗\"],\"FjkaiT\":[\"缩小\"],\"FlqOE9\":[\"这意味着:\"],\"FolHNl\":[\"管理您的账户和身份验证\"],\"Fp2Dif\":[\"已退出服务器\"],\"G5KmCc\":[\"GZ-Line(全域 Z-Line)\"],\"GDs0lz\":[\"<0>风险: 敏感信息(消息、私人对话、身份验证详情)可能会暴露给网络管理员或位于 IRC 服务器之间的攻击者。\"],\"GR+2I3\":[\"新增邀請遮罩(例如 nick!*@*, *!*@host.com)\"],\"GRLyMU\":[\"关闭弹出的服务器通知\"],\"GlHnXw\":[\"暱稱修改失敗: \",[\"error\"],\" \",[\"0\"]],\"GswZF3\":[\"預覽:\"],\"GtmO8/\":[\"来自\"],\"GtuHUQ\":[\"在伺服器上重新命名此頻道,所有使用者都會看到新名稱。\"],\"GuGfFX\":[\"切换搜索\"],\"GxkJXS\":[\"上傳中...\"],\"GzbwnK\":[\"已加入频道\"],\"GzsUDB\":[\"擴充個人資料\"],\"H/PnT8\":[\"插入表情\"],\"H6Izzl\":[\"您的首選顏色代碼\"],\"H9jIv+\":[\"顯示加入/離開\"],\"HAKBY9\":[\"上傳檔案\"],\"HdE1If\":[\"頻道\"],\"Hk4AW9\":[\"您的首選顯示名稱\"],\"HmHDk7\":[\"选择成员\"],\"HrQzPU\":[[\"networkName\"],\" 上的頻道\"],\"I2tXQ5\":[\"发消息给 @\",[\"0\"],\"(Enter 换行,Shift+Enter 发送)\"],\"I6bw/h\":[\"封禁使用者\"],\"I92Z+b\":[\"启用通知\"],\"I9D72S\":[\"您確定要刪除此訊息嗎?此操作無法復原。\"],\"IA+1wo\":[\"顯示使用者被踢出頻道的事件\"],\"IDwkJx\":[\"IRC Operator\"],\"ILlU+s\":[\"資訊:\"],\"IUwGEM\":[\"保存更改\"],\"IVeGK6\":[[\"0\"],\"、\",[\"1\"],\" 和 \",[\"2\"],\" 正在输入...\"],\"IgrLD/\":[\"暫停\"],\"Im6JED\":[\"WHISPER\"],\"ImOQa9\":[\"回复\"],\"IoHMnl\":[\"最大值為 \",[\"0\"]],\"IvMj+0\":[\"管理员\"],\"J28zul\":[\"正在連線...\"],\"J5T9NW\":[\"使用者資訊\"],\"J8Y5+z\":[\"哎呀!網路分裂!⚠️\"],\"JBHkBA\":[\"已离开频道\"],\"JCwL0Q\":[\"输入原因(可选)\"],\"JFciKP\":[\"切换\"],\"JXGkhG\":[\"更改频道名称(仅限管理员)\"],\"JcD7qf\":[\"更多操作\"],\"JdkA+c\":[\"隱密 (+s)\"],\"Jmu12l\":[\"服务器频道\"],\"JvQ++s\":[\"啟用 Markdown\"],\"K2jwh/\":[\"無可用 WHOIS 資料\"],\"KAXSwC\":[\"语音权限\"],\"KDfTdX\":[\"删除消息\"],\"KKBlUU\":[\"嵌入\"],\"KM0pLb\":[\"欢迎来到本频道!\"],\"KR6W2h\":[\"取消忽略使用者\"],\"KV+Bi1\":[\"僅限邀請 (+i)\"],\"KdCtwE\":[\"在重置計數器之前監控洪水活動的秒數\"],\"Kkezga\":[\"服务器密码\"],\"KsiQ/8\":[\"使用者必須受邀才能加入頻道\"],\"L+gB/D\":[\"頻道資訊\"],\"LC1a7n\":[\"IRC 服务器报告其服务器间链路的安全级别较低。这意味着当您的消息在网络中的 IRC 服务器之间转发时,可能未经过妥善加密,或者 SSL/TLS 证书未被正确验证。\"],\"LNfLR5\":[\"顯示踢出事件\"],\"LQb0W/\":[\"顯示所有事件\"],\"LU7/yA\":[\"顯示用的別名,可包含空格、表情符號和特殊字元。真實頻道名(\",[\"channelName\"],\")仍用於 IRC 指令。\"],\"LUb9O7\":[\"需要有效的服务器端口\"],\"Lb+BUl\":[\"https://example.com/avatar.jpg\"],\"LcET2C\":[\"隱私權政策\"],\"LcuSDR\":[\"管理您的个人资料信息和元数据\"],\"LqLS9B\":[\"顯示暱稱變更\"],\"LsDQt2\":[\"频道设置\"],\"LtI9AS\":[\"频道所有者\"],\"LuNhhL\":[\"对此消息做出了回应\"],\"M/AZNG\":[\"頭像圖片 URL\"],\"M/WIer\":[\"傳送訊息\"],\"M8er/5\":[\"名稱:\"],\"MHk+7g\":[\"上一张图片\"],\"MRorGe\":[\"私信用户\"],\"MVbSGP\":[\"時間視窗(秒)\"],\"MkpcsT\":[\"您的訊息和設定儲存在本機裝置上\"],\"N/hDSy\":[\"標記為機器人——通常為 'on' 或空白\"],\"N7TQbE\":[\"邀請使用者加入 \",[\"channelName\"]],\"NCca/o\":[\"輸入預設暱稱...\"],\"Nqs6B9\":[\"显示所有外部媒体。任何 URL 都可能向未知服务器发送请求。\"],\"Nt+9O7\":[\"使用 WebSocket 而非原始 TCP\"],\"NxIHzc\":[\"踢出用戶\"],\"O+v/cL\":[\"浏览服务器上的所有频道\"],\"ODwSCk\":[\"发送 GIF\"],\"OGQ5kK\":[\"配置通知声音和高亮提示\"],\"OIPt1Z\":[\"显示或隐藏成员列表侧栏\"],\"OKSNq/\":[\"非常严格\"],\"ONWvwQ\":[\"上傳\"],\"OVKoQO\":[\"您的帳戶認證密碼\"],\"ObsidianIRC - Bringing IRC to the future\":[\"ObsidianIRC - 將 IRC 帶入未來\"],\"OhCpra\":[\"设置主题…\"],\"OkltoQ\":[\"通过昵称封禁 \",[\"username\"],\"(阻止其使用相同昵称重新加入)\"],\"P+t/Te\":[\"無其他資料\"],\"P42Wcc\":[\"安全\"],\"PD38l0\":[\"频道头像预览\"],\"PD9mEt\":[\"输入消息...\"],\"PPqfdA\":[\"打开频道配置设置\"],\"PSCjfZ\":[\"此頻道顯示的主題,所有使用者均可查看。\"],\"PZCecv\":[\"PDF 預覽\"],\"PeLgsC\":[[\"c\",\"plural\",{\"other\":[[\"c\"],\" 次\"]}]],\"PguS2C\":[\"新增例外遮罩(例如 nick!*@*, *!*@host.com)\"],\"Pil5Ty\":[\"顯示 \",[\"displayedChannelsCount\"],\",共 \",[\"0\"],\" 個頻道\"],\"PqhVlJ\":[\"封禁用户(按 Hostmask)\"],\"Q+chwU\":[\"用戶名:\"],\"Q6hhn8\":[\"偏好设置\"],\"QF4a34\":[\"請輸入使用者名稱\"],\"QGqSZ2\":[\"颜色与格式\"],\"QJQd1J\":[\"編輯資料\"],\"QSzGDE\":[\"閒置\"],\"QUlny5\":[\"欢迎来到 \",[\"0\"],\"!\"],\"Qoq+GP\":[\"阅读更多\"],\"QuSkCF\":[\"筛选频道...\"],\"QwUrDZ\":[\"將主題更改為:\",[\"topic\"]],\"R0UH07\":[\"第 \",[\"0\"],\" 張,共 \",[\"1\"],\" 張\"],\"R7SsBE\":[\"靜音\"],\"R8rf1X\":[\"点击设置主题\"],\"RArB3D\":[\"被 \",[\"username\"],\" 踢出 \",[\"channelName\"]],\"RI3cWd\":[\"使用 ObsidianIRC 探索 IRC 的世界\"],\"RMMaN5\":[\"已審核 (+m)\"],\"RWw9Lg\":[\"關閉視窗\"],\"RZ2BuZ\":[\"帳戶 \",[\"account\"],\" 註冊需要驗證:\",[\"message\"]],\"RySp6q\":[\"隱藏評論\"],\"SPKQTd\":[\"昵称为必填项\"],\"SPVjfj\":[\"留空将默认显示\\\"无原因\\\"\"],\"SQKPvQ\":[\"邀请用户\"],\"SkZcl+\":[\"選擇預定義的洪水保護設定檔。這些設定檔為不同使用場景提供均衡的保護設定。\"],\"Slr+3C\":[\"最少使用者數\"],\"Spnlre\":[\"您邀請了 \",[\"target\"],\" 加入 \",[\"channel\"]],\"T/ckN5\":[\"在查看器中打开\"],\"T91vKp\":[\"播放\"],\"TV2Wdu\":[\"了解我們如何處理您的資料並保護您的隱私。\"],\"TgFpwD\":[\"正在套用...\"],\"TkzSFB\":[\"无更改\"],\"TtserG\":[\"输入真实姓名\"],\"Ttz9J1\":[\"輸入密碼...\"],\"Tz0i8g\":[\"設定\"],\"U3pytU\":[\"管理员\"],\"UDb2YD\":[\"添加表情\"],\"UE4KO5\":[\"*channel*\"],\"UGT5vp\":[\"儲存設定\"],\"UV5hLB\":[\"未找到封禁\"],\"Uaj3Nd\":[\"状态消息\"],\"Ue3uny\":[\"預設(無設定檔)\"],\"UkARhe\":[\"普通 – 標準保護\"],\"Umn7Cj\":[\"尚無評論,成為第一個吧!\"],\"UtUIRh\":[[\"0\"],\" 則較早的訊息\"],\"UwzP+U\":[\"安全連線\"],\"V0/A4O\":[\"頻道擁有者\"],\"V4qgxE\":[\"建立時間早於(分鐘前)\"],\"V8yTm6\":[\"清除搜索\"],\"VJMMyz\":[\"ObsidianIRC - 将 IRC 带入未来\"],\"VJScHU\":[\"原因\"],\"VLsmVV\":[\"静音通知\"],\"VbyRUy\":[\"評論\"],\"Vmx0mQ\":[\"設定者:\"],\"VqnIZz\":[\"查看我们的隐私政策和数据使用规范\"],\"VrMygG\":[\"最小長度為 \",[\"0\"]],\"VrnTui\":[\"您的代名詞,顯示在個人資料中\"],\"W8E3qn\":[\"已驗證帳戶\"],\"WAakm9\":[\"删除频道\"],\"WFxTHC\":[\"新增封禁遮罩(例如 nick!*@*, *!*@host.com)\"],\"WN1g9F\":[\"服务器地址为必填项\"],\"WRYdXW\":[\"音频进度\"],\"WUOH5B\":[\"忽略使用者\"],\"WWEXnZ\":[[\"0\",\"plural\",{\"other\":[\"顯示另外 \",[\"1\"],\" 個項目\"]}]],\"Weq9zb\":[\"一般\"],\"Wfj7Sk\":[\"开启或关闭通知声音\"],\"Wm7gbG\":[\"GitHub:\"],\"WyeHWY\":[\"*spam*\"],\"WzMCru\":[\"用户资料\"],\"X6S3lt\":[\"搜索设置、频道、服务器...\"],\"XEHan5\":[\"仍然继续\"],\"XI1+wb\":[\"格式無效\"],\"XIXeuC\":[\"发消息给 @\",[\"0\"]],\"XMS+k4\":[\"发起私信\"],\"XWgxXq\":[\"相簿\"],\"Xd7+IT\":[\"取消置顶私聊\"],\"Xm/s+u\":[\"顯示\"],\"Xp2n93\":[\"显示来自服务器受信任文件主机的媒体。不会向外部服务发送任何请求。\"],\"XvjC4F\":[\"正在儲存...\"],\"Y/qryO\":[\"未找到与搜索条件匹配的用户\"],\"YAqRpI\":[\"帳戶 \",[\"account\"],\" 註冊成功:\",[\"message\"]],\"YEfzvP\":[\"受保護主題 (+t)\"],\"YQOn6a\":[\"收起成员列表\"],\"YRCoE9\":[\"頻道操作員\"],\"YURQaF\":[\"查看個人資料\"],\"YdBSvr\":[\"控制媒体显示和外部内容\"],\"Yj6U3V\":[\"無中央伺服器:\"],\"YjvpGx\":[\"代词\"],\"YqH4l4\":[\"无密钥\"],\"YyUPpV\":[\"帳戶:\"],\"ZJSWfw\":[\"中斷伺服器連線時顯示的訊息\"],\"ZR1dJ4\":[\"邀請\"],\"ZdWg0V\":[\"在浏览器中打开\"],\"ZhRBbl\":[\"搜索消息…\"],\"Zmcu3y\":[\"進階篩選\"],\"a2/8e5\":[\"主題設定時間晚於(分鐘前)\"],\"aHKcKc\":[\"上一页\"],\"aJTbXX\":[\"Oper 密码\"],\"aQryQv\":[\"模式已存在\"],\"aW9pLN\":[\"頻道允許的最大使用者數,留空表示無限制。\"],\"ah4fmZ\":[\"同时显示来自 YouTube、Vimeo、SoundCloud 及类似知名服务的预览。\"],\"aifXak\":[\"此頻道沒有媒體\"],\"ap2zBz\":[\"宽松\"],\"az8lvo\":[\"关\"],\"azXSNo\":[\"展开成员列表\"],\"azdliB\":[\"登录账户\"],\"b26wlF\":[\"她/她的\"],\"bD/+Ei\":[\"严格\"],\"bQ6BJn\":[\"設定詳細的洪水保護規則。每條規則指定要監控的活動類型以及超過閾值時採取的操作。\"],\"beV7+y\":[\"使用者將收到加入 \",[\"channelName\"],\" 的邀請。\"],\"bk84cH\":[\"离开消息\"],\"bkHdLj\":[\"添加 IRC 服务器\"],\"bmQLn5\":[\"新增規則\"],\"bwRvnp\":[\"操作\"],\"c8+EVZ\":[\"已验证账户\"],\"cGYUlD\":[\"未加载任何媒体预览。\"],\"cLF98o\":[\"顯示評論 (\",[\"commentCount\"],\")\"],\"cLKIDO\":[\"没有可用用户\"],\"cSgpoS\":[\"置顶私聊\"],\"cde3ce\":[\"发消息给 <0>\",[\"0\"],\"\"],\"chQsxg\":[\"複製格式化輸出\"],\"cl/A5J\":[\"欢迎来到 \",[\"__DEFAULT_IRC_SERVER_NAME__\"],\"!\"],\"cnGeoo\":[\"删除\"],\"coPLXT\":[\"我們不在伺服器上儲存您的 IRC 通訊\"],\"crYH/6\":[\"SoundCloud 播放器\"],\"d3sis4\":[\"添加服务器\"],\"d9aN5k\":[\"将 \",[\"username\"],\" 移出频道\"],\"dEgA5A\":[\"取消\"],\"dGi1We\":[\"取消置顶此私信对话\"],\"dJVuyC\":[\"離開了 \",[\"channelName\"],\" (\",[\"reason\"],\")\"],\"dMtLDE\":[\"至\"],\"dXqxlh\":[\"<0>⚠️ 安全风险! 此连接可能容易遭受窃听或中间人攻击。\"],\"da9Q/R\":[\"已更改频道模式\"],\"dhJN3N\":[\"顯示評論\"],\"dj2xTE\":[\"关闭通知\"],\"dpCzmC\":[\"洪水保護設定\"],\"e9dQpT\":[\"是否在新标签页中打开此链接?\"],\"ePK91l\":[\"编辑\"],\"eYBDuB\":[\"上傳圖片或提供含可選 \",[\"size\"],\" 替換的 URL\"],\"edBbee\":[\"通过 hostmask 封禁 \",[\"username\"],\"(阻止其从相同 IP/主机重新加入)\"],\"ekfzWq\":[\"使用者設定\"],\"elPDWs\":[\"自定义您的 IRC 客户端体验\"],\"eu2osY\":[\"<0>💡 建议: 仅在您信任此服务器并了解相关风险的情况下继续操作。避免通过此连接共享敏感信息或密码。\"],\"euEhbr\":[\"點擊加入 \",[\"channel\"]],\"ez3vLd\":[\"啟用多行輸入\"],\"f0J5Ki\":[\"服务器之间的通信可能使用未加密的连接\"],\"f9BHJk\":[\"警告用户\"],\"fDOLLd\":[\"未找到頻道。\"],\"ffzDkB\":[\"匿名分析:\"],\"fq1GF9\":[\"顯示使用者中斷伺服器連線的事件\"],\"gEF57C\":[\"此伺服器僅支援一種連線類型\"],\"gJuLUI\":[\"忽略清單\"],\"gNzMrk\":[\"目前頭像\"],\"gjPWyO\":[\"輸入暱稱...\"],\"gz6UQ3\":[\"最大化\"],\"h6razj\":[\"排除頻道名稱遮罩\"],\"hG6jnw\":[\"未设置主题\"],\"hG89Ed\":[\"圖片\"],\"hZ6znB\":[\"端口\"],\"ha+Bz5\":[\"例如:100:1440\"],\"hehnjM\":[\"數量\"],\"hzdLuQ\":[\"只有獲得發言權或更高權限的使用者才能發言\"],\"i0qMbr\":[\"主页\"],\"iDNBZe\":[\"通知\"],\"iH8pgl\":[\"返回\"],\"iL9SZg\":[\"封禁用户(按昵称)\"],\"iNt+3c\":[\"返回图片\"],\"iQvi+a\":[\"不再提醒我此服务器的低链路安全问题\"],\"iSLIjg\":[\"連線\"],\"iWXkHH\":[\"半管理员\"],\"iZeTtp\":[\"服务器地址\"],\"idD8Ev\":[\"已儲存\"],\"iivqkW\":[\"登入時間\"],\"ij+Elv\":[\"图片预览\"],\"ilIWp7\":[\"切换通知\"],\"iuaqvB\":[\"使用 * 作為萬用字元。範例:baduser!*@*, *!*@spammer.com, troll*!*@*\"],\"ixkTse\":[\"机器人\"],\"j2DGR0\":[\"依主機遮罩封禁\"],\"jA4uoI\":[\"話題:\"],\"jLXxGK\":[\"https://example.com\"],\"jPSk57\":[\"原因(可选)\"],\"jUV7CU\":[\"上傳頭像\"],\"jW5Uwh\":[\"控制載入的外部媒體量。關閉 / 安全 / 可信來源 / 所有內容。\"],\"jXzms5\":[\"附件选项\"],\"jZlrte\":[\"颜色\"],\"jfC/xh\":[\"聯絡方式\"],\"jywMpv\":[\"#new-channel-name\"],\"k112DD\":[\"載入較早的訊息\"],\"k3ID0F\":[\"筛选成员…\"],\"k65gsE\":[\"深入查看\"],\"k7Zgob\":[\"取消连接\"],\"kAVx5h\":[\"未找到邀請\"],\"kCLEPU\":[\"已連線至\"],\"kF5LKb\":[\"已忽略的模式:\"],\"kGeOx/\":[\"加入 \",[\"0\"]],\"kITKr8\":[\"正在載入頻道模式...\"],\"kPpPsw\":[\"您是 IRC Operator\"],\"kWJmRL\":[\"您\"],\"kfcRb0\":[\"头像\"],\"kjMqSj\":[\"複製 JSON\"],\"krViRy\":[\"點擊以 JSON 格式複製\"],\"ks71ra\":[\"例外\"],\"kw4lRv\":[\"頻道半操作員\"],\"kxgIRq\":[\"选择或添加频道以开始使用。\"],\"ky6dWe\":[\"头像预览\"],\"l+GxCv\":[\"正在載入頻道...\"],\"l+IUVW\":[\"帳戶 \",[\"account\"],\" 驗證成功:\",[\"message\"]],\"l/siQz\":[[\"reconnectCount\",\"plural\",{\"other\":[\"已重新連線 \",[\"reconnectCount\"],\" 次\"]}]],\"l5jmzx\":[[\"0\"],\" 和 \",[\"1\"],\" 正在输入...\"],\"lHy8N5\":[\"正在載入更多頻道...\"],\"lbpf14\":[\"加入 \",[\"value\"]],\"lfFsZ4\":[\"頻道\"],\"lkNdiH\":[\"帳戶名稱\"],\"ln500L\":[\"ObsidianIRC\"],\"lnCMdg\":[\"上传图片\"],\"loQxaJ\":[\"我回来了\"],\"lvfaxv\":[\"首頁\"],\"m16xKo\":[\"新增\"],\"m8flAk\":[\"預覽(尚未上傳)\"],\"mEPxTp\":[\"<0>⚠️ 请注意! 仅打开来自可信来源的链接。恶意链接可能危害您的安全或隐私。\"],\"mHGdhG\":[\"伺服器資訊\"],\"mHS8lb\":[\"发消息到 #\",[\"0\"]],\"mMYBD9\":[\"寬泛 – 更廣的保護範圍\"],\"mTGsPd\":[\"頻道主題\"],\"mU8j6O\":[\"禁止外部訊息 (+n)\"],\"mZp8FL\":[\"自動回退至單行\"],\"mdQu8G\":[\"YourNickname\"],\"miSSBQ\":[\"評論 (\",[\"commentCount\"],\")\"],\"mvyLSy\":[\"用户已认证\"],\"mwtcGl\":[\"关闭评论\"],\"mzI/c+\":[\"下载\"],\"n3fGRk\":[\"由 \",[\"0\"],\" 設定\"],\"nE9jsU\":[\"寬鬆 – 較弱的保護\"],\"nNflMD\":[\"离开频道\"],\"nPXkBi\":[\"正在載入 WHOIS 資料...\"],\"nQnxxF\":[\"发消息到 #\",[\"0\"],\"(Shift+Enter 换行)\"],\"nWMRxa\":[\"取消置顶\"],\"nkC032\":[\"无洪水防护配置\"],\"o69z4d\":[\"向 \",[\"username\"],\" 发送警告消息\"],\"o9ylQi\":[\"搜尋 GIF 以開始\"],\"oFGkER\":[\"服务器通知\"],\"oOi11l\":[\"滚动到底部\"],\"oQEzQR\":[\"新私訊\"],\"oXOSPE\":[\"在线\"],\"oal760\":[\"服务器链路可能遭受中间人攻击\"],\"oeqmmJ\":[\"受信任来源\"],\"ovBPCi\":[\"默认\"],\"p0Z69r\":[\"模式不能為空\"],\"p1KgtK\":[\"音频加载失败\"],\"p59pEv\":[\"更多詳情\"],\"p7sRI6\":[\"讓其他人知道您正在輸入\"],\"pBm1od\":[\"秘密频道\"],\"pNmiXx\":[\"所有伺服器的預設暱稱\"],\"pUUo9G\":[\"主機名:\"],\"pVGPmz\":[\"账户密码\"],\"peNE68\":[\"永久\"],\"plhHQt\":[\"無資料\"],\"pm6+q5\":[\"安全警告\"],\"pn5qSs\":[\"其他資訊\"],\"q0cR4S\":[\"現在稱為 **\",[\"newNick\"],\"**\"],\"qFcunY\":[\"頻道不會出現在 LIST 或 NAMES 指令中\"],\"qLpTm/\":[\"移除表情 \",[\"emoji\"]],\"qVkGWK\":[\"置顶\"],\"qY8wNa\":[\"主页\"],\"qb0xJ7\":[\"萬用字元:* 符合任意字元,? 符合單一字元。範例:nick!*@*, *!*@host.com, *!*user@*\"],\"qhzpRq\":[\"頻道金鑰 (+k)\"],\"qtoOYG\":[\"无限制\"],\"r1W2AS\":[\"檔案託管圖片\"],\"rIPR2O\":[\"主題設定時間早於(分鐘前)\"],\"rMMSYo\":[\"最大長度為 \",[\"0\"]],\"rWtzQe\":[\"網路已分裂並重新連接。✅\"],\"rYG2u6\":[\"请稍候...\"],\"rdUucN\":[\"预览\"],\"rjGI/Q\":[\"隐私\"],\"rk8iDX\":[\"正在載入 GIF...\"],\"rn6SBY\":[\"取消靜音\"],\"s/UKqq\":[\"已被踢出频道\"],\"s8cATI\":[\"加入了 \",[\"channelName\"]],\"sCO9ue\":[\"与 <0>\",[\"serverName\"],\" 的连接存在以下安全问题:\"],\"sGH11W\":[\"伺服器\"],\"sHI1H+\":[\"現在稱為 **\",[\"newNick\"],\"**\"],\"sJyV04\":[[\"inviter\"],\" 邀請您加入 \",[\"channel\"]],\"sby+1/\":[\"點擊複製\"],\"sfN25C\":[\"您的真實姓名或全名\"],\"sliuzR\":[\"打开链接\"],\"sqrO9R\":[\"自訂提及\"],\"sr6RdJ\":[\"Shift+Enter 換行\"],\"swrCpB\":[\"頻道已由 \",[\"user\"],\" 從 \",[\"oldName\"],\" 重新命名為 \",[\"newName\"],[\"0\"]],\"sxkWRg\":[\"進階\"],\"t/YqKh\":[\"移除\"],\"t47eHD\":[\"您在此伺服器上的唯一識別碼\"],\"tAkAh0\":[\"含可選 \",[\"size\"],\" 替換的 URL。範例:https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"tXLJS3\":[\"显示或隐藏频道列表侧栏\"],\"tfDRzk\":[\"保存\"],\"tiBsJk\":[\"離開了 \",[\"channelName\"]],\"tt4/UD\":[\"退出了 (\",[\"reason\"],\")\"],\"u0TcnO\":[\"暱稱 {nick} 已被使用,正在用 {newNick} 重試\"],\"u0a8B4\":[\"以 IRC 操作員身分進行管理存取認證\"],\"u0rWFU\":[\"建立時間晚於(分鐘前)\"],\"u72w3t\":[\"要忽略的使用者和模式\"],\"u7jc2L\":[\"退出了\"],\"uAQUqI\":[\"状态\"],\"uB85T3\":[\"儲存失敗:\",[\"msg\"]],\"uV3DOL\":[\"G-Line\"],\"uW3lLI\":[\"IRC 伺服器:\"],\"usSSr/\":[\"缩放级别\"],\"v7uvcf\":[\"軟體:\"],\"vE8kb+\":[\"Shift+Enter 換行(Enter 傳送)\"],\"vERlcd\":[\"个人资料\"],\"vK0RL8\":[\"無主題\"],\"vSJd18\":[\"影片\"],\"vXIe7J\":[\"语言\"],\"vaHYxN\":[\"真实姓名\"],\"vhjbKr\":[\"离开\"],\"w4NYox\":[[\"title\"],\" 客戶端\"],\"w8xQRx\":[\"值無效\"],\"wFjjxZ\":[\"被 \",[\"username\"],\" 踢出 \",[\"channelName\"],\" (\",[\"reason\"],\")\"],\"wGjaGl\":[\"未找到封禁例外\"],\"wPrGnM\":[\"頻道管理員\"],\"wRkP2d\":[\"GIF\"],\"wbm86v\":[\"顯示使用者加入或離開頻道的事件\"],\"whqZ9r\":[\"要醒目提示的額外詞語或短語\"],\"wm7RV4\":[\"通知聲音\"],\"wz/Yoq\":[\"您的消息在服务器之间转发时可能被截获\"],\"xCJdfg\":[\"清除\"],\"xUHRTR\":[\"連線時自動以操作員身分認證\"],\"xWHwwQ\":[\"封禁\"],\"xYilR2\":[\"媒体\"],\"xceQrO\":[\"仅支持安全的 WebSocket 连接\"],\"xdtXa+\":[\"頻道名稱\"],\"xfXC7q\":[\"文字頻道\"],\"xlCYOE\":[\"正在載入更多訊息...\"],\"xlhswE\":[\"最小值為 \",[\"0\"]],\"xq97Ci\":[\"添加词语或短语...\"],\"xuRqRq\":[\"用戶端限制 (+l)\"],\"xwF+7J\":[[\"0\"],\" 正在输入...\"],\"yNeucF\":[\"此伺服器不支援擴充個人資料元資料(IRCv3 METADATA 擴充功能)。頭像、顯示名稱和狀態等欄位不可用。\"],\"yPlrca\":[\"頻道頭像\"],\"yQE2r9\":[\"載入中\"],\"ySU+JY\":[\"your@email.com\"],\"yTX1Rt\":[\"Oper 使用者名稱\"],\"yYOzWD\":[\"日志\"],\"yfx9Re\":[\"IRC 操作員密碼\"],\"ygCKqB\":[\"停止\"],\"ymDxJx\":[\"IRC 操作員使用者名稱\"],\"yrpRsQ\":[\"依名稱排序\"],\"yz7wBu\":[\"关闭\"],\"zJw+jA\":[\"設定模式:\",[\"0\"]],\"zebeLu\":[\"输入 oper 用户名\"],\"zpr0Bw\":[\"GZ-Line\"]}"); \ No newline at end of file +/*eslint-disable*/export const messages=JSON.parse("{\"+5VMrz\":[\"無效的模式格式,請使用 nick!user@host 格式(允許萬用字元 *)\"],\"+6NQQA\":[\"通用支持频道\"],\"+6NyRG\":[\"客戶端\"],\"+K0AvT\":[\"断开连接\"],\"+cyFdH\":[\"標記為離開時的預設訊息\"],\"+mVPqU\":[\"在訊息中渲染 Markdown 格式\"],\"+vqCJH\":[\"您的帳戶認證使用者名稱\"],\"+yPBXI\":[\"選擇檔案\"],\"+zy2Nq\":[\"類型\"],\"/09cao\":[\"链路安全级别较低(级别 \",[\"securityLevel\"],\")\"],\"/3BQ4J\":[\"頻道外的使用者無法傳送訊息\"],\"/6BzZF\":[\"切换成员列表\"],\"/TNOPk\":[\"使用者已離開\"],\"/XQgft\":[\"探索\"],\"/cF7Rs\":[\"音量\"],\"/dqduX\":[\"下一页\"],\"/fc3q4\":[\"所有内容\"],\"/kISDh\":[\"啟用通知聲音\"],\"/n04sB\":[\"踢出\"],\"/rTz0M\":[\"音訊\"],\"/rfkZe\":[\"提及和訊息時播放聲音\"],\"0/0ZGA\":[\"頻道名稱遮罩\"],\"0D6j7U\":[\"了解更多自訂規則 →\"],\"0XsHcR\":[\"踢出用户\"],\"0ZpE//\":[\"依使用者數排序\"],\"0bEPwz\":[\"设置为离开\"],\"0dGkPt\":[\"展开频道列表\"],\"0gS7M5\":[\"显示名称\"],\"0kS+M8\":[\"範例網路\"],\"0rgoY7\":[\"僅連線至您選擇的伺服器\"],\"0wdd7X\":[\"加入\"],\"0wkVYx\":[\"私人訊息\"],\"111uHX\":[\"链接预览\"],\"196EG4\":[\"删除私聊\"],\"1DSr1i\":[\"注册账户\"],\"1O/24y\":[\"切换频道列表\"],\"1TNIig\":[\"Open\"],\"1VPJJ2\":[\"外部链接警告\"],\"1ZC/dv\":[\"沒有未讀提及或訊息\"],\"1pO1zi\":[\"服务器名称为必填项\"],\"1uwfzQ\":[\"查看频道主题\"],\"268g7c\":[\"输入显示名称\"],\"2FOFq1\":[\"网络上的服务器管理员可能读取您的消息\"],\"2FYpfJ\":[\"更多\"],\"2HF1Y2\":[[\"inviter\"],\" 邀請了 \",[\"target\"],\" 加入 \",[\"channel\"]],\"2I70QL\":[\"查看用户资料信息\"],\"2QYdmE\":[\"用戶:\"],\"2QpEjG\":[\"已離開\"],\"2YE223\":[\"发消息到 #\",[\"0\"],\"(Enter 换行,Shift+Enter 发送)\"],\"2bimFY\":[\"使用服务器密码\"],\"2iTmdZ\":[\"本機儲存:\"],\"2odkwe\":[\"嚴格 – 更強的保護\"],\"2uDhbA\":[\"输入要邀请的用户名\"],\"2ygf/L\":[\"← 返回\"],\"2zEgxj\":[\"搜索 GIF...\"],\"3RdPhl\":[\"重命名频道\"],\"3THokf\":[\"有發言權的使用者\"],\"3TSz9S\":[\"最小化\"],\"3jBDvM\":[\"頻道顯示名稱\"],\"3ryuFU\":[\"可選的當機報告以改善應用程式\"],\"3uBF/8\":[\"关闭查看器\"],\"3uwW8F\":[\"https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"3xf8Kz\":[\"輸入帳戶名稱...\"],\"4/Rr0R\":[\"邀请用户加入当前频道\"],\"4EZrJN\":[\"規則\"],\"4JJtW9\":[\"#overflow\"],\"4NqeT4\":[\"洪水設定檔 (+F)\"],\"4RZQRK\":[\"你在做什麼?\"],\"4hfTrB\":[\"昵称\"],\"4n99LO\":[\"已在 \",[\"0\"]],\"4t6vMV\":[\"短訊息自動切換至單行\"],\"4vsHmf\":[\"時間(分鐘)\"],\"4x/Axu\":[\"您的 bouncer 還沒有任何網路。新增一個以開始使用。\"],\"5+INAX\":[\"醒目提示提及您的訊息\"],\"5R5Pv/\":[\"Oper 用户名\"],\"678PKt\":[\"网络名称\"],\"6Aih4U\":[\"离线\"],\"6CO3WE\":[\"加入頻道需要密碼,留空可移除金鑰。\"],\"6HhMs3\":[\"退出消息\"],\"6V3Ea3\":[\"已复制\"],\"6lGV3K\":[\"收起\"],\"6yFOEi\":[\"輸入 oper 密碼...\"],\"7+IHTZ\":[\"未選擇檔案\"],\"73fnil\":[\"TLS\"],\"73hrRi\":[\"nick!user@host(例如:spam*!*@*, *!*@badhost.com)\"],\"7QkKyN\":[\"发送私信\"],\"7U1W7c\":[\"非常宽松\"],\"7Y1YQj\":[\"真實姓名:\"],\"7YHArF\":[\"— 在檢視器中開啟\"],\"7fjnVl\":[\"搜索用户...\"],\"7jL88x\":[\"刪除此訊息?此操作無法復原。\"],\"7nGhhM\":[\"您在想什么?\"],\"7sEpu1\":[\"成员 — \",[\"0\"]],\"7sNhEz\":[\"用户名\"],\"8H0Q+x\":[\"了解更多設定檔 →\"],\"8Phu0A\":[\"顯示使用者更改暱稱的事件\"],\"8XTG9e\":[\"输入 oper 密码\"],\"8XsV2J\":[\"重新傳送\"],\"8ZsakT\":[\"密码\"],\"8kR84m\":[\"您即将打开一个外部链接:\"],\"8lCgih\":[\"移除規則\"],\"8p/xVT\":[[\"0\",\"plural\",{\"one\":[[\"1\"]],\"other\":[[\"2\"]]}]],\"8wRzac\":[[\"joinCount\",\"plural\",{\"other\":[\"已加入 \",[\"joinCount\"],\" 次\"]}]],\"9BMLnJ\":[\"重新连接服务器\"],\"9OEgyT\":[\"添加表情\"],\"9PQ8m2\":[\"G-Line(全域封禁)\"],\"9Qs99X\":[\"電子郵件:\"],\"9QupBP\":[\"删除规则\"],\"9W7tl5\":[\"(未變更)\"],\"9bG48P\":[\"傳送中\"],\"9f5f0u\":[\"有隱私問題?請聯絡我們:\"],\"9iweoP\":[[\"0\"],\" 上的網路\"],\"9unqs3\":[\"離開:\"],\"9v3hwv\":[\"未找到服务器。\"],\"9zb2WA\":[\"连接中\"],\"A1taO8\":[\"搜索\"],\"A2adVi\":[\"傳送正在輸入通知\"],\"A9Rhec\":[\"頻道名稱\"],\"AWOSPo\":[\"放大\"],\"AXSpEQ\":[\"連線時獲取 Oper\"],\"AeXO77\":[\"账户\"],\"AhNP40\":[\"跳转\"],\"Ai2U7L\":[\"主機\"],\"AjBQnf\":[\"已更改昵称\"],\"AmXVh6\":[\"https://example.com/avatar.png\"],\"AnRu/j\":[\"取消回复\"],\"ApSx0O\":[\"找到 \",[\"0\"],\" 則符合「\",[\"searchQuery\"],\"」的訊息\"],\"AxPAXW\":[\"未找到結果\"],\"AyNqAB\":[\"在聊天中顯示所有伺服器事件\"],\"B/QqGw\":[\"暂时离开\"],\"B0sB2k\":[\"明文\"],\"B8AaMI\":[\"此欄位為必填\"],\"BA2c49\":[\"伺服器不支援進階 LIST 篩選\"],\"BDKt3I\":[[\"0\"],\"、\",[\"1\"],\"、\",[\"2\"],\" 以及另外 \",[\"3\"],\" 人正在输入...\"],\"BGul2A\":[\"您有未保存的更改。确定要关闭而不保存吗?\"],\"BIf9fi\":[\"您的狀態訊息\"],\"BZz3md\":[\"您的個人網站\"],\"Bgm/H7\":[\"允許輸入多行文字\"],\"BiQIl1\":[\"置顶此私信对话\"],\"BlNZZ2\":[\"点击跳转到消息\"],\"Bowq3c\":[\"只有管理員可以更改頻道主題\"],\"Btozzp\":[\"此图片已过期\"],\"Bycfjm\":[\"總計:\",[\"0\"]],\"C6IBQc\":[\"複製完整 JSON\"],\"C9L9wL\":[\"資料收集\"],\"CDq4wC\":[\"管理用户\"],\"CHVRxG\":[\"发消息给 @\",[\"0\"],\"(Shift+Enter 换行)\"],\"CN9zdR\":[\"Oper 用户名和密码为必填项\"],\"CW3sYa\":[\"添加表情 \",[\"emoji\"]],\"CaAkqd\":[\"顯示退出事件\"],\"CbvaYj\":[\"依暱稱封禁\"],\"CcK+Ft\":[\"PDF\"],\"Ce8q3L\":[\"选择频道\"],\"CsekCi\":[\"普通\"],\"D+NlUC\":[\"系统\"],\"D28t6+\":[\"已加入並退出\"],\"DB8zMK\":[\"套用\"],\"DBcWHr\":[\"自訂通知音效檔\"],\"DTy9Xw\":[\"媒體預覽\"],\"Dj4pSr\":[\"选择一个安全密码\"],\"Du+zn+\":[\"搜尋中...\"],\"Du2T2f\":[\"未找到設定\"],\"DwsSVQ\":[\"套用篩選並重新整理\"],\"E3W/zd\":[\"預設暱稱\"],\"E6nRW7\":[\"复制链接\"],\"E703RG\":[\"模式:\"],\"EAeu1Z\":[\"傳送邀請\"],\"EFKJQT\":[\"設定\"],\"EGPQBv\":[\"自訂洪水規則 (+f)\"],\"ELik0r\":[\"查看完整隱私權政策\"],\"EPbeC2\":[\"查看或编辑频道主题\"],\"EQCDNT\":[\"輸入 oper 用戶名...\"],\"EUvulZ\":[\"找到 1 則符合「\",[\"searchQuery\"],\"」的訊息\"],\"EatZYJ\":[\"下一张图片\"],\"EdQY6l\":[\"無\"],\"EnqLYU\":[\"搜索服务器...\"],\"F0OKMc\":[\"编辑服务器\"],\"F6Int2\":[\"啟用醒目提示\"],\"FDoLyE\":[\"最多使用者數\"],\"FUU/hZ\":[\"控制聊天中載入的外部媒體數量。\"],\"Fdp03t\":[\"開啟\"],\"FfPWR0\":[\"弹窗\"],\"FjkaiT\":[\"缩小\"],\"FlqOE9\":[\"这意味着:\"],\"FolHNl\":[\"管理您的账户和身份验证\"],\"Fp2Dif\":[\"已退出服务器\"],\"G5KmCc\":[\"GZ-Line(全域 Z-Line)\"],\"GDs0lz\":[\"<0>风险: 敏感信息(消息、私人对话、身份验证详情)可能会暴露给网络管理员或位于 IRC 服务器之间的攻击者。\"],\"GR+2I3\":[\"新增邀請遮罩(例如 nick!*@*, *!*@host.com)\"],\"GRLyMU\":[\"关闭弹出的服务器通知\"],\"GlHnXw\":[\"暱稱修改失敗: \",[\"error\"],\" \",[\"0\"]],\"GswZF3\":[\"預覽:\"],\"GtmO8/\":[\"来自\"],\"GtuHUQ\":[\"在伺服器上重新命名此頻道,所有使用者都會看到新名稱。\"],\"GuGfFX\":[\"切换搜索\"],\"GxkJXS\":[\"上傳中...\"],\"GzbwnK\":[\"已加入频道\"],\"GzsUDB\":[\"擴充個人資料\"],\"H/PnT8\":[\"插入表情\"],\"H6Izzl\":[\"您的首選顏色代碼\"],\"H9jIv+\":[\"顯示加入/離開\"],\"HAKBY9\":[\"上傳檔案\"],\"HdE1If\":[\"頻道\"],\"Hk4AW9\":[\"您的首選顯示名稱\"],\"HmHDk7\":[\"选择成员\"],\"HrQzPU\":[[\"networkName\"],\" 上的頻道\"],\"I2tXQ5\":[\"发消息给 @\",[\"0\"],\"(Enter 换行,Shift+Enter 发送)\"],\"I6bw/h\":[\"封禁使用者\"],\"I92Z+b\":[\"启用通知\"],\"I9D72S\":[\"您確定要刪除此訊息嗎?此操作無法復原。\"],\"IA+1wo\":[\"顯示使用者被踢出頻道的事件\"],\"IDwkJx\":[\"IRC Operator\"],\"ILlU+s\":[\"資訊:\"],\"IUwGEM\":[\"保存更改\"],\"IVeGK6\":[[\"0\"],\"、\",[\"1\"],\" 和 \",[\"2\"],\" 正在输入...\"],\"IgrLD/\":[\"暫停\"],\"Im6JED\":[\"WHISPER\"],\"ImOQa9\":[\"回复\"],\"IoHMnl\":[\"最大值為 \",[\"0\"]],\"IvMj+0\":[\"管理员\"],\"J28zul\":[\"正在連線...\"],\"J5T9NW\":[\"使用者資訊\"],\"J8Y5+z\":[\"哎呀!網路分裂!⚠️\"],\"JBHkBA\":[\"已离开频道\"],\"JCwL0Q\":[\"输入原因(可选)\"],\"JFciKP\":[\"切换\"],\"JXGkhG\":[\"更改频道名称(仅限管理员)\"],\"JcD7qf\":[\"更多操作\"],\"JdkA+c\":[\"隱密 (+s)\"],\"Jmu12l\":[\"服务器频道\"],\"JvQ++s\":[\"啟用 Markdown\"],\"K2jwh/\":[\"無可用 WHOIS 資料\"],\"KAXSwC\":[\"语音权限\"],\"KDfTdX\":[\"删除消息\"],\"KKBlUU\":[\"嵌入\"],\"KM0pLb\":[\"欢迎来到本频道!\"],\"KR6W2h\":[\"取消忽略使用者\"],\"KV+Bi1\":[\"僅限邀請 (+i)\"],\"KdCtwE\":[\"在重置計數器之前監控洪水活動的秒數\"],\"Kkezga\":[\"服务器密码\"],\"KsiQ/8\":[\"使用者必須受邀才能加入頻道\"],\"L+gB/D\":[\"頻道資訊\"],\"LC1a7n\":[\"IRC 服务器报告其服务器间链路的安全级别较低。这意味着当您的消息在网络中的 IRC 服务器之间转发时,可能未经过妥善加密,或者 SSL/TLS 证书未被正确验证。\"],\"LNfLR5\":[\"顯示踢出事件\"],\"LP+1Z7\":[\"新增網路\"],\"LQb0W/\":[\"顯示所有事件\"],\"LU7/yA\":[\"顯示用的別名,可包含空格、表情符號和特殊字元。真實頻道名(\",[\"channelName\"],\")仍用於 IRC 指令。\"],\"LUb9O7\":[\"需要有效的服务器端口\"],\"Lb+BUl\":[\"https://example.com/avatar.jpg\"],\"LcET2C\":[\"隱私權政策\"],\"LcuSDR\":[\"管理您的个人资料信息和元数据\"],\"LqLS9B\":[\"顯示暱稱變更\"],\"LsDQt2\":[\"频道设置\"],\"LtI9AS\":[\"频道所有者\"],\"LuNhhL\":[\"对此消息做出了回应\"],\"M/AZNG\":[\"頭像圖片 URL\"],\"M/WIer\":[\"傳送訊息\"],\"M8er/5\":[\"名稱:\"],\"MHk+7g\":[\"上一张图片\"],\"MRorGe\":[\"私信用户\"],\"MVbSGP\":[\"時間視窗(秒)\"],\"MkpcsT\":[\"您的訊息和設定儲存在本機裝置上\"],\"MzPdC2\":[\"伺服器密碼 (PASS)\"],\"N/hDSy\":[\"標記為機器人——通常為 'on' 或空白\"],\"N6j2JH\":[\"編輯 \",[\"0\"]],\"N7TQbE\":[\"邀請使用者加入 \",[\"channelName\"]],\"NCca/o\":[\"輸入預設暱稱...\"],\"Nqs6B9\":[\"显示所有外部媒体。任何 URL 都可能向未知服务器发送请求。\"],\"Nt+9O7\":[\"使用 WebSocket 而非原始 TCP\"],\"NxIHzc\":[\"踢出用戶\"],\"O+v/cL\":[\"浏览服务器上的所有频道\"],\"OCGpR4\":[\"(繼承)\"],\"ODwSCk\":[\"发送 GIF\"],\"OGQ5kK\":[\"配置通知声音和高亮提示\"],\"OIPt1Z\":[\"显示或隐藏成员列表侧栏\"],\"OKSNq/\":[\"非常严格\"],\"ONWvwQ\":[\"上傳\"],\"OVKoQO\":[\"您的帳戶認證密碼\"],\"ObsidianIRC - Bringing IRC to the future\":[\"ObsidianIRC - 將 IRC 帶入未來\"],\"OhCpra\":[\"设置主题…\"],\"OkltoQ\":[\"通过昵称封禁 \",[\"username\"],\"(阻止其使用相同昵称重新加入)\"],\"P+t/Te\":[\"無其他資料\"],\"P42Wcc\":[\"安全\"],\"PD38l0\":[\"频道头像预览\"],\"PD9mEt\":[\"输入消息...\"],\"PPqfdA\":[\"打开频道配置设置\"],\"PSCjfZ\":[\"此頻道顯示的主題,所有使用者均可查看。\"],\"PZCecv\":[\"PDF 預覽\"],\"PeLgsC\":[[\"c\",\"plural\",{\"other\":[[\"c\"],\" 次\"]}]],\"PguS2C\":[\"新增例外遮罩(例如 nick!*@*, *!*@host.com)\"],\"Pil5Ty\":[\"顯示 \",[\"displayedChannelsCount\"],\",共 \",[\"0\"],\" 個頻道\"],\"PqhVlJ\":[\"封禁用户(按 Hostmask)\"],\"Q+chwU\":[\"用戶名:\"],\"Q3v9Wc\":[\"是,刪除\"],\"Q6hhn8\":[\"偏好设置\"],\"QF4a34\":[\"請輸入使用者名稱\"],\"QGqSZ2\":[\"颜色与格式\"],\"QJQd1J\":[\"編輯資料\"],\"QSzGDE\":[\"閒置\"],\"QUlny5\":[\"欢迎来到 \",[\"0\"],\"!\"],\"Qoq+GP\":[\"阅读更多\"],\"QuSkCF\":[\"筛选频道...\"],\"QwUrDZ\":[\"將主題更改為:\",[\"topic\"]],\"R0UH07\":[\"第 \",[\"0\"],\" 張,共 \",[\"1\"],\" 張\"],\"R7SsBE\":[\"靜音\"],\"R8rf1X\":[\"点击设置主题\"],\"RArB3D\":[\"被 \",[\"username\"],\" 踢出 \",[\"channelName\"]],\"RI3cWd\":[\"使用 ObsidianIRC 探索 IRC 的世界\"],\"RMMaN5\":[\"已審核 (+m)\"],\"RWw9Lg\":[\"關閉視窗\"],\"RZ2BuZ\":[\"帳戶 \",[\"account\"],\" 註冊需要驗證:\",[\"message\"]],\"RySp6q\":[\"隱藏評論\"],\"S5Togi\":[\"正在從您的 bouncer 載入網路…\"],\"SPKQTd\":[\"昵称为必填项\"],\"SPVjfj\":[\"留空将默认显示\\\"无原因\\\"\"],\"SQKPvQ\":[\"邀请用户\"],\"STmlpb\":[\"Back to network list\"],\"SkZcl+\":[\"選擇預定義的洪水保護設定檔。這些設定檔為不同使用場景提供均衡的保護設定。\"],\"Slr+3C\":[\"最少使用者數\"],\"Spnlre\":[\"您邀請了 \",[\"target\"],\" 加入 \",[\"channel\"]],\"T/ckN5\":[\"在查看器中打开\"],\"T91vKp\":[\"播放\"],\"TV2Wdu\":[\"了解我們如何處理您的資料並保護您的隱私。\"],\"TgFpwD\":[\"正在套用...\"],\"TkzSFB\":[\"无更改\"],\"TtserG\":[\"输入真实姓名\"],\"Ttz9J1\":[\"輸入密碼...\"],\"Tz0i8g\":[\"設定\"],\"U3pytU\":[\"管理员\"],\"UDb2YD\":[\"添加表情\"],\"UE4KO5\":[\"*channel*\"],\"UGT5vp\":[\"儲存設定\"],\"UV5hLB\":[\"未找到封禁\"],\"Uaj3Nd\":[\"状态消息\"],\"Ue3uny\":[\"預設(無設定檔)\"],\"UkARhe\":[\"普通 – 標準保護\"],\"Umn7Cj\":[\"尚無評論,成為第一個吧!\"],\"UtUIRh\":[[\"0\"],\" 則較早的訊息\"],\"UwzP+U\":[\"安全連線\"],\"V0/A4O\":[\"頻道擁有者\"],\"V4qgxE\":[\"建立時間早於(分鐘前)\"],\"V8yTm6\":[\"清除搜索\"],\"VJMMyz\":[\"ObsidianIRC - 将 IRC 带入未来\"],\"VJScHU\":[\"原因\"],\"VLsmVV\":[\"静音通知\"],\"VbyRUy\":[\"評論\"],\"Vmx0mQ\":[\"設定者:\"],\"VqnIZz\":[\"查看我们的隐私政策和数据使用规范\"],\"VrMygG\":[\"最小長度為 \",[\"0\"]],\"VrnTui\":[\"您的代名詞,顯示在個人資料中\"],\"W8E3qn\":[\"已驗證帳戶\"],\"WAakm9\":[\"删除频道\"],\"WFxTHC\":[\"新增封禁遮罩(例如 nick!*@*, *!*@host.com)\"],\"WN1g9F\":[\"服务器地址为必填项\"],\"WRYdXW\":[\"音频进度\"],\"WUOH5B\":[\"忽略使用者\"],\"WWEXnZ\":[[\"0\",\"plural\",{\"other\":[\"顯示另外 \",[\"1\"],\" 個項目\"]}]],\"Weq9zb\":[\"一般\"],\"Wfj7Sk\":[\"开启或关闭通知声音\"],\"Wm7gbG\":[\"GitHub:\"],\"WyeHWY\":[\"*spam*\"],\"WzMCru\":[\"用户资料\"],\"X6S3lt\":[\"搜索设置、频道、服务器...\"],\"XEHan5\":[\"仍然继续\"],\"XI1+wb\":[\"格式無效\"],\"XIXeuC\":[\"发消息给 @\",[\"0\"]],\"XMS+k4\":[\"发起私信\"],\"XWgxXq\":[\"相簿\"],\"Xd7+IT\":[\"取消置顶私聊\"],\"Xm/s+u\":[\"顯示\"],\"Xp2n93\":[\"显示来自服务器受信任文件主机的媒体。不会向外部服务发送任何请求。\"],\"XvjC4F\":[\"正在儲存...\"],\"Y/qryO\":[\"未找到与搜索条件匹配的用户\"],\"YAqRpI\":[\"帳戶 \",[\"account\"],\" 註冊成功:\",[\"message\"]],\"YEfzvP\":[\"受保護主題 (+t)\"],\"YQOn6a\":[\"收起成员列表\"],\"YRCoE9\":[\"頻道操作員\"],\"YURQaF\":[\"查看個人資料\"],\"YdBSvr\":[\"控制媒体显示和外部内容\"],\"Yj6U3V\":[\"無中央伺服器:\"],\"YjvpGx\":[\"代词\"],\"YqH4l4\":[\"无密钥\"],\"YyUPpV\":[\"帳戶:\"],\"ZJSWfw\":[\"中斷伺服器連線時顯示的訊息\"],\"ZR1dJ4\":[\"邀請\"],\"ZdWg0V\":[\"在浏览器中打开\"],\"ZhRBbl\":[\"搜索消息…\"],\"Zmcu3y\":[\"進階篩選\"],\"a2/8e5\":[\"主題設定時間晚於(分鐘前)\"],\"aHKcKc\":[\"上一页\"],\"aJTbXX\":[\"Oper 密码\"],\"aQryQv\":[\"模式已存在\"],\"aW9pLN\":[\"頻道允許的最大使用者數,留空表示無限制。\"],\"ah4fmZ\":[\"同时显示来自 YouTube、Vimeo、SoundCloud 及类似知名服务的预览。\"],\"aifXak\":[\"此頻道沒有媒體\"],\"ap2zBz\":[\"宽松\"],\"az8lvo\":[\"关\"],\"azXSNo\":[\"展开成员列表\"],\"azdliB\":[\"登录账户\"],\"b26wlF\":[\"她/她的\"],\"bD/+Ei\":[\"严格\"],\"bQ6BJn\":[\"設定詳細的洪水保護規則。每條規則指定要監控的活動類型以及超過閾值時採取的操作。\"],\"beV7+y\":[\"使用者將收到加入 \",[\"channelName\"],\" 的邀請。\"],\"bk84cH\":[\"离开消息\"],\"bkHdLj\":[\"添加 IRC 服务器\"],\"bmQLn5\":[\"新增規則\"],\"bv4cFj\":[\"傳輸方式\"],\"bwRvnp\":[\"操作\"],\"c8+EVZ\":[\"已验证账户\"],\"cGYUlD\":[\"未加载任何媒体预览。\"],\"cLF98o\":[\"顯示評論 (\",[\"commentCount\"],\")\"],\"cLKIDO\":[\"没有可用用户\"],\"cSgpoS\":[\"置顶私聊\"],\"cde3ce\":[\"发消息给 <0>\",[\"0\"],\"\"],\"chQsxg\":[\"複製格式化輸出\"],\"cl/A5J\":[\"欢迎来到 \",[\"__DEFAULT_IRC_SERVER_NAME__\"],\"!\"],\"cnGeoo\":[\"删除\"],\"coPLXT\":[\"我們不在伺服器上儲存您的 IRC 通訊\"],\"crYH/6\":[\"SoundCloud 播放器\"],\"cv5DQb\":[\"未設定主機\"],\"d3sis4\":[\"添加服务器\"],\"d9aN5k\":[\"将 \",[\"username\"],\" 移出频道\"],\"dEgA5A\":[\"取消\"],\"dGi1We\":[\"取消置顶此私信对话\"],\"dJVuyC\":[\"離開了 \",[\"channelName\"],\" (\",[\"reason\"],\")\"],\"dMtLDE\":[\"至\"],\"dXqxlh\":[\"<0>⚠️ 安全风险! 此连接可能容易遭受窃听或中间人攻击。\"],\"da9Q/R\":[\"已更改频道模式\"],\"dhJN3N\":[\"顯示評論\"],\"dj2xTE\":[\"关闭通知\"],\"dpCzmC\":[\"洪水保護設定\"],\"e9dQpT\":[\"是否在新标签页中打开此链接?\"],\"ePK91l\":[\"编辑\"],\"eYBDuB\":[\"上傳圖片或提供含可選 \",[\"size\"],\" 替換的 URL\"],\"edBbee\":[\"通过 hostmask 封禁 \",[\"username\"],\"(阻止其从相同 IP/主机重新加入)\"],\"ekfzWq\":[\"使用者設定\"],\"elPDWs\":[\"自定义您的 IRC 客户端体验\"],\"eu2osY\":[\"<0>💡 建议: 仅在您信任此服务器并了解相关风险的情况下继续操作。避免通过此连接共享敏感信息或密码。\"],\"euEhbr\":[\"點擊加入 \",[\"channel\"]],\"ez3vLd\":[\"啟用多行輸入\"],\"f0J5Ki\":[\"服务器之间的通信可能使用未加密的连接\"],\"f9BHJk\":[\"警告用户\"],\"fDOLLd\":[\"未找到頻道。\"],\"ffzDkB\":[\"匿名分析:\"],\"fq1GF9\":[\"顯示使用者中斷伺服器連線的事件\"],\"gEF57C\":[\"此伺服器僅支援一種連線類型\"],\"gJuLUI\":[\"忽略清單\"],\"gNzMrk\":[\"目前頭像\"],\"gjPWyO\":[\"輸入暱稱...\"],\"gz6UQ3\":[\"最大化\"],\"h6/IMX\":[\"新增您的第一個網路\"],\"h6razj\":[\"排除頻道名稱遮罩\"],\"hG6jnw\":[\"未设置主题\"],\"hG89Ed\":[\"圖片\"],\"hZ6znB\":[\"端口\"],\"ha+Bz5\":[\"例如:100:1440\"],\"hehnjM\":[\"數量\"],\"hzdLuQ\":[\"只有獲得發言權或更高權限的使用者才能發言\"],\"i0qMbr\":[\"主页\"],\"iDNBZe\":[\"通知\"],\"iH8pgl\":[\"返回\"],\"iL9SZg\":[\"封禁用户(按昵称)\"],\"iNt+3c\":[\"返回图片\"],\"iQvi+a\":[\"不再提醒我此服务器的低链路安全问题\"],\"iSLIjg\":[\"連線\"],\"iWXkHH\":[\"半管理员\"],\"iZeTtp\":[\"服务器地址\"],\"idD8Ev\":[\"已儲存\"],\"iivqkW\":[\"登入時間\"],\"ij+Elv\":[\"图片预览\"],\"ilIWp7\":[\"切换通知\"],\"iuaqvB\":[\"使用 * 作為萬用字元。範例:baduser!*@*, *!*@spammer.com, troll*!*@*\"],\"ixkTse\":[\"机器人\"],\"j2DGR0\":[\"依主機遮罩封禁\"],\"jA4uoI\":[\"話題:\"],\"jLXxGK\":[\"https://example.com\"],\"jPSk57\":[\"原因(可选)\"],\"jUV7CU\":[\"上傳頭像\"],\"jW5Uwh\":[\"控制載入的外部媒體量。關閉 / 安全 / 可信來源 / 所有內容。\"],\"jXzms5\":[\"附件选项\"],\"jZlrte\":[\"颜色\"],\"jfC/xh\":[\"聯絡方式\"],\"jywMpv\":[\"#new-channel-name\"],\"k112DD\":[\"載入較早的訊息\"],\"k3ID0F\":[\"筛选成员…\"],\"k65gsE\":[\"深入查看\"],\"k7Zgob\":[\"取消连接\"],\"kAVx5h\":[\"未找到邀請\"],\"kCLEPU\":[\"已連線至\"],\"kF5LKb\":[\"已忽略的模式:\"],\"kGeOx/\":[\"加入 \",[\"0\"]],\"kITKr8\":[\"正在載入頻道模式...\"],\"kPpPsw\":[\"您是 IRC Operator\"],\"kWJmRL\":[\"您\"],\"kfcRb0\":[\"头像\"],\"kjMqSj\":[\"複製 JSON\"],\"krViRy\":[\"點擊以 JSON 格式複製\"],\"ks71ra\":[\"例外\"],\"kw4lRv\":[\"頻道半操作員\"],\"kxgIRq\":[\"选择或添加频道以开始使用。\"],\"ky6dWe\":[\"头像预览\"],\"l+GxCv\":[\"正在載入頻道...\"],\"l+IUVW\":[\"帳戶 \",[\"account\"],\" 驗證成功:\",[\"message\"]],\"l/siQz\":[[\"reconnectCount\",\"plural\",{\"other\":[\"已重新連線 \",[\"reconnectCount\"],\" 次\"]}]],\"l5jmzx\":[[\"0\"],\" 和 \",[\"1\"],\" 正在输入...\"],\"lHy8N5\":[\"正在載入更多頻道...\"],\"lbpf14\":[\"加入 \",[\"value\"]],\"lfFsZ4\":[\"頻道\"],\"lkNdiH\":[\"帳戶名稱\"],\"ln500L\":[\"ObsidianIRC\"],\"lnCMdg\":[\"上传图片\"],\"loQxaJ\":[\"我回来了\"],\"lvfaxv\":[\"首頁\"],\"m0oxpP\":[\"Libera Chat\"],\"m16xKo\":[\"新增\"],\"m8flAk\":[\"預覽(尚未上傳)\"],\"mEPxTp\":[\"<0>⚠️ 请注意! 仅打开来自可信来源的链接。恶意链接可能危害您的安全或隐私。\"],\"mHGdhG\":[\"伺服器資訊\"],\"mHS8lb\":[\"发消息到 #\",[\"0\"]],\"mMYBD9\":[\"寬泛 – 更廣的保護範圍\"],\"mTGsPd\":[\"頻道主題\"],\"mU8j6O\":[\"禁止外部訊息 (+n)\"],\"mZp8FL\":[\"自動回退至單行\"],\"mdQu8G\":[\"YourNickname\"],\"miSSBQ\":[\"評論 (\",[\"commentCount\"],\")\"],\"mvyLSy\":[\"用户已认证\"],\"mwtcGl\":[\"关闭评论\"],\"myL0MR\":[\"刪除此網路?\"],\"mzI/c+\":[\"下载\"],\"n3fGRk\":[\"由 \",[\"0\"],\" 設定\"],\"nE9jsU\":[\"寬鬆 – 較弱的保護\"],\"nNflMD\":[\"离开频道\"],\"nPXkBi\":[\"正在載入 WHOIS 資料...\"],\"nQnxxF\":[\"发消息到 #\",[\"0\"],\"(Shift+Enter 换行)\"],\"nWMRxa\":[\"取消置顶\"],\"nkC032\":[\"无洪水防护配置\"],\"o69z4d\":[\"向 \",[\"username\"],\" 发送警告消息\"],\"o9ylQi\":[\"搜尋 GIF 以開始\"],\"oFGkER\":[\"服务器通知\"],\"oOi11l\":[\"滚动到底部\"],\"oQEzQR\":[\"新私訊\"],\"oXOSPE\":[\"在线\"],\"oal760\":[\"服务器链路可能遭受中间人攻击\"],\"oeqmmJ\":[\"受信任来源\"],\"ovBPCi\":[\"默认\"],\"p0Z69r\":[\"模式不能為空\"],\"p1KgtK\":[\"音频加载失败\"],\"p59pEv\":[\"更多詳情\"],\"p7sRI6\":[\"讓其他人知道您正在輸入\"],\"pBm1od\":[\"秘密频道\"],\"pNmiXx\":[\"所有伺服器的預設暱稱\"],\"pUUo9G\":[\"主機名:\"],\"pVGPmz\":[\"账户密码\"],\"peNE68\":[\"永久\"],\"plhHQt\":[\"無資料\"],\"pm6+q5\":[\"安全警告\"],\"pn5qSs\":[\"其他資訊\"],\"q0cR4S\":[\"現在稱為 **\",[\"newNick\"],\"**\"],\"qFcunY\":[\"頻道不會出現在 LIST 或 NAMES 指令中\"],\"qLpTm/\":[\"移除表情 \",[\"emoji\"]],\"qVkGWK\":[\"置顶\"],\"qY8wNa\":[\"主页\"],\"qb0xJ7\":[\"萬用字元:* 符合任意字元,? 符合單一字元。範例:nick!*@*, *!*@host.com, *!*user@*\"],\"qhzpRq\":[\"頻道金鑰 (+k)\"],\"qtoOYG\":[\"无限制\"],\"r1W2AS\":[\"檔案託管圖片\"],\"rIPR2O\":[\"主題設定時間早於(分鐘前)\"],\"rMMSYo\":[\"最大長度為 \",[\"0\"]],\"rWtzQe\":[\"網路已分裂並重新連接。✅\"],\"rYG2u6\":[\"请稍候...\"],\"rdUucN\":[\"预览\"],\"rjGI/Q\":[\"隐私\"],\"rk8iDX\":[\"正在載入 GIF...\"],\"rn6SBY\":[\"取消靜音\"],\"s/UKqq\":[\"已被踢出频道\"],\"s8cATI\":[\"加入了 \",[\"channelName\"]],\"sCO9ue\":[\"与 <0>\",[\"serverName\"],\" 的连接存在以下安全问题:\"],\"sGH11W\":[\"伺服器\"],\"sHI1H+\":[\"現在稱為 **\",[\"newNick\"],\"**\"],\"sJyV04\":[[\"inviter\"],\" 邀請您加入 \",[\"channel\"]],\"sUBSbK\":[\"尚無上游網路。\"],\"sby+1/\":[\"點擊複製\"],\"sfN25C\":[\"您的真實姓名或全名\"],\"sliuzR\":[\"打开链接\"],\"sqrO9R\":[\"自訂提及\"],\"sr6RdJ\":[\"Shift+Enter 換行\"],\"swrCpB\":[\"頻道已由 \",[\"user\"],\" 從 \",[\"oldName\"],\" 重新命名為 \",[\"newName\"],[\"0\"]],\"sxkWRg\":[\"進階\"],\"t/YqKh\":[\"移除\"],\"t47eHD\":[\"您在此伺服器上的唯一識別碼\"],\"tAkAh0\":[\"含可選 \",[\"size\"],\" 替換的 URL。範例:https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"tXLJS3\":[\"显示或隐藏频道列表侧栏\"],\"tfDRzk\":[\"保存\"],\"tiBsJk\":[\"離開了 \",[\"channelName\"]],\"tt4/UD\":[\"退出了 (\",[\"reason\"],\")\"],\"u0TcnO\":[\"暱稱 {nick} 已被使用,正在用 {newNick} 重試\"],\"u0a8B4\":[\"以 IRC 操作員身分進行管理存取認證\"],\"u0rWFU\":[\"建立時間晚於(分鐘前)\"],\"u72w3t\":[\"要忽略的使用者和模式\"],\"u7jc2L\":[\"退出了\"],\"uAQUqI\":[\"状态\"],\"uB85T3\":[\"儲存失敗:\",[\"msg\"]],\"uV3DOL\":[\"G-Line\"],\"uW3lLI\":[\"IRC 伺服器:\"],\"usSSr/\":[\"缩放级别\"],\"v7uvcf\":[\"軟體:\"],\"vE8kb+\":[\"Shift+Enter 換行(Enter 傳送)\"],\"vERlcd\":[\"个人资料\"],\"vK0RL8\":[\"無主題\"],\"vSJd18\":[\"影片\"],\"vXIe7J\":[\"语言\"],\"vaHYxN\":[\"真实姓名\"],\"vhjbKr\":[\"离开\"],\"w/nogd\":[[\"0\"],\" network\",[\"1\"],\" — pick one to join\"],\"w4NYox\":[[\"title\"],\" 客戶端\"],\"w8xQRx\":[\"值無效\"],\"wFjjxZ\":[\"被 \",[\"username\"],\" 踢出 \",[\"channelName\"],\" (\",[\"reason\"],\")\"],\"wGjaGl\":[\"未找到封禁例外\"],\"wPrGnM\":[\"頻道管理員\"],\"wRkP2d\":[\"GIF\"],\"wbm86v\":[\"顯示使用者加入或離開頻道的事件\"],\"whqZ9r\":[\"要醒目提示的額外詞語或短語\"],\"wm7RV4\":[\"通知聲音\"],\"wz/Yoq\":[\"您的消息在服务器之间转发时可能被截获\"],\"xCJdfg\":[\"清除\"],\"xUHRTR\":[\"連線時自動以操作員身分認證\"],\"xWHwwQ\":[\"封禁\"],\"xYilR2\":[\"媒体\"],\"xceQrO\":[\"仅支持安全的 WebSocket 连接\"],\"xdtXa+\":[\"頻道名稱\"],\"xfXC7q\":[\"文字頻道\"],\"xlCYOE\":[\"正在載入更多訊息...\"],\"xlhswE\":[\"最小值為 \",[\"0\"]],\"xq97Ci\":[\"添加词语或短语...\"],\"xuRqRq\":[\"用戶端限制 (+l)\"],\"xwF+7J\":[[\"0\"],\" 正在输入...\"],\"yJztBY\":[\"刪除網路\"],\"yNeucF\":[\"此伺服器不支援擴充個人資料元資料(IRCv3 METADATA 擴充功能)。頭像、顯示名稱和狀態等欄位不可用。\"],\"yPlrca\":[\"頻道頭像\"],\"yQE2r9\":[\"載入中\"],\"ySU+JY\":[\"your@email.com\"],\"yTX1Rt\":[\"Oper 使用者名稱\"],\"yYOzWD\":[\"日志\"],\"yfx9Re\":[\"IRC 操作員密碼\"],\"ygCKqB\":[\"停止\"],\"ymDxJx\":[\"IRC 操作員使用者名稱\"],\"yrpRsQ\":[\"依名稱排序\"],\"yz7wBu\":[\"关闭\"],\"zJw+jA\":[\"設定模式:\",[\"0\"]],\"zebeLu\":[\"输入 oper 用户名\"],\"zpr0Bw\":[\"GZ-Line\"]}"); \ No newline at end of file diff --git a/src/locales/zh-TW/messages.po b/src/locales/zh-TW/messages.po index bd947d07..0ede7f6c 100644 --- a/src/locales/zh-TW/messages.po +++ b/src/locales/zh-TW/messages.po @@ -22,6 +22,16 @@ msgstr "ObsidianIRC - 將 IRC 帶入未來" msgid "— open in viewer" msgstr "— 在檢視器中開啟" +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx +msgid "(inherit)" +msgstr "(繼承)" + +#: src/components/ui/BouncerNetworkForm.tsx +msgid "(unchanged)" +msgstr "(未變更)" + #. placeholder {0}: filteredMessages.length #. placeholder {1}: import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import type * as React from "react"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { SCROLL_TOLERANCE, useScrollToBottom, } from "../../hooks/useScrollToBottom"; import { groupConsecutiveEvents } from "../../lib/eventGrouping"; import ircClient from "../../lib/ircClient"; import useStore from "../../store"; import type { Message as MessageType } from "../../types"; import { CollapsedEventMessage } from "../message/CollapsedEventMessage"; import { MessageItem } from "../message/MessageItem"; import LoadingSpinner from "../ui/LoadingSpinner"; import { ScrollToBottomButton } from "../ui/ScrollToBottomButton"; export const DEFAULT_VISIBLE_MESSAGE_COUNT = 100; // Stable empty array — prevents selector from returning a new [] on every render // when the channel has no messages yet (undefined ?? [] would create a new ref each time). const EMPTY_MESSAGES: import("../../types").Message[] = []; export interface ChannelMessageListHandle { setAtBottom: () => void; scrollToBottom: () => void; getScrollState: () => { scrollTop: number; isAtBottom: boolean; visibleCount: number; }; } interface ChannelMessageListProps { channelKey: string; serverId: string; channelId: string | null; privateChatId: string | null; isActive: boolean; searchQuery: string; isMemberListVisible: boolean; onReply: (msg: MessageType | null) => void; onUsernameContextMenu: ( e: React.MouseEvent, username: string, serverId: string, channelId: string, avatarEl?: Element | null, ) => void; onIrcLinkClick: (url: string) => void; onReactClick: (msg: MessageType, el: Element) => void; onReactionUnreact: (emoji: string, msg: MessageType) => void; onOpenReactionModal: ( msg: MessageType, position: { x: number; y: number }, ) => void; onDirectReaction: (emoji: string, msg: MessageType) => void; onRedactMessage: (msg: MessageType) => void; onOpenProfile: (username: string) => void; joinChannel: (serverId: string, channelName: string) => void; onClearSearch: () => void; highlightedMessageId?: string; // undefined = first visit; null = was at bottom; object = restore to saved position initialScrollState?: { scrollTop: number; visibleCount: number } | null; } export const ChannelMessageList = forwardRef< ChannelMessageListHandle, ChannelMessageListProps >( ( { channelKey, serverId, channelId, privateChatId, isActive, searchQuery, isMemberListVisible, onReply, onUsernameContextMenu, onIrcLinkClick, onReactClick, onReactionUnreact, onOpenReactionModal, onDirectReaction, onRedactMessage, onOpenProfile, joinChannel, onClearSearch, highlightedMessageId, initialScrollState, }, ref, ) => { const { t } = useLingui(); const [visibleMessageCount, setVisibleMessageCount] = useState( initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT, ); // Ref mirror so getScrollState closure always reads the current value without needing it as a dep. const visibleMessageCountRef = useRef(visibleMessageCount); visibleMessageCountRef.current = visibleMessageCount; // Distinguishes initial join (full-screen spinner) from subsequent "load more" (button spinner). const [isFetchingMore, setIsFetchingMore] = useState(false); const isFetchingMoreRef = useRef(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const messagesInnerRef = useRef(null); // prev scrollHeight for prepend delta-correction. const prevScrollHeightRef = useRef(0); // Ref mirror of isScrolledUp — lets useLayoutEffect closures read current value // without listing isScrolledUp as a dep (which would re-run effects on every scroll). const isScrolledUpRef = useRef(false); const prevFilteredLengthRef = useRef(0); const prevFirstMsgIdRef = useRef(null); // Set by the window-growth layoutEffect (or button handler) when a true prepend is detected. // Consumed by the delta-correction layoutEffect one render later (after visibleCount grows). // Using a flag instead of tracking displayedMessages[0]?.id because slice(-N) slides the // window on every bottom append, changing displayedMessages[0] even for non-prepend renders. const pendingPrependRef = useRef(false); // Shared scrollHeight baseline between the delta-correction layout effect and the inner // ResizeObserver. When scrollTop is corrected after a prepend, we update this so the RO's // "was at bottom" check is not fooled by the adjusted scrollTop vs its stale prevSH. const resizeObserverPrevSHRef = useRef(0); const channelMessages = useStore( useCallback( (state) => state.messages[channelKey] ?? EMPTY_MESSAGES, [channelKey], ), ); const servers = useStore((state) => state.servers); const mobileViewActiveColumn = useStore( (state) => state.ui.mobileViewActiveColumn, ); const channel = useMemo( () => channelId ? (servers .find((s) => s.id === serverId) ?.channels.find((c) => c.id === channelId) ?? null) : null, [servers, serverId, channelId], ); const { isScrolledUp, wasAtBottomRef, scrollToBottom } = useScrollToBottom( messagesContainerRef, messagesEndRef, { channelId: `${channelId || privateChatId}-${isMemberListVisible}` }, ); // Snapshot of the last known scroll position captured while the container was visible. // getScrollState() reads this instead of the live DOM because React commits display:none // before running cleanup effects, collapsing scrollTop/scrollHeight/clientHeight to 0. const lastScrollTopRef = useRef(initialScrollState?.scrollTop ?? 0); useEffect(() => { const container = messagesContainerRef.current; if (!container) return; const onScroll = () => { if (container.clientHeight > 0) lastScrollTopRef.current = container.scrollTop; }; container.addEventListener("scroll", onScroll, { passive: true }); return () => container.removeEventListener("scroll", onScroll); }, []); // Restore scroll position when a keep-alive channel transitions from hidden to visible. // display:none may reset scrollTop to 0; lastScrollTopRef was captured while visible. const prevActiveRef = useRef(isActive); useLayoutEffect(() => { if (isActive && !prevActiveRef.current) { const container = messagesContainerRef.current; if (container && lastScrollTopRef.current > 0) { container.scrollTop = lastScrollTopRef.current; } } prevActiveRef.current = isActive; }, [isActive]); useImperativeHandle(ref, () => ({ setAtBottom: () => { wasAtBottomRef.current = true; }, scrollToBottom, getScrollState: () => ({ scrollTop: lastScrollTopRef.current, isAtBottom: wasAtBottomRef.current, visibleCount: visibleMessageCountRef.current, }), })); const filteredMessages = useMemo(() => { if (!searchQuery.trim()) return channelMessages; const query = searchQuery.toLowerCase(); return channelMessages.filter( (msg) => msg.content.toLowerCase().includes(query) || msg.userId.toLowerCase().includes(query), ); }, [channelMessages, searchQuery]); useEffect(() => { isScrolledUpRef.current = isScrolledUp; // When the user returns to the bottom, shrink the window back to the base so // slice(-N) resumes trimming old messages from the top (memory optimization). // Only shrink if we grew above the base — preserves a sub-default saved visibleCount. if (!isScrolledUp) { setVisibleMessageCount((prev) => prev > DEFAULT_VISIBLE_MESSAGE_COUNT ? DEFAULT_VISIBLE_MESSAGE_COUNT : prev, ); } }, [isScrolledUp]); // Reset ref-tracked windowing state when switching channels. // visibleMessageCount is NOT reset here — useState(initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT) // already initializes it correctly on mount, and this effect runs once on mount for the // same channelKey (each instance is bound to exactly one channel by the parent key={}). // biome-ignore lint/correctness/useExhaustiveDependencies: intentional full reset on channel change useEffect(() => { prevFilteredLengthRef.current = 0; prevFirstMsgIdRef.current = null; prevScrollHeightRef.current = 0; pendingPrependRef.current = false; resizeObserverPrevSHRef.current = 0; }, [channelKey]); const displayedMessages = useMemo(() => { if (searchQuery.trim()) return filteredMessages; return filteredMessages.slice(-visibleMessageCount); }, [filteredMessages, visibleMessageCount, searchQuery]); const locallyHidden = filteredMessages.length > displayedMessages.length; const serverHasMore = channel?.hasMoreHistory === true; const hasMoreMessages = locallyHidden || serverHasMore; const eventGroups = useMemo( () => groupConsecutiveEvents(displayedMessages), [displayedMessages], ); const isLoadingHistory = channel?.isLoadingHistory ?? false; // Scroll to bottom on initial mount, unless a saved position was passed in. // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount only useEffect(() => { const container = messagesContainerRef.current; if (!container) return; if (initialScrollState) { container.scrollTop = initialScrollState.scrollTop; lastScrollTopRef.current = initialScrollState.scrollTop; wasAtBottomRef.current = false; } else { container.scrollTop = container.scrollHeight; lastScrollTopRef.current = container.scrollHeight; wasAtBottomRef.current = true; } }, []); // Scroll to bottom after initial join history loads; clear fetch spinner at batch end. const wasLoadingHistoryRef = useRef(false); // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is stable via useCallback; refs and setters are stable useLayoutEffect(() => { if (wasLoadingHistoryRef.current && !isLoadingHistory) { if (isFetchingMoreRef.current) { // delta correction for scroll position is handled by useLayoutEffect([displayedMessages]) isFetchingMoreRef.current = false; setIsFetchingMore(false); } else { scrollToBottom(); wasAtBottomRef.current = true; } } wasLoadingHistoryRef.current = isLoadingHistory; }, [isLoadingHistory]); // When older messages are prepended, grow the window so they enter displayedMessages. // When new messages arrive at the bottom while the user is scrolled up, also grow the // window to keep the current top messages visible — slice(-N) otherwise slides the // window forward and hides them, incrementing the "N older messages" counter on every // incoming message. Only let the slice trim from the top when the user is at the bottom // (where auto-scroll handles keeping them current). useLayoutEffect(() => { const newLength = filteredMessages.length; const newFirstId = filteredMessages[0]?.id ?? null; const delta = newLength - prevFilteredLengthRef.current; if (prevFilteredLengthRef.current > 0 && delta > 0) { if (newFirstId !== prevFirstMsgIdRef.current) { // Messages prepended (load-more): signal delta-correction to compensate scrollTop. pendingPrependRef.current = true; setVisibleMessageCount((prev) => prev + delta); } else if (isScrolledUpRef.current) { // Messages appended at bottom while user is scrolled up reading history. // Expand the window to prevent top messages from dropping out of the slice. setVisibleMessageCount((prev) => prev + delta); } } prevFilteredLengthRef.current = newLength; prevFirstMsgIdRef.current = newFirstId; }, [filteredMessages]); // Compensate scrollTop when content is prepended above the viewport. // biome-ignore lint/correctness/useExhaustiveDependencies: runs on every displayedMessages render to capture the resulting scrollHeight; refs are stable useLayoutEffect(() => { const container = messagesContainerRef.current; if (!container) return; // Skip while container is display:none — scrollHeight collapses to 0 and would // poison prevScrollHeightRef, causing a huge spurious delta on the next visible render. if (container.clientHeight === 0) return; const prevHeight = prevScrollHeightRef.current; const newHeight = container.scrollHeight; // Only correct when a true load-more prepend happened (flag set by the window-growth // layoutEffect or button handler). Bottom appends slide the slice(-N) window which also // changes displayedMessages[0] — ID-comparison can't distinguish the two cases. const wasPrepend = pendingPrependRef.current; // Only consume the flag when scrollHeight actually changed — the server-side load-more // path goes through two renders: Render A (filteredMessages grows, visibleCount unchanged, // same displayedMessages content, same scrollHeight) then Render B (visibleCount grows, // new messages enter displayedMessages, scrollHeight grows). The flag must survive Render A // so it's still set when Render B fires the actual correction. if (wasPrepend && newHeight !== prevHeight) { pendingPrependRef.current = false; } if ( isScrolledUpRef.current && prevHeight > 0 && newHeight > prevHeight && wasPrepend ) { const delta = newHeight - prevHeight; container.scrollTop += delta; resizeObserverPrevSHRef.current = newHeight; } prevScrollHeightRef.current = newHeight; }, [displayedMessages]); // Re-stick to bottom when inner message content grows (media/audio previews loading). // Uses prevScrollHeight instead of wasAtBottomRef to avoid stale-flag race where the // ref is true while the user is actively scrolling up. // When the container width changes (member list toggle, window resize), text reflows // and scrollHeight changes; preserve proportional scroll position for scrolled-up users. // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is a stable ref useEffect(() => { const container = messagesContainerRef.current; const inner = messagesInnerRef.current; if (!inner || !container) return; resizeObserverPrevSHRef.current = container.scrollHeight; let prevClientWidth = container.clientWidth; const observer = new ResizeObserver(() => { if (container.clientHeight === 0) return; // Effect may re-initialize while container is display:none (ref=0). // Re-seed with current dimensions and skip — no reliable "was at bottom" data. if (resizeObserverPrevSHRef.current === 0) { resizeObserverPrevSHRef.current = container.scrollHeight; prevClientWidth = container.clientWidth; return; } const currentClientWidth = container.clientWidth; const widthChanged = currentClientWidth !== prevClientWidth; prevClientWidth = currentClientWidth; const prevSH = resizeObserverPrevSHRef.current; const wasAtPrevBottom = container.scrollTop + container.clientHeight >= prevSH - SCROLL_TOLERANCE; resizeObserverPrevSHRef.current = container.scrollHeight; if (wasAtPrevBottom) { scrollToBottom(); } else if (widthChanged && prevSH > 0) { const ratio = container.scrollTop / prevSH; container.scrollTop = Math.round(ratio * container.scrollHeight); } }); observer.observe(inner); return () => observer.disconnect(); }, [isLoadingHistory, channelId, privateChatId]); // Auto-scroll on new messages — skip when this channel is hidden (display:none). // biome-ignore lint/correctness/useExhaustiveDependencies: only scroll when messages change, not when isActive changes useEffect(() => { if (!isActive) return; const isNarrowView = window.matchMedia("(max-width: 768px)").matches; const isChatVisible = !isNarrowView || mobileViewActiveColumn === "chatView"; if (wasAtBottomRef.current && isChatVisible) { scrollToBottom(); } }, [displayedMessages, mobileViewActiveColumn, scrollToBottom, isActive]); return ( <>
{isLoadingHistory && !isFetchingMore ? (
) : (
{hasMoreMessages && !searchQuery && (
)} {searchQuery && (
{plural(filteredMessages.length, { one: t`Found 1 message matching "${searchQuery}"`, other: t`Found ${filteredMessages.length} messages matching "${searchQuery}"`, })}
)} {eventGroups.map((group) => { if (group.type === "eventGroup") { const firstId = group.messages[0]?.id || ""; const lastId = group.messages[group.messages.length - 1]?.id || ""; const groupKey = `group-${firstId}-${lastId}`; return ( ); } const message = group.messages[0]; const originalIndex = channelMessages.findIndex( (m) => m.id === message.id, ); const previousMessage = channelMessages[originalIndex - 1]; const showHeader = !previousMessage || previousMessage.type !== "message" || previousMessage.userId !== message.userId || new Date(message.timestamp).getTime() - new Date(previousMessage.timestamp).getTime() > 5 * 60 * 1000; return ( ); })}
)}
); }, ); ChannelMessageList.displayName = "ChannelMessageList"; // Wrap with memo so hidden keep-alive channels skip re-renders when their props // haven't changed (e.g. when messageText changes in the input — the only thing // that changes on typing is local state inside ChatArea, not the props we pass here). export const MemoChannelMessageList = memo(ChannelMessageList); #. placeholder {2}: import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import type * as React from "react"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { SCROLL_TOLERANCE, useScrollToBottom, } from "../../hooks/useScrollToBottom"; import { groupConsecutiveEvents } from "../../lib/eventGrouping"; import ircClient from "../../lib/ircClient"; import useStore from "../../store"; import type { Message as MessageType } from "../../types"; import { CollapsedEventMessage } from "../message/CollapsedEventMessage"; import { MessageItem } from "../message/MessageItem"; import LoadingSpinner from "../ui/LoadingSpinner"; import { ScrollToBottomButton } from "../ui/ScrollToBottomButton"; export const DEFAULT_VISIBLE_MESSAGE_COUNT = 100; // Stable empty array — prevents selector from returning a new [] on every render // when the channel has no messages yet (undefined ?? [] would create a new ref each time). const EMPTY_MESSAGES: import("../../types").Message[] = []; export interface ChannelMessageListHandle { setAtBottom: () => void; scrollToBottom: () => void; getScrollState: () => { scrollTop: number; isAtBottom: boolean; visibleCount: number; }; } interface ChannelMessageListProps { channelKey: string; serverId: string; channelId: string | null; privateChatId: string | null; isActive: boolean; searchQuery: string; isMemberListVisible: boolean; onReply: (msg: MessageType | null) => void; onUsernameContextMenu: ( e: React.MouseEvent, username: string, serverId: string, channelId: string, avatarEl?: Element | null, ) => void; onIrcLinkClick: (url: string) => void; onReactClick: (msg: MessageType, el: Element) => void; onReactionUnreact: (emoji: string, msg: MessageType) => void; onOpenReactionModal: ( msg: MessageType, position: { x: number; y: number }, ) => void; onDirectReaction: (emoji: string, msg: MessageType) => void; onRedactMessage: (msg: MessageType) => void; onOpenProfile: (username: string) => void; joinChannel: (serverId: string, channelName: string) => void; onClearSearch: () => void; highlightedMessageId?: string; // undefined = first visit; null = was at bottom; object = restore to saved position initialScrollState?: { scrollTop: number; visibleCount: number } | null; } export const ChannelMessageList = forwardRef< ChannelMessageListHandle, ChannelMessageListProps >( ( { channelKey, serverId, channelId, privateChatId, isActive, searchQuery, isMemberListVisible, onReply, onUsernameContextMenu, onIrcLinkClick, onReactClick, onReactionUnreact, onOpenReactionModal, onDirectReaction, onRedactMessage, onOpenProfile, joinChannel, onClearSearch, highlightedMessageId, initialScrollState, }, ref, ) => { const { t } = useLingui(); const [visibleMessageCount, setVisibleMessageCount] = useState( initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT, ); // Ref mirror so getScrollState closure always reads the current value without needing it as a dep. const visibleMessageCountRef = useRef(visibleMessageCount); visibleMessageCountRef.current = visibleMessageCount; // Distinguishes initial join (full-screen spinner) from subsequent "load more" (button spinner). const [isFetchingMore, setIsFetchingMore] = useState(false); const isFetchingMoreRef = useRef(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const messagesInnerRef = useRef(null); // prev scrollHeight for prepend delta-correction. const prevScrollHeightRef = useRef(0); // Ref mirror of isScrolledUp — lets useLayoutEffect closures read current value // without listing isScrolledUp as a dep (which would re-run effects on every scroll). const isScrolledUpRef = useRef(false); const prevFilteredLengthRef = useRef(0); const prevFirstMsgIdRef = useRef(null); // Set by the window-growth layoutEffect (or button handler) when a true prepend is detected. // Consumed by the delta-correction layoutEffect one render later (after visibleCount grows). // Using a flag instead of tracking displayedMessages[0]?.id because slice(-N) slides the // window on every bottom append, changing displayedMessages[0] even for non-prepend renders. const pendingPrependRef = useRef(false); // Shared scrollHeight baseline between the delta-correction layout effect and the inner // ResizeObserver. When scrollTop is corrected after a prepend, we update this so the RO's // "was at bottom" check is not fooled by the adjusted scrollTop vs its stale prevSH. const resizeObserverPrevSHRef = useRef(0); const channelMessages = useStore( useCallback( (state) => state.messages[channelKey] ?? EMPTY_MESSAGES, [channelKey], ), ); const servers = useStore((state) => state.servers); const mobileViewActiveColumn = useStore( (state) => state.ui.mobileViewActiveColumn, ); const channel = useMemo( () => channelId ? (servers .find((s) => s.id === serverId) ?.channels.find((c) => c.id === channelId) ?? null) : null, [servers, serverId, channelId], ); const { isScrolledUp, wasAtBottomRef, scrollToBottom } = useScrollToBottom( messagesContainerRef, messagesEndRef, { channelId: `${channelId || privateChatId}-${isMemberListVisible}` }, ); // Snapshot of the last known scroll position captured while the container was visible. // getScrollState() reads this instead of the live DOM because React commits display:none // before running cleanup effects, collapsing scrollTop/scrollHeight/clientHeight to 0. const lastScrollTopRef = useRef(initialScrollState?.scrollTop ?? 0); useEffect(() => { const container = messagesContainerRef.current; if (!container) return; const onScroll = () => { if (container.clientHeight > 0) lastScrollTopRef.current = container.scrollTop; }; container.addEventListener("scroll", onScroll, { passive: true }); return () => container.removeEventListener("scroll", onScroll); }, []); // Restore scroll position when a keep-alive channel transitions from hidden to visible. // display:none may reset scrollTop to 0; lastScrollTopRef was captured while visible. const prevActiveRef = useRef(isActive); useLayoutEffect(() => { if (isActive && !prevActiveRef.current) { const container = messagesContainerRef.current; if (container && lastScrollTopRef.current > 0) { container.scrollTop = lastScrollTopRef.current; } } prevActiveRef.current = isActive; }, [isActive]); useImperativeHandle(ref, () => ({ setAtBottom: () => { wasAtBottomRef.current = true; }, scrollToBottom, getScrollState: () => ({ scrollTop: lastScrollTopRef.current, isAtBottom: wasAtBottomRef.current, visibleCount: visibleMessageCountRef.current, }), })); const filteredMessages = useMemo(() => { if (!searchQuery.trim()) return channelMessages; const query = searchQuery.toLowerCase(); return channelMessages.filter( (msg) => msg.content.toLowerCase().includes(query) || msg.userId.toLowerCase().includes(query), ); }, [channelMessages, searchQuery]); useEffect(() => { isScrolledUpRef.current = isScrolledUp; // When the user returns to the bottom, shrink the window back to the base so // slice(-N) resumes trimming old messages from the top (memory optimization). // Only shrink if we grew above the base — preserves a sub-default saved visibleCount. if (!isScrolledUp) { setVisibleMessageCount((prev) => prev > DEFAULT_VISIBLE_MESSAGE_COUNT ? DEFAULT_VISIBLE_MESSAGE_COUNT : prev, ); } }, [isScrolledUp]); // Reset ref-tracked windowing state when switching channels. // visibleMessageCount is NOT reset here — useState(initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT) // already initializes it correctly on mount, and this effect runs once on mount for the // same channelKey (each instance is bound to exactly one channel by the parent key={}). // biome-ignore lint/correctness/useExhaustiveDependencies: intentional full reset on channel change useEffect(() => { prevFilteredLengthRef.current = 0; prevFirstMsgIdRef.current = null; prevScrollHeightRef.current = 0; pendingPrependRef.current = false; resizeObserverPrevSHRef.current = 0; }, [channelKey]); const displayedMessages = useMemo(() => { if (searchQuery.trim()) return filteredMessages; return filteredMessages.slice(-visibleMessageCount); }, [filteredMessages, visibleMessageCount, searchQuery]); const locallyHidden = filteredMessages.length > displayedMessages.length; const serverHasMore = channel?.hasMoreHistory === true; const hasMoreMessages = locallyHidden || serverHasMore; const eventGroups = useMemo( () => groupConsecutiveEvents(displayedMessages), [displayedMessages], ); const isLoadingHistory = channel?.isLoadingHistory ?? false; // Scroll to bottom on initial mount, unless a saved position was passed in. // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount only useEffect(() => { const container = messagesContainerRef.current; if (!container) return; if (initialScrollState) { container.scrollTop = initialScrollState.scrollTop; lastScrollTopRef.current = initialScrollState.scrollTop; wasAtBottomRef.current = false; } else { container.scrollTop = container.scrollHeight; lastScrollTopRef.current = container.scrollHeight; wasAtBottomRef.current = true; } }, []); // Scroll to bottom after initial join history loads; clear fetch spinner at batch end. const wasLoadingHistoryRef = useRef(false); // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is stable via useCallback; refs and setters are stable useLayoutEffect(() => { if (wasLoadingHistoryRef.current && !isLoadingHistory) { if (isFetchingMoreRef.current) { // delta correction for scroll position is handled by useLayoutEffect([displayedMessages]) isFetchingMoreRef.current = false; setIsFetchingMore(false); } else { scrollToBottom(); wasAtBottomRef.current = true; } } wasLoadingHistoryRef.current = isLoadingHistory; }, [isLoadingHistory]); // When older messages are prepended, grow the window so they enter displayedMessages. // When new messages arrive at the bottom while the user is scrolled up, also grow the // window to keep the current top messages visible — slice(-N) otherwise slides the // window forward and hides them, incrementing the "N older messages" counter on every // incoming message. Only let the slice trim from the top when the user is at the bottom // (where auto-scroll handles keeping them current). useLayoutEffect(() => { const newLength = filteredMessages.length; const newFirstId = filteredMessages[0]?.id ?? null; const delta = newLength - prevFilteredLengthRef.current; if (prevFilteredLengthRef.current > 0 && delta > 0) { if (newFirstId !== prevFirstMsgIdRef.current) { // Messages prepended (load-more): signal delta-correction to compensate scrollTop. pendingPrependRef.current = true; setVisibleMessageCount((prev) => prev + delta); } else if (isScrolledUpRef.current) { // Messages appended at bottom while user is scrolled up reading history. // Expand the window to prevent top messages from dropping out of the slice. setVisibleMessageCount((prev) => prev + delta); } } prevFilteredLengthRef.current = newLength; prevFirstMsgIdRef.current = newFirstId; }, [filteredMessages]); // Compensate scrollTop when content is prepended above the viewport. // biome-ignore lint/correctness/useExhaustiveDependencies: runs on every displayedMessages render to capture the resulting scrollHeight; refs are stable useLayoutEffect(() => { const container = messagesContainerRef.current; if (!container) return; // Skip while container is display:none — scrollHeight collapses to 0 and would // poison prevScrollHeightRef, causing a huge spurious delta on the next visible render. if (container.clientHeight === 0) return; const prevHeight = prevScrollHeightRef.current; const newHeight = container.scrollHeight; // Only correct when a true load-more prepend happened (flag set by the window-growth // layoutEffect or button handler). Bottom appends slide the slice(-N) window which also // changes displayedMessages[0] — ID-comparison can't distinguish the two cases. const wasPrepend = pendingPrependRef.current; // Only consume the flag when scrollHeight actually changed — the server-side load-more // path goes through two renders: Render A (filteredMessages grows, visibleCount unchanged, // same displayedMessages content, same scrollHeight) then Render B (visibleCount grows, // new messages enter displayedMessages, scrollHeight grows). The flag must survive Render A // so it's still set when Render B fires the actual correction. if (wasPrepend && newHeight !== prevHeight) { pendingPrependRef.current = false; } if ( isScrolledUpRef.current && prevHeight > 0 && newHeight > prevHeight && wasPrepend ) { const delta = newHeight - prevHeight; container.scrollTop += delta; resizeObserverPrevSHRef.current = newHeight; } prevScrollHeightRef.current = newHeight; }, [displayedMessages]); // Re-stick to bottom when inner message content grows (media/audio previews loading). // Uses prevScrollHeight instead of wasAtBottomRef to avoid stale-flag race where the // ref is true while the user is actively scrolling up. // When the container width changes (member list toggle, window resize), text reflows // and scrollHeight changes; preserve proportional scroll position for scrolled-up users. // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is a stable ref useEffect(() => { const container = messagesContainerRef.current; const inner = messagesInnerRef.current; if (!inner || !container) return; resizeObserverPrevSHRef.current = container.scrollHeight; let prevClientWidth = container.clientWidth; const observer = new ResizeObserver(() => { if (container.clientHeight === 0) return; // Effect may re-initialize while container is display:none (ref=0). // Re-seed with current dimensions and skip — no reliable "was at bottom" data. if (resizeObserverPrevSHRef.current === 0) { resizeObserverPrevSHRef.current = container.scrollHeight; prevClientWidth = container.clientWidth; return; } const currentClientWidth = container.clientWidth; const widthChanged = currentClientWidth !== prevClientWidth; prevClientWidth = currentClientWidth; const prevSH = resizeObserverPrevSHRef.current; const wasAtPrevBottom = container.scrollTop + container.clientHeight >= prevSH - SCROLL_TOLERANCE; resizeObserverPrevSHRef.current = container.scrollHeight; if (wasAtPrevBottom) { scrollToBottom(); } else if (widthChanged && prevSH > 0) { const ratio = container.scrollTop / prevSH; container.scrollTop = Math.round(ratio * container.scrollHeight); } }); observer.observe(inner); return () => observer.disconnect(); }, [isLoadingHistory, channelId, privateChatId]); // Auto-scroll on new messages — skip when this channel is hidden (display:none). // biome-ignore lint/correctness/useExhaustiveDependencies: only scroll when messages change, not when isActive changes useEffect(() => { if (!isActive) return; const isNarrowView = window.matchMedia("(max-width: 768px)").matches; const isChatVisible = !isNarrowView || mobileViewActiveColumn === "chatView"; if (wasAtBottomRef.current && isChatVisible) { scrollToBottom(); } }, [displayedMessages, mobileViewActiveColumn, scrollToBottom, isActive]); return ( <>
{isLoadingHistory && !isFetchingMore ? (
) : (
{hasMoreMessages && !searchQuery && (
)} {searchQuery && (
{plural(filteredMessages.length, { one: t`Found 1 message matching "${searchQuery}"`, other: t`Found ${filteredMessages.length} messages matching "${searchQuery}"`, })}
)} {eventGroups.map((group) => { if (group.type === "eventGroup") { const firstId = group.messages[0]?.id || ""; const lastId = group.messages[group.messages.length - 1]?.id || ""; const groupKey = `group-${firstId}-${lastId}`; return ( ); } const message = group.messages[0]; const originalIndex = channelMessages.findIndex( (m) => m.id === message.id, ); const previousMessage = channelMessages[originalIndex - 1]; const showHeader = !previousMessage || previousMessage.type !== "message" || previousMessage.userId !== message.userId || new Date(message.timestamp).getTime() - new Date(previousMessage.timestamp).getTime() > 5 * 60 * 1000; return ( ); })}
)}
); }, ); ChannelMessageList.displayName = "ChannelMessageList"; // Wrap with memo so hidden keep-alive channels skip re-renders when their props // haven't changed (e.g. when messageText changes in the input — the only thing // that changes on typing is local state inside ChatArea, not the props we pass here). export const MemoChannelMessageList = memo(ChannelMessageList); @@ -46,6 +56,12 @@ msgstr "{0} 和 {1} 正在输入..." msgid "{0} is typing..." msgstr "{0} 正在输入..." +#. placeholder {0}: networks.length +#. placeholder {1}: networks.length === 1 ? "" : "s" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "{0} network{1} — pick one to join" +msgstr "" + #. placeholder {0}: filteredMessages.length - displayedMessages.length #: src/components/layout/ChannelMessageList.tsx msgid "{0} older messages" @@ -186,6 +202,12 @@ msgstr "新增邀請遮罩(例如 nick!*@*, *!*@host.com)" msgid "Add IRC Server" msgstr "添加 IRC 服务器" +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworksPanel.tsx +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Add Network" +msgstr "新增網路" + #: src/components/message/MessageActions.tsx #: src/components/message/MessageReactions.tsx #: src/components/message/MessageReactions.tsx @@ -205,6 +227,10 @@ msgstr "新增規則" msgid "Add Server" msgstr "添加服务器" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Add your first network" +msgstr "新增您的第一個網路" + #: src/components/message/JsonLogMessage.tsx msgid "Additional Details" msgstr "更多詳情" @@ -358,6 +384,10 @@ msgstr "返回" msgid "Back to image" msgstr "返回图片" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Back to network list" +msgstr "" + #: src/components/ui/ModerationModal.tsx msgid "Ban {username} by hostmask (prevents them from rejoining from the same IP/host)" msgstr "通过 hostmask 封禁 {username}(阻止其从相同 IP/主机重新加入)" @@ -405,6 +435,8 @@ msgstr "浏览服务器上的所有频道" #: src/components/ui/AddPrivateChatModal.tsx #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/ChannelSettingsModal.tsx #: src/components/ui/ExternalLinkWarningModal.tsx #: src/components/ui/FloodSettingsModal.tsx @@ -640,6 +672,7 @@ msgid "Configure notification sounds and highlights" msgstr "配置通知声音和高亮提示" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworksPanel.tsx msgid "Connect" msgstr "連線" @@ -759,6 +792,10 @@ msgstr "删除频道" msgid "Delete message" msgstr "删除消息" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Delete network" +msgstr "刪除網路" + #: src/components/layout/ChannelList.tsx msgid "Delete Private Chat" msgstr "删除私聊" @@ -767,6 +804,10 @@ msgstr "删除私聊" msgid "Delete this message? This cannot be undone." msgstr "刪除此訊息?此操作無法復原。" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Delete this network?" +msgstr "刪除此網路?" + #: src/components/layout/ServerList.tsx #: src/components/mobile/ServerBottomSheet.tsx msgid "Disconnect" @@ -830,10 +871,16 @@ msgstr "下载" msgid "e.g., 100:1440" msgstr "例如:100:1440" +#: src/components/ui/BouncerNetworksPanel.tsx #: src/components/ui/ChannelSettingsModal.tsx msgid "Edit" msgstr "编辑" +#. placeholder {0}: editingNetwork?.attributes.name || editingNetwork?.netid +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Edit {0}" +msgstr "編輯 {0}" + #: src/components/ui/UserProfileModal.tsx msgid "Edit Profile" msgstr "編輯資料" @@ -1057,6 +1104,7 @@ msgstr "首頁" msgid "Homepage" msgstr "主页" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserProfileModal.tsx msgid "Host" msgstr "主機" @@ -1271,6 +1319,10 @@ msgstr "已离开频道" msgid "Let others know when you are typing" msgstr "讓其他人知道您正在輸入" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Libera Chat" +msgstr "Libera Chat" + #: src/components/message/LinkPreview.tsx msgid "Link preview" msgstr "链接预览" @@ -1299,6 +1351,10 @@ msgstr "正在載入 GIF..." msgid "Loading more channels..." msgstr "正在載入更多頻道..." +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Loading networks from your bouncer…" +msgstr "正在從您的 bouncer 載入網路…" + #: src/components/ui/UserProfileModal.tsx msgid "Loading WHOIS data..." msgstr "正在載入 WHOIS 資料..." @@ -1486,9 +1542,15 @@ msgid "Name:" msgstr "名稱:" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx msgid "Network Name" msgstr "网络名称" +#. placeholder {0}: server?.name ?? bouncerServerId +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Networks on {0}" +msgstr "{0} 上的網路" + #: src/components/ui/QuickActions.tsx msgid "New DM" msgstr "新私訊" @@ -1511,6 +1573,7 @@ msgid "nick!user@host (e.g., spam*!*@*, *!*@badhost.com)" msgstr "nick!user@host(例如:spam*!*@*, *!*@badhost.com)" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserSettings.tsx #: src/lib/settings/definitions/allSettings.ts msgid "Nickname" @@ -1570,6 +1633,10 @@ msgstr "未選擇檔案" msgid "No flood profile" msgstr "无洪水防护配置" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "no host set" +msgstr "未設定主機" + #: src/components/ui/ChannelSettingsModal.tsx msgid "No invitations found" msgstr "未找到邀請" @@ -1610,6 +1677,10 @@ msgstr "未设置主题" msgid "No unread mentions or messages" msgstr "沒有未讀提及或訊息" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "No upstream networks yet." +msgstr "尚無上游網路。" + #: src/components/ui/AddPrivateChatModal.tsx msgid "No users available" msgstr "没有可用用户" @@ -1696,6 +1767,10 @@ msgstr "哎呀!網路分裂!⚠️" msgid "Op" msgstr "管理员" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Open" +msgstr "" + #: src/components/ui/QuickActions/uiActionConfig.tsx msgid "Open channel configuration settings" msgstr "打开频道配置设置" @@ -1799,6 +1874,10 @@ msgstr "置顶私聊" msgid "Pin this private message conversation" msgstr "置顶此私信对话" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Plaintext" +msgstr "明文" + #: src/components/message/MediaPreview.tsx #: src/components/message/MediaPreview.tsx #: src/components/message/MediaPreview.tsx @@ -1827,6 +1906,7 @@ msgid "PM User" msgstr "私信用户" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx msgid "Port" msgstr "端口" @@ -1918,6 +1998,7 @@ msgstr "对此消息做出了回应" msgid "Read more" msgstr "阅读更多" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserProfileModal.tsx #: src/components/ui/UserSettings.tsx #: src/lib/settings/definitions/allSettings.ts @@ -2002,6 +2083,7 @@ msgstr "規則" msgid "Safe" msgstr "安全" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/ChannelSettingsModal.tsx #: src/components/ui/TopicModal.tsx #: src/components/ui/UserSettings.tsx @@ -2183,6 +2265,10 @@ msgstr "网络上的服务器管理员可能读取您的消息" msgid "Server Password" msgstr "服务器密码" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Server Password (PASS)" +msgstr "伺服器密碼 (PASS)" + #: src/components/ui/LinkSecurityWarningModal.tsx msgid "Server-to-server communication may use unencrypted connections" msgstr "服务器之间的通信可能使用未加密的连接" @@ -2378,6 +2464,10 @@ msgstr "時間(分鐘)" msgid "Time Window (seconds)" msgstr "時間視窗(秒)" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "TLS" +msgstr "TLS" + #: src/components/message/WhisperMessage.tsx #: src/components/message/WhisperMessage.tsx msgid "to" @@ -2426,6 +2516,10 @@ msgstr "話題:" msgid "Total: {0}" msgstr "總計:{0}" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Transport" +msgstr "傳輸方式" + #: src/components/ui/UserSettings.tsx msgid "Trusted Sources" msgstr "受信任来源" @@ -2536,6 +2630,7 @@ msgstr "用户资料" msgid "User Settings" msgstr "使用者設定" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/InviteUserModal.tsx #: src/components/ui/ModerationModal.tsx msgid "Username" @@ -2683,6 +2778,10 @@ msgstr "寬泛 – 更廣的保護範圍" msgid "Will default to 'no reason' if left empty" msgstr "留空将默认显示\"无原因\"" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Yes, delete" +msgstr "是,刪除" + #: src/components/message/CollapsedEventMessage.tsx #: src/components/message/EventMessage.tsx msgid "You" @@ -2713,6 +2812,10 @@ msgstr "您的帳戶認證密碼" msgid "Your account username for authentication" msgstr "您的帳戶認證使用者名稱" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Your bouncer doesn't have any networks yet. Add one to get started." +msgstr "您的 bouncer 還沒有任何網路。新增一個以開始使用。" + #: src/lib/settings/definitions/allSettings.ts msgid "Your default nickname for all servers" msgstr "所有伺服器的預設暱稱" diff --git a/src/locales/zh/messages.mjs b/src/locales/zh/messages.mjs index 14f4a654..58b7c019 100644 --- a/src/locales/zh/messages.mjs +++ b/src/locales/zh/messages.mjs @@ -1 +1 @@ -/*eslint-disable*/export const messages=JSON.parse("{\"+5VMrz\":[\"无效的模式格式,请使用 nick!user@host 格式(允许通配符 *)\"],\"+6NQQA\":[\"通用支持频道\"],\"+6NyRG\":[\"客户端\"],\"+K0AvT\":[\"断开连接\"],\"+cyFdH\":[\"标记为离开时的默认消息\"],\"+mVPqU\":[\"在消息中渲染 Markdown 格式\"],\"+vqCJH\":[\"您的账户认证用户名\"],\"+yPBXI\":[\"选择文件\"],\"+zy2Nq\":[\"类型\"],\"/09cao\":[\"链路安全级别较低(级别 \",[\"securityLevel\"],\")\"],\"/3BQ4J\":[\"频道外的用户无法发送消息\"],\"/6BzZF\":[\"切换成员列表\"],\"/TNOPk\":[\"用户已离开\"],\"/XQgft\":[\"发现\"],\"/cF7Rs\":[\"音量\"],\"/dqduX\":[\"下一页\"],\"/fc3q4\":[\"所有内容\"],\"/kISDh\":[\"启用通知声音\"],\"/n04sB\":[\"踢出\"],\"/rTz0M\":[\"音频\"],\"/rfkZe\":[\"提及和消息时播放声音\"],\"0/0ZGA\":[\"频道名称掩码\"],\"0D6j7U\":[\"了解更多自定义规则 →\"],\"0XsHcR\":[\"踢出用户\"],\"0ZpE//\":[\"按用户数排序\"],\"0bEPwz\":[\"设置为离开\"],\"0dGkPt\":[\"展开频道列表\"],\"0gS7M5\":[\"显示名称\"],\"0kS+M8\":[\"示例网络\"],\"0rgoY7\":[\"仅连接您选择的服务器\"],\"0wdd7X\":[\"加入\"],\"0wkVYx\":[\"私信\"],\"111uHX\":[\"链接预览\"],\"196EG4\":[\"删除私聊\"],\"1DSr1i\":[\"注册账户\"],\"1O/24y\":[\"切换频道列表\"],\"1VPJJ2\":[\"外部链接警告\"],\"1ZC/dv\":[\"没有未读提及或消息\"],\"1pO1zi\":[\"服务器名称为必填项\"],\"1uwfzQ\":[\"查看频道主题\"],\"268g7c\":[\"输入显示名称\"],\"2FOFq1\":[\"网络上的服务器管理员可能读取您的消息\"],\"2FYpfJ\":[\"更多\"],\"2HF1Y2\":[[\"inviter\"],\" 邀请了 \",[\"target\"],\" 加入 \",[\"channel\"]],\"2I70QL\":[\"查看用户资料信息\"],\"2QYdmE\":[\"用户:\"],\"2QpEjG\":[\"已离开\"],\"2YE223\":[\"发消息到 #\",[\"0\"],\"(Enter 换行,Shift+Enter 发送)\"],\"2bimFY\":[\"使用服务器密码\"],\"2iTmdZ\":[\"本地存储:\"],\"2odkwe\":[\"严格 – 更强的保护\"],\"2uDhbA\":[\"输入要邀请的用户名\"],\"2ygf/L\":[\"← 返回\"],\"2zEgxj\":[\"搜索 GIF...\"],\"3RdPhl\":[\"重命名频道\"],\"3THokf\":[\"有发言权的用户\"],\"3TSz9S\":[\"最小化\"],\"3jBDvM\":[\"频道显示名称\"],\"3ryuFU\":[\"可选的崩溃报告以改善应用\"],\"3uBF/8\":[\"关闭查看器\"],\"3uwW8F\":[\"https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"3xf8Kz\":[\"输入账户名称...\"],\"4/Rr0R\":[\"邀请用户加入当前频道\"],\"4EZrJN\":[\"规则\"],\"4JJtW9\":[\"#overflow\"],\"4NqeT4\":[\"洪水配置文件 (+F)\"],\"4RZQRK\":[\"你在做什么?\"],\"4hfTrB\":[\"昵称\"],\"4n99LO\":[\"已在 \",[\"0\"]],\"4t6vMV\":[\"短消息自动切换到单行\"],\"4vsHmf\":[\"时间(分钟)\"],\"5+INAX\":[\"高亮提及您的消息\"],\"5R5Pv/\":[\"Oper 用户名\"],\"678PKt\":[\"网络名称\"],\"6Aih4U\":[\"离线\"],\"6CO3WE\":[\"加入频道需要密码,留空可移除密钥。\"],\"6HhMs3\":[\"退出消息\"],\"6V3Ea3\":[\"已复制\"],\"6lGV3K\":[\"收起\"],\"6yFOEi\":[\"输入 oper 密码...\"],\"7+IHTZ\":[\"未选择文件\"],\"73hrRi\":[\"nick!user@host(例如:spam*!*@*, *!*@badhost.com)\"],\"7QkKyN\":[\"发送私信\"],\"7U1W7c\":[\"非常宽松\"],\"7Y1YQj\":[\"真实姓名:\"],\"7YHArF\":[\"— 在查看器中打开\"],\"7fjnVl\":[\"搜索用户...\"],\"7jL88x\":[\"删除此消息?此操作无法撤销。\"],\"7nGhhM\":[\"您在想什么?\"],\"7sEpu1\":[\"成员 — \",[\"0\"]],\"7sNhEz\":[\"用户名\"],\"8H0Q+x\":[\"了解更多配置文件 →\"],\"8Phu0A\":[\"显示用户更改昵称的事件\"],\"8XTG9e\":[\"输入 oper 密码\"],\"8XsV2J\":[\"重新发送\"],\"8ZsakT\":[\"密码\"],\"8kR84m\":[\"您即将打开一个外部链接:\"],\"8lCgih\":[\"删除规则\"],\"8p/xVT\":[[\"0\",\"plural\",{\"one\":[[\"1\"]],\"other\":[[\"2\"]]}]],\"8wRzac\":[[\"joinCount\",\"plural\",{\"other\":[\"已加入 \",[\"joinCount\"],\" 次\"]}]],\"9BMLnJ\":[\"重新连接服务器\"],\"9OEgyT\":[\"添加表情\"],\"9PQ8m2\":[\"G-Line(全局封禁)\"],\"9Qs99X\":[\"邮箱:\"],\"9QupBP\":[\"删除规则\"],\"9bG48P\":[\"发送中\"],\"9f5f0u\":[\"有隐私问题?联系我们:\"],\"9unqs3\":[\"离开:\"],\"9v3hwv\":[\"未找到服务器。\"],\"9zb2WA\":[\"连接中\"],\"A1taO8\":[\"搜索\"],\"A2adVi\":[\"发送正在输入通知\"],\"A9Rhec\":[\"频道名称\"],\"AWOSPo\":[\"放大\"],\"AXSpEQ\":[\"连接时获取 Oper\"],\"AeXO77\":[\"账户\"],\"AhNP40\":[\"跳转\"],\"Ai2U7L\":[\"主机\"],\"AjBQnf\":[\"已更改昵称\"],\"AmXVh6\":[\"https://example.com/avatar.png\"],\"AnRu/j\":[\"取消回复\"],\"ApSx0O\":[\"找到 \",[\"0\"],\" 条匹配\\\"\",[\"searchQuery\"],\"\\\"的消息\"],\"AxPAXW\":[\"未找到结果\"],\"AyNqAB\":[\"在聊天中显示所有服务器事件\"],\"B/QqGw\":[\"暂时离开\"],\"B8AaMI\":[\"此字段为必填项\"],\"BA2c49\":[\"服务器不支持高级 LIST 筛选\"],\"BDKt3I\":[[\"0\"],\"、\",[\"1\"],\"、\",[\"2\"],\" 以及另外 \",[\"3\"],\" 人正在输入...\"],\"BGul2A\":[\"您有未保存的更改。确定要关闭而不保存吗?\"],\"BIf9fi\":[\"您的状态消息\"],\"BZz3md\":[\"您的个人网站\"],\"Bgm/H7\":[\"允许输入多行文本\"],\"BiQIl1\":[\"置顶此私信对话\"],\"BlNZZ2\":[\"点击跳转到消息\"],\"Bowq3c\":[\"只有管理员可以更改频道主题\"],\"Btozzp\":[\"此图片已过期\"],\"Bycfjm\":[\"总计:\",[\"0\"]],\"C6IBQc\":[\"复制完整 JSON\"],\"C9L9wL\":[\"数据收集\"],\"CDq4wC\":[\"管理用户\"],\"CHVRxG\":[\"发消息给 @\",[\"0\"],\"(Shift+Enter 换行)\"],\"CN9zdR\":[\"Oper 用户名和密码为必填项\"],\"CW3sYa\":[\"添加表情 \",[\"emoji\"]],\"CaAkqd\":[\"显示退出事件\"],\"CbvaYj\":[\"按昵称封禁\"],\"CcK+Ft\":[\"PDF\"],\"Ce8q3L\":[\"选择频道\"],\"CsekCi\":[\"普通\"],\"D+NlUC\":[\"系统\"],\"D28t6+\":[\"已加入并退出\"],\"DB8zMK\":[\"应用\"],\"DBcWHr\":[\"自定义通知音频文件\"],\"DTy9Xw\":[\"媒体预览\"],\"Dj4pSr\":[\"选择一个安全密码\"],\"Du+zn+\":[\"搜索中...\"],\"Du2T2f\":[\"未找到设置\"],\"DwsSVQ\":[\"应用筛选并刷新\"],\"E3W/zd\":[\"默认昵称\"],\"E6nRW7\":[\"复制链接\"],\"E703RG\":[\"模式:\"],\"EAeu1Z\":[\"发送邀请\"],\"EFKJQT\":[\"设置\"],\"EGPQBv\":[\"自定义洪水规则 (+f)\"],\"ELik0r\":[\"查看完整隐私政策\"],\"EPbeC2\":[\"查看或编辑频道主题\"],\"EQCDNT\":[\"输入 oper 用户名...\"],\"EUvulZ\":[\"找到 1 条匹配\\\"\",[\"searchQuery\"],\"\\\"的消息\"],\"EatZYJ\":[\"下一张图片\"],\"EdQY6l\":[\"无\"],\"EnqLYU\":[\"搜索服务器...\"],\"F0OKMc\":[\"编辑服务器\"],\"F6Int2\":[\"启用高亮\"],\"FDoLyE\":[\"最大用户数\"],\"FUU/hZ\":[\"控制聊天中加载的外部媒体数量。\"],\"Fdp03t\":[\"开启\"],\"FfPWR0\":[\"弹窗\"],\"FjkaiT\":[\"缩小\"],\"FlqOE9\":[\"这意味着:\"],\"FolHNl\":[\"管理您的账户和身份验证\"],\"Fp2Dif\":[\"已退出服务器\"],\"G5KmCc\":[\"GZ-Line(全局 Z-Line)\"],\"GDs0lz\":[\"<0>风险: 敏感信息(消息、私人对话、身份验证详情)可能会暴露给网络管理员或位于 IRC 服务器之间的攻击者。\"],\"GR+2I3\":[\"添加邀请掩码(例如 nick!*@*, *!*@host.com)\"],\"GRLyMU\":[\"关闭弹出的服务器通知\"],\"GlHnXw\":[\"昵称修改失败: \",[\"error\"],\" \",[\"0\"]],\"GswZF3\":[\"预览:\"],\"GtmO8/\":[\"来自\"],\"GtuHUQ\":[\"在服务器上重命名此频道,所有用户都会看到新名称。\"],\"GuGfFX\":[\"切换搜索\"],\"GxkJXS\":[\"上传中...\"],\"GzbwnK\":[\"已加入频道\"],\"GzsUDB\":[\"扩展资料\"],\"H/PnT8\":[\"插入表情\"],\"H6Izzl\":[\"您的首选颜色代码\"],\"H9jIv+\":[\"显示加入/离开\"],\"HAKBY9\":[\"上传文件\"],\"HdE1If\":[\"频道\"],\"Hk4AW9\":[\"您的首选显示名称\"],\"HmHDk7\":[\"选择成员\"],\"HrQzPU\":[[\"networkName\"],\" 上的频道\"],\"I2tXQ5\":[\"发消息给 @\",[\"0\"],\"(Enter 换行,Shift+Enter 发送)\"],\"I6bw/h\":[\"封禁用户\"],\"I92Z+b\":[\"启用通知\"],\"I9D72S\":[\"您确定要删除此消息吗?此操作无法撤销。\"],\"IA+1wo\":[\"显示用户被踢出频道的事件\"],\"IDwkJx\":[\"IRC Operator\"],\"ILlU+s\":[\"信息:\"],\"IUwGEM\":[\"保存更改\"],\"IVeGK6\":[[\"0\"],\"、\",[\"1\"],\" 和 \",[\"2\"],\" 正在输入...\"],\"IgrLD/\":[\"暂停\"],\"Im6JED\":[\"WHISPER\"],\"ImOQa9\":[\"回复\"],\"IoHMnl\":[\"最大值为 \",[\"0\"]],\"IvMj+0\":[\"管理员\"],\"J28zul\":[\"正在连接...\"],\"J5T9NW\":[\"用户信息\"],\"J8Y5+z\":[\"哎呀!网络分裂!⚠️\"],\"JBHkBA\":[\"已离开频道\"],\"JCwL0Q\":[\"输入原因(可选)\"],\"JFciKP\":[\"切换\"],\"JXGkhG\":[\"更改频道名称(仅限管理员)\"],\"JcD7qf\":[\"更多操作\"],\"JdkA+c\":[\"隐秘 (+s)\"],\"Jmu12l\":[\"服务器频道\"],\"JvQ++s\":[\"启用 Markdown\"],\"K2jwh/\":[\"无可用 WHOIS 数据\"],\"KAXSwC\":[\"语音权限\"],\"KDfTdX\":[\"删除消息\"],\"KKBlUU\":[\"嵌入\"],\"KM0pLb\":[\"欢迎来到本频道!\"],\"KR6W2h\":[\"取消忽略用户\"],\"KV+Bi1\":[\"仅限邀请 (+i)\"],\"KdCtwE\":[\"在重置计数器之前监控洪水活动的秒数\"],\"Kkezga\":[\"服务器密码\"],\"KsiQ/8\":[\"用户必须受邀才能加入频道\"],\"L+gB/D\":[\"频道信息\"],\"LC1a7n\":[\"IRC 服务器报告其服务器间链路的安全级别较低。这意味着当您的消息在网络中的 IRC 服务器之间转发时,可能未经过妥善加密,或者 SSL/TLS 证书未被正确验证。\"],\"LNfLR5\":[\"显示踢出事件\"],\"LQb0W/\":[\"显示所有事件\"],\"LU7/yA\":[\"显示用的别名,可包含空格、表情和特殊字符。真实频道名(\",[\"channelName\"],\")仍用于 IRC 命令。\"],\"LUb9O7\":[\"需要有效的服务器端口\"],\"Lb+BUl\":[\"https://example.com/avatar.jpg\"],\"LcET2C\":[\"隐私政策\"],\"LcuSDR\":[\"管理您的个人资料信息和元数据\"],\"LqLS9B\":[\"显示昵称变更\"],\"LsDQt2\":[\"频道设置\"],\"LtI9AS\":[\"频道所有者\"],\"LuNhhL\":[\"对此消息做出了回应\"],\"M/AZNG\":[\"头像图片 URL\"],\"M/WIer\":[\"发送消息\"],\"M8er/5\":[\"名称:\"],\"MHk+7g\":[\"上一张图片\"],\"MRorGe\":[\"私信用户\"],\"MVbSGP\":[\"时间窗口(秒)\"],\"MkpcsT\":[\"您的消息和设置存储在本地设备上\"],\"N/hDSy\":[\"标记为机器人——通常为 'on' 或空\"],\"N7TQbE\":[\"邀请用户加入 \",[\"channelName\"]],\"NCca/o\":[\"输入默认昵称...\"],\"Nqs6B9\":[\"显示所有外部媒体。任何 URL 都可能向未知服务器发送请求。\"],\"Nt+9O7\":[\"使用 WebSocket 而非原始 TCP\"],\"NxIHzc\":[\"踢出用户\"],\"O+v/cL\":[\"浏览服务器上的所有频道\"],\"ODwSCk\":[\"发送 GIF\"],\"OGQ5kK\":[\"配置通知声音和高亮提示\"],\"OIPt1Z\":[\"显示或隐藏成员列表侧栏\"],\"OKSNq/\":[\"非常严格\"],\"ONWvwQ\":[\"上传\"],\"OVKoQO\":[\"您的账户认证密码\"],\"ObsidianIRC - Bringing IRC to the future\":[\"ObsidianIRC - 将 IRC 带入未来\"],\"OhCpra\":[\"设置主题…\"],\"OkltoQ\":[\"通过昵称封禁 \",[\"username\"],\"(阻止其使用相同昵称重新加入)\"],\"P+t/Te\":[\"无其他数据\"],\"P42Wcc\":[\"安全\"],\"PD38l0\":[\"频道头像预览\"],\"PD9mEt\":[\"输入消息...\"],\"PPqfdA\":[\"打开频道配置设置\"],\"PSCjfZ\":[\"此频道显示的主题,所有用户均可查看。\"],\"PZCecv\":[\"PDF 预览\"],\"PeLgsC\":[[\"c\",\"plural\",{\"other\":[[\"c\"],\" 次\"]}]],\"PguS2C\":[\"添加例外掩码(例如 nick!*@*, *!*@host.com)\"],\"Pil5Ty\":[\"显示 \",[\"displayedChannelsCount\"],\",共 \",[\"0\"],\" 个频道\"],\"PqhVlJ\":[\"封禁用户(按 Hostmask)\"],\"Q+chwU\":[\"用户名:\"],\"Q6hhn8\":[\"偏好设置\"],\"QF4a34\":[\"请输入用户名\"],\"QGqSZ2\":[\"颜色与格式\"],\"QJQd1J\":[\"编辑资料\"],\"QSzGDE\":[\"闲置\"],\"QUlny5\":[\"欢迎来到 \",[\"0\"],\"!\"],\"Qoq+GP\":[\"阅读更多\"],\"QuSkCF\":[\"筛选频道...\"],\"QwUrDZ\":[\"将话题更改为:\",[\"topic\"]],\"R0UH07\":[\"第 \",[\"0\"],\" 张,共 \",[\"1\"],\" 张\"],\"R7SsBE\":[\"静音\"],\"R8rf1X\":[\"点击设置主题\"],\"RArB3D\":[\"被 \",[\"username\"],\" 踢出 \",[\"channelName\"]],\"RI3cWd\":[\"使用 ObsidianIRC 探索 IRC 的世界\"],\"RMMaN5\":[\"受管理 (+m)\"],\"RWw9Lg\":[\"关闭窗口\"],\"RZ2BuZ\":[\"账户 \",[\"account\"],\" 注册需要验证:\",[\"message\"]],\"RySp6q\":[\"隐藏评论\"],\"SPKQTd\":[\"昵称为必填项\"],\"SPVjfj\":[\"留空将默认显示\\\"无原因\\\"\"],\"SQKPvQ\":[\"邀请用户\"],\"SkZcl+\":[\"选择预定义的洪水保护配置文件。这些配置文件为不同使用场景提供均衡的保护设置。\"],\"Slr+3C\":[\"最小用户数\"],\"Spnlre\":[\"您邀请了 \",[\"target\"],\" 加入 \",[\"channel\"]],\"T/ckN5\":[\"在查看器中打开\"],\"T91vKp\":[\"播放\"],\"TV2Wdu\":[\"了解我们如何处理您的数据并保护您的隐私。\"],\"TgFpwD\":[\"正在应用...\"],\"TkzSFB\":[\"无更改\"],\"TtserG\":[\"输入真实姓名\"],\"Ttz9J1\":[\"输入密码...\"],\"Tz0i8g\":[\"设置\"],\"U3pytU\":[\"管理员\"],\"UDb2YD\":[\"添加表情\"],\"UE4KO5\":[\"*channel*\"],\"UGT5vp\":[\"保存设置\"],\"UV5hLB\":[\"未找到封禁\"],\"Uaj3Nd\":[\"状态消息\"],\"Ue3uny\":[\"默认(无配置文件)\"],\"UkARhe\":[\"普通 – 标准保护\"],\"Umn7Cj\":[\"暂无评论,成为第一个吧!\"],\"UtUIRh\":[[\"0\"],\" 条旧消息\"],\"UwzP+U\":[\"安全连接\"],\"V0/A4O\":[\"频道所有者\"],\"V4qgxE\":[\"创建时间早于(分钟前)\"],\"V8yTm6\":[\"清除搜索\"],\"VJMMyz\":[\"ObsidianIRC - 将 IRC 带入未来\"],\"VJScHU\":[\"原因\"],\"VLsmVV\":[\"静音通知\"],\"VbyRUy\":[\"评论\"],\"Vmx0mQ\":[\"设置者:\"],\"VqnIZz\":[\"查看我们的隐私政策和数据使用规范\"],\"VrMygG\":[\"最小长度为 \",[\"0\"]],\"VrnTui\":[\"您的代词,显示在个人资料中\"],\"W8E3qn\":[\"已验证账户\"],\"WAakm9\":[\"删除频道\"],\"WFxTHC\":[\"添加封禁掩码(例如 nick!*@*, *!*@host.com)\"],\"WN1g9F\":[\"服务器地址为必填项\"],\"WRYdXW\":[\"音频进度\"],\"WUOH5B\":[\"忽略用户\"],\"WWEXnZ\":[[\"0\",\"plural\",{\"other\":[\"显示另外 \",[\"1\"],\" 个项目\"]}]],\"Weq9zb\":[\"常规\"],\"Wfj7Sk\":[\"开启或关闭通知声音\"],\"Wm7gbG\":[\"GitHub:\"],\"WyeHWY\":[\"*spam*\"],\"WzMCru\":[\"用户资料\"],\"X6S3lt\":[\"搜索设置、频道、服务器...\"],\"XEHan5\":[\"仍然继续\"],\"XI1+wb\":[\"格式无效\"],\"XIXeuC\":[\"发消息给 @\",[\"0\"]],\"XMS+k4\":[\"发起私信\"],\"XWgxXq\":[\"相册\"],\"Xd7+IT\":[\"取消置顶私聊\"],\"Xm/s+u\":[\"显示\"],\"Xp2n93\":[\"显示来自服务器受信任文件主机的媒体。不会向外部服务发送任何请求。\"],\"XvjC4F\":[\"正在保存...\"],\"Y/qryO\":[\"未找到与搜索条件匹配的用户\"],\"YAqRpI\":[\"账户 \",[\"account\"],\" 注册成功:\",[\"message\"]],\"YEfzvP\":[\"受保护主题 (+t)\"],\"YQOn6a\":[\"收起成员列表\"],\"YRCoE9\":[\"频道操作员\"],\"YURQaF\":[\"查看资料\"],\"YdBSvr\":[\"控制媒体显示和外部内容\"],\"Yj6U3V\":[\"无中央服务器:\"],\"YjvpGx\":[\"代词\"],\"YqH4l4\":[\"无密钥\"],\"YyUPpV\":[\"账户:\"],\"ZJSWfw\":[\"断开服务器时显示的消息\"],\"ZR1dJ4\":[\"邀请\"],\"ZdWg0V\":[\"在浏览器中打开\"],\"ZhRBbl\":[\"搜索消息…\"],\"Zmcu3y\":[\"高级筛选\"],\"a2/8e5\":[\"主题设置时间晚于(分钟前)\"],\"aHKcKc\":[\"上一页\"],\"aJTbXX\":[\"Oper 密码\"],\"aQryQv\":[\"模式已存在\"],\"aW9pLN\":[\"频道允许的最大用户数,留空表示无限制。\"],\"ah4fmZ\":[\"同时显示来自 YouTube、Vimeo、SoundCloud 及类似知名服务的预览。\"],\"aifXak\":[\"此频道没有媒体\"],\"ap2zBz\":[\"宽松\"],\"az8lvo\":[\"关\"],\"azXSNo\":[\"展开成员列表\"],\"azdliB\":[\"登录账户\"],\"b26wlF\":[\"她/她的\"],\"bD/+Ei\":[\"严格\"],\"bQ6BJn\":[\"配置详细的洪水保护规则。每条规则指定要监控的活动类型以及超过阈值时采取的操作。\"],\"beV7+y\":[\"用户将收到加入 \",[\"channelName\"],\" 的邀请。\"],\"bk84cH\":[\"离开消息\"],\"bkHdLj\":[\"添加 IRC 服务器\"],\"bmQLn5\":[\"添加规则\"],\"bwRvnp\":[\"操作\"],\"c8+EVZ\":[\"已验证账户\"],\"cGYUlD\":[\"未加载任何媒体预览。\"],\"cLF98o\":[\"显示评论 (\",[\"commentCount\"],\")\"],\"cLKIDO\":[\"没有可用用户\"],\"cSgpoS\":[\"置顶私聊\"],\"cde3ce\":[\"发消息给 <0>\",[\"0\"],\"\"],\"chQsxg\":[\"复制格式化输出\"],\"cl/A5J\":[\"欢迎来到 \",[\"__DEFAULT_IRC_SERVER_NAME__\"],\"!\"],\"cnGeoo\":[\"删除\"],\"coPLXT\":[\"我们不在服务器上存储您的 IRC 通信\"],\"crYH/6\":[\"SoundCloud 播放器\"],\"d3sis4\":[\"添加服务器\"],\"d9aN5k\":[\"将 \",[\"username\"],\" 移出频道\"],\"dEgA5A\":[\"取消\"],\"dGi1We\":[\"取消置顶此私信对话\"],\"dJVuyC\":[\"离开了 \",[\"channelName\"],\" (\",[\"reason\"],\")\"],\"dMtLDE\":[\"至\"],\"dXqxlh\":[\"<0>⚠️ 安全风险! 此连接可能容易遭受窃听或中间人攻击。\"],\"da9Q/R\":[\"已更改频道模式\"],\"dhJN3N\":[\"显示评论\"],\"dj2xTE\":[\"关闭通知\"],\"dpCzmC\":[\"洪水保护设置\"],\"e9dQpT\":[\"是否在新标签页中打开此链接?\"],\"ePK91l\":[\"编辑\"],\"eYBDuB\":[\"上传图片或提供带可选 \",[\"size\"],\" 替换的 URL\"],\"edBbee\":[\"通过 hostmask 封禁 \",[\"username\"],\"(阻止其从相同 IP/主机重新加入)\"],\"ekfzWq\":[\"用户设置\"],\"elPDWs\":[\"自定义您的 IRC 客户端体验\"],\"eu2osY\":[\"<0>💡 建议: 仅在您信任此服务器并了解相关风险的情况下继续操作。避免通过此连接共享敏感信息或密码。\"],\"euEhbr\":[\"点击加入 \",[\"channel\"]],\"ez3vLd\":[\"启用多行输入\"],\"f0J5Ki\":[\"服务器之间的通信可能使用未加密的连接\"],\"f9BHJk\":[\"警告用户\"],\"fDOLLd\":[\"未找到频道。\"],\"ffzDkB\":[\"匿名分析:\"],\"fq1GF9\":[\"显示用户断开服务器连接的事件\"],\"gEF57C\":[\"此服务器仅支持一种连接类型\"],\"gJuLUI\":[\"忽略列表\"],\"gNzMrk\":[\"当前头像\"],\"gjPWyO\":[\"输入昵称...\"],\"gz6UQ3\":[\"最大化\"],\"h6razj\":[\"排除频道名称掩码\"],\"hG6jnw\":[\"未设置主题\"],\"hG89Ed\":[\"图片\"],\"hZ6znB\":[\"端口\"],\"ha+Bz5\":[\"例如:100:1440\"],\"hehnjM\":[\"数量\"],\"hzdLuQ\":[\"只有获得发言权或更高权限的用户才能发言\"],\"i0qMbr\":[\"主页\"],\"iDNBZe\":[\"通知\"],\"iH8pgl\":[\"返回\"],\"iL9SZg\":[\"封禁用户(按昵称)\"],\"iNt+3c\":[\"返回图片\"],\"iQvi+a\":[\"不再提醒我此服务器的低链路安全问题\"],\"iSLIjg\":[\"连接\"],\"iWXkHH\":[\"半管理员\"],\"iZeTtp\":[\"服务器地址\"],\"idD8Ev\":[\"已保存\"],\"iivqkW\":[\"登录时间\"],\"ij+Elv\":[\"图片预览\"],\"ilIWp7\":[\"切换通知\"],\"iuaqvB\":[\"用 * 作通配符。示例:baduser!*@*, *!*@spammer.com, troll*!*@*\"],\"ixkTse\":[\"机器人\"],\"j2DGR0\":[\"按主机掩码封禁\"],\"jA4uoI\":[\"话题:\"],\"jLXxGK\":[\"https://example.com\"],\"jPSk57\":[\"原因(可选)\"],\"jUV7CU\":[\"上传头像\"],\"jW5Uwh\":[\"控制加载的外部媒体量。关闭 / 安全 / 可信来源 / 所有内容。\"],\"jXzms5\":[\"附件选项\"],\"jZlrte\":[\"颜色\"],\"jfC/xh\":[\"联系方式\"],\"jywMpv\":[\"#new-channel-name\"],\"k112DD\":[\"加载旧消息\"],\"k3ID0F\":[\"筛选成员…\"],\"k65gsE\":[\"深入查看\"],\"k7Zgob\":[\"取消连接\"],\"kAVx5h\":[\"未找到邀请\"],\"kCLEPU\":[\"已连接到\"],\"kF5LKb\":[\"已忽略的模式:\"],\"kGeOx/\":[\"加入 \",[\"0\"]],\"kITKr8\":[\"正在加载频道模式...\"],\"kPpPsw\":[\"您是 IRC Operator\"],\"kWJmRL\":[\"您\"],\"kfcRb0\":[\"头像\"],\"kjMqSj\":[\"复制 JSON\"],\"krViRy\":[\"点击以 JSON 格式复制\"],\"ks71ra\":[\"例外\"],\"kw4lRv\":[\"频道半操作员\"],\"kxgIRq\":[\"选择或添加频道以开始使用。\"],\"ky6dWe\":[\"头像预览\"],\"l+GxCv\":[\"正在加载频道...\"],\"l+IUVW\":[\"账户 \",[\"account\"],\" 验证成功:\",[\"message\"]],\"l/siQz\":[[\"reconnectCount\",\"plural\",{\"other\":[\"已重新连接 \",[\"reconnectCount\"],\" 次\"]}]],\"l5jmzx\":[[\"0\"],\" 和 \",[\"1\"],\" 正在输入...\"],\"lHy8N5\":[\"正在加载更多频道...\"],\"lbpf14\":[\"加入 \",[\"value\"]],\"lfFsZ4\":[\"频道\"],\"lkNdiH\":[\"账户名称\"],\"ln500L\":[\"ObsidianIRC\"],\"lnCMdg\":[\"上传图片\"],\"loQxaJ\":[\"我回来了\"],\"lvfaxv\":[\"主页\"],\"m16xKo\":[\"添加\"],\"m8flAk\":[\"预览(尚未上传)\"],\"mEPxTp\":[\"<0>⚠️ 请注意! 仅打开来自可信来源的链接。恶意链接可能危害您的安全或隐私。\"],\"mHGdhG\":[\"服务器信息\"],\"mHS8lb\":[\"发消息到 #\",[\"0\"]],\"mMYBD9\":[\"宽泛 – 更广的保护范围\"],\"mTGsPd\":[\"频道主题\"],\"mU8j6O\":[\"禁止外部消息 (+n)\"],\"mZp8FL\":[\"自动回退到单行\"],\"mdQu8G\":[\"YourNickname\"],\"miSSBQ\":[\"评论 (\",[\"commentCount\"],\")\"],\"mvyLSy\":[\"用户已认证\"],\"mwtcGl\":[\"关闭评论\"],\"mzI/c+\":[\"下载\"],\"n3fGRk\":[\"由 \",[\"0\"],\" 设置\"],\"nE9jsU\":[\"宽松 – 较弱的保护\"],\"nNflMD\":[\"离开频道\"],\"nPXkBi\":[\"正在加载 WHOIS 数据...\"],\"nQnxxF\":[\"发消息到 #\",[\"0\"],\"(Shift+Enter 换行)\"],\"nWMRxa\":[\"取消置顶\"],\"nkC032\":[\"无洪水防护配置\"],\"o69z4d\":[\"向 \",[\"username\"],\" 发送警告消息\"],\"o9ylQi\":[\"搜索 GIF 以开始\"],\"oFGkER\":[\"服务器通知\"],\"oOi11l\":[\"滚动到底部\"],\"oQEzQR\":[\"新私信\"],\"oXOSPE\":[\"在线\"],\"oal760\":[\"服务器链路可能遭受中间人攻击\"],\"oeqmmJ\":[\"受信任来源\"],\"ovBPCi\":[\"默认\"],\"p0Z69r\":[\"模式不能为空\"],\"p1KgtK\":[\"音频加载失败\"],\"p59pEv\":[\"更多详情\"],\"p7sRI6\":[\"让其他人知道您正在输入\"],\"pBm1od\":[\"秘密频道\"],\"pNmiXx\":[\"所有服务器的默认昵称\"],\"pUUo9G\":[\"主机名:\"],\"pVGPmz\":[\"账户密码\"],\"peNE68\":[\"永久\"],\"plhHQt\":[\"无数据\"],\"pm6+q5\":[\"安全警告\"],\"pn5qSs\":[\"附加信息\"],\"q0cR4S\":[\"现在称为 **\",[\"newNick\"],\"**\"],\"qFcunY\":[\"频道不会出现在 LIST 或 NAMES 命令中\"],\"qLpTm/\":[\"移除表情 \",[\"emoji\"]],\"qVkGWK\":[\"置顶\"],\"qY8wNa\":[\"主页\"],\"qb0xJ7\":[\"通配符:* 匹配任意字符,? 匹配单个字符。示例:nick!*@*, *!*@host.com, *!*user@*\"],\"qhzpRq\":[\"频道密钥 (+k)\"],\"qtoOYG\":[\"无限制\"],\"r1W2AS\":[\"文件托管图片\"],\"rIPR2O\":[\"主题设置时间早于(分钟前)\"],\"rMMSYo\":[\"最大长度为 \",[\"0\"]],\"rWtzQe\":[\"网络已分裂并重新连接。✅\"],\"rYG2u6\":[\"请稍候...\"],\"rdUucN\":[\"预览\"],\"rjGI/Q\":[\"隐私\"],\"rk8iDX\":[\"正在加载 GIF...\"],\"rn6SBY\":[\"取消静音\"],\"s/UKqq\":[\"已被踢出频道\"],\"s8cATI\":[\"加入了 \",[\"channelName\"]],\"sCO9ue\":[\"与 <0>\",[\"serverName\"],\" 的连接存在以下安全问题:\"],\"sGH11W\":[\"服务器\"],\"sHI1H+\":[\"现在称为 **\",[\"newNick\"],\"**\"],\"sJyV04\":[[\"inviter\"],\" 邀请您加入 \",[\"channel\"]],\"sby+1/\":[\"点击复制\"],\"sfN25C\":[\"您的真实姓名或全名\"],\"sliuzR\":[\"打开链接\"],\"sqrO9R\":[\"自定义提及\"],\"sr6RdJ\":[\"Shift+Enter 换行\"],\"swrCpB\":[\"频道已由 \",[\"user\"],\" 从 \",[\"oldName\"],\" 重命名为 \",[\"newName\"],[\"0\"]],\"sxkWRg\":[\"高级\"],\"t/YqKh\":[\"移除\"],\"t47eHD\":[\"您在此服务器上的唯一标识符\"],\"tAkAh0\":[\"可选 \",[\"size\"],\" 替换的 URL。示例:https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"tXLJS3\":[\"显示或隐藏频道列表侧栏\"],\"tfDRzk\":[\"保存\"],\"tiBsJk\":[\"离开了 \",[\"channelName\"]],\"tt4/UD\":[\"退出了 (\",[\"reason\"],\")\"],\"u0TcnO\":[\"昵称 {nick} 已被使用,正在用 {newNick} 重试\"],\"u0a8B4\":[\"以 IRC 运营商身份进行管理访问认证\"],\"u0rWFU\":[\"创建时间晚于(分钟前)\"],\"u72w3t\":[\"要忽略的用户和模式\"],\"u7jc2L\":[\"退出了\"],\"uAQUqI\":[\"状态\"],\"uB85T3\":[\"保存失败:\",[\"msg\"]],\"uV3DOL\":[\"G-Line\"],\"uW3lLI\":[\"IRC 服务器:\"],\"usSSr/\":[\"缩放级别\"],\"v7uvcf\":[\"软件:\"],\"vE8kb+\":[\"Shift+Enter 换行(Enter 发送)\"],\"vERlcd\":[\"个人资料\"],\"vK0RL8\":[\"无主题\"],\"vSJd18\":[\"视频\"],\"vXIe7J\":[\"语言\"],\"vaHYxN\":[\"真实姓名\"],\"vhjbKr\":[\"离开\"],\"w4NYox\":[[\"title\"],\" 客户端\"],\"w8xQRx\":[\"值无效\"],\"wFjjxZ\":[\"被 \",[\"username\"],\" 踢出 \",[\"channelName\"],\" (\",[\"reason\"],\")\"],\"wGjaGl\":[\"未找到封禁例外\"],\"wPrGnM\":[\"频道管理员\"],\"wRkP2d\":[\"GIF\"],\"wbm86v\":[\"显示用户加入或离开频道的事件\"],\"whqZ9r\":[\"要高亮的额外词语或短语\"],\"wm7RV4\":[\"通知声音\"],\"wz/Yoq\":[\"您的消息在服务器之间转发时可能被截获\"],\"xCJdfg\":[\"清除\"],\"xUHRTR\":[\"连接时自动以操作员身份认证\"],\"xWHwwQ\":[\"封禁\"],\"xYilR2\":[\"媒体\"],\"xceQrO\":[\"仅支持安全的 WebSocket 连接\"],\"xdtXa+\":[\"频道名称\"],\"xfXC7q\":[\"文字频道\"],\"xlCYOE\":[\"正在加载更多消息...\"],\"xlhswE\":[\"最小值为 \",[\"0\"]],\"xq97Ci\":[\"添加词语或短语...\"],\"xuRqRq\":[\"客户端限制 (+l)\"],\"xwF+7J\":[[\"0\"],\" 正在输入...\"],\"yNeucF\":[\"此服务器不支持扩展个人资料元数据(IRCv3 METADATA 扩展)。头像、显示名称和状态等字段不可用。\"],\"yPlrca\":[\"频道头像\"],\"yQE2r9\":[\"加载中\"],\"ySU+JY\":[\"your@email.com\"],\"yTX1Rt\":[\"Oper 用户名\"],\"yYOzWD\":[\"日志\"],\"yfx9Re\":[\"IRC 运营商密码\"],\"ygCKqB\":[\"停止\"],\"ymDxJx\":[\"IRC 运营商用户名\"],\"yrpRsQ\":[\"按名称排序\"],\"yz7wBu\":[\"关闭\"],\"zJw+jA\":[\"设置模式:\",[\"0\"]],\"zebeLu\":[\"输入 oper 用户名\"],\"zpr0Bw\":[\"GZ-Line\"]}"); \ No newline at end of file +/*eslint-disable*/export const messages=JSON.parse("{\"+5VMrz\":[\"无效的模式格式,请使用 nick!user@host 格式(允许通配符 *)\"],\"+6NQQA\":[\"通用支持频道\"],\"+6NyRG\":[\"客户端\"],\"+K0AvT\":[\"断开连接\"],\"+cyFdH\":[\"标记为离开时的默认消息\"],\"+mVPqU\":[\"在消息中渲染 Markdown 格式\"],\"+vqCJH\":[\"您的账户认证用户名\"],\"+yPBXI\":[\"选择文件\"],\"+zy2Nq\":[\"类型\"],\"/09cao\":[\"链路安全级别较低(级别 \",[\"securityLevel\"],\")\"],\"/3BQ4J\":[\"频道外的用户无法发送消息\"],\"/6BzZF\":[\"切换成员列表\"],\"/TNOPk\":[\"用户已离开\"],\"/XQgft\":[\"发现\"],\"/cF7Rs\":[\"音量\"],\"/dqduX\":[\"下一页\"],\"/fc3q4\":[\"所有内容\"],\"/kISDh\":[\"启用通知声音\"],\"/n04sB\":[\"踢出\"],\"/rTz0M\":[\"音频\"],\"/rfkZe\":[\"提及和消息时播放声音\"],\"0/0ZGA\":[\"频道名称掩码\"],\"0D6j7U\":[\"了解更多自定义规则 →\"],\"0XsHcR\":[\"踢出用户\"],\"0ZpE//\":[\"按用户数排序\"],\"0bEPwz\":[\"设置为离开\"],\"0dGkPt\":[\"展开频道列表\"],\"0gS7M5\":[\"显示名称\"],\"0kS+M8\":[\"示例网络\"],\"0rgoY7\":[\"仅连接您选择的服务器\"],\"0wdd7X\":[\"加入\"],\"0wkVYx\":[\"私信\"],\"111uHX\":[\"链接预览\"],\"196EG4\":[\"删除私聊\"],\"1DSr1i\":[\"注册账户\"],\"1O/24y\":[\"切换频道列表\"],\"1TNIig\":[\"Open\"],\"1VPJJ2\":[\"外部链接警告\"],\"1ZC/dv\":[\"没有未读提及或消息\"],\"1pO1zi\":[\"服务器名称为必填项\"],\"1uwfzQ\":[\"查看频道主题\"],\"268g7c\":[\"输入显示名称\"],\"2FOFq1\":[\"网络上的服务器管理员可能读取您的消息\"],\"2FYpfJ\":[\"更多\"],\"2HF1Y2\":[[\"inviter\"],\" 邀请了 \",[\"target\"],\" 加入 \",[\"channel\"]],\"2I70QL\":[\"查看用户资料信息\"],\"2QYdmE\":[\"用户:\"],\"2QpEjG\":[\"已离开\"],\"2YE223\":[\"发消息到 #\",[\"0\"],\"(Enter 换行,Shift+Enter 发送)\"],\"2bimFY\":[\"使用服务器密码\"],\"2iTmdZ\":[\"本地存储:\"],\"2odkwe\":[\"严格 – 更强的保护\"],\"2uDhbA\":[\"输入要邀请的用户名\"],\"2ygf/L\":[\"← 返回\"],\"2zEgxj\":[\"搜索 GIF...\"],\"3RdPhl\":[\"重命名频道\"],\"3THokf\":[\"有发言权的用户\"],\"3TSz9S\":[\"最小化\"],\"3jBDvM\":[\"频道显示名称\"],\"3ryuFU\":[\"可选的崩溃报告以改善应用\"],\"3uBF/8\":[\"关闭查看器\"],\"3uwW8F\":[\"https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"3xf8Kz\":[\"输入账户名称...\"],\"4/Rr0R\":[\"邀请用户加入当前频道\"],\"4EZrJN\":[\"规则\"],\"4JJtW9\":[\"#overflow\"],\"4NqeT4\":[\"洪水配置文件 (+F)\"],\"4RZQRK\":[\"你在做什么?\"],\"4hfTrB\":[\"昵称\"],\"4n99LO\":[\"已在 \",[\"0\"]],\"4t6vMV\":[\"短消息自动切换到单行\"],\"4vsHmf\":[\"时间(分钟)\"],\"4x/Axu\":[\"您的 bouncer 尚未添加任何网络。添加一个以开始使用。\"],\"5+INAX\":[\"高亮提及您的消息\"],\"5R5Pv/\":[\"Oper 用户名\"],\"678PKt\":[\"网络名称\"],\"6Aih4U\":[\"离线\"],\"6CO3WE\":[\"加入频道需要密码,留空可移除密钥。\"],\"6HhMs3\":[\"退出消息\"],\"6V3Ea3\":[\"已复制\"],\"6lGV3K\":[\"收起\"],\"6yFOEi\":[\"输入 oper 密码...\"],\"7+IHTZ\":[\"未选择文件\"],\"73fnil\":[\"TLS\"],\"73hrRi\":[\"nick!user@host(例如:spam*!*@*, *!*@badhost.com)\"],\"7QkKyN\":[\"发送私信\"],\"7U1W7c\":[\"非常宽松\"],\"7Y1YQj\":[\"真实姓名:\"],\"7YHArF\":[\"— 在查看器中打开\"],\"7fjnVl\":[\"搜索用户...\"],\"7jL88x\":[\"删除此消息?此操作无法撤销。\"],\"7nGhhM\":[\"您在想什么?\"],\"7sEpu1\":[\"成员 — \",[\"0\"]],\"7sNhEz\":[\"用户名\"],\"8H0Q+x\":[\"了解更多配置文件 →\"],\"8Phu0A\":[\"显示用户更改昵称的事件\"],\"8XTG9e\":[\"输入 oper 密码\"],\"8XsV2J\":[\"重新发送\"],\"8ZsakT\":[\"密码\"],\"8kR84m\":[\"您即将打开一个外部链接:\"],\"8lCgih\":[\"删除规则\"],\"8p/xVT\":[[\"0\",\"plural\",{\"one\":[[\"1\"]],\"other\":[[\"2\"]]}]],\"8wRzac\":[[\"joinCount\",\"plural\",{\"other\":[\"已加入 \",[\"joinCount\"],\" 次\"]}]],\"9BMLnJ\":[\"重新连接服务器\"],\"9OEgyT\":[\"添加表情\"],\"9PQ8m2\":[\"G-Line(全局封禁)\"],\"9Qs99X\":[\"邮箱:\"],\"9QupBP\":[\"删除规则\"],\"9W7tl5\":[\"(未更改)\"],\"9bG48P\":[\"发送中\"],\"9f5f0u\":[\"有隐私问题?联系我们:\"],\"9iweoP\":[[\"0\"],\" 上的网络\"],\"9unqs3\":[\"离开:\"],\"9v3hwv\":[\"未找到服务器。\"],\"9zb2WA\":[\"连接中\"],\"A1taO8\":[\"搜索\"],\"A2adVi\":[\"发送正在输入通知\"],\"A9Rhec\":[\"频道名称\"],\"AWOSPo\":[\"放大\"],\"AXSpEQ\":[\"连接时获取 Oper\"],\"AeXO77\":[\"账户\"],\"AhNP40\":[\"跳转\"],\"Ai2U7L\":[\"主机\"],\"AjBQnf\":[\"已更改昵称\"],\"AmXVh6\":[\"https://example.com/avatar.png\"],\"AnRu/j\":[\"取消回复\"],\"ApSx0O\":[\"找到 \",[\"0\"],\" 条匹配\\\"\",[\"searchQuery\"],\"\\\"的消息\"],\"AxPAXW\":[\"未找到结果\"],\"AyNqAB\":[\"在聊天中显示所有服务器事件\"],\"B/QqGw\":[\"暂时离开\"],\"B0sB2k\":[\"明文\"],\"B8AaMI\":[\"此字段为必填项\"],\"BA2c49\":[\"服务器不支持高级 LIST 筛选\"],\"BDKt3I\":[[\"0\"],\"、\",[\"1\"],\"、\",[\"2\"],\" 以及另外 \",[\"3\"],\" 人正在输入...\"],\"BGul2A\":[\"您有未保存的更改。确定要关闭而不保存吗?\"],\"BIf9fi\":[\"您的状态消息\"],\"BZz3md\":[\"您的个人网站\"],\"Bgm/H7\":[\"允许输入多行文本\"],\"BiQIl1\":[\"置顶此私信对话\"],\"BlNZZ2\":[\"点击跳转到消息\"],\"Bowq3c\":[\"只有管理员可以更改频道主题\"],\"Btozzp\":[\"此图片已过期\"],\"Bycfjm\":[\"总计:\",[\"0\"]],\"C6IBQc\":[\"复制完整 JSON\"],\"C9L9wL\":[\"数据收集\"],\"CDq4wC\":[\"管理用户\"],\"CHVRxG\":[\"发消息给 @\",[\"0\"],\"(Shift+Enter 换行)\"],\"CN9zdR\":[\"Oper 用户名和密码为必填项\"],\"CW3sYa\":[\"添加表情 \",[\"emoji\"]],\"CaAkqd\":[\"显示退出事件\"],\"CbvaYj\":[\"按昵称封禁\"],\"CcK+Ft\":[\"PDF\"],\"Ce8q3L\":[\"选择频道\"],\"CsekCi\":[\"普通\"],\"D+NlUC\":[\"系统\"],\"D28t6+\":[\"已加入并退出\"],\"DB8zMK\":[\"应用\"],\"DBcWHr\":[\"自定义通知音频文件\"],\"DTy9Xw\":[\"媒体预览\"],\"Dj4pSr\":[\"选择一个安全密码\"],\"Du+zn+\":[\"搜索中...\"],\"Du2T2f\":[\"未找到设置\"],\"DwsSVQ\":[\"应用筛选并刷新\"],\"E3W/zd\":[\"默认昵称\"],\"E6nRW7\":[\"复制链接\"],\"E703RG\":[\"模式:\"],\"EAeu1Z\":[\"发送邀请\"],\"EFKJQT\":[\"设置\"],\"EGPQBv\":[\"自定义洪水规则 (+f)\"],\"ELik0r\":[\"查看完整隐私政策\"],\"EPbeC2\":[\"查看或编辑频道主题\"],\"EQCDNT\":[\"输入 oper 用户名...\"],\"EUvulZ\":[\"找到 1 条匹配\\\"\",[\"searchQuery\"],\"\\\"的消息\"],\"EatZYJ\":[\"下一张图片\"],\"EdQY6l\":[\"无\"],\"EnqLYU\":[\"搜索服务器...\"],\"F0OKMc\":[\"编辑服务器\"],\"F6Int2\":[\"启用高亮\"],\"FDoLyE\":[\"最大用户数\"],\"FUU/hZ\":[\"控制聊天中加载的外部媒体数量。\"],\"Fdp03t\":[\"开启\"],\"FfPWR0\":[\"弹窗\"],\"FjkaiT\":[\"缩小\"],\"FlqOE9\":[\"这意味着:\"],\"FolHNl\":[\"管理您的账户和身份验证\"],\"Fp2Dif\":[\"已退出服务器\"],\"G5KmCc\":[\"GZ-Line(全局 Z-Line)\"],\"GDs0lz\":[\"<0>风险: 敏感信息(消息、私人对话、身份验证详情)可能会暴露给网络管理员或位于 IRC 服务器之间的攻击者。\"],\"GR+2I3\":[\"添加邀请掩码(例如 nick!*@*, *!*@host.com)\"],\"GRLyMU\":[\"关闭弹出的服务器通知\"],\"GlHnXw\":[\"昵称修改失败: \",[\"error\"],\" \",[\"0\"]],\"GswZF3\":[\"预览:\"],\"GtmO8/\":[\"来自\"],\"GtuHUQ\":[\"在服务器上重命名此频道,所有用户都会看到新名称。\"],\"GuGfFX\":[\"切换搜索\"],\"GxkJXS\":[\"上传中...\"],\"GzbwnK\":[\"已加入频道\"],\"GzsUDB\":[\"扩展资料\"],\"H/PnT8\":[\"插入表情\"],\"H6Izzl\":[\"您的首选颜色代码\"],\"H9jIv+\":[\"显示加入/离开\"],\"HAKBY9\":[\"上传文件\"],\"HdE1If\":[\"频道\"],\"Hk4AW9\":[\"您的首选显示名称\"],\"HmHDk7\":[\"选择成员\"],\"HrQzPU\":[[\"networkName\"],\" 上的频道\"],\"I2tXQ5\":[\"发消息给 @\",[\"0\"],\"(Enter 换行,Shift+Enter 发送)\"],\"I6bw/h\":[\"封禁用户\"],\"I92Z+b\":[\"启用通知\"],\"I9D72S\":[\"您确定要删除此消息吗?此操作无法撤销。\"],\"IA+1wo\":[\"显示用户被踢出频道的事件\"],\"IDwkJx\":[\"IRC Operator\"],\"ILlU+s\":[\"信息:\"],\"IUwGEM\":[\"保存更改\"],\"IVeGK6\":[[\"0\"],\"、\",[\"1\"],\" 和 \",[\"2\"],\" 正在输入...\"],\"IgrLD/\":[\"暂停\"],\"Im6JED\":[\"WHISPER\"],\"ImOQa9\":[\"回复\"],\"IoHMnl\":[\"最大值为 \",[\"0\"]],\"IvMj+0\":[\"管理员\"],\"J28zul\":[\"正在连接...\"],\"J5T9NW\":[\"用户信息\"],\"J8Y5+z\":[\"哎呀!网络分裂!⚠️\"],\"JBHkBA\":[\"已离开频道\"],\"JCwL0Q\":[\"输入原因(可选)\"],\"JFciKP\":[\"切换\"],\"JXGkhG\":[\"更改频道名称(仅限管理员)\"],\"JcD7qf\":[\"更多操作\"],\"JdkA+c\":[\"隐秘 (+s)\"],\"Jmu12l\":[\"服务器频道\"],\"JvQ++s\":[\"启用 Markdown\"],\"K2jwh/\":[\"无可用 WHOIS 数据\"],\"KAXSwC\":[\"语音权限\"],\"KDfTdX\":[\"删除消息\"],\"KKBlUU\":[\"嵌入\"],\"KM0pLb\":[\"欢迎来到本频道!\"],\"KR6W2h\":[\"取消忽略用户\"],\"KV+Bi1\":[\"仅限邀请 (+i)\"],\"KdCtwE\":[\"在重置计数器之前监控洪水活动的秒数\"],\"Kkezga\":[\"服务器密码\"],\"KsiQ/8\":[\"用户必须受邀才能加入频道\"],\"L+gB/D\":[\"频道信息\"],\"LC1a7n\":[\"IRC 服务器报告其服务器间链路的安全级别较低。这意味着当您的消息在网络中的 IRC 服务器之间转发时,可能未经过妥善加密,或者 SSL/TLS 证书未被正确验证。\"],\"LNfLR5\":[\"显示踢出事件\"],\"LP+1Z7\":[\"添加网络\"],\"LQb0W/\":[\"显示所有事件\"],\"LU7/yA\":[\"显示用的别名,可包含空格、表情和特殊字符。真实频道名(\",[\"channelName\"],\")仍用于 IRC 命令。\"],\"LUb9O7\":[\"需要有效的服务器端口\"],\"Lb+BUl\":[\"https://example.com/avatar.jpg\"],\"LcET2C\":[\"隐私政策\"],\"LcuSDR\":[\"管理您的个人资料信息和元数据\"],\"LqLS9B\":[\"显示昵称变更\"],\"LsDQt2\":[\"频道设置\"],\"LtI9AS\":[\"频道所有者\"],\"LuNhhL\":[\"对此消息做出了回应\"],\"M/AZNG\":[\"头像图片 URL\"],\"M/WIer\":[\"发送消息\"],\"M8er/5\":[\"名称:\"],\"MHk+7g\":[\"上一张图片\"],\"MRorGe\":[\"私信用户\"],\"MVbSGP\":[\"时间窗口(秒)\"],\"MkpcsT\":[\"您的消息和设置存储在本地设备上\"],\"MzPdC2\":[\"服务器密码 (PASS)\"],\"N/hDSy\":[\"标记为机器人——通常为 'on' 或空\"],\"N6j2JH\":[\"编辑 \",[\"0\"]],\"N7TQbE\":[\"邀请用户加入 \",[\"channelName\"]],\"NCca/o\":[\"输入默认昵称...\"],\"Nqs6B9\":[\"显示所有外部媒体。任何 URL 都可能向未知服务器发送请求。\"],\"Nt+9O7\":[\"使用 WebSocket 而非原始 TCP\"],\"NxIHzc\":[\"踢出用户\"],\"O+v/cL\":[\"浏览服务器上的所有频道\"],\"OCGpR4\":[\"(继承)\"],\"ODwSCk\":[\"发送 GIF\"],\"OGQ5kK\":[\"配置通知声音和高亮提示\"],\"OIPt1Z\":[\"显示或隐藏成员列表侧栏\"],\"OKSNq/\":[\"非常严格\"],\"ONWvwQ\":[\"上传\"],\"OVKoQO\":[\"您的账户认证密码\"],\"ObsidianIRC - Bringing IRC to the future\":[\"ObsidianIRC - 将 IRC 带入未来\"],\"OhCpra\":[\"设置主题…\"],\"OkltoQ\":[\"通过昵称封禁 \",[\"username\"],\"(阻止其使用相同昵称重新加入)\"],\"P+t/Te\":[\"无其他数据\"],\"P42Wcc\":[\"安全\"],\"PD38l0\":[\"频道头像预览\"],\"PD9mEt\":[\"输入消息...\"],\"PPqfdA\":[\"打开频道配置设置\"],\"PSCjfZ\":[\"此频道显示的主题,所有用户均可查看。\"],\"PZCecv\":[\"PDF 预览\"],\"PeLgsC\":[[\"c\",\"plural\",{\"other\":[[\"c\"],\" 次\"]}]],\"PguS2C\":[\"添加例外掩码(例如 nick!*@*, *!*@host.com)\"],\"Pil5Ty\":[\"显示 \",[\"displayedChannelsCount\"],\",共 \",[\"0\"],\" 个频道\"],\"PqhVlJ\":[\"封禁用户(按 Hostmask)\"],\"Q+chwU\":[\"用户名:\"],\"Q3v9Wc\":[\"是,删除\"],\"Q6hhn8\":[\"偏好设置\"],\"QF4a34\":[\"请输入用户名\"],\"QGqSZ2\":[\"颜色与格式\"],\"QJQd1J\":[\"编辑资料\"],\"QSzGDE\":[\"闲置\"],\"QUlny5\":[\"欢迎来到 \",[\"0\"],\"!\"],\"Qoq+GP\":[\"阅读更多\"],\"QuSkCF\":[\"筛选频道...\"],\"QwUrDZ\":[\"将话题更改为:\",[\"topic\"]],\"R0UH07\":[\"第 \",[\"0\"],\" 张,共 \",[\"1\"],\" 张\"],\"R7SsBE\":[\"静音\"],\"R8rf1X\":[\"点击设置主题\"],\"RArB3D\":[\"被 \",[\"username\"],\" 踢出 \",[\"channelName\"]],\"RI3cWd\":[\"使用 ObsidianIRC 探索 IRC 的世界\"],\"RMMaN5\":[\"受管理 (+m)\"],\"RWw9Lg\":[\"关闭窗口\"],\"RZ2BuZ\":[\"账户 \",[\"account\"],\" 注册需要验证:\",[\"message\"]],\"RySp6q\":[\"隐藏评论\"],\"S5Togi\":[\"正在从您的 bouncer 加载网络…\"],\"SPKQTd\":[\"昵称为必填项\"],\"SPVjfj\":[\"留空将默认显示\\\"无原因\\\"\"],\"SQKPvQ\":[\"邀请用户\"],\"STmlpb\":[\"Back to network list\"],\"SkZcl+\":[\"选择预定义的洪水保护配置文件。这些配置文件为不同使用场景提供均衡的保护设置。\"],\"Slr+3C\":[\"最小用户数\"],\"Spnlre\":[\"您邀请了 \",[\"target\"],\" 加入 \",[\"channel\"]],\"T/ckN5\":[\"在查看器中打开\"],\"T91vKp\":[\"播放\"],\"TV2Wdu\":[\"了解我们如何处理您的数据并保护您的隐私。\"],\"TgFpwD\":[\"正在应用...\"],\"TkzSFB\":[\"无更改\"],\"TtserG\":[\"输入真实姓名\"],\"Ttz9J1\":[\"输入密码...\"],\"Tz0i8g\":[\"设置\"],\"U3pytU\":[\"管理员\"],\"UDb2YD\":[\"添加表情\"],\"UE4KO5\":[\"*channel*\"],\"UGT5vp\":[\"保存设置\"],\"UV5hLB\":[\"未找到封禁\"],\"Uaj3Nd\":[\"状态消息\"],\"Ue3uny\":[\"默认(无配置文件)\"],\"UkARhe\":[\"普通 – 标准保护\"],\"Umn7Cj\":[\"暂无评论,成为第一个吧!\"],\"UtUIRh\":[[\"0\"],\" 条旧消息\"],\"UwzP+U\":[\"安全连接\"],\"V0/A4O\":[\"频道所有者\"],\"V4qgxE\":[\"创建时间早于(分钟前)\"],\"V8yTm6\":[\"清除搜索\"],\"VJMMyz\":[\"ObsidianIRC - 将 IRC 带入未来\"],\"VJScHU\":[\"原因\"],\"VLsmVV\":[\"静音通知\"],\"VbyRUy\":[\"评论\"],\"Vmx0mQ\":[\"设置者:\"],\"VqnIZz\":[\"查看我们的隐私政策和数据使用规范\"],\"VrMygG\":[\"最小长度为 \",[\"0\"]],\"VrnTui\":[\"您的代词,显示在个人资料中\"],\"W8E3qn\":[\"已验证账户\"],\"WAakm9\":[\"删除频道\"],\"WFxTHC\":[\"添加封禁掩码(例如 nick!*@*, *!*@host.com)\"],\"WN1g9F\":[\"服务器地址为必填项\"],\"WRYdXW\":[\"音频进度\"],\"WUOH5B\":[\"忽略用户\"],\"WWEXnZ\":[[\"0\",\"plural\",{\"other\":[\"显示另外 \",[\"1\"],\" 个项目\"]}]],\"Weq9zb\":[\"常规\"],\"Wfj7Sk\":[\"开启或关闭通知声音\"],\"Wm7gbG\":[\"GitHub:\"],\"WyeHWY\":[\"*spam*\"],\"WzMCru\":[\"用户资料\"],\"X6S3lt\":[\"搜索设置、频道、服务器...\"],\"XEHan5\":[\"仍然继续\"],\"XI1+wb\":[\"格式无效\"],\"XIXeuC\":[\"发消息给 @\",[\"0\"]],\"XMS+k4\":[\"发起私信\"],\"XWgxXq\":[\"相册\"],\"Xd7+IT\":[\"取消置顶私聊\"],\"Xm/s+u\":[\"显示\"],\"Xp2n93\":[\"显示来自服务器受信任文件主机的媒体。不会向外部服务发送任何请求。\"],\"XvjC4F\":[\"正在保存...\"],\"Y/qryO\":[\"未找到与搜索条件匹配的用户\"],\"YAqRpI\":[\"账户 \",[\"account\"],\" 注册成功:\",[\"message\"]],\"YEfzvP\":[\"受保护主题 (+t)\"],\"YQOn6a\":[\"收起成员列表\"],\"YRCoE9\":[\"频道操作员\"],\"YURQaF\":[\"查看资料\"],\"YdBSvr\":[\"控制媒体显示和外部内容\"],\"Yj6U3V\":[\"无中央服务器:\"],\"YjvpGx\":[\"代词\"],\"YqH4l4\":[\"无密钥\"],\"YyUPpV\":[\"账户:\"],\"ZJSWfw\":[\"断开服务器时显示的消息\"],\"ZR1dJ4\":[\"邀请\"],\"ZdWg0V\":[\"在浏览器中打开\"],\"ZhRBbl\":[\"搜索消息…\"],\"Zmcu3y\":[\"高级筛选\"],\"a2/8e5\":[\"主题设置时间晚于(分钟前)\"],\"aHKcKc\":[\"上一页\"],\"aJTbXX\":[\"Oper 密码\"],\"aQryQv\":[\"模式已存在\"],\"aW9pLN\":[\"频道允许的最大用户数,留空表示无限制。\"],\"ah4fmZ\":[\"同时显示来自 YouTube、Vimeo、SoundCloud 及类似知名服务的预览。\"],\"aifXak\":[\"此频道没有媒体\"],\"ap2zBz\":[\"宽松\"],\"az8lvo\":[\"关\"],\"azXSNo\":[\"展开成员列表\"],\"azdliB\":[\"登录账户\"],\"b26wlF\":[\"她/她的\"],\"bD/+Ei\":[\"严格\"],\"bQ6BJn\":[\"配置详细的洪水保护规则。每条规则指定要监控的活动类型以及超过阈值时采取的操作。\"],\"beV7+y\":[\"用户将收到加入 \",[\"channelName\"],\" 的邀请。\"],\"bk84cH\":[\"离开消息\"],\"bkHdLj\":[\"添加 IRC 服务器\"],\"bmQLn5\":[\"添加规则\"],\"bv4cFj\":[\"传输方式\"],\"bwRvnp\":[\"操作\"],\"c8+EVZ\":[\"已验证账户\"],\"cGYUlD\":[\"未加载任何媒体预览。\"],\"cLF98o\":[\"显示评论 (\",[\"commentCount\"],\")\"],\"cLKIDO\":[\"没有可用用户\"],\"cSgpoS\":[\"置顶私聊\"],\"cde3ce\":[\"发消息给 <0>\",[\"0\"],\"\"],\"chQsxg\":[\"复制格式化输出\"],\"cl/A5J\":[\"欢迎来到 \",[\"__DEFAULT_IRC_SERVER_NAME__\"],\"!\"],\"cnGeoo\":[\"删除\"],\"coPLXT\":[\"我们不在服务器上存储您的 IRC 通信\"],\"crYH/6\":[\"SoundCloud 播放器\"],\"cv5DQb\":[\"未设置主机\"],\"d3sis4\":[\"添加服务器\"],\"d9aN5k\":[\"将 \",[\"username\"],\" 移出频道\"],\"dEgA5A\":[\"取消\"],\"dGi1We\":[\"取消置顶此私信对话\"],\"dJVuyC\":[\"离开了 \",[\"channelName\"],\" (\",[\"reason\"],\")\"],\"dMtLDE\":[\"至\"],\"dXqxlh\":[\"<0>⚠️ 安全风险! 此连接可能容易遭受窃听或中间人攻击。\"],\"da9Q/R\":[\"已更改频道模式\"],\"dhJN3N\":[\"显示评论\"],\"dj2xTE\":[\"关闭通知\"],\"dpCzmC\":[\"洪水保护设置\"],\"e9dQpT\":[\"是否在新标签页中打开此链接?\"],\"ePK91l\":[\"编辑\"],\"eYBDuB\":[\"上传图片或提供带可选 \",[\"size\"],\" 替换的 URL\"],\"edBbee\":[\"通过 hostmask 封禁 \",[\"username\"],\"(阻止其从相同 IP/主机重新加入)\"],\"ekfzWq\":[\"用户设置\"],\"elPDWs\":[\"自定义您的 IRC 客户端体验\"],\"eu2osY\":[\"<0>💡 建议: 仅在您信任此服务器并了解相关风险的情况下继续操作。避免通过此连接共享敏感信息或密码。\"],\"euEhbr\":[\"点击加入 \",[\"channel\"]],\"ez3vLd\":[\"启用多行输入\"],\"f0J5Ki\":[\"服务器之间的通信可能使用未加密的连接\"],\"f9BHJk\":[\"警告用户\"],\"fDOLLd\":[\"未找到频道。\"],\"ffzDkB\":[\"匿名分析:\"],\"fq1GF9\":[\"显示用户断开服务器连接的事件\"],\"gEF57C\":[\"此服务器仅支持一种连接类型\"],\"gJuLUI\":[\"忽略列表\"],\"gNzMrk\":[\"当前头像\"],\"gjPWyO\":[\"输入昵称...\"],\"gz6UQ3\":[\"最大化\"],\"h6/IMX\":[\"添加您的第一个网络\"],\"h6razj\":[\"排除频道名称掩码\"],\"hG6jnw\":[\"未设置主题\"],\"hG89Ed\":[\"图片\"],\"hZ6znB\":[\"端口\"],\"ha+Bz5\":[\"例如:100:1440\"],\"hehnjM\":[\"数量\"],\"hzdLuQ\":[\"只有获得发言权或更高权限的用户才能发言\"],\"i0qMbr\":[\"主页\"],\"iDNBZe\":[\"通知\"],\"iH8pgl\":[\"返回\"],\"iL9SZg\":[\"封禁用户(按昵称)\"],\"iNt+3c\":[\"返回图片\"],\"iQvi+a\":[\"不再提醒我此服务器的低链路安全问题\"],\"iSLIjg\":[\"连接\"],\"iWXkHH\":[\"半管理员\"],\"iZeTtp\":[\"服务器地址\"],\"idD8Ev\":[\"已保存\"],\"iivqkW\":[\"登录时间\"],\"ij+Elv\":[\"图片预览\"],\"ilIWp7\":[\"切换通知\"],\"iuaqvB\":[\"用 * 作通配符。示例:baduser!*@*, *!*@spammer.com, troll*!*@*\"],\"ixkTse\":[\"机器人\"],\"j2DGR0\":[\"按主机掩码封禁\"],\"jA4uoI\":[\"话题:\"],\"jLXxGK\":[\"https://example.com\"],\"jPSk57\":[\"原因(可选)\"],\"jUV7CU\":[\"上传头像\"],\"jW5Uwh\":[\"控制加载的外部媒体量。关闭 / 安全 / 可信来源 / 所有内容。\"],\"jXzms5\":[\"附件选项\"],\"jZlrte\":[\"颜色\"],\"jfC/xh\":[\"联系方式\"],\"jywMpv\":[\"#new-channel-name\"],\"k112DD\":[\"加载旧消息\"],\"k3ID0F\":[\"筛选成员…\"],\"k65gsE\":[\"深入查看\"],\"k7Zgob\":[\"取消连接\"],\"kAVx5h\":[\"未找到邀请\"],\"kCLEPU\":[\"已连接到\"],\"kF5LKb\":[\"已忽略的模式:\"],\"kGeOx/\":[\"加入 \",[\"0\"]],\"kITKr8\":[\"正在加载频道模式...\"],\"kPpPsw\":[\"您是 IRC Operator\"],\"kWJmRL\":[\"您\"],\"kfcRb0\":[\"头像\"],\"kjMqSj\":[\"复制 JSON\"],\"krViRy\":[\"点击以 JSON 格式复制\"],\"ks71ra\":[\"例外\"],\"kw4lRv\":[\"频道半操作员\"],\"kxgIRq\":[\"选择或添加频道以开始使用。\"],\"ky6dWe\":[\"头像预览\"],\"l+GxCv\":[\"正在加载频道...\"],\"l+IUVW\":[\"账户 \",[\"account\"],\" 验证成功:\",[\"message\"]],\"l/siQz\":[[\"reconnectCount\",\"plural\",{\"other\":[\"已重新连接 \",[\"reconnectCount\"],\" 次\"]}]],\"l5jmzx\":[[\"0\"],\" 和 \",[\"1\"],\" 正在输入...\"],\"lHy8N5\":[\"正在加载更多频道...\"],\"lbpf14\":[\"加入 \",[\"value\"]],\"lfFsZ4\":[\"频道\"],\"lkNdiH\":[\"账户名称\"],\"ln500L\":[\"ObsidianIRC\"],\"lnCMdg\":[\"上传图片\"],\"loQxaJ\":[\"我回来了\"],\"lvfaxv\":[\"主页\"],\"m0oxpP\":[\"Libera Chat\"],\"m16xKo\":[\"添加\"],\"m8flAk\":[\"预览(尚未上传)\"],\"mEPxTp\":[\"<0>⚠️ 请注意! 仅打开来自可信来源的链接。恶意链接可能危害您的安全或隐私。\"],\"mHGdhG\":[\"服务器信息\"],\"mHS8lb\":[\"发消息到 #\",[\"0\"]],\"mMYBD9\":[\"宽泛 – 更广的保护范围\"],\"mTGsPd\":[\"频道主题\"],\"mU8j6O\":[\"禁止外部消息 (+n)\"],\"mZp8FL\":[\"自动回退到单行\"],\"mdQu8G\":[\"YourNickname\"],\"miSSBQ\":[\"评论 (\",[\"commentCount\"],\")\"],\"mvyLSy\":[\"用户已认证\"],\"mwtcGl\":[\"关闭评论\"],\"myL0MR\":[\"删除此网络?\"],\"mzI/c+\":[\"下载\"],\"n3fGRk\":[\"由 \",[\"0\"],\" 设置\"],\"nE9jsU\":[\"宽松 – 较弱的保护\"],\"nNflMD\":[\"离开频道\"],\"nPXkBi\":[\"正在加载 WHOIS 数据...\"],\"nQnxxF\":[\"发消息到 #\",[\"0\"],\"(Shift+Enter 换行)\"],\"nWMRxa\":[\"取消置顶\"],\"nkC032\":[\"无洪水防护配置\"],\"o69z4d\":[\"向 \",[\"username\"],\" 发送警告消息\"],\"o9ylQi\":[\"搜索 GIF 以开始\"],\"oFGkER\":[\"服务器通知\"],\"oOi11l\":[\"滚动到底部\"],\"oQEzQR\":[\"新私信\"],\"oXOSPE\":[\"在线\"],\"oal760\":[\"服务器链路可能遭受中间人攻击\"],\"oeqmmJ\":[\"受信任来源\"],\"ovBPCi\":[\"默认\"],\"p0Z69r\":[\"模式不能为空\"],\"p1KgtK\":[\"音频加载失败\"],\"p59pEv\":[\"更多详情\"],\"p7sRI6\":[\"让其他人知道您正在输入\"],\"pBm1od\":[\"秘密频道\"],\"pNmiXx\":[\"所有服务器的默认昵称\"],\"pUUo9G\":[\"主机名:\"],\"pVGPmz\":[\"账户密码\"],\"peNE68\":[\"永久\"],\"plhHQt\":[\"无数据\"],\"pm6+q5\":[\"安全警告\"],\"pn5qSs\":[\"附加信息\"],\"q0cR4S\":[\"现在称为 **\",[\"newNick\"],\"**\"],\"qFcunY\":[\"频道不会出现在 LIST 或 NAMES 命令中\"],\"qLpTm/\":[\"移除表情 \",[\"emoji\"]],\"qVkGWK\":[\"置顶\"],\"qY8wNa\":[\"主页\"],\"qb0xJ7\":[\"通配符:* 匹配任意字符,? 匹配单个字符。示例:nick!*@*, *!*@host.com, *!*user@*\"],\"qhzpRq\":[\"频道密钥 (+k)\"],\"qtoOYG\":[\"无限制\"],\"r1W2AS\":[\"文件托管图片\"],\"rIPR2O\":[\"主题设置时间早于(分钟前)\"],\"rMMSYo\":[\"最大长度为 \",[\"0\"]],\"rWtzQe\":[\"网络已分裂并重新连接。✅\"],\"rYG2u6\":[\"请稍候...\"],\"rdUucN\":[\"预览\"],\"rjGI/Q\":[\"隐私\"],\"rk8iDX\":[\"正在加载 GIF...\"],\"rn6SBY\":[\"取消静音\"],\"s/UKqq\":[\"已被踢出频道\"],\"s8cATI\":[\"加入了 \",[\"channelName\"]],\"sCO9ue\":[\"与 <0>\",[\"serverName\"],\" 的连接存在以下安全问题:\"],\"sGH11W\":[\"服务器\"],\"sHI1H+\":[\"现在称为 **\",[\"newNick\"],\"**\"],\"sJyV04\":[[\"inviter\"],\" 邀请您加入 \",[\"channel\"]],\"sUBSbK\":[\"尚无上游网络。\"],\"sby+1/\":[\"点击复制\"],\"sfN25C\":[\"您的真实姓名或全名\"],\"sliuzR\":[\"打开链接\"],\"sqrO9R\":[\"自定义提及\"],\"sr6RdJ\":[\"Shift+Enter 换行\"],\"swrCpB\":[\"频道已由 \",[\"user\"],\" 从 \",[\"oldName\"],\" 重命名为 \",[\"newName\"],[\"0\"]],\"sxkWRg\":[\"高级\"],\"t/YqKh\":[\"移除\"],\"t47eHD\":[\"您在此服务器上的唯一标识符\"],\"tAkAh0\":[\"可选 \",[\"size\"],\" 替换的 URL。示例:https://example.com/avatar/\",[\"size\"],\"/channel.jpg\"],\"tXLJS3\":[\"显示或隐藏频道列表侧栏\"],\"tfDRzk\":[\"保存\"],\"tiBsJk\":[\"离开了 \",[\"channelName\"]],\"tt4/UD\":[\"退出了 (\",[\"reason\"],\")\"],\"u0TcnO\":[\"昵称 {nick} 已被使用,正在用 {newNick} 重试\"],\"u0a8B4\":[\"以 IRC 运营商身份进行管理访问认证\"],\"u0rWFU\":[\"创建时间晚于(分钟前)\"],\"u72w3t\":[\"要忽略的用户和模式\"],\"u7jc2L\":[\"退出了\"],\"uAQUqI\":[\"状态\"],\"uB85T3\":[\"保存失败:\",[\"msg\"]],\"uV3DOL\":[\"G-Line\"],\"uW3lLI\":[\"IRC 服务器:\"],\"usSSr/\":[\"缩放级别\"],\"v7uvcf\":[\"软件:\"],\"vE8kb+\":[\"Shift+Enter 换行(Enter 发送)\"],\"vERlcd\":[\"个人资料\"],\"vK0RL8\":[\"无主题\"],\"vSJd18\":[\"视频\"],\"vXIe7J\":[\"语言\"],\"vaHYxN\":[\"真实姓名\"],\"vhjbKr\":[\"离开\"],\"w/nogd\":[[\"0\"],\" network\",[\"1\"],\" — pick one to join\"],\"w4NYox\":[[\"title\"],\" 客户端\"],\"w8xQRx\":[\"值无效\"],\"wFjjxZ\":[\"被 \",[\"username\"],\" 踢出 \",[\"channelName\"],\" (\",[\"reason\"],\")\"],\"wGjaGl\":[\"未找到封禁例外\"],\"wPrGnM\":[\"频道管理员\"],\"wRkP2d\":[\"GIF\"],\"wbm86v\":[\"显示用户加入或离开频道的事件\"],\"whqZ9r\":[\"要高亮的额外词语或短语\"],\"wm7RV4\":[\"通知声音\"],\"wz/Yoq\":[\"您的消息在服务器之间转发时可能被截获\"],\"xCJdfg\":[\"清除\"],\"xUHRTR\":[\"连接时自动以操作员身份认证\"],\"xWHwwQ\":[\"封禁\"],\"xYilR2\":[\"媒体\"],\"xceQrO\":[\"仅支持安全的 WebSocket 连接\"],\"xdtXa+\":[\"频道名称\"],\"xfXC7q\":[\"文字频道\"],\"xlCYOE\":[\"正在加载更多消息...\"],\"xlhswE\":[\"最小值为 \",[\"0\"]],\"xq97Ci\":[\"添加词语或短语...\"],\"xuRqRq\":[\"客户端限制 (+l)\"],\"xwF+7J\":[[\"0\"],\" 正在输入...\"],\"yJztBY\":[\"删除网络\"],\"yNeucF\":[\"此服务器不支持扩展个人资料元数据(IRCv3 METADATA 扩展)。头像、显示名称和状态等字段不可用。\"],\"yPlrca\":[\"频道头像\"],\"yQE2r9\":[\"加载中\"],\"ySU+JY\":[\"your@email.com\"],\"yTX1Rt\":[\"Oper 用户名\"],\"yYOzWD\":[\"日志\"],\"yfx9Re\":[\"IRC 运营商密码\"],\"ygCKqB\":[\"停止\"],\"ymDxJx\":[\"IRC 运营商用户名\"],\"yrpRsQ\":[\"按名称排序\"],\"yz7wBu\":[\"关闭\"],\"zJw+jA\":[\"设置模式:\",[\"0\"]],\"zebeLu\":[\"输入 oper 用户名\"],\"zpr0Bw\":[\"GZ-Line\"]}"); \ No newline at end of file diff --git a/src/locales/zh/messages.po b/src/locales/zh/messages.po index 96500015..469c27c7 100644 --- a/src/locales/zh/messages.po +++ b/src/locales/zh/messages.po @@ -22,6 +22,16 @@ msgstr "ObsidianIRC - 将 IRC 带入未来" msgid "— open in viewer" msgstr "— 在查看器中打开" +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx +msgid "(inherit)" +msgstr "(继承)" + +#: src/components/ui/BouncerNetworkForm.tsx +msgid "(unchanged)" +msgstr "(未更改)" + #. placeholder {0}: filteredMessages.length #. placeholder {1}: import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import type * as React from "react"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { SCROLL_TOLERANCE, useScrollToBottom, } from "../../hooks/useScrollToBottom"; import { groupConsecutiveEvents } from "../../lib/eventGrouping"; import ircClient from "../../lib/ircClient"; import useStore from "../../store"; import type { Message as MessageType } from "../../types"; import { CollapsedEventMessage } from "../message/CollapsedEventMessage"; import { MessageItem } from "../message/MessageItem"; import LoadingSpinner from "../ui/LoadingSpinner"; import { ScrollToBottomButton } from "../ui/ScrollToBottomButton"; export const DEFAULT_VISIBLE_MESSAGE_COUNT = 100; // Stable empty array — prevents selector from returning a new [] on every render // when the channel has no messages yet (undefined ?? [] would create a new ref each time). const EMPTY_MESSAGES: import("../../types").Message[] = []; export interface ChannelMessageListHandle { setAtBottom: () => void; scrollToBottom: () => void; getScrollState: () => { scrollTop: number; isAtBottom: boolean; visibleCount: number; }; } interface ChannelMessageListProps { channelKey: string; serverId: string; channelId: string | null; privateChatId: string | null; isActive: boolean; searchQuery: string; isMemberListVisible: boolean; onReply: (msg: MessageType | null) => void; onUsernameContextMenu: ( e: React.MouseEvent, username: string, serverId: string, channelId: string, avatarEl?: Element | null, ) => void; onIrcLinkClick: (url: string) => void; onReactClick: (msg: MessageType, el: Element) => void; onReactionUnreact: (emoji: string, msg: MessageType) => void; onOpenReactionModal: ( msg: MessageType, position: { x: number; y: number }, ) => void; onDirectReaction: (emoji: string, msg: MessageType) => void; onRedactMessage: (msg: MessageType) => void; onOpenProfile: (username: string) => void; joinChannel: (serverId: string, channelName: string) => void; onClearSearch: () => void; highlightedMessageId?: string; // undefined = first visit; null = was at bottom; object = restore to saved position initialScrollState?: { scrollTop: number; visibleCount: number } | null; } export const ChannelMessageList = forwardRef< ChannelMessageListHandle, ChannelMessageListProps >( ( { channelKey, serverId, channelId, privateChatId, isActive, searchQuery, isMemberListVisible, onReply, onUsernameContextMenu, onIrcLinkClick, onReactClick, onReactionUnreact, onOpenReactionModal, onDirectReaction, onRedactMessage, onOpenProfile, joinChannel, onClearSearch, highlightedMessageId, initialScrollState, }, ref, ) => { const { t } = useLingui(); const [visibleMessageCount, setVisibleMessageCount] = useState( initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT, ); // Ref mirror so getScrollState closure always reads the current value without needing it as a dep. const visibleMessageCountRef = useRef(visibleMessageCount); visibleMessageCountRef.current = visibleMessageCount; // Distinguishes initial join (full-screen spinner) from subsequent "load more" (button spinner). const [isFetchingMore, setIsFetchingMore] = useState(false); const isFetchingMoreRef = useRef(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const messagesInnerRef = useRef(null); // prev scrollHeight for prepend delta-correction. const prevScrollHeightRef = useRef(0); // Ref mirror of isScrolledUp — lets useLayoutEffect closures read current value // without listing isScrolledUp as a dep (which would re-run effects on every scroll). const isScrolledUpRef = useRef(false); const prevFilteredLengthRef = useRef(0); const prevFirstMsgIdRef = useRef(null); // Set by the window-growth layoutEffect (or button handler) when a true prepend is detected. // Consumed by the delta-correction layoutEffect one render later (after visibleCount grows). // Using a flag instead of tracking displayedMessages[0]?.id because slice(-N) slides the // window on every bottom append, changing displayedMessages[0] even for non-prepend renders. const pendingPrependRef = useRef(false); // Shared scrollHeight baseline between the delta-correction layout effect and the inner // ResizeObserver. When scrollTop is corrected after a prepend, we update this so the RO's // "was at bottom" check is not fooled by the adjusted scrollTop vs its stale prevSH. const resizeObserverPrevSHRef = useRef(0); const channelMessages = useStore( useCallback( (state) => state.messages[channelKey] ?? EMPTY_MESSAGES, [channelKey], ), ); const servers = useStore((state) => state.servers); const mobileViewActiveColumn = useStore( (state) => state.ui.mobileViewActiveColumn, ); const channel = useMemo( () => channelId ? (servers .find((s) => s.id === serverId) ?.channels.find((c) => c.id === channelId) ?? null) : null, [servers, serverId, channelId], ); const { isScrolledUp, wasAtBottomRef, scrollToBottom } = useScrollToBottom( messagesContainerRef, messagesEndRef, { channelId: `${channelId || privateChatId}-${isMemberListVisible}` }, ); // Snapshot of the last known scroll position captured while the container was visible. // getScrollState() reads this instead of the live DOM because React commits display:none // before running cleanup effects, collapsing scrollTop/scrollHeight/clientHeight to 0. const lastScrollTopRef = useRef(initialScrollState?.scrollTop ?? 0); useEffect(() => { const container = messagesContainerRef.current; if (!container) return; const onScroll = () => { if (container.clientHeight > 0) lastScrollTopRef.current = container.scrollTop; }; container.addEventListener("scroll", onScroll, { passive: true }); return () => container.removeEventListener("scroll", onScroll); }, []); // Restore scroll position when a keep-alive channel transitions from hidden to visible. // display:none may reset scrollTop to 0; lastScrollTopRef was captured while visible. const prevActiveRef = useRef(isActive); useLayoutEffect(() => { if (isActive && !prevActiveRef.current) { const container = messagesContainerRef.current; if (container && lastScrollTopRef.current > 0) { container.scrollTop = lastScrollTopRef.current; } } prevActiveRef.current = isActive; }, [isActive]); useImperativeHandle(ref, () => ({ setAtBottom: () => { wasAtBottomRef.current = true; }, scrollToBottom, getScrollState: () => ({ scrollTop: lastScrollTopRef.current, isAtBottom: wasAtBottomRef.current, visibleCount: visibleMessageCountRef.current, }), })); const filteredMessages = useMemo(() => { if (!searchQuery.trim()) return channelMessages; const query = searchQuery.toLowerCase(); return channelMessages.filter( (msg) => msg.content.toLowerCase().includes(query) || msg.userId.toLowerCase().includes(query), ); }, [channelMessages, searchQuery]); useEffect(() => { isScrolledUpRef.current = isScrolledUp; // When the user returns to the bottom, shrink the window back to the base so // slice(-N) resumes trimming old messages from the top (memory optimization). // Only shrink if we grew above the base — preserves a sub-default saved visibleCount. if (!isScrolledUp) { setVisibleMessageCount((prev) => prev > DEFAULT_VISIBLE_MESSAGE_COUNT ? DEFAULT_VISIBLE_MESSAGE_COUNT : prev, ); } }, [isScrolledUp]); // Reset ref-tracked windowing state when switching channels. // visibleMessageCount is NOT reset here — useState(initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT) // already initializes it correctly on mount, and this effect runs once on mount for the // same channelKey (each instance is bound to exactly one channel by the parent key={}). // biome-ignore lint/correctness/useExhaustiveDependencies: intentional full reset on channel change useEffect(() => { prevFilteredLengthRef.current = 0; prevFirstMsgIdRef.current = null; prevScrollHeightRef.current = 0; pendingPrependRef.current = false; resizeObserverPrevSHRef.current = 0; }, [channelKey]); const displayedMessages = useMemo(() => { if (searchQuery.trim()) return filteredMessages; return filteredMessages.slice(-visibleMessageCount); }, [filteredMessages, visibleMessageCount, searchQuery]); const locallyHidden = filteredMessages.length > displayedMessages.length; const serverHasMore = channel?.hasMoreHistory === true; const hasMoreMessages = locallyHidden || serverHasMore; const eventGroups = useMemo( () => groupConsecutiveEvents(displayedMessages), [displayedMessages], ); const isLoadingHistory = channel?.isLoadingHistory ?? false; // Scroll to bottom on initial mount, unless a saved position was passed in. // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount only useEffect(() => { const container = messagesContainerRef.current; if (!container) return; if (initialScrollState) { container.scrollTop = initialScrollState.scrollTop; lastScrollTopRef.current = initialScrollState.scrollTop; wasAtBottomRef.current = false; } else { container.scrollTop = container.scrollHeight; lastScrollTopRef.current = container.scrollHeight; wasAtBottomRef.current = true; } }, []); // Scroll to bottom after initial join history loads; clear fetch spinner at batch end. const wasLoadingHistoryRef = useRef(false); // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is stable via useCallback; refs and setters are stable useLayoutEffect(() => { if (wasLoadingHistoryRef.current && !isLoadingHistory) { if (isFetchingMoreRef.current) { // delta correction for scroll position is handled by useLayoutEffect([displayedMessages]) isFetchingMoreRef.current = false; setIsFetchingMore(false); } else { scrollToBottom(); wasAtBottomRef.current = true; } } wasLoadingHistoryRef.current = isLoadingHistory; }, [isLoadingHistory]); // When older messages are prepended, grow the window so they enter displayedMessages. // When new messages arrive at the bottom while the user is scrolled up, also grow the // window to keep the current top messages visible — slice(-N) otherwise slides the // window forward and hides them, incrementing the "N older messages" counter on every // incoming message. Only let the slice trim from the top when the user is at the bottom // (where auto-scroll handles keeping them current). useLayoutEffect(() => { const newLength = filteredMessages.length; const newFirstId = filteredMessages[0]?.id ?? null; const delta = newLength - prevFilteredLengthRef.current; if (prevFilteredLengthRef.current > 0 && delta > 0) { if (newFirstId !== prevFirstMsgIdRef.current) { // Messages prepended (load-more): signal delta-correction to compensate scrollTop. pendingPrependRef.current = true; setVisibleMessageCount((prev) => prev + delta); } else if (isScrolledUpRef.current) { // Messages appended at bottom while user is scrolled up reading history. // Expand the window to prevent top messages from dropping out of the slice. setVisibleMessageCount((prev) => prev + delta); } } prevFilteredLengthRef.current = newLength; prevFirstMsgIdRef.current = newFirstId; }, [filteredMessages]); // Compensate scrollTop when content is prepended above the viewport. // biome-ignore lint/correctness/useExhaustiveDependencies: runs on every displayedMessages render to capture the resulting scrollHeight; refs are stable useLayoutEffect(() => { const container = messagesContainerRef.current; if (!container) return; // Skip while container is display:none — scrollHeight collapses to 0 and would // poison prevScrollHeightRef, causing a huge spurious delta on the next visible render. if (container.clientHeight === 0) return; const prevHeight = prevScrollHeightRef.current; const newHeight = container.scrollHeight; // Only correct when a true load-more prepend happened (flag set by the window-growth // layoutEffect or button handler). Bottom appends slide the slice(-N) window which also // changes displayedMessages[0] — ID-comparison can't distinguish the two cases. const wasPrepend = pendingPrependRef.current; // Only consume the flag when scrollHeight actually changed — the server-side load-more // path goes through two renders: Render A (filteredMessages grows, visibleCount unchanged, // same displayedMessages content, same scrollHeight) then Render B (visibleCount grows, // new messages enter displayedMessages, scrollHeight grows). The flag must survive Render A // so it's still set when Render B fires the actual correction. if (wasPrepend && newHeight !== prevHeight) { pendingPrependRef.current = false; } if ( isScrolledUpRef.current && prevHeight > 0 && newHeight > prevHeight && wasPrepend ) { const delta = newHeight - prevHeight; container.scrollTop += delta; resizeObserverPrevSHRef.current = newHeight; } prevScrollHeightRef.current = newHeight; }, [displayedMessages]); // Re-stick to bottom when inner message content grows (media/audio previews loading). // Uses prevScrollHeight instead of wasAtBottomRef to avoid stale-flag race where the // ref is true while the user is actively scrolling up. // When the container width changes (member list toggle, window resize), text reflows // and scrollHeight changes; preserve proportional scroll position for scrolled-up users. // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is a stable ref useEffect(() => { const container = messagesContainerRef.current; const inner = messagesInnerRef.current; if (!inner || !container) return; resizeObserverPrevSHRef.current = container.scrollHeight; let prevClientWidth = container.clientWidth; const observer = new ResizeObserver(() => { if (container.clientHeight === 0) return; // Effect may re-initialize while container is display:none (ref=0). // Re-seed with current dimensions and skip — no reliable "was at bottom" data. if (resizeObserverPrevSHRef.current === 0) { resizeObserverPrevSHRef.current = container.scrollHeight; prevClientWidth = container.clientWidth; return; } const currentClientWidth = container.clientWidth; const widthChanged = currentClientWidth !== prevClientWidth; prevClientWidth = currentClientWidth; const prevSH = resizeObserverPrevSHRef.current; const wasAtPrevBottom = container.scrollTop + container.clientHeight >= prevSH - SCROLL_TOLERANCE; resizeObserverPrevSHRef.current = container.scrollHeight; if (wasAtPrevBottom) { scrollToBottom(); } else if (widthChanged && prevSH > 0) { const ratio = container.scrollTop / prevSH; container.scrollTop = Math.round(ratio * container.scrollHeight); } }); observer.observe(inner); return () => observer.disconnect(); }, [isLoadingHistory, channelId, privateChatId]); // Auto-scroll on new messages — skip when this channel is hidden (display:none). // biome-ignore lint/correctness/useExhaustiveDependencies: only scroll when messages change, not when isActive changes useEffect(() => { if (!isActive) return; const isNarrowView = window.matchMedia("(max-width: 768px)").matches; const isChatVisible = !isNarrowView || mobileViewActiveColumn === "chatView"; if (wasAtBottomRef.current && isChatVisible) { scrollToBottom(); } }, [displayedMessages, mobileViewActiveColumn, scrollToBottom, isActive]); return ( <>
{isLoadingHistory && !isFetchingMore ? (
) : (
{hasMoreMessages && !searchQuery && (
)} {searchQuery && (
{plural(filteredMessages.length, { one: t`Found 1 message matching "${searchQuery}"`, other: t`Found ${filteredMessages.length} messages matching "${searchQuery}"`, })}
)} {eventGroups.map((group) => { if (group.type === "eventGroup") { const firstId = group.messages[0]?.id || ""; const lastId = group.messages[group.messages.length - 1]?.id || ""; const groupKey = `group-${firstId}-${lastId}`; return ( ); } const message = group.messages[0]; const originalIndex = channelMessages.findIndex( (m) => m.id === message.id, ); const previousMessage = channelMessages[originalIndex - 1]; const showHeader = !previousMessage || previousMessage.type !== "message" || previousMessage.userId !== message.userId || new Date(message.timestamp).getTime() - new Date(previousMessage.timestamp).getTime() > 5 * 60 * 1000; return ( ); })}
)}
); }, ); ChannelMessageList.displayName = "ChannelMessageList"; // Wrap with memo so hidden keep-alive channels skip re-renders when their props // haven't changed (e.g. when messageText changes in the input — the only thing // that changes on typing is local state inside ChatArea, not the props we pass here). export const MemoChannelMessageList = memo(ChannelMessageList); #. placeholder {2}: import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import type * as React from "react"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { SCROLL_TOLERANCE, useScrollToBottom, } from "../../hooks/useScrollToBottom"; import { groupConsecutiveEvents } from "../../lib/eventGrouping"; import ircClient from "../../lib/ircClient"; import useStore from "../../store"; import type { Message as MessageType } from "../../types"; import { CollapsedEventMessage } from "../message/CollapsedEventMessage"; import { MessageItem } from "../message/MessageItem"; import LoadingSpinner from "../ui/LoadingSpinner"; import { ScrollToBottomButton } from "../ui/ScrollToBottomButton"; export const DEFAULT_VISIBLE_MESSAGE_COUNT = 100; // Stable empty array — prevents selector from returning a new [] on every render // when the channel has no messages yet (undefined ?? [] would create a new ref each time). const EMPTY_MESSAGES: import("../../types").Message[] = []; export interface ChannelMessageListHandle { setAtBottom: () => void; scrollToBottom: () => void; getScrollState: () => { scrollTop: number; isAtBottom: boolean; visibleCount: number; }; } interface ChannelMessageListProps { channelKey: string; serverId: string; channelId: string | null; privateChatId: string | null; isActive: boolean; searchQuery: string; isMemberListVisible: boolean; onReply: (msg: MessageType | null) => void; onUsernameContextMenu: ( e: React.MouseEvent, username: string, serverId: string, channelId: string, avatarEl?: Element | null, ) => void; onIrcLinkClick: (url: string) => void; onReactClick: (msg: MessageType, el: Element) => void; onReactionUnreact: (emoji: string, msg: MessageType) => void; onOpenReactionModal: ( msg: MessageType, position: { x: number; y: number }, ) => void; onDirectReaction: (emoji: string, msg: MessageType) => void; onRedactMessage: (msg: MessageType) => void; onOpenProfile: (username: string) => void; joinChannel: (serverId: string, channelName: string) => void; onClearSearch: () => void; highlightedMessageId?: string; // undefined = first visit; null = was at bottom; object = restore to saved position initialScrollState?: { scrollTop: number; visibleCount: number } | null; } export const ChannelMessageList = forwardRef< ChannelMessageListHandle, ChannelMessageListProps >( ( { channelKey, serverId, channelId, privateChatId, isActive, searchQuery, isMemberListVisible, onReply, onUsernameContextMenu, onIrcLinkClick, onReactClick, onReactionUnreact, onOpenReactionModal, onDirectReaction, onRedactMessage, onOpenProfile, joinChannel, onClearSearch, highlightedMessageId, initialScrollState, }, ref, ) => { const { t } = useLingui(); const [visibleMessageCount, setVisibleMessageCount] = useState( initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT, ); // Ref mirror so getScrollState closure always reads the current value without needing it as a dep. const visibleMessageCountRef = useRef(visibleMessageCount); visibleMessageCountRef.current = visibleMessageCount; // Distinguishes initial join (full-screen spinner) from subsequent "load more" (button spinner). const [isFetchingMore, setIsFetchingMore] = useState(false); const isFetchingMoreRef = useRef(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const messagesInnerRef = useRef(null); // prev scrollHeight for prepend delta-correction. const prevScrollHeightRef = useRef(0); // Ref mirror of isScrolledUp — lets useLayoutEffect closures read current value // without listing isScrolledUp as a dep (which would re-run effects on every scroll). const isScrolledUpRef = useRef(false); const prevFilteredLengthRef = useRef(0); const prevFirstMsgIdRef = useRef(null); // Set by the window-growth layoutEffect (or button handler) when a true prepend is detected. // Consumed by the delta-correction layoutEffect one render later (after visibleCount grows). // Using a flag instead of tracking displayedMessages[0]?.id because slice(-N) slides the // window on every bottom append, changing displayedMessages[0] even for non-prepend renders. const pendingPrependRef = useRef(false); // Shared scrollHeight baseline between the delta-correction layout effect and the inner // ResizeObserver. When scrollTop is corrected after a prepend, we update this so the RO's // "was at bottom" check is not fooled by the adjusted scrollTop vs its stale prevSH. const resizeObserverPrevSHRef = useRef(0); const channelMessages = useStore( useCallback( (state) => state.messages[channelKey] ?? EMPTY_MESSAGES, [channelKey], ), ); const servers = useStore((state) => state.servers); const mobileViewActiveColumn = useStore( (state) => state.ui.mobileViewActiveColumn, ); const channel = useMemo( () => channelId ? (servers .find((s) => s.id === serverId) ?.channels.find((c) => c.id === channelId) ?? null) : null, [servers, serverId, channelId], ); const { isScrolledUp, wasAtBottomRef, scrollToBottom } = useScrollToBottom( messagesContainerRef, messagesEndRef, { channelId: `${channelId || privateChatId}-${isMemberListVisible}` }, ); // Snapshot of the last known scroll position captured while the container was visible. // getScrollState() reads this instead of the live DOM because React commits display:none // before running cleanup effects, collapsing scrollTop/scrollHeight/clientHeight to 0. const lastScrollTopRef = useRef(initialScrollState?.scrollTop ?? 0); useEffect(() => { const container = messagesContainerRef.current; if (!container) return; const onScroll = () => { if (container.clientHeight > 0) lastScrollTopRef.current = container.scrollTop; }; container.addEventListener("scroll", onScroll, { passive: true }); return () => container.removeEventListener("scroll", onScroll); }, []); // Restore scroll position when a keep-alive channel transitions from hidden to visible. // display:none may reset scrollTop to 0; lastScrollTopRef was captured while visible. const prevActiveRef = useRef(isActive); useLayoutEffect(() => { if (isActive && !prevActiveRef.current) { const container = messagesContainerRef.current; if (container && lastScrollTopRef.current > 0) { container.scrollTop = lastScrollTopRef.current; } } prevActiveRef.current = isActive; }, [isActive]); useImperativeHandle(ref, () => ({ setAtBottom: () => { wasAtBottomRef.current = true; }, scrollToBottom, getScrollState: () => ({ scrollTop: lastScrollTopRef.current, isAtBottom: wasAtBottomRef.current, visibleCount: visibleMessageCountRef.current, }), })); const filteredMessages = useMemo(() => { if (!searchQuery.trim()) return channelMessages; const query = searchQuery.toLowerCase(); return channelMessages.filter( (msg) => msg.content.toLowerCase().includes(query) || msg.userId.toLowerCase().includes(query), ); }, [channelMessages, searchQuery]); useEffect(() => { isScrolledUpRef.current = isScrolledUp; // When the user returns to the bottom, shrink the window back to the base so // slice(-N) resumes trimming old messages from the top (memory optimization). // Only shrink if we grew above the base — preserves a sub-default saved visibleCount. if (!isScrolledUp) { setVisibleMessageCount((prev) => prev > DEFAULT_VISIBLE_MESSAGE_COUNT ? DEFAULT_VISIBLE_MESSAGE_COUNT : prev, ); } }, [isScrolledUp]); // Reset ref-tracked windowing state when switching channels. // visibleMessageCount is NOT reset here — useState(initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT) // already initializes it correctly on mount, and this effect runs once on mount for the // same channelKey (each instance is bound to exactly one channel by the parent key={}). // biome-ignore lint/correctness/useExhaustiveDependencies: intentional full reset on channel change useEffect(() => { prevFilteredLengthRef.current = 0; prevFirstMsgIdRef.current = null; prevScrollHeightRef.current = 0; pendingPrependRef.current = false; resizeObserverPrevSHRef.current = 0; }, [channelKey]); const displayedMessages = useMemo(() => { if (searchQuery.trim()) return filteredMessages; return filteredMessages.slice(-visibleMessageCount); }, [filteredMessages, visibleMessageCount, searchQuery]); const locallyHidden = filteredMessages.length > displayedMessages.length; const serverHasMore = channel?.hasMoreHistory === true; const hasMoreMessages = locallyHidden || serverHasMore; const eventGroups = useMemo( () => groupConsecutiveEvents(displayedMessages), [displayedMessages], ); const isLoadingHistory = channel?.isLoadingHistory ?? false; // Scroll to bottom on initial mount, unless a saved position was passed in. // biome-ignore lint/correctness/useExhaustiveDependencies: run once on mount only useEffect(() => { const container = messagesContainerRef.current; if (!container) return; if (initialScrollState) { container.scrollTop = initialScrollState.scrollTop; lastScrollTopRef.current = initialScrollState.scrollTop; wasAtBottomRef.current = false; } else { container.scrollTop = container.scrollHeight; lastScrollTopRef.current = container.scrollHeight; wasAtBottomRef.current = true; } }, []); // Scroll to bottom after initial join history loads; clear fetch spinner at batch end. const wasLoadingHistoryRef = useRef(false); // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is stable via useCallback; refs and setters are stable useLayoutEffect(() => { if (wasLoadingHistoryRef.current && !isLoadingHistory) { if (isFetchingMoreRef.current) { // delta correction for scroll position is handled by useLayoutEffect([displayedMessages]) isFetchingMoreRef.current = false; setIsFetchingMore(false); } else { scrollToBottom(); wasAtBottomRef.current = true; } } wasLoadingHistoryRef.current = isLoadingHistory; }, [isLoadingHistory]); // When older messages are prepended, grow the window so they enter displayedMessages. // When new messages arrive at the bottom while the user is scrolled up, also grow the // window to keep the current top messages visible — slice(-N) otherwise slides the // window forward and hides them, incrementing the "N older messages" counter on every // incoming message. Only let the slice trim from the top when the user is at the bottom // (where auto-scroll handles keeping them current). useLayoutEffect(() => { const newLength = filteredMessages.length; const newFirstId = filteredMessages[0]?.id ?? null; const delta = newLength - prevFilteredLengthRef.current; if (prevFilteredLengthRef.current > 0 && delta > 0) { if (newFirstId !== prevFirstMsgIdRef.current) { // Messages prepended (load-more): signal delta-correction to compensate scrollTop. pendingPrependRef.current = true; setVisibleMessageCount((prev) => prev + delta); } else if (isScrolledUpRef.current) { // Messages appended at bottom while user is scrolled up reading history. // Expand the window to prevent top messages from dropping out of the slice. setVisibleMessageCount((prev) => prev + delta); } } prevFilteredLengthRef.current = newLength; prevFirstMsgIdRef.current = newFirstId; }, [filteredMessages]); // Compensate scrollTop when content is prepended above the viewport. // biome-ignore lint/correctness/useExhaustiveDependencies: runs on every displayedMessages render to capture the resulting scrollHeight; refs are stable useLayoutEffect(() => { const container = messagesContainerRef.current; if (!container) return; // Skip while container is display:none — scrollHeight collapses to 0 and would // poison prevScrollHeightRef, causing a huge spurious delta on the next visible render. if (container.clientHeight === 0) return; const prevHeight = prevScrollHeightRef.current; const newHeight = container.scrollHeight; // Only correct when a true load-more prepend happened (flag set by the window-growth // layoutEffect or button handler). Bottom appends slide the slice(-N) window which also // changes displayedMessages[0] — ID-comparison can't distinguish the two cases. const wasPrepend = pendingPrependRef.current; // Only consume the flag when scrollHeight actually changed — the server-side load-more // path goes through two renders: Render A (filteredMessages grows, visibleCount unchanged, // same displayedMessages content, same scrollHeight) then Render B (visibleCount grows, // new messages enter displayedMessages, scrollHeight grows). The flag must survive Render A // so it's still set when Render B fires the actual correction. if (wasPrepend && newHeight !== prevHeight) { pendingPrependRef.current = false; } if ( isScrolledUpRef.current && prevHeight > 0 && newHeight > prevHeight && wasPrepend ) { const delta = newHeight - prevHeight; container.scrollTop += delta; resizeObserverPrevSHRef.current = newHeight; } prevScrollHeightRef.current = newHeight; }, [displayedMessages]); // Re-stick to bottom when inner message content grows (media/audio previews loading). // Uses prevScrollHeight instead of wasAtBottomRef to avoid stale-flag race where the // ref is true while the user is actively scrolling up. // When the container width changes (member list toggle, window resize), text reflows // and scrollHeight changes; preserve proportional scroll position for scrolled-up users. // biome-ignore lint/correctness/useExhaustiveDependencies: scrollToBottom is a stable ref useEffect(() => { const container = messagesContainerRef.current; const inner = messagesInnerRef.current; if (!inner || !container) return; resizeObserverPrevSHRef.current = container.scrollHeight; let prevClientWidth = container.clientWidth; const observer = new ResizeObserver(() => { if (container.clientHeight === 0) return; // Effect may re-initialize while container is display:none (ref=0). // Re-seed with current dimensions and skip — no reliable "was at bottom" data. if (resizeObserverPrevSHRef.current === 0) { resizeObserverPrevSHRef.current = container.scrollHeight; prevClientWidth = container.clientWidth; return; } const currentClientWidth = container.clientWidth; const widthChanged = currentClientWidth !== prevClientWidth; prevClientWidth = currentClientWidth; const prevSH = resizeObserverPrevSHRef.current; const wasAtPrevBottom = container.scrollTop + container.clientHeight >= prevSH - SCROLL_TOLERANCE; resizeObserverPrevSHRef.current = container.scrollHeight; if (wasAtPrevBottom) { scrollToBottom(); } else if (widthChanged && prevSH > 0) { const ratio = container.scrollTop / prevSH; container.scrollTop = Math.round(ratio * container.scrollHeight); } }); observer.observe(inner); return () => observer.disconnect(); }, [isLoadingHistory, channelId, privateChatId]); // Auto-scroll on new messages — skip when this channel is hidden (display:none). // biome-ignore lint/correctness/useExhaustiveDependencies: only scroll when messages change, not when isActive changes useEffect(() => { if (!isActive) return; const isNarrowView = window.matchMedia("(max-width: 768px)").matches; const isChatVisible = !isNarrowView || mobileViewActiveColumn === "chatView"; if (wasAtBottomRef.current && isChatVisible) { scrollToBottom(); } }, [displayedMessages, mobileViewActiveColumn, scrollToBottom, isActive]); return ( <>
{isLoadingHistory && !isFetchingMore ? (
) : (
{hasMoreMessages && !searchQuery && (
)} {searchQuery && (
{plural(filteredMessages.length, { one: t`Found 1 message matching "${searchQuery}"`, other: t`Found ${filteredMessages.length} messages matching "${searchQuery}"`, })}
)} {eventGroups.map((group) => { if (group.type === "eventGroup") { const firstId = group.messages[0]?.id || ""; const lastId = group.messages[group.messages.length - 1]?.id || ""; const groupKey = `group-${firstId}-${lastId}`; return ( ); } const message = group.messages[0]; const originalIndex = channelMessages.findIndex( (m) => m.id === message.id, ); const previousMessage = channelMessages[originalIndex - 1]; const showHeader = !previousMessage || previousMessage.type !== "message" || previousMessage.userId !== message.userId || new Date(message.timestamp).getTime() - new Date(previousMessage.timestamp).getTime() > 5 * 60 * 1000; return ( ); })}
)}
); }, ); ChannelMessageList.displayName = "ChannelMessageList"; // Wrap with memo so hidden keep-alive channels skip re-renders when their props // haven't changed (e.g. when messageText changes in the input — the only thing // that changes on typing is local state inside ChatArea, not the props we pass here). export const MemoChannelMessageList = memo(ChannelMessageList); @@ -46,6 +56,12 @@ msgstr "{0} 和 {1} 正在输入..." msgid "{0} is typing..." msgstr "{0} 正在输入..." +#. placeholder {0}: networks.length +#. placeholder {1}: networks.length === 1 ? "" : "s" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "{0} network{1} — pick one to join" +msgstr "" + #. placeholder {0}: filteredMessages.length - displayedMessages.length #: src/components/layout/ChannelMessageList.tsx msgid "{0} older messages" @@ -186,6 +202,12 @@ msgstr "添加邀请掩码(例如 nick!*@*, *!*@host.com)" msgid "Add IRC Server" msgstr "添加 IRC 服务器" +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworksPanel.tsx +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Add Network" +msgstr "添加网络" + #: src/components/message/MessageActions.tsx #: src/components/message/MessageReactions.tsx #: src/components/message/MessageReactions.tsx @@ -205,6 +227,10 @@ msgstr "添加规则" msgid "Add Server" msgstr "添加服务器" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Add your first network" +msgstr "添加您的第一个网络" + #: src/components/message/JsonLogMessage.tsx msgid "Additional Details" msgstr "更多详情" @@ -358,6 +384,10 @@ msgstr "返回" msgid "Back to image" msgstr "返回图片" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Back to network list" +msgstr "" + #: src/components/ui/ModerationModal.tsx msgid "Ban {username} by hostmask (prevents them from rejoining from the same IP/host)" msgstr "通过 hostmask 封禁 {username}(阻止其从相同 IP/主机重新加入)" @@ -405,6 +435,8 @@ msgstr "浏览服务器上的所有频道" #: src/components/ui/AddPrivateChatModal.tsx #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/ChannelSettingsModal.tsx #: src/components/ui/ExternalLinkWarningModal.tsx #: src/components/ui/FloodSettingsModal.tsx @@ -640,6 +672,7 @@ msgid "Configure notification sounds and highlights" msgstr "配置通知声音和高亮提示" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworksPanel.tsx msgid "Connect" msgstr "连接" @@ -759,6 +792,10 @@ msgstr "删除频道" msgid "Delete message" msgstr "删除消息" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Delete network" +msgstr "删除网络" + #: src/components/layout/ChannelList.tsx msgid "Delete Private Chat" msgstr "删除私聊" @@ -767,6 +804,10 @@ msgstr "删除私聊" msgid "Delete this message? This cannot be undone." msgstr "删除此消息?此操作无法撤销。" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Delete this network?" +msgstr "删除此网络?" + #: src/components/layout/ServerList.tsx #: src/components/mobile/ServerBottomSheet.tsx msgid "Disconnect" @@ -830,10 +871,16 @@ msgstr "下载" msgid "e.g., 100:1440" msgstr "例如:100:1440" +#: src/components/ui/BouncerNetworksPanel.tsx #: src/components/ui/ChannelSettingsModal.tsx msgid "Edit" msgstr "编辑" +#. placeholder {0}: editingNetwork?.attributes.name || editingNetwork?.netid +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Edit {0}" +msgstr "编辑 {0}" + #: src/components/ui/UserProfileModal.tsx msgid "Edit Profile" msgstr "编辑资料" @@ -1057,6 +1104,7 @@ msgstr "主页" msgid "Homepage" msgstr "主页" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserProfileModal.tsx msgid "Host" msgstr "主机" @@ -1271,6 +1319,10 @@ msgstr "已离开频道" msgid "Let others know when you are typing" msgstr "让其他人知道您正在输入" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Libera Chat" +msgstr "Libera Chat" + #: src/components/message/LinkPreview.tsx msgid "Link preview" msgstr "链接预览" @@ -1299,6 +1351,10 @@ msgstr "正在加载 GIF..." msgid "Loading more channels..." msgstr "正在加载更多频道..." +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Loading networks from your bouncer…" +msgstr "正在从您的 bouncer 加载网络…" + #: src/components/ui/UserProfileModal.tsx msgid "Loading WHOIS data..." msgstr "正在加载 WHOIS 数据..." @@ -1486,9 +1542,15 @@ msgid "Name:" msgstr "名称:" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx msgid "Network Name" msgstr "网络名称" +#. placeholder {0}: server?.name ?? bouncerServerId +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Networks on {0}" +msgstr "{0} 上的网络" + #: src/components/ui/QuickActions.tsx msgid "New DM" msgstr "新私信" @@ -1511,6 +1573,7 @@ msgid "nick!user@host (e.g., spam*!*@*, *!*@badhost.com)" msgstr "nick!user@host(例如:spam*!*@*, *!*@badhost.com)" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserSettings.tsx #: src/lib/settings/definitions/allSettings.ts msgid "Nickname" @@ -1570,6 +1633,10 @@ msgstr "未选择文件" msgid "No flood profile" msgstr "无洪水防护配置" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "no host set" +msgstr "未设置主机" + #: src/components/ui/ChannelSettingsModal.tsx msgid "No invitations found" msgstr "未找到邀请" @@ -1610,6 +1677,10 @@ msgstr "未设置主题" msgid "No unread mentions or messages" msgstr "没有未读提及或消息" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "No upstream networks yet." +msgstr "尚无上游网络。" + #: src/components/ui/AddPrivateChatModal.tsx msgid "No users available" msgstr "没有可用用户" @@ -1696,6 +1767,10 @@ msgstr "哎呀!网络分裂!⚠️" msgid "Op" msgstr "管理员" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Open" +msgstr "" + #: src/components/ui/QuickActions/uiActionConfig.tsx msgid "Open channel configuration settings" msgstr "打开频道配置设置" @@ -1799,6 +1874,10 @@ msgstr "置顶私聊" msgid "Pin this private message conversation" msgstr "置顶此私信对话" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Plaintext" +msgstr "明文" + #: src/components/message/MediaPreview.tsx #: src/components/message/MediaPreview.tsx #: src/components/message/MediaPreview.tsx @@ -1827,6 +1906,7 @@ msgid "PM User" msgstr "私信用户" #: src/components/ui/AddServerModal.tsx +#: src/components/ui/BouncerNetworkForm.tsx msgid "Port" msgstr "端口" @@ -1918,6 +1998,7 @@ msgstr "对此消息做出了回应" msgid "Read more" msgstr "阅读更多" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/UserProfileModal.tsx #: src/components/ui/UserSettings.tsx #: src/lib/settings/definitions/allSettings.ts @@ -2002,6 +2083,7 @@ msgstr "规则" msgid "Safe" msgstr "安全" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/ChannelSettingsModal.tsx #: src/components/ui/TopicModal.tsx #: src/components/ui/UserSettings.tsx @@ -2183,6 +2265,10 @@ msgstr "网络上的服务器管理员可能读取您的消息" msgid "Server Password" msgstr "服务器密码" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Server Password (PASS)" +msgstr "服务器密码 (PASS)" + #: src/components/ui/LinkSecurityWarningModal.tsx msgid "Server-to-server communication may use unencrypted connections" msgstr "服务器之间的通信可能使用未加密的连接" @@ -2378,6 +2464,10 @@ msgstr "时间(分钟)" msgid "Time Window (seconds)" msgstr "时间窗口(秒)" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "TLS" +msgstr "TLS" + #: src/components/message/WhisperMessage.tsx #: src/components/message/WhisperMessage.tsx msgid "to" @@ -2426,6 +2516,10 @@ msgstr "话题:" msgid "Total: {0}" msgstr "总计:{0}" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Transport" +msgstr "传输方式" + #: src/components/ui/UserSettings.tsx msgid "Trusted Sources" msgstr "受信任来源" @@ -2536,6 +2630,7 @@ msgstr "用户资料" msgid "User Settings" msgstr "用户设置" +#: src/components/ui/BouncerNetworkForm.tsx #: src/components/ui/InviteUserModal.tsx #: src/components/ui/ModerationModal.tsx msgid "Username" @@ -2683,6 +2778,10 @@ msgstr "宽泛 – 更广的保护范围" msgid "Will default to 'no reason' if left empty" msgstr "留空将默认显示\"无原因\"" +#: src/components/ui/BouncerNetworkForm.tsx +msgid "Yes, delete" +msgstr "是,删除" + #: src/components/message/CollapsedEventMessage.tsx #: src/components/message/EventMessage.tsx msgid "You" @@ -2713,6 +2812,10 @@ msgstr "您的账户认证密码" msgid "Your account username for authentication" msgstr "您的账户认证用户名" +#: src/components/ui/BouncerNetworksPanel.tsx +msgid "Your bouncer doesn't have any networks yet. Add one to get started." +msgstr "您的 bouncer 尚未添加任何网络。添加一个以开始使用。" + #: src/lib/settings/definitions/allSettings.ts msgid "Your default nickname for all servers" msgstr "所有服务器的默认昵称" diff --git a/src/store/handlers/auth.ts b/src/store/handlers/auth.ts index 88628c14..cf20cef4 100644 --- a/src/store/handlers/auth.ts +++ b/src/store/handlers/auth.ts @@ -639,7 +639,7 @@ export function registerAuthHandlers(store: StoreApi): void { } if (!preventCapEnd) { - ircClient.sendRaw(serverId, "CAP END"); + ircClient.sendCapEnd(serverId); ircClient.userOnConnect(serverId); } }); diff --git a/src/store/handlers/bouncer.ts b/src/store/handlers/bouncer.ts index 50d1e1b3..4e6e4e14 100644 --- a/src/store/handlers/bouncer.ts +++ b/src/store/handlers/bouncer.ts @@ -91,15 +91,14 @@ export function registerBouncerHandlers(store: StoreApi): void { }, ); - // CAP ACK plumbing: when the bouncer-networks cap is acked, mark the - // bouncer as supported; when the -notify variant is acked, mark it so - // the UI can skip an explicit LISTNETWORKS (the server pushes the - // initial dump unprompted). - ircClient.on("CAP_ACKNOWLEDGED", ({ serverId, key, capabilities }) => { - if (key !== "ACK" && key !== "NEW") return; - const caps = capabilities.split(" "); - const supported = caps.includes("soju.im/bouncer-networks"); - const notify = caps.includes("soju.im/bouncer-networks-notify"); + // CAP ACK plumbing: CAP_ACKNOWLEDGED fires once per acked cap with the + // cap name in `key`. Mark supported when bouncer-networks is acked, and + // notifyEnabled when the -notify variant is acked (the latter lets us + // skip an explicit LISTNETWORKS since the server pushes the initial + // dump unprompted). + ircClient.on("CAP_ACKNOWLEDGED", ({ serverId, key }) => { + const supported = key === "soju.im/bouncer-networks"; + const notify = key === "soju.im/bouncer-networks-notify"; if (!supported && !notify) return; store.setState((state) => ({ bouncers: ensureBouncer(state, serverId, { diff --git a/src/store/index.ts b/src/store/index.ts index 2557e501..d4491bec 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -570,6 +570,13 @@ export interface AppState { attrs: Record, ) => void; bouncerDelNetwork: (bouncerServerId: string, netid: string) => void; + // Open a child IRC connection to the bouncer that is bound to the + // given upstream network via BOUNCER BIND before CAP END. + // Reuses the parent's credentials. Resolves with the new Server. + bouncerConnectNetwork: ( + bouncerServerId: string, + netid: string, + ) => Promise; // WHOIS data cache whoisData: Record>; // serverId -> nickname -> whois data // Account registration state @@ -2775,9 +2782,100 @@ const useStore = create((set, get) => ({ runPendingMigrations(); const savedServers = loadSavedServers(); - const connectionPromises = []; - for (const savedServer of savedServers) { + // Bouncer children share host:port with their parent control session + // and the bouncer commonly rejects child auth that races the parent's + // SASL. Gate every child reconnect on its parent's `ready` event so + // the BIND lands on an authenticated session, not on one mid-handshake. + // Standalone servers and bouncer parents still connect immediately in + // parallel. + const parents = savedServers.filter((s) => !s.bouncerNetid); + const children = savedServers.filter((s) => !!s.bouncerNetid); + + // Seed each child Server row in "connecting" state up-front so the + // sidebar shows the row immediately, even before the parent is ready. + set((state) => { + const next = [...state.servers]; + for (const child of children) { + if (next.some((s) => s.id === child.id)) continue; + const childUrlHost = ensureUrlFormat(child.host, child.port); + next.push({ + id: child.id, + name: child.name || normalizeHost(childUrlHost), + host: normalizeHost(childUrlHost), + port: child.port, + channels: [], + privateChats: [], + isConnected: false, + connectionState: "connecting", + users: [], + bouncerServerId: child.bouncerServerId, + bouncerNetid: child.bouncerNetid, + isBouncerControl: child.isBouncerControl, + }); + } + return { servers: next }; + }); + + // Helper: actually fire the WSS connect for a child. Used after the + // parent reports `ready` (or immediately if the parent is already up). + const dispatchChildConnect = (savedServer: ServerConfig) => { + const { id, name, host, port, nickname, password } = savedServer; + const urlHost = ensureUrlFormat(host, port); + ircClient.setPendingBouncerBind(id, savedServer.bouncerNetid as string); + const p = ircClient.connect( + name || normalizeHost(urlHost), + urlHost, + port, + nickname, + password, + savedServer.saslAccountName, + savedServer.saslPassword, + id, + ); + p.catch((error) => { + console.error(`Failed to reconnect bouncer child ${urlHost}`, error); + set((state) => ({ + servers: state.servers.map((s) => + s.id === id + ? { ...s, connectionState: "disconnected" as const } + : s, + ), + })); + }); + return p; + }; + + // Index children by parent id so a single `ready` event from a parent + // dispatches every child it owns in one burst. + const childrenByParent = new Map(); + for (const c of children) { + const pid = c.bouncerServerId; + if (!pid) continue; + const arr = childrenByParent.get(pid) ?? []; + arr.push(c); + childrenByParent.set(pid, arr); + } + + // Register the deferred-child trigger BEFORE kicking off parents so + // there is no window where the parent's RPL_WELCOME could race past + // an unregistered listener. + const pendingParents = new Set(childrenByParent.keys()); + const onParentReady = ({ serverId }: { serverId: string }) => { + if (!pendingParents.has(serverId)) return; + pendingParents.delete(serverId); + const kids = childrenByParent.get(serverId) ?? []; + for (const child of kids) dispatchChildConnect(child); + if (pendingParents.size === 0) { + ircClient.deleteHook("ready", onParentReady); + } + }; + if (pendingParents.size > 0) { + ircClient.on("ready", onParentReady); + } + + const connectionPromises: Promise[] = []; + for (const savedServer of parents) { const { id, name, @@ -2785,33 +2883,32 @@ const useStore = create((set, get) => ({ port, nickname, password, - channels, saslEnabled, saslAccountName, saslPassword, } = savedServer; - // Ensure host is in URL format (handles old hostname-only entries) const urlHost = ensureUrlFormat(host, port); - // Check if server already exists in store using normalized comparison const existingServer = get().servers.find( (s) => normalizeHost(s.host) === normalizeHost(urlHost) && s.port === port, ); if (!existingServer) { - // Add server to store with connecting state const connectingServer: Server = { id, name: name || normalizeHost(urlHost), - host: normalizeHost(urlHost), // Store normalized hostname in state + host: normalizeHost(urlHost), port, channels: [], privateChats: [], isConnected: false, connectionState: "connecting", users: [], + bouncerServerId: savedServer.bouncerServerId, + bouncerNetid: savedServer.bouncerNetid, + isBouncerControl: savedServer.isBouncerControl, }; set((state) => ({ @@ -2819,38 +2916,50 @@ const useStore = create((set, get) => ({ })); } - const connectionPromise = get() - .connect( - name || normalizeHost(urlHost), - urlHost, // Use full URL - port, - nickname, - saslEnabled, - password, - saslAccountName, - saslPassword, - ) - .catch((error) => { - console.error(`Failed to reconnect to server ${urlHost}`, error); - // Update server state to disconnected using normalized comparison + let connectionPromise: Promise = get().connect( + name || normalizeHost(urlHost), + urlHost, + port, + nickname, + saslEnabled, + password, + saslAccountName, + saslPassword, + ); + connectionPromise = connectionPromise.catch((error) => { + console.error(`Failed to reconnect to server ${urlHost}`, error); + set((state) => ({ + servers: state.servers.map((s) => + normalizeHost(s.host) === normalizeHost(urlHost) && s.port === port + ? { ...s, connectionState: "disconnected" as const } + : s, + ), + })); + // If a parent fails outright, its children will never get `ready`. + // Mark them disconnected so the UI stops spinning, and detach the + // listener once all parents have settled. + if (childrenByParent.has(id)) { + pendingParents.delete(id); set((state) => ({ servers: state.servers.map((s) => - normalizeHost(s.host) === normalizeHost(urlHost) && - s.port === port + s.bouncerServerId === id ? { ...s, connectionState: "disconnected" as const } : s, ), })); - }); + if (pendingParents.size === 0) { + ircClient.deleteHook("ready", onParentReady); + } + } + }); connectionPromises.push(connectionPromise); } - // Wait for all connections to complete + // Only await the parent burst here. Children are kicked off + // asynchronously from the `ready` listener above and don't block + // initial app boot. await Promise.all(connectionPromises); - - // Note: UI selection is now loaded immediately from localStorage in initial state, - // so no need for delayed restoration here }, reconnectServer: async (serverId: string) => { @@ -3793,6 +3902,88 @@ const useStore = create((set, get) => ({ ircClient.bouncerDelNetwork(bouncerServerId, netid); }, + bouncerConnectNetwork: async (bouncerServerId, netid) => { + const state = get(); + const parent = state.servers.find((s) => s.id === bouncerServerId); + if (!parent) return undefined; + // Idempotent: if we already have a child server for this binding, + // do nothing -- whatever its current connectionState is, a fresh + // connect would re-issue BIND on top of the existing socket. The + // user can use the per-server reconnect affordance to retry. + const childId = uuidv5(`${bouncerServerId}:${netid}`, CHANNEL_NAMESPACE); + if (state.servers.some((s) => s.id === childId)) return undefined; + // Reuse the parent's persisted credentials. Bouncers authenticate + // the user once on the control connection and then accept child + // connections from the same client with the same auth. + const savedParent = storage.servers + .load() + .find((s) => s.id === bouncerServerId); + if (!savedParent) return undefined; + + // Network name from the bouncer's BOUNCER NETWORK announcement (if + // any) -- a friendly label for the new Server. + const bouncer = state.bouncers[bouncerServerId]; + const network = bouncer?.networks[netid]; + const friendly = network?.attributes.name || `${parent.name}/${netid}`; + + // Persist the child config so a subsequent connectToSavedServers + // can restore the same child. ServerConfig carries the + // bouncerServerId + bouncerNetid linkage. + const savedServers = storage.servers.load(); + if (!savedServers.find((s) => s.id === childId)) { + savedServers.push({ + ...savedParent, + id: childId, + name: friendly, + bouncerServerId, + bouncerNetid: netid, + isBouncerControl: false, + addedAt: Date.now(), + }); + storage.servers.save(savedServers); + } + + // Seed an in-memory Server row in "connecting" state so the UI + // can show it immediately. + set((state) => { + if (state.servers.some((s) => s.id === childId)) return state; + const seed: Server = { + id: childId, + name: friendly, + host: parent.host, + port: parent.port, + channels: [], + privateChats: [], + users: [], + isConnected: false, + connectionState: "connecting", + bouncerServerId, + bouncerNetid: netid, + }; + return { servers: [...state.servers, seed] }; + }); + + // Queue the BIND so the next CAP END for this new connection + // emits `BOUNCER BIND ` first. + ircClient.setPendingBouncerBind(childId, netid); + + // The in-memory Server.host is stripped to a bare hostname during + // the original parent connect, which loses the protocol + path for + // WSS URLs (e.g. wss://obby.t3ks.com:6662/socket -> obby.t3ks.com). + // Use the persisted savedParent.host instead so the child landing + // on the same listener actually opens /socket, not the bare root. + return ircClient.connect( + friendly, + savedParent.host, + savedParent.port, + savedParent.nickname, + savedParent.password, + savedParent.saslAccountName, + savedParent.saslPassword, + childId, + ); + }, + sendRaw: (serverId, command) => { ircClient.sendRaw(serverId, command); }, diff --git a/src/types/index.ts b/src/types/index.ts index a4c56114..13d7ec27 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -68,6 +68,16 @@ export interface Server { id: string; name: string; networkName?: string; // Network name from ISUPPORT NETWORK token + // soju.im/bouncer-networks linkage: + // `bouncerServerId` — set on a child connection; points back to the + // control connection's server id. + // `bouncerNetid` — the upstream `netid` this connection bound to + // via BOUNCER BIND before CAP END. + // `isBouncerControl` — true on the parent control connection (the + // one that did CAP REQ soju.im/bouncer-networks without binding). + bouncerServerId?: string; + bouncerNetid?: string; + isBouncerControl?: boolean; host: string; port: number; channels: Channel[]; @@ -165,6 +175,12 @@ export interface ServerConfig { operOnConnect?: boolean; addedAt?: number; // Timestamp when server was added (ms since epoch) oauth?: ServerOAuthConfig; + // soju.im/bouncer-networks: persisted form of the parent/child link + // so child connections re-bind to their upstream automatically after + // a page reload. + bouncerServerId?: string; + bouncerNetid?: string; + isBouncerControl?: boolean; } export interface ServerOAuthConfig { diff --git a/tests/components/BouncerNetworkForm.test.tsx b/tests/components/BouncerNetworkForm.test.tsx new file mode 100644 index 00000000..49b60cbd --- /dev/null +++ b/tests/components/BouncerNetworkForm.test.tsx @@ -0,0 +1,182 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import { + attrsToValues, + BouncerNetworkForm, + valuesToAttrs, +} from "../../src/components/ui/BouncerNetworkForm"; + +describe("attrsToValues", () => { + test("maps standard attributes through", () => { + const v = attrsToValues({ + name: "Libera", + host: "irc.libera.chat", + port: "6697", + tls: "1", + nickname: "nick", + username: "user", + realname: "real", + pass: "p", + }); + expect(v).toEqual({ + name: "Libera", + host: "irc.libera.chat", + port: "6697", + tls: true, + nickname: "nick", + username: "user", + realname: "real", + pass: "p", + }); + }); + + test("defaults missing attributes to empty/TLS=on", () => { + const v = attrsToValues({}); + expect(v.tls).toBe(true); + expect(v.name).toBe(""); + expect(v.host).toBe(""); + }); + + test("tls=0 disables TLS", () => { + expect(attrsToValues({ tls: "0" }).tls).toBe(false); + }); +}); + +describe("valuesToAttrs", () => { + const base = { + name: "", + host: "", + port: "", + tls: true, + nickname: "", + username: "", + realname: "", + pass: "", + }; + + test("emits all set values when no original provided (add)", () => { + const out = valuesToAttrs({ + ...base, + name: "Libera", + host: "irc.libera.chat", + port: "6697", + }); + expect(out).toEqual({ + name: "Libera", + host: "irc.libera.chat", + port: "6697", + tls: "1", + nickname: "", + username: "", + realname: "", + pass: "", + }); + }); + + test("emits only diffs when original provided (edit)", () => { + const original = { + name: "Libera", + host: "irc.libera.chat", + port: "6697", + tls: "1", + nickname: "old", + username: "", + realname: "", + pass: "", + }; + const out = valuesToAttrs( + { ...base, ...{ ...attrsToValues(original), nickname: "new" } }, + original, + ); + expect(out).toEqual({ nickname: "new" }); + }); + + test("treats unset original tls as enabled (1) for diffing", () => { + const original = { host: "x" }; + const values = { ...base, host: "x", tls: true }; + const out = valuesToAttrs(values, original); + expect(out).toEqual({}); + }); + + test("emits tls=0 when toggled off from enabled-default original", () => { + const original = { host: "x" }; + const values = { ...base, host: "x", tls: false }; + const out = valuesToAttrs(values, original); + expect(out).toEqual({ tls: "0" }); + }); + + test("does not emit read-only attributes", () => { + const out = valuesToAttrs({ ...base, host: "x" }); + expect(out).not.toHaveProperty("state"); + expect(out).not.toHaveProperty("error"); + }); +}); + +describe("", () => { + test("renders empty fields in add mode", () => { + render(); + expect(screen.getByTestId("bouncer-form-host")).toHaveValue(""); + expect(screen.getByTestId("bouncer-form-name")).toHaveValue(""); + }); + + test("save is disabled with empty host", () => { + render(); + expect(screen.getByTestId("bouncer-form-save")).toBeDisabled(); + }); + + test("enables save once host is set (add mode)", () => { + const onSave = vi.fn(); + render(); + fireEvent.change(screen.getByTestId("bouncer-form-host"), { + target: { value: "irc.libera.chat" }, + }); + const btn = screen.getByTestId("bouncer-form-save"); + expect(btn).not.toBeDisabled(); + fireEvent.click(btn); + expect(onSave).toHaveBeenCalled(); + expect(onSave.mock.calls[0][0]).toMatchObject({ host: "irc.libera.chat" }); + }); + + test("edit mode disables save until a change is made", () => { + const initial = { name: "Libera", host: "irc.libera.chat", tls: "1" }; + render( + , + ); + expect(screen.getByTestId("bouncer-form-save")).toBeDisabled(); + fireEvent.change(screen.getByTestId("bouncer-form-name"), { + target: { value: "Libera Renamed" }, + }); + expect(screen.getByTestId("bouncer-form-save")).not.toBeDisabled(); + }); + + test("renders field-level error from props", () => { + render( + , + ); + expect(screen.getByText("bad host")).toBeInTheDocument(); + }); + + test("delete confirmation flow only in edit mode", () => { + const onDelete = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByTestId("bouncer-form-delete")); + fireEvent.click(screen.getByTestId("bouncer-form-confirm-delete")); + expect(onDelete).toHaveBeenCalled(); + }); +}); diff --git a/tests/components/LinkSecurityWarningModal.test.tsx b/tests/components/LinkSecurityWarningModal.test.tsx index 8907e7d7..17786ec3 100644 --- a/tests/components/LinkSecurityWarningModal.test.tsx +++ b/tests/components/LinkSecurityWarningModal.test.tsx @@ -33,6 +33,7 @@ const mockSaveServersToLocalStorage = vi.mocked(saveServersToLocalStorage); vi.mock("../../src/lib/ircClient", () => ({ default: { sendRaw: vi.fn(), + sendCapEnd: vi.fn(), userOnConnect: vi.fn(), }, })); diff --git a/tests/protocol/bouncerBind.test.ts b/tests/protocol/bouncerBind.test.ts new file mode 100644 index 00000000..9b813784 --- /dev/null +++ b/tests/protocol/bouncerBind.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, test } from "vitest"; +import { IRCClient } from "../../src/lib/irc/IRCClient"; + +// We don't need a real WS for this test — sendCapEnd / setPendingBouncerBind +// route through this.sendRaw, which we can mock at the class level. + +describe("IRCClient sendCapEnd / pending BIND", () => { + let client: IRCClient; + let sent: string[]; + + beforeEach(() => { + client = new IRCClient(); + sent = []; + // biome-ignore lint/suspicious/noExplicitAny: stub the raw send for assertion + (client as any).sendRaw = (_id: string, line: string) => sent.push(line); + }); + + test("emits CAP END alone when no BIND is queued (regular connection)", () => { + client.sendCapEnd("s1"); + expect(sent).toEqual(["CAP END"]); + }); + + test("emits BOUNCER BIND immediately before CAP END", () => { + client.setPendingBouncerBind("s1", "42"); + client.sendCapEnd("s1"); + expect(sent).toEqual(["BOUNCER BIND 42", "CAP END"]); + }); + + test("BIND is consumed -- a follow-up sendCapEnd does not re-emit", () => { + client.setPendingBouncerBind("s1", "42"); + client.sendCapEnd("s1"); + sent.length = 0; + client.sendCapEnd("s1"); + expect(sent).toEqual(["CAP END"]); + }); + + test("BINDs are scoped per serverId", () => { + client.setPendingBouncerBind("control", ""); + // s1 has no BIND queued; control would never get a BIND in + // practice (empty netid would be wrong anyway), and crucially the + // queue doesn't leak across serverIds. + client.setPendingBouncerBind("child-a", "42"); + client.setPendingBouncerBind("child-b", "43"); + + client.sendCapEnd("child-a"); + client.sendCapEnd("child-b"); + expect(sent).toEqual([ + "BOUNCER BIND 42", + "CAP END", + "BOUNCER BIND 43", + "CAP END", + ]); + }); +}); diff --git a/tests/store/bouncer.test.ts b/tests/store/bouncer.test.ts index 95d8615d..0bb732dc 100644 --- a/tests/store/bouncer.test.ts +++ b/tests/store/bouncer.test.ts @@ -20,9 +20,13 @@ describe("bouncer store reducer", () => { test("CAP_ACKNOWLEDGED sets the supported / notifyEnabled flags", () => { ircClient.triggerEvent("CAP_ACKNOWLEDGED", { serverId: SID, - key: "ACK", - capabilities: - "soju.im/bouncer-networks soju.im/bouncer-networks-notify message-tags", + key: "soju.im/bouncer-networks", + capabilities: "", + }); + ircClient.triggerEvent("CAP_ACKNOWLEDGED", { + serverId: SID, + key: "soju.im/bouncer-networks-notify", + capabilities: "", }); const b = getBouncer(useStore.getState()); expect(b?.supported).toBe(true); diff --git a/tests/store/bouncerConnect.test.ts b/tests/store/bouncerConnect.test.ts new file mode 100644 index 00000000..6b4e307a --- /dev/null +++ b/tests/store/bouncerConnect.test.ts @@ -0,0 +1,156 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import ircClient from "../../src/lib/ircClient"; +import useStore, { type AppState } from "../../src/store"; + +const BOUNCER_ID = "ctl-bouncer-1"; + +// localStorage is globally mocked in tests/setup.ts; back it with a +// real Map per test so storage.servers.load() / save() actually round +// trip. +const lsBacking = new Map(); +function installLocalStorageBacking() { + lsBacking.clear(); + vi.mocked(window.localStorage.getItem).mockImplementation( + (k: string) => lsBacking.get(k) ?? null, + ); + vi.mocked(window.localStorage.setItem).mockImplementation( + (k: string, v: string) => { + lsBacking.set(k, v); + }, + ); + vi.mocked(window.localStorage.removeItem).mockImplementation((k: string) => { + lsBacking.delete(k); + }); +} + +function seedParentServer() { + installLocalStorageBacking(); + lsBacking.set( + "savedServers", + JSON.stringify([ + { + id: BOUNCER_ID, + host: "wss://soju.example", + port: 6697, + nickname: "alice", + saslEnabled: true, + saslAccountName: "alice", + saslPassword: "secret", + channels: [], + isBouncerControl: true, + }, + ]), + ); + useStore.setState({ + servers: [ + { + id: BOUNCER_ID, + name: "soju", + host: "wss://soju.example", + port: 6697, + channels: [], + privateChats: [], + users: [], + isConnected: true, + isBouncerControl: true, + }, + ], + bouncers: { + [BOUNCER_ID]: { + serverId: BOUNCER_ID, + supported: true, + notifyEnabled: true, + networks: { + "42": { + netid: "42", + attributes: { name: "Libera", host: "irc.libera.chat" }, + }, + }, + listed: true, + }, + }, + } as Partial); +} + +describe("bouncerConnectNetwork action", () => { + beforeEach(() => { + seedParentServer(); + vi.spyOn(ircClient, "connect").mockResolvedValue(undefined as never); + vi.spyOn(ircClient, "setPendingBouncerBind"); + }); + + afterEach(() => { + vi.restoreAllMocks(); + lsBacking.clear(); + useStore.setState({ servers: [], bouncers: {} } as Partial); + }); + + test("queues BOUNCER BIND on a fresh childId before calling connect", async () => { + await useStore.getState().bouncerConnectNetwork(BOUNCER_ID, "42"); + + // setPendingBouncerBind was called with a new id (not the bouncer's id) + expect(ircClient.setPendingBouncerBind).toHaveBeenCalledTimes(1); + const [childId, netid] = ( + ircClient.setPendingBouncerBind as ReturnType + ).mock.calls[0]; + expect(netid).toBe("42"); + expect(childId).not.toBe(BOUNCER_ID); + + // ...and connect() was invoked with the same id + expect(ircClient.connect).toHaveBeenCalledTimes(1); + const connectArgs = (ircClient.connect as ReturnType).mock + .calls[0]; + expect(connectArgs[connectArgs.length - 1]).toBe(childId); + }); + + test("seeds the child Server with bouncer linkage and connecting state", async () => { + await useStore.getState().bouncerConnectNetwork(BOUNCER_ID, "42"); + const child = useStore + .getState() + .servers.find((s) => s.bouncerNetid === "42"); + expect(child).toBeDefined(); + expect(child?.bouncerServerId).toBe(BOUNCER_ID); + expect(child?.isBouncerControl).toBeFalsy(); + expect(child?.connectionState).toBe("connecting"); + // Friendly name comes from the BOUNCER NETWORK attribute "name". + expect(child?.name).toBe("Libera"); + }); + + test("persists the child ServerConfig with parent's credentials", async () => { + await useStore.getState().bouncerConnectNetwork(BOUNCER_ID, "42"); + const saved = JSON.parse(lsBacking.get("savedServers") ?? "[]") as Array<{ + bouncerNetid?: string; + bouncerServerId?: string; + saslAccountName?: string; + saslPassword?: string; + host?: string; + port?: number; + }>; + const child = saved.find((s) => s.bouncerNetid === "42"); + expect(child).toBeDefined(); + expect(child?.bouncerServerId).toBe(BOUNCER_ID); + expect(child?.saslAccountName).toBe("alice"); + expect(child?.saslPassword).toBe("secret"); + expect(child?.host).toBe("wss://soju.example"); + expect(child?.port).toBe(6697); + }); + + test("repeated calls for the same netid are idempotent for persistence", async () => { + await useStore.getState().bouncerConnectNetwork(BOUNCER_ID, "42"); + await useStore.getState().bouncerConnectNetwork(BOUNCER_ID, "42"); + const saved = JSON.parse(lsBacking.get("savedServers") ?? "[]") as Array<{ + bouncerNetid?: string; + }>; + const matches = saved.filter((s) => s.bouncerNetid === "42"); + expect(matches).toHaveLength(1); + }); + + test("returns undefined when the parent bouncer has no saved config", async () => { + lsBacking.set("savedServers", "[]"); + const result = await useStore + .getState() + .bouncerConnectNetwork(BOUNCER_ID, "42"); + expect(result).toBeUndefined(); + expect(ircClient.connect).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/store/connectToSavedServers.test.ts b/tests/store/connectToSavedServers.test.ts new file mode 100644 index 00000000..d03399d4 --- /dev/null +++ b/tests/store/connectToSavedServers.test.ts @@ -0,0 +1,149 @@ +/** + * Boot-time reconnect ordering for bouncer parents and their children. + * + * The previous behaviour fired parent + child reconnects in parallel, + * which raced the children's SASL/BIND against the parent's not-yet- + * authenticated session and surfaced as "invalid password" against + * soju. The fix: dispatch each child only after its parent emits + * `ready`. These tests pin that ordering down so a regression shows + * up here, not in production. + */ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import ircClient from "../../src/lib/ircClient"; +import useStore, { type AppState } from "../../src/store"; + +const PARENT_ID = "p-1"; +const CHILD_ID = "c-1"; + +const lsBacking = new Map(); +function installLocalStorageBacking() { + lsBacking.clear(); + vi.mocked(window.localStorage.getItem).mockImplementation( + (k: string) => lsBacking.get(k) ?? null, + ); + vi.mocked(window.localStorage.setItem).mockImplementation( + (k: string, v: string) => { + lsBacking.set(k, v); + }, + ); + vi.mocked(window.localStorage.removeItem).mockImplementation((k: string) => { + lsBacking.delete(k); + }); +} + +function seedParentAndChild() { + installLocalStorageBacking(); + lsBacking.set( + "savedServers", + JSON.stringify([ + { + id: PARENT_ID, + name: "soju", + host: "wss://soju.example:6662/socket", + port: 6662, + nickname: "alice", + saslEnabled: true, + saslAccountName: "alice", + saslPassword: "secret", + channels: [], + isBouncerControl: true, + }, + { + id: CHILD_ID, + name: "Libera", + host: "wss://soju.example:6662/socket", + port: 6662, + nickname: "alice", + saslEnabled: true, + saslAccountName: "alice", + saslPassword: "secret", + channels: [], + bouncerServerId: PARENT_ID, + bouncerNetid: "42", + isBouncerControl: false, + }, + ]), + ); + useStore.setState({ + servers: [], + hasConnectedToSavedServers: false, + } as Partial); +} + +describe("connectToSavedServers gates bouncer children on parent ready", () => { + beforeEach(() => { + seedParentAndChild(); + // Track ircClient.on/deleteHook so we can fire the `ready` event ourselves. + vi.spyOn(ircClient, "connect").mockResolvedValue(undefined as never); + vi.spyOn(ircClient, "setPendingBouncerBind"); + // Store-level connect for non-bouncer parents — also a no-op spy. + vi.spyOn(useStore.getState(), "connect").mockResolvedValue( + undefined as never, + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + lsBacking.clear(); + // Drop every listener the test registered. The store under test + // attaches a `ready` listener to ircClient that survives across + // tests if we don't clear it -- multiple stacked listeners would + // dispatch the same child connect more than once. + ( + ircClient as unknown as { eventCallbacks: Record } + ).eventCallbacks = {}; + useStore.setState({ + servers: [], + hasConnectedToSavedServers: false, + } as Partial); + }); + + test("seeds child Server immediately so the UI shows a row from t=0", async () => { + await useStore.getState().connectToSavedServers(); + const child = useStore.getState().servers.find((s) => s.id === CHILD_ID); + expect(child).toBeDefined(); + expect(child?.bouncerServerId).toBe(PARENT_ID); + expect(child?.bouncerNetid).toBe("42"); + expect(child?.connectionState).toBe("connecting"); + }); + + test("does NOT dispatch the child connect before parent emits ready", async () => { + await useStore.getState().connectToSavedServers(); + // The parent's connect happened (store-level); the child should not have + // its own WS yet. + const ircConnectCalls = ( + ircClient.connect as ReturnType + ).mock.calls.filter((c) => c[c.length - 1] === CHILD_ID); + expect(ircConnectCalls).toHaveLength(0); + expect(ircClient.setPendingBouncerBind).not.toHaveBeenCalled(); + }); + + test("dispatches the child connect once the parent's ready event fires", async () => { + await useStore.getState().connectToSavedServers(); + // Fire ready for the parent — the deferred-child listener should + // pick it up and call ircClient.connect with the child id. + ircClient.triggerEvent("ready", { + serverId: PARENT_ID, + serverName: "soju.example", + nickname: "alice", + }); + expect(ircClient.setPendingBouncerBind).toHaveBeenCalledWith( + CHILD_ID, + "42", + ); + const callsForChild = ( + ircClient.connect as ReturnType + ).mock.calls.filter((c) => c[c.length - 1] === CHILD_ID); + expect(callsForChild).toHaveLength(1); + }); + + test("ignores ready events from unrelated servers", async () => { + await useStore.getState().connectToSavedServers(); + ircClient.triggerEvent("ready", { + serverId: "some-other-server", + serverName: "irrelevant", + nickname: "alice", + }); + expect(ircClient.setPendingBouncerBind).not.toHaveBeenCalled(); + }); +});