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:
PapaTutuWawa 2022-08-25 15:15:48 +02:00
commit bc52ec6fb9
35 changed files with 581 additions and 263 deletions

View File

@ -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);

View File

@ -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;
} }

View File

@ -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, '');
} }

View File

@ -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;
} }

View File

@ -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)) {

View File

@ -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(),

View File

@ -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 {

View File

@ -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;
}

View File

@ -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;

View File

@ -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));
}
} }

View File

@ -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;
}

View File

@ -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,

View File

@ -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,

View File

@ -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,
), ),

View File

@ -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(

View File

@ -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;

View File

@ -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),
), ),
) )
], ],

View File

@ -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();
} }

View File

@ -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();
} }

View File

@ -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,
); );
} }

View File

@ -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();
} }

View File

@ -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;

View File

@ -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),
) )
], ],
), ),

View File

@ -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

View File

@ -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,

View File

@ -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';

View File

@ -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);
} }

View File

@ -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(

View File

@ -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,
);
}
} }

View File

@ -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 {

View File

@ -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;
} }

View File

@ -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),
); );
} }
} }

View File

@ -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>

View File

@ -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:

View File

@ -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