4
0
Эх сурвалжийг харах

Open What's new from a tab on the edge of the screen and restyle the panel (#598)

* Open What's new from a tab on the edge of the screen and restyle the panel

The header icon is replaced by a vertical What's new tab on the right edge, on desktop only, which can be hidden and switched back on in Settings, About. The panel is a timeline of cards in the theme colour and loads ten entries at a time as it is scrolled. The account menu and Settings, About also open it. What's new is now Torqvoice Cloud only, and an empty list is checked again after a minute.

* Show a YouTube link on an entry, ask for a comment only on thumbs down, and make paging recover from a failed page
Bernt Christian Egeland 7 цаг өмнө
parent
commit
92e7071f3a
44 өөрчлөгдсөн 856 нэмэгдсэн , 149 устгасан
  1. 5 0
      messages/de/common.json
  2. 5 1
      messages/de/settings.json
  3. 5 0
      messages/en/common.json
  4. 5 1
      messages/en/settings.json
  5. 5 0
      messages/es/common.json
  6. 5 1
      messages/es/settings.json
  7. 5 0
      messages/fr/common.json
  8. 5 1
      messages/fr/settings.json
  9. 5 0
      messages/it/common.json
  10. 5 1
      messages/it/settings.json
  11. 5 0
      messages/lt/common.json
  12. 5 1
      messages/lt/settings.json
  13. 5 0
      messages/nb/common.json
  14. 5 1
      messages/nb/settings.json
  15. 5 0
      messages/nl/common.json
  16. 5 1
      messages/nl/settings.json
  17. 5 0
      messages/pl/common.json
  18. 5 1
      messages/pl/settings.json
  19. 5 0
      messages/pt-BR/common.json
  20. 5 1
      messages/pt-BR/settings.json
  21. 5 0
      messages/pt-PT/common.json
  22. 5 1
      messages/pt-PT/settings.json
  23. 5 0
      messages/ru/common.json
  24. 5 1
      messages/ru/settings.json
  25. 5 0
      messages/tr/common.json
  26. 5 1
      messages/tr/settings.json
  27. 1 0
      src/__tests__/features/whats-new/select.test.ts
  28. 144 5
      src/__tests__/features/whats-new/whats-new-panel.test.tsx
  29. 10 4
      src/app/(authenticated)/layout.tsx
  30. 2 0
      src/app/(authenticated)/settings/about/page.tsx
  31. 2 0
      src/components/app-sidebar.tsx
  32. 0 2
      src/components/page-header.tsx
  33. 52 0
      src/features/whats-new/Actions/loadMoreWhatsNew.ts
  34. 41 0
      src/features/whats-new/Components/WhatsNewAboutRows.tsx
  35. 0 37
      src/features/whats-new/Components/WhatsNewButton.tsx
  36. 21 19
      src/features/whats-new/Components/WhatsNewFeedback.tsx
  37. 42 0
      src/features/whats-new/Components/WhatsNewMenuItem.tsx
  38. 189 60
      src/features/whats-new/Components/WhatsNewPanel.tsx
  39. 107 8
      src/features/whats-new/Components/WhatsNewProvider.tsx
  40. 71 0
      src/features/whats-new/Components/WhatsNewTab.tsx
  41. 13 1
      src/features/whats-new/Lib/feed.server.ts
  42. 6 0
      src/features/whats-new/Lib/paging.ts
  43. 23 0
      src/features/whats-new/Lib/tabPreference.ts
  44. 2 0
      src/features/whats-new/Schema/whatsNewSchema.ts

+ 5 - 0
messages/de/common.json

@@ -59,10 +59,15 @@
     "openUnread": "Neuigkeiten: Updates, die Sie noch nicht gesehen haben",
     "openUnread": "Neuigkeiten: Updates, die Sie noch nicht gesehen haben",
     "description": "Die neuesten Funktionen, die aktuellsten zuerst.",
     "description": "Die neuesten Funktionen, die aktuellsten zuerst.",
     "unread": "Neu seit Ihrem letzten Besuch",
     "unread": "Neu seit Ihrem letzten Besuch",
+    "newCount": "{count} neu",
+    "showOlder": "Ältere anzeigen",
+    "hideTab": "Diesen Reiter ausblenden",
+    "tabHidden": "Reiter ausgeblendet. Sie können ihn unter Einstellungen, Info wieder einschalten.",
     "kindNew": "Neu",
     "kindNew": "Neu",
     "kindImproved": "Verbessert",
     "kindImproved": "Verbessert",
     "open": "Öffnen",
     "open": "Öffnen",
     "guide": "Anleitung lesen",
     "guide": "Anleitung lesen",
