Skip to content

Commit 687ad40

Browse files
Extract swipe gesture logic and improve push notification handling (#135)
1 parent 24609ef commit 687ad40

13 files changed

Lines changed: 259 additions & 78 deletions

File tree

backend/src/routes/sse.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ export function createSSERoutes() {
9494
if (!result.success) {
9595
return c.json({ success: false, error: 'Invalid request', details: result.error.issues }, 400)
9696
}
97-
const success = sseAggregator.setClientVisibility(result.data.clientId, result.data.visible)
97+
const success = sseAggregator.setClientVisibility(result.data.clientId, result.data.visible, result.data.activeSessionId ?? null)
9898
if (!success) {
9999
return c.json({ success: false, error: 'Client not found' }, 404)
100100
}

backend/src/services/notification.ts

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -191,18 +191,15 @@ export class NotificationService {
191191
return rows.map((r) => r.user_id);
192192
}
193193

194-
private hasActiveSSEClients(): boolean {
195-
return sseAggregator.hasVisibleClients();
196-
}
197-
198194
async handleSSEEvent(
199195
_directory: string,
200196
event: SSEEvent
201197
): Promise<void> {
202198
const config = EVENT_CONFIG[event.type];
203199
if (!config) return;
204200

205-
if (this.hasActiveSSEClients()) return;
201+
const sessionId = event.properties.sessionID as string | undefined;
202+
if (sessionId && sseAggregator.isSessionBeingViewed(sessionId)) return;
206203

207204
if (!this.isConfigured()) return;
208205

@@ -216,27 +213,38 @@ export class NotificationService {
216213
if (!notifPrefs.enabled) continue;
217214
if (!notifPrefs.events[config.preferencesKey]) continue;
218215

219-
const sessionId = event.properties.sessionID as string | undefined;
220-
221216
let notificationUrl = "/";
222-
if (sessionId && _directory) {
217+
let repoName = "";
218+
let repoId: number | undefined;
219+
220+
if (_directory) {
223221
const reposBasePath = getReposPath();
224222
const localPath = path.relative(reposBasePath, _directory);
225223
const repo = getRepoByLocalPath(this.db, localPath);
226-
224+
227225
if (repo) {
228-
notificationUrl = `/repos/${repo.id}/sessions/${sessionId}`;
226+
repoId = repo.id;
227+
repoName = path.basename(repo.localPath);
228+
if (sessionId) {
229+
notificationUrl = `/repos/${repo.id}/sessions/${sessionId}`;
230+
} else {
231+
notificationUrl = `/repos/${repo.id}`;
232+
}
229233
}
230234
}
231235

236+
const body = config.bodyFn(event.properties);
237+
232238
const payload: PushNotificationPayload = {
233-
title: config.title,
234-
body: config.bodyFn(event.properties),
239+
title: repoName ? `[${repoName.toUpperCase()}] ${config.title}` : config.title,
240+
body: body,
235241
tag: `${event.type}-${sessionId ?? "global"}`,
236242
data: {
237243
eventType: event.type,
238244
sessionId,
239245
directory: _directory,
246+
repoId,
247+
repoName,
240248
url: notificationUrl,
241249
},
242250
};

backend/src/services/sse-aggregator.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ interface SSEClient {
1111
callback: SSEClientCallback
1212
directories: Set<string>
1313
visible: boolean
14+
activeSessionId: string | null
1415
}
1516

1617
interface DirectoryConnection {
@@ -51,7 +52,8 @@ class SSEAggregator {
5152
id,
5253
callback,
5354
directories: new Set(directories),
54-
visible: false
55+
visible: false,
56+
activeSessionId: null
5557
}
5658
this.clients.set(id, client)
5759

@@ -353,19 +355,22 @@ class SSEAggregator {
353355
return this.clients.size
354356
}
355357

356-
setClientVisibility(id: string, visible: boolean): boolean {
358+
setClientVisibility(id: string, visible: boolean, activeSessionId: string | null = null): boolean {
357359
const client = this.clients.get(id)
358360
if (!client) {
359361
logger.warn(`setClientVisibility: client ${id} not found`)
360362
return false
361363
}
362364
client.visible = visible
365+
client.activeSessionId = visible ? activeSessionId : null
363366
return true
364367
}
365368

366-
hasVisibleClients(): boolean {
369+
isSessionBeingViewed(sessionId: string): boolean {
367370
for (const client of this.clients.values()) {
368-
if (client.visible) return true
371+
if (client.visible && client.activeSessionId === sessionId) {
372+
return true
373+
}
369374
}
370375
return false
371376
}

frontend/eslint.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint'
66
import { defineConfig, globalIgnores } from 'eslint/config'
77

88
export default defineConfig([
9-
globalIgnores(['dist']),
9+
globalIgnores(['dist', 'src/sw.ts']),
1010
{
1111
files: ['**/*.{ts,tsx}'],
1212
extends: [

frontend/src/App.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11

22
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
3-
import { createBrowserRouter, RouterProvider, Outlet } from 'react-router-dom'
3+
import { createBrowserRouter, RouterProvider, Outlet, useNavigate } from 'react-router-dom'
4+
import { useEffect } from 'react'
45
import { Toaster } from 'sonner'
56
import { Repos } from './pages/Repos'
67
import { RepoDetail } from './pages/RepoDetail'
@@ -61,8 +62,19 @@ function PermissionDialogWrapper() {
6162
}
6263

6364
function AppShell() {
65+
const navigate = useNavigate()
6466
useTheme()
6567

68+
useEffect(() => {
69+
const channel = new BroadcastChannel('notification-click')
70+
channel.onmessage = (event: MessageEvent) => {
71+
if (event.data?.url) {
72+
navigate(event.data.url)
73+
}
74+
}
75+
return () => channel.close()
76+
}, [navigate])
77+
6678
return (
6779
<AuthProvider>
6880
<EventProvider>

frontend/src/components/session/SessionCard.tsx

Lines changed: 13 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import { useState, useRef } from "react";
1+
import { useRef, useEffect } from "react";
22
import { Card } from "@/components/ui/card";
33
import { Checkbox } from "@/components/ui/checkbox";
44
import { MiniScanner } from "@/components/ui/mini-scanner";
55
import { Trash2, Clock } from "lucide-react";
66
import { formatDistanceToNow } from "date-fns";
77
import type { Session } from "@/api/types";
8+
import { useSwipe } from "@/hooks/useSwipe";
89

910
interface SessionCardProps {
1011
session: Session;
@@ -25,57 +26,26 @@ export const SessionCard = ({
2526
onToggleSelection,
2627
onDelete,
2728
}: SessionCardProps) => {
28-
const [swipeOffset, setSwipeOffset] = useState(0);
29-
const [isSwipeOpen, setIsSwipeOpen] = useState(false);
30-
const touchStartX = useRef<number | null>(null);
3129
const cardRef = useRef<HTMLDivElement>(null);
30+
const { bind, swipeOffset, isOpen, isSwipingBack, close, swipeStyles } = useSwipe();
3231

33-
const handleTouchStart = (e: React.TouchEvent) => {
34-
touchStartX.current = e.touches[0].clientX;
35-
};
36-
37-
const handleTouchMove = (e: React.TouchEvent) => {
38-
if (touchStartX.current === null) return;
39-
40-
const currentX = e.touches[0].clientX;
41-
const diff = touchStartX.current - currentX;
42-
43-
if (diff > 0) {
44-
const newOffset = Math.min(diff, 80);
45-
setSwipeOffset(newOffset);
46-
} else if (diff < 0 && isSwipeOpen) {
47-
const newOffset = Math.max(0, 80 + diff);
48-
setSwipeOffset(newOffset);
49-
}
50-
};
51-
52-
const handleTouchEnd = () => {
53-
if (swipeOffset > 50) {
54-
setIsSwipeOpen(true);
55-
setSwipeOffset(80);
56-
} else if (swipeOffset < 30) {
57-
setIsSwipeOpen(false);
58-
setSwipeOffset(0);
32+
useEffect(() => {
33+
if (cardRef.current) {
34+
return bind(cardRef.current);
5935
}
60-
touchStartX.current = null;
61-
};
62-
63-
const closeSwipe = () => {
64-
setSwipeOffset(0);
65-
setIsSwipeOpen(false);
66-
};
36+
}, [bind]);
6737

6838
const handleDeleteClick = (e: React.MouseEvent<HTMLButtonElement>) => {
6939
e.stopPropagation();
7040
onDelete(e);
71-
closeSwipe();
41+
close();
7242
};
7343

7444
return (
75-
<div className="relative" onClick={closeSwipe}>
45+
<div className="relative" onClick={close}>
7646
<div
7747
className={`absolute top-0.5 right-0 bottom-0.5 w-20 bg-red-600 flex items-center justify-center rounded-r-lg transition-opacity ${
78-
swipeOffset > 20 || isSwipeOpen ? "opacity-100" : "opacity-0"
48+
!isSwipingBack && (isOpen || swipeOffset > 40) ? "opacity-100" : "opacity-0"
7949
}`}
8050
>
8151
<button
@@ -85,17 +55,10 @@ export const SessionCard = ({
8555
<Trash2 className="w-5 h-5" />
8656
</button>
8757
</div>
88-
<div
89-
ref={cardRef}
90-
onTouchStart={handleTouchStart}
91-
onTouchMove={handleTouchMove}
92-
onTouchEnd={handleTouchEnd}
93-
style={{ transform: `translateX(-${swipeOffset}px)` }}
94-
className="transition-transform"
95-
>
58+
<div ref={cardRef} style={swipeStyles}>
9659
<Card
9760
className={`p-2 cursor-pointer transition-all overflow-hidden ${
98-
isSwipeOpen
61+
isOpen
9962
? "rounded-none"
10063
: "rounded-r-lg"
10164
} ${
@@ -106,7 +69,7 @@ export const SessionCard = ({
10669
: "bg-card border-border hover:bg-accent hover:border-border"
10770
} hover:shadow-lg`}
10871
onClick={() => {
109-
if (!isSwipeOpen) {
72+
if (!isOpen) {
11073
onSelect(session.id);
11174
}
11275
}}

frontend/src/components/session/SessionList.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,8 +167,8 @@ export const SessionList = ({
167167
/>
168168
</div>
169169

170-
<div className="flex-1 overflow-y-auto px-4 pt-4 pb-4 min-h-0 [mask-image:linear-gradient(to_bottom,transparent,black_16px,black)]">
171-
<div className="flex flex-col gap-2">
170+
<div className="flex-1 overflow-y-auto overflow-x-hidden px-4 pt-4 pb-4 min-h-0 [mask-image:linear-gradient(to_bottom,transparent,black_16px,black)]">
171+
<div className="flex flex-col gap-4">
172172
{filteredSessions.length === 0 ? (
173173
<div className="text-sm text-muted-foreground text-center py-4">
174174
No sessions found

frontend/src/hooks/useSSE.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ export const useSSE = (opcodeUrl: string | null | undefined, directory?: string,
4343
const client = useOpenCodeClient(opcodeUrl, directory)
4444
const queryClient = useQueryClient()
4545
const mountedRef = useRef(true)
46+
const sessionIdRef = useRef(currentSessionId)
47+
sessionIdRef.current = currentSessionId
4648
const [isConnected, setIsConnected] = useState(false)
4749
const [error, setError] = useState<string | null>(null)
4850
const [isReconnecting, setIsReconnecting] = useState(false)
@@ -363,7 +365,7 @@ export const useSSE = (opcodeUrl: string | null | undefined, directory?: string,
363365
if (connected) {
364366
setError(null)
365367
fetchInitialData()
366-
sseManager.reportVisibility(document.visibilityState === 'visible')
368+
sseManager.reportVisibility(document.visibilityState === 'visible', sessionIdRef.current)
367369
} else {
368370
setError('Connection lost. Reconnecting...')
369371
}
@@ -378,7 +380,7 @@ export const useSSE = (opcodeUrl: string | null | undefined, directory?: string,
378380
}
379381

380382
const handleVisibilityChange = () => {
381-
sseManager.reportVisibility(document.visibilityState === 'visible')
383+
sseManager.reportVisibility(document.visibilityState === 'visible', sessionIdRef.current)
382384
}
383385

384386
document.addEventListener('visibilitychange', handleVisibilityChange)
@@ -395,5 +397,11 @@ export const useSSE = (opcodeUrl: string | null | undefined, directory?: string,
395397
}
396398
}, [opcodeUrl, directory, handleSSEEvent, fetchInitialData])
397399

400+
useEffect(() => {
401+
if (isConnected && document.visibilityState === 'visible') {
402+
sseManager.reportVisibility(true, currentSessionId)
403+
}
404+
}, [currentSessionId, isConnected])
405+
398406
return { isConnected, error, isReconnecting }
399407
}

0 commit comments

Comments
 (0)