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,6 +74,17 @@ 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,
|
||||
@ -79,6 +93,8 @@ class MessageService {
|
||||
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) {
|
||||
@ -586,6 +620,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
|
||||
final conversationJid = event.isCarbon
|
||||
@ -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';
|
||||
|
||||
@ -69,6 +70,9 @@ class MessageEvent extends XmppEvent {
|
||||
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,18 +8,20 @@ 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.body,
|
||||
this.requestDeliveryReceipt = false,
|
||||
this.requestChatMarkers = true,
|
||||
this.id,
|
||||
@ -29,9 +31,12 @@ class MessageDetails {
|
||||
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,7 +71,6 @@ 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');
|
||||
|
||||
@ -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)),);
|
||||
}
|
||||
@ -170,6 +180,30 @@ class MessageManager extends XmppManagerBase {
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@override
|
||||
String getId() => fileUploadNotificationManager;
|
||||
|
||||
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
|
||||
],
|
||||
@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)!,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
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,20 +15,9 @@ class FileMetadataData {
|
||||
required this.thumbnails,
|
||||
Map<String, String>? hashes,
|
||||
}) : hashes = hashes ?? const {};
|
||||
final String? mediaType;
|
||||
|
||||
// TODO(Unknown): Maybe create a special type for this
|
||||
final String? dimensions;
|
||||
|
||||
final List<Thumbnail> thumbnails;
|
||||
final String? desc;
|
||||
final Map<String, String> hashes;
|
||||
final int? length;
|
||||
final String? name;
|
||||
final int? size;
|
||||
}
|
||||
|
||||
FileMetadataData parseFileMetadataElement(XMLNode node) {
|
||||
/// 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');
|
||||
|
||||
@ -61,35 +50,46 @@ FileMetadataData parseFileMetadataElement(XMLNode node) {
|
||||
size: size,
|
||||
thumbnails: thumbnails,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
XMLNode constructFileMetadataElement(FileMetadataData data) {
|
||||
final String? mediaType;
|
||||
|
||||
// TODO(Unknown): Maybe create a special type for this
|
||||
final String? dimensions;
|
||||
|
||||
final List<Thumbnail> thumbnails;
|
||||
final String? desc;
|
||||
final Map<String, String> hashes;
|
||||
final int? length;
|
||||
final String? name;
|
||||
final int? size;
|
||||
|
||||
XMLNode toXML() {
|
||||
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) {
|
||||
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()));
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@ -9,32 +9,32 @@ import 'package:moxxyv2/xmpp/xeps/xep_0446.dart';
|
||||
|
||||
class StatelessFileSharingData {
|
||||
|
||||
const StatelessFileSharingData({ required this.metadata, required this.url });
|
||||
final FileMetadataData metadata;
|
||||
final String url;
|
||||
}
|
||||
const StatelessFileSharingData(this.metadata, this.url);
|
||||
|
||||
StatelessFileSharingData parseSFSElement(XMLNode node) {
|
||||
/// 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 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,
|
||||
FileMetadataData.fromXML(node.firstTag('file')!),
|
||||
url,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
XMLNode constructSFSElement(StatelessFileSharingData data) {
|
||||
final FileMetadataData metadata;
|
||||
final String url;
|
||||
|
||||
XMLNode toXML() {
|
||||
return XMLNode.xmlns(
|
||||
tag: 'file-sharing',
|
||||
xmlns: sfsXmlns,
|
||||
children: [
|
||||
constructFileMetadataElement(data.metadata),
|
||||
metadata.toXML(),
|
||||
XMLNode(
|
||||
tag: 'sources',
|
||||
children: [
|
||||
@ -42,13 +42,14 @@ XMLNode constructSFSElement(StatelessFileSharingData data) {
|
||||
tag: 'url-data',
|
||||
xmlns: urlDataXmlns,
|
||||
attributes: <String, String>{
|
||||
'target': data.url,
|
||||
'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