Compare commits

...

56 Commits

Author SHA1 Message Date
7068b989ef Merge pull request 'Implement Stickers' (#184) from feat/stickers into master
Reviewed-on: https://codeberg.org/moxxy/moxxyv2/pulls/184
2022-12-19 14:38:43 +00:00
820fda78e7 fix(ui): Fix sticker overviews having a black background 2022-12-19 15:35:22 +01:00
d758423ec6 fix(ui): Close the pickers when starting an audio message 2022-12-19 15:29:50 +01:00
5472f097a4 fix(meta): Depend on a git revision of moxxmpp 2022-12-19 14:15:13 +01:00
e373f5cffe feat(service): Implement automatic sticker pack downloading 2022-12-19 14:09:30 +01:00
f04729261b feat(ui): Add toasts 2022-12-19 13:33:05 +01:00
b6c8778aec fix(ui): Remove the restriction on clickable sticker messages 2022-12-19 12:48:15 +01:00
8dfe8d55a0 docs(meta): Add sticker pack format docs 2022-12-19 12:46:49 +01:00
36b7d5ce42 feat(ui): Show a spinner while a sticker pack import is running 2022-12-19 12:38:50 +01:00
8d780c3252 fix(ui): Fix alignment of description 2022-12-19 12:36:36 +01:00
a841d5de2d fix(ui): Some translations were off 2022-12-19 12:36:21 +01:00
fdd8d306f7 fix(ui): Close the emoji picker/sticker picker if the keyboard is visible 2022-12-19 12:14:25 +01:00
9510a0fced feat(ui): Make managing sticker packs nicer 2022-12-19 12:02:26 +01:00
c3ec9dfb11 fix(ui): Do not trigger the sticker pack page for sent messages 2022-12-18 20:28:29 +01:00
82c136b684 feat(ui): Update the conversation when stickers get added or removed 2022-12-18 20:19:49 +01:00
ea4bb752b9 fix(service): Guard against importing a sticker pack twice 2022-12-18 18:56:39 +01:00
bac673df99 fix(ui): Give the StickerPicker a background 2022-12-18 18:56:23 +01:00
df2c2f5e4b feat(ui): Honour restricted sticker packs 2022-12-18 18:25:44 +01:00
8c3863f970 feat(ui): Handle sticker XMPP URIs 2022-12-18 18:14:29 +01:00
bc49e31164 fix(service): Add missing attributes to stickers and sticker packs 2022-12-18 17:39:25 +01:00
ce4c54b0d5 fix(service): Freshly downloaded sticker packs are shown as still remote 2022-12-18 17:23:06 +01:00
7b09cdeefd feat(ui): Fix sticker messages not being rebuilt after downloading sticker pack 2022-12-18 17:20:50 +01:00
39dc96ab7a feat(ui): Add a shimmer for loading stickers 2022-12-18 17:10:10 +01:00
2d13ff328e feat(service): Publish a sticker pack after downloading it 2022-12-18 15:15:14 +01:00
53dd598547 feat(meta): Implement installing a remote sticker pack 2022-12-18 15:05:53 +01:00
40b4a540a8 feat(ui): Implement showing a remote sticker pack 2022-12-18 14:20:18 +01:00
33ae53c199 feat(meta): Mark XEP-0449 as complete 2022-12-18 00:57:34 +01:00
97e9b0636b fix(service): Fix OOB fallback messing the body up 2022-12-18 00:56:46 +01:00
b0b21e9d53 feat(ui): Prepare for remote sticker packs 2022-12-17 21:54:12 +01:00
53d5402502 feat(ui): Add a wrapper for local and remote images 2022-12-17 21:43:02 +01:00
a190a9564e fix(service): Silence some warning 2022-12-17 21:36:22 +01:00
7846520788 feat(service): Show 'Sticker' in the notification 2022-12-17 21:34:35 +01:00
3444683983 fix(service): Fix sent stickers not being visible in the preview 2022-12-17 21:29:06 +01:00
00118ddafe fix(meta): Switch to a hashKey 2022-12-17 21:16:38 +01:00
525ba293e3 feat(service): Remove sticker pack files on removal 2022-12-17 19:40:12 +01:00
071f6c08fd feat(ui): Add forgotten i18n string 2022-12-17 19:33:40 +01:00
da70236a45 feat(ui): Translate missing strings 2022-12-17 19:31:39 +01:00
cfdda2d293 feat(meta): Cleanup + simple sticker pack management 2022-12-17 19:24:20 +01:00
aba265d787 feat(service): Do not import sticker packs that are restricted 2022-12-17 17:42:54 +01:00
bbcb37bc4e feat(meta): Update DOAP 2022-12-17 17:34:58 +01:00
eff7d7493d feat(service): Calculate the sticker pack's id on import 2022-12-17 17:34:09 +01:00
730916758e feat(ui): Implement a simple sticker overview page 2022-12-17 16:21:14 +01:00
9acfe2751e feat(ui): Show the sticker in the conversation preview 2022-12-17 14:04:12 +01:00
386569d7cf feat(ui): Correctly handle quoting stickers 2022-12-17 14:00:41 +01:00
39a7e1eb19 feat(ui): Fix linter issues + i18n 2022-12-17 13:53:22 +01:00
f492845235 feat(ui): Somewhat handle not locally available stickers 2022-12-17 13:45:08 +01:00
ab42fc8b57 feat(ui): Add a sticker settings page 2022-12-17 13:36:44 +01:00
a5a9fce330 fix(ui): Fix crash 2022-12-17 13:21:21 +01:00
a70286dda4 feat(service): Allow receiving stickers 2022-12-17 12:40:50 +01:00
2b3e587be4 feat(meta): Display and sending of stickers 2022-12-17 12:22:10 +01:00
ebfac9730b fix(meta): Fix linter issues 2022-12-16 23:09:27 +01:00
fbd3c6ca92 refactor(ui): Refactor the StickerPicker 2022-12-16 23:02:40 +01:00
1cd3dabcea fix(ui): Make the send button's bottom padding adhere to the pickers 2022-12-16 22:54:31 +01:00
eba17880d0 feat(meta): Adapt the sticker picker 2022-12-16 22:49:59 +01:00
c168f910a9 feat(service): Allow importing sticker packs 2022-12-16 22:33:06 +01:00
98dd704fda feat(meta): Begin work in stickers 2022-12-16 20:58:02 +01:00
61 changed files with 2890 additions and 86 deletions

View File

@ -32,6 +32,7 @@
"video": "Video",
"audio": "Audio",
"file": "File",
"sticker": "Sticker",
"retracted": "The message has been retracted",
"retractedFallback": "A previous message has been retracted but your client does not support it"
},
@ -185,6 +186,14 @@
"blur": "Blur background",
"setAsBackground": "Set as background image"
},
"stickerPack": {
"removeConfirmTitle": "Remove sticker pack",
"removeConfirmBody": "Are you sure you want to remove this sticker pack?",
"installConfirmTitle": "Install sticker pack",
"installConfirmBody": "Are you sure you want to install this sticker pack?",
"restricted": "This sticker pack is restricted. That means that the stickers will be displayed but cannot be sent.",
"fetchingFailure": "Could not find the sticker pack"
},
"settings": {
"settings": {
"title": "Settings",
@ -271,6 +280,17 @@
"urlEmpty": "URL cannot be empty",
"urlInvalid": "Invalid URL",
"redirectDialogTitle": "$serviceName Redirect"
},
"stickers": {
"title": "Stickers",
"stickerSection": "Sticker",
"displayStickers": "Display stickers in chat",
"autoDownload": "Automatically download stickers",
"autoDownloadBody": "If enabled, stickers are automatically downloaded when the sender is in your contact list.",
"stickerPacksSection": "Sticker packs",
"importStickerPack": "Import sticker pack",
"importSuccess": "Sticker pack successfully imported",
"importFailure": "Failed to import sticker pack"
}
}
}

View File

@ -32,6 +32,7 @@
"video": "Video",
"audio": "Audio",
"file": "Datei",
"sticker": "Sticker",
"retracted": "Die Nachricht wurde zurückgezogen",
"retractedFallback": "Eine vorherige Nachricht wurde zurückgezogen. Dein Client unterstüzt dies jedoch nicht"
},
@ -166,10 +167,10 @@
"recreateOwnDeviceConfirmBody": "Das wird die kryptographische Identität dieses Geräts neu erstellen. Wenn Kontakte die kryptographische Indentität verifiziert haben, dann müssen diese es erneut tun. Fortfahren?"
},
"devices": {
"title": "Devices",
"recreateSessions": "Rebuild sessions",
"recreateSessionsConfirmTitle": "Rebuild sessions?",
"recreateSessionsConfirmBody": "This will recreate the cryptographic sessions with your own devices. Use only if your own devices throw decryption errors."
"title": "Geräte",
"recreateSessions": "Sessions zurücksetzen",
"recreateSessionsConfirmTitle": "Sessions zurücksetzen?",
"recreateSessionsConfirmBody": "Dies wird alle Sessions mit Deinen Geräten neu erstellen. Tue dies nur, wenn deine Geräte Fehler beim Entschlüsseln erzeugen."
}
},
"blocklist": {
@ -185,6 +186,14 @@
"blur": "Hintergrund weichzeichnen",
"setAsBackground": "Als Hintergrundbild festlegen"
},
"stickerPack": {
"removeConfirmTitle": "Stickerpack entfernen",
"removeConfirmBody": "Bist Du Dir sicher, dass du das Stickerpack entfernen möchtest?",
"installConfirmTitle": "Stickerpack installieren",
"installConfirmBody": "Bist Du Dir sicher, dass Du das Stickerpack installieren möchtest?",
"restricted": "Dieses Stickerpack ist eingeschränkt. Das bedeutet, dass es im Chat angezeigt wird, jedoch nicht versendet werden kann.",
"fetchingFailure": "Konnte das Stickerpack nicht finden"
},
"settings": {
"settings": {
"title": "Einstellungen",
@ -271,6 +280,17 @@
"urlEmpty": "URL kann nicht leer sein",
"urlInvalid": "Ungültige URL",
"redirectDialogTitle": "${serviceName}weiterleitung"
},
"stickers": {
"title": "Stickers",
"stickerSection": "Sticker",
"displayStickers": "Sticker im Chat anzeigen",
"autoDownload": "Sticker automatisch herunterladen",
"autoDownloadBody": "Wenn aktiviert, dann werden Sticker automatisch heruntergeladen, wenn der Sender in der Kontaktliste ist.",
"stickerPacksSection": "Stickerpacks",
"importStickerPack": "Stickerpack importieren",
"importSuccess": "Stickerpack erfolgreich importiert",
"importFailure": "Beim Import des Stickerpacks ist ein Fehler aufgetreten"
}
}
}

43
docs/stickerpacks.md Normal file
View File

@ -0,0 +1,43 @@
# Sticker Packs
Moxxy supports sending and receiving sticker packs using XEP-0449 version 0.1.1. Sticker
packs can also be imported using a Moxxy specific format.
## File Format
A Moxxy sticker pack is a flat tar archive that contains the following files:
- `urn.xmpp.stickers.0.xml`
- The sticker files
### `urn.xmpp.stickers.0.xml`
This file is the sticker pack's metadata file. It describes the sticker pack the same
way as the examples in XEP-0449 do. There are, however, some differences:
- Each `<file />` element must contain a `<name />` element that matches with a file in the tar archive
- Each sticker MUST contain at least one HTTP(s) source
- The `<hash />` of the `<pack />` element is ignored as Moxxy computes it itself, so it can be omitted
An example for the metadata file is the following:
```xml
<pack xmlns='urn:xmpp:stickers:0'>
<name>Example</name>
<summary>Example sticker pack.</summary>
<item>
<file xmlns='urn:xmpp:file:metadata:0'>
<media-type>image/png</media-type>
<desc>:some-sticker:</desc>
<name>suprise.png</name>
<size>531910</size>
<dimensions>1030x1030</dimensions>
<hash xmlns='urn:xmpp:hashes:2' algo='sha-256'>1Ha4okUGNRAA04KibwWUmklqqBqdhg7+20dfsr/wLik=</hash>
</file>
<sources xmlns='urn:xmpp:sfs:0'>
<url-data xmlns='http://jabber.org/protocol/url-data' target='...' />
</sources>
</item>
<!-- ... -->
</pack>
```

View File

@ -36,6 +36,9 @@ files:
roster:
type: List<RosterItem>?
deserialise: true
stickers:
type: List<StickerPack>?
deserialise: true
# Returned by [GetMessagesForJidCommand]
- name: MessagesResultEvent
extends: BackgroundEvent
@ -208,6 +211,53 @@ files:
conversationJid: String
title: String
avatarUrl: String
- name: StickerPackImportSuccessEvent
extends: BackgroundEvent
implements:
- JsonImplementation
attributes:
stickerPack:
type: StickerPack
deserialise: true
- name: StickerPackImportFailureEvent
extends: BackgroundEvent
implements:
- JsonImplementation
attributes:
- name: FetchStickerPackSuccessResult
extends: BackgroundEvent
implements:
- JsonImplementation
attributes:
stickerPack:
type: StickerPack
deserialise: true
- name: FetchStickerPackFailureResult
extends: BackgroundEvent
implements:
- JsonImplementation
attributes:
- name: StickerPackInstallSuccessEvent
extends: BackgroundEvent
implements:
- JsonImplementation
attributes:
stickerPack:
type: StickerPack
deserialise: true
- name: StickerPackInstallFailureEvent
extends: BackgroundEvent
implements:
- JsonImplementation
attributes:
- name: StickerPackAddedEvent
extends: BackgroundEvent
implements:
- JsonImplementation
attributes:
stickerPack:
type: StickerPack
deserialise: true
generate_builder: true
builder_name: "Event"
builder_baseclass: "BackgroundEvent"
@ -442,6 +492,41 @@ files:
attributes:
deviceId: int
jid: String
- name: ImportStickerPackCommand
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
path: String
- name: RemoveStickerPackCommand
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
stickerPackId: String
- name: SendStickerCommand
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
stickerPackId: String
stickerHashKey: String
recipient: String
- name: FetchStickerPackCommand
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
stickerPackId: String
jid: String
- name: InstallStickerPackCommand
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
stickerPack:
type: StickerPack
deserialise: true
generate_builder: true
# get${builder_Name}FromJson
builder_name: "Command"

