From 532a6818e659b4755e13784ffa04e20b3e9d15e2 Mon Sep 17 00:00:00 2001 From: Matheus Fillipe Date: Thu, 12 Mar 2026 08:46:39 +0100 Subject: [PATCH 01/51] 0.2.6 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2d506fae..2dae5199 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "obsidianirc-client", - "version": "0.2.5", + "version": "0.2.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "obsidianirc-client", - "version": "0.2.5", + "version": "0.2.6", "dependencies": { "@heroicons/react": "^2.2.0", "@tauri-apps/api": "^2.0.0", diff --git a/package.json b/package.json index dea54a44..43eace02 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "obsidianirc-client", "private": true, - "version": "0.2.5", + "version": "0.2.6", "type": "module", "homepage": "https://github.com/ObsidianIRC/ObsidianIRC", "scripts": { From 8fb7414300feaa88cc7abcdd57e5925ce3313e22 Mon Sep 17 00:00:00 2001 From: Matheus Fillipe Date: Sat, 14 Mar 2026 01:14:05 +0100 Subject: [PATCH 02/51] Add image view modal --- src/components/message/MessageItem.tsx | 69 +++--- src/components/ui/ImageLightboxModal.tsx | 233 +++++++++++++++++++ src/lib/modal/components/Button.tsx | 3 + tests/components/ImageLightboxModal.test.tsx | 149 ++++++++++++ 4 files changed, 422 insertions(+), 32 deletions(-) create mode 100644 src/components/ui/ImageLightboxModal.tsx create mode 100644 tests/components/ImageLightboxModal.test.tsx diff --git a/src/components/message/MessageItem.tsx b/src/components/message/MessageItem.tsx index 942317da..ec420439 100644 --- a/src/components/message/MessageItem.tsx +++ b/src/components/message/MessageItem.tsx @@ -10,9 +10,9 @@ import { processMarkdownInText, } from "../../lib/ircUtils"; import { stripIrcFormatting } from "../../lib/messageFormatter"; -import { openExternalUrl } from "../../lib/openUrl"; import useStore, { loadSavedMetadata } from "../../store"; import type { MessageType, PrivateChat, User } from "../../types"; +import { ImageLightboxModal } from "../ui/ImageLightboxModal"; import { EnhancedLinkWrapper } from "../ui/LinkWrapper"; import type { CollapsibleMessageHandle } from "./CollapsibleMessage"; import { InviteMessage } from "./InviteMessage"; @@ -140,6 +140,7 @@ const ImageWithFallback: React.FC<{ }> = ({ url, isFilehostImage = false, serverId, onOpenProfile }) => { const [imageError, setImageError] = useState(false); const [imageLoaded, setImageLoaded] = useState(false); + const [lightboxOpen, setLightboxOpen] = useState(false); const [resolvedUrl, setResolvedUrl] = useState(null); const [exifData, setExifData] = useState<{ author?: string; @@ -300,39 +301,43 @@ const ImageWithFallback: React.FC<{ } return ( -
-
- {!imageLoaded && !imageError && ( -
- -
- )} - {isFilehostImage { - e.preventDefault(); - await openExternalUrl(url); - }} - onLoad={() => setImageLoaded(true)} - onError={() => setImageError(true)} - style={{ maxHeight: "150px" }} - /> - {isFilehostImage && exifData && imageLoaded && ( - + setLightboxOpen(false)} + /> +
+
+ {!imageLoaded && !imageError && ( +
+ +
+ )} + {isFilehostImage setLightboxOpen(true)} + onLoad={() => setImageLoaded(true)} + onError={() => setImageError(true)} + style={{ maxHeight: "150px" }} /> - )} + {isFilehostImage && exifData && imageLoaded && ( + + )} +
-
+ ); }; diff --git a/src/components/ui/ImageLightboxModal.tsx b/src/components/ui/ImageLightboxModal.tsx new file mode 100644 index 00000000..504243eb --- /dev/null +++ b/src/components/ui/ImageLightboxModal.tsx @@ -0,0 +1,233 @@ +import { XMarkIcon } from "@heroicons/react/24/solid"; +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { FaExternalLinkAlt, FaMinus, FaPlus } from "react-icons/fa"; +import { openExternalUrl } from "../../lib/openUrl"; +import ExternalLinkWarningModal from "./ExternalLinkWarningModal"; + +const ZOOM_STEP = 0.25; +export const ZOOM_MIN = 0.5; +export const ZOOM_MAX = 4; + +export function clampZoom(z: number): number { + return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z)); +} + +interface ImageLightboxModalProps { + isOpen: boolean; + url: string; + onClose: () => void; +} + +export function ImageLightboxModal({ + isOpen, + url, + onClose, +}: ImageLightboxModalProps) { + const [zoom, setZoom] = useState(1); + const [translate, setTranslate] = useState({ x: 0, y: 0 }); + const [isDragging, setIsDragging] = useState(false); + const [showWarning, setShowWarning] = useState(false); + const dragRef = useRef<{ + startMouseX: number; + startMouseY: number; + startTransX: number; + startTransY: number; + } | null>(null); + // Prevents the mouseup click event from firing the zoom toggle after a drag gesture + const hasDraggedRef = useRef(false); + + useEffect(() => { + if (isOpen) { + setZoom(1); + setTranslate({ x: 0, y: 0 }); + } + }, [isOpen]); + + useEffect(() => { + if (!isOpen) return; + const handleEsc = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + onClose(); + } + }; + document.addEventListener("keydown", handleEsc); + return () => document.removeEventListener("keydown", handleEsc); + }, [isOpen, onClose]); + + const changeZoom = (newZoom: number) => { + const clamped = clampZoom(newZoom); + setZoom(clamped); + if (clamped <= 1) setTranslate({ x: 0, y: 0 }); + }; + + const handleMouseDown = (e: React.MouseEvent) => { + if (zoom <= 1) return; + e.preventDefault(); + e.stopPropagation(); + hasDraggedRef.current = false; + setIsDragging(true); + dragRef.current = { + startMouseX: e.clientX, + startMouseY: e.clientY, + startTransX: translate.x, + startTransY: translate.y, + }; + }; + + const handleMouseMove = (e: React.MouseEvent) => { + if (!dragRef.current) return; + const dx = e.clientX - dragRef.current.startMouseX; + const dy = e.clientY - dragRef.current.startMouseY; + if (Math.abs(dx) > 3 || Math.abs(dy) > 3) { + hasDraggedRef.current = true; + } + setTranslate({ + x: dragRef.current.startTransX + dx, + y: dragRef.current.startTransY + dy, + }); + }; + + const stopDrag = () => { + dragRef.current = null; + setIsDragging(false); + }; + + const handleImageClick = (e: React.MouseEvent) => { + e.stopPropagation(); + if (hasDraggedRef.current) { + hasDraggedRef.current = false; + return; + } + changeZoom(zoom === 1 ? 2 : 1); + }; + + const handleConfirmOpen = async () => { + await openExternalUrl(url); + setShowWarning(false); + }; + + if (!isOpen) return null; + + const cursor = isDragging ? "grabbing" : zoom > 1 ? "zoom-out" : "zoom-in"; + const portalTarget = document.getElementById("root") ?? document.body; + + return createPortal( + <> + setShowWarning(false)} + /> + +
+