enh: swipe to reply
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user