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