Skip to content

Commit bee01ee

Browse files
committed
fix: use optimistic UI updates and memoization to eliminate flicker
- Update notification state immediately before API call (optimistic UI) - Memoize all notification child components to prevent unnecessary re-renders - Use opacity transition instead of removing unread dot from DOM
1 parent 64c93ca commit bee01ee

6 files changed

Lines changed: 76 additions & 43 deletions

File tree

ui/src/components/notifications/MarkdownContent.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState } from "react";
1+
import { memo, useState } from "react";
22
import ReactMarkdown from "react-markdown";
33
import rehypeRaw from "rehype-raw";
44
import remarkBreaks from "remark-breaks";
@@ -55,7 +55,10 @@ function ImageWithFallback({ src, alt }: { src: string; alt?: string }) {
5555
);
5656
}
5757

58-
export function MarkdownContent({ content, className }: MarkdownContentProps) {
58+
export const MarkdownContent = memo(function MarkdownContent({
59+
content,
60+
className,
61+
}: MarkdownContentProps) {
5962
const processedContent = preprocessMarkdown(content);
6063

6164
return (
@@ -125,4 +128,4 @@ export function MarkdownContent({ content, className }: MarkdownContentProps) {
125128
</ReactMarkdown>
126129
</div>
127130
);
128-
}
131+
});
Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { open } from "@tauri-apps/plugin-shell";
22
import { ExternalLink } from "lucide-react";
3+
import { memo, useCallback } from "react";
34
import { Button } from "@/components/ui/button";
45
import { isTauri } from "@/lib/tauri";
56
import type { NotificationAction } from "@/types/ntfy";
@@ -8,26 +9,28 @@ interface NotificationActionsProps {
89
actions: NotificationAction[];
910
}
1011

11-
export function NotificationActions({ actions }: NotificationActionsProps) {
12-
if (actions.length === 0) return null;
13-
14-
const handleClick = async (
15-
e: React.MouseEvent,
16-
action: NotificationAction,
17-
) => {
18-
e.stopPropagation();
19-
if (!action.url) return;
12+
export const NotificationActions = memo(function NotificationActions({
13+
actions,
14+
}: NotificationActionsProps) {
15+
const handleClick = useCallback(
16+
async (e: React.MouseEvent, action: NotificationAction) => {
17+
e.stopPropagation();
18+
if (!action.url) return;
2019

21-
try {
22-
if (isTauri()) {
23-
await open(action.url);
24-
} else {
25-
window.open(action.url, "_blank", "noopener,noreferrer");
20+
try {
21+
if (isTauri()) {
22+
await open(action.url);
23+
} else {
24+
window.open(action.url, "_blank", "noopener,noreferrer");
25+
}
26+
} catch (err) {
27+
console.error("Failed to open URL:", err);
2628
}
27-
} catch (err) {
28-
console.error("Failed to open URL:", err);
29-
}
30-
};
29+
},
30+
[],
31+
);
32+
33+
if (actions.length === 0) return null;
3134

3235
return (
3336
<div className="flex flex-wrap gap-2 mt-4">
@@ -45,4 +48,4 @@ export function NotificationActions({ actions }: NotificationActionsProps) {
4548
))}
4649
</div>
4750
);
48-
}
51+
});

ui/src/components/notifications/NotificationAttachments.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Download, FileText, Image as ImageIcon } from "lucide-react";
2+
import { memo } from "react";
23
import { Button } from "@/components/ui/button";
34
import { useLazyImage } from "@/hooks";
45
import type { Attachment } from "@/types/ntfy";
@@ -35,7 +36,7 @@ function formatFileSize(bytes: number): string {
3536
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
3637
}
3738

38-
export function NotificationAttachments({
39+
export const NotificationAttachments = memo(function NotificationAttachments({
3940
attachments,
4041
}: NotificationAttachmentsProps) {
4142
if (attachments.length === 0) return null;
@@ -88,4 +89,4 @@ export function NotificationAttachments({
8889
))}
8990
</div>
9091
);
91-
}
92+
});

ui/src/components/notifications/NotificationHeader.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { memo } from "react";
2+
import { cn } from "@/lib/utils";
13
import type { NotificationPriority } from "@/types/ntfy";
24
import { PriorityBadge } from "./PriorityBadge";
35

@@ -25,7 +27,7 @@ function formatTimestamp(timestamp: number): string {
2527
return new Date(timestamp).toLocaleDateString();
2628
}
2729

