v4: the notifications page restores its list and scroll on Back, and shows what it missed — it now saves its loaded pages and scroll position as it is left and restores them on Back, through useFeedCache, useScrollSave and useScrollRestore like the feeds and the profile's tabs; the saved list keeps the ones the server confirmed read as read, so coming back doesn't highlight them again. The page never refetched after a reconnect, so notifications sent while the socket was down never showed, and the next read-all marked them read unseen; a reconnect, and every restored list (after a reload the socket hasn't said yet whether anything is new), now fetch the first page again, and what is new goes on top with the socket's arrivals, those alone sorted newest first so the server's order stays. Marking read now follows the unread notifications shown, each sent once, instead of each socket message, and a list with none unread sends nothing. The arrivals shift the server's offset pages, so a next page could repeat the last one's tail: each notification shows once. The next page loads as the end scrolls into view, with FeedFooter, as every other page list does, and the loading and error states go through ListState. The "N new" pill was a number before a separately translated "new", which French made "3 nouveau"; it is one plural message, "3 nouvelles". Dates past a week and the tooltip use the app's locale rather than the browser's. The mention, comment and like icons had no colored circle; they have one now, and the decorative emoji are hidden from screen readers. 670 frontend tests pass (new: loading on scroll, the save on leaving, the restore with what came meanwhile, the reconnect backfill), and tsc, deno fmt, deno lint, doc:lint and i18n:compile --strict are clean; the restore was checked in the running app at phone width.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -407,6 +407,10 @@ msgstr "{0}–{1} characters: letters, numbers, or underscores"
|
||||
msgid "{label} ({count})"
|
||||
msgstr "{label} ({count})"
|
||||
|
||||
#: src/pages/Notifications.tsx:502
|
||||
msgid "{totalUnread, plural, one {# new} other {# new}}"
|
||||
msgstr "{totalUnread, plural, one {# new} other {# new}}"
|
||||
|
||||
#: src/components/chat/ChatModal.tsx:756
|
||||
msgid "{unseenCount} new messages"
|
||||
msgstr "{unseenCount} new messages"
|
||||
@@ -441,47 +445,47 @@ msgstr "+ Playlist"
|
||||
|
||||
#. placeholder {0}: d.commenterUsername
|
||||
#. placeholder {1}: d.dumpTitle
|
||||
#: src/pages/Notifications.tsx:225
|
||||
#: src/pages/Notifications.tsx:235
|
||||
msgid "<0>{0}</0> commented on <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> commented on <1>{1}</1>"
|
||||
|
||||
#. placeholder {0}: d.followerUsername
|
||||
#. placeholder {1}: d.playlistTitle
|
||||
#: src/pages/Notifications.tsx:185
|
||||
#: src/pages/Notifications.tsx:195
|
||||
msgid "<0>{0}</0> followed your playlist <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> followed your playlist <1>{1}</1>"
|
||||
|
||||
#. placeholder {0}: d.likerUsername
|
||||
#. placeholder {1}: d.dumpTitle
|
||||
#: src/pages/Notifications.tsx:235
|
||||
#: src/pages/Notifications.tsx:245
|
||||
msgid "<0>{0}</0> liked your comment on <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> liked your comment on <1>{1}</1>"
|
||||
|
||||
#. placeholder {0}: d.mentionerUsername
|
||||
#: src/pages/Notifications.tsx:251
|
||||
#: src/pages/Notifications.tsx:261
|
||||
msgid "<0>{0}</0> mentioned you in <1>{where}</1>"
|
||||
msgstr "<0>{0}</0> mentioned you in <1>{where}</1>"
|
||||
|
||||
#. placeholder {0}: d.dumperUsername
|
||||
#. placeholder {1}: d.dumpTitle
|
||||
#: src/pages/Notifications.tsx:195
|
||||
#: src/pages/Notifications.tsx:205
|
||||
msgid "<0>{0}</0> posted <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> posted <1>{1}</1>"
|
||||
|
||||
#. placeholder {0}: d.followerUsername
|
||||
#: src/pages/Notifications.tsx:176
|
||||
#: src/pages/Notifications.tsx:186
|
||||
msgid "<0>{0}</0> started following you"
|
||||
msgstr "<0>{0}</0> started following you"
|
||||
|
||||
#. placeholder {0}: d.voterUsername
|
||||
#. placeholder {1}: d.dumpTitle
|
||||
#: src/pages/Notifications.tsx:215
|
||||
#: src/pages/Notifications.tsx:225
|
||||
msgid "<0>{0}</0> upvoted <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> upvoted <1>{1}</1>"
|
||||
|
||||
#. placeholder {0}: d.dumpTitle
|
||||
#. placeholder {1}: d.playlistTitle
|
||||
#: src/pages/Notifications.tsx:205
|
||||
#: src/pages/Notifications.tsx:215
|
||||
msgid "<0>{0}</0> was added to <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> was added to <1>{1}</1>"
|
||||
|
||||
@@ -489,7 +493,7 @@ msgstr "<0>{0}</0> was added to <1>{1}</1>"
|
||||
msgid "1 new message"
|
||||
msgstr "1 new message"
|
||||
|
||||
#: src/pages/Notifications.tsx:246
|
||||
#: src/pages/Notifications.tsx:256
|
||||
msgid "a comment"
|
||||
msgstr "a comment"
|
||||
|
||||
@@ -497,7 +501,7 @@ msgstr "a comment"
|
||||
msgid "A link is required"
|
||||
msgstr "A link is required"
|
||||
|
||||
#: src/pages/Notifications.tsx:249
|
||||
#: src/pages/Notifications.tsx:259
|
||||
msgid "a post"
|
||||
msgstr "a post"
|
||||
|
||||
@@ -871,7 +875,7 @@ msgstr "Dumped!"
|
||||
|
||||
#: src/components/profile/ProfileDumpsTab.tsx:45
|
||||
#: src/pages/Search.tsx:120
|
||||
#: src/pages/UserPublicProfile.tsx:154
|
||||
#: src/pages/UserPublicProfile.tsx:158
|
||||
msgid "Dumps"
|
||||
msgstr "Dumps"
|
||||
|
||||
@@ -880,7 +884,7 @@ msgstr "Dumps"
|
||||
msgid "Dumps ({0})"
|
||||
msgstr "Dumps ({0})"
|
||||
|
||||
#: src/pages/Notifications.tsx:405
|
||||
#: src/pages/Notifications.tsx:301
|
||||
msgid "Earlier"
|
||||
msgstr "Earlier"
|
||||
|
||||
@@ -979,7 +983,6 @@ msgid "Failed to generate invite"
|
||||
msgstr "Failed to generate invite"
|
||||
|
||||
#: src/components/shared/ListState.tsx:43
|
||||
#: src/pages/Notifications.tsx:379
|
||||
msgid "Failed to load"
|
||||
msgstr "Failed to load"
|
||||
|
||||
@@ -1053,7 +1056,7 @@ msgstr "Follow some users to see their dumps here."
|
||||
#: src/components/feed/FeedTabBar.tsx:43
|
||||
#: src/components/profile/ProfileFollowTab.tsx:163
|
||||
#: src/pages/Settings.tsx:53
|
||||
#: src/pages/UserPublicProfile.tsx:157
|
||||
#: src/pages/UserPublicProfile.tsx:161
|
||||
msgid "Followed"
|
||||
msgstr "Followed"
|
||||
|
||||
@@ -1127,7 +1130,7 @@ msgid "invited by"
|
||||
msgstr "invited by"
|
||||
|
||||
#: src/components/profile/ProfileInviteesTab.tsx:77
|
||||
#: src/pages/UserPublicProfile.tsx:158
|
||||
#: src/pages/UserPublicProfile.tsx:162
|
||||
msgid "Invitees"
|
||||
msgstr "Invitees"
|
||||
|
||||
@@ -1209,7 +1212,6 @@ msgstr "Loading profile…"
|
||||
#: src/components/shared/ListState.tsx:38
|
||||
#: src/components/shared/LoadMoreButton.tsx:32
|
||||
#: src/components/social/UserListPopover.tsx:151
|
||||
#: src/pages/Notifications.tsx:375
|
||||
msgid "Loading…"
|
||||
msgstr "Loading…"
|
||||
|
||||
@@ -1228,8 +1230,8 @@ msgstr "Log in to like"
|
||||
msgid "Log in to vote"
|
||||
msgstr "Log in to vote"
|
||||
|
||||
#: src/components/profile/ProfileHeader.tsx:185
|
||||
#: src/pages/UserPublicProfile.tsx:127
|
||||
#: src/components/profile/ProfileHeader.tsx:189
|
||||
#: src/pages/UserPublicProfile.tsx:131
|
||||
msgid "Log out"
|
||||
msgstr "Log out"
|
||||
|
||||
@@ -1249,7 +1251,7 @@ msgstr "Lowercase letters, numbers and single hyphens only"
|
||||
#: src/components/profile/ProfileHeader.tsx:181
|
||||
#: src/pages/Manage.tsx:23
|
||||
#: src/pages/Manage.tsx:30
|
||||
#: src/pages/UserPublicProfile.tsx:160
|
||||
#: src/pages/UserPublicProfile.tsx:164
|
||||
msgid "Manage"
|
||||
msgstr "Manage"
|
||||
|
||||
@@ -1270,10 +1272,6 @@ msgstr "Mute"
|
||||
msgid "Name"
|
||||
msgstr "Name"
|
||||
|
||||
#: src/pages/Notifications.tsx:368
|
||||
msgid "new"
|
||||
msgstr "new"
|
||||
|
||||
#: src/components/chat/ChatModal.tsx:669
|
||||
#: src/components/feed/FeedTabBar.tsx:40
|
||||
#: src/pages/Settings.tsx:51
|
||||
@@ -1361,13 +1359,13 @@ msgstr "Not following anyone yet."
|
||||
|
||||
#: src/components/profile/ProfileDumpsTab.tsx:53
|
||||
#: src/components/profile/ProfileOverviewTab.tsx:47
|
||||
#: src/pages/Notifications.tsx:386
|
||||
#: src/pages/Notifications.tsx:516
|
||||
msgid "Nothing here yet."
|
||||
msgstr "Nothing here yet."
|
||||
|
||||
#: src/components/layout/NotificationBell.tsx:54
|
||||
#: src/pages/Notifications.tsx:308
|
||||
#: src/pages/Notifications.tsx:364
|
||||
#: src/pages/Notifications.tsx:374
|
||||
#: src/pages/Notifications.tsx:498
|
||||
#: src/pages/Settings.tsx:131
|
||||
msgid "Notifications"
|
||||
msgstr "Notifications"
|
||||
@@ -1405,7 +1403,7 @@ msgstr "Open source"
|
||||
msgid "or <0>browse files</0>"
|
||||
msgstr "or <0>browse files</0>"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:153
|
||||
#: src/pages/UserPublicProfile.tsx:157
|
||||
msgid "Overview"
|
||||
msgstr "Overview"
|
||||
|
||||
@@ -1471,7 +1469,7 @@ msgstr "Playlist title"
|
||||
#: src/components/profile/ProfileFollowTab.tsx:167
|
||||
#: src/components/profile/ProfilePlaylistsTab.tsx:118
|
||||
#: src/pages/Search.tsx:123
|
||||
#: src/pages/UserPublicProfile.tsx:156
|
||||
#: src/pages/UserPublicProfile.tsx:160
|
||||
msgid "Playlists"
|
||||
msgstr "Playlists"
|
||||
|
||||
@@ -1738,7 +1736,7 @@ msgstr "Submit search"
|
||||
msgid "That file is over {limit}."
|
||||
msgstr "That file is over {limit}."
|
||||
|
||||
#: src/pages/Notifications.tsx:248
|
||||
#: src/pages/Notifications.tsx:258
|
||||
msgid "the chat"
|
||||
msgstr "the chat"
|
||||
|
||||
@@ -1800,7 +1798,7 @@ msgstr "Title"
|
||||
msgid "Title is required."
|
||||
msgstr "Title is required."
|
||||
|
||||
#: src/pages/Notifications.tsx:402
|
||||
#: src/pages/Notifications.tsx:299
|
||||
#: src/utils/chat/time.ts:24
|
||||
msgid "Today"
|
||||
msgstr "Today"
|
||||
@@ -1880,7 +1878,7 @@ msgid "Upvote"
|
||||
msgstr "Upvote"
|
||||
|
||||
#: src/components/profile/ProfileDumpsTab.tsx:45
|
||||
#: src/pages/UserPublicProfile.tsx:155
|
||||
#: src/pages/UserPublicProfile.tsx:159
|
||||
msgid "Upvoted"
|
||||
msgstr "Upvoted"
|
||||
|
||||
@@ -1965,7 +1963,7 @@ msgstr "Why?"
|
||||
msgid "Write a reply…"
|
||||
msgstr "Write a reply…"
|
||||
|
||||
#: src/pages/Notifications.tsx:404
|
||||
#: src/pages/Notifications.tsx:300
|
||||
#: src/utils/chat/time.ts:27
|
||||
msgid "Yesterday"
|
||||
msgstr "Yesterday"
|
||||
@@ -1982,7 +1980,7 @@ msgstr "You won't get the monthly recap any more. You can turn it back on in you
|
||||
msgid "You won't get the weekly digest any more. You can turn it back on in your settings."
|
||||
msgstr "You won't get the weekly digest any more. You can turn it back on in your settings."
|
||||
|
||||
#: src/pages/Notifications.tsx:389
|
||||
#: src/pages/Notifications.tsx:519
|
||||
msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
|
||||
msgstr "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -407,6 +407,10 @@ msgstr "{0}–{1} caractères : lettres, chiffres ou tirets bas"
|
||||
msgid "{label} ({count})"
|
||||
msgstr "{label} ({count})"
|
||||
|
||||
#: src/pages/Notifications.tsx:502
|
||||
msgid "{totalUnread, plural, one {# new} other {# new}}"
|
||||
msgstr "{totalUnread, plural, one {# nouvelle} other {# nouvelles}}"
|
||||
|
||||
#: src/components/chat/ChatModal.tsx:756
|
||||
msgid "{unseenCount} new messages"
|
||||
msgstr "{unseenCount} nouveaux messages"
|
||||
@@ -441,47 +445,47 @@ msgstr "+ Collection"
|
||||
|
||||
#. placeholder {0}: d.commenterUsername
|
||||
#. placeholder {1}: d.dumpTitle
|
||||
#: src/pages/Notifications.tsx:225
|
||||
#: src/pages/Notifications.tsx:235
|
||||
msgid "<0>{0}</0> commented on <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> a commenté sur <1>{1}</1>"
|
||||
|
||||
#. placeholder {0}: d.followerUsername
|
||||
#. placeholder {1}: d.playlistTitle
|
||||
#: src/pages/Notifications.tsx:185
|
||||
#: src/pages/Notifications.tsx:195
|
||||
msgid "<0>{0}</0> followed your playlist <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> a suivi votre collection <1>{1}</1>"
|
||||
|
||||
#. placeholder {0}: d.likerUsername
|
||||
#. placeholder {1}: d.dumpTitle
|
||||
#: src/pages/Notifications.tsx:235
|
||||
#: src/pages/Notifications.tsx:245
|
||||
msgid "<0>{0}</0> liked your comment on <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> a aimé votre commentaire sur <1>{1}</1>"
|
||||
|
||||
#. placeholder {0}: d.mentionerUsername
|
||||
#: src/pages/Notifications.tsx:251
|
||||
#: src/pages/Notifications.tsx:261
|
||||
msgid "<0>{0}</0> mentioned you in <1>{where}</1>"
|
||||
msgstr "<0>{0}</0> vous a mentionné dans <1>{where}</1>"
|
||||
|
||||
#. placeholder {0}: d.dumperUsername
|
||||
#. placeholder {1}: d.dumpTitle
|
||||
#: src/pages/Notifications.tsx:195
|
||||
#: src/pages/Notifications.tsx:205
|
||||
msgid "<0>{0}</0> posted <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> a publié <1>{1}</1>"
|
||||
|
||||
#. placeholder {0}: d.followerUsername
|
||||
#: src/pages/Notifications.tsx:176
|
||||
#: src/pages/Notifications.tsx:186
|
||||
msgid "<0>{0}</0> started following you"
|
||||
msgstr "<0>{0}</0> a commencé à vous suivre"
|
||||
|
||||
#. placeholder {0}: d.voterUsername
|
||||
#. placeholder {1}: d.dumpTitle
|
||||
#: src/pages/Notifications.tsx:215
|
||||
#: src/pages/Notifications.tsx:225
|
||||
msgid "<0>{0}</0> upvoted <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> a voté pour <1>{1}</1>"
|
||||
|
||||
#. placeholder {0}: d.dumpTitle
|
||||
#. placeholder {1}: d.playlistTitle
|
||||
#: src/pages/Notifications.tsx:205
|
||||
#: src/pages/Notifications.tsx:215
|
||||
msgid "<0>{0}</0> was added to <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> a été ajouté à <1>{1}</1>"
|
||||
|
||||
@@ -489,7 +493,7 @@ msgstr "<0>{0}</0> a été ajouté à <1>{1}</1>"
|
||||
msgid "1 new message"
|
||||
msgstr "1 nouveau message"
|
||||
|
||||
#: src/pages/Notifications.tsx:246
|
||||
#: src/pages/Notifications.tsx:256
|
||||
msgid "a comment"
|
||||
msgstr "un commentaire"
|
||||
|
||||
@@ -497,7 +501,7 @@ msgstr "un commentaire"
|
||||
msgid "A link is required"
|
||||
msgstr "Il faut un lien"
|
||||
|
||||
#: src/pages/Notifications.tsx:249
|
||||
#: src/pages/Notifications.tsx:259
|
||||
msgid "a post"
|
||||
msgstr "une publication"
|
||||
|
||||
@@ -871,7 +875,7 @@ msgstr "Recommandé !"
|
||||
|
||||
#: src/components/profile/ProfileDumpsTab.tsx:45
|
||||
#: src/pages/Search.tsx:120
|
||||
#: src/pages/UserPublicProfile.tsx:154
|
||||
#: src/pages/UserPublicProfile.tsx:158
|
||||
msgid "Dumps"
|
||||
msgstr "Recos"
|
||||
|
||||
@@ -880,7 +884,7 @@ msgstr "Recos"
|
||||
msgid "Dumps ({0})"
|
||||
msgstr "Recos ({0})"
|
||||
|
||||
#: src/pages/Notifications.tsx:405
|
||||
#: src/pages/Notifications.tsx:301
|
||||
msgid "Earlier"
|
||||
msgstr "Plus tôt"
|
||||
|
||||
@@ -979,7 +983,6 @@ msgid "Failed to generate invite"
|
||||
msgstr "Impossible de générer une invitation"
|
||||
|
||||
#: src/components/shared/ListState.tsx:43
|
||||
#: src/pages/Notifications.tsx:379
|
||||
msgid "Failed to load"
|
||||
msgstr "Chargement échoué"
|
||||
|
||||
@@ -1053,7 +1056,7 @@ msgstr "Suivez des utilisateurs pour voir leurs recos ici."
|
||||
#: src/components/feed/FeedTabBar.tsx:43
|
||||
#: src/components/profile/ProfileFollowTab.tsx:163
|
||||
#: src/pages/Settings.tsx:53
|
||||
#: src/pages/UserPublicProfile.tsx:157
|
||||
#: src/pages/UserPublicProfile.tsx:161
|
||||
msgid "Followed"
|
||||
msgstr "Suivi"
|
||||
|
||||
@@ -1127,7 +1130,7 @@ msgid "invited by"
|
||||
msgstr "invité par"
|
||||
|
||||
#: src/components/profile/ProfileInviteesTab.tsx:77
|
||||
#: src/pages/UserPublicProfile.tsx:158
|
||||
#: src/pages/UserPublicProfile.tsx:162
|
||||
msgid "Invitees"
|
||||
msgstr "Invités"
|
||||
|
||||
@@ -1209,7 +1212,6 @@ msgstr "Chargement du profil…"
|
||||
#: src/components/shared/ListState.tsx:38
|
||||
#: src/components/shared/LoadMoreButton.tsx:32
|
||||
#: src/components/social/UserListPopover.tsx:151
|
||||
#: src/pages/Notifications.tsx:375
|
||||
msgid "Loading…"
|
||||
msgstr "Chargement…"
|
||||
|
||||
@@ -1228,8 +1230,8 @@ msgstr "Se connecter pour aimer"
|
||||
msgid "Log in to vote"
|
||||
msgstr "Se connecter pour voter"
|
||||
|
||||
#: src/components/profile/ProfileHeader.tsx:185
|
||||
#: src/pages/UserPublicProfile.tsx:127
|
||||
#: src/components/profile/ProfileHeader.tsx:189
|
||||
#: src/pages/UserPublicProfile.tsx:131
|
||||
msgid "Log out"
|
||||
msgstr "Se déconnecter"
|
||||
|
||||
@@ -1249,7 +1251,7 @@ msgstr "Minuscules, chiffres et tirets simples seulement"
|
||||
#: src/components/profile/ProfileHeader.tsx:181
|
||||
#: src/pages/Manage.tsx:23
|
||||
#: src/pages/Manage.tsx:30
|
||||
#: src/pages/UserPublicProfile.tsx:160
|
||||
#: src/pages/UserPublicProfile.tsx:164
|
||||
msgid "Manage"
|
||||
msgstr "Administration"
|
||||
|
||||
@@ -1270,10 +1272,6 @@ msgstr "Couper le son"
|
||||
msgid "Name"
|
||||
msgstr "Nom"
|
||||
|
||||
#: src/pages/Notifications.tsx:368
|
||||
msgid "new"
|
||||
msgstr "nouveau"
|
||||
|
||||
#: src/components/chat/ChatModal.tsx:669
|
||||
#: src/components/feed/FeedTabBar.tsx:40
|
||||
#: src/pages/Settings.tsx:51
|
||||
@@ -1361,13 +1359,13 @@ msgstr "Aucun abonnement pour le moment."
|
||||
|
||||
#: src/components/profile/ProfileDumpsTab.tsx:53
|
||||
#: src/components/profile/ProfileOverviewTab.tsx:47
|
||||
#: src/pages/Notifications.tsx:386
|
||||
#: src/pages/Notifications.tsx:516
|
||||
msgid "Nothing here yet."
|
||||
msgstr "Rien ici pour l'instant."
|
||||
|
||||
#: src/components/layout/NotificationBell.tsx:54
|
||||
#: src/pages/Notifications.tsx:308
|
||||
#: src/pages/Notifications.tsx:364
|
||||
#: src/pages/Notifications.tsx:374
|
||||
#: src/pages/Notifications.tsx:498
|
||||
#: src/pages/Settings.tsx:131
|
||||
msgid "Notifications"
|
||||
msgstr "Notifications"
|
||||
@@ -1405,7 +1403,7 @@ msgstr "Ouvrir la source"
|
||||
msgid "or <0>browse files</0>"
|
||||
msgstr "ou <0>parcourir les fichiers</0>"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:153
|
||||
#: src/pages/UserPublicProfile.tsx:157
|
||||
msgid "Overview"
|
||||
msgstr "Aperçu"
|
||||
|
||||
@@ -1471,7 +1469,7 @@ msgstr "Titre de la collection"
|
||||
#: src/components/profile/ProfileFollowTab.tsx:167
|
||||
#: src/components/profile/ProfilePlaylistsTab.tsx:118
|
||||
#: src/pages/Search.tsx:123
|
||||
#: src/pages/UserPublicProfile.tsx:156
|
||||
#: src/pages/UserPublicProfile.tsx:160
|
||||
msgid "Playlists"
|
||||
msgstr "Collections"
|
||||
|
||||
@@ -1738,7 +1736,7 @@ msgstr "Lancer la recherche"
|
||||
msgid "That file is over {limit}."
|
||||
msgstr "Ce fichier dépasse {limit}."
|
||||
|
||||
#: src/pages/Notifications.tsx:248
|
||||
#: src/pages/Notifications.tsx:258
|
||||
msgid "the chat"
|
||||
msgstr "la tribune"
|
||||
|
||||
@@ -1800,7 +1798,7 @@ msgstr "Titre"
|
||||
msgid "Title is required."
|
||||
msgstr "Un titre est requis."
|
||||
|
||||
#: src/pages/Notifications.tsx:402
|
||||
#: src/pages/Notifications.tsx:299
|
||||
#: src/utils/chat/time.ts:24
|
||||
msgid "Today"
|
||||
msgstr "Aujourd'hui"
|
||||
@@ -1880,7 +1878,7 @@ msgid "Upvote"
|
||||
msgstr "Voter"
|
||||
|
||||
#: src/components/profile/ProfileDumpsTab.tsx:45
|
||||
#: src/pages/UserPublicProfile.tsx:155
|
||||
#: src/pages/UserPublicProfile.tsx:159
|
||||
msgid "Upvoted"
|
||||
msgstr "Voté"
|
||||
|
||||
@@ -1965,7 +1963,7 @@ msgstr "Pourquoi ?"
|
||||
msgid "Write a reply…"
|
||||
msgstr "Écrire une réponse…"
|
||||
|
||||
#: src/pages/Notifications.tsx:404
|
||||
#: src/pages/Notifications.tsx:300
|
||||
#: src/utils/chat/time.ts:27
|
||||
msgid "Yesterday"
|
||||
msgstr "Hier"
|
||||
@@ -1982,7 +1980,7 @@ msgstr "Vous ne recevrez plus le récap mensuel. Vous pouvez le réactiver dans
|
||||
msgid "You won't get the weekly digest any more. You can turn it back on in your settings."
|
||||
msgstr "Vous ne recevrez plus le résumé hebdomadaire. Vous pouvez le réactiver dans vos paramètres."
|
||||
|
||||
#: src/pages/Notifications.tsx:389
|
||||
#: src/pages/Notifications.tsx:519
|
||||
msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
|
||||
msgstr "Vous serez notifié lorsque quelqu'un suit vos collections, vote pour vos recos ou publie du nouveau contenu."
|
||||
|
||||
|
||||
19
src/App.css
19
src/App.css
@@ -6584,6 +6584,20 @@ body.has-player .chat-fab {
|
||||
background: color-mix(in srgb, #10b981 30%, transparent);
|
||||
color: #10b981;
|
||||
}
|
||||
.notif-icon--mention {
|
||||
background: color-mix(in srgb, #ec4899 30%, transparent);
|
||||
color: #ec4899;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
.notif-icon--comment {
|
||||
background: color-mix(in srgb, #0ea5e9 30%, transparent);
|
||||
color: #0ea5e9;
|
||||
}
|
||||
.notif-icon--like {
|
||||
background: color-mix(in srgb, #f43f5e 30%, transparent);
|
||||
color: #f43f5e;
|
||||
}
|
||||
|
||||
.notification-body {
|
||||
flex: 1;
|
||||
@@ -6879,11 +6893,6 @@ body.has-player .chat-fab {
|
||||
color: var(--color-text-muted, #888);
|
||||
}
|
||||
|
||||
.notif-icon--mention {
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
/* ── Tooltip ── */
|
||||
.tooltip {
|
||||
background: var(--color-surface);
|
||||
|
||||
@@ -6,18 +6,19 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import { relativeTime } from "../utils/shared/relativeTime.ts";
|
||||
|
||||
import { API_URL, NOTIFICATIONS_PAGE_SIZE } from "../config/api.ts";
|
||||
import { useAuth } from "../hooks/auth/useAuth.ts";
|
||||
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
|
||||
import { ErrorCard } from "../components/shared/ErrorCard.tsx";
|
||||
import { ListState } from "../components/shared/ListState.tsx";
|
||||
import { Tooltip } from "../components/shared/Tooltip.tsx";
|
||||
import { LoadMoreButton } from "../components/shared/LoadMoreButton.tsx";
|
||||
import { FeedFooter } from "../components/feed/FeedFooter.tsx";
|
||||
import { useWS } from "../hooks/ws/useWS.ts";
|
||||
import type {
|
||||
CommentLikedData,
|
||||
@@ -33,9 +34,18 @@ import type {
|
||||
UserFollowedData,
|
||||
UserMentionedData,
|
||||
} from "../../shared/notification.ts";
|
||||
import { deserializeNotification } from "../utils/shared/deserialize.ts";
|
||||
import { pageUrl, readPage } from "../utils/feed/pagedList.ts";
|
||||
import {
|
||||
deserializeNotification,
|
||||
hydrateNotification,
|
||||
} from "../utils/shared/deserialize.ts";
|
||||
import { canLoadMore, pageUrl, readPage } from "../utils/feed/pagedList.ts";
|
||||
import { useFeedCache } from "../hooks/feed/useFeedCache.ts";
|
||||
import { usePagedList } from "../hooks/feed/usePagedList.ts";
|
||||
import { useInfiniteScroll } from "../hooks/feed/useInfiniteScroll.ts";
|
||||
import {
|
||||
useScrollRestore,
|
||||
useScrollSave,
|
||||
} from "../hooks/feed/useScrollSave.ts";
|
||||
import { PageShell } from "../components/layout/PageShell.tsx";
|
||||
import { dumpPath, playlistPath, userPath } from "../../shared/paths.ts";
|
||||
|
||||
@@ -99,7 +109,7 @@ function NotifIcon({ type }: { type: Notification["type"] }) {
|
||||
like: <HeartSvg />,
|
||||
};
|
||||
return (
|
||||
<span className={`notif-icon notif-icon--${kind}`}>
|
||||
<span className={`notif-icon notif-icon--${kind}`} aria-hidden="true">
|
||||
{glyphs[kind]}
|
||||
</span>
|
||||
);
|
||||
@@ -265,7 +275,10 @@ function notificationContent(n: Notification): React.ReactNode {
|
||||
function timeAgo(date: Date): string {
|
||||
const abs = Math.abs(Date.now() - date.getTime()) / 1000;
|
||||
if (abs < 7 * 86400) return relativeTime(date);
|
||||
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
return date.toLocaleDateString(i18n.locale, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
/** Returns the local midnight that starts the day of `d`, in epoch ms. */
|
||||
@@ -275,42 +288,102 @@ function startOfDay(d: Date): number {
|
||||
|
||||
/**
|
||||
* Returns `items` grouped under Today, Yesterday and Earlier, in that order,
|
||||
* without the empty groups. The labels are keys, which the page translates.
|
||||
* without the empty groups, each with its translated label.
|
||||
*/
|
||||
function groupByDate(
|
||||
items: Notification[],
|
||||
): { label: string; items: Notification[] }[] {
|
||||
const todayTs = startOfDay(new Date());
|
||||
const yesterdayTs = todayTs - 86_400_000;
|
||||
|
||||
const buckets: Record<string, Notification[]> = {};
|
||||
const groups = [
|
||||
{ label: t`Today`, items: [] as Notification[] },
|
||||
{ label: t`Yesterday`, items: [] as Notification[] },
|
||||
{ label: t`Earlier`, items: [] as Notification[] },
|
||||
];
|
||||
for (const n of items) {
|
||||
const ts = startOfDay(n.createdAt);
|
||||
const key = ts >= todayTs
|
||||
? "Today"
|
||||
: ts >= yesterdayTs
|
||||
? "Yesterday"
|
||||
: "Earlier";
|
||||
(buckets[key] ??= []).push(n);
|
||||
groups[ts >= todayTs ? 0 : ts >= yesterdayTs ? 1 : 2].items.push(n);
|
||||
}
|
||||
|
||||
return (["Today", "Yesterday", "Earlier"] as const)
|
||||
.filter((k) => buckets[k]?.length)
|
||||
.map((label) => ({ label, items: buckets[label] }));
|
||||
return groups.filter((g) => g.items.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders `/notifications`, for a signed-in user. Opening the page marks every
|
||||
* notification read on the server, and so does each that arrives while it is
|
||||
* open. The unread ones stay highlighted until the page is left.
|
||||
* Returns `added` and `items` together, each once and newest first, the
|
||||
* version in `added` winning.
|
||||
*/
|
||||
function newestFirst(
|
||||
added: Notification[],
|
||||
items: Notification[],
|
||||
): Notification[] {
|
||||
const ids = new Set(added.map((n) => n.id));
|
||||
return [...added, ...items.filter((n) => !ids.has(n.id))].sort((a, b) =>
|
||||
b.createdAt.getTime() - a.createdAt.getTime()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders notification `n` as a row, a link to what it is about unless that
|
||||
* is gone.
|
||||
*/
|
||||
function NotificationItem({ n }: { n: Notification }) {
|
||||
const link = notificationLink(n);
|
||||
const inner = (
|
||||
<>
|
||||
<NotifIcon type={n.type} />
|
||||
<div className="notification-body">
|
||||
<span className="notification-content">
|
||||
{notificationContent(n)}
|
||||
</span>
|
||||
<Tooltip text={n.createdAt.toLocaleString(i18n.locale)}>
|
||||
<time
|
||||
className="notification-time"
|
||||
dateTime={n.createdAt.toISOString()}
|
||||
>
|
||||
{timeAgo(n.createdAt)}
|
||||
</time>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{!n.read && <span className="notif-dot" aria-hidden="true" />}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<li
|
||||
className={`notification-item${
|
||||
!n.read ? " notification-item--unread" : ""
|
||||
}`}
|
||||
>
|
||||
{link
|
||||
? (
|
||||
<Link to={link} className="notification-item-link">
|
||||
{inner}
|
||||
</Link>
|
||||
)
|
||||
: <div className="notification-item-link">{inner}</div>}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders `/notifications`, for a signed-in user, a page at a time as the list
|
||||
* scrolls. Opening the page marks every notification read on the server, and
|
||||
* so does each that arrives while it is open. The unread ones stay highlighted
|
||||
* until the page is left. Going back restores the list and the scroll, with
|
||||
* what came in meanwhile on top.
|
||||
*/
|
||||
export function Notifications(): React.JSX.Element {
|
||||
useDocumentTitle(t`Notifications`);
|
||||
const { user, authFetch } = useAuth();
|
||||
const { clearUnreadNotifications, lastNotification } = useWS();
|
||||
const { state, loadMore } = usePagedList(
|
||||
user?.id,
|
||||
(page, signal) =>
|
||||
const {
|
||||
clearUnreadNotifications,
|
||||
lastNotification,
|
||||
connectionEpoch,
|
||||
} = useWS();
|
||||
const { cached, saveState } = useFeedCache<Notification>(
|
||||
"feed:notifications",
|
||||
hydrateNotification,
|
||||
);
|
||||
const fetchPage = useCallback(
|
||||
(page: number, signal?: AbortSignal) =>
|
||||
authFetch(
|
||||
pageUrl(
|
||||
`${API_URL}/api/me/notifications`,
|
||||
@@ -319,39 +392,98 @@ export function Notifications(): React.JSX.Element {
|
||||
),
|
||||
{ signal },
|
||||
).then((res) => readPage(res, deserializeNotification)),
|
||||
[authFetch],
|
||||
);
|
||||
const { state, loadMore } = usePagedList(
|
||||
user?.id,
|
||||
fetchPage,
|
||||
cached,
|
||||
);
|
||||
const loaded = state.status === "loaded" ? state.data : null;
|
||||
|
||||
// Each new `lastNotification` is kept once, during render rather than in an
|
||||
// effect, so it shows in the same frame. The list may predate it or not.
|
||||
// What arrives while the page is open goes on top, newest first. Each new
|
||||
// `lastNotification` is kept once, during render rather than in an effect,
|
||||
// so it shows in the same frame. The list may predate it or not.
|
||||
const [arrived, setArrived] = useState<Notification[]>([]);
|
||||
const [prevLastNotification, setPrevLastNotification] = useState(
|
||||
lastNotification,
|
||||
);
|
||||
if (prevLastNotification !== lastNotification && lastNotification !== null) {
|
||||
setPrevLastNotification(lastNotification);
|
||||
setArrived((a) => [lastNotification, ...a]);
|
||||
setArrived((a) => newestFirst([lastNotification], a));
|
||||
}
|
||||
const items = useMemo(() => {
|
||||
if (state.status !== "loaded") return [];
|
||||
const listed = new Set(state.data.items.map((n) => n.id));
|
||||
return [...arrived.filter((n) => !listed.has(n.id)), ...state.data.items];
|
||||
}, [state, arrived]);
|
||||
|
||||
// Every notification is marked read on the server once the list shows, and
|
||||
// again for each that arrives while the page is open. Only the bell clears:
|
||||
// clearing the list too would take its dots and "new" count away a moment
|
||||
// after they appear. A failure leaves them unread for next time.
|
||||
const loaded = state.status === "loaded";
|
||||
const markedReadRef = useRef<Notification | null | undefined>(undefined);
|
||||
// What came while the page wasn't listening, on a reconnect and on a
|
||||
// restored list, is fetched as the first page again and arrives with the
|
||||
// rest. A restored list always asks, since after a reload the socket hasn't
|
||||
// said yet whether anything is new. The epoch is marked done once that page
|
||||
// is in, so StrictMode's aborted first run doesn't skip it.
|
||||
const backfilledEpochRef = useRef<number | null>(
|
||||
cached ? null : connectionEpoch,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!loaded || markedReadRef.current === lastNotification) return;
|
||||
markedReadRef.current = lastNotification;
|
||||
authFetch(`${API_URL}/api/me/notifications/read`, { method: "POST" })
|
||||
.then((res) => {
|
||||
if (res.ok) clearUnreadNotifications();
|
||||
if (connectionEpoch === backfilledEpochRef.current) return;
|
||||
const controller = new AbortController();
|
||||
fetchPage(1, controller.signal)
|
||||
.then(({ items }) => {
|
||||
backfilledEpochRef.current = connectionEpoch;
|
||||
setArrived((a) => newestFirst(items, a));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [lastNotification, loaded, authFetch, clearUnreadNotifications]);
|
||||
return () => controller.abort();
|
||||
}, [connectionEpoch, fetchPage]);
|
||||
|
||||
// Each once: the arrivals may be in the list too, and they shift the
|
||||
// server's pages, so a next page repeats the tail of the last.
|
||||
const items = useMemo(() => {
|
||||
if (!loaded) return [];
|
||||
const byId = new Map<string, Notification>();
|
||||
for (const n of [...arrived, ...loaded.items]) {
|
||||
if (!byId.has(n.id)) byId.set(n.id, n);
|
||||
}
|
||||
return [...byId.values()];
|
||||
}, [loaded, arrived]);
|
||||
|
||||
// Each unread notification shown is marked read on the server, by a request
|
||||
// that marks them all: once the list shows, and again for those that arrive
|
||||
// or are backfilled while the page is open. Only the bell clears: clearing
|
||||
// the list too would take its dots and "new" count away a moment after they
|
||||
// appear. A failure leaves them unread for next time.
|
||||
const sentReadRef = useRef(new Set<string>());
|
||||
// Those the server confirmed, which the saved list keeps as read.
|
||||
const confirmedReadRef = useRef(new Set<string>());
|
||||
useEffect(() => {
|
||||
const ids = items
|
||||
.filter((n) => !n.read && !sentReadRef.current.has(n.id))
|
||||
.map((n) => n.id);
|
||||
if (ids.length === 0) return;
|
||||
for (const id of ids) sentReadRef.current.add(id);
|
||||
authFetch(`${API_URL}/api/me/notifications/read`, { method: "POST" })
|
||||
.then((res) => {
|
||||
if (!res.ok) return;
|
||||
for (const id of ids) confirmedReadRef.current.add(id);
|
||||
clearUnreadNotifications();
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [items, authFetch, clearUnreadNotifications]);
|
||||
|
||||
const sentinelRef = useInfiniteScroll(loadMore, canLoadMore(loaded));
|
||||
// The arrivals are saved with the list, and the ones marked read as read,
|
||||
// so that coming back shows them as the server has them.
|
||||
useScrollSave(
|
||||
!!loaded,
|
||||
useCallback((y) => {
|
||||
if (!loaded) return;
|
||||
const read = confirmedReadRef.current;
|
||||
saveState({
|
||||
...loaded,
|
||||
items: items.map((n) =>
|
||||
!n.read && read.has(n.id) ? { ...n, read: true } : n
|
||||
),
|
||||
}, y);
|
||||
}, [loaded, items, saveState]),
|
||||
);
|
||||
useScrollRestore(cached?.scrollY, !!loaded);
|
||||
|
||||
const totalUnread = items.filter((n) => !n.read).length;
|
||||
|
||||
@@ -360,101 +492,58 @@ export function Notifications(): React.JSX.Element {
|
||||
<div className="notifications-page">
|
||||
<div className="notifications-header">
|
||||
<h1 className="notifications-title">
|
||||
<span className="notifications-title-bell">🔔</span>
|
||||
<span className="notifications-title-bell" aria-hidden="true">
|
||||
🔔
|
||||
</span>
|
||||
<Trans>Notifications</Trans>
|
||||
</h1>
|
||||
{state.status === "loaded" && totalUnread > 0 && (
|
||||
{totalUnread > 0 && (
|
||||
<span className="notifications-unread-pill">
|
||||
{totalUnread} <Trans>new</Trans>
|
||||
<Plural value={totalUnread} one="# new" other="# new" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{state.status === "loading" && (
|
||||
<p className="page-loading">
|
||||
<Trans>Loading…</Trans>
|
||||
</p>
|
||||
)}
|
||||
{state.status === "error" && (
|
||||
<ErrorCard title={t`Failed to load`} message={state.error} />
|
||||
)}
|
||||
|
||||
{state.status === "loaded" && items.length === 0 && (
|
||||
<div className="notifications-empty">
|
||||
<span className="notifications-empty-icon">🔕</span>
|
||||
<p>
|
||||
<Trans>Nothing here yet.</Trans>
|
||||
</p>
|
||||
<p className="notifications-empty-hint">
|
||||
<Trans>
|
||||
You'll be notified when someone follows your playlists, upvotes
|
||||
your dumps, or posts new content.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.status === "loaded" && items.length > 0 &&
|
||||
groupByDate(items).map(({ label, items }) => (
|
||||
<section key={label} className="notif-group">
|
||||
<h2 className="notif-group-label">
|
||||
{label === "Today"
|
||||
? t`Today`
|
||||
: label === "Yesterday"
|
||||
? t`Yesterday`
|
||||
: t`Earlier`}
|
||||
</h2>
|
||||
<ul className="notification-list">
|
||||
{items.map((n) => {
|
||||
const link = notificationLink(n);
|
||||
const inner = (
|
||||
<>
|
||||
<NotifIcon type={n.type} />
|
||||
<div className="notification-body">
|
||||
<span className="notification-content">
|
||||
{notificationContent(n)}
|
||||
</span>
|
||||
<Tooltip text={n.createdAt.toLocaleString()}>
|
||||
<time
|
||||
className="notification-time"
|
||||
dateTime={n.createdAt.toISOString()}
|
||||
>
|
||||
{timeAgo(n.createdAt)}
|
||||
</time>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{!n.read && (
|
||||
<span className="notif-dot" aria-hidden="true" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<li
|
||||
key={n.id}
|
||||
className={`notification-item${
|
||||
!n.read ? " notification-item--unread" : ""
|
||||
}`}
|
||||
>
|
||||
{link
|
||||
? (
|
||||
<Link to={link} className="notification-item-link">
|
||||
{inner}
|
||||
</Link>
|
||||
)
|
||||
: <div className="notification-item-link">{inner}</div>}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{state.status === "loaded" && state.data.hasMore && (
|
||||
<LoadMoreButton
|
||||
loadingMore={state.data.loadingMore}
|
||||
onClick={loadMore}
|
||||
/>
|
||||
)}
|
||||
<ListState
|
||||
state={state}
|
||||
isEmpty={() => items.length === 0}
|
||||
empty={
|
||||
<div className="notifications-empty">
|
||||
<span className="notifications-empty-icon" aria-hidden="true">
|
||||
🔕
|
||||
</span>
|
||||
<p>
|
||||
<Trans>Nothing here yet.</Trans>
|
||||
</p>
|
||||
<p className="notifications-empty-hint">
|
||||
<Trans>
|
||||
You'll be notified when someone follows your playlists,
|
||||
upvotes your dumps, or posts new content.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{({ data }) => (
|
||||
<>
|
||||
{groupByDate(items).map((group) => (
|
||||
<section key={group.label} className="notif-group">
|
||||
<h2 className="notif-group-label">{group.label}</h2>
|
||||
<ul className="notification-list">
|
||||
{group.items.map((n) => (
|
||||
<NotificationItem key={n.id} n={n} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
<FeedFooter
|
||||
hasMore={data.hasMore}
|
||||
loadingMore={data.loadingMore}
|
||||
sentinelRef={sentinelRef}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ListState>
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { act, screen, waitFor } from "@testing-library/react";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { Notifications } from "../../pages/Notifications.tsx";
|
||||
import type { Notification } from "../../../shared/notification.ts";
|
||||
@@ -7,6 +7,7 @@ import { calls, deferred, fail, route } from "../fetch.ts";
|
||||
import { makeNotification, makeSession, page, wire } from "../fixtures.ts";
|
||||
import { renderApp } from "../render.tsx";
|
||||
import { FakeWebSocket } from "../ws.ts";
|
||||
import { FakeIntersectionObserver } from "../setup.ts";
|
||||
|
||||
const me = makeSession({ id: "me" });
|
||||
const DAY = 86_400_000;
|
||||
@@ -21,14 +22,20 @@ function open(items: Notification[], { hasMore = false } = {}) {
|
||||
);
|
||||
route("POST /api/me/notifications/read", () => ({}));
|
||||
const user = userEvent.setup();
|
||||
renderApp(<Notifications />, { session: me, route: "/notifications" });
|
||||
const view = renderApp(<Notifications />, {
|
||||
session: me,
|
||||
route: "/notifications",
|
||||
});
|
||||
const socket = FakeWebSocket.last;
|
||||
socket.welcome({
|
||||
unreadNotificationCount: items.filter((n) => !n.read).length,
|
||||
});
|
||||
return { user, socket };
|
||||
return { user, socket, view };
|
||||
}
|
||||
|
||||
/** The list as the page saves it on leaving, which a Back restores. */
|
||||
const CACHE_KEY = "feed:notifications:me";
|
||||
|
||||
const n = {
|
||||
follow: makeNotification({
|
||||
type: "user_followed",
|
||||
@@ -260,6 +267,26 @@ describe("Notifications", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("one missed while the socket was down shows after a reconnect, and is marked read", async () => {
|
||||
const missed = { ...n.upvoted, createdAt: new Date() };
|
||||
route("GET /api/me/notifications", () => page([n.follow]), { once: true });
|
||||
const { socket } = open([missed, n.follow]);
|
||||
await screen.findByText(/started following you/);
|
||||
socket.drop();
|
||||
await act(() => new Promise((r) => setTimeout(r, 600)));
|
||||
FakeWebSocket.last.welcome({ unreadNotificationCount: 1 });
|
||||
const upvoted = await screen.findByText(/^upvoted$/);
|
||||
expect(document.querySelector(".notification-item"))
|
||||
.toContainElement(upvoted);
|
||||
await waitFor(() =>
|
||||
expect(calls("POST /api/me/notifications/read")).toHaveLength(2)
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Notifications" }))
|
||||
.toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
test("one that arrives while the list is loading isn't lost", async () => {
|
||||
const list = deferred<unknown>();
|
||||
route("GET /api/me/notifications", () => list.promise);
|
||||
@@ -274,7 +301,7 @@ describe("Notifications", () => {
|
||||
// read-all after the load will mark it read.
|
||||
socket.receive({
|
||||
type: "notification_created",
|
||||
notification: wire(n.upvoted),
|
||||
notification: wire({ ...n.upvoted, createdAt: new Date() }),
|
||||
});
|
||||
// And one the list does have, sent while it was on its way: shown once.
|
||||
socket.receive({
|
||||
@@ -295,7 +322,7 @@ describe("Notifications", () => {
|
||||
expect(calls("POST /api/me/notifications/read")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("load more", async () => {
|
||||
test("the next page loads as the end scrolls into view", async () => {
|
||||
route(
|
||||
"GET /api/me/notifications",
|
||||
(req) =>
|
||||
@@ -304,11 +331,55 @@ describe("Notifications", () => {
|
||||
: page([n.upvoted], false),
|
||||
);
|
||||
route("POST /api/me/notifications/read", () => ({}));
|
||||
const user = userEvent.setup();
|
||||
renderApp(<Notifications />, { session: me });
|
||||
await user.click(await screen.findByRole("button", { name: "Load more" }));
|
||||
await screen.findByText(/^upvoted$/);
|
||||
expect(screen.queryByRole("button", { name: "Load more" })).not
|
||||
.toBeInTheDocument();
|
||||
await screen.findByText(/started following you/);
|
||||
act(() => FakeIntersectionObserver.instances.at(-1)!.report(true));
|
||||
expect(await screen.findByText(/^upvoted$/)).toBeInTheDocument();
|
||||
expect(screen.getByText("You've reached the end.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("leaving saves the list, with the ones marked read as read", async () => {
|
||||
const { view } = open([n.follow, { ...n.upvoted, read: true }]);
|
||||
await screen.findByText(/started following you/);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Notifications" }))
|
||||
.toBeInTheDocument()
|
||||
);
|
||||
view.unmount();
|
||||
const saved = JSON.parse(sessionStorage.getItem(CACHE_KEY)!);
|
||||
expect(saved.items.map((x: Notification) => [x.id, x.read])).toEqual([
|
||||
[n.follow.id, true],
|
||||
[n.upvoted.id, true],
|
||||
]);
|
||||
});
|
||||
|
||||
test("going back restores the list and the scroll, with what came meanwhile on top", async () => {
|
||||
sessionStorage.setItem(
|
||||
CACHE_KEY,
|
||||
JSON.stringify({
|
||||
items: [wire({ ...n.follow, read: true })],
|
||||
page: 1,
|
||||
hasMore: false,
|
||||
scrollY: 420,
|
||||
savedAt: Date.now(),
|
||||
}),
|
||||
);
|
||||
const scrollTo = vi.spyOn(globalThis, "scrollTo");
|
||||
const newer = { ...n.upvoted, createdAt: new Date(Date.now() + 1000) };
|
||||
// A MemoryRouter's first entry is a POP, as after Back.
|
||||
open([newer, { ...n.follow, read: true }]);
|
||||
// Shown before the page is fetched again.
|
||||
expect(screen.getByText(/started following you/)).toBeInTheDocument();
|
||||
expect(scrollTo).toHaveBeenCalledWith(0, 420);
|
||||
const upvoted = await screen.findByText(/^upvoted$/);
|
||||
const items = document.querySelectorAll(".notification-item");
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0]).toContainElement(upvoted);
|
||||
expect(items[0]).toHaveClass("notification-item--unread");
|
||||
expect(items[1]).not.toHaveClass("notification-item--unread");
|
||||
await waitFor(() =>
|
||||
expect(calls("POST /api/me/notifications/read")).toHaveLength(1)
|
||||
);
|
||||
expect(calls("GET /api/me/notifications")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -119,3 +119,11 @@ export function deserializePlaylistMembership(
|
||||
export function deserializeNotification(raw: Wire<Notification>): Notification {
|
||||
return { ...raw, createdAt: new Date(raw.createdAt) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `raw` as a {@link Notification}, for `useFeedCache`, which reads it
|
||||
* back untyped from sessionStorage.
|
||||
*/
|
||||
export function hydrateNotification(raw: unknown): Notification {
|
||||
return deserializeNotification(raw as Wire<Notification>);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user