diff --git a/.github/workflows/flutter-analyze.yaml b/.github/workflows/flutter-analyze.yaml index 6e4026e..de08d68 100644 --- a/.github/workflows/flutter-analyze.yaml +++ b/.github/workflows/flutter-analyze.yaml @@ -24,6 +24,11 @@ jobs: - name: Analyze project source run: flutter analyze --no-pub + # Unit tests only: the integration tests (tag `integration`) need a + # running Supabase stack, see tool/test_integration.sh. + - name: Run unit tests + run: flutter test --no-pub --exclude-tags integration + - name: Install pana run: dart pub global activate pana diff --git a/.gitignore b/.gitignore index 298cac1..65bd562 100644 --- a/.gitignore +++ b/.gitignore @@ -119,4 +119,11 @@ app.*.symbols !**/ios/**/Podfile.lock # Test scripts files -test-scripts \ No newline at end of file +test-scripts + +# Supabase CLI machine-local state (created by `supabase link`/`supabase +# start`, e.g. via example/utils/supabase_prepare.*). The rest of supabase/ +# (config.toml for the local test stack, tests, Edge Functions) is tracked. +/supabase/.temp/ +/supabase/.branches/ +/supabase/.env* \ No newline at end of file diff --git a/.pubignore b/.pubignore new file mode 100644 index 0000000..2eb5db5 --- /dev/null +++ b/.pubignore @@ -0,0 +1,149 @@ +# Miscellaneous +*.class +*.lock +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# Visual Studio Code related +.classpath +.project +.settings/ +.vscode/ + +# Flutter repo-specific +/bin/cache/ +/bin/internal/bootstrap.bat +/bin/internal/bootstrap.sh +/bin/mingit/ +/dev/benchmarks/mega_gallery/ +/dev/bots/.recipe_deps +/dev/bots/android_tools/ +/dev/devicelab/ABresults*.json +/dev/docs/doc/ +/dev/docs/flutter.docs.zip +/dev/docs/lib/ +/dev/docs/pubspec.yaml +/dev/integration_tests/**/xcuserdata +/dev/integration_tests/**/Pods +/packages/flutter/coverage/ +version +analysis_benchmark.json + +# packages file containing multi-root paths +.packages.generated + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +**/generated_plugin_registrant.dart +.packages +.pub-cache/ +.pub/ +build/ +flutter_*.png +linked_*.ds +unlinked.ds +unlinked_spec.ds + +# Android related +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java +**/android/key.properties +*.jks + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/.last_build_id +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/ephemeral +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# macOS +**/macos/Flutter/GeneratedPluginRegistrant.swift + +# Coverage +coverage/ + +# Symbols +app.*.symbols + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages +!/dev/ci/**/Gemfile.lock +!**/ios/**/Podfile.lock + +# Test scripts files +test-scripts + +# Supabase CLI machine-local state (created by `supabase link`/`supabase +# start`, e.g. via example/utils/supabase_prepare.*). The rest of supabase/ +# (config.toml for the local test stack, tests, Edge Functions) is tracked. +/supabase/.temp/ +/supabase/.branches/ +/supabase/.env* +# --------------------------------------------------------------------------- +# pub only: a .pubignore replaces .gitignore for `dart pub publish`, so the +# rules above are repeated. Below, what the repository needs but the package +# doesn't (the documentation site, local tooling). +# --------------------------------------------------------------------------- +CNAME +/tool/ +/supabase/config.toml +/supabase/migrations/ +/supabase/tests/ +# The Docusaurus site, except the screenshot pub.dev shows. +/doc/* +!/doc/static/ +/doc/static/* +!/doc/static/img/ +/doc/static/img/* +!/doc/static/img/example_light.jpg +!/doc/static/img/demo.gif +!/doc/static/img/insideapp-logo-dark-text.png +!/doc/static/img/insideapp-logo-light-text.png diff --git a/CHANGELOG.md b/CHANGELOG.md index dfd3211..58e1b18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,69 @@ +## [2.0.0-rc.1] - 2026-10-21 +#### [@rickypid](https://github.com/rickypid) + +Release candidate of 2.0.0: the API and the database schema are complete, feedback welcome before the stable release. + +⚠️⚠️ **Breaking change: Dart API and database schema, see [MIGRATION.md](MIGRATION.md)** ⚠️⚠️ + +Moves the package to `flutter_chat_core`/`flutter_chat_ui` 2.x and redesigns +the database. The same SQL scripts create a fresh database or migrate a 1.x +one (maintenance window required). + +### Breaking changes + +* `ChatRoom`, `ChatUser` and `flutter_chat_core`'s `Message` replace the + `flutter_chat_types` models; dates are `DateTime`, ids are uuid strings. +* `SupabaseChatController` implements `flutter_chat_core`'s `ChatController`; + `roomsUpdates()`/`updateRoomList` become `roomsChanges()`/`applyRoomChange`. +* `SupabaseChatCoreConfig` takes named parameters; + `realtimeChatTypingUserPrefixChannel` becomes `realtimeRoomChannelPrefix`. +* Members are managed with `addMembers`/`removeMember`/`leaveRoom`/ + `setMemberRole` instead of `updateRoom`; `fetchUser` and + `processRoomRow(s)` are removed. +* Errors are thrown as `SupabaseChatCoreException` subtypes. +* Database: uuid ids, `timestamptz` dates, `chats.room_members`, messages + partitioned by week (`pg_partman` + `pg_cron`), rooms created through the + `chats.create_room` RPC, message changes over private Realtime Broadcast + channels (self-hosted Realtime >= v2.33). + +### Added + +* `SupabaseChatRoomsController`: paginated, searchable rooms list kept in + sync over Realtime, with unread counters. +* Telegram-like features: read receipts and unread counts, replies, + reactions, editing, pinned messages, message search, pinned and muted + rooms, group roles, optimistic sending with retry. +* Scoping hooks on `SupabaseChatCoreConfig` to keep the chats of different + workspaces apart. +* Injectable client (`SupabaseChatCore(client:, config:)`) and `dispose()`. +* `cleanup-media` Edge Function to delete the files of deleted messages. +* `migrate_storage_uuids.sh`/`.ps1`: moves the 1.x attachments to the new + room folders. +* Redesigned Telegram-like example app, with voice messages on every + platform. + +### Fixed + +* #38: the typing indicator hit the Realtime Presence rate limit; it now uses + Broadcast. +* Web: the online status observer never removed its focus/blur listeners. +* Realtime channels leaked by the online status helpers. + +### Security + +* Stricter RLS: no self-granted membership, messages sent only as yourself, + edits by the author, group management by admins only. +* Private room channels authorized by policies on `realtime.messages`. +* Policies limited to `authenticated`, internal tables and trigger functions + closed to clients (clean Supabase advisors). + +### Dependencies + +* `flutter_chat_types` replaced by `flutter_chat_core: ^2.9.0`. +* `supabase_flutter: ^2.17.2`; Dart `>=3.8.0`, Flutter `>=3.32.0`. +* The example pins `flutter_chat_ui: '>=2.11.1 <2.12.0'` (2.12.0 crashes + inside a standard `Scaffold`). + ## [1.6.2] - 2026-03-31 #### [@rickypid](https://github.com/rickypid) diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..99ccaa1 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,338 @@ +# Migrating from 1.x to 2.x + +`flutter_supabase_chat_core` 2.0.0 moves from +[`flutter_chat_types`](https://pub.dev/packages/flutter_chat_types) (the +model package behind `flutter_chat_ui` 1.x) to +[`flutter_chat_core`](https://pub.dev/packages/flutter_chat_core) (the model +package behind the rewritten `flutter_chat_ui` 2.x), and redesigns the +database: uuid ids, `timestamptz` dates, a `room_members` table, partitioned +messages, Realtime Broadcast and stricter RLS. + +Both sides must move together: **a 1.x app does not work against a 2.x +database, and a 2.x app does not work against a 1.x database.** Plan a +maintenance window (see [Database migration](#database-migration)). + +The [example app](example) is a complete 2.x integration; use it as a +reference while porting. + +## Dependencies + +```yaml +dependencies: + flutter_supabase_chat_core: ^2.0.0-rc.1 + flutter_chat_core: ^2.9.0 # replaces flutter_chat_types + flutter_chat_ui: ^2.11.1 # if you use the UI (2.x API) + flyer_chat_text_message: ^2.6.0 # one package per message type you render + flyer_chat_image_message: ^2.3.0 + flyer_chat_file_message: ^2.4.0 + uuid: ^4.5.1 +``` + +Minimum SDKs are now `Dart >=3.8.0` and `Flutter >=3.32.0`. + +`flutter_chat_ui` 2.12.0 moved its `Composer` to the experimental +`material_ui` package, which crashes inside a standard `Scaffold` ("No +Material widget found"); the example pins `'>=2.11.1 <2.12.0'`. For the same +reason, since `flutter_chat_core` 2.9.1 `ChatTheme.fromThemeData` takes a +`material_ui` theme, not Flutter's `ThemeData`: build the `ChatTheme` +yourself (see `example/lib/src/theme/app_theme.dart`). + +## Dart API + +### Mapping + +| 1.x | 2.x | +|-----|-----| +| `types.Room` | `ChatRoom` (`id` is a uuid string, `createdAt`/`updatedAt` are `DateTime?`, members in `users` each with its per-room `role`). New: `unreadCount`, `lastReadAt`, `pinnedAt`/`isPinned`, `mutedUntil`/`isMuted`, `ChatRoom.mutedForever`, `member()`, `roleOf()`, `otherUser()`, `lastMessage` | +| `types.RoomType` | `ChatRoomType` (`direct`, `group`) | +| `types.Role` | `ChatRoomRole` (`admin`, `moderator`; `null` = plain member). Roles are per room | +| `types.User` | `ChatUser` (`DateTime?` dates, `fullName`, `initials`). Convert with `toCoreUser()` for `Chat.resolveUser` (`role` goes to `metadata['role']`) | +| `types.Message` and subclasses | `flutter_chat_core`'s sealed `Message` (`Message.text`, `.image`, `.file`, `.audio`, ...). A message carries `authorId`, not an embedded `author` | +| `types.PartialText` / `PartialImage` / `PartialFile` / `PartialCustom` | Build a full `core.Message` with a client id: `core.Message.text(id: const Uuid().v7(), authorId: me, text: text)` | +| Epoch milliseconds (`int`) dates | `DateTime` (UTC); columns are `timestamptz` | +| `SupabaseChatCoreConfig('chats', 'rooms', ...)` (9 positional args) | `SupabaseChatCoreConfig(...)` with **named** parameters, all with defaults, plus `copyWith` | +| `config.realtimeChatTypingUserPrefixChannel` (`'chat-user-typing-'`) | `config.realtimeRoomChannelPrefix` (`'chats:room:'`): one private Broadcast channel per room for messages **and** typing | +| – | New config: `roomMembersTableName`, `usersViewName`, `userAvatarsBucket`, scoping hooks `defaultRoomMetadata`, `roomsQueryFilter`, `usersQueryFilter`, `roomFilter` | +| `SupabaseChatCore.instance` (private constructor) | Still `SupabaseChatCore.instance`; you can also create `SupabaseChatCore(client: ..., config: ...)` and `dispose()` it | +| `SupabaseChatCore.instance.config = ...` / `setConfig()` | `setConfig()` (`config` is now a getter) | +| `roomsUpdates()` + `SupabaseChatCore.updateRoomList(list, newRooms)` | `roomsChanges()` (`ChatRoomUpserted` / `ChatRoomRemoved`) + `SupabaseChatCore.applyRoomChange(list, change)`, or simply `SupabaseChatRoomsController` | +| `rooms({filter, offset, limit})` | Same, with non-nullable `offset`/`limit`. Pinned rooms come first, and a page now holds exactly `limit` rooms (1.x returned `limit + 1`) | +| `room(roomId)`, `getRoom(roomId)` | Same, returning `ChatRoom`. The stream also follows member changes and completes when the room is deleted or you leave it | +| `createRoom(types.User, {metadata})` | `createRoom(ChatUser, {metadata})`, through the `chats.create_room` RPC: an existing direct room is reused (per pair and metadata scope) | +| `createGroupRoom({creatorRole: types.Role.admin, users: List, ...})` | `createGroupRoom({creatorRole: ChatRoomRole.admin, users: List, ...})`, through the same RPC | +| `updateRoom(types.Room)` (could also change members) | `updateRoom(ChatRoom)` changes name, avatar and metadata only. Members: `addMembers()`, `removeMember()`, `leaveRoom()`, `setMemberRole()` | +| `deleteRoom(roomId)` | Same (group rooms: admins only) | +| `users({filter, offset, limit})` | Same plus `excludeLoggedUser`; every word of `filter` must match the first or last name | +| `user(uid:)`, `updateUser(types.User)` | `user(uid:)`, `updateUser(ChatUser)` | +| `loggedUser` (`types.User?`) | `loggedUser` (`ChatUser?`), plus `loggedUserId` and `refreshLoggedUser()` | +| `sendMessage(dynamic partialMessage, roomId)` | `SupabaseChatController.sendMessage(message)` (optimistic), or `SupabaseChatCore.sendMessage(core.Message, roomId)` | +| `updateMessage(types.Message, roomId)` | `updateMessage(core.Message, roomId)`: content and metadata only. Status, reactions and the pinned flag go through `markRoomAsRead()`, `toggleReaction()`, `setMessagePinned()` | +| `deleteMessage(roomId, messageId)` | Same on `SupabaseChatCore` | +| `uploadAsset(types.Room, name, bytes)` | `uploadAsset(ChatRoom, name, bytes)`; `UploadAssetResult` gained `path` | +| `getAssetUrl(path)`, `generateRoomAssetPath(types.Room, name)` | `getAssetUrl(path, {bucket})`, `generateRoomAssetPath(ChatRoom, name)` (`/-`) | +| – | New: `uploadUserAvatar()`, `markRoomAsRead()`, `pinRoom()`, `muteRoom()`, `unmuteRoom()`, `messages(roomId, before:, limit:)`, `message()`, `pinnedMessages()`, `searchMessages()`, `toggleReaction()`, `setMessagePinned()` | +| `fetchUser()`, `processRoomRow()`, `processRoomsRows()` (`util.dart`) | Removed: the `rooms_l` view embeds members, use `ChatRoom.fromJson(row, currentUserId: uid)`. `util.dart` now exports `parseChatDate`, `formatChatDate`, `messageFromRow`, `messageToRow` | +| `SupabaseChatController({pageSize = 10, required types.Room room})` | `SupabaseChatController({required ChatRoom room, pageSize = 20, onError, autoMarkAsRead = true, chatCore})` | +| `controller.messages` (`Stream>`) | The controller **is** a `ChatController`: pass it to `Chat.chatController`. `messages` is the current `List` | +| `controller.loadPreviousMessages()` (page number) | Same name, cursor-based (`hasMoreMessages`, `initialLoad`) | +| `controller.deleteMessage(roomId, messageId)` | `controller.deleteMessage(messageId)` | +| `controller.typingUsers` (`Stream>`) | `Stream>`, plus `currentTypingUsers` | +| `onTyping()` / `endTyping()` over **Presence** | Same methods, over **Broadcast** (throttled), see below | +| – | New on the controller: `retryMessage`, `editTextMessage`, `toggleReaction`, `setPinned`, `pinnedMessages`, `resolveMessage`, `jumpToMessage`, `markAsRead`, `inMemoryController` | +| `UserOnlineStateObserver`, `UserOnlineStatusWidget` | Same, with an optional `chatCore:` | +| Raw `PostgrestException`/`StorageException`, `Future.error('User does not exist')`, silent no-ops without a user | `SupabaseChatCoreException` (`...AuthException`, `...RequestException`, `...StorageException`) with the original error in `cause` | + +### Rooms list + +```dart +// 1.x +SupabaseChatCore.instance.roomsUpdates().listen((rooms) { + setState(() => _rooms = SupabaseChatCore.updateRoomList(_rooms, rooms)); +}); + +// 2.x, by hand +SupabaseChatCore.instance.roomsChanges().listen((change) { + setState(() => _rooms = SupabaseChatCore.applyRoomChange(_rooms, change)); +}); + +// 2.x, with pagination, search and unread total +final rooms = SupabaseChatRoomsController()..loadFirstPage(); +ListenableBuilder(listenable: rooms, builder: (context, _) => ...); +``` + +### Conversation + +```dart +// 1.x +StreamBuilder>( + stream: _chatController.messages, + builder: (context, snapshot) => Chat( + messages: snapshot.data ?? [], + user: SupabaseChatCore.instance.loggedUser!, + onSendPressed: (partial) => + SupabaseChatCore.instance.sendMessage(partial, room.id), + ), +); + +// 2.x +Chat( + currentUserId: SupabaseChatCore.instance.loggedUserId!, + resolveUser: (id) async => room.member(id)?.toCoreUser(), + chatController: _chatController, + onMessageSend: (text) => _chatController.sendMessage( + core.Message.text( + id: const Uuid().v7(), + authorId: SupabaseChatCore.instance.loggedUserId!, + text: text, + ), + ), + builders: core.Builders( + chatAnimatedListBuilder: (context, itemBuilder) => ChatAnimatedList( + itemBuilder: itemBuilder, + onEndReached: _chatController.loadPreviousMessages, + ), + textMessageBuilder: + (context, message, index, {required isSentByMe, groupStatus}) => + FlyerChatTextMessage(message: message, index: index), + ), +); +``` + +Use `const Uuid().v7()` for message ids: the id is stored as-is, so the +controller recognizes the Realtime echo of its optimistic copy, and v7 ids are +time-ordered like the database's own default. A failed send keeps the message +with status `error` (`retryMessage` sends it again) and rethrows. + +### Read receipts and link previews + +- 1.x marked messages `seen` from `onMessageVisibilityChanged`. In 2.x the + controller calls `chats.mark_room_as_read` for you (once the first page is + loaded and on every incoming message) unless you pass + `autoMarkAsRead: false`; it resets the room's `unreadCount` and flips the + other members' messages to `seen`. Don't update `status` with + `updateMessage`: it ignores it. +- Automatic link preview fetching (`onPreviewDataFetched`) has no + counterpart: `linkPreviewData` is stored and read if you fill it yourself. + +### Typing indicator + +1.x tracked typing with Realtime **Presence** on `chat-user-typing-`. +Presence `track()` calls are rate-limited and the channel was dropped while +typing ([#38](https://github.com/insideapp-srl/flutter_supabase_chat_core/issues/38)). +2.x sends `typing` **Broadcast** events on the room's private channel +`chats:room:`: at most one every 2 s while typing, a "stopped" event +after 3 s of inactivity or on send, and receivers expire a user after 5 s +without events. Keep calling `onTyping()` on every keystroke; the throttling is +internal. + +## Database migration + +`example/utils/sql/01_database_schema.sql` detects the 1.x schema +(`chats.rooms.id` is `bigint`) and migrates it; on a fresh database it creates +the 2.x schema, and on a 2.x one it does nothing. + +### What changes + +- `chats.rooms.id`, `chats.messages.id` and `chats.messages."roomId"` become + `uuid` (default `chats.uuid_generate_v7()`, time-ordered). `chats.users.id` + is untouched (it matches `auth.users.id`). The old-to-new room id mapping + is kept in `chats.room_id_migration_map` until the attachments are moved + (step 9). +- Dates become `timestamptz` (`createdAt`, `updatedAt`, `lastSeen`). +- `chats.rooms."userIds"`/`"userRoles"` become rows of + `chats.room_members` (`roomId`, `userId`, `role`, `lastReadAt`, + `pinnedAt`, `mutedUntil`). Every member's `lastReadAt` is set to the + migration time, so nobody gets a flood of unread badges. +- `chats.messages` columns follow `flutter_chat_core` names (`uri` → + `source`, `waveForm` → `waveform`, `previewData` → `linkPreviewData`, + `repliedMessage->>'id'` → `replyToMessageId`), plus `reactions` and + `pinned`. `remoteId`, `isLoading`, `showStatus`, `repliedMessage` are not + carried over. +- `chats.messages` becomes a table partitioned by week on `createdAt` + (`pg_partman`, maintained hourly by `pg_cron`, no retention). Weekly + partitions start 4 weeks back; older history goes to the default partition + `chats.messages_default` (split it later with + `partman.partition_data_proc()` if you want). +- The `rooms."lastMessages"` snapshots are rebuilt from the migrated + messages; the rooms' `updatedAt` (list order) is kept. +- The 1.x tables are renamed to `chats.users_backup_`, + `chats.rooms_backup_`, `chats.messages_backup_` and **kept**. + Their RLS policies are dropped: RLS stays enabled, so they are closed to + `anon`/`authenticated`. +- RLS is stricter: rooms are created only through `chats.create_room`, + nobody can add themselves to a room, messages can only be sent as yourself + and edited by their author, deletions are limited to the author (or a group + admin), group management is admin-only. Private Realtime channels are + authorized by policies on `realtime.messages`. +- `updatedAt` of a room is only bumped by new messages (not by edits, + reactions or read receipts), so reading a chat no longer reorders lists. + +### Steps + +1. **Check the prerequisites.** `pg_partman` and `pg_cron` must be available + (the script creates them), and Realtime must support Broadcast from + Database (Supabase cloud; self-hosted `supabase/realtime` >= v2.33). +2. **Check the roles.** 2.x only accepts `admin` and `moderator` (or `NULL`) + as roles, enforced by a `CHECK` constraint; 1.x allowed any + `types.Role` value. The migration turns any other role (`agent`, `user`, + ...) into a plain member (`NULL`). If this returns rows you want to keep + elevated, map them to `admin`/`moderator` before migrating: + + ```sql + SELECT id, role FROM chats.users WHERE role NOT IN ('admin', 'moderator'); + SELECT id, r.key, r.value FROM chats.rooms, jsonb_each_text("userRoles") r + WHERE r.value NOT IN ('admin', 'moderator'); + ``` + +3. **Back up the database** (`pg_dump`, or a Supabase backup). The script + keeps the old tables, but a dump is the only way back. +4. **Start the maintenance window**: stop the 1.x clients (or make sure they + can't reach the database) until the 2.x app is released. +5. **Run the scripts in order**: `01`, `02`, `03`, `04`, `05`, `99`, with + `example/utils/prepare.sh`/`prepare.ps1` (psql) or the commands printed by + `supabase_prepare.sh`/`.ps1` (`supabase db query --linked`). `01` prints a + `NOTICE` for every backup table it keeps. If you use custom names in + `SupabaseChatCoreConfig`, adapt the scripts first. +6. **Verify the data** against the backups (replace ``): + + ```sql + SELECT (SELECT count(*) FROM chats.users_backup_) AS users_v1, + (SELECT count(*) FROM chats.users) AS users_v2, + (SELECT count(*) FROM chats.rooms_backup_) AS rooms_v1, + (SELECT count(*) FROM chats.rooms) AS rooms_v2, + (SELECT count(*) FROM chats.messages_backup_) AS messages_v1, + (SELECT count(*) FROM chats.messages) AS messages_v2, + (SELECT sum(cardinality("userIds")) FROM chats.rooms_backup_) AS members_v1, + (SELECT count(*) FROM chats.room_members) AS members_v2; + ``` + + A member id repeated in a 1.x `userIds` array becomes a single + `room_members` row, so `members_v2` can be lower than `members_v1`. +7. **Self-hosted Realtime**: if you restored or reset the database while + Realtime was running, restart the Realtime container: its Postgres Changes + poller can stay degraded and silently stop delivering room updates + (`tool/local_db.sh` does the same locally). +8. **Optional: media cleanup.** Deploy the `cleanup-media` Edge Function and + run `06_cleanup_media_schedule.sql` (see the README, "Media cleanup"), so + the files of deleted messages are removed from Storage. +9. **Move the 1.x attachments** to the new room folders (see "Attachments + uploaded by 1.x" below). It can also run after the window: until then + the old URLs keep working. +10. **Release the 2.x app** and end the maintenance window. +11. **Drop the backups** when you're satisfied, messages first (their old + foreign keys point at the other backups): + + ```sql + DROP TABLE chats.messages_backup_; + DROP TABLE chats.rooms_backup_; + DROP TABLE chats.users_backup_; + ``` + +### Attachments uploaded by 1.x + +Files uploaded by 1.x live under `chats_assets//...`, +and right after the migration the messages' `source` URLs still point there. +They keep working: the storage policies resolve the old folders through +`chats.room_id_migration_map` (`chats.is_chat_member_path`). + +`example/utils/migrate_storage_uuids.sh` (or `.ps1` on Windows) moves them to +`chats_assets//...`, so every attachment follows the 2.x layout +and the map can go: + +```bash +export SUPABASE_SERVICE_ROLE_KEY=... # service role (or secret) key +./migrate_storage_uuids.sh -h -p -d postgres -U postgres \ + -a https://.supabase.co -n # dry run: what would move +./migrate_storage_uuids.sh -h -p -d postgres -U postgres \ + -a https://.supabase.co +``` + +```powershell +$env:SUPABASE_SERVICE_ROLE_KEY = '...' +.\migrate_storage_uuids.ps1 -hostname -port -database postgres -user postgres ` + -apiUrl https://.supabase.co -dryRun +``` + +For a self-hosted instance pass its API (Kong) URL. For each room the script +copies the objects (Storage API, server side), rewrites the room's URLs in +one transaction (`messages.source`, `rooms."imageUrl"`; the `lastMessages` +snapshot follows by trigger) and only then deletes the old objects. A +failure stops it without losing anything: run it again and it resumes. The +apps receive the new URLs over Realtime; a file opened from an old URL in +the meantime fails until the message is reloaded. When no room is left it +drops `chats.room_id_migration_map` (asking first, `-y`/`-yes` to skip the +question). Attachments of deleted rooms have nothing pointing at them: the +script moves them as well, delete them yourself if you don't need them. + +### Known limitations of the migration + +- Message ids are regenerated; `replyToMessageId` is remapped to the new ids. + A reply whose original message no longer exists gets `NULL`. +- 1.x accepted any role text: only `admin` and `moderator` survive (on + `chats.users` and on the room members), any other value (`agent`, `user`, + ...) becomes a plain member (`NULL`). +- 1.x `channel` rooms have no 2.x counterpart: `ChatRoom.fromJson` reads an + unknown type as `group`. +- Only the exact 1.x shape and fresh databases are recognized. A database in + some other, partially migrated shape must be reconciled by hand first. + +## Checklist + +1. Update the dependencies (`flutter_chat_core`, `flutter_chat_ui` 2.x, + `flyer_chat_*_message`, `uuid`). +2. Replace `types.Room`/`types.User`/`types.Role` with `ChatRoom`/`ChatUser`/ + `ChatRoomRole`, and `Partial*` messages with `core.Message.*` built with + `const Uuid().v7()` ids. +3. Switch dates from epoch milliseconds to `DateTime`. +4. Replace `roomsUpdates()`/`updateRoomList` with + `SupabaseChatRoomsController` or `roomsChanges()`/`applyRoomChange`. +5. Pass `SupabaseChatController` to `Chat.chatController`, wire + `resolveUser`, `onMessageSend` and `builders`; drop your "seen" tracking. +6. Move member changes from `updateRoom` to `addMembers`/`removeMember`/ + `leaveRoom`/`setMemberRole`. +7. Rename `realtimeChatTypingUserPrefixChannel` to `realtimeRoomChannelPrefix` + and switch `SupabaseChatCoreConfig` to named parameters, if you customized + it. +8. Catch `SupabaseChatCoreException` instead of Supabase exceptions. +9. Migrate the database during a maintenance window, verify, then release. +10. Move the 1.x attachments with `migrate_storage_uuids.sh`/`.ps1`. diff --git a/README.md b/README.md index 1d0233a..e2f2693 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,14 @@

- - Pub + + Pub version - Pub + Pub points - Pub - - - Build Status + Pub likes CodeFactor @@ -19,19 +16,19 @@

- Pub + GitHub repository - Build Status + Open issues - CodeFactor + License

- Flyer Chat logo + Flyer Chat logo

@@ -40,7 +37,11 @@

- This project is an implementation of the flutter_chat_types, flutter_chat_ui packages based on a backend created with Supabase. + A Supabase backend for flutter_chat_core and flutter_chat_ui 2.x: rooms, members, messages, attachments, read receipts, reactions, typing indicators and online status, over Postgres + RLS and Supabase Realtime. +

+ +

+ ℹ️ Coming from 1.x (flutter_chat_types)? Read MIGRATION.md: 2.0 changes both the Dart API and the database schema. ℹ️


@@ -58,9 +59,11 @@

- - Chat Image - + Demo: a message arriving in real time with the typing indicator, a reaction, a reply and a voice message +

+ +

+ The example app on Supabase cloud: real-time messages and typing indicator, reactions, replies and voice messages.


@@ -77,173 +80,489 @@ Flyer Chat is a platform for creating in-app chat experiences using Flutter. Thi * **Chat UI agnostic**. You can choose the chat UI you prefer. But if you don't have one, we provide our own free and open-source [Flutter Chat UI](https://pub.dev/packages/flutter_chat_ui), which can be used to create a working chat in minutes. -* **Easy to use**. Returns streams of data for messages, rooms and users. [Supabase Security Rules](https://supabase.com/docs/guides/database/postgres/row-level-security) control access to the data. Check our [documentation](https://flutter-supabase-chat-core.insideapp.it/guides/supabase-security) for the info. +* **Secure by default**. Every table, storage bucket and Realtime channel is protected by [Postgres Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security). Check our [documentation](https://flutter-supabase-chat-core.insideapp.it/guides/supabase-security) for the details. + +## Features + +- **Direct and group rooms**, created atomically by a database function. Direct rooms are de-duplicated per pair of users (and per metadata scope, see [Scoping](#scoping-workspaces-organizations)). +- **Group management**: per-room roles (`admin`, `moderator`), add/remove members, change roles, rename, leave, delete. A room left without members is deleted automatically. +- **Messages** of every `flutter_chat_core` type (text, image, file, audio with waveform, video, custom...), with replies, edits, reactions, pinned messages and search. +- **Optimistic sending**: messages appear right away, are reconciled with the stored row and can be retried when they fail. +- **Read receipts and unread counters**: `markRoomAsRead` flips other members' messages to `seen` and resets the room's `unreadCount`. +- **Per-user preferences**: pin a room on top of your list, mute it for a while or for ever. +- **Realtime everywhere**: message changes over private Broadcast channels, a throttled typing indicator, the rooms list kept in sync, online status over Presence. +- **Cursor-based pagination** for messages and offset pagination for rooms and users. +- **Attachments and avatars** in private Storage buckets, plus an Edge Function that deletes the files of deleted messages. +- **Scoping hooks** to keep the chats of different workspaces/organizations apart without forking the package. +- **Typed errors**: every failure surfaces as a `SupabaseChatCoreException`. +- **Scales with history**: `chats.messages` is partitioned by week with `pg_partman`, ids are time-ordered UUIDv7. + +## Requirements + +- `Dart >=3.8.0` and `Flutter >=3.32.0`. +- A [Supabase](https://supabase.com) project (cloud or self-hosted) with: + - the `pg_partman` and `pg_cron` extensions available (the SQL scripts create them); + - Realtime with **Broadcast from Database** support (Supabase cloud, or self-hosted `supabase/realtime` >= v2.33): message changes are sent by a database trigger; + - the `chats` schema exposed through the Data API (see below). + +## Installation + +```yaml +dependencies: + flutter_supabase_chat_core: ^2.0.0-rc.1 + supabase_flutter: ^2.17.2 + uuid: ^4.5.1 + + # Optional, for the ready-made UI: + flutter_chat_ui: ^2.11.1 + flyer_chat_text_message: ^2.6.0 + flyer_chat_image_message: ^2.3.0 + flyer_chat_file_message: ^2.4.0 +``` + +`flutter_chat_ui` 2.x ships no renderer for images or files (only a minimal text fallback): add one `flyer_chat_*_message` package per message type you use. The example app pins `flutter_chat_ui: '>=2.11.1 <2.12.0'`: 2.12.0 moved the `Composer` to the experimental `material_ui` package, which crashes inside a standard `Scaffold` ("No Material widget found"). -## Getting Started +## Supabase setup -The example project that you find in the package repository allows you to have a cross-platform chat app in just a few minutes. +The database is set up by the SQL scripts in [`example/utils/sql`](example/utils/sql), applied in this order: -### Requirements +| Script | Content | +|--------|---------| +| `01_database_schema.sql` | Schema `chats`, tables, weekly partitions, indexes, `pg_cron` maintenance job. **Also migrates a 1.x database** (see [MIGRATION.md](MIGRATION.md)). | +| `02_database_trigger.sql` | Profile creation on sign-up, last message snapshot, `sent` status, media cleanup queue, empty room deletion, Realtime broadcast of message changes. | +| `03_database_policy.sql` | Access helpers, RPCs and RLS policies (tables and `realtime.messages`). | +| `04_storage.sql` | Buckets `chats_assets` and `chats_user_avatar` and their policies. | +| `05_database_view.sql` | Views `chats.rooms_l` and `chats.messages_l`. | +| `99_database_schema_permission.sql` | Final grants. | +| `06_cleanup_media_schedule.sql` | **Optional**: schedules the `cleanup-media` Edge Function (see [Media cleanup](#media-cleanup)). | -`Dart >=2.19.0` and `Flutter >=3.0.0`, [Supabase](https://supabase.com) project. +Every script is idempotent: re-running them is safe. -### Create a Supabase project +### Option A: create a project with the Supabase CLI -1. Install Supabase CLI: [Official documentation](https://supabase.com/docs/guides/cli/getting-started) -2. Open your bash -3. Login with Supabase: +1. Install the [Supabase CLI](https://supabase.com/docs/guides/cli/getting-started) and log in with `supabase login`. +2. Linux/macOS only: install [`jq`](https://jqlang.org/download/). +3. Run the script from `example/utils`: ```bash -supabase login +cd example/utils +./supabase_prepare.sh # Linux/macOS ``` -4. Create new project (For example `demo-chat`): - -```bash -supabase projects create demo-chat +```powershell +cd example\utils +.\supabase_prepare.ps1 # Windows ``` -5. Select your organization -6. Select an region -7. Insert a secure password for new Postgres database (Save this in a secure location) -8. Obtain your `REFERENCE ID` (After command select your project, for example `demo-chat`): +It lets you create a new project (organization and region picked from a list, database password generated for you) or pick an existing one (it then asks for its database password), fetches the API keys and writes: -```bash -supabase projects list -``` +- `example/lib/supabase_options_for_test.dart` and `example/lib/main_test.dart` (gitignored), so you can run the example with `flutter run -t lib/main_test.dart`; +- `example/utils/project-secrets.local.json` with the project ref, URL, keys and database credentials. **It contains secrets: it is gitignored, never commit it.** + +Then it offers to apply scripts `01`…`05` and `99` through `supabase link` + `supabase db query --linked` (no direct `psql` connection needed). + +
+Manual project creation + +1. `supabase projects create demo-chat` (choose your organization, region, and a secure database password — save it, it won't be shown again) +2. `supabase projects list` to get the project's `REFERENCE ID` +3. `supabase projects api-keys --project-ref ` to get the `anon`/publishable key + +
-9. Obtain your `anon` key (After command select your project, for example `demo-chat`): +### Option B: apply the scripts with `psql` + +`prepare.sh`/`prepare.ps1` apply scripts `01`…`05` and `99` with [`psql`](https://www.postgresql.org/download/). They ask for the database password once (or read `PGPASSWORD`) and stop at the first failing script. ```bash -supabase projects api-keys +cd example/utils +./prepare.sh -h -p -d -U ``` -10. Edit `example project` file `example/lib/supabase_options.dart`, insert your project `{{your_project_reference_id}}` and `{{supabase_anon_key}}` -#### Prepare Supabase project +```powershell +cd example\utils +.\prepare.ps1 -hostname -port -database -user +``` -Inside the example project (`example/utils`) there is a script, running the latter will automatically configure the Supabase project, creating tables, security rules, buckets and everything that is necessary for the example project to function. +The connection parameters are in the dashboard of your project: -In order to run the script you need to be aware of the following information about your Supabase project: +![Supabase dashboard database info](doc/static/img/supabase-project-credential.png "Database info") -- `host` : Project host -- `port` : Database port -- `database` : Database name -- `user` : Database user -- `password` : Database password +### Expose the `chats` schema -This information, except the password which is provided only during the creation of the database (if necessary, you can use the password reset function of your database to obtain it), can be found very easily from the Dashboard of your Supabase project: +Add `chats` to the exposed schemas (Dashboard → Project Settings → Data API): -![Supabase dashboard database info](doc/static/img/supabase-project-credential.png "Database info") +![Supabase dashboard schema exposure setting](doc/static/img/supabase-schema-exposure-setting.png "Schema exposure setting") -#### Running prepare script +Optional, **for testing only**: disable email confirmation to sign up in one click. -Below are the commands for running the scripts (During execution you will be asked for the password for your database user): +![Supabase dashboard disable email verification](doc/static/img/supabase-disable-confirm-email.png "Disable email verification") -> Required `psql` installed -> [Official documentation](https://www.postgresql.org/download/) +## Quick start -#### Linux +Initialize Supabase, then wrap your home page in `UserOnlineStateObserver` to publish the user's online status: -```bash -cd ./example/utils/ -./prepare.sh -h "your-postgres-host" -p your-postgres-port -d "your-postgres-database-name" -U "your-postgres-user" +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_supabase_chat_core/flutter_supabase_chat_core.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + await Supabase.initialize( + url: 'https://.supabase.co', + publishableKey: '', + ); + runApp( + const MaterialApp(home: UserOnlineStateObserver(child: RoomsPage())), + ); +} ``` -#### Windows +`SupabaseChatCore.instance` is bound to `Supabase.instance.client`, so `Supabase.initialize` must complete before it is used. Users sign up with Supabase Auth: the `chats.handle_new_user` trigger creates their chat profile, reading `first_name`/`last_name`/`avatar_url` from the sign-up metadata when present. -```powershell -cd .\example\utils\ -.\prepare.ps1 -hostname "your-postgres-host" -port your-postgres-port -database "your-postgres-database-name" -user "your-postgres-user" +### Rooms list + +`SupabaseChatRoomsController` is a `ChangeNotifier` holding a paginated, searchable list of the user's rooms, kept in sync over Realtime (pinned rooms first, then most recent activity): + +```dart +class RoomsPage extends StatefulWidget { + const RoomsPage({super.key}); + + @override + State createState() => _RoomsPageState(); +} + +class _RoomsPageState extends State { + final _rooms = SupabaseChatRoomsController()..loadFirstPage(); + + @override + void dispose() { + _rooms.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text('Chats')), + body: ListenableBuilder( + listenable: _rooms, + builder: (context, _) => ListView.builder( + itemCount: _rooms.rooms.length, + itemBuilder: (context, index) { + final room = _rooms.rooms[index]; + return ListTile( + title: Text(room.name ?? 'Chat'), + trailing: room.unreadCount > 0 + ? Badge(label: Text('${room.unreadCount}')) + : null, + onTap: () { + _rooms.markLocallyAsRead(room.id); + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => RoomPage(room: room)), + ); + }, + ); + }, + ), + ), + ); +} ``` -after running the database preparation script. you need to change the database schema exposure setting by adding the `chats` schema (from the supabase dashboard): +Call `loadNextPage()` when the list is scrolled near its end (e.g. from a `ScrollController` listener). It also exposes `search(filter)`, `refresh()`, `hasMore`, `isLoading`, `error`, `totalUnreadCount` (muted rooms excluded) and `pinRoom`/`muteRoom`/`unmuteRoom`, which update the list right away. -![Supabase dashboard schema exposure setting](doc/static/img/supabase-schema-exposure-setting.png "Schema exposure setting") +Start conversations with `SupabaseChatCore.instance.createRoom(otherUser)` (direct) or `createGroupRoom(name: ..., users: [...])`; pick users with `users(filter: ..., excludeLoggedUser: true)`. -Optional (**Only for test**): Disable email verification and save the configuration (To speed up testing and allow user registration in just one click, it is advisable to disable mailbox verification): +### Conversation -![Supabase dashboard disable email verification](doc/static/img/supabase-disable-confirm-email.png "Disable email verification") +`SupabaseChatController` implements `flutter_chat_core`'s `ChatController`: create one per open room and pass it to `Chat`. + +```dart +import 'package:flutter_chat_core/flutter_chat_core.dart' as core; +import 'package:flutter_chat_ui/flutter_chat_ui.dart'; +import 'package:flyer_chat_text_message/flyer_chat_text_message.dart'; +import 'package:uuid/uuid.dart'; -Read our [documentation](https://flutter-supabase-chat-core.insideapp.it) or see the [example](https://github.com/insideapp-srl/flutter_supabase_chat_core/tree/main/example) project. To run the example project you need to have your own [Supabase](https://supabase.com/dashboard/projects) project and then follow [Add Supabase to your Flutter app](https://supabase.com/docs/reference/dart/initializing), override `example/lib/supabase_options.dart`, don't commit it though 😉 +class RoomPage extends StatefulWidget { + const RoomPage({super.key, required this.room}); -After all of this is done you will need to register a couple of users and the example app will automatically suggest email and password on the register screen, default password is `Qawsed1-`. To set up [Supabase Security Rules](https://supabase.com/docs/guides/database/postgres/row-level-security) so users can see only the data they should see, continue with our [documentation](https://flutter-supabase-chat-core.insideapp.it/). + final ChatRoom room; -## Package Widgets + @override + State createState() => _RoomPageState(); +} -This package implements some purely logical widgets (Without implementation of a specific UI). These wedgets make it easy to implement some chat-related logic without any effort. +class _RoomPageState extends State { + late final _controller = SupabaseChatController( + room: widget.room, + // Failures of background work (initial load, Realtime, mark as read). + onError: (error) => ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(error.message))), + ); + + String get _me => SupabaseChatCore.instance.loggedUserId!; + + Future _resolveUser(String id) async => + (widget.room.member(id) ?? + await SupabaseChatCore.instance.user(uid: id)) + ?.toCoreUser(); + + Future _send(String text) async { + try { + // Shown right away with status `sending`, reconciled with the stored + // row, marked `error` (see retryMessage) if the request fails. + await _controller.sendMessage( + core.Message.text(id: const Uuid().v7(), authorId: _me, text: text), + ); + } on SupabaseChatCoreException catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(e.message))); + } + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: Text(widget.room.name ?? 'Chat')), + body: Chat( + currentUserId: _me, + resolveUser: _resolveUser, + chatController: _controller, + onMessageSend: _send, + builders: core.Builders( + chatAnimatedListBuilder: (context, itemBuilder) => ChatAnimatedList( + itemBuilder: itemBuilder, + onEndReached: _controller.loadPreviousMessages, + ), + textMessageBuilder: + (context, message, index, {required isSentByMe, groupStatus}) => + FlyerChatTextMessage(message: message, index: index), + ), + ), + ); +} +``` + +Generate message ids with `const Uuid().v7()`: the id is stored as-is (so the Realtime echo updates the optimistic copy instead of duplicating it), and v7 ids are time-ordered like the database's own `chats.uuid_generate_v7()`. + +What else the controller does: + +| Member | Purpose | +|--------|---------| +| `pageSize` (20), `initialLoad`, `hasMoreMessages`, `loadPreviousMessages()` | Cursor-based pagination (stable while new messages arrive). | +| `autoMarkAsRead` (true), `markAsRead()` | Marks the room as read after the first page and on every incoming message. Pass `false` to decide yourself (e.g. only in foreground). | +| `sendMessage`, `retryMessage` | Optimistic send; failed messages keep status `error`. | +| `editTextMessage`, `deleteMessage` | Edit your text messages; delete yours (group admins: any). | +| `toggleReaction` | Add/remove your reaction (atomic RPC). | +| `pinnedMessages` (`ValueNotifier`), `setPinned` | Pinned messages kept up to date in real time. | +| `resolveMessage`, `jumpToMessage` | Resolve a quoted message (fetched if not loaded) and scroll to any message, loading older pages as needed. | +| `onTyping()`, `endTyping()`, `typingUsers`, `currentTypingUsers` | Typing indicator (see [Realtime](#realtime-and-typing-indicator)). | +| `inMemoryController` | The underlying `InMemoryChatController`, for advanced use. | + +Call `onTyping()` on every keystroke of the composer, e.g. with your own `TextEditingController` passed to `Composer` through `Builders.composerBuilder`; `sendMessage` stops the indicator for you. + +### `SupabaseChatCore` at a glance + +| Area | Methods | +|------|---------| +| Rooms | `rooms()`, `roomsChanges()` + `SupabaseChatCore.applyRoomChange()`, `room(id)` (stream), `getRoom()`, `createRoom()`, `createGroupRoom()`, `updateRoom()`, `deleteRoom()`, `markRoomAsRead()`, `pinRoom()`, `muteRoom()`, `unmuteRoom()` | +| Members | `addMembers()`, `removeMember()`, `leaveRoom()`, `setMemberRole()` | +| Users | `users(filter:, excludeLoggedUser:)`, `user()`, `updateUser()`, `uploadUserAvatar()`, `loggedUser`, `loggedUserId`, `refreshLoggedUser()` | +| Messages | `messages(roomId, before:, limit:)`, `message()`, `pinnedMessages()`, `searchMessages()`, `sendMessage()`, `updateMessage()`, `toggleReaction()`, `setMessagePinned()`, `deleteMessage()` | +| Storage | `uploadAsset()`, `getAssetUrl()`, `httpSupabaseHeaders` (to load authenticated URLs) | +| Presence | `setPresenceStatus()`, `userOnlineStatus()` | + +`SupabaseChatCore.instance` is the singleton bound to `Supabase.instance.client`. Create your own instance with `SupabaseChatCore(client: ..., config: ...)` to use another client (e.g. several users in one process, as the integration tests do), and `dispose()` it when done. Controllers and widgets accept a `chatCore:` parameter for that. + +### Error handling + +Every public method throws a `SupabaseChatCoreException` subtype, with the original error in `cause`: + +- `SupabaseChatCoreAuthException`: no logged-in user; +- `SupabaseChatCoreRequestException`: the request failed (RLS, constraint, connectivity, Realtime subscription...); +- `SupabaseChatCoreStorageException`: an upload failed. + +## Scoping (workspaces, organizations) + +`SupabaseChatCoreConfig` takes named parameters, all with defaults matching the SQL scripts (`schema: 'chats'`, `realtimeRoomChannelPrefix: 'chats:room:'`, `chatAssetsBucket: 'chats_assets'`, `userAvatarsBucket: 'chats_user_avatar'`, ...). Besides names, it offers four hooks to keep the chats of different scopes apart: + +```dart +SupabaseChatCore.instance.setConfig( + const SupabaseChatCoreConfig().copyWith( + defaultRoomMetadata: () => {'workspaceId': currentWorkspaceId}, + roomsQueryFilter: (q) => q.eq('metadata->>workspaceId', currentWorkspaceId), + roomFilter: (room) => room.metadata?['workspaceId'] == currentWorkspaceId, + usersQueryFilter: (q) => q.eq('metadata->>workspaceId', currentWorkspaceId), + ), +); +``` + +- `defaultRoomMetadata` is merged into every new room; direct rooms are de-duplicated per pair **and** per these metadata entries, so each scope gets its own direct room. +- `roomsQueryFilter` / `usersQueryFilter` narrow every rooms / users query. +- `roomFilter` drops rooms received over Realtime that don't belong to the scope (keep it consistent with `roomsQueryFilter`). +- `usersViewName` lets `users()`/`user()` read from a view (e.g. only users sharing a workspace with the caller) instead of `chats.users`. + +Hooks run on every call, so they can read state that changes at runtime. **They only narrow what the client asks for: if the scope is a security boundary, enforce the same rule with RLS.** If you rename tables, views, buckets or the channel prefix, update the SQL scripts accordingly. + +## Realtime and typing indicator + +| What | Transport | +|------|-----------| +| Message inserts/updates/deletes | Private **Broadcast** channel `chats:room:`, sent by the `chats.broadcast_message_change` trigger (`realtime.broadcast_changes`). | +| Typing indicator | Same channel, Broadcast events sent by clients. | +| Rooms list, single room | **Postgres Changes** on `chats.rooms` and `chats.room_members`. | +| Online status | **Presence** channel `online-user-`. | + +- Postgres Changes can't be used for messages: `chats.messages` is partitioned and Postgres Changes reports rows under their physical partition, so a subscription on the parent table never matches. Broadcast also authorizes once at join time instead of checking RLS per change per subscriber. +- Room channels are private: RLS policies on `realtime.messages` only let room members join (receive) and send. One channel per room is shared by every controller open on it; a channel refused as unauthorized is released and not retried. +- The typing indicator moved from Presence to Broadcast ([#38](https://github.com/insideapp-srl/flutter_supabase_chat_core/issues/38)): Presence `track()` calls are rate-limited and the channel was dropped while typing. Each client sends `typing: true` at most every **2 s**, `typing: false` after **3 s** of inactivity (or on send), and receivers drop a user without a fresh event after **5 s**, so a lost "stopped" event can't leave a stale indicator. Trade-off: Broadcast keeps no state, so someone opening the room while another user is already typing sees the indicator from that user's next event (at most 2 s later). +- Online status: every `userOnlineStatus(uid)` listener of the same user shares one Presence channel, closed when the last listener cancels; the stream emits the current status first. + +## Package widgets ### UserOnlineStateObserver -This widget should be placed in the widget tree as soon as possible, this is because this component is responsible for checking and updating the status of the user who logged into the app. When the app is in the foreground other users are notified of the online status, vice versa when the app is closed. +Publishes the logged-in user's online status following the app lifecycle: online while in the foreground (on the web: while the browser window is focused), offline otherwise; going offline also stores `lastSeen`. It sets the user online as soon as it is mounted. Place it once, high in the widget tree. ```dart -@override -Widget build(BuildContext context) => - MaterialApp( - ... - home: const UserOnlineStateObserver( - child: RoomsPage(), - ), - ); +MaterialApp( + home: const UserOnlineStateObserver(child: RoomsPage()), +); ``` ### UserOnlineStatusWidget -This server widget to get the status of a specific user. It can be used, for example, to animate a user's avatar with an LED to indicate their online/offline status. +Rebuilds with the online/offline status of a user, e.g. to show a dot on an avatar: ```dart -@override -Widget build(BuildContext context) { - return UserOnlineStatusWidget( - uid: user.id, - builder: (status) { - //Use status == UserOnlineStatus.online to animate your avatar - }, - ); -} +UserOnlineStatusWidget( + uid: user.id, + builder: (status) => Icon( + Icons.circle, + size: 10, + color: status == UserOnlineStatus.online ? Colors.green : Colors.grey, + ), +); ``` +Both widgets take an optional `chatCore:` (defaults to `SupabaseChatCore.instance`). + ## RLS (Row level security) -The preparation script automatically configures the security rules on the database tables and storage buckets, below is a summary of the rules that are applied: +`03_database_policy.sql` and `04_storage.sql` set up the following rules. "Admin" means a member with role `admin` in a group room; in a direct room both members have the same rights. ### Tables -#### Table `chats.users` +| Table | INSERT | SELECT | UPDATE | DELETE | +|-------|--------|--------|--------|--------| +| `chats.users` | Nobody (created by the sign-up trigger) | Authenticated users | The user himself | Nobody (deleted with `auth.users`) | +| `chats.rooms` | Nobody (only through `chats.create_room`) | Members | Admins | Admins | +| `chats.room_members` | Group admins, group rooms only (no self-join) | Members of the same room | Admins (roles) | Yourself (leave) or admins (remove) | +| `chats.messages` | Members, as themselves only (`authorId = auth.uid()`) | Members | The author | The author, or a group admin | +| `realtime.messages` (topics `chats:room:*`) | Members (Broadcast only) | Members (join the channel) | – | – | -- `INSERT` : Nobody, this table is populate by trigger on auth.users. -- `SELECT` : All users authenticated. -- `UPDATE` : Only the user himself. -- `DELETE` : Nobody. +UPDATE policies state their rule once, in `USING`: without `WITH CHECK`, Postgres applies it to the new row too, so a row can't be moved out of the rule. -#### Table `chats.rooms` +Operations touching rows of other users go through `SECURITY DEFINER` RPCs that check membership themselves: -- `INSERT` : All users authenticated. -- `SELECT` : All users who are members of the chat room. -- `UPDATE` : All users who are members of the chat room. -- `DELETE` : All users who are members of the chat room. +| RPC | Who | +|-----|-----| +| `chats.create_room(p_type, p_user_ids, p_name, p_image_url, p_metadata, p_creator_role)` | Any authenticated user (the caller is always a member) | +| `chats.mark_room_as_read(p_room_id)` | Members | +| `chats.toggle_message_reaction(p_message_id, p_reaction)` | Members | +| `chats.pin_room(p_room_id, p_pinned)`, `chats.mute_room(p_room_id, p_until)` | Members (own preference) | +| `chats.set_message_pinned(p_message_id, p_pinned)` | Admins (either member of a direct room) | -#### Table `chats.messages` +### Storage buckets -- `INSERT` : All users who are members of the chat room. -- `SELECT` : All users who are members of the chat room. -- `UPDATE` : All users who are members of the chat room. -- `DELETE` : All users who are members of the chat room. +Both buckets are private: files are served through authenticated URLs (`getAssetUrl`, loaded with `httpSupabaseHeaders`). -### Storage buckets +| Bucket | INSERT | SELECT | UPDATE | DELETE | +|--------|--------|--------|--------|--------| +| `chats_assets` (`/`) | Room members | Room members | Room members | Room members | +| `chats_user_avatar` (`/`) | The user himself | Authenticated users | The user himself | The user himself | + +Attachments uploaded by 1.x live under the old numeric room id folder: they stay readable through `chats.room_id_migration_map` until `example/utils/migrate_storage_uuids.sh`/`.ps1` moves them to the room uuid folder (see [MIGRATION.md](MIGRATION.md)). + +## Media cleanup + +Deleting a message deletes its row immediately. When it referenced a Storage file, the `chats.queue_media_for_deletion` trigger records the object in `chats.media_pending_deletion` (closed to clients), because SQL alone can't delete objects from the storage backend. The [`cleanup-media`](supabase/functions/cleanup-media/index.ts) Edge Function drains that queue (500 objects per call) through the Storage API. -#### Bucket `chats_assets` +1. Deploy it: `supabase functions deploy cleanup-media`. +2. Store the project URL and a service role key in Vault, once: -- `INSERT` : All users who are members of the chat room. -- `SELECT` : All users who are members of the chat room. -- `UPDATE` : All users who are members of the chat room. -- `DELETE` : All users who are members of the chat room. +```sql +select vault.create_secret('https://.supabase.co', 'chats_project_url'); +select vault.create_secret('', 'chats_service_role_key'); +``` + +3. Run `example/utils/sql/06_cleanup_media_schedule.sql`: it schedules the call every 15 minutes with `pg_cron` + `pg_net`, only when the queue isn't empty. + +Without it nothing breaks: deleted messages' files simply stay in the bucket. + +## Testing and development + +| Suite | Command | +|-------|---------| +| Unit tests (models, row mapping) | `flutter test test/unit` | +| Integration tests against a local Supabase stack | `supabase start` once, then `tool/test_integration.sh` | +| Database behaviour tests (pgTAP) | `tool/local_db.sh && supabase test db` | + +- `tool/local_db.sh` resets the local database, applies the SQL scripts exactly like end users do and restarts the Realtime container (after a reset its Postgres Changes poller can stay degraded). `--no-reset` applies them on top of the current database, to check they are idempotent. +- `tool/test_integration.sh` runs `tool/local_db.sh`, reads the local URL and keys from `supabase status` and runs `flutter test test/integration --tags integration`. Pass `--no-reset` to keep the database. +- `tool/v1_fixture.sh` rebuilds the local database in the 1.x shape (scripts from the `main` branch, `V1_REF` to change it) with sample data; follow it with `tool/local_db.sh --no-reset` to exercise the migration. +- `tool/seed_demo.sh` seeds demo users and conversations (incl. a voice message): sign in as `alice@demo.dev` / `Demo-password-1` (also `bob@`, `carol@`, `dave@`, `erin@demo.dev`). + +## Example app + +The [example](example) is a Telegram-like chat built on this package and `flutter_chat_ui`: see [example/README.md](example/README.md) to run it. + +| Feature | Web | Android | iOS | Windows | macOS | Linux | +|----------------------------------|:---:|:-------:|:---:|:-------:|:-----:|:-----:| +| Sign up / sign in | ✅ | ✅ | 🟡 | ✅ | 🟡 | 🟡 | +| Rooms list (pagination, search) | ✅ | ✅ | 🟡 | ✅ | 🟡 | 🟡 | +| Pin / mute chats, unread badges | ✅ | ✅ | 🟡 | ✅ | 🟡 | 🟡 | +| Create direct room | ✅ | ✅ | 🟡 | ✅ | 🟡 | 🟡 | +| Create group room | ✅ | ✅ | 🟡 | ✅ | 🟡 | 🟡 | +| Group info and member management | ✅ | ✅ | 🟡 | ✅ | 🟡 | 🟡 | +| Search users | ✅ | ✅ | 🟡 | ✅ | 🟡 | 🟡 | +| Chat screen, messages pagination | ✅ | ✅ | 🟡 | ✅ | 🟡 | 🟡 | +| Replies (swipe), edit, delete | ✅ | ✅ | 🟡 | ✅ | 🟡 | 🟡 | +| Reactions, pinned messages | ✅ | ✅ | 🟡 | ✅ | 🟡 | 🟡 | +| Read receipts | ✅ | ✅ | 🟡 | ✅ | 🟡 | 🟡 | +| Search messages | ✅ | ✅ | 🟡 | ✅ | 🟡 | 🟡 | +| Upload / preview image | ✅ | ✅ | 🟡 | ✅ | 🟡 | 🟡 | +| Upload / download file | ✅ | ✅ | 🟡 | ✅ | 🟡 | 🟡 | +| Voice messages with waveform | ✅ | ✅ | 🟡 | ✅ | 🟡 | 🟡 | +| Typing indicator | ✅ | ✅ | 🟡 | ✅ | 🟡 | 🟡 | +| Users online status | ✅ | ✅ | 🟡 | ✅ | 🟡 | 🟡 | +| Profile and avatar | ✅ | ✅ | 🟡 | ✅ | 🟡 | 🟡 | +| Light / dark theme | ✅ | ✅ | 🟡 | ✅ | 🟡 | 🟡 | +| Two-pane layout on wide screens | ✅ | 🟡 | 🟡 | ✅ | 🟡 | 🟡 | +| Channel rooms | 🚧 | 🚧 | 🚧 | 🚧 | 🚧 | 🚧 | + +- ✅ Tested +- 🟡 Supported but not tested +- 🚧 Planned -#### Bucket `chats_user_avatar` +## Roadmap -- `INSERT` : Only the user himself. -- `SELECT` : All users authenticated. -- `UPDATE` : Only the user himself. -- `DELETE` : Only the user himself. +1. ~~Chat room groups~~ ✅ Done +2. ~~Sending audio messages~~ ✅ Done +3. ~~Error handling~~ ✅ Done +4. ~~Search messages~~ ✅ Done +5. Chat room channels (broadcast-only rooms) +6. Push notifications example (database trigger + Edge Function) +7. Link preview fetching +8. Improve documentation ## Contributing @@ -257,43 +576,19 @@ Flyer Chat has adopted the [Contributor Covenant](https://www.contributor-covena Licensed under the [Apache License, Version 2.0](LICENSE) -## Example project progress - -Below are the features implemented for each platform: - -| Feature | Web | Android | iOS | Windows | macOS | Linux | -|--------------------------|:---:|:-------:|:---:|:-------:|:-----:|:-----:| -| Signup | ✅ | ✅ | 🟡 | ✅ | 🟡 | 🟡 | -| SignIn | ✅ | ✅ | 🟡 | ✅ | 🟡 | 🟡 | -| Rooms list screen | ✅ | ✅ | 🟡 | ✅ | 🟡 | 🟡 | -| Create direct room | ✅ | ✅ | 🟡 | ✅ | 🟡 | 🟡 | -| Create group room | 🚧 | 🚧 | 🚧 | 🚧 | 🚧 | 🚧 | -| Create channel room | 🚧 | 🚧 | 🚧 | 🚧 | 🚧 | 🚧 | -| Chat screen | ✅ | ✅ | 🟡 | ✅ | 🟡 | 🟡 | -| Search room | ✅ | ✅ | 🟡 | ✅ | 🟡 | 🟡 | -| Search message | 🚧 | 🚧 | 🚧 | 🚧 | 🚧 | 🚧 | -| Search user | ✅ | ✅ | 🟡 | ✅ | 🟡 | 🟡 | -| Upload image | ✅ | ✅ | 🟡 | ✅ | 🟡 | 🟡 | -| Preview image message | ✅ | ✅ | 🟡 | ✅ | 🟡 | 🟡 | -| Upload file | ✅ | ✅ | 🟡 | ✅ | 🟡 | 🟡 | -| Download file | ✅ | ✅ | 🟡 | ✅ | 🟡 | 🟡 | -| Users online status | ✅ | ✅ | 🟡 | ✅ | 🟡 | 🟡 | -| Chat messages pagination | ✅ | ✅ | 🟡 | ✅ | 🟡 | 🟡 | -| Users pagination | ✅ | ✅ | 🟡 | ✅ | 🟡 | 🟡 | -| Users typing status | ✅ | ✅ | 🟡 | ✅ | 🟡 | 🟡 | - -- ✅ Tested -- 🟡 Supported but not tested -- 🚧 Planned - -## Activities to complete (Roadmap) - -Below are some activities to complete to have a more complete and optimized project also for use cases in larger projects. +--- -1. Add the missing triggers -2. Error handling -3. Chat room groups -4. Chat room channels -5. Sending audio messages -6. Improve documentation +

+ + + + InsideApp + + +

+

+ Made with ❤️ by InsideApp + · Documentation + · GitHub +

diff --git a/analysis_options.yaml b/analysis_options.yaml index 18f9502..0871ecc 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -14,6 +14,7 @@ linter: - sized_box_for_whitespace - sort_child_properties_last - sort_pub_dependencies + - public_member_api_docs - type_annotate_public_apis - unawaited_futures - use_named_constants diff --git a/dart_test.yaml b/dart_test.yaml new file mode 100644 index 0000000..7bee3ba --- /dev/null +++ b/dart_test.yaml @@ -0,0 +1,5 @@ +tags: + integration: + # Needs a running Supabase stack with the chat schema applied, see + # tool/test_integration.sh. Realtime round trips take a while. + timeout: 2x diff --git a/doc/docs/guides/supabase-realtime.md b/doc/docs/guides/supabase-realtime.md new file mode 100644 index 0000000..992ed70 --- /dev/null +++ b/doc/docs/guides/supabase-realtime.md @@ -0,0 +1,89 @@ +--- +id: supabase-realtime +title: Realtime +--- + +The package uses all three [Supabase Realtime](https://supabase.com/docs/guides/realtime) features, each where it fits best: + +| Data | Feature | Channel | +|------|---------|---------| +| Message inserts, updates, deletes | Broadcast from Database | private `chats:room:` | +| Typing indicator | Broadcast (client to client) | same private channel | +| Rooms list (`roomsChanges()`, `SupabaseChatRoomsController`) and single room (`room(id)`) | Postgres Changes on `chats.rooms` and `chats.room_members` | one per subscription | +| Online status | Presence | `online-user-` | + +## Room channels + +Every message change is sent to the room's private channel by the `chats.broadcast_message_change` trigger, which calls `realtime.broadcast_changes` with the operation (`INSERT`, `UPDATE`, `DELETE`) as event name and the new and old rows as payload. `SupabaseChatController` listens to those events and applies them to its list. + +Why not Postgres Changes on `chats.messages`: + +* `chats.messages` is partitioned, and Postgres Changes reports each row under the partition it physically lives in (`chats.messages_p20261019`, ...), so a subscription on the parent table never matches anything; +* Broadcast authorizes once, when the channel is joined, instead of evaluating RLS for every change and every subscriber; +* a `DELETE` broadcast carries the whole old row, so it can be routed to its room. + +A failure to broadcast never loses the message: the trigger reports it as a Postgres `WARNING` and the write goes through. + +Requirements: Realtime with Broadcast from Database support (Supabase cloud, or self-hosted `supabase/realtime` >= v2.33). + +### Authorization + +Room channels are private (`RealtimeChannelConfig(private: true)`). Realtime authorizes them against RLS policies on `realtime.messages`, created by `03_database_policy.sql`: + +```sql +CREATE POLICY "chats.realtime_room_read" + ON realtime.messages + AS PERMISSIVE + FOR SELECT + TO authenticated + USING (chats.is_chat_member_topic((SELECT realtime.topic()))); + +CREATE POLICY "chats.realtime_room_write" + ON realtime.messages + AS PERMISSIVE + FOR INSERT + TO authenticated + WITH CHECK ( + extension = 'broadcast' + AND chats.is_chat_member_topic((SELECT realtime.topic())) + ); +``` + +`chats.is_chat_member_topic` only matches topics starting with `chats:room:` followed by a valid uuid, so policies other apps define on `realtime.messages` are unaffected and malformed topics are simply refused. + +### Sharing and lifecycle + +Realtime allows a single join per topic per connection, so a `SupabaseChatCore` keeps one channel per room, shared by every controller open on it, and releases it when the last one is disposed. When Realtime refuses a channel as unauthorized (the user isn't, or is no longer, a member) the channel is released and not retried, instead of rejoining with backoff for as long as the connection lives; the controller reports the failure through `onError`. + +The Postgres Changes subscriptions behind `room(id)` and `roomsChanges()` use a unique topic per subscription, so several listeners of the same data can coexist. A failed or timed-out subscription is surfaced as a `SupabaseChatCoreRequestException` on the stream. + +## Typing indicator + +The typing indicator used Realtime Presence up to 1.x. Supabase rate-limits Presence `track()` calls per client and drops the channel when the limit is exceeded, which a typing indicator updating every few hundred milliseconds hits within seconds ([issue #38](https://github.com/insideapp-srl/flutter_supabase_chat_core/issues/38)). Since 2.x it uses Broadcast events on the room channel, which are cheap and ephemeral. + +The protocol, per user: + +* while typing, a `typing` event with `{uid, typing: true}` at most once every **2 seconds** (`onTyping()` can be called on every keystroke); +* a `{uid, typing: false}` event after **3 seconds** without keystrokes, when a message is sent, or when the last controller of the room is disposed; +* receivers drop a user who hasn't sent `typing: true` within **5 seconds**, so a lost "stopped" event (app killed, connection lost) can never leave a stale indicator behind. + +Trade-off: Broadcast keeps no state. Someone opening a room while another member is already typing sees the indicator only from that member's next event, at most 2 seconds later. + +## Rooms list + +`roomsChanges()` subscribes to: + +* `UPDATE`/`DELETE` on `chats.rooms` (a new message updates the room's `lastMessages` and `updatedAt`); +* `INSERT` on `chats.room_members` for the current user (added to a room); +* `DELETE` on `chats.room_members` (the current user left or was removed, or another member left). + +Each relevant event refetches the room from `chats.rooms_l` (so the unread count and the caller's preferences are fresh) and emits a `ChatRoomUpserted`, or a `ChatRoomRemoved` when the room is gone or no longer visible. Rooms rejected by `SupabaseChatCoreConfig.roomFilter` are skipped. Postgres Changes still checks RLS, so users only receive events about rooms they can read. + +## Online status + +`UserOnlineStateObserver` tracks the logged-in user on the Presence channel `online-user-` while the app is in the foreground and untracks it otherwise; going offline also stores `chats.users."lastSeen"`. `userOnlineStatus(uid)` (and `UserOnlineStatusWidget`) listen to that channel: every listener of the same user shares one channel, closed when the last listener cancels, and the stream emits the current status first. + +## Self-hosted notes + +* `supabase db reset` (or restoring a database) restarts Postgres but not Realtime, whose Postgres Changes poller can stay in a degraded state and silently stop delivering events: restart the Realtime container afterwards. `tool/local_db.sh` does it for the local stack. +* `chats.rooms`, `chats.room_members`, `chats.users` and `chats.messages` are added to the `supabase_realtime` publication by `01_database_schema.sql`. diff --git a/doc/docs/guides/supabase-security-rls.md b/doc/docs/guides/supabase-security-rls.md index 1080221..b575722 100644 --- a/doc/docs/guides/supabase-security-rls.md +++ b/doc/docs/guides/supabase-security-rls.md @@ -3,308 +3,267 @@ id: supabase-security title: Security Rules --- -This is a drop-in example of [Postgres Row Security Level](https://supabase.com/docs/guides/database/postgres/row-level-security) designed for a typical chat application. +`example/utils/sql/03_database_policy.sql` and `04_storage.sql` protect every table, bucket and Realtime channel with [Postgres Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security). Both scripts are idempotent: every function is replaced and every policy dropped and recreated. -# Helper functions +## Access model -Security rules make use of some helper functions: +* **`chats.users`**: readable by any authenticated user, updatable by its owner only, created and deleted by triggers/foreign keys only. +* **`chats.rooms`**: readable by members. Created only through `chats.create_room()` (room and members in one transaction, direct rooms de-duplicated). Group rooms are renamed and deleted by their admins, direct rooms by either member. +* **`chats.room_members`**: readable by members of the same room. Group admins add and remove members and change roles; anyone can leave a room. Nobody can add themselves: knowing a room's id is not enough to join it. +* **`chats.messages`**: readable by members; a message can only be sent as yourself, edited by its author, and deleted by its author or a group admin. +* Read receipts, reactions, pins and per-user preferences touch rows of other people (or columns clients may not write), so they go through `SECURITY DEFINER` RPCs that check membership themselves. + +"Admin" means a member with role `admin` in a **group** room. In a **direct** room both members have the same rights over it, so the admin helper is true for either of them. + +## Helper functions ```sql - DROP function if exists chats.is_auth; - CREATE OR REPLACE FUNCTION chats.is_auth() - RETURNS boolean - LANGUAGE 'plpgsql' - COST 100 - VOLATILE NOT LEAKPROOF SECURITY INVOKER - SET search_path = '' - AS $BODY$ - BEGIN - return auth.uid() IS NOT NULL; - end; - $BODY$; - - DROP function if exists chats.is_owner; - CREATE OR REPLACE FUNCTION chats.is_owner(user_id uuid) - RETURNS boolean - LANGUAGE 'plpgsql' - COST 100 - VOLATILE NOT LEAKPROOF SECURITY INVOKER - SET search_path = '' - AS $BODY$ - BEGIN - return auth.uid() = user_id; - end; - $BODY$; - - DROP function if exists chats.is_member; - CREATE OR REPLACE FUNCTION chats.is_member(members uuid[]) - RETURNS boolean - LANGUAGE 'plpgsql' - COST 100 - VOLATILE NOT LEAKPROOF SECURITY INVOKER - SET search_path = '' - AS $BODY$ - BEGIN - return auth.uid() = ANY(members); - end; - $BODY$; - - DROP function if exists chats.is_chat_member; - CREATE OR REPLACE FUNCTION chats.is_chat_member(room_id bigint) - RETURNS boolean - LANGUAGE 'plpgsql' - COST 100 - VOLATILE NOT LEAKPROOF SECURITY INVOKER - SET search_path = '' - AS $BODY$ - DECLARE - members uuid[]; - BEGIN - SELECT "userIds" INTO members - FROM chats.rooms - WHERE id = room_id; - return chats.is_member(members); - end; - $BODY$; +-- auth.uid() IS NOT NULL / auth.uid() = user_id +CREATE OR REPLACE FUNCTION chats.is_auth() ... +CREATE OR REPLACE FUNCTION chats.is_owner(user_id uuid) ... + +-- SECURITY DEFINER on purpose: it is called from chats.room_members' own +-- SELECT policy. As SECURITY INVOKER its lookup on chats.room_members would +-- re-apply that same policy, recursing forever. +CREATE OR REPLACE FUNCTION chats.is_chat_member(room_id uuid) + RETURNS boolean + LANGUAGE sql + STABLE + SECURITY DEFINER + SET search_path = '' +AS $$ + SELECT EXISTS ( + SELECT 1 FROM chats.room_members + WHERE "roomId" = room_id AND "userId" = auth.uid() + ); +$$; + +-- Admin of a group room, or member of a direct room. +CREATE OR REPLACE FUNCTION chats.is_chat_admin(room_id uuid) + RETURNS boolean + LANGUAGE sql + STABLE + SECURITY DEFINER + SET search_path = '' +AS $$ + SELECT EXISTS ( + SELECT 1 + FROM chats.room_members rm + JOIN chats.rooms r ON r.id = rm."roomId" + WHERE rm."roomId" = room_id + AND rm."userId" = auth.uid() + AND (rm.role = 'admin' OR r.type = 'direct') + ); +$$; + +-- The caller's role in a room ('admin', 'moderator', or NULL). +CREATE OR REPLACE FUNCTION chats.chat_member_role(room_id uuid) ... ``` -# Security rules + +Path and topic helpers never raise on malformed input (a bare `::uuid` cast inside a policy could fail queries on unrelated rows, since SQL doesn't guarantee the evaluation order of AND-ed conditions): + +* `chats.is_chat_member_path(object_name)`: is the caller a member of the room whose id is the first folder of a `chats_assets` path? Folders named after a 1.x numeric room id are resolved through `chats.room_id_migration_map`, so attachments uploaded before the migration stay readable until `migrate_storage_uuids` moves them (see MIGRATION.md). +* `chats.is_owner_path(object_name)`: is the first folder the caller's own user id? +* `chats.is_chat_member_topic(topic)`: is the caller a member of the room of a `chats:room:` Realtime topic? False for any other topic. ## Tables -### Summary +| Table | INSERT | SELECT | UPDATE | DELETE | +|-------|--------|--------|--------|--------| +| `chats.users` | Nobody (sign-up trigger) | Authenticated users | Owner | Nobody (deleted with `auth.users`) | +| `chats.rooms` | Nobody (`chats.create_room`) | Members | Admins | Admins | +| `chats.room_members` | Group admins, group rooms only | Members of the same room | Admins | Yourself, or admins | +| `chats.messages` | Members, as themselves | Members | Author | Author, or group admin | -#### Table `chats.users` +### `chats.users` -- `INSERT` : Nobody, this table is populate by trigger on auth.users. -- `SELECT` : All users authenticated. -- `UPDATE` : Only the user himself. -- `DELETE` : Nobody. +```sql +CREATE POLICY "chats.users_grant_create" ON chats.users + AS PERMISSIVE FOR INSERT TO authenticated + WITH CHECK (false); -- Created by trigger -#### Table `chats.rooms` +CREATE POLICY "chats.users_grant_read" ON chats.users + AS PERMISSIVE FOR SELECT TO authenticated + USING (chats.is_auth()); -- `INSERT` : All users authenticated. -- `SELECT` : All users who are members of the chat room. -- `UPDATE` : All users who are members of the chat room. -- `DELETE` : All users who are members of the chat room. +CREATE POLICY "chats.users_grant_update" ON chats.users + AS PERMISSIVE FOR UPDATE TO authenticated + USING (chats.is_owner(id)); -#### Table `chats.messages` +CREATE POLICY "chats.users_grant_delete" ON chats.users + AS PERMISSIVE FOR DELETE TO authenticated + USING (false); -- Delete by foreign key +``` -- `INSERT` : All users who are members of the chat room. -- `SELECT` : All users who are members of the chat room. -- `UPDATE` : All users who are members of the chat room. -- `DELETE` : All users who are members of the chat room. +### `chats.rooms` ---- +```sql +CREATE POLICY "chats.rooms_grant_create" ON chats.rooms + AS PERMISSIVE FOR INSERT TO authenticated + WITH CHECK (false); -- Created by chats.create_room() -Security rules implemented: +CREATE POLICY "chats.rooms_grant_read" ON chats.rooms + AS PERMISSIVE FOR SELECT TO authenticated + USING (chats.is_chat_member(id)); -### Table `users` +CREATE POLICY "chats.rooms_grant_update" ON chats.rooms + AS PERMISSIVE FOR UPDATE TO authenticated + USING (chats.is_chat_admin(id)); -```sql - CREATE POLICY "chats.users_grant_create" - ON chats.users - AS PERMISSIVE - FOR INSERT - TO public - WITH CHECK (false); -- Created by trigger - - CREATE POLICY "chats.users_grant_read" - ON chats.users - AS PERMISSIVE - FOR SELECT - TO public - USING (chats.is_auth()); - - CREATE POLICY "chats.users_grant_update" - ON chats.users - AS PERMISSIVE - FOR UPDATE - TO public - USING (chats.is_auth()) - WITH CHECK (chats.is_owner(id)); - - CREATE POLICY "chats.users_grant_delete" - ON chats.users - AS PERMISSIVE - FOR DELETE - TO public - USING (false); -- Delete by foreign key +CREATE POLICY "chats.rooms_grant_delete" ON chats.rooms + AS PERMISSIVE FOR DELETE TO authenticated + USING (chats.is_chat_admin(id)); ``` -### Table `rooms` +### `chats.room_members` ```sql - CREATE POLICY "chats.rooms_grant_create" - ON chats.rooms - AS PERMISSIVE - FOR INSERT - TO public - WITH CHECK (chats.is_auth()); - - CREATE POLICY "chats.rooms_grant_read" - ON chats.rooms - AS PERMISSIVE - FOR SELECT - TO public - USING (chats.is_member("userIds")); - - CREATE POLICY "chats.rooms_grant_update" - ON chats.rooms - AS PERMISSIVE - FOR UPDATE - TO public - USING (chats.is_member("userIds")) - WITH CHECK (chats.is_member("userIds")); - - CREATE POLICY "chats.rooms_grant_delete" - ON chats.rooms - AS PERMISSIVE - FOR DELETE - TO public - USING (chats.is_member("userIds")); +-- Only group admins add members, and only to group rooms (a direct room's +-- membership is fixed at creation). No "add yourself" path. +CREATE POLICY "chats.room_members_grant_create" ON chats.room_members + AS PERMISSIVE FOR INSERT TO authenticated + WITH CHECK ( + chats.is_chat_admin("roomId") + AND EXISTS (SELECT 1 FROM chats.rooms r WHERE r.id = "roomId" AND r.type = 'group') + ); + +CREATE POLICY "chats.room_members_grant_read" ON chats.room_members + AS PERMISSIVE FOR SELECT TO authenticated + USING (chats.is_chat_member("roomId")); + +-- Role changes ("lastReadAt", "pinnedAt", "mutedUntil" go through RPCs). +CREATE POLICY "chats.room_members_grant_update" ON chats.room_members + AS PERMISSIVE FOR UPDATE TO authenticated + USING (chats.is_chat_admin("roomId")); + +-- Leave a room (your own row) or remove a member (admins). +CREATE POLICY "chats.room_members_grant_delete" ON chats.room_members + AS PERMISSIVE FOR DELETE TO authenticated + USING ("userId" = (SELECT auth.uid()) OR chats.is_chat_admin("roomId")); ``` -### Table `messages` +A room left without members is deleted by the `delete_room_if_empty` trigger (see [Database Triggers](supabse-trigges.md)). + +### `chats.messages` ```sql - CREATE POLICY "chats.messages_grant_create" - ON chats.messages - AS PERMISSIVE - FOR INSERT - TO public - WITH CHECK (chats.is_chat_member("roomId")); - - CREATE POLICY "chats.messages_grant_read" - ON chats.messages - AS PERMISSIVE - FOR SELECT - TO public - USING (chats.is_chat_member("roomId")); - - CREATE POLICY "chats.messages_grant_update" - ON chats.messages - AS PERMISSIVE - FOR UPDATE - TO public - USING (chats.is_chat_member("roomId")) - WITH CHECK (chats.is_chat_member("roomId")); - - CREATE POLICY "chats.messages_grant_delete" - ON chats.messages - AS PERMISSIVE - FOR DELETE - TO public - USING (chats.is_chat_member("roomId")); +CREATE POLICY "chats.messages_grant_create" ON chats.messages + AS PERMISSIVE FOR INSERT TO authenticated + WITH CHECK (chats.is_chat_member("roomId") AND "authorId" = (SELECT auth.uid())); + +CREATE POLICY "chats.messages_grant_read" ON chats.messages + AS PERMISSIVE FOR SELECT TO authenticated + USING (chats.is_chat_member("roomId")); + +CREATE POLICY "chats.messages_grant_update" ON chats.messages + AS PERMISSIVE FOR UPDATE TO authenticated + USING (chats.is_chat_member("roomId") AND "authorId" = (SELECT auth.uid())); + +CREATE POLICY "chats.messages_grant_delete" ON chats.messages + AS PERMISSIVE FOR DELETE TO authenticated + USING ( + chats.is_chat_member("roomId") + AND ("authorId" = (SELECT auth.uid()) OR chats.chat_member_role("roomId") = 'admin') + ); ``` -## Storage buckets - -### Summary +UPDATE policies state their rule once, in `USING`: without `WITH CHECK`, Postgres applies the `USING` expression to the new row too, so a row can neither be selected nor moved outside of the rule. -#### Bucket `chats_assets` +Enabling RLS on a partitioned table doesn't apply to partitions created later, so `chats.ensure_messages_partitions_rls()` enables it on every `chats.messages` partition and revokes their direct grants (partitions are only reached through the parent). It runs after the scripts and hourly with the `pg_cron` partition maintenance. -- `INSERT` : All users who are members of the chat room. -- `SELECT` : All users who are members of the chat room. -- `UPDATE` : All users who are members of the chat room. -- `DELETE` : All users who are members of the chat room. +## RPCs -#### Bucket `chats_user_avatar` +All `SECURITY DEFINER` (they bypass RLS), so each one checks the caller itself: -- `INSERT` : Only the user himself. -- `SELECT` : All users authenticated. -- `UPDATE` : Only the user himself. -- `DELETE` : Only the user himself. - ---- +| Function | Checks | Does | +|----------|--------|------| +| `chats.create_room(p_type, p_user_ids, p_name, p_image_url, p_metadata, p_creator_role)` | Logged in, valid type, known users, exactly one other user for `direct` | Creates the room and its members (the caller always included, with `p_creator_role` in groups); returns an existing direct room for the same pair and metadata scope, serialized with an advisory lock | +| `chats.mark_room_as_read(p_room_id)` | Member | Stores the caller's `lastReadAt` and flips the other members' messages to `seen` | +| `chats.toggle_message_reaction(p_message_id, p_reaction)` | Member, reaction of 1–64 characters | Adds/removes the caller's reaction atomically; returns the reactions map | +| `chats.pin_room(p_room_id, p_pinned)` | Member | Sets the caller's own `pinnedAt` | +| `chats.mute_room(p_room_id, p_until)` | Member | Sets the caller's own `mutedUntil` (`'infinity'` for ever, `NULL` to unmute) | +| `chats.set_message_pinned(p_message_id, p_pinned)` | Admin (either member of a direct room) | Pins/unpins a message for everyone | -Security rules implemented: +## Realtime -### Bucket `chats_assets` +Room channels (`chats:room:`) are private. Members may join them (receive the message changes and typing events) and send Broadcast messages to them; nobody else can: ```sql - DROP policy IF EXISTS "storage.object_grant_create_auth_chats_assets" - ON storage.objects; - create policy "storage.object_grant_create_auth_chats_assets" - on storage.objects for insert - with check ( - bucket_id = 'chats_assets' - and - chats.is_chat_member((storage.foldername(name))[1]::bigint)); - - DROP policy IF EXISTS "storage.object_grant_read_auth_chats_assets" - ON storage.objects; - create policy "storage.object_grant_read_auth_chats_assets" - on storage.objects for select - using ( - bucket_id = 'chats_assets' - and - chats.is_chat_member((storage.foldername(name))[1]::bigint)); - - DROP policy IF EXISTS "storage.object_grant_update_auth_chats_assets" - ON storage.objects; - create policy "storage.object_grant_update_auth_chats_assets" - on storage.objects for update - using ( - bucket_id = 'chats_assets' - and - chats.is_chat_member((storage.foldername(name))[1]::bigint)) - with check ( - bucket_id = 'chats_assets' - and - chats.is_chat_member((storage.foldername(name))[1]::bigint)); - - DROP policy IF EXISTS "storage.object_grant_delete_auth_chats_assets" - ON storage.objects; - create policy "storage.object_grant_delete_auth_chats_assets" - on storage.objects for delete - using ( - bucket_id = 'chats_assets' - and - chats.is_chat_member((storage.foldername(name))[1]::bigint)); +CREATE POLICY "chats.realtime_room_read" ON realtime.messages + AS PERMISSIVE FOR SELECT TO authenticated + USING (chats.is_chat_member_topic((SELECT realtime.topic()))); + +CREATE POLICY "chats.realtime_room_write" ON realtime.messages + AS PERMISSIVE FOR INSERT TO authenticated + WITH CHECK ( + extension = 'broadcast' + AND chats.is_chat_member_topic((SELECT realtime.topic())) + ); ``` -### Bucket `chats_user_avatar` +See [Realtime](supabase-realtime.md). + +## Storage buckets + +Both buckets are private; files are served through authenticated URLs (`/storage/v1/object/authenticated/...`) with the user's access token. + +| Bucket | INSERT | SELECT | UPDATE | DELETE | +|--------|--------|--------|--------|--------| +| `chats_assets` (`/`) | Room members | Room members | Room members | Room members | +| `chats_user_avatar` (`/`) | Owner | Authenticated users | Owner | Owner | ```sql - DROP policy IF EXISTS "storage.object_grant_create_auth_chats_user_avatar" - ON storage.objects; - create policy "storage.object_grant_create_auth_chats_user_avatar" - on storage.objects for insert - with check ( - bucket_id = 'chats_user_avatar' - and - chats.is_owner((storage.foldername(name))[1]::uuid)); - - DROP policy IF EXISTS "storage.object_grant_read_auth_chats_user_avatar" - ON storage.objects; - create policy "storage.object_grant_read_auth_chats_user_avatar" - on storage.objects for select - using ( - bucket_id = 'chats_user_avatar' - and - chats.is_auth()); - - DROP policy IF EXISTS "storage.object_grant_update_auth_chats_user_avatar" - ON storage.objects; - create policy "storage.object_grant_update_auth_chats_user_avatar" - on storage.objects for update - using ( - bucket_id = 'chats_user_avatar' - and - chats.is_owner((storage.foldername(name))[1]::uuid)) - with check ( - bucket_id = 'chats_user_avatar' - and - chats.is_owner((storage.foldername(name))[1]::uuid)); - - DROP policy IF EXISTS "storage.object_grant_delete_auth_chats_user_avatar" - ON storage.objects; - create policy "storage.object_grant_delete_auth_chats_user_avatar" - on storage.objects for delete - using ( - bucket_id = 'chats_user_avatar' - and - chats.is_owner((storage.foldername(name))[1]::uuid)); +create policy "storage.object_grant_read_auth_chats_assets" +on storage.objects for select +to authenticated +using ( + bucket_id = 'chats_assets' + and + chats.is_chat_member_path(name)); + +create policy "storage.object_grant_create_auth_chats_user_avatar" +on storage.objects for insert +to authenticated +with check ( + bucket_id = 'chats_user_avatar' + and + chats.is_owner_path(name)); + +create policy "storage.object_grant_read_auth_chats_user_avatar" +on storage.objects for select +to authenticated +using ( + bucket_id = 'chats_user_avatar' + and + chats.is_auth()); ``` -To learn more head over to the [Postgres Row Security Level](https://supabase.com/docs/guides/database/postgres/row-level-security) website. +The other `chats_assets` policies use `chats.is_chat_member_path(name)` and the other avatar policies `chats.is_owner_path(name)` in the same way. + +## Privileges + +The policies target `authenticated` only: `anon` gets no chat rows (instead +of an error) and the helpers they call don't need to be executable by `anon`. +`99_database_schema_permission.sql` therefore revokes `EXECUTE` on the policy +helpers from `anon`, and on the trigger functions from every client role, so +none of them can be called through `/rest/v1/rpc`. The `auth.uid()` checks +are written `(SELECT auth.uid())`, evaluated once per query instead of once +per row. + +With the scripts applied, the Supabase Security and Performance Advisors only +report what is intended: the RPCs and policy helpers executable by +`authenticated` (they check the caller themselves), the tables closed on +purpose with RLS and no policy (partitions, internal queues, 1.x backups), +and the indexes not used yet on a new project. + +## Internal tables + +* `chats.media_pending_deletion` (the media cleanup queue) has RLS enabled and no policies: only `SECURITY DEFINER` functions and the `service_role` (the `cleanup-media` Edge Function) reach it. +* `chats.room_id_migration_map` (1.x → 2.x room ids, only present after a migration, dropped by `migrate_storage_uuids` once the attachments are moved) is internal bookkeeping read by `chats.is_chat_member_path`. +* After a 1.x migration, the `*_backup_` tables keep RLS enabled with their policies dropped, so they are closed to clients. + +:::caution +The client-side scoping hooks of `SupabaseChatCoreConfig` (workspaces, organizations) only filter what the app asks for. If a scope is a security boundary, add the same rule to these policies. +::: + +To learn more head over to the [Postgres Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security) guide. diff --git a/doc/docs/guides/supabase-usage.md b/doc/docs/guides/supabase-usage.md index 4e200fd..0d347d2 100644 --- a/doc/docs/guides/supabase-usage.md +++ b/doc/docs/guides/supabase-usage.md @@ -3,154 +3,339 @@ id: supabase-usage title: Usage --- -As mentioned in [How it works?](../introduction/supabase-overview.md#how-it-works), you will need to register a user using [Supabase Authentication](https://supabase.com/docs/guides/auth). If you are using Supabase Authentication as your auth provider you don't need to do anything, a trigger will create a new row in the `chat.users` table. +:::info +This page describes the 2.x API. Coming from 1.x? See [MIGRATION.md](https://github.com/insideapp-srl/flutter_supabase_chat_core/blob/main/MIGRATION.md) for the old → new mapping. +::: -## Update user +## Users and authentication -You can provide values like `firstName`, `imageUrl` and `lastName` if you're planning to have a screen with all users available for chat. +Users sign up with [Supabase Authentication](https://supabase.com/docs/guides/auth). The `chats.handle_new_user` trigger creates their chat profile in `chats.users`, taking the first name, last name and avatar from the sign-up metadata when present (`first_name`/`firstName`/`given_name`, `last_name`/`lastName`/`family_name`, `avatar_url`/`picture`): ```dart -import 'package:flutter_chat_types/flutter_chat_types.dart' as types; -import 'package:flutter_supabase_chat_core/flutter_supabase_chat_core.dart'; +await Supabase.instance.client.auth.signUp( + email: email, + password: password, + data: {'first_name': 'John', 'last_name': 'Doe'}, +); +``` + +`SupabaseChatCore.instance` keeps track of the logged-in user: + +* `loggedSupabaseUser`: the Supabase Auth `User` (from `supabase_flutter`); +* `loggedUserId`: its id; +* `loggedUser`: the chat profile, a `ChatUser` (with `firstName`, `lastName`, `imageUrl`, `lastSeen`, `fullName`, `initials`), reloaded on auth state changes; `refreshLoggedUser()` reloads it on demand. + +Update the profile and the avatar: -... -user.firstName: 'John'; -user.imageUrl: 'https://i.pravatar.cc/300'; -user.lastName: 'Doe'; +```dart +final me = SupabaseChatCore.instance.loggedUser!; +await SupabaseChatCore.instance.updateUser( + me.copyWith(firstName: 'John', lastName: 'Doe'), +); +await SupabaseChatCore.instance.uploadUserAvatar('me.jpg', bytes); +``` -await SupabaseChatCore.instance.updateUser(user); +List users (e.g. for a "new chat" picker) with `users()`. It's a paginated `Future`; every word of `filter` must match the first or last name: +```dart +final page = await SupabaseChatCore.instance.users( + filter: 'jo', + offset: 0, + limit: 20, + excludeLoggedUser: true, +); ``` -## Supabase Chat users +`user(uid: id)` returns a single user. At the UI boundary, `ChatUser.toCoreUser()` converts it to the `flutter_chat_core` `User` expected by `Chat.resolveUser`. + +## Rooms -You can use the `SupabaseChatCore.instance.users()` stream which will return all registered users with avatars and names. +### Creating rooms ```dart -import 'package:flutter/material.dart'; -import 'package:flutter_chat_types/flutter_chat_types.dart' as types; -import 'package:flutter_supabase_chat_core/flutter_supabase_chat_core.dart'; +// Direct room: reused if one already exists with this user. +final room = await SupabaseChatCore.instance.createRoom(otherUser); + +// Group room: the caller is added as admin (see creatorRole). +final group = await SupabaseChatCore.instance.createGroupRoom( + name: 'Product team', + users: [bob, carol], + imageUrl: null, + metadata: {'topic': 'release'}, +); +``` -class UsersPage extends StatelessWidget { - const UsersPage({Key? key}) : super(key: key); +Both go through the `chats.create_room` RPC, which creates the room and its members in one transaction. A direct room is unique per pair of users **and** metadata scope: an existing one is returned when its metadata contains every entry of the requested metadata (see [Scoping](#scoping-workspaces-organizations)). - @override - Widget build(BuildContext context) { - return Scaffold( - body: StreamBuilder>( - stream: SupabaseChatCore.instance.users(), - initialData: const [], - builder: (context, snapshot) { - // ... - }, - ), - ); - } -} +### Rooms list + +`SupabaseChatRoomsController` is a `ChangeNotifier` with a paginated, searchable list of the user's rooms, kept in sync over Realtime: new rooms appear, rooms receiving messages move up with their last message and unread count updated, deleted or left rooms disappear. + +```dart +final rooms = SupabaseChatRoomsController(pageSize: 20)..loadFirstPage(); + +ListenableBuilder( + listenable: rooms, + builder: (context, _) => ListView( + children: [ + for (final room in rooms.rooms) + ListTile( + title: Text(room.name ?? 'Chat'), + subtitle: Text(room.isMuted ? 'muted' : ''), + trailing: room.unreadCount > 0 + ? Badge(label: Text('${room.unreadCount}')) + : null, + ), + ], + ), +); ``` -## Starting a chat +| Member | Purpose | +|--------|---------| +| `loadFirstPage()`, `loadNextPage()`, `refresh()`, `hasMore`, `isLoading`, `error` | Pagination and state | +| `search(filter)`, `filter` | Name search (direct rooms are named after the other member). While a filter is active, Realtime only updates rooms already in the list | +| `totalUnreadCount` | Unread messages across the loaded rooms, muted rooms excluded | +| `markLocallyAsRead(roomId)` | Clear a badge right away, before the server round trip | +| `pinRoom()`, `muteRoom()`, `unmuteRoom()` | Same as `SupabaseChatCore`, updating the list at once | + +Rooms are sorted with the ones the user pinned first (most recently pinned first), then by `updatedAt`, which only new messages bump. `unreadCount` is capped at 100 by the view (show "99+"). -When you have access to that `uid` or you have the whole `User` class from the `SupabaseChatCore.instance.users()` stream, you can call either `createRoom` or `createGroupRoom`. For the group, you will need to additionally provide a name and an optional image. +Building your own list instead? `rooms(filter:, offset:, limit:)` returns a page, `roomsChanges()` a stream of `ChatRoomUpserted`/`ChatRoomRemoved` changes, and `SupabaseChatCore.applyRoomChange(list, change)` merges one into a list. + +### A single room + +* `getRoom(id)` returns the room, or `null` if it doesn't exist or the user isn't a member. +* `room(id)` returns a stream that emits the room right away and on every change to it or its members, and completes when the room is deleted or the user leaves it. +* A `ChatRoom` exposes `users` (each with its per-room `role`), `member(id)`, `roleOf(id)`, `otherUser(myId)` for direct rooms, `lastMessage`, `unreadCount`, `lastReadAt`, `isPinned`/`pinnedAt` and `isMuted`/`mutedUntil` (`ChatRoom.mutedForever` when muted for ever). + +### Managing rooms + +| Method | Who can | +|--------|---------| +| `updateRoom(room)` | Name, avatar and metadata of a group: admins. Direct rooms: metadata only, either member | +| `addMembers(roomId, userIds, role:)` | Group admins | +| `removeMember(roomId, userId)` | Admins, or yourself | +| `leaveRoom(roomId)` | Any member | +| `setMemberRole(roomId, userId, role)` | Admins (`null` = plain member) | +| `deleteRoom(roomId)` | Group admins, or either member of a direct room. Deletes every message | +| `markRoomAsRead(roomId)` | Any member: resets `unreadCount` and flips other members' messages to `seen` | +| `pinRoom(roomId, pinned:)`, `muteRoom(roomId, duration:)`, `unmuteRoom(roomId)` | Any member, for themselves only | + +A room left without members is deleted automatically. Muting is advisory: read `room.isMuted` to decide whether to notify. + +## Messages + +### SupabaseChatController + +`SupabaseChatController` implements `flutter_chat_core`'s `ChatController`. Create one per open room, pass it to `Chat.chatController` and dispose it with the page: ```dart -import 'package:flutter/material.dart'; -import 'package:flutter_chat_types/flutter_chat_types.dart' as types; +import 'package:flutter_chat_core/flutter_chat_core.dart' as core; +import 'package:flutter_chat_ui/flutter_chat_ui.dart'; import 'package:flutter_supabase_chat_core/flutter_supabase_chat_core.dart'; +import 'package:flyer_chat_text_message/flyer_chat_text_message.dart'; +import 'package:uuid/uuid.dart'; + +class _RoomPageState extends State { + late final _controller = SupabaseChatController( + room: widget.room, + onError: (error) => ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(error.message))), + ); -class UsersPage extends StatelessWidget { - const UsersPage({Key? key}) : super(key: key); + String get _me => SupabaseChatCore.instance.loggedUserId!; - void _handlePressed(types.User otherUser, BuildContext context) async { - final room = await SupabaseChatCore.instance.createRoom(otherUser); - // Navigate to the Chat screen + @override + void dispose() { + _controller.dispose(); + super.dispose(); } @override - Widget build(BuildContext context) { - return Scaffold( - body: StreamBuilder>( - stream: SupabaseChatCore.instance.users(), - initialData: const [], - builder: (context, snapshot) { - // ... - }, + Widget build(BuildContext context) => Chat( + currentUserId: _me, + resolveUser: (id) async => + (widget.room.member(id) ?? + await SupabaseChatCore.instance.user(uid: id)) + ?.toCoreUser(), + chatController: _controller, + onMessageSend: (text) => _controller.sendMessage( + core.Message.text(id: const Uuid().v7(), authorId: _me, text: text), + ), + builders: core.Builders( + chatAnimatedListBuilder: (context, itemBuilder) => ChatAnimatedList( + itemBuilder: itemBuilder, + onEndReached: _controller.loadPreviousMessages, ), - ); - } + textMessageBuilder: + (context, message, index, {required isSentByMe, groupStatus}) => + FlyerChatTextMessage(message: message, index: index), + // imageMessageBuilder, fileMessageBuilder, audioMessageBuilder... + ), + ); } ``` -## Rooms +Constructor parameters: `room`, `pageSize` (20), `onError` (failures of background work: initial load, Realtime processing, automatic mark-as-read), `autoMarkAsRead` (true) and `chatCore` (defaults to `SupabaseChatCore.instance`). + +### Loading messages + +The first page loads on creation (`initialLoad` completes when done). `loadPreviousMessages()` loads the next older page using the oldest loaded `createdAt` as cursor, so pages stay stable while new messages arrive; `hasMoreMessages` tells whether there may be more. New, edited and deleted messages arrive over Realtime and are applied with `flutter_chat_ui`'s insert/update/remove animations. -To render user's rooms you use the `SupabaseChatCore.instance.rooms()` stream. `Room` class will have the name and image URL taken either from provided ones for the group or set to the other person's image URL and name. See [Security Rules](supabase-security-rls.md) for more info about rooms filtering. +### Sending ```dart -import 'package:flutter/material.dart'; -import 'package:flutter_chat_types/flutter_chat_types.dart' as types; -import 'package:flutter_supabase_chat_core/flutter_supabase_chat_core.dart'; +await _controller.sendMessage( + core.Message.image( + id: const Uuid().v7(), + authorId: _me, + source: upload.url, + size: bytes.length, + ), +); +``` -class RoomsPage extends StatelessWidget { - const RoomsPage({Key? key}) : super(key: key); +* The message is inserted locally right away with status `sending`. +* `authorId` is always set to the logged-in user (RLS enforces it too), `createdAt` defaults to now, and the database sets the status to `sent`. +* The id is stored as-is, so the Realtime echo of the insert updates the local copy instead of duplicating it. Use `const Uuid().v7()`: v7 ids are time-ordered like the database's own `chats.uuid_generate_v7()`, which keeps the primary key index insert-friendly. +* On failure the local copy keeps status `error` and the exception is rethrown; `retryMessage(message)` sends it again, `deleteMessage(id)` drops it locally. - @override - Widget build(BuildContext context) { - return Scaffold( - body: StreamBuilder>( - stream: SupabaseChatCore.instance.rooms(), - initialData: const [], - builder: (context, snapshot) { - // ... - }, - ), - ); - } -} +`sendMessage` also stops the typing indicator. Upload attachments first with `SupabaseChatCore.instance.uploadAsset(room, fileName, bytes)`: it returns an `UploadAssetResult` with the authenticated `url`, the detected `mimeType` and the object `path` (`/-`). Load those URLs with `SupabaseChatCore.instance.httpSupabaseHeaders` (e.g. `FlyerChatImageMessage(headers: ...)`). + +`SupabaseChatCore.instance.sendMessage(message, roomId)` sends without the optimistic part, e.g. from code that doesn't use a controller. + +### Read receipts + +With `autoMarkAsRead` the controller calls `markRoomAsRead` once the first page is loaded and whenever another member's message arrives. The `chats.mark_room_as_read` RPC stores the read time on the membership (driving `unreadCount`) and flips the other members' messages to `seen`, which reaches their senders as Realtime updates. In a group, a message is `seen` as soon as any other member read it. Pass `autoMarkAsRead: false` and call `markAsRead()` yourself if, for example, you only want to mark rooms read while the app is in the foreground. + +### Editing, reactions, pins, deletion + +| Controller method | Notes | +|-------------------|-------| +| `editTextMessage(message, text)` | Author only. Sets `editedAt` and `metadata['edited'] = true`; rolled back locally on failure | +| `toggleReaction(messageId, reaction)` | Any member; atomic `chats.toggle_message_reaction` RPC. Reactions map an emoji to user ids | +| `setPinned(message, pinned:)`, `pinnedMessages` | Group admins or either member of a direct room. `pinnedMessages` is a `ValueNotifier` updated in real time, e.g. for a pinned banner | +| `deleteMessage(messageId)` | The author, or a group admin. Returns whether a message was deleted | + +`SupabaseChatCore.updateMessage(message, roomId)` updates content and metadata only: status, reactions and the pinned flag have their own calls. + +### Replies, search and jumping + +* Set `replyToMessageId` when building a message to reply to another one. +* `resolveMessage(id)` returns a message from the loaded ones or fetches it once (cached), e.g. to render the quoted message. +* `jumpToMessage(id)` loads older pages until the message is found (at most `maxPages`, default 20) and scrolls to it. +* `SupabaseChatCore.instance.searchMessages(roomId, query)` returns the text messages containing `query` (case-insensitive), newest first. + +### Typing indicator + +Call `onTyping()` on every keystroke of the composer (throttling is internal) and listen to `typingUsers`, a stream of the other members currently typing (`currentTypingUsers` for the current value): + +```dart +_text.addListener(() { + if (_text.text.isNotEmpty) _controller.onTyping(); +}); + +StreamBuilder>( + stream: _controller.typingUsers, + builder: (context, snapshot) => Text( + (snapshot.data ?? const []).isEmpty ? '' : 'typing…', + ), +); ``` -## Messages +Pass `_text` to `Composer(textEditingController: _text)` through `Builders.composerBuilder`. See [Realtime](supabase-realtime.md) for how it works. + +## Online status -`SupabaseChatCore.instance.messages` stream will give you access to all messages in the specified room. If you want to have dynamic updates for the room itself, you will need to wrap messages stream with a room stream. See the [example](https://github.com/insideapp-srl/flutter_supabase_chat_core/blob/main/example/lib/chat.dart). +Wrap your home page in `UserOnlineStateObserver`: it publishes the user as online while the app is in the foreground (on the web: while the window is focused), offline otherwise, and stores `lastSeen` when going offline. ```dart -import 'package:flutter/material.dart'; -import 'package:flutter_chat_types/flutter_chat_types.dart' as types; -import 'package:flutter_supabase_chat_core/flutter_supabase_chat_core.dart'; +MaterialApp(home: const UserOnlineStateObserver(child: HomePage())); +``` -class ChatPage extends StatelessWidget { - const ChatPage({Key? key}) : super(key: key); +Show someone's status with `UserOnlineStatusWidget`, or listen to `SupabaseChatCore.instance.userOnlineStatus(uid)` directly: - @override - Widget build(BuildContext context) { - return Scaffold( - body: StreamBuilder>( - initialData: const [], - stream: SupabaseChatCore.instance.messages(widget.room), - builder: (context, snapshot) { - // ... - }, - ), - ); - } +```dart +UserOnlineStatusWidget( + uid: user.id, + builder: (status) => Text( + status == UserOnlineStatus.online ? 'online' : 'last seen recently', + ), +); +``` + +## Scoping (workspaces, organizations) + +`SupabaseChatCoreConfig` holds the names of the schema, tables, views, channel prefixes and buckets (all named parameters with defaults matching the SQL scripts) and four hooks to keep the chats of different scopes apart without forking the package: + +```dart +SupabaseChatCore.instance.setConfig( + const SupabaseChatCoreConfig().copyWith( + defaultRoomMetadata: () => {'workspaceId': currentWorkspaceId}, + roomsQueryFilter: (q) => q.eq('metadata->>workspaceId', currentWorkspaceId), + roomFilter: (room) => room.metadata?['workspaceId'] == currentWorkspaceId, + usersQueryFilter: (q) => q.eq('metadata->>workspaceId', currentWorkspaceId), + ), +); +``` + +| Hook | Effect | +|------|--------| +| `defaultRoomMetadata` | Merged into the metadata of every room created by `createRoom`/`createGroupRoom` (explicit metadata wins). Direct rooms are de-duplicated per scope | +| `roomsQueryFilter` | Applied to every rooms query, including Realtime refetches | +| `usersQueryFilter` | Applied to every `users()` query | +| `roomFilter` | Rooms received over Realtime for which it returns `false` are ignored. Keep it consistent with `roomsQueryFilter` | + +`usersViewName` makes `users()`/`user()` read from a view instead of `chats.users`, e.g. one exposing only the users sharing a workspace with the caller. + +Hooks are evaluated on every call, so they can read state that changes at runtime. Call `setConfig` before using the instance. + +:::caution +Hooks only narrow what the client asks for. If a scope is a security boundary, enforce the same rule server side with RLS policies. +::: + +When you rename tables, views, buckets or `realtimeRoomChannelPrefix`, update the SQL scripts accordingly: the channel prefix is also hard-coded in the `chats.broadcast_message_change` trigger and in `chats.is_chat_member_topic`. + +To work with a different `SupabaseClient` (e.g. several logged-in users in one process, as the integration tests do), create your own `SupabaseChatCore(client: ..., config: ...)`, pass it as `chatCore:` to controllers and widgets, and `dispose()` it when done. + +## Error handling + +Every public method of `SupabaseChatCore`, `SupabaseChatController` and `SupabaseChatRoomsController` throws a `SupabaseChatCoreException` subtype, with the original error in `cause`: + +* `SupabaseChatCoreAuthException`: an operation needs a logged-in user and there isn't one; +* `SupabaseChatCoreRequestException`: the request failed (RLS, constraint, connectivity, Realtime subscription...); +* `SupabaseChatCoreStorageException`: an upload failed. + +```dart +try { + await SupabaseChatCore.instance.leaveRoom(room.id); +} on SupabaseChatCoreException catch (e) { + // e.message names the operation, e.cause holds the PostgrestException/... } ``` -If you use Flyer Chat UI you can just pass `snapshot.data ?? []` to the `messages` parameter of the Chat widget. See the [example](https://github.com/insideapp-srl/flutter_supabase_chat_core/blob/main/example/lib/chat.dart). +Background failures that no caller awaits are reported elsewhere: `SupabaseChatController.onError`, `SupabaseChatRoomsController.error`, and as errors on the `room()`/`roomsChanges()` streams. -### Send a message +## Media cleanup -To send a message use `SupabaseChatCore.instance.sendMessage`, it accepts 2 parameters: +Deleting a message deletes its row immediately. When it referenced a file in Supabase Storage, the `chats.queue_media_for_deletion` trigger records the object in `chats.media_pending_deletion`, because SQL alone can't delete objects from the storage backend. The [`cleanup-media`](https://github.com/insideapp-srl/flutter_supabase_chat_core/blob/main/supabase/functions/cleanup-media/index.ts) Edge Function drains the queue (up to 500 objects per call) through the Storage API and marks the rows as processed. -* Any partial message. Click [here](https://docs.flyer.chat/flutter/chat-ui/types) to learn more about the types or check the [API reference](https://pub.dev/documentation/flutter_chat_types/latest/index.html). You provide a partial message because Supabase will set fields like `authorId`, `createdAt` and `id` automatically. -* Room ID. +1. Deploy it: `supabase functions deploy cleanup-media`. +2. Store the project URL and a service role key in Vault, once (never commit real keys): -### Update the message +```sql +select vault.create_secret('https://.supabase.co', 'chats_project_url'); +select vault.create_secret('', 'chats_service_role_key'); +``` -To update the message use `SupabaseChatCore.instance.updateMessage`, it accepts 2 parameters: +3. Run `example/utils/sql/06_cleanup_media_schedule.sql`: it schedules the function every 15 minutes with `pg_cron` + `pg_net`, only when the queue isn't empty. Re-running it replaces the job. -* Any message. Click [here](https://docs.flyer.chat/flutter/chat-ui/types) to learn more about the types or check the [API reference](https://pub.dev/documentation/flutter_chat_types/latest/index.html). Use a message you get from the `SupabaseChatCore.instance.messages` stream, update it and send as this parameter. -* Room ID. +You can also call it by hand: -## `supabaseUser` +```bash +curl -X POST "$SUPABASE_URL/functions/v1/cleanup-media" \ + -H "Authorization: Bearer $SUPABASE_SERVICE_ROLE_KEY" +``` -`SupabaseChatCore.instance.supabaseUser` is a shortcut you can use to see which user is currently logged in through Supabase Authentication. The returned type comes from the Supabase library and **it is not the same `User` as from the `flutter_chat_types` package**. +Without it nothing breaks: the files of deleted messages simply stay in the bucket. diff --git a/doc/docs/guides/supabse-indexes.md b/doc/docs/guides/supabse-indexes.md index 0e16001..e260640 100644 --- a/doc/docs/guides/supabse-indexes.md +++ b/doc/docs/guides/supabse-indexes.md @@ -3,11 +3,57 @@ id: supabase-indexes title: Database Indexes --- -## `chats.messages` indexes +## Ids: uuid v7 -These indexes are added to improve the performance of foreign keys in database tables: +`chats.rooms.id` and `chats.messages.id` are `uuid`, generated by `chats.uuid_generate_v7()`: time-ordered UUIDs ([RFC 9562](https://www.rfc-editor.org/rfc/rfc9562) UUIDv7), so ids stay roughly sequential like the old bigint identities and don't fragment the primary key indexes the way random UUIDv4 would. The function works on any Postgres version Supabase ships, without extensions (native `uuidv7()` only arrives with Postgres 18). + +`chats.users.id` is not generated: it must match `auth.users.id`. + +Generate message ids the same way on the client, with `const Uuid().v7()`: `SupabaseChatController.sendMessage` stores the id as-is, which is how it recognizes the Realtime echo of its optimistic copy. + +## Message partitioning + +`chats.messages` is a native `PARTITION BY RANGE ("createdAt")` table managed by [pg_partman](https://github.com/pgpartman/pg_partman): + +* weekly partitions (`p_interval => '7 days'`), 4 created in advance; +* a default partition (`chats.messages_default`) catches any row outside the generated range; +* no retention: chat history is kept forever, partitioning only keeps indexes, vacuum and queries fast as history grows; +* the hourly `pg_cron` job `partman-run-maintenance` runs `partman.run_maintenance_proc()` and `chats.ensure_messages_partitions_rls()` (RLS isn't inherited by partitions created later). + +`pg_partman` is installed in its own `partman` schema, so its tables aren't exposed through the `chats` grants. To change the interval or add retention, adapt the `partman.create_parent(...)` call and the `partman.part_config` update in `01_database_schema.sql`. + +After a 1.x migration, weekly partitions start 4 weeks before the migration and older messages land in the default partition (one partition per week since the first message would mean hundreds of partitions, each scanned by per-room queries). Split it later with `partman.partition_data_proc()` if you want. + +## Primary keys + +Postgres requires the partition key in every unique index of a partitioned table, so the messages primary key is composite. Nothing references `messages.id` with a foreign key. ```sql -CREATE INDEX ON "chats"."messages" USING btree ("authorId"); -CREATE INDEX ON "chats"."messages" USING btree ("roomId"); +ALTER TABLE ONLY chats.users ADD CONSTRAINT users_pkey PRIMARY KEY (id); +ALTER TABLE ONLY chats.rooms ADD CONSTRAINT rooms_pkey PRIMARY KEY (id); +-- chats.room_members +PRIMARY KEY ("roomId", "userId") +ALTER TABLE ONLY chats.messages ADD CONSTRAINT messages_pkey PRIMARY KEY (id, "createdAt"); ``` + +## Secondary indexes + +```sql +-- Rooms list and membership lookups by user (the primary key covers lookups by room). +CREATE INDEX IF NOT EXISTS "room_members_userId_idx" ON chats.room_members USING btree ("userId"); + +-- Foreign key to auth.users. +CREATE INDEX ON chats.messages USING btree ("authorId"); + +-- Message pagination (newest first, per room), the last-message trigger and +-- rooms_l."unreadCount": all filter by room and order/compare by "createdAt". +CREATE INDEX IF NOT EXISTS "messages_roomId_createdAt_idx" + ON chats.messages USING btree ("roomId", "createdAt" DESC); + +-- Pinned messages: few rows per room, a partial index keeps it tiny. +CREATE INDEX IF NOT EXISTS "messages_roomId_pinned_idx" + ON chats.messages USING btree ("roomId", "createdAt" DESC) + WHERE pinned; +``` + +Indexes created on the partitioned parent are propagated by Postgres to every partition, current and future. The composite `("roomId", "createdAt" DESC)` index supersedes the old `messages_roomId_idx`, which the script drops. diff --git a/doc/docs/guides/supabse-trigges.md b/doc/docs/guides/supabse-trigges.md index b15b33a..26f5009 100644 --- a/doc/docs/guides/supabse-trigges.md +++ b/doc/docs/guides/supabse-trigges.md @@ -3,19 +3,65 @@ id: supabase-triggers title: Database Triggers --- +`example/utils/sql/02_database_trigger.sql` defines the triggers below. Row-level triggers on the partitioned `chats.messages` apply to every partition automatically (Postgres 13+), so they are defined once on the parent table. + +| Trigger | Table | When | Function | +|---------|-------|------|----------| +| `on_auth_user_created` | `auth.users` | after insert | `chats.handle_new_user()` | +| `update_last_messages_trigger` | `chats.messages` | after insert/update/delete | `chats.update_last_messages()` | +| `update_status_before_insert` | `chats.messages` | before insert | `chats.set_message_status_to_sent()` | +| `queue_media_for_deletion_trigger` | `chats.messages` | after delete | `chats.queue_media_for_deletion()` | +| `broadcast_message_change_trigger` | `chats.messages` | after insert/update/delete | `chats.broadcast_message_change()` | +| `delete_room_if_empty_trigger` | `chats.room_members` | after delete, deferred | `chats.delete_room_if_empty()` | + +## Handle new user + +Creates the chat profile of every new auth user, taking the names and avatar from the sign-up metadata when present (`auth.signUp(data: {'first_name': ..., 'last_name': ..., 'avatar_url': ...})`, also the shape most OAuth providers fill in), so the profile is complete without a second update call: + +```sql +CREATE OR REPLACE FUNCTION chats.handle_new_user() + RETURNS trigger + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = '' +AS $BODY$ +DECLARE + meta jsonb := coalesce(new.raw_user_meta_data, '{}'::jsonb); +BEGIN + INSERT INTO chats.users (id, "firstName", "lastName", "imageUrl", "createdAt", "updatedAt", "lastSeen") + VALUES ( + new.id, + nullif(coalesce(meta ->> 'first_name', meta ->> 'firstName', meta ->> 'given_name'), ''), + nullif(coalesce(meta ->> 'last_name', meta ->> 'lastName', meta ->> 'family_name'), ''), + nullif(coalesce(meta ->> 'avatar_url', meta ->> 'picture'), ''), + now(), now(), now() + ) + ON CONFLICT (id) DO NOTHING; + RETURN new; +END; +$BODY$; + +drop trigger if exists on_auth_user_created on auth.users; +create trigger on_auth_user_created + after insert on auth.users + for each row execute procedure chats.handle_new_user(); +``` + +If your app doesn't authenticate with Supabase Auth, replace this trigger with your own way of filling `chats.users` (its `id` must still match `auth.users.id`). + ## Update room last message -This is a trigger that sets room's `lastMessages` to the most recent message sent once recieved in Supabase. +Keeps `chats.rooms."lastMessages"` in sync with the room's newest message. Only a **new** message bumps `updatedAt`: edits, reactions and read receipts refresh the snapshot but keep the order, otherwise reading a chat would reorder everyone's rooms list. `SECURITY DEFINER` because any member sends messages, but only admins may update `chats.rooms` under RLS. ```sql CREATE OR REPLACE FUNCTION chats.update_last_messages() RETURNS TRIGGER + SECURITY DEFINER SET search_path = '' AS $$ DECLARE latest_message jsonb; - ts_in_milliseconds bigint; - affected_room_id bigint; + affected_room_id uuid; BEGIN IF TG_OP = 'DELETE' THEN affected_room_id := OLD."roomId"; @@ -35,11 +81,11 @@ BEGIN (SELECT "lastMessages" FROM chats.rooms WHERE id = affected_room_id) ) LIMIT 1 ) THEN - SELECT EXTRACT(epoch FROM NOW()) * 1000 INTO ts_in_milliseconds; - UPDATE chats.rooms - SET "updatedAt" = ts_in_milliseconds, - "lastMessages" = jsonb_build_array(latest_message) + SET "updatedAt" = CASE WHEN TG_OP = 'INSERT' THEN now() ELSE "updatedAt" END, + "lastMessages" = CASE WHEN latest_message IS NULL + THEN NULL + ELSE jsonb_build_array(latest_message) END WHERE id = affected_room_id; END IF; @@ -50,17 +96,11 @@ BEGIN END IF; END; $$ LANGUAGE plpgsql; - -drop trigger if exists update_last_messages_trigger on chats.messages; -CREATE TRIGGER update_last_messages_trigger - AFTER INSERT OR UPDATE OR DELETE ON chats.messages - FOR EACH ROW -EXECUTE FUNCTION chats.update_last_messages(); ``` -## Set message status to sent +The room update is what the rooms list listens to over Realtime (Postgres Changes on `chats.rooms`). -"This trigger, on the other hand, is responsible for setting the message status to `sent` when it is added to the `messages` table: +## Set message status to sent ```sql CREATE OR REPLACE FUNCTION chats.set_message_status_to_sent() @@ -72,40 +112,59 @@ BEGIN RETURN NEW; END; $$ LANGUAGE plpgsql; - -drop trigger if exists update_status_before_insert on chats.messages; -CREATE TRIGGER update_status_before_insert - BEFORE INSERT ON chats.messages - FOR EACH ROW EXECUTE FUNCTION chats.set_message_status_to_sent(); ``` -## Handle new user from `auth.users` +Later statuses come from `chats.mark_room_as_read()`, which flips messages to `seen`. + +## Queue media for deletion + +SQL can't delete objects from the storage backend. When a deleted message's `source` points at Supabase Storage (`.../object/authenticated//`), the bucket and path are recorded in `chats.media_pending_deletion`, for the `cleanup-media` Edge Function to remove later (see [Usage](supabase-usage.md#media-cleanup)). The message row itself is deleted immediately. `SECURITY DEFINER` because the queue is closed to clients. + +## Delete room if empty -"This trigger, is responsible for replicate `auth.users` table rows in `chats.users` table, this is to avoid exposing user data : +A room with no members left is deleted, with its messages. It's a `DEFERRABLE INITIALLY DEFERRED` constraint trigger, so the check happens at commit and never sees an intermediate state of a transaction; `SECURITY DEFINER` because the last member leaving is no longer a member by the time it runs. ```sql +CREATE CONSTRAINT TRIGGER delete_room_if_empty_trigger + AFTER DELETE ON chats.room_members + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION chats.delete_room_if_empty(); +``` -CREATE OR REPLACE FUNCTION chats.handle_new_user() - RETURNS trigger - LANGUAGE 'plpgsql' - COST 100 - VOLATILE NOT LEAKPROOF SECURITY DEFINER - SET search_path=public +## Broadcast message changes + +Delivers every message change to the room's private Realtime channel `chats:room:`, where `SupabaseChatController` listens. Postgres Changes can't be used for `chats.messages` because it is partitioned (see [Realtime](supabase-realtime.md)). + +```sql +CREATE OR REPLACE FUNCTION chats.broadcast_message_change() + RETURNS TRIGGER + LANGUAGE plpgsql + SECURITY DEFINER SET search_path = '' -AS $BODY$ +AS $$ DECLARE - ts_in_milliseconds bigint; + v_room_id uuid := coalesce(NEW."roomId", OLD."roomId"); BEGIN - SELECT EXTRACT(epoch FROM NOW()) * 1000 INTO ts_in_milliseconds; - insert into chats.users (id, "createdAt", "updatedAt", "lastSeen") - values (new.id, ts_in_milliseconds, ts_in_milliseconds, ts_in_milliseconds); - return new; -end; -$BODY$; + BEGIN + PERFORM realtime.broadcast_changes( + 'chats:room:' || v_room_id::text, + TG_OP, + TG_OP, + 'messages', + 'chats', + NEW, + OLD + ); + EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'chats.broadcast_message_change: % (room %)', SQLERRM, v_room_id; + END; + RETURN NULL; +END; +$$; +``` -drop trigger if exists on_auth_user_created on auth.users; -create trigger on_auth_user_created - after insert on auth.users - for each row execute procedure chats.handle_new_user(); +A failure to broadcast never loses the message: it is logged as a `WARNING` and the write goes through. If you change `SupabaseChatCoreConfig.realtimeRoomChannelPrefix`, change the `'chats:room:'` prefix here and in `chats.is_chat_member_topic()` too. + +## Ideas -``` \ No newline at end of file +Triggers are also the natural place for push notifications: e.g. an `AFTER INSERT` trigger on `chats.messages` calling an Edge Function with `pg_net`, skipping the members whose `room_members."mutedUntil"` is in the future. diff --git a/doc/docs/guides/supabse-views.md b/doc/docs/guides/supabse-views.md index 2095ec4..a7091c0 100644 --- a/doc/docs/guides/supabse-views.md +++ b/doc/docs/guides/supabse-views.md @@ -3,9 +3,16 @@ id: supabase-views title: Database Views --- +The client reads rooms and messages through two views defined in `example/utils/sql/05_database_view.sql`. Both are `security_invoker`, so the RLS policies of the underlying tables apply to the caller. + ## Rooms view -This is a view of `rooms` table, this view allows you to obtain the name of the sender of the message dynamically in direct rooms, based on the logged-in user the name of the correspondent is displayed, it is also included the list of uses member objects. +`chats.rooms_l` returns the rooms the caller is a member of, with everything a rooms list needs in one query: + +* `users`: every member, each with its room-specific `role` folded in (ordered by join time); +* `name`: for direct rooms, the other member's full name; +* `lastReadAt`, `pinnedAt`, `mutedUntil`: the caller's own membership state; +* `unreadCount`: messages from other members newer than `lastReadAt`, capped at 100 so a long-unread room never costs a full scan (show "99+"). ```sql create or replace view chats.rooms_l @@ -15,30 +22,43 @@ select r.id, r.metadata, case when r.type = 'direct' and auth.uid() is not null then - (select coalesce(u."firstName", '') || ' ' || coalesce(u."lastName", '') - from chats.users u - where u.id = any (r."userIds") - and u.id <> auth.uid() + (select nullif(trim(coalesce(u."firstName", '') || ' ' || coalesce(u."lastName", '')), '') + from chats.room_members rm + join chats.users u on u.id = rm."userId" + where rm."roomId" = r.id + and rm."userId" <> auth.uid() limit 1) else r.name end as name, r.type, - r."userIds", r."lastMessages", - r."userRoles", r."createdAt", r."updatedAt", - (select jsonb_agg(to_jsonb(u)) - from chats.users u - where u.id = any (r."userIds")) as users -from chats.rooms r; + (select jsonb_agg(to_jsonb(u) || jsonb_build_object('role', rm.role) order by rm."createdAt") + from chats.room_members rm + join chats.users u on u.id = rm."userId" + where rm."roomId" = r.id) as users, + me."lastReadAt", + me."pinnedAt", + me."mutedUntil", + (select count(*) + from (select 1 + from chats.messages m + where m."roomId" = r.id + and m."authorId" <> auth.uid() + and m."createdAt" > coalesce(me."lastReadAt", '-infinity'::timestamptz) + limit 100) unread)::int as "unreadCount" +from chats.rooms r +left join chats.room_members me + on me."roomId" = r.id and me."userId" = auth.uid(); ``` +`ChatRoom.fromJson(row, currentUserId: uid)` builds a `ChatRoom` from a row; for direct rooms it also takes the avatar of the other member. `mutedUntil = 'infinity'` (muted for ever) becomes `ChatRoom.mutedForever`. ## Messages view -This is a view of `messages` table, this view allows you to obtain the author user object and room object. +`chats.messages_l` is a straight passthrough of `chats.messages` under RLS: `flutter_chat_core`'s `Message` only carries `authorId` (not an embedded author) and has no room field, so there is nothing to join. Column names match `flutter_chat_core`'s field names (`source`, `waveform`, `linkPreviewData`, `replyToMessageId`, ...); `messageFromRow` converts a row to a `Message` (dates to epoch milliseconds, `duration` from the stored milliseconds to seconds). ```sql create or replace view chats.messages_l @@ -49,25 +69,22 @@ select m.id, m.duration, m."mimeType", m.name, - m."remoteId", - m."repliedMessage", + m."replyToMessageId", m."roomId", - m."showStatus", m.size, m.status, m.type, m."updatedAt", - m.uri, - m."waveForm", - m."isLoading", + m.source, + m.waveform, m.height, m.width, - m."previewData", + m."linkPreviewData", m."authorId", m.text, - to_jsonb(u) as author, - to_jsonb(r) as room -from chats.messages m - left join chats.users u on u.id = m."authorId" - left join chats.rooms_l r on r.id = m."roomId"; -``` \ No newline at end of file + m.reactions, + m.pinned +from chats.messages m; +``` + +Both views are granted `SELECT` to `authenticated` and `service_role`. To expose a narrower list of users (e.g. only the ones sharing a workspace with the caller), create your own view and point `SupabaseChatCoreConfig.usersViewName` at it. diff --git a/doc/docs/guides/testing.md b/doc/docs/guides/testing.md new file mode 100644 index 0000000..ca46051 --- /dev/null +++ b/doc/docs/guides/testing.md @@ -0,0 +1,57 @@ +--- +id: testing +title: Testing and development +--- + +The repository has three test suites and a few scripts to run them against a local Supabase stack, started with the [Supabase CLI](https://supabase.com/docs/guides/cli/getting-started) (`supabase/config.toml` exposes the `chats` schema). The scripts are Bash and need `psql`, `curl` and Docker. + +## Unit tests + +Models, date parsing and the mapping between `chats.messages` rows and `flutter_chat_core` messages. No backend needed: + +```bash +flutter test test/unit +``` + +## Integration tests + +`test/integration` drives the real package against the local stack: users, rooms and scoping hooks, real-time delivery and reconciliation, read receipts, pagination, edits/reactions/deletions, voice messages, attachments' access rules, pinned and muted rooms, the typing indicator, `roomsChanges()`, `SupabaseChatRoomsController` and online status. Every test user gets its own `SupabaseClient` and `SupabaseChatCore`. + +```bash +supabase start # once +tool/test_integration.sh # resets the DB, applies the SQL, runs the tests +tool/test_integration.sh --no-reset # keeps the current database +``` + +The script exports `CHAT_TEST_SUPABASE_URL`, `CHAT_TEST_ANON_KEY` and `CHAT_TEST_SERVICE_ROLE_KEY` from `supabase status`; without them the integration tests are skipped. Other arguments are forwarded to `flutter test`. + +## Database tests (pgTAP) + +`supabase/tests/database/chats_test.sql` checks the RLS policies, RPCs, triggers, views, storage and Realtime helpers and the partitions' RLS, in a transaction rolled back at the end: + +```bash +tool/local_db.sh +supabase test db +``` + +## Scripts + +| Script | Purpose | +|--------|---------| +| `tool/local_db.sh` | Resets the local database, applies `example/utils/sql` exactly like end users do, reloads PostgREST's schema cache and restarts the Realtime container. `--no-reset` applies the scripts on top of the current database, to check they are idempotent | +| `tool/test_integration.sh` | `tool/local_db.sh` + integration tests | +| `tool/v1_fixture.sh` | Rebuilds the local database in the 1.x shape (SQL scripts from the `main` branch, or `V1_REF`) with sample data. Follow it with `tool/local_db.sh --no-reset` to exercise the 1.x → 2.x migration | +| `tool/test_storage_migration.sh` | Builds a 1.x database with attachments in the old room folders, migrates it and runs `example/utils/migrate_storage_uuids.sh` (dry run, resume after a stop, URLs, cleanup of the map). `--ps` runs the PowerShell variant | +| `tool/seed_demo.sh` | Seeds demo users and conversations (direct and group rooms, replies, reactions, a pinned message, a muted and pinned group, unread messages, a voice message) | + +## Trying the example locally + +```bash +supabase start +tool/local_db.sh && tool/seed_demo.sh +cd example +flutter run --dart-define=SUPABASE_URL=http://127.0.0.1:54321 \ + --dart-define=SUPABASE_ANON_KEY= +``` + +Sign in as `alice@demo.dev` with password `Demo-password-1` (also `bob@demo.dev`, `carol@demo.dev`, `dave@demo.dev`, `erin@demo.dev`). diff --git a/doc/docs/introduction/supabase-overview.md b/doc/docs/introduction/supabase-overview.md index a6cb798..03e34db 100644 --- a/doc/docs/introduction/supabase-overview.md +++ b/doc/docs/introduction/supabase-overview.md @@ -8,23 +8,39 @@ Have you ever wanted to implement a chat in your application? Do you think it's Try this, thanks to the magnificent [Supabase](https://supabase.com/) platform and the [Flutter Chat UI](https://pub.dev/packages/flutter_chat_ui) package, you can achieve it in just a few minutes and effortlessly. +![Demo: real-time messages, reactions, replies and voice messages](/img/demo.gif) + --- -Flyer Chat is a platform for creating in-app chat experiences using Flutter or React Native. This is the documentation for Supabase BaaS implementation for Flutter. +Flyer Chat is a platform for creating in-app chat experiences using Flutter. This is the documentation of `flutter_supabase_chat_core`, the [Supabase](https://supabase.com/docs) backend for [`flutter_chat_core`](https://pub.dev/packages/flutter_chat_core) and [`flutter_chat_ui`](https://pub.dev/packages/flutter_chat_ui) 2.x. + +:::info +Coming from 1.x (`flutter_chat_types`)? 2.0 changes both the Dart API and the database schema: read [MIGRATION.md](https://github.com/insideapp-srl/flutter_supabase_chat_core/blob/main/MIGRATION.md). +::: + +## How it works + +Everything lives in a dedicated `chats` Postgres schema, protected by [Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security): + +* `chats.users` holds the chat profiles (names, avatar, last seen). A row is created by a trigger for every Supabase Auth user, so every chat user must be registered with [Supabase Authentication](https://supabase.com/docs/guides/auth) (or a third-party auth provider configured in Supabase) and `chats.users.id` matches `auth.users.id`. +* `chats.rooms` holds the conversations: `direct` (1-to-1) or `group`, with a name, an avatar, custom metadata and a snapshot of the last message. +* `chats.room_members` links users to rooms, with a per-room `role` (`admin`, `moderator` or none) and the member's own state: when they last read the room, whether they pinned or muted it. +* `chats.messages` holds the messages, with the same field names as `flutter_chat_core`'s `Message`. It is partitioned by week to stay fast as history grows. -## How it works? +Two views, `chats.rooms_l` and `chats.messages_l`, are what the client reads: `rooms_l` adds the members, the caller's unread count and preferences, and the other member's name for direct rooms. -We use [Supabase](https://supabase.com/docs) as the backend. There we have three tables, `rooms`, `messages` and `users`. Let's break them down: +Writes that touch other people's rows (creating a room with its members, read receipts, reactions, pins) go through `SECURITY DEFINER` functions that check membership themselves. See [Security Rules](../guides/supabase-security-rls.md). -* `rooms` table contains all conversations in your app. [Postgres Row Security Level](https://supabase.com/docs/guides/database/postgres/row-level-security) are responsible for showing only those rooms where the user's in. The room contains some metadata, a participants list and other room information. -* `messages` table contains all messages in your app. [Postgres Row Security Level](https://supabase.com/docs/guides/database/postgres/row-level-security) are responsible for showing only those messages of the rooms where the user's in. The massage contains all message data. -* `users` table contains users data, such as avatars and names. You can use this collection to render a list of users available for chat. This table is populate automatically by trigger. +[Supabase Realtime](https://supabase.com/docs/guides/realtime) keeps the UI live: message changes travel on a private Broadcast channel per room, sent by a database trigger; the rooms list follows Postgres Changes; online status uses Presence. See [Realtime](../guides/supabase-realtime.md). -Both array of participant IDs in the `room` and rows in `users` tables use `User UID` from [Supabase Authentication](https://supabase.com/docs/guides/auth) as an ID, for easier navigation through the data. That means every user of the chat should be registered using Supabase's authentication module, but if your app doesn't use Supabase as an authentication provider, you still can register in Supabase by providing your `JWT` token, so you can have your backend working together with a chat on Supabase (in this case it will be necessary to remove the trigger that inserts users). +[Supabase Storage](https://supabase.com/docs/guides/storage) holds attachments (`chats_assets`, one folder per room) and avatars (`chats_user_avatar`, one folder per user), both private buckets. -[Postgres Triggers](https://supabase.com/docs/guides/database/postgres/triggers) can be used for setting message's statuses and for triggering push notifications. In this documentation, you will see an [example](../guides/supabse-trigges.md) of setting `delivered` message status. +On the Flutter side, the package provides: -[Supabase Storage](https://supabase.com/docs/guides/storage) can be used as a storage provider for images and files. +* `SupabaseChatCore`: rooms, members, users, messages, uploads and presence; +* `SupabaseChatRoomsController`: a live, paginated rooms list; +* `SupabaseChatController`: a `flutter_chat_core` `ChatController` for one room, to pass to `flutter_chat_ui`'s `Chat`; +* `UserOnlineStateObserver` and `UserOnlineStatusWidget` for online status. ## Motivation diff --git a/doc/docs/introduction/supabase-package-installation.md b/doc/docs/introduction/supabase-package-installation.md index 096f261..66c50d9 100644 --- a/doc/docs/introduction/supabase-package-installation.md +++ b/doc/docs/introduction/supabase-package-installation.md @@ -1,8 +1,42 @@ --- id: supabase-package-installation -title: Installation supabase_flutter +title: Package installation --- -This library depends on [supabase_flutter](https://pub.dev/packages/supabase_flutter). Follow the instructions there to configure the Supabase project and install [supabase_flutter](https://supabase.com/docs/reference/dart/introduction) plugin. +This package depends on [supabase_flutter](https://pub.dev/packages/supabase_flutter): follow its [instructions](https://supabase.com/docs/reference/dart/introduction) to add Supabase to your app. -Add `flutter_supabase_chat_core` to your package's `pubspec.yaml` file. Check current version on [pub.dev](https://pub.dev/packages/flutter_supabase_chat_core/install). \ No newline at end of file +Requirements: `Dart >=3.8.0` and `Flutter >=3.32.0`. + +```yaml +dependencies: + flutter_supabase_chat_core: ^2.0.0-rc.1 + supabase_flutter: ^2.17.2 + uuid: ^4.5.1 + + # Optional, for the ready-made UI: + flutter_chat_ui: ^2.11.1 + flyer_chat_text_message: ^2.6.0 + flyer_chat_image_message: ^2.3.0 + flyer_chat_file_message: ^2.4.0 +``` + +Check the current version on [pub.dev](https://pub.dev/packages/flutter_supabase_chat_core/install). + +Since 2.x the package builds on [`flutter_chat_core`](https://pub.dev/packages/flutter_chat_core) (not `flutter_chat_types`): messages are `flutter_chat_core` `Message`s, and rooms and users are the package's own `ChatRoom` and `ChatUser`. + +`flutter_chat_ui` 2.x ships no renderer for images, files or audio (only a minimal text fallback): add one `flyer_chat_*_message` package per message type you render, or write your own builder (the example app has its own voice message bubble). + +:::caution +`flutter_chat_ui` 2.12.0 moved its `Composer` to the experimental `material_ui` package, which crashes inside a standard `Scaffold` ("No Material widget found"). The example app pins `flutter_chat_ui: '>=2.11.1 <2.12.0'`. For the same reason, since `flutter_chat_core` 2.9.1 `ChatTheme.fromThemeData` takes a `material_ui` theme: build the `ChatTheme` field by field, as `example/lib/src/theme/app_theme.dart` does. +::: + +Initialize Supabase before using the package: `SupabaseChatCore.instance` is bound to `Supabase.instance.client`. + +```dart +await Supabase.initialize( + url: 'https://.supabase.co', + publishableKey: '', +); +``` + +Upgrading an existing 1.x project? See [MIGRATION.md](https://github.com/insideapp-srl/flutter_supabase_chat_core/blob/main/MIGRATION.md), including the required database migration. diff --git a/doc/docs/introduction/supabase-project-configuration.md b/doc/docs/introduction/supabase-project-configuration.md index 8b05bb4..227f1bb 100644 --- a/doc/docs/introduction/supabase-project-configuration.md +++ b/doc/docs/introduction/supabase-project-configuration.md @@ -3,83 +3,97 @@ id: supabase-project-configuration title: Supabase project preparation --- -### Create a Supabase project +The database is set up by the SQL scripts in [`example/utils/sql`](https://github.com/insideapp-srl/flutter_supabase_chat_core/tree/main/example/utils/sql), applied in this order: -1. Install Supabase CLI: [Official documentation](https://supabase.com/docs/guides/cli/getting-started) -2. Open your bash -3. Login with Supabase: +| Script | Content | +|--------|---------| +| `01_database_schema.sql` | Schema `chats`, tables, weekly partitions of `chats.messages`, indexes, `pg_cron` maintenance job. Also migrates a 1.x database. | +| `02_database_trigger.sql` | [Triggers](../guides/supabse-trigges.md): profile creation, last message snapshot, `sent` status, media cleanup queue, empty room deletion, Realtime broadcast of message changes. | +| `03_database_policy.sql` | Access helpers, RPCs and [RLS policies](../guides/supabase-security-rls.md) (tables and `realtime.messages`). | +| `04_storage.sql` | Buckets `chats_assets` and `chats_user_avatar` and their policies. | +| `05_database_view.sql` | [Views](../guides/supabse-views.md) `chats.rooms_l` and `chats.messages_l`. | +| `99_database_schema_permission.sql` | Final grants. | +| `06_cleanup_media_schedule.sql` | Optional: schedules the `cleanup-media` Edge Function (see [Usage](../guides/supabase-usage.md#media-cleanup)). | -```bash -supabase login -``` +Every script is idempotent, so re-running them is safe. `01_database_schema.sql` detects the shape of the database: it creates the 2.x schema on a fresh database, migrates a 1.x one (see [MIGRATION.md](https://github.com/insideapp-srl/flutter_supabase_chat_core/blob/main/MIGRATION.md)) and does nothing on an up-to-date one. -4. Create new project (For example `demo-chat`): +:::info Requirements +* The `pg_partman` and `pg_cron` extensions must be available: the script creates them (`pg_partman` in its own `partman` schema). +* Realtime must support **Broadcast from Database** (Supabase cloud, or self-hosted `supabase/realtime` >= v2.33): message changes are sent by a database trigger. +::: -```bash -supabase projects create demo-chat -``` +## Option A: Supabase CLI -5. Select your organization -6. Select an region -7. Insert a secure password for new Postgres database (Save this in a secure location) -8. Obtain your `REFERENCE ID` (After command select your project, for example `demo-chat`): +1. Install the [Supabase CLI](https://supabase.com/docs/guides/cli/getting-started) and log in with `supabase login` (the script opens the login flow if needed). +2. Linux/macOS only: install [`jq`](https://jqlang.org/download/). +3. Run the script from `example/utils`: ```bash -supabase projects list +cd example/utils +./supabase_prepare.sh ``` -9. Obtain your `anon` key (After command select your project, for example `demo-chat`): - -```bash -supabase projects api-keys +```powershell +cd example\utils +.\supabase_prepare.ps1 ``` -10. Edit `example project` file `example/lib/supabase_options.dart`, insert your project `{{your_project_reference_id}}` and `{{supabase_anon_key}}` - -#### Prepare Supabase project - -Inside the example project (`example/utils`) there is a script, running the latter will automatically configure the Supabase project, creating tables, security rules, buckets and everything that is necessary for the example project to function. -In order to run the script you need to be aware of the following information about your Supabase project: +It asks whether to create a new project (organization and region picked from a list with the arrow keys, database password generated for you) or to use an existing one (it then asks for its database password, which Supabase only shows at creation time). Then it: -- `host` : Project host -- `port` : Database port -- `database` : Database name -- `user` : Database user -- `password` : Database password +* fetches the project's API keys; +* writes `example/lib/supabase_options_for_test.dart` and `example/lib/main_test.dart` (both gitignored; the tracked `main.dart`/`supabase_options.dart` stay untouched), so you can run the example with `flutter run -t lib/main_test.dart`; +* writes `example/utils/project-secrets.local.json` with the project ref, URL, keys and database credentials; +* offers to apply scripts `01`…`05` and `99` with `supabase link` + `supabase db query --linked`. This goes through Supavisor's IPv4-compatible pooler, so it also works on networks without IPv6, where a direct `db..supabase.co` connection hangs. If you skip this step, it prints the commands to run later. -This information, with the exception of the password which is provided only during the creation of the database (if necessary, you can use the password reset function of your database to obtain it), can be found very easily from the Dashboard of your Supabase project: +:::danger +`project-secrets.local.json` contains secrets. It's gitignored: never commit it. +::: -![Supabase dashboard database info](/img/supabase-project-credential.png "Database info") +
+Manual project creation -#### Running prepare script +1. `supabase projects create demo-chat` (choose your organization, region, and a secure database password — save it, it won't be shown again) +2. `supabase projects list` to get the project's `REFERENCE ID` +3. `supabase projects api-keys --project-ref ` to get the `anon`/publishable key -Below are the commands for running the scripts (During execution you will be asked for the password for your database user): +
-> Required `psql` installed -> [Official documentation](https://www.postgresql.org/download/) +## Option B: psql -#### Linux +`prepare.sh`/`prepare.ps1` apply scripts `01`…`05` and `99` with [`psql`](https://www.postgresql.org/download/). They ask for the database password once (or read `PGPASSWORD`), stop at the first failing script, and can be run from any directory. ```bash -cd ./example/utils/ -./prepare.sh -h "your-postgres-host" -p your-postgres-port -d "your-postgres-database-name" -U "your-postgres-user" +./example/utils/prepare.sh -h -p -d -U ``` -#### Windows - ```powershell -cd .\example\utils\ -.\prepare.ps1 -hostname "your-postgres-host" -port your-postgres-port -database "your-postgres-database-name" -user "your-postgres-user" +.\example\utils\prepare.ps1 -hostname -port -database -user ``` +The connection parameters are in the dashboard of your project: + +![Supabase dashboard database info](/img/supabase-project-credential.png "Database info") + +Neither script applies the optional `06_cleanup_media_schedule.sql`: it needs the Edge Function deployed and two Vault secrets first (see [Usage](../guides/supabase-usage.md#media-cleanup)). -after running the database preparation script. you need to change the database schema exposure setting by adding the `chats` schema (from the supabase dashboard): +## Expose the `chats` schema + +After the scripts, add `chats` to the schemas exposed by the Data API (Dashboard → Project Settings → Data API): ![Supabase dashboard schema exposure setting](/img/supabase-schema-exposure-setting.png "Schema exposure setting") -Optional (**Only for test**): Disable email verification and save the configuration (To speed up testing and allow user registration in just one click, it is advisable to disable mailbox verification): +Optional, **for testing only**: disable email confirmation to sign up in one click. ![Supabase dashboard disable email verification](/img/supabase-disable-confirm-email.png "Disable email verification") -Read our [documentation](https://flutter-supabase-chat-core.insideapp.it) or see the [example](https://github.com/insideapp-srl/flutter_supabase_chat_core/tree/main/example) project. To run the example project you need to have your own [Supabase](https://supabase.com/dashboard/projects) project and then follow [Add Supabase to your Flutter app](https://supabase.com/docs/reference/dart/initializing), override `example/lib/supabase_options.dart`, don't commit it though 😉 +## Run the example + +```bash +cd example +flutter run -t lib/main_test.dart # after supabase_prepare.sh/.ps1 +# or, with any project: +flutter run --dart-define=SUPABASE_URL=https://.supabase.co \ + --dart-define=SUPABASE_ANON_KEY= +``` -After all of this is done you will need to register a couple of users and the example app will automatically suggest email and password on the register screen, default password is `Qawsed1-`. To set up [Supabase Security Rules](https://supabase.com/docs/guides/database/postgres/row-level-security) so users can see only the data they should see, continue with our [documentation](https://flutter-supabase-chat-core.insideapp.it/). +Create an account from the sign-in page ("Fill in a demo account" generates a name, an email and the password `Demo-password-1`). The profile is created by the `chats.handle_new_user` trigger from the sign-up metadata. To try it against a local stack with demo data, see [Testing](../guides/testing.md). diff --git a/doc/docusaurus.config.ts b/doc/docusaurus.config.ts index 7088432..4ce138d 100644 --- a/doc/docusaurus.config.ts +++ b/doc/docusaurus.config.ts @@ -4,7 +4,7 @@ import type * as Preset from '@docusaurus/preset-classic'; const config: Config = { title: 'Flutter Supabase Chat Core', - tagline: 'Dinosaurs are cool', + tagline: 'Real-time chat for Flutter on Supabase', favicon: 'img/favicon.ico', // Set the production url of your site here @@ -21,6 +21,17 @@ const config: Config = { onBrokenLinks: 'throw', onBrokenMarkdownLinks: 'warn', + headTags: [ + { + tagName: 'link', + attributes: { + rel: 'apple-touch-icon', + sizes: '180x180', + href: '/img/apple-touch-icon.png', + }, + }, + ], + // Even if you don't use internationalization, you can use this field to set // useful metadata like html lang. For example, if your site is Chinese, you // may want to replace "en" with "zh-Hans". @@ -48,7 +59,7 @@ const config: Config = { ], themeConfig: { - // Replace with your project's social card + // Link preview image (example app screenshot). image: 'img/social-card.png', colorMode: { defaultMode: 'dark', @@ -56,10 +67,11 @@ const config: Config = { respectPrefersColorScheme: false, }, navbar: { - title: 'Flutter Supabase Chat Core', + title: 'Supabase Chat Core', logo: { alt: 'Flyer Chat Logo', - src: 'img/logo.svg', + src: 'img/flyer-chat-logo-dark.svg', + srcDark: 'img/flyer-chat-logo-light.svg', }, items: [ { @@ -106,15 +118,6 @@ const config: Config = { }, ], }, - { - title: 'More', - items: [ - { - label: 'Blog', - to: 'https://blog.insideapp.it/', - }, - ], - }, ], copyright: `Copyright © ${new Date().getFullYear()} My Project, Inc. Built with Docusaurus.`, }, diff --git a/doc/package-lock.json b/doc/package-lock.json index 2a1c160..927098f 100644 --- a/doc/package-lock.json +++ b/doc/package-lock.json @@ -8,8 +8,8 @@ "name": "flutter-supabase-chat-core", "version": "1.4.3", "dependencies": { - "@docusaurus/core": "3.9.2", - "@docusaurus/preset-classic": "3.9.2", + "@docusaurus/core": "3.10.2", + "@docusaurus/preset-classic": "3.10.2", "@mdx-js/react": "^3.1.0", "clsx": "^2.1.1", "prism-react-renderer": "^2.4.1", @@ -17,56 +17,71 @@ "react-dom": "^18.3.1" }, "devDependencies": { - "@docusaurus/module-type-aliases": "3.9.2", - "@docusaurus/tsconfig": "3.9.2", - "@docusaurus/types": "3.9.2", + "@docusaurus/module-type-aliases": "3.10.2", + "@docusaurus/tsconfig": "3.10.2", + "@docusaurus/types": "3.10.2", "typescript": "~5.7.2" }, "engines": { "node": ">=18.0" } }, + "node_modules/@11ty/gray-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@11ty/gray-matter/-/gray-matter-1.0.0.tgz", + "integrity": "sha512-7mJJl+wf1AByoT0PknQiQfOPnVNT4fevGrUBVWO4HXsnYn1aQPyRyrELYrNUFleUBM++KzMKN6QaxHPk0t/6/g==", + "license": "MIT", + "dependencies": { + "js-yaml": "^4.1.0", + "kind-of": "^6.0.3", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + }, + "engines": { + "node": ">=11" + } + }, "node_modules/@algolia/abtesting": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.16.0.tgz", - "integrity": "sha512-alHFZ68/i9qLC/muEB07VQ9r7cB8AvCcGX6dVQi2PNHhc/ZQRmmFAv8KK1ay4UiseGSFr7f0nXBKsZ/jRg7e4g==", + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.25.0.tgz", + "integrity": "sha512-rSTin9Uta23uaewYVQEp8XI9T3iA/zrg0/1G2vhf8oFFDxFL5vybnZ5IQwsVAg4JpKxPX4/WYNKdcfWrZymk7w==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.0", - "@algolia/requester-browser-xhr": "5.50.0", - "@algolia/requester-fetch": "5.50.0", - "@algolia/requester-node-http": "5.50.0" + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/autocomplete-core": { - "version": "1.19.2", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.19.2.tgz", - "integrity": "sha512-mKv7RyuAzXvwmq+0XRK8HqZXt9iZ5Kkm2huLjgn5JoCPtDy+oh9yxUMfDDaVCw0oyzZ1isdJBc7l9nuCyyR7Nw==", + "version": "1.19.10", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.19.10.tgz", + "integrity": "sha512-K8uQ5iZCCMeqqE0JxjvcxJzDFuqZC1LTGJvR2P76ofDoOdKTs6jqoNxG5MnhzlTkNfE98VOQjcrg7qLxXPpT7g==", "license": "MIT", "dependencies": { - "@algolia/autocomplete-plugin-algolia-insights": "1.19.2", - "@algolia/autocomplete-shared": "1.19.2" + "@algolia/autocomplete-plugin-algolia-insights": "1.19.10", + "@algolia/autocomplete-shared": "1.19.10" } }, "node_modules/@algolia/autocomplete-plugin-algolia-insights": { - "version": "1.19.2", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.19.2.tgz", - "integrity": "sha512-TjxbcC/r4vwmnZaPwrHtkXNeqvlpdyR+oR9Wi2XyfORkiGkLTVhX2j+O9SaCCINbKoDfc+c2PB8NjfOnz7+oKg==", + "version": "1.19.10", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.19.10.tgz", + "integrity": "sha512-zy/cYkMKEDdT4ns/SZC6by99qaIzLCTtckPw7cTe8BMPepwXjjQdhRyBCk2CJodMQc4Ktb0zJrt2YxTtMYa4Gg==", "license": "MIT", "dependencies": { - "@algolia/autocomplete-shared": "1.19.2" + "@algolia/autocomplete-shared": "1.19.10" }, "peerDependencies": { "search-insights": ">= 1 < 3" } }, "node_modules/@algolia/autocomplete-shared": { - "version": "1.19.2", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.19.2.tgz", - "integrity": "sha512-jEazxZTVD2nLrC+wYlVHQgpBoBB5KPStrJxLzsIFl6Kqd1AlG9sIAGl39V5tECLpIQzB3Qa2T6ZPJ1ChkwMK/w==", + "version": "1.19.10", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.19.10.tgz", + "integrity": "sha512-vGJ2MDLgDOK3k256ks6t8u07bX4jdRQ0B/8MuRjjcvGTveVWwOU5lNd564uyuuqPMXQITl03HJx/juMtIFcQRw==", "license": "MIT", "peerDependencies": { "@algolia/client-search": ">= 4.9.1 < 6", @@ -74,99 +89,99 @@ } }, "node_modules/@algolia/client-abtesting": { - "version": "5.50.0", - "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.50.0.tgz", - "integrity": "sha512-mfgUdLQNxOAvCZUGzPQxjahEWEPuQkKlV0ZtGmePOa9ZxIQZlk31vRBNbM6ScU8jTH41SCYE77G/lCifDr1SVw==", + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.59.0.tgz", + "integrity": "sha512-bm2XN0hCSMYwStSsCBT0/PUB2BDxoyR1Lnub3c392HMEy9bi8PUSW8vR6zltVEKWG2t4PQFWMD5C07bmJxGgPg==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.0", - "@algolia/requester-browser-xhr": "5.50.0", - "@algolia/requester-fetch": "5.50.0", - "@algolia/requester-node-http": "5.50.0" + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-analytics": { - "version": "5.50.0", - "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.50.0.tgz", - "integrity": "sha512-5mjokeKYyPaP3Q8IYJEnutI+O4dW/Ixxx5IgsSxT04pCfGqPXxTOH311hTQxyNpcGGEOGrMv8n8Z+UMTPamioQ==", + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.59.0.tgz", + "integrity": "sha512-XOFPOTa69WuqHR6c5tMgnUUwwqQgNSzMpxmhrgA9KmxRf8WIqEa0cokHJvohk5CYb7CZx0xeSL6Bk2IUJ7Lv7Q==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.0", - "@algolia/requester-browser-xhr": "5.50.0", - "@algolia/requester-fetch": "5.50.0", - "@algolia/requester-node-http": "5.50.0" + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-common": { - "version": "5.50.0", - "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.50.0.tgz", - "integrity": "sha512-emtOvR6dl3rX3sBJXXbofMNHU1qMQqQSWu319RMrNL5BWoBqyiq7y0Zn6cjJm7aGHV/Qbf+KCCYeWNKEMPI3BQ==", + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.59.0.tgz", + "integrity": "sha512-PC8ipLOYFKRTfIUY1J3FJxS6ryzWziaXmIX9/sNMoUR8L+XhF7hX2QAeUI87YVl5zujvlYTSOb+HAIqKXDjyHQ==", "license": "MIT", "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-insights": { - "version": "5.50.0", - "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.50.0.tgz", - "integrity": "sha512-IerGH2/hcj/6bwkpQg/HHRqmlGN1XwygQWythAk0gZFBrghs9danJaYuSS3ShzLSVoIVth4jY5GDPX9Lbw5cgg==", + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.59.0.tgz", + "integrity": "sha512-yFNcCMM5fHiyoR0HuxMrzy+VjDcmhFUZTm2IJ2DHwGsVH3B5SEob4zTmeEZ3j/AZqNnmrJOCKl/tJnBg7+84bA==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.0", - "@algolia/requester-browser-xhr": "5.50.0", - "@algolia/requester-fetch": "5.50.0", - "@algolia/requester-node-http": "5.50.0" + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-personalization": { - "version": "5.50.0", - "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.50.0.tgz", - "integrity": "sha512-3idPJeXn5L0MmgP9jk9JJqblrQ/SguN93dNK9z9gfgyupBhHnJMOEjrRYcVgTIfvG13Y04wO+Q0FxE2Ut8PVbA==", + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.59.0.tgz", + "integrity": "sha512-GYja6HkDt2VrQhWmB2cLx3Z5fDwI9no7q+xwCWcFrTPm5CLH9QZvK0XLO9co1FcNIDxxkMaP3AXM39/XcecEOg==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.0", - "@algolia/requester-browser-xhr": "5.50.0", - "@algolia/requester-fetch": "5.50.0", - "@algolia/requester-node-http": "5.50.0" + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-query-suggestions": { - "version": "5.50.0", - "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.50.0.tgz", - "integrity": "sha512-q7qRoWrQK1a8m5EFQEmPlo7+pg9mVQ8X5jsChtChERre0uS2pdYEDixBBl0ydBSGkdGbLUDufcACIhH/077E4g==", + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.59.0.tgz", + "integrity": "sha512-Wofg7bMpWh8N5qDDZs0wy6whc+KMmdNsxgrIGp9Ug2s1Bka0uq7AjyckdxReKPHLA0Q1qH8X/SNh0wn2t2X2Lw==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.0", - "@algolia/requester-browser-xhr": "5.50.0", - "@algolia/requester-fetch": "5.50.0", - "@algolia/requester-node-http": "5.50.0" + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-search": { - "version": "5.50.0", - "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.50.0.tgz", - "integrity": "sha512-Jc360x4yqb3eEg4OY4KEIdGePBxZogivKI+OGIU8aLXgAYPTECvzeOBc90312yHA1hr3AeRlAFl0rIc8lQaIrQ==", + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.59.0.tgz", + "integrity": "sha512-fHnALZfbEnODczGk14Y/1YBRApp6UEpZUTexGcMUPzY7RDc7q4HN2Y6jh0KUG8Jo9B+q+wOoQEc3ziR0ErbuFg==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.0", - "@algolia/requester-browser-xhr": "5.50.0", - "@algolia/requester-fetch": "5.50.0", - "@algolia/requester-node-http": "5.50.0" + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" }, "engines": { "node": ">= 14.0.0" @@ -179,93 +194,93 @@ "license": "MIT" }, "node_modules/@algolia/ingestion": { - "version": "1.50.0", - "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.50.0.tgz", - "integrity": "sha512-OS3/Viao+NPpyBbEY3tf6hLewppG+UclD+9i0ju56mq2DrdMJFCkEky6Sk9S5VPcbLzxzg3BqBX6u9Q35w19aQ==", + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.59.0.tgz", + "integrity": "sha512-Fa38s1mHgoaLCT117sfJ6P78rtxUt93CYxBYpq1VOIcslaF+cH+1h5uikAPsKfxLnsuEDZHiBiaI6eNPTsQMRA==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.0", - "@algolia/requester-browser-xhr": "5.50.0", - "@algolia/requester-fetch": "5.50.0", - "@algolia/requester-node-http": "5.50.0" + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/monitoring": { - "version": "1.50.0", - "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.50.0.tgz", - "integrity": "sha512-/znwgSiGufpbJVIoDmeQaHtTq+OMdDawFRbMSJVv+12n79hW+qdQXS8/Uu3BD3yn0BzgVFJEvrsHrCsInZKdhw==", + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.59.0.tgz", + "integrity": "sha512-NyNsRSqM2tF1MX7ZGw/j4rduoEJiQ5wfvbSo/CFVdEzYCjyxbGFRPOZyS/GETJG9rk1TdHOq8NZqKqk/u1fm4g==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.0", - "@algolia/requester-browser-xhr": "5.50.0", - "@algolia/requester-fetch": "5.50.0", - "@algolia/requester-node-http": "5.50.0" + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/recommend": { - "version": "5.50.0", - "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.50.0.tgz", - "integrity": "sha512-dHjUfu4jfjdQiKDpCpAnM7LP5yfG0oNShtfpF5rMCel6/4HIoqJ4DC4h5GKDzgrvJYtgAhblo0AYBmOM00T+lQ==", + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.59.0.tgz", + "integrity": "sha512-nXBK2uygWtvbCOffMWqqfjjcEtp9enDKY5/2Pw/Hhw8VVaOyK2ThbGK04bNX/Le+qoK1VHYkodWGVTKfw0Otkw==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.0", - "@algolia/requester-browser-xhr": "5.50.0", - "@algolia/requester-fetch": "5.50.0", - "@algolia/requester-node-http": "5.50.0" + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-browser-xhr": { - "version": "5.50.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.50.0.tgz", - "integrity": "sha512-bffIbUljAWnh/Ctu5uScORajuUavqmZ0ACYd1fQQeSSYA9NNN83ynO26pSc2dZRXpSK0fkc1//qSSFXMKGu+aw==", + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.59.0.tgz", + "integrity": "sha512-yb+4afX/zja8QwX0KmV4/ae2kyYkRknsE79CRusYS2U5D8qwn2wq0cn1x12f3tm3F3+5FgmDdbuGdQTuCLSKMQ==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.0" + "@algolia/client-common": "5.59.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-fetch": { - "version": "5.50.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.50.0.tgz", - "integrity": "sha512-y0EwNvPGvkM+yTAqqO6Gpt9wVGm3CLDtpLvNEiB3VGvN3WzfkjZGtLUsG/ru2kVJIIU7QcV0puuYgEpBeFxcJg==", + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.59.0.tgz", + "integrity": "sha512-Lp52TmpA1QtNmdHzs505Xwf4tgII7RAapkDSF9AAtWIBETcGaTIaXci4Rd9nS6SdaWK1BWTSqAPp5wzh6UN3Ag==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.0" + "@algolia/client-common": "5.59.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-node-http": { - "version": "5.50.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.50.0.tgz", - "integrity": "sha512-xpwefe4fCOWnZgXCbkGpqQY6jgBSCf2hmgnySbyzZIccrv3SoashHKGPE4x6vVG+gdHrGciMTAcDo9HOZwH22Q==", + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.59.0.tgz", + "integrity": "sha512-YYHLEs5rC6oRTFwT7bJaKBR9NSFzikeVHGAT1ffATmUQzR8XqyhHXjoieAUoR7fBU6I5cfy1FVeh5GoRgriuzA==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.50.0" + "@algolia/client-common": "5.59.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -274,29 +289,29 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -322,13 +337,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -338,25 +353,38 @@ } }, "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", - "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz", + "integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-builder-binary-assignment-operator-visitor": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.25.9.tgz", + "integrity": "sha512-C47lC7LIDCnz0h4vai/tpNOI95tCd5ZT3iBt/DBH5lXKHZsyNQv18yf1wIIg2ntiQNgmAvA+DgZ82iW8Qdym8g==", "license": "MIT", "dependencies": { - "@babel/types": "^7.27.3" + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -375,17 +403,17 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", - "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.25.9.tgz", + "integrity": "sha512-UTZQMvt0d/rSz6KI+qdu7GQze5TIajwTS++GUozlw8VBJDEOAqSXwm1WvmYEZwqdqSGQshRocPDqrt4HBZB3fQ==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/helper-replace-supers": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/traverse": "^7.28.6", + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-member-expression-to-functions": "^7.25.9", + "@babel/helper-optimise-call-expression": "^7.25.9", + "@babel/helper-replace-supers": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", + "@babel/traverse": "^7.25.9", "semver": "^6.3.1" }, "engines": { @@ -405,13 +433,13 @@ } }, "node_modules/@babel/helper-create-regexp-features-plugin": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", - "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.25.9.tgz", + "integrity": "sha512-ORPNZ3h6ZRkOyAa/SaHU+XsLZr0UQzRwuDQ0cczIA17nAzZ+85G5cVkOJIj7QavLZGSe8QXUmNFxSZzjcZF9bw==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "regexpu-core": "^6.3.1", + "@babel/helper-annotate-as-pure": "^7.25.9", + "regexpu-core": "^6.1.1", "semver": "^6.3.1" }, "engines": { @@ -447,49 +475,49 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", - "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.9.tgz", + "integrity": "sha512-wbfdZ9w5vk0C0oyHqAJbc62+vet5prjj01jjJ8sKn3j9h3MQQlflEdXYvuqRWjHnM12coDEqiC1IRCi0U/EKwQ==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5" + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -499,35 +527,35 @@ } }, "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", - "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.25.9.tgz", + "integrity": "sha512-FIpuNaz5ow8VyrYcnXQTDRGvV6tTjkNtCK/RYNDXGSLlUD6cBuQTSw43CShGxjvfBTfcUA/r6UhUCbtYqkhcuQ==", "license": "MIT", "dependencies": { - "@babel/types": "^7.27.1" + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-remap-async-to-generator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", - "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.25.9.tgz", + "integrity": "sha512-IZtukuUeBbhgOcaW2s06OXTzVNJR0ybm4W5xC1opWFFJMZbwRj5LCk+ByYH7WdZPZTt8KnFwA8pvjN2yqcPlgw==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-wrap-function": "^7.27.1", - "@babel/traverse": "^7.27.1" + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-wrap-function": "^7.25.9", + "@babel/traverse": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -537,14 +565,14 @@ } }, "node_modules/@babel/helper-replace-supers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", - "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.25.9.tgz", + "integrity": "sha512-IiDqTOTBQy0sWyeXyGSC5TBJpGFXBkRynjBeXsvbhQFKj2viwJC76Epz35YLU1fpe/Am6Vppb7W7zM4fPQzLsQ==", "license": "MIT", "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/traverse": "^7.28.6" + "@babel/helper-member-expression-to-functions": "^7.25.9", + "@babel/helper-optimise-call-expression": "^7.25.9", + "@babel/traverse": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -553,80 +581,93 @@ "@babel/core": "^7.0.0" } }, + "node_modules/@babel/helper-simple-access": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.25.9.tgz", + "integrity": "sha512-c6WHXuiaRsJTyHYLJV75t9IqsmTbItYfdj99PnzYGQZkYKvan5/2jKJ7gu31J3/BJ/A18grImSPModuyG/Eo0Q==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", - "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.25.9.tgz", + "integrity": "sha512-K4Du3BFa3gvyhzgPcntrkDgZzQaq6uozzcpGbOO1OEJaI+EJdqWIMTLgFgQf6lrfiDFo5FU+BxKepI9RmZqahA==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-wrap-function": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz", - "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.25.9.tgz", + "integrity": "sha512-ETzz9UTjQSTmw39GboatdymDq4XIQbR8ySgVrylRhPOFpsd+JrKHIuF0de7GCWmem+T4uC5z7EZguod7Wj4A4g==", "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.25.9", + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -636,13 +677,13 @@ } }, "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz", - "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.9.tgz", + "integrity": "sha512-ZkRyVkThtxQ/J6nv3JFYv1RYY+JT5BvU0y3k5bWrmuG4woXypRa4PXmm9RhOwodRkYFWqC0C0cqcJ4OqR7kW+g==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.28.5" + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/traverse": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -652,12 +693,12 @@ } }, "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", - "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.25.9.tgz", + "integrity": "sha512-MrGRLZxLD/Zjj0gdU15dfs+HH/OXvnw/U4jJD8vpcP2CJQapPEv1IWwjc/qMg7ItBlPwSv1hRBbb7LeuANdcnw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -667,12 +708,12 @@ } }, "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", - "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.25.9.tgz", + "integrity": "sha512-2qUwwfAFpJLZqxd02YW9btUCZHl+RFvdDkNfZwaIJrvB8Tesjsk8pEQkTvGwZXLqXUx/2oyY3ySRhm6HOXuCug==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -682,14 +723,14 @@ } }, "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", - "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.25.9.tgz", + "integrity": "sha512-6xWgLZTJXwilVjlnV7ospI3xi+sl8lN8rXXbBD6vYn3UYDlGsag8wrZkKcSI8G6KgqKP7vNFaDgeDnfAABq61g==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/plugin-transform-optional-chaining": "^7.27.1" + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", + "@babel/plugin-transform-optional-chaining": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -699,13 +740,13 @@ } }, "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz", - "integrity": "sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.25.9.tgz", + "integrity": "sha512-aLnMXYPnzwwqhYSCyXfKkIkYgJ8zv9RK+roo9DkTXz38ynIhd9XCbN08s3MGvqL2MYGVUGdRQLL/JqBIeJhJBg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/traverse": "^7.28.6" + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/traverse": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -739,12 +780,12 @@ } }, "node_modules/@babel/plugin-syntax-import-assertions": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz", - "integrity": "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.26.0.tgz", + "integrity": "sha512-QCWT5Hh830hK5EQa7XzuqIkQU9tT/whqbDz7kuaZMHFl1inRRg7JnuAEOQ0Ur0QUl0NufCk1msK2BeY79Aj/eg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -754,12 +795,12 @@ } }, "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", - "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.26.0.tgz", + "integrity": "sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -769,12 +810,12 @@ } }, "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", - "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.25.9.tgz", + "integrity": "sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -784,12 +825,12 @@ } }, "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", - "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.25.9.tgz", + "integrity": "sha512-hjMgRy5hb8uJJjUcdWunWVcoi9bGpJp8p5Ol1229PoN6aytsLwNMgmdftO23wnCLMfVmTwZDWMPNq/D1SY60JQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -815,12 +856,12 @@ } }, "node_modules/@babel/plugin-transform-arrow-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", - "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.25.9.tgz", + "integrity": "sha512-6jmooXYIwn9ca5/RylZADJ+EnSxVUS5sjeJ9UPk6RWRzXCmOJCy6dqItPJFpw2cuCangPK4OYr5uhGKcmrm5Qg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -830,14 +871,14 @@ } }, "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.0.tgz", - "integrity": "sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.25.9.tgz", + "integrity": "sha512-RXV6QAzTBbhDMO9fWwOmwwTuYaiPbggWQ9INdZqAYeSHyG7FzQ+nOZaUUjNwKv9pV3aE4WFqFm1Hnbci5tBCAw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-remap-async-to-generator": "^7.27.1", - "@babel/traverse": "^7.29.0" + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-remap-async-to-generator": "^7.25.9", + "@babel/traverse": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -847,14 +888,14 @@ } }, "node_modules/@babel/plugin-transform-async-to-generator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz", - "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.25.9.tgz", + "integrity": "sha512-NT7Ejn7Z/LjUH0Gv5KsBCxh7BH3fbLTV0ptHvpeMvrt3cPThHfJfst9Wrb7S8EvJ7vRTFI7z+VAvFVEQn/m5zQ==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-remap-async-to-generator": "^7.27.1" + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-remap-async-to-generator": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -864,12 +905,12 @@ } }, "node_modules/@babel/plugin-transform-block-scoped-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", - "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.25.9.tgz", + "integrity": "sha512-toHc9fzab0ZfenFpsyYinOX0J/5dgJVA2fm64xPewu7CoYHWEivIWKxkK2rMi4r3yQqLnVmheMXRdG+k239CgA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -879,12 +920,12 @@ } }, "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz", - "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.25.9.tgz", + "integrity": "sha512-1F05O7AYjymAtqbsFETboN1NvBdcnzMerO+zlMyJBEz6WkMdejvGWw9p05iTSjC85RLlBseHHQpYaM4gzJkBGg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -894,13 +935,13 @@ } }, "node_modules/@babel/plugin-transform-class-properties": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz", - "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.25.9.tgz", + "integrity": "sha512-bbMAII8GRSkcd0h0b4X+36GksxuheLFjP65ul9w6C3KgAamI3JqErNgSrosX6ZPj+Mpim5VvEbawXxJCyEUV3Q==", "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -910,13 +951,13 @@ } }, "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz", - "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.26.0.tgz", + "integrity": "sha512-6J2APTs7BDDm+UMqP1useWqhcRAXo0WIoVj26N7kPFB6S73Lgvyka4KTZYIxtgYXiN5HTyRObA72N2iu628iTQ==", "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -926,17 +967,17 @@ } }, "node_modules/@babel/plugin-transform-classes": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz", - "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.25.9.tgz", + "integrity": "sha512-mD8APIXmseE7oZvZgGABDyM34GUmK45Um2TXiBUt7PnuAxrgoSVf123qUzPxEr/+/BHrRn5NMZCdE2m/1F8DGg==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-globals": "^7.28.0", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-replace-supers": "^7.28.6", - "@babel/traverse": "^7.28.6" + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-replace-supers": "^7.25.9", + "@babel/traverse": "^7.25.9", + "globals": "^11.1.0" }, "engines": { "node": ">=6.9.0" @@ -946,13 +987,13 @@ } }, "node_modules/@babel/plugin-transform-computed-properties": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz", - "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.25.9.tgz", + "integrity": "sha512-HnBegGqXZR12xbcTHlJ9HGxw1OniltT26J5YpfruGqtUHlz/xKf/G2ak9e+t0rVqrjXa9WOhvYPz1ERfMj23AA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/template": "^7.28.6" + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/template": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -962,13 +1003,12 @@ } }, "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", - "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.25.9.tgz", + "integrity": "sha512-WkCGb/3ZxXepmMiX101nnGiU+1CAdut8oHyEOHxkKuS1qKpU2SMXE2uSvfz8PBuLd49V6LEsbtyPhWC7fnkgvQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.28.5" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -978,13 +1018,13 @@ } }, "node_modules/@babel/plugin-transform-dotall-regex": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz", - "integrity": "sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.25.9.tgz", + "integrity": "sha512-t7ZQ7g5trIgSRYhI9pIJtRl64KHotutUJsh4Eze5l7olJv+mRSg4/MmbZ0tv1eeqRbdvo/+trvJD/Oc5DmW2cA==", "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -994,12 +1034,12 @@ } }, "node_modules/@babel/plugin-transform-duplicate-keys": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", - "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.25.9.tgz", + "integrity": "sha512-LZxhJ6dvBb/f3x8xwWIuyiAHy56nrRG3PeYTpBkkzkYRRQ6tJLu68lEF5VIqMUZiAV7a8+Tb78nEoMCMcqjXBw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1009,13 +1049,13 @@ } }, "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.0.tgz", - "integrity": "sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.25.9.tgz", + "integrity": "sha512-0UfuJS0EsXbRvKnwcLjFtJy/Sxc5J5jhLHnFhy7u4zih97Hz6tJkLU+O+FMMrNZrosUPxDi6sYxJ/EA8jDiAog==", "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1025,28 +1065,12 @@ } }, "node_modules/@babel/plugin-transform-dynamic-import": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", - "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-explicit-resource-management": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz", - "integrity": "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.25.9.tgz", + "integrity": "sha512-GCggjexbmSLaFhqsojeugBpeaRIgWNTcgKVq/0qIteFEqY2A+b9QidYadrWlnbWQUrW5fn+mCvf3tr7OeBFTyg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/plugin-transform-destructuring": "^7.28.5" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1056,12 +1080,13 @@ } }, "node_modules/@babel/plugin-transform-exponentiation-operator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz", - "integrity": "sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.25.9.tgz", + "integrity": "sha512-KRhdhlVk2nObA5AYa7QMgTMTVJdfHprfpAk4DjZVtllqRg9qarilstTKEhpVjyt+Npi8ThRyiV8176Am3CodPA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-builder-binary-assignment-operator-visitor": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1071,12 +1096,12 @@ } }, "node_modules/@babel/plugin-transform-export-namespace-from": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", - "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.25.9.tgz", + "integrity": "sha512-2NsEz+CxzJIVOPx2o9UsW1rXLqtChtLoVnwYHHiB04wS5sgn7mrV45fWMBX0Kk+ub9uXytVYfNP2HjbVbCB3Ww==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1086,13 +1111,13 @@ } }, "node_modules/@babel/plugin-transform-for-of": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", - "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.25.9.tgz", + "integrity": "sha512-LqHxduHoaGELJl2uhImHwRQudhCM50pT46rIBNvtT/Oql3nqiS3wOwP+5ten7NpYSXrrVLgtZU3DZmPtWZo16A==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1102,14 +1127,14 @@ } }, "node_modules/@babel/plugin-transform-function-name": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", - "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.25.9.tgz", + "integrity": "sha512-8lP+Yxjv14Vc5MuWBpJsoUCd3hD6V9DgBon2FVYL4jJgbnVQ9fTgYmonchzZJOVNgzEgbxp4OwAf6xz6M/14XA==", "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.27.1" + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/traverse": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1119,12 +1144,12 @@ } }, "node_modules/@babel/plugin-transform-json-strings": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz", - "integrity": "sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.25.9.tgz", + "integrity": "sha512-xoTMk0WXceiiIvsaquQQUaLLXSW1KJ159KP87VilruQm0LNNGxWzahxSS6T6i4Zg3ezp4vA4zuwiNUR53qmQAw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1134,12 +1159,12 @@ } }, "node_modules/@babel/plugin-transform-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", - "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.25.9.tgz", + "integrity": "sha512-9N7+2lFziW8W9pBl2TzaNht3+pgMIRP74zizeCSrtnSKVdUl8mAjjOP2OOVQAfZ881P2cNjDj1uAMEdeD50nuQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1149,12 +1174,12 @@ } }, "node_modules/@babel/plugin-transform-logical-assignment-operators": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz", - "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.25.9.tgz", + "integrity": "sha512-wI4wRAzGko551Y8eVf6iOY9EouIDTtPb0ByZx+ktDGHwv6bHFimrgJM/2T021txPZ2s4c7bqvHbd+vXG6K948Q==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1164,12 +1189,12 @@ } }, "node_modules/@babel/plugin-transform-member-expression-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", - "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.25.9.tgz", + "integrity": "sha512-PYazBVfofCQkkMzh2P6IdIUaCEWni3iYEerAsRWuVd8+jlM1S9S9cz1dF9hIzyoZ8IA3+OwVYIp9v9e+GbgZhA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1179,13 +1204,13 @@ } }, "node_modules/@babel/plugin-transform-modules-amd": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", - "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.25.9.tgz", + "integrity": "sha512-g5T11tnI36jVClQlMlt4qKDLlWnG5pP9CSM4GhdRciTNMRgkfpo5cR6b4rGIOYPgRRuFAvwjPQ/Yk+ql4dyhbw==", "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1195,13 +1220,14 @@ } }, "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", - "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.25.9.tgz", + "integrity": "sha512-dwh2Ol1jWwL2MgkCzUSOvfmKElqQcuswAZypBSUsScMXvgdT8Ekq5YA6TtqpTVWH+4903NmboMuH1o9i8Rxlyg==", "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-simple-access": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1211,15 +1237,15 @@ } }, "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz", - "integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.8.tgz", + "integrity": "sha512-6iSnEK0zlkLKU4heofK/AdmRD4e2SHVpJMtrwnTCzhnaM98ria4rTrOXBBi45BTTYnJtO8txnPsX4fChYXkmeA==", "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.29.0" + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.8" }, "engines": { "node": ">=6.9.0" @@ -1229,13 +1255,13 @@ } }, "node_modules/@babel/plugin-transform-modules-umd": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", - "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.25.9.tgz", + "integrity": "sha512-bS9MVObUgE7ww36HEfwe6g9WakQ0KF07mQF74uuXdkoziUPfKyu/nIm663kz//e5O1nPInPFx36z7WJmJ4yNEw==", "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1245,13 +1271,13 @@ } }, "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz", - "integrity": "sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.25.9.tgz", + "integrity": "sha512-oqB6WHdKTGl3q/ItQhpLSnWWOpjUJLsOCLVyeFgeTktkBSCiurvPOsyt93gibI9CmuKvTUEtWmG5VhZD+5T/KA==", "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1261,12 +1287,12 @@ } }, "node_modules/@babel/plugin-transform-new-target": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", - "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.25.9.tgz", + "integrity": "sha512-U/3p8X1yCSoKyUj2eOBIx3FOn6pElFOKvAAGf8HTtItuPyB+ZeOqfn+mvTtg9ZlOAjsPdK3ayQEjqHjU/yLeVQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1276,12 +1302,12 @@ } }, "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz", - "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.25.9.tgz", + "integrity": "sha512-ENfftpLZw5EItALAD4WsY/KUWvhUlZndm5GC7G3evUsVeSJB6p0pBeLQUnRnBCBx7zV0RKQjR9kCuwrsIrjWog==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1291,12 +1317,12 @@ } }, "node_modules/@babel/plugin-transform-numeric-separator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz", - "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.25.9.tgz", + "integrity": "sha512-TlprrJ1GBZ3r6s96Yq8gEQv82s8/5HnCVHtEJScUj90thHQbwe+E5MLhi2bbNHBEJuzrvltXSru+BUxHDoog7Q==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1306,16 +1332,14 @@ } }, "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz", - "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.25.9.tgz", + "integrity": "sha512-fSaXafEE9CVHPweLYw4J0emp1t8zYTXyzN3UuG+lylqkvYd7RMrsOQ8TYx5RF231be0vqtFC6jnx3UmpJmKBYg==", "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/plugin-transform-destructuring": "^7.28.5", - "@babel/plugin-transform-parameters": "^7.27.7", - "@babel/traverse": "^7.28.6" + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/plugin-transform-parameters": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1325,13 +1349,13 @@ } }, "node_modules/@babel/plugin-transform-object-super": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", - "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.25.9.tgz", + "integrity": "sha512-Kj/Gh+Rw2RNLbCK1VAWj2U48yxxqL2x0k10nPtSdRa0O2xnHXalD0s+o1A6a0W43gJ00ANo38jxkQreckOzv5A==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-replace-supers": "^7.27.1" + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-replace-supers": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1341,12 +1365,12 @@ } }, "node_modules/@babel/plugin-transform-optional-catch-binding": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz", - "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.25.9.tgz", + "integrity": "sha512-qM/6m6hQZzDcZF3onzIhZeDHDO43bkNNlOX0i8n3lR6zLbu0GN2d8qfM/IERJZYauhAHSLHy39NF0Ctdvcid7g==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1356,13 +1380,13 @@ } }, "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz", - "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.25.9.tgz", + "integrity": "sha512-6AvV0FsLULbpnXeBjrY4dmWF8F7gf8QnvTEoO/wX/5xm/xE1Xo8oPuD3MPS+KS9f9XBEAWN7X1aWr4z9HdOr7A==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1372,12 +1396,12 @@ } }, "node_modules/@babel/plugin-transform-parameters": { - "version": "7.27.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", - "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.25.9.tgz", + "integrity": "sha512-wzz6MKwpnshBAiRmn4jR8LYz/g8Ksg0o80XmwZDlordjwEk9SxBzTWC7F5ef1jhbrbOW2DJ5J6ayRukrJmnr0g==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1387,13 +1411,13 @@ } }, "node_modules/@babel/plugin-transform-private-methods": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz", - "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.25.9.tgz", + "integrity": "sha512-D/JUozNpQLAPUVusvqMxyvjzllRaF8/nSrP1s2YGQT/W4LHK4xxsMcHjhOGTS01mp9Hda8nswb+FblLdJornQw==", "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1403,14 +1427,14 @@ } }, "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz", - "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.25.9.tgz", + "integrity": "sha512-Evf3kcMqzXA3xfYJmZ9Pg1OvKdtqsDMSWBDzZOPLvHiTt36E75jLDQo5w1gtRU95Q4E5PDttrTf25Fw8d/uWLw==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1420,12 +1444,12 @@ } }, "node_modules/@babel/plugin-transform-property-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", - "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.25.9.tgz", + "integrity": "sha512-IvIUeV5KrS/VPavfSM/Iu+RE6llrHrYIKY1yfCzyO/lMXHQ+p7uGhonmGVisv6tSBSVgWzMBohTcvkC9vQcQFA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1435,12 +1459,12 @@ } }, "node_modules/@babel/plugin-transform-react-constant-elements": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.27.1.tgz", - "integrity": "sha512-edoidOjl/ZxvYo4lSBOQGDSyToYVkTAwyVoa2tkuYTSmjrB1+uAedoL5iROVLXkxH+vRgA7uP4tMg2pUJpZ3Ug==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.25.9.tgz", + "integrity": "sha512-Ncw2JFsJVuvfRsa2lSHiC55kETQVLSnsYGQ1JDDwkUeWGTL/8Tom8aLTnlqgoeuopWrbbGndrc9AlLYrIosrow==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1450,12 +1474,12 @@ } }, "node_modules/@babel/plugin-transform-react-display-name": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz", - "integrity": "sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.25.9.tgz", + "integrity": "sha512-KJfMlYIUxQB1CJfO3e0+h0ZHWOTLCPP115Awhaz8U0Zpq36Gl/cXlpoyMRnUWlhNUBAzldnCiAZNvCDj7CrKxQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1465,16 +1489,16 @@ } }, "node_modules/@babel/plugin-transform-react-jsx": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.28.6.tgz", - "integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.25.9.tgz", + "integrity": "sha512-s5XwpQYCqGerXl+Pu6VDL3x0j2d82eiV77UJ8a2mDHAW7j9SWRqQ2y1fNo1Z74CdcYipl5Z41zvjj4Nfzq36rw==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/plugin-syntax-jsx": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/plugin-syntax-jsx": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1484,12 +1508,12 @@ } }, "node_modules/@babel/plugin-transform-react-jsx-development": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz", - "integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.25.9.tgz", + "integrity": "sha512-9mj6rm7XVYs4mdLIpbZnHOYdpW42uoiBCTVowg7sP1thUOiANgMb4UtpRivR0pp5iL+ocvUv7X4mZgFRpJEzGw==", "license": "MIT", "dependencies": { - "@babel/plugin-transform-react-jsx": "^7.27.1" + "@babel/plugin-transform-react-jsx": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1499,13 +1523,13 @@ } }, "node_modules/@babel/plugin-transform-react-pure-annotations": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.27.1.tgz", - "integrity": "sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.25.9.tgz", + "integrity": "sha512-KQ/Takk3T8Qzj5TppkS1be588lkbTp5uj7w6a0LeQaTMSckU/wK0oJ/pih+T690tkgI5jfmg2TqDJvd41Sj1Cg==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1515,12 +1539,13 @@ } }, "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz", - "integrity": "sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.25.9.tgz", + "integrity": "sha512-vwDcDNsgMPDGP0nMqzahDWE5/MLcX8sv96+wfX7as7LoF/kr97Bo/7fI00lXY4wUXYfVmwIIyG80fGZ1uvt2qg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.25.9", + "regenerator-transform": "^0.15.2" }, "engines": { "node": ">=6.9.0" @@ -1530,13 +1555,13 @@ } }, "node_modules/@babel/plugin-transform-regexp-modifiers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz", - "integrity": "sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.26.0.tgz", + "integrity": "sha512-vN6saax7lrA2yA/Pak3sCxuD6F5InBjn9IcrIKQPjpsLvuHYLVroTxjdlVRHjjBWxKOqIwpTXDkOssYT4BFdRw==", "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1546,12 +1571,12 @@ } }, "node_modules/@babel/plugin-transform-reserved-words": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", - "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.25.9.tgz", + "integrity": "sha512-7DL7DKYjn5Su++4RXu8puKZm2XBPHyjWLUidaPEkCUBbE7IPcsrkRHggAOOKydH1dASWdcUBxrkOGNxUv5P3Jg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1561,13 +1586,13 @@ } }, "node_modules/@babel/plugin-transform-runtime": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.0.tgz", - "integrity": "sha512-jlaRT5dJtMaMCV6fAuLbsQMSwz/QkvaHOHOSXRitGGwSpR1blCY4KUKoyP2tYO8vJcqYe8cEj96cqSztv3uF9w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.7.tgz", + "integrity": "sha512-xmAscdE/AsqRW7vutbPNoUmu/nF5SrLKPs7aoJgEjo35lLKA/Bc0i2rMv/hr1+Y0o1bQCiVtith3u2vdgRL39Q==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", "babel-plugin-polyfill-corejs2": "^0.4.14", "babel-plugin-polyfill-corejs3": "^0.13.0", "babel-plugin-polyfill-regenerator": "^0.6.5", @@ -1580,6 +1605,19 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-runtime/node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", + "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.5", + "core-js-compat": "^3.43.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, "node_modules/@babel/plugin-transform-runtime/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -1590,12 +1628,12 @@ } }, "node_modules/@babel/plugin-transform-shorthand-properties": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", - "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.25.9.tgz", + "integrity": "sha512-MUv6t0FhO5qHnS/W8XCbHmiRWOphNufpE1IVxhK5kuN3Td9FT1x4rx4K42s3RYdMXCXpfWkGSbCSd0Z64xA7Ng==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1605,13 +1643,13 @@ } }, "node_modules/@babel/plugin-transform-spread": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz", - "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.25.9.tgz", + "integrity": "sha512-oNknIB0TbURU5pqJFVbOOFspVlrpVwo2H1+HUIsVDvp5VauGGDP1ZEvO8Nn5xyMEs3dakajOxlmkNW7kNgSm6A==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1621,12 +1659,12 @@ } }, "node_modules/@babel/plugin-transform-sticky-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", - "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.25.9.tgz", + "integrity": "sha512-WqBUSgeVwucYDP9U/xNRQam7xV8W5Zf+6Eo7T2SRVUFlhRiMNFdFz58u0KZmCVVqs2i7SHgpRnAhzRNmKfi2uA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1636,12 +1674,12 @@ } }, "node_modules/@babel/plugin-transform-template-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", - "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.25.9.tgz", + "integrity": "sha512-o97AE4syN71M/lxrCtQByzphAdlYluKPDBzDVzMmfCobUjjhAryZV0AIpRPrxN0eAkxXO6ZLEScmt+PNhj2OTw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1651,12 +1689,12 @@ } }, "node_modules/@babel/plugin-transform-typeof-symbol": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", - "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.25.9.tgz", + "integrity": "sha512-v61XqUMiueJROUv66BVIOi0Fv/CUuZuZMl5NkRoCVxLAnMexZ0A3kMe7vvZ0nulxMuMp0Mk6S5hNh48yki08ZA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1666,16 +1704,16 @@ } }, "node_modules/@babel/plugin-transform-typescript": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz", - "integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.25.9.tgz", + "integrity": "sha512-7PbZQZP50tzv2KGGnhh82GSyMB01yKY9scIjf1a+GfZCtInOWqUH5+1EBU4t9fyR5Oykkkc9vFTs4OHrhHXljQ==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/plugin-syntax-typescript": "^7.28.6" + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", + "@babel/plugin-syntax-typescript": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1685,12 +1723,12 @@ } }, "node_modules/@babel/plugin-transform-unicode-escapes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", - "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.25.9.tgz", + "integrity": "sha512-s5EDrE6bW97LtxOcGj1Khcx5AaXwiMmi4toFWRDP9/y0Woo6pXC+iyPu/KuhKtfSrNFd7jJB+/fkOtZy6aIC6Q==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1700,13 +1738,13 @@ } }, "node_modules/@babel/plugin-transform-unicode-property-regex": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz", - "integrity": "sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.25.9.tgz", + "integrity": "sha512-Jt2d8Ga+QwRluxRQ307Vlxa6dMrYEMZCgGxoPR8V52rxPyldHu3hdlHspxaqYmE7oID5+kB+UKUB/eWS+DkkWg==", "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1716,13 +1754,13 @@ } }, "node_modules/@babel/plugin-transform-unicode-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", - "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.25.9.tgz", + "integrity": "sha512-yoxstj7Rg9dlNn9UQxzk4fcNivwv4nUYz7fYXBaKxvw/lnmPuOm/ikoELygbYq68Bls3D/D+NBPHiLwZdZZ4HA==", "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1732,13 +1770,13 @@ } }, "node_modules/@babel/plugin-transform-unicode-sets-regex": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz", - "integrity": "sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.25.9.tgz", + "integrity": "sha512-8BYqO3GeVNHtx69fdPshN3fnzUNLrWdHhk/icSwigksJGczKSizZ+Z6SBCxTs723Fr5VSNorTIK7a+R2tISvwQ==", "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1748,80 +1786,79 @@ } }, "node_modules/@babel/preset-env": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.2.tgz", - "integrity": "sha512-DYD23veRYGvBFhcTY1iUvJnDNpuqNd/BzBwCvzOTKUnJjKg5kpUBh3/u9585Agdkgj+QuygG7jLfOPWMa2KVNw==", - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5", - "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.6", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.26.0.tgz", + "integrity": "sha512-H84Fxq0CQJNdPFT2DrfnylZ3cf5K43rGfWK4LJGPpjKHiZlk0/RzwEus3PDDZZg+/Er7lCA03MVacueUuXdzfw==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.26.0", + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-validator-option": "^7.25.9", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.25.9", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.25.9", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.25.9", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.25.9", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.25.9", "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", - "@babel/plugin-syntax-import-assertions": "^7.28.6", - "@babel/plugin-syntax-import-attributes": "^7.28.6", + "@babel/plugin-syntax-import-assertions": "^7.26.0", + "@babel/plugin-syntax-import-attributes": "^7.26.0", "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", - "@babel/plugin-transform-arrow-functions": "^7.27.1", - "@babel/plugin-transform-async-generator-functions": "^7.29.0", - "@babel/plugin-transform-async-to-generator": "^7.28.6", - "@babel/plugin-transform-block-scoped-functions": "^7.27.1", - "@babel/plugin-transform-block-scoping": "^7.28.6", - "@babel/plugin-transform-class-properties": "^7.28.6", - "@babel/plugin-transform-class-static-block": "^7.28.6", - "@babel/plugin-transform-classes": "^7.28.6", - "@babel/plugin-transform-computed-properties": "^7.28.6", - "@babel/plugin-transform-destructuring": "^7.28.5", - "@babel/plugin-transform-dotall-regex": "^7.28.6", - "@babel/plugin-transform-duplicate-keys": "^7.27.1", - "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.0", - "@babel/plugin-transform-dynamic-import": "^7.27.1", - "@babel/plugin-transform-explicit-resource-management": "^7.28.6", - "@babel/plugin-transform-exponentiation-operator": "^7.28.6", - "@babel/plugin-transform-export-namespace-from": "^7.27.1", - "@babel/plugin-transform-for-of": "^7.27.1", - "@babel/plugin-transform-function-name": "^7.27.1", - "@babel/plugin-transform-json-strings": "^7.28.6", - "@babel/plugin-transform-literals": "^7.27.1", - "@babel/plugin-transform-logical-assignment-operators": "^7.28.6", - "@babel/plugin-transform-member-expression-literals": "^7.27.1", - "@babel/plugin-transform-modules-amd": "^7.27.1", - "@babel/plugin-transform-modules-commonjs": "^7.28.6", - "@babel/plugin-transform-modules-systemjs": "^7.29.0", - "@babel/plugin-transform-modules-umd": "^7.27.1", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.0", - "@babel/plugin-transform-new-target": "^7.27.1", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6", - "@babel/plugin-transform-numeric-separator": "^7.28.6", - "@babel/plugin-transform-object-rest-spread": "^7.28.6", - "@babel/plugin-transform-object-super": "^7.27.1", - "@babel/plugin-transform-optional-catch-binding": "^7.28.6", - "@babel/plugin-transform-optional-chaining": "^7.28.6", - "@babel/plugin-transform-parameters": "^7.27.7", - "@babel/plugin-transform-private-methods": "^7.28.6", - "@babel/plugin-transform-private-property-in-object": "^7.28.6", - "@babel/plugin-transform-property-literals": "^7.27.1", - "@babel/plugin-transform-regenerator": "^7.29.0", - "@babel/plugin-transform-regexp-modifiers": "^7.28.6", - "@babel/plugin-transform-reserved-words": "^7.27.1", - "@babel/plugin-transform-shorthand-properties": "^7.27.1", - "@babel/plugin-transform-spread": "^7.28.6", - "@babel/plugin-transform-sticky-regex": "^7.27.1", - "@babel/plugin-transform-template-literals": "^7.27.1", - "@babel/plugin-transform-typeof-symbol": "^7.27.1", - "@babel/plugin-transform-unicode-escapes": "^7.27.1", - "@babel/plugin-transform-unicode-property-regex": "^7.28.6", - "@babel/plugin-transform-unicode-regex": "^7.27.1", - "@babel/plugin-transform-unicode-sets-regex": "^7.28.6", + "@babel/plugin-transform-arrow-functions": "^7.25.9", + "@babel/plugin-transform-async-generator-functions": "^7.25.9", + "@babel/plugin-transform-async-to-generator": "^7.25.9", + "@babel/plugin-transform-block-scoped-functions": "^7.25.9", + "@babel/plugin-transform-block-scoping": "^7.25.9", + "@babel/plugin-transform-class-properties": "^7.25.9", + "@babel/plugin-transform-class-static-block": "^7.26.0", + "@babel/plugin-transform-classes": "^7.25.9", + "@babel/plugin-transform-computed-properties": "^7.25.9", + "@babel/plugin-transform-destructuring": "^7.25.9", + "@babel/plugin-transform-dotall-regex": "^7.25.9", + "@babel/plugin-transform-duplicate-keys": "^7.25.9", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.25.9", + "@babel/plugin-transform-dynamic-import": "^7.25.9", + "@babel/plugin-transform-exponentiation-operator": "^7.25.9", + "@babel/plugin-transform-export-namespace-from": "^7.25.9", + "@babel/plugin-transform-for-of": "^7.25.9", + "@babel/plugin-transform-function-name": "^7.25.9", + "@babel/plugin-transform-json-strings": "^7.25.9", + "@babel/plugin-transform-literals": "^7.25.9", + "@babel/plugin-transform-logical-assignment-operators": "^7.25.9", + "@babel/plugin-transform-member-expression-literals": "^7.25.9", + "@babel/plugin-transform-modules-amd": "^7.25.9", + "@babel/plugin-transform-modules-commonjs": "^7.25.9", + "@babel/plugin-transform-modules-systemjs": "^7.25.9", + "@babel/plugin-transform-modules-umd": "^7.25.9", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.25.9", + "@babel/plugin-transform-new-target": "^7.25.9", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.25.9", + "@babel/plugin-transform-numeric-separator": "^7.25.9", + "@babel/plugin-transform-object-rest-spread": "^7.25.9", + "@babel/plugin-transform-object-super": "^7.25.9", + "@babel/plugin-transform-optional-catch-binding": "^7.25.9", + "@babel/plugin-transform-optional-chaining": "^7.25.9", + "@babel/plugin-transform-parameters": "^7.25.9", + "@babel/plugin-transform-private-methods": "^7.25.9", + "@babel/plugin-transform-private-property-in-object": "^7.25.9", + "@babel/plugin-transform-property-literals": "^7.25.9", + "@babel/plugin-transform-regenerator": "^7.25.9", + "@babel/plugin-transform-regexp-modifiers": "^7.26.0", + "@babel/plugin-transform-reserved-words": "^7.25.9", + "@babel/plugin-transform-shorthand-properties": "^7.25.9", + "@babel/plugin-transform-spread": "^7.25.9", + "@babel/plugin-transform-sticky-regex": "^7.25.9", + "@babel/plugin-transform-template-literals": "^7.25.9", + "@babel/plugin-transform-typeof-symbol": "^7.25.9", + "@babel/plugin-transform-unicode-escapes": "^7.25.9", + "@babel/plugin-transform-unicode-property-regex": "^7.25.9", + "@babel/plugin-transform-unicode-regex": "^7.25.9", + "@babel/plugin-transform-unicode-sets-regex": "^7.25.9", "@babel/preset-modules": "0.1.6-no-external-plugins", - "babel-plugin-polyfill-corejs2": "^0.4.15", - "babel-plugin-polyfill-corejs3": "^0.14.0", - "babel-plugin-polyfill-regenerator": "^0.6.6", - "core-js-compat": "^3.48.0", + "babel-plugin-polyfill-corejs2": "^0.4.10", + "babel-plugin-polyfill-corejs3": "^0.10.6", + "babel-plugin-polyfill-regenerator": "^0.6.1", + "core-js-compat": "^3.38.1", "semver": "^6.3.1" }, "engines": { @@ -1831,19 +1868,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/preset-env/node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.14.2", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz", - "integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==", - "license": "MIT", - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.8", - "core-js-compat": "^3.48.0" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, "node_modules/@babel/preset-env/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -1868,17 +1892,17 @@ } }, "node_modules/@babel/preset-react": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.28.5.tgz", - "integrity": "sha512-Z3J8vhRq7CeLjdC58jLv4lnZ5RKFUJWqH5emvxmv9Hv3BD1T9R/Im713R4MTKwvFaV74ejZ3sM01LyEKk4ugNQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.25.9.tgz", + "integrity": "sha512-D3to0uSPiWE7rBrdIICCd0tJSIGpLaaGptna2+w7Pft5xMqLpA1sz99DK5TZ1TjGbdQ/VI1eCSZ06dv3lT4JOw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-transform-react-display-name": "^7.28.0", - "@babel/plugin-transform-react-jsx": "^7.27.1", - "@babel/plugin-transform-react-jsx-development": "^7.27.1", - "@babel/plugin-transform-react-pure-annotations": "^7.27.1" + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-validator-option": "^7.25.9", + "@babel/plugin-transform-react-display-name": "^7.25.9", + "@babel/plugin-transform-react-jsx": "^7.25.9", + "@babel/plugin-transform-react-jsx-development": "^7.25.9", + "@babel/plugin-transform-react-pure-annotations": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1888,16 +1912,16 @@ } }, "node_modules/@babel/preset-typescript": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz", - "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.26.0.tgz", + "integrity": "sha512-NMk1IGZ5I/oHhoXEElcm+xUnL/szL6xflkFZmoEU9xj1qSJXpiS7rsspYo92B4DRCDvZn2erT5LdsCeXAKNCkg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-syntax-jsx": "^7.27.1", - "@babel/plugin-transform-modules-commonjs": "^7.27.1", - "@babel/plugin-transform-typescript": "^7.28.5" + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-validator-option": "^7.25.9", + "@babel/plugin-syntax-jsx": "^7.25.9", + "@babel/plugin-transform-modules-commonjs": "^7.25.9", + "@babel/plugin-transform-typescript": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1907,52 +1931,40 @@ } }, "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/runtime-corejs3": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.29.2.tgz", - "integrity": "sha512-Lc94FOD5+0aXhdb0Tdg3RUtqT6yWbI/BbFWvlaSJ3gAb9Ks+99nHRDKADVqC37er4eCB0fHyWT+y+K3QOvJKbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "license": "MIT", - "dependencies": { - "core-js-pure": "^3.48.0" - }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -1960,13 +1972,13 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -2215,9 +2227,9 @@ } }, "node_modules/@csstools/postcss-cascade-layers/node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -2636,9 +2648,9 @@ } }, "node_modules/@csstools/postcss-is-pseudo-class/node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -3083,9 +3095,9 @@ } }, "node_modules/@csstools/postcss-scope-pseudo-class/node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -3306,9 +3318,9 @@ } }, "node_modules/@docsearch/core": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/@docsearch/core/-/core-4.6.2.tgz", - "integrity": "sha512-/S0e6Dj7Zcm8m9Rru49YEX49dhU11be68c+S/BCyN8zQsTTgkKzXlhRbVL5mV6lOLC2+ZRRryaTdcm070Ug2oA==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@docsearch/core/-/core-4.7.0.tgz", + "integrity": "sha512-p/9xVKmPDj3FPvMfPf5naVO3Ej8SCbcUugGvx1+8GgkuBNbqxqN2Irx3WLBv8VY0jH7XpRwKWdlmjXLZsmTLsg==", "license": "MIT", "peerDependencies": { "@types/react": ">= 16.8.0 < 20.0.0", @@ -3328,20 +3340,20 @@ } }, "node_modules/@docsearch/css": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-4.6.2.tgz", - "integrity": "sha512-fH/cn8BjEEdM2nJdjNMHIvOVYupG6AIDtFVDgIZrNzdCSj4KXr9kd+hsehqsNGYjpUjObeKYKvgy/IwCb1jZYQ==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-4.7.0.tgz", + "integrity": "sha512-Sk5xkdRFeE7PeWjG9l4AfTwdvMfr9wHiwNNCpHXT4v4SNyNMKdHGvEILc31BgaVFGDDNbv5u/a73tofRiwbEZw==", "license": "MIT" }, "node_modules/@docsearch/react": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-4.6.2.tgz", - "integrity": "sha512-/BbtGFtqVOGwZx0dw/UfhN/0/DmMQYnulY4iv0tPRhC2JCXv0ka/+izwt3Jzo1ZxXS/2eMvv9zHsBJOK1I9f/w==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-4.7.0.tgz", + "integrity": "sha512-x6oedjJ8O8/pIDBsMo5Orca3/6cQCz616/CwthVe68l43mqnj2lrJ9kFQITBqy8hMsS3nWeBWFoVO5dJ1DCFKA==", "license": "MIT", "dependencies": { "@algolia/autocomplete-core": "1.19.2", - "@docsearch/core": "4.6.2", - "@docsearch/css": "4.6.2" + "@docsearch/core": "4.7.0", + "@docsearch/css": "4.7.0" }, "peerDependencies": { "@types/react": ">= 16.8.0 < 20.0.0", @@ -3364,10 +3376,42 @@ } } }, + "node_modules/@docsearch/react/node_modules/@algolia/autocomplete-core": { + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.19.2.tgz", + "integrity": "sha512-mKv7RyuAzXvwmq+0XRK8HqZXt9iZ5Kkm2huLjgn5JoCPtDy+oh9yxUMfDDaVCw0oyzZ1isdJBc7l9nuCyyR7Nw==", + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-plugin-algolia-insights": "1.19.2", + "@algolia/autocomplete-shared": "1.19.2" + } + }, + "node_modules/@docsearch/react/node_modules/@algolia/autocomplete-plugin-algolia-insights": { + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.19.2.tgz", + "integrity": "sha512-TjxbcC/r4vwmnZaPwrHtkXNeqvlpdyR+oR9Wi2XyfORkiGkLTVhX2j+O9SaCCINbKoDfc+c2PB8NjfOnz7+oKg==", + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-shared": "1.19.2" + }, + "peerDependencies": { + "search-insights": ">= 1 < 3" + } + }, + "node_modules/@docsearch/react/node_modules/@algolia/autocomplete-shared": { + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.19.2.tgz", + "integrity": "sha512-jEazxZTVD2nLrC+wYlVHQgpBoBB5KPStrJxLzsIFl6Kqd1AlG9sIAGl39V5tECLpIQzB3Qa2T6ZPJ1ChkwMK/w==", + "license": "MIT", + "peerDependencies": { + "@algolia/client-search": ">= 4.9.1 < 6", + "algoliasearch": ">= 4.9.1 < 6" + } + }, "node_modules/@docusaurus/babel": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/babel/-/babel-3.9.2.tgz", - "integrity": "sha512-GEANdi/SgER+L7Japs25YiGil/AUDnFFHaCGPBbundxoWtCkA2lmy7/tFmgED4y1htAy6Oi4wkJEQdGssnw9MA==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/babel/-/babel-3.10.2.tgz", + "integrity": "sha512-aJ1hpGyvfkte3dDAfNbWM4biW4yWZBVz7TIGLZP+v+tWOBgxX3e0N5ZIXHIvmfNNXTI77pcHUx3KmtOk05Ze3Q==", "license": "MIT", "dependencies": { "@babel/core": "^7.25.9", @@ -3378,10 +3422,9 @@ "@babel/preset-react": "^7.25.9", "@babel/preset-typescript": "^7.25.9", "@babel/runtime": "^7.25.9", - "@babel/runtime-corejs3": "^7.25.9", "@babel/traverse": "^7.25.9", - "@docusaurus/logger": "3.9.2", - "@docusaurus/utils": "3.9.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/utils": "3.10.2", "babel-plugin-dynamic-import-node": "^2.3.3", "fs-extra": "^11.1.1", "tslib": "^2.6.0" @@ -3391,17 +3434,17 @@ } }, "node_modules/@docusaurus/bundler": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/bundler/-/bundler-3.9.2.tgz", - "integrity": "sha512-ZOVi6GYgTcsZcUzjblpzk3wH1Fya2VNpd5jtHoCCFcJlMQ1EYXZetfAnRHLcyiFeBABaI1ltTYbOBtH/gahGVA==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/bundler/-/bundler-3.10.2.tgz", + "integrity": "sha512-i0ZNcy0f0WhaOlYVgzLsWhIoEXO9kS3HRoKPtgE6vQtZUq7arKZaYdNBudr3mqCmd+TyOkwtwfHgs1ENj07r5g==", "license": "MIT", "dependencies": { "@babel/core": "^7.25.9", - "@docusaurus/babel": "3.9.2", - "@docusaurus/cssnano-preset": "3.9.2", - "@docusaurus/logger": "3.9.2", - "@docusaurus/types": "3.9.2", - "@docusaurus/utils": "3.9.2", + "@docusaurus/babel": "3.10.2", + "@docusaurus/cssnano-preset": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", "babel-loader": "^9.2.1", "clean-css": "^5.3.3", "copy-webpack-plugin": "^11.0.0", @@ -3419,7 +3462,7 @@ "tslib": "^2.6.0", "url-loader": "^4.1.1", "webpack": "^5.95.0", - "webpackbar": "^6.0.1" + "webpackbar": "^7.0.0" }, "engines": { "node": ">=20.0" @@ -3434,18 +3477,18 @@ } }, "node_modules/@docusaurus/core": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-3.9.2.tgz", - "integrity": "sha512-HbjwKeC+pHUFBfLMNzuSjqFE/58+rLVKmOU3lxQrpsxLBOGosYco/Q0GduBb0/jEMRiyEqjNT/01rRdOMWq5pw==", - "license": "MIT", - "dependencies": { - "@docusaurus/babel": "3.9.2", - "@docusaurus/bundler": "3.9.2", - "@docusaurus/logger": "3.9.2", - "@docusaurus/mdx-loader": "3.9.2", - "@docusaurus/utils": "3.9.2", - "@docusaurus/utils-common": "3.9.2", - "@docusaurus/utils-validation": "3.9.2", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-3.10.2.tgz", + "integrity": "sha512-EYByj6nk+aD9KeVxV6Hmo2/nAAT79P21Y82ycTBOBtrmqilloIbIEhgL2/8Xpt2Jz/pgNqHAwyusOGwmbKeJmA==", + "license": "MIT", + "dependencies": { + "@docusaurus/babel": "3.10.2", + "@docusaurus/bundler": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/mdx-loader": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "boxen": "^6.2.1", "chalk": "^4.1.2", "chokidar": "^3.5.3", @@ -3453,11 +3496,11 @@ "combine-promises": "^1.1.0", "commander": "^5.1.0", "core-js": "^3.31.1", - "detect-port": "^1.5.1", + "detect-port": "^2.1.0", "escape-html": "^1.0.3", "eta": "^2.2.0", "eval": "^0.1.8", - "execa": "5.1.1", + "execa": "^5.1.1", "fs-extra": "^11.1.1", "html-tags": "^3.3.1", "html-webpack-plugin": "^5.6.0", @@ -3468,12 +3511,12 @@ "prompts": "^2.4.2", "react-helmet-async": "npm:@slorber/react-helmet-async@1.3.0", "react-loadable": "npm:@docusaurus/react-loadable@6.0.0", - "react-loadable-ssr-addon-v5-slorber": "^1.0.1", + "react-loadable-ssr-addon-v5-slorber": "^1.0.3", "react-router": "^5.3.4", "react-router-config": "^5.1.1", "react-router-dom": "^5.3.4", "semver": "^7.5.4", - "serve-handler": "^6.1.6", + "serve-handler": "^6.1.7", "tinypool": "^1.0.2", "tslib": "^2.6.0", "update-notifier": "^6.0.2", @@ -3489,15 +3532,21 @@ "node": ">=20.0" }, "peerDependencies": { + "@docusaurus/faster": "*", "@mdx-js/react": "^3.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@docusaurus/faster": { + "optional": true + } } }, "node_modules/@docusaurus/cssnano-preset": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/cssnano-preset/-/cssnano-preset-3.9.2.tgz", - "integrity": "sha512-8gBKup94aGttRduABsj7bpPFTX7kbwu+xh3K9NMCF5K4bWBqTFYW+REKHF6iBVDHRJ4grZdIPbvkiHd/XNKRMQ==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/cssnano-preset/-/cssnano-preset-3.10.2.tgz", + "integrity": "sha512-4gCnHRbJLTloiwfvFAa92tgb2gI4KYhvjfQVYnEaiMO/EgvWfCo1LwytHXen+1oZAN0VAlS0JAPxp3MsvKDa3A==", "license": "MIT", "dependencies": { "cssnano-preset-advanced": "^6.1.2", @@ -3510,9 +3559,9 @@ } }, "node_modules/@docusaurus/logger": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/logger/-/logger-3.9.2.tgz", - "integrity": "sha512-/SVCc57ByARzGSU60c50rMyQlBuMIJCjcsJlkphxY6B0GV4UH3tcA1994N8fFfbJ9kX3jIBe/xg3XP5qBtGDbA==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/logger/-/logger-3.10.2.tgz", + "integrity": "sha512-gSEwqtPfCAnC3ZSJY6xL7tcIfgg0vFD39jbv93eakuweyvO2864xR0K+kmKwBhkTCtWRNjuGGnb5rdmkD/ndqw==", "license": "MIT", "dependencies": { "chalk": "^4.1.2", @@ -3523,14 +3572,14 @@ } }, "node_modules/@docusaurus/mdx-loader": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/mdx-loader/-/mdx-loader-3.9.2.tgz", - "integrity": "sha512-wiYoGwF9gdd6rev62xDU8AAM8JuLI/hlwOtCzMmYcspEkzecKrP8J8X+KpYnTlACBUUtXNJpSoCwFWJhLRevzQ==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/mdx-loader/-/mdx-loader-3.10.2.tgz", + "integrity": "sha512-9Fd4V/SFjfrVQ0JH5EN0+iPWyFunvTeQE3gfyFeetqPaXMP0OylIjOw16dCuXG4NZJrYdBqwzjh18/h3gRi47w==", "license": "MIT", "dependencies": { - "@docusaurus/logger": "3.9.2", - "@docusaurus/utils": "3.9.2", - "@docusaurus/utils-validation": "3.9.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "@mdx-js/mdx": "^3.0.0", "@slorber/remark-comment": "^1.0.0", "escape-html": "^1.0.3", @@ -3562,12 +3611,12 @@ } }, "node_modules/@docusaurus/module-type-aliases": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/module-type-aliases/-/module-type-aliases-3.9.2.tgz", - "integrity": "sha512-8qVe2QA9hVLzvnxP46ysuofJUIc/yYQ82tvA/rBTrnpXtCjNSFLxEZfd5U8cYZuJIVlkPxamsIgwd5tGZXfvew==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/module-type-aliases/-/module-type-aliases-3.10.2.tgz", + "integrity": "sha512-h/I5e4jaAhDHW4vaLENi1i2hnOEnXY1t9R+nnRTbgUl7ymVRzN/HF7dDfj8rKYGj8gfIge+Ef+iYRAMtbGvsrQ==", "license": "MIT", "dependencies": { - "@docusaurus/types": "3.9.2", + "@docusaurus/types": "3.10.2", "@types/history": "^4.7.11", "@types/react": "*", "@types/react-router-config": "*", @@ -3581,20 +3630,21 @@ } }, "node_modules/@docusaurus/plugin-content-blog": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.9.2.tgz", - "integrity": "sha512-3I2HXy3L1QcjLJLGAoTvoBnpOwa6DPUa3Q0dMK19UTY9mhPkKQg/DYhAGTiBUKcTR0f08iw7kLPqOhIgdV3eVQ==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.9.2", - "@docusaurus/logger": "3.9.2", - "@docusaurus/mdx-loader": "3.9.2", - "@docusaurus/theme-common": "3.9.2", - "@docusaurus/types": "3.9.2", - "@docusaurus/utils": "3.9.2", - "@docusaurus/utils-common": "3.9.2", - "@docusaurus/utils-validation": "3.9.2", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.10.2.tgz", + "integrity": "sha512-0cbEnNKf0InmLkhj/+nVRmqEnWEoOE8Mh+2x1qOXI0qYpCnphq4RXknVJ8BvybKRXqYVvbmdMfiJSup+k4tm5w==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/mdx-loader": "3.10.2", + "@docusaurus/theme-common": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "cheerio": "1.0.0-rc.12", + "combine-promises": "^1.1.0", "feed": "^4.2.2", "fs-extra": "^11.1.1", "lodash": "^4.17.21", @@ -3615,20 +3665,20 @@ } }, "node_modules/@docusaurus/plugin-content-docs": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.9.2.tgz", - "integrity": "sha512-C5wZsGuKTY8jEYsqdxhhFOe1ZDjH0uIYJ9T/jebHwkyxqnr4wW0jTkB72OMqNjsoQRcb0JN3PcSeTwFlVgzCZg==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.9.2", - "@docusaurus/logger": "3.9.2", - "@docusaurus/mdx-loader": "3.9.2", - "@docusaurus/module-type-aliases": "3.9.2", - "@docusaurus/theme-common": "3.9.2", - "@docusaurus/types": "3.9.2", - "@docusaurus/utils": "3.9.2", - "@docusaurus/utils-common": "3.9.2", - "@docusaurus/utils-validation": "3.9.2", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.10.2.tgz", + "integrity": "sha512-Sqwl4FPoZBDrlY8I2VU2H8O0M91CHp9T8ToMSkTZmjvHCif+1laqfXi6sTk8IfyVS/trN5yNjcWd1bFsGB6W5Q==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/mdx-loader": "3.10.2", + "@docusaurus/module-type-aliases": "3.10.2", + "@docusaurus/theme-common": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "@types/react-router-config": "^5.0.7", "combine-promises": "^1.1.0", "fs-extra": "^11.1.1", @@ -3648,16 +3698,16 @@ } }, "node_modules/@docusaurus/plugin-content-pages": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.9.2.tgz", - "integrity": "sha512-s4849w/p4noXUrGpPUF0BPqIAfdAe76BLaRGAGKZ1gTDNiGxGcpsLcwJ9OTi1/V8A+AzvsmI9pkjie2zjIQZKA==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.10.2.tgz", + "integrity": "sha512-h5R12sZ/vV9EPiVjvIl9YFCOwkpwXes7dQMYt3EvP6Pphu4amHxxTqWxf08Fl5DR8h+oZMbWpFTNw5vKEYfvzQ==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.9.2", - "@docusaurus/mdx-loader": "3.9.2", - "@docusaurus/types": "3.9.2", - "@docusaurus/utils": "3.9.2", - "@docusaurus/utils-validation": "3.9.2", + "@docusaurus/core": "3.10.2", + "@docusaurus/mdx-loader": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "fs-extra": "^11.1.1", "tslib": "^2.6.0", "webpack": "^5.88.1" @@ -3671,15 +3721,15 @@ } }, "node_modules/@docusaurus/plugin-css-cascade-layers": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-css-cascade-layers/-/plugin-css-cascade-layers-3.9.2.tgz", - "integrity": "sha512-w1s3+Ss+eOQbscGM4cfIFBlVg/QKxyYgj26k5AnakuHkKxH6004ZtuLe5awMBotIYF2bbGDoDhpgQ4r/kcj4rQ==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-css-cascade-layers/-/plugin-css-cascade-layers-3.10.2.tgz", + "integrity": "sha512-UkdvQby5OQUKWrw3lLnSTJXQ6VETaUVTuPQX9AABtmFm5h+ifEBx1OQ+LN726Q4byuwBf2ElHkf4qU4hTxdvRg==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.9.2", - "@docusaurus/types": "3.9.2", - "@docusaurus/utils": "3.9.2", - "@docusaurus/utils-validation": "3.9.2", + "@docusaurus/core": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "tslib": "^2.6.0" }, "engines": { @@ -3687,14 +3737,14 @@ } }, "node_modules/@docusaurus/plugin-debug": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-debug/-/plugin-debug-3.9.2.tgz", - "integrity": "sha512-j7a5hWuAFxyQAkilZwhsQ/b3T7FfHZ+0dub6j/GxKNFJp2h9qk/P1Bp7vrGASnvA9KNQBBL1ZXTe7jlh4VdPdA==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-debug/-/plugin-debug-3.10.2.tgz", + "integrity": "sha512-8vbZNOSCpnsT57EY6CgN7sgRVmx3KTYwO8Uvo2pbxOyb8tbqAwtT9SslqaQ41HbA1v1hpn5RP7u5s2KvRwAFpQ==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.9.2", - "@docusaurus/types": "3.9.2", - "@docusaurus/utils": "3.9.2", + "@docusaurus/core": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", "fs-extra": "^11.1.1", "react-json-view-lite": "^2.3.0", "tslib": "^2.6.0" @@ -3708,14 +3758,14 @@ } }, "node_modules/@docusaurus/plugin-google-analytics": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.9.2.tgz", - "integrity": "sha512-mAwwQJ1Us9jL/lVjXtErXto4p4/iaLlweC54yDUK1a97WfkC6Z2k5/769JsFgwOwOP+n5mUQGACXOEQ0XDuVUw==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.10.2.tgz", + "integrity": "sha512-kMHMBK9j4VAtgd5owwrRLRIi0EjkrpXlX7ePj1+y68XfVZV9I1T4S+koPDm+Hfw2TtnyHvh0uNrDvjz+DjQGVA==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.9.2", - "@docusaurus/types": "3.9.2", - "@docusaurus/utils-validation": "3.9.2", + "@docusaurus/core": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "tslib": "^2.6.0" }, "engines": { @@ -3727,15 +3777,14 @@ } }, "node_modules/@docusaurus/plugin-google-gtag": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.9.2.tgz", - "integrity": "sha512-YJ4lDCphabBtw19ooSlc1MnxtYGpjFV9rEdzjLsUnBCeis2djUyCozZaFhCg6NGEwOn7HDDyMh0yzcdRpnuIvA==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.10.2.tgz", + "integrity": "sha512-Vt90nNFhtAChRe9+it1hcHFgFvETdSnOkL5Bma+p6E/yU2tAYrvvyk+gv+LJGM2ZUkyKuKXLRsZ2Lb0bO7+Vog==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.9.2", - "@docusaurus/types": "3.9.2", - "@docusaurus/utils-validation": "3.9.2", - "@types/gtag.js": "^0.0.12", + "@docusaurus/core": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "tslib": "^2.6.0" }, "engines": { @@ -3747,14 +3796,14 @@ } }, "node_modules/@docusaurus/plugin-google-tag-manager": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.9.2.tgz", - "integrity": "sha512-LJtIrkZN/tuHD8NqDAW1Tnw0ekOwRTfobWPsdO15YxcicBo2ykKF0/D6n0vVBfd3srwr9Z6rzrIWYrMzBGrvNw==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.10.2.tgz", + "integrity": "sha512-MLCffCldysi/R0nzJQP7ZWd0xAoGNnSTiVOo6TTR6mKVGFhE+/XArGe67ZcaZv1uytgQXoXs92VJrgVDrz80rQ==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.9.2", - "@docusaurus/types": "3.9.2", - "@docusaurus/utils-validation": "3.9.2", + "@docusaurus/core": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "tslib": "^2.6.0" }, "engines": { @@ -3766,17 +3815,17 @@ } }, "node_modules/@docusaurus/plugin-sitemap": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.9.2.tgz", - "integrity": "sha512-WLh7ymgDXjG8oPoM/T4/zUP7KcSuFYRZAUTl8vR6VzYkfc18GBM4xLhcT+AKOwun6kBivYKUJf+vlqYJkm+RHw==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.10.2.tgz", + "integrity": "sha512-PODkwg5XetLML3hU/3xpCKJUZ9cqExLaBnD/Fzzwj2VHogLeqnDisLIujae87zuze7T4mCm2A6KEqZkyiz07EQ==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.9.2", - "@docusaurus/logger": "3.9.2", - "@docusaurus/types": "3.9.2", - "@docusaurus/utils": "3.9.2", - "@docusaurus/utils-common": "3.9.2", - "@docusaurus/utils-validation": "3.9.2", + "@docusaurus/core": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "fs-extra": "^11.1.1", "sitemap": "^7.1.1", "tslib": "^2.6.0" @@ -3790,15 +3839,15 @@ } }, "node_modules/@docusaurus/plugin-svgr": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-svgr/-/plugin-svgr-3.9.2.tgz", - "integrity": "sha512-n+1DE+5b3Lnf27TgVU5jM1d4x5tUh2oW5LTsBxJX4PsAPV0JGcmI6p3yLYtEY0LRVEIJh+8RsdQmRE66wSV8mw==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-svgr/-/plugin-svgr-3.10.2.tgz", + "integrity": "sha512-JgfT3jWM0TJ8Uw0cEcqxHpybngQY1vlBYpuuNO+gEh5iPh5Ar+vxq/u9CFrYsWeXy48BN7Db76Pzp2edNXUQ8A==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.9.2", - "@docusaurus/types": "3.9.2", - "@docusaurus/utils": "3.9.2", - "@docusaurus/utils-validation": "3.9.2", + "@docusaurus/core": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "@svgr/core": "8.1.0", "@svgr/webpack": "^8.1.0", "tslib": "^2.6.0", @@ -3813,26 +3862,26 @@ } }, "node_modules/@docusaurus/preset-classic": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/preset-classic/-/preset-classic-3.9.2.tgz", - "integrity": "sha512-IgyYO2Gvaigi21LuDIe+nvmN/dfGXAiMcV/murFqcpjnZc7jxFAxW+9LEjdPt61uZLxG4ByW/oUmX/DDK9t/8w==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.9.2", - "@docusaurus/plugin-content-blog": "3.9.2", - "@docusaurus/plugin-content-docs": "3.9.2", - "@docusaurus/plugin-content-pages": "3.9.2", - "@docusaurus/plugin-css-cascade-layers": "3.9.2", - "@docusaurus/plugin-debug": "3.9.2", - "@docusaurus/plugin-google-analytics": "3.9.2", - "@docusaurus/plugin-google-gtag": "3.9.2", - "@docusaurus/plugin-google-tag-manager": "3.9.2", - "@docusaurus/plugin-sitemap": "3.9.2", - "@docusaurus/plugin-svgr": "3.9.2", - "@docusaurus/theme-classic": "3.9.2", - "@docusaurus/theme-common": "3.9.2", - "@docusaurus/theme-search-algolia": "3.9.2", - "@docusaurus/types": "3.9.2" + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/preset-classic/-/preset-classic-3.10.2.tgz", + "integrity": "sha512-a4B3VczmDl99zK0EufDQYomdJ186WDingjmDXxhN2PNPS9Ty/Y2M5CLFX1KQMRKqRTLiRDKfutzG5IY1FC/ceg==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/plugin-content-blog": "3.10.2", + "@docusaurus/plugin-content-docs": "3.10.2", + "@docusaurus/plugin-content-pages": "3.10.2", + "@docusaurus/plugin-css-cascade-layers": "3.10.2", + "@docusaurus/plugin-debug": "3.10.2", + "@docusaurus/plugin-google-analytics": "3.10.2", + "@docusaurus/plugin-google-gtag": "3.10.2", + "@docusaurus/plugin-google-tag-manager": "3.10.2", + "@docusaurus/plugin-sitemap": "3.10.2", + "@docusaurus/plugin-svgr": "3.10.2", + "@docusaurus/theme-classic": "3.10.2", + "@docusaurus/theme-common": "3.10.2", + "@docusaurus/theme-search-algolia": "3.10.2", + "@docusaurus/types": "3.10.2" }, "engines": { "node": ">=20.0" @@ -3843,26 +3892,27 @@ } }, "node_modules/@docusaurus/theme-classic": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-classic/-/theme-classic-3.9.2.tgz", - "integrity": "sha512-IGUsArG5hhekXd7RDb11v94ycpJpFdJPkLnt10fFQWOVxAtq5/D7hT6lzc2fhyQKaaCE62qVajOMKL7OiAFAIA==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.9.2", - "@docusaurus/logger": "3.9.2", - "@docusaurus/mdx-loader": "3.9.2", - "@docusaurus/module-type-aliases": "3.9.2", - "@docusaurus/plugin-content-blog": "3.9.2", - "@docusaurus/plugin-content-docs": "3.9.2", - "@docusaurus/plugin-content-pages": "3.9.2", - "@docusaurus/theme-common": "3.9.2", - "@docusaurus/theme-translations": "3.9.2", - "@docusaurus/types": "3.9.2", - "@docusaurus/utils": "3.9.2", - "@docusaurus/utils-common": "3.9.2", - "@docusaurus/utils-validation": "3.9.2", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-classic/-/theme-classic-3.10.2.tgz", + "integrity": "sha512-JqTSLQmqmA9uKWZsD5iwBGJ4JyKB4/yTw6PsSXVPRJG/6GAm/u+add9Iip+hvwP12/AnPNztrdxsI14NJW4KeA==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/mdx-loader": "3.10.2", + "@docusaurus/module-type-aliases": "3.10.2", + "@docusaurus/plugin-content-blog": "3.10.2", + "@docusaurus/plugin-content-docs": "3.10.2", + "@docusaurus/plugin-content-pages": "3.10.2", + "@docusaurus/theme-common": "3.10.2", + "@docusaurus/theme-translations": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "@mdx-js/react": "^3.0.0", "clsx": "^2.0.0", + "copy-text-to-clipboard": "^3.2.0", "infima": "0.2.0-alpha.45", "lodash": "^4.17.21", "nprogress": "^0.2.0", @@ -3883,15 +3933,15 @@ } }, "node_modules/@docusaurus/theme-common": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-3.9.2.tgz", - "integrity": "sha512-6c4DAbR6n6nPbnZhY2V3tzpnKnGL+6aOsLvFL26VRqhlczli9eWG0VDUNoCQEPnGwDMhPS42UhSAnz5pThm5Ag==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-3.10.2.tgz", + "integrity": "sha512-R9b/vMpK1yye6hNZTA6x/ivRv+at6GhxnXcxkpzCGzO1R1RwiquqiFg2wMFh6aqlJTpWRFKpFD2TzCDQcyOU0A==", "license": "MIT", "dependencies": { - "@docusaurus/mdx-loader": "3.9.2", - "@docusaurus/module-type-aliases": "3.9.2", - "@docusaurus/utils": "3.9.2", - "@docusaurus/utils-common": "3.9.2", + "@docusaurus/mdx-loader": "3.10.2", + "@docusaurus/module-type-aliases": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", "@types/history": "^4.7.11", "@types/react": "*", "@types/react-router-config": "*", @@ -3911,19 +3961,20 @@ } }, "node_modules/@docusaurus/theme-search-algolia": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.9.2.tgz", - "integrity": "sha512-GBDSFNwjnh5/LdkxCKQHkgO2pIMX1447BxYUBG2wBiajS21uj64a+gH/qlbQjDLxmGrbrllBrtJkUHxIsiwRnw==", - "license": "MIT", - "dependencies": { - "@docsearch/react": "^3.9.0 || ^4.1.0", - "@docusaurus/core": "3.9.2", - "@docusaurus/logger": "3.9.2", - "@docusaurus/plugin-content-docs": "3.9.2", - "@docusaurus/theme-common": "3.9.2", - "@docusaurus/theme-translations": "3.9.2", - "@docusaurus/utils": "3.9.2", - "@docusaurus/utils-validation": "3.9.2", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.10.2.tgz", + "integrity": "sha512-1msxllyhi/5m77JukXtp5UFnUAriwZIC1oJ7MTnpQpCwLTbclJi5BK5n28CTZuSXpQN2ewbbnqRgAhMM6c6ihg==", + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-core": "^1.19.2", + "@docsearch/react": "^3.9.0 || ^4.3.2", + "@docusaurus/core": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/plugin-content-docs": "3.10.2", + "@docusaurus/theme-common": "3.10.2", + "@docusaurus/theme-translations": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "algoliasearch": "^5.37.0", "algoliasearch-helper": "^3.26.0", "clsx": "^2.0.0", @@ -3942,9 +3993,9 @@ } }, "node_modules/@docusaurus/theme-translations": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-3.9.2.tgz", - "integrity": "sha512-vIryvpP18ON9T9rjgMRFLr2xJVDpw1rtagEGf8Ccce4CkTrvM/fRB8N2nyWYOW5u3DdjkwKw5fBa+3tbn9P4PA==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-3.10.2.tgz", + "integrity": "sha512-iv20wrxnyXkY89LM3TzRlzGlt5fIGO5UnaR6UL1ZVfB9RRFjxQFQ6awDrwAc6Km8Y5gD8pInuwYPF+6/TiCxXA==", "license": "MIT", "dependencies": { "fs-extra": "^11.1.1", @@ -3955,16 +4006,16 @@ } }, "node_modules/@docusaurus/tsconfig": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/tsconfig/-/tsconfig-3.9.2.tgz", - "integrity": "sha512-j6/Fp4Rlpxsc632cnRnl5HpOWeb6ZKssDj6/XzzAzVGXXfm9Eptx3rxCC+fDzySn9fHTS+CWJjPineCR1bB5WQ==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/tsconfig/-/tsconfig-3.10.2.tgz", + "integrity": "sha512-5GiB7h/nFsMFPO9mCqcRNE1yA5TSXXNCshNIgHPL6fCPOjcTDixs6qjQBu8ddkgPcicwCvOA7n3jeK2rGdJk6g==", "dev": true, "license": "MIT" }, "node_modules/@docusaurus/types": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/types/-/types-3.9.2.tgz", - "integrity": "sha512-Ux1JUNswg+EfUEmajJjyhIohKceitY/yzjRUpu04WXgvVz+fbhVC0p+R0JhvEu4ytw8zIAys2hrdpQPBHRIa8Q==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/types/-/types-3.10.2.tgz", + "integrity": "sha512-B6rvfwIFSapUqUJjMriZswX13K8l5Z7AcmVE6uTEJpYddQieSTR12DsGaFtcZAIDsQd4p+0WTl0Vc6jmZK0Trw==", "license": "MIT", "dependencies": { "@mdx-js/mdx": "^3.0.0", @@ -3998,21 +4049,21 @@ } }, "node_modules/@docusaurus/utils": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/utils/-/utils-3.9.2.tgz", - "integrity": "sha512-lBSBiRruFurFKXr5Hbsl2thmGweAPmddhF3jb99U4EMDA5L+e5Y1rAkOS07Nvrup7HUMBDrCV45meaxZnt28nQ==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/utils/-/utils-3.10.2.tgz", + "integrity": "sha512-xx0W3eav2uW1NRIpuHJWNwLTC15xPNjU4Uxi9NSnd3swYC96BE3vFiT93SD8s24kmAAWNwgZwfZ2fghGZ01Lcw==", "license": "MIT", "dependencies": { - "@docusaurus/logger": "3.9.2", - "@docusaurus/types": "3.9.2", - "@docusaurus/utils-common": "3.9.2", + "@11ty/gray-matter": "^1.0.0", + "@docusaurus/logger": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils-common": "3.10.2", "escape-string-regexp": "^4.0.0", - "execa": "5.1.1", + "execa": "^5.1.1", "file-loader": "^6.2.0", "fs-extra": "^11.1.1", "github-slugger": "^1.5.0", "globby": "^11.1.0", - "gray-matter": "^4.0.3", "jiti": "^1.20.0", "js-yaml": "^4.1.0", "lodash": "^4.17.21", @@ -4030,12 +4081,12 @@ } }, "node_modules/@docusaurus/utils-common": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/utils-common/-/utils-common-3.9.2.tgz", - "integrity": "sha512-I53UC1QctruA6SWLvbjbhCpAw7+X7PePoe5pYcwTOEXD/PxeP8LnECAhTHHwWCblyUX5bMi4QLRkxvyZ+IT8Aw==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/utils-common/-/utils-common-3.10.2.tgz", + "integrity": "sha512-x3Dz6jv6iQKBNjBmVTu8p57abMp/VNTUgKBMgRVXJc5444orBTsArv0+cdfrXTiz/VMmHfDRVkPbL7GH2B7T7w==", "license": "MIT", "dependencies": { - "@docusaurus/types": "3.9.2", + "@docusaurus/types": "3.10.2", "tslib": "^2.6.0" }, "engines": { @@ -4043,14 +4094,14 @@ } }, "node_modules/@docusaurus/utils-validation": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@docusaurus/utils-validation/-/utils-validation-3.9.2.tgz", - "integrity": "sha512-l7yk3X5VnNmATbwijJkexdhulNsQaNDwoagiwujXoxFbWLcxHQqNQ+c/IAlzrfMMOfa/8xSBZ7KEKDesE/2J7A==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/utils-validation/-/utils-validation-3.10.2.tgz", + "integrity": "sha512-sn8unbDfUL585NtR3cwHefPicOyaHvPaX7VD0aOg/siIxUBoKyKKaGEqzJZDS64mM43TnxurkYDtmB1wsJlZsw==", "license": "MIT", "dependencies": { - "@docusaurus/logger": "3.9.2", - "@docusaurus/utils": "3.9.2", - "@docusaurus/utils-common": "3.9.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", "fs-extra": "^11.2.0", "joi": "^17.9.2", "js-yaml": "^4.1.0", @@ -4143,9 +4194,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -4207,13 +4258,13 @@ } }, "node_modules/@jsonjoy.com/fs-core": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-core/-/fs-core-4.57.1.tgz", - "integrity": "sha512-YrEi/ZPmgc+GfdO0esBF04qv8boK9Dg9WpRQw/+vM8Qt3nnVIJWIa8HwZ/LXVZ0DB11XUROM8El/7yYTJX+WtA==", + "version": "4.75.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-core/-/fs-core-4.75.0.tgz", + "integrity": "sha512-G0E9RxkI4VhrIFSn5Gy5hPhF4tPXbsjriSnL8TutVs4d5MAocTawlwRSyjcycUsO+DByzpNehI46vmI764YSkw==", "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-node-builtins": "4.57.1", - "@jsonjoy.com/fs-node-utils": "4.57.1", + "@jsonjoy.com/fs-node-builtins": "4.75.0", + "@jsonjoy.com/fs-node-utils": "4.75.0", "thingies": "^2.5.0" }, "engines": { @@ -4228,14 +4279,14 @@ } }, "node_modules/@jsonjoy.com/fs-fsa": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-fsa/-/fs-fsa-4.57.1.tgz", - "integrity": "sha512-ooEPvSW/HQDivPDPZMibHGKZf/QS4WRir1czGZmXmp3MsQqLECZEpN0JobrD8iV9BzsuwdIv+PxtWX9WpPLsIA==", + "version": "4.75.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-fsa/-/fs-fsa-4.75.0.tgz", + "integrity": "sha512-IoMlNWjzcFK5NOUZnCx1hWGbYeUhrLg7cwWi0QTLgUCp5WbDCbzUR1ipqeFgyFsyPrUG+bFPgzS35gjlMT9Vyg==", "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-core": "4.57.1", - "@jsonjoy.com/fs-node-builtins": "4.57.1", - "@jsonjoy.com/fs-node-utils": "4.57.1", + "@jsonjoy.com/fs-core": "4.75.0", + "@jsonjoy.com/fs-node-builtins": "4.75.0", + "@jsonjoy.com/fs-node-utils": "4.75.0", "thingies": "^2.5.0" }, "engines": { @@ -4250,16 +4301,16 @@ } }, "node_modules/@jsonjoy.com/fs-node": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node/-/fs-node-4.57.1.tgz", - "integrity": "sha512-3YaKhP8gXEKN+2O49GLNfNb5l2gbnCFHyAaybbA2JkkbQP3dpdef7WcUaHAulg/c5Dg4VncHsA3NWAUSZMR5KQ==", + "version": "4.75.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node/-/fs-node-4.75.0.tgz", + "integrity": "sha512-+6GrUqEOxnhpXcMHyK1YN48qJWxpA88o+oCvnyY0RlyyKz/hvIsW970GXnoDfGxX6Tt/RRN1lLN4yfZNINwhzQ==", "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-core": "4.57.1", - "@jsonjoy.com/fs-node-builtins": "4.57.1", - "@jsonjoy.com/fs-node-utils": "4.57.1", - "@jsonjoy.com/fs-print": "4.57.1", - "@jsonjoy.com/fs-snapshot": "4.57.1", + "@jsonjoy.com/fs-core": "4.75.0", + "@jsonjoy.com/fs-node-builtins": "4.75.0", + "@jsonjoy.com/fs-node-utils": "4.75.0", + "@jsonjoy.com/fs-print": "4.75.0", + "@jsonjoy.com/fs-snapshot": "4.75.0", "glob-to-regex.js": "^1.0.0", "thingies": "^2.5.0" }, @@ -4275,9 +4326,9 @@ } }, "node_modules/@jsonjoy.com/fs-node-builtins": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.57.1.tgz", - "integrity": "sha512-XHkFKQ5GSH3uxm8c3ZYXVrexGdscpWKIcMWKFQpMpMJc8gA3AwOMBJXJlgpdJqmrhPyQXxaY9nbkNeYpacC0Og==", + "version": "4.75.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.75.0.tgz", + "integrity": "sha512-ennOTWjGp8+1lC7Y2Py7cJIS+vR4wF74i/lIpxLMJM8FgkeYBXsgyhPpUgUreB6S9Qpp6CRAVyJp6kgPSFvUMw==", "license": "Apache-2.0", "engines": { "node": ">=10.0" @@ -4291,14 +4342,14 @@ } }, "node_modules/@jsonjoy.com/fs-node-to-fsa": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-to-fsa/-/fs-node-to-fsa-4.57.1.tgz", - "integrity": "sha512-pqGHyWWzNck4jRfaGV39hkqpY5QjRUQ/nRbNT7FYbBa0xf4bDG+TE1Gt2KWZrSkrkZZDE3qZUjYMbjwSliX6pg==", + "version": "4.75.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-to-fsa/-/fs-node-to-fsa-4.75.0.tgz", + "integrity": "sha512-RH5YQd3vAvTdZL0MV/LyM8tJnKphG8kJyGXaNLxNN5r7ENPUKI55ZkoSzohaHXQfAq6Ef9uxC8MzwFGTKRtYPQ==", "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-fsa": "4.57.1", - "@jsonjoy.com/fs-node-builtins": "4.57.1", - "@jsonjoy.com/fs-node-utils": "4.57.1" + "@jsonjoy.com/fs-fsa": "4.75.0", + "@jsonjoy.com/fs-node-builtins": "4.75.0", + "@jsonjoy.com/fs-node-utils": "4.75.0" }, "engines": { "node": ">=10.0" @@ -4312,12 +4363,13 @@ } }, "node_modules/@jsonjoy.com/fs-node-utils": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.57.1.tgz", - "integrity": "sha512-vp+7ZzIB8v43G+GLXTS4oDUSQmhAsRz532QmmWBbdYA20s465JvwhkSFvX9cVTqRRAQg+vZ7zWDaIEh0lFe2gw==", + "version": "4.75.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.75.0.tgz", + "integrity": "sha512-gEAojafarixiyZYkoP5J+CeaV6y/SndvUPRz0/pdoa62koLP2D+SHnoWIpyVBBPW6NJ0FVrF+R1K0ZuXhNX2HQ==", "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-node-builtins": "4.57.1" + "@jsonjoy.com/fs-node-builtins": "4.75.0", + "glob-to-regex.js": "^1.0.1" }, "engines": { "node": ">=10.0" @@ -4331,12 +4383,12 @@ } }, "node_modules/@jsonjoy.com/fs-print": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-print/-/fs-print-4.57.1.tgz", - "integrity": "sha512-Ynct7ZJmfk6qoXDOKfpovNA36ITUx8rChLmRQtW08J73VOiuNsU8PB6d/Xs7fxJC2ohWR3a5AqyjmLojfrw5yw==", + "version": "4.75.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-print/-/fs-print-4.75.0.tgz", + "integrity": "sha512-bAyOYr2MRqch6INi34tArXPH4jtm0uEk8uswdmB0TPq7q2Jewwp3xHFaoOXUfCkI/Q7n4w6vccnWS3afmy9nxg==", "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-node-utils": "4.57.1", + "@jsonjoy.com/fs-node-utils": "4.75.0", "tree-dump": "^1.1.0" }, "engines": { @@ -4351,13 +4403,13 @@ } }, "node_modules/@jsonjoy.com/fs-snapshot": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.57.1.tgz", - "integrity": "sha512-/oG8xBNFMbDXTq9J7vepSA1kerS5vpgd3p5QZSPd+nX59uwodGJftI51gDYyHRpP57P3WCQf7LHtBYPqwUg2Bg==", + "version": "4.75.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.75.0.tgz", + "integrity": "sha512-TQATJHVtL36otQQuYriOnKRRFOBduHGbY0eb4YYgONcPvxijG37+0vTVaDZj9zdDQb8rHHGVr43d3g3mwGw8VA==", "license": "Apache-2.0", "dependencies": { "@jsonjoy.com/buffers": "^17.65.0", - "@jsonjoy.com/fs-node-utils": "4.57.1", + "@jsonjoy.com/fs-node-utils": "4.75.0", "@jsonjoy.com/json-pack": "^17.65.0", "@jsonjoy.com/util": "^17.65.0" }, @@ -4574,16 +4626,15 @@ "license": "MIT" }, "node_modules/@mdx-js/mdx": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.1.tgz", - "integrity": "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.0.tgz", + "integrity": "sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw==", "license": "MIT", "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", - "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", @@ -4611,12 +4662,12 @@ } }, "node_modules/@mdx-js/mdx/node_modules/source-map": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", - "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", "license": "BSD-3-Clause", "engines": { - "node": ">= 12" + "node": ">= 8" } }, "node_modules/@mdx-js/react": { @@ -4651,7 +4702,6 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" @@ -4664,7 +4714,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "license": "MIT", "engines": { "node": ">= 8" } @@ -4673,7 +4722,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" @@ -4683,128 +4731,167 @@ } }, "node_modules/@peculiar/asn1-cms": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.6.1.tgz", - "integrity": "sha512-vdG4fBF6Lkirkcl53q6eOdn3XYKt+kJTG59edgRZORlg/3atWWEReRCx5rYE1ZzTTX6vLK5zDMjHh7vbrcXGtw==", + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.9.4.tgz", + "integrity": "sha512-cben7oxmQsUGZqotus7yt0srYdncOT6RNWcTQ77T2RFOXejYVYkXadrfePdRcrVpO9K95IRLKKglG2k38jKXuw==", "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.1", - "@peculiar/asn1-x509-attr": "^2.6.1", - "asn1js": "^3.0.6", + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/asn1-x509-attr": "^2.9.4", + "asn1js": "^3.0.10", "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" } }, "node_modules/@peculiar/asn1-csr": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.6.1.tgz", - "integrity": "sha512-WRWnKfIocHyzFYQTka8O/tXCiBquAPSrRjXbOkHbO4qdmS6loffCEGs+rby6WxxGdJCuunnhS2duHURhjyio6w==", + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.9.4.tgz", + "integrity": "sha512-xd4YN4vpRjkDAQWVfZZkeu12IEND7DOpkqaHSIHxZl1uggUNa9Ju0QxY2jHvDAS9pP0zhRBytg8ifsnGo3V0jw==", "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.1", - "asn1js": "^3.0.6", + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" } }, "node_modules/@peculiar/asn1-ecc": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.1.tgz", - "integrity": "sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g==", + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.9.4.tgz", + "integrity": "sha512-JJXefFshRAuVAjWQo/39bkg1ywc1VaiO44S8RRC+Ykvf/u2KDmYffoDb0ZBPCR5uJy4AGKQhl8mX+Q8ShcWaXQ==", "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.1", - "asn1js": "^3.0.6", + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" } }, "node_modules/@peculiar/asn1-pfx": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.6.1.tgz", - "integrity": "sha512-nB5jVQy3MAAWvq0KY0R2JUZG8bO/bTLpnwyOzXyEh/e54ynGTatAR+csOnXkkVD9AFZ2uL8Z7EV918+qB1qDvw==", + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.9.4.tgz", + "integrity": "sha512-khuGzHTzNzk4GDlIBEILyIs6Lce0yn0ZBdoI9v93kmNncfZRhD+AQ5ODFqdhvoE8cMJF/JMTQ8yA+t1D14kqCw==", "license": "MIT", "dependencies": { - "@peculiar/asn1-cms": "^2.6.1", - "@peculiar/asn1-pkcs8": "^2.6.1", - "@peculiar/asn1-rsa": "^2.6.1", - "@peculiar/asn1-schema": "^2.6.0", - "asn1js": "^3.0.6", + "@peculiar/asn1-cms": "^2.9.4", + "@peculiar/asn1-pkcs8": "^2.9.4", + "@peculiar/asn1-rsa": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "asn1js": "^3.0.10", "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" } }, "node_modules/@peculiar/asn1-pkcs8": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.6.1.tgz", - "integrity": "sha512-JB5iQ9Izn5yGMw3ZG4Nw3Xn/hb/G38GYF3lf7WmJb8JZUydhVGEjK/ZlFSWhnlB7K/4oqEs8HnfFIKklhR58Tw==", + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.9.4.tgz", + "integrity": "sha512-duRdotlUx9eDZe6QrQpQKl61RbWykCHBCkKayP8V8XdEFwlKHZ8qGGDMyS6Pye7OX7nLFttTTpRkJeet78ckwQ==", "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.1", - "asn1js": "^3.0.6", + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" } }, "node_modules/@peculiar/asn1-pkcs9": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.6.1.tgz", - "integrity": "sha512-5EV8nZoMSxeWmcxWmmcolg22ojZRgJg+Y9MX2fnE2bGRo5KQLqV5IL9kdSQDZxlHz95tHvIq9F//bvL1OeNILw==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-cms": "^2.6.1", - "@peculiar/asn1-pfx": "^2.6.1", - "@peculiar/asn1-pkcs8": "^2.6.1", - "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.1", - "@peculiar/asn1-x509-attr": "^2.6.1", - "asn1js": "^3.0.6", + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.9.4.tgz", + "integrity": "sha512-kaL4cNxBpdQE2dKlyZBqz4ygCrwffO+8wfoxTEqM1Z8RadvCeELBRzcv0dzM8aY9azHMwODO5nxU65zXmhToOQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.9.4", + "@peculiar/asn1-pfx": "^2.9.4", + "@peculiar/asn1-pkcs8": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/asn1-x509-attr": "^2.9.4", + "asn1js": "^3.0.10", "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" } }, "node_modules/@peculiar/asn1-rsa": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.6.1.tgz", - "integrity": "sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA==", + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.9.4.tgz", + "integrity": "sha512-pZ96eD1PptovcWQ/GSmuNFXd/7EQJNlKfDaNCyE2rx3W0v6QFelkzquVqRSRyyDXXCYD69ZXJDzZ8GhIiQzKoA==", "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.1", - "asn1js": "^3.0.6", + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" } }, "node_modules/@peculiar/asn1-schema": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz", - "integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==", + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.4.tgz", + "integrity": "sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg==", "license": "MIT", "dependencies": { - "asn1js": "^3.0.6", - "pvtsutils": "^1.3.6", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" } }, "node_modules/@peculiar/asn1-x509": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.6.1.tgz", - "integrity": "sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA==", + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.9.4.tgz", + "integrity": "sha512-CxhBo/RdEbMMob7T31ZdQjGuoyRFLVwrDzTn25bihzBasRg9kRm/0IxIPvhgQtcK/9dNcO1XQL2fuPugwELL0Q==", "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.6.0", - "asn1js": "^3.0.6", - "pvtsutils": "^1.3.6", + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" } }, "node_modules/@peculiar/asn1-x509-attr": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.6.1.tgz", - "integrity": "sha512-tlW6cxoHwgcQghnJwv3YS+9OO1737zgPogZ+CgWRUK4roEwIPzRH4JEiG770xe5HX2ATfCpmX60gurfWIF9dcQ==", + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.9.4.tgz", + "integrity": "sha512-ehQXbpQaQYycgu8OrvigwSPTFfVRcu0ECNYCWw+yzBp02Lw5paRqzzhUpfOgO2K38+WfFZuEz/0RPtam5g0OMg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.1", - "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, @@ -4894,9 +4981,9 @@ "license": "BSD-3-Clause" }, "node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", "license": "MIT" }, "node_modules/@sindresorhus/is": { @@ -5202,6 +5289,15 @@ "node": ">=14.16" } }, + "node_modules/@types/acorn": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@types/acorn/-/acorn-4.0.6.tgz", + "integrity": "sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -5241,38 +5337,18 @@ } }, "node_modules/@types/debug": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", - "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", "license": "MIT", "dependencies": { "@types/ms": "*" } }, - "node_modules/@types/eslint": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", - "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", - "license": "MIT", - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", - "license": "MIT", - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "license": "MIT" }, "node_modules/@types/estree-jsx": { @@ -5297,9 +5373,9 @@ } }, "node_modules/@types/express-serve-static-core": { - "version": "4.19.8", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", - "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "version": "4.19.9", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.9.tgz", + "integrity": "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==", "license": "MIT", "dependencies": { "@types/node": "*", @@ -5308,12 +5384,6 @@ "@types/send": "*" } }, - "node_modules/@types/gtag.js": { - "version": "0.0.12", - "resolved": "https://registry.npmjs.org/@types/gtag.js/-/gtag.js-0.0.12.tgz", - "integrity": "sha512-YQV9bUsemkzG81Ea295/nF/5GijnD2Af7QhEofh7xu+kvCN6RdodgNwwGWXB5GMI3NoyvQo0odNctoH/qLMIpg==", - "license": "MIT" - }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", @@ -5403,9 +5473,9 @@ "license": "MIT" }, "node_modules/@types/ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", - "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "version": "0.7.34", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", + "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==", "license": "MIT" }, "node_modules/@types/node": { @@ -5427,9 +5497,9 @@ "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==" }, "node_modules/@types/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", "license": "MIT" }, "node_modules/@types/range-parser": { @@ -5576,9 +5646,9 @@ "license": "MIT" }, "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", "license": "ISC" }, "node_modules/@webassemblyjs/ast": { @@ -5762,9 +5832,9 @@ } }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -5773,18 +5843,6 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-import-phases": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", - "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", - "license": "MIT", - "engines": { - "node": ">=10.13.0" - }, - "peerDependencies": { - "acorn": "^8.14.0" - } - }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -5806,18 +5864,18 @@ } }, "node_modules/address": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/address/-/address-1.2.2.tgz", - "integrity": "sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/address/-/address-2.0.3.tgz", + "integrity": "sha512-XNAb/a6TCqou+TufU8/u11HCu9x1gYvOoxLwtlXgIqmkrYQADVv6ljyW2zwiPhHz9R1gItAWpuDrdJMmrOBFEA==", + "license": "MIT", "engines": { - "node": ">= 10.0.0" + "node": ">= 16.0.0" } }, "node_modules/aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "license": "MIT", "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" @@ -5827,15 +5885,15 @@ } }, "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" }, "funding": { "type": "github", @@ -5843,9 +5901,10 @@ } }, "node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", "dependencies": { "ajv": "^8.0.0" }, @@ -5858,47 +5917,65 @@ } } }, - "node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.3" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", "peerDependencies": { - "ajv": "^8.8.2" + "ajv": "^6.9.1" } }, "node_modules/algoliasearch": { - "version": "5.50.0", - "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.50.0.tgz", - "integrity": "sha512-yE5I83Q2s8euVou8Y3feXK08wyZInJWLYXgWO6Xti9jBUEZAGUahyeQ7wSZWkifLWVnQVKEz5RAmBlXG5nqxog==", - "license": "MIT", - "dependencies": { - "@algolia/abtesting": "1.16.0", - "@algolia/client-abtesting": "5.50.0", - "@algolia/client-analytics": "5.50.0", - "@algolia/client-common": "5.50.0", - "@algolia/client-insights": "5.50.0", - "@algolia/client-personalization": "5.50.0", - "@algolia/client-query-suggestions": "5.50.0", - "@algolia/client-search": "5.50.0", - "@algolia/ingestion": "1.50.0", - "@algolia/monitoring": "1.50.0", - "@algolia/recommend": "5.50.0", - "@algolia/requester-browser-xhr": "5.50.0", - "@algolia/requester-fetch": "5.50.0", - "@algolia/requester-node-http": "5.50.0" + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.59.0.tgz", + "integrity": "sha512-wUXzaeI7B526W4y1gFg3lcxgDZ67XSgRJIiellYWOas/pLpO7rOtxm9Gr2E/C8aiSDXIgx1q5TdSsvK67Uakqw==", + "license": "MIT", + "dependencies": { + "@algolia/abtesting": "1.25.0", + "@algolia/client-abtesting": "5.59.0", + "@algolia/client-analytics": "5.59.0", + "@algolia/client-common": "5.59.0", + "@algolia/client-insights": "5.59.0", + "@algolia/client-personalization": "5.59.0", + "@algolia/client-query-suggestions": "5.59.0", + "@algolia/client-search": "5.59.0", + "@algolia/ingestion": "1.59.0", + "@algolia/monitoring": "1.59.0", + "@algolia/recommend": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/algoliasearch-helper": { - "version": "3.28.1", - "resolved": "https://registry.npmjs.org/algoliasearch-helper/-/algoliasearch-helper-3.28.1.tgz", - "integrity": "sha512-6iXpbkkrAI5HFpCWXlNmIDSBuoN/U1XnEvb2yJAoWfqrZ+DrybI7MQ5P5mthFaprmocq+zbi6HxnR28xnZAYBw==", + "version": "3.29.3", + "resolved": "https://registry.npmjs.org/algoliasearch-helper/-/algoliasearch-helper-3.29.3.tgz", + "integrity": "sha512-gVOMbbPVrCO3Xs+B+BLAIGFqIQow6qHMTYCEeBqLQB9m4ZmKUqMwkEumlal2iyyezHEd4Y0FvFTLbCRutCutIQ==", "license": "MIT", "dependencies": { "@algolia/events": "^4.0.1" @@ -5928,33 +6005,6 @@ "node": ">=8" } }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "license": "MIT", - "dependencies": { - "type-fest": "^0.21.3" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-escapes/node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ansi-html-community": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", @@ -5989,11 +6039,19 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/ansis": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-3.17.0.tgz", + "integrity": "sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==", + "license": "ISC", + "engines": { + "node": ">=14" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -6024,19 +6082,18 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/asn1js": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz", - "integrity": "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==", + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", "license": "BSD-3-Clause", "dependencies": { "pvtsutils": "^1.3.6", - "pvutils": "^1.1.3", + "pvutils": "^1.1.5", "tslib": "^2.8.1" }, "engines": { @@ -6053,9 +6110,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.4.27", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", - "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "version": "10.5.6", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.6.tgz", + "integrity": "sha512-HiH4oYNc5+DQEx/b8FPfMj+WHH/WWUBmbN5r1Uf/ixtfyFkk+wGIlfJT/RD5eAyzlwkeYRtZUCI8qrEi26pl2g==", "funding": [ { "type": "opencollective", @@ -6072,8 +6129,8 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.28.1", - "caniuse-lite": "^1.0.30001774", + "browserslist": "^4.28.9", + "caniuse-lite": "^1.0.30001810", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" @@ -6105,6 +6162,59 @@ "webpack": ">=5" } }, + "node_modules/babel-loader/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/babel-loader/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/babel-loader/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/babel-loader/node_modules/schema-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.4.0.tgz", + "integrity": "sha512-ZzWFVzFgyRHi/T2Ecxm37lL6J9+hZO0P9L7LCuLxAbC6XWxkvxcaQBCXhQxMGn/Tdt9nD7zIBk0ELwhMQ3UEDg==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/babel-plugin-dynamic-import-node": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz", @@ -6138,13 +6248,13 @@ } }, "node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", - "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", + "version": "0.10.6", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.6.tgz", + "integrity": "sha512-b37+KR2i/khY5sKmWNVQAnitvquQbNdWy6lJdsr0kmquCKEEUgMKK4SboVM3HtfnZilfjr4MMQ7vY58FVWDtIA==", "license": "MIT", "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.5", - "core-js-compat": "^3.43.0" + "@babel/helper-define-polyfill-provider": "^0.6.2", + "core-js-compat": "^3.38.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" @@ -6178,9 +6288,9 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.12", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.12.tgz", - "integrity": "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ==", + "version": "2.11.22", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.22.tgz", + "integrity": "sha512-pWc4w51fBFd7mav43/zKRC+RI6f4yfzQoVlfvE8dECePyfkn1bzLp01Fj0QACcyCZyFhiEMyD2qScfKRWgWibA==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -6205,21 +6315,17 @@ } }, "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "license": "MIT", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", "engines": { "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "version": "1.20.8", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", + "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -6230,7 +6336,7 @@ "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", - "qs": "~6.14.0", + "qs": "~6.16.0", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" @@ -6264,10 +6370,26 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/body-parser/node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/bonjour-service": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.3.0.tgz", - "integrity": "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA==", + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.4.4.tgz", + "integrity": "sha512-jCZcVv7eoc4QesRscwEZtSROBen+6LpKAmBIsQYQrsAeVHLyMXWX/t6eIV5KiRZYNUBl8eVqImEEMQ8L5+c/Kw==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -6301,9 +6423,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -6323,9 +6445,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", "funding": [ { "type": "opencollective", @@ -6342,11 +6464,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" }, "bin": { "browserslist": "cli.js" @@ -6429,14 +6551,14 @@ } }, "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" }, "engines": { @@ -6517,9 +6639,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001782", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001782.tgz", - "integrity": "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "funding": [ { "type": "opencollective", @@ -6808,7 +6930,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "license": "MIT", "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -6865,7 +6986,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "license": "MIT", "engines": { "node": ">=6" } @@ -6956,9 +7076,9 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/colord": { - "version": "2.9.3", - "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", - "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.10.0.tgz", + "integrity": "sha512-AidJptpBJmjTclAp9BkLwJi0T93fo5epJnbaZslpg6QVzpHjAiveF55mE9AcUJiGMqRHgMDY8soMsQtuNYMHfw==", "license": "MIT" }, "node_modules/colorette": { @@ -7012,14 +7132,15 @@ } }, "node_modules/compression": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", - "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz", + "integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==", "license": "MIT", "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", + "destroy": "1.2.0", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", @@ -7027,6 +7148,10 @@ }, "engines": { "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/compression/node_modules/bytes": { @@ -7141,6 +7266,18 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/copy-text-to-clipboard": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/copy-text-to-clipboard/-/copy-text-to-clipboard-3.2.2.tgz", + "integrity": "sha512-T6SqyLd1iLuqPA90J5N4cTalrtovCySh58iiZDGJ6FGznbclKh4UI+FGacQSgFzwKG77W7XT5gwbVEbd9cIH1A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/copy-webpack-plugin": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-11.0.0.tgz", @@ -7165,6 +7302,34 @@ "webpack": "^5.1.0" } }, + "node_modules/copy-webpack-plugin/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/copy-webpack-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, "node_modules/copy-webpack-plugin/node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -7196,6 +7361,31 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/copy-webpack-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/copy-webpack-plugin/node_modules/schema-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.4.0.tgz", + "integrity": "sha512-ZzWFVzFgyRHi/T2Ecxm37lL6J9+hZO0P9L7LCuLxAbC6XWxkvxcaQBCXhQxMGn/Tdt9nD7zIBk0ELwhMQ3UEDg==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/copy-webpack-plugin/node_modules/slash": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", @@ -7219,24 +7409,16 @@ } }, "node_modules/core-js-compat": { - "version": "3.49.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", - "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.50.0.tgz", + "integrity": "sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q==", "license": "MIT", "dependencies": { - "browserslist": "^4.28.1" + "browserslist": "^4.28.7" + }, + "engines": { + "node": ">=6.4.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, - "node_modules/core-js-pure": { - "version": "3.49.0", - "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.49.0.tgz", - "integrity": "sha512-XM4RFka59xATyJv/cS3O3Kml72hQXUeGRuuTmMYFxwzc9/7C8OYTaIR/Ji+Yt8DXzsFLNhat15cE/JP15HrCgw==", - "hasInstallScript": true, - "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/core-js" @@ -7278,7 +7460,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -7339,9 +7520,9 @@ } }, "node_modules/css-blank-pseudo/node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -7352,9 +7533,9 @@ } }, "node_modules/css-declaration-sorter": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-7.3.1.tgz", - "integrity": "sha512-gz6x+KkgNCjxq3Var03pRYLhyNfwhkKF1g/yoLgDNtFvVu0/fOLV9C8fFEZRjACp/XQLumjAYo7JVjzH3wLbxA==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-7.4.0.tgz", + "integrity": "sha512-LTuzjPoyA2vMGKKcaOqKSp7Ub2eGrNfKiZH4LpezxpNrsICGCSFvsQOI29psISxNZtaXibkC2CXzrQ5enMeGGw==", "license": "ISC", "engines": { "node": "^14 || ^16 || >=18" @@ -7413,9 +7594,9 @@ } }, "node_modules/css-has-pseudo/node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -7504,6 +7685,34 @@ } } }, + "node_modules/css-minimizer-webpack-plugin/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, "node_modules/css-minimizer-webpack-plugin/node_modules/jest-worker": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", @@ -7519,6 +7728,31 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/css-minimizer-webpack-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/schema-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.4.0.tgz", + "integrity": "sha512-ZzWFVzFgyRHi/T2Ecxm37lL6J9+hZO0P9L7LCuLxAbC6XWxkvxcaQBCXhQxMGn/Tdt9nD7zIBk0ELwhMQ3UEDg==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/css-prefers-color-scheme": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/css-prefers-color-scheme/-/css-prefers-color-scheme-10.0.0.tgz", @@ -7581,9 +7815,9 @@ } }, "node_modules/cssdb": { - "version": "8.8.0", - "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-8.8.0.tgz", - "integrity": "sha512-QbLeyz2Bgso1iRlh7IpWk6OKa3lLNGXsujVjDMPl9rOZpxKeiG69icLpbLCFxeURwmcdIfZqQyhlooKJYM4f8Q==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-8.11.0.tgz", + "integrity": "sha512-VzY/8kcK5M8oCVr/cwh24J8XVlCOITpmzCizKBC28o7Z1s23MMojXoJ3q7a+TQQoMKvxC3YlG0Z9yU10kJF1uQ==", "funding": [ { "type": "opencollective", @@ -7766,9 +8000,9 @@ } }, "node_modules/decode-named-character-reference": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", - "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz", + "integrity": "sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==", "license": "MIT", "dependencies": { "character-entities": "^2.0.0" @@ -7821,9 +8055,9 @@ } }, "node_modules/default-browser": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", - "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz", + "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==", "license": "MIT", "dependencies": { "bundle-name": "^4.1.0", @@ -7877,7 +8111,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", - "license": "MIT", "engines": { "node": ">=8" } @@ -7934,16 +8167,19 @@ "license": "MIT" }, "node_modules/detect-port": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/detect-port/-/detect-port-1.5.1.tgz", - "integrity": "sha512-aBzdj76lueB6uUst5iAs7+0H/oOjqI5D16XUWxlWMIMROhcM0rfsNVk93zTngq1dDNpoXRr++Sus7ETAExppAQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-port/-/detect-port-2.1.0.tgz", + "integrity": "sha512-epZuWb/6Q62L+nDHJc/hQAqf8pylsqgk3BpZXVBx1CDnr3nkrVNn73Uu1rXcFzkNcc+hkP3whuOg7JZYaQB65Q==", + "license": "MIT", "dependencies": { - "address": "^1.0.1", - "debug": "4" + "address": "^2.0.1" }, "bin": { - "detect": "bin/detect-port.js", - "detect-port": "bin/detect-port.js" + "detect": "dist/commonjs/bin/detect-port.js", + "detect-port": "dist/commonjs/bin/detect-port.js" + }, + "engines": { + "node": ">= 16.0.0" } }, "node_modules/devlop": { @@ -7963,7 +8199,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "license": "MIT", "dependencies": { "path-type": "^4.0.0" }, @@ -8104,9 +8339,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.329", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.329.tgz", - "integrity": "sha512-/4t+AS1l4S3ZC0Ja7PHFIWeBIxGA3QGqV8/yKsP36v7NcyUCl+bIcmw6s5zVuMIECWwBrAK/6QLzTmbJChBboQ==", + "version": "1.5.427", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.427.tgz", + "integrity": "sha512-n14zb3FdsChZ2BNobqNHAJMcP3ifFv4paox2LvCrfVAQcqGiSURgbJl+PfMpHVCNFkStnNc+RRVtPBTVW5PDgw==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -8149,13 +8384,13 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.20.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", - "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" + "tapable": "^2.3.3" }, "engines": { "node": ">=10.13.0" @@ -8170,9 +8405,9 @@ } }, "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -8197,15 +8432,15 @@ } }, "node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "license": "MIT" }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -8281,58 +8516,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esrecurse/node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "engines": { - "node": ">=4.0" - } - }, "node_modules/estree-util-attach-comments": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/estree-util-attach-comments/-/estree-util-attach-comments-3.0.0.tgz", @@ -8402,12 +8585,12 @@ } }, "node_modules/estree-util-to-js/node_modules/source-map": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", - "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", "license": "BSD-3-Clause", "engines": { - "node": ">= 12" + "node": ">= 8" } }, "node_modules/estree-util-value-to-estree": { @@ -8489,8 +8672,7 @@ "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "license": "MIT" + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" }, "node_modules/events": { "version": "3.3.0", @@ -8504,7 +8686,6 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "license": "MIT", "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", @@ -8524,14 +8705,14 @@ } }, "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "~1.20.3", + "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", @@ -8550,7 +8731,7 @@ "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "~6.14.0", + "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -8635,16 +8816,15 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "license": "MIT", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", - "micromatch": "^4.0.8" + "micromatch": "^4.0.4" }, "engines": { "node": ">=8.6.0" @@ -8653,19 +8833,28 @@ "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "license": "MIT" + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" }, "node_modules/fast-uri": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.3.tgz", - "integrity": "sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==" + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" }, "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "license": "ISC", + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", "dependencies": { "reusify": "^1.0.4" } @@ -8707,30 +8896,6 @@ "node": ">=0.4.0" } }, - "node_modules/figures": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", - "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^1.0.5" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/figures/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/file-loader": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz", @@ -8751,55 +8916,6 @@ "webpack": "^4.0.0 || ^5.0.0" } }, - "node_modules/file-loader/node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/file-loader/node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "license": "MIT", - "peerDependencies": { - "ajv": "^6.9.1" - } - }, - "node_modules/file-loader/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "license": "MIT" - }, - "node_modules/file-loader/node_modules/schema-utils": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", - "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -8886,9 +9002,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -8970,7 +9086,6 @@ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "hasInstallScript": true, - "license": "MIT", "optional": true, "os": [ "darwin" @@ -9061,7 +9176,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "license": "ISC", "dependencies": { "is-glob": "^4.0.1" }, @@ -9085,12 +9199,6 @@ "tslib": "2" } }, - "node_modules/glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "license": "BSD-2-Clause" - }, "node_modules/global-dirs": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.1.tgz", @@ -9113,11 +9221,19 @@ "node": ">=10" } }, + "node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/globby": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "license": "MIT", "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", @@ -9185,43 +9301,6 @@ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, - "node_modules/gray-matter": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", - "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", - "license": "MIT", - "dependencies": { - "js-yaml": "^3.13.1", - "kind-of": "^6.0.2", - "section-matter": "^1.0.0", - "strip-bom-string": "^1.0.0" - }, - "engines": { - "node": ">=6.0" - } - }, - "node_modules/gray-matter/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/gray-matter/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/gzip-size": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", @@ -9286,9 +9365,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -9298,16 +9377,16 @@ } }, "node_modules/hast-util-from-parse5": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", - "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.2.tgz", + "integrity": "sha512-SfMzfdAi/zAoZ1KkFEyyeXBn7u/ShQrfd675ZEE9M3qj+PMFX05xubzRyF76CCSJu8au9jgVxDV1+okFvgZU4A==", "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", - "property-information": "^7.0.0", + "property-information": "^6.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" @@ -9356,9 +9435,9 @@ } }, "node_modules/hast-util-to-estree": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/hast-util-to-estree/-/hast-util-to-estree-3.1.3.tgz", - "integrity": "sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/hast-util-to-estree/-/hast-util-to-estree-3.1.0.tgz", + "integrity": "sha512-lfX5g6hqVh9kjS/B9E2gSkvHH4SZNiQFiqWS0x9fENzEl+8W12RqdRxX6d/Cwxi30tPQs3bIO+aolQJNp1bIyw==", "license": "MIT", "dependencies": { "@types/estree": "^1.0.0", @@ -9372,9 +9451,9 @@ "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", - "property-information": "^7.0.0", + "property-information": "^6.0.0", "space-separated-tokens": "^2.0.0", - "style-to-js": "^1.0.0", + "style-to-object": "^0.4.0", "unist-util-position": "^5.0.0", "zwitch": "^2.0.0" }, @@ -9383,10 +9462,25 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-to-estree/node_modules/inline-style-parser": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.1.1.tgz", + "integrity": "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==", + "license": "MIT" + }, + "node_modules/hast-util-to-estree/node_modules/style-to-object": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.4.4.tgz", + "integrity": "sha512-HYNoHZa2GorYNyqiCaBgsxvcJIn7OHq6inEga+E6Ke3m5JkoqpQbnFssk4jwe+K7AhGa2fcha4wSOf1Kn01dMg==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.1.1" + } + }, "node_modules/hast-util-to-jsx-runtime": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", - "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.2.tgz", + "integrity": "sha512-1ngXYb+V9UT5h+PxNRa1O1FYguZK/XL+gkeqvp7EdHlB9oHUG0eYRo/vY5inBdcqo3RkPMC58/H94HvkbfGdyg==", "license": "MIT", "dependencies": { "@types/estree": "^1.0.0", @@ -9399,9 +9493,9 @@ "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", - "property-information": "^7.0.0", + "property-information": "^6.0.0", "space-separated-tokens": "^2.0.0", - "style-to-js": "^1.0.0", + "style-to-object": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" }, @@ -9411,15 +9505,15 @@ } }, "node_modules/hast-util-to-parse5": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", - "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.0.tgz", + "integrity": "sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw==", "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", - "property-information": "^7.0.0", + "property-information": "^6.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" @@ -9443,15 +9537,15 @@ } }, "node_modules/hastscript": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", - "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.0.tgz", + "integrity": "sha512-jzaLBGavEDKHrc5EfFImKN7nZKKBdSLIdGvCwDZ9TfzbF2ffXiov8CKE445L2Z1Ek2t/m4SKQ2j6Ipv7NyUolw==", "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", - "property-information": "^7.0.0", + "property-information": "^6.0.0", "space-separated-tokens": "^2.0.0" }, "funding": { @@ -9733,9 +9827,9 @@ } }, "node_modules/http-proxy-middleware": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz", - "integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==", + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.10.tgz", + "integrity": "sha512-RKzRWNPxUZqbuk3BC5mGVJbBnWgr+diEnjJexIOytFbBzDy88Fbh/YvBr3DsNrl1jYAfjWfpATEv0NO35FDuPQ==", "license": "MIT", "dependencies": { "@types/http-proxy": "^1.17.8", @@ -9784,7 +9878,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "license": "Apache-2.0", "engines": { "node": ">=10.17.0" } @@ -9823,10 +9916,9 @@ } }, "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "license": "MIT", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", + "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", "engines": { "node": ">= 4" } @@ -9844,9 +9936,9 @@ } }, "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -9879,7 +9971,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "license": "MIT", "engines": { "node": ">=8" } @@ -9905,9 +9996,9 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" }, "node_modules/inline-style-parser": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", - "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz", + "integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==", "license": "MIT" }, "node_modules/invariant": { @@ -9919,9 +10010,9 @@ } }, "node_modules/ipaddr.js": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", - "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", + "integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==", "license": "MIT", "engines": { "node": ">= 10" @@ -9961,7 +10052,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" }, @@ -9981,12 +10071,12 @@ } }, "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", "license": "MIT", "dependencies": { - "hasown": "^2.0.2" + "hasown": "^2.0.3" }, "engines": { "node": ">= 0.4" @@ -10009,7 +10099,6 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", - "license": "MIT", "bin": { "is-docker": "cli.js" }, @@ -10033,7 +10122,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -10050,7 +10138,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" }, @@ -10117,9 +10204,9 @@ } }, "node_modules/is-network-error": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.1.tgz", - "integrity": "sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.2.tgz", + "integrity": "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==", "license": "MIT", "engines": { "node": ">=16" @@ -10201,7 +10288,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "license": "MIT", "engines": { "node": ">=8" }, @@ -10218,7 +10304,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", - "license": "MIT", "dependencies": { "is-docker": "^2.0.0" }, @@ -10242,8 +10327,7 @@ "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, "node_modules/isobject": { "version": "3.0.1", @@ -10284,18 +10368,18 @@ } }, "node_modules/jiti": { - "version": "1.21.7", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", - "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "version": "1.21.6", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz", + "integrity": "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==", "license": "MIT", "bin": { "jiti": "bin/jiti.js" } }, "node_modules/joi": { - "version": "17.13.3", - "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", - "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", + "version": "17.13.8", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.8.tgz", + "integrity": "sha512-iPKOGmiRw1jxf/JOPwxmCcUQAOdF359mdzYiP2DJ+TMX0YK2zjK3D+zYOaGjpumWxOFF/l2xVWjRVK5bGSLdEw==", "license": "BSD-3-Clause", "dependencies": { "@hapi/hoek": "^9.3.0", @@ -10311,9 +10395,19 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -10323,9 +10417,9 @@ } }, "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -10345,10 +10439,9 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" }, "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" }, "node_modules/json5": { "version": "2.2.3", @@ -10412,13 +10505,13 @@ } }, "node_modules/launch-editor": { - "version": "2.13.2", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.13.2.tgz", - "integrity": "sha512-4VVDnbOpLXy/s8rdRCSXb+zfMeFR0WlJWpET1iA9CQdlZDfwyLjUuGQzXU4VeOoey6AicSAluWan7Etga6Kcmg==", + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.14.1.tgz", + "integrity": "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==", "license": "MIT", "dependencies": { "picocolors": "^1.1.1", - "shell-quote": "^1.8.3" + "shell-quote": "^1.8.4" } }, "node_modules/leven": { @@ -10447,19 +10540,6 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, - "node_modules/loader-runner": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", - "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", - "license": "MIT", - "engines": { - "node": ">=6.11.5" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/loader-utils": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", @@ -10490,9 +10570,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.debounce": { @@ -10594,14 +10674,13 @@ } }, "node_modules/mdast-util-directive": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-directive/-/mdast-util-directive-3.1.0.tgz", - "integrity": "sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-directive/-/mdast-util-directive-3.0.0.tgz", + "integrity": "sha512-JUpYOqKI4mM3sZcNxmF/ox04XYFFkNwr0CFlrQIkCwbvH0xzMCqkMqAde9wRd80VAhaUrwFwKm2nxretdT1h7Q==", "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", - "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", @@ -10615,9 +10694,9 @@ } }, "node_modules/mdast-util-find-and-replace": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", - "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.1.tgz", + "integrity": "sha512-SG21kZHGC3XRTSUhtofZkBzZTJNM5ecCi0SK2IMKmSXR8vO3peL+kb1O0z7Zl83jKtutG4k5Wv/W7V3/YHvzPA==", "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", @@ -10643,9 +10722,9 @@ } }, "node_modules/mdast-util-from-markdown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", - "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", @@ -10713,9 +10792,9 @@ } }, "node_modules/mdast-util-gfm": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", - "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.0.0.tgz", + "integrity": "sha512-dgQEX5Amaq+DuUqf26jJqSK9qgixgd6rYDHAv4aTBuA92cTknZlKpPfa86Z/s8Dj8xsAQpFfBmPUHWJBWqS4Bw==", "license": "MIT", "dependencies": { "mdast-util-from-markdown": "^2.0.0", @@ -10785,9 +10864,9 @@ "license": "MIT" }, "node_modules/mdast-util-gfm-footnote": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", - "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.0.0.tgz", + "integrity": "sha512-5jOT2boTSVkMnQ7LTrd6n/18kqwjmuYqo7JUPe+tRCY6O7dAuTFMtTPauYYrMPpox9hlN0uOx/FL8XvEfG9/mQ==", "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", @@ -10885,9 +10964,9 @@ } }, "node_modules/mdast-util-mdx-jsx": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", - "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.1.3.tgz", + "integrity": "sha512-bfOjvNt+1AcbPLTFMFWY149nJz0OjmewJs3LQQ5pIyVGxP4CdOqNVJL6kTaM5c68p8q82Xv3nCyFfUnuEcH3UQ==", "license": "MIT", "dependencies": { "@types/estree-jsx": "^1.0.0", @@ -11011,19 +11090,19 @@ } }, "node_modules/memfs": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.57.1.tgz", - "integrity": "sha512-WvzrWPwMQT+PtbX2Et64R4qXKK0fj/8pO85MrUCzymX3twwCiJCdvntW3HdhG1teLJcHDDLIKx5+c3HckWYZtQ==", + "version": "4.75.0", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.75.0.tgz", + "integrity": "sha512-ds3qQCghw0FsG9S+CSAR9F1n5nCwlA7Z5Mf8xgRec6iBAVPPwldmD5UxJhMstWhMhOVBwZ/FoBz09B46m3Le/g==", "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-core": "4.57.1", - "@jsonjoy.com/fs-fsa": "4.57.1", - "@jsonjoy.com/fs-node": "4.57.1", - "@jsonjoy.com/fs-node-builtins": "4.57.1", - "@jsonjoy.com/fs-node-to-fsa": "4.57.1", - "@jsonjoy.com/fs-node-utils": "4.57.1", - "@jsonjoy.com/fs-print": "4.57.1", - "@jsonjoy.com/fs-snapshot": "4.57.1", + "@jsonjoy.com/fs-core": "4.75.0", + "@jsonjoy.com/fs-fsa": "4.75.0", + "@jsonjoy.com/fs-node": "4.75.0", + "@jsonjoy.com/fs-node-builtins": "4.75.0", + "@jsonjoy.com/fs-node-to-fsa": "4.75.0", + "@jsonjoy.com/fs-node-utils": "4.75.0", + "@jsonjoy.com/fs-print": "4.75.0", + "@jsonjoy.com/fs-snapshot": "4.75.0", "@jsonjoy.com/json-pack": "^1.11.0", "@jsonjoy.com/util": "^1.9.0", "glob-to-regex.js": "^1.0.1", @@ -11034,9 +11113,6 @@ "funding": { "type": "github", "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" } }, "node_modules/merge-descriptors": { @@ -11057,7 +11133,6 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "license": "MIT", "engines": { "node": ">= 8" } @@ -11072,9 +11147,9 @@ } }, "node_modules/micromark": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", - "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.1.tgz", + "integrity": "sha512-eBPdkcoCNvYcxQOAKAlceo5SNdzZWfF+FcSupREAzdAh9rRmE239CEQAiTwIgblwnoM8zzj35sZ5ZwvSEOF6Kw==", "funding": [ { "type": "GitHub Sponsors", @@ -11107,9 +11182,9 @@ } }, "node_modules/micromark-core-commonmark": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", - "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.2.tgz", + "integrity": "sha512-FKjQKbxd1cibWMM1P9N+H8TwlgGgSkWZMmfuVucLCHaYqeSvJ0hFeHsIa65pA2nYbes0f8LDHPMrd9X7Ujxg9w==", "funding": [ { "type": "GitHub Sponsors", @@ -11506,9 +11581,9 @@ "license": "MIT" }, "node_modules/micromark-extension-gfm-table": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", - "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.0.tgz", + "integrity": "sha512-Ub2ncQv+fwD70/l4ou27b4YzfNaCJOvyX4HxXU15m7mpYY+rjuWzsLIPZHJL253Z643RpbcP1oeIJlQ/SKW67g==", "license": "MIT", "dependencies": { "devlop": "^1.0.0", @@ -11665,9 +11740,9 @@ "license": "MIT" }, "node_modules/micromark-extension-mdx-expression": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.1.tgz", - "integrity": "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.0.tgz", + "integrity": "sha512-sI0nwhUDz97xyzqJAbHQhp5TfaxEvZZZ2JDqUo+7NvyIYG6BZ5CPPqj2ogUoPJlmXHBnyZUzISg9+oUmU6tUjQ==", "funding": [ { "type": "GitHub Sponsors", @@ -11747,11 +11822,12 @@ "license": "MIT" }, "node_modules/micromark-extension-mdx-jsx": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/micromark-extension-mdx-jsx/-/micromark-extension-mdx-jsx-3.0.2.tgz", - "integrity": "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-jsx/-/micromark-extension-mdx-jsx-3.0.1.tgz", + "integrity": "sha512-vNuFb9czP8QCtAQcEJn0UJQJZA8Dk6DXKBqx+bg/w0WGuSxDxNr7hErW89tHUY31dUW4NqEOWwmEUNhjTFmHkg==", "license": "MIT", "dependencies": { + "@types/acorn": "^4.0.0", "@types/estree": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", @@ -12030,9 +12106,9 @@ "license": "MIT" }, "node_modules/micromark-factory-mdx-expression": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/micromark-factory-mdx-expression/-/micromark-factory-mdx-expression-2.0.3.tgz", - "integrity": "sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-factory-mdx-expression/-/micromark-factory-mdx-expression-2.0.2.tgz", + "integrity": "sha512-5E5I2pFzJyg2CtemqAbcyCktpHXuJbABnsb32wX2U8IQKhhVFBqkcZR5LRm1WVoFqa4kTueZK4abep7wdo9nrw==", "funding": [ { "type": "GitHub Sponsors", @@ -12562,9 +12638,9 @@ "license": "MIT" }, "node_modules/micromark-util-events-to-acorn": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/micromark-util-events-to-acorn/-/micromark-util-events-to-acorn-2.0.3.tgz", - "integrity": "sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-events-to-acorn/-/micromark-util-events-to-acorn-2.0.2.tgz", + "integrity": "sha512-Fk+xmBrOv9QZnEDguL9OI9/NQQp6Hz4FuQ4YmCb/5V7+9eAh1s6AYSvL20kHkD67YIg7EpE54TiSlcsf3vyZgA==", "funding": [ { "type": "GitHub Sponsors", @@ -12577,6 +12653,7 @@ ], "license": "MIT", "dependencies": { + "@types/acorn": "^4.0.0", "@types/estree": "^1.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", @@ -12730,9 +12807,9 @@ "license": "MIT" }, "node_modules/micromark-util-subtokenize": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", - "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.0.3.tgz", + "integrity": "sha512-VXJJuNxYWSoYL6AJ6OQECCFGhIU2GGHMw8tahogePBrjkG8aCCas3ibkp7RnVOSTClg2is05/R7maAhF1XyQMg==", "funding": [ { "type": "GitHub Sponsors", @@ -12784,9 +12861,9 @@ "license": "MIT" }, "node_modules/micromark-util-types": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", - "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.1.tgz", + "integrity": "sha512-534m2WhVTddrcKVepwmVEVnUAmtrx9bfIjNoQHRqfnvdaHQiFytEhJoTgpWJvDEXCO5gLTQh3wYC1PgOJA4NSQ==", "funding": [ { "type": "GitHub Sponsors", @@ -12859,7 +12936,6 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "license": "MIT", "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" @@ -12903,7 +12979,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "license": "MIT", "engines": { "node": ">=6" } @@ -12939,14 +13014,67 @@ "webpack": "^5.0.0" } }, - "node_modules/minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "license": "ISC" - }, - "node_modules/minimatch": { - "version": "3.1.5", + "node_modules/mini-css-extract-plugin/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/mini-css-extract-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/mini-css-extract-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/mini-css-extract-plugin/node_modules/schema-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.4.0.tgz", + "integrity": "sha512-ZzWFVzFgyRHi/T2Ecxm37lL6J9+hZO0P9L7LCuLxAbC6XWxkvxcaQBCXhQxMGn/Tdt9nD7zIBk0ELwhMQ3UEDg==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/minimatch": { + "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", @@ -12965,6 +13093,131 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minimizer-webpack-plugin": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.10.1.tgz", + "integrity": "sha512-+dsZEyTcy1lkq41lxdiOqvb26gXbYGgwY+30w37f9PqUVkuEBejBLDotsHOTjuga9xJyGLW2DqzKDUyJ4W/PMQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.3", + "terser": "^5.51.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@napi-rs/image": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "imagemin": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "sharp": { + "optional": true + }, + "svgo": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/minimizer-webpack-plugin/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/minimizer-webpack-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/minimizer-webpack-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/minimizer-webpack-plugin/node_modules/schema-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.4.0.tgz", + "integrity": "sha512-ZzWFVzFgyRHi/T2Ecxm37lL6J9+hZO0P9L7LCuLxAbC6XWxkvxcaQBCXhQxMGn/Tdt9nD7zIBk0ELwhMQ3UEDg==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/mrmime": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz", @@ -12993,9 +13246,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", "funding": [ { "type": "github", @@ -13034,9 +13287,9 @@ } }, "node_modules/node-emoji": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.2.0.tgz", - "integrity": "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.1.3.tgz", + "integrity": "sha512-E2WEOVsgs7O16zsURJ/eH8BqhF029wGpEOnv7Urwdo2wmQanOACwJQh0devF9D9RhoZru0+9JXIS0dBXIAz+lA==", "license": "MIT", "dependencies": { "@sindresorhus/is": "^4.6.0", @@ -13049,16 +13302,18 @@ } }, "node_modules/node-releases": { - "version": "2.0.36", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", - "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", - "license": "MIT" + "version": "2.0.55", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz", + "integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -13067,7 +13322,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "license": "MIT", "dependencies": { "path-key": "^3.0.0" }, @@ -13112,55 +13366,6 @@ "webpack": "^4.0.0 || ^5.0.0" } }, - "node_modules/null-loader/node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/null-loader/node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "license": "MIT", - "peerDependencies": { - "ajv": "^6.9.1" - } - }, - "node_modules/null-loader/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "license": "MIT" - }, - "node_modules/null-loader/node_modules/schema-utils": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", - "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -13241,7 +13446,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "license": "MIT", "dependencies": { "mimic-fn": "^2.1.0" }, @@ -13256,7 +13460,6 @@ "version": "8.4.2", "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", - "license": "MIT", "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", @@ -13328,7 +13531,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", - "license": "MIT", "dependencies": { "aggregate-error": "^3.0.0" }, @@ -13423,12 +13625,13 @@ } }, "node_modules/parse-entities": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", - "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.1.tgz", + "integrity": "sha512-SWzvYcSJh4d/SGLIOQfZ/CoNv6BTlI6YEQ7Nj82oDVnRpwe/Z/F1EMx42x3JAOwGBlCjeCH0BRJQbQ/opHL17w==", "license": "MIT", "dependencies": { "@types/unist": "^2.0.0", + "character-entities": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", @@ -13472,12 +13675,12 @@ "license": "ISC" }, "node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz", + "integrity": "sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==", "license": "MIT", "dependencies": { - "entities": "^6.0.0" + "entities": "^4.5.0" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" @@ -13512,9 +13715,9 @@ } }, "node_modules/parse5/node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -13559,7 +13762,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", "engines": { "node": ">=8" } @@ -13582,7 +13784,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "license": "MIT", "engines": { "node": ">=8" } @@ -13637,9 +13838,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "funding": [ { "type": "opencollective", @@ -13656,7 +13857,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -13690,9 +13891,9 @@ } }, "node_modules/postcss-attribute-case-insensitive/node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -13934,9 +14135,9 @@ } }, "node_modules/postcss-custom-selectors/node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -13972,9 +14173,9 @@ } }, "node_modules/postcss-dir-pseudo-class/node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -14100,9 +14301,9 @@ } }, "node_modules/postcss-focus-visible/node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -14138,9 +14339,9 @@ } }, "node_modules/postcss-focus-within/node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -14427,9 +14628,9 @@ } }, "node_modules/postcss-modules-local-by-default/node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -14455,9 +14656,9 @@ } }, "node_modules/postcss-modules-scope/node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -14554,9 +14755,9 @@ } }, "node_modules/postcss-nesting/node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -14917,9 +15118,9 @@ } }, "node_modules/postcss-pseudo-class-any-link/node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -15010,9 +15211,9 @@ } }, "node_modules/postcss-selector-not/node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -15023,9 +15224,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -15168,9 +15369,9 @@ } }, "node_modules/property-information": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", - "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz", + "integrity": "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==", "license": "MIT", "funding": { "type": "github", @@ -15208,7 +15409,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "license": "MIT", "engines": { "node": ">=6" } @@ -15237,21 +15437,22 @@ } }, "node_modules/pvutils": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", - "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz", + "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", "license": "MIT", "engines": { "node": ">=16.0.0" } }, "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -15277,8 +15478,7 @@ "type": "consulting", "url": "https://feross.org/support" } - ], - "license": "MIT" + ] }, "node_modules/quick-lru": { "version": "5.1.1", @@ -15411,9 +15611,10 @@ } }, "node_modules/react-loadable-ssr-addon-v5-slorber": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/react-loadable-ssr-addon-v5-slorber/-/react-loadable-ssr-addon-v5-slorber-1.0.1.tgz", - "integrity": "sha512-lq3Lyw1lGku8zUEJPDxsNm1AfYHBrO9Y1+olAYwpUJ2IGFBskM0DMKok97A6LWUpHm+o7IvQBOWu9MLenp9Z+A==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/react-loadable-ssr-addon-v5-slorber/-/react-loadable-ssr-addon-v5-slorber-1.0.3.tgz", + "integrity": "sha512-GXfh9VLwB5ERaCsU6RULh7tkemeX15aNh6wuMEBtfdyMa7fFG8TXrhXlx1SoEK2Ty/l6XIkzzYIQmyaWW3JgdQ==", + "license": "MIT", "dependencies": { "@babel/runtime": "^7.10.3" }, @@ -15491,7 +15692,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "license": "MIT", "dependencies": { "picomatch": "^2.2.1" }, @@ -15515,9 +15715,9 @@ } }, "node_modules/recma-jsx": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/recma-jsx/-/recma-jsx-1.0.1.tgz", - "integrity": "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/recma-jsx/-/recma-jsx-1.0.0.tgz", + "integrity": "sha512-5vwkv65qWwYxg+Atz95acp8DMu1JDSqdGkA2Of1j6rCreyFUE/gp15fC8MnGEuG1W68UKjM6x6+YTWIh7hZM/Q==", "license": "MIT", "dependencies": { "acorn-jsx": "^5.0.0", @@ -15529,9 +15729,6 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" - }, - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "node_modules/recma-parse": { @@ -15579,9 +15776,9 @@ "license": "MIT" }, "node_modules/regenerate-unicode-properties": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", - "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.0.tgz", + "integrity": "sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA==", "license": "MIT", "dependencies": { "regenerate": "^1.4.2" @@ -15590,18 +15787,27 @@ "node": ">=4" } }, + "node_modules/regenerator-transform": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz", + "integrity": "sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.4" + } + }, "node_modules/regexpu-core": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", - "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.2.0.tgz", + "integrity": "sha512-H66BPQMrv+V16t8xtmq+UC0CBpiTBA60V8ibS1QVReIp8T1z8hwFxqcGzm9K6lgsN7sB5edVH8a+ze6Fqm4weA==", "license": "MIT", "dependencies": { "regenerate": "^1.4.2", - "regenerate-unicode-properties": "^10.2.2", + "regenerate-unicode-properties": "^10.2.0", "regjsgen": "^0.8.0", - "regjsparser": "^0.13.0", + "regjsparser": "^0.12.0", "unicode-match-property-ecmascript": "^2.0.0", - "unicode-match-property-value-ecmascript": "^2.2.1" + "unicode-match-property-value-ecmascript": "^2.1.0" }, "engines": { "node": ">=4" @@ -15639,12 +15845,12 @@ "license": "MIT" }, "node_modules/regjsparser": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz", - "integrity": "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==", + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.12.0.tgz", + "integrity": "sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==", "license": "BSD-2-Clause", "dependencies": { - "jsesc": "~3.1.0" + "jsesc": "~3.0.2" }, "bin": { "regjsparser": "bin/parser" @@ -15689,9 +15895,9 @@ } }, "node_modules/remark-directive": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/remark-directive/-/remark-directive-3.0.1.tgz", - "integrity": "sha512-gwglrEQEZcZYgVyG1tQuA+h58EZfq5CSULw7J90AFuCTyib1thgHPoqQ+h9iFvU6R+vnZ5oNFQR5QKgGpk741A==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/remark-directive/-/remark-directive-3.0.0.tgz", + "integrity": "sha512-l1UyWJ6Eg1VPU7Hm/9tt0zKtReJQNOA4+iDMAxTyZNWnJnFlbS/7zhiel/rogTLQ2vMYwDzSJa4BiVNqGlqIMA==", "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", @@ -15737,9 +15943,9 @@ } }, "node_modules/remark-gfm": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", - "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.0.tgz", + "integrity": "sha512-U92vJgBPkbw4Zfu/IiW2oTZLSL3Zpv+uI7My2eq8JxKgqraFdU8YUGicEJCEgSbeaG+QDFqIcwwfMTOEelPxuA==", "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", @@ -15755,9 +15961,9 @@ } }, "node_modules/remark-mdx": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.1.1.tgz", - "integrity": "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.1.0.tgz", + "integrity": "sha512-Ngl/H3YXyBV9RcRNdlYsZujAmhsxwzxpDzpDEhFBVAGthS4GDgnctpDjgFl/ULx5UEDzqtW1cyBSNKqYYrqLBA==", "license": "MIT", "dependencies": { "mdast-util-mdx": "^3.0.0", @@ -15785,9 +15991,9 @@ } }, "node_modules/remark-rehype": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", - "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.1.tgz", + "integrity": "sha512-g/osARvjkBXb6Wo0XvAeXQohVta8i84ACbenPpoSsxTOQH/Ae0/RGP4WZgnMH5pMLpsj4FG7OHmcIcXxpza8eQ==", "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", @@ -15828,15 +16034,6 @@ "strip-ansi": "^6.0.1" } }, - "node_modules/repeat-string": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", - "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", - "license": "MIT", - "engines": { - "node": ">=0.10" - } - }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -15860,11 +16057,12 @@ "license": "MIT" }, "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", "license": "MIT", "dependencies": { + "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" @@ -15922,10 +16120,9 @@ } }, "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "license": "MIT", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", "engines": { "iojs": ">=1.0.0", "node": ">=0.10.0" @@ -15991,7 +16188,6 @@ "url": "https://feross.org/support" } ], - "license": "MIT", "dependencies": { "queue-microtask": "^1.2.2" } @@ -16023,9 +16219,9 @@ "license": "MIT" }, "node_modules/sax": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", - "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", "license": "BlueOak-1.0.0", "engines": { "node": ">=11.0.0" @@ -16046,15 +16242,13 @@ "license": "Apache-2.0" }, "node_modules/schema-utils": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", - "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", - "license": "MIT", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" }, "engines": { "node": ">= 10.13.0" @@ -16196,9 +16390,9 @@ } }, "node_modules/serialize-javascript": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz", - "integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.2.tgz", + "integrity": "sha512-GL2BWwVa6JydKO6l/ljVgjAZF4QJ3S7dWDWi53s5GZT8PJzD2fNxi67HANQGKAqPrwEpL5ba7gUBGi0Ls/sEoQ==", "license": "BSD-3-Clause", "engines": { "node": ">=20.0.0" @@ -16372,7 +16566,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" }, @@ -16384,15 +16577,14 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz", + "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -16402,14 +16594,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -16421,13 +16613,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -16537,7 +16729,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "license": "MIT", "engines": { "node": ">=8" } @@ -16638,12 +16829,6 @@ "wbuf": "^1.7.3" } }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "license": "BSD-3-Clause" - }, "node_modules/srcset": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/srcset/-/srcset-4.0.0.tgz", @@ -16778,7 +16963,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "license": "MIT", "engines": { "node": ">=6" } @@ -16791,22 +16975,13 @@ "node": ">=0.10.0" } }, - "node_modules/style-to-js": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", - "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", - "license": "MIT", - "dependencies": { - "style-to-object": "1.0.14" - } - }, "node_modules/style-to-object": { - "version": "1.0.14", - "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", - "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.8.tgz", + "integrity": "sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g==", "license": "MIT", "dependencies": { - "inline-style-parser": "0.2.7" + "inline-style-parser": "0.2.4" } }, "node_modules/stylehacks": { @@ -16858,9 +17033,9 @@ "license": "MIT" }, "node_modules/svgo": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.3.tgz", - "integrity": "sha512-+wn7I4p7YgJhHs38k2TNjy1vCfPIfLIJWR5MnCStsN8WuuTcBnRKcMHQLMM2ijxGZmDoZwNv8ipl5aTTen62ng==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.5.tgz", + "integrity": "sha512-8SQMzdrvWaD8deUmrnYB+ASyxBVgWUOilg+A75nE/76WdLpj6LopCwiAVvkzkcqy/9b7t2Mg7faFLjg0ZRcZ3w==", "license": "MIT", "dependencies": { "commander": "^7.2.0", @@ -16892,9 +17067,9 @@ } }, "node_modules/svgo/node_modules/css-select": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", - "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", + "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", "license": "BSD-2-Clause", "dependencies": { "boolbase": "^1.0.0", @@ -16937,9 +17112,9 @@ } }, "node_modules/svgo/node_modules/domutils": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", - "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", "license": "BSD-2-Clause", "dependencies": { "dom-serializer": "^2.0.0", @@ -16963,9 +17138,9 @@ } }, "node_modules/tapable": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", - "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "license": "MIT", "engines": { "node": ">=6" @@ -16976,9 +17151,9 @@ } }, "node_modules/terser": { - "version": "5.46.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", - "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", + "version": "5.51.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.51.2.tgz", + "integrity": "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==", "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", @@ -16994,9 +17169,9 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz", - "integrity": "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==", + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==", "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", @@ -17015,26 +17190,106 @@ "webpack": "^5.1.0" }, "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, "@swc/core": { "optional": true }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, "esbuild": { "optional": true }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, "uglify-js": { "optional": true } } }, + "node_modules/terser-webpack-plugin/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/terser-webpack-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/terser-webpack-plugin/node_modules/schema-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.4.0.tgz", + "integrity": "sha512-ZzWFVzFgyRHi/T2Ecxm37lL6J9+hZO0P9L7LCuLxAbC6XWxkvxcaQBCXhQxMGn/Tdt9nD7zIBk0ELwhMQ3UEDg==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/terser/node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" }, "node_modules/thingies": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.6.0.tgz", - "integrity": "sha512-rMHRjmlFLM1R96UYPvpmnc3LYtdFrT33JIB7L9hetGue1qAPfn1N2LJeEjxUSidu1Iku+haLZXDuEXUHNGO/lg==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.6.1.tgz", + "integrity": "sha512-cV/CMGTK3M4MlnJ/0At6ismOw/A0EEniDNScajjz/Br3c1sqE72YD01rGpPTKwd27wAxI5Pr+6+0w8yofzFRYw==", "license": "MIT", "engines": { "node": ">=10.18" @@ -17244,18 +17499,18 @@ } }, "node_modules/unicode-match-property-value-ecmascript": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", - "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.0.tgz", + "integrity": "sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg==", "license": "MIT", "engines": { "node": ">=4" } }, "node_modules/unicode-property-aliases-ecmascript": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", - "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", + "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", "license": "MIT", "engines": { "node": ">=4" @@ -17295,9 +17550,9 @@ } }, "node_modules/unist-util-is": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", - "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", + "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==", "license": "MIT", "dependencies": { "@types/unist": "^3.0.0" @@ -17347,9 +17602,9 @@ } }, "node_modules/unist-util-visit": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", - "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", + "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", @@ -17362,9 +17617,9 @@ } }, "node_modules/unist-util-visit-parents": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", - "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz", + "integrity": "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==", "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", @@ -17393,9 +17648,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", "funding": [ { "type": "opencollective", @@ -17496,7 +17751,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" } @@ -17528,55 +17782,6 @@ } } }, - "node_modules/url-loader/node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/url-loader/node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "license": "MIT", - "peerDependencies": { - "ajv": "^6.9.1" - } - }, - "node_modules/url-loader/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "license": "MIT" - }, - "node_modules/url-loader/node_modules/schema-utils": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", - "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -17610,6 +17815,7 @@ "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", "license": "MIT", "bin": { "uuid": "dist/bin/uuid" @@ -17658,9 +17864,9 @@ } }, "node_modules/vfile-message": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", - "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz", + "integrity": "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==", "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", @@ -17672,12 +17878,11 @@ } }, "node_modules/watchpack": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", - "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", + "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", "license": "MIT", "dependencies": { - "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" }, "engines": { @@ -17704,36 +17909,30 @@ } }, "node_modules/webpack": { - "version": "5.105.4", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz", - "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", + "version": "5.110.3", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.110.3.tgz", + "integrity": "sha512-GuizBzRvo9YPpyoNMf3ag7AzxbaW85qrRSqTha345KyJbAFPt3/cMzBM0h+RWg7SK/7DdzRLINP3LvQ0hvr4hg==", "license": "MIT", "dependencies": { - "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.16.0", - "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.20.0", - "es-module-lexer": "^2.0.0", - "eslint-scope": "5.1.1", + "enhanced-resolve": "^5.24.4", + "es-module-lexer": "^2.1.0", "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", - "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.3.1", - "mime-types": "^2.1.27", + "mime-db": "^1.54.0", + "minimizer-webpack-plugin": "^5.7.0", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.3.17", - "watchpack": "^2.5.1", - "webpack-sources": "^3.3.4" + "watchpack": "^2.5.2", + "webpack-sources": "^3.5.1" }, "bin": { "webpack": "bin/webpack.js" @@ -17785,9 +17984,9 @@ } }, "node_modules/webpack-dev-middleware": { - "version": "7.4.5", - "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-7.4.5.tgz", - "integrity": "sha512-uxQ6YqGdE4hgDKNf7hUiPXOdtkXvBJXrfEGYSx7P7LC8hnUYGK70X6xQXUvXeNyBDDcsiQXpG2m3G9vxowaEuA==", + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-7.4.6.tgz", + "integrity": "sha512-yBWCMvIfUmuhAE8vdqUKzH0vg9kuWN0KeG4vBnqRplUFHRU7lMQjkiJWxVQzvo2BTewqhPhDlMB41rAt2jVA9A==", "license": "MIT", "dependencies": { "colorette": "^2.0.10", @@ -17813,6 +18012,40 @@ } } }, + "node_modules/webpack-dev-middleware/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/webpack-dev-middleware/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/webpack-dev-middleware/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/webpack-dev-middleware/node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -17839,18 +18072,41 @@ } }, "node_modules/webpack-dev-middleware/node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", "license": "MIT", "engines": { "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/webpack-dev-middleware/node_modules/schema-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.4.0.tgz", + "integrity": "sha512-ZzWFVzFgyRHi/T2Ecxm37lL6J9+hZO0P9L7LCuLxAbC6XWxkvxcaQBCXhQxMGn/Tdt9nD7zIBk0ELwhMQ3UEDg==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/webpack-dev-server": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.3.tgz", - "integrity": "sha512-9Gyu2F7+bg4Vv+pjbovuYDhHX+mqdqITykfzdM9UyKqKHlsE5aAjRhR+oOEfXW5vBeu8tarzlJFIZva4ZjAdrQ==", + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.6.tgz", + "integrity": "sha512-HNLRmamRvVavZQ+avceZifmv8hmdUjg43t6MI4SqJDwFdW7RPQwH5vzGhDRZSX59SgfbeHhLnq3g+uooWo7pVw==", "license": "MIT", "dependencies": { "@types/bonjour": "^3.5.13", @@ -17871,7 +18127,7 @@ "graceful-fs": "^4.2.6", "http-proxy-middleware": "^2.0.9", "ipaddr.js": "^2.1.0", - "launch-editor": "^2.6.1", + "launch-editor": "^2.14.1", "open": "^10.0.3", "p-retry": "^6.2.0", "schema-utils": "^4.2.0", @@ -17904,6 +18160,34 @@ } } }, + "node_modules/webpack-dev-server/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/webpack-dev-server/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, "node_modules/webpack-dev-server/node_modules/define-lazy-prop": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", @@ -17916,6 +18200,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/webpack-dev-server/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/webpack-dev-server/node_modules/open": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", @@ -17934,10 +18224,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/webpack-dev-server/node_modules/schema-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.4.0.tgz", + "integrity": "sha512-ZzWFVzFgyRHi/T2Ecxm37lL6J9+hZO0P9L7LCuLxAbC6XWxkvxcaQBCXhQxMGn/Tdt9nD7zIBk0ELwhMQ3UEDg==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/webpack-dev-server/node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -17969,84 +18278,107 @@ } }, "node_modules/webpack-sources": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.4.tgz", - "integrity": "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==", + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.1.tgz", + "integrity": "sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==", "license": "MIT", "engines": { "node": ">=10.13.0" } }, - "node_modules/webpackbar": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/webpackbar/-/webpackbar-6.0.1.tgz", - "integrity": "sha512-TnErZpmuKdwWBdMoexjio3KKX6ZtoKHRVvLIU0A47R0VVBDtx3ZyOJDktgYixhoJokZTYTt1Z37OkO9pnGJa9Q==", + "node_modules/webpack/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { - "ansi-escapes": "^4.3.2", - "chalk": "^4.1.2", - "consola": "^3.2.3", - "figures": "^3.2.0", - "markdown-table": "^2.0.0", - "pretty-time": "^1.1.0", - "std-env": "^3.7.0", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=14.21.3" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, - "peerDependencies": { - "webpack": "3 || 4 || 5" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/webpackbar/node_modules/markdown-table": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-2.0.0.tgz", - "integrity": "sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A==", + "node_modules/webpack/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "license": "MIT", "dependencies": { - "repeat-string": "^1.0.0" + "fast-deep-equal": "^3.1.3" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "peerDependencies": { + "ajv": "^8.8.2" } }, - "node_modules/webpackbar/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/webpack/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/webpack/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.4.0.tgz", + "integrity": "sha512-ZzWFVzFgyRHi/T2Ecxm37lL6J9+hZO0P9L7LCuLxAbC6XWxkvxcaQBCXhQxMGn/Tdt9nD7zIBk0ELwhMQ3UEDg==", "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "@types/json-schema": "^7.0.15", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", + "ajv-keywords": "^5.1.0" }, "engines": { - "node": ">=8" + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, - "node_modules/webpackbar/node_modules/wrap-ansi": { + "node_modules/webpackbar": { "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "resolved": "https://registry.npmjs.org/webpackbar/-/webpackbar-7.0.0.tgz", + "integrity": "sha512-aS9soqSO2iCHgqHoCrj4LbfGQUboDCYJPSFOAchEK+9psIjNrfSWW4Y0YEz67MKURNvMmfo0ycOg9d/+OOf9/Q==", "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "ansis": "^3.2.0", + "consola": "^3.2.3", + "pretty-time": "^1.1.0", + "std-env": "^3.7.0" }, "engines": { - "node": ">=10" + "node": ">=14.21.3" }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "peerDependencies": { + "@rspack/core": "*", + "webpack": "3 || 4 || 5" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } } }, "node_modules/websocket-driver": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", - "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz", + "integrity": "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==", "license": "Apache-2.0", "dependencies": { "http-parser-js": ">=0.5.1", @@ -18070,7 +18402,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", "dependencies": { "isexe": "^2.0.0" }, @@ -18164,9 +18495,10 @@ } }, "node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", + "license": "MIT", "engines": { "node": ">=8.3.0" }, diff --git a/doc/package.json b/doc/package.json index 767a8ec..add250f 100644 --- a/doc/package.json +++ b/doc/package.json @@ -15,8 +15,8 @@ "typecheck": "tsc" }, "dependencies": { - "@docusaurus/core": "3.9.2", - "@docusaurus/preset-classic": "3.9.2", + "@docusaurus/core": "3.10.2", + "@docusaurus/preset-classic": "3.10.2", "@mdx-js/react": "^3.1.0", "clsx": "^2.1.1", "prism-react-renderer": "^2.4.1", @@ -24,9 +24,9 @@ "react-dom": "^18.3.1" }, "devDependencies": { - "@docusaurus/module-type-aliases": "3.9.2", - "@docusaurus/tsconfig": "3.9.2", - "@docusaurus/types": "3.9.2", + "@docusaurus/module-type-aliases": "3.10.2", + "@docusaurus/tsconfig": "3.10.2", + "@docusaurus/types": "3.10.2", "typescript": "~5.7.2" }, "browserslist": { diff --git a/doc/sidebars.ts b/doc/sidebars.ts index fb83bc6..70a55d7 100644 --- a/doc/sidebars.ts +++ b/doc/sidebars.ts @@ -31,10 +31,12 @@ const sidebars: SidebarsConfig = { label: 'Guides', items: [ 'guides/supabase-usage', + 'guides/supabase-realtime', 'guides/supabase-security', 'guides/supabase-triggers', 'guides/supabase-views', 'guides/supabase-indexes', + 'guides/testing', ], }, ], diff --git a/doc/src/css/custom.css b/doc/src/css/custom.css index 2bc6a4c..8e0ace3 100644 --- a/doc/src/css/custom.css +++ b/doc/src/css/custom.css @@ -6,25 +6,25 @@ /* You can override the default Infima variables here. */ :root { - --ifm-color-primary: #2e8555; - --ifm-color-primary-dark: #29784c; - --ifm-color-primary-darker: #277148; - --ifm-color-primary-darkest: #205d3b; - --ifm-color-primary-light: #33925d; - --ifm-color-primary-lighter: #359962; - --ifm-color-primary-lightest: #3cad6e; + --ifm-color-primary: #f0ff94; + --ifm-color-primary-dark: #eaff6c; + --ifm-color-primary-darker: #e8ff58; + --ifm-color-primary-darkest: #dfff1b; + --ifm-color-primary-light: #f6ffbc; + --ifm-color-primary-lighter: #f8ffd0; + --ifm-color-primary-lightest: #ffffff; --ifm-code-font-size: 95%; --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1); } /* For readability concerns, you should choose a lighter palette in dark mode. */ [data-theme='dark'] { - --ifm-color-primary: #25c2a0; - --ifm-color-primary-dark: #21af90; - --ifm-color-primary-darker: #1fa588; - --ifm-color-primary-darkest: #1a8870; - --ifm-color-primary-light: #29d5b0; - --ifm-color-primary-lighter: #32d8b4; - --ifm-color-primary-lightest: #4fddbf; + --ifm-color-primary: #f0ff94; + --ifm-color-primary-dark: #eaff6c; + --ifm-color-primary-darker: #e8ff58; + --ifm-color-primary-darkest: #dfff1b; + --ifm-color-primary-light: #f6ffbc; + --ifm-color-primary-lighter: #f8ffd0; + --ifm-color-primary-lightest: #ffffff; --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3); } diff --git a/doc/static/img/apple-touch-icon.png b/doc/static/img/apple-touch-icon.png new file mode 100644 index 0000000..8dfee8c Binary files /dev/null and b/doc/static/img/apple-touch-icon.png differ diff --git a/doc/static/img/demo.gif b/doc/static/img/demo.gif new file mode 100644 index 0000000..f7dea82 Binary files /dev/null and b/doc/static/img/demo.gif differ diff --git a/doc/static/img/example_light.jpg b/doc/static/img/example_light.jpg new file mode 100644 index 0000000..f75d325 Binary files /dev/null and b/doc/static/img/example_light.jpg differ diff --git a/doc/static/img/favicon.ico b/doc/static/img/favicon.ico index 5029e5d..12cfca4 100644 Binary files a/doc/static/img/favicon.ico and b/doc/static/img/favicon.ico differ diff --git a/doc/static/img/flayer_chat_ui_screenshot.png b/doc/static/img/flayer_chat_ui_screenshot.png deleted file mode 100644 index 49a67a4..0000000 Binary files a/doc/static/img/flayer_chat_ui_screenshot.png and /dev/null differ diff --git a/doc/static/img/flyer-chat-logo-dark.svg b/doc/static/img/flyer-chat-logo-dark.svg new file mode 100644 index 0000000..efb61c7 --- /dev/null +++ b/doc/static/img/flyer-chat-logo-dark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/doc/static/img/flyer-chat-logo-light.svg b/doc/static/img/flyer-chat-logo-light.svg new file mode 100644 index 0000000..ce5e8b5 --- /dev/null +++ b/doc/static/img/flyer-chat-logo-light.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/doc/static/img/insideapp-logo-dark-text.png b/doc/static/img/insideapp-logo-dark-text.png new file mode 100644 index 0000000..767922e Binary files /dev/null and b/doc/static/img/insideapp-logo-dark-text.png differ diff --git a/doc/static/img/insideapp-logo-light-text.png b/doc/static/img/insideapp-logo-light-text.png new file mode 100644 index 0000000..4d5a73c Binary files /dev/null and b/doc/static/img/insideapp-logo-light-text.png differ diff --git a/doc/static/img/logo.svg b/doc/static/img/logo.svg deleted file mode 100644 index 5c29d6e..0000000 --- a/doc/static/img/logo.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/doc/static/img/social-card.png b/doc/static/img/social-card.png index e3c8c5f..1071b86 100644 Binary files a/doc/static/img/social-card.png and b/doc/static/img/social-card.png differ diff --git a/doc/static/img/supabase-disable-confirm-email.png b/doc/static/img/supabase-disable-confirm-email.png index 12f75c4..ab95f99 100644 Binary files a/doc/static/img/supabase-disable-confirm-email.png and b/doc/static/img/supabase-disable-confirm-email.png differ diff --git a/doc/static/img/supabase-project-credential.png b/doc/static/img/supabase-project-credential.png index ebf2942..35aac60 100644 Binary files a/doc/static/img/supabase-project-credential.png and b/doc/static/img/supabase-project-credential.png differ diff --git a/doc/static/img/supabase-schema-exposure-setting.png b/doc/static/img/supabase-schema-exposure-setting.png index f9007db..4531a4a 100644 Binary files a/doc/static/img/supabase-schema-exposure-setting.png and b/doc/static/img/supabase-schema-exposure-setting.png differ diff --git a/example/.gitignore b/example/.gitignore index 8235a04..3b37beb 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -45,3 +45,4 @@ app.*.map.json /lib/supabase_options_for_test.dart /lib/main_test.dart +/utils/project-secrets.local.json diff --git a/example/README.md b/example/README.md index 2b3fce4..7eeca82 100644 --- a/example/README.md +++ b/example/README.md @@ -1,16 +1,78 @@ -# example +# flutter_supabase_chat_core example -A new Flutter project. +A Telegram-like chat app built on `flutter_supabase_chat_core` and +`flutter_chat_ui` 2.x. It runs on Android, iOS, web, Windows, macOS and Linux. -## Getting Started +![Demo: real-time messages, reactions, replies and voice messages](../doc/static/img/demo.gif) -This project is a starting point for a Flutter application. +![Example app, light theme](../doc/static/img/example_light.jpg) -A few resources to get you started if this is your first Flutter project: +## Features -- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) +- Sign up / sign in (names go into the sign-up metadata, which the database + copies into the chat profile), "Fill in a demo account" button. +- Chats list: pagination, search, unread badges, pin to top, mute for an hour + or for ever, leave/delete from a long press. +- New direct chat or group with user search; group info page to rename, add + and remove members, promote/demote admins, leave or delete the group. +- Conversation: light/dark theme, wallpaper, bubbles with tails, day chips, + author names and avatars in groups, swipe to reply with quotes, edit, + delete, copy, reactions, pinned messages banner, read receipts, typing + indicator, online/last seen status, search in chat with jump to message, + failed messages with retry. +- Attachments: photos (camera on Android/iOS), files, voice messages with a + live waveform on every platform, web included. +- Profile: name, avatar, theme mode. +- Two-pane layout (list + conversation) on wide screens. -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev/), which offers tutorials, -samples, guidance on mobile development, and a full API reference. +## Run + +You need a Supabase project with the chat schema applied: see the +[main README](../README.md#supabase-setup). + +**With `supabase_prepare.sh`/`.ps1`** (in `utils/`): it creates or picks a +project, applies the SQL scripts and writes the gitignored +`lib/supabase_options_for_test.dart` and `lib/main_test.dart`: + +```bash +flutter run -t lib/main_test.dart +``` + +**With any project**, pass its URL and anon/publishable key at build time +(`lib/supabase_options.dart` reads them, no file to edit): + +```bash +flutter run --dart-define=SUPABASE_URL=https://.supabase.co \ + --dart-define=SUPABASE_ANON_KEY= +``` + +Don't commit real keys in `lib/supabase_options.dart`: it is a tracked +placeholder. + +## Demo data on a local stack + +From the repository root, with the [Supabase CLI](https://supabase.com/docs/guides/cli/getting-started): + +```bash +supabase start +tool/local_db.sh && tool/seed_demo.sh +cd example +flutter run --dart-define=SUPABASE_URL=http://127.0.0.1:54321 \ + --dart-define=SUPABASE_ANON_KEY= +``` + +Sign in as `alice@demo.dev` / `Demo-password-1` (also `bob@`, `carol@`, +`dave@`, `erin@demo.dev`): direct chats, two groups (one pinned and muted), +replies, reactions, a pinned message, unread messages and a voice message. + +## Notes + +- `flutter_chat_ui` is pinned to `'>=2.11.1 <2.12.0'`: 2.12.0 moved the + `Composer` to the experimental `material_ui` package, which crashes inside a + standard `Scaffold` ("No Material widget found"). +- `ChatTheme` is built field by field in `lib/src/theme/app_theme.dart`: + since `flutter_chat_core` 2.9.1 `ChatTheme.fromThemeData` takes a + `material_ui` theme, not Flutter's `ThemeData`. +- Voice messages use a custom `AudioMessageBubble` + (`lib/src/widgets/audio_message_bubble.dart`): the published + `flyer_chat_audio_message` package has no usable widget yet. diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 4f711d8..d283182 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ + UIApplicationSupportsIndirectInputEvents + NSMicrophoneUsageDescription + This app uses the microphone to record audio messages. diff --git a/example/lib/main.dart b/example/lib/main.dart index 931fa2e..4ecffd6 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,40 +1,14 @@ import 'package:flutter/material.dart'; -import 'package:flutter_supabase_chat_core/flutter_supabase_chat_core.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; -import 'src/pages/home.dart'; -import 'src/theme/color_schemes.dart'; +import 'src/app.dart'; import 'supabase_options.dart'; -void main() async { +Future main() async { WidgetsFlutterBinding.ensureInitialized(); await Supabase.initialize( url: supabaseOptions.url, - anonKey: supabaseOptions.anonKey, + publishableKey: supabaseOptions.anonKey, ); - runApp(const MyApp()); -} - -class MyApp extends StatelessWidget { - const MyApp({ - super.key, - }); - - @override - Widget build(BuildContext context) => MaterialApp( - title: 'Supabase Chat', - debugShowCheckedModeBanner: false, - theme: ThemeData( - useMaterial3: true, - colorScheme: lightColorScheme, - ), - darkTheme: ThemeData( - useMaterial3: true, - colorScheme: darkColorScheme, - ), - themeMode: ThemeMode.dark, - home: const UserOnlineStateObserver( - child: HomePage(), - ), - ); + runApp(const ChatExampleApp()); } diff --git a/example/lib/src/app.dart b/example/lib/src/app.dart new file mode 100644 index 0000000..b3b9e04 --- /dev/null +++ b/example/lib/src/app.dart @@ -0,0 +1,95 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_supabase_chat_core/flutter_supabase_chat_core.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import 'pages/auth_page.dart'; +import 'pages/home_page.dart'; +import 'theme/app_theme.dart'; + +/// App-wide theme mode, switched from the profile sheet. +final themeMode = ValueNotifier(ThemeMode.system); + +class ChatExampleApp extends StatelessWidget { + const ChatExampleApp({super.key}); + + @override + Widget build(BuildContext context) => ValueListenableBuilder( + valueListenable: themeMode, + builder: (context, mode, _) => MaterialApp( + title: 'Supabase Chat', + debugShowCheckedModeBanner: false, + theme: AppTheme.light, + darkTheme: AppTheme.dark, + themeMode: mode, + home: const UserOnlineStateObserver(child: AuthGate()), + ), + ); +} + +/// Shows [AuthPage] or [HomePage] depending on the auth state. +class AuthGate extends StatefulWidget { + const AuthGate({super.key}); + + @override + State createState() => _AuthGateState(); +} + +class _AuthGateState extends State { + late final StreamSubscription _subscription; + Session? _session = Supabase.instance.client.auth.currentSession; + bool _profileReady = false; + + @override + void initState() { + super.initState(); + _subscription = Supabase.instance.client.auth.onAuthStateChange.listen( + (data) => _onSession(data.session), + onError: (Object error) { + if (mounted) setState(() => _session = null); + }, + ); + _onSession(_session); + } + + Future _onSession(Session? session) async { + final changedUser = session?.user.id != _session?.user.id; + setState(() { + _session = session; + if (changedUser || session == null) _profileReady = false; + }); + if (session == null || _profileReady) return; + try { + final profile = await SupabaseChatCore.instance.refreshLoggedUser(); + if (profile == null) { + // A valid session without a chat profile: the account was deleted + // (or the database reset). Start over from the sign-in page. + await Supabase.instance.client.auth.signOut(); + return; + } + } on SupabaseChatCoreException { + // The home page works without a cached profile (it's only used for + // the own avatar); the next auth event retries. + } + if (mounted && _session?.user.id == session.user.id) { + setState(() => _profileReady = true); + } + } + + @override + void dispose() { + _subscription.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => AnimatedSwitcher( + duration: const Duration(milliseconds: 350), + child: _session == null + ? const AuthPage() + : !_profileReady + ? const Scaffold(body: Center(child: CircularProgressIndicator())) + : HomePage(key: ValueKey(_session!.user.id)), + ); +} diff --git a/example/lib/src/class/message_status_ex.dart b/example/lib/src/class/message_status_ex.dart deleted file mode 100644 index d936a92..0000000 --- a/example/lib/src/class/message_status_ex.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_chat_types/flutter_chat_types.dart' as types; - -extension MessageStatusEx on types.Status { - IconData get icon { - switch (this) { - case types.Status.delivered: - return Icons.done_all; - case types.Status.error: - return Icons.error_outline; - case types.Status.seen: - return Icons.done_all; - case types.Status.sending: - return Icons.timelapse; - case types.Status.sent: - return Icons.done; - } - } -} diff --git a/example/lib/src/class/user_ex.dart b/example/lib/src/class/user_ex.dart deleted file mode 100644 index 3f6490b..0000000 --- a/example/lib/src/class/user_ex.dart +++ /dev/null @@ -1,7 +0,0 @@ -import 'package:flutter_chat_types/flutter_chat_types.dart' as types; - -extension UserEx on types.User { - String getUserName() => firstName != null || lastName != null - ? '${firstName ?? ''} ${lastName ?? ''}'.trim() - : id; -} diff --git a/example/lib/src/pages/auth.dart b/example/lib/src/pages/auth.dart deleted file mode 100644 index 68249b7..0000000 --- a/example/lib/src/pages/auth.dart +++ /dev/null @@ -1,102 +0,0 @@ -import 'package:faker/faker.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_chat_types/flutter_chat_types.dart' as types; -import 'package:flutter_login/flutter_login.dart'; -import 'package:flutter_supabase_chat_core/flutter_supabase_chat_core.dart'; -import 'package:supabase_flutter/supabase_flutter.dart'; - -import 'home.dart'; - -class AuthScreen extends StatefulWidget { - const AuthScreen({ - super.key, - }); - - @override - State createState() => _AuthScreenState(); -} - -class _AuthScreenState extends State { - final faker = Faker(); - - @override - Widget build(BuildContext context) => FlutterLogin( - logo: const AssetImage('assets/flyer_logo.png'), - savedEmail: faker.internet.email(), - savedPassword: 'Qawsed1-', //faker.internet.password(), - navigateBackAfterRecovery: true, - additionalSignupFields: [ - UserFormField( - keyName: 'first_name', - displayName: 'First name', - defaultValue: faker.person.firstName(), - fieldValidator: (value) { - if (value == null || value == '') return 'Required'; - return null; - }, - ), - UserFormField( - keyName: 'last_name', - displayName: 'Last name', - defaultValue: faker.person.lastName(), - fieldValidator: (value) { - if (value == null || value == '') return 'Required'; - return null; - }, - ), - ], - passwordValidator: (value) { - if (value!.isEmpty) { - return 'Password is empty'; - } - return null; - }, - onLogin: (loginData) async { - try { - await Supabase.instance.client.auth.signInWithPassword( - email: loginData.name, - password: loginData.password, - ); - } catch (e) { - return e.toString(); - } - return null; - }, - onSignup: (signupData) async { - try { - final response = await Supabase.instance.client.auth.signUp( - email: signupData.name, - password: signupData.password!, - ); - await SupabaseChatCore.instance.updateUser( - types.User( - firstName: signupData.additionalSignupData!['first_name'], - id: response.user!.id, - lastName: signupData.additionalSignupData!['last_name'], - ), - ); - } catch (e) { - return e.toString(); - } - return null; - }, - onSubmitAnimationCompleted: () { - Navigator.of(context).pushReplacement( - MaterialPageRoute( - builder: (context) => const HomePage(), - ), - ); - }, - onRecoverPassword: (name) async { - try { - await Supabase.instance.client.auth.resetPasswordForEmail( - name, - ); - } catch (e) { - return e.toString(); - } - return null; - }, - initialAuthMode: AuthMode.signup, - ); -} diff --git a/example/lib/src/pages/auth_page.dart b/example/lib/src/pages/auth_page.dart new file mode 100644 index 0000000..7af565c --- /dev/null +++ b/example/lib/src/pages/auth_page.dart @@ -0,0 +1,302 @@ +import 'package:faker/faker.dart'; +import 'package:flutter/material.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +/// Sign in / sign up. First and last name go into the sign-up metadata, +/// which the `chats.handle_new_user` trigger copies into the chat profile. +class AuthPage extends StatefulWidget { + const AuthPage({super.key}); + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + final _formKey = GlobalKey(); + final _email = TextEditingController(); + final _password = TextEditingController(); + final _firstName = TextEditingController(); + final _lastName = TextEditingController(); + bool _signUp = false; + bool _busy = false; + bool _obscure = true; + + @override + void dispose() { + _email.dispose(); + _password.dispose(); + _firstName.dispose(); + _lastName.dispose(); + super.dispose(); + } + + void _fillDemoAccount() { + final faker = Faker(); + final first = faker.person.firstName(); + final last = faker.person.lastName(); + setState(() { + _signUp = true; + _firstName.text = first; + _lastName.text = last; + _email.text = + '${first.toLowerCase()}.${last.toLowerCase()}${faker.randomGenerator.integer(999)}@example.com'; + _password.text = 'Demo-password-1'; + }); + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + setState(() => _busy = true); + final auth = Supabase.instance.client.auth; + final messenger = ScaffoldMessenger.of(context); + try { + if (_signUp) { + final response = await auth.signUp( + email: _email.text.trim(), + password: _password.text, + data: { + 'first_name': _firstName.text.trim(), + 'last_name': _lastName.text.trim(), + }, + ); + if (response.session == null) { + messenger.showSnackBar( + const SnackBar( + content: Text('Check your inbox to confirm your email.'), + ), + ); + } + } else { + await auth.signInWithPassword( + email: _email.text.trim(), + password: _password.text, + ); + } + } on AuthException catch (e) { + messenger.showSnackBar(SnackBar(content: Text(e.message))); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + String? _required(String? value) => + value == null || value.trim().isEmpty ? 'Required' : null; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + return Scaffold( + body: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + scheme.primaryContainer, + scheme.surface, + scheme.tertiaryContainer, + ], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + spacing: 24, + children: [ + _Logo(scheme: scheme), + Column( + spacing: 6, + children: [ + Text( + 'Supabase Chat', + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + Text( + 'Realtime chat on Supabase + Flyer Chat', + style: theme.textTheme.bodyLarge?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + Card( + elevation: 0, + color: scheme.surface.withValues(alpha: .85), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(28), + ), + child: Padding( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: AutofillGroup( + child: Column( + spacing: 14, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SegmentedButton( + segments: const [ + ButtonSegment( + value: false, + label: Text('Sign in'), + ), + ButtonSegment( + value: true, + label: Text('Create account'), + ), + ], + selected: {_signUp}, + showSelectedIcon: false, + onSelectionChanged: (s) => + setState(() => _signUp = s.first), + ), + AnimatedSize( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + child: _signUp + ? Row( + spacing: 12, + children: [ + Expanded( + child: TextFormField( + controller: _firstName, + textCapitalization: + TextCapitalization.words, + autofillHints: const [ + AutofillHints.givenName, + ], + decoration: + const InputDecoration( + labelText: 'First name', + ), + validator: _required, + ), + ), + Expanded( + child: TextFormField( + controller: _lastName, + textCapitalization: + TextCapitalization.words, + autofillHints: const [ + AutofillHints.familyName, + ], + decoration: + const InputDecoration( + labelText: 'Last name', + ), + ), + ), + ], + ) + : const SizedBox(width: double.infinity), + ), + TextFormField( + controller: _email, + keyboardType: TextInputType.emailAddress, + autofillHints: const [AutofillHints.email], + decoration: const InputDecoration( + labelText: 'Email', + prefixIcon: Icon(Icons.alternate_email), + ), + validator: (v) => v != null && v.contains('@') + ? null + : 'Enter a valid email', + ), + TextFormField( + controller: _password, + obscureText: _obscure, + autofillHints: [ + _signUp + ? AutofillHints.newPassword + : AutofillHints.password, + ], + decoration: InputDecoration( + labelText: 'Password', + prefixIcon: const Icon(Icons.lock_outline), + suffixIcon: IconButton( + icon: Icon( + _obscure + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + onPressed: () => + setState(() => _obscure = !_obscure), + ), + ), + validator: (v) => v != null && v.length >= 6 + ? null + : 'At least 6 characters', + onFieldSubmitted: (_) => _submit(), + ), + const SizedBox(height: 4), + FilledButton( + onPressed: _busy ? null : _submit, + child: _busy + ? const SizedBox.square( + dimension: 22, + child: CircularProgressIndicator( + strokeWidth: 2.5, + ), + ) + : Text( + _signUp + ? 'Create account' + : 'Sign in', + ), + ), + TextButton.icon( + onPressed: _busy ? null : _fillDemoAccount, + icon: const Icon(Icons.auto_awesome_outlined), + label: const Text('Fill in a demo account'), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _Logo extends StatelessWidget { + const _Logo({required this.scheme}); + + final ColorScheme scheme; + + @override + Widget build(BuildContext context) => Container( + width: 88, + height: 88, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(28), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [scheme.primary, scheme.tertiary], + ), + boxShadow: [ + BoxShadow( + color: scheme.primary.withValues(alpha: .35), + blurRadius: 24, + offset: const Offset(0, 10), + ), + ], + ), + child: Icon(Icons.forum_rounded, size: 46, color: scheme.onPrimary), + ); +} diff --git a/example/lib/src/pages/home.dart b/example/lib/src/pages/home.dart deleted file mode 100644 index e5a3870..0000000 --- a/example/lib/src/pages/home.dart +++ /dev/null @@ -1,111 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:supabase_flutter/supabase_flutter.dart'; - -import 'auth.dart'; -import 'rooms.dart'; -import 'users.dart'; - -class HomePage extends StatefulWidget { - const HomePage({super.key}); - - @override - State createState() => _HomePageState(); -} - -class _HomePageState extends State { - bool _error = false; - bool _initialized = false; - User? _user; - - @override - void initState() { - initializeSupabase(); - super.initState(); - } - - void initializeSupabase() async { - try { - Supabase.instance.client.auth.onAuthStateChange.listen((data) { - setState(() { - _user = data.session?.user; - }); - }); - setState(() { - _initialized = true; - }); - } catch (e) { - setState(() { - _error = true; - }); - } - } - - void logout() async { - await Supabase.instance.client.auth.signOut(); - } - - @override - Widget build(BuildContext context) { - if (_error) { - return Container(); - } - - if (!_initialized) { - return Container(); - } - - return Scaffold( - appBar: AppBar( - actions: [ - IconButton( - icon: const Icon( - Icons.add, - ), - onPressed: _user == null - ? null - : () { - Navigator.of(context).push( - MaterialPageRoute( - fullscreenDialog: true, - builder: (context) => const UsersPage(), - ), - ); - }, - ), - ], - leading: IconButton( - icon: const Icon(Icons.logout), - onPressed: _user == null ? null : logout, - ), - systemOverlayStyle: SystemUiOverlayStyle.light, - title: const Text('Rooms'), - ), - body: _user == null - ? Container( - alignment: Alignment.center, - margin: const EdgeInsets.only( - bottom: 200, - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('Not authenticated'), - TextButton( - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute( - fullscreenDialog: true, - builder: (context) => const AuthScreen(), - ), - ); - }, - child: const Text('Login'), - ), - ], - ), - ) - : RoomsPage(), - ); - } -} diff --git a/example/lib/src/pages/home_page.dart b/example/lib/src/pages/home_page.dart new file mode 100644 index 0000000..a413222 --- /dev/null +++ b/example/lib/src/pages/home_page.dart @@ -0,0 +1,317 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_supabase_chat_core/flutter_supabase_chat_core.dart'; + +import '../widgets/chat_wallpaper.dart'; +import '../widgets/empty_state.dart'; +import '../widgets/room_tile.dart'; +import '../widgets/user_avatar.dart'; +import 'new_chat_sheet.dart'; +import 'profile_sheet.dart'; +import 'room_page.dart'; + +/// Chats list + conversation, Telegram style. Side by side on wide +/// screens (tablet, desktop, web), list → pushed conversation on phones. +class HomePage extends StatefulWidget { + const HomePage({super.key}); + + @override + State createState() => _HomePageState(); +} + +class _HomePageState extends State { + static const _wideBreakpoint = 840.0; + final _rooms = SupabaseChatRoomsController()..loadFirstPage(); + final _scroll = ScrollController(); + final _searchController = TextEditingController(); + bool _searching = false; + ChatRoom? _selected; + + String get _me => SupabaseChatCore.instance.loggedUserId!; + + @override + void initState() { + super.initState(); + _scroll.addListener(() { + if (_scroll.position.extentAfter < 400) _rooms.loadNextPage(); + }); + } + + @override + void dispose() { + _rooms.dispose(); + _scroll.dispose(); + _searchController.dispose(); + super.dispose(); + } + + bool _isWide(BuildContext context) => + MediaQuery.sizeOf(context).width >= _wideBreakpoint; + + Future _open(ChatRoom room) async { + _rooms.markLocallyAsRead(room.id); + if (_isWide(context)) { + setState(() => _selected = room); + return; + } + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => RoomPage( + room: room, + rooms: _rooms, + onLeft: () => Navigator.pop(context), + ), + ), + ); + _rooms.markLocallyAsRead(room.id); + } + + Future _newChat() async { + final room = await NewChatSheet.show(context); + if (room != null && mounted) await _open(room); + } + + Future _guard(Future Function() action) async { + try { + await action(); + } on SupabaseChatCoreException catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${e.message}: ${e.cause ?? ''}')), + ); + } + } + } + + Future _roomMenu(ChatRoom room) async { + unawaited(HapticFeedback.mediumImpact()); + final isGroup = room.type == ChatRoomType.group; + final scheme = Theme.of(context).colorScheme; + await showModalBottomSheet( + context: context, + builder: (sheetContext) { + void run(Future Function() action) { + Navigator.pop(sheetContext); + _guard(action); + } + + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: Icon( + room.isPinned + ? Icons.push_pin_outlined + : Icons.push_pin_rounded, + ), + title: Text(room.isPinned ? 'Unpin' : 'Pin to top'), + onTap: () => + run(() => _rooms.pinRoom(room.id, pinned: !room.isPinned)), + ), + if (room.isMuted) + ListTile( + leading: const Icon(Icons.volume_up_outlined), + title: const Text('Unmute'), + onTap: () => run(() => _rooms.unmuteRoom(room.id)), + ) + else ...[ + ListTile( + leading: const Icon(Icons.notifications_paused_outlined), + title: const Text('Mute for 1 hour'), + onTap: () => run( + () => _rooms.muteRoom( + room.id, + duration: const Duration(hours: 1), + ), + ), + ), + ListTile( + leading: const Icon(Icons.volume_off_outlined), + title: const Text('Mute forever'), + onTap: () => run(() => _rooms.muteRoom(room.id)), + ), + ], + ListTile( + leading: Icon( + isGroup ? Icons.logout_rounded : Icons.delete_outline_rounded, + color: scheme.error, + ), + title: Text( + isGroup ? 'Leave group' : 'Delete chat', + style: TextStyle(color: scheme.error), + ), + onTap: () => run( + () => isGroup + ? SupabaseChatCore.instance.leaveRoom(room.id) + : SupabaseChatCore.instance.deleteRoom(room.id), + ), + ), + const SizedBox(height: 8), + ], + ), + ); + }, + ); + } + + PreferredSizeWidget _appBar(BuildContext context) { + final me = SupabaseChatCore.instance.loggedUser; + if (_searching) { + return AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back_rounded), + onPressed: () { + setState(() => _searching = false); + _searchController.clear(); + _rooms.search(''); + }, + ), + titleSpacing: 0, + title: TextField( + controller: _searchController, + autofocus: true, + onChanged: _rooms.search, + decoration: const InputDecoration( + hintText: 'Search', + filled: false, + border: InputBorder.none, + focusedBorder: InputBorder.none, + ), + ), + ); + } + return AppBar( + leading: Padding( + padding: const EdgeInsets.all(8), + child: InkWell( + customBorder: const CircleBorder(), + onTap: () async { + await ProfileSheet.show(context); + if (mounted) setState(() {}); + }, + child: me == null + ? const Icon(Icons.menu_rounded) + : UserAvatar.user(me, size: 36), + ), + ), + title: ListenableBuilder( + listenable: _rooms, + builder: (context, _) => Row( + spacing: 8, + children: [ + const Text('Chats'), + if (_rooms.isLoading && _rooms.rooms.isNotEmpty) + const SizedBox.square( + dimension: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ], + ), + ), + actions: [ + IconButton( + tooltip: 'Search', + onPressed: () => setState(() => _searching = true), + icon: const Icon(Icons.search_rounded), + ), + const SizedBox(width: 4), + ], + ); + } + + Widget _list(BuildContext context) => Scaffold( + appBar: _appBar(context), + body: ListenableBuilder( + listenable: _rooms, + builder: (context, _) { + if (_rooms.rooms.isEmpty) { + if (_rooms.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + if (_rooms.error != null) { + return EmptyState( + icon: Icons.cloud_off_rounded, + title: 'Cannot load your chats', + message: '${_rooms.error!.cause ?? _rooms.error}', + action: FilledButton.tonal( + onPressed: _rooms.refresh, + child: const Text('Retry'), + ), + ); + } + return EmptyState( + icon: Icons.chat_bubble_outline_rounded, + title: _rooms.filter.isEmpty ? 'No chats yet' : 'No results', + message: _rooms.filter.isEmpty + ? 'Tap the pencil to message someone or create a group.' + : 'Try a different name.', + ); + } + return RefreshIndicator( + onRefresh: _rooms.refresh, + child: ListView.separated( + controller: _scroll, + padding: const EdgeInsets.only(bottom: 96), + itemCount: _rooms.rooms.length + (_rooms.hasMore ? 1 : 0), + separatorBuilder: (_, _) => const Divider(indent: 76), + itemBuilder: (context, index) { + if (index >= _rooms.rooms.length) { + return const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ); + } + final room = _rooms.rooms[index]; + return RoomTile( + room: room, + currentUserId: _me, + selected: _selected?.id == room.id, + onTap: () => _open(room), + onLongPress: () => _roomMenu(room), + ); + }, + ), + ); + }, + ), + floatingActionButton: FloatingActionButton( + tooltip: 'New message', + onPressed: _newChat, + child: const Icon(Icons.edit_rounded), + ), + ); + + @override + Widget build(BuildContext context) { + if (!_isWide(context)) return _list(context); + final selected = _selected; + return Scaffold( + body: Row( + children: [ + SizedBox(width: 380, child: _list(context)), + const VerticalDivider(width: 1), + Expanded( + child: selected == null + ? const ChatWallpaper( + child: Center( + child: ServiceChip( + text: 'Select a chat to start messaging', + ), + ), + ) + : RoomPage( + key: ValueKey(selected.id), + room: selected, + rooms: _rooms, + embedded: true, + onLeft: () => setState(() => _selected = null), + ), + ), + ], + ), + ); + } +} diff --git a/example/lib/src/pages/image_viewer_page.dart b/example/lib/src/pages/image_viewer_page.dart new file mode 100644 index 0000000..879d2d7 --- /dev/null +++ b/example/lib/src/pages/image_viewer_page.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart' as core; +import 'package:flutter_supabase_chat_core/flutter_supabase_chat_core.dart'; + +/// Full-screen, zoomable image. +class ImageViewerPage extends StatelessWidget { + const ImageViewerPage({super.key, required this.message}); + + final core.ImageMessage message; + + @override + Widget build(BuildContext context) => Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + ), + body: Center( + child: InteractiveViewer( + maxScale: 5, + child: Hero( + tag: message.id, + child: Image.network( + message.source, + headers: SupabaseChatCore.instance.httpSupabaseHeaders, + loadingBuilder: (context, child, progress) => progress == null + ? child + : const CircularProgressIndicator(color: Colors.white), + errorBuilder: (_, _, _) => const Icon( + Icons.broken_image_outlined, + color: Colors.white54, + size: 64, + ), + ), + ), + ), + ), + ); +} diff --git a/example/lib/src/pages/message_search_page.dart b/example/lib/src/pages/message_search_page.dart new file mode 100644 index 0000000..f62a2ec --- /dev/null +++ b/example/lib/src/pages/message_search_page.dart @@ -0,0 +1,168 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart' as core; +import 'package:flutter_supabase_chat_core/flutter_supabase_chat_core.dart'; + +import '../utils/format.dart'; +import '../widgets/empty_state.dart'; +import '../widgets/user_avatar.dart'; + +/// Search messages of a room by text. Pops the tapped message, so the +/// caller can jump to it. +class MessageSearchPage extends StatefulWidget { + const MessageSearchPage({super.key, required this.room}); + + final ChatRoom room; + + @override + State createState() => _MessageSearchPageState(); +} + +class _MessageSearchPageState extends State { + Timer? _debounce; + String _query = ''; + List _results = const []; + bool _loading = false; + + @override + void dispose() { + _debounce?.cancel(); + super.dispose(); + } + + void _onChanged(String value) { + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 300), () => _search(value)); + } + + Future _search(String query) async { + setState(() { + _query = query.trim(); + _loading = _query.isNotEmpty; + }); + if (_query.isEmpty) { + setState(() => _results = const []); + return; + } + try { + final results = await SupabaseChatCore.instance.searchMessages( + widget.room.id, + _query, + ); + if (mounted && query.trim() == _query) { + setState(() => _results = results); + } + } on SupabaseChatCoreException catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Search failed: ${e.cause ?? e}')), + ); + } + } finally { + if (mounted) setState(() => _loading = false); + } + } + + /// Highlights every case-insensitive occurrence of the query. + TextSpan _highlight(String text, TextStyle? base, Color color) { + if (_query.isEmpty) return TextSpan(text: text, style: base); + final lower = text.toLowerCase(); + final needle = _query.toLowerCase(); + final spans = []; + var start = 0; + while (true) { + final index = lower.indexOf(needle, start); + if (index < 0) break; + spans + ..add(TextSpan(text: text.substring(start, index))) + ..add( + TextSpan( + text: text.substring(index, index + needle.length), + style: TextStyle(color: color, fontWeight: FontWeight.w700), + ), + ); + start = index + needle.length; + } + spans.add(TextSpan(text: text.substring(start))); + return TextSpan(style: base, children: spans); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( + appBar: AppBar( + titleSpacing: 0, + title: TextField( + autofocus: true, + onChanged: _onChanged, + textInputAction: TextInputAction.search, + onSubmitted: _search, + decoration: const InputDecoration( + hintText: 'Search messages', + filled: false, + border: InputBorder.none, + focusedBorder: InputBorder.none, + ), + ), + bottom: _loading + ? const PreferredSize( + preferredSize: Size.fromHeight(2), + child: LinearProgressIndicator(minHeight: 2), + ) + : null, + ), + body: _query.isEmpty + ? const EmptyState( + icon: Icons.manage_search_rounded, + title: 'Search this chat', + message: 'Type a word to find messages containing it.', + ) + : _results.isEmpty && !_loading + ? const EmptyState( + icon: Icons.search_off_rounded, + title: 'No results', + message: 'Try a different word.', + ) + : ListView.separated( + itemCount: _results.length, + separatorBuilder: (_, _) => const Divider(indent: 72), + itemBuilder: (context, index) { + final message = _results[index]; + final author = widget.room.member(message.authorId); + final text = message is core.TextMessage ? message.text : ''; + return ListTile( + leading: author == null + ? const CircleAvatar(child: Icon(Icons.person_outline)) + : UserAvatar.user(author), + title: Row( + children: [ + Expanded( + child: Text( + author?.fullName ?? 'Unknown', + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + Text( + formatListTime(context, message.createdAt), + style: theme.textTheme.labelSmall, + ), + ], + ), + subtitle: Text.rich( + _highlight( + text, + theme.textTheme.bodyMedium, + theme.colorScheme.primary, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + onTap: () => Navigator.of(context).pop(message), + ); + }, + ), + ); + } +} diff --git a/example/lib/src/pages/new_chat_sheet.dart b/example/lib/src/pages/new_chat_sheet.dart new file mode 100644 index 0000000..37f46cf --- /dev/null +++ b/example/lib/src/pages/new_chat_sheet.dart @@ -0,0 +1,326 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_supabase_chat_core/flutter_supabase_chat_core.dart'; + +import '../widgets/user_avatar.dart'; + +/// Picks people to talk to: tap someone for a direct chat, or switch to +/// "New group", select several people and name the group. Pops the +/// resulting [ChatRoom]. +/// +/// With [excludeUserIds] it works as a member picker for an existing group +/// instead, popping the selected users as a `List`. +class NewChatSheet extends StatefulWidget { + const NewChatSheet({super.key, this.excludeUserIds}); + + final Set? excludeUserIds; + + /// Opens the sheet and returns the created/opened room, if any. + static Future show(BuildContext context) => + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (_) => const NewChatSheet(), + ); + + /// Opens the sheet as a member picker. + static Future?> pickMembers( + BuildContext context, { + required Set excludeUserIds, + }) => showModalBottomSheet>( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (_) => NewChatSheet(excludeUserIds: excludeUserIds), + ); + + @override + State createState() => _NewChatSheetState(); +} + +class _NewChatSheetState extends State { + static const _pageSize = 30; + final _scroll = ScrollController(); + final _groupName = TextEditingController(); + final List _users = []; + final Map _selected = {}; + Timer? _debounce; + String _filter = ''; + bool _loading = false; + bool _hasMore = true; + bool _creating = false; + late bool _groupMode = widget.excludeUserIds != null; + int _generation = 0; + + bool get _pickingMembers => widget.excludeUserIds != null; + + @override + void initState() { + super.initState(); + _scroll.addListener(() { + if (_scroll.position.extentAfter < 300) _load(); + }); + _load(reset: true); + } + + @override + void dispose() { + _debounce?.cancel(); + _scroll.dispose(); + _groupName.dispose(); + super.dispose(); + } + + Future _load({bool reset = false}) async { + if (!reset && (_loading || !_hasMore)) return; + final generation = reset ? ++_generation : _generation; + setState(() { + _loading = true; + if (reset) { + _users.clear(); + _hasMore = true; + } + }); + try { + final page = await SupabaseChatCore.instance.users( + filter: _filter, + offset: _users.length, + limit: _pageSize, + excludeLoggedUser: true, + ); + if (!mounted || generation != _generation) return; + setState(() { + _users.addAll( + page.where((u) => !(widget.excludeUserIds?.contains(u.id) ?? false)), + ); + _hasMore = page.length >= _pageSize; + }); + } on SupabaseChatCoreException catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Cannot load users: ${e.cause ?? e}')), + ); + } + } finally { + if (mounted && generation == _generation) { + setState(() => _loading = false); + } + } + } + + void _onSearch(String value) { + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 300), () { + _filter = value; + _load(reset: true); + }); + } + + Future _openDirect(ChatUser user) async { + setState(() => _creating = true); + try { + final room = await SupabaseChatCore.instance.createRoom(user); + if (mounted) Navigator.of(context).pop(room); + } on SupabaseChatCoreException catch (e) { + _showError(e); + } + } + + Future _createGroup() async { + final name = _groupName.text.trim(); + if (name.isEmpty || _selected.isEmpty) return; + setState(() => _creating = true); + try { + final room = await SupabaseChatCore.instance.createGroupRoom( + name: name, + users: _selected.values.toList(), + ); + if (mounted) Navigator.of(context).pop(room); + } on SupabaseChatCoreException catch (e) { + _showError(e); + } + } + + void _showError(SupabaseChatCoreException e) { + if (!mounted) return; + setState(() => _creating = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Something went wrong: ${e.cause ?? e}')), + ); + } + + void _toggle(ChatUser user) => setState(() { + if (_selected.remove(user.id) == null) _selected[user.id] = user; + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final canSubmit = _pickingMembers + ? _selected.isNotEmpty + : _selected.isNotEmpty && _groupName.text.trim().isNotEmpty; + + return DraggableScrollableSheet( + expand: false, + initialChildSize: .9, + minChildSize: .5, + maxChildSize: 1, + builder: (context, _) => Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 12, 8), + child: Row( + children: [ + Expanded( + child: Text( + _pickingMembers + ? 'Add members' + : _groupMode + ? 'New group' + : 'New chat', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + ), + if (!_pickingMembers) + TextButton.icon( + onPressed: () => setState(() { + _groupMode = !_groupMode; + _selected.clear(); + }), + icon: Icon( + _groupMode + ? Icons.person_outline + : Icons.group_add_outlined, + ), + label: Text(_groupMode ? 'Direct chat' : 'New group'), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + spacing: 10, + children: [ + if (_groupMode && !_pickingMembers) + TextField( + controller: _groupName, + textCapitalization: TextCapitalization.sentences, + onChanged: (_) => setState(() {}), + decoration: const InputDecoration( + hintText: 'Group name', + prefixIcon: Icon(Icons.edit_outlined), + ), + ), + TextField( + onChanged: _onSearch, + decoration: const InputDecoration( + hintText: 'Search people', + prefixIcon: Icon(Icons.search_rounded), + ), + ), + ], + ), + ), + if (_selected.isNotEmpty) + SizedBox( + height: 56, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.fromLTRB(16, 10, 16, 0), + children: [ + for (final user in _selected.values) + Padding( + padding: const EdgeInsets.only(right: 8), + child: InputChip( + avatar: UserAvatar.user(user, size: 24), + label: Text(user.firstName ?? user.fullName), + onDeleted: () => _toggle(user), + ), + ), + ], + ), + ), + const SizedBox(height: 8), + Expanded( + child: _users.isEmpty && !_loading + ? Center( + child: Text( + 'Nobody found', + style: TextStyle(color: scheme.onSurfaceVariant), + ), + ) + : ListView.builder( + controller: _scroll, + padding: const EdgeInsets.symmetric(horizontal: 8), + itemCount: _users.length + (_hasMore ? 1 : 0), + itemBuilder: (context, index) { + if (index >= _users.length) { + return const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ); + } + final user = _users[index]; + final selected = _selected.containsKey(user.id); + return ListTile( + enabled: !_creating, + leading: UserAvatar.user(user, showOnlineStatus: true), + title: Text( + user.fullName.isEmpty + ? 'Unnamed user' + : user.fullName, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + trailing: _groupMode + ? Checkbox( + value: selected, + onChanged: (_) => _toggle(user), + ) + : const Icon(Icons.chevron_right_rounded), + onTap: () => + _groupMode ? _toggle(user) : _openDirect(user), + ); + }, + ), + ), + if (_groupMode) + SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: FilledButton.icon( + onPressed: !canSubmit || _creating + ? null + : _pickingMembers + ? () => + Navigator.of(context).pop(_selected.values.toList()) + : _createGroup, + icon: _creating + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon( + _pickingMembers + ? Icons.person_add_alt_1_outlined + : Icons.check_rounded, + ), + label: Text( + _pickingMembers + ? 'Add ${_selected.length} member${_selected.length == 1 ? '' : 's'}' + : 'Create group (${_selected.length})', + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/example/lib/src/pages/profile_sheet.dart b/example/lib/src/pages/profile_sheet.dart new file mode 100644 index 0000000..0ca6e3a --- /dev/null +++ b/example/lib/src/pages/profile_sheet.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_supabase_chat_core/flutter_supabase_chat_core.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../app.dart'; +import '../widgets/user_avatar.dart'; + +/// Own profile: avatar, name, theme and sign out. +class ProfileSheet extends StatefulWidget { + const ProfileSheet({super.key}); + + static Future show(BuildContext context) => showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (_) => const ProfileSheet(), + ); + + @override + State createState() => _ProfileSheetState(); +} + +class _ProfileSheetState extends State { + final _chat = SupabaseChatCore.instance; + late final _firstName = TextEditingController( + text: _chat.loggedUser?.firstName, + ); + late final _lastName = TextEditingController( + text: _chat.loggedUser?.lastName, + ); + bool _saving = false; + + @override + void dispose() { + _firstName.dispose(); + _lastName.dispose(); + super.dispose(); + } + + Future _run(Future Function() action, String error) async { + setState(() => _saving = true); + try { + await action(); + } on SupabaseChatCoreException catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('$error: ${e.cause ?? e}'))); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _pickAvatar() async { + final image = await ImagePicker().pickImage( + source: ImageSource.gallery, + maxWidth: 512, + maxHeight: 512, + imageQuality: 85, + ); + if (image == null) return; + final bytes = await image.readAsBytes(); + await _run( + () => _chat.uploadUserAvatar(image.name, bytes), + 'Cannot update the avatar', + ); + } + + Future _save() => _run(() async { + final user = _chat.loggedUser; + if (user == null) return; + await _chat.updateUser( + user.copyWith( + firstName: _firstName.text.trim(), + lastName: _lastName.text.trim(), + ), + ); + if (mounted) Navigator.of(context).pop(); + }, 'Cannot save the profile'); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final user = _chat.loggedUser; + final email = Supabase.instance.client.auth.currentUser?.email ?? ''; + return Padding( + padding: EdgeInsets.fromLTRB( + 24, + 0, + 24, + 24 + MediaQuery.viewInsetsOf(context).bottom, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 16, + children: [ + Stack( + children: [ + if (user != null) UserAvatar.user(user, size: 96), + Positioned( + right: 0, + bottom: 0, + child: IconButton.filled( + onPressed: _saving ? null : _pickAvatar, + icon: const Icon(Icons.photo_camera_outlined, size: 18), + ), + ), + ], + ), + Text(email, style: theme.textTheme.bodyMedium), + Row( + spacing: 12, + children: [ + Expanded( + child: TextField( + controller: _firstName, + decoration: const InputDecoration(labelText: 'First name'), + ), + ), + Expanded( + child: TextField( + controller: _lastName, + decoration: const InputDecoration(labelText: 'Last name'), + ), + ), + ], + ), + ValueListenableBuilder( + valueListenable: themeMode, + builder: (context, mode, _) => SegmentedButton( + segments: const [ + ButtonSegment( + value: ThemeMode.system, + icon: Icon(Icons.brightness_auto_outlined), + label: Text('System'), + ), + ButtonSegment( + value: ThemeMode.light, + icon: Icon(Icons.light_mode_outlined), + label: Text('Light'), + ), + ButtonSegment( + value: ThemeMode.dark, + icon: Icon(Icons.dark_mode_outlined), + label: Text('Dark'), + ), + ], + selected: {mode}, + onSelectionChanged: (s) => themeMode.value = s.first, + ), + ), + FilledButton( + onPressed: _saving ? null : _save, + child: const Text('Save'), + ), + TextButton.icon( + onPressed: () async { + Navigator.of(context).pop(); + await Supabase.instance.client.auth.signOut(); + }, + icon: const Icon(Icons.logout_rounded), + label: const Text('Sign out'), + style: TextButton.styleFrom( + foregroundColor: theme.colorScheme.error, + ), + ), + ], + ), + ); + } +} diff --git a/example/lib/src/pages/room.dart b/example/lib/src/pages/room.dart deleted file mode 100644 index 4d713c4..0000000 --- a/example/lib/src/pages/room.dart +++ /dev/null @@ -1,252 +0,0 @@ -import 'package:file_picker/file_picker.dart'; -import 'package:file_saver/file_saver.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_chat_types/flutter_chat_types.dart' as types; -import 'package:flutter_chat_ui/flutter_chat_ui.dart'; -import 'package:flutter_supabase_chat_core/flutter_supabase_chat_core.dart'; -import 'package:http/http.dart' as http; -import 'package:image_picker/image_picker.dart'; -import 'package:open_filex/open_filex.dart'; - -class RoomPage extends StatefulWidget { - const RoomPage({ - super.key, - required this.room, - }); - - final types.Room room; - - @override - State createState() => _RoomPageState(); -} - -class _RoomPageState extends State { - bool _isAttachmentUploading = false; - late SupabaseChatController _chatController; - - @override - void initState() { - _chatController = SupabaseChatController(room: widget.room); - super.initState(); - } - - @override - void dispose() { - _chatController.dispose(); - super.dispose(); - } - - void _handleAttachmentPressed() { - showModalBottomSheet( - context: context, - builder: (BuildContext context) => SafeArea( - child: SizedBox( - height: 130, - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - TextButton( - onPressed: () { - Navigator.pop(context); - _handleImageSelection(); - }, - child: const Row( - children: [ - Icon(Icons.image), - Text('Image'), - ], - ), - ), - TextButton( - onPressed: () { - Navigator.pop(context); - _handleFileSelection(); - }, - child: const Row( - children: [ - Icon(Icons.attach_file), - Text('File'), - ], - ), - ), - ], - ), - ), - ), - ), - ); - } - - void _handleFileSelection() async { - final result = await FilePicker.platform.pickFiles( - type: FileType.any, - withData: true, - ); - if (result != null && result.files.single.bytes != null) { - _setAttachmentUploading(true); - try { - final bytes = result.files.single.bytes; - final name = result.files.single.name; - final uploadResult = await SupabaseChatCore.instance - .uploadAsset(widget.room, name, bytes!); - final message = types.PartialFile( - mimeType: uploadResult.mimeType, - name: name, - size: result.files.single.size, - uri: uploadResult.url, - ); - await SupabaseChatCore.instance.sendMessage(message, widget.room.id); - _setAttachmentUploading(false); - } finally { - _setAttachmentUploading(false); - } - } - } - - void _handleImageSelection() async { - final result = await ImagePicker().pickImage( - imageQuality: 70, - maxWidth: 1440, - source: ImageSource.gallery, - ); - if (result != null) { - _setAttachmentUploading(true); - final bytes = await result.readAsBytes(); - final size = bytes.length; - final image = await decodeImageFromList(bytes); - final name = result.name; - try { - final uploadResult = await SupabaseChatCore.instance - .uploadAsset(widget.room, name, bytes); - final message = types.PartialImage( - height: image.height.toDouble(), - name: name, - size: size, - uri: uploadResult.url, - width: image.width.toDouble(), - ); - await SupabaseChatCore.instance.sendMessage( - message, - widget.room.id, - ); - _setAttachmentUploading(false); - } finally { - _setAttachmentUploading(false); - } - } - } - - void _handleMessageTap(BuildContext _, types.Message message) async { - if (message is types.FileMessage) { - final client = http.Client(); - final request = await client.get( - Uri.parse(message.uri), - headers: SupabaseChatCore.instance.httpSupabaseHeaders, - ); - final result = await FileSaver.instance.saveFile( - name: message.uri.split('/').last, - bytes: request.bodyBytes, - ); - await OpenFilex.open(result); - } - } - - Future _handlePreviewDataFetched( - types.TextMessage message, - types.PreviewData previewData, - ) async { - final updatedMessage = message.copyWith(previewData: previewData); - - await SupabaseChatCore.instance - .updateMessage(updatedMessage, widget.room.id); - } - - Future _handleSendPressed(types.PartialText message) async { - await _chatController.endTyping(); - await SupabaseChatCore.instance.sendMessage( - message, - widget.room.id, - ); - } - - void _setAttachmentUploading(bool uploading) { - setState(() { - _isAttachmentUploading = uploading; - }); - } - - @override - Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - systemOverlayStyle: SystemUiOverlayStyle.light, - title: const Text('Chat'), - ), - body: StreamBuilder>( - initialData: const [], - stream: _chatController.messages, - builder: (context, messages) => StreamBuilder>( - initialData: const [], - stream: _chatController.typingUsers, - builder: (context, users) => Chat( - showUserNames: true, - showUserAvatars: true, - theme: const DefaultChatTheme( - messageMaxWidth: 600, - ), - typingIndicatorOptions: TypingIndicatorOptions( - typingUsers: users.data ?? [], - ), - isAttachmentUploading: _isAttachmentUploading, - messages: messages.data ?? [], - onAttachmentPressed: _handleAttachmentPressed, - onMessageTap: _handleMessageTap, - onPreviewDataFetched: _handlePreviewDataFetched, - onSendPressed: _handleSendPressed, - user: SupabaseChatCore.instance.loggedUser!, - imageHeaders: SupabaseChatCore.instance.httpSupabaseHeaders, - onMessageVisibilityChanged: (message, visible) async { - if (message.status != types.Status.seen && - message.author.id != - SupabaseChatCore.instance.loggedSupabaseUser!.id) { - await SupabaseChatCore.instance.updateMessage( - message.copyWith(status: types.Status.seen), - widget.room.id, - ); - } - }, - onEndReached: _chatController.loadPreviousMessages, - inputOptions: InputOptions( - enabled: true, - onTextChanged: (text) => _chatController.onTyping(), - ), - onMessageLongPress: (context, p1) async { - final confirm = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text('Confirmation of deletion'), - content: Text('Do you really want to delete this message?'), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(true), - child: Text('Delete'), - ), - ], - ), - ); - if (confirm == true) { - await _chatController.deleteMessage(widget.room.id, p1.id); - } - }, - ), - ), - ), - ); -} diff --git a/example/lib/src/pages/room_info_page.dart b/example/lib/src/pages/room_info_page.dart new file mode 100644 index 0000000..0b8edfe --- /dev/null +++ b/example/lib/src/pages/room_info_page.dart @@ -0,0 +1,326 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_supabase_chat_core/flutter_supabase_chat_core.dart'; + +import '../widgets/user_avatar.dart'; +import 'new_chat_sheet.dart'; + +/// Group details: name, members and their roles. Admins rename the group, +/// add/remove members and promote/demote admins; everyone can leave. +/// Pops `true` when the user left or deleted the group. +class RoomInfoPage extends StatefulWidget { + const RoomInfoPage({super.key, required this.room}); + + final ChatRoom room; + + @override + State createState() => _RoomInfoPageState(); +} + +class _RoomInfoPageState extends State { + final _chat = SupabaseChatCore.instance; + late ChatRoom _room = widget.room; + late final StreamSubscription _subscription; + + String get _me => _chat.loggedUserId!; + + bool get _isAdmin => _room.roleOf(_me) == ChatRoomRole.admin; + + @override + void initState() { + super.initState(); + _subscription = _chat + .room(_room.id) + .listen( + (room) { + if (mounted) setState(() => _room = room); + }, + onError: (Object e) { + if (e is SupabaseChatCoreException) _showError(e); + }, + onDone: () { + if (mounted) Navigator.of(context).pop(true); + }, + ); + } + + @override + void dispose() { + _subscription.cancel(); + super.dispose(); + } + + void _showError(SupabaseChatCoreException e) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('${e.message}: ${e.cause ?? ''}'))); + } + + Future _guard(Future Function() action) async { + try { + await action(); + } on SupabaseChatCoreException catch (e) { + _showError(e); + } + } + + Future _rename() async { + final controller = TextEditingController(text: _room.name); + final name = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Rename group'), + content: TextField(controller: controller, autofocus: true), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, controller.text.trim()), + child: const Text('Save'), + ), + ], + ), + ).whenComplete(controller.dispose); + if (name == null || name.isEmpty) return; + await _guard(() => _chat.updateRoom(_room.copyWith(name: name))); + } + + Future _addMembers() async { + final users = await NewChatSheet.pickMembers( + context, + excludeUserIds: _room.users.map((u) => u.id).toSet(), + ); + if (users == null || users.isEmpty) return; + await _guard( + () => _chat.addMembers(_room.id, users.map((u) => u.id).toList()), + ); + } + + Future _confirm(String title, String message, String action) async => + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: Text(action), + ), + ], + ), + ) ?? + false; + + Future _memberActions(ChatUser user) async { + final isAdmin = user.role == ChatRoomRole.admin; + await showModalBottomSheet( + context: context, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: Icon( + isAdmin + ? Icons.remove_moderator_outlined + : Icons.shield_outlined, + ), + title: Text(isAdmin ? 'Remove admin rights' : 'Make admin'), + onTap: () { + Navigator.pop(sheetContext); + _guard( + () => _chat.setMemberRole( + _room.id, + user.id, + isAdmin ? null : ChatRoomRole.admin, + ), + ); + }, + ), + ListTile( + leading: Icon( + Icons.person_remove_outlined, + color: Theme.of(context).colorScheme.error, + ), + title: Text( + 'Remove from group', + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + onTap: () { + Navigator.pop(sheetContext); + _guard(() => _chat.removeMember(_room.id, user.id)); + }, + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + + Future _leave() async { + if (!await _confirm( + 'Leave group?', + "You won't receive messages from this group anymore.", + 'Leave', + )) { + return; + } + await _guard(() => _chat.leaveRoom(_room.id)); + } + + Future _delete() async { + if (!await _confirm( + 'Delete group?', + 'The group and all its messages are deleted for everyone.', + 'Delete', + )) { + return; + } + await _guard(() => _chat.deleteRoom(_room.id)); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final members = [..._room.users] + ..sort((a, b) { + if (a.id == _me) return -1; + if (b.id == _me) return 1; + final byRole = (a.role == ChatRoomRole.admin ? 0 : 1).compareTo( + b.role == ChatRoomRole.admin ? 0 : 1, + ); + return byRole != 0 ? byRole : a.fullName.compareTo(b.fullName); + }); + + return Scaffold( + appBar: AppBar(title: const Text('Group info')), + body: ListView( + padding: const EdgeInsets.only(bottom: 32), + children: [ + const SizedBox(height: 8), + Center(child: UserAvatar.room(_room, _me, size: 104)), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: Text( + _room.name ?? 'Group', + textAlign: TextAlign.center, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + ), + if (_isAdmin) + IconButton( + tooltip: 'Rename', + onPressed: _rename, + icon: const Icon(Icons.edit_outlined), + ), + ], + ), + Text( + '${_room.users.length} members', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 24), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Card( + elevation: 0, + color: scheme.surfaceContainerLow, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + child: Column( + children: [ + if (_isAdmin) + ListTile( + leading: CircleAvatar( + backgroundColor: scheme.primaryContainer, + child: Icon( + Icons.person_add_alt_1_outlined, + color: scheme.onPrimaryContainer, + ), + ), + title: const Text('Add members'), + onTap: _addMembers, + ), + for (final user in members) + ListTile( + leading: UserAvatar.user(user, showOnlineStatus: true), + title: Text( + user.id == _me ? 'You' : user.fullName, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + trailing: user.role == ChatRoomRole.admin + ? Chip( + label: const Text('Admin'), + visualDensity: VisualDensity.compact, + side: BorderSide.none, + backgroundColor: scheme.tertiaryContainer, + ) + : null, + onTap: _isAdmin && user.id != _me + ? () => _memberActions(user) + : null, + ), + ], + ), + ), + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Card( + elevation: 0, + color: scheme.surfaceContainerLow, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + child: Column( + children: [ + ListTile( + leading: Icon(Icons.logout_rounded, color: scheme.error), + title: Text( + 'Leave group', + style: TextStyle(color: scheme.error), + ), + onTap: _leave, + ), + if (_isAdmin) + ListTile( + leading: Icon( + Icons.delete_outline_rounded, + color: scheme.error, + ), + title: Text( + 'Delete group', + style: TextStyle(color: scheme.error), + ), + onTap: _delete, + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/example/lib/src/pages/room_page.dart b/example/lib/src/pages/room_page.dart new file mode 100644 index 0000000..c546619 --- /dev/null +++ b/example/lib/src/pages/room_page.dart @@ -0,0 +1,850 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart' as core; +import 'package:flutter_chat_ui/flutter_chat_ui.dart'; +import 'package:flutter_supabase_chat_core/flutter_supabase_chat_core.dart'; +import 'package:flyer_chat_file_message/flyer_chat_file_message.dart'; +import 'package:flyer_chat_image_message/flyer_chat_image_message.dart'; +import 'package:flyer_chat_text_message/flyer_chat_text_message.dart'; +import 'package:uuid/uuid.dart'; + +import '../theme/app_theme.dart'; +import '../utils/attachments.dart'; +import '../utils/format.dart'; +import '../widgets/audio_message_bubble.dart'; +import '../widgets/chat_composer.dart'; +import '../widgets/chat_wallpaper.dart'; +import '../widgets/message_actions_sheet.dart'; +import '../widgets/pinned_banner.dart'; +import '../widgets/reactions_row.dart'; +import '../widgets/reply_quote.dart'; +import '../widgets/swipe_to_reply.dart'; +import '../widgets/user_avatar.dart'; +import 'image_viewer_page.dart'; +import 'message_search_page.dart'; +import 'room_info_page.dart'; + +/// A conversation, Telegram style. +class RoomPage extends StatefulWidget { + const RoomPage({ + super.key, + required this.room, + this.rooms, + this.embedded = false, + this.onLeft, + }); + + final ChatRoom room; + + /// Rooms list controller, to apply pin/mute changes to the list at once. + final SupabaseChatRoomsController? rooms; + + /// Shown inside the wide layout's right pane (no back button). + final bool embedded; + + /// Called after the user left or deleted the room. + final VoidCallback? onLeft; + + @override + State createState() => _RoomPageState(); +} + +/// Keeps the browser's own context menu off while a room is shown on the web, +/// so a right click on a message opens only its actions. Counted: when the +/// selected room changes, the new page starts before the old one ends. +abstract final class _BrowserContextMenu { + static int _rooms = 0; + + static void acquire() { + if (!kIsWeb || _rooms++ > 0) return; + unawaited(BrowserContextMenu.disableContextMenu()); + } + + static void release() { + if (!kIsWeb || --_rooms > 0) return; + unawaited(BrowserContextMenu.enableContextMenu()); + } +} + +class _RoomPageState extends State { + final _chat = SupabaseChatCore.instance; + final _text = TextEditingController(); + final _composerFocus = FocusNode(); + late final SupabaseChatController _controller; + late final StreamSubscription _roomSubscription; + late ChatRoom _room = widget.room; + core.Message? _replyTo; + bool _uploading = false; + + String get _me => _chat.loggedUserId!; + + bool get _isGroup => _room.type == ChatRoomType.group; + + /// Direct rooms: both members. Groups: admins. + bool get _canModerate => !_isGroup || _room.roleOf(_me) == ChatRoomRole.admin; + + @override + void initState() { + super.initState(); + _BrowserContextMenu.acquire(); + _controller = SupabaseChatController(room: widget.room, onError: _onError); + _text.addListener(() { + if (_text.text.isNotEmpty) _controller.onTyping(); + }); + _roomSubscription = _chat + .room(widget.room.id) + .listen( + (room) { + if (mounted) setState(() => _room = room); + }, + onError: (Object e) { + if (e is SupabaseChatCoreException) _onError(e); + }, + onDone: () { + // Room deleted, or we were removed from it. + if (mounted) widget.onLeft?.call(); + }, + ); + } + + @override + void dispose() { + _roomSubscription.cancel(); + _controller.dispose(); + _text.dispose(); + _composerFocus.dispose(); + _BrowserContextMenu.release(); + super.dispose(); + } + + void _onError(SupabaseChatCoreException error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${error.message}: ${error.cause ?? ''}')), + ); + } + + Future _guard(Future Function() action) async { + try { + await action(); + } on SupabaseChatCoreException catch (e) { + _onError(e); + } + } + + Future _resolveUser(String id) async { + final member = _room.member(id); + if (member != null) return member.toCoreUser(); + final user = await _chat.user(uid: id); + return user?.toCoreUser(); + } + + void _startReply(core.Message message) { + unawaited(HapticFeedback.lightImpact()); + setState(() => _replyTo = message); + _composerFocus.requestFocus(); + } + + void _send(String text) { + final trimmed = text.trim(); + if (trimmed.isEmpty) return; + final replyTo = _replyTo?.id; + setState(() => _replyTo = null); + _guard( + () => _controller.sendMessage( + core.Message.text( + id: const Uuid().v7(), + authorId: _me, + text: trimmed, + replyToMessageId: replyTo, + ), + ), + ); + } + + Future _attach() async { + final kind = await showModalBottomSheet( + context: context, + builder: (_) => const AttachmentPickerSheet(), + ); + if (kind != null && mounted) await _sendAttachment(kind); + } + + /// Picks or records an attachment of [kind], uploads and sends it. + Future _sendAttachment(AttachmentKind kind) async { + setState(() => _uploading = true); + try { + final message = await pickAttachment( + context, + kind, + room: _room, + authorId: _me, + ); + if (message != null) await _controller.sendMessage(message); + } on SupabaseChatCoreException catch (e) { + _onError(e); + } finally { + if (mounted) setState(() => _uploading = false); + } + } + + Future _jumpTo(core.Message message) async { + final found = await _controller.jumpToMessage(message.id); + if (!found && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('That message is too far back')), + ); + } + } + + Future _onMessageTap( + BuildContext context, + core.Message message, { + required int index, + required TapUpDetails details, + }) async { + switch (message) { + case core.ImageMessage(): + await Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => ImageViewerPage(message: message), + ), + ); + case core.FileMessage(): + await _guard(() => openFileMessage(message)); + default: + break; + } + } + + Future _onMessageLongPress( + BuildContext context, + core.Message message, { + required int index, + required LongPressStartDetails details, + }) => _showActions(context, message); + + /// Desktop/web: right click opens the same menu as a long press. + void _onMessageSecondaryTap( + BuildContext context, + core.Message message, { + required int index, + TapUpDetails? details, + }) => unawaited(_showActions(context, message)); + + Future _showActions(BuildContext context, core.Message message) async { + unawaited(HapticFeedback.mediumImpact()); + final mine = message.authorId == _me; + final action = await MessageActionsSheet.show( + context, + message: message, + canEdit: mine && message is core.TextMessage, + canDelete: mine || _room.roleOf(_me) == ChatRoomRole.admin, + canPin: _canModerate, + ); + if (action == null || !mounted) return; + switch (action) { + case MessageReact(:final emoji): + await _guard(() => _controller.toggleReaction(message.id, emoji)); + case MessageReply(): + _startReply(message); + case MessagePin(:final pinned): + await _guard(() => _controller.setPinned(message, pinned: pinned)); + case MessageCopy(): + if (message is core.TextMessage) { + await Clipboard.setData(ClipboardData(text: message.text)); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Copied to clipboard')), + ); + } + } + case MessageEdit(): + final text = await _editDialog((message as core.TextMessage).text); + if (text != null && text.trim().isNotEmpty && text != message.text) { + await _guard(() => _controller.editTextMessage(message, text.trim())); + } + case MessageDelete(): + await _guard(() async { + await _controller.deleteMessage(message.id); + }); + case MessageRetry(): + await _guard(() => _controller.retryMessage(message)); + } + } + + Future _editDialog(String initial) { + final controller = TextEditingController(text: initial); + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Edit message'), + content: TextField( + controller: controller, + autofocus: true, + maxLines: null, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, controller.text), + child: const Text('Save'), + ), + ], + ), + ).whenComplete(controller.dispose); + } + + Future _openInfo() async { + final left = await Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => RoomInfoPage(room: _room))); + if (left == true && mounted) widget.onLeft?.call(); + } + + Future _search() async { + final message = await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => MessageSearchPage(room: _room)), + ); + if (message != null && mounted) await _jumpTo(message); + } + + Future _toggleMute() => _guard(() async { + final rooms = widget.rooms; + if (_room.isMuted) { + await (rooms?.unmuteRoom(_room.id) ?? _chat.unmuteRoom(_room.id)); + } else { + await (rooms?.muteRoom(_room.id) ?? _chat.muteRoom(_room.id)); + } + final room = await _chat.getRoom(_room.id); + if (room != null && mounted) setState(() => _room = room); + }); + + Future _deleteDirect() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete chat?'), + content: const Text('Every message will be deleted for both of you.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + minimumSize: const Size(0, 40), + ), + onPressed: () => Navigator.pop(context, true), + child: const Text('Delete'), + ), + ], + ), + ); + if (confirmed == true) await _guard(() => _chat.deleteRoom(_room.id)); + } + + Widget _subtitle(ThemeData theme) { + final style = theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ); + return StreamBuilder>( + stream: _controller.typingUsers, + builder: (context, snapshot) { + final typing = snapshot.data ?? const []; + if (typing.isNotEmpty) { + return Row( + spacing: 6, + children: [ + IsTypingIndicator(color: theme.colorScheme.primary, size: 4), + Flexible( + child: Text( + _isGroup + ? '${typing.map((u) => u.firstName ?? 'Someone').join(', ')} typing' + : 'typing', + overflow: TextOverflow.ellipsis, + style: style?.copyWith(color: theme.colorScheme.primary), + ), + ), + ], + ); + } + if (_isGroup) { + return Text('${_room.users.length} members', style: style); + } + final other = _room.otherUser(_me); + if (other == null) return const SizedBox.shrink(); + return UserOnlineStatusWidget( + uid: other.id, + builder: (status) => Text( + status == UserOnlineStatus.online + ? 'online' + : formatLastSeen(other.lastSeen), + style: status == UserOnlineStatus.online + ? style?.copyWith(color: theme.colorScheme.primary) + : style, + ), + ); + }, + ); + } + + /// Telegram draws the "tail" on the last bubble of a group: square the + /// corner on the sender side there, round everything else. + BorderRadius _bubbleRadius(bool isSentByMe, core.MessageGroupStatus? group) { + const big = Radius.circular(16); + const small = Radius.circular(4); + final last = group?.isLast ?? true; + return BorderRadius.only( + topLeft: big, + topRight: big, + bottomLeft: !isSentByMe && last ? small : big, + bottomRight: isSentByMe && last ? small : big, + ); + } + + Widget? _dateHeader(int index, core.Message message) { + final messages = _controller.messages; + final date = message.createdAt?.toLocal(); + if (date == null) return null; + if (index > 0 && index - 1 < messages.length) { + final previous = messages[index - 1].createdAt?.toLocal(); + if (previous != null && + previous.year == date.year && + previous.month == date.month && + previous.day == date.day) { + return null; + } + } + return ServiceChip(text: formatDayHeader(context, date)); + } + + Widget _messageWrapper( + BuildContext context, + core.Message message, + int index, + Animation animation, + Widget child, { + bool? isRemoved, + required bool isSentByMe, + core.MessageGroupStatus? groupStatus, + }) { + final showAuthor = _isGroup && !isSentByMe; + final firstInGroup = groupStatus?.isFirst ?? true; + final lastInGroup = groupStatus?.isLast ?? true; + final author = _room.member(message.authorId); + final scheme = Theme.of(context).colorScheme; + final dateHeader = _dateHeader(index, message); + final authorHeader = showAuthor && firstInGroup && author != null + ? Padding( + padding: const EdgeInsets.only(left: 52, bottom: 2, top: 4), + child: Text( + author.fullName, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: avatarColor(author.id, scheme), + fontWeight: FontWeight.w700, + ), + ), + ) + : null; + final edited = message.metadata?['edited'] == true; + return ChatMessage( + message: message, + index: index, + animation: animation, + isRemoved: isRemoved, + groupStatus: groupStatus, + leadingWidget: showAuthor + ? Padding( + padding: const EdgeInsets.only(right: 8), + child: lastInGroup && author != null + ? UserAvatar.user(author, size: 34) + : const SizedBox(width: 34), + ) + : null, + headerWidget: dateHeader == null && authorHeader == null + ? null + : Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [?dateHeader, ?authorHeader], + ), + bottomWidget: Column( + crossAxisAlignment: isSentByMe + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + ReactionsRow( + message: message, + currentUserId: _me, + alignEnd: isSentByMe, + onToggle: (emoji) => + _guard(() => _controller.toggleReaction(message.id, emoji)), + ), + if (edited) + Padding( + padding: const EdgeInsets.only(top: 1, left: 4, right: 4), + child: Text( + 'edited', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: ChatPalette.of(context).onServiceChip, + shadows: const [Shadow(blurRadius: 4, color: Colors.black26)], + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ), + child: message.status == core.MessageStatus.error + ? child + : SwipeToReply(onReply: () => _startReply(message), child: child), + ); + } + + Widget? _quoteFor(core.Message message, Color onBubble) { + final id = message.replyToMessageId; + if (id == null) return null; + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: ReplyQuote( + key: ValueKey('quote-$id'), + controller: _controller, + room: _room, + messageId: id, + onSurface: onBubble, + onTap: () async { + final quoted = await _controller.resolveMessage(id); + if (quoted != null) await _jumpTo(quoted); + }, + ), + ); + } + + Widget _composerTop(ThemeData theme) { + final reply = _replyTo; + final palette = ChatPalette.of(context); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedSize( + duration: const Duration(milliseconds: 180), + child: reply == null + ? const SizedBox(width: double.infinity) + : Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 4, 0), + child: Row( + spacing: 8, + children: [ + Icon(Icons.reply_rounded, color: palette.accent), + Expanded( + child: QuoteBlock( + color: palette.accent, + title: + 'Reply to ${_room.member(reply.authorId)?.fullName ?? 'message'}', + text: messagePreview(reply).text, + textColor: theme.colorScheme.onSurfaceVariant, + ), + ), + IconButton( + tooltip: 'Cancel reply', + onPressed: () => setState(() => _replyTo = null), + icon: const Icon(Icons.close_rounded), + ), + ], + ), + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final palette = ChatPalette.of(context); + final chatTheme = AppTheme.chatTheme(theme); + final headers = _chat.httpSupabaseHeaders; + TextStyle metaStyle(bool mine) => theme.textTheme.labelSmall!.copyWith( + color: mine ? palette.sentMeta : palette.receivedMeta, + ); + + return Scaffold( + appBar: AppBar( + automaticallyImplyLeading: !widget.embedded, + titleSpacing: widget.embedded ? 16 : 0, + title: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: _isGroup ? _openInfo : null, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + spacing: 12, + children: [ + UserAvatar.room(_room, _me, size: 40), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + spacing: 4, + children: [ + Flexible( + child: Text( + _room.name ?? 'Chat', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + if (_room.isMuted) + Icon( + Icons.volume_off_rounded, + size: 16, + color: scheme.onSurfaceVariant, + ), + ], + ), + _subtitle(theme), + ], + ), + ), + ], + ), + ), + ), + actions: [ + IconButton( + tooltip: 'Search', + onPressed: _search, + icon: const Icon(Icons.search_rounded), + ), + PopupMenuButton( + itemBuilder: (_) => [ + PopupMenuItem( + onTap: _toggleMute, + child: ListTile( + leading: Icon( + _room.isMuted + ? Icons.volume_up_outlined + : Icons.volume_off_outlined, + ), + title: Text(_room.isMuted ? 'Unmute' : 'Mute'), + ), + ), + if (_isGroup) + PopupMenuItem( + onTap: _openInfo, + child: const ListTile( + leading: Icon(Icons.info_outline_rounded), + title: Text('Group info'), + ), + ) + else + PopupMenuItem( + onTap: _deleteDirect, + child: ListTile( + leading: Icon( + Icons.delete_outline_rounded, + color: scheme.error, + ), + title: Text( + 'Delete chat', + style: TextStyle(color: scheme.error), + ), + ), + ), + ], + ), + ], + bottom: _uploading + ? const PreferredSize( + preferredSize: Size.fromHeight(2), + child: LinearProgressIndicator(minHeight: 2), + ) + : null, + ), + body: Column( + children: [ + PinnedBanner( + controller: _controller, + onJump: _jumpTo, + onUnpin: _canModerate + ? (m) => _guard(() => _controller.setPinned(m, pinned: false)) + : null, + ), + Expanded( + child: ChatWallpaper( + child: Chat( + currentUserId: _me, + resolveUser: _resolveUser, + chatController: _controller, + theme: chatTheme, + backgroundColor: Colors.transparent, + onMessageSend: _send, + onAttachmentTap: _uploading ? null : _attach, + onMessageTap: _onMessageTap, + onMessageLongPress: _onMessageLongPress, + onMessageSecondaryTap: _onMessageSecondaryTap, + builders: core.Builders( + chatAnimatedListBuilder: (context, itemBuilder) => + ChatAnimatedList( + itemBuilder: itemBuilder, + onEndReached: _controller.loadPreviousMessages, + ), + chatMessageBuilder: _messageWrapper, + emptyChatListBuilder: (context) => Center( + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: palette.serviceChip, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + const Text('👋', style: TextStyle(fontSize: 48)), + Text( + 'No messages here yet', + style: theme.textTheme.titleSmall?.copyWith( + color: palette.onServiceChip, + fontWeight: FontWeight.w700, + ), + ), + TextButton( + onPressed: () => _send('👋'), + child: Text( + 'Say hi', + style: TextStyle(color: palette.onServiceChip), + ), + ), + ], + ), + ), + ), + textMessageBuilder: + ( + context, + message, + index, { + required isSentByMe, + groupStatus, + }) => FlyerChatTextMessage( + message: message, + index: index, + showStatus: isSentByMe, + borderRadius: _bubbleRadius(isSentByMe, groupStatus), + padding: const EdgeInsets.fromLTRB(10, 6, 10, 6), + sentBackgroundColor: palette.sentBubble, + receivedBackgroundColor: palette.receivedBubble, + sentTextStyle: theme.textTheme.bodyLarge?.copyWith( + color: palette.onSentBubble, + ), + receivedTextStyle: theme.textTheme.bodyLarge?.copyWith( + color: palette.onReceivedBubble, + ), + sentLinksColor: palette.accent, + receivedLinksColor: palette.accent, + timeStyle: metaStyle(isSentByMe), + timeAndStatusPosition: + core.TimeAndStatusPosition.inline, + topWidget: _quoteFor( + message, + isSentByMe + ? palette.onSentBubble + : palette.onReceivedBubble, + ), + ), + imageMessageBuilder: + ( + context, + message, + index, { + required isSentByMe, + groupStatus, + }) => Hero( + tag: message.id, + child: FlyerChatImageMessage( + message: message, + index: index, + headers: headers, + showStatus: isSentByMe, + borderRadius: _bubbleRadius(isSentByMe, groupStatus), + timeBackground: Colors.black45, + timeStyle: theme.textTheme.labelSmall?.copyWith( + color: Colors.white, + ), + ), + ), + fileMessageBuilder: + ( + context, + message, + index, { + required isSentByMe, + groupStatus, + }) => + FlyerChatFileMessage(message: message, index: index), + audioMessageBuilder: + ( + context, + message, + index, { + required isSentByMe, + groupStatus, + }) => AudioMessageBubble( + message: message, + isSentByMe: isSentByMe, + footer: Text( + formatTime(message.createdAt ?? DateTime.now()), + style: metaStyle(isSentByMe), + ), + ), + unsupportedMessageBuilder: + ( + context, + message, + index, { + required isSentByMe, + groupStatus, + }) => Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: palette.receivedBubble, + borderRadius: BorderRadius.circular(16), + ), + child: Text( + 'Unsupported message', + style: TextStyle(color: palette.receivedMeta), + ), + ), + composerBuilder: (context) => ChatComposer( + textEditingController: _text, + focusNode: _composerFocus, + sendOnEnter: kIsWeb, + backgroundColor: scheme.surface, + inputFillColor: scheme.surface, + topWidget: _composerTop(theme), + onRecordVoice: _uploading + ? null + : () => _sendAttachment(AttachmentKind.audio), + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/example/lib/src/pages/rooms.dart b/example/lib/src/pages/rooms.dart deleted file mode 100644 index a33a58e..0000000 --- a/example/lib/src/pages/rooms.dart +++ /dev/null @@ -1,123 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_chat_types/flutter_chat_types.dart' as types; -import 'package:flutter_supabase_chat_core/flutter_supabase_chat_core.dart'; -import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; - -import '../widgets/room_tile.dart'; -import 'room.dart'; - -class RoomsPage extends StatefulWidget { - const RoomsPage({super.key}); - - @override - State createState() => _RoomsPageState(); -} - -class _RoomsPageState extends State { - static const _pageSize = 20; - String _filter = ''; - - final PagingController _controller = - PagingController(firstPageKey: 0); - - @override - void initState() { - _controller.addPageRequestListener((pageKey) { - _fetchPage(pageKey); - }); - super.initState(); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - void _setFilters(String filter) { - WidgetsBinding.instance.addPostFrameCallback((_) { - _filter = filter; - if (mounted) { - _controller.nextPageKey = 0; - _controller.refresh(); - } - }); - } - - Future _fetchPage(int offset) async { - try { - final newItems = await SupabaseChatCore.instance - .rooms(filter: _filter, offset: offset, limit: _pageSize); - final isLastPage = newItems.length < _pageSize; - if (isLastPage) { - _controller.appendLastPage(newItems); - } else { - final nextPageKey = offset + newItems.length; - _controller.appendPage(newItems, nextPageKey); - } - } catch (error) { - _controller.error = error; - } - } - - @override - Widget build(BuildContext context) => Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - FractionallySizedBox( - widthFactor: .5, - child: TextField( - decoration: InputDecoration( - border: OutlineInputBorder(), - labelText: 'Search', - ), - onChanged: (value) => _setFilters(value), - ), - ), - Expanded( - child: RefreshIndicator( - onRefresh: () async { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _controller.nextPageKey = 0; - _controller.refresh(); - } - }); - }, - child: StreamBuilder>( - stream: SupabaseChatCore.instance.roomsUpdates(), - builder: (context, snapshot) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - if (_filter == '' && snapshot.data != null) { - _controller.itemList = SupabaseChatCore.updateRoomList( - _controller.itemList ?? [], - snapshot.data!, - ); - } - } - }); - return PagedListView( - pagingController: _controller, - builderDelegate: PagedChildBuilderDelegate( - itemBuilder: (context, room, index) => RoomTile( - room: room, - onTap: (room) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => RoomPage( - room: room, - ), - ), - ); - }, - ), - ), - ); - }, - ), - ), - ), - ], - ); -} diff --git a/example/lib/src/pages/users.dart b/example/lib/src/pages/users.dart deleted file mode 100644 index a1c262b..0000000 --- a/example/lib/src/pages/users.dart +++ /dev/null @@ -1,113 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_chat_types/flutter_chat_types.dart' as types; -import 'package:flutter_supabase_chat_core/flutter_supabase_chat_core.dart'; -import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; - -import '../widgets/user_tile.dart'; -import 'room.dart'; - -class UsersPage extends StatefulWidget { - const UsersPage({super.key}); - - @override - State createState() => _UsersPageState(); -} - -class _UsersPageState extends State { - static const _pageSize = 20; - String _filter = ''; - - final PagingController _controller = - PagingController(firstPageKey: 0); - - @override - void initState() { - _controller.addPageRequestListener((pageKey) { - _fetchPage(pageKey); - }); - super.initState(); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - void _setFilters(String filter) { - WidgetsBinding.instance.addPostFrameCallback((_) { - _filter = filter; - if (mounted) { - _controller.nextPageKey = 0; - _controller.refresh(); - } - }); - } - - Future _fetchPage(int offset) async { - try { - final newItems = await SupabaseChatCore.instance - .users(filter: _filter, offset: offset, limit: _pageSize); - final isLastPage = newItems.length < _pageSize; - if (isLastPage) { - _controller.appendLastPage(newItems); - } else { - final nextPageKey = offset + newItems.length; - _controller.appendPage(newItems, nextPageKey); - } - } catch (error) { - _controller.error = error; - } - } - - void _handlePressed(types.User otherUser, BuildContext context) async { - final navigator = Navigator.of(context); - final room = await SupabaseChatCore.instance.createRoom(otherUser); - - navigator.pop(); - await navigator.push( - MaterialPageRoute( - builder: (context) => RoomPage( - room: room, - ), - ), - ); - } - - @override - Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - systemOverlayStyle: SystemUiOverlayStyle.light, - title: const Text('Users'), - ), - body: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - FractionallySizedBox( - widthFactor: .5, - child: TextField( - decoration: InputDecoration( - border: OutlineInputBorder(), - labelText: 'Search', - ), - onChanged: (value) => _setFilters(value), - ), - ), - Expanded( - child: PagedListView( - pagingController: _controller, - builderDelegate: PagedChildBuilderDelegate( - itemBuilder: (context, user, index) => UserTile( - user: user, - onTap: (user) { - _handlePressed(user, context); - }, - ), - ), - ), - ), - ], - ), - ); -} diff --git a/example/lib/src/theme/app_theme.dart b/example/lib/src/theme/app_theme.dart new file mode 100644 index 0000000..b4f74f9 --- /dev/null +++ b/example/lib/src/theme/app_theme.dart @@ -0,0 +1,231 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart' as core; + +/// Telegram-like accent blue the whole Material 3 palette is derived from. +const _seed = Color(0xFF3390EC); + +/// Chat-specific colors (bubbles, wallpaper), Telegram-inspired, exposed +/// as a [ThemeExtension] so every widget picks the right light/dark set. +@immutable +class ChatPalette extends ThemeExtension { + const ChatPalette({ + required this.sentBubble, + required this.onSentBubble, + required this.sentMeta, + required this.receivedBubble, + required this.onReceivedBubble, + required this.receivedMeta, + required this.wallpaper, + required this.wallpaperPattern, + required this.serviceChip, + required this.onServiceChip, + required this.accent, + }); + + static const light = ChatPalette( + sentBubble: Color(0xFFEEFFDE), + onSentBubble: Color(0xFF000000), + sentMeta: Color(0xFF4FAE4E), + receivedBubble: Color(0xFFFFFFFF), + onReceivedBubble: Color(0xFF000000), + receivedMeta: Color(0xFFA1AAB3), + wallpaper: [Color(0xFFDCE8C4), Color(0xFFB9D7A5), Color(0xFFD7DFAE)], + wallpaperPattern: Color(0x1A2F4F1F), + serviceChip: Color(0x5A3D5A2B), + onServiceChip: Color(0xFFFFFFFF), + accent: Color(0xFF3390EC), + ); + + static const dark = ChatPalette( + sentBubble: Color(0xFF2B5278), + onSentBubble: Color(0xFFFFFFFF), + sentMeta: Color(0xFF7DA8D3), + receivedBubble: Color(0xFF182533), + onReceivedBubble: Color(0xFFFFFFFF), + receivedMeta: Color(0xFF6D7F8F), + wallpaper: [Color(0xFF0E1621), Color(0xFF13202D), Color(0xFF0F1A26)], + wallpaperPattern: Color(0x10FFFFFF), + serviceChip: Color(0x661E2C3A), + onServiceChip: Color(0xFFFFFFFF), + accent: Color(0xFF64B5EF), + ); + + final Color sentBubble; + final Color onSentBubble; + final Color sentMeta; + final Color receivedBubble; + final Color onReceivedBubble; + final Color receivedMeta; + final List wallpaper; + final Color wallpaperPattern; + final Color serviceChip; + final Color onServiceChip; + final Color accent; + + static ChatPalette of(BuildContext context) => + Theme.of(context).extension() ?? light; + + @override + ChatPalette copyWith() => this; + + @override + ChatPalette lerp(ChatPalette? other, double t) => + other == null || t < .5 ? this : other; +} + +/// App-wide theme, light and dark, plus the matching chat theme. +abstract final class AppTheme { + static ThemeData get light => _build(Brightness.light); + + static ThemeData get dark => _build(Brightness.dark); + + static ThemeData _build(Brightness brightness) { + final dark = brightness == Brightness.dark; + var scheme = ColorScheme.fromSeed( + seedColor: _seed, + brightness: brightness, + dynamicSchemeVariant: DynamicSchemeVariant.vibrant, + ); + scheme = scheme.copyWith( + primary: dark ? const Color(0xFF64B5EF) : _seed, + onPrimary: Colors.white, + surface: dark ? const Color(0xFF17212B) : Colors.white, + surfaceContainerLow: dark + ? const Color(0xFF1C2733) + : const Color(0xFFF4F4F5), + surfaceContainer: dark + ? const Color(0xFF202B36) + : const Color(0xFFF1F2F4), + surfaceContainerHigh: dark + ? const Color(0xFF242F3D) + : const Color(0xFFEBEDF0), + surfaceContainerHighest: dark + ? const Color(0xFF2B3744) + : const Color(0xFFE4E7EB), + ); + final base = ThemeData( + colorScheme: scheme, + useMaterial3: true, + extensions: [dark ? ChatPalette.dark : ChatPalette.light], + ); + return base.copyWith( + scaffoldBackgroundColor: scheme.surface, + appBarTheme: AppBarTheme( + backgroundColor: scheme.surface, + foregroundColor: scheme.onSurface, + surfaceTintColor: Colors.transparent, + scrolledUnderElevation: .5, + shadowColor: Colors.black26, + centerTitle: false, + titleTextStyle: base.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + color: scheme.onSurface, + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: scheme.surfaceContainerHigh, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: scheme.primary, width: 1.5), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(50), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + textStyle: base.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + listTileTheme: const ListTileThemeData( + contentPadding: EdgeInsets.symmetric(horizontal: 16), + ), + bottomSheetTheme: BottomSheetThemeData( + showDragHandle: true, + backgroundColor: scheme.surface, + surfaceTintColor: Colors.transparent, + ), + dialogTheme: DialogThemeData( + backgroundColor: scheme.surface, + surfaceTintColor: Colors.transparent, + ), + popupMenuTheme: PopupMenuThemeData( + color: scheme.surface, + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + floatingActionButtonTheme: FloatingActionButtonThemeData( + backgroundColor: scheme.primary, + foregroundColor: Colors.white, + shape: const CircleBorder(), + ), + dividerTheme: DividerThemeData( + color: scheme.outlineVariant.withValues(alpha: .4), + space: 1, + thickness: .5, + ), + snackBarTheme: const SnackBarThemeData( + behavior: SnackBarBehavior.floating, + ), + ); + } + + /// `flutter_chat_ui` theme derived from [theme]. + /// + /// Built field by field rather than with `ChatTheme.fromThemeData`: since + /// flutter_chat_core 2.9.1 that factory takes the experimental + /// `material_ui` package's ThemeData, not Flutter's. + static core.ChatTheme chatTheme(ThemeData theme) { + final scheme = theme.colorScheme; + final palette = theme.extension() ?? ChatPalette.light; + final text = theme.textTheme; + TextStyle style(TextStyle? s) => s ?? const TextStyle(); + return core.ChatTheme( + colors: core.ChatColors( + primary: palette.sentBubble, + onPrimary: palette.onSentBubble, + surface: scheme.surface, + onSurface: scheme.onSurface, + surfaceContainerLow: scheme.surfaceContainerLow, + surfaceContainer: palette.receivedBubble, + surfaceContainerHigh: scheme.surfaceContainerHigh, + ), + typography: core.ChatTypography( + bodyLarge: style(text.bodyLarge), + bodyMedium: style(text.bodyMedium), + bodySmall: style(text.bodySmall), + labelLarge: style(text.labelLarge), + labelMedium: style(text.labelMedium), + labelSmall: style(text.labelSmall), + ), + shape: const BorderRadius.all(Radius.circular(16)), + ); + } +} + +/// Deterministic avatar color for [seed] (e.g. a user id), from the same +/// set of hues Telegram uses for its letter avatars. +Color avatarColor(String seed, ColorScheme scheme) { + const colors = [ + Color(0xFFE17076), + Color(0xFFFAA774), + Color(0xFFA695E7), + Color(0xFF7BC862), + Color(0xFF6EC9CB), + Color(0xFF65AADD), + Color(0xFFEE7AAE), + ]; + return colors[seed.hashCode.abs() % colors.length]; +} diff --git a/example/lib/src/theme/color_schemes.dart b/example/lib/src/theme/color_schemes.dart deleted file mode 100644 index d5783d0..0000000 --- a/example/lib/src/theme/color_schemes.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:flutter/material.dart'; - -const lightColorScheme = ColorScheme( - brightness: Brightness.light, - primary: Color(0xFF6750A4), - onPrimary: Color(0xFFFFFFFF), - primaryContainer: Color(0xFFEADDFF), - onPrimaryContainer: Color(0xFF21005D), - secondary: Color(0xFF625B71), - onSecondary: Color(0xFFFFFFFF), - secondaryContainer: Color(0xFFE8DEF8), - onSecondaryContainer: Color(0xFF1D192B), - tertiary: Color(0xFF7D5260), - onTertiary: Color(0xFFFFFFFF), - tertiaryContainer: Color(0xFFFFD8E4), - onTertiaryContainer: Color(0xFF31111D), - error: Color(0xFFB3261E), - onError: Color(0xFFFFFFFF), - errorContainer: Color(0xFFF9DEDC), - onErrorContainer: Color(0xFF410E0B), - outline: Color(0xFF79747E), - surface: Color(0xFFFFFBFE), - onSurface: Color(0xFF1C1B1F), - surfaceContainerHighest: Color(0xFFE7E0EC), - onSurfaceVariant: Color(0xFF49454F), - inverseSurface: Color(0xFF313033), - onInverseSurface: Color(0xFFF4EFF4), - inversePrimary: Color(0xFFD0BCFF), - shadow: Color(0xFF000000), - surfaceTint: Color(0xFF6750A4), - outlineVariant: Color(0xFFCAC4D0), - scrim: Color(0xFF000000), -); - -const darkColorScheme = ColorScheme( - brightness: Brightness.dark, - primary: Color(0xFFD0BCFF), - onPrimary: Color(0xFF381E72), - primaryContainer: Color(0xFF4F378B), - onPrimaryContainer: Color(0xFFEADDFF), - secondary: Color(0xFFCCC2DC), - onSecondary: Color(0xFF332D41), - secondaryContainer: Color(0xFF4A4458), - onSecondaryContainer: Color(0xFFE8DEF8), - tertiary: Color(0xFFEFB8C8), - onTertiary: Color(0xFF492532), - tertiaryContainer: Color(0xFF633B48), - onTertiaryContainer: Color(0xFFFFD8E4), - error: Color(0xFFF2B8B5), - onError: Color(0xFF601410), - errorContainer: Color(0xFF8C1D18), - onErrorContainer: Color(0xFFF9DEDC), - outline: Color(0xFF938F99), - surface: Color(0xFF1C1B1F), - onSurface: Color(0xFFE6E1E5), - surfaceContainerHighest: Color(0xFF49454F), - onSurfaceVariant: Color(0xFFCAC4D0), - inverseSurface: Color(0xFFE6E1E5), - onInverseSurface: Color(0xFF313033), - inversePrimary: Color(0xFF6750A4), - shadow: Color(0xFF000000), - surfaceTint: Color(0xFFD0BCFF), - outlineVariant: Color(0xFF49454F), - scrim: Color(0xFF000000), -); diff --git a/example/lib/src/util.dart b/example/lib/src/util.dart deleted file mode 100644 index 330755b..0000000 --- a/example/lib/src/util.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'dart:ui'; - -const colors = [ - Color(0xffff6767), - Color(0xff66e0da), - Color(0xfff5a2d9), - Color(0xfff0c722), - Color(0xff6a85e5), - Color(0xfffd9a6f), - Color(0xff92db6e), - Color(0xff73b8e5), - Color(0xfffd7590), - Color(0xffc78ae5), -]; - -Color getAvatarColor(String name) { - final index = name.hashCode % colors.length; - return colors[index]; -} diff --git a/example/lib/src/utils/attachments.dart b/example/lib/src/utils/attachments.dart new file mode 100644 index 0000000..1319705 --- /dev/null +++ b/example/lib/src/utils/attachments.dart @@ -0,0 +1,340 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:file_saver/file_saver.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart' as core; +import 'package:flutter_supabase_chat_core/flutter_supabase_chat_core.dart'; +import 'package:http/http.dart' as http; +import 'package:image_picker/image_picker.dart'; +import 'package:open_filex/open_filex.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:record/record.dart'; +import 'package:uuid/uuid.dart'; + +import 'format.dart'; + +/// What the user wants to attach. [audio] is recorded from the composer's +/// microphone, not offered by [AttachmentPickerSheet]. +enum AttachmentKind { camera, gallery, file, audio } + +/// Bottom sheet offering the attachment kinds available on this platform. +class AttachmentPickerSheet extends StatelessWidget { + const AttachmentPickerSheet({super.key}); + + @override + Widget build(BuildContext context) { + final mobile = !kIsWeb && (Platform.isAndroid || Platform.isIOS); + final options = [ + if (mobile) + (AttachmentKind.camera, Icons.photo_camera_outlined, 'Camera', 0), + (AttachmentKind.gallery, Icons.photo_outlined, 'Photo', 1), + (AttachmentKind.file, Icons.insert_drive_file_outlined, 'File', 2), + ]; + final scheme = Theme.of(context).colorScheme; + final tints = [ + scheme.primaryContainer, + scheme.tertiaryContainer, + scheme.secondaryContainer, + scheme.errorContainer, + ]; + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), + child: Wrap( + alignment: WrapAlignment.spaceEvenly, + spacing: 16, + runSpacing: 16, + children: [ + for (final (kind, icon, label, tint) in options) + InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () => Navigator.pop(context, kind), + child: Padding( + padding: const EdgeInsets.all(8), + child: Column( + spacing: 8, + children: [ + CircleAvatar( + radius: 30, + backgroundColor: tints[tint], + child: Icon(icon, size: 28), + ), + Text(label), + ], + ), + ), + ), + ], + ), + ), + ); + } +} + +/// Lets the user pick/record an attachment of [kind], uploads it to +/// [room]'s folder and returns the message to send (or `null` if +/// cancelled). +Future pickAttachment( + BuildContext context, + AttachmentKind kind, { + required ChatRoom room, + required String authorId, +}) async { + final chat = SupabaseChatCore.instance; + switch (kind) { + case AttachmentKind.camera: + case AttachmentKind.gallery: + final picked = await ImagePicker().pickImage( + source: kind == AttachmentKind.camera + ? ImageSource.camera + : ImageSource.gallery, + imageQuality: 75, + maxWidth: 1920, + ); + if (picked == null) return null; + final bytes = await picked.readAsBytes(); + final image = await decodeImageFromList(bytes); + final upload = await chat.uploadAsset(room, picked.name, bytes); + return core.Message.image( + id: const Uuid().v7(), + authorId: authorId, + source: upload.url, + width: image.width.toDouble(), + height: image.height.toDouble(), + size: bytes.length, + ); + case AttachmentKind.file: + final file = await FilePicker.pickFile(); + if (file == null) return null; + final bytes = await file.readAsBytes(); + final upload = await chat.uploadAsset(room, file.name, bytes); + return core.Message.file( + id: const Uuid().v7(), + authorId: authorId, + source: upload.url, + name: file.name, + size: bytes.length, + mimeType: upload.mimeType, + ); + case AttachmentKind.audio: + final recorder = AudioRecorder(); + try { + if (!await recorder.hasPermission()) return null; + final format = await _voiceFormat(recorder); + // Web records into an in-memory blob (the path is ignored); native + // platforms into a temporary file. + final path = kIsWeb + ? '' + : '${(await getTemporaryDirectory()).path}/${const Uuid().v7()}.${format.extension}'; + await recorder.start(RecordConfig(encoder: format.encoder), path: path); + if (!context.mounted) return null; + final samples = []; + final amplitude = recorder + .onAmplitudeChanged(const Duration(milliseconds: 100)) + // dBFS (-160..0) -> 0..1, keeping the audible -50..0 dB range. + .listen( + (a) => samples.add(((a.current + 50) / 50).clamp(0.0, 1.0)), + ); + final duration = await showModalBottomSheet( + context: context, + isDismissible: false, + enableDrag: false, + builder: (_) => _RecordingSheet(samples: samples), + ); + await amplitude.cancel(); + final recorded = await recorder.stop(); + if (recorded == null || duration == null) return null; + final bytes = kIsWeb + ? (await http.get(Uri.parse(recorded))).bodyBytes + : await File(recorded).readAsBytes(); + if (bytes.isEmpty) return null; + final upload = await chat.uploadAsset( + room, + 'voice.${format.extension}', + bytes, + ); + return core.Message.audio( + id: const Uuid().v7(), + authorId: authorId, + source: upload.url, + duration: duration, + size: bytes.length, + waveform: downsampleWaveform(samples), + ); + } finally { + await recorder.dispose(); + } + } +} + +typedef _VoiceFormat = ({AudioEncoder encoder, String extension}); + +/// Best voice recording format the platform supports: AAC in an M4A +/// container where available (every native platform), Opus in WebM in +/// browsers (Chrome/Firefox can't encode AAC), WAV as a last resort. +Future<_VoiceFormat> _voiceFormat(AudioRecorder recorder) async { + const candidates = <_VoiceFormat>[ + (encoder: AudioEncoder.aacLc, extension: 'm4a'), + (encoder: AudioEncoder.opus, extension: 'webm'), + (encoder: AudioEncoder.wav, extension: 'wav'), + ]; + for (final format in candidates) { + if (await recorder.isEncoderSupported(format.encoder)) return format; + } + return candidates.last; +} + +/// MIME type of a voice message from its file extension (the audio player +/// needs it to decode the downloaded bytes, especially on the web). +String voiceMimeType(String source) { + final path = Uri.tryParse(source)?.path.toLowerCase() ?? ''; + if (path.endsWith('.webm')) return 'audio/webm'; + if (path.endsWith('.ogg') || path.endsWith('.opus')) return 'audio/ogg'; + if (path.endsWith('.wav')) return 'audio/wav'; + if (path.endsWith('.mp3')) return 'audio/mpeg'; + return 'audio/mp4'; +} + +/// Downloads a file message (with the auth headers) and opens it. +Future openFileMessage(core.FileMessage message) async { + final response = await http.get( + Uri.parse(message.source), + headers: SupabaseChatCore.instance.httpSupabaseHeaders, + ); + if (response.statusCode != 200) { + throw SupabaseChatCoreStorageException( + 'Download failed (${response.statusCode})', + ); + } + final saved = await FileSaver.instance.saveFile( + name: message.name, + bytes: response.bodyBytes, + ); + if (!kIsWeb) await OpenFilex.open(saved); +} + +/// Reduces [samples] to [bars] values (max of each bucket), the resolution +/// a voice message bubble draws. +List downsampleWaveform(List samples, {int bars = 48}) { + if (samples.isEmpty) return const []; + if (samples.length <= bars) return List.of(samples); + final bucket = samples.length / bars; + return [ + for (var i = 0; i < bars; i++) + samples + .sublist((i * bucket).floor(), ((i + 1) * bucket).floor()) + .fold(0.0, (a, b) => a > b ? a : b), + ]; +} + +class _RecordingSheet extends StatefulWidget { + const _RecordingSheet({required this.samples}); + + /// Live amplitude samples (0..1), appended by the recorder. + final List samples; + + @override + State<_RecordingSheet> createState() => _RecordingSheetState(); +} + +class _RecordingSheetState extends State<_RecordingSheet> + with SingleTickerProviderStateMixin { + final _stopwatch = Stopwatch()..start(); + late final Timer _timer; + late final AnimationController _pulse = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + )..repeat(reverse: true); + + @override + void initState() { + super.initState(); + _timer = Timer.periodic( + const Duration(milliseconds: 250), + (_) => setState(() {}), + ); + } + + @override + void dispose() { + _timer.cancel(); + _pulse.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 16, + children: [ + ScaleTransition( + scale: Tween(begin: .9, end: 1.1).animate(_pulse), + child: CircleAvatar( + radius: 36, + backgroundColor: scheme.errorContainer, + child: Icon(Icons.mic_rounded, size: 36, color: scheme.error), + ), + ), + SizedBox( + height: 40, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 2, + children: [ + for (final v + in widget.samples.length > 40 + ? widget.samples.sublist(widget.samples.length - 40) + : widget.samples) + AnimatedContainer( + duration: const Duration(milliseconds: 100), + width: 3, + height: 4 + v * 36, + decoration: BoxDecoration( + color: scheme.primary, + borderRadius: BorderRadius.circular(2), + ), + ), + ], + ), + ), + Text( + formatDuration(_stopwatch.elapsed), + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + Row( + spacing: 12, + children: [ + Expanded( + child: OutlinedButton( + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(52), + ), + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ), + Expanded( + child: FilledButton.icon( + onPressed: () => Navigator.pop(context, _stopwatch.elapsed), + icon: const Icon(Icons.send_rounded), + label: const Text('Send'), + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/example/lib/src/utils/format.dart b/example/lib/src/utils/format.dart new file mode 100644 index 0000000..aa9d3cd --- /dev/null +++ b/example/lib/src/utils/format.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart' as core; + +/// Compact timestamp for lists: time today, weekday this week, date before. +String formatListTime(BuildContext context, DateTime? date) { + if (date == null) return ''; + final local = date.toLocal(); + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final day = DateTime(local.year, local.month, local.day); + final localizations = MaterialLocalizations.of(context); + // 24h like the bubbles' timestamps (flyer_chat_* messages use HH:mm). + if (day == today) return formatTime(local); + if (today.difference(day).inDays == 1) return 'Yesterday'; + if (today.difference(day).inDays < 7) { + const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + return days[local.weekday - 1]; + } + return localizations.formatShortDate(local); +} + +/// `HH:mm` local time. +String formatTime(DateTime date) { + final local = date.toLocal(); + return '${local.hour.toString().padLeft(2, '0')}:' + '${local.minute.toString().padLeft(2, '0')}'; +} + +/// Day separator label in a conversation: "Today", "Yesterday", the +/// weekday within a week, the full date before. +String formatDayHeader(BuildContext context, DateTime date) { + final local = date.toLocal(); + final now = DateTime.now(); + final days = DateTime( + now.year, + now.month, + now.day, + ).difference(DateTime(local.year, local.month, local.day)).inDays; + if (days == 0) return 'Today'; + if (days == 1) return 'Yesterday'; + final localizations = MaterialLocalizations.of(context); + if (local.year == now.year) return localizations.formatMediumDate(local); + return localizations.formatFullDate(local); +} + +/// "last seen ..." label for a user's [lastSeen]. +String formatLastSeen(DateTime? lastSeen) { + if (lastSeen == null) return 'offline'; + final diff = DateTime.now().difference(lastSeen.toLocal()); + if (diff.inMinutes < 1) return 'last seen just now'; + if (diff.inMinutes < 60) return 'last seen ${diff.inMinutes} min ago'; + if (diff.inHours < 24) return 'last seen ${diff.inHours} h ago'; + if (diff.inDays < 7) return 'last seen ${diff.inDays} d ago'; + return 'last seen a while ago'; +} + +/// One-line preview of a message for the rooms list, with a matching icon. +({IconData? icon, String text}) messagePreview(core.Message? message) => + switch (message) { + null => (icon: null, text: 'No messages yet'), + core.TextMessage(:final text) => (icon: null, text: text), + core.ImageMessage() => (icon: Icons.photo_outlined, text: 'Photo'), + core.FileMessage(:final name) => ( + icon: Icons.insert_drive_file_outlined, + text: name, + ), + core.AudioMessage(:final duration) => ( + icon: Icons.mic_none_rounded, + text: 'Voice message (${formatDuration(duration)})', + ), + core.VideoMessage() => (icon: Icons.videocam_outlined, text: 'Video'), + _ => (icon: Icons.info_outline, text: 'Message'), + }; + +/// `m:ss` duration. +String formatDuration(Duration d) { + final minutes = d.inMinutes; + final seconds = d.inSeconds.remainder(60).toString().padLeft(2, '0'); + return '$minutes:$seconds'; +} + +/// Human file size (KB/MB). +String formatBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(0)} KB'; + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; +} diff --git a/example/lib/src/widgets/audio_message_bubble.dart b/example/lib/src/widgets/audio_message_bubble.dart new file mode 100644 index 0000000..6c91728 --- /dev/null +++ b/example/lib/src/widgets/audio_message_bubble.dart @@ -0,0 +1,282 @@ +import 'dart:async'; +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:audioplayers/audioplayers.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart' as core; +import 'package:flutter_supabase_chat_core/flutter_supabase_chat_core.dart'; +import 'package:http/http.dart' as http; + +import '../theme/app_theme.dart'; +import '../utils/attachments.dart'; +import '../utils/format.dart'; + +/// Telegram-style voice message: round play button, waveform that fills +/// as it plays (tap/drag it to seek), elapsed/total duration. +/// +/// There's no usable `flyer_chat_audio_message` widget to build on yet (the +/// published package is an empty placeholder), so this is a small custom +/// builder instead — wired via `Builders(audioMessageBuilder: ...)`. +class AudioMessageBubble extends StatefulWidget { + const AudioMessageBubble({ + super.key, + required this.message, + required this.isSentByMe, + this.footer, + }); + + final core.AudioMessage message; + final bool isSentByMe; + + /// Time/status line drawn under the waveform. + final Widget? footer; + + @override + State createState() => _AudioMessageBubbleState(); +} + +class _AudioMessageBubbleState extends State { + final AudioPlayer _player = AudioPlayer(); + final List> _subscriptions = []; + PlayerState _state = PlayerState.stopped; + Duration _position = Duration.zero; + Uint8List? _bytes; + bool _loading = false; + + late final List _bars = _resolveBars(); + + List _resolveBars() { + final waveform = widget.message.waveform; + if (waveform != null && waveform.isNotEmpty) return waveform; + // No recorded waveform (e.g. uploaded file): a stable pseudo-random one. + final random = math.Random(widget.message.id.hashCode); + return [for (var i = 0; i < 40; i++) .2 + random.nextDouble() * .8]; + } + + @override + void initState() { + super.initState(); + _subscriptions + ..add( + _player.onPlayerStateChanged.listen((state) { + if (mounted) setState(() => _state = state); + }), + ) + ..add( + _player.onPositionChanged.listen((position) { + if (mounted) setState(() => _position = position); + }), + ) + ..add( + _player.onPlayerComplete.listen((_) { + if (mounted) setState(() => _position = Duration.zero); + }), + ); + } + + @override + void dispose() { + for (final subscription in _subscriptions) { + subscription.cancel(); + } + _player.dispose(); + super.dispose(); + } + + Future _ensureSource() async { + if (_bytes != null) return; + setState(() => _loading = true); + try { + // Playing the URL directly would need auth headers audioplayers can't + // attach on every platform: fetch the (authenticated) bytes first. + final response = await http.get( + Uri.parse(widget.message.source), + headers: SupabaseChatCore.instance.httpSupabaseHeaders, + ); + if (response.statusCode != 200) { + throw SupabaseChatCoreStorageException( + 'Download failed (${response.statusCode})', + ); + } + _bytes = response.bodyBytes; + await _player.setSource( + BytesSource(_bytes!, mimeType: voiceMimeType(widget.message.source)), + ); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _guard(Future Function() action) async { + try { + await action(); + } on Object catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Cannot play audio: $e'))); + } + } + } + + Future _toggle() => _guard(() async { + if (_state == PlayerState.playing) return _player.pause(); + await _ensureSource(); + await _player.resume(); + }); + + Future _seek(double fraction) => _guard(() async { + await _ensureSource(); + final target = widget.message.duration * fraction.clamp(0.0, 1.0); + await _player.seek(target); + if (mounted) setState(() => _position = target); + }); + + @override + Widget build(BuildContext context) { + final palette = ChatPalette.of(context); + final mine = widget.isSentByMe; + final background = mine ? palette.sentBubble : palette.receivedBubble; + final meta = mine ? palette.sentMeta : palette.receivedMeta; + final accent = mine ? palette.sentMeta : palette.accent; + final total = widget.message.duration; + final progress = total.inMilliseconds == 0 + ? 0.0 + : (_position.inMilliseconds / total.inMilliseconds).clamp(0.0, 1.0); + final playing = _state == PlayerState.playing; + + return Container( + width: 260, + padding: const EdgeInsets.fromLTRB(8, 8, 12, 6), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + spacing: 10, + children: [ + Material( + color: accent, + shape: const CircleBorder(), + child: InkWell( + customBorder: const CircleBorder(), + onTap: _loading ? null : _toggle, + child: SizedBox.square( + dimension: 44, + child: _loading + ? const Padding( + padding: EdgeInsets.all(12), + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : AnimatedSwitcher( + duration: const Duration(milliseconds: 150), + child: Icon( + playing + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + key: ValueKey(playing), + color: Colors.white, + size: 28, + ), + ), + ), + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + LayoutBuilder( + builder: (context, constraints) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (d) => + _seek(d.localPosition.dx / constraints.maxWidth), + onHorizontalDragUpdate: (d) => + _seek(d.localPosition.dx / constraints.maxWidth), + child: SizedBox( + height: 28, + width: double.infinity, + child: CustomPaint( + painter: _WaveformPainter( + bars: _bars, + progress: progress, + played: accent, + unplayed: meta.withValues(alpha: .45), + ), + ), + ), + ), + ), + Row( + children: [ + Text( + formatDuration( + _state == PlayerState.stopped && + _position == Duration.zero + ? total + : _position, + ), + style: TextStyle( + color: meta, + fontSize: 12, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + const Spacer(), + ?widget.footer, + ], + ), + ], + ), + ), + ], + ), + ); + } +} + +class _WaveformPainter extends CustomPainter { + _WaveformPainter({ + required this.bars, + required this.progress, + required this.played, + required this.unplayed, + }); + + final List bars; + final double progress; + final Color played; + final Color unplayed; + + @override + void paint(Canvas canvas, Size size) { + if (bars.isEmpty) return; + const gap = 2.0; + final width = math.max( + 1.5, + (size.width - gap * (bars.length - 1)) / bars.length, + ); + final paint = Paint(); + for (var i = 0; i < bars.length; i++) { + final x = i * (width + gap); + final h = math.max(3.0, bars[i] * size.height); + paint.color = (i + .5) / bars.length <= progress ? played : unplayed; + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(x, (size.height - h) / 2, width, h), + Radius.circular(width / 2), + ), + paint, + ); + } + } + + @override + bool shouldRepaint(_WaveformPainter old) => + old.progress != progress || old.played != played || old.bars != bars; +} diff --git a/example/lib/src/widgets/chat_composer.dart b/example/lib/src/widgets/chat_composer.dart new file mode 100644 index 0000000..3b9e006 --- /dev/null +++ b/example/lib/src/widgets/chat_composer.dart @@ -0,0 +1,196 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_chat_ui/flutter_chat_ui.dart'; +import 'package:provider/provider.dart'; + +/// Message composer laid out as in messaging apps: attachment button, text +/// field, and on the right the send button, which becomes a microphone +/// (record a voice message) while the field is empty. +/// +/// A drop-in replacement of `flutter_chat_ui`'s `Composer` (which has no slot +/// for an extra button) for `Chat`'s `composerBuilder`: it sends through the +/// chat's `onMessageSend`, opens its `onAttachmentTap` and reports its height +/// to the message list the same way. +class ChatComposer extends StatefulWidget { + const ChatComposer({ + super.key, + required this.textEditingController, + required this.focusNode, + this.hintText = 'Message', + this.onRecordVoice, + this.sendOnEnter = false, + this.topWidget, + this.backgroundColor, + this.inputFillColor, + }); + + /// Controller of the text field, owned by the caller. + final TextEditingController textEditingController; + + /// Focus of the text field, owned by the caller. + final FocusNode focusNode; + + final String hintText; + + /// Records a voice message, from the microphone shown while the field is + /// empty. Without it the send button is always shown. + final VoidCallback? onRecordVoice; + + /// Whether Enter sends (Shift+Enter then adds a new line), as on desktop. + final bool sendOnEnter; + + /// Shown above the field (e.g. the message being replied to). + final Widget? topWidget; + + final Color? backgroundColor; + final Color? inputFillColor; + + @override + State createState() => _ChatComposerState(); +} + +class _ChatComposerState extends State { + final _key = GlobalKey(); + late bool _hasText = _text.text.trim().isNotEmpty; + + TextEditingController get _text => widget.textEditingController; + + @override + void initState() { + super.initState(); + _text.addListener(_onTextChanged); + widget.focusNode.onKeyEvent = _onKeyEvent; + WidgetsBinding.instance.addPostFrameCallback((_) => _measure()); + } + + @override + void didUpdateWidget(covariant ChatComposer oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.textEditingController != widget.textEditingController) { + oldWidget.textEditingController.removeListener(_onTextChanged); + _text.addListener(_onTextChanged); + } + // The reply preview above the field changes its height. + WidgetsBinding.instance.addPostFrameCallback((_) => _measure()); + } + + @override + void dispose() { + _text.removeListener(_onTextChanged); + widget.focusNode.onKeyEvent = null; + super.dispose(); + } + + void _onTextChanged() { + final hasText = _text.text.trim().isNotEmpty; + if (hasText != _hasText) setState(() => _hasText = hasText); + } + + KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.enter && + widget.sendOnEnter ^ HardwareKeyboard.instance.isShiftPressed) { + _send(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + void _send() { + final text = _text.text.trim(); + if (text.isEmpty) return; + context.read()?.call(text); + _text.clear(); + } + + /// Tells the message list how much room to leave at the bottom. + void _measure() { + if (!mounted) return; + final box = _key.currentContext?.findRenderObject() as RenderBox?; + if (box == null) return; + context.read().setHeight( + box.size.height - MediaQuery.of(context).padding.bottom, + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final onAttachmentTap = context.read(); + final showMic = !_hasText && widget.onRecordVoice != null; + return Positioned( + left: 0, + right: 0, + bottom: 0, + child: Container( + key: _key, + color: widget.backgroundColor ?? scheme.surfaceContainerLow, + child: Column( + children: [ + ?widget.topWidget, + Padding( + padding: const EdgeInsets.all(8).add( + EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom), + ), + child: Row( + spacing: 8, + children: [ + if (onAttachmentTap != null) + IconButton( + icon: const Icon(Icons.attach_file), + tooltip: 'Attach', + color: scheme.onSurfaceVariant, + onPressed: onAttachmentTap, + ), + Expanded( + child: TextField( + controller: _text, + focusNode: widget.focusNode, + minLines: 1, + maxLines: 3, + textCapitalization: TextCapitalization.sentences, + textInputAction: TextInputAction.newline, + decoration: InputDecoration( + hintText: widget.hintText, + border: const OutlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.all(Radius.circular(24)), + ), + filled: true, + fillColor: + widget.inputFillColor ?? + scheme.surfaceContainerHigh, + hoverColor: Colors.transparent, + ), + ), + ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 150), + transitionBuilder: (child, animation) => + ScaleTransition(scale: animation, child: child), + child: showMic + ? IconButton( + key: const ValueKey('mic'), + icon: const Icon(Icons.mic_none_rounded), + tooltip: 'Record a voice message', + color: scheme.primary, + onPressed: widget.onRecordVoice, + ) + : IconButton( + key: const ValueKey('send'), + icon: const Icon(Icons.send), + tooltip: 'Send', + color: scheme.primary, + onPressed: _hasText ? _send : null, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/example/lib/src/widgets/chat_wallpaper.dart b/example/lib/src/widgets/chat_wallpaper.dart new file mode 100644 index 0000000..47a0f41 --- /dev/null +++ b/example/lib/src/widgets/chat_wallpaper.dart @@ -0,0 +1,121 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../theme/app_theme.dart'; + +/// Telegram-style chat background: a soft gradient with a faint doodle +/// pattern of chat-themed icons on top. +class ChatWallpaper extends StatelessWidget { + const ChatWallpaper({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final palette = ChatPalette.of(context); + return DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: palette.wallpaper, + ), + ), + // The doodles are scattered past the edges: clip them to this box. + child: ClipRect( + child: CustomPaint( + painter: _DoodlePainter(palette.wallpaperPattern), + child: child, + ), + ), + ); + } +} + +class _DoodlePainter extends CustomPainter { + _DoodlePainter(this.color); + + final Color color; + + static const _icons = [ + Icons.chat_bubble_outline_rounded, + Icons.favorite_border_rounded, + Icons.star_border_rounded, + Icons.music_note_outlined, + Icons.local_cafe_outlined, + Icons.camera_alt_outlined, + Icons.emoji_emotions_outlined, + Icons.flight_outlined, + Icons.headphones_outlined, + Icons.pets_outlined, + Icons.wb_sunny_outlined, + Icons.send_outlined, + ]; + + @override + void paint(Canvas canvas, Size size) { + const cell = 64.0; + final random = math.Random(7); + for (var y = 0.0; y < size.height + cell; y += cell) { + final rowOffset = ((y / cell).round().isOdd) ? cell / 2 : 0.0; + for (var x = -cell; x < size.width + cell; x += cell) { + final icon = _icons[random.nextInt(_icons.length)]; + final painter = TextPainter( + text: TextSpan( + text: String.fromCharCode(icon.codePoint), + style: TextStyle( + fontSize: 22 + random.nextDouble() * 8, + fontFamily: icon.fontFamily, + package: icon.fontPackage, + color: color, + ), + ), + textDirection: TextDirection.ltr, + )..layout(); + canvas + ..save() + ..translate( + x + rowOffset + random.nextDouble() * 12, + y + random.nextDouble() * 12, + ) + ..rotate((random.nextDouble() - .5) * .9); + painter.paint(canvas, Offset(-painter.width / 2, -painter.height / 2)); + canvas.restore(); + } + } + } + + @override + bool shouldRepaint(_DoodlePainter oldDelegate) => oldDelegate.color != color; +} + +/// Centered translucent "service" chip (dates, notices), as Telegram draws +/// them over the wallpaper. +class ServiceChip extends StatelessWidget { + const ServiceChip({super.key, required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + final palette = ChatPalette.of(context); + return Center( + child: Container( + margin: const EdgeInsets.symmetric(vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: palette.serviceChip, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + text, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: palette.onServiceChip, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + } +} diff --git a/example/lib/src/widgets/empty_state.dart b/example/lib/src/widgets/empty_state.dart new file mode 100644 index 0000000..cbb6c3a --- /dev/null +++ b/example/lib/src/widgets/empty_state.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; + +/// Friendly placeholder for empty lists / unselected panes. +class EmptyState extends StatelessWidget { + const EmptyState({ + super.key, + required this.icon, + required this.title, + this.message, + this.action, + }); + + final IconData icon; + final String title; + final String? message; + final Widget? action; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 12, + children: [ + Container( + width: 96, + height: 96, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [scheme.primaryContainer, scheme.tertiaryContainer], + ), + ), + child: Icon(icon, size: 44, color: scheme.onPrimaryContainer), + ), + const SizedBox(height: 4), + Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + textAlign: TextAlign.center, + ), + if (message != null) + Text( + message!, + style: theme.textTheme.bodyMedium?.copyWith( + color: scheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + if (action != null) ...[const SizedBox(height: 8), action!], + ], + ), + ), + ); + } +} diff --git a/example/lib/src/widgets/message_actions_sheet.dart b/example/lib/src/widgets/message_actions_sheet.dart new file mode 100644 index 0000000..6eaafce --- /dev/null +++ b/example/lib/src/widgets/message_actions_sheet.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart' as core; + +/// What the user picked in [MessageActionsSheet]. +sealed class MessageAction { + const MessageAction(); +} + +class MessageReact extends MessageAction { + const MessageReact(this.emoji); + final String emoji; +} + +class MessageReply extends MessageAction { + const MessageReply(); +} + +class MessagePin extends MessageAction { + const MessagePin({required this.pinned}); + final bool pinned; +} + +class MessageCopy extends MessageAction { + const MessageCopy(); +} + +class MessageEdit extends MessageAction { + const MessageEdit(); +} + +class MessageDelete extends MessageAction { + const MessageDelete(); +} + +class MessageRetry extends MessageAction { + const MessageRetry(); +} + +/// Long-press menu of a message: quick reactions plus contextual actions. +class MessageActionsSheet extends StatelessWidget { + const MessageActionsSheet({ + super.key, + required this.message, + required this.canEdit, + required this.canDelete, + required this.canPin, + }); + + static const quickReactions = ['👍', '❤️', '😂', '😮', '😢', '🙏']; + + final core.Message message; + final bool canEdit; + final bool canDelete; + final bool canPin; + + static Future show( + BuildContext context, { + required core.Message message, + required bool canEdit, + required bool canDelete, + required bool canPin, + }) => showModalBottomSheet( + context: context, + builder: (_) => MessageActionsSheet( + message: message, + canEdit: canEdit, + canDelete: canDelete, + canPin: canPin, + ), + ); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final failed = message.status == core.MessageStatus.error; + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!failed) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + for (final emoji in quickReactions) + InkResponse( + radius: 28, + onTap: () => Navigator.pop(context, MessageReact(emoji)), + child: Padding( + padding: const EdgeInsets.all(6), + child: Text( + emoji, + style: const TextStyle(fontSize: 30), + ), + ), + ), + ], + ), + ), + if (failed) + ListTile( + leading: const Icon(Icons.refresh_rounded), + title: const Text('Retry sending'), + onTap: () => Navigator.pop(context, const MessageRetry()), + ), + if (!failed) + ListTile( + leading: const Icon(Icons.reply_rounded), + title: const Text('Reply'), + onTap: () => Navigator.pop(context, const MessageReply()), + ), + if (canPin && !failed) + ListTile( + leading: Icon( + message.pinned == true + ? Icons.push_pin_outlined + : Icons.push_pin_rounded, + ), + title: Text(message.pinned == true ? 'Unpin' : 'Pin'), + onTap: () => Navigator.pop( + context, + MessagePin(pinned: message.pinned != true), + ), + ), + if (message is core.TextMessage) + ListTile( + leading: const Icon(Icons.copy_rounded), + title: const Text('Copy text'), + onTap: () => Navigator.pop(context, const MessageCopy()), + ), + if (canEdit && !failed) + ListTile( + leading: const Icon(Icons.edit_outlined), + title: const Text('Edit'), + onTap: () => Navigator.pop(context, const MessageEdit()), + ), + if (canDelete || failed) + ListTile( + leading: Icon(Icons.delete_outline_rounded, color: scheme.error), + title: Text('Delete', style: TextStyle(color: scheme.error)), + onTap: () => Navigator.pop(context, const MessageDelete()), + ), + const SizedBox(height: 8), + ], + ), + ); + } +} diff --git a/example/lib/src/widgets/pinned_banner.dart b/example/lib/src/widgets/pinned_banner.dart new file mode 100644 index 0000000..8b33755 --- /dev/null +++ b/example/lib/src/widgets/pinned_banner.dart @@ -0,0 +1,150 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart' as core; +import 'package:flutter_supabase_chat_core/flutter_supabase_chat_core.dart'; + +import '../utils/format.dart'; + +/// Telegram-style pinned message bar under the app bar. With several +/// pinned messages, each tap jumps to the current one and moves on to the +/// next (older) one; a vertical indicator shows the position. +class PinnedBanner extends StatefulWidget { + const PinnedBanner({ + super.key, + required this.controller, + required this.onJump, + this.onUnpin, + }); + + final SupabaseChatController controller; + final void Function(core.Message message) onJump; + + /// Shown as an "x" when the user may unpin (admins / direct rooms). + final void Function(core.Message message)? onUnpin; + + @override + State createState() => _PinnedBannerState(); +} + +class _PinnedBannerState extends State { + int _index = 0; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + return ValueListenableBuilder>( + valueListenable: widget.controller.pinnedMessages, + builder: (context, pinned, _) { + final show = pinned.isNotEmpty; + final index = show ? _index % pinned.length : 0; + final message = show ? pinned[index] : null; + return AnimatedSize( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOut, + child: message == null + ? const SizedBox(width: double.infinity) + : Material( + color: scheme.surface, + child: InkWell( + onTap: () { + widget.onJump(message); + setState(() => _index = index + 1); + }, + child: Container( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: + theme.dividerTheme.color ?? + scheme.outlineVariant, + width: .5, + ), + ), + ), + padding: const EdgeInsets.fromLTRB(12, 6, 4, 6), + child: Row( + spacing: 10, + children: [ + _PositionBar( + count: pinned.length, + index: index, + color: scheme.primary, + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + pinned.length > 1 + ? 'Pinned message #${pinned.length - index}' + : 'Pinned message', + style: theme.textTheme.labelLarge?.copyWith( + color: scheme.primary, + fontWeight: FontWeight.w700, + ), + ), + Text( + messagePreview(message).text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium, + ), + ], + ), + ), + if (widget.onUnpin != null) + IconButton( + tooltip: 'Unpin', + visualDensity: VisualDensity.compact, + onPressed: () => widget.onUnpin!(message), + icon: Icon( + Icons.close_rounded, + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ), + ); + }, + ); + } +} + +class _PositionBar extends StatelessWidget { + const _PositionBar({ + required this.count, + required this.index, + required this.color, + }); + + final int count; + final int index; + final Color color; + + @override + Widget build(BuildContext context) { + final segments = count.clamp(1, 4); + final active = index % segments; + return SizedBox( + width: 3, + height: 36, + child: Column( + spacing: 2, + children: [ + for (var i = 0; i < segments; i++) + Expanded( + child: DecoratedBox( + decoration: BoxDecoration( + color: i == active ? color : color.withValues(alpha: .3), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + ], + ), + ); + } +} diff --git a/example/lib/src/widgets/reactions_row.dart b/example/lib/src/widgets/reactions_row.dart new file mode 100644 index 0000000..2281ba6 --- /dev/null +++ b/example/lib/src/widgets/reactions_row.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart' as core; + +/// Reaction chips under a message bubble (emoji + count). Tapping one +/// toggles the current user's reaction. +class ReactionsRow extends StatelessWidget { + const ReactionsRow({ + super.key, + required this.message, + required this.currentUserId, + required this.onToggle, + required this.alignEnd, + }); + + final core.Message message; + final String currentUserId; + final void Function(String emoji) onToggle; + final bool alignEnd; + + @override + Widget build(BuildContext context) { + final reactions = message.reactions; + if (reactions == null || reactions.isEmpty) return const SizedBox.shrink(); + final scheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.only(top: 2, bottom: 4), + child: Wrap( + alignment: alignEnd ? WrapAlignment.end : WrapAlignment.start, + spacing: 4, + runSpacing: 4, + children: [ + for (final MapEntry(key: emoji, value: users) in reactions.entries) + Material( + color: users.contains(currentUserId) + ? scheme.primaryContainer + : scheme.surfaceContainerHighest, + shape: StadiumBorder( + side: BorderSide( + color: users.contains(currentUserId) + ? scheme.primary.withValues(alpha: .5) + : Colors.transparent, + ), + ), + child: InkWell( + customBorder: const StadiumBorder(), + onTap: () => onToggle(emoji), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + child: Text( + users.length > 1 ? '$emoji ${users.length}' : emoji, + style: Theme.of(context).textTheme.labelMedium, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/example/lib/src/widgets/reply_quote.dart b/example/lib/src/widgets/reply_quote.dart new file mode 100644 index 0000000..c90b309 --- /dev/null +++ b/example/lib/src/widgets/reply_quote.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart' as core; +import 'package:flutter_supabase_chat_core/flutter_supabase_chat_core.dart'; + +import '../theme/app_theme.dart'; +import '../utils/format.dart'; + +/// Quoted message inside a reply bubble (Telegram style: colored bar, +/// author name, one-line preview). Resolves the quoted message through the +/// controller (loaded messages first, fetched otherwise). Tap to jump to it. +class ReplyQuote extends StatefulWidget { + const ReplyQuote({ + super.key, + required this.controller, + required this.room, + required this.messageId, + required this.onSurface, + this.onTap, + }); + + final SupabaseChatController controller; + final ChatRoom room; + final String messageId; + final Color onSurface; + final VoidCallback? onTap; + + @override + State createState() => _ReplyQuoteState(); +} + +class _ReplyQuoteState extends State { + late final Future _message = widget.controller.resolveMessage( + widget.messageId, + ); + + @override + Widget build(BuildContext context) => FutureBuilder( + future: _message, + builder: (context, snapshot) { + final message = snapshot.data; + final author = message == null + ? null + : widget.room.member(message.authorId); + final color = author == null + ? ChatPalette.of(context).accent + : avatarColor(author.id, Theme.of(context).colorScheme); + final preview = messagePreview(message); + return QuoteBlock( + color: color, + title: author?.fullName ?? (snapshot.hasData ? 'Deleted message' : '…'), + text: message == null ? '' : preview.text, + textColor: widget.onSurface, + onTap: widget.onTap, + ); + }, + ); +} + +/// The visual of a quote: left accent bar on a tinted background. +class QuoteBlock extends StatelessWidget { + const QuoteBlock({ + super.key, + required this.color, + required this.title, + required this.text, + required this.textColor, + this.onTap, + this.trailing, + }); + + final Color color; + final String title; + final String text; + final Color textColor; + final VoidCallback? onTap; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + return Material( + color: color.withValues(alpha: .12), + borderRadius: BorderRadius.circular(6), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Container( + decoration: BoxDecoration( + border: Border(left: BorderSide(color: color, width: 3)), + ), + padding: const EdgeInsets.fromLTRB(8, 4, 8, 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.labelLarge?.copyWith( + color: color, + fontWeight: FontWeight.w700, + ), + ), + if (text.isNotEmpty) + Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.bodySmall?.copyWith(color: textColor), + ), + ], + ), + ), + ?trailing, + ], + ), + ), + ), + ); + } +} diff --git a/example/lib/src/widgets/room_tile.dart b/example/lib/src/widgets/room_tile.dart index 88ae5e1..32ec2cc 100644 --- a/example/lib/src/widgets/room_tile.dart +++ b/example/lib/src/widgets/room_tile.dart @@ -1,132 +1,224 @@ import 'package:flutter/material.dart'; -import 'package:flutter_chat_types/flutter_chat_types.dart' as types; +import 'package:flutter_chat_core/flutter_chat_core.dart' as core; import 'package:flutter_supabase_chat_core/flutter_supabase_chat_core.dart'; -import 'package:timeago/timeago.dart' as timeago; -import '../class/message_status_ex.dart'; -import '../util.dart'; +import '../utils/format.dart'; +import 'user_avatar.dart'; +/// One conversation in the chats list, Telegram style: avatar (with online +/// dot for direct chats), name (+ muted icon), last message with sender +/// prefix and read ticks, time, and an unread badge — grey when muted — or +/// a pin for pinned chats. class RoomTile extends StatelessWidget { - final types.Room room; - final ValueChanged onTap; - const RoomTile({ super.key, required this.room, + required this.currentUserId, required this.onTap, + this.onLongPress, + this.selected = false, }); - Widget _buildAvatar(types.Room room) { - final color = getAvatarColor(room.id); - var otherUserIndex = -1; - types.User? otherUser; + final ChatRoom room; + final String currentUserId; + final VoidCallback onTap; + final VoidCallback? onLongPress; + final bool selected; - if (room.type == types.RoomType.direct) { - otherUserIndex = room.users.indexWhere( - (u) => u.id != SupabaseChatCore.instance.loggedSupabaseUser!.id, - ); - if (otherUserIndex >= 0) { - otherUser = room.users[otherUserIndex]; - } - } + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final last = room.lastMessage; + final preview = messagePreview(last); + final unread = room.unreadCount > 0; + final mine = last != null && last.authorId == currentUserId; + // "You: ..." for own messages, ": ..." in groups. + final prefix = last == null + ? null + : mine + ? 'You' + : room.type == ChatRoomType.group + ? room.member(last.authorId)?.firstName + : null; - final hasImage = room.imageUrl != null; - final name = room.name ?? ''; - final Widget child = CircleAvatar( - backgroundColor: hasImage ? Colors.transparent : color, - backgroundImage: hasImage ? NetworkImage(room.imageUrl!) : null, - radius: 20, - child: !hasImage - ? Text( - name.isEmpty ? '' : name[0].toUpperCase(), - style: const TextStyle(color: Colors.white), - ) - : null, - ); - if (otherUser == null) { - return Padding( - padding: const EdgeInsets.only(right: 16), - child: child, - ); - } - return Padding( - padding: const EdgeInsets.only(right: 16), - child: UserOnlineStatusWidget( - uid: otherUser.id, - builder: (status) => Stack( - alignment: Alignment.bottomRight, - children: [ - child, - if (status == UserOnlineStatus.online) - Container( - width: 10, - height: 10, - margin: const EdgeInsets.only( - right: 3, - bottom: 3, - ), - decoration: BoxDecoration( - color: Colors.green, - shape: BoxShape.circle, - border: Border.all( - color: Colors.white, - width: 2, - ), + return Material( + color: selected + ? scheme.primary.withValues(alpha: .12) + : Colors.transparent, + child: InkWell( + onTap: onTap, + onLongPress: onLongPress, + child: Padding( + padding: const EdgeInsets.fromLTRB(10, 7, 12, 7), + child: Row( + spacing: 12, + children: [ + UserAvatar.room(room, currentUserId, size: 54), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 2, + children: [ + Row( + spacing: 4, + children: [ + // The name takes the free space, so status and time + // always sit on the right edge. + Expanded( + child: Row( + spacing: 4, + children: [ + if (room.type == ChatRoomType.group) + Icon( + Icons.group_rounded, + size: 16, + color: scheme.onSurface, + ), + Flexible( + child: Text( + room.name ?? 'Unnamed', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + if (room.isMuted) + Icon( + Icons.volume_off_rounded, + size: 15, + color: scheme.onSurfaceVariant, + ), + ], + ), + ), + if (mine) _StatusIcon(status: last.status), + Text( + formatListTime( + context, + last?.createdAt ?? room.updatedAt, + ), + style: theme.textTheme.labelSmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + Row( + spacing: 6, + children: [ + Expanded( + child: Text.rich( + TextSpan( + children: [ + if (prefix != null) + TextSpan( + text: '$prefix: ', + style: TextStyle(color: scheme.onSurface), + ), + if (preview.icon != null) + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Padding( + padding: const EdgeInsets.only(right: 3), + child: Icon( + preview.icon, + size: 15, + color: scheme.primary, + ), + ), + ), + TextSpan(text: preview.text), + ], + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ), + if (unread) + _Badge(count: room.unreadCount, muted: room.isMuted) + else if (room.isPinned) + Transform.rotate( + angle: .7, + child: Icon( + Icons.push_pin_rounded, + size: 16, + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ], ), ), - ], + ], + ), ), ), ); } +} + +class _Badge extends StatelessWidget { + const _Badge({required this.count, required this.muted}); + + final int count; + final bool muted; @override - Widget build(BuildContext context) => ListTile( - key: ValueKey(room.id), - leading: _buildAvatar(room), - title: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(room.name ?? ''), - if (room.lastMessages?.isNotEmpty == true) - Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - timeago.format( - DateTime.now().subtract( - Duration( - milliseconds: DateTime.now().millisecondsSinceEpoch - - (room.updatedAt ?? 0), - ), - ), - locale: 'en_short', - ), - ), - if (room.lastMessages!.first.status != null) - Padding( - padding: const EdgeInsets.only(left: 8.0), - child: Icon( - size: 20, - room.lastMessages!.first.status!.icon, - color: - room.lastMessages!.first.status == types.Status.seen - ? Colors.lightBlue - : null, - ), - ), - ], - ), - ], + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + constraints: const BoxConstraints(minWidth: 22), + height: 22, + padding: const EdgeInsets.symmetric(horizontal: 7), + alignment: Alignment.center, + decoration: BoxDecoration( + color: muted ? scheme.outline : scheme.primary, + borderRadius: BorderRadius.circular(11), + ), + child: Text( + count >= 100 ? '99+' : '$count', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w700, ), - subtitle: room.lastMessages?.isNotEmpty == true && - room.lastMessages!.first is types.TextMessage - ? Text( - (room.lastMessages!.first as types.TextMessage).text, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ) - : null, - onTap: () => onTap(room), - ); + ), + ); + } +} + +class _StatusIcon extends StatelessWidget { + const _StatusIcon({required this.status}); + + final core.MessageStatus? status; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return switch (status) { + core.MessageStatus.seen => Icon( + Icons.done_all_rounded, + size: 16, + color: scheme.primary, + ), + core.MessageStatus.sending => Icon( + Icons.schedule_rounded, + size: 14, + color: scheme.onSurfaceVariant, + ), + core.MessageStatus.error => Icon( + Icons.error_outline_rounded, + size: 16, + color: scheme.error, + ), + _ => Icon(Icons.done_rounded, size: 16, color: scheme.primary), + }; + } } diff --git a/example/lib/src/widgets/swipe_to_reply.dart b/example/lib/src/widgets/swipe_to_reply.dart new file mode 100644 index 0000000..cc7e479 --- /dev/null +++ b/example/lib/src/widgets/swipe_to_reply.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../theme/app_theme.dart'; + +/// Swipe a message to the left to reply to it, as in Telegram: the bubble +/// follows the finger, a reply icon fades in, and releasing past the +/// threshold triggers [onReply] (with a light haptic tick). +class SwipeToReply extends StatefulWidget { + const SwipeToReply({super.key, required this.child, required this.onReply}); + + final Widget child; + final VoidCallback onReply; + + @override + State createState() => _SwipeToReplyState(); +} + +class _SwipeToReplyState extends State + with SingleTickerProviderStateMixin { + static const _threshold = 64.0; + // Created in initState, not lazily: dispose() would otherwise create its + // ticker while the element is being unmounted (a message that was never + // swiped), which Flutter rejects. + late final AnimationController _back; + double _dx = 0; + double _from = 0; + bool _armed = false; + + @override + void initState() { + super.initState(); + _back = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 200), + )..addListener(() => setState(() => _dx = _from * (1 - _back.value))); + } + + @override + void dispose() { + _back.dispose(); + super.dispose(); + } + + void _update(DragUpdateDetails details) { + setState(() { + _dx = (_dx + details.delta.dx).clamp(-_threshold * 1.4, 0); + }); + final armed = _dx <= -_threshold; + if (armed && !_armed) HapticFeedback.selectionClick(); + _armed = armed; + } + + void _end(DragEndDetails details) { + if (_armed) widget.onReply(); + _armed = false; + _from = _dx; + _back.forward(from: 0); + } + + @override + Widget build(BuildContext context) { + final progress = (-_dx / _threshold).clamp(0.0, 1.0); + final palette = ChatPalette.of(context); + return GestureDetector( + behavior: HitTestBehavior.translucent, + onHorizontalDragUpdate: _update, + onHorizontalDragEnd: _end, + child: Stack( + alignment: Alignment.centerRight, + children: [ + Positioned( + right: 12, + child: Opacity( + opacity: progress, + child: Transform.scale( + scale: .6 + .4 * progress, + child: CircleAvatar( + radius: 16, + backgroundColor: palette.serviceChip, + child: const Icon( + Icons.reply_rounded, + size: 18, + color: Colors.white, + ), + ), + ), + ), + ), + Transform.translate(offset: Offset(_dx, 0), child: widget.child), + ], + ), + ); + } +} diff --git a/example/lib/src/widgets/user_avatar.dart b/example/lib/src/widgets/user_avatar.dart new file mode 100644 index 0000000..5221040 --- /dev/null +++ b/example/lib/src/widgets/user_avatar.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_supabase_chat_core/flutter_supabase_chat_core.dart'; + +import '../theme/app_theme.dart'; + +/// Circular avatar: the image when there is one (loaded with the Supabase +/// auth headers, since chat buckets are private), initials on a stable +/// color otherwise. Optionally shows a live online dot. +class UserAvatar extends StatelessWidget { + const UserAvatar({ + super.key, + required this.id, + this.name, + this.imageUrl, + this.size = 44, + this.showOnlineStatus = false, + this.icon, + }); + + /// Creates an avatar for [user]. + factory UserAvatar.user( + ChatUser user, { + double size = 44, + bool showOnlineStatus = false, + }) => UserAvatar( + id: user.id, + name: user.fullName.isEmpty ? null : user.fullName, + imageUrl: user.imageUrl, + size: size, + showOnlineStatus: showOnlineStatus, + ); + + /// Creates an avatar for [room]: the other member for direct rooms (with + /// their online status), a group icon otherwise. + factory UserAvatar.room( + ChatRoom room, + String currentUserId, { + double size = 48, + }) { + final other = room.otherUser(currentUserId); + if (other != null) { + return UserAvatar( + id: other.id, + name: room.name, + imageUrl: room.imageUrl, + size: size, + showOnlineStatus: true, + ); + } + return UserAvatar( + id: room.id, + name: room.name, + imageUrl: room.imageUrl, + size: size, + icon: Icons.groups_rounded, + ); + } + + final String id; + final String? name; + final String? imageUrl; + final double size; + final bool showOnlineStatus; + final IconData? icon; + + String get _initials { + final words = (name ?? '').trim().split(RegExp(r'\s+')); + return words + .where((w) => w.isNotEmpty) + .take(2) + .map((w) => w[0].toUpperCase()) + .join(); + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final color = avatarColor(id, scheme); + final initials = _initials; + final fallback = Center( + child: icon != null || initials.isEmpty + ? Icon( + icon ?? Icons.person_rounded, + color: Colors.white, + size: size * .5, + ) + : Text( + initials, + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + fontSize: size * .36, + ), + ), + ); + + final avatar = Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [color, Color.lerp(color, Colors.black, .18)!], + ), + ), + clipBehavior: Clip.antiAlias, + child: imageUrl == null + ? fallback + : Image.network( + imageUrl!, + headers: SupabaseChatCore.instance.httpSupabaseHeaders, + fit: BoxFit.cover, + width: size, + height: size, + errorBuilder: (_, _, _) => fallback, + ), + ); + + if (!showOnlineStatus) return avatar; + return SizedBox( + width: size, + height: size, + child: Stack( + clipBehavior: Clip.none, + children: [ + avatar, + Positioned( + right: -1, + bottom: -1, + child: UserOnlineStatusWidget( + uid: id, + builder: (status) => AnimatedScale( + scale: status == UserOnlineStatus.online ? 1 : 0, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutBack, + child: Container( + width: size * .3, + height: size * .3, + decoration: BoxDecoration( + color: const Color(0xFF22C55E), + shape: BoxShape.circle, + border: Border.all(color: scheme.surface, width: 2.5), + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/example/lib/src/widgets/user_tile.dart b/example/lib/src/widgets/user_tile.dart deleted file mode 100644 index 9e4aa6b..0000000 --- a/example/lib/src/widgets/user_tile.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_chat_types/flutter_chat_types.dart' as types; -import 'package:flutter_supabase_chat_core/flutter_supabase_chat_core.dart'; - -import '../class/user_ex.dart'; -import '../util.dart'; - -class UserTile extends StatelessWidget { - final types.User user; - final ValueChanged onTap; - - const UserTile({ - super.key, - required this.user, - required this.onTap, - }); - - Widget _buildAvatar(types.User user) { - final color = getAvatarColor(user.id); - final hasImage = user.imageUrl != null; - final name = user.getUserName(); - return Container( - margin: const EdgeInsets.only(right: 16), - child: UserOnlineStatusWidget( - uid: user.id, - builder: (status) => Stack( - alignment: Alignment.bottomRight, - children: [ - CircleAvatar( - backgroundColor: hasImage ? Colors.transparent : color, - backgroundImage: hasImage ? NetworkImage(user.imageUrl!) : null, - radius: 20, - child: !hasImage - ? Text( - name.isEmpty ? '' : name[0].toUpperCase(), - style: const TextStyle(color: Colors.white), - ) - : null, - ), - if (status == UserOnlineStatus.online) - Container( - width: 10, - height: 10, - margin: const EdgeInsets.only(right: 3, bottom: 3), - decoration: BoxDecoration( - color: Colors.green, - shape: BoxShape.circle, - border: Border.all( - color: Colors.white, - width: 2, - ), - ), - ), - ], - ), - ), - ); - } - - @override - Widget build(BuildContext context) => ListTile( - leading: _buildAvatar(user), - title: Text(user.getUserName()), - onTap: () => onTap(user), - ); -} diff --git a/example/lib/supabase_options.dart b/example/lib/supabase_options.dart index 1398cd8..a206d1c 100644 --- a/example/lib/supabase_options.dart +++ b/example/lib/supabase_options.dart @@ -2,13 +2,23 @@ class SupabaseOptions { final String url; final String anonKey; - SupabaseOptions({ - required this.url, - required this.anonKey, - }); + const SupabaseOptions({required this.url, required this.anonKey}); } -final SupabaseOptions supabaseOptions = SupabaseOptions( - url: 'https://{{your_project_reference_id}}.supabase.co', - anonKey: '{{supabase_anon_key}}', +/// Fill in your project's URL and anon/publishable key, or pass them at +/// build time without editing this file: +/// +/// ```sh +/// flutter run --dart-define=SUPABASE_URL=https://.supabase.co \ +/// --dart-define=SUPABASE_ANON_KEY= +/// ``` +const SupabaseOptions supabaseOptions = SupabaseOptions( + url: String.fromEnvironment( + 'SUPABASE_URL', + defaultValue: 'https://{{your_project_reference_id}}.supabase.co', + ), + anonKey: String.fromEnvironment( + 'SUPABASE_ANON_KEY', + defaultValue: '{{supabase_anon_key}}', + ), ); diff --git a/example/linux/flutter/generated_plugin_registrant.cc b/example/linux/flutter/generated_plugin_registrant.cc index 3c28536..e9c5473 100644 --- a/example/linux/flutter/generated_plugin_registrant.cc +++ b/example/linux/flutter/generated_plugin_registrant.cc @@ -6,21 +6,29 @@ #include "generated_plugin_registrant.h" +#include +#include #include #include -#include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) app_links_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "AppLinksPluginLinux"); + app_links_plugin_linux_register_with_registrar(app_links_linux_registrar); + g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin"); + audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar); g_autoptr(FlPluginRegistrar) file_saver_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSaverPlugin"); file_saver_plugin_register_with_registrar(file_saver_registrar); g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); - g_autoptr(FlPluginRegistrar) gtk_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); - gtk_plugin_register_with_registrar(gtk_registrar); + g_autoptr(FlPluginRegistrar) record_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin"); + record_linux_plugin_register_with_registrar(record_linux_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/example/linux/flutter/generated_plugins.cmake b/example/linux/flutter/generated_plugins.cmake index 78ece0a..3d6bdc2 100644 --- a/example/linux/flutter/generated_plugins.cmake +++ b/example/linux/flutter/generated_plugins.cmake @@ -3,13 +3,16 @@ # list(APPEND FLUTTER_PLUGIN_LIST + app_links_linux + audioplayers_linux file_saver file_selector_linux - gtk + record_linux url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/example/macos/Runner/DebugProfile.entitlements b/example/macos/Runner/DebugProfile.entitlements index 3ba6c12..df5a4c3 100644 --- a/example/macos/Runner/DebugProfile.entitlements +++ b/example/macos/Runner/DebugProfile.entitlements @@ -10,5 +10,7 @@ com.apple.security.network.server + com.apple.security.device.audio-input + diff --git a/example/macos/Runner/Info.plist b/example/macos/Runner/Info.plist index 4789daa..956740a 100644 --- a/example/macos/Runner/Info.plist +++ b/example/macos/Runner/Info.plist @@ -28,5 +28,7 @@ MainMenu NSPrincipalClass NSApplication + NSMicrophoneUsageDescription + This app uses the microphone to record audio messages. diff --git a/example/macos/Runner/Release.entitlements b/example/macos/Runner/Release.entitlements index 852fa1a..f29279b 100644 --- a/example/macos/Runner/Release.entitlements +++ b/example/macos/Runner/Release.entitlements @@ -4,5 +4,7 @@ com.apple.security.app-sandbox + com.apple.security.device.audio-input + diff --git a/example/pubspec.yaml b/example/pubspec.yaml index cd0bbe0..6de861d 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,37 +1,48 @@ name: example -description: A new Flutter project. +description: > + Example app for flutter_supabase_chat_core: direct and group chats, + attachments, voice messages, reactions, read receipts, typing indicators + and online status on Supabase, with the Flyer Chat UI. publish_to: 'none' -version: 1.6.2 +version: 2.0.0 environment: - sdk: '>=3.4.0 <4.0.0' + sdk: '>=3.8.0 <4.0.0' dependencies: + audioplayers: ^6.8.1 cupertino_icons: ^1.0.8 - dio: ^5.8.0+1 faker: ^2.2.0 - file_picker: ^10.2.0 - file_saver: ^0.2.14 + file_picker: ^12.1.0 + file_saver: ^0.4.0 flutter: sdk: flutter - flutter_chat_types: ^3.6.2 - flutter_chat_ui: ^1.6.15 - flutter_login: ^5.0.0 + flutter_chat_core: ^2.9.0 + # 2.12.0 (released 2026-09-12) swapped Composer's TextField to an + # experimental `material_ui` package with its own Material widget, which + # doesn't satisfy a standard Scaffold's Material ancestor -- crashes with + # "No Material widget found." Pinned below it until that's sorted out + # upstream. + flutter_chat_ui: '>=2.11.1 <2.12.0' flutter_supabase_chat_core: path: ../ - flutter_svg: ^2.2.0 + flyer_chat_file_message: ^2.4.0 + flyer_chat_image_message: ^2.3.0 + flyer_chat_text_message: ^2.6.0 http: ^1.4.0 image_picker: ^1.1.2 - infinite_scroll_pagination: ^4.1.0 open_filex: ^4.7.0 path_provider: ^2.1.5 + # Reads the callbacks `Chat` provides to its composer (see ChatComposer). + provider: ^6.1.5 + record: ^7.1.1 supabase_flutter: ^2.9.1 - timeago: ^3.7.1 + uuid: ^4.5.1 dev_dependencies: - flutter_lints: ^5.0.0 + flutter_lints: ^6.0.0 flutter: uses-material-design: true diff --git a/example/utils/migrate_storage_uuids.ps1 b/example/utils/migrate_storage_uuids.ps1 new file mode 100644 index 0000000..372c2f0 --- /dev/null +++ b/example/utils/migrate_storage_uuids.ps1 @@ -0,0 +1,198 @@ +# Moves the chat attachments uploaded by v1 to the v2 room folders, after +# 01_database_schema.sql migrated a v1 database: +# chats_assets// -> chats_assets// +# +# .\migrate_storage_uuids.ps1 -hostname -port -database -user ` +# -apiUrl [-bucket ] [-dryRun] [-yes] +# +# -apiUrl Supabase API URL: https://.supabase.co, or the Kong +# URL of a self-hosted instance (local stack: http://127.0.0.1:54321) +# -bucket attachments bucket (default chats_assets) +# -dryRun only print what would be moved +# -yes drop chats.room_id_migration_map at the end without asking +# +# Reads the database password from $env:PGPASSWORD and the service role (or +# secret) key from $env:SUPABASE_SERVICE_ROLE_KEY, asking for them when unset. +# +# For each room still using its old folder it: +# 1. copies the objects to the new folder (Storage API, server side); +# 2. rewrites the URLs of the room in one transaction: messages.source and +# rooms."imageUrl" (the "lastMessages" snapshot follows by trigger); +# 3. deletes the old objects, only once every copy exists. +# A failure stops the script and nothing is lost: run it again, it resumes +# from where it stopped. Until a room is moved its old URLs keep working +# (the storage policies resolve the old folders through +# chats.room_id_migration_map). When no room is left, the map is no longer +# needed and is dropped (asking first, unless -yes). +# +# Requires: psql. +param( + [Parameter(Mandatory = $true)][string]$hostname, + [Parameter(Mandatory = $true)][int]$port, + [Parameter(Mandatory = $true)][string]$database, + [Parameter(Mandatory = $true)][string]$user, + [Parameter(Mandatory = $true)][string]$apiUrl, + [string]$bucket = 'chats_assets', + [switch]$dryRun, + [switch]$yes +) +$ErrorActionPreference = 'Stop' + +if (-not (Get-Command psql -ErrorAction SilentlyContinue)) { + Write-Error "psql was not found. Please ensure PostgreSQL is installed and that psql is in your PATH." + exit 1 +} +if ($bucket -notmatch '^[A-Za-z0-9_.-]+$') { + Write-Error "invalid bucket name: $bucket" + exit 1 +} +$apiUrl = $apiUrl.TrimEnd('/') + +function Read-Secret([string]$prompt) { + $secure = Read-Host $prompt -AsSecureString + return [Runtime.InteropServices.Marshal]::PtrToStringAuto( + [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)) +} +if (-not $env:PGPASSWORD) { $env:PGPASSWORD = Read-Secret "Password for $user@$hostname" } +$serviceKey = $env:SUPABASE_SERVICE_ROLE_KEY +if (-not $serviceKey) { $serviceKey = Read-Secret 'Service role (or secret) key' } + +# File names travel through psql's output: keep them UTF-8 end to end. +$env:PGCLIENTENCODING = 'UTF8' +[Console]::OutputEncoding = [Text.Encoding]::UTF8 +$OutputEncoding = [Text.Encoding]::UTF8 + +# Runs a query and returns its output lines (unaligned, tuples only). +function Invoke-Sql([string]$query) { + $lines = $query | & psql -h $hostname -p $port -d $database -U $user -v ON_ERROR_STOP=1 -X -q -At + if ($LASTEXITCODE -ne 0) { throw "psql failed (exit code $LASTEXITCODE)" } + return @($lines | Where-Object { $_ -ne '' }) +} + +# Legacy JWT keys go in Authorization too; the new sb_secret_ keys only in +# apikey (the gateway turns them into a service role JWT). +$headers = @{ apikey = $serviceKey } +if ($serviceKey.StartsWith('eyJ')) { $headers.Authorization = "Bearer $serviceKey" } + +# POST/DELETE a JSON body to the Storage API, failing with its error message. +function Invoke-Storage([string]$method, [string]$path, [string]$body) { + try { + Invoke-RestMethod -Method $method -Uri "$apiUrl/storage/v1/$path" -Headers $headers ` + -ContentType 'application/json; charset=utf-8' -Body ([Text.Encoding]::UTF8.GetBytes($body)) | Out-Null + } + catch { + throw "Storage API $method $path failed: $($_.Exception.Message) $($_.ErrorDetails.Message)" + } +} + +if ((Invoke-Sql "SELECT to_regclass('chats.room_id_migration_map') IS NOT NULL") -ne 't') { + Write-Host "chats.room_id_migration_map doesn't exist: no v1 room to move." + exit 0 +} + +# Rooms with objects in their old folder, or URLs still pointing there. +$rooms = Invoke-Sql @" +SELECT m.old_id || ' ' || m.new_id +FROM chats.room_id_migration_map m +WHERE EXISTS (SELECT 1 FROM storage.objects o + WHERE o.bucket_id = '$bucket' AND o.name LIKE m.old_id || '/%') + OR EXISTS (SELECT 1 FROM chats.messages x + WHERE x."roomId" = m.new_id AND x.source LIKE '%/$bucket/' || m.old_id || '/%') + OR EXISTS (SELECT 1 FROM chats.rooms r + WHERE r.id = m.new_id AND r."imageUrl" LIKE '%/$bucket/' || m.old_id || '/%') +ORDER BY m.old_id; +"@ + +$roomCount = 0 +$objectCount = 0 +foreach ($room in $rooms) { + $oldId, $newId = $room -split ' ' + # Both come from the map (bigint, uuid): checked anyway, since they are + # spliced into the SQL below. + if ($oldId -notmatch '^[0-9]+$' -or $newId -notmatch '^[0-9a-f-]{36}$') { + throw "unexpected map row: $room" + } + $oldPrefix = "$oldId/" + $newPrefix = "$newId/" + $from = $oldPrefix.Length + 1 + + # Objects of the old folder not copied yet (a previous run may have + # stopped half way), as Storage API copy requests: Postgres builds the + # JSON, so any file name is escaped correctly. + $copies = Invoke-Sql @" +SELECT json_build_object('bucketId', o.bucket_id, 'sourceKey', o.name, + 'destinationKey', '$newPrefix' || substr(o.name, $from)) +FROM storage.objects o +WHERE o.bucket_id = '$bucket' AND o.name LIKE '$oldPrefix%' + AND NOT EXISTS (SELECT 1 FROM storage.objects d + WHERE d.bucket_id = '$bucket' + AND d.name = '$newPrefix' || substr(o.name, $from)) +ORDER BY o.name; +"@ + Write-Host "room $oldId -> ${newId}: $($copies.Count) object(s) to copy" + $roomCount++ + $objectCount += $copies.Count + if ($dryRun) { continue } + + # 1. Copy. + foreach ($body in $copies) { Invoke-Storage 'POST' 'object/copy' $body } + + # 2. Rewrite the URLs of the room, all or nothing. + Invoke-Sql @" +BEGIN; +UPDATE chats.messages +SET source = replace(source, '/$bucket/$oldPrefix', '/$bucket/$newPrefix') +WHERE "roomId" = '$newId' AND source LIKE '%/$bucket/$oldPrefix%'; +UPDATE chats.rooms +SET "imageUrl" = replace("imageUrl", '/$bucket/$oldPrefix', '/$bucket/$newPrefix') +WHERE id = '$newId' AND "imageUrl" LIKE '%/$bucket/$oldPrefix%'; +COMMIT; +"@ | Out-Null + + # 3. Delete the old objects, once every one of them has its copy. + $missing = Invoke-Sql @" +SELECT count(*) FROM storage.objects o +WHERE o.bucket_id = '$bucket' AND o.name LIKE '$oldPrefix%' + AND NOT EXISTS (SELECT 1 FROM storage.objects d + WHERE d.bucket_id = '$bucket' + AND d.name = '$newPrefix' || substr(o.name, $from)); +"@ + if ($missing -ne '0') { throw "room ${oldId}: $missing object(s) without a copy, old folder kept" } + # The Storage API deletes at most 1000 objects per request. + $deletes = Invoke-Sql @" +SELECT json_build_object('prefixes', json_agg(name ORDER BY name)) +FROM (SELECT name, (row_number() OVER (ORDER BY name) - 1) / 1000 AS batch + FROM storage.objects + WHERE bucket_id = '$bucket' AND name LIKE '$oldPrefix%') o +GROUP BY batch ORDER BY batch; +"@ + foreach ($body in $deletes) { Invoke-Storage 'DELETE' "object/$bucket" $body } +} + +if ($dryRun) { + Write-Host "dry run: $roomCount room(s), $objectCount object(s) to copy" + exit 0 +} +Write-Host "moved $roomCount room(s), $objectCount object(s) copied in this run" + +# Nothing points at the old folders any more: the map is no longer needed. +$left = Invoke-Sql @" +SELECT count(*) FROM chats.room_id_migration_map m +WHERE EXISTS (SELECT 1 FROM storage.objects o + WHERE o.bucket_id = '$bucket' AND o.name LIKE m.old_id || '/%') + OR EXISTS (SELECT 1 FROM chats.messages x + WHERE x."roomId" = m.new_id AND x.source LIKE '%/$bucket/' || m.old_id || '/%'); +"@ +if ($left -ne '0') { + Write-Error "$left room(s) still use their old folder: chats.room_id_migration_map kept" + exit 1 +} +if (-not $yes) { + $answer = Read-Host 'Every attachment is in its v2 folder. Drop chats.room_id_migration_map? [y/N]' + if ($answer -notmatch '^[Yy]$') { + Write-Host 'chats.room_id_migration_map kept' + exit 0 + } +} +Invoke-Sql 'DROP TABLE chats.room_id_migration_map' | Out-Null +Write-Host 'chats.room_id_migration_map dropped' diff --git a/example/utils/migrate_storage_uuids.sh b/example/utils/migrate_storage_uuids.sh new file mode 100755 index 0000000..66e3232 --- /dev/null +++ b/example/utils/migrate_storage_uuids.sh @@ -0,0 +1,218 @@ +#!/bin/bash +# Moves the chat attachments uploaded by v1 to the v2 room folders, after +# 01_database_schema.sql migrated a v1 database: +# chats_assets// -> chats_assets// +# +# ./migrate_storage_uuids.sh -h -p -d -U \ +# -a [-b ] [-n] [-y] +# +# -a Supabase API URL: https://.supabase.co, or the Kong URL +# of a self-hosted instance (local stack: http://127.0.0.1:54321) +# -b attachments bucket (default chats_assets) +# -n dry run: only print what would be moved +# -y drop chats.room_id_migration_map at the end without asking +# +# Reads the database password from PGPASSWORD and the service role (or +# secret) key from SUPABASE_SERVICE_ROLE_KEY, asking for them when unset. +# +# For each room still using its old folder it: +# 1. copies the objects to the new folder (Storage API, server side); +# 2. rewrites the URLs of the room in one transaction: messages.source and +# rooms."imageUrl" (the "lastMessages" snapshot follows by trigger); +# 3. deletes the old objects, only once every copy exists. +# A failure stops the script and nothing is lost: run it again, it resumes +# from where it stopped. Until a room is moved its old URLs keep working +# (the storage policies resolve the old folders through +# chats.room_id_migration_map). When no room is left, the map is no longer +# needed and is dropped (asking first, unless -y). +# +# Requires: psql and curl. +set -euo pipefail + +BUCKET="chats_assets" +DRY_RUN=0 +ASSUME_YES=0 +while getopts h:p:d:U:a:b:ny flag; do + case "${flag}" in + h) DB_HOST=${OPTARG} ;; + p) DB_PORT=${OPTARG} ;; + d) DB_NAME=${OPTARG} ;; + U) DB_USER=${OPTARG} ;; + a) API_URL=${OPTARG%/} ;; + b) BUCKET=${OPTARG} ;; + n) DRY_RUN=1 ;; + y) ASSUME_YES=1 ;; + *) echo "usage: $0 -h host -p port -d database -U user -a api_url [-b bucket] [-n] [-y]" >&2; exit 1 ;; + esac +done +: "${DB_HOST:?-h host is required}" "${DB_PORT:?-p port is required}" \ + "${DB_NAME:?-d database is required}" "${DB_USER:?-U user is required}" \ + "${API_URL:?-a api url is required}" + +for tool in psql curl; do + command -v "$tool" >/dev/null 2>&1 || { echo "$tool not found: install it and re-run" >&2; exit 1; } +done +[[ "$BUCKET" =~ ^[A-Za-z0-9_.-]+$ ]] || { echo "invalid bucket name: $BUCKET" >&2; exit 1; } + +if [[ -z "${PGPASSWORD:-}" ]]; then + read -rsp "Password for $DB_USER@$DB_HOST: " PGPASSWORD + echo + export PGPASSWORD +fi +if [[ -z "${SUPABASE_SERVICE_ROLE_KEY:-}" ]]; then + read -rsp "Service role (or secret) key: " SUPABASE_SERVICE_ROLE_KEY + echo +fi + +# File names travel through psql's output: keep them UTF-8 whatever the +# locale. Strips the \r of the Windows psql builds (Git Bash), harmless +# elsewhere. +export PGCLIENTENCODING=UTF8 +sql() { + psql -h "$DB_HOST" -p "$DB_PORT" -d "$DB_NAME" -U "$DB_USER" \ + -v ON_ERROR_STOP=1 -X -q -At "$@" | tr -d '\r' +} + +# Legacy JWT keys go in Authorization too; the new sb_secret_ keys only in +# apikey (the gateway turns them into a service role JWT). +AUTH_HEADERS=(-H "apikey: $SUPABASE_SERVICE_ROLE_KEY") +if [[ "$SUPABASE_SERVICE_ROLE_KEY" == eyJ* ]]; then + AUTH_HEADERS+=(-H "Authorization: Bearer $SUPABASE_SERVICE_ROLE_KEY") +fi +RESPONSE="$(mktemp)" +trap 'rm -f "$RESPONSE"' EXIT + +# POST/DELETE a JSON body to the Storage API, failing with its error message. +storage() { + local method=$1 path=$2 body=$3 status + status="$(curl -sS -o "$RESPONSE" -w '%{http_code}' -X "$method" \ + "${AUTH_HEADERS[@]}" -H 'Content-Type: application/json' \ + --data "$body" "$API_URL/storage/v1/$path")" + if [[ "$status" != 2* ]]; then + echo "Storage API $method $path failed (HTTP $status): $(cat "$RESPONSE")" >&2 + return 1 + fi +} + +if [[ "$(sql -c "SELECT to_regclass('chats.room_id_migration_map') IS NOT NULL")" != "t" ]]; then + echo "chats.room_id_migration_map doesn't exist: no v1 room to move." + exit 0 +fi + +# Rooms with objects in their old folder, or URLs still pointing there. +ROOMS="$(sql < $NEW_ID" >&2; exit 1; } + OLD_PREFIX="$OLD_ID/" + NEW_PREFIX="$NEW_ID/" + + # Objects of the old folder not copied yet (a previous run may have + # stopped half way), as Storage API copy requests: Postgres builds the + # JSON, so any file name is escaped correctly. + TO_COPY="$(sql < $NEW_ID: $COPIES object(s) to copy" + ROOM_COUNT=$((ROOM_COUNT + 1)) + OBJECT_COUNT=$((OBJECT_COUNT + COPIES)) + [[ $DRY_RUN == 1 ]] && continue + + # 1. Copy. + while IFS= read -r BODY; do + [[ -z "$BODY" ]] && continue + storage POST object/copy "$BODY" + done <<<"$TO_COPY" + + # 2. Rewrite the URLs of the room, all or nothing. + sql <&2 + exit 1 + fi + # The Storage API deletes at most 1000 objects per request. + DELETES="$(sql <&2 + exit 1 +fi +if [[ $ASSUME_YES == 0 ]]; then + read -rp "Every attachment is in its v2 folder. Drop chats.room_id_migration_map? [y/N] " ANSWER + [[ "$ANSWER" =~ ^[Yy]$ ]] || { echo "chats.room_id_migration_map kept"; exit 0; } +fi +sql -c 'DROP TABLE chats.room_id_migration_map' +echo "chats.room_id_migration_map dropped" diff --git a/example/utils/prepare.ps1 b/example/utils/prepare.ps1 index 9078aae..e24d391 100644 --- a/example/utils/prepare.ps1 +++ b/example/utils/prepare.ps1 @@ -1,23 +1,37 @@ +# Applies the chat schema to a Supabase/Postgres database: +# .\prepare.ps1 -hostname -port -database -user +# Asks for the database password once (or reads $env:PGPASSWORD), and stops at +# the first failing script so the database is never left half prepared. Every +# script is idempotent: re-running is safe, also to migrate a v1 database. param( - [string]$hostname, - [int]$port, - [string]$database, - [string]$user + [Parameter(Mandatory = $true)][string]$hostname, + [Parameter(Mandatory = $true)][int]$port, + [Parameter(Mandatory = $true)][string]$database, + [Parameter(Mandatory = $true)][string]$user ) -try { - Get-Command psql.exe -ErrorAction Stop -} -catch { +if (-not (Get-Command psql -ErrorAction SilentlyContinue)) { Write-Error "psql was not found. Please ensure PostgreSQL is installed and that psql is in your PATH." - exit + exit 1 } -$psqlCommand = "psql -U $user -h $hostname -p $port -d $database" +if (-not $env:PGPASSWORD) { + $secure = Read-Host "Password for $user@$hostname" -AsSecureString + $env:PGPASSWORD = [Runtime.InteropServices.Marshal]::PtrToStringAuto( + [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)) +} -Invoke-Expression "$psqlCommand -f .\sql\01_database_schema.sql" -Invoke-Expression "$psqlCommand -f .\sql\02_database_trigger.sql" -Invoke-Expression "$psqlCommand -f .\sql\03_database_policy.sql" -Invoke-Expression "$psqlCommand -f .\sql\04_storage.sql" -Invoke-Expression "$psqlCommand -f .\sql\05_database_view.sql" -Invoke-Expression "$psqlCommand -f .\sql\99_database_schema_permission.sql" +$sqlDir = Join-Path $PSScriptRoot 'sql' +$files = @( + '01_database_schema.sql', '02_database_trigger.sql', '03_database_policy.sql', + '04_storage.sql', '05_database_view.sql', '99_database_schema_permission.sql' +) +foreach ($file in $files) { + Write-Host "applying $file" + & psql -U $user -h $hostname -p $port -d $database -v ON_ERROR_STOP=1 -q -f (Join-Path $sqlDir $file) + if ($LASTEXITCODE -ne 0) { + Write-Error "$file failed (exit code $LASTEXITCODE): the remaining scripts were not applied." + exit $LASTEXITCODE + } +} +Write-Host "done" diff --git a/example/utils/prepare.sh b/example/utils/prepare.sh index fec3c50..0b57a6d 100644 --- a/example/utils/prepare.sh +++ b/example/utils/prepare.sh @@ -1,4 +1,10 @@ #!/bin/bash +# Applies the chat schema to a Supabase/Postgres database: +# ./prepare.sh -h -p -d -U +# Asks for the database password once (or reads PGPASSWORD), and stops at the +# first failing script so the database is never left half prepared. Every +# script is idempotent: re-running is safe, also to migrate a v1 database. +set -euo pipefail while getopts h:p:d:U: flag do @@ -7,12 +13,28 @@ do p) port=${OPTARG};; d) database=${OPTARG};; U) user=${OPTARG};; + *) echo "usage: $0 -h host -p port -d database -U user" >&2; exit 1;; esac done +: "${hostname:?-h host is required}" "${port:?-p port is required}" \ + "${database:?-d database is required}" "${user:?-U user is required}" -psql -U $user -h $hostname -p $port -d $database -f ./sql/01_database_schema.sql -psql -U $user -h $hostname -p $port -d $database -f ./sql/02_database_trigger.sql -psql -U $user -h $hostname -p $port -d $database -f ./sql/03_database_policy.sql -psql -U $user -h $hostname -p $port -d $database -f ./sql/04_storage.sql -psql -U $user -h $hostname -p $port -d $database -f ./sql/05_database_view.sql -psql -U $user -h $hostname -p $port -d $database -f ./sql/99_database_schema_permission.sql \ No newline at end of file +command -v psql >/dev/null 2>&1 || { + echo "psql not found: install PostgreSQL client tools (https://www.postgresql.org/download/)" >&2 + exit 1 +} + +if [[ -z "${PGPASSWORD:-}" ]]; then + read -rsp "Password for $user@$hostname: " PGPASSWORD + echo + export PGPASSWORD +fi + +SQL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/sql" && pwd)" +for f in 01_database_schema.sql 02_database_trigger.sql 03_database_policy.sql \ + 04_storage.sql 05_database_view.sql 99_database_schema_permission.sql; do + echo "applying $f" + psql -U "$user" -h "$hostname" -p "$port" -d "$database" \ + -v ON_ERROR_STOP=1 -q -f "$SQL_DIR/$f" +done +echo "done" diff --git a/example/utils/sql/01_database_schema.sql b/example/utils/sql/01_database_schema.sql index 63ab63c..c7e48fe 100644 --- a/example/utils/sql/01_database_schema.sql +++ b/example/utils/sql/01_database_schema.sql @@ -9,114 +9,722 @@ SET xmloption = content; SET client_min_messages = warning; SET row_security = off; -CREATE SCHEMA chats; - +-- ============================================================================ +-- chats schema setup (v2, flutter_chat_core based) +-- +-- This script is idempotent and self-detecting: it bootstraps a fresh +-- database, or migrates the original v1 (flutter_chat_types) schema to v2, +-- from the same file. Re-running it on an already up-to-date database is a +-- no-op. Adjust the "chats" schema name below if you used a custom +-- SupabaseChatCoreConfig.schema. +-- +-- Only two prior shapes are recognized: no chats.rooms at all (fresh +-- install), or the exact v1 shape this package ever shipped (bigint ids, +-- epoch-ms bigint dates, chats.rooms."userIds" uuid[]/"userRoles" jsonb, +-- chats.messages' old flutter_chat_types columns). If your database is +-- instead in some other, partially-migrated shape (e.g. from an earlier +-- draft of this script), reconcile or drop+recreate the chats schema +-- yourself before running this — it is not handled here. +-- +-- v2 shape: +-- - chats.rooms.id / chats.messages.id / chats.messages."roomId": uuid, +-- default chats.uuid_generate_v7() (time-ordered — see below). Either +-- generated by the database or supplied by the client (e.g. for +-- optimistic UI: SupabaseChatController.sendMessage inserts locally with +-- a client-generated id, which Realtime then echoes back unchanged). +-- chats.users.id is untouched: it must keep matching auth.users.id. +-- - "createdAt"/"updatedAt"/"lastSeen" are timestamptz, not epoch-ms +-- bigint. +-- - Room membership lives in chats.room_members (roomId, userId FKs), +-- not a chats.rooms."userIds" uuid[]/"userRoles" jsonb pair. A room is +-- deleted automatically once its last member is removed. "role" is +-- constrained by a CHECK constraint (not a native enum: adding an +-- allowed value later is a plain DROP+ADD CONSTRAINT, no ALTER TYPE). +-- - chats.messages is a native RANGE partition on "createdAt", managed by +-- pg_partman with weekly partitions and no automatic retention (chat +-- history is kept forever — partitioning is purely for keeping +-- indexes/vacuum/queries scalable as history grows). +-- - Deleting a message removes the row immediately; if it referenced a +-- chats_assets storage object, the object's path is recorded in +-- chats.media_pending_deletion for a separate cleanup job (a Deno Edge +-- Function, see supabase/functions/cleanup-media) to remove later — SQL +-- alone can't delete objects from the storage backend. +-- +-- A v1 migration renames each old table to a `..._backup_` table +-- and keeps it (never drops it) rather than rebuilding in place; verify row +-- counts, then drop them yourself once satisfied (oldest-dependency-first: +-- messages_backup_* before rooms_backup_*/users_backup_*, since the old +-- messages_roomId_fkey/messages_authorId_fkey constraints still point at +-- them). See MIGRATION.md for the full writeup. Attachments uploaded by v1 +-- keep their URLs (copied unchanged into chats.messages.source), which keep +-- working: the storage policies also accept the old bigint room folders. +-- example/utils/migrate_storage_uuids.sh/.ps1 then moves them to the uuid +-- folders and drops chats.room_id_migration_map. +-- +-- Run a migration during a maintenance window: the app must be compatible +-- with the resulting shape before it runs against this database again. +-- +-- Requires the pg_partman and pg_cron extensions available (both ship in +-- Supabase's self-hosted Postgres image). pg_cron jobs run against the +-- database it's installed in — on Supabase self-hosted that's normally the +-- `postgres` database already, no extra setup needed there. +-- ============================================================================ + +CREATE SCHEMA IF NOT EXISTS chats; ALTER SCHEMA chats OWNER TO postgres; SET default_tablespace = ''; - SET default_table_access_method = heap; -CREATE TABLE chats.messages ( - id bigint NOT NULL, - "createdAt" bigint, - metadata jsonb, - duration bigint, - "mimeType" text, - name text, - "remoteId" text, - "repliedMessage" jsonb, - "roomId" bigint NOT NULL, - "showStatus" boolean, - size bigint, - status text, - type text, - "updatedAt" bigint, - uri text, - "waveForm" jsonb, - "isLoading" boolean, - height double precision, - width double precision, - "previewData" jsonb, - "authorId" uuid NOT NULL, - text text -); - -ALTER TABLE chats.messages OWNER TO postgres; - -ALTER TABLE chats.messages ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( - SEQUENCE NAME chats.messages_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1 -); - -CREATE TABLE chats.rooms ( - id bigint NOT NULL, - "imageUrl" text, - metadata jsonb, - name text, - type text, - "userIds" uuid[] NOT NULL, - "lastMessages" jsonb, - "userRoles" jsonb, - "createdAt" bigint NOT NULL, - "updatedAt" bigint NOT NULL -); - -ALTER TABLE chats.rooms OWNER TO postgres; - -ALTER TABLE chats.rooms ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( - SEQUENCE NAME chats.rooms_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1 -); - -CREATE TABLE chats.users ( - "firstName" text, - "imageUrl" text, - "lastName" text, - metadata jsonb, - role text, - id uuid NOT NULL, - "createdAt" bigint NOT NULL, - "updatedAt" bigint NOT NULL, - "lastSeen" bigint NOT NULL -); - -ALTER TABLE chats.users OWNER TO postgres; - -ALTER TABLE ONLY chats.messages - ADD CONSTRAINT messages_pkey PRIMARY KEY (id); - -ALTER TABLE ONLY chats.rooms - ADD CONSTRAINT rooms_pkey PRIMARY KEY (id); - -ALTER TABLE ONLY chats.users - ADD CONSTRAINT users_pkey PRIMARY KEY (id); - -ALTER TABLE ONLY chats.messages - ADD CONSTRAINT "messages_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES auth.users(id) ON DELETE CASCADE; - -ALTER TABLE ONLY chats.messages - ADD CONSTRAINT "messages_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES chats.rooms(id) ON UPDATE CASCADE ON DELETE CASCADE; - -ALTER TABLE ONLY chats.users - ADD CONSTRAINT users_id_fkey FOREIGN KEY (id) REFERENCES auth.users(id) ON UPDATE CASCADE ON DELETE CASCADE; - -CREATE INDEX ON "chats"."messages" USING btree ("authorId"); -CREATE INDEX ON "chats"."messages" USING btree ("roomId"); - -ALTER TABLE chats.messages ENABLE ROW LEVEL SECURITY; - -ALTER TABLE chats.rooms ENABLE ROW LEVEL SECURITY; - -ALTER TABLE chats.users ENABLE ROW LEVEL SECURITY; +-- pg_partman gets its own schema so its catalog/maintenance tables aren't +-- exposed to anon/authenticated via the blanket `GRANT ALL ... IN SCHEMA +-- chats` below. `CREATE EXTENSION ... SCHEMA partman` does NOT create that +-- schema for you -- it must already exist, or the extension creation fails. +CREATE SCHEMA IF NOT EXISTS partman; +CREATE EXTENSION IF NOT EXISTS pg_partman SCHEMA partman; +CREATE EXTENSION IF NOT EXISTS pg_cron; + +-- Time-ordered UUIDs (RFC 9562 UUIDv7): the id generator for chats.rooms and +-- chats.messages instead of a fully random UUIDv4, so ids stay roughly +-- sequential (like the old bigint identities) and don't fragment each +-- partition's primary key index the way random UUIDs would. Native +-- uuidv7() ships starting with Postgres 18; this works on any version +-- Supabase currently ships, no extra extension required. +CREATE OR REPLACE FUNCTION chats.uuid_generate_v7() +RETURNS uuid +LANGUAGE sql +VOLATILE +SET search_path = '' +AS $$ + SELECT encode( + set_bit( + set_bit( + overlay(uuid_send(gen_random_uuid()) placing + substring(int8send(floor(extract(epoch FROM clock_timestamp()) * 1000)::bigint) from 3) + from 1 for 6 + ), + 52, 1 + ), + 53, 1 + ), + 'hex' + )::uuid; +$$; + +-- Enabling row level security on a partitioned table only recurses to the +-- partitions that already exist at that moment -- it does NOT retroactively +-- apply to partitions created afterwards (unlike indexes and row-level +-- triggers, which Postgres does propagate automatically). pg_partman keeps +-- creating new chats.messages partitions on its own schedule, so this is +-- called both right after creating partitions and from the hourly pg_cron +-- maintenance job below, to catch any partition RLS wasn't enabled on yet. +CREATE OR REPLACE FUNCTION chats.ensure_messages_partitions_rls() +RETURNS void +LANGUAGE plpgsql +SET search_path = '' +AS $$ +DECLARE + r record; +BEGIN + FOR r IN + SELECT c.oid::regclass AS partition + FROM pg_inherits i + JOIN pg_class c ON c.oid = i.inhrelid + WHERE i.inhparent = 'chats.messages'::regclass + AND NOT c.relrowsecurity + LOOP + EXECUTE format('ALTER TABLE %s ENABLE ROW LEVEL SECURITY', r.partition); + END LOOP; + -- Partitions are only ever reached through chats.messages (and its + -- policies): drop the direct grants default privileges give them. + FOR r IN + SELECT c.oid::regclass AS partition + FROM pg_inherits i + JOIN pg_class c ON c.oid = i.inhrelid + WHERE i.inhparent = 'chats.messages'::regclass + AND (has_table_privilege('anon', c.oid, 'SELECT') + OR has_table_privilege('authenticated', c.oid, 'SELECT')) + LOOP + EXECUTE format('REVOKE ALL ON TABLE %s FROM anon, authenticated', r.partition); + END LOOP; +END; +$$; + +DO $do$ +DECLARE + v_ts text := extract(epoch FROM clock_timestamp())::bigint::text; + v_is_fresh boolean; + v_is_v1 boolean := false; + v_start_ts timestamptz; + v_rooms_backup text; + v_users_backup text; + v_messages_backup text; + v_needs_partman boolean := false; +BEGIN + SELECT NOT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'chats' AND table_name = 'rooms' + ) INTO v_is_fresh; + + IF NOT v_is_fresh THEN + SELECT (data_type <> 'uuid') INTO v_is_v1 + FROM information_schema.columns + WHERE table_schema = 'chats' AND table_name = 'rooms' AND column_name = 'id'; + END IF; + + -- ======================================================================== + -- chats.users / chats.rooms + -- ======================================================================== + IF v_is_fresh THEN + RAISE NOTICE 'Fresh install: creating chats.users and chats.rooms directly in v2 shape'; + + CREATE TABLE chats.users ( + "firstName" text, + "imageUrl" text, + "lastName" text, + metadata jsonb, + role text, + id uuid NOT NULL, + "createdAt" timestamptz NOT NULL DEFAULT now(), + "updatedAt" timestamptz NOT NULL DEFAULT now(), + "lastSeen" timestamptz NOT NULL DEFAULT now() + ); + ALTER TABLE chats.users OWNER TO postgres; + ALTER TABLE ONLY chats.users ADD CONSTRAINT users_pkey PRIMARY KEY (id); + ALTER TABLE ONLY chats.users + ADD CONSTRAINT users_id_fkey FOREIGN KEY (id) REFERENCES auth.users(id) ON UPDATE CASCADE ON DELETE CASCADE; + ALTER TABLE chats.users ENABLE ROW LEVEL SECURITY; + ALTER PUBLICATION supabase_realtime ADD TABLE ONLY chats.users; + + CREATE TABLE chats.rooms ( + id uuid NOT NULL DEFAULT chats.uuid_generate_v7(), + "imageUrl" text, + metadata jsonb, + name text, + type text, + "lastMessages" jsonb, + "createdAt" timestamptz NOT NULL DEFAULT now(), + "updatedAt" timestamptz NOT NULL DEFAULT now() + ); + ALTER TABLE chats.rooms OWNER TO postgres; + ALTER TABLE ONLY chats.rooms ADD CONSTRAINT rooms_pkey PRIMARY KEY (id); + ALTER TABLE chats.rooms ENABLE ROW LEVEL SECURITY; + ALTER PUBLICATION supabase_realtime ADD TABLE ONLY chats.rooms; + + ELSIF v_is_v1 THEN + RAISE NOTICE 'v1 schema detected (chats.rooms.id is bigint): migrating users and rooms to v2 shape'; + + -- Maps each old bigint room id to its freshly generated uuid, built + -- before anything is renamed away. Reused below for room_members and + -- chats.messages."roomId". Kept around (not temporary) after this + -- script finishes: the storage policies use it to authorize the v1 + -- attachments still stored under `chats_assets//...`, + -- until migrate_storage_uuids.sh/.ps1 moves them and drops it. + CREATE TABLE IF NOT EXISTS chats.room_id_migration_map ( + old_id bigint PRIMARY KEY, + new_id uuid NOT NULL + ); + INSERT INTO chats.room_id_migration_map (old_id, new_id) + SELECT id, chats.uuid_generate_v7() FROM chats.rooms; + + -- --- chats.users: rename away, recreate in final shape, copy back --- + -- Done BEFORE chats.room_members is created below: room_members has + -- a real FK to chats.users(id), and Postgres binds foreign keys to + -- the referenced table by OID, not by name. Creating room_members + -- against a chats.users that's about to be renamed away would leave + -- its FK permanently pointing at the renamed backup table. + v_users_backup := 'users_backup_' || v_ts; + EXECUTE format('ALTER TABLE chats.users RENAME TO %I', v_users_backup); + -- RENAME TABLE doesn't rename its constraints/indexes -- without + -- this, the new chats.users below can't reuse the "users_pkey" name + -- (index/constraint names are unique per schema, not per table). + EXECUTE format('ALTER TABLE chats.%I RENAME CONSTRAINT users_pkey TO %I', v_users_backup, v_users_backup || '_pkey'); + + CREATE TABLE chats.users ( + "firstName" text, + "imageUrl" text, + "lastName" text, + metadata jsonb, + role text, + id uuid NOT NULL, + "createdAt" timestamptz NOT NULL DEFAULT now(), + "updatedAt" timestamptz NOT NULL DEFAULT now(), + "lastSeen" timestamptz NOT NULL DEFAULT now() + ); + ALTER TABLE chats.users OWNER TO postgres; + ALTER TABLE ONLY chats.users ADD CONSTRAINT users_pkey PRIMARY KEY (id); + ALTER TABLE ONLY chats.users + ADD CONSTRAINT users_id_fkey FOREIGN KEY (id) REFERENCES auth.users(id) ON UPDATE CASCADE ON DELETE CASCADE; + ALTER TABLE chats.users ENABLE ROW LEVEL SECURITY; + + EXECUTE format($f$ + INSERT INTO chats.users ("firstName", "imageUrl", "lastName", metadata, role, id, "createdAt", "updatedAt", "lastSeen") + -- v1 accepted any role text ('agent', 'user', ...): only the + -- elevated roles survive, everything else is a plain member. + SELECT "firstName", "imageUrl", "lastName", metadata, + CASE WHEN role IN ('admin', 'moderator') THEN role END, id, + to_timestamp("createdAt" / 1000.0), to_timestamp("updatedAt" / 1000.0), to_timestamp("lastSeen" / 1000.0) + FROM chats.%I + $f$, v_users_backup); + + -- Publication membership follows the rename onto the backup table + -- (tracked by relation, not by name) -- move it back onto the new + -- chats.users. + IF EXISTS ( + SELECT 1 FROM pg_publication_tables + WHERE pubname = 'supabase_realtime' AND schemaname = 'chats' AND tablename = v_users_backup + ) THEN + EXECUTE format('ALTER PUBLICATION supabase_realtime DROP TABLE chats.%I', v_users_backup); + END IF; + ALTER PUBLICATION supabase_realtime ADD TABLE ONLY chats.users; + + RAISE NOTICE 'chats.% kept as a backup — verify row counts match, then drop it yourself once satisfied.', v_users_backup; + + -- --- chats.rooms: rename away, recreate in final shape, copy back --- + v_rooms_backup := 'rooms_backup_' || v_ts; + EXECUTE format('ALTER TABLE chats.rooms RENAME TO %I', v_rooms_backup); + EXECUTE format('ALTER TABLE chats.%I RENAME CONSTRAINT rooms_pkey TO %I', v_rooms_backup, v_rooms_backup || '_pkey'); + + CREATE TABLE chats.rooms ( + id uuid NOT NULL DEFAULT chats.uuid_generate_v7(), + "imageUrl" text, + metadata jsonb, + name text, + type text, + "lastMessages" jsonb, + "createdAt" timestamptz NOT NULL DEFAULT now(), + "updatedAt" timestamptz NOT NULL DEFAULT now() + ); + ALTER TABLE chats.rooms OWNER TO postgres; + ALTER TABLE ONLY chats.rooms ADD CONSTRAINT rooms_pkey PRIMARY KEY (id); + ALTER TABLE chats.rooms ENABLE ROW LEVEL SECURITY; + + EXECUTE format($f$ + INSERT INTO chats.rooms (id, "imageUrl", metadata, name, type, "lastMessages", "createdAt", "updatedAt") + SELECT m.new_id, r."imageUrl", r.metadata, r.name, r.type, r."lastMessages", + to_timestamp(r."createdAt" / 1000.0), to_timestamp(r."updatedAt" / 1000.0) + FROM chats.%I r + JOIN chats.room_id_migration_map m ON m.old_id = r.id + $f$, v_rooms_backup); + + IF EXISTS ( + SELECT 1 FROM pg_publication_tables + WHERE pubname = 'supabase_realtime' AND schemaname = 'chats' AND tablename = v_rooms_backup + ) THEN + EXECUTE format('ALTER PUBLICATION supabase_realtime DROP TABLE chats.%I', v_rooms_backup); + END IF; + ALTER PUBLICATION supabase_realtime ADD TABLE ONLY chats.rooms; + + RAISE NOTICE 'chats.% kept as a backup — verify row counts match, then drop it yourself once satisfied.', v_rooms_backup; + ELSE + RAISE NOTICE 'chats.users/chats.rooms already at v2 shape: nothing to do'; + END IF; + + -- ======================================================================== + -- chats.room_members + -- + -- Only created here: under the two shapes this script recognizes (fresh + -- install, or exact v1), it never already exists beforehand. Created + -- only after chats.users and chats.rooms above have reached their final + -- v2 shape, so its foreign keys always bind to the real tables. + -- ======================================================================== + IF v_is_fresh OR v_is_v1 THEN + RAISE NOTICE 'Creating chats.room_members'; + + CREATE TABLE chats.room_members ( + "roomId" uuid NOT NULL REFERENCES chats.rooms(id) ON DELETE CASCADE, + "userId" uuid NOT NULL REFERENCES chats.users(id) ON DELETE CASCADE, + role text, + "createdAt" timestamptz NOT NULL DEFAULT now(), + "lastReadAt" timestamptz, + "pinnedAt" timestamptz, + "mutedUntil" timestamptz, + PRIMARY KEY ("roomId", "userId") + ); + ALTER TABLE chats.room_members OWNER TO postgres; + ALTER TABLE chats.room_members ENABLE ROW LEVEL SECURITY; + ALTER PUBLICATION supabase_realtime ADD TABLE ONLY chats.room_members; + + IF v_is_v1 THEN + RAISE NOTICE 'Populating chats.room_members from chats.%''s userIds/userRoles', v_rooms_backup; + EXECUTE format($f$ + INSERT INTO chats.room_members ("roomId", "userId", role) + SELECT m.new_id, u, + CASE WHEN r."userRoles" ->> u::text IN ('admin', 'moderator') THEN r."userRoles" ->> u::text END + FROM chats.%I r + JOIN chats.room_id_migration_map m ON m.old_id = r.id, + unnest(r."userIds") AS u + ON CONFLICT ("roomId", "userId") DO NOTHING + $f$, v_rooms_backup); + END IF; + END IF; + + -- Columns/indexes added after the first v2 draft: applied unconditionally + -- (and idempotently) so a database bootstrapped from an earlier v2 draft + -- of this script catches up too, not just fresh installs and v1 + -- migrations. + -- + -- "lastReadAt": when the member last marked the room as read + -- (chats.mark_room_as_read), drives rooms_l."unreadCount". + ALTER TABLE chats.room_members ADD COLUMN IF NOT EXISTS "lastReadAt" timestamptz; + -- Per-member preferences: "pinnedAt" keeps the room on top of the + -- member's list (chats.pin_room), "mutedUntil" silences it until then + -- (chats.mute_room; 'infinity' for ever). Only the member sees them. + ALTER TABLE chats.room_members ADD COLUMN IF NOT EXISTS "pinnedAt" timestamptz; + ALTER TABLE chats.room_members ADD COLUMN IF NOT EXISTS "mutedUntil" timestamptz; + -- Room list / membership lookups by user (the primary key only covers + -- lookups by room). + CREATE INDEX IF NOT EXISTS "room_members_userId_idx" ON chats.room_members USING btree ("userId"); + + -- Constrain role to the values ChatRoomRole knows about (NULL = plain + -- member/user, no elevated role). A CHECK constraint rather than a + -- native enum: adding an allowed value later is a plain DROP+ADD + -- CONSTRAINT, no ALTER TYPE / no risk of a value already committed to + -- production rows before the type change lands. + ALTER TABLE chats.users DROP CONSTRAINT IF EXISTS users_role_check; + ALTER TABLE chats.users ADD CONSTRAINT users_role_check CHECK (role IN ('admin', 'moderator')); + ALTER TABLE chats.room_members DROP CONSTRAINT IF EXISTS room_members_role_check; + ALTER TABLE chats.room_members ADD CONSTRAINT room_members_role_check CHECK (role IN ('admin', 'moderator')); + + -- ======================================================================== + -- chats.messages + -- ======================================================================== + IF v_is_fresh THEN + RAISE NOTICE 'Creating chats.messages (fresh install, partitioned + uuid + timestamptz from the start)'; + + -- Column names match flutter_chat_core's v2 Message model field + -- names directly (source, waveform, linkPreviewData, + -- replyToMessageId, ...). + CREATE TABLE chats.messages ( + id uuid NOT NULL DEFAULT chats.uuid_generate_v7(), + "createdAt" timestamptz NOT NULL DEFAULT now(), + metadata jsonb, + duration bigint, + "mimeType" text, + name text, + "replyToMessageId" text, + "roomId" uuid NOT NULL, + size bigint, + status text, + type text, + "updatedAt" timestamptz, + source text, + waveform jsonb, + height double precision, + width double precision, + "linkPreviewData" jsonb, + "authorId" uuid NOT NULL, + text text, + reactions jsonb, + pinned boolean + ) PARTITION BY RANGE ("createdAt"); + + ALTER TABLE chats.messages OWNER TO postgres; + + -- "id" alone can no longer be the primary key: native partitioning + -- requires the partition key ("createdAt") to be part of every + -- unique/primary key index. Nothing else references messages.id via + -- foreign key, so this is safe. + ALTER TABLE ONLY chats.messages ADD CONSTRAINT messages_pkey PRIMARY KEY (id, "createdAt"); + + -- No "ONLY": Postgres rejects it for a foreign key added on a + -- partitioned table ("cannot use ONLY for foreign key on + -- partitioned table") — the constraint must apply to the whole + -- partition hierarchy anyway. + ALTER TABLE chats.messages + ADD CONSTRAINT "messages_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES auth.users(id) ON DELETE CASCADE; + + ALTER TABLE chats.messages + ADD CONSTRAINT "messages_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES chats.rooms(id) ON UPDATE CASCADE ON DELETE CASCADE; + + CREATE INDEX ON chats.messages USING btree ("authorId"); + + ALTER TABLE chats.messages ENABLE ROW LEVEL SECURITY; + ALTER PUBLICATION supabase_realtime ADD TABLE chats.messages; + + v_needs_partman := true; + + ELSIF v_is_v1 THEN + RAISE NOTICE 'Migrating chats.messages (v1 columns, bigint id, epoch-ms dates) to v2 shape'; + + -- The v1 triggers on this table (last message snapshot, ...) still + -- expect bigint room ids, but chats.rooms is already uuid at this + -- point: any UPDATE below would fire them and fail. Disable them for + -- good -- this table only survives as the messages_backup_* copy, + -- and 02_database_trigger.sql recreates every trigger on the new + -- chats.messages. + ALTER TABLE chats.messages DISABLE TRIGGER USER; + + -- v1 (flutter_chat_types) column renames, done in place before the + -- rebuild below so the copy-back SELECT can just name the v2 columns + -- directly. + ALTER TABLE chats.messages RENAME COLUMN uri TO source; + ALTER TABLE chats.messages RENAME COLUMN "waveForm" TO waveform; + ALTER TABLE chats.messages RENAME COLUMN "previewData" TO "linkPreviewData"; + + ALTER TABLE chats.messages ADD COLUMN IF NOT EXISTS "replyToMessageId" text; + UPDATE chats.messages + SET "replyToMessageId" = "repliedMessage" ->> 'id' + WHERE "repliedMessage" IS NOT NULL + AND "replyToMessageId" IS NULL; + + ALTER TABLE chats.messages ADD COLUMN IF NOT EXISTS reactions jsonb; + ALTER TABLE chats.messages ADD COLUMN IF NOT EXISTS pinned boolean; + + -- "remoteId", "isLoading", "showStatus" and "repliedMessage" have no + -- v2 equivalent and are left in place, unused, on the backup table + -- created below (never on the final chats.messages) — nothing to + -- drop here. + + -- Weekly partitions only from a few weeks back: older history lands + -- in pg_partman's default partition (chats.messages_default) instead + -- of one partition per week since the very first message -- years of + -- history would otherwise mean hundreds of partitions, each one + -- scanned by every per-room query (they filter by room, not by + -- date). Split it later with partman.partition_data_proc() if you + -- want it partitioned too. + SELECT LEAST( + to_timestamp(MIN("createdAt") / 1000.0), + date_trunc('week', now()) - interval '4 weeks' + ) INTO v_start_ts FROM chats.messages; + v_start_ts := GREATEST(v_start_ts, date_trunc('week', now()) - interval '4 weeks'); + + -- Can't ALTER this table into the partitioned/uuid/timestamptz shape + -- in place: rename it out of the way, create the new table, then + -- copy the data back in below. + v_messages_backup := 'messages_backup_' || v_ts; + EXECUTE format('ALTER TABLE chats.messages RENAME TO %I', v_messages_backup); + EXECUTE format('ALTER TABLE chats.%I RENAME CONSTRAINT messages_pkey TO %I', v_messages_backup, v_messages_backup || '_pkey'); + + CREATE TABLE chats.messages ( + id uuid NOT NULL DEFAULT chats.uuid_generate_v7(), + "createdAt" timestamptz NOT NULL DEFAULT now(), + metadata jsonb, + duration bigint, + "mimeType" text, + name text, + "replyToMessageId" text, + "roomId" uuid NOT NULL, + size bigint, + status text, + type text, + "updatedAt" timestamptz, + source text, + waveform jsonb, + height double precision, + width double precision, + "linkPreviewData" jsonb, + "authorId" uuid NOT NULL, + text text, + reactions jsonb, + pinned boolean + ) PARTITION BY RANGE ("createdAt"); + + ALTER TABLE chats.messages OWNER TO postgres; + + ALTER TABLE ONLY chats.messages ADD CONSTRAINT messages_pkey PRIMARY KEY (id, "createdAt"); + + ALTER TABLE chats.messages + ADD CONSTRAINT "messages_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES auth.users(id) ON DELETE CASCADE; + + ALTER TABLE chats.messages + ADD CONSTRAINT "messages_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES chats.rooms(id) ON UPDATE CASCADE ON DELETE CASCADE; + + CREATE INDEX ON chats.messages USING btree ("authorId"); + + ALTER TABLE chats.messages ENABLE ROW LEVEL SECURITY; + + v_needs_partman := true; + ELSE + RAISE NOTICE 'chats.messages already at v2 shape: nothing to do'; + END IF; + + IF v_needs_partman THEN + -- A rebuild (v1 migration) replaces the physical chats.messages + -- table entirely, but pg_partman's own bookkeeping is keyed by table + -- NAME, not by relation -- a stale partman.part_config row from a + -- previous registration would make create_parent() below fail with a + -- duplicate key error otherwise. + DELETE FROM partman.part_config WHERE parent_table = 'chats.messages'; + + -- Register with pg_partman before copying any old data back in, so + -- weekly partitions already exist from the oldest message + -- (p_start_partition) through p_premake weeks into the future. + -- p_default_table (pg_partman's default) keeps a catch-all partition + -- so no insert can ever fail even if a row's createdAt lands outside + -- the generated range. "createdAt" is a native timestamptz now, so + -- no p_epoch is needed (that was only for the old epoch-ms bigint). + PERFORM partman.create_parent( + p_parent_table => 'chats.messages', + p_control => 'createdAt', + p_interval => '7 days', -- weekly; recent pg_partman versions dropped the old 'weekly'/'monthly'/... shorthands in favor of plain Postgres interval literals + p_premake => 4, + p_start_partition => COALESCE(v_start_ts, now())::text + ); + + -- History is kept forever: partitioning here is for scalability + -- (query/index/vacuum performance), not for expiring old messages. + UPDATE partman.part_config + SET retention = NULL, + retention_keep_table = true, + infinite_time_partitions = true + WHERE parent_table = 'chats.messages'; + + -- create_parent() just created the initial batch of partitions -- + -- none of them inherited chats.messages' RLS setting (see the note + -- on ensure_messages_partitions_rls() above). + PERFORM chats.ensure_messages_partitions_rls(); + END IF; + + IF v_messages_backup IS NOT NULL THEN + EXECUTE format($f$ + INSERT INTO chats.messages (id, "createdAt", metadata, duration, "mimeType", name, "replyToMessageId", "roomId", size, status, type, "updatedAt", source, waveform, height, width, "linkPreviewData", "authorId", text, reactions, pinned) + SELECT chats.uuid_generate_v7(), to_timestamp(m."createdAt" / 1000.0), m.metadata, m.duration, m."mimeType", m.name, NULL, rm.new_id, m.size, m.status, m.type, + CASE WHEN m."updatedAt" IS NULL THEN NULL ELSE to_timestamp(m."updatedAt" / 1000.0) END, + m.source, m.waveform, m.height, m.width, m."linkPreviewData", m."authorId", m.text, m.reactions, m.pinned + FROM chats.%I m + JOIN chats.room_id_migration_map rm ON rm.old_id = m."roomId" + $f$, v_messages_backup); + + -- Replies pointed at bigint message ids: map them to the new ids + -- (a message is identified by room, author and creation time). + -- Replies to a message that no longer exists stay NULL. + EXECUTE format($f$ + WITH ids AS ( + SELECT old.id AS old_id, new.id AS new_id + FROM chats.%I old + JOIN chats.room_id_migration_map rm ON rm.old_id = old."roomId" + JOIN chats.messages new + ON new."roomId" = rm.new_id + AND new."authorId" = old."authorId" + AND new."createdAt" = to_timestamp(old."createdAt" / 1000.0) + ) + UPDATE chats.messages m + SET "replyToMessageId" = target.new_id::text + FROM chats.%I old + JOIN ids src ON src.old_id = old.id + JOIN ids target ON target.old_id::text = old."replyToMessageId" + WHERE m.id = src.new_id AND old."replyToMessageId" IS NOT NULL + $f$, v_messages_backup, v_messages_backup); + + -- Publication membership followed the rename onto the backup table + -- (tracked by relation, not by name) — move it back onto + -- chats.messages, and add it WITHOUT "ONLY": a partitioned parent + -- never holds rows itself, so Realtime must follow all of its + -- (current and future) partitions instead. + IF EXISTS ( + SELECT 1 FROM pg_publication_tables + WHERE pubname = 'supabase_realtime' AND schemaname = 'chats' AND tablename = v_messages_backup + ) THEN + EXECUTE format('ALTER PUBLICATION supabase_realtime DROP TABLE chats.%I', v_messages_backup); + END IF; + ALTER PUBLICATION supabase_realtime ADD TABLE chats.messages; + + RAISE NOTICE 'chats.% kept as a backup of the pre-migration messages table — verify row counts match, then drop it yourself once satisfied.', v_messages_backup; + + -- chats.rooms."lastMessages" still holds v1-shaped snapshots (uri, + -- bigint ids...): rebuild them from the migrated messages, leaving + -- "updatedAt" (the room list order) untouched. + UPDATE chats.rooms r + SET "lastMessages" = ( + SELECT jsonb_build_array(to_jsonb(m)) + FROM chats.messages m + WHERE m."roomId" = r.id + ORDER BY m."createdAt" DESC + LIMIT 1 + ); + + -- Members already read everything that existed before the + -- migration: don't flood them with unread badges. + UPDATE chats.room_members SET "lastReadAt" = now() WHERE "lastReadAt" IS NULL; + END IF; + + -- Message pagination (newest first, per room), the last-message trigger + -- and rooms_l."unreadCount" all filter by room and order/compare by + -- "createdAt": one composite index serves all three. Created on the + -- partitioned parent, so Postgres propagates it to every partition, + -- current and future. + IF to_regclass('chats.messages') IS NOT NULL THEN + -- Superseded by the composite index below. Only dropped when it + -- really belongs to chats.messages: after a v1 migration the same + -- auto-generated name can belong to the kept messages_backup_* table. + IF EXISTS ( + SELECT 1 FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + WHERE i.indrelid = 'chats.messages'::regclass + AND c.relname = 'messages_roomId_idx' + ) THEN + DROP INDEX chats."messages_roomId_idx"; + END IF; + CREATE INDEX IF NOT EXISTS "messages_roomId_createdAt_idx" + ON chats.messages USING btree ("roomId", "createdAt" DESC); + -- Pinned messages banner: few rows per room, partial index keeps it + -- tiny. + CREATE INDEX IF NOT EXISTS "messages_roomId_pinned_idx" + ON chats.messages USING btree ("roomId", "createdAt" DESC) + WHERE pinned; + END IF; + + -- The v1 tables were renamed to *_backup_* with their RLS policies + -- attached (policies follow the table, not its name). Drop those + -- policies: RLS stays enabled on the backups, so with no policy left + -- they're closed to anon/authenticated (only postgres/service_role can + -- still read them), and the v1 helper functions the policies depend on + -- (e.g. chats.is_member(uuid[])) can be dropped by + -- 03_database_policy.sql. The data itself is kept untouched. + IF v_is_v1 THEN + DECLARE + p record; + BEGIN + FOR p IN + SELECT tablename, policyname FROM pg_policies + WHERE schemaname = 'chats' + AND tablename IN (v_users_backup, v_rooms_backup, v_messages_backup) + LOOP + EXECUTE format('DROP POLICY %I ON chats.%I', p.policyname, p.tablename); + END LOOP; + END; + END IF; + + -- ======================================================================== + -- chats.media_pending_deletion + -- ======================================================================== + CREATE TABLE IF NOT EXISTS chats.media_pending_deletion ( + id uuid NOT NULL DEFAULT chats.uuid_generate_v7() PRIMARY KEY, + "bucketId" text NOT NULL, + path text NOT NULL, + "createdAt" timestamptz NOT NULL DEFAULT now(), + "processedAt" timestamptz + ); + ALTER TABLE chats.media_pending_deletion OWNER TO postgres; + -- No RLS policies defined on purpose: only the cleanup-media Edge + -- Function (using the service_role key, which bypasses RLS) and this + -- migration script need access. RLS is still enabled so enabling it + -- isn't silently forgotten, and the blanket anon/authenticated grants + -- below are revoked for this table specifically at the end of the file. + ALTER TABLE chats.media_pending_deletion ENABLE ROW LEVEL SECURITY; +END +$do$; + +-- Weekly-partition maintenance (pre-creating upcoming partitions). Runs +-- hourly via pg_cron; cheap/no-op when there's nothing due. No retention job +-- is scheduled — see the note above about keeping chat history forever. +DO $do$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'partman-run-maintenance') THEN + PERFORM cron.schedule( + 'partman-run-maintenance', + '0 * * * *', + $sql$CALL partman.run_maintenance_proc(); SELECT chats.ensure_messages_partitions_rls();$sql$ + ); + ELSE + PERFORM cron.alter_job( + (SELECT jobid FROM cron.job WHERE jobname = 'partman-run-maintenance'), + command => 'CALL partman.run_maintenance_proc(); SELECT chats.ensure_messages_partitions_rls();' + ); + END IF; +END +$do$; REVOKE USAGE ON SCHEMA chats FROM PUBLIC; GRANT USAGE ON SCHEMA chats TO anon; @@ -127,17 +735,13 @@ GRANT ALL ON TABLE chats.messages TO anon; GRANT ALL ON TABLE chats.messages TO authenticated; GRANT ALL ON TABLE chats.messages TO service_role; -GRANT ALL ON SEQUENCE chats.messages_id_seq TO anon; -GRANT ALL ON SEQUENCE chats.messages_id_seq TO authenticated; -GRANT ALL ON SEQUENCE chats.messages_id_seq TO service_role; - GRANT ALL ON TABLE chats.rooms TO anon; GRANT ALL ON TABLE chats.rooms TO authenticated; GRANT ALL ON TABLE chats.rooms TO service_role; -GRANT ALL ON SEQUENCE chats.rooms_id_seq TO anon; -GRANT ALL ON SEQUENCE chats.rooms_id_seq TO authenticated; -GRANT ALL ON SEQUENCE chats.rooms_id_seq TO service_role; +GRANT ALL ON TABLE chats.room_members TO anon; +GRANT ALL ON TABLE chats.room_members TO authenticated; +GRANT ALL ON TABLE chats.room_members TO service_role; GRANT ALL ON TABLE chats.users TO anon; GRANT ALL ON TABLE chats.users TO authenticated; @@ -151,8 +755,27 @@ ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA chats GRANT ALL ON TABLES T ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA chats GRANT ALL ON ROUTINES TO anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA chats GRANT ALL ON SEQUENCES TO anon, authenticated, service_role; -ALTER PUBLICATION supabase_realtime ADD TABLE ONLY chats.messages; - -ALTER PUBLICATION supabase_realtime ADD TABLE ONLY chats.rooms; - -ALTER PUBLICATION supabase_realtime ADD TABLE ONLY chats.users; \ No newline at end of file +-- chats.room_id_migration_map is internal migration bookkeeping (old +-- bigint room id -> new uuid), not part of the app's data model — undo the +-- blanket grant above for it specifically so it isn't exposed to anon/ +-- authenticated via PostgREST. Only exists at all if a v1 (bigint-id) rooms +-- table was ever migrated, hence the existence check. +DO $do$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'chats' AND table_name = 'room_id_migration_map' + ) THEN + REVOKE ALL ON TABLE chats.room_id_migration_map FROM anon, authenticated; + END IF; +END +$do$; + +-- chats.media_pending_deletion is an internal cleanup queue for the +-- cleanup-media Edge Function (service_role only) — not part of the app's +-- data model, so undo the blanket anon/authenticated grant for it too. +REVOKE ALL ON TABLE chats.media_pending_deletion FROM anon, authenticated; + +-- The blanket grants above also reach the chats.messages partitions, which +-- must only be accessed through the parent table: take them back. +SELECT chats.ensure_messages_partitions_rls(); diff --git a/example/utils/sql/02_database_trigger.sql b/example/utils/sql/02_database_trigger.sql index ba261b8..087a8cf 100644 --- a/example/utils/sql/02_database_trigger.sql +++ b/example/utils/sql/02_database_trigger.sql @@ -1,20 +1,29 @@ +-- Creates the chat profile of every new auth user. First/last name and +-- avatar are taken from the sign-up metadata when present +-- (`auth.signUp(data: {'first_name': ..., 'last_name': ..., 'avatar_url': ...})`, +-- also the shape most OAuth providers fill in), so a profile is complete +-- from the start without a second, separately-authorized update call. CREATE OR REPLACE FUNCTION chats.handle_new_user() RETURNS trigger - LANGUAGE 'plpgsql' - COST 100 - VOLATILE NOT LEAKPROOF SECURITY DEFINER - SET search_path=public + LANGUAGE plpgsql + SECURITY DEFINER SET search_path = '' AS $BODY$ DECLARE - ts_in_milliseconds bigint; + meta jsonb := coalesce(new.raw_user_meta_data, '{}'::jsonb); BEGIN - SELECT EXTRACT(epoch FROM NOW()) * 1000 INTO ts_in_milliseconds; - insert into chats.users (id, "createdAt", "updatedAt", "lastSeen") - values (new.id, ts_in_milliseconds, ts_in_milliseconds, ts_in_milliseconds); - return new; -end; + INSERT INTO chats.users (id, "firstName", "lastName", "imageUrl", "createdAt", "updatedAt", "lastSeen") + VALUES ( + new.id, + nullif(coalesce(meta ->> 'first_name', meta ->> 'firstName', meta ->> 'given_name'), ''), + nullif(coalesce(meta ->> 'last_name', meta ->> 'lastName', meta ->> 'family_name'), ''), + nullif(coalesce(meta ->> 'avatar_url', meta ->> 'picture'), ''), + now(), now(), now() + ) + ON CONFLICT (id) DO NOTHING; + RETURN new; +END; $BODY$; drop trigger if exists on_auth_user_created on auth.users; @@ -23,14 +32,17 @@ create trigger on_auth_user_created for each row execute procedure chats.handle_new_user(); +-- Keeps chats.rooms."lastMessages"/"updatedAt" in sync with the room's +-- newest message. SECURITY DEFINER: any member sends messages, but only +-- room admins may update chats.rooms under RLS. CREATE OR REPLACE FUNCTION chats.update_last_messages() RETURNS TRIGGER + SECURITY DEFINER SET search_path = '' AS $$ DECLARE latest_message jsonb; - ts_in_milliseconds bigint; - affected_room_id bigint; + affected_room_id uuid; BEGIN IF TG_OP = 'DELETE' THEN affected_room_id := OLD."roomId"; @@ -50,11 +62,15 @@ BEGIN (SELECT "lastMessages" FROM chats.rooms WHERE id = affected_room_id) ) LIMIT 1 ) THEN - SELECT EXTRACT(epoch FROM NOW()) * 1000 INTO ts_in_milliseconds; - + -- Only a new message moves the room up the list: edits, reactions + -- and read receipts (status -> seen) refresh the snapshot but keep + -- "updatedAt", or merely reading a chat would reorder everyone's + -- rooms list. UPDATE chats.rooms - SET "updatedAt" = ts_in_milliseconds, - "lastMessages" = jsonb_build_array(latest_message) + SET "updatedAt" = CASE WHEN TG_OP = 'INSERT' THEN now() ELSE "updatedAt" END, + "lastMessages" = CASE WHEN latest_message IS NULL + THEN NULL + ELSE jsonb_build_array(latest_message) END WHERE id = affected_room_id; END IF; @@ -87,3 +103,128 @@ drop trigger if exists update_status_before_insert on chats.messages; CREATE TRIGGER update_status_before_insert BEFORE INSERT ON chats.messages FOR EACH ROW EXECUTE FUNCTION chats.set_message_status_to_sent(); + +-- A message's storage media (its `source`, when it points at the +-- chats_assets bucket) can't be deleted by SQL alone -- the storage backend +-- needs a real API call. Instead, record the bucket/path here for the +-- cleanup-media Edge Function (see supabase/functions/cleanup-media) to +-- pick up and remove later, whenever a message referencing one is deleted. +-- Deleting the message row itself still happens immediately, same as today. +-- SECURITY DEFINER: chats.media_pending_deletion is closed to clients. +CREATE OR REPLACE FUNCTION chats.queue_media_for_deletion() + RETURNS TRIGGER + SECURITY DEFINER + SET search_path = '' +AS $$ +DECLARE + marker text := '/object/authenticated/'; + idx int; + rest text; + slash_idx int; +BEGIN + IF OLD.source IS NOT NULL THEN + idx := position(marker IN OLD.source); + IF idx > 0 THEN + rest := substring(OLD.source FROM idx + char_length(marker)); + slash_idx := position('/' IN rest); + IF slash_idx > 0 THEN + INSERT INTO chats.media_pending_deletion ("bucketId", path) + VALUES ( + substring(rest FROM 1 FOR slash_idx - 1), + substring(rest FROM slash_idx + 1) + ); + END IF; + END IF; + END IF; + RETURN OLD; +END; +$$ LANGUAGE plpgsql; + +-- Row-level triggers on a partitioned parent apply to all of its +-- partitions automatically (PG13+, unlike the RLS-enabled flag -- see the +-- note in 01_database_schema.sql), so this needs to be defined only once +-- here on chats.messages, not per-partition. +drop trigger if exists queue_media_for_deletion_trigger on chats.messages; +CREATE TRIGGER queue_media_for_deletion_trigger + AFTER DELETE ON chats.messages + FOR EACH ROW EXECUTE FUNCTION chats.queue_media_for_deletion(); + +-- A room with zero members left is deleted automatically. This must be a +-- DEFERRED constraint trigger (checked at transaction commit, not +-- immediately after each row delete): chats.replace_room_members() +-- (03_database_policy.sql) deletes every existing member row before +-- re-inserting the new set, all in one transaction -- an immediate trigger +-- would see zero members mid-function and delete the room before the new +-- members are even inserted. +-- SECURITY DEFINER: the last member leaving is no longer a member (nor an +-- admin) by the time this runs, so RLS would silently skip the delete. +CREATE OR REPLACE FUNCTION chats.delete_room_if_empty() + RETURNS TRIGGER + SECURITY DEFINER + SET search_path = '' +AS $$ +DECLARE + remaining int; +BEGIN + SELECT count(*) INTO remaining FROM chats.room_members WHERE "roomId" = OLD."roomId"; + IF remaining = 0 THEN + DELETE FROM chats.rooms WHERE id = OLD."roomId"; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +drop trigger if exists delete_room_if_empty_trigger on chats.room_members; +CREATE CONSTRAINT TRIGGER delete_room_if_empty_trigger + AFTER DELETE ON chats.room_members + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION chats.delete_room_if_empty(); + +-- Delivers every message change to the room's private Realtime channel +-- (`chats:room:`), where SupabaseChatController listens for it. +-- +-- Broadcast from Database instead of Postgres Changes: chats.messages is +-- partitioned, and Postgres Changes reports each row under the partition it +-- physically lives in (chats.messages_p20261019, ...), so a subscription on +-- chats.messages never matches anything. Broadcast also scales better (one +-- message per change, authorized once at join time, instead of an RLS check +-- per change per subscriber) and DELETE events keep the whole old row, so +-- they can be routed to their room. +-- +-- Who may join the channel is decided by the RLS policies on +-- realtime.messages (03_database_policy.sql). Requires Realtime with +-- Broadcast from Database support (Supabase cloud; self-hosted +-- supabase/realtime >= v2.33). +-- +-- A failure to broadcast must never lose the message itself: it is reported +-- as a WARNING (visible in the Postgres logs) and the write goes through. +CREATE OR REPLACE FUNCTION chats.broadcast_message_change() + RETURNS TRIGGER + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = '' +AS $$ +DECLARE + v_room_id uuid := coalesce(NEW."roomId", OLD."roomId"); +BEGIN + BEGIN + PERFORM realtime.broadcast_changes( + 'chats:room:' || v_room_id::text, + TG_OP, + TG_OP, + 'messages', + 'chats', + NEW, + OLD + ); + EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'chats.broadcast_message_change: % (room %)', SQLERRM, v_room_id; + END; + RETURN NULL; +END; +$$; + +drop trigger if exists broadcast_message_change_trigger on chats.messages; +CREATE TRIGGER broadcast_message_change_trigger + AFTER INSERT OR UPDATE OR DELETE ON chats.messages + FOR EACH ROW EXECUTE FUNCTION chats.broadcast_message_change(); diff --git a/example/utils/sql/03_database_policy.sql b/example/utils/sql/03_database_policy.sql index 9d7c708..3a78dbd 100644 --- a/example/utils/sql/03_database_policy.sql +++ b/example/utils/sql/03_database_policy.sql @@ -1,3 +1,24 @@ +-- ============================================================================ +-- chats: access helpers, RPCs and row level security policies (v2) +-- +-- Idempotent: every function is CREATE OR REPLACE'd and every policy is +-- dropped and recreated, so re-running this file is always safe. +-- +-- Access model: +-- - chats.users: readable by any authenticated user, updatable by its +-- owner only, created/deleted by triggers/foreign keys only. +-- - chats.rooms: readable by members. Created only through +-- chats.create_room() (atomic: room + members in one transaction, and +-- direct rooms are de-duplicated). Group rooms are renamed/deleted by +-- their admins, direct rooms by either member. +-- - chats.room_members: readable by members of the same room. Group admins +-- add/remove members and change roles; anyone can leave a room. +-- - chats.messages: readable/writable by room members; a message can only +-- be sent as yourself, edited by its author, and deleted by its author +-- (or a group admin). Read receipts and reactions go through +-- SECURITY DEFINER RPCs, since they touch other people's rows. +-- ============================================================================ + DROP POLICY IF EXISTS "chats.users_grant_create" ON chats.users; DROP POLICY IF EXISTS "chats.users_grant_read" ON chats.users; DROP POLICY IF EXISTS "chats.users_grant_update" ON chats.users; @@ -8,147 +29,616 @@ DROP POLICY IF EXISTS "chats.rooms_grant_read" ON chats.rooms; DROP POLICY IF EXISTS "chats.rooms_grant_update" ON chats.rooms; DROP POLICY IF EXISTS "chats.rooms_grant_delete" ON chats.rooms; +DROP POLICY IF EXISTS "chats.room_members_grant_create" ON chats.room_members; +DROP POLICY IF EXISTS "chats.room_members_grant_read" ON chats.room_members; +DROP POLICY IF EXISTS "chats.room_members_grant_update" ON chats.room_members; +DROP POLICY IF EXISTS "chats.room_members_grant_delete" ON chats.room_members; + DROP POLICY IF EXISTS "chats.messages_grant_create" ON chats.messages; DROP POLICY IF EXISTS "chats.messages_grant_read" ON chats.messages; DROP POLICY IF EXISTS "chats.messages_grant_update" ON chats.messages; DROP POLICY IF EXISTS "chats.messages_grant_delete" ON chats.messages; +-- ---------------------------------------------------------------------------- +-- Helpers +-- ---------------------------------------------------------------------------- + CREATE OR REPLACE FUNCTION chats.is_auth() RETURNS boolean - LANGUAGE 'plpgsql' - COST 100 - VOLATILE NOT LEAKPROOF SECURITY INVOKER + LANGUAGE sql + STABLE + SECURITY INVOKER SET search_path = '' -AS $BODY$ -BEGIN - return auth.uid() IS NOT NULL; -end; -$BODY$; +AS $$ + SELECT auth.uid() IS NOT NULL; +$$; CREATE OR REPLACE FUNCTION chats.is_owner(user_id uuid) RETURNS boolean - LANGUAGE 'plpgsql' - COST 100 - VOLATILE NOT LEAKPROOF SECURITY INVOKER + LANGUAGE sql + STABLE + SECURITY INVOKER SET search_path = '' -AS $BODY$ +AS $$ + SELECT auth.uid() = user_id; +$$; + +-- Superseded by chats.room_members (chats.rooms."userIds" no longer +-- exists) -- all policies calling it were already dropped above. +DROP FUNCTION IF EXISTS chats.is_member(uuid[]); + +-- chats.rooms/messages ids are uuid since 2.x — drop the old bigint-arg +-- overload too. The v1 chats_assets storage policies still call it: drop +-- them first, 04_storage.sql recreates them on top of the uuid helpers. +DROP POLICY IF EXISTS "storage.object_grant_create_auth_chats_assets" ON storage.objects; +DROP POLICY IF EXISTS "storage.object_grant_read_auth_chats_assets" ON storage.objects; +DROP POLICY IF EXISTS "storage.object_grant_update_auth_chats_assets" ON storage.objects; +DROP POLICY IF EXISTS "storage.object_grant_delete_auth_chats_assets" ON storage.objects; +DROP FUNCTION IF EXISTS chats.is_chat_member(bigint); + +-- Superseded by chats.create_room() and per-row membership management. +DROP FUNCTION IF EXISTS chats.replace_room_members(uuid, jsonb); + +-- SECURITY DEFINER on purpose: it is called from chats.room_members' own +-- SELECT policy. As SECURITY INVOKER its lookup on chats.room_members would +-- re-apply that same policy, recursing forever. +CREATE OR REPLACE FUNCTION chats.is_chat_member(room_id uuid) + RETURNS boolean + LANGUAGE sql + STABLE + SECURITY DEFINER + SET search_path = '' +AS $$ + SELECT EXISTS ( + SELECT 1 FROM chats.room_members + WHERE "roomId" = room_id AND "userId" = auth.uid() + ); +$$; + +-- True when the caller is an admin of the room (group rooms), or a member +-- of a direct room (both members of a 1-to-1 conversation have the same +-- rights over it). +CREATE OR REPLACE FUNCTION chats.is_chat_admin(room_id uuid) + RETURNS boolean + LANGUAGE sql + STABLE + SECURITY DEFINER + SET search_path = '' +AS $$ + SELECT EXISTS ( + SELECT 1 + FROM chats.room_members rm + JOIN chats.rooms r ON r.id = rm."roomId" + WHERE rm."roomId" = room_id + AND rm."userId" = auth.uid() + AND (rm.role = 'admin' OR r.type = 'direct') + ); +$$; + +-- The caller's role in a room ('admin', 'moderator', or NULL for a plain +-- member or a non-member). +CREATE OR REPLACE FUNCTION chats.chat_member_role(room_id uuid) + RETURNS text + LANGUAGE sql + STABLE + SECURITY DEFINER + SET search_path = '' +AS $$ + SELECT role FROM chats.room_members + WHERE "roomId" = room_id AND "userId" = auth.uid(); +$$; + +-- Storage helper: is the caller a member of the room whose id is the first +-- folder of a `chats_assets` object path (`/`)? Returns false +-- instead of raising for paths whose first folder isn't a uuid -- a bare +-- `::uuid` cast inside a storage policy could otherwise fail queries on +-- unrelated objects, since SQL doesn't guarantee the evaluation order of +-- the policy's AND-ed conditions. +CREATE OR REPLACE FUNCTION chats.is_chat_member_path(object_name text) + RETURNS boolean + LANGUAGE plpgsql + STABLE + SECURITY DEFINER + SET search_path = '' +AS $$ +DECLARE + folder text := split_part(object_name, '/', 1); + mapped uuid; BEGIN - return auth.uid() = user_id; -end; -$BODY$; + IF folder ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' THEN + RETURN chats.is_chat_member(folder::uuid); + END IF; + -- Attachments uploaded before the v1 -> v2 migration live under the old + -- bigint room id (`/`): resolve it through the migration + -- map, so they stay readable without moving any storage object. + IF folder ~ '^[0-9]{1,18}$' AND to_regclass('chats.room_id_migration_map') IS NOT NULL THEN + EXECUTE 'SELECT new_id FROM chats.room_id_migration_map WHERE old_id = $1' + INTO mapped USING folder::bigint; + RETURN mapped IS NOT NULL AND chats.is_chat_member(mapped); + END IF; + RETURN false; +END; +$$; -CREATE OR REPLACE FUNCTION chats.is_member(members uuid[]) +-- Realtime helper: is the caller a member of the room a private channel +-- topic (`chats:room:`) belongs to? False for any other topic, and +-- for a malformed id (same safe-cast rationale as above). +CREATE OR REPLACE FUNCTION chats.is_chat_member_topic(topic text) RETURNS boolean - LANGUAGE 'plpgsql' - COST 100 - VOLATILE NOT LEAKPROOF SECURITY INVOKER + LANGUAGE plpgsql + STABLE + SECURITY INVOKER SET search_path = '' -AS $BODY$ +AS $$ +DECLARE + room text; BEGIN - return auth.uid() = ANY(members); -end; -$BODY$; + IF topic IS NULL OR left(topic, 11) <> 'chats:room:' THEN + RETURN false; + END IF; + room := substr(topic, 12); + IF room !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' THEN + RETURN false; + END IF; + RETURN chats.is_chat_member(room::uuid); +END; +$$; -CREATE OR REPLACE FUNCTION chats.is_chat_member(room_id bigint) +-- Storage helper: is the first folder of an object path (`/`) +-- the caller's own user id? Same safe-cast rationale as above. +CREATE OR REPLACE FUNCTION chats.is_owner_path(object_name text) RETURNS boolean - LANGUAGE 'plpgsql' - COST 100 - VOLATILE NOT LEAKPROOF SECURITY INVOKER + LANGUAGE sql + STABLE + SECURITY INVOKER + SET search_path = '' +AS $$ + SELECT auth.uid() IS NOT NULL + AND split_part(object_name, '/', 1) = auth.uid()::text; +$$; + +-- ---------------------------------------------------------------------------- +-- RPCs +-- ---------------------------------------------------------------------------- + +-- Creates a room together with its members, atomically, and returns its id. +-- +-- p_type 'direct' or 'group'. +-- p_user_ids the other members (the caller is always added). +-- p_name group name (ignored for direct rooms). +-- p_image_url group avatar (ignored for direct rooms). +-- p_metadata custom data stored on the room. +-- p_creator_role the caller's role in a group room (default 'admin'). +-- +-- A direct room is unique per pair of users *and* metadata scope: if one +-- already exists whose metadata contains every key/value of p_metadata, its +-- id is returned instead of creating a duplicate. That way apps that +-- partition chats (e.g. by workspace, through metadata) get one direct room +-- per pair per partition. +-- +-- SECURITY DEFINER because the caller can't satisfy the rooms/room_members +-- RLS policies for a room that has no members yet. +CREATE OR REPLACE FUNCTION chats.create_room( + p_type text, + p_user_ids uuid[], + p_name text DEFAULT NULL, + p_image_url text DEFAULT NULL, + p_metadata jsonb DEFAULT NULL, + p_creator_role text DEFAULT 'admin' +) + RETURNS uuid + LANGUAGE plpgsql + VOLATILE + SECURITY DEFINER SET search_path = '' -AS $BODY$ +AS $$ DECLARE - members uuid[]; + v_uid uuid := auth.uid(); + v_members uuid[]; + v_room_id uuid; + v_now timestamptz := now(); BEGIN - SELECT "userIds" INTO members - FROM chats.rooms - WHERE id = room_id; - return chats.is_member(members); -end; -$BODY$; + IF v_uid IS NULL THEN + RAISE EXCEPTION 'create_room requires an authenticated user' + USING ERRCODE = '42501'; + END IF; + IF p_type IS NULL OR p_type NOT IN ('direct', 'group') THEN + RAISE EXCEPTION 'invalid room type: %', p_type USING ERRCODE = '22023'; + END IF; + + SELECT array_agg(DISTINCT u ORDER BY u) + INTO v_members + FROM unnest(coalesce(p_user_ids, '{}'::uuid[]) || v_uid) AS u + WHERE u IS NOT NULL; + + IF EXISTS ( + SELECT 1 FROM unnest(v_members) AS u + WHERE NOT EXISTS (SELECT 1 FROM chats.users cu WHERE cu.id = u) + ) THEN + RAISE EXCEPTION 'unknown user id in p_user_ids' USING ERRCODE = '23503'; + END IF; + + IF p_type = 'direct' THEN + IF cardinality(v_members) <> 2 THEN + RAISE EXCEPTION 'a direct room needs exactly one other user' + USING ERRCODE = '22023'; + END IF; + + -- Serialize concurrent creations for the same pair, so two clients + -- opening the same conversation at once can't create two rooms. + PERFORM pg_advisory_xact_lock( + hashtextextended('chats.direct:' || v_members[1] || ':' || v_members[2], 0) + ); + + SELECT r.id INTO v_room_id + FROM chats.rooms r + WHERE r.type = 'direct' + AND coalesce(r.metadata, '{}'::jsonb) @> coalesce(p_metadata, '{}'::jsonb) + AND (SELECT array_agg(rm."userId" ORDER BY rm."userId") + FROM chats.room_members rm WHERE rm."roomId" = r.id) = v_members + ORDER BY r."createdAt" + LIMIT 1; + + IF v_room_id IS NOT NULL THEN + RETURN v_room_id; + END IF; + + INSERT INTO chats.rooms (type, metadata, "createdAt", "updatedAt") + VALUES ('direct', p_metadata, v_now, v_now) + RETURNING id INTO v_room_id; + + INSERT INTO chats.room_members ("roomId", "userId", role, "lastReadAt") + SELECT v_room_id, u, NULL, CASE WHEN u = v_uid THEN v_now END + FROM unnest(v_members) AS u; + ELSE + INSERT INTO chats.rooms (type, name, "imageUrl", metadata, "createdAt", "updatedAt") + VALUES ('group', p_name, p_image_url, p_metadata, v_now, v_now) + RETURNING id INTO v_room_id; + + INSERT INTO chats.room_members ("roomId", "userId", role, "lastReadAt") + SELECT v_room_id, u, + CASE WHEN u = v_uid THEN p_creator_role END, + CASE WHEN u = v_uid THEN v_now END + FROM unnest(v_members) AS u; + END IF; + + RETURN v_room_id; +END; +$$; + +-- Marks every message of a room as read by the caller: stores the read +-- time on the caller's membership (drives rooms_l."unreadCount") and flips +-- other members' messages to status 'seen' (drives the sender's read +-- receipts, delivered to them as Realtime UPDATE events). In a group room a +-- message is 'seen' as soon as any other member read it. +CREATE OR REPLACE FUNCTION chats.mark_room_as_read(p_room_id uuid) + RETURNS void + LANGUAGE plpgsql + VOLATILE + SECURITY DEFINER + SET search_path = '' +AS $$ +DECLARE + v_uid uuid := auth.uid(); + v_now timestamptz := now(); +BEGIN + IF NOT chats.is_chat_member(p_room_id) THEN + RAISE EXCEPTION 'not a member of this room' USING ERRCODE = '42501'; + END IF; + + UPDATE chats.room_members + SET "lastReadAt" = v_now + WHERE "roomId" = p_room_id AND "userId" = v_uid; + + UPDATE chats.messages + SET status = 'seen' + WHERE "roomId" = p_room_id + AND "authorId" <> v_uid + AND "createdAt" <= v_now + AND status IS DISTINCT FROM 'seen'; +END; +$$; + +-- Adds or removes the caller's [p_reaction] (e.g. an emoji) on a message, +-- atomically (reactions is a jsonb map shared by every member -- a +-- read-modify-write from the client would lose concurrent reactions). +-- Returns the message's updated reactions map. +CREATE OR REPLACE FUNCTION chats.toggle_message_reaction(p_message_id uuid, p_reaction text) + RETURNS jsonb + LANGUAGE plpgsql + VOLATILE + SECURITY DEFINER + SET search_path = '' +AS $$ +DECLARE + v_uid text := auth.uid()::text; + v_room_id uuid; + v_reactions jsonb; + v_users jsonb; +BEGIN + IF p_reaction IS NULL OR length(p_reaction) = 0 OR length(p_reaction) > 64 THEN + RAISE EXCEPTION 'invalid reaction' USING ERRCODE = '22023'; + END IF; + + SELECT "roomId", coalesce(reactions, '{}'::jsonb) + INTO v_room_id, v_reactions + FROM chats.messages + WHERE id = p_message_id + FOR UPDATE; + + IF v_room_id IS NULL OR NOT chats.is_chat_member(v_room_id) THEN + RAISE EXCEPTION 'message not found' USING ERRCODE = 'P0002'; + END IF; + + v_users := coalesce(v_reactions -> p_reaction, '[]'::jsonb); + IF v_users ? v_uid THEN + v_users := v_users - v_uid; + ELSE + v_users := v_users || to_jsonb(v_uid); + END IF; + + IF jsonb_array_length(v_users) = 0 THEN + v_reactions := v_reactions - p_reaction; + ELSE + v_reactions := jsonb_set(v_reactions, ARRAY[p_reaction], v_users); + END IF; + + UPDATE chats.messages + SET reactions = CASE WHEN v_reactions = '{}'::jsonb THEN NULL ELSE v_reactions END + WHERE id = p_message_id; + + RETURN v_reactions; +END; +$$; + +-- Pins (p_pinned = true) or unpins a room on top of the caller's own rooms +-- list. Per-member: other members don't see it. +CREATE OR REPLACE FUNCTION chats.pin_room(p_room_id uuid, p_pinned boolean) + RETURNS void + LANGUAGE plpgsql + VOLATILE + SECURITY DEFINER + SET search_path = '' +AS $$ +BEGIN + UPDATE chats.room_members + SET "pinnedAt" = CASE WHEN p_pinned THEN now() END + WHERE "roomId" = p_room_id AND "userId" = auth.uid(); + IF NOT FOUND THEN + RAISE EXCEPTION 'not a member of this room' USING ERRCODE = '42501'; + END IF; +END; +$$; + +-- Mutes a room for the caller until p_until ('infinity' for ever), or +-- unmutes it (NULL). Per-member: apps read rooms_l."mutedUntil" to skip +-- notifications/badges. +CREATE OR REPLACE FUNCTION chats.mute_room(p_room_id uuid, p_until timestamptz) + RETURNS void + LANGUAGE plpgsql + VOLATILE + SECURITY DEFINER + SET search_path = '' +AS $$ +BEGIN + UPDATE chats.room_members + SET "mutedUntil" = p_until + WHERE "roomId" = p_room_id AND "userId" = auth.uid(); + IF NOT FOUND THEN + RAISE EXCEPTION 'not a member of this room' USING ERRCODE = '42501'; + END IF; +END; +$$; + +-- Pins/unpins a message for everyone in its room: either member of a +-- direct room, or an admin of a group room. SECURITY DEFINER since the +-- message usually belongs to someone else (UPDATE is author-only under RLS). +CREATE OR REPLACE FUNCTION chats.set_message_pinned(p_message_id uuid, p_pinned boolean) + RETURNS void + LANGUAGE plpgsql + VOLATILE + SECURITY DEFINER + SET search_path = '' +AS $$ +DECLARE + v_room_id uuid; +BEGIN + SELECT "roomId" INTO v_room_id FROM chats.messages WHERE id = p_message_id; + IF v_room_id IS NULL OR NOT chats.is_chat_member(v_room_id) THEN + RAISE EXCEPTION 'message not found' USING ERRCODE = 'P0002'; + END IF; + IF NOT chats.is_chat_admin(v_room_id) THEN + RAISE EXCEPTION 'only room admins can pin messages' USING ERRCODE = '42501'; + END IF; + UPDATE chats.messages + SET pinned = CASE WHEN p_pinned THEN true END + WHERE id = p_message_id; +END; +$$; + +REVOKE ALL ON FUNCTION chats.create_room(text, uuid[], text, text, jsonb, text) FROM PUBLIC, anon; +REVOKE ALL ON FUNCTION chats.mark_room_as_read(uuid) FROM PUBLIC, anon; +REVOKE ALL ON FUNCTION chats.toggle_message_reaction(uuid, text) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION chats.create_room(text, uuid[], text, text, jsonb, text) TO authenticated, service_role; +GRANT EXECUTE ON FUNCTION chats.mark_room_as_read(uuid) TO authenticated, service_role; +GRANT EXECUTE ON FUNCTION chats.toggle_message_reaction(uuid, text) TO authenticated, service_role; +REVOKE ALL ON FUNCTION chats.pin_room(uuid, boolean) FROM PUBLIC, anon; +REVOKE ALL ON FUNCTION chats.mute_room(uuid, timestamptz) FROM PUBLIC, anon; +REVOKE ALL ON FUNCTION chats.set_message_pinned(uuid, boolean) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION chats.pin_room(uuid, boolean) TO authenticated, service_role; +GRANT EXECUTE ON FUNCTION chats.mute_room(uuid, timestamptz) TO authenticated, service_role; +GRANT EXECUTE ON FUNCTION chats.set_message_pinned(uuid, boolean) TO authenticated, service_role; + +-- ---------------------------------------------------------------------------- +-- chats.users +-- +-- UPDATE policies state their rule once, in USING: without WITH CHECK, +-- Postgres applies the USING expression to the new row too, so the row can +-- neither be selected nor moved outside of the rule. +-- ---------------------------------------------------------------------------- CREATE POLICY "chats.users_grant_create" ON chats.users AS PERMISSIVE FOR INSERT - TO public + TO authenticated WITH CHECK (false); -- Created by trigger CREATE POLICY "chats.users_grant_read" ON chats.users AS PERMISSIVE FOR SELECT - TO public + TO authenticated USING (chats.is_auth()); CREATE POLICY "chats.users_grant_update" ON chats.users AS PERMISSIVE FOR UPDATE - TO public - USING (chats.is_auth()) - WITH CHECK (chats.is_owner(id)); + TO authenticated + USING (chats.is_owner(id)); CREATE POLICY "chats.users_grant_delete" ON chats.users AS PERMISSIVE FOR DELETE - TO public + TO authenticated USING (false); -- Delete by foreign key +-- ---------------------------------------------------------------------------- +-- chats.rooms +-- ---------------------------------------------------------------------------- + CREATE POLICY "chats.rooms_grant_create" ON chats.rooms AS PERMISSIVE FOR INSERT - TO public - WITH CHECK (chats.is_auth()); + TO authenticated + WITH CHECK (false); -- Created by chats.create_room() CREATE POLICY "chats.rooms_grant_read" ON chats.rooms AS PERMISSIVE FOR SELECT - TO public - USING (chats.is_member("userIds")); + TO authenticated + USING (chats.is_chat_member(id)); CREATE POLICY "chats.rooms_grant_update" ON chats.rooms AS PERMISSIVE FOR UPDATE - TO public - USING (chats.is_member("userIds")) - WITH CHECK (chats.is_member("userIds")); + TO authenticated + USING (chats.is_chat_admin(id)); CREATE POLICY "chats.rooms_grant_delete" ON chats.rooms AS PERMISSIVE FOR DELETE - TO public - USING (chats.is_member("userIds")); + TO authenticated + USING (chats.is_chat_admin(id)); + +-- ---------------------------------------------------------------------------- +-- chats.room_members +-- ---------------------------------------------------------------------------- + +-- Only group admins add members, and only to group rooms (a direct room's +-- membership is fixed at creation). There is deliberately no "add yourself" +-- path: knowing a room's id must not be enough to join it. +CREATE POLICY "chats.room_members_grant_create" + ON chats.room_members + AS PERMISSIVE + FOR INSERT + TO authenticated + WITH CHECK ( + chats.is_chat_admin("roomId") + AND EXISTS (SELECT 1 FROM chats.rooms r WHERE r.id = "roomId" AND r.type = 'group') + ); + +CREATE POLICY "chats.room_members_grant_read" + ON chats.room_members + AS PERMISSIVE + FOR SELECT + TO authenticated + USING (chats.is_chat_member("roomId")); + +-- Role changes. ("lastReadAt" goes through chats.mark_room_as_read().) +CREATE POLICY "chats.room_members_grant_update" + ON chats.room_members + AS PERMISSIVE + FOR UPDATE + TO authenticated + USING (chats.is_chat_admin("roomId")); + +-- Covers both "leave a room" (deleting your own row) and "remove a member" +-- (a group admin deleting someone else's row). A room left without members +-- is deleted by the delete_room_if_empty trigger. +CREATE POLICY "chats.room_members_grant_delete" + ON chats.room_members + AS PERMISSIVE + FOR DELETE + TO authenticated + USING ("userId" = (SELECT auth.uid()) OR chats.is_chat_admin("roomId")); + +-- ---------------------------------------------------------------------------- +-- chats.messages +-- ---------------------------------------------------------------------------- CREATE POLICY "chats.messages_grant_create" ON chats.messages AS PERMISSIVE FOR INSERT - TO public - WITH CHECK (chats.is_chat_member("roomId")); + TO authenticated + WITH CHECK (chats.is_chat_member("roomId") AND "authorId" = (SELECT auth.uid())); CREATE POLICY "chats.messages_grant_read" ON chats.messages AS PERMISSIVE FOR SELECT - TO public + TO authenticated USING (chats.is_chat_member("roomId")); CREATE POLICY "chats.messages_grant_update" ON chats.messages AS PERMISSIVE FOR UPDATE - TO public - USING (chats.is_chat_member("roomId")) - WITH CHECK (chats.is_chat_member("roomId")); + TO authenticated + USING (chats.is_chat_member("roomId") AND "authorId" = (SELECT auth.uid())); CREATE POLICY "chats.messages_grant_delete" ON chats.messages AS PERMISSIVE FOR DELETE - TO public - USING (chats.is_chat_member("roomId")); \ No newline at end of file + TO authenticated + USING ( + chats.is_chat_member("roomId") + AND ("authorId" = (SELECT auth.uid()) OR chats.chat_member_role("roomId") = 'admin') + ); + +-- ---------------------------------------------------------------------------- +-- Realtime: private room channels (`chats:room:`) +-- +-- Realtime authorizes joining a private channel (receiving its broadcasts, +-- incl. the message changes sent by chats.broadcast_message_change) against +-- SELECT policies on realtime.messages, and sending to it (typing +-- indicator) against INSERT policies. Both are limited to room members. +-- Only topics starting with `chats:room:` are matched, so policies other +-- apps define on realtime.messages are unaffected. +-- ---------------------------------------------------------------------------- +DROP POLICY IF EXISTS "chats.realtime_room_read" ON realtime.messages; +CREATE POLICY "chats.realtime_room_read" + ON realtime.messages + AS PERMISSIVE + FOR SELECT + TO authenticated + USING (chats.is_chat_member_topic((SELECT realtime.topic()))); + +DROP POLICY IF EXISTS "chats.realtime_room_write" ON realtime.messages; +CREATE POLICY "chats.realtime_room_write" + ON realtime.messages + AS PERMISSIVE + FOR INSERT + TO authenticated + WITH CHECK ( + extension = 'broadcast' + AND chats.is_chat_member_topic((SELECT realtime.topic())) + ); diff --git a/example/utils/sql/04_storage.sql b/example/utils/sql/04_storage.sql index 65e3867..11a4fd5 100644 --- a/example/utils/sql/04_storage.sql +++ b/example/utils/sql/04_storage.sql @@ -1,62 +1,78 @@ +-- Storage buckets and policies for chat attachments and user avatars. +-- +-- chats_assets// readable/writable by room members. +-- chats_user_avatar// readable by any authenticated user, +-- writable by its owner only. +-- +-- Both buckets are private: files are served through authenticated URLs +-- (`/storage/v1/object/authenticated/...`) with the user's access token. +-- Requires 03_database_policy.sql (path helpers) to have run first. insert into storage.buckets (id, name) values ('chats_assets', 'chats_assets'), - ('chats_user_avatar', 'chats_user_avatar'); + ('chats_user_avatar', 'chats_user_avatar') +on conflict (id) do nothing; DROP policy IF EXISTS "storage.object_grant_create_auth_chats_assets" ON storage.objects; create policy "storage.object_grant_create_auth_chats_assets" on storage.objects for insert +to authenticated with check ( bucket_id = 'chats_assets' and - chats.is_chat_member((storage.foldername(name))[1]::bigint)); + chats.is_chat_member_path(name)); DROP policy IF EXISTS "storage.object_grant_read_auth_chats_assets" ON storage.objects; create policy "storage.object_grant_read_auth_chats_assets" on storage.objects for select +to authenticated using ( bucket_id = 'chats_assets' and - chats.is_chat_member((storage.foldername(name))[1]::bigint)); + chats.is_chat_member_path(name)); DROP policy IF EXISTS "storage.object_grant_update_auth_chats_assets" ON storage.objects; create policy "storage.object_grant_update_auth_chats_assets" on storage.objects for update +to authenticated using ( bucket_id = 'chats_assets' and - chats.is_chat_member((storage.foldername(name))[1]::bigint)) + chats.is_chat_member_path(name)) with check ( bucket_id = 'chats_assets' and - chats.is_chat_member((storage.foldername(name))[1]::bigint)); + chats.is_chat_member_path(name)); DROP policy IF EXISTS "storage.object_grant_delete_auth_chats_assets" ON storage.objects; create policy "storage.object_grant_delete_auth_chats_assets" on storage.objects for delete +to authenticated using ( bucket_id = 'chats_assets' and - chats.is_chat_member((storage.foldername(name))[1]::bigint)); + chats.is_chat_member_path(name)); DROP policy IF EXISTS "storage.object_grant_create_auth_chats_user_avatar" ON storage.objects; create policy "storage.object_grant_create_auth_chats_user_avatar" on storage.objects for insert +to authenticated with check ( bucket_id = 'chats_user_avatar' and - chats.is_owner((storage.foldername(name))[1]::uuid)); + chats.is_owner_path(name)); DROP policy IF EXISTS "storage.object_grant_read_auth_chats_user_avatar" ON storage.objects; create policy "storage.object_grant_read_auth_chats_user_avatar" on storage.objects for select +to authenticated using ( bucket_id = 'chats_user_avatar' and @@ -66,20 +82,22 @@ DROP policy IF EXISTS "storage.object_grant_update_auth_chats_user_avatar" ON storage.objects; create policy "storage.object_grant_update_auth_chats_user_avatar" on storage.objects for update +to authenticated using ( bucket_id = 'chats_user_avatar' and - chats.is_owner((storage.foldername(name))[1]::uuid)) + chats.is_owner_path(name)) with check ( bucket_id = 'chats_user_avatar' and - chats.is_owner((storage.foldername(name))[1]::uuid)); + chats.is_owner_path(name)); DROP policy IF EXISTS "storage.object_grant_delete_auth_chats_user_avatar" ON storage.objects; create policy "storage.object_grant_delete_auth_chats_user_avatar" on storage.objects for delete +to authenticated using ( bucket_id = 'chats_user_avatar' and - chats.is_owner((storage.foldername(name))[1]::uuid)); + chats.is_owner_path(name)); diff --git a/example/utils/sql/05_database_view.sql b/example/utils/sql/05_database_view.sql index b8abbae..fbd312e 100644 --- a/example/utils/sql/05_database_view.sql +++ b/example/utils/sql/05_database_view.sql @@ -1,6 +1,14 @@ DROP VIEW IF EXISTS chats.messages_l; DROP VIEW IF EXISTS chats.rooms_l; +-- Rooms the caller is a member of (RLS, security_invoker), with: +-- - "users": every member, each with its room-specific `role` folded in, +-- so callers don't need a separate room_members lookup; +-- - "name": for direct rooms, the other member's full name; +-- - "lastReadAt": when the caller last marked the room as read; +-- - "pinnedAt"/"mutedUntil": the caller's own pin/mute preferences; +-- - "unreadCount": messages from other members newer than that, capped at +-- 100 so a long-unread room never costs a full scan (show "99+"). create or replace view chats.rooms_l with (security_invoker='on') as select r.id, @@ -8,26 +16,41 @@ select r.id, r.metadata, case when r.type = 'direct' and auth.uid() is not null then - (select coalesce(u."firstName", '') || ' ' || coalesce(u."lastName", '') - from chats.users u - where u.id = any (r."userIds") - and u.id <> auth.uid() + (select nullif(trim(coalesce(u."firstName", '') || ' ' || coalesce(u."lastName", '')), '') + from chats.room_members rm + join chats.users u on u.id = rm."userId" + where rm."roomId" = r.id + and rm."userId" <> auth.uid() limit 1) else r.name end as name, r.type, - r."userIds", r."lastMessages", - r."userRoles", r."createdAt", r."updatedAt", - (select jsonb_agg(to_jsonb(u)) - from chats.users u - where u.id = any (r."userIds")) as users -from chats.rooms r; + (select jsonb_agg(to_jsonb(u) || jsonb_build_object('role', rm.role) order by rm."createdAt") + from chats.room_members rm + join chats.users u on u.id = rm."userId" + where rm."roomId" = r.id) as users, + me."lastReadAt", + me."pinnedAt", + me."mutedUntil", + (select count(*) + from (select 1 + from chats.messages m + where m."roomId" = r.id + and m."authorId" <> auth.uid() + and m."createdAt" > coalesce(me."lastReadAt", '-infinity'::timestamptz) + limit 100) unread)::int as "unreadCount" +from chats.rooms r +left join chats.room_members me + on me."roomId" = r.id and me."userId" = auth.uid(); +-- No author/room joins: flutter_chat_core's v2 Message model only carries +-- authorId (not an embedded author) and has no roomId field at all, so this +-- view is a straight passthrough of chats.messages under RLS. create or replace view chats.messages_l with (security_invoker='on') as select m.id, @@ -36,24 +59,22 @@ select m.id, m.duration, m."mimeType", m.name, - m."remoteId", - m."repliedMessage", + m."replyToMessageId", m."roomId", - m."showStatus", m.size, m.status, m.type, m."updatedAt", - m.uri, - m."waveForm", - m."isLoading", + m.source, + m.waveform, m.height, m.width, - m."previewData", + m."linkPreviewData", m."authorId", m.text, - to_jsonb(u) as author, - to_jsonb(r) as room -from chats.messages m - left join chats.users u on u.id = m."authorId" - left join chats.rooms_l r on r.id = m."roomId"; + m.reactions, + m.pinned +from chats.messages m; + +GRANT SELECT ON chats.rooms_l TO authenticated, service_role; +GRANT SELECT ON chats.messages_l TO authenticated, service_role; diff --git a/example/utils/sql/06_cleanup_media_schedule.sql b/example/utils/sql/06_cleanup_media_schedule.sql new file mode 100644 index 0000000..c24edf6 --- /dev/null +++ b/example/utils/sql/06_cleanup_media_schedule.sql @@ -0,0 +1,44 @@ +-- OPTIONAL: schedules the cleanup-media Edge Function (supabase/functions/ +-- cleanup-media), which deletes the storage objects of deleted messages +-- queued in chats.media_pending_deletion. Without it, deleted messages' +-- files simply stay in the bucket (the queue keeps growing, nothing breaks). +-- +-- Prerequisites: +-- 1. Deploy the function: supabase functions deploy cleanup-media +-- 2. Store the project URL and a service role key in Vault, once (run in +-- the SQL editor, replacing the placeholders -- never commit real keys): +-- +-- select vault.create_secret('https://.supabase.co', 'chats_project_url'); +-- select vault.create_secret('', 'chats_service_role_key'); +-- +-- Then run this file. It schedules the call every 15 minutes through pg_cron +-- + pg_net (both available on Supabase), reading the secrets at run time. +-- Re-running it is safe (the job is replaced). + +CREATE EXTENSION IF NOT EXISTS pg_net WITH SCHEMA extensions; +CREATE EXTENSION IF NOT EXISTS pg_cron; + +DO $do$ +BEGIN + IF EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'chats-cleanup-media') THEN + PERFORM cron.unschedule('chats-cleanup-media'); + END IF; + + PERFORM cron.schedule( + 'chats-cleanup-media', + '*/15 * * * *', + $sql$ + SELECT net.http_post( + url := (SELECT decrypted_secret FROM vault.decrypted_secrets WHERE name = 'chats_project_url') + || '/functions/v1/cleanup-media', + headers := jsonb_build_object( + 'Content-Type', 'application/json', + 'Authorization', 'Bearer ' || (SELECT decrypted_secret FROM vault.decrypted_secrets WHERE name = 'chats_service_role_key') + ), + body := '{}'::jsonb + ) + WHERE EXISTS (SELECT 1 FROM chats.media_pending_deletion WHERE "processedAt" IS NULL); + $sql$ + ); +END +$do$; diff --git a/example/utils/sql/99_database_schema_permission.sql b/example/utils/sql/99_database_schema_permission.sql index 69a77ca..797c7d1 100644 --- a/example/utils/sql/99_database_schema_permission.sql +++ b/example/utils/sql/99_database_schema_permission.sql @@ -5,3 +5,41 @@ GRANT ALL ON ALL SEQUENCES IN SCHEMA chats TO anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA chats GRANT ALL ON TABLES TO anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA chats GRANT ALL ON ROUTINES TO anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA chats GRANT ALL ON SEQUENCES TO anon, authenticated, service_role; + +-- The blanket grants above reopen what the previous scripts closed on +-- purpose: close it again. Internal tables (migration map, media cleanup +-- queue) stay service_role only, and the RPCs need a signed-in user. +DO $do$ +BEGIN + IF to_regclass('chats.room_id_migration_map') IS NOT NULL THEN + REVOKE ALL ON TABLE chats.room_id_migration_map FROM anon, authenticated; + END IF; +END +$do$; +REVOKE ALL ON TABLE chats.media_pending_deletion FROM anon, authenticated; +REVOKE ALL ON FUNCTION chats.create_room(text, uuid[], text, text, jsonb, text) FROM anon; +REVOKE ALL ON FUNCTION chats.mark_room_as_read(uuid) FROM anon; +REVOKE ALL ON FUNCTION chats.toggle_message_reaction(uuid, text) FROM anon; +REVOKE ALL ON FUNCTION chats.pin_room(uuid, boolean) FROM anon; +REVOKE ALL ON FUNCTION chats.mute_room(uuid, timestamptz) FROM anon; +REVOKE ALL ON FUNCTION chats.set_message_pinned(uuid, boolean) FROM anon; + +-- Trigger functions run whatever the caller's privileges (EXECUTE is only +-- checked when the trigger is created): nobody needs to call them through +-- /rest/v1/rpc. The policy helpers stay callable by authenticated users (the +-- policies run as the caller) but not by anon, which no chat policy targets. +REVOKE ALL ON FUNCTION chats.handle_new_user() FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION chats.update_last_messages() FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION chats.set_message_status_to_sent() FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION chats.queue_media_for_deletion() FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION chats.delete_room_if_empty() FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION chats.broadcast_message_change() FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION chats.ensure_messages_partitions_rls() FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION chats.is_chat_member(uuid) FROM PUBLIC, anon; +REVOKE ALL ON FUNCTION chats.is_chat_admin(uuid) FROM PUBLIC, anon; +REVOKE ALL ON FUNCTION chats.chat_member_role(uuid) FROM PUBLIC, anon; +REVOKE ALL ON FUNCTION chats.is_chat_member_path(text) FROM PUBLIC, anon; + +-- The blanket grants above also reach the chats.messages partitions, which +-- must only be accessed through the parent table: take them back. +SELECT chats.ensure_messages_partitions_rls(); diff --git a/example/utils/supabase/.temp/cli-latest b/example/utils/supabase/.temp/cli-latest new file mode 100644 index 0000000..ee671af --- /dev/null +++ b/example/utils/supabase/.temp/cli-latest @@ -0,0 +1 @@ +v2.117.0 \ No newline at end of file diff --git a/example/utils/supabase/.temp/linked-project.json b/example/utils/supabase/.temp/linked-project.json new file mode 100644 index 0000000..91ac058 --- /dev/null +++ b/example/utils/supabase/.temp/linked-project.json @@ -0,0 +1 @@ +{"ref":"pcgnergvynlaxsjrrgcw","name":"demo-chat","organization_id":"osamvvnloklihoetnwmr","organization_slug":"osamvvnloklihoetnwmr"} \ No newline at end of file diff --git a/example/utils/supabase_prepare.ps1 b/example/utils/supabase_prepare.ps1 new file mode 100644 index 0000000..5e674db --- /dev/null +++ b/example/utils/supabase_prepare.ps1 @@ -0,0 +1,328 @@ +# Automates the manual "Create a Supabase project" steps from the README: +# creates a new project (or points at an existing one) via the Supabase CLI, +# fetches its ref/anon key, writes example/lib/supabase_options_for_test.dart +# (already gitignored) and a project-secrets.local.json reference file +# (gitignored too -- never commit it), then optionally runs the SQL scripts +# against the database via `supabase link` + `supabase db query --linked`. +# +# That path deliberately avoids a raw psql/TCP connection: `supabase link` +# defaults to Supavisor's IPv4-compatible pooler and resolves the right +# regional shard for you, where a direct `db..supabase.co` connection is +# IPv6-only and simply hangs forever on a network without working IPv6 (a +# real case hit while developing this script). `supabase db query --linked` +# then runs each SQL file through that same linked connection. +# +# Requires: supabase CLI (logged in via `supabase login`). psql is only +# needed for the separate manual step-by-step procedure in MIGRATION.md. +param( + [string]$ProjectName, + [string]$OrgId, + [string]$Region +) + +$ErrorActionPreference = 'Stop' +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +# Pin `supabase link`'s local state (./supabase/.temp/...) to the repo root +# regardless of the caller's current directory, via the CLI's own --workdir +# flag -- see the SqlFiles loop below. +$repoRoot = Split-Path -Parent (Split-Path -Parent $scriptDir) + +try { + Get-Command supabase -ErrorAction Stop | Out-Null +} +catch { + Write-Error "supabase CLI was not found. See https://supabase.com/docs/guides/cli/getting-started" + exit 1 +} + +try { + # Only stdout is redirected here -- redirecting a native command's + # stderr (e.g. with `*>`) makes PowerShell 5.1 treat it as a terminating + # error under $ErrorActionPreference = 'Stop', which would abort the + # script on the very "not logged in" message we're checking for. + supabase projects list | Out-Null +} +catch { + # Not logged in yet -- fall through to the login flow below. +} +if ($LASTEXITCODE -ne 0) { + Write-Host "Not logged in to the Supabase CLI -- opening the login flow..." + supabase login + if ($LASTEXITCODE -ne 0) { + Write-Error "Supabase login failed." + exit 1 + } +} + +# Renders $Options under $Prompt and lets the user move through them with +# the Up/Down arrow keys, confirming with Enter. Returns the chosen 0-based +# index. +function Select-FromList { + param( + [Parameter(Mandatory = $true)][string]$Prompt, + [Parameter(Mandatory = $true)][string[]]$Options + ) + $selected = 0 + $padWidth = (($Options | ForEach-Object { $_.Length } | Measure-Object -Maximum).Maximum) + 6 + $drawn = $false + while ($true) { + if ($drawn) { + [Console]::SetCursorPosition(0, [Console]::CursorTop - ($Options.Count + 1)) + } + $drawn = $true + Write-Host $Prompt + for ($i = 0; $i -lt $Options.Count; $i++) { + if ($i -eq $selected) { + Write-Host (" > " + $Options[$i]).PadRight($padWidth) -ForegroundColor Cyan + } + else { + Write-Host (" " + $Options[$i]).PadRight($padWidth) + } + } + $key = [Console]::ReadKey($true) + if ($key.Key -eq [ConsoleKey]::UpArrow -and $selected -gt 0) { $selected-- } + elseif ($key.Key -eq [ConsoleKey]::DownArrow -and $selected -lt ($Options.Count - 1)) { $selected++ } + elseif ($key.Key -eq [ConsoleKey]::Enter) { return $selected } + } +} + +# Skip the create-vs-existing menu when called with -ProjectName (keeps +# scripted/non-interactive usage of the old parameters working). +$useExisting = $false +if (-not $ProjectName) { + $actionIndex = Select-FromList -Prompt "What do you want to do?" -Options @("Create a new project", "Use an existing project") + if ($actionIndex -eq 1) { $useExisting = $true } +} + +if ($useExisting) { + $projectsJson = supabase projects list --output-format json | Out-String + $projectsParsed = $projectsJson | ConvertFrom-Json + # Normalize regardless of whether this CLI version wraps the list as + # {"projects": [...]} (like "orgs list"/"api-keys" do) or not. + if ($projectsParsed.PSObject.Properties.Name -contains 'projects') { + $projects = @($projectsParsed.projects) + } + else { + $projects = @($projectsParsed) + } + if ($projects.Count -eq 0) { + Write-Error "No existing projects found." + exit 1 + } + $labels = @($projects | ForEach-Object { + $regionDisplay = if ($_.region) { $_.region } else { '?' } + "$($_.name) [$($_.id)] ($regionDisplay)" + }) + $index = Select-FromList -Prompt "Select a project (Up/Down, Enter):" -Options $labels + $ProjectName = $projects[$index].name + $ProjectRef = $projects[$index].id + $OrgId = $projects[$index].organization_id + $Region = $projects[$index].region + $DbHost = $projects[$index].database.host + if (-not $DbHost) { $DbHost = "db.$ProjectRef.supabase.co" } + + Write-Host "" + Write-Host "Supabase only shows a project's database password once, at creation time." + $securePassword = Read-Host "Database password for '$ProjectName' (leave blank to skip preparing the database now)" -AsSecureString + $DbPassword = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto( + [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($securePassword) + ) +} +else { + if (-not $OrgId) { + $orgsJson = supabase orgs list --output-format json | Out-String + $orgs = @(($orgsJson | ConvertFrom-Json).organizations) + if ($orgs.Count -eq 0) { + Write-Error "No organizations found. Create one at https://supabase.com/dashboard/org first." + exit 1 + } + elseif ($orgs.Count -eq 1) { + $OrgId = $orgs[0].id + Write-Host "Using organization: $($orgs[0].name) ($OrgId)" + } + else { + $labels = @($orgs | ForEach-Object { "$($_.name) [$($_.id)]" }) + $index = Select-FromList -Prompt "Select an organization (Up/Down, Enter):" -Options $labels + $OrgId = $orgs[$index].id + } + } + + if (-not $ProjectName) { + $ProjectName = Read-Host "Project name [demo-chat]" + if (-not $ProjectName) { $ProjectName = "demo-chat" } + } + + # Keep in sync with the --region choices listed by `supabase projects create --help`. + $Regions = @( + 'ap-east-1', 'ap-northeast-1', 'ap-northeast-2', 'ap-south-1', 'ap-southeast-1', 'ap-southeast-2', + 'ca-central-1', 'eu-central-1', 'eu-central-2', 'eu-north-1', 'eu-west-1', 'eu-west-2', 'eu-west-3', + 'sa-east-1', 'us-east-1', 'us-east-2', 'us-west-1', 'us-west-2' + ) + if (-not $Region) { + $index = Select-FromList -Prompt "Select a region (Up/Down, Enter):" -Options $Regions + $Region = $Regions[$index] + } + + # The database password can only be set at creation time and Supabase + # never shows it again -- generate a strong one instead of asking for + # it, it's saved in the secrets file below. + $DbPassword = ([guid]::NewGuid().ToString("N") + [guid]::NewGuid().ToString("N")).Substring(0, 32) + + Write-Host "Creating project '$ProjectName' (this can take a minute)..." + $createJson = supabase projects create $ProjectName --org-id $OrgId --db-password $DbPassword --region $Region --output-format json | Out-String + $create = $createJson | ConvertFrom-Json + + $ProjectRef = $create.id + if (-not $ProjectRef) { + Write-Error "Could not read the project ref from 'supabase projects create' output:`n$createJson" + exit 1 + } + + $DbHost = $create.database.host + if (-not $DbHost) { $DbHost = "db.$ProjectRef.supabase.co" } +} + +Write-Host "Fetching API keys..." +$keysJson = supabase projects api-keys --project-ref $ProjectRef --reveal --output-format json | Out-String +$keys = @(($keysJson | ConvertFrom-Json).keys) + +# Projects can carry both the new key system (type "publishable"/"secret") +# and the legacy one (name "anon"/"service_role") during the migration +# window -- prefer the new type, falling back to the legacy name. +$anonKeyEntry = $keys | Where-Object { $_.type -eq 'publishable' } | Select-Object -First 1 +if (-not $anonKeyEntry) { + $anonKeyEntry = $keys | Where-Object { $_.name -eq 'anon' } | Select-Object -First 1 +} +$serviceKeyEntry = $keys | Where-Object { $_.type -eq 'secret' } | Select-Object -First 1 +if (-not $serviceKeyEntry) { + $serviceKeyEntry = $keys | Where-Object { $_.name -eq 'service_role' } | Select-Object -First 1 +} + +if (-not $anonKeyEntry) { + Write-Error "Could not find the anon/publishable key in the API keys response. Raw output:`n$keysJson" + exit 1 +} +$AnonKey = $anonKeyEntry.api_key +$ServiceKey = if ($serviceKeyEntry) { $serviceKeyEntry.api_key } else { "" } + +$ProjectUrl = "https://$ProjectRef.supabase.co" + +$optionsPath = Join-Path $scriptDir "..\lib\supabase_options_for_test.dart" +@" +class SupabaseOptions { + final String url; + final String anonKey; + + SupabaseOptions({ + required this.url, + required this.anonKey, + }); +} + +final SupabaseOptions supabaseOptions = SupabaseOptions( + url: '$ProjectUrl', + anonKey: '$AnonKey', +); +"@ | Out-File -FilePath $optionsPath -Encoding utf8 +Write-Host "Wrote $optionsPath" + +# main.dart imports the tracked supabase_options.dart (a placeholder +# template -- never overwrite it with real secrets). main_test.dart is a +# generated, gitignored copy that imports supabase_options_for_test.dart +# instead, so `flutter run -t lib/main_test.dart` uses your real project +# without ever touching a tracked file. +$mainTestPath = Join-Path $scriptDir "..\lib\main_test.dart" +(Get-Content -Raw (Join-Path $scriptDir "..\lib\main.dart")) ` + -replace "'supabase_options\.dart'", "'supabase_options_for_test.dart'" | + Out-File -FilePath $mainTestPath -Encoding utf8 +Write-Host "Wrote $mainTestPath -- run the app with: flutter run -t lib/main_test.dart" + +$secrets = [ordered]@{ + _warning = "Contains secrets. Never commit this file (already gitignored)." + name = $ProjectName + ref = $ProjectRef + org_id = $OrgId + region = $Region + url = $ProjectUrl + anon_key = $AnonKey + service_role_key = $ServiceKey + db = [ordered]@{ + host = $DbHost + port = 5432 + database = "postgres" + user = "postgres" + password = $DbPassword + } +} +$secretsPath = Join-Path $scriptDir "project-secrets.local.json" +$secrets | ConvertTo-Json -Depth 5 | Out-File -FilePath $secretsPath -Encoding utf8 + +Write-Host "" +Write-Host "Project reference and secrets saved to: $secretsPath" +Write-Host "This file is gitignored -- never commit it." +Write-Host "" + +$dbPrepared = $false + +# Order matters: 01 creates the schema/tables, 02 the triggers, 03 the RLS +# policies, 04 the storage bucket/policies, 05 the views, 99 the final +# permission grants. +$SqlFiles = @( + '01_database_schema.sql', '02_database_trigger.sql', '03_database_policy.sql', + '04_storage.sql', '05_database_view.sql', '99_database_schema_permission.sql' +) +$linkCommand = "supabase link --project-ref $ProjectRef -p '' --workdir '$repoRoot'" +$runCommands = ($SqlFiles | ForEach-Object { "supabase db query --linked --file '$scriptDir\sql\$_' --workdir '$repoRoot'" }) -join "`n " +$manualCommand = "$linkCommand`n $runCommands" + +if (-not $DbPassword) { + Write-Host "No database password provided -- skipping database preparation. Run it yourself once you have it:" + Write-Host " $manualCommand" +} +else { + $runPrepare = Read-Host "Run the database preparation scripts now? [Y/n]" + if ($runPrepare -ne 'n' -and $runPrepare -ne 'N') { + $maxAttempts = 24 + Write-Host "Linking to the project (up to $($maxAttempts * 5)s -- a freshly created project can take a minute or two to finish provisioning)..." + $linked = $false + for ($i = 1; $i -le $maxAttempts; $i++) { + Write-Host " attempt $i/$maxAttempts..." + try { + supabase link --project-ref $ProjectRef -p $DbPassword --workdir $repoRoot 2>$null | Out-Null + if ($LASTEXITCODE -eq 0) { $linked = $true; break } + } + catch { + # Not ready yet -- keep retrying. + } + if ($i -lt $maxAttempts) { Start-Sleep -Seconds 5 } + } + + if (-not $linked) { + Write-Warning "Could not link to the project after $($maxAttempts * 5)s. Run the commands yourself once it's up:" + Write-Host " $manualCommand" + } + else { + $failed = $false + foreach ($sqlFile in $SqlFiles) { + Write-Host "Running $sqlFile..." + $path = Join-Path $scriptDir "sql\$sqlFile" + supabase db query --linked --file $path --workdir $repoRoot | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Warning "$sqlFile failed -- stopping. Fix the error, then re-run the remaining files yourself:" + Write-Host " $runCommands" + $failed = $true + break + } + } + $dbPrepared = -not $failed + } + } +} + +Write-Host "" +if ($dbPrepared) { + Write-Host "Done. In the Supabase dashboard, expose the 'chats' schema under Database > API Settings > Exposed schemas." +} +else { + Write-Host "Project created, but the database was NOT prepared yet -- run the command shown above once you can, then expose the 'chats' schema under Database > API Settings > Exposed schemas in the Supabase dashboard." +} diff --git a/example/utils/supabase_prepare.sh b/example/utils/supabase_prepare.sh new file mode 100644 index 0000000..80086e5 --- /dev/null +++ b/example/utils/supabase_prepare.sh @@ -0,0 +1,303 @@ +#!/bin/bash +# Automates the manual "Create a Supabase project" steps from the README: +# creates a new project (or points at an existing one) via the Supabase CLI, +# fetches its ref/anon key, writes example/lib/supabase_options_for_test.dart +# (already gitignored) and a project-secrets.local.json reference file +# (gitignored too — never commit it), then optionally runs the SQL scripts +# against the database via `supabase link` + `supabase db query --linked`. +# +# That path deliberately avoids a raw psql/TCP connection: `supabase link` +# defaults to Supavisor's IPv4-compatible pooler and resolves the right +# regional shard for you, where a direct `db..supabase.co` connection is +# IPv6-only and simply hangs forever on a network without working IPv6 (a +# real case hit while developing this script). `supabase db query --linked` +# then runs each SQL file through that same linked connection. +# +# Requires: supabase CLI (logged in via `supabase login`), jq. psql is only +# needed for the separate manual step-by-step procedure in MIGRATION.md. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# Pin `supabase link`'s local state (./supabase/.temp/...) to the repo root +# regardless of the caller's current directory, via the CLI's own --workdir +# flag -- see the SQL_FILES loop below. +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +command -v supabase >/dev/null 2>&1 || { + echo "supabase CLI not found. See https://supabase.com/docs/guides/cli/getting-started" >&2 + exit 1 +} +command -v jq >/dev/null 2>&1 || { + echo "jq not found. Install it (e.g. 'apt install jq', 'brew install jq') and re-run." >&2 + exit 1 +} + +if ! supabase projects list >/dev/null 2>&1; then + echo "Not logged in to the Supabase CLI — opening the login flow..." + supabase login +fi + +# Renders $1 as a heading and $2.. as a list the user can move through with +# the Up/Down arrow keys, confirming with Enter. Echoes the chosen 0-based +# index on stdout — everything else is drawn on stderr, so it's safe to +# capture with `idx=$(select_from_list ...)`. +select_from_list() { + local prompt="$1" + shift + local options=("$@") + local count=${#options[@]} + local selected=0 + local key rest + + draw() { + echo "$prompt" >&2 + local i + for i in "${!options[@]}"; do + if [[ $i -eq $selected ]]; then + printf ' \033[36m> %s\033[0m\n' "${options[$i]}" >&2 + else + printf ' %s\n' "${options[$i]}" >&2 + fi + done + } + + draw + while true; do + key="" + IFS= read -rsn1 key &2 + tput ed >&2 + draw + elif [[ -z "$key" ]]; then + break + fi + done + echo "$selected" +} + +PROJECT_NAME_ARG="${1:-}" +ORG_ID_ARG="${2:-}" +REGION_ARG="${3:-}" + +# Skip the create-vs-existing menu when called with a project name argument +# (keeps scripted/non-interactive usage of the old positional args working). +USE_EXISTING=0 +if [[ -z "$PROJECT_NAME_ARG" ]]; then + ACTIONS=("Create a new project" "Use an existing project") + IDX="$(select_from_list "What do you want to do?" "${ACTIONS[@]}")" + [[ "$IDX" -eq 1 ]] && USE_EXISTING=1 +fi + +if [[ "$USE_EXISTING" -eq 1 ]]; then + PROJECTS_JSON="$(supabase projects list --output-format json)" + # Normalize to a bare array regardless of whether this CLI version wraps + # it as {"projects": [...]} (like "orgs list"/"api-keys" do) or not. + PROJECTS_JSON="$(jq 'if (type == "object") and has("projects") then .projects else . end' <<<"$PROJECTS_JSON")" + PROJECT_COUNT="$(jq 'length' <<<"$PROJECTS_JSON")" + if [[ "$PROJECT_COUNT" -eq 0 ]]; then + echo "No existing projects found." >&2 + exit 1 + fi + mapfile -t PROJECT_LABELS < <(jq -r '.[] | "\(.name) [\(.id)] (\(.region // "?"))"' <<<"$PROJECTS_JSON") + IDX="$(select_from_list "Select a project (↑/↓, Enter):" "${PROJECT_LABELS[@]}")" + PROJECT_NAME="$(jq -r --argjson idx "$IDX" '.[$idx].name // empty' <<<"$PROJECTS_JSON")" + PROJECT_REF="$(jq -r --argjson idx "$IDX" '.[$idx].id // empty' <<<"$PROJECTS_JSON")" + ORG_ID="$(jq -r --argjson idx "$IDX" '.[$idx].organization_id // empty' <<<"$PROJECTS_JSON")" + REGION="$(jq -r --argjson idx "$IDX" '.[$idx].region // empty' <<<"$PROJECTS_JSON")" + DB_HOST="$(jq -r --argjson idx "$IDX" '.[$idx].database.host // empty' <<<"$PROJECTS_JSON")" + [[ -z "$DB_HOST" ]] && DB_HOST="db.${PROJECT_REF}.supabase.co" + + echo + echo "Supabase only shows a project's database password once, at creation time." + read -rsp "Database password for '$PROJECT_NAME' (leave blank to skip preparing the database now): " DB_PASSWORD + echo +else + ORG_ID="$ORG_ID_ARG" + if [[ -z "$ORG_ID" ]]; then + ORGS_JSON="$(supabase orgs list --output-format json)" + ORG_COUNT="$(jq '.organizations | length' <<<"$ORGS_JSON")" + if [[ "$ORG_COUNT" -eq 0 ]]; then + echo "No organizations found. Create one at https://supabase.com/dashboard/org first." >&2 + exit 1 + elif [[ "$ORG_COUNT" -eq 1 ]]; then + ORG_ID="$(jq -r '.organizations[0].id' <<<"$ORGS_JSON")" + echo "Using organization: $(jq -r '.organizations[0].name' <<<"$ORGS_JSON") ($ORG_ID)" + else + mapfile -t ORG_LABELS < <(jq -r '.organizations[] | "\(.name) [\(.id)]"' <<<"$ORGS_JSON") + IDX="$(select_from_list "Select an organization (↑/↓, Enter):" "${ORG_LABELS[@]}")" + ORG_ID="$(jq -r --argjson idx "$IDX" '.organizations[$idx].id' <<<"$ORGS_JSON")" + fi + fi + + PROJECT_NAME="$PROJECT_NAME_ARG" + if [[ -z "$PROJECT_NAME" ]]; then + read -rp "Project name [demo-chat]: " PROJECT_NAME + PROJECT_NAME="${PROJECT_NAME:-demo-chat}" + fi + + # Keep in sync with the --region choices listed by `supabase projects create --help`. + REGIONS=(ap-east-1 ap-northeast-1 ap-northeast-2 ap-south-1 ap-southeast-1 ap-southeast-2 + ca-central-1 eu-central-1 eu-central-2 eu-north-1 eu-west-1 eu-west-2 eu-west-3 + sa-east-1 us-east-1 us-east-2 us-west-1 us-west-2) + + REGION="$REGION_ARG" + if [[ -z "$REGION" ]]; then + IDX="$(select_from_list "Select a region (↑/↓, Enter):" "${REGIONS[@]}")" + REGION="${REGIONS[$IDX]}" + fi + + # The database password can only be set at creation time and Supabase + # never shows it again — generate a strong one instead of asking for it, + # it's saved in the secrets file below. + DB_PASSWORD="$(openssl rand -base64 24)" + + echo "Creating project '$PROJECT_NAME' (this can take a minute)..." + CREATE_JSON="$(supabase projects create "$PROJECT_NAME" --org-id "$ORG_ID" --db-password "$DB_PASSWORD" --region "$REGION" --output-format json)" + + PROJECT_REF="$(jq -r '.id // empty' <<<"$CREATE_JSON")" + if [[ -z "$PROJECT_REF" ]]; then + echo "Could not read the project ref from 'supabase projects create' output:" >&2 + echo "$CREATE_JSON" >&2 + exit 1 + fi + + DB_HOST="$(jq -r '.database.host // empty' <<<"$CREATE_JSON")" + [[ -z "$DB_HOST" ]] && DB_HOST="db.${PROJECT_REF}.supabase.co" +fi + +echo "Fetching API keys..." +KEYS_JSON="$(supabase projects api-keys --project-ref "$PROJECT_REF" --reveal --output-format json)" + +# Projects can carry both the new key system (type "publishable"/"secret") +# and the legacy one (name "anon"/"service_role") during the migration +# window -- prefer the new type, falling back to the legacy name. +ANON_KEY="$(jq -r ' + ([.keys[] | select(.type=="publishable")] + [.keys[] | select(.name=="anon")])[0].api_key // empty +' <<<"$KEYS_JSON")" +SERVICE_KEY="$(jq -r ' + ([.keys[] | select(.type=="secret")] + [.keys[] | select(.name=="service_role")])[0].api_key // empty +' <<<"$KEYS_JSON")" + +if [[ -z "$ANON_KEY" ]]; then + echo "Could not find the anon/publishable key in the API keys response. Raw output:" >&2 + echo "$KEYS_JSON" >&2 + exit 1 +fi + +PROJECT_URL="https://${PROJECT_REF}.supabase.co" + +OPTIONS_FILE="$SCRIPT_DIR/../lib/supabase_options_for_test.dart" +cat >"$OPTIONS_FILE" <"$MAIN_TEST_FILE" +echo "Wrote $MAIN_TEST_FILE — run the app with: flutter run -t lib/main_test.dart" + +SECRETS_FILE="$SCRIPT_DIR/project-secrets.local.json" +jq -n \ + --arg name "$PROJECT_NAME" \ + --arg ref "$PROJECT_REF" \ + --arg org_id "$ORG_ID" \ + --arg region "$REGION" \ + --arg url "$PROJECT_URL" \ + --arg anon_key "$ANON_KEY" \ + --arg service_role_key "$SERVICE_KEY" \ + --arg db_host "$DB_HOST" \ + --arg db_password "$DB_PASSWORD" \ + '{ + _warning: "Contains secrets. Never commit this file (already gitignored).", + name: $name, ref: $ref, org_id: $org_id, region: $region, url: $url, + anon_key: $anon_key, service_role_key: $service_role_key, + db: {host: $db_host, port: 5432, database: "postgres", user: "postgres", password: $db_password} + }' >"$SECRETS_FILE" +chmod 600 "$SECRETS_FILE" + +echo +echo "Project reference and secrets saved to: $SECRETS_FILE" +echo "This file is gitignored — never commit it." +echo + +DB_PREPARED=0 + +# Order matters: 01 creates the schema/tables, 02 the triggers, 03 the RLS +# policies, 04 the storage bucket/policies, 05 the views, 99 the final +# permission grants. +SQL_FILES=(01_database_schema.sql 02_database_trigger.sql 03_database_policy.sql + 04_storage.sql 05_database_view.sql 99_database_schema_permission.sql) +LINK_CMD="supabase link --project-ref $PROJECT_REF -p '' --workdir '$REPO_ROOT'" +RUN_CMDS="$(for f in "${SQL_FILES[@]}"; do echo "supabase db query --linked --file '$SCRIPT_DIR/sql/$f' --workdir '$REPO_ROOT'"; done)" +MANUAL_CMD="$LINK_CMD +$RUN_CMDS" + +if [[ -z "$DB_PASSWORD" ]]; then + echo "No database password provided — skipping database preparation. Run it yourself once you have it:" + echo " $MANUAL_CMD" +else + read -rp "Run the database preparation scripts now? [Y/n] " RUN_PREPARE + if [[ "${RUN_PREPARE:-Y}" != "n" && "${RUN_PREPARE:-Y}" != "N" ]]; then + MAX_ATTEMPTS=24 + echo "Linking to the project (up to $((MAX_ATTEMPTS * 5))s — a freshly created project can take a minute or two to finish provisioning)..." + LINKED=0 + for i in $(seq 1 "$MAX_ATTEMPTS"); do + echo " attempt $i/$MAX_ATTEMPTS..." + if supabase link --project-ref "$PROJECT_REF" -p "$DB_PASSWORD" --workdir "$REPO_ROOT" >/dev/null 2>&1; then + LINKED=1 + break + fi + [[ "$i" -lt "$MAX_ATTEMPTS" ]] && sleep 5 + done + + if [[ "$LINKED" -ne 1 ]]; then + echo "Could not link to the project after $((MAX_ATTEMPTS * 5))s. Run the commands yourself once it's up:" >&2 + echo " $MANUAL_CMD" >&2 + else + FAILED=0 + for f in "${SQL_FILES[@]}"; do + echo "Running $f..." + if ! supabase db query --linked --file "$SCRIPT_DIR/sql/$f" --workdir "$REPO_ROOT"; then + echo "$f failed — stopping. Fix the error, then re-run the remaining files yourself:" >&2 + echo " $RUN_CMDS" >&2 + FAILED=1 + break + fi + done + [[ "$FAILED" -eq 0 ]] && DB_PREPARED=1 + fi + fi +fi + +echo +if [[ "$DB_PREPARED" -eq 1 ]]; then + echo "Done. In the Supabase dashboard, expose the 'chats' schema under Database > API Settings > Exposed schemas." +else + echo "Project created, but the database was NOT prepared yet — run the command shown above once you can, then expose the 'chats' schema under Database > API Settings > Exposed schemas in the Supabase dashboard." +fi diff --git a/example/windows/flutter/generated_plugin_registrant.cc b/example/windows/flutter/generated_plugin_registrant.cc index 6931037..442c760 100644 --- a/example/windows/flutter/generated_plugin_registrant.cc +++ b/example/windows/flutter/generated_plugin_registrant.cc @@ -7,17 +7,23 @@ #include "generated_plugin_registrant.h" #include +#include #include #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { AppLinksPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("AppLinksPluginCApi")); + AudioplayersWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin")); FileSaverPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSaverPlugin")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); + RecordWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("RecordWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/example/windows/flutter/generated_plugins.cmake b/example/windows/flutter/generated_plugins.cmake index 348d212..6669906 100644 --- a/example/windows/flutter/generated_plugins.cmake +++ b/example/windows/flutter/generated_plugins.cmake @@ -4,12 +4,15 @@ list(APPEND FLUTTER_PLUGIN_LIST app_links + audioplayers_windows file_saver file_selector_windows + record_windows url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/lib/flutter_supabase_chat_core.dart b/lib/flutter_supabase_chat_core.dart index 7909689..cfa3f78 100644 --- a/lib/flutter_supabase_chat_core.dart +++ b/lib/flutter_supabase_chat_core.dart @@ -1,8 +1,19 @@ +/// Supabase backend for chat apps built on `flutter_chat_core` / +/// `flutter_chat_ui`: rooms, members, messages, attachments, read receipts, +/// reactions, typing indicators and online status, over Postgres + RLS and +/// Supabase Realtime. library; +export 'src/class/chat_room.dart'; +export 'src/class/chat_room_change.dart'; +export 'src/class/chat_room_role.dart'; +export 'src/class/chat_room_type.dart'; +export 'src/class/chat_user.dart'; export 'src/class/supabase_chat_controller.dart'; export 'src/class/supabase_chat_core.dart'; export 'src/class/supabase_chat_core_config.dart'; +export 'src/class/supabase_chat_core_exception.dart'; +export 'src/class/supabase_chat_rooms_controller.dart'; export 'src/class/upload_asset_result.dart'; export 'src/class/user_online_status.dart'; export 'src/util.dart'; diff --git a/lib/src/class/chat_room.dart b/lib/src/class/chat_room.dart new file mode 100644 index 0000000..9977619 --- /dev/null +++ b/lib/src/class/chat_room.dart @@ -0,0 +1,204 @@ +import 'package:flutter_chat_core/flutter_chat_core.dart' as core; +import 'package:meta/meta.dart'; + +import '../util.dart'; +import 'chat_room_role.dart'; +import 'chat_room_type.dart'; +import 'chat_user.dart'; + +/// Represents a room/conversation stored in the `rooms` table. +/// +/// `flutter_chat_core` v2 dropped the concept of a "room" entirely — it only +/// deals with messages and users. This class fills that gap and stays +/// specific to this package. +@immutable +class ChatRoom { + /// Creates a room. + const ChatRoom({ + required this.id, + this.imageUrl, + this.metadata, + this.name, + required this.type, + required this.users, + this.lastMessages, + this.createdAt, + this.updatedAt, + this.unreadCount = 0, + this.lastReadAt, + this.pinnedAt, + this.mutedUntil, + }); + + /// Builds a room out of a `rooms_l` view row. + /// + /// For a [ChatRoomType.direct] room, [name] and [imageUrl] are taken from + /// the member other than [currentUserId] (the view already computes the + /// name, this also fills in the avatar). + factory ChatRoom.fromJson( + Map json, { + String? currentUserId, + }) { + final type = + ChatRoomType.values.asNameMap()[json['type'] as String?] ?? + ChatRoomType.group; + final users = ((json['users'] as List?) ?? const []) + .map((u) => ChatUser.fromJson((u as Map).cast())) + .toList(); + + var name = json['name'] as String?; + var imageUrl = json['imageUrl'] as String?; + if (type == ChatRoomType.direct && currentUserId != null) { + for (final user in users) { + if (user.id != currentUserId) { + imageUrl = user.imageUrl; + final fullName = user.fullName; + if (fullName.isNotEmpty) name = fullName; + break; + } + } + } + + final rawLastMessages = json['lastMessages'] as List?; + return ChatRoom( + id: json['id'].toString(), + imageUrl: imageUrl, + metadata: (json['metadata'] as Map?)?.cast(), + name: name?.trim().isEmpty ?? true ? null : name!.trim(), + type: type, + users: users, + lastMessages: rawLastMessages + ?.whereType() + .map((m) => messageFromRow(m.cast())) + .toList(), + createdAt: parseChatDate(json['createdAt']), + updatedAt: parseChatDate(json['updatedAt']), + unreadCount: (json['unreadCount'] as num?)?.toInt() ?? 0, + lastReadAt: parseChatDate(json['lastReadAt']), + pinnedAt: parseChatDate(json['pinnedAt']), + mutedUntil: _parseMutedUntil(json['mutedUntil']), + ); + } + + // Postgres serializes the timestamptz 'infinity' (muted for ever) as the + // literal string "infinity", which DateTime can't represent. + static DateTime? _parseMutedUntil(Object? value) => + value == 'infinity' ? mutedForever : parseChatDate(value); + + /// [mutedUntil] value meaning "muted until unmuted". + static final DateTime mutedForever = DateTime.utc(9999, 12, 31); + + /// Unique identifier (uuid). + final String id; + + /// Room avatar URL. For a direct room, the other member's avatar. + final String? imageUrl; + + /// Additional custom data. + final Map? metadata; + + /// Room name. For a direct room, the other member's full name. + final String? name; + + /// Direct (1-to-1) or group room. + final ChatRoomType type; + + /// Room members, each carrying its room-specific [ChatUser.role]. + final List users; + + /// Preview of the most recent message(s), already converted to + /// `flutter_chat_core`'s [core.Message]. + final List? lastMessages; + + /// When the room was created. + final DateTime? createdAt; + + /// When the room was last updated (bumped by every new message). + final DateTime? updatedAt; + + /// Messages from other members newer than [lastReadAt] (for the current + /// user). Always `0` for rooms not loaded from the `rooms_l` view. + final int unreadCount; + + /// When the current user last marked this room as read. + final DateTime? lastReadAt; + + /// When the current user pinned this room on top of their list (`null` + /// when not pinned). Per user. + final DateTime? pinnedAt; + + /// Until when the current user muted this room (`null` when not muted, + /// [mutedForever] for ever). Per user. + final DateTime? mutedUntil; + + /// Whether the current user pinned this room. + bool get isPinned => pinnedAt != null; + + /// Whether the room is currently muted for the current user. + bool get isMuted => + mutedUntil != null && mutedUntil!.isAfter(DateTime.now().toUtc()); + + /// The most recent message, if any. + core.Message? get lastMessage => lastMessages == null || lastMessages!.isEmpty + ? null + : lastMessages!.first; + + /// The member with id [userId], if any. + ChatUser? member(String userId) { + for (final user in users) { + if (user.id == userId) return user; + } + return null; + } + + /// Role of the member with id [userId] (`null` for a plain member or a + /// non-member). + ChatRoomRole? roleOf(String userId) => member(userId)?.role; + + /// For a direct room, the member that isn't [currentUserId]. + ChatUser? otherUser(String currentUserId) { + if (type != ChatRoomType.direct) return null; + for (final user in users) { + if (user.id != currentUserId) return user; + } + return null; + } + + /// Returns a copy with the given fields replaced. [pinnedAt] and + /// [mutedUntil] take a function so they can be reset to `null`. + ChatRoom copyWith({ + String? imageUrl, + Map? metadata, + String? name, + List? users, + List? lastMessages, + DateTime? updatedAt, + int? unreadCount, + DateTime? lastReadAt, + DateTime? Function()? pinnedAt, + DateTime? Function()? mutedUntil, + }) => ChatRoom( + id: id, + imageUrl: imageUrl ?? this.imageUrl, + metadata: metadata ?? this.metadata, + name: name ?? this.name, + type: type, + users: users ?? this.users, + lastMessages: lastMessages ?? this.lastMessages, + createdAt: createdAt, + updatedAt: updatedAt ?? this.updatedAt, + unreadCount: unreadCount ?? this.unreadCount, + lastReadAt: lastReadAt ?? this.lastReadAt, + pinnedAt: pinnedAt != null ? pinnedAt() : this.pinnedAt, + mutedUntil: mutedUntil != null ? mutedUntil() : this.mutedUntil, + ); + + @override + bool operator ==(Object other) => other is ChatRoom && other.id == id; + + @override + int get hashCode => id.hashCode; + + @override + String toString() => 'ChatRoom($id, ${type.name}, $name)'; +} diff --git a/lib/src/class/chat_room_change.dart b/lib/src/class/chat_room_change.dart new file mode 100644 index 0000000..d5f741a --- /dev/null +++ b/lib/src/class/chat_room_change.dart @@ -0,0 +1,29 @@ +import 'package:meta/meta.dart'; + +import 'chat_room.dart'; + +/// A change to the list of rooms the logged-in user is a member of, as +/// emitted by `SupabaseChatCore.roomsChanges`. +@immutable +sealed class ChatRoomChange { + const ChatRoomChange(); +} + +/// A room was created, updated (e.g. it received a new message), or the +/// user was added to it. +final class ChatRoomUpserted extends ChatRoomChange { + /// Creates an upsert change. + const ChatRoomUpserted(this.room); + + /// The room, freshly loaded from the rooms view. + final ChatRoom room; +} + +/// A room was deleted, or the user is no longer a member of it. +final class ChatRoomRemoved extends ChatRoomChange { + /// Creates a removal change. + const ChatRoomRemoved(this.roomId); + + /// Id of the room that is gone. + final String roomId; +} diff --git a/lib/src/class/chat_room_role.dart b/lib/src/class/chat_room_role.dart new file mode 100644 index 0000000..0aa2995 --- /dev/null +++ b/lib/src/class/chat_room_role.dart @@ -0,0 +1,19 @@ +/// Role of a [ChatUser] inside a [ChatRoom]. A plain member has no role +/// (`null`). +enum ChatRoomRole { + /// Can rename the room, manage its members and their roles, delete it. + admin, + + /// Reserved for app-specific moderation rules. + moderator, +} + +/// Parses a [ChatRoomRole] by name, returning `null` for `null` or unknown +/// values instead of throwing. +ChatRoomRole? chatRoomRoleFromName(String? name) { + if (name == null) return null; + for (final role in ChatRoomRole.values) { + if (role.name == name) return role; + } + return null; +} diff --git a/lib/src/class/chat_room_type.dart b/lib/src/class/chat_room_type.dart new file mode 100644 index 0000000..c1920cf --- /dev/null +++ b/lib/src/class/chat_room_type.dart @@ -0,0 +1,8 @@ +/// Type of a [ChatRoom]. +enum ChatRoomType { + /// 1-to-1 conversation between two users. + direct, + + /// Conversation between more than two users. + group, +} diff --git a/lib/src/class/chat_user.dart b/lib/src/class/chat_user.dart new file mode 100644 index 0000000..91e15fa --- /dev/null +++ b/lib/src/class/chat_user.dart @@ -0,0 +1,139 @@ +import 'package:flutter_chat_core/flutter_chat_core.dart' as core; +import 'package:meta/meta.dart'; + +import '../util.dart'; +import 'chat_room_role.dart'; + +/// Represents a user stored in the `users` table. +/// +/// This is the package's own user model: `flutter_chat_core`'s [core.User] +/// only carries `id`, `name`, `imageSource`, `createdAt` and `metadata`, so it +/// cannot represent [firstName]/[lastName]/[role] on its own. Use +/// [toCoreUser] at the UI boundary (e.g. for `Chat.resolveUser`) to obtain a +/// `flutter_chat_core` [core.User]. +@immutable +class ChatUser { + /// Creates a user. + const ChatUser({ + required this.id, + this.firstName, + this.lastName, + this.imageUrl, + this.role, + this.metadata, + this.createdAt, + this.updatedAt, + this.lastSeen, + }); + + /// Builds a user out of a `users` row (or an element of a room's embedded + /// `users` array, which also carries the room-specific `role`). + factory ChatUser.fromJson(Map json) => ChatUser( + id: json['id'] as String, + firstName: json['firstName'] as String?, + lastName: json['lastName'] as String?, + imageUrl: json['imageUrl'] as String?, + role: chatRoomRoleFromName(json['role'] as String?), + metadata: (json['metadata'] as Map?)?.cast(), + createdAt: parseChatDate(json['createdAt']), + updatedAt: parseChatDate(json['updatedAt']), + lastSeen: parseChatDate(json['lastSeen']), + ); + + /// Unique identifier, matches Supabase `auth.users.id`. + final String id; + + /// First name, if set. + final String? firstName; + + /// Last name, if set. + final String? lastName; + + /// Avatar URL, if set. + final String? imageUrl; + + /// Role inside the room this user was loaded from (`null` for a plain + /// member, or when the user wasn't loaded in the context of a room). + final ChatRoomRole? role; + + /// Additional custom data. + final Map? metadata; + + /// When the user was created. + final DateTime? createdAt; + + /// When the user was last updated. + final DateTime? updatedAt; + + /// When the user was last seen. + final DateTime? lastSeen; + + /// [firstName] and [lastName] joined by a space, trimmed. + String get fullName => [ + firstName, + lastName, + ].where((e) => e != null && e.isNotEmpty).join(' ').trim(); + + /// Up to two uppercase initials from [firstName]/[lastName] (e.g. `JD`), + /// handy for avatar placeholders. Empty when neither is set. + String get initials => [firstName, lastName] + .where((e) => e != null && e.trim().isNotEmpty) + .map((e) => e!.trim()[0].toUpperCase()) + .join(); + + /// Serializes this user with the same keys [ChatUser.fromJson] reads. + Map toJson() => { + 'id': id, + 'firstName': firstName, + 'lastName': lastName, + 'imageUrl': imageUrl, + 'role': role?.name, + 'metadata': metadata, + 'createdAt': createdAt?.toUtc().toIso8601String(), + 'updatedAt': updatedAt?.toUtc().toIso8601String(), + 'lastSeen': lastSeen?.toUtc().toIso8601String(), + }; + + /// Returns a copy with the given fields replaced. + ChatUser copyWith({ + String? firstName, + String? lastName, + String? imageUrl, + ChatRoomRole? role, + Map? metadata, + DateTime? updatedAt, + DateTime? lastSeen, + }) => ChatUser( + id: id, + firstName: firstName ?? this.firstName, + lastName: lastName ?? this.lastName, + imageUrl: imageUrl ?? this.imageUrl, + role: role ?? this.role, + metadata: metadata ?? this.metadata, + createdAt: createdAt, + updatedAt: updatedAt ?? this.updatedAt, + lastSeen: lastSeen ?? this.lastSeen, + ); + + /// Converts this user to the [core.User] model expected by + /// `flutter_chat_core` / `flutter_chat_ui` (e.g. `Chat.resolveUser`). + /// + /// [role] has no equivalent field on [core.User] and is added to + /// `metadata['role']` instead. + core.User toCoreUser() => core.User( + id: id, + name: fullName.isEmpty ? null : fullName, + imageSource: imageUrl, + createdAt: createdAt, + metadata: {if (role != null) 'role': role!.name, ...?metadata}, + ); + + @override + bool operator ==(Object other) => other is ChatUser && other.id == id; + + @override + int get hashCode => id.hashCode; + + @override + String toString() => 'ChatUser($id, $fullName)'; +} diff --git a/lib/src/class/presence_watch.dart b/lib/src/class/presence_watch.dart new file mode 100644 index 0000000..307b786 --- /dev/null +++ b/lib/src/class/presence_watch.dart @@ -0,0 +1,84 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import 'user_online_status.dart'; + +/// Observes one user's Presence channel on behalf of every listener of +/// `SupabaseChatCore.userOnlineStatus` for that user, so N widgets showing +/// the same user's status share a single Realtime channel. +@internal +class PresenceWatch { + /// Subscribes to [topic] right away. + PresenceWatch({ + required this.client, + required this.topic, + required this.uid, + required this.onEmpty, + }) { + _channel = client.channel(topic) + ..onPresenceSync((_) => _sync()) + ..subscribe(); + } + + /// Client the channel lives on. + final SupabaseClient client; + + /// Presence channel topic (``). + final String topic; + + /// Observed user. + final String uid; + + /// Called once the last listener is removed, right before the channel is + /// released. + final void Function() onEmpty; + + late final RealtimeChannel _channel; + final Set> _listeners = {}; + UserOnlineStatus _status = UserOnlineStatus.offline; + bool _disposed = false; + + void _sync() { + final online = _channel.presenceState().any( + (state) => state.presences.any((p) => p.payload['uid'] == uid), + ); + final status = online ? UserOnlineStatus.online : UserOnlineStatus.offline; + if (status == _status) return; + _status = status; + for (final listener in _listeners.toList()) { + listener.add(status); + } + } + + /// Adds [listener], emitting the current status to it right away. + void addListener(MultiStreamController listener) { + _listeners.add(listener); + listener.add(_status); + } + + /// Removes [listener]; releases the channel when it was the last one. + Future removeListener( + MultiStreamController listener, + ) async { + _listeners.remove(listener); + if (_listeners.isEmpty) { + // Unregister first, so a watch requested while this one is still + // shutting down is a fresh one rather than this disposed instance. + onEmpty(); + await dispose(); + } + } + + /// Releases the channel. + Future dispose() async { + if (_disposed) return; + _disposed = true; + for (final listener in _listeners.toList()) { + await listener.close(); + } + _listeners.clear(); + await client.removeChannel(_channel); + } +} diff --git a/lib/src/class/room_channel.dart b/lib/src/class/room_channel.dart new file mode 100644 index 0000000..5a2c34e --- /dev/null +++ b/lib/src/class/room_channel.dart @@ -0,0 +1,270 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +/// Signature of a [RoomChannel] typing listener: receives the ids of the +/// users currently typing in the room (never including the local user). +typedef TypingListener = void Function(Set typingUserIds); + +/// Signature of a [RoomChannel] message listener, called for every +/// INSERT/UPDATE/DELETE on the room's messages. +typedef MessageChangeListener = + void Function( + String operation, + Map record, + Map oldRecord, + ); + +/// A room's private Supabase Realtime **Broadcast** channel +/// (``), shared by every controller open on that room +/// (Realtime only allows one join per topic per connection). It carries: +/// +/// - **message changes**, sent by the database itself (the +/// `chats.broadcast_message_change` trigger calls +/// `realtime.broadcast_changes`). Postgres Changes can't be used for +/// messages: `chats.messages` is partitioned, and Postgres Changes +/// reports rows under their physical partition, never matching a +/// subscription on the parent table; +/// - the **typing indicator**, sent by clients. +/// +/// The channel is private: Realtime authorizes joining (reading) and +/// sending against RLS policies on `realtime.messages`, which only let room +/// members in (see 03_database_policy.sql). +/// +/// ## Typing indicator (issue #38) +/// +/// Broadcast instead of Presence: Supabase rate-limits Presence `track()` +/// calls per client (a handful per 30s window) and drops the channel when +/// the limit is exceeded, which a typing indicator updating every few +/// hundred milliseconds hits within seconds. Broadcast messages are cheap, +/// ephemeral and not rate-limited at that scale. +/// +/// Protocol, per user: while typing, a `typing` event with `typing: true` at +/// most once every [sendInterval]; a `typing: false` event once the user +/// stops ([stopTyping], or [idleTimeout] after the last keystroke). +/// Receivers drop a user that hasn't sent a `typing: true` within +/// [expiryTimeout], so a lost "stopped" event (app killed, connection lost) +/// can never leave a stale indicator behind. +/// +/// Trade-off: Broadcast has no state, so someone opening the room while +/// another user is already typing only sees the indicator from that user's +/// next event (at most [sendInterval] later). +@internal +class RoomChannel { + /// Joins [topic] right away. + RoomChannel({ + required this.client, + required this.topic, + required this.onEmpty, + this.sendInterval = const Duration(seconds: 2), + this.idleTimeout = const Duration(seconds: 3), + this.expiryTimeout = const Duration(seconds: 5), + }) { + _channel = client.channel( + topic, + opts: const RealtimeChannelConfig(private: true), + ); + _channel.onBroadcast(event: typingEvent, callback: _onTyping); + for (final operation in const ['INSERT', 'UPDATE', 'DELETE']) { + _channel.onBroadcast( + event: operation, + callback: (message) => _onMessageChange(operation, message), + ); + } + _channel.subscribe((status, error) { + _subscribed = status == RealtimeSubscribeStatus.subscribed; + if (status == RealtimeSubscribeStatus.channelError && + '$error'.contains('Unauthorized')) { + // Not (or no longer) a member: retrying can't succeed, and the + // client would otherwise keep rejoining with backoff for as long as + // the connection lives, piling up subscription errors server side. + _unauthorized = true; + unawaited(client.removeChannel(_channel)); + } + if (status == RealtimeSubscribeStatus.channelError || + status == RealtimeSubscribeStatus.timedOut) { + for (final listener in _errorListeners.toList()) { + listener(error ?? 'Realtime channel $topic: ${status.name}'); + } + } + }); + } + + /// Broadcast event name of typing updates. + static const typingEvent = 'typing'; + + /// Client the channel lives on. + final SupabaseClient client; + + /// Channel topic (``). + final String topic; + + /// Called once the last listener is removed, right before the channel is + /// released. + final void Function() onEmpty; + + /// Minimum interval between two `typing: true` events from this client. + final Duration sendInterval; + + /// Inactivity after which this client announces it stopped typing. + final Duration idleTimeout; + + /// How long a remote user stays "typing" without a fresh event. + final Duration expiryTimeout; + + late final RealtimeChannel _channel; + bool _subscribed = false; + bool _disposed = false; + bool _unauthorized = false; + final Set _typingListeners = {}; + final Set _messageListeners = {}; + final Set _errorListeners = {}; + final Map _typing = {}; + + DateTime? _lastSentAt; + String? _typingAs; + Timer? _idleTimer; + + /// Whether the channel is currently joined. + bool get isSubscribed => _subscribed; + + /// Whether Realtime refused the channel (the user isn't a member of the + /// room). The channel is released and won't be retried. + bool get isUnauthorized => _unauthorized; + + /// Ids of the remote users currently typing. + Set get typingUserIds => Set.unmodifiable(_typing.keys); + + static Map _payloadOf(Map message) => + message['payload'] is Map + ? (message['payload'] as Map).cast() + : message; + + void _onMessageChange(String operation, Map message) { + final payload = _payloadOf(message); + final record = (payload['record'] as Map?)?.cast(); + final oldRecord = (payload['old_record'] as Map?)?.cast(); + for (final listener in _messageListeners.toList()) { + listener(operation, record ?? const {}, oldRecord ?? const {}); + } + } + + void _onTyping(Map message) { + final payload = _payloadOf(message); + final uid = payload['uid']?.toString(); + if (uid == null || uid == _typingAs) return; + if (payload['typing'] == true) { + _typing[uid]?.cancel(); + _typing[uid] = Timer(expiryTimeout, () { + _typing.remove(uid); + _notifyTyping(); + }); + } else { + _typing.remove(uid)?.cancel(); + } + _notifyTyping(); + } + + void _notifyTyping() { + final ids = typingUserIds; + for (final listener in _typingListeners.toList()) { + listener(ids); + } + } + + Future _sendTyping(String uid, bool typing) async { + if (!_subscribed || _disposed) return; + try { + await _channel.sendBroadcastMessage( + event: typingEvent, + payload: {'uid': uid, 'typing': typing}, + ); + } on Object { + // Typing indicators are best effort: a dropped event is corrected by + // the next one, or by the receivers' expiry timer. + } + } + + /// Call on every keystroke of user [uid]: throttled to one event every + /// [sendInterval], and followed by a "stopped" event after [idleTimeout] + /// of inactivity. + void typing(String uid) { + _typingAs = uid; + _idleTimer?.cancel(); + _idleTimer = Timer(idleTimeout, () => stopTyping(uid)); + final now = DateTime.now(); + final last = _lastSentAt; + if (last != null && now.difference(last) < sendInterval) return; + _lastSentAt = now; + unawaited(_sendTyping(uid, true)); + } + + /// Announces that user [uid] stopped typing (e.g. on message sent). No-op + /// if it wasn't typing. + Future stopTyping(String uid) async { + _idleTimer?.cancel(); + _idleTimer = null; + if (_lastSentAt == null) return; + _lastSentAt = null; + await _sendTyping(uid, false); + } + + /// Adds a typing [listener], calling it right away with the current state. + void addTypingListener(TypingListener listener) { + _typingListeners.add(listener); + listener(typingUserIds); + } + + /// Removes a typing [listener]; see [_releaseIfUnused]. + Future removeTypingListener(TypingListener listener) async { + _typingListeners.remove(listener); + await _releaseIfUnused(); + } + + /// Adds a message change [listener], and an optional [onError] called + /// when joining the channel fails (e.g. not authorized). + void addMessageListener( + MessageChangeListener listener, { + void Function(Object error)? onError, + }) { + _messageListeners.add(listener); + if (onError != null) _errorListeners.add(onError); + } + + /// Removes a message change [listener] (and its [onError]); see + /// [_releaseIfUnused]. + Future removeMessageListener( + MessageChangeListener listener, { + void Function(Object error)? onError, + }) async { + _messageListeners.remove(listener); + if (onError != null) _errorListeners.remove(onError); + await _releaseIfUnused(); + } + + Future _releaseIfUnused() async { + if (_typingListeners.isNotEmpty || _messageListeners.isNotEmpty) return; + // Unregister first, so a channel requested while this one is still + // shutting down is a fresh one rather than this disposed instance. + onEmpty(); + final uid = _typingAs; + if (uid != null) await stopTyping(uid); + await dispose(); + } + + /// Releases the channel and timers. + Future dispose() async { + if (_disposed) return; + _disposed = true; + _idleTimer?.cancel(); + for (final timer in _typing.values) { + timer.cancel(); + } + _typing.clear(); + _typingListeners.clear(); + _messageListeners.clear(); + _errorListeners.clear(); + if (!_unauthorized) await client.removeChannel(_channel); + } +} diff --git a/lib/src/class/supabase_chat_controller.dart b/lib/src/class/supabase_chat_controller.dart index f182b3d..f0e1177 100644 --- a/lib/src/class/supabase_chat_controller.dart +++ b/lib/src/class/supabase_chat_controller.dart @@ -1,177 +1,530 @@ import 'dart:async'; -import 'package:flutter_chat_types/flutter_chat_types.dart' as types; -import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart' as core; -import '../../flutter_supabase_chat_core.dart'; +import '../util.dart'; +import 'chat_room.dart'; +import 'chat_user.dart'; +import 'room_channel.dart'; +import 'supabase_chat_core.dart'; +import 'supabase_chat_core_exception.dart'; -/// Provides Supabase chat controller. Instance new class -/// SupabaseChatController to manage a chat. -class SupabaseChatController { - late types.Room _room; - final List _messages = []; - final int pageSize; - int _currentPage = 0; - final _messagesController = StreamController>(); - final _typingController = StreamController>(); - late RealtimeChannel _typingChannel; - bool _typingChannelSubscribed = false; - Timer? _throttleTimer; - Timer? _endTypingTimer; - - /// SupabaseChatController constructor - /// [pageSize] define a room messages pagination size - /// [room] is required, is the controller's reference to the room +/// A Supabase-backed `flutter_chat_core` [core.ChatController]. +/// +/// Instantiate one [SupabaseChatController] per open room and pass it +/// directly to `Chat.chatController`. Internally it keeps an +/// [core.InMemoryChatController] in sync with the room's private Supabase +/// Realtime channel (message `INSERT`/`UPDATE`/`DELETE` events broadcast by +/// the database), so the UI gets the animated insert/update/remove behavior +/// `flutter_chat_ui` expects. +/// +/// On top of that it handles: +/// - cursor-based pagination ([loadPreviousMessages], [hasMoreMessages]); +/// - optimistic sends ([sendMessage]) with automatic reconciliation and a +/// failed state you can [retryMessage]; +/// - read receipts ([markAsRead], automatic by default); +/// - reactions ([toggleReaction]) and edits ([editTextMessage]); +/// - pinned messages ([pinnedMessages], [setPinned]); +/// - replies ([resolveMessage]) and jumping to any message, loading older +/// pages as needed ([jumpToMessage]); +/// - the room's typing indicator ([onTyping], [endTyping], +/// [typingUsers]). +class SupabaseChatController + with core.ScrollToMessageMixin + implements core.ChatController { + /// Creates a controller for [room] and starts loading its latest + /// [pageSize] messages. + /// + /// [onError] is called when a background operation (initial load, + /// realtime processing, automatic mark-as-read) fails — those aren't + /// awaited by any caller, so this is the only way to observe their + /// failures. Calls you await directly (e.g. [loadPreviousMessages]) throw + /// normally instead. + /// + /// With [autoMarkAsRead] (default) the room is marked as read once the + /// first page is loaded and whenever another member's message arrives + /// while the controller is alive. Pass `false` to call [markAsRead] + /// yourself (e.g. only while the app is in the foreground). + /// + /// [chatCore] defaults to [SupabaseChatCore.instance]. SupabaseChatController({ - this.pageSize = 10, - required types.Room room, - }) { - _room = room; - _typingChannel = _client.channel( - '${_config.realtimeChatTypingUserPrefixChannel}${_room.id}', - opts: RealtimeChannelConfig( - key: 'typing-state', - ), - ); - _typingChannel.onPresenceSync((_) { - final newState = _typingChannel.presenceState(); - var typingUsers = []; - final keyIndex = newState.indexWhere( - (e) => e.key == 'typing-state', - ); - if (keyIndex >= 0) { - final users = newState[keyIndex] - .presences - .where((e) => e.payload['typing'] == true) - .map( - (e) => e.payload['uid'].toString(), - ) - .toList(); - typingUsers = _room.users - .where( - (e) => - users.contains(e.id) && - e.id != SupabaseChatCore.instance.loggedSupabaseUser!.id, - ) - .toList(); - } - _typingController.sink.add(typingUsers); - }).subscribe( - (status, error) { - _typingChannelSubscribed = status == RealtimeSubscribeStatus.subscribed; - }, - ); + required ChatRoom room, + this.pageSize = 20, + this.onError, + this.autoMarkAsRead = true, + SupabaseChatCore? chatCore, + }) : _room = room, + _chatCore = chatCore ?? SupabaseChatCore.instance { + _channel = _chatCore.roomChannel(room.id) + ..addMessageListener(_onMessageChange, onError: _onChannelError) + ..addTypingListener(_onTypingChanged); + _initialLoad = _loadInitialMessages(); + unawaited(_loadPinned()); } - SupabaseClient get _client => SupabaseChatCore.instance.client; + final ChatRoom _room; + final SupabaseChatCore _chatCore; + final core.InMemoryChatController _inner = core.InMemoryChatController(); + + /// Number of messages loaded per page. + final int pageSize; + + /// Whether the room is marked as read automatically (see constructor). + final bool autoMarkAsRead; + + /// Called when a background operation fails. See the constructor doc. + final void Function(SupabaseChatCoreException error)? onError; + + late final RoomChannel _channel; + late final Future _initialLoad; + final _typingController = StreamController>.broadcast(); + List _typingUsers = const []; + bool _hasMoreMessages = true; + bool _loadingPrevious = false; + bool _disposed = false; + Timer? _markAsReadTimer; + final Map _resolved = {}; + + /// Pinned messages of the room, newest first, kept up to date in real + /// time (e.g. for a "pinned message" banner). + final ValueNotifier> pinnedMessages = ValueNotifier( + const [], + ); - SupabaseChatCoreConfig get _config => SupabaseChatCore.instance.config; + /// The room this controller works on. + ChatRoom get room => _room; - PostgrestTransformBuilder _messagesQuery() => _client - .schema(_config.schema) - .from(_config.messagesViewName) - .select() - .eq('roomId', int.parse(_room.id)) - .order('createdAt', ascending: false) - .range(pageSize * _currentPage, (_currentPage * pageSize) + pageSize); + /// Completes when the first page of messages is loaded (or failed to load, + /// see [onError]). + Future get initialLoad => _initialLoad; - void _onData( - List> newData, + /// Whether older messages may still be available to + /// [loadPreviousMessages]. + bool get hasMoreMessages => _hasMoreMessages; + + String? get _currentUserId => _chatCore.loggedUserId; + + void _handleBackgroundError(String operation, Object error, StackTrace st) { + if (_disposed) return; + onError?.call(_chatCore.mapError(operation, error, st)); + } + + void _onChannelError(Object error) => + _handleBackgroundError('subscribe', error, StackTrace.current); + + void _onMessageChange( + String operation, + Map record, + Map oldRecord, ) { - for (var val in newData) { - final author = _room.users.firstWhere( - (u) => u.id == val['authorId'], - orElse: () => types.User(id: val['authorId'] as String), + switch (operation) { + case 'INSERT': + _onUpsert(record, isInsert: true); + case 'UPDATE': + _onUpsert(record, isInsert: false); + case 'DELETE': + _onDelete(oldRecord); + } + } + + core.Message? _findById(String id) { + for (final m in _inner.messages) { + if (m.id == id) return m; + } + return null; + } + + static DateTime _timeOf(core.Message m) => + m.createdAt ?? DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); + + static List _ascending(Iterable messages) => + messages.toList()..sort((a, b) => _timeOf(a).compareTo(_timeOf(b))); + + Future _loadInitialMessages() async { + try { + final page = await _chatCore.messages(_room.id, limit: pageSize); + if (_disposed) return; + _hasMoreMessages = page.length >= pageSize; + // Realtime may already have delivered (newer) messages while the page + // was loading: keep them. + final loadedIds = page.map((m) => m.id).toSet(); + final arrived = _inner.messages.where((m) => !loadedIds.contains(m.id)); + // Not animated: a page appears at once (as in messaging apps), and a + // list animating every item of the page breaks Flutter's + // SliverAnimatedList as soon as another change lands before the + // animations end ("AnimationController.dispose() called more than + // once"). + await _inner.setMessages( + _ascending([...page, ...arrived]), + animated: false, ); - val['author'] = author.toJson(); - val['id'] = val['id'].toString(); - val['roomId'] = val['roomId'].toString(); - final newMessage = types.Message.fromJson(val); - final index = _messages.indexWhere((msg) => msg.id == newMessage.id); - if (index != -1) { - _messages[index] = newMessage; - } else { - _messages.add(newMessage); + if (autoMarkAsRead) _scheduleMarkAsRead(Duration.zero); + } catch (e, st) { + _handleBackgroundError('loadInitialMessages', e, st); + } + } + + Future _loadPinned() async { + try { + final pinned = await _chatCore.pinnedMessages(_room.id); + if (!_disposed) pinnedMessages.value = pinned; + } catch (e, st) { + _handleBackgroundError('loadPinnedMessages', e, st); + } + } + + void _trackPinned(core.Message message, {bool removed = false}) { + final others = pinnedMessages.value.where((m) => m.id != message.id); + final pinned = !removed && message.pinned == true; + final changed = pinned || pinnedMessages.value.length != others.length; + if (!changed) return; + final next = [...others, if (pinned) message] + ..sort((a, b) => _timeOf(b).compareTo(_timeOf(a))); + pinnedMessages.value = next; + } + + Future _insertSorted(core.Message message) async { + final messages = _inner.messages; + var index = messages.length; + while (index > 0 && + _timeOf(messages[index - 1]).isAfter(_timeOf(message))) { + index--; + } + await _inner.insertMessage(message, index: index); + } + + Future _onUpsert( + Map row, { + required bool isInsert, + }) async { + if (_disposed) return; + try { + final message = messageFromRow(row); + _trackPinned(message); + if (_resolved.containsKey(message.id)) _resolved[message.id] = message; + final old = _findById(message.id); + if (old != null) { + // Includes the Realtime echo of our own optimistic send: replace the + // local "sending" copy with the stored one (status, server fields). + await _inner.updateMessage(old, message); + } else if (isInsert) { + await _insertSorted(message); + if (autoMarkAsRead && message.authorId != _currentUserId) { + _scheduleMarkAsRead(const Duration(milliseconds: 800)); + } } + } catch (e, st) { + _handleBackgroundError(isInsert ? 'onInsert' : 'onUpdate', e, st); + } + } + + Future _onDelete(Map row) async { + if (_disposed) return; + try { + final id = row['id']; + if (id == null) return; + final old = _findById(id.toString()); + _resolved.remove(id.toString()); + final pinned = pinnedMessages.value.where((m) => m.id == id.toString()); + if (pinned.isNotEmpty) _trackPinned(pinned.first, removed: true); + if (old != null) await _inner.removeMessage(old); + } catch (e, st) { + _handleBackgroundError('onDelete', e, st); } - _messages.sort( - (a, b) => b.createdAt?.compareTo(a.createdAt ?? 0) ?? -1, - ); - _messagesController.sink.add(_messages); - } - - /// Returns a stream of messages from Supabase for a specified room. - /// Only the amount of messages specified in [pageSize] will be loaded, - /// then it will be necessary to call the [loadPreviousMessages] method to get - /// the next page of messages - Stream> get messages { - _messagesQuery().then((value) => _onData(value)); - _client - .channel('${_config.schema}:${_config.messagesTableName}:${_room.id}') - .onPostgresChanges( - event: PostgresChangeEvent.all, - schema: _config.schema, - table: _config.messagesTableName, - filter: PostgresChangeFilter( - type: PostgresChangeFilterType.eq, - column: 'roomId', - value: _room.id, - ), - callback: (payload) => _onData([payload.newRecord]), - ) - .subscribe(); - return _messagesController.stream; } - /// This method allows to receive on the stream [messages] the next - /// page + /// Loads the next (older) page of messages and prepends it to + /// [messages]. Call this e.g. from `ChatAnimatedList.onEndReached`. + /// No-op when [hasMoreMessages] is false or a load is already running. Future loadPreviousMessages() async { - _currentPage += 1; - await _messagesQuery().then((value) => _onData(value)); - } - - /// Returns a stream of typing users from Supabase for a specified room. - Stream> get typingUsers => _typingController.stream; - - void onTyping() async { - if (_typingChannelSubscribed && - SupabaseChatCore.instance.loggedSupabaseUser != null) { - if (_throttleTimer?.isActive ?? false) return; - _throttleTimer = Timer(Duration(milliseconds: 500), () {}); - _endTypingTimer?.cancel(); - _endTypingTimer = Timer( - Duration(seconds: 3), - () async { - await _typingChannel.track(_typingInfo(false)); - }, + await _initialLoad; + if (!_hasMoreMessages || _loadingPrevious || _disposed) return; + _loadingPrevious = true; + try { + final persisted = _inner.messages.where( + (m) => m.status != core.MessageStatus.sending, + ); + final oldest = persisted.isEmpty ? null : persisted.first.createdAt; + final page = await _chatCore.messages( + _room.id, + before: oldest, + limit: pageSize, ); - await _typingChannel.track(_typingInfo(true)); + if (_disposed) return; + _hasMoreMessages = page.length >= pageSize; + final fresh = page.where((m) => _findById(m.id) == null); + if (fresh.isNotEmpty) { + await _inner.insertAllMessages( + _ascending(fresh), + index: 0, + animated: false, + ); + } + } finally { + _loadingPrevious = false; } } + /// Returns message [messageId] of this room: from the loaded messages + /// when there, otherwise fetched once (and cached) — e.g. to render the + /// message a reply quotes ([core.Message.replyToMessageId]). + Future resolveMessage(String messageId) async { + final local = _findById(messageId) ?? _resolved[messageId]; + if (local != null) return local; + final fetched = await _chatCore.message(_room.id, messageId); + if (fetched != null) _resolved[messageId] = fetched; + return fetched; + } + + /// Scrolls to message [messageId], loading older pages until it is found + /// (at most [maxPages] of them). Returns whether it was found. Needs the + /// controller to be attached to a `ChatAnimatedList` (it is when passed to + /// `Chat`). + Future jumpToMessage(String messageId, {int maxPages = 20}) async { + await _initialLoad; + var pages = 0; + while (_findById(messageId) == null && + _hasMoreMessages && + pages < maxPages) { + await loadPreviousMessages(); + pages++; + } + if (_findById(messageId) == null) return false; + // Let the list lay out the newly inserted pages first. + await Future.delayed(const Duration(milliseconds: 50)); + await scrollToMessage( + messageId, + alignment: .5, + duration: const Duration(milliseconds: 350), + ); + return true; + } + + // --------------------------------------------------------------------------- + // Typing indicator + // --------------------------------------------------------------------------- + + void _onTypingChanged(Set ids) { + if (_disposed) return; + final me = _currentUserId; + _typingUsers = _room.users + .where((u) => ids.contains(u.id) && u.id != me) + .toList(growable: false); + _typingController.add(_typingUsers); + } + + /// Members (other than the logged-in user) currently typing in this room. + List get currentTypingUsers => _typingUsers; + + /// Stream of the members (other than the logged-in user) currently typing + /// in this room. Emits the current list first. + Stream> get typingUsers => Stream.multi((listener) { + listener.add(_typingUsers); + final sub = _typingController.stream.listen(listener.add); + listener.onCancel = sub.cancel; + }); + + /// Call on every keystroke in the composer. Throttled internally, see + /// issue #38: typing state travels over Realtime Broadcast, not Presence. + void onTyping() { + final uid = _currentUserId; + if (uid == null || _disposed) return; + _channel.typing(uid); + } + + /// Announces that the logged-in user stopped typing (called automatically + /// by [sendMessage]). Future endTyping() async { - _endTypingTimer?.cancel(); - await _typingChannel.track(_typingInfo(false)); + final uid = _currentUserId; + if (uid == null || _disposed) return; + await _channel.stopTyping(uid); + } + + // --------------------------------------------------------------------------- + // Actions + // --------------------------------------------------------------------------- + + /// Sends [message] to this room, inserting it locally right away + /// (optimistic UI, status `sending`) instead of waiting for Realtime. + /// + /// Build [message] with a client-generated id (e.g. `const Uuid().v7()` — + /// v7 keeps ids roughly time-ordered, same as the database's own default). + /// Supabase stores that exact id, so when the insert comes back over + /// Realtime the local copy is simply updated with the stored one — no + /// duplicate. If the request fails, the local copy is kept with status + /// `error` (see [retryMessage]) and the error is rethrown. + Future sendMessage(core.Message message) async { + final uid = _currentUserId; + final local = message.copyWith( + authorId: uid ?? message.authorId, + createdAt: message.createdAt ?? DateTime.now().toUtc(), + status: core.MessageStatus.sending, + ); + final existing = _findById(local.id); + if (existing == null) { + await _inner.insertMessage(local); + } else { + await _inner.updateMessage(existing, local); + } + unawaited(endTyping()); + try { + await _chatCore.sendMessage(local, _room.id); + final sent = _findById(local.id); + if (sent != null && sent.status == core.MessageStatus.sending) { + await _inner.updateMessage( + sent, + sent.copyWith(status: core.MessageStatus.sent), + ); + } + } catch (_) { + final failed = _findById(local.id); + if (failed != null && !_disposed) { + await _inner.updateMessage( + failed, + failed.copyWith( + status: core.MessageStatus.error, + failedAt: DateTime.now().toUtc(), + ), + ); + } + rethrow; + } + } + + /// Sends again a message whose [sendMessage] failed (status `error`). + Future retryMessage(core.Message message) => + sendMessage(message.copyWith(failedAt: null, createdAt: DateTime.now())); + + /// Replaces the text of the logged-in user's text [message]. + Future editTextMessage(core.TextMessage message, String text) async { + final edited = message.copyWith( + text: text, + editedAt: DateTime.now().toUtc(), + metadata: {...?message.metadata, 'edited': true}, + ); + final old = _findById(message.id); + if (old != null) await _inner.updateMessage(old, edited); + try { + await _chatCore.updateMessage(edited, _room.id); + } catch (_) { + final current = _findById(message.id); + if (current != null && !_disposed) { + await _inner.updateMessage(current, message); + } + rethrow; + } + } + + /// Adds or removes the logged-in user's [reaction] on message + /// [messageId]. + Future toggleReaction(String messageId, String reaction) async { + final reactions = await _chatCore.toggleReaction(messageId, reaction); + final old = _findById(messageId); + if (old != null && !_disposed) { + await _inner.updateMessage( + old, + old.copyWith(reactions: reactions.isEmpty ? null : reactions), + ); + } } - Map _typingInfo(bool typing) => { - 'uid': SupabaseChatCore.instance.loggedSupabaseUser!.id, - 'timestamp': DateTime.now().toIso8601String(), - 'typing': typing, - }; + /// Pins (or unpins) [message] for everyone in the room (either member of + /// a direct room, or a group admin). + Future setPinned(core.Message message, {bool pinned = true}) async { + await _chatCore.setMessagePinned(message.id, pinned: pinned); + final updated = message.copyWith(pinned: pinned ? true : null); + _trackPinned(updated); + final old = _findById(message.id); + if (old != null && !_disposed) await _inner.updateMessage(old, updated); + } - /// Removes message. - Future deleteMessage(String roomId, String messageId) async { - final result = - await SupabaseChatCore.instance.deleteMessage(roomId, messageId); + /// Removes a message both from Supabase and from [messages]. A message + /// that never reached the server (status `error`) is only removed + /// locally. + Future deleteMessage(String messageId) async { + final local = _findById(messageId); + if (local != null && local.status == core.MessageStatus.error) { + await _inner.removeMessage(local); + return true; + } + final result = await _chatCore.deleteMessage(_room.id, messageId); if (result) { - _messages.removeWhere((e) => messageId == e.id); - _messagesController.sink.add(_messages); + final old = _findById(messageId); + if (old != null) await _inner.removeMessage(old); } return result; } + void _scheduleMarkAsRead(Duration delay) { + _markAsReadTimer?.cancel(); + _markAsReadTimer = Timer(delay, () async { + try { + await markAsRead(); + } catch (e, st) { + _handleBackgroundError('markAsRead', e, st); + } + }); + } + + /// Marks every message of this room as read by the logged-in user (see + /// `SupabaseChatCore.markRoomAsRead`). + Future markAsRead() async { + if (_disposed) return; + await _chatCore.markRoomAsRead(_room.id); + } + + /// Underlying in-memory controller, exposed for advanced use cases not + /// covered by this class (e.g. upload progress tracking via + /// `UploadProgressMixin`, or programmatic scrolling via + /// `ScrollToMessageMixin`). + core.InMemoryChatController get inMemoryController => _inner; + + // ChatController interface: delegates to [_inner], which Realtime keeps + // in sync with Supabase. + + @override + List get messages => _inner.messages; + + @override + Stream get operationsStream => _inner.operationsStream; + + @override + Future insertMessage(core.Message message, {int? index}) => + _inner.insertMessage(message, index: index); + + @override + Future insertAllMessages(List messages, {int? index}) => + _inner.insertAllMessages(messages, index: index); + + @override + Future updateMessage( + core.Message oldMessage, + core.Message newMessage, + ) => _inner.updateMessage(oldMessage, newMessage); + + @override + Future removeMessage(core.Message message) => + _inner.removeMessage(message); + + @override + Future setMessages(List messages) => + _inner.setMessages(messages); + + @override void dispose() { - _typingChannel.untrack(); + if (_disposed) return; + _disposed = true; + _markAsReadTimer?.cancel(); + unawaited(_channel.removeTypingListener(_onTypingChanged)); + unawaited( + _channel.removeMessageListener( + _onMessageChange, + onError: _onChannelError, + ), + ); + unawaited(_typingController.close()); + pinnedMessages.dispose(); + _inner.dispose(); } } diff --git a/lib/src/class/supabase_chat_core.dart b/lib/src/class/supabase_chat_core.dart index eea714c..7420ef5 100644 --- a/lib/src/class/supabase_chat_core.dart +++ b/lib/src/class/supabase_chat_core.dart @@ -1,601 +1,1166 @@ import 'dart:async'; import 'dart:typed_data'; -import 'package:flutter_chat_types/flutter_chat_types.dart' as types; +import 'package:flutter_chat_core/flutter_chat_core.dart' as core; +import 'package:meta/meta.dart'; import 'package:mime/mime.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:uuid/uuid.dart'; -import '../../flutter_supabase_chat_core.dart'; - -/// Provides access to Supabase chat data. Singleton, use -/// SupabaseChatCore.instance to access methods. +import '../util.dart'; +import 'chat_room.dart'; +import 'chat_room_change.dart'; +import 'chat_room_role.dart'; +import 'chat_room_type.dart'; +import 'chat_user.dart'; +import 'presence_watch.dart'; +import 'room_channel.dart'; +import 'supabase_chat_core_config.dart'; +import 'supabase_chat_core_exception.dart'; +import 'upload_asset_result.dart'; +import 'user_online_status.dart'; + +/// Provides access to Supabase chat data. +/// +/// Most apps use the [instance] singleton, bound to `Supabase.instance`. +/// Create your own instance with [SupabaseChatCore.new] to work with a +/// different [SupabaseClient] (e.g. several logged-in users in the same +/// process, as integration tests do) — and [dispose] it when done. class SupabaseChatCore { - SupabaseChatCore._privateConstructor() { - Supabase.instance.client.auth.onAuthStateChange.listen((data) async { - if (loggedSupabaseUser != null) { - _loggedUser = await user(uid: loggedSupabaseUser!.id); - if (_currentUserOnlineStatusChannel == null) { - _currentUserOnlineStatusChannel ??= - _getUserOnlineStatusChannel(loggedSupabaseUser!.id); - _currentUserOnlineStatusChannel?.subscribe( - (status, error) async { - _userStatusSubscribed = - status == RealtimeSubscribeStatus.subscribed; - if (_lastOnlineStatus == UserOnlineStatus.online) { - await _trackUserStatus(); - } else { - await _currentUserOnlineStatusChannel?.untrack(); - } - }, - ); - } - } else { + /// Creates a chat core bound to [client] (defaults to + /// `Supabase.instance.client`, which must already be initialized). + SupabaseChatCore({ + SupabaseClient? client, + SupabaseChatCoreConfig config = const SupabaseChatCoreConfig(), + }) : _client = client, + _config = config { + _authSubscription = this.client.auth.onAuthStateChange.listen( + (data) => _onAuthStateChange(), + onError: (Object error, StackTrace st) { + // Auth stream errors (e.g. a failed token refresh) are surfaced to + // the app by supabase_flutter itself; here they only mean "no + // cached user", which the next successful auth event fixes. _loggedUser = null; - await _currentUserOnlineStatusChannel?.unsubscribe(); - _userStatusSubscribed = false; - _currentUserOnlineStatusChannel = null; - } - }); + }, + ); + if (loggedSupabaseUser != null) _onAuthStateChange(); } - /// Singleton instance. - static final SupabaseChatCore instance = - SupabaseChatCore._privateConstructor(); - - /// Config to set custom names for users, room and messages tables. Also - /// see [SupabaseChatCoreConfig]. - SupabaseChatCoreConfig config = const SupabaseChatCoreConfig( - 'chats', - 'rooms', - 'rooms_l', - 'messages', - 'messages_l', - 'users', - 'online-user-', - //online-user-${uid} - 'chat-user-typing-', - //chat-user-typing-${room_id} - 'chats_assets', - ); + /// Singleton instance, bound to `Supabase.instance.client`. + static final SupabaseChatCore instance = SupabaseChatCore(); - /// Sets custom config to change default names for users, rooms - /// and messages tables. Also see [SupabaseChatCoreConfig]. + final SupabaseClient? _client; + SupabaseChatCoreConfig _config; + late final StreamSubscription _authSubscription; + + /// Gets the [SupabaseClient] this instance works with. + SupabaseClient get client => _client ?? Supabase.instance.client; + + /// Config to set custom names for tables, views, channels and buckets, and + /// the optional scoping hooks. See [SupabaseChatCoreConfig]. + SupabaseChatCoreConfig get config => _config; + + /// Replaces the config. See [SupabaseChatCoreConfig]. void setConfig(SupabaseChatCoreConfig supabaseChatCoreConfig) { - config = supabaseChatCoreConfig; + _config = supabaseChatCoreConfig; } - /// Current logged in user in Supabase. Is update automatically. - User? get loggedSupabaseUser => Supabase.instance.client.auth.currentUser; + /// Current logged in user in Supabase. Is updated automatically. + User? get loggedSupabaseUser => client.auth.currentUser; + + /// Id of the current logged in user, or `null`. + String? get loggedUserId => loggedSupabaseUser?.id; - types.User? _loggedUser; + ChatUser? _loggedUser; - /// Current logged in user. Is update automatically. - types.User? get loggedUser => _loggedUser; + /// Current logged in user's chat profile. Is updated automatically on auth + /// state changes (and by [updateUser]). + ChatUser? get loggedUser => _loggedUser; + + String _requireUserId(String operation) { + final uid = loggedUserId; + if (uid == null) { + throw SupabaseChatCoreAuthException( + '$operation requires a logged-in user', + ); + } + return uid; + } - RealtimeChannel _getUserOnlineStatusChannel(String uid) => - client.channel('${config.realtimeOnlineUserPrefixChannel}$uid'); + // --------------------------------------------------------------------------- + // Auth / presence + // --------------------------------------------------------------------------- + String? _presenceUserId; + RealtimeChannel? _ownPresenceChannel; + bool _ownPresenceSubscribed = false; UserOnlineStatus _lastOnlineStatus = UserOnlineStatus.offline; + final _ownStatusController = StreamController.broadcast(); - RealtimeChannel? _currentUserOnlineStatusChannel; + Future _onAuthStateChange() async { + final uid = loggedUserId; + if (uid != _presenceUserId) { + await _stopOwnPresence(); + if (uid != null) _startOwnPresence(uid); + } + if (uid == null) { + _loggedUser = null; + return; + } + try { + _loggedUser = await user(uid: uid); + } on SupabaseChatCoreException { + // Auth state changes fire in the background with no caller to + // report to; fail safe to "no cached user" rather than crashing the + // listener (the next auth state change, or [refreshLoggedUser], + // retries). + _loggedUser = null; + } + } - bool _userStatusSubscribed = false; + /// Reloads [loggedUser] from Supabase and returns it. + Future refreshLoggedUser() async { + final uid = loggedUserId; + _loggedUser = uid == null ? null : await user(uid: uid); + return _loggedUser; + } - Future _trackUserStatus() async { - final userStatus = { - 'uid': loggedSupabaseUser?.id, - 'online_at': DateTime.now().toIso8601String(), - }; - await _currentUserOnlineStatusChannel?.track(userStatus); + void _startOwnPresence(String uid) { + _presenceUserId = uid; + _ownPresenceChannel = client.channel( + '${config.realtimeOnlineUserPrefixChannel}$uid', + ); + _ownPresenceChannel!.subscribe((status, error) async { + _ownPresenceSubscribed = status == RealtimeSubscribeStatus.subscribed; + if (_ownPresenceSubscribed && + _lastOnlineStatus == UserOnlineStatus.online) { + await _trackOwnPresence(); + } + }); } + Future _stopOwnPresence() async { + final channel = _ownPresenceChannel; + _ownPresenceChannel = null; + _ownPresenceSubscribed = false; + _presenceUserId = null; + if (channel != null) await client.removeChannel(channel); + } + + Future _trackOwnPresence() async { + await _ownPresenceChannel?.track({ + 'uid': _presenceUserId, + 'online_at': DateTime.now().toUtc().toIso8601String(), + }); + } + + /// Publishes the logged-in user's online status (see + /// `UserOnlineStateObserver`, which calls this on app lifecycle changes). + /// + /// Going offline also stores the user's `lastSeen`. Future setPresenceStatus(UserOnlineStatus status) async { + if (_lastOnlineStatus == status) return; _lastOnlineStatus = status; + _ownStatusController.add(status); switch (status) { case UserOnlineStatus.online: - if (_userStatusSubscribed) { - await _trackUserStatus(); - } - break; + if (_ownPresenceSubscribed) await _trackOwnPresence(); case UserOnlineStatus.offline: - if (_userStatusSubscribed) { - await _currentUserOnlineStatusChannel?.untrack(); + if (_ownPresenceSubscribed) await _ownPresenceChannel?.untrack(); + final uid = loggedUserId; + if (uid != null) { + try { + await client + .schema(config.schema) + .from(config.usersTableName) + .update({'lastSeen': formatChatDate(DateTime.now())}) + .eq('id', uid); + } on Object { + // Best effort: going offline usually happens while the app is + // being backgrounded, often without connectivity. + } } - break; } } - final Map _onlineUserChannels = {}; - - UserOnlineStatus _userStatus(List presences, String uid) => - presences.map((e) => e.payload['uid']).contains(uid) - ? UserOnlineStatus.online - : UserOnlineStatus.offline; + final Map _presenceWatches = {}; - /// Returns a stream of online user state from Supabase Realtime. + /// Returns a stream of the online status of user [uid], over Supabase + /// Realtime Presence. Emits the current status first. The underlying + /// channel is shared by all listeners of the same user and closed when the + /// last one cancels. Stream userOnlineStatus(String uid) { - final controller = StreamController(); - if (_onlineUserChannels[uid] == null) { - _onlineUserChannels[uid] = _getUserOnlineStatusChannel(uid); - _onlineUserChannels[uid]!.onPresenceJoin((payload) { - controller.sink.add(_userStatus(payload.newPresences, uid)); - }).onPresenceLeave((payload) { - controller.sink.add(_userStatus(payload.currentPresences, uid)); - }).subscribe(); + if (uid == loggedUserId) { + return Stream.multi((listener) { + listener.add(_lastOnlineStatus); + final sub = _ownStatusController.stream.listen(listener.add); + listener.onCancel = sub.cancel; + }); } - return controller.stream; + return Stream.multi((listener) { + final watch = _presenceWatches.putIfAbsent( + uid, + () => PresenceWatch( + client: client, + topic: '${config.realtimeOnlineUserPrefixChannel}$uid', + uid: uid, + onEmpty: () => _presenceWatches.remove(uid), + ), + ); + watch.addListener(listener); + listener.onCancel = () => watch.removeListener(listener); + }); } - /// Returns the URL of an asset path in the bucket - String getAssetUrl(String path) => - '${client.storage.url}/object/authenticated/${config.chatAssetsBucket}/$path'; + final Map _roomChannels = {}; + + /// Shared private Broadcast channel of room [roomId] (message changes and + /// typing indicator). One per room per [SupabaseChatCore], however many + /// controllers are open on that room: Realtime only allows one join per + /// topic per connection. + @internal + RoomChannel roomChannel(String roomId) => _roomChannels.putIfAbsent( + roomId, + () => RoomChannel( + client: client, + topic: '${config.realtimeRoomChannelPrefix}$roomId', + onEmpty: () => _roomChannels.remove(roomId), + ), + ); + + // --------------------------------------------------------------------------- + // Storage + // --------------------------------------------------------------------------- + + /// Returns the authenticated URL of an object [path] in [bucket] (defaults + /// to the chat assets bucket). + String getAssetUrl(String path, {String? bucket}) => + '${client.storage.url}/object/authenticated/${bucket ?? config.chatAssetsBucket}/$path'; + + /// Returns a unique object path for [assetName] inside [room]'s folder. + String generateRoomAssetPath(ChatRoom room, String assetName) => + '${room.id}/${const Uuid().v7()}-${_safeFileName(assetName)}'; - /// Returns a path based on the specified room id and asset name - String generateRoomAssetPath(types.Room room, String assetName) => - '${room.id}/${const Uuid().v1()}-$assetName'; + static String _safeFileName(String name) => + name.replaceAll(RegExp(r'[^A-Za-z0-9._-]'), '_'); - /// Allows you to upload an asset to a specific room by returning its URL and mimeType + /// Uploads an attachment to [room]'s folder in the chat assets bucket and + /// returns its URL and detected MIME type. Future uploadAsset( - types.Room room, + ChatRoom room, String assetName, Uint8List bytes, - ) async { + ) => _guard('uploadAsset', () async { final mimeType = lookupMimeType(assetName, headerBytes: bytes); final path = generateRoomAssetPath(room, assetName); - await Supabase.instance.client.storage - .from(SupabaseChatCore.instance.config.chatAssetsBucket) + await client.storage + .from(config.chatAssetsBucket) .uploadBinary( path, bytes, fileOptions: FileOptions(contentType: mimeType), ); - return UploadAssetResult(url: getAssetUrl(path), mimeType: mimeType); - } + return UploadAssetResult( + url: getAssetUrl(path), + mimeType: mimeType, + path: path, + ); + }); - /// Gets proper [SupabaseClient] instance. - SupabaseClient get client => Supabase.instance.client; + /// Uploads [bytes] as the logged-in user's avatar, stores its URL on the + /// user's profile and returns the upload result. + Future uploadUserAvatar( + String fileName, + Uint8List bytes, + ) async { + final uid = _requireUserId('uploadUserAvatar'); + return _guard('uploadUserAvatar', () async { + final mimeType = lookupMimeType(fileName, headerBytes: bytes); + final path = '$uid/${const Uuid().v7()}-${_safeFileName(fileName)}'; + await client.storage + .from(config.userAvatarsBucket) + .uploadBinary( + path, + bytes, + fileOptions: FileOptions(contentType: mimeType), + ); + final url = getAssetUrl(path, bucket: config.userAvatarsBucket); + await client + .schema(config.schema) + .from(config.usersTableName) + .update({ + 'imageUrl': url, + 'updatedAt': formatChatDate(DateTime.now()), + }) + .eq('id', uid); + _loggedUser = _loggedUser?.copyWith(imageUrl: url); + return UploadAssetResult(url: url, mimeType: mimeType, path: path); + }); + } - /// Return header for authenticate api calls to Supabase + /// Headers authenticating HTTP requests to Supabase (e.g. to load + /// attachments from [getAssetUrl] URLs with `Image.network` or `http`). Map get httpSupabaseHeaders => { - 'Authorization': 'Bearer ${client.auth.currentSession?.accessToken}', - }; + 'Authorization': 'Bearer ${client.auth.currentSession?.accessToken}', + }; + + // --------------------------------------------------------------------------- + // Errors + // --------------------------------------------------------------------------- + + /// Wraps whatever the Supabase client throws for [operation] into the + /// matching [SupabaseChatCoreException] subtype, so callers only need to + /// catch one type instead of every possible Supabase exception. + @internal + SupabaseChatCoreException mapError( + String operation, + Object error, + StackTrace stackTrace, + ) { + if (error is SupabaseChatCoreException) return error; + if (error is AuthException) { + return SupabaseChatCoreAuthException( + '$operation failed', + cause: error, + stackTrace: stackTrace, + ); + } + if (error is StorageException) { + return SupabaseChatCoreStorageException( + '$operation failed', + cause: error, + stackTrace: stackTrace, + ); + } + return SupabaseChatCoreRequestException( + '$operation failed', + cause: error, + stackTrace: stackTrace, + ); + } - /// Creates a chat group room with [users]. Creator is automatically - /// added to the group. [name] is required and will be used as - /// a group name. Add an optional [imageUrl] that will be a group avatar - /// and [metadata] for any additional custom data. - Future createGroupRoom({ - types.Role creatorRole = types.Role.admin, + /// Runs [body], rethrowing any error it throws as the matching + /// [SupabaseChatCoreException] subtype (see [mapError]). + Future _guard(String operation, Future Function() body) async { + try { + return await body(); + } catch (e, st) { + throw mapError(operation, e, st); + } + } + + // --------------------------------------------------------------------------- + // Rooms + // --------------------------------------------------------------------------- + + Map? _roomMetadata(Map? metadata) { + final defaults = config.defaultRoomMetadata?.call(); + if (defaults == null || defaults.isEmpty) return metadata; + return {...defaults, ...?metadata}; + } + + Future _createRoom({ + required ChatRoomType type, + required List userIds, + String? name, String? imageUrl, Map? metadata, - required String name, - required List users, + ChatRoomRole? creatorRole, }) async { - if (loggedSupabaseUser == null) return Future.error('User does not exist'); - - final roomUsers = [loggedUser!.copyWith(role: creatorRole)] + users; - - final room = - await client.schema(config.schema).from(config.roomsTableName).insert({ - 'createdAt': DateTime.now().millisecondsSinceEpoch, - 'imageUrl': imageUrl, - 'metadata': metadata, - 'name': name, - 'type': types.RoomType.group.toShortString(), - 'updatedAt': DateTime.now().millisecondsSinceEpoch, - 'userIds': roomUsers.map((u) => u.id).toList(), - 'userRoles': roomUsers.fold>( - {}, - (previousValue, user) => { - ...previousValue, - user.id: user.role?.toShortString(), - }, - ), - }).select(); - - return types.Room( - id: room.first['id'].toString(), - imageUrl: imageUrl, - metadata: metadata, - name: name, - type: types.RoomType.group, - users: roomUsers, - ); + final id = await client + .schema(config.schema) + .rpc( + 'create_room', + params: { + 'p_type': type.name, + 'p_user_ids': userIds, + 'p_name': name, + 'p_image_url': imageUrl, + 'p_metadata': _roomMetadata(metadata), + 'p_creator_role': creatorRole?.name, + }, + ); + return id.toString(); + } + + Future _loadCreatedRoom(String operation, String roomId) async { + final room = await getRoom(roomId); + if (room == null) { + throw SupabaseChatCoreRequestException( + '$operation: room $roomId was created but cannot be read back', + ); + } + return room; } - /// Creates a direct chat for 2 people. Add [metadata] for any additional - /// custom data. - Future createRoom( - types.User otherUser, { + /// Creates a group room with [users] (the logged-in user is added + /// automatically with [creatorRole]). [name] is the group name, [imageUrl] + /// an optional avatar and [metadata] any additional custom data (merged + /// over [SupabaseChatCoreConfig.defaultRoomMetadata]). + Future createGroupRoom({ + ChatRoomRole creatorRole = ChatRoomRole.admin, + String? imageUrl, Map? metadata, + required String name, + required List users, }) async { - final su = loggedSupabaseUser; - - if (su == null) return Future.error('User does not exist'); - - // Sort two user ids array to always have the same array for both users, - // this will make it easy to find the room if exist and make one read only. - final userIds = [su.id, otherUser.id]..sort(); + _requireUserId('createGroupRoom'); + return _guard('createGroupRoom', () async { + final roomId = await _createRoom( + type: ChatRoomType.group, + userIds: users.map((u) => u.id).toList(), + name: name, + imageUrl: imageUrl, + metadata: metadata, + creatorRole: creatorRole, + ); + return _loadCreatedRoom('createGroupRoom', roomId); + }); + } - final roomQuery = await client - .schema(config.schema) - .from(config.roomsTableName) - .select() - .eq('type', types.RoomType.direct.toShortString()) - .eq('userIds', userIds) - .limit(1); - // Check if room already exist. - if (roomQuery.isNotEmpty) { - final room = (await processRoomsRows( - su, - client, - roomQuery, - config.usersTableName, - config.schema, - )) - .first; - - return room; + /// Returns the direct (1-to-1) room with [otherUser], creating it if it + /// doesn't exist yet. Add [metadata] for any additional custom data (merged + /// over [SupabaseChatCoreConfig.defaultRoomMetadata]): an existing direct + /// room is only reused when its metadata contains every entry of it. + Future createRoom( + ChatUser otherUser, { + Map? metadata, + }) async { + final uid = _requireUserId('createRoom'); + if (otherUser.id == uid) { + throw const SupabaseChatCoreRequestException( + 'createRoom: cannot create a direct room with yourself', + ); } + return _guard('createRoom', () async { + final roomId = await _createRoom( + type: ChatRoomType.direct, + userIds: [otherUser.id], + metadata: metadata, + ); + return _loadCreatedRoom('createRoom', roomId); + }); + } - final currentUser = await fetchUser( - client, - su.id, - config.usersTableName, - config.schema, - ); + /// Updates [room]'s name, avatar and metadata (group rooms: admins only; + /// direct rooms: metadata only). Membership is managed with [addMembers], + /// [removeMember], [leaveRoom] and [setMemberRole]. + Future updateRoom(ChatRoom room) async { + _requireUserId('updateRoom'); + return _guard('updateRoom', () async { + final isDirect = room.type == ChatRoomType.direct; + await client + .schema(config.schema) + .from(config.roomsTableName) + .update({ + if (!isDirect) 'imageUrl': room.imageUrl, + if (!isDirect) 'name': room.name, + 'metadata': room.metadata, + 'updatedAt': formatChatDate(DateTime.now()), + }) + .eq('id', room.id); + }); + } - final users = [types.User.fromJson(currentUser), otherUser]; - - // Create new room with sorted user ids array. - final room = - await client.schema(config.schema).from(config.roomsTableName).insert({ - 'createdAt': DateTime.now().millisecondsSinceEpoch, - 'imageUrl': null, - 'metadata': metadata, - 'name': null, - 'type': types.RoomType.direct.toShortString(), - 'updatedAt': DateTime.now().millisecondsSinceEpoch, - 'userIds': userIds, - 'userRoles': null, - }).select(); - return types.Room( - id: room.first['id'].toString(), - metadata: metadata, - type: types.RoomType.direct, - users: users, - ); + /// Adds [userIds] to the group room [roomId] (admins only), with an + /// optional [role]. + Future addMembers( + String roomId, + List userIds, { + ChatRoomRole? role, + }) async { + _requireUserId('addMembers'); + if (userIds.isEmpty) return; + return _guard('addMembers', () async { + await client + .schema(config.schema) + .from(config.roomMembersTableName) + .upsert( + [ + for (final userId in userIds) + {'roomId': roomId, 'userId': userId, 'role': role?.name}, + ], + onConflict: 'roomId,userId', + ignoreDuplicates: true, + ); + }); } - /// Update [types.User] in Supabase to store name and avatar used on - /// rooms list. - Future updateUser(types.User user) async { - await client.schema(config.schema).from(config.usersTableName).update({ - 'firstName': user.firstName, - 'imageUrl': user.imageUrl, - 'lastName': user.lastName, - 'metadata': user.metadata, - 'role': user.role?.toShortString(), - 'updatedAt': DateTime.now().millisecondsSinceEpoch, - }).eq('id', user.id); + /// Removes [userId] from room [roomId] (admins only, or yourself — see + /// [leaveRoom]). A room left without members is deleted. + Future removeMember(String roomId, String userId) async { + _requireUserId('removeMember'); + return _guard('removeMember', () async { + await client + .schema(config.schema) + .from(config.roomMembersTableName) + .delete() + .eq('roomId', roomId) + .eq('userId', userId); + }); } - /// Removes message. - Future deleteMessage(String roomId, String messageId) async { - final result = await client - .schema(config.schema) - .from(config.messagesTableName) - .delete() - .eq('roomId', roomId) - .eq('id', messageId) - .select(); - return result.isNotEmpty; + /// Removes the logged-in user from room [roomId]. A room left without + /// members is deleted. + Future leaveRoom(String roomId) => + removeMember(roomId, _requireUserId('leaveRoom')); + + /// Sets [userId]'s [role] in room [roomId] (`null` for a plain member). + /// Admins only. + Future setMemberRole( + String roomId, + String userId, + ChatRoomRole? role, + ) async { + _requireUserId('setMemberRole'); + return _guard('setMemberRole', () async { + await client + .schema(config.schema) + .from(config.roomMembersTableName) + .update({'role': role?.name}) + .eq('roomId', roomId) + .eq('userId', userId); + }); } - /// Removes room. - Future deleteRoom(String roomId) async { + /// Deletes room [roomId] with all its messages (group rooms: admins only; + /// direct rooms: either member). + Future deleteRoom(String roomId) => _guard('deleteRoom', () async { await client .schema(config.schema) .from(config.roomsTableName) .delete() .eq('id', roomId); + }); + + /// Marks every message of room [roomId] as read by the logged-in user: + /// resets the room's [ChatRoom.unreadCount] and flips other members' + /// messages to `seen` (their read receipts). + Future markRoomAsRead(String roomId) async { + _requireUserId('markRoomAsRead'); + return _guard('markRoomAsRead', () async { + await client + .schema(config.schema) + .rpc('mark_room_as_read', params: {'p_room_id': roomId}); + }); } - /// Get room. - Future getRoom(String roomId) async { - final fu = loggedSupabaseUser; - if (fu == null) return null; - final doc = await client - .schema(config.schema) - .from(config.roomsTableName) - .select() - .eq('id', roomId) - .limit(1); - return processRoomRow( - doc.first, - fu, - client, - config.usersTableName, - config.schema, - ); + /// Pins (or unpins) room [roomId] on top of the logged-in user's rooms + /// list. Only affects the logged-in user. + Future pinRoom(String roomId, {bool pinned = true}) async { + _requireUserId('pinRoom'); + return _guard('pinRoom', () async { + await client + .schema(config.schema) + .rpc( + 'pin_room', + params: {'p_room_id': roomId, 'p_pinned': pinned}, + ); + }); } - /// Returns a stream of changes in a room from Supabase. - Stream room(String roomId) { - final fu = loggedSupabaseUser; - if (fu == null) return const Stream.empty(); - return client - .schema(config.schema) - .from(config.roomsTableName) - .stream(primaryKey: ['id']) - .eq('id', roomId) - .asyncMap( - (doc) => processRoomRow( - doc.first, - fu, - client, - config.usersTableName, - config.schema, - ), - ); + /// Mutes room [roomId] for the logged-in user, for [duration] (for ever + /// when `null`). Muting is advisory: read [ChatRoom.isMuted] to decide + /// whether to notify. Only affects the logged-in user. + Future muteRoom(String roomId, {Duration? duration}) async { + _requireUserId('muteRoom'); + return _guard('muteRoom', () async { + await client + .schema(config.schema) + .rpc( + 'mute_room', + params: { + 'p_room_id': roomId, + 'p_until': duration == null + ? 'infinity' + : formatChatDate(DateTime.now().add(duration)), + }, + ); + }); } - /// Returns a paginated list of rooms from Supabase. Only rooms where current - /// logged in user exist are returned. - Future> rooms({ - String? filter, - int? offset = 0, - int? limit = 20, - }) async { - if (loggedSupabaseUser == null) return const []; - final table = client.schema(config.schema).from(config.roomsViewName); - - final queryUnlimited = filter != null && filter != '' - ? table.select().ilike('name', '%$filter%') - : table.select(); - var query = queryUnlimited.order('updatedAt', ascending: false); - if (offset != null && limit != null) { - query = query.range(offset, offset + limit); - } else if (limit != null) { - query = query.limit(limit); - } - final response = await query; - final rooms = []; - for (var r in response) { - rooms.add( - await processRoomRow( - r, - loggedSupabaseUser!, - client, - config.usersTableName, - config.schema, - ), - ); - } - return rooms; + /// Unmutes room [roomId] for the logged-in user. + Future unmuteRoom(String roomId) async { + _requireUserId('unmuteRoom'); + return _guard('unmuteRoom', () async { + await client + .schema(config.schema) + .rpc( + 'mute_room', + params: {'p_room_id': roomId, 'p_until': null}, + ); + }); } - static List updateRoomList( - List roomsList, - List newRooms, - ) { - final rooms = List.from(roomsList); - for (var newRoom in newRooms) { - final index = rooms.indexWhere((room) => room.id == newRoom.id); - if (index != -1) { - rooms[index] = newRoom; - } else { - rooms.add(newRoom); - } - } - rooms.sort( - (a, b) => b.updatedAt?.compareTo(a.updatedAt ?? 0) ?? -1, - ); - return rooms; - } - - /// Returns a stream of rooms updates from Supabase. Only rooms where current - /// logged in user exist are returned. - Stream> roomsUpdates() { - final fu = loggedSupabaseUser; - if (fu == null) return const Stream.empty(); - final controller = StreamController>(); - final roomsList = []; - - Future onData(List> data) async { - for (var val in data) { - final newRoom = await processRoomRow( - val, - fu, - client, - config.usersTableName, - config.schema, - ); - final index = roomsList.indexWhere((room) => room.id == newRoom.id); - if (index != -1) { - roomsList[index] = newRoom; + /// Returns room [roomId], or `null` if it doesn't exist or the logged-in + /// user isn't a member. + Future getRoom(String roomId) async { + final uid = loggedUserId; + if (uid == null) return null; + return _guard('getRoom', () async { + final rows = await client + .schema(config.schema) + .from(config.roomsViewName) + .select() + .eq('id', roomId) + .limit(1); + return rows.isEmpty + ? null + : ChatRoom.fromJson(rows.first, currentUserId: uid); + }); + } + + /// Returns a stream of room [roomId]: emits it right away, then again on + /// every change to the room or its members. Completes when the room is + /// deleted or the logged-in user leaves it. + Stream room(String roomId) { + final uid = loggedUserId; + if (uid == null) return const Stream.empty(); + + late final StreamController controller; + RealtimeChannel? channel; + var refreshing = Future.value(); + + Future refresh() => refreshing = refreshing.then((_) async { + if (controller.isClosed) return; + try { + final room = await getRoom(roomId); + if (controller.isClosed) return; + if (room == null) { + await controller.close(); } else { - roomsList.add(newRoom); + controller.add(room); } + } on SupabaseChatCoreException catch (e) { + if (!controller.isClosed) controller.addError(e); } - controller.sink.add(roomsList); - } + }); - client - .channel('${config.schema}:${config.roomsTableName}') - .onPostgresChanges( - event: PostgresChangeEvent.all, - schema: config.schema, - table: config.roomsTableName, - callback: (payload) => onData([payload.newRecord]), - ) - .subscribe(); + controller = StreamController( + onListen: () { + channel = client + .channel(_uniqueTopic('${config.schema}:room:$roomId')) + .onPostgresChanges( + event: PostgresChangeEvent.update, + schema: config.schema, + table: config.roomsTableName, + filter: PostgresChangeFilter( + type: PostgresChangeFilterType.eq, + column: 'id', + value: roomId, + ), + callback: (_) => refresh(), + ) + .onPostgresChanges( + event: PostgresChangeEvent.insert, + schema: config.schema, + table: config.roomMembersTableName, + filter: PostgresChangeFilter( + type: PostgresChangeFilterType.eq, + column: 'roomId', + value: roomId, + ), + callback: (_) => refresh(), + ) + .onPostgresChanges( + event: PostgresChangeEvent.update, + schema: config.schema, + table: config.roomMembersTableName, + filter: PostgresChangeFilter( + type: PostgresChangeFilterType.eq, + column: 'roomId', + value: roomId, + ), + callback: (_) => refresh(), + ) + // DELETE events can't be filtered (Realtime only ships the old + // row's primary key for them): refetch on any, cheap enough. + .onPostgresChanges( + event: PostgresChangeEvent.delete, + schema: config.schema, + table: config.roomMembersTableName, + callback: (payload) { + final old = payload.oldRecord; + if (old['roomId'] == null || old['roomId'] == roomId) { + refresh(); + } + }, + ) + .onPostgresChanges( + event: PostgresChangeEvent.delete, + schema: config.schema, + table: config.roomsTableName, + callback: (payload) { + if (payload.oldRecord['id'] == roomId) refresh(); + }, + ) + .subscribe( + (status, error) => + _reportSubscribeError(controller, status, error), + ); + refresh(); + }, + onCancel: () async { + final c = channel; + channel = null; + if (c != null) await client.removeChannel(c); + }, + ); return controller.stream; } - /// Sends a message to the Supabase. Accepts any partial message and a - /// room ID. If arbitrary data is provided in the [partialMessage] - /// does nothing. - Future sendMessage(dynamic partialMessage, String roomId) async { - if (loggedSupabaseUser == null) return; - - types.Message? message; - - if (partialMessage is types.PartialCustom) { - message = types.CustomMessage.fromPartial( - author: types.User(id: loggedSupabaseUser!.id), - id: '', - partialCustom: partialMessage, - ); - } else if (partialMessage is types.PartialFile) { - message = types.FileMessage.fromPartial( - author: types.User(id: loggedSupabaseUser!.id), - id: '', - partialFile: partialMessage, - ); - } else if (partialMessage is types.PartialImage) { - message = types.ImageMessage.fromPartial( - author: types.User(id: loggedSupabaseUser!.id), - id: '', - partialImage: partialMessage, - ); - } else if (partialMessage is types.PartialText) { - message = types.TextMessage.fromPartial( - author: types.User(id: loggedSupabaseUser!.id), - id: '', - partialText: partialMessage, - ); - } - - if (message != null) { - final messageMap = message.toJson(); - messageMap.removeWhere((key, value) => key == 'author' || key == 'id'); - messageMap['roomId'] = roomId; - messageMap['authorId'] = loggedSupabaseUser!.id; - messageMap['createdAt'] = DateTime.now().millisecondsSinceEpoch; - messageMap['updatedAt'] = DateTime.now().millisecondsSinceEpoch; - - await client - .schema(config.schema) - .from(config.messagesTableName) - .insert(messageMap); - - await client + /// Returns a page of the rooms the logged-in user is a member of: the ones + /// they pinned first, then most recently updated first. [filter] matches the room name + /// (case-insensitive; direct rooms are named after the other member). + /// [SupabaseChatCoreConfig.roomsQueryFilter] is applied too. + Future> rooms({ + String? filter, + int offset = 0, + int limit = 20, + }) async { + final uid = loggedUserId; + if (uid == null) return const []; + return _guard('rooms', () async { + var query = client .schema(config.schema) - .from(config.roomsTableName) - .update({'updatedAt': DateTime.now().millisecondsSinceEpoch}).eq( - 'id', - roomId, - ); - } + .from(config.roomsViewName) + .select(); + if (filter != null && filter.trim().isNotEmpty) { + query = query.ilike('name', '%${filter.trim()}%'); + } + query = config.roomsQueryFilter?.call(query) ?? query; + final rows = await query + .order('pinnedAt', ascending: false, nullsFirst: false) + .order('updatedAt', ascending: false) + .order('id', ascending: false) + .range(offset, offset + limit - 1); + return rows + .map((row) => ChatRoom.fromJson(row, currentUserId: uid)) + .toList(); + }); } - /// Updates a message in the Supabase. Accepts any message and a - /// room ID. Message will probably be taken from the [messages] stream. - Future updateMessage(types.Message message, String roomId) async { - //if (supabaseUser == null) return; - //if (message.author.id != supabaseUser!.id) return; + /// Returns a stream of changes to the rooms the logged-in user is a member + /// of: a [ChatRoomUpserted] when a room is created, updated (e.g. receives + /// a message) or the user is added to it, a [ChatRoomRemoved] when it is + /// deleted or the user leaves it. Rooms rejected by + /// [SupabaseChatCoreConfig.roomFilter] are skipped. + /// + /// Merge it into a list with [applyRoomChange], or use + /// `SupabaseChatRoomsController`, which does it for you. + Stream roomsChanges() { + final uid = loggedUserId; + if (uid == null) return const Stream.empty(); + + late final StreamController controller; + RealtimeChannel? channel; + var queue = Future.value(); + + void upsert(String roomId) => queue = queue.then((_) async { + if (controller.isClosed) return; + try { + final room = await getRoom(roomId); + if (controller.isClosed) return; + if (room == null) { + controller.add(ChatRoomRemoved(roomId)); + } else if (config.roomFilter?.call(room) ?? true) { + controller.add(ChatRoomUpserted(room)); + } + } on SupabaseChatCoreException catch (e) { + if (!controller.isClosed) controller.addError(e); + } + }); - final messageMap = message.toJson(); - messageMap.removeWhere( - (key, value) => key == 'author' || key == 'createdAt' || key == 'id', - ); - //messageMap['authorId'] = message.author.id; - messageMap['updatedAt'] = DateTime.now().millisecondsSinceEpoch; + void remove(String roomId) => queue = queue.then((_) { + if (!controller.isClosed) controller.add(ChatRoomRemoved(roomId)); + }); - await client - .schema(config.schema) - .from(config.messagesTableName) - .update(messageMap) - .eq('roomId', roomId) - .eq('id', message.id); - } - - /// Updates a room in the Supabase. Accepts any room. - /// Room will probably be taken from the [rooms] stream. - Future updateRoom(types.Room room) async { - if (loggedSupabaseUser == null) return; - - final roomMap = room.toJson(); - roomMap.removeWhere( - (key, value) => - key == 'createdAt' || - key == 'id' || - key == 'lastMessages' || - key == 'users', + controller = StreamController( + onListen: () { + channel = client + .channel(_uniqueTopic('${config.schema}:rooms:$uid')) + .onPostgresChanges( + event: PostgresChangeEvent.update, + schema: config.schema, + table: config.roomsTableName, + callback: (payload) => upsert(payload.newRecord['id'].toString()), + ) + .onPostgresChanges( + event: PostgresChangeEvent.delete, + schema: config.schema, + table: config.roomsTableName, + callback: (payload) { + final id = payload.oldRecord['id']; + if (id != null) remove(id.toString()); + }, + ) + .onPostgresChanges( + event: PostgresChangeEvent.insert, + schema: config.schema, + table: config.roomMembersTableName, + filter: PostgresChangeFilter( + type: PostgresChangeFilterType.eq, + column: 'userId', + value: uid, + ), + callback: (payload) => + upsert(payload.newRecord['roomId'].toString()), + ) + // Unfilterable (see [room]): the old row only carries the + // primary key, which does include roomId/userId here. + .onPostgresChanges( + event: PostgresChangeEvent.delete, + schema: config.schema, + table: config.roomMembersTableName, + callback: (payload) { + final old = payload.oldRecord; + if (old['roomId'] == null) return; + if (old['userId'] == uid) { + remove(old['roomId'].toString()); + } else { + upsert(old['roomId'].toString()); + } + }, + ) + .subscribe( + (status, error) => + _reportSubscribeError(controller, status, error), + ); + }, + onCancel: () async { + final c = channel; + channel = null; + if (c != null) await client.removeChannel(c); + }, ); + return controller.stream; + } - if (room.type == types.RoomType.direct) { - roomMap['imageUrl'] = null; - roomMap['name'] = null; + /// Applies [change] to [rooms] and returns the resulting list, sorted by + /// [ChatRoom.updatedAt] (most recent first). When [onlyExisting] is true + /// an upsert of a room not in [rooms] is ignored (e.g. while a search + /// filter is active). + static List applyRoomChange( + List rooms, + ChatRoomChange change, { + bool onlyExisting = false, + }) { + final result = List.from(rooms); + switch (change) { + case ChatRoomUpserted(:final room): + final index = result.indexWhere((r) => r.id == room.id); + if (index >= 0) { + result[index] = room; + } else if (!onlyExisting) { + result.add(room); + } + case ChatRoomRemoved(:final roomId): + result.removeWhere((r) => r.id == roomId); } + result.sort(compareRoomsByUpdatedAt); + return result; + } - roomMap['lastMessages'] = room.lastMessages?.map((m) { - final messageMap = m.toJson(); + /// Sort comparator of rooms lists: rooms pinned by the user first (most + /// recently pinned first), then most recently updated first, rooms + /// without an [ChatRoom.updatedAt] last. + static int compareRoomsByUpdatedAt(ChatRoom a, ChatRoom b) { + final ap = a.pinnedAt, bp = b.pinnedAt; + if (ap != null || bp != null) { + if (ap == null) return 1; + if (bp == null) return -1; + return bp.compareTo(ap); + } + final au = a.updatedAt, bu = b.updatedAt; + if (au == null && bu == null) return 0; + if (au == null) return 1; + if (bu == null) return -1; + return bu.compareTo(au); + } - messageMap.removeWhere( - (key, value) => - key == 'author' || - key == 'createdAt' || - key == 'id' || - key == 'updatedAt', + /// Surfaces a failed/timed out Realtime subscription on [controller]'s + /// stream, instead of silently never delivering changes. + void _reportSubscribeError( + StreamController controller, + RealtimeSubscribeStatus status, + Object? error, + ) { + if (controller.isClosed) return; + if (status == RealtimeSubscribeStatus.channelError || + status == RealtimeSubscribeStatus.timedOut) { + controller.addError( + SupabaseChatCoreRequestException( + 'Realtime subscription ${status.name}', + cause: error, + ), ); + } + } - messageMap['authorId'] = m.author.id; + static int _topicCounter = 0; - return messageMap; - }).toList(); - roomMap['updatedAt'] = DateTime.now().millisecondsSinceEpoch; - roomMap['userIds'] = room.users.map((u) => u.id).toList(); + /// Postgres changes channels can be named freely: a per-subscription + /// unique topic lets several listeners of the same data coexist on one + /// Realtime connection (which allows a single join per topic). + static String _uniqueTopic(String base) => '$base:${++_topicCounter}'; - await client - .schema(config.schema) - .from(config.roomsTableName) - .update(roomMap) - .eq('id', room.id); - } + // --------------------------------------------------------------------------- + // Users + // --------------------------------------------------------------------------- - /// Returns a paginated list of users from Supabase. - Future> users({ + /// Returns a page of users, sorted by name. Every word of [filter] must + /// match the first or last name (case-insensitive). Set + /// [excludeLoggedUser] to leave the logged-in user out (e.g. in a "start a + /// conversation" picker). [SupabaseChatCoreConfig.usersQueryFilter] is + /// applied too. + Future> users({ String? filter, - int? offset = 0, - int? limit = 20, + int offset = 0, + int limit = 20, + bool excludeLoggedUser = false, }) async { - if (loggedSupabaseUser == null) return const []; - final table = client.schema(config.schema).from(config.usersTableName); - - final queryUnlimited = filter != null && filter != '' - ? table.select().or( - 'or(firstName.ilike.%$filter%,lastName.ilike.%$filter%)', - ) - : table.select(); - var query = queryUnlimited - .order('firstName', ascending: true) - .order('lastName', ascending: true); - if (offset != null && limit != null) { - query = query.range(offset, offset + limit); - } else if (limit != null) { - query = query.limit(limit); - } - final response = await query; - return response - .map( - (e) => types.User.fromJson(e), - ) - .toList(); + final uid = loggedUserId; + if (uid == null) return const []; + return _guard('users', () async { + var query = client + .schema(config.schema) + .from(config.usersViewName) + .select(); + for (final word in (filter ?? '').trim().split(RegExp(r'\s+'))) { + if (word.isEmpty) continue; + final value = + '"%${word.replaceAll(r'\', r'\\').replaceAll('"', r'\"')}%"'; + query = query.or('firstName.ilike.$value,lastName.ilike.$value'); + } + if (excludeLoggedUser) query = query.neq('id', uid); + query = config.usersQueryFilter?.call(query) ?? query; + final rows = await query + .order('firstName', ascending: true) + .order('lastName', ascending: true) + .order('id', ascending: true) + .range(offset, offset + limit - 1); + return rows.map(ChatUser.fromJson).toList(); + }); } - /// Returns a user from Supabase. - Future user({ - required String uid, - }) async { - final response = await client + /// Returns user [uid], or `null` if not found. + Future user({required String uid}) => _guard('user', () async { + final rows = await client .schema(config.schema) - .from(config.usersTableName) + .from(config.usersViewName) .select() .eq('id', uid) .limit(1); - return response.isNotEmpty ? types.User.fromJson(response.first) : null; + return rows.isEmpty ? null : ChatUser.fromJson(rows.first); + }); + + /// Updates the logged-in user's profile (name, avatar, metadata). + Future updateUser(ChatUser user) => _guard('updateUser', () async { + await client + .schema(config.schema) + .from(config.usersTableName) + .update({ + 'firstName': user.firstName, + 'imageUrl': user.imageUrl, + 'lastName': user.lastName, + 'metadata': user.metadata, + 'updatedAt': formatChatDate(DateTime.now()), + }) + .eq('id', user.id); + if (user.id == loggedUserId) { + _loggedUser = (_loggedUser ?? user).copyWith( + firstName: user.firstName, + lastName: user.lastName, + imageUrl: user.imageUrl, + metadata: user.metadata, + ); + } + }); + + // --------------------------------------------------------------------------- + // Messages + // --------------------------------------------------------------------------- + + /// Returns up to [limit] messages of room [roomId], newest first, older + /// than [before] when given (cursor-based pagination: stable while new + /// messages keep arriving). + Future> messages( + String roomId, { + DateTime? before, + int limit = 20, + }) => _guard('messages', () async { + var query = client + .schema(config.schema) + .from(config.messagesViewName) + .select() + .eq('roomId', roomId); + if (before != null) { + query = query.lt('createdAt', formatChatDate(before)!); + } + final rows = await query + .order('createdAt', ascending: false) + .order('id', ascending: false) + .limit(limit); + return rows.map(messageFromRow).toList(); + }); + + /// Returns message [messageId] of room [roomId], or `null` if not found + /// (e.g. to show the message a reply quotes when it isn't loaded). + Future message(String roomId, String messageId) => + _guard('message', () async { + final rows = await client + .schema(config.schema) + .from(config.messagesViewName) + .select() + .eq('roomId', roomId) + .eq('id', messageId) + .limit(1); + return rows.isEmpty ? null : messageFromRow(rows.first); + }); + + /// Returns the pinned messages of room [roomId], newest first. + Future> pinnedMessages(String roomId) => + _guard('pinnedMessages', () async { + final rows = await client + .schema(config.schema) + .from(config.messagesViewName) + .select() + .eq('roomId', roomId) + .eq('pinned', true) + .order('createdAt', ascending: false) + .limit(50); + return rows.map(messageFromRow).toList(); + }); + + /// Returns up to [limit] text messages of room [roomId] whose text + /// contains [query] (case-insensitive), newest first. + Future> searchMessages( + String roomId, + String query, { + int limit = 50, + }) async { + final trimmed = query.trim(); + if (trimmed.isEmpty) return const []; + return _guard('searchMessages', () async { + final rows = await client + .schema(config.schema) + .from(config.messagesViewName) + .select() + .eq('roomId', roomId) + .ilike('text', '%$trimmed%') + .order('createdAt', ascending: false) + .limit(limit); + return rows.map(messageFromRow).toList(); + }); + } + + /// Pins (or unpins) message [messageId] for everyone in its room: either + /// member of a direct room, or a group admin. + Future setMessagePinned(String messageId, {bool pinned = true}) async { + _requireUserId('setMessagePinned'); + return _guard('setMessagePinned', () async { + await client + .schema(config.schema) + .rpc( + 'set_message_pinned', + params: {'p_message_id': messageId, 'p_pinned': pinned}, + ); + }); + } + + /// Sends [message] to room [roomId]. Build [message] with one of + /// `flutter_chat_core`'s `Message` factories (`Message.text`, + /// `Message.image`, ...) and your own `id` (e.g. `const Uuid().v7()`). + /// `authorId` is always set to the logged-in user, and `createdAt` + /// defaults to now. Stores [message]'s `id` as-is, so a caller doing + /// optimistic UI (see `SupabaseChatController.sendMessage`) gets the exact + /// same row back from Realtime instead of a second, differently-id'd one. + Future sendMessage(core.Message message, String roomId) async { + final uid = _requireUserId('sendMessage'); + return _guard('sendMessage', () async { + final row = messageToRow(message) + ..['id'] = message.id + ..['roomId'] = roomId + ..['authorId'] = uid + ..['createdAt'] = formatChatDate(message.createdAt ?? DateTime.now()); + await client + .schema(config.schema) + .from(config.messagesTableName) + .insert(row); + }); + } + + /// Updates [message]'s content and metadata in room [roomId]. Only the + /// author can update a message. Status, reactions and the pinned flag are + /// left alone: see [markRoomAsRead], [toggleReaction] and + /// [setMessagePinned]. + Future updateMessage(core.Message message, String roomId) => + _guard('updateMessage', () async { + final row = messageToRow(message) + ..remove('status') + ..remove('reactions') + ..remove('pinned') + ..['updatedAt'] = formatChatDate(DateTime.now()); + await client + .schema(config.schema) + .from(config.messagesTableName) + .update(row) + .eq('roomId', roomId) + .eq('id', message.id); + }); + + /// Adds the logged-in user's [reaction] (e.g. an emoji) to message + /// [messageId], or removes it if already there. Returns the message's + /// updated reactions (reaction → user ids). + Future>> toggleReaction( + String messageId, + String reaction, + ) async { + _requireUserId('toggleReaction'); + return _guard('toggleReaction', () async { + final result = await client + .schema(config.schema) + .rpc( + 'toggle_message_reaction', + params: {'p_message_id': messageId, 'p_reaction': reaction}, + ); + final map = (result as Map?) ?? const {}; + return { + for (final entry in map.entries) + entry.key.toString(): [ + for (final id in (entry.value as List? ?? const [])) id.toString(), + ], + }; + }); + } + + /// Deletes message [messageId] of room [roomId] (its author, or a room + /// admin). Returns whether a message was actually deleted. + Future deleteMessage(String roomId, String messageId) => + _guard('deleteMessage', () async { + final result = await client + .schema(config.schema) + .from(config.messagesTableName) + .delete() + .eq('roomId', roomId) + .eq('id', messageId) + .select('id'); + return result.isNotEmpty; + }); + + /// Releases the Realtime channels and listeners held by this instance. + /// Only needed for instances you created yourself, not for [instance]. + Future dispose() async { + await _authSubscription.cancel(); + await _stopOwnPresence(); + for (final watch in _presenceWatches.values.toList()) { + await watch.dispose(); + } + _presenceWatches.clear(); + for (final channel in _roomChannels.values.toList()) { + await channel.dispose(); + } + _roomChannels.clear(); + await _ownStatusController.close(); } } diff --git a/lib/src/class/supabase_chat_core_config.dart b/lib/src/class/supabase_chat_core_config.dart index 7de137a..b470bfb 100644 --- a/lib/src/class/supabase_chat_core_config.dart +++ b/lib/src/class/supabase_chat_core_config.dart @@ -1,48 +1,172 @@ import 'package:meta/meta.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; -/// Class that represents the chat config. Can be used for setting custom names -/// for rooms and users collections. Call [SupabaseChatCore.instance.setConfig] -/// before doing anything else with [SupabaseChatCore.instance] if you want to -/// change the default collection names. When using custom names don't forget -/// to update your security rules and indexes. +import 'chat_room.dart'; + +/// Signature of a hook that narrows a Supabase select query, e.g. to scope +/// rooms or users to an organization, workspace or any other partition of +/// your data. Return the (possibly filtered) [query]. +typedef ChatQueryFilter = + PostgrestFilterBuilder Function( + PostgrestFilterBuilder query, + ); + +/// Signature of a hook that decides whether a [ChatRoom] received over +/// Realtime belongs to the current scope (see +/// [SupabaseChatCoreConfig.roomFilter]). +typedef ChatRoomPredicate = bool Function(ChatRoom room); + +/// Signature of a hook returning metadata merged into every new room (see +/// [SupabaseChatCoreConfig.defaultRoomMetadata]). +typedef ChatMetadataProvider = Map Function(); + +/// Names of the database objects, Realtime channels and storage buckets the +/// package works with, plus optional hooks to scope the data it reads. +/// +/// Call `SupabaseChatCore.instance.setConfig` before doing anything else with +/// `SupabaseChatCore.instance` if you need to change any of the defaults. +/// When using custom names don't forget to update the SQL scripts (RLS +/// policies, views, triggers) accordingly. +/// +/// ## Scoping hooks +/// +/// [defaultRoomMetadata], [roomsQueryFilter], [usersQueryFilter] and +/// [roomFilter] let a multi-tenant/multi-workspace app keep each scope's +/// chats separate without forking the package: +/// +/// ```dart +/// SupabaseChatCore.instance.setConfig( +/// const SupabaseChatCoreConfig().copyWith( +/// defaultRoomMetadata: () => {'workspaceId': currentWorkspaceId}, +/// roomsQueryFilter: (q) => q.eq('metadata->>workspaceId', currentWorkspaceId), +/// roomFilter: (room) => room.metadata?['workspaceId'] == currentWorkspaceId, +/// ), +/// ); +/// ``` +/// +/// Hooks are evaluated on every call, so they can read state that changes at +/// runtime. They only narrow what the client asks for: enforce the same rule +/// server side (RLS) if it is a security boundary. @immutable class SupabaseChatCoreConfig { - const SupabaseChatCoreConfig( - this.schema, - this.roomsTableName, - this.roomsViewName, - this.messagesTableName, - this.messagesViewName, - this.usersTableName, - this.realtimeOnlineUserPrefixChannel, - this.realtimeChatTypingUserPrefixChannel, - this.chatAssetsBucket, - ); + /// Creates a config. Every argument has a default matching the SQL scripts + /// shipped in `example/utils/sql`. + const SupabaseChatCoreConfig({ + this.schema = 'chats', + this.roomsTableName = 'rooms', + this.roomsViewName = 'rooms_l', + this.roomMembersTableName = 'room_members', + this.messagesTableName = 'messages', + this.messagesViewName = 'messages_l', + this.usersTableName = 'users', + String? usersViewName, + this.realtimeOnlineUserPrefixChannel = 'online-user-', + this.realtimeRoomChannelPrefix = 'chats:room:', + this.chatAssetsBucket = 'chats_assets', + this.userAvatarsBucket = 'chats_user_avatar', + this.defaultRoomMetadata, + this.roomsQueryFilter, + this.usersQueryFilter, + this.roomFilter, + }) : _usersViewName = usersViewName; - /// Property to set database schema name. + /// Database schema holding every chat table, view and function. final String schema; - /// Property to set rooms table name. + /// Rooms table name (written to). final String roomsTableName; - /// Property to set rooms table view name. + /// Rooms view name (read from): rooms with their members embedded. final String roomsViewName; - /// Property to set messages table name. + /// Room membership table name (`roomId`, `userId`, `role`, `lastReadAt`). + final String roomMembersTableName; + + /// Messages table name (written to, and listened to over Realtime). final String messagesTableName; - /// Property to set messages view name. + /// Messages view name (read from). final String messagesViewName; - /// Property to set users table name. + /// Users table name (written to by `updateUser`). final String usersTableName; - /// Property to set online users realtime channel. + /// Users table or view name read by `users`/`user`. Defaults to + /// [usersTableName]; point it at a view to expose a narrower or + /// differently-sourced list of users (e.g. only users sharing a workspace + /// with the caller). + String get usersViewName => _usersViewName ?? usersTableName; + final String? _usersViewName; + + /// Prefix of the Presence channel used for a user's online status + /// (``). final String realtimeOnlineUserPrefixChannel; - /// Property to set users typing in room realtime channel. - final String realtimeChatTypingUserPrefixChannel; + /// Prefix of a room's private Realtime Broadcast channel + /// (``), carrying its message changes and typing + /// indicator. Must match the topic used by the + /// `chats.broadcast_message_change` trigger and the `realtime.messages` + /// RLS policies in the SQL scripts. + final String realtimeRoomChannelPrefix; - /// Property to set chat assets bucket. + /// Storage bucket for message attachments (`/`). final String chatAssetsBucket; + + /// Storage bucket for user avatars (`/`). + final String userAvatarsBucket; + + /// Metadata merged into every room created by `createRoom` and + /// `createGroupRoom`. Explicit `metadata` passed to those methods wins on + /// conflicting keys. Also used to tell direct rooms apart: an existing + /// direct room is only reused when its metadata contains these entries. + final ChatMetadataProvider? defaultRoomMetadata; + + /// Applied to every rooms query (`rooms`, Realtime refetches). + final ChatQueryFilter? roomsQueryFilter; + + /// Applied to every users query (`users`). + final ChatQueryFilter? usersQueryFilter; + + /// Applied to every room received over Realtime: rooms for which it + /// returns `false` are ignored. Keep it consistent with [roomsQueryFilter]. + final ChatRoomPredicate? roomFilter; + + /// Returns a copy with the given fields replaced. + SupabaseChatCoreConfig copyWith({ + String? schema, + String? roomsTableName, + String? roomsViewName, + String? roomMembersTableName, + String? messagesTableName, + String? messagesViewName, + String? usersTableName, + String? usersViewName, + String? realtimeOnlineUserPrefixChannel, + String? realtimeRoomChannelPrefix, + String? chatAssetsBucket, + String? userAvatarsBucket, + ChatMetadataProvider? defaultRoomMetadata, + ChatQueryFilter? roomsQueryFilter, + ChatQueryFilter? usersQueryFilter, + ChatRoomPredicate? roomFilter, + }) => SupabaseChatCoreConfig( + schema: schema ?? this.schema, + roomsTableName: roomsTableName ?? this.roomsTableName, + roomsViewName: roomsViewName ?? this.roomsViewName, + roomMembersTableName: roomMembersTableName ?? this.roomMembersTableName, + messagesTableName: messagesTableName ?? this.messagesTableName, + messagesViewName: messagesViewName ?? this.messagesViewName, + usersTableName: usersTableName ?? this.usersTableName, + usersViewName: usersViewName ?? _usersViewName, + realtimeOnlineUserPrefixChannel: + realtimeOnlineUserPrefixChannel ?? this.realtimeOnlineUserPrefixChannel, + realtimeRoomChannelPrefix: + realtimeRoomChannelPrefix ?? this.realtimeRoomChannelPrefix, + chatAssetsBucket: chatAssetsBucket ?? this.chatAssetsBucket, + userAvatarsBucket: userAvatarsBucket ?? this.userAvatarsBucket, + defaultRoomMetadata: defaultRoomMetadata ?? this.defaultRoomMetadata, + roomsQueryFilter: roomsQueryFilter ?? this.roomsQueryFilter, + usersQueryFilter: usersQueryFilter ?? this.usersQueryFilter, + roomFilter: roomFilter ?? this.roomFilter, + ); } diff --git a/lib/src/class/supabase_chat_core_exception.dart b/lib/src/class/supabase_chat_core_exception.dart new file mode 100644 index 0000000..8a13364 --- /dev/null +++ b/lib/src/class/supabase_chat_core_exception.dart @@ -0,0 +1,58 @@ +/// Base class for every exception thrown by [SupabaseChatCore] and +/// [SupabaseChatController]'s public API. +/// +/// Wraps whatever the underlying Supabase client actually threw (a +/// `PostgrestException`, `AuthException`, `StorageException`, or any other +/// error) as [cause], so callers can catch this one type instead of having +/// to know every exception type the Supabase client can raise. +sealed class SupabaseChatCoreException implements Exception { + /// Creates the exception. + const SupabaseChatCoreException(this.message, {this.cause, this.stackTrace}); + + /// Describes which operation failed (e.g. `'sendMessage'`). + final String message; + + /// The original error/exception this wraps, if any. + final Object? cause; + + /// The stack trace captured at the point [cause] was thrown, if any. + final StackTrace? stackTrace; + + @override + String toString() => cause == null + ? '$runtimeType: $message' + : '$runtimeType: $message (cause: $cause)'; +} + +/// Thrown when an operation requires a logged-in Supabase user and there +/// isn't one. +final class SupabaseChatCoreAuthException extends SupabaseChatCoreException { + /// Creates the exception; see [SupabaseChatCoreException]. + const SupabaseChatCoreAuthException( + super.message, { + super.cause, + super.stackTrace, + }); +} + +/// Thrown when a Supabase/Postgres request fails (RLS permission denied, +/// constraint violation, connectivity, ...). +final class SupabaseChatCoreRequestException extends SupabaseChatCoreException { + /// Creates the exception; see [SupabaseChatCoreException]. + const SupabaseChatCoreRequestException( + super.message, { + super.cause, + super.stackTrace, + }); +} + +/// Thrown when uploading a chat asset (image, file, avatar) to Supabase +/// Storage fails. +final class SupabaseChatCoreStorageException extends SupabaseChatCoreException { + /// Creates the exception; see [SupabaseChatCoreException]. + const SupabaseChatCoreStorageException( + super.message, { + super.cause, + super.stackTrace, + }); +} diff --git a/lib/src/class/supabase_chat_rooms_controller.dart b/lib/src/class/supabase_chat_rooms_controller.dart new file mode 100644 index 0000000..7134ba9 --- /dev/null +++ b/lib/src/class/supabase_chat_rooms_controller.dart @@ -0,0 +1,213 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import 'chat_room.dart'; +import 'chat_room_change.dart'; +import 'supabase_chat_core.dart'; +import 'supabase_chat_core_exception.dart'; + +/// Keeps a paginated, searchable list of the logged-in user's rooms in sync +/// with Supabase Realtime: new rooms appear, rooms receiving messages move to +/// the top (with their unread count and last message updated), deleted or +/// left rooms disappear. +/// +/// ```dart +/// final rooms = SupabaseChatRoomsController()..loadFirstPage(); +/// ListenableBuilder( +/// listenable: rooms, +/// builder: (context, _) => ListView( +/// children: [for (final room in rooms.rooms) Text(room.name ?? '')], +/// ), +/// ); +/// ``` +/// +/// While a [search] filter is active, Realtime only updates rooms already in +/// the list (a room matching the filter can't be told apart client side). +class SupabaseChatRoomsController extends ChangeNotifier { + /// Creates a controller. Call [loadFirstPage] to start. + /// + /// [chatCore] defaults to [SupabaseChatCore.instance]. + SupabaseChatRoomsController({this.pageSize = 20, SupabaseChatCore? chatCore}) + : _chatCore = chatCore ?? SupabaseChatCore.instance; + + final SupabaseChatCore _chatCore; + + /// Number of rooms loaded per page. + final int pageSize; + + List _rooms = const []; + String _filter = ''; + bool _isLoading = false; + bool _hasMore = true; + SupabaseChatCoreException? _error; + StreamSubscription? _changes; + int _generation = 0; + bool _disposed = false; + + /// Loaded rooms, most recently updated first. + List get rooms => _rooms; + + /// Current search filter. + String get filter => _filter; + + /// Whether a page is being loaded. + bool get isLoading => _isLoading; + + /// Whether more rooms may be available to [loadNextPage]. + bool get hasMore => _hasMore; + + /// The last load error, if any (cleared by the next successful load). + SupabaseChatCoreException? get error => _error; + + /// Total unread messages across the loaded rooms, muted rooms excluded. + int get totalUnreadCount => _rooms.fold( + 0, + (total, room) => room.isMuted ? total : total + room.unreadCount, + ); + + void _notify() { + if (!_disposed) notifyListeners(); + } + + void _listenToChanges() { + _changes ??= _chatCore.roomsChanges().listen( + (change) { + _rooms = SupabaseChatCore.applyRoomChange( + _rooms, + change, + onlyExisting: _filter.isNotEmpty, + ); + _notify(); + }, + onError: (Object e) { + if (e is SupabaseChatCoreException) { + _error = e; + _notify(); + } + }, + ); + } + + /// (Re)loads the first page, keeping the current [filter]. + Future loadFirstPage() async { + _listenToChanges(); + final generation = ++_generation; + _hasMore = true; + _isLoading = true; + _notify(); + try { + final page = await _chatCore.rooms( + filter: _filter, + offset: 0, + limit: pageSize, + ); + if (generation != _generation || _disposed) return; + _rooms = page; + _hasMore = page.length >= pageSize; + _error = null; + } on SupabaseChatCoreException catch (e) { + if (generation != _generation || _disposed) return; + _error = e; + } finally { + if (generation == _generation) { + _isLoading = false; + _notify(); + } + } + } + + /// Loads the next page. No-op while loading or when [hasMore] is false. + Future loadNextPage() async { + if (_isLoading || !_hasMore) return; + final generation = _generation; + _isLoading = true; + _notify(); + try { + final page = await _chatCore.rooms( + filter: _filter, + offset: _rooms.length, + limit: pageSize, + ); + if (generation != _generation || _disposed) return; + final known = _rooms.map((r) => r.id).toSet(); + _rooms = [..._rooms, ...page.where((r) => !known.contains(r.id))]; + _hasMore = page.length >= pageSize; + _error = null; + } on SupabaseChatCoreException catch (e) { + if (generation != _generation || _disposed) return; + _error = e; + } finally { + if (generation == _generation) { + _isLoading = false; + _notify(); + } + } + } + + /// Applies a name [filter] (empty for none) and reloads the first page. + Future search(String filter) { + _filter = filter.trim(); + return loadFirstPage(); + } + + /// Same as [loadFirstPage], e.g. for pull-to-refresh. + Future refresh() => loadFirstPage(); + + /// Locally resets [room]'s unread count (e.g. right after opening it, so + /// the badge disappears before the server round trip completes). + void markLocallyAsRead(String roomId) { + final index = _rooms.indexWhere((r) => r.id == roomId); + if (index < 0 || _rooms[index].unreadCount == 0) return; + _rooms = List.of(_rooms)..[index] = _rooms[index].copyWith(unreadCount: 0); + _notify(); + } + + /// Pins/unpins [roomId] for the logged-in user, updating the list right + /// away. + Future pinRoom(String roomId, {bool pinned = true}) async { + await _chatCore.pinRoom(roomId, pinned: pinned); + _updateLocally( + roomId, + (room) => + room.copyWith(pinnedAt: () => pinned ? DateTime.now().toUtc() : null), + ); + } + + /// Mutes [roomId] for the logged-in user (for ever when [duration] is + /// `null`), updating the list right away. + Future muteRoom(String roomId, {Duration? duration}) async { + await _chatCore.muteRoom(roomId, duration: duration); + _updateLocally( + roomId, + (room) => room.copyWith( + mutedUntil: () => duration == null + ? ChatRoom.mutedForever + : DateTime.now().toUtc().add(duration), + ), + ); + } + + /// Unmutes [roomId] for the logged-in user, updating the list right away. + Future unmuteRoom(String roomId) async { + await _chatCore.unmuteRoom(roomId); + _updateLocally(roomId, (room) => room.copyWith(mutedUntil: () => null)); + } + + void _updateLocally(String roomId, ChatRoom Function(ChatRoom) update) { + final index = _rooms.indexWhere((r) => r.id == roomId); + if (index < 0) return; + _rooms = SupabaseChatCore.applyRoomChange( + _rooms, + ChatRoomUpserted(update(_rooms[index])), + ); + _notify(); + } + + @override + void dispose() { + _disposed = true; + unawaited(_changes?.cancel()); + super.dispose(); + } +} diff --git a/lib/src/class/upload_asset_result.dart b/lib/src/class/upload_asset_result.dart index 82473de..ff7e631 100644 --- a/lib/src/class/upload_asset_result.dart +++ b/lib/src/class/upload_asset_result.dart @@ -1,9 +1,23 @@ -class UploadAssetResult { - final String url; - final String? mimeType; +import 'package:meta/meta.dart'; - UploadAssetResult({ +/// Result of uploading a file through `SupabaseChatCore.uploadAsset` or +/// `SupabaseChatCore.uploadUserAvatar`. +@immutable +class UploadAssetResult { + /// Creates an upload result. + const UploadAssetResult({ required this.url, required this.mimeType, + this.path, }); + + /// Authenticated URL of the uploaded file (load it with + /// `SupabaseChatCore.httpSupabaseHeaders`). + final String url; + + /// MIME type detected from the file name and content, if any. + final String? mimeType; + + /// Object path inside its bucket. + final String? path; } diff --git a/lib/src/class/user_online_status.dart b/lib/src/class/user_online_status.dart index 673d6c9..3bc5bfc 100644 --- a/lib/src/class/user_online_status.dart +++ b/lib/src/class/user_online_status.dart @@ -1,4 +1,8 @@ +/// Online status of a user, as tracked over Supabase Realtime Presence. enum UserOnlineStatus { + /// The user has the app in the foreground. online, + + /// The user has the app in the background, closed, or is logged out. offline, } diff --git a/lib/src/util.dart b/lib/src/util.dart index 37d787b..67f9385 100644 --- a/lib/src/util.dart +++ b/lib/src/util.dart @@ -1,115 +1,180 @@ -import 'package:flutter_chat_types/flutter_chat_types.dart' as types; -import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart' as core; -/// Extension with one [toShortString] method. -extension RoleToShortString on types.Role { - /// Converts enum to the string equal to enum's name. - String toShortString() => toString().split('.').last; +/// Parses a date as returned by Supabase/PostgREST into a UTC [DateTime]. +/// +/// Accepts an ISO-8601 string (`timestamptz` columns), epoch milliseconds +/// (`int`, e.g. legacy rows or `flutter_chat_core` JSON) or a [DateTime]. +/// Returns `null` for `null` or unparseable values. +DateTime? parseChatDate(Object? value) { + if (value == null) return null; + if (value is DateTime) return value.toUtc(); + if (value is int) { + return DateTime.fromMillisecondsSinceEpoch(value, isUtc: true); + } + if (value is num) { + return DateTime.fromMillisecondsSinceEpoch(value.toInt(), isUtc: true); + } + if (value is String) return DateTime.tryParse(value)?.toUtc(); + return null; } -/// Extension with one [toShortString] method. -extension RoomTypeToShortString on types.RoomType { - /// Converts enum to the string equal to enum's name. - String toShortString() => toString().split('.').last; -} +/// Formats [value] the way `timestamptz` columns expect it (ISO-8601, UTC). +String? formatChatDate(DateTime? value) => value?.toUtc().toIso8601String(); -/// Fetches user from Firebase and returns a promise. -Future> fetchUser( - SupabaseClient instance, - String userId, - String usersTableName, - String schema, { - String? role, -}) async { - final data = (await instance - .schema(schema) - .from(usersTableName) - .select() - .eq('id', userId) - .limit(1)) - .first; - data['role'] = role; - return data; -} +const _messageDateKeys = { + 'createdAt', + 'updatedAt', + 'deletedAt', + 'failedAt', + 'sentAt', + 'deliveredAt', + 'seenAt', + 'editedAt', +}; -/// Returns a list of [types.Room] created from Firebase query. -/// If room has 2 participants, sets correct room name and image. -Future> processRoomsRows( - User supabaseUser, - SupabaseClient instance, - List rows, - String usersTableName, - String schema, -) async => - await Future.wait( - rows.map( - (doc) => processRoomRow( - doc, - supabaseUser, - instance, - usersTableName, - schema, - ), - ), - ); +/// Builds a `flutter_chat_core` [core.Message] out of a raw `messages` (or +/// `messages_l`) table row, a Realtime payload record or a +/// `rooms."lastMessages"` snapshot. +/// +/// The `messages` table columns match `flutter_chat_core`'s v2 field names +/// directly (`source`, `waveform`, `linkPreviewData`, `replyToMessageId`, +/// ...), so this only needs to: stringify the uuid `id`/`authorId`, convert +/// `timestamptz` dates to the epoch milliseconds `flutter_chat_core`'s JSON +/// uses, convert `duration` from the DB's milliseconds to v2's seconds, and +/// normalize a legacy-shaped `linkPreviewData` (e.g. missing the image +/// width/height v2 requires). +/// +/// Never throws: a row that doesn't match its declared type becomes a +/// [core.UnsupportedMessage], so one bad row can't break a whole list. +core.Message messageFromRow(Map row) { + final id = row['id'].toString(); + final authorId = row['authorId'].toString(); + + final json = {...row} + ..['id'] = id + ..['authorId'] = authorId + ..['type'] = row['type'] ?? 'unsupported' + ..remove('roomId'); + + for (final key in _messageDateKeys) { + if (!json.containsKey(key)) continue; + final date = parseChatDate(json[key]); + if (date == null) { + json.remove(key); + } else { + json[key] = date.millisecondsSinceEpoch; + } + } + + // Drop nulls: flutter_chat_core's generated fromJson treats an explicit + // null for a non-nullable field as a type error, but uses the default for + // a missing one. + json.removeWhere((key, value) => value == null); + + final durationMs = row['duration']; + if (durationMs is num) { + json['duration'] = (durationMs / 1000).round(); + } + + final status = json['status']; + if (status is String && + !core.MessageStatus.values.any((value) => value.name == status)) { + json.remove('status'); + } -/// Returns a [types.Room] created from Firebase document. -Future processRoomRow( - Map data, - User supabaseUser, - SupabaseClient instance, - String usersTableName, - String schema, -) async { - var imageUrl = data['imageUrl'] as String?; - var name = data['name'] as String?; - final type = data['type'] as String; - final userIds = data['userIds'] as List; - final userRoles = data['userRoles'] as Map?; - final users = data['users']?.map( - (e) { - e['role'] = userRoles?[e['id']]; - return e; + final reactions = json['reactions']; + if (reactions is Map) { + json['reactions'] = { + for (final entry in reactions.entries) + entry.key.toString(): [ + for (final userId in (entry.value as List? ?? const [])) + userId.toString(), + ], + }; + } else { + json.remove('reactions'); + } + + final preview = row['linkPreviewData']; + if (preview is! Map || preview['link'] == null) { + json.remove('linkPreviewData'); + } else { + final image = preview['image']; + json['linkPreviewData'] = { + 'link': preview['link'], + if (preview['title'] != null) 'title': preview['title'], + if (preview['description'] != null) 'description': preview['description'], + if (image is Map && image['url'] != null) + 'image': { + 'url': image['url'], + 'width': (image['width'] as num?)?.toDouble() ?? 0, + 'height': (image['height'] as num?)?.toDouble() ?? 0, }, - ).toList() ?? - await Future.wait( - userIds.map( - (userId) => fetchUser( - instance, - userId as String, - usersTableName, - schema, - role: userRoles?[userId] as String?, - ), - ), - ); - if (type == types.RoomType.direct.toShortString()) { - final index = users.indexWhere( - (u) => u['id'] != supabaseUser.id, + }; + } + + try { + return core.Message.fromJson(json); + } on Object { + // Row didn't carry the fields required by its declared type (e.g. a + // legacy row with unexpected nulls). Fall back to an unsupported + // message instead of crashing the realtime/pagination stream. + return core.Message.unsupported( + id: id, + authorId: authorId, + createdAt: parseChatDate(row['createdAt']), + metadata: (row['metadata'] as Map?)?.cast(), + ); + } +} + +/// Columns of the `messages` table that a [core.Message] can populate. +/// `id`, `authorId`, `roomId` and the dates are deliberately excluded: +/// callers set those explicitly (see `SupabaseChatCore.sendMessage`). +const _messageRowKeys = { + 'type', + 'status', + 'metadata', + 'reactions', + 'pinned', + 'replyToMessageId', + 'text', + 'source', + 'name', + 'mimeType', + 'size', + 'width', + 'height', + 'duration', + 'waveform', + 'linkPreviewData', +}; + +/// Builds a `messages` table row out of a `flutter_chat_core` [core.Message]. +/// +/// Throws an [ArgumentError] for message types that can't be persisted +/// ([core.TextStreamMessage], [core.UnsupportedMessage]). +Map messageToRow(core.Message message) { + if (message is core.TextStreamMessage) { + throw ArgumentError( + 'TextStreamMessage is a client-side only placeholder and cannot be ' + 'persisted. Send a TextMessage once streaming is complete.', ); - if (index >= 0) { - final otherUser = users[index]; - imageUrl = otherUser['imageUrl'] as String?; - name = '${otherUser['firstName'] ?? ''} ${otherUser['lastName'] ?? ''}' - .trim(); - } } - data['imageUrl'] = imageUrl; - data['name'] = name; - data['users'] = users; - data['id'] = data['id'].toString(); - if (data['lastMessages'] != null) { - final lastMessages = data['lastMessages'].map((lm) { - final author = users.firstWhere( - (u) => u['id'] == lm['authorId'], - orElse: () => {'id': lm['authorId'] as String}, - ); - lm['author'] = author; - lm['id'] = lm['id'].toString(); - lm['roomId'] = lm['roomId'].toString(); - return lm; - }).toList(); - data['lastMessages'] = lastMessages; + if (message is core.UnsupportedMessage) { + throw ArgumentError('Cannot persist an UnsupportedMessage.'); } - return types.Room.fromJson(data); + + final json = message.toJson(); + final row = { + for (final key in _messageRowKeys) + if (json.containsKey(key)) key: json[key], + }; + + final duration = row['duration']; + if (duration is int) { + row['duration'] = duration * 1000; // stored in milliseconds + } + + return row; } diff --git a/lib/src/widgets/user_online_status.dart b/lib/src/widgets/user_online_status.dart index 1aece95..484352b 100644 --- a/lib/src/widgets/user_online_status.dart +++ b/lib/src/widgets/user_online_status.dart @@ -1,28 +1,61 @@ import 'package:flutter/material.dart'; -import '../../flutter_supabase_chat_core.dart'; -/// This widget allows you to observe the online/offline status of a specific user -class UserOnlineStatusWidget extends StatelessWidget { - /// [uid] of the user to be observed - final String uid; - - /// [builder] the function that is called at each user state change event - final Widget Function(UserOnlineStatus status) builder; +import '../class/supabase_chat_core.dart'; +import '../class/user_online_status.dart'; - /// [UserOnlineStatusWidget] Constructor - /// Required [uid] : uid of the user to be observed - /// Required [builder] : is the function that is called at each user state change event +/// Rebuilds with the online/offline status of user [uid]. +class UserOnlineStatusWidget extends StatefulWidget { + /// Creates the widget. [chatCore] defaults to + /// [SupabaseChatCore.instance]. const UserOnlineStatusWidget({ super.key, required this.uid, required this.builder, + this.chatCore, }); + /// Id of the observed user. + final String uid; + + /// Called with the current status, and again on every change. + final Widget Function(UserOnlineStatus status) builder; + + /// Chat core used to observe the status. + final SupabaseChatCore? chatCore; + @override - Widget build(BuildContext context) => StreamBuilder( - stream: SupabaseChatCore.instance.userOnlineStatus(uid), - initialData: UserOnlineStatus.offline, - builder: (context, snapshot) => - builder(snapshot.data ?? UserOnlineStatus.offline), + State createState() => _UserOnlineStatusWidgetState(); +} + +class _UserOnlineStatusWidgetState extends State { + late Stream _stream; + + Stream _createStream() => + (widget.chatCore ?? SupabaseChatCore.instance).userOnlineStatus( + widget.uid, ); + + @override + void initState() { + super.initState(); + // Created once, not in build: every subscription opens (or shares) a + // Realtime channel. + _stream = _createStream(); + } + + @override + void didUpdateWidget(UserOnlineStatusWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.uid != widget.uid || oldWidget.chatCore != widget.chatCore) { + _stream = _createStream(); + } + } + + @override + Widget build(BuildContext context) => StreamBuilder( + stream: _stream, + initialData: UserOnlineStatus.offline, + builder: (context, snapshot) => + widget.builder(snapshot.data ?? UserOnlineStatus.offline), + ); } diff --git a/lib/src/widgets/user_status_detector/other_platform.dart b/lib/src/widgets/user_status_detector/other_platform.dart index 358cdae..782ef14 100644 --- a/lib/src/widgets/user_status_detector/other_platform.dart +++ b/lib/src/widgets/user_status_detector/other_platform.dart @@ -2,8 +2,10 @@ import 'package:flutter/material.dart'; import 'platforms_widgets_binding.dart'; +/// Creates the binding for the current platform. PlatformsWidgetsBinding getInstance() => OtherPlatformsWidgetsBinding(); +/// Native platforms: Flutter's own [WidgetsBinding] lifecycle events. class OtherPlatformsWidgetsBinding extends PlatformsWidgetsBinding { @override void addObserver(WidgetsBindingObserver state) { diff --git a/lib/src/widgets/user_status_detector/platforms_widgets_binding.dart b/lib/src/widgets/user_status_detector/platforms_widgets_binding.dart index 23c133b..3f3c039 100644 --- a/lib/src/widgets/user_status_detector/platforms_widgets_binding.dart +++ b/lib/src/widgets/user_status_detector/platforms_widgets_binding.dart @@ -1,6 +1,11 @@ import 'package:flutter/material.dart'; +/// Delivers app lifecycle changes (foreground/background) to a +/// [WidgetsBindingObserver] in a platform-appropriate way. abstract class PlatformsWidgetsBinding { + /// Starts delivering lifecycle changes to [state]. void addObserver(WidgetsBindingObserver state); + + /// Stops delivering lifecycle changes to [state]. void removeObserver(WidgetsBindingObserver state); } diff --git a/lib/src/widgets/user_status_detector/web_platform.dart b/lib/src/widgets/user_status_detector/web_platform.dart index caff831..002f027 100644 --- a/lib/src/widgets/user_status_detector/web_platform.dart +++ b/lib/src/widgets/user_status_detector/web_platform.dart @@ -4,8 +4,11 @@ import 'package:web/web.dart' as web; import 'platforms_widgets_binding.dart'; +/// Creates the binding for the current platform. PlatformsWidgetsBinding getInstance() => WebWidgetsBinding(); +/// Web: browser window focus/blur, since browser tabs rarely report +/// Flutter lifecycle changes. class WebWidgetsBinding extends PlatformsWidgetsBinding { JSFunction? _focusListener; JSFunction? _blurListener; diff --git a/lib/src/widgets/user_status_detector/widgets_binding.dart b/lib/src/widgets/user_status_detector/widgets_binding.dart index 9f3da58..3eaf7f2 100644 --- a/lib/src/widgets/user_status_detector/widgets_binding.dart +++ b/lib/src/widgets/user_status_detector/widgets_binding.dart @@ -1,4 +1,7 @@ -import 'other_platform.dart' if (dart.library.html) './web_platform.dart'; +import 'other_platform.dart' if (dart.library.js_interop) 'web_platform.dart'; import 'platforms_widgets_binding.dart'; -PlatformsWidgetsBinding get widgetsBinding => getInstance(); +/// Platform-specific app lifecycle binding, created once: the web +/// implementation keeps the focus/blur listeners it registered, so +/// removeObserver must be called on the same instance as addObserver. +final PlatformsWidgetsBinding widgetsBinding = getInstance(); diff --git a/lib/src/widgets/user_status_observer.dart b/lib/src/widgets/user_status_observer.dart index 8907ba9..4e66748 100644 --- a/lib/src/widgets/user_status_observer.dart +++ b/lib/src/widgets/user_status_observer.dart @@ -1,22 +1,31 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import '../class/supabase_chat_core.dart'; import '../class/user_online_status.dart'; import 'user_status_detector/widgets_binding.dart'; -/// This widget takes care of observing the state of the application if it is foregrounded -/// or if it is reduced. At each state change it notifies on the Supabase Realtime channel the state -/// online/offline of the user +/// Publishes the logged-in user's online status over Supabase Realtime +/// Presence, following the app lifecycle: online while the app is in the +/// foreground (or the browser tab is focused), offline otherwise. +/// +/// Place it once, high in the widget tree (e.g. around your home page). class UserOnlineStateObserver extends StatefulWidget { - /// [child] Child widgets in the widget tree - final Widget child; - - /// [UserOnlineStateObserver] Widget constructor + /// Creates the observer. [chatCore] defaults to + /// [SupabaseChatCore.instance]. const UserOnlineStateObserver({ super.key, required this.child, + this.chatCore, }); + /// Child widget. + final Widget child; + + /// Chat core whose presence is published. + final SupabaseChatCore? chatCore; + @override State createState() => _UserOnlineStateObserverState(); @@ -24,15 +33,25 @@ class UserOnlineStateObserver extends StatefulWidget { class _UserOnlineStateObserverState extends State with WidgetsBindingObserver { + SupabaseChatCore get _chatCore => + widget.chatCore ?? SupabaseChatCore.instance; + @override void initState() { - widgetsBinding.addObserver(this); super.initState(); + widgetsBinding.addObserver(this); + // No lifecycle event fires for the initial state: an app that just + // started is in the foreground. + final state = WidgetsBinding.instance.lifecycleState; + if (state == null || state == AppLifecycleState.resumed) { + unawaited(_chatCore.setPresenceStatus(UserOnlineStatus.online)); + } } @override void dispose() { widgetsBinding.removeObserver(this); + unawaited(_chatCore.setPresenceStatus(UserOnlineStatus.offline)); super.dispose(); } @@ -40,14 +59,12 @@ class _UserOnlineStateObserverState extends State void didChangeAppLifecycleState(AppLifecycleState state) { switch (state) { case AppLifecycleState.resumed: - SupabaseChatCore.instance.setPresenceStatus(UserOnlineStatus.online); - break; + unawaited(_chatCore.setPresenceStatus(UserOnlineStatus.online)); case AppLifecycleState.inactive: case AppLifecycleState.paused: case AppLifecycleState.hidden: case AppLifecycleState.detached: - SupabaseChatCore.instance.setPresenceStatus(UserOnlineStatus.offline); - break; + unawaited(_chatCore.setPresenceStatus(UserOnlineStatus.offline)); } } diff --git a/pubspec.yaml b/pubspec.yaml index 73e18c9..d136951 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,27 +2,36 @@ name: flutter_supabase_chat_core description: > Actively maintained, community-driven Supabase BaaS for chat applications with an optional chat UI. -version: 1.6.2 +version: 2.0.0-rc.1 homepage: https://flutter-supabase-chat-core.insideapp.it repository: https://github.com/insideapp-srl/flutter_supabase_chat_core +issue_tracker: https://github.com/insideapp-srl/flutter_supabase_chat_core/issues +topics: + - chat + - supabase + - realtime + - messaging + - flutter-chat-ui environment: - sdk: '>=3.4.0 <4.0.0' - flutter: '>=3.22.0' + sdk: '>=3.8.0 <4.0.0' + flutter: '>=3.32.0' dependencies: flutter: sdk: flutter - flutter_chat_types: ^3.6.2 + flutter_chat_core: ^2.9.0 meta: ^1.16.0 mime: '>=1.0.2 <3.0.0' - supabase_flutter: ^2.9.1 + supabase_flutter: ^2.17.2 uuid: ^4.5.1 web: ^1.1.1 dev_dependencies: - flutter_lints: ^5.0.0 + flutter_lints: ^6.0.0 + flutter_test: + sdk: flutter screenshots: - - description: 'Flutter Supabase Chat Core - screenshot 1' - path: doc/static/img/flayer_chat_ui_screenshot.png \ No newline at end of file + - description: 'Example app: Telegram-style chat with replies, reactions and voice messages' + path: doc/static/img/example_light.jpg \ No newline at end of file diff --git a/supabase/.gitignore b/supabase/.gitignore new file mode 100644 index 0000000..ad9264f --- /dev/null +++ b/supabase/.gitignore @@ -0,0 +1,8 @@ +# Supabase +.branches +.temp + +# dotenvx +.env.keys +.env.local +.env.*.local diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 0000000..4ff5152 --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1,415 @@ +# For detailed configuration reference documentation, visit: +# https://supabase.com/docs/guides/local-development/cli/config +# A string used to distinguish different Supabase projects on the same host. Defaults to the +# working directory name when running `supabase init`. +project_id = "flutter_supabase_chat_core" + +[api] +enabled = true +# Port to use for the API URL. +port = 54321 +# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API +# endpoints. `public` and `graphql_public` schemas are included by default. +schemas = ["public", "graphql_public", "chats"] +# Extra schemas to add to the search_path of every request. +extra_search_path = ["public", "extensions"] +# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size +# for accidental or malicious requests. +max_rows = 1000 +# Controls whether new tables, views, sequences and functions created in the `public` schema by +# `postgres` are reachable through the Data API roles (`anon`, `authenticated`, `service_role`) +# without explicit GRANTs, matching the cloud default. Set to `false` to require explicit GRANTs +# instead. Left unset, a fresh project falls back to `true`. +# auto_expose_new_tables = true + +[api.tls] +# Enable HTTPS endpoints locally using a self-signed certificate. +enabled = false +# Paths to self-signed certificate pair. +# cert_path = "../certs/my-cert.pem" +# key_path = "../certs/my-key.pem" + +[db] +# Port to use for the local database URL. +port = 54322 +# Port used by db diff command to initialize the shadow database. +shadow_port = 54320 +# Maximum amount of time to wait for health check when starting the local database. +health_timeout = "2m" +# The database major version to use. This has to be the same as your remote database's. Run `SHOW +# server_version;` on the remote database to check. +major_version = 17 + +[db.pooler] +enabled = false +# Port to use for the local connection pooler. +port = 54329 +# Specifies when a server connection can be reused by other clients. +# Configure one of the supported pooler modes: `transaction`, `session`. +pool_mode = "transaction" +# How many server connections to allow per user/database pair. +default_pool_size = 20 +# Maximum number of client connections allowed. +max_client_conn = 100 + +# [db.vault] +# secret_key = "env(SECRET_VALUE)" + +[db.migrations] +# If disabled, migrations will be skipped during a db push or reset. +enabled = true +# Specifies an ordered list of schema files, directories, or glob patterns that describe your database. +# Supports paths relative to supabase directory: "./schemas/*.sql", "./database". +schema_paths = [] + +[db.seed] +# If enabled, seeds the database after migrations during a db reset. +enabled = true +# Specifies an ordered list of seed files to load during db reset. +# Supports glob patterns relative to supabase directory: "./seeds/*.sql" +sql_paths = ["./seed.sql"] + +[db.network_restrictions] +# Enable management of network restrictions. +enabled = false +# List of IPv4 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv4 connections. Set empty array to block all IPs. +allowed_cidrs = ["0.0.0.0/0"] +# List of IPv6 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv6 connections. Set empty array to block all IPs. +allowed_cidrs_v6 = ["::/0"] + +# Uncomment to reject non-secure connections to the database. +# [db.ssl_enforcement] +# enabled = true + +[realtime] +enabled = true +# Bind realtime via either IPv4 or IPv6. (default: IPv4) +# ip_version = "IPv6" +# The maximum length in bytes of HTTP request headers. (default: 4096) +# max_header_length = 4096 + +[studio] +enabled = true +# Port to use for Supabase Studio. +port = 54323 +# External URL of the API server that frontend connects to. +api_url = "http://127.0.0.1" +# OpenAI API Key to use for Supabase AI in the Supabase Studio. +openai_api_key = "env(OPENAI_API_KEY)" + +# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they +# are monitored, and you can view the emails that would have been sent from the web interface. +[local_smtp] +enabled = true +# Port to use for the email testing server web interface. +port = 54324 +# Uncomment to expose additional ports for testing user applications that send emails. +# smtp_port = 54325 +# pop3_port = 54326 +# admin_email = "admin@email.com" +# sender_name = "Admin" + +[storage] +enabled = true +# The maximum file size allowed (e.g. "5MB", "500KB"). +file_size_limit = "50MiB" + +# Uncomment to configure local storage buckets +# [storage.buckets.images] +# public = false +# file_size_limit = "50MiB" +# allowed_mime_types = ["image/png", "image/jpeg"] +# objects_path = "./images" + +# Allow connections via S3 compatible clients +[storage.s3_protocol] +enabled = true + +# Image transformation API is available to Supabase Pro plan. +# [storage.image_transformation] +# enabled = true + +# Store analytical data in S3 for running ETL jobs over Iceberg Catalog +# This feature is only available on the hosted platform. +[storage.analytics] +enabled = false +max_namespaces = 5 +max_tables = 10 +max_catalogs = 2 + +# Analytics Buckets is available to Supabase Pro plan. +# [storage.analytics.buckets.my-warehouse] + +# Store vector embeddings in S3 for large and durable datasets +[storage.vector] +enabled = true +max_buckets = 10 +max_indexes = 5 + +# Vector Buckets is available to Supabase Pro plan. +# [storage.vector.buckets.documents-openai] + +[auth] +enabled = true +# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used +# in emails. +site_url = "http://127.0.0.1:3000" +# The public URL that Auth serves on. Defaults to the API external URL with `/auth/v1` appended. +# external_url = "" +# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +additional_redirect_urls = ["https://127.0.0.1:3000"] +# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). +jwt_expiry = 3600 +# JWT issuer URL. If not set, defaults to auth.external_url. +# jwt_issuer = "" +# Path to JWT signing key. DO NOT commit your signing keys file to git. +# signing_keys_path = "./signing_keys.json" +# If disabled, the refresh token will never expire. +enable_refresh_token_rotation = true +# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. +# Requires enable_refresh_token_rotation = true. +refresh_token_reuse_interval = 10 +# Allow/disallow new user signups to your project. +enable_signup = true +# Allow/disallow anonymous sign-ins to your project. +enable_anonymous_sign_ins = false +# Allow/disallow testing manual linking of accounts +enable_manual_linking = false +# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. +minimum_password_length = 6 +# Passwords that do not meet the following requirements will be rejected as weak. Supported values +# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols` +password_requirements = "" + +# Configure passkey sign-ins. +# [auth.passkey] +# enabled = false + +# Configure WebAuthn relying party settings (required when passkey is enabled). +# [auth.webauthn] +# rp_display_name = "Supabase" +# rp_id = "localhost" +# rp_origins = ["http://127.0.0.1:3000"] + +[auth.rate_limit] +# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled. +email_sent = 2 +# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled. +sms_sent = 30 +# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true. +anonymous_users = 30 +# Number of sessions that can be refreshed in a 5 minute interval per IP address. +token_refresh = 150 +# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users). +sign_in_sign_ups = 30 +# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address. +token_verifications = 30 +# Number of Web3 logins that can be made in a 5 minute interval per IP address. +web3 = 30 + +# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`. +# [auth.captcha] +# enabled = true +# provider = "hcaptcha" +# secret = "" + +[auth.email] +# Allow/disallow new user signups via email to your project. +enable_signup = true +# If enabled, a user will be required to confirm any email change on both the old, and new email +# addresses. If disabled, only the new email is required to confirm. +double_confirm_changes = true +# If enabled, users need to confirm their email address before signing in. +enable_confirmations = false +# If enabled, users will need to reauthenticate or have logged in recently to change their password. +secure_password_change = false +# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. +max_frequency = "1s" +# Number of characters used in the email OTP. +otp_length = 6 +# Number of seconds before the email OTP expires (defaults to 1 hour). +otp_expiry = 3600 + +# Use a production-ready SMTP server +# [auth.email.smtp] +# enabled = true +# host = "smtp.sendgrid.net" +# port = 587 +# user = "apikey" +# pass = "env(SENDGRID_API_KEY)" +# admin_email = "admin@email.com" +# sender_name = "Admin" + +# Uncomment to customize email template +# [auth.email.template.invite] +# subject = "You have been invited" +# content_path = "./supabase/templates/invite.html" + +# Uncomment to customize notification email template +# [auth.email.notification.password_changed] +# enabled = true +# subject = "Your password has been changed" +# content_path = "./supabase/templates/password_changed_notification.html" + +[auth.sms] +# Allow/disallow new user signups via SMS to your project. +enable_signup = false +# If enabled, users need to confirm their phone number before signing in. +enable_confirmations = false +# Template for sending OTP to users +template = "Your code is {{ .Code }}" +# Controls the minimum amount of time that must pass before sending another sms otp. +max_frequency = "5s" + +# Use pre-defined map of phone number to OTP for testing. +# [auth.sms.test_otp] +# 4152127777 = "123456" + +# Configure logged in session timeouts. +# [auth.sessions] +# Force log out after the specified duration. +# timebox = "24h" +# Force log out if the user has been inactive longer than the specified duration. +# inactivity_timeout = "8h" + +# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object. +# [auth.hook.before_user_created] +# enabled = true +# uri = "pg-functions://postgres/auth/before-user-created-hook" + +# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. +# [auth.hook.custom_access_token] +# enabled = true +# uri = "pg-functions:////" + +# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. +[auth.sms.twilio] +enabled = false +account_sid = "" +message_service_sid = "" +# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: +auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" + +# Multi-factor-authentication is available to Supabase Pro plan. +[auth.mfa] +# Control how many MFA factors can be enrolled at once per user. +max_enrolled_factors = 10 + +# Control MFA via App Authenticator (TOTP) +[auth.mfa.totp] +enroll_enabled = false +verify_enabled = false + +# Configure MFA via Phone Messaging +[auth.mfa.phone] +enroll_enabled = false +verify_enabled = false +otp_length = 6 +template = "Your code is {{ .Code }}" +max_frequency = "5s" + +# Configure MFA via WebAuthn +# [auth.mfa.web_authn] +# enroll_enabled = true +# verify_enabled = true + +# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, +# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, +# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`. +[auth.external.apple] +enabled = false +client_id = "" +# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: +secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" +# Overrides the default auth callback URL derived from auth.external_url. +redirect_uri = "" +# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, +# or any other third-party OIDC providers. +url = "" +# If enabled, the nonce check will be skipped. Required for local sign in with Google auth. +skip_nonce_check = false +# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address. +email_optional = false + +# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard. +# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting. +[auth.web3.solana] +enabled = false + +# Use Firebase Auth as a third-party provider alongside Supabase Auth. +[auth.third_party.firebase] +enabled = false +# project_id = "my-firebase-project" + +# Use Auth0 as a third-party provider alongside Supabase Auth. +[auth.third_party.auth0] +enabled = false +# tenant = "my-auth0-tenant" +# tenant_region = "us" + +# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. +[auth.third_party.aws_cognito] +enabled = false +# user_pool_id = "my-user-pool-id" +# user_pool_region = "us-east-1" + +# Use Clerk as a third-party provider alongside Supabase Auth. +[auth.third_party.clerk] +enabled = false +# Obtain from https://clerk.com/setup/supabase +# domain = "example.clerk.accounts.dev" + +# OAuth server configuration +[auth.oauth_server] +# Enable OAuth server functionality +enabled = false +# Path for OAuth consent flow UI +authorization_url_path = "/oauth/consent" +# Allow dynamic client registration +allow_dynamic_registration = false + +[edge_runtime] +enabled = true +# Supported request policies: `oneshot`, `per_worker`. +# `per_worker` (default) — enables hot reload during local development. +# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks). +policy = "per_worker" +# Port to attach the Chrome inspector for debugging edge functions. +inspector_port = 8083 +# The Deno major version to use. +deno_version = 2 + +# [edge_runtime.secrets] +# secret_key = "env(SECRET_VALUE)" + +[analytics] +enabled = true +port = 54327 +# Configure one of the supported backends: `postgres`, `bigquery`. +backend = "postgres" + +# Experimental features may be deprecated any time +[experimental] +# Configures Postgres storage engine to use OrioleDB (S3) +orioledb_version = "" +# Configures S3 bucket URL, eg. .s3-.amazonaws.com +s3_host = "env(S3_HOST)" +# Configures S3 bucket region, eg. us-east-1 +s3_region = "env(S3_REGION)" +# Configures AWS_ACCESS_KEY_ID for S3 bucket +s3_access_key = "env(S3_ACCESS_KEY)" +# Configures AWS_SECRET_ACCESS_KEY for S3 bucket +s3_secret_key = "env(S3_SECRET_KEY)" + +# pg-delta is the schema diff engine for db diff / db pull / db remote commit. +# Set enabled = false to fall back to the legacy migra engine. +[experimental.pgdelta] +enabled = true +# Directory under `supabase/` where declarative files are written. +# declarative_schema_path = "./schemas" +# JSON string passed through to pg-delta SQL formatting. When omitted, SQL is +# formatted with uppercase keywords, indent 2, max width 180, trailing commas, +# and column/key alignment. Set to "null" to emit raw, unformatted SQL. +# format_options = "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":180,\"commaStyle\":\"trailing\"}" diff --git a/supabase/functions/cleanup-media/index.ts b/supabase/functions/cleanup-media/index.ts new file mode 100644 index 0000000..3869c46 --- /dev/null +++ b/supabase/functions/cleanup-media/index.ts @@ -0,0 +1,101 @@ +// cleanup-media: deletes the storage objects of deleted chat messages. +// +// Deleting a message removes its row immediately; when it referenced a file +// in Supabase Storage, the chats.queue_media_for_deletion trigger records the +// object in chats.media_pending_deletion, because SQL alone can't delete +// objects from the storage backend. This function drains that queue: it +// removes each queued object through the Storage API and marks the row as +// processed. +// +// Invoke it periodically (see example/utils/sql/06_cleanup_media_schedule.sql +// for a pg_cron + pg_net schedule), or manually: +// curl -X POST "$SUPABASE_URL/functions/v1/cleanup-media" \ +// -H "Authorization: Bearer $SUPABASE_SERVICE_ROLE_KEY" +// +// Safe to call by anyone holding a valid JWT: it only ever deletes objects +// whose message is already gone, and it is idempotent. +import { createClient } from "jsr:@supabase/supabase-js@2"; + +const BATCH_SIZE = 500; +const SCHEMA = Deno.env.get("CHAT_SCHEMA") ?? "chats"; + +interface PendingRow { + id: string; + bucketId: string; + path: string; +} + +Deno.serve(async (req) => { + if (req.method !== "POST" && req.method !== "GET") { + return Response.json({ error: "method not allowed" }, { status: 405 }); + } + + const url = Deno.env.get("SUPABASE_URL"); + const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); + if (!url || !serviceKey) { + return Response.json( + { error: "SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY not configured" }, + { status: 500 }, + ); + } + + const supabase = createClient(url, serviceKey, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + + const { data, error } = await supabase + .schema(SCHEMA) + .from("media_pending_deletion") + .select("id, bucketId, path") + .is("processedAt", null) + .order("createdAt", { ascending: true }) + .limit(BATCH_SIZE); + + if (error) { + console.error("cleanup-media: cannot read the queue", error); + return Response.json({ error: error.message }, { status: 500 }); + } + + const rows = (data ?? []) as PendingRow[]; + const byBucket = new Map(); + for (const row of rows) { + const list = byBucket.get(row.bucketId) ?? []; + list.push(row); + byBucket.set(row.bucketId, list); + } + + const processed: string[] = []; + const failures: { bucketId: string; error: string }[] = []; + + for (const [bucketId, bucketRows] of byBucket) { + // Storage's remove() treats already-missing objects as success, so a + // retried batch never gets stuck on a file deleted by a previous run. + const { error: removeError } = await supabase.storage + .from(bucketId) + .remove(bucketRows.map((r) => r.path)); + if (removeError) { + console.error(`cleanup-media: cannot remove from ${bucketId}`, removeError); + failures.push({ bucketId, error: removeError.message }); + continue; + } + processed.push(...bucketRows.map((r) => r.id)); + } + + if (processed.length > 0) { + const { error: updateError } = await supabase + .schema(SCHEMA) + .from("media_pending_deletion") + .update({ processedAt: new Date().toISOString() }) + .in("id", processed); + if (updateError) { + console.error("cleanup-media: cannot mark rows processed", updateError); + return Response.json({ error: updateError.message }, { status: 500 }); + } + } + + return Response.json({ + processed: processed.length, + failed: failures, + more: rows.length === BATCH_SIZE, + }, { status: failures.length > 0 ? 207 : 200 }); +}); diff --git a/supabase/migrations/00000000000000_chats_schema.sql b/supabase/migrations/00000000000000_chats_schema.sql new file mode 100644 index 0000000..a8fc6ae --- /dev/null +++ b/supabase/migrations/00000000000000_chats_schema.sql @@ -0,0 +1,5 @@ +-- Local development stack only (`supabase start`): PostgREST is configured to +-- expose the `chats` schema (see config.toml) and refuses to start if it +-- doesn't exist yet. The real schema is applied afterwards from +-- example/utils/sql by tool/local_db.sh, exactly as end users apply it. +CREATE SCHEMA IF NOT EXISTS chats; diff --git a/supabase/tests/database/chats_test.sql b/supabase/tests/database/chats_test.sql new file mode 100644 index 0000000..f88b1a0 --- /dev/null +++ b/supabase/tests/database/chats_test.sql @@ -0,0 +1,230 @@ +-- pgTAP behaviour tests for the chat schema (example/utils/sql). +-- Run against the local stack after tool/local_db.sh: +-- supabase test db +-- Everything runs in one transaction that is rolled back at the end. +BEGIN; +CREATE EXTENSION IF NOT EXISTS pgtap WITH SCHEMA extensions; +SET search_path = extensions, public; + +SELECT plan(67); + +-- --------------------------------------------------------------------------- +-- Fixtures: four auth users (the chats.users rows come from the trigger). +-- --------------------------------------------------------------------------- +INSERT INTO auth.users (id, email, aud, role, raw_user_meta_data) VALUES + ('fa000000-0000-0000-0000-000000000001', 'alice@test.dev', 'authenticated', 'authenticated', '{"first_name":"Alice","last_name":"Liddell"}'), + ('fb000000-0000-0000-0000-000000000002', 'bob@test.dev', 'authenticated', 'authenticated', '{"first_name":"Bob","last_name":"Builder"}'), + ('fc000000-0000-0000-0000-000000000003', 'carol@test.dev', 'authenticated', 'authenticated', '{"given_name":"Carol"}'), + ('fd000000-0000-0000-0000-000000000004', 'dave@test.dev', 'authenticated', 'authenticated', NULL); + +CREATE TEMP TABLE ids (k text PRIMARY KEY, v uuid) ON COMMIT DROP; +GRANT ALL ON ids TO authenticated; + +CREATE FUNCTION pg_temp.login(uid uuid) RETURNS void LANGUAGE sql AS $$ + SELECT set_config('role', 'authenticated', true), + set_config('request.jwt.claims', json_build_object('sub', uid, 'role', 'authenticated')::text, true); +$$; +CREATE FUNCTION pg_temp.logout() RETURNS void LANGUAGE sql AS $$ + SELECT set_config('role', 'postgres', true), + set_config('request.jwt.claims', '', true); +$$; + +-- --------------------------------------------------------------------------- +-- handle_new_user +-- --------------------------------------------------------------------------- +SELECT is((SELECT count(*)::int FROM chats.users WHERE id::text ~ '^f[a-d]000000-'), 4, 'a chats.users row is created for every auth user'); +SELECT is((SELECT "firstName" || ' ' || "lastName" FROM chats.users WHERE id = 'fa000000-0000-0000-0000-000000000001'), + 'Alice Liddell', 'names are taken from sign-up metadata'); +SELECT is((SELECT "firstName" FROM chats.users WHERE id = 'fc000000-0000-0000-0000-000000000003'), + 'Carol', 'OAuth-style given_name is supported'); +SELECT is((SELECT "firstName" FROM chats.users WHERE id = 'fd000000-0000-0000-0000-000000000004'), + NULL, 'missing metadata leaves names empty'); + +-- --------------------------------------------------------------------------- +-- chats.users RLS +-- --------------------------------------------------------------------------- +SELECT pg_temp.login('fa000000-0000-0000-0000-000000000001'); +SELECT is((SELECT count(*)::int FROM chats.users WHERE id::text ~ '^f[a-d]000000-'), 4, 'authenticated users can read every profile'); +UPDATE chats.users SET "firstName" = 'Al' WHERE id = 'fa000000-0000-0000-0000-000000000001'; +UPDATE chats.users SET "firstName" = 'Hacked' WHERE id = 'fb000000-0000-0000-0000-000000000002'; +SELECT pg_temp.logout(); +SELECT is((SELECT "firstName" FROM chats.users WHERE id = 'fa000000-0000-0000-0000-000000000001'), 'Al', 'a user can update its own profile'); +SELECT is((SELECT "firstName" FROM chats.users WHERE id = 'fb000000-0000-0000-0000-000000000002'), 'Bob', 'a user cannot update someone else''s profile'); + +SET LOCAL role anon; +SELECT is((SELECT count(*)::int FROM chats.users), 0, 'anon cannot read profiles'); +RESET role; + +-- --------------------------------------------------------------------------- +-- create_room: direct +-- --------------------------------------------------------------------------- +SELECT pg_temp.login('fa000000-0000-0000-0000-000000000001'); +SELECT throws_ok($$INSERT INTO chats.rooms (type) VALUES ('group')$$, '42501', NULL, 'rooms cannot be inserted directly'); + +INSERT INTO ids SELECT 'direct_ab', chats.create_room('direct', ARRAY['fb000000-0000-0000-0000-000000000002']::uuid[]); +SELECT isnt((SELECT v FROM ids WHERE k = 'direct_ab'), NULL, 'create_room returns the new direct room id'); +SELECT is(chats.create_room('direct', ARRAY['fb000000-0000-0000-0000-000000000002']::uuid[]), + (SELECT v FROM ids WHERE k = 'direct_ab'), 'an existing direct room is reused'); +SELECT pg_temp.login('fb000000-0000-0000-0000-000000000002'); +SELECT is(chats.create_room('direct', ARRAY['fa000000-0000-0000-0000-000000000001']::uuid[]), + (SELECT v FROM ids WHERE k = 'direct_ab'), 'the direct room is reused from the other side too'); +INSERT INTO ids SELECT 'direct_ab_ws', chats.create_room('direct', ARRAY['fa000000-0000-0000-0000-000000000001']::uuid[], p_metadata => '{"workspaceId":"w1"}'); +SELECT isnt((SELECT v FROM ids WHERE k = 'direct_ab_ws'), (SELECT v FROM ids WHERE k = 'direct_ab'), + 'a direct room in another metadata scope is a different room'); +SELECT is(chats.create_room('direct', ARRAY['fa000000-0000-0000-0000-000000000001']::uuid[], p_metadata => '{"workspaceId":"w1"}'), + (SELECT v FROM ids WHERE k = 'direct_ab_ws'), 'the scoped direct room is reused within its scope'); +SELECT throws_ok($$SELECT chats.create_room('direct', ARRAY['fa000000-0000-0000-0000-000000000001','fc000000-0000-0000-0000-000000000003']::uuid[])$$, + '22023', NULL, 'a direct room needs exactly two members'); +SELECT throws_ok($$SELECT chats.create_room('direct', ARRAY['eeeeeeee-0000-0000-0000-000000000000']::uuid[])$$, + '23503', NULL, 'unknown users are rejected'); +SELECT throws_ok($$SELECT chats.create_room('channel', ARRAY[]::uuid[])$$, '22023', NULL, 'unknown room types are rejected'); + +SELECT pg_temp.logout(); +SELECT throws_ok($$SELECT chats.create_room('group', ARRAY[]::uuid[])$$, '42501', NULL, 'create_room requires a logged in user'); + +-- --------------------------------------------------------------------------- +-- create_room: group, rooms_l +-- --------------------------------------------------------------------------- +SELECT pg_temp.login('fa000000-0000-0000-0000-000000000001'); +INSERT INTO ids SELECT 'group_abc', chats.create_room('group', + ARRAY['fb000000-0000-0000-0000-000000000002','fc000000-0000-0000-0000-000000000003']::uuid[], 'Team', NULL, '{"color":"red"}'); +SELECT is((SELECT role FROM chats.room_members WHERE "roomId" = (SELECT v FROM ids WHERE k = 'group_abc') AND "userId" = auth.uid()), + 'admin', 'the group creator is admin'); +SELECT is((SELECT count(*)::int FROM chats.room_members WHERE "roomId" = (SELECT v FROM ids WHERE k = 'group_abc')), + 3, 'every group member is added'); +SELECT is((SELECT name FROM chats.rooms_l WHERE id = (SELECT v FROM ids WHERE k = 'direct_ab')), + 'Bob Builder', 'rooms_l names a direct room after the other member'); +SELECT is((SELECT jsonb_array_length(users) FROM chats.rooms_l WHERE id = (SELECT v FROM ids WHERE k = 'group_abc')), + 3, 'rooms_l embeds the members'); +SELECT is((SELECT count(*)::int FROM chats.rooms_l), 3, 'alice sees her three rooms only'); + +SELECT pg_temp.login('fd000000-0000-0000-0000-000000000004'); +SELECT is((SELECT count(*)::int FROM chats.rooms), 0, 'non-members see no rooms'); +SELECT is((SELECT count(*)::int FROM chats.room_members), 0, 'non-members see no memberships'); +SELECT throws_ok(format($$INSERT INTO chats.room_members ("roomId", "userId") VALUES (%L, auth.uid())$$, (SELECT v FROM ids WHERE k = 'group_abc')), + '42501', NULL, 'knowing a room id is not enough to join it'); + +-- --------------------------------------------------------------------------- +-- Messages +-- --------------------------------------------------------------------------- +SELECT pg_temp.login('fa000000-0000-0000-0000-000000000001'); +INSERT INTO ids VALUES ('m1', 'f0000000-0000-0000-0000-000000000001'); +SELECT lives_ok(format($$INSERT INTO chats.messages (id, "roomId", "authorId", type, text, status, "createdAt") + VALUES ('f0000000-0000-0000-0000-000000000001', %L, auth.uid(), 'text', 'hello', 'sending', now() - interval '1 minute')$$, + (SELECT v FROM ids WHERE k = 'group_abc')), + 'a member can send a message'); +SELECT is((SELECT status FROM chats.messages WHERE id = 'f0000000-0000-0000-0000-000000000001'), 'sent', 'inserted messages are marked sent'); +SELECT throws_ok(format($$INSERT INTO chats.messages ("roomId", "authorId", type, text) VALUES (%L, 'fb000000-0000-0000-0000-000000000002', 'text', 'spoof')$$, + (SELECT v FROM ids WHERE k = 'group_abc')), + '42501', NULL, 'a message cannot be sent on behalf of someone else'); +SELECT is((SELECT "lastMessages" -> 0 ->> 'text' FROM chats.rooms WHERE id = (SELECT v FROM ids WHERE k = 'group_abc')), + 'hello', 'the room keeps a snapshot of its last message'); + +SELECT pg_temp.login('fb000000-0000-0000-0000-000000000002'); +SELECT is((SELECT "unreadCount" FROM chats.rooms_l WHERE id = (SELECT v FROM ids WHERE k = 'group_abc')), 1, 'bob has one unread message'); +UPDATE chats.messages SET text = 'edited by bob' WHERE id = 'f0000000-0000-0000-0000-000000000001'; +DELETE FROM chats.messages WHERE id = 'f0000000-0000-0000-0000-000000000001'; +SELECT is((SELECT text FROM chats.messages WHERE id = 'f0000000-0000-0000-0000-000000000001'), 'hello', + 'only the author can edit, and a plain member cannot delete someone else''s message'); + +SELECT lives_ok($$SELECT chats.toggle_message_reaction('f0000000-0000-0000-0000-000000000001', '👍')$$, 'a member can react'); +SELECT pg_temp.login('fc000000-0000-0000-0000-000000000003'); +SELECT is(chats.toggle_message_reaction('f0000000-0000-0000-0000-000000000001', '👍') -> '👍', + '["fb000000-0000-0000-0000-000000000002", "fc000000-0000-0000-0000-000000000003"]'::jsonb, 'reactions accumulate per user'); +SELECT is(chats.toggle_message_reaction('f0000000-0000-0000-0000-000000000001', '👍') -> '👍', + '["fb000000-0000-0000-0000-000000000002"]'::jsonb, 'reacting again removes the reaction'); +SELECT pg_temp.login('fd000000-0000-0000-0000-000000000004'); +SELECT throws_ok($$SELECT chats.toggle_message_reaction('f0000000-0000-0000-0000-000000000001', '👍')$$, 'P0002', NULL, 'non-members cannot react'); +SELECT throws_ok(format($$SELECT chats.mark_room_as_read(%L)$$, (SELECT v FROM ids WHERE k = 'group_abc')), '42501', NULL, 'non-members cannot mark a room as read'); +SELECT is((SELECT count(*)::int FROM chats.messages), 0, 'non-members cannot read messages'); + +SELECT pg_temp.login('fb000000-0000-0000-0000-000000000002'); +CREATE TEMP TABLE times ON COMMIT DROP AS + SELECT "updatedAt" AS t FROM chats.rooms WHERE id = (SELECT v FROM ids WHERE k = 'group_abc'); +SELECT lives_ok(format($$SELECT chats.mark_room_as_read(%L)$$, (SELECT v FROM ids WHERE k = 'group_abc')), 'a member can mark a room as read'); +SELECT is((SELECT "updatedAt" FROM chats.rooms WHERE id = (SELECT v FROM ids WHERE k = 'group_abc')), (SELECT t FROM times), + 'reading a room does not move it up the rooms list'); +SELECT is((SELECT "unreadCount" FROM chats.rooms_l WHERE id = (SELECT v FROM ids WHERE k = 'group_abc')), 0, 'marking as read clears the unread count'); +SELECT is((SELECT status FROM chats.messages WHERE id = 'f0000000-0000-0000-0000-000000000001'), 'seen', 'marking as read flips other members'' messages to seen'); + +-- --------------------------------------------------------------------------- +-- Per-member preferences and pinned messages +-- --------------------------------------------------------------------------- +SELECT lives_ok(format($$SELECT chats.pin_room(%L, true)$$, (SELECT v FROM ids WHERE k = 'group_abc')), 'a member can pin a room'); +SELECT isnt((SELECT "pinnedAt" FROM chats.rooms_l WHERE id = (SELECT v FROM ids WHERE k = 'group_abc')), NULL, 'rooms_l exposes the own pin'); +SELECT lives_ok(format($$SELECT chats.mute_room(%L, 'infinity')$$, (SELECT v FROM ids WHERE k = 'group_abc')), 'a member can mute a room for ever'); +SELECT pg_temp.login('fa000000-0000-0000-0000-000000000001'); +SELECT is((SELECT "pinnedAt" FROM chats.rooms_l WHERE id = (SELECT v FROM ids WHERE k = 'group_abc')), NULL, 'pins are per member'); +SELECT pg_temp.login('fb000000-0000-0000-0000-000000000002'); +SELECT throws_ok($$SELECT chats.set_message_pinned('f0000000-0000-0000-0000-000000000001', true)$$, '42501', NULL, 'plain group members cannot pin messages'); +SELECT pg_temp.login('fa000000-0000-0000-0000-000000000001'); +SELECT lives_ok($$SELECT chats.set_message_pinned('f0000000-0000-0000-0000-000000000001', true)$$, 'group admins can pin messages'); +SELECT pg_temp.logout(); +SELECT is((SELECT pinned FROM chats.messages WHERE id = 'f0000000-0000-0000-0000-000000000001'), true, 'the message is pinned'); +SELECT pg_temp.login('fb000000-0000-0000-0000-000000000002'); + +-- --------------------------------------------------------------------------- +-- Room administration +-- --------------------------------------------------------------------------- +UPDATE chats.rooms SET name = 'Renamed by bob' WHERE id = (SELECT v FROM ids WHERE k = 'group_abc'); +SELECT pg_temp.login('fa000000-0000-0000-0000-000000000001'); +SELECT is((SELECT name FROM chats.rooms WHERE id = (SELECT v FROM ids WHERE k = 'group_abc')), 'Team', 'plain members cannot rename a group'); +UPDATE chats.rooms SET name = 'Team 2' WHERE id = (SELECT v FROM ids WHERE k = 'group_abc'); +SELECT is((SELECT name FROM chats.rooms WHERE id = (SELECT v FROM ids WHERE k = 'group_abc')), 'Team 2', 'admins can rename a group'); +SELECT lives_ok(format($$INSERT INTO chats.room_members ("roomId", "userId") VALUES (%L, 'fd000000-0000-0000-0000-000000000004')$$, (SELECT v FROM ids WHERE k = 'group_abc')), + 'admins can add members'); +SELECT throws_ok(format($$INSERT INTO chats.room_members ("roomId", "userId") VALUES (%L, 'fc000000-0000-0000-0000-000000000003')$$, (SELECT v FROM ids WHERE k = 'direct_ab')), + '42501', NULL, 'direct rooms membership is fixed'); +UPDATE chats.room_members SET role = 'moderator' WHERE "roomId" = (SELECT v FROM ids WHERE k = 'group_abc') AND "userId" = 'fc000000-0000-0000-0000-000000000003'; +SELECT is((SELECT role FROM chats.room_members WHERE "roomId" = (SELECT v FROM ids WHERE k = 'group_abc') AND "userId" = 'fc000000-0000-0000-0000-000000000003'), + 'moderator', 'admins can change roles'); +SELECT lives_ok($$DELETE FROM chats.messages WHERE id = 'f0000000-0000-0000-0000-000000000001'$$, 'admins can delete any message of their group'); +SELECT pg_temp.logout(); +SELECT is((SELECT count(*)::int FROM chats.messages WHERE id = 'f0000000-0000-0000-0000-000000000001'), 0, 'the message is gone'); + +-- --------------------------------------------------------------------------- +-- Media cleanup queue and empty rooms +-- --------------------------------------------------------------------------- +SELECT pg_temp.login('fa000000-0000-0000-0000-000000000001'); +INSERT INTO chats.messages ("roomId", "authorId", type, source, name, size) +VALUES ((SELECT v FROM ids WHERE k = 'direct_ab'), auth.uid(), 'file', + 'http://127.0.0.1:54321/storage/v1/object/authenticated/chats_assets/' || (SELECT v FROM ids WHERE k = 'direct_ab') || '/x.pdf', 'x.pdf', 1); +DELETE FROM chats.messages WHERE type = 'file'; +SELECT pg_temp.logout(); +SELECT is((SELECT "bucketId" || ':' || path FROM chats.media_pending_deletion WHERE path LIKE '%/x.pdf'), + 'chats_assets:' || (SELECT v FROM ids WHERE k = 'direct_ab') || '/x.pdf', 'deleting a media message queues its file for cleanup'); + +SELECT pg_temp.login('fa000000-0000-0000-0000-000000000001'); +DELETE FROM chats.room_members WHERE "roomId" = (SELECT v FROM ids WHERE k = 'direct_ab_ws') AND "userId" = auth.uid(); +SELECT pg_temp.login('fb000000-0000-0000-0000-000000000002'); +DELETE FROM chats.room_members WHERE "roomId" = (SELECT v FROM ids WHERE k = 'direct_ab_ws') AND "userId" = auth.uid(); +SELECT pg_temp.logout(); +SET CONSTRAINTS ALL IMMEDIATE; +SELECT is((SELECT count(*)::int FROM chats.rooms WHERE id = (SELECT v FROM ids WHERE k = 'direct_ab_ws')), 0, 'a room is deleted once its last member leaves'); + +-- --------------------------------------------------------------------------- +-- Storage policies +-- --------------------------------------------------------------------------- +SELECT pg_temp.login('fd000000-0000-0000-0000-000000000004'); +SELECT is(chats.is_chat_member_path('not-a-uuid/file.png'), false, 'non-uuid folders are simply not members (no cast error)'); +SELECT is(chats.is_owner_path(auth.uid()::text || '/avatar.png'), true, 'users own their avatar folder'); +SELECT is(chats.is_chat_member_path((SELECT v FROM ids WHERE k = 'group_abc')::text || '/a.png'), true, 'room folders are open to members'); + +-- Realtime private channels +SELECT is(chats.is_chat_member_topic('chats:room:' || (SELECT v FROM ids WHERE k = 'group_abc')), true, 'members may join their room channel'); +SELECT is(chats.is_chat_member_topic('chats:room:' || (SELECT v FROM ids WHERE k = 'direct_ab')), false, 'non-members may not join a room channel'); +SELECT is(chats.is_chat_member_topic('chats:room:garbage'), false, 'malformed topics are rejected without errors'); +SELECT is(chats.is_chat_member_topic('other:topic'), false, 'unrelated topics are left to other policies'); +SELECT pg_temp.logout(); + +-- Partitions are reached only through chats.messages +SELECT is((SELECT count(*)::int FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhrelid + WHERE i.inhparent = 'chats.messages'::regclass AND NOT c.relrowsecurity), 0, + 'every messages partition has RLS enabled'); +SELECT is((SELECT count(*)::int FROM pg_inherits i + WHERE i.inhparent = 'chats.messages'::regclass + AND has_table_privilege('authenticated', i.inhrelid, 'SELECT')), 0, + 'messages partitions are not directly granted to API roles'); + +SELECT * FROM finish(); +ROLLBACK; diff --git a/test/integration/chat_integration_test.dart b/test/integration/chat_integration_test.dart new file mode 100644 index 0000000..4f9d620 --- /dev/null +++ b/test/integration/chat_integration_test.dart @@ -0,0 +1,660 @@ +@Tags(['integration']) +library; + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_chat_core/flutter_chat_core.dart' as core; +import 'package:flutter_supabase_chat_core/flutter_supabase_chat_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:uuid/uuid.dart'; + +import 'harness.dart'; + +core.Message _text(TestUser author, String text) => + core.Message.text(id: const Uuid().v7(), authorId: author.id, text: text); + +void main() { + late TestUser alice; + late TestUser bob; + late TestUser carol; + // Set once setUpAll created every user: tearDownAll must not touch the + // late fields when setUpAll failed half-way. + var ready = false; + + setUpAll(() async { + if (skipReason != null) return; + alice = await TestUser.create('Alice', 'Liddell'); + bob = await TestUser.create('Bob', 'Builder'); + carol = await TestUser.create('Carol', 'Danvers'); + ready = true; + }); + + tearDownAll(() async { + if (skipReason != null || !ready) return; + await alice.dispose(); + await bob.dispose(); + await carol.dispose(); + }); + + group('users', skip: skipReason, () { + test('profiles are created from sign-up metadata', () async { + expect(alice.chat.loggedUser?.fullName, 'Alice Liddell'); + final fetched = await bob.chat.user(uid: alice.id); + expect(fetched?.firstName, 'Alice'); + expect(fetched?.initials, 'AL'); + }); + + test('search matches every word against first/last name', () async { + final found = await alice.chat.users(filter: 'bob build', limit: 100); + expect(found.map((u) => u.id), contains(bob.id)); + final none = await alice.chat.users(filter: 'bob danvers', limit: 100); + expect(none.map((u) => u.id), isNot(contains(bob.id))); + }); + + test('excludeLoggedUser leaves the caller out', () async { + final all = await alice.chat.users(limit: 1000, excludeLoggedUser: true); + expect(all.map((u) => u.id), isNot(contains(alice.id))); + }); + + test('a filter with PostgREST syntax characters is safe', () async { + final found = await alice.chat.users(filter: 'a,b) or(x."', limit: 5); + expect(found, isEmpty); + }); + + test('updateUser updates the own profile only', () async { + await alice.chat.updateUser( + alice.chat.loggedUser!.copyWith(metadata: {'bio': 'hi'}), + ); + expect((await bob.chat.user(uid: alice.id))?.metadata, {'bio': 'hi'}); + + await bob.chat.updateUser(alice.asChatUser.copyWith(firstName: 'Evil')); + expect((await bob.chat.user(uid: alice.id))?.firstName, 'Alice'); + }); + }); + + group('rooms', skip: skipReason, () { + test( + 'a direct room is created once and named after the other user', + () async { + final fromAlice = await alice.chat.createRoom(bob.asChatUser); + final fromBob = await bob.chat.createRoom(alice.asChatUser); + expect(fromBob.id, fromAlice.id); + expect(fromAlice.type, ChatRoomType.direct); + expect(fromAlice.name, 'Bob Builder'); + expect(fromBob.name, 'Alice Liddell'); + expect( + fromAlice.users.map((u) => u.id), + unorderedEquals([alice.id, bob.id]), + ); + }, + ); + + test('a direct room with yourself is rejected', () async { + expect( + () => alice.chat.createRoom(alice.asChatUser), + throwsA(isA()), + ); + }); + + test('a group room gets its members and the creator is admin', () async { + final room = await alice.chat.createGroupRoom( + name: 'Wonderland', + users: [bob.asChatUser, carol.asChatUser], + metadata: {'topic': 'tea'}, + ); + expect(room.type, ChatRoomType.group); + expect(room.name, 'Wonderland'); + expect(room.metadata, {'topic': 'tea'}); + expect(room.roleOf(alice.id), ChatRoomRole.admin); + expect(room.roleOf(bob.id), isNull); + expect(room.users, hasLength(3)); + }); + + test('only admins rename a group, members can leave it', () async { + final room = await alice.chat.createGroupRoom( + name: 'Admins only', + users: [bob.asChatUser], + ); + await bob.chat.updateRoom(room.copyWith(name: 'Hijacked')); + expect((await alice.chat.getRoom(room.id))?.name, 'Admins only'); + + await alice.chat.updateRoom(room.copyWith(name: 'Renamed')); + expect((await bob.chat.getRoom(room.id))?.name, 'Renamed'); + + await alice.chat.addMembers(room.id, [carol.id]); + expect((await carol.chat.getRoom(room.id))?.users, hasLength(3)); + + await alice.chat.setMemberRole(room.id, carol.id, ChatRoomRole.moderator); + expect( + (await carol.chat.getRoom(room.id))?.roleOf(carol.id), + ChatRoomRole.moderator, + ); + + await bob.chat.leaveRoom(room.id); + expect(await bob.chat.getRoom(room.id), isNull); + expect((await alice.chat.getRoom(room.id))?.users, hasLength(2)); + + await alice.chat.removeMember(room.id, carol.id); + expect(await carol.chat.getRoom(room.id), isNull); + }); + + test('rooms are paginated and searchable', () async { + final user = await TestUser.create('Paula', 'Pager'); + addTearDown(user.dispose); + for (var i = 0; i < 5; i++) { + await user.chat.createGroupRoom(name: 'Page room $i', users: []); + } + final first = await user.chat.rooms(limit: 3); + final second = await user.chat.rooms(offset: 3, limit: 3); + expect(first, hasLength(3)); + expect(second, hasLength(2)); + expect({...first, ...second}, hasLength(5)); + expect(first.first.name, 'Page room 4', reason: 'newest first'); + + final searched = await user.chat.rooms(filter: 'room 2'); + expect(searched.map((r) => r.name), ['Page room 2']); + }); + + test('scoping hooks keep each scope separate', () async { + var scope = 'w1'; + final config = const SupabaseChatCoreConfig().copyWith( + defaultRoomMetadata: () => {'workspaceId': scope}, + roomsQueryFilter: (q) => q.eq('metadata->>workspaceId', scope), + roomFilter: (room) => room.metadata?['workspaceId'] == scope, + ); + final dan = await TestUser.create('Dan', 'Scoped', config: config); + final eve = await TestUser.create('Eve', 'Scoped', config: config); + addTearDown(dan.dispose); + addTearDown(eve.dispose); + + final inW1 = await dan.chat.createRoom(eve.asChatUser); + scope = 'w2'; + final inW2 = await dan.chat.createRoom(eve.asChatUser); + expect(inW2.id, isNot(inW1.id), reason: 'one direct room per scope'); + expect(inW2.metadata, {'workspaceId': 'w2'}); + + expect((await dan.chat.rooms()).map((r) => r.id), [inW2.id]); + scope = 'w1'; + expect((await eve.chat.rooms()).map((r) => r.id), [inW1.id]); + expect((await dan.chat.createRoom(eve.asChatUser)).id, inW1.id); + }); + }); + + group('messages', skip: skipReason, () { + late ChatRoom room; + + setUp(() async { + room = await alice.chat.createGroupRoom( + name: 'Messages ${const Uuid().v4()}', + users: [bob.asChatUser], + ); + }); + + test('a message is delivered in real time and reconciled', () async { + final aliceController = SupabaseChatController( + room: room, + chatCore: alice.chat, + autoMarkAsRead: false, + ); + final bobController = SupabaseChatController( + room: room, + chatCore: bob.chat, + autoMarkAsRead: false, + ); + addTearDown(aliceController.dispose); + addTearDown(bobController.dispose); + await Future.wait([ + aliceController.initialLoad, + bobController.initialLoad, + ]); + await realtimeReady(alice.client); + await realtimeReady(bob.client); + + final message = _text(alice, 'Hello Bob'); + final sending = aliceController.sendMessage(message); + expect( + aliceController.messages.single.status, + core.MessageStatus.sending, + ); + await sending; + + await eventually( + () => bobController.messages.any((m) => m.id == message.id), + ); + final received = bobController.messages.single as core.TextMessage; + expect(received.text, 'Hello Bob'); + expect(received.authorId, alice.id); + expect(received.status, core.MessageStatus.sent); + expect( + aliceController.messages, + hasLength(1), + reason: 'no duplicate echo', + ); + expect(aliceController.messages.single.status, core.MessageStatus.sent); + + final updatedRoom = await bob.chat.getRoom(room.id); + expect( + (updatedRoom?.lastMessage as core.TextMessage?)?.text, + 'Hello Bob', + ); + expect(updatedRoom?.unreadCount, 1); + }); + + test('read receipts flow back to the sender', () async { + final aliceController = SupabaseChatController( + room: room, + chatCore: alice.chat, + autoMarkAsRead: false, + ); + addTearDown(aliceController.dispose); + await aliceController.initialLoad; + await realtimeReady(alice.client); + + final message = _text(alice, 'Did you read this?'); + await aliceController.sendMessage(message); + + final bobController = SupabaseChatController( + room: room, + chatCore: bob.chat, + ); + addTearDown(bobController.dispose); + await bobController.initialLoad; + + await eventually( + () => aliceController.messages.single.status == core.MessageStatus.seen, + reason: 'status seen not received', + ); + expect((await bob.chat.getRoom(room.id))?.unreadCount, 0); + }); + + test('pagination loads every older message exactly once', () async { + for (var i = 0; i < 25; i++) { + await alice.chat.sendMessage( + core.Message.text( + id: const Uuid().v7(), + authorId: alice.id, + text: 'm$i', + createdAt: DateTime.now().toUtc().add(Duration(milliseconds: i)), + ), + room.id, + ); + } + final controller = SupabaseChatController( + room: room, + chatCore: bob.chat, + pageSize: 10, + autoMarkAsRead: false, + ); + addTearDown(controller.dispose); + await controller.initialLoad; + expect(controller.messages, hasLength(10)); + expect((controller.messages.last as core.TextMessage).text, 'm24'); + + while (controller.hasMoreMessages) { + await controller.loadPreviousMessages(); + } + final texts = controller.messages + .map((m) => (m as core.TextMessage).text) + .toList(); + expect(texts, [for (var i = 0; i < 25; i++) 'm$i']); + }); + + test('edits, reactions and deletions propagate', () async { + final aliceController = SupabaseChatController( + room: room, + chatCore: alice.chat, + autoMarkAsRead: false, + ); + final bobController = SupabaseChatController( + room: room, + chatCore: bob.chat, + autoMarkAsRead: false, + ); + addTearDown(aliceController.dispose); + addTearDown(bobController.dispose); + await Future.wait([ + aliceController.initialLoad, + bobController.initialLoad, + ]); + await realtimeReady(alice.client); + await realtimeReady(bob.client); + + final message = _text(alice, 'Typo'); + await aliceController.sendMessage(message); + await eventually(() => bobController.messages.isNotEmpty); + + await aliceController.editTextMessage( + aliceController.messages.single as core.TextMessage, + 'Fixed', + ); + await eventually( + () => + (bobController.messages.single as core.TextMessage).text == 'Fixed', + ); + expect(bobController.messages.single.metadata?['edited'], true); + + await bobController.toggleReaction(message.id, '❤️'); + await eventually( + () => + aliceController.messages.single.reactions?['❤️']?.contains( + bob.id, + ) ?? + false, + ); + + expect( + await bobController.deleteMessage(message.id), + isFalse, + reason: 'members cannot delete other members messages', + ); + expect(await aliceController.deleteMessage(message.id), isTrue); + await eventually(() => bobController.messages.isEmpty); + }); + + test( + 'a failed send is kept with status error and can be retried', + () async { + final outsider = await TestUser.create('Olga', 'Outsider'); + addTearDown(outsider.dispose); + final controller = SupabaseChatController( + room: room, + chatCore: outsider.chat, + autoMarkAsRead: false, + ); + addTearDown(controller.dispose); + await controller.initialLoad; + + final message = _text(outsider, 'Let me in'); + await expectLater( + controller.sendMessage(message), + throwsA(isA()), + ); + expect(controller.messages.single.status, core.MessageStatus.error); + expect(await controller.deleteMessage(message.id), isTrue); + expect(controller.messages, isEmpty); + }, + ); + + test('voice messages keep duration and waveform end to end', () async { + final controller = SupabaseChatController( + room: room, + chatCore: bob.chat, + autoMarkAsRead: false, + ); + addTearDown(controller.dispose); + await controller.initialLoad; + await realtimeReady(bob.client); + + // A tiny fake recording: the bytes don't need to be valid audio here. + final bytes = Uint8List.fromList(List.generate(2048, (i) => i % 256)); + final upload = await alice.chat.uploadAsset(room, 'voice.webm', bytes); + final message = core.Message.audio( + id: const Uuid().v7(), + authorId: alice.id, + source: upload.url, + duration: const Duration(seconds: 7), + size: bytes.length, + waveform: const [0.1, 0.5, 1.0, 0.25], + ); + await alice.chat.sendMessage(message, room.id); + + await eventually( + () => controller.messages.any((m) => m.id == message.id), + ); + final received = controller.messages.single as core.AudioMessage; + expect(received.duration, const Duration(seconds: 7)); + expect(received.waveform, [0.1, 0.5, 1.0, 0.25]); + expect(received.source, upload.url); + final downloaded = await bob.client.storage + .from('chats_assets') + .download(upload.path!); + expect(downloaded, bytes); + final updatedRoom = await bob.chat.getRoom(room.id); + expect(updatedRoom?.lastMessage, isA()); + }); + + test('attachments are readable by members only', () async { + final bytes = Uint8List.fromList(utf8.encode('hello file')); + final upload = await alice.chat.uploadAsset( + room, + 'notes (v2).txt', + bytes, + ); + expect(upload.mimeType, 'text/plain'); + expect(upload.path, startsWith('${room.id}/')); + expect(upload.url, contains('/object/authenticated/chats_assets/')); + + final downloaded = await bob.client.storage + .from('chats_assets') + .download(upload.path!); + expect(utf8.decode(downloaded), 'hello file'); + + await expectLater( + carol.client.storage.from('chats_assets').download(upload.path!), + throwsA(isA()), + ); + }); + }); + + group('telegram-like features', skip: skipReason, () { + test('rooms can be pinned on top and muted, per user', () async { + final user = await TestUser.create('Tina', 'Telegram'); + addTearDown(user.dispose); + final older = await user.chat.createGroupRoom( + name: 'Older', + users: [alice.asChatUser], + ); + await user.chat.createGroupRoom(name: 'Newer', users: [alice.asChatUser]); + expect((await user.chat.rooms()).first.name, 'Newer'); + + await user.chat.pinRoom(older.id); + final rooms = await user.chat.rooms(); + expect(rooms.first.id, older.id); + expect(rooms.first.isPinned, isTrue); + expect( + (await alice.chat.getRoom(older.id))?.isPinned, + isFalse, + reason: 'per user', + ); + + await user.chat.muteRoom(older.id); + expect((await user.chat.getRoom(older.id))?.isMuted, isTrue); + expect( + (await user.chat.getRoom(older.id))?.mutedUntil, + ChatRoom.mutedForever, + ); + await user.chat.muteRoom(older.id, duration: const Duration(hours: 1)); + expect((await user.chat.getRoom(older.id))?.isMuted, isTrue); + await user.chat.unmuteRoom(older.id); + expect((await user.chat.getRoom(older.id))?.isMuted, isFalse); + + await user.chat.pinRoom(older.id, pinned: false); + expect((await user.chat.rooms()).first.name, 'Newer'); + }); + + test('messages can be pinned, searched and replied to', () async { + final room = await alice.chat.createGroupRoom( + name: 'Pins', + users: [bob.asChatUser], + ); + final controller = SupabaseChatController( + room: room, + chatCore: bob.chat, + autoMarkAsRead: false, + ); + addTearDown(controller.dispose); + await controller.initialLoad; + await realtimeReady(bob.client); + + final original = _text(alice, 'The meeting is at 10:00'); + await alice.chat.sendMessage(original, room.id); + await alice.chat.sendMessage( + core.Message.text( + id: const Uuid().v7(), + authorId: alice.id, + text: 'Bring the slides', + replyToMessageId: original.id, + ), + room.id, + ); + await eventually(() => controller.messages.length == 2); + final reply = controller.messages.last; + expect(reply.replyToMessageId, original.id); + final quoted = await controller.resolveMessage(reply.replyToMessageId!); + expect((quoted as core.TextMessage?)?.text, 'The meeting is at 10:00'); + + await expectLater( + bob.chat.setMessagePinned(original.id), + throwsA(isA()), + reason: 'only admins pin in groups', + ); + await alice.chat.setMessagePinned(original.id); + await eventually(() => controller.pinnedMessages.value.length == 1); + expect(controller.pinnedMessages.value.single.id, original.id); + await alice.chat.setMessagePinned(original.id, pinned: false); + await eventually(() => controller.pinnedMessages.value.isEmpty); + + final found = await bob.chat.searchMessages(room.id, 'MEETING'); + expect(found.map((m) => m.id), [original.id]); + expect( + await bob.chat.searchMessages(room.id, 'nothing like this'), + isEmpty, + ); + }); + }); + + group('realtime', skip: skipReason, () { + test('typing indicator uses throttled broadcast (issue #38)', () async { + final room = await alice.chat.createGroupRoom( + name: 'Typing', + users: [bob.asChatUser], + ); + final aliceController = SupabaseChatController( + room: room, + chatCore: alice.chat, + ); + final bobController = SupabaseChatController( + room: room, + chatCore: bob.chat, + ); + addTearDown(aliceController.dispose); + addTearDown(bobController.dispose); + await realtimeReady(alice.client); + await realtimeReady(bob.client); + + final events = >[]; + final sub = bobController.typingUsers.listen( + (users) => events.add(users.map((u) => u.id).toList()), + ); + addTearDown(sub.cancel); + + // 20 keystrokes in ~1s: far beyond Presence's rate limit, but only one + // broadcast goes out. + for (var i = 0; i < 20; i++) { + aliceController.onTyping(); + await Future.delayed(const Duration(milliseconds: 50)); + } + await eventually(() => bobController.currentTypingUsers.isNotEmpty); + expect(bobController.currentTypingUsers.single.id, alice.id); + expect( + events.where((e) => e.isNotEmpty).length, + 1, + reason: 'throttled to a single typing event', + ); + + await aliceController.endTyping(); + await eventually(() => bobController.currentTypingUsers.isEmpty); + expect( + aliceController.currentTypingUsers, + isEmpty, + reason: 'own typing is never reported', + ); + }); + + test('roomsChanges reports new, updated and left rooms', () async { + final rita = await TestUser.create('Rita', 'Realtime'); + addTearDown(rita.dispose); + final changes = []; + final errors = []; + final sub = rita.chat.roomsChanges().listen( + changes.add, + onError: errors.add, + ); + addTearDown(sub.cancel); + await realtimeReady(rita.client); + + final room = await alice.chat.createGroupRoom( + name: 'Changes', + users: [rita.asChatUser], + ); + await eventually( + () => changes.any((c) => c is ChatRoomUpserted && c.room.id == room.id), + reason: 'added-to-room upsert (errors: $errors)', + ); + + changes.clear(); + await alice.chat.sendMessage(_text(alice, 'ping'), room.id); + await eventually( + () => changes.any( + (c) => + c is ChatRoomUpserted && + c.room.id == room.id && + c.room.unreadCount == 1, + ), + reason: 'new-message upsert', + ); + + await rita.chat.leaveRoom(room.id); + await eventually( + () => changes.any((c) => c is ChatRoomRemoved && c.roomId == room.id), + reason: 'left-room removal', + ); + }); + + test('SupabaseChatRoomsController keeps its list in sync', () async { + final dora = await TestUser.create('Dora', 'Explorer'); + addTearDown(dora.dispose); + final controller = SupabaseChatRoomsController(chatCore: dora.chat); + addTearDown(controller.dispose); + await controller.loadFirstPage(); + expect(controller.rooms, isEmpty); + await realtimeReady(dora.client); + + final first = await alice.chat.createGroupRoom( + name: 'One', + users: [dora.asChatUser], + ); + final second = await alice.chat.createGroupRoom( + name: 'Two', + users: [dora.asChatUser], + ); + await eventually(() => controller.rooms.length == 2); + expect(controller.rooms.first.id, second.id); + + await alice.chat.sendMessage(_text(alice, 'bump'), first.id); + await eventually(() => controller.rooms.first.id == first.id); + expect(controller.totalUnreadCount, 1); + + await alice.chat.deleteRoom(second.id); + await eventually(() => controller.rooms.length == 1); + }); + + test('online status is shared over presence', () async { + final statuses = []; + final sub = bob.chat.userOnlineStatus(alice.id).listen(statuses.add); + addTearDown(sub.cancel); + await realtimeReady(bob.client); + await realtimeReady(alice.client); + + await alice.chat.setPresenceStatus(UserOnlineStatus.online); + await eventually(() => statuses.lastOrNull == UserOnlineStatus.online); + + await alice.chat.setPresenceStatus(UserOnlineStatus.offline); + await eventually(() => statuses.last == UserOnlineStatus.offline); + final profile = await bob.chat.user(uid: alice.id); + expect(profile?.lastSeen, isNotNull); + }); + }); +} diff --git a/test/integration/harness.dart b/test/integration/harness.dart new file mode 100644 index 0000000..0ff2843 --- /dev/null +++ b/test/integration/harness.dart @@ -0,0 +1,133 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_supabase_chat_core/flutter_supabase_chat_core.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:uuid/uuid.dart'; + +/// Connection settings, from the environment (see tool/test_integration.sh). +final supabaseUrl = Platform.environment['CHAT_TEST_SUPABASE_URL']; +final anonKey = Platform.environment['CHAT_TEST_ANON_KEY']; +final serviceRoleKey = Platform.environment['CHAT_TEST_SERVICE_ROLE_KEY']; + +/// Reason to skip the integration suite, or `null` when it can run. +String? get skipReason => + supabaseUrl == null || anonKey == null || serviceRoleKey == null + ? 'CHAT_TEST_SUPABASE_URL / CHAT_TEST_ANON_KEY / ' + 'CHAT_TEST_SERVICE_ROLE_KEY not set: run tool/test_integration.sh' + : null; + +/// A signed-in test user with its own Supabase client and chat core. +class TestUser { + TestUser._(this.client, this.chat, this.id, this.firstName, this.lastName); + + /// Creates a confirmed auth user through the admin API (names go into the + /// sign-up metadata, like the example app does), then signs it in on a + /// dedicated client. + static Future create( + String firstName, + String lastName, { + SupabaseChatCoreConfig config = const SupabaseChatCoreConfig(), + }) async { + final email = + '${firstName.toLowerCase()}.${const Uuid().v4().substring(0, 8)}@test.dev'; + const password = 'Test-password-1'; + final admin = SupabaseClient(supabaseUrl!, serviceRoleKey!); + try { + await admin.auth.admin.createUser( + AdminUserAttributes( + email: email, + password: password, + emailConfirm: true, + userMetadata: {'first_name': firstName, 'last_name': lastName}, + ), + ); + } finally { + await admin.dispose(); + } + + final client = SupabaseClient( + supabaseUrl!, + anonKey!, + authOptions: const AuthClientOptions(autoRefreshToken: false), + ); + final auth = await client.auth.signInWithPassword( + email: email, + password: password, + ); + final chat = SupabaseChatCore(client: client, config: config); + final user = TestUser._( + chat.client, + chat, + auth.user!.id, + firstName, + lastName, + ); + // A freshly issued JWT can be rejected for a second or two as "issued + // in the future" when the Docker VM clock drifts from the host's. + await eventually(() async { + try { + await chat.refreshLoggedUser(); + return true; + } on SupabaseChatCoreRequestException { + return false; + } + }, reason: 'first authenticated request never succeeded'); + return user; + } + + /// Supabase client signed in as this user. + final SupabaseClient client; + + /// Chat core bound to [client]. + final SupabaseChatCore chat; + + /// Auth user id. + final String id; + + /// First name given at sign-up. + final String firstName; + + /// Last name given at sign-up. + final String lastName; + + /// This user as a [ChatUser]. + ChatUser get asChatUser => + ChatUser(id: id, firstName: firstName, lastName: lastName); + + /// Releases the chat core and client. + Future dispose() async { + await chat.dispose(); + await client.removeAllChannels(); + await client.realtime.disconnect(); + await client.dispose(); + } +} + +/// Polls [condition] until it holds, failing after [timeout]. +Future eventually( + FutureOr Function() condition, { + Duration timeout = const Duration(seconds: 15), + String? reason, +}) async { + final deadline = DateTime.now().add(timeout); + while (true) { + if (await condition()) return; + if (DateTime.now().isAfter(deadline)) { + throw TimeoutException(reason ?? 'condition not met', timeout); + } + await Future.delayed(const Duration(milliseconds: 100)); + } +} + +/// Waits until Realtime has joined every channel of [client] (postgres +/// changes subscriptions only start delivering once joined), plus a short +/// settle time for the server to attach the replication stream. +Future realtimeReady(SupabaseClient client) async { + await eventually( + // ignore: invalid_use_of_internal_member + () => client.getChannels().every((c) => c.isJoined), + reason: 'realtime channels not joined', + ); + await Future.delayed(const Duration(seconds: 2)); +} diff --git a/test/unit/models_test.dart b/test/unit/models_test.dart new file mode 100644 index 0000000..acbb712 --- /dev/null +++ b/test/unit/models_test.dart @@ -0,0 +1,220 @@ +import 'package:flutter_chat_core/flutter_chat_core.dart' as core; +import 'package:flutter_supabase_chat_core/flutter_supabase_chat_core.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _me = 'a0000000-0000-0000-0000-000000000001'; +const _other = 'b0000000-0000-0000-0000-000000000002'; + +Map _roomRow({ + String type = 'direct', + String? name, + int unread = 0, +}) => { + 'id': 'r1', + 'type': type, + 'name': name, + 'imageUrl': null, + 'metadata': {'workspaceId': 'w1'}, + 'createdAt': '2026-10-01T00:00:00+00:00', + 'updatedAt': '2026-10-02T00:00:00+00:00', + 'lastReadAt': null, + 'unreadCount': unread, + 'lastMessages': [ + { + 'id': 'm1', + 'authorId': _other, + 'roomId': 'r1', + 'type': 'text', + 'text': 'Last one', + 'createdAt': '2026-10-02T00:00:00+00:00', + }, + ], + 'users': [ + {'id': _me, 'firstName': 'Alice', 'lastName': 'Liddell', 'role': 'admin'}, + { + 'id': _other, + 'firstName': 'Bob', + 'lastName': null, + 'imageUrl': 'https://x/bob.png', + 'role': null, + }, + ], +}; + +void main() { + group('ChatUser', () { + test('fromJson reads timestamptz dates and unknown roles leniently', () { + final user = ChatUser.fromJson({ + 'id': _me, + 'firstName': 'Alice', + 'lastName': 'Liddell', + 'role': 'superuser', + 'createdAt': '2026-01-01T00:00:00Z', + 'lastSeen': 1767225600000, + }); + expect(user.role, isNull); + expect(user.createdAt, DateTime.utc(2026)); + expect(user.lastSeen, DateTime.utc(2026)); + expect(user.fullName, 'Alice Liddell'); + expect(user.initials, 'AL'); + }); + + test('toJson/fromJson round-trip', () { + final user = ChatUser( + id: _me, + firstName: 'A', + role: ChatRoomRole.moderator, + metadata: const {'k': 1}, + createdAt: DateTime.utc(2026, 2, 3), + ); + final copy = ChatUser.fromJson(user.toJson()); + expect(copy.role, ChatRoomRole.moderator); + expect(copy.metadata, {'k': 1}); + expect(copy.createdAt, user.createdAt); + }); + + test('toCoreUser carries name, avatar and role', () { + final core = const ChatUser( + id: _me, + firstName: 'Alice', + imageUrl: 'https://x/a.png', + role: ChatRoomRole.admin, + ).toCoreUser(); + expect(core.name, 'Alice'); + expect(core.imageSource, 'https://x/a.png'); + expect(core.metadata, {'role': 'admin'}); + }); + + test('names fall back gracefully', () { + const empty = ChatUser(id: _me); + expect(empty.fullName, ''); + expect(empty.initials, ''); + expect(empty.toCoreUser().name, isNull); + }); + }); + + group('ChatRoom.fromJson', () { + test('a direct room takes the other member name and avatar', () { + final room = ChatRoom.fromJson(_roomRow(), currentUserId: _me); + expect(room.type, ChatRoomType.direct); + expect(room.name, 'Bob'); + expect(room.imageUrl, 'https://x/bob.png'); + expect(room.otherUser(_me)?.id, _other); + }); + + test('a group room keeps its own name and member roles', () { + final room = ChatRoom.fromJson( + _roomRow(type: 'group', name: 'Team', unread: 4), + currentUserId: _me, + ); + expect(room.name, 'Team'); + expect(room.roleOf(_me), ChatRoomRole.admin); + expect(room.roleOf(_other), isNull); + expect(room.otherUser(_me), isNull); + expect(room.unreadCount, 4); + }); + + test('parses dates, metadata and the last message', () { + final room = ChatRoom.fromJson(_roomRow(), currentUserId: _me); + expect(room.updatedAt, DateTime.utc(2026, 10, 2)); + expect(room.metadata, {'workspaceId': 'w1'}); + expect((room.lastMessage as core.TextMessage?)?.text, 'Last one'); + }); + + test('tolerates a row without users or last messages', () { + final room = ChatRoom.fromJson({'id': 'r2', 'type': 'group'}); + expect(room.users, isEmpty); + expect(room.lastMessage, isNull); + expect(room.unreadCount, 0); + }); + }); + + group('SupabaseChatCore.applyRoomChange', () { + ChatRoom room(String id, int day) => ChatRoom( + id: id, + type: ChatRoomType.group, + users: const [], + updatedAt: DateTime.utc(2026, 10, day), + ); + + test('upserts and sorts by updatedAt, most recent first', () { + var rooms = [room('a', 1), room('b', 2)]; + rooms = SupabaseChatCore.applyRoomChange( + rooms, + ChatRoomUpserted(room('a', 3)), + ); + expect(rooms.map((r) => r.id), ['a', 'b']); + rooms = SupabaseChatCore.applyRoomChange( + rooms, + ChatRoomUpserted(room('c', 4)), + ); + expect(rooms.map((r) => r.id), ['c', 'a', 'b']); + }); + + test('removes rooms', () { + final rooms = SupabaseChatCore.applyRoomChange([ + room('a', 1), + room('b', 2), + ], const ChatRoomRemoved('a')); + expect(rooms.map((r) => r.id), ['b']); + }); + + test('onlyExisting ignores unknown rooms', () { + final rooms = SupabaseChatCore.applyRoomChange( + [room('a', 1)], + ChatRoomUpserted(room('z', 9)), + onlyExisting: true, + ); + expect(rooms.map((r) => r.id), ['a']); + }); + + test('rooms without updatedAt sort last', () { + const undated = ChatRoom(id: 'u', type: ChatRoomType.group, users: []); + final rooms = SupabaseChatCore.applyRoomChange([ + undated, + ], ChatRoomUpserted(room('a', 1))); + expect(rooms.map((r) => r.id), ['a', 'u']); + }); + }); + + group('SupabaseChatCoreConfig', () { + test('defaults match the SQL scripts', () { + const config = SupabaseChatCoreConfig(); + expect(config.schema, 'chats'); + expect(config.roomsViewName, 'rooms_l'); + expect(config.realtimeRoomChannelPrefix, 'chats:room:'); + expect(config.usersViewName, 'users'); + }); + + test('usersViewName follows usersTableName unless set', () { + const custom = SupabaseChatCoreConfig(usersTableName: 'people'); + expect(custom.usersViewName, 'people'); + expect( + custom.copyWith(usersViewName: 'people_l').usersViewName, + 'people_l', + ); + expect(custom.copyWith(schema: 'x').usersViewName, 'people'); + }); + + test('copyWith keeps the hooks', () { + final config = const SupabaseChatCoreConfig().copyWith( + defaultRoomMetadata: () => {'w': 1}, + roomFilter: (room) => false, + ); + final copy = config.copyWith(schema: 'other'); + expect(copy.defaultRoomMetadata?.call(), {'w': 1}); + expect( + copy.roomFilter?.call( + const ChatRoom(id: 'r', type: ChatRoomType.group, users: []), + ), + isFalse, + ); + }); + }); + + test('chatRoomRoleFromName', () { + expect(chatRoomRoleFromName('admin'), ChatRoomRole.admin); + expect(chatRoomRoleFromName(null), isNull); + expect(chatRoomRoleFromName('nope'), isNull); + }); +} diff --git a/test/unit/util_test.dart b/test/unit/util_test.dart new file mode 100644 index 0000000..e8f082b --- /dev/null +++ b/test/unit/util_test.dart @@ -0,0 +1,181 @@ +import 'package:flutter_chat_core/flutter_chat_core.dart' as core; +import 'package:flutter_supabase_chat_core/flutter_supabase_chat_core.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('parseChatDate', () { + test('parses timestamptz strings to UTC', () { + final date = parseChatDate('2026-10-20T16:16:48.600641+02:00'); + expect(date, DateTime.utc(2026, 10, 20, 14, 16, 48, 600, 641)); + expect(date!.isUtc, isTrue); + }); + + test('parses epoch milliseconds', () { + expect(parseChatDate(0), DateTime.utc(1970)); + expect(parseChatDate(1000.0), DateTime.utc(1970, 1, 1, 0, 0, 1)); + }); + + test('returns null for null or garbage', () { + expect(parseChatDate(null), isNull); + expect(parseChatDate('not a date'), isNull); + expect(parseChatDate(const []), isNull); + }); + + test('formatChatDate round-trips', () { + final date = DateTime.utc(2026, 1, 2, 3, 4, 5); + expect(parseChatDate(formatChatDate(date)), date); + expect(formatChatDate(null), isNull); + }); + }); + + group('messageFromRow', () { + final base = { + 'id': '0199f0aa-0000-7000-8000-000000000001', + 'roomId': '0199f0aa-0000-7000-8000-0000000000ff', + 'authorId': 'a0000000-0000-0000-0000-000000000001', + 'createdAt': '2026-10-20T10:00:00+00:00', + 'updatedAt': null, + 'metadata': null, + 'status': 'sent', + }; + + test('maps a text row, dropping roomId and nulls', () { + final message = messageFromRow({...base, 'type': 'text', 'text': 'Hi'}); + expect(message, isA()); + final text = message as core.TextMessage; + expect(text.text, 'Hi'); + expect(text.id, base['id']); + expect(text.authorId, base['authorId']); + expect(text.createdAt, DateTime.utc(2026, 10, 20, 10)); + expect(text.status, core.MessageStatus.sent); + }); + + test('converts duration from milliseconds to seconds', () { + final message = messageFromRow({ + ...base, + 'type': 'audio', + 'source': 'https://x/a.m4a', + 'duration': 12400, + 'size': 10, + }); + expect( + (message as core.AudioMessage).duration, + const Duration(seconds: 12), + ); + }); + + test('normalizes reactions and legacy link previews', () { + final message = + messageFromRow({ + ...base, + 'type': 'text', + 'text': 'see https://flyer.chat', + 'reactions': { + '👍': ['u1', 'u2'], + }, + 'linkPreviewData': { + 'link': 'https://flyer.chat', + 'title': 'Flyer', + 'image': {'url': 'https://flyer.chat/i.png'}, + }, + }) + as core.TextMessage; + expect(message.reactions, { + '👍': ['u1', 'u2'], + }); + expect(message.linkPreviewData?.title, 'Flyer'); + expect(message.linkPreviewData?.image?.width, 0); + }); + + test('ignores an unknown status instead of failing', () { + final message = messageFromRow({ + ...base, + 'type': 'text', + 'text': 'x', + 'status': 'teleported', + }); + expect(message, isA()); + expect(message.status, isNull); + }); + + test('falls back to unsupported for rows missing required fields', () { + final message = messageFromRow({...base, 'type': 'image'}); + expect(message, isA()); + expect(message.id, base['id']); + expect(message.createdAt, DateTime.utc(2026, 10, 20, 10)); + }); + + test('treats a missing type as unsupported', () { + expect(messageFromRow({...base}), isA()); + }); + + test('reads rooms."lastMessages" snapshots (to_jsonb of a row)', () { + final message = messageFromRow({ + ...base, + 'type': 'file', + 'source': 'https://x/f.pdf', + 'name': 'f.pdf', + 'size': 3, + 'mimeType': 'application/pdf', + 'height': null, + 'width': null, + 'waveform': null, + }); + expect((message as core.FileMessage).name, 'f.pdf'); + }); + }); + + group('messageToRow', () { + test('keeps only table columns and converts duration to ms', () { + final row = messageToRow( + core.Message.audio( + id: 'm1', + authorId: 'u1', + source: 'https://x/a.m4a', + duration: const Duration(seconds: 3), + size: 42, + createdAt: DateTime.utc(2026), + ), + ); + expect(row['type'], 'audio'); + expect(row['duration'], 3000); + expect(row['size'], 42); + expect(row.containsKey('id'), isFalse); + expect(row.containsKey('authorId'), isFalse); + expect(row.containsKey('createdAt'), isFalse); + }); + + test('round-trips through messageFromRow', () { + final original = core.Message.image( + id: 'm2', + authorId: 'u1', + source: 'https://x/i.png', + width: 640, + height: 480, + size: 1000, + createdAt: DateTime.utc(2026, 5, 1), + metadata: {'k': 'v'}, + ); + final row = messageToRow(original) + ..['id'] = original.id + ..['authorId'] = original.authorId + ..['createdAt'] = formatChatDate(original.createdAt); + expect(messageFromRow(row), original); + }); + + test('rejects client-only message types', () { + expect( + () => messageToRow( + const core.Message.textStream(id: 's', authorId: 'u', streamId: 's'), + ), + throwsArgumentError, + ); + expect( + () => messageToRow( + const core.Message.unsupported(id: 'x', authorId: 'u'), + ), + throwsArgumentError, + ); + }); + }); +} diff --git a/tool/local_db.sh b/tool/local_db.sh new file mode 100644 index 0000000..910c2c7 --- /dev/null +++ b/tool/local_db.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Resets the local Supabase stack's database (`supabase start` must be +# running) and applies the chat SQL scripts from example/utils/sql exactly +# the way end users apply them, then reloads PostgREST's schema cache. +# +# tool/local_db.sh reset + apply +# tool/local_db.sh --no-reset apply on top of the current database (checks +# the scripts are idempotent) +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SQL_DIR="$ROOT/example/utils/sql" +DB_URL="${DB_URL:-postgresql://postgres:postgres@127.0.0.1:54322/postgres}" + +if [[ "${1:-}" != "--no-reset" ]]; then + (cd "$ROOT" && supabase db reset --local >/dev/null 2>&1) +fi + +for f in 01_database_schema.sql 02_database_trigger.sql 03_database_policy.sql \ + 04_storage.sql 05_database_view.sql 99_database_schema_permission.sql; do + echo "applying $f" + psql "$DB_URL" -v ON_ERROR_STOP=1 -q -o /dev/null -f "$SQL_DIR/$f" 2>&1 \ + | { grep -v -E 'NOTICE|^$' || true; } +done + +psql "$DB_URL" -q -c "NOTIFY pgrst, 'reload schema';" + +# `supabase db reset` restarts Postgres but not Realtime, whose Postgres +# Changes poller can stay in a degraded "recovery" state afterwards and +# silently stop delivering events. Restart it on a fresh database. +if [[ "${1:-}" != "--no-reset" ]]; then + REALTIME="$(docker ps --format '{{.Names}}' | grep '^supabase_realtime_' | head -1)" + if [[ -n "$REALTIME" ]]; then + docker restart "$REALTIME" >/dev/null + for _ in $(seq 1 30); do + curl -sf "http://127.0.0.1:54321/realtime/v1/api/ping" >/dev/null 2>&1 && break + sleep 1 + done + fi +fi +echo "done" diff --git a/tool/seed_demo.sh b/tool/seed_demo.sh new file mode 100644 index 0000000..1c710c3 --- /dev/null +++ b/tool/seed_demo.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +# Seeds a Supabase project with demo users and conversations, e.g. to try +# the example app or take screenshots: +# tool/local_db.sh && tool/seed_demo.sh # local stack +# tool/seed_demo.sh --project-ref # cloud project (CLI login) +# Every demo user signs in with password "Demo-password-1": +# alice@demo.dev (main account), bob@demo.dev, carol@demo.dev, +# dave@demo.dev, erin@demo.dev +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" +DB_URL="${DB_URL:-postgresql://postgres:postgres@127.0.0.1:54322/postgres}" +PROJECT_REF="" +if [[ "${1:-}" == "--project-ref" ]]; then PROJECT_REF="${2:?--project-ref needs a ref}"; fi + +if [[ -n "$PROJECT_REF" ]]; then + # Cloud: keys from the CLI, SQL through the Management API (no password). + API_URL="https://$PROJECT_REF.supabase.co" + SERVICE_ROLE_KEY="$(supabase projects api-keys --project-ref "$PROJECT_REF" -o json 2>/dev/null | + python -c "import sys,json;print(next(k['api_key'] for k in json.load(sys.stdin) if k['name']=='service_role'))")" +else + eval "$(supabase status -o env 2>/dev/null | grep -E '^(API_URL|SERVICE_ROLE_KEY)=')" +fi + +# Runs SQL (stdin, or -c) and prints the first column of each row. +sql() { + if [[ -n "$PROJECT_REF" ]]; then + local f; f="$(mktemp)" + if [[ "${1:-}" == "-c" ]]; then printf '%s' "$2" > "$f"; else cat > "$f"; fi + supabase db query --linked --project-ref "$PROJECT_REF" -f "$f" -o json 2>/dev/null | + python -c "import sys,json;[print(list(r.values())[0]) for r in json.load(sys.stdin).get('rows') or []]" + rm -f "$f" + else + psql "$DB_URL" -v ON_ERROR_STOP=1 -q -At "$@" | tr -d '\r' + fi +} + +create_user() { + curl -sf -X POST "$API_URL/auth/v1/admin/users" \ + -H "Authorization: Bearer $SERVICE_ROLE_KEY" \ + -H "apikey: $SERVICE_ROLE_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"$1\",\"password\":\"Demo-password-1\",\"email_confirm\":true,\"user_metadata\":{\"first_name\":\"$2\",\"last_name\":\"$3\"}}" \ + >/dev/null || echo " ($1 already exists)" +} + +echo "creating demo users" +create_user alice@demo.dev Alice Liddell +create_user bob@demo.dev Bob Marley +create_user carol@demo.dev Carol Danvers +create_user dave@demo.dev Dave Grohl +create_user erin@demo.dev Erin Brockovich + +echo "creating conversations" +sql <<'SQL' +CREATE OR REPLACE FUNCTION pg_temp.uid(p_email text) RETURNS uuid +LANGUAGE sql AS $$ SELECT id FROM auth.users WHERE email = p_email $$; + +CREATE OR REPLACE FUNCTION pg_temp.as_user(p_email text) RETURNS void +LANGUAGE sql AS $$ + SELECT set_config('request.jwt.claims', + json_build_object('sub', pg_temp.uid(p_email), 'role', 'authenticated')::text, true); +$$; + +-- Sends a message from p_email to p_room, p_minutes_ago minutes ago. +CREATE OR REPLACE FUNCTION pg_temp.say(p_room uuid, p_email text, p_text text, + p_minutes_ago int, p_reply uuid DEFAULT NULL) +RETURNS uuid LANGUAGE sql AS $$ + INSERT INTO chats.messages ("roomId", "authorId", type, text, "createdAt", "replyToMessageId") + VALUES (p_room, pg_temp.uid(p_email), 'text', p_text, + now() - make_interval(mins => p_minutes_ago), p_reply::text) + RETURNING id; +$$; + +DO $$ +DECLARE + r_bob uuid; r_carol uuid; r_dave uuid; r_team uuid; r_trip uuid; + m uuid; m2 uuid; +BEGIN + PERFORM pg_temp.as_user('alice@demo.dev'); + r_bob := chats.create_room('direct', ARRAY[pg_temp.uid('bob@demo.dev')]); + r_carol := chats.create_room('direct', ARRAY[pg_temp.uid('carol@demo.dev')]); + r_dave := chats.create_room('direct', ARRAY[pg_temp.uid('dave@demo.dev')]); + r_team := chats.create_room('group', ARRAY[pg_temp.uid('bob@demo.dev'), pg_temp.uid('carol@demo.dev'), pg_temp.uid('erin@demo.dev')], 'Product team'); + r_trip := chats.create_room('group', ARRAY[pg_temp.uid('dave@demo.dev'), pg_temp.uid('erin@demo.dev')], 'Weekend trip 🏔️'); + + -- Bob: yesterday + today, with a reply and a reaction. + m := pg_temp.say(r_bob, 'bob@demo.dev', 'Hey Alice! Are we still on for tomorrow?', 1500); + PERFORM pg_temp.say(r_bob, 'alice@demo.dev', 'Yes! 10:00 at the usual place ☕', 1495, m); + PERFORM pg_temp.say(r_bob, 'bob@demo.dev', 'Perfect, see you there', 1490); + m2 := pg_temp.say(r_bob, 'bob@demo.dev', 'I just pushed the new build, can you take a look?', 25); + PERFORM pg_temp.say(r_bob, 'alice@demo.dev', 'On it 👀', 20, m2); + PERFORM pg_temp.say(r_bob, 'bob@demo.dev', 'Thanks! Let me know if anything looks off', 3); + UPDATE chats.messages SET reactions = jsonb_build_object('👍', jsonb_build_array(pg_temp.uid('bob@demo.dev')::text)) WHERE id = m; + + -- Carol: already read conversation. + PERFORM pg_temp.say(r_carol, 'carol@demo.dev', 'Did you see the launch video?', 300); + PERFORM pg_temp.say(r_carol, 'alice@demo.dev', 'Amazing work, congrats to the whole team 🎉', 290); + + -- Dave: an older chat. + PERFORM pg_temp.say(r_dave, 'dave@demo.dev', 'Concert tickets are booked 🎸', 4000); + PERFORM pg_temp.say(r_dave, 'alice@demo.dev', 'Can''t wait!', 3990); + + -- Product team: busy group with a pinned message. + m := pg_temp.say(r_team, 'alice@demo.dev', 'Release checklist is in the shared drive, please tick your items by Friday', 600); + UPDATE chats.messages SET pinned = true WHERE id = m; + PERFORM pg_temp.say(r_team, 'erin@demo.dev', 'Done with QA on Android ✅', 120); + PERFORM pg_temp.say(r_team, 'carol@demo.dev', 'iOS too. One crash on iPad, fixing it now', 60); + PERFORM pg_temp.say(r_team, 'bob@demo.dev', 'Backend migration ran fine on staging', 12); + PERFORM pg_temp.say(r_team, 'erin@demo.dev', 'Great, ship it Friday?', 5); + + -- Weekend trip: muted group, pinned on top. + PERFORM pg_temp.say(r_trip, 'dave@demo.dev', 'Who''s bringing the tent?', 200); + PERFORM pg_temp.say(r_trip, 'erin@demo.dev', 'I have one for 3 people', 180); + UPDATE chats.room_members SET "mutedUntil" = 'infinity', "pinnedAt" = now() + WHERE "roomId" = r_trip AND "userId" = pg_temp.uid('alice@demo.dev'); + + -- Alice read everything up to an hour ago: the latest messages stay unread. + UPDATE chats.room_members SET "lastReadAt" = now() - interval '15 minutes' + WHERE "userId" = pg_temp.uid('alice@demo.dev'); + UPDATE chats.messages SET status = 'seen' + WHERE "authorId" = pg_temp.uid('alice@demo.dev'); +END +$$; +SQL +echo "adding a voice message" +ROOM_ID=$(sql -c "select rm.\"roomId\" from chats.room_members rm join chats.rooms r on r.id = rm.\"roomId\" where r.type = 'direct' and rm.\"userId\" = (select id from auth.users where email = 'bob@demo.dev') and rm.\"roomId\" in (select \"roomId\" from chats.room_members where \"userId\" = (select id from auth.users where email = 'alice@demo.dev')) limit 1") +TMP_WAV="$(mktemp).wav" +# 3s of a gently pulsing 440 Hz tone, 16 kHz mono 16-bit PCM. +python - "$TMP_WAV" <<'PY' +import math, struct, sys, wave +rate, seconds = 16000, 3 +with wave.open(sys.argv[1], "wb") as w: + w.setnchannels(1); w.setsampwidth(2); w.setframerate(rate) + frames = bytearray() + for i in range(rate * seconds): + t = i / rate + env = 0.35 + 0.35 * math.sin(2 * math.pi * 1.5 * t) ** 2 + frames += struct.pack("/dev/null +rm -f "$TMP_WAV" +sql -c "insert into chats.messages (\"roomId\", \"authorId\", type, source, duration, size, waveform, \"createdAt\") + values ('$ROOM_ID', (select id from auth.users where email = 'bob@demo.dev'), 'audio', + '$API_URL/storage/v1/object/authenticated/chats_assets/$OBJECT', 3000, 96044, + '[0.35,0.5,0.7,0.62,0.4,0.36,0.55,0.75,0.68,0.45,0.35,0.52,0.72,0.66,0.42,0.36,0.56,0.74,0.65,0.44,0.35,0.5,0.7,0.6]', + now() - interval '1 minute')" + +echo "done: sign in as alice@demo.dev / Demo-password-1" diff --git a/tool/test_integration.sh b/tool/test_integration.sh new file mode 100644 index 0000000..bf602e9 --- /dev/null +++ b/tool/test_integration.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Runs the integration test suite against the local Supabase stack: +# supabase start (once) +# tool/test_integration.sh (resets the DB, applies the SQL, runs tests) +# Pass --no-reset to keep the current database. Any other argument is +# forwarded to `flutter test`. +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +RESET=1 +ARGS=() +for a in "$@"; do + if [[ "$a" == "--no-reset" ]]; then RESET=0; else ARGS+=("$a"); fi +done + +if [[ $RESET == 1 ]]; then tool/local_db.sh; fi + +eval "$(supabase status -o env 2>/dev/null | grep -E '^(API_URL|ANON_KEY|SERVICE_ROLE_KEY)=')" +export CHAT_TEST_SUPABASE_URL="$API_URL" +export CHAT_TEST_ANON_KEY="$ANON_KEY" +export CHAT_TEST_SERVICE_ROLE_KEY="$SERVICE_ROLE_KEY" + +flutter test test/integration --tags integration --concurrency=1 "${ARGS[@]}" diff --git a/tool/test_storage_migration.sh b/tool/test_storage_migration.sh new file mode 100755 index 0000000..3842cc3 --- /dev/null +++ b/tool/test_storage_migration.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# End-to-end test of example/utils/migrate_storage_uuids.sh on the local +# stack: builds a v1 database with attachments in the old numeric room +# folders, migrates it to v2, moves the attachments and checks objects, URLs +# and the cleanup of chats.room_id_migration_map. --ps runs the PowerShell +# variant. Leaves a migrated database behind: run tool/local_db.sh +# afterwards for a clean one. +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" +DB_URL="postgresql://postgres:postgres@127.0.0.1:54322/postgres" +eval "$(supabase status -o env 2>/dev/null | grep -E '^(API_URL|SERVICE_ROLE_KEY)=')" +export PGPASSWORD=postgres SUPABASE_SERVICE_ROLE_KEY="$SERVICE_ROLE_KEY" + +fail() { echo "FAIL: $*" >&2; exit 1; } +q() { psql "$DB_URL" -X -At -c "$1" | tr -d '\r'; } +upload() { + curl -sSf -o /dev/null -X POST -H "apikey: $SERVICE_ROLE_KEY" -H "Authorization: Bearer $SERVICE_ROLE_KEY" \ + -H 'Content-Type: text/plain' --data "$2" "$API_URL/storage/v1/object/chats_assets/$1" +} +download() { + curl -sSf -H "apikey: $SERVICE_ROLE_KEY" -H "Authorization: Bearer $SERVICE_ROLE_KEY" \ + "$API_URL/storage/v1/object/authenticated/chats_assets/$1" +} +# --ps runs the PowerShell variant (Windows) instead of the bash one. +migrate() { + if [[ "${VARIANT:-}" == ps ]]; then + local flags=() + for a in "$@"; do + case "$a" in -n) flags+=(-dryRun) ;; -y) flags+=(-yes) ;; esac + done + powershell.exe -NoProfile -ExecutionPolicy Bypass -File example/utils/migrate_storage_uuids.ps1 \ + -hostname 127.0.0.1 -port 54322 -database postgres -user postgres -apiUrl "$API_URL" "${flags[@]}" \ + | tr -d '\r' + else + example/utils/migrate_storage_uuids.sh -h 127.0.0.1 -p 54322 -d postgres -U postgres -a "$API_URL" "$@" + fi +} +[[ "${1:-}" == --ps ]] && VARIANT=ps + +echo "== v1 database with attachments" +tool/v1_fixture.sh +upload 11/img.png 'image bytes' +upload "11/sub/a%20b'.txt" 'nested file' +upload 10/voice.m4a 'voice bytes' +psql "$DB_URL" -X -q -c "UPDATE chats.messages SET uri = 'http://x/storage/v1/object/authenticated/chats_assets/10/voice.m4a', type = 'audio', duration = 1 WHERE id = 100" + +echo "== v2 migration" +tool/local_db.sh --no-reset >/dev/null +NEW_10="$(q 'SELECT new_id FROM chats.room_id_migration_map WHERE old_id = 10')" +NEW_11="$(q 'SELECT new_id FROM chats.room_id_migration_map WHERE old_id = 11')" + +echo "== dry run changes nothing" +migrate -n | tee /dev/stderr | grep -q 'dry run: 2 room(s), 3 object(s)' || fail "dry run summary" +[[ "$(q "SELECT count(*) FROM storage.objects WHERE bucket_id = 'chats_assets' AND name ~ '^[0-9]+/'")" == 3 ]] || fail "dry run moved objects" + +echo "== resume after a run stopped half way (one object already copied)" +curl -sSf -o /dev/null -X POST -H "apikey: $SERVICE_ROLE_KEY" -H "Authorization: Bearer $SERVICE_ROLE_KEY" \ + -H 'Content-Type: application/json' \ + --data "{\"bucketId\":\"chats_assets\",\"sourceKey\":\"11/img.png\",\"destinationKey\":\"$NEW_11/img.png\"}" \ + "$API_URL/storage/v1/object/copy" +migrate -y + +echo "== checks" +[[ "$(q "SELECT count(*) FROM storage.objects WHERE bucket_id = 'chats_assets' AND name ~ '^[0-9]+/'")" == 0 ]] || fail "old objects left" +[[ "$(download "$NEW_11/img.png")" == 'image bytes' ]] || fail "img.png not in the new folder" +[[ "$(download "$NEW_11/sub/a%20b'.txt")" == 'nested file' ]] || fail "nested file not in the new folder" +[[ "$(download "$NEW_10/voice.m4a")" == 'voice bytes' ]] || fail "voice.m4a not in the new folder" +[[ "$(q "SELECT count(*) FROM chats.messages WHERE source ~ '/chats_assets/[0-9]+/'")" == 0 ]] || fail "messages still point at old folders" +[[ "$(q "SELECT source FROM chats.messages WHERE type = 'image'")" == *"/chats_assets/$NEW_11/img.png" ]] || fail "image URL not rewritten" +[[ "$(q "SELECT \"lastMessages\" -> 0 ->> 'source' FROM chats.rooms WHERE id = '$NEW_11'")" == *"/chats_assets/$NEW_11/img.png" ]] || fail "lastMessages snapshot not refreshed" +[[ "$(q "SELECT to_regclass('chats.room_id_migration_map') IS NULL")" == t ]] || fail "map not dropped" + +echo "== a second run has nothing to do" +migrate -y | grep -q "doesn't exist" || fail "second run" + +echo "storage migration OK" diff --git a/tool/v1_fixture.sh b/tool/v1_fixture.sh new file mode 100644 index 0000000..228e2bd --- /dev/null +++ b/tool/v1_fixture.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Rebuilds the local database in the original v1 (flutter_chat_types) shape, +# using the SQL scripts shipped on the `main` branch, and fills it with a +# small, realistic data set. Used to test the v1 -> v2 migration path: +# tool/v1_fixture.sh && tool/local_db.sh --no-reset +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DB_URL="${DB_URL:-postgresql://postgres:postgres@127.0.0.1:54322/postgres}" +V1_REF="${V1_REF:-main}" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +cd "$ROOT" +supabase db reset --local >/dev/null 2>&1 +psql "$DB_URL" -q -c 'DROP SCHEMA chats CASCADE' +for f in 01_database_schema.sql 02_database_trigger.sql 03_database_policy.sql \ + 04_storage.sql 05_database_view.sql 99_database_schema_permission.sql; do + git show "$V1_REF:example/utils/sql/$f" > "$TMP/$f" + psql "$DB_URL" -v ON_ERROR_STOP=1 -q -o /dev/null -f "$TMP/$f" 2>&1 | { grep -v NOTICE || true; } +done + +psql "$DB_URL" -v ON_ERROR_STOP=1 -q <<'SQL' +insert into auth.users (id, email, aud, role) values + ('a0000000-0000-0000-0000-000000000001','a@t.dev','authenticated','authenticated'), + ('b0000000-0000-0000-0000-000000000002','b@t.dev','authenticated','authenticated'); +update chats.users set "firstName" = 'Alice' where id = 'a0000000-0000-0000-0000-000000000001'; +update chats.users set role = 'agent' where id = 'b0000000-0000-0000-0000-000000000002'; +insert into chats.rooms (id, type, "userIds", "userRoles", "createdAt", "updatedAt", name) values + (10, 'direct', '{a0000000-0000-0000-0000-000000000001,b0000000-0000-0000-0000-000000000002}', null, 1700000000000, 1700000500000, null), + (11, 'group', '{a0000000-0000-0000-0000-000000000001,b0000000-0000-0000-0000-000000000002}', '{"a0000000-0000-0000-0000-000000000001":"admin","b0000000-0000-0000-0000-000000000002":"agent"}', 1700000000000, 1700000600000, 'Old group'); +insert into chats.messages (id, "roomId", "authorId", type, text, "createdAt", "updatedAt", status, "repliedMessage") values + (100, 10, 'a0000000-0000-0000-0000-000000000001', 'text', 'v1 hello', 1700000100000, 1700000100000, 'seen', null), + (101, 10, 'b0000000-0000-0000-0000-000000000002', 'text', 'v1 reply', 1700000200000, null, 'sent', '{"id":"100"}'), + (102, 11, 'a0000000-0000-0000-0000-000000000001', 'image', null, 1600000000000, null, 'sent', null); +update chats.messages +set uri = 'http://x/storage/v1/object/authenticated/chats_assets/11/img.png', width = 10, height = 10, size = 5 +where id = 102; +SQL +echo "v1 fixture ready"