28-
export function NotificationHeader({
30+
export const NotificationHeader = memo(function NotificationHeader({
2931
title,
3032
timestamp,
3133
priority,
@@ -34,7 +36,12 @@ export function NotificationHeader({
3436
return (
3537
<div className="flex items-start justify-between gap-3">
3638
<div className="flex items-center gap-2 min-w-0">
37-
{!read && <span className="h-2 w-2 rounded-full bg-primary shrink-0" />}
39+
<span
40+
className={cn(
41+
"h-2 w-2 rounded-full bg-primary shrink-0 transition-opacity",
42+
read && "opacity-0",
43+
)}
44+
/>
3845
<h3 className="font-semibold text-sm truncate">{title}</h3>
3946
</div>
4047
<div className="flex items-center gap-2 shrink-0">
@@ -45,4 +52,4 @@ export function NotificationHeader({
4552
</div>
4653
</div>
4754
);
48-
}
55+
});

ui/src/components/notifications/NotificationTags.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
import { memo } from "react";
12
import { Badge } from "@/components/ui/badge";
23

34
interface NotificationTagsProps {
45
tags: string[];
56
}
67

7-
export function NotificationTags({ tags }: NotificationTagsProps) {
8+
export const NotificationTags = memo(function NotificationTags({
9+
tags,
10+
}: NotificationTagsProps) {
811
if (tags.length === 0) return null;
912

1013
return (
@@ -20,4 +23,4 @@ export function NotificationTags({ tags }: NotificationTagsProps) {
2023
))}
2124
</div>
2225
);
23-
}
26+
});

ui/src/hooks/useNotifications.ts

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,10 @@ export function useNotifications(subscriptions: Subscription[]) {
6969

7070
/**
7171
* Marks a notification as read.
72+
* Uses optimistic UI update for instant feedback.
7273
*/
73-
const markAsRead = useCallback(async (id: string) => {
74-
if (isTauri()) {
75-
await notificationsApi.markAsRead(id);
76-
}
77-
74+
const markAsRead = useCallback((id: string) => {
75+
// Optimistic update - instant UI feedback
7876
setByTopic((prev) => {
7977
const topicId = findTopicForNotification(prev, id);
8078
if (!topicId) return prev;
@@ -87,31 +85,42 @@ export function useNotifications(subscriptions: Subscription[]) {
8785
);
8886
return new Map(prev).set(topicId, updated);
8987
});
88+
89+
// API call in background
90+
if (isTauri()) {
91+
notificationsApi.markAsRead(id).catch((err) => {
92+
console.error("Failed to mark as read:", err);
93+
});
94+
}
9095
}, []);
9196

9297
/**
9398
* Marks all notifications in a topic as read.
99+
* Uses optimistic UI update for instant feedback.
94100
*/
95-
const markAllAsRead = useCallback(async (subscriptionId: string) => {
96-
if (isTauri()) {
97-
await notificationsApi.markAllAsRead(subscriptionId);
98-
}
101+
const markAllAsRead = useCallback((subscriptionId: string) => {
102+
// Optimistic update - instant UI feedback
99103
setByTopic((prev) => {
100104
const notifs = prev.get(subscriptionId);
101105
if (!notifs) return prev;
102106
const updated = notifs.map((n) => ({ ...n, read: true }));
103107
return new Map(prev).set(subscriptionId, updated);
104108
});
109+
110+
// API call in background
111+
if (isTauri()) {
112+
notificationsApi.markAllAsRead(subscriptionId).catch((err) => {
113+
console.error("Failed to mark all as read:", err);
114+
});
115+
}
105116
}, []);
106117

107118
/**
108119
* Deletes a notification.
120+
* Uses optimistic UI update for instant feedback.
109121
*/
110-
const deleteNotification = useCallback(async (id: string) => {
111-
if (isTauri()) {
112-
await notificationsApi.delete(id);
113-
}
114-
122+
const deleteNotification = useCallback((id: string) => {
123+
// Optimistic update - instant UI feedback
115124
setByTopic((prev) => {
116125
const topicId = findTopicForNotification(prev, id);
117126
if (!topicId) return prev;
@@ -122,6 +131,13 @@ export function useNotifications(subscriptions: Subscription[]) {
122131
const filtered = notifs.filter((n) => n.id !== id);
123132
return new Map(prev).set(topicId, filtered);
124133
});
134+
135+
// API call in background
136+
if (isTauri()) {
137+
notificationsApi.delete(id).catch((err) => {
138+
console.error("Failed to delete notification:", err);
139+
});
140+
}
125141
}, []);
126142

127143
/**

0 commit comments

Comments
 (0)