Merge pull request 'Improve avatar and presence handling' (#285) from feat/better-avatar-handling into master
Reviewed-on: https://codeberg.org/moxxy/moxxy/pulls/285
This commit is contained in:
commit
20489fbb25
@ -208,7 +208,7 @@ files:
|
||||
attributes:
|
||||
conversationJid: String
|
||||
title: String
|
||||
avatarUrl: String
|
||||
avatarPath: String
|
||||
- name: StickerPackImportSuccessEvent
|
||||
extends: BackgroundEvent
|
||||
implements:
|
||||
@ -274,6 +274,13 @@ files:
|
||||
reactions:
|
||||
type: List<ReactionGroup>
|
||||
deserialise: true
|
||||
# Triggered when the stream negotiations have been completed
|
||||
- name: StreamNegotiationsCompletedEvent
|
||||
extends: BackgroundEvent
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
resumed: bool
|
||||
generate_builder: true
|
||||
builder_name: "Event"
|
||||
builder_baseclass: "BackgroundEvent"
|
||||
@ -576,6 +583,20 @@ files:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
messageId: int
|
||||
- name: RequestAvatarForJidCommand
|
||||
extends: BackgroundCommand
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
jid: String
|
||||
hash: String?
|
||||
ownAvatar: bool
|
||||
- name: DebugCommand
|
||||
extends: BackgroundCommand
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
id: int
|
||||
generate_builder: true
|
||||
# get${builder_Name}FromJson
|
||||
builder_name: "Command"
|
||||
|
@ -63,6 +63,7 @@ import 'package:moxxyv2/ui/pages/share_selection.dart';
|
||||
import 'package:moxxyv2/ui/pages/splashscreen/splashscreen.dart';
|
||||
import 'package:moxxyv2/ui/pages/sticker_pack.dart';
|
||||
import 'package:moxxyv2/ui/pages/util/qrcode.dart';
|
||||
import 'package:moxxyv2/ui/service/avatars.dart';
|
||||
import 'package:moxxyv2/ui/service/data.dart';
|
||||
import 'package:moxxyv2/ui/service/progress.dart';
|
||||
import 'package:moxxyv2/ui/service/sharing.dart';
|
||||
@ -83,6 +84,7 @@ void setupLogging() {
|
||||
Future<void> setupUIServices() async {
|
||||
GetIt.I.registerSingleton<UIProgressService>(UIProgressService());
|
||||
GetIt.I.registerSingleton<UIDataService>(UIDataService());
|
||||
GetIt.I.registerSingleton<UIAvatarsService>(UIAvatarsService());
|
||||
GetIt.I.registerSingleton<UISharingService>(UISharingService());
|
||||
}
|
||||
|
||||
|
@ -1,8 +1,6 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:cryptography/cryptography.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:hex/hex.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/conversation.dart';
|
||||
@ -14,60 +12,100 @@ import 'package:moxxyv2/shared/avatar.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
|
||||
/// Removes line breaks and spaces from [original]. This might happen when we request the
|
||||
/// avatar data. Returns the cleaned version.
|
||||
String _cleanBase64String(String original) {
|
||||
var ret = original;
|
||||
for (final char in ['\n', ' ']) {
|
||||
ret = ret.replaceAll(char, '');
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
class _AvatarData {
|
||||
const _AvatarData(this.data, this.id);
|
||||
final List<int> data;
|
||||
final String id;
|
||||
}
|
||||
|
||||
class AvatarService {
|
||||
final Logger _log = Logger('AvatarService');
|
||||
|
||||
Future<void> handleAvatarUpdate(AvatarUpdatedEvent event) async {
|
||||
await updateAvatarForJid(
|
||||
event.jid,
|
||||
event.hash,
|
||||
base64Decode(_cleanBase64String(event.base64)),
|
||||
);
|
||||
/// List of JIDs for which we have already requested the avatar in the current stream.
|
||||
final List<JID> _requestedInStream = [];
|
||||
|
||||
void resetCache() {
|
||||
_requestedInStream.clear();
|
||||
}
|
||||
|
||||
Future<void> updateAvatarForJid(
|
||||
String jid,
|
||||
Future<bool> _fetchAvatarForJid(JID jid, String hash) async {
|
||||
final conn = GetIt.I.get<XmppConnection>();
|
||||
final am = conn.getManagerById<UserAvatarManager>(userAvatarManager)!;
|
||||
final rawAvatar = await am.getUserAvatar(jid);
|
||||
if (rawAvatar.isType<AvatarError>()) {
|
||||
_log.warning('Failed to request avatar for $jid');
|
||||
return false;
|
||||
}
|
||||
|
||||
final avatar = rawAvatar.get<UserAvatarData>();
|
||||
await _updateAvatarForJid(
|
||||
jid,
|
||||
avatar.hash,
|
||||
avatar.data,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Requests the avatar for [jid]. [oldHash], if given, is the last SHA-1 hash of the known avatar.
|
||||
/// If the avatar for [jid] has already been requested in this stream session, does nothing. Otherwise,
|
||||
/// requests the XEP-0084 metadata and queries the new avatar only if the queried SHA-1 != [oldHash].
|
||||
///
|
||||
/// Returns true, if everything went okay. Returns false if an error occurred.
|
||||
Future<bool> requestAvatar(JID jid, String? oldHash) async {
|
||||
if (_requestedInStream.contains(jid)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
_requestedInStream.add(jid);
|
||||
final conn = GetIt.I.get<XmppConnection>();
|
||||
final am = conn.getManagerById<UserAvatarManager>(userAvatarManager)!;
|
||||
final rawId = await am.getAvatarId(jid);
|
||||
|
||||
if (rawId.isType<AvatarError>()) {
|
||||
_log.finest(
|
||||
'Failed to get avatar metadata for $jid using XEP-0084: ${rawId.get<AvatarError>()}',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
final id = rawId.get<String>();
|
||||
if (id == oldHash) {
|
||||
_log.finest('Not fetching avatar for $jid since the hashes are equal');
|
||||
return true;
|
||||
}
|
||||
|
||||
return _fetchAvatarForJid(jid, id);
|
||||
}
|
||||
|
||||
Future<void> handleAvatarUpdate(UserAvatarUpdatedEvent event) async {
|
||||
if (event.metadata.isEmpty) return;
|
||||
|
||||
// TODO(Unknown): Maybe make a better decision?
|
||||
await _fetchAvatarForJid(event.jid, event.metadata.first.id);
|
||||
}
|
||||
|
||||
/// Updates the avatar path and hash for the conversation and/or roster item with jid [JID].
|
||||
/// [hash] is the new hash of the avatar. [data] is the raw avatar data.
|
||||
Future<void> _updateAvatarForJid(
|
||||
JID jid,
|
||||
String hash,
|
||||
List<int> data,
|
||||
) async {
|
||||
final cs = GetIt.I.get<ConversationService>();
|
||||
final rs = GetIt.I.get<RosterService>();
|
||||
final originalConversation = await cs.getConversationByJid(jid);
|
||||
final originalRoster = await rs.getRosterItemByJid(jid);
|
||||
final originalConversation = await cs.getConversationByJid(jid.toString());
|
||||
final originalRoster = await rs.getRosterItemByJid(jid.toString());
|
||||
|
||||
if (originalConversation == null && originalRoster == null) return;
|
||||
|
||||
final avatarPath = await saveAvatarInCache(
|
||||
data,
|
||||
hash,
|
||||
jid,
|
||||
(originalConversation?.avatarUrl ?? originalRoster?.avatarUrl)!,
|
||||
jid.toString(),
|
||||
(originalConversation?.avatarPath ?? originalRoster?.avatarPath)!,
|
||||
);
|
||||
|
||||
if (originalConversation != null) {
|
||||
final conversation = await cs.createOrUpdateConversation(
|
||||
jid,
|
||||
jid.toString(),
|
||||
update: (c) async {
|
||||
return cs.updateConversation(
|
||||
jid,
|
||||
avatarUrl: avatarPath,
|
||||
jid.toString(),
|
||||
avatarPath: avatarPath,
|
||||
avatarHash: hash,
|
||||
);
|
||||
},
|
||||
);
|
||||
@ -81,7 +119,7 @@ class AvatarService {
|
||||
if (originalRoster != null) {
|
||||
final roster = await rs.updateRosterItem(
|
||||
originalRoster.id,
|
||||
avatarUrl: avatarPath,
|
||||
avatarPath: avatarPath,
|
||||
avatarHash: hash,
|
||||
);
|
||||
|
||||
@ -89,80 +127,6 @@ class AvatarService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<_AvatarData?> _handleUserAvatar(String jid, String oldHash) async {
|
||||
final am = GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getManagerById<UserAvatarManager>(userAvatarManager)!;
|
||||
final idResult = await am.getAvatarId(JID.fromString(jid));
|
||||
if (idResult.isType<AvatarError>()) {
|
||||
_log.warning('Failed to get avatar id via XEP-0084 for $jid');
|
||||
return null;
|
||||
}
|
||||
final id = idResult.get<String>();
|
||||
if (id == oldHash) return null;
|
||||
|
||||
final avatarResult = await am.getUserAvatar(jid);
|
||||
if (avatarResult.isType<AvatarError>()) {
|
||||
_log.warning('Failed to get avatar data via XEP-0084 for $jid');
|
||||
return null;
|
||||
}
|
||||
final avatar = avatarResult.get<UserAvatar>();
|
||||
|
||||
return _AvatarData(
|
||||
base64Decode(_cleanBase64String(avatar.base64)),
|
||||
avatar.hash,
|
||||
);
|
||||
}
|
||||
|
||||
Future<_AvatarData?> _handleVcardAvatar(String jid, String oldHash) async {
|
||||
// Query the vCard
|
||||
final vm = GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getManagerById<VCardManager>(vcardManager)!;
|
||||
final vcardResult = await vm.requestVCard(jid);
|
||||
if (vcardResult.isType<VCardError>()) return null;
|
||||
|
||||
final binval = vcardResult.get<VCard>().photo?.binval;
|
||||
if (binval == null) return null;
|
||||
|
||||
final data = base64Decode(_cleanBase64String(binval));
|
||||
final rawHash = await Sha1().hash(data);
|
||||
final hash = HEX.encode(rawHash.bytes);
|
||||
|
||||
vm.setLastHash(jid, hash);
|
||||
|
||||
return _AvatarData(
|
||||
data,
|
||||
hash,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> fetchAndUpdateAvatarForJid(String jid, String oldHash) async {
|
||||
_AvatarData? data;
|
||||
data ??= await _handleUserAvatar(jid, oldHash);
|
||||
data ??= await _handleVcardAvatar(jid, oldHash);
|
||||
|
||||
if (data != null) {
|
||||
await updateAvatarForJid(jid, data.id, data.data);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> subscribeJid(String jid) async {
|
||||
return (await GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getManagerById<UserAvatarManager>(userAvatarManager)!
|
||||
.subscribe(jid))
|
||||
.isType<bool>();
|
||||
}
|
||||
|
||||
Future<bool> unsubscribeJid(String jid) async {
|
||||
return (await GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getManagerById<UserAvatarManager>(userAvatarManager)!
|
||||
.unsubscribe(jid))
|
||||
.isType<bool>();
|
||||
}
|
||||
|
||||
/// Publishes the data at [path] as an avatar with PubSub ID
|
||||
/// [hash]. [hash] must be the hex-encoded version of the SHA-1 hash
|
||||
/// of the avatar data.
|
||||
@ -201,6 +165,7 @@ class AvatarService {
|
||||
imageSize.height.toInt(),
|
||||
// TODO(PapaTutuWawa): Maybe do a check here
|
||||
'image/png',
|
||||
null,
|
||||
),
|
||||
public,
|
||||
);
|
||||
@ -213,38 +178,44 @@ class AvatarService {
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Like [requestAvatar], but fetches and processes the avatar for our own account.
|
||||
Future<void> requestOwnAvatar() async {
|
||||
final xss = GetIt.I.get<XmppStateService>();
|
||||
final state = await xss.getXmppState();
|
||||
final jid = JID.fromString(state.jid!);
|
||||
|
||||
if (_requestedInStream.contains(jid)) {
|
||||
return;
|
||||
}
|
||||
_requestedInStream.add(jid);
|
||||
|
||||
final am = GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getManagerById<UserAvatarManager>(userAvatarManager)!;
|
||||
final xss = GetIt.I.get<XmppStateService>();
|
||||
final state = await xss.getXmppState();
|
||||
final jid = state.jid!;
|
||||
final idResult = await am.getAvatarId(JID.fromString(jid));
|
||||
if (idResult.isType<AvatarError>()) {
|
||||
_log.info('Error while getting latest avatar id for own avatar');
|
||||
final rawId = await am.getAvatarId(jid);
|
||||
if (rawId.isType<AvatarError>()) {
|
||||
_log.finest(
|
||||
'Failed to get avatar metadata for $jid using XEP-0084: ${rawId.get<AvatarError>()}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
final id = idResult.get<String>();
|
||||
final id = rawId.get<String>();
|
||||
|
||||
if (id == state.avatarHash) return;
|
||||
|
||||
_log.info(
|
||||
'Mismatch between saved avatar data and server-side avatar data about ourself',
|
||||
);
|
||||
final avatarDataResult = await am.getUserAvatar(jid);
|
||||
if (avatarDataResult.isType<AvatarError>()) {
|
||||
_log.severe('Failed to fetch our avatar');
|
||||
if (id == state.avatarHash) {
|
||||
_log.finest('Not fetching avatar for $jid since the hashes are equal');
|
||||
return;
|
||||
}
|
||||
final avatarData = avatarDataResult.get<UserAvatar>();
|
||||
|
||||
_log.info('Received data for our own avatar');
|
||||
|
||||
final rawAvatar = await am.getUserAvatar(jid);
|
||||
if (rawAvatar.isType<AvatarError>()) {
|
||||
_log.warning('Failed to request avatar for $jid');
|
||||
return;
|
||||
}
|
||||
final avatarData = rawAvatar.get<UserAvatarData>();
|
||||
final avatarPath = await saveAvatarInCache(
|
||||
base64Decode(_cleanBase64String(avatarData.base64)),
|
||||
avatarData.data,
|
||||
avatarData.hash,
|
||||
jid,
|
||||
jid.toString(),
|
||||
state.avatarUrl,
|
||||
);
|
||||
await xss.modifyXmppState(
|
||||
|
@ -87,8 +87,7 @@ class ConversationService {
|
||||
tmp.add(
|
||||
Conversation.fromDatabaseJson(
|
||||
c,
|
||||
rosterItem != null && !rosterItem.pseudoRosterItem,
|
||||
rosterItem?.subscription ?? 'none',
|
||||
rosterItem?.showAddToRosterButton ?? true,
|
||||
lastMessage,
|
||||
),
|
||||
);
|
||||
@ -136,7 +135,8 @@ class ConversationService {
|
||||
Message? lastMessage,
|
||||
bool? open,
|
||||
int? unreadCounter,
|
||||
String? avatarUrl,
|
||||
String? avatarPath,
|
||||
Object? avatarHash = notSpecified,
|
||||
ChatState? chatState,
|
||||
bool? muted,
|
||||
bool? encrypted,
|
||||
@ -160,8 +160,11 @@ class ConversationService {
|
||||
if (unreadCounter != null) {
|
||||
c['unreadCounter'] = unreadCounter;
|
||||
}
|
||||
if (avatarUrl != null) {
|
||||
c['avatarUrl'] = avatarUrl;
|
||||
if (avatarPath != null) {
|
||||
c['avatarPath'] = avatarPath;
|
||||
}
|
||||
if (avatarHash != notSpecified) {
|
||||
c['avatarHash'] = avatarHash as String?;
|
||||
}
|
||||
if (muted != null) {
|
||||
c['muted'] = boolToInt(muted);
|
||||
@ -191,8 +194,7 @@ class ConversationService {
|
||||
await GetIt.I.get<RosterService>().getRosterItemByJid(jid);
|
||||
var newConversation = Conversation.fromDatabaseJson(
|
||||
result,
|
||||
rosterItem != null,
|
||||
rosterItem?.subscription ?? 'none',
|
||||
rosterItem?.showAddToRosterButton ?? true,
|
||||
lastMessage,
|
||||
);
|
||||
|
||||
@ -215,7 +217,7 @@ class ConversationService {
|
||||
String title,
|
||||
Message? lastMessage,
|
||||
ConversationType type,
|
||||
String avatarUrl,
|
||||
String avatarPath,
|
||||
String jid,
|
||||
int unreadCounter,
|
||||
int lastChangeTimestamp,
|
||||
@ -231,14 +233,14 @@ class ConversationService {
|
||||
final newConversation = Conversation(
|
||||
title,
|
||||
lastMessage,
|
||||
avatarUrl,
|
||||
avatarPath,
|
||||
null,
|
||||
jid,
|
||||
unreadCounter,
|
||||
type,
|
||||
lastChangeTimestamp,
|
||||
open,
|
||||
rosterItem != null && !rosterItem.pseudoRosterItem,
|
||||
rosterItem?.subscription ?? 'none',
|
||||
rosterItem?.showAddToRosterButton ?? true,
|
||||
muted,
|
||||
encrypted,
|
||||
ChatState.gone,
|
||||
|
@ -41,6 +41,8 @@ import 'package:moxxyv2/service/database/migrations/0002_reactions.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0002_reactions_2.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0002_shared_media.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0002_sticker_metadata.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0003_avatar_hashes.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0003_remove_subscriptions.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:random_string/random_string.dart';
|
||||
// ignore: implementation_imports
|
||||
@ -144,6 +146,8 @@ const List<DatabaseMigration<Database>> migrations = [
|
||||
DatabaseMigration(35, upgradeFromV34ToV35),
|
||||
DatabaseMigration(36, upgradeFromV35ToV36),
|
||||
DatabaseMigration(37, upgradeFromV36ToV37),
|
||||
DatabaseMigration(38, upgradeFromV37ToV38),
|
||||
DatabaseMigration(39, upgradeFromV38ToV39),
|
||||
];
|
||||
|
||||
class DatabaseService {
|
||||
@ -179,10 +183,23 @@ class DatabaseService {
|
||||
_log.finest('Key generation done...');
|
||||
}
|
||||
|
||||
// Just some sanity checks
|
||||
final version = migrations.last.version;
|
||||
assert(
|
||||
migrations.every((migration) => migration.version <= version),
|
||||
"Every migration's version must be smaller or equal to the last version",
|
||||
);
|
||||
assert(
|
||||
migrations
|
||||
.sublist(0, migrations.length - 1)
|
||||
.every((migration) => migration.version < version),
|
||||
'The last migration must have the largest version',
|
||||
);
|
||||
|
||||
database = await openDatabase(
|
||||
dbPath,
|
||||
password: key,
|
||||
version: 37,
|
||||
version: version,
|
||||
onCreate: createDatabase,
|
||||
onConfigure: (db) async {
|
||||
// In order to do schema changes during database upgrades, we disable foreign
|
||||
|
13
lib/service/database/migrations/0003_avatar_hashes.dart
Normal file
13
lib/service/database/migrations/0003_avatar_hashes.dart
Normal file
@ -0,0 +1,13 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV37ToV38(Database db) async {
|
||||
await db
|
||||
.execute('ALTER TABLE $conversationsTable ADD COLUMN avatarHash TEXT');
|
||||
await db.execute(
|
||||
'ALTER TABLE $conversationsTable RENAME COLUMN avatarUrl TO avatarPath',
|
||||
);
|
||||
await db.execute(
|
||||
'ALTER TABLE $rosterTable RENAME COLUMN avatarUrl TO avatarPath',
|
||||
);
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV38ToV39(Database db) async {
|
||||
await db.execute('DROP TABLE $subscriptionsTable');
|
||||
}
|
@ -25,10 +25,10 @@ import 'package:moxxyv2/service/reactions.dart';
|
||||
import 'package:moxxyv2/service/roster.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/service/stickers.dart';
|
||||
import 'package:moxxyv2/service/subscription.dart';
|
||||
import 'package:moxxyv2/service/xmpp.dart';
|
||||
import 'package:moxxyv2/service/xmpp_state.dart';
|
||||
import 'package:moxxyv2/shared/commands.dart';
|
||||
import 'package:moxxyv2/shared/debug.dart' as debug;
|
||||
import 'package:moxxyv2/shared/eventhandler.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
@ -100,6 +100,8 @@ void setupBackgroundEventHandler() {
|
||||
EventTypeMatcher<GetPagedMessagesCommand>(performGetPagedMessages),
|
||||
EventTypeMatcher<GetPagedSharedMediaCommand>(performGetPagedSharedMedia),
|
||||
EventTypeMatcher<GetReactionsForMessageCommand>(performGetReactions),
|
||||
EventTypeMatcher<RequestAvatarForJidCommand>(performRequestAvatarForJid),
|
||||
EventTypeMatcher<DebugCommand>(performDebugCommand),
|
||||
]);
|
||||
|
||||
GetIt.I.registerSingleton<EventHandler>(handler);
|
||||
@ -318,7 +320,7 @@ Future<void> performSendMessage(
|
||||
command.editSid!,
|
||||
command.recipients.first,
|
||||
command.chatState.isNotEmpty
|
||||
? chatStateFromString(command.chatState)
|
||||
? ChatState.fromName(command.chatState)
|
||||
: null,
|
||||
);
|
||||
return;
|
||||
@ -328,7 +330,7 @@ Future<void> performSendMessage(
|
||||
body: command.body,
|
||||
recipients: command.recipients,
|
||||
chatState: command.chatState.isNotEmpty
|
||||
? chatStateFromString(command.chatState)
|
||||
? ChatState.fromName(command.chatState)
|
||||
: null,
|
||||
quotedMessage: command.quotedMessage,
|
||||
currentConversationJid: command.currentConversationJid,
|
||||
@ -505,30 +507,39 @@ Future<void> performAddContact(
|
||||
},
|
||||
);
|
||||
|
||||
// Manage subscription requests
|
||||
final srs = GetIt.I.get<SubscriptionRequestService>();
|
||||
final hasSubscriptionRequest = await srs.hasPendingSubscriptionRequest(jid);
|
||||
if (hasSubscriptionRequest) {
|
||||
await srs.acceptSubscriptionRequest(jid);
|
||||
}
|
||||
|
||||
// Add to roster, if needed
|
||||
final item = await roster.getRosterItemByJid(jid);
|
||||
if (item != null) {
|
||||
if (item.subscription != 'from' && item.subscription != 'both') {
|
||||
GetIt.I.get<Logger>().finest(
|
||||
'Roster item already exists with no presence subscription from them. Sending subscription request',
|
||||
);
|
||||
srs.sendSubscriptionRequest(jid);
|
||||
GetIt.I.get<Logger>().finest(
|
||||
'Roster item for $jid has subscription "${item.subscription}" with ask "${item.ask}"',
|
||||
);
|
||||
|
||||
// Nothing more to do
|
||||
if (item.subscription == 'both') {
|
||||
return;
|
||||
}
|
||||
|
||||
final pm = GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getManagerById<PresenceManager>(presenceManager)!;
|
||||
switch (item.subscription) {
|
||||
case 'both':
|
||||
return;
|
||||
case 'none':
|
||||
case 'from':
|
||||
if (item.ask != 'subscribe') {
|
||||
// Try to move from "from"/"none" to "both", by going over "From + Pending Out"
|
||||
await pm.requestSubscription(JID.fromString(item.jid));
|
||||
}
|
||||
break;
|
||||
case 'to':
|
||||
// Move from "to" to "both"
|
||||
await pm.acceptSubscriptionRequest(JID.fromString(item.jid));
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
await roster.addToRosterWrapper('', '', jid, jid.split('@')[0]);
|
||||
}
|
||||
|
||||
// Try to figure out an avatar
|
||||
// TODO(Unknown): Don't do that here. Do it more intelligently.
|
||||
await GetIt.I.get<AvatarService>().subscribeJid(jid);
|
||||
await GetIt.I.get<AvatarService>().fetchAndUpdateAvatarForJid(jid, '');
|
||||
}
|
||||
|
||||
Future<void> performRemoveContact(
|
||||
@ -547,7 +558,7 @@ Future<void> performRemoveContact(
|
||||
sendEvent(
|
||||
ConversationUpdatedEvent(
|
||||
conversation: conversation.copyWith(
|
||||
inRoster: false,
|
||||
showAddToRoster: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -615,23 +626,32 @@ Future<void> performSetShareOnlineStatus(
|
||||
dynamic extra,
|
||||
}) async {
|
||||
final rs = GetIt.I.get<RosterService>();
|
||||
final srs = GetIt.I.get<SubscriptionRequestService>();
|
||||
final item = await rs.getRosterItemByJid(command.jid);
|
||||
|
||||
// TODO(Unknown): Maybe log
|
||||
if (item == null) return;
|
||||
|
||||
final jid = JID.fromString(command.jid);
|
||||
final pm = GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getManagerById<PresenceManager>(presenceManager)!;
|
||||
if (command.share) {
|
||||
if (item.ask == 'subscribe') {
|
||||
await srs.acceptSubscriptionRequest(command.jid);
|
||||
} else {
|
||||
srs.sendSubscriptionRequest(command.jid);
|
||||
switch (item.subscription) {
|
||||
case 'to':
|
||||
await pm.acceptSubscriptionRequest(jid);
|
||||
break;
|
||||
case 'none':
|
||||
case 'from':
|
||||
await pm.requestSubscription(jid);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
if (item.ask == 'subscribe') {
|
||||
await srs.rejectSubscriptionRequest(command.jid);
|
||||
} else {
|
||||
srs.sendUnsubscriptionRequest(command.jid);
|
||||
switch (item.subscription) {
|
||||
case 'both':
|
||||
case 'from':
|
||||
case 'to':
|
||||
await pm.unsubscribe(jid);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -673,9 +693,9 @@ Future<void> performSendChatState(
|
||||
final conn = GetIt.I.get<XmppConnection>();
|
||||
|
||||
if (command.jid != '') {
|
||||
conn
|
||||
await conn
|
||||
.getManagerById<ChatStateManager>(chatStateManager)!
|
||||
.sendChatState(chatStateFromString(command.state), command.jid);
|
||||
.sendChatState(ChatState.fromName(command.state), command.jid);
|
||||
}
|
||||
}
|
||||
|
||||
@ -864,17 +884,14 @@ Future<void> performMessageRetraction(
|
||||
true,
|
||||
);
|
||||
if (command.conversationJid != '') {
|
||||
(GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getManagerById<MessageManager>(messageManager)!)
|
||||
.sendMessage(
|
||||
MessageDetails(
|
||||
to: command.conversationJid,
|
||||
messageRetraction: MessageRetractionData(
|
||||
command.originId,
|
||||
t.messages.retractedFallback,
|
||||
),
|
||||
),
|
||||
final manager = GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getManagerById<MessageManager>(messageManager)!;
|
||||
await manager.sendMessage(
|
||||
JID.fromString(command.conversationJid),
|
||||
TypedMap<StanzaHandlerExtension>.fromList([
|
||||
MessageRetractionData(command.originId, t.messages.retractedFallback),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -956,24 +973,25 @@ Future<void> performAddMessageReaction(
|
||||
final jid = (await GetIt.I.get<XmppStateService>().getXmppState()).jid!;
|
||||
|
||||
// Send the reaction
|
||||
GetIt.I
|
||||
final manager = GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getManagerById<MessageManager>(messageManager)!
|
||||
.sendMessage(
|
||||
MessageDetails(
|
||||
to: command.conversationJid,
|
||||
messageReactions: MessageReactions(
|
||||
msg.originId ?? msg.sid,
|
||||
await rs.getReactionsForMessageByJid(
|
||||
command.messageId,
|
||||
jid,
|
||||
),
|
||||
),
|
||||
requestChatMarkers: false,
|
||||
messageProcessingHints:
|
||||
!msg.containsNoStore ? [MessageProcessingHint.store] : null,
|
||||
.getManagerById<MessageManager>(messageManager)!;
|
||||
await manager.sendMessage(
|
||||
JID.fromString(command.conversationJid),
|
||||
TypedMap<StanzaHandlerExtension>.fromList([
|
||||
MessageReactionsData(
|
||||
msg.originId ?? msg.sid,
|
||||
await rs.getReactionsForMessageByJid(
|
||||
command.messageId,
|
||||
jid,
|
||||
),
|
||||
);
|
||||
),
|
||||
const MarkableData(false),
|
||||
MessageProcessingHintData([
|
||||
if (!msg.containsNoStore) MessageProcessingHint.store,
|
||||
]),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -995,24 +1013,25 @@ Future<void> performRemoveMessageReaction(
|
||||
final jid = (await GetIt.I.get<XmppStateService>().getXmppState()).jid!;
|
||||
|
||||
// Send the reaction
|
||||
GetIt.I
|
||||
final manager = GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getManagerById<MessageManager>(messageManager)!
|
||||
.sendMessage(
|
||||
MessageDetails(
|
||||
to: command.conversationJid,
|
||||
messageReactions: MessageReactions(
|
||||
msg.originId ?? msg.sid,
|
||||
await rs.getReactionsForMessageByJid(
|
||||
command.messageId,
|
||||
jid,
|
||||
),
|
||||
),
|
||||
requestChatMarkers: false,
|
||||
messageProcessingHints:
|
||||
!msg.containsNoStore ? [MessageProcessingHint.store] : null,
|
||||
.getManagerById<MessageManager>(messageManager)!;
|
||||
await manager.sendMessage(
|
||||
JID.fromString(command.conversationJid),
|
||||
TypedMap<StanzaHandlerExtension>.fromList([
|
||||
MessageReactionsData(
|
||||
msg.originId ?? msg.sid,
|
||||
await rs.getReactionsForMessageByJid(
|
||||
command.messageId,
|
||||
jid,
|
||||
),
|
||||
);
|
||||
),
|
||||
const MarkableData(false),
|
||||
MessageProcessingHintData([
|
||||
if (!msg.containsNoStore) MessageProcessingHint.store,
|
||||
]),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1248,3 +1267,46 @@ Future<void> performGetReactions(
|
||||
id: id,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> performRequestAvatarForJid(
|
||||
RequestAvatarForJidCommand command, {
|
||||
dynamic extra,
|
||||
}) async {
|
||||
final as = GetIt.I.get<AvatarService>();
|
||||
Future<void> future;
|
||||
if (command.ownAvatar) {
|
||||
future = as.requestOwnAvatar();
|
||||
} else {
|
||||
future = as.requestAvatar(
|
||||
JID.fromString(command.jid),
|
||||
command.hash,
|
||||
);
|
||||
}
|
||||
|
||||
unawaited(future);
|
||||
}
|
||||
|
||||
Future<void> performDebugCommand(
|
||||
DebugCommand command, {
|
||||
dynamic extra,
|
||||
}) async {
|
||||
final conn = GetIt.I.get<XmppConnection>();
|
||||
|
||||
if (command.id == debug.DebugCommand.clearStreamResumption.id) {
|
||||
// Disconnect
|
||||
await conn.disconnect();
|
||||
|
||||
// Reset stream management
|
||||
await conn.getManagerById<StreamManagementManager>(smManager)!.resetState();
|
||||
|
||||
// Reconnect
|
||||
await conn.connect(
|
||||
shouldReconnect: true,
|
||||
waitForConnection: true,
|
||||
);
|
||||
} else if (command.id == debug.DebugCommand.requestRoster.id) {
|
||||
await conn
|
||||
.getManagerById<RosterManager>(rosterManager)!
|
||||
.requestRoster(useRosterVersion: false);
|
||||
}
|
||||
}
|
||||
|
@ -124,11 +124,7 @@ String getUnrecoverableErrorString(NonRecoverableErrorEvent event) {
|
||||
/// This information is complemented either the srcUrl or – if unavailable –
|
||||
/// by the body of the quoted message. For non-media messages, we always use
|
||||
/// the body as fallback.
|
||||
String? createFallbackBodyForQuotedMessage(Message? quotedMessage) {
|
||||
if (quotedMessage == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String createFallbackBodyForQuotedMessage(Message quotedMessage) {
|
||||
if (quotedMessage.isMedia) {
|
||||
// Create formatted size string, if size is stored
|
||||
String quoteMessageSize;
|
||||
|
@ -338,14 +338,13 @@ class HttpFileTransferService {
|
||||
sendEvent(MessageUpdatedEvent(message: msg));
|
||||
|
||||
// Send the message to the recipient
|
||||
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
|
||||
MessageDetails(
|
||||
to: recipient,
|
||||
body: slot.getUrl,
|
||||
requestDeliveryReceipt: true,
|
||||
id: msg.sid,
|
||||
originId: msg.originId,
|
||||
sfs: StatelessFileSharingData(
|
||||
await conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
|
||||
JID.fromString(recipient),
|
||||
TypedMap<StanzaHandlerExtension>.fromList([
|
||||
MessageBodyData(slot.getUrl),
|
||||
const MessageDeliveryReceiptData(true),
|
||||
StableIdData(msg.originId, null),
|
||||
StatelessFileSharingData(
|
||||
FileMetadataData(
|
||||
mediaType: job.mime,
|
||||
size: stat.size,
|
||||
@ -353,11 +352,12 @@ class HttpFileTransferService {
|
||||
thumbnails: job.thumbnails,
|
||||
hashes: plaintextHashes,
|
||||
),
|
||||
<StatelessFileSharingSource>[source],
|
||||
[source],
|
||||
includeOOBFallback: true,
|
||||
),
|
||||
shouldEncrypt: job.encryptMap[recipient]!,
|
||||
funReplacement: oldSid,
|
||||
),
|
||||
FileUploadNotificationReplacementData(oldSid),
|
||||
MessageIdData(msg.sid),
|
||||
]),
|
||||
);
|
||||
_log.finest(
|
||||
'Sent message with file upload for ${job.path} to $recipient',
|
||||
|
@ -1,12 +1,32 @@
|
||||
import 'dart:async';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/conversation.dart';
|
||||
import 'package:moxxyv2/service/roster.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/service/xmpp_state.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/models/roster.dart';
|
||||
|
||||
/// Update the "showAddToRoster" state of the conversation with jid [jid] to
|
||||
/// [showAddToRoster], if the conversation exists.
|
||||
Future<void> updateConversation(String jid, bool showAddToRoster) async {
|
||||
final cs = GetIt.I.get<ConversationService>();
|
||||
final newConversation = await cs.createOrUpdateConversation(
|
||||
jid,
|
||||
update: (conversation) async {
|
||||
final c = conversation.copyWith(
|
||||
showAddToRoster: showAddToRoster,
|
||||
);
|
||||
cs.setConversation(c);
|
||||
return c;
|
||||
},
|
||||
);
|
||||
if (newConversation != null) {
|
||||
sendEvent(ConversationUpdatedEvent(conversation: newConversation));
|
||||
}
|
||||
}
|
||||
|
||||
class MoxxyRosterStateManager extends BaseRosterStateManager {
|
||||
@override
|
||||
Future<RosterCacheLoadResult> loadRosterCache() async {
|
||||
@ -45,6 +65,7 @@ class MoxxyRosterStateManager extends BaseRosterStateManager {
|
||||
// Remove stale items
|
||||
for (final jid in removed) {
|
||||
await rs.removeRosterItemByJid(jid);
|
||||
await updateConversation(jid, true);
|
||||
}
|
||||
|
||||
// Create new roster items
|
||||
@ -54,21 +75,23 @@ class MoxxyRosterStateManager extends BaseRosterStateManager {
|
||||
// Skip adding items twice
|
||||
if (exists) continue;
|
||||
|
||||
rosterAdded.add(
|
||||
await rs.addRosterItemFromData(
|
||||
'',
|
||||
'',
|
||||
item.jid,
|
||||
item.name ?? item.jid.split('@').first,
|
||||
item.subscription,
|
||||
item.ask ?? '',
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
groups: item.groups,
|
||||
),
|
||||
final newRosterItem = await rs.addRosterItemFromData(
|
||||
'',
|
||||
'',
|
||||
item.jid,
|
||||
item.name ?? item.jid.split('@').first,
|
||||
item.subscription,
|
||||
item.ask ?? '',
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
groups: item.groups,
|
||||
);
|
||||
rosterAdded.add(newRosterItem);
|
||||
|
||||
// Update the cached conversation item
|
||||
await updateConversation(item.jid, newRosterItem.showAddToRosterButton);
|
||||
}
|
||||
|
||||
// Update modified items
|
||||
@ -80,15 +103,17 @@ class MoxxyRosterStateManager extends BaseRosterStateManager {
|
||||
continue;
|
||||
}
|
||||
|
||||
rosterModified.add(
|
||||
await rs.updateRosterItem(
|
||||
ritem.id,
|
||||
title: item.name,
|
||||
subscription: item.subscription,
|
||||
ask: item.ask,
|
||||
groups: item.groups,
|
||||
),
|
||||
final newRosterItem = await rs.updateRosterItem(
|
||||
ritem.id,
|
||||
title: item.name,
|
||||
subscription: item.subscription,
|
||||
ask: item.ask,
|
||||
groups: item.groups,
|
||||
);
|
||||
rosterModified.add(newRosterItem);
|
||||
|
||||
// Update the cached conversation item
|
||||
await updateConversation(item.jid, newRosterItem.showAddToRosterButton);
|
||||
}
|
||||
|
||||
// Tell the UI
|
||||
|
@ -36,7 +36,7 @@ class NotificationsService {
|
||||
MessageNotificationTappedEvent(
|
||||
conversationJid: action.payload!['conversationJid']!,
|
||||
title: action.payload!['title']!,
|
||||
avatarUrl: action.payload!['avatarUrl']!,
|
||||
avatarPath: action.payload!['avatarPath']!,
|
||||
),
|
||||
);
|
||||
} else if (action.buttonKeyPressed == _notificationActionKeyRead) {
|
||||
@ -110,8 +110,8 @@ class NotificationsService {
|
||||
final title =
|
||||
contactIntegrationEnabled ? c.contactDisplayName ?? c.title : c.title;
|
||||
final avatarPath = contactIntegrationEnabled
|
||||
? c.contactAvatarPath ?? c.avatarUrl
|
||||
: c.avatarUrl;
|
||||
? c.contactAvatarPath ?? c.avatarPath
|
||||
: c.avatarPath;
|
||||
|
||||
await AwesomeNotifications().createNotification(
|
||||
content: NotificationContent(
|
||||
@ -131,7 +131,7 @@ class NotificationsService {
|
||||
'conversationJid': c.jid,
|
||||
'sid': m.sid,
|
||||
'title': title,
|
||||
'avatarUrl': avatarPath,
|
||||
'avatarPath': avatarPath,
|
||||
},
|
||||
),
|
||||
actionButtons: [
|
||||
|
@ -8,7 +8,6 @@ import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
import 'package:moxxyv2/service/not_specified.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/service/subscription.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/models/roster.dart';
|
||||
|
||||
@ -32,7 +31,7 @@ class RosterService {
|
||||
|
||||
/// Wrapper around [DatabaseService]'s addRosterItemFromData that updates the cache.
|
||||
Future<RosterItem> addRosterItemFromData(
|
||||
String avatarUrl,
|
||||
String avatarPath,
|
||||
String avatarHash,
|
||||
String jid,
|
||||
String title,
|
||||
@ -47,7 +46,7 @@ class RosterService {
|
||||
// TODO(PapaTutuWawa): Handle groups
|
||||
final i = RosterItem(
|
||||
-1,
|
||||
avatarUrl,
|
||||
avatarPath,
|
||||
avatarHash,
|
||||
jid,
|
||||
title,
|
||||
@ -76,7 +75,7 @@ class RosterService {
|
||||
/// Wrapper around [DatabaseService]'s updateRosterItem that updates the cache.
|
||||
Future<RosterItem> updateRosterItem(
|
||||
int id, {
|
||||
String? avatarUrl,
|
||||
String? avatarPath,
|
||||
String? avatarHash,
|
||||
String? title,
|
||||
String? subscription,
|
||||
@ -89,8 +88,8 @@ class RosterService {
|
||||
}) async {
|
||||
final i = <String, dynamic>{};
|
||||
|
||||
if (avatarUrl != null) {
|
||||
i['avatarUrl'] = avatarUrl;
|
||||
if (avatarPath != null) {
|
||||
i['avatarPath'] = avatarPath;
|
||||
}
|
||||
if (avatarHash != null) {
|
||||
i['avatarHash'] = avatarHash;
|
||||
@ -197,7 +196,7 @@ class RosterService {
|
||||
/// and, if it was successful, create the database entry. Returns the
|
||||
/// [RosterItem] model object.
|
||||
Future<RosterItem> addToRosterWrapper(
|
||||
String avatarUrl,
|
||||
String avatarPath,
|
||||
String avatarHash,
|
||||
String jid,
|
||||
String title,
|
||||
@ -205,7 +204,7 @@ class RosterService {
|
||||
final css = GetIt.I.get<ContactsService>();
|
||||
final contactId = await css.getContactIdForJid(jid);
|
||||
final item = await addRosterItemFromData(
|
||||
avatarUrl,
|
||||
avatarPath,
|
||||
avatarHash,
|
||||
jid,
|
||||
title,
|
||||
@ -217,14 +216,19 @@ class RosterService {
|
||||
await css.getContactDisplayName(contactId),
|
||||
);
|
||||
|
||||
final result = await GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getRosterManager()!
|
||||
.addToRoster(jid, title);
|
||||
final conn = GetIt.I.get<XmppConnection>();
|
||||
final result = await conn.getRosterManager()!.addToRoster(jid, title);
|
||||
if (!result) {
|
||||
// TODO(Unknown): Signal error?
|
||||
}
|
||||
|
||||
final to = JID.fromString(jid);
|
||||
final preApproval =
|
||||
await conn.getPresenceManager()!.preApproveSubscription(to);
|
||||
if (!preApproval) {
|
||||
await conn.getPresenceManager()!.requestSubscription(to);
|
||||
}
|
||||
|
||||
sendEvent(RosterDiffEvent(added: [item]));
|
||||
return item;
|
||||
}
|
||||
@ -236,14 +240,14 @@ class RosterService {
|
||||
String jid, {
|
||||
bool unsubscribe = true,
|
||||
}) async {
|
||||
final roster = GetIt.I.get<XmppConnection>().getRosterManager()!;
|
||||
final conn = GetIt.I.get<XmppConnection>();
|
||||
final roster = conn.getRosterManager()!;
|
||||
final pm = conn.getManagerById<PresenceManager>(presenceManager)!;
|
||||
final result = await roster.removeFromRoster(jid);
|
||||
if (result == RosterRemovalResult.okay ||
|
||||
result == RosterRemovalResult.itemNotFound) {
|
||||
if (unsubscribe) {
|
||||
GetIt.I
|
||||
.get<SubscriptionRequestService>()
|
||||
.sendUnsubscriptionRequest(jid);
|
||||
await pm.unsubscribe(JID.fromString(jid));
|
||||
}
|
||||
|
||||
_log.finest('Removing from roster maybe worked. Removing from database');
|
||||
|
@ -33,7 +33,6 @@ import 'package:moxxyv2/service/preferences.dart';
|
||||
import 'package:moxxyv2/service/reactions.dart';
|
||||
import 'package:moxxyv2/service/roster.dart';
|
||||
import 'package:moxxyv2/service/stickers.dart';
|
||||
import 'package:moxxyv2/service/subscription.dart';
|
||||
import 'package:moxxyv2/service/xmpp.dart';
|
||||
import 'package:moxxyv2/service/xmpp_state.dart';
|
||||
import 'package:moxxyv2/shared/commands.dart';
|
||||
@ -175,9 +174,6 @@ Future<void> entrypoint() async {
|
||||
GetIt.I.registerSingleton<ContactsService>(ContactsService());
|
||||
GetIt.I.registerSingleton<StickersService>(StickersService());
|
||||
GetIt.I.registerSingleton<XmppStateService>(XmppStateService());
|
||||
GetIt.I.registerSingleton<SubscriptionRequestService>(
|
||||
SubscriptionRequestService(),
|
||||
);
|
||||
GetIt.I.registerSingleton<FilesService>(FilesService());
|
||||
GetIt.I.registerSingleton<ReactionsService>(ReactionsService());
|
||||
final xmpp = XmppService();
|
||||
@ -211,6 +207,7 @@ Future<void> entrypoint() async {
|
||||
StreamManagementNegotiator(),
|
||||
CSINegotiator(),
|
||||
RosterFeatureNegotiator(),
|
||||
PresenceNegotiator(),
|
||||
SaslScramNegotiator(10, '', '', ScramHashType.sha512),
|
||||
SaslScramNegotiator(9, '', '', ScramHashType.sha256),
|
||||
SaslScramNegotiator(8, '', '', ScramHashType.sha1),
|
||||
@ -230,7 +227,6 @@ Future<void> entrypoint() async {
|
||||
CSIManager(),
|
||||
CarbonsManager(),
|
||||
PubSubManager(),
|
||||
VCardManager(),
|
||||
UserAvatarManager(),
|
||||
StableIdManager(),
|
||||
MessageDeliveryReceiptManager(),
|
||||
@ -249,6 +245,7 @@ Future<void> entrypoint() async {
|
||||
LastMessageCorrectionManager(),
|
||||
MessageReactionsManager(),
|
||||
StickersManager(),
|
||||
MessageProcessingHintManager(),
|
||||
]);
|
||||
|
||||
GetIt.I.registerSingleton<XmppConnection>(connection);
|
||||
|
@ -1,95 +0,0 @@
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
import 'package:synchronized/synchronized.dart';
|
||||
|
||||
class SubscriptionRequestService {
|
||||
List<String>? _subscriptionRequests;
|
||||
|
||||
final Lock _lock = Lock();
|
||||
|
||||
/// Only load data from the database into
|
||||
/// [SubscriptionRequestService._subscriptionRequests] when the cache has not yet
|
||||
/// been loaded.
|
||||
Future<void> _loadSubscriptionRequestsIfNeeded() async {
|
||||
await _lock.synchronized(() async {
|
||||
_subscriptionRequests ??= List<String>.from(
|
||||
(await GetIt.I
|
||||
.get<DatabaseService>()
|
||||
.database
|
||||
.query(subscriptionsTable))
|
||||
.map((m) => m['jid']! as String)
|
||||
.toList(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<String>> getSubscriptionRequests() async {
|
||||
await _loadSubscriptionRequestsIfNeeded();
|
||||
return _subscriptionRequests!;
|
||||
}
|
||||
|
||||
Future<void> addSubscriptionRequest(String jid) async {
|
||||
await _loadSubscriptionRequestsIfNeeded();
|
||||
|
||||
await _lock.synchronized(() async {
|
||||
if (!_subscriptionRequests!.contains(jid)) {
|
||||
_subscriptionRequests!.add(jid);
|
||||
|
||||
await GetIt.I.get<DatabaseService>().database.insert(
|
||||
subscriptionsTable,
|
||||
{
|
||||
'jid': jid,
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.ignore,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> removeSubscriptionRequest(String jid) async {
|
||||
await _loadSubscriptionRequestsIfNeeded();
|
||||
|
||||
await _lock.synchronized(() async {
|
||||
if (_subscriptionRequests!.contains(jid)) {
|
||||
_subscriptionRequests!.remove(jid);
|
||||
await GetIt.I.get<DatabaseService>().database.delete(
|
||||
subscriptionsTable,
|
||||
where: 'jid = ?',
|
||||
whereArgs: [jid],
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> hasPendingSubscriptionRequest(String jid) async {
|
||||
return (await getSubscriptionRequests()).contains(jid);
|
||||
}
|
||||
|
||||
PresenceManager get _presence =>
|
||||
GetIt.I.get<XmppConnection>().getPresenceManager()!;
|
||||
|
||||
/// Accept a subscription request from [jid].
|
||||
Future<void> acceptSubscriptionRequest(String jid) async {
|
||||
_presence.sendSubscriptionRequestApproval(jid);
|
||||
await removeSubscriptionRequest(jid);
|
||||
}
|
||||
|
||||
/// Reject a subscription request from [jid].
|
||||
Future<void> rejectSubscriptionRequest(String jid) async {
|
||||
_presence.sendSubscriptionRequestRejection(jid);
|
||||
await removeSubscriptionRequest(jid);
|
||||
}
|
||||
|
||||
/// Send a subscription request to [jid].
|
||||
void sendSubscriptionRequest(String jid) {
|
||||
_presence.sendSubscriptionRequest(jid);
|
||||
}
|
||||
|
||||
/// Remove a presence subscription with [jid].
|
||||
void sendUnsubscriptionRequest(String jid) {
|
||||
_presence.sendUnsubscriptionRequest(jid);
|
||||
}
|
||||
}
|
@ -29,7 +29,6 @@ import 'package:moxxyv2/service/preferences.dart';
|
||||
import 'package:moxxyv2/service/reactions.dart';
|
||||
import 'package:moxxyv2/service/roster.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/service/subscription.dart';
|
||||
import 'package:moxxyv2/service/xmpp_state.dart';
|
||||
import 'package:moxxyv2/shared/error_types.dart';
|
||||
import 'package:moxxyv2/shared/eventhandler.dart';
|
||||
@ -56,7 +55,7 @@ class XmppService {
|
||||
_onDeliveryReceiptReceived,
|
||||
),
|
||||
EventTypeMatcher<ChatMarkerEvent>(_onChatMarker),
|
||||
EventTypeMatcher<AvatarUpdatedEvent>(_onAvatarUpdated),
|
||||
EventTypeMatcher<UserAvatarUpdatedEvent>(_onAvatarUpdated),
|
||||
EventTypeMatcher<StanzaAckedEvent>(_onStanzaAcked),
|
||||
EventTypeMatcher<MessageEvent>(_onMessage),
|
||||
EventTypeMatcher<BlocklistBlockPushEvent>(_onBlocklistBlockPush),
|
||||
@ -184,14 +183,15 @@ class XmppService {
|
||||
|
||||
if (conversation?.type != ConversationType.note) {
|
||||
// Send the correction
|
||||
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
|
||||
MessageDetails(
|
||||
to: recipient,
|
||||
body: newBody,
|
||||
lastMessageCorrectionId: oldId,
|
||||
chatState: chatState,
|
||||
),
|
||||
);
|
||||
final manager = conn.getManagerById<MessageManager>(messageManager)!;
|
||||
await manager.sendMessage(
|
||||
JID.fromString(recipient),
|
||||
TypedMap<StanzaHandlerExtension>.fromList([
|
||||
MessageBodyData(newBody),
|
||||
LastMessageCorrectionData(oldId),
|
||||
if (chatState != null) chatState,
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -259,28 +259,39 @@ class XmppService {
|
||||
|
||||
if (conversation?.type == ConversationType.chat) {
|
||||
final moxxmppSticker = sticker?.toMoxxmpp();
|
||||
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
|
||||
MessageDetails(
|
||||
to: recipient,
|
||||
body: body,
|
||||
requestDeliveryReceipt: true,
|
||||
id: sid,
|
||||
originId: originId,
|
||||
quoteBody: createFallbackBodyForQuotedMessage(quotedMessage),
|
||||
quoteFrom: quotedMessage?.sender,
|
||||
quoteId: quotedMessage?.sid,
|
||||
chatState: chatState,
|
||||
shouldEncrypt: conversation!.encrypted,
|
||||
stickerPackId: sticker?.stickerPackId,
|
||||
sfs: moxxmppSticker != null
|
||||
? StatelessFileSharingData(
|
||||
moxxmppSticker.metadata,
|
||||
moxxmppSticker.sources,
|
||||
)
|
||||
: null,
|
||||
setOOBFallbackBody: sticker != null ? false : true,
|
||||
final manager = conn.getManagerById<MessageManager>(messageManager)!;
|
||||
|
||||
await manager.sendMessage(
|
||||
JID.fromString(recipient),
|
||||
TypedMap<StanzaHandlerExtension>.fromList([
|
||||
MessageBodyData(body),
|
||||
const MarkableData(true),
|
||||
MessageIdData(sid),
|
||||
StableIdData(originId, null),
|
||||
|
||||
if (sticker != null && moxxmppSticker != null)
|
||||
StickersData(
|
||||
sticker.stickerPackId,
|
||||
StatelessFileSharingData(
|
||||
moxxmppSticker.metadata,
|
||||
moxxmppSticker.sources,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Optional chat state
|
||||
if (chatState != null) chatState,
|
||||
|
||||
// Prepare the appropriate quote
|
||||
if (quotedMessage != null)
|
||||
ReplyData.fromQuoteData(
|
||||
quotedMessage.sid,
|
||||
QuoteData.fromBodies(
|
||||
createFallbackBodyForQuotedMessage(quotedMessage),
|
||||
body,
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
sendEvent(
|
||||
@ -290,7 +301,9 @@ class XmppService {
|
||||
}
|
||||
|
||||
MediaFileLocation? _getEmbeddedFile(MessageEvent event) {
|
||||
if (event.sfs?.sources.isNotEmpty ?? false) {
|
||||
final sfs = event.extensions.get<StatelessFileSharingData>();
|
||||
final oob = event.extensions.get<OOBData>();
|
||||
if (sfs?.sources.isNotEmpty ?? false) {
|
||||
// final source = firstWhereOrNull(
|
||||
// event.sfs!.sources,
|
||||
// (StatelessFileSharingSource source) {
|
||||
@ -300,14 +313,14 @@ class XmppService {
|
||||
// );
|
||||
|
||||
final hasUrlSource = firstWhereOrNull(
|
||||
event.sfs!.sources,
|
||||
sfs!.sources,
|
||||
(src) => src is StatelessFileSharingUrlSource,
|
||||
) !=
|
||||
null;
|
||||
|
||||
final name = event.sfs!.metadata.name;
|
||||
final name = sfs.metadata.name;
|
||||
if (hasUrlSource) {
|
||||
final sources = event.sfs!.sources
|
||||
final sources = sfs.sources
|
||||
.whereType<StatelessFileSharingUrlSource>()
|
||||
.map((src) => src.url)
|
||||
.toList();
|
||||
@ -317,13 +330,13 @@ class XmppService {
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
event.sfs!.metadata.hashes,
|
||||
sfs.metadata.hashes,
|
||||
null,
|
||||
event.sfs!.metadata.size,
|
||||
sfs.metadata.size,
|
||||
);
|
||||
} else {
|
||||
final encryptedSource = firstWhereOrNull(
|
||||
event.sfs!.sources,
|
||||
sfs.sources,
|
||||
(src) => src is StatelessFileSharingEncryptedSource,
|
||||
)! as StatelessFileSharingEncryptedSource;
|
||||
|
||||
@ -335,15 +348,15 @@ class XmppService {
|
||||
encryptedSource.encryption.toNamespace(),
|
||||
encryptedSource.key,
|
||||
encryptedSource.iv,
|
||||
event.sfs?.metadata.hashes,
|
||||
sfs.metadata.hashes,
|
||||
encryptedSource.hashes,
|
||||
event.sfs!.metadata.size,
|
||||
sfs.metadata.size,
|
||||
);
|
||||
}
|
||||
} else if (event.oob != null) {
|
||||
} else if (oob != null) {
|
||||
return MediaFileLocation(
|
||||
[event.oob!.url!],
|
||||
filenameFromUrl(event.oob!.url!),
|
||||
[oob.url!],
|
||||
filenameFromUrl(oob.url!),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
@ -360,45 +373,36 @@ class XmppService {
|
||||
final result = await GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getDiscoManager()!
|
||||
.discoInfoQuery(event.fromJid);
|
||||
.discoInfoQuery(event.from);
|
||||
if (result.isType<DiscoError>()) return;
|
||||
|
||||
final info = result.get<DiscoInfo>();
|
||||
if (event.isMarkable && info.features.contains(chatMarkersXmlns)) {
|
||||
unawaited(
|
||||
GetIt.I.get<XmppConnection>().sendStanza(
|
||||
StanzaDetails(
|
||||
Stanza.message(
|
||||
to: event.fromJid.toBare().toString(),
|
||||
type: event.type,
|
||||
children: [
|
||||
makeChatMarker(
|
||||
'received',
|
||||
event.originId ?? event.sid,
|
||||
)
|
||||
],
|
||||
),
|
||||
awaitable: false,
|
||||
),
|
||||
),
|
||||
final isMarkable =
|
||||
event.extensions.get<MarkableData>()?.isMarkable ?? false;
|
||||
final deliveryReceiptRequested =
|
||||
event.extensions.get<MessageDeliveryReceiptData>()?.receiptRequested ??
|
||||
false;
|
||||
final originId = event.extensions.get<StableIdData>()?.originId;
|
||||
final manager = GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getManagerById<MessageManager>(messageManager)!;
|
||||
if (isMarkable && info.features.contains(chatMarkersXmlns)) {
|
||||
await manager.sendMessage(
|
||||
event.from.toBare(),
|
||||
TypedMap<StanzaHandlerExtension>.fromList([
|
||||
ChatMarkerData(
|
||||
ChatMarker.received,
|
||||
originId ?? event.id,
|
||||
)
|
||||
]),
|
||||
);
|
||||
} else if (event.deliveryReceiptRequested &&
|
||||
} else if (deliveryReceiptRequested &&
|
||||
info.features.contains(deliveryXmlns)) {
|
||||
unawaited(
|
||||
GetIt.I.get<XmppConnection>().sendStanza(
|
||||
StanzaDetails(
|
||||
Stanza.message(
|
||||
to: event.fromJid.toBare().toString(),
|
||||
type: event.type,
|
||||
children: [
|
||||
makeMessageDeliveryResponse(
|
||||
event.originId ?? event.sid,
|
||||
)
|
||||
],
|
||||
),
|
||||
awaitable: false,
|
||||
),
|
||||
),
|
||||
await manager.sendMessage(
|
||||
event.from.toBare(),
|
||||
TypedMap<StanzaHandlerExtension>.fromList([
|
||||
MessageDeliveryReceivedData(originId ?? event.id),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -410,19 +414,14 @@ class XmppService {
|
||||
final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
|
||||
if (!prefs.sendChatMarkers) return;
|
||||
|
||||
unawaited(
|
||||
GetIt.I.get<XmppConnection>().sendStanza(
|
||||
StanzaDetails(
|
||||
Stanza.message(
|
||||
to: to,
|
||||
type: 'chat',
|
||||
children: [
|
||||
makeChatMarker('displayed', sid),
|
||||
],
|
||||
),
|
||||
awaitable: false,
|
||||
),
|
||||
),
|
||||
final manager = GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getManagerById<MessageManager>(messageManager)!;
|
||||
await manager.sendMessage(
|
||||
JID.fromString(to),
|
||||
TypedMap<StanzaHandlerExtension>.fromList([
|
||||
ChatMarkerData(ChatMarker.displayed, sid),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
@ -601,7 +600,7 @@ class XmppService {
|
||||
rosterItem?.title ?? recipient.split('@').first,
|
||||
lastMessages[recipient],
|
||||
ConversationType.chat,
|
||||
rosterItem?.avatarUrl ?? '',
|
||||
rosterItem?.avatarPath ?? '',
|
||||
recipient,
|
||||
0,
|
||||
DateTime.now().millisecondsSinceEpoch,
|
||||
@ -643,6 +642,7 @@ class XmppService {
|
||||
|
||||
// Requesting Upload slots and uploading
|
||||
final hfts = GetIt.I.get<HttpFileTransferService>();
|
||||
final manager = conn.getManagerById<MessageManager>(messageManager)!;
|
||||
for (final path in paths) {
|
||||
final pathMime = lookupMimeType(path);
|
||||
|
||||
@ -661,20 +661,21 @@ class XmppService {
|
||||
}
|
||||
}
|
||||
if (recipient != '') {
|
||||
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
|
||||
MessageDetails(
|
||||
to: recipient,
|
||||
id: messages[path]![recipient]!.sid,
|
||||
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] ?? [],
|
||||
),
|
||||
shouldEncrypt: encrypt[recipient]!,
|
||||
await manager.sendMessage(
|
||||
JID.fromString(recipient),
|
||||
TypedMap<StanzaHandlerExtension>.fromList([
|
||||
MessageIdData(messages[path]![recipient]!.sid),
|
||||
FileUploadNotificationData(
|
||||
FileMetadataData(
|
||||
// TODO(Unknown): Maybe add media type specific metadata
|
||||
mediaType: lookupMimeType(path),
|
||||
name: pathlib.basename(path),
|
||||
size: File(path).statSync().size,
|
||||
thumbnails: thumbnails[path] ?? [],
|
||||
),
|
||||
);
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -761,18 +762,24 @@ class XmppService {
|
||||
unawaited(_initializeOmemoService(settings.jid.toString()));
|
||||
|
||||
if (!event.resumed) {
|
||||
// Reset the avatar service's cache
|
||||
GetIt.I.get<AvatarService>().resetCache();
|
||||
|
||||
// Reset the blocking service's cache
|
||||
GetIt.I.get<BlocklistService>().onNewConnection();
|
||||
|
||||
// Reset the OMEMO cache
|
||||
GetIt.I.get<OmemoService>().onNewConnection();
|
||||
|
||||
// Enable carbons
|
||||
final carbonsResult = await connection
|
||||
.getManagerById<CarbonsManager>(carbonsManager)!
|
||||
.enableCarbons();
|
||||
if (!carbonsResult) {
|
||||
_log.warning('Failed to enable carbons');
|
||||
// Enable carbons, if they're not already enabled (e.g. by using SASL2)
|
||||
final cm = connection.getManagerById<CarbonsManager>(carbonsManager)!;
|
||||
if (!cm.isEnabled) {
|
||||
final carbonsResult = await cm.enableCarbons();
|
||||
if (!carbonsResult) {
|
||||
_log.warning('Failed to enable carbons');
|
||||
}
|
||||
} else {
|
||||
_log.info('Not enabling carbons as they are already enabled');
|
||||
}
|
||||
|
||||
// In section 5 of XEP-0198 it says that a client should not request the roster
|
||||
@ -781,22 +788,9 @@ class XmppService {
|
||||
.getManagerById<RosterManager>(rosterManager)!
|
||||
.requestRoster();
|
||||
|
||||
// TODO(Unknown): Once groupchats come into the equation, this gets trickier
|
||||
final roster = await GetIt.I.get<RosterService>().getRoster();
|
||||
for (final item in roster) {
|
||||
await GetIt.I
|
||||
.get<AvatarService>()
|
||||
.fetchAndUpdateAvatarForJid(item.jid, item.avatarHash);
|
||||
}
|
||||
|
||||
await GetIt.I.get<BlocklistService>().getBlocklist();
|
||||
}
|
||||
|
||||
// Make sure we display our own avatar correctly.
|
||||
// Note that this only requests the avatar if its hash differs from the locally cached avatar's.
|
||||
// TODO(Unknown): Maybe don't do this on mobile Internet
|
||||
unawaited(GetIt.I.get<AvatarService>().requestOwnAvatar());
|
||||
|
||||
if (_loginTriggeredFromUI) {
|
||||
// TODO(Unknown): Trigger another event so the UI can see this aswell
|
||||
await GetIt.I.get<XmppStateService>().modifyXmppState(
|
||||
@ -808,6 +802,10 @@ class XmppService {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
sendEvent(
|
||||
StreamNegotiationsCompletedEvent(resumed: event.resumed),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onConnectionStateChanged(
|
||||
@ -837,20 +835,24 @@ class XmppService {
|
||||
SubscriptionRequestReceivedEvent event, {
|
||||
dynamic extra,
|
||||
}) async {
|
||||
final jid = event.from.toBare().toString();
|
||||
final jid = event.from.toBare();
|
||||
|
||||
// Auto-accept if the JID is in the roster
|
||||
final rs = GetIt.I.get<RosterService>();
|
||||
final srs = GetIt.I.get<SubscriptionRequestService>();
|
||||
final rosterItem = await rs.getRosterItemByJid(jid);
|
||||
final rosterItem = await rs.getRosterItemByJid(jid.toString());
|
||||
if (rosterItem != null) {
|
||||
await srs.acceptSubscriptionRequest(jid);
|
||||
final pm = GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getManagerById<PresenceManager>(presenceManager)!;
|
||||
|
||||
switch (rosterItem.subscription) {
|
||||
case 'from':
|
||||
await pm.acceptSubscriptionRequest(jid);
|
||||
break;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await GetIt.I.get<SubscriptionRequestService>().addSubscriptionRequest(
|
||||
event.from.toBare().toString(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onDeliveryReceiptReceived(
|
||||
@ -900,12 +902,12 @@ class XmppService {
|
||||
final msg = await ms.updateMessage(
|
||||
dbMsg.id,
|
||||
received: dbMsg.received ||
|
||||
event.type == 'received' ||
|
||||
event.type == 'displayed' ||
|
||||
event.type == 'acknowledged',
|
||||
event.type == ChatMarker.received ||
|
||||
event.type == ChatMarker.displayed ||
|
||||
event.type == ChatMarker.acknowledged,
|
||||
displayed: dbMsg.displayed ||
|
||||
event.type == 'displayed' ||
|
||||
event.type == 'acknowledged',
|
||||
event.type == ChatMarker.displayed ||
|
||||
event.type == ChatMarker.acknowledged,
|
||||
);
|
||||
sendEvent(MessageUpdatedEvent(message: msg));
|
||||
|
||||
@ -935,14 +937,21 @@ 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.fun != null;
|
||||
final body = event.extensions.get<MessageBodyData>()?.body;
|
||||
final sfs = event.extensions.get<StatelessFileSharingData>();
|
||||
final fun = event.extensions.get<FileUploadNotificationData>();
|
||||
|
||||
return (body?.isNotEmpty ?? false) || sfs != null || fun != null;
|
||||
}
|
||||
|
||||
/// Extract the thumbnail data from a message, if existent.
|
||||
String? _getThumbnailData(MessageEvent event) {
|
||||
final sfs = event.extensions.get<StatelessFileSharingData>();
|
||||
final fun = event.extensions.get<FileUploadNotificationData>();
|
||||
|
||||
final thumbnails = firstNotNull([
|
||||
event.sfs?.metadata.thumbnails,
|
||||
event.fun?.thumbnails,
|
||||
sfs?.metadata.thumbnails,
|
||||
fun?.metadata.thumbnails,
|
||||
]) ??
|
||||
[];
|
||||
for (final i in thumbnails) {
|
||||
@ -956,27 +965,33 @@ class XmppService {
|
||||
|
||||
/// Extract the mime guess from a message, if existent.
|
||||
String? _getMimeGuess(MessageEvent event) {
|
||||
final sfs = event.extensions.get<StatelessFileSharingData>();
|
||||
final fun = event.extensions.get<FileUploadNotificationData>();
|
||||
|
||||
return firstNotNull([
|
||||
event.sfs?.metadata.mediaType,
|
||||
event.fun?.mediaType,
|
||||
sfs?.metadata.mediaType,
|
||||
fun?.metadata.mediaType,
|
||||
]);
|
||||
}
|
||||
|
||||
/// Extract the embedded dimensions, if existent.
|
||||
Size? _getDimensions(MessageEvent event) {
|
||||
if (event.sfs != null &&
|
||||
event.sfs?.metadata.width != null &&
|
||||
event.sfs?.metadata.height != null) {
|
||||
final sfs = event.extensions.get<StatelessFileSharingData>();
|
||||
final fun = event.extensions.get<FileUploadNotificationData>();
|
||||
|
||||
if (sfs != null &&
|
||||
sfs.metadata.width != null &&
|
||||
sfs.metadata.height != null) {
|
||||
return Size(
|
||||
event.sfs!.metadata.width!.toDouble(),
|
||||
event.sfs!.metadata.height!.toDouble(),
|
||||
sfs.metadata.width!.toDouble(),
|
||||
sfs.metadata.height!.toDouble(),
|
||||
);
|
||||
} else if (event.fun != null &&
|
||||
event.fun?.width != null &&
|
||||
event.fun?.height != null) {
|
||||
} else if (fun != null &&
|
||||
fun.metadata.width != null &&
|
||||
fun.metadata.height != null) {
|
||||
return Size(
|
||||
event.fun!.width!.toDouble(),
|
||||
event.fun!.height!.toDouble(),
|
||||
fun.metadata.width!.toDouble(),
|
||||
fun.metadata.height!.toDouble(),
|
||||
);
|
||||
}
|
||||
|
||||
@ -987,11 +1002,14 @@ class XmppService {
|
||||
/// [embeddedFile] is the possible source of the file. If no file is present, then
|
||||
/// [embeddedFile] is null.
|
||||
bool _isFileEmbedded(MessageEvent event, MediaFileLocation? embeddedFile) {
|
||||
final body = event.extensions.get<MessageBodyData>()?.body;
|
||||
final oob = event.extensions.get<OOBData>();
|
||||
|
||||
// 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 embeddedFile != null &&
|
||||
Uri.parse(embeddedFile.urls.first).scheme == 'https' &&
|
||||
implies(event.oob != null, event.body == event.oob?.url);
|
||||
implies(oob != null, body == oob?.url);
|
||||
}
|
||||
|
||||
/// Handle a message retraction given the MessageEvent [event].
|
||||
@ -1001,8 +1019,8 @@ class XmppService {
|
||||
) async {
|
||||
await GetIt.I.get<MessageService>().retractMessage(
|
||||
conversationJid,
|
||||
event.messageRetraction!.id,
|
||||
event.fromJid.toBare().toString(),
|
||||
event.extensions.get<MessageRetractionData>()!.id,
|
||||
event.from.toBare().toString(),
|
||||
false,
|
||||
);
|
||||
}
|
||||
@ -1020,19 +1038,19 @@ class XmppService {
|
||||
Future<void> _handleErrorMessage(MessageEvent event) async {
|
||||
if (event.error == null) {
|
||||
_log.warning(
|
||||
'Received error for message ${event.sid} without an error element',
|
||||
'Received error for message ${event.id} without an error element',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
final msg = await ms.getMessageByStanzaId(
|
||||
event.fromJid.toBare().toString(),
|
||||
event.sid,
|
||||
event.from.toBare().toString(),
|
||||
event.id,
|
||||
);
|
||||
|
||||
if (msg == null) {
|
||||
_log.warning('Received error for message ${event.sid} we cannot find');
|
||||
_log.warning('Received error for message ${event.id} we cannot find');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1068,23 +1086,23 @@ class XmppService {
|
||||
) async {
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
final cs = GetIt.I.get<ConversationService>();
|
||||
|
||||
final correctionId = event.extensions.get<LastMessageCorrectionData>()!.id;
|
||||
final msg = await ms.getMessageByStanzaId(
|
||||
conversationJid,
|
||||
event.messageCorrectionId!,
|
||||
correctionId,
|
||||
);
|
||||
if (msg == null) {
|
||||
_log.warning(
|
||||
'Received message correction for message ${event.messageCorrectionId} we cannot find.',
|
||||
'Received message correction for message $correctionId we cannot find.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the Jid is allowed to do correct the message
|
||||
// TODO(Unknown): Maybe use the JID parser?
|
||||
final bareSender = event.fromJid.toBare().toString();
|
||||
if (msg.sender.split('/').first != bareSender) {
|
||||
if (msg.senderJid.toBare() != event.from.toBare()) {
|
||||
_log.warning(
|
||||
'Received a message correction from $bareSender for message that is not sent by $bareSender',
|
||||
'Received a message correction from ${event.from} for a message that is sent by ${msg.sender}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
@ -1097,9 +1115,10 @@ class XmppService {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO(Unknown): Should we null-check here?
|
||||
final newMsg = await ms.updateMessage(
|
||||
msg.id,
|
||||
body: event.body,
|
||||
body: event.extensions.get<MessageBodyData>()!.body,
|
||||
isEdited: true,
|
||||
);
|
||||
sendEvent(MessageUpdatedEvent(message: newMsg));
|
||||
@ -1120,30 +1139,32 @@ class XmppService {
|
||||
) async {
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
// TODO(Unknown): Once we support groupchats, we need to instead query by the stanza-id
|
||||
final reactions = event.extensions.get<MessageReactionsData>()!;
|
||||
final msg = await ms.getMessageByXmppId(
|
||||
event.messageReactions!.messageId,
|
||||
reactions.messageId,
|
||||
conversationJid,
|
||||
queryReactionPreview: false,
|
||||
);
|
||||
if (msg == null) {
|
||||
_log.warning(
|
||||
'Received reactions for ${event.messageReactions!.messageId} from ${event.fromJid} for $conversationJid, but could not find message.',
|
||||
'Received reactions for ${reactions.messageId} from ${event.from} for $conversationJid, but could not find message.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await GetIt.I.get<ReactionsService>().processNewReactions(
|
||||
msg,
|
||||
event.fromJid.toBare().toString(),
|
||||
event.messageReactions!.emojis,
|
||||
event.from.toBare().toString(),
|
||||
reactions.emojis,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onMessage(MessageEvent event, {dynamic extra}) async {
|
||||
// The jid this message event is meant for
|
||||
final conversationJid = event.isCarbon
|
||||
? event.toJid.toBare().toString()
|
||||
: event.fromJid.toBare().toString();
|
||||
final isCarbon = event.extensions.get<CarbonsData>()?.isCarbon ?? false;
|
||||
final conversationJid = isCarbon
|
||||
? event.to.toBare().toString()
|
||||
: event.from.toBare().toString();
|
||||
|
||||
if (event.type == 'error') {
|
||||
await _handleErrorMessage(event);
|
||||
@ -1152,40 +1173,41 @@ class XmppService {
|
||||
}
|
||||
|
||||
// Process the chat state update. Can also be attached to other messages
|
||||
if (event.chatState != null) {
|
||||
await _onChatState(event.chatState!, conversationJid);
|
||||
final chatState = event.extensions.get<ChatState>();
|
||||
if (chatState != null) {
|
||||
await _onChatState(chatState, conversationJid);
|
||||
}
|
||||
|
||||
// Process message corrections separately
|
||||
if (event.messageCorrectionId != null) {
|
||||
if (event.extensions.get<LastMessageCorrectionData>() != null) {
|
||||
await _handleMessageCorrection(event, conversationJid);
|
||||
return;
|
||||
}
|
||||
|
||||
// Process File Upload Notifications replacements separately
|
||||
if (event.funReplacement != null) {
|
||||
if (event.extensions.get<FileUploadNotificationReplacementData>() != null) {
|
||||
await _handleFileUploadNotificationReplacement(event, conversationJid);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.messageRetraction != null) {
|
||||
if (event.extensions.get<MessageRetractionData>() != null) {
|
||||
await _handleMessageRetraction(event, conversationJid);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle message reactions
|
||||
if (event.messageReactions != null) {
|
||||
if (event.extensions.get<MessageReactionsData>() != null) {
|
||||
await _handleMessageReactions(event, conversationJid);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop the processing here if the event does not describe a displayable message
|
||||
if (!_isMessageEventMessage(event) &&
|
||||
event.other['encryption_error'] == null) return;
|
||||
if (event.other['encryption_error'] is InvalidKeyExchangeException) return;
|
||||
if (!_isMessageEventMessage(event) && event.encryptionError == null) return;
|
||||
if (event.encryptionError is InvalidKeyExchangeException) return;
|
||||
|
||||
// Ignore File Upload Notifications where we don't have a filename.
|
||||
if (event.fun != null && event.fun!.name == null) {
|
||||
final fun = event.extensions.get<FileUploadNotificationData>();
|
||||
if (fun != null && fun.metadata.name == null) {
|
||||
_log.finest(
|
||||
'Ignoring File Upload Notification as it does not specify a filename',
|
||||
);
|
||||
@ -1200,25 +1222,30 @@ class XmppService {
|
||||
// Is the conversation partner in our roster
|
||||
final isInRoster = rosterItem != null;
|
||||
// True if the message was sent by us (via a Carbon)
|
||||
final sent =
|
||||
event.isCarbon && event.fromJid.toBare().toString() == state.jid;
|
||||
final sent = isCarbon && event.from.toBare().toString() == state.jid;
|
||||
// The timestamp at which we received the message
|
||||
final messageTimestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
// Acknowledge the message if enabled
|
||||
if (event.deliveryReceiptRequested && isInRoster && prefs.sendChatMarkers) {
|
||||
final receiptRequested =
|
||||
event.extensions.get<MessageDeliveryReceiptData>()?.receiptRequested ??
|
||||
false;
|
||||
if (receiptRequested && isInRoster && prefs.sendChatMarkers) {
|
||||
// NOTE: We do not await it to prevent us being blocked if the IQ response id delayed
|
||||
await _acknowledgeMessage(event);
|
||||
}
|
||||
|
||||
// Pre-process the message in case it is a reply to another message
|
||||
// TODO(Unknown): Fix the notion that no body means body == ''
|
||||
final body = event.extensions.get<MessageBodyData>()?.body ?? '';
|
||||
final reply = event.extensions.get<ReplyData>();
|
||||
String? replyId;
|
||||
var messageBody = event.body;
|
||||
if (event.reply != null) {
|
||||
replyId = event.reply!.id;
|
||||
var messageBody = body;
|
||||
if (reply != null) {
|
||||
replyId = reply.id;
|
||||
|
||||
// Strip the compatibility fallback, if specified
|
||||
messageBody = event.reply!.removeFallback(messageBody);
|
||||
messageBody = reply.withoutFallback ?? body;
|
||||
_log.finest('Removed message reply compatibility fallback from message');
|
||||
}
|
||||
|
||||
@ -1261,18 +1288,21 @@ class XmppService {
|
||||
var message = await ms.addMessageFromData(
|
||||
messageBody,
|
||||
messageTimestamp,
|
||||
event.fromJid.toString(),
|
||||
event.from.toString(),
|
||||
conversationJid,
|
||||
event.sid,
|
||||
event.fun != null,
|
||||
event.id,
|
||||
fun != null,
|
||||
event.encrypted,
|
||||
event.messageProcessingHints?.contains(MessageProcessingHint.noStore) ??
|
||||
event.extensions
|
||||
.get<MessageProcessingHintData>()
|
||||
?.hints
|
||||
.contains(MessageProcessingHint.noStore) ??
|
||||
false,
|
||||
fileMetadata: fileMetadata?.fileMetadata,
|
||||
quoteId: replyId,
|
||||
originId: event.originId,
|
||||
errorType: errorTypeFromException(event.other['encryption_error']),
|
||||
stickerPackId: event.stickerPackId,
|
||||
originId: event.extensions.get<StableIdData>()?.originId,
|
||||
errorType: errorTypeFromException(event.encryptionError),
|
||||
stickerPackId: event.extensions.get<StickersData>()?.stickerPackId,
|
||||
);
|
||||
|
||||
// Attempt to auto-download the embedded file, if
|
||||
@ -1336,7 +1366,7 @@ class XmppService {
|
||||
rosterItem?.title ?? conversationJid.split('@')[0],
|
||||
message,
|
||||
ConversationType.chat,
|
||||
rosterItem?.avatarUrl ?? '',
|
||||
rosterItem?.avatarPath ?? '',
|
||||
conversationJid,
|
||||
sent ? 0 : 1,
|
||||
messageTimestamp,
|
||||
@ -1392,7 +1422,7 @@ class XmppService {
|
||||
}
|
||||
|
||||
// Mark the file as downlading when it includes a File Upload Notification
|
||||
if (event.fun != null) {
|
||||
if (fun != null) {
|
||||
message = await ms.updateMessage(
|
||||
message.id,
|
||||
isDownloading: true,
|
||||
@ -1408,8 +1438,10 @@ class XmppService {
|
||||
String conversationJid,
|
||||
) async {
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
var message =
|
||||
await ms.getMessageByStanzaId(conversationJid, event.funReplacement!);
|
||||
|
||||
final replacementId =
|
||||
event.extensions.get<FileUploadNotificationReplacementData>()!.id;
|
||||
var message = await ms.getMessageByStanzaId(conversationJid, replacementId);
|
||||
if (message == null) {
|
||||
_log.warning(
|
||||
'Received a FileUploadNotification replacement for unknown message',
|
||||
@ -1426,11 +1458,9 @@ class XmppService {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if (message.senderJid != event.from.toBare()) {
|
||||
_log.warning(
|
||||
'Received a FileUploadNotification replacement by $bareSender for message that is not sent by $bareSender',
|
||||
'Received a FileUploadNotification replacement by ${event.from} for a message that is sent by ${message.sender}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
@ -1454,8 +1484,8 @@ class XmppService {
|
||||
fileMetadata: fileMetadata ?? notSpecified,
|
||||
isFileUploadNotification: false,
|
||||
isDownloading: shouldDownload,
|
||||
sid: event.sid,
|
||||
originId: event.originId,
|
||||
sid: event.id,
|
||||
originId: event.extensions.get<StableIdData>()?.originId,
|
||||
);
|
||||
|
||||
// Remove the old entry
|
||||
@ -1493,7 +1523,7 @@ class XmppService {
|
||||
}
|
||||
|
||||
Future<void> _onAvatarUpdated(
|
||||
AvatarUpdatedEvent event, {
|
||||
UserAvatarUpdatedEvent event, {
|
||||
dynamic extra,
|
||||
}) async {
|
||||
await GetIt.I.get<AvatarService>().handleAvatarUpdate(event);
|
||||
|
10
lib/shared/debug.dart
Normal file
10
lib/shared/debug.dart
Normal file
@ -0,0 +1,10 @@
|
||||
enum DebugCommand {
|
||||
/// Clear the stream resumption state so that the next connection is fresh.
|
||||
clearStreamResumption(0),
|
||||
requestRoster(1);
|
||||
|
||||
const DebugCommand(this.id);
|
||||
|
||||
/// The id of the command
|
||||
final int id;
|
||||
}
|
@ -14,11 +14,11 @@ class ConversationChatStateConverter
|
||||
|
||||
@override
|
||||
ChatState fromJson(Map<String, dynamic> json) =>
|
||||
chatStateFromString(json['chatState'] as String);
|
||||
ChatState.fromName(json['chatState'] as String);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson(ChatState state) => <String, String>{
|
||||
'chatState': chatStateToString(state),
|
||||
'chatState': state.toName(),
|
||||
};
|
||||
}
|
||||
|
||||
@ -49,30 +49,52 @@ enum ConversationType {
|
||||
@freezed
|
||||
class Conversation with _$Conversation {
|
||||
factory Conversation(
|
||||
/// The title of the chat.
|
||||
String title,
|
||||
|
||||
// The newest message in the chat.
|
||||
@ConversationMessageConverter() Message? lastMessage,
|
||||
String avatarUrl,
|
||||
|
||||
// The path to the avatar.
|
||||
String avatarPath,
|
||||
|
||||
// The hash of the avatar.
|
||||
String? avatarHash,
|
||||
|
||||
// The JID of the entity we're having a chat with...
|
||||
String jid,
|
||||
|
||||
// The number of unread messages.
|
||||
int unreadCounter,
|
||||
|
||||
// The kind of chat this conversation is representing.
|
||||
ConversationType type,
|
||||
|
||||
// The timestamp the conversation was last changed.
|
||||
// NOTE: In milliseconds since Epoch or -1 if none has ever happened
|
||||
int lastChangeTimestamp,
|
||||
// Indicates if the conversation should be shown on the homescreen
|
||||
|
||||
// Indicates if the conversation should be shown on the homescreen.
|
||||
bool open,
|
||||
// Indicates, if [jid] is a regular user, if the user is in the roster.
|
||||
bool inRoster,
|
||||
// The subscription state of the roster item
|
||||
String subscription,
|
||||
|
||||
/// Flag indicating whether the "add to roster" button should be shown.
|
||||
bool showAddToRoster,
|
||||
|
||||
// Whether the chat is muted (true = muted, false = not muted)
|
||||
bool muted,
|
||||
|
||||
// Whether the conversation is encrypted or not (true = encrypted, false = unencrypted)
|
||||
bool encrypted,
|
||||
|
||||
// The current chat state
|
||||
@ConversationChatStateConverter() ChatState chatState, {
|
||||
|
||||
// The id of the contact in the device's phonebook if it exists
|
||||
String? contactId,
|
||||
|
||||
// The path to the contact avatar, if available
|
||||
String? contactAvatarPath,
|
||||
|
||||
// The contact's display name, if it exists
|
||||
String? contactDisplayName,
|
||||
}) = _Conversation;
|
||||
@ -85,16 +107,14 @@ class Conversation with _$Conversation {
|
||||
|
||||
factory Conversation.fromDatabaseJson(
|
||||
Map<String, dynamic> json,
|
||||
bool inRoster,
|
||||
String subscription,
|
||||
bool showAddToRoster,
|
||||
Message? lastMessage,
|
||||
) {
|
||||
return Conversation.fromJson({
|
||||
...json,
|
||||
'muted': intToBool(json['muted']! as int),
|
||||
'open': intToBool(json['open']! as int),
|
||||
'inRoster': inRoster,
|
||||
'subscription': subscription,
|
||||
'showAddToRoster': showAddToRoster,
|
||||
'encrypted': intToBool(json['encrypted']! as int),
|
||||
'chatState':
|
||||
const ConversationChatStateConverter().toJson(ChatState.gone),
|
||||
@ -107,8 +127,7 @@ class Conversation with _$Conversation {
|
||||
final map = toJson()
|
||||
..remove('id')
|
||||
..remove('chatState')
|
||||
..remove('inRoster')
|
||||
..remove('subscription')
|
||||
..remove('showAddToRoster')
|
||||
..remove('lastMessage');
|
||||
|
||||
return {
|
||||
@ -128,10 +147,10 @@ class Conversation with _$Conversation {
|
||||
/// XMPP avatar's path.
|
||||
String? get avatarPathWithOptionalContact {
|
||||
if (GetIt.I.get<PreferencesBloc>().state.enableContactIntegration) {
|
||||
return contactAvatarPath ?? avatarUrl;
|
||||
return contactAvatarPath ?? avatarPath;
|
||||
}
|
||||
|
||||
return avatarUrl;
|
||||
return avatarPath;
|
||||
}
|
||||
|
||||
/// The title of the chat. This returns, if enabled, first the contact's display
|
||||
|
@ -1,5 +1,6 @@
|
||||
import 'dart:convert';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
import 'package:moxxyv2/shared/error_types.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
@ -201,9 +202,12 @@ class Message with _$Message {
|
||||
/// Returns true if the message can be copied to the clipboard.
|
||||
bool get isCopyable => !isMedia && body.isNotEmpty && !isPseudoMessage;
|
||||
|
||||
/// Returns true if the message is a sticker
|
||||
/// Returns true if the message is a sticker.
|
||||
bool get isSticker => isMedia && stickerPackId != null && !isPseudoMessage;
|
||||
|
||||
/// True if the message is a media message
|
||||
/// True if the message is a media message.
|
||||
bool get isMedia => fileMetadata != null;
|
||||
|
||||
/// The JID of the sender in moxxmpp's format.
|
||||
JID get senderJid => JID.fromString(sender);
|
||||
}
|
||||
|
@ -8,7 +8,7 @@ part 'roster.g.dart';
|
||||
class RosterItem with _$RosterItem {
|
||||
factory RosterItem(
|
||||
int id,
|
||||
String avatarUrl,
|
||||
String avatarPath,
|
||||
String avatarHash,
|
||||
String jid,
|
||||
String title,
|
||||
@ -53,4 +53,24 @@ class RosterItem with _$RosterItem {
|
||||
'pseudoRosterItem': boolToInt(pseudoRosterItem),
|
||||
};
|
||||
}
|
||||
|
||||
/// Whether a conversation with this roster item should display the "Add to roster" button.
|
||||
bool get showAddToRosterButton {
|
||||
// Those chats are not dealt with on the roster
|
||||
if (pseudoRosterItem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A full presence subscription is already achieved. Nothing to do
|
||||
if (subscription == 'both') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// We are not yet waiting for a response to the presence request
|
||||
if (ask == 'subscribe' && ['none', 'from', 'to'].contains(subscription)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
@ -65,7 +65,7 @@ class AddContactBloc extends Bloc<AddContactEvent, AddContactState> {
|
||||
RequestedConversationEvent(
|
||||
result.conversation!.jid,
|
||||
result.conversation!.title,
|
||||
result.conversation!.avatarUrl,
|
||||
result.conversation!.avatarPath,
|
||||
removeUntilConversations: true,
|
||||
),
|
||||
);
|
||||
|
@ -102,7 +102,7 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
||||
emit(
|
||||
state.copyWith(
|
||||
conversation: state.conversation!.copyWith(
|
||||
inRoster: true,
|
||||
showAddToRoster: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
@ -53,7 +53,7 @@ class ConversationsBloc extends Bloc<ConversationsEvent, ConversationsState> {
|
||||
state.copyWith(
|
||||
displayName: event.displayName,
|
||||
jid: event.jid,
|
||||
avatarUrl: event.avatarUrl ?? '',
|
||||
avatarPath: event.avatarUrl ?? '',
|
||||
conversations: event.conversations..sort(compareConversation),
|
||||
),
|
||||
);
|
||||
@ -118,7 +118,7 @@ class ConversationsBloc extends Bloc<ConversationsEvent, ConversationsState> {
|
||||
) async {
|
||||
return emit(
|
||||
state.copyWith(
|
||||
avatarUrl: event.path,
|
||||
avatarPath: event.path,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -5,7 +5,7 @@ class ConversationsState with _$ConversationsState {
|
||||
factory ConversationsState({
|
||||
@Default(<Conversation>[]) List<Conversation> conversations,
|
||||
@Default('') String displayName,
|
||||
@Default('') String avatarUrl,
|
||||
@Default('') String avatarPath,
|
||||
@Default('') String jid,
|
||||
}) = _ConversationsState;
|
||||
}
|
||||
|
@ -90,16 +90,6 @@ class ProfileBloc extends Bloc<ProfileEvent, ProfileState> {
|
||||
SetSubscriptionStateEvent event,
|
||||
Emitter<ProfileState> emit,
|
||||
) async {
|
||||
emit(
|
||||
state.copyWith(
|
||||
conversation: state.conversation!.copyWith(
|
||||
// NOTE: This is wrong, but we just keep it like this until the real result comes
|
||||
// in.
|
||||
subscription: event.shareStatus ? 'to' : 'from',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||
SetShareOnlineStatusCommand(jid: event.jid, share: event.shareStatus),
|
||||
awaitable: false,
|
||||
|
@ -28,6 +28,7 @@ enum ShareSelectionType {
|
||||
class ShareListItem {
|
||||
const ShareListItem(
|
||||
this.avatarPath,
|
||||
this.avatarHash,
|
||||
this.jid,
|
||||
this.title,
|
||||
this.isConversation,
|
||||
@ -38,6 +39,7 @@ class ShareListItem {
|
||||
this.contactDisplayName,
|
||||
);
|
||||
final String avatarPath;
|
||||
final String? avatarHash;
|
||||
final String jid;
|
||||
final String title;
|
||||
final bool isConversation;
|
||||
@ -79,7 +81,8 @@ class ShareSelectionBloc
|
||||
final items = List<ShareListItem>.from(
|
||||
conversations.map((c) {
|
||||
return ShareListItem(
|
||||
c.avatarUrl,
|
||||
c.avatarPath,
|
||||
c.avatarHash,
|
||||
c.jid,
|
||||
c.title,
|
||||
true,
|
||||
@ -100,7 +103,8 @@ class ShareSelectionBloc
|
||||
if (index == -1) {
|
||||
items.add(
|
||||
ShareListItem(
|
||||
rosterItem.avatarUrl,
|
||||
rosterItem.avatarPath,
|
||||
rosterItem.avatarHash,
|
||||
rosterItem.jid,
|
||||
rosterItem.title,
|
||||
false,
|
||||
@ -113,7 +117,8 @@ class ShareSelectionBloc
|
||||
);
|
||||
} else {
|
||||
items[index] = ShareListItem(
|
||||
rosterItem.avatarUrl,
|
||||
rosterItem.avatarPath,
|
||||
rosterItem.avatarHash,
|
||||
rosterItem.jid,
|
||||
rosterItem.title,
|
||||
false,
|
||||
@ -187,7 +192,7 @@ class ShareSelectionBloc
|
||||
SendMessageCommand(
|
||||
recipients: _getRecipients(),
|
||||
body: state.text!,
|
||||
chatState: chatStateToString(ChatState.gone),
|
||||
chatState: ChatState.gone.toName(),
|
||||
),
|
||||
);
|
||||
|
||||
|
@ -324,7 +324,7 @@ class BidirectionalConversationController
|
||||
recipients: [conversationJid],
|
||||
body: text,
|
||||
quotedMessage: _quotedMessage,
|
||||
chatState: chatStateToString(ChatState.active),
|
||||
chatState: ChatState.active.toName(),
|
||||
editId: _messageEditingState?.id,
|
||||
editSid: _messageEditingState?.sid,
|
||||
currentConversationJid: conversationJid,
|
||||
|
@ -17,6 +17,7 @@ import 'package:moxxyv2/ui/bloc/profile_bloc.dart' as profile;
|
||||
import 'package:moxxyv2/ui/bloc/stickers_bloc.dart' as stickers;
|
||||
import 'package:moxxyv2/ui/controller/conversation_controller.dart';
|
||||
import 'package:moxxyv2/ui/prestart.dart';
|
||||
import 'package:moxxyv2/ui/service/avatars.dart';
|
||||
import 'package:moxxyv2/ui/service/progress.dart';
|
||||
|
||||
void setupEventHandler() {
|
||||
@ -34,6 +35,9 @@ void setupEventHandler() {
|
||||
EventTypeMatcher<ServiceReadyEvent>(onServiceReady),
|
||||
EventTypeMatcher<MessageNotificationTappedEvent>(onNotificationTappend),
|
||||
EventTypeMatcher<StickerPackAddedEvent>(onStickerPackAdded),
|
||||
EventTypeMatcher<StreamNegotiationsCompletedEvent>(
|
||||
onStreamNegotiationsDone,
|
||||
),
|
||||
]);
|
||||
|
||||
GetIt.I.registerSingleton<EventHandler>(handler);
|
||||
@ -173,7 +177,7 @@ Future<void> onNotificationTappend(
|
||||
conversation.RequestedConversationEvent(
|
||||
event.conversationJid,
|
||||
event.title,
|
||||
event.avatarUrl,
|
||||
event.avatarPath,
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -186,3 +190,12 @@ Future<void> onStickerPackAdded(
|
||||
stickers.StickerPackAddedEvent(event.stickerPack),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> onStreamNegotiationsDone(
|
||||
StreamNegotiationsCompletedEvent event, {
|
||||
dynamic extra,
|
||||
}) async {
|
||||
if (!event.resumed) {
|
||||
GetIt.I.get<UIAvatarsService>().resetCache();
|
||||
}
|
||||
}
|
||||
|
@ -448,9 +448,12 @@ class ConversationPageState extends State<ConversationPage>
|
||||
children: [
|
||||
BlocBuilder<ConversationBloc, ConversationState>(
|
||||
buildWhen: (prev, next) =>
|
||||
prev.conversation?.inRoster != next.conversation?.inRoster,
|
||||
prev.conversation?.showAddToRoster !=
|
||||
next.conversation?.showAddToRoster,
|
||||
builder: (context, state) {
|
||||
if ((state.conversation?.inRoster ?? false) ||
|
||||
final showAddToRoster =
|
||||
state.conversation?.showAddToRoster ?? false;
|
||||
if (!showAddToRoster ||
|
||||
state.conversation?.type == ConversationType.note) {
|
||||
return const SizedBox();
|
||||
}
|
||||
|
@ -49,7 +49,7 @@ class ConversationTopbar extends StatelessWidget
|
||||
|
||||
bool _shouldRebuild(ConversationState prev, ConversationState next) {
|
||||
return prev.conversation?.title != next.conversation?.title ||
|
||||
prev.conversation?.avatarUrl != next.conversation?.avatarUrl ||
|
||||
prev.conversation?.avatarPath != next.conversation?.avatarPath ||
|
||||
prev.conversation?.chatState != next.conversation?.chatState ||
|
||||
prev.conversation?.jid != next.conversation?.jid ||
|
||||
prev.conversation?.encrypted != next.conversation?.encrypted;
|
||||
@ -110,14 +110,17 @@ class ConversationTopbar extends StatelessWidget
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: RebuildOnContactIntegrationChange(
|
||||
builder: () => AvatarWrapper(
|
||||
radius: 25,
|
||||
avatarUrl: state.conversation
|
||||
?.avatarPathWithOptionalContact ??
|
||||
'',
|
||||
builder: () => CachingXMPPAvatar(
|
||||
jid: state.conversation?.jid ?? '',
|
||||
altText: state
|
||||
.conversation?.titleWithOptionalContact ??
|
||||
'A',
|
||||
radius: 25,
|
||||
hasContactId:
|
||||
state.conversation?.contactId != null,
|
||||
hash: state.conversation?.avatarHash,
|
||||
path: state.conversation?.avatarPath,
|
||||
shouldRequest: state.conversation != null,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -158,7 +158,7 @@ class ConversationsPageState extends State<ConversationsPage>
|
||||
RequestedConversationEvent(
|
||||
item.jid,
|
||||
item.title,
|
||||
item.avatarUrl,
|
||||
item.avatarPath,
|
||||
),
|
||||
),
|
||||
key: key,
|
||||
@ -251,7 +251,7 @@ class ConversationsPageState extends State<ConversationsPage>
|
||||
profile.ProfilePageRequestedEvent(
|
||||
true,
|
||||
jid: state.jid,
|
||||
avatarUrl: state.avatarUrl,
|
||||
avatarUrl: state.avatarPath,
|
||||
displayName: state.displayName,
|
||||
),
|
||||
);
|
||||
@ -265,10 +265,17 @@ class ConversationsPageState extends State<ConversationsPage>
|
||||
tag: 'self_profile_picture',
|
||||
child: Material(
|
||||
color: const Color.fromRGBO(0, 0, 0, 0),
|
||||
child: AvatarWrapper(
|
||||
// NOTE: We do not care about the avatar hash because
|
||||
// we just read it from the XMPP state in the
|
||||
// avatar service.
|
||||
child: CachingXMPPAvatar(
|
||||
radius: 20,
|
||||
avatarUrl: state.avatarUrl,
|
||||
path: state.avatarPath,
|
||||
altText: state.jid[0],
|
||||
altIcon: Icons.person,
|
||||
hasContactId: false,
|
||||
jid: state.jid,
|
||||
ownAvatar: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -108,14 +108,14 @@ class NewConversationPage extends StatelessWidget {
|
||||
false,
|
||||
false,
|
||||
),
|
||||
item.avatarUrl,
|
||||
item.avatarPath,
|
||||
item.avatarHash,
|
||||
item.jid,
|
||||
0,
|
||||
ConversationType.chat,
|
||||
0,
|
||||
true,
|
||||
true,
|
||||
'',
|
||||
false,
|
||||
false,
|
||||
ChatState.gone,
|
||||
@ -130,7 +130,7 @@ class NewConversationPage extends StatelessWidget {
|
||||
NewConversationAddedEvent(
|
||||
item.jid,
|
||||
item.title,
|
||||
item.avatarUrl,
|
||||
item.avatarPath,
|
||||
ConversationType.chat,
|
||||
),
|
||||
),
|
||||
|
@ -1,7 +1,10 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:moxplatform/moxplatform.dart';
|
||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||
import 'package:moxxyv2/shared/commands.dart';
|
||||
import 'package:moxxyv2/shared/debug.dart' as debug;
|
||||
import 'package:moxxyv2/shared/models/preferences.dart';
|
||||
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
@ -148,6 +151,28 @@ class DebuggingPage extends StatelessWidget {
|
||||
...kDebugMode
|
||||
? [
|
||||
const SectionTitle('Testing'),
|
||||
SettingsRow(
|
||||
title: 'Reset stream management state',
|
||||
onTap: () {
|
||||
MoxplatformPlugin.handler.getDataSender().sendData(
|
||||
DebugCommand(
|
||||
id: debug.DebugCommand.clearStreamResumption.id,
|
||||
),
|
||||
awaitable: false,
|
||||
);
|
||||
},
|
||||
),
|
||||
SettingsRow(
|
||||
title: 'Request roster',
|
||||
onTap: () {
|
||||
MoxplatformPlugin.handler.getDataSender().sendData(
|
||||
DebugCommand(
|
||||
id: debug.DebugCommand.requestRoster.id,
|
||||
),
|
||||
awaitable: false,
|
||||
);
|
||||
},
|
||||
),
|
||||
SettingsRow(
|
||||
title: 'Reset showDebugMenu state',
|
||||
onTap: () {
|
||||
|
@ -76,13 +76,13 @@ class ShareSelectionPage extends StatelessWidget {
|
||||
item.title,
|
||||
null,
|
||||
item.avatarPath,
|
||||
item.avatarHash,
|
||||
item.jid,
|
||||
0,
|
||||
ConversationType.chat,
|
||||
0,
|
||||
true,
|
||||
true,
|
||||
'',
|
||||
false,
|
||||
false,
|
||||
ChatState.gone,
|
||||
|
30
lib/ui/service/avatars.dart
Normal file
30
lib/ui/service/avatars.dart
Normal file
@ -0,0 +1,30 @@
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxplatform/moxplatform.dart';
|
||||
import 'package:moxxyv2/shared/commands.dart';
|
||||
|
||||
class UIAvatarsService {
|
||||
/// Logger
|
||||
final Logger _log = Logger('UIAvatarsService');
|
||||
|
||||
/// Mapping between a JID and whether we have requested an avatar for the
|
||||
/// JID already in the session (from login until stream resumption failure).
|
||||
final Map<String, bool> _avatarRequested = {};
|
||||
|
||||
void requestAvatarIfRequired(String jid, String? hash, bool ownAvatar) {
|
||||
if (_avatarRequested[jid] ?? false) return;
|
||||
|
||||
_log.finest('Requesting avatar for $jid');
|
||||
_avatarRequested[jid] = true;
|
||||
MoxplatformPlugin.handler.getDataSender().sendData(
|
||||
RequestAvatarForJidCommand(
|
||||
jid: jid,
|
||||
hash: hash,
|
||||
ownAvatar: ownAvatar,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void resetCache() {
|
||||
_avatarRequested.clear();
|
||||
}
|
||||
}
|
@ -1,6 +1,9 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
|
||||
import 'package:moxxyv2/ui/helpers.dart';
|
||||
import 'package:moxxyv2/ui/service/avatars.dart';
|
||||
import 'package:moxxyv2/ui/theme.dart';
|
||||
|
||||
class AvatarWrapper extends StatelessWidget {
|
||||
@ -99,3 +102,90 @@ class AvatarWrapper extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CachingXMPPAvatar extends StatefulWidget {
|
||||
const CachingXMPPAvatar({
|
||||
required this.jid,
|
||||
required this.altText,
|
||||
required this.radius,
|
||||
required this.hasContactId,
|
||||
this.altIcon,
|
||||
this.shouldRequest = true,
|
||||
this.ownAvatar = false,
|
||||
this.hash,
|
||||
this.path,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The JID of the entity.
|
||||
final String jid;
|
||||
|
||||
/// The hash of the JID's avatar or null, if we don't know of an avatar.
|
||||
final String? hash;
|
||||
|
||||
/// The alt-text, if [path] is null.
|
||||
final String altText;
|
||||
|
||||
/// The (potentially null) path to the avatar image.
|
||||
final String? path;
|
||||
|
||||
/// The radius of the avatar widget.
|
||||
final double radius;
|
||||
|
||||
/// Flag indicating whether the conversation has a contactId != null.
|
||||
final bool hasContactId;
|
||||
|
||||
/// Flag indicating whether a request for the avatar should happen or not.
|
||||
final bool shouldRequest;
|
||||
|
||||
/// Alt-icon, if [path] is null.
|
||||
final IconData? altIcon;
|
||||
|
||||
/// Flag indicating whether this avatar is our own avatar.
|
||||
final bool ownAvatar;
|
||||
|
||||
@override
|
||||
CachingXMPPAvatarState createState() => CachingXMPPAvatarState();
|
||||
}
|
||||
|
||||
class CachingXMPPAvatarState extends State<CachingXMPPAvatar> {
|
||||
void _performRequest() {
|
||||
// Only request the avatar if we don't have a contact integration avatar already.
|
||||
if (!GetIt.I.get<PreferencesBloc>().state.enableContactIntegration ||
|
||||
!widget.hasContactId) {
|
||||
GetIt.I.get<UIAvatarsService>().requestAvatarIfRequired(
|
||||
widget.jid,
|
||||
widget.hash,
|
||||
widget.ownAvatar,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
if (!widget.shouldRequest) return;
|
||||
|
||||
_performRequest();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(CachingXMPPAvatar oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
if (widget.shouldRequest && !oldWidget.shouldRequest) {
|
||||
_performRequest();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AvatarWrapper(
|
||||
avatarUrl: widget.path,
|
||||
altText: widget.altText,
|
||||
altIcon: widget.altIcon,
|
||||
radius: widget.radius,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -81,12 +81,12 @@ class ReactionList extends StatelessWidget {
|
||||
return ReactionsRow(
|
||||
avatar: ownReaction
|
||||
? AvatarWrapper(
|
||||
avatarUrl: bloc.state.avatarUrl,
|
||||
avatarUrl: bloc.state.avatarPath,
|
||||
radius: 35,
|
||||
altIcon: Icons.person,
|
||||
)
|
||||
: AvatarWrapper(
|
||||
avatarUrl: conversation?.avatarUrl,
|
||||
avatarUrl: conversation?.avatarPath,
|
||||
radius: 35,
|
||||
altIcon: Icons.person,
|
||||
),
|
||||
|
@ -180,10 +180,14 @@ class ConversationsListRowState extends State<ConversationsListRow> {
|
||||
Widget _buildAvatar() {
|
||||
return RebuildOnContactIntegrationChange(
|
||||
builder: () {
|
||||
final avatar = AvatarWrapper(
|
||||
final avatar = CachingXMPPAvatar(
|
||||
radius: 35,
|
||||
avatarUrl: widget.conversation.avatarPathWithOptionalContact,
|
||||
jid: widget.conversation.jid,
|
||||
hash: widget.conversation.avatarHash,
|
||||
altText: widget.conversation.titleWithOptionalContact,
|
||||
path: widget.conversation.avatarPathWithOptionalContact,
|
||||
hasContactId: widget.conversation.contactId != null,
|
||||
shouldRequest: widget.conversation.type != ConversationType.note,
|
||||
);
|
||||
|
||||
if (widget.enableAvatarOnTap &&
|
||||
|
@ -34,13 +34,6 @@
|
||||
<xmpp:version>2.5rc3</xmpp:version>
|
||||
</xmpp:SupportedXep>
|
||||
</implements>
|
||||
<implements>
|
||||
<xmpp:SupportedXep>
|
||||
<xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0054.html" />
|
||||
<xmpp:status>partial</xmpp:status>
|
||||
<xmpp:version>1.2</xmpp:version>
|
||||
</xmpp:SupportedXep>
|
||||
</implements>
|
||||
<implements>
|
||||
<xmpp:SupportedXep>
|
||||
<xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0060.html" />
|
||||
|
@ -954,10 +954,10 @@ packages:
|
||||
description:
|
||||
path: "packages/moxxmpp"
|
||||
ref: HEAD
|
||||
resolved-ref: "1475cb542f7d68824f8f4b11efd751cfb3eea428"
|
||||
resolved-ref: f2d8c6a009d3f08806e1565bf9b92407e659f178
|
||||
url: "https://codeberg.org/moxxy/moxxmpp.git"
|
||||
source: git
|
||||
version: "0.3.2"
|
||||
version: "0.4.0"
|
||||
moxxmpp_socket_tcp:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -71,7 +71,7 @@ dependencies:
|
||||
version: 0.1.15
|
||||
moxxmpp:
|
||||
hosted: https://git.polynom.me/api/packages/Moxxy/pub
|
||||
version: 0.3.1
|
||||
version: 0.4.0
|
||||
moxxmpp_socket_tcp:
|
||||
hosted: https://git.polynom.me/api/packages/Moxxy/pub
|
||||
version: 0.3.1
|
||||
@ -139,7 +139,7 @@ dependency_overrides:
|
||||
moxxmpp:
|
||||
git:
|
||||
url: https://codeberg.org/moxxy/moxxmpp.git
|
||||
rev: 1475cb542f7d68824f8f4b11efd751cfb3eea428
|
||||
rev: f2d8c6a009d3f08806e1565bf9b92407e659f178
|
||||
path: packages/moxxmpp
|
||||
|
||||
extra_licenses:
|
||||
|
19
scripts/check-pr.sh
Normal file
19
scripts/check-pr.sh
Normal file
@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
# Run before merging a PR. Checks if the formatting is correct.
|
||||
# Check if there are any formatting issues
|
||||
echo "Checking formatting..."
|
||||
dart format --output none --set-exit-if-changed . &> /dev/null
|
||||
if [[ ! "$?" = "0" ]]; then
|
||||
echo "Error: dart format indicates that format the code is not formatted properly"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if the linter has any issues
|
||||
echo "Checking linter..."
|
||||
flutter analyze &> /dev/null
|
||||
if [[ ! "$?" = "0" ]]; then
|
||||
echo "Error: flutter analyze indicates that there are lint issues"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "PR looks good!"
|
Loading…
Reference in New Issue
Block a user