Skip to content

Commit 012ce95

Browse files
committed
enh: swipe to reply
1 parent 6c2b2f2 commit 012ce95

2 files changed

Lines changed: 170 additions & 41 deletions

File tree

src/lib/components/channel/Messages/Message.svelte

Lines changed: 138 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,67 @@
6565
let editedContent = null;
6666
let showDeleteConfirmDialog = false;
6767
68+
// Swipe-to-reply state
69+
let swipeStartX = 0;
70+
let swipeStartY = 0;
71+
let swipeOffsetX = 0;
72+
let isSwiping = false;
73+
let swipeLocked = false; // locked to horizontal once determined
74+
let swipeMessageEl: HTMLElement | null = null;
75+
76+
const SWIPE_THRESHOLD = 60;
77+
const SWIPE_MAX = 100;
78+
const SWIPE_DEAD_ZONE = 10;
79+
80+
const handleTouchStart = (e: TouchEvent) => {
81+
if (disabled || edit || !onReply) return;
82+
const touch = e.touches[0];
83+
swipeStartX = touch.clientX;
84+
swipeStartY = touch.clientY;
85+
swipeOffsetX = 0;
86+
isSwiping = false;
87+
swipeLocked = false;
88+
};
89+
90+
const handleTouchMove = (e: TouchEvent) => {
91+
if (disabled || edit || !onReply) return;
92+
const touch = e.touches[0];
93+
const deltaX = touch.clientX - swipeStartX;
94+
const deltaY = touch.clientY - swipeStartY;
95+
96+
// Determine swipe direction from dead zone
97+
if (!swipeLocked && (Math.abs(deltaX) > SWIPE_DEAD_ZONE || Math.abs(deltaY) > SWIPE_DEAD_ZONE)) {
98+
if (Math.abs(deltaY) > Math.abs(deltaX)) {
99+
// Vertical scroll — abort swipe tracking
100+
isSwiping = false;
101+
swipeLocked = true;
102+
return;
103+
}
104+
// Horizontal swipe — lock in
105+
swipeLocked = true;
106+
isSwiping = true;
107+
}
108+
109+
if (!isSwiping) return;
110+
111+
// Only allow right swipe
112+
const clampedX = Math.max(0, deltaX);
113+
// Dampen the motion beyond threshold for a rubber-band feel
114+
swipeOffsetX = clampedX <= SWIPE_THRESHOLD
115+
? clampedX
116+
: SWIPE_THRESHOLD + (clampedX - SWIPE_THRESHOLD) * 0.3;
117+
swipeOffsetX = Math.min(swipeOffsetX, SWIPE_MAX);
118+
};
119+
120+
const handleTouchEnd = () => {
121+
if (isSwiping && swipeOffsetX >= SWIPE_THRESHOLD && onReply) {
122+
onReply(message);
123+
}
124+
swipeOffsetX = 0;
125+
isSwiping = false;
126+
swipeLocked = false;
127+
};
128+
68129
const loadMessageData = async () => {
69130
if (message && message?.data === true) {
70131
const res = await getMessageData(localStorage.token, channel?.id, message.id);
@@ -92,20 +153,39 @@
92153

93154
{#if message}
94155
<div
95-
id="message-{message.id}"
96-
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
97-
? className
98-
: `px-5 ${
99-
replyToMessage ? 'border-l-4 border-blue-500 bg-blue-100/10 dark:bg-blue-100/5 pl-4' : ''
100-
} ${
101-
(message?.reply_to_message?.meta?.model_id ?? message?.reply_to_message?.user_id) ===
102-
$user?.id
103-
? 'border-l-4 border-orange-500 bg-orange-100/10 dark:bg-orange-100/5 pl-4'
104-
: ''
105-
} ${message?.is_pinned ? 'bg-yellow-100/20 dark:bg-yellow-100/5' : ''}`} {showUserProfile
106-
? 'pt-1.5 pb-0.5'
107-
: ''}"
156+
class="swipe-reply-wrapper relative"
157+
on:touchstart={handleTouchStart}
158+
on:touchmove={handleTouchMove}
159+
on:touchend={handleTouchEnd}
108160
>
161+
<!-- Swipe reply indicator -->
162+
{#if swipeOffsetX > 0}
163+
<div
164+
class="swipe-reply-indicator"
165+
style="opacity: {Math.min(swipeOffsetX / SWIPE_THRESHOLD, 1)}; transform: scale({0.5 + Math.min(swipeOffsetX / SWIPE_THRESHOLD, 1) * 0.5});"
166+
>
167+
<div class="swipe-reply-icon" class:swipe-reply-icon--active={swipeOffsetX >= SWIPE_THRESHOLD}>
168+
<ArrowUpLeftAlt className="size-5" />
169+
</div>
170+
</div>
171+
{/if}
172+
173+
<div
174+
id="message-{message.id}"
175+
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
176+
? className
177+
: `px-5 ${
178+
replyToMessage ? 'border-l-4 border-blue-500 bg-blue-100/10 dark:bg-blue-100/5 pl-4' : ''
179+
} ${
180+
(message?.reply_to_message?.meta?.model_id ?? message?.reply_to_message?.user_id) ===
181+
$user?.id
182+
? 'border-l-4 border-orange-500 bg-orange-100/10 dark:bg-orange-100/5 pl-4'
183+
: ''
184+
} ${message?.is_pinned ? 'bg-yellow-100/20 dark:bg-yellow-100/5' : ''}`} {showUserProfile
185+
? 'pt-1.5 pb-0.5'
186+
: ''}"
187+
style="transform: translateX({swipeOffsetX}px); {swipeOffsetX > 0 ? '' : 'transition: transform 0.3s cubic-bezier(0.2, 0.9, 0.3, 1);'}"
188+
>
109189
{#if !edit && !disabled}
110190
<div
111191
class=" absolute {showButtons ? '' : 'invisible group-hover:visible'} right-1 -top-2 z-10"
@@ -545,6 +625,7 @@
545625
{/if}
546626
</div>
547627
</div>
628+
</div>
548629
</div>
549630
{/if}
550631

@@ -561,4 +642,48 @@
561642
background-color: transparent;
562643
}
563644
}
645+
646+
/* Swipe-to-reply styles */
647+
.swipe-reply-wrapper {
648+
touch-action: pan-y;
649+
}
650+
651+
.swipe-reply-indicator {
652+
position: absolute;
653+
left: 8px;
654+
top: 0;
655+
bottom: 0;
656+
display: flex;
657+
align-items: center;
658+
justify-content: center;
659+
z-index: 5;
660+
pointer-events: none;
661+
}
662+
663+
.swipe-reply-icon {
664+
display: flex;
665+
align-items: center;
666+
justify-content: center;
667+
width: 32px;
668+
height: 32px;
669+
border-radius: 50%;
670+
background-color: rgba(128, 128, 128, 0.15);
671+
color: rgba(128, 128, 128, 0.8);
672+
transition: background-color 0.15s, color 0.15s;
673+
}
674+
675+
.swipe-reply-icon--active {
676+
background-color: rgba(59, 130, 246, 0.2);
677+
color: rgb(59, 130, 246);
678+
}
679+
680+
:global(.dark) .swipe-reply-icon {
681+
background-color: rgba(200, 200, 200, 0.1);
682+
color: rgba(200, 200, 200, 0.6);
683+
}
684+
685+
:global(.dark) .swipe-reply-icon--active {
686+
background-color: rgba(96, 165, 250, 0.2);
687+
color: rgb(96, 165, 250);
688+
}
564689
</style>

src/lib/components/channel/Navbar.svelte

Lines changed: 32 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,9 @@
153153
{/if}
154154
</div>
155155

156-
<div class="self-start flex flex-none items-center text-gray-600 dark:text-gray-400 gap-1">
156+
<div
157+
class="self-start flex flex-none items-center text-gray-600 dark:text-gray-400 gap-1 shrink-0"
158+
>
157159
{#if channel}
158160
<Tooltip content={$i18n.t('Pinned Messages')}>
159161
<button
@@ -164,7 +166,7 @@
164166
showChannelPinnedMessagesModal = true;
165167
}}
166168
>
167-
<div class=" flex items-center gap-0.5 m-auto self-center">
169+
<div class=" flex items-center gap-0.5 m-auto self-center shrink-0">
168170
<Pin className=" size-4" strokeWidth="1.5" />
169171
</div>
170172
</button>
@@ -173,17 +175,17 @@
173175
{#if channel?.user_count !== undefined}
174176
<Tooltip content={$i18n.t('Users')}>
175177
<button
176-
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"
178+
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"
177179
aria-label="User Count"
178180
type="button"
179181
on:click={() => {
180182
showChannelInfoModal = true;
181183
}}
182184
>
183-
<div class=" flex items-center gap-0.5 m-auto self-center">
185+
<div class=" flex items-center gap-0.5 m-auto self-center shrink-0">
184186
<UserAlt className=" size-4" strokeWidth="1.5" />
185187

186-
<div class="text-sm">
188+
<div class="text-sm shrink-0">
187189
{channel.user_count}
188190
</div>
189191
</div>
@@ -193,30 +195,32 @@
193195
{/if}
194196

195197
{#if $user !== undefined}
196-
<UserMenu
197-
className="w-[240px]"
198-
role={$user?.role}
199-
help={true}
200-
on:show={(e) => {
201-
if (e.detail === 'archived-chat') {
202-
showArchivedChats.set(true);
203-
}
204-
}}
205-
>
206-
<button
207-
class="select-none flex rounded-xl p-1.5 w-full hover:bg-gray-50 dark:hover:bg-gray-850 transition"
208-
aria-label="User Menu"
198+
<div>
199+
<UserMenu
200+
className="w-[240px]"
201+
role={$user?.role}
202+
help={true}
203+
on:show={(e) => {
204+
if (e.detail === 'archived-chat') {
205+
showArchivedChats.set(true);
206+
}
207+
}}
209208
>
210-
<div class=" self-center">
211-
<img
212-
src={`${WEBUI_API_BASE_URL}/users/${$user?.id}/profile/image`}
213-
class="size-6 object-cover rounded-full"
214-
alt="User profile"
215-
draggable="false"
216-
/>
217-
</div>
218-
</button>
219-
</UserMenu>
209+
<button
210+
class="select-none flex rounded-xl p-1.5 w-full hover:bg-gray-50 dark:hover:bg-gray-850 transition"
211+
aria-label="User Menu"
212+
>
213+
<div class=" self-center">
214+
<img
215+
src={`${WEBUI_API_BASE_URL}/users/${$user?.id}/profile/image`}
216+
class="size-6 object-cover rounded-full"
217+
alt="User profile"
218+
draggable="false"
219+
/>
220+
</div>
221+
</button>
222+
</UserMenu>
223+
</div>
220224
{/if}
221225
</div>
222226
</div>

0 commit comments

Comments
 (0)