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) { | ||||
|   return Message( | ||||
|     m.from, | ||||
|     m.sender, | ||||
|     m.body, | ||||
|     m.timestamp, | ||||
|     m.sent, | ||||
|     m.sid, | ||||
|     m.id!, | ||||
|     m.conversationJid, | ||||
|     m.isMedia, | ||||
|     m.isFileUploadNotification, | ||||
|     originId: m.originId, | ||||
|     received: m.received, | ||||
|     displayed: m.displayed, | ||||
| @ -82,6 +82,7 @@ Message messageDbToModel(DBMessage m) { | ||||
|     srcUrl: m.srcUrl, | ||||
|     quotes: m.quotes.value != null ? messageDbToModel(m.quotes.value!) : null, | ||||
|     errorType: m.errorType, | ||||
|     filename: m.filename, | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| @ -233,11 +234,11 @@ class DatabaseService { | ||||
|   Future<Message> addMessageFromData( | ||||
|     String body, | ||||
|     int timestamp, | ||||
|     String from, | ||||
|     String sender, | ||||
|     String conversationJid, | ||||
|     bool sent, | ||||
|     bool isMedia, | ||||
|     String sid, | ||||
|     bool isFileUploadNotification, | ||||
|     { | ||||
|       String? srcUrl, | ||||
|       String? mediaUrl, | ||||
| @ -246,14 +247,14 @@ class DatabaseService { | ||||
|       String? thumbnailDimensions, | ||||
|       String? originId, | ||||
|       String? quoteId, | ||||
|       String? filename, | ||||
|     } | ||||
|   ) async { | ||||
|     final m = DBMessage() | ||||
|       ..from = from | ||||
|       ..conversationJid = conversationJid | ||||
|       ..timestamp = timestamp | ||||
|       ..body = body | ||||
|       ..sent = sent | ||||
|       ..sender = sender | ||||
|       ..isMedia = isMedia | ||||
|       ..mediaType = mediaType | ||||
|       ..mediaUrl = mediaUrl | ||||
| @ -265,7 +266,9 @@ class DatabaseService { | ||||
|       ..displayed = false | ||||
|       ..acked = false | ||||
|       ..originId = originId | ||||
|       ..errorType = noError; | ||||
|       ..errorType = noError | ||||
|       ..isFileUploadNotification = isFileUploadNotification | ||||
|       ..filename = filename; | ||||
| 
 | ||||
|     if (quoteId != null) { | ||||
|       final quotes = await getMessageByXmppId(quoteId, conversationJid); | ||||
| @ -303,6 +306,8 @@ class DatabaseService { | ||||
|     bool? displayed, | ||||
|     bool? acked, | ||||
|     int? errorType, | ||||
|     bool? isFileUploadNotification, | ||||
|     String? srcUrl, | ||||
|   }) async { | ||||
|     final i = (await _isar.dBMessages.get(id))!; | ||||
|     if (mediaUrl != null) { | ||||
| @ -323,6 +328,12 @@ class DatabaseService { | ||||
|     if (errorType != null) { | ||||
|       i.errorType = errorType; | ||||
|     } | ||||
|     if (isFileUploadNotification != null) { | ||||
|       i.isFileUploadNotification = isFileUploadNotification; | ||||
|     } | ||||
|     if (srcUrl != null) { | ||||
|       i.srcUrl = srcUrl; | ||||
|     } | ||||
| 
 | ||||
|     await _isar.writeTxn(() async { | ||||
|       await _isar.dBMessages.put(i); | ||||
|  | ||||
| @ -7,9 +7,6 @@ part 'message.g.dart'; | ||||
| class DBMessage { | ||||
|   int? id; | ||||
| 
 | ||||
|   @Index(caseSensitive: false) | ||||
|   late String from; | ||||
| 
 | ||||
|   @Index(caseSensitive: false) | ||||
|   late String conversationJid; | ||||
| 
 | ||||
| @ -17,9 +14,9 @@ class DBMessage { | ||||
| 
 | ||||
|   late String body; | ||||
| 
 | ||||
|   // TODO(Unknown): Replace by just checking if sender == us | ||||
|   /// Indicate if the message was sent by the user (true) or received by the user (false) | ||||
|   late bool sent; | ||||
|   /// The full JID of the sender | ||||
|   @Index(caseSensitive: false) | ||||
|   late String sender; | ||||
| 
 | ||||
|   late String sid; | ||||
|   String? originId; | ||||
| @ -33,6 +30,10 @@ class DBMessage { | ||||
|   /// that clearly identifies the error. | ||||
|   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 | ||||
|   final quotes = IsarLink<DBMessage>(); | ||||
|    | ||||
| @ -49,4 +50,7 @@ class DBMessage { | ||||
|   String? thumbnailData; | ||||
|   /// The dimensions of the thumbnail | ||||
|   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, | ||||
|             originId: job.message.originId, | ||||
|             sfs: StatelessFileSharingData( | ||||
|               url: slot.getUrl, | ||||
|               metadata: FileMetadataData( | ||||
|               FileMetadataData( | ||||
|                 mediaType: fileMime, | ||||
|                 size: stat.size, | ||||
|                 name: pathlib.basename(job.path), | ||||
|                 // TODO(Unknown): Add a thumbnail | ||||
|                 thumbnails: [], | ||||
|                 thumbnails: job.thumbnails, | ||||
|               ), | ||||
|               slot.getUrl, | ||||
|             ), | ||||
|           ), | ||||
|         ); | ||||
| @ -295,11 +294,12 @@ class HttpFileTransferService { | ||||
|           job.mId, | ||||
|           mediaUrl: downloadedPath, | ||||
|           mediaType: mime, | ||||
|           isFileUploadNotification: 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'); | ||||
|           await notification.showNotification(msg, ''); | ||||
|         } | ||||
|  | ||||
| @ -1,15 +1,17 @@ | ||||
| import 'package:meta/meta.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. | ||||
| @immutable | ||||
| 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 recipient; | ||||
|   final Message message; | ||||
|   final String copyToPath; | ||||
|   final List<Thumbnail> thumbnails; | ||||
| 
 | ||||
|   @override | ||||
|   bool operator ==(Object other) { | ||||
| @ -17,22 +19,24 @@ class FileUploadJob { | ||||
|       recipient == other.recipient && | ||||
|       path == other.path && | ||||
|       message == other.message && | ||||
|       copyToPath == other.copyToPath; | ||||
|       copyToPath == other.copyToPath && | ||||
|       thumbnails == other.thumbnails; | ||||
|   } | ||||
| 
 | ||||
|   @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. | ||||
| @immutable | ||||
| 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 int mId; | ||||
|   final String conversationJid; | ||||
|   final String? mimeGuess; | ||||
|   final bool shouldShowNotification; | ||||
|    | ||||
|   @override | ||||
|   bool operator ==(Object other) { | ||||
| @ -40,8 +44,9 @@ class FileDownloadJob { | ||||
|       url == other.url && | ||||
|       mId == other.mId && | ||||
|       conversationJid == other.conversationJid && | ||||
|       mimeGuess == other.mimeGuess; | ||||
|       mimeGuess == other.mimeGuess && | ||||
|       shouldShowNotification == other.shouldShowNotification; | ||||
|   } | ||||
|   @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:logging/logging.dart'; | ||||
| import 'package:moxxyv2/service/database.dart'; | ||||
| import 'package:moxxyv2/shared/helpers.dart'; | ||||
| import 'package:moxxyv2/shared/models/message.dart'; | ||||
| 
 | ||||
| class MessageService { | ||||
| @ -30,11 +31,11 @@ class MessageService { | ||||
|   Future<Message> addMessageFromData( | ||||
|     String body, | ||||
|     int timestamp, | ||||
|     String from, | ||||
|     String sender, | ||||
|     String conversationJid, | ||||
|     bool sent, | ||||
|     bool isMedia, | ||||
|     String sid, | ||||
|     bool isFileUploadNotification, | ||||
|     { | ||||
|       String? srcUrl, | ||||
|       String? mediaUrl, | ||||
| @ -43,16 +44,17 @@ class MessageService { | ||||
|       String? thumbnailDimensions, | ||||
|       String? originId, | ||||
|       String? quoteId, | ||||
|       String? filename, | ||||
|     } | ||||
|   ) async { | ||||
|     final msg = await GetIt.I.get<DatabaseService>().addMessageFromData( | ||||
|       body, | ||||
|       timestamp, | ||||
|       from, | ||||
|       sender, | ||||
|       conversationJid, | ||||
|       sent, | ||||
|       isMedia, | ||||
|       sid, | ||||
|       isFileUploadNotification, | ||||
|       srcUrl: srcUrl, | ||||
|       mediaUrl: mediaUrl, | ||||
|       mediaType: mediaType, | ||||
| @ -60,6 +62,7 @@ class MessageService { | ||||
|       thumbnailDimensions: thumbnailDimensions, | ||||
|       originId: originId, | ||||
|       quoteId: quoteId, | ||||
|       filename: filename, | ||||
|     ); | ||||
| 
 | ||||
|     // Only update the cache if the conversation already has been loaded. This prevents | ||||
| @ -71,14 +74,27 @@ class MessageService { | ||||
|     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 | ||||
|   Future<Message> updateMessage(int id, { | ||||
|       String? mediaUrl, | ||||
|       String? mediaType, | ||||
|       bool? received, | ||||
|       bool? displayed, | ||||
|       bool? acked, | ||||
|       int? errorType, | ||||
|     String? mediaUrl, | ||||
|     String? mediaType, | ||||
|     bool? received, | ||||
|     bool? displayed, | ||||
|     bool? acked, | ||||
|     int? errorType, | ||||
|     bool? isFileUploadNotification, | ||||
|     String? srcUrl, | ||||
|   }) async { | ||||
|     final newMessage = await GetIt.I.get<DatabaseService>().updateMessage( | ||||
|       id, | ||||
| @ -88,6 +104,8 @@ class MessageService { | ||||
|       displayed: displayed, | ||||
|       acked: acked, | ||||
|       errorType: errorType, | ||||
|       isFileUploadNotification: isFileUploadNotification, | ||||
|       srcUrl: srcUrl, | ||||
|     ); | ||||
| 
 | ||||
|     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/presence.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_0060.dart'; | ||||
| import 'package:moxxyv2/xmpp/xeps/xep_0066.dart'; | ||||
| @ -201,6 +202,7 @@ Future<void> entrypoint() async { | ||||
|       BlockingManager(), | ||||
|       ChatStateManager(), | ||||
|       HttpFileUploadManager(), | ||||
|       FileUploadNotificationManager(), | ||||
|     ]) | ||||
|     ..registerFeatureNegotiators([ | ||||
|       ResourceBindingNegotiator(), | ||||
|  | ||||
| @ -1,8 +1,11 @@ | ||||
| import 'dart:async'; | ||||
| import 'dart:convert'; | ||||
| import 'dart:io'; | ||||
| import 'package:blurhash_dart/blurhash_dart.dart'; | ||||
| import 'package:connectivity_plus/connectivity_plus.dart'; | ||||
| import 'package:flutter_secure_storage/flutter_secure_storage.dart'; | ||||
| import 'package:get_it/get_it.dart'; | ||||
| import 'package:image/image.dart'; | ||||
| import 'package:logging/logging.dart'; | ||||
| import 'package:mime/mime.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/settings.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_0184.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:permission_handler/permission_handler.dart'; | ||||
| 
 | ||||
| @ -204,9 +208,9 @@ class XmppService { | ||||
|       timestamp, | ||||
|       conn.getConnectionSettings().jid.toString(), | ||||
|       jid, | ||||
|       true, | ||||
|       false, | ||||
|       sid, | ||||
|       false, | ||||
|       originId: originId, | ||||
|       quoteId: quotedMessage?.originId ?? quotedMessage?.sid, | ||||
|     ); | ||||
| @ -226,7 +230,7 @@ class XmppService { | ||||
|         id: sid, | ||||
|         originId: originId, | ||||
|         quoteBody: quotedMessage?.body, | ||||
|         quoteFrom: quotedMessage?.from, | ||||
|         quoteFrom: quotedMessage?.sender, | ||||
|         quoteId: quotedMessage?.originId ?? quotedMessage?.sid, | ||||
|         chatState: chatState, | ||||
|       ), | ||||
| @ -334,24 +338,52 @@ class XmppService { | ||||
|      | ||||
|     // Path -> Message | ||||
|     final messages = <String, Message>{}; | ||||
|     // Path -> Thumbnails | ||||
|     final thumbnails = <String, List<Thumbnail>>{}; | ||||
| 
 | ||||
|     // Create the messages and shared media entries | ||||
|     final conn = GetIt.I.get<XmppConnection>(); | ||||
|     for (final path in paths) { | ||||
|       final pathMime = lookupMimeType(path); | ||||
|       final msg = await ms.addMessageFromData( | ||||
|         '', | ||||
|         DateTime.now().millisecondsSinceEpoch,  | ||||
|         conn.getConnectionSettings().jid.toString(), | ||||
|         recipient, | ||||
|         true, | ||||
|         true, | ||||
|         conn.generateId(), | ||||
|         false, | ||||
|         mediaUrl: path, | ||||
|         mediaType: lookupMimeType(path), | ||||
|         mediaType: pathMime, | ||||
|         originId: conn.generateId(), | ||||
|       ); | ||||
|       messages[path] = msg; | ||||
|       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 | ||||
| @ -387,6 +419,7 @@ class XmppService { | ||||
|           path, | ||||
|           await getDownloadPath(pathlib.basename(path), recipient, pathMime), | ||||
|           messages[path]!, | ||||
|           thumbnails[path] ?? [], | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
| @ -568,14 +601,15 @@ class XmppService { | ||||
| 
 | ||||
|   /// Return true if [event] describes a message that we want to display. | ||||
|   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. | ||||
|   String? _getThumbnailData(MessageEvent event) { | ||||
|     final thumbnails = firstNotNull([ | ||||
|         event.sfs?.metadata.thumbnails, | ||||
|         event.sims?.thumbnails | ||||
|         event.sims?.thumbnails, | ||||
|         event.fun?.thumbnails, | ||||
|     ]) ?? []; | ||||
|     for (final i in thumbnails) { | ||||
|       if (i is BlurhashThumbnail) { | ||||
| @ -585,6 +619,35 @@ class XmppService { | ||||
| 
 | ||||
|     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 { | ||||
|     // 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 | ||||
|     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 | ||||
|     if (!_isMessageEventMessage(event)) return; | ||||
| 
 | ||||
| @ -631,13 +700,9 @@ class XmppService { | ||||
|     final embeddedFileUrl = _getMessageSrcUrl(event); | ||||
|     // 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. | ||||
|     final isFileEmbedded = embeddedFileUrl != null | ||||
|       && Uri.parse(embeddedFileUrl).scheme == 'https' | ||||
|       && implies(event.oob != null, event.body == event.oob?.url); | ||||
|     final isFileEmbedded = _isFileEmbedded(event, embeddedFileUrl); | ||||
|     // Indicates if we should auto-download the file, if a file is specified in the message | ||||
|     final shouldDownload = (await Permission.storage.status).isGranted | ||||
|       && await _automaticFileDownloadAllowed() | ||||
|       && isInRoster; | ||||
|     final shouldDownload = await _shouldDownloadFile(conversationJid); | ||||
|     // The thumbnail for the embedded file. | ||||
|     final thumbnailData = _getThumbnailData(event); | ||||
|     // 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. | ||||
|     var shouldNotify = !(isFileEmbedded && isInRoster && shouldDownload); | ||||
|     // A guess for the Mime type of the embedded file. | ||||
|     String? mimeGuess; | ||||
|     var mimeGuess = _getMimeGuess(event); | ||||
| 
 | ||||
|     // Create the message in the database | ||||
|     final ms = GetIt.I.get<MessageService>(); | ||||
| @ -655,21 +720,22 @@ class XmppService { | ||||
|       messageTimestamp, | ||||
|       event.fromJid.toString(), | ||||
|       conversationJid, | ||||
|       sent, | ||||
|       isFileEmbedded, | ||||
|       isFileEmbedded || event.fun != null, | ||||
|       event.sid, | ||||
|       event.fun != null, | ||||
|       srcUrl: embeddedFileUrl, | ||||
|       mediaType: mimeGuess, | ||||
|       thumbnailData: thumbnailData, | ||||
|       // TODO(Unknown): What about SIMS? | ||||
|       thumbnailDimensions: event.sfs?.metadata.dimensions, | ||||
|       thumbnailDimensions: event.sfs?.metadata.dimensions ?? event.fun?.dimensions, | ||||
|       quoteId: replyId, | ||||
|       filename: event.fun?.name, | ||||
|     ); | ||||
|      | ||||
|     // Attempt to auto-download the embedded file | ||||
|     if (isFileEmbedded && shouldDownload) { | ||||
|       final fts = GetIt.I.get<HttpFileTransferService>(); | ||||
|       final metadata = await peekFile(embeddedFileUrl); | ||||
|       final metadata = await peekFile(embeddedFileUrl!); | ||||
| 
 | ||||
|       if (metadata.mime != null) mimeGuess = metadata.mime; | ||||
| 
 | ||||
| @ -695,7 +761,7 @@ class XmppService { | ||||
|     final cs = GetIt.I.get<ConversationService>(); | ||||
|     final ns = GetIt.I.get<NotificationsService>(); | ||||
|     // 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 | ||||
|     final isConversationOpened = _currentlyOpenedChatJid == conversationJid; | ||||
|     // The conversation we're about to modify, if it exists | ||||
| @ -754,7 +820,67 @@ class XmppService { | ||||
|     } | ||||
| 
 | ||||
|     // 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 { | ||||
|  | ||||
| @ -1,4 +1,5 @@ | ||||
| import 'dart:core'; | ||||
| import 'package:moxxyv2/shared/models/message.dart'; | ||||
| import 'package:moxxyv2/xmpp/xeps/xep_0085.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 | ||||
|    | ||||
|   factory Message( | ||||
|     String from, | ||||
|     String sender, | ||||
|     String body, | ||||
|     int timestamp, | ||||
|     bool sent, | ||||
|     String sid, | ||||
|     int id, | ||||
|     String conversationJid, | ||||
|     bool isMedia, | ||||
|     bool isFileUploadNotification, | ||||
|     { | ||||
|       int? errorType, | ||||
|       String? mediaUrl, | ||||
| @ -32,6 +32,7 @@ class Message with _$Message { | ||||
|       @Default(false) bool acked, | ||||
|       String? originId, | ||||
|       Message? quotes, | ||||
|       String? filename, | ||||
|     } | ||||
|   ) = _Message; | ||||
| 
 | ||||
|  | ||||
| @ -45,6 +45,7 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> { | ||||
|     on<FilePickerRequestedEvent>(_onFilePickerRequested); | ||||
|     on<ScrollStateSetEvent>(_onScrollStateSet); | ||||
|     on<EmojiPickerToggledEvent>(_onEmojiPickerToggled); | ||||
|     on<OwnJidReceivedEvent>(_onOwnJidReceived); | ||||
|   } | ||||
|   /// The current chat state with the conversation partner | ||||
|   ChatState _currentChatState; | ||||
| @ -113,6 +114,7 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> { | ||||
|     emit( | ||||
|       state.copyWith( | ||||
|         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}); | ||||
|   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 | ||||
| class ConversationState with _$ConversationState { | ||||
|   factory ConversationState({ | ||||
|     // Our own JID | ||||
|     @Default('') String jid, | ||||
|     @Default('') String messageText, | ||||
|     @Default(false) bool showSendButton, | ||||
|     @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_bloc/flutter_bloc.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/constants.dart'; | ||||
| import 'package:moxxyv2/ui/helpers.dart'; | ||||
| @ -55,6 +56,7 @@ class ConversationBottomRow extends StatelessWidget { | ||||
|                       controller: controller, | ||||
|                       topWidget: state.quotedMessage != null ? buildQuoteMessageWidget( | ||||
|                         state.quotedMessage!, | ||||
|                         isSent(state.quotedMessage!, state.jid), | ||||
|                         resetQuote: () => context.read<ConversationBloc>().add(QuoteRemovedEvent()), | ||||
|                       ) : null, | ||||
|                       shouldSummonKeyboard: () => !state.emojiPickerVisible, | ||||
|  | ||||
| @ -2,6 +2,7 @@ import 'dart:io'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_bloc/flutter_bloc.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/constants.dart'; | ||||
| import 'package:moxxyv2/ui/helpers.dart'; | ||||
| @ -52,18 +53,22 @@ class ConversationPageState extends State<ConversationPage> { | ||||
|     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 | ||||
|     final index = state.messages.length - 1 - _index; | ||||
|     final item = state.messages[index]; | ||||
|     final start = index - 1 < 0 ? true : state.messages[index - 1].sent != item.sent; | ||||
|     final end = index + 1 >= state.messages.length ? true : state.messages[index + 1].sent != item.sent; | ||||
|     final start = index - 1 < 0 ? | ||||
|       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 lastMessageTimestamp = index > 0 ? state.messages[index - 1].timestamp : null; | ||||
|      | ||||
|     return ChatBubble( | ||||
|       message: item, | ||||
|       sentBySelf: item.sent, | ||||
|       sentBySelf: isSent(item, jid), | ||||
|       start: start, | ||||
|       end: end, | ||||
|       between: between, | ||||
| @ -201,12 +206,20 @@ class ConversationPageState extends State<ConversationPage> { | ||||
|                   ), | ||||
| 
 | ||||
|                   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, | ||||
|                     builder: (context, state) => Expanded( | ||||
|                       child: ListView.builder( | ||||
|                         reverse: true, | ||||
|                         itemCount: state.messages.length, | ||||
|                         itemBuilder: (context, index) => _renderBubble(state, context, index, maxWidth), | ||||
|                         itemBuilder: (context, index) => _renderBubble( | ||||
|                           state, | ||||
|                           context, | ||||
|                           index, | ||||
|                           maxWidth, | ||||
|                           state.jid, | ||||
|                         ), | ||||
|                         shrinkWrap: true, | ||||
|                         controller: _scrollController, | ||||
|                       ), | ||||
|  | ||||
| @ -3,6 +3,7 @@ import 'dart:async'; | ||||
| import 'package:get_it/get_it.dart'; | ||||
| import 'package:logging/logging.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/navigation_bloc.dart'; | ||||
| import 'package:moxxyv2/ui/bloc/newconversation_bloc.dart'; | ||||
| @ -33,6 +34,7 @@ Future<void> preStartDone(PreStartDoneEvent result, { dynamic extra }) async { | ||||
|         result.roster!, | ||||
|       ), | ||||
|     ); | ||||
|     GetIt.I.get<ConversationBloc>().add(OwnJidReceivedEvent(result.jid!)); | ||||
| 
 | ||||
|     GetIt.I.get<Logger>().finest('Navigating to conversations'); | ||||
|     GetIt.I.get<NavigationBloc>().add( | ||||
|  | ||||
| @ -8,8 +8,9 @@ import 'package:moxxyv2/ui/constants.dart'; | ||||
| 
 | ||||
| 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 bool sent; | ||||
| 
 | ||||
|   @override | ||||
|   MessageBubbleBottomState createState() => MessageBubbleBottomState(); | ||||
| @ -54,17 +55,17 @@ class MessageBubbleBottomState extends State<MessageBubbleBottom> { | ||||
|   } | ||||
| 
 | ||||
|   bool _showBlueCheckmarks() { | ||||
|     return widget.message.sent && widget.message.displayed; | ||||
|     return widget.sent && widget.message.displayed; | ||||
|   } | ||||
| 
 | ||||
|   bool _showCheckmarks() { | ||||
|     return widget.message.sent && | ||||
|     return widget.sent && | ||||
|             widget.message.received && | ||||
|             !widget.message.displayed; | ||||
|   } | ||||
| 
 | ||||
|   bool _showCheckmark() { | ||||
|     return widget.message.sent && | ||||
|     return widget.sent && | ||||
|             widget.message.acked && | ||||
|             !widget.message.received && | ||||
|             !widget.message.displayed; | ||||
|  | ||||
| @ -156,7 +156,7 @@ class ChatBubbleState extends State<ChatBubble> | ||||
|               child: Padding( | ||||
|                 // NOTE: Images don't work well with padding here | ||||
|                 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.filename, | ||||
|     this.radius, | ||||
|     this.sent, | ||||
|     { | ||||
|       this.extra, | ||||
|       this.onTap, | ||||
| @ -29,6 +30,7 @@ class FileChatBaseWidget extends StatelessWidget { | ||||
|   final String filename; | ||||
|   final BorderRadius radius; | ||||
|   final Widget? extra; | ||||
|   final bool sent; | ||||
|   final void Function()? onTap; | ||||
| 
 | ||||
|   @override | ||||
| @ -51,7 +53,7 @@ class FileChatBaseWidget extends StatelessWidget { | ||||
|           ], | ||||
|         ), | ||||
|       ), | ||||
|       MessageBubbleBottom(message), | ||||
|       MessageBubbleBottom(message, sent), | ||||
|       radius, | ||||
|       gradient: false, | ||||
|       extra: extra, | ||||
| @ -67,6 +69,7 @@ class FileChatWidget extends StatelessWidget { | ||||
|   const FileChatWidget( | ||||
|     this.message, | ||||
|     this.radius, | ||||
|     this.sent, | ||||
|     { | ||||
|       this.extra, | ||||
|       Key? key, | ||||
| @ -74,14 +77,16 @@ class FileChatWidget extends StatelessWidget { | ||||
|   ) : super(key: key); | ||||
|   final Message message; | ||||
|   final BorderRadius radius; | ||||
|   final bool sent; | ||||
|   final Widget? extra; | ||||
| 
 | ||||
|   Widget _buildNonDownloaded() { | ||||
|     return FileChatBaseWidget( | ||||
|       message, | ||||
|       Icons.file_present, | ||||
|       filenameFromUrl(message.srcUrl!), | ||||
|       message.isFileUploadNotification ? (message.filename ?? '') : filenameFromUrl(message.srcUrl!), | ||||
|       radius, | ||||
|       sent, | ||||
|       extra: DownloadButton( | ||||
|         onPressed: () { | ||||
|           MoxplatformPlugin.handler.getDataSender().sendData( | ||||
| @ -97,8 +102,9 @@ class FileChatWidget extends StatelessWidget { | ||||
|     return FileChatBaseWidget( | ||||
|       message, | ||||
|       Icons.file_present, | ||||
|       filenameFromUrl(message.srcUrl!), | ||||
|       message.isFileUploadNotification ? (message.filename ?? '') : filenameFromUrl(message.srcUrl!), | ||||
|       radius, | ||||
|       sent, | ||||
|       extra: ProgressWidget(id: message.id), | ||||
|     ); | ||||
|   } | ||||
| @ -107,8 +113,9 @@ class FileChatWidget extends StatelessWidget { | ||||
|     return FileChatBaseWidget( | ||||
|       message, | ||||
|       Icons.file_present, | ||||
|       filenameFromUrl(message.srcUrl!), | ||||
|       message.isFileUploadNotification ? (message.filename ?? '') : filenameFromUrl(message.srcUrl!), | ||||
|       radius, | ||||
|       sent, | ||||
|       onTap: () { | ||||
|         OpenFile.open(message.mediaUrl); | ||||
|       }, | ||||
| @ -117,7 +124,7 @@ class FileChatWidget extends StatelessWidget { | ||||
| 
 | ||||
|   Widget _buildWrapper() { | ||||
|     if (!message.isDownloading && message.mediaUrl != null) return _buildInner(); | ||||
|     if (message.isDownloading) return _buildDownloading(); | ||||
|     if (message.isFileUploadNotification || message.isDownloading) return _buildDownloading(); | ||||
| 
 | ||||
|     return _buildNonDownloaded(); | ||||
|   } | ||||
|  | ||||
| @ -19,6 +19,7 @@ class ImageChatWidget extends StatelessWidget { | ||||
|     this.message, | ||||
|     this.radius, | ||||
|     this.maxWidth, | ||||
|     this.sent, | ||||
|     { | ||||
|       Key? key, | ||||
|     } | ||||
| @ -26,11 +27,12 @@ class ImageChatWidget extends StatelessWidget { | ||||
|   final Message message; | ||||
|   final BorderRadius radius; | ||||
|   final double maxWidth; | ||||
|   final bool sent; | ||||
| 
 | ||||
|   Widget _buildUploading() { | ||||
|     return MediaBaseChatWidget( | ||||
|       Image.file(File(message.mediaUrl!)), | ||||
|       MessageBubbleBottom(message), | ||||
|       MessageBubbleBottom(message, sent), | ||||
|       radius, | ||||
|       extra: ProgressWidget(id: message.id), | ||||
|     ); | ||||
| @ -50,7 +52,7 @@ class ImageChatWidget extends StatelessWidget { | ||||
|             decodingHeight: thumbnailSize.height.toInt(), | ||||
|           ), | ||||
|         ), | ||||
|         MessageBubbleBottom(message), | ||||
|         MessageBubbleBottom(message, sent), | ||||
|         radius, | ||||
|         extra: ProgressWidget(id: message.id), | ||||
|       ); | ||||
| @ -58,8 +60,9 @@ class ImageChatWidget extends StatelessWidget { | ||||
|       return FileChatBaseWidget( | ||||
|         message, | ||||
|         Icons.image, | ||||
|         filenameFromUrl(message.srcUrl!), | ||||
|         message.isFileUploadNotification ? (message.filename ?? '') : filenameFromUrl(message.srcUrl!), | ||||
|         radius, | ||||
|         sent, | ||||
|         extra: ProgressWidget(id: message.id), | ||||
|       ); | ||||
|     } | ||||
| @ -69,7 +72,7 @@ class ImageChatWidget extends StatelessWidget { | ||||
|   Widget _buildImage() { | ||||
|     return MediaBaseChatWidget( | ||||
|       Image.file(File(message.mediaUrl!)), | ||||
|       MessageBubbleBottom(message), | ||||
|       MessageBubbleBottom(message, sent), | ||||
|       radius, | ||||
|       onTap: () { | ||||
|         OpenFile.open(message.mediaUrl); | ||||
| @ -91,7 +94,7 @@ class ImageChatWidget extends StatelessWidget { | ||||
|             decodingHeight: thumbnailSize.height.toInt(), | ||||
|           ), | ||||
|         ), | ||||
|         MessageBubbleBottom(message), | ||||
|         MessageBubbleBottom(message, sent), | ||||
|         radius, | ||||
|         extra: DownloadButton( | ||||
|           onPressed: () => requestMediaDownload(message), | ||||
| @ -101,8 +104,9 @@ class ImageChatWidget extends StatelessWidget { | ||||
|       return FileChatBaseWidget( | ||||
|         message, | ||||
|         Icons.image, | ||||
|         filenameFromUrl(message.srcUrl!), | ||||
|         message.isFileUploadNotification ? (message.filename ?? '') : filenameFromUrl(message.srcUrl!), | ||||
|         radius, | ||||
|         sent, | ||||
|         extra: DownloadButton( | ||||
|           onPressed: () { | ||||
|             MoxplatformPlugin.handler.getDataSender().sendData( | ||||
| @ -118,10 +122,10 @@ class ImageChatWidget extends StatelessWidget { | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     if (message.isUploading) return _buildUploading(); | ||||
|     if (message.isDownloading) return _buildDownloading(); | ||||
|     if (message.isFileUploadNotification || message.isDownloading) return _buildDownloading(); | ||||
| 
 | ||||
|     // 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(); | ||||
|   } | ||||
|  | ||||
| @ -47,33 +47,34 @@ MessageType getMessageType(Message message) { | ||||
| } | ||||
| 
 | ||||
| /// 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)) { | ||||
|     case MessageType.text: { | ||||
|       return TextChatWidget( | ||||
|         message, | ||||
|         topWidget: message.quotes != null ? buildQuoteMessageWidget(message.quotes!) : null, | ||||
|         sent, | ||||
|         topWidget: message.quotes != null ? buildQuoteMessageWidget(message.quotes!, sent) : null, | ||||
|       ); | ||||
|     } | ||||
|     case MessageType.image: { | ||||
|       return ImageChatWidget(message, radius, maxWidth); | ||||
|       return ImageChatWidget(message, radius, maxWidth, sent); | ||||
|     } | ||||
|     case MessageType.video: { | ||||
|       return VideoChatWidget(message, radius, maxWidth); | ||||
|       return VideoChatWidget(message, radius, maxWidth, sent); | ||||
|     } | ||||
|     // TODO(Unknown): Implement audio | ||||
|     //case MessageType.audio: return buildImageMessageWidget(message); | ||||
|     case MessageType.file: { | ||||
|       return FileChatWidget(message, radius); | ||||
|       return FileChatWidget(message, radius, sent); | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /// 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)) { | ||||
|     case MessageType.text: | ||||
|       return QuoteBaseWidget(message, Text(message.body), resetQuotedMessage: resetQuote); | ||||
|       return QuoteBaseWidget(message, Text(message.body), sent, resetQuotedMessage: resetQuote); | ||||
|     case MessageType.image: | ||||
|       return QuoteBaseWidget( | ||||
|         message, | ||||
| @ -90,6 +91,7 @@ Widget buildQuoteMessageWidget(Message message, { void Function()? resetQuote}) | ||||
|             ), | ||||
|           ], | ||||
|         ), | ||||
|         sent, | ||||
|         resetQuotedMessage: resetQuote, | ||||
|       ); | ||||
|     case MessageType.video: | ||||
| @ -136,6 +138,7 @@ Widget buildQuoteMessageWidget(Message message, { void Function()? resetQuote}) | ||||
|             ) | ||||
|           ], | ||||
|         ), | ||||
|         sent, | ||||
|         resetQuotedMessage: resetQuote, | ||||
|       ); | ||||
|     // TODO(Unknown): Implement audio | ||||
| @ -170,6 +173,7 @@ Widget buildQuoteMessageWidget(Message message, { void Function()? resetQuote}) | ||||
|             ) | ||||
|           ], | ||||
|         ), | ||||
|         sent, | ||||
|         resetQuotedMessage: resetQuote, | ||||
|       ); | ||||
|   } | ||||
|  | ||||
| @ -21,6 +21,7 @@ class VideoChatWidget extends StatelessWidget { | ||||
|     this.message, | ||||
|     this.radius, | ||||
|     this.maxWidth, | ||||
|     this.sent, | ||||
|     { | ||||
|       Key? key, | ||||
|     } | ||||
| @ -28,6 +29,7 @@ class VideoChatWidget extends StatelessWidget { | ||||
|   final Message message; | ||||
|   final double maxWidth; | ||||
|   final BorderRadius radius; | ||||
|   final bool sent; | ||||
| 
 | ||||
|   Widget _buildUploading() { | ||||
|     return MediaBaseChatWidget( | ||||
| @ -35,7 +37,7 @@ class VideoChatWidget extends StatelessWidget { | ||||
|         message.mediaUrl!, | ||||
|         Image.memory, | ||||
|       ), | ||||
|       MessageBubbleBottom(message), | ||||
|       MessageBubbleBottom(message, sent), | ||||
|       radius, | ||||
|       extra: ProgressWidget(id: message.id), | ||||
|     ); | ||||
| @ -55,7 +57,7 @@ class VideoChatWidget extends StatelessWidget { | ||||
|             decodingHeight: thumbnailSize.height.toInt(), | ||||
|           ), | ||||
|         ), | ||||
|         MessageBubbleBottom(message), | ||||
|         MessageBubbleBottom(message, sent), | ||||
|         radius, | ||||
|         extra: ProgressWidget(id: message.id), | ||||
|       ); | ||||
| @ -63,8 +65,9 @@ class VideoChatWidget extends StatelessWidget { | ||||
|       return FileChatBaseWidget( | ||||
|         message, | ||||
|         Icons.video_file_outlined, | ||||
|         filenameFromUrl(message.srcUrl!), | ||||
|         message.isFileUploadNotification ? (message.filename ?? '') : filenameFromUrl(message.srcUrl!), | ||||
|         radius, | ||||
|         sent, | ||||
|         extra: ProgressWidget(id: message.id), | ||||
|       ); | ||||
|     } | ||||
| @ -77,7 +80,7 @@ class VideoChatWidget extends StatelessWidget { | ||||
|         message.mediaUrl!, | ||||
|         Image.memory, | ||||
|       ), | ||||
|       MessageBubbleBottom(message), | ||||
|       MessageBubbleBottom(message, sent), | ||||
|       radius, | ||||
|       onTap: () { | ||||
|         OpenFile.open(message.mediaUrl); | ||||
| @ -100,7 +103,7 @@ class VideoChatWidget extends StatelessWidget { | ||||
|             decodingHeight: thumbnailSize.height.toInt(), | ||||
|           ), | ||||
|         ), | ||||
|         MessageBubbleBottom(message), | ||||
|         MessageBubbleBottom(message, sent), | ||||
|         radius, | ||||
|         extra: DownloadButton( | ||||
|           onPressed: () => requestMediaDownload(message), | ||||
| @ -110,8 +113,9 @@ class VideoChatWidget extends StatelessWidget { | ||||
|       return FileChatBaseWidget( | ||||
|         message, | ||||
|         Icons.video_file_outlined, | ||||
|         filenameFromUrl(message.srcUrl!), | ||||
|         message.isFileUploadNotification ? (message.filename ?? '') : filenameFromUrl(message.srcUrl!), | ||||
|         radius, | ||||
|         sent, | ||||
|         extra: DownloadButton( | ||||
|           onPressed: () { | ||||
|             MoxplatformPlugin.handler.getDataSender().sendData( | ||||
| @ -127,10 +131,10 @@ class VideoChatWidget extends StatelessWidget { | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     if (message.isUploading) return _buildUploading(); | ||||
|     if (message.isDownloading) return _buildDownloading(); | ||||
|     if (message.isFileUploadNotification || message.isDownloading) return _buildDownloading(); | ||||
| 
 | ||||
|     // 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(); | ||||
|   } | ||||
|  | ||||
| @ -11,6 +11,7 @@ class QuoteBaseWidget extends StatelessWidget { | ||||
|   const QuoteBaseWidget( | ||||
|     this.message, | ||||
|     this.child, | ||||
|     this.sent, | ||||
|     { | ||||
|       this.resetQuotedMessage, | ||||
|       Key? key, | ||||
| @ -18,10 +19,11 @@ class QuoteBaseWidget extends StatelessWidget { | ||||
|   ) : super(key: key); | ||||
|   final Message message; | ||||
|   final Widget child; | ||||
|   final bool sent; | ||||
|   final void Function()? resetQuotedMessage; | ||||
| 
 | ||||
|   Color _getColor() { | ||||
|     if (message.sent) { | ||||
|     if (sent) { | ||||
|       return bubbleColorSentQuoted; | ||||
|     } else { | ||||
|       return bubbleColorReceivedQuoted; | ||||
|  | ||||
| @ -10,12 +10,14 @@ class TextChatWidget extends StatelessWidget { | ||||
| 
 | ||||
|   const TextChatWidget( | ||||
|     this.message, | ||||
|     this.sent, | ||||
|     { | ||||
|       this.topWidget, | ||||
|       Key? key, | ||||
|     } | ||||
|   ) : super(key: key); | ||||
|   final Message message; | ||||
|   final bool sent; | ||||
|   final Widget? topWidget; | ||||
| 
 | ||||
|   @override | ||||
| @ -40,7 +42,7 @@ class TextChatWidget extends StatelessWidget { | ||||
|           ), | ||||
|           Padding( | ||||
|             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_0359.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_0461.dart'; | ||||
| 
 | ||||
| @ -55,20 +56,23 @@ class StreamResumeFailedEvent extends XmppEvent {} | ||||
| class MessageEvent extends XmppEvent { | ||||
| 
 | ||||
|   MessageEvent({ | ||||
|       required this.body, | ||||
|       required this.fromJid, | ||||
|       required this.toJid, | ||||
|       required this.sid, | ||||
|       required this.stanzaId, | ||||
|       required this.isCarbon, | ||||
|       required this.deliveryReceiptRequested, | ||||
|       required this.isMarkable, | ||||
|       this.type, | ||||
|       this.oob, | ||||
|       this.sfs, | ||||
|       this.sims, | ||||
|       this.reply, | ||||
|       this.chatState, | ||||
|     required this.body, | ||||
|     required this.fromJid, | ||||
|     required this.toJid, | ||||
|     required this.sid, | ||||
|     required this.stanzaId, | ||||
|     required this.isCarbon, | ||||
|     required this.deliveryReceiptRequested, | ||||
|     required this.isMarkable, | ||||
|     this.type, | ||||
|     this.oob, | ||||
|     this.sfs, | ||||
|     this.sims, | ||||
|     this.reply, | ||||
|     this.chatState, | ||||
|     this.fun, | ||||
|     this.funReplacement, | ||||
|     this.funCancellation, | ||||
|   }); | ||||
|   final String body; | ||||
|   final JID fromJid; | ||||
| @ -84,6 +88,9 @@ class MessageEvent extends XmppEvent { | ||||
|   final StatelessMediaSharingData? sims; | ||||
|   final ReplyData? reply; | ||||
|   final ChatState? chatState; | ||||
|   final FileMetadataData? fun; | ||||
|   final String? funReplacement; | ||||
|   final String? funCancellation; | ||||
| } | ||||
| 
 | ||||
| /// 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_0359.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_0461.dart'; | ||||
| 
 | ||||
| @ -32,6 +33,13 @@ class StanzaHandlerData with _$StanzaHandlerData { | ||||
|       @Default(false) bool isCarbon, | ||||
|       @Default(false) bool deliveryReceiptRequested, | ||||
|       @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 | ||||
|       // pass data around. | ||||
|       @Default(<String, dynamic>{}) Map<String, dynamic> other, | ||||
|  | ||||
| @ -19,3 +19,4 @@ const blockingManager = 'im.moxxy.blockingmanager'; | ||||
| const httpFileUploadManager = 'im.moxxy.httpfileuploadmanager'; | ||||
| const chatStateManager = 'im.moxxy.chatstatemanager'; | ||||
| 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/stanza.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_0085.dart'; | ||||
| import 'package:moxxyv2/xmpp/xeps/xep_0184.dart'; | ||||
| import 'package:moxxyv2/xmpp/xeps/xep_0333.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'; | ||||
| 
 | ||||
| class MessageDetails { | ||||
| 
 | ||||
|   const MessageDetails({ | ||||
|       required this.to, | ||||
|       required this.body, | ||||
|       this.requestDeliveryReceipt = false, | ||||
|       this.requestChatMarkers = true, | ||||
|       this.id, | ||||
|       this.originId, | ||||
|       this.quoteBody, | ||||
|       this.quoteId, | ||||
|       this.quoteFrom, | ||||
|       this.chatState, | ||||
|       this.sfs, | ||||
|     required this.to, | ||||
|     this.body, | ||||
|     this.requestDeliveryReceipt = false, | ||||
|     this.requestChatMarkers = true, | ||||
|     this.id, | ||||
|     this.originId, | ||||
|     this.quoteBody, | ||||
|     this.quoteId, | ||||
|     this.quoteFrom, | ||||
|     this.chatState, | ||||
|     this.sfs, | ||||
|     this.fun, | ||||
|     this.funReplacement, | ||||
|     this.funCancellation, | ||||
|   }); | ||||
|   final String to; | ||||
|   final String body; | ||||
|   final String? body; | ||||
|   final bool requestDeliveryReceipt; | ||||
|   final bool requestChatMarkers; | ||||
|   final String? id; | ||||
| @ -41,6 +46,9 @@ class MessageDetails { | ||||
|   final String? quoteFrom; | ||||
|   final ChatState? chatState; | ||||
|   final StatelessFileSharingData? sfs; | ||||
|   final FileMetadataData? fun; | ||||
|   final String? funReplacement; | ||||
|   final String? funCancellation; | ||||
| } | ||||
| 
 | ||||
| class MessageManager extends XmppManagerBase { | ||||
| @ -63,10 +71,9 @@ class MessageManager extends XmppManagerBase { | ||||
|   Future<bool> isSupported() async => true; | ||||
|    | ||||
|   Future<StanzaHandlerData> _onMessage(Stanza _, StanzaHandlerData state) async { | ||||
|     // First check if it's a carbon | ||||
|     final message = state.stanza; | ||||
|     final body = message.firstTag('body'); | ||||
|      | ||||
| 
 | ||||
|     getAttributes().sendEvent(MessageEvent( | ||||
|       body: body != null ? body.innerText() : '', | ||||
|       fromJid: JID.fromString(message.attributes['from']! as String), | ||||
| @ -82,6 +89,9 @@ class MessageManager extends XmppManagerBase { | ||||
|       sims: state.sims, | ||||
|       reply: state.reply, | ||||
|       chatState: state.chatState, | ||||
|       fun: state.fun, | ||||
|       funReplacement: state.funReplacement, | ||||
|       funCancellation: state.funCancellation, | ||||
|     ),); | ||||
| 
 | ||||
|     return state.copyWith(done: true); | ||||
| @ -158,7 +168,7 @@ class MessageManager extends XmppManagerBase { | ||||
| 
 | ||||
|     if (details.sfs != null) { | ||||
|       stanza | ||||
|         ..addChild(constructSFSElement(details.sfs!)) | ||||
|         ..addChild(details.sfs!.toXML()) | ||||
|         // SFS recommends OOB as a fallback | ||||
|         ..addChild(constructOOBNode(OOBData(url: details.sfs!.url)),); | ||||
|     } | ||||
| @ -169,6 +179,30 @@ class MessageManager extends XmppManagerBase { | ||||
|         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); | ||||
|   } | ||||
|  | ||||
| @ -1,15 +1,15 @@ | ||||
| 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 blurhashThumbnailType = 'blurhash'; | ||||
| const fileThumbnailsXmlns = 'proto:urn:xmpp:eft:0'; | ||||
| const blurhashThumbnailType = '$fileThumbnailsXmlns:blurhash'; | ||||
| 
 | ||||
| abstract class Thumbnail {} | ||||
| 
 | ||||
| class BlurhashThumbnail extends Thumbnail { | ||||
| 
 | ||||
|   BlurhashThumbnail({ required this.hash }); | ||||
|   BlurhashThumbnail(this.hash); | ||||
|   final String hash; | ||||
| } | ||||
| 
 | ||||
| @ -20,7 +20,7 @@ Thumbnail? parseFileThumbnailElement(XMLNode node) { | ||||
|   switch (node.attributes['type']!) { | ||||
|     case blurhashThumbnailType: { | ||||
|       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)!; | ||||
|   var type = ''; | ||||
|   if (thumbnail is BlurhashThumbnail) { | ||||
|     type = 'blurhash'; | ||||
|     type = blurhashThumbnailType; | ||||
|   } | ||||
| 
 | ||||
|   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/stringxml.dart'; | ||||
| import 'package:moxxyv2/xmpp/xeps/staging/file_thumbnails.dart'; | ||||
| import 'package:moxxyv2/xmpp/stanza.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 | ||||
| 
 | ||||
| const fileUploadNotificationXmlns = 'proto:urn:xmpp:fun:0'; | ||||
| 
 | ||||
| class FileUploadNotificationData { | ||||
| class FileUploadNotificationManager extends XmppManagerBase { | ||||
|   FileUploadNotificationManager() : super(); | ||||
| 
 | ||||
|   const FileUploadNotificationData({ required this.metadata, required this.thumbnails }); | ||||
|   final FileMetadataData metadata; | ||||
|   final List<Thumbnail> thumbnails; | ||||
| } | ||||
| 
 | ||||
| XMLNode constructFileUploadNotification(FileMetadataData metadata, List<Thumbnail> thumbnails) { | ||||
|   final thumbnailNodes = thumbnails.map(constructFileThumbnailElement).toList(); | ||||
|   return XMLNode.xmlns( | ||||
|     tag: 'file-upload', | ||||
|     xmlns: fileUploadNotificationXmlns, | ||||
|     children: [ | ||||
|       constructFileMetadataElement(metadata), | ||||
|       ...thumbnailNodes | ||||
|     ], | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| FileUploadNotificationData parseFileUploadNotification(XMLNode node) { | ||||
|   assert(node.attributes['xmlns'] == fileUploadNotificationXmlns, 'Invalid element xmlns'); | ||||
|   assert(node.tag == 'file-upload', 'Invalid element name'); | ||||
| 
 | ||||
|   final thumbnails = node.findTags('thumbnail', xmlns: fileThumbnailsXmlns).map((t) => parseFileThumbnailElement(t)!).toList(); | ||||
|    | ||||
|   return FileUploadNotificationData( | ||||
|     metadata: parseFileMetadataElement(node.firstTag('file', xmlns: fileMetadataXmlns)!), | ||||
|     thumbnails: thumbnails, | ||||
|   ); | ||||
|   @override | ||||
|   String getId() => fileUploadNotificationManager; | ||||
| 
 | ||||
|   @override | ||||
|   String getName() => 'FileUploadNotificationManager'; | ||||
| 
 | ||||
|   @override | ||||
|   List<StanzaHandler> getIncomingStanzaHandlers() => [ | ||||
|     StanzaHandler( | ||||
|       stanzaTag: 'message', | ||||
|       tagName: 'file-upload', | ||||
|       tagXmlns: fileUploadNotificationXmlns, | ||||
|       callback: _onFileUploadNotificationReceived, | ||||
|     ), | ||||
|     StanzaHandler( | ||||
|       stanzaTag: 'message', | ||||
|       tagName: 'replaces', | ||||
|       tagXmlns: fileUploadNotificationXmlns, | ||||
|       callback: _onFileUploadNotificationReplacementReceived, | ||||
|     ), | ||||
|     StanzaHandler( | ||||
|       stanzaTag: 'message', | ||||
|       tagName: 'cancelled', | ||||
|       tagXmlns: fileUploadNotificationXmlns, | ||||
|       callback: _onFileUploadNotificationCancellationReceived, | ||||
|     ), | ||||
|   ]; | ||||
| 
 | ||||
|   @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/stanza.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 { | ||||
| 
 | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| import 'package:moxxyv2/xmpp/namespaces.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'; | ||||
| 
 | ||||
| class FileMetadataData { | ||||
| @ -15,6 +15,43 @@ class FileMetadataData { | ||||
|       required this.thumbnails, | ||||
|       Map<String, String>? hashes, | ||||
|   }) : 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; | ||||
| 
 | ||||
|   // TODO(Unknown): Maybe create a special type for this | ||||
| @ -26,70 +63,33 @@ class FileMetadataData { | ||||
|   final int? length; | ||||
|   final String? name; | ||||
|   final int? size; | ||||
| } | ||||
| 
 | ||||
| FileMetadataData parseFileMetadataElement(XMLNode node) { | ||||
|   assert(node.attributes['xmlns'] == fileMetadataXmlns, 'Invalid element xmlns'); | ||||
|   assert(node.tag == 'file', 'Invalid element anme'); | ||||
|   XMLNode toXML() { | ||||
|     final node = XMLNode.xmlns( | ||||
|       tag: 'file', | ||||
|       xmlns: fileMetadataXmlns, | ||||
|       children: List.empty(growable: true), | ||||
|     ); | ||||
| 
 | ||||
|   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; | ||||
|     if (mediaType != null) node.addChild(XMLNode(tag: 'media-type', text: mediaType)); | ||||
|     if (dimensions != null) node.addChild(XMLNode(tag: 'dimensions', text: dimensions)); | ||||
|     if (desc != null) node.addChild(XMLNode(tag: 'desc', text: desc)); | ||||
|     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 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) { | ||||
|     for (final hash in hashes.entries) { | ||||
|       node.addChild( | ||||
|         constructHashElement(hash.key, hash.value), | ||||
|       ); | ||||
|     } | ||||
|   } | ||||
|   if (data.thumbnails.isNotEmpty) { | ||||
|     for (final thumbnail in data.thumbnails) { | ||||
| 
 | ||||
|     for (final thumbnail in thumbnails) { | ||||
|       node.addChild( | ||||
|         constructFileThumbnailElement(thumbnail), | ||||
|       ); | ||||
|     } | ||||
|      | ||||
|     return node; | ||||
|   } | ||||
|    | ||||
|   return node; | ||||
| } | ||||
|  | ||||
| @ -9,46 +9,47 @@ import 'package:moxxyv2/xmpp/xeps/xep_0446.dart'; | ||||
| 
 | ||||
| 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 String url; | ||||
| } | ||||
| 
 | ||||
| StatelessFileSharingData parseSFSElement(XMLNode node) { | ||||
|   assert(node.attributes['xmlns'] == sfsXmlns, 'Invalid element xmlns'); | ||||
|   assert(node.tag == 'file-sharing', 'Invalid element name'); | ||||
| 
 | ||||
|   final metadata = parseFileMetadataElement(node.firstTag('file')!); | ||||
|   final sources = node.firstTag('sources')!; | ||||
|   final urldata = sources.firstTag('url-data', xmlns: urlDataXmlns); | ||||
|   final url = urldata!.attributes['target']! as String; | ||||
| 
 | ||||
|   return StatelessFileSharingData( | ||||
|     metadata: metadata, | ||||
|     url: 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, | ||||
|             }, | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ], | ||||
|   ); | ||||
|   XMLNode toXML() { | ||||
|     return XMLNode.xmlns( | ||||
|       tag: 'file-sharing', | ||||
|       xmlns: sfsXmlns, | ||||
|       children: [ | ||||
|         metadata.toXML(), | ||||
|         XMLNode( | ||||
|           tag: 'sources', | ||||
|           children: [ | ||||
|             XMLNode.xmlns( | ||||
|               tag: 'url-data', | ||||
|               xmlns: urlDataXmlns, | ||||
|               attributes: <String, String>{ | ||||
|                 'target': url, | ||||
|               }, | ||||
|             ), | ||||
|           ], | ||||
|         ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class SFSManager extends XmppManagerBase { | ||||
| @ -77,7 +78,7 @@ class SFSManager extends XmppManagerBase { | ||||
|     final sfs = message.firstTag('file-sharing', xmlns: sfsXmlns)!; | ||||
| 
 | ||||
|     return state.copyWith( | ||||
|       sfs: parseSFSElement(sfs), | ||||
|       sfs: StatelessFileSharingData.fromXML(sfs), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
							
								
								
									
										16
									
								
								moxxy.doap
									
									
									
									
									
								
							
							
						
						
									
										16
									
								
								moxxy.doap
									
									
									
									
									
								
							| @ -184,18 +184,18 @@ | ||||
|     </implements> | ||||
|     <implements> | ||||
|       <xmpp:SupportedXep> | ||||
| 	<xmpp:xep rdf:resource="https://github.com/PapaTutuWawa/custom-xeps/blob/master/xep-xxxx-file-thumbnails.md" /> | ||||
| 	<xmpp:status>complete</xmpp:status> | ||||
| 	<xmpp:version>0.0.3</xmpp:version> | ||||
| 	<xmpp:note xml:lang="en">Read-only implementation</xmpp:note> | ||||
| 	<xmpp:xep rdf:resource="https://codeberg.org/moxxy/custom-xeps/src/branch/master/xep-xxxx-extensible-file-thumbnails.md" /> | ||||
| 	<xmpp:status>partial</xmpp:status> | ||||
| 	<xmpp:version>0.2.1</xmpp:version> | ||||
| 	<xmpp:note xml:lang="en">Only Blurhash is implemented</xmpp:note> | ||||
|       </xmpp:SupportedXep> | ||||
|     </implements> | ||||
|     <implements> | ||||
|       <xmpp:SupportedXep> | ||||
| 	<xmpp:xep rdf:resource="https://github.com/PapaTutuWawa/custom-xeps/blob/master/xep-xxxx-file-upload-notification.md" /> | ||||
| 	<xmpp:status>complete</xmpp:status> | ||||
| 	<xmpp:version>0.0.4</xmpp:version> | ||||
| 	<xmpp:note xml:lang="en">Not-connected read-only implementation</xmpp:note> | ||||
| 	<xmpp:xep rdf:resource="https://codeberg.org/moxxy/custom-xeps/src/branch/master/xep-xxxx-file-upload-notification.md" /> | ||||
| 	<xmpp:status>partial</xmpp:status> | ||||
| 	<xmpp:version>0.0.5</xmpp:version> | ||||
| 	<xmpp:note xml:lang="en">Sending and receiving implemented; cancellation not implemented</xmpp:note> | ||||
|       </xmpp:SupportedXep> | ||||
|     </implements> | ||||
|   </Project> | ||||
|  | ||||
| @ -50,6 +50,13 @@ packages: | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     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: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|  | ||||
| @ -13,6 +13,7 @@ dependencies: | ||||
|   archive: 3.3.1 | ||||
|   badges: 2.0.3 | ||||
|   bloc: 8.1.0 | ||||
|   blurhash_dart: 1.1.0 | ||||
|   connectivity_plus: 2.3.6 | ||||
|   crop_your_image: 0.7.2 | ||||
|   cryptography: 2.0.5 | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user