75 Commits

Author SHA1 Message Date
241a8b4d53 release(meta): Release 0.4.1 2023-01-18 21:39:35 +01:00
25d193e930 feat(meta): Add a build script 2023-01-18 21:19:20 +01:00
e6924cc02d fix(ui): Rearange the settings page a bit 2023-01-18 20:19:34 +01:00
60985c6b37 feat(ui): Hide testing commands outside of debug mode 2023-01-18 20:14:26 +01:00
a015399b57 fix(ui): Allow users to unlock the developer options
Fixes #211.
2023-01-18 20:10:01 +01:00
4b6c7998f3 fix(meta): Sharing now also works when the app is closed
Fixes #218.
2023-01-18 15:05:56 +01:00
26312e313f feat(meta): Bump moxxmpp 2023-01-15 00:55:47 +01:00
b63b5d7fd2 fix(service): Fix stanza correlation when from is missing
Fixed by bumping moxxmpp.
2023-01-14 16:30:22 +01:00
ca2943a94d feat(ui): Hide the speed dial when recording an audio message 2023-01-14 12:59:41 +01:00
32a4cd9361 feat(meta): Bump moxxmpp and moxxmpp_socket_tcp 2023-01-14 12:57:35 +01:00
2320e4ed17 fix(service): Remove weird newline 2023-01-14 12:44:46 +01:00
dee479a918 fix(ui): Move the DragTargets more to the left 2023-01-13 23:05:15 +01:00
6895ef1e32 feat(ui): Move the send button back to a speed dial
This makes the voice message UX more like what Signal and co. do.
Also makes the message TextField less crowded. Kind of fixes #207.
2023-01-13 23:03:02 +01:00
5c51eefa3e fix(i18n): Add missing string 2023-01-13 18:58:12 +01:00
0d7ae321a7 feat(ui): Improve the look of the message input field 2023-01-13 18:55:16 +01:00
b4063a64e0 fix(service): Await future 2023-01-13 18:20:04 +01:00
65154f2f5c feat(ui): Rework the file widget 2023-01-13 18:18:22 +01:00
19a22bd0d1 fix(ui): Fix text overflow for the file widget 2023-01-13 17:59:00 +01:00
a7da7baf5a feat(meta): Bump moxxmpp 2023-01-13 17:48:50 +01:00
a344a94112 fix(xmpp): Fix quotes being cut off
Fixes #203.
2023-01-13 13:41:37 +01:00
f44861fead feat(ui): The quote bubble base only depends on the surrounding bubble
Also adds an indicator as to who send a message. Fixes #213.
2023-01-13 00:49:56 +01:00
1c4a30ebb4 fix(ui): Show a text when no sticker packs are installed 2023-01-12 23:53:27 +01:00
70e2ca3d3e fix(ui): Fix some non-occurences of pickerHeight 2023-01-12 23:46:37 +01:00
0d4aee1625 feat(ui): Merge the emoji and the sticker picker
Fixes #209.
2023-01-12 23:44:31 +01:00
ad6aa33b7c fix(ui): Date bubbles' text is always black 2023-01-12 21:18:59 +01:00
284b5fa4df feat(ui): Make the bottom backdrop transparent 2023-01-12 21:16:14 +01:00
b9aac0c3d7 fix(service): Fix file uploads and downloads 2023-01-12 21:09:19 +01:00
6ce90e08ef fix(ui): NPE when a media message has not been downloaded 2023-01-11 17:49:04 +01:00
5ac80d8d60 fix(ui): Fix smaller code issues 2023-01-11 17:48:57 +01:00
56e1fa52d8 feat(ui): Make quotes look nicer 2023-01-11 17:44:51 +01:00
3ae1b7d168 fix(ui): Improve the contrast of the fallback avatar letters
Fixes #206.
2023-01-11 17:17:25 +01:00
d8f654c81c feat(ui): Remove the shadow of the TextField
Fixes #208.
2023-01-11 16:56:49 +01:00
cbcbd4d6dc fix(ui): Remove the use of a Stack inside the quote base
This makes the code feel nicer and also fixes #204 since Flutter
can now use the IconButton's dimensions for layouting and size
computations.
2023-01-10 18:15:58 +01:00
be899b5611 feat(ui): Small color improvements 2023-01-10 17:47:16 +01:00
361bbe8d85 fix(meta): Bump moxxmpp to fix SM 2023-01-09 13:49:43 +01:00
1e017af277 fix(service): Fix only the first roster item being added to the database 2023-01-07 22:23:50 +01:00
c4c22a36bb fix(service): Fix OMEMO device generation 2023-01-07 20:53:29 +01:00
84924b480b feat(service): Call omemo_dart's onNewConnection 2023-01-05 15:22:30 +01:00
358074f4ee fix(service): Generating OMEMO keys failed 2023-01-05 12:39:41 +01:00
084314fbcf fix(ui): Fix version number 2023-01-05 12:36:58 +01:00
c42f301ae0 fix(ui): Fix using the wrong color in text quotes
Fixes #196.
2023-01-05 12:36:03 +01:00
c8cd37e451 release: Tag version 0.4.0 2023-01-02 21:13:08 +01:00
9f8f3a5407 fix(meta): Fix fresh/migrated version hickups 2023-01-02 21:11:49 +01:00
6f1493808f feat(ui): Move the bubbles into their own directory 2023-01-02 19:01:55 +01:00
c9d32694db fix(i18n): Translate forgotten strings 2023-01-02 18:59:28 +01:00
8632a2fc81 fix(ui): Finally fix message bubbles? 2023-01-02 18:59:08 +01:00
46a09d5b62 feat(service): Manage sticker pack privacy
Fixes #192.
2023-01-02 18:04:27 +01:00
b7e5bbc7d2 fix(service): Fix avatars sometimes being not available 2023-01-02 17:38:22 +01:00
ed264f0c16 fix(service): Fix 'ghost' devices appearing 2023-01-02 17:19:23 +01:00
f1820575ad feat(ui): Show the ink splash on new device messages 2023-01-02 17:12:50 +01:00
d2e42d0a3c feat(meta): Show a message if a contact adds a new device 2023-01-02 15:19:08 +01:00
842cf5aaaa fix(service): Maybe fix avatar fetching crashes 2023-01-02 14:02:13 +01:00
c8f727e982 feat(meta): Update moxxmpp and omemo_dart 2023-01-02 14:00:21 +01:00
fd3c9190de Merge pull request 'Migrate to OmemoManager API' (#194) from feat/omemo-improvement into master
Reviewed-on: https://codeberg.org/moxxy/moxxyv2/pulls/194
2023-01-01 19:09:17 +00:00
69439d2b13 feat(meta): Remove commented-out omemo_dart override 2023-01-01 19:36:58 +01:00
6d41fee73f feat(meta): Lockfile update 2023-01-01 18:24:13 +01:00
0de99adeed fix(service): Adjust to new omemo_dart API 2023-01-01 18:22:41 +01:00
f71fd7c82c feat(meta): Update omemo_dart and moxxmpp 2023-01-01 18:22:25 +01:00
0a6b0b8fa5 feat(service): Migrate to new omemo_dart design 2023-01-01 15:02:35 +01:00
5e0ce8f098 fix(ui): Only show stickers that are images 2022-12-27 12:51:23 +01:00
9fc5989bd4 feat(ui): Add an assertion for adding a contact 2022-12-25 13:20:13 +01:00
cbe81861a5 fix(service): Fix avatars being empty when OMEMO is enabled 2022-12-25 13:09:24 +01:00
76a03cc2fa feat(service): Rework the blocklist service
Maybe fixes #14.
2022-12-25 01:25:12 +01:00
3774760548 fix(service): Fix missing type 2022-12-24 22:33:54 +01:00
4b1942b949 fix(ui): Move the date bubbles out of the chat bubble 2022-12-23 14:45:32 +01:00
Millesimus
2f03c02b58 fix(service): Remove unnecessary dio error handling. 2022-12-22 20:28:26 +01:00
Millesimus
639143934f Chore(service): A little tidying. 2022-12-22 20:28:26 +01:00
Millesimus
81bbbcd8e4 Fix(service): Re-enable progress indication using a completer. 2022-12-22 20:28:26 +01:00
Millesimus
bedd46756d Fix(service): flatten memory usage of downloads (sacrificing download progress indication). 2022-12-22 20:28:26 +01:00
Millesimus
bb6b342d82 Fix(service): flatten memory usage of uploads. 2022-12-22 20:28:17 +01:00
b6eb12cf30 feat(ui): Replace settings_ui with custom UI elements 2022-12-22 13:39:07 +01:00
80f8129011 feat(ui): Fix dialog corner radius and make barrier dismissible 2022-12-22 12:20:57 +01:00
86daad2455 feat(ui): Replace the modal with a dialog 2022-12-22 00:49:01 +01:00
e71cbd5ba9 fix(ui): Translate the "Shared media" in the profile
Also left-align the title.
2022-12-22 00:22:06 +01:00
c0fb9beef7 feat(ui): Replace the squircles with a simple list 2022-12-22 00:16:14 +01:00
92 changed files with 3350 additions and 2153 deletions

3
.gitignore vendored
View File

@@ -60,3 +60,6 @@ lib/i18n/*.dart
# Android artifacts
.android
# Build scripts
release/

View File

@@ -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]

View File

@@ -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",
@@ -154,15 +185,14 @@
"confirmBody": "One or more chats are unencrypted. This means that the file will be leaked to the server. Do you still want to continue?"
},
"profile": {
"self": {
"devices": "Devices"
"general": {
"omemo": "Security"
},
"conversation": {
"muteChatTooltip": "Mute chat",
"unmuteChatTooltip": "Unmute chat",
"muteChat": "Mute",
"unmuteChat": "Unmute",
"devices": "Devices"
"notifications": "Notifications",
"notificationsMuted": "Muted",
"notificationsEnabled": "Enabled",
"sharedMedia": "Media"
},
"owndevices": {
"title": "Own Devices",
@@ -214,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",
@@ -265,6 +299,7 @@
"automaticDownloadsText": "Moxxy will automatically download files on...",
"automaticDownloadsMaximumSize": "Maximum Download Size",
"automaticDownloadsMaximumSizeSubtext": "The maximum file size for a file to be automatically downloaded",
"automaticDownloadAlways": "Always",
"wifi": "Wifi",
"mobileData": "Mobile data"
},
@@ -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",

View File

@@ -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",
@@ -154,15 +185,14 @@
"confirmBody": "Einer oder mehr Chats sind unverschlüsselt. Das bedeutet, dass die Dateien dem Server unverschlüsselt vorliegen. Dateien trotzdem senden?"
},
"profile": {
"self": {
"devices": "Geräte"
"general": {
"omemo": "Sicherheit"
},
"conversation": {
"muteChatTooltip": "Chat stummschalten",
"unmuteChatTooltip": "Chat lautstellen",
"muteChat": "Stummschalten",
"unmuteChat": "Lautstellen",
"devices": "Geräte"
"notifications": "Benachrichtigungen",
"notificationsMuted": "Stumm",
"notificationsEnabled": "Eingeschaltet",
"sharedMedia": "Medien"
},
"owndevices": {
"title": "Eigene Geräte",
@@ -214,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",
@@ -265,6 +299,7 @@
"automaticDownloadsText": "Moxxy läd Dateien automatisch herunter, wenn verbunden mit...",
"automaticDownloadsMaximumSize": "Maximale Downloadgröße",
"automaticDownloadsMaximumSizeSubtext": "Die maximale Dateigröße, die automatisch heruntergeladen werden soll",
"automaticDownloadAlways": "Immer",
"wifi": "Wifi",
"mobileData": "Mobile Daten"
},
@@ -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",

View 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

View File

@@ -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>

View File

@@ -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"

View File

@@ -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

View File

@@ -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));
}
}

View File

@@ -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();
}
}

View File

@@ -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;

View File

@@ -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(),
);
}

View File

@@ -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();
}
}

View 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
);
''',
);
}

View 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;',
);
}

View 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(),
);
}

View 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(),
);
}

View File

@@ -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,
);
}

View 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;
}
}

View File

@@ -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,
);
}

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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

View File

@@ -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...');

View File

@@ -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,
),
);
}
}

View File

@@ -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);
}

View File

@@ -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();
}
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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(

View File

@@ -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 {

View File

@@ -1,5 +1,4 @@
import 'dart:io';
import 'package:path/path.dart' as pathlib;
import 'package:path_provider/path_provider.dart';

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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/');
}

View File

@@ -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,

View File

@@ -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(

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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,
),
);
}

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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(

View File

@@ -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;
}

View File

@@ -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';

View File

@@ -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),
),

View File

@@ -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,
),
),
),

View File

@@ -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,
),
),
),
);

View File

@@ -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(

View File

@@ -1,15 +1,13 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/models/conversation.dart';
import 'package:moxxyv2/ui/bloc/devices_bloc.dart';
import 'package:moxxyv2/ui/bloc/profile_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/helpers.dart';
import 'package:moxxyv2/ui/widgets/avatar.dart';
import 'package:moxxyv2/ui/widgets/chat/shared/base.dart';
import 'package:moxxyv2/ui/widgets/contact_helper.dart';
import 'package:moxxyv2/ui/widgets/profile/options.dart';
//import 'package:phosphor_flutter/phosphor_flutter.dart';
class ConversationProfileHeader extends StatelessWidget {
@@ -81,124 +79,97 @@ class ConversationProfileHeader extends StatelessWidget {
),
),
),
Padding(
padding: const EdgeInsets.only(top: 16),
child: Row(
//mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Tooltip(
message: conversation.muted ?
t.pages.profile.conversation.unmuteChatTooltip :
t.pages.profile.conversation.muteChatTooltip,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SharedMediaContainer(
Icon(
conversation.muted ?
Icons.do_not_disturb_on :
Icons.do_not_disturb_off,
size: 32,
),
color: getTileColor(context),
onTap: () {
GetIt.I.get<ProfileBloc>().add(
MuteStateSetEvent(
conversation.jid,
!conversation.muted,
),
);
},
),
Text(
conversation.muted ?
t.pages.profile.conversation.unmuteChat :
t.pages.profile.conversation.muteChat,
style: const TextStyle(
fontSize: fontsizeAppbar,
),
),
],
),
),
// TODO(PapaTutuWawa): Only show when the chat partner has OMEMO keys
Tooltip(
message: t.pages.profile.conversation.devices,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
SharedMediaContainer(
const Icon(
Icons.security_outlined,
size: 32,
),
color: getTileColor(context),
onTap: () {
GetIt.I.get<DevicesBloc>().add(DevicesRequestedEvent(conversation.jid));
},
),
Text(
t.pages.profile.conversation.devices,
style: const TextStyle(
fontSize: fontsizeAppbar,
),
),
],
),
),
// TODO(Unknown): How to integrate this into the UI?
/*
Tooltip(
message: subscribed ?
'Unsubscribe' :
'Subscribe',
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SharedMediaContainer(
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: ColoredBox(
color: getTileColor(context),
child: Icon(
subscribed ?
PhosphorIcons.link :
PhosphorIcons.linkBreak,
size: 32,
),
),
),
onTap: () {
GetIt.I.get<ProfileBloc>().add(
SetSubscriptionStateEvent(
conversation.jid,
!subscribed,
),
);
},
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
subscribed ?
'Unsubscribe' :
'Subscribe',
style: TextStyle(
fontSize: fontsizeAppbar,
),
),
Icon(Icons.info),
],
),
],
Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(
top: 16,
left: 64,
right: 64,
),
child: ProfileOptions(
options: [
ProfileOption(
icon: Icons.security_outlined,
title: t.pages.profile.general.omemo,
onTap: () {
context.read<DevicesBloc>().add(
DevicesRequestedEvent(conversation.jid),
);
},
),
),*/
],
ProfileOption(
icon: conversation.muted ?
Icons.notifications_off :
Icons.notifications,
title: t.pages.profile.conversation.notifications,
description: conversation.muted ?
t.pages.profile.conversation.notificationsMuted :
t.pages.profile.conversation.notificationsEnabled,
onTap: () {
context.read<ProfileBloc>().add(
MuteStateSetEvent(
conversation.jid,
!conversation.muted,
),
);
},
),
],
),
),
),
/*
// TODO(Unknown): How to integrate this into the UI?
Tooltip(
message: subscribed ?
'Unsubscribe' :
'Subscribe',
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SharedMediaContainer(
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: ColoredBox(
color: getTileColor(context),
child: Icon(
subscribed ?
PhosphorIcons.link :
PhosphorIcons.linkBreak,
size: 32,
),
),
),
onTap: () {
GetIt.I.get<ProfileBloc>().add(
SetSubscriptionStateEvent(
conversation.jid,
!subscribed,
),
);
},
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
subscribed ?
'Unsubscribe' :
'Subscribe',
style: TextStyle(
fontSize: fontsizeAppbar,
),
),
Icon(Icons.info),
],
),
],
),
),*/
],
);
}

View File

@@ -48,14 +48,11 @@ class ProfilePage extends StatelessWidget {
child: _buildHeader(context, state),
),
// TODO(Unknown): Maybe don't show this conditionally but always
Visibility(
visible: !state.isSelfProfile && state.conversation!.sharedMedia.isNotEmpty,
child: state.isSelfProfile ? const SizedBox() : SharedMediaDisplay(
if (!state.isSelfProfile && state.conversation!.sharedMedia.isNotEmpty)
SharedMediaDisplay(
state.conversation!.sharedMedia,
state.conversation!.jid,
),
)
],
),
Positioned(

View File

@@ -1,11 +1,10 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/ui/bloc/own_devices_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/helpers.dart';
import 'package:moxxyv2/ui/widgets/avatar.dart';
import 'package:moxxyv2/ui/widgets/chat/shared/base.dart';
import 'package:moxxyv2/ui/widgets/profile/options.dart';
class SelfProfileHeader extends StatelessWidget {
const SelfProfileHeader(
@@ -79,36 +78,27 @@ class SelfProfileHeader extends StatelessWidget {
],
),
),
Padding(
padding: const EdgeInsets.only(top: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Tooltip(
message: t.pages.profile.self.devices,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
SharedMediaContainer(
const Icon(
Icons.security_outlined,
size: 32,
),
color: getTileColor(context),
onTap: () {
GetIt.I.get<OwnDevicesBloc>().add(OwnDevicesRequestedEvent());
},
),
Text(
t.pages.profile.self.devices,
style: const TextStyle(
fontSize: fontsizeAppbar,
),
),
],
Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(
top: 16,
left: 64,
right: 64,
),
child: ProfileOptions(
options: [
ProfileOption(
icon: Icons.security_outlined,
title: t.pages.profile.general.omemo,
onTap: () {
context.read<OwnDevicesBloc>().add(
OwnDevicesRequestedEvent(),
);
},
),
),
],
],
),
),
),
],

View File

@@ -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(

View File

@@ -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);
}
},
),
],
),

View File

@@ -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),
),
),
),
],
);
},
),
),
],
),

View File

@@ -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();
},
)
],
),
);
},
)
],
)
),
);
},
),
] : [],
],
),
),

View File

@@ -4,11 +4,11 @@ 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);
final int value;
final String text;
@@ -19,9 +19,77 @@ const _autoDownloadSizes = <_AutoDownloadSizes>[
_AutoDownloadSizes('5MB', 5),
_AutoDownloadSizes('15MB', 15),
_AutoDownloadSizes('100MB', 100),
_AutoDownloadSizes('Always', -1),
_AutoDownloadSizes('', -1),
];
class AutoDownloadSizeDialog extends StatefulWidget {
const AutoDownloadSizeDialog({
required this.selectedValueInitial,
super.key,
});
final int selectedValueInitial;
@override
AutoDownloadSizeDialogState createState() => AutoDownloadSizeDialogState();
}
class AutoDownloadSizeDialogState extends State<AutoDownloadSizeDialog> {
int selection = -1;
@override
void initState() {
super.initState();
selection = widget.selectedValueInitial;
}
@override
Widget build(BuildContext context) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(textfieldRadiusRegular),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 12,
),
content: SingleChildScrollView(
child: Table(
defaultVerticalAlignment: TableCellVerticalAlignment.middle,
children: _autoDownloadSizes
.map((size) => TableRow(
children: [
Text(
size.value == -1 ?
t.pages.settings.network.automaticDownloadAlways :
size.text,
),
Checkbox(
value: size.value == selection,
onChanged: (value) {
if (size.value == selection) return;
setState(() => selection = size.value);
},
),
],
),).toList(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(selection),
child: Text(t.global.dialogAccept),
),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(t.global.dialogCancel),
),
],
);
}
}
class NetworkPage extends StatelessWidget {
const NetworkPage({ super.key });
@@ -32,95 +100,71 @@ class NetworkPage extends StatelessWidget {
),
);
Widget _buildFileSizeListItem(BuildContext context, String text, int value, bool selected) {
final textTheme = Theme.of(context).textTheme.subtitle2;
return TextButton(
onPressed: () {
Navigator.of(context).pop();
final bloc = context.read<PreferencesBloc>();
bloc.add(
PreferencesChangedEvent(
bloc.state.copyWith(maximumAutoDownloadSize: value),
),
);
},
child: selected
? IntrinsicWidth(
child: Row(
children: [
Text(text, style: textTheme),
Padding(
padding: const EdgeInsets.only(left: 8),
child: Icon(
Icons.check,
color: textTheme!.color,
),
)
],
),
)
: Text(text, style: textTheme),
);
}
@override
Widget build(BuildContext context) {
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),
),
builder: (context, state) => ListView(
children: [
SectionTitle(t.pages.settings.network.automaticDownloadsSection),
SettingsRow(
title: t.pages.settings.network.automaticDownloadsText,
),
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),
),
),
SettingsTile.switchTile(
title: Text(t.pages.settings.network.mobileData),
initialValue: state.autoDownloadMobile,
onToggle: (value) => context.read<PreferencesBloc>().add(
PreferencesChangedEvent(
state.copyWith(autoDownloadMobile: 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),
),
),
SettingsTile(
title: Text(t.pages.settings.network.automaticDownloadsMaximumSize),
description: Text(t.pages.settings.network.automaticDownloadsMaximumSizeSubtext),
onPressed: (context) {
showModalBottomSheet<dynamic>(
context: context,
builder: (BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: ListView.builder(
shrinkWrap: true,
itemCount: _autoDownloadSizes.length,
itemBuilder: (BuildContext context, int index) => _buildFileSizeListItem(
context,
_autoDownloadSizes[index].text,
_autoDownloadSizes[index].value,
_autoDownloadSizes[index].value == state.maximumAutoDownloadSize,
),
),
);
},
);
},
),
],
)
),
),
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),
),
);
},
),
],
),
),

View File

@@ -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),
),
],
),

View File

@@ -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) {

View File

@@ -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),
)
],
)
] : []
],
],
),
),
);
}

View File

@@ -91,6 +91,11 @@ class StickersSettingsPage extends StatelessWidget {
),
SectionTitle(t.pages.settings.stickers.stickerPacksSection),
if (stickers.stickerPacks.isEmpty)
SettingsRow(
title: t.pages.conversation.stickerPickerNoStickersLine1,
),
],
);
}

View File

@@ -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(

View 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;
}
}

View File

@@ -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,
),
],
);
}

View File

@@ -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),
);
}
}

View File

@@ -18,7 +18,12 @@ class DateBubble extends StatelessWidget {
horizontal: 8,
vertical: 6,
),
child: Text(value),
child: Text(
value,
style: const TextStyle(
color: Colors.white,
),
),
),
),
);

View 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,
),
),
),
),
),
);
}
}

View File

@@ -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);
}
}

View File

@@ -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),

View File

@@ -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!);
},

View File

@@ -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),

View File

@@ -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:

View File

@@ -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),

View File

@@ -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,
),
),
),
],
),
),
),
),
);

View 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;
}

View File

@@ -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),
),
),
],
),
),
],
),

View 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,
);
}
}

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/models/media.dart';
import 'package:moxxyv2/ui/widgets/chat/media/media.dart';
import 'package:moxxyv2/ui/widgets/chat/shared/summary.dart';
@@ -36,14 +37,17 @@ class SharedMediaDisplay extends StatelessWidget {
final padding = 0.5 * (width - 15 - 300);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.only(top: 25),
Padding(
padding: EdgeInsets.only(
top: 25,
left: padding,
right: padding,
),
child: Text(
'Shared Media',
style: TextStyle(
fontSize: 25,
),
t.pages.profile.conversation.sharedMedia,
style: Theme.of(context).textTheme.headline5,
),
),
Padding(

View File

@@ -56,7 +56,7 @@ class TextChatWidget extends StatelessWidget {
child: ParsedText(
text: getMessageText(),
style: TextStyle(
color: const Color(0xffffffff),
color: bubbleTextColor,
fontSize: fontsize,
),
parse: [

View 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,
),
],
),
),
],
),
),
);
},
);
}
}

View File

@@ -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(

View File

@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
class ProfileOption {
const ProfileOption({
required this.icon,
required this.title,
required this.onTap,
this.description,
});
final IconData icon;
final String title;
final String? description;
final void Function() onTap;
}
class ProfileOptions extends StatelessWidget {
const ProfileOptions({
required this.options,
super.key,
});
final List<ProfileOption> options;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: options
.map((option) => InkWell(
onTap: option.onTap,
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 16,
horizontal: 8,
),
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 16),
child: Icon(
option.icon,
size: 32,
),
),
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
option.title,
style: Theme.of(context).textTheme.headline6,
),
if (option.description != null)
Text(
option.description!,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
],
),
),
),).toList(),
);
}
}

View File

@@ -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: [

View File

@@ -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),
);
},
);

View File

@@ -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,

View File

@@ -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"

View File

@@ -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
View 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