Compare commits
50 Commits
d2e42d0a3c
...
v0.4.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 241a8b4d53 | |||
| 25d193e930 | |||
| e6924cc02d | |||
| 60985c6b37 | |||
| a015399b57 | |||
| 4b6c7998f3 | |||
| 26312e313f | |||
| b63b5d7fd2 | |||
| ca2943a94d | |||
| 32a4cd9361 | |||
| 2320e4ed17 | |||
| dee479a918 | |||
| 6895ef1e32 | |||
| 5c51eefa3e | |||
| 0d7ae321a7 | |||
| b4063a64e0 | |||
| 65154f2f5c | |||
| 19a22bd0d1 | |||
| a7da7baf5a | |||
| a344a94112 | |||
| f44861fead | |||
| 1c4a30ebb4 | |||
| 70e2ca3d3e | |||
| 0d4aee1625 | |||
| ad6aa33b7c | |||
| 284b5fa4df | |||
| b9aac0c3d7 | |||
| 6ce90e08ef | |||
| 5ac80d8d60 | |||
| 56e1fa52d8 | |||
| 3ae1b7d168 | |||
| d8f654c81c | |||
| cbcbd4d6dc | |||
| be899b5611 | |||
| 361bbe8d85 | |||
| 1e017af277 | |||
| c4c22a36bb | |||
| 84924b480b | |||
| 358074f4ee | |||
| 084314fbcf | |||
| c42f301ae0 | |||
| c8cd37e451 | |||
| 9f8f3a5407 | |||
| 6f1493808f | |||
| c9d32694db | |||
| 8632a2fc81 | |||
| 46a09d5b62 | |||
| b7e5bbc7d2 | |||
| ed264f0c16 | |||
| f1820575ad |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -60,3 +60,6 @@ lib/i18n/*.dart
|
||||
|
||||
# Android artifacts
|
||||
.android
|
||||
|
||||
# Build scripts
|
||||
release/
|
||||
|
||||
2
.gitlint
2
.gitlint
@@ -7,7 +7,7 @@ line-length=72
|
||||
[title-trailing-punctuation]
|
||||
[title-hard-tab]
|
||||
[title-match-regex]
|
||||
regex=^(feat|fix|chore|refactor|docs|release|test)\((xmpp|service|ui|shared|meta|tests|i18n)+(,(xmpp|service|ui|shared|meta|tests|i18n))*\): .*$
|
||||
regex=^((feat|fix|chore|refactor|docs|release|test)\((xmpp|service|ui|shared|meta|tests|i18n)+(,(xmpp|service|ui|shared|meta|tests|i18n))*\)|release): .*$
|
||||
|
||||
|
||||
[body-trailing-whitespace]
|
||||
|
||||
@@ -27,6 +27,31 @@
|
||||
"warningChannelDescription": "Warnings related to Moxxy"
|
||||
}
|
||||
},
|
||||
"dateTime": {
|
||||
"justNow": "Just now",
|
||||
"nMinutesAgo": "${min}min ago",
|
||||
"mondayAbbrev": "Mon",
|
||||
"tuesdayAbbrev": "Tue",
|
||||
"wednessdayAbbrev": "Wed",
|
||||
"thursdayAbbrev": "Thu",
|
||||
"fridayAbbrev": "Fri",
|
||||
"saturdayAbbrev": "Sat",
|
||||
"sundayAbbrev": "Sun",
|
||||
"january": "January",
|
||||
"february": "February",
|
||||
"march": "March",
|
||||
"april": "April",
|
||||
"may": "May",
|
||||
"june": "June",
|
||||
"july": "July",
|
||||
"august": "August",
|
||||
"september": "September",
|
||||
"october": "October",
|
||||
"november": "November",
|
||||
"december": "December",
|
||||
"today": "Today",
|
||||
"yesterday": "Yesterday"
|
||||
},
|
||||
"messages": {
|
||||
"image": "Image",
|
||||
"video": "Video",
|
||||
@@ -34,7 +59,8 @@
|
||||
"file": "File",
|
||||
"sticker": "Sticker",
|
||||
"retracted": "The message has been retracted",
|
||||
"retractedFallback": "A previous message has been retracted but your client does not support it"
|
||||
"retractedFallback": "A previous message has been retracted but your client does not support it",
|
||||
"you": "You"
|
||||
},
|
||||
"errors": {
|
||||
"omemo": {
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
7
fastlane/metadata/android/en-US/changelogs/9.txt
Normal file
7
fastlane/metadata/android/en-US/changelogs/9.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
* Expose the debug menu by tapping the Moxxy icon on the about page 10 times
|
||||
* Maybe fix a connection race condition
|
||||
* Allow sharing media with the app when it was closed
|
||||
* Make quotes prettier
|
||||
* Make the bottom part of the conversation page prettier
|
||||
* Fix roster fetching
|
||||
* Fix OMEMO key generation
|
||||
@@ -10,12 +10,14 @@ Currently supported features include:
|
||||
<li>Typing indicators and message markers</li>
|
||||
<li>Chat backgrounds</li>
|
||||
<li>Runs in the background without Push Notifications</li>
|
||||
<li>OMEMO (Currently not compatible with most apps)</li>
|
||||
<li>Stickers</li>
|
||||
</ul>
|
||||
|
||||
For the best experience, I recommend a server that:
|
||||
<ul>
|
||||
<li>Supports direct TLS/StartTLS on the same domain as in the Jid</li>
|
||||
<li>Supports SCRAM-SHA-1 or SCRAM-SHA-256</li>
|
||||
<li>Supports SCRAM-SHA-1, SCRAM-SHA-256 or SCRAM-SHA-512</li>
|
||||
<li>Supports HTTP File Upload</li>
|
||||
<li>Supports Stream Management</li>
|
||||
<li>Supports Client State Indication</li>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
14
lib/service/database/migrations/0000_stickers_privacy.dart
Normal file
14
lib/service/database/migrations/0000_stickers_privacy.dart
Normal file
@@ -0,0 +1,14 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/shared/models/preference.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV24ToV25(Database db) async {
|
||||
await db.insert(
|
||||
preferenceTable,
|
||||
Preference(
|
||||
'isStickersNodePublic',
|
||||
typeBool,
|
||||
'true',
|
||||
).toDatabaseJson(),
|
||||
);
|
||||
}
|
||||
15
lib/service/database/migrations/0001_debug_menu.dart
Normal file
15
lib/service/database/migrations/0001_debug_menu.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
import 'package:moxxyv2/shared/models/preference.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV25ToV26(Database db) async {
|
||||
await db.insert(
|
||||
preferenceTable,
|
||||
Preference(
|
||||
'showDebugMenu',
|
||||
typeBool,
|
||||
boolToString(false),
|
||||
).toDatabaseJson(),
|
||||
);
|
||||
}
|
||||
@@ -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' ?
|
||||
|
||||
137
lib/service/httpfiletransfer/client.dart
Normal file
137
lib/service/httpfiletransfer/client.dart
Normal file
@@ -0,0 +1,137 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:meta/meta.dart';
|
||||
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
|
||||
|
||||
typedef ProgressCallback = void Function(int total, int current);
|
||||
|
||||
@immutable
|
||||
class HttpPeekResult {
|
||||
const HttpPeekResult(this.contentType, this.contentLength);
|
||||
final String? contentType;
|
||||
final int? contentLength;
|
||||
}
|
||||
|
||||
/// Download the file found at [uri] into the file [destination]. [onProgress] is
|
||||
/// called whenever new data has been downloaded.
|
||||
///
|
||||
/// Returns the status code if the server responded. If an error occurs, returns null.
|
||||
Future<int?> downloadFile(Uri uri, String destination, ProgressCallback onProgress) async {
|
||||
// TODO(Unknown): How do we close fileSink? Do we have to?
|
||||
IOSink? fileSink;
|
||||
final client = HttpClient();
|
||||
try {
|
||||
final req = await client.getUrl(uri);
|
||||
final resp = await req.close();
|
||||
|
||||
if (!isRequestOkay(resp.statusCode)) {
|
||||
client.close(force: true);
|
||||
return resp.statusCode;
|
||||
}
|
||||
|
||||
// The size of the remote file
|
||||
final length = resp.contentLength;
|
||||
|
||||
fileSink = File(destination).openWrite(mode: FileMode.append);
|
||||
var bytes = 0;
|
||||
final downloadCompleter = Completer<void>();
|
||||
unawaited(
|
||||
resp.transform(
|
||||
StreamTransformer<List<int>, List<int>>.fromHandlers(
|
||||
handleData: (data, sink) {
|
||||
bytes += data.length;
|
||||
onProgress(length, bytes);
|
||||
|
||||
sink.add(data);
|
||||
},
|
||||
handleDone: (sink) {
|
||||
downloadCompleter.complete();
|
||||
},
|
||||
),
|
||||
).pipe(fileSink),
|
||||
);
|
||||
|
||||
// Wait for the download to complete
|
||||
await downloadCompleter.future;
|
||||
client.close(force: true);
|
||||
//await fileSink.close();
|
||||
|
||||
return resp.statusCode;
|
||||
} catch (ex) {
|
||||
client.close(force: true);
|
||||
//await fileSink?.close();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload the file found at [filePath] to [destination]. [headers] are HTTP headers
|
||||
/// that are added to the PUT request. [onProgress] is called whenever new data has
|
||||
/// been downloaded.
|
||||
///
|
||||
/// Returns the status code if the server responded. If an error occurs, returns null.
|
||||
Future<int?> uploadFile(Uri destination, Map<String, String> headers, String filePath, ProgressCallback onProgress) async {
|
||||
final client = HttpClient();
|
||||
try {
|
||||
final req = await client.putUrl(destination);
|
||||
final file = File(filePath);
|
||||
final length = await file.length();
|
||||
req.contentLength = length;
|
||||
|
||||
// Set all known headers
|
||||
headers.forEach((headerName, headerValue) {
|
||||
req.headers.set(headerName, headerValue);
|
||||
});
|
||||
|
||||
var bytes = 0;
|
||||
final stream = file.openRead().transform(
|
||||
StreamTransformer<List<int>, List<int>>.fromHandlers(
|
||||
handleData: (data, sink) {
|
||||
bytes += data.length;
|
||||
onProgress(length, bytes);
|
||||
|
||||
sink.add(data);
|
||||
},
|
||||
handleDone: (sink) {
|
||||
sink.close();
|
||||
},
|
||||
),
|
||||
);
|
||||
await req.addStream(stream);
|
||||
final resp = await req.close();
|
||||
|
||||
return resp.statusCode;
|
||||
} catch (ex) {
|
||||
client.close(force: true);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends a HEAD request to [uri].
|
||||
///
|
||||
/// Returns the content type and content length if the server responded. If an error
|
||||
/// occurs, returns null.
|
||||
Future<HttpPeekResult?> peekUrl(Uri uri) async {
|
||||
final client = HttpClient();
|
||||
|
||||
try {
|
||||
final req = await client.headUrl(uri);
|
||||
final resp = await req.close();
|
||||
|
||||
if (!isRequestOkay(resp.statusCode)) {
|
||||
client.close(force: true);
|
||||
return null;
|
||||
}
|
||||
|
||||
client.close(force: true);
|
||||
final contentType = resp.headers['Content-Type'];
|
||||
return HttpPeekResult(
|
||||
contentType != null && contentType.isNotEmpty ?
|
||||
contentType.first :
|
||||
null,
|
||||
resp.contentLength,
|
||||
);
|
||||
} catch (ex) {
|
||||
client.close(force: true);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:external_path/external_path.dart';
|
||||
import 'package:moxxyv2/service/httpfiletransfer/client.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
|
||||
@@ -43,7 +43,6 @@ bool isRequestOkay(int? statusCode) {
|
||||
}
|
||||
|
||||
class FileMetadata {
|
||||
|
||||
const FileMetadata({ this.mime, this.size });
|
||||
final String? mime;
|
||||
final int? size;
|
||||
@@ -53,15 +52,10 @@ class FileMetadata {
|
||||
/// does not specify the Content-Length header, null is returned.
|
||||
/// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Length
|
||||
Future<FileMetadata> peekFile(String url) async {
|
||||
final response = await Dio().headUri<dynamic>(Uri.parse(url));
|
||||
|
||||
if (!isRequestOkay(response.statusCode)) return const FileMetadata();
|
||||
|
||||
final contentLengthHeaders = response.headers['Content-Length'];
|
||||
final contentTypeHeaders = response.headers['Content-Type'];
|
||||
final result = await peekUrl(Uri.parse(url));
|
||||
|
||||
return FileMetadata(
|
||||
mime: contentTypeHeaders?.first,
|
||||
size: contentLengthHeaders != null && contentLengthHeaders.isNotEmpty ? int.parse(contentLengthHeaders.first) : null,
|
||||
mime: result?.contentType,
|
||||
size: result?.contentLength,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:dio/dio.dart' as dio;
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:mime/mime.dart';
|
||||
@@ -15,6 +14,7 @@ import 'package:moxxyv2/service/conversation.dart';
|
||||
import 'package:moxxyv2/service/cryptography/cryptography.dart';
|
||||
import 'package:moxxyv2/service/cryptography/types.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/httpfiletransfer/client.dart' as client;
|
||||
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
|
||||
import 'package:moxxyv2/service/httpfiletransfer/jobs.dart';
|
||||
import 'package:moxxyv2/service/message.dart';
|
||||
@@ -102,19 +102,17 @@ class HttpFileTransferService {
|
||||
|
||||
/// Queue the download job [job] to be performed.
|
||||
Future<void> downloadFile(FileDownloadJob job) async {
|
||||
var canDownload = false;
|
||||
await _uploadLock.synchronized(() async {
|
||||
if (_currentDownloadJob != null) {
|
||||
_log.finest('Queuing up download task.');
|
||||
_downloadQueue.add(job);
|
||||
} else {
|
||||
_log.finest('Executing download task.');
|
||||
_currentDownloadJob = job;
|
||||
canDownload = true;
|
||||
|
||||
unawaited(_performFileDownload(job));
|
||||
}
|
||||
});
|
||||
|
||||
if (canDownload) {
|
||||
unawaited(_performFileDownload(job));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _copyFile(FileUploadJob job) async {
|
||||
@@ -183,7 +181,6 @@ class HttpFileTransferService {
|
||||
}
|
||||
|
||||
final file = File(path);
|
||||
final data = 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;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import 'package:synchronized/synchronized.dart';
|
||||
/// backoff. This means that we perform the random backoff only as long as we are
|
||||
/// connected. Otherwise, we idle until we have a connection again.
|
||||
class MoxxyReconnectionPolicy extends ReconnectionPolicy {
|
||||
|
||||
MoxxyReconnectionPolicy({ bool isTesting = false, this.maxBackoffTime })
|
||||
: _isTesting = isTesting,
|
||||
_timerLock = Lock(),
|
||||
@@ -46,7 +45,7 @@ class MoxxyReconnectionPolicy extends ReconnectionPolicy {
|
||||
// Cancel the timer if it was running
|
||||
await _stopTimer();
|
||||
await setIsReconnecting(false);
|
||||
triggerConnectionLost!();
|
||||
await triggerConnectionLost!();
|
||||
} else if (regained && shouldReconnect) {
|
||||
// We should reconnect
|
||||
_log.finest('Network regained. Attempting reconnection...');
|
||||
|
||||
@@ -1,21 +1,91 @@
|
||||
import 'dart:async';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/roster.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/service/xmpp.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/models/roster.dart';
|
||||
|
||||
class MoxxyRosterManager extends RosterManager {
|
||||
class MoxxyRosterStateManager extends BaseRosterStateManager {
|
||||
@override
|
||||
Future<void> commitLastRosterVersion(String version) async {
|
||||
await GetIt.I.get<XmppService>().modifyXmppState((state) => state.copyWith(
|
||||
lastRosterVersion: version,
|
||||
),);
|
||||
Future<RosterCacheLoadResult> loadRosterCache() async {
|
||||
final rs = GetIt.I.get<RosterService>();
|
||||
return RosterCacheLoadResult(
|
||||
(await GetIt.I.get<XmppService>().getXmppState()).lastRosterVersion,
|
||||
(await rs.getRoster()).map((item) => XmppRosterItem(
|
||||
jid: item.jid,
|
||||
name: item.title,
|
||||
subscription: item.subscription,
|
||||
ask: item.ask.isEmpty ? null : item.ask,
|
||||
groups: item.groups,
|
||||
),).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> loadLastRosterVersion() async {
|
||||
final ver = (await GetIt.I.get<XmppService>().getXmppState()).lastRosterVersion;
|
||||
if (ver != null) {
|
||||
setRosterVersion(ver);
|
||||
Future<void> commitRoster(String? version, List<String> removed, List<XmppRosterItem> modified, List<XmppRosterItem> added) async {
|
||||
final rs = GetIt.I.get<RosterService>();
|
||||
final xs = GetIt.I.get<XmppService>();
|
||||
await xs.modifyXmppState((state) => state.copyWith(
|
||||
lastRosterVersion: version,
|
||||
),);
|
||||
|
||||
// Remove stale items
|
||||
for (final jid in removed) {
|
||||
await rs.removeRosterItemByJid(jid);
|
||||
}
|
||||
|
||||
// Create new roster items
|
||||
final rosterAdded = List<RosterItem>.empty(growable: true);
|
||||
for (final item in added) {
|
||||
rosterAdded.add(
|
||||
await rs.addRosterItemFromData(
|
||||
'',
|
||||
'',
|
||||
item.jid,
|
||||
item.name ?? item.jid.split('@').first,
|
||||
item.subscription,
|
||||
item.ask ?? '',
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
groups: item.groups,
|
||||
),
|
||||
);
|
||||
|
||||
// TODO(PapaTutuWawa): Fetch the avatar
|
||||
}
|
||||
|
||||
// Update modified items
|
||||
final rosterModified = List<RosterItem>.empty(growable: true);
|
||||
for (final item in modified) {
|
||||
final ritem = await rs.getRosterItemByJid(item.jid);
|
||||
if (ritem == null) {
|
||||
//_log.warning('Could not find roster item with JID $jid during update');
|
||||
continue;
|
||||
}
|
||||
|
||||
rosterModified.add(
|
||||
await rs.updateRosterItem(
|
||||
ritem.id,
|
||||
title: item.name,
|
||||
subscription: item.subscription,
|
||||
ask: item.ask,
|
||||
groups: item.groups,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Tell the UI
|
||||
// TODO(Unknown): This may not be the cleanest place to put it
|
||||
sendEvent(
|
||||
RosterDiffEvent(
|
||||
added: rosterAdded,
|
||||
modified: rosterModified,
|
||||
removed: removed,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,222 +1,28 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxlib/moxlib.dart';
|
||||
import 'package:moxplatform/moxplatform.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/contacts.dart';
|
||||
import 'package:moxxyv2/service/conversation.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/not_specified.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/models/conversation.dart';
|
||||
import 'package:moxxyv2/shared/models/roster.dart';
|
||||
|
||||
/// Closure which returns true if the jid of a [RosterItem] is equal to [jid].
|
||||
bool Function(RosterItem) _jidEqualsWrapper(String jid) {
|
||||
return (i) => i.jid == jid;
|
||||
}
|
||||
|
||||
typedef AddRosterItemFunction = Future<RosterItem> Function(
|
||||
String avatarUrl,
|
||||
String avatarHash,
|
||||
String jid,
|
||||
String title,
|
||||
String subscription,
|
||||
String ask,
|
||||
bool pseudoRosterItem,
|
||||
String? contactId,
|
||||
String? contactAvatarPath,
|
||||
String? contactDisplayName,
|
||||
{
|
||||
List<String> groups,
|
||||
}
|
||||
);
|
||||
typedef UpdateRosterItemFunction = Future<RosterItem> Function(
|
||||
int id, {
|
||||
String? avatarUrl,
|
||||
String? avatarHash,
|
||||
String? title,
|
||||
String? subscription,
|
||||
String? ask,
|
||||
Object pseudoRosterItem,
|
||||
List<String>? groups,
|
||||
}
|
||||
);
|
||||
typedef RemoveRosterItemFunction = Future<void> Function(String jid);
|
||||
typedef GetConversationFunction = Future<Conversation?> Function(String jid);
|
||||
typedef SendEventFunction = void Function(BackgroundEvent event, { String? id });
|
||||
|
||||
/// Compare the local roster with the roster we received either by request or by push.
|
||||
/// Returns a diff between the roster before and after the request or the push.
|
||||
/// NOTE: This abuses the [RosterDiffEvent] type a bit.
|
||||
Future<RosterDiffEvent> processRosterDiff(
|
||||
List<RosterItem> currentRoster,
|
||||
List<XmppRosterItem> remoteRoster,
|
||||
bool isRosterPush,
|
||||
AddRosterItemFunction addRosterItemFromData,
|
||||
UpdateRosterItemFunction updateRosterItem,
|
||||
RemoveRosterItemFunction removeRosterItemByJid,
|
||||
GetConversationFunction getConversationByJid,
|
||||
SendEventFunction _sendEvent,
|
||||
) async {
|
||||
final css = GetIt.I.get<ContactsService>();
|
||||
final removed = List<String>.empty(growable: true);
|
||||
final modified = List<RosterItem>.empty(growable: true);
|
||||
final added = List<RosterItem>.empty(growable: true);
|
||||
|
||||
for (final item in remoteRoster) {
|
||||
if (isRosterPush) {
|
||||
final litem = firstWhereOrNull(currentRoster, _jidEqualsWrapper(item.jid));
|
||||
if (litem != null) {
|
||||
if (item.subscription == 'remove') {
|
||||
// We have the item locally but it has been removed
|
||||
|
||||
if (litem.contactId != null) {
|
||||
// We have the contact associated with a contact
|
||||
final newItem = await updateRosterItem(
|
||||
litem.id,
|
||||
ask: 'none',
|
||||
subscription: 'none',
|
||||
pseudoRosterItem: true,
|
||||
);
|
||||
modified.add(newItem);
|
||||
} else {
|
||||
await removeRosterItemByJid(item.jid);
|
||||
removed.add(item.jid);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Item has been modified
|
||||
final newItem = await updateRosterItem(
|
||||
litem.id,
|
||||
subscription: item.subscription,
|
||||
title: item.name,
|
||||
ask: item.ask,
|
||||
pseudoRosterItem: false,
|
||||
groups: item.groups,
|
||||
);
|
||||
|
||||
modified.add(newItem);
|
||||
|
||||
// Check if we have a conversation that we need to modify
|
||||
final conv = await getConversationByJid(item.jid);
|
||||
if (conv != null) {
|
||||
_sendEvent(
|
||||
ConversationUpdatedEvent(
|
||||
conversation: conv.copyWith(subscription: item.subscription),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Item does not exist locally
|
||||
if (item.subscription == 'remove') {
|
||||
// Item has been removed but we don't have it locally
|
||||
removed.add(item.jid);
|
||||
} else {
|
||||
// Item has been added and we don't have it locally
|
||||
final contactId = await css.getContactIdForJid(item.jid);
|
||||
final newItem = await addRosterItemFromData(
|
||||
'',
|
||||
'',
|
||||
item.jid,
|
||||
item.name ?? item.jid.split('@')[0],
|
||||
item.subscription,
|
||||
item.ask ?? '',
|
||||
false,
|
||||
contactId,
|
||||
await css.getProfilePicturePathForJid(item.jid),
|
||||
await css.getContactDisplayName(contactId),
|
||||
groups: item.groups,
|
||||
);
|
||||
|
||||
added.add(newItem);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
final litem = firstWhereOrNull(currentRoster, _jidEqualsWrapper(item.jid));
|
||||
if (litem != null) {
|
||||
// Item is modified
|
||||
if (litem.title != item.name || litem.subscription != item.subscription || !listEquals(litem.groups, item.groups)) {
|
||||
final modifiedItem = await updateRosterItem(
|
||||
litem.id,
|
||||
title: item.name,
|
||||
subscription: item.subscription,
|
||||
pseudoRosterItem: false,
|
||||
groups: item.groups,
|
||||
);
|
||||
modified.add(modifiedItem);
|
||||
|
||||
// Check if we have a conversation that we need to modify
|
||||
final conv = await getConversationByJid(litem.jid);
|
||||
if (conv != null) {
|
||||
_sendEvent(
|
||||
ConversationUpdatedEvent(
|
||||
conversation: conv.copyWith(subscription: item.subscription),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Item is new
|
||||
final contactId = await css.getContactIdForJid(item.jid);
|
||||
added.add(await addRosterItemFromData(
|
||||
'',
|
||||
'',
|
||||
item.jid,
|
||||
item.jid.split('@')[0],
|
||||
item.subscription,
|
||||
item.ask ?? '',
|
||||
false,
|
||||
contactId,
|
||||
await css.getProfilePicturePathForJid(item.jid),
|
||||
await css.getContactDisplayName(contactId),
|
||||
groups: item.groups,
|
||||
),);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isRosterPush) {
|
||||
for (final item in currentRoster) {
|
||||
final ritem = firstWhereOrNull(remoteRoster, (XmppRosterItem i) => i.jid == item.jid);
|
||||
if (ritem == null) {
|
||||
await removeRosterItemByJid(item.jid);
|
||||
removed.add(item.jid);
|
||||
}
|
||||
// We don't handle the modification case here as that is covered by the huge
|
||||
// loop above
|
||||
}
|
||||
}
|
||||
|
||||
return RosterDiffEvent(
|
||||
added: added,
|
||||
modified: modified,
|
||||
removed: removed,
|
||||
);
|
||||
}
|
||||
|
||||
class RosterService {
|
||||
|
||||
RosterService()
|
||||
: _rosterCache = HashMap(),
|
||||
_rosterLoaded = false,
|
||||
_log = Logger('RosterService');
|
||||
final HashMap<String, RosterItem> _rosterCache;
|
||||
bool _rosterLoaded;
|
||||
RosterService() : _log = Logger('RosterService');
|
||||
Map<String, RosterItem>? _rosterCache;
|
||||
final Logger _log;
|
||||
|
||||
Future<bool> isInRoster(String jid) async {
|
||||
if (!_rosterLoaded) {
|
||||
|
||||
Future<void> _loadRosterIfNeeded() async {
|
||||
if (_rosterCache == null) {
|
||||
await loadRosterFromDatabase();
|
||||
}
|
||||
}
|
||||
|
||||
return _rosterCache.containsKey(jid);
|
||||
Future<bool> isInRoster(String jid) async {
|
||||
await _loadRosterIfNeeded();
|
||||
return _rosterCache!.containsKey(jid);
|
||||
}
|
||||
|
||||
/// Wrapper around [DatabaseService]'s addRosterItemFromData that updates the cache.
|
||||
@@ -250,7 +56,7 @@ class RosterService {
|
||||
);
|
||||
|
||||
// Update the cache
|
||||
_rosterCache[item.jid] = item;
|
||||
_rosterCache![item.jid] = item;
|
||||
|
||||
return item;
|
||||
}
|
||||
@@ -285,26 +91,26 @@ class RosterService {
|
||||
);
|
||||
|
||||
// Update cache
|
||||
_rosterCache[newItem.jid] = newItem;
|
||||
_rosterCache![newItem.jid] = newItem;
|
||||
|
||||
return newItem;
|
||||
}
|
||||
|
||||
/// Wrapper around [DatabaseService]'s removeRosterItem.
|
||||
Future<void> removeRosterItem(int id) async {
|
||||
// NOTE: This call ensures that _rosterCache != null
|
||||
await GetIt.I.get<DatabaseService>().removeRosterItem(id);
|
||||
|
||||
assert(_rosterCache != null, '_rosterCache must be non-null');
|
||||
|
||||
/// Update cache
|
||||
_rosterCache.removeWhere((_, value) => value.id == id);
|
||||
_rosterCache!.removeWhere((_, value) => value.id == id);
|
||||
}
|
||||
|
||||
/// Removes a roster item from the database based on its JID.
|
||||
Future<void> removeRosterItemByJid(String jid) async {
|
||||
if (!_rosterLoaded) {
|
||||
await loadRosterFromDatabase();
|
||||
}
|
||||
await _loadRosterIfNeeded();
|
||||
|
||||
for (final item in _rosterCache.values) {
|
||||
for (final item in _rosterCache!.values) {
|
||||
if (item.jid == jid) {
|
||||
await removeRosterItem(item.id);
|
||||
return;
|
||||
@@ -314,17 +120,14 @@ class RosterService {
|
||||
|
||||
/// Returns the entire roster
|
||||
Future<List<RosterItem>> getRoster() async {
|
||||
if (!_rosterLoaded) {
|
||||
await loadRosterFromDatabase();
|
||||
}
|
||||
|
||||
return _rosterCache.values.toList();
|
||||
await _loadRosterIfNeeded();
|
||||
return _rosterCache!.values.toList();
|
||||
}
|
||||
|
||||
/// Returns the roster item with jid [jid] if it exists. Null otherwise.
|
||||
Future<RosterItem?> getRosterItemByJid(String jid) async {
|
||||
if (await isInRoster(jid)) {
|
||||
return _rosterCache[jid];
|
||||
return _rosterCache![jid];
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -335,9 +138,9 @@ class RosterService {
|
||||
Future<List<RosterItem>> loadRosterFromDatabase() async {
|
||||
final items = await GetIt.I.get<DatabaseService>().loadRosterItems();
|
||||
|
||||
_rosterLoaded = true;
|
||||
_rosterCache = <String, RosterItem>{};
|
||||
for (final item in items) {
|
||||
_rosterCache[item.jid] = item;
|
||||
_rosterCache![item.jid] = item;
|
||||
}
|
||||
|
||||
return items;
|
||||
@@ -392,59 +195,6 @@ class RosterService {
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> requestRoster() async {
|
||||
final roster = GetIt.I.get<XmppConnection>().getManagerById<RosterManager>(rosterManager)!;
|
||||
Result<RosterRequestResult?, RosterError> result;
|
||||
if (roster.rosterVersioningAvailable()) {
|
||||
_log.fine('Stream supports roster versioning');
|
||||
result = await roster.requestRosterPushes();
|
||||
_log.fine('Requesting roster pushes done');
|
||||
} else {
|
||||
_log.fine('Stream does not support roster versioning');
|
||||
result = await roster.requestRoster();
|
||||
}
|
||||
|
||||
if (result.isType<RosterError>()) {
|
||||
_log.warning('Failed to request roster');
|
||||
return;
|
||||
}
|
||||
|
||||
final value = result.get<RosterRequestResult?>();
|
||||
if (value != null) {
|
||||
final currentRoster = await getRoster();
|
||||
sendEvent(
|
||||
await processRosterDiff(
|
||||
currentRoster,
|
||||
value.items,
|
||||
false,
|
||||
addRosterItemFromData,
|
||||
updateRosterItem,
|
||||
removeRosterItemByJid,
|
||||
GetIt.I.get<ConversationService>().getConversationByJid,
|
||||
sendEvent,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles a roster push.
|
||||
Future<void> handleRosterPushEvent(RosterPushEvent event) async {
|
||||
final item = event.item;
|
||||
final currentRoster = await getRoster();
|
||||
sendEvent(
|
||||
await processRosterDiff(
|
||||
currentRoster,
|
||||
[ item ],
|
||||
true,
|
||||
addRosterItemFromData,
|
||||
updateRosterItem,
|
||||
removeRosterItemByJid,
|
||||
GetIt.I.get<ConversationService>().getConversationByJid,
|
||||
sendEvent,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> acceptSubscriptionRequest(String jid) async {
|
||||
GetIt.I.get<XmppConnection>().getPresenceManager().sendSubscriptionRequestApproval(jid);
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ Future<void> entrypoint() async {
|
||||
)..registerManagers([
|
||||
MoxxyStreamManagementManager(),
|
||||
MoxxyDiscoManager(),
|
||||
MoxxyRosterManager(),
|
||||
RosterManager(MoxxyRosterStateManager()),
|
||||
MoxxyOmemoManager(),
|
||||
PingManager(),
|
||||
MessageManager(),
|
||||
@@ -252,6 +252,7 @@ Future<void> entrypoint() async {
|
||||
sendEvent(ServiceReadyEvent());
|
||||
}
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> receiveUIEvent(Map<String, dynamic>? data) async {
|
||||
await GetIt.I.get<SynchronizedQueue<Map<String, dynamic>?>>().add(data);
|
||||
}
|
||||
|
||||
@@ -2,14 +2,15 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:archive/archive.dart';
|
||||
import 'package:dio/dio.dart' as dio;
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxlib/moxlib.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/helpers.dart';
|
||||
import 'package:moxxyv2/service/httpfiletransfer/client.dart';
|
||||
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
|
||||
import 'package:moxxyv2/service/preferences.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/service/xmpp.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
@@ -89,10 +90,17 @@ class StickersService {
|
||||
}
|
||||
|
||||
Future<void> _publishStickerPack(moxxmpp.StickerPack pack) async {
|
||||
final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
|
||||
final state = await GetIt.I.get<XmppService>().getXmppState();
|
||||
final result = await GetIt.I.get<moxxmpp.XmppConnection>()
|
||||
.getManagerById<moxxmpp.StickersManager>(moxxmpp.stickersManager)!
|
||||
.publishStickerPack(moxxmpp.JID.fromString(state.jid!), pack);
|
||||
.publishStickerPack(
|
||||
moxxmpp.JID.fromString(state.jid!),
|
||||
pack,
|
||||
accessModel: prefs.isStickersNodePublic ?
|
||||
'open' :
|
||||
null,
|
||||
);
|
||||
|
||||
if (result.isType<moxxmpp.PubSubError>()) {
|
||||
_log.severe('Failed to publish sticker pack');
|
||||
@@ -159,20 +167,15 @@ class StickersService {
|
||||
stickerPackPath,
|
||||
sticker.hashes.values.first,
|
||||
);
|
||||
dio.Response<dynamic>? response;
|
||||
try {
|
||||
response = await dio.Dio().downloadUri(
|
||||
Uri.parse(sticker.urlSources.first),
|
||||
stickerPath,
|
||||
);
|
||||
} on dio.DioError catch(err) {
|
||||
_log.severe('Error downloading ${sticker.urlSources.first}: $err');
|
||||
success = false;
|
||||
break;
|
||||
}
|
||||
final downloadStatusCode = await downloadFile(
|
||||
Uri.parse(sticker.urlSources.first),
|
||||
stickerPath,
|
||||
(_, __) {},
|
||||
);
|
||||
|
||||
if (!isRequestOkay(response.statusCode)) {
|
||||
_log.severe('Request not okay: $response');
|
||||
if (!isRequestOkay(downloadStatusCode)) {
|
||||
_log.severe('Request not okay: $downloadStatusCode');
|
||||
success = false;
|
||||
break;
|
||||
}
|
||||
stickers[i] = sticker.copyWith(
|
||||
|
||||
@@ -55,7 +55,6 @@ class XmppService {
|
||||
EventTypeMatcher<SubscriptionRequestReceivedEvent>(_onSubscriptionRequestReceived),
|
||||
EventTypeMatcher<DeliveryReceiptReceivedEvent>(_onDeliveryReceiptReceived),
|
||||
EventTypeMatcher<ChatMarkerEvent>(_onChatMarker),
|
||||
EventTypeMatcher<RosterPushEvent>(_onRosterPush),
|
||||
EventTypeMatcher<AvatarUpdatedEvent>(_onAvatarUpdated),
|
||||
EventTypeMatcher<StanzaAckedEvent>(_onStanzaAcked),
|
||||
EventTypeMatcher<MessageEvent>(_onMessage),
|
||||
@@ -663,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);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as pathlib;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ String formatConversationTimestamp(int timestamp, int now) {
|
||||
return '${hourDifference}h';
|
||||
}
|
||||
} else if (difference <= Duration.millisecondsPerMinute) {
|
||||
return 'Just now';
|
||||
return t.dateTime.justNow;
|
||||
}
|
||||
|
||||
return '${(difference / Duration.millisecondsPerMinute).floor()}min';
|
||||
@@ -58,9 +58,10 @@ String formatMessageTimestamp(int timestamp, int now) {
|
||||
return '${dt.hour}:${padInt(dt.minute)}';
|
||||
} else {
|
||||
if (difference < Duration.millisecondsPerMinute) {
|
||||
return 'Just now';
|
||||
return t.dateTime.justNow;
|
||||
} else {
|
||||
return '${(difference / Duration.millisecondsPerMinute).floor()}min ago';
|
||||
final diff = (difference / Duration.millisecondsPerMinute).floor();
|
||||
return t.dateTime.nMinutesAgo(min: diff);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,19 +70,19 @@ String formatMessageTimestamp(int timestamp, int now) {
|
||||
String weekdayToStringAbbrev(int day) {
|
||||
switch (day) {
|
||||
case DateTime.monday:
|
||||
return 'Mon';
|
||||
return t.dateTime.mondayAbbrev;
|
||||
case DateTime.tuesday:
|
||||
return 'Tue';
|
||||
return t.dateTime.tuesdayAbbrev;
|
||||
case DateTime.wednesday:
|
||||
return 'Wed';
|
||||
return t.dateTime.wednessdayAbbrev;
|
||||
case DateTime.thursday:
|
||||
return 'Thu';
|
||||
return t.dateTime.thursdayAbbrev;
|
||||
case DateTime.friday:
|
||||
return 'Fri';
|
||||
return t.dateTime.fridayAbbrev;
|
||||
case DateTime.saturday:
|
||||
return 'Sat';
|
||||
return t.dateTime.saturdayAbbrev;
|
||||
case DateTime.sunday:
|
||||
return 'Sun';
|
||||
return t.dateTime.sundayAbbrev;
|
||||
}
|
||||
|
||||
// Should not happen
|
||||
@@ -92,29 +93,29 @@ String weekdayToStringAbbrev(int day) {
|
||||
String monthToString(int month) {
|
||||
switch (month) {
|
||||
case DateTime.january:
|
||||
return 'January';
|
||||
return t.dateTime.january;
|
||||
case DateTime.february:
|
||||
return 'February';
|
||||
return t.dateTime.february;
|
||||
case DateTime.march:
|
||||
return 'March';
|
||||
return t.dateTime.march;
|
||||
case DateTime.april:
|
||||
return 'April';
|
||||
return t.dateTime.april;
|
||||
case DateTime.may:
|
||||
return 'May';
|
||||
return t.dateTime.may;
|
||||
case DateTime.june:
|
||||
return 'June';
|
||||
return t.dateTime.june;
|
||||
case DateTime.july:
|
||||
return 'July';
|
||||
return t.dateTime.july;
|
||||
case DateTime.august:
|
||||
return 'August';
|
||||
return t.dateTime.august;
|
||||
case DateTime.september:
|
||||
return 'September';
|
||||
return t.dateTime.september;
|
||||
case DateTime.october:
|
||||
return 'October';
|
||||
return t.dateTime.october;
|
||||
case DateTime.november:
|
||||
return 'November';
|
||||
return t.dateTime.november;
|
||||
case DateTime.december:
|
||||
return 'December';
|
||||
return t.dateTime.december;
|
||||
}
|
||||
|
||||
// Should not happen
|
||||
@@ -125,9 +126,9 @@ String monthToString(int month) {
|
||||
/// like 'Today', 'Yesterday', 'Fri, 7. August' or '6. August 2022'.
|
||||
String formatDateBubble(DateTime dt, DateTime now) {
|
||||
if (dt.day == now.day && dt.month == now.month && dt.year == now.year) {
|
||||
return 'Today';
|
||||
return t.dateTime.today;
|
||||
} else if (now.subtract(const Duration(days: 1)).day == dt.day) {
|
||||
return 'Yesterday';
|
||||
return t.dateTime.yesterday;
|
||||
} else if (dt.year == now.year) {
|
||||
return '${weekdayToStringAbbrev(dt.weekday)}, ${dt.day}. ${monthToString(dt.month)}';
|
||||
} else {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -51,7 +51,7 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
||||
on<BackgroundChangedEvent>(_onBackgroundChanged);
|
||||
on<ImagePickerRequestedEvent>(_onImagePickerRequested);
|
||||
on<FilePickerRequestedEvent>(_onFilePickerRequested);
|
||||
on<EmojiPickerToggledEvent>(_onEmojiPickerToggled);
|
||||
on<PickerToggledEvent>(_onPickerToggled);
|
||||
on<OwnJidReceivedEvent>(_onOwnJidReceived);
|
||||
on<OmemoSetEvent>(_onOmemoSet);
|
||||
on<MessageRetractedEvent>(_onMessageRetracted);
|
||||
@@ -64,7 +64,6 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
||||
on<RecordingCanceledEvent>(_onRecordingCanceled);
|
||||
on<ReactionAddedEvent>(_onReactionAdded);
|
||||
on<ReactionRemovedEvent>(_onReactionRemoved);
|
||||
on<StickerPickerToggledEvent>(_onStickerPickerToggled);
|
||||
on<StickerSentEvent>(_onStickerSent);
|
||||
on<SoftKeyboardVisibilityChanged>(_onSoftKeyboardVisibilityChanged);
|
||||
|
||||
@@ -239,8 +238,7 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
||||
messageText: '',
|
||||
quotedMessage: null,
|
||||
sendButtonState: defaultSendButtonState,
|
||||
emojiPickerVisible: false,
|
||||
stickerPickerVisible: false,
|
||||
pickerVisible: false,
|
||||
messageEditing: false,
|
||||
messageEditingOriginalBody: '',
|
||||
messageEditingId: null,
|
||||
@@ -375,12 +373,11 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onEmojiPickerToggled(EmojiPickerToggledEvent event, Emitter<ConversationState> emit) async {
|
||||
final newState = !state.emojiPickerVisible;
|
||||
Future<void> _onPickerToggled(PickerToggledEvent event, Emitter<ConversationState> emit) async {
|
||||
final newState = !state.pickerVisible;
|
||||
emit(
|
||||
state.copyWith(
|
||||
emojiPickerVisible: newState,
|
||||
stickerPickerVisible: false,
|
||||
pickerVisible: newState,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -461,8 +458,7 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
||||
state.copyWith(
|
||||
isDragging: true,
|
||||
isRecording: true,
|
||||
emojiPickerVisible: false,
|
||||
stickerPickerVisible: false,
|
||||
pickerVisible: false,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -643,16 +639,6 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onStickerPickerToggled(StickerPickerToggledEvent event, Emitter<ConversationState> emit) async {
|
||||
await SystemChannels.textInput.invokeMethod('TextInput.hide');
|
||||
emit(
|
||||
state.copyWith(
|
||||
stickerPickerVisible: !state.stickerPickerVisible,
|
||||
emojiPickerVisible: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onStickerSent(StickerSentEvent event, Emitter<ConversationState> emit) async {
|
||||
await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||
SendStickerCommand(
|
||||
@@ -666,17 +652,16 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
||||
// Close the picker
|
||||
emit(
|
||||
state.copyWith(
|
||||
stickerPickerVisible: false,
|
||||
pickerVisible: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onSoftKeyboardVisibilityChanged(SoftKeyboardVisibilityChanged event, Emitter<ConversationState> emit) async {
|
||||
if (event.visible && (state.emojiPickerVisible || state.stickerPickerVisible)) {
|
||||
if (event.visible && (state.pickerVisible)) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
emojiPickerVisible: false,
|
||||
stickerPickerVisible: false,
|
||||
pickerVisible: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,14 +98,8 @@ class ImagePickerRequestedEvent extends ConversationEvent {}
|
||||
class FilePickerRequestedEvent extends ConversationEvent {}
|
||||
|
||||
/// Triggered when the emoji button is pressed
|
||||
class EmojiPickerToggledEvent extends ConversationEvent {
|
||||
EmojiPickerToggledEvent({this.handleKeyboard = true});
|
||||
final bool handleKeyboard;
|
||||
}
|
||||
|
||||
/// Triggered when the sticker button is pressed
|
||||
class StickerPickerToggledEvent extends ConversationEvent {
|
||||
StickerPickerToggledEvent({this.handleKeyboard = true});
|
||||
class PickerToggledEvent extends ConversationEvent {
|
||||
PickerToggledEvent({this.handleKeyboard = true});
|
||||
final bool handleKeyboard;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
part of 'conversation_bloc.dart';
|
||||
|
||||
enum SendButtonState {
|
||||
audio,
|
||||
multi,
|
||||
send,
|
||||
cancelCorrection,
|
||||
}
|
||||
const defaultSendButtonState = SendButtonState.audio;
|
||||
const defaultSendButtonState = SendButtonState.multi;
|
||||
|
||||
@freezed
|
||||
class ConversationState with _$ConversationState {
|
||||
@@ -18,8 +18,7 @@ class ConversationState with _$ConversationState {
|
||||
@Default(<Message>[]) List<Message> messages,
|
||||
@Default(null) Conversation? conversation,
|
||||
@Default('') String backgroundPath,
|
||||
@Default(false) bool emojiPickerVisible,
|
||||
@Default(false) bool stickerPickerVisible,
|
||||
@Default(false) bool pickerVisible,
|
||||
@Default(false) bool messageEditing,
|
||||
@Default('') String messageEditingOriginalBody,
|
||||
@Default(null) String? messageEditingSid,
|
||||
|
||||
@@ -6,9 +6,8 @@ abstract class PreferencesEvent {}
|
||||
/// If [notify] is true, then the background service will be
|
||||
/// notified of this change.
|
||||
class PreferencesChangedEvent extends PreferencesEvent {
|
||||
|
||||
PreferencesChangedEvent(this.preferences, {
|
||||
this.notify = true,
|
||||
this.notify = true,
|
||||
});
|
||||
final PreferencesState preferences;
|
||||
final bool notify;
|
||||
@@ -19,7 +18,6 @@ class SignedOutEvent extends PreferencesEvent {}
|
||||
|
||||
/// Triggered when a background image has been set
|
||||
class BackgroundImageSetEvent extends PreferencesEvent {
|
||||
|
||||
BackgroundImageSetEvent(this.backgroundPath);
|
||||
final String backgroundPath;
|
||||
}
|
||||
|
||||
@@ -130,7 +130,13 @@ class ShareSelectionBloc extends Bloc<ShareSelectionEvent, ShareSelectionState>
|
||||
}
|
||||
|
||||
Future<void> _onRequested(ShareSelectionRequestedEvent event, Emitter<ShareSelectionState> emit) async {
|
||||
emit(state.copyWith(paths: event.paths, text: event.text, type: event.type));
|
||||
emit(
|
||||
state.copyWith(
|
||||
paths: event.paths,
|
||||
text: event.text,
|
||||
type: event.type,
|
||||
),
|
||||
);
|
||||
|
||||
GetIt.I.get<NavigationBloc>().add(
|
||||
PushedNamedAndRemoveUntilEvent(
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
|
||||
import 'package:flutter_speed_dial/flutter_speed_dial.dart';
|
||||
import 'package:flutter_vibrate/flutter_vibrate.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
@@ -14,8 +13,9 @@ import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/helpers.dart';
|
||||
import 'package:moxxyv2/ui/pages/conversation/blink.dart';
|
||||
import 'package:moxxyv2/ui/pages/conversation/timer.dart';
|
||||
import 'package:moxxyv2/ui/theme.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/media/media.dart';
|
||||
import 'package:moxxyv2/ui/widgets/sticker_picker.dart';
|
||||
import 'package:moxxyv2/ui/widgets/combined_picker.dart';
|
||||
import 'package:moxxyv2/ui/widgets/textfield.dart';
|
||||
import 'package:phosphor_flutter/phosphor_flutter.dart';
|
||||
|
||||
@@ -32,7 +32,55 @@ class _TextFieldIconButton extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
size: 24,
|
||||
color: primaryColor,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TextFieldRecordButton extends StatelessWidget {
|
||||
const _TextFieldRecordButton();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LongPressDraggable<int>(
|
||||
data: 1,
|
||||
axis: Axis.vertical,
|
||||
onDragStarted: () {
|
||||
Vibrate.feedback(FeedbackType.heavy);
|
||||
context.read<ConversationBloc>().add(
|
||||
SendButtonDragStartedEvent(),
|
||||
);
|
||||
},
|
||||
onDraggableCanceled: (_, __) {
|
||||
Vibrate.feedback(FeedbackType.heavy);
|
||||
context.read<ConversationBloc>().add(
|
||||
SendButtonDragEndedEvent(),
|
||||
);
|
||||
},
|
||||
childWhenDragging: const SizedBox(),
|
||||
feedback: SizedBox(
|
||||
width: 45,
|
||||
height: 45,
|
||||
child: FloatingActionButton(
|
||||
onPressed: null,
|
||||
heroTag: 'fabDragged',
|
||||
backgroundColor: Colors.red.shade600,
|
||||
child: BlinkingIcon(
|
||||
icon: Icons.mic,
|
||||
duration: const Duration(milliseconds: 600),
|
||||
start: Colors.white,
|
||||
end: Colors.red.shade600,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Icon(
|
||||
Icons.mic,
|
||||
size: 24,
|
||||
color: primaryColor,
|
||||
),
|
||||
),
|
||||
@@ -43,12 +91,16 @@ class _TextFieldIconButton extends StatelessWidget {
|
||||
class ConversationBottomRow extends StatefulWidget {
|
||||
const ConversationBottomRow(
|
||||
this.controller,
|
||||
this.focusNode, {
|
||||
this.tabController,
|
||||
this.focusNode,
|
||||
this.speedDialValueNotifier, {
|
||||
super.key,
|
||||
}
|
||||
);
|
||||
final TextEditingController controller;
|
||||
final TabController tabController;
|
||||
final FocusNode focusNode;
|
||||
final ValueNotifier<bool> speedDialValueNotifier;
|
||||
|
||||
@override
|
||||
ConversationBottomRowState createState() => ConversationBottomRowState();
|
||||
@@ -56,7 +108,7 @@ class ConversationBottomRow extends StatefulWidget {
|
||||
|
||||
class ConversationBottomRowState extends State<ConversationBottomRow> {
|
||||
late StreamSubscription<bool> _keyboardVisibilitySubscription;
|
||||
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -78,22 +130,21 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
|
||||
);
|
||||
}
|
||||
|
||||
Color _getTextColor(BuildContext context) {
|
||||
// TODO(Unknown): Work on the colors
|
||||
if (MediaQuery.of(context).platformBrightness == Brightness.dark) {
|
||||
return Colors.white;
|
||||
}
|
||||
|
||||
return Colors.black;
|
||||
}
|
||||
|
||||
IconData _getSendButtonIcon(ConversationState state) {
|
||||
switch (state.sendButtonState) {
|
||||
case SendButtonState.audio: return Icons.mic;
|
||||
case SendButtonState.multi: return Icons.add;
|
||||
case SendButtonState.send: return Icons.send;
|
||||
case SendButtonState.cancelCorrection: return Icons.clear;
|
||||
}
|
||||
}
|
||||
|
||||
IconData _getPickerIcon() {
|
||||
if (widget.tabController.index == 0) {
|
||||
return Icons.insert_emoticon;
|
||||
}
|
||||
|
||||
return PhosphorIcons.stickerBold;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -108,17 +159,25 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: BlocBuilder<ConversationBloc, ConversationState>(
|
||||
buildWhen: (prev, next) => prev.sendButtonState != next.sendButtonState || prev.quotedMessage != next.quotedMessage || prev.emojiPickerVisible != next.emojiPickerVisible || prev.messageText != next.messageText || prev.messageEditing != next.messageEditing || prev.messageEditingOriginalBody != next.messageEditingOriginalBody,
|
||||
buildWhen: (prev, next) => prev.sendButtonState != next.sendButtonState || prev.quotedMessage != next.quotedMessage || prev.pickerVisible != next.pickerVisible || prev.messageText != next.messageText || prev.messageEditing != next.messageEditing || prev.messageEditingOriginalBody != next.messageEditingOriginalBody || prev.isRecording != next.isRecording,
|
||||
builder: (context, state) => Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CustomTextField(
|
||||
// TODO(Unknown): Work on the colors
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
textColor: _getTextColor(context),
|
||||
enableBoxShadow: true,
|
||||
backgroundColor: Theme
|
||||
.of(context)
|
||||
.extension<MoxxyThemeData>()!
|
||||
.conversationTextFieldColor,
|
||||
textColor: Theme
|
||||
.of(context)
|
||||
.extension<MoxxyThemeData>()!
|
||||
.conversationTextFieldTextColor,
|
||||
maxLines: 5,
|
||||
hintText: 'Send a message...',
|
||||
hintText: t.pages.conversation.messageHint,
|
||||
hintTextColor: Theme
|
||||
.of(context)
|
||||
.extension<MoxxyThemeData>()!
|
||||
.conversationTextFieldHintTextColor,
|
||||
isDense: true,
|
||||
onChanged: (value) {
|
||||
context.read<ConversationBloc>().add(
|
||||
@@ -126,51 +185,34 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
|
||||
);
|
||||
},
|
||||
contentPadding: textfieldPaddingConversation,
|
||||
fontSize: textFieldFontSizeConversation,
|
||||
cornerRadius: textfieldRadiusConversation,
|
||||
controller: widget.controller,
|
||||
topWidget: state.quotedMessage != null ? buildQuoteMessageWidget(
|
||||
state.quotedMessage!,
|
||||
isSent(state.quotedMessage!, state.jid),
|
||||
resetQuote: () => context.read<ConversationBloc>().add(QuoteRemovedEvent()),
|
||||
) : null,
|
||||
topWidget: state.quotedMessage != null ?
|
||||
buildQuoteMessageWidget(
|
||||
state.quotedMessage!,
|
||||
isSent(state.quotedMessage!, state.jid),
|
||||
resetQuote: () => context.read<ConversationBloc>().add(QuoteRemovedEvent()),
|
||||
) :
|
||||
null,
|
||||
focusNode: widget.focusNode,
|
||||
shouldSummonKeyboard: () => !state.emojiPickerVisible,
|
||||
shouldSummonKeyboard: () => !state.pickerVisible,
|
||||
prefixIcon: IntrinsicWidth(
|
||||
child: Row(
|
||||
children: [
|
||||
InkWell(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Icon(
|
||||
state.emojiPickerVisible ?
|
||||
Icons.keyboard :
|
||||
Icons.insert_emoticon,
|
||||
color: primaryColor,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
context.read<ConversationBloc>().add(EmojiPickerToggledEvent());
|
||||
},
|
||||
),
|
||||
Visibility(
|
||||
visible: state.messageText.isEmpty && state.quotedMessage == null,
|
||||
child: InkWell(
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.only(right: 8),
|
||||
child: Icon(
|
||||
PhosphorIcons.stickerBold,
|
||||
size: 24,
|
||||
color: primaryColor,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: _TextFieldIconButton(
|
||||
state.pickerVisible ?
|
||||
Icons.keyboard :
|
||||
_getPickerIcon(),
|
||||
() {
|
||||
context.read<ConversationBloc>().add(
|
||||
StickerPickerToggledEvent(),
|
||||
PickerToggledEvent(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -181,31 +223,10 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
|
||||
suffixIcon: state.messageText.isEmpty && state.quotedMessage == null ?
|
||||
IntrinsicWidth(
|
||||
child: Row(
|
||||
children: [
|
||||
_TextFieldIconButton(
|
||||
Icons.attach_file,
|
||||
() {
|
||||
context.read<ConversationBloc>().add(
|
||||
FilePickerRequestedEvent(),
|
||||
);
|
||||
},
|
||||
),
|
||||
_TextFieldIconButton(
|
||||
Icons.photo_camera,
|
||||
() {
|
||||
showNotImplementedDialog(
|
||||
'taking photos',
|
||||
context,
|
||||
);
|
||||
},
|
||||
),
|
||||
_TextFieldIconButton(
|
||||
Icons.image,
|
||||
() {
|
||||
context.read<ConversationBloc>().add(
|
||||
ImagePickerRequestedEvent(),
|
||||
);
|
||||
},
|
||||
children: const [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(right: 8),
|
||||
child: _TextFieldRecordButton(),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -217,23 +238,129 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 8),
|
||||
child: SizedBox(
|
||||
height: 45,
|
||||
width: 45,
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: AnimatedOpacity(
|
||||
opacity: state.isRecording ? 0 : 1,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
child: IgnorePointer(
|
||||
ignoring: state.isRecording,
|
||||
child: SizedBox(
|
||||
height: 45,
|
||||
width: 45,
|
||||
child: SpeedDial(
|
||||
icon: _getSendButtonIcon(state),
|
||||
backgroundColor: primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
children: [
|
||||
SpeedDialChild(
|
||||
child: const Icon(Icons.image),
|
||||
onTap: () {
|
||||
context.read<ConversationBloc>().add(
|
||||
ImagePickerRequestedEvent(),
|
||||
);
|
||||
},
|
||||
backgroundColor: primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
label: t.pages.conversation.sendImages,
|
||||
),
|
||||
SpeedDialChild(
|
||||
child: const Icon(Icons.file_present),
|
||||
onTap: () {
|
||||
context.read<ConversationBloc>().add(
|
||||
FilePickerRequestedEvent(),
|
||||
);
|
||||
},
|
||||
backgroundColor: primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
label: t.pages.conversation.sendFiles,
|
||||
),
|
||||
SpeedDialChild(
|
||||
child: const Icon(Icons.photo_camera),
|
||||
onTap: () {
|
||||
showNotImplementedDialog('taking photos', context);
|
||||
},
|
||||
backgroundColor: primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
label: t.pages.conversation.takePhotos,
|
||||
),
|
||||
],
|
||||
openCloseDial: widget.speedDialValueNotifier,
|
||||
onPress: () {
|
||||
switch (state.sendButtonState) {
|
||||
case SendButtonState.cancelCorrection:
|
||||
context.read<ConversationBloc>().add(
|
||||
MessageEditCancelledEvent(),
|
||||
);
|
||||
widget.controller.text = '';
|
||||
return;
|
||||
case SendButtonState.send:
|
||||
context.read<ConversationBloc>().add(
|
||||
MessageSentEvent(),
|
||||
);
|
||||
widget.controller.text = '';
|
||||
return;
|
||||
case SendButtonState.multi:
|
||||
widget.speedDialValueNotifier.value = !widget.speedDialValueNotifier.value;
|
||||
return;
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
BlocBuilder<ConversationBloc, ConversationState>(
|
||||
buildWhen: (prev, next) => prev.stickerPickerVisible != next.stickerPickerVisible,
|
||||
buildWhen: (prev, next) => prev.pickerVisible != next.pickerVisible,
|
||||
builder: (context, state) => Offstage(
|
||||
offstage: !state.stickerPickerVisible,
|
||||
child: StickerPicker(
|
||||
width: MediaQuery.of(context).size.width,
|
||||
offstage: !state.pickerVisible,
|
||||
child: CombinedPicker(
|
||||
tabController: widget.tabController,
|
||||
onEmojiTapped: (emoji) {
|
||||
final bloc = context.read<ConversationBloc>();
|
||||
final selection = widget.controller.selection;
|
||||
final baseOffset = max(selection.baseOffset, 0);
|
||||
final extentOffset = max(selection.extentOffset, 0);
|
||||
final prefix = bloc.state.messageText.substring(0, baseOffset);
|
||||
final suffix = bloc.state.messageText.substring(extentOffset);
|
||||
final newText = '$prefix${emoji.emoji}$suffix';
|
||||
final newValue = baseOffset + emoji.emoji.codeUnits.length;
|
||||
bloc.add(MessageTextChangedEvent(newText));
|
||||
widget.controller
|
||||
..text = newText
|
||||
..selection = TextSelection(
|
||||
baseOffset: newValue,
|
||||
extentOffset: newValue,
|
||||
);
|
||||
},
|
||||
onBackspaceTapped: () {
|
||||
// Taken from https://github.com/Fintasys/emoji_picker_flutter/blob/master/lib/src/emoji_picker.dart#L183
|
||||
final bloc = context.read<ConversationBloc>();
|
||||
final text = bloc.state.messageText;
|
||||
final selection = widget.controller.selection;
|
||||
final cursorPosition = widget.controller.selection.base.offset;
|
||||
|
||||
if (cursorPosition < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
final newTextBeforeCursor = selection
|
||||
.textBefore(text).characters
|
||||
.skipLast(1)
|
||||
.toString();
|
||||
|
||||
bloc.add(MessageTextChangedEvent(newTextBeforeCursor));
|
||||
widget.controller
|
||||
..text = newTextBeforeCursor
|
||||
..selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: newTextBeforeCursor.length),
|
||||
);
|
||||
},
|
||||
onStickerTapped: (sticker, pack) {
|
||||
context.read<ConversationBloc>().add(
|
||||
StickerSentEvent(
|
||||
@@ -245,164 +372,14 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
BlocBuilder<ConversationBloc, ConversationState>(
|
||||
buildWhen: (prev, next) => prev.emojiPickerVisible != next.emojiPickerVisible,
|
||||
builder: (context, state) => Offstage(
|
||||
offstage: !state.emojiPickerVisible,
|
||||
child: SizedBox(
|
||||
height: 250,
|
||||
child: EmojiPicker(
|
||||
onEmojiSelected: (_, emoji) {
|
||||
final bloc = context.read<ConversationBloc>();
|
||||
final selection = widget.controller.selection;
|
||||
final baseOffset = max(selection.baseOffset, 0);
|
||||
final extentOffset = max(selection.extentOffset, 0);
|
||||
final prefix = bloc.state.messageText.substring(0, baseOffset);
|
||||
final suffix = bloc.state.messageText.substring(extentOffset);
|
||||
final newText = '$prefix${emoji.emoji}$suffix';
|
||||
final newValue = baseOffset + emoji.emoji.codeUnits.length;
|
||||
bloc.add(MessageTextChangedEvent(newText));
|
||||
widget.controller
|
||||
..text = newText
|
||||
..selection = TextSelection(
|
||||
baseOffset: newValue,
|
||||
extentOffset: newValue,
|
||||
);
|
||||
},
|
||||
onBackspacePressed: () {
|
||||
// Taken from https://github.com/Fintasys/emoji_picker_flutter/blob/master/lib/src/emoji_picker.dart#L183
|
||||
final bloc = context.read<ConversationBloc>();
|
||||
final text = bloc.state.messageText;
|
||||
final selection = widget.controller.selection;
|
||||
final cursorPosition = widget.controller.selection.base.offset;
|
||||
|
||||
if (cursorPosition < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
final newTextBeforeCursor = selection
|
||||
.textBefore(text).characters
|
||||
.skipLast(1)
|
||||
.toString();
|
||||
|
||||
bloc.add(MessageTextChangedEvent(newTextBeforeCursor));
|
||||
widget.controller
|
||||
..text = newTextBeforeCursor
|
||||
..selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: newTextBeforeCursor.length),
|
||||
);
|
||||
},
|
||||
config: Config(
|
||||
bgColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
BlocBuilder<ConversationBloc, ConversationState>(
|
||||
buildWhen: (prev, next) => prev.sendButtonState != next.sendButtonState ||
|
||||
prev.isDragging != next.isDragging ||
|
||||
prev.isLocked != next.isLocked ||
|
||||
prev.emojiPickerVisible != next.emojiPickerVisible ||
|
||||
prev.stickerPickerVisible != next.stickerPickerVisible,
|
||||
builder: (context, state) {
|
||||
return Positioned(
|
||||
right: 8,
|
||||
bottom: state.emojiPickerVisible || state.stickerPickerVisible ?
|
||||
258 /* 8 (Regular padding) + 250 (Height of the pickers) */ :
|
||||
8,
|
||||
child: Visibility(
|
||||
visible: !state.isDragging && !state.isLocked,
|
||||
child: LongPressDraggable<int>(
|
||||
data: 1,
|
||||
axis: Axis.vertical,
|
||||
onDragStarted: () {
|
||||
Vibrate.feedback(FeedbackType.heavy);
|
||||
context.read<ConversationBloc>().add(
|
||||
SendButtonDragStartedEvent(),
|
||||
);
|
||||
},
|
||||
onDraggableCanceled: (_, __) {
|
||||
Vibrate.feedback(FeedbackType.heavy);
|
||||
context.read<ConversationBloc>().add(
|
||||
SendButtonDragEndedEvent(),
|
||||
);
|
||||
},
|
||||
feedback: SizedBox(
|
||||
height: 45,
|
||||
width: 45,
|
||||
child: FloatingActionButton(
|
||||
onPressed: null,
|
||||
heroTag: 'fabDragged',
|
||||
backgroundColor: Colors.red.shade600,
|
||||
child: BlinkingIcon(
|
||||
icon: Icons.mic,
|
||||
duration: const Duration(milliseconds: 600),
|
||||
start: Colors.white,
|
||||
end: Colors.red.shade600,
|
||||
),
|
||||
),
|
||||
),
|
||||
childWhenDragging: SizedBox(
|
||||
height: 45,
|
||||
width: 45,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey,
|
||||
borderRadius: BorderRadius.circular(45),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 45,
|
||||
width: 45,
|
||||
child: FloatingActionButton(
|
||||
heroTag: 'fabRest',
|
||||
onPressed: () {
|
||||
switch (state.sendButtonState) {
|
||||
case SendButtonState.audio:
|
||||
Vibrate.feedback(FeedbackType.heavy);
|
||||
Fluttertoast.showToast(
|
||||
msg: t.warnings.conversation.holdForLonger,
|
||||
gravity: ToastGravity.SNACKBAR,
|
||||
toastLength: Toast.LENGTH_SHORT,
|
||||
);
|
||||
return;
|
||||
case SendButtonState.cancelCorrection:
|
||||
context.read<ConversationBloc>().add(
|
||||
MessageEditCancelledEvent(),
|
||||
);
|
||||
widget.controller.text = '';
|
||||
return;
|
||||
case SendButtonState.send:
|
||||
context.read<ConversationBloc>().add(
|
||||
MessageSentEvent(),
|
||||
);
|
||||
widget.controller.text = '';
|
||||
return;
|
||||
}
|
||||
},
|
||||
child: Icon(
|
||||
_getSendButtonIcon(state),
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
|
||||
Positioned(
|
||||
left: 8,
|
||||
bottom: 11,
|
||||
bottom: 8,
|
||||
right: 61,
|
||||
child: BlocBuilder<ConversationBloc, ConversationState>(
|
||||
buildWhen: (prev, next) => prev.isRecording != next.isRecording,
|
||||
@@ -413,7 +390,7 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
|
||||
child: IgnorePointer(
|
||||
ignoring: !state.isRecording,
|
||||
child: SizedBox(
|
||||
height: 38,
|
||||
height: textFieldFontSizeConversation + 2 * 12 + 2,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(textfieldRadiusConversation),
|
||||
@@ -423,14 +400,14 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
|
||||
// created and destroyed to prevent the timer from running
|
||||
// until the user closes the page.
|
||||
child: state.isRecording ?
|
||||
const Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(left: 16),
|
||||
child: TimerWidget(),
|
||||
),
|
||||
) :
|
||||
null,
|
||||
const Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(left: 16),
|
||||
child: TimerWidget(),
|
||||
),
|
||||
) :
|
||||
null,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -17,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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -276,7 +276,6 @@ class ConversationsPageState extends State<ConversationsPage> with TickerProvide
|
||||
icon: Icons.chat,
|
||||
curve: Curves.bounceInOut,
|
||||
backgroundColor: primaryColor,
|
||||
// TODO(Unknown): Theme dependent?
|
||||
foregroundColor: Colors.white,
|
||||
children: [
|
||||
SpeedDialChild(
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||
import 'package:moxxyv2/shared/models/preferences.dart';
|
||||
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/widgets/topbar.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
// TODO(PapaTutuWawa): Include license text
|
||||
class SettingsAboutPage extends StatelessWidget {
|
||||
class SettingsAboutPage extends StatefulWidget {
|
||||
const SettingsAboutPage({ super.key });
|
||||
|
||||
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
||||
@@ -14,7 +18,18 @@ class SettingsAboutPage extends StatelessWidget {
|
||||
name: aboutRoute,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@override
|
||||
SettingsAboutPageState createState() => SettingsAboutPageState();
|
||||
}
|
||||
|
||||
class SettingsAboutPageState extends State<SettingsAboutPage> {
|
||||
/// The amount of taps on the Moxxy logo, if showDebugMenu is false
|
||||
int _counter = 0;
|
||||
|
||||
/// True, if the toast ("You're already a developer") has already been shown once.
|
||||
bool _alreadyShownNotificationShown = false;
|
||||
|
||||
Future<void> _openUrl(String url) async {
|
||||
if (!await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication)) {
|
||||
// TODO(Unknown): Show a popup to copy the url
|
||||
@@ -29,9 +44,45 @@ class SettingsAboutPage extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: paddingVeryLarge),
|
||||
child: Column(
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/images/logo.png',
|
||||
width: 200, height: 200,
|
||||
BlocBuilder<PreferencesBloc, PreferencesState>(
|
||||
buildWhen: (prev, next) => prev.showDebugMenu != next.showDebugMenu,
|
||||
builder: (context, state) => InkWell(
|
||||
onTap: () async {
|
||||
if (state.showDebugMenu) {
|
||||
if (_counter == 0 && !_alreadyShownNotificationShown) {
|
||||
_alreadyShownNotificationShown = true;
|
||||
await Fluttertoast.showToast(
|
||||
msg: t.pages.settings.about.debugMenuAlreadyShown,
|
||||
gravity: ToastGravity.SNACKBAR,
|
||||
toastLength: Toast.LENGTH_SHORT,
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_counter++;
|
||||
if (_counter == 10) {
|
||||
context.read<PreferencesBloc>().add(
|
||||
PreferencesChangedEvent(
|
||||
state.copyWith(
|
||||
showDebugMenu: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await Fluttertoast.showToast(
|
||||
msg: t.pages.settings.about.debugMenuShown,
|
||||
gravity: ToastGravity.SNACKBAR,
|
||||
toastLength: Toast.LENGTH_SHORT,
|
||||
);
|
||||
}
|
||||
},
|
||||
child:Image.asset(
|
||||
'assets/images/logo.png',
|
||||
width: 200, height: 200,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
t.global.title,
|
||||
@@ -55,7 +106,7 @@ class SettingsAboutPage extends StatelessWidget {
|
||||
child: Text(
|
||||
// TODO(Unknown): Generate this at build time
|
||||
t.pages.settings.about.version(
|
||||
version: '0.3.0',
|
||||
version: '0.4.1',
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
] : [],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,6 +91,11 @@ class StickersSettingsPage extends StatelessWidget {
|
||||
),
|
||||
|
||||
SectionTitle(t.pages.settings.stickers.stickerPacksSection),
|
||||
|
||||
if (stickers.stickerPacks.isEmpty)
|
||||
SettingsRow(
|
||||
title: t.pages.conversation.stickerPickerNoStickersLine1,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,12 +12,15 @@ import 'package:moxxyv2/ui/bloc/share_selection_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/stickers_bloc.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/service/data.dart';
|
||||
import 'package:share_handler/share_handler.dart';
|
||||
import 'package:moxxyv2/ui/service/sharing.dart';
|
||||
|
||||
/// Handler for when we received a [PreStartDoneEvent].
|
||||
Future<void> preStartDone(PreStartDoneEvent result, { dynamic extra }) async {
|
||||
GetIt.I.get<PreferencesBloc>().add(
|
||||
PreferencesChangedEvent(result.preferences),
|
||||
PreferencesChangedEvent(
|
||||
result.preferences,
|
||||
notify: false,
|
||||
),
|
||||
);
|
||||
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -50,12 +53,19 @@ Future<void> preStartDone(PreStartDoneEvent result, { dynamic extra }) async {
|
||||
result.stickers!,
|
||||
),
|
||||
);
|
||||
GetIt.I.get<ShareSelectionBloc>().add(
|
||||
ShareSelectionInitEvent(
|
||||
result.conversations!,
|
||||
result.roster!,
|
||||
),
|
||||
);
|
||||
|
||||
GetIt.I.get<Logger>().finest('Navigating to conversations');
|
||||
|
||||
// Only go to the Conversations page when we did not start due to a sharing intent
|
||||
final handler = ShareHandlerPlatform.instance;
|
||||
if (await handler.getInitialSharedMedia() == null) {
|
||||
final sharing = GetIt.I.get<UISharingService>();
|
||||
if (sharing.hasEarlyMedia) {
|
||||
GetIt.I.get<Logger>().finest('Early media available. Navigating to share selection');
|
||||
await sharing.handleEarlySharedMedia();
|
||||
} else {
|
||||
GetIt.I.get<Logger>().finest('Navigating to conversations');
|
||||
GetIt.I.get<NavigationBloc>().add(
|
||||
PushedNamedAndRemoveUntilEvent(
|
||||
const NavigationDestination(conversationsRoute),
|
||||
@@ -63,15 +73,14 @@ Future<void> preStartDone(PreStartDoneEvent result, { dynamic extra }) async {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
GetIt.I.get<ShareSelectionBloc>().add(
|
||||
ShareSelectionInitEvent(
|
||||
result.conversations!,
|
||||
result.roster!,
|
||||
),
|
||||
);
|
||||
} else if (result.state == preStartNotLoggedInState) {
|
||||
// Set UI data
|
||||
GetIt.I.get<UIDataService>().isLoggedIn = false;
|
||||
|
||||
// Clear shared media data
|
||||
await GetIt.I.get<UISharingService>().clearSharedMedia();
|
||||
|
||||
// Navigate to the intro page
|
||||
GetIt.I.get<Logger>().finest('Navigating to intro');
|
||||
GetIt.I.get<NavigationBloc>().add(
|
||||
PushedNamedAndRemoveUntilEvent(
|
||||
|
||||
75
lib/ui/service/sharing.dart
Normal file
75
lib/ui/service/sharing.dart
Normal file
@@ -0,0 +1,75 @@
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxxyv2/ui/bloc/share_selection_bloc.dart';
|
||||
import 'package:moxxyv2/ui/service/data.dart';
|
||||
import 'package:share_handler/share_handler.dart';
|
||||
|
||||
/// This service is responsible for storing a sharing request and or executing it.
|
||||
class UISharingService {
|
||||
/// A possible media object that was shared to Moxxy while the app was closed.
|
||||
SharedMedia? _media;
|
||||
|
||||
/// Flag indicating whether the service has already been initialized or not.
|
||||
bool _initialized = false;
|
||||
|
||||
/// Logger
|
||||
final Logger _log = Logger('UISharingService');
|
||||
|
||||
/// If [media] is non-null, forwards the metadata to the ShareSelectionBloc, which
|
||||
/// will open the share dialog.
|
||||
/// If [media] is null, then nothing will happen.
|
||||
Future<void> _handleSharedMedia(SharedMedia? media) async {
|
||||
if (media == null) return;
|
||||
|
||||
_log.finest('Handling media');
|
||||
final attachments = media.attachments ?? [];
|
||||
GetIt.I.get<ShareSelectionBloc>().add(
|
||||
ShareSelectionRequestedEvent(
|
||||
attachments.map((a) => a!.path).toList(),
|
||||
media.content,
|
||||
media.content != null ? ShareSelectionType.text : ShareSelectionType.media,
|
||||
),
|
||||
);
|
||||
|
||||
await clearSharedMedia();
|
||||
}
|
||||
|
||||
/// Clears all shared media data we (and the share_handler plugin has) have.
|
||||
Future<void> clearSharedMedia() async {
|
||||
_log.finest('Clearing media');
|
||||
await ShareHandlerPlatform.instance.resetInitialSharedMedia();
|
||||
_media = null;
|
||||
}
|
||||
|
||||
/// True if we have early media. False if not.
|
||||
bool get hasEarlyMedia => _media != null;
|
||||
|
||||
/// If Moxxy was started with a share intent, then this function is equivalent to
|
||||
/// [UISharingService._handleSharedMedia] but called with said share intent's metadata.
|
||||
Future<void> handleEarlySharedMedia() async {
|
||||
await _handleSharedMedia(_media);
|
||||
}
|
||||
|
||||
/// Sets up streams for reacting to share intents. Also stores an initial shared media
|
||||
/// object for later retrieval, if available.
|
||||
Future<void> initialize() async {
|
||||
if (_initialized) return;
|
||||
|
||||
final media = await ShareHandlerPlatform.instance.getInitialSharedMedia();
|
||||
if (media != null) {
|
||||
_log.finest('initialize: Early media is not null');
|
||||
_media = media;
|
||||
}
|
||||
|
||||
ShareHandlerPlatform.instance.sharedMediaStream.listen((SharedMedia media) async {
|
||||
if (GetIt.I.get<UIDataService>().isLoggedIn) {
|
||||
_log.finest('stream: Handle shared media via stream');
|
||||
await _handleSharedMedia(media);
|
||||
}
|
||||
|
||||
await ShareHandlerPlatform.instance.resetInitialSharedMedia();
|
||||
});
|
||||
|
||||
_initialized = true;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,59 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
|
||||
/// A theme extension for Moxxy specific colors.
|
||||
@immutable
|
||||
class MoxxyThemeData extends ThemeExtension<MoxxyThemeData> {
|
||||
const MoxxyThemeData({
|
||||
required this.conversationTextFieldColor,
|
||||
required this.profileFallbackBackgroundColor,
|
||||
required this.profileFallbackTextColor,
|
||||
required this.bubbleQuoteInTextFieldColor,
|
||||
required this.bubbleQuoteInTextFieldTextColor,
|
||||
required this.conversationTextFieldHintTextColor,
|
||||
required this.conversationTextFieldTextColor,
|
||||
});
|
||||
|
||||
/// The color of the conversation TextField
|
||||
final Color conversationTextFieldColor;
|
||||
|
||||
/// The color of the background of a user with no avatar
|
||||
final Color profileFallbackBackgroundColor;
|
||||
|
||||
/// The text color of a user with no avatar
|
||||
final Color profileFallbackTextColor;
|
||||
|
||||
/// The color of a quote bubble displayed inside the TextField
|
||||
final Color bubbleQuoteInTextFieldColor;
|
||||
|
||||
/// The color of text inside a quote bubble inside the TextField
|
||||
final Color bubbleQuoteInTextFieldTextColor;
|
||||
|
||||
/// The color of the hint text inside the TextField of the ConversationPage
|
||||
final Color conversationTextFieldHintTextColor;
|
||||
|
||||
/// The regular text color of the message TextField on the ConversationPage
|
||||
final Color conversationTextFieldTextColor;
|
||||
|
||||
@override
|
||||
MoxxyThemeData copyWith({Color? conversationTextFieldColor, Color? profileFallbackBackgroundColor, Color? profileFallbackTextColor, Color? bubbleQuoteInTextFieldColor, Color? bubbleQuoteInTextFieldTextColor, Color? conversationTextFieldHintTextColor, Color? conversationTextFieldTextColor,}) {
|
||||
return MoxxyThemeData(
|
||||
conversationTextFieldColor: conversationTextFieldColor ?? this.conversationTextFieldColor,
|
||||
profileFallbackBackgroundColor: profileFallbackBackgroundColor ?? this.profileFallbackBackgroundColor,
|
||||
profileFallbackTextColor: profileFallbackTextColor ?? this.profileFallbackTextColor,
|
||||
bubbleQuoteInTextFieldColor: bubbleQuoteInTextFieldColor ?? this.bubbleQuoteInTextFieldColor,
|
||||
bubbleQuoteInTextFieldTextColor: bubbleQuoteInTextFieldTextColor ?? this.bubbleQuoteInTextFieldTextColor,
|
||||
conversationTextFieldHintTextColor: conversationTextFieldHintTextColor ?? this.conversationTextFieldHintTextColor,
|
||||
conversationTextFieldTextColor: conversationTextFieldTextColor ?? this.conversationTextFieldTextColor,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
MoxxyThemeData lerp(ThemeExtension<MoxxyThemeData>? other, double t) {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function for quickly generating MaterialStateProperty instances that
|
||||
/// only differentiate between a color for the element's disabled state and for all
|
||||
/// other states.
|
||||
@@ -52,5 +105,28 @@ ThemeData getThemeData(BuildContext context, Brightness brightness) {
|
||||
return primaryColor;
|
||||
}),
|
||||
),
|
||||
|
||||
extensions: [
|
||||
if (brightness == Brightness.dark)
|
||||
const MoxxyThemeData(
|
||||
conversationTextFieldColor: conversationTextFieldColorDark,
|
||||
profileFallbackBackgroundColor: profileFallbackBackgroundColorDark,
|
||||
profileFallbackTextColor: profileFallbackTextColorDark,
|
||||
bubbleQuoteInTextFieldColor: bubbleQuoteInTextFieldColorDark,
|
||||
bubbleQuoteInTextFieldTextColor: bubbleQuoteInTextFieldTextColorDark,
|
||||
conversationTextFieldHintTextColor: textFieldHintTextColorDark,
|
||||
conversationTextFieldTextColor: textFieldTextColorDark,
|
||||
)
|
||||
else
|
||||
const MoxxyThemeData(
|
||||
conversationTextFieldColor: conversationTextFieldColorLight,
|
||||
profileFallbackBackgroundColor: profileFallbackBackgroundColorLight,
|
||||
profileFallbackTextColor: profileFallbackTextColorLight,
|
||||
bubbleQuoteInTextFieldColor: bubbleQuoteInTextFieldColorLight,
|
||||
bubbleQuoteInTextFieldTextColor: bubbleQuoteInTextFieldTextColorLight,
|
||||
conversationTextFieldHintTextColor: textFieldHintTextColorLight,
|
||||
conversationTextFieldTextColor: textFieldTextColorLight,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:moxxyv2/ui/helpers.dart';
|
||||
import 'package:moxxyv2/ui/theme.dart';
|
||||
|
||||
class AvatarWrapper extends StatelessWidget {
|
||||
const AvatarWrapper({ required this.radius, this.avatarUrl, this.altText, this.altIcon, this.onTapFunction, this.showEditButton = false, super.key })
|
||||
@@ -14,12 +14,13 @@ class AvatarWrapper extends StatelessWidget {
|
||||
final bool showEditButton;
|
||||
final void Function()? onTapFunction;
|
||||
|
||||
Widget _constructAlt() {
|
||||
Widget _constructAlt(BuildContext context) {
|
||||
if (altText != null) {
|
||||
return Text(
|
||||
avatarAltText(altText!),
|
||||
style: TextStyle(
|
||||
fontSize: radius * 0.8,
|
||||
color: Theme.of(context).extension<MoxxyThemeData>()!.profileFallbackTextColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -27,25 +28,26 @@ class AvatarWrapper extends StatelessWidget {
|
||||
return Icon(
|
||||
altIcon,
|
||||
size: radius,
|
||||
color: Theme.of(context).extension<MoxxyThemeData>()!.profileFallbackTextColor,
|
||||
);
|
||||
}
|
||||
|
||||
/// Either display the alt or the actual image
|
||||
Widget _avatarWrapper() {
|
||||
Widget _avatarWrapper(BuildContext context) {
|
||||
final useAlt = avatarUrl == null || avatarUrl == '';
|
||||
|
||||
return CircleAvatar(
|
||||
backgroundColor: Colors.grey[800],
|
||||
backgroundColor: Theme.of(context).extension<MoxxyThemeData>()!.profileFallbackBackgroundColor,
|
||||
backgroundImage: !useAlt ? FileImage(File(avatarUrl!)) : null,
|
||||
radius: radius,
|
||||
child: useAlt ? _constructAlt() : null,
|
||||
child: useAlt ? _constructAlt(context) : null,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _withEditButton() {
|
||||
Widget _withEditButton(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
_avatarWrapper(),
|
||||
_avatarWrapper(context),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
@@ -71,7 +73,9 @@ class AvatarWrapper extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTapFunction,
|
||||
child: showEditButton ? _withEditButton() : _avatarWrapper(),
|
||||
child: showEditButton ?
|
||||
_withEditButton(context) :
|
||||
_avatarWrapper(context),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,12 @@ class DateBubble extends StatelessWidget {
|
||||
horizontal: 8,
|
||||
vertical: 6,
|
||||
),
|
||||
child: Text(value),
|
||||
child: Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -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,
|
||||
@@ -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),
|
||||
|
||||
@@ -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!);
|
||||
},
|
||||
|
||||
@@ -58,12 +58,14 @@ class ImageChatWidget extends StatelessWidget {
|
||||
} else {
|
||||
return FileChatBaseWidget(
|
||||
message,
|
||||
Icons.image,
|
||||
message.isFileUploadNotification ? (message.filename ?? '') : filenameFromUrl(message.srcUrl!),
|
||||
message.isFileUploadNotification ?
|
||||
(message.filename ?? '') :
|
||||
filenameFromUrl(message.srcUrl!),
|
||||
radius,
|
||||
maxWidth,
|
||||
sent,
|
||||
extra: ProgressWidget(id: message.id),
|
||||
mimeType: message.mediaType,
|
||||
downloadButton: ProgressWidget(id: message.id),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -114,12 +116,14 @@ class ImageChatWidget extends StatelessWidget {
|
||||
} else {
|
||||
return FileChatBaseWidget(
|
||||
message,
|
||||
Icons.image,
|
||||
message.isFileUploadNotification ? (message.filename ?? '') : filenameFromUrl(message.srcUrl!),
|
||||
message.isFileUploadNotification ?
|
||||
(message.filename ?? '') :
|
||||
filenameFromUrl(message.srcUrl!),
|
||||
radius,
|
||||
maxWidth,
|
||||
sent,
|
||||
extra: DownloadButton(
|
||||
mimeType: message.mediaType,
|
||||
downloadButton: DownloadButton(
|
||||
onPressed: () {
|
||||
MoxplatformPlugin.handler.getDataSender().sendData(
|
||||
RequestDownloadCommand(message: message),
|
||||
|
||||
@@ -9,10 +9,10 @@ import 'package:moxxyv2/ui/widgets/chat/media/sticker.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/media/video.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/playbutton.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/quote/audio.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/quote/base.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/quote/file.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/quote/image.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/quote/sticker.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/quote/text.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/quote/video.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/shared/audio.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/shared/file.dart';
|
||||
@@ -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:
|
||||
|
||||
@@ -69,12 +69,14 @@ class VideoChatWidget extends StatelessWidget {
|
||||
} else {
|
||||
return FileChatBaseWidget(
|
||||
message,
|
||||
Icons.video_file_outlined,
|
||||
message.isFileUploadNotification ? (message.filename ?? '') : filenameFromUrl(message.srcUrl!),
|
||||
message.isFileUploadNotification ?
|
||||
(message.filename ?? '') :
|
||||
filenameFromUrl(message.srcUrl!),
|
||||
radius,
|
||||
maxWidth,
|
||||
sent,
|
||||
extra: ProgressWidget(id: message.id),
|
||||
mimeType: message.mediaType,
|
||||
downloadButton: ProgressWidget(id: message.id),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -122,12 +124,14 @@ class VideoChatWidget extends StatelessWidget {
|
||||
} else {
|
||||
return FileChatBaseWidget(
|
||||
message,
|
||||
Icons.video_file_outlined,
|
||||
message.isFileUploadNotification ? (message.filename ?? '') : filenameFromUrl(message.srcUrl!),
|
||||
message.isFileUploadNotification ?
|
||||
(message.filename ?? '') :
|
||||
filenameFromUrl(message.srcUrl!),
|
||||
radius,
|
||||
maxWidth,
|
||||
sent,
|
||||
extra: DownloadButton(
|
||||
mimeType: message.mediaType,
|
||||
downloadButton: DownloadButton(
|
||||
onPressed: () {
|
||||
MoxplatformPlugin.handler.getDataSender().sendData(
|
||||
RequestDownloadCommand(message: message),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/theme.dart';
|
||||
|
||||
/// This Widget is used to show that a message has been quoted.
|
||||
class QuoteBaseWidget extends StatelessWidget {
|
||||
@@ -20,7 +21,11 @@ class QuoteBaseWidget extends StatelessWidget {
|
||||
final bool sent;
|
||||
final void Function()? resetQuotedMessage;
|
||||
|
||||
Color _getColor() {
|
||||
Color _getColor(BuildContext context) {
|
||||
if (resetQuotedMessage != null) {
|
||||
return Theme.of(context).extension<MoxxyThemeData>()!.bubbleQuoteInTextFieldColor;
|
||||
}
|
||||
|
||||
if (sent) {
|
||||
return bubbleColorSentQuoted;
|
||||
} else {
|
||||
@@ -30,7 +35,6 @@ class QuoteBaseWidget extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const quoteLeftBorderWidth = 7.0;
|
||||
EdgeInsetsGeometry padding = const EdgeInsets.only(
|
||||
left: 8 + quoteLeftBorderWidth,
|
||||
right: 8,
|
||||
@@ -45,39 +49,42 @@ class QuoteBaseWidget extends StatelessWidget {
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Material(
|
||||
color: _getColor(),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(radiusLarge),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
color: Colors.white,
|
||||
width: quoteLeftBorderWidth,
|
||||
),
|
||||
),
|
||||
|
||||
if (resetQuotedMessage != null)
|
||||
Positioned(
|
||||
right: 3,
|
||||
top: 3,
|
||||
child: IconButton(
|
||||
onPressed: resetQuotedMessage,
|
||||
icon: const Icon(
|
||||
Icons.close,
|
||||
size: 24,
|
||||
),
|
||||
child: Material(
|
||||
color: _getColor(context),
|
||||
child: DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
left: BorderSide(
|
||||
color: Colors.white,
|
||||
width: quoteLeftBorderWidth,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: padding,
|
||||
child: child,
|
||||
),
|
||||
],
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: padding,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
|
||||
if (resetQuotedMessage != null)
|
||||
Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: IconButton(
|
||||
onPressed: resetQuotedMessage,
|
||||
icon: const Icon(
|
||||
Icons.close,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
52
lib/ui/widgets/chat/quote/helpers.dart
Normal file
52
lib/ui/widgets/chat/quote/helpers.dart
Normal file
@@ -0,0 +1,52 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/service/data.dart';
|
||||
import 'package:moxxyv2/ui/theme.dart';
|
||||
|
||||
class QuoteSenderText extends StatelessWidget {
|
||||
const QuoteSenderText({
|
||||
required this.sender,
|
||||
required this.resetQuoteNotNull,
|
||||
required this.sent,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The sender JID of the quoted message.
|
||||
final String sender;
|
||||
|
||||
/// True if resetQuote is not null.
|
||||
final bool resetQuoteNotNull;
|
||||
|
||||
/// The sent attribute passed to the quote widget.
|
||||
final bool sent;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sentBySelf = resetQuoteNotNull ?
|
||||
sent :
|
||||
sender == GetIt.I.get<UIDataService>().ownJid;
|
||||
|
||||
return Text(
|
||||
sentBySelf ?
|
||||
t.messages.you :
|
||||
GetIt.I.get<ConversationBloc>().state.conversation!.titleWithOptionalContact,
|
||||
style: const TextStyle(
|
||||
color: bubbleTextQuoteSenderColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Figures out the best text color for quotes. [context] is the surrounding
|
||||
/// BuildContext. [insideTextField] is true if the quote is used as a widget inside
|
||||
/// the TextField.
|
||||
Color getQuoteTextColor(BuildContext context, bool insideTextField) {
|
||||
if (!insideTextField) return bubbleTextQuoteColor;
|
||||
|
||||
return Theme.of(context).extension<MoxxyThemeData>()!.bubbleQuoteInTextFieldTextColor;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/quote/base.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/quote/helpers.dart';
|
||||
|
||||
class QuotedMediaBaseWidget extends StatelessWidget {
|
||||
const QuotedMediaBaseWidget(
|
||||
@@ -28,7 +29,24 @@ class QuotedMediaBaseWidget extends StatelessWidget {
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: Text(text),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
QuoteSenderText(
|
||||
sender: message.sender,
|
||||
resetQuoteNotNull: resetQuote != null,
|
||||
sent: sent,
|
||||
),
|
||||
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: getQuoteTextColor(context, resetQuote != null),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
44
lib/ui/widgets/chat/quote/text.dart
Normal file
44
lib/ui/widgets/chat/quote/text.dart
Normal file
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/quote/base.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/quote/helpers.dart';
|
||||
|
||||
class QuotedTextWidget extends StatelessWidget {
|
||||
const QuotedTextWidget(
|
||||
this.message,
|
||||
this.sent, {
|
||||
this.resetQuote,
|
||||
super.key,
|
||||
}
|
||||
);
|
||||
final Message message;
|
||||
final bool sent;
|
||||
final void Function()? resetQuote;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return QuoteBaseWidget(
|
||||
message,
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
QuoteSenderText(
|
||||
sender: message.sender,
|
||||
resetQuoteNotNull: resetQuote != null,
|
||||
sent: sent,
|
||||
),
|
||||
|
||||
Text(
|
||||
message.body,
|
||||
style: TextStyle(
|
||||
color: getQuoteTextColor(context, resetQuote != null),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
sent,
|
||||
resetQuotedMessage: resetQuote,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ class TextChatWidget extends StatelessWidget {
|
||||
child: ParsedText(
|
||||
text: getMessageText(),
|
||||
style: TextStyle(
|
||||
color: const Color(0xffffffff),
|
||||
color: bubbleTextColor,
|
||||
fontSize: fontsize,
|
||||
),
|
||||
parse: [
|
||||
|
||||
79
lib/ui/widgets/combined_picker.dart
Normal file
79
lib/ui/widgets/combined_picker.dart
Normal file
@@ -0,0 +1,79 @@
|
||||
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:moxxyv2/shared/models/sticker.dart';
|
||||
import 'package:moxxyv2/shared/models/sticker_pack.dart';
|
||||
import 'package:moxxyv2/ui/bloc/stickers_bloc.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/widgets/sticker_picker.dart';
|
||||
import 'package:phosphor_flutter/phosphor_flutter.dart';
|
||||
|
||||
class CombinedPicker extends StatelessWidget {
|
||||
const CombinedPicker({
|
||||
required this.tabController,
|
||||
required this.onEmojiTapped,
|
||||
required this.onBackspaceTapped,
|
||||
required this.onStickerTapped,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The controlling tab controller
|
||||
final TabController tabController;
|
||||
|
||||
/// Called when an emoji has been tapped from the list.
|
||||
final void Function(Emoji) onEmojiTapped;
|
||||
|
||||
/// Called when the backspace button has been tapped
|
||||
final void Function() onBackspaceTapped;
|
||||
|
||||
/// Called when a sticker has been tapped
|
||||
final void Function(Sticker, StickerPack) onStickerTapped;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<StickersBloc, StickersState>(
|
||||
builder: (context, state) {
|
||||
final scaffoldColor = Theme.of(context).scaffoldBackgroundColor;
|
||||
final width = MediaQuery.of(context).size.width;
|
||||
return SizedBox(
|
||||
height: pickerHeight,
|
||||
width: width,
|
||||
child: ColoredBox(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: Column(
|
||||
children: [
|
||||
TabBar(
|
||||
controller: tabController,
|
||||
indicatorColor: primaryColor,
|
||||
tabs: const [
|
||||
Tab(icon: Icon(Icons.insert_emoticon)),
|
||||
Tab(icon: Icon(PhosphorIcons.stickerBold)),
|
||||
],
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: tabController,
|
||||
children: [
|
||||
EmojiPicker(
|
||||
onEmojiSelected: (_, emoji) => onEmojiTapped(emoji),
|
||||
onBackspacePressed: onBackspaceTapped,
|
||||
config: Config(
|
||||
bgColor: scaffoldColor,
|
||||
),
|
||||
),
|
||||
StickerPicker(
|
||||
width: width,
|
||||
onStickerTapped: onStickerTapped,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -154,19 +154,27 @@ class ConversationsListRowState extends State<ConversationsListRow> {
|
||||
);
|
||||
}
|
||||
} else if (widget.conversation.lastMessage!.mediaType!.startsWith('image/')) {
|
||||
preview = SharedImageWidget(
|
||||
widget.conversation.lastMessage!.mediaUrl!,
|
||||
borderRadius: 5,
|
||||
size: 30,
|
||||
);
|
||||
if (widget.conversation.lastMessage!.mediaUrl == null) {
|
||||
preview = const SizedBox();
|
||||
} else {
|
||||
preview = SharedImageWidget(
|
||||
widget.conversation.lastMessage!.mediaUrl!,
|
||||
borderRadius: 5,
|
||||
size: 30,
|
||||
);
|
||||
}
|
||||
} else if (widget.conversation.lastMessage!.mediaType!.startsWith('video/')) {
|
||||
preview = SharedVideoWidget(
|
||||
widget.conversation.lastMessage!.mediaUrl!,
|
||||
widget.conversation.jid,
|
||||
widget.conversation.lastMessage!.mediaType!,
|
||||
borderRadius: 5,
|
||||
size: 30,
|
||||
);
|
||||
if (widget.conversation.lastMessage!.mediaUrl == null) {
|
||||
preview = const SizedBox();
|
||||
} else {
|
||||
preview = SharedVideoWidget(
|
||||
widget.conversation.lastMessage!.mediaUrl!,
|
||||
widget.conversation.jid,
|
||||
widget.conversation.lastMessage!.mediaType!,
|
||||
borderRadius: 5,
|
||||
size: 30,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Padding(
|
||||
|
||||
@@ -131,17 +131,9 @@ class StickerPicker extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<StickersBloc, StickersState>(
|
||||
builder: (context, state) {
|
||||
|
||||
return SizedBox(
|
||||
height: 250,
|
||||
width: MediaQuery.of(context).size.width,
|
||||
child: ColoredBox(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: _buildList(context, state),
|
||||
),
|
||||
),
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: _buildList(context, state),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ class CustomTextField extends StatelessWidget {
|
||||
this.errorText,
|
||||
this.labelText,
|
||||
this.hintText,
|
||||
this.hintTextColor,
|
||||
this.suffix,
|
||||
this.suffixText,
|
||||
this.topWidget,
|
||||
@@ -31,6 +32,7 @@ class CustomTextField extends StatelessWidget {
|
||||
this.onTap,
|
||||
this.shouldSummonKeyboard,
|
||||
this.focusNode,
|
||||
this.fontSize,
|
||||
super.key,
|
||||
});
|
||||
final double cornerRadius;
|
||||
@@ -54,8 +56,10 @@ class CustomTextField extends StatelessWidget {
|
||||
final int minLines;
|
||||
final Color? backgroundColor;
|
||||
final Color? textColor;
|
||||
final Color? hintTextColor;
|
||||
final double? borderWidth;
|
||||
final Color? borderColor;
|
||||
final double? fontSize;
|
||||
final TextEditingController? controller;
|
||||
final ValueChanged<String>? onChanged;
|
||||
final void Function()? onTap;
|
||||
@@ -64,7 +68,12 @@ class CustomTextField extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = textColor != null ? TextStyle(color: textColor) : null;
|
||||
final style = textColor != null ?
|
||||
TextStyle(
|
||||
color: textColor,
|
||||
fontSize: fontSize,
|
||||
) :
|
||||
null;
|
||||
return Column(
|
||||
children: [
|
||||
DecoratedBox(
|
||||
@@ -105,7 +114,10 @@ class CustomTextField extends StatelessWidget {
|
||||
isDense: isDense,
|
||||
labelStyle: style,
|
||||
suffixStyle: style,
|
||||
hintStyle: style,
|
||||
hintStyle: TextStyle(
|
||||
color: hintTextColor,
|
||||
fontSize: fontSize,
|
||||
),
|
||||
prefixIcon: prefixIcon,
|
||||
prefixIconConstraints: prefixIconConstraints,
|
||||
suffixIconConstraints: suffixIconConstraints,
|
||||
|
||||
21
pubspec.lock
21
pubspec.lock
@@ -337,13 +337,6 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
dio:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dio
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "4.0.6"
|
||||
emoji_picker_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -836,16 +829,18 @@ packages:
|
||||
description:
|
||||
path: "packages/moxxmpp"
|
||||
ref: HEAD
|
||||
resolved-ref: "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"
|
||||
|
||||
14
pubspec.yaml
14
pubspec.yaml
@@ -3,7 +3,7 @@ description: An experimental XMPP client
|
||||
|
||||
publish_to: 'none'
|
||||
|
||||
version: 0.3.0+5
|
||||
version: 0.4.1+9
|
||||
|
||||
environment:
|
||||
sdk: ">=2.17.0 <3.0.0"
|
||||
@@ -23,7 +23,6 @@ dependencies:
|
||||
#cupertino_icons: 1.0.2
|
||||
dart_emoji: 0.2.0+2
|
||||
decorated_icon: 1.2.1
|
||||
dio: 4.0.6
|
||||
emoji_picker_flutter: 1.3.1
|
||||
external_path: 1.0.1
|
||||
file_picker: 5.0.1
|
||||
@@ -75,7 +74,7 @@ dependencies:
|
||||
native_imaging: 0.1.0
|
||||
omemo_dart:
|
||||
hosted: https://git.polynom.me/api/packages/PapaTutuWawa/pub
|
||||
version: 0.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
17
scripts/build.sh
Normal file
@@ -0,0 +1,17 @@
|
||||
#!/bin/bash
|
||||
[[ $1 = "--clean" ]] && flutter clean
|
||||
|
||||
# Build everything again
|
||||
flutter pub run build_runner build
|
||||
|
||||
# Build the release apk
|
||||
flutter build apk \
|
||||
--release \
|
||||
--split-per-abi
|
||||
|
||||
# Create a folder with releases
|
||||
[[ -d ./release ]] && rm -rf ./release
|
||||
mkdir release
|
||||
cp build/app/outputs/flutter-apk/app-arm64-v8a-release.apk ./release/moxxy-arm64-v8a-release.apk
|
||||
cp build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk ./release/moxxy-armeabi-v7a-release.apk
|
||||
cp build/app/outputs/flutter-apk/app-x86_64-release.apk ./release/moxxy-x86_64-release.apk
|
||||
Reference in New Issue
Block a user