Merge branch 'main' into fix/friends-and-karma-ui

This commit is contained in:
Moyase
2026-01-03 20:13:08 +02:00
committed by GitHub
3 changed files with 312 additions and 77 deletions

View File

@@ -794,6 +794,7 @@
"empty_description": "You're all caught up! Check back later for new updates.", "empty_description": "You're all caught up! Check back later for new updates.",
"empty_filter_description": "No notifications match this filter.", "empty_filter_description": "No notifications match this filter.",
"filter_all": "All", "filter_all": "All",
"filter_unread": "Unread",
"filter_friends": "Friends", "filter_friends": "Friends",
"filter_badges": "Badges", "filter_badges": "Badges",
"filter_upvotes": "Upvotes", "filter_upvotes": "Upvotes",

View File

@@ -8,6 +8,72 @@
width: 100%; width: 100%;
max-width: 800px; max-width: 800px;
margin: 0 auto; margin: 0 auto;
min-height: calc(100vh - 200px);
&__header {
display: flex;
justify-content: space-between;
align-items: center;
gap: calc(globals.$spacing-unit * 2);
}
&__filter-tabs {
display: flex;
gap: globals.$spacing-unit;
position: relative;
flex: 1;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
&__tab-wrapper {
position: relative;
}
&__tab {
background: none;
border: none;
color: rgba(255, 255, 255, 0.6);
padding: calc(globals.$spacing-unit) calc(globals.$spacing-unit * 2);
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: color ease 0.2s;
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 0.5);
&:hover {
color: rgba(255, 255, 255, 0.8);
}
&--active {
color: white;
}
}
&__tab-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
background-color: rgba(255, 255, 255, 0.15);
border-radius: 6px;
font-size: 11px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
line-height: 20px;
}
&__tab-underline {
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background: white;
}
&__actions { &__actions {
display: flex; display: flex;
@@ -15,6 +81,12 @@
justify-content: flex-end; justify-content: flex-end;
} }
&__content-wrapper {
display: flex;
flex-direction: column;
flex: 1;
}
&__list { &__list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -23,14 +95,22 @@
&__empty { &__empty {
display: flex; display: flex;
flex: 1;
width: 100%; width: 100%;
height: 100%;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
flex-direction: column; flex-direction: column;
gap: globals.$spacing-unit; gap: globals.$spacing-unit;
} }
&__empty-filter {
display: flex;
justify-content: center;
align-items: center;
padding: calc(globals.$spacing-unit * 6);
color: globals.$body-color;
}
&__icon-container { &__icon-container {
width: 60px; width: 60px;
height: 60px; height: 60px;

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { BellIcon } from "@primer/octicons-react"; import { BellIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
@@ -18,6 +18,11 @@ import type {
} from "@types"; } from "@types";
import "./notifications.scss"; import "./notifications.scss";
type NotificationFilter = "all" | "unread";
const STAGGER_DELAY_MS = 70;
const EXIT_DURATION_MS = 250;
export default function Notifications() { export default function Notifications() {
const { t, i18n } = useTranslation("notifications_page"); const { t, i18n } = useTranslation("notifications_page");
const { showSuccessToast, showErrorToast } = useToast(); const { showSuccessToast, showErrorToast } = useToast();
@@ -34,12 +39,14 @@ export default function Notifications() {
>([]); >([]);
const [badges, setBadges] = useState<Badge[]>([]); const [badges, setBadges] = useState<Badge[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [clearingIds, setClearingIds] = useState<Set<string>>(new Set()); const [isClearing, setIsClearing] = useState(false);
const [filter, setFilter] = useState<NotificationFilter>("all");
const [pagination, setPagination] = useState({ const [pagination, setPagination] = useState({
total: 0, total: 0,
hasMore: false, hasMore: false,
skip: 0, skip: 0,
}); });
const clearingTimeoutsRef = useRef<NodeJS.Timeout[]>([]);
const fetchLocalNotifications = useCallback(async () => { const fetchLocalNotifications = useCallback(async () => {
try { try {
@@ -65,7 +72,11 @@ export default function Notifications() {
}, [i18n.language]); }, [i18n.language]);
const fetchApiNotifications = useCallback( const fetchApiNotifications = useCallback(
async (skip = 0, append = false) => { async (
skip = 0,
append = false,
filterParam: NotificationFilter = "all"
) => {
if (!userDetails) return; if (!userDetails) return;
try { try {
@@ -74,7 +85,7 @@ export default function Notifications() {
await window.electron.hydraApi.get<NotificationsResponse>( await window.electron.hydraApi.get<NotificationsResponse>(
"/profile/notifications", "/profile/notifications",
{ {
params: { filter: "all", take: 20, skip }, params: { filter: filterParam, take: 20, skip },
needsAuth: true, needsAuth: true,
} }
); );
@@ -101,24 +112,24 @@ export default function Notifications() {
[userDetails] [userDetails]
); );
const fetchAllNotifications = useCallback(async () => { const fetchAllNotifications = useCallback(
setIsLoading(true); async (filterParam: NotificationFilter = "all") => {
await Promise.all([ setIsLoading(true);
fetchLocalNotifications(), await Promise.all([
fetchBadges(), fetchLocalNotifications(),
userDetails ? fetchApiNotifications(0, false) : Promise.resolve(), fetchBadges(),
]); userDetails
setIsLoading(false); ? fetchApiNotifications(0, false, filterParam)
}, [ : Promise.resolve(),
fetchLocalNotifications, ]);
fetchBadges, setIsLoading(false);
fetchApiNotifications, },
userDetails, [fetchLocalNotifications, fetchBadges, fetchApiNotifications, userDetails]
]); );
useEffect(() => { useEffect(() => {
fetchAllNotifications(); fetchAllNotifications(filter);
}, [fetchAllNotifications]); }, [fetchAllNotifications, filter]);
useEffect(() => { useEffect(() => {
const unsubscribe = window.electron.onLocalNotificationCreated( const unsubscribe = window.electron.onLocalNotificationCreated(
@@ -130,6 +141,13 @@ export default function Notifications() {
return () => unsubscribe(); return () => unsubscribe();
}, []); }, []);
// Cleanup timeouts on unmount
useEffect(() => {
return () => {
clearingTimeoutsRef.current.forEach(clearTimeout);
};
}, []);
const mergedNotifications = useMemo<MergedNotification[]>(() => { const mergedNotifications = useMemo<MergedNotification[]>(() => {
const sortByDate = (a: MergedNotification, b: MergedNotification) => const sortByDate = (a: MergedNotification, b: MergedNotification) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
@@ -144,23 +162,28 @@ export default function Notifications() {
.filter((n) => n.priority !== 1) .filter((n) => n.priority !== 1)
.map((n) => ({ ...n, source: "api" as const })); .map((n) => ({ ...n, source: "api" as const }));
const localWithSource: MergedNotification[] = localNotifications.map( // Filter local notifications based on current filter
(n) => ({ const filteredLocalNotifications =
filter === "unread"
? localNotifications.filter((n) => !n.isRead)
: localNotifications;
const localWithSource: MergedNotification[] =
filteredLocalNotifications.map((n) => ({
...n, ...n,
source: "local" as const, source: "local" as const,
}) }));
);
const lowPriority = [...lowPriorityApi, ...localWithSource].sort( const lowPriority = [...lowPriorityApi, ...localWithSource].sort(
sortByDate sortByDate
); );
return [...highPriority, ...lowPriority]; return [...highPriority, ...lowPriority];
}, [apiNotifications, localNotifications]); }, [apiNotifications, localNotifications, filter]);
const displayedNotifications = useMemo(() => { const displayedNotifications = useMemo(() => {
return mergedNotifications.filter((n) => !clearingIds.has(n.id)); return mergedNotifications;
}, [mergedNotifications, clearingIds]); }, [mergedNotifications]);
const notifyCountChange = useCallback(() => { const notifyCountChange = useCallback(() => {
window.dispatchEvent(new CustomEvent("notificationsChanged")); window.dispatchEvent(new CustomEvent("notificationsChanged"));
@@ -251,42 +274,86 @@ export default function Notifications() {
[showErrorToast, t, notifyCountChange] [showErrorToast, t, notifyCountChange]
); );
const removeNotificationFromState = useCallback(
(notification: MergedNotification) => {
if (notification.source === "api") {
setApiNotifications((prev) =>
prev.filter((n) => n.id !== notification.id)
);
} else {
setLocalNotifications((prev) =>
prev.filter((n) => n.id !== notification.id)
);
}
},
[]
);
const removeNotificationWithDelay = useCallback(
(notification: MergedNotification, delayMs: number): Promise<void> => {
return new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
removeNotificationFromState(notification);
resolve();
}, delayMs);
clearingTimeoutsRef.current.push(timeout);
});
},
[removeNotificationFromState]
);
const handleClearAll = useCallback(async () => { const handleClearAll = useCallback(async () => {
if (isClearing) return;
try { try {
// Mark all as clearing for animation setIsClearing(true);
const allIds = new Set([
...apiNotifications.map((n) => n.id),
...localNotifications.map((n) => n.id),
]);
setClearingIds(allIds);
// Wait for exit animation // Clear any existing timeouts
await new Promise((resolve) => setTimeout(resolve, 300)); clearingTimeoutsRef.current.forEach(clearTimeout);
clearingTimeoutsRef.current = [];
// Clear all API notifications // Snapshot current notifications for staggered removal
if (userDetails && apiNotifications.length > 0) { const notificationsToRemove = [...displayedNotifications];
const totalNotifications = notificationsToRemove.length;
if (totalNotifications === 0) {
setIsClearing(false);
return;
}
// Remove items one by one with staggered delays for visual effect
const removalPromises = notificationsToRemove.map((notification, index) =>
removeNotificationWithDelay(notification, index * STAGGER_DELAY_MS)
);
// Wait for all items to be removed from state
await Promise.all(removalPromises);
// Wait for the last exit animation to complete
await new Promise((resolve) => setTimeout(resolve, EXIT_DURATION_MS));
// Perform actual backend deletions (state is already cleared by staggered removal)
if (userDetails) {
await window.electron.hydraApi.delete(`/profile/notifications/all`, { await window.electron.hydraApi.delete(`/profile/notifications/all`, {
needsAuth: true, needsAuth: true,
}); });
setApiNotifications([]);
} }
// Clear all local notifications
await window.electron.clearAllLocalNotifications(); await window.electron.clearAllLocalNotifications();
setLocalNotifications([]);
setClearingIds(new Set());
setPagination({ total: 0, hasMore: false, skip: 0 }); setPagination({ total: 0, hasMore: false, skip: 0 });
notifyCountChange(); notifyCountChange();
showSuccessToast(t("cleared_all")); showSuccessToast(t("cleared_all"));
} catch (error) { } catch (error) {
logger.error("Failed to clear all notifications", error); logger.error("Failed to clear all notifications", error);
setClearingIds(new Set());
showErrorToast(t("failed_to_clear")); showErrorToast(t("failed_to_clear"));
} finally {
setIsClearing(false);
clearingTimeoutsRef.current = [];
} }
}, [ }, [
apiNotifications, displayedNotifications,
localNotifications, isClearing,
removeNotificationWithDelay,
userDetails, userDetails,
showSuccessToast, showSuccessToast,
showErrorToast, showErrorToast,
@@ -296,9 +363,19 @@ export default function Notifications() {
const handleLoadMore = useCallback(() => { const handleLoadMore = useCallback(() => {
if (pagination.hasMore && !isLoading) { if (pagination.hasMore && !isLoading) {
fetchApiNotifications(pagination.skip, true); fetchApiNotifications(pagination.skip, true, filter);
} }
}, [pagination, isLoading, fetchApiNotifications]); }, [pagination, isLoading, fetchApiNotifications, filter]);
const handleFilterChange = useCallback(
(newFilter: NotificationFilter) => {
if (newFilter !== filter) {
setFilter(newFilter);
setPagination({ total: 0, hasMore: false, skip: 0 });
}
},
[filter]
);
const handleAcceptFriendRequest = useCallback(() => { const handleAcceptFriendRequest = useCallback(() => {
showSuccessToast(t("friend_request_accepted")); showSuccessToast(t("friend_request_accepted"));
@@ -317,10 +394,13 @@ export default function Notifications() {
return ( return (
<motion.div <motion.div
key={key} key={key}
layout
initial={{ opacity: 0, x: -20 }} initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 100, transition: { duration: 0.2 } }} exit={{
opacity: 0,
x: 80,
transition: { duration: EXIT_DURATION_MS / 1000 },
}}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
> >
{notification.source === "local" ? ( {notification.source === "local" ? (
@@ -343,8 +423,57 @@ export default function Notifications() {
); );
}; };
const unreadCount = useMemo(() => {
const apiUnread = apiNotifications.filter((n) => !n.isRead).length;
const localUnread = localNotifications.filter((n) => !n.isRead).length;
return apiUnread + localUnread;
}, [apiNotifications, localNotifications]);
const renderFilterTabs = () => (
<div className="notifications__filter-tabs">
<div className="notifications__tab-wrapper">
<button
type="button"
className={`notifications__tab ${filter === "all" ? "notifications__tab--active" : ""}`}
onClick={() => handleFilterChange("all")}
>
{t("filter_all")}
</button>
{filter === "all" && (
<motion.div
className="notifications__tab-underline"
layoutId="notifications-tab-underline"
transition={{ type: "spring", stiffness: 300, damping: 30 }}
/>
)}
</div>
<div className="notifications__tab-wrapper">
<button
type="button"
className={`notifications__tab ${filter === "unread" ? "notifications__tab--active" : ""}`}
onClick={() => handleFilterChange("unread")}
>
{t("filter_unread")}
{unreadCount > 0 && (
<span className="notifications__tab-badge">{unreadCount}</span>
)}
</button>
{filter === "unread" && (
<motion.div
className="notifications__tab-underline"
layoutId="notifications-tab-underline"
transition={{ type: "spring", stiffness: 300, damping: 30 }}
/>
)}
</div>
</div>
);
const hasNoNotifications = mergedNotifications.length === 0;
const shouldDisableActions = isClearing || hasNoNotifications;
const renderContent = () => { const renderContent = () => {
if (isLoading && mergedNotifications.length === 0) { if (isLoading && hasNoNotifications) {
return ( return (
<div className="notifications__loading"> <div className="notifications__loading">
<span>{t("loading")}</span> <span>{t("loading")}</span>
@@ -352,36 +481,61 @@ export default function Notifications() {
); );
} }
if (mergedNotifications.length === 0) {
return (
<div className="notifications__empty">
<div className="notifications__icon-container">
<BellIcon size={24} />
</div>
<h2>{t("empty_title")}</h2>
<p>{t("empty_description")}</p>
</div>
);
}
return ( return (
<div className="notifications"> <div className="notifications">
<div className="notifications__actions"> <div className="notifications__header">
<Button theme="outline" onClick={handleMarkAllAsRead}> {renderFilterTabs()}
{t("mark_all_as_read")} <div className="notifications__actions">
</Button> <Button
<Button theme="danger" onClick={handleClearAll}> theme="outline"
{t("clear_all")} onClick={handleMarkAllAsRead}
</Button> disabled={shouldDisableActions}
>
{t("mark_all_as_read")}
</Button>
<Button
theme="danger"
onClick={handleClearAll}
disabled={shouldDisableActions}
>
{t("clear_all")}
</Button>
</div>
</div> </div>
<div className="notifications__list"> {/* Keep AnimatePresence mounted during clearing to preserve exit animations */}
<AnimatePresence mode="popLayout"> <AnimatePresence mode="wait">
{displayedNotifications.map(renderNotification)} <motion.div
</AnimatePresence> key={filter}
</div> className="notifications__content-wrapper"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
transition={{ duration: 0.2 }}
>
{hasNoNotifications && !isClearing ? (
<div className="notifications__empty">
<div className="notifications__icon-container">
<BellIcon size={24} />
</div>
<h2>{t("empty_title")}</h2>
<p>
{filter === "unread"
? t("empty_filter_description")
: t("empty_description")}
</p>
</div>
) : (
<div className="notifications__list">
<AnimatePresence>
{displayedNotifications.map(renderNotification)}
</AnimatePresence>
</div>
)}
</motion.div>
</AnimatePresence>
{pagination.hasMore && ( {pagination.hasMore && !isClearing && (
<div className="notifications__load-more"> <div className="notifications__load-more">
<Button <Button
theme="outline" theme="outline"