Merge pull request 'Implement File Upload Notifications' (#79) from feat/file-upload-notifications into master
Reviewed-on: https://codeberg.org/moxxy/moxxyv2/pulls/79
This commit is contained in:
		
						commit
						bc52ec6fb9
					
				| @ -63,14 +63,14 @@ RosterItem rosterDbToModel(DBRosterItem i) { | |||||||
| 
 | 
 | ||||||
| Message messageDbToModel(DBMessage m) { | Message messageDbToModel(DBMessage m) { | ||||||
|   return Message( |   return Message( | ||||||
|     m.from, |     m.sender, | ||||||
|     m.body, |     m.body, | ||||||
|     m.timestamp, |     m.timestamp, | ||||||
|     m.sent, |  | ||||||
|     m.sid, |     m.sid, | ||||||
|     m.id!, |     m.id!, | ||||||
|     m.conversationJid, |     m.conversationJid, | ||||||
|     m.isMedia, |     m.isMedia, | ||||||
|  |     m.isFileUploadNotification, | ||||||
|     originId: m.originId, |     originId: m.originId, | ||||||
|     received: m.received, |     received: m.received, | ||||||
|     displayed: m.displayed, |     displayed: m.displayed, | ||||||
| @ -82,6 +82,7 @@ Message messageDbToModel(DBMessage m) { | |||||||
|     srcUrl: m.srcUrl, |     srcUrl: m.srcUrl, | ||||||
|     quotes: m.quotes.value != null ? messageDbToModel(m.quotes.value!) : null, |     quotes: m.quotes.value != null ? messageDbToModel(m.quotes.value!) : null, | ||||||
|     errorType: m.errorType, |     errorType: m.errorType, | ||||||
|  |     filename: m.filename, | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| @ -233,11 +234,11 @@ class DatabaseService { | |||||||
|   Future<Message> addMessageFromData( |   Future<Message> addMessageFromData( | ||||||
|     String body, |     String body, | ||||||
|     int timestamp, |     int timestamp, | ||||||
|     String from, |     String sender, | ||||||
|     String conversationJid, |     String conversationJid, | ||||||
|     bool sent, |  | ||||||
|     bool isMedia, |     bool isMedia, | ||||||
|     String sid, |     String sid, | ||||||
|  |     bool isFileUploadNotification, | ||||||
|     { |     { | ||||||
|       String? srcUrl, |       String? srcUrl, | ||||||
|       String? mediaUrl, |       String? mediaUrl, | ||||||
| @ -246,14 +247,14 @@ class DatabaseService { | |||||||
|       String? thumbnailDimensions, |       String? thumbnailDimensions, | ||||||
|       String? originId, |       String? originId, | ||||||
|       String? quoteId, |       String? quoteId, | ||||||
|  |       String? filename, | ||||||
|     } |     } | ||||||
|   ) async { |   ) async { | ||||||
|     final m = DBMessage() |     final m = DBMessage() | ||||||
|       ..from = from |  | ||||||
|       ..conversationJid = conversationJid |       ..conversationJid = conversationJid | ||||||
|       ..timestamp = timestamp |       ..timestamp = timestamp | ||||||
|       ..body = body |       ..body = body | ||||||
|       ..sent = sent |       ..sender = sender | ||||||
|       ..isMedia = isMedia |       ..isMedia = isMedia | ||||||
|       ..mediaType = mediaType |       ..mediaType = mediaType | ||||||
|       ..mediaUrl = mediaUrl |       ..mediaUrl = mediaUrl | ||||||
| @ -265,7 +266,9 @@ class DatabaseService { | |||||||
|       ..displayed = false |       ..displayed = false | ||||||
|       ..acked = false |       ..acked = false | ||||||
|       ..originId = originId |       ..originId = originId | ||||||
|       ..errorType = noError; |       ..errorType = noError | ||||||
|  |       ..isFileUploadNotification = isFileUploadNotification | ||||||
|  |       ..filename = filename; | ||||||
| 
 | 
 | ||||||
|     if (quoteId != null) { |     if (quoteId != null) { | ||||||
|       final quotes = await getMessageByXmppId(quoteId, conversationJid); |       final quotes = await getMessageByXmppId(quoteId, conversationJid); | ||||||
| @ -303,6 +306,8 @@ class DatabaseService { | |||||||
|     bool? displayed, |     bool? displayed, | ||||||
|     bool? acked, |     bool? acked, | ||||||
|     int? errorType, |     int? errorType, | ||||||
|  |     bool? isFileUploadNotification, | ||||||
|  |     String? srcUrl, | ||||||
|   }) async { |   }) async { | ||||||
|     final i = (await _isar.dBMessages.get(id))!; |     final i = (await _isar.dBMessages.get(id))!; | ||||||
|     if (mediaUrl != null) { |     if (mediaUrl != null) { | ||||||
| @ -323,6 +328,12 @@ class DatabaseService { | |||||||
|     if (errorType != null) { |     if (errorType != null) { | ||||||
|       i.errorType = errorType; |       i.errorType = errorType; | ||||||
|     } |     } | ||||||
|  |     if (isFileUploadNotification != null) { | ||||||
|  |       i.isFileUploadNotification = isFileUploadNotification; | ||||||
|  |     } | ||||||
|  |     if (srcUrl != null) { | ||||||
|  |       i.srcUrl = srcUrl; | ||||||
|  |     } | ||||||
| 
 | 
 | ||||||
|     await _isar.writeTxn(() async { |     await _isar.writeTxn(() async { | ||||||
|       await _isar.dBMessages.put(i); |       await _isar.dBMessages.put(i); | ||||||
|  | |||||||
| @ -7,9 +7,6 @@ part 'message.g.dart'; | |||||||
| class DBMessage { | class DBMessage { | ||||||
|   int? id; |   int? id; | ||||||
| 
 | 
 | ||||||
|   @Index(caseSensitive: false) |  | ||||||
|   late String from; |  | ||||||
| 
 |  | ||||||
|   @Index(caseSensitive: false) |   @Index(caseSensitive: false) | ||||||
|   late String conversationJid; |   late String conversationJid; | ||||||
| 
 | 
 | ||||||
| @ -17,9 +14,9 @@ class DBMessage { | |||||||
| 
 | 
 | ||||||
|   late String body; |   late String body; | ||||||
| 
 | 
 | ||||||
|   // TODO(Unknown): Replace by just checking if sender == us |   /// The full JID of the sender | ||||||
|   /// Indicate if the message was sent by the user (true) or received by the user (false) |   @Index(caseSensitive: false) | ||||||
|   late bool sent; |   late String sender; | ||||||
| 
 | 
 | ||||||
|   late String sid; |   late String sid; | ||||||
|   String? originId; |   String? originId; | ||||||
| @ -33,6 +30,10 @@ class DBMessage { | |||||||
|   /// that clearly identifies the error. |   /// that clearly identifies the error. | ||||||
|   late int errorType; |   late int errorType; | ||||||
| 
 | 
 | ||||||
|  |   /// If true, then the message is currently a placeholder for a File Upload Notification | ||||||
|  |   /// and may be replaced | ||||||
|  |   late bool isFileUploadNotification; | ||||||
|  |    | ||||||
|   /// The message that this one quotes |   /// The message that this one quotes | ||||||
|   final quotes = IsarLink<DBMessage>(); |   final quotes = IsarLink<DBMessage>(); | ||||||
|    |    | ||||||
| @ -49,4 +50,7 @@ class DBMessage { | |||||||
|   String? thumbnailData; |   String? thumbnailData; | ||||||
|   /// The dimensions of the thumbnail |   /// The dimensions of the thumbnail | ||||||
|   String? thumbnailDimensions; |   String? thumbnailDimensions; | ||||||
|  | 
 | ||||||
|  |   /// The filename of the file. Useful for when we don't have the full URL yet | ||||||
|  |   String? filename; | ||||||
| } | } | ||||||
|  | |||||||
| @ -213,14 +213,13 @@ class HttpFileTransferService { | |||||||
|             id: job.message.sid, |             id: job.message.sid, | ||||||
|             originId: job.message.originId, |             originId: job.message.originId, | ||||||
|             sfs: StatelessFileSharingData( |             sfs: StatelessFileSharingData( | ||||||
|               url: slot.getUrl, |               FileMetadataData( | ||||||
|               metadata: FileMetadataData( |  | ||||||
|                 mediaType: fileMime, |                 mediaType: fileMime, | ||||||
|                 size: stat.size, |                 size: stat.size, | ||||||
|                 name: pathlib.basename(job.path), |                 name: pathlib.basename(job.path), | ||||||
|                 // TODO(Unknown): Add a thumbnail |                 thumbnails: job.thumbnails, | ||||||
|                 thumbnails: [], |  | ||||||
|               ), |               ), | ||||||
|  |               slot.getUrl, | ||||||
|             ), |             ), | ||||||
|           ), |           ), | ||||||
|         ); |         ); | ||||||
| @ -295,11 +294,12 @@ class HttpFileTransferService { | |||||||
|           job.mId, |           job.mId, | ||||||
|           mediaUrl: downloadedPath, |           mediaUrl: downloadedPath, | ||||||
|           mediaType: mime, |           mediaType: mime, | ||||||
|  |           isFileUploadNotification: false, | ||||||
|         ); |         ); | ||||||
| 
 | 
 | ||||||
|         sendEvent(MessageUpdatedEvent(message: msg.copyWith(isDownloading: false))); |         sendEvent(MessageUpdatedEvent(message: msg.copyWith(isDownloading: false))); | ||||||
| 
 | 
 | ||||||
|         if (notification.shouldShowNotification(msg.conversationJid)) { |         if (notification.shouldShowNotification(msg.conversationJid) && job.shouldShowNotification) { | ||||||
|           _log.finest('Creating notification with bigPicture $downloadedPath'); |           _log.finest('Creating notification with bigPicture $downloadedPath'); | ||||||
|           await notification.showNotification(msg, ''); |           await notification.showNotification(msg, ''); | ||||||
|         } |         } | ||||||
|  | |||||||
| @ -1,15 +1,17 @@ | |||||||
| import 'package:meta/meta.dart'; | import 'package:meta/meta.dart'; | ||||||
| import 'package:moxxyv2/shared/models/message.dart'; | import 'package:moxxyv2/shared/models/message.dart'; | ||||||
|  | import 'package:moxxyv2/xmpp/xeps/staging/extensible_file_thumbnails.dart'; | ||||||
| 
 | 
 | ||||||
| /// A job describing the download of a file. | /// A job describing the download of a file. | ||||||
| @immutable | @immutable | ||||||
| class FileUploadJob { | class FileUploadJob { | ||||||
| 
 | 
 | ||||||
|   const FileUploadJob(this.recipient, this.path, this.copyToPath, this.message); |   const FileUploadJob(this.recipient, this.path, this.copyToPath, this.message, this.thumbnails); | ||||||
|   final String path; |   final String path; | ||||||
|   final String recipient; |   final String recipient; | ||||||
|   final Message message; |   final Message message; | ||||||
|   final String copyToPath; |   final String copyToPath; | ||||||
|  |   final List<Thumbnail> thumbnails; | ||||||
| 
 | 
 | ||||||
|   @override |   @override | ||||||
|   bool operator ==(Object other) { |   bool operator ==(Object other) { | ||||||
| @ -17,22 +19,24 @@ class FileUploadJob { | |||||||
|       recipient == other.recipient && |       recipient == other.recipient && | ||||||
|       path == other.path && |       path == other.path && | ||||||
|       message == other.message && |       message == other.message && | ||||||
|       copyToPath == other.copyToPath; |       copyToPath == other.copyToPath && | ||||||
|  |       thumbnails == other.thumbnails; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   @override |   @override | ||||||
|   int get hashCode => path.hashCode ^ recipient.hashCode ^ message.hashCode ^ copyToPath.hashCode; |   int get hashCode => path.hashCode ^ recipient.hashCode ^ message.hashCode ^ copyToPath.hashCode ^ thumbnails.hashCode; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| /// A job describing the upload of a file. | /// A job describing the upload of a file. | ||||||
| @immutable | @immutable | ||||||
| class FileDownloadJob { | class FileDownloadJob { | ||||||
| 
 | 
 | ||||||
|   const FileDownloadJob(this.url, this.mId, this.conversationJid, this.mimeGuess); |   const FileDownloadJob(this.url, this.mId, this.conversationJid, this.mimeGuess, {this.shouldShowNotification = true}); | ||||||
|   final String url; |   final String url; | ||||||
|   final int mId; |   final int mId; | ||||||
|   final String conversationJid; |   final String conversationJid; | ||||||
|   final String? mimeGuess; |   final String? mimeGuess; | ||||||
|  |   final bool shouldShowNotification; | ||||||
|    |    | ||||||
|   @override |   @override | ||||||
|   bool operator ==(Object other) { |   bool operator ==(Object other) { | ||||||
| @ -40,8 +44,9 @@ class FileDownloadJob { | |||||||
|       url == other.url && |       url == other.url && | ||||||
|       mId == other.mId && |       mId == other.mId && | ||||||
|       conversationJid == other.conversationJid && |       conversationJid == other.conversationJid && | ||||||
|       mimeGuess == other.mimeGuess; |       mimeGuess == other.mimeGuess && | ||||||
|  |       shouldShowNotification == other.shouldShowNotification; | ||||||
|   } |   } | ||||||
|   @override |   @override | ||||||
|   int get hashCode => url.hashCode ^ mId.hashCode ^ conversationJid.hashCode ^ mimeGuess.hashCode; |   int get hashCode => url.hashCode ^ mId.hashCode ^ conversationJid.hashCode ^ mimeGuess.hashCode ^ shouldShowNotification.hashCode; | ||||||
| } | } | ||||||
|  | |||||||
| @ -3,6 +3,7 @@ import 'dart:collection'; | |||||||
| import 'package:get_it/get_it.dart'; | import 'package:get_it/get_it.dart'; | ||||||
| import 'package:logging/logging.dart'; | import 'package:logging/logging.dart'; | ||||||
| import 'package:moxxyv2/service/database.dart'; | import 'package:moxxyv2/service/database.dart'; | ||||||
|  | import 'package:moxxyv2/shared/helpers.dart'; | ||||||
| import 'package:moxxyv2/shared/models/message.dart'; | import 'package:moxxyv2/shared/models/message.dart'; | ||||||
| 
 | 
 | ||||||
| class MessageService { | class MessageService { | ||||||
| @ -30,11 +31,11 @@ class MessageService { | |||||||
|   Future<Message> addMessageFromData( |   Future<Message> addMessageFromData( | ||||||
|     String body, |     String body, | ||||||
|     int timestamp, |     int timestamp, | ||||||
|     String from, |     String sender, | ||||||
|     String conversationJid, |     String conversationJid, | ||||||
|     bool sent, |  | ||||||
|     bool isMedia, |     bool isMedia, | ||||||
|     String sid, |     String sid, | ||||||
|  |     bool isFileUploadNotification, | ||||||
|     { |     { | ||||||
|       String? srcUrl, |       String? srcUrl, | ||||||
|       String? mediaUrl, |       String? mediaUrl, | ||||||
| @ -43,16 +44,17 @@ class MessageService { | |||||||
|       String? thumbnailDimensions, |       String? thumbnailDimensions, | ||||||
|       String? originId, |       String? originId, | ||||||
|       String? quoteId, |       String? quoteId, | ||||||
|  |       String? filename, | ||||||
|     } |     } | ||||||
|   ) async { |   ) async { | ||||||
|     final msg = await GetIt.I.get<DatabaseService>().addMessageFromData( |     final msg = await GetIt.I.get<DatabaseService>().addMessageFromData( | ||||||
|       body, |       body, | ||||||
|       timestamp, |       timestamp, | ||||||
|       from, |       sender, | ||||||
|       conversationJid, |       conversationJid, | ||||||
|       sent, |  | ||||||
|       isMedia, |       isMedia, | ||||||
|       sid, |       sid, | ||||||
|  |       isFileUploadNotification, | ||||||
|       srcUrl: srcUrl, |       srcUrl: srcUrl, | ||||||
|       mediaUrl: mediaUrl, |       mediaUrl: mediaUrl, | ||||||
|       mediaType: mediaType, |       mediaType: mediaType, | ||||||
| @ -60,6 +62,7 @@ class MessageService { | |||||||
|       thumbnailDimensions: thumbnailDimensions, |       thumbnailDimensions: thumbnailDimensions, | ||||||
|       originId: originId, |       originId: originId, | ||||||
|       quoteId: quoteId, |       quoteId: quoteId, | ||||||
|  |       filename: filename, | ||||||
|     ); |     ); | ||||||
| 
 | 
 | ||||||
|     // Only update the cache if the conversation already has been loaded. This prevents |     // Only update the cache if the conversation already has been loaded. This prevents | ||||||
| @ -71,14 +74,27 @@ class MessageService { | |||||||
|     return msg; |     return msg; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   Future<Message?> getMessageByStanzaId(String conversationJid, String stanzaId) async { | ||||||
|  |     if (_messageCache.containsKey(conversationJid)) { | ||||||
|  |       await getMessagesForJid(conversationJid); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return firstWhereOrNull( | ||||||
|  |       _messageCache[conversationJid]!, | ||||||
|  |       (message) => message.sid == stanzaId, | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |    | ||||||
|   /// Wrapper around [DatabaseService]'s updateMessage that updates the cache |   /// Wrapper around [DatabaseService]'s updateMessage that updates the cache | ||||||
|   Future<Message> updateMessage(int id, { |   Future<Message> updateMessage(int id, { | ||||||
|       String? mediaUrl, |     String? mediaUrl, | ||||||
|       String? mediaType, |     String? mediaType, | ||||||
|       bool? received, |     bool? received, | ||||||
|       bool? displayed, |     bool? displayed, | ||||||
|       bool? acked, |     bool? acked, | ||||||
|       int? errorType, |     int? errorType, | ||||||
|  |     bool? isFileUploadNotification, | ||||||
|  |     String? srcUrl, | ||||||
|   }) async { |   }) async { | ||||||
|     final newMessage = await GetIt.I.get<DatabaseService>().updateMessage( |     final newMessage = await GetIt.I.get<DatabaseService>().updateMessage( | ||||||
|       id, |       id, | ||||||
| @ -88,6 +104,8 @@ class MessageService { | |||||||
|       displayed: displayed, |       displayed: displayed, | ||||||
|       acked: acked, |       acked: acked, | ||||||
|       errorType: errorType, |       errorType: errorType, | ||||||
|  |       isFileUploadNotification: isFileUploadNotification, | ||||||
|  |       srcUrl: srcUrl, | ||||||
|     ); |     ); | ||||||
| 
 | 
 | ||||||
|     if (_messageCache.containsKey(newMessage.conversationJid)) { |     if (_messageCache.containsKey(newMessage.conversationJid)) { | ||||||
|  | |||||||
| @ -39,6 +39,7 @@ import 'package:moxxyv2/xmpp/negotiators/starttls.dart'; | |||||||
| import 'package:moxxyv2/xmpp/ping.dart'; | import 'package:moxxyv2/xmpp/ping.dart'; | ||||||
| import 'package:moxxyv2/xmpp/presence.dart'; | import 'package:moxxyv2/xmpp/presence.dart'; | ||||||
| import 'package:moxxyv2/xmpp/roster.dart'; | import 'package:moxxyv2/xmpp/roster.dart'; | ||||||
|  | import 'package:moxxyv2/xmpp/xeps/staging/file_upload_notification.dart'; | ||||||
| import 'package:moxxyv2/xmpp/xeps/xep_0054.dart'; | import 'package:moxxyv2/xmpp/xeps/xep_0054.dart'; | ||||||
| import 'package:moxxyv2/xmpp/xeps/xep_0060.dart'; | import 'package:moxxyv2/xmpp/xeps/xep_0060.dart'; | ||||||
| import 'package:moxxyv2/xmpp/xeps/xep_0066.dart'; | import 'package:moxxyv2/xmpp/xeps/xep_0066.dart'; | ||||||
| @ -201,6 +202,7 @@ Future<void> entrypoint() async { | |||||||
|       BlockingManager(), |       BlockingManager(), | ||||||
|       ChatStateManager(), |       ChatStateManager(), | ||||||
|       HttpFileUploadManager(), |       HttpFileUploadManager(), | ||||||
|  |       FileUploadNotificationManager(), | ||||||
|     ]) |     ]) | ||||||
|     ..registerFeatureNegotiators([ |     ..registerFeatureNegotiators([ | ||||||
|       ResourceBindingNegotiator(), |       ResourceBindingNegotiator(), | ||||||
|  | |||||||
| @ -1,8 +1,11 @@ | |||||||
| import 'dart:async'; | import 'dart:async'; | ||||||
| import 'dart:convert'; | import 'dart:convert'; | ||||||
|  | import 'dart:io'; | ||||||
|  | import 'package:blurhash_dart/blurhash_dart.dart'; | ||||||
| import 'package:connectivity_plus/connectivity_plus.dart'; | import 'package:connectivity_plus/connectivity_plus.dart'; | ||||||
| import 'package:flutter_secure_storage/flutter_secure_storage.dart'; | import 'package:flutter_secure_storage/flutter_secure_storage.dart'; | ||||||
| import 'package:get_it/get_it.dart'; | import 'package:get_it/get_it.dart'; | ||||||
|  | import 'package:image/image.dart'; | ||||||
| import 'package:logging/logging.dart'; | import 'package:logging/logging.dart'; | ||||||
| import 'package:mime/mime.dart'; | import 'package:mime/mime.dart'; | ||||||
| import 'package:moxlib/moxlib.dart'; | import 'package:moxlib/moxlib.dart'; | ||||||
| @ -37,10 +40,11 @@ import 'package:moxxyv2/xmpp/namespaces.dart'; | |||||||
| import 'package:moxxyv2/xmpp/roster.dart'; | import 'package:moxxyv2/xmpp/roster.dart'; | ||||||
| import 'package:moxxyv2/xmpp/settings.dart'; | import 'package:moxxyv2/xmpp/settings.dart'; | ||||||
| import 'package:moxxyv2/xmpp/stanza.dart'; | import 'package:moxxyv2/xmpp/stanza.dart'; | ||||||
| import 'package:moxxyv2/xmpp/xeps/staging/file_thumbnails.dart'; | import 'package:moxxyv2/xmpp/xeps/staging/extensible_file_thumbnails.dart'; | ||||||
| import 'package:moxxyv2/xmpp/xeps/xep_0085.dart'; | import 'package:moxxyv2/xmpp/xeps/xep_0085.dart'; | ||||||
| import 'package:moxxyv2/xmpp/xeps/xep_0184.dart'; | import 'package:moxxyv2/xmpp/xeps/xep_0184.dart'; | ||||||
| import 'package:moxxyv2/xmpp/xeps/xep_0333.dart'; | import 'package:moxxyv2/xmpp/xeps/xep_0333.dart'; | ||||||
|  | import 'package:moxxyv2/xmpp/xeps/xep_0446.dart'; | ||||||
| import 'package:path/path.dart' as pathlib; | import 'package:path/path.dart' as pathlib; | ||||||
| import 'package:permission_handler/permission_handler.dart'; | import 'package:permission_handler/permission_handler.dart'; | ||||||
| 
 | 
 | ||||||
| @ -204,9 +208,9 @@ class XmppService { | |||||||
|       timestamp, |       timestamp, | ||||||
|       conn.getConnectionSettings().jid.toString(), |       conn.getConnectionSettings().jid.toString(), | ||||||
|       jid, |       jid, | ||||||
|       true, |  | ||||||
|       false, |       false, | ||||||
|       sid, |       sid, | ||||||
|  |       false, | ||||||
|       originId: originId, |       originId: originId, | ||||||
|       quoteId: quotedMessage?.originId ?? quotedMessage?.sid, |       quoteId: quotedMessage?.originId ?? quotedMessage?.sid, | ||||||
|     ); |     ); | ||||||
| @ -226,7 +230,7 @@ class XmppService { | |||||||
|         id: sid, |         id: sid, | ||||||
|         originId: originId, |         originId: originId, | ||||||
|         quoteBody: quotedMessage?.body, |         quoteBody: quotedMessage?.body, | ||||||
|         quoteFrom: quotedMessage?.from, |         quoteFrom: quotedMessage?.sender, | ||||||
|         quoteId: quotedMessage?.originId ?? quotedMessage?.sid, |         quoteId: quotedMessage?.originId ?? quotedMessage?.sid, | ||||||
|         chatState: chatState, |         chatState: chatState, | ||||||
|       ), |       ), | ||||||
| @ -334,24 +338,52 @@ class XmppService { | |||||||
|      |      | ||||||
|     // Path -> Message |     // Path -> Message | ||||||
|     final messages = <String, Message>{}; |     final messages = <String, Message>{}; | ||||||
|  |     // Path -> Thumbnails | ||||||
|  |     final thumbnails = <String, List<Thumbnail>>{}; | ||||||
| 
 | 
 | ||||||
|     // Create the messages and shared media entries |     // Create the messages and shared media entries | ||||||
|     final conn = GetIt.I.get<XmppConnection>(); |     final conn = GetIt.I.get<XmppConnection>(); | ||||||
|     for (final path in paths) { |     for (final path in paths) { | ||||||
|  |       final pathMime = lookupMimeType(path); | ||||||
|       final msg = await ms.addMessageFromData( |       final msg = await ms.addMessageFromData( | ||||||
|         '', |         '', | ||||||
|         DateTime.now().millisecondsSinceEpoch,  |         DateTime.now().millisecondsSinceEpoch,  | ||||||
|         conn.getConnectionSettings().jid.toString(), |         conn.getConnectionSettings().jid.toString(), | ||||||
|         recipient, |         recipient, | ||||||
|         true, |         true, | ||||||
|         true, |  | ||||||
|         conn.generateId(), |         conn.generateId(), | ||||||
|  |         false, | ||||||
|         mediaUrl: path, |         mediaUrl: path, | ||||||
|         mediaType: lookupMimeType(path), |         mediaType: pathMime, | ||||||
|         originId: conn.generateId(), |         originId: conn.generateId(), | ||||||
|       ); |       ); | ||||||
|       messages[path] = msg; |       messages[path] = msg; | ||||||
|       sendEvent(MessageAddedEvent(message: msg.copyWith(isUploading: true))); |       sendEvent(MessageAddedEvent(message: msg.copyWith(isUploading: true))); | ||||||
|  | 
 | ||||||
|  |       // TODO(PapaTutuWawa): Do this for videos | ||||||
|  |       // TODO(PapaTutuWawa): Maybe do this in a separate isolate | ||||||
|  |       if ((pathMime ?? '').startsWith('image/')) { | ||||||
|  |         final image = decodeImage((await File(path).readAsBytes()).toList()); | ||||||
|  |         if (image != null) { | ||||||
|  |           thumbnails[path] = [BlurhashThumbnail(BlurHash.encode(image).hash)]; | ||||||
|  |         } else { | ||||||
|  |           _log.warning('Failed to generate thumbnail for $path'); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       // Send an upload notification | ||||||
|  |       conn.getManagerById<MessageManager>(messageManager)!.sendMessage( | ||||||
|  |         MessageDetails( | ||||||
|  |           to: recipient, | ||||||
|  |           fun: FileMetadataData( | ||||||
|  |             // TODO(Unknown): Maybe add media type specific metadata | ||||||
|  |             mediaType: lookupMimeType(path), | ||||||
|  |             name: pathlib.basename(path), | ||||||
|  |             size: File(path).statSync().size, | ||||||
|  |             thumbnails: thumbnails[path] ?? [], | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |       ); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     // Create the shared media entries |     // Create the shared media entries | ||||||
| @ -387,6 +419,7 @@ class XmppService { | |||||||
|           path, |           path, | ||||||
|           await getDownloadPath(pathlib.basename(path), recipient, pathMime), |           await getDownloadPath(pathlib.basename(path), recipient, pathMime), | ||||||
|           messages[path]!, |           messages[path]!, | ||||||
|  |           thumbnails[path] ?? [], | ||||||
|         ), |         ), | ||||||
|       ); |       ); | ||||||
|     } |     } | ||||||
| @ -568,14 +601,15 @@ class XmppService { | |||||||
| 
 | 
 | ||||||
|   /// Return true if [event] describes a message that we want to display. |   /// Return true if [event] describes a message that we want to display. | ||||||
|   bool _isMessageEventMessage(MessageEvent event) { |   bool _isMessageEventMessage(MessageEvent event) { | ||||||
|     return event.body.isNotEmpty || event.sfs != null || event.sims != null; |     return event.body.isNotEmpty || event.sfs != null || event.sims != null || event.fun != null; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   /// Extract the thumbnail data from a message, if existent. |   /// Extract the thumbnail data from a message, if existent. | ||||||
|   String? _getThumbnailData(MessageEvent event) { |   String? _getThumbnailData(MessageEvent event) { | ||||||
|     final thumbnails = firstNotNull([ |     final thumbnails = firstNotNull([ | ||||||
|         event.sfs?.metadata.thumbnails, |         event.sfs?.metadata.thumbnails, | ||||||
|         event.sims?.thumbnails |         event.sims?.thumbnails, | ||||||
|  |         event.fun?.thumbnails, | ||||||
|     ]) ?? []; |     ]) ?? []; | ||||||
|     for (final i in thumbnails) { |     for (final i in thumbnails) { | ||||||
|       if (i is BlurhashThumbnail) { |       if (i is BlurhashThumbnail) { | ||||||
| @ -585,6 +619,35 @@ class XmppService { | |||||||
| 
 | 
 | ||||||
|     return null; |     return null; | ||||||
|   } |   } | ||||||
|  | 
 | ||||||
|  |   /// Extract the mime guess from a message, if existent. | ||||||
|  |   String? _getMimeGuess(MessageEvent event) { | ||||||
|  |     return firstNotNull([ | ||||||
|  |         event.sfs?.metadata.mediaType, | ||||||
|  |         event.sims?.mediaType, | ||||||
|  |         event.fun?.mediaType, | ||||||
|  |     ]); | ||||||
|  |   } | ||||||
|  |    | ||||||
|  |   /// Returns true if a file is embedded in [event]. If not, returns false. | ||||||
|  |   /// [embeddedFileUrl] is the possible Url of the file. If no file is present, then | ||||||
|  |   /// [embeddedFileUrl] is null. | ||||||
|  |   bool _isFileEmbedded(MessageEvent event, String? embeddedFileUrl) { | ||||||
|  |     // True if we determine a file to be embedded. Checks if the Url is using HTTPS and | ||||||
|  |     // that the message body and the OOB url are the same if the OOB url is not null. | ||||||
|  |     return embeddedFileUrl != null | ||||||
|  |       && Uri.parse(embeddedFileUrl).scheme == 'https' | ||||||
|  |       && implies(event.oob != null, event.body == event.oob?.url); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /// Returns true if a file should be automatically downloaded. If it should not, it | ||||||
|  |   /// returns false. | ||||||
|  |   /// [conversationJid] refers to the JID of the conversation the message was received in. | ||||||
|  |   Future<bool> _shouldDownloadFile(String conversationJid) async { | ||||||
|  |     return (await Permission.storage.status).isGranted | ||||||
|  |       && await _automaticFileDownloadAllowed() | ||||||
|  |       && await GetIt.I.get<RosterService>().isInRoster(conversationJid); | ||||||
|  |   } | ||||||
|    |    | ||||||
|   Future<void> _onMessage(MessageEvent event, { dynamic extra }) async { |   Future<void> _onMessage(MessageEvent event, { dynamic extra }) async { | ||||||
|     // The jid this message event is meant for |     // The jid this message event is meant for | ||||||
| @ -595,6 +658,12 @@ class XmppService { | |||||||
|     // Process the chat state update. Can also be attached to other messages |     // Process the chat state update. Can also be attached to other messages | ||||||
|     if (event.chatState != null) await _onChatState(event.chatState!, conversationJid); |     if (event.chatState != null) await _onChatState(event.chatState!, conversationJid); | ||||||
| 
 | 
 | ||||||
|  |     // Process File Upload Notifications replacements separately | ||||||
|  |     if (event.funReplacement != null) { | ||||||
|  |       await _handleFileUploadNotificationReplacement(event, conversationJid); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |      | ||||||
|     // Stop the processing here if the event does not describe a displayable message |     // Stop the processing here if the event does not describe a displayable message | ||||||
|     if (!_isMessageEventMessage(event)) return; |     if (!_isMessageEventMessage(event)) return; | ||||||
| 
 | 
 | ||||||
| @ -631,13 +700,9 @@ class XmppService { | |||||||
|     final embeddedFileUrl = _getMessageSrcUrl(event); |     final embeddedFileUrl = _getMessageSrcUrl(event); | ||||||
|     // True if we determine a file to be embedded. Checks if the Url is using HTTPS and |     // True if we determine a file to be embedded. Checks if the Url is using HTTPS and | ||||||
|     // that the message body and the OOB url are the same if the OOB url is not null. |     // that the message body and the OOB url are the same if the OOB url is not null. | ||||||
|     final isFileEmbedded = embeddedFileUrl != null |     final isFileEmbedded = _isFileEmbedded(event, embeddedFileUrl); | ||||||
|       && Uri.parse(embeddedFileUrl).scheme == 'https' |  | ||||||
|       && implies(event.oob != null, event.body == event.oob?.url); |  | ||||||
|     // Indicates if we should auto-download the file, if a file is specified in the message |     // Indicates if we should auto-download the file, if a file is specified in the message | ||||||
|     final shouldDownload = (await Permission.storage.status).isGranted |     final shouldDownload = await _shouldDownloadFile(conversationJid); | ||||||
|       && await _automaticFileDownloadAllowed() |  | ||||||
|       && isInRoster; |  | ||||||
|     // The thumbnail for the embedded file. |     // The thumbnail for the embedded file. | ||||||
|     final thumbnailData = _getThumbnailData(event); |     final thumbnailData = _getThumbnailData(event); | ||||||
|     // Indicates if a notification should be created for the message. |     // Indicates if a notification should be created for the message. | ||||||
| @ -646,7 +711,7 @@ class XmppService { | |||||||
|     // download to happen automatically, then the notification should happen immediately. |     // download to happen automatically, then the notification should happen immediately. | ||||||
|     var shouldNotify = !(isFileEmbedded && isInRoster && shouldDownload); |     var shouldNotify = !(isFileEmbedded && isInRoster && shouldDownload); | ||||||
|     // A guess for the Mime type of the embedded file. |     // A guess for the Mime type of the embedded file. | ||||||
|     String? mimeGuess; |     var mimeGuess = _getMimeGuess(event); | ||||||
| 
 | 
 | ||||||
|     // Create the message in the database |     // Create the message in the database | ||||||
|     final ms = GetIt.I.get<MessageService>(); |     final ms = GetIt.I.get<MessageService>(); | ||||||
| @ -655,21 +720,22 @@ class XmppService { | |||||||
|       messageTimestamp, |       messageTimestamp, | ||||||
|       event.fromJid.toString(), |       event.fromJid.toString(), | ||||||
|       conversationJid, |       conversationJid, | ||||||
|       sent, |       isFileEmbedded || event.fun != null, | ||||||
|       isFileEmbedded, |  | ||||||
|       event.sid, |       event.sid, | ||||||
|  |       event.fun != null, | ||||||
|       srcUrl: embeddedFileUrl, |       srcUrl: embeddedFileUrl, | ||||||
|       mediaType: mimeGuess, |       mediaType: mimeGuess, | ||||||
|       thumbnailData: thumbnailData, |       thumbnailData: thumbnailData, | ||||||
|       // TODO(Unknown): What about SIMS? |       // TODO(Unknown): What about SIMS? | ||||||
|       thumbnailDimensions: event.sfs?.metadata.dimensions, |       thumbnailDimensions: event.sfs?.metadata.dimensions ?? event.fun?.dimensions, | ||||||
|       quoteId: replyId, |       quoteId: replyId, | ||||||
|  |       filename: event.fun?.name, | ||||||
|     ); |     ); | ||||||
|      |      | ||||||
|     // Attempt to auto-download the embedded file |     // Attempt to auto-download the embedded file | ||||||
|     if (isFileEmbedded && shouldDownload) { |     if (isFileEmbedded && shouldDownload) { | ||||||
|       final fts = GetIt.I.get<HttpFileTransferService>(); |       final fts = GetIt.I.get<HttpFileTransferService>(); | ||||||
|       final metadata = await peekFile(embeddedFileUrl); |       final metadata = await peekFile(embeddedFileUrl!); | ||||||
| 
 | 
 | ||||||
|       if (metadata.mime != null) mimeGuess = metadata.mime; |       if (metadata.mime != null) mimeGuess = metadata.mime; | ||||||
| 
 | 
 | ||||||
| @ -695,7 +761,7 @@ class XmppService { | |||||||
|     final cs = GetIt.I.get<ConversationService>(); |     final cs = GetIt.I.get<ConversationService>(); | ||||||
|     final ns = GetIt.I.get<NotificationsService>(); |     final ns = GetIt.I.get<NotificationsService>(); | ||||||
|     // The body to be displayed in the conversations list |     // The body to be displayed in the conversations list | ||||||
|     final conversationBody = isFileEmbedded ? mimeTypeToConversationBody(mimeGuess) : messageBody; |     final conversationBody = isFileEmbedded || message.isFileUploadNotification ? mimeTypeToConversationBody(mimeGuess) : messageBody; | ||||||
|     // Specifies if we have the conversation this message goes to opened |     // Specifies if we have the conversation this message goes to opened | ||||||
|     final isConversationOpened = _currentlyOpenedChatJid == conversationJid; |     final isConversationOpened = _currentlyOpenedChatJid == conversationJid; | ||||||
|     // The conversation we're about to modify, if it exists |     // The conversation we're about to modify, if it exists | ||||||
| @ -754,7 +820,67 @@ class XmppService { | |||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     // Notify the UI of the message |     // Notify the UI of the message | ||||||
|     sendEvent(MessageAddedEvent(message: message)); |     sendEvent( | ||||||
|  |       MessageAddedEvent( | ||||||
|  |         message: message.copyWith( | ||||||
|  |           isDownloading: event.fun != null, | ||||||
|  |         ), | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   Future<void> _handleFileUploadNotificationReplacement(MessageEvent event, String conversationJid) async { | ||||||
|  |     final ms = GetIt.I.get<MessageService>(); | ||||||
|  |     var message = await ms.getMessageByStanzaId(conversationJid, event.funReplacement!); | ||||||
|  |     if (message == null) { | ||||||
|  |       _log.warning('Received a FileUploadNotification replacement for unknown message'); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // Check if we can even replace the message | ||||||
|  |     if (!message.isFileUploadNotification) { | ||||||
|  |       _log.warning('Received a FileUploadNotification replacement for message that is not marked as a FileUploadNotification'); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // Check if the Jid is allowed to do so | ||||||
|  |     // TODO(Unknown): Maybe use the JID parser? | ||||||
|  |     final bareSender = event.fromJid.toBare().toString(); | ||||||
|  |     if (message.sender.split('/').first != bareSender) { | ||||||
|  |       _log.warning('Received a FileUploadNotification replacement by $bareSender for message that is not sent by $bareSender'); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // The Url of the file embedded in the message, if there is one. | ||||||
|  |     final embeddedFileUrl = _getMessageSrcUrl(event); | ||||||
|  |     // Is there even a file we can download? | ||||||
|  |     final isFileEmbedded = _isFileEmbedded(event, embeddedFileUrl); | ||||||
|  | 
 | ||||||
|  |     if (isFileEmbedded) { | ||||||
|  |       if (await _shouldDownloadFile(conversationJid)) { | ||||||
|  |         message = message.copyWith(isDownloading: true); | ||||||
|  |         await GetIt.I.get<HttpFileTransferService>().downloadFile( | ||||||
|  |           FileDownloadJob( | ||||||
|  |             embeddedFileUrl!, | ||||||
|  |             message.id, | ||||||
|  |             conversationJid, | ||||||
|  |             null, | ||||||
|  |             shouldShowNotification: false, | ||||||
|  |           ), | ||||||
|  |         ); | ||||||
|  |       } else { | ||||||
|  |         message = await ms.updateMessage( | ||||||
|  |           message.id, | ||||||
|  |           srcUrl: embeddedFileUrl, | ||||||
|  |           isFileUploadNotification: false, | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         // Tell the UI | ||||||
|  |         sendEvent(MessageUpdatedEvent(message: message.copyWith(isDownloading: false))); | ||||||
|  |       } | ||||||
|  |     } else { | ||||||
|  |       _log.warning('Received a File Upload Notification replacement but the replacement contains no file!'); | ||||||
|  |     } | ||||||
|   } |   } | ||||||
|    |    | ||||||
|   Future<void> _onRosterPush(RosterPushEvent event, { dynamic extra }) async { |   Future<void> _onRosterPush(RosterPushEvent event, { dynamic extra }) async { | ||||||
|  | |||||||
| @ -1,4 +1,5 @@ | |||||||
| import 'dart:core'; | import 'dart:core'; | ||||||
|  | import 'package:moxxyv2/shared/models/message.dart'; | ||||||
| import 'package:moxxyv2/xmpp/xeps/xep_0085.dart'; | import 'package:moxxyv2/xmpp/xeps/xep_0085.dart'; | ||||||
| import 'package:synchronized/synchronized.dart'; | import 'package:synchronized/synchronized.dart'; | ||||||
| 
 | 
 | ||||||
| @ -302,3 +303,9 @@ extension ExceptionSafeLock on Lock { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | /// Returns true if the message [message] was sent by us ([jid]). If not, returns false. | ||||||
|  | bool isSent(Message message, String jid) { | ||||||
|  |   // TODO(PapaTutuWawa): Does this work? | ||||||
|  |   return message.sender.split('/').first == jid.split('/').first; | ||||||
|  | } | ||||||
|  | |||||||
| @ -10,14 +10,14 @@ class Message with _$Message { | |||||||
|   // NOTE: srcUrl is the Url that a file has been or can be downloaded from |   // NOTE: srcUrl is the Url that a file has been or can be downloaded from | ||||||
|    |    | ||||||
|   factory Message( |   factory Message( | ||||||
|     String from, |     String sender, | ||||||
|     String body, |     String body, | ||||||
|     int timestamp, |     int timestamp, | ||||||
|     bool sent, |  | ||||||
|     String sid, |     String sid, | ||||||
|     int id, |     int id, | ||||||
|     String conversationJid, |     String conversationJid, | ||||||
|     bool isMedia, |     bool isMedia, | ||||||
|  |     bool isFileUploadNotification, | ||||||
|     { |     { | ||||||
|       int? errorType, |       int? errorType, | ||||||
|       String? mediaUrl, |       String? mediaUrl, | ||||||
| @ -32,6 +32,7 @@ class Message with _$Message { | |||||||
|       @Default(false) bool acked, |       @Default(false) bool acked, | ||||||
|       String? originId, |       String? originId, | ||||||
|       Message? quotes, |       Message? quotes, | ||||||
|  |       String? filename, | ||||||
|     } |     } | ||||||
|   ) = _Message; |   ) = _Message; | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -45,6 +45,7 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> { | |||||||
|     on<FilePickerRequestedEvent>(_onFilePickerRequested); |     on<FilePickerRequestedEvent>(_onFilePickerRequested); | ||||||
|     on<ScrollStateSetEvent>(_onScrollStateSet); |     on<ScrollStateSetEvent>(_onScrollStateSet); | ||||||
|     on<EmojiPickerToggledEvent>(_onEmojiPickerToggled); |     on<EmojiPickerToggledEvent>(_onEmojiPickerToggled); | ||||||
|  |     on<OwnJidReceivedEvent>(_onOwnJidReceived); | ||||||
|   } |   } | ||||||
|   /// The current chat state with the conversation partner |   /// The current chat state with the conversation partner | ||||||
|   ChatState _currentChatState; |   ChatState _currentChatState; | ||||||
| @ -113,6 +114,7 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> { | |||||||
|     emit( |     emit( | ||||||
|       state.copyWith( |       state.copyWith( | ||||||
|         conversation: conversation, |         conversation: conversation, | ||||||
|  |         quotedMessage: null, | ||||||
|       ), |       ), | ||||||
|     ); |     ); | ||||||
| 
 | 
 | ||||||
| @ -324,4 +326,8 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> { | |||||||
|       } |       } | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  | 
 | ||||||
|  |   Future<void> _onOwnJidReceived(OwnJidReceivedEvent event, Emitter<ConversationState> emit) async { | ||||||
|  |     emit(state.copyWith(jid: event.jid)); | ||||||
|  |   } | ||||||
| } | } | ||||||
|  | |||||||
| @ -120,3 +120,9 @@ class EmojiPickerToggledEvent extends ConversationEvent { | |||||||
|   EmojiPickerToggledEvent({this.handleKeyboard = true}); |   EmojiPickerToggledEvent({this.handleKeyboard = true}); | ||||||
|   final bool handleKeyboard; |   final bool handleKeyboard; | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | /// Triggered when we received our own JID | ||||||
|  | class OwnJidReceivedEvent extends ConversationEvent { | ||||||
|  |   OwnJidReceivedEvent(this.jid); | ||||||
|  |   final String jid; | ||||||
|  | } | ||||||
|  | |||||||
| @ -3,6 +3,8 @@ part of 'conversation_bloc.dart'; | |||||||
| @freezed | @freezed | ||||||
| class ConversationState with _$ConversationState { | class ConversationState with _$ConversationState { | ||||||
|   factory ConversationState({ |   factory ConversationState({ | ||||||
|  |     // Our own JID | ||||||
|  |     @Default('') String jid, | ||||||
|     @Default('') String messageText, |     @Default('') String messageText, | ||||||
|     @Default(false) bool showSendButton, |     @Default(false) bool showSendButton, | ||||||
|     @Default(null) Message? quotedMessage, |     @Default(null) Message? quotedMessage, | ||||||
|  | |||||||
| @ -2,6 +2,7 @@ import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; | |||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:flutter_bloc/flutter_bloc.dart'; | import 'package:flutter_bloc/flutter_bloc.dart'; | ||||||
| import 'package:flutter_speed_dial/flutter_speed_dial.dart'; | import 'package:flutter_speed_dial/flutter_speed_dial.dart'; | ||||||
|  | import 'package:moxxyv2/shared/helpers.dart'; | ||||||
| import 'package:moxxyv2/ui/bloc/conversation_bloc.dart'; | import 'package:moxxyv2/ui/bloc/conversation_bloc.dart'; | ||||||
| import 'package:moxxyv2/ui/constants.dart'; | import 'package:moxxyv2/ui/constants.dart'; | ||||||
| import 'package:moxxyv2/ui/helpers.dart'; | import 'package:moxxyv2/ui/helpers.dart'; | ||||||
| @ -55,6 +56,7 @@ class ConversationBottomRow extends StatelessWidget { | |||||||
|                       controller: controller, |                       controller: controller, | ||||||
|                       topWidget: state.quotedMessage != null ? buildQuoteMessageWidget( |                       topWidget: state.quotedMessage != null ? buildQuoteMessageWidget( | ||||||
|                         state.quotedMessage!, |                         state.quotedMessage!, | ||||||
|  |                         isSent(state.quotedMessage!, state.jid), | ||||||
|                         resetQuote: () => context.read<ConversationBloc>().add(QuoteRemovedEvent()), |                         resetQuote: () => context.read<ConversationBloc>().add(QuoteRemovedEvent()), | ||||||
|                       ) : null, |                       ) : null, | ||||||
|                       shouldSummonKeyboard: () => !state.emojiPickerVisible, |                       shouldSummonKeyboard: () => !state.emojiPickerVisible, | ||||||
|  | |||||||
| @ -2,6 +2,7 @@ import 'dart:io'; | |||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:flutter_bloc/flutter_bloc.dart'; | import 'package:flutter_bloc/flutter_bloc.dart'; | ||||||
| import 'package:get_it/get_it.dart'; | import 'package:get_it/get_it.dart'; | ||||||
|  | import 'package:moxxyv2/shared/helpers.dart'; | ||||||
| import 'package:moxxyv2/ui/bloc/conversation_bloc.dart'; | import 'package:moxxyv2/ui/bloc/conversation_bloc.dart'; | ||||||
| import 'package:moxxyv2/ui/constants.dart'; | import 'package:moxxyv2/ui/constants.dart'; | ||||||
| import 'package:moxxyv2/ui/helpers.dart'; | import 'package:moxxyv2/ui/helpers.dart'; | ||||||
| @ -52,18 +53,22 @@ class ConversationPageState extends State<ConversationPage> { | |||||||
|     super.dispose(); |     super.dispose(); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   Widget _renderBubble(ConversationState state, BuildContext context, int _index, double maxWidth) { |   Widget _renderBubble(ConversationState state, BuildContext context, int _index, double maxWidth, String jid) { | ||||||
|     // TODO(Unknown): Since we reverse the list: Fix start, end and between |     // TODO(Unknown): Since we reverse the list: Fix start, end and between | ||||||
|     final index = state.messages.length - 1 - _index; |     final index = state.messages.length - 1 - _index; | ||||||
|     final item = state.messages[index]; |     final item = state.messages[index]; | ||||||
|     final start = index - 1 < 0 ? true : state.messages[index - 1].sent != item.sent; |     final start = index - 1 < 0 ? | ||||||
|     final end = index + 1 >= state.messages.length ? true : state.messages[index + 1].sent != item.sent; |       true : | ||||||
|  |       isSent(state.messages[index - 1], jid) != isSent(item, jid); | ||||||
|  |     final end = index + 1 >= state.messages.length ? | ||||||
|  |       true : | ||||||
|  |       isSent(state.messages[index + 1], jid) != isSent(item, jid); | ||||||
|     final between = !start && !end; |     final between = !start && !end; | ||||||
|     final lastMessageTimestamp = index > 0 ? state.messages[index - 1].timestamp : null; |     final lastMessageTimestamp = index > 0 ? state.messages[index - 1].timestamp : null; | ||||||
|      |      | ||||||
|     return ChatBubble( |     return ChatBubble( | ||||||
|       message: item, |       message: item, | ||||||
|       sentBySelf: item.sent, |       sentBySelf: isSent(item, jid), | ||||||
|       start: start, |       start: start, | ||||||
|       end: end, |       end: end, | ||||||
|       between: between, |       between: between, | ||||||
| @ -201,12 +206,20 @@ class ConversationPageState extends State<ConversationPage> { | |||||||
|                   ), |                   ), | ||||||
| 
 | 
 | ||||||
|                   BlocBuilder<ConversationBloc, ConversationState>( |                   BlocBuilder<ConversationBloc, ConversationState>( | ||||||
|  |                     // NOTE: We don't need to update when the jid changes as it should | ||||||
|  |                     //       be static over the entire lifetime of the BLoC. | ||||||
|                     buildWhen: (prev, next) => prev.messages != next.messages, |                     buildWhen: (prev, next) => prev.messages != next.messages, | ||||||
|                     builder: (context, state) => Expanded( |                     builder: (context, state) => Expanded( | ||||||
|                       child: ListView.builder( |                       child: ListView.builder( | ||||||
|                         reverse: true, |                         reverse: true, | ||||||
|                         itemCount: state.messages.length, |                         itemCount: state.messages.length, | ||||||
|                         itemBuilder: (context, index) => _renderBubble(state, context, index, maxWidth), |                         itemBuilder: (context, index) => _renderBubble( | ||||||
|  |                           state, | ||||||
|  |                           context, | ||||||
|  |                           index, | ||||||
|  |                           maxWidth, | ||||||
|  |                           state.jid, | ||||||
|  |                         ), | ||||||
|                         shrinkWrap: true, |                         shrinkWrap: true, | ||||||
|                         controller: _scrollController, |                         controller: _scrollController, | ||||||
|                       ), |                       ), | ||||||
|  | |||||||
| @ -3,6 +3,7 @@ import 'dart:async'; | |||||||
| import 'package:get_it/get_it.dart'; | import 'package:get_it/get_it.dart'; | ||||||
| import 'package:logging/logging.dart'; | import 'package:logging/logging.dart'; | ||||||
| import 'package:moxxyv2/shared/events.dart'; | import 'package:moxxyv2/shared/events.dart'; | ||||||
|  | import 'package:moxxyv2/ui/bloc/conversation_bloc.dart'; | ||||||
| import 'package:moxxyv2/ui/bloc/conversations_bloc.dart'; | import 'package:moxxyv2/ui/bloc/conversations_bloc.dart'; | ||||||
| import 'package:moxxyv2/ui/bloc/navigation_bloc.dart'; | import 'package:moxxyv2/ui/bloc/navigation_bloc.dart'; | ||||||
| import 'package:moxxyv2/ui/bloc/newconversation_bloc.dart'; | import 'package:moxxyv2/ui/bloc/newconversation_bloc.dart'; | ||||||
| @ -33,6 +34,7 @@ Future<void> preStartDone(PreStartDoneEvent result, { dynamic extra }) async { | |||||||
|         result.roster!, |         result.roster!, | ||||||
|       ), |       ), | ||||||
|     ); |     ); | ||||||
|  |     GetIt.I.get<ConversationBloc>().add(OwnJidReceivedEvent(result.jid!)); | ||||||
| 
 | 
 | ||||||
|     GetIt.I.get<Logger>().finest('Navigating to conversations'); |     GetIt.I.get<Logger>().finest('Navigating to conversations'); | ||||||
|     GetIt.I.get<NavigationBloc>().add( |     GetIt.I.get<NavigationBloc>().add( | ||||||
|  | |||||||
| @ -8,8 +8,9 @@ import 'package:moxxyv2/ui/constants.dart'; | |||||||
| 
 | 
 | ||||||
| class MessageBubbleBottom extends StatefulWidget { | class MessageBubbleBottom extends StatefulWidget { | ||||||
| 
 | 
 | ||||||
|   const MessageBubbleBottom(this.message, { Key? key }): super(key: key); |   const MessageBubbleBottom(this.message, this.sent, { Key? key }): super(key: key); | ||||||
|   final Message message; |   final Message message; | ||||||
|  |   final bool sent; | ||||||
| 
 | 
 | ||||||
|   @override |   @override | ||||||
|   MessageBubbleBottomState createState() => MessageBubbleBottomState(); |   MessageBubbleBottomState createState() => MessageBubbleBottomState(); | ||||||
| @ -54,17 +55,17 @@ class MessageBubbleBottomState extends State<MessageBubbleBottom> { | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   bool _showBlueCheckmarks() { |   bool _showBlueCheckmarks() { | ||||||
|     return widget.message.sent && widget.message.displayed; |     return widget.sent && widget.message.displayed; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   bool _showCheckmarks() { |   bool _showCheckmarks() { | ||||||
|     return widget.message.sent && |     return widget.sent && | ||||||
|             widget.message.received && |             widget.message.received && | ||||||
|             !widget.message.displayed; |             !widget.message.displayed; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   bool _showCheckmark() { |   bool _showCheckmark() { | ||||||
|     return widget.message.sent && |     return widget.sent && | ||||||
|             widget.message.acked && |             widget.message.acked && | ||||||
|             !widget.message.received && |             !widget.message.received && | ||||||
|             !widget.message.displayed; |             !widget.message.displayed; | ||||||
|  | |||||||
| @ -156,7 +156,7 @@ class ChatBubbleState extends State<ChatBubble> | |||||||
|               child: Padding( |               child: Padding( | ||||||
|                 // NOTE: Images don't work well with padding here |                 // NOTE: Images don't work well with padding here | ||||||
|                 padding: widget.message.isMedia || widget.message.quotes != null ? EdgeInsets.zero : const EdgeInsets.all(8), |                 padding: widget.message.isMedia || widget.message.quotes != null ? EdgeInsets.zero : const EdgeInsets.all(8), | ||||||
|                 child: buildMessageWidget(widget.message, widget.maxWidth, _getBorderRadius()), |                 child: buildMessageWidget(widget.message, widget.maxWidth, _getBorderRadius(), widget.sentBySelf), | ||||||
|               ), |               ), | ||||||
|             ) |             ) | ||||||
|           ], |           ], | ||||||
|  | |||||||
| @ -18,6 +18,7 @@ class FileChatBaseWidget extends StatelessWidget { | |||||||
|     this.icon, |     this.icon, | ||||||
|     this.filename, |     this.filename, | ||||||
|     this.radius, |     this.radius, | ||||||
|  |     this.sent, | ||||||
|     { |     { | ||||||
|       this.extra, |       this.extra, | ||||||
|       this.onTap, |       this.onTap, | ||||||
| @ -29,6 +30,7 @@ class FileChatBaseWidget extends StatelessWidget { | |||||||
|   final String filename; |   final String filename; | ||||||
|   final BorderRadius radius; |   final BorderRadius radius; | ||||||
|   final Widget? extra; |   final Widget? extra; | ||||||
|  |   final bool sent; | ||||||
|   final void Function()? onTap; |   final void Function()? onTap; | ||||||
| 
 | 
 | ||||||
|   @override |   @override | ||||||
| @ -51,7 +53,7 @@ class FileChatBaseWidget extends StatelessWidget { | |||||||
|           ], |           ], | ||||||
|         ), |         ), | ||||||
|       ), |       ), | ||||||
|       MessageBubbleBottom(message), |       MessageBubbleBottom(message, sent), | ||||||
|       radius, |       radius, | ||||||
|       gradient: false, |       gradient: false, | ||||||
|       extra: extra, |       extra: extra, | ||||||
| @ -67,6 +69,7 @@ class FileChatWidget extends StatelessWidget { | |||||||
|   const FileChatWidget( |   const FileChatWidget( | ||||||
|     this.message, |     this.message, | ||||||
|     this.radius, |     this.radius, | ||||||
|  |     this.sent, | ||||||
|     { |     { | ||||||
|       this.extra, |       this.extra, | ||||||
|       Key? key, |       Key? key, | ||||||
| @ -74,14 +77,16 @@ class FileChatWidget extends StatelessWidget { | |||||||
|   ) : super(key: key); |   ) : super(key: key); | ||||||
|   final Message message; |   final Message message; | ||||||
|   final BorderRadius radius; |   final BorderRadius radius; | ||||||
|  |   final bool sent; | ||||||
|   final Widget? extra; |   final Widget? extra; | ||||||
| 
 | 
 | ||||||
|   Widget _buildNonDownloaded() { |   Widget _buildNonDownloaded() { | ||||||
|     return FileChatBaseWidget( |     return FileChatBaseWidget( | ||||||
|       message, |       message, | ||||||
|       Icons.file_present, |       Icons.file_present, | ||||||
|       filenameFromUrl(message.srcUrl!), |       message.isFileUploadNotification ? (message.filename ?? '') : filenameFromUrl(message.srcUrl!), | ||||||
|       radius, |       radius, | ||||||
|  |       sent, | ||||||
|       extra: DownloadButton( |       extra: DownloadButton( | ||||||
|         onPressed: () { |         onPressed: () { | ||||||
|           MoxplatformPlugin.handler.getDataSender().sendData( |           MoxplatformPlugin.handler.getDataSender().sendData( | ||||||
| @ -97,8 +102,9 @@ class FileChatWidget extends StatelessWidget { | |||||||
|     return FileChatBaseWidget( |     return FileChatBaseWidget( | ||||||
|       message, |       message, | ||||||
|       Icons.file_present, |       Icons.file_present, | ||||||
|       filenameFromUrl(message.srcUrl!), |       message.isFileUploadNotification ? (message.filename ?? '') : filenameFromUrl(message.srcUrl!), | ||||||
|       radius, |       radius, | ||||||
|  |       sent, | ||||||
|       extra: ProgressWidget(id: message.id), |       extra: ProgressWidget(id: message.id), | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
| @ -107,8 +113,9 @@ class FileChatWidget extends StatelessWidget { | |||||||
|     return FileChatBaseWidget( |     return FileChatBaseWidget( | ||||||
|       message, |       message, | ||||||
|       Icons.file_present, |       Icons.file_present, | ||||||
|       filenameFromUrl(message.srcUrl!), |       message.isFileUploadNotification ? (message.filename ?? '') : filenameFromUrl(message.srcUrl!), | ||||||
|       radius, |       radius, | ||||||
|  |       sent, | ||||||
|       onTap: () { |       onTap: () { | ||||||
|         OpenFile.open(message.mediaUrl); |         OpenFile.open(message.mediaUrl); | ||||||
|       }, |       }, | ||||||
| @ -117,7 +124,7 @@ class FileChatWidget extends StatelessWidget { | |||||||
| 
 | 
 | ||||||
|   Widget _buildWrapper() { |   Widget _buildWrapper() { | ||||||
|     if (!message.isDownloading && message.mediaUrl != null) return _buildInner(); |     if (!message.isDownloading && message.mediaUrl != null) return _buildInner(); | ||||||
|     if (message.isDownloading) return _buildDownloading(); |     if (message.isFileUploadNotification || message.isDownloading) return _buildDownloading(); | ||||||
| 
 | 
 | ||||||
|     return _buildNonDownloaded(); |     return _buildNonDownloaded(); | ||||||
|   } |   } | ||||||
|  | |||||||
| @ -19,6 +19,7 @@ class ImageChatWidget extends StatelessWidget { | |||||||
|     this.message, |     this.message, | ||||||
|     this.radius, |     this.radius, | ||||||
|     this.maxWidth, |     this.maxWidth, | ||||||
|  |     this.sent, | ||||||
|     { |     { | ||||||
|       Key? key, |       Key? key, | ||||||
|     } |     } | ||||||
| @ -26,11 +27,12 @@ class ImageChatWidget extends StatelessWidget { | |||||||
|   final Message message; |   final Message message; | ||||||
|   final BorderRadius radius; |   final BorderRadius radius; | ||||||
|   final double maxWidth; |   final double maxWidth; | ||||||
|  |   final bool sent; | ||||||
| 
 | 
 | ||||||
|   Widget _buildUploading() { |   Widget _buildUploading() { | ||||||
|     return MediaBaseChatWidget( |     return MediaBaseChatWidget( | ||||||
|       Image.file(File(message.mediaUrl!)), |       Image.file(File(message.mediaUrl!)), | ||||||
|       MessageBubbleBottom(message), |       MessageBubbleBottom(message, sent), | ||||||
|       radius, |       radius, | ||||||
|       extra: ProgressWidget(id: message.id), |       extra: ProgressWidget(id: message.id), | ||||||
|     ); |     ); | ||||||
| @ -50,7 +52,7 @@ class ImageChatWidget extends StatelessWidget { | |||||||
|             decodingHeight: thumbnailSize.height.toInt(), |             decodingHeight: thumbnailSize.height.toInt(), | ||||||
|           ), |           ), | ||||||
|         ), |         ), | ||||||
|         MessageBubbleBottom(message), |         MessageBubbleBottom(message, sent), | ||||||
|         radius, |         radius, | ||||||
|         extra: ProgressWidget(id: message.id), |         extra: ProgressWidget(id: message.id), | ||||||
|       ); |       ); | ||||||
| @ -58,8 +60,9 @@ class ImageChatWidget extends StatelessWidget { | |||||||
|       return FileChatBaseWidget( |       return FileChatBaseWidget( | ||||||
|         message, |         message, | ||||||
|         Icons.image, |         Icons.image, | ||||||
|         filenameFromUrl(message.srcUrl!), |         message.isFileUploadNotification ? (message.filename ?? '') : filenameFromUrl(message.srcUrl!), | ||||||
|         radius, |         radius, | ||||||
|  |         sent, | ||||||
|         extra: ProgressWidget(id: message.id), |         extra: ProgressWidget(id: message.id), | ||||||
|       ); |       ); | ||||||
|     } |     } | ||||||
| @ -69,7 +72,7 @@ class ImageChatWidget extends StatelessWidget { | |||||||
|   Widget _buildImage() { |   Widget _buildImage() { | ||||||
|     return MediaBaseChatWidget( |     return MediaBaseChatWidget( | ||||||
|       Image.file(File(message.mediaUrl!)), |       Image.file(File(message.mediaUrl!)), | ||||||
|       MessageBubbleBottom(message), |       MessageBubbleBottom(message, sent), | ||||||
|       radius, |       radius, | ||||||
|       onTap: () { |       onTap: () { | ||||||
|         OpenFile.open(message.mediaUrl); |         OpenFile.open(message.mediaUrl); | ||||||
| @ -91,7 +94,7 @@ class ImageChatWidget extends StatelessWidget { | |||||||
|             decodingHeight: thumbnailSize.height.toInt(), |             decodingHeight: thumbnailSize.height.toInt(), | ||||||
|           ), |           ), | ||||||
|         ), |         ), | ||||||
|         MessageBubbleBottom(message), |         MessageBubbleBottom(message, sent), | ||||||
|         radius, |         radius, | ||||||
|         extra: DownloadButton( |         extra: DownloadButton( | ||||||
|           onPressed: () => requestMediaDownload(message), |           onPressed: () => requestMediaDownload(message), | ||||||
| @ -101,8 +104,9 @@ class ImageChatWidget extends StatelessWidget { | |||||||
|       return FileChatBaseWidget( |       return FileChatBaseWidget( | ||||||
|         message, |         message, | ||||||
|         Icons.image, |         Icons.image, | ||||||
|         filenameFromUrl(message.srcUrl!), |         message.isFileUploadNotification ? (message.filename ?? '') : filenameFromUrl(message.srcUrl!), | ||||||
|         radius, |         radius, | ||||||
|  |         sent, | ||||||
|         extra: DownloadButton( |         extra: DownloadButton( | ||||||
|           onPressed: () { |           onPressed: () { | ||||||
|             MoxplatformPlugin.handler.getDataSender().sendData( |             MoxplatformPlugin.handler.getDataSender().sendData( | ||||||
| @ -118,10 +122,10 @@ class ImageChatWidget extends StatelessWidget { | |||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     if (message.isUploading) return _buildUploading(); |     if (message.isUploading) return _buildUploading(); | ||||||
|     if (message.isDownloading) return _buildDownloading(); |     if (message.isFileUploadNotification || message.isDownloading) return _buildDownloading(); | ||||||
| 
 | 
 | ||||||
|     // TODO(PapaTutuWawa): Maybe use an async builder |     // TODO(PapaTutuWawa): Maybe use an async builder | ||||||
|     if (File(message.mediaUrl!).existsSync()) return _buildImage(); |     if (message.mediaUrl != null && File(message.mediaUrl!).existsSync()) return _buildImage(); | ||||||
| 
 | 
 | ||||||
|     return _buildDownloadable(); |     return _buildDownloadable(); | ||||||
|   } |   } | ||||||
|  | |||||||
| @ -47,33 +47,34 @@ MessageType getMessageType(Message message) { | |||||||
| } | } | ||||||
| 
 | 
 | ||||||
| /// Build an inlinable message widget | /// Build an inlinable message widget | ||||||
| Widget buildMessageWidget(Message message, double maxWidth, BorderRadius radius) { | Widget buildMessageWidget(Message message, double maxWidth, BorderRadius radius, bool sent) { | ||||||
|   switch (getMessageType(message)) { |   switch (getMessageType(message)) { | ||||||
|     case MessageType.text: { |     case MessageType.text: { | ||||||
|       return TextChatWidget( |       return TextChatWidget( | ||||||
|         message, |         message, | ||||||
|         topWidget: message.quotes != null ? buildQuoteMessageWidget(message.quotes!) : null, |         sent, | ||||||
|  |         topWidget: message.quotes != null ? buildQuoteMessageWidget(message.quotes!, sent) : null, | ||||||
|       ); |       ); | ||||||
|     } |     } | ||||||
|     case MessageType.image: { |     case MessageType.image: { | ||||||
|       return ImageChatWidget(message, radius, maxWidth); |       return ImageChatWidget(message, radius, maxWidth, sent); | ||||||
|     } |     } | ||||||
|     case MessageType.video: { |     case MessageType.video: { | ||||||
|       return VideoChatWidget(message, radius, maxWidth); |       return VideoChatWidget(message, radius, maxWidth, sent); | ||||||
|     } |     } | ||||||
|     // TODO(Unknown): Implement audio |     // TODO(Unknown): Implement audio | ||||||
|     //case MessageType.audio: return buildImageMessageWidget(message); |     //case MessageType.audio: return buildImageMessageWidget(message); | ||||||
|     case MessageType.file: { |     case MessageType.file: { | ||||||
|       return FileChatWidget(message, radius); |       return FileChatWidget(message, radius, sent); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| /// Build a widget that represents a quoted message within another bubble. | /// Build a widget that represents a quoted message within another bubble. | ||||||
| Widget buildQuoteMessageWidget(Message message, { void Function()? resetQuote}) { | Widget buildQuoteMessageWidget(Message message, bool sent, { void Function()? resetQuote}) { | ||||||
|   switch (getMessageType(message)) { |   switch (getMessageType(message)) { | ||||||
|     case MessageType.text: |     case MessageType.text: | ||||||
|       return QuoteBaseWidget(message, Text(message.body), resetQuotedMessage: resetQuote); |       return QuoteBaseWidget(message, Text(message.body), sent, resetQuotedMessage: resetQuote); | ||||||
|     case MessageType.image: |     case MessageType.image: | ||||||
|       return QuoteBaseWidget( |       return QuoteBaseWidget( | ||||||
|         message, |         message, | ||||||
| @ -90,6 +91,7 @@ Widget buildQuoteMessageWidget(Message message, { void Function()? resetQuote}) | |||||||
|             ), |             ), | ||||||
|           ], |           ], | ||||||
|         ), |         ), | ||||||
|  |         sent, | ||||||
|         resetQuotedMessage: resetQuote, |         resetQuotedMessage: resetQuote, | ||||||
|       ); |       ); | ||||||
|     case MessageType.video: |     case MessageType.video: | ||||||
| @ -136,6 +138,7 @@ Widget buildQuoteMessageWidget(Message message, { void Function()? resetQuote}) | |||||||
|             ) |             ) | ||||||
|           ], |           ], | ||||||
|         ), |         ), | ||||||
|  |         sent, | ||||||
|         resetQuotedMessage: resetQuote, |         resetQuotedMessage: resetQuote, | ||||||
|       ); |       ); | ||||||
|     // TODO(Unknown): Implement audio |     // TODO(Unknown): Implement audio | ||||||
| @ -170,6 +173,7 @@ Widget buildQuoteMessageWidget(Message message, { void Function()? resetQuote}) | |||||||
|             ) |             ) | ||||||
|           ], |           ], | ||||||
|         ), |         ), | ||||||
|  |         sent, | ||||||
|         resetQuotedMessage: resetQuote, |         resetQuotedMessage: resetQuote, | ||||||
|       ); |       ); | ||||||
|   } |   } | ||||||
|  | |||||||
| @ -21,6 +21,7 @@ class VideoChatWidget extends StatelessWidget { | |||||||
|     this.message, |     this.message, | ||||||
|     this.radius, |     this.radius, | ||||||
|     this.maxWidth, |     this.maxWidth, | ||||||
|  |     this.sent, | ||||||
|     { |     { | ||||||
|       Key? key, |       Key? key, | ||||||
|     } |     } | ||||||
| @ -28,6 +29,7 @@ class VideoChatWidget extends StatelessWidget { | |||||||
|   final Message message; |   final Message message; | ||||||
|   final double maxWidth; |   final double maxWidth; | ||||||
|   final BorderRadius radius; |   final BorderRadius radius; | ||||||
|  |   final bool sent; | ||||||
| 
 | 
 | ||||||
|   Widget _buildUploading() { |   Widget _buildUploading() { | ||||||
|     return MediaBaseChatWidget( |     return MediaBaseChatWidget( | ||||||
| @ -35,7 +37,7 @@ class VideoChatWidget extends StatelessWidget { | |||||||
|         message.mediaUrl!, |         message.mediaUrl!, | ||||||
|         Image.memory, |         Image.memory, | ||||||
|       ), |       ), | ||||||
|       MessageBubbleBottom(message), |       MessageBubbleBottom(message, sent), | ||||||
|       radius, |       radius, | ||||||
|       extra: ProgressWidget(id: message.id), |       extra: ProgressWidget(id: message.id), | ||||||
|     ); |     ); | ||||||
| @ -55,7 +57,7 @@ class VideoChatWidget extends StatelessWidget { | |||||||
|             decodingHeight: thumbnailSize.height.toInt(), |             decodingHeight: thumbnailSize.height.toInt(), | ||||||
|           ), |           ), | ||||||
|         ), |         ), | ||||||
|         MessageBubbleBottom(message), |         MessageBubbleBottom(message, sent), | ||||||
|         radius, |         radius, | ||||||
|         extra: ProgressWidget(id: message.id), |         extra: ProgressWidget(id: message.id), | ||||||
|       ); |       ); | ||||||
| @ -63,8 +65,9 @@ class VideoChatWidget extends StatelessWidget { | |||||||
|       return FileChatBaseWidget( |       return FileChatBaseWidget( | ||||||
|         message, |         message, | ||||||
|         Icons.video_file_outlined, |         Icons.video_file_outlined, | ||||||
|         filenameFromUrl(message.srcUrl!), |         message.isFileUploadNotification ? (message.filename ?? '') : filenameFromUrl(message.srcUrl!), | ||||||
|         radius, |         radius, | ||||||
|  |         sent, | ||||||
|         extra: ProgressWidget(id: message.id), |         extra: ProgressWidget(id: message.id), | ||||||
|       ); |       ); | ||||||
|     } |     } | ||||||
| @ -77,7 +80,7 @@ class VideoChatWidget extends StatelessWidget { | |||||||
|         message.mediaUrl!, |         message.mediaUrl!, | ||||||
|         Image.memory, |         Image.memory, | ||||||
|       ), |       ), | ||||||
|       MessageBubbleBottom(message), |       MessageBubbleBottom(message, sent), | ||||||
|       radius, |       radius, | ||||||
|       onTap: () { |       onTap: () { | ||||||
|         OpenFile.open(message.mediaUrl); |         OpenFile.open(message.mediaUrl); | ||||||
| @ -100,7 +103,7 @@ class VideoChatWidget extends StatelessWidget { | |||||||
|             decodingHeight: thumbnailSize.height.toInt(), |             decodingHeight: thumbnailSize.height.toInt(), | ||||||
|           ), |           ), | ||||||
|         ), |         ), | ||||||
|         MessageBubbleBottom(message), |         MessageBubbleBottom(message, sent), | ||||||
|         radius, |         radius, | ||||||
|         extra: DownloadButton( |         extra: DownloadButton( | ||||||
|           onPressed: () => requestMediaDownload(message), |           onPressed: () => requestMediaDownload(message), | ||||||
| @ -110,8 +113,9 @@ class VideoChatWidget extends StatelessWidget { | |||||||
|       return FileChatBaseWidget( |       return FileChatBaseWidget( | ||||||
|         message, |         message, | ||||||
|         Icons.video_file_outlined, |         Icons.video_file_outlined, | ||||||
|         filenameFromUrl(message.srcUrl!), |         message.isFileUploadNotification ? (message.filename ?? '') : filenameFromUrl(message.srcUrl!), | ||||||
|         radius, |         radius, | ||||||
|  |         sent, | ||||||
|         extra: DownloadButton( |         extra: DownloadButton( | ||||||
|           onPressed: () { |           onPressed: () { | ||||||
|             MoxplatformPlugin.handler.getDataSender().sendData( |             MoxplatformPlugin.handler.getDataSender().sendData( | ||||||
| @ -127,10 +131,10 @@ class VideoChatWidget extends StatelessWidget { | |||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     if (message.isUploading) return _buildUploading(); |     if (message.isUploading) return _buildUploading(); | ||||||
|     if (message.isDownloading) return _buildDownloading(); |     if (message.isFileUploadNotification || message.isDownloading) return _buildDownloading(); | ||||||
| 
 | 
 | ||||||
|     // TODO(PapaTutuWawa): Maybe use an async builder |     // TODO(PapaTutuWawa): Maybe use an async builder | ||||||
|     if (File(message.mediaUrl!).existsSync()) return _buildVideo(); |     if (message.mediaUrl != null && File(message.mediaUrl!).existsSync()) return _buildVideo(); | ||||||
| 
 | 
 | ||||||
|     return _buildDownloadable(); |     return _buildDownloadable(); | ||||||
|   } |   } | ||||||
|  | |||||||
| @ -11,6 +11,7 @@ class QuoteBaseWidget extends StatelessWidget { | |||||||
|   const QuoteBaseWidget( |   const QuoteBaseWidget( | ||||||
|     this.message, |     this.message, | ||||||
|     this.child, |     this.child, | ||||||
|  |     this.sent, | ||||||
|     { |     { | ||||||
|       this.resetQuotedMessage, |       this.resetQuotedMessage, | ||||||
|       Key? key, |       Key? key, | ||||||
| @ -18,10 +19,11 @@ class QuoteBaseWidget extends StatelessWidget { | |||||||
|   ) : super(key: key); |   ) : super(key: key); | ||||||
|   final Message message; |   final Message message; | ||||||
|   final Widget child; |   final Widget child; | ||||||
|  |   final bool sent; | ||||||
|   final void Function()? resetQuotedMessage; |   final void Function()? resetQuotedMessage; | ||||||
| 
 | 
 | ||||||
|   Color _getColor() { |   Color _getColor() { | ||||||
|     if (message.sent) { |     if (sent) { | ||||||
|       return bubbleColorSentQuoted; |       return bubbleColorSentQuoted; | ||||||
|     } else { |     } else { | ||||||
|       return bubbleColorReceivedQuoted; |       return bubbleColorReceivedQuoted; | ||||||
|  | |||||||
| @ -10,12 +10,14 @@ class TextChatWidget extends StatelessWidget { | |||||||
| 
 | 
 | ||||||
|   const TextChatWidget( |   const TextChatWidget( | ||||||
|     this.message, |     this.message, | ||||||
|  |     this.sent, | ||||||
|     { |     { | ||||||
|       this.topWidget, |       this.topWidget, | ||||||
|       Key? key, |       Key? key, | ||||||
|     } |     } | ||||||
|   ) : super(key: key); |   ) : super(key: key); | ||||||
|   final Message message; |   final Message message; | ||||||
|  |   final bool sent; | ||||||
|   final Widget? topWidget; |   final Widget? topWidget; | ||||||
| 
 | 
 | ||||||
|   @override |   @override | ||||||
| @ -40,7 +42,7 @@ class TextChatWidget extends StatelessWidget { | |||||||
|           ), |           ), | ||||||
|           Padding( |           Padding( | ||||||
|             padding: topWidget != null ? const EdgeInsets.only(left: 8, right: 8, bottom: 8) : EdgeInsets.zero, |             padding: topWidget != null ? const EdgeInsets.only(left: 8, right: 8, bottom: 8) : EdgeInsets.zero, | ||||||
|             child: MessageBubbleBottom(message), |             child: MessageBubbleBottom(message, sent), | ||||||
|           ) |           ) | ||||||
|         ], |         ], | ||||||
|       ), |       ), | ||||||
|  | |||||||
| @ -7,6 +7,7 @@ import 'package:moxxyv2/xmpp/xeps/xep_0066.dart'; | |||||||
| import 'package:moxxyv2/xmpp/xeps/xep_0085.dart'; | import 'package:moxxyv2/xmpp/xeps/xep_0085.dart'; | ||||||
| import 'package:moxxyv2/xmpp/xeps/xep_0359.dart'; | import 'package:moxxyv2/xmpp/xeps/xep_0359.dart'; | ||||||
| import 'package:moxxyv2/xmpp/xeps/xep_0385.dart'; | import 'package:moxxyv2/xmpp/xeps/xep_0385.dart'; | ||||||
|  | import 'package:moxxyv2/xmpp/xeps/xep_0446.dart'; | ||||||
| import 'package:moxxyv2/xmpp/xeps/xep_0447.dart'; | import 'package:moxxyv2/xmpp/xeps/xep_0447.dart'; | ||||||
| import 'package:moxxyv2/xmpp/xeps/xep_0461.dart'; | import 'package:moxxyv2/xmpp/xeps/xep_0461.dart'; | ||||||
| 
 | 
 | ||||||
| @ -55,20 +56,23 @@ class StreamResumeFailedEvent extends XmppEvent {} | |||||||
| class MessageEvent extends XmppEvent { | class MessageEvent extends XmppEvent { | ||||||
| 
 | 
 | ||||||
|   MessageEvent({ |   MessageEvent({ | ||||||
|       required this.body, |     required this.body, | ||||||
|       required this.fromJid, |     required this.fromJid, | ||||||
|       required this.toJid, |     required this.toJid, | ||||||
|       required this.sid, |     required this.sid, | ||||||
|       required this.stanzaId, |     required this.stanzaId, | ||||||
|       required this.isCarbon, |     required this.isCarbon, | ||||||
|       required this.deliveryReceiptRequested, |     required this.deliveryReceiptRequested, | ||||||
|       required this.isMarkable, |     required this.isMarkable, | ||||||
|       this.type, |     this.type, | ||||||
|       this.oob, |     this.oob, | ||||||
|       this.sfs, |     this.sfs, | ||||||
|       this.sims, |     this.sims, | ||||||
|       this.reply, |     this.reply, | ||||||
|       this.chatState, |     this.chatState, | ||||||
|  |     this.fun, | ||||||
|  |     this.funReplacement, | ||||||
|  |     this.funCancellation, | ||||||
|   }); |   }); | ||||||
|   final String body; |   final String body; | ||||||
|   final JID fromJid; |   final JID fromJid; | ||||||
| @ -84,6 +88,9 @@ class MessageEvent extends XmppEvent { | |||||||
|   final StatelessMediaSharingData? sims; |   final StatelessMediaSharingData? sims; | ||||||
|   final ReplyData? reply; |   final ReplyData? reply; | ||||||
|   final ChatState? chatState; |   final ChatState? chatState; | ||||||
|  |   final FileMetadataData? fun; | ||||||
|  |   final String? funReplacement; | ||||||
|  |   final String? funCancellation; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| /// Triggered when a client responds to our delivery receipt request | /// Triggered when a client responds to our delivery receipt request | ||||||
|  | |||||||
| @ -5,6 +5,7 @@ import 'package:moxxyv2/xmpp/xeps/xep_0066.dart'; | |||||||
| import 'package:moxxyv2/xmpp/xeps/xep_0085.dart'; | import 'package:moxxyv2/xmpp/xeps/xep_0085.dart'; | ||||||
| import 'package:moxxyv2/xmpp/xeps/xep_0359.dart'; | import 'package:moxxyv2/xmpp/xeps/xep_0359.dart'; | ||||||
| import 'package:moxxyv2/xmpp/xeps/xep_0385.dart'; | import 'package:moxxyv2/xmpp/xeps/xep_0385.dart'; | ||||||
|  | import 'package:moxxyv2/xmpp/xeps/xep_0446.dart'; | ||||||
| import 'package:moxxyv2/xmpp/xeps/xep_0447.dart'; | import 'package:moxxyv2/xmpp/xeps/xep_0447.dart'; | ||||||
| import 'package:moxxyv2/xmpp/xeps/xep_0461.dart'; | import 'package:moxxyv2/xmpp/xeps/xep_0461.dart'; | ||||||
| 
 | 
 | ||||||
| @ -32,6 +33,13 @@ class StanzaHandlerData with _$StanzaHandlerData { | |||||||
|       @Default(false) bool isCarbon, |       @Default(false) bool isCarbon, | ||||||
|       @Default(false) bool deliveryReceiptRequested, |       @Default(false) bool deliveryReceiptRequested, | ||||||
|       @Default(false) bool isMarkable, |       @Default(false) bool isMarkable, | ||||||
|  |       // File Upload Notifications | ||||||
|  |       // A notification | ||||||
|  |       FileMetadataData? fun, | ||||||
|  |       // The stanza id this replaces | ||||||
|  |       String? funReplacement, | ||||||
|  |       // The stanza id this cancels | ||||||
|  |       String? funCancellation, | ||||||
|       // This is for stanza handlers that are not part of the XMPP library but still need |       // This is for stanza handlers that are not part of the XMPP library but still need | ||||||
|       // pass data around. |       // pass data around. | ||||||
|       @Default(<String, dynamic>{}) Map<String, dynamic> other, |       @Default(<String, dynamic>{}) Map<String, dynamic> other, | ||||||
|  | |||||||
| @ -19,3 +19,4 @@ const blockingManager = 'im.moxxy.blockingmanager'; | |||||||
| const httpFileUploadManager = 'im.moxxy.httpfileuploadmanager'; | const httpFileUploadManager = 'im.moxxy.httpfileuploadmanager'; | ||||||
| const chatStateManager = 'im.moxxy.chatstatemanager'; | const chatStateManager = 'im.moxxy.chatstatemanager'; | ||||||
| const pingManager = 'im.moxxy.ping'; | const pingManager = 'im.moxxy.ping'; | ||||||
|  | const fileUploadNotificationManager = 'im.moxxy.fileuploadnotificationmanager'; | ||||||
|  | |||||||
| @ -8,30 +8,35 @@ import 'package:moxxyv2/xmpp/managers/namespaces.dart'; | |||||||
| import 'package:moxxyv2/xmpp/namespaces.dart'; | import 'package:moxxyv2/xmpp/namespaces.dart'; | ||||||
| import 'package:moxxyv2/xmpp/stanza.dart'; | import 'package:moxxyv2/xmpp/stanza.dart'; | ||||||
| import 'package:moxxyv2/xmpp/stringxml.dart'; | import 'package:moxxyv2/xmpp/stringxml.dart'; | ||||||
|  | import 'package:moxxyv2/xmpp/xeps/staging/file_upload_notification.dart'; | ||||||
| import 'package:moxxyv2/xmpp/xeps/xep_0066.dart'; | import 'package:moxxyv2/xmpp/xeps/xep_0066.dart'; | ||||||
| import 'package:moxxyv2/xmpp/xeps/xep_0085.dart'; | import 'package:moxxyv2/xmpp/xeps/xep_0085.dart'; | ||||||
| import 'package:moxxyv2/xmpp/xeps/xep_0184.dart'; | import 'package:moxxyv2/xmpp/xeps/xep_0184.dart'; | ||||||
| import 'package:moxxyv2/xmpp/xeps/xep_0333.dart'; | import 'package:moxxyv2/xmpp/xeps/xep_0333.dart'; | ||||||
| import 'package:moxxyv2/xmpp/xeps/xep_0359.dart'; | import 'package:moxxyv2/xmpp/xeps/xep_0359.dart'; | ||||||
|  | import 'package:moxxyv2/xmpp/xeps/xep_0446.dart'; | ||||||
| import 'package:moxxyv2/xmpp/xeps/xep_0447.dart'; | import 'package:moxxyv2/xmpp/xeps/xep_0447.dart'; | ||||||
| 
 | 
 | ||||||
| class MessageDetails { | class MessageDetails { | ||||||
| 
 | 
 | ||||||
|   const MessageDetails({ |   const MessageDetails({ | ||||||
|       required this.to, |     required this.to, | ||||||
|       required this.body, |     this.body, | ||||||
|       this.requestDeliveryReceipt = false, |     this.requestDeliveryReceipt = false, | ||||||
|       this.requestChatMarkers = true, |     this.requestChatMarkers = true, | ||||||
|       this.id, |     this.id, | ||||||
|       this.originId, |     this.originId, | ||||||
|       this.quoteBody, |     this.quoteBody, | ||||||
|       this.quoteId, |     this.quoteId, | ||||||
|       this.quoteFrom, |     this.quoteFrom, | ||||||
|       this.chatState, |     this.chatState, | ||||||
|       this.sfs, |     this.sfs, | ||||||
|  |     this.fun, | ||||||
|  |     this.funReplacement, | ||||||
|  |     this.funCancellation, | ||||||
|   }); |   }); | ||||||
|   final String to; |   final String to; | ||||||
|   final String body; |   final String? body; | ||||||
|   final bool requestDeliveryReceipt; |   final bool requestDeliveryReceipt; | ||||||
|   final bool requestChatMarkers; |   final bool requestChatMarkers; | ||||||
|   final String? id; |   final String? id; | ||||||
| @ -41,6 +46,9 @@ class MessageDetails { | |||||||
|   final String? quoteFrom; |   final String? quoteFrom; | ||||||
|   final ChatState? chatState; |   final ChatState? chatState; | ||||||
|   final StatelessFileSharingData? sfs; |   final StatelessFileSharingData? sfs; | ||||||
|  |   final FileMetadataData? fun; | ||||||
|  |   final String? funReplacement; | ||||||
|  |   final String? funCancellation; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| class MessageManager extends XmppManagerBase { | class MessageManager extends XmppManagerBase { | ||||||
| @ -63,10 +71,9 @@ class MessageManager extends XmppManagerBase { | |||||||
|   Future<bool> isSupported() async => true; |   Future<bool> isSupported() async => true; | ||||||
|    |    | ||||||
|   Future<StanzaHandlerData> _onMessage(Stanza _, StanzaHandlerData state) async { |   Future<StanzaHandlerData> _onMessage(Stanza _, StanzaHandlerData state) async { | ||||||
|     // First check if it's a carbon |  | ||||||
|     final message = state.stanza; |     final message = state.stanza; | ||||||
|     final body = message.firstTag('body'); |     final body = message.firstTag('body'); | ||||||
|      | 
 | ||||||
|     getAttributes().sendEvent(MessageEvent( |     getAttributes().sendEvent(MessageEvent( | ||||||
|       body: body != null ? body.innerText() : '', |       body: body != null ? body.innerText() : '', | ||||||
|       fromJid: JID.fromString(message.attributes['from']! as String), |       fromJid: JID.fromString(message.attributes['from']! as String), | ||||||
| @ -82,6 +89,9 @@ class MessageManager extends XmppManagerBase { | |||||||
|       sims: state.sims, |       sims: state.sims, | ||||||
|       reply: state.reply, |       reply: state.reply, | ||||||
|       chatState: state.chatState, |       chatState: state.chatState, | ||||||
|  |       fun: state.fun, | ||||||
|  |       funReplacement: state.funReplacement, | ||||||
|  |       funCancellation: state.funCancellation, | ||||||
|     ),); |     ),); | ||||||
| 
 | 
 | ||||||
|     return state.copyWith(done: true); |     return state.copyWith(done: true); | ||||||
| @ -158,7 +168,7 @@ class MessageManager extends XmppManagerBase { | |||||||
| 
 | 
 | ||||||
|     if (details.sfs != null) { |     if (details.sfs != null) { | ||||||
|       stanza |       stanza | ||||||
|         ..addChild(constructSFSElement(details.sfs!)) |         ..addChild(details.sfs!.toXML()) | ||||||
|         // SFS recommends OOB as a fallback |         // SFS recommends OOB as a fallback | ||||||
|         ..addChild(constructOOBNode(OOBData(url: details.sfs!.url)),); |         ..addChild(constructOOBNode(OOBData(url: details.sfs!.url)),); | ||||||
|     } |     } | ||||||
| @ -169,6 +179,30 @@ class MessageManager extends XmppManagerBase { | |||||||
|         XMLNode.xmlns(tag: chatStateToString(details.chatState!), xmlns: chatStateXmlns), |         XMLNode.xmlns(tag: chatStateToString(details.chatState!), xmlns: chatStateXmlns), | ||||||
|       ); |       ); | ||||||
|     } |     } | ||||||
|  | 
 | ||||||
|  |     if (details.fun != null) { | ||||||
|  |       stanza.addChild( | ||||||
|  |         XMLNode.xmlns( | ||||||
|  |           tag: 'file-upload', | ||||||
|  |           xmlns: fileUploadNotificationXmlns, | ||||||
|  |           children: [ | ||||||
|  |             details.fun!.toXML(), | ||||||
|  |           ], | ||||||
|  |         ), | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (details.funReplacement != null) { | ||||||
|  |       stanza.addChild( | ||||||
|  |         XMLNode.xmlns( | ||||||
|  |           tag: 'replaces', | ||||||
|  |           xmlns: fileUploadNotificationXmlns, | ||||||
|  |           attributes: <String, String>{ | ||||||
|  |             'id': details.funReplacement!, | ||||||
|  |           }, | ||||||
|  |         ), | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|      |      | ||||||
|     getAttributes().sendStanza(stanza, awaitable: false); |     getAttributes().sendStanza(stanza, awaitable: false); | ||||||
|   } |   } | ||||||
|  | |||||||
| @ -1,15 +1,15 @@ | |||||||
| import 'package:moxxyv2/xmpp/stringxml.dart'; | import 'package:moxxyv2/xmpp/stringxml.dart'; | ||||||
| 
 | 
 | ||||||
| /// NOTE: Specified by https://github.com/PapaTutuWawa/custom-xeps/blob/master/xep-xxxx-file-thumbnails.md | /// NOTE: Specified by https://codeberg.org/moxxy/custom-xeps/src/branch/master/xep-xxxx-extensible-file-thumbnails.md | ||||||
| 
 | 
 | ||||||
| const fileThumbnailsXmlns = 'proto:urn:xmpp:file-thumbnails:0'; | const fileThumbnailsXmlns = 'proto:urn:xmpp:eft:0'; | ||||||
| const blurhashThumbnailType = 'blurhash'; | const blurhashThumbnailType = '$fileThumbnailsXmlns:blurhash'; | ||||||
| 
 | 
 | ||||||
| abstract class Thumbnail {} | abstract class Thumbnail {} | ||||||
| 
 | 
 | ||||||
| class BlurhashThumbnail extends Thumbnail { | class BlurhashThumbnail extends Thumbnail { | ||||||
| 
 | 
 | ||||||
|   BlurhashThumbnail({ required this.hash }); |   BlurhashThumbnail(this.hash); | ||||||
|   final String hash; |   final String hash; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| @ -20,7 +20,7 @@ Thumbnail? parseFileThumbnailElement(XMLNode node) { | |||||||
|   switch (node.attributes['type']!) { |   switch (node.attributes['type']!) { | ||||||
|     case blurhashThumbnailType: { |     case blurhashThumbnailType: { | ||||||
|       final hash = node.firstTag('blurhash')!.innerText(); |       final hash = node.firstTag('blurhash')!.innerText(); | ||||||
|       return BlurhashThumbnail(hash: hash); |       return BlurhashThumbnail(hash); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
| @ -42,7 +42,7 @@ XMLNode constructFileThumbnailElement(Thumbnail thumbnail) { | |||||||
|   final node = _fromThumbnail(thumbnail)!; |   final node = _fromThumbnail(thumbnail)!; | ||||||
|   var type = ''; |   var type = ''; | ||||||
|   if (thumbnail is BlurhashThumbnail) { |   if (thumbnail is BlurhashThumbnail) { | ||||||
|     type = 'blurhash'; |     type = blurhashThumbnailType; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   return XMLNode.xmlns( |   return XMLNode.xmlns( | ||||||
| @ -1,39 +1,69 @@ | |||||||
|  | import 'package:moxxyv2/xmpp/managers/base.dart'; | ||||||
|  | import 'package:moxxyv2/xmpp/managers/data.dart'; | ||||||
|  | import 'package:moxxyv2/xmpp/managers/handlers.dart'; | ||||||
|  | import 'package:moxxyv2/xmpp/managers/namespaces.dart'; | ||||||
| import 'package:moxxyv2/xmpp/namespaces.dart'; | import 'package:moxxyv2/xmpp/namespaces.dart'; | ||||||
| import 'package:moxxyv2/xmpp/stringxml.dart'; | import 'package:moxxyv2/xmpp/stanza.dart'; | ||||||
| import 'package:moxxyv2/xmpp/xeps/staging/file_thumbnails.dart'; |  | ||||||
| import 'package:moxxyv2/xmpp/xeps/xep_0446.dart'; | import 'package:moxxyv2/xmpp/xeps/xep_0446.dart'; | ||||||
| 
 | 
 | ||||||
| /// NOTE: Specified by https://github.com/PapaTutuWawa/custom-xeps/blob/master/xep-xxxx-file-upload-notifications.md | /// NOTE: Specified by https://github.com/PapaTutuWawa/custom-xeps/blob/master/xep-xxxx-file-upload-notifications.md | ||||||
| 
 | 
 | ||||||
| const fileUploadNotificationXmlns = 'proto:urn:xmpp:fun:0'; | const fileUploadNotificationXmlns = 'proto:urn:xmpp:fun:0'; | ||||||
| 
 | 
 | ||||||
| class FileUploadNotificationData { | class FileUploadNotificationManager extends XmppManagerBase { | ||||||
|  |   FileUploadNotificationManager() : super(); | ||||||
| 
 | 
 | ||||||
|   const FileUploadNotificationData({ required this.metadata, required this.thumbnails }); |   @override | ||||||
|   final FileMetadataData metadata; |   String getId() => fileUploadNotificationManager; | ||||||
|   final List<Thumbnail> thumbnails; | 
 | ||||||
| } |   @override | ||||||
| 
 |   String getName() => 'FileUploadNotificationManager'; | ||||||
| XMLNode constructFileUploadNotification(FileMetadataData metadata, List<Thumbnail> thumbnails) { | 
 | ||||||
|   final thumbnailNodes = thumbnails.map(constructFileThumbnailElement).toList(); |   @override | ||||||
|   return XMLNode.xmlns( |   List<StanzaHandler> getIncomingStanzaHandlers() => [ | ||||||
|     tag: 'file-upload', |     StanzaHandler( | ||||||
|     xmlns: fileUploadNotificationXmlns, |       stanzaTag: 'message', | ||||||
|     children: [ |       tagName: 'file-upload', | ||||||
|       constructFileMetadataElement(metadata), |       tagXmlns: fileUploadNotificationXmlns, | ||||||
|       ...thumbnailNodes |       callback: _onFileUploadNotificationReceived, | ||||||
|     ], |     ), | ||||||
|   ); |     StanzaHandler( | ||||||
| } |       stanzaTag: 'message', | ||||||
| 
 |       tagName: 'replaces', | ||||||
| FileUploadNotificationData parseFileUploadNotification(XMLNode node) { |       tagXmlns: fileUploadNotificationXmlns, | ||||||
|   assert(node.attributes['xmlns'] == fileUploadNotificationXmlns, 'Invalid element xmlns'); |       callback: _onFileUploadNotificationReplacementReceived, | ||||||
|   assert(node.tag == 'file-upload', 'Invalid element name'); |     ), | ||||||
| 
 |     StanzaHandler( | ||||||
|   final thumbnails = node.findTags('thumbnail', xmlns: fileThumbnailsXmlns).map((t) => parseFileThumbnailElement(t)!).toList(); |       stanzaTag: 'message', | ||||||
|    |       tagName: 'cancelled', | ||||||
|   return FileUploadNotificationData( |       tagXmlns: fileUploadNotificationXmlns, | ||||||
|     metadata: parseFileMetadataElement(node.firstTag('file', xmlns: fileMetadataXmlns)!), |       callback: _onFileUploadNotificationCancellationReceived, | ||||||
|     thumbnails: thumbnails, |     ), | ||||||
|   ); |   ]; | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   Future<bool> isSupported() async => true; | ||||||
|  | 
 | ||||||
|  |   Future<StanzaHandlerData> _onFileUploadNotificationReceived(Stanza message, StanzaHandlerData state) async { | ||||||
|  |     final funElement = message.firstTag('file-upload', xmlns: fileUploadNotificationXmlns)!; | ||||||
|  |     return state.copyWith( | ||||||
|  |       fun: FileMetadataData.fromXML( | ||||||
|  |         funElement.firstTag('file', xmlns: fileMetadataXmlns)!, | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   Future<StanzaHandlerData> _onFileUploadNotificationReplacementReceived(Stanza message, StanzaHandlerData state) async { | ||||||
|  |     final element = message.firstTag('replaces', xmlns: fileUploadNotificationXmlns)!; | ||||||
|  |     return state.copyWith( | ||||||
|  |       funReplacement: element.attributes['id']! as String, | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   Future<StanzaHandlerData> _onFileUploadNotificationCancellationReceived(Stanza message, StanzaHandlerData state) async { | ||||||
|  |     final element = message.firstTag('cancels', xmlns: fileUploadNotificationXmlns)!; | ||||||
|  |     return state.copyWith( | ||||||
|  |       funCancellation: element.attributes['id']! as String, | ||||||
|  |     ); | ||||||
|  |   } | ||||||
| } | } | ||||||
|  | |||||||
| @ -5,7 +5,7 @@ import 'package:moxxyv2/xmpp/managers/namespaces.dart'; | |||||||
| import 'package:moxxyv2/xmpp/namespaces.dart'; | import 'package:moxxyv2/xmpp/namespaces.dart'; | ||||||
| import 'package:moxxyv2/xmpp/stanza.dart'; | import 'package:moxxyv2/xmpp/stanza.dart'; | ||||||
| import 'package:moxxyv2/xmpp/stringxml.dart'; | import 'package:moxxyv2/xmpp/stringxml.dart'; | ||||||
| import 'package:moxxyv2/xmpp/xeps/staging/file_thumbnails.dart'; | import 'package:moxxyv2/xmpp/xeps/staging/extensible_file_thumbnails.dart'; | ||||||
| 
 | 
 | ||||||
| class StatelessMediaSharingData { | class StatelessMediaSharingData { | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -1,6 +1,6 @@ | |||||||
| import 'package:moxxyv2/xmpp/namespaces.dart'; | import 'package:moxxyv2/xmpp/namespaces.dart'; | ||||||
| import 'package:moxxyv2/xmpp/stringxml.dart'; | import 'package:moxxyv2/xmpp/stringxml.dart'; | ||||||
| import 'package:moxxyv2/xmpp/xeps/staging/file_thumbnails.dart'; | import 'package:moxxyv2/xmpp/xeps/staging/extensible_file_thumbnails.dart'; | ||||||
| import 'package:moxxyv2/xmpp/xeps/xep_0300.dart'; | import 'package:moxxyv2/xmpp/xeps/xep_0300.dart'; | ||||||
| 
 | 
 | ||||||
| class FileMetadataData { | class FileMetadataData { | ||||||
| @ -15,6 +15,43 @@ class FileMetadataData { | |||||||
|       required this.thumbnails, |       required this.thumbnails, | ||||||
|       Map<String, String>? hashes, |       Map<String, String>? hashes, | ||||||
|   }) : hashes = hashes ?? const {}; |   }) : hashes = hashes ?? const {}; | ||||||
|  | 
 | ||||||
|  |   /// Parse [node] as a FileMetadataData element. | ||||||
|  |   factory FileMetadataData.fromXML(XMLNode node) { | ||||||
|  |     assert(node.attributes['xmlns'] == fileMetadataXmlns, 'Invalid element xmlns'); | ||||||
|  |     assert(node.tag == 'file', 'Invalid element anme'); | ||||||
|  | 
 | ||||||
|  |     final lengthElement = node.firstTag('length'); | ||||||
|  |     final length = lengthElement != null ? int.parse(lengthElement.innerText()) : null; | ||||||
|  |     final sizeElement = node.firstTag('size'); | ||||||
|  |     final size = sizeElement != null ? int.parse(sizeElement.innerText()) : null; | ||||||
|  | 
 | ||||||
|  |     final hashes = <String, String>{}; | ||||||
|  |     for (final e in node.findTags('hash')) { | ||||||
|  |       hashes[e.attributes['algo']! as String] = e.innerText(); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // Thumbnails | ||||||
|  |     final thumbnails = List<Thumbnail>.empty(growable: true); | ||||||
|  |     for (final i in node.findTags('file-thumbnail')) { | ||||||
|  |       final thumbnail = parseFileThumbnailElement(i); | ||||||
|  |       if (thumbnail != null) { | ||||||
|  |         thumbnails.add(thumbnail); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     return FileMetadataData( | ||||||
|  |       mediaType: node.firstTag('media-type')?.innerText(), | ||||||
|  |       dimensions: node.firstTag('dimensions')?.innerText(), | ||||||
|  |       desc: node.firstTag('desc')?.innerText(), | ||||||
|  |       hashes: hashes, | ||||||
|  |       length: length, | ||||||
|  |       name: node.firstTag('name')?.innerText(), | ||||||
|  |       size: size, | ||||||
|  |       thumbnails: thumbnails, | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   final String? mediaType; |   final String? mediaType; | ||||||
| 
 | 
 | ||||||
|   // TODO(Unknown): Maybe create a special type for this |   // TODO(Unknown): Maybe create a special type for this | ||||||
| @ -26,70 +63,33 @@ class FileMetadataData { | |||||||
|   final int? length; |   final int? length; | ||||||
|   final String? name; |   final String? name; | ||||||
|   final int? size; |   final int? size; | ||||||
| } |  | ||||||
| 
 | 
 | ||||||
| FileMetadataData parseFileMetadataElement(XMLNode node) { |   XMLNode toXML() { | ||||||
|   assert(node.attributes['xmlns'] == fileMetadataXmlns, 'Invalid element xmlns'); |     final node = XMLNode.xmlns( | ||||||
|   assert(node.tag == 'file', 'Invalid element anme'); |       tag: 'file', | ||||||
|  |       xmlns: fileMetadataXmlns, | ||||||
|  |       children: List.empty(growable: true), | ||||||
|  |     ); | ||||||
| 
 | 
 | ||||||
|   final lengthElement = node.firstTag('length'); |     if (mediaType != null) node.addChild(XMLNode(tag: 'media-type', text: mediaType)); | ||||||
|   final length = lengthElement != null ? int.parse(lengthElement.innerText()) : null; |     if (dimensions != null) node.addChild(XMLNode(tag: 'dimensions', text: dimensions)); | ||||||
|   final sizeElement = node.firstTag('size'); |     if (desc != null) node.addChild(XMLNode(tag: 'desc', text: desc)); | ||||||
|   final size = sizeElement != null ? int.parse(sizeElement.innerText()) : null; |     if (length != null) node.addChild(XMLNode(tag: 'length', text: length.toString())); | ||||||
|  |     if (name != null) node.addChild(XMLNode(tag: 'name', text: name)); | ||||||
|  |     if (size != null) node.addChild(XMLNode(tag: 'size', text: size.toString())); | ||||||
| 
 | 
 | ||||||
|   final hashes = <String, String>{}; |     for (final hash in hashes.entries) { | ||||||
|   for (final e in node.findTags('hash')) { |  | ||||||
|     hashes[e.attributes['algo']! as String] = e.innerText(); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   // Thumbnails |  | ||||||
|   final thumbnails = List<Thumbnail>.empty(growable: true); |  | ||||||
|   for (final i in node.findTags('file-thumbnail')) { |  | ||||||
|     final thumbnail = parseFileThumbnailElement(i); |  | ||||||
|     if (thumbnail != null) { |  | ||||||
|       thumbnails.add(thumbnail); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|    |  | ||||||
|   return FileMetadataData( |  | ||||||
|     mediaType: node.firstTag('media-type')?.innerText(), |  | ||||||
|     dimensions: node.firstTag('dimensions')?.innerText(), |  | ||||||
|     desc: node.firstTag('desc')?.innerText(), |  | ||||||
|     hashes: hashes, |  | ||||||
|     length: length, |  | ||||||
|     name: node.firstTag('name')?.innerText(), |  | ||||||
|     size: size, |  | ||||||
|     thumbnails: thumbnails, |  | ||||||
|   ); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| XMLNode constructFileMetadataElement(FileMetadataData data) { |  | ||||||
|   final node = XMLNode.xmlns( |  | ||||||
|     tag: 'file', |  | ||||||
|     xmlns: fileMetadataXmlns, |  | ||||||
|     children: List.empty(growable: true), |  | ||||||
|   ); |  | ||||||
| 
 |  | ||||||
|   if (data.mediaType != null) node.addChild(XMLNode(tag: 'media-type', text: data.mediaType)); |  | ||||||
|   if (data.dimensions != null) node.addChild(XMLNode(tag: 'dimensions', text: data.dimensions)); |  | ||||||
|   if (data.desc != null) node.addChild(XMLNode(tag: 'desc', text: data.desc)); |  | ||||||
|   if (data.length != null) node.addChild(XMLNode(tag: 'length', text: data.length.toString())); |  | ||||||
|   if (data.name != null) node.addChild(XMLNode(tag: 'name', text: data.name)); |  | ||||||
|   if (data.size != null) node.addChild(XMLNode(tag: 'size', text: data.size.toString())); |  | ||||||
|   if (data.hashes.isNotEmpty) { |  | ||||||
|     for (final hash in data.hashes.entries) { |  | ||||||
|       node.addChild( |       node.addChild( | ||||||
|         constructHashElement(hash.key, hash.value), |         constructHashElement(hash.key, hash.value), | ||||||
|       ); |       ); | ||||||
|     } |     } | ||||||
|   } | 
 | ||||||
|   if (data.thumbnails.isNotEmpty) { |     for (final thumbnail in thumbnails) { | ||||||
|     for (final thumbnail in data.thumbnails) { |  | ||||||
|       node.addChild( |       node.addChild( | ||||||
|         constructFileThumbnailElement(thumbnail), |         constructFileThumbnailElement(thumbnail), | ||||||
|       ); |       ); | ||||||
|     } |     } | ||||||
|  |      | ||||||
|  |     return node; | ||||||
|   } |   } | ||||||
|    |  | ||||||
|   return node; |  | ||||||
| } | } | ||||||
|  | |||||||
| @ -9,46 +9,47 @@ import 'package:moxxyv2/xmpp/xeps/xep_0446.dart'; | |||||||
| 
 | 
 | ||||||
| class StatelessFileSharingData { | class StatelessFileSharingData { | ||||||
| 
 | 
 | ||||||
|   const StatelessFileSharingData({ required this.metadata, required this.url }); |   const StatelessFileSharingData(this.metadata, this.url); | ||||||
|  | 
 | ||||||
|  |   /// Parse [node] as a StatelessFileSharingData element. | ||||||
|  |   factory StatelessFileSharingData.fromXML(XMLNode node) { | ||||||
|  |     assert(node.attributes['xmlns'] == sfsXmlns, 'Invalid element xmlns'); | ||||||
|  |     assert(node.tag == 'file-sharing', 'Invalid element name'); | ||||||
|  | 
 | ||||||
|  |     final sources = node.firstTag('sources')!; | ||||||
|  |     final urldata = sources.firstTag('url-data', xmlns: urlDataXmlns); | ||||||
|  |     final url = urldata!.attributes['target']! as String; | ||||||
|  | 
 | ||||||
|  |     return StatelessFileSharingData( | ||||||
|  |       FileMetadataData.fromXML(node.firstTag('file')!), | ||||||
|  |       url, | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   final FileMetadataData metadata; |   final FileMetadataData metadata; | ||||||
|   final String url; |   final String url; | ||||||
| } |  | ||||||
| 
 | 
 | ||||||
| StatelessFileSharingData parseSFSElement(XMLNode node) { |   XMLNode toXML() { | ||||||
|   assert(node.attributes['xmlns'] == sfsXmlns, 'Invalid element xmlns'); |     return XMLNode.xmlns( | ||||||
|   assert(node.tag == 'file-sharing', 'Invalid element name'); |       tag: 'file-sharing', | ||||||
| 
 |       xmlns: sfsXmlns, | ||||||
|   final metadata = parseFileMetadataElement(node.firstTag('file')!); |       children: [ | ||||||
|   final sources = node.firstTag('sources')!; |         metadata.toXML(), | ||||||
|   final urldata = sources.firstTag('url-data', xmlns: urlDataXmlns); |         XMLNode( | ||||||
|   final url = urldata!.attributes['target']! as String; |           tag: 'sources', | ||||||
| 
 |           children: [ | ||||||
|   return StatelessFileSharingData( |             XMLNode.xmlns( | ||||||
|     metadata: metadata, |               tag: 'url-data', | ||||||
|     url: url, |               xmlns: urlDataXmlns, | ||||||
|   ); |               attributes: <String, String>{ | ||||||
| } |                 'target': url, | ||||||
| 
 |               }, | ||||||
| XMLNode constructSFSElement(StatelessFileSharingData data) { |             ), | ||||||
|   return XMLNode.xmlns( |           ], | ||||||
|     tag: 'file-sharing', |         ), | ||||||
|     xmlns: sfsXmlns, |       ], | ||||||
|     children: [ |     ); | ||||||
|       constructFileMetadataElement(data.metadata), |   } | ||||||
|       XMLNode( |  | ||||||
|         tag: 'sources', |  | ||||||
|         children: [ |  | ||||||
|           XMLNode.xmlns( |  | ||||||
|             tag: 'url-data', |  | ||||||
|             xmlns: urlDataXmlns, |  | ||||||
|             attributes: <String, String>{ |  | ||||||
|               'target': data.url, |  | ||||||
|             }, |  | ||||||
|           ), |  | ||||||
|         ], |  | ||||||
|       ), |  | ||||||
|     ], |  | ||||||
|   ); |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| class SFSManager extends XmppManagerBase { | class SFSManager extends XmppManagerBase { | ||||||
| @ -77,7 +78,7 @@ class SFSManager extends XmppManagerBase { | |||||||
|     final sfs = message.firstTag('file-sharing', xmlns: sfsXmlns)!; |     final sfs = message.firstTag('file-sharing', xmlns: sfsXmlns)!; | ||||||
| 
 | 
 | ||||||
|     return state.copyWith( |     return state.copyWith( | ||||||
|       sfs: parseSFSElement(sfs), |       sfs: StatelessFileSharingData.fromXML(sfs), | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  | |||||||
							
								
								
									
										16
									
								
								moxxy.doap
									
									
									
									
									
								
							
							
						
						
									
										16
									
								
								moxxy.doap
									
									
									
									
									
								
							| @ -184,18 +184,18 @@ | |||||||
|     </implements> |     </implements> | ||||||
|     <implements> |     <implements> | ||||||
|       <xmpp:SupportedXep> |       <xmpp:SupportedXep> | ||||||
| 	<xmpp:xep rdf:resource="https://github.com/PapaTutuWawa/custom-xeps/blob/master/xep-xxxx-file-thumbnails.md" /> | 	<xmpp:xep rdf:resource="https://codeberg.org/moxxy/custom-xeps/src/branch/master/xep-xxxx-extensible-file-thumbnails.md" /> | ||||||
| 	<xmpp:status>complete</xmpp:status> | 	<xmpp:status>partial</xmpp:status> | ||||||
| 	<xmpp:version>0.0.3</xmpp:version> | 	<xmpp:version>0.2.1</xmpp:version> | ||||||
| 	<xmpp:note xml:lang="en">Read-only implementation</xmpp:note> | 	<xmpp:note xml:lang="en">Only Blurhash is implemented</xmpp:note> | ||||||
|       </xmpp:SupportedXep> |       </xmpp:SupportedXep> | ||||||
|     </implements> |     </implements> | ||||||
|     <implements> |     <implements> | ||||||
|       <xmpp:SupportedXep> |       <xmpp:SupportedXep> | ||||||
| 	<xmpp:xep rdf:resource="https://github.com/PapaTutuWawa/custom-xeps/blob/master/xep-xxxx-file-upload-notification.md" /> | 	<xmpp:xep rdf:resource="https://codeberg.org/moxxy/custom-xeps/src/branch/master/xep-xxxx-file-upload-notification.md" /> | ||||||
| 	<xmpp:status>complete</xmpp:status> | 	<xmpp:status>partial</xmpp:status> | ||||||
| 	<xmpp:version>0.0.4</xmpp:version> | 	<xmpp:version>0.0.5</xmpp:version> | ||||||
| 	<xmpp:note xml:lang="en">Not-connected read-only implementation</xmpp:note> | 	<xmpp:note xml:lang="en">Sending and receiving implemented; cancellation not implemented</xmpp:note> | ||||||
|       </xmpp:SupportedXep> |       </xmpp:SupportedXep> | ||||||
|     </implements> |     </implements> | ||||||
|   </Project> |   </Project> | ||||||
|  | |||||||
| @ -50,6 +50,13 @@ packages: | |||||||
|       url: "https://pub.dartlang.org" |       url: "https://pub.dartlang.org" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "8.1.0" |     version: "8.1.0" | ||||||
|  |   blurhash_dart: | ||||||
|  |     dependency: "direct main" | ||||||
|  |     description: | ||||||
|  |       name: blurhash_dart | ||||||
|  |       url: "https://pub.dartlang.org" | ||||||
|  |     source: hosted | ||||||
|  |     version: "1.1.0" | ||||||
|   boolean_selector: |   boolean_selector: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|  | |||||||
| @ -13,6 +13,7 @@ dependencies: | |||||||
|   archive: 3.3.1 |   archive: 3.3.1 | ||||||
|   badges: 2.0.3 |   badges: 2.0.3 | ||||||
|   bloc: 8.1.0 |   bloc: 8.1.0 | ||||||
|  |   blurhash_dart: 1.1.0 | ||||||
|   connectivity_plus: 2.3.6 |   connectivity_plus: 2.3.6 | ||||||
|   crop_your_image: 0.7.2 |   crop_your_image: 0.7.2 | ||||||
|   cryptography: 2.0.5 |   cryptography: 2.0.5 | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user