From 012ce95f27d57bea8911bd63bfb923443c5797ae Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 29 Mar 2026 19:09:13 -0500 Subject: [PATCH] enh: swipe to reply --- .../channel/Messages/Message.svelte | 151 ++++++++++++++++-- src/lib/components/channel/Navbar.svelte | 60 +++---- 2 files changed, 170 insertions(+), 41 deletions(-) diff --git a/src/lib/components/channel/Messages/Message.svelte b/src/lib/components/channel/Messages/Message.svelte index c1d1414f43..3923f9fb0d 100644 --- a/src/lib/components/channel/Messages/Message.svelte +++ b/src/lib/components/channel/Messages/Message.svelte @@ -65,6 +65,67 @@ let editedContent = null; let showDeleteConfirmDialog = false; + // Swipe-to-reply state + let swipeStartX = 0; + let swipeStartY = 0; + let swipeOffsetX = 0; + let isSwiping = false; + let swipeLocked = false; // locked to horizontal once determined + let swipeMessageEl: HTMLElement | null = null; + + const SWIPE_THRESHOLD = 60; + const SWIPE_MAX = 100; + const SWIPE_DEAD_ZONE = 10; + + const handleTouchStart = (e: TouchEvent) => { + if (disabled || edit || !onReply) return; + const touch = e.touches[0]; + swipeStartX = touch.clientX; + swipeStartY = touch.clientY; + swipeOffsetX = 0; + isSwiping = false; + swipeLocked = false; + }; + + const handleTouchMove = (e: TouchEvent) => { + if (disabled || edit || !onReply) return; + const touch = e.touches[0]; + const deltaX = touch.clientX - swipeStartX; + const deltaY = touch.clientY - swipeStartY; + + // Determine swipe direction from dead zone + if (!swipeLocked && (Math.abs(deltaX) > SWIPE_DEAD_ZONE || Math.abs(deltaY) > SWIPE_DEAD_ZONE)) { + if (Math.abs(deltaY) > Math.abs(deltaX)) { + // Vertical scroll — abort swipe tracking + isSwiping = false; + swipeLocked = true; + return; + } + // Horizontal swipe — lock in + swipeLocked = true; + isSwiping = true; + } + + if (!isSwiping) return; + + // Only allow right swipe + const clampedX = Math.max(0, deltaX); + // Dampen the motion beyond threshold for a rubber-band feel + swipeOffsetX = clampedX <= SWIPE_THRESHOLD + ? clampedX + : SWIPE_THRESHOLD + (clampedX - SWIPE_THRESHOLD) * 0.3; + swipeOffsetX = Math.min(swipeOffsetX, SWIPE_MAX); + }; + + const handleTouchEnd = () => { + if (isSwiping && swipeOffsetX >= SWIPE_THRESHOLD && onReply) { + onReply(message); + } + swipeOffsetX = 0; + isSwiping = false; + swipeLocked = false; + }; + const loadMessageData = async () => { if (message && message?.data === true) { const res = await getMessageData(localStorage.token, channel?.id, message.id); @@ -92,20 +153,39 @@ {#if message}
+ + {#if swipeOffsetX > 0} +
+
= SWIPE_THRESHOLD}> + +
+
+ {/if} + +
{#if !edit && !disabled}
+
{/if} @@ -561,4 +642,48 @@ background-color: transparent; } } + + /* Swipe-to-reply styles */ + .swipe-reply-wrapper { + touch-action: pan-y; + } + + .swipe-reply-indicator { + position: absolute; + left: 8px; + top: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 5; + pointer-events: none; + } + + .swipe-reply-icon { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 50%; + background-color: rgba(128, 128, 128, 0.15); + color: rgba(128, 128, 128, 0.8); + transition: background-color 0.15s, color 0.15s; + } + + .swipe-reply-icon--active { + background-color: rgba(59, 130, 246, 0.2); + color: rgb(59, 130, 246); + } + + :global(.dark) .swipe-reply-icon { + background-color: rgba(200, 200, 200, 0.1); + color: rgba(200, 200, 200, 0.6); + } + + :global(.dark) .swipe-reply-icon--active { + background-color: rgba(96, 165, 250, 0.2); + color: rgb(96, 165, 250); + } diff --git a/src/lib/components/channel/Navbar.svelte b/src/lib/components/channel/Navbar.svelte index a9c8043600..660b2175cc 100644 --- a/src/lib/components/channel/Navbar.svelte +++ b/src/lib/components/channel/Navbar.svelte @@ -153,7 +153,9 @@ {/if} -
+
{#if channel} @@ -173,17 +175,17 @@ {#if channel?.user_count !== undefined} - + + +
{/if}