From 41e020b5cc3380cebd2bf3db99aa9553471ff130 Mon Sep 17 00:00:00 2001 From: Matheus Fillipe Date: Sun, 4 Jan 2026 22:49:52 -0300 Subject: [PATCH 01/29] minor state fixes and ensure entering channel from channel list modal --- src/components/layout/AppLayout.tsx | 36 +++++------ src/components/layout/ChannelList.tsx | 6 +- src/components/ui/ChannelListModal.tsx | 8 ++- src/components/ui/QuickActions.tsx | 8 ++- src/hooks/useJoinAndSelectChannel.ts | 50 +++++++++++++++ src/store/index.ts | 85 ++++++++++++++++++++------ 6 files changed, 151 insertions(+), 42 deletions(-) create mode 100644 src/hooks/useJoinAndSelectChannel.ts diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index 8bd9b492..18a1b777 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -140,39 +140,41 @@ export const AppLayout: React.FC = () => { } }; - // Set correct state for mobile view + // Sync mobile/desktop view states useEffect(() => { - if (!isNarrowView) return; + if (!isNarrowView) { + // Desktop: auto-hide member list only if too narrow + if (isTooNarrowForMemberList && isMemberListVisible) { + toggleMemberList(false); + } + return; // Don't handle mobile logic on desktop + } + + // Mobile: sync toggles with mobileViewActiveColumn switch (mobileViewActiveColumn) { case "serverList": - toggleChannelList(true); + if (!isChannelListVisible) toggleChannelList(true); + if (isMemberListVisible) toggleMemberList(false); break; case "chatView": - toggleChannelList(false); - toggleMemberList(false); + if (isChannelListVisible) toggleChannelList(false); + if (isMemberListVisible) toggleMemberList(false); break; case "memberList": - toggleChannelList(false); + if (isChannelListVisible) toggleChannelList(false); + if (!isMemberListVisible) toggleMemberList(true); break; } }, [ isNarrowView, + isTooNarrowForMemberList, mobileViewActiveColumn, + isChannelListVisible, + isMemberListVisible, toggleChannelList, toggleMemberList, ]); - // Show channel list if the screen is resized - useEffect(() => { - toggleChannelList(true); - }, [toggleChannelList]); - - // Hide member list if the screen is too narrow - useEffect(() => { - if (isNarrowView) return; - toggleMemberList(!isTooNarrowForMemberList); - }, [isTooNarrowForMemberList, toggleMemberList, isNarrowView]); - const getLayoutColumn = (column: layoutColumn) => { // On mobile, only show the active column if (isNarrowView && column !== mobileViewActiveColumn) return null; diff --git a/src/components/layout/ChannelList.tsx b/src/components/layout/ChannelList.tsx index bc03e190..39d622c0 100644 --- a/src/components/layout/ChannelList.tsx +++ b/src/components/layout/ChannelList.tsx @@ -13,6 +13,7 @@ import { FaTrash, FaUser, } from "react-icons/fa"; +import { useJoinAndSelectChannel } from "../../hooks/useJoinAndSelectChannel"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import ircClient from "../../lib/ircClient"; import { @@ -32,7 +33,6 @@ export const ChannelList: React.FC<{ const { selectChannel, selectPrivateChat, - joinChannel, leaveChannel, deletePrivateChat, pinPrivateChat, @@ -43,6 +43,8 @@ export const ChannelList: React.FC<{ reorderChannels, } = useStore(); + const joinAndSelectChannel = useJoinAndSelectChannel(); + const selectedServerId = useStore((state) => state.ui.selectedServerId); const selectedChannelId = useStore((state) => { if (!state.ui.selectedServerId) return null; @@ -321,7 +323,7 @@ export const ChannelList: React.FC<{ ? newChannelName.trim() : `#${newChannelName.trim()}`; - joinChannel(selectedServerId, channelName); + joinAndSelectChannel(selectedServerId, channelName); setNewChannelName(""); } }; diff --git a/src/components/ui/ChannelListModal.tsx b/src/components/ui/ChannelListModal.tsx index bf733e3a..b80a6381 100644 --- a/src/components/ui/ChannelListModal.tsx +++ b/src/components/ui/ChannelListModal.tsx @@ -1,6 +1,7 @@ import type React from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import { FaTimes, FaUsers } from "react-icons/fa"; +import { useJoinAndSelectChannel } from "../../hooks/useJoinAndSelectChannel"; import ircClient from "../../lib/ircClient"; import { getChannelAvatarUrl, getChannelDisplayName } from "../../lib/ircUtils"; import useStore from "../../store"; @@ -16,9 +17,10 @@ const ChannelListModal: React.FC = () => { listChannels, updateChannelListFilters, toggleChannelListModal, - joinChannel, } = useStore(); + const joinAndSelectChannel = useJoinAndSelectChannel(); + const selectedServer = servers.find((s) => s.id === selectedServerId); const elist = (selectedServer?.elist || "").toUpperCase(); const rawChannels = selectedServerId @@ -252,8 +254,8 @@ const ChannelListModal: React.FC = () => { const handleJoinChannel = (channelName: string) => { if (selectedServerId) { - joinChannel(selectedServerId, channelName); - toggleChannelListModal(false); // Optionally close modal after joining + joinAndSelectChannel(selectedServerId, channelName); + toggleChannelListModal(false); } }; diff --git a/src/components/ui/QuickActions.tsx b/src/components/ui/QuickActions.tsx index 7de19dab..7b17f531 100644 --- a/src/components/ui/QuickActions.tsx +++ b/src/components/ui/QuickActions.tsx @@ -8,6 +8,7 @@ import { FaTimes, FaUser, } from "react-icons/fa"; +import { useJoinAndSelectChannel } from "../../hooks/useJoinAndSelectChannel"; import { fuzzyMatch } from "../../lib/fuzzySearch"; import ircClient from "../../lib/ircClient"; import { settingsRegistry } from "../../lib/settings"; @@ -48,10 +49,11 @@ const QuickActions: React.FC = () => { selectChannel, selectPrivateChat, selectServer, - joinChannel, openPrivateChat, requestChatInputFocus, } = useStore(); + + const joinAndSelectChannel = useJoinAndSelectChannel(); const [searchQuery, setSearchQuery] = useState(""); const [selectedIndex, setSelectedIndex] = useState(0); const inputRef = useRef(null); @@ -399,7 +401,7 @@ const QuickActions: React.FC = () => { const channelName = (result.data as { channelName: string }) .channelName; selectServer(result.serverId); - joinChannel(result.serverId, channelName); + joinAndSelectChannel(result.serverId, channelName); } break; } @@ -431,7 +433,7 @@ const QuickActions: React.FC = () => { selectChannel, selectPrivateChat, handleClose, - joinChannel, + joinAndSelectChannel, openPrivateChat, ], ); diff --git a/src/hooks/useJoinAndSelectChannel.ts b/src/hooks/useJoinAndSelectChannel.ts new file mode 100644 index 00000000..12b8121d --- /dev/null +++ b/src/hooks/useJoinAndSelectChannel.ts @@ -0,0 +1,50 @@ +import { useCallback } from "react"; +import useStore from "../store"; + +/** + * Hook that provides a function to join a channel and automatically select it + * once the IRC JOIN event has been processed and the channel appears in the store. + * + * This ensures users get immediate visual feedback when joining channels from + * various UI components (modal, quick actions, sidebar input). + */ +export const useJoinAndSelectChannel = () => { + const { joinChannel, selectChannel } = useStore(); + + const joinAndSelectChannel = useCallback( + (serverId: string, channelName: string) => { + // Send the JOIN command + joinChannel(serverId, channelName); + + // Poll for the channel to appear in the store after JOIN event is processed + const pollForChannel = (attempts = 0) => { + // Give up after 2 seconds (20 attempts × 100ms) + if (attempts > 20) { + console.warn( + `Failed to find channel ${channelName} after joining (gave up after 2s)`, + ); + return; + } + + const server = useStore + .getState() + .servers.find((s) => s.id === serverId); + const channel = server?.channels.find((c) => c.name === channelName); + + if (channel) { + // Channel found! Select it to open in the UI + selectChannel(channel.id); + } else { + // Channel not found yet, poll again in 100ms + setTimeout(() => pollForChannel(attempts + 1), 100); + } + }; + + // Start polling + pollForChannel(); + }, + [joinChannel, selectChannel], + ); + + return joinAndSelectChannel; +}; diff --git a/src/store/index.ts b/src/store/index.ts index aed74e56..1e117950 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -705,6 +705,7 @@ export interface AppState { ) => void; hideContextMenu: () => void; setMobileViewActiveColumn: (column: layoutColumn) => void; + setMobileView: (view: layoutColumn) => void; // Server notices popup actions toggleServerNoticesPopup: (isOpen?: boolean) => void; minimizeServerNoticesPopup: (isMinimized?: boolean) => void; @@ -1483,6 +1484,7 @@ const useStore = create((set, get) => ({ set((state) => { // Special case for server notices if (channelId === "server-notices") { + const isNarrowView = window.matchMedia("(max-width: 768px)").matches; return { ui: { ...state.ui, @@ -1495,7 +1497,9 @@ const useStore = create((set, get) => ({ }, ), isMobileMenuOpen: false, - mobileViewActiveColumn: "chatView", + mobileViewActiveColumn: isNarrowView + ? "chatView" + : state.ui.mobileViewActiveColumn, }, }; } @@ -1539,6 +1543,7 @@ const useStore = create((set, get) => ({ return server; }); + const isNarrowView = window.matchMedia("(max-width: 768px)").matches; return { servers: updatedServers, ui: { @@ -1549,11 +1554,14 @@ const useStore = create((set, get) => ({ selectedPrivateChatId: null, }), isMobileMenuOpen: false, - mobileViewActiveColumn: "chatView", + mobileViewActiveColumn: isNarrowView + ? "chatView" + : state.ui.mobileViewActiveColumn, }, }; } + const isNarrowView = window.matchMedia("(max-width: 768px)").matches; return { ui: { ...state.ui, @@ -1566,7 +1574,9 @@ const useStore = create((set, get) => ({ }, ), isMobileMenuOpen: false, - mobileViewActiveColumn: "chatView", + mobileViewActiveColumn: isNarrowView + ? "chatView" + : state.ui.mobileViewActiveColumn, }, }; }); @@ -1691,6 +1701,7 @@ const useStore = create((set, get) => ({ return server; }); + const isNarrowView = window.matchMedia("(max-width: 768px)").matches; return { servers: updatedServers, ui: { @@ -1701,11 +1712,14 @@ const useStore = create((set, get) => ({ selectedPrivateChatId: privateChatId, }), isMobileMenuOpen: false, - mobileViewActiveColumn: "chatView", + mobileViewActiveColumn: isNarrowView + ? "chatView" + : state.ui.mobileViewActiveColumn, }, }; } + const isNarrowView = window.matchMedia("(max-width: 768px)").matches; return { ui: { ...state.ui, @@ -1718,7 +1732,9 @@ const useStore = create((set, get) => ({ }, ), isMobileMenuOpen: false, - mobileViewActiveColumn: "chatView", + mobileViewActiveColumn: isNarrowView + ? "chatView" + : state.ui.mobileViewActiveColumn, }, }; }); @@ -2367,23 +2383,20 @@ const useStore = create((set, get) => ({ toggleMemberList: (isOpen) => { set((state) => { const openState = - isOpen !== undefined ? isOpen : !state.ui.isChannelListVisible; + isOpen !== undefined ? isOpen : !state.ui.isMemberListVisible; - // Only change mobileViewActiveColumn if we're not on the serverList view - // This prevents desktop member list toggles from affecting mobile navigation - const shouldUpdateMobileColumn = - state.ui.mobileViewActiveColumn !== "serverList"; + // Only update mobileViewActiveColumn if actually in narrow view + const isNarrowView = window.matchMedia("(max-width: 768px)").matches; return { ui: { ...state.ui, - isMemberListVisible: - openState !== undefined ? openState : !state.ui.isMemberListVisible, - mobileViewActiveColumn: shouldUpdateMobileColumn + isMemberListVisible: openState, + mobileViewActiveColumn: isNarrowView ? openState ? "memberList" : "chatView" - : state.ui.mobileViewActiveColumn, + : state.ui.mobileViewActiveColumn, // Don't change on desktop }, }; }); @@ -2393,13 +2406,18 @@ const useStore = create((set, get) => ({ set((state) => { const openState = isOpen !== undefined ? isOpen : !state.ui.isChannelListVisible; + + // Only update mobileViewActiveColumn if actually in narrow view + const isNarrowView = window.matchMedia("(max-width: 768px)").matches; + return { ui: { ...state.ui, isChannelListVisible: openState, - mobileViewActiveColumn: openState - ? "serverList" - : state.ui.mobileViewActiveColumn, + mobileViewActiveColumn: + isNarrowView && openState + ? "serverList" + : state.ui.mobileViewActiveColumn, // Don't change on desktop }, }; }); @@ -2471,6 +2489,39 @@ const useStore = create((set, get) => ({ })); }, + // Single source of truth for mobile navigation - syncs all related states + setMobileView: (view: layoutColumn) => { + set((state) => { + // Only execute in narrow view + const isNarrowView = window.matchMedia("(max-width: 768px)").matches; + if (!isNarrowView) return state; + + // Sync all related states based on the active column + const updates = { + serverList: { + isChannelListVisible: true, + isMemberListVisible: false, + }, + chatView: { + isChannelListVisible: false, + isMemberListVisible: false, + }, + memberList: { + isChannelListVisible: false, + isMemberListVisible: true, + }, + }[view]; + + return { + ui: { + ...state.ui, + mobileViewActiveColumn: view, + ...updates, + }, + }; + }); + }, + toggleServerNoticesPopup: (isOpen) => { set((state) => ({ ui: { From 3cebfa5e1e7ec6879cc27a57bfabfcb0e73aeeb4 Mon Sep 17 00:00:00 2001 From: Matheus Fillipe Date: Mon, 5 Jan 2026 20:36:21 -0300 Subject: [PATCH 02/29] Simplify add server button --- src/components/layout/ServerList.tsx | 36 +++++++--------------------- src/hooks/useJoinAndSelectChannel.ts | 15 ++++++++---- tests/App.test.tsx | 20 +++++++--------- 3 files changed, 27 insertions(+), 44 deletions(-) diff --git a/src/components/layout/ServerList.tsx b/src/components/layout/ServerList.tsx index f355e5c5..72c2b890 100644 --- a/src/components/layout/ServerList.tsx +++ b/src/components/layout/ServerList.tsx @@ -1,6 +1,6 @@ import type React from "react"; import { useEffect, useState } from "react"; -import { FaEllipsisH, FaPencilAlt, FaRedo, FaTrash } from "react-icons/fa"; +import { FaPencilAlt, FaPlus, FaRedo, FaTrash } from "react-icons/fa"; import ircClient from "../../lib/ircClient"; import useStore from "../../store"; import type { Server } from "../../types"; @@ -17,13 +17,10 @@ export const ServerList: React.FC = () => { toggleEditServerModal, // Add toggleEditServerModal action } = useStore(); - const [isOptionsOpen, setIsOptionsOpen] = useState(false); const [shimmeringServers, setShimmeringServers] = useState>( new Set(), ); - const toggleOptions = () => setIsOptionsOpen((prev) => !prev); - // Generate initial for server icon const getServerInitial = (server: Server): string => { // Use network name if available, otherwise server name @@ -81,33 +78,18 @@ export const ServerList: React.FC = () => {
- {/* Options Button */} + {/* Add Server Button */}
toggleAddServerModal(true)} + data-testid="server-list-add-button" > - -
- - {/* Dropdown Menu */} - {isOptionsOpen && ( -
- + +
+ Add Server
- )} +
{/* Server list */} diff --git a/src/hooks/useJoinAndSelectChannel.ts b/src/hooks/useJoinAndSelectChannel.ts index 12b8121d..d80aba2b 100644 --- a/src/hooks/useJoinAndSelectChannel.ts +++ b/src/hooks/useJoinAndSelectChannel.ts @@ -9,7 +9,7 @@ import useStore from "../store"; * various UI components (modal, quick actions, sidebar input). */ export const useJoinAndSelectChannel = () => { - const { joinChannel, selectChannel } = useStore(); + const { joinChannel, selectChannel, servers } = useStore(); const joinAndSelectChannel = useCallback( (serverId: string, channelName: string) => { @@ -26,9 +26,14 @@ export const useJoinAndSelectChannel = () => { return; } - const server = useStore - .getState() - .servers.find((s) => s.id === serverId); + // Get current servers state via getState if available (for production), + // or fall back to the servers from the hook (for tests) + const currentServers = + typeof useStore.getState === "function" + ? useStore.getState().servers + : servers; + + const server = currentServers.find((s) => s.id === serverId); const channel = server?.channels.find((c) => c.name === channelName); if (channel) { @@ -43,7 +48,7 @@ export const useJoinAndSelectChannel = () => { // Start polling pollForChannel(); }, - [joinChannel, selectChannel], + [joinChannel, selectChannel, servers], ); return joinAndSelectChannel; diff --git a/tests/App.test.tsx b/tests/App.test.tsx index bc3cabbe..871b4342 100644 --- a/tests/App.test.tsx +++ b/tests/App.test.tsx @@ -116,9 +116,8 @@ describe("App", () => { ); const user = userEvent.setup(); - // Open modal - await user.click(screen.getByTestId("server-list-options-button")); - await user.click(screen.getByText(/Add Server/i)); + // Open modal by clicking add server button + await user.click(screen.getByTestId("server-list-add-button")); // Check that toggleAddServerModal was called with true expect(mockStoreState.toggleAddServerModal).toHaveBeenCalledWith(true); @@ -145,9 +144,8 @@ describe("App", () => { capabilities: [], }); - // Open modal - await user.click(screen.getByTestId("server-list-options-button")); - await user.click(screen.getByText(/Add Server/i)); + // Open modal by clicking add server button + await user.click(screen.getByTestId("server-list-add-button")); // Check that toggleAddServerModal was called expect(mockStoreState.toggleAddServerModal).toHaveBeenCalledWith(true); @@ -166,9 +164,8 @@ describe("App", () => { new Error("Connection failed"), ); - // Open modal - await user.click(screen.getByTestId("server-list-options-button")); - await user.click(screen.getByText(/Add Server/i)); + // Open modal by clicking add server button + await user.click(screen.getByTestId("server-list-add-button")); // Check that toggleAddServerModal was called expect(mockStoreState.toggleAddServerModal).toHaveBeenCalledWith(true); @@ -187,9 +184,8 @@ describe("App", () => { new Error("Connection failed"), ); - // Open modal - await user.click(screen.getByTestId("server-list-options-button")); - await user.click(screen.getByText(/Add Server/i)); + // Open modal by clicking add server button + await user.click(screen.getByTestId("server-list-add-button")); // Check that toggleAddServerModal was called expect(mockStoreState.toggleAddServerModal).toHaveBeenCalledWith(true); From 0cd0ec2e39647b18f80a423389ede0f04c2f4e1b Mon Sep 17 00:00:00 2001 From: Matheus Fillipe Date: Tue, 6 Jan 2026 00:17:04 -0300 Subject: [PATCH 03/29] improve header for mobile --- src/components/layout/ChannelList.tsx | 2 +- src/components/layout/ChatArea.tsx | 17 +- src/components/layout/ChatHeader.tsx | 211 ++++++++++++++++++----- src/components/layout/ServerList.tsx | 2 +- src/components/ui/HeaderOverflowMenu.tsx | 98 +++++++++++ 5 files changed, 284 insertions(+), 46 deletions(-) create mode 100644 src/components/ui/HeaderOverflowMenu.tsx diff --git a/src/components/layout/ChannelList.tsx b/src/components/layout/ChannelList.tsx index 39d622c0..4b30718d 100644 --- a/src/components/layout/ChannelList.tsx +++ b/src/components/layout/ChannelList.tsx @@ -1275,7 +1275,7 @@ export const ChannelList: React.FC<{ )}
-
+
toggleSettingsModal(true)} diff --git a/src/components/layout/ChatArea.tsx b/src/components/layout/ChatArea.tsx index ca10322c..a17f47a5 100644 --- a/src/components/layout/ChatArea.tsx +++ b/src/components/layout/ChatArea.tsx @@ -1504,10 +1504,19 @@ export const ChatArea: React.FC<{ )} {/* Search results indicator */} {searchQuery && ( -
- Found {filteredMessages.length} message - {filteredMessages.length === 1 ? "" : "s"} matching " - {searchQuery}" +
+ + Found {filteredMessages.length} message + {filteredMessages.length === 1 ? "" : "s"} matching " + {searchQuery}" + +
)} {eventGroups.map((group) => { diff --git a/src/components/layout/ChatHeader.tsx b/src/components/layout/ChatHeader.tsx index 01511a1d..23efa8c7 100644 --- a/src/components/layout/ChatHeader.tsx +++ b/src/components/layout/ChatHeader.tsx @@ -1,6 +1,6 @@ import { UsersIcon } from "@heroicons/react/24/solid"; import type React from "react"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { FaBell, FaBellSlash, @@ -8,12 +8,14 @@ import { FaChevronLeft, FaChevronRight, FaEdit, + FaEllipsisV, FaHashtag, FaInfoCircle, FaList, FaPenAlt, FaSearch, FaThumbtack, + FaTimes, FaUser, FaUserPlus, } from "react-icons/fa"; @@ -26,6 +28,9 @@ import { } from "../../lib/ircUtils"; import useStore, { loadSavedMetadata } from "../../store"; import type { Channel, PrivateChat, User } from "../../types"; +import HeaderOverflowMenu, { + type HeaderOverflowMenuItem, +} from "../ui/HeaderOverflowMenu"; import UserProfileModal from "../ui/UserProfileModal"; interface ChatHeaderProps { @@ -78,6 +83,9 @@ export const ChatHeader: React.FC = ({ const [editedTopic, setEditedTopic] = useState(""); const [avatarLoadFailed, setAvatarLoadFailed] = useState(false); const [userProfileModalOpen, setUserProfileModalOpen] = useState(false); + const [isSearchExpanded, setIsSearchExpanded] = useState(false); + const [isOverflowMenuOpen, setIsOverflowMenuOpen] = useState(false); + const overflowButtonRef = useRef(null); const servers = useStore((state) => state.servers); @@ -232,6 +240,41 @@ export const ChatHeader: React.FC = ({ ); })(); + // Reset search expanded state when channel changes + // biome-ignore lint/correctness/useExhaustiveDependencies: Need to reset when channel changes + useEffect(() => { + setIsSearchExpanded(false); + setIsOverflowMenuOpen(false); + }, [selectedChannelId]); + + // Define overflow menu items based on context + const overflowMenuItems: HeaderOverflowMenuItem[] = [ + { + label: "Channel Settings", + icon: , + onClick: onOpenChannelSettings, + show: !!selectedChannel, + }, + { + label: "Invite User", + icon: , + onClick: onOpenInviteUser, + show: !!selectedChannel, + }, + { + label: "List Channels", + icon: , + onClick: () => toggleChannelListModal(true), + show: true, + }, + { + label: "Rename Channel", + icon: , + onClick: () => toggleChannelRenameModal(true), + show: !!(selectedChannel && isOperator), + }, + ].filter((item) => item.show); + return (
@@ -549,9 +592,15 @@ export const ChatHeader: React.FC = ({
{!!selectedServerId && selectedChannelId !== "server-notices" && (
+ {/* Bell - always visible */} + + {/* Users - visible for channels */} {selectedChannel && ( )} - {selectedChannel && ( + + {/* Search - icon on mobile, input on desktop */} +
+ + {/* Desktop search - always visible */} +
+ onSearchQueryChange(e.target.value)} + className="bg-discord-dark-400 text-discord-text-muted text-sm rounded px-2 py-1 pr-14 w-32 focus:outline-none focus:ring-1 focus:ring-discord-text-link" + /> + {searchQuery && ( + + )} + +
+ + {/* Mobile expanded search */} + {isSearchExpanded && ( +
+
+ onSearchQueryChange(e.target.value)} + onBlur={() => setIsSearchExpanded(false)} + onKeyDown={(e) => { + if (e.key === "Escape") { + setIsSearchExpanded(false); + } + }} + className="bg-discord-dark-400 text-white text-sm rounded px-2 py-1 pr-8 w-40 focus:outline-none focus:ring-1 focus:ring-discord-text-link" + /> + +
+
+ )} +
+ + {/* Overflow menu button - mobile only */} + + + {/* Desktop - original buttons */} + {selectedChannel && ( + <> + + + )} {selectedChannel && isOperator && ( )} - {/* Only show member list toggle for channels, not private chats */} - {selectedChannel && ( - - )} -
- onSearchQueryChange(e.target.value)} - className="bg-discord-dark-400 text-discord-text-muted text-sm rounded px-2 py-1 w-20 md:w-32 focus:outline-none focus:ring-1 focus:ring-discord-text-link" - /> - -
)} + + {/* Overflow Menu Component */} + setIsOverflowMenuOpen(false)} + menuItems={overflowMenuItems} + anchorElement={overflowButtonRef.current} + /> {selectedChannelId === "server-notices" && (
{/* TODO: Re-enable pop out button for server notices diff --git a/src/components/layout/ServerList.tsx b/src/components/layout/ServerList.tsx index 72c2b890..a62328cb 100644 --- a/src/components/layout/ServerList.tsx +++ b/src/components/layout/ServerList.tsx @@ -48,7 +48,7 @@ export const ServerList: React.FC = () => { }, []); return ( -
+
{/* Home button - in Discord this would be DMs */}
void; + show: boolean; +} + +interface HeaderOverflowMenuProps { + isOpen: boolean; + onClose: () => void; + menuItems: HeaderOverflowMenuItem[]; + anchorElement: HTMLElement | null; +} + +export const HeaderOverflowMenu: React.FC = ({ + isOpen, + onClose, + menuItems, + anchorElement, +}) => { + const menuRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + onClose(); + } + }; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose(); + } + }; + + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("keydown", handleEscape); + } + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("keydown", handleEscape); + }; + }, [isOpen, onClose]); + + if (!isOpen || !anchorElement) return null; + + // Calculate menu position based on anchor element + const rect = anchorElement.getBoundingClientRect(); + const menuWidth = 200; + const menuHeight = menuItems.length * 40 + 8; // Approximate height (40px per item + padding) + + // Position menu below and aligned to right edge of button + // Adjust to prevent going off-screen + const x = rect.right - menuWidth; + const y = rect.bottom + 4; + + const adjustedX = Math.max(8, Math.min(x, window.innerWidth - menuWidth - 8)); + const adjustedY = Math.min(y, window.innerHeight - menuHeight - 8); + + const handleMenuItemClick = (onClick: () => void) => { + onClick(); + onClose(); + }; + + return ( +
+
+ {menuItems.map((item) => ( + + ))} +
+
+ ); +}; + +export default HeaderOverflowMenu; From 45c72742c637f49691f24cf159a8b949794f075f Mon Sep 17 00:00:00 2001 From: Matheus Fillipe Date: Tue, 6 Jan 2026 00:21:22 -0300 Subject: [PATCH 04/29] hmmm dark background is better honestly --- src/components/layout/ServerList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/layout/ServerList.tsx b/src/components/layout/ServerList.tsx index a62328cb..04610cbd 100644 --- a/src/components/layout/ServerList.tsx +++ b/src/components/layout/ServerList.tsx @@ -81,7 +81,7 @@ export const ServerList: React.FC = () => { {/* Add Server Button */}
toggleAddServerModal(true)} data-testid="server-list-add-button" > From 0bb773ad2aa23da899473ff9c19713671004392d Mon Sep 17 00:00:00 2001 From: Matheus Fillipe Date: Tue, 6 Jan 2026 00:27:20 -0300 Subject: [PATCH 05/29] add server channels button to blank server page --- src/components/layout/ChatHeader.tsx | 4 ++-- src/components/ui/BlankPage.tsx | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/components/layout/ChatHeader.tsx b/src/components/layout/ChatHeader.tsx index 23efa8c7..f779f380 100644 --- a/src/components/layout/ChatHeader.tsx +++ b/src/components/layout/ChatHeader.tsx @@ -262,7 +262,7 @@ export const ChatHeader: React.FC = ({ show: !!selectedChannel, }, { - label: "List Channels", + label: "Server Channels", icon: , onClick: () => toggleChannelListModal(true), show: true, @@ -737,7 +737,7 @@ export const ChatHeader: React.FC = ({ diff --git a/src/components/ui/BlankPage.tsx b/src/components/ui/BlankPage.tsx index 73915c2d..c8e92443 100644 --- a/src/components/ui/BlankPage.tsx +++ b/src/components/ui/BlankPage.tsx @@ -1,10 +1,12 @@ import type * as React from "react"; +import { FaList } from "react-icons/fa"; import useStore from "../../store"; const BlankPage: React.FC = () => { const { servers, ui: { selectedServerId }, + toggleChannelListModal, } = useStore(); const server = servers.find((s) => s.id === selectedServerId); @@ -15,6 +17,15 @@ const BlankPage: React.FC = () => { Welcome to {server?.name || "the unknown"}!

Select or add a channel to get started.

+ +
); }; From c779f8b10b62988b51839ba92e5bc96052f18d29 Mon Sep 17 00:00:00 2001 From: Matheus Fillipe Date: Tue, 6 Jan 2026 22:56:34 -0300 Subject: [PATCH 06/29] Use loading spinner for server connection --- src/App.tsx | 3 ++ src/components/ui/LoadingOverlay.tsx | 12 +++++++ src/lib/ircClient.ts | 17 +++++++--- src/protocol/index.ts | 51 ++++++++++++++++++++++++++-- src/store/index.ts | 39 +++++++++++++-------- 5 files changed, 101 insertions(+), 21 deletions(-) create mode 100644 src/components/ui/LoadingOverlay.tsx diff --git a/src/App.tsx b/src/App.tsx index 25e5ca29..f7cca9cc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,7 @@ import ChannelListModal from "./components/ui/ChannelListModal"; import ChannelRenameModal from "./components/ui/ChannelRenameModal"; import { EditServerModal } from "./components/ui/EditServerModal"; import LinkSecurityWarningModal from "./components/ui/LinkSecurityWarningModal"; +import LoadingOverlay from "./components/ui/LoadingOverlay"; import QuickActions from "./components/ui/QuickActions"; import UserProfileModal from "./components/ui/UserProfileModal"; import UserSettings from "./components/ui/UserSettings"; @@ -95,6 +96,7 @@ const App: React.FC = () => { toggleServerNoticesPopup, clearProfileViewRequest, messages, + isConnecting, } = useStore(); // Local state for User Profile modal @@ -245,6 +247,7 @@ const App: React.FC = () => { joinChannel={joinChannel} /> )} + {isConnecting && } } /> diff --git a/src/components/ui/LoadingOverlay.tsx b/src/components/ui/LoadingOverlay.tsx new file mode 100644 index 00000000..39e43907 --- /dev/null +++ b/src/components/ui/LoadingOverlay.tsx @@ -0,0 +1,12 @@ +import type React from "react"; +import LoadingSpinner from "./LoadingSpinner"; + +export const LoadingOverlay: React.FC = () => { + return ( +
+ +
+ ); +}; + +export default LoadingOverlay; diff --git a/src/lib/ircClient.ts b/src/lib/ircClient.ts index d5eeb8b6..61d04fb3 100644 --- a/src/lib/ircClient.ts +++ b/src/lib/ircClient.ts @@ -566,12 +566,13 @@ export class IRCClient { // to ensure connection is fully established before sending PINGs socket.onclose = () => { - console.log(`WebSocket onclose for server ${actualHost}`); - // Stop WebSocket ping timers + if (!this.servers.has(server.id)) { + return; + } + this.stopWebSocketPing(server.id); this.sockets.delete(server.id); server.isConnected = false; - // Only start reconnection if not already reconnecting (e.g., from ERROR handler) const wasReconnecting = server.connectionState === "reconnecting"; server.connectionState = "disconnected"; this.triggerEvent("connectionStateChange", { @@ -579,7 +580,6 @@ export class IRCClient { connectionState: "disconnected", }); this.pendingConnections.delete(connectionKey); - // Start reconnection logic only if not already reconnecting if (!wasReconnecting) { this.startReconnection( server.id, @@ -656,6 +656,15 @@ export class IRCClient { this.stopWebSocketPing(serverId); } + removeServer(serverId: string): void { + this.disconnect(serverId); + this.servers.delete(serverId); + this.capNegotiationComplete.delete(serverId); + this.pendingCapReqs.delete(serverId); + this.capLsAccumulated.delete(serverId); + this.saslMechanisms.delete(serverId); + } + private startReconnection( serverId: string, name: string, diff --git a/src/protocol/index.ts b/src/protocol/index.ts index 0785f789..23f6b095 100644 --- a/src/protocol/index.ts +++ b/src/protocol/index.ts @@ -4,6 +4,19 @@ import type { AppState } from "../store/"; import { registerISupportHandler } from "./isupport"; import { registerModeHandler } from "./mode"; +const CONNECTION_TIMEOUT_MS = 30000; +const connectionTimeouts = new Map(); + +const clearConnectionTimeout = (serverId: string) => { + const timeout = connectionTimeouts.get(serverId); + if (timeout) { + clearTimeout(timeout); + connectionTimeouts.delete(serverId); + } +}; + +export const clearServerConnectionTimeout = clearConnectionTimeout; + export function registerAllProtocolHandlers( ircClient: IRCClient, useStore: UseBoundStore>, @@ -11,17 +24,49 @@ export function registerAllProtocolHandlers( registerISupportHandler(ircClient, useStore); registerModeHandler(ircClient, useStore); - // Register ready event handler for shimmer effect ircClient.on("ready", ({ serverId }) => { - useStore.getState().triggerServerShimmer(serverId); + clearConnectionTimeout(serverId); + useStore.setState({ + isConnecting: false, + connectingServerId: null, + }); + requestAnimationFrame(() => { + useStore.getState().triggerServerShimmer(serverId); + }); }); - // Register connection state change handler ircClient.on("connectionStateChange", ({ serverId, connectionState }) => { useStore.setState((state) => ({ servers: state.servers.map((server) => server.id === serverId ? { ...server, connectionState } : server, ), })); + + if (connectionState === "connected") { + const timeout = setTimeout(() => { + useStore.setState({ + isConnecting: false, + connectingServerId: null, + }); + connectionTimeouts.delete(serverId); + }, CONNECTION_TIMEOUT_MS); + + connectionTimeouts.set(serverId, timeout); + } + + if ( + connectionState === "disconnected" || + connectionState === "reconnecting" + ) { + clearConnectionTimeout(serverId); + + const state = useStore.getState(); + if (state.connectingServerId === serverId) { + useStore.setState({ + isConnecting: false, + connectingServerId: null, + }); + } + } }); } diff --git a/src/store/index.ts b/src/store/index.ts index 1e117950..3e756866 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -11,7 +11,10 @@ import { extractMentions, showMentionNotification, } from "../lib/notifications"; -import { registerAllProtocolHandlers } from "../protocol"; +import { + clearServerConnectionTimeout, + registerAllProtocolHandlers, +} from "../protocol"; import type { Channel, Message, @@ -460,6 +463,7 @@ export interface AppState { servers: Server[]; currentUser: User | null; isConnecting: boolean; + connectingServerId: string | null; selectedServerId: string | null; connectionError: string | null; messages: Record; @@ -778,6 +782,7 @@ const useStore = create((set, get) => ({ servers: [], currentUser: null, isConnecting: false, + connectingServerId: null, connectionError: null, messages: {}, typingUsers: {}, @@ -953,22 +958,21 @@ const useStore = create((set, get) => ({ (s) => s.host === host && s.port === port, ); if (existingServerIndex !== -1) { - // Update existing server properties const updatedServers = [...state.servers]; const existingServer = updatedServers[existingServerIndex]; updatedServers[existingServerIndex] = { ...existingServer, ...server, - id: existingServer.id, // Keep the original ID + id: existingServer.id, }; return { servers: updatedServers, - isConnecting: false, + connectingServerId: server.id, }; } return { servers: [...state.servers, server], - isConnecting: false, + connectingServerId: server.id, }; }); @@ -1059,9 +1063,9 @@ const useStore = create((set, get) => ({ }, disconnect: (serverId) => { + clearServerConnectionTimeout(serverId); ircClient.disconnect(serverId); - // Update the state to reflect disconnection set((state) => { const updatedServers = state.servers.map((server) => { if (server.id === serverId) { @@ -1074,15 +1078,12 @@ const useStore = create((set, get) => ({ return server; }); - // Update selected server/channel if we were on the disconnected server let newUi = { ...state.ui }; if (state.ui.selectedServerId === serverId) { - // Find another connected server, or set to null const nextServer = updatedServers.find( (s) => s.isConnected && s.id !== serverId, ); if (nextServer) { - // Restore the previously selected tab for the new server const serverSelection = getServerSelection(state, nextServer.id); newUi = { ...newUi, @@ -1101,8 +1102,14 @@ const useStore = create((set, get) => ({ } } + const clearConnectionState = + state.connectingServerId === serverId + ? { isConnecting: false, connectingServerId: null } + : {}; + return { servers: updatedServers, + ...clearConnectionState, ui: newUi, }; }); @@ -2212,12 +2219,14 @@ const useStore = create((set, get) => ({ }, deleteServer: (serverId) => { + clearServerConnectionTimeout(serverId); + ircClient.removeServer(serverId); + set((state) => { const serverToDelete = state.servers.find( (server) => server.id === serverId, ); - // Remove server from localStorage const savedServers = loadSavedServers(); const updatedServers = savedServers.filter( (s) => @@ -2225,20 +2234,24 @@ const useStore = create((set, get) => ({ ); saveServersToLocalStorage(updatedServers); - // Remove server's metadata from localStorage const savedMetadata = loadSavedMetadata(); delete savedMetadata[serverId]; saveMetadataToLocalStorage(savedMetadata); - // Update state const remainingServers = state.servers.filter( (server) => server.id !== serverId, ); const newSelectedServerId = remainingServers.length > 0 ? remainingServers[0].id : null; + const clearConnectionState = + state.connectingServerId === serverId + ? { isConnecting: false, connectingServerId: null } + : {}; + return { servers: remainingServers, + ...clearConnectionState, ui: { ...state.ui, selectedServerId: newSelectedServerId, @@ -2248,8 +2261,6 @@ const useStore = create((set, get) => ({ }, }; }); - - ircClient.disconnect(serverId); }, updateServer: (serverId, config) => { From da220013bc8a6ea0688462b60bd4f73ce76d7e22 Mon Sep 17 00:00:00 2001 From: Matheus Fillipe Date: Tue, 6 Jan 2026 23:32:14 -0300 Subject: [PATCH 07/29] swipe left and right for mobile pages --- src/components/layout/AppLayout.tsx | 121 +++-- src/hooks/useSwipeNavigation.ts | 143 ++++++ tests/components/layout/AppLayout.test.tsx | 431 ++++++++++++++++++ tests/hooks/useSwipeNavigation.test.ts | 491 +++++++++++++++++++++ 4 files changed, 1161 insertions(+), 25 deletions(-) create mode 100644 src/hooks/useSwipeNavigation.ts create mode 100644 tests/components/layout/AppLayout.test.tsx create mode 100644 tests/hooks/useSwipeNavigation.test.ts diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index 18a1b777..047124f4 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -2,6 +2,7 @@ import { platform } from "@tauri-apps/plugin-os"; import type React from "react"; import { useEffect } from "react"; import { useMediaQuery } from "../../hooks/useMediaQuery"; +import { useSwipeNavigation } from "../../hooks/useSwipeNavigation"; import useStore from "../../store"; import type { layoutColumn } from "../../store/types"; import { GlobalNotifications } from "../ui/GlobalNotifications"; @@ -11,6 +12,11 @@ import { MemberList } from "./MemberList"; import { ResizableSidebar } from "./ResizableSidebar"; import { ServerList } from "./ServerList"; +const PAGE_ORDER: layoutColumn[] = ["serverList", "chatView", "memberList"]; +const getPageIndex = (column: layoutColumn): number => + PAGE_ORDER.indexOf(column); +const getColumnFromPage = (page: number): layoutColumn => PAGE_ORDER[page]; + export const AppLayout: React.FC = () => { const { ui, @@ -70,27 +76,50 @@ export const AppLayout: React.FC = () => { const isNarrowView = useMediaQuery(); const isTooNarrowForMemberList = useMediaQuery("(max-width: 1080px)"); + const currentPageIndex = getPageIndex(mobileViewActiveColumn); + const totalPages = selectedServerId ? 3 : 2; + + const { + containerRef, + offset, + isTransitioning, + handleTouchStart, + handleTouchMove, + handleTouchEnd, + } = useSwipeNavigation({ + currentPage: currentPageIndex, + totalPages, + onPageChange: (page) => setMobileViewActiveColumn(getColumnFromPage(page)), + }); + const getLayoutColumnElement = (column: layoutColumn) => { switch (column) { case "serverList": + if (isNarrowView) { + return ( +
+ {__HIDE_SERVER_LIST__ ? null : ( +
+ +
+ )} +
+ toggleChannelList(!isChannelListVisible)} + /> +
+
+ ); + } return ( <> {__HIDE_SERVER_LIST__ ? null : ( -
+
)} - { side="left" onMinReached={() => toggleChannelList(false)} > -
+
{ - toggleChannelList(!isChannelListVisible); - }} + onToggle={() => toggleChannelList(!isChannelListVisible)} />
@@ -112,19 +137,26 @@ export const AppLayout: React.FC = () => { ); case "chatView": return ( -
+
{ - toggleChannelList(!isChannelListVisible); - }} + onToggleChanList={() => toggleChannelList(!isChannelListVisible)} />
); case "memberList": + if (isNarrowView) { + return ( +
+ +
+ ); + } return ( { paddingLeft: "var(--safe-area-inset-left)", }} > - {getLayoutColumn("serverList")} - {getLayoutColumn("chatView")} - {selectedServerId && getLayoutColumn("memberList")} + {isNarrowView ? ( +
+
+ {PAGE_ORDER.filter( + (col) => col !== "memberList" || selectedServerId, + ).map((column) => ( +
+ {getLayoutColumnElement(column)} +
+ ))} +
+
+ ) : ( + <> + {getLayoutColumn("serverList")} + {getLayoutColumn("chatView")} + {selectedServerId && getLayoutColumn("memberList")} + + )}
); diff --git a/src/hooks/useSwipeNavigation.ts b/src/hooks/useSwipeNavigation.ts new file mode 100644 index 00000000..d5e2cece --- /dev/null +++ b/src/hooks/useSwipeNavigation.ts @@ -0,0 +1,143 @@ +import { useCallback, useRef, useState } from "react"; + +interface SwipeNavigationConfig { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; + threshold?: number; + rubberBandStrength?: number; +} + +interface SwipeNavigationReturn { + containerRef: React.RefObject; + offset: number; + isTransitioning: boolean; + handleTouchStart: (e: React.TouchEvent) => void; + handleTouchMove: (e: React.TouchEvent) => void; + handleTouchEnd: (e: React.TouchEvent) => void; +} + +interface TouchState { + startX: number; + startY: number; + currentX: number; + currentY: number; + isDragging: boolean; + isHorizontal: boolean | null; +} + +export function useSwipeNavigation({ + currentPage, + totalPages, + onPageChange, + threshold = 50, + rubberBandStrength = 0.3, +}: SwipeNavigationConfig): SwipeNavigationReturn { + const containerRef = useRef(null); + const [offset, setOffset] = useState(0); + const [isTransitioning, setIsTransitioning] = useState(false); + const touchState = useRef({ + startX: 0, + startY: 0, + currentX: 0, + currentY: 0, + isDragging: false, + isHorizontal: null, + }); + + const handleTouchStart = useCallback((e: React.TouchEvent) => { + const touch = e.touches[0]; + touchState.current = { + startX: touch.clientX, + startY: touch.clientY, + currentX: touch.clientX, + currentY: touch.clientY, + isDragging: false, + isHorizontal: null, + }; + }, []); + + const handleTouchMove = useCallback( + (e: React.TouchEvent) => { + if (isTransitioning) return; + + const touch = e.touches[0]; + const deltaX = touch.clientX - touchState.current.startX; + const deltaY = touch.clientY - touchState.current.startY; + + if (touchState.current.isHorizontal === null) { + const absX = Math.abs(deltaX); + const absY = Math.abs(deltaY); + + if (absX > 10 || absY > 10) { + touchState.current.isHorizontal = absX > absY; + } + } + + if (touchState.current.isHorizontal === false) { + return; + } + + if (touchState.current.isHorizontal === true) { + e.preventDefault(); + touchState.current.isDragging = true; + + let newOffset = deltaX; + + if (currentPage === 0 && deltaX > 0) { + newOffset = deltaX * rubberBandStrength; + } else if (currentPage === totalPages - 1 && deltaX < 0) { + newOffset = deltaX * rubberBandStrength; + } + + setOffset(newOffset); + } + }, + [currentPage, totalPages, rubberBandStrength, isTransitioning], + ); + + const handleTouchEnd = useCallback(() => { + if (!touchState.current.isDragging) { + return; + } + + const deltaX = offset; + let newPage = currentPage; + + if (Math.abs(deltaX) > threshold) { + if (deltaX > 0 && currentPage > 0) { + newPage = currentPage - 1; + } else if (deltaX < 0 && currentPage < totalPages - 1) { + newPage = currentPage + 1; + } + } + + setIsTransitioning(true); + setOffset(0); + touchState.current = { + startX: 0, + startY: 0, + currentX: 0, + currentY: 0, + isDragging: false, + isHorizontal: null, + }; + + if (newPage !== currentPage) { + onPageChange(newPage); + } + + setTimeout(() => { + setIsTransitioning(false); + }, 300); + }, [offset, currentPage, totalPages, threshold, onPageChange]); + + return { + containerRef, + offset, + isTransitioning, + handleTouchStart, + handleTouchMove, + handleTouchEnd, + }; +} diff --git a/tests/components/layout/AppLayout.test.tsx b/tests/components/layout/AppLayout.test.tsx new file mode 100644 index 00000000..94f208a4 --- /dev/null +++ b/tests/components/layout/AppLayout.test.tsx @@ -0,0 +1,431 @@ +import { act, fireEvent, render } from "@testing-library/react"; +import { BrowserRouter } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AppLayout } from "../../../src/components/layout/AppLayout"; +import useStore from "../../../src/store"; +import type { Channel, Server, User } from "../../../src/types"; + +vi.mock("@tauri-apps/plugin-os", () => ({ + platform: vi.fn(() => "linux"), +})); + +Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query) => ({ + matches: query === "(max-width: 768px)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}); + +const mockUsers: User[] = [ + { id: "1", username: "alice", isOnline: true }, + { id: "2", username: "bob", isOnline: true }, +]; + +const mockChannel: Channel = { + id: "channel1", + name: "#general", + topic: "General discussion", + isPrivate: false, + serverId: "server1", + unreadCount: 0, + isMentioned: false, + messages: [], + users: mockUsers, +}; + +const mockServer: Server = { + id: "server1", + name: "Test Server", + host: "irc.test.com", + port: 6667, + channels: [mockChannel], + privateChats: [], + isConnected: true, + users: mockUsers, +}; + +const renderAppLayout = () => { + return render( + + + , + ); +}; + +const createTouchEvent = ( + type: string, + clientX: number, + clientY: number, +): TouchEvent => { + const touch = { + clientX, + clientY, + screenX: clientX, + screenY: clientY, + pageX: clientX, + pageY: clientY, + identifier: 0, + target: document.createElement("div"), + } as Touch; + + return new TouchEvent(type, { + bubbles: true, + cancelable: true, + touches: type !== "touchend" ? [touch] : [], + targetTouches: type !== "touchend" ? [touch] : [], + changedTouches: [touch], + }); +}; + +describe("AppLayout Swipe Navigation", () => { + beforeEach(() => { + vi.useFakeTimers(); + useStore.setState({ + servers: [mockServer], + currentUser: { id: "user1", username: "testuser", isOnline: true }, + ui: { + selectedServerId: "server1", + perServerSelections: { + server1: { + selectedChannelId: "channel1", + selectedPrivateChatId: null, + }, + }, + isMemberListVisible: false, + isChannelListVisible: true, + isAddServerModalOpen: false, + isEditServerModalOpen: false, + editServerId: null, + isSettingsModalOpen: false, + isQuickActionsOpen: false, + isUserProfileModalOpen: false, + isDarkMode: true, + isMobileMenuOpen: false, + isChannelListModalOpen: false, + isChannelRenameModalOpen: false, + linkSecurityWarnings: [], + mobileViewActiveColumn: "serverList", + isServerMenuOpen: false, + contextMenu: { + isOpen: false, + x: 0, + y: 0, + type: "server", + itemId: null, + }, + prefillServerDetails: null, + inputAttachments: [], + isServerNoticesPopupOpen: false, + serverNoticesPopupMinimized: false, + profileViewRequest: null, + settingsNavigation: null, + }, + messages: {}, + isConnecting: false, + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + describe("Mobile view (narrow screen)", () => { + beforeEach(() => { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query) => ({ + matches: query === "(max-width: 768px)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); + }); + + it("should start on serverList page", () => { + renderAppLayout(); + expect(useStore.getState().ui.mobileViewActiveColumn).toBe("serverList"); + }); + + it("should change to chatView when swiping left", () => { + renderAppLayout(); + const container = document.querySelector( + ".relative.w-full.h-full.overflow-hidden", + ); + expect(container).toBeTruthy(); + + act(() => { + fireEvent( + container as Element, + createTouchEvent("touchstart", 200, 100), + ); + }); + + act(() => { + fireEvent( + container as Element, + createTouchEvent("touchmove", 100, 100), + ); + }); + + act(() => { + fireEvent(container as Element, createTouchEvent("touchend", 100, 100)); + }); + + act(() => { + vi.advanceTimersByTime(300); + }); + + expect(useStore.getState().ui.mobileViewActiveColumn).toBe("chatView"); + }); + + it("should change to memberList when swiping left from chatView", () => { + useStore.setState({ + ui: { + ...useStore.getState().ui, + mobileViewActiveColumn: "chatView", + }, + }); + + renderAppLayout(); + const container = document.querySelector( + ".relative.w-full.h-full.overflow-hidden", + ); + + act(() => { + fireEvent( + container as Element, + createTouchEvent("touchstart", 200, 100), + ); + }); + + act(() => { + fireEvent( + container as Element, + createTouchEvent("touchmove", 100, 100), + ); + }); + + act(() => { + fireEvent(container as Element, createTouchEvent("touchend", 100, 100)); + }); + + act(() => { + vi.advanceTimersByTime(300); + }); + + expect(useStore.getState().ui.mobileViewActiveColumn).toBe("memberList"); + }); + + it("should go back to chatView when swiping right from memberList", () => { + useStore.setState({ + ui: { + ...useStore.getState().ui, + mobileViewActiveColumn: "memberList", + isMemberListVisible: true, + }, + }); + + renderAppLayout(); + const container = document.querySelector( + ".relative.w-full.h-full.overflow-hidden", + ); + + act(() => { + fireEvent( + container as Element, + createTouchEvent("touchstart", 100, 100), + ); + }); + + act(() => { + fireEvent( + container as Element, + createTouchEvent("touchmove", 200, 100), + ); + }); + + act(() => { + fireEvent(container as Element, createTouchEvent("touchend", 200, 100)); + }); + + act(() => { + vi.advanceTimersByTime(300); + }); + + expect(useStore.getState().ui.mobileViewActiveColumn).toBe("chatView"); + }); + + it("should not change page when swipe is below threshold", () => { + renderAppLayout(); + const container = document.querySelector( + ".relative.w-full.h-full.overflow-hidden", + ); + + const initialColumn = useStore.getState().ui.mobileViewActiveColumn; + + act(() => { + fireEvent( + container as Element, + createTouchEvent("touchstart", 100, 100), + ); + }); + + act(() => { + fireEvent( + container as Element, + createTouchEvent("touchmove", 120, 100), + ); + }); + + act(() => { + fireEvent(container as Element, createTouchEvent("touchend", 120, 100)); + }); + + expect(useStore.getState().ui.mobileViewActiveColumn).toBe(initialColumn); + }); + + it("should have 3 pages when server is selected", () => { + renderAppLayout(); + const pages = document.querySelectorAll("[data-swipe-page]"); + expect(pages.length).toBe(3); + }); + + it("should have 2 pages when no server is selected", () => { + useStore.setState({ + ui: { + ...useStore.getState().ui, + selectedServerId: null, + }, + }); + + renderAppLayout(); + const pages = document.querySelectorAll("[data-swipe-page]"); + expect(pages.length).toBe(2); + }); + }); + + describe("Desktop view (wide screen)", () => { + beforeEach(() => { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query) => ({ + matches: query !== "(max-width: 768px)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); + }); + + it("should not render swipe container on desktop", () => { + renderAppLayout(); + const swipeContainer = document.querySelector( + ".relative.w-full.h-full.overflow-hidden", + ); + expect(swipeContainer).toBeFalsy(); + }); + + it("should render all columns side by side", () => { + renderAppLayout(); + const serverList = document.querySelector(".server-list"); + const channelList = document.querySelector(".channel-list"); + expect(serverList).toBeTruthy(); + expect(channelList).toBeTruthy(); + }); + }); + + describe("Android back button", () => { + beforeEach(() => { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query) => ({ + matches: query === "(max-width: 768px)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); + + vi.mock("@tauri-apps/plugin-os", () => ({ + platform: vi.fn(() => "android"), + })); + + Object.defineProperty(window, "__TAURI__", { + value: true, + writable: true, + }); + }); + + it("should navigate from chatView to serverList on back", () => { + useStore.setState({ + ui: { + ...useStore.getState().ui, + mobileViewActiveColumn: "chatView", + }, + }); + + renderAppLayout(); + + // @ts-expect-error - androidBackCallback is dynamically added + const result = window.androidBackCallback?.(); + + expect(useStore.getState().ui.mobileViewActiveColumn).toBe("serverList"); + expect(result).toBe(false); + }); + + it("should navigate from memberList to chatView on back", () => { + useStore.setState({ + ui: { + ...useStore.getState().ui, + mobileViewActiveColumn: "memberList", + isMemberListVisible: true, + }, + }); + + renderAppLayout(); + + // @ts-expect-error - androidBackCallback is dynamically added + const result = window.androidBackCallback?.(); + + expect(useStore.getState().ui.mobileViewActiveColumn).toBe("chatView"); + expect(result).toBe(false); + }); + + it("should allow app exit from serverList on back", () => { + useStore.setState({ + ui: { + ...useStore.getState().ui, + mobileViewActiveColumn: "serverList", + }, + }); + + renderAppLayout(); + + // @ts-expect-error - androidBackCallback is dynamically added + const result = window.androidBackCallback?.(); + + expect(result).toBe(true); + }); + }); +}); diff --git a/tests/hooks/useSwipeNavigation.test.ts b/tests/hooks/useSwipeNavigation.test.ts new file mode 100644 index 00000000..2f66cce5 --- /dev/null +++ b/tests/hooks/useSwipeNavigation.test.ts @@ -0,0 +1,491 @@ +import { act, renderHook } from "@testing-library/react"; +import type React from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useSwipeNavigation } from "../../src/hooks/useSwipeNavigation"; + +describe("useSwipeNavigation", () => { + let onPageChange: ReturnType; + + beforeEach(() => { + onPageChange = vi.fn(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + const createTouchEvent = ( + type: "touchstart" | "touchmove" | "touchend", + clientX: number, + clientY: number, + ): React.TouchEvent => { + const event = { + touches: type !== "touchend" ? [{ clientX, clientY }] : [], + preventDefault: vi.fn(), + } as unknown as React.TouchEvent; + return event; + }; + + it("should initialize with correct default values", () => { + const { result } = renderHook(() => + useSwipeNavigation({ + currentPage: 0, + totalPages: 3, + onPageChange, + }), + ); + + expect(result.current.offset).toBe(0); + expect(result.current.isTransitioning).toBe(false); + expect(result.current.containerRef.current).toBeNull(); + }); + + it("should use custom threshold and rubberBandStrength", () => { + const { result } = renderHook(() => + useSwipeNavigation({ + currentPage: 0, + totalPages: 3, + onPageChange, + threshold: 100, + rubberBandStrength: 0.5, + }), + ); + + expect(result.current).toBeDefined(); + }); + + describe("Direction locking", () => { + it("should detect horizontal swipe when horizontal movement is greater", () => { + const { result } = renderHook(() => + useSwipeNavigation({ + currentPage: 1, + totalPages: 3, + onPageChange, + }), + ); + + act(() => { + result.current.handleTouchStart( + createTouchEvent("touchstart", 100, 100), + ); + }); + + act(() => { + result.current.handleTouchMove(createTouchEvent("touchmove", 130, 105)); + }); + + expect(result.current.offset).not.toBe(0); + }); + + it("should not handle swipe when vertical movement is greater", () => { + const { result } = renderHook(() => + useSwipeNavigation({ + currentPage: 1, + totalPages: 3, + onPageChange, + }), + ); + + act(() => { + result.current.handleTouchStart( + createTouchEvent("touchstart", 100, 100), + ); + }); + + act(() => { + result.current.handleTouchMove(createTouchEvent("touchmove", 105, 130)); + }); + + expect(result.current.offset).toBe(0); + }); + + it("should lock direction after first significant movement", () => { + const { result } = renderHook(() => + useSwipeNavigation({ + currentPage: 1, + totalPages: 3, + onPageChange, + }), + ); + + act(() => { + result.current.handleTouchStart( + createTouchEvent("touchstart", 100, 100), + ); + }); + + act(() => { + result.current.handleTouchMove(createTouchEvent("touchmove", 130, 100)); + }); + + const firstOffset = result.current.offset; + expect(firstOffset).not.toBe(0); + + act(() => { + result.current.handleTouchMove(createTouchEvent("touchmove", 140, 200)); + }); + + expect(result.current.offset).not.toBe(firstOffset); + expect(result.current.offset).toBeGreaterThan(0); + }); + }); + + describe("Page changes", () => { + it("should change page forward when swiping left beyond threshold", () => { + const { result } = renderHook(() => + useSwipeNavigation({ + currentPage: 1, + totalPages: 3, + onPageChange, + threshold: 50, + }), + ); + + act(() => { + result.current.handleTouchStart( + createTouchEvent("touchstart", 100, 100), + ); + }); + + act(() => { + result.current.handleTouchMove(createTouchEvent("touchmove", 30, 100)); + }); + + act(() => { + result.current.handleTouchEnd(createTouchEvent("touchend", 0, 0)); + }); + + expect(onPageChange).toHaveBeenCalledWith(2); + }); + + it("should change page backward when swiping right beyond threshold", () => { + const { result } = renderHook(() => + useSwipeNavigation({ + currentPage: 1, + totalPages: 3, + onPageChange, + threshold: 50, + }), + ); + + act(() => { + result.current.handleTouchStart( + createTouchEvent("touchstart", 100, 100), + ); + }); + + act(() => { + result.current.handleTouchMove(createTouchEvent("touchmove", 170, 100)); + }); + + act(() => { + result.current.handleTouchEnd(createTouchEvent("touchend", 0, 0)); + }); + + expect(onPageChange).toHaveBeenCalledWith(0); + }); + + it("should not change page when swipe is below threshold", () => { + const { result } = renderHook(() => + useSwipeNavigation({ + currentPage: 1, + totalPages: 3, + onPageChange, + threshold: 50, + }), + ); + + act(() => { + result.current.handleTouchStart( + createTouchEvent("touchstart", 100, 100), + ); + }); + + act(() => { + result.current.handleTouchMove(createTouchEvent("touchmove", 130, 100)); + }); + + act(() => { + result.current.handleTouchEnd(createTouchEvent("touchend", 0, 0)); + }); + + expect(onPageChange).not.toHaveBeenCalled(); + }); + + it("should not change to page before first page", () => { + const { result } = renderHook(() => + useSwipeNavigation({ + currentPage: 0, + totalPages: 3, + onPageChange, + }), + ); + + act(() => { + result.current.handleTouchStart( + createTouchEvent("touchstart", 100, 100), + ); + }); + + act(() => { + result.current.handleTouchMove(createTouchEvent("touchmove", 200, 100)); + }); + + act(() => { + result.current.handleTouchEnd(createTouchEvent("touchend", 0, 0)); + }); + + expect(onPageChange).not.toHaveBeenCalled(); + }); + + it("should not change to page after last page", () => { + const { result } = renderHook(() => + useSwipeNavigation({ + currentPage: 2, + totalPages: 3, + onPageChange, + }), + ); + + act(() => { + result.current.handleTouchStart( + createTouchEvent("touchstart", 100, 100), + ); + }); + + act(() => { + result.current.handleTouchMove(createTouchEvent("touchmove", 0, 100)); + }); + + act(() => { + result.current.handleTouchEnd(createTouchEvent("touchend", 0, 0)); + }); + + expect(onPageChange).not.toHaveBeenCalled(); + }); + }); + + describe("Rubber-band effect", () => { + it("should apply rubber-band resistance at first page when swiping right", () => { + const { result } = renderHook(() => + useSwipeNavigation({ + currentPage: 0, + totalPages: 3, + onPageChange, + rubberBandStrength: 0.3, + }), + ); + + act(() => { + result.current.handleTouchStart( + createTouchEvent("touchstart", 100, 100), + ); + }); + + act(() => { + result.current.handleTouchMove(createTouchEvent("touchmove", 200, 100)); + }); + + expect(result.current.offset).toBe(100 * 0.3); + }); + + it("should apply rubber-band resistance at last page when swiping left", () => { + const { result } = renderHook(() => + useSwipeNavigation({ + currentPage: 2, + totalPages: 3, + onPageChange, + rubberBandStrength: 0.3, + }), + ); + + act(() => { + result.current.handleTouchStart( + createTouchEvent("touchstart", 100, 100), + ); + }); + + act(() => { + result.current.handleTouchMove(createTouchEvent("touchmove", 0, 100)); + }); + + expect(result.current.offset).toBe(-100 * 0.3); + }); + + it("should not apply rubber-band on middle pages", () => { + const { result } = renderHook(() => + useSwipeNavigation({ + currentPage: 1, + totalPages: 3, + onPageChange, + rubberBandStrength: 0.3, + }), + ); + + act(() => { + result.current.handleTouchStart( + createTouchEvent("touchstart", 100, 100), + ); + }); + + act(() => { + result.current.handleTouchMove(createTouchEvent("touchmove", 200, 100)); + }); + + expect(result.current.offset).toBe(100); + }); + }); + + describe("Transition state", () => { + it("should set isTransitioning to true after touch end", () => { + const { result } = renderHook(() => + useSwipeNavigation({ + currentPage: 1, + totalPages: 3, + onPageChange, + }), + ); + + act(() => { + result.current.handleTouchStart( + createTouchEvent("touchstart", 100, 100), + ); + }); + + act(() => { + result.current.handleTouchMove(createTouchEvent("touchmove", 30, 100)); + }); + + act(() => { + result.current.handleTouchEnd(createTouchEvent("touchend", 0, 0)); + }); + + expect(result.current.isTransitioning).toBe(true); + }); + + it("should reset isTransitioning after timeout", () => { + const { result } = renderHook(() => + useSwipeNavigation({ + currentPage: 1, + totalPages: 3, + onPageChange, + }), + ); + + act(() => { + result.current.handleTouchStart( + createTouchEvent("touchstart", 100, 100), + ); + }); + + act(() => { + result.current.handleTouchMove(createTouchEvent("touchmove", 30, 100)); + }); + + act(() => { + result.current.handleTouchEnd(createTouchEvent("touchend", 0, 0)); + }); + + expect(result.current.isTransitioning).toBe(true); + + act(() => { + vi.advanceTimersByTime(300); + }); + + expect(result.current.isTransitioning).toBe(false); + }); + + it("should ignore touch move when isTransitioning is true", () => { + const { result } = renderHook(() => + useSwipeNavigation({ + currentPage: 1, + totalPages: 3, + onPageChange, + }), + ); + + act(() => { + result.current.handleTouchStart( + createTouchEvent("touchstart", 100, 100), + ); + }); + + act(() => { + result.current.handleTouchMove(createTouchEvent("touchmove", 30, 100)); + }); + + act(() => { + result.current.handleTouchEnd(createTouchEvent("touchend", 0, 0)); + }); + + const offsetAfterEnd = result.current.offset; + + act(() => { + result.current.handleTouchStart( + createTouchEvent("touchstart", 100, 100), + ); + }); + + act(() => { + result.current.handleTouchMove(createTouchEvent("touchmove", 200, 100)); + }); + + expect(result.current.offset).toBe(offsetAfterEnd); + }); + }); + + describe("Touch state reset", () => { + it("should reset offset to 0 after touch end", () => { + const { result } = renderHook(() => + useSwipeNavigation({ + currentPage: 1, + totalPages: 3, + onPageChange, + }), + ); + + act(() => { + result.current.handleTouchStart( + createTouchEvent("touchstart", 100, 100), + ); + }); + + act(() => { + result.current.handleTouchMove(createTouchEvent("touchmove", 130, 100)); + }); + + expect(result.current.offset).not.toBe(0); + + act(() => { + result.current.handleTouchEnd(createTouchEvent("touchend", 0, 0)); + }); + + expect(result.current.offset).toBe(0); + }); + + it("should not trigger touch end actions if not dragging", () => { + const { result } = renderHook(() => + useSwipeNavigation({ + currentPage: 1, + totalPages: 3, + onPageChange, + }), + ); + + act(() => { + result.current.handleTouchStart( + createTouchEvent("touchstart", 100, 100), + ); + }); + + act(() => { + result.current.handleTouchEnd(createTouchEvent("touchend", 0, 0)); + }); + + expect(onPageChange).not.toHaveBeenCalled(); + expect(result.current.isTransitioning).toBe(false); + }); + }); +}); From 94d3bc9ad7bf13d023ea5e16e5aee27b2f8fc1d9 Mon Sep 17 00:00:00 2001 From: Matheus Fillipe Date: Tue, 6 Jan 2026 23:39:37 -0300 Subject: [PATCH 08/29] fix typescript build errors --- tests/components/layout/AppLayout.test.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/components/layout/AppLayout.test.tsx b/tests/components/layout/AppLayout.test.tsx index 94f208a4..ca279ff6 100644 --- a/tests/components/layout/AppLayout.test.tsx +++ b/tests/components/layout/AppLayout.test.tsx @@ -73,7 +73,11 @@ const createTouchEvent = ( pageY: clientY, identifier: 0, target: document.createElement("div"), - } as Touch; + force: 0, + radiusX: 0, + radiusY: 0, + rotationAngle: 0, + } as unknown as Touch; return new TouchEvent(type, { bubbles: true, @@ -126,6 +130,7 @@ describe("AppLayout Swipe Navigation", () => { serverNoticesPopupMinimized: false, profileViewRequest: null, settingsNavigation: null, + shouldFocusChatInput: false, }, messages: {}, isConnecting: false, From 9e57bb5e190988d612dce458f433321adc146a98 Mon Sep 17 00:00:00 2001 From: Matheus Fillipe Date: Wed, 21 Jan 2026 23:21:16 +0100 Subject: [PATCH 09/29] 08:49 < user379> the three-dots menu when on a narrow screen doesn't open any menu --- src/components/layout/ChatHeader.tsx | 83 ++++++++++++------------ src/components/ui/HeaderOverflowMenu.tsx | 5 +- 2 files changed, 47 insertions(+), 41 deletions(-) diff --git a/src/components/layout/ChatHeader.tsx b/src/components/layout/ChatHeader.tsx index f779f380..e639f760 100644 --- a/src/components/layout/ChatHeader.tsx +++ b/src/components/layout/ChatHeader.tsx @@ -88,6 +88,9 @@ export const ChatHeader: React.FC = ({ const overflowButtonRef = useRef(null); const servers = useStore((state) => state.servers); + const mobileViewActiveColumn = useStore( + (state) => state.ui.mobileViewActiveColumn, + ); // Get global settings for media controls const { showSafeMedia, showExternalContent } = useStore( @@ -240,12 +243,12 @@ export const ChatHeader: React.FC = ({ ); })(); - // Reset search expanded state when channel changes - // biome-ignore lint/correctness/useExhaustiveDependencies: Need to reset when channel changes + // Reset search expanded state and overflow menu when channel or mobile view changes + // biome-ignore lint/correctness/useExhaustiveDependencies: Need to reset when channel or page changes useEffect(() => { setIsSearchExpanded(false); setIsOverflowMenuOpen(false); - }, [selectedChannelId]); + }, [selectedChannelId, mobileViewActiveColumn]); // Define overflow menu items based on context const overflowMenuItems: HeaderOverflowMenuItem[] = [ @@ -634,7 +637,43 @@ export const ChatHeader: React.FC = ({ )} - {/* Search - icon on mobile, input on desktop */} + {/* Desktop - action buttons */} + {selectedChannel && ( + <> + + + + )} + + {selectedChannel && isOperator && ( + + )} + + {/* Search - icon on mobile, input on desktop (rightmost on desktop) */}
- - {/* Desktop - original buttons */} - {selectedChannel && ( - <> - - - - )} - - {selectedChannel && isOperator && ( - - )}
)} diff --git a/src/components/ui/HeaderOverflowMenu.tsx b/src/components/ui/HeaderOverflowMenu.tsx index 02a599f7..13a835dd 100644 --- a/src/components/ui/HeaderOverflowMenu.tsx +++ b/src/components/ui/HeaderOverflowMenu.tsx @@ -1,5 +1,6 @@ import type React from "react"; import { type ReactNode, useEffect, useRef } from "react"; +import { createPortal } from "react-dom"; export interface HeaderOverflowMenuItem { label: string; @@ -67,7 +68,7 @@ export const HeaderOverflowMenu: React.FC = ({ onClose(); }; - return ( + const menuContent = (
= ({
); + + return createPortal(menuContent, document.body); }; export default HeaderOverflowMenu; From 4fddaf4c304bf0a5663db192c7cde1bf7f2e11b2 Mon Sep 17 00:00:00 2001 From: Matheus Fillipe Date: Wed, 21 Jan 2026 23:28:36 +0100 Subject: [PATCH 10/29] 08:49 < user379> "Account settings are only available in hosted chat mode." 08:49 < user379> what 08:49 < Valware> why though 08:49 < Valware> =] --- src/components/ui/UserSettings.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/components/ui/UserSettings.tsx b/src/components/ui/UserSettings.tsx index 72663e1f..1f05b411 100644 --- a/src/components/ui/UserSettings.tsx +++ b/src/components/ui/UserSettings.tsx @@ -179,7 +179,6 @@ export const UserSettings: React.FC = React.memo(() => { () => (currentServer ? serverSupportsMetadata(currentServer.id) : false), [currentServer], ); - const isHostedChatMode = __HIDE_SERVER_LIST__; const isMobile = useMediaQuery("(max-width: 768px)"); // Category state @@ -631,8 +630,8 @@ export const UserSettings: React.FC = React.memo(() => { reader.readAsDataURL(notificationSoundFile); } - // Save oper settings if in hosted chat mode - if (isHostedChatMode && serverConfig) { + // Save oper settings for the current server + if (serverConfig) { updateServer(serverConfig.id, { ...serverConfig, operUsername: operName, @@ -657,7 +656,6 @@ export const UserSettings: React.FC = React.memo(() => { currentUser, settings, notificationSoundFile, - isHostedChatMode, serverConfig, operName, operOnConnect, @@ -1007,10 +1005,10 @@ export const UserSettings: React.FC = React.memo(() => { // Render account settings const renderAccountFields = () => { - if (!isHostedChatMode) { + if (!currentServer || !serverConfig) { return (
- Account settings are only available in hosted chat mode. + Connect to a server to manage operator settings.
); } From 9afdeaed2ea702d327d265c27db8e1d9aab76197 Mon Sep 17 00:00:00 2001 From: Matheus Fillipe Date: Wed, 21 Jan 2026 23:54:56 +0100 Subject: [PATCH 11/29] 08:49 < Valware> also none of the ircs:// are able to be reconnected to when I close and reopen the app --- src/store/index.ts | 113 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 86 insertions(+), 27 deletions(-) diff --git a/src/store/index.ts b/src/store/index.ts index 3e756866..008172c9 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -32,6 +32,32 @@ const LOCAL_STORAGE_SETTINGS_KEY = "globalSettings"; const LOCAL_STORAGE_CHANNEL_ORDER_KEY = "channelOrder"; const LOCAL_STORAGE_PINNED_PMS_KEY = "pinnedPrivateChats"; +// Helper function to normalize host for comparison (extract hostname from URL or return as-is) +function normalizeHost(host: string): string { + if (host.includes("://")) { + // Extract hostname from URL format + const withoutProtocol = host.replace(/^(irc|ircs|ws|wss):\/\//, ""); + return withoutProtocol.split(":")[0]; // Get just hostname, strip port if present + } + return host; +} + +// Helper function to ensure host is in URL format +function ensureUrlFormat(host: string, port: number): string { + if (host.includes("://")) { + return host; // Already in URL format + } + // Convert old hostname-only format to URL + const isLocalhost = + host === "localhost" || host === "127.0.0.1" || host === "::1"; + const scheme = isLocalhost + ? "ws" + : port === 6697 || port === 9999 || port === 443 || port === 993 + ? "wss" + : "ws"; + return `${scheme}://${host}:${port}`; +} + // Type for saved metadata structure: serverId -> target -> key -> metadata type SavedMetadata = Record< string, @@ -896,7 +922,10 @@ const useStore = create((set, get) => ({ // Check if already connected to this server const state = get(); const existingServer = state.servers.find( - (s) => s.host === host && s.port === port && s.isConnected, + (s) => + normalizeHost(s.host) === normalizeHost(host) && + s.port === port && + s.isConnected, ); if (existingServer) { // Already connected, just return the existing server @@ -909,7 +938,7 @@ const useStore = create((set, get) => ({ // Look up saved server to get its ID const existingSavedServers: ServerConfig[] = loadSavedServers(); const existingSavedServer = existingSavedServers.find( - (s) => s.host === host && s.port === port, + (s) => normalizeHost(s.host) === normalizeHost(host) && s.port === port, ); const server = await ircClient.connect( @@ -925,18 +954,27 @@ const useStore = create((set, get) => ({ // Save server to localStorage const savedServers: ServerConfig[] = loadSavedServers(); + + // Ensure host is in URL format for storage + const urlHost = ensureUrlFormat(host, port); + + // Find existing server using normalized comparison const savedServer = savedServers.find( - (s) => s.host === host && s.port === port, + (s) => + normalizeHost(s.host) === normalizeHost(urlHost) && s.port === port, ); const channelsToJoin = savedServer?.channels || []; + // Remove existing server entry using normalized comparison const updatedServers = savedServers.filter( - (s) => s.host !== host || s.port !== port, + (s) => + normalizeHost(s.host) !== normalizeHost(urlHost) || s.port !== port, ); + updatedServers.push({ - id: server.id, // Include the server ID here - name: server.name, // Save the server name - host, + id: server.id, + name: server.name, + host: urlHost, // Always save as full URL port, nickname, saslEnabled: !!saslPassword, @@ -955,7 +993,9 @@ const useStore = create((set, get) => ({ set((state) => { const existingServerIndex = state.servers.findIndex( - (s) => s.host === host && s.port === port, + (s) => + normalizeHost(s.host) === normalizeHost(server.host) && + s.port === port, ); if (existingServerIndex !== -1) { const updatedServers = [...state.servers]; @@ -981,7 +1021,8 @@ const useStore = create((set, get) => ({ if (isLocalhost) { const savedServers = loadSavedServers(); const serverConfig = savedServers.find( - (s) => s.host === host && s.port === port, + (s) => + normalizeHost(s.host) === normalizeHost(host) && s.port === port, ); // Only show warning if not already skipped @@ -1033,7 +1074,8 @@ const useStore = create((set, get) => ({ set((state) => { const existingServerIndex = state.servers.findIndex( - (s) => s.host === host && s.port === port, + (s) => + normalizeHost(s.host) === normalizeHost(host) && s.port === port, ); if (existingServerIndex !== -1) { // Update existing server to disconnected @@ -1142,7 +1184,9 @@ const useStore = create((set, get) => ({ const currentServer = state.servers.find((s) => s.id === serverId); const savedServer = savedServers.find( (s) => - s.host === currentServer?.host && s.port === currentServer?.port, + normalizeHost(s.host) === + normalizeHost(currentServer?.host || "") && + s.port === currentServer?.port, ); if (savedServer && !savedServer.channels.includes(channel.name)) { savedServer.channels.push(channel.name); @@ -1215,7 +1259,9 @@ const useStore = create((set, get) => ({ const savedServers = loadSavedServers(); const currentServer = updatedServers.find((s) => s.id === serverId); const savedServer = savedServers.find( - (s) => s.host === currentServer?.host && s.port === currentServer?.port, + (s) => + normalizeHost(s.host) === normalizeHost(currentServer?.host || "") && + s.port === currentServer?.port, ); if (savedServer) { savedServer.channels = currentServer?.channels.map((c) => c.name) || []; @@ -1627,7 +1673,9 @@ const useStore = create((set, get) => ({ if (server) { const savedServers = loadSavedServers(); const savedServer = savedServers.find( - (s) => s.host === server.host && s.port === server.port, + (s) => + normalizeHost(s.host) === normalizeHost(server.host) && + s.port === server.port, ); if (savedServer) { @@ -2113,17 +2161,21 @@ const useStore = create((set, get) => ({ saslPassword, } = savedServer; - // Check if server already exists in store + // Ensure host is in URL format (handles old hostname-only entries) + const urlHost = ensureUrlFormat(host, port); + + // Check if server already exists in store using normalized comparison const existingServer = get().servers.find( - (s) => s.host === host && s.port === port, + (s) => + normalizeHost(s.host) === normalizeHost(urlHost) && s.port === port, ); if (!existingServer) { // Add server to store with connecting state const connectingServer: Server = { id, - name: name || host, - host, + name: name || normalizeHost(urlHost), + host: normalizeHost(urlHost), // Store normalized hostname in state port, channels: [], privateChats: [], @@ -2139,8 +2191,8 @@ const useStore = create((set, get) => ({ try { await get().connect( - name || host, - host, + name || normalizeHost(urlHost), + urlHost, // Use full URL port, nickname, saslEnabled, @@ -2149,11 +2201,11 @@ const useStore = create((set, get) => ({ saslPassword, ); } catch (error) { - console.error(`Failed to reconnect to server ${host}:${port}`, error); - // Update server state to disconnected + console.error(`Failed to reconnect to server ${urlHost}`, error); + // Update server state to disconnected using normalized comparison set((state) => ({ servers: state.servers.map((s) => - s.host === host && s.port === port + normalizeHost(s.host) === normalizeHost(urlHost) && s.port === port ? { ...s, connectionState: "disconnected" as const } : s, ), @@ -2183,7 +2235,9 @@ const useStore = create((set, get) => ({ // Get saved server config to get credentials const savedServers = loadSavedServers(); const savedServer = savedServers.find( - (s) => s.host === server.host && s.port === server.port, + (s) => + normalizeHost(s.host) === normalizeHost(server.host) && + s.port === server.port, ); if (!savedServer) { @@ -2195,9 +2249,12 @@ const useStore = create((set, get) => ({ throw new Error(`No saved configuration found for server ${serverId}`); } + // Ensure host is in URL format (handles old hostname-only entries) + const urlHost = ensureUrlFormat(savedServer.host, savedServer.port); + await get().connect( - savedServer.name || savedServer.host, - savedServer.host, + savedServer.name || normalizeHost(savedServer.host), + urlHost, // Use full URL savedServer.port, savedServer.nickname, savedServer.saslEnabled, @@ -2230,7 +2287,8 @@ const useStore = create((set, get) => ({ const savedServers = loadSavedServers(); const updatedServers = savedServers.filter( (s) => - s.host !== serverToDelete?.host || s.port !== serverToDelete?.port, + normalizeHost(s.host) !== normalizeHost(serverToDelete?.host || "") || + s.port !== serverToDelete?.port, ); saveServersToLocalStorage(updatedServers); @@ -5770,7 +5828,8 @@ ircClient.on("CAP LS", ({ serverId, cliCaps }) => { const serverConfig = currentServer ? savedServers.find( (s) => - s.host === currentServer.host && s.port === currentServer.port, + normalizeHost(s.host) === normalizeHost(currentServer.host) && + s.port === currentServer.port, ) : undefined; From 922933528357234dc4972c51d26a1febe42c10be Mon Sep 17 00:00:00 2001 From: Matheus Fillipe Date: Thu, 22 Jan 2026 14:22:14 +0100 Subject: [PATCH 12/29] avoid auto entering channels on mobile width --- src/store/index.ts | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/src/store/index.ts b/src/store/index.ts index 008172c9..e7cac480 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -31,6 +31,7 @@ const LOCAL_STORAGE_METADATA_KEY = "serverMetadata"; const LOCAL_STORAGE_SETTINGS_KEY = "globalSettings"; const LOCAL_STORAGE_CHANNEL_ORDER_KEY = "channelOrder"; const LOCAL_STORAGE_PINNED_PMS_KEY = "pinnedPrivateChats"; +const NARROW_VIEW_QUERY = "(max-width: 768px)"; // Helper function to normalize host for comparison (extract hostname from URL or return as-is) function normalizeHost(host: string): string { @@ -1496,13 +1497,17 @@ const useStore = create((set, get) => ({ // Find the server const server = state.servers.find((s) => s.id === serverId); - // Get the previously selected tab for this server, or default to first channel + const isNarrowView = window.matchMedia(NARROW_VIEW_QUERY).matches; const serverSelection = getServerSelection(state, serverId); let selectedChannelId = serverSelection.selectedChannelId; let selectedPrivateChatId = serverSelection.selectedPrivateChatId; - // If no previous selection or the selected items no longer exist, default to first channel - if (server) { + if (isNarrowView) { + // On mobile, never auto-select channels to avoid navigation state mismatch + selectedChannelId = null; + selectedPrivateChatId = null; + } else if (server) { + // On desktop, restore previous selection or select first channel const channelExists = selectedChannelId && server.channels.some((c) => c.id === selectedChannelId); @@ -1537,7 +1542,7 @@ const useStore = create((set, get) => ({ set((state) => { // Special case for server notices if (channelId === "server-notices") { - const isNarrowView = window.matchMedia("(max-width: 768px)").matches; + const isNarrowView = window.matchMedia(NARROW_VIEW_QUERY).matches; return { ui: { ...state.ui, @@ -1596,7 +1601,7 @@ const useStore = create((set, get) => ({ return server; }); - const isNarrowView = window.matchMedia("(max-width: 768px)").matches; + const isNarrowView = window.matchMedia(NARROW_VIEW_QUERY).matches; return { servers: updatedServers, ui: { @@ -1614,7 +1619,7 @@ const useStore = create((set, get) => ({ }; } - const isNarrowView = window.matchMedia("(max-width: 768px)").matches; + const isNarrowView = window.matchMedia(NARROW_VIEW_QUERY).matches; return { ui: { ...state.ui, @@ -1756,7 +1761,7 @@ const useStore = create((set, get) => ({ return server; }); - const isNarrowView = window.matchMedia("(max-width: 768px)").matches; + const isNarrowView = window.matchMedia(NARROW_VIEW_QUERY).matches; return { servers: updatedServers, ui: { @@ -1774,7 +1779,7 @@ const useStore = create((set, get) => ({ }; } - const isNarrowView = window.matchMedia("(max-width: 768px)").matches; + const isNarrowView = window.matchMedia(NARROW_VIEW_QUERY).matches; return { ui: { ...state.ui, @@ -2455,7 +2460,7 @@ const useStore = create((set, get) => ({ isOpen !== undefined ? isOpen : !state.ui.isMemberListVisible; // Only update mobileViewActiveColumn if actually in narrow view - const isNarrowView = window.matchMedia("(max-width: 768px)").matches; + const isNarrowView = window.matchMedia(NARROW_VIEW_QUERY).matches; return { ui: { @@ -2477,7 +2482,7 @@ const useStore = create((set, get) => ({ isOpen !== undefined ? isOpen : !state.ui.isChannelListVisible; // Only update mobileViewActiveColumn if actually in narrow view - const isNarrowView = window.matchMedia("(max-width: 768px)").matches; + const isNarrowView = window.matchMedia(NARROW_VIEW_QUERY).matches; return { ui: { @@ -2562,7 +2567,7 @@ const useStore = create((set, get) => ({ setMobileView: (view: layoutColumn) => { set((state) => { // Only execute in narrow view - const isNarrowView = window.matchMedia("(max-width: 768px)").matches; + const isNarrowView = window.matchMedia(NARROW_VIEW_QUERY).matches; if (!isNarrowView) return state; // Sync all related states based on the active column From de63a3c5f7ffb64e578927bc1d7ac468cf7c241f Mon Sep 17 00:00:00 2001 From: Matheus Fillipe Date: Fri, 23 Jan 2026 23:53:29 +0100 Subject: [PATCH 13/29] small fix for menus --- src/components/layout/ChatHeader.tsx | 55 ++++++++---- src/components/layout/MemberList.tsx | 1 + src/components/ui/TopicModal.tsx | 122 ++++++++++++++++++++++++++ src/components/ui/UserContextMenu.tsx | 5 +- src/hooks/useSwipeNavigation.ts | 6 ++ src/store/index.ts | 72 +++++++++------ 6 files changed, 214 insertions(+), 47 deletions(-) create mode 100644 src/components/ui/TopicModal.tsx diff --git a/src/components/layout/ChatHeader.tsx b/src/components/layout/ChatHeader.tsx index e639f760..4b01b6ec 100644 --- a/src/components/layout/ChatHeader.tsx +++ b/src/components/layout/ChatHeader.tsx @@ -31,6 +31,7 @@ import type { Channel, PrivateChat, User } from "../../types"; import HeaderOverflowMenu, { type HeaderOverflowMenuItem, } from "../ui/HeaderOverflowMenu"; +import TopicModal from "../ui/TopicModal"; import UserProfileModal from "../ui/UserProfileModal"; interface ChatHeaderProps { @@ -85,6 +86,7 @@ export const ChatHeader: React.FC = ({ const [userProfileModalOpen, setUserProfileModalOpen] = useState(false); const [isSearchExpanded, setIsSearchExpanded] = useState(false); const [isOverflowMenuOpen, setIsOverflowMenuOpen] = useState(false); + const [isTopicModalOpen, setIsTopicModalOpen] = useState(false); const overflowButtonRef = useRef(null); const servers = useStore((state) => state.servers); @@ -408,31 +410,37 @@ export const ChatHeader: React.FC = ({
); }; diff --git a/src/components/layout/MemberList.tsx b/src/components/layout/MemberList.tsx index b430ba9c..4618e3f5 100644 --- a/src/components/layout/MemberList.tsx +++ b/src/components/layout/MemberList.tsx @@ -152,6 +152,7 @@ const UserItem: React.FC<{ return (
{ const avatarElement = e.currentTarget.querySelector(".w-10.h-10"); diff --git a/src/components/ui/TopicModal.tsx b/src/components/ui/TopicModal.tsx new file mode 100644 index 00000000..3fef641c --- /dev/null +++ b/src/components/ui/TopicModal.tsx @@ -0,0 +1,122 @@ +import type React from "react"; +import { useState } from "react"; +import { createPortal } from "react-dom"; +import { FaTimes } from "react-icons/fa"; +import ircClient from "../../lib/ircClient"; +import { hasOpPermission } from "../../lib/ircUtils"; +import type { Channel, User } from "../../types"; + +interface TopicModalProps { + isOpen: boolean; + onClose: () => void; + channel: Channel; + serverId: string; + currentUser: User | null; +} + +export const TopicModal: React.FC = ({ + isOpen, + onClose, + channel, + serverId, + currentUser, +}) => { + const [editedTopic, setEditedTopic] = useState(channel.topic || ""); + const [isEditing, setIsEditing] = useState(false); + + const currentUserInChannel = channel.users.find( + (u) => u.username === currentUser?.username, + ); + const canEdit = hasOpPermission(currentUserInChannel?.status); + + const handleSave = () => { + if (serverId && channel) { + ircClient.setTopic(serverId, channel.name, editedTopic); + setIsEditing(false); + onClose(); + } + }; + + const handleCancel = () => { + setEditedTopic(channel.topic || ""); + setIsEditing(false); + }; + + if (!isOpen) return null; + + const modalContent = ( +
+
+
+

Channel Topic

+ +
+ +
+
+ + {isEditing ? ( +