Merge pull request 'Implement File Upload Notifications' (#79) from feat/file-upload-notifications into master

Reviewed-on: https://codeberg.org/moxxy/moxxyv2/pulls/79
This commit is contained in:
PapaTutuWawa 2022-08-25 15:15:48 +02:00
commit bc52ec6fb9
35 changed files with 581 additions and 263 deletions

View File

@ -63,14 +63,14 @@ RosterItem rosterDbToModel(DBRosterItem i) {
Message messageDbToModel(DBMessage m) {
return Message(
m.from,
m.sender,
m.body,
m.timestamp,
m.sent,
m.sid,
m.id!,
m.conversationJid,
m.isMedia,
m.isFileUploadNotification,
originId: m.originId,
received: m.received,
displayed: m.displayed,
@ -82,6 +82,7 @@ Message messageDbToModel(DBMessage m) {
srcUrl: m.srcUrl,
quotes: m.quotes.value != null ? messageDbToModel(m.quotes.value!) : null,
errorType: m.errorType,
filename: m.filename,
);
}
@ -233,11 +234,11 @@ class DatabaseService {
Future<Message> addMessageFromData(
String body,
int timestamp,
String from,
String sender,
String conversationJid,
bool sent,
bool isMedia,
String sid,
bool isFileUploadNotification,
{
String? srcUrl,
String? mediaUrl,
@ -246,14 +247,14 @@ class DatabaseService {
String? thumbnailDimensions,
String? originId,
String? quoteId,
String? filename,
}
) async {
final m = DBMessage()
..from = from
..conversationJid = conversationJid
..timestamp = timestamp
..body = body
..sent = sent
..sender = sender
..isMedia = isMedia
..mediaType = mediaType
..mediaUrl = mediaUrl
@ -265,7 +266,9 @@ class DatabaseService {
..displayed = false
..acked = false
..originId = originId
..errorType = noError;
..errorType = noError
..isFileUploadNotification = isFileUploadNotification
..filename = filename;
if (quoteId != null) {
final quotes = await getMessageByXmppId(quoteId, conversationJid);
@ -303,6 +306,8 @@ class DatabaseService {
bool? displayed,
bool? acked,
int? errorType,
bool? isFileUploadNotification,
String? srcUrl,
}) async {
final i = (await _isar.dBMessages.get(id))!;
if (mediaUrl != null) {
@ -323,6 +328,12 @@ class DatabaseService {
if (errorType != null) {
i.errorType = errorType;
}
if (isFileUploadNotification != null) {
i.isFileUploadNotification = isFileUploadNotification;
}
if (srcUrl != null) {
i.srcUrl = srcUrl;
}
await _isar.writeTxn(() async {
await _isar.dBMessages.put(i);

View File

@ -7,9 +7,6 @@ part 'message.g.dart';
class DBMessage {
int? id;
@Index(caseSensitive: false)
late String from;
@Index(caseSensitive: false)
late String conversationJid;
@ -17,9 +14,9 @@ class DBMessage {
late String body;
// TODO(Unknown): Replace by just checking if sender == us
/// Indicate if the message was sent by the user (true) or received by the user (false)
late bool sent;
/// The full JID of the sender
@Index(caseSensitive: false)
late String sender;
late String sid;
String? originId;
@ -33,6 +30,10 @@ class DBMessage {
/// that clearly identifies the error.
late int errorType;
/// If true, then the message is currently a placeholder for a File Upload Notification
/// and may be replaced
late bool isFileUploadNotification;
/// The message that this one quotes
final quotes = IsarLink<DBMessage>();
@ -49,4 +50,7 @@ class DBMessage {
String? thumbnailData;
/// The dimensions of the thumbnail
String? thumbnailDimensions;
/// The filename of the file. Useful for when we don't have the full URL yet
String? filename;
}

View File

@ -213,14 +213,13 @@ class HttpFileTransferService {
id: job.message.sid,
originId: job.message.originId,
sfs: StatelessFileSharingData(
url: slot.getUrl,
metadata: FileMetadataData(
FileMetadataData(
mediaType: fileMime,
size: stat.size,
name: pathlib.basename(job.path),
// TODO(Unknown): Add a thumbnail
thumbnails: [],
thumbnails: job.thumbnails,
),
slot.getUrl,
),
),
);
@ -295,11 +294,12 @@ class HttpFileTransferService {
job.mId,
mediaUrl: downloadedPath,
mediaType: mime,
isFileUploadNotification: false,
);
sendEvent(MessageUpdatedEvent(message: msg.copyWith(isDownloading: false)));
if (notification.shouldShowNotification(msg.conversationJid)) {
if (notification.shouldShowNotification(msg.conversationJid) && job.shouldShowNotification) {
_log.finest('Creating notification with bigPicture $downloadedPath');
await notification.showNotification(msg, '');
}

View File

@ -1,15 +1,17 @@
import 'package:meta/meta.dart';
import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/xmpp/xeps/staging/extensible_file_thumbnails.dart';
/// A job describing the download of a file.
@immutable
class FileUploadJob {
const FileUploadJob(this.recipient, this.path, this.copyToPath, this.message);
const FileUploadJob(this.recipient, this.path, this.copyToPath, this.message, this.thumbnails);
final String path;
final String recipient;
final Message message;
final String copyToPath;
final List<Thumbnail> thumbnails;
@override
bool operator ==(Object other) {
@ -17,22 +19,24 @@ class FileUploadJob {
recipient == other.recipient &&
path == other.path &&
message == other.message &&
copyToPath == other.copyToPath;
copyToPath == other.copyToPath &&
thumbnails == other.thumbnails;
}
@override
int get hashCode => path.hashCode ^ recipient.hashCode ^ message.hashCode ^ copyToPath.hashCode;
int get hashCode => path.hashCode ^ recipient.hashCode ^ message.hashCode ^ copyToPath.hashCode ^ thumbnails.hashCode;
}
/// A job describing the upload of a file.
@immutable
class FileDownloadJob {
const FileDownloadJob(this.url, this.mId, this.conversationJid, this.mimeGuess);
const FileDownloadJob(this.url, this.mId, this.conversationJid, this.mimeGuess, {this.shouldShowNotification = true});
final String url;
final int mId;
final String conversationJid;
final String? mimeGuess;
final bool shouldShowNotification;
@override
bool operator ==(Object other) {
@ -40,8 +44,9 @@ class FileDownloadJob {
url == other.url &&
mId == other.mId &&
conversationJid == other.conversationJid &&
mimeGuess == other.mimeGuess;
mimeGuess == other.mimeGuess &&
shouldShowNotification == other.shouldShowNotification;
}
@override
int get hashCode => url.hashCode ^ mId.hashCode ^ conversationJid.hashCode ^ mimeGuess.hashCode;
int get hashCode => url.hashCode ^ mId.hashCode ^ conversationJid.hashCode ^ mimeGuess.hashCode ^ shouldShowNotification.hashCode;
}

View File

@ -3,6 +3,7 @@ import 'dart:collection';
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:moxxyv2/service/database.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/message.dart';
class MessageService {
@ -30,11 +31,11 @@ class MessageService {
Future<Message> addMessageFromData(
String body,
int timestamp,
String from,
String sender,
String conversationJid,
bool sent,
bool isMedia,
String sid,
bool isFileUploadNotification,
{
String? srcUrl,
String? mediaUrl,
@ -43,16 +44,17 @@ class MessageService {
String? thumbnailDimensions,
String? originId,
String? quoteId,
String? filename,
}
) async {
final msg = await GetIt.I.get<DatabaseService>().addMessageFromData(
body,
timestamp,
from,
sender,
conversationJid,
sent,
isMedia,
sid,
isFileUploadNotification,
srcUrl: srcUrl,
mediaUrl: mediaUrl,
mediaType: mediaType,
@ -60,6 +62,7 @@ class MessageService {
thumbnailDimensions: thumbnailDimensions,
originId: originId,
quoteId: quoteId,
filename: filename,
);
// Only update the cache if the conversation already has been loaded. This prevents
@ -71,14 +74,27 @@ class MessageService {
return msg;
}
Future<Message?> getMessageByStanzaId(String conversationJid, String stanzaId) async {
if (_messageCache.containsKey(conversationJid)) {
await getMessagesForJid(conversationJid);
}
return firstWhereOrNull(
_messageCache[conversationJid]!,
(message) => message.sid == stanzaId,
);
}
/// Wrapper around [DatabaseService]'s updateMessage that updates the cache
Future<Message> updateMessage(int id, {
String? mediaUrl,
String? mediaType,
bool? received,
bool? displayed,
bool? acked,
int? errorType,
String? mediaUrl,
String? mediaType,
bool? received,
bool? displayed,
bool? acked,
int? errorType,
bool? isFileUploadNotification,
String? srcUrl,
}) async {
final newMessage = await GetIt.I.get<DatabaseService>().updateMessage(
id,
@ -88,6 +104,8 @@ class MessageService {
displayed: displayed,
acked: acked,
errorType: errorType,
isFileUploadNotification: isFileUploadNotification,
srcUrl: srcUrl,
);
if (_messageCache.containsKey(newMessage.conversationJid)) {

View File

@ -39,6 +39,7 @@ import 'package:moxxyv2/xmpp/negotiators/starttls.dart';
import 'package:moxxyv2/xmpp/ping.dart';
import 'package:moxxyv2/xmpp/presence.dart';
import 'package:moxxyv2/xmpp/roster.dart';
import 'package:moxxyv2/xmpp/xeps/staging/file_upload_notification.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0054.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0060.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0066.dart';
@ -201,6 +202,7 @@ Future<void> entrypoint() async {
BlockingManager(),
ChatStateManager(),
HttpFileUploadManager(),
FileUploadNotificationManager(),
])
..registerFeatureNegotiators([
ResourceBindingNegotiator(),

View File

@ -1,8 +1,11 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:blurhash_dart/blurhash_dart.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:get_it/get_it.dart';
import 'package:image/image.dart';
import 'package:logging/logging.dart';
import 'package:mime/mime.dart';
import 'package:moxlib/moxlib.dart';
@ -37,10 +40,11 @@ import 'package:moxxyv2/xmpp/namespaces.dart';
import 'package:moxxyv2/xmpp/roster.dart';
import 'package:moxxyv2/xmpp/settings.dart';
import 'package:moxxyv2/xmpp/stanza.dart';
import 'package:moxxyv2/xmpp/xeps/staging/file_thumbnails.dart';
import 'package:moxxyv2/xmpp/xeps/staging/extensible_file_thumbnails.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0085.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0184.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0333.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0446.dart';
import 'package:path/path.dart' as pathlib;
import 'package:permission_handler/permission_handler.dart';
@ -204,9 +208,9 @@ class XmppService {
timestamp,
conn.getConnectionSettings().jid.toString(),
jid,
true,
false,
sid,
false,
originId: originId,
quoteId: quotedMessage?.originId ?? quotedMessage?.sid,
);
@ -226,7 +230,7 @@ class XmppService {
id: sid,
originId: originId,
quoteBody: quotedMessage?.body,
quoteFrom: quotedMessage?.from,
quoteFrom: quotedMessage?.sender,
quoteId: quotedMessage?.originId ?? quotedMessage?.sid,
chatState: chatState,
),
@ -334,24 +338,52 @@ class XmppService {
// Path -> Message
final messages = <String, Message>{};
// Path -> Thumbnails
final thumbnails = <String, List<Thumbnail>>{};
// Create the messages and shared media entries
final conn = GetIt.I.get<XmppConnection>();
for (final path in paths) {
final pathMime = lookupMimeType(path);
final msg = await ms.addMessageFromData(
'',
DateTime.now().millisecondsSinceEpoch,
conn.getConnectionSettings().jid.toString(),
recipient,
true,
true,
conn.generateId(),
false,
mediaUrl: path,
mediaType: lookupMimeType(path),
mediaType: pathMime,
originId: conn.generateId(),
);
messages[path] = msg;
sendEvent(MessageAddedEvent(message: msg.copyWith(isUploading: true)));
// TODO(PapaTutuWawa): Do this for videos
// TODO(PapaTutuWawa): Maybe do this in a separate isolate
if ((pathMime ?? '').startsWith('image/')) {
final image = decodeImage((await File(path).readAsBytes()).toList());
if (image != null) {
thumbnails[path] = [BlurhashThumbnail(BlurHash.encode(image).hash)];
} else {
_log.warning('Failed to generate thumbnail for $path');
}
}
// Send an upload notification
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
MessageDetails(
to: recipient,
fun: FileMetadataData(
// TODO(Unknown): Maybe add media type specific metadata
mediaType: lookupMimeType(path),
name: pathlib.basename(path),
size: File(path).statSync().size,
thumbnails: thumbnails[path] ?? [],
),
),
);
}
// Create the shared media entries
@ -387,6 +419,7 @@ class XmppService {
path,
await getDownloadPath(pathlib.basename(path), recipient, pathMime),
messages[path]!,
thumbnails[path] ?? [],
),
);
}
@ -568,14 +601,15 @@ class XmppService {
/// Return true if [event] describes a message that we want to display.
bool _isMessageEventMessage(MessageEvent event) {
return event.body.isNotEmpty || event.sfs != null || event.sims != null;
return event.body.isNotEmpty || event.sfs != null || event.sims != null || event.fun != null;
}
/// Extract the thumbnail data from a message, if existent.
String? _getThumbnailData(MessageEvent event) {
final thumbnails = firstNotNull([
event.sfs?.metadata.thumbnails,
event.sims?.thumbnails
event.sims?.thumbnails,
event.fun?.thumbnails,
]) ?? [];
for (final i in thumbnails) {
if (i is BlurhashThumbnail) {
@ -586,6 +620,35 @@ class XmppService {
return null;
}
/// Extract the mime guess from a message, if existent.
String? _getMimeGuess(MessageEvent event) {
return firstNotNull([
event.sfs?.metadata.mediaType,
event.sims?.mediaType,
event.fun?.mediaType,
]);
}
/// Returns true if a file is embedded in [event]. If not, returns false.
/// [embeddedFileUrl] is the possible Url of the file. If no file is present, then
/// [embeddedFileUrl] is null.
bool _isFileEmbedded(MessageEvent event, String? embeddedFileUrl) {
// True if we determine a file to be embedded. Checks if the Url is using HTTPS and
// that the message body and the OOB url are the same if the OOB url is not null.
return embeddedFileUrl != null
&& Uri.parse(embeddedFileUrl).scheme == 'https'
&& implies(event.oob != null, event.body == event.oob?.url);
}
/// Returns true if a file should be automatically downloaded. If it should not, it
/// returns false.
/// [conversationJid] refers to the JID of the conversation the message was received in.
Future<bool> _shouldDownloadFile(String conversationJid) async {
return (await Permission.storage.status).isGranted
&& await _automaticFileDownloadAllowed()
&& await GetIt.I.get<RosterService>().isInRoster(conversationJid);
}
Future<void> _onMessage(MessageEvent event, { dynamic extra }) async {
// The jid this message event is meant for
final conversationJid = event.isCarbon
@ -595,6 +658,12 @@ class XmppService {
// Process the chat state update. Can also be attached to other messages
if (event.chatState != null) await _onChatState(event.chatState!, conversationJid);
// Process File Upload Notifications replacements separately
if (event.funReplacement != null) {
await _handleFileUploadNotificationReplacement(event, conversationJid);
return;
}
// Stop the processing here if the event does not describe a displayable message
if (!_isMessageEventMessage(event)) return;
@ -631,13 +700,9 @@ class XmppService {
final embeddedFileUrl = _getMessageSrcUrl(event);
// True if we determine a file to be embedded. Checks if the Url is using HTTPS and
// that the message body and the OOB url are the same if the OOB url is not null.
final isFileEmbedded = embeddedFileUrl != null
&& Uri.parse(embeddedFileUrl).scheme == 'https'
&& implies(event.oob != null, event.body == event.oob?.url);
final isFileEmbedded = _isFileEmbedded(event, embeddedFileUrl);
// Indicates if we should auto-download the file, if a file is specified in the message
final shouldDownload = (await Permission.storage.status).isGranted
&& await _automaticFileDownloadAllowed()
&& isInRoster;
final shouldDownload = await _shouldDownloadFile(conversationJid);
// The thumbnail for the embedded file.
final thumbnailData = _getThumbnailData(event);
// Indicates if a notification should be created for the message.
@ -646,7 +711,7 @@ class XmppService {
// download to happen automatically, then the notification should happen immediately.
var shouldNotify = !(isFileEmbedded && isInRoster && shouldDownload);
// A guess for the Mime type of the embedded file.
String? mimeGuess;
var mimeGuess = _getMimeGuess(event);
// Create the message in the database
final ms = GetIt.I.get<MessageService>();
@ -655,21 +720,22 @@ class XmppService {
messageTimestamp,
event.fromJid.toString(),
conversationJid,
sent,
isFileEmbedded,
isFileEmbedded || event.fun != null,
event.sid,
event.fun != null,
srcUrl: embeddedFileUrl,
mediaType: mimeGuess,
thumbnailData: thumbnailData,
// TODO(Unknown): What about SIMS?
thumbnailDimensions: event.sfs?.metadata.dimensions,
thumbnailDimensions: event.sfs?.metadata.dimensions ?? event.fun?.dimensions,
quoteId: replyId,
filename: event.fun?.name,
);
// Attempt to auto-download the embedded file
if (isFileEmbedded && shouldDownload) {
final fts = GetIt.I.get<HttpFileTransferService>();
final metadata = await peekFile(embeddedFileUrl);
final metadata = await peekFile(embeddedFileUrl!);
if (metadata.mime != null) mimeGuess = metadata.mime;
@ -695,7 +761,7 @@ class XmppService {
final cs = GetIt.I.get<ConversationService>();
final ns = GetIt.I.get<NotificationsService>();
// The body to be displayed in the conversations list
final conversationBody = isFileEmbedded ? mimeTypeToConversationBody(mimeGuess) : messageBody;
final conversationBody = isFileEmbedded || message.isFileUploadNotification ? mimeTypeToConversationBody(mimeGuess) : messageBody;
// Specifies if we have the conversation this message goes to opened
final isConversationOpened = _currentlyOpenedChatJid == conversationJid;
// The conversation we're about to modify, if it exists
@ -754,7 +820,67 @@ class XmppService {
}
// Notify the UI of the message
sendEvent(MessageAddedEvent(message: message));
sendEvent(
MessageAddedEvent(
message: message.copyWith(
isDownloading: event.fun != null,
),
),
);
}
Future<void> _handleFileUploadNotificationReplacement(MessageEvent event, String conversationJid) async {
final ms = GetIt.I.get<MessageService>();
var message = await ms.getMessageByStanzaId(conversationJid, event.funReplacement!);
if (message == null) {
_log.warning('Received a FileUploadNotification replacement for unknown message');
return;
}
// Check if we can even replace the message
if (!message.isFileUploadNotification) {
_log.warning('Received a FileUploadNotification replacement for message that is not marked as a FileUploadNotification');
return;
}
// Check if the Jid is allowed to do so
// TODO(Unknown): Maybe use the JID parser?
final bareSender = event.fromJid.toBare().toString();
if (message.sender.split('/').first != bareSender) {
_log.warning('Received a FileUploadNotification replacement by $bareSender for message that is not sent by $bareSender');
return;
}
// The Url of the file embedded in the message, if there is one.
final embeddedFileUrl = _getMessageSrcUrl(event);
// Is there even a file we can download?
final isFileEmbedded = _isFileEmbedded(event, embeddedFileUrl);
if (isFileEmbedded) {
if (await _shouldDownloadFile(conversationJid)) {
message = message.copyWith(isDownloading: true);
await GetIt.I.get<HttpFileTransferService>().downloadFile(
FileDownloadJob(
embeddedFileUrl!,
message.id,
conversationJid,
null,
shouldShowNotification: false,
),
);
} else {
message = await ms.updateMessage(
message.id,
srcUrl: embeddedFileUrl,
isFileUploadNotification: false,
);
// Tell the UI
sendEvent(MessageUpdatedEvent(message: message.copyWith(isDownloading: false)));
}
} else {
_log.warning('Received a File Upload Notification replacement but the replacement contains no file!');
}
}
Future<void> _onRosterPush(RosterPushEvent event, { dynamic extra }) async {

View File

@ -1,4 +1,5 @@
import 'dart:core';
import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0085.dart';
import 'package:synchronized/synchronized.dart';
@ -302,3 +303,9 @@ extension ExceptionSafeLock on Lock {
}
}
}
/// Returns true if the message [message] was sent by us ([jid]). If not, returns false.
bool isSent(Message message, String jid) {
// TODO(PapaTutuWawa): Does this work?
return message.sender.split('/').first == jid.split('/').first;
}

View File

@ -10,14 +10,14 @@ class Message with _$Message {
// NOTE: srcUrl is the Url that a file has been or can be downloaded from
factory Message(
String from,
String sender,
String body,
int timestamp,
bool sent,
String sid,
int id,
String conversationJid,
bool isMedia,
bool isFileUploadNotification,
{
int? errorType,
String? mediaUrl,
@ -32,6 +32,7 @@ class Message with _$Message {
@Default(false) bool acked,
String? originId,
Message? quotes,
String? filename,
}
) = _Message;

View File

@ -45,6 +45,7 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
on<FilePickerRequestedEvent>(_onFilePickerRequested);
on<ScrollStateSetEvent>(_onScrollStateSet);
on<EmojiPickerToggledEvent>(_onEmojiPickerToggled);
on<OwnJidReceivedEvent>(_onOwnJidReceived);
}
/// The current chat state with the conversation partner
ChatState _currentChatState;
@ -113,6 +114,7 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
emit(
state.copyWith(
conversation: conversation,
quotedMessage: null,
),
);
@ -324,4 +326,8 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
}
}
}
Future<void> _onOwnJidReceived(OwnJidReceivedEvent event, Emitter<ConversationState> emit) async {
emit(state.copyWith(jid: event.jid));
}
}

View File

@ -120,3 +120,9 @@ class EmojiPickerToggledEvent extends ConversationEvent {
EmojiPickerToggledEvent({this.handleKeyboard = true});
final bool handleKeyboard;
}
/// Triggered when we received our own JID
class OwnJidReceivedEvent extends ConversationEvent {
OwnJidReceivedEvent(this.jid);
final String jid;
}

View File

@ -3,6 +3,8 @@ part of 'conversation_bloc.dart';
@freezed
class ConversationState with _$ConversationState {
factory ConversationState({
// Our own JID
@Default('') String jid,
@Default('') String messageText,
@Default(false) bool showSendButton,
@Default(null) Message? quotedMessage,

View File

@ -2,6 +2,7 @@ import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_speed_dial/flutter_speed_dial.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/helpers.dart';
@ -55,6 +56,7 @@ class ConversationBottomRow extends StatelessWidget {
controller: controller,
topWidget: state.quotedMessage != null ? buildQuoteMessageWidget(
state.quotedMessage!,
isSent(state.quotedMessage!, state.jid),
resetQuote: () => context.read<ConversationBloc>().add(QuoteRemovedEvent()),
) : null,
shouldSummonKeyboard: () => !state.emojiPickerVisible,

View File

@ -2,6 +2,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/helpers.dart';
@ -52,18 +53,22 @@ class ConversationPageState extends State<ConversationPage> {
super.dispose();
}
Widget _renderBubble(ConversationState state, BuildContext context, int _index, double maxWidth) {
Widget _renderBubble(ConversationState state, BuildContext context, int _index, double maxWidth, String jid) {
// TODO(Unknown): Since we reverse the list: Fix start, end and between
final index = state.messages.length - 1 - _index;
final item = state.messages[index];
final start = index - 1 < 0 ? true : state.messages[index - 1].sent != item.sent;
final end = index + 1 >= state.messages.length ? true : state.messages[index + 1].sent != item.sent;
final start = index - 1 < 0 ?
true :
isSent(state.messages[index - 1], jid) != isSent(item, jid);
final end = index + 1 >= state.messages.length ?
true :
isSent(state.messages[index + 1], jid) != isSent(item, jid);
final between = !start && !end;
final lastMessageTimestamp = index > 0 ? state.messages[index - 1].timestamp : null;
return ChatBubble(
message: item,
sentBySelf: item.sent,
sentBySelf: isSent(item, jid),
start: start,
end: end,
between: between,
@ -201,12 +206,20 @@ class ConversationPageState extends State<ConversationPage> {
),
BlocBuilder<ConversationBloc, ConversationState>(
// NOTE: We don't need to update when the jid changes as it should
// be static over the entire lifetime of the BLoC.
buildWhen: (prev, next) => prev.messages != next.messages,
builder: (context, state) => Expanded(
child: ListView.builder(
reverse: true,
itemCount: state.messages.length,
itemBuilder: (context, index) => _renderBubble(state, context, index, maxWidth),
itemBuilder: (context, index) => _renderBubble(
state,
context,
index,
maxWidth,
state.jid,
),
shrinkWrap: true,
controller: _scrollController,
),

View File

@ -3,6 +3,7 @@ import 'dart:async';
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart';
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
import 'package:moxxyv2/ui/bloc/newconversation_bloc.dart';
@ -33,6 +34,7 @@ Future<void> preStartDone(PreStartDoneEvent result, { dynamic extra }) async {
result.roster!,
),
);
GetIt.I.get<ConversationBloc>().add(OwnJidReceivedEvent(result.jid!));
GetIt.I.get<Logger>().finest('Navigating to conversations');
GetIt.I.get<NavigationBloc>().add(

View File

@ -8,8 +8,9 @@ import 'package:moxxyv2/ui/constants.dart';
class MessageBubbleBottom extends StatefulWidget {
const MessageBubbleBottom(this.message, { Key? key }): super(key: key);
const MessageBubbleBottom(this.message, this.sent, { Key? key }): super(key: key);
final Message message;
final bool sent;
@override
MessageBubbleBottomState createState() => MessageBubbleBottomState();
@ -54,17 +55,17 @@ class MessageBubbleBottomState extends State<MessageBubbleBottom> {
}
bool _showBlueCheckmarks() {
return widget.message.sent && widget.message.displayed;
return widget.sent && widget.message.displayed;
}
bool _showCheckmarks() {
return widget.message.sent &&
return widget.sent &&
widget.message.received &&
!widget.message.displayed;
}
bool _showCheckmark() {
return widget.message.sent &&
return widget.sent &&
widget.message.acked &&
!widget.message.received &&
!widget.message.displayed;

View File

@ -156,7 +156,7 @@ class ChatBubbleState extends State<ChatBubble>
child: Padding(
// NOTE: Images don't work well with padding here
padding: widget.message.isMedia || widget.message.quotes != null ? EdgeInsets.zero : const EdgeInsets.all(8),
child: buildMessageWidget(widget.message, widget.maxWidth, _getBorderRadius()),
child: buildMessageWidget(widget.message, widget.maxWidth, _getBorderRadius(), widget.sentBySelf),
),
)
],

View File

@ -18,6 +18,7 @@ class FileChatBaseWidget extends StatelessWidget {
this.icon,
this.filename,
this.radius,
this.sent,
{
this.extra,
this.onTap,
@ -29,6 +30,7 @@ class FileChatBaseWidget extends StatelessWidget {
final String filename;
final BorderRadius radius;
final Widget? extra;
final bool sent;
final void Function()? onTap;
@override
@ -51,7 +53,7 @@ class FileChatBaseWidget extends StatelessWidget {
],
),
),
MessageBubbleBottom(message),
MessageBubbleBottom(message, sent),
radius,
gradient: false,
extra: extra,
@ -67,6 +69,7 @@ class FileChatWidget extends StatelessWidget {
const FileChatWidget(
this.message,
this.radius,
this.sent,
{
this.extra,
Key? key,
@ -74,14 +77,16 @@ class FileChatWidget extends StatelessWidget {
) : super(key: key);
final Message message;
final BorderRadius radius;
final bool sent;
final Widget? extra;
Widget _buildNonDownloaded() {
return FileChatBaseWidget(
message,
Icons.file_present,
filenameFromUrl(message.srcUrl!),
message.isFileUploadNotification ? (message.filename ?? '') : filenameFromUrl(message.srcUrl!),
radius,
sent,
extra: DownloadButton(
onPressed: () {
MoxplatformPlugin.handler.getDataSender().sendData(
@ -97,8 +102,9 @@ class FileChatWidget extends StatelessWidget {
return FileChatBaseWidget(
message,
Icons.file_present,
filenameFromUrl(message.srcUrl!),
message.isFileUploadNotification ? (message.filename ?? '') : filenameFromUrl(message.srcUrl!),
radius,
sent,
extra: ProgressWidget(id: message.id),
);
}
@ -107,8 +113,9 @@ class FileChatWidget extends StatelessWidget {
return FileChatBaseWidget(
message,
Icons.file_present,
filenameFromUrl(message.srcUrl!),
message.isFileUploadNotification ? (message.filename ?? '') : filenameFromUrl(message.srcUrl!),
radius,
sent,
onTap: () {
OpenFile.open(message.mediaUrl);
},
@ -117,7 +124,7 @@ class FileChatWidget extends StatelessWidget {
Widget _buildWrapper() {
if (!message.isDownloading && message.mediaUrl != null) return _buildInner();
if (message.isDownloading) return _buildDownloading();
if (message.isFileUploadNotification || message.isDownloading) return _buildDownloading();
return _buildNonDownloaded();
}

View File

@ -19,6 +19,7 @@ class ImageChatWidget extends StatelessWidget {
this.message,
this.radius,
this.maxWidth,
this.sent,
{
Key? key,
}
@ -26,11 +27,12 @@ class ImageChatWidget extends StatelessWidget {
final Message message;
final BorderRadius radius;
final double maxWidth;
final bool sent;
Widget _buildUploading() {
return MediaBaseChatWidget(
Image.file(File(message.mediaUrl!)),
MessageBubbleBottom(message),
MessageBubbleBottom(message, sent),
radius,
extra: ProgressWidget(id: message.id),
);
@ -50,7 +52,7 @@ class ImageChatWidget extends StatelessWidget {
decodingHeight: thumbnailSize.height.toInt(),
),
),
MessageBubbleBottom(message),
MessageBubbleBottom(message, sent),
radius,
extra: ProgressWidget(id: message.id),
);
@ -58,8 +60,9 @@ class ImageChatWidget extends StatelessWidget {
return FileChatBaseWidget(
message,
Icons.image,
filenameFromUrl(message.srcUrl!),
message.isFileUploadNotification ? (message.filename ?? '') : filenameFromUrl(message.srcUrl!),
radius,
sent,
extra: ProgressWidget(id: message.id),
);
}
@ -69,7 +72,7 @@ class ImageChatWidget extends StatelessWidget {
Widget _buildImage() {
return MediaBaseChatWidget(
Image.file(File(message.mediaUrl!)),
MessageBubbleBottom(message),
MessageBubbleBottom(message, sent),
radius,
onTap: () {
OpenFile.open(message.mediaUrl);
@ -91,7 +94,7 @@ class ImageChatWidget extends StatelessWidget {
decodingHeight: thumbnailSize.height.toInt(),
),
),
MessageBubbleBottom(message),
MessageBubbleBottom(message, sent),
radius,
extra: DownloadButton(
onPressed: () => requestMediaDownload(message),
@ -101,8 +104,9 @@ class ImageChatWidget extends StatelessWidget {
return FileChatBaseWidget(
message,
Icons.image,
filenameFromUrl(message.srcUrl!),
message.isFileUploadNotification ? (message.filename ?? '') : filenameFromUrl(message.srcUrl!),
radius,
sent,
extra: DownloadButton(
onPressed: () {
MoxplatformPlugin.handler.getDataSender().sendData(
@ -118,10 +122,10 @@ class ImageChatWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (message.isUploading) return _buildUploading();
if (message.isDownloading) return _buildDownloading();
if (message.isFileUploadNotification || message.isDownloading) return _buildDownloading();
// TODO(PapaTutuWawa): Maybe use an async builder
if (File(message.mediaUrl!).existsSync()) return _buildImage();
if (message.mediaUrl != null && File(message.mediaUrl!).existsSync()) return _buildImage();
return _buildDownloadable();
}

View File

@ -47,33 +47,34 @@ MessageType getMessageType(Message message) {
}
/// Build an inlinable message widget
Widget buildMessageWidget(Message message, double maxWidth, BorderRadius radius) {
Widget buildMessageWidget(Message message, double maxWidth, BorderRadius radius, bool sent) {
switch (getMessageType(message)) {
case MessageType.text: {
return TextChatWidget(
message,
topWidget: message.quotes != null ? buildQuoteMessageWidget(message.quotes!) : null,
sent,
topWidget: message.quotes != null ? buildQuoteMessageWidget(message.quotes!, sent) : null,
);
}
case MessageType.image: {
return ImageChatWidget(message, radius, maxWidth);
return ImageChatWidget(message, radius, maxWidth, sent);
}
case MessageType.video: {
return VideoChatWidget(message, radius, maxWidth);
return VideoChatWidget(message, radius, maxWidth, sent);
}
// TODO(Unknown): Implement audio
//case MessageType.audio: return buildImageMessageWidget(message);
case MessageType.file: {
return FileChatWidget(message, radius);
return FileChatWidget(message, radius, sent);
}
}
}
/// Build a widget that represents a quoted message within another bubble.
Widget buildQuoteMessageWidget(Message message, { void Function()? resetQuote}) {
Widget buildQuoteMessageWidget(Message message, bool sent, { void Function()? resetQuote}) {
switch (getMessageType(message)) {
case MessageType.text:
return QuoteBaseWidget(message, Text(message.body), resetQuotedMessage: resetQuote);
return QuoteBaseWidget(message, Text(message.body), sent, resetQuotedMessage: resetQuote);
case MessageType.image:
return QuoteBaseWidget(
message,
@ -90,6 +91,7 @@ Widget buildQuoteMessageWidget(Message message, { void Function()? resetQuote})
),
],
),
sent,
resetQuotedMessage: resetQuote,
);
case MessageType.video:
@ -136,6 +138,7 @@ Widget buildQuoteMessageWidget(Message message, { void Function()? resetQuote})
)
],
),
sent,
resetQuotedMessage: resetQuote,
);
// TODO(Unknown): Implement audio
@ -170,6 +173,7 @@ Widget buildQuoteMessageWidget(Message message, { void Function()? resetQuote})
)
],
),
sent,
resetQuotedMessage: resetQuote,
);
}

View File

@ -21,6 +21,7 @@ class VideoChatWidget extends StatelessWidget {
this.message,
this.radius,
this.maxWidth,
this.sent,
{
Key? key,
}
@ -28,6 +29,7 @@ class VideoChatWidget extends StatelessWidget {
final Message message;
final double maxWidth;
final BorderRadius radius;
final bool sent;
Widget _buildUploading() {
return MediaBaseChatWidget(
@ -35,7 +37,7 @@ class VideoChatWidget extends StatelessWidget {
message.mediaUrl!,
Image.memory,
),
MessageBubbleBottom(message),
MessageBubbleBottom(message, sent),
radius,
extra: ProgressWidget(id: message.id),
);
@ -55,7 +57,7 @@ class VideoChatWidget extends StatelessWidget {
decodingHeight: thumbnailSize.height.toInt(),
),
),
MessageBubbleBottom(message),
MessageBubbleBottom(message, sent),
radius,
extra: ProgressWidget(id: message.id),
);
@ -63,8 +65,9 @@ class VideoChatWidget extends StatelessWidget {
return FileChatBaseWidget(
message,
Icons.video_file_outlined,
filenameFromUrl(message.srcUrl!),
message.isFileUploadNotification ? (message.filename ?? '') : filenameFromUrl(message.srcUrl!),
radius,
sent,
extra: ProgressWidget(id: message.id),
);
}
@ -77,7 +80,7 @@ class VideoChatWidget extends StatelessWidget {
message.mediaUrl!,
Image.memory,
),
MessageBubbleBottom(message),
MessageBubbleBottom(message, sent),
radius,
onTap: () {
OpenFile.open(message.mediaUrl);
@ -100,7 +103,7 @@ class VideoChatWidget extends StatelessWidget {
decodingHeight: thumbnailSize.height.toInt(),
),
),
MessageBubbleBottom(message),
MessageBubbleBottom(message, sent),
radius,
extra: DownloadButton(
onPressed: () => requestMediaDownload(message),
@ -110,8 +113,9 @@ class VideoChatWidget extends StatelessWidget {
return FileChatBaseWidget(
message,
Icons.video_file_outlined,
filenameFromUrl(message.srcUrl!),
message.isFileUploadNotification ? (message.filename ?? '') : filenameFromUrl(message.srcUrl!),
radius,
sent,
extra: DownloadButton(
onPressed: () {
MoxplatformPlugin.handler.getDataSender().sendData(
@ -127,10 +131,10 @@ class VideoChatWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (message.isUploading) return _buildUploading();
if (message.isDownloading) return _buildDownloading();
if (message.isFileUploadNotification || message.isDownloading) return _buildDownloading();
// TODO(PapaTutuWawa): Maybe use an async builder
if (File(message.mediaUrl!).existsSync()) return _buildVideo();
if (message.mediaUrl != null && File(message.mediaUrl!).existsSync()) return _buildVideo();
return _buildDownloadable();
}

View File

@ -11,6 +11,7 @@ class QuoteBaseWidget extends StatelessWidget {
const QuoteBaseWidget(
this.message,
this.child,
this.sent,
{
this.resetQuotedMessage,
Key? key,
@ -18,10 +19,11 @@ class QuoteBaseWidget extends StatelessWidget {
) : super(key: key);
final Message message;
final Widget child;
final bool sent;
final void Function()? resetQuotedMessage;
Color _getColor() {
if (message.sent) {
if (sent) {
return bubbleColorSentQuoted;
} else {
return bubbleColorReceivedQuoted;

View File

@ -10,12 +10,14 @@ class TextChatWidget extends StatelessWidget {
const TextChatWidget(
this.message,
this.sent,
{
this.topWidget,
Key? key,
}
) : super(key: key);
final Message message;
final bool sent;
final Widget? topWidget;
@override
@ -40,7 +42,7 @@ class TextChatWidget extends StatelessWidget {
),
Padding(
padding: topWidget != null ? const EdgeInsets.only(left: 8, right: 8, bottom: 8) : EdgeInsets.zero,
child: MessageBubbleBottom(message),
child: MessageBubbleBottom(message, sent),
)
],
),

View File

@ -7,6 +7,7 @@ import 'package:moxxyv2/xmpp/xeps/xep_0066.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0085.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0359.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0385.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0446.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0447.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0461.dart';
@ -55,20 +56,23 @@ class StreamResumeFailedEvent extends XmppEvent {}
class MessageEvent extends XmppEvent {
MessageEvent({
required this.body,
required this.fromJid,
required this.toJid,
required this.sid,
required this.stanzaId,
required this.isCarbon,
required this.deliveryReceiptRequested,
required this.isMarkable,
this.type,
this.oob,
this.sfs,
this.sims,
this.reply,
this.chatState,
required this.body,
required this.fromJid,
required this.toJid,
required this.sid,
required this.stanzaId,
required this.isCarbon,
required this.deliveryReceiptRequested,
required this.isMarkable,
this.type,
this.oob,
this.sfs,
this.sims,
this.reply,
this.chatState,
this.fun,
this.funReplacement,
this.funCancellation,
});
final String body;
final JID fromJid;
@ -84,6 +88,9 @@ class MessageEvent extends XmppEvent {
final StatelessMediaSharingData? sims;
final ReplyData? reply;
final ChatState? chatState;
final FileMetadataData? fun;
final String? funReplacement;
final String? funCancellation;
}
/// Triggered when a client responds to our delivery receipt request

View File

@ -5,6 +5,7 @@ import 'package:moxxyv2/xmpp/xeps/xep_0066.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0085.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0359.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0385.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0446.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0447.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0461.dart';
@ -32,6 +33,13 @@ class StanzaHandlerData with _$StanzaHandlerData {
@Default(false) bool isCarbon,
@Default(false) bool deliveryReceiptRequested,
@Default(false) bool isMarkable,
// File Upload Notifications
// A notification
FileMetadataData? fun,
// The stanza id this replaces
String? funReplacement,
// The stanza id this cancels
String? funCancellation,
// This is for stanza handlers that are not part of the XMPP library but still need
// pass data around.
@Default(<String, dynamic>{}) Map<String, dynamic> other,

View File

@ -19,3 +19,4 @@ const blockingManager = 'im.moxxy.blockingmanager';
const httpFileUploadManager = 'im.moxxy.httpfileuploadmanager';
const chatStateManager = 'im.moxxy.chatstatemanager';
const pingManager = 'im.moxxy.ping';
const fileUploadNotificationManager = 'im.moxxy.fileuploadnotificationmanager';

View File

@ -8,30 +8,35 @@ import 'package:moxxyv2/xmpp/managers/namespaces.dart';
import 'package:moxxyv2/xmpp/namespaces.dart';
import 'package:moxxyv2/xmpp/stanza.dart';
import 'package:moxxyv2/xmpp/stringxml.dart';
import 'package:moxxyv2/xmpp/xeps/staging/file_upload_notification.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0066.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0085.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0184.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0333.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0359.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0446.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0447.dart';
class MessageDetails {
const MessageDetails({
required this.to,
required this.body,
this.requestDeliveryReceipt = false,
this.requestChatMarkers = true,
this.id,
this.originId,
this.quoteBody,
this.quoteId,
this.quoteFrom,
this.chatState,
this.sfs,
required this.to,
this.body,
this.requestDeliveryReceipt = false,
this.requestChatMarkers = true,
this.id,
this.originId,
this.quoteBody,
this.quoteId,
this.quoteFrom,
this.chatState,
this.sfs,
this.fun,
this.funReplacement,
this.funCancellation,
});
final String to;
final String body;
final String? body;
final bool requestDeliveryReceipt;
final bool requestChatMarkers;
final String? id;
@ -41,6 +46,9 @@ class MessageDetails {
final String? quoteFrom;
final ChatState? chatState;
final StatelessFileSharingData? sfs;
final FileMetadataData? fun;
final String? funReplacement;
final String? funCancellation;
}
class MessageManager extends XmppManagerBase {
@ -63,7 +71,6 @@ class MessageManager extends XmppManagerBase {
Future<bool> isSupported() async => true;
Future<StanzaHandlerData> _onMessage(Stanza _, StanzaHandlerData state) async {
// First check if it's a carbon
final message = state.stanza;
final body = message.firstTag('body');
@ -82,6 +89,9 @@ class MessageManager extends XmppManagerBase {
sims: state.sims,
reply: state.reply,
chatState: state.chatState,
fun: state.fun,
funReplacement: state.funReplacement,
funCancellation: state.funCancellation,
),);
return state.copyWith(done: true);
@ -158,7 +168,7 @@ class MessageManager extends XmppManagerBase {
if (details.sfs != null) {
stanza
..addChild(constructSFSElement(details.sfs!))
..addChild(details.sfs!.toXML())
// SFS recommends OOB as a fallback
..addChild(constructOOBNode(OOBData(url: details.sfs!.url)),);
}
@ -170,6 +180,30 @@ class MessageManager extends XmppManagerBase {
);
}
if (details.fun != null) {
stanza.addChild(
XMLNode.xmlns(
tag: 'file-upload',
xmlns: fileUploadNotificationXmlns,
children: [
details.fun!.toXML(),
],
),
);
}
if (details.funReplacement != null) {
stanza.addChild(
XMLNode.xmlns(
tag: 'replaces',
xmlns: fileUploadNotificationXmlns,
attributes: <String, String>{
'id': details.funReplacement!,
},
),
);
}
getAttributes().sendStanza(stanza, awaitable: false);
}
}

View File

@ -1,15 +1,15 @@
import 'package:moxxyv2/xmpp/stringxml.dart';
/// NOTE: Specified by https://github.com/PapaTutuWawa/custom-xeps/blob/master/xep-xxxx-file-thumbnails.md
/// NOTE: Specified by https://codeberg.org/moxxy/custom-xeps/src/branch/master/xep-xxxx-extensible-file-thumbnails.md
const fileThumbnailsXmlns = 'proto:urn:xmpp:file-thumbnails:0';
const blurhashThumbnailType = 'blurhash';
const fileThumbnailsXmlns = 'proto:urn:xmpp:eft:0';
const blurhashThumbnailType = '$fileThumbnailsXmlns:blurhash';
abstract class Thumbnail {}
class BlurhashThumbnail extends Thumbnail {
BlurhashThumbnail({ required this.hash });
BlurhashThumbnail(this.hash);
final String hash;
}
@ -20,7 +20,7 @@ Thumbnail? parseFileThumbnailElement(XMLNode node) {
switch (node.attributes['type']!) {
case blurhashThumbnailType: {
final hash = node.firstTag('blurhash')!.innerText();
return BlurhashThumbnail(hash: hash);
return BlurhashThumbnail(hash);
}
}
@ -42,7 +42,7 @@ XMLNode constructFileThumbnailElement(Thumbnail thumbnail) {
final node = _fromThumbnail(thumbnail)!;
var type = '';
if (thumbnail is BlurhashThumbnail) {
type = 'blurhash';
type = blurhashThumbnailType;
}
return XMLNode.xmlns(

View File

@ -1,39 +1,69 @@
import 'package:moxxyv2/xmpp/managers/base.dart';
import 'package:moxxyv2/xmpp/managers/data.dart';
import 'package:moxxyv2/xmpp/managers/handlers.dart';
import 'package:moxxyv2/xmpp/managers/namespaces.dart';
import 'package:moxxyv2/xmpp/namespaces.dart';
import 'package:moxxyv2/xmpp/stringxml.dart';
import 'package:moxxyv2/xmpp/xeps/staging/file_thumbnails.dart';
import 'package:moxxyv2/xmpp/stanza.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0446.dart';
/// NOTE: Specified by https://github.com/PapaTutuWawa/custom-xeps/blob/master/xep-xxxx-file-upload-notifications.md
const fileUploadNotificationXmlns = 'proto:urn:xmpp:fun:0';
class FileUploadNotificationData {
class FileUploadNotificationManager extends XmppManagerBase {
FileUploadNotificationManager() : super();
const FileUploadNotificationData({ required this.metadata, required this.thumbnails });
final FileMetadataData metadata;
final List<Thumbnail> thumbnails;
}
XMLNode constructFileUploadNotification(FileMetadataData metadata, List<Thumbnail> thumbnails) {
final thumbnailNodes = thumbnails.map(constructFileThumbnailElement).toList();
return XMLNode.xmlns(
tag: 'file-upload',
xmlns: fileUploadNotificationXmlns,
children: [
constructFileMetadataElement(metadata),
...thumbnailNodes
],
);
}
FileUploadNotificationData parseFileUploadNotification(XMLNode node) {
assert(node.attributes['xmlns'] == fileUploadNotificationXmlns, 'Invalid element xmlns');
assert(node.tag == 'file-upload', 'Invalid element name');
final thumbnails = node.findTags('thumbnail', xmlns: fileThumbnailsXmlns).map((t) => parseFileThumbnailElement(t)!).toList();
return FileUploadNotificationData(
metadata: parseFileMetadataElement(node.firstTag('file', xmlns: fileMetadataXmlns)!),
thumbnails: thumbnails,
);
@override
String getId() => fileUploadNotificationManager;
@override
String getName() => 'FileUploadNotificationManager';
@override
List<StanzaHandler> getIncomingStanzaHandlers() => [
StanzaHandler(
stanzaTag: 'message',
tagName: 'file-upload',
tagXmlns: fileUploadNotificationXmlns,
callback: _onFileUploadNotificationReceived,
),
StanzaHandler(
stanzaTag: 'message',
tagName: 'replaces',
tagXmlns: fileUploadNotificationXmlns,
callback: _onFileUploadNotificationReplacementReceived,
),
StanzaHandler(
stanzaTag: 'message',
tagName: 'cancelled',
tagXmlns: fileUploadNotificationXmlns,
callback: _onFileUploadNotificationCancellationReceived,
),
];
@override
Future<bool> isSupported() async => true;
Future<StanzaHandlerData> _onFileUploadNotificationReceived(Stanza message, StanzaHandlerData state) async {
final funElement = message.firstTag('file-upload', xmlns: fileUploadNotificationXmlns)!;
return state.copyWith(
fun: FileMetadataData.fromXML(
funElement.firstTag('file', xmlns: fileMetadataXmlns)!,
),
);
}
Future<StanzaHandlerData> _onFileUploadNotificationReplacementReceived(Stanza message, StanzaHandlerData state) async {
final element = message.firstTag('replaces', xmlns: fileUploadNotificationXmlns)!;
return state.copyWith(
funReplacement: element.attributes['id']! as String,
);
}
Future<StanzaHandlerData> _onFileUploadNotificationCancellationReceived(Stanza message, StanzaHandlerData state) async {
final element = message.firstTag('cancels', xmlns: fileUploadNotificationXmlns)!;
return state.copyWith(
funCancellation: element.attributes['id']! as String,
);
}
}

View File

@ -5,7 +5,7 @@ import 'package:moxxyv2/xmpp/managers/namespaces.dart';
import 'package:moxxyv2/xmpp/namespaces.dart';
import 'package:moxxyv2/xmpp/stanza.dart';
import 'package:moxxyv2/xmpp/stringxml.dart';
import 'package:moxxyv2/xmpp/xeps/staging/file_thumbnails.dart';
import 'package:moxxyv2/xmpp/xeps/staging/extensible_file_thumbnails.dart';
class StatelessMediaSharingData {

View File

@ -1,6 +1,6 @@
import 'package:moxxyv2/xmpp/namespaces.dart';
import 'package:moxxyv2/xmpp/stringxml.dart';
import 'package:moxxyv2/xmpp/xeps/staging/file_thumbnails.dart';
import 'package:moxxyv2/xmpp/xeps/staging/extensible_file_thumbnails.dart';
import 'package:moxxyv2/xmpp/xeps/xep_0300.dart';
class FileMetadataData {
@ -15,6 +15,43 @@ class FileMetadataData {
required this.thumbnails,
Map<String, String>? hashes,
}) : hashes = hashes ?? const {};
/// Parse [node] as a FileMetadataData element.
factory FileMetadataData.fromXML(XMLNode node) {
assert(node.attributes['xmlns'] == fileMetadataXmlns, 'Invalid element xmlns');
assert(node.tag == 'file', 'Invalid element anme');
final lengthElement = node.firstTag('length');
final length = lengthElement != null ? int.parse(lengthElement.innerText()) : null;
final sizeElement = node.firstTag('size');
final size = sizeElement != null ? int.parse(sizeElement.innerText()) : null;
final hashes = <String, String>{};
for (final e in node.findTags('hash')) {
hashes[e.attributes['algo']! as String] = e.innerText();
}
// Thumbnails
final thumbnails = List<Thumbnail>.empty(growable: true);
for (final i in node.findTags('file-thumbnail')) {
final thumbnail = parseFileThumbnailElement(i);
if (thumbnail != null) {
thumbnails.add(thumbnail);
}
}
return FileMetadataData(
mediaType: node.firstTag('media-type')?.innerText(),
dimensions: node.firstTag('dimensions')?.innerText(),
desc: node.firstTag('desc')?.innerText(),
hashes: hashes,
length: length,
name: node.firstTag('name')?.innerText(),
size: size,
thumbnails: thumbnails,
);
}
final String? mediaType;
// TODO(Unknown): Maybe create a special type for this
@ -26,70 +63,33 @@ class FileMetadataData {
final int? length;
final String? name;
final int? size;
}
FileMetadataData parseFileMetadataElement(XMLNode node) {
assert(node.attributes['xmlns'] == fileMetadataXmlns, 'Invalid element xmlns');
assert(node.tag == 'file', 'Invalid element anme');
XMLNode toXML() {
final node = XMLNode.xmlns(
tag: 'file',
xmlns: fileMetadataXmlns,
children: List.empty(growable: true),
);
final lengthElement = node.firstTag('length');
final length = lengthElement != null ? int.parse(lengthElement.innerText()) : null;
final sizeElement = node.firstTag('size');
final size = sizeElement != null ? int.parse(sizeElement.innerText()) : null;
if (mediaType != null) node.addChild(XMLNode(tag: 'media-type', text: mediaType));
if (dimensions != null) node.addChild(XMLNode(tag: 'dimensions', text: dimensions));
if (desc != null) node.addChild(XMLNode(tag: 'desc', text: desc));
if (length != null) node.addChild(XMLNode(tag: 'length', text: length.toString()));
if (name != null) node.addChild(XMLNode(tag: 'name', text: name));
if (size != null) node.addChild(XMLNode(tag: 'size', text: size.toString()));
final hashes = <String, String>{};
for (final e in node.findTags('hash')) {
hashes[e.attributes['algo']! as String] = e.innerText();
}
// Thumbnails
final thumbnails = List<Thumbnail>.empty(growable: true);
for (final i in node.findTags('file-thumbnail')) {
final thumbnail = parseFileThumbnailElement(i);
if (thumbnail != null) {
thumbnails.add(thumbnail);
}
}
return FileMetadataData(
mediaType: node.firstTag('media-type')?.innerText(),
dimensions: node.firstTag('dimensions')?.innerText(),
desc: node.firstTag('desc')?.innerText(),
hashes: hashes,
length: length,
name: node.firstTag('name')?.innerText(),
size: size,
thumbnails: thumbnails,
);
}
XMLNode constructFileMetadataElement(FileMetadataData data) {
final node = XMLNode.xmlns(
tag: 'file',
xmlns: fileMetadataXmlns,
children: List.empty(growable: true),
);
if (data.mediaType != null) node.addChild(XMLNode(tag: 'media-type', text: data.mediaType));
if (data.dimensions != null) node.addChild(XMLNode(tag: 'dimensions', text: data.dimensions));
if (data.desc != null) node.addChild(XMLNode(tag: 'desc', text: data.desc));
if (data.length != null) node.addChild(XMLNode(tag: 'length', text: data.length.toString()));
if (data.name != null) node.addChild(XMLNode(tag: 'name', text: data.name));
if (data.size != null) node.addChild(XMLNode(tag: 'size', text: data.size.toString()));
if (data.hashes.isNotEmpty) {
for (final hash in data.hashes.entries) {
for (final hash in hashes.entries) {
node.addChild(
constructHashElement(hash.key, hash.value),
);
}
}
if (data.thumbnails.isNotEmpty) {
for (final thumbnail in data.thumbnails) {
for (final thumbnail in thumbnails) {
node.addChild(
constructFileThumbnailElement(thumbnail),
);
}
}
return node;
return node;
}
}

View File

@ -9,46 +9,47 @@ import 'package:moxxyv2/xmpp/xeps/xep_0446.dart';
class StatelessFileSharingData {
const StatelessFileSharingData({ required this.metadata, required this.url });
const StatelessFileSharingData(this.metadata, this.url);
/// Parse [node] as a StatelessFileSharingData element.
factory StatelessFileSharingData.fromXML(XMLNode node) {
assert(node.attributes['xmlns'] == sfsXmlns, 'Invalid element xmlns');
assert(node.tag == 'file-sharing', 'Invalid element name');
final sources = node.firstTag('sources')!;
final urldata = sources.firstTag('url-data', xmlns: urlDataXmlns);
final url = urldata!.attributes['target']! as String;
return StatelessFileSharingData(
FileMetadataData.fromXML(node.firstTag('file')!),
url,
);
}
final FileMetadataData metadata;
final String url;
}
StatelessFileSharingData parseSFSElement(XMLNode node) {
assert(node.attributes['xmlns'] == sfsXmlns, 'Invalid element xmlns');
assert(node.tag == 'file-sharing', 'Invalid element name');
final metadata = parseFileMetadataElement(node.firstTag('file')!);
final sources = node.firstTag('sources')!;
final urldata = sources.firstTag('url-data', xmlns: urlDataXmlns);
final url = urldata!.attributes['target']! as String;
return StatelessFileSharingData(
metadata: metadata,
url: url,
);
}
XMLNode constructSFSElement(StatelessFileSharingData data) {
return XMLNode.xmlns(
tag: 'file-sharing',
xmlns: sfsXmlns,
children: [
constructFileMetadataElement(data.metadata),
XMLNode(
tag: 'sources',
children: [
XMLNode.xmlns(
tag: 'url-data',
xmlns: urlDataXmlns,
attributes: <String, String>{
'target': data.url,
},
),
],
),
],
);
XMLNode toXML() {
return XMLNode.xmlns(
tag: 'file-sharing',
xmlns: sfsXmlns,
children: [
metadata.toXML(),
XMLNode(
tag: 'sources',
children: [
XMLNode.xmlns(
tag: 'url-data',
xmlns: urlDataXmlns,
attributes: <String, String>{
'target': url,
},
),
],
),
],
);
}
}
class SFSManager extends XmppManagerBase {
@ -77,7 +78,7 @@ class SFSManager extends XmppManagerBase {
final sfs = message.firstTag('file-sharing', xmlns: sfsXmlns)!;
return state.copyWith(
sfs: parseSFSElement(sfs),
sfs: StatelessFileSharingData.fromXML(sfs),
);
}
}

View File

@ -184,18 +184,18 @@
</implements>
<implements>
<xmpp:SupportedXep>
<xmpp:xep rdf:resource="https://github.com/PapaTutuWawa/custom-xeps/blob/master/xep-xxxx-file-thumbnails.md" />
<xmpp:status>complete</xmpp:status>
<xmpp:version>0.0.3</xmpp:version>
<xmpp:note xml:lang="en">Read-only implementation</xmpp:note>
<xmpp:xep rdf:resource="https://codeberg.org/moxxy/custom-xeps/src/branch/master/xep-xxxx-extensible-file-thumbnails.md" />
<xmpp:status>partial</xmpp:status>
<xmpp:version>0.2.1</xmpp:version>
<xmpp:note xml:lang="en">Only Blurhash is implemented</xmpp:note>
</xmpp:SupportedXep>
</implements>
<implements>
<xmpp:SupportedXep>
<xmpp:xep rdf:resource="https://github.com/PapaTutuWawa/custom-xeps/blob/master/xep-xxxx-file-upload-notification.md" />
<xmpp:status>complete</xmpp:status>
<xmpp:version>0.0.4</xmpp:version>
<xmpp:note xml:lang="en">Not-connected read-only implementation</xmpp:note>
<xmpp:xep rdf:resource="https://codeberg.org/moxxy/custom-xeps/src/branch/master/xep-xxxx-file-upload-notification.md" />
<xmpp:status>partial</xmpp:status>
<xmpp:version>0.0.5</xmpp:version>
<xmpp:note xml:lang="en">Sending and receiving implemented; cancellation not implemented</xmpp:note>
</xmpp:SupportedXep>
</implements>
</Project>

View File

@ -50,6 +50,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "8.1.0"
blurhash_dart:
dependency: "direct main"
description:
name: blurhash_dart
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.0"
boolean_selector:
dependency: transitive
description:

View File

@ -13,6 +13,7 @@ dependencies:
archive: 3.3.1
badges: 2.0.3
bloc: 8.1.0
blurhash_dart: 1.1.0
connectivity_plus: 2.3.6
crop_your_image: 0.7.2
cryptography: 2.0.5