From 78e095129e237d840bbf52e21f062ed5c6f3de1b Mon Sep 17 00:00:00 2001 From: Matheus Fillipe Date: Tue, 24 Feb 2026 18:38:25 +0100 Subject: [PATCH 1/2] Support TCP servers in native app discover page --- src/App.tsx | 3 ++- src/components/layout/AppLayout.tsx | 3 ++- src/components/layout/ChatArea.tsx | 4 ++-- src/components/ui/AddServerModal.tsx | 13 +++---------- src/components/ui/HomeScreen.tsx | 12 +++++++++--- src/hooks/useKeyboardResize.ts | 3 ++- src/lib/tauri.ts | 8 ++++++++ 7 files changed, 28 insertions(+), 18 deletions(-) create mode 100644 src/lib/tauri.ts diff --git a/src/App.tsx b/src/App.tsx index 386654f6e..eacb9e2cc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,7 @@ import UserSettings from "./components/ui/UserSettings"; import { useKeyboardResize } from "./hooks/useKeyboardResize"; import ircClient from "./lib/ircClient"; import { parseIrcUrl } from "./lib/ircUrlParser"; +import { isTauri } from "./lib/tauri"; import useStore, { loadSavedServers } from "./store"; import type { ConnectionDetails } from "./store/types"; @@ -161,7 +162,7 @@ const App: React.FC = () => { // Handle deeplinks useEffect(() => { const setupDeepLinkHandler = async () => { - if (typeof window === "undefined" || !window.__TAURI__) { + if (!isTauri()) { return; } diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index 7c386f14c..97913d47b 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -3,6 +3,7 @@ import type React from "react"; import { useEffect } from "react"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import { useSwipeNavigation } from "../../hooks/useSwipeNavigation"; +import { isTauri } from "../../lib/tauri"; import useStore from "../../store"; import type { layoutColumn } from "../../store/types"; import { GlobalNotifications } from "../ui/GlobalNotifications"; @@ -215,7 +216,7 @@ export const AppLayout: React.FC = () => { // Handle mobile back button // TODO: ios - if ("__TAURI__" in window && platform() === "android") { + if (isTauri() && platform() === "android") { // @ts-expect-error window.androidBackCallback = () => { switch (mobileViewActiveColumn) { diff --git a/src/components/layout/ChatArea.tsx b/src/components/layout/ChatArea.tsx index 6be36e8d9..35acb9af9 100644 --- a/src/components/layout/ChatArea.tsx +++ b/src/components/layout/ChatArea.tsx @@ -24,6 +24,7 @@ import { getPreviewStyles, isValidFormattingType, } from "../../lib/messageFormatter"; +import { isTauri } from "../../lib/tauri"; import useStore from "../../store"; import type { Message as MessageType, User } from "../../types"; import { CollapsedEventMessage } from "../message/CollapsedEventMessage"; @@ -1401,8 +1402,7 @@ export const ChatArea: React.FC<{ // biome-ignore lint/correctness/useExhaustiveDependencies(selectedChannelId): Only focus when channel changes // biome-ignore lint/correctness/useExhaustiveDependencies(selectedPrivateChatId): Only focus when private chat changes useEffect(() => { - if ("__TAURI__" in window && ["android", "ios"].includes(platform())) - return; + if (isTauri() && ["android", "ios"].includes(platform())) return; // Don't steal focus if any modal is open if ( isSettingsModalOpen || diff --git a/src/components/ui/AddServerModal.tsx b/src/components/ui/AddServerModal.tsx index ce096f156..150fe6a14 100644 --- a/src/components/ui/AddServerModal.tsx +++ b/src/components/ui/AddServerModal.tsx @@ -1,16 +1,9 @@ import type React from "react"; import { useState } from "react"; import { FaQuestionCircle, FaTimes } from "react-icons/fa"; +import { isTauri } from "../../lib/tauri"; import useStore from "../../store"; -// Check if we're running in Tauri -declare global { - interface Window { - __TAURI__?: unknown; - } -} -const isTauri = typeof window !== "undefined" && window.__TAURI__ !== undefined; - export const AddServerModal: React.FC = () => { const { toggleAddServerModal, @@ -80,7 +73,7 @@ export const AddServerModal: React.FC = () => { try { // Modify host to include protocol if IRC is selected let finalHost = serverHost; - if (isTauri && useIrcProtocol) { + if (isTauri() && useIrcProtocol) { const port = Number.parseInt(serverPort, 10); // Remove any existing protocol prefix from serverHost const cleanHost = serverHost.replace( @@ -278,7 +271,7 @@ export const AddServerModal: React.FC = () => { Use server password - {isTauri && ( + {isTauri() && (
{ @@ -7,7 +8,7 @@ const DiscoverGrid = () => { useStore(); const [query, setQuery] = useState(""); const [servers, setServers] = useState< - { name: string; description: string; server?: string; port?: string }[] + { name: string; description: string; wss?: string; ircs?: string }[] >([]); useEffect(() => { @@ -35,11 +36,16 @@ const DiscoverGrid = () => { server.description.toLowerCase().includes(query.toLowerCase()), ); + const uriPort = (uri: string, defaultPort: string) => + new URL(uri).port || defaultPort; + const handleServerClick = (server: Record) => { + const uri = isTauri() ? server.ircs : server.wss; + const defaultPort = isTauri() ? "6697" : "443"; toggleAddServerModal(true, { name: server.name, - host: server.server || "", // Use empty string if server is undefined - port: server.port || "443", // Default to 443 if port is undefined + host: uri ? new URL(uri).hostname : "", + port: uri ? uriPort(uri, defaultPort) : defaultPort, nickname: "", // Generate a default nickname ui: { disableServerConnectionInfo: true, diff --git a/src/hooks/useKeyboardResize.ts b/src/hooks/useKeyboardResize.ts index 519077b22..eb05fabf6 100644 --- a/src/hooks/useKeyboardResize.ts +++ b/src/hooks/useKeyboardResize.ts @@ -1,5 +1,6 @@ import { platform } from "@tauri-apps/plugin-os"; import { useEffect } from "react"; +import { isTauri } from "../lib/tauri"; // Hook to handle keyboard visibility and viewport resizing on mobile platforms export const useKeyboardResize = () => { @@ -16,7 +17,7 @@ export const useKeyboardResize = () => { } // If we're in Tauri, check the platform - if ("__TAURI__" in window) { + if (isTauri()) { try { const currentPlatform = platform(); if (!["android", "ios"].includes(currentPlatform)) { diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts new file mode 100644 index 000000000..115d3718a --- /dev/null +++ b/src/lib/tauri.ts @@ -0,0 +1,8 @@ +declare global { + interface Window { + __TAURI__?: unknown; + } +} + +export const isTauri = () => + typeof window !== "undefined" && window.__TAURI__ !== undefined; From f413c0e37a1f586eb7586c2514fb917dc6b057e0 Mon Sep 17 00:00:00 2001 From: Matheus Fillipe Date: Mon, 9 Mar 2026 09:55:39 +0100 Subject: [PATCH 2/2] hide irc or wss depending if browser or native --- src/components/ui/AddServerModal.tsx | 40 +++--- src/components/ui/HomeScreen.tsx | 62 ++++++--- src/store/types.ts | 1 + tests/components/HomeScreen.test.ts | 200 +++++++++++++++++++++++++++ 4 files changed, 266 insertions(+), 37 deletions(-) create mode 100644 tests/components/HomeScreen.test.ts diff --git a/src/components/ui/AddServerModal.tsx b/src/components/ui/AddServerModal.tsx index 8090bf2c5..6cffecb9a 100644 --- a/src/components/ui/AddServerModal.tsx +++ b/src/components/ui/AddServerModal.tsx @@ -103,21 +103,9 @@ export const AddServerModal: React.FC = () => { /^(https?|wss|ircs?|irc):\/\//, "", ); - - const isLocalhost = - cleanHost.toLowerCase() === "localhost" || - cleanHost === "127.0.0.1" || - cleanHost === "::1"; - - const isSSLPort = - !isLocalhost && - (port === 6697 || port === 9999 || port === 443 || port === 993); - - if (useWebSocket) { - finalHost = `${isSSLPort ? "wss" : "ws"}://${cleanHost}:${port}`; - } else { - finalHost = `${isSSLPort ? "ircs" : "irc"}://${cleanHost}:${port}`; - } + finalHost = useWebSocket + ? `wss://${cleanHost}:${port}` + : `ircs://${cleanHost}:${port}`; } await connect( @@ -145,6 +133,7 @@ export const AddServerModal: React.FC = () => { const disableServerConnectionInfo = prefillServerDetails?.ui?.disableServerConnectionInfo; const hideServerInfo = prefillServerDetails?.ui?.hideServerInfo; + const lockWebSocket = prefillServerDetails?.ui?.lockWebSocket; return ( { setServerHost(e.target.value)} placeholder="irc.example.com" className={`w-full rounded px-3 py-2 focus:outline-none focus:ring-1 focus:ring-discord-primary ${ @@ -217,16 +210,23 @@ export const AddServerModal: React.FC = () => { type="checkbox" id="useWebSocket" checked={useWebSocket} - onChange={() => setUseWebSocket(!useWebSocket)} - className="accent-discord-accent rounded" + onChange={() => + !lockWebSocket && setUseWebSocket(!useWebSocket) + } + disabled={!!lockWebSocket} + className={`accent-discord-accent rounded ${lockWebSocket ? "opacity-50 cursor-not-allowed" : ""}`} /> diff --git a/src/components/ui/HomeScreen.tsx b/src/components/ui/HomeScreen.tsx index 4e2e5f81a..54746f60d 100644 --- a/src/components/ui/HomeScreen.tsx +++ b/src/components/ui/HomeScreen.tsx @@ -1,16 +1,22 @@ import { useEffect, useState } from "react"; import { FaPlus } from "react-icons/fa"; -import { isTauri } from "../../lib/tauri"; +import { isTauri } from "../../lib/platformUtils"; import useStore from "../../store"; import { TextInput } from "./TextInput"; +interface DiscoverServer { + name: string; + description: string; + wss?: string; + ircs?: string; + obsidian?: boolean; +} + const DiscoverGrid = () => { const { toggleAddServerModal, connect, isConnecting, connectionError } = useStore(); const [query, setQuery] = useState(""); - const [servers, setServers] = useState< - { name: string; description: string; wss?: string; ircs?: string }[] - >([]); + const [servers, setServers] = useState([]); useEffect(() => { const fetchServers = async () => { @@ -31,26 +37,48 @@ const DiscoverGrid = () => { fetchServers(); }, []); // Empty dependency array ensures this runs only once - const filteredServers = servers.filter( - (server) => - server.name.toLowerCase().includes(query.toLowerCase()) || - server.description.toLowerCase().includes(query.toLowerCase()), - ); + // Browser can only connect via WebSocket — hide servers that don't offer wss. + // Tauri supports both raw TCP (ircs) and wss, so show all servers with either. + const filteredServers = servers + .filter((server) => + isTauri() ? !!(server.wss || server.ircs) : !!server.wss, + ) + .filter( + (server) => + server.name.toLowerCase().includes(query.toLowerCase()) || + server.description.toLowerCase().includes(query.toLowerCase()), + ); + + const handleServerClick = (server: DiscoverServer) => { + const hasWss = !!server.wss; + const hasIrcs = !!server.ircs; + + // Tauri: prefer raw TCP (ircs); if only wss available, use that. + // Browser: always wss (non-wss servers are already filtered out above). + const useWebSocket = isTauri() ? !hasIrcs : false; + const uri = isTauri() + ? useWebSocket + ? server.wss + : (server.ircs ?? server.wss) + : server.wss; + if (!uri) return; + const parsed = new URL(uri); - const uriPort = (uri: string, defaultPort: string) => - new URL(uri).port || defaultPort; + // Lock the WSS toggle when only one protocol is available (Tauri only). + const lockWebSocket = isTauri() && !(hasWss && hasIrcs); - const handleServerClick = (server: Record) => { - const uri = isTauri() ? server.ircs : server.wss; - const defaultPort = isTauri() ? "6697" : "443"; toggleAddServerModal(true, { name: server.name, - host: uri ? new URL(uri).hostname : "", - port: uri ? uriPort(uri, defaultPort) : defaultPort, - nickname: "", // Generate a default nickname + // Pass the full URI so ircClient picks up the correct protocol (wss/ircs). + // AddServerModal strips the scheme for display when the field is disabled. + host: uri, + port: parsed.port || (isTauri() ? "6697" : "443"), + useWebSocket, + nickname: "", ui: { disableServerConnectionInfo: true, title: server.name, + lockWebSocket, }, }); }; diff --git a/src/store/types.ts b/src/store/types.ts index fa41dc3f3..433ef3825 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -11,6 +11,7 @@ export interface ConnectionDetails { hideServerInfo?: boolean; hideClose?: boolean; title?: string; + lockWebSocket?: boolean; }; } diff --git a/tests/components/HomeScreen.test.ts b/tests/components/HomeScreen.test.ts new file mode 100644 index 000000000..2dbe09e1b --- /dev/null +++ b/tests/components/HomeScreen.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from "vitest"; + +interface DiscoverServer { + name: string; + description: string; + wss?: string; + ircs?: string; + obsidian?: boolean; +} + +interface PrefillResult { + host: string; + port: string; + useWebSocket: boolean; + lockWebSocket: boolean; +} + +// Logic mirroring HomeScreen.handleServerClick +function buildPrefill( + server: DiscoverServer, + isTauri: boolean, +): PrefillResult | null { + const hasWss = !!server.wss; + const hasIrcs = !!server.ircs; + + if (!hasWss && !hasIrcs) return null; + if (!isTauri && !hasWss) return null; // browser can't connect without wss + + const useWebSocket = isTauri ? !hasIrcs : false; + // Browser always uses wss (non-wss servers are filtered before this point). + // Tauri: ircs unless forced to wss. + const uri = isTauri + ? useWebSocket + ? server.wss + : (server.ircs ?? server.wss) + : server.wss; + if (!uri) return null; + const parsed = new URL(uri); + + return { + host: uri, + port: parsed.port || (isTauri ? "6697" : "443"), + useWebSocket, + lockWebSocket: isTauri && !(hasWss && hasIrcs), + }; +} + +// Logic mirroring HomeScreen filteredServers platform filter +function isVisible(server: DiscoverServer, isTauri: boolean): boolean { + return isTauri ? !!(server.wss || server.ircs) : !!server.wss; +} + +// Mock servers.json examples covering all cases +const bothProtocols: DiscoverServer = { + name: "h4ks.com", + description: "Both wss and ircs available", + wss: "wss://irc.h4ks.com:443", + ircs: "ircs://irc.h4ks.com:6697", +}; + +const ircsOnly: DiscoverServer = { + name: "IRCsOnly Network", + description: "Raw TCP only, no WebSocket endpoint", + ircs: "ircs://irc.ircsonly.net:6697", +}; + +const wssOnly: DiscoverServer = { + name: "WSSOnly Network", + description: "WebSocket only, no raw TCP endpoint", + wss: "wss://irc.wssonly.net:8097", +}; + +const nonStandardPort: DiscoverServer = { + name: "InspIRCd Test Network", + description: "Non-standard ports on both protocols", + wss: "wss://testnet.inspircd.org:8097", + ircs: "ircs://testnet.inspircd.org:6697", +}; + +const noUrls: DiscoverServer = { + name: "No URLs", + description: "Missing both protocols", +}; + +describe("HomeScreen server list filtering", () => { + const allServers = [ + bothProtocols, + ircsOnly, + wssOnly, + nonStandardPort, + noUrls, + ]; + + it("browser: shows only servers with wss", () => { + const visible = allServers.filter((s) => isVisible(s, false)); + expect(visible.map((s) => s.name)).toEqual([ + "h4ks.com", + "WSSOnly Network", + "InspIRCd Test Network", + ]); + }); + + it("browser: hides ircs-only servers (no WebSocket available)", () => { + expect(isVisible(ircsOnly, false)).toBe(false); + }); + + it("Tauri: shows servers with wss, ircs, or both", () => { + const visible = allServers.filter((s) => isVisible(s, true)); + expect(visible.map((s) => s.name)).toEqual([ + "h4ks.com", + "IRCsOnly Network", + "WSSOnly Network", + "InspIRCd Test Network", + ]); + }); + + it("Tauri: hides servers with neither protocol", () => { + expect(isVisible(noUrls, true)).toBe(false); + }); +}); + +describe("HomeScreen prefill building — browser (non-Tauri)", () => { + it("uses wss URL for a server with both protocols", () => { + const result = buildPrefill(bothProtocols, false); + expect(result?.host).toBe("wss://irc.h4ks.com:443"); + expect(result?.port).toBe("443"); + expect(result?.useWebSocket).toBe(false); // not used; wss checkbox hidden on web + expect(result?.lockWebSocket).toBe(false); + }); + + it("uses wss URL for a wss-only server", () => { + const result = buildPrefill(wssOnly, false); + expect(result?.host).toBe("wss://irc.wssonly.net:8097"); + expect(result?.port).toBe("8097"); + }); + + it("returns null for ircs-only server (not connectable from browser)", () => { + expect(buildPrefill(ircsOnly, false)).toBeNull(); + }); + + it("returns null when no protocols available", () => { + expect(buildPrefill(noUrls, false)).toBeNull(); + }); +}); + +describe("HomeScreen prefill building — Tauri", () => { + it("both protocols: defaults to ircs, WSS toggle unlocked", () => { + const result = buildPrefill(bothProtocols, true); + expect(result?.host).toBe("ircs://irc.h4ks.com:6697"); + expect(result?.port).toBe("6697"); + expect(result?.useWebSocket).toBe(false); + expect(result?.lockWebSocket).toBe(false); + }); + + it("ircs-only: uses ircs, WSS toggle locked (off)", () => { + const result = buildPrefill(ircsOnly, true); + expect(result?.host).toBe("ircs://irc.ircsonly.net:6697"); + expect(result?.port).toBe("6697"); + expect(result?.useWebSocket).toBe(false); + expect(result?.lockWebSocket).toBe(true); + }); + + it("wss-only: uses wss, WSS toggle locked (on)", () => { + const result = buildPrefill(wssOnly, true); + expect(result?.host).toBe("wss://irc.wssonly.net:8097"); + expect(result?.port).toBe("8097"); + expect(result?.useWebSocket).toBe(true); + expect(result?.lockWebSocket).toBe(true); + }); + + it("non-standard ports: preserves correct port from URL", () => { + const result = buildPrefill(nonStandardPort, true); + expect(result?.host).toBe("ircs://testnet.inspircd.org:6697"); + expect(result?.port).toBe("6697"); + expect(result?.useWebSocket).toBe(false); + expect(result?.lockWebSocket).toBe(false); + }); + + it("returns null when no protocols available", () => { + expect(buildPrefill(noUrls, true)).toBeNull(); + }); +}); + +describe("AddServerModal host display (scheme stripped when field is disabled)", () => { + it("strips wss:// scheme for display", () => { + const host = "wss://irc.h4ks.com:443"; + expect(new URL(host).hostname).toBe("irc.h4ks.com"); + }); + + it("strips ircs:// scheme for display", () => { + const host = "ircs://testnet.inspircd.org:6697"; + expect(new URL(host).hostname).toBe("testnet.inspircd.org"); + }); + + it("leaves plain hostname unchanged", () => { + const host = "irc.example.com"; + const display = host.includes("://") ? new URL(host).hostname : host; + expect(display).toBe("irc.example.com"); + }); +});