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,6 +74,17 @@ class MessageService {
return msg;
}
Future<Message?> getMessageByStanzaId(String conversationJid, String stanzaId) async {
if (_messageCache.containsKey(conversationJid)) {
await getMessagesForJid(conversationJid);
}
return firstWhereOrNull(
_messageCache[conversationJid]!,
(message) => message.sid == stanzaId,
);
}
/// Wrapper around [DatabaseService]'s updateMessage that updates the cache
Future<Message> updateMessage(int id, {
String? mediaUrl,
@ -79,6 +93,8 @@ class MessageService {
bool? displayed,
bool? acked,
int? errorType,
bool? isFileUploadNotification,
String? srcUrl,
}) async {
final newMessage = await GetIt.I.get<DatabaseService>().updateMessage(
id,
@ -88,6 +104,8 @@ class MessageService {
displayed: displayed,
acked: acked,
errorType: errorType,
isFileUploadNotification: isFileUploadNotification,
srcUrl: srcUrl,
);
if (_messageCache.containsKey(newMessage.conversationJid)) {

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';
@ -69,6 +70,9 @@ class MessageEvent extends XmppEvent {
this.sims,
this.reply,
this.chatState,
this.fun,
this.funReplacement,
this.funCancellation,
});
final String body;
final JID fromJid;
@ -84,6 +88,9 @@ class MessageEvent extends XmppEvent {
final StatelessMediaSharingData? sims;
final ReplyData? reply;
final ChatState? chatState;
final FileMetadataData? fun;
final String? funReplacement;
final String? funCancellation;
}
/// Triggered when a client responds to our delivery receipt request

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

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;
}
@override
String getId() => fileUploadNotificationManager;
XMLNode constructFileUploadNotification(FileMetadataData metadata, List<Thumbnail> thumbnails) {
final thumbnailNodes = thumbnails.map(constructFileThumbnailElement).toList();
return XMLNode.xmlns(
tag: 'file-upload',
xmlns: fileUploadNotificationXmlns,
children: [
constructFileMetadataElement(metadata),
...thumbnailNodes
],
@override
String getName() => 'FileUploadNotificationManager';
@override
List<StanzaHandler> getIncomingStanzaHandlers() => [
StanzaHandler(
stanzaTag: 'message',
tagName: 'file-upload',
tagXmlns: fileUploadNotificationXmlns,
callback: _onFileUploadNotificationReceived,
),
StanzaHandler(
stanzaTag: 'message',
tagName: 'replaces',
tagXmlns: fileUploadNotificationXmlns,
callback: _onFileUploadNotificationReplacementReceived,
),
StanzaHandler(
stanzaTag: 'message',
tagName: 'cancelled',
tagXmlns: fileUploadNotificationXmlns,
callback: _onFileUploadNotificationCancellationReceived,
),
];
@override
Future<bool> isSupported() async => true;
Future<StanzaHandlerData> _onFileUploadNotificationReceived(Stanza message, StanzaHandlerData state) async {
final funElement = message.firstTag('file-upload', xmlns: fileUploadNotificationXmlns)!;
return state.copyWith(
fun: FileMetadataData.fromXML(
funElement.firstTag('file', xmlns: fileMetadataXmlns)!,
),
);
}
FileUploadNotificationData parseFileUploadNotification(XMLNode node) {
assert(node.attributes['xmlns'] == fileUploadNotificationXmlns, 'Invalid element xmlns');
assert(node.tag == 'file-upload', 'Invalid element name');
final thumbnails = node.findTags('thumbnail', xmlns: fileThumbnailsXmlns).map((t) => parseFileThumbnailElement(t)!).toList();
return FileUploadNotificationData(
metadata: parseFileMetadataElement(node.firstTag('file', xmlns: fileMetadataXmlns)!),
thumbnails: thumbnails,
Future<StanzaHandlerData> _onFileUploadNotificationReplacementReceived(Stanza message, StanzaHandlerData state) async {
final element = message.firstTag('replaces', xmlns: fileUploadNotificationXmlns)!;
return state.copyWith(
funReplacement: element.attributes['id']! as String,
);
}
Future<StanzaHandlerData> _onFileUploadNotificationCancellationReceived(Stanza message, StanzaHandlerData state) async {
final element = message.firstTag('cancels', xmlns: fileUploadNotificationXmlns)!;
return state.copyWith(
funCancellation: element.attributes['id']! as String,
);
}
}

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,20 +15,9 @@ class FileMetadataData {
required this.thumbnails,
Map<String, String>? hashes,
}) : hashes = hashes ?? const {};
final String? mediaType;
// TODO(Unknown): Maybe create a special type for this
final String? dimensions;
final List<Thumbnail> thumbnails;
final String? desc;
final Map<String, String> hashes;
final int? length;
final String? name;
final int? size;
}
FileMetadataData parseFileMetadataElement(XMLNode node) {
/// Parse [node] as a FileMetadataData element.
factory FileMetadataData.fromXML(XMLNode node) {
assert(node.attributes['xmlns'] == fileMetadataXmlns, 'Invalid element xmlns');
assert(node.tag == 'file', 'Invalid element anme');
@ -63,33 +52,44 @@ FileMetadataData parseFileMetadataElement(XMLNode node) {
);
}
XMLNode constructFileMetadataElement(FileMetadataData data) {
final String? mediaType;
// TODO(Unknown): Maybe create a special type for this
final String? dimensions;
final List<Thumbnail> thumbnails;
final String? desc;
final Map<String, String> hashes;
final int? length;
final String? name;
final int? size;
XMLNode toXML() {
final node = XMLNode.xmlns(
tag: 'file',
xmlns: fileMetadataXmlns,
children: List.empty(growable: true),
);
if (data.mediaType != null) node.addChild(XMLNode(tag: 'media-type', text: data.mediaType));
if (data.dimensions != null) node.addChild(XMLNode(tag: 'dimensions', text: data.dimensions));
if (data.desc != null) node.addChild(XMLNode(tag: 'desc', text: data.desc));
if (data.length != null) node.addChild(XMLNode(tag: 'length', text: data.length.toString()));
if (data.name != null) node.addChild(XMLNode(tag: 'name', text: data.name));
if (data.size != null) node.addChild(XMLNode(tag: 'size', text: data.size.toString()));
if (data.hashes.isNotEmpty) {
for (final hash in data.hashes.entries) {
if (mediaType != null) node.addChild(XMLNode(tag: 'media-type', text: mediaType));
if (dimensions != null) node.addChild(XMLNode(tag: 'dimensions', text: dimensions));
if (desc != null) node.addChild(XMLNode(tag: 'desc', text: desc));
if (length != null) node.addChild(XMLNode(tag: 'length', text: length.toString()));
if (name != null) node.addChild(XMLNode(tag: 'name', text: name));
if (size != null) node.addChild(XMLNode(tag: 'size', text: size.toString()));
for (final hash in hashes.entries) {
node.addChild(
constructHashElement(hash.key, hash.value),
);
}
}
if (data.thumbnails.isNotEmpty) {
for (final thumbnail in data.thumbnails) {
for (final thumbnail in thumbnails) {
node.addChild(
constructFileThumbnailElement(thumbnail),
);
}
}
return node;
}
}

View File

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