Compare commits
72 Commits
86daad2455
...
v0.4.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 241a8b4d53 | |||
| 25d193e930 | |||
| e6924cc02d | |||
| 60985c6b37 | |||
| a015399b57 | |||
| 4b6c7998f3 | |||
| 26312e313f | |||
| b63b5d7fd2 | |||
| ca2943a94d | |||
| 32a4cd9361 | |||
| 2320e4ed17 | |||
| dee479a918 | |||
| 6895ef1e32 | |||
| 5c51eefa3e | |||
| 0d7ae321a7 | |||
| b4063a64e0 | |||
| 65154f2f5c | |||
| 19a22bd0d1 | |||
| a7da7baf5a | |||
| a344a94112 | |||
| f44861fead | |||
| 1c4a30ebb4 | |||
| 70e2ca3d3e | |||
| 0d4aee1625 | |||
| ad6aa33b7c | |||
| 284b5fa4df | |||
| b9aac0c3d7 | |||
| 6ce90e08ef | |||
| 5ac80d8d60 | |||
| 56e1fa52d8 | |||
| 3ae1b7d168 | |||
| d8f654c81c | |||
| cbcbd4d6dc | |||
| be899b5611 | |||
| 361bbe8d85 | |||
| 1e017af277 | |||
| c4c22a36bb | |||
| 84924b480b | |||
| 358074f4ee | |||
| 084314fbcf | |||
| c42f301ae0 | |||
| c8cd37e451 | |||
| 9f8f3a5407 | |||
| 6f1493808f | |||
| c9d32694db | |||
| 8632a2fc81 | |||
| 46a09d5b62 | |||
| b7e5bbc7d2 | |||
| ed264f0c16 | |||
| f1820575ad | |||
| d2e42d0a3c | |||
| 842cf5aaaa | |||
| c8f727e982 | |||
| fd3c9190de | |||
| 69439d2b13 | |||
| 6d41fee73f | |||
| 0de99adeed | |||
| f71fd7c82c | |||
| 0a6b0b8fa5 | |||
| 5e0ce8f098 | |||
| 9fc5989bd4 | |||
| cbe81861a5 | |||
| 76a03cc2fa | |||
| 3774760548 | |||
| 4b1942b949 | |||
|
|
2f03c02b58 | ||
|
|
639143934f | ||
|
|
81bbbcd8e4 | ||
|
|
bedd46756d | ||
|
|
bb6b342d82 | ||
| b6eb12cf30 | |||
| 80f8129011 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -60,3 +60,6 @@ lib/i18n/*.dart
|
||||
|
||||
# Android artifacts
|
||||
.android
|
||||
|
||||
# Build scripts
|
||||
release/
|
||||
|
||||
2
.gitlint
2
.gitlint
@@ -7,7 +7,7 @@ line-length=72
|
||||
[title-trailing-punctuation]
|
||||
[title-hard-tab]
|
||||
[title-match-regex]
|
||||
regex=^(feat|fix|chore|refactor|docs|release|test)\((xmpp|service|ui|shared|meta|tests|i18n)+(,(xmpp|service|ui|shared|meta|tests|i18n))*\): .*$
|
||||
regex=^((feat|fix|chore|refactor|docs|release|test)\((xmpp|service|ui|shared|meta|tests|i18n)+(,(xmpp|service|ui|shared|meta|tests|i18n))*\)|release): .*$
|
||||
|
||||
|
||||
[body-trailing-whitespace]
|
||||
|
||||
@@ -27,6 +27,31 @@
|
||||
"warningChannelDescription": "Warnings related to Moxxy"
|
||||
}
|
||||
},
|
||||
"dateTime": {
|
||||
"justNow": "Just now",
|
||||
"nMinutesAgo": "${min}min ago",
|
||||
"mondayAbbrev": "Mon",
|
||||
"tuesdayAbbrev": "Tue",
|
||||
"wednessdayAbbrev": "Wed",
|
||||
"thursdayAbbrev": "Thu",
|
||||
"fridayAbbrev": "Fri",
|
||||
"saturdayAbbrev": "Sat",
|
||||
"sundayAbbrev": "Sun",
|
||||
"january": "January",
|
||||
"february": "February",
|
||||
"march": "March",
|
||||
"april": "April",
|
||||
"may": "May",
|
||||
"june": "June",
|
||||
"july": "July",
|
||||
"august": "August",
|
||||
"september": "September",
|
||||
"october": "October",
|
||||
"november": "November",
|
||||
"december": "December",
|
||||
"today": "Today",
|
||||
"yesterday": "Yesterday"
|
||||
},
|
||||
"messages": {
|
||||
"image": "Image",
|
||||
"video": "Video",
|
||||
@@ -34,7 +59,8 @@
|
||||
"file": "File",
|
||||
"sticker": "Sticker",
|
||||
"retracted": "The message has been retracted",
|
||||
"retractedFallback": "A previous message has been retracted but your client does not support it"
|
||||
"retractedFallback": "A previous message has been retracted but your client does not support it",
|
||||
"you": "You"
|
||||
},
|
||||
"errors": {
|
||||
"omemo": {
|
||||
@@ -132,7 +158,12 @@
|
||||
"addToContactsBody": "Are you sure you want to add ${jid} to your contacts?",
|
||||
"stickerPickerNoStickersLine1": "You have no sticker packs installed.",
|
||||
"stickerPickerNoStickersLine2": "They can be installed in the sticker settings.",
|
||||
"stickerSettings": "Sticker settings"
|
||||
"stickerSettings": "Sticker settings",
|
||||
"newDeviceMessage": "${title} added a new encryption device",
|
||||
"messageHint": "Send a message...",
|
||||
"sendImages": "Send images",
|
||||
"sendFiles": "Send files",
|
||||
"takePhotos": "Take photos"
|
||||
},
|
||||
"addcontact": {
|
||||
"title": "Add new contact",
|
||||
@@ -213,13 +244,17 @@
|
||||
"signOutConfirmTitle": "Sign Out",
|
||||
"signOutConfirmBody": "You are about to sign out. Proceed?",
|
||||
"miscellaneousSection": "Miscellaneous",
|
||||
"debuggingSection": "Debugging"
|
||||
"debuggingSection": "Debugging",
|
||||
"general": "General"
|
||||
},
|
||||
"about": {
|
||||
"title": "About",
|
||||
"licensed": "Licensed under GPL3",
|
||||
"version": "Version ${version}",
|
||||
"viewSourceCode": "View source code"
|
||||
"viewSourceCode": "View source code",
|
||||
"nMoreToGo": "${n} more to go...",
|
||||
"debugMenuShown": "You are now a developer!",
|
||||
"debugMenuAlreadyShown": "You are already a developer!"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Appearance",
|
||||
@@ -290,7 +325,9 @@
|
||||
"cannotEnableRedirectSubtext": "You must first set a proxy service to redirect to. To do so, tap the field next to the switch.",
|
||||
"urlEmpty": "URL cannot be empty",
|
||||
"urlInvalid": "Invalid URL",
|
||||
"redirectDialogTitle": "$serviceName Redirect"
|
||||
"redirectDialogTitle": "$serviceName Redirect",
|
||||
"stickersPrivacy": "Keep sticker list public",
|
||||
"stickersPrivacySubtext": "If enabled, everyone will be able to see your list of installed sticker packs."
|
||||
},
|
||||
"stickers": {
|
||||
"title": "Stickers",
|
||||
|
||||
@@ -27,6 +27,31 @@
|
||||
"warningChannelDescription": "Warnungen im Bezug auf Moxxy"
|
||||
}
|
||||
},
|
||||
"dateTime": {
|
||||
"justNow": "Gerade",
|
||||
"nMinutesAgo": "vor ${min}min",
|
||||
"mondayAbbrev": "Mon",
|
||||
"tuesdayAbbrev": "Die",
|
||||
"wednessdayAbbrev": "Mit",
|
||||
"thursdayAbbrev": "Don",
|
||||
"fridayAbbrev": "Fre",
|
||||
"saturdayAbbrev": "Sam",
|
||||
"sundayAbbrev": "Son",
|
||||
"january": "Januar",
|
||||
"february": "Februar",
|
||||
"march": "März",
|
||||
"april": "April",
|
||||
"may": "Mai",
|
||||
"june": "Juni",
|
||||
"july": "Juli",
|
||||
"august": "August",
|
||||
"september": "September",
|
||||
"october": "Oktober",
|
||||
"november": "November",
|
||||
"december": "Dezember",
|
||||
"today": "Heute",
|
||||
"yesterday": "Gestern"
|
||||
},
|
||||
"messages": {
|
||||
"image": "Bild",
|
||||
"video": "Video",
|
||||
@@ -34,7 +59,8 @@
|
||||
"file": "Datei",
|
||||
"sticker": "Sticker",
|
||||
"retracted": "Die Nachricht wurde zurückgezogen",
|
||||
"retractedFallback": "Eine vorherige Nachricht wurde zurückgezogen. Dein Client unterstüzt dies jedoch nicht"
|
||||
"retractedFallback": "Eine vorherige Nachricht wurde zurückgezogen. Dein Client unterstüzt dies jedoch nicht",
|
||||
"you": "Du"
|
||||
},
|
||||
"errors": {
|
||||
"omemo": {
|
||||
@@ -132,7 +158,12 @@
|
||||
"addToContactsBody": "Bist du dir sicher, dass du ${jid} zu deinen Kontakten hinzufügen möchtest?",
|
||||
"stickerPickerNoStickersLine1": "Du hast keine Stickerpacks installiert.",
|
||||
"stickerPickerNoStickersLine2": "Diese können in den Stickereinstellungen installiert werden.",
|
||||
"stickerSettings": "Stickereinstellungen"
|
||||
"stickerSettings": "Stickereinstellungen",
|
||||
"newDeviceMessage": "${title} hat ein neues Verschlüsselungsgerät hinzugefügt",
|
||||
"messageHint": "Nachricht senden...",
|
||||
"sendImages": "Bilder senden",
|
||||
"sendFiles": "Dateien senden",
|
||||
"takePhotos": "Bilder aufnehmen"
|
||||
},
|
||||
"addcontact": {
|
||||
"title": "Neuen Kontakt hinzufügen",
|
||||
@@ -213,13 +244,17 @@
|
||||
"signOutConfirmTitle": "Abmelden",
|
||||
"signOutConfirmBody": "Du bist dabei dich abzumelden. Fortfahren?",
|
||||
"miscellaneousSection": "Unterschiedlich",
|
||||
"debuggingSection": "Debugging"
|
||||
"debuggingSection": "Debugging",
|
||||
"general": "Generell"
|
||||
},
|
||||
"about": {
|
||||
"title": "Über",
|
||||
"licensed": "Lizensiert unter GPL3",
|
||||
"version": "Version ${version}",
|
||||
"viewSourceCode": "Quellcode anschauen"
|
||||
"viewSourceCode": "Quellcode anschauen",
|
||||
"nMoreToGo": "Noch ${n}...",
|
||||
"debugMenuShown": "Du bist jetzt ein Entwickler!",
|
||||
"debugMenuAlreadyShown": "Du bist bereits ein Entwickler!"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Aussehen",
|
||||
@@ -290,7 +325,9 @@
|
||||
"cannotEnableRedirectSubtext": "Du must zuerst einen Proxydienst auswählen. Dazu berühre das Feld neben dem Schalter.",
|
||||
"urlEmpty": "URL kann nicht leer sein",
|
||||
"urlInvalid": "Ungültige URL",
|
||||
"redirectDialogTitle": "${serviceName}weiterleitung"
|
||||
"redirectDialogTitle": "${serviceName}weiterleitung",
|
||||
"stickersPrivacy": "Stickerliste öffentlich halten",
|
||||
"stickersPrivacySubtext": "Wenn eingeschaltet, dann kann jeder die Liste Deiner installierten Stickerpacks sehen."
|
||||
},
|
||||
"stickers": {
|
||||
"title": "Stickers",
|
||||
|
||||
7
fastlane/metadata/android/en-US/changelogs/9.txt
Normal file
7
fastlane/metadata/android/en-US/changelogs/9.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
* Expose the debug menu by tapping the Moxxy icon on the about page 10 times
|
||||
* Maybe fix a connection race condition
|
||||
* Allow sharing media with the app when it was closed
|
||||
* Make quotes prettier
|
||||
* Make the bottom part of the conversation page prettier
|
||||
* Fix roster fetching
|
||||
* Fix OMEMO key generation
|
||||
@@ -10,12 +10,14 @@ Currently supported features include:
|
||||
<li>Typing indicators and message markers</li>
|
||||
<li>Chat backgrounds</li>
|
||||
<li>Runs in the background without Push Notifications</li>
|
||||
<li>OMEMO (Currently not compatible with most apps)</li>
|
||||
<li>Stickers</li>
|
||||
</ul>
|
||||
|
||||
For the best experience, I recommend a server that:
|
||||
<ul>
|
||||
<li>Supports direct TLS/StartTLS on the same domain as in the Jid</li>
|
||||
<li>Supports SCRAM-SHA-1 or SCRAM-SHA-256</li>
|
||||
<li>Supports SCRAM-SHA-1, SCRAM-SHA-256 or SCRAM-SHA-512</li>
|
||||
<li>Supports HTTP File Upload</li>
|
||||
<li>Supports Stream Management</li>
|
||||
<li>Supports Client State Indication</li>
|
||||
|
||||
@@ -106,6 +106,13 @@ files:
|
||||
extends: BackgroundEvent
|
||||
implements:
|
||||
- JsonImplementation
|
||||
# Triggered in response to a [GetBlocklistCommand]
|
||||
- name: GetBlocklistResultEvent
|
||||
extends: BackgroundEvent
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
entries: List<String>
|
||||
# Triggered by DownloadService or UploadService.
|
||||
- name: ProgressEvent
|
||||
extends: BackgroundEvent
|
||||
@@ -527,6 +534,11 @@ files:
|
||||
stickerPack:
|
||||
type: StickerPack
|
||||
deserialise: true
|
||||
- name: GetBlocklistCommand
|
||||
extends: BackgroundCommand
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
generate_builder: true
|
||||
# get${builder_Name}FromJson
|
||||
builder_name: "Command"
|
||||
|
||||
@@ -65,9 +65,9 @@ import 'package:moxxyv2/ui/pages/sticker_pack.dart';
|
||||
import 'package:moxxyv2/ui/pages/util/qrcode.dart';
|
||||
import 'package:moxxyv2/ui/service/data.dart';
|
||||
import 'package:moxxyv2/ui/service/progress.dart';
|
||||
import 'package:moxxyv2/ui/service/sharing.dart';
|
||||
import 'package:moxxyv2/ui/theme.dart';
|
||||
import 'package:page_transition/page_transition.dart';
|
||||
import 'package:share_handler/share_handler.dart';
|
||||
|
||||
void setupLogging() {
|
||||
Logger.root.level = kDebugMode ? Level.ALL : Level.INFO;
|
||||
@@ -81,6 +81,7 @@ void setupLogging() {
|
||||
Future<void> setupUIServices() async {
|
||||
GetIt.I.registerSingleton<UIProgressService>(UIProgressService());
|
||||
GetIt.I.registerSingleton<UIDataService>(UIDataService());
|
||||
GetIt.I.registerSingleton<UISharingService>(UISharingService());
|
||||
}
|
||||
|
||||
void setupBlocs(GlobalKey<NavigatorState> navKey) {
|
||||
@@ -88,7 +89,8 @@ void setupBlocs(GlobalKey<NavigatorState> navKey) {
|
||||
GetIt.I.registerSingleton<ConversationsBloc>(ConversationsBloc());
|
||||
GetIt.I.registerSingleton<NewConversationBloc>(NewConversationBloc());
|
||||
GetIt.I.registerSingleton<ConversationBloc>(ConversationBloc());
|
||||
GetIt.I.registerSingleton<BlocklistBloc>(BlocklistBloc()); GetIt.I.registerSingleton<ProfileBloc>(ProfileBloc());
|
||||
GetIt.I.registerSingleton<BlocklistBloc>(BlocklistBloc());
|
||||
GetIt.I.registerSingleton<ProfileBloc>(ProfileBloc());
|
||||
GetIt.I.registerSingleton<PreferencesBloc>(PreferencesBloc());
|
||||
GetIt.I.registerSingleton<AddContactBloc>(AddContactBloc());
|
||||
GetIt.I.registerSingleton<SharedMediaBloc>(SharedMediaBloc());
|
||||
@@ -103,9 +105,6 @@ void setupBlocs(GlobalKey<NavigatorState> navKey) {
|
||||
GetIt.I.registerSingleton<StickerPackBloc>(StickerPackBloc());
|
||||
}
|
||||
|
||||
// TODO(Unknown): Replace all Column(children: [ Padding(), Padding, ...]) with a
|
||||
// Padding(padding: ..., child: Column(children: [ ... ]))
|
||||
// TODO(Unknown): Theme the switches
|
||||
void main() async {
|
||||
setupLogging();
|
||||
await setupUIServices();
|
||||
@@ -186,7 +185,6 @@ void main() async {
|
||||
}
|
||||
|
||||
class MyApp extends StatefulWidget {
|
||||
|
||||
const MyApp(this.navigationKey, { super.key });
|
||||
final GlobalKey<NavigatorState> navigationKey;
|
||||
|
||||
@@ -200,46 +198,18 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initState();
|
||||
}
|
||||
|
||||
/// Async "version" of initState()
|
||||
Future<void> _initState() async {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
_setupSharingHandler();
|
||||
// Set up receiving share intents
|
||||
await GetIt.I.get<UISharingService>().initialize();
|
||||
|
||||
// Lift the UI block
|
||||
GetIt.I.get<SynchronizedQueue<Map<String, dynamic>?>>().removeQueueLock();
|
||||
}
|
||||
|
||||
Future<void> _handleSharedMedia(SharedMedia media) async {
|
||||
final attachments = media.attachments ?? [];
|
||||
GetIt.I.get<ShareSelectionBloc>().add(
|
||||
ShareSelectionRequestedEvent(
|
||||
attachments.map((a) => a!.path).toList(),
|
||||
media.content,
|
||||
media.content != null ? ShareSelectionType.text : ShareSelectionType.media,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _setupSharingHandler() async {
|
||||
final handler = ShareHandlerPlatform.instance;
|
||||
final media = await handler.getInitialSharedMedia();
|
||||
|
||||
// Shared while the app was closed
|
||||
if (media != null) {
|
||||
if (GetIt.I.get<UIDataService>().isLoggedIn) {
|
||||
await _handleSharedMedia(media);
|
||||
}
|
||||
|
||||
await handler.resetInitialSharedMedia();
|
||||
}
|
||||
|
||||
// Shared while the app is stil running
|
||||
handler.sharedMediaStream.listen((SharedMedia media) async {
|
||||
if (GetIt.I.get<UIDataService>().isLoggedIn) {
|
||||
await _handleSharedMedia(media);
|
||||
}
|
||||
|
||||
await handler.resetInitialSharedMedia();
|
||||
});
|
||||
await GetIt.I.get<SynchronizedQueue<Map<String, dynamic>?>>().removeQueueLock();
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'package:cryptography/cryptography.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:hex/hex.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxlib/moxlib.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/conversation.dart';
|
||||
import 'package:moxxyv2/service/preferences.dart';
|
||||
@@ -26,55 +25,48 @@ String _cleanBase64String(String original) {
|
||||
return ret;
|
||||
}
|
||||
|
||||
class _AvatarData {
|
||||
const _AvatarData(this.data, this.id);
|
||||
final List<int> data;
|
||||
final String id;
|
||||
}
|
||||
|
||||
class AvatarService {
|
||||
AvatarService() : _log = Logger('AvatarService');
|
||||
final Logger _log;
|
||||
final Logger _log = Logger('AvatarService');
|
||||
|
||||
UserAvatarManager _getUserAvatarManager() => GetIt.I.get<XmppConnection>().getManagerById<UserAvatarManager>(userAvatarManager)!;
|
||||
|
||||
DiscoManager _getDiscoManager() => GetIt.I.get<XmppConnection>().getManagerById<DiscoManager>(discoManager)!;
|
||||
Future<void> handleAvatarUpdate(AvatarUpdatedEvent event) async {
|
||||
await updateAvatarForJid(
|
||||
event.jid,
|
||||
event.hash,
|
||||
base64Decode(_cleanBase64String(event.base64)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> updateAvatarForJid(String jid, String hash, String base64) async {
|
||||
Future<void> updateAvatarForJid(String 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);
|
||||
var saved = false;
|
||||
final originalRoster = await rs.getRosterItemByJid(jid);
|
||||
|
||||
// Clean the raw data. Since this may arrive by chunks, those chunks may contain
|
||||
// weird data pieces.
|
||||
final base64Data = base64Decode(_cleanBase64String(base64));
|
||||
if (originalConversation == null && originalRoster == null) return;
|
||||
|
||||
final avatarPath = await saveAvatarInCache(
|
||||
data,
|
||||
hash,
|
||||
jid,
|
||||
(originalConversation?.avatarUrl ?? originalRoster?.avatarUrl)!,
|
||||
);
|
||||
|
||||
if (originalConversation != null) {
|
||||
final avatarPath = await saveAvatarInCache(
|
||||
base64Data,
|
||||
hash,
|
||||
jid,
|
||||
originalConversation.avatarUrl,
|
||||
);
|
||||
saved = true;
|
||||
final conv = await cs.updateConversation(
|
||||
originalConversation.id,
|
||||
avatarUrl: avatarPath,
|
||||
);
|
||||
|
||||
sendEvent(ConversationUpdatedEvent(conversation: conv));
|
||||
} else {
|
||||
_log.warning('Failed to get conversation');
|
||||
}
|
||||
|
||||
final originalRoster = await rs.getRosterItemByJid(jid);
|
||||
|
||||
if (originalRoster != null) {
|
||||
var avatarPath = '';
|
||||
if (saved) {
|
||||
avatarPath = await getAvatarPath(jid, hash);
|
||||
} else {
|
||||
avatarPath = await saveAvatarInCache(
|
||||
base64Data,
|
||||
hash,
|
||||
jid,
|
||||
originalRoster.avatarUrl,
|
||||
);
|
||||
}
|
||||
|
||||
final roster = await rs.updateRosterItem(
|
||||
originalRoster.id,
|
||||
avatarUrl: avatarPath,
|
||||
@@ -84,66 +76,73 @@ class AvatarService {
|
||||
sendEvent(RosterDiffEvent(modified: [roster]));
|
||||
}
|
||||
}
|
||||
|
||||
Future<_AvatarData?> _handleUserAvatar(String jid, String oldHash) async {
|
||||
final am = GetIt.I.get<XmppConnection>()
|
||||
.getManagerById<UserAvatarManager>(userAvatarManager)!;
|
||||
final idResult = await am.getAvatarId(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 {
|
||||
final response = await _getDiscoManager().discoItemsQuery(jid);
|
||||
final items = response.isType<DiscoError>() ?
|
||||
<DiscoItem>[] :
|
||||
response.get<List<DiscoItem>>();
|
||||
final itemNodes = items.map((i) => i.node);
|
||||
_AvatarData? data;
|
||||
data ??= await _handleUserAvatar(jid, oldHash);
|
||||
data ??= await _handleVcardAvatar(jid, oldHash);
|
||||
|
||||
_log.finest('Disco items for $jid:');
|
||||
for (final item in itemNodes) {
|
||||
_log.finest('- $item');
|
||||
if (data != null) {
|
||||
await updateAvatarForJid(jid, data.id, data.data);
|
||||
}
|
||||
|
||||
var base64 = '';
|
||||
var hash = '';
|
||||
if (listContains<DiscoItem>(items, (item) => item.node == userAvatarDataXmlns)) {
|
||||
final avatar = _getUserAvatarManager();
|
||||
final pubsubHash = await avatar.getAvatarId(jid);
|
||||
|
||||
// Don't request if we already have the newest avatar
|
||||
if (pubsubHash == oldHash) return;
|
||||
|
||||
// Query via PubSub
|
||||
final data = await avatar.getUserAvatar(jid);
|
||||
if (data == null) return;
|
||||
|
||||
base64 = data.base64;
|
||||
hash = data.hash;
|
||||
} else {
|
||||
// Query the vCard
|
||||
final vm = GetIt.I.get<XmppConnection>().getManagerById<VCardManager>(vcardManager)!;
|
||||
final vcard = await vm.requestVCard(jid);
|
||||
if (vcard != null) {
|
||||
final binval = vcard.photo?.binval;
|
||||
if (binval != null) {
|
||||
// Clean the raw data. Since this may arrive by chunks, those chunks may contain
|
||||
// weird data pieces.
|
||||
base64 = _cleanBase64String(binval);
|
||||
|
||||
final rawHash = await Sha1().hash(base64Decode(base64));
|
||||
hash = HEX.encode(rawHash.bytes);
|
||||
|
||||
vm.setLastHash(jid, hash);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await updateAvatarForJid(jid, hash, base64);
|
||||
}
|
||||
|
||||
Future<bool> subscribeJid(String jid) async {
|
||||
return _getUserAvatarManager().subscribe(jid);
|
||||
return (await GetIt.I.get<XmppConnection>()
|
||||
.getManagerById<UserAvatarManager>(userAvatarManager)!
|
||||
.subscribe(jid)).isType<bool>();
|
||||
}
|
||||
|
||||
Future<bool> unsubscribeJid(String jid) async {
|
||||
return _getUserAvatarManager().unsubscribe(jid);
|
||||
return (await GetIt.I.get<XmppConnection>()
|
||||
.getManagerById<UserAvatarManager>(userAvatarManager)!
|
||||
.unsubscribe(jid)).isType<bool>();
|
||||
}
|
||||
|
||||
/// Publishes the data at [path] as an avatar with PubSub ID
|
||||
@@ -160,13 +159,22 @@ class AvatarService {
|
||||
final imageSize = (await getImageSizeFromData(bytes))!;
|
||||
|
||||
// Publish data and metadata
|
||||
final manager = _getUserAvatarManager();
|
||||
await manager.publishUserAvatar(
|
||||
final am = GetIt.I.get<XmppConnection>()
|
||||
.getManagerById<UserAvatarManager>(userAvatarManager)!;
|
||||
|
||||
_log.finest('Publishing avatar...');
|
||||
final dataResult = await am.publishUserAvatar(
|
||||
base64,
|
||||
hash,
|
||||
public,
|
||||
);
|
||||
await manager.publishUserAvatarMetadata(
|
||||
if (dataResult.isType<AvatarError>()) {
|
||||
_log.finest('Avatar data publishing failed');
|
||||
return false;
|
||||
}
|
||||
|
||||
// TODO(Unknown): Make sure that the image is not too large.
|
||||
final metadataResult = await am.publishUserAvatarMetadata(
|
||||
UserAvatarMetadata(
|
||||
hash,
|
||||
bytes.length,
|
||||
@@ -177,39 +185,51 @@ class AvatarService {
|
||||
),
|
||||
public,
|
||||
);
|
||||
if (metadataResult.isType<AvatarError>()) {
|
||||
_log.finest('Avatar metadata publishing failed');
|
||||
return false;
|
||||
}
|
||||
|
||||
_log.finest('Avatar publishing done');
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> requestOwnAvatar() async {
|
||||
final avatar = _getUserAvatarManager();
|
||||
final am = GetIt.I.get<XmppConnection>()
|
||||
.getManagerById<UserAvatarManager>(userAvatarManager)!;
|
||||
final xmpp = GetIt.I.get<XmppService>();
|
||||
final state = await xmpp.getXmppState();
|
||||
final jid = state.jid!;
|
||||
final id = await avatar.getAvatarId(jid);
|
||||
|
||||
final idResult = await am.getAvatarId(jid);
|
||||
if (idResult.isType<AvatarError>()) {
|
||||
_log.info('Error while getting latest avatar id for own avatar');
|
||||
return;
|
||||
}
|
||||
final id = idResult.get<String>();
|
||||
|
||||
if (id == state.avatarHash) return;
|
||||
|
||||
_log.info('Mismatch between saved avatar data and server-side avatar data about ourself');
|
||||
final data = await avatar.getUserAvatar(jid);
|
||||
if (data == null) {
|
||||
final avatarDataResult = await am.getUserAvatar(jid);
|
||||
if (avatarDataResult.isType<AvatarError>()) {
|
||||
_log.severe('Failed to fetch our avatar');
|
||||
return;
|
||||
}
|
||||
final avatarData = avatarDataResult.get<UserAvatar>();
|
||||
|
||||
_log.info('Received data for our own avatar');
|
||||
|
||||
final avatarPath = await saveAvatarInCache(
|
||||
base64Decode(_cleanBase64String(data.base64)),
|
||||
data.hash,
|
||||
base64Decode(_cleanBase64String(avatarData.base64)),
|
||||
avatarData.hash,
|
||||
jid,
|
||||
state.avatarUrl,
|
||||
);
|
||||
await xmpp.modifyXmppState((state) => state.copyWith(
|
||||
avatarUrl: avatarPath,
|
||||
avatarHash: data.hash,
|
||||
avatarHash: avatarData.hash,
|
||||
),);
|
||||
|
||||
sendEvent(SelfAvatarChangedEvent(path: avatarPath, hash: data.hash));
|
||||
sendEvent(SelfAvatarChangedEvent(path: avatarPath, hash: avatarData.hash));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
|
||||
@@ -9,35 +12,93 @@ enum BlockPushType {
|
||||
}
|
||||
|
||||
class BlocklistService {
|
||||
|
||||
BlocklistService() :
|
||||
_blocklistCache = List.empty(growable: true),
|
||||
_requestedBlocklist = false;
|
||||
final List<String> _blocklistCache;
|
||||
bool _requestedBlocklist;
|
||||
BlocklistService();
|
||||
List<String>? _blocklist;
|
||||
bool _requested = false;
|
||||
bool? _supported;
|
||||
final Logger _log = Logger('BlocklistService');
|
||||
|
||||
Future<List<String>> _requestBlocklist() async {
|
||||
final manager = GetIt.I.get<XmppConnection>().getManagerById<BlockingManager>(blockingManager)!;
|
||||
_blocklistCache
|
||||
..clear()
|
||||
..addAll(await manager.getBlocklist());
|
||||
_requestedBlocklist = true;
|
||||
return _blocklistCache;
|
||||
void onNewConnection() {
|
||||
// Invalidate the caches
|
||||
_blocklist = null;
|
||||
_requested = false;
|
||||
_supported = null;
|
||||
}
|
||||
|
||||
Future<bool> _checkSupport() async {
|
||||
return _supported ??= await GetIt.I.get<XmppConnection>()
|
||||
.getManagerById<BlockingManager>(blockingManager)!
|
||||
.isSupported();
|
||||
}
|
||||
|
||||
Future<void> _requestBlocklist() async {
|
||||
assert(_blocklist != null, 'The blocklist must be loaded from the database before requesting');
|
||||
|
||||
// Check if blocking is supported
|
||||
if (!(await _checkSupport())) {
|
||||
_log.warning('Blocklist requested but server does not support it.');
|
||||
return;
|
||||
}
|
||||
|
||||
final blocklist = await GetIt.I.get<XmppConnection>()
|
||||
.getManagerById<BlockingManager>(blockingManager)!
|
||||
.getBlocklist();
|
||||
|
||||
// Diff the received blocklist with the cache
|
||||
final newItems = List<String>.empty(growable: true);
|
||||
final removedItems = List<String>.empty(growable: true);
|
||||
final db = GetIt.I.get<DatabaseService>();
|
||||
for (final item in blocklist) {
|
||||
if (!_blocklist!.contains(item)) {
|
||||
await db.addBlocklistEntry(item);
|
||||
_blocklist!.add(item);
|
||||
newItems.add(item);
|
||||
}
|
||||
}
|
||||
|
||||
// Diff the cache with the received blocklist
|
||||
for (final item in _blocklist!) {
|
||||
if (!blocklist.contains(item)) {
|
||||
await db.removeBlocklistEntry(item);
|
||||
_blocklist!.remove(item);
|
||||
removedItems.add(item);
|
||||
}
|
||||
}
|
||||
|
||||
_requested = true;
|
||||
|
||||
// Trigger an UI event if we have anything to tell the UI
|
||||
if (newItems.isNotEmpty || removedItems.isNotEmpty) {
|
||||
sendEvent(
|
||||
BlocklistPushEvent(
|
||||
added: newItems,
|
||||
removed: removedItems,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the blocklist from the database
|
||||
Future<List<String>> getBlocklist() async {
|
||||
if (!_requestedBlocklist) {
|
||||
_blocklistCache
|
||||
..clear()
|
||||
..addAll(await _requestBlocklist());
|
||||
if (_blocklist == null) {
|
||||
_blocklist = await GetIt.I.get<DatabaseService>().getBlocklistEntries();
|
||||
|
||||
if (!_requested) {
|
||||
unawaited(_requestBlocklist());
|
||||
}
|
||||
|
||||
return _blocklist!;
|
||||
}
|
||||
|
||||
return _blocklistCache;
|
||||
|
||||
if (!_requested) {
|
||||
unawaited(_requestBlocklist());
|
||||
}
|
||||
|
||||
return _blocklist!;
|
||||
}
|
||||
|
||||
void onUnblockAllPush() {
|
||||
_blocklistCache.clear();
|
||||
_blocklist = List<String>.empty(growable: true);
|
||||
sendEvent(
|
||||
BlocklistUnblockAllEvent(),
|
||||
);
|
||||
@@ -45,21 +106,25 @@ class BlocklistService {
|
||||
|
||||
Future<void> onBlocklistPush(BlockPushType type, List<String> items) async {
|
||||
// We will fetch it later when getBlocklist is called
|
||||
if (!_requestedBlocklist) return;
|
||||
if (!_requested) return;
|
||||
|
||||
final newBlocks = List<String>.empty(growable: true);
|
||||
final removedBlocks = List<String>.empty(growable: true);
|
||||
for (final item in items) {
|
||||
switch (type) {
|
||||
case BlockPushType.block: {
|
||||
if (_blocklistCache.contains(item)) continue;
|
||||
_blocklistCache.add(item);
|
||||
if (_blocklist!.contains(item)) continue;
|
||||
_blocklist!.add(item);
|
||||
newBlocks.add(item);
|
||||
|
||||
await GetIt.I.get<DatabaseService>().addBlocklistEntry(item);
|
||||
}
|
||||
break;
|
||||
case BlockPushType.unblock: {
|
||||
_blocklistCache.removeWhere((i) => i == item);
|
||||
_blocklist!.removeWhere((i) => i == item);
|
||||
removedBlocks.add(item);
|
||||
|
||||
await GetIt.I.get<DatabaseService>().removeBlocklistEntry(item);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -74,17 +139,47 @@ class BlocklistService {
|
||||
}
|
||||
|
||||
Future<bool> blockJid(String jid) async {
|
||||
final manager = GetIt.I.get<XmppConnection>().getManagerById<BlockingManager>(blockingManager)!;
|
||||
return manager.block([ jid ]);
|
||||
// Check if blocking is supported
|
||||
if (!(await _checkSupport())) {
|
||||
_log.warning('Blocking $jid requested but server does not support it.');
|
||||
return false;
|
||||
}
|
||||
|
||||
_blocklist!.add(jid);
|
||||
await GetIt.I.get<DatabaseService>()
|
||||
.addBlocklistEntry(jid);
|
||||
return GetIt.I.get<XmppConnection>()
|
||||
.getManagerById<BlockingManager>(blockingManager)!
|
||||
.block([jid]);
|
||||
}
|
||||
|
||||
Future<bool> unblockJid(String jid) async {
|
||||
final manager = GetIt.I.get<XmppConnection>().getManagerById<BlockingManager>(blockingManager)!;
|
||||
return manager.unblock([ jid ]);
|
||||
// Check if blocking is supported
|
||||
if (!(await _checkSupport())) {
|
||||
_log.warning('Unblocking $jid requested but server does not support it.');
|
||||
return false;
|
||||
}
|
||||
|
||||
_blocklist!.remove(jid);
|
||||
await GetIt.I.get<DatabaseService>()
|
||||
.removeBlocklistEntry(jid);
|
||||
return GetIt.I.get<XmppConnection>()
|
||||
.getManagerById<BlockingManager>(blockingManager)!
|
||||
.unblock([jid]);
|
||||
}
|
||||
|
||||
Future<bool> unblockAll() async {
|
||||
final manager = GetIt.I.get<XmppConnection>().getManagerById<BlockingManager>(blockingManager)!;
|
||||
return manager.unblockAll();
|
||||
// Check if blocking is supported
|
||||
if (!(await _checkSupport())) {
|
||||
_log.warning('Unblocking all JIDs requested but server does not support it.');
|
||||
return false;
|
||||
}
|
||||
|
||||
_blocklist!.clear();
|
||||
await GetIt.I.get<DatabaseService>()
|
||||
.removeAllBlocklistEntries();
|
||||
return GetIt.I.get<XmppConnection>()
|
||||
.getManagerById<BlockingManager>(blockingManager)!
|
||||
.unblockAll();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ const xmppStateTable = 'XmppState';
|
||||
const contactsTable = 'Contacts';
|
||||
const stickersTable = 'Stickers';
|
||||
const stickerPacksTable = 'StickerPacks';
|
||||
const blocklistTable = 'Blocklist';
|
||||
|
||||
const typeString = 0;
|
||||
const typeInt = 1;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
import 'package:moxxyv2/shared/models/preference.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
@@ -57,7 +58,9 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
containsNoStore INTEGER NOT NULL,
|
||||
stickerPackId TEXT,
|
||||
stickerHashKey TEXT,
|
||||
CONSTRAINT fk_quote FOREIGN KEY (quote_id) REFERENCES $messagesTable (id),
|
||||
pseudoMessageType INTEGER,
|
||||
pseudoMessageData TEXT,
|
||||
CONSTRAINT fk_quote FOREIGN KEY (quote_id) REFERENCES $messagesTable (id)
|
||||
)''',
|
||||
);
|
||||
|
||||
@@ -158,6 +161,15 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
restricted INTEGER NOT NULL
|
||||
)''',
|
||||
);
|
||||
|
||||
// Blocklist
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $blocklistTable (
|
||||
jid TEXT PRIMARY KEY
|
||||
);
|
||||
''',
|
||||
);
|
||||
|
||||
// OMEMO
|
||||
await db.execute(
|
||||
@@ -408,4 +420,20 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
'false',
|
||||
).toDatabaseJson(),
|
||||
);
|
||||
await db.insert(
|
||||
preferenceTable,
|
||||
Preference(
|
||||
'isStickersNodePublic',
|
||||
typeBool,
|
||||
'true',
|
||||
).toDatabaseJson(),
|
||||
);
|
||||
await db.insert(
|
||||
preferenceTable,
|
||||
Preference(
|
||||
'showDebugMenu',
|
||||
typeBool,
|
||||
boolToString(false),
|
||||
).toDatabaseJson(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/service/database/creation.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0000_blocklist.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0000_contacts_integration.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0000_contacts_integration_avatar.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0000_contacts_integration_pseudo.dart';
|
||||
@@ -17,6 +18,7 @@ import 'package:moxxyv2/service/database/migrations/0000_conversations3.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0000_language.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0000_lmc.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0000_omemo_fingerprint_cache.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0000_pseudo_messages.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0000_reactions.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0000_reactions_store_hint.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0000_retraction.dart';
|
||||
@@ -28,7 +30,9 @@ import 'package:moxxyv2/service/database/migrations/0000_stickers_hash_key2.dart
|
||||
import 'package:moxxyv2/service/database/migrations/0000_stickers_missing_attributes.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0000_stickers_missing_attributes2.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0000_stickers_missing_attributes3.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0000_stickers_privacy.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0000_xmpp_state.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0001_debug_menu.dart';
|
||||
import 'package:moxxyv2/service/helpers.dart';
|
||||
import 'package:moxxyv2/service/not_specified.dart';
|
||||
import 'package:moxxyv2/service/omemo/omemo.dart';
|
||||
@@ -79,7 +83,7 @@ class DatabaseService {
|
||||
_db = await openDatabase(
|
||||
dbPath,
|
||||
password: key,
|
||||
version: 22,
|
||||
version: 26,
|
||||
onCreate: createDatabase,
|
||||
onConfigure: (db) async {
|
||||
// In order to do schema changes during database upgrades, we disable foreign
|
||||
@@ -176,6 +180,22 @@ class DatabaseService {
|
||||
_log.finest('Running migration for database version 22');
|
||||
await upgradeFromV21ToV22(db);
|
||||
}
|
||||
if (oldVersion < 23) {
|
||||
_log.finest('Running migration for database version 23');
|
||||
await upgradeFromV22ToV23(db);
|
||||
}
|
||||
if (oldVersion < 24) {
|
||||
_log.finest('Running migration for database version 24');
|
||||
await upgradeFromV23ToV24(db);
|
||||
}
|
||||
if (oldVersion < 25) {
|
||||
_log.finest('Running migration for database version 25');
|
||||
await upgradeFromV24ToV25(db);
|
||||
}
|
||||
if (oldVersion < 26) {
|
||||
_log.finest('Running migration for database version 26');
|
||||
await upgradeFromV25ToV26(db);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -423,6 +443,8 @@ class DatabaseService {
|
||||
int? mediaSize,
|
||||
String? stickerPackId,
|
||||
String? stickerHashKey,
|
||||
int? pseudoMessageType,
|
||||
Map<String, dynamic>? pseudoMessageData,
|
||||
}
|
||||
) async {
|
||||
var m = Message(
|
||||
@@ -459,6 +481,8 @@ class DatabaseService {
|
||||
mediaSize: mediaSize,
|
||||
stickerPackId: stickerPackId,
|
||||
stickerHashKey: stickerHashKey,
|
||||
pseudoMessageType: pseudoMessageType,
|
||||
pseudoMessageData: pseudoMessageData,
|
||||
);
|
||||
|
||||
if (quoteId != null) {
|
||||
@@ -1015,7 +1039,7 @@ class DatabaseService {
|
||||
await batch.commit();
|
||||
}
|
||||
|
||||
Future<void> saveOmemoDevice(Device device) async {
|
||||
Future<void> saveOmemoDevice(OmemoDevice device) async {
|
||||
await _db.insert(
|
||||
omemoDeviceTable,
|
||||
{
|
||||
@@ -1027,7 +1051,7 @@ class DatabaseService {
|
||||
);
|
||||
}
|
||||
|
||||
Future<Device?> loadOmemoDevice(String jid) async {
|
||||
Future<OmemoDevice?> loadOmemoDevice(String jid) async {
|
||||
final data = await _db.query(
|
||||
omemoDeviceTable,
|
||||
where: 'jid = ?',
|
||||
@@ -1050,7 +1074,7 @@ class DatabaseService {
|
||||
});
|
||||
}
|
||||
deviceJson['opks'] = opks;
|
||||
return Device.fromJson(deviceJson);
|
||||
return OmemoDevice.fromJson(deviceJson);
|
||||
}
|
||||
|
||||
Future<Map<String, List<int>>> loadOmemoDeviceList() async {
|
||||
@@ -1257,4 +1281,35 @@ class DatabaseService {
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> addBlocklistEntry(String jid) async {
|
||||
await _db.insert(
|
||||
blocklistTable,
|
||||
{
|
||||
'jid': jid,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> removeBlocklistEntry(String jid) async {
|
||||
await _db.delete(
|
||||
blocklistTable,
|
||||
where: 'jid = ?',
|
||||
whereArgs: [jid],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> removeAllBlocklistEntries() async {
|
||||
await _db.delete(
|
||||
blocklistTable,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<String>> getBlocklistEntries() async {
|
||||
final result = await _db.query(blocklistTable);
|
||||
|
||||
return result
|
||||
.map((m) => m['jid']! as String)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
13
lib/service/database/migrations/0000_blocklist.dart
Normal file
13
lib/service/database/migrations/0000_blocklist.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/shared/models/preference.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV22ToV23(Database db) async {
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $blocklistTable (
|
||||
jid TEXT PRIMARY KEY
|
||||
);
|
||||
''',
|
||||
);
|
||||
}
|
||||
12
lib/service/database/migrations/0000_pseudo_messages.dart
Normal file
12
lib/service/database/migrations/0000_pseudo_messages.dart
Normal file
@@ -0,0 +1,12 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/shared/models/preference.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV23ToV24(Database db) async {
|
||||
await db.execute(
|
||||
'ALTER TABLE $messagesTable ADD COLUMN pseudoMessageType INTEGER;',
|
||||
);
|
||||
await db.execute(
|
||||
'ALTER TABLE $messagesTable ADD COLUMN pseudoMessageData TEXT;',
|
||||
);
|
||||
}
|
||||
14
lib/service/database/migrations/0000_stickers_privacy.dart
Normal file
14
lib/service/database/migrations/0000_stickers_privacy.dart
Normal file
@@ -0,0 +1,14 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/shared/models/preference.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV24ToV25(Database db) async {
|
||||
await db.insert(
|
||||
preferenceTable,
|
||||
Preference(
|
||||
'isStickersNodePublic',
|
||||
typeBool,
|
||||
'true',
|
||||
).toDatabaseJson(),
|
||||
);
|
||||
}
|
||||
15
lib/service/database/migrations/0001_debug_menu.dart
Normal file
15
lib/service/database/migrations/0001_debug_menu.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
import 'package:moxxyv2/shared/models/preference.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV25ToV26(Database db) async {
|
||||
await db.insert(
|
||||
preferenceTable,
|
||||
Preference(
|
||||
'showDebugMenu',
|
||||
typeBool,
|
||||
boolToString(false),
|
||||
).toDatabaseJson(),
|
||||
);
|
||||
}
|
||||
@@ -79,6 +79,7 @@ void setupBackgroundEventHandler() {
|
||||
EventTypeMatcher<RemoveStickerPackCommand>(performRemoveStickerPack),
|
||||
EventTypeMatcher<FetchStickerPackCommand>(performFetchStickerPack),
|
||||
EventTypeMatcher<InstallStickerPackCommand>(performStickerPackInstall),
|
||||
EventTypeMatcher<GetBlocklistCommand>(performGetBlocklist),
|
||||
]);
|
||||
|
||||
GetIt.I.registerSingleton<EventHandler>(handler);
|
||||
@@ -347,6 +348,37 @@ Future<void> performSetPreferences(SetPreferencesCommand command, { dynamic extr
|
||||
css.disableDatabaseListener();
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(Unknown): Maybe handle this in StickersService
|
||||
// If sticker visibility was changed, apply the settings to the PubSub node
|
||||
final pm = GetIt.I.get<XmppConnection>()
|
||||
.getManagerById<PubSubManager>(pubsubManager)!;
|
||||
final ownJid = (await GetIt.I.get<XmppService>().getXmppState()).jid!;
|
||||
if (command.preferences.isStickersNodePublic && !oldPrefs.isStickersNodePublic) {
|
||||
// Set to open
|
||||
unawaited(
|
||||
pm.configure(
|
||||
ownJid,
|
||||
stickersXmlns,
|
||||
const PubSubPublishOptions(
|
||||
accessModel: 'open',
|
||||
maxItems: 'max',
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (!command.preferences.isStickersNodePublic && oldPrefs.isStickersNodePublic) {
|
||||
// Set to presence
|
||||
unawaited(
|
||||
pm.configure(
|
||||
ownJid,
|
||||
stickersXmlns,
|
||||
const PubSubPublishOptions(
|
||||
accessModel: 'presence',
|
||||
maxItems: 'max',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Set the locale
|
||||
final locale = command.preferences.languageLocaleCode == 'default' ?
|
||||
@@ -588,9 +620,8 @@ Future<void> performRecreateSessions(RecreateSessionsCommand command, { dynamic
|
||||
await GetIt.I.get<OmemoService>().removeAllSessions(command.jid);
|
||||
|
||||
final conn = GetIt.I.get<XmppConnection>();
|
||||
await conn.getManagerById<OmemoManager>(omemoManager)!.sendEmptyMessage(
|
||||
JID.fromString(command.jid),
|
||||
findNewSessions: true,
|
||||
await conn.getManagerById<BaseOmemoManager>(omemoManager)!.sendOmemoHeartbeat(
|
||||
command.jid,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -622,7 +653,7 @@ Future<void> performGetOwnOmemoFingerprints(GetOwnOmemoFingerprintsCommand comma
|
||||
|
||||
Future<void> performRemoveOwnDevice(RemoveOwnDeviceCommand command, { dynamic extra }) async {
|
||||
await GetIt.I.get<XmppConnection>()
|
||||
.getManagerById<OmemoManager>(omemoManager)!
|
||||
.getManagerById<BaseOmemoManager>(omemoManager)!
|
||||
.deleteDevice(command.deviceId);
|
||||
}
|
||||
|
||||
@@ -890,3 +921,15 @@ Future<void> performStickerPackInstall(InstallStickerPackCommand command, { dyna
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> performGetBlocklist(GetBlocklistCommand command, { dynamic extra }) async {
|
||||
final id = extra as String;
|
||||
|
||||
final result = await GetIt.I.get<BlocklistService>().getBlocklist();
|
||||
sendEvent(
|
||||
GetBlocklistResultEvent(
|
||||
entries: result,
|
||||
),
|
||||
id: id,
|
||||
);
|
||||
}
|
||||
|
||||
137
lib/service/httpfiletransfer/client.dart
Normal file
137
lib/service/httpfiletransfer/client.dart
Normal file
@@ -0,0 +1,137 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:meta/meta.dart';
|
||||
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
|
||||
|
||||
typedef ProgressCallback = void Function(int total, int current);
|
||||
|
||||
@immutable
|
||||
class HttpPeekResult {
|
||||
const HttpPeekResult(this.contentType, this.contentLength);
|
||||
final String? contentType;
|
||||
final int? contentLength;
|
||||
}
|
||||
|
||||
/// Download the file found at [uri] into the file [destination]. [onProgress] is
|
||||
/// called whenever new data has been downloaded.
|
||||
///
|
||||
/// Returns the status code if the server responded. If an error occurs, returns null.
|
||||
Future<int?> downloadFile(Uri uri, String destination, ProgressCallback onProgress) async {
|
||||
// TODO(Unknown): How do we close fileSink? Do we have to?
|
||||
IOSink? fileSink;
|
||||
final client = HttpClient();
|
||||
try {
|
||||
final req = await client.getUrl(uri);
|
||||
final resp = await req.close();
|
||||
|
||||
if (!isRequestOkay(resp.statusCode)) {
|
||||
client.close(force: true);
|
||||
return resp.statusCode;
|
||||
}
|
||||
|
||||
// The size of the remote file
|
||||
final length = resp.contentLength;
|
||||
|
||||
fileSink = File(destination).openWrite(mode: FileMode.append);
|
||||
var bytes = 0;
|
||||
final downloadCompleter = Completer<void>();
|
||||
unawaited(
|
||||
resp.transform(
|
||||
StreamTransformer<List<int>, List<int>>.fromHandlers(
|
||||
handleData: (data, sink) {
|
||||
bytes += data.length;
|
||||
onProgress(length, bytes);
|
||||
|
||||
sink.add(data);
|
||||
},
|
||||
handleDone: (sink) {
|
||||
downloadCompleter.complete();
|
||||
},
|
||||
),
|
||||
).pipe(fileSink),
|
||||
);
|
||||
|
||||
// Wait for the download to complete
|
||||
await downloadCompleter.future;
|
||||
client.close(force: true);
|
||||
//await fileSink.close();
|
||||
|
||||
return resp.statusCode;
|
||||
} catch (ex) {
|
||||
client.close(force: true);
|
||||
//await fileSink?.close();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload the file found at [filePath] to [destination]. [headers] are HTTP headers
|
||||
/// that are added to the PUT request. [onProgress] is called whenever new data has
|
||||
/// been downloaded.
|
||||
///
|
||||
/// Returns the status code if the server responded. If an error occurs, returns null.
|
||||
Future<int?> uploadFile(Uri destination, Map<String, String> headers, String filePath, ProgressCallback onProgress) async {
|
||||
final client = HttpClient();
|
||||
try {
|
||||
final req = await client.putUrl(destination);
|
||||
final file = File(filePath);
|
||||
final length = await file.length();
|
||||
req.contentLength = length;
|
||||
|
||||
// Set all known headers
|
||||
headers.forEach((headerName, headerValue) {
|
||||
req.headers.set(headerName, headerValue);
|
||||
});
|
||||
|
||||
var bytes = 0;
|
||||
final stream = file.openRead().transform(
|
||||
StreamTransformer<List<int>, List<int>>.fromHandlers(
|
||||
handleData: (data, sink) {
|
||||
bytes += data.length;
|
||||
onProgress(length, bytes);
|
||||
|
||||
sink.add(data);
|
||||
},
|
||||
handleDone: (sink) {
|
||||
sink.close();
|
||||
},
|
||||
),
|
||||
);
|
||||
await req.addStream(stream);
|
||||
final resp = await req.close();
|
||||
|
||||
return resp.statusCode;
|
||||
} catch (ex) {
|
||||
client.close(force: true);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends a HEAD request to [uri].
|
||||
///
|
||||
/// Returns the content type and content length if the server responded. If an error
|
||||
/// occurs, returns null.
|
||||
Future<HttpPeekResult?> peekUrl(Uri uri) async {
|
||||
final client = HttpClient();
|
||||
|
||||
try {
|
||||
final req = await client.headUrl(uri);
|
||||
final resp = await req.close();
|
||||
|
||||
if (!isRequestOkay(resp.statusCode)) {
|
||||
client.close(force: true);
|
||||
return null;
|
||||
}
|
||||
|
||||
client.close(force: true);
|
||||
final contentType = resp.headers['Content-Type'];
|
||||
return HttpPeekResult(
|
||||
contentType != null && contentType.isNotEmpty ?
|
||||
contentType.first :
|
||||
null,
|
||||
resp.contentLength,
|
||||
);
|
||||
} catch (ex) {
|
||||
client.close(force: true);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:external_path/external_path.dart';
|
||||
import 'package:moxxyv2/service/httpfiletransfer/client.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
|
||||
@@ -43,7 +43,6 @@ bool isRequestOkay(int? statusCode) {
|
||||
}
|
||||
|
||||
class FileMetadata {
|
||||
|
||||
const FileMetadata({ this.mime, this.size });
|
||||
final String? mime;
|
||||
final int? size;
|
||||
@@ -53,15 +52,10 @@ class FileMetadata {
|
||||
/// does not specify the Content-Length header, null is returned.
|
||||
/// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Length
|
||||
Future<FileMetadata> peekFile(String url) async {
|
||||
final response = await Dio().headUri<dynamic>(Uri.parse(url));
|
||||
|
||||
if (!isRequestOkay(response.statusCode)) return const FileMetadata();
|
||||
|
||||
final contentLengthHeaders = response.headers['Content-Length'];
|
||||
final contentTypeHeaders = response.headers['Content-Type'];
|
||||
final result = await peekUrl(Uri.parse(url));
|
||||
|
||||
return FileMetadata(
|
||||
mime: contentTypeHeaders?.first,
|
||||
size: contentLengthHeaders != null && contentLengthHeaders.isNotEmpty ? int.parse(contentLengthHeaders.first) : null,
|
||||
mime: result?.contentType,
|
||||
size: result?.contentLength,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:dio/dio.dart' as dio;
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:mime/mime.dart';
|
||||
@@ -15,6 +14,7 @@ import 'package:moxxyv2/service/conversation.dart';
|
||||
import 'package:moxxyv2/service/cryptography/cryptography.dart';
|
||||
import 'package:moxxyv2/service/cryptography/types.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/httpfiletransfer/client.dart' as client;
|
||||
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
|
||||
import 'package:moxxyv2/service/httpfiletransfer/jobs.dart';
|
||||
import 'package:moxxyv2/service/message.dart';
|
||||
@@ -102,19 +102,17 @@ class HttpFileTransferService {
|
||||
|
||||
/// Queue the download job [job] to be performed.
|
||||
Future<void> downloadFile(FileDownloadJob job) async {
|
||||
var canDownload = false;
|
||||
await _uploadLock.synchronized(() async {
|
||||
if (_currentDownloadJob != null) {
|
||||
_log.finest('Queuing up download task.');
|
||||
_downloadQueue.add(job);
|
||||
} else {
|
||||
_log.finest('Executing download task.');
|
||||
_currentDownloadJob = job;
|
||||
canDownload = true;
|
||||
|
||||
unawaited(_performFileDownload(job));
|
||||
}
|
||||
});
|
||||
|
||||
if (canDownload) {
|
||||
unawaited(_performFileDownload(job));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _copyFile(FileUploadJob job) async {
|
||||
@@ -183,7 +181,6 @@ class HttpFileTransferService {
|
||||
}
|
||||
|
||||
final file = File(path);
|
||||
final data = await file.readAsBytes();
|
||||
final stat = file.statSync();
|
||||
|
||||
// Request the upload slot
|
||||
@@ -200,120 +197,110 @@ class HttpFileTransferService {
|
||||
return;
|
||||
}
|
||||
final slot = slotResult.get<HttpFileUploadSlot>();
|
||||
try {
|
||||
final response = await dio.Dio().putUri<dynamic>(
|
||||
Uri.parse(slot.putUrl),
|
||||
options: dio.Options(
|
||||
headers: slot.headers,
|
||||
contentType: 'application/octet-stream',
|
||||
requestEncoder: (_, __) => data,
|
||||
),
|
||||
data: data,
|
||||
onSendProgress: (count, total) {
|
||||
// TODO(PapaTutuWawa): Make this smarter by also checking if one of those chats
|
||||
// is open.
|
||||
if (job.recipients.length == 1) {
|
||||
final progress = count.toDouble() / total.toDouble();
|
||||
sendEvent(
|
||||
ProgressEvent(
|
||||
id: job.messageMap.values.first.id,
|
||||
progress: progress == 1 ? 0.99 : progress,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
if (response.statusCode != 201) {
|
||||
// TODO(PapaTutuWawa): Trigger event
|
||||
_log.severe('Upload failed');
|
||||
await _fileUploadFailed(job, fileUploadFailedError);
|
||||
return;
|
||||
} else {
|
||||
_log.fine('Upload was successful');
|
||||
|
||||
const uuid = Uuid();
|
||||
for (final recipient in job.recipients) {
|
||||
// Notify UI of upload completion
|
||||
var msg = await ms.updateMessage(
|
||||
job.messageMap[recipient]!.id,
|
||||
mediaSize: stat.size,
|
||||
errorType: noError,
|
||||
encryptionScheme: encryption != null ?
|
||||
SFSEncryptionType.aes256GcmNoPadding.toNamespace() :
|
||||
null,
|
||||
key: encryption != null ? base64Encode(encryption.key) : null,
|
||||
iv: encryption != null ? base64Encode(encryption.iv) : null,
|
||||
isUploading: false,
|
||||
srcUrl: slot.getUrl,
|
||||
);
|
||||
// TODO(Unknown): Maybe batch those two together?
|
||||
final oldSid = msg.sid;
|
||||
msg = await ms.updateMessage(
|
||||
msg.id,
|
||||
sid: uuid.v4(),
|
||||
originId: uuid.v4(),
|
||||
);
|
||||
sendEvent(MessageUpdatedEvent(message: msg));
|
||||
|
||||
StatelessFileSharingSource source;
|
||||
final plaintextHashes = <String, String>{};
|
||||
if (encryption != null) {
|
||||
source = StatelessFileSharingEncryptedSource(
|
||||
SFSEncryptionType.aes256GcmNoPadding,
|
||||
encryption.key,
|
||||
encryption.iv,
|
||||
encryption.ciphertextHashes,
|
||||
StatelessFileSharingUrlSource(slot.getUrl),
|
||||
);
|
||||
|
||||
plaintextHashes.addAll(encryption.plaintextHashes);
|
||||
} else {
|
||||
source = StatelessFileSharingUrlSource(slot.getUrl);
|
||||
try {
|
||||
plaintextHashes[hashSha256] = await GetIt.I.get<CryptographyService>()
|
||||
.hashFile(job.path, HashFunction.sha256);
|
||||
} catch (ex) {
|
||||
_log.warning('Failed to hash file ${job.path} using SHA-256: $ex');
|
||||
}
|
||||
}
|
||||
|
||||
// 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(
|
||||
FileMetadataData(
|
||||
mediaType: job.mime,
|
||||
size: stat.size,
|
||||
name: pathlib.basename(job.path),
|
||||
thumbnails: job.thumbnails,
|
||||
hashes: plaintextHashes,
|
||||
),
|
||||
<StatelessFileSharingSource>[source],
|
||||
),
|
||||
shouldEncrypt: job.encryptMap[recipient]!,
|
||||
funReplacement: oldSid,
|
||||
final uploadStatusCode = await client.uploadFile(
|
||||
Uri.parse(slot.putUrl),
|
||||
slot.headers,
|
||||
path,
|
||||
(total, current) {
|
||||
// TODO(PapaTutuWawa): Make this smarter by also checking if one of those chats
|
||||
// is open.
|
||||
if (job.recipients.length == 1) {
|
||||
final progress = current.toDouble() / total.toDouble();
|
||||
sendEvent(
|
||||
ProgressEvent(
|
||||
id: job.messageMap.values.first.id,
|
||||
progress: progress == 1 ? 0.99 : progress,
|
||||
),
|
||||
);
|
||||
_log.finest('Sent message with file upload for ${job.path} to $recipient');
|
||||
|
||||
final isMultiMedia = job.mime?.startsWith('image/') == true || job.mime?.startsWith('video/') == true;
|
||||
if (isMultiMedia) {
|
||||
_log.finest('File appears to be either an image or a video. Copying it to the correct directory...');
|
||||
unawaited(_copyFile(job));
|
||||
}
|
||||
}
|
||||
}
|
||||
} on dio.DioError {
|
||||
_log.finest('Upload failed due to connection error');
|
||||
},
|
||||
);
|
||||
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
if (!isRequestOkay(uploadStatusCode)) {
|
||||
_log.severe('Upload failed');
|
||||
await _fileUploadFailed(job, fileUploadFailedError);
|
||||
return;
|
||||
} else {
|
||||
_log.fine('Upload was successful');
|
||||
|
||||
const uuid = Uuid();
|
||||
for (final recipient in job.recipients) {
|
||||
// Notify UI of upload completion
|
||||
var msg = await ms.updateMessage(
|
||||
job.messageMap[recipient]!.id,
|
||||
mediaSize: stat.size,
|
||||
errorType: noError,
|
||||
encryptionScheme: encryption != null ?
|
||||
SFSEncryptionType.aes256GcmNoPadding.toNamespace() :
|
||||
null,
|
||||
key: encryption != null ? base64Encode(encryption.key) : null,
|
||||
iv: encryption != null ? base64Encode(encryption.iv) : null,
|
||||
isUploading: false,
|
||||
srcUrl: slot.getUrl,
|
||||
);
|
||||
// TODO(Unknown): Maybe batch those two together?
|
||||
final oldSid = msg.sid;
|
||||
msg = await ms.updateMessage(
|
||||
msg.id,
|
||||
sid: uuid.v4(),
|
||||
originId: uuid.v4(),
|
||||
);
|
||||
sendEvent(MessageUpdatedEvent(message: msg));
|
||||
|
||||
StatelessFileSharingSource source;
|
||||
final plaintextHashes = <String, String>{};
|
||||
if (encryption != null) {
|
||||
source = StatelessFileSharingEncryptedSource(
|
||||
SFSEncryptionType.aes256GcmNoPadding,
|
||||
encryption.key,
|
||||
encryption.iv,
|
||||
encryption.ciphertextHashes,
|
||||
StatelessFileSharingUrlSource(slot.getUrl),
|
||||
);
|
||||
|
||||
plaintextHashes.addAll(encryption.plaintextHashes);
|
||||
} else {
|
||||
source = StatelessFileSharingUrlSource(slot.getUrl);
|
||||
try {
|
||||
plaintextHashes[hashSha256] = await GetIt.I.get<CryptographyService>()
|
||||
.hashFile(job.path, HashFunction.sha256);
|
||||
} catch (ex) {
|
||||
_log.warning('Failed to hash file ${job.path} using SHA-256: $ex');
|
||||
}
|
||||
}
|
||||
|
||||
// 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(
|
||||
FileMetadataData(
|
||||
mediaType: job.mime,
|
||||
size: stat.size,
|
||||
name: pathlib.basename(job.path),
|
||||
thumbnails: job.thumbnails,
|
||||
hashes: plaintextHashes,
|
||||
),
|
||||
<StatelessFileSharingSource>[source],
|
||||
),
|
||||
shouldEncrypt: job.encryptMap[recipient]!,
|
||||
funReplacement: oldSid,
|
||||
),
|
||||
);
|
||||
_log.finest('Sent message with file upload for ${job.path} to $recipient');
|
||||
|
||||
final isMultiMedia = job.mime?.startsWith('image/') == true || job.mime?.startsWith('video/') == true;
|
||||
if (isMultiMedia) {
|
||||
_log.finest('File appears to be either an image or a video. Copying it to the correct directory...');
|
||||
unawaited(_copyFile(job));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await _pickNextUploadTask();
|
||||
@@ -349,7 +336,6 @@ class HttpFileTransferService {
|
||||
/// Actually attempt to download the file described by the job [job].
|
||||
Future<void> _performFileDownload(FileDownloadJob job) async {
|
||||
final filename = job.location.filename;
|
||||
_log.finest('Downloading ${job.location.url} as $filename');
|
||||
final downloadedPath = await getDownloadPath(filename, job.conversationJid, job.mimeGuess);
|
||||
|
||||
var downloadPath = downloadedPath;
|
||||
@@ -359,13 +345,16 @@ class HttpFileTransferService {
|
||||
downloadPath = pathlib.join(tempDir.path, filename);
|
||||
}
|
||||
|
||||
dio.Response<dynamic>? response;
|
||||
_log.finest('Downloading ${job.location.url} as $filename (MIME guess ${job.mimeGuess}) to $downloadPath (-> $downloadedPath)');
|
||||
|
||||
int? downloadStatusCode;
|
||||
try {
|
||||
response = await dio.Dio().downloadUri(
|
||||
_log.finest('Beginning download...');
|
||||
downloadStatusCode = await client.downloadFile(
|
||||
Uri.parse(job.location.url),
|
||||
downloadPath,
|
||||
onReceiveProgress: (count, total) {
|
||||
final progress = count.toDouble() / total.toDouble();
|
||||
(total, current) {
|
||||
final progress = current.toDouble() / total.toDouble();
|
||||
sendEvent(
|
||||
ProgressEvent(
|
||||
id: job.mId,
|
||||
@@ -374,156 +363,155 @@ class HttpFileTransferService {
|
||||
);
|
||||
},
|
||||
);
|
||||
} on dio.DioError catch(err) {
|
||||
// TODO(PapaTutuWawa): React if we received an error that is not related to the
|
||||
// connection.
|
||||
_log.finest('Download done...');
|
||||
} catch (err) {
|
||||
_log.finest('Failed to download: $err');
|
||||
}
|
||||
|
||||
if (!isRequestOkay(downloadStatusCode)) {
|
||||
_log.warning('HTTP GET of ${job.location.url} returned $downloadStatusCode');
|
||||
await _fileDownloadFailed(job, fileDownloadFailedError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isRequestOkay(response.statusCode)) {
|
||||
_log.warning('HTTP GET of ${job.location.url} returned ${response.statusCode}');
|
||||
await _fileDownloadFailed(job, fileDownloadFailedError);
|
||||
return;
|
||||
} else {
|
||||
var integrityCheckPassed = true;
|
||||
final conv = (await GetIt.I.get<ConversationService>()
|
||||
.getConversationByJid(job.conversationJid))!;
|
||||
final decryptionKeysAvailable = job.location.key != null && job.location.iv != null;
|
||||
if (decryptionKeysAvailable) {
|
||||
// The file was downloaded and is now being decrypted
|
||||
sendEvent(
|
||||
ProgressEvent(
|
||||
id: job.mId,
|
||||
),
|
||||
var integrityCheckPassed = true;
|
||||
final conv = (await GetIt.I.get<ConversationService>()
|
||||
.getConversationByJid(job.conversationJid))!;
|
||||
final decryptionKeysAvailable = job.location.key != null && job.location.iv != null;
|
||||
if (decryptionKeysAvailable) {
|
||||
// The file was downloaded and is now being decrypted
|
||||
sendEvent(
|
||||
ProgressEvent(
|
||||
id: job.mId,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
final result = await GetIt.I.get<CryptographyService>().decryptFile(
|
||||
downloadPath,
|
||||
downloadedPath,
|
||||
encryptionTypeFromNamespace(job.location.encryptionScheme!),
|
||||
job.location.key!,
|
||||
job.location.iv!,
|
||||
job.location.plaintextHashes ?? {},
|
||||
job.location.ciphertextHashes ?? {},
|
||||
);
|
||||
|
||||
try {
|
||||
final result = await GetIt.I.get<CryptographyService>().decryptFile(
|
||||
downloadPath,
|
||||
downloadedPath,
|
||||
encryptionTypeFromNamespace(job.location.encryptionScheme!),
|
||||
job.location.key!,
|
||||
job.location.iv!,
|
||||
job.location.plaintextHashes ?? {},
|
||||
job.location.ciphertextHashes ?? {},
|
||||
);
|
||||
|
||||
if (!result.decryptionOkay) {
|
||||
_log.warning('Failed to decrypt $downloadPath');
|
||||
await _fileDownloadFailed(job, messageFailedToDecryptFile);
|
||||
return;
|
||||
}
|
||||
|
||||
integrityCheckPassed = result.plaintextOkay && result.ciphertextOkay;
|
||||
} catch (ex) {
|
||||
_log.warning('Decryption of $downloadPath ($downloadedPath) failed: $ex');
|
||||
if (!result.decryptionOkay) {
|
||||
_log.warning('Failed to decrypt $downloadPath');
|
||||
await _fileDownloadFailed(job, messageFailedToDecryptFile);
|
||||
return;
|
||||
}
|
||||
|
||||
unawaited(Directory(pathlib.dirname(downloadPath)).delete(recursive: true));
|
||||
integrityCheckPassed = result.plaintextOkay && result.ciphertextOkay;
|
||||
} catch (ex) {
|
||||
_log.warning('Decryption of $downloadPath ($downloadedPath) failed: $ex');
|
||||
await _fileDownloadFailed(job, messageFailedToDecryptFile);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check the MIME type
|
||||
final notification = GetIt.I.get<NotificationsService>();
|
||||
final mime = job.mimeGuess ?? lookupMimeType(downloadedPath);
|
||||
|
||||
int? mediaWidth;
|
||||
int? mediaHeight;
|
||||
if (mime != null) {
|
||||
if (mime.startsWith('image/')) {
|
||||
MoxplatformPlugin.media.scanFile(downloadedPath);
|
||||
|
||||
// Find out the dimensions
|
||||
final imageSize = await getImageSizeFromPath(downloadedPath);
|
||||
if (imageSize == null) {
|
||||
_log.warning('Failed to get image size for $downloadedPath');
|
||||
}
|
||||
|
||||
mediaWidth = imageSize?.width.toInt();
|
||||
mediaHeight = imageSize?.height.toInt();
|
||||
} else if (mime.startsWith('video/')) {
|
||||
MoxplatformPlugin.media.scanFile(downloadedPath);
|
||||
|
||||
/*
|
||||
// Generate thumbnail
|
||||
final thumbnailPath = await getVideoThumbnailPath(
|
||||
downloadedPath,
|
||||
job.conversationJid,
|
||||
);
|
||||
|
||||
// Find out the dimensions
|
||||
final imageSize = await getImageSizeFromPath(thumbnailPath);
|
||||
if (imageSize == null) {
|
||||
_log.warning('Failed to get image size for $downloadedPath ($thumbnailPath)');
|
||||
}
|
||||
|
||||
mediaWidth = imageSize?.width.toInt();
|
||||
mediaHeight = imageSize?.height.toInt();*/
|
||||
} else if (mime.startsWith('audio/')) {
|
||||
MoxplatformPlugin.media.scanFile(downloadedPath);
|
||||
}
|
||||
}
|
||||
|
||||
final msg = await GetIt.I.get<MessageService>().updateMessage(
|
||||
job.mId,
|
||||
mediaUrl: downloadedPath,
|
||||
mediaType: mime,
|
||||
mediaWidth: mediaWidth,
|
||||
mediaHeight: mediaHeight,
|
||||
mediaSize: File(downloadedPath).lengthSync(),
|
||||
isFileUploadNotification: false,
|
||||
warningType: integrityCheckPassed ?
|
||||
null :
|
||||
warningFileIntegrityCheckFailed,
|
||||
errorType: conv.encrypted && !decryptionKeysAvailable ?
|
||||
messageChatEncryptedButFileNot :
|
||||
null,
|
||||
isDownloading: false,
|
||||
);
|
||||
|
||||
sendEvent(MessageUpdatedEvent(message: msg));
|
||||
|
||||
final sharedMedium = await GetIt.I.get<DatabaseService>().addSharedMediumFromData(
|
||||
downloadedPath,
|
||||
msg.timestamp,
|
||||
conv.id,
|
||||
job.mId,
|
||||
mime: mime,
|
||||
);
|
||||
final newConv = conv.copyWith(
|
||||
lastMessage: conv.lastMessage?.id == job.mId ?
|
||||
msg :
|
||||
conv.lastMessage,
|
||||
sharedMedia: [
|
||||
sharedMedium,
|
||||
...conv.sharedMedia,
|
||||
],
|
||||
);
|
||||
GetIt.I.get<ConversationService>().setConversation(newConv);
|
||||
|
||||
// Show a notification
|
||||
if (notification.shouldShowNotification(msg.conversationJid) && job.shouldShowNotification) {
|
||||
_log.finest('Creating notification with bigPicture $downloadedPath');
|
||||
await notification.showNotification(newConv, msg, '');
|
||||
}
|
||||
|
||||
sendEvent(ConversationUpdatedEvent(conversation: newConv));
|
||||
unawaited(Directory(pathlib.dirname(downloadPath)).delete(recursive: true));
|
||||
}
|
||||
|
||||
// Check the MIME type
|
||||
final notification = GetIt.I.get<NotificationsService>();
|
||||
final mime = job.mimeGuess ?? lookupMimeType(downloadedPath);
|
||||
|
||||
int? mediaWidth;
|
||||
int? mediaHeight;
|
||||
if (mime != null) {
|
||||
if (mime.startsWith('image/')) {
|
||||
MoxplatformPlugin.media.scanFile(downloadedPath);
|
||||
|
||||
// Find out the dimensions
|
||||
final imageSize = await getImageSizeFromPath(downloadedPath);
|
||||
if (imageSize == null) {
|
||||
_log.warning('Failed to get image size for $downloadedPath');
|
||||
}
|
||||
|
||||
mediaWidth = imageSize?.width.toInt();
|
||||
mediaHeight = imageSize?.height.toInt();
|
||||
} else if (mime.startsWith('video/')) {
|
||||
MoxplatformPlugin.media.scanFile(downloadedPath);
|
||||
|
||||
/*
|
||||
// Generate thumbnail
|
||||
final thumbnailPath = await getVideoThumbnailPath(
|
||||
downloadedPath,
|
||||
job.conversationJid,
|
||||
);
|
||||
|
||||
// Find out the dimensions
|
||||
final imageSize = await getImageSizeFromPath(thumbnailPath);
|
||||
if (imageSize == null) {
|
||||
_log.warning('Failed to get image size for $downloadedPath ($thumbnailPath)');
|
||||
}
|
||||
|
||||
mediaWidth = imageSize?.width.toInt();
|
||||
mediaHeight = imageSize?.height.toInt();*/
|
||||
} else if (mime.startsWith('audio/')) {
|
||||
MoxplatformPlugin.media.scanFile(downloadedPath);
|
||||
}
|
||||
}
|
||||
|
||||
final msg = await GetIt.I.get<MessageService>().updateMessage(
|
||||
job.mId,
|
||||
mediaUrl: downloadedPath,
|
||||
mediaType: mime,
|
||||
mediaWidth: mediaWidth,
|
||||
mediaHeight: mediaHeight,
|
||||
mediaSize: File(downloadedPath).lengthSync(),
|
||||
isFileUploadNotification: false,
|
||||
warningType: integrityCheckPassed ?
|
||||
null :
|
||||
warningFileIntegrityCheckFailed,
|
||||
errorType: conv.encrypted && !decryptionKeysAvailable ?
|
||||
messageChatEncryptedButFileNot :
|
||||
null,
|
||||
isDownloading: false,
|
||||
);
|
||||
|
||||
sendEvent(MessageUpdatedEvent(message: msg));
|
||||
|
||||
final sharedMedium = await GetIt.I.get<DatabaseService>().addSharedMediumFromData(
|
||||
downloadedPath,
|
||||
msg.timestamp,
|
||||
conv.id,
|
||||
job.mId,
|
||||
mime: mime,
|
||||
);
|
||||
final newConv = conv.copyWith(
|
||||
lastMessage: conv.lastMessage?.id == job.mId ?
|
||||
msg :
|
||||
conv.lastMessage,
|
||||
sharedMedia: [
|
||||
sharedMedium,
|
||||
...conv.sharedMedia,
|
||||
],
|
||||
);
|
||||
GetIt.I.get<ConversationService>().setConversation(newConv);
|
||||
|
||||
// Show a notification
|
||||
if (notification.shouldShowNotification(msg.conversationJid) && job.shouldShowNotification) {
|
||||
_log.finest('Creating notification with bigPicture $downloadedPath');
|
||||
await notification.showNotification(newConv, msg, '');
|
||||
}
|
||||
|
||||
sendEvent(ConversationUpdatedEvent(conversation: newConv));
|
||||
|
||||
// Free the download resources for the next one
|
||||
await _pickNextDownloadTask();
|
||||
}
|
||||
|
||||
Future<void> _pickNextDownloadTask() async {
|
||||
if (GetIt.I.get<ConnectivityService>().currentState == ConnectivityResult.none) return;
|
||||
|
||||
await _downloadLock.synchronized(() async {
|
||||
if (_downloadQueue.isNotEmpty) {
|
||||
_currentDownloadJob = _downloadQueue.removeFirst();
|
||||
unawaited(_performFileDownload(_currentDownloadJob!));
|
||||
|
||||
// Only download if we have a connection
|
||||
if (GetIt.I.get<ConnectivityService>().currentState != ConnectivityResult.none) {
|
||||
unawaited(_performFileDownload(_currentDownloadJob!));
|
||||
}
|
||||
} else {
|
||||
_currentDownloadJob = null;
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@ class MessageService {
|
||||
int? mediaSize,
|
||||
String? stickerPackId,
|
||||
String? stickerHashKey,
|
||||
int? pseudoMessageType,
|
||||
Map<String, dynamic>? pseudoMessageData,
|
||||
}
|
||||
) async {
|
||||
final msg = await GetIt.I.get<DatabaseService>().addMessageFromData(
|
||||
@@ -99,6 +101,8 @@ class MessageService {
|
||||
mediaSize: mediaSize,
|
||||
stickerPackId: stickerPackId,
|
||||
stickerHashKey: stickerHashKey,
|
||||
pseudoMessageType: pseudoMessageType,
|
||||
pseudoMessageData: pseudoMessageData,
|
||||
);
|
||||
|
||||
// Only update the cache if the conversation already has been loaded. This prevents
|
||||
|
||||
@@ -4,15 +4,14 @@ import 'package:moxxyv2/service/conversation.dart';
|
||||
import 'package:moxxyv2/service/omemo/omemo.dart';
|
||||
import 'package:omemo_dart/omemo_dart.dart';
|
||||
|
||||
class MoxxyOmemoManager extends OmemoManager {
|
||||
|
||||
class MoxxyOmemoManager extends BaseOmemoManager {
|
||||
MoxxyOmemoManager() : super();
|
||||
|
||||
@override
|
||||
Future<OmemoSessionManager> getSessionManager() async {
|
||||
Future<OmemoManager> getOmemoManager() async {
|
||||
final os = GetIt.I.get<OmemoService>();
|
||||
await os.ensureInitialized();
|
||||
return os.omemoState;
|
||||
return os.omemoManager;
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -12,7 +12,6 @@ import 'package:synchronized/synchronized.dart';
|
||||
/// backoff. This means that we perform the random backoff only as long as we are
|
||||
/// connected. Otherwise, we idle until we have a connection again.
|
||||
class MoxxyReconnectionPolicy extends ReconnectionPolicy {
|
||||
|
||||
MoxxyReconnectionPolicy({ bool isTesting = false, this.maxBackoffTime })
|
||||
: _isTesting = isTesting,
|
||||
_timerLock = Lock(),
|
||||
@@ -46,7 +45,7 @@ class MoxxyReconnectionPolicy extends ReconnectionPolicy {
|
||||
// Cancel the timer if it was running
|
||||
await _stopTimer();
|
||||
await setIsReconnecting(false);
|
||||
triggerConnectionLost!();
|
||||
await triggerConnectionLost!();
|
||||
} else if (regained && shouldReconnect) {
|
||||
// We should reconnect
|
||||
_log.finest('Network regained. Attempting reconnection...');
|
||||
|
||||
@@ -1,21 +1,91 @@
|
||||
import 'dart:async';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/roster.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/service/xmpp.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/models/roster.dart';
|
||||
|
||||
class MoxxyRosterManager extends RosterManager {
|
||||
class MoxxyRosterStateManager extends BaseRosterStateManager {
|
||||
@override
|
||||
Future<void> commitLastRosterVersion(String version) async {
|
||||
await GetIt.I.get<XmppService>().modifyXmppState((state) => state.copyWith(
|
||||
lastRosterVersion: version,
|
||||
),);
|
||||
Future<RosterCacheLoadResult> loadRosterCache() async {
|
||||
final rs = GetIt.I.get<RosterService>();
|
||||
return RosterCacheLoadResult(
|
||||
(await GetIt.I.get<XmppService>().getXmppState()).lastRosterVersion,
|
||||
(await rs.getRoster()).map((item) => XmppRosterItem(
|
||||
jid: item.jid,
|
||||
name: item.title,
|
||||
subscription: item.subscription,
|
||||
ask: item.ask.isEmpty ? null : item.ask,
|
||||
groups: item.groups,
|
||||
),).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> loadLastRosterVersion() async {
|
||||
final ver = (await GetIt.I.get<XmppService>().getXmppState()).lastRosterVersion;
|
||||
if (ver != null) {
|
||||
setRosterVersion(ver);
|
||||
Future<void> commitRoster(String? version, List<String> removed, List<XmppRosterItem> modified, List<XmppRosterItem> added) async {
|
||||
final rs = GetIt.I.get<RosterService>();
|
||||
final xs = GetIt.I.get<XmppService>();
|
||||
await xs.modifyXmppState((state) => state.copyWith(
|
||||
lastRosterVersion: version,
|
||||
),);
|
||||
|
||||
// Remove stale items
|
||||
for (final jid in removed) {
|
||||
await rs.removeRosterItemByJid(jid);
|
||||
}
|
||||
|
||||
// Create new roster items
|
||||
final rosterAdded = List<RosterItem>.empty(growable: true);
|
||||
for (final item in added) {
|
||||
rosterAdded.add(
|
||||
await rs.addRosterItemFromData(
|
||||
'',
|
||||
'',
|
||||
item.jid,
|
||||
item.name ?? item.jid.split('@').first,
|
||||
item.subscription,
|
||||
item.ask ?? '',
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
groups: item.groups,
|
||||
),
|
||||
);
|
||||
|
||||
// TODO(PapaTutuWawa): Fetch the avatar
|
||||
}
|
||||
|
||||
// Update modified items
|
||||
final rosterModified = List<RosterItem>.empty(growable: true);
|
||||
for (final item in modified) {
|
||||
final ritem = await rs.getRosterItemByJid(item.jid);
|
||||
if (ritem == null) {
|
||||
//_log.warning('Could not find roster item with JID $jid during update');
|
||||
continue;
|
||||
}
|
||||
|
||||
rosterModified.add(
|
||||
await rs.updateRosterItem(
|
||||
ritem.id,
|
||||
title: item.name,
|
||||
subscription: item.subscription,
|
||||
ask: item.ask,
|
||||
groups: item.groups,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Tell the UI
|
||||
// TODO(Unknown): This may not be the cleanest place to put it
|
||||
sendEvent(
|
||||
RosterDiffEvent(
|
||||
added: rosterAdded,
|
||||
modified: rosterModified,
|
||||
removed: removed,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import 'package:moxxyv2/service/moxxmpp/omemo.dart';
|
||||
import 'package:omemo_dart/omemo_dart.dart';
|
||||
|
||||
Future<OmemoSessionManager> generateNewIdentityImpl(String jid) async {
|
||||
return OmemoSessionManager.generateNewIdentity(
|
||||
jid,
|
||||
MoxxyBTBVTrustManager(
|
||||
<RatchetMapKey, BTBVTrustState>{},
|
||||
<RatchetMapKey, bool>{},
|
||||
<String, List<int>>{},
|
||||
),
|
||||
);
|
||||
Future<OmemoDevice> generateNewIdentityImpl(String jid) async {
|
||||
return OmemoDevice.generateNewDevice(jid);
|
||||
}
|
||||
|
||||
@@ -4,12 +4,17 @@ import 'package:flutter/foundation.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:moxxmpp/moxxmpp.dart' as moxxmpp;
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/message.dart';
|
||||
import 'package:moxxyv2/service/moxxmpp/omemo.dart';
|
||||
import 'package:moxxyv2/service/omemo/implementations.dart';
|
||||
import 'package:moxxyv2/service/omemo/types.dart';
|
||||
import 'package:moxxyv2/shared/models/omemo_device.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/service/xmpp.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:moxxyv2/shared/models/omemo_device.dart' as model;
|
||||
import 'package:omemo_dart/omemo_dart.dart';
|
||||
import 'package:synchronized/synchronized.dart';
|
||||
|
||||
@@ -28,7 +33,7 @@ class OmemoService {
|
||||
final Queue<Completer<void>> _waitingForInitialization = Queue<Completer<void>>();
|
||||
final Map<String, Map<int, String>> _fingerprintCache = {};
|
||||
|
||||
late OmemoSessionManager omemoState;
|
||||
late OmemoManager omemoManager;
|
||||
|
||||
Future<void> initializeIfNeeded(String jid) async {
|
||||
final done = await _lock.synchronized(() => _initialized);
|
||||
@@ -36,32 +41,43 @@ class OmemoService {
|
||||
|
||||
final db = GetIt.I.get<DatabaseService>();
|
||||
final device = await db.loadOmemoDevice(jid);
|
||||
final ratchetMap = <RatchetMapKey, OmemoDoubleRatchet>{};
|
||||
final deviceList = <String, List<int>>{};
|
||||
if (device == null) {
|
||||
_log.info('No OMEMO marker found. Generating OMEMO identity...');
|
||||
// Generate the identity in the background
|
||||
omemoState = await compute(generateNewIdentityImpl, jid);
|
||||
|
||||
await commitDevice(await omemoState.getDevice());
|
||||
await commitDeviceMap(<String, List<int>>{});
|
||||
await commitTrustManager(await omemoState.trustManager.toJson());
|
||||
} else {
|
||||
_log.info('OMEMO marker found. Restoring OMEMO state...');
|
||||
final ratchetMap = <RatchetMapKey, OmemoDoubleRatchet>{};
|
||||
for (final ratchet in await GetIt.I.get<DatabaseService>().loadRatchets()) {
|
||||
final key = RatchetMapKey(ratchet.jid, ratchet.id);
|
||||
ratchetMap[key] = ratchet.ratchet;
|
||||
}
|
||||
|
||||
final db = GetIt.I.get<DatabaseService>();
|
||||
omemoState = OmemoSessionManager(
|
||||
device,
|
||||
await db.loadOmemoDeviceList(),
|
||||
ratchetMap,
|
||||
await loadTrustManager(),
|
||||
);
|
||||
deviceList.addAll(await db.loadOmemoDeviceList());
|
||||
}
|
||||
|
||||
omemoState.eventStream.listen((event) async {
|
||||
final om = GetIt.I.get<moxxmpp.XmppConnection>().
|
||||
getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!;
|
||||
omemoManager = OmemoManager(
|
||||
device ?? await compute(generateNewIdentityImpl, jid),
|
||||
await loadTrustManager(),
|
||||
om.sendEmptyMessageImpl,
|
||||
om.fetchDeviceList,
|
||||
om.fetchDeviceBundle,
|
||||
om.subscribeToDeviceListImpl,
|
||||
);
|
||||
|
||||
if (device == null) {
|
||||
await commitDevice(await omemoManager.getDevice());
|
||||
await commitDeviceMap(<String, List<int>>{});
|
||||
await commitTrustManager(await omemoManager.trustManager.toJson());
|
||||
}
|
||||
|
||||
omemoManager.initialize(
|
||||
ratchetMap,
|
||||
deviceList,
|
||||
);
|
||||
|
||||
omemoManager.eventStream.listen((event) async {
|
||||
if (event is RatchetModifiedEvent) {
|
||||
await GetIt.I.get<DatabaseService>().saveRatchet(
|
||||
OmemoDoubleRatchetWrapper(event.ratchet, event.deviceId, event.jid),
|
||||
@@ -69,7 +85,7 @@ class OmemoService {
|
||||
|
||||
if (event.added) {
|
||||
// Cache the fingerprint
|
||||
final fingerprint = HEX.encode(await event.ratchet.ik.getBytes());
|
||||
final fingerprint = await event.ratchet.getOmemoFingerprint();
|
||||
await GetIt.I.get<DatabaseService>().addFingerprintsToCache([
|
||||
OmemoCacheTriple(
|
||||
event.jid,
|
||||
@@ -81,15 +97,17 @@ class OmemoService {
|
||||
if (_fingerprintCache.containsKey(event.jid)) {
|
||||
_fingerprintCache[event.jid]![event.deviceId] = fingerprint;
|
||||
}
|
||||
|
||||
await addNewDeviceMessage(event.jid, event.deviceId);
|
||||
}
|
||||
} else if (event is DeviceMapModifiedEvent) {
|
||||
await commitDeviceMap(event.map);
|
||||
} else if (event is DeviceListModifiedEvent) {
|
||||
await commitDeviceMap(event.list);
|
||||
} else if (event is DeviceModifiedEvent) {
|
||||
await commitDevice(event.device);
|
||||
|
||||
// Publish it
|
||||
await GetIt.I.get<XmppConnection>()
|
||||
.getManagerById<OmemoManager>(omemoManager)!
|
||||
await GetIt.I.get<moxxmpp.XmppConnection>()
|
||||
.getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!
|
||||
.publishBundle(await event.device.toBundle());
|
||||
}
|
||||
});
|
||||
@@ -104,32 +122,63 @@ class OmemoService {
|
||||
});
|
||||
}
|
||||
|
||||
Future<OmemoDevice> regenerateDevice(String jid) async {
|
||||
/// Adds a pseudo message saying that [jid] added a new device with id [deviceId].
|
||||
/// If, however, [jid] is our own JID, then nothing is done.
|
||||
Future<void> addNewDeviceMessage(String jid, int deviceId) async {
|
||||
// Add a pseudo message if it is not about our own devices
|
||||
final xmppState = await GetIt.I.get<XmppService>().getXmppState();
|
||||
if (jid == xmppState.jid) return;
|
||||
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
final message = await ms.addMessageFromData(
|
||||
'',
|
||||
DateTime.now().millisecondsSinceEpoch,
|
||||
'',
|
||||
jid,
|
||||
false,
|
||||
'',
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
pseudoMessageType: pseudoMessageTypeNewDevice,
|
||||
pseudoMessageData: <String, dynamic>{
|
||||
'deviceId': deviceId,
|
||||
'jid': jid,
|
||||
},
|
||||
);
|
||||
sendEvent(
|
||||
MessageAddedEvent(
|
||||
message: message,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<model.OmemoDevice> regenerateDevice(String jid) async {
|
||||
// Prevent access to the session manager as it is (mostly) guarded ensureInitialized
|
||||
await _lock.synchronized(() {
|
||||
_initialized = false;
|
||||
});
|
||||
|
||||
_log.info('No OMEMO marker found. Generating OMEMO identity...');
|
||||
final oldId = await omemoState.getDeviceId();
|
||||
final oldId = await omemoManager.getDeviceId();
|
||||
|
||||
// Clear the database
|
||||
await GetIt.I.get<DatabaseService>().emptyOmemoSessionTables();
|
||||
|
||||
// Regenerate the identity in the background
|
||||
omemoState = await compute(generateNewIdentityImpl, jid);
|
||||
|
||||
await commitDevice(await omemoState.getDevice());
|
||||
final device = await compute(generateNewIdentityImpl, jid);
|
||||
await omemoManager.replaceDevice(device);
|
||||
await commitDevice(device);
|
||||
await commitDeviceMap(<String, List<int>>{});
|
||||
await commitTrustManager(await omemoState.trustManager.toJson());
|
||||
await commitTrustManager(await omemoManager.trustManager.toJson());
|
||||
|
||||
// Remove the old device
|
||||
final omemo = GetIt.I.get<XmppConnection>()
|
||||
.getManagerById<OmemoManager>(omemoManager)!;
|
||||
final omemo = GetIt.I.get<moxxmpp.XmppConnection>()
|
||||
.getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!;
|
||||
await omemo.deleteDevice(oldId);
|
||||
|
||||
// Publish the new one
|
||||
await omemo.publishBundle(await omemoState.getDeviceBundle());
|
||||
await omemo.publishBundle(await omemoManager.getDeviceBundle());
|
||||
|
||||
// Allow access again
|
||||
await _lock.synchronized(() {
|
||||
@@ -142,7 +191,7 @@ class OmemoService {
|
||||
});
|
||||
|
||||
// Return the OmemoDevice
|
||||
return OmemoDevice(
|
||||
return model.OmemoDevice(
|
||||
await getDeviceFingerprint(),
|
||||
true,
|
||||
true,
|
||||
@@ -173,7 +222,7 @@ class OmemoService {
|
||||
await GetIt.I.get<DatabaseService>().saveOmemoDeviceList(deviceMap);
|
||||
}
|
||||
|
||||
Future<void> commitDevice(Device device) async {
|
||||
Future<void> commitDevice(OmemoDevice device) async {
|
||||
await GetIt.I.get<DatabaseService>().saveOmemoDevice(device);
|
||||
}
|
||||
|
||||
@@ -184,46 +233,46 @@ class OmemoService {
|
||||
await ensureInitialized();
|
||||
_log.finest('publishDeviceIfNeeded: Done');
|
||||
|
||||
final conn = GetIt.I.get<XmppConnection>();
|
||||
final omemo = conn.getManagerById<OmemoManager>(omemoManager)!;
|
||||
final dm = conn.getManagerById<DiscoManager>(discoManager)!;
|
||||
final conn = GetIt.I.get<moxxmpp.XmppConnection>();
|
||||
final omemo = conn.getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!;
|
||||
final dm = conn.getManagerById<moxxmpp.DiscoManager>(moxxmpp.discoManager)!;
|
||||
final bareJid = conn.getConnectionSettings().jid.toBare();
|
||||
final device = await omemoState.getDevice();
|
||||
final device = await omemoManager.getDevice();
|
||||
|
||||
final bundlesRaw = await dm.discoItemsQuery(
|
||||
bareJid.toString(),
|
||||
node: omemoBundlesXmlns,
|
||||
node: moxxmpp.omemoBundlesXmlns,
|
||||
);
|
||||
if (bundlesRaw.isType<DiscoError>()) {
|
||||
if (bundlesRaw.isType<moxxmpp.DiscoError>()) {
|
||||
await omemo.publishBundle(await device.toBundle());
|
||||
return bundlesRaw.get<DiscoError>();
|
||||
return bundlesRaw.get<moxxmpp.DiscoError>();
|
||||
}
|
||||
|
||||
final bundleIds = bundlesRaw
|
||||
.get<List<DiscoItem>>()
|
||||
.get<List<moxxmpp.DiscoItem>>()
|
||||
.where((item) => item.name != null)
|
||||
.map((item) => int.parse(item.name!));
|
||||
if (!bundleIds.contains(device.id)) {
|
||||
final result = await omemo.publishBundle(await device.toBundle());
|
||||
if (result.isType<OmemoError>()) return result.get<OmemoError>();
|
||||
if (result.isType<moxxmpp.OmemoError>()) return result.get<moxxmpp.OmemoError>();
|
||||
return null;
|
||||
}
|
||||
|
||||
final idsRaw = await omemo.getDeviceList(bareJid);
|
||||
final ids = idsRaw.isType<OmemoError>() ? <int>[] : idsRaw.get<List<int>>();
|
||||
final ids = idsRaw.isType<moxxmpp.OmemoError>() ? <int>[] : idsRaw.get<List<int>>();
|
||||
if (!ids.contains(device.id)) {
|
||||
final result = await omemo.publishBundle(await device.toBundle());
|
||||
if (result.isType<OmemoError>()) return result.get<OmemoError>();
|
||||
if (result.isType<moxxmpp.OmemoError>()) return result.get<moxxmpp.OmemoError>();
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _fetchFingerprintsAndCache(JID jid) async {
|
||||
Future<void> _fetchFingerprintsAndCache(moxxmpp.JID jid) async {
|
||||
final bareJid = jid.toBare().toString();
|
||||
final allDevicesRaw = await GetIt.I.get<XmppConnection>()
|
||||
.getManagerById<OmemoManager>(omemoManager)!
|
||||
final allDevicesRaw = await GetIt.I.get<moxxmpp.XmppConnection>()
|
||||
.getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!
|
||||
.retrieveDeviceBundles(jid);
|
||||
if (allDevicesRaw.isType<List<OmemoBundle>>()) {
|
||||
final allDevices = allDevicesRaw.get<List<OmemoBundle>>();
|
||||
@@ -244,7 +293,7 @@ class OmemoService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadOrFetchFingerprints(JID jid) async {
|
||||
Future<void> _loadOrFetchFingerprints(moxxmpp.JID jid) async {
|
||||
final bareJid = jid.toBare().toString();
|
||||
if (!_fingerprintCache.containsKey(bareJid)) {
|
||||
// First try to load it from the database
|
||||
@@ -267,20 +316,20 @@ class OmemoService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<OmemoDevice>> getOmemoKeysForJid(String jid) async {
|
||||
Future<List<model.OmemoDevice>> getOmemoKeysForJid(String jid) async {
|
||||
await ensureInitialized();
|
||||
|
||||
// Get finger prints if we have to
|
||||
await _loadOrFetchFingerprints(JID.fromString(jid));
|
||||
await _loadOrFetchFingerprints(moxxmpp.JID.fromString(jid));
|
||||
|
||||
final keys = List<OmemoDevice>.empty(growable: true);
|
||||
final tm = omemoState.trustManager as BlindTrustBeforeVerificationTrustManager;
|
||||
final keys = List<model.OmemoDevice>.empty(growable: true);
|
||||
final tm = omemoManager.trustManager as BlindTrustBeforeVerificationTrustManager;
|
||||
final trustMap = await tm.getDevicesTrust(jid);
|
||||
|
||||
if (!_fingerprintCache.containsKey(jid)) return [];
|
||||
for (final deviceId in _fingerprintCache[jid]!.keys) {
|
||||
keys.add(
|
||||
OmemoDevice(
|
||||
model.OmemoDevice(
|
||||
_fingerprintCache[jid]![deviceId]!,
|
||||
await tm.isTrusted(jid, deviceId),
|
||||
trustMap[deviceId] == BTBVTrustState.verified,
|
||||
@@ -316,29 +365,27 @@ class OmemoService {
|
||||
|
||||
Future<void> setOmemoKeyEnabled(String jid, int deviceId, bool enabled) async {
|
||||
await ensureInitialized();
|
||||
await omemoState.trustManager.setEnabled(jid, deviceId, enabled);
|
||||
await omemoManager.trustManager.setEnabled(jid, deviceId, enabled);
|
||||
}
|
||||
|
||||
Future<void> removeAllSessions(String jid) async {
|
||||
await ensureInitialized();
|
||||
await omemoState.removeAllRatchets(jid);
|
||||
await omemoManager.removeAllRatchets(jid);
|
||||
}
|
||||
|
||||
Future<int> getDeviceId() async {
|
||||
await ensureInitialized();
|
||||
return omemoState.getDeviceId();
|
||||
return omemoManager.getDeviceId();
|
||||
}
|
||||
|
||||
Future<String> getDeviceFingerprint() async {
|
||||
return (await omemoState.getHexFingerprintForDevice()).fingerprint;
|
||||
}
|
||||
Future<String> getDeviceFingerprint() => omemoManager.getDeviceFingerprint();
|
||||
|
||||
/// Returns a list of OmemoDevices for devices we have sessions with and other devices
|
||||
/// published on [ownJid]'s devices PubSub node.
|
||||
/// Note that the list is made so that the current device is excluded.
|
||||
Future<List<OmemoDevice>> getOwnFingerprints(JID ownJid) async {
|
||||
Future<List<model.OmemoDevice>> getOwnFingerprints(moxxmpp.JID ownJid) async {
|
||||
final ownId = await getDeviceId();
|
||||
final keys = List<OmemoDevice>.from(
|
||||
final keys = List<model.OmemoDevice>.from(
|
||||
await getOmemoKeysForJid(ownJid.toString()),
|
||||
);
|
||||
final bareJid = ownJid.toBare().toString();
|
||||
@@ -346,15 +393,16 @@ class OmemoService {
|
||||
// Get fingerprints if we have to
|
||||
await _loadOrFetchFingerprints(ownJid);
|
||||
|
||||
final tm = omemoState.trustManager as BlindTrustBeforeVerificationTrustManager;
|
||||
final tm = omemoManager.trustManager as BlindTrustBeforeVerificationTrustManager;
|
||||
final trustMap = await tm.getDevicesTrust(bareJid);
|
||||
|
||||
for (final deviceId in _fingerprintCache[bareJid]!.keys) {
|
||||
if (deviceId == ownId) continue;
|
||||
if (keys.indexWhere((key) => key.deviceId == deviceId) != -1) continue;
|
||||
|
||||
final fingerprint = _fingerprintCache[bareJid]![deviceId]!;
|
||||
keys.add(
|
||||
OmemoDevice(
|
||||
model.OmemoDevice(
|
||||
fingerprint,
|
||||
await tm.isTrusted(bareJid, deviceId),
|
||||
trustMap[deviceId] == BTBVTrustState.verified,
|
||||
@@ -369,11 +417,18 @@ class OmemoService {
|
||||
}
|
||||
|
||||
Future<void> verifyDevice(int deviceId, String jid) async {
|
||||
final tm = omemoState.trustManager as BlindTrustBeforeVerificationTrustManager;
|
||||
final tm = omemoManager.trustManager as BlindTrustBeforeVerificationTrustManager;
|
||||
await tm.setDeviceTrust(
|
||||
jid,
|
||||
deviceId,
|
||||
BTBVTrustState.verified,
|
||||
);
|
||||
}
|
||||
|
||||
/// Tells omemo_dart, that certain caches are to be seen as invalidated.
|
||||
void onNewConnection() {
|
||||
if (_initialized) {
|
||||
omemoManager.onNewConnection();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,222 +1,28 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxlib/moxlib.dart';
|
||||
import 'package:moxplatform/moxplatform.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/contacts.dart';
|
||||
import 'package:moxxyv2/service/conversation.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/not_specified.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/models/conversation.dart';
|
||||
import 'package:moxxyv2/shared/models/roster.dart';
|
||||
|
||||
/// Closure which returns true if the jid of a [RosterItem] is equal to [jid].
|
||||
bool Function(RosterItem) _jidEqualsWrapper(String jid) {
|
||||
return (i) => i.jid == jid;
|
||||
}
|
||||
|
||||
typedef AddRosterItemFunction = Future<RosterItem> Function(
|
||||
String avatarUrl,
|
||||
String avatarHash,
|
||||
String jid,
|
||||
String title,
|
||||
String subscription,
|
||||
String ask,
|
||||
bool pseudoRosterItem,
|
||||
String? contactId,
|
||||
String? contactAvatarPath,
|
||||
String? contactDisplayName,
|
||||
{
|
||||
List<String> groups,
|
||||
}
|
||||
);
|
||||
typedef UpdateRosterItemFunction = Future<RosterItem> Function(
|
||||
int id, {
|
||||
String? avatarUrl,
|
||||
String? avatarHash,
|
||||
String? title,
|
||||
String? subscription,
|
||||
String? ask,
|
||||
Object pseudoRosterItem,
|
||||
List<String>? groups,
|
||||
}
|
||||
);
|
||||
typedef RemoveRosterItemFunction = Future<void> Function(String jid);
|
||||
typedef GetConversationFunction = Future<Conversation?> Function(String jid);
|
||||
typedef SendEventFunction = void Function(BackgroundEvent event, { String? id });
|
||||
|
||||
/// Compare the local roster with the roster we received either by request or by push.
|
||||
/// Returns a diff between the roster before and after the request or the push.
|
||||
/// NOTE: This abuses the [RosterDiffEvent] type a bit.
|
||||
Future<RosterDiffEvent> processRosterDiff(
|
||||
List<RosterItem> currentRoster,
|
||||
List<XmppRosterItem> remoteRoster,
|
||||
bool isRosterPush,
|
||||
AddRosterItemFunction addRosterItemFromData,
|
||||
UpdateRosterItemFunction updateRosterItem,
|
||||
RemoveRosterItemFunction removeRosterItemByJid,
|
||||
GetConversationFunction getConversationByJid,
|
||||
SendEventFunction _sendEvent,
|
||||
) async {
|
||||
final css = GetIt.I.get<ContactsService>();
|
||||
final removed = List<String>.empty(growable: true);
|
||||
final modified = List<RosterItem>.empty(growable: true);
|
||||
final added = List<RosterItem>.empty(growable: true);
|
||||
|
||||
for (final item in remoteRoster) {
|
||||
if (isRosterPush) {
|
||||
final litem = firstWhereOrNull(currentRoster, _jidEqualsWrapper(item.jid));
|
||||
if (litem != null) {
|
||||
if (item.subscription == 'remove') {
|
||||
// We have the item locally but it has been removed
|
||||
|
||||
if (litem.contactId != null) {
|
||||
// We have the contact associated with a contact
|
||||
final newItem = await updateRosterItem(
|
||||
litem.id,
|
||||
ask: 'none',
|
||||
subscription: 'none',
|
||||
pseudoRosterItem: true,
|
||||
);
|
||||
modified.add(newItem);
|
||||
} else {
|
||||
await removeRosterItemByJid(item.jid);
|
||||
removed.add(item.jid);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Item has been modified
|
||||
final newItem = await updateRosterItem(
|
||||
litem.id,
|
||||
subscription: item.subscription,
|
||||
title: item.name,
|
||||
ask: item.ask,
|
||||
pseudoRosterItem: false,
|
||||
groups: item.groups,
|
||||
);
|
||||
|
||||
modified.add(newItem);
|
||||
|
||||
// Check if we have a conversation that we need to modify
|
||||
final conv = await getConversationByJid(item.jid);
|
||||
if (conv != null) {
|
||||
_sendEvent(
|
||||
ConversationUpdatedEvent(
|
||||
conversation: conv.copyWith(subscription: item.subscription),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Item does not exist locally
|
||||
if (item.subscription == 'remove') {
|
||||
// Item has been removed but we don't have it locally
|
||||
removed.add(item.jid);
|
||||
} else {
|
||||
// Item has been added and we don't have it locally
|
||||
final contactId = await css.getContactIdForJid(item.jid);
|
||||
final newItem = await addRosterItemFromData(
|
||||
'',
|
||||
'',
|
||||
item.jid,
|
||||
item.name ?? item.jid.split('@')[0],
|
||||
item.subscription,
|
||||
item.ask ?? '',
|
||||
false,
|
||||
contactId,
|
||||
await css.getProfilePicturePathForJid(item.jid),
|
||||
await css.getContactDisplayName(contactId),
|
||||
groups: item.groups,
|
||||
);
|
||||
|
||||
added.add(newItem);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
final litem = firstWhereOrNull(currentRoster, _jidEqualsWrapper(item.jid));
|
||||
if (litem != null) {
|
||||
// Item is modified
|
||||
if (litem.title != item.name || litem.subscription != item.subscription || !listEquals(litem.groups, item.groups)) {
|
||||
final modifiedItem = await updateRosterItem(
|
||||
litem.id,
|
||||
title: item.name,
|
||||
subscription: item.subscription,
|
||||
pseudoRosterItem: false,
|
||||
groups: item.groups,
|
||||
);
|
||||
modified.add(modifiedItem);
|
||||
|
||||
// Check if we have a conversation that we need to modify
|
||||
final conv = await getConversationByJid(litem.jid);
|
||||
if (conv != null) {
|
||||
_sendEvent(
|
||||
ConversationUpdatedEvent(
|
||||
conversation: conv.copyWith(subscription: item.subscription),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Item is new
|
||||
final contactId = await css.getContactIdForJid(item.jid);
|
||||
added.add(await addRosterItemFromData(
|
||||
'',
|
||||
'',
|
||||
item.jid,
|
||||
item.jid.split('@')[0],
|
||||
item.subscription,
|
||||
item.ask ?? '',
|
||||
false,
|
||||
contactId,
|
||||
await css.getProfilePicturePathForJid(item.jid),
|
||||
await css.getContactDisplayName(contactId),
|
||||
groups: item.groups,
|
||||
),);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isRosterPush) {
|
||||
for (final item in currentRoster) {
|
||||
final ritem = firstWhereOrNull(remoteRoster, (XmppRosterItem i) => i.jid == item.jid);
|
||||
if (ritem == null) {
|
||||
await removeRosterItemByJid(item.jid);
|
||||
removed.add(item.jid);
|
||||
}
|
||||
// We don't handle the modification case here as that is covered by the huge
|
||||
// loop above
|
||||
}
|
||||
}
|
||||
|
||||
return RosterDiffEvent(
|
||||
added: added,
|
||||
modified: modified,
|
||||
removed: removed,
|
||||
);
|
||||
}
|
||||
|
||||
class RosterService {
|
||||
|
||||
RosterService()
|
||||
: _rosterCache = HashMap(),
|
||||
_rosterLoaded = false,
|
||||
_log = Logger('RosterService');
|
||||
final HashMap<String, RosterItem> _rosterCache;
|
||||
bool _rosterLoaded;
|
||||
RosterService() : _log = Logger('RosterService');
|
||||
Map<String, RosterItem>? _rosterCache;
|
||||
final Logger _log;
|
||||
|
||||
Future<bool> isInRoster(String jid) async {
|
||||
if (!_rosterLoaded) {
|
||||
|
||||
Future<void> _loadRosterIfNeeded() async {
|
||||
if (_rosterCache == null) {
|
||||
await loadRosterFromDatabase();
|
||||
}
|
||||
}
|
||||
|
||||
return _rosterCache.containsKey(jid);
|
||||
Future<bool> isInRoster(String jid) async {
|
||||
await _loadRosterIfNeeded();
|
||||
return _rosterCache!.containsKey(jid);
|
||||
}
|
||||
|
||||
/// Wrapper around [DatabaseService]'s addRosterItemFromData that updates the cache.
|
||||
@@ -250,7 +56,7 @@ class RosterService {
|
||||
);
|
||||
|
||||
// Update the cache
|
||||
_rosterCache[item.jid] = item;
|
||||
_rosterCache![item.jid] = item;
|
||||
|
||||
return item;
|
||||
}
|
||||
@@ -285,26 +91,26 @@ class RosterService {
|
||||
);
|
||||
|
||||
// Update cache
|
||||
_rosterCache[newItem.jid] = newItem;
|
||||
_rosterCache![newItem.jid] = newItem;
|
||||
|
||||
return newItem;
|
||||
}
|
||||
|
||||
/// Wrapper around [DatabaseService]'s removeRosterItem.
|
||||
Future<void> removeRosterItem(int id) async {
|
||||
// NOTE: This call ensures that _rosterCache != null
|
||||
await GetIt.I.get<DatabaseService>().removeRosterItem(id);
|
||||
|
||||
assert(_rosterCache != null, '_rosterCache must be non-null');
|
||||
|
||||
/// Update cache
|
||||
_rosterCache.removeWhere((_, value) => value.id == id);
|
||||
_rosterCache!.removeWhere((_, value) => value.id == id);
|
||||
}
|
||||
|
||||
/// Removes a roster item from the database based on its JID.
|
||||
Future<void> removeRosterItemByJid(String jid) async {
|
||||
if (!_rosterLoaded) {
|
||||
await loadRosterFromDatabase();
|
||||
}
|
||||
await _loadRosterIfNeeded();
|
||||
|
||||
for (final item in _rosterCache.values) {
|
||||
for (final item in _rosterCache!.values) {
|
||||
if (item.jid == jid) {
|
||||
await removeRosterItem(item.id);
|
||||
return;
|
||||
@@ -314,17 +120,14 @@ class RosterService {
|
||||
|
||||
/// Returns the entire roster
|
||||
Future<List<RosterItem>> getRoster() async {
|
||||
if (!_rosterLoaded) {
|
||||
await loadRosterFromDatabase();
|
||||
}
|
||||
|
||||
return _rosterCache.values.toList();
|
||||
await _loadRosterIfNeeded();
|
||||
return _rosterCache!.values.toList();
|
||||
}
|
||||
|
||||
/// Returns the roster item with jid [jid] if it exists. Null otherwise.
|
||||
Future<RosterItem?> getRosterItemByJid(String jid) async {
|
||||
if (await isInRoster(jid)) {
|
||||
return _rosterCache[jid];
|
||||
return _rosterCache![jid];
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -335,9 +138,9 @@ class RosterService {
|
||||
Future<List<RosterItem>> loadRosterFromDatabase() async {
|
||||
final items = await GetIt.I.get<DatabaseService>().loadRosterItems();
|
||||
|
||||
_rosterLoaded = true;
|
||||
_rosterCache = <String, RosterItem>{};
|
||||
for (final item in items) {
|
||||
_rosterCache[item.jid] = item;
|
||||
_rosterCache![item.jid] = item;
|
||||
}
|
||||
|
||||
return items;
|
||||
@@ -392,59 +195,6 @@ class RosterService {
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> requestRoster() async {
|
||||
final roster = GetIt.I.get<XmppConnection>().getManagerById<RosterManager>(rosterManager)!;
|
||||
Result<RosterRequestResult?, RosterError> result;
|
||||
if (roster.rosterVersioningAvailable()) {
|
||||
_log.fine('Stream supports roster versioning');
|
||||
result = await roster.requestRosterPushes();
|
||||
_log.fine('Requesting roster pushes done');
|
||||
} else {
|
||||
_log.fine('Stream does not support roster versioning');
|
||||
result = await roster.requestRoster();
|
||||
}
|
||||
|
||||
if (result.isType<RosterError>()) {
|
||||
_log.warning('Failed to request roster');
|
||||
return;
|
||||
}
|
||||
|
||||
final value = result.get<RosterRequestResult?>();
|
||||
if (value != null) {
|
||||
final currentRoster = await getRoster();
|
||||
sendEvent(
|
||||
await processRosterDiff(
|
||||
currentRoster,
|
||||
value.items,
|
||||
false,
|
||||
addRosterItemFromData,
|
||||
updateRosterItem,
|
||||
removeRosterItemByJid,
|
||||
GetIt.I.get<ConversationService>().getConversationByJid,
|
||||
sendEvent,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles a roster push.
|
||||
Future<void> handleRosterPushEvent(RosterPushEvent event) async {
|
||||
final item = event.item;
|
||||
final currentRoster = await getRoster();
|
||||
sendEvent(
|
||||
await processRosterDiff(
|
||||
currentRoster,
|
||||
[ item ],
|
||||
true,
|
||||
addRosterItemFromData,
|
||||
updateRosterItem,
|
||||
removeRosterItemByJid,
|
||||
GetIt.I.get<ConversationService>().getConversationByJid,
|
||||
sendEvent,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> acceptSubscriptionRequest(String jid) async {
|
||||
GetIt.I.get<XmppConnection>().getPresenceManager().sendSubscriptionRequestApproval(jid);
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ Future<void> entrypoint() async {
|
||||
)..registerManagers([
|
||||
MoxxyStreamManagementManager(),
|
||||
MoxxyDiscoManager(),
|
||||
MoxxyRosterManager(),
|
||||
RosterManager(MoxxyRosterStateManager()),
|
||||
MoxxyOmemoManager(),
|
||||
PingManager(),
|
||||
MessageManager(),
|
||||
@@ -252,6 +252,7 @@ Future<void> entrypoint() async {
|
||||
sendEvent(ServiceReadyEvent());
|
||||
}
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> receiveUIEvent(Map<String, dynamic>? data) async {
|
||||
await GetIt.I.get<SynchronizedQueue<Map<String, dynamic>?>>().add(data);
|
||||
}
|
||||
|
||||
@@ -2,14 +2,15 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:archive/archive.dart';
|
||||
import 'package:dio/dio.dart' as dio;
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxlib/moxlib.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/helpers.dart';
|
||||
import 'package:moxxyv2/service/httpfiletransfer/client.dart';
|
||||
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
|
||||
import 'package:moxxyv2/service/preferences.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/service/xmpp.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
@@ -89,10 +90,17 @@ class StickersService {
|
||||
}
|
||||
|
||||
Future<void> _publishStickerPack(moxxmpp.StickerPack pack) async {
|
||||
final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
|
||||
final state = await GetIt.I.get<XmppService>().getXmppState();
|
||||
final result = await GetIt.I.get<moxxmpp.XmppConnection>()
|
||||
.getManagerById<moxxmpp.StickersManager>(moxxmpp.stickersManager)!
|
||||
.publishStickerPack(moxxmpp.JID.fromString(state.jid!), pack);
|
||||
.publishStickerPack(
|
||||
moxxmpp.JID.fromString(state.jid!),
|
||||
pack,
|
||||
accessModel: prefs.isStickersNodePublic ?
|
||||
'open' :
|
||||
null,
|
||||
);
|
||||
|
||||
if (result.isType<moxxmpp.PubSubError>()) {
|
||||
_log.severe('Failed to publish sticker pack');
|
||||
@@ -159,20 +167,15 @@ class StickersService {
|
||||
stickerPackPath,
|
||||
sticker.hashes.values.first,
|
||||
);
|
||||
dio.Response<dynamic>? response;
|
||||
try {
|
||||
response = await dio.Dio().downloadUri(
|
||||
Uri.parse(sticker.urlSources.first),
|
||||
stickerPath,
|
||||
);
|
||||
} on dio.DioError catch(err) {
|
||||
_log.severe('Error downloading ${sticker.urlSources.first}: $err');
|
||||
success = false;
|
||||
break;
|
||||
}
|
||||
final downloadStatusCode = await downloadFile(
|
||||
Uri.parse(sticker.urlSources.first),
|
||||
stickerPath,
|
||||
(_, __) {},
|
||||
);
|
||||
|
||||
if (!isRequestOkay(response.statusCode)) {
|
||||
_log.severe('Request not okay: $response');
|
||||
if (!isRequestOkay(downloadStatusCode)) {
|
||||
_log.severe('Request not okay: $downloadStatusCode');
|
||||
success = false;
|
||||
break;
|
||||
}
|
||||
stickers[i] = sticker.copyWith(
|
||||
|
||||
@@ -55,7 +55,6 @@ class XmppService {
|
||||
EventTypeMatcher<SubscriptionRequestReceivedEvent>(_onSubscriptionRequestReceived),
|
||||
EventTypeMatcher<DeliveryReceiptReceivedEvent>(_onDeliveryReceiptReceived),
|
||||
EventTypeMatcher<ChatMarkerEvent>(_onChatMarker),
|
||||
EventTypeMatcher<RosterPushEvent>(_onRosterPush),
|
||||
EventTypeMatcher<AvatarUpdatedEvent>(_onAvatarUpdated),
|
||||
EventTypeMatcher<StanzaAckedEvent>(_onStanzaAcked),
|
||||
EventTypeMatcher<MessageEvent>(_onMessage),
|
||||
@@ -663,8 +662,14 @@ class XmppService {
|
||||
|
||||
_log.finest('Connection connected. Is resumed? ${event.resumed}');
|
||||
unawaited(_initializeOmemoService(settings.jid.toString()));
|
||||
|
||||
|
||||
if (!event.resumed) {
|
||||
// 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)!
|
||||
@@ -675,8 +680,10 @@ class XmppService {
|
||||
|
||||
// In section 5 of XEP-0198 it says that a client should not request the roster
|
||||
// in case of a stream resumption.
|
||||
await GetIt.I.get<RosterService>().requestRoster();
|
||||
|
||||
await connection
|
||||
.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) {
|
||||
@@ -1140,15 +1147,12 @@ class XmppService {
|
||||
// Pre-process the message in case it is a reply to another message
|
||||
String? replyId;
|
||||
var messageBody = event.body;
|
||||
// TODO(Unknown): Implement
|
||||
if (event.reply != null /* && check if event.reply.to is okay */) {
|
||||
if (event.reply != null) {
|
||||
replyId = event.reply!.id;
|
||||
|
||||
// Strip the compatibility fallback, if specified
|
||||
if (event.reply!.start != null && event.reply!.end != null) {
|
||||
messageBody = messageBody.replaceRange(event.reply!.start!, event.reply!.end, '');
|
||||
_log.finest('Removed message reply compatibility fallback from message');
|
||||
}
|
||||
messageBody = event.reply!.removeFallback(messageBody);
|
||||
_log.finest('Removed message reply compatibility fallback from message');
|
||||
}
|
||||
|
||||
// The Url of the file embedded in the message, if there is one.
|
||||
@@ -1230,6 +1234,7 @@ class XmppService {
|
||||
final fts = GetIt.I.get<HttpFileTransferService>();
|
||||
final metadata = await peekFile(embeddedFile!.url);
|
||||
|
||||
_log.finest('Advertised file MIME: ${metadata.mime}');
|
||||
if (metadata.mime != null) mimeGuess = metadata.mime;
|
||||
|
||||
// Auto-download only if the file is below the set limit, if the limit is not set to
|
||||
@@ -1381,12 +1386,13 @@ class XmppService {
|
||||
sendEvent(MessageUpdatedEvent(message: message));
|
||||
|
||||
if (shouldDownload) {
|
||||
_log.finest('Advertised file MIME: ${_getMimeGuess(event)}');
|
||||
await GetIt.I.get<HttpFileTransferService>().downloadFile(
|
||||
FileDownloadJob(
|
||||
embeddedFile,
|
||||
message.id,
|
||||
conversationJid,
|
||||
null,
|
||||
_getMimeGuess(event),
|
||||
shouldShowNotification: false,
|
||||
),
|
||||
);
|
||||
@@ -1396,17 +1402,8 @@ class XmppService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onRosterPush(RosterPushEvent event, { dynamic extra }) async {
|
||||
_log.fine("Roster push version: ${event.ver ?? "(null)"}");
|
||||
await GetIt.I.get<RosterService>().handleRosterPushEvent(event);
|
||||
}
|
||||
|
||||
Future<void> _onAvatarUpdated(AvatarUpdatedEvent event, { dynamic extra }) async {
|
||||
await GetIt.I.get<AvatarService>().updateAvatarForJid(
|
||||
event.jid,
|
||||
event.hash,
|
||||
event.base64,
|
||||
);
|
||||
await GetIt.I.get<AvatarService>().handleAvatarUpdate(event);
|
||||
}
|
||||
|
||||
Future<void> _onStanzaAcked(StanzaAckedEvent event, { dynamic extra }) async {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as pathlib;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ String formatConversationTimestamp(int timestamp, int now) {
|
||||
return '${hourDifference}h';
|
||||
}
|
||||
} else if (difference <= Duration.millisecondsPerMinute) {
|
||||
return 'Just now';
|
||||
return t.dateTime.justNow;
|
||||
}
|
||||
|
||||
return '${(difference / Duration.millisecondsPerMinute).floor()}min';
|
||||
@@ -58,9 +58,10 @@ String formatMessageTimestamp(int timestamp, int now) {
|
||||
return '${dt.hour}:${padInt(dt.minute)}';
|
||||
} else {
|
||||
if (difference < Duration.millisecondsPerMinute) {
|
||||
return 'Just now';
|
||||
return t.dateTime.justNow;
|
||||
} else {
|
||||
return '${(difference / Duration.millisecondsPerMinute).floor()}min ago';
|
||||
final diff = (difference / Duration.millisecondsPerMinute).floor();
|
||||
return t.dateTime.nMinutesAgo(min: diff);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,19 +70,19 @@ String formatMessageTimestamp(int timestamp, int now) {
|
||||
String weekdayToStringAbbrev(int day) {
|
||||
switch (day) {
|
||||
case DateTime.monday:
|
||||
return 'Mon';
|
||||
return t.dateTime.mondayAbbrev;
|
||||
case DateTime.tuesday:
|
||||
return 'Tue';
|
||||
return t.dateTime.tuesdayAbbrev;
|
||||
case DateTime.wednesday:
|
||||
return 'Wed';
|
||||
return t.dateTime.wednessdayAbbrev;
|
||||
case DateTime.thursday:
|
||||
return 'Thu';
|
||||
return t.dateTime.thursdayAbbrev;
|
||||
case DateTime.friday:
|
||||
return 'Fri';
|
||||
return t.dateTime.fridayAbbrev;
|
||||
case DateTime.saturday:
|
||||
return 'Sat';
|
||||
return t.dateTime.saturdayAbbrev;
|
||||
case DateTime.sunday:
|
||||
return 'Sun';
|
||||
return t.dateTime.sundayAbbrev;
|
||||
}
|
||||
|
||||
// Should not happen
|
||||
@@ -92,29 +93,29 @@ String weekdayToStringAbbrev(int day) {
|
||||
String monthToString(int month) {
|
||||
switch (month) {
|
||||
case DateTime.january:
|
||||
return 'January';
|
||||
return t.dateTime.january;
|
||||
case DateTime.february:
|
||||
return 'February';
|
||||
return t.dateTime.february;
|
||||
case DateTime.march:
|
||||
return 'March';
|
||||
return t.dateTime.march;
|
||||
case DateTime.april:
|
||||
return 'April';
|
||||
return t.dateTime.april;
|
||||
case DateTime.may:
|
||||
return 'May';
|
||||
return t.dateTime.may;
|
||||
case DateTime.june:
|
||||
return 'June';
|
||||
return t.dateTime.june;
|
||||
case DateTime.july:
|
||||
return 'July';
|
||||
return t.dateTime.july;
|
||||
case DateTime.august:
|
||||
return 'August';
|
||||
return t.dateTime.august;
|
||||
case DateTime.september:
|
||||
return 'September';
|
||||
return t.dateTime.september;
|
||||
case DateTime.october:
|
||||
return 'October';
|
||||
return t.dateTime.october;
|
||||
case DateTime.november:
|
||||
return 'November';
|
||||
return t.dateTime.november;
|
||||
case DateTime.december:
|
||||
return 'December';
|
||||
return t.dateTime.december;
|
||||
}
|
||||
|
||||
// Should not happen
|
||||
@@ -125,9 +126,9 @@ String monthToString(int month) {
|
||||
/// like 'Today', 'Yesterday', 'Fri, 7. August' or '6. August 2022'.
|
||||
String formatDateBubble(DateTime dt, DateTime now) {
|
||||
if (dt.day == now.day && dt.month == now.month && dt.year == now.year) {
|
||||
return 'Today';
|
||||
return t.dateTime.today;
|
||||
} else if (now.subtract(const Duration(days: 1)).day == dt.day) {
|
||||
return 'Yesterday';
|
||||
return t.dateTime.yesterday;
|
||||
} else if (dt.year == now.year) {
|
||||
return '${weekdayToStringAbbrev(dt.weekday)}, ${dt.day}. ${monthToString(dt.month)}';
|
||||
} else {
|
||||
|
||||
@@ -9,18 +9,33 @@ import 'package:moxxyv2/shared/warning_types.dart';
|
||||
part 'message.freezed.dart';
|
||||
part 'message.g.dart';
|
||||
|
||||
const pseudoMessageTypeNewDevice = 1;
|
||||
|
||||
Map<String, String>? _optionalJsonDecode(String? data) {
|
||||
if (data == null) return null;
|
||||
|
||||
return (jsonDecode(data) as Map<dynamic, dynamic>).cast<String, String>();
|
||||
}
|
||||
|
||||
String? _optionalJsonEncode(Map<String, String>? data) {
|
||||
Map<String, dynamic> _optionalJsonDecodeWithFallback(String? data) {
|
||||
if (data == null) return <String, dynamic>{};
|
||||
|
||||
return (jsonDecode(data) as Map<dynamic, dynamic>).cast<String, dynamic>();
|
||||
}
|
||||
|
||||
String? _optionalJsonEncode(Map<String, dynamic>? data) {
|
||||
if (data == null) return null;
|
||||
|
||||
return jsonEncode(data);
|
||||
}
|
||||
|
||||
String? _optionalJsonEncodeWithFallback(Map<String, dynamic>? data) {
|
||||
if (data == null) return null;
|
||||
if (data.isEmpty) return null;
|
||||
|
||||
return jsonEncode(data);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class Message with _$Message {
|
||||
factory Message(
|
||||
@@ -66,6 +81,8 @@ class Message with _$Message {
|
||||
@Default([]) List<Reaction> reactions,
|
||||
String? stickerPackId,
|
||||
String? stickerHashKey,
|
||||
int? pseudoMessageType,
|
||||
Map<String, dynamic>? pseudoMessageData,
|
||||
}
|
||||
) = _Message;
|
||||
|
||||
@@ -91,6 +108,7 @@ class Message with _$Message {
|
||||
'isEdited': intToBool(json['isEdited']! as int),
|
||||
'containsNoStore': intToBool(json['containsNoStore']! as int),
|
||||
'reactions': <Map<String, dynamic>>[],
|
||||
'pseudoMessageData': _optionalJsonDecodeWithFallback(json['pseudoMessageData'] as String?)
|
||||
}).copyWith(
|
||||
quotes: quotes,
|
||||
reactions: (jsonDecode(json['reactions']! as String) as List<dynamic>)
|
||||
@@ -104,7 +122,8 @@ class Message with _$Message {
|
||||
final map = toJson()
|
||||
..remove('id')
|
||||
..remove('quotes')
|
||||
..remove('reactions');
|
||||
..remove('reactions')
|
||||
..remove('pseudoMessageData');
|
||||
|
||||
return {
|
||||
...map,
|
||||
@@ -128,6 +147,7 @@ class Message with _$Message {
|
||||
.map((r) => r.toJson())
|
||||
.toList(),
|
||||
),
|
||||
'pseudoMessageData': _optionalJsonEncodeWithFallback(pseudoMessageData),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -143,27 +163,30 @@ class Message with _$Message {
|
||||
return mimeTypeToEmoji(mediaType, addTypeName: false);
|
||||
}
|
||||
|
||||
/// True if the message is a pseudo message.
|
||||
bool get isPseudoMessage => pseudoMessageType != null && pseudoMessageData != null;
|
||||
|
||||
/// Returns true if the message can be quoted. False if not.
|
||||
bool get isQuotable => !hasError && !isRetracted && !isFileUploadNotification && !isUploading && !isDownloading;
|
||||
bool get isQuotable => !hasError && !isRetracted && !isFileUploadNotification && !isUploading && !isDownloading && !isPseudoMessage;
|
||||
|
||||
/// Returns true if the message can be retracted. False if not.
|
||||
/// [sentBySelf] asks whether or not the message was sent by us (the current Jid).
|
||||
bool canRetract(bool sentBySelf) {
|
||||
return originId != null && sentBySelf && !isFileUploadNotification && !isUploading && !isDownloading;
|
||||
return originId != null && sentBySelf && !isFileUploadNotification && !isUploading && !isDownloading && !isPseudoMessage;
|
||||
}
|
||||
|
||||
/// Returns true if we can send a reaction for this message.
|
||||
bool get isReactable => !hasError && !isRetracted && !isFileUploadNotification && !isUploading && !isDownloading;
|
||||
bool get isReactable => !hasError && !isRetracted && !isFileUploadNotification && !isUploading && !isDownloading && !isPseudoMessage;
|
||||
|
||||
/// Returns true if the message can be edited. False if not.
|
||||
/// [sentBySelf] asks whether or not the message was sent by us (the current Jid).
|
||||
bool canEdit(bool sentBySelf) {
|
||||
return sentBySelf && !isMedia && !isFileUploadNotification && !isUploading && !isDownloading;
|
||||
return sentBySelf && !isMedia && !isFileUploadNotification && !isUploading && !isDownloading && !isPseudoMessage;
|
||||
}
|
||||
|
||||
/// Returns true if the message can open the selection menu by longpressing. False if
|
||||
/// not.
|
||||
bool get isLongpressable => !isRetracted;
|
||||
bool get isLongpressable => !isRetracted && !isPseudoMessage;
|
||||
|
||||
/// Returns true if the menu item to show the error should be shown in the
|
||||
/// longpress menu.
|
||||
@@ -176,14 +199,14 @@ class Message with _$Message {
|
||||
|
||||
/// Returns true if the message contains media that can be thumbnailed, i.e. videos or
|
||||
/// images.
|
||||
bool get isThumbnailable => isMedia && mediaType != null && (
|
||||
bool get isThumbnailable => !isPseudoMessage && isMedia && mediaType != null && (
|
||||
mediaType!.startsWith('image/') ||
|
||||
mediaType!.startsWith('video/')
|
||||
);
|
||||
|
||||
/// Returns true if the message can be copied to the clipboard.
|
||||
bool get isCopyable => !isMedia && body.isNotEmpty;
|
||||
bool get isCopyable => !isMedia && body.isNotEmpty && !isPseudoMessage;
|
||||
|
||||
/// Returns true if the message is a sticker
|
||||
bool get isSticker => isMedia && stickerPackId != null && stickerHashKey != null;
|
||||
bool get isSticker => isMedia && stickerPackId != null && stickerHashKey != null && !isPseudoMessage;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ class PreferencesState with _$PreferencesState {
|
||||
@Default(false) bool enableContactIntegration,
|
||||
@Default(true) bool enableStickers,
|
||||
@Default(true) bool autoDownloadStickersFromContacts,
|
||||
@Default(true) bool isStickersNodePublic,
|
||||
@Default(false) bool showDebugMenu,
|
||||
}) = _PreferencesState;
|
||||
|
||||
// JSON serialization
|
||||
|
||||
@@ -85,4 +85,7 @@ class Sticker with _$Sticker {
|
||||
.toList(),
|
||||
suggests,
|
||||
);
|
||||
|
||||
/// True, if the sticker is backed by an image with MIME type image/*.
|
||||
bool get isImage => mediaType.startsWith('image/');
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ class AddContactBloc extends Bloc<AddContactEvent, AddContactState> {
|
||||
}
|
||||
}
|
||||
|
||||
assert(result.conversation != null, 'RequestedConversationEvent must contain a not null conversation');
|
||||
GetIt.I.get<ConversationBloc>().add(
|
||||
RequestedConversationEvent(
|
||||
result.conversation!.jid,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxplatform/moxplatform.dart';
|
||||
import 'package:moxxyv2/shared/commands.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
|
||||
part 'blocklist_bloc.freezed.dart';
|
||||
part 'blocklist_event.dart';
|
||||
@@ -9,11 +13,44 @@ part 'blocklist_state.dart';
|
||||
|
||||
class BlocklistBloc extends Bloc<BlocklistEvent, BlocklistState> {
|
||||
BlocklistBloc() : super(BlocklistState()) {
|
||||
on<BlocklistRequestedEvent>(_onBlocklistRequested);
|
||||
on<UnblockedJidEvent>(_onJidUnblocked);
|
||||
on<UnblockedAllEvent>(_onUnblockedAll);
|
||||
on<BlocklistPushedEvent>(_onBlocklistPushed);
|
||||
}
|
||||
|
||||
Future<void> _onBlocklistRequested(BlocklistRequestedEvent event, Emitter<BlocklistState> emit) async {
|
||||
final mustDoWork = state.blocklist.isEmpty;
|
||||
|
||||
if (mustDoWork) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
isWorking: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
GetIt.I.get<NavigationBloc>().add(
|
||||
PushedNamedEvent(
|
||||
const NavigationDestination(blocklistRoute),
|
||||
),
|
||||
);
|
||||
|
||||
if (state.blocklist.isEmpty) {
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
final result = await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||
GetBlocklistCommand(),
|
||||
) as GetBlocklistResultEvent;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
blocklist: result.entries,
|
||||
isWorking: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onJidUnblocked(UnblockedJidEvent event, Emitter<BlocklistState> emit) async {
|
||||
await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||
UnblockJidCommand(
|
||||
|
||||
@@ -2,9 +2,11 @@ part of 'blocklist_bloc.dart';
|
||||
|
||||
abstract class BlocklistEvent {}
|
||||
|
||||
/// Triggered when the blocklist page has been requested
|
||||
class BlocklistRequestedEvent extends BlocklistEvent {}
|
||||
|
||||
/// Triggered when a JID is unblocked
|
||||
class UnblockedJidEvent extends BlocklistEvent {
|
||||
|
||||
UnblockedJidEvent(this.jid);
|
||||
final String jid;
|
||||
}
|
||||
@@ -16,7 +18,6 @@ class UnblockedAllEvent extends BlocklistEvent {
|
||||
|
||||
/// Triggered when we receive a blocklist push
|
||||
class BlocklistPushedEvent extends BlocklistEvent {
|
||||
|
||||
BlocklistPushedEvent(this.added, this.removed);
|
||||
final List<String> added;
|
||||
final List<String> removed;
|
||||
|
||||
@@ -4,5 +4,6 @@ part of 'blocklist_bloc.dart';
|
||||
class BlocklistState with _$BlocklistState {
|
||||
factory BlocklistState({
|
||||
@Default(<String>[]) List<String> blocklist,
|
||||
@Default(false) bool isWorking,
|
||||
}) = _BlocklistState;
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
||||
on<BackgroundChangedEvent>(_onBackgroundChanged);
|
||||
on<ImagePickerRequestedEvent>(_onImagePickerRequested);
|
||||
on<FilePickerRequestedEvent>(_onFilePickerRequested);
|
||||
on<EmojiPickerToggledEvent>(_onEmojiPickerToggled);
|
||||
on<PickerToggledEvent>(_onPickerToggled);
|
||||
on<OwnJidReceivedEvent>(_onOwnJidReceived);
|
||||
on<OmemoSetEvent>(_onOmemoSet);
|
||||
on<MessageRetractedEvent>(_onMessageRetracted);
|
||||
@@ -64,7 +64,6 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
||||
on<RecordingCanceledEvent>(_onRecordingCanceled);
|
||||
on<ReactionAddedEvent>(_onReactionAdded);
|
||||
on<ReactionRemovedEvent>(_onReactionRemoved);
|
||||
on<StickerPickerToggledEvent>(_onStickerPickerToggled);
|
||||
on<StickerSentEvent>(_onStickerSent);
|
||||
on<SoftKeyboardVisibilityChanged>(_onSoftKeyboardVisibilityChanged);
|
||||
|
||||
@@ -239,8 +238,7 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
||||
messageText: '',
|
||||
quotedMessage: null,
|
||||
sendButtonState: defaultSendButtonState,
|
||||
emojiPickerVisible: false,
|
||||
stickerPickerVisible: false,
|
||||
pickerVisible: false,
|
||||
messageEditing: false,
|
||||
messageEditingOriginalBody: '',
|
||||
messageEditingId: null,
|
||||
@@ -375,12 +373,11 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onEmojiPickerToggled(EmojiPickerToggledEvent event, Emitter<ConversationState> emit) async {
|
||||
final newState = !state.emojiPickerVisible;
|
||||
Future<void> _onPickerToggled(PickerToggledEvent event, Emitter<ConversationState> emit) async {
|
||||
final newState = !state.pickerVisible;
|
||||
emit(
|
||||
state.copyWith(
|
||||
emojiPickerVisible: newState,
|
||||
stickerPickerVisible: false,
|
||||
pickerVisible: newState,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -461,8 +458,7 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
||||
state.copyWith(
|
||||
isDragging: true,
|
||||
isRecording: true,
|
||||
emojiPickerVisible: false,
|
||||
stickerPickerVisible: false,
|
||||
pickerVisible: false,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -643,16 +639,6 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onStickerPickerToggled(StickerPickerToggledEvent event, Emitter<ConversationState> emit) async {
|
||||
await SystemChannels.textInput.invokeMethod('TextInput.hide');
|
||||
emit(
|
||||
state.copyWith(
|
||||
stickerPickerVisible: !state.stickerPickerVisible,
|
||||
emojiPickerVisible: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onStickerSent(StickerSentEvent event, Emitter<ConversationState> emit) async {
|
||||
await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||
SendStickerCommand(
|
||||
@@ -666,17 +652,16 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
||||
// Close the picker
|
||||
emit(
|
||||
state.copyWith(
|
||||
stickerPickerVisible: false,
|
||||
pickerVisible: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onSoftKeyboardVisibilityChanged(SoftKeyboardVisibilityChanged event, Emitter<ConversationState> emit) async {
|
||||
if (event.visible && (state.emojiPickerVisible || state.stickerPickerVisible)) {
|
||||
if (event.visible && (state.pickerVisible)) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
emojiPickerVisible: false,
|
||||
stickerPickerVisible: false,
|
||||
pickerVisible: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,14 +98,8 @@ class ImagePickerRequestedEvent extends ConversationEvent {}
|
||||
class FilePickerRequestedEvent extends ConversationEvent {}
|
||||
|
||||
/// Triggered when the emoji button is pressed
|
||||
class EmojiPickerToggledEvent extends ConversationEvent {
|
||||
EmojiPickerToggledEvent({this.handleKeyboard = true});
|
||||
final bool handleKeyboard;
|
||||
}
|
||||
|
||||
/// Triggered when the sticker button is pressed
|
||||
class StickerPickerToggledEvent extends ConversationEvent {
|
||||
StickerPickerToggledEvent({this.handleKeyboard = true});
|
||||
class PickerToggledEvent extends ConversationEvent {
|
||||
PickerToggledEvent({this.handleKeyboard = true});
|
||||
final bool handleKeyboard;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
part of 'conversation_bloc.dart';
|
||||
|
||||
enum SendButtonState {
|
||||
audio,
|
||||
multi,
|
||||
send,
|
||||
cancelCorrection,
|
||||
}
|
||||
const defaultSendButtonState = SendButtonState.audio;
|
||||
const defaultSendButtonState = SendButtonState.multi;
|
||||
|
||||
@freezed
|
||||
class ConversationState with _$ConversationState {
|
||||
@@ -18,8 +18,7 @@ class ConversationState with _$ConversationState {
|
||||
@Default(<Message>[]) List<Message> messages,
|
||||
@Default(null) Conversation? conversation,
|
||||
@Default('') String backgroundPath,
|
||||
@Default(false) bool emojiPickerVisible,
|
||||
@Default(false) bool stickerPickerVisible,
|
||||
@Default(false) bool pickerVisible,
|
||||
@Default(false) bool messageEditing,
|
||||
@Default('') String messageEditingOriginalBody,
|
||||
@Default(null) String? messageEditingSid,
|
||||
|
||||
@@ -14,7 +14,6 @@ part 'devices_event.dart';
|
||||
part 'devices_state.dart';
|
||||
|
||||
class DevicesBloc extends Bloc<DevicesEvent, DevicesState> {
|
||||
|
||||
DevicesBloc() : super(DevicesState()) {
|
||||
on<DevicesRequestedEvent>(_onRequested);
|
||||
on<DeviceEnabledSetEvent>(_onDeviceEnabledSet);
|
||||
|
||||
@@ -4,14 +4,12 @@ abstract class DevicesEvent {}
|
||||
|
||||
/// Triggered when the user requested the key page
|
||||
class DevicesRequestedEvent extends DevicesEvent {
|
||||
|
||||
DevicesRequestedEvent(this.jid);
|
||||
final String jid;
|
||||
}
|
||||
|
||||
/// Triggered by the UI when we want to enable or disable a key
|
||||
class DeviceEnabledSetEvent extends DevicesEvent {
|
||||
|
||||
DeviceEnabledSetEvent(this.deviceId, this.enabled);
|
||||
final int deviceId;
|
||||
final bool enabled;
|
||||
|
||||
@@ -6,9 +6,8 @@ abstract class PreferencesEvent {}
|
||||
/// If [notify] is true, then the background service will be
|
||||
/// notified of this change.
|
||||
class PreferencesChangedEvent extends PreferencesEvent {
|
||||
|
||||
PreferencesChangedEvent(this.preferences, {
|
||||
this.notify = true,
|
||||
this.notify = true,
|
||||
});
|
||||
final PreferencesState preferences;
|
||||
final bool notify;
|
||||
@@ -19,7 +18,6 @@ class SignedOutEvent extends PreferencesEvent {}
|
||||
|
||||
/// Triggered when a background image has been set
|
||||
class BackgroundImageSetEvent extends PreferencesEvent {
|
||||
|
||||
BackgroundImageSetEvent(this.backgroundPath);
|
||||
final String backgroundPath;
|
||||
}
|
||||
|
||||
@@ -130,7 +130,13 @@ class ShareSelectionBloc extends Bloc<ShareSelectionEvent, ShareSelectionState>
|
||||
}
|
||||
|
||||
Future<void> _onRequested(ShareSelectionRequestedEvent event, Emitter<ShareSelectionState> emit) async {
|
||||
emit(state.copyWith(paths: event.paths, text: event.text, type: event.type));
|
||||
emit(
|
||||
state.copyWith(
|
||||
paths: event.paths,
|
||||
text: event.text,
|
||||
type: event.type,
|
||||
),
|
||||
);
|
||||
|
||||
GetIt.I.get<NavigationBloc>().add(
|
||||
PushedNamedAndRemoveUntilEvent(
|
||||
|
||||
@@ -31,6 +31,8 @@ class StickersBloc extends Bloc<StickersEvent, StickersState> {
|
||||
final map = <StickerKey, Sticker>{};
|
||||
for (final pack in event.stickerPacks) {
|
||||
for (final sticker in pack.stickers) {
|
||||
if (!sticker.isImage) continue;
|
||||
|
||||
map[StickerKey(pack.id, sticker.hashKey)] = sticker;
|
||||
}
|
||||
}
|
||||
@@ -92,6 +94,8 @@ class StickersBloc extends Bloc<StickersEvent, StickersState> {
|
||||
if (result is StickerPackImportSuccessEvent) {
|
||||
final sm = Map<StickerKey, Sticker>.from(state.stickerMap);
|
||||
for (final sticker in result.stickerPack.stickers) {
|
||||
if (!sticker.isImage) continue;
|
||||
|
||||
sm[StickerKey(result.stickerPack.id, sticker.hashKey)] = sticker;
|
||||
}
|
||||
emit(
|
||||
@@ -128,6 +132,8 @@ class StickersBloc extends Bloc<StickersEvent, StickersState> {
|
||||
Future<void> _onStickerPackAdded(StickerPackAddedEvent event, Emitter<StickersState> emit) async {
|
||||
final sm = Map<StickerKey, Sticker>.from(state.stickerMap);
|
||||
for (final sticker in event.stickerPack.stickers) {
|
||||
if (!sticker.isImage) continue;
|
||||
|
||||
sm[StickerKey(event.stickerPack.id, sticker.hashKey)] = sticker;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,24 @@ const Radius radiusLarge = Radius.circular(10);
|
||||
const Radius radiusSmall = Radius.circular(4);
|
||||
|
||||
const double textfieldRadiusRegular = 15;
|
||||
const double textfieldRadiusConversation = 20;
|
||||
const EdgeInsetsGeometry textfieldPaddingRegular = EdgeInsets.only(top: 4, bottom: 4, left: 8, right: 8);
|
||||
const EdgeInsetsGeometry textfieldPaddingConversation = EdgeInsets.all(10);
|
||||
const double textfieldRadiusConversation = 25;
|
||||
const EdgeInsetsGeometry textfieldPaddingRegular = EdgeInsets.only(
|
||||
top: 4,
|
||||
bottom: 4,
|
||||
left: 8,
|
||||
right: 8,
|
||||
);
|
||||
|
||||
/// The inner TextField padding for the TextField on the ConversationPage.
|
||||
const EdgeInsetsGeometry textfieldPaddingConversation = EdgeInsets.only(
|
||||
top: 12,
|
||||
bottom: 12,
|
||||
left: 8,
|
||||
right: 8,
|
||||
);
|
||||
|
||||
/// The font size for the TextField on the ConversationPage
|
||||
const double textFieldFontSizeConversation = 18;
|
||||
|
||||
const int primaryColorHexRGBO = 0xffcf4aff;
|
||||
const int primaryColorAltHexRGB = 0xff9c18cd;
|
||||
@@ -17,12 +32,64 @@ const Color primaryColorAlt = Color(primaryColorAltHexRGB);
|
||||
const Color primaryColorDisabled = Color(primaryColorDisabledHexRGB);
|
||||
const Color textColorDisabled = Color(textColorDisabledHexRGB);
|
||||
|
||||
/// The color of a quote bubble displayed inside the TextField
|
||||
const Color bubbleQuoteInTextFieldColorLight = Color(0xffc7c7c7);
|
||||
const Color bubbleQuoteInTextFieldColorDark = Color(0xff2f2f2f);
|
||||
|
||||
/// The color of text inside a quote bubble inside the TextField
|
||||
const Color bubbleQuoteInTextFieldTextColorLight = Color(0xff373737);
|
||||
const Color bubbleQuoteInTextFieldTextColorDark = Color(0xffdadada);
|
||||
|
||||
/// The text color of the hint text on the ConversationPage
|
||||
const Color textFieldHintTextColorLight = Color(0xff4a4a4a);
|
||||
const Color textFieldHintTextColorDark = Color(0xffd6d6d6);
|
||||
|
||||
/// The regular text color of the TextField on the ConversationPage
|
||||
const Color textFieldTextColorLight = Colors.black;
|
||||
const Color textFieldTextColorDark = Colors.white;
|
||||
|
||||
/// The color of a bubble that was sent
|
||||
const Color bubbleColorSent = Color(0xff7e0bce);
|
||||
const Color bubbleColorSentQuoted = bubbleColorSent;
|
||||
|
||||
/// The color of the quote widget for a sent quote
|
||||
const Color bubbleColorSentQuoted = Color(0xff6e0ab4);
|
||||
|
||||
/// The color of a bubble that was received
|
||||
const Color bubbleColorReceived = Color(0xff222222);
|
||||
const Color bubbleColorReceivedQuoted = bubbleColorReceived;
|
||||
|
||||
/// The color of the quote widget for a received quote
|
||||
const Color bubbleColorReceivedQuoted = Color(0xff2f2f2f);
|
||||
|
||||
/// The color of a bubble when the message is unencrypted while the chat is encrypted
|
||||
const Color bubbleColorUnencrypted = Color(0xffd40000);
|
||||
|
||||
/// The color of a bubble for a pseudo message of type new device
|
||||
const Color bubbleColorNewDevice = Color(0xffeee8d5);
|
||||
|
||||
/// The color of text within a regular bubble
|
||||
const Color bubbleTextColor = Color(0xffffffff);
|
||||
|
||||
/// The color of text within a quote widget
|
||||
const Color bubbleTextQuoteColor = Color(0xffdadada);
|
||||
|
||||
/// The color of the sender name in a quote
|
||||
const Color bubbleTextQuoteSenderColor = Color(0xffff90ff);
|
||||
|
||||
/// The color of the input text field of the conversation page
|
||||
const Color conversationTextFieldColorLight = Color(0xffe6e6e6);
|
||||
const Color conversationTextFieldColorDark = Color(0xff414141);
|
||||
|
||||
/// The width of the white left border of quote widgets
|
||||
const double quoteLeftBorderWidth = 4;
|
||||
|
||||
/// The background color of the avatar when no actual avatar is available
|
||||
const Color profileFallbackBackgroundColorLight = Color(0xffc3c3c3);
|
||||
const Color profileFallbackBackgroundColorDark = Color(0xff424242);
|
||||
|
||||
/// The text color of the avatar fallback text
|
||||
const Color profileFallbackTextColorLight = Color(0xff343434);
|
||||
const Color profileFallbackTextColorDark = Colors.white;
|
||||
|
||||
const Color settingsSectionTitleColor = Color(0xffb72fe7);
|
||||
|
||||
const double paddingVeryLarge = 64;
|
||||
@@ -37,16 +104,20 @@ const double fontsizeBody = 15;
|
||||
const double fontsizeBodyOnlyEmojis = 30;
|
||||
const double fontsizeSubbody = 10;
|
||||
|
||||
// The color for a shared media item
|
||||
/// The color for a shared media item
|
||||
final Color sharedMediaItemBackgroundColor = Colors.grey.shade500;
|
||||
// The color for a shared media summary
|
||||
|
||||
/// The color for a shared media summary
|
||||
final Color sharedMediaSummaryBackgroundColor = Colors.grey.shade500;
|
||||
|
||||
// The translucent black we use when we need to ensure good contrast, for example when
|
||||
// displaying the download progress indicator.
|
||||
/// The translucent black we use when we need to ensure good contrast, for example when
|
||||
/// displaying the download progress indicator.
|
||||
final backdropBlack = Colors.black.withAlpha(150);
|
||||
|
||||
// Navigation constants
|
||||
/// The height of the emoji/sticker picker
|
||||
const double pickerHeight = 300;
|
||||
|
||||
/// Navigation constants
|
||||
const String cropRoute = '/crop';
|
||||
const String introRoute = '/intro';
|
||||
const String loginRoute = '/route';
|
||||
|
||||
@@ -23,59 +23,74 @@ class BlocklistPage extends StatelessWidget {
|
||||
Widget _buildListView(BlocklistState state) {
|
||||
// ignore: non_bool_condition,avoid_dynamic_calls
|
||||
if (state.blocklist.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: paddingVeryLarge),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Image.asset('assets/images/happy_news.png'),
|
||||
return Column(
|
||||
children: [
|
||||
if (state.isWorking)
|
||||
const LinearProgressIndicator(),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: paddingVeryLarge),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Image.asset('assets/images/happy_news.png'),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(t.pages.blocklist.noUsersBlocked),
|
||||
)
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(t.pages.blocklist.noUsersBlocked),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: state.blocklist.length,
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
// ignore: avoid_dynamic_calls
|
||||
final jid = state.blocklist[index];
|
||||
return Column(
|
||||
children: [
|
||||
if (state.isWorking)
|
||||
const LinearProgressIndicator(),
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 32,
|
||||
vertical: 16,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(jid),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
color: Colors.red,
|
||||
onPressed: () async {
|
||||
final result = await showConfirmationDialog(
|
||||
t.pages.blocklist.unblockJidConfirmTitle(jid: jid),
|
||||
t.pages.blocklist.unblockJidConfirmBody(jid: jid),
|
||||
context,
|
||||
);
|
||||
ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: state.blocklist.length,
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
// ignore: avoid_dynamic_calls
|
||||
final jid = state.blocklist[index];
|
||||
|
||||
if (result) {
|
||||
// ignore: use_build_context_synchronously
|
||||
context.read<BlocklistBloc>().add(UnblockedJidEvent(jid));
|
||||
}
|
||||
},
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 32,
|
||||
vertical: 16,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(jid),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
color: Colors.red,
|
||||
onPressed: () async {
|
||||
final result = await showConfirmationDialog(
|
||||
t.pages.blocklist.unblockJidConfirmTitle(jid: jid),
|
||||
t.pages.blocklist.unblockJidConfirmBody(jid: jid),
|
||||
context,
|
||||
);
|
||||
|
||||
if (result) {
|
||||
// ignore: use_build_context_synchronously
|
||||
context.read<BlocklistBloc>().add(UnblockedJidEvent(jid));
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -108,6 +123,7 @@ class BlocklistPage extends StatelessWidget {
|
||||
icon: const Icon(Icons.more_vert),
|
||||
itemBuilder: (BuildContext context) => [
|
||||
PopupMenuItem(
|
||||
enabled: state.blocklist.isNotEmpty,
|
||||
value: BlocklistOptions.unblockAll,
|
||||
child: Text(t.pages.blocklist.unblockAll),
|
||||
),
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
|
||||
import 'package:flutter_speed_dial/flutter_speed_dial.dart';
|
||||
import 'package:flutter_vibrate/flutter_vibrate.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
@@ -14,8 +13,9 @@ import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/helpers.dart';
|
||||
import 'package:moxxyv2/ui/pages/conversation/blink.dart';
|
||||
import 'package:moxxyv2/ui/pages/conversation/timer.dart';
|
||||
import 'package:moxxyv2/ui/theme.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/media/media.dart';
|
||||
import 'package:moxxyv2/ui/widgets/sticker_picker.dart';
|
||||
import 'package:moxxyv2/ui/widgets/combined_picker.dart';
|
||||
import 'package:moxxyv2/ui/widgets/textfield.dart';
|
||||
import 'package:phosphor_flutter/phosphor_flutter.dart';
|
||||
|
||||
@@ -32,7 +32,55 @@ class _TextFieldIconButton extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
size: 24,
|
||||
color: primaryColor,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TextFieldRecordButton extends StatelessWidget {
|
||||
const _TextFieldRecordButton();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LongPressDraggable<int>(
|
||||
data: 1,
|
||||
axis: Axis.vertical,
|
||||
onDragStarted: () {
|
||||
Vibrate.feedback(FeedbackType.heavy);
|
||||
context.read<ConversationBloc>().add(
|
||||
SendButtonDragStartedEvent(),
|
||||
);
|
||||
},
|
||||
onDraggableCanceled: (_, __) {
|
||||
Vibrate.feedback(FeedbackType.heavy);
|
||||
context.read<ConversationBloc>().add(
|
||||
SendButtonDragEndedEvent(),
|
||||
);
|
||||
},
|
||||
childWhenDragging: const SizedBox(),
|
||||
feedback: SizedBox(
|
||||
width: 45,
|
||||
height: 45,
|
||||
child: FloatingActionButton(
|
||||
onPressed: null,
|
||||
heroTag: 'fabDragged',
|
||||
backgroundColor: Colors.red.shade600,
|
||||
child: BlinkingIcon(
|
||||
icon: Icons.mic,
|
||||
duration: const Duration(milliseconds: 600),
|
||||
start: Colors.white,
|
||||
end: Colors.red.shade600,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Icon(
|
||||
Icons.mic,
|
||||
size: 24,
|
||||
color: primaryColor,
|
||||
),
|
||||
),
|
||||
@@ -43,12 +91,16 @@ class _TextFieldIconButton extends StatelessWidget {
|
||||
class ConversationBottomRow extends StatefulWidget {
|
||||
const ConversationBottomRow(
|
||||
this.controller,
|
||||
this.focusNode, {
|
||||
this.tabController,
|
||||
this.focusNode,
|
||||
this.speedDialValueNotifier, {
|
||||
super.key,
|
||||
}
|
||||
);
|
||||
final TextEditingController controller;
|
||||
final TabController tabController;
|
||||
final FocusNode focusNode;
|
||||
final ValueNotifier<bool> speedDialValueNotifier;
|
||||
|
||||
@override
|
||||
ConversationBottomRowState createState() => ConversationBottomRowState();
|
||||
@@ -56,7 +108,7 @@ class ConversationBottomRow extends StatefulWidget {
|
||||
|
||||
class ConversationBottomRowState extends State<ConversationBottomRow> {
|
||||
late StreamSubscription<bool> _keyboardVisibilitySubscription;
|
||||
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -78,22 +130,21 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
|
||||
);
|
||||
}
|
||||
|
||||
Color _getTextColor(BuildContext context) {
|
||||
// TODO(Unknown): Work on the colors
|
||||
if (MediaQuery.of(context).platformBrightness == Brightness.dark) {
|
||||
return Colors.white;
|
||||
}
|
||||
|
||||
return Colors.black;
|
||||
}
|
||||
|
||||
IconData _getSendButtonIcon(ConversationState state) {
|
||||
switch (state.sendButtonState) {
|
||||
case SendButtonState.audio: return Icons.mic;
|
||||
case SendButtonState.multi: return Icons.add;
|
||||
case SendButtonState.send: return Icons.send;
|
||||
case SendButtonState.cancelCorrection: return Icons.clear;
|
||||
}
|
||||
}
|
||||
|
||||
IconData _getPickerIcon() {
|
||||
if (widget.tabController.index == 0) {
|
||||
return Icons.insert_emoticon;
|
||||
}
|
||||
|
||||
return PhosphorIcons.stickerBold;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -108,17 +159,25 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: BlocBuilder<ConversationBloc, ConversationState>(
|
||||
buildWhen: (prev, next) => prev.sendButtonState != next.sendButtonState || prev.quotedMessage != next.quotedMessage || prev.emojiPickerVisible != next.emojiPickerVisible || prev.messageText != next.messageText || prev.messageEditing != next.messageEditing || prev.messageEditingOriginalBody != next.messageEditingOriginalBody,
|
||||
buildWhen: (prev, next) => prev.sendButtonState != next.sendButtonState || prev.quotedMessage != next.quotedMessage || prev.pickerVisible != next.pickerVisible || prev.messageText != next.messageText || prev.messageEditing != next.messageEditing || prev.messageEditingOriginalBody != next.messageEditingOriginalBody || prev.isRecording != next.isRecording,
|
||||
builder: (context, state) => Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CustomTextField(
|
||||
// TODO(Unknown): Work on the colors
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
textColor: _getTextColor(context),
|
||||
enableBoxShadow: true,
|
||||
backgroundColor: Theme
|
||||
.of(context)
|
||||
.extension<MoxxyThemeData>()!
|
||||
.conversationTextFieldColor,
|
||||
textColor: Theme
|
||||
.of(context)
|
||||
.extension<MoxxyThemeData>()!
|
||||
.conversationTextFieldTextColor,
|
||||
maxLines: 5,
|
||||
hintText: 'Send a message...',
|
||||
hintText: t.pages.conversation.messageHint,
|
||||
hintTextColor: Theme
|
||||
.of(context)
|
||||
.extension<MoxxyThemeData>()!
|
||||
.conversationTextFieldHintTextColor,
|
||||
isDense: true,
|
||||
onChanged: (value) {
|
||||
context.read<ConversationBloc>().add(
|
||||
@@ -126,51 +185,34 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
|
||||
);
|
||||
},
|
||||
contentPadding: textfieldPaddingConversation,
|
||||
fontSize: textFieldFontSizeConversation,
|
||||
cornerRadius: textfieldRadiusConversation,
|
||||
controller: widget.controller,
|
||||
topWidget: state.quotedMessage != null ? buildQuoteMessageWidget(
|
||||
state.quotedMessage!,
|
||||
isSent(state.quotedMessage!, state.jid),
|
||||
resetQuote: () => context.read<ConversationBloc>().add(QuoteRemovedEvent()),
|
||||
) : null,
|
||||
topWidget: state.quotedMessage != null ?
|
||||
buildQuoteMessageWidget(
|
||||
state.quotedMessage!,
|
||||
isSent(state.quotedMessage!, state.jid),
|
||||
resetQuote: () => context.read<ConversationBloc>().add(QuoteRemovedEvent()),
|
||||
) :
|
||||
null,
|
||||
focusNode: widget.focusNode,
|
||||
shouldSummonKeyboard: () => !state.emojiPickerVisible,
|
||||
shouldSummonKeyboard: () => !state.pickerVisible,
|
||||
prefixIcon: IntrinsicWidth(
|
||||
child: Row(
|
||||
children: [
|
||||
InkWell(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Icon(
|
||||
state.emojiPickerVisible ?
|
||||
Icons.keyboard :
|
||||
Icons.insert_emoticon,
|
||||
color: primaryColor,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
context.read<ConversationBloc>().add(EmojiPickerToggledEvent());
|
||||
},
|
||||
),
|
||||
Visibility(
|
||||
visible: state.messageText.isEmpty && state.quotedMessage == null,
|
||||
child: InkWell(
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.only(right: 8),
|
||||
child: Icon(
|
||||
PhosphorIcons.stickerBold,
|
||||
size: 24,
|
||||
color: primaryColor,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: _TextFieldIconButton(
|
||||
state.pickerVisible ?
|
||||
Icons.keyboard :
|
||||
_getPickerIcon(),
|
||||
() {
|
||||
context.read<ConversationBloc>().add(
|
||||
StickerPickerToggledEvent(),
|
||||
PickerToggledEvent(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -181,31 +223,10 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
|
||||
suffixIcon: state.messageText.isEmpty && state.quotedMessage == null ?
|
||||
IntrinsicWidth(
|
||||
child: Row(
|
||||
children: [
|
||||
_TextFieldIconButton(
|
||||
Icons.attach_file,
|
||||
() {
|
||||
context.read<ConversationBloc>().add(
|
||||
FilePickerRequestedEvent(),
|
||||
);
|
||||
},
|
||||
),
|
||||
_TextFieldIconButton(
|
||||
Icons.photo_camera,
|
||||
() {
|
||||
showNotImplementedDialog(
|
||||
'taking photos',
|
||||
context,
|
||||
);
|
||||
},
|
||||
),
|
||||
_TextFieldIconButton(
|
||||
Icons.image,
|
||||
() {
|
||||
context.read<ConversationBloc>().add(
|
||||
ImagePickerRequestedEvent(),
|
||||
);
|
||||
},
|
||||
children: const [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(right: 8),
|
||||
child: _TextFieldRecordButton(),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -217,23 +238,129 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 8),
|
||||
child: SizedBox(
|
||||
height: 45,
|
||||
width: 45,
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: AnimatedOpacity(
|
||||
opacity: state.isRecording ? 0 : 1,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
child: IgnorePointer(
|
||||
ignoring: state.isRecording,
|
||||
child: SizedBox(
|
||||
height: 45,
|
||||
width: 45,
|
||||
child: SpeedDial(
|
||||
icon: _getSendButtonIcon(state),
|
||||
backgroundColor: primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
children: [
|
||||
SpeedDialChild(
|
||||
child: const Icon(Icons.image),
|
||||
onTap: () {
|
||||
context.read<ConversationBloc>().add(
|
||||
ImagePickerRequestedEvent(),
|
||||
);
|
||||
},
|
||||
backgroundColor: primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
label: t.pages.conversation.sendImages,
|
||||
),
|
||||
SpeedDialChild(
|
||||
child: const Icon(Icons.file_present),
|
||||
onTap: () {
|
||||
context.read<ConversationBloc>().add(
|
||||
FilePickerRequestedEvent(),
|
||||
);
|
||||
},
|
||||
backgroundColor: primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
label: t.pages.conversation.sendFiles,
|
||||
),
|
||||
SpeedDialChild(
|
||||
child: const Icon(Icons.photo_camera),
|
||||
onTap: () {
|
||||
showNotImplementedDialog('taking photos', context);
|
||||
},
|
||||
backgroundColor: primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
label: t.pages.conversation.takePhotos,
|
||||
),
|
||||
],
|
||||
openCloseDial: widget.speedDialValueNotifier,
|
||||
onPress: () {
|
||||
switch (state.sendButtonState) {
|
||||
case SendButtonState.cancelCorrection:
|
||||
context.read<ConversationBloc>().add(
|
||||
MessageEditCancelledEvent(),
|
||||
);
|
||||
widget.controller.text = '';
|
||||
return;
|
||||
case SendButtonState.send:
|
||||
context.read<ConversationBloc>().add(
|
||||
MessageSentEvent(),
|
||||
);
|
||||
widget.controller.text = '';
|
||||
return;
|
||||
case SendButtonState.multi:
|
||||
widget.speedDialValueNotifier.value = !widget.speedDialValueNotifier.value;
|
||||
return;
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
BlocBuilder<ConversationBloc, ConversationState>(
|
||||
buildWhen: (prev, next) => prev.stickerPickerVisible != next.stickerPickerVisible,
|
||||
buildWhen: (prev, next) => prev.pickerVisible != next.pickerVisible,
|
||||
builder: (context, state) => Offstage(
|
||||
offstage: !state.stickerPickerVisible,
|
||||
child: StickerPicker(
|
||||
width: MediaQuery.of(context).size.width,
|
||||
offstage: !state.pickerVisible,
|
||||
child: CombinedPicker(
|
||||
tabController: widget.tabController,
|
||||
onEmojiTapped: (emoji) {
|
||||
final bloc = context.read<ConversationBloc>();
|
||||
final selection = widget.controller.selection;
|
||||
final baseOffset = max(selection.baseOffset, 0);
|
||||
final extentOffset = max(selection.extentOffset, 0);
|
||||
final prefix = bloc.state.messageText.substring(0, baseOffset);
|
||||
final suffix = bloc.state.messageText.substring(extentOffset);
|
||||
final newText = '$prefix${emoji.emoji}$suffix';
|
||||
final newValue = baseOffset + emoji.emoji.codeUnits.length;
|
||||
bloc.add(MessageTextChangedEvent(newText));
|
||||
widget.controller
|
||||
..text = newText
|
||||
..selection = TextSelection(
|
||||
baseOffset: newValue,
|
||||
extentOffset: newValue,
|
||||
);
|
||||
},
|
||||
onBackspaceTapped: () {
|
||||
// Taken from https://github.com/Fintasys/emoji_picker_flutter/blob/master/lib/src/emoji_picker.dart#L183
|
||||
final bloc = context.read<ConversationBloc>();
|
||||
final text = bloc.state.messageText;
|
||||
final selection = widget.controller.selection;
|
||||
final cursorPosition = widget.controller.selection.base.offset;
|
||||
|
||||
if (cursorPosition < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
final newTextBeforeCursor = selection
|
||||
.textBefore(text).characters
|
||||
.skipLast(1)
|
||||
.toString();
|
||||
|
||||
bloc.add(MessageTextChangedEvent(newTextBeforeCursor));
|
||||
widget.controller
|
||||
..text = newTextBeforeCursor
|
||||
..selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: newTextBeforeCursor.length),
|
||||
);
|
||||
},
|
||||
onStickerTapped: (sticker, pack) {
|
||||
context.read<ConversationBloc>().add(
|
||||
StickerSentEvent(
|
||||
@@ -245,164 +372,14 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
BlocBuilder<ConversationBloc, ConversationState>(
|
||||
buildWhen: (prev, next) => prev.emojiPickerVisible != next.emojiPickerVisible,
|
||||
builder: (context, state) => Offstage(
|
||||
offstage: !state.emojiPickerVisible,
|
||||
child: SizedBox(
|
||||
height: 250,
|
||||
child: EmojiPicker(
|
||||
onEmojiSelected: (_, emoji) {
|
||||
final bloc = context.read<ConversationBloc>();
|
||||
final selection = widget.controller.selection;
|
||||
final baseOffset = max(selection.baseOffset, 0);
|
||||
final extentOffset = max(selection.extentOffset, 0);
|
||||
final prefix = bloc.state.messageText.substring(0, baseOffset);
|
||||
final suffix = bloc.state.messageText.substring(extentOffset);
|
||||
final newText = '$prefix${emoji.emoji}$suffix';
|
||||
final newValue = baseOffset + emoji.emoji.codeUnits.length;
|
||||
bloc.add(MessageTextChangedEvent(newText));
|
||||
widget.controller
|
||||
..text = newText
|
||||
..selection = TextSelection(
|
||||
baseOffset: newValue,
|
||||
extentOffset: newValue,
|
||||
);
|
||||
},
|
||||
onBackspacePressed: () {
|
||||
// Taken from https://github.com/Fintasys/emoji_picker_flutter/blob/master/lib/src/emoji_picker.dart#L183
|
||||
final bloc = context.read<ConversationBloc>();
|
||||
final text = bloc.state.messageText;
|
||||
final selection = widget.controller.selection;
|
||||
final cursorPosition = widget.controller.selection.base.offset;
|
||||
|
||||
if (cursorPosition < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
final newTextBeforeCursor = selection
|
||||
.textBefore(text).characters
|
||||
.skipLast(1)
|
||||
.toString();
|
||||
|
||||
bloc.add(MessageTextChangedEvent(newTextBeforeCursor));
|
||||
widget.controller
|
||||
..text = newTextBeforeCursor
|
||||
..selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: newTextBeforeCursor.length),
|
||||
);
|
||||
},
|
||||
config: Config(
|
||||
bgColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
BlocBuilder<ConversationBloc, ConversationState>(
|
||||
buildWhen: (prev, next) => prev.sendButtonState != next.sendButtonState ||
|
||||
prev.isDragging != next.isDragging ||
|
||||
prev.isLocked != next.isLocked ||
|
||||
prev.emojiPickerVisible != next.emojiPickerVisible ||
|
||||
prev.stickerPickerVisible != next.stickerPickerVisible,
|
||||
builder: (context, state) {
|
||||
return Positioned(
|
||||
right: 8,
|
||||
bottom: state.emojiPickerVisible || state.stickerPickerVisible ?
|
||||
258 /* 8 (Regular padding) + 250 (Height of the pickers) */ :
|
||||
8,
|
||||
child: Visibility(
|
||||
visible: !state.isDragging && !state.isLocked,
|
||||
child: LongPressDraggable<int>(
|
||||
data: 1,
|
||||
axis: Axis.vertical,
|
||||
onDragStarted: () {
|
||||
Vibrate.feedback(FeedbackType.heavy);
|
||||
context.read<ConversationBloc>().add(
|
||||
SendButtonDragStartedEvent(),
|
||||
);
|
||||
},
|
||||
onDraggableCanceled: (_, __) {
|
||||
Vibrate.feedback(FeedbackType.heavy);
|
||||
context.read<ConversationBloc>().add(
|
||||
SendButtonDragEndedEvent(),
|
||||
);
|
||||
},
|
||||
feedback: SizedBox(
|
||||
height: 45,
|
||||
width: 45,
|
||||
child: FloatingActionButton(
|
||||
onPressed: null,
|
||||
heroTag: 'fabDragged',
|
||||
backgroundColor: Colors.red.shade600,
|
||||
child: BlinkingIcon(
|
||||
icon: Icons.mic,
|
||||
duration: const Duration(milliseconds: 600),
|
||||
start: Colors.white,
|
||||
end: Colors.red.shade600,
|
||||
),
|
||||
),
|
||||
),
|
||||
childWhenDragging: SizedBox(
|
||||
height: 45,
|
||||
width: 45,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey,
|
||||
borderRadius: BorderRadius.circular(45),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 45,
|
||||
width: 45,
|
||||
child: FloatingActionButton(
|
||||
heroTag: 'fabRest',
|
||||
onPressed: () {
|
||||
switch (state.sendButtonState) {
|
||||
case SendButtonState.audio:
|
||||
Vibrate.feedback(FeedbackType.heavy);
|
||||
Fluttertoast.showToast(
|
||||
msg: t.warnings.conversation.holdForLonger,
|
||||
gravity: ToastGravity.SNACKBAR,
|
||||
toastLength: Toast.LENGTH_SHORT,
|
||||
);
|
||||
return;
|
||||
case SendButtonState.cancelCorrection:
|
||||
context.read<ConversationBloc>().add(
|
||||
MessageEditCancelledEvent(),
|
||||
);
|
||||
widget.controller.text = '';
|
||||
return;
|
||||
case SendButtonState.send:
|
||||
context.read<ConversationBloc>().add(
|
||||
MessageSentEvent(),
|
||||
);
|
||||
widget.controller.text = '';
|
||||
return;
|
||||
}
|
||||
},
|
||||
child: Icon(
|
||||
_getSendButtonIcon(state),
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
|
||||
Positioned(
|
||||
left: 8,
|
||||
bottom: 11,
|
||||
bottom: 8,
|
||||
right: 61,
|
||||
child: BlocBuilder<ConversationBloc, ConversationState>(
|
||||
buildWhen: (prev, next) => prev.isRecording != next.isRecording,
|
||||
@@ -413,7 +390,7 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
|
||||
child: IgnorePointer(
|
||||
ignoring: !state.isRecording,
|
||||
child: SizedBox(
|
||||
height: 38,
|
||||
height: textFieldFontSizeConversation + 2 * 12 + 2,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(textfieldRadiusConversation),
|
||||
@@ -423,14 +400,14 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
|
||||
// created and destroyed to prevent the timer from running
|
||||
// until the user closes the page.
|
||||
child: state.isRecording ?
|
||||
const Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(left: 16),
|
||||
child: TimerWidget(),
|
||||
),
|
||||
) :
|
||||
null,
|
||||
const Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(left: 16),
|
||||
child: TimerWidget(),
|
||||
),
|
||||
) :
|
||||
null,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -17,6 +17,9 @@ import 'package:moxxyv2/ui/pages/conversation/blink.dart';
|
||||
import 'package:moxxyv2/ui/pages/conversation/bottom.dart';
|
||||
import 'package:moxxyv2/ui/pages/conversation/helpers.dart';
|
||||
import 'package:moxxyv2/ui/pages/conversation/topbar.dart';
|
||||
import 'package:moxxyv2/ui/theme.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/bubbles/date.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/bubbles/new_device.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/chatbubble.dart';
|
||||
import 'package:moxxyv2/ui/widgets/overview_menu.dart';
|
||||
|
||||
@@ -35,23 +38,24 @@ class ConversationPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class ConversationPageState extends State<ConversationPage> with TickerProviderStateMixin {
|
||||
ConversationPageState() :
|
||||
_controller = TextEditingController(),
|
||||
_scrollController = ScrollController(),
|
||||
_scrolledToBottomState = true,
|
||||
super();
|
||||
final TextEditingController _controller;
|
||||
final ScrollController _scrollController;
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
late final AnimationController _animationController;
|
||||
late final AnimationController _overviewAnimationController;
|
||||
late final TabController _tabController;
|
||||
late Animation<double> _overviewMsgAnimation;
|
||||
late final Animation<double> _scrollToBottom;
|
||||
bool _scrolledToBottomState;
|
||||
bool _scrolledToBottomState = true;
|
||||
late FocusNode _textfieldFocus;
|
||||
final ValueNotifier<bool> _isSpeedDialOpen = ValueNotifier(false);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(
|
||||
length: 2,
|
||||
vsync: this,
|
||||
);
|
||||
_textfieldFocus = FocusNode();
|
||||
_scrollController.addListener(_onScroll);
|
||||
|
||||
@@ -73,6 +77,7 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
_controller.dispose();
|
||||
_scrollController
|
||||
..removeListener(_onScroll)
|
||||
@@ -104,11 +109,68 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Widget _renderBubble(ConversationState state, BuildContext context, int _index, double maxWidth, String jid) {
|
||||
if (_index.isEven) {
|
||||
if (_index == 0) return const SizedBox();
|
||||
|
||||
final prevIndexRaw = (_index + 2) ~/ 2;
|
||||
final prevIndex = state.messages.length - prevIndexRaw;
|
||||
final prevMessageDateTime = prevIndex < 0 || prevIndexRaw == 0 ?
|
||||
null :
|
||||
DateTime.fromMillisecondsSinceEpoch(
|
||||
state.messages[prevIndex].timestamp,
|
||||
);
|
||||
|
||||
if (prevMessageDateTime == null) return const SizedBox();
|
||||
|
||||
final nextIndexRaw = _index ~/ 2;
|
||||
final nextIndex = state.messages.length - nextIndexRaw;
|
||||
final nextMessageDateTime = nextIndex < 0 || nextIndexRaw == 0 ?
|
||||
null :
|
||||
DateTime.fromMillisecondsSinceEpoch(
|
||||
state.messages[nextIndex].timestamp,
|
||||
);
|
||||
if (nextMessageDateTime == null) return const SizedBox();
|
||||
|
||||
// Check if we have to render a date bubble
|
||||
if (prevMessageDateTime.day != nextMessageDateTime.day ||
|
||||
prevMessageDateTime.month != nextMessageDateTime.month ||
|
||||
prevMessageDateTime.year != nextMessageDateTime.year) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
DateBubble(
|
||||
formatDateBubble(nextMessageDateTime, DateTime.now()),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
// TODO(Unknown): Since we reverse the list: Fix start, end and between
|
||||
final index = state.messages.length - 1 - _index;
|
||||
final index = state.messages.length - 1 - (_index - 1) ~/ 2;
|
||||
final item = state.messages[index];
|
||||
|
||||
if (item.isPseudoMessage) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: maxWidth,
|
||||
),
|
||||
child: NewDeviceBubble(
|
||||
data: item.pseudoMessageData!,
|
||||
title: state.conversation!.title,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final start = index - 1 < 0 ?
|
||||
true :
|
||||
isSent(state.messages[index - 1], jid) != isSent(item, jid);
|
||||
@@ -116,7 +178,6 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
||||
true :
|
||||
isSent(state.messages[index + 1], jid) != isSent(item, jid);
|
||||
final between = !start && !end;
|
||||
final lastMessageTimestamp = index > 0 ? state.messages[index - 1].timestamp : null;
|
||||
final sentBySelf = isSent(item, jid);
|
||||
|
||||
final bubble = RawChatBubble(
|
||||
@@ -134,7 +195,6 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
||||
message: item,
|
||||
sentBySelf: sentBySelf,
|
||||
maxWidth: maxWidth,
|
||||
lastMessageTimestamp: lastMessageTimestamp,
|
||||
onSwipedCallback: (_) => _quoteMessage(context, item),
|
||||
onReactionTap: (reaction) {
|
||||
final bloc = context.read<ConversationBloc>();
|
||||
@@ -216,7 +276,7 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
||||
// ignore: use_build_context_synchronously
|
||||
Navigator.of(context).pop(emoji.emoji);
|
||||
},
|
||||
//height: 250,
|
||||
//height: pickerHeight,
|
||||
config: Config(
|
||||
bgColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
),
|
||||
@@ -390,7 +450,6 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maxWidth = MediaQuery.of(context).size.width * 0.6;
|
||||
|
||||
return WillPopScope(
|
||||
onWillPop: () async {
|
||||
// TODO(PapaTutuWawa): Check if we are recording an audio message and handle
|
||||
@@ -399,15 +458,8 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
||||
if (bloc.state.isRecording) {
|
||||
// TODO(PapaTutuWawa): Show a dialog
|
||||
return true;
|
||||
} else if (bloc.state.emojiPickerVisible) {
|
||||
bloc.add(EmojiPickerToggledEvent(handleKeyboard: false));
|
||||
|
||||
return false;
|
||||
} else if (bloc.state.stickerPickerVisible) {
|
||||
bloc.add(StickerPickerToggledEvent());
|
||||
if (_textfieldFocus.hasFocus) {
|
||||
_textfieldFocus.unfocus();
|
||||
}
|
||||
} else if (bloc.state.pickerVisible) {
|
||||
bloc.add(PickerToggledEvent(handleKeyboard: false));
|
||||
|
||||
return false;
|
||||
} else {
|
||||
@@ -451,6 +503,7 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
@@ -478,8 +531,7 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
||||
buildWhen: (prev, next) => prev.messages != next.messages || prev.conversation!.encrypted != next.conversation!.encrypted,
|
||||
builder: (context, state) => Expanded(
|
||||
child: ListView.builder(
|
||||
reverse: true,
|
||||
itemCount: state.messages.length,
|
||||
itemCount: state.messages.length * 2,
|
||||
itemBuilder: (context, index) => _renderBubble(
|
||||
state,
|
||||
context,
|
||||
@@ -488,14 +540,22 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
||||
state.jid,
|
||||
),
|
||||
shrinkWrap: true,
|
||||
reverse: true,
|
||||
controller: _scrollController,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
ConversationBottomRow(
|
||||
_controller,
|
||||
_textfieldFocus,
|
||||
ColoredBox(
|
||||
color: Theme.of(context)
|
||||
.scaffoldBackgroundColor
|
||||
.withOpacity(0.4),
|
||||
child: ConversationBottomRow(
|
||||
_controller,
|
||||
_tabController,
|
||||
_textfieldFocus,
|
||||
_isSpeedDialOpen,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -503,12 +563,11 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
||||
),
|
||||
|
||||
BlocBuilder<ConversationBloc, ConversationState>(
|
||||
buildWhen: (prev, next) => prev.emojiPickerVisible != next.emojiPickerVisible ||
|
||||
prev.stickerPickerVisible != next.stickerPickerVisible,
|
||||
buildWhen: (prev, next) => prev.pickerVisible != next.pickerVisible,
|
||||
builder: (context, state) => Positioned(
|
||||
right: 8,
|
||||
bottom: state.emojiPickerVisible || state.stickerPickerVisible ?
|
||||
330 /* 80 + 250 */ :
|
||||
bottom: state.pickerVisible ?
|
||||
pickerHeight + 80 :
|
||||
80,
|
||||
child: Material(
|
||||
color: const Color.fromRGBO(0, 0, 0, 0),
|
||||
@@ -538,7 +597,7 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
||||
|
||||
// Indicator for the swipe to lock gesture
|
||||
Positioned(
|
||||
right: 8,
|
||||
right: 53,
|
||||
bottom: 100,
|
||||
child: IgnorePointer(
|
||||
child: BlocBuilder<ConversationBloc, ConversationState>(
|
||||
@@ -583,8 +642,8 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
||||
),
|
||||
|
||||
Positioned(
|
||||
right: 8,
|
||||
bottom: 250,
|
||||
right: 61,
|
||||
bottom: pickerHeight,
|
||||
child: BlocBuilder<ConversationBloc, ConversationState>(
|
||||
builder: (context, state) {
|
||||
return DragTarget<int>(
|
||||
@@ -612,7 +671,10 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
||||
null,
|
||||
backgroundColor: state.isLocked ?
|
||||
Colors.red.shade600 :
|
||||
Colors.grey,
|
||||
Theme
|
||||
.of(context)
|
||||
.extension<MoxxyThemeData>()!
|
||||
.conversationTextFieldColor,
|
||||
child: state.isLocked ?
|
||||
BlinkingIcon(
|
||||
icon: Icons.mic,
|
||||
@@ -620,7 +682,13 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
||||
start: Colors.white,
|
||||
end: Colors.red.shade600,
|
||||
) :
|
||||
const Icon(Icons.lock, color: Colors.white),
|
||||
Icon(
|
||||
Icons.lock,
|
||||
color: Theme
|
||||
.of(context)
|
||||
.extension<MoxxyThemeData>()!
|
||||
.conversationTextFieldTextColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -631,7 +699,7 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
||||
),
|
||||
|
||||
Positioned(
|
||||
right: 8,
|
||||
right: 61,
|
||||
bottom: 380,
|
||||
child: BlocBuilder<ConversationBloc, ConversationState>(
|
||||
builder: (context, state) {
|
||||
@@ -658,8 +726,17 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
||||
);
|
||||
} :
|
||||
null,
|
||||
backgroundColor: Colors.grey,
|
||||
child: const Icon(Icons.delete, color: Colors.white),
|
||||
backgroundColor: Theme
|
||||
.of(context)
|
||||
.extension<MoxxyThemeData>()!
|
||||
.conversationTextFieldColor,
|
||||
child: Icon(
|
||||
Icons.delete,
|
||||
color: Theme
|
||||
.of(context)
|
||||
.extension<MoxxyThemeData>()!
|
||||
.conversationTextFieldTextColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -276,7 +276,6 @@ class ConversationsPageState extends State<ConversationsPage> with TickerProvide
|
||||
icon: Icons.chat,
|
||||
curve: Curves.bounceInOut,
|
||||
backgroundColor: primaryColor,
|
||||
// TODO(Unknown): Theme dependent?
|
||||
foregroundColor: Colors.white,
|
||||
children: [
|
||||
SpeedDialChild(
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||
import 'package:moxxyv2/shared/models/preferences.dart';
|
||||
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/widgets/topbar.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
// TODO(PapaTutuWawa): Include license text
|
||||
class SettingsAboutPage extends StatelessWidget {
|
||||
class SettingsAboutPage extends StatefulWidget {
|
||||
const SettingsAboutPage({ super.key });
|
||||
|
||||
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
||||
@@ -14,7 +18,18 @@ class SettingsAboutPage extends StatelessWidget {
|
||||
name: aboutRoute,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@override
|
||||
SettingsAboutPageState createState() => SettingsAboutPageState();
|
||||
}
|
||||
|
||||
class SettingsAboutPageState extends State<SettingsAboutPage> {
|
||||
/// The amount of taps on the Moxxy logo, if showDebugMenu is false
|
||||
int _counter = 0;
|
||||
|
||||
/// True, if the toast ("You're already a developer") has already been shown once.
|
||||
bool _alreadyShownNotificationShown = false;
|
||||
|
||||
Future<void> _openUrl(String url) async {
|
||||
if (!await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication)) {
|
||||
// TODO(Unknown): Show a popup to copy the url
|
||||
@@ -29,9 +44,45 @@ class SettingsAboutPage extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: paddingVeryLarge),
|
||||
child: Column(
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/images/logo.png',
|
||||
width: 200, height: 200,
|
||||
BlocBuilder<PreferencesBloc, PreferencesState>(
|
||||
buildWhen: (prev, next) => prev.showDebugMenu != next.showDebugMenu,
|
||||
builder: (context, state) => InkWell(
|
||||
onTap: () async {
|
||||
if (state.showDebugMenu) {
|
||||
if (_counter == 0 && !_alreadyShownNotificationShown) {
|
||||
_alreadyShownNotificationShown = true;
|
||||
await Fluttertoast.showToast(
|
||||
msg: t.pages.settings.about.debugMenuAlreadyShown,
|
||||
gravity: ToastGravity.SNACKBAR,
|
||||
toastLength: Toast.LENGTH_SHORT,
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_counter++;
|
||||
if (_counter == 10) {
|
||||
context.read<PreferencesBloc>().add(
|
||||
PreferencesChangedEvent(
|
||||
state.copyWith(
|
||||
showDebugMenu: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await Fluttertoast.showToast(
|
||||
msg: t.pages.settings.about.debugMenuShown,
|
||||
gravity: ToastGravity.SNACKBAR,
|
||||
toastLength: Toast.LENGTH_SHORT,
|
||||
);
|
||||
}
|
||||
},
|
||||
child:Image.asset(
|
||||
'assets/images/logo.png',
|
||||
width: 200, height: 200,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
t.global.title,
|
||||
@@ -55,7 +106,7 @@ class SettingsAboutPage extends StatelessWidget {
|
||||
child: Text(
|
||||
// TODO(Unknown): Generate this at build time
|
||||
t.pages.settings.about.version(
|
||||
version: '0.3.0',
|
||||
version: '0.4.1',
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
|
||||
@@ -5,8 +5,9 @@ import 'package:moxxyv2/shared/models/preferences.dart';
|
||||
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/helpers.dart';
|
||||
import 'package:moxxyv2/ui/widgets/settings/row.dart';
|
||||
import 'package:moxxyv2/ui/widgets/settings/title.dart';
|
||||
import 'package:moxxyv2/ui/widgets/topbar.dart';
|
||||
import 'package:settings_ui/settings_ui.dart';
|
||||
|
||||
Widget _buildLanguageOption(BuildContext context, String localeCode, PreferencesState state) {
|
||||
final selected = state.languageLocaleCode == localeCode;
|
||||
@@ -45,57 +46,51 @@ class AppearanceSettingsPage extends StatelessWidget {
|
||||
return Scaffold(
|
||||
appBar: BorderlessTopbar.simple(t.pages.settings.appearance.title),
|
||||
body: BlocBuilder<PreferencesBloc, PreferencesState>(
|
||||
builder: (context, state) => SettingsList(
|
||||
sections: [
|
||||
SettingsSection(
|
||||
title: Text(t.pages.settings.appearance.languageSection),
|
||||
tiles: [
|
||||
SettingsTile(
|
||||
title: Text(t.pages.settings.appearance.language),
|
||||
description: Text(
|
||||
t.pages.settings.appearance.languageSubtext(
|
||||
selectedLanguage: localeCodeToLanguageName(state.languageLocaleCode),
|
||||
),
|
||||
),
|
||||
onPressed: (context) async {
|
||||
final result = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return SimpleDialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(textfieldRadiusRegular),
|
||||
),
|
||||
title: Text(t.pages.settings.appearance.language),
|
||||
children: [
|
||||
_buildLanguageOption(context, 'default', state),
|
||||
_buildLanguageOption(context, 'de', state),
|
||||
_buildLanguageOption(context, 'en', state),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (result == null) {
|
||||
// Do nothing as the dialog was dismissed
|
||||
return;
|
||||
}
|
||||
|
||||
// Change preferences and set the app's locale
|
||||
// ignore: use_build_context_synchronously
|
||||
context.read<PreferencesBloc>().add(
|
||||
PreferencesChangedEvent(
|
||||
state.copyWith(languageLocaleCode: result),
|
||||
builder: (context, state) => ListView(
|
||||
children: [
|
||||
SectionTitle(t.pages.settings.appearance.languageSection),
|
||||
SettingsRow(
|
||||
title: t.pages.settings.appearance.language,
|
||||
description: t.pages.settings.appearance.languageSubtext(
|
||||
selectedLanguage: localeCodeToLanguageName(state.languageLocaleCode),
|
||||
),
|
||||
onTap: () async {
|
||||
final result = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return SimpleDialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(textfieldRadiusRegular),
|
||||
),
|
||||
title: Text(t.pages.settings.appearance.language),
|
||||
children: [
|
||||
_buildLanguageOption(context, 'default', state),
|
||||
_buildLanguageOption(context, 'de', state),
|
||||
_buildLanguageOption(context, 'en', state),
|
||||
],
|
||||
);
|
||||
|
||||
if (result == 'default') {
|
||||
LocaleSettings.useDeviceLocale();
|
||||
} else {
|
||||
LocaleSettings.setLocaleRaw(result);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
if (result == null) {
|
||||
// Do nothing as the dialog was dismissed
|
||||
return;
|
||||
}
|
||||
|
||||
// Change preferences and set the app's locale
|
||||
// ignore: use_build_context_synchronously
|
||||
context.read<PreferencesBloc>().add(
|
||||
PreferencesChangedEvent(
|
||||
state.copyWith(languageLocaleCode: result),
|
||||
),
|
||||
);
|
||||
|
||||
if (result == 'default') {
|
||||
LocaleSettings.useDeviceLocale();
|
||||
} else {
|
||||
LocaleSettings.setLocaleRaw(result);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -9,11 +9,12 @@ import 'package:moxxyv2/ui/bloc/cropbackground_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/helpers.dart';
|
||||
import 'package:moxxyv2/ui/widgets/settings/row.dart';
|
||||
import 'package:moxxyv2/ui/widgets/settings/title.dart';
|
||||
import 'package:moxxyv2/ui/widgets/topbar.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:settings_ui/settings_ui.dart';
|
||||
|
||||
class ConversationSettingsPage extends StatelessWidget {
|
||||
const ConversationSettingsPage({ super.key });
|
||||
@@ -67,89 +68,94 @@ class ConversationSettingsPage extends StatelessWidget {
|
||||
return Scaffold(
|
||||
appBar: BorderlessTopbar.simple(t.pages.settings.conversation.title),
|
||||
body: BlocBuilder<PreferencesBloc, PreferencesState>(
|
||||
builder: (context, state) => SettingsList(
|
||||
sections: [
|
||||
SettingsSection(
|
||||
title: Text(t.pages.settings.conversation.appearance),
|
||||
tiles: [
|
||||
SettingsTile(
|
||||
title: Text(t.pages.settings.conversation.selectBackgroundImage),
|
||||
description: Text(t.pages.settings.conversation.selectBackgroundImageDescription),
|
||||
onPressed: (context) async {
|
||||
final backgroundPath = await _pickBackgroundImage();
|
||||
builder: (context, state) => ListView(
|
||||
children: [
|
||||
SectionTitle(t.pages.settings.conversation.appearance),
|
||||
|
||||
if (backgroundPath != null) {
|
||||
// ignore: use_build_context_synchronously
|
||||
context.read<CropBackgroundBloc>().add(
|
||||
CropBackgroundRequestedEvent(backgroundPath),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
SettingsTile(
|
||||
title: Text(t.pages.settings.conversation.removeBackgroundImage),
|
||||
onPressed: (context) async {
|
||||
final result = await showConfirmationDialog(
|
||||
t.pages.settings.conversation.removeBackgroundImageConfirmTitle,
|
||||
t.pages.settings.conversation.removeBackgroundImageConfirmBody,
|
||||
context,
|
||||
);
|
||||
SettingsRow(
|
||||
title: t.pages.settings.conversation.selectBackgroundImage,
|
||||
description: t.pages.settings.conversation.selectBackgroundImageDescription,
|
||||
onTap: () async {
|
||||
final backgroundPath = await _pickBackgroundImage();
|
||||
|
||||
if (result) {
|
||||
// ignore: use_build_context_synchronously
|
||||
await _removeBackgroundImage(context, state);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
if (backgroundPath != null) {
|
||||
// ignore: use_build_context_synchronously
|
||||
context.read<CropBackgroundBloc>().add(
|
||||
CropBackgroundRequestedEvent(backgroundPath),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
SettingsSection(
|
||||
title: Text(t.pages.settings.conversation.behaviourSection),
|
||||
tiles: [
|
||||
SettingsTile.switchTile(
|
||||
title: Text(t.pages.settings.conversation.contactsIntegration),
|
||||
description: Text(t.pages.settings.conversation.contactsIntegrationBody),
|
||||
initialValue: state.enableContactIntegration,
|
||||
onToggle: (value) async {
|
||||
// Ensure that we have the permission before changing the value
|
||||
if (value && await Permission.contacts.status == PermissionStatus.denied) {
|
||||
if (!(await Permission.contacts.request().isGranted)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ignore: use_build_context_synchronously
|
||||
context.read<PreferencesBloc>().add(
|
||||
PreferencesChangedEvent(
|
||||
state.copyWith(enableContactIntegration: value),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
SettingsRow(
|
||||
title: t.pages.settings.conversation.removeBackgroundImage,
|
||||
onTap: () async {
|
||||
final result = await showConfirmationDialog(
|
||||
t.pages.settings.conversation.removeBackgroundImageConfirmTitle,
|
||||
t.pages.settings.conversation.removeBackgroundImageConfirmBody,
|
||||
context,
|
||||
);
|
||||
|
||||
if (result) {
|
||||
// ignore: use_build_context_synchronously
|
||||
await _removeBackgroundImage(context, state);
|
||||
}
|
||||
},
|
||||
),
|
||||
SettingsSection(
|
||||
title: Text(t.pages.settings.conversation.newChatsSection),
|
||||
tiles: [
|
||||
SettingsTile.switchTile(
|
||||
title: Text(t.pages.settings.conversation.newChatsMuteByDefault),
|
||||
initialValue: state.defaultMuteState,
|
||||
onToggle: (value) => context.read<PreferencesBloc>().add(
|
||||
|
||||
SectionTitle(t.pages.settings.conversation.behaviourSection),
|
||||
|
||||
SettingsRow(
|
||||
title: t.pages.settings.conversation.contactsIntegration,
|
||||
description: t.pages.settings.conversation.contactsIntegrationBody,
|
||||
suffix: Switch(
|
||||
value: state.enableContactIntegration,
|
||||
onChanged: (value) async {
|
||||
// Ensure that we have the permission before changing the value
|
||||
if (value && await Permission.contacts.status == PermissionStatus.denied) {
|
||||
if (!(await Permission.contacts.request().isGranted)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ignore: use_build_context_synchronously
|
||||
context.read<PreferencesBloc>().add(
|
||||
PreferencesChangedEvent(
|
||||
state.copyWith(enableContactIntegration: value),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
SectionTitle(t.pages.settings.conversation.newChatsSection),
|
||||
|
||||
SettingsRow(
|
||||
title: t.pages.settings.conversation.newChatsMuteByDefault,
|
||||
suffix: Switch(
|
||||
value: state.defaultMuteState,
|
||||
onChanged: (value) {
|
||||
context.read<PreferencesBloc>().add(
|
||||
PreferencesChangedEvent(
|
||||
state.copyWith(defaultMuteState: value),
|
||||
),
|
||||
),
|
||||
),
|
||||
SettingsTile.switchTile(
|
||||
title: Text(t.pages.settings.conversation.newChatsE2EE),
|
||||
initialValue: state.enableOmemoByDefault,
|
||||
onToggle: (value) => context.read<PreferencesBloc>().add(
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
SettingsRow(
|
||||
title: t.pages.settings.conversation.newChatsE2EE,
|
||||
suffix: Switch(
|
||||
value: state.enableOmemoByDefault,
|
||||
onChanged: (value) {
|
||||
context.read<PreferencesBloc>().add(
|
||||
PreferencesChangedEvent(
|
||||
state.copyWith(enableOmemoByDefault: value),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||
import 'package:moxxyv2/shared/models/preferences.dart';
|
||||
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/widgets/settings/row.dart';
|
||||
import 'package:moxxyv2/ui/widgets/settings/title.dart';
|
||||
import 'package:moxxyv2/ui/widgets/topbar.dart';
|
||||
import 'package:settings_ui/settings_ui.dart';
|
||||
|
||||
class DebuggingPage extends StatelessWidget {
|
||||
DebuggingPage({ super.key })
|
||||
@@ -28,111 +30,129 @@ class DebuggingPage extends StatelessWidget {
|
||||
return Scaffold(
|
||||
appBar: BorderlessTopbar.simple(t.pages.settings.debugging.title),
|
||||
body: BlocBuilder<PreferencesBloc, PreferencesState>(
|
||||
builder: (context, state) => SettingsList(
|
||||
sections: [
|
||||
SettingsSection(
|
||||
title: Text(t.pages.settings.debugging.generalSection),
|
||||
tiles: [
|
||||
SettingsTile.switchTile(
|
||||
title: Text(t.pages.settings.debugging.generalEnableDebugging),
|
||||
onToggle: (value) => context.read<PreferencesBloc>().add(
|
||||
builder: (context, state) => ListView(
|
||||
children: [
|
||||
SectionTitle(t.pages.settings.debugging.generalSection),
|
||||
SettingsRow(
|
||||
title: t.pages.settings.debugging.generalEnableDebugging,
|
||||
suffix: Switch(
|
||||
value: state.debugEnabled,
|
||||
onChanged: (value) {
|
||||
context.read<PreferencesBloc>().add(
|
||||
PreferencesChangedEvent(
|
||||
state.copyWith(debugEnabled: value),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
SettingsRow(
|
||||
title: t.pages.settings.debugging.generalEncryptionPassword,
|
||||
description: t.pages.settings.debugging.generalEncryptionPasswordSubtext,
|
||||
onTap: () {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (BuildContext context) => AlertDialog(
|
||||
title: Text(t.pages.settings.debugging.generalEncryptionPassword),
|
||||
content: TextField(
|
||||
minLines: 1,
|
||||
obscureText: true,
|
||||
controller: _passphraseController,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: Text(t.global.dialogAccept),
|
||||
onPressed: () {
|
||||
context.read<PreferencesBloc>().add(
|
||||
PreferencesChangedEvent(
|
||||
state.copyWith(debugPassphrase: _passphraseController.text),
|
||||
),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
initialValue: state.debugEnabled,
|
||||
),
|
||||
SettingsTile(
|
||||
title: Text(t.pages.settings.debugging.generalEncryptionPassword),
|
||||
description: Text(t.pages.settings.debugging.generalEncryptionPasswordSubtext),
|
||||
onPressed: (context) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (BuildContext context) => AlertDialog(
|
||||
title: Text(t.pages.settings.debugging.generalEncryptionPassword),
|
||||
content: TextField(
|
||||
minLines: 1,
|
||||
obscureText: true,
|
||||
controller: _passphraseController,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: Text(t.global.dialogAccept),
|
||||
onPressed: () {
|
||||
context.read<PreferencesBloc>().add(
|
||||
PreferencesChangedEvent(
|
||||
state.copyWith(debugPassphrase: _passphraseController.text),
|
||||
),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
)
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
SettingsRow(
|
||||
title: t.pages.settings.debugging.generalLoggingIp,
|
||||
description: t.pages.settings.debugging.generalLoggingIpSubtext,
|
||||
onTap: () {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (BuildContext context) => AlertDialog(
|
||||
title: Text(t.pages.settings.debugging.generalLoggingIp),
|
||||
content: TextField(
|
||||
minLines: 1,
|
||||
obscureText: true,
|
||||
controller: _ipController,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: Text(t.global.dialogAccept),
|
||||
onPressed: () {
|
||||
context.read<PreferencesBloc>().add(
|
||||
PreferencesChangedEvent(
|
||||
state.copyWith(debugIp: _ipController.text),
|
||||
),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
SettingsRow(
|
||||
title: t.pages.settings.debugging.generalLoggingPort,
|
||||
description: t.pages.settings.debugging.generalLoggingPortSubtext,
|
||||
onTap: () {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (BuildContext context) => AlertDialog(
|
||||
title: Text(t.pages.settings.debugging.generalLoggingPort),
|
||||
content: TextField(
|
||||
minLines: 1,
|
||||
controller: _portController,
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: Text(t.global.dialogAccept),
|
||||
onPressed: () {
|
||||
context.read<PreferencesBloc>().add(
|
||||
PreferencesChangedEvent(
|
||||
state.copyWith(debugPort: int.parse(_portController.text)),
|
||||
),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// Hide the testing commands outside of debug mode
|
||||
...kDebugMode ? [
|
||||
const SectionTitle('Testing'),
|
||||
SettingsRow(
|
||||
title: 'Reset showDebugMenu state',
|
||||
onTap: () {
|
||||
context.read<PreferencesBloc>().add(
|
||||
PreferencesChangedEvent(
|
||||
state.copyWith(
|
||||
showDebugMenu: false,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
SettingsTile(
|
||||
title: Text(t.pages.settings.debugging.generalLoggingIp),
|
||||
description: Text(t.pages.settings.debugging.generalLoggingIpSubtext),
|
||||
onPressed: (context) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (BuildContext context) => AlertDialog(
|
||||
title: Text(t.pages.settings.debugging.generalLoggingIp),
|
||||
content: TextField(
|
||||
minLines: 1,
|
||||
controller: _ipController,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: Text(t.global.dialogAccept),
|
||||
onPressed: () {
|
||||
context.read<PreferencesBloc>().add(
|
||||
PreferencesChangedEvent(
|
||||
state.copyWith(debugIp: _ipController.text),
|
||||
),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
SettingsTile(
|
||||
title: Text(t.pages.settings.debugging.generalLoggingPort),
|
||||
description: Text(t.pages.settings.debugging.generalLoggingPortSubtext),
|
||||
onPressed: (context) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (BuildContext context) => AlertDialog(
|
||||
title: Text(t.pages.settings.debugging.generalLoggingPort),
|
||||
content: TextField(
|
||||
minLines: 1,
|
||||
controller: _portController,
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: Text(t.global.dialogAccept),
|
||||
onPressed: () {
|
||||
context.read<PreferencesBloc>().add(
|
||||
PreferencesChangedEvent(
|
||||
state.copyWith(debugPort: int.parse(_portController.text)),
|
||||
),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
] : [],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -4,8 +4,9 @@ import 'package:moxxyv2/i18n/strings.g.dart';
|
||||
import 'package:moxxyv2/shared/models/preferences.dart';
|
||||
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/widgets/settings/row.dart';
|
||||
import 'package:moxxyv2/ui/widgets/settings/title.dart';
|
||||
import 'package:moxxyv2/ui/widgets/topbar.dart';
|
||||
import 'package:settings_ui/settings_ui.dart';
|
||||
|
||||
class _AutoDownloadSizes {
|
||||
const _AutoDownloadSizes(this.text, this.value);
|
||||
@@ -45,6 +46,9 @@ class AutoDownloadSizeDialogState extends State<AutoDownloadSizeDialog> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(textfieldRadiusRegular),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 32,
|
||||
vertical: 12,
|
||||
@@ -101,56 +105,66 @@ class NetworkPage extends StatelessWidget {
|
||||
return Scaffold(
|
||||
appBar: BorderlessTopbar.simple(t.pages.settings.network.title),
|
||||
body: BlocBuilder<PreferencesBloc, PreferencesState>(
|
||||
builder: (context, state) => SettingsList(
|
||||
sections: [
|
||||
SettingsSection(
|
||||
title: Text(t.pages.settings.network.automaticDownloadsSection),
|
||||
tiles: [
|
||||
SettingsTile(
|
||||
title: Text(t.pages.settings.network.automaticDownloadsText),
|
||||
),
|
||||
SettingsTile.switchTile(
|
||||
title: Text(t.pages.settings.network.wifi),
|
||||
initialValue: state.autoDownloadWifi,
|
||||
onToggle: (value) => context.read<PreferencesBloc>().add(
|
||||
PreferencesChangedEvent(
|
||||
state.copyWith(autoDownloadWifi: value),
|
||||
),
|
||||
),
|
||||
),
|
||||
SettingsTile.switchTile(
|
||||
title: Text(t.pages.settings.network.mobileData),
|
||||
initialValue: state.autoDownloadMobile,
|
||||
onToggle: (value) => context.read<PreferencesBloc>().add(
|
||||
PreferencesChangedEvent(
|
||||
state.copyWith(autoDownloadMobile: value),
|
||||
),
|
||||
),
|
||||
),
|
||||
SettingsTile(
|
||||
title: Text(t.pages.settings.network.automaticDownloadsMaximumSize),
|
||||
description: Text(t.pages.settings.network.automaticDownloadsMaximumSizeSubtext),
|
||||
onPressed: (context) async {
|
||||
final result = await showDialog<int>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AutoDownloadSizeDialog(
|
||||
selectedValueInitial: state.maximumAutoDownloadSize,
|
||||
),
|
||||
);
|
||||
if (result == null) return;
|
||||
if (state.maximumAutoDownloadSize == result) return;
|
||||
builder: (context, state) => ListView(
|
||||
children: [
|
||||
SectionTitle(t.pages.settings.network.automaticDownloadsSection),
|
||||
|
||||
SettingsRow(
|
||||
title: t.pages.settings.network.automaticDownloadsText,
|
||||
),
|
||||
|
||||
// ignore: use_build_context_synchronously
|
||||
context.read<PreferencesBloc>().add(
|
||||
PreferencesChangedEvent(
|
||||
state.copyWith(maximumAutoDownloadSize: result),
|
||||
),
|
||||
);
|
||||
},
|
||||
SettingsRow(
|
||||
title: t.pages.settings.network.wifi,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 8,
|
||||
horizontal: 16,
|
||||
),
|
||||
suffix: Switch(
|
||||
value: state.autoDownloadWifi,
|
||||
onChanged: (value) => context.read<PreferencesBloc>().add(
|
||||
PreferencesChangedEvent(
|
||||
state.copyWith(autoDownloadWifi: value),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
),
|
||||
),
|
||||
SettingsRow(
|
||||
title: t.pages.settings.network.mobileData,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 8,
|
||||
horizontal: 16,
|
||||
),
|
||||
suffix: Switch(
|
||||
value: state.autoDownloadMobile,
|
||||
onChanged: (value) => context.read<PreferencesBloc>().add(
|
||||
PreferencesChangedEvent(
|
||||
state.copyWith(autoDownloadMobile: value),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SettingsRow(
|
||||
title: t.pages.settings.network.automaticDownloadsMaximumSize,
|
||||
description: t.pages.settings.network.automaticDownloadsMaximumSizeSubtext,
|
||||
onTap: () async {
|
||||
final result = await showDialog<int>(
|
||||
context: context,
|
||||
builder: (context) => AutoDownloadSizeDialog(
|
||||
selectedValueInitial: state.maximumAutoDownloadSize,
|
||||
),
|
||||
);
|
||||
if (result == null) return;
|
||||
if (state.maximumAutoDownloadSize == result) return;
|
||||
|
||||
// ignore: use_build_context_synchronously
|
||||
context.read<PreferencesBloc>().add(
|
||||
PreferencesChangedEvent(
|
||||
state.copyWith(maximumAutoDownloadSize: result),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -5,8 +5,9 @@ import 'package:moxxyv2/shared/models/preferences.dart';
|
||||
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/pages/settings/privacy/tile.dart';
|
||||
import 'package:moxxyv2/ui/widgets/settings/row.dart';
|
||||
import 'package:moxxyv2/ui/widgets/settings/title.dart';
|
||||
import 'package:moxxyv2/ui/widgets/topbar.dart';
|
||||
import 'package:settings_ui/settings_ui.dart';
|
||||
|
||||
class PrivacyPage extends StatelessWidget {
|
||||
const PrivacyPage({ super.key });
|
||||
@@ -23,88 +24,113 @@ class PrivacyPage extends StatelessWidget {
|
||||
return Scaffold(
|
||||
appBar: BorderlessTopbar.simple(t.pages.settings.privacy.title),
|
||||
body: BlocBuilder<PreferencesBloc, PreferencesState>(
|
||||
builder: (context, state) => SettingsList(
|
||||
sections: [
|
||||
SettingsSection(
|
||||
title: Text(t.pages.settings.privacy.generalSection),
|
||||
tiles: [
|
||||
SettingsTile.switchTile(
|
||||
title: Text(t.pages.settings.privacy.showContactRequests),
|
||||
description: Text(t.pages.settings.privacy.showContactRequestsSubtext),
|
||||
initialValue: state.showSubscriptionRequests,
|
||||
onToggle: (value) => context.read<PreferencesBloc>().add(
|
||||
builder: (context, state) => ListView(
|
||||
children: [
|
||||
SectionTitle(t.pages.settings.privacy.generalSection),
|
||||
|
||||
SettingsRow(
|
||||
title: t.pages.settings.privacy.showContactRequests,
|
||||
description: t.pages.settings.privacy.showContactRequestsSubtext,
|
||||
suffix: Switch(
|
||||
value: state.showSubscriptionRequests,
|
||||
onChanged: (value) {
|
||||
context.read<PreferencesBloc>().add(
|
||||
PreferencesChangedEvent(
|
||||
state.copyWith(showSubscriptionRequests: value),
|
||||
),
|
||||
),
|
||||
),
|
||||
SettingsTile.switchTile(
|
||||
title: Text(t.pages.settings.privacy.profilePictureVisibility),
|
||||
description: Text(t.pages.settings.privacy.profilePictureVisibilitSubtext),
|
||||
initialValue: state.isAvatarPublic,
|
||||
onToggle: (value) => context.read<PreferencesBloc>().add(
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
SettingsRow(
|
||||
title: t.pages.settings.privacy.profilePictureVisibility,
|
||||
description: t.pages.settings.privacy.profilePictureVisibilitSubtext,
|
||||
suffix: Switch(
|
||||
value: state.isAvatarPublic,
|
||||
onChanged: (value) {
|
||||
context.read<PreferencesBloc>().add(
|
||||
PreferencesChangedEvent(
|
||||
state.copyWith(isAvatarPublic: value),
|
||||
),
|
||||
),
|
||||
),
|
||||
SettingsTile.switchTile(
|
||||
title: Text(t.pages.settings.privacy.autoAcceptSubscriptionRequests),
|
||||
description: Text(t.pages.settings.privacy.autoAcceptSubscriptionRequestsSubtext),
|
||||
initialValue: state.autoAcceptSubscriptionRequests,
|
||||
onToggle: (value) => context.read<PreferencesBloc>().add(
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
SettingsRow(
|
||||
title: t.pages.settings.privacy.autoAcceptSubscriptionRequests,
|
||||
description: t.pages.settings.privacy.autoAcceptSubscriptionRequestsSubtext,
|
||||
suffix: Switch(
|
||||
value: state.autoAcceptSubscriptionRequests,
|
||||
onChanged: (value) {
|
||||
context.read<PreferencesBloc>().add(
|
||||
PreferencesChangedEvent(
|
||||
state.copyWith(autoAcceptSubscriptionRequests: value),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
SettingsSection(
|
||||
title: Text(t.pages.settings.privacy.conversationsSection),
|
||||
tiles: [
|
||||
SettingsTile.switchTile(
|
||||
title: Text(t.pages.settings.privacy.sendChatMarkers),
|
||||
description: Text(t.pages.settings.privacy.sendChatMarkersSubtext),
|
||||
initialValue: state.sendChatMarkers,
|
||||
onToggle: (value) => context.read<PreferencesBloc>().add(
|
||||
SettingsRow(
|
||||
title: t.pages.settings.privacy.stickersPrivacy,
|
||||
description: t.pages.settings.privacy.stickersPrivacySubtext,
|
||||
suffix: Switch(
|
||||
value: state.isStickersNodePublic,
|
||||
onChanged: (value) {
|
||||
context.read<PreferencesBloc>().add(
|
||||
PreferencesChangedEvent(
|
||||
state.copyWith(isStickersNodePublic: value),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
SectionTitle(t.pages.settings.privacy.conversationsSection),
|
||||
SettingsRow(
|
||||
title: t.pages.settings.privacy.sendChatMarkers,
|
||||
description: t.pages.settings.privacy.sendChatMarkersSubtext,
|
||||
suffix: Switch(
|
||||
value: state.sendChatMarkers,
|
||||
onChanged: (value) {
|
||||
context.read<PreferencesBloc>().add(
|
||||
PreferencesChangedEvent(
|
||||
state.copyWith(sendChatMarkers: value),
|
||||
),
|
||||
),
|
||||
),
|
||||
SettingsTile.switchTile(
|
||||
title: Text(t.pages.settings.privacy.sendChatStates),
|
||||
description: Text(t.pages.settings.privacy.sendChatStatesSubtext),
|
||||
initialValue: state.sendChatStates,
|
||||
onToggle: (value) => context.read<PreferencesBloc>().add(
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
SettingsRow(
|
||||
title: t.pages.settings.privacy.sendChatStates,
|
||||
description: t.pages.settings.privacy.sendChatStatesSubtext,
|
||||
suffix: Switch(
|
||||
value: state.sendChatStates,
|
||||
onChanged: (value) {
|
||||
context.read<PreferencesBloc>().add(
|
||||
PreferencesChangedEvent(
|
||||
state.copyWith(sendChatStates: value),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
SettingsSection(
|
||||
title: Text(t.pages.settings.privacy.redirectsSection),
|
||||
tiles: [
|
||||
RedirectSettingsTile(
|
||||
'Youtube',
|
||||
'Invidious',
|
||||
(state) => state.youtubeRedirect,
|
||||
(state, value) => state.copyWith(youtubeRedirect: value),
|
||||
(state) => state.enableYoutubeRedirect,
|
||||
(state, value) => state.copyWith(enableYoutubeRedirect: value),
|
||||
),
|
||||
RedirectSettingsTile(
|
||||
'Twitter',
|
||||
'Nitter',
|
||||
(state) => state.twitterRedirect,
|
||||
(state, value) => state.copyWith(twitterRedirect: value),
|
||||
(state) => state.enableTwitterRedirect,
|
||||
(state, value) => state.copyWith(enableTwitterRedirect: value),
|
||||
),
|
||||
],
|
||||
|
||||
SectionTitle(t.pages.settings.privacy.redirectsSection),
|
||||
RedirectSettingsTile(
|
||||
'Youtube',
|
||||
'Invidious',
|
||||
(state) => state.youtubeRedirect,
|
||||
(state, value) => state.copyWith(youtubeRedirect: value),
|
||||
(state) => state.enableYoutubeRedirect,
|
||||
(state, value) => state.copyWith(enableYoutubeRedirect: value),
|
||||
),
|
||||
RedirectSettingsTile(
|
||||
'Twitter',
|
||||
'Nitter',
|
||||
(state) => state.twitterRedirect,
|
||||
(state, value) => state.copyWith(twitterRedirect: value),
|
||||
(state) => state.enableTwitterRedirect,
|
||||
(state, value) => state.copyWith(enableTwitterRedirect: value),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -5,9 +5,9 @@ import 'package:moxxyv2/shared/models/preferences.dart';
|
||||
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
|
||||
import 'package:moxxyv2/ui/helpers.dart';
|
||||
import 'package:moxxyv2/ui/pages/settings/privacy/redirect_dialog.dart';
|
||||
import 'package:settings_ui/settings_ui.dart';
|
||||
import 'package:moxxyv2/ui/widgets/settings/row.dart';
|
||||
|
||||
class RedirectSettingsTile extends AbstractSettingsTile {
|
||||
class RedirectSettingsTile extends StatelessWidget {
|
||||
const RedirectSettingsTile(
|
||||
this.serviceName,
|
||||
this.exampleProxy,
|
||||
@@ -27,28 +27,13 @@ class RedirectSettingsTile extends AbstractSettingsTile {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<PreferencesBloc, PreferencesState>(
|
||||
builder: (context, state) => SettingsTile(
|
||||
title: Text(t.pages.settings.privacy.redirectsTitle(serviceName: serviceName)),
|
||||
description: Column(
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
t.pages.settings.privacy.redirectText(
|
||||
serviceName: serviceName,
|
||||
exampleProxy: exampleProxy,
|
||||
),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
t.pages.settings.privacy.currentlySelected(proxy: getProxy(state)),
|
||||
),
|
||||
),
|
||||
],
|
||||
builder: (context, state) => SettingsRow(
|
||||
title: t.pages.settings.privacy.redirectsTitle(serviceName: serviceName),
|
||||
description: t.pages.settings.privacy.redirectText(
|
||||
serviceName: serviceName,
|
||||
exampleProxy: exampleProxy,
|
||||
),
|
||||
onPressed: (context) {
|
||||
onTap: () {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (BuildContext context) => RedirectDialog(
|
||||
@@ -60,7 +45,7 @@ class RedirectSettingsTile extends AbstractSettingsTile {
|
||||
),
|
||||
);
|
||||
},
|
||||
trailing: Switch(
|
||||
suffix: Switch(
|
||||
value: getEnabled(state),
|
||||
onChanged: (value) {
|
||||
if (getProxy(state).isEmpty) {
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||
import 'package:moxxyv2/shared/models/preferences.dart';
|
||||
import 'package:moxxyv2/ui/bloc/blocklist_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/helpers.dart';
|
||||
import 'package:moxxyv2/ui/widgets/settings/row.dart';
|
||||
import 'package:moxxyv2/ui/widgets/settings/title.dart';
|
||||
import 'package:moxxyv2/ui/widgets/topbar.dart';
|
||||
import 'package:phosphor_flutter/phosphor_flutter.dart';
|
||||
import 'package:settings_ui/settings_ui.dart';
|
||||
|
||||
class SettingsPage extends StatelessWidget {
|
||||
const SettingsPage({ super.key });
|
||||
@@ -23,92 +27,134 @@ class SettingsPage extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: BorderlessTopbar.simple(t.pages.settings.settings.title),
|
||||
body: SettingsList(
|
||||
sections: [
|
||||
SettingsSection(
|
||||
title: Text(t.pages.settings.settings.conversationsSection),
|
||||
tiles: [
|
||||
SettingsTile(
|
||||
title: Text(t.pages.settings.conversation.title),
|
||||
leading: const Icon(Icons.chat_bubble),
|
||||
onPressed: (context) => Navigator.pushNamed(context, conversationSettingsRoute),
|
||||
body: BlocBuilder<PreferencesBloc, PreferencesState>(
|
||||
buildWhen: (prev, next) => prev.showDebugMenu != next.showDebugMenu,
|
||||
builder: (context, state) => ListView(
|
||||
children: [
|
||||
SectionTitle(t.pages.settings.settings.general),
|
||||
SettingsRow(
|
||||
title: t.pages.settings.appearance.title,
|
||||
prefix: const Padding(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
child: Icon(Icons.brush),
|
||||
),
|
||||
SettingsTile(
|
||||
title: Text(t.pages.settings.stickers.title),
|
||||
leading: const Icon(PhosphorIcons.stickerBold),
|
||||
onPressed: (context) => Navigator.pushNamed(context, stickersRoute),
|
||||
),
|
||||
SettingsTile(
|
||||
title: Text(t.pages.settings.network.title),
|
||||
leading: const Icon(Icons.network_wifi),
|
||||
onPressed: (context) => Navigator.pushNamed(context, networkRoute),
|
||||
),
|
||||
SettingsTile(
|
||||
title: Text(t.pages.settings.privacy.title),
|
||||
leading: const Icon(Icons.shield),
|
||||
onPressed: (context) => Navigator.pushNamed(context, privacyRoute),
|
||||
)
|
||||
],
|
||||
),
|
||||
SettingsSection(
|
||||
title: Text(t.pages.settings.settings.accountSection),
|
||||
tiles: [
|
||||
SettingsTile(
|
||||
title: Text(t.pages.blocklist.title),
|
||||
leading: const Icon(Icons.block),
|
||||
onPressed: (context) => Navigator.pushNamed(context, blocklistRoute),
|
||||
),
|
||||
SettingsTile(
|
||||
title: Text(t.pages.settings.settings.signOut),
|
||||
leading: const Icon(Icons.logout),
|
||||
onPressed: (context) async {
|
||||
final result = await showConfirmationDialog(
|
||||
t.pages.settings.settings.signOutConfirmTitle,
|
||||
t.pages.settings.settings.signOutConfirmBody,
|
||||
context,
|
||||
);
|
||||
onTap: () {
|
||||
Navigator.pushNamed(context, appearanceRoute);
|
||||
},
|
||||
),
|
||||
|
||||
if (result) {
|
||||
GetIt.I.get<PreferencesBloc>().add(SignedOutEvent());
|
||||
}
|
||||
SectionTitle(t.pages.settings.settings.conversationsSection),
|
||||
SettingsRow(
|
||||
title: t.pages.settings.settings.conversationsSection,
|
||||
prefix: const Padding(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
child: Icon(Icons.chat_bubble),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pushNamed(context, conversationSettingsRoute);
|
||||
},
|
||||
),
|
||||
SettingsRow(
|
||||
title: t.pages.settings.stickers.title,
|
||||
prefix: const Padding(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
child: Icon(PhosphorIcons.stickerBold),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pushNamed(context, stickersRoute);
|
||||
},
|
||||
),
|
||||
SettingsRow(
|
||||
title: t.pages.settings.network.title,
|
||||
prefix: const Padding(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
child: Icon(Icons.network_wifi),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pushNamed(context, networkRoute);
|
||||
},
|
||||
),
|
||||
SettingsRow(
|
||||
title: t.pages.settings.privacy.title,
|
||||
prefix: const Padding(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
child: Icon(Icons.shield),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pushNamed(context, privacyRoute);
|
||||
},
|
||||
),
|
||||
SettingsRow(
|
||||
title: t.pages.blocklist.title,
|
||||
prefix: const Padding(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
child: Icon(Icons.block),
|
||||
),
|
||||
onTap: () {
|
||||
GetIt.I.get<BlocklistBloc>().add(
|
||||
BlocklistRequestedEvent(),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
SectionTitle(t.pages.settings.settings.accountSection),
|
||||
SettingsRow(
|
||||
title: t.pages.settings.settings.signOut,
|
||||
prefix: const Padding(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
child: Icon(Icons.logout),
|
||||
),
|
||||
onTap: () async {
|
||||
final result = await showConfirmationDialog(
|
||||
t.pages.settings.settings.signOutConfirmTitle,
|
||||
t.pages.settings.settings.signOutConfirmBody,
|
||||
context,
|
||||
);
|
||||
|
||||
if (result) {
|
||||
GetIt.I.get<PreferencesBloc>().add(SignedOutEvent());
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
SectionTitle(t.pages.settings.settings.miscellaneousSection),
|
||||
SettingsRow(
|
||||
title: t.pages.settings.about.title,
|
||||
prefix: const Padding(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
child: Icon(Icons.info),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pushNamed(context, aboutRoute);
|
||||
},
|
||||
),
|
||||
SettingsRow(
|
||||
title: t.pages.settings.licenses.title,
|
||||
prefix: const Padding(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
child: Icon(Icons.info),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pushNamed(context, licensesRoute);
|
||||
},
|
||||
),
|
||||
|
||||
if (kDebugMode || state.showDebugMenu)
|
||||
SectionTitle(t.pages.settings.settings.debuggingSection),
|
||||
|
||||
if (kDebugMode || state.showDebugMenu)
|
||||
SettingsRow(
|
||||
title: t.pages.settings.debugging.title,
|
||||
prefix: const Padding(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
child: Icon(Icons.info),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pushNamed(context, debuggingRoute);
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
SettingsSection(
|
||||
title: Text(t.pages.settings.settings.miscellaneousSection),
|
||||
tiles: [
|
||||
SettingsTile(
|
||||
title: Text(t.pages.settings.appearance.title),
|
||||
leading: const Icon(Icons.brush),
|
||||
onPressed: (context) => Navigator.pushNamed(context, appearanceRoute),
|
||||
),
|
||||
SettingsTile(
|
||||
title: Text(t.pages.settings.about.title),
|
||||
leading: const Icon(Icons.info),
|
||||
onPressed: (context) => Navigator.pushNamed(context, aboutRoute),
|
||||
),
|
||||
SettingsTile(
|
||||
title: Text(t.pages.settings.licenses.title),
|
||||
leading: const Icon(Icons.description),
|
||||
onPressed: (context) => Navigator.pushNamed(context, licensesRoute),
|
||||
)
|
||||
],
|
||||
),
|
||||
// TODO(Unknown): Maybe also have a switch somewhere
|
||||
...kDebugMode ? [
|
||||
SettingsSection(
|
||||
title: Text(t.pages.settings.settings.debuggingSection),
|
||||
tiles: [
|
||||
SettingsTile(
|
||||
title: Text(t.pages.settings.debugging.title),
|
||||
leading: const Icon(Icons.info),
|
||||
onPressed: (context) => Navigator.pushNamed(context, debuggingRoute),
|
||||
)
|
||||
],
|
||||
)
|
||||
] : []
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,6 +91,11 @@ class StickersSettingsPage extends StatelessWidget {
|
||||
),
|
||||
|
||||
SectionTitle(t.pages.settings.stickers.stickerPacksSection),
|
||||
|
||||
if (stickers.stickerPacks.isEmpty)
|
||||
SettingsRow(
|
||||
title: t.pages.conversation.stickerPickerNoStickersLine1,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,12 +12,15 @@ import 'package:moxxyv2/ui/bloc/share_selection_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/stickers_bloc.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/service/data.dart';
|
||||
import 'package:share_handler/share_handler.dart';
|
||||
import 'package:moxxyv2/ui/service/sharing.dart';
|
||||
|
||||
/// Handler for when we received a [PreStartDoneEvent].
|
||||
Future<void> preStartDone(PreStartDoneEvent result, { dynamic extra }) async {
|
||||
GetIt.I.get<PreferencesBloc>().add(
|
||||
PreferencesChangedEvent(result.preferences),
|
||||
PreferencesChangedEvent(
|
||||
result.preferences,
|
||||
notify: false,
|
||||
),
|
||||
);
|
||||
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -50,12 +53,19 @@ Future<void> preStartDone(PreStartDoneEvent result, { dynamic extra }) async {
|
||||
result.stickers!,
|
||||
),
|
||||
);
|
||||
GetIt.I.get<ShareSelectionBloc>().add(
|
||||
ShareSelectionInitEvent(
|
||||
result.conversations!,
|
||||
result.roster!,
|
||||
),
|
||||
);
|
||||
|
||||
GetIt.I.get<Logger>().finest('Navigating to conversations');
|
||||
|
||||
// Only go to the Conversations page when we did not start due to a sharing intent
|
||||
final handler = ShareHandlerPlatform.instance;
|
||||
if (await handler.getInitialSharedMedia() == null) {
|
||||
final sharing = GetIt.I.get<UISharingService>();
|
||||
if (sharing.hasEarlyMedia) {
|
||||
GetIt.I.get<Logger>().finest('Early media available. Navigating to share selection');
|
||||
await sharing.handleEarlySharedMedia();
|
||||
} else {
|
||||
GetIt.I.get<Logger>().finest('Navigating to conversations');
|
||||
GetIt.I.get<NavigationBloc>().add(
|
||||
PushedNamedAndRemoveUntilEvent(
|
||||
const NavigationDestination(conversationsRoute),
|
||||
@@ -63,15 +73,14 @@ Future<void> preStartDone(PreStartDoneEvent result, { dynamic extra }) async {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
GetIt.I.get<ShareSelectionBloc>().add(
|
||||
ShareSelectionInitEvent(
|
||||
result.conversations!,
|
||||
result.roster!,
|
||||
),
|
||||
);
|
||||
} else if (result.state == preStartNotLoggedInState) {
|
||||
// Set UI data
|
||||
GetIt.I.get<UIDataService>().isLoggedIn = false;
|
||||
|
||||
// Clear shared media data
|
||||
await GetIt.I.get<UISharingService>().clearSharedMedia();
|
||||
|
||||
// Navigate to the intro page
|
||||
GetIt.I.get<Logger>().finest('Navigating to intro');
|
||||
GetIt.I.get<NavigationBloc>().add(
|
||||
PushedNamedAndRemoveUntilEvent(
|
||||
|
||||
75
lib/ui/service/sharing.dart
Normal file
75
lib/ui/service/sharing.dart
Normal file
@@ -0,0 +1,75 @@
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxxyv2/ui/bloc/share_selection_bloc.dart';
|
||||
import 'package:moxxyv2/ui/service/data.dart';
|
||||
import 'package:share_handler/share_handler.dart';
|
||||
|
||||
/// This service is responsible for storing a sharing request and or executing it.
|
||||
class UISharingService {
|
||||
/// A possible media object that was shared to Moxxy while the app was closed.
|
||||
SharedMedia? _media;
|
||||
|
||||
/// Flag indicating whether the service has already been initialized or not.
|
||||
bool _initialized = false;
|
||||
|
||||
/// Logger
|
||||
final Logger _log = Logger('UISharingService');
|
||||
|
||||
/// If [media] is non-null, forwards the metadata to the ShareSelectionBloc, which
|
||||
/// will open the share dialog.
|
||||
/// If [media] is null, then nothing will happen.
|
||||
Future<void> _handleSharedMedia(SharedMedia? media) async {
|
||||
if (media == null) return;
|
||||
|
||||
_log.finest('Handling media');
|
||||
final attachments = media.attachments ?? [];
|
||||
GetIt.I.get<ShareSelectionBloc>().add(
|
||||
ShareSelectionRequestedEvent(
|
||||
attachments.map((a) => a!.path).toList(),
|
||||
media.content,
|
||||
media.content != null ? ShareSelectionType.text : ShareSelectionType.media,
|
||||
),
|
||||
);
|
||||
|
||||
await clearSharedMedia();
|
||||
}
|
||||
|
||||
/// Clears all shared media data we (and the share_handler plugin has) have.
|
||||
Future<void> clearSharedMedia() async {
|
||||
_log.finest('Clearing media');
|
||||
await ShareHandlerPlatform.instance.resetInitialSharedMedia();
|
||||
_media = null;
|
||||
}
|
||||
|
||||
/// True if we have early media. False if not.
|
||||
bool get hasEarlyMedia => _media != null;
|
||||
|
||||
/// If Moxxy was started with a share intent, then this function is equivalent to
|
||||
/// [UISharingService._handleSharedMedia] but called with said share intent's metadata.
|
||||
Future<void> handleEarlySharedMedia() async {
|
||||
await _handleSharedMedia(_media);
|
||||
}
|
||||
|
||||
/// Sets up streams for reacting to share intents. Also stores an initial shared media
|
||||
/// object for later retrieval, if available.
|
||||
Future<void> initialize() async {
|
||||
if (_initialized) return;
|
||||
|
||||
final media = await ShareHandlerPlatform.instance.getInitialSharedMedia();
|
||||
if (media != null) {
|
||||
_log.finest('initialize: Early media is not null');
|
||||
_media = media;
|
||||
}
|
||||
|
||||
ShareHandlerPlatform.instance.sharedMediaStream.listen((SharedMedia media) async {
|
||||
if (GetIt.I.get<UIDataService>().isLoggedIn) {
|
||||
_log.finest('stream: Handle shared media via stream');
|
||||
await _handleSharedMedia(media);
|
||||
}
|
||||
|
||||
await ShareHandlerPlatform.instance.resetInitialSharedMedia();
|
||||
});
|
||||
|
||||
_initialized = true;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,59 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
|
||||
/// A theme extension for Moxxy specific colors.
|
||||
@immutable
|
||||
class MoxxyThemeData extends ThemeExtension<MoxxyThemeData> {
|
||||
const MoxxyThemeData({
|
||||
required this.conversationTextFieldColor,
|
||||
required this.profileFallbackBackgroundColor,
|
||||
required this.profileFallbackTextColor,
|
||||
required this.bubbleQuoteInTextFieldColor,
|
||||
required this.bubbleQuoteInTextFieldTextColor,
|
||||
required this.conversationTextFieldHintTextColor,
|
||||
required this.conversationTextFieldTextColor,
|
||||
});
|
||||
|
||||
/// The color of the conversation TextField
|
||||
final Color conversationTextFieldColor;
|
||||
|
||||
/// The color of the background of a user with no avatar
|
||||
final Color profileFallbackBackgroundColor;
|
||||
|
||||
/// The text color of a user with no avatar
|
||||
final Color profileFallbackTextColor;
|
||||
|
||||
/// The color of a quote bubble displayed inside the TextField
|
||||
final Color bubbleQuoteInTextFieldColor;
|
||||
|
||||
/// The color of text inside a quote bubble inside the TextField
|
||||
final Color bubbleQuoteInTextFieldTextColor;
|
||||
|
||||
/// The color of the hint text inside the TextField of the ConversationPage
|
||||
final Color conversationTextFieldHintTextColor;
|
||||
|
||||
/// The regular text color of the message TextField on the ConversationPage
|
||||
final Color conversationTextFieldTextColor;
|
||||
|
||||
@override
|
||||
MoxxyThemeData copyWith({Color? conversationTextFieldColor, Color? profileFallbackBackgroundColor, Color? profileFallbackTextColor, Color? bubbleQuoteInTextFieldColor, Color? bubbleQuoteInTextFieldTextColor, Color? conversationTextFieldHintTextColor, Color? conversationTextFieldTextColor,}) {
|
||||
return MoxxyThemeData(
|
||||
conversationTextFieldColor: conversationTextFieldColor ?? this.conversationTextFieldColor,
|
||||
profileFallbackBackgroundColor: profileFallbackBackgroundColor ?? this.profileFallbackBackgroundColor,
|
||||
profileFallbackTextColor: profileFallbackTextColor ?? this.profileFallbackTextColor,
|
||||
bubbleQuoteInTextFieldColor: bubbleQuoteInTextFieldColor ?? this.bubbleQuoteInTextFieldColor,
|
||||
bubbleQuoteInTextFieldTextColor: bubbleQuoteInTextFieldTextColor ?? this.bubbleQuoteInTextFieldTextColor,
|
||||
conversationTextFieldHintTextColor: conversationTextFieldHintTextColor ?? this.conversationTextFieldHintTextColor,
|
||||
conversationTextFieldTextColor: conversationTextFieldTextColor ?? this.conversationTextFieldTextColor,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
MoxxyThemeData lerp(ThemeExtension<MoxxyThemeData>? other, double t) {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function for quickly generating MaterialStateProperty instances that
|
||||
/// only differentiate between a color for the element's disabled state and for all
|
||||
/// other states.
|
||||
@@ -52,5 +105,28 @@ ThemeData getThemeData(BuildContext context, Brightness brightness) {
|
||||
return primaryColor;
|
||||
}),
|
||||
),
|
||||
|
||||
extensions: [
|
||||
if (brightness == Brightness.dark)
|
||||
const MoxxyThemeData(
|
||||
conversationTextFieldColor: conversationTextFieldColorDark,
|
||||
profileFallbackBackgroundColor: profileFallbackBackgroundColorDark,
|
||||
profileFallbackTextColor: profileFallbackTextColorDark,
|
||||
bubbleQuoteInTextFieldColor: bubbleQuoteInTextFieldColorDark,
|
||||
bubbleQuoteInTextFieldTextColor: bubbleQuoteInTextFieldTextColorDark,
|
||||
conversationTextFieldHintTextColor: textFieldHintTextColorDark,
|
||||
conversationTextFieldTextColor: textFieldTextColorDark,
|
||||
)
|
||||
else
|
||||
const MoxxyThemeData(
|
||||
conversationTextFieldColor: conversationTextFieldColorLight,
|
||||
profileFallbackBackgroundColor: profileFallbackBackgroundColorLight,
|
||||
profileFallbackTextColor: profileFallbackTextColorLight,
|
||||
bubbleQuoteInTextFieldColor: bubbleQuoteInTextFieldColorLight,
|
||||
bubbleQuoteInTextFieldTextColor: bubbleQuoteInTextFieldTextColorLight,
|
||||
conversationTextFieldHintTextColor: textFieldHintTextColorLight,
|
||||
conversationTextFieldTextColor: textFieldTextColorLight,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:moxxyv2/ui/helpers.dart';
|
||||
import 'package:moxxyv2/ui/theme.dart';
|
||||
|
||||
class AvatarWrapper extends StatelessWidget {
|
||||
const AvatarWrapper({ required this.radius, this.avatarUrl, this.altText, this.altIcon, this.onTapFunction, this.showEditButton = false, super.key })
|
||||
@@ -14,12 +14,13 @@ class AvatarWrapper extends StatelessWidget {
|
||||
final bool showEditButton;
|
||||
final void Function()? onTapFunction;
|
||||
|
||||
Widget _constructAlt() {
|
||||
Widget _constructAlt(BuildContext context) {
|
||||
if (altText != null) {
|
||||
return Text(
|
||||
avatarAltText(altText!),
|
||||
style: TextStyle(
|
||||
fontSize: radius * 0.8,
|
||||
color: Theme.of(context).extension<MoxxyThemeData>()!.profileFallbackTextColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -27,25 +28,26 @@ class AvatarWrapper extends StatelessWidget {
|
||||
return Icon(
|
||||
altIcon,
|
||||
size: radius,
|
||||
color: Theme.of(context).extension<MoxxyThemeData>()!.profileFallbackTextColor,
|
||||
);
|
||||
}
|
||||
|
||||
/// Either display the alt or the actual image
|
||||
Widget _avatarWrapper() {
|
||||
Widget _avatarWrapper(BuildContext context) {
|
||||
final useAlt = avatarUrl == null || avatarUrl == '';
|
||||
|
||||
return CircleAvatar(
|
||||
backgroundColor: Colors.grey[800],
|
||||
backgroundColor: Theme.of(context).extension<MoxxyThemeData>()!.profileFallbackBackgroundColor,
|
||||
backgroundImage: !useAlt ? FileImage(File(avatarUrl!)) : null,
|
||||
radius: radius,
|
||||
child: useAlt ? _constructAlt() : null,
|
||||
child: useAlt ? _constructAlt(context) : null,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _withEditButton() {
|
||||
Widget _withEditButton(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
_avatarWrapper(),
|
||||
_avatarWrapper(context),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
@@ -71,7 +73,9 @@ class AvatarWrapper extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTapFunction,
|
||||
child: showEditButton ? _withEditButton() : _avatarWrapper(),
|
||||
child: showEditButton ?
|
||||
_withEditButton(context) :
|
||||
_avatarWrapper(context),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,12 @@ class DateBubble extends StatelessWidget {
|
||||
horizontal: 8,
|
||||
vertical: 6,
|
||||
),
|
||||
child: Text(value),
|
||||
child: Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
48
lib/ui/widgets/chat/bubbles/new_device.dart
Normal file
48
lib/ui/widgets/chat/bubbles/new_device.dart
Normal file
@@ -0,0 +1,48 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||
import 'package:moxxyv2/ui/bloc/devices_bloc.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
|
||||
class NewDeviceBubble extends StatelessWidget {
|
||||
const NewDeviceBubble({
|
||||
required this.data,
|
||||
required this.title,
|
||||
super.key,
|
||||
});
|
||||
final Map<String, dynamic> data;
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
child: Material(
|
||||
color: bubbleColorNewDevice,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
context.read<DevicesBloc>().add(
|
||||
DevicesRequestedEvent(data['jid']! as String),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 6,
|
||||
),
|
||||
child: Text(
|
||||
t.pages.conversation.newDeviceMessage(title: title),
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,9 @@
|
||||
// TODO(Unknown): The timestamp is too small
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_vibrate/flutter_vibrate.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:moxxyv2/shared/models/reaction.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/datebubble.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/media/media.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/reactionbubble.dart';
|
||||
import 'package:swipeable_tile/swipeable_tile.dart';
|
||||
@@ -48,6 +46,11 @@ class RawChatBubble extends StatelessWidget {
|
||||
isInlinedWidget = message.mediaType!.startsWith('image/');
|
||||
}
|
||||
|
||||
// Check if it is a pseudo message
|
||||
if (message.isPseudoMessage) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if it is an embedded file
|
||||
if (message.isMedia && message.mediaUrl != null && isInlinedWidget) {
|
||||
return true;
|
||||
@@ -112,7 +115,6 @@ class ChatBubble extends StatefulWidget {
|
||||
required this.message,
|
||||
required this.sentBySelf,
|
||||
required this.maxWidth,
|
||||
required this.lastMessageTimestamp,
|
||||
required this.onSwipedCallback,
|
||||
required this.bubble,
|
||||
this.onLongPressed,
|
||||
@@ -123,8 +125,6 @@ class ChatBubble extends StatefulWidget {
|
||||
final bool sentBySelf;
|
||||
// For rendering the corners
|
||||
final double maxWidth;
|
||||
// For rendering the date bubble
|
||||
final int? lastMessageTimestamp;
|
||||
// For acting on swiping
|
||||
final void Function(Message) onSwipedCallback;
|
||||
// For acting on long-pressing the message
|
||||
@@ -177,7 +177,10 @@ class ChatBubbleState extends State<ChatBubble>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBubble(BuildContext context) {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
|
||||
return SwipeableTile.swipeToTrigger(
|
||||
direction: _getSwipeDirection(),
|
||||
swipeThreshold: 0.2,
|
||||
@@ -263,41 +266,4 @@ class ChatBubbleState extends State<ChatBubble>
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWithDateBubble(Widget widget, String dateString) {
|
||||
return IntrinsicHeight(
|
||||
child: Column(
|
||||
children: [
|
||||
DateBubble(dateString),
|
||||
widget,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
// lastMessageTimestamp == null means that there is no previous message
|
||||
final thisMessageDateTime = DateTime.fromMillisecondsSinceEpoch(widget.message.timestamp);
|
||||
if (widget.lastMessageTimestamp == null) {
|
||||
return _buildWithDateBubble(
|
||||
_buildBubble(context),
|
||||
formatDateBubble(thisMessageDateTime, DateTime.now()),
|
||||
);
|
||||
}
|
||||
|
||||
final lastMessageDateTime = DateTime.fromMillisecondsSinceEpoch(widget.lastMessageTimestamp!);
|
||||
|
||||
if (lastMessageDateTime.day != thisMessageDateTime.day ||
|
||||
lastMessageDateTime.month != thisMessageDateTime.month ||
|
||||
lastMessageDateTime.year != thisMessageDateTime.year) {
|
||||
return _buildWithDateBubble(
|
||||
_buildBubble(context),
|
||||
formatDateBubble(thisMessageDateTime, DateTime.now()),
|
||||
);
|
||||
}
|
||||
|
||||
return _buildBubble(context);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,17 +254,16 @@ class AudioChatState extends State<AudioChatWidget> {
|
||||
}
|
||||
|
||||
Widget _buildDownloadable() {
|
||||
// TODO(Unknown): Implement
|
||||
return FileChatBaseWidget(
|
||||
widget.message,
|
||||
Icons.image,
|
||||
widget.message.isFileUploadNotification ?
|
||||
(widget.message.filename ?? '') :
|
||||
filenameFromUrl(widget.message.srcUrl!),
|
||||
widget.radius,
|
||||
widget.maxWidth,
|
||||
widget.sent,
|
||||
extra: DownloadButton(
|
||||
mimeType: widget.message.mediaType,
|
||||
downloadButton: DownloadButton(
|
||||
onPressed: () {
|
||||
MoxplatformPlugin.handler.getDataSender().sendData(
|
||||
RequestDownloadCommand(message: widget.message),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'dart:core';
|
||||
import 'package:auto_size_text/auto_size_text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:moxplatform/moxplatform.dart';
|
||||
import 'package:moxxyv2/shared/commands.dart';
|
||||
@@ -15,25 +14,39 @@ import 'package:moxxyv2/ui/widgets/chat/progress.dart';
|
||||
class FileChatBaseWidget extends StatelessWidget {
|
||||
const FileChatBaseWidget(
|
||||
this.message,
|
||||
this.icon,
|
||||
this.filename,
|
||||
this.radius,
|
||||
this.maxWidth,
|
||||
this.sent,
|
||||
{
|
||||
this.extra,
|
||||
this.downloadButton,
|
||||
this.onTap,
|
||||
this.mimeType,
|
||||
super.key,
|
||||
}
|
||||
);
|
||||
final Message message;
|
||||
final IconData icon;
|
||||
final String filename;
|
||||
final BorderRadius radius;
|
||||
final double maxWidth;
|
||||
final Widget? extra;
|
||||
final Widget? downloadButton;
|
||||
final bool sent;
|
||||
final void Function()? onTap;
|
||||
final String? mimeType;
|
||||
|
||||
IconData _mimeTypeToIcon() {
|
||||
if (mimeType == null) return Icons.file_present;
|
||||
|
||||
if (mimeType!.startsWith('image/')) {
|
||||
return Icons.image;
|
||||
} else if (mimeType!.startsWith('video/')) {
|
||||
return Icons.video_file_outlined;
|
||||
} else if (mimeType!.startsWith('audio/')) {
|
||||
return Icons.music_note;
|
||||
}
|
||||
|
||||
return Icons.file_present;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -41,21 +54,43 @@ class FileChatBaseWidget extends StatelessWidget {
|
||||
width: maxWidth,
|
||||
child: MediaBaseChatWidget(
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 48,
|
||||
),
|
||||
if (downloadButton != null)
|
||||
downloadButton!,
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 6),
|
||||
child: AutoSizeText(
|
||||
filename,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
filename,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
_mimeTypeToIcon(),
|
||||
size: 48,
|
||||
),
|
||||
|
||||
Text(
|
||||
mimeTypeToName(mimeType),
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -64,7 +99,7 @@ class FileChatBaseWidget extends StatelessWidget {
|
||||
MessageBubbleBottom(message, sent),
|
||||
radius,
|
||||
gradient: false,
|
||||
extra: extra,
|
||||
//extra: extra,
|
||||
onTap: onTap,
|
||||
),
|
||||
);
|
||||
@@ -93,12 +128,14 @@ class FileChatWidget extends StatelessWidget {
|
||||
Widget _buildNonDownloaded() {
|
||||
return FileChatBaseWidget(
|
||||
message,
|
||||
Icons.file_present,
|
||||
message.isFileUploadNotification ? (message.filename ?? '') : filenameFromUrl(message.srcUrl!),
|
||||
message.isFileUploadNotification ?
|
||||
(message.filename ?? '') :
|
||||
filenameFromUrl(message.srcUrl!),
|
||||
radius,
|
||||
maxWidth,
|
||||
sent,
|
||||
extra: DownloadButton(
|
||||
mimeType: message.mediaType,
|
||||
downloadButton: DownloadButton(
|
||||
onPressed: () {
|
||||
MoxplatformPlugin.handler.getDataSender().sendData(
|
||||
RequestDownloadCommand(message: message),
|
||||
@@ -112,23 +149,27 @@ class FileChatWidget extends StatelessWidget {
|
||||
Widget _buildDownloading() {
|
||||
return FileChatBaseWidget(
|
||||
message,
|
||||
Icons.file_present,
|
||||
message.isFileUploadNotification ? (message.filename ?? '') : filenameFromUrl(message.srcUrl!),
|
||||
message.isFileUploadNotification ?
|
||||
(message.filename ?? '') :
|
||||
filenameFromUrl(message.srcUrl ?? ''),
|
||||
radius,
|
||||
maxWidth,
|
||||
sent,
|
||||
extra: ProgressWidget(id: message.id),
|
||||
mimeType: message.mediaType,
|
||||
downloadButton: ProgressWidget(id: message.id),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInner() {
|
||||
return FileChatBaseWidget(
|
||||
message,
|
||||
Icons.file_present,
|
||||
message.isFileUploadNotification ? (message.filename ?? '') : filenameFromUrl(message.srcUrl!),
|
||||
message.isFileUploadNotification ?
|
||||
(message.filename ?? '') :
|
||||
filenameFromUrl(message.srcUrl!),
|
||||
radius,
|
||||
maxWidth,
|
||||
sent,
|
||||
mimeType: message.mediaType,
|
||||
onTap: () {
|
||||
openFile(message.mediaUrl!);
|
||||
},
|
||||
|
||||
@@ -58,12 +58,14 @@ class ImageChatWidget extends StatelessWidget {
|
||||
} else {
|
||||
return FileChatBaseWidget(
|
||||
message,
|
||||
Icons.image,
|
||||
message.isFileUploadNotification ? (message.filename ?? '') : filenameFromUrl(message.srcUrl!),
|
||||
message.isFileUploadNotification ?
|
||||
(message.filename ?? '') :
|
||||
filenameFromUrl(message.srcUrl!),
|
||||
radius,
|
||||
maxWidth,
|
||||
sent,
|
||||
extra: ProgressWidget(id: message.id),
|
||||
mimeType: message.mediaType,
|
||||
downloadButton: ProgressWidget(id: message.id),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -114,12 +116,14 @@ class ImageChatWidget extends StatelessWidget {
|
||||
} else {
|
||||
return FileChatBaseWidget(
|
||||
message,
|
||||
Icons.image,
|
||||
message.isFileUploadNotification ? (message.filename ?? '') : filenameFromUrl(message.srcUrl!),
|
||||
message.isFileUploadNotification ?
|
||||
(message.filename ?? '') :
|
||||
filenameFromUrl(message.srcUrl!),
|
||||
radius,
|
||||
maxWidth,
|
||||
sent,
|
||||
extra: DownloadButton(
|
||||
mimeType: message.mediaType,
|
||||
downloadButton: DownloadButton(
|
||||
onPressed: () {
|
||||
MoxplatformPlugin.handler.getDataSender().sendData(
|
||||
RequestDownloadCommand(message: message),
|
||||
|
||||
@@ -9,10 +9,10 @@ import 'package:moxxyv2/ui/widgets/chat/media/sticker.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/media/video.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/playbutton.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/quote/audio.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/quote/base.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/quote/file.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/quote/image.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/quote/sticker.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/quote/text.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/quote/video.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/shared/audio.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/shared/file.dart';
|
||||
@@ -26,7 +26,7 @@ enum MessageType {
|
||||
video,
|
||||
audio,
|
||||
file,
|
||||
sticker
|
||||
sticker,
|
||||
}
|
||||
|
||||
/// Deduce the type of message we are dealing with to pick the correct
|
||||
@@ -72,7 +72,9 @@ Widget buildMessageWidget(Message message, double maxWidth, BorderRadius radius,
|
||||
return TextChatWidget(
|
||||
message,
|
||||
sent,
|
||||
topWidget: message.quotes != null ? buildQuoteMessageWidget(message.quotes!, sent) : null,
|
||||
topWidget: message.quotes != null ?
|
||||
buildQuoteMessageWidget(message.quotes!, sent) :
|
||||
null,
|
||||
);
|
||||
}
|
||||
case MessageType.image:
|
||||
@@ -83,9 +85,8 @@ Widget buildMessageWidget(Message message, double maxWidth, BorderRadius radius,
|
||||
return StickerChatWidget(message, radius, maxWidth, sent);
|
||||
case MessageType.audio:
|
||||
return AudioChatWidget(message, radius, maxWidth, sent);
|
||||
case MessageType.file: {
|
||||
case MessageType.file:
|
||||
return FileChatWidget(message, radius, maxWidth, sent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,12 +96,7 @@ Widget buildQuoteMessageWidget(Message message, bool sent, { void Function()? re
|
||||
case MessageType.sticker:
|
||||
return QuotedStickerWidget(message, sent, resetQuote: resetQuote);
|
||||
case MessageType.text:
|
||||
return QuoteBaseWidget(
|
||||
message,
|
||||
Text(message.body),
|
||||
sent,
|
||||
resetQuotedMessage: resetQuote,
|
||||
);
|
||||
return QuotedTextWidget(message, sent, resetQuote: resetQuote);
|
||||
case MessageType.image:
|
||||
return QuotedImageWidget(message, sent, resetQuote: resetQuote);
|
||||
case MessageType.video:
|
||||
|
||||
@@ -69,12 +69,14 @@ class VideoChatWidget extends StatelessWidget {
|
||||
} else {
|
||||
return FileChatBaseWidget(
|
||||
message,
|
||||
Icons.video_file_outlined,
|
||||
message.isFileUploadNotification ? (message.filename ?? '') : filenameFromUrl(message.srcUrl!),
|
||||
message.isFileUploadNotification ?
|
||||
(message.filename ?? '') :
|
||||
filenameFromUrl(message.srcUrl!),
|
||||
radius,
|
||||
maxWidth,
|
||||
sent,
|
||||
extra: ProgressWidget(id: message.id),
|
||||
mimeType: message.mediaType,
|
||||
downloadButton: ProgressWidget(id: message.id),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -122,12 +124,14 @@ class VideoChatWidget extends StatelessWidget {
|
||||
} else {
|
||||
return FileChatBaseWidget(
|
||||
message,
|
||||
Icons.video_file_outlined,
|
||||
message.isFileUploadNotification ? (message.filename ?? '') : filenameFromUrl(message.srcUrl!),
|
||||
message.isFileUploadNotification ?
|
||||
(message.filename ?? '') :
|
||||
filenameFromUrl(message.srcUrl!),
|
||||
radius,
|
||||
maxWidth,
|
||||
sent,
|
||||
extra: DownloadButton(
|
||||
mimeType: message.mediaType,
|
||||
downloadButton: DownloadButton(
|
||||
onPressed: () {
|
||||
MoxplatformPlugin.handler.getDataSender().sendData(
|
||||
RequestDownloadCommand(message: message),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/theme.dart';
|
||||
|
||||
/// This Widget is used to show that a message has been quoted.
|
||||
class QuoteBaseWidget extends StatelessWidget {
|
||||
@@ -20,7 +21,11 @@ class QuoteBaseWidget extends StatelessWidget {
|
||||
final bool sent;
|
||||
final void Function()? resetQuotedMessage;
|
||||
|
||||
Color _getColor() {
|
||||
Color _getColor(BuildContext context) {
|
||||
if (resetQuotedMessage != null) {
|
||||
return Theme.of(context).extension<MoxxyThemeData>()!.bubbleQuoteInTextFieldColor;
|
||||
}
|
||||
|
||||
if (sent) {
|
||||
return bubbleColorSentQuoted;
|
||||
} else {
|
||||
@@ -30,7 +35,6 @@ class QuoteBaseWidget extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const quoteLeftBorderWidth = 7.0;
|
||||
EdgeInsetsGeometry padding = const EdgeInsets.only(
|
||||
left: 8 + quoteLeftBorderWidth,
|
||||
right: 8,
|
||||
@@ -45,39 +49,42 @@ class QuoteBaseWidget extends StatelessWidget {
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Material(
|
||||
color: _getColor(),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(radiusLarge),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
color: Colors.white,
|
||||
width: quoteLeftBorderWidth,
|
||||
),
|
||||
),
|
||||
|
||||
if (resetQuotedMessage != null)
|
||||
Positioned(
|
||||
right: 3,
|
||||
top: 3,
|
||||
child: IconButton(
|
||||
onPressed: resetQuotedMessage,
|
||||
icon: const Icon(
|
||||
Icons.close,
|
||||
size: 24,
|
||||
),
|
||||
child: Material(
|
||||
color: _getColor(context),
|
||||
child: DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
left: BorderSide(
|
||||
color: Colors.white,
|
||||
width: quoteLeftBorderWidth,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: padding,
|
||||
child: child,
|
||||
),
|
||||
],
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: padding,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
|
||||
if (resetQuotedMessage != null)
|
||||
Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: IconButton(
|
||||
onPressed: resetQuotedMessage,
|
||||
icon: const Icon(
|
||||
Icons.close,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
52
lib/ui/widgets/chat/quote/helpers.dart
Normal file
52
lib/ui/widgets/chat/quote/helpers.dart
Normal file
@@ -0,0 +1,52 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/service/data.dart';
|
||||
import 'package:moxxyv2/ui/theme.dart';
|
||||
|
||||
class QuoteSenderText extends StatelessWidget {
|
||||
const QuoteSenderText({
|
||||
required this.sender,
|
||||
required this.resetQuoteNotNull,
|
||||
required this.sent,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The sender JID of the quoted message.
|
||||
final String sender;
|
||||
|
||||
/// True if resetQuote is not null.
|
||||
final bool resetQuoteNotNull;
|
||||
|
||||
/// The sent attribute passed to the quote widget.
|
||||
final bool sent;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sentBySelf = resetQuoteNotNull ?
|
||||
sent :
|
||||
sender == GetIt.I.get<UIDataService>().ownJid;
|
||||
|
||||
return Text(
|
||||
sentBySelf ?
|
||||
t.messages.you :
|
||||
GetIt.I.get<ConversationBloc>().state.conversation!.titleWithOptionalContact,
|
||||
style: const TextStyle(
|
||||
color: bubbleTextQuoteSenderColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Figures out the best text color for quotes. [context] is the surrounding
|
||||
/// BuildContext. [insideTextField] is true if the quote is used as a widget inside
|
||||
/// the TextField.
|
||||
Color getQuoteTextColor(BuildContext context, bool insideTextField) {
|
||||
if (!insideTextField) return bubbleTextQuoteColor;
|
||||
|
||||
return Theme.of(context).extension<MoxxyThemeData>()!.bubbleQuoteInTextFieldTextColor;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/quote/base.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/quote/helpers.dart';
|
||||
|
||||
class QuotedMediaBaseWidget extends StatelessWidget {
|
||||
const QuotedMediaBaseWidget(
|
||||
@@ -28,7 +29,24 @@ class QuotedMediaBaseWidget extends StatelessWidget {
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: Text(text),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
QuoteSenderText(
|
||||
sender: message.sender,
|
||||
resetQuoteNotNull: resetQuote != null,
|
||||
sent: sent,
|
||||
),
|
||||
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: getQuoteTextColor(context, resetQuote != null),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
44
lib/ui/widgets/chat/quote/text.dart
Normal file
44
lib/ui/widgets/chat/quote/text.dart
Normal file
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/quote/base.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/quote/helpers.dart';
|
||||
|
||||
class QuotedTextWidget extends StatelessWidget {
|
||||
const QuotedTextWidget(
|
||||
this.message,
|
||||
this.sent, {
|
||||
this.resetQuote,
|
||||
super.key,
|
||||
}
|
||||
);
|
||||
final Message message;
|
||||
final bool sent;
|
||||
final void Function()? resetQuote;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuoteBaseWidget(
|
||||
message,
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
QuoteSenderText(
|
||||
sender: message.sender,
|
||||
resetQuoteNotNull: resetQuote != null,
|
||||
sent: sent,
|
||||
),
|
||||
|
||||
Text(
|
||||
message.body,
|
||||
style: TextStyle(
|
||||
color: getQuoteTextColor(context, resetQuote != null),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
sent,
|
||||
resetQuotedMessage: resetQuote,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ class TextChatWidget extends StatelessWidget {
|
||||
child: ParsedText(
|
||||
text: getMessageText(),
|
||||
style: TextStyle(
|
||||
color: const Color(0xffffffff),
|
||||
color: bubbleTextColor,
|
||||
fontSize: fontsize,
|
||||
),
|
||||
parse: [
|
||||
|
||||
79
lib/ui/widgets/combined_picker.dart
Normal file
79
lib/ui/widgets/combined_picker.dart
Normal file
@@ -0,0 +1,79 @@
|
||||
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:moxxyv2/shared/models/sticker.dart';
|
||||
import 'package:moxxyv2/shared/models/sticker_pack.dart';
|
||||
import 'package:moxxyv2/ui/bloc/stickers_bloc.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/widgets/sticker_picker.dart';
|
||||
import 'package:phosphor_flutter/phosphor_flutter.dart';
|
||||
|
||||
class CombinedPicker extends StatelessWidget {
|
||||
const CombinedPicker({
|
||||
required this.tabController,
|
||||
required this.onEmojiTapped,
|
||||
required this.onBackspaceTapped,
|
||||
required this.onStickerTapped,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The controlling tab controller
|
||||
final TabController tabController;
|
||||
|
||||
/// Called when an emoji has been tapped from the list.
|
||||
final void Function(Emoji) onEmojiTapped;
|
||||
|
||||
/// Called when the backspace button has been tapped
|
||||
final void Function() onBackspaceTapped;
|
||||
|
||||
/// Called when a sticker has been tapped
|
||||
final void Function(Sticker, StickerPack) onStickerTapped;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<StickersBloc, StickersState>(
|
||||
builder: (context, state) {
|
||||
final scaffoldColor = Theme.of(context).scaffoldBackgroundColor;
|
||||
final width = MediaQuery.of(context).size.width;
|
||||
return SizedBox(
|
||||
height: pickerHeight,
|
||||
width: width,
|
||||
child: ColoredBox(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: Column(
|
||||
children: [
|
||||
TabBar(
|
||||
controller: tabController,
|
||||
indicatorColor: primaryColor,
|
||||
tabs: const [
|
||||
Tab(icon: Icon(Icons.insert_emoticon)),
|
||||
Tab(icon: Icon(PhosphorIcons.stickerBold)),
|
||||
],
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: tabController,
|
||||
children: [
|
||||
EmojiPicker(
|
||||
onEmojiSelected: (_, emoji) => onEmojiTapped(emoji),
|
||||
onBackspacePressed: onBackspaceTapped,
|
||||
config: Config(
|
||||
bgColor: scaffoldColor,
|
||||
),
|
||||
),
|
||||
StickerPicker(
|
||||
width: width,
|
||||
onStickerTapped: onStickerTapped,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -154,19 +154,27 @@ class ConversationsListRowState extends State<ConversationsListRow> {
|
||||
);
|
||||
}
|
||||
} else if (widget.conversation.lastMessage!.mediaType!.startsWith('image/')) {
|
||||
preview = SharedImageWidget(
|
||||
widget.conversation.lastMessage!.mediaUrl!,
|
||||
borderRadius: 5,
|
||||
size: 30,
|
||||
);
|
||||
if (widget.conversation.lastMessage!.mediaUrl == null) {
|
||||
preview = const SizedBox();
|
||||
} else {
|
||||
preview = SharedImageWidget(
|
||||
widget.conversation.lastMessage!.mediaUrl!,
|
||||
borderRadius: 5,
|
||||
size: 30,
|
||||
);
|
||||
}
|
||||
} else if (widget.conversation.lastMessage!.mediaType!.startsWith('video/')) {
|
||||
preview = SharedVideoWidget(
|
||||
widget.conversation.lastMessage!.mediaUrl!,
|
||||
widget.conversation.jid,
|
||||
widget.conversation.lastMessage!.mediaType!,
|
||||
borderRadius: 5,
|
||||
size: 30,
|
||||
);
|
||||
if (widget.conversation.lastMessage!.mediaUrl == null) {
|
||||
preview = const SizedBox();
|
||||
} else {
|
||||
preview = SharedVideoWidget(
|
||||
widget.conversation.lastMessage!.mediaUrl!,
|
||||
widget.conversation.jid,
|
||||
widget.conversation.lastMessage!.mediaType!,
|
||||
borderRadius: 5,
|
||||
size: 30,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Padding(
|
||||
|
||||
@@ -12,6 +12,10 @@ class SettingsRow extends StatelessWidget {
|
||||
this.prefix,
|
||||
this.onTap,
|
||||
this.crossAxisAlignment = CrossAxisAlignment.center,
|
||||
this.padding = const EdgeInsets.symmetric(
|
||||
vertical: 16,
|
||||
horizontal: 16,
|
||||
),
|
||||
super.key,
|
||||
});
|
||||
final String title;
|
||||
@@ -21,16 +25,14 @@ class SettingsRow extends StatelessWidget {
|
||||
final Widget? prefix;
|
||||
final void Function()? onTap;
|
||||
final CrossAxisAlignment crossAxisAlignment;
|
||||
final EdgeInsets padding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 16,
|
||||
horizontal: 16,
|
||||
),
|
||||
padding: padding,
|
||||
child: Row(
|
||||
crossAxisAlignment: crossAxisAlignment,
|
||||
children: [
|
||||
|
||||
@@ -131,17 +131,9 @@ class StickerPicker extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<StickersBloc, StickersState>(
|
||||
builder: (context, state) {
|
||||
|
||||
return SizedBox(
|
||||
height: 250,
|
||||
width: MediaQuery.of(context).size.width,
|
||||
child: ColoredBox(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: _buildList(context, state),
|
||||
),
|
||||
),
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: _buildList(context, state),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ class CustomTextField extends StatelessWidget {
|
||||
this.errorText,
|
||||
this.labelText,
|
||||
this.hintText,
|
||||
this.hintTextColor,
|
||||
this.suffix,
|
||||
this.suffixText,
|
||||
this.topWidget,
|
||||
@@ -31,6 +32,7 @@ class CustomTextField extends StatelessWidget {
|
||||
this.onTap,
|
||||
this.shouldSummonKeyboard,
|
||||
this.focusNode,
|
||||
this.fontSize,
|
||||
super.key,
|
||||
});
|
||||
final double cornerRadius;
|
||||
@@ -54,8 +56,10 @@ class CustomTextField extends StatelessWidget {
|
||||
final int minLines;
|
||||
final Color? backgroundColor;
|
||||
final Color? textColor;
|
||||
final Color? hintTextColor;
|
||||
final double? borderWidth;
|
||||
final Color? borderColor;
|
||||
final double? fontSize;
|
||||
final TextEditingController? controller;
|
||||
final ValueChanged<String>? onChanged;
|
||||
final void Function()? onTap;
|
||||
@@ -64,7 +68,12 @@ class CustomTextField extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = textColor != null ? TextStyle(color: textColor) : null;
|
||||
final style = textColor != null ?
|
||||
TextStyle(
|
||||
color: textColor,
|
||||
fontSize: fontSize,
|
||||
) :
|
||||
null;
|
||||
return Column(
|
||||
children: [
|
||||
DecoratedBox(
|
||||
@@ -105,7 +114,10 @@ class CustomTextField extends StatelessWidget {
|
||||
isDense: isDense,
|
||||
labelStyle: style,
|
||||
suffixStyle: style,
|
||||
hintStyle: style,
|
||||
hintStyle: TextStyle(
|
||||
color: hintTextColor,
|
||||
fontSize: fontSize,
|
||||
),
|
||||
prefixIcon: prefixIcon,
|
||||
prefixIconConstraints: prefixIconConstraints,
|
||||
suffixIconConstraints: suffixIconConstraints,
|
||||
|
||||
36
pubspec.lock
36
pubspec.lock
@@ -337,13 +337,6 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
dio:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dio
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "4.0.6"
|
||||
emoji_picker_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -836,16 +829,18 @@ packages:
|
||||
description:
|
||||
path: "packages/moxxmpp"
|
||||
ref: HEAD
|
||||
resolved-ref: d64220426bb70adf28628b9670330630914ddd47
|
||||
resolved-ref: "6c63b53cf4870bc1303b1a2df835d5b67a9b88c4"
|
||||
url: "https://git.polynom.me/Moxxy/moxxmpp.git"
|
||||
source: git
|
||||
version: "0.1.6+1"
|
||||
moxxmpp_socket_tcp:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: moxxmpp_socket_tcp
|
||||
url: "https://git.polynom.me/api/packages/Moxxy/pub/"
|
||||
source: hosted
|
||||
path: "packages/moxxmpp_socket_tcp"
|
||||
ref: HEAD
|
||||
resolved-ref: a8d80eaddf8784532d88442d74202a47af7de047
|
||||
url: "https://git.polynom.me/Moxxy/moxxmpp.git"
|
||||
source: git
|
||||
version: "0.1.2+9"
|
||||
moxxyv2_builders:
|
||||
dependency: "direct main"
|
||||
@@ -885,12 +880,10 @@ packages:
|
||||
omemo_dart:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: HEAD
|
||||
resolved-ref: fe1ba99b14b516ecf6d147c57bd986d8afe7f3fc
|
||||
url: "https://codeberg.org/PapaTutuWawa/omemo_dart.git"
|
||||
source: git
|
||||
version: "0.3.2"
|
||||
name: omemo_dart
|
||||
url: "https://git.polynom.me/api/packages/PapaTutuWawa/pub/"
|
||||
source: hosted
|
||||
version: "0.4.2"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1150,13 +1143,6 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
settings_ui:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: settings_ui
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
share_handler:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1606,5 +1592,5 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
sdks:
|
||||
dart: ">=2.17.5 <3.0.0"
|
||||
dart: ">=2.17.0 <3.0.0"
|
||||
flutter: ">=3.3.8"
|
||||
|
||||
26
pubspec.yaml
26
pubspec.yaml
@@ -3,7 +3,7 @@ description: An experimental XMPP client
|
||||
|
||||
publish_to: 'none'
|
||||
|
||||
version: 0.3.0+5
|
||||
version: 0.4.1+9
|
||||
|
||||
environment:
|
||||
sdk: ">=2.17.0 <3.0.0"
|
||||
@@ -23,7 +23,6 @@ dependencies:
|
||||
#cupertino_icons: 1.0.2
|
||||
dart_emoji: 0.2.0+2
|
||||
decorated_icon: 1.2.1
|
||||
dio: 4.0.6
|
||||
emoji_picker_flutter: 1.3.1
|
||||
external_path: 1.0.1
|
||||
file_picker: 5.0.1
|
||||
@@ -75,7 +74,7 @@ dependencies:
|
||||
native_imaging: 0.1.0
|
||||
omemo_dart:
|
||||
hosted: https://git.polynom.me/api/packages/PapaTutuWawa/pub
|
||||
version: 0.3.1
|
||||
version: 0.4.2
|
||||
page_transition: 2.0.9
|
||||
path: 1.8.2
|
||||
path_provider: 2.0.11
|
||||
@@ -84,7 +83,6 @@ dependencies:
|
||||
qr_flutter: 4.0.0
|
||||
random_string: 2.3.1
|
||||
record: 4.4.3
|
||||
settings_ui: 2.0.2
|
||||
share_handler: 0.0.16
|
||||
slang: 3.4.0
|
||||
slang_flutter: 3.4.0
|
||||
@@ -133,21 +131,23 @@ dependency_overrides:
|
||||
version: 3.3.8
|
||||
|
||||
# NOTE: Leave here for development purposes
|
||||
#moxxmpp:
|
||||
# path: ../moxxmpp/packages/moxxmpp
|
||||
#omemo_dart:
|
||||
# path: ../../Personal/omemo_dart$
|
||||
# moxxmpp:
|
||||
# path: ../moxxmpp/packages/moxxmpp
|
||||
# moxxmpp_socket_tcp:
|
||||
# path: ../moxxmpp/packages/moxxmpp_socket_tcp
|
||||
# omemo_dart:
|
||||
# path: ../../Personal/omemo_dart
|
||||
|
||||
moxxmpp:
|
||||
git:
|
||||
url: https://git.polynom.me/Moxxy/moxxmpp.git
|
||||
rev: d64220426bb70adf28628b9670330630914ddd47
|
||||
rev: 6c63b53cf4870bc1303b1a2df835d5b67a9b88c4
|
||||
path: packages/moxxmpp
|
||||
|
||||
omemo_dart:
|
||||
moxxmpp_socket_tcp:
|
||||
git:
|
||||
url: https://codeberg.org/PapaTutuWawa/omemo_dart.git
|
||||
rev: fe1ba99b14b516ecf6d147c57bd986d8afe7f3fc
|
||||
url: https://git.polynom.me/Moxxy/moxxmpp.git
|
||||
rev: 1aa50699adfa975831a52c737a7c63c54083bd9c
|
||||
path: packages/moxxmpp_socket_tcp
|
||||
|
||||
extra_licenses:
|
||||
- name: undraw.co
|
||||
|
||||
17
scripts/build.sh
Normal file
17
scripts/build.sh
Normal file
@@ -0,0 +1,17 @@
|
||||
#!/bin/bash
|
||||
[[ $1 = "--clean" ]] && flutter clean
|
||||
|
||||
# Build everything again
|
||||
flutter pub run build_runner build
|
||||
|
||||
# Build the release apk
|
||||
flutter build apk \
|
||||
--release \
|
||||
--split-per-abi
|
||||
|
||||
# Create a folder with releases
|
||||
[[ -d ./release ]] && rm -rf ./release
|
||||
mkdir release
|
||||
cp build/app/outputs/flutter-apk/app-arm64-v8a-release.apk ./release/moxxy-arm64-v8a-release.apk
|
||||
cp build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk ./release/moxxy-armeabi-v7a-release.apk
|
||||
cp build/app/outputs/flutter-apk/app-x86_64-release.apk ./release/moxxy-x86_64-release.apk
|
||||
Reference in New Issue
Block a user