View File

@ -27,6 +27,8 @@ import 'package:moxxyv2/ui/bloc/sendfiles_bloc.dart';
import 'package:moxxyv2/ui/bloc/server_info_bloc.dart';
import 'package:moxxyv2/ui/bloc/share_selection_bloc.dart';
import 'package:moxxyv2/ui/bloc/sharedmedia_bloc.dart';
import 'package:moxxyv2/ui/bloc/sticker_pack_bloc.dart';
import 'package:moxxyv2/ui/bloc/stickers_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/events.dart';
/*
@ -55,9 +57,11 @@ import 'package:moxxyv2/ui/pages/settings/licenses.dart';
import 'package:moxxyv2/ui/pages/settings/network.dart';
import 'package:moxxyv2/ui/pages/settings/privacy/privacy.dart';
import 'package:moxxyv2/ui/pages/settings/settings.dart';
import 'package:moxxyv2/ui/pages/settings/stickers.dart';
import 'package:moxxyv2/ui/pages/share_selection.dart';
import 'package:moxxyv2/ui/pages/sharedmedia.dart';
import 'package:moxxyv2/ui/pages/splashscreen/splashscreen.dart';
import 'package:moxxyv2/ui/pages/sticker_pack.dart';
import 'package:moxxyv2/ui/pages/util/qrcode.dart';
import 'package:moxxyv2/ui/service/data.dart';
import 'package:moxxyv2/ui/service/progress.dart';
@ -95,6 +99,8 @@ void setupBlocs(GlobalKey<NavigatorState> navKey) {
GetIt.I.registerSingleton<ServerInfoBloc>(ServerInfoBloc());
GetIt.I.registerSingleton<DevicesBloc>(DevicesBloc());
GetIt.I.registerSingleton<OwnDevicesBloc>(OwnDevicesBloc());
GetIt.I.registerSingleton<StickersBloc>(StickersBloc());
GetIt.I.registerSingleton<StickerPackBloc>(StickerPackBloc());
}
// TODO(Unknown): Replace all Column(children: [ Padding(), Padding, ...]) with a
@ -165,6 +171,12 @@ void main() async {
BlocProvider<OwnDevicesBloc>(
create: (_) => GetIt.I.get<OwnDevicesBloc>(),
),
BlocProvider<StickersBloc>(
create: (_) => GetIt.I.get<StickersBloc>(),
),
BlocProvider<StickerPackBloc>(
create: (_) => GetIt.I.get<StickerPackBloc>(),
),
],
child: TranslationProvider(
child: MyApp(navKey),
@ -303,6 +315,8 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
case qrCodeScannerRoute: return QrCodeScanningPage.getRoute(
settings.arguments! as QrCodeScanningArguments,
);
case stickersRoute: return StickersSettingsPage.route;
case stickerPackRoute: return StickerPackPage.route;
}
return null;

View File

@ -12,6 +12,8 @@ const omemoTrustEnableListTable = 'OmemoTrustEnableList';
const omemoFingerprintCache = 'OmemoFingerprintCache';
const xmppStateTable = 'XmppState';
const contactsTable = 'Contacts';
const stickersTable = 'Stickers';
const stickerPacksTable = 'StickerPacks';
const typeString = 0;
const typeInt = 1;

View File

@ -55,7 +55,9 @@ Future<void> createDatabase(Database db, int version) async {
isEdited INTEGER NOT NULL,
reactions TEXT NOT NULL,
containsNoStore INTEGER NOT NULL,
CONSTRAINT fk_quote FOREIGN KEY (quote_id) REFERENCES $messagesTable (id)
stickerPackId TEXT,
stickerHashKey TEXT,
CONSTRAINT fk_quote FOREIGN KEY (quote_id) REFERENCES $messagesTable (id),
)''',
);
@ -126,6 +128,37 @@ Future<void> createDatabase(Database db, int version) async {
)''',
);
// Stickers
await db.execute(
'''
CREATE TABLE $stickersTable (
hashKey TEXT PRIMARY KEY,
mediaType TEXT NOT NULL,
desc TEXT NOT NULL,
size INTEGER NOT NULL,
width INTEGER,
height INTEGER,
hashes TEXT NOT NULL,
urlSources TEXT NOT NULL,
path TEXT NOT NULL,
stickerPackId TEXT NOT NULL,
suggests TEXT NOT NULL,
CONSTRAINT fk_sticker_pack FOREIGN KEY (stickerPackId) REFERENCES $stickerPacksTable (id)
ON DELETE CASCADE
)''',
);
await db.execute(
'''
CREATE TABLE $stickerPacksTable (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL,
hashAlgorithm TEXT NOT NULL,
hashValue TEXT NOT NULL,
restricted INTEGER NOT NULL
)''',
);
// OMEMO
await db.execute(
'''

View File

@ -22,7 +22,14 @@ import 'package:moxxyv2/service/database/migrations/0000_reactions_store_hint.da
import 'package:moxxyv2/service/database/migrations/0000_retraction.dart';
import 'package:moxxyv2/service/database/migrations/0000_retraction_conversation.dart';
import 'package:moxxyv2/service/database/migrations/0000_shared_media.dart';
import 'package:moxxyv2/service/database/migrations/0000_stickers.dart';
import 'package:moxxyv2/service/database/migrations/0000_stickers_hash_key.dart';
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_xmpp_state.dart';
import 'package:moxxyv2/service/helpers.dart';
import 'package:moxxyv2/service/not_specified.dart';
import 'package:moxxyv2/service/omemo/omemo.dart';
import 'package:moxxyv2/service/omemo/types.dart';
@ -34,6 +41,8 @@ import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/shared/models/preferences.dart';
import 'package:moxxyv2/shared/models/reaction.dart';
import 'package:moxxyv2/shared/models/roster.dart';
import 'package:moxxyv2/shared/models/sticker.dart' as sticker;
import 'package:moxxyv2/shared/models/sticker_pack.dart' as sticker_pack;
import 'package:omemo_dart/omemo_dart.dart';
import 'package:path/path.dart' as path;
import 'package:random_string/random_string.dart';
@ -70,7 +79,7 @@ class DatabaseService {
_db = await openDatabase(
dbPath,
password: key,
version: 16,
version: 22,
onCreate: createDatabase,
onConfigure: (db) async {
// In order to do schema changes during database upgrades, we disable foreign
@ -143,6 +152,30 @@ class DatabaseService {
_log.finest('Running migration for database version 16');
await upgradeFromV15ToV16(db);
}
if (oldVersion < 17) {
_log.finest('Running migration for database version 17');
await upgradeFromV16ToV17(db);
}
if (oldVersion < 18) {
_log.finest('Running migration for database version 18');
await upgradeFromV17ToV18(db);
}
if (oldVersion < 19) {
_log.finest('Running migration for database version 19');
await upgradeFromV18ToV19(db);
}
if (oldVersion < 20) {
_log.finest('Running migration for database version 20');
await upgradeFromV19ToV20(db);
}
if (oldVersion < 21) {
_log.finest('Running migration for database version 21');
await upgradeFromV20ToV21(db);
}
if (oldVersion < 22) {
_log.finest('Running migration for database version 22');
await upgradeFromV21ToV22(db);
}
},
);
@ -388,6 +421,8 @@ class DatabaseService {
bool isDownloading = false,
bool isUploading = false,
int? mediaSize,
String? stickerPackId,
String? stickerHashKey,
}
) async {
var m = Message(
@ -422,6 +457,8 @@ class DatabaseService {
isUploading: isUploading,
isDownloading: isDownloading,
mediaSize: mediaSize,
stickerPackId: stickerPackId,
stickerHashKey: stickerHashKey,
);
if (quoteId != null) {
@ -1128,4 +1165,96 @@ class DatabaseService {
whereArgs: [id],
);
}
Future<void> addStickerPackFromData(sticker_pack.StickerPack pack) async {
await _db.insert(
stickerPacksTable,
pack.toDatabaseJson(),
);
}
Future<sticker.Sticker> addStickerFromData(
String mediaType,
String desc,
int size,
int? width,
int? height,
Map<String, String> hashes,
List<String> urlSources,
String path,
String stickerPackId,
Map<String, String> suggests,
) async {
final s = sticker.Sticker(
getStickerHashKey(hashes),
mediaType,
desc,
size,
width,
height,
hashes,
urlSources,
path,
stickerPackId,
suggests,
);
await _db.insert(stickersTable, s.toDatabaseJson());
return s;
}
Future<List<sticker_pack.StickerPack>> loadStickerPacks() async {
final rawPacks = await _db.query(stickerPacksTable);
final stickerPacks = List<sticker_pack.StickerPack>.empty(growable: true);
for (final pack in rawPacks) {
final rawStickers = await _db.query(
stickersTable,
where: 'stickerPackId = ?',
whereArgs: [pack['id']! as String],
);
stickerPacks.add(
sticker_pack.StickerPack.fromDatabaseJson(
pack,
rawStickers
.map(sticker.Sticker.fromDatabaseJson)
.toList(),
),
);
}
return stickerPacks;
}
Future<void> removeStickerPackById(String id) async {
await _db.delete(
stickerPacksTable,
where: 'id = ?',
whereArgs: [id],
);
}
Future<sticker_pack.StickerPack?> getStickerPackById(String id) async {
final rawPack = await _db.query(
stickerPacksTable,
where: 'id = ?',
whereArgs: [id],
limit: 1,
);
if (rawPack.isEmpty) return null;
final rawStickers = await _db.query(
stickersTable,
where: 'stickerPackId = ?',
whereArgs: [id],
);
return sticker_pack.StickerPack.fromDatabaseJson(
rawPack.first,
rawStickers
.map(sticker.Sticker.fromDatabaseJson)
.toList(),
);
}
}

View File

@ -0,0 +1,59 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/shared/models/preference.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV16ToV17(Database db) async {
await db.execute(
'''
CREATE TABLE $stickersTable (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mediaType TEXT NOT NULL,
desc TEXT NOT NULL,
size INTEGER NOT NULL,
width INTEGER,
height INTEGER,
hashes TEXT NOT NULL,
urlSources TEXT NOT NULL,
path TEXT NOT NULL,
stickerPackId TEXT NOT NULL,
CONSTRAINT fk_sticker_pack FOREIGN KEY (stickerPackId) REFERENCES $stickerPacksTable (id)
ON DELETE CASCADE
)''',
);
await db.execute(
'''
CREATE TABLE $stickerPacksTable (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL,
hashAlgorithm TEXT NOT NULL,
hashValue TEXT NOT NULL
)''',
);
// Add the sticker attributes to Messages
await db.execute(
'ALTER TABLE $messagesTable ADD COLUMN stickerPackId TEXT;',
);
await db.execute(
'ALTER TABLE $messagesTable ADD COLUMN stickerId INTEGER;',
);
// Add the new preferences
await db.insert(
preferenceTable,
Preference(
'enableStickers',
typeBool,
'true',
).toDatabaseJson(),
);
await db.insert(
preferenceTable,
Preference(
'autoDownloadStickersFromContacts',
typeBool,
'true',
).toDatabaseJson(),
);
}

View File

@ -0,0 +1,49 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV17ToV18(Database db) async {
// Update messages
await db.execute(
'ALTER TABLE $messagesTable DROP COLUMN stickerId;',
);
await db.execute(
'ALTER TABLE $messagesTable ADD COLUMN stickerHashKey TEXT;',
);
// Drop stickers
await db.execute(
'DROP TABLE $stickerPacksTable;'
);
await db.execute(
'DROP TABLE $stickersTable;'
);
await db.execute(
'''
CREATE TABLE $stickersTable (
hashKey TEXT PRIMARY KEY,
mediaType TEXT NOT NULL,
desc TEXT NOT NULL,
size INTEGER NOT NULL,
width INTEGER,
height INTEGER,
hashes TEXT NOT NULL,
urlSources TEXT NOT NULL,
path TEXT NOT NULL,
stickerPackId TEXT NOT NULL,
CONSTRAINT fk_sticker_pack FOREIGN KEY (stickerPackId) REFERENCES $stickerPacksTable (id)
ON DELETE CASCADE
)''',
);
await db.execute(
'''
CREATE TABLE $stickerPacksTable (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL,
hashAlgorithm TEXT NOT NULL,
hashValue TEXT NOT NULL,
stickerHashKey TEXT NOT NULL
)''',
);
}

View File

@ -0,0 +1,8 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV18ToV19(Database db) async {
await db.execute(
'ALTER TABLE $stickerPacksTable DROP COLUMN stickerHashKey;',
);
}

View File

@ -0,0 +1,12 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/helpers.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV19ToV20(Database db) async {
await db.execute(
'ALTER TABLE $stickerPacksTable ADD COLUMN restricted DEFAULT ${boolToInt(false)};',
);
await db.execute(
'ALTER TABLE $stickersTable ADD COLUMN suggests DEFAULT "";',
);
}

View File

@ -0,0 +1,19 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/helpers.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV20ToV21(Database db) async {
await db.execute(
'ALTER TABLE $stickerPacksTable DROP COLUMN restricted;',
);
await db.execute(
'ALTER TABLE $stickersTable DROP COLUMN suggests;',
);
await db.execute(
'ALTER TABLE $stickerPacksTable ADD COLUMN restricted INTEGER NOT NULL DEFAULT ${boolToInt(false)};',
);
await db.execute(
'ALTER TABLE $stickersTable ADD COLUMN suggests TEXT NOT NULL DEFAULT "";',
);
}

View File

@ -0,0 +1,13 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/helpers.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV21ToV22(Database db) async {
await db.execute(
'ALTER TABLE $stickersTable DROP COLUMN suggests;',
);
await db.execute(
'ALTER TABLE $stickersTable ADD COLUMN suggests TEXT NOT NULL DEFAULT "{}";',
);
}

View File

@ -24,6 +24,7 @@ import 'package:moxxyv2/service/preferences.dart';
import 'package:moxxyv2/service/roster.dart';
import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/state.dart';
import 'package:moxxyv2/service/stickers.dart';
import 'package:moxxyv2/service/xmpp.dart';
import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/eventhandler.dart';
@ -31,6 +32,8 @@ import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/preferences.dart';
import 'package:moxxyv2/shared/models/reaction.dart';
import 'package:moxxyv2/shared/models/sticker.dart' as sticker;
import 'package:moxxyv2/shared/models/sticker_pack.dart' as sticker_pack;
import 'package:moxxyv2/shared/synchronized_queue.dart';
import 'package:permission_handler/permission_handler.dart';
@ -71,6 +74,11 @@ void setupBackgroundEventHandler() {
EventTypeMatcher<AddReactionToMessageCommand>(performAddMessageReaction),
EventTypeMatcher<RemoveReactionFromMessageCommand>(performRemoveMessageReaction),
EventTypeMatcher<MarkOmemoDeviceAsVerifiedCommand>(performMarkDeviceVerified),
EventTypeMatcher<ImportStickerPackCommand>(performImportStickerPack),
EventTypeMatcher<SendStickerCommand>(performSendSticker),
EventTypeMatcher<RemoveStickerPackCommand>(performRemoveStickerPack),
EventTypeMatcher<FetchStickerPackCommand>(performFetchStickerPack),
EventTypeMatcher<InstallStickerPackCommand>(performStickerPackInstall),
]);
GetIt.I.registerSingleton<EventHandler>(handler);
@ -131,7 +139,7 @@ Future<PreStartDoneEvent> _buildPreStartDoneEvent(PreferencesState preferences)
permissions.add(Permission.storage.value);
await xmpp.modifyXmppState((state) => state.copyWith(
askedStoragePermission: true,
askedStoragePermission: true,
),);
}
@ -145,6 +153,7 @@ Future<PreStartDoneEvent> _buildPreStartDoneEvent(PreferencesState preferences)
preferences: preferences,
conversations: (await GetIt.I.get<DatabaseService>().loadConversations()).where((c) => c.open).toList(),
roster: await GetIt.I.get<RosterService>().loadRosterFromDatabase(),
stickers: await GetIt.I.get<StickersService>().getStickerPacks(),
);
}
@ -772,3 +781,112 @@ Future<void> performMarkDeviceVerified(MarkOmemoDeviceAsVerifiedCommand command,
command.jid,
);
}
Future<void> performImportStickerPack(ImportStickerPackCommand command, { dynamic extra }) async {
final id = extra as String;
final result = await GetIt.I.get<StickersService>().importFromFile(command.path);
if (result != null) {
sendEvent(
StickerPackImportSuccessEvent(
stickerPack: result,
),
id: id,
);
} else {
sendEvent(
StickerPackImportFailureEvent(),
id: id,
);
}
}
Future<void> performSendSticker(SendStickerCommand command, { dynamic extra }) async {
final xs = GetIt.I.get<XmppService>();
final ss = GetIt.I.get<StickersService>();
final sticker = await ss.getStickerByHashKey(
command.stickerPackId,
command.stickerHashKey,
);
assert(sticker != null, 'Sticker not found');
await xs.sendMessage(
body: sticker!.desc,
recipients: [command.recipient],
sticker: sticker,
);
}
Future<void> performRemoveStickerPack(RemoveStickerPackCommand command, { dynamic extra }) async {
await GetIt.I.get<StickersService>().removeStickerPack(
command.stickerPackId,
);
}
Future<void> performFetchStickerPack(FetchStickerPackCommand command, { dynamic extra }) async {
final id = extra as String;
final result = await GetIt.I.get<XmppConnection>()
.getManagerById<StickersManager>(stickersManager)!
.fetchStickerPack(JID.fromString(command.jid), command.stickerPackId);
if (result.isType<PubSubError>()) {
sendEvent(
FetchStickerPackFailureResult(),
id: id,
);
} else {
final stickerPack = result.get<StickerPack>();
sendEvent(
FetchStickerPackSuccessResult(
stickerPack: sticker_pack.StickerPack(
command.stickerPackId,
stickerPack.name,
stickerPack.summary,
stickerPack.stickers
.map((s) => sticker.Sticker(
'',
s.metadata.mediaType!,
s.metadata.desc!,
s.metadata.size!,
s.metadata.width,
s.metadata.height,
s.metadata.hashes,
s.sources
.whereType<StatelessFileSharingUrlSource>()
.map((src) => src.url)
.toList(),
'',
command.stickerPackId,
s.suggests,
),).toList(),
stickerPack.hashAlgorithm.toName(),
stickerPack.hashValue,
stickerPack.restricted,
false,
),
),
id: id,
);
}
}
Future<void> performStickerPackInstall(InstallStickerPackCommand command, { dynamic extra }) async {
final id = extra as String;
final ss = GetIt.I.get<StickersService>();
final pack = await ss.installFromPubSub(command.stickerPack);
if (pack != null) {
sendEvent(
StickerPackInstallSuccessEvent(
stickerPack: pack,
),
id: id,
);
} else {
sendEvent(
StickerPackInstallFailureEvent(),
id: id,
);
}
}

View File

@ -73,3 +73,28 @@ String xmppErrorToTranslatableString(XmppError error) {
return t.errors.login.unspecified;
}
String getStickerHashKeyType(Map<String, String> hashes) {
if (hashes.containsKey('blake2b-512')) {
return 'blake2b-512';
} else if (hashes.containsKey('blake2b-512')) {
return 'blake2b-256';
} else if (hashes.containsKey('sha3-512')) {
return 'sha3-512';
} else if (hashes.containsKey('sha3-256')) {
return 'sha3-256';
} else if (hashes.containsKey('sha3-256')) {
return 'sha-512';
} else if (hashes.containsKey('sha-256')) {
return 'sha-256';
}
assert(false, 'No valid hash found');
return '';
}
String getStickerHashKey(Map<String, String> hashes) {
final key = getStickerHashKeyType(hashes);
return '$key:${hashes[key]}';
}

View File

@ -64,6 +64,8 @@ class MessageService {
bool isDownloading = false,
bool isUploading = false,
int? mediaSize,
String? stickerPackId,
String? stickerHashKey,
}
) async {
final msg = await GetIt.I.get<DatabaseService>().addMessageFromData(
@ -95,6 +97,8 @@ class MessageService {
isUploading: isUploading,
isDownloading: isDownloading,
mediaSize: mediaSize,
stickerPackId: stickerPackId,
stickerHashKey: stickerHashKey,
);
// Only update the cache if the conversation already has been loaded. This prevents

View File

@ -87,9 +87,14 @@ class NotificationsService {
/// then Android's BigPicture will be used.
Future<void> showNotification(modelc.Conversation c, modelm.Message m, String title, { String? body }) async {
// See https://github.com/MaikuB/flutter_local_notifications/blob/master/flutter_local_notifications/example/lib/main.dart#L1293
final body = m.isMedia ?
mimeTypeToEmoji(m.mediaType) :
m.body;
String body;
if (m.stickerPackId != null) {
body = t.messages.sticker;
} else if (m.isMedia) {
body = mimeTypeToEmoji(m.mediaType);
} else {
body = m.body;
}
final css = GetIt.I.get<ContactsService>();
final contactIntegrationEnabled = await css.isContactIntegrationEnabled();

View File

@ -31,6 +31,7 @@ import 'package:moxxyv2/service/notifications.dart';
import 'package:moxxyv2/service/omemo/omemo.dart';
import 'package:moxxyv2/service/preferences.dart';
import 'package:moxxyv2/service/roster.dart';
import 'package:moxxyv2/service/stickers.dart';
import 'package:moxxyv2/service/xmpp.dart';
import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/eventhandler.dart';
@ -155,6 +156,7 @@ Future<void> entrypoint() async {
GetIt.I.registerSingleton<OmemoService>(OmemoService());
GetIt.I.registerSingleton<CryptographyService>(CryptographyService());
GetIt.I.registerSingleton<ContactsService>(ContactsService());
GetIt.I.registerSingleton<StickersService>(StickersService());
final xmpp = XmppService();
GetIt.I.registerSingleton<XmppService>(xmpp);
@ -202,6 +204,7 @@ Future<void> entrypoint() async {
MessageRetractionManager(),
LastMessageCorrectionManager(),
MessageReactionsManager(),
StickersManager(),
])
..registerFeatureNegotiators([
ResourceBindingNegotiator(),

341
lib/service/stickers.dart Normal file
View File

@ -0,0 +1,341 @@
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/helpers.dart';
import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/xmpp.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/sticker.dart';
import 'package:moxxyv2/shared/models/sticker_pack.dart';
import 'package:path/path.dart' as p;
class StickersService {
final Map<String, StickerPack> _stickerPacks = {};
final Logger _log = Logger('StickersService');
Future<StickerPack?> getStickerPackById(String id) async {
if (_stickerPacks.containsKey(id)) return _stickerPacks[id];
final pack = await GetIt.I.get<DatabaseService>().getStickerPackById(id);
if (pack == null) return null;
_stickerPacks[id] = pack;
return _stickerPacks[id];
}
Future<Sticker?> getStickerByHashKey(String packId, String hashKey) async {
final pack = await getStickerPackById(packId);
if (pack == null) return null;
return firstWhereOrNull<Sticker>(
pack.stickers,
(sticker) => sticker.hashKey == hashKey,
);
}
Future<List<StickerPack>> getStickerPacks() async {
if (_stickerPacks.isEmpty) {
final packs = await GetIt.I.get<DatabaseService>().loadStickerPacks();
for (final pack in packs) {
_stickerPacks[pack.id] = pack;
}
}
return _stickerPacks.values.toList();
}
Future<void> removeStickerPack(String id) async {
final pack = await getStickerPackById(id);
assert(pack != null, 'The sticker pack must exist');
// Delete the files
final stickerPackPath = await getStickerPackPath(
pack!.hashAlgorithm,
pack.hashValue,
);
final stickerPackDir = Directory(stickerPackPath);
if (stickerPackDir.existsSync()) {
unawaited(
stickerPackDir.delete(
recursive: true,
),
);
}
// Remove from the database
await GetIt.I.get<DatabaseService>().removeStickerPackById(id);
// Remove from the cache
_stickerPacks.remove(id);
// Retract from PubSub
final state = await GetIt.I.get<XmppService>().getXmppState();
final result = await GetIt.I.get<moxxmpp.XmppConnection>()
.getManagerById<moxxmpp.StickersManager>(moxxmpp.stickersManager)!
.retractStickerPack(moxxmpp.JID.fromString(state.jid!), id);
if (result.isType<moxxmpp.PubSubError>()) {
_log.severe('Failed to retract sticker pack');
}
}
Future<void> _publishStickerPack(moxxmpp.StickerPack pack) async {
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);
if (result.isType<moxxmpp.PubSubError>()) {
_log.severe('Failed to publish sticker pack');
}
}
/// Returns the path to the sticker pack with hash algorithm [algo] and hash [hash].
/// Ensures that the directory exists before returning.
Future<String> _getStickerPackPath(String algo, String hash) async {
final stickerDirPath = await getStickerPackPath(algo, hash);
final stickerDir = Directory(stickerDirPath);
if (!stickerDir.existsSync()) await stickerDir.create(recursive: true);
return stickerDirPath;
}
Future<void> importFromPubSubWithEvent(moxxmpp.JID jid, String stickerPackId) async {
final stickerPack = await importFromPubSub(jid, stickerPackId);
if (stickerPack == null) return;
sendEvent(
StickerPackAddedEvent(
stickerPack: stickerPack,
),
);
}
/// Takes the jid of the host [jid] and the id [stickerPackId] of the sticker pack
/// and tries to fetch and install it, including publishing on our own PubSub node.
///
/// On success, returns the installed StickerPack. On failure, returns null.
Future<StickerPack?> importFromPubSub(moxxmpp.JID jid, String stickerPackId) async {
final result = await GetIt.I.get<moxxmpp.XmppConnection>()
.getManagerById<moxxmpp.StickersManager>(moxxmpp.stickersManager)!
.fetchStickerPack(jid.toBare(), stickerPackId);
if (result.isType<moxxmpp.PubSubError>()) {
_log.warning('Failed to fetch sticker pack $jid:$stickerPackId');
return null;
}
final stickerPackRaw = StickerPack.fromMoxxmpp(
result.get<moxxmpp.StickerPack>(),
false,
);
// Install the sticker pack
return installFromPubSub(stickerPackRaw);
}
Future<StickerPack?> installFromPubSub(StickerPack remotePack) async {
assert(!remotePack.local, 'Sticker pack must be remote');
final stickerPackPath = await _getStickerPackPath(
remotePack.hashAlgorithm,
remotePack.hashValue,
);
var success = true;
final stickers = List<Sticker>.from(remotePack.stickers);
for (var i = 0; i < stickers.length; i++) {
final sticker = stickers[i];
final stickerPath = p.join(
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;
}
if (!isRequestOkay(response.statusCode)) {
_log.severe('Request not okay: $response');
break;
}
stickers[i] = sticker.copyWith(
path: stickerPath,
hashKey: getStickerHashKey(sticker.hashes),
);
}
if (!success) {
_log.severe('Import failed');
return null;
}
// Add the sticker pack to the database
final db = GetIt.I.get<DatabaseService>();
await db.addStickerPackFromData(remotePack);
// Add the stickers to the database
final stickersDb = List<Sticker>.empty(growable: true);
for (final sticker in stickers) {
stickersDb.add(
await db.addStickerFromData(
sticker.mediaType,
sticker.desc,
sticker.size,
sticker.width,
sticker.height,
sticker.hashes,
sticker.urlSources,
sticker.path,
remotePack.hashValue,
sticker.suggests,
),
);
}
// Publish but don't block
unawaited(
_publishStickerPack(remotePack.toMoxxmpp()),
);
return remotePack.copyWith(
stickers: stickersDb,
local: true,
);
}
/// Imports a sticker pack from [path].
/// The format is as follows:
/// - The file MUST be an uncompressed tar archive
/// - All files must be at the top level of the archive
/// - A file 'urn.xmpp.stickers.0.xml' must exist and must contain only the <pack /> element
/// - The File Metadata Elements must also contain a <name /> element
/// - The file referenced by the <name/> element must also exist on the archive's top level
Future<StickerPack?> importFromFile(String path) async {
final archiveBytes = await File(path).readAsBytes();
final archive = TarDecoder().decodeBytes(archiveBytes);
final metadata = archive.findFile('urn.xmpp.stickers.0.xml');
if (metadata == null) {
_log.severe('Invalid sticker pack: No metadata file');
return null;
}
final content = utf8.decode(metadata.content as List<int>);
final node = moxxmpp.XMLNode.fromString(content);
final packRaw = moxxmpp.StickerPack.fromXML(
'',
node,
hashAvailable: false,
);
if (packRaw.restricted) {
_log.severe('Invalid sticker pack: Restricted');
return null;
}
for (final sticker in packRaw.stickers) {
final filename = sticker.metadata.name;
if (filename == null) {
_log.severe('Invalid sticker pack: One sticker has no <name/>');
return null;
}
final stickerFile = archive.findFile(filename);
if (stickerFile == null) {
_log.severe('Invalid sticker pack: $filename does not exist in archive');
return null;
}
}
final pack = packRaw.copyWithId(
moxxmpp.HashFunction.sha256,
await packRaw.getHash(moxxmpp.HashFunction.sha256),
);
_log.finest('New sticker pack identifier: sha256:${pack.id}');
if (await getStickerPackById(pack.id) != null) {
_log.severe('Invalid sticker pack: Already exists');
return null;
}
final stickerDirPath = await getStickerPackPath(
pack.hashAlgorithm.toName(),
pack.hashValue,
);
final stickerDir = Directory(stickerDirPath);
if (!stickerDir.existsSync()) await stickerDir.create(recursive: true);
final db = GetIt.I.get<DatabaseService>();
// Create the sticker pack first
final stickerPack = StickerPack(
pack.hashValue,
pack.name,
pack.summary,
[],
pack.hashAlgorithm.toName(),
pack.hashValue,
pack.restricted,
true,
);
await db.addStickerPackFromData(stickerPack);
// Add all stickers
final stickers = List<Sticker>.empty(growable: true);
for (final sticker in pack.stickers) {
final filename = sticker.metadata.name!;
final stickerFile = archive.findFile(filename)!;
final stickerPath = p.join(stickerDirPath, filename);
await File(stickerPath).writeAsBytes(
stickerFile.content as List<int>,
);
stickers.add(
await db.addStickerFromData(
sticker.metadata.mediaType!,
sticker.metadata.desc!,
sticker.metadata.size!,
null,
null,
sticker.metadata.hashes,
sticker.sources
.whereType<moxxmpp.StatelessFileSharingUrlSource>()
.map((moxxmpp.StatelessFileSharingUrlSource source) => source.url)
.toList(),
stickerPath,
pack.hashValue,
sticker.suggests,
),
);
}
final stickerPackWithStickers = stickerPack.copyWith(
stickers: stickers,
);
// Add it to the cache
_stickerPacks[pack.hashValue] = stickerPackWithStickers;
_log.info('Sticker pack ${stickerPack.id} successfully added to the database');
// Publish but don't block
unawaited(_publishStickerPack(pack));
return stickerPackWithStickers;
}
}

View File

@ -28,6 +28,7 @@ import 'package:moxxyv2/service/preferences.dart';
import 'package:moxxyv2/service/roster.dart';
import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/state.dart';
import 'package:moxxyv2/service/stickers.dart';
import 'package:moxxyv2/shared/error_types.dart';
import 'package:moxxyv2/shared/eventhandler.dart';
import 'package:moxxyv2/shared/events.dart';
@ -35,6 +36,7 @@ import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/media.dart';
import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/shared/models/reaction.dart';
import 'package:moxxyv2/shared/models/sticker.dart' as sticker;
import 'package:path/path.dart' as pathlib;
import 'package:permission_handler/permission_handler.dart';
@ -177,6 +179,7 @@ class XmppService {
Message? quotedMessage,
String? commandId,
ChatState? chatState,
sticker.Sticker? sticker,
}) async {
final ms = GetIt.I.get<MessageService>();
final cs = GetIt.I.get<ConversationService>();
@ -192,7 +195,7 @@ class XmppService {
timestamp,
conn.getConnectionSettings().jid.toString(),
recipient,
false,
sticker != null,
sid,
false,
conversation!.encrypted,
@ -200,6 +203,10 @@ class XmppService {
false,
originId: originId,
quoteId: quotedMessage?.sid,
stickerPackId: sticker?.stickerPackId,
stickerHashKey: sticker?.hashKey,
srcUrl: sticker?.urlSources.first,
mediaType: sticker?.mediaType,
);
final newConversation = await cs.updateConversation(
conversation.id,
@ -225,6 +232,27 @@ class XmppService {
quoteId: quotedMessage?.sid,
chatState: chatState,
shouldEncrypt: newConversation.encrypted,
stickerPackId: sticker?.stickerPackId,
sfs: sticker == null ?
null :
StatelessFileSharingData(
FileMetadataData(
mediaType: sticker.mediaType,
width: sticker.width,
height: sticker.height,
desc: sticker.desc,
size: sticker.size,
thumbnails: [],
hashes: sticker.hashes,
),
sticker.urlSources
// ignore: unnecessary_lambdas
.map((s) => StatelessFileSharingUrlSource(s))
.toList(),
),
setOOBFallbackBody: sticker != null ?
false :
true,
),
);
@ -846,7 +874,8 @@ class XmppService {
// that the message body and the OOB url are the same if the OOB url is not null.
return embeddedFile != null
&& Uri.parse(embeddedFile.url).scheme == 'https'
&& implies(event.oob != null, event.body == event.oob?.url);
&& implies(event.oob != null, event.body == event.oob?.url)
&& event.stickerPackId == null;
}
/// Handle a message retraction given the MessageEvent [event].
@ -1138,7 +1167,34 @@ class XmppService {
var shouldNotify = !(isFileEmbedded && isInRoster && shouldDownload);
// A guess for the Mime type of the embedded file.
var mimeGuess = _getMimeGuess(event);
// Guess a sticker hash key, if the message is a sticker
final stickerHashKey = event.stickerPackId != null ?
getStickerHashKey(event.sfs!.metadata.hashes) :
null;
// The potential sticker pack
final stickerPack = event.stickerPackId != null ?
await GetIt.I.get<StickersService>().getStickerPackById(
event.stickerPackId!,
) :
null;
// Automatically download the sticker pack, if
// - a sticker was received,
// - the sender is in the roster,
// - we don't have the sticker pack locally,
// - and it is enabled in the settings
if (event.stickerPackId != null &&
stickerPack == null &&
prefs.autoDownloadStickersFromContacts &&
isInRoster) {
unawaited(
GetIt.I.get<StickersService>().importFromPubSubWithEvent(
event.fromJid,
event.stickerPackId!,
),
);
}
// Create the message in the database
final ms = GetIt.I.get<MessageService>();
final dimensions = _getDimensions(event);
@ -1147,7 +1203,7 @@ class XmppService {
messageTimestamp,
event.fromJid.toString(),
conversationJid,
isFileEmbedded || event.fun != null,
isFileEmbedded || event.fun != null || event.stickerPackId != null,
event.sid,
event.fun != null,
event.encrypted,
@ -1164,6 +1220,9 @@ class XmppService {
quoteId: replyId,
originId: event.stanzaId.originId,
errorType: errorTypeFromException(event.other['encryption_error']),
plaintextHashes: event.sfs?.metadata.hashes,
stickerPackId: event.stickerPackId,
stickerHashKey: stickerHashKey,
);
// Attempt to auto-download the embedded file

View File

@ -2,5 +2,6 @@ import 'package:moxlib/awaitabledatasender.dart';
import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/shared/models/preferences.dart';
import 'package:moxxyv2/shared/models/sticker_pack.dart';
part 'commands.moxxy.dart';

View File

@ -5,6 +5,7 @@ import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/shared/models/omemo_device.dart';
import 'package:moxxyv2/shared/models/preferences.dart';
import 'package:moxxyv2/shared/models/roster.dart';
import 'package:moxxyv2/shared/models/sticker_pack.dart';
part 'events.moxxy.dart';

View File

@ -402,3 +402,12 @@ Future<String> getContactProfilePicturePath(String id) async {
return p.join(avatarDir, id);
}
Future<String> getStickerPackPath(String hashFunction, String hashValue) async {
final appDir = await getApplicationDocumentsDirectory();
return p.join(
appDir.path,
'stickers',
'${hashFunction}_$hashValue',
);
}

View File

@ -12,7 +12,7 @@ part 'message.g.dart';
Map<String, String>? _optionalJsonDecode(String? data) {
if (data == null) return null;
return jsonDecode(data) as Map<String, String>;
return (jsonDecode(data) as Map<dynamic, dynamic>).cast<String, String>();
}
String? _optionalJsonEncode(Map<String, String>? data) {
@ -64,6 +64,8 @@ class Message with _$Message {
Map<String, String>? ciphertextHashes,
int? mediaSize,
@Default([]) List<Reaction> reactions,
String? stickerPackId,
String? stickerHashKey,
}
) = _Message;
@ -181,4 +183,7 @@ class Message with _$Message {
/// Returns true if the message can be copied to the clipboard.
bool get isCopyable => !isMedia && body.isNotEmpty;
/// Returns true if the message is a sticker
bool get isSticker => isMedia && stickerPackId != null && stickerHashKey != null;
}

View File

@ -29,6 +29,8 @@ class PreferencesState with _$PreferencesState {
// be used
@Default('default') String languageLocaleCode,
@Default(false) bool enableContactIntegration,
@Default(true) bool enableStickers,
@Default(true) bool autoDownloadStickersFromContacts,
}) = _PreferencesState;
// JSON serialization

View File

@ -0,0 +1,88 @@
import 'dart:convert';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
import 'package:moxxyv2/service/helpers.dart';
part 'sticker.freezed.dart';
part 'sticker.g.dart';
@freezed
class Sticker with _$Sticker {
factory Sticker(
String hashKey,
String mediaType,
String desc,
int size,
int? width,
int? height,
/// Hash algorithm (algo attribute) -> Base64 encoded hash
Map<String, String> hashes,
List<String> urlSources,
String path,
String stickerPackId,
Map<String, String> suggests,
) = _Sticker;
const Sticker._();
/// Moxxmpp
factory Sticker.fromMoxxmpp(moxxmpp.Sticker sticker, String stickerPackId) => Sticker(
getStickerHashKey(sticker.metadata.hashes),
sticker.metadata.mediaType!,
sticker.metadata.desc!,
sticker.metadata.size!,
sticker.metadata.width,
sticker.metadata.height,
sticker.metadata.hashes,
sticker.sources
.whereType<moxxmpp.StatelessFileSharingUrlSource>()
.map((src) => src.url)
.toList(),
'',
stickerPackId,
sticker.suggests,
);
/// JSON
factory Sticker.fromJson(Map<String, dynamic> json) => _$StickerFromJson(json);
factory Sticker.fromDatabaseJson(Map<String, dynamic> json) {
return Sticker.fromJson({
...json,
'hashes': (jsonDecode(json['hashes']! as String) as Map<dynamic, dynamic>).cast<String, String>(),
'urlSources': (jsonDecode(json['urlSources']! as String) as List<dynamic>).cast<String>(),
'suggests': (jsonDecode(json['suggests']! as String) as Map<dynamic, dynamic>).cast<String, String>(),
});
}
Map<String, dynamic> toDatabaseJson() {
final map = toJson()
..remove('hashes')
..remove('urlSources')
..remove('suggests');
return {
...map,
'hashes': jsonEncode(hashes),
'urlSources': jsonEncode(urlSources),
'suggests': jsonEncode(suggests),
};
}
moxxmpp.Sticker toMoxxmpp() => moxxmpp.Sticker(
moxxmpp.FileMetadataData(
mediaType: mediaType,
desc: desc,
size: size,
width: width,
height: height,
thumbnails: [],
hashes: hashes,
),
urlSources
// ignore: unnecessary_lambdas
.map((src) => moxxmpp.StatelessFileSharingUrlSource(src))
.toList(),
suggests,
);
}

View File

@ -0,0 +1,74 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/shared/models/sticker.dart';
part 'sticker_pack.freezed.dart';
part 'sticker_pack.g.dart';
@freezed
class StickerPack with _$StickerPack {
factory StickerPack(
String id,
String name,
String description,
List<Sticker> stickers,
String hashAlgorithm,
String hashValue,
bool restricted,
bool local,
) = _StickerPack;
const StickerPack._();
/// Moxxmpp
factory StickerPack.fromMoxxmpp(moxxmpp.StickerPack pack, bool local) => StickerPack(
pack.id,
pack.name,
pack.summary,
pack.stickers
.map((sticker) => Sticker.fromMoxxmpp(sticker, pack.id))
.toList(),
pack.hashAlgorithm.toName(),
pack.hashValue,
pack.restricted,
local,
);
/// JSON
factory StickerPack.fromJson(Map<String, dynamic> json) => _$StickerPackFromJson(json);
factory StickerPack.fromDatabaseJson(Map<String, dynamic> json, List<Sticker> stickers) {
final pack = StickerPack.fromJson({
...json,
'local': true,
'restricted': intToBool(json['restricted']! as int),
'stickers': <Sticker>[],
});
return pack.copyWith(stickers: stickers);
}
Map<String, dynamic> toDatabaseJson() {
final json = toJson()
..remove('local')
..remove('stickers');
return {
...json,
'restricted': boolToInt(restricted),
};
}
moxxmpp.StickerPack toMoxxmpp() => moxxmpp.StickerPack(
id,
name,
description,
moxxmpp.hashFunctionFromName(hashAlgorithm),
hashValue,
stickers
.map((sticker) => sticker.toMoxxmpp())
.toList(),
restricted,
);
}

View File

@ -64,6 +64,9 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
on<RecordingCanceledEvent>(_onRecordingCanceled);
on<ReactionAddedEvent>(_onReactionAdded);
on<ReactionRemovedEvent>(_onReactionRemoved);
on<StickerPickerToggledEvent>(_onStickerPickerToggled);
on<StickerSentEvent>(_onStickerSent);
on<SoftKeyboardVisibilityChanged>(_onSoftKeyboardVisibilityChanged);
_audioRecorder = Record();
}
@ -237,6 +240,7 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
quotedMessage: null,
sendButtonState: defaultSendButtonState,
emojiPickerVisible: false,
stickerPickerVisible: false,
messageEditing: false,
messageEditingOriginalBody: '',
messageEditingId: null,
@ -373,7 +377,12 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
Future<void> _onEmojiPickerToggled(EmojiPickerToggledEvent event, Emitter<ConversationState> emit) async {
final newState = !state.emojiPickerVisible;
emit(state.copyWith(emojiPickerVisible: newState));
emit(
state.copyWith(
emojiPickerVisible: newState,
stickerPickerVisible: false,
),
);
if (event.handleKeyboard) {
if (newState) {
@ -452,6 +461,8 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
state.copyWith(
isDragging: true,
isRecording: true,
emojiPickerVisible: false,
stickerPickerVisible: false,
),
);
@ -631,4 +642,43 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
awaitable: false,
);
}
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(
stickerPackId: event.stickerPackId,
stickerHashKey: event.stickerHashKey,
recipient: state.conversation!.jid,
),
awaitable: false,
);
// Close the picker
emit(
state.copyWith(
stickerPickerVisible: false,
),
);
}
Future<void> _onSoftKeyboardVisibilityChanged(SoftKeyboardVisibilityChanged event, Emitter<ConversationState> emit) async {
if (event.visible && (state.emojiPickerVisible || state.stickerPickerVisible)) {
emit(
state.copyWith(
emojiPickerVisible: false,
stickerPickerVisible: false,
),
);
}
}
}

View File

@ -103,6 +103,12 @@ class EmojiPickerToggledEvent extends ConversationEvent {
final bool handleKeyboard;
}
/// Triggered when the sticker button is pressed
class StickerPickerToggledEvent extends ConversationEvent {
StickerPickerToggledEvent({this.handleKeyboard = true});
final bool handleKeyboard;
}
/// Triggered when we received our own JID
class OwnJidReceivedEvent extends ConversationEvent {
OwnJidReceivedEvent(this.jid);
@ -160,3 +166,16 @@ class ReactionRemovedEvent extends ConversationEvent {
final String emoji;
final int index;
}
/// Triggered when a sticker has been sent
class StickerSentEvent extends ConversationEvent {
StickerSentEvent(this.stickerPackId, this.stickerHashKey);
final String stickerPackId;
final String stickerHashKey;
}
/// Triggered when the softkeyboard's visibility changed
class SoftKeyboardVisibilityChanged extends ConversationEvent {
SoftKeyboardVisibilityChanged(this.visible);
final bool visible;
}

View File

@ -19,6 +19,7 @@ class ConversationState with _$ConversationState {
@Default(null) Conversation? conversation,
@Default('') String backgroundPath,
@Default(false) bool emojiPickerVisible,
@Default(false) bool stickerPickerVisible,
@Default(false) bool messageEditing,
@Default('') String messageEditingOriginalBody,
@Default(null) String? messageEditingSid,

View File

@ -15,20 +15,17 @@ class NavigationDestination {
abstract class NavigationEvent {}
class PushedNamedEvent extends NavigationEvent {
PushedNamedEvent(this.destination);
final NavigationDestination destination;
}
class PushedNamedAndRemoveUntilEvent extends NavigationEvent {
PushedNamedAndRemoveUntilEvent(this.destination, this.predicate);
final NavigationDestination destination;
final RoutePredicate predicate;
}
class PushedNamedReplaceEvent extends NavigationEvent {
PushedNamedReplaceEvent(this.destination);
final NavigationDestination destination;
}

View File

@ -0,0 +1,184 @@
import 'package:bloc/bloc.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:get_it/get_it.dart';
import 'package:moxlib/moxlib.dart';
import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/models/sticker_pack.dart';
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
import 'package:moxxyv2/ui/bloc/stickers_bloc.dart' as stickers;
import 'package:moxxyv2/ui/constants.dart';
part 'sticker_pack_bloc.freezed.dart';
part 'sticker_pack_event.dart';
part 'sticker_pack_state.dart';
class StickerPackBloc extends Bloc<StickerPackEvent, StickerPackState> {
StickerPackBloc() : super(StickerPackState()) {
on<LocallyAvailableStickerPackRequested>(_onLocalStickerPackRequested);
on<StickerPackRemovedEvent>(_onStickerPackRemoved);
on<RemoteStickerPackRequested>(_onRemoteStickerPackRequested);
on<StickerPackInstalledEvent>(_onStickerPackInstalled);
on<StickerPackRequested>(_onStickerPackRequested);
}
Future<void> _onLocalStickerPackRequested(LocallyAvailableStickerPackRequested event, Emitter<StickerPackState> emit) async {
emit(
state.copyWith(
isWorking: true,
isInstalling: false,
),
);
// Navigate
GetIt.I.get<NavigationBloc>().add(
PushedNamedEvent(
const NavigationDestination(stickerPackRoute),
),
);
// Apply
final stickerPack = firstWhereOrNull(
GetIt.I.get<stickers.StickersBloc>().state.stickerPacks,
(StickerPack pack) => pack.id == event.stickerPackId,
);
assert(stickerPack != null, 'The sticker pack must be found');
emit(
state.copyWith(
isWorking: false,
stickerPack: stickerPack,
),
);
}
Future<void> _onStickerPackRemoved(StickerPackRemovedEvent event, Emitter<StickerPackState> emit) async {
// Reset internal state
emit(
state.copyWith(
stickerPack: null,
isWorking: true,
),
);
// Leave the page
GetIt.I.get<NavigationBloc>().add(
PoppedRouteEvent(),
);
// Remove the sticker pack
GetIt.I.get<stickers.StickersBloc>().add(
stickers.StickerPackRemovedEvent(event.stickerPackId),
);
}
Future<void> _onRemoteStickerPackRequested(RemoteStickerPackRequested event, Emitter<StickerPackState> emit) async {
final mustDoWork = state.stickerPack == null || state.stickerPack?.id != event.stickerPackId;
if (mustDoWork) {
emit(
state.copyWith(
isWorking: true,
isInstalling: false,
),
);
}
// Navigate
GetIt.I.get<NavigationBloc>().add(
PushedNamedEvent(
const NavigationDestination(stickerPackRoute),
),
);
if (mustDoWork) {
final result = await MoxplatformPlugin.handler.getDataSender().sendData(
FetchStickerPackCommand(
stickerPackId: event.stickerPackId,
jid: event.jid,
),
);
if (result is FetchStickerPackSuccessResult) {
emit(
state.copyWith(
isWorking: false,
stickerPack: result.stickerPack,
),
);
} else {
// Leave the page
GetIt.I.get<NavigationBloc>().add(
PoppedRouteEvent(),
);
}
}
}
Future<void> _onStickerPackInstalled(StickerPackInstalledEvent event, Emitter<StickerPackState> emit) async {
assert(!state.stickerPack!.local, 'Sticker pack must be remote');
emit(
state.copyWith(
isInstalling: true,
),
);
final result = await MoxplatformPlugin.handler.getDataSender().sendData(
InstallStickerPackCommand(
stickerPack: state.stickerPack!,
),
);
emit(
state.copyWith(
isInstalling: false,
),
);
if (result is StickerPackInstallSuccessEvent) {
GetIt.I.get<stickers.StickersBloc>().add(
stickers.StickerPackAddedEvent(result.stickerPack),
);
// Leave the page
GetIt.I.get<NavigationBloc>().add(
PoppedRouteEvent(),
);
} else {
// Leave the page
GetIt.I.get<NavigationBloc>().add(
PoppedRouteEvent(),
);
await Fluttertoast.showToast(
msg: t.pages.stickerPack.fetchingFailure,
gravity: ToastGravity.SNACKBAR,
toastLength: Toast.LENGTH_SHORT,
);
}
}
Future<void> _onStickerPackRequested(StickerPackRequested event, Emitter<StickerPackState> emit) async {
// Find out if the sticker pack is locally available or not
final stickerPack = firstWhereOrNull(
GetIt.I.get<stickers.StickersBloc>().state.stickerPacks,
(StickerPack pack) => pack.id == event.stickerPackId,
);
if (stickerPack == null) {
await _onRemoteStickerPackRequested(
RemoteStickerPackRequested(
event.stickerPackId,
event.jid,
),
emit,
);
} else {
await _onLocalStickerPackRequested(
LocallyAvailableStickerPackRequested(event.stickerPackId),
emit,
);
}
}
}

View File

@ -0,0 +1,33 @@
part of 'sticker_pack_bloc.dart';
abstract class StickerPackEvent {}
/// Triggered by the UI when the user navigates to a locally available sticker pack
class LocallyAvailableStickerPackRequested extends StickerPackEvent {
LocallyAvailableStickerPackRequested(this.stickerPackId);
final String stickerPackId;
}
/// Triggered by the UI when the user navigates to a remote sticker pack
class RemoteStickerPackRequested extends StickerPackEvent {
RemoteStickerPackRequested(this.stickerPackId, this.jid);
final String stickerPackId;
final String jid;
}
/// Triggered by the UI when the sticker pack is removed
class StickerPackRemovedEvent extends StickerPackEvent {
StickerPackRemovedEvent(this.stickerPackId);
final String stickerPackId;
}
/// Triggered by the UI when the sticker pack currently displayed is to be installed
class StickerPackInstalledEvent extends StickerPackEvent {}
/// Triggered by the UI when a URL has been tapped that contains a sticker pack that
/// or may not be locally available.
class StickerPackRequested extends StickerPackEvent {
StickerPackRequested(this.jid, this.stickerPackId);
final String jid;
final String stickerPackId;
}

View File

@ -0,0 +1,10 @@
part of 'sticker_pack_bloc.dart';
@freezed
class StickerPackState with _$StickerPackState {
factory StickerPackState({
StickerPack? stickerPack,
@Default(false) bool isWorking,
@Default(false) bool isInstalling,
}) = _StickerPackState;
}

View File

@ -0,0 +1,144 @@
import 'dart:async';
import 'dart:io';
import 'package:bloc/bloc.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/painting.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:moxlib/moxlib.dart';
import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/models/sticker.dart';
import 'package:moxxyv2/shared/models/sticker_pack.dart';
part 'stickers_bloc.freezed.dart';
part 'stickers_event.dart';
part 'stickers_state.dart';
class StickersBloc extends Bloc<StickersEvent, StickersState> {
StickersBloc() : super(StickersState()) {
on<StickersSetEvent>(_onStickersSet);
on<StickerPackRemovedEvent>(_onStickerPackRemoved);
on<StickerPackImportedEvent>(_onStickerPackImported);
on<StickerPackAddedEvent>(_onStickerPackAdded);
}
Future<void> _onStickersSet(StickersSetEvent event, Emitter<StickersState> emit) async {
// Also store a mapping of (pack Id, sticker Id) -> Sticker to allow fast lookup
// of the sticker in the UI.
final map = <StickerKey, Sticker>{};
for (final pack in event.stickerPacks) {
for (final sticker in pack.stickers) {
map[StickerKey(pack.id, sticker.hashKey)] = sticker;
}
}
emit(
state.copyWith(
stickerPacks: event.stickerPacks,
stickerMap: map,
),
);
}
Future<void> _onStickerPackRemoved(StickerPackRemovedEvent event, Emitter<StickersState> emit) async {
final stickerPack = firstWhereOrNull(
state.stickerPacks,
(StickerPack sp) => sp.id == event.stickerPackId,
)!;
final sm = Map<StickerKey, Sticker>.from(state.stickerMap);
for (final sticker in stickerPack.stickers) {
sm.remove(StickerKey(stickerPack.id, sticker.hashKey));
// Evict stickers from the cache
unawaited(FileImage(File(sticker.path)).evict());
}
emit(
state.copyWith(
stickerPacks: List.from(
state.stickerPacks.where((sp) => sp.id != event.stickerPackId),
),
stickerMap: sm,
),
);
await MoxplatformPlugin.handler.getDataSender().sendData(
RemoveStickerPackCommand(
stickerPackId: event.stickerPackId,
),
awaitable: false,
);
}
Future<void> _onStickerPackImported(StickerPackImportedEvent event, Emitter<StickersState> emit) async {
final file = await FilePicker.platform.pickFiles();
if (file == null) return;
emit(
state.copyWith(
isImportRunning: true,
),
);
final result = await MoxplatformPlugin.handler.getDataSender().sendData(
ImportStickerPackCommand(
path: file.files.single.path!,
),
);
if (result is StickerPackImportSuccessEvent) {
final sm = Map<StickerKey, Sticker>.from(state.stickerMap);
for (final sticker in result.stickerPack.stickers) {
sm[StickerKey(result.stickerPack.id, sticker.hashKey)] = sticker;
}
emit(
state.copyWith(
stickerPacks: List<StickerPack>.from([
...state.stickerPacks,
result.stickerPack,
]),
stickerMap: sm,
isImportRunning: false,
),
);
await Fluttertoast.showToast(
msg: t.pages.settings.stickers.importSuccess,
gravity: ToastGravity.SNACKBAR,
toastLength: Toast.LENGTH_SHORT,
);
} else {
emit(
state.copyWith(
isImportRunning: false,
),
);
await Fluttertoast.showToast(
msg: t.pages.settings.stickers.importFailure,
gravity: ToastGravity.SNACKBAR,
toastLength: Toast.LENGTH_SHORT,
);
}
}
Future<void> _onStickerPackAdded(StickerPackAddedEvent event, Emitter<StickersState> emit) async {
final sm = Map<StickerKey, Sticker>.from(state.stickerMap);
for (final sticker in event.stickerPack.stickers) {
sm[StickerKey(event.stickerPack.id, sticker.hashKey)] = sticker;
}
emit(
state.copyWith(
stickerPacks: List<StickerPack>.from([
...state.stickerPacks,
event.stickerPack,
]),
stickerMap: sm,
),
);
}
}

View File

@ -0,0 +1,25 @@
part of 'stickers_bloc.dart';
abstract class StickersEvent {}
class StickersSetEvent extends StickersEvent {
StickersSetEvent(
this.stickerPacks,
);
final List<StickerPack> stickerPacks;
}
/// Triggered by the UI when a sticker pack has been removed
class StickerPackRemovedEvent extends StickersEvent {
StickerPackRemovedEvent(this.stickerPackId);
final String stickerPackId;
}
/// Triggered by the UI when a sticker pack has been imported
class StickerPackImportedEvent extends StickersEvent {}
/// Triggered by the UI when a sticker pack has been imported
class StickerPackAddedEvent extends StickersEvent {
StickerPackAddedEvent(this.stickerPack);
final StickerPack stickerPack;
}

View File

@ -0,0 +1,25 @@
part of 'stickers_bloc.dart';
@immutable
class StickerKey {
const StickerKey(this.packId, this.stickerHashKey);
final String packId;
final String stickerHashKey;
@override
int get hashCode => packId.hashCode ^ stickerHashKey.hashCode;
@override
bool operator ==(Object other) {
return other is StickerKey && other.packId == packId && other.stickerHashKey == stickerHashKey;
}
}
@freezed
class StickersState with _$StickersState {
factory StickersState({
@Default([]) List<StickerPack> stickerPacks,
@Default({}) Map<StickerKey, Sticker> stickerMap,
@Default(false) bool isImportRunning,
}) = _StickersState;
}

View File

@ -23,6 +23,8 @@ const Color bubbleColorReceived = Color(0xff222222);
const Color bubbleColorReceivedQuoted = bubbleColorReceived;
const Color bubbleColorUnencrypted = Color(0xffd40000);
const Color settingsSectionTitleColor = Color(0xffb72fe7);
const double paddingVeryLarge = 64;
const Color tileColorDark = Color(0xff5c5c5c);
@ -61,9 +63,11 @@ const String networkRoute = '$settingsRoute/network';
const String backgroundCroppingRoute = '$settingsRoute/appearance/background';
const String conversationSettingsRoute = '$settingsRoute/conversation';
const String appearanceRoute = '$settingsRoute/appearance';
const String stickersRoute = '$settingsRoute/stickers';
const String blocklistRoute = '/blocklist';
const String shareSelectionRoute = '/share_selection';
const String serverInfoRoute = '$profileRoute/server_info';
const String devicesRoute = '$profileRoute/devices';
const String ownDevicesRoute = '$profileRoute/own_devices';
const String qrCodeScannerRoute = '/util/qr_code_scanner';
const String stickerPackRoute = '/stickers/sticker_pack';

View File

@ -15,6 +15,7 @@ import 'package:moxxyv2/ui/bloc/conversations_bloc.dart' as conversations;
import 'package:moxxyv2/ui/bloc/newconversation_bloc.dart' as new_conversation;
import 'package:moxxyv2/ui/bloc/profile_bloc.dart' as profile;
import 'package:moxxyv2/ui/bloc/sharedmedia_bloc.dart' as sharedmedia;
import 'package:moxxyv2/ui/bloc/stickers_bloc.dart' as stickers;
import 'package:moxxyv2/ui/prestart.dart';
import 'package:moxxyv2/ui/service/progress.dart';
@ -32,6 +33,7 @@ void setupEventHandler() {
EventTypeMatcher<PreStartDoneEvent>(preStartDone),
EventTypeMatcher<ServiceReadyEvent>(onServiceReady),
EventTypeMatcher<MessageNotificationTappedEvent>(onNotificationTappend),
EventTypeMatcher<StickerPackAddedEvent>(onStickerPackAdded),
]);
GetIt.I.registerSingleton<EventHandler>(handler);
@ -159,3 +161,9 @@ Future<void> onNotificationTappend(MessageNotificationTappedEvent event, { dynam
),
);
}
Future<void> onStickerPackAdded(StickerPackAddedEvent event, { dynamic extra }) async {
GetIt.I.get<stickers.StickersBloc>().add(
stickers.StickerPackAddedEvent(event.stickerPack),
);
}

View File

@ -6,13 +6,17 @@ import 'package:flutter/material.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:get_it/get_it.dart';
import 'package:hex/hex.dart';
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/avatar.dart';
import 'package:moxxyv2/shared/models/omemo_device.dart';
import 'package:moxxyv2/ui/bloc/crop_bloc.dart';
import 'package:moxxyv2/ui/bloc/sticker_pack_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/pages/util/qrcode.dart';
import 'package:moxxyv2/ui/redirects.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:url_launcher/url_launcher.dart';
/// Shows a dialog asking the user if they are sure that they want to proceed with an
/// action. Resolves to true if the user pressed the confirm button. Returns false if
@ -288,3 +292,43 @@ int isVerificationUriValid(List<OmemoDevice> devices, Uri scannedUri, String dev
return index;
}
/// Parse the URI [uriString] and trigger an appropriate UI action.
Future<void> handleUri(String uriString) async {
final uri = Uri.tryParse(uriString);
if (uri == null) return;
if (uri.scheme == 'xmpp') {
final psAction = uri.queryParameters['pubsub;action'];
if (psAction != null) {
final parts = psAction.split(';');
String? node;
String? item;
for (final p in parts) {
if (p.startsWith('node=')) {
node = p.substring(5);
} else if (p.startsWith('item=')) {
item = p.substring(5);
}
}
if (node == moxxmpp.stickersXmlns && item != null) {
// Retrieve a sticker pack
GetIt.I.get<StickerPackBloc>().add(
StickerPackRequested(
uri.path,
item,
),
);
}
}
return;
}
await launchUrl(
redirectUrl(uri),
mode: LaunchMode.externalNonBrowserApplication,
);
}

View File

@ -1,9 +1,12 @@
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_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';
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart';
@ -12,6 +15,7 @@ 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/widgets/chat/media/media.dart';
import 'package:moxxyv2/ui/widgets/sticker_picker.dart';
import 'package:moxxyv2/ui/widgets/textfield.dart';
import 'package:phosphor_flutter/phosphor_flutter.dart';
@ -51,6 +55,29 @@ class ConversationBottomRow extends StatefulWidget {
}
class ConversationBottomRowState extends State<ConversationBottomRow> {
late StreamSubscription<bool> _keyboardVisibilitySubscription;
@override
void initState() {
super.initState();
_keyboardVisibilitySubscription = KeyboardVisibilityController().onChange.listen(
_onKeyboardVisibilityChanged,
);
}
@override
void dispose() {
_keyboardVisibilitySubscription.cancel();
super.dispose();
}
void _onKeyboardVisibilityChanged(bool visible) {
GetIt.I.get<ConversationBloc>().add(
SoftKeyboardVisibilityChanged(visible),
);
}
Color _getTextColor(BuildContext context) {
// TODO(Unknown): Work on the colors
if (MediaQuery.of(context).platformBrightness == Brightness.dark) {
@ -138,7 +165,9 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
),
),
onTap: () {
showNotImplementedDialog('stickers', context);
context.read<ConversationBloc>().add(
StickerPickerToggledEvent(),
);
},
),
),
@ -199,6 +228,24 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
),
),
),
BlocBuilder<ConversationBloc, ConversationState>(
buildWhen: (prev, next) => prev.stickerPickerVisible != next.stickerPickerVisible,
builder: (context, state) => Offstage(
offstage: !state.stickerPickerVisible,
child: StickerPicker(
width: MediaQuery.of(context).size.width,
onStickerTapped: (sticker, pack) {
context.read<ConversationBloc>().add(
StickerSentEvent(
pack.id,
sticker.hashKey,
),
);
},
),
),
),
BlocBuilder<ConversationBloc, ConversationState>(
buildWhen: (prev, next) => prev.emojiPickerVisible != next.emojiPickerVisible,
builder: (context, state) => Offstage(
@ -258,15 +305,19 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
),
),
Positioned(
right: 8,
bottom: 8,
child: BlocBuilder<ConversationBloc, ConversationState>(
buildWhen: (prev, next) => prev.sendButtonState != next.sendButtonState ||
prev.isDragging != next.isDragging ||
prev.isLocked != next.isLocked,
builder: (context, state) {
return Visibility(
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,
@ -316,25 +367,25 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
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;
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;
context.read<ConversationBloc>().add(
MessageEditCancelledEvent(),
);
widget.controller.text = '';
return;
case SendButtonState.send:
context.read<ConversationBloc>().add(
MessageSentEvent(),
);
widget.controller.text = '';
return;
context.read<ConversationBloc>().add(
MessageSentEvent(),
);
widget.controller.text = '';
return;
}
},
child: Icon(
@ -344,9 +395,9 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
),
),
),
);
},
),
),
);
},
),
Positioned(

View File

@ -191,6 +191,9 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
end,
),
highlight: bubble,
materialColor: item.isSticker ?
Colors.transparent :
null,
children: [
...item.isReactable ? [
OverviewMenuItem(
@ -392,21 +395,24 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
onWillPop: () async {
// TODO(PapaTutuWawa): Check if we are recording an audio message and handle
// that accordingly
if (_textfieldFocus.hasFocus) {
_textfieldFocus.unfocus();
return false;
}
final bloc = GetIt.I.get<ConversationBloc>();
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();
}
return false;
} else {
bloc.add(CurrentConversationResetEvent());
return true;
}
},

View File

@ -6,6 +6,7 @@ import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/helpers.dart';
import 'package:moxxyv2/ui/widgets/topbar.dart';
import 'package:phosphor_flutter/phosphor_flutter.dart';
import 'package:settings_ui/settings_ui.dart';
class SettingsPage extends StatelessWidget {
@ -32,6 +33,11 @@ class SettingsPage extends StatelessWidget {
leading: const Icon(Icons.chat_bubble),
onPressed: (context) => Navigator.pushNamed(context, conversationSettingsRoute),
),
SettingsTile(
title: Text(t.pages.settings.stickers.title),
leading: const Icon(PhosphorIcons.stickerBold),
onPressed: (context) => Navigator.pushNamed(context, stickersRoute),
),
SettingsTile(
title: Text(t.pages.settings.network.title),
leading: const Icon(Icons.network_wifi),

View File

@ -0,0 +1,160 @@
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/preferences_bloc.dart';
import 'package:moxxyv2/ui/bloc/sticker_pack_bloc.dart';
import 'package:moxxyv2/ui/bloc/stickers_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/widgets/settings/row.dart';
import 'package:moxxyv2/ui/widgets/settings/title.dart';
import 'package:moxxyv2/ui/widgets/topbar.dart';
import 'package:phosphor_flutter/phosphor_flutter.dart';
class StickersSettingsPage extends StatelessWidget {
const StickersSettingsPage({ super.key });
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
builder: (_) => const StickersSettingsPage(),
settings: const RouteSettings(
name: stickersRoute,
),
);
@override
Widget build(BuildContext context) {
return BlocBuilder<StickersBloc, StickersState>(
builder: (_, stickers) => WillPopScope(
onWillPop: () async {
return !stickers.isImportRunning;
},
child: Stack(
children: [
Positioned(
top: 0,
left: 0,
right: 0,
bottom: 0,
child: Scaffold(
appBar: BorderlessTopbar.simple(t.pages.settings.stickers.title),
body: BlocBuilder<PreferencesBloc, PreferencesState>(
builder: (_, prefs) => Padding(
padding: EdgeInsets.zero,
child: ListView.builder(
itemCount: stickers.stickerPacks.length + 1,
itemBuilder: (___, index) {
if (index == 0) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionTitle(t.pages.settings.stickers.displayStickers),
SettingsRow(
title: t.pages.settings.stickers.displayStickers,
suffix: Switch(
value: prefs.enableStickers,
onChanged: (value) {
context.read<PreferencesBloc>().add(
PreferencesChangedEvent(
prefs.copyWith(enableStickers: value),
),
);
},
),
),
SettingsRow(
title: t.pages.settings.stickers.autoDownload,
description: t.pages.settings.stickers.autoDownloadBody,
suffix: Switch(
value: prefs.autoDownloadStickersFromContacts,
onChanged: (value) {
context.read<PreferencesBloc>().add(
PreferencesChangedEvent(
prefs.copyWith(autoDownloadStickersFromContacts: value),
),
);
},
),
),
SettingsRow(
onTap: () {
GetIt.I.get<StickersBloc>().add(
StickerPackImportedEvent(),
);
},
title: t.pages.settings.stickers.importStickerPack,
),
SectionTitle(t.pages.settings.stickers.stickerPacksSection),
],
);
}
return SettingsRow(
title: stickers.stickerPacks[index - 1].name,
description: stickers.stickerPacks[index - 1].description,
crossAxisAlignment: CrossAxisAlignment.start,
prefix: const Padding(
padding: EdgeInsets.only(right: 16),
child: SizedBox(
width: 48,
height: 48,
// TODO(PapaTutuWawa): Sticker pack thumbnails would be nice
child: ClipRRect(
borderRadius: BorderRadius.all(radiusLarge),
child: ColoredBox(
color: Colors.white60,
child: Icon(
PhosphorIcons.stickerBold,
size: 32,
),
),
),
),
),
onTap: () {
GetIt.I.get<StickerPackBloc>().add(
LocallyAvailableStickerPackRequested(
stickers.stickerPacks[index - 1].id,
),
);
},
);
},
),
),
),
),
),
Positioned(
top: 0,
left: 0,
right: 0,
bottom: 0,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 100),
curve: Curves.decelerate,
opacity: stickers.isImportRunning ? 1 : 0,
child: IgnorePointer(
ignoring: !stickers.isImportRunning,
child: const ColoredBox(
color: Colors.black54,
child: Align(
child: CircularProgressIndicator(),
),
),
),
),
),
],
),
),
);
}
}

View File

@ -0,0 +1,268 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/models/sticker.dart';
import 'package:moxxyv2/ui/bloc/sticker_pack_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/helpers.dart';
import 'package:moxxyv2/ui/widgets/chat/shared/base.dart';
import 'package:moxxyv2/ui/widgets/shimmer.dart';
import 'package:moxxyv2/ui/widgets/topbar.dart';
/// Wrapper around displaying stickers that may or may not be installed on the system.
class StickerWrapper extends StatelessWidget {
const StickerWrapper(
this.sticker, {
this.width,
this.height,
this.cover = true,
super.key,
}
);
final Sticker sticker;
final double? width;
final double? height;
final bool cover;
@override
Widget build(BuildContext context) {
if (sticker.path.isNotEmpty) {
return Image.file(
File(sticker.path),
fit: cover ?
BoxFit.contain :
null,
width: width,
height: height,
);
} else {
return Image.network(
sticker.urlSources.first,
fit: cover ?
BoxFit.contain :
null,
width: width,
height: height,
loadingBuilder: (_, child, event) {
if (event == null) return child;
return ClipRRect(
borderRadius: const BorderRadius.all(radiusLarge),
child: SizedBox(
width: width,
height: height,
child: const ShimmerWidget(),
),
);
},
);
}
}
}
class StickerPackPage extends StatelessWidget {
const StickerPackPage({ super.key });
static MaterialPageRoute<void> get route => MaterialPageRoute<void>(
builder: (_) => const StickerPackPage(),
settings: const RouteSettings(
name: stickerPackRoute,
),
);
Future<void> _onDeletePressed(BuildContext context, StickerPackState state) async {
final result = await showConfirmationDialog(
t.pages.stickerPack.removeConfirmTitle,
t.pages.stickerPack.removeConfirmBody,
context,
);
if (result) {
// ignore: use_build_context_synchronously
context.read<StickerPackBloc>().add(
StickerPackRemovedEvent(state.stickerPack!.id),
);
}
}
Future<void> _onInstallPressed(BuildContext context, StickerPackState state) async {
final result = await showConfirmationDialog(
t.pages.stickerPack.installConfirmTitle,
t.pages.stickerPack.installConfirmBody,
context,
);
if (result) {
// ignore: use_build_context_synchronously
context.read<StickerPackBloc>().add(
StickerPackInstalledEvent(),
);
}
}
Widget _buildButton(BuildContext context, StickerPackState state) {
Widget child;
Color color;
if (state.stickerPack!.local) {
color = Colors.red;
child = const Icon(
Icons.delete,
size: 32,
);
} else {
color = Colors.green;
if (state.isInstalling) {
child = const Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator(),
);
} else {
child = const Icon(
Icons.download,
size: 32,
);
}
}
return SharedMediaContainer(
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: ColoredBox(
color: color,
child: child,
),
),
onTap: () {
if (state.stickerPack!.local) {
_onDeletePressed(context, state);
} else {
if (state.isInstalling) return;
_onInstallPressed(context, state);
}
},
);
}
Widget _buildBody(BuildContext context, StickerPackState state) {
final width = MediaQuery.of(context).size.width;
final itemSize = (width - 2 * 15 - 3 * 30) / 4;
return Column(
children: [
Row(
children: [
SizedBox(
width: MediaQuery.of(context).size.width * 0.7,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
state.stickerPack?.description ?? '',
),
...state.stickerPack?.restricted == true ?
[
Padding(
padding: const EdgeInsets.only(top: 16),
child: Text(
t.pages.stickerPack.restricted,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
),
] : [],
],
),
),
),
const Spacer(),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: _buildButton(context, state),
),
],
),
Padding(
padding: const EdgeInsets.only(top: 16),
child: ListView.builder(
shrinkWrap: true,
itemCount: (state.stickerPack!.stickers.length / 4).ceil(),
itemBuilder: (_, index) {
final length = state.stickerPack!.stickers.length - index * 4;
return SizedBox(
width: width,
child: Row(
children: List<int>.generate(
length >= 4 ?
4 :
length,
(i) => i,
).map((rowIndex) {
final sticker = state.stickerPack!.stickers[index * 4 + rowIndex];
return Padding(
padding: const EdgeInsets.all(15),
child: InkWell(
onTap: () {
showDialog<void>(
context: context,
builder: (context) {
return IgnorePointer(
child: StickerWrapper(
sticker,
width: width - 80 * 2,
cover: false,
),
);
},
);
},
child: StickerWrapper(
sticker,
width: itemSize,
height: itemSize,
),
),
);
}).toList(),
),
);
},
),
),
],
);
}
@override
Widget build(BuildContext context) {
return BlocBuilder<StickerPackBloc, StickerPackState>(
builder: (context, state) => Scaffold(
appBar: BorderlessTopbar.simple(
state.stickerPack?.name ?? '...',
),
body: state.isWorking ?
SizedBox(
width: MediaQuery.of(context).size.width,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
CircularProgressIndicator(),
],
),
) :
_buildBody(context, state),
),
);
}
}

View File

@ -9,6 +9,7 @@ import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
import 'package:moxxyv2/ui/bloc/newconversation_bloc.dart';
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
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';
@ -44,6 +45,11 @@ Future<void> preStartDone(PreStartDoneEvent result, { dynamic extra }) async {
result.roster!,
),
);
GetIt.I.get<StickersBloc>().add(
StickersSetEvent(
result.stickers!,
),
);
GetIt.I.get<Logger>().finest('Navigating to conversations');

View File

@ -1,15 +1,32 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:phosphor_flutter/phosphor_flutter.dart';
const _bubbleBottomIconSize = fontsizeSubbody * 1.5;
/// A row containing all the neccessary message metadata, like edit state, received
/// time and so on.
///
/// [message] refers to the message whose metadata we should display.
///
/// [sent] is true if the current user sent the message. If it was received (and is not
/// a carbon from a message we sent on another device), this should be false.
///
/// [shrink] indiactes whether the internal Row element should have a mainAxisSize of
/// min (true) or max (false). Defaults to false.
class MessageBubbleBottom extends StatefulWidget {
const MessageBubbleBottom(this.message, this.sent, { super.key });
const MessageBubbleBottom(this.message, this.sent, {
this.shrink = false,
super.key,
});
final Message message;
final bool sent;
final bool shrink;
@override
MessageBubbleBottomState createState() => MessageBubbleBottomState();
@ -74,6 +91,9 @@ class MessageBubbleBottomState extends State<MessageBubbleBottom> {
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.end,
mainAxisSize: widget.shrink ?
MainAxisSize.min :
MainAxisSize.max,
children: [
Padding(
padding: const EdgeInsets.only(top: 3),
@ -96,6 +116,15 @@ class MessageBubbleBottomState extends State<MessageBubbleBottom> {
),
),
] : [],
...widget.message.stickerPackId != null && !GetIt.I.get<PreferencesBloc>().state.enableStickers ? [
const Padding(
padding: EdgeInsets.only(left: 3),
child: Icon(
PhosphorIcons.stickerBold,
size: _bubbleBottomIconSize,
),
),
] : [],
...widget.message.encrypted ? [
const Padding(
padding: EdgeInsets.only(left: 3),

View File

@ -40,20 +40,21 @@ class RawChatBubble extends StatelessWidget {
bottomRight: sentBySelf && (between || start) && !(start && end) ? radiusSmall : radiusLarge,
);
}
/// Returns true if the mime type has a special widget which replaces the bubble.
/// False otherwise.
bool _isInlinedWidget() {
if (message.mediaType != null) {
return message.mediaType!.startsWith('image/');
}
return false;
}
/// Specified when the message bubble should not have color
bool _shouldNotColorBubble() {
return message.isMedia && message.mediaUrl != null && _isInlinedWidget();
var isInlinedWidget = false;
if (message.mediaType != null) {
isInlinedWidget = message.mediaType!.startsWith('image/');
}
// Check if it is an embedded file
if (message.isMedia && message.mediaUrl != null && isInlinedWidget) {
return true;
}
// Stickers are also not colored
return message.stickerPackId != null && message.stickerHashKey != null;
}
Color? _getBubbleColor(BuildContext context) {

View File

@ -5,12 +5,14 @@ import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/ui/widgets/chat/media/audio.dart';
import 'package:moxxyv2/ui/widgets/chat/media/file.dart';
import 'package:moxxyv2/ui/widgets/chat/media/image.dart';
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/video.dart';
import 'package:moxxyv2/ui/widgets/chat/shared/audio.dart';
import 'package:moxxyv2/ui/widgets/chat/shared/file.dart';
@ -23,13 +25,18 @@ enum MessageType {
image,
video,
audio,
file
file,
sticker
}
/// Deduce the type of message we are dealing with to pick the correct
/// widget.
MessageType getMessageType(Message message) {
if (message.isMedia) {
if (message.stickerPackId != null) {
return MessageType.sticker;
}
final mime = message.mediaType;
if (mime == null) return MessageType.file;
@ -68,12 +75,12 @@ Widget buildMessageWidget(Message message, double maxWidth, BorderRadius radius,
topWidget: message.quotes != null ? buildQuoteMessageWidget(message.quotes!, sent) : null,
);
}
case MessageType.image: {
case MessageType.image:
return ImageChatWidget(message, radius, maxWidth, sent);
}
case MessageType.video: {
case MessageType.video:
return VideoChatWidget(message, radius, maxWidth, sent);
}
case MessageType.sticker:
return StickerChatWidget(message, radius, maxWidth, sent);
case MessageType.audio:
return AudioChatWidget(message, radius, maxWidth, sent);
case MessageType.file: {
@ -85,6 +92,8 @@ Widget buildMessageWidget(Message message, double maxWidth, BorderRadius radius,
/// Build a widget that represents a quoted message within another bubble.
Widget buildQuoteMessageWidget(Message message, bool sent, { void Function()? resetQuote}) {
switch (getMessageType(message)) {
case MessageType.sticker:
return QuotedStickerWidget(message, sent, resetQuote: resetQuote);
case MessageType.text:
return QuoteBaseWidget(
message,

View File

@ -0,0 +1,128 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/shared/models/sticker.dart';
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
import 'package:moxxyv2/ui/bloc/sticker_pack_bloc.dart';
import 'package:moxxyv2/ui/bloc/stickers_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/widgets/chat/bottom.dart';
import 'package:phosphor_flutter/phosphor_flutter.dart';
class StickerChatWidget extends StatelessWidget {
const StickerChatWidget(
this.message,
this.radius,
this.maxWidth,
this.sent,
{
super.key,
}
);
final Message message;
final double maxWidth;
final BorderRadius radius;
final bool sent;
Widget _buildNotAvailable(BuildContext context) {
return Align(
alignment: sent ?
Alignment.centerRight :
Alignment.centerLeft,
child: DecoratedBox(
decoration: BoxDecoration(
color: sent ?
bubbleColorSent :
bubbleColorReceived,
borderRadius: const BorderRadius.all(radiusLarge),
),
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Padding(
padding: EdgeInsets.only(right: 8),
child: Icon(
PhosphorIcons.stickerBold,
),
),
Text(
message.body,
),
],
),
),
),
);
}
@override
Widget build(BuildContext context) {
return BlocBuilder<StickersBloc, StickersState>(
buildWhen: (prev, next) => prev.stickerPacks.length != next.stickerPacks.length,
builder: (context, state) {
Sticker? sticker;
if (message.stickerPackId != null && message.stickerHashKey != null) {
final stickerKey = StickerKey(message.stickerPackId!, message.stickerHashKey!);
sticker = state.stickerMap[stickerKey];
}
return IntrinsicHeight(
child: Column(
children: [
// ignore: prefer_if_elements_to_conditional_expressions
sticker != null && GetIt.I.get<PreferencesBloc>().state.enableStickers ?
InkWell(
onTap: () {
GetIt.I.get<StickerPackBloc>().add(
LocallyAvailableStickerPackRequested(
sticker!.stickerPackId,
),
);
},
child: Image.file(File(sticker.path)),
) :
InkWell(
onTap: () {
context.read<StickerPackBloc>().add(
RemoteStickerPackRequested(
message.stickerPackId!,
// TODO(PapaTutuWawa): This does not feel clean
message.sender.split('/').first,
),
);
},
child: _buildNotAvailable(context),
),
Align(
alignment: sent ?
Alignment.centerRight :
Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(top: 1),
child: DecoratedBox(
decoration: BoxDecoration(
color: sent ?
bubbleColorSent :
bubbleColorReceived,
borderRadius: const BorderRadius.all(radiusLarge),
),
child: Padding(
padding: const EdgeInsets.all(8),
child: MessageBubbleBottom(message, sent, shrink: true),
),
),
),
),
],
),
);
},
);
}
}

View File

@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/shared/models/sticker.dart';
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
import 'package:moxxyv2/ui/bloc/stickers_bloc.dart';
import 'package:moxxyv2/ui/widgets/chat/quote/base.dart';
import 'package:moxxyv2/ui/widgets/chat/quote/media.dart';
import 'package:moxxyv2/ui/widgets/chat/shared/image.dart';
import 'package:phosphor_flutter/phosphor_flutter.dart';
class QuotedStickerWidget extends StatelessWidget {
const QuotedStickerWidget(
this.message,
this.sent, {
this.resetQuote,
super.key,
}
);
final Message message;
final bool sent;
final void Function()? resetQuote;
@override
Widget build(BuildContext context) {
Sticker? sticker;
if (message.stickerPackId != null &&
message.stickerHashKey != null &&
GetIt.I.get<PreferencesBloc>().state.enableStickers) {
final stickerKey = StickerKey(message.stickerPackId!, message.stickerHashKey!);
sticker = GetIt.I.get<StickersBloc>().state.stickerMap[stickerKey];
}
if (sticker != null) {
return QuotedMediaBaseWidget(
message,
SharedImageWidget(
sticker.path,
size: 48,
borderRadius: 8,
),
message.body,
sent,
resetQuote: resetQuote,
);
} else {
return QuoteBaseWidget(
message,
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Padding(
padding: EdgeInsets.only(right: 8),
child: Icon(
PhosphorIcons.stickerBold,
),
),
Text(
message.body,
),
],
),
sent,
resetQuotedMessage: resetQuote,
);
}
}
}

View File

@ -5,9 +5,8 @@ import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/error_types.dart';
import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/redirects.dart';
import 'package:moxxyv2/ui/helpers.dart';
import 'package:moxxyv2/ui/widgets/chat/bottom.dart';
import 'package:url_launcher/url_launcher.dart';
/// Used whenever the mime type either doesn't match any specific chat widget or we just
/// cannot determine the mime type.
@ -62,16 +61,13 @@ class TextChatWidget extends StatelessWidget {
),
parse: [
MatchText(
type: ParsedType.URL,
// Taken from flutter_parsed_text's source code. Added ";" and "%" to
// valid URLs
pattern: r'[(http(s)?):\/\/(www\.)?a-zA-Z0-9@:._\+-~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:_\+.~#?&\/\/=\;\%]*)',
style: const TextStyle(
decoration: TextDecoration.underline,
),
onTap: (url) async {
await launchUrl(
redirectUrl(Uri.parse(url)),
mode: LaunchMode.externalNonBrowserApplication,
);
},
onTap: handleUri,
)
],
),

View File

@ -2,11 +2,15 @@ import 'dart:async';
import 'dart:io';
import 'package:badges/badges.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/constants.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/conversation.dart';
import 'package:moxxyv2/shared/models/sticker.dart';
import 'package:moxxyv2/ui/bloc/preferences_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:moxxyv2/ui/widgets/avatar.dart';
@ -14,6 +18,7 @@ import 'package:moxxyv2/ui/widgets/chat/shared/image.dart';
import 'package:moxxyv2/ui/widgets/chat/shared/video.dart';
import 'package:moxxyv2/ui/widgets/chat/typing.dart';
import 'package:moxxyv2/ui/widgets/contact_helper.dart';
import 'package:phosphor_flutter/phosphor_flutter.dart';
class ConversationsListRow extends StatefulWidget {
const ConversationsListRow(
@ -121,9 +126,34 @@ class ConversationsListRowState extends State<ConversationsListRow> {
);
}
Widget _buildLastMessagePreview() {
Widget _buildLastMessagePreview(StickersState state) {
Widget? preview;
if (widget.conversation.lastMessage!.mediaType!.startsWith('image/')) {
if (widget.conversation.lastMessage!.stickerPackId != null) {
Sticker? sticker;
if (widget.conversation.lastMessage!.stickerPackId != null &&
widget.conversation.lastMessage!.stickerHashKey != null &&
GetIt.I.get<PreferencesBloc>().state.enableStickers) {
final stickerKey = StickerKey(
widget.conversation.lastMessage!.stickerPackId!,
widget.conversation.lastMessage!.stickerHashKey!,
);
sticker = state.stickerMap[stickerKey];
}
if (sticker != null) {
preview = SharedImageWidget(
sticker.path,
borderRadius: 5,
size: 30,
);
} else {
preview = const Icon(
PhosphorIcons.stickerBold,
size: 30,
);
}
} else if (widget.conversation.lastMessage!.mediaType!.startsWith('image/')) {
preview = SharedImageWidget(
widget.conversation.lastMessage!.mediaUrl!,
borderRadius: 5,
@ -160,7 +190,9 @@ class ConversationsListRowState extends State<ConversationsListRow> {
} else if (lastMessage.isMedia) {
// If the file is thumbnailable, we display a small preview on the left of the
// body, so we don't need the emoji then.
if (lastMessage.isThumbnailable) {
if (lastMessage.stickerPackId != null) {
body = t.messages.sticker;
} else if (lastMessage.isThumbnailable) {
body = mimeTypeToName(lastMessage.mediaType);
} else {
body = mimeTypeToEmoji(lastMessage.mediaType);
@ -283,7 +315,11 @@ class ConversationsListRowState extends State<ConversationsListRow> {
...widget.conversation.lastMessage?.isThumbnailable == true && !widget.conversation.isTyping ? [
Padding(
padding: const EdgeInsets.only(right: 5),
child: _buildLastMessagePreview(),
child: BlocBuilder<StickersBloc, StickersState>(
buildWhen: (prev, next) => prev.stickerPacks.length != next.stickerPacks.length &&
widget.conversation.lastMessage?.stickerPackId != null,
builder: (_, state) => _buildLastMessagePreview(state),
),
),
] : [
const SizedBox(height: 30),

View File

@ -42,6 +42,7 @@ class OverviewMenu extends StatelessWidget {
this.rightBorder = true,
this.left,
this.right,
this.materialColor,
super.key,
}
);
@ -52,6 +53,7 @@ class OverviewMenu extends StatelessWidget {
final double? left;
final double? right;
final BorderRadius? highlightMaterialBorder;
final Color? materialColor;
@override
Widget build(BuildContext context) {
@ -66,6 +68,7 @@ class OverviewMenu extends StatelessWidget {
top: _animation.value,
child: Material(
borderRadius: highlightMaterialBorder,
color: materialColor,
child: highlight,
),
);

View File

@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
/// A row for usage in a settings UI. Similar to what settings_ui provides but without
/// the need to be wrapped in a SettingsList. Useful for when showing settings in a
/// dynamically built list.
class SettingsRow extends StatelessWidget {
const SettingsRow({
required this.title,
this.description,
this.maxLines = 3,
this.suffix,
this.prefix,
this.onTap,
this.crossAxisAlignment = CrossAxisAlignment.center,
super.key,
});
final String title;
final String? description;
final int maxLines;
final Widget? suffix;
final Widget? prefix;
final void Function()? onTap;
final CrossAxisAlignment crossAxisAlignment;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 16,
horizontal: 16,
),
child: Row(
crossAxisAlignment: crossAxisAlignment,
children: [
if (prefix != null)
prefix!,
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w400,
),
),
if (description != null)
Padding(
padding: const EdgeInsets.only(
top: 4,
),
child: Text(
description!,
maxLines: maxLines,
style: Theme.of(context).textTheme.bodyMedium,
),
),
],
),
),
if (suffix != null)
suffix!,
],
),
),
);
}
}

View File

@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'package:moxxyv2/ui/constants.dart';
/// A section title similar to what settings_ui provides.
class SectionTitle extends StatelessWidget {
const SectionTitle(this.title, { super.key });
final String title;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(
top: 16,
left: 16,
right: 16,
),
child: Text(
title,
style: Theme.of(context).textTheme.subtitle2!.copyWith(
color: settingsSectionTitleColor,
),
),
);
}
}

View File

@ -0,0 +1,102 @@
import 'dart:io';
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';
class StickerPicker extends StatelessWidget {
StickerPicker({
required this.width,
required this.onStickerTapped,
super.key,
}) {
_itemSize = (width - 2 * 15 - 3 * 30) / 4;
}
final double width;
late final double _itemSize;
final void Function(Sticker, StickerPack) onStickerTapped;
@override
Widget build(BuildContext context) {
return BlocBuilder<StickersBloc, StickersState>(
builder: (context, state) {
final stickerPacks = state.stickerPacks
.where((pack) => !pack.restricted)
.toList();
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: ListView.builder(
itemCount: stickerPacks.length * 2,
itemBuilder: (_, si) {
if (si.isEven) {
return Padding(
padding: const EdgeInsets.only(left: 15),
child: Text(
stickerPacks[si ~/ 2].name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 20,
),
),
);
}
final sindex = (si - 1) ~/ 2;
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: (stickerPacks[sindex].stickers.length / 4).ceil(),
itemBuilder: (_, index) {
final stickersLength = stickerPacks[sindex].stickers.length - index * 4;
return SizedBox(
width: width,
child: Row(
children: List<int>.generate(
stickersLength >= 4 ?
4 :
stickersLength,
(i) => i,
).map((rowIndex) {
return Padding(
padding: const EdgeInsets.all(15),
child: InkWell(
onTap: () {
onStickerTapped(
stickerPacks[sindex].stickers[index * 4 + rowIndex],
stickerPacks[sindex],
);
},
child: Image.file(
File(
stickerPacks[sindex].stickers[index * 4 + rowIndex].path,
),
key: ValueKey('${state.stickerPacks[sindex].id}_${index * 4 + rowIndex}'),
fit: BoxFit.contain,
width: _itemSize,
height: _itemSize,
),
),
);
}).toList(),
),
);
},
);
},
),
),
),
);
},
);
}
}

View File

@ -238,6 +238,13 @@
<xmpp:version>0.2.0</xmpp:version>
</xmpp:SupportedXep>
</implements>
<implements>
<xmpp:SupportedXep>
<xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0449.html" />
<xmpp:status>complete</xmpp:status>
<xmpp:version>0.1.1</xmpp:version>
</xmpp:SupportedXep>
</implements>
<implements>
<xmpp:SupportedXep>
<xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0461.html" />

View File

@ -433,6 +433,48 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.2"
flutter_keyboard_visibility:
dependency: "direct main"
description:
name: flutter_keyboard_visibility
url: "https://pub.dartlang.org"
source: hosted
version: "5.4.0"
flutter_keyboard_visibility_linux:
dependency: transitive
description:
name: flutter_keyboard_visibility_linux
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
flutter_keyboard_visibility_macos:
dependency: transitive
description:
name: flutter_keyboard_visibility_macos
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
flutter_keyboard_visibility_platform_interface:
dependency: transitive
description:
name: flutter_keyboard_visibility_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
flutter_keyboard_visibility_web:
dependency: transitive
description:
name: flutter_keyboard_visibility_web
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
flutter_keyboard_visibility_windows:
dependency: transitive
description:
name: flutter_keyboard_visibility_windows
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
flutter_launcher_icons:
dependency: "direct dev"
description:
@ -787,7 +829,7 @@ packages:
description:
path: "packages/moxxmpp"
ref: HEAD
resolved-ref: "88efdc361c04711cb528bdc8b3a4022335fe4488"
resolved-ref: d64220426bb70adf28628b9670330630914ddd47
url: "https://git.polynom.me/Moxxy/moxxmpp.git"
source: git
version: "0.1.6+1"

View File

@ -33,6 +33,7 @@ dependencies:
flutter_contacts: 1.1.5+1
flutter_image_compress: 1.1.0
flutter_isolate: 2.0.2
flutter_keyboard_visibility: 5.4.0
flutter_localizations:
sdk: flutter
flutter_parsed_text: 2.2.1
@ -139,7 +140,7 @@ dependency_overrides:
moxxmpp:
git:
url: https://git.polynom.me/Moxxy/moxxmpp.git
rev: 88efdc361c04711cb528bdc8b3a4022335fe4488
rev: d64220426bb70adf28628b9670330630914ddd47
path: packages/moxxmpp
omemo_dart: