mirror of
https://github.com/open-webui/open-webui.git
synced 2026-08-28 08:25:01 -06:00
enh: swipe to reply
This commit is contained in:
@@ -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}
|
||||
<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 transition 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'
|
||||
: ''}"
|
||||
class="swipe-reply-wrapper relative"
|
||||
on:touchstart={handleTouchStart}
|
||||
on:touchmove={handleTouchMove}
|
||||
on:touchend={handleTouchEnd}
|
||||
>
|
||||
<!-- 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}
|
||||
<div
|
||||
class=" absolute {showButtons ? '' : 'invisible group-hover:visible'} right-1 -top-2 z-10"
|
||||
@@ -545,6 +625,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/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);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -153,7 +153,9 @@
|
||||
{/if}
|
||||
</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}
|
||||
<Tooltip content={$i18n.t('Pinned Messages')}>
|
||||
<button
|
||||
@@ -164,7 +166,7 @@
|
||||
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" />
|
||||
</div>
|
||||
</button>
|
||||
@@ -173,17 +175,17 @@
|
||||
{#if channel?.user_count !== undefined}
|
||||
<Tooltip content={$i18n.t('Users')}>
|
||||
<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"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
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" />
|
||||
|
||||
<div class="text-sm">
|
||||
<div class="text-sm shrink-0">
|
||||
{channel.user_count}
|
||||
</div>
|
||||
</div>
|
||||
@@ -193,30 +195,32 @@
|
||||
{/if}
|
||||
|
||||
{#if $user !== undefined}
|
||||
<UserMenu
|
||||
className="w-[240px]"
|
||||
role={$user?.role}
|
||||
help={true}
|
||||
on:show={(e) => {
|
||||
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>
|
||||
<UserMenu
|
||||
className="w-[240px]"
|
||||
role={$user?.role}
|
||||
help={true}
|
||||
on:show={(e) => {
|
||||
if (e.detail === 'archived-chat') {
|
||||
showArchivedChats.set(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class=" self-center">
|
||||
<img
|
||||
src={`${WEBUI_API_BASE_URL}/users/${$user?.id}/profile/image`}
|
||||
class="size-6 object-cover rounded-full"
|
||||
alt="User profile"
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</UserMenu>
|
||||
<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">
|
||||
<img
|
||||
src={`${WEBUI_API_BASE_URL}/users/${$user?.id}/profile/image`}
|
||||
class="size-6 object-cover rounded-full"
|
||||
alt="User profile"
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</UserMenu>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user