allow sending emoji/stickers from emote packs
This commit is contained in:
parent
868023a4a1
commit
3754bc2feb
23 changed files with 701 additions and 74 deletions
25
lib/controllers/all_emoji.dart
Normal file
25
lib/controllers/all_emoji.dart
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:material_emoji_picker/material_emoji_picker.dart";
|
||||
import "package:nexus/controllers/image_packs.dart";
|
||||
import "package:nexus/helpers/extensions/image_pack_emoji.dart";
|
||||
|
||||
class AllEmojiController(final String roomId)
|
||||
extends AsyncNotifier<IList<EmojiCategory>> {
|
||||
@override
|
||||
Future<IList<EmojiCategory>> build() async {
|
||||
final packs = await ref.watch(ImagePacksController.provider(roomId).future);
|
||||
final unicode = await ref.watch(EmojiController.provider.future);
|
||||
|
||||
return packs
|
||||
.map((pack) => pack.emojiCategory)
|
||||
.nonNulls
|
||||
.toIList()
|
||||
.addAll(unicode);
|
||||
}
|
||||
|
||||
static final provider = AsyncNotifierProvider.autoDispose
|
||||
.family<AllEmojiController, IList<EmojiCategory>, String>(
|
||||
AllEmojiController.new,
|
||||
);
|
||||
}
|
||||
|
|
@ -29,6 +29,7 @@ import "package:nexus/models/requests/get_event_context.dart";
|
|||
import "package:nexus/models/requests/get_mentions.dart";
|
||||
import "package:nexus/models/requests/get_related_events.dart";
|
||||
import "package:nexus/models/requests/get_room_state.dart";
|
||||
import "package:nexus/models/requests/get_specific_room_state.dart";
|
||||
import "package:nexus/models/requests/get_space_hierarchy.dart";
|
||||
import "package:nexus/models/requests/join_room.dart";
|
||||
import "package:nexus/models/profile_response.dart";
|
||||
|
|
@ -185,6 +186,7 @@ class ClientController extends AsyncNotifier<int> {
|
|||
...json,
|
||||
"content": {
|
||||
...content,
|
||||
if (request.relatesTo != null)
|
||||
"m.relates_to": {
|
||||
...((content["m.relates_to"] as Map<String, dynamic>?) ?? {}),
|
||||
"event_id": request.relatesTo,
|
||||
|
|
@ -265,6 +267,15 @@ class ClientController extends AsyncNotifier<int> {
|
|||
);
|
||||
}
|
||||
|
||||
Future<IList<Event>> getSpecificRoomState(
|
||||
GetSpecificRoomStateRequest request,
|
||||
) async => .new(
|
||||
((await _sendCommand("get_specific_room_state", request.toJson()))
|
||||
as List? ??
|
||||
[])
|
||||
.map((event) => .fromJson(event)),
|
||||
);
|
||||
|
||||
Future<IList<Event>?> getRelatedEvents(
|
||||
GetRelatedEventsRequest request,
|
||||
) async {
|
||||
|
|
|
|||
112
lib/controllers/image_pack_sources.dart
Normal file
112
lib/controllers/image_pack_sources.dart
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/account_data.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/controllers/space_edges.dart";
|
||||
import "package:nexus/models/content/content.dart";
|
||||
import "package:nexus/models/requests/get_specific_room_state.dart";
|
||||
|
||||
typedef ImagePackSource = ({String roomId, String? stateKey});
|
||||
|
||||
class ImagePackSourcesController(final String roomId)
|
||||
extends AsyncNotifier<IList<ImagePackSource>> {
|
||||
@override
|
||||
Future<IList<ImagePackSource>> build() async {
|
||||
final enabledPacks = ref.watch(
|
||||
AccountDataController.provider.select(
|
||||
(data) =>
|
||||
(data.imagePackRooms ?? data.legacyImagePackRooms)?.rooms.map(
|
||||
(roomId, packs) => .new(roomId, packs.keys.toISet()),
|
||||
) ??
|
||||
const .empty(),
|
||||
),
|
||||
);
|
||||
final spaceEdges = ref.watch(SpaceEdgesController.provider);
|
||||
final directParents = ref.watch(
|
||||
RoomsController.provider.select(
|
||||
(rooms) =>
|
||||
rooms[roomId]?.state["m.space.parent"]?.keys.toISet() ??
|
||||
const .empty(),
|
||||
),
|
||||
);
|
||||
|
||||
Iterable<String> parentsOf(String childId) => spaceEdges.entries
|
||||
.where((entry) => entry.value.any((edge) => edge.childId == childId))
|
||||
.map((entry) => entry.key)
|
||||
.followedBy(childId == roomId ? directParents : const []);
|
||||
|
||||
ISet<String> withAncestors(ISet<String> spaces, String childId) =>
|
||||
parentsOf(childId).fold(
|
||||
spaces,
|
||||
(spaces, parentId) => spaces.contains(parentId)
|
||||
? spaces
|
||||
: withAncestors(spaces.add(parentId), parentId),
|
||||
);
|
||||
|
||||
final rooms = ref.read(RoomsController.provider);
|
||||
final spaces = withAncestors(
|
||||
const .empty(),
|
||||
roomId,
|
||||
).remove(roomId).where(rooms.containsKey).toIList();
|
||||
|
||||
final client = ref.read(ClientController.provider.notifier);
|
||||
final roomsNotifier = ref.read(RoomsController.provider.notifier);
|
||||
|
||||
await Future.wait(
|
||||
spaces
|
||||
.where((spaceId) => !rooms[spaceId]!.hasFetchedState)
|
||||
.map(
|
||||
(spaceId) async => roomsNotifier.addState(
|
||||
spaceId,
|
||||
await client.getRoomState(.new(roomId: spaceId)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const packTypes = <EventType>[.imagePack, .legacyImagePack];
|
||||
// Rooms we aren't in can't be fetched, and fetched rooms have every pack
|
||||
final missingKeys = enabledPacks.entries
|
||||
.where((entry) => !(rooms[entry.key]?.hasFetchedState ?? true))
|
||||
.expand(
|
||||
(entry) => entry.value
|
||||
.where(
|
||||
(stateKey) => packTypes.every(
|
||||
(type) =>
|
||||
rooms[entry.key]?.state[type.type]?[stateKey] == null,
|
||||
),
|
||||
)
|
||||
.expand(
|
||||
(stateKey) => packTypes.map(
|
||||
(type) => RoomStateKey(
|
||||
roomId: entry.key,
|
||||
type: type.type,
|
||||
stateKey: stateKey,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toIList();
|
||||
|
||||
if (missingKeys.isNotEmpty) {
|
||||
roomsNotifier.addSpecificState(
|
||||
await client.getSpecificRoomState(.new(keys: missingKeys)),
|
||||
);
|
||||
}
|
||||
|
||||
return .new([
|
||||
(roomId: roomId, stateKey: null),
|
||||
...spaces.map((spaceId) => (roomId: spaceId, stateKey: null)),
|
||||
...enabledPacks.entries.expand(
|
||||
(entry) => entry.value.map(
|
||||
(stateKey) => (roomId: entry.key, stateKey: stateKey),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
static final provider = AsyncNotifierProvider.autoDispose
|
||||
.family<ImagePackSourcesController, IList<ImagePackSource>, String>(
|
||||
ImagePackSourcesController.new,
|
||||
);
|
||||
}
|
||||
72
lib/controllers/image_packs.dart
Normal file
72
lib/controllers/image_packs.dart
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/image_pack_sources.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/models/content/content.dart";
|
||||
import "package:nexus/models/content/image_pack.dart";
|
||||
import "package:nexus/models/event.dart";
|
||||
import "package:nexus/models/image_pack.dart";
|
||||
|
||||
class ImagePacksController(final String roomId)
|
||||
extends AsyncNotifier<IList<ImagePack>> {
|
||||
@override
|
||||
Future<IList<ImagePack>> build() async {
|
||||
final sources = await ref.watch(
|
||||
ImagePackSourcesController.provider(roomId).future,
|
||||
);
|
||||
|
||||
final packs = ref.watch(
|
||||
RoomsController.provider.select(
|
||||
(rooms) => sources.expand((source) {
|
||||
final room = rooms[source.roomId];
|
||||
if (room == null) return const <ImagePack>[];
|
||||
|
||||
final newPacks = room.state[EventType.imagePack.type];
|
||||
final legacyPacks = room.state[EventType.legacyImagePack.type];
|
||||
final roomName = room.metadata?.name ?? source.roomId;
|
||||
|
||||
return switch (source.stateKey) {
|
||||
final stateKey? => ISet([stateKey]),
|
||||
null => ISet(
|
||||
newPacks?.keys ?? const <String>[],
|
||||
).addAll(legacyPacks?.keys ?? const []),
|
||||
}
|
||||
.map(
|
||||
(stateKey) =>
|
||||
switch (newPacks?[stateKey] ?? legacyPacks?[stateKey]) {
|
||||
final rowId? => switch (room.events[rowId]) {
|
||||
Event(
|
||||
redactedBy: null,
|
||||
content: final ImagePackContent content,
|
||||
) =>
|
||||
ImagePack.fromContent(
|
||||
content,
|
||||
id: "${source.roomId}/$stateKey",
|
||||
fallbackName: stateKey.isEmpty
|
||||
? roomName
|
||||
: "$roomName - $stateKey",
|
||||
),
|
||||
_ => null,
|
||||
},
|
||||
null => null,
|
||||
},
|
||||
)
|
||||
.nonNulls;
|
||||
}).toIList(),
|
||||
),
|
||||
);
|
||||
|
||||
return .new(
|
||||
packs
|
||||
.whereNoDuplicates(by: (pack) => pack.id)
|
||||
.where(
|
||||
(pack) => pack.emoticons.isNotEmpty || pack.stickers.isNotEmpty,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static final provider = AsyncNotifierProvider.autoDispose
|
||||
.family<ImagePacksController, IList<ImagePack>, String>(
|
||||
ImagePacksController.new,
|
||||
);
|
||||
}
|
||||
|
|
@ -23,7 +23,13 @@ class RecentEmojiController extends Notifier<IList<RecentEmoji>> {
|
|||
),
|
||||
);
|
||||
|
||||
return .new(recents.where((recent) => emoji.contains(recent.emoji)));
|
||||
return .new(
|
||||
recents.where(
|
||||
(recent) =>
|
||||
// Is unicode or custom emote
|
||||
emoji.contains(recent.emoji) || recent.emoji.startsWith("mxc://"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> add(String emoji) => ref
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ import "package:nexus/controllers/attachment.dart";
|
|||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/models/content/content.dart";
|
||||
import "package:nexus/models/content/image_pack.dart";
|
||||
import "package:nexus/models/content/reaction.dart";
|
||||
import "package:nexus/models/content/sticker.dart";
|
||||
import "package:nexus/models/direction.dart";
|
||||
import "package:nexus/models/event.dart";
|
||||
import "package:nexus/models/requests/redact_event.dart";
|
||||
|
|
@ -315,14 +317,24 @@ class RoomChatController(final ChatParams info)
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> sendReaction(String reaction, Event event) async {
|
||||
/// Reacts to [event] with [reaction], which is an mxc URL for custom emoji,
|
||||
/// with their [shortcode].
|
||||
Future<void> sendReaction(
|
||||
String reaction,
|
||||
Event event, {
|
||||
String? shortcode,
|
||||
}) async {
|
||||
final client = ref.read(ClientController.provider.notifier);
|
||||
|
||||
await client.sendEvent(
|
||||
.new(
|
||||
roomId: info.roomId,
|
||||
type: EventType.reaction.type,
|
||||
content: ReactionContent(key: reaction),
|
||||
// Wrapped in colons, like other clients send it
|
||||
content: ReactionContent(
|
||||
key: reaction,
|
||||
shortcode: shortcode == null ? null : ":$shortcode:",
|
||||
),
|
||||
synchronous: true,
|
||||
disableEncryption: true,
|
||||
relatesTo: event.eventId,
|
||||
|
|
@ -331,6 +343,33 @@ class RoomChatController(final ChatParams info)
|
|||
);
|
||||
}
|
||||
|
||||
Future<void> sendSticker(String shortcode, PackImage image) async {
|
||||
final event = await ref
|
||||
.read(ClientController.provider.notifier)
|
||||
.sendEvent(
|
||||
.new(
|
||||
roomId: info.roomId,
|
||||
type: EventType.sticker.type,
|
||||
content: StickerContent(
|
||||
body: image.body ?? shortcode,
|
||||
info: image.info ?? const .new(),
|
||||
url: image.url,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
ref
|
||||
.read(RoomsController.provider.notifier)
|
||||
.update(
|
||||
.new({
|
||||
info.roomId: .new(
|
||||
events: .new({event.rowId: event}),
|
||||
clientSticky: .new({event.rowId}),
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
static final provider = AsyncNotifierProvider.family
|
||||
.autoDispose<RoomChatController, RoomChat?, ChatParams>(
|
||||
RoomChatController.new,
|
||||
|
|
|
|||
|
|
@ -11,16 +11,25 @@ class RoomsController extends Notifier<IMap<String, Room>> {
|
|||
@override
|
||||
IMap<String, Room> build() => .new();
|
||||
|
||||
void addState(
|
||||
String roomId,
|
||||
IList<Event> state, {
|
||||
bool isMembers = false,
|
||||
}) => update(
|
||||
void addState(String roomId, IList<Event> state, {bool isMembers = false}) =>
|
||||
update(
|
||||
.new({
|
||||
roomId: Room(
|
||||
roomId: stateRoom(state)
|
||||
.copyWith(hasFetchedState: true, hasFetchedMembers: isMembers),
|
||||
}),
|
||||
.new(),
|
||||
);
|
||||
|
||||
void addSpecificState(Iterable<Event> state) => update(
|
||||
.new(
|
||||
state
|
||||
.groupListsBy((event) => event.roomId)
|
||||
.map((roomId, roomState) => .new(roomId, stateRoom(roomState))),
|
||||
),
|
||||
);
|
||||
|
||||
static Room stateRoom(Iterable<Event> state) => Room(
|
||||
events: .fromEntries(state.map((event) => .new(event.rowId, event))),
|
||||
hasFetchedState: true,
|
||||
hasFetchedMembers: isMembers,
|
||||
state: .new(
|
||||
state
|
||||
.groupListsBy((event) => event.type)
|
||||
|
|
@ -31,9 +40,6 @@ class RoomsController extends Notifier<IMap<String, Room>> {
|
|||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
.new(),
|
||||
);
|
||||
|
||||
static IMap<String, IList<ReadReceipt>> mergeReceipts(
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import "package:nexus/models/content/message.dart";
|
|||
import "package:nexus/models/event.dart";
|
||||
import "package:nexus/models/relation_type.dart";
|
||||
import "package:nexus/widgets/emoji_picker.dart";
|
||||
import "package:nexus/widgets/mxc_emoji.dart";
|
||||
import "package:nexus/main.dart";
|
||||
|
||||
extension BuildEventOptions on Event {
|
||||
|
|
@ -97,6 +98,8 @@ extension BuildEventOptions on Event {
|
|||
mainAxisAlignment: isDesktop ? .center : .spaceEvenly,
|
||||
children: [
|
||||
...{
|
||||
// Custom emoji are sent without their shortcode, as only
|
||||
// their mxc URL is recorded
|
||||
...ref
|
||||
.read(RecentEmojiController.provider)
|
||||
.map((entry) => entry.emoji),
|
||||
|
|
@ -109,7 +112,12 @@ extension BuildEventOptions on Event {
|
|||
.take(isDesktop ? 3 : 5)
|
||||
.map(
|
||||
(emoji) => IconButton(
|
||||
icon: Text(
|
||||
icon: emoji.startsWith("mxc://")
|
||||
? SizedBox.square(
|
||||
dimension: isDesktop ? 18 : 24,
|
||||
child: MxcEmoji(Uri.parse(emoji)),
|
||||
)
|
||||
: Text(
|
||||
emoji,
|
||||
style: .new(fontSize: isDesktop ? null : 18),
|
||||
),
|
||||
|
|
@ -134,12 +142,13 @@ extension BuildEventOptions on Event {
|
|||
|
||||
context.showAdaptiveSheet(
|
||||
(context) => EmojiPicker(
|
||||
roomId: roomId,
|
||||
allowFreeText: true,
|
||||
onSelection: (emoji) {
|
||||
onSelection: (emoji, {shortcode}) {
|
||||
Navigator.of(context).pop();
|
||||
ref
|
||||
.read(notifier)
|
||||
.sendReaction(emoji, this)
|
||||
.sendReaction(emoji, this, shortcode: shortcode)
|
||||
.onError(showError);
|
||||
},
|
||||
),
|
||||
|
|
|
|||
29
lib/helpers/extensions/image_pack_emoji.dart
Normal file
29
lib/helpers/extensions/image_pack_emoji.dart
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:material_emoji_picker/material_emoji_picker.dart";
|
||||
import "package:nexus/models/content/image_pack.dart";
|
||||
import "package:nexus/models/image_pack.dart";
|
||||
import "package:nexus/widgets/mxc_emoji.dart";
|
||||
|
||||
extension ImagePackEmoji on ImagePack {
|
||||
EmojiCategory? get emojiCategory => _category(emoticons);
|
||||
EmojiCategory? get stickerCategory => _category(stickers);
|
||||
|
||||
EmojiCategory? _category(IMap<String, PackImage> images) => images.isEmpty
|
||||
? null
|
||||
: .new(
|
||||
icon: MxcEmoji(avatar ?? images.values.first.url),
|
||||
name: name,
|
||||
emojis: images
|
||||
.mapTo(
|
||||
(shortcode, image) => Emoji(
|
||||
widget: MxcEmoji(image.url, label: shortcode),
|
||||
value: image.url.toString(),
|
||||
aliases: .new([shortcode]),
|
||||
description: image.body ?? shortcode,
|
||||
tags: const .empty(),
|
||||
data: image,
|
||||
),
|
||||
)
|
||||
.toIList(),
|
||||
);
|
||||
}
|
||||
|
|
@ -16,10 +16,24 @@ class const AccountData({
|
|||
|
||||
@JsonKey(name: AccountData.recentEmojiKey)
|
||||
final RecentEmojiData recentEmoji = const RecentEmojiData(),
|
||||
|
||||
/// Room image packs the user has enabled everywhere
|
||||
@JsonKey(
|
||||
name: AccountData.imagePackRoomsKey,
|
||||
fromJson: ImagePackRooms.tryFromJson,
|
||||
)
|
||||
final ImagePackRooms? imagePackRooms,
|
||||
@JsonKey(
|
||||
name: AccountData.legacyImagePackRoomsKey,
|
||||
fromJson: ImagePackRooms.tryFromJson,
|
||||
)
|
||||
final ImagePackRooms? legacyImagePackRooms,
|
||||
}) with _$AccountData {
|
||||
static const invitePermissionConfigKey = "m.invite_permission_config";
|
||||
static const directKey = "m.direct";
|
||||
static const recentEmojiKey = "m.recent_emoji";
|
||||
static const imagePackRoomsKey = "m.image_pack.rooms";
|
||||
static const legacyImagePackRoomsKey = "im.ponies.emote_rooms";
|
||||
|
||||
Map<String, Object?> toJson() => _$AccountDataToJson(this);
|
||||
|
||||
|
|
@ -38,6 +52,28 @@ class const RecentEmojiData({
|
|||
_$RecentEmojiDataFromJson(json);
|
||||
}
|
||||
|
||||
@Freezed(toJson: false, fromJson: false)
|
||||
@JsonSerializable()
|
||||
class const ImagePackRooms({
|
||||
/// Room IDs to the state keys of their enabled packs, each to a (currently
|
||||
/// empty) object
|
||||
final IMap<String, IMap<String, Map<String, dynamic>>> rooms = const .empty(),
|
||||
}) with _$ImagePackRooms {
|
||||
static ImagePackRooms? tryFromJson(Map<String, dynamic>? json) {
|
||||
if (json == null) return null;
|
||||
try {
|
||||
return .fromJson(json);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object?> toJson() => _$ImagePackRoomsToJson(this);
|
||||
|
||||
factory ImagePackRooms.fromJson(Map<String, Object?> json) =>
|
||||
_$ImagePackRoomsFromJson(json);
|
||||
}
|
||||
|
||||
@Freezed(toJson: false, fromJson: false)
|
||||
@JsonSerializable()
|
||||
class const InvitePermissionConfig({
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import "package:nexus/models/content/server_acl.dart";
|
|||
import "package:nexus/models/content/topic.dart";
|
||||
import "package:nexus/models/content/sticker.dart";
|
||||
import "package:nexus/models/content/history_visibility.dart";
|
||||
import "package:nexus/models/content/image_pack.dart";
|
||||
|
||||
class const Content({final String? parseError, final StackTrace? stackTrace}) {
|
||||
factory Content.fromJson(Map<String, dynamic> json) => const Content();
|
||||
|
|
@ -61,5 +62,7 @@ enum EventType(
|
|||
tombstone("m.room.tombstone", Content.fromJson),
|
||||
reaction("m.reaction", ReactionContent.fromJson),
|
||||
pinnedEvents("m.room.pinned_events", PinnedEventsContent.fromJson),
|
||||
imagePack("m.room.image_pack", ImagePackContent.fromJson),
|
||||
legacyImagePack("im.ponies.room_emotes", ImagePackContent.fromJson),
|
||||
message("m.room.message", MessageContent.fromJson),
|
||||
}
|
||||
|
|
|
|||
68
lib/models/content/image_pack.dart
Normal file
68
lib/models/content/image_pack.dart
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:freezed_annotation/freezed_annotation.dart";
|
||||
import "package:nexus/models/content/content.dart";
|
||||
import "package:nexus/models/info/image.dart";
|
||||
part "image_pack.freezed.dart";
|
||||
part "image_pack.g.dart";
|
||||
|
||||
/// An image pack (MSC2545), from a room's state.
|
||||
@freezed
|
||||
sealed class ImagePackContent extends Content with _$ImagePackContent {
|
||||
ImagePackContent._();
|
||||
factory ImagePackContent({
|
||||
@Default(IMap.empty()) IMap<String, PackImage> images,
|
||||
PackInfo? pack,
|
||||
}) = _ImagePackContent;
|
||||
|
||||
IMap<String, PackImage> imagesFor(ImageUsage usage) => images.where(
|
||||
(_, image) => switch ((image.usage, pack?.usage ?? const .empty())) {
|
||||
(final usages, _) when usages.isNotEmpty => usages.contains(usage),
|
||||
(_, final packUsages) when packUsages.isNotEmpty => packUsages.contains(
|
||||
usage,
|
||||
),
|
||||
_ => true,
|
||||
},
|
||||
);
|
||||
|
||||
factory ImagePackContent.fromJson(Map<String, Object?> json) =>
|
||||
_$ImagePackContentFromJson(json);
|
||||
}
|
||||
|
||||
@Freezed(toJson: false, fromJson: false)
|
||||
@JsonSerializable()
|
||||
class const PackImage({
|
||||
required final Uri url,
|
||||
final String? body,
|
||||
final ImageInfo? info,
|
||||
@JsonKey(fromJson: ImageUsage.setFromJson)
|
||||
final ISet<ImageUsage> usage = const .empty(),
|
||||
}) with _$PackImage {
|
||||
Map<String, Object?> toJson() => _$PackImageToJson(this);
|
||||
|
||||
factory PackImage.fromJson(Map<String, Object?> json) =>
|
||||
_$PackImageFromJson(json);
|
||||
}
|
||||
|
||||
@Freezed(toJson: false, fromJson: false)
|
||||
@JsonSerializable()
|
||||
class const PackInfo({
|
||||
final String? displayName,
|
||||
final Uri? avatarUrl,
|
||||
final String? attribution,
|
||||
@JsonKey(fromJson: ImageUsage.setFromJson)
|
||||
final ISet<ImageUsage> usage = const .empty(),
|
||||
}) with _$PackInfo {
|
||||
Map<String, Object?> toJson() => _$PackInfoToJson(this);
|
||||
|
||||
factory PackInfo.fromJson(Map<String, Object?> json) =>
|
||||
_$PackInfoFromJson(json);
|
||||
}
|
||||
|
||||
enum ImageUsage {
|
||||
emoticon,
|
||||
sticker;
|
||||
|
||||
/// Ignores usages we don't know about.
|
||||
static ISet<ImageUsage> setFromJson(List<dynamic>? json) =>
|
||||
ISet(values.where((value) => json?.contains(value.name) ?? false));
|
||||
}
|
||||
|
|
@ -11,11 +11,15 @@ sealed class ReactionContent extends Content with _$ReactionContent {
|
|||
|
||||
factory ReactionContent({
|
||||
@JsonKey(readValue: ReactionContent.keyJsonFromJson) String? key,
|
||||
|
||||
/// For custom emoji reactions
|
||||
@JsonKey(name: "com.beeper.reaction.shortcode") String? shortcode,
|
||||
}) = _ReactionContent;
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => {
|
||||
"m.relates_to": {"key": key},
|
||||
"com.beeper.reaction.shortcode": ?shortcode,
|
||||
};
|
||||
|
||||
factory ReactionContent.fromJson(Map<String, Object?> json) =>
|
||||
|
|
|
|||
33
lib/models/image_pack.dart
Normal file
33
lib/models/image_pack.dart
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:freezed_annotation/freezed_annotation.dart";
|
||||
import "package:nexus/models/content/image_pack.dart";
|
||||
|
||||
part "image_pack.freezed.dart";
|
||||
|
||||
@Freezed(toJson: false, fromJson: false)
|
||||
class const ImagePack({
|
||||
required final String id,
|
||||
required final String name,
|
||||
final Uri? avatar,
|
||||
|
||||
/// Map of shortcode to images
|
||||
required final IMap<String, PackImage> emoticons,
|
||||
required final IMap<String, PackImage> stickers,
|
||||
}) with _$ImagePack {
|
||||
static String emojiMarkdown(String shortcode, String url) {
|
||||
final title = ":$shortcode:".replaceAll(r"\", r"\\").replaceAll('"', r'\"');
|
||||
return '';
|
||||
}
|
||||
|
||||
factory ImagePack.fromContent(
|
||||
ImagePackContent content, {
|
||||
required String id,
|
||||
required String fallbackName,
|
||||
}) => .new(
|
||||
id: id,
|
||||
name: content.pack?.displayName ?? fallbackName,
|
||||
avatar: content.pack?.avatarUrl,
|
||||
emoticons: content.imagesFor(.emoticon),
|
||||
stickers: content.imagesFor(.sticker),
|
||||
);
|
||||
}
|
||||
29
lib/models/requests/get_specific_room_state.dart
Normal file
29
lib/models/requests/get_specific_room_state.dart
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:freezed_annotation/freezed_annotation.dart";
|
||||
|
||||
part "get_specific_room_state.freezed.dart";
|
||||
part "get_specific_room_state.g.dart";
|
||||
|
||||
@Freezed(toJson: false, fromJson: false)
|
||||
@JsonSerializable()
|
||||
class const GetSpecificRoomStateRequest({
|
||||
required final IList<RoomStateKey> keys,
|
||||
}) with _$GetSpecificRoomStateRequest {
|
||||
Map<String, Object?> toJson() => _$GetSpecificRoomStateRequestToJson(this);
|
||||
|
||||
factory GetSpecificRoomStateRequest.fromJson(Map<String, Object?> json) =>
|
||||
_$GetSpecificRoomStateRequestFromJson(json);
|
||||
}
|
||||
|
||||
@Freezed(toJson: false, fromJson: false)
|
||||
@JsonSerializable()
|
||||
class const RoomStateKey({
|
||||
required final String roomId,
|
||||
required final String type,
|
||||
required final String stateKey,
|
||||
}) with _$RoomStateKey {
|
||||
Map<String, Object?> toJson() => _$RoomStateKeyToJson(this);
|
||||
|
||||
factory RoomStateKey.fromJson(Map<String, Object?> json) =>
|
||||
_$RoomStateKeyFromJson(json);
|
||||
}
|
||||
|
|
@ -17,7 +17,11 @@ import "package:nexus/controllers/image_picker.dart";
|
|||
import "package:nexus/helpers/file_dialog_helper.dart";
|
||||
import "package:image_picker/image_picker.dart";
|
||||
import "package:nexus/controllers/power_level.dart";
|
||||
import "package:nexus/controllers/image_packs.dart";
|
||||
import "package:nexus/models/content/image_pack.dart";
|
||||
import "package:nexus/models/content/message.dart";
|
||||
import "package:nexus/models/image_pack.dart";
|
||||
import "package:nexus/widgets/sticker_picker.dart";
|
||||
import "package:nexus/models/relation_type.dart";
|
||||
import "package:nexus/widgets/composer/overlays/tagger_overlay.dart";
|
||||
import "package:nexus/widgets/composer/paste_files_action.dart";
|
||||
|
|
@ -38,6 +42,8 @@ class const Composer(
|
|||
required IList<Tag> tags,
|
||||
})
|
||||
onSend,
|
||||
required final Future<void> Function(String shortcode, PackImage image)
|
||||
onSendSticker,
|
||||
final FocusNode? node,
|
||||
super.key,
|
||||
}) extends HookConsumerWidget {
|
||||
|
|
@ -48,6 +54,17 @@ class const Composer(
|
|||
final triggerCharacter = useState("");
|
||||
final shouldMention = useState(true);
|
||||
final query = useState("");
|
||||
final canSendStickers =
|
||||
ref.watch(
|
||||
PowerLevelController.provider(
|
||||
.new(eventType: .sticker, roomId: roomId),
|
||||
),
|
||||
) &&
|
||||
(ref
|
||||
.watch(ImagePacksController.provider(roomId))
|
||||
.value
|
||||
?.any((pack) => pack.stickers.isNotEmpty) ??
|
||||
false);
|
||||
final relatedEvent = this.relatedEvent == null
|
||||
? null
|
||||
: ref.watch(
|
||||
|
|
@ -253,9 +270,27 @@ class const Composer(
|
|||
IconButton(
|
||||
onPressed: () => context.showAdaptiveSheet(
|
||||
(context) => EmojiPicker(
|
||||
onSelection: (value) {
|
||||
roomId: roomId,
|
||||
onSelection: (value, {shortcode}) {
|
||||
Navigator.of(context).pop();
|
||||
if (shortcode == null) {
|
||||
controller.text += value;
|
||||
} else {
|
||||
final text = "${controller.text}:";
|
||||
controller.value = .new(
|
||||
text: text,
|
||||
selection: .collapsed(
|
||||
offset: text.length,
|
||||
),
|
||||
);
|
||||
controller.addTag(
|
||||
id: ImagePack.emojiMarkdown(
|
||||
shortcode,
|
||||
value,
|
||||
),
|
||||
name: shortcode,
|
||||
);
|
||||
}
|
||||
node?.requestFocus();
|
||||
},
|
||||
),
|
||||
|
|
@ -308,6 +343,22 @@ class const Composer(
|
|||
leading: Icon(Icons.attachment),
|
||||
),
|
||||
),
|
||||
if (canSendStickers)
|
||||
PopupMenuItem(
|
||||
onTap: () => context.showAdaptiveSheet(
|
||||
(context) => StickerPicker(
|
||||
roomId: roomId,
|
||||
onSelection: (shortcode, image) {
|
||||
Navigator.of(context).pop();
|
||||
onSendSticker(shortcode, image);
|
||||
},
|
||||
),
|
||||
),
|
||||
child: ListTile(
|
||||
title: Text("Sticker"),
|
||||
leading: Icon(Icons.sticky_note_2),
|
||||
),
|
||||
),
|
||||
],
|
||||
icon: Icon(Icons.add),
|
||||
),
|
||||
|
|
@ -315,6 +366,9 @@ class const Composer(
|
|||
Expanded(
|
||||
child: FlutterTagger(
|
||||
triggerStrategy: .eager,
|
||||
// Characters that continue a search, for
|
||||
// e.g. :+1, :custom_emoji or @user.name
|
||||
searchRegex: .new(r"^[\w.+-]*$"),
|
||||
overlay: TaggerOverlay(
|
||||
query.value,
|
||||
roomId: roomId,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import "package:collection/collection.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:material_emoji_picker/material_emoji_picker.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/controllers/all_emoji.dart";
|
||||
import "package:nexus/helpers/extensions/better_when.dart";
|
||||
import "package:nexus/models/image_pack.dart";
|
||||
|
||||
class const EmojiOverlay(
|
||||
final String query, {
|
||||
|
|
@ -13,7 +14,7 @@ class const EmojiOverlay(
|
|||
}) extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) => ref
|
||||
.watch(EmojiController.provider)
|
||||
.watch(AllEmojiController.provider(roomId))
|
||||
.betterWhen(
|
||||
data: (emojis) => ListView(
|
||||
children: emojis
|
||||
|
|
@ -34,8 +35,11 @@ class const EmojiOverlay(
|
|||
title: Text(emoji.aliases.first),
|
||||
subtitle: Text(emoji.description),
|
||||
onTap: () => addTag(
|
||||
id: Uri.tryParse(emoji.value)?.scheme == "mxc"
|
||||
? "" // TODO: Handle custom emotes
|
||||
id: emoji.value.startsWith("mxc://")
|
||||
? ImagePack.emojiMarkdown(
|
||||
emoji.aliases.first,
|
||||
emoji.value,
|
||||
)
|
||||
: emoji.value,
|
||||
name: emoji.aliases.first,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,15 +1,23 @@
|
|||
import "dart:async";
|
||||
|
||||
import "package:collection/collection.dart";
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:material_emoji_picker/material_emoji_picker.dart" as upstream;
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/controllers/all_emoji.dart";
|
||||
import "package:nexus/controllers/image_packs.dart";
|
||||
import "package:nexus/controllers/recent_emoji.dart";
|
||||
import "package:nexus/helpers/extensions/better_when.dart";
|
||||
import "package:nexus/helpers/extensions/image_pack_emoji.dart";
|
||||
import "package:nexus/main.dart";
|
||||
import "package:nexus/models/content/image_pack.dart";
|
||||
|
||||
class const EmojiPicker({
|
||||
required final FutureOr<void> Function(String value) onSelection,
|
||||
required final String roomId,
|
||||
|
||||
required final FutureOr<void> Function(String value, {String? shortcode})
|
||||
onSelection,
|
||||
final bool allowFreeText = false,
|
||||
super.key,
|
||||
}) extends ConsumerWidget {
|
||||
|
|
@ -18,39 +26,51 @@ class const EmojiPicker({
|
|||
final recentEmoji = ref.watch(RecentEmojiController.provider);
|
||||
|
||||
return ref
|
||||
.watch(upstream.EmojiController.provider)
|
||||
.watch(AllEmojiController.provider(roomId))
|
||||
.betterWhen(
|
||||
data: (categories) => upstream.EmojiPicker(
|
||||
onSelection: (emoji, isFreeText) async {
|
||||
await onSelection(emoji);
|
||||
data: (categories) {
|
||||
final allEmoji = categories.expand((category) => category.emojis);
|
||||
final customCategories =
|
||||
ref
|
||||
.watch(ImagePacksController.provider(roomId))
|
||||
.value
|
||||
?.map((pack) => pack.emojiCategory)
|
||||
.nonNulls
|
||||
.toIList() ??
|
||||
const .empty();
|
||||
|
||||
return upstream.EmojiPicker(
|
||||
onSelection: (emoji) async {
|
||||
await onSelection(
|
||||
emoji.value,
|
||||
shortcode: emoji.data is PackImage
|
||||
? emoji.aliases.first
|
||||
: null,
|
||||
);
|
||||
|
||||
if (!isFreeText) {
|
||||
await ref
|
||||
.read(RecentEmojiController.provider.notifier)
|
||||
.add(emoji)
|
||||
.add(emoji.value)
|
||||
.onError(showError);
|
||||
}
|
||||
},
|
||||
allowFreeText: allowFreeText,
|
||||
prependCategories: .new([
|
||||
onFreeText: allowFreeText ? (text) => onSelection(text) : null,
|
||||
prependCategories: IList<upstream.EmojiCategory>([
|
||||
.new(
|
||||
icon: Icon(Icons.history),
|
||||
name: "Recent",
|
||||
emojis: .new(
|
||||
recentEmoji
|
||||
.map(
|
||||
(recent) => categories
|
||||
.map((element) => element.emojis)
|
||||
.flattened
|
||||
.firstWhereOrNull(
|
||||
(recent) => allEmoji.firstWhereOrNull(
|
||||
(element) => element.value == recent.emoji,
|
||||
),
|
||||
)
|
||||
.nonNulls,
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]).addAll(customCategories),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
16
lib/widgets/mxc_emoji.dart
Normal file
16
lib/widgets/mxc_emoji.dart
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import "package:material_ui/material_ui.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/helpers/mxc_image.dart";
|
||||
|
||||
/// A custom emoji or sticker from an image pack, scaled to fit its parent.
|
||||
class const MxcEmoji(final Uri url, {final String? label, super.key})
|
||||
extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) => Image(
|
||||
image: MxcImage(ref, .new(mxc: url)),
|
||||
semanticLabel: label,
|
||||
fit: .contain,
|
||||
errorBuilder: (context, _, _) =>
|
||||
Icon(Icons.broken_image, color: Theme.of(context).colorScheme.error),
|
||||
);
|
||||
}
|
||||
|
|
@ -323,6 +323,10 @@ final class const RoomChat({
|
|||
relatedEvent.value,
|
||||
)
|
||||
.onError(showError),
|
||||
onSendSticker: (shortcode, image) =>
|
||||
notifier
|
||||
.sendSticker(shortcode, image)
|
||||
.onError(showError),
|
||||
relationType: relationType.value,
|
||||
relatedEvent: relatedEvent.value,
|
||||
onDismiss: () {
|
||||
|
|
|
|||
47
lib/widgets/sticker_picker.dart
Normal file
47
lib/widgets/sticker_picker.dart
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import "dart:async";
|
||||
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:material_emoji_picker/material_emoji_picker.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/controllers/image_packs.dart";
|
||||
import "package:nexus/helpers/extensions/better_when.dart";
|
||||
import "package:nexus/helpers/extensions/image_pack_emoji.dart";
|
||||
import "package:nexus/models/content/image_pack.dart";
|
||||
|
||||
/// Stickers from the image packs available in [roomId], grouped by pack.
|
||||
class const StickerPicker({
|
||||
required final String roomId,
|
||||
required final FutureOr<void> Function(String shortcode, PackImage image)
|
||||
onSelection,
|
||||
super.key,
|
||||
}) extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) => ref
|
||||
.watch(ImagePacksController.provider(roomId))
|
||||
.betterWhen(
|
||||
data: (packs) {
|
||||
final categories = packs
|
||||
.map((pack) => pack.stickerCategory)
|
||||
.nonNulls
|
||||
.toIList();
|
||||
|
||||
return categories.isEmpty
|
||||
? Padding(
|
||||
padding: .all(16),
|
||||
child: Center(child: Text("No sticker packs available")),
|
||||
)
|
||||
: EmojiPicker(
|
||||
prependCategories: categories,
|
||||
includeDefaultEmoji: false,
|
||||
itemSize: 96,
|
||||
searchHint: "Search stickers",
|
||||
onSelection: (sticker) {
|
||||
if (sticker.data case final PackImage image) {
|
||||
return onSelection(sticker.aliases.first, image);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -924,10 +924,10 @@ packages:
|
|||
dependency: "direct main"
|
||||
description:
|
||||
name: material_emoji_picker
|
||||
sha256: "738e70a8d2f592be05dd9889037cc6098a63e0d8c48723125848865ab305f138"
|
||||
sha256: "563acfa4dbfd023c2e1ff3b4ebf1083dbe2fc5f848f300682164245ad5fc5464"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
version: "2.0.1"
|
||||
material_ui:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ dependencies:
|
|||
unifiedpush_storage_shared_preferences: 1.0.0
|
||||
flutter_local_notifications: 22.3.1
|
||||
timezone: 0.11.1
|
||||
material_emoji_picker: 1.2.0
|
||||
material_emoji_picker: 2.0.1
|
||||
xdg_desktop_portal:
|
||||
git:
|
||||
url: https://github.com/Henry-Hiles/xdg_desktop_portal.dart
|
||||
|
|
|
|||
Loading…
Reference in a new issue