+    "video": "Video ansehen",
     "version": "Version {version}",
     "version": "Version {version}",
     "allReleaseNotes": "Alle Versionshinweise",
     "allReleaseNotes": "Alle Versionshinweise",
     "feedback": {
     "feedback": {

+ 5 - 1
messages/de/settings.json

@@ -1691,7 +1691,11 @@
     "version": "Version",
     "version": "Version",
     "website": "Webseite",
     "website": "Webseite",
     "documentation": "Dokumentation",
     "documentation": "Dokumentation",
-    "changelog": "Anderungsprotokoll"
+    "changelog": "Anderungsprotokoll",
+    "whatsNew": "Neuigkeiten",
+    "whatsNewOpen": "Neuigkeiten ansehen",
+    "whatsNewTab": "Den Reiter Neuigkeiten am Bildschirmrand anzeigen",
+    "whatsNewTabHint": "Nur auf größeren Bildschirmen."
   },
   },
   "portal": {
   "portal": {
     "title": "Kundenportal",
     "title": "Kundenportal",

+ 5 - 0
messages/en/common.json

@@ -59,10 +59,15 @@
     "openUnread": "What's new: updates you have not seen",
     "openUnread": "What's new: updates you have not seen",
     "description": "The latest additions, newest first.",
     "description": "The latest additions, newest first.",
     "unread": "New since you last looked",
     "unread": "New since you last looked",
+    "newCount": "{count} new",
+    "showOlder": "Show older updates",
+    "hideTab": "Hide this tab",
+    "tabHidden": "Tab hidden. You can turn it back on in Settings, About.",
     "kindNew": "New",
     "kindNew": "New",
     "kindImproved": "Improved",
     "kindImproved": "Improved",
     "open": "Open",
     "open": "Open",
     "guide": "Read the guide",
     "guide": "Read the guide",
+    "video": "Watch the video",
     "version": "Version {version}",
     "version": "Version {version}",
     "allReleaseNotes": "All release notes",
     "allReleaseNotes": "All release notes",
     "feedback": {
     "feedback": {

+ 5 - 1
messages/en/settings.json

@@ -1691,7 +1691,11 @@
     "version": "Version",
     "version": "Version",
     "website": "Website",
     "website": "Website",
     "documentation": "Documentation",
     "documentation": "Documentation",
-    "changelog": "Changelog"
+    "changelog": "Changelog",
+    "whatsNew": "What's new",
+    "whatsNewOpen": "See what is new",
+    "whatsNewTab": "Show the What's new tab at the side of the screen",
+    "whatsNewTabHint": "On larger screens only."
   },
   },
   "portal": {
   "portal": {
     "title": "Customer Portal",
     "title": "Customer Portal",

+ 5 - 0
messages/es/common.json

@@ -59,10 +59,15 @@
     "openUnread": "Novedades: actualizaciones que aún no ha visto",
     "openUnread": "Novedades: actualizaciones que aún no ha visto",
     "description": "Las últimas incorporaciones, las más recientes primero.",
     "description": "Las últimas incorporaciones, las más recientes primero.",
     "unread": "Nuevo desde su última visita",
     "unread": "Nuevo desde su última visita",
+    "newCount": "{count, plural, one {# nueva} other {# nuevas}}",
+    "showOlder": "Ver anteriores",
+    "hideTab": "Ocultar esta pestaña",
+    "tabHidden": "Pestaña oculta. Puede volver a activarla en Configuración, Acerca de.",
     "kindNew": "Nuevo",
     "kindNew": "Nuevo",
     "kindImproved": "Mejorado",
     "kindImproved": "Mejorado",
     "open": "Abrir",
     "open": "Abrir",
     "guide": "Leer la guía",
     "guide": "Leer la guía",
+    "video": "Ver el vídeo",
     "version": "Versión {version}",
     "version": "Versión {version}",
     "allReleaseNotes": "Todas las notas de versión",
     "allReleaseNotes": "Todas las notas de versión",
     "feedback": {
     "feedback": {

+ 5 - 1
messages/es/settings.json

@@ -1691,7 +1691,11 @@
     "version": "Versión",
     "version": "Versión",
     "website": "Sitio web",
     "website": "Sitio web",
     "documentation": "Documentacion",
     "documentation": "Documentacion",
-    "changelog": "Registro de cambios"
+    "changelog": "Registro de cambios",
+    "whatsNew": "Novedades",
+    "whatsNewOpen": "Ver las novedades",
+    "whatsNewTab": "Mostrar la pestaña Novedades en el lateral de la pantalla",
+    "whatsNewTabHint": "Solo en pantallas grandes."
   },
   },
   "portal": {
   "portal": {
     "title": "Portal del cliente",
     "title": "Portal del cliente",

+ 5 - 0
messages/fr/common.json

@@ -59,10 +59,15 @@
     "openUnread": "Nouveautés : mises à jour que vous n'avez pas encore vues",
     "openUnread": "Nouveautés : mises à jour que vous n'avez pas encore vues",
     "description": "Les derniers ajouts, du plus récent au plus ancien.",
     "description": "Les derniers ajouts, du plus récent au plus ancien.",
     "unread": "Nouveau depuis votre dernière visite",
     "unread": "Nouveau depuis votre dernière visite",
+    "newCount": "{count, plural, one {# nouveauté} other {# nouveautés}}",
+    "showOlder": "Afficher les plus anciennes",
+    "hideTab": "Masquer cet onglet",
+    "tabHidden": "Onglet masqué. Vous pouvez le réactiver dans Paramètres, A propos.",
     "kindNew": "Nouveau",
     "kindNew": "Nouveau",
     "kindImproved": "Amélioré",
     "kindImproved": "Amélioré",
     "open": "Ouvrir",
     "open": "Ouvrir",
     "guide": "Lire le guide",
     "guide": "Lire le guide",
+    "video": "Voir la vidéo",
     "version": "Version {version}",
     "version": "Version {version}",
     "allReleaseNotes": "Toutes les notes de version",
     "allReleaseNotes": "Toutes les notes de version",
     "feedback": {
     "feedback": {

+ 5 - 1
messages/fr/settings.json

@@ -1691,7 +1691,11 @@
     "version": "Version",
     "version": "Version",
     "website": "Site web",
     "website": "Site web",
     "documentation": "Documentation",
     "documentation": "Documentation",
-    "changelog": "Journal des modifications"
+    "changelog": "Journal des modifications",
+    "whatsNew": "Nouveautés",
+    "whatsNewOpen": "Voir les nouveautés",
+    "whatsNewTab": "Afficher l'onglet Nouveautés sur le côté de l'écran",
+    "whatsNewTabHint": "Uniquement sur les grands écrans."
   },
   },
   "portal": {
   "portal": {
     "title": "Portail client",
     "title": "Portail client",

+ 5 - 0
messages/it/common.json

@@ -59,10 +59,15 @@
     "openUnread": "Novità: aggiornamenti che non ha ancora visto",
     "openUnread": "Novità: aggiornamenti che non ha ancora visto",
     "description": "Le ultime aggiunte, dalle più recenti.",
     "description": "Le ultime aggiunte, dalle più recenti.",
     "unread": "Nuovo dalla Sua ultima visita",
     "unread": "Nuovo dalla Sua ultima visita",
+    "newCount": "{count, plural, one {# nuova} other {# nuove}}",
+    "showOlder": "Mostra le precedenti",
+    "hideTab": "Nascondi questa scheda",
+    "tabHidden": "Scheda nascosta. Può riattivarla in Impostazioni, Informazioni.",
     "kindNew": "Nuovo",
     "kindNew": "Nuovo",
     "kindImproved": "Migliorato",
     "kindImproved": "Migliorato",
     "open": "Apri",
     "open": "Apri",
     "guide": "Leggi la guida",
     "guide": "Leggi la guida",
+    "video": "Guarda il video",
     "version": "Versione {version}",
     "version": "Versione {version}",
     "allReleaseNotes": "Tutte le note di rilascio",
     "allReleaseNotes": "Tutte le note di rilascio",
     "feedback": {
     "feedback": {

+ 5 - 1
messages/it/settings.json

@@ -1691,7 +1691,11 @@
     "version": "Versione",
     "version": "Versione",
     "website": "Sito web",
     "website": "Sito web",
     "documentation": "Documentazione",
     "documentation": "Documentazione",
-    "changelog": "Registro modifiche"
+    "changelog": "Registro modifiche",
+    "whatsNew": "Novità",
+    "whatsNewOpen": "Scopri le novità",
+    "whatsNewTab": "Mostra la scheda Novità sul lato dello schermo",
+    "whatsNewTabHint": "Solo su schermi più grandi."
   },
   },
   "portal": {
   "portal": {
     "title": "Portale clienti",
     "title": "Portale clienti",

+ 5 - 0
messages/lt/common.json

@@ -59,10 +59,15 @@
     "openUnread": "Naujienos: atnaujinimai, kurių dar nematėte",
     "openUnread": "Naujienos: atnaujinimai, kurių dar nematėte",
     "description": "Naujausi papildymai, nuo naujausių.",
     "description": "Naujausi papildymai, nuo naujausių.",
     "unread": "Nauja nuo paskutinio apsilankymo",
     "unread": "Nauja nuo paskutinio apsilankymo",
+    "newCount": "Naujų: {count}",
+    "showOlder": "Rodyti senesnius",
+    "hideTab": "Slėpti šį skirtuką",
+    "tabHidden": "Skirtukas paslėptas. Vėl įjungti galite skiltyje Nustatymai, Apie.",
     "kindNew": "Nauja",
     "kindNew": "Nauja",
     "kindImproved": "Patobulinta",
     "kindImproved": "Patobulinta",
     "open": "Atidaryti",
     "open": "Atidaryti",
     "guide": "Skaityti vadovą",
     "guide": "Skaityti vadovą",
+    "video": "Žiūrėti vaizdo įrašą",
     "version": "Versija {version}",
     "version": "Versija {version}",
     "allReleaseNotes": "Visos versijų pastabos",
     "allReleaseNotes": "Visos versijų pastabos",
     "feedback": {
     "feedback": {

+ 5 - 1
messages/lt/settings.json

@@ -1691,7 +1691,11 @@
     "version": "Versija",
     "version": "Versija",
     "website": "Svetainė",
     "website": "Svetainė",
     "documentation": "Dokumentacija",
     "documentation": "Dokumentacija",
-    "changelog": "Pakeitimų žurnalas"
+    "changelog": "Pakeitimų žurnalas",
+    "whatsNew": "Naujienos",
+    "whatsNewOpen": "Žiūrėti naujienas",
+    "whatsNewTab": "Rodyti skirtuką Naujienos ekrano šone",
+    "whatsNewTabHint": "Tik didesniuose ekranuose."
   },
   },
   "portal": {
   "portal": {
     "title": "Kliento portalas",
     "title": "Kliento portalas",

+ 5 - 0
messages/nb/common.json

@@ -59,10 +59,15 @@
     "openUnread": "Hva er nytt: oppdateringer du ikke har sett",
     "openUnread": "Hva er nytt: oppdateringer du ikke har sett",
     "description": "De siste nyhetene, nyeste først.",
     "description": "De siste nyhetene, nyeste først.",
     "unread": "Nytt siden sist",
     "unread": "Nytt siden sist",
+    "newCount": "{count, plural, one {# ny} other {# nye}}",
+    "showOlder": "Vis eldre",
+    "hideTab": "Skjul denne fanen",
+    "tabHidden": "Fanen er skjult. Du kan slå den på igjen under Innstillinger, Om.",
     "kindNew": "Nytt",
     "kindNew": "Nytt",
     "kindImproved": "Forbedret",
     "kindImproved": "Forbedret",
     "open": "Åpne",
     "open": "Åpne",
     "guide": "Les veiledningen",
     "guide": "Les veiledningen",
+    "video": "Se videoen",
     "version": "Versjon {version}",
     "version": "Versjon {version}",
     "allReleaseNotes": "Alle versjonsmerknader",
     "allReleaseNotes": "Alle versjonsmerknader",
     "feedback": {
     "feedback": {

+ 5 - 1
messages/nb/settings.json

@@ -1691,7 +1691,11 @@
     "version": "Versjon",
     "version": "Versjon",
     "website": "Nettsted",
     "website": "Nettsted",
     "documentation": "Dokumentasjon",
     "documentation": "Dokumentasjon",
-    "changelog": "Endringslogg"
+    "changelog": "Endringslogg",
+    "whatsNew": "Hva er nytt",
+    "whatsNewOpen": "Se hva som er nytt",
+    "whatsNewTab": "Vis Hva er nytt-fanen på siden av skjermen",
+    "whatsNewTabHint": "Bare på større skjermer."
   },
   },
   "portal": {
   "portal": {
     "title": "Kundeportal",
     "title": "Kundeportal",

+ 5 - 0
messages/nl/common.json

@@ -59,10 +59,15 @@
     "openUnread": "Wat is er nieuw: updates die u nog niet hebt gezien",
     "openUnread": "Wat is er nieuw: updates die u nog niet hebt gezien",
     "description": "De nieuwste toevoegingen, nieuwste eerst.",
     "description": "De nieuwste toevoegingen, nieuwste eerst.",
     "unread": "Nieuw sinds uw laatste bezoek",
     "unread": "Nieuw sinds uw laatste bezoek",
+    "newCount": "{count} nieuw",
+    "showOlder": "Oudere tonen",
+    "hideTab": "Dit tabblad verbergen",
+    "tabHidden": "Tabblad verborgen. U kunt het weer aanzetten via Instellingen, Over.",
     "kindNew": "Nieuw",
     "kindNew": "Nieuw",
     "kindImproved": "Verbeterd",
     "kindImproved": "Verbeterd",
     "open": "Openen",
     "open": "Openen",
     "guide": "Lees de handleiding",
     "guide": "Lees de handleiding",
+    "video": "Bekijk de video",
     "version": "Versie {version}",
     "version": "Versie {version}",
     "allReleaseNotes": "Alle release notes",
     "allReleaseNotes": "Alle release notes",
     "feedback": {
     "feedback": {

+ 5 - 1
messages/nl/settings.json

@@ -1691,7 +1691,11 @@
     "version": "Versie",
     "version": "Versie",
     "website": "Website",
     "website": "Website",
     "documentation": "Documentatie",
     "documentation": "Documentatie",
-    "changelog": "Wijzigingslog"
+    "changelog": "Wijzigingslog",
+    "whatsNew": "Wat is er nieuw",
+    "whatsNewOpen": "Bekijk wat er nieuw is",
+    "whatsNewTab": "Het tabblad Wat is er nieuw aan de zijkant van het scherm tonen",
+    "whatsNewTabHint": "Alleen op grotere schermen."
   },
   },
   "portal": {
   "portal": {
     "title": "Klantenportaal",
     "title": "Klantenportaal",

+ 5 - 0
messages/pl/common.json

@@ -59,10 +59,15 @@
     "openUnread": "Nowości: aktualizacje, których jeszcze nie znasz",
     "openUnread": "Nowości: aktualizacje, których jeszcze nie znasz",
     "description": "Ostatnie zmiany, od najnowszych.",
     "description": "Ostatnie zmiany, od najnowszych.",
     "unread": "Nowe od Twojej ostatniej wizyty",
     "unread": "Nowe od Twojej ostatniej wizyty",
+    "newCount": "{count, plural, one {# nowość} few {# nowości} many {# nowości} other {# nowości}}",
+    "showOlder": "Pokaż starsze",
+    "hideTab": "Ukryj tę kartę",
+    "tabHidden": "Karta ukryta. Możesz ją ponownie włączyć w sekcji Ustawienia, Informacje.",
     "kindNew": "Nowość",
     "kindNew": "Nowość",
     "kindImproved": "Ulepszone",
     "kindImproved": "Ulepszone",
     "open": "Otwórz",
     "open": "Otwórz",
     "guide": "Przeczytaj przewodnik",
     "guide": "Przeczytaj przewodnik",
+    "video": "Obejrzyj film",
     "version": "Wersja {version}",
     "version": "Wersja {version}",
     "allReleaseNotes": "Wszystkie informacje o wydaniach",
     "allReleaseNotes": "Wszystkie informacje o wydaniach",
     "feedback": {
     "feedback": {

+ 5 - 1
messages/pl/settings.json

@@ -1691,7 +1691,11 @@
     "version": "Wersja",
     "version": "Wersja",
     "website": "Strona",
     "website": "Strona",
     "documentation": "Dokumentacja",
     "documentation": "Dokumentacja",
-    "changelog": "Dziennik zmian"
+    "changelog": "Dziennik zmian",
+    "whatsNew": "Nowości",
+    "whatsNewOpen": "Zobacz nowości",
+    "whatsNewTab": "Pokazuj kartę Nowości z boku ekranu",
+    "whatsNewTabHint": "Tylko na większych ekranach."
   },
   },
   "portal": {
   "portal": {
     "title": "Portal klienta",
     "title": "Portal klienta",

+ 5 - 0
messages/pt-BR/common.json

@@ -59,10 +59,15 @@
     "openUnread": "Novidades: atualizações que você ainda não viu",
     "openUnread": "Novidades: atualizações que você ainda não viu",
     "description": "As últimas adições, das mais recentes para as mais antigas.",
     "description": "As últimas adições, das mais recentes para as mais antigas.",
     "unread": "Novo desde sua última visita",
     "unread": "Novo desde sua última visita",
+    "newCount": "{count, plural, one {# nova} other {# novas}}",
+    "showOlder": "Mostrar anteriores",
+    "hideTab": "Ocultar esta aba",
+    "tabHidden": "Aba oculta. Você pode reativá-la em Configurações, Sobre.",
     "kindNew": "Novo",
     "kindNew": "Novo",
     "kindImproved": "Melhorado",
     "kindImproved": "Melhorado",
     "open": "Abrir",
     "open": "Abrir",
     "guide": "Ler o guia",
     "guide": "Ler o guia",
+    "video": "Assistir ao vídeo",
     "version": "Versão {version}",
     "version": "Versão {version}",
     "allReleaseNotes": "Todas as notas de versão",
     "allReleaseNotes": "Todas as notas de versão",
     "feedback": {
     "feedback": {

+ 5 - 1
messages/pt-BR/settings.json

@@ -1691,7 +1691,11 @@
     "version": "Versão",
     "version": "Versão",
     "website": "Site",
     "website": "Site",
     "documentation": "Documentação",
     "documentation": "Documentação",
-    "changelog": "Registro de alterações"
+    "changelog": "Registro de alterações",
+    "whatsNew": "Novidades",
+    "whatsNewOpen": "Ver as novidades",
+    "whatsNewTab": "Mostrar a aba Novidades na lateral da tela",
+    "whatsNewTabHint": "Somente em telas maiores."
   },
   },
   "portal": {
   "portal": {
     "title": "Portal do cliente",
     "title": "Portal do cliente",

+ 5 - 0
messages/pt-PT/common.json

@@ -59,10 +59,15 @@
     "openUnread": "Novidades: atualizações que ainda não viu",
     "openUnread": "Novidades: atualizações que ainda não viu",
     "description": "As últimas adições, das mais recentes para as mais antigas.",
     "description": "As últimas adições, das mais recentes para as mais antigas.",
     "unread": "Novo desde a sua última visita",
     "unread": "Novo desde a sua última visita",
+    "newCount": "{count, plural, one {# nova} other {# novas}}",
+    "showOlder": "Mostrar anteriores",
+    "hideTab": "Ocultar este separador",
+    "tabHidden": "Separador oculto. Pode voltar a ativá-lo em Definições, Sobre.",
     "kindNew": "Novo",
     "kindNew": "Novo",
     "kindImproved": "Melhorado",
     "kindImproved": "Melhorado",
     "open": "Abrir",
     "open": "Abrir",
     "guide": "Ler o guia",
     "guide": "Ler o guia",
+    "video": "Ver o vídeo",
     "version": "Versão {version}",
     "version": "Versão {version}",
     "allReleaseNotes": "Todas as notas de versão",
     "allReleaseNotes": "Todas as notas de versão",
     "feedback": {
     "feedback": {

+ 5 - 1
messages/pt-PT/settings.json

@@ -1691,7 +1691,11 @@
     "version": "Versão",
     "version": "Versão",
     "website": "Site",
     "website": "Site",
     "documentation": "Documentação",
     "documentation": "Documentação",
-    "changelog": "Registo de alterações"
+    "changelog": "Registo de alterações",
+    "whatsNew": "Novidades",
+    "whatsNewOpen": "Ver as novidades",
+    "whatsNewTab": "Mostrar o separador Novidades na lateral do ecrã",
+    "whatsNewTabHint": "Apenas em ecrãs maiores."
   },
   },
   "portal": {
   "portal": {
     "title": "Portal do cliente",
     "title": "Portal do cliente",

+ 5 - 0
messages/ru/common.json

@@ -59,10 +59,15 @@
     "openUnread": "Что нового: обновления, которые вы ещё не видели",
     "openUnread": "Что нового: обновления, которые вы ещё не видели",
     "description": "Последние новинки, сначала самые свежие.",
     "description": "Последние новинки, сначала самые свежие.",
     "unread": "Новое с вашего последнего визита",
     "unread": "Новое с вашего последнего визита",
+    "newCount": "{count, plural, one {# новое} few {# новых} many {# новых} other {# новых}}",
+    "showOlder": "Показать более ранние",
+    "hideTab": "Скрыть эту вкладку",
+    "tabHidden": "Вкладка скрыта. Включить её снова можно в разделе Настройки, О программе.",
     "kindNew": "Новое",
     "kindNew": "Новое",
     "kindImproved": "Улучшено",
     "kindImproved": "Улучшено",
     "open": "Открыть",
     "open": "Открыть",
     "guide": "Читать руководство",
     "guide": "Читать руководство",
+    "video": "Смотреть видео",
     "version": "Версия {version}",
     "version": "Версия {version}",
     "allReleaseNotes": "Все примечания к выпускам",
     "allReleaseNotes": "Все примечания к выпускам",
     "feedback": {
     "feedback": {

+ 5 - 1
messages/ru/settings.json

@@ -1691,7 +1691,11 @@
     "version": "Версия",
     "version": "Версия",
     "website": "Веб-сайт",
     "website": "Веб-сайт",
     "documentation": "Документация",
     "documentation": "Документация",
-    "changelog": "Журнал изменений"
+    "changelog": "Журнал изменений",
+    "whatsNew": "Что нового",
+    "whatsNewOpen": "Посмотреть, что нового",
+    "whatsNewTab": "Показывать вкладку «Что нового» сбоку экрана",
+    "whatsNewTabHint": "Только на больших экранах."
   },
   },
   "portal": {
   "portal": {
     "title": "Портал клиента",
     "title": "Портал клиента",

+ 5 - 0
messages/tr/common.json

@@ -59,10 +59,15 @@
     "openUnread": "Yenilikler: henüz görmediğiniz güncellemeler",
     "openUnread": "Yenilikler: henüz görmediğiniz güncellemeler",
     "description": "En son eklenenler, en yenisi önce.",
     "description": "En son eklenenler, en yenisi önce.",
     "unread": "Son ziyaretinizden bu yana yeni",
     "unread": "Son ziyaretinizden bu yana yeni",
+    "newCount": "{count} yeni",
+    "showOlder": "Daha eskileri göster",
+    "hideTab": "Bu sekmeyi gizle",
+    "tabHidden": "Sekme gizlendi. Ayarlar, Hakkında bölümünden yeniden açabilirsiniz.",
     "kindNew": "Yeni",
     "kindNew": "Yeni",
     "kindImproved": "İyileştirildi",
     "kindImproved": "İyileştirildi",
     "open": "Aç",
     "open": "Aç",
     "guide": "Kılavuzu oku",
     "guide": "Kılavuzu oku",
+    "video": "Videoyu izle",
     "version": "Sürüm {version}",
     "version": "Sürüm {version}",
     "allReleaseNotes": "Tüm sürüm notları",
     "allReleaseNotes": "Tüm sürüm notları",
     "feedback": {
     "feedback": {

+ 5 - 1
messages/tr/settings.json

@@ -1691,7 +1691,11 @@
     "version": "Sürüm",
     "version": "Sürüm",
     "website": "Web Sitesi",
     "website": "Web Sitesi",
     "documentation": "Belgeler",
     "documentation": "Belgeler",
-    "changelog": "Değişiklik Günlüğü"
+    "changelog": "Değişiklik Günlüğü",
+    "whatsNew": "Yenilikler",
+    "whatsNewOpen": "Yenilikleri gör",
+    "whatsNewTab": "Yenilikler sekmesini ekranın kenarında göster",
+    "whatsNewTabHint": "Yalnızca büyük ekranlarda."
   },
   },
   "portal": {
   "portal": {
     "title": "Müşteri Portalı",
     "title": "Müşteri Portalı",

+ 1 - 0
src/__tests__/features/whats-new/select.test.ts

@@ -21,6 +21,7 @@ function entry(overrides: Partial<WhatsNewEntry> = {}): WhatsNewEntry {
     ctaLabel: null,
     ctaLabel: null,
     ctaHref: null,
     ctaHref: null,
     docsHref: null,
     docsHref: null,
+    videoUrl: null,
     minVersion: null,
     minVersion: null,
     audience: 'all',
     audience: 'all',
     subject: null,
     subject: null,

+ 144 - 5
src/__tests__/features/whats-new/whats-new-panel.test.tsx

@@ -22,11 +22,24 @@ vi.mock('@/features/whats-new/Actions/sendWhatsNewFeedback', () => ({
   sendWhatsNewFeedback: (input: unknown) => sendWhatsNewFeedback(input),
   sendWhatsNewFeedback: (input: unknown) => sendWhatsNewFeedback(input),
 }))
 }))
 
 
+const loadMoreWhatsNew = vi.fn(async (_offset: unknown) => ({
+  success: true,
+  data: { entries: [] as WhatsNewEntry[], hasMore: false },
+}))
+vi.mock('@/features/whats-new/Actions/loadMoreWhatsNew', () => ({
+  loadMoreWhatsNew: (offset: unknown) => loadMoreWhatsNew(offset),
+}))
+
 const toastError = vi.fn()
 const toastError = vi.fn()
-vi.mock('sonner', () => ({ toast: { error: (message: string) => toastError(message) } }))
+const toastPlain = vi.fn()
+vi.mock('sonner', () => ({
+  toast: Object.assign((message: string) => toastPlain(message), {
+    error: (message: string) => toastError(message),
+  }),
+}))
 
 
 const { WhatsNewProvider } = await import('@/features/whats-new/Components/WhatsNewProvider')
 const { WhatsNewProvider } = await import('@/features/whats-new/Components/WhatsNewProvider')
-const { WhatsNewButton } = await import('@/features/whats-new/Components/WhatsNewButton')
+const { WhatsNewAboutRows } = await import('@/features/whats-new/Components/WhatsNewAboutRows')
 
 
 const ENTRIES: WhatsNewEntry[] = [
 const ENTRIES: WhatsNewEntry[] = [
   {
   {
@@ -38,6 +51,7 @@ const ENTRIES: WhatsNewEntry[] = [
     ctaLabel: 'Open the calendar',
     ctaLabel: 'Open the calendar',
     ctaHref: '/calendar',
     ctaHref: '/calendar',
     docsHref: '/docs/features/calendar',
     docsHref: '/docs/features/calendar',
+    videoUrl: 'https://youtu.be/abc123',
     minVersion: null,
     minVersion: null,
     audience: 'all',
     audience: 'all',
     subject: null,
     subject: null,
@@ -52,6 +66,7 @@ const ENTRIES: WhatsNewEntry[] = [
     ctaLabel: null,
     ctaLabel: null,
     ctaHref: null,
     ctaHref: null,
     docsHref: null,
     docsHref: null,
+    videoUrl: null,
     minVersion: null,
     minVersion: null,
     audience: 'all',
     audience: 'all',
     subject: null,
     subject: null,
@@ -63,17 +78,21 @@ function show({
   entries = ENTRIES,
   entries = ENTRIES,
   unread = ['appointments'],
   unread = ['appointments'],
   feedback = 'anonymous' as 'identified' | 'anonymous' | null,
   feedback = 'anonymous' as 'identified' | 'anonymous' | null,
+  tabHidden = false,
+  hasMore = false,
 } = {}) {
 } = {}) {
   return render(
   return render(
     <WhatsNewProvider
     <WhatsNewProvider
       entries={entries}
       entries={entries}
+      hasMore={hasMore}
       unreadIds={unread}
       unreadIds={unread}
+      initialTabHidden={tabHidden}
       timeZone="Europe/Oslo"
       timeZone="Europe/Oslo"
       appVersion="v2.14.0"
       appVersion="v2.14.0"
       releaseNotesUrl="https://example.test/releases"
       releaseNotesUrl="https://example.test/releases"
       feedback={feedback}
       feedback={feedback}
     >
     >
-      <WhatsNewButton />
+      <WhatsNewAboutRows />
     </WhatsNewProvider>
     </WhatsNewProvider>
   )
   )
 }
 }
@@ -84,9 +103,10 @@ beforeEach(() => {
 })
 })
 
 
 describe("What's new", () => {
 describe("What's new", () => {
-  it('offers no button when there is nothing to read', () => {
+  it('offers nothing to press when there is nothing to read', () => {
     show({ entries: [], unread: [] })
     show({ entries: [], unread: [] })
     expect(screen.queryByRole('button')).not.toBeInTheDocument()
     expect(screen.queryByRole('button')).not.toBeInTheDocument()
+    expect(screen.queryByRole('switch')).not.toBeInTheDocument()
   })
   })
 
 
   it('says there is something unseen, then stops saying so once the panel was opened', async () => {
   it('says there is something unseen, then stops saying so once the panel was opened', async () => {
@@ -151,7 +171,7 @@ describe("What's new", () => {
     show({ feedback: 'identified' })
     show({ feedback: 'identified' })
     await user.click(screen.getByRole('button', { name: /What's new/ }))
     await user.click(screen.getByRole('button', { name: /What's new/ }))
     const first = screen.getByText('Appointments on the calendar').closest('article') as HTMLElement
     const first = screen.getByText('Appointments on the calendar').closest('article') as HTMLElement
-    await user.click(within(first).getByRole('button', { name: 'Yes, this is useful' }))
+    await user.click(within(first).getByRole('button', { name: 'No, not what I expected' }))
     expect(
     expect(
       await within(first).findByText(
       await within(first).findByText(
         'Sent to the Torqvoice team with your email address, so we can reply.'
         'Sent to the Torqvoice team with your email address, so we can reply.'
@@ -159,6 +179,19 @@ describe("What's new", () => {
     ).toBeInTheDocument()
     ).toBeInTheDocument()
   })
   })
 
 
+  it('sends a thumb up and asks for nothing more', async () => {
+    const user = userEvent.setup()
+    show()
+    await user.click(screen.getByRole('button', { name: /What's new/ }))
+    const first = screen.getByText('Appointments on the calendar').closest('article') as HTMLElement
+    const up = within(first).getByRole('button', { name: 'Yes, this is useful' })
+    await user.click(up)
+
+    await waitFor(() => expect(up).toHaveAttribute('aria-pressed', 'true'))
+    expect(sendWhatsNewFeedback).toHaveBeenCalledWith({ entryId: 'appointments', vote: 'up' })
+    expect(within(first).queryByRole('textbox')).not.toBeInTheDocument()
+  })
+
   it('takes the thumb back and says so when the vote could not be sent', async () => {
   it('takes the thumb back and says so when the vote could not be sent', async () => {
     sendWhatsNewFeedback.mockResolvedValueOnce({ success: false })
     sendWhatsNewFeedback.mockResolvedValueOnce({ success: false })
     const user = userEvent.setup()
     const user = userEvent.setup()
@@ -224,3 +257,109 @@ describe("What's new, when things go wrong", () => {
     expect(screen.queryByRole('link', { name: /Open the calendar/ })).not.toBeInTheDocument()
     expect(screen.queryByRole('link', { name: /Open the calendar/ })).not.toBeInTheDocument()
   })
   })
 })
 })
+
+describe("the What's new tab on the edge of the screen", () => {
+  it('can be sent away, says where to get it back, and stays away', async () => {
+    const user = userEvent.setup()
+    show()
+    await user.click(screen.getByRole('button', { name: 'Hide this tab' }))
+
+    expect(screen.queryByRole('button', { name: /^What's new/ })).not.toBeInTheDocument()
+    expect(toastPlain).toHaveBeenCalledWith(
+      'Tab hidden. You can turn it back on in Settings, About.'
+    )
+    expect(document.cookie).toContain('whatsNewTab=hidden')
+  })
+
+  it('is not drawn for somebody who hid it, but the news can still be opened from About', async () => {
+    const user = userEvent.setup()
+    show({ tabHidden: true })
+    expect(screen.queryByRole('button', { name: /^What's new/ })).not.toBeInTheDocument()
+
+    await user.click(screen.getByRole('button', { name: 'See what is new' }))
+    expect(await screen.findByRole('dialog', { name: "What's new" })).toBeInTheDocument()
+  })
+
+  it('comes back when it is switched on again in About', async () => {
+    const user = userEvent.setup()
+    show({ tabHidden: true })
+    const toggle = screen.getByRole('switch', {
+      name: /Show the What's new tab at the side of the screen/,
+    })
+    expect(toggle).not.toBeChecked()
+    await user.click(toggle)
+
+    expect(screen.getByRole('button', { name: /^What's new/ })).toBeInTheDocument()
+    expect(document.cookie).toContain('whatsNewTab=shown')
+  })
+})
+
+describe("older highlights in the What's new panel", () => {
+  it('offers nothing more when the first page is all there is', async () => {
+    const user = userEvent.setup()
+    show()
+    await user.click(screen.getByRole('button', { name: /^What's new/ }))
+    expect(screen.queryByRole('button', { name: 'Show older updates' })).not.toBeInTheDocument()
+  })
+
+  it('adds the next page under the ones already shown, and stops offering at the end', async () => {
+    loadMoreWhatsNew.mockResolvedValueOnce({
+      success: true,
+      data: {
+        entries: [{ ...ENTRIES[1], id: 'closures', title: 'Closed days and staff leave' }],
+        hasMore: false,
+      },
+    })
+    const user = userEvent.setup()
+    show({ hasMore: true })
+    await user.click(screen.getByRole('button', { name: /^What's new/ }))
+    await user.click(screen.getByRole('button', { name: 'Show older updates' }))
+
+    expect(loadMoreWhatsNew).toHaveBeenCalledWith(ENTRIES.length)
+    expect(await screen.findByText('Closed days and staff leave')).toBeInTheDocument()
+    expect(screen.getByText('Appointments on the calendar')).toBeInTheDocument()
+    expect(screen.queryByRole('button', { name: 'Show older updates' })).not.toBeInTheDocument()
+  })
+
+  it('keeps the button after a page fails, and a second press brings the page', async () => {
+    loadMoreWhatsNew.mockRejectedValueOnce(new Error('offline'))
+    loadMoreWhatsNew.mockResolvedValueOnce({
+      success: true,
+      data: {
+        entries: [{ ...ENTRIES[1], id: 'closures', title: 'Closed days and staff leave' }],
+        hasMore: false,
+      },
+    })
+    const user = userEvent.setup()
+    show({ hasMore: true })
+    await user.click(screen.getByRole('button', { name: /^What's new/ }))
+    await user.click(screen.getByRole('button', { name: 'Show older updates' }))
+
+    const again = await screen.findByRole('button', { name: 'Show older updates' })
+    await waitFor(() => expect(again).toBeEnabled())
+    await user.click(again)
+    expect(await screen.findByText('Closed days and staff leave')).toBeInTheDocument()
+  })
+})
+
+describe("a video on a What's new entry", () => {
+  it('links to YouTube from the entry that has one, and only that one', async () => {
+    const user = userEvent.setup()
+    show()
+    await user.click(screen.getByRole('button', { name: /^What's new/ }))
+    const links = screen.getAllByRole('link', { name: 'Watch the video' })
+    expect(links).toHaveLength(1)
+    expect(links[0]).toHaveAttribute('href', 'https://youtu.be/abc123')
+    expect(links[0]).toHaveAttribute('target', '_blank')
+  })
+
+  it('shows no video link for an address that is not YouTube', async () => {
+    const user = userEvent.setup()
+    show({
+      entries: [{ ...ENTRIES[0], videoUrl: 'https://youtube.com.evil.example/watch' }],
+      unread: [],
+    })
+    await user.click(screen.getByRole('button', { name: /^What's new/ }))
+    expect(screen.queryByRole('link', { name: 'Watch the video' })).not.toBeInTheDocument()
+  })
+})

+ 10 - 4
src/app/(authenticated)/layout.tsx

@@ -60,7 +60,9 @@ import { SaveShortcutListener } from '@/components/save-shortcut-listener'
 import { AnalyticsIdentity } from '@/components/analytics-identity'
 import { AnalyticsIdentity } from '@/components/analytics-identity'
 import { WhatsNewProvider } from '@/features/whats-new/Components/WhatsNewProvider'
 import { WhatsNewProvider } from '@/features/whats-new/Components/WhatsNewProvider'
 import { getWhatsNewFeed } from '@/features/whats-new/Lib/feed.server'
 import { getWhatsNewFeed } from '@/features/whats-new/Lib/feed.server'
+import { WHATS_NEW_PAGE_SIZE } from '@/features/whats-new/Lib/paging'
 import { selectEntries, unreadIds } from '@/features/whats-new/Lib/select'
 import { selectEntries, unreadIds } from '@/features/whats-new/Lib/select'
+import { isWhatsNewTabHidden, WHATS_NEW_TAB_COOKIE } from '@/features/whats-new/Lib/tabPreference'
 
 
 export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
 export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
   const data = await getLayoutData()
   const data = await getLayoutData()
@@ -177,12 +179,12 @@ export default async function DashboardLayout({ children }: { children: React.Re
   }
   }
 
 
   // The highlights written on torqvoice.com, narrowed to what this person can
   // The highlights written on torqvoice.com, narrowed to what this person can
-  // actually open on this install. A licensed self-hosted install runs under
-  // the workshop's own name, and news signed by Torqvoice has no place there.
+  // actually open. Torqvoice Cloud only: a self-hosted install is the
+  // workshop's own, and news from us has no place appearing inside it.
   const appVersion = process.env.APP_VERSION || 'development'
   const appVersion = process.env.APP_VERSION || 'development'
   const releaseNotesUrl =
   const releaseNotesUrl =
     process.env.RELEASE_NOTES_URL || 'https://github.com/Torqvoice/torqvoice/releases'
     process.env.RELEASE_NOTES_URL || 'https://github.com/Torqvoice/torqvoice/releases'
-  const whatsNewHidden = !isCloudMode() && features.brandingRemoved
+  const whatsNewHidden = !isCloudMode()
   const whatsNewEntries = whatsNewHidden
   const whatsNewEntries = whatsNewHidden
     ? []
     ? []
     : selectEntries(await getWhatsNewFeed(await getLocale()), {
     : selectEntries(await getWhatsNewFeed(await getLocale()), {
@@ -190,6 +192,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
         cloud: isCloudMode(),
         cloud: isCloudMode(),
         visibleSubjects,
         visibleSubjects,
       })
       })
+  const whatsNewTabHidden = isWhatsNewTabHidden((await cookies()).get(WHATS_NEW_TAB_COOKIE)?.value)
   const whatsNewUnread = unreadIds(whatsNewEntries, {
   const whatsNewUnread = unreadIds(whatsNewEntries, {
     seenAt: data.whatsNewSeenAt,
     seenAt: data.whatsNewSeenAt,
     accountCreatedAt: data.userCreatedAt,
     accountCreatedAt: data.userCreatedAt,
@@ -332,8 +335,11 @@ export default async function DashboardLayout({ children }: { children: React.Re
           >
           >
             <WhiteLabelCtaProvider show={showWhiteLabelCta}>
             <WhiteLabelCtaProvider show={showWhiteLabelCta}>
               <WhatsNewProvider
               <WhatsNewProvider
-                entries={whatsNewEntries}
+                // The first page only; the panel asks for older ones as it is scrolled.
+                entries={whatsNewEntries.slice(0, WHATS_NEW_PAGE_SIZE)}
+                hasMore={whatsNewEntries.length > WHATS_NEW_PAGE_SIZE}
                 unreadIds={whatsNewUnread}
                 unreadIds={whatsNewUnread}
+                initialTabHidden={whatsNewTabHidden}
                 timeZone={safeTimeZone(data.timezone)}
                 timeZone={safeTimeZone(data.timezone)}
                 appVersion={appVersion}
                 appVersion={appVersion}
                 releaseNotesUrl={releaseNotesUrl}
                 releaseNotesUrl={releaseNotesUrl}

+ 2 - 0
src/app/(authenticated)/settings/about/page.tsx

@@ -3,6 +3,7 @@ import Link from 'next/link'
 import { getTranslations } from 'next-intl/server'
 import { getTranslations } from 'next-intl/server'
 import { Separator } from '@/components/ui/separator'
 import { Separator } from '@/components/ui/separator'
 import { ExternalLink, Info } from 'lucide-react'
 import { ExternalLink, Info } from 'lucide-react'
+import { WhatsNewAboutRows } from '@/features/whats-new/Components/WhatsNewAboutRows'
 
 
 export default async function AboutSettingsPage() {
 export default async function AboutSettingsPage() {
   const t = await getTranslations('settings')
   const t = await getTranslations('settings')
@@ -45,6 +46,7 @@ export default async function AboutSettingsPage() {
               </Link>
               </Link>
             </div>
             </div>
           ))}
           ))}
+          <WhatsNewAboutRows />
         </div>
         </div>
       </AppCard>
       </AppCard>
     </div>
     </div>

+ 2 - 0
src/components/app-sidebar.tsx

@@ -87,6 +87,7 @@ import {
   NotificationPanel,
   NotificationPanel,
 } from '@/features/notifications/Components/NotificationPanel'
 } from '@/features/notifications/Components/NotificationPanel'
 import { InstallMenuItem } from '@/components/pwa-install-prompt'
 import { InstallMenuItem } from '@/components/pwa-install-prompt'
+import { WhatsNewMenuItem } from '@/features/whats-new/Components/WhatsNewMenuItem'
 import { FullscreenLauncher, FullscreenMenuItem } from '@/components/fullscreen-toggle'
 import { FullscreenLauncher, FullscreenMenuItem } from '@/components/fullscreen-toggle'
 import { FeatureHint } from '@/components/feature-hint'
 import { FeatureHint } from '@/components/feature-hint'
 import { ANNOUNCEMENTS } from '@/features/settings/Lib/featureHints'
 import { ANNOUNCEMENTS } from '@/features/settings/Lib/featureHints'
@@ -736,6 +737,7 @@ export function AppSidebar({
                 {/* Device conveniences, out of the navigation where they
                 {/* Device conveniences, out of the navigation where they
                     used to pass for pages. Each renders nothing where it
                     used to pass for pages. Each renders nothing where it
                     does not apply. */}
                     does not apply. */}
+                <WhatsNewMenuItem />
                 <InstallMenuItem />
                 <InstallMenuItem />
                 <FullscreenMenuItem />
                 <FullscreenMenuItem />
                 <DropdownMenuSeparator />
                 <DropdownMenuSeparator />

+ 0 - 2
src/components/page-header.tsx

@@ -22,7 +22,6 @@ import { useLicenseExpiry } from '@/components/license-expiry-context'
 import { BANNER_PRIORITY, useBannerSlot } from '@/components/banner-slot'
 import { BANNER_PRIORITY, useBannerSlot } from '@/components/banner-slot'
 import { QuickCreateMenu } from '@/components/quick-create-menu'
 import { QuickCreateMenu } from '@/components/quick-create-menu'
 import { DocsLink } from '@/components/docs-link'
 import { DocsLink } from '@/components/docs-link'
-import { WhatsNewButton } from '@/features/whats-new/Components/WhatsNewButton'
 import { RunningClockPill } from '@/features/time-tracking/Components/RunningClockPill'
 import { RunningClockPill } from '@/features/time-tracking/Components/RunningClockPill'
 import { useServiceType } from '@/components/service-type-context'
 import { useServiceType } from '@/components/service-type-context'
 import { SETTINGS_PAGES } from '@/features/settings/Lib/settingsPages'
 import { SETTINGS_PAGES } from '@/features/settings/Lib/settingsPages'
@@ -346,7 +345,6 @@ export function PageHeader() {
           <RunningClockPill />
           <RunningClockPill />
           <PlateTrigger />
           <PlateTrigger />
           <SearchTrigger />
           <SearchTrigger />
-          <WhatsNewButton />
           <QuickCreateMenu />
           <QuickCreateMenu />
           {showWhiteLabelCta && (
           {showWhiteLabelCta && (
             <Button asChild variant="outline" size="sm" className="hidden sm:inline-flex">
             <Button asChild variant="outline" size="sm" className="hidden sm:inline-flex">

+ 52 - 0
src/features/whats-new/Actions/loadMoreWhatsNew.ts

@@ -0,0 +1,52 @@
+'use server'
+
+import { getLocale } from 'next-intl/server'
+import { z } from 'zod'
+import { getCachedMembership } from '@/lib/cached-session'
+import { isCloudMode } from '@/lib/features'
+import { hasPermission, PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { withAuth } from '@/lib/with-auth'
+import { getWhatsNewFeed } from '../Lib/feed.server'
+import { WHATS_NEW_PAGE_SIZE } from '../Lib/paging'
+import { selectEntries } from '../Lib/select'
+
+/**
+ * The screens this person can open, by the same rule the signed-in layout
+ * uses for the sidebar: owners and admins see everything, a member sees what
+ * their role can read, and a member without a role sees nothing.
+ */
+async function readableSubjects(userId: string, role: string): Promise<string[]> {
+  const all = Object.values(PermissionSubject)
+  if (role === 'owner' || role === 'admin' || role === 'super_admin') return all
+  const membership = await getCachedMembership(userId)
+  if (!membership?.roleId) return []
+  const permissions = membership.customRole?.permissions ?? []
+  return all.filter((subject) =>
+    hasPermission(permissions, { action: PermissionAction.READ, subject })
+  )
+}
+
+/**
+ * The next page of highlights for the panel, from where the reader has
+ * scrolled to. Read from the list this server already holds, so scrolling
+ * never sends anything to torqvoice.com, and narrowed for this person
+ * exactly as the first page was.
+ */
+export async function loadMoreWhatsNew(offset: unknown) {
+  return withAuth(
+    async ({ userId, role }) => {
+      const from = z.number().int().min(0).max(1000).parse(offset)
+      if (!isCloudMode()) return { entries: [], hasMore: false }
+      const all = selectEntries(await getWhatsNewFeed(await getLocale()), {
+        appVersion: process.env.APP_VERSION || 'development',
+        cloud: true,
+        visibleSubjects: await readableSubjects(userId, role),
+      })
+      return {
+        entries: all.slice(from, from + WHATS_NEW_PAGE_SIZE),
+        hasMore: all.length > from + WHATS_NEW_PAGE_SIZE,
+      }
+    },
+    { allowDuringRestore: true }
+  )
+}

+ 41 - 0
src/features/whats-new/Components/WhatsNewAboutRows.tsx

@@ -0,0 +1,41 @@
+'use client'
+
+import { useId } from 'react'
+import { useTranslations } from 'next-intl'
+import { Button } from '@/components/ui/button'
+import { Switch } from '@/components/ui/switch'
+import { useWhatsNew } from './WhatsNewProvider'
+
+/**
+ * The "What's new" rows on Settings, About: a button that opens the panel,
+ * and the switch for the tab on the edge of the screen, which is where
+ * somebody who hid the tab comes to get it back. Nothing is drawn when there
+ * is no news to open, because there is no tab to switch either.
+ */
+export function WhatsNewAboutRows() {
+  const t = useTranslations('settings.about')
+  const switchId = useId()
+  const { available, open, tabHidden, setTabHidden } = useWhatsNew()
+  if (!available) return null
+  return (
+    <>
+      <div id="about-whats-new" className="flex items-center justify-between gap-4">
+        <span className="text-sm text-muted-foreground">{t('whatsNew')}</span>
+        <Button type="button" variant="outline" size="sm" onClick={open}>
+          {t('whatsNewOpen')}
+        </Button>
+      </div>
+      <div className="hidden items-center justify-between gap-4 md:flex">
+        <label htmlFor={switchId} className="flex flex-col text-sm text-muted-foreground">
+          {t('whatsNewTab')}
+          <span className="text-xs">{t('whatsNewTabHint')}</span>
+        </label>
+        <Switch
+          id={switchId}
+          checked={!tabHidden}
+          onCheckedChange={(checked) => setTabHidden(!checked)}
+        />
+      </div>
+    </>
+  )
+}

+ 0 - 37
src/features/whats-new/Components/WhatsNewButton.tsx

@@ -1,37 +0,0 @@
-'use client'
-
-import { Gift } from 'lucide-react'
-import { useTranslations } from 'next-intl'
-import { useWhatsNew } from './WhatsNewProvider'
-
-/**
- * The header's way into the "What's new" panel.
- *
- * A dot, not a number: the count pills in the sidebar mean work waiting on
- * somebody, and three unread announcements are not that. Renders nothing when
- * there is no news to open, so the header never offers an empty panel.
- */
-export function WhatsNewButton() {
-  const t = useTranslations('common.whatsNew')
-  const { available, hasUnread, open } = useWhatsNew()
-  if (!available) return null
-
-  const label = hasUnread ? t('openUnread') : t('title')
-  return (
-    <button
-      type="button"
-      onClick={open}
-      aria-label={label}
-      title={label}
-      className="relative flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
-    >
-      <Gift className="h-4 w-4" />
-      {hasUnread && (
-        <span
-          aria-hidden
-          className="absolute right-1 top-1 size-2 rounded-full bg-primary ring-2 ring-background"
-        />
-      )}
-    </button>
-  )
-}

+ 21 - 19
src/features/whats-new/Components/WhatsNewFeedback.tsx

@@ -34,9 +34,10 @@ function rememberVote(entryId: string, vote: Vote) {
 /**
 /**
  * "Was this useful?" under a highlight.
  * "Was this useful?" under a highlight.
  *
  *
- * The thumb is the whole answer and is sent the moment it is pressed. The
- * comment box that opens under it is an invitation, not a second step: most
- * people will not type anything, and their vote must not depend on it.
+ * The thumb is the whole answer and is sent the moment it is pressed. A thumb
+ * down opens a comment box under it, as an invitation, not a second step:
+ * most people will not type anything, and their vote must not depend on it.
+ * A thumb up opens nothing.
  *
  *
  * A bare thumb names nobody, anywhere. Only a comment from the cloud carries
  * A bare thumb names nobody, anywhere. Only a comment from the cloud carries
  * the sender's email, and the line under the box says so before Send.
  * the sender's email, and the line under the box says so before Send.
@@ -68,7 +69,8 @@ export function WhatsNewFeedback({
     setBusy(true)
     setBusy(true)
     const previous = vote
     const previous = vote
     setVote(next)
     setVote(next)
-    setWriting(true)
+    // A thumb up is the whole answer. Only a thumb down asks what went wrong.
+    setWriting(next === 'down')
     setCommentSent(false)
     setCommentSent(false)
     const result = await sendWhatsNewFeedback({ entryId, vote: next })
     const result = await sendWhatsNewFeedback({ entryId, vote: next })
     setBusy(false)
     setBusy(false)
@@ -99,14 +101,23 @@ export function WhatsNewFeedback({
     cn(
     cn(
       'flex h-7 w-7 cursor-pointer items-center justify-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
       'flex h-7 w-7 cursor-pointer items-center justify-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
       vote === which
       vote === which
-        ? 'bg-primary/15 text-foreground'
+        ? 'bg-primary text-primary-foreground'
         : 'text-muted-foreground hover:bg-muted hover:text-foreground'
         : 'text-muted-foreground hover:bg-muted hover:text-foreground'
     )
     )
 
 
   return (
   return (
-    <div className="flex flex-col gap-2 pt-1">
+    <div className="flex flex-col gap-2">
       <div className="flex items-center gap-1">
       <div className="flex items-center gap-1">
-        <span className="mr-1 text-xs text-muted-foreground">{t('question')}</span>
+        <span className="mr-auto flex items-center gap-2 text-xs text-muted-foreground">
+          {commentSent ? (
+            <span className="flex items-center gap-1" role="status">
+              <Check className="h-3.5 w-3.5 text-primary" />
+              {t('thanks')}
+            </span>
+          ) : (
+            t('question')
+          )}
+        </span>
         <button
         <button
           type="button"
           type="button"
           onClick={() => cast('up')}
           onClick={() => cast('up')}
@@ -129,20 +140,11 @@ export function WhatsNewFeedback({
         >
         >
           <ThumbsDown className="h-3.5 w-3.5" />
           <ThumbsDown className="h-3.5 w-3.5" />
         </button>
         </button>
-        {commentSent && (
-          <span
-            className="ml-1 flex items-center gap-1 text-xs text-muted-foreground"
-            role="status"
-          >
-            <Check className="h-3.5 w-3.5" />
-            {t('thanks')}
-          </span>
-        )}
       </div>
       </div>
-      {writing && vote && (
-        <div className="flex flex-col gap-2 rounded-lg border bg-muted/30 p-3">
+      {writing && vote === 'down' && (
+        <div className="flex flex-col gap-2 pb-1.5 pt-1">
           <label htmlFor={commentId} className="text-xs font-medium">
           <label htmlFor={commentId} className="text-xs font-medium">
-            {vote === 'down' ? t('askDown') : t('askUp')}
+            {t('askDown')}
           </label>
           </label>
           <Textarea
           <Textarea
             id={commentId}
             id={commentId}

+ 42 - 0
src/features/whats-new/Components/WhatsNewMenuItem.tsx

@@ -0,0 +1,42 @@
+'use client'
+
+import { Megaphone, PanelRightOpen } from 'lucide-react'
+import { useTranslations } from 'next-intl'
+import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
+import { useWhatsNew } from './WhatsNewProvider'
+
+/**
+ * "What's new" in the account menu: the link that is there on every screen
+ * size, with or without the tab, and under it the way to bring a hidden tab
+ * back. Renders nothing when there is no news to open.
+ */
+export function WhatsNewMenuItem() {
+  const t = useTranslations('common.whatsNew')
+  const tAbout = useTranslations('settings.about')
+  const { available, hasUnread, open, tabHidden, setTabHidden } = useWhatsNew()
+  if (!available) return null
+  return (
+    <>
+      <DropdownMenuItem onClick={open}>
+        <Megaphone className="mr-2 size-4" />
+        {t('title')}
+        {hasUnread && (
+          <span
+            role="img"
+            aria-label={t('unread')}
+            className="ml-auto size-2 rounded-full bg-primary"
+          />
+        )}
+      </DropdownMenuItem>
+      {/* The way back for somebody who hid the tab and whose role cannot
+          open Settings, where the switch for it is. Larger screens only,
+          like the tab itself. */}
+      {tabHidden && (
+        <DropdownMenuItem className="hidden md:flex" onClick={() => setTabHidden(false)}>
+          <PanelRightOpen className="mr-2 size-4 shrink-0" />
+          {tAbout('whatsNewTab')}
+        </DropdownMenuItem>
+      )}
+    </>
+  )
+}

+ 189 - 60
src/features/whats-new/Components/WhatsNewPanel.tsx

@@ -1,7 +1,8 @@
 'use client'
 'use client'
 
 
+import { useEffect, useRef } from 'react'
 import Link from 'next/link'
 import Link from 'next/link'
-import { ArrowRight } from 'lucide-react'
+import { ArrowRight, Loader2 } from 'lucide-react'
 import { useFormatter, useTranslations } from 'next-intl'
 import { useFormatter, useTranslations } from 'next-intl'
 import { Button } from '@/components/ui/button'
 import { Button } from '@/components/ui/button'
 import {
 import {
@@ -26,6 +27,32 @@ function docsUrl(href: string | null): string | null {
   return href?.startsWith('/docs/') ? `${MARKETING_URL}${href}` : null
   return href?.startsWith('/docs/') ? `${MARKETING_URL}${href}` : null
 }
 }
 
 
+const YOUTUBE_HOSTS = ['youtube.com', 'www.youtube.com', 'm.youtube.com', 'youtu.be']
+
+/** An https address on YouTube, checked here too: the feed is another server's word. */
+function youtubeUrl(href: string | null): string | null {
+  if (!href) return null
+  try {
+    const url = new URL(href)
+    return url.protocol === 'https:' && YOUTUBE_HOSTS.includes(url.hostname) ? url.href : null
+  } catch {
+    return null
+  }
+}
+
+/** YouTube's own mark, drawn from its path: the icon set has dropped brand icons. */
+function YouTubeMark({ className }: { className?: string }) {
+  return (
+    <svg viewBox="0 0 24 24" aria-hidden className={className}>
+      <path d="M6 7h12v10H6z" fill="#fff" />
+      <path
+        fill="#f00"
+        d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"
+      />
+    </svg>
+  )
+}
+
 const BODY_STYLES = cn(
 const BODY_STYLES = cn(
   'text-sm leading-relaxed text-foreground/80',
   'text-sm leading-relaxed text-foreground/80',
   '[&_p+p]:mt-2 [&_p]:m-0',
   '[&_p+p]:mt-2 [&_p]:m-0',
@@ -54,24 +81,30 @@ function Entry({
   const format = useFormatter()
   const format = useFormatter()
   const cta = appPath(entry.ctaHref)
   const cta = appPath(entry.ctaHref)
   const guide = docsUrl(entry.docsHref)
   const guide = docsUrl(entry.docsHref)
+  const video = youtubeUrl(entry.videoUrl)
 
 
   return (
   return (
-    <article className="flex flex-col gap-2.5 border-b px-6 py-5 last:border-b-0">
-      <div className="flex items-center gap-2">
-        {marked && (
-          <span className="size-1.5 rounded-full bg-primary" role="img" aria-label={t('unread')} />
+    <li className="group relative pb-7 pl-7 last:pb-0">
+      {/* The line that runs from this entry down to the next one. */}
+      <span
+        aria-hidden
+        className="absolute bottom-0 left-[5px] top-4 w-px bg-primary/25 group-last:hidden"
+      />
+      {/* Its point on the line: filled while the entry is news to the reader. */}
+      <span
+        role={marked ? 'img' : undefined}
+        aria-label={marked ? t('unread') : undefined}
+        aria-hidden={marked ? undefined : true}
+        className={cn(
+          'absolute left-0 top-[3px] size-[11px] rounded-full border-2 border-primary',
+          marked ? 'bg-primary ring-4 ring-primary/20' : 'bg-background'
         )}
         )}
-        <span
-          className={cn(
-            'rounded px-1.5 py-0.5 text-[10.5px] font-semibold uppercase tracking-wider',
-            entry.kind === 'new'
-              ? 'bg-primary/15 text-foreground'
-              : 'bg-muted text-muted-foreground'
-          )}
+      />
+      <div className="flex items-center gap-2">
+        <time
+          dateTime={entry.publishedAt}
+          className="text-xs font-medium uppercase tracking-wide text-muted-foreground"
         >
         >
-          {entry.kind === 'new' ? t('kindNew') : t('kindImproved')}
-        </span>
-        <time dateTime={entry.publishedAt} className="text-xs text-muted-foreground">
           {/* The workshop's calendar day, like every other date in the app. */}
           {/* The workshop's calendar day, like every other date in the app. */}
           {format.dateTime(new Date(entry.publishedAt), {
           {format.dateTime(new Date(entry.publishedAt), {
             day: 'numeric',
             day: 'numeric',
@@ -80,42 +113,81 @@ function Entry({
             timeZone,
             timeZone,
           })}
           })}
         </time>
         </time>
+        <span
+          className={cn(
+            'rounded-full px-2 py-0.5 text-[10.5px] font-semibold uppercase tracking-wider',
+            entry.kind === 'new'
+              ? 'bg-primary text-primary-foreground'
+              : 'border border-primary/40 text-foreground'
+          )}
+        >
+          {entry.kind === 'new' ? t('kindNew') : t('kindImproved')}
+        </span>
       </div>
       </div>
-      <h3 className="text-[15px] font-semibold leading-snug">{entry.title}</h3>
-      {entry.imageUrl && (
-        <img
-          src={entry.imageUrl}
-          alt=""
-          loading="lazy"
-          className="w-full rounded-lg border bg-muted object-cover"
+      <article className="mt-2.5 overflow-hidden rounded-xl border bg-card shadow-xs">
+        {/* The card hairline the rest of the app uses: primary, gone by the far edge. */}
+        <div
+          aria-hidden
+          className="h-0.5 bg-linear-to-r from-primary via-primary/40 to-transparent"
         />
         />
-      )}
-      {/* Sanitized on the server by Lib/sanitizeBody before it reaches here. */}
-      <div className={BODY_STYLES} dangerouslySetInnerHTML={{ __html: entry.body }} />
-      {(cta || guide) && (
-        <div className="flex flex-wrap items-center gap-x-4 gap-y-2 pt-0.5">
-          {cta && (
-            <Button asChild variant="outline" size="sm">
-              <Link href={cta} onClick={onNavigate}>
-                {entry.ctaLabel || t('open')}
-                <ArrowRight className="h-3.5 w-3.5" />
-              </Link>
-            </Button>
+        <div className="flex flex-col gap-2.5 p-4">
+          <h3 className="text-base font-semibold leading-snug">{entry.title}</h3>
+          {entry.imageUrl && (
+            <img
+              src={entry.imageUrl}
+              alt=""
+              loading="lazy"
+              className="w-full rounded-lg border bg-muted object-cover"
+            />
           )}
           )}
-          {guide && (
-            <a
-              href={guide}
-              target="_blank"
-              rel="noopener noreferrer"
-              className="rounded text-sm font-medium text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
-            >
-              {t('guide')}
-            </a>
+          {/* Sanitized on the server by Lib/sanitizeBody before it reaches here. */}
+          <div className={BODY_STYLES} dangerouslySetInnerHTML={{ __html: entry.body }} />
+          {(cta || guide || video) && (
+            <div className="flex flex-wrap items-center gap-x-4 gap-y-2 pt-1">
+              {cta && (
+                <Button
+                  asChild
+                  variant="outline"
+                  size="sm"
+                  className="border-primary/35 bg-primary/10 hover:bg-primary/15"
+                >
+                  <Link href={cta} onClick={onNavigate}>
+                    {entry.ctaLabel || t('open')}
+                    <ArrowRight className="h-3.5 w-3.5" />
+                  </Link>
+                </Button>
+              )}
+              {video && (
+                <a
+                  href={video}
+                  target="_blank"
+                  rel="noopener noreferrer"
+                  className="flex items-center gap-1.5 rounded text-sm font-medium text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
+                >
+                  <YouTubeMark className="h-4 w-4 shrink-0" />
+                  {t('video')}
+                </a>
+              )}
+              {guide && (
+                <a
+                  href={guide}
+                  target="_blank"
+                  rel="noopener noreferrer"
+                  className="rounded text-sm font-medium text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
+                >
+                  {t('guide')}
+                </a>
+              )}
+            </div>
           )}
           )}
         </div>
         </div>
-      )}
-      {feedback && <WhatsNewFeedback entryId={entry.id} identified={feedback === 'identified'} />}
-    </article>
+        {feedback && (
+          <div className="border-t bg-muted/40 px-4 py-2">
+            <WhatsNewFeedback entryId={entry.id} identified={feedback === 'identified'} />
+          </div>
+        )}
+      </article>
+    </li>
   )
   )
 }
 }
 
 
@@ -130,6 +202,10 @@ export function WhatsNewPanel({
   open,
   open,
   onOpenChange,
   onOpenChange,
   entries,
   entries,
+  hasMore,
+  loadingMore,
+  autoLoad = true,
+  onLoadMore,
   markedIds,
   markedIds,
   timeZone,
   timeZone,
   appVersion,
   appVersion,
@@ -139,6 +215,12 @@ export function WhatsNewPanel({
   open: boolean
   open: boolean
   onOpenChange: (open: boolean) => void
   onOpenChange: (open: boolean) => void
   entries: WhatsNewEntry[]
   entries: WhatsNewEntry[]
+  /** Whether older entries exist beyond the ones given. */
+  hasMore: boolean
+  loadingMore: boolean
+  /** False after a page failed: only the button asks again, not scrolling. */
+  autoLoad?: boolean
+  onLoadMore: () => void
   markedIds: ReadonlySet<string>
   markedIds: ReadonlySet<string>
   /** The workshop's time zone, for the day an entry was published. */
   /** The workshop's time zone, for the day an entry was published. */
   timeZone: string
   timeZone: string
@@ -148,26 +230,73 @@ export function WhatsNewPanel({
 }) {
 }) {
   const t = useTranslations('common.whatsNew')
   const t = useTranslations('common.whatsNew')
 
 
+  // Older entries load as the end of the list scrolls into view. The button
+  // under the list does the same thing, for a keyboard and for a browser
+  // without the observer.
+  const scroller = useRef<HTMLDivElement>(null)
+  const sentinel = useRef<HTMLDivElement>(null)
+  useEffect(() => {
+    const target = sentinel.current
+    if (!open || !hasMore || !autoLoad || !target || typeof IntersectionObserver === 'undefined')
+      return
+    const observer = new IntersectionObserver(
+      (hits) => {
+        if (hits.some((hit) => hit.isIntersecting)) onLoadMore()
+      },
+      { root: scroller.current, rootMargin: '200px' }
+    )
+    observer.observe(target)
+    return () => observer.disconnect()
+  }, [open, hasMore, autoLoad, onLoadMore])
+
   return (
   return (
     <Sheet open={open} onOpenChange={onOpenChange}>
     <Sheet open={open} onOpenChange={onOpenChange}>
       <SheetContent side="right" className="w-full gap-0 p-0 sm:max-w-md">
       <SheetContent side="right" className="w-full gap-0 p-0 sm:max-w-md">
-        <SheetHeader className="border-b px-6 py-5">
-          <SheetTitle className="text-lg">{t('title')}</SheetTitle>
+        <SheetHeader className="relative gap-1 bg-linear-to-b from-primary/15 to-transparent px-6 pb-5 pt-6">
+          <div className="flex items-center gap-2.5">
+            <SheetTitle className="text-xl">{t('title')}</SheetTitle>
+            {markedIds.size > 0 && (
+              <span className="rounded-full bg-primary px-2 py-0.5 text-xs font-semibold text-primary-foreground">
+                {t('newCount', { count: markedIds.size })}
+              </span>
+            )}
+          </div>
           <SheetDescription>{t('description')}</SheetDescription>
           <SheetDescription>{t('description')}</SheetDescription>
+          <span
+            aria-hidden
+            className="absolute inset-x-0 bottom-0 h-px bg-linear-to-r from-primary via-primary/35 to-transparent"
+          />
         </SheetHeader>
         </SheetHeader>
-        <div className="min-h-0 flex-1 overflow-y-auto">
-          {entries.map((entry) => (
-            <Entry
-              key={entry.id}
-              entry={entry}
-              marked={markedIds.has(entry.id)}
-              timeZone={timeZone}
-              feedback={feedback}
-              onNavigate={() => onOpenChange(false)}
-            />
-          ))}
+        <div ref={scroller} className="min-h-0 flex-1 overflow-y-auto bg-muted/30">
+          <ol className="px-6 py-6">
+            {entries.map((entry) => (
+              <Entry
+                key={entry.id}
+                entry={entry}
+                marked={markedIds.has(entry.id)}
+                timeZone={timeZone}
+                feedback={feedback}
+                onNavigate={() => onOpenChange(false)}
+              />
+            ))}
+          </ol>
+          {hasMore && (
+            <div ref={sentinel} className="flex justify-center px-6 pb-6">
+              <Button
+                type="button"
+                variant="ghost"
+                size="sm"
+                onClick={onLoadMore}
+                disabled={loadingMore}
+                className="text-muted-foreground"
+              >
+                {loadingMore && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
+                {t('showOlder')}
+              </Button>
+            </div>
+          )}
         </div>
         </div>
-        <div className="flex items-center justify-between gap-4 border-t bg-muted/40 px-6 py-3 text-sm">
+        <div className="flex items-center justify-between gap-4 border-t px-6 py-3 text-sm">
           <span className="text-muted-foreground">
           <span className="text-muted-foreground">
             {appVersion === 'development' ? appVersion : t('version', { version: appVersion })}
             {appVersion === 'development' ? appVersion : t('version', { version: appVersion })}
           </span>
           </span>

+ 107 - 8
src/features/whats-new/Components/WhatsNewProvider.tsx

@@ -1,16 +1,30 @@
 'use client'
 'use client'
 
 
-import { createContext, useCallback, useContext, useMemo, useState } from 'react'
+import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
+import { useLocale, useTranslations } from 'next-intl'
+import { toast } from 'sonner'
+import { loadMoreWhatsNew } from '../Actions/loadMoreWhatsNew'
 import { markWhatsNewSeen } from '../Actions/markWhatsNewSeen'
 import { markWhatsNewSeen } from '../Actions/markWhatsNewSeen'
+import { rememberWhatsNewTabHidden } from '../Lib/tabPreference'
 import type { WhatsNewEntry } from '../Schema/whatsNewSchema'
 import type { WhatsNewEntry } from '../Schema/whatsNewSchema'
 import { WhatsNewPanel } from './WhatsNewPanel'
 import { WhatsNewPanel } from './WhatsNewPanel'
+import { WhatsNewTab } from './WhatsNewTab'
 
 
 const Context = createContext<{
 const Context = createContext<{
   /** False when there is nothing to show: the feed is empty, hidden or unreachable. */
   /** False when there is nothing to show: the feed is empty, hidden or unreachable. */
   available: boolean
   available: boolean
   hasUnread: boolean
   hasUnread: boolean
   open: () => void
   open: () => void
-}>({ available: false, hasUnread: false, open: () => undefined })
+  /** Whether this browser has sent the tab on the edge of the screen away. */
+  tabHidden: boolean
+  setTabHidden: (hidden: boolean) => void
+}>({
+  available: false,
+  hasUnread: false,
+  open: () => undefined,
+  tabHidden: false,
+  setTabHidden: () => undefined,
+})
 
 
 export function useWhatsNew() {
 export function useWhatsNew() {
   return useContext(Context)
   return useContext(Context)
@@ -19,23 +33,30 @@ export function useWhatsNew() {
 /**
 /**
  * Holds the "What's new" panel for the signed-in app.
  * Holds the "What's new" panel for the signed-in app.
  *
  *
- * One panel, here, rather than one per button: the header button and the
- * update banner's link both open it, and they sit in different parts of the
- * layout. The entries arrive from the server with the page, so opening the
+ * One panel, here, rather than one per button: the tab on the edge of the
+ * screen, the account menu and the update banner's link all open it, and
+ * they sit in different parts of the layout. The entries arrive from the server with the page, so opening the
  * panel never waits on the network.
  * panel never waits on the network.
  */
  */
 export function WhatsNewProvider({
 export function WhatsNewProvider({
   entries,
   entries,
+  hasMore: initialHasMore = false,
   unreadIds,
   unreadIds,
+  initialTabHidden = false,
   timeZone,
   timeZone,
   appVersion,
   appVersion,
   releaseNotesUrl,
   releaseNotesUrl,
   feedback,
   feedback,
   children,
   children,
 }: {
 }: {
+  /** The newest highlights: the first page, sent with the page itself. */
   entries: WhatsNewEntry[]
   entries: WhatsNewEntry[]
+  /** Whether there are older ones the panel can ask for. */
+  hasMore?: boolean
   /** Entries published since this person last opened the panel. */
   /** Entries published since this person last opened the panel. */
   unreadIds: string[]
   unreadIds: string[]
+  /** This browser's remembered choice, read from its cookie on the server. */
+  initialTabHidden?: boolean
   timeZone: string
   timeZone: string
   appVersion: string
   appVersion: string
   releaseNotesUrl: string
   releaseNotesUrl: string
@@ -47,7 +68,13 @@ export function WhatsNewProvider({
   feedback: 'identified' | 'anonymous' | null
   feedback: 'identified' | 'anonymous' | null
   children: React.ReactNode
   children: React.ReactNode
 }) {
 }) {
+  const t = useTranslations('common.whatsNew')
   const [isOpen, setIsOpen] = useState(false)
   const [isOpen, setIsOpen] = useState(false)
+  const [tabHidden, setTabHiddenState] = useState(initialTabHidden)
+  const setTabHidden = useCallback((hidden: boolean) => {
+    setTabHiddenState(hidden)
+    rememberWhatsNewTabHidden(hidden)
+  }, [])
   // What has been acknowledged in this tab. The server's list catches up on
   // What has been acknowledged in this tab. The server's list catches up on
   // the next navigation; until then this is what clears the dot at once.
   // the next navigation; until then this is what clears the dot at once.
   const [acknowledged, setAcknowledged] = useState<ReadonlySet<string>>(new Set())
   const [acknowledged, setAcknowledged] = useState<ReadonlySet<string>>(new Set())
@@ -55,6 +82,57 @@ export function WhatsNewProvider({
   // their marker while it is being read, which is the whole point of one.
   // their marker while it is being read, which is the whole point of one.
   const [marked, setMarked] = useState<ReadonlySet<string>>(new Set())
   const [marked, setMarked] = useState<ReadonlySet<string>>(new Set())
 
 
+  // Older entries the reader has scrolled to. Thrown away when the first
+  // page changes, because offsets into the old list no longer mean anything,
+  // and when the language does, because they were written in the old one.
+  const locale = useLocale()
+  const firstPageKey = `${locale}:${entries.map((entry) => entry.id).join(',')}`
+  const [older, setOlder] = useState<WhatsNewEntry[]>([])
+  const [hasMore, setHasMore] = useState(initialHasMore)
+  const [loadingMore, setLoadingMore] = useState(false)
+  // A page that could not be fetched. Scrolling stops asking until the
+  // button is pressed, so a server that is down is not asked in a loop.
+  const [loadFailed, setLoadFailed] = useState(false)
+  // Which first page a request belongs to. An answer that comes back after
+  // the list was replaced is for a list that is no longer on screen.
+  const generation = useRef(0)
+  const inFlight = useRef(false)
+  useEffect(() => {
+    generation.current += 1
+    inFlight.current = false
+    setOlder([])
+    setHasMore(initialHasMore)
+    setLoadingMore(false)
+    setLoadFailed(false)
+  }, [firstPageKey, initialHasMore])
+
+  const loadMore = useCallback(async () => {
+    if (inFlight.current || !hasMore) return
+    const asked = generation.current
+    inFlight.current = true
+    setLoadingMore(true)
+    let page: { entries: WhatsNewEntry[]; hasMore: boolean } | null = null
+    try {
+      const result = await loadMoreWhatsNew(entries.length + older.length)
+      if (result.success && result.data) page = result.data
+    } catch {
+      // The network, or a deploy in between. Handled as a failed page below.
+    }
+    if (asked !== generation.current) return
+    inFlight.current = false
+    setLoadingMore(false)
+    if (!page) {
+      setLoadFailed(true)
+      return
+    }
+    setLoadFailed(false)
+    const known = new Set([...entries, ...older].map((entry) => entry.id))
+    const fresh = page.entries.filter((entry) => !known.has(entry.id))
+    setOlder((current) => [...current, ...fresh])
+    // Nothing new came back: stop asking rather than ask forever.
+    setHasMore(page.hasMore && fresh.length > 0)
+  }, [hasMore, entries, older])
+
   const pending = useMemo(
   const pending = useMemo(
     () => unreadIds.filter((id) => !acknowledged.has(id)),
     () => unreadIds.filter((id) => !acknowledged.has(id)),
     [unreadIds, acknowledged]
     [unreadIds, acknowledged]
@@ -77,18 +155,39 @@ export function WhatsNewProvider({
   }, [pending])
   }, [pending])
 
 
   const value = useMemo(
   const value = useMemo(
-    () => ({ available: entries.length > 0, hasUnread: pending.length > 0, open }),
-    [entries.length, pending.length, open]
+    () => ({
+      available: entries.length > 0,
+      hasUnread: pending.length > 0,
+      open,
+      tabHidden,
+      setTabHidden,
+    }),
+    [entries.length, pending.length, open, tabHidden, setTabHidden]
   )
   )
 
 
   return (
   return (
     <Context.Provider value={value}>
     <Context.Provider value={value}>
       {children}
       {children}
+      {entries.length > 0 && !tabHidden && (
+        <WhatsNewTab
+          hasUnread={pending.length > 0}
+          onOpen={open}
+          onHide={() => {
+            setTabHidden(true)
+            // Said once, at the moment it goes, so the way back is known.
+            toast(t('tabHidden'))
+          }}
+        />
+      )}
       {entries.length > 0 && (
       {entries.length > 0 && (
         <WhatsNewPanel
         <WhatsNewPanel
           open={isOpen}
           open={isOpen}
           onOpenChange={setIsOpen}
           onOpenChange={setIsOpen}
-          entries={entries}
+          entries={older.length > 0 ? [...entries, ...older] : entries}
+          hasMore={hasMore}
+          loadingMore={loadingMore}
+          autoLoad={!loadFailed}
+          onLoadMore={loadMore}
           markedIds={marked}
           markedIds={marked}
           timeZone={timeZone}
           timeZone={timeZone}
           appVersion={appVersion}
           appVersion={appVersion}

+ 71 - 0
src/features/whats-new/Components/WhatsNewTab.tsx

@@ -0,0 +1,71 @@
+'use client'
+
+import { X } from 'lucide-react'
+import { useTranslations } from 'next-intl'
+import { cn } from '@/lib/utils'
+
+/**
+ * The way into the "What's new" panel: a tab on the right edge of the
+ * screen, written sideways, where the panel itself comes out from.
+ *
+ * A word, not an icon. An icon in the header went unnoticed, because nothing
+ * about it said what it was; a tab that says "What's new" needs no learning.
+ * It is always there, loud while something is unseen and quiet once it has
+ * been read, and it carries its own way out for people who would rather not
+ * have it. Desktop only: on a phone the edge of the screen is the edge of
+ * the content, and the account menu has the same link.
+ */
+export function WhatsNewTab({
+  hasUnread,
+  onOpen,
+  onHide,
+}: {
+  hasUnread: boolean
+  onOpen: () => void
+  onHide: () => void
+}) {
+  const t = useTranslations('common.whatsNew')
+  return (
+    <div
+      className={cn(
+        'fixed right-0 top-1/2 z-40 hidden -translate-y-1/2 flex-col items-center overflow-hidden rounded-l-lg border border-r-0 shadow-md md:flex print:hidden',
+        hasUnread
+          ? 'border-primary bg-primary text-primary-foreground'
+          : 'bg-card text-foreground/80'
+      )}
+    >
+      <button
+        type="button"
+        onClick={onOpen}
+        aria-label={hasUnread ? t('openUnread') : t('title')}
+        className={cn(
+          'flex cursor-pointer items-center gap-2.5 px-2 pb-3.5 pt-4 text-[13px] font-semibold tracking-wide transition-colors [writing-mode:vertical-rl] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring',
+          hasUnread ? 'hover:bg-primary-foreground/10' : 'hover:bg-muted hover:text-foreground'
+        )}
+      >
+        {hasUnread && (
+          <span aria-hidden className="relative flex size-2">
+            <span className="absolute inline-flex size-full animate-ping rounded-full bg-primary-foreground/70 motion-reduce:hidden" />
+            <span className="relative inline-flex size-2 rounded-full bg-primary-foreground" />
+          </span>
+        )}
+        {/* Read from the bottom up, the way a label on a spine is. */}
+        <span className="rotate-180">{t('title')}</span>
+      </button>
+      <button
+        type="button"
+        onClick={onHide}
+        aria-label={t('hideTab')}
+        title={t('hideTab')}
+        className={cn(
+          'flex h-7 w-full cursor-pointer items-center justify-center border-t transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring',
+          hasUnread
+            ? 'border-primary-foreground/25 text-primary-foreground/80 hover:bg-primary-foreground/10 hover:text-primary-foreground'
+            : 'hover:bg-muted hover:text-foreground'
+        )}
+      >
+        <X className="size-3.5" />
+      </button>
+    </div>
+  )
+}

+ 13 - 1
src/features/whats-new/Lib/feed.server.ts

@@ -45,7 +45,13 @@ async function load(locale: string): Promise<WhatsNewEntry[]> {
 function refresh(locale: string, previous: WhatsNewEntry[]): Promise<void> {
 function refresh(locale: string, previous: WhatsNewEntry[]): Promise<void> {
   return load(locale)
   return load(locale)
     .then((entries) => {
     .then((entries) => {
-      cache.set(locale, { entries, expiresAt: Date.now() + FRESH_MS })
+      // An empty list is checked again soon: the first entry ever published,
+      // or the first one tried on staging, should not take ten minutes to
+      // turn up.
+      cache.set(locale, {
+        entries,
+        expiresAt: Date.now() + (entries.length > 0 ? FRESH_MS : RETRY_MS),
+      })
     })
     })
     .catch(() => {
     .catch(() => {
       // torqvoice.com is unreachable, or an install has no way out to the
       // torqvoice.com is unreachable, or an install has no way out to the
@@ -82,6 +88,12 @@ export async function getWhatsNewFeed(locale: string): Promise<WhatsNewEntry[]>
   }
   }
   if (Date.now() >= cached.expiresAt && !cached.refreshing) {
   if (Date.now() >= cached.expiresAt && !cached.refreshing) {
     cached.refreshing = refresh(locale, cached.entries)
     cached.refreshing = refresh(locale, cached.entries)
+    // Nothing worth showing meanwhile, so wait for the answer rather than
+    // hand back the empty list one more time.
+    if (cached.entries.length === 0) {
+      await cached.refreshing
+      return cache.get(locale)?.entries ?? []
+    }
   }
   }
   return cached.entries
   return cached.entries
 }
 }

+ 6 - 0
src/features/whats-new/Lib/paging.ts

@@ -0,0 +1,6 @@
+/**
+ * How many highlights the panel is given at a time. The first ten arrive
+ * with the page; older ones are asked for as the reader scrolls to them, so
+ * a long history costs nothing to the people who never open the panel.
+ */
+export const WHATS_NEW_PAGE_SIZE = 10

+ 23 - 0
src/features/whats-new/Lib/tabPreference.ts

@@ -0,0 +1,23 @@
+/**
+ * Whether this browser shows the "What's new" tab on the edge of the screen.
+ *
+ * The tab is an invitation, and somebody who does not want it should be able
+ * to send it away for good: news stays reachable from the account menu and
+ * from Settings, About, where the tab can also be switched back on.
+ *
+ * A cookie, like the other remembered choices: the signed-in layout is
+ * rendered on the server, and reading the choice there means a hidden tab
+ * never flashes up before the browser has had its say.
+ */
+export const WHATS_NEW_TAB_COOKIE = 'whatsNewTab'
+
+const MAX_AGE = 60 * 60 * 24 * 365
+
+export function isWhatsNewTabHidden(raw: string | undefined | null): boolean {
+  return raw === 'hidden'
+}
+
+/** Client only: remember the choice for the next server render. */
+export function rememberWhatsNewTabHidden(hidden: boolean) {
+  document.cookie = `${WHATS_NEW_TAB_COOKIE}=${hidden ? 'hidden' : 'shown'}; path=/; max-age=${MAX_AGE}; SameSite=Lax`
+}

+ 2 - 0
src/features/whats-new/Schema/whatsNewSchema.ts

@@ -19,6 +19,8 @@ export const whatsNewEntrySchema = z.object({
   ctaHref: z.string().max(300).nullable().catch(null),
   ctaHref: z.string().max(300).nullable().catch(null),
   /** A docs path on torqvoice.com, without a locale. */
   /** A docs path on torqvoice.com, without a locale. */
   docsHref: z.string().max(300).nullable().catch(null),
   docsHref: z.string().max(300).nullable().catch(null),
+  /** A YouTube address. Absent from a site that does not send it yet. */
+  videoUrl: z.string().max(300).nullable().catch(null),
   /** The first release that has the feature. Older installs never see the entry. */
   /** The first release that has the feature. Older installs never see the entry. */
   minVersion: z.string().max(32).nullable().catch(null),
   minVersion: z.string().max(32).nullable().catch(null),
   audience: z.enum(['all', 'cloud', 'self-hosted']).catch('all'),
   audience: z.enum(['all', 'cloud', 'self-hosted']).catch('all'),