useAnnouncements.ts 3.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687
  1. import { useCallback, useMemo } from 'react';
  2. import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
  3. import { announcementsApi, type Announcement, type AnnouncementList, type AnnouncementText } from '../api/client';
  4. import { useAuth } from '../contexts/AuthContext';
  5. // Hosts a message may link to. The backend already drops any other link; this
  6. // is the second check, so a bad link never becomes a clickable one.
  7. const LINK_HOSTS = ['github.com', 'bambuddy.cool'];
  8. export function isAllowedAnnouncementLink(url: string | null | undefined): url is string {
  9. if (!url) return false;
  10. try {
  11. const parsed = new URL(url);
  12. if (parsed.protocol !== 'https:' || parsed.username || parsed.password) return false;
  13. if (parsed.port && parsed.port !== '443') return false;
  14. const host = parsed.hostname.toLowerCase();
  15. return LINK_HOSTS.some((allowed) => host === allowed || host.endsWith(`.${allowed}`));
  16. } catch {
  17. return false;
  18. }
  19. }
  20. /** The message in the UI language when it was written in it, else English. */
  21. export function announcementText(announcement: Announcement, language: string): AnnouncementText {
  22. const { texts } = announcement;
  23. if (texts[language]) return texts[language];
  24. const lower = language.toLowerCase();
  25. const sameCase = Object.keys(texts).find((key) => key.toLowerCase() === lower);
  26. if (sameCase) return texts[sameCase];
  27. // "pt" finds "pt-BR", "zh-HK" finds "zh-TW" or "zh-CN" -- closer than English.
  28. const base = lower.split('-')[0];
  29. const sameBase = Object.keys(texts).find((key) => key.toLowerCase().split('-')[0] === base);
  30. return sameBase ? texts[sameBase] : texts.en;
  31. }
  32. const SEVERITY: Record<Announcement['level'], number> = { critical: 2, important: 1, info: 0 };
  33. /**
  34. * Announcements from the Bambuddy maintainers, for whoever may see them.
  35. *
  36. * The backend says whether this user may see them at all (`visible`: switched
  37. * on, and admin or "show to all users"), so there is no separate permission
  38. * check here. `visible` with an empty list is an empty inbox, not "hide".
  39. */
  40. export function useAnnouncements() {
  41. const { authEnabled, user, loading } = useAuth();
  42. const queryClient = useQueryClient();
  43. const { data } = useQuery({
  44. queryKey: ['announcements'],
  45. queryFn: announcementsApi.list,
  46. enabled: !loading && (!authEnabled || !!user),
  47. staleTime: 5 * 60 * 1000,
  48. // A local read of what the backend fetched; the backend itself only goes
  49. // to GitHub every few hours.
  50. refetchInterval: 10 * 60 * 1000,
  51. });
  52. const visible = data?.visible ?? false;
  53. const announcements = useMemo(() => data?.announcements ?? [], [data]);
  54. const markReadMutation = useMutation({
  55. mutationFn: announcementsApi.markRead,
  56. onMutate: (id: string) => {
  57. queryClient.setQueryData<AnnouncementList>(['announcements'], (old) =>
  58. old && { ...old, announcements: old.announcements.map((a) => (a.id === id ? { ...a, read: true } : a)) }
  59. );
  60. },
  61. onError: () => queryClient.invalidateQueries({ queryKey: ['announcements'] }),
  62. });
  63. const { mutate } = markReadMutation;
  64. const markRead = useCallback((id: string) => mutate(id), [mutate]);
  65. // History is never unread: it was current once, and either read then or missed.
  66. const unread = useMemo(() => announcements.filter((a) => !a.read && !a.archived), [announcements]);
  67. // What earns a banner: important or critical, not yet read. Most severe first,
  68. // then newest (the list already comes newest first).
  69. const bannerItems = useMemo(
  70. () =>
  71. unread
  72. .filter((a) => a.level !== 'info')
  73. .sort((a, b) => SEVERITY[b.level] - SEVERITY[a.level]),
  74. [unread]
  75. );
  76. return { visible, announcements, unread, bannerItems, markRead };
  77. }