diff --git a/src/lib/chatMarkdownCopy.ts b/src/lib/chatMarkdownCopy.ts
new file mode 100644
index 00000000..6f22ef9a
--- /dev/null
+++ b/src/lib/chatMarkdownCopy.ts
@@ -0,0 +1,161 @@
+/**
+ * Markdown serialization for copying chat selections.
+ *
+ * The rendered DOM strips markdown (code fences, links, emphasis) down to plain
+ * text and hides author/timestamp from the selection, so a raw browser copy
+ * loses both the structure and who said what. Each message row carries its
+ * original markdown source plus author/time in data attributes; on copy we walk
+ * the selected rows and rebuild a Discord-style markdown transcript from those.
+ */
+
+export interface MessageCopyData {
+ author: string;
+ time: string;
+ source: string;
+}
+
+/**
+ * Author label for a copied message. Includes the underlying nick alongside a
+ * display name (e.g. "Brazil ⇨ Germany (mattf)") so the transcript stays
+ * attributable even when display names are decorative or collide.
+ */
+export function formatCopyAuthor(
+ displayName: string | undefined | null,
+ username: string,
+ isSystem = false,
+): string {
+ if (isSystem) return "System";
+ if (displayName && displayName !== username) {
+ return `${displayName} (${username})`;
+ }
+ return displayName || username;
+}
+
+/** Normalizes a raw message body for copying (unwraps CTCP ACTION, trims). */
+export function normalizeCopySource(raw: string): string {
+ // CTCP ACTION (/me) arrives as "ACTION " once the markers are stripped.
+ const action = /^ACTION (.*)$/s.exec(raw);
+ const text = action ? `* ${action[1]}` : raw;
+ return text.replace(/\s+$/, "");
+}
+
+/**
+ * Builds a markdown transcript. Consecutive messages from the same author share
+ * one "author — time" header, mirroring how the chat groups them visually.
+ */
+export function serializeMessagesToMarkdown(
+ messages: MessageCopyData[],
+): string {
+ const blocks: string[] = [];
+ let prevAuthor: string | null = null;
+ let current: string[] = [];
+
+ const flush = () => {
+ if (current.length > 0) blocks.push(current.join("\n"));
+ current = [];
+ };
+
+ for (const msg of messages) {
+ const source = normalizeCopySource(msg.source);
+ if (msg.author !== prevAuthor) {
+ flush();
+ current.push(`**${msg.author}** — ${msg.time}`);
+ prevAuthor = msg.author;
+ }
+ if (source) current.push(source);
+ }
+ flush();
+
+ return blocks.join("\n\n");
+}
+
+/** Reads the per-message copy data attributes, falling back to visible text. */
+export function readMessageCopyData(el: HTMLElement): MessageCopyData | null {
+ const author = el.getAttribute("data-md-author");
+ const time = el.getAttribute("data-md-time");
+ if (author === null || time === null) return null;
+ const source = el.getAttribute("data-md-source");
+ return {
+ author,
+ time,
+ // Rows without an explicit source (events, system) copy their visible text.
+ source: source ?? el.textContent ?? "",
+ };
+}
+
+function rangeForNode(node: Node): Range {
+ const r = node.ownerDocument?.createRange() ?? document.createRange();
+ r.selectNode(node);
+ return r;
+}
+
+/** True when `range` overlaps `node` at all (even partially). */
+function rangeIntersectsNode(range: Range, node: Node): boolean {
+ const nodeRange = rangeForNode(node);
+ return (
+ range.compareBoundaryPoints(Range.END_TO_START, nodeRange) < 0 &&
+ range.compareBoundaryPoints(Range.START_TO_END, nodeRange) > 0
+ );
+}
+
+/** True when `range` fully encloses `node`. */
+function rangeCoversNode(range: Range, node: Node): boolean {
+ const nodeRange = rangeForNode(node);
+ return (
+ range.compareBoundaryPoints(Range.START_TO_START, nodeRange) <= 0 &&
+ range.compareBoundaryPoints(Range.END_TO_END, nodeRange) >= 0
+ );
+}
+
+/** Selected message rows in document order, deduped across multi-range selections. */
+export function messageNodesInSelection(
+ selection: Selection,
+ root: ParentNode,
+): HTMLElement[] {
+ const candidates = Array.from(
+ root.querySelectorAll("[data-message-id]"),
+ );
+ if (candidates.length === 0) return [];
+
+ const ranges: Range[] = [];
+ for (let i = 0; i < selection.rangeCount; i++) {
+ ranges.push(selection.getRangeAt(i));
+ }
+ return candidates.filter((node) =>
+ ranges.some((range) => rangeIntersectsNode(range, node)),
+ );
+}
+
+/**
+ * Markdown for the current selection, or null when the caller should let the
+ * browser perform its native copy. Native copy is preferred for a partial
+ * selection inside a single message (the user is grabbing a substring); markdown
+ * is produced for any multi-message selection or a fully-selected single message
+ * (where the rendered text would otherwise lose its markdown and author).
+ */
+export function buildMarkdownFromSelection(
+ selection: Selection | null,
+ root: ParentNode,
+): string | null {
+ if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
+ return null;
+ }
+ const nodes = messageNodesInSelection(selection, root);
+ if (nodes.length === 0) return null;
+
+ if (nodes.length === 1) {
+ const ranges = Array.from({ length: selection.rangeCount }, (_, i) =>
+ selection.getRangeAt(i),
+ );
+ if (!ranges.some((range) => rangeCoversNode(range, nodes[0]))) {
+ return null;
+ }
+ }
+
+ const data = nodes
+ .map(readMessageCopyData)
+ .filter((d): d is MessageCopyData => d !== null);
+ if (data.length === 0) return null;
+
+ return serializeMessagesToMarkdown(data);
+}
diff --git a/src/lib/mediaProbe.ts b/src/lib/mediaProbe.ts
index 355dca0a..438a8862 100644
--- a/src/lib/mediaProbe.ts
+++ b/src/lib/mediaProbe.ts
@@ -38,10 +38,17 @@ export function getCachedProbeResult(
return cache.get(url) ?? null;
}
+interface HttpProbe {
+ contentType: string;
+ contentLength?: number;
+ ok: boolean;
+ status: number;
+}
+
async function tryHttpFetch(
url: string,
method: "HEAD" | "GET",
-): Promise<{ contentType: string; contentLength?: number } | null> {
+): Promise {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5_000);
try {
@@ -54,15 +61,19 @@ async function tryHttpFetch(
signal: controller.signal,
});
clearTimeout(timer);
- const ct = (response.headers.get("Content-Type") ?? "")
+ const contentType = (response.headers.get("Content-Type") ?? "")
.split(";")[0]
.trim()
.toLowerCase();
- if (!ct) return null;
const lengthHeader = response.headers.get("Content-Length");
const contentLength =
lengthHeader !== null ? Number(lengthHeader) : undefined;
- return { contentType: ct, contentLength };
+ return {
+ contentType,
+ contentLength,
+ ok: response.ok,
+ status: response.status,
+ };
} catch (_err) {
clearTimeout(timer);
return null;
@@ -70,15 +81,27 @@ async function tryHttpFetch(
}
/**
- * Tries HEAD then GET (with Range: bytes=0-0) to read the Content-Type header.
- * Some streaming servers (e.g. Icecast) reject HEAD but respond correctly to GET.
+ * Reads the Content-Type via HEAD, falling back to a ranged GET. Some streaming
+ * servers (e.g. Icecast, suno) reject HEAD with 405/501 but answer GET correctly,
+ * so a non-2xx HEAD must not be trusted as the resource's real type.
*/
async function fetchContentType(
url: string,
): Promise<{ contentType: string; contentLength?: number } | null> {
const head = await tryHttpFetch(url, "HEAD");
- if (head) return head;
- return tryHttpFetch(url, "GET");
+ if (head?.ok && head.contentType) {
+ return { contentType: head.contentType, contentLength: head.contentLength };
+ }
+ // Only the GET retry can help when HEAD threw (CORS), was rejected as a method
+ // (405/501), or returned 2xx without a type. A 404/410/5xx won't improve on GET.
+ if (head !== null && !head.ok && head.status !== 405 && head.status !== 501) {
+ return null;
+ }
+ const get = await tryHttpFetch(url, "GET");
+ if (get?.ok && get.contentType) {
+ return { contentType: get.contentType, contentLength: get.contentLength };
+ }
+ return null;
}
/** Maps a Content-Type to a ProbeResult. Single source of truth for media classification. */
diff --git a/src/locales/cs/messages.mjs b/src/locales/cs/messages.mjs
index 9d025589..b871ad16 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\"],\"/4C8U0\":[\"Copy all\"],\"/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\"],\"2F9+AZ\":[\"No raw IRC traffic captured yet. Try connecting or sending a message.\"],\"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\"],\"8o3dPc\":[\"Přetáhněte soubory k nahrání\"],\"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:0> 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\"],\"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!0> 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í:0> 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í!0> 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\"],\"0> 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\"],\"/4C8U0\":[\"Copy all\"],\"/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\"],\"2F9+AZ\":[\"No raw IRC traffic captured yet. Try connecting or sending a message.\"],\"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\"],\"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\"],\"8o3dPc\":[\"Přetáhněte soubory k nahrání\"],\"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:0> 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\"],\"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!0> 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í:0> 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í!0> Otevírejte pouze odkazy z důvěryhodných zdrojů. Škodlivé odkazy mohou ohrozit vaši bezpečnost nebo soukromí.\"],\"mH+wEJ\":[\"Message \",[\"0\"],\" (Enter for new line, Shift+Enter to send)\"],\"mHGdhG\":[\"Informace o serveru\"],\"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...\"],\"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\"],\"pqr+oY\":[\"Message \",[\"0\"]],\"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\"],\"0> 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\"],\"z0DY9w\":[\"Message \",[\"0\"],\" (Shift+Enter for new line)\"],\"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 8a298a52..0e341fe2 100644
--- a/src/locales/cs/messages.po
+++ b/src/locales/cs/messages.po
@@ -23,8 +23,8 @@ msgid "— open in viewer"
msgstr "— otevřít v prohlížeči"
#. 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 ( <>
> ); }, ); 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 ( <>
> ); }, ); 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 {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 { buildMarkdownFromSelection } from "../../lib/chatMarkdownCopy"; 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 ( <>
> ); }, ); 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 { buildMarkdownFromSelection } from "../../lib/chatMarkdownCopy"; 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 ( <>
> ); }, ); 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);
#: src/components/layout/ChannelMessageList.tsx
msgid "{0, plural, one {{1}} other {{2}}}"
msgstr "{0, plural, one {{1}} other {{2}}}"
@@ -1384,6 +1384,21 @@ msgstr "Náhledy médií"
msgid "Members — {0}"
msgstr "Členové — {0}"
+#. placeholder {0}: selectedChannel.name
+#: src/components/layout/ChatArea.tsx
+msgid "Message {0}"
+msgstr ""
+
+#. placeholder {0}: selectedChannel.name
+#: src/components/layout/ChatArea.tsx
+msgid "Message {0} (Enter for new line, Shift+Enter to send)"
+msgstr ""
+
+#. placeholder {0}: selectedChannel.name
+#: src/components/layout/ChatArea.tsx
+msgid "Message {0} (Shift+Enter for new line)"
+msgstr ""
+
#. placeholder {0}: selectedPrivateChat.username
#: src/components/layout/ChatArea.tsx
msgid "Message @{0}"
@@ -1399,21 +1414,6 @@ msgstr "Zpráva @{0} (Enter pro nový řádek, Shift+Enter pro odeslání)"
msgid "Message @{0} (Shift+Enter for new line)"
msgstr "Zpráva @{0} (Shift+Enter pro nový řádek)"
-#. placeholder {0}: selectedChannel.name.replace(/^#/, "")
-#: src/components/layout/ChatArea.tsx
-msgid "Message #{0}"
-msgstr "Zpráva #{0}"
-
-#. placeholder {0}: selectedChannel.name.replace(/^#/, "")
-#: src/components/layout/ChatArea.tsx
-msgid "Message #{0} (Enter for new line, Shift+Enter to send)"
-msgstr "Zpráva #{0} (Enter pro nový řádek, Shift+Enter pro odeslání)"
-
-#. placeholder {0}: selectedChannel.name.replace(/^#/, "")
-#: src/components/layout/ChatArea.tsx
-msgid "Message #{0} (Shift+Enter for new line)"
-msgstr "Zpráva #{0} (Shift+Enter pro nový řádek)"
-
#. placeholder {0}: searchTerm.trim()
#: src/components/ui/AddPrivateChatModal.tsx
msgid "Message <0>{0}0>"
diff --git a/src/locales/de/messages.mjs b/src/locales/de/messages.mjs
index bff55485..3bf123aa 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\"],\"/4C8U0\":[\"Copy all\"],\"/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\"],\"2F9+AZ\":[\"No raw IRC traffic captured yet. Try connecting or sending a message.\"],\"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\"],\"8o3dPc\":[\"Dateien zum Hochladen hier ablegen\"],\"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:0> 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\"],\"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!0> 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:0> 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!0> Ö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\"],\"0> 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\"],\"/4C8U0\":[\"Copy all\"],\"/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\"],\"2F9+AZ\":[\"No raw IRC traffic captured yet. Try connecting or sending a message.\"],\"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\"],\"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\"],\"8o3dPc\":[\"Dateien zum Hochladen hier ablegen\"],\"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:0> 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\"],\"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!0> 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:0> 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!0> Öffne nur Links aus vertrauenswürdigen Quellen. Bösartige Links können deine Sicherheit oder Privatsphäre gefährden.\"],\"mH+wEJ\":[\"Message \",[\"0\"],\" (Enter for new line, Shift+Enter to send)\"],\"mHGdhG\":[\"Serverinformationen\"],\"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...\"],\"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\"],\"pqr+oY\":[\"Message \",[\"0\"]],\"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\"],\"0> 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\"],\"z0DY9w\":[\"Message \",[\"0\"],\" (Shift+Enter for new line)\"],\"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 758c58d4..b038c99c 100644
--- a/src/locales/de/messages.po
+++ b/src/locales/de/messages.po
@@ -23,8 +23,8 @@ msgid "— open in viewer"
msgstr "— im Viewer öffnen"
#. 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 ( <>
> ); }, ); 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 ( <>
> ); }, ); 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 {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 { buildMarkdownFromSelection } from "../../lib/chatMarkdownCopy"; 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 ( <>
> ); }, ); 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 { buildMarkdownFromSelection } from "../../lib/chatMarkdownCopy"; 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 ( <>
> ); }, ); 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);
#: src/components/layout/ChannelMessageList.tsx
msgid "{0, plural, one {{1}} other {{2}}}"
msgstr "{0, plural, one {{1}} other {{2}}}"
@@ -1384,6 +1384,21 @@ msgstr "Medienvorschauen"
msgid "Members — {0}"
msgstr "Mitglieder — {0}"
+#. placeholder {0}: selectedChannel.name
+#: src/components/layout/ChatArea.tsx
+msgid "Message {0}"
+msgstr ""
+
+#. placeholder {0}: selectedChannel.name
+#: src/components/layout/ChatArea.tsx
+msgid "Message {0} (Enter for new line, Shift+Enter to send)"
+msgstr ""
+
+#. placeholder {0}: selectedChannel.name
+#: src/components/layout/ChatArea.tsx
+msgid "Message {0} (Shift+Enter for new line)"
+msgstr ""
+
#. placeholder {0}: selectedPrivateChat.username
#: src/components/layout/ChatArea.tsx
msgid "Message @{0}"
@@ -1399,21 +1414,6 @@ msgstr "Nachricht an @{0} (Enter für neue Zeile, Shift+Enter zum Senden)"
msgid "Message @{0} (Shift+Enter for new line)"
msgstr "Nachricht an @{0} (Shift+Enter für neue Zeile)"
-#. placeholder {0}: selectedChannel.name.replace(/^#/, "")
-#: src/components/layout/ChatArea.tsx
-msgid "Message #{0}"
-msgstr "Nachricht an #{0}"
-
-#. placeholder {0}: selectedChannel.name.replace(/^#/, "")
-#: src/components/layout/ChatArea.tsx
-msgid "Message #{0} (Enter for new line, Shift+Enter to send)"
-msgstr "Nachricht an #{0} (Enter für neue Zeile, Shift+Enter zum Senden)"
-
-#. placeholder {0}: selectedChannel.name.replace(/^#/, "")
-#: src/components/layout/ChatArea.tsx
-msgid "Message #{0} (Shift+Enter for new line)"
-msgstr "Nachricht an #{0} (Shift+Enter für neue Zeile)"
-
#. placeholder {0}: searchTerm.trim()
#: src/components/ui/AddPrivateChatModal.tsx
msgid "Message <0>{0}0>"
diff --git a/src/locales/en/messages.mjs b/src/locales/en/messages.mjs
index 11072b87..18e3338c 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\"],\"/4C8U0\":[\"Copy all\"],\"/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\"],\"2F9+AZ\":[\"No raw IRC traffic captured yet. Try connecting or sending a message.\"],\"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\"],\"8o3dPc\":[\"Drop files to upload\"],\"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:0> 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\"],\"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!0> 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:0> 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!0> 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\"],\"0> 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\"],\"/4C8U0\":[\"Copy all\"],\"/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\"],\"2F9+AZ\":[\"No raw IRC traffic captured yet. Try connecting or sending a message.\"],\"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\"],\"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\"],\"8o3dPc\":[\"Drop files to upload\"],\"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:0> 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\"],\"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!0> 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:0> 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!0> Only open links from trusted sources. Malicious links can compromise your security or privacy.\"],\"mH+wEJ\":[\"Message \",[\"0\"],\" (Enter for new line, Shift+Enter to send)\"],\"mHGdhG\":[\"Server Information\"],\"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...\"],\"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\"],\"pqr+oY\":[\"Message \",[\"0\"]],\"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\"],\"0> 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\"],\"z0DY9w\":[\"Message \",[\"0\"],\" (Shift+Enter for new line)\"],\"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 4a5ba542..6b55a487 100644
--- a/src/locales/en/messages.po
+++ b/src/locales/en/messages.po
@@ -22,8 +22,8 @@ msgid "— open in viewer"
msgstr "— open in viewer"
#. 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 ( <>
> ); }, ); 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 ( <>
> ); }, ); 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 {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 { buildMarkdownFromSelection } from "../../lib/chatMarkdownCopy"; 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 ( <>
> ); }, ); 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 { buildMarkdownFromSelection } from "../../lib/chatMarkdownCopy"; 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 ( <>
> ); }, ); 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);
#: src/components/layout/ChannelMessageList.tsx
msgid "{0, plural, one {{1}} other {{2}}}"
msgstr "{0, plural, one {{1}} other {{2}}}"
@@ -1383,6 +1383,21 @@ msgstr "Media Previews"
msgid "Members — {0}"
msgstr "Members — {0}"
+#. placeholder {0}: selectedChannel.name
+#: src/components/layout/ChatArea.tsx
+msgid "Message {0}"
+msgstr "Message {0}"
+
+#. placeholder {0}: selectedChannel.name
+#: src/components/layout/ChatArea.tsx
+msgid "Message {0} (Enter for new line, Shift+Enter to send)"
+msgstr "Message {0} (Enter for new line, Shift+Enter to send)"
+
+#. placeholder {0}: selectedChannel.name
+#: src/components/layout/ChatArea.tsx
+msgid "Message {0} (Shift+Enter for new line)"
+msgstr "Message {0} (Shift+Enter for new line)"
+
#. placeholder {0}: selectedPrivateChat.username
#: src/components/layout/ChatArea.tsx
msgid "Message @{0}"
@@ -1398,21 +1413,6 @@ msgstr "Message @{0} (Enter for new line, Shift+Enter to send)"
msgid "Message @{0} (Shift+Enter for new line)"
msgstr "Message @{0} (Shift+Enter for new line)"
-#. placeholder {0}: selectedChannel.name.replace(/^#/, "")
-#: src/components/layout/ChatArea.tsx
-msgid "Message #{0}"
-msgstr "Message #{0}"
-
-#. placeholder {0}: selectedChannel.name.replace(/^#/, "")
-#: src/components/layout/ChatArea.tsx
-msgid "Message #{0} (Enter for new line, Shift+Enter to send)"
-msgstr "Message #{0} (Enter for new line, Shift+Enter to send)"
-
-#. placeholder {0}: selectedChannel.name.replace(/^#/, "")
-#: src/components/layout/ChatArea.tsx
-msgid "Message #{0} (Shift+Enter for new line)"
-msgstr "Message #{0} (Shift+Enter for new line)"
-
#. placeholder {0}: searchTerm.trim()
#: src/components/ui/AddPrivateChatModal.tsx
msgid "Message <0>{0}0>"
diff --git a/src/locales/es/messages.mjs b/src/locales/es/messages.mjs
index 9ea0cec9..fabebe27 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\"],\"/4C8U0\":[\"Copy all\"],\"/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\"],\"2F9+AZ\":[\"No raw IRC traffic captured yet. Try connecting or sending a message.\"],\"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\"],\"8o3dPc\":[\"Suelta los archivos para subirlos\"],\"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:0> 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\"],\"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!0> 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:0> 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!0> 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\"],\"0> 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\"],\"/4C8U0\":[\"Copy all\"],\"/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\"],\"2F9+AZ\":[\"No raw IRC traffic captured yet. Try connecting or sending a message.\"],\"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ó\"],\"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\"],\"8o3dPc\":[\"Suelta los archivos para subirlos\"],\"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:0> 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\"],\"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!0> 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:0> 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!0> Solo abre enlaces de fuentes de confianza. Los enlaces maliciosos pueden comprometer tu seguridad o privacidad.\"],\"mH+wEJ\":[\"Message \",[\"0\"],\" (Enter for new line, Shift+Enter to send)\"],\"mHGdhG\":[\"Información del servidor\"],\"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...\"],\"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\"],\"pqr+oY\":[\"Message \",[\"0\"]],\"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\"],\"0> 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\"],\"z0DY9w\":[\"Message \",[\"0\"],\" (Shift+Enter for new line)\"],\"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 3ed0b8df..cc1d0356 100644
--- a/src/locales/es/messages.po
+++ b/src/locales/es/messages.po
@@ -23,8 +23,8 @@ msgid "— open in viewer"
msgstr "— abrir en visor"
#. 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 ( <>
> ); }, ); 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 ( <>
> ); }, ); 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 {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 { buildMarkdownFromSelection } from "../../lib/chatMarkdownCopy"; 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 ( <>
> ); }, ); 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 { buildMarkdownFromSelection } from "../../lib/chatMarkdownCopy"; 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 ( <>
> ); }, ); 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);
#: src/components/layout/ChannelMessageList.tsx
msgid "{0, plural, one {{1}} other {{2}}}"
msgstr "{0, plural, one {{1}} other {{2}}}"
@@ -1384,6 +1384,21 @@ msgstr "Vistas previas de medios"
msgid "Members — {0}"
msgstr "Miembros — {0}"
+#. placeholder {0}: selectedChannel.name
+#: src/components/layout/ChatArea.tsx
+msgid "Message {0}"
+msgstr ""
+
+#. placeholder {0}: selectedChannel.name
+#: src/components/layout/ChatArea.tsx
+msgid "Message {0} (Enter for new line, Shift+Enter to send)"
+msgstr ""
+
+#. placeholder {0}: selectedChannel.name
+#: src/components/layout/ChatArea.tsx
+msgid "Message {0} (Shift+Enter for new line)"
+msgstr ""
+
#. placeholder {0}: selectedPrivateChat.username
#: src/components/layout/ChatArea.tsx
msgid "Message @{0}"
@@ -1399,21 +1414,6 @@ msgstr "Mensaje a @{0} (Intro para nueva línea, Mayús+Intro para enviar)"
msgid "Message @{0} (Shift+Enter for new line)"
msgstr "Mensaje a @{0} (Mayús+Intro para nueva línea)"
-#. placeholder {0}: selectedChannel.name.replace(/^#/, "")
-#: src/components/layout/ChatArea.tsx
-msgid "Message #{0}"
-msgstr "Mensaje en #{0}"
-
-#. placeholder {0}: selectedChannel.name.replace(/^#/, "")
-#: src/components/layout/ChatArea.tsx
-msgid "Message #{0} (Enter for new line, Shift+Enter to send)"
-msgstr "Mensaje en #{0} (Intro para nueva línea, Mayús+Intro para enviar)"
-
-#. placeholder {0}: selectedChannel.name.replace(/^#/, "")
-#: src/components/layout/ChatArea.tsx
-msgid "Message #{0} (Shift+Enter for new line)"
-msgstr "Mensaje en #{0} (Mayús+Intro para nueva línea)"
-
#. placeholder {0}: searchTerm.trim()
#: src/components/ui/AddPrivateChatModal.tsx
msgid "Message <0>{0}0>"
diff --git a/src/locales/fi/messages.mjs b/src/locales/fi/messages.mjs
index eb7cd3bd..20d5aa9e 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\"],\"/4C8U0\":[\"Copy all\"],\"/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\"],\"2F9+AZ\":[\"No raw IRC traffic captured yet. Try connecting or sending a message.\"],\"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ö\"],\"8o3dPc\":[\"Pudota tiedostot ladataksesi\"],\"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:0> 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\"],\"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!0> 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:0> 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!0> 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\"],\"0> 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\"],\"/4C8U0\":[\"Copy all\"],\"/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\"],\"2F9+AZ\":[\"No raw IRC traffic captured yet. Try connecting or sending a message.\"],\"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\"],\"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ö\"],\"8o3dPc\":[\"Pudota tiedostot ladataksesi\"],\"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:0> 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\"],\"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!0> 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:0> 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!0> Avaa linkkejä vain luotetuista lähteistä. Haitalliset linkit voivat vaarantaa tietoturvasi tai yksityisyytesi.\"],\"mH+wEJ\":[\"Message \",[\"0\"],\" (Enter for new line, Shift+Enter to send)\"],\"mHGdhG\":[\"Palvelimen tiedot\"],\"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...\"],\"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\"],\"pqr+oY\":[\"Message \",[\"0\"]],\"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\"],\"0> 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\"],\"z0DY9w\":[\"Message \",[\"0\"],\" (Shift+Enter for new line)\"],\"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 73be6fac..c350b981 100644
--- a/src/locales/fi/messages.po
+++ b/src/locales/fi/messages.po
@@ -23,8 +23,8 @@ msgid "— open in viewer"
msgstr "— avaa katseluohjelmassa"
#. 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 ( <>
> ); }, ); 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 ( <>
> ); }, ); 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 {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 { buildMarkdownFromSelection } from "../../lib/chatMarkdownCopy"; 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 ( <>
> ); }, ); 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 { buildMarkdownFromSelection } from "../../lib/chatMarkdownCopy"; 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 ( <>
> ); }, ); 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);
#: src/components/layout/ChannelMessageList.tsx
msgid "{0, plural, one {{1}} other {{2}}}"
msgstr "{0, plural, one {{1}} other {{2}}}"
@@ -1384,6 +1384,21 @@ msgstr "Median esikatselut"
msgid "Members — {0}"
msgstr "Jäsenet — {0}"
+#. placeholder {0}: selectedChannel.name
+#: src/components/layout/ChatArea.tsx
+msgid "Message {0}"
+msgstr ""
+
+#. placeholder {0}: selectedChannel.name
+#: src/components/layout/ChatArea.tsx
+msgid "Message {0} (Enter for new line, Shift+Enter to send)"
+msgstr ""
+
+#. placeholder {0}: selectedChannel.name
+#: src/components/layout/ChatArea.tsx
+msgid "Message {0} (Shift+Enter for new line)"
+msgstr ""
+
#. placeholder {0}: selectedPrivateChat.username
#: src/components/layout/ChatArea.tsx
msgid "Message @{0}"
@@ -1399,21 +1414,6 @@ msgstr "Viesti @{0} (Enter = uusi rivi, Shift+Enter = lähetä)"
msgid "Message @{0} (Shift+Enter for new line)"
msgstr "Viesti @{0} (Shift+Enter = uusi rivi)"
-#. placeholder {0}: selectedChannel.name.replace(/^#/, "")
-#: src/components/layout/ChatArea.tsx
-msgid "Message #{0}"
-msgstr "Viesti #{0}"
-
-#. placeholder {0}: selectedChannel.name.replace(/^#/, "")
-#: src/components/layout/ChatArea.tsx
-msgid "Message #{0} (Enter for new line, Shift+Enter to send)"
-msgstr "Viesti #{0} (Enter = uusi rivi, Shift+Enter = lähetä)"
-
-#. placeholder {0}: selectedChannel.name.replace(/^#/, "")
-#: src/components/layout/ChatArea.tsx
-msgid "Message #{0} (Shift+Enter for new line)"
-msgstr "Viesti #{0} (Shift+Enter = uusi rivi)"
-
#. placeholder {0}: searchTerm.trim()
#: src/components/ui/AddPrivateChatModal.tsx
msgid "Message <0>{0}0>"
diff --git a/src/locales/fr/messages.mjs b/src/locales/fr/messages.mjs
index 8ca89395..00057de8 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\"],\"/4C8U0\":[\"Copy all\"],\"/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\"],\"2F9+AZ\":[\"No raw IRC traffic captured yet. Try connecting or sending a message.\"],\"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\"],\"8o3dPc\":[\"Déposez les fichiers à téléverser\"],\"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 :0> 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\"],\"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é !0> 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 :0> 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 !0> 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\"],\"0> 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\"],\"/4C8U0\":[\"Copy all\"],\"/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\"],\"2F9+AZ\":[\"No raw IRC traffic captured yet. Try connecting or sending a message.\"],\"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é\"],\"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\"],\"8o3dPc\":[\"Déposez les fichiers à téléverser\"],\"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 :0> 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\"],\"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é !0> 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 :0> 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 !0> N'ouvrez que des liens provenant de sources fiables. Des liens malveillants peuvent compromettre votre sécurité ou votre vie privée.\"],\"mH+wEJ\":[\"Message \",[\"0\"],\" (Enter for new line, Shift+Enter to send)\"],\"mHGdhG\":[\"Informations sur le serveur\"],\"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...\"],\"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\"],\"pqr+oY\":[\"Message \",[\"0\"]],\"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\"],\"0> 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\"],\"z0DY9w\":[\"Message \",[\"0\"],\" (Shift+Enter for new line)\"],\"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 f7aa9cb9..16eaf975 100644
--- a/src/locales/fr/messages.po
+++ b/src/locales/fr/messages.po
@@ -23,8 +23,8 @@ msgid "— open in viewer"
msgstr "— ouvrir dans le visualiseur"
#. 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 ( <>
> ); }, ); 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 ( <>
> ); }, ); 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 {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 { buildMarkdownFromSelection } from "../../lib/chatMarkdownCopy"; 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 ( <>
> ); }, ); 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 { buildMarkdownFromSelection } from "../../lib/chatMarkdownCopy"; 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 ( <>
> ); }, ); 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);
#: src/components/layout/ChannelMessageList.tsx
msgid "{0, plural, one {{1}} other {{2}}}"
msgstr "{0, plural, one {{1}} other {{2}}}"
@@ -1384,6 +1384,21 @@ msgstr "Aperçus des médias"
msgid "Members — {0}"
msgstr "Membres — {0}"
+#. placeholder {0}: selectedChannel.name
+#: src/components/layout/ChatArea.tsx
+msgid "Message {0}"
+msgstr ""
+
+#. placeholder {0}: selectedChannel.name
+#: src/components/layout/ChatArea.tsx
+msgid "Message {0} (Enter for new line, Shift+Enter to send)"
+msgstr ""
+
+#. placeholder {0}: selectedChannel.name
+#: src/components/layout/ChatArea.tsx
+msgid "Message {0} (Shift+Enter for new line)"
+msgstr ""
+
#. placeholder {0}: selectedPrivateChat.username
#: src/components/layout/ChatArea.tsx
msgid "Message @{0}"
@@ -1399,21 +1414,6 @@ msgstr "Message à @{0} (Entrée pour nouvelle ligne, Maj+Entrée pour envoyer)"
msgid "Message @{0} (Shift+Enter for new line)"
msgstr "Message à @{0} (Maj+Entrée pour nouvelle ligne)"
-#. placeholder {0}: selectedChannel.name.replace(/^#/, "")
-#: src/components/layout/ChatArea.tsx
-msgid "Message #{0}"
-msgstr "Message dans #{0}"
-
-#. placeholder {0}: selectedChannel.name.replace(/^#/, "")
-#: src/components/layout/ChatArea.tsx
-msgid "Message #{0} (Enter for new line, Shift+Enter to send)"
-msgstr "Message dans #{0} (Entrée pour nouvelle ligne, Maj+Entrée pour envoyer)"
-
-#. placeholder {0}: selectedChannel.name.replace(/^#/, "")
-#: src/components/layout/ChatArea.tsx
-msgid "Message #{0} (Shift+Enter for new line)"
-msgstr "Message dans #{0} (Maj+Entrée pour nouvelle ligne)"
-
#. placeholder {0}: searchTerm.trim()
#: src/components/ui/AddPrivateChatModal.tsx
msgid "Message <0>{0}0>"
diff --git a/src/locales/it/messages.mjs b/src/locales/it/messages.mjs
index f4c9e1fe..9d4bab50 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\"],\"/4C8U0\":[\"Copy all\"],\"/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\"],\"2F9+AZ\":[\"No raw IRC traffic captured yet. Try connecting or sending a message.\"],\"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\"],\"8o3dPc\":[\"Trascina qui i file da caricare\"],\"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:0> 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\"],\"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!0> 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:0> 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!0> 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\"],\"0> 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\"],\"/4C8U0\":[\"Copy all\"],\"/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\"],\"2F9+AZ\":[\"No raw IRC traffic captured yet. Try connecting or sending a message.\"],\"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\"],\"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\"],\"8o3dPc\":[\"Trascina qui i file da caricare\"],\"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:0> 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\"],\"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!0> 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:0> 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!0> Apri solo link da fonti attendibili. I link malevoli possono compromettere la tua sicurezza o privacy.\"],\"mH+wEJ\":[\"Message \",[\"0\"],\" (Enter for new line, Shift+Enter to send)\"],\"mHGdhG\":[\"Informazioni sul server\"],\"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...\"],\"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\"],\"pqr+oY\":[\"Message \",[\"0\"]],\"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\"],\"0> 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\"],\"z0DY9w\":[\"Message \",[\"0\"],\" (Shift+Enter for new line)\"],\"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 ee2db611..6f2f06ba 100644
--- a/src/locales/it/messages.po
+++ b/src/locales/it/messages.po
@@ -23,8 +23,8 @@ msgid "— open in viewer"
msgstr "— apri nel visualizzatore"
#. 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 ( <>
> ); }, ); 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 ( <>
> ); }, ); 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 {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 { buildMarkdownFromSelection } from "../../lib/chatMarkdownCopy"; 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 ( <>
> ); }, ); 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 { buildMarkdownFromSelection } from "../../lib/chatMarkdownCopy"; 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 ( <>
> ); }, ); 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 ( <>
> ); }, ); 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 {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 { buildMarkdownFromSelection } from "../../lib/chatMarkdownCopy"; 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 ( <>
> ); }, ); 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 { buildMarkdownFromSelection } from "../../lib/chatMarkdownCopy"; 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 ( <>
> ); }, ); 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);
#: src/components/layout/ChannelMessageList.tsx
msgid "{0, plural, one {{1}} other {{2}}}"
msgstr "{0, plural, one {{1}} other {{2}}}"
@@ -1384,6 +1384,21 @@ msgstr "メディアプレビュー"
msgid "Members — {0}"
msgstr "メンバー — {0}"
+#. placeholder {0}: selectedChannel.name
+#: src/components/layout/ChatArea.tsx
+msgid "Message {0}"
+msgstr ""
+
+#. placeholder {0}: selectedChannel.name
+#: src/components/layout/ChatArea.tsx
+msgid "Message {0} (Enter for new line, Shift+Enter to send)"
+msgstr ""
+
+#. placeholder {0}: selectedChannel.name
+#: src/components/layout/ChatArea.tsx
+msgid "Message {0} (Shift+Enter for new line)"
+msgstr ""
+
#. placeholder {0}: selectedPrivateChat.username
#: src/components/layout/ChatArea.tsx
msgid "Message @{0}"
@@ -1399,21 +1414,6 @@ msgstr "@{0} へメッセージ(Enterで改行、Shift+Enterで送信)"
msgid "Message @{0} (Shift+Enter for new line)"
msgstr "@{0} へメッセージ(Shift+Enterで改行)"
-#. placeholder {0}: selectedChannel.name.replace(/^#/, "")
-#: src/components/layout/ChatArea.tsx
-msgid "Message #{0}"
-msgstr "#{0} へメッセージ"
-
-#. placeholder {0}: selectedChannel.name.replace(/^#/, "")
-#: src/components/layout/ChatArea.tsx
-msgid "Message #{0} (Enter for new line, Shift+Enter to send)"
-msgstr "#{0} へメッセージ(Enterで改行、Shift+Enterで送信)"
-
-#. placeholder {0}: selectedChannel.name.replace(/^#/, "")
-#: src/components/layout/ChatArea.tsx
-msgid "Message #{0} (Shift+Enter for new line)"
-msgstr "#{0} へメッセージ(Shift+Enterで改行)"
-
#. placeholder {0}: searchTerm.trim()
#: src/components/ui/AddPrivateChatModal.tsx
msgid "Message <0>{0}0>"
diff --git a/src/locales/ko/messages.mjs b/src/locales/ko/messages.mjs
index 438d402e..e2ca16a5 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\":[\"채널 외부 사용자는 메시지를 보낼 수 없습니다\"],\"/4C8U0\":[\"Copy all\"],\"/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\":[\"표시 이름 입력\"],\"2F9+AZ\":[\"No raw IRC traffic captured yet. Try connecting or sending a message.\"],\"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\":[\"규칙 제거\"],\"8o3dPc\":[\"업로드할 파일을 여기에 놓으세요\"],\"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>위험: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\"],\"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>⚠️ 보안 위험!0> 이 연결은 도청 또는 중간자 공격에 취약할 수 있습니다.\"],\"da9Q/R\":[\"채널 모드 변경됨\"],\"dhJN3N\":[\"댓글 보기\"],\"dj2xTE\":[\"알림 닫기\"],\"dpCzmC\":[\"플러드 방지 설정\"],\"e9dQpT\":[\"이 링크를 새 탭에서 여시겠습니까?\"],\"ePK91l\":[\"편집\"],\"eYBDuB\":[\"이미지를 업로드하거나 동적 크기 조정을 위해 \",[\"size\"],\" 대체가 있는 URL을 제공하세요\"],\"edBbee\":[\"호스트마스크로 \",[\"username\"],\" 차단 (동일 IP/호스트에서 재입장 방지)\"],\"ekfzWq\":[\"사용자 설정\"],\"elPDWs\":[\"IRC 클라이언트 환경을 맞춤 설정하세요\"],\"eu2osY\":[\"<0>💡 권장사항: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>⚠️ 주의하세요!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\"],\"0>에 대한 연결에 다음과 같은 보안 문제가 있습니다:\"],\"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\":[\"채널 외부 사용자는 메시지를 보낼 수 없습니다\"],\"/4C8U0\":[\"Copy all\"],\"/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\":[\"표시 이름 입력\"],\"2F9+AZ\":[\"No raw IRC traffic captured yet. Try connecting or sending a message.\"],\"2FOFq1\":[\"네트워크 서버 운영자가 메시지를 읽을 수 있습니다\"],\"2FYpfJ\":[\"더 보기\"],\"2HF1Y2\":[[\"inviter\"],\"이(가) \",[\"target\"],\"을(를) \",[\"channel\"],\"에 초대했습니다\"],\"2I70QL\":[\"사용자 프로필 정보 보기\"],\"2QYdmE\":[\"사용자:\"],\"2QpEjG\":[\"퇴장했습니다\"],\"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\":[\"규칙 제거\"],\"8o3dPc\":[\"업로드할 파일을 여기에 놓으세요\"],\"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>위험: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\"],\"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>⚠️ 보안 위험!0> 이 연결은 도청 또는 중간자 공격에 취약할 수 있습니다.\"],\"da9Q/R\":[\"채널 모드 변경됨\"],\"dhJN3N\":[\"댓글 보기\"],\"dj2xTE\":[\"알림 닫기\"],\"dpCzmC\":[\"플러드 방지 설정\"],\"e9dQpT\":[\"이 링크를 새 탭에서 여시겠습니까?\"],\"ePK91l\":[\"편집\"],\"eYBDuB\":[\"이미지를 업로드하거나 동적 크기 조정을 위해 \",[\"size\"],\" 대체가 있는 URL을 제공하세요\"],\"edBbee\":[\"호스트마스크로 \",[\"username\"],\" 차단 (동일 IP/호스트에서 재입장 방지)\"],\"ekfzWq\":[\"사용자 설정\"],\"elPDWs\":[\"IRC 클라이언트 환경을 맞춤 설정하세요\"],\"eu2osY\":[\"<0>💡 권장사항: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>⚠️ 주의하세요!0> 신뢰할 수 있는 출처의 링크만 여세요. 악성 링크는 보안이나 개인정보를 침해할 수 있습니다.\"],\"mH+wEJ\":[\"Message \",[\"0\"],\" (Enter for new line, Shift+Enter to send)\"],\"mHGdhG\":[\"서버 정보\"],\"mMYBD9\":[\"광역 - 더 넓은 보호 범위\"],\"mTGsPd\":[\"채널 주제\"],\"mU8j6O\":[\"외부 메시지 차단 (+n)\"],\"mZp8FL\":[\"자동으로 한 줄 모드로 전환\"],\"mdQu8G\":[\"YourNickname\"],\"miSSBQ\":[\"댓글 (\",[\"commentCount\"],\")\"],\"mvyLSy\":[\"인증된 사용자\"],\"mwtcGl\":[\"댓글 닫기\"],\"mzI/c+\":[\"다운로드\"],\"n3fGRk\":[[\"0\"],\"이(가) 설정\"],\"nE9jsU\":[\"완화 - 덜 공격적인 보호\"],\"nNflMD\":[\"채널 나가기\"],\"nPXkBi\":[\"WHOIS 데이터 불러오는 중...\"],\"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\":[\"추가 정보\"],\"pqr+oY\":[\"Message \",[\"0\"]],\"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\"],\"0>에 대한 연결에 다음과 같은 보안 문제가 있습니다:\"],\"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\":[\"닫기\"],\"z0DY9w\":[\"Message \",[\"0\"],\" (Shift+Enter for new line)\"],\"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 3d822033..00244143 100644
--- a/src/locales/ko/messages.po
+++ b/src/locales/ko/messages.po
@@ -23,8 +23,8 @@ msgid "— open in viewer"
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 ( <>
> ); }, ); 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 ( <>
> ); }, ); 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 {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 { buildMarkdownFromSelection } from "../../lib/chatMarkdownCopy"; 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 ( <>
> ); }, ); 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 { buildMarkdownFromSelection } from "../../lib/chatMarkdownCopy"; 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 ( <>
> ); }, ); 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);
#: src/components/layout/ChannelMessageList.tsx
msgid "{0, plural, one {{1}} other {{2}}}"
msgstr "{0, plural, one {{1}} other {{2}}}"
@@ -1384,6 +1384,21 @@ msgstr "미디어 미리보기"
msgid "Members — {0}"
msgstr "멤버 — {0}명"
+#. placeholder {0}: selectedChannel.name
+#: src/components/layout/ChatArea.tsx
+msgid "Message {0}"
+msgstr ""
+
+#. placeholder {0}: selectedChannel.name
+#: src/components/layout/ChatArea.tsx
+msgid "Message {0} (Enter for new line, Shift+Enter to send)"
+msgstr ""
+
+#. placeholder {0}: selectedChannel.name
+#: src/components/layout/ChatArea.tsx
+msgid "Message {0} (Shift+Enter for new line)"
+msgstr ""
+
#. placeholder {0}: selectedPrivateChat.username
#: src/components/layout/ChatArea.tsx
msgid "Message @{0}"
@@ -1399,21 +1414,6 @@ msgstr "@{0}에게 메시지 (Enter로 줄 바꿈, Shift+Enter로 전송)"
msgid "Message @{0} (Shift+Enter for new line)"
msgstr "@{0}에게 메시지 (Shift+Enter로 줄 바꿈)"
-#. placeholder {0}: selectedChannel.name.replace(/^#/, "")
-#: src/components/layout/ChatArea.tsx
-msgid "Message #{0}"
-msgstr "#{0}에 메시지 보내기"
-
-#. placeholder {0}: selectedChannel.name.replace(/^#/, "")
-#: src/components/layout/ChatArea.tsx
-msgid "Message #{0} (Enter for new line, Shift+Enter to send)"
-msgstr "#{0}에 메시지 (Enter로 줄 바꿈, Shift+Enter로 전송)"
-
-#. placeholder {0}: selectedChannel.name.replace(/^#/, "")
-#: src/components/layout/ChatArea.tsx
-msgid "Message #{0} (Shift+Enter for new line)"
-msgstr "#{0}에 메시지 (Shift+Enter로 줄 바꿈)"
-
#. placeholder {0}: searchTerm.trim()
#: src/components/ui/AddPrivateChatModal.tsx
msgid "Message <0>{0}0>"
diff --git a/src/locales/nl/messages.mjs b/src/locales/nl/messages.mjs
index de2ced3f..c9774bb0 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\"],\"/4C8U0\":[\"Copy all\"],\"/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\"],\"2F9+AZ\":[\"No raw IRC traffic captured yet. Try connecting or sending a message.\"],\"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\"],\"8o3dPc\":[\"Sleep bestanden hierheen om te uploaden\"],\"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:0> 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\"],\"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!0> 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:0> 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!0> 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\"],\"0> 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\"],\"/4C8U0\":[\"Copy all\"],\"/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\"],\"2F9+AZ\":[\"No raw IRC traffic captured yet. Try connecting or sending a message.\"],\"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\"],\"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\"],\"8o3dPc\":[\"Sleep bestanden hierheen om te uploaden\"],\"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:0> 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\"],\"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!0> 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:0> 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!0> Open alleen links van vertrouwde bronnen. Kwaadaardige links kunnen je beveiliging of privacy in gevaar brengen.\"],\"mH+wEJ\":[\"Message \",[\"0\"],\" (Enter for new line, Shift+Enter to send)\"],\"mHGdhG\":[\"Serverinformatie\"],\"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...\"],\"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\"],\"pqr+oY\":[\"Message \",[\"0\"]],\"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\"],\"0> 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\"],\"z0DY9w\":[\"Message \",[\"0\"],\" (Shift+Enter for new line)\"],\"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 2beae22f..3beb5ad5 100644
--- a/src/locales/nl/messages.po
+++ b/src/locales/nl/messages.po
@@ -23,8 +23,8 @@ msgid "— open in viewer"
msgstr "— openen in viewer"
#. 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 ( <>
> ); }, ); 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 ( <>
> ); }, ); 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 {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 { buildMarkdownFromSelection } from "../../lib/chatMarkdownCopy"; 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 ( <>
> ); }, ); 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 { buildMarkdownFromSelection } from "../../lib/chatMarkdownCopy"; 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 ( <>
> ); }, ); 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);
#: src/components/layout/ChannelMessageList.tsx
msgid "{0, plural, one {{1}} other {{2}}}"
msgstr "{0, plural, one {{1}} other {{2}}}"
@@ -1384,6 +1384,21 @@ msgstr "Mediavoorbeeldweergaven"
msgid "Members — {0}"
msgstr "Leden — {0}"
+#. placeholder {0}: selectedChannel.name
+#: src/components/layout/ChatArea.tsx
+msgid "Message {0}"
+msgstr ""
+
+#. placeholder {0}: selectedChannel.name
+#: src/components/layout/ChatArea.tsx
+msgid "Message {0} (Enter for new line, Shift+Enter to send)"
+msgstr ""
+
+#. placeholder {0}: selectedChannel.name
+#: src/components/layout/ChatArea.tsx
+msgid "Message {0} (Shift+Enter for new line)"
+msgstr ""
+
#. placeholder {0}: selectedPrivateChat.username
#: src/components/layout/ChatArea.tsx
msgid "Message @{0}"
@@ -1399,21 +1414,6 @@ msgstr "Bericht aan @{0} (Enter voor nieuwe regel, Shift+Enter om te verzenden)"
msgid "Message @{0} (Shift+Enter for new line)"
msgstr "Bericht aan @{0} (Shift+Enter voor nieuwe regel)"
-#. placeholder {0}: selectedChannel.name.replace(/^#/, "")
-#: src/components/layout/ChatArea.tsx
-msgid "Message #{0}"
-msgstr "Bericht in #{0}"
-
-#. placeholder {0}: selectedChannel.name.replace(/^#/, "")
-#: src/components/layout/ChatArea.tsx
-msgid "Message #{0} (Enter for new line, Shift+Enter to send)"
-msgstr "Bericht in #{0} (Enter voor nieuwe regel, Shift+Enter om te verzenden)"
-
-#. placeholder {0}: selectedChannel.name.replace(/^#/, "")
-#: src/components/layout/ChatArea.tsx
-msgid "Message #{0} (Shift+Enter for new line)"
-msgstr "Bericht in #{0} (Shift+Enter voor nieuwe regel)"
-
#. placeholder {0}: searchTerm.trim()
#: src/components/ui/AddPrivateChatModal.tsx
msgid "Message <0>{0}0>"
diff --git a/src/locales/pl/messages.mjs b/src/locales/pl/messages.mjs
index 95b5dbab..6795db7b 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\"],\"/4C8U0\":[\"Copy all\"],\"/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ę\"],\"2F9+AZ\":[\"No raw IRC traffic captured yet. Try connecting or sending a message.\"],\"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łę\"],\"8o3dPc\":[\"Upuść pliki, aby je przesłać\"],\"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:0> 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\"],\"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!0> 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:0> 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!0> 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\"],\"0> 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\"],\"/4C8U0\":[\"Copy all\"],\"/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ę\"],\"2F9+AZ\":[\"No raw IRC traffic captured yet. Try connecting or sending a message.\"],\"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ł\"],\"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łę\"],\"8o3dPc\":[\"Upuść pliki, aby je przesłać\"],\"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:0> 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\"],\"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!0> 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:0> 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!0> Otwieraj tylko linki z zaufanych źródeł. Złośliwe linki mogą narazić Twoje bezpieczeństwo lub prywatność.\"],\"mH+wEJ\":[\"Message \",[\"0\"],\" (Enter for new line, Shift+Enter to send)\"],\"mHGdhG\":[\"Informacje o serwerze\"],\"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...\"],\"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\"],\"pqr+oY\":[\"Message \",[\"0\"]],\"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\"],\"0> 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\"],\"z0DY9w\":[\"Message \",[\"0\"],\" (Shift+Enter for new line)\"],\"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 953c4989..89ba8fe6 100644
--- a/src/locales/pl/messages.po
+++ b/src/locales/pl/messages.po
@@ -23,8 +23,8 @@ msgid "— open in viewer"
msgstr "— otwórz w przeglądarce"
#. 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 ( <>
> ); }, ); 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 ( <>
> ); }, ); 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 {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 { buildMarkdownFromSelection } from "../../lib/chatMarkdownCopy"; 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 ( <>
> ); }, ); 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 { buildMarkdownFromSelection } from "../../lib/chatMarkdownCopy"; 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 ( <>
> ); }, ); 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);
#: src/components/layout/ChannelMessageList.tsx
msgid "{0, plural, one {{1}} other {{2}}}"
msgstr "{0, plural, one {{1}} other {{2}}}"
@@ -1384,6 +1384,21 @@ msgstr "Podglądy mediów"
msgid "Members — {0}"
msgstr "Członkowie — {0}"
+#. placeholder {0}: selectedChannel.name
+#: src/components/layout/ChatArea.tsx
+msgid "Message {0}"
+msgstr ""
+
+#. placeholder {0}: selectedChannel.name
+#: src/components/layout/ChatArea.tsx
+msgid "Message {0} (Enter for new line, Shift+Enter to send)"
+msgstr ""
+
+#. placeholder {0}: selectedChannel.name
+#: src/components/layout/ChatArea.tsx
+msgid "Message {0} (Shift+Enter for new line)"
+msgstr ""
+
#. placeholder {0}: selectedPrivateChat.username
#: src/components/layout/ChatArea.tsx
msgid "Message @{0}"
@@ -1399,21 +1414,6 @@ msgstr "Wiadomość do @{0} (Enter – nowa linia, Shift+Enter – wyślij)"
msgid "Message @{0} (Shift+Enter for new line)"
msgstr "Wiadomość do @{0} (Shift+Enter – nowa linia)"
-#. placeholder {0}: selectedChannel.name.replace(/^#/, "")
-#: src/components/layout/ChatArea.tsx
-msgid "Message #{0}"
-msgstr "Wiadomość na #{0}"
-
-#. placeholder {0}: selectedChannel.name.replace(/^#/, "")
-#: src/components/layout/ChatArea.tsx
-msgid "Message #{0} (Enter for new line, Shift+Enter to send)"
-msgstr "Wiadomość na #{0} (Enter – nowa linia, Shift+Enter – wyślij)"
-
-#. placeholder {0}: selectedChannel.name.replace(/^#/, "")
-#: src/components/layout/ChatArea.tsx
-msgid "Message #{0} (Shift+Enter for new line)"
-msgstr "Wiadomość na #{0} (Shift+Enter – nowa linia)"
-
#. placeholder {0}: searchTerm.trim()
#: src/components/ui/AddPrivateChatModal.tsx
msgid "Message <0>{0}0>"
diff --git a/src/locales/pt/messages.mjs b/src/locales/pt/messages.mjs
index f05509d3..cc4fc85f 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\"],\"/4C8U0\":[\"Copy all\"],\"/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\"],\"2F9+AZ\":[\"No raw IRC traffic captured yet. Try connecting or sending a message.\"],\"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\"],\"8o3dPc\":[\"Solte os arquivos para enviar\"],\"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:0> 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\"],\"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!0> 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:0> 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!0> 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\"],\"0> 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\"],\"/4C8U0\":[\"Copy all\"],\"/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\"],\"2F9+AZ\":[\"No raw IRC traffic captured yet. Try connecting or sending a message.\"],\"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\"],\"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\"],\"8o3dPc\":[\"Solte os arquivos para enviar\"],\"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:0> 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\"],\"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!0> 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:0> 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!0> Abra apenas links de fontes confiáveis. Links maliciosos podem comprometer sua segurança ou privacidade.\"],\"mH+wEJ\":[\"Mensagem \",[\"0\"],\" (Enter para nova linha, Shift+Enter para enviar)\"],\"mHGdhG\":[\"Informações do servidor\"],\"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...\"],\"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\"],\"pqr+oY\":[\"Mensagem \",[\"0\"]],\"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\"],\"0> 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\"],\"z0DY9w\":[\"Mensagem \",[\"0\"],\" (Shift+Enter para nova linha)\"],\"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 b948b145..007a043f 100644
--- a/src/locales/pt/messages.po
+++ b/src/locales/pt/messages.po
@@ -23,8 +23,8 @@ msgid "— open in viewer"
msgstr "— abrir no visualizador"
#. 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 ( <>
> ); }, ); 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 ( <>
> ); }, ); 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 {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 { buildMarkdownFromSelection } from "../../lib/chatMarkdownCopy"; 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 ( <>
> ); }, ); 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 { buildMarkdownFromSelection } from "../../lib/chatMarkdownCopy"; 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 ( <>
> ); }, ); 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);
#: src/components/layout/ChannelMessageList.tsx
msgid "{0, plural, one {{1}} other {{2}}}"
msgstr "{0, plural, one {{1}} other {{2}}}"
@@ -1384,6 +1384,21 @@ msgstr "Pré-visualizações de mídia"
msgid "Members — {0}"
msgstr "Membros — {0}"
+#. placeholder {0}: selectedChannel.name
+#: src/components/layout/ChatArea.tsx
+msgid "Message {0}"
+msgstr "Mensagem {0}"
+
+#. placeholder {0}: selectedChannel.name
+#: src/components/layout/ChatArea.tsx
+msgid "Message {0} (Enter for new line, Shift+Enter to send)"
+msgstr "Mensagem {0} (Enter para nova linha, Shift+Enter para enviar)"
+
+#. placeholder {0}: selectedChannel.name
+#: src/components/layout/ChatArea.tsx
+msgid "Message {0} (Shift+Enter for new line)"
+msgstr "Mensagem {0} (Shift+Enter para nova linha)"
+
#. placeholder {0}: selectedPrivateChat.username
#: src/components/layout/ChatArea.tsx
msgid "Message @{0}"
@@ -1399,21 +1414,6 @@ msgstr "Mensagem @{0} (Enter para nova linha, Shift+Enter para enviar)"
msgid "Message @{0} (Shift+Enter for new line)"
msgstr "Mensagem @{0} (Shift+Enter para nova linha)"
-#. placeholder {0}: selectedChannel.name.replace(/^#/, "")
-#: src/components/layout/ChatArea.tsx
-msgid "Message #{0}"
-msgstr "Mensagem #{0}"
-
-#. placeholder {0}: selectedChannel.name.replace(/^#/, "")
-#: src/components/layout/ChatArea.tsx
-msgid "Message #{0} (Enter for new line, Shift+Enter to send)"
-msgstr "Mensagem #{0} (Enter para nova linha, Shift+Enter para enviar)"
-
-#. placeholder {0}: selectedChannel.name.replace(/^#/, "")
-#: src/components/layout/ChatArea.tsx
-msgid "Message #{0} (Shift+Enter for new line)"
-msgstr "Mensagem #{0} (Shift+Enter para nova linha)"
-
#. placeholder {0}: searchTerm.trim()
#: src/components/ui/AddPrivateChatModal.tsx
msgid "Message <0>{0}0>"
diff --git a/src/locales/ro/messages.mjs b/src/locales/ro/messages.mjs
index 4f8cb7d3..926ceac9 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\"],\"/4C8U0\":[\"Copy all\"],\"/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\"],\"2F9+AZ\":[\"No raw IRC traffic captured yet. Try connecting or sending a message.\"],\"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\"],\"8o3dPc\":[\"Plasați fișierele pentru a le încărca\"],\"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:0> 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\"],\"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!0> 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:0> 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!0> 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\"],\"0> 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\"],\"/4C8U0\":[\"Copy all\"],\"/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\"],\"2F9+AZ\":[\"No raw IRC traffic captured yet. Try connecting or sending a message.\"],\"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\"],\"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\"],\"8o3dPc\":[\"Plasați fișierele pentru a le încărca\"],\"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:0> 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\"],\"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!0> 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:0> 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!0> Deschide numai linkuri din surse de încredere. Linkurile malițioase îți pot compromite securitatea sau confidențialitatea.\"],\"mH+wEJ\":[\"Message \",[\"0\"],\" (Enter for new line, Shift+Enter to send)\"],\"mHGdhG\":[\"Informații server\"],\"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...\"],\"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\"],\"pqr+oY\":[\"Message \",[\"0\"]],\"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\"],\"0> 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\"],\"z0DY9w\":[\"Message \",[\"0\"],\" (Shift+Enter for new line)\"],\"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 5311937c..c4fda5a7 100644
--- a/src/locales/ro/messages.po
+++ b/src/locales/ro/messages.po
@@ -23,8 +23,8 @@ msgid "— open in viewer"
msgstr "— deschide în vizualizator"
#. 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 ( <>
> ); }, ); 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 ( <>
> ); }, ); 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 {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 { buildMarkdownFromSelection } from "../../lib/chatMarkdownCopy"; 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 ( <>
> ); }, ); 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 { buildMarkdownFromSelection } from "../../lib/chatMarkdownCopy"; 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 ( <>
> ); }, ); 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);
#: src/components/layout/ChannelMessageList.tsx
msgid "{0, plural, one {{1}} other {{2}}}"
msgstr "{0, plural, one {{1}} other {{2}}}"
@@ -1384,6 +1384,21 @@ msgstr "Previzualizări media"
msgid "Members — {0}"
msgstr "Membri — {0}"
+#. placeholder {0}: selectedChannel.name
+#: src/components/layout/ChatArea.tsx
+msgid "Message {0}"
+msgstr ""
+
+#. placeholder {0}: selectedChannel.name
+#: src/components/layout/ChatArea.tsx
+msgid "Message {0} (Enter for new line, Shift+Enter to send)"
+msgstr ""
+
+#. placeholder {0}: selectedChannel.name
+#: src/components/layout/ChatArea.tsx
+msgid "Message {0} (Shift+Enter for new line)"
+msgstr ""
+
#. placeholder {0}: selectedPrivateChat.username
#: src/components/layout/ChatArea.tsx
msgid "Message @{0}"
@@ -1399,21 +1414,6 @@ msgstr "Mesaj @{0} (Enter pentru linie nouă, Shift+Enter pentru trimitere)"
msgid "Message @{0} (Shift+Enter for new line)"
msgstr "Mesaj @{0} (Shift+Enter pentru linie nouă)"
-#. placeholder {0}: selectedChannel.name.replace(/^#/, "")
-#: src/components/layout/ChatArea.tsx
-msgid "Message #{0}"
-msgstr "Mesaj #{0}"
-
-#. placeholder {0}: selectedChannel.name.replace(/^#/, "")
-#: src/components/layout/ChatArea.tsx
-msgid "Message #{0} (Enter for new line, Shift+Enter to send)"
-msgstr "Mesaj #{0} (Enter pentru linie nouă, Shift+Enter pentru trimitere)"
-
-#. placeholder {0}: selectedChannel.name.replace(/^#/, "")
-#: src/components/layout/ChatArea.tsx
-msgid "Message #{0} (Shift+Enter for new line)"
-msgstr "Mesaj #{0} (Shift+Enter pentru linie nouă)"
-
#. placeholder {0}: searchTerm.trim()
#: src/components/ui/AddPrivateChatModal.tsx
msgid "Message <0>{0}0>"
diff --git a/src/locales/ru/messages.mjs b/src/locales/ru/messages.mjs
index f95becea..ebfba166 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\":[\"Пользователи вне канала не могут отправлять в него сообщения\"],\"/4C8U0\":[\"Copy all\"],\"/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\":[\"Введите отображаемое имя\"],\"2F9+AZ\":[\"No raw IRC traffic captured yet. Try connecting or sending a message.\"],\"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\":[\"Удалить правило\"],\"8o3dPc\":[\"Перетащите файлы для загрузки\"],\"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>Риск: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\"],\"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>⚠️ Угроза безопасности!0> Это соединение может быть уязвимо для перехвата или атак типа «человек посередине».\"],\"da9Q/R\":[\"Изменил режимы канала\"],\"dhJN3N\":[\"Показать комментарии\"],\"dj2xTE\":[\"Закрыть уведомление\"],\"dpCzmC\":[\"Настройки защиты от флуда\"],\"e9dQpT\":[\"Открыть эту ссылку в новой вкладке?\"],\"ePK91l\":[\"Изменить\"],\"eYBDuB\":[\"Загрузите изображение или укажите URL с необязательной подстановкой \",[\"size\"],\" для динамического масштабирования\"],\"edBbee\":[\"Забанить \",[\"username\"],\" по hostmask (запрещает переподключение с того же IP/хоста)\"],\"ekfzWq\":[\"Настройки пользователя\"],\"elPDWs\":[\"Настройте IRC-клиент под себя\"],\"eu2osY\":[\"<0>💡 Рекомендация: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>⚠️ Будьте осторожны!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\"],\"0> имеет следующие проблемы безопасности:\"],\"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\":[\"Пользователи вне канала не могут отправлять в него сообщения\"],\"/4C8U0\":[\"Copy all\"],\"/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\":[\"Введите отображаемое имя\"],\"2F9+AZ\":[\"No raw IRC traffic captured yet. Try connecting or sending a message.\"],\"2FOFq1\":[\"Операторы серверов сети потенциально могут читать ваши сообщения\"],\"2FYpfJ\":[\"Ещё\"],\"2HF1Y2\":[[\"inviter\"],\" пригласил \",[\"target\"],\" присоединиться к \",[\"channel\"]],\"2I70QL\":[\"Просмотреть информацию профиля пользователя\"],\"2QYdmE\":[\"Пользователи:\"],\"2QpEjG\":[\"вышел\"],\"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\":[\"Удалить правило\"],\"8o3dPc\":[\"Перетащите файлы для загрузки\"],\"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>Риск: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\"],\"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>⚠️ Угроза безопасности!0> Это соединение может быть уязвимо для перехвата или атак типа «человек посередине».\"],\"da9Q/R\":[\"Изменил режимы канала\"],\"dhJN3N\":[\"Показать комментарии\"],\"dj2xTE\":[\"Закрыть уведомление\"],\"dpCzmC\":[\"Настройки защиты от флуда\"],\"e9dQpT\":[\"Открыть эту ссылку в новой вкладке?\"],\"ePK91l\":[\"Изменить\"],\"eYBDuB\":[\"Загрузите изображение или укажите URL с необязательной подстановкой \",[\"size\"],\" для динамического масштабирования\"],\"edBbee\":[\"Забанить \",[\"username\"],\" по hostmask (запрещает переподключение с того же IP/хоста)\"],\"ekfzWq\":[\"Настройки пользователя\"],\"elPDWs\":[\"Настройте IRC-клиент под себя\"],\"eu2osY\":[\"<0>💡 Рекомендация: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>⚠️ Будьте осторожны!0> Открывайте ссылки только из доверенных источников. Вредоносные ссылки могут угрожать вашей безопасности или конфиденциальности.\"],\"mH+wEJ\":[\"Message \",[\"0\"],\" (Enter for new line, Shift+Enter to send)\"],\"mHGdhG\":[\"Информация о сервере\"],\"mMYBD9\":[\"Широкий — более широкая область защиты\"],\"mTGsPd\":[\"Тема канала\"],\"mU8j6O\":[\"Без внешних сообщений (+n)\"],\"mZp8FL\":[\"Автоматический возврат к однострочному режиму\"],\"mdQu8G\":[\"YourNickname\"],\"miSSBQ\":[\"Комментарии (\",[\"commentCount\"],\")\"],\"mvyLSy\":[\"Пользователь аутентифицирован\"],\"mwtcGl\":[\"Закрыть комментарии\"],\"mzI/c+\":[\"Скачать\"],\"n3fGRk\":[\"установил \",[\"0\"]],\"nE9jsU\":[\"Мягкий — менее строгая защита\"],\"nNflMD\":[\"Покинуть канал\"],\"nPXkBi\":[\"Загрузка данных WHOIS...\"],\"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\":[\"Дополнительная информация\"],\"pqr+oY\":[\"Message \",[\"0\"]],\"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\"],\"0> имеет следующие проблемы безопасности:\"],\"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\":[\"Закрыть\"],\"z0DY9w\":[\"Message \",[\"0\"],\" (Shift+Enter for new line)\"],\"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 816e61e0..ecf2e6b4 100644
--- a/src/locales/ru/messages.po
+++ b/src/locales/ru/messages.po
@@ -23,8 +23,8 @@ msgid "— open in viewer"
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 ( <>
> ); }, ); 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 ( <>
> ); }, ); 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 {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 { buildMarkdownFromSelection } from "../../lib/chatMarkdownCopy"; 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 ( <>
> ); }, ); 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 { buildMarkdownFromSelection } from "../../lib/chatMarkdownCopy"; 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 ( <>
> ); }, ); 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);
#: src/components/layout/ChannelMessageList.tsx
msgid "{0, plural, one {{1}} other {{2}}}"
msgstr "{0, plural, one {{1}} other {{2}}}"
@@ -1384,6 +1384,21 @@ msgstr "Предпросмотр медиа"
msgid "Members — {0}"
msgstr "Участники — {0}"
+#. placeholder {0}: selectedChannel.name
+#: src/components/layout/ChatArea.tsx
+msgid "Message {0}"
+msgstr ""
+
+#. placeholder {0}: selectedChannel.name
+#: src/components/layout/ChatArea.tsx
+msgid "Message {0} (Enter for new line, Shift+Enter to send)"
+msgstr ""
+
+#. placeholder {0}: selectedChannel.name
+#: src/components/layout/ChatArea.tsx
+msgid "Message {0} (Shift+Enter for new line)"
+msgstr ""
+
#. placeholder {0}: selectedPrivateChat.username
#: src/components/layout/ChatArea.tsx
msgid "Message @{0}"
@@ -1399,21 +1414,6 @@ msgstr "Сообщение @{0} (Enter — новая строка, Shift+Enter
msgid "Message @{0} (Shift+Enter for new line)"
msgstr "Сообщение @{0} (Shift+Enter — новая строка)"
-#. placeholder {0}: selectedChannel.name.replace(/^#/, "")
-#: src/components/layout/ChatArea.tsx
-msgid "Message #{0}"
-msgstr "Сообщение #{0}"
-
-#. placeholder {0}: selectedChannel.name.replace(/^#/, "")
-#: src/components/layout/ChatArea.tsx
-msgid "Message #{0} (Enter for new line, Shift+Enter to send)"
-msgstr "Сообщение #{0} (Enter — новая строка, Shift+Enter — отправить)"
-
-#. placeholder {0}: selectedChannel.name.replace(/^#/, "")
-#: src/components/layout/ChatArea.tsx
-msgid "Message #{0} (Shift+Enter for new line)"
-msgstr "Сообщение #{0} (Shift+Enter — новая строка)"
-
#. placeholder {0}: searchTerm.trim()
#: src/components/ui/AddPrivateChatModal.tsx
msgid "Message <0>{0}0>"
diff --git a/src/locales/sv/messages.mjs b/src/locales/sv/messages.mjs
index c12bbabe..eee1cbbb 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\"],\"/4C8U0\":[\"Copy all\"],\"/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\"],\"2F9+AZ\":[\"No raw IRC traffic captured yet. Try connecting or sending a message.\"],\"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\"],\"8o3dPc\":[\"Släpp filer för att ladda upp\"],\"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:0> 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\"],\"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!0> 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:0> 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!0> Ö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\"],\"0> 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\"],\"/4C8U0\":[\"Copy all\"],\"/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\"],\"2F9+AZ\":[\"No raw IRC traffic captured yet. Try connecting or sending a message.\"],\"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\"],\"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\"],\"8o3dPc\":[\"Släpp filer för att ladda upp\"],\"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:0> 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\"],\"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!0> 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:0> 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!0> Öppna bara länkar från betrodda källor. Skadliga länkar kan äventyra din säkerhet eller integritet.\"],\"mH+wEJ\":[\"Message \",[\"0\"],\" (Enter for new line, Shift+Enter to send)\"],\"mHGdhG\":[\"Serverinformation\"],\"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...\"],\"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\"],\"pqr+oY\":[\"Message \",[\"0\"]],\"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\"],\"0> 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\"],\"z0DY9w\":[\"Message \",[\"0\"],\" (Shift+Enter for new line)\"],\"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 a93def66..6a5c2dba 100644
--- a/src/locales/sv/messages.po
+++ b/src/locales/sv/messages.po
@@ -23,8 +23,8 @@ msgid "— open in viewer"
msgstr "— öppna i visaren"
#. 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 ( <>
> ); }, ); 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 ( <>
> ); }, ); 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 {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 { buildMarkdownFromSelection } from "../../lib/chatMarkdownCopy"; 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 ( <>
> ); }, ); 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 { buildMarkdownFromSelection } from "../../lib/chatMarkdownCopy"; 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 ( <>
> ); }, ); 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);
#: src/components/layout/ChannelMessageList.tsx
msgid "{0, plural, one {{1}} other {{2}}}"
msgstr "{0, plural, one {{1}} other {{2}}}"
@@ -1384,6 +1384,21 @@ msgstr "Medieförhandsvisningar"
msgid "Members — {0}"
msgstr "Medlemmar — {0}"
+#. placeholder {0}: selectedChannel.name
+#: src/components/layout/ChatArea.tsx
+msgid "Message {0}"
+msgstr ""
+
+#. placeholder {0}: selectedChannel.name
+#: src/components/layout/ChatArea.tsx
+msgid "Message {0} (Enter for new line, Shift+Enter to send)"
+msgstr ""
+
+#. placeholder {0}: selectedChannel.name
+#: src/components/layout/ChatArea.tsx
+msgid "Message {0} (Shift+Enter for new line)"
+msgstr ""
+
#. placeholder {0}: selectedPrivateChat.username
#: src/components/layout/ChatArea.tsx
msgid "Message @{0}"
@@ -1399,21 +1414,6 @@ msgstr "Meddelande @{0} (Enter för ny rad, Shift+Enter för att skicka)"
msgid "Message @{0} (Shift+Enter for new line)"
msgstr "Meddelande @{0} (Shift+Enter för ny rad)"
-#. placeholder {0}: selectedChannel.name.replace(/^#/, "")
-#: src/components/layout/ChatArea.tsx
-msgid "Message #{0}"
-msgstr "Meddelande #{0}"
-
-#. placeholder {0}: selectedChannel.name.replace(/^#/, "")
-#: src/components/layout/ChatArea.tsx
-msgid "Message #{0} (Enter for new line, Shift+Enter to send)"
-msgstr "Meddelande #{0} (Enter för ny rad, Shift+Enter för att skicka)"
-
-#. placeholder {0}: selectedChannel.name.replace(/^#/, "")
-#: src/components/layout/ChatArea.tsx
-msgid "Message #{0} (Shift+Enter for new line)"
-msgstr "Meddelande #{0} (Shift+Enter för ny rad)"
-
#. placeholder {0}: searchTerm.trim()
#: src/components/ui/AddPrivateChatModal.tsx
msgid "Message <0>{0}0>"
diff --git a/src/locales/tr/messages.mjs b/src/locales/tr/messages.mjs
index 713baab0..142a341e 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\"],\"/4C8U0\":[\"Copy all\"],\"/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\"],\"2F9+AZ\":[\"No raw IRC traffic captured yet. Try connecting or sending a message.\"],\"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\"],\"8o3dPc\":[\"Yüklemek için dosyaları buraya bırakın\"],\"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:0> 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\"],\"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!0> 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:0> 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!0> 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\"],\"0> 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\"],\"/4C8U0\":[\"Copy all\"],\"/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\"],\"2F9+AZ\":[\"No raw IRC traffic captured yet. Try connecting or sending a message.\"],\"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ı\"],\"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\"],\"8o3dPc\":[\"Yüklemek için dosyaları buraya bırakın\"],\"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:0> 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\"],\"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!0> 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:0> 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!0> 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.\"],\"mH+wEJ\":[\"Message \",[\"0\"],\" (Enter for new line, Shift+Enter to send)\"],\"mHGdhG\":[\"Sunucu bilgisi\"],\"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...\"],\"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\"],\"pqr+oY\":[\"Message \",[\"0\"]],\"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\"],\"0> 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\"],\"z0DY9w\":[\"Message \",[\"0\"],\" (Shift+Enter for new line)\"],\"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 117e3162..ceed9448 100644
--- a/src/locales/tr/messages.po
+++ b/src/locales/tr/messages.po
@@ -23,8 +23,8 @@ msgid "— open in viewer"
msgstr "— görüntüleyicide aç"
#. 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 ( <>