From 74ece95229950863488821f06f67556f3e85fc44 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 7 May 2026 22:11:37 -0500 Subject: [PATCH 01/12] fix(wifi): remove non-functional Open Wi-Fi Settings button The button opened the PyCon US app's iOS settings panel rather than the OS Wi-Fi pane. There is no reliable cross-platform way to deep-link to OS Wi-Fi settings, so drop the button and the platform-specific handler. Closes PYMOBIL-118 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/pages/wifi/wifi.page.html | 4 ---- src/app/pages/wifi/wifi.page.ts | 36 +------------------------------ 2 files changed, 1 insertion(+), 39 deletions(-) diff --git a/src/app/pages/wifi/wifi.page.html b/src/app/pages/wifi/wifi.page.html index 8f41e2ee..b2abf3d5 100644 --- a/src/app/pages/wifi/wifi.page.html +++ b/src/app/pages/wifi/wifi.page.html @@ -33,10 +33,6 @@

Connect to Wi-Fi

Copy Password - - - Open Wi-Fi Settings - Date: Thu, 7 May 2026 22:11:56 -0500 Subject: [PATCH 02/12] fix(rooms): add Room 101AB pin between 101A and 101B PyLadies Lunch is in Room 101AB; the venue normalizer for 101AB had no matching entry so the room map showed nothing for that session. Center the new pin at x=17 (midpoint of 101A x=14 and 101B x=19). Closes PYMOBIL-122 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/location-map/room-locations.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/location-map/room-locations.ts b/src/app/location-map/room-locations.ts index 9781bcab..37074e90 100644 --- a/src/app/location-map/room-locations.ts +++ b/src/app/location-map/room-locations.ts @@ -86,6 +86,7 @@ const ROOM_LOCATIONS_RAW: Record = { // tilted parallelogram in the upper right. '101a': concourse('Room 101A', 14, 50, 'Tutorials / PSF Members Lunch / PyLadies Lunch'), '101b': concourse('Room 101B', 19, 50, 'Tutorials / PSF Members Lunch / PyLadies Lunch'), + '101ab': concourse('Room 101AB', 17, 50, 'Tutorials / PSF Members Lunch / PyLadies Lunch'), '102a': concourse('Room 102A', 23, 50, 'Tutorials / Open Spaces'), '102b': concourse('Room 102B', 27, 50, 'Tutorials / Open Spaces'), '102c': concourse('Room 102C', 31, 50, 'Tutorials / Open Spaces'), From 6713bb6d140b36654af3249a4d60e142d1a6b4a4 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 7 May 2026 22:12:47 -0500 Subject: [PATCH 03/12] fix(schedule): don't filter posters and breaks as placeholder slots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The placeholder filter introduced in PYMOBIL-117 dropped any slot whose name matched its kind, had no description, and had no speakers — perfect for cancelled "talk" stubs. Real poster (name="poster") and break (name="break") slots happen to share that exact shape, so the filter was eating them too. When poster slots got dropped, collapsedGroups never picked them up, the "Poster" track never got registered in data.tracks, and the excludeTracks list never contained it — so synthetic per-poster sessions leaked onto every track page (Open Spaces, AI, Keynotes, Talks, etc.). Skip kind="poster" and kind="break" before the placeholder shape check so collapsed groups behave normally and the Poster track exists when schedule-list.page.ts builds excludeTracks. Refs PYMOBIL-117 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/providers/conference-data.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/app/providers/conference-data.ts b/src/app/providers/conference-data.ts index 7310771d..9e6e1074 100644 --- a/src/app/providers/conference-data.ts +++ b/src/app/providers/conference-data.ts @@ -63,8 +63,16 @@ export class ConferenceData { // Pretalx leaves cancelled-talk slots in the feed with name === kind, // no description, and no speakers. The web schedule hides them; without // this filter the mobile app shows a row literally titled "talk". + // + // CAREFUL: posters and breaks legitimately have this same shape + // (name="poster"/"break", empty description, no contact) — but they're + // *not* cancelled, they're collapsed/expanded later. Skip those kinds + // here so we don't accidentally drop them. Filtering out poster slots + // here was the cause of posters leaking onto every track page (their + // "Poster" track never got registered). PYMOBIL-117 / PYMOBIL-bug. private isPlaceholderSlot(slot: any): boolean { if (!slot || typeof slot.kind !== 'string') return false; + if (slot.kind === 'poster' || slot.kind === 'break') return false; const name = typeof slot.name === 'string' ? slot.name.trim().toLowerCase() : ''; if (!name) return false; if (name !== slot.kind.toLowerCase()) return false; From aeae2a527c4f7366bb3d42625950cd5787e56c32 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 7 May 2026 22:13:54 -0500 Subject: [PATCH 04/12] feat(rooms): seed sprint-only rooms into the Rooms list Sprints are shipped as a separate `sprints` array in conference.json rather than as schedule slots, so the Seaside S-rooms / ballrooms that host sprints never picked up any sessions in the per-room index and never showed in the Rooms list. After building roomMap from data.sessions, seed empty entries for any ROOM_LOCATIONS entry whose sublabel contains "Sprints" (S-1, S-3, S-4, S-5, Seaside Lobby, Seaside Ballroom A/B, etc.). Empty rooms render their location info from the static map, which is correct. Closes PYMOBIL-120 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/providers/conference-data.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/app/providers/conference-data.ts b/src/app/providers/conference-data.ts index 9e6e1074..e19b4e49 100644 --- a/src/app/providers/conference-data.ts +++ b/src/app/providers/conference-data.ts @@ -8,6 +8,7 @@ import markdownToTxt from 'markdown-to-txt'; import { UserData } from './user-data'; import { environment } from '../../environments/environment'; +import { ROOM_LOCATIONS } from '../location-map/room-locations'; @Injectable({ providedIn: 'root' @@ -757,6 +758,19 @@ export class ConferenceData { session.displayLocation = session.displayLocationOverride || (links.length > 0 ? links[0].name : (session.location || '')); }); + // Seed empty entries for sprint-only rooms. Sprints aren't in + // data.sessions (the API ships them as a separate `sprints` array, not + // as schedule slots), so without this the Seaside S-rooms / ballrooms + // never appear in the Rooms list — even though they're real venues + // attendees need to find. PYMOBIL-120. + Object.values(ROOM_LOCATIONS).forEach((loc: any) => { + if (!loc?.sublabel || !/sprints/i.test(loc.sublabel)) return; + const slug = this.slugifyRoom(loc.label); + if (!roomMap.has(slug)) { + roomMap.set(slug, { name: loc.label, slug, sessions: [] }); + } + }); + roomMap.forEach((room: any) => { room.sessions.sort( (a: any, b: any) => From 047426cb7d009b929838bd17c7d5fe897091e53d Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 7 May 2026 22:14:08 -0500 Subject: [PATCH 05/12] fix(schedule): build excludeTracks before fetching the session list initSchedule() in schedule-list.page.ts called resetSessions() outside the getTracks() subscribe, so the session fetch ran with an empty excludeTracks. Every track-list page (Open Spaces, AI, Keynotes, etc.) showed sessions from every track on first render until the user re-triggered a fetch. Move resetSessions() inside the getTracks() callback so the filter reads a populated excludeTracks. Also add a defensive whitelist on the open-spaces variant (track === 'Open Space') so any future regression in the slugify-track-names dance can't leak posters back onto that specific page. Closes PYMOBIL-126 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../pages/schedule-list/schedule-list.page.ts | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/app/pages/schedule-list/schedule-list.page.ts b/src/app/pages/schedule-list/schedule-list.page.ts index 5254ce10..fefe45d6 100644 --- a/src/app/pages/schedule-list/schedule-list.page.ts +++ b/src/app/pages/schedule-list/schedule-list.page.ts @@ -159,6 +159,18 @@ export class ScheduleListPage implements OnInit { loader.present(); this.displaySessions = []; this.confData.getSessions(this.sessionQueryText, this.excludeTracks).subscribe((sessions: any[]) => { + // Belt-and-suspenders: on the open-spaces page, restrict to + // sessions whose track is literally 'Open Space'. The + // excludeTracks filter already does this in theory, but if the + // initial async ordering ever gets restored (or a session has a + // missing/empty `tracks` array) we don't want posters leaking + // back onto this page. Equivalent guard for any other view that + // expects only one track is left for future cleanup. + if (this.isOpenSpaceView) { + sessions = sessions.filter( + (s: any) => s?.track === 'Open Space' || s?.tracks?.includes('Open Space'), + ); + } this.sessions = sessions; this.generateSessions(); setTimeout(() => {loader.dismiss()}, 100); @@ -184,6 +196,12 @@ export class ScheduleListPage implements OnInit { this.confData.load().subscribe((data: any) => { if (data.sessions) { + // Build excludeTracks BEFORE fetching the filtered session list. + // Previously resetSessions() ran outside this subscribe, so by the + // time the filter executed excludeTracks was still empty and + // every session leaked through (e.g. posters showing on the + // open-spaces page). Move resetSessions inside so the filter + // sees a populated excludeTracks. PYMOBIL-bug. this.confData.getTracks().subscribe((tracks: any[]) => { tracks.forEach((track, index, arr) => { const trackNameToCompare = typeof track === 'string' ? track : track.name; @@ -208,8 +226,8 @@ export class ScheduleListPage implements OnInit { this.excludeTracks.splice(i, 1); } } + this.resetSessions(); }); - this.resetSessions(); } }); } From 14484532eebe2e455d39e76d265ae1f819184e05 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 7 May 2026 22:15:54 -0500 Subject: [PATCH 06/12] fix(open-spaces): hide proposals and link cards to us.pycon.org MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two adjustments to the open-spaces ingestion path in conference-data.ts: 1. Skip submissions that have no start, no end, or no room — the pretalx feed includes proposed open-spaces alongside scheduled ones, and they were rendering as Sun cards with placeholder "Expo Hall AB" location. 2. Carry a `siteUrl` on each open-space session so the schedule-list card and the session-detail page can deep-link to the website's modal: /2026/schedule/open-spaces/#OpenSpace-. The website's dialog.js opens the matching on hashchange. Adds a "View on us.pycon.org" link to both the open-space card and session-detail. The link uses target="_blank" + rel="noopener" and stops event propagation so it doesn't also trigger the card's session-detail navigation. Closes PYMOBIL-124 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../pages/schedule-list/schedule-list.page.html | 8 ++++++++ .../pages/schedule-list/schedule-list.page.scss | 14 ++++++++++++++ src/app/pages/session-detail/session-detail.html | 7 +++++++ src/app/pages/session-detail/session-detail.scss | 16 ++++++++++++++++ src/app/providers/conference-data.ts | 16 ++++++++++++++++ 5 files changed, 61 insertions(+) diff --git a/src/app/pages/schedule-list/schedule-list.page.html b/src/app/pages/schedule-list/schedule-list.page.html index fcd67111..b1b35ac7 100644 --- a/src/app/pages/schedule-list/schedule-list.page.html +++ b/src/app/pages/schedule-list/schedule-list.page.html @@ -117,6 +117,14 @@

+ + + + + View on us.pycon.org + + + diff --git a/src/app/pages/schedule-list/schedule-list.page.scss b/src/app/pages/schedule-list/schedule-list.page.scss index ca3258ab..66451aca 100644 --- a/src/app/pages/schedule-list/schedule-list.page.scss +++ b/src/app/pages/schedule-list/schedule-list.page.scss @@ -165,3 +165,17 @@ ion-title { border-radius: 4px; object-fit: cover; } + +.open-space-site-link { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.85rem; + color: var(--ion-color-primary); + text-decoration: none; + margin-top: 4px; + + ion-icon { font-size: 0.9rem; } + + &:hover { text-decoration: underline; } +} diff --git a/src/app/pages/session-detail/session-detail.html b/src/app/pages/session-detail/session-detail.html index 3b28ef1f..7180d61f 100644 --- a/src/app/pages/session-detail/session-detail.html +++ b/src/app/pages/session-detail/session-detail.html @@ -99,6 +99,13 @@

{{ keynoteAbstr (click)="onDescriptionClick($event)"> + +

Posters ({{posters.length}})

{ + // Skip submissions that haven't been scheduled yet — un-roomed or + // un-timed entries are *proposals*, not confirmed sessions, and + // shouldn't appear on the schedule. The pretalx feed includes all + // submissions regardless of state. + if (!openSpace?.start || !openSpace?.end) return; + const startMs = new Date(openSpace.start).getTime(); + const endMs = new Date(openSpace.end).getTime(); + if (Number.isNaN(startMs) || Number.isNaN(endMs)) return; + const room = openSpace.room_display || openSpace.room || ''; + if (!room.trim()) return; var start = new Date(openSpace.start); var end = new Date(openSpace.end); var session = { @@ -237,6 +247,12 @@ export class ConferenceData { "id": openSpace.conf_key + 9000, "day": start.toLocaleDateString('en-us', {timeZone: environment.timezone, weekday: 'short'}), "imageUrl": this.resolveOpenSpaceImage(openSpace.image_url), + // Deep-link to the open-space modal on us.pycon.org. The website + // opens a matching `#OpenSpace-` on hash change + // (see pycon-site/static/js/lib/dialog.js, PR #712). conf_key is + // the OpenSpacesSignup PK on the website side. The page lives at + // /2026/schedule/open-spaces/, NOT /2026/schedule/conference/. + "siteUrl": `${environment.baseUrl}/2026/schedule/open-spaces/#OpenSpace-${openSpace.conf_key}`, } this.data.sessions.push(session); From 9cc3d0a5214ec876301317361b30400767c1078a Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 7 May 2026 22:16:28 -0500 Subject: [PATCH 07/12] feat(notifications): add settings page with FCM topics + local reminders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an in-app Notifications settings page (☰ menu → Notifications) with five toggles: - Lightning Talk sign-ups — local notifications scheduled client-side 15 min before each lightning-talk signup window opens - Open Space sign-ups — local notifications scheduled 15 min before each day's open-space signup window opens - Announcements — FCM topic 'announcements' (general updates) - Schedule changes — FCM topic 'schedule-changes' - Emergency & safety alerts — FCM topic 'emergency' Plus a master "All notifications" toggle that flips every category in one shot, and a Diagnostics card that surfaces the device's FCM token with a Copy button so staff can send test pushes without the Web Inspector dance (App Store builds disable inspectability). Architecture: - New providers/notifications.service.ts owns prefs persistence, local-notification scheduling, and FCM topic subscribe/unsubscribe. Topic state is re-synced on every applyPrefs() so it stays consistent across reinstalls and token rotations. - Toggling a push category subscribes/unsubscribes from the underlying FCM topic — devices that have a category off don't receive the push at all (OS-level + in-app), which is true opt-out, not just in-app banner suppression. - Push handler in app.component.ts checks shouldShowPushBanner() before showing the in-app toast; topic-based opt-out makes this a defense in depth for sends that target all devices instead of a topic. Adds three packages: - @capacitor/local-notifications — client-scheduled reminders - @capacitor-firebase/messaging — exposes subscribeToTopic / unsubscribeFromTopic, which @capacitor/push-notifications doesn't - firebase — required by the web fallback of capacitor-firebase/messaging Drops the explicit `pod 'FirebaseMessaging'` from ios/App/Podfile — CapacitorFirebaseMessaging pulls it transitively at ~> 11.7.0; pinning both caused pod install to fail with "could not find compatible versions". Closes PYMOBIL-106 Closes PYMOBIL-121 Co-Authored-By: Claude Opus 4.7 (1M context) --- ios/App/Podfile | 8 +- ios/App/Podfile.lock | 60 +- package-lock.json | 861 +++++++++++++++++- package.json | 3 + src/app/app.component.html | 7 + src/app/app.component.ts | 12 + .../notification-settings-routing.module.ts | 17 + .../notification-settings.module.ts | 18 + .../notification-settings.page.html | 115 +++ .../notification-settings.page.scss | 139 +++ .../notification-settings.page.ts | 124 +++ .../tabs-page/tabs-page-routing.module.ts | 12 + src/app/providers/notifications.service.ts | 271 ++++++ 13 files changed, 1601 insertions(+), 46 deletions(-) create mode 100644 src/app/pages/notification-settings/notification-settings-routing.module.ts create mode 100644 src/app/pages/notification-settings/notification-settings.module.ts create mode 100644 src/app/pages/notification-settings/notification-settings.page.html create mode 100644 src/app/pages/notification-settings/notification-settings.page.scss create mode 100644 src/app/pages/notification-settings/notification-settings.page.ts create mode 100644 src/app/providers/notifications.service.ts diff --git a/ios/App/Podfile b/ios/App/Podfile index 3737cdf1..be5a41e3 100644 --- a/ios/App/Podfile +++ b/ios/App/Podfile @@ -11,6 +11,7 @@ install! 'cocoapods', :disable_input_output_paths => true def capacitor_pods pod 'Capacitor', :path => '../../node_modules/@capacitor/ios' pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios' + pod 'CapacitorFirebaseMessaging', :path => '../../node_modules/@capacitor-firebase/messaging' pod 'CapacitorMlkitBarcodeScanning', :path => '../../node_modules/@capacitor-mlkit/barcode-scanning' pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app' pod 'CapacitorDevice', :path => '../../node_modules/@capacitor/device' @@ -19,6 +20,7 @@ def capacitor_pods pod 'CapacitorInappbrowser', :path => '../../node_modules/@capacitor/inappbrowser' pod 'CapacitorKeyboard', :path => '../../node_modules/@capacitor/keyboard' pod 'CapacitorLiveUpdates', :path => '../../node_modules/@capacitor/live-updates' + pod 'CapacitorLocalNotifications', :path => '../../node_modules/@capacitor/local-notifications' pod 'CapacitorNetwork', :path => '../../node_modules/@capacitor/network' pod 'CapacitorPushNotifications', :path => '../../node_modules/@capacitor/push-notifications' pod 'CapacitorShare', :path => '../../node_modules/@capacitor/share' @@ -29,8 +31,10 @@ end target 'PyCon US' do capacitor_pods - # Add your Pods here - pod 'FirebaseMessaging' + # FirebaseMessaging is pulled transitively by CapacitorFirebaseMessaging + # (the @capacitor-firebase/messaging plugin) — no explicit pod needed. + # The plugin pins to ~> 11.7.0 on its v7 line; pinning our own version + # here causes "could not find compatible versions" in pod install. end post_install do |installer| diff --git a/ios/App/Podfile.lock b/ios/App/Podfile.lock index 0d65d3bb..7bda9cd9 100644 --- a/ios/App/Podfile.lock +++ b/ios/App/Podfile.lock @@ -9,6 +9,9 @@ PODS: - CapacitorFilesystem (7.1.6): - Capacitor - IONFilesystemLib (~> 1.0.1) + - CapacitorFirebaseMessaging (7.5.0): + - Capacitor + - FirebaseMessaging (~> 11.7.0) - CapacitorHaptics (7.0.3): - Capacitor - CapacitorInappbrowser (2.5.3): @@ -19,6 +22,8 @@ PODS: - CapacitorLiveUpdates (0.4.0): - Capacitor - IonicLiveUpdates (~> 0.5.6) + - CapacitorLocalNotifications (7.0.6): + - Capacitor - CapacitorMlkitBarcodeScanning (7.5.0): - Capacitor - GoogleMLKit/BarcodeScanning (= 7.0.0) @@ -34,25 +39,25 @@ PODS: - Capacitor - EbarooniCapacitorCalendar (7.2.0): - Capacitor - - FirebaseCore (12.7.0): - - FirebaseCoreInternal (~> 12.7.0) - - GoogleUtilities/Environment (~> 8.1) - - GoogleUtilities/Logger (~> 8.1) - - FirebaseCoreInternal (12.7.0): - - "GoogleUtilities/NSData+zlib (~> 8.1)" - - FirebaseInstallations (12.7.0): - - FirebaseCore (~> 12.7.0) - - GoogleUtilities/Environment (~> 8.1) - - GoogleUtilities/UserDefaults (~> 8.1) + - FirebaseCore (11.7.0): + - FirebaseCoreInternal (~> 11.7.0) + - GoogleUtilities/Environment (~> 8.0) + - GoogleUtilities/Logger (~> 8.0) + - FirebaseCoreInternal (11.7.0): + - "GoogleUtilities/NSData+zlib (~> 8.0)" + - FirebaseInstallations (11.7.0): + - FirebaseCore (~> 11.7.0) + - GoogleUtilities/Environment (~> 8.0) + - GoogleUtilities/UserDefaults (~> 8.0) - PromisesObjC (~> 2.4) - - FirebaseMessaging (12.7.0): - - FirebaseCore (~> 12.7.0) - - FirebaseInstallations (~> 12.7.0) - - GoogleDataTransport (~> 10.1) - - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - - GoogleUtilities/Environment (~> 8.1) - - GoogleUtilities/Reachability (~> 8.1) - - GoogleUtilities/UserDefaults (~> 8.1) + - FirebaseMessaging (11.7.0): + - FirebaseCore (~> 11.7.0) + - FirebaseInstallations (~> 11.0) + - GoogleDataTransport (~> 10.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.0) + - GoogleUtilities/Environment (~> 8.0) + - GoogleUtilities/Reachability (~> 8.0) + - GoogleUtilities/UserDefaults (~> 8.0) - nanopb (~> 3.30910.0) - GoogleDataTransport (10.1.0): - nanopb (~> 3.30910.0) @@ -125,10 +130,12 @@ DEPENDENCIES: - "CapacitorCordova (from `../../node_modules/@capacitor/ios`)" - "CapacitorDevice (from `../../node_modules/@capacitor/device`)" - "CapacitorFilesystem (from `../../node_modules/@capacitor/filesystem`)" + - "CapacitorFirebaseMessaging (from `../../node_modules/@capacitor-firebase/messaging`)" - "CapacitorHaptics (from `../../node_modules/@capacitor/haptics`)" - "CapacitorInappbrowser (from `../../node_modules/@capacitor/inappbrowser`)" - "CapacitorKeyboard (from `../../node_modules/@capacitor/keyboard`)" - "CapacitorLiveUpdates (from `../../node_modules/@capacitor/live-updates`)" + - "CapacitorLocalNotifications (from `../../node_modules/@capacitor/local-notifications`)" - "CapacitorMlkitBarcodeScanning (from `../../node_modules/@capacitor-mlkit/barcode-scanning`)" - "CapacitorNetwork (from `../../node_modules/@capacitor/network`)" - "CapacitorPushNotifications (from `../../node_modules/@capacitor/push-notifications`)" @@ -136,7 +143,6 @@ DEPENDENCIES: - "CapacitorSplashScreen (from `../../node_modules/@capacitor/splash-screen`)" - "CapacitorStatusBar (from `../../node_modules/@capacitor/status-bar`)" - "EbarooniCapacitorCalendar (from `../../node_modules/@ebarooni/capacitor-calendar`)" - - FirebaseMessaging SPEC REPOS: trunk: @@ -170,6 +176,8 @@ EXTERNAL SOURCES: :path: "../../node_modules/@capacitor/device" CapacitorFilesystem: :path: "../../node_modules/@capacitor/filesystem" + CapacitorFirebaseMessaging: + :path: "../../node_modules/@capacitor-firebase/messaging" CapacitorHaptics: :path: "../../node_modules/@capacitor/haptics" CapacitorInappbrowser: @@ -178,6 +186,8 @@ EXTERNAL SOURCES: :path: "../../node_modules/@capacitor/keyboard" CapacitorLiveUpdates: :path: "../../node_modules/@capacitor/live-updates" + CapacitorLocalNotifications: + :path: "../../node_modules/@capacitor/local-notifications" CapacitorMlkitBarcodeScanning: :path: "../../node_modules/@capacitor-mlkit/barcode-scanning" CapacitorNetwork: @@ -199,10 +209,12 @@ SPEC CHECKSUMS: CapacitorCordova: bf648a636f3c153f652d312ae145fb508b6ffced CapacitorDevice: b64654eb4d404373e733c01d835055ba59286e5c CapacitorFilesystem: 66f05ee0d8b1ccdc00d509091273a9b3b57c4a0b + CapacitorFirebaseMessaging: 1ac12c0dab16d1e4edcf9cb878620507ac868a4e CapacitorHaptics: ce15be8f287fa2c61c7d2d9e958885b90cf0bebc CapacitorInappbrowser: e1dc727db99127c2908b69408f7da8118d854a84 CapacitorKeyboard: 5660c760113bfa48962817a785879373cf5339c3 CapacitorLiveUpdates: 4a2ebeb2788f787d3133877ee159a3e900443b14 + CapacitorLocalNotifications: 2e3f5b717ec9cd2bd64d93c99a9470941e1c80be CapacitorMlkitBarcodeScanning: afd6fc431b550026a2c052e11ab2b71c7ae30011 CapacitorNetwork: 1a22460c6f900686f12ffa52f3074ee313821b08 CapacitorPushNotifications: 7604512af53bbfdf1dd8fb2256b9ef1e8a69cdc2 @@ -210,10 +222,10 @@ SPEC CHECKSUMS: CapacitorSplashScreen: 000f7591d546907dda5cbb514bd6b6d9e6048cf8 CapacitorStatusBar: d0e0151c89c001a9c7125bac59ddedf76b664768 EbarooniCapacitorCalendar: 4568a8a318b940245bf6e257a8b115626581bb7d - FirebaseCore: c7b57863ce0859281a66d16ca36d665c45d332b5 - FirebaseCoreInternal: 571a2dd8c975410966199623351db3a3265c874d - FirebaseInstallations: 6d05424a046b68ca146b4de4376f05b4e9262fc3 - FirebaseMessaging: b5f7bdc62b91b6102015991fb7bc6fa75f643908 + FirebaseCore: 3227e35f4197a924206fbcdc0349325baf4f5de4 + FirebaseCoreInternal: d6c17dafc8dc33614733a8b52df78fcb4394c881 + FirebaseInstallations: 9347e719c3d52d8d7b9074b2c32407dd027305e9 + FirebaseMessaging: 00ece041b71ddb52a2862ffdee73fb6e9824bd0c GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleMLKit: eff9e23ec1d90ea4157a1ee2e32a4f610c5b3318 GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8 @@ -229,6 +241,6 @@ SPEC CHECKSUMS: OSInAppBrowserLib: bf7cdcc072394ae827f8cfe3f5f9c807691834c0 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 -PODFILE CHECKSUM: 4f7de5e223497bbd68a0c069de289c2f8059d888 +PODFILE CHECKSUM: 1572db56c7cc15934c703c7d85119a9d625577d8 COCOAPODS: 1.16.2 diff --git a/package-lock.json b/package-lock.json index 8d6fccce..08fc6134 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ionic-conference-app", - "version": "0.0.0", + "version": "26.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ionic-conference-app", - "version": "0.0.0", + "version": "26.0.0", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -17,6 +17,7 @@ "@angular/platform-browser-dynamic": "^17.3.12", "@angular/router": "^17.3.12", "@angular/service-worker": "^17.3.12", + "@capacitor-firebase/messaging": "^7.5.0", "@capacitor-mlkit/barcode-scanning": "^7.0.0", "@capacitor/android": "^7.0.0", "@capacitor/app": "^7.0.0", @@ -28,6 +29,7 @@ "@capacitor/ios": "^7.0.0", "@capacitor/keyboard": "^7.0.0", "@capacitor/live-updates": "^0.4.0", + "@capacitor/local-notifications": "^7.0.6", "@capacitor/network": "^7.0.0", "@capacitor/push-notifications": "^7.0.0", "@capacitor/share": "^7.0.0", @@ -41,6 +43,7 @@ "@types/crypto-js": "^4.2.2", "core-js": "^3.6.4", "crypto-js": "^4.2.0", + "firebase": "^11.10.0", "js-base64": "^3.7.7", "markdown-to-txt": "^2.0.1", "patch-package": "^8.0.0", @@ -3000,6 +3003,31 @@ "node": ">=6.9.0" } }, + "node_modules/@capacitor-firebase/messaging": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@capacitor-firebase/messaging/-/messaging-7.5.0.tgz", + "integrity": "sha512-mQ4542IX7HWyGnHZLAkOvqzYYpaH47v6ijOsBO/CdyA5hfTdGc5fbxzdS5xMYKTxfpFiecb1qgEDmB5XFWvWOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/capawesome-team/" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/capawesome" + } + ], + "license": "Apache-2.0", + "peerDependencies": { + "@capacitor/core": ">=7.0.0", + "firebase": "^11.2.0" + }, + "peerDependenciesMeta": { + "firebase": { + "optional": true + } + } + }, "node_modules/@capacitor-mlkit/barcode-scanning": { "version": "7.5.0", "resolved": "https://registry.npmjs.org/@capacitor-mlkit/barcode-scanning/-/barcode-scanning-7.5.0.tgz", @@ -3460,6 +3488,15 @@ "@capacitor/core": "^7.0.0" } }, + "node_modules/@capacitor/local-notifications": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@capacitor/local-notifications/-/local-notifications-7.0.6.tgz", + "integrity": "sha512-RuZQ9P5aAlw6VU2i0i6g4i1XYZbbeDE9oaBg87gggHaUUscX7b1lltTlC1t11/JCPoyjECnZ/N8xfDNZ26hKhw==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=7.0.0" + } + }, "node_modules/@capacitor/network": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/@capacitor/network/-/network-7.0.3.tgz", @@ -4102,6 +4139,645 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@firebase/ai": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@firebase/ai/-/ai-1.4.1.tgz", + "integrity": "sha512-bcusQfA/tHjUjBTnMx6jdoPMpDl3r8K15Z+snHz9wq0Foox0F/V+kNLXucEOHoTL2hTc9l+onZCyBJs2QoIC3g==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/analytics": { + "version": "0.10.17", + "resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.17.tgz", + "integrity": "sha512-n5vfBbvzduMou/2cqsnKrIes4auaBjdhg8QNA2ZQZ59QgtO2QiwBaXQZQE4O4sgB0Ds1tvLgUUkY+pwzu6/xEg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/installations": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/analytics-compat": { + "version": "0.2.23", + "resolved": "https://registry.npmjs.org/@firebase/analytics-compat/-/analytics-compat-0.2.23.tgz", + "integrity": "sha512-3AdO10RN18G5AzREPoFgYhW6vWXr3u+OYQv6pl3CX6Fky8QRk0AHurZlY3Q1xkXO0TDxIsdhO3y65HF7PBOJDw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/analytics": "0.10.17", + "@firebase/analytics-types": "0.8.3", + "@firebase/component": "0.6.18", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/analytics-types": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@firebase/analytics-types/-/analytics-types-0.8.3.tgz", + "integrity": "sha512-VrIp/d8iq2g501qO46uGz3hjbDb8xzYMrbu8Tp0ovzIzrvJZ2fvmj649gTjge/b7cCCcjT0H37g1gVtlNhnkbg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app": { + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.13.2.tgz", + "integrity": "sha512-jwtMmJa1BXXDCiDx1vC6SFN/+HfYG53UkfJa6qeN5ogvOunzbFDO3wISZy5n9xgYFUrEP6M7e8EG++riHNTv9w==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/app-check": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@firebase/app-check/-/app-check-0.10.1.tgz", + "integrity": "sha512-MgNdlms9Qb0oSny87pwpjKush9qUwCJhfmTJHDfrcKo4neLGiSeVE4qJkzP7EQTIUFKp84pbTxobSAXkiuQVYQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/app-check-compat": { + "version": "0.3.26", + "resolved": "https://registry.npmjs.org/@firebase/app-check-compat/-/app-check-compat-0.3.26.tgz", + "integrity": "sha512-PkX+XJMLDea6nmnopzFKlr+s2LMQGqdyT2DHdbx1v1dPSqOol2YzgpgymmhC67vitXVpNvS3m/AiWQWWhhRRPQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check": "0.10.1", + "@firebase/app-check-types": "0.5.3", + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/app-check-interop-types": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz", + "integrity": "sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-check-types": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check-types/-/app-check-types-0.5.3.tgz", + "integrity": "sha512-hyl5rKSj0QmwPdsAxrI5x1otDlByQ7bvNvVt8G/XPO2CSwE++rmSVf3VEhaeOR4J8ZFaF0Z0NDSmLejPweZ3ng==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-compat": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.4.2.tgz", + "integrity": "sha512-LssbyKHlwLeiV8GBATyOyjmHcMpX/tFjzRUCS1jnwGAew1VsBB4fJowyS5Ud5LdFbYpJeS+IQoC+RQxpK7eH3Q==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app": "0.13.2", + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/app-types": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", + "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/auth": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.10.8.tgz", + "integrity": "sha512-GpuTz5ap8zumr/ocnPY57ZanX02COsXloY6Y/2LYPAuXYiaJRf6BAGDEdRq1BMjP93kqQnKNuKZUTMZbQ8MNYA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@react-native-async-storage/async-storage": "^1.18.1" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } + } + }, + "node_modules/@firebase/auth-compat": { + "version": "0.5.28", + "resolved": "https://registry.npmjs.org/@firebase/auth-compat/-/auth-compat-0.5.28.tgz", + "integrity": "sha512-HpMSo/cc6Y8IX7bkRIaPPqT//Jt83iWy5rmDWeThXQCAImstkdNo3giFLORJwrZw2ptiGkOij64EH1ztNJzc7Q==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/auth": "1.10.8", + "@firebase/auth-types": "0.13.0", + "@firebase/component": "0.6.18", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz", + "integrity": "sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/auth-types": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@firebase/auth-types/-/auth-types-0.13.0.tgz", + "integrity": "sha512-S/PuIjni0AQRLF+l9ck0YpsMOdE8GO2KU6ubmBB7P+7TJUCQDa3R1dlgYm9UzGbbePMZsp0xzB93f2b/CgxMOg==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/component": { + "version": "0.6.18", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.6.18.tgz", + "integrity": "sha512-n28kPCkE2dL2U28fSxZJjzPPVpKsQminJ6NrzcKXAI0E/lYC8YhfwpyllScqVEvAI3J2QgJZWYgrX+1qGI+SQQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/data-connect": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/@firebase/data-connect/-/data-connect-0.3.10.tgz", + "integrity": "sha512-VMVk7zxIkgwlVQIWHOKFahmleIjiVFwFOjmakXPd/LDgaB/5vzwsB5DWIYo+3KhGxWpidQlR8geCIn39YflJIQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/database": { + "version": "1.0.20", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.0.20.tgz", + "integrity": "sha512-H9Rpj1pQ1yc9+4HQOotFGLxqAXwOzCHsRSRjcQFNOr8lhUt6LeYjf0NSRL04sc4X0dWe8DsCvYKxMYvFG/iOJw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.0.11.tgz", + "integrity": "sha512-itEsHARSsYS95+udF/TtIzNeQ0Uhx4uIna0sk4E0wQJBUnLc/G1X6D7oRljoOuwwCezRLGvWBRyNrugv/esOEw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/database": "1.0.20", + "@firebase/database-types": "1.0.15", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/database-types": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.15.tgz", + "integrity": "sha512-XWHJ0VUJ0k2E9HDMlKxlgy/ZuTa9EvHCGLjaKSUvrQnwhgZuRU5N3yX6SZ+ftf2hTzZmfRkv+b3QRvGg40bKNw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-types": "0.9.3", + "@firebase/util": "1.12.1" + } + }, + "node_modules/@firebase/firestore": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-4.8.0.tgz", + "integrity": "sha512-QSRk+Q1/CaabKyqn3C32KSFiOdZpSqI9rpLK5BHPcooElumOBooPFa6YkDdiT+/KhJtel36LdAacha9BptMj2A==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "@firebase/webchannel-wrapper": "1.0.3", + "@grpc/grpc-js": "~1.9.0", + "@grpc/proto-loader": "^0.7.8", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/firestore-compat": { + "version": "0.3.53", + "resolved": "https://registry.npmjs.org/@firebase/firestore-compat/-/firestore-compat-0.3.53.tgz", + "integrity": "sha512-qI3yZL8ljwAYWrTousWYbemay2YZa+udLWugjdjju2KODWtLG94DfO4NALJgPLv8CVGcDHNFXoyQexdRA0Cz8Q==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/firestore": "4.8.0", + "@firebase/firestore-types": "3.0.3", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/firestore-types": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@firebase/firestore-types/-/firestore-types-3.0.3.tgz", + "integrity": "sha512-hD2jGdiWRxB/eZWF89xcK9gF8wvENDJkzpVFb4aGkzfEaKxVRD1kjz1t1Wj8VZEp2LCB53Yx1zD8mrhQu87R6Q==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/functions": { + "version": "0.12.9", + "resolved": "https://registry.npmjs.org/@firebase/functions/-/functions-0.12.9.tgz", + "integrity": "sha512-FG95w6vjbUXN84Ehezc2SDjGmGq225UYbHrb/ptkRT7OTuCiQRErOQuyt1jI1tvcDekdNog+anIObihNFz79Lg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.6.18", + "@firebase/messaging-interop-types": "0.2.3", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/functions-compat": { + "version": "0.3.26", + "resolved": "https://registry.npmjs.org/@firebase/functions-compat/-/functions-compat-0.3.26.tgz", + "integrity": "sha512-A798/6ff5LcG2LTWqaGazbFYnjBW8zc65YfID/en83ALmkhu2b0G8ykvQnLtakbV9ajrMYPn7Yc/XcYsZIUsjA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/functions": "0.12.9", + "@firebase/functions-types": "0.6.3", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/functions-types": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@firebase/functions-types/-/functions-types-0.6.3.tgz", + "integrity": "sha512-EZoDKQLUHFKNx6VLipQwrSMh01A1SaL3Wg6Hpi//x6/fJ6Ee4hrAeswK99I5Ht8roiniKHw4iO0B1Oxj5I4plg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/installations": { + "version": "0.6.18", + "resolved": "https://registry.npmjs.org/@firebase/installations/-/installations-0.6.18.tgz", + "integrity": "sha512-NQ86uGAcvO8nBRwVltRL9QQ4Reidc/3whdAasgeWCPIcrhOKDuNpAALa6eCVryLnK14ua2DqekCOX5uC9XbU/A==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/util": "1.12.1", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/installations-compat": { + "version": "0.2.18", + "resolved": "https://registry.npmjs.org/@firebase/installations-compat/-/installations-compat-0.2.18.tgz", + "integrity": "sha512-aLFohRpJO5kKBL/XYL4tN+GdwEB/Q6Vo9eZOM/6Kic7asSUgmSfGPpGUZO1OAaSRGwF4Lqnvi1f/f9VZnKzChw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/installations": "0.6.18", + "@firebase/installations-types": "0.5.3", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/installations-types": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@firebase/installations-types/-/installations-types-0.5.3.tgz", + "integrity": "sha512-2FJI7gkLqIE0iYsNQ1P751lO3hER+Umykel+TkLwHj6plzWVxqvfclPUZhcKFVQObqloEBTmpi2Ozn7EkCABAA==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/logger": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.4.4.tgz", + "integrity": "sha512-mH0PEh1zoXGnaR8gD1DeGeNZtWFKbnz9hDO91dIml3iou1gpOnLqXQ2dJfB71dj6dpmUjcQ6phY3ZZJbjErr9g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/messaging": { + "version": "0.12.22", + "resolved": "https://registry.npmjs.org/@firebase/messaging/-/messaging-0.12.22.tgz", + "integrity": "sha512-GJcrPLc+Hu7nk+XQ70Okt3M1u1eRr2ZvpMbzbc54oTPJZySHcX9ccZGVFcsZbSZ6o1uqumm8Oc7OFkD3Rn1/og==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/installations": "0.6.18", + "@firebase/messaging-interop-types": "0.2.3", + "@firebase/util": "1.12.1", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/messaging-compat": { + "version": "0.2.22", + "resolved": "https://registry.npmjs.org/@firebase/messaging-compat/-/messaging-compat-0.2.22.tgz", + "integrity": "sha512-5ZHtRnj6YO6f/QPa/KU6gryjmX4Kg33Kn4gRpNU6M1K47Gm8kcQwPkX7erRUYEH1mIWptfvjvXMHWoZaWjkU7A==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/messaging": "0.12.22", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/messaging-interop-types": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@firebase/messaging-interop-types/-/messaging-interop-types-0.2.3.tgz", + "integrity": "sha512-xfzFaJpzcmtDjycpDeCUj0Ge10ATFi/VHVIvEEjDNc3hodVBQADZ7BWQU7CuFpjSHE+eLuBI13z5F/9xOoGX8Q==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/performance": { + "version": "0.7.7", + "resolved": "https://registry.npmjs.org/@firebase/performance/-/performance-0.7.7.tgz", + "integrity": "sha512-JTlTQNZKAd4+Q5sodpw6CN+6NmwbY72av3Lb6wUKTsL7rb3cuBIhQSrslWbVz0SwK3x0ZNcqX24qtRbwKiv+6w==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/installations": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0", + "web-vitals": "^4.2.4" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/performance-compat": { + "version": "0.2.20", + "resolved": "https://registry.npmjs.org/@firebase/performance-compat/-/performance-compat-0.2.20.tgz", + "integrity": "sha512-XkFK5NmOKCBuqOKWeRgBUFZZGz9SzdTZp4OqeUg+5nyjapTiZ4XoiiUL8z7mB2q+63rPmBl7msv682J3rcDXIQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/performance": "0.7.7", + "@firebase/performance-types": "0.2.3", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/performance-types": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@firebase/performance-types/-/performance-types-0.2.3.tgz", + "integrity": "sha512-IgkyTz6QZVPAq8GSkLYJvwSLr3LS9+V6vNPQr0x4YozZJiLF5jYixj0amDtATf1X0EtYHqoPO48a9ija8GocxQ==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/remote-config": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@firebase/remote-config/-/remote-config-0.6.5.tgz", + "integrity": "sha512-fU0c8HY0vrVHwC+zQ/fpXSqHyDMuuuglV94VF6Yonhz8Fg2J+KOowPGANM0SZkLvVOYpTeWp3ZmM+F6NjwWLnw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/installations": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/remote-config-compat": { + "version": "0.2.18", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-compat/-/remote-config-compat-0.2.18.tgz", + "integrity": "sha512-YiETpldhDy7zUrnS8e+3l7cNs0sL7+tVAxvVYU0lu7O+qLHbmdtAxmgY+wJqWdW2c9nDvBFec7QiF58pEUu0qQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/remote-config": "0.6.5", + "@firebase/remote-config-types": "0.4.0", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/remote-config-types": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-types/-/remote-config-types-0.4.0.tgz", + "integrity": "sha512-7p3mRE/ldCNYt8fmWMQ/MSGRmXYlJ15Rvs9Rk17t8p0WwZDbeK7eRmoI1tvCPaDzn9Oqh+yD6Lw+sGLsLg4kKg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/storage": { + "version": "0.13.14", + "resolved": "https://registry.npmjs.org/@firebase/storage/-/storage-0.13.14.tgz", + "integrity": "sha512-xTq5ixxORzx+bfqCpsh+o3fxOsGoDjC1nO0Mq2+KsOcny3l7beyBhP/y1u5T6mgsFQwI1j6oAkbT5cWdDBx87g==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/storage-compat": { + "version": "0.3.24", + "resolved": "https://registry.npmjs.org/@firebase/storage-compat/-/storage-compat-0.3.24.tgz", + "integrity": "sha512-XHn2tLniiP7BFKJaPZ0P8YQXKiVJX+bMyE2j2YWjYfaddqiJnROJYqSomwW6L3Y+gZAga35ONXUJQju6MB6SOQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/storage": "0.13.14", + "@firebase/storage-types": "0.8.3", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/storage-types": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@firebase/storage-types/-/storage-types-0.8.3.tgz", + "integrity": "sha512-+Muk7g9uwngTpd8xn9OdF/D48uiQ7I1Fae7ULsWPuKoCH3HU7bfFPhxtJYzyhjdniowhuDpQcfPmuNRAqZEfvg==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/util": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.12.1.tgz", + "integrity": "sha512-zGlBn/9Dnya5ta9bX/fgEoNC3Cp8s6h+uYPYaDieZsFOAdHP/ExzQ/eaDgxD3GOROdPkLKpvKY0iIzr9adle0w==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/webchannel-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.3.tgz", + "integrity": "sha512-2xCRM9q9FlzGZCdgDMJwc0gyUkWFtkosy7Xxr6sFgQwn+wMNIWd7xIvYNauU1r64B5L5rsGKy/n9TKJ0aAFeqQ==", + "license": "Apache-2.0" + }, + "node_modules/@grpc/grpc-js": { + "version": "1.9.15", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.15.tgz", + "integrity": "sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.7.8", + "@types/node": ">=12.12.47" + }, + "engines": { + "node": "^8.13.0 || >=10.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -5916,6 +6592,70 @@ "prettier": ">=2.4.0" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz", + "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.55.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", @@ -6845,7 +7585,6 @@ "version": "12.20.55", "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz", "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/node-forge": { @@ -7905,7 +8644,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -9027,7 +9765,6 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -10591,7 +11328,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/emojis-list": { @@ -10913,7 +11649,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -11657,7 +12392,6 @@ "version": "0.11.4", "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", - "dev": true, "license": "Apache-2.0", "dependencies": { "websocket-driver": ">=0.5.1" @@ -11813,6 +12547,42 @@ "micromatch": "^4.0.2" } }, + "node_modules/firebase": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/firebase/-/firebase-11.10.0.tgz", + "integrity": "sha512-nKBXoDzF0DrXTBQJlZa+sbC5By99ysYU1D6PkMRYknm0nCW7rJly47q492Ht7Ndz5MeYSBuboKuhS1e6mFC03w==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/ai": "1.4.1", + "@firebase/analytics": "0.10.17", + "@firebase/analytics-compat": "0.2.23", + "@firebase/app": "0.13.2", + "@firebase/app-check": "0.10.1", + "@firebase/app-check-compat": "0.3.26", + "@firebase/app-compat": "0.4.2", + "@firebase/app-types": "0.9.3", + "@firebase/auth": "1.10.8", + "@firebase/auth-compat": "0.5.28", + "@firebase/data-connect": "0.3.10", + "@firebase/database": "1.0.20", + "@firebase/database-compat": "2.0.11", + "@firebase/firestore": "4.8.0", + "@firebase/firestore-compat": "0.3.53", + "@firebase/functions": "0.12.9", + "@firebase/functions-compat": "0.3.26", + "@firebase/installations": "0.6.18", + "@firebase/installations-compat": "0.2.18", + "@firebase/messaging": "0.12.22", + "@firebase/messaging-compat": "0.2.22", + "@firebase/performance": "0.7.7", + "@firebase/performance-compat": "0.2.20", + "@firebase/remote-config": "0.6.5", + "@firebase/remote-config-compat": "0.2.18", + "@firebase/storage": "0.13.14", + "@firebase/storage-compat": "0.3.24", + "@firebase/util": "1.12.1" + } + }, "node_modules/flat": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", @@ -12116,7 +12886,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -12925,7 +13694,6 @@ "version": "0.5.10", "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", - "dev": true, "license": "MIT" }, "node_modules/http-proxy": { @@ -13047,6 +13815,12 @@ "postcss": "^8.1.0" } }, + "node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "license": "ISC" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -13348,7 +14122,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -14740,6 +15513,12 @@ "lodash.keys": "^3.0.0" } }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -15018,6 +15797,12 @@ "node": ">=8.0" } }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -17547,6 +18332,45 @@ "node": ">=6" } }, + "node_modules/protobufjs": { + "version": "7.5.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.6.tgz", + "integrity": "sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.1", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/protobufjs/node_modules/@types/node": { + "version": "25.6.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.2.tgz", + "integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/protobufjs/node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "license": "MIT" + }, "node_modules/protractor": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/protractor/-/protractor-7.0.0.tgz", @@ -18887,7 +19711,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -20671,7 +21494,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -20702,7 +21524,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -21997,6 +22818,12 @@ "defaults": "^1.0.3" } }, + "node_modules/web-vitals": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", + "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==", + "license": "Apache-2.0" + }, "node_modules/webdriver-js-extender": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/webdriver-js-extender/-/webdriver-js-extender-2.1.0.tgz", @@ -22571,7 +23398,6 @@ "version": "0.7.4", "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", - "dev": true, "license": "Apache-2.0", "dependencies": { "http-parser-js": ">=0.5.1", @@ -22586,7 +23412,6 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=0.8.0" @@ -22703,7 +23528,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -22892,7 +23716,6 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -22924,7 +23747,6 @@ "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -22953,7 +23775,6 @@ "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" diff --git a/package.json b/package.json index 003b8a05..958d9420 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@angular/platform-browser-dynamic": "^17.3.12", "@angular/router": "^17.3.12", "@angular/service-worker": "^17.3.12", + "@capacitor-firebase/messaging": "^7.5.0", "@capacitor-mlkit/barcode-scanning": "^7.0.0", "@capacitor/android": "^7.0.0", "@capacitor/app": "^7.0.0", @@ -38,6 +39,7 @@ "@capacitor/ios": "^7.0.0", "@capacitor/keyboard": "^7.0.0", "@capacitor/live-updates": "^0.4.0", + "@capacitor/local-notifications": "^7.0.6", "@capacitor/network": "^7.0.0", "@capacitor/push-notifications": "^7.0.0", "@capacitor/share": "^7.0.0", @@ -51,6 +53,7 @@ "@types/crypto-js": "^4.2.2", "core-js": "^3.6.4", "crypto-js": "^4.2.0", + "firebase": "^11.10.0", "js-base64": "^3.7.7", "markdown-to-txt": "^2.0.1", "patch-package": "^8.0.0", diff --git a/src/app/app.component.html b/src/app/app.component.html index 59ab0f36..d3664abe 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -194,6 +194,13 @@

Install Latest Update

+ + + + Notifications + + + { this.checkNotifications(); this.liveUpdateService.checkForUpdate(); + // Load saved prefs and (re)schedule local sign-up reminders. Re-runs + // every cold start so missed/expired reminders get cleaned up and + // anything still in the future gets re-armed. + this.notifications.getPrefs().then(() => this.notifications.applyPrefs()); }, 5000); } @@ -111,6 +117,12 @@ export class AppComponent implements OnInit { PushNotifications.addListener( 'pushNotificationReceived', async (notification: PushNotificationSchema) => { + // FCM pushes (emergency, announcements, schedule changes) — the + // toggle in Settings opts the device out of the underlying topic + // so users who turn a category off don't receive the push at all. + // This check is a defense in case staff send to all devices + // instead of a topic. + if (!this.notifications.shouldShowPushBanner()) return; this.toastCtrl.create({ message: `${notification.title}: ${notification.body}`, position: 'top', diff --git a/src/app/pages/notification-settings/notification-settings-routing.module.ts b/src/app/pages/notification-settings/notification-settings-routing.module.ts new file mode 100644 index 00000000..a55d0b76 --- /dev/null +++ b/src/app/pages/notification-settings/notification-settings-routing.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; + +import { NotificationSettingsPage } from './notification-settings.page'; + +const routes: Routes = [ + { + path: '', + component: NotificationSettingsPage, + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class NotificationSettingsPageRoutingModule {} diff --git a/src/app/pages/notification-settings/notification-settings.module.ts b/src/app/pages/notification-settings/notification-settings.module.ts new file mode 100644 index 00000000..7d62550f --- /dev/null +++ b/src/app/pages/notification-settings/notification-settings.module.ts @@ -0,0 +1,18 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { IonicModule } from '@ionic/angular'; + +import { NotificationSettingsPageRoutingModule } from './notification-settings-routing.module'; +import { NotificationSettingsPage } from './notification-settings.page'; + +@NgModule({ + imports: [ + CommonModule, + FormsModule, + IonicModule, + NotificationSettingsPageRoutingModule, + ], + declarations: [NotificationSettingsPage], +}) +export class NotificationSettingsPageModule {} diff --git a/src/app/pages/notification-settings/notification-settings.page.html b/src/app/pages/notification-settings/notification-settings.page.html new file mode 100644 index 00000000..a37f6faf --- /dev/null +++ b/src/app/pages/notification-settings/notification-settings.page.html @@ -0,0 +1,115 @@ + + + + + + Notifications + + + + +
+ +

Notifications

+

Choose which alerts you want from the PyCon app.

+
+ + + + +

All notifications

+

{{ allDisabled ? 'Everything muted.' : (allEnabled ? 'Everything on.' : 'Mixed — toggle to set them all at once.') }}

+
+ + +
+ + + +

Lightning Talk sign-ups

+

Reminds you 15 min before each lightning-talk signup window opens.

+
+ + +
+ + + +

Open Space sign-ups

+

Reminds you 15 min before each day's open-space signup window opens.

+
+ + +
+ + + +

Announcements

+

General conference updates — "keynote starting now," "opening reception in 20 minutes," etc.

+
+ + +
+ + + +

Schedule changes

+

Push when a session is rescheduled, moved to a different room, or cancelled.

+
+ + +
+ + + +

Emergency & safety alerts

+

Push when staff send a safety, ICE, or emergency notice.

+
+ + +
+
+ +
+ + +

If you previously denied notification permission for this app, enable it from your device settings to receive alerts.

+
+
+ + + + Diagnostics + + + +

FCM registration token

+

{{ fcmToken }}

+
+
+ + + + Copy token + + +
+
diff --git a/src/app/pages/notification-settings/notification-settings.page.scss b/src/app/pages/notification-settings/notification-settings.page.scss new file mode 100644 index 00000000..5006a0bc --- /dev/null +++ b/src/app/pages/notification-settings/notification-settings.page.scss @@ -0,0 +1,139 @@ +ion-header { + background: linear-gradient(180deg, #3B3EA9 0%, #3B3EA9 100%); + &::after { display: none; } +} + +ion-toolbar { + --background: transparent; + --border-color: transparent; + --color: #ffffff; +} + +ion-toolbar ion-menu-button { + --color: #ffffff; +} + +ion-title { + opacity: 0; + transition: opacity 0.25s ease; + + &.title-visible { + opacity: 1; + } +} + +.notif-hero { + display: flex; + flex-direction: column; + align-items: center; + padding: 16px 16px 40px; + background: linear-gradient(180deg, #3B3EA9 23.5%, #101136 53.29%); + color: #fff; + text-align: center; + + .notif-hero-icon { + font-size: 72px; + margin-bottom: 16px; + color: #FFD779; + } + + h1 { + margin: 0; + font-size: 1.6rem; + font-weight: 700; + } + + p { + margin: 6px 16px 0; + font-size: 0.9rem; + opacity: 0.8; + } +} + +.notif-list { + margin: -24px 16px 0; + border-radius: 16px; + background: var(--ion-card-background, #ffffff); + box-shadow: 0 8px 32px rgba(16, 17, 54, 0.15); + position: relative; + z-index: 1; + overflow: hidden; + + ion-item { + --background: transparent; + } + + .notif-master { + --background: var(--ion-color-step-50, #f7f8fb); + --inner-padding-top: 14px; + --inner-padding-bottom: 14px; + + .notif-label h2 { + font-weight: 700; + } + } + + .notif-label { + h2 { + font-size: 1rem; + font-weight: 600; + margin: 0 0 0.25rem; + white-space: normal; + } + p { + font-size: 0.8rem; + color: var(--ion-color-medium); + margin: 0; + white-space: normal; + } + } +} + +.notif-token-card { + margin: 16px; + border-radius: 12px; + background: var(--ion-card-background, #ffffff); + box-shadow: 0 2px 8px rgba(16, 17, 54, 0.08); + overflow: hidden; + + ion-item { + --background: transparent; + } + + ion-item-divider { + --background: var(--ion-color-step-50, #f4f4f4); + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--ion-color-medium); + min-height: 32px; + } + + .notif-token { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.7rem; + word-break: break-all; + user-select: all; + -webkit-user-select: all; + color: var(--ion-color-medium); + } +} + +.notif-note { + margin: 24px 16px 16px; + display: flex; + align-items: center; + gap: 8px; + justify-content: center; + + ion-icon { + font-size: 1.1rem; + color: var(--ion-color-medium); + } + + p { + margin: 0; + font-size: 0.8rem; + } +} diff --git a/src/app/pages/notification-settings/notification-settings.page.ts b/src/app/pages/notification-settings/notification-settings.page.ts new file mode 100644 index 00000000..1a690ae4 --- /dev/null +++ b/src/app/pages/notification-settings/notification-settings.page.ts @@ -0,0 +1,124 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { ToastController } from '@ionic/angular'; +import { Subscription } from 'rxjs'; +import { + NotificationsService, + NotificationCategory, + NotificationPrefs, +} from '../../providers/notifications.service'; + +@Component({ + selector: 'app-notification-settings', + templateUrl: './notification-settings.page.html', + styleUrls: ['./notification-settings.page.scss'], +}) +export class NotificationSettingsPage implements OnInit, OnDestroy { + prefs: NotificationPrefs = { + lightning: true, + openSpace: true, + emergency: true, + announcements: true, + scheduleChanges: true, + }; + loaded = false; + showTitle = false; + fcmToken: string | null = null; + private tokenSub?: Subscription; + + onScroll(event: any) { + this.showTitle = event.detail.scrollTop > 100; + } + + constructor( + private notifications: NotificationsService, + private toastCtrl: ToastController, + ) {} + + async ngOnInit() { + this.prefs = await this.notifications.getPrefs(); + this.loaded = true; + this.tokenSub = this.notifications.fcmToken$.subscribe((token) => { + this.fcmToken = token; + }); + } + + ngOnDestroy() { + this.tokenSub?.unsubscribe(); + } + + async onToggle(key: NotificationCategory, value: boolean) { + this.prefs = { ...this.prefs, [key]: value }; + await this.notifications.setPref(key, value); + const toast = await this.toastCtrl.create({ + message: value ? 'Notifications enabled' : 'Notifications disabled', + duration: 1500, + position: 'bottom', + }); + toast.present(); + } + + // True iff every category is currently enabled. Drives the master + // toggle's checked state. Indeterminate (some on, some off) reads as + // false so flipping it pushes everything on. + get allEnabled(): boolean { + return ( + this.prefs.lightning && + this.prefs.openSpace && + this.prefs.announcements && + this.prefs.scheduleChanges && + this.prefs.emergency + ); + } + + // True iff every category is currently disabled. Drives copy on the + // master toggle's subtitle so users can tell which state they're in + // when categories are mixed. + get allDisabled(): boolean { + return ( + !this.prefs.lightning && + !this.prefs.openSpace && + !this.prefs.announcements && + !this.prefs.scheduleChanges && + !this.prefs.emergency + ); + } + + async onMasterToggle(value: boolean) { + this.prefs = { + ...this.prefs, + lightning: value, + openSpace: value, + announcements: value, + scheduleChanges: value, + emergency: value, + }; + await this.notifications.setAllPrefs(value); + const toast = await this.toastCtrl.create({ + message: value ? 'All notifications enabled' : 'All notifications muted', + duration: 1500, + position: 'bottom', + }); + toast.present(); + } + + async copyToken() { + if (!this.fcmToken) return; + try { + await navigator.clipboard.writeText(this.fcmToken); + const toast = await this.toastCtrl.create({ + message: 'FCM token copied', + duration: 1500, + position: 'bottom', + color: 'success', + }); + toast.present(); + } catch { + const toast = await this.toastCtrl.create({ + message: 'Could not copy — long-press the token to select.', + duration: 2500, + position: 'bottom', + }); + toast.present(); + } + } +} diff --git a/src/app/pages/tabs-page/tabs-page-routing.module.ts b/src/app/pages/tabs-page/tabs-page-routing.module.ts index 9656360b..bc014033 100644 --- a/src/app/pages/tabs-page/tabs-page-routing.module.ts +++ b/src/app/pages/tabs-page/tabs-page-routing.module.ts @@ -125,6 +125,18 @@ const routes: Routes = [ } ] }, + { + path: 'notifications', + children: [ + { + path: '', + loadChildren: () => + import('../notification-settings/notification-settings.module').then( + m => m.NotificationSettingsPageModule, + ), + }, + ], + }, { path: 'rooms', children: [ diff --git a/src/app/providers/notifications.service.ts b/src/app/providers/notifications.service.ts new file mode 100644 index 00000000..e403fbb5 --- /dev/null +++ b/src/app/providers/notifications.service.ts @@ -0,0 +1,271 @@ +import { Injectable } from '@angular/core'; +import { Platform, ToastController } from '@ionic/angular'; +import { Storage } from '@ionic/storage-angular'; +import { + LocalNotifications, + ScheduleOptions, + PermissionStatus, +} from '@capacitor/local-notifications'; +import { PushNotifications } from '@capacitor/push-notifications'; +import { FirebaseMessaging } from '@capacitor-firebase/messaging'; +import { BehaviorSubject } from 'rxjs'; + + +export type NotificationCategory = + | 'lightning' + | 'openSpace' + | 'emergency' + | 'announcements' + | 'scheduleChanges'; + +export interface NotificationPrefs { + lightning: boolean; + openSpace: boolean; + emergency: boolean; + announcements: boolean; + scheduleChanges: boolean; +} + +interface ScheduledReminder { + id: number; + category: 'lightning' | 'openSpace'; + title: string; + body: string; + fireAt: Date; +} + +const PREF_KEY = 'notification_prefs'; + +// Default: opt-in by default for the conference. Users who don't want them +// can flip the toggles off in Settings. +const DEFAULT_PREFS: NotificationPrefs = { + lightning: true, + openSpace: true, + emergency: true, + announcements: true, + scheduleChanges: true, +}; + +// Map of toggle category → FCM topic name. Only push-driven categories +// appear here; client-scheduled local notifications (lightning, openSpace) +// are absent. Topic strings must be alphanumeric + dashes/underscores per +// FCM rules. Send pushes from Firebase Console → New campaign → +// Notifications → Target → Topic → enter the topic name. +const TOPIC_BY_CATEGORY: Partial> = { + emergency: 'emergency', + announcements: 'announcements', + scheduleChanges: 'schedule-changes', +}; + +// All times PDT (America/Los_Angeles, UTC-7). Reminders fire 15 minutes +// before the signup window opens. Edit this table if the published +// signup times change — the service rebuilds its schedule from this list +// each time `applyPrefs()` runs. +// +// Source: PYMOBIL-106 issue body. Fri/Sat open-space times in the source +// were flagged as needing verification — adjust here if pycon staff +// publish different times. +const REMINDERS: Array> = [ + // Lightning talks + { + category: 'lightning', + title: 'Lightning Talk sign-ups open soon', + body: 'Friday morning slot — signup opens at 9:00 AM, deadline 1:00 PM.', + fireAt: new Date('2026-05-16T08:45:00-07:00'), + }, + { + category: 'lightning', + title: 'Lightning Talk sign-ups open soon', + body: 'Friday evening slot — signup opens at 5:00 AM, deadline 9:00 AM.', + fireAt: new Date('2026-05-16T04:45:00-07:00'), + }, + { + category: 'lightning', + title: 'Lightning Talk sign-ups open soon', + body: 'Saturday morning slot — signup opens at 9:00 AM, deadline 1:00 PM.', + fireAt: new Date('2026-05-17T08:45:00-07:00'), + }, + { + category: 'lightning', + title: 'Lightning Talk sign-ups open soon', + body: 'Saturday afternoon slot — signup opens at 5:00 AM, deadline 9:00 AM.', + fireAt: new Date('2026-05-17T04:45:00-07:00'), + }, + // Open spaces + { + category: 'openSpace', + title: 'Open Space sign-ups open soon', + body: 'Thursday slots open at 5:00 AM PDT.', + fireAt: new Date('2026-05-15T04:45:00-07:00'), + }, + { + category: 'openSpace', + title: 'Open Space sign-ups open soon', + body: 'Friday slots open at 5:00 AM PDT.', + fireAt: new Date('2026-05-16T04:45:00-07:00'), + }, + { + category: 'openSpace', + title: 'Open Space sign-ups open soon', + body: 'Saturday slots open at 5:00 AM PDT.', + fireAt: new Date('2026-05-17T04:45:00-07:00'), + }, +]; + +// Stable IDs in the 8000–8099 range. Local-notification IDs must be 32-bit +// signed ints; using a deterministic offset means rescheduling cancels the +// prior copy instead of stacking duplicates. +const REMINDER_ID_BASE = 8000; + +@Injectable({ providedIn: 'root' }) +export class NotificationsService { + private prefs: NotificationPrefs = { ...DEFAULT_PREFS }; + private storageReady: Promise; + + // Surfaces the current FCM registration token so the Notifications page + // can let staff copy it out for testing — Web Inspector is unavailable on + // App Store builds, so without this they have no way to retrieve it. + // Updated whenever the OS fires the registration event. + readonly fcmToken$ = new BehaviorSubject(null); + + constructor( + private storage: Storage, + private platform: Platform, + private toastCtrl: ToastController, + ) { + this.storageReady = this.storage.create().then(() => undefined); + this.attachTokenListener(); + } + + private attachTokenListener() { + if (!this.platform.is('hybrid')) return; + PushNotifications.addListener('registration', (token) => { + if (token?.value) this.fcmToken$.next(token.value); + }).catch(() => undefined); + } + + async getPrefs(): Promise { + await this.storageReady; + const saved = await this.storage.get(PREF_KEY); + if (saved && typeof saved === 'object') { + this.prefs = { ...DEFAULT_PREFS, ...saved }; + } + return { ...this.prefs }; + } + + async setPref(key: NotificationCategory, value: boolean): Promise { + await this.storageReady; + this.prefs = { ...this.prefs, [key]: value }; + await this.storage.set(PREF_KEY, this.prefs); + const topic = TOPIC_BY_CATEGORY[key]; + if (topic) { + await this.syncTopic(topic, value); + } + await this.applyPrefs(); + } + + // Bulk setter for the "Mute all" / "Enable all" master control. Flips + // every category to the given value, persists once, then runs a single + // applyPrefs (which handles topic + local-notification reconciliation). + async setAllPrefs(value: boolean): Promise { + await this.storageReady; + const next: NotificationPrefs = { ...this.prefs }; + (Object.keys(next) as NotificationCategory[]).forEach((k) => { + next[k] = value; + }); + this.prefs = next; + await this.storage.set(PREF_KEY, this.prefs); + await this.applyPrefs(); + } + + // Subscribe/unsubscribe the device from an FCM topic so the toggle + // actually opts the user out of OS-level pushes — not just the in-app + // banner. Idempotent; safe to call repeatedly. No-op on web. + private async syncTopic(topic: string, enabled: boolean): Promise { + if (!this.platform.is('hybrid')) return; + try { + if (enabled) { + await FirebaseMessaging.subscribeToTopic({ topic }); + } else { + await FirebaseMessaging.unsubscribeFromTopic({ topic }); + } + } catch (err) { + console.warn(`NotificationsService: topic sync failed for ${topic}`, err); + } + } + + // Re-evaluate scheduled local notifications against the current prefs. + // Called on app startup and after every toggle change. Push-driven + // categories (emergency) don't need rescheduling — they're filtered at + // receive time in handleEmergencyPush(). + async applyPrefs(): Promise { + if (!this.platform.is('hybrid')) return; + // Re-assert FCM topic subscriptions on every apply — covers cases + // where the device's topic state drifts from prefs (fresh install, + // token rotation, app reinstall) by always pushing local state up to + // Firebase. + for (const [category, topic] of Object.entries(TOPIC_BY_CATEGORY)) { + if (!topic) continue; + const enabled = this.prefs[category as NotificationCategory]; + await this.syncTopic(topic, enabled); + } + try { + const granted = await this.ensurePermission(); + if (!granted) return; + + // Cancel anything we previously scheduled. Safe to call with IDs that + // aren't currently scheduled — the plugin no-ops them. + const allIds = REMINDERS.map((_, idx) => ({ id: REMINDER_ID_BASE + idx })); + await LocalNotifications.cancel({ notifications: allIds }); + + const now = Date.now(); + const toSchedule: ScheduleOptions['notifications'] = []; + REMINDERS.forEach((r, idx) => { + if (!this.prefs[r.category]) return; + if (r.fireAt.getTime() <= now) return; // skip past windows + toSchedule.push({ + id: REMINDER_ID_BASE + idx, + title: r.title, + body: r.body, + schedule: { at: r.fireAt, allowWhileIdle: true }, + smallIcon: 'ic_stat_notify', + }); + }); + if (toSchedule.length > 0) { + await LocalNotifications.schedule({ notifications: toSchedule }); + } + } catch (err) { + console.warn('NotificationsService: applyPrefs failed', err); + } + } + + // With FCM topics handling opt-out at the server level, devices that + // have toggled a category off won't receive the push at all. We keep + // this check as a safety net for pushes that staff send to "all + // devices" rather than via a topic — those still reach everyone, so + // we suppress the in-app banner if every push category is disabled. + shouldShowPushBanner(): boolean { + return ( + this.prefs.emergency || + this.prefs.announcements || + this.prefs.scheduleChanges + ); + } + + private async ensurePermission(): Promise { + let status: PermissionStatus; + try { + status = await LocalNotifications.checkPermissions(); + } catch { + return false; + } + if (status.display === 'granted') return true; + if (status.display === 'denied') return false; + try { + const requested = await LocalNotifications.requestPermissions(); + return requested.display === 'granted'; + } catch { + return false; + } + } +} From 69f661d4562755a5b7522ff6cb39cbbd5f2c886d Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 7 May 2026 22:16:55 -0500 Subject: [PATCH 08/12] feat(speakers): add D&I Panel + Steering Council Panel keynotes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors what us.pycon.org/2026/about/keynote-speakers shows. Two panels were missing from the in-app Keynote Speakers page: - Diversity & Inclusion Panel — six PSF D&I Workgroup panelists from PyLadies chapters across Brazil, the U.S., Ghana, and Malaysia (Jules, Débora Azevedo, Alla Barbalat, Georgi Ker, Theresa Seyram Agbenyegah "Stancy", Abhijeet Mote) - Steering Council Panel — five council members (Barry Warsaw, Donghee Na, Pablo Galindo Salgado, Savannah Ostrowski, Thomas Wouters) Both render with photos, bios, and a tappable session link to their schedule slot. Steering Council photos upgraded from 48×48 thumbs to 100×100 cells with names, matching the website's larger format. session-detail.ts also now renders panelist photos + abstract for plenary panel sessions. Previously the keynote enrichment only fired when track === 'Keynote', so the panels rendered as empty pages with just the bare schedule link. Detection extended to match panel session titles, and the keynote enrichment now optionally accepts a `panelists` list on the abstract entry to populate keynoteData when the session title doesn't include speaker names (the case for these panels). Amanda Casari's keynote-speakers entry also got the abstract paragraph from the website (no title — rendered as body only). Closes PYMOBIL-125 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../keynote-speakers.page.html | 47 ++++++- .../keynote-speakers.page.scss | 94 +++++++++++++ .../keynote-speakers/keynote-speakers.page.ts | 82 ++++++++++++ .../pages/session-detail/session-detail.ts | 125 +++++++++++++++++- 4 files changed, 335 insertions(+), 13 deletions(-) diff --git a/src/app/pages/keynote-speakers/keynote-speakers.page.html b/src/app/pages/keynote-speakers/keynote-speakers.page.html index 299af666..473643d7 100644 --- a/src/app/pages/keynote-speakers/keynote-speakers.page.html +++ b/src/app/pages/keynote-speakers/keynote-speakers.page.html @@ -27,7 +27,7 @@

{{ speaker.name }}

{{ speaker.session.name }}

-

{{ speaker.session.day }} {{ speaker.session.timeStart }} — {{ speaker.session.location }}

+

{{ speaker.session.day }} {{ speaker.session.timeStart }} — {{ speaker.session.displayLocation || speaker.session.location }}

@@ -35,14 +35,49 @@

{{ speaker.session.name }}

-
-
- - {{ member.name }} +

{{ steeringCouncil.name }}

+
+
+ + {{ member.name }}
-

{{ steeringCouncil.name }}

{{ steeringCouncil.bio }}

+ + + +

{{ steeringCouncil.session.name }}

+

{{ steeringCouncil.session.day }} {{ steeringCouncil.session.timeStart }} — {{ steeringCouncil.session.displayLocation || steeringCouncil.session.location }}

+
+
+ + + + + +

{{ diversityPanel.eyebrow }}

+

{{ diversityPanel.name }}

+

{{ diversityPanel.intro }}

+
+
+ +
+

{{ member.name }}

+

{{ member.bio }}

+
+
+
+ + + +

{{ diversityPanel.session.name }}

+

{{ diversityPanel.session.day }} {{ diversityPanel.session.timeStart }} — {{ diversityPanel.session.displayLocation || diversityPanel.session.location }}

+
+
diff --git a/src/app/pages/keynote-speakers/keynote-speakers.page.scss b/src/app/pages/keynote-speakers/keynote-speakers.page.scss index eab42019..8239625c 100644 --- a/src/app/pages/keynote-speakers/keynote-speakers.page.scss +++ b/src/app/pages/keynote-speakers/keynote-speakers.page.scss @@ -148,3 +148,97 @@ ion-title { max-width: 56px; line-height: 1.2; } + +.ks-council-grid { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 16px; + margin: 16px 0 20px; +} + +.ks-council-cell { + display: flex; + flex-direction: column; + align-items: center; + width: 110px; +} + +.ks-council-photo-large { + width: 100px; + height: 100px; + border-radius: 8px; + object-fit: cover; + border: 2px solid #3B3EA9; +} + +.ks-council-name-large { + margin-top: 8px; + font-size: 0.78rem; + font-weight: 600; + text-align: center; + line-height: 1.25; + color: var(--ion-text-color); +} + +.ks-card-panel { + .ks-panel-eyebrow { + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--ion-color-medium); + margin: 0 0 4px; + } + + h2 { + font-size: 1.1rem; + font-weight: 700; + margin: 0 0 8px; + } + + .ks-panel-intro { + font-size: 0.92rem; + color: var(--ion-color-step-700, #4a4a4a); + margin: 0 0 16px; + } +} + +.ks-panel-members { + display: flex; + flex-direction: column; + gap: 14px; +} + +.ks-panel-member { + display: flex; + gap: 12px; + align-items: flex-start; +} + +.ks-panel-photo { + width: 64px; + height: 64px; + border-radius: 6px; + object-fit: cover; + flex-shrink: 0; + border: 1px solid var(--ion-color-step-150, #e0e0e0); +} + +.ks-panel-text { + flex: 1; + min-width: 0; + + h3 { + margin: 0 0 4px; + font-size: 0.95rem; + font-weight: 600; + } + + p { + margin: 0; + font-size: 0.82rem; + color: var(--ion-color-medium); + line-height: 1.4; + } +} diff --git a/src/app/pages/keynote-speakers/keynote-speakers.page.ts b/src/app/pages/keynote-speakers/keynote-speakers.page.ts index 60945f20..6eeef954 100644 --- a/src/app/pages/keynote-speakers/keynote-speakers.page.ts +++ b/src/app/pages/keynote-speakers/keynote-speakers.page.ts @@ -19,6 +19,21 @@ interface SteeringCouncil { name: string; members: SteeringCouncilMember[]; bio: string; + session?: any; +} + +interface PanelMember { + name: string; + photo: string | null; + bio: string; +} + +interface Panel { + name: string; + eyebrow: string; + members: PanelMember[]; + intro: string; + session?: any; } @Component({ @@ -70,6 +85,55 @@ export class KeynoteSpeakersPage implements OnInit { bio: 'The Python Steering Council is a 5-person elected committee that assumes a mandate to maintain the quality and stability of the Python language and CPython interpreter, improve the contributor experience, formalize and maintain a relationship between the Python core team and the PSF, establish decision making processes for Python Enhancement Proposals, seek consensus among contributors and the Python core team, and resolve decisions and disputes in decision making among the language.', }; + diversityPanel: Panel = { + name: 'D&I Panel: Python is for Everyone — Growing the Community Without Limits', + eyebrow: 'Hosted by the PSF Diversity & Inclusion Workgroup', + intro: + 'A panel from the PSF Diversity & Inclusion Workgroup on growing the Python community without limits — bringing together organizers and contributors from PyLadies chapters across Brazil, the U.S., Ghana, and Malaysia.', + members: [ + { + name: 'Jules', + photo: null, + bio: + 'Jules (they/them, she/her) is a nonbinary Brazilian who is PyLadies Recife and PyLadies Brasil Co-organizer. Fullstack developer by daylight and artist by moonlight, they are always eager to support event organizers and help provide a more inclusive community at the Diversity and Inclusion Workgroup from PSF. Former board member from Python Brazil Association (APyB) from 2022 to 2026. AuDHD and STEMinist.', + }, + { + name: 'Débora Azevedo', + photo: 'https://pycon-assets.s3.amazonaws.com/2026/media/images/deborah.original.png', + bio: + 'Débora is a public school teacher in Brazil, and one of the cofounders of PyLadies Brazil, the largest PyLadies chapter in the world. She’s a PhD student and she researches educational software development. She’s currently one of the organizers of Python Nordeste, a regional Python conference in Brazil, and a former PSF board member (2021–2024).', + }, + { + name: 'Alla Barbalat', + photo: 'https://pycon-assets.s3.amazonaws.com/2026/media/images/alla.original.png', + bio: + 'Alla Barbalat began her career as a lawyer before transitioning into tech. She is the lead organizer of PyLadies San Francisco, an avid Python user, and a speaker on topics at the intersection of Python, AI, and law.', + }, + { + name: 'Georgi Ker', + photo: 'https://pycon-assets.s3.amazonaws.com/2026/media/images/georgi.original.jpg', + bio: + 'Georgi Ker is the Director and a Fellow of the Python Software Foundation. She co-organizes PyLadiesCon and chairs the D&I Workgroup within the PSF. She is also one of the co-hosts of the podcast series "The Hidden Figures of Python" alongside Mariatta Wijaya, Cheuk Ting Ho, and Tereza Iofciu.', + }, + { + name: 'Theresa Seyram Agbenyegah (Stancy)', + photo: 'https://pycon-assets.s3.amazonaws.com/2026/media/images/Stancy-Portrait.original.jpg', + bio: + 'Theresa Seyram Agbenyegah (mostly referred to in the Tech community as Stancy) is a Software Engineer, Open-Source advocate, and Social Entrepreneur. She currently serves as the Programmes and Events Lead for PyLadies Ghana and is a member of Python Ghana. She is a DSF member and a member of the DSF event support working group, a PSF Diversity and Inclusion workgroup member, an Outreach ambassador for the CHAOSS DEI workgroup, and a Django Girls organizer.', + }, + { + name: 'Abhijeet Mote', + photo: 'https://pycon-assets.s3.amazonaws.com/2026/media/images/MNTN_Matt-Lief-Anderson_2856-Edit.original.jpg', + bio: + 'Abhijeet is a Lead Python AI Engineer and Fellow of the Python Software Foundation. He founded Python Penang, Malaysia, where he has helped grow the local developer community. He has spoken at international conferences including PyCon Italy, runs workshops, and mentors students and underrepresented groups in technology. His work focuses on scalable Python AI systems, distributed systems, data pipelines, and LLM-based applications across adtech, semiconductor, and healthcare.', + }, + ], + }; + + // Fallback avatar for panelists without an uploaded photo. Reuses the + // global default so the placeholder matches the rest of the app. + panelistFallback = 'assets/img/person-circle-outline.png'; + constructor( public liveUpdateService: LiveUpdateService, private confData: ConferenceData, @@ -89,6 +153,24 @@ export class KeynoteSpeakersPage implements OnInit { speaker.session = match; } }); + + // The D&I Panel ships as a kind="plenary" slot named + // "Diversity & Inclusion Panel" — not as a keynote — so look it + // up across all sessions, not just `keynoteSessions` above. + this.diversityPanel.session = data.sessions.find( + (s: any) => + typeof s.name === 'string' && + /diversity\s*(?:&|and)\s*inclusion\s+panel/i.test(s.name), + ); + // Steering Council session link. The schedule slot is literally + // titled "Steering Council Panel" (after track-prefix stripping), + // not "Python Steering Council" — match flexibly so future title + // tweaks don't break the link. + this.steeringCouncil.session = data.sessions.find( + (s: any) => + typeof s.name === 'string' && + /steering\s+council/i.test(s.name), + ); } }); } diff --git a/src/app/pages/session-detail/session-detail.ts b/src/app/pages/session-detail/session-detail.ts index d59a81b2..0731b005 100644 --- a/src/app/pages/session-detail/session-detail.ts +++ b/src/app/pages/session-detail/session-detail.ts @@ -18,6 +18,11 @@ interface KeynoteAbstract { title?: string; eyebrow?: string; paragraphs: string[]; + // Names from `keynoteSpeakers` to render alongside the abstract. Used + // for panels (D&I Panel, Steering Council, etc.) where the session + // title doesn't include the speaker names directly so the substring + // match below can't pick them up. + panelists?: string[]; } @Component({ @@ -68,6 +73,46 @@ export class SessionDetailPage implements OnDestroy { 'English \u2014 Maintaining one of the most widely used programming languages in the world is not just a matter of code. It means carrying decisions that affect millions of people, being part of a community that never sleeps, and finding reasons to keep going when nobody asks you to and nobody pays you for it. The world of software is shifting, and with it, the rules of the game for those who hold it together from the inside. In this talk I will share what I have learned after years in the trenches of open source: what it really means to be a maintainer, what you gain, what you lose, and why in spite of everything it is still worth it.', ], }, + { + match: ['Diversity & Inclusion Panel', 'D&I Panel', 'Python is for Everyone'], + title: 'D&I Panel: Python is for Everyone \u2014 Growing the Community Without Limits', + eyebrow: 'Panel hosted by the PSF Diversity & Inclusion Workgroup', + paragraphs: [ + 'A panel from the PSF Diversity & Inclusion Workgroup on growing the Python community without limits \u2014 bringing together organizers and contributors from PyLadies chapters across Brazil, the U.S., Ghana, and Malaysia.', + ], + panelists: [ + 'Jules', + 'D\u00e9bora Azevedo', + 'Alla Barbalat', + 'Georgi Ker', + 'Theresa Seyram Agbenyegah', + 'Abhijeet Mote', + ], + }, + { + match: ['Steering Council Panel', 'Steering Council'], + title: 'Python Steering Council Panel', + eyebrow: 'Annual address from the Python Steering Council', + paragraphs: [ + 'The Python Steering Council is a 5-person elected committee that assumes a mandate to maintain the quality and stability of the Python language and CPython interpreter, improve the contributor experience, formalize and maintain a relationship between the Python core team and the PSF, establish decision making processes for Python Enhancement Proposals, seek consensus among contributors and the Python core team, and resolve decisions and disputes in decision making among the language.', + ], + panelists: [ + 'Barry Warsaw', + 'Donghee Na', + 'Pablo Galindo Salgado', + 'Savannah Ostrowski', + 'Thomas Wouters', + ], + }, + { + // Amanda's keynote-speakers page entry has no abstract title yet \u2014 + // this is rendered as a single body paragraph. Source: + // https://us.pycon.org/2026/about/keynote-speakers/#amanda-casari + match: ['amanda casari', 'Amanda Casari'], + paragraphs: [ + 'amanda casari is an engineer and researcher who has worked in many technical and socio-technical disciplines for over 20 years, including developer relations, product management, data science, and underwater robotics. amanda was named an External Faculty member of the Vermont Complex Systems Center in 2021 and co-authored Feature Engineering for Machine Learning Principles and Techniques for Data Scientists for O\u2019Reilly. amanda is persistently fascinated by complexity, the differences between the systems we aim to create and the ones that emerge, roller derby, and pie.', + ], + }, ]; private keynoteSpeakers: Record = { @@ -91,6 +136,54 @@ export class SessionDetailPage implements OnDestroy { photo: 'https://pycon-assets.s3.amazonaws.com/2026/media/images/Pablo_Galindo_Salgado.original.jpg', bio: 'CPython core developer and Theoretical Physicist. Currently serving on the Python Steering Council in his 6th term and release manager for Python 3.10 and 3.11.', }, + // D&I Panel — six panelists. The session-detail enrichment matches by + // substring on the session name; for the panel, the talk name is + // "Diversity & Inclusion Panel" and the panelist names below all + // appear in the abstract paragraphs above. Keep these names in lowercase + // for case-insensitive matching when needed. + 'Jules': { + photo: 'assets/img/person-circle-outline.png', + bio: 'Jules (they/them, she/her) is a nonbinary Brazilian who is PyLadies Recife and PyLadies Brasil Co-organizer. Fullstack developer by daylight and artist by moonlight, they are always eager to support event organizers and help provide a more inclusive community at the Diversity and Inclusion Workgroup from PSF. Former board member from Python Brazil Association (APyB) from 2022 to 2026. AuDHD and STEMinist.', + }, + 'Débora Azevedo': { + photo: 'https://pycon-assets.s3.amazonaws.com/2026/media/images/deborah.original.png', + bio: 'Débora is a public school teacher in Brazil, and one of the cofounders of PyLadies Brazil, the largest PyLadies chapter in the world. She’s a PhD student and she researches educational software development. She’s currently one of the organizers of Python Nordeste, a regional Python conference in Brazil, and a former PSF board member (2021–2024).', + }, + 'Alla Barbalat': { + photo: 'https://pycon-assets.s3.amazonaws.com/2026/media/images/alla.original.png', + bio: 'Alla Barbalat began her career as a lawyer before transitioning into tech. She is the lead organizer of PyLadies San Francisco, an avid Python user, and a speaker on topics at the intersection of Python, AI, and law.', + }, + 'Georgi Ker': { + photo: 'https://pycon-assets.s3.amazonaws.com/2026/media/images/georgi.original.jpg', + bio: 'Georgi Ker is the Director and a Fellow of the Python Software Foundation. She co-organizes PyLadiesCon and chairs the D&I Workgroup within the PSF. She is also one of the co-hosts of the podcast series "The Hidden Figures of Python" alongside Mariatta Wijaya, Cheuk Ting Ho, and Tereza Iofciu.', + }, + 'Theresa Seyram Agbenyegah': { + photo: 'https://pycon-assets.s3.amazonaws.com/2026/media/images/Stancy-Portrait.original.jpg', + bio: 'Theresa Seyram Agbenyegah (mostly referred to in the Tech community as Stancy) is a Software Engineer, Open-Source advocate, and Social Entrepreneur. She currently serves as the Programmes and Events Lead for PyLadies Ghana and is a member of Python Ghana. She is a DSF member and a member of the DSF event support working group, a PSF Diversity and Inclusion workgroup member, an Outreach ambassador for the CHAOSS DEI workgroup, and a Django Girls organizer.', + }, + 'Abhijeet Mote': { + photo: 'https://pycon-assets.s3.amazonaws.com/2026/media/images/MNTN_Matt-Lief-Anderson_2856-Edit.original.jpg', + bio: 'Abhijeet is a Lead Python AI Engineer and Fellow of the Python Software Foundation. He founded Python Penang, Malaysia, where he has helped grow the local developer community. He has spoken at international conferences including PyCon Italy, runs workshops, and mentors students and underrepresented groups in technology. His work focuses on scalable Python AI systems, distributed systems, data pipelines, and LLM-based applications across adtech, semiconductor, and healthcare.', + }, + // Python Steering Council panelists. Pablo is already above with his + // standalone keynote bio — kept that copy; the Steering Council panel + // pulls all five via the abstract's `panelists` list. + 'Barry Warsaw': { + photo: 'https://pycon-assets.s3.amazonaws.com/2026/media/images/Barry_PyCon.max-165x165.jpg', + bio: 'Python Steering Council member.', + }, + 'Donghee Na': { + photo: 'https://pycon-assets.s3.amazonaws.com/2026/media/images/donghee_na.max-165x165.jpg', + bio: 'Python Steering Council member and CPython core developer.', + }, + 'Savannah Ostrowski': { + photo: 'https://pycon-assets.s3.amazonaws.com/2026/media/images/savannah.max-165x165.jpg', + bio: 'Python Steering Council member.', + }, + 'Thomas Wouters': { + photo: 'https://pycon-assets.s3.amazonaws.com/2026/media/images/Thomas_Wouters.max-165x165.jpg', + bio: 'Python Steering Council member, CPython core developer, and release manager.', + }, }; constructor( @@ -133,6 +226,12 @@ export class SessionDetailPage implements OnDestroy { this.session = foundSession; this.isOpenSpace = this.session?.track === 'Open Space' || this.session?.tracks?.includes('Open Space'); this.isKeynote = this.session?.tracks?.includes('keynote') || this.session?.track === 'Keynote'; + // Panels (D&I, Steering Council) ship as kind="plenary" not + // "keynote", so isKeynote is false — but they still need the + // panelist photos + abstract treatment. Detect by name pattern. + const isPanel = + typeof this.session?.name === 'string' && + /(?:diversity\s*(?:&|and)\s*inclusion\s+panel|steering\s+council\s+panel)/i.test(this.session.name); // Only the *collapsed* "Posters" schedule slot lists every poster; // individual poster session-detail pages show their own description. this.isPosters = this.session?.track === 'Poster' && this.session?.name === 'Posters'; @@ -148,17 +247,29 @@ export class SessionDetailPage implements OnDestroy { this.keynoteData = []; this.keynoteAbstract = null; - // Enrich keynote sessions with speaker photo/bio. Collect every - // matching speaker so co-hosted keynotes (e.g. "Rachell Calhoun & - // Tim Schilling") render all speakers, not just the first match. - if (this.isKeynote) { + // Enrich keynote and panel sessions with speaker photo/bio. + // Keynotes match by substring on the session title (works because + // a keynote's title typically includes the speaker's name). + // Panels (D&I, Steering Council) match by abstract: the abstract + // entry carries an explicit `panelists` list of names to render, + // since the session title doesn't name them. + if (this.isKeynote || isPanel) { const sessionName = (this.session?.name || '').toLowerCase(); - this.keynoteData = Object.entries(this.keynoteSpeakers) - .filter(([name]) => sessionName.includes(name.toLowerCase())) - .map(([name, data]) => ({ name, ...data })); this.keynoteAbstract = this.keynoteAbstracts.find( (a) => a.match.some((m) => sessionName.includes(m.toLowerCase())) ) || null; + if (this.keynoteAbstract?.panelists?.length) { + this.keynoteData = this.keynoteAbstract.panelists + .map((name) => { + const data = this.keynoteSpeakers[name]; + return data ? { name, ...data } : null; + }) + .filter((entry): entry is { name: string; photo: string; bio: string } => entry !== null); + } else { + this.keynoteData = Object.entries(this.keynoteSpeakers) + .filter(([name]) => sessionName.includes(name.toLowerCase())) + .map(([name, data]) => ({ name, ...data })); + } } if (this.session?.id != null) { From 9c18a4b20e188a64758f4c19ca40358f5a91237b Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 7 May 2026 22:40:11 -0500 Subject: [PATCH 09/12] fix(notifications): correct off-by-one reminder dates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PyCon US 2026 weekday → date mapping is Thu=May 14, Fri=May 15, Sat=May 16, Sun=May 17. PYMOBIL-106's issue body listed Friday as "May 16", Saturday as "May 17", and Thursday as "May 15" — those are off by one day. The mobile reminder schedule was copying those verbatim, so every reminder would have fired the day after the signup window actually opened. Shift every entry in REMINDERS back one day so reminders fire 15 min before the real PDT signup time. Also drops `smallIcon: 'ic_stat_notify'` from the schedule call — there's no matching Android drawable in this project; the default app icon will be used instead. Refs PYMOBIL-106 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/providers/notifications.service.ts | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/app/providers/notifications.service.ts b/src/app/providers/notifications.service.ts index e403fbb5..fe475295 100644 --- a/src/app/providers/notifications.service.ts +++ b/src/app/providers/notifications.service.ts @@ -62,8 +62,10 @@ const TOPIC_BY_CATEGORY: Partial> = { // signup times change — the service rebuilds its schedule from this list // each time `applyPrefs()` runs. // -// Source: PYMOBIL-106 issue body. Fri/Sat open-space times in the source -// were flagged as needing verification — adjust here if pycon staff +// PyCon US 2026 calendar: Thu = May 14, Fri = May 15, Sat = May 16, Sun = May 17. +// (PYMOBIL-106's issue body had off-by-one dates — fixed here.) +// Fri/Sat open-space times were flagged as needing verification by staff; +// the 5:00 AM placeholder kept here so the infra ships — adjust if staff // publish different times. const REMINDERS: Array> = [ // Lightning talks @@ -71,44 +73,44 @@ const REMINDERS: Array> = [ category: 'lightning', title: 'Lightning Talk sign-ups open soon', body: 'Friday morning slot — signup opens at 9:00 AM, deadline 1:00 PM.', - fireAt: new Date('2026-05-16T08:45:00-07:00'), + fireAt: new Date('2026-05-15T08:45:00-07:00'), }, { category: 'lightning', title: 'Lightning Talk sign-ups open soon', body: 'Friday evening slot — signup opens at 5:00 AM, deadline 9:00 AM.', - fireAt: new Date('2026-05-16T04:45:00-07:00'), + fireAt: new Date('2026-05-15T04:45:00-07:00'), }, { category: 'lightning', title: 'Lightning Talk sign-ups open soon', body: 'Saturday morning slot — signup opens at 9:00 AM, deadline 1:00 PM.', - fireAt: new Date('2026-05-17T08:45:00-07:00'), + fireAt: new Date('2026-05-16T08:45:00-07:00'), }, { category: 'lightning', title: 'Lightning Talk sign-ups open soon', body: 'Saturday afternoon slot — signup opens at 5:00 AM, deadline 9:00 AM.', - fireAt: new Date('2026-05-17T04:45:00-07:00'), + fireAt: new Date('2026-05-16T04:45:00-07:00'), }, // Open spaces { category: 'openSpace', title: 'Open Space sign-ups open soon', body: 'Thursday slots open at 5:00 AM PDT.', - fireAt: new Date('2026-05-15T04:45:00-07:00'), + fireAt: new Date('2026-05-14T04:45:00-07:00'), }, { category: 'openSpace', title: 'Open Space sign-ups open soon', body: 'Friday slots open at 5:00 AM PDT.', - fireAt: new Date('2026-05-16T04:45:00-07:00'), + fireAt: new Date('2026-05-15T04:45:00-07:00'), }, { category: 'openSpace', title: 'Open Space sign-ups open soon', body: 'Saturday slots open at 5:00 AM PDT.', - fireAt: new Date('2026-05-17T04:45:00-07:00'), + fireAt: new Date('2026-05-16T04:45:00-07:00'), }, ]; @@ -228,7 +230,6 @@ export class NotificationsService { title: r.title, body: r.body, schedule: { at: r.fireAt, allowWhileIdle: true }, - smallIcon: 'ic_stat_notify', }); }); if (toSchedule.length > 0) { From e8d65f2eb5f8e689b1bdd16cb7001499bee37be8 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 8 May 2026 09:43:21 -0500 Subject: [PATCH 10/12] fix(notifications): use only @capacitor-firebase/messaging for push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capawesome's @capacitor-firebase/messaging README explicitly warns that no other Capacitor push-notification plugin can be installed alongside it — both swizzle APNs registration on iOS and both register FirebaseMessagingService receivers on Android, which makes token delivery and foreground push handling nondeterministic. Remove @capacitor/push-notifications entirely: - Drop the npm dependency and the matching CapacitorPushNotifications pod from ios/App/Podfile. - app.component.ts: replace PushNotifications.{addListener, requestPermissions, register} with FirebaseMessaging equivalents (tokenReceived listener, requestPermissions, getToken — getToken triggers APNs registration on iOS so a separate register() step isn't needed). Foreground delivery now flows through FirebaseMessaging.addListener('notificationReceived'). - notifications.service.ts: token capture now uses FirebaseMessaging.addListener('tokenReceived') and eagerly pulls FirebaseMessaging.getToken() at startup so the Diagnostics card on the Notifications page populates without waiting for a rotation. Refs PYMOBIL-121 Co-Authored-By: Claude Opus 4.7 (1M context) --- ios/App/Podfile | 1 - package-lock.json | 10 ---- package.json | 1 - src/app/app.component.ts | 62 ++++++++++++---------- src/app/providers/notifications.service.ts | 14 +++-- 5 files changed, 46 insertions(+), 42 deletions(-) diff --git a/ios/App/Podfile b/ios/App/Podfile index be5a41e3..6c2b5f1b 100644 --- a/ios/App/Podfile +++ b/ios/App/Podfile @@ -22,7 +22,6 @@ def capacitor_pods pod 'CapacitorLiveUpdates', :path => '../../node_modules/@capacitor/live-updates' pod 'CapacitorLocalNotifications', :path => '../../node_modules/@capacitor/local-notifications' pod 'CapacitorNetwork', :path => '../../node_modules/@capacitor/network' - pod 'CapacitorPushNotifications', :path => '../../node_modules/@capacitor/push-notifications' pod 'CapacitorShare', :path => '../../node_modules/@capacitor/share' pod 'CapacitorSplashScreen', :path => '../../node_modules/@capacitor/splash-screen' pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar' diff --git a/package-lock.json b/package-lock.json index 08fc6134..376385f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,6 @@ "@capacitor/live-updates": "^0.4.0", "@capacitor/local-notifications": "^7.0.6", "@capacitor/network": "^7.0.0", - "@capacitor/push-notifications": "^7.0.0", "@capacitor/share": "^7.0.0", "@capacitor/splash-screen": "^7.0.0", "@capacitor/status-bar": "^7.0.0", @@ -3506,15 +3505,6 @@ "@capacitor/core": ">=7.0.0" } }, - "node_modules/@capacitor/push-notifications": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/@capacitor/push-notifications/-/push-notifications-7.0.4.tgz", - "integrity": "sha512-ygTRlA9OiTNfwMZwk7/AUhtVb0/AoCDedsg5RLZUFs5g9abW0ePPzhLl4rSIYwp+4njNjXyIUsQvhnh344/HZA==", - "license": "MIT", - "peerDependencies": { - "@capacitor/core": ">=7.0.0" - } - }, "node_modules/@capacitor/share": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/@capacitor/share/-/share-7.0.3.tgz", diff --git a/package.json b/package.json index 958d9420..18d3f39c 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,6 @@ "@capacitor/live-updates": "^0.4.0", "@capacitor/local-notifications": "^7.0.6", "@capacitor/network": "^7.0.0", - "@capacitor/push-notifications": "^7.0.0", "@capacitor/share": "^7.0.0", "@capacitor/splash-screen": "^7.0.0", "@capacitor/status-bar": "^7.0.0", diff --git a/src/app/app.component.ts b/src/app/app.component.ts index c1ca1b95..12653e0a 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -3,7 +3,7 @@ import { Router } from '@angular/router'; import { MenuController, Platform, ToastController } from '@ionic/angular'; import { SplashScreen } from '@capacitor/splash-screen'; -import { PushNotifications, PushNotificationSchema } from '@capacitor/push-notifications'; +import { FirebaseMessaging } from '@capacitor-firebase/messaging'; import { Storage } from '@ionic/storage-angular'; @@ -106,34 +106,42 @@ export class AppComponent implements OnInit { } checkNotifications() { - PushNotifications.addListener('registration', token => { - console.info('Registration token: ', token.value); + // Single-stack push: @capacitor-firebase/messaging owns APNs/FCM + // registration, topic subscription, and foreground delivery. The + // older @capacitor/push-notifications plugin was removed because + // Capawesome explicitly warns the two cannot coexist (duplicate + // APNs swizzling, conflicting FirebaseMessagingService receivers). + FirebaseMessaging.addListener('tokenReceived', (event) => { + console.info('Registration token: ', event.token); }); - PushNotifications.requestPermissions().then(result => { - if (result.receive === 'granted') { - PushNotifications.register(); - } + FirebaseMessaging.requestPermissions().then((result) => { + if (result.receive !== 'granted') return; + // Calling getToken() also triggers APNs registration on iOS, so + // we don't need a separate register() step. + FirebaseMessaging.getToken().catch((err) => { + console.warn('FirebaseMessaging.getToken failed', err); + }); + }); + FirebaseMessaging.addListener('notificationReceived', async (event) => { + // FCM pushes (emergency, announcements, schedule changes) — the + // toggle in Settings opts the device out of the underlying topic + // so users who turn a category off don't receive the push at all. + // This check is a defense in case staff send to all devices + // instead of a topic. + if (!this.notifications.shouldShowPushBanner()) return; + const notification = event?.notification; + const title = notification?.title ?? 'PyCon US'; + const body = notification?.body ?? ''; + this.toastCtrl.create({ + message: body ? `${title}: ${body}` : title, + position: 'top', + buttons: [{ + icon: 'close', + side: 'end', + role: 'cancel', + }], + }).then((toast) => toast.present()); }); - PushNotifications.addListener( - 'pushNotificationReceived', - async (notification: PushNotificationSchema) => { - // FCM pushes (emergency, announcements, schedule changes) — the - // toggle in Settings opts the device out of the underlying topic - // so users who turn a category off don't receive the push at all. - // This check is a defense in case staff send to all devices - // instead of a topic. - if (!this.notifications.shouldShowPushBanner()) return; - this.toastCtrl.create({ - message: `${notification.title}: ${notification.body}`, - position: 'top', - buttons: [{ - icon: 'close', - side: 'end', - role: 'cancel' - }] - }).then(toast => toast.present()); - } - ); } initializeApp() { diff --git a/src/app/providers/notifications.service.ts b/src/app/providers/notifications.service.ts index fe475295..ba6d4a5a 100644 --- a/src/app/providers/notifications.service.ts +++ b/src/app/providers/notifications.service.ts @@ -6,7 +6,6 @@ import { ScheduleOptions, PermissionStatus, } from '@capacitor/local-notifications'; -import { PushNotifications } from '@capacitor/push-notifications'; import { FirebaseMessaging } from '@capacitor-firebase/messaging'; import { BehaviorSubject } from 'rxjs'; @@ -141,9 +140,18 @@ export class NotificationsService { private attachTokenListener() { if (!this.platform.is('hybrid')) return; - PushNotifications.addListener('registration', (token) => { - if (token?.value) this.fcmToken$.next(token.value); + FirebaseMessaging.addListener('tokenReceived', (event) => { + if (event?.token) this.fcmToken$.next(event.token); }).catch(() => undefined); + // tokenReceived only fires on rotation; on a fresh launch we may + // already have a token cached server-side. Pull it eagerly so the + // Diagnostics card on the Notifications page shows it immediately + // instead of waiting for the next rotation. + FirebaseMessaging.getToken() + .then((res) => { + if (res?.token) this.fcmToken$.next(res.token); + }) + .catch(() => undefined); } async getPrefs(): Promise { From 2ce4204682c253a7dd571d806eb4bb009e6609ec Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 8 May 2026 09:48:42 -0500 Subject: [PATCH 11/12] feat(notifications): track opt-in/out rates via Firebase Analytics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds @capacitor-firebase/analytics so the Firebase console can show how many users disable each notification category — there's no aggregate FCM-topic-subscriber API to query for this otherwise. Instrumentation: - `toggle_notification` event on every individual toggle change, with `category` and `enabled` params - `toggle_all_notifications` event when the master toggle flips everything at once - `notification_received` event from the FCM foreground listener, with `topic` (read from data payload) and `banner_shown` so delivery counts can be compared to in-app banner display - Per-category user properties (`notif_lightning`, `notif_openSpace`, `notif_emergency`, `notif_announcements`, `notif_scheduleChanges`) so Firebase Audiences segment by today's snapshot, not just deltas. The snapshot runs once on every cold start in applyPrefs() so pre-existing installs report state without needing to flip a toggle. Plugin failures don't bubble — analytics is best-effort, never blocks a toggle change. Web platform skipped. Closes PYMOBIL-127 Co-Authored-By: Claude Opus 4.7 (1M context) --- ios/App/Podfile | 1 + package-lock.json | 26 ++++++++++++ package.json | 1 + src/app/app.component.ts | 14 ++++++- src/app/providers/notifications.service.ts | 47 ++++++++++++++++++++++ 5 files changed, 88 insertions(+), 1 deletion(-) diff --git a/ios/App/Podfile b/ios/App/Podfile index 6c2b5f1b..34bebd79 100644 --- a/ios/App/Podfile +++ b/ios/App/Podfile @@ -11,6 +11,7 @@ install! 'cocoapods', :disable_input_output_paths => true def capacitor_pods pod 'Capacitor', :path => '../../node_modules/@capacitor/ios' pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios' + pod 'CapacitorFirebaseAnalytics', :path => '../../node_modules/@capacitor-firebase/analytics' pod 'CapacitorFirebaseMessaging', :path => '../../node_modules/@capacitor-firebase/messaging' pod 'CapacitorMlkitBarcodeScanning', :path => '../../node_modules/@capacitor-mlkit/barcode-scanning' pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app' diff --git a/package-lock.json b/package-lock.json index 376385f6..f07fed7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@angular/platform-browser-dynamic": "^17.3.12", "@angular/router": "^17.3.12", "@angular/service-worker": "^17.3.12", + "@capacitor-firebase/analytics": "^7.5.0", "@capacitor-firebase/messaging": "^7.5.0", "@capacitor-mlkit/barcode-scanning": "^7.0.0", "@capacitor/android": "^7.0.0", @@ -3002,6 +3003,31 @@ "node": ">=6.9.0" } }, + "node_modules/@capacitor-firebase/analytics": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@capacitor-firebase/analytics/-/analytics-7.5.0.tgz", + "integrity": "sha512-PxMTltu0wa7uT/tpXEPos8mentU4kDoU7yJeSzLIeO6ysB2Qp7N2/4GamKP1ITpZ0OvUuU7fjG5zqAQj5xX2jg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/capawesome-team/" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/capawesome" + } + ], + "license": "Apache-2.0", + "peerDependencies": { + "@capacitor/core": ">=7.0.0", + "firebase": "^11.2.0" + }, + "peerDependenciesMeta": { + "firebase": { + "optional": true + } + } + }, "node_modules/@capacitor-firebase/messaging": { "version": "7.5.0", "resolved": "https://registry.npmjs.org/@capacitor-firebase/messaging/-/messaging-7.5.0.tgz", diff --git a/package.json b/package.json index 18d3f39c..ae50adb0 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@angular/platform-browser-dynamic": "^17.3.12", "@angular/router": "^17.3.12", "@angular/service-worker": "^17.3.12", + "@capacitor-firebase/analytics": "^7.5.0", "@capacitor-firebase/messaging": "^7.5.0", "@capacitor-mlkit/barcode-scanning": "^7.0.0", "@capacitor/android": "^7.0.0", diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 12653e0a..38d2d863 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -4,6 +4,7 @@ import { MenuController, Platform, ToastController } from '@ionic/angular'; import { SplashScreen } from '@capacitor/splash-screen'; import { FirebaseMessaging } from '@capacitor-firebase/messaging'; +import { FirebaseAnalytics } from '@capacitor-firebase/analytics'; import { Storage } from '@ionic/storage-angular'; @@ -128,7 +129,18 @@ export class AppComponent implements OnInit { // so users who turn a category off don't receive the push at all. // This check is a defense in case staff send to all devices // instead of a topic. - if (!this.notifications.shouldShowPushBanner()) return; + const showed = this.notifications.shouldShowPushBanner(); + // Log every received push so we can compare delivery counts to + // opt-in rates in Firebase Analytics. The `topic` data field is + // attached server-side when staff fire a campaign via topic. + FirebaseAnalytics.logEvent({ + name: 'notification_received', + params: { + topic: event?.notification?.data?.['topic'] ?? 'unknown', + banner_shown: showed, + }, + }).catch(() => undefined); + if (!showed) return; const notification = event?.notification; const title = notification?.title ?? 'PyCon US'; const body = notification?.body ?? ''; diff --git a/src/app/providers/notifications.service.ts b/src/app/providers/notifications.service.ts index ba6d4a5a..edf87317 100644 --- a/src/app/providers/notifications.service.ts +++ b/src/app/providers/notifications.service.ts @@ -7,6 +7,7 @@ import { PermissionStatus, } from '@capacitor/local-notifications'; import { FirebaseMessaging } from '@capacitor-firebase/messaging'; +import { FirebaseAnalytics } from '@capacitor-firebase/analytics'; import { BehaviorSubject } from 'rxjs'; @@ -172,6 +173,8 @@ export class NotificationsService { await this.syncTopic(topic, value); } await this.applyPrefs(); + this.logToggle(key, value); + this.setUserProperty(key, value); } // Bulk setter for the "Mute all" / "Enable all" master control. Flips @@ -186,6 +189,45 @@ export class NotificationsService { this.prefs = next; await this.storage.set(PREF_KEY, this.prefs); await this.applyPrefs(); + this.logEvent('toggle_all_notifications', { enabled: value }); + (Object.keys(this.prefs) as NotificationCategory[]).forEach((k) => { + this.setUserProperty(k, value); + }); + } + + // Push the current pref state into Firebase user properties so audience + // segmentation in the Firebase / GA console reports today's snapshot, + // not just deltas. Called from setPref/setAllPrefs (above) and once on + // startup (in applyPrefs) so pre-existing installs report state too. + private snapshotPrefsToAnalytics(): void { + (Object.keys(this.prefs) as NotificationCategory[]).forEach((k) => { + this.setUserProperty(k, this.prefs[k]); + }); + } + + private logToggle(key: NotificationCategory, value: boolean): void { + this.logEvent('toggle_notification', { + // Stick with snake_case keys — Firebase Analytics' default schema. + category: key, + enabled: value, + }); + } + + private logEvent(name: string, params: Record): void { + if (!this.platform.is('hybrid')) return; + FirebaseAnalytics.logEvent({ name, params }).catch((err) => { + console.warn(`FirebaseAnalytics.logEvent(${name}) failed`, err); + }); + } + + private setUserProperty(key: NotificationCategory, value: boolean): void { + if (!this.platform.is('hybrid')) return; + // User-property keys must be ≤24 chars and start with a letter; the + // `notif_` prefix keeps them grouped in Firebase Analytics' UI. + FirebaseAnalytics.setUserProperty({ + key: `notif_${key}`.slice(0, 24), + value: value ? 'on' : 'off', + }).catch(() => undefined); } // Subscribe/unsubscribe the device from an FCM topic so the toggle @@ -210,6 +252,11 @@ export class NotificationsService { // receive time in handleEmergencyPush(). async applyPrefs(): Promise { if (!this.platform.is('hybrid')) return; + // Snapshot current prefs into Firebase Analytics user properties on + // every cold-start apply — Audiences / segments in the Firebase + // console will reflect today's state for every active install, not + // just users who flip a toggle. + this.snapshotPrefsToAnalytics(); // Re-assert FCM topic subscriptions on every apply — covers cases // where the device's topic state drifts from prefs (fresh install, // token rotation, app reinstall) by always pushing local state up to From b02d47ad3bb0e16c0409dbdb107bf4d51c425f57 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Fri, 8 May 2026 09:54:46 -0500 Subject: [PATCH 12/12] feat(notifications): add Daily digest toggle, scrub copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adds a Daily digest category (FCM topic `daily-digest`) for one-push-per-day conference recap notifications that link out to us.pycon.org. Default-on like the other categories. - Hero copy: "the PyCon app" → "the PyCon US app". - Emergency description no longer enumerates "ICE" — staff can use the channel for any safety / emergency notice without front-loading the user-facing copy with one specific scenario. Refs PYMOBIL-121 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../notification-settings.page.html | 16 ++++++++++++++-- .../notification-settings.page.ts | 4 ++++ src/app/providers/notifications.service.ts | 9 +++++++-- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/app/pages/notification-settings/notification-settings.page.html b/src/app/pages/notification-settings/notification-settings.page.html index a37f6faf..5bf062af 100644 --- a/src/app/pages/notification-settings/notification-settings.page.html +++ b/src/app/pages/notification-settings/notification-settings.page.html @@ -11,7 +11,7 @@

Notifications

-

Choose which alerts you want from the PyCon app.

+

Choose which alerts you want from the PyCon US app.

@@ -75,10 +75,22 @@

Schedule changes

+ + +

Daily digest

+

One push per day with the day's highlights, linking to the recap on us.pycon.org.

+
+ + +
+

Emergency & safety alerts

-

Push when staff send a safety, ICE, or emergency notice.

+

Push when staff send a safety or emergency notice.

> = { emergency: 'emergency', announcements: 'announcements', scheduleChanges: 'schedule-changes', + dailyDigest: 'daily-digest', }; // All times PDT (America/Los_Angeles, UTC-7). Reminders fire 15 minutes @@ -304,7 +308,8 @@ export class NotificationsService { return ( this.prefs.emergency || this.prefs.announcements || - this.prefs.scheduleChanges + this.prefs.scheduleChanges || + this.prefs.dailyDigest ); }