Compare commits
56 Commits
4ecebe8982
...
7068b989ef
Author | SHA1 | Date | |
---|---|---|---|
7068b989ef | |||
820fda78e7 | |||
d758423ec6 | |||
5472f097a4 | |||
e373f5cffe | |||
f04729261b | |||
b6c8778aec | |||
8dfe8d55a0 | |||
36b7d5ce42 | |||
8d780c3252 | |||
a841d5de2d | |||
fdd8d306f7 | |||
9510a0fced | |||
c3ec9dfb11 | |||
82c136b684 | |||
ea4bb752b9 | |||
bac673df99 | |||
df2c2f5e4b | |||
8c3863f970 | |||
bc49e31164 | |||
ce4c54b0d5 | |||
7b09cdeefd | |||
39dc96ab7a | |||
2d13ff328e | |||
53dd598547 | |||
40b4a540a8 | |||
33ae53c199 | |||
97e9b0636b | |||
b0b21e9d53 | |||
53d5402502 | |||
a190a9564e | |||
7846520788 | |||
3444683983 | |||
00118ddafe | |||
525ba293e3 | |||
071f6c08fd | |||
da70236a45 | |||
cfdda2d293 | |||
aba265d787 | |||
bbcb37bc4e | |||
eff7d7493d | |||
730916758e | |||
9acfe2751e | |||
386569d7cf | |||
39a7e1eb19 | |||
f492845235 | |||
ab42fc8b57 | |||
a5a9fce330 | |||
a70286dda4 | |||
2b3e587be4 | |||
ebfac9730b | |||
fbd3c6ca92 | |||
1cd3dabcea | |||
eba17880d0 | |||
c168f910a9 | |||
98dd704fda |
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
43
docs/stickerpacks.md
Normal 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>
|
||||
```
|
@ -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"
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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(
|
||||
'''
|
||||
|
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
59
lib/service/database/migrations/0000_stickers.dart
Normal file
59
lib/service/database/migrations/0000_stickers.dart
Normal 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(),
|
||||
);
|
||||
}
|
49
lib/service/database/migrations/0000_stickers_hash_key.dart
Normal file
49
lib/service/database/migrations/0000_stickers_hash_key.dart
Normal 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
|
||||
)''',
|
||||
);
|
||||
}
|
@ -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;',
|
||||
);
|
||||
}
|
@ -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 "";',
|
||||
);
|
||||
}
|
@ -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 "";',
|
||||
);
|
||||
}
|
@ -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 "{}";',
|
||||
);
|
||||
}
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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]}';
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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();
|
||||
|
@ -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
341
lib/service/stickers.dart
Normal 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;
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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';
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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',
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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
|
||||
|
88
lib/shared/models/sticker.dart
Normal file
88
lib/shared/models/sticker.dart
Normal 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,
|
||||
);
|
||||
}
|
74
lib/shared/models/sticker_pack.dart
Normal file
74
lib/shared/models/sticker_pack.dart
Normal 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,
|
||||
);
|
||||
}
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
}
|
||||
|
184
lib/ui/bloc/sticker_pack_bloc.dart
Normal file
184
lib/ui/bloc/sticker_pack_bloc.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
33
lib/ui/bloc/sticker_pack_event.dart
Normal file
33
lib/ui/bloc/sticker_pack_event.dart
Normal 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;
|
||||
}
|
10
lib/ui/bloc/sticker_pack_state.dart
Normal file
10
lib/ui/bloc/sticker_pack_state.dart
Normal 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;
|
||||
}
|
144
lib/ui/bloc/stickers_bloc.dart
Normal file
144
lib/ui/bloc/stickers_bloc.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
25
lib/ui/bloc/stickers_event.dart
Normal file
25
lib/ui/bloc/stickers_event.dart
Normal 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;
|
||||
}
|
25
lib/ui/bloc/stickers_state.dart
Normal file
25
lib/ui/bloc/stickers_state.dart
Normal 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;
|
||||
}
|
@ -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';
|
||||
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
@ -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(
|
||||
|
@ -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;
|
||||
}
|
||||
},
|
||||
|
@ -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),
|
||||
|
160
lib/ui/pages/settings/stickers.dart
Normal file
160
lib/ui/pages/settings/stickers.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
268
lib/ui/pages/sticker_pack.dart
Normal file
268
lib/ui/pages/sticker_pack.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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');
|
||||
|
||||
|
@ -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),
|
||||
|
@ -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) {
|
||||
|
@ -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,
|
||||
|
128
lib/ui/widgets/chat/media/sticker.dart
Normal file
128
lib/ui/widgets/chat/media/sticker.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
68
lib/ui/widgets/chat/quote/sticker.dart
Normal file
68
lib/ui/widgets/chat/quote/sticker.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@ -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,
|
||||
)
|
||||
],
|
||||
),
|
||||
|
@ -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),
|
||||
|
@ -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,
|
||||
),
|
||||
);
|
||||
|
75
lib/ui/widgets/settings/row.dart
Normal file
75
lib/ui/widgets/settings/row.dart
Normal 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!,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
25
lib/ui/widgets/settings/title.dart
Normal file
25
lib/ui/widgets/settings/title.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
102
lib/ui/widgets/sticker_picker.dart
Normal file
102
lib/ui/widgets/sticker_picker.dart
Normal 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(),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
@ -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" />
|
||||
|
44
pubspec.lock
44
pubspec.lock
@ -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"
|
||||
|
@ -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:
|
||||
|
Loading…
Reference in New Issue
Block a user