diff --git a/CHANGELOG.md b/CHANGELOG.md index dce8650..d697a22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +## [1.4.0] - 2024-11-21 +#### [@rickypid](https://github.com/rickypid) + +⚠️⚠️ **Need schema migration** ⚠️⚠️ + +### Improvements + +* Now when we get the rooms the `rooms_l` view is used so that we can get all the information without having to do multiple queries + +### Fixed + +* Fixed #20 Chat creator role is null instead of admin +* Fixed online user status realtime subscription + ## [1.3.2] - 2024-11-13 #### [@rickypid](https://github.com/rickypid) diff --git a/README.md b/README.md index b596b35..1b832ac 100644 --- a/README.md +++ b/README.md @@ -279,4 +279,4 @@ Below are some activities to complete to have a more complete and optimized proj 4. Chat room channels 5. Sending audio messages 6. Improve documentation -7. Use rooms view for improvement user parsing performance + diff --git a/doc/docs/guides/supabse-views.md b/doc/docs/guides/supabse-views.md index d4e7b7c..2095ec4 100644 --- a/doc/docs/guides/supabse-views.md +++ b/doc/docs/guides/supabse-views.md @@ -5,30 +5,69 @@ title: Database Views ## 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. +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. ```sql -DROP VIEW IF EXISTS chats.rooms_l; -create view chats.rooms_l - WITH (security_invoker='on') as -select - r.id, - r."imageUrl", - 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() - limit 1) - else - r.name - end as name, - r.type, - r."userIds", - r."lastMessages", - r."userRoles", - r."createdAt", - r."updatedAt" +create or replace view chats.rooms_l + with (security_invoker='on') as +select r.id, + r."imageUrl", + 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() + 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; ``` + + +## Messages view + +This is a view of `messages` table, this view allows you to obtain the author user object and room object. + +```sql +create or replace view chats.messages_l + with (security_invoker='on') as +select m.id, + m."createdAt", + m.metadata, + m.duration, + m."mimeType", + m.name, + m."remoteId", + m."repliedMessage", + m."roomId", + m."showStatus", + m.size, + m.status, + m.type, + m."updatedAt", + m.uri, + m."waveForm", + m."isLoading", + m.height, + m.width, + m."previewData", + 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 diff --git a/doc/package.json b/doc/package.json index e082f83..4d84934 100644 --- a/doc/package.json +++ b/doc/package.json @@ -1,6 +1,6 @@ { "name": "flutter-supabase-chat-core", - "version": "1.3.2", + "version": "1.4.0", "private": true, "scripts": { "docusaurus": "docusaurus", diff --git a/example/lib/src/pages/auth.dart b/example/lib/src/pages/auth.dart index 7897456..68249b7 100644 --- a/example/lib/src/pages/auth.dart +++ b/example/lib/src/pages/auth.dart @@ -5,7 +5,7 @@ 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 'rooms.dart'; +import 'home.dart'; class AuthScreen extends StatefulWidget { const AuthScreen({ @@ -83,7 +83,7 @@ class _AuthScreenState extends State { onSubmitAnimationCompleted: () { Navigator.of(context).pushReplacement( MaterialPageRoute( - builder: (context) => const RoomsPage(), + builder: (context) => const HomePage(), ), ); }, diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 7deacd4..dc2f08f 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -2,7 +2,7 @@ name: example description: A new Flutter project. publish_to: 'none' -version: 1.3.2 +version: 1.4.0 environment: sdk: '>=3.4.0 <4.0.0' @@ -11,7 +11,7 @@ dependencies: cupertino_icons: ^1.0.8 dio: ^5.7.0 faker: ^2.2.0 - file_picker: ^8.1.3 + file_picker: ^8.1.4 file_saver: ^0.2.14 flutter: sdk: flutter @@ -20,13 +20,13 @@ dependencies: flutter_login: ^5.0.0 flutter_supabase_chat_core: path: ../ - flutter_svg: ^2.0.10+1 + flutter_svg: ^2.0.14 http: ^1.2.2 image_picker: ^1.1.2 - infinite_scroll_pagination: ^4.0.0 + infinite_scroll_pagination: ^4.1.0 open_filex: ^4.5.0 - path_provider: ^2.1.4 - supabase_flutter: ^2.8.0 + path_provider: ^2.1.5 + supabase_flutter: ^2.8.1 timeago: ^3.7.0 diff --git a/example/utils/sql/05_database_view.sql b/example/utils/sql/05_database_view.sql index 6f073ef..b8abbae 100644 --- a/example/utils/sql/05_database_view.sql +++ b/example/utils/sql/05_database_view.sql @@ -1,23 +1,59 @@ +DROP VIEW IF EXISTS chats.messages_l; DROP VIEW IF EXISTS chats.rooms_l; -create view chats.rooms_l - WITH (security_invoker='on') as -select - r.id, - r."imageUrl", - 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() - limit 1) - else - r.name - end as name, - r.type, - r."userIds", - r."lastMessages", - r."userRoles", - r."createdAt", - r."updatedAt" -from chats.rooms r; \ No newline at end of file + +create or replace view chats.rooms_l + with (security_invoker='on') as +select r.id, + r."imageUrl", + 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() + 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; + + +create or replace view chats.messages_l + with (security_invoker='on') as +select m.id, + m."createdAt", + m.metadata, + m.duration, + m."mimeType", + m.name, + m."remoteId", + m."repliedMessage", + m."roomId", + m."showStatus", + m.size, + m.status, + m.type, + m."updatedAt", + m.uri, + m."waveForm", + m."isLoading", + m.height, + m.width, + m."previewData", + 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"; diff --git a/lib/src/class/supabase_chat_controller.dart b/lib/src/class/supabase_chat_controller.dart index cce0e62..bf3301b 100644 --- a/lib/src/class/supabase_chat_controller.dart +++ b/lib/src/class/supabase_chat_controller.dart @@ -69,7 +69,7 @@ class SupabaseChatController { PostgrestTransformBuilder _messagesQuery() => _client .schema(_config.schema) - .from(_config.messagesTableName) + .from(_config.messagesViewName) .select() .eq('roomId', int.parse(_room.id)) .order('createdAt', ascending: false) diff --git a/lib/src/class/supabase_chat_core.dart b/lib/src/class/supabase_chat_core.dart index 5b081e0..328df81 100644 --- a/lib/src/class/supabase_chat_core.dart +++ b/lib/src/class/supabase_chat_core.dart @@ -15,10 +15,25 @@ class SupabaseChatCore { Supabase.instance.client.auth.onAuthStateChange.listen((data) async { if (loggedSupabaseUser != null) { _loggedUser = await user(uid: loggedSupabaseUser!.id); - _currentUserOnlineStatusChannel = - getUserOnlineStatusChannel(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 { _loggedUser = null; + await _currentUserOnlineStatusChannel?.unsubscribe(); + _userStatusSubscribed = false; _currentUserOnlineStatusChannel = null; } }); @@ -35,6 +50,7 @@ class SupabaseChatCore { 'rooms', 'rooms_l', 'messages', + 'messages_l', 'users', 'online-user-', //online-user-${uid} @@ -57,15 +73,14 @@ class SupabaseChatCore { /// Current logged in user. Is update automatically. types.User? get loggedUser => _loggedUser; - /// Returns user online status realtime channel . - RealtimeChannel getUserOnlineStatusChannel(String uid) => + RealtimeChannel _getUserOnlineStatusChannel(String uid) => client.channel('${config.realtimeOnlineUserPrefixChannel}$uid'); - /// Returns a current user online status realtime channel . + UserOnlineStatus _lastOnlineStatus = UserOnlineStatus.offline; + RealtimeChannel? _currentUserOnlineStatusChannel; bool _userStatusSubscribed = false; - bool _userStatusSubscribing = false; Future _trackUserStatus() async { final userStatus = { @@ -76,18 +91,7 @@ class SupabaseChatCore { } Future setPresenceStatus(UserOnlineStatus status) async { - if (!_userStatusSubscribed && !_userStatusSubscribing) { - _userStatusSubscribing = true; - _currentUserOnlineStatusChannel?.subscribe( - (status, error) async { - if (status != RealtimeSubscribeStatus.subscribed) return; - _userStatusSubscribed = true; - _userStatusSubscribing = false; - await _trackUserStatus(); - }, - ); - } - + _lastOnlineStatus = status; switch (status) { case UserOnlineStatus.online: if (_userStatusSubscribed) { @@ -102,6 +106,27 @@ class SupabaseChatCore { } } + final Map _onlineUserChannels = {}; + + UserOnlineStatus _userStatus(List presences, String uid) => + presences.map((e) => e.payload['uid']).contains(uid) + ? UserOnlineStatus.online + : UserOnlineStatus.offline; + + /// Returns a stream of online user state from Supabase Realtime. + 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(); + } + return controller.stream; + } + /// Returns the URL of an asset path in the bucket String getAssetUrl(String path) => '${client.storage.url}/object/authenticated/${config.chatAssetsBucket}/$path'; @@ -149,15 +174,7 @@ class SupabaseChatCore { }) async { if (loggedSupabaseUser == null) return Future.error('User does not exist'); - final currentUser = await fetchUser( - client, - loggedSupabaseUser!.id, - config.usersTableName, - config.schema, - role: creatorRole.toShortString(), - ); - - final roomUsers = [types.User.fromJson(currentUser)] + users; + final roomUsers = [loggedUser!] + users; final room = await client.schema(config.schema).from(config.roomsTableName).insert({ @@ -324,21 +341,6 @@ class SupabaseChatCore { ); } - /// Returns a stream of online user state from Supabase Realtime. - Stream userOnlineStatus(String uid) { - final controller = StreamController(); - UserOnlineStatus userStatus(List presences, String uid) => - presences.map((e) => e.payload['uid']).contains(uid) - ? UserOnlineStatus.online - : UserOnlineStatus.offline; - getUserOnlineStatusChannel(uid).onPresenceJoin((payload) { - controller.sink.add(userStatus(payload.newPresences, uid)); - }).onPresenceLeave((payload) { - controller.sink.add(userStatus(payload.currentPresences, uid)); - }).subscribe(); - return controller.stream; - } - /// Returns a paginated list of rooms from Supabase. Only rooms where current /// logged in user exist are returned. Future> rooms({ diff --git a/lib/src/class/supabase_chat_core_config.dart b/lib/src/class/supabase_chat_core_config.dart index b0cdd18..7de137a 100644 --- a/lib/src/class/supabase_chat_core_config.dart +++ b/lib/src/class/supabase_chat_core_config.dart @@ -12,6 +12,7 @@ class SupabaseChatCoreConfig { this.roomsTableName, this.roomsViewName, this.messagesTableName, + this.messagesViewName, this.usersTableName, this.realtimeOnlineUserPrefixChannel, this.realtimeChatTypingUserPrefixChannel, @@ -30,6 +31,9 @@ class SupabaseChatCoreConfig { /// Property to set messages table name. final String messagesTableName; + /// Property to set messages view name. + final String messagesViewName; + /// Property to set users table name. final String usersTableName; diff --git a/lib/src/util.dart b/lib/src/util.dart index 8e34e8a..1080348 100644 --- a/lib/src/util.dart +++ b/lib/src/util.dart @@ -20,14 +20,17 @@ Future> fetchUser( String usersTableName, String schema, { String? role, -}) async => - (await instance - .schema(schema) - .from(usersTableName) - .select() - .eq('id', userId) - .limit(1)) - .first; +}) async { + final data = (await instance + .schema(schema) + .from(usersTableName) + .select() + .eq('id', userId) + .limit(1)) + .first; + data['role'] = role; + return data; +} /// Returns a list of [types.Room] created from Firebase query. /// If room has 2 participants, sets correct room name and image. @@ -63,30 +66,28 @@ Future processRoomRow( final type = data['type'] as String; final userIds = data['userIds'] as List; final userRoles = data['userRoles'] as Map?; - - final users = await Future.wait( - userIds.map( - (userId) => fetchUser( - instance, - userId as String, - usersTableName, - schema, - role: userRoles?[userId] as String?, - ), - ), - ); + final users = data['users'] ?? + await Future.wait( + userIds.map( + (userId) => fetchUser( + instance, + userId as String, + usersTableName, + schema, + role: userRoles?[userId] as String?, + ), + ), + ); if (type == types.RoomType.direct.toShortString()) { - try { - final otherUser = users.firstWhere( - (u) => u['id'] != supabaseUser.id, - ); + final index = users.indexWhere( + (u) => u['id'] != supabaseUser.id, + ); + if (index >= 0) { + final otherUser = users[index]; imageUrl = otherUser['imageUrl'] as String?; name = '${otherUser['firstName'] ?? ''} ${otherUser['lastName'] ?? ''}' .trim(); - } catch (e) { - // Do nothing if other user is not found, because he should be found. - // Consider falling back to some default values. } } data['imageUrl'] = imageUrl; diff --git a/pubspec.yaml b/pubspec.yaml index 27703c7..8886d15 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: flutter_supabase_chat_core description: > Actively maintained, community-driven Supabase BaaS for chat applications with an optional chat UI. -version: 1.3.2 +version: 1.4.0 homepage: https://flutter-supabase-chat-core.insideapp.it repository: https://github.com/insideapp-srl/flutter_supabase_chat_core @@ -16,7 +16,7 @@ dependencies: flutter_chat_types: ^3.6.2 meta: ^1.15.0 mime: ^1.0.6 - supabase_flutter: ^2.8.0 + supabase_flutter: ^2.8.1 uuid: ^4.5.1 dev_dependencies: