50 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
63 changed files with 2027 additions and 1372 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": {
@@ -133,7 +159,11 @@
"stickerPickerNoStickersLine1": "You have no sticker packs installed.",
"stickerPickerNoStickersLine2": "They can be installed in the sticker settings.",
"stickerSettings": "Sticker settings",
"newDeviceMessage": "${title} added a new encryption device"
"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",
@@ -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",
@@ -291,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": {
@@ -133,7 +159,11 @@
"stickerPickerNoStickersLine1": "Du hast keine Stickerpacks installiert.",
"stickerPickerNoStickersLine2": "Diese können in den Stickereinstellungen installiert werden.",
"stickerSettings": "Stickereinstellungen",
"newDeviceMessage": "${title} hat ein neues Verschlüsselungsgerät hinzugefügt"
"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",
@@ -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",
@@ -291,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

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

@@ -46,42 +46,27 @@ class AvatarService {
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.
if (originalConversation == null && originalRoster == null) return;
final avatarPath = await saveAvatarInCache(
data,
hash,
jid,
(originalConversation?.avatarUrl ?? originalRoster?.avatarUrl)!,
);
if (originalConversation != null) {
final avatarPath = await saveAvatarInCache(
data,
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(
data,
hash,
jid,
originalRoster.avatarUrl,
);
}
final roster = await rs.updateRosterItem(
originalRoster.id,
avatarUrl: avatarPath,
@@ -176,12 +161,20 @@ class AvatarService {
// Publish data and metadata
final am = GetIt.I.get<XmppConnection>()
.getManagerById<UserAvatarManager>(userAvatarManager)!;
await am.publishUserAvatar(
_log.finest('Publishing avatar...');
final dataResult = await am.publishUserAvatar(
base64,
hash,
public,
);
await am.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,
@@ -192,7 +185,12 @@ class AvatarService {
),
public,
);
if (metadataResult.isType<AvatarError>()) {
_log.finest('Avatar metadata publishing failed');
return false;
}
_log.finest('Avatar publishing done');
return true;
}

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';
@@ -59,7 +60,7 @@ Future<void> createDatabase(Database db, int version) async {
stickerHashKey TEXT,
pseudoMessageType INTEGER,
pseudoMessageData TEXT,
CONSTRAINT fk_quote FOREIGN KEY (quote_id) REFERENCES $messagesTable (id),
CONSTRAINT fk_quote FOREIGN KEY (quote_id) REFERENCES $messagesTable (id)
)''',
);
@@ -419,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

@@ -30,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';
@@ -81,7 +83,7 @@ class DatabaseService {
_db = await openDatabase(
dbPath,
password: key,
version: 24,
version: 26,
onCreate: createDatabase,
onConfigure: (db) async {
// In order to do schema changes during database upgrades, we disable foreign
@@ -186,6 +188,14 @@ class DatabaseService {
_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);
}
},
);

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

@@ -348,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' ?

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 = file.openRead();
final stat = file.statSync();
// Request the upload slot
@@ -200,119 +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',
),
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();
@@ -348,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;
@@ -358,202 +345,173 @@ class HttpFileTransferService {
downloadPath = pathlib.join(tempDir.path, filename);
}
// Prepare file and completer.
final file = await File(downloadedPath).create();
final fileSink = file.openWrite(mode: FileMode.writeOnlyAppend);
final downloadCompleter = Completer<void>();
dio.Response<dio.ResponseBody>? response;
_log.finest('Downloading ${job.location.url} as $filename (MIME guess ${job.mimeGuess}) to $downloadPath (-> $downloadedPath)');
int? downloadStatusCode;
try {
response = await dio.Dio().get<dio.ResponseBody>(
job.location.url,
options: dio.Options(
responseType: dio.ResponseType.stream,
_log.finest('Beginning download...');
downloadStatusCode = await client.downloadFile(
Uri.parse(job.location.url),
downloadPath,
(total, current) {
final progress = current.toDouble() / total.toDouble();
sendEvent(
ProgressEvent(
id: job.mId,
progress: progress == 1 ? 0.99 : progress,
),
);
},
);
_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;
}
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,
),
);
final downloadStream = response.data?.stream;
if (downloadStream != null) {
final totalFileSizeString = response.headers['Content-Length']?.first;
final totalFileSize = int.parse(totalFileSizeString!);
// Since acting on downloadStream events like to fire progress events
// causes memory spikes relative to the file size, I chose to listen to
// the created file instead and wait for its completion.
file.watch().listen((FileSystemEvent event) async {
if (event is FileSystemCreateEvent ||
event is FileSystemModifyEvent) {
final fileSize = await File(downloadedPath).length();
final progress = fileSize / totalFileSize;
sendEvent(
ProgressEvent(
id: job.mId,
progress: progress == 1 ? 0.99 : progress,
),
);
if (progress >= 1 && !downloadCompleter.isCompleted) {
downloadCompleter.complete();
}
}
});
downloadStream.listen(fileSink.add);
await downloadCompleter.future;
await fileSink.flush();
await fileSink.close();
}
} on dio.DioError catch (err) {
_log.finest('Failed to download: $err');
if (response.runtimeType != dio.Response<dio.ResponseBody>) {
response = null;
}
}
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,
),
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

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

@@ -40,41 +40,43 @@ class OmemoService {
if (done) return;
final db = GetIt.I.get<DatabaseService>();
var device = await db.loadOmemoDevice(jid);
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
device = await compute(generateNewIdentityImpl, jid);
await commitDevice(device!);
await commitDeviceMap(<String, List<int>>{});
await commitTrustManager(await omemoManager.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>();
final om = GetIt.I.get<moxxmpp.XmppConnection>().
getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!;
omemoManager = OmemoManager(
device,
await loadTrustManager(),
om.sendEmptyMessageImpl,
om.fetchDeviceList,
om.fetchDeviceBundle,
om.subscribeToDeviceListImpl,
);
omemoManager.initialize(
ratchetMap,
await db.loadOmemoDeviceList(),
);
deviceList.addAll(await db.loadOmemoDeviceList());
}
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(
@@ -368,7 +370,6 @@ class OmemoService {
Future<void> removeAllSessions(String jid) async {
await ensureInitialized();
// TODO(PapaTutuWawa): Reset trust decisions in the TrustManager
await omemoManager.removeAllRatchets(jid);
}
@@ -397,6 +398,7 @@ class OmemoService {
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(
@@ -422,4 +424,11 @@ class OmemoService {
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,11 +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)!
@@ -678,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) {
@@ -1143,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.
@@ -1233,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
@@ -1384,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,
),
);
@@ -1399,11 +1402,6 @@ 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>().handleAvatarUpdate(event);
}

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

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

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

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

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

@@ -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,9 +17,10 @@ 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/chat/datebubble.dart';
import 'package:moxxyv2/ui/widgets/chat/media/new_device.dart';
import 'package:moxxyv2/ui/widgets/overview_menu.dart';
class ConversationPage extends StatefulWidget {
@@ -37,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);
@@ -75,6 +77,7 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
@override
void dispose() {
_tabController.dispose();
_controller.dispose();
_scrollController
..removeListener(_onScroll)
@@ -109,22 +112,31 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
Widget _renderBubble(ConversationState state, BuildContext context, int _index, double maxWidth, String jid) {
if (_index.isEven) {
// Check if we have to render a date bubble
final nextMessageDateTime = DateTime.fromMillisecondsSinceEpoch(
state.messages[state.messages.length - 1 - _index ~/ 2].timestamp,
);
final nextIndex = state.messages.length - 2 - _index ~/ 2;
final lastMessageDateTime = nextIndex > 0 ?
DateTime.fromMillisecondsSinceEpoch(state.messages[nextIndex].timestamp) :
null;
if (lastMessageDateTime == null) {
return const SizedBox();
}
if (_index == 0) return const SizedBox();
if (lastMessageDateTime.day != nextMessageDateTime.day ||
lastMessageDateTime.month != nextMessageDateTime.month ||
lastMessageDateTime.year != nextMessageDateTime.year) {
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: [
@@ -264,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,
),
@@ -446,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 {
@@ -498,6 +503,7 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
},
),
),
Positioned(
left: 0,
right: 0,
@@ -540,9 +546,16 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
),
),
ConversationBottomRow(
_controller,
_textfieldFocus,
ColoredBox(
color: Theme.of(context)
.scaffoldBackgroundColor
.withOpacity(0.4),
child: ConversationBottomRow(
_controller,
_tabController,
_textfieldFocus,
_isSpeedDialOpen,
),
),
],
),
@@ -550,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),
@@ -585,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>(
@@ -630,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>(
@@ -659,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,
@@ -667,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,
),
),
),
);
@@ -678,7 +699,7 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
),
Positioned(
right: 8,
right: 61,
bottom: 380,
child: BlocBuilder<ConversationBloc, ConversationState>(
builder: (context, state) {
@@ -705,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,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

@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
@@ -135,6 +136,23 @@ class DebuggingPage extends StatelessWidget {
);
},
),
// 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,
),
),
);
},
),
] : [],
],
),
),

View File

@@ -70,6 +70,20 @@ class PrivacyPage extends StatelessWidget {
},
),
),
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(

View File

@@ -1,7 +1,9 @@
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';
@@ -25,129 +27,134 @@ class SettingsPage extends StatelessWidget {
Widget build(BuildContext context) {
return Scaffold(
appBar: BorderlessTopbar.simple(t.pages.settings.settings.title),
body: ListView(
children: [
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);
},
),
SectionTitle(t.pages.settings.settings.accountSection),
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(),
);
},
),
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.appearance.title,
prefix: const Padding(
padding: EdgeInsets.only(right: 16),
child: Icon(Icons.logout),
),
onTap: () {
Navigator.pushNamed(context, appearanceRoute);
},
),
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)
SectionTitle(t.pages.settings.settings.debuggingSection),
if (kDebugMode)
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.debugging.title,
title: t.pages.settings.appearance.title,
prefix: const Padding(
padding: EdgeInsets.only(right: 16),
child: Icon(Icons.brush),
),
onTap: () {
Navigator.pushNamed(context, appearanceRoute);
},
),
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, debuggingRoute);
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);
},
),
],
),
),
);
}

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

@@ -2,6 +2,7 @@ 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({
@@ -18,14 +19,14 @@ class NewDeviceBubble extends StatelessWidget {
padding: const EdgeInsets.all(8),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: InkWell(
onTap: () {
context.read<DevicesBloc>().add(
DevicesRequestedEvent(data['jid']! as String),
);
},
child: ColoredBox(
color: const Color(0xffeee8d5),
child: Material(
color: bubbleColorNewDevice,
child: InkWell(
onTap: () {
context.read<DevicesBloc>().add(
DevicesRequestedEvent(data['jid']! as String),
);
},
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,

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

@@ -14,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) {
@@ -40,20 +54,43 @@ class FileChatBaseWidget extends StatelessWidget {
width: maxWidth,
child: MediaBaseChatWidget(
Padding(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.all(8),
child: Row(
children: [
Icon(
icon,
size: 48,
),
if (downloadButton != null)
downloadButton!,
Padding(
padding: const EdgeInsets.only(left: 6),
child: Text(
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,
),
),
],
),
),
],
),
),
],
@@ -62,7 +99,7 @@ class FileChatBaseWidget extends StatelessWidget {
MessageBubbleBottom(message, sent),
radius,
gradient: false,
extra: extra,
//extra: extra,
onTap: onTap,
),
);
@@ -91,14 +128,14 @@ class FileChatWidget extends StatelessWidget {
Widget _buildNonDownloaded() {
return FileChatBaseWidget(
message,
Icons.file_present,
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,25 +149,27 @@ class FileChatWidget extends StatelessWidget {
Widget _buildDownloading() {
return FileChatBaseWidget(
message,
Icons.file_present,
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';
@@ -96,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

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

@@ -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: "62001c1e29b644fcf7fe12618d77571853fd073e"
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"
@@ -888,7 +883,7 @@ packages:
name: omemo_dart
url: "https://git.polynom.me/api/packages/PapaTutuWawa/pub/"
source: hosted
version: "0.4.1"
version: "0.4.2"
package_config:
dependency: transitive
description:
@@ -1597,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.4.1
version: 0.4.2
page_transition: 2.0.9
path: 1.8.2
path_provider: 2.0.11
@@ -134,14 +133,21 @@ dependency_overrides:
# NOTE: Leave here for development purposes
# 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: 596693c2067bc3fe73250f07cd88e7040a285537
rev: 6c63b53cf4870bc1303b1a2df835d5b67a9b88c4
path: packages/moxxmpp
moxxmpp_socket_tcp:
git:
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