enh: swipe to reply

This commit is contained in:
Timothy Jaeryang Baek
2026-03-29 19:09:13 -05:00
parent 6c2b2f2c3e
commit 012ce95f27
2 changed files with 170 additions and 41 deletions
@@ -65,6 +65,67 @@
let editedContent = null; let editedContent = null;
let showDeleteConfirmDialog = false; 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 () => { const loadMessageData = async () => {
if (message && message?.data === true) { if (message && message?.data === true) {
const res = await getMessageData(localStorage.token, channel?.id, message.id); const res = await getMessageData(localStorage.token, channel?.id, message.id);
@@ -92,20 +153,39 @@
{#if message} {#if message}
<div <div
id="message-{message.id}" class="swipe-reply-wrapper relative"
class="flex flex-col justify-between w-full max-w-full mx-auto group hover:bg-gray-300/5 dark:hover:bg-gray-700/5 transition relative {className on:touchstart={handleTouchStart}
? className on:touchmove={handleTouchMove}
: `px-5 ${ on:touchend={handleTouchEnd}
replyToMessage ? 'border-l-4 border-blue-500 bg-blue-100/10 dark:bg-blue-100/5 pl-4' : ''
} ${
(message?.reply_to_message?.meta?.model_id ?? message?.reply_to_message?.user_id) ===
$user?.id
? 'border-l-4 border-orange-500 bg-orange-100/10 dark:bg-orange-100/5 pl-4'
: ''
} ${message?.is_pinned ? 'bg-yellow-100/20 dark:bg-yellow-100/5' : ''}`} {showUserProfile
? 'pt-1.5 pb-0.5'
: ''}"
> >
<!-- Swipe reply indicator -->
{#if swipeOffsetX > 0}
<div
class="swipe-reply-indicator"
style="opacity: {Math.min(swipeOffsetX / SWIPE_THRESHOLD, 1)}; transform: scale({0.5 + Math.min(swipeOffsetX / SWIPE_THRESHOLD, 1) * 0.5});"
>
<div class="swipe-reply-icon" class:swipe-reply-icon--active={swipeOffsetX >= SWIPE_THRESHOLD}>
<ArrowUpLeftAlt className="size-5" />
</div>
</div>
{/if}
<div
id="message-{message.id}"
class="flex flex-col justify-between w-full max-w-full mx-auto group hover:bg-gray-300/5 dark:hover:bg-gray-700/5 relative {className
? className
: `px-5 ${
replyToMessage ? 'border-l-4 border-blue-500 bg-blue-100/10 dark:bg-blue-100/5 pl-4' : ''
} ${
(message?.reply_to_message?.meta?.model_id ?? message?.reply_to_message?.user_id) ===
$user?.id
? 'border-l-4 border-orange-500 bg-orange-100/10 dark:bg-orange-100/5 pl-4'
: ''
} ${message?.is_pinned ? 'bg-yellow-100/20 dark:bg-yellow-100/5' : ''}`} {showUserProfile
? 'pt-1.5 pb-0.5'
: ''}"
style="transform: translateX({swipeOffsetX}px); {swipeOffsetX > 0 ? '' : 'transition: transform 0.3s cubic-bezier(0.2, 0.9, 0.3, 1);'}"
>
{#if !edit && !disabled} {#if !edit && !disabled}
<div <div
class=" absolute {showButtons ? '' : 'invisible group-hover:visible'} right-1 -top-2 z-10" class=" absolute {showButtons ? '' : 'invisible group-hover:visible'} right-1 -top-2 z-10"
@@ -545,6 +625,7 @@
{/if} {/if}
</div> </div>
</div> </div>
</div>
</div> </div>
{/if} {/if}
@@ -561,4 +642,48 @@
background-color: transparent; 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);
}
</style> </style>
+32 -28
View File
@@ -153,7 +153,9 @@
{/if} {/if}
</div> </div>
<div class="self-start flex flex-none items-center text-gray-600 dark:text-gray-400 gap-1"> <div
class="self-start flex flex-none items-center text-gray-600 dark:text-gray-400 gap-1 shrink-0"
>
{#if channel} {#if channel}
<Tooltip content={$i18n.t('Pinned Messages')}> <Tooltip content={$i18n.t('Pinned Messages')}>
<button <button
@@ -164,7 +166,7 @@
showChannelPinnedMessagesModal = true; showChannelPinnedMessagesModal = true;
}} }}
> >
<div class=" flex items-center gap-0.5 m-auto self-center"> <div class=" flex items-center gap-0.5 m-auto self-center shrink-0">
<Pin className=" size-4" strokeWidth="1.5" /> <Pin className=" size-4" strokeWidth="1.5" />
</div> </div>
</button> </button>
@@ -173,17 +175,17 @@
{#if channel?.user_count !== undefined} {#if channel?.user_count !== undefined}
<Tooltip content={$i18n.t('Users')}> <Tooltip content={$i18n.t('Users')}>
<button <button
class=" flex cursor-pointer py-1 px-1.5 border dark:border-gray-850 border-gray-50 rounded-xl text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-850 transition" class=" flex cursor-pointer shrink-0 py-1 px-1.5 border dark:border-gray-850 border-gray-50 rounded-xl text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-850 transition"
aria-label="User Count" aria-label="User Count"
type="button" type="button"
on:click={() => { on:click={() => {
showChannelInfoModal = true; showChannelInfoModal = true;
}} }}
> >
<div class=" flex items-center gap-0.5 m-auto self-center"> <div class=" flex items-center gap-0.5 m-auto self-center shrink-0">
<UserAlt className=" size-4" strokeWidth="1.5" /> <UserAlt className=" size-4" strokeWidth="1.5" />
<div class="text-sm"> <div class="text-sm shrink-0">
{channel.user_count} {channel.user_count}
</div> </div>
</div> </div>
@@ -193,30 +195,32 @@
{/if} {/if}
{#if $user !== undefined} {#if $user !== undefined}
<UserMenu <div>
className="w-[240px]" <UserMenu
role={$user?.role} className="w-[240px]"
help={true} role={$user?.role}
on:show={(e) => { help={true}
if (e.detail === 'archived-chat') { on:show={(e) => {
showArchivedChats.set(true); if (e.detail === 'archived-chat') {
} showArchivedChats.set(true);
}} }
> }}
<button
class="select-none flex rounded-xl p-1.5 w-full hover:bg-gray-50 dark:hover:bg-gray-850 transition"
aria-label="User Menu"
> >
<div class=" self-center"> <button
<img class="select-none flex rounded-xl p-1.5 w-full hover:bg-gray-50 dark:hover:bg-gray-850 transition"
src={`${WEBUI_API_BASE_URL}/users/${$user?.id}/profile/image`} aria-label="User Menu"
class="size-6 object-cover rounded-full" >
alt="User profile" <div class=" self-center">
draggable="false" <img
/> src={`${WEBUI_API_BASE_URL}/users/${$user?.id}/profile/image`}
</div> class="size-6 object-cover rounded-full"
</button> alt="User profile"
</UserMenu> draggable="false"
/>
</div>
</button>
</UserMenu>
</div>
{/if} {/if}
</div> </div>
</div> </div>