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_mentions.dart";
|
||||||
import "package:nexus/models/requests/get_related_events.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_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/get_space_hierarchy.dart";
|
||||||
import "package:nexus/models/requests/join_room.dart";
|
import "package:nexus/models/requests/join_room.dart";
|
||||||
import "package:nexus/models/profile_response.dart";
|
import "package:nexus/models/profile_response.dart";
|
||||||
|
|
@ -185,11 +186,12 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
...json,
|
...json,
|
||||||
"content": {
|
"content": {
|
||||||
...content,
|
...content,
|
||||||
"m.relates_to": {
|
if (request.relatesTo != null)
|
||||||
...((content["m.relates_to"] as Map<String, dynamic>?) ?? {}),
|
"m.relates_to": {
|
||||||
"event_id": request.relatesTo,
|
...((content["m.relates_to"] as Map<String, dynamic>?) ?? {}),
|
||||||
"rel_type": request.relationType,
|
"event_id": request.relatesTo,
|
||||||
},
|
"rel_type": request.relationType,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
@ -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(
|
Future<IList<Event>?> getRelatedEvents(
|
||||||
GetRelatedEventsRequest request,
|
GetRelatedEventsRequest request,
|
||||||
) async {
|
) 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
|
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/client.dart";
|
||||||
import "package:nexus/controllers/rooms.dart";
|
import "package:nexus/controllers/rooms.dart";
|
||||||
import "package:nexus/models/content/content.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/reaction.dart";
|
||||||
|
import "package:nexus/models/content/sticker.dart";
|
||||||
import "package:nexus/models/direction.dart";
|
import "package:nexus/models/direction.dart";
|
||||||
import "package:nexus/models/event.dart";
|
import "package:nexus/models/event.dart";
|
||||||
import "package:nexus/models/requests/redact_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);
|
final client = ref.read(ClientController.provider.notifier);
|
||||||
|
|
||||||
await client.sendEvent(
|
await client.sendEvent(
|
||||||
.new(
|
.new(
|
||||||
roomId: info.roomId,
|
roomId: info.roomId,
|
||||||
type: EventType.reaction.type,
|
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,
|
synchronous: true,
|
||||||
disableEncryption: true,
|
disableEncryption: true,
|
||||||
relatesTo: event.eventId,
|
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
|
static final provider = AsyncNotifierProvider.family
|
||||||
.autoDispose<RoomChatController, RoomChat?, ChatParams>(
|
.autoDispose<RoomChatController, RoomChat?, ChatParams>(
|
||||||
RoomChatController.new,
|
RoomChatController.new,
|
||||||
|
|
|
||||||
|
|
@ -11,29 +11,35 @@ class RoomsController extends Notifier<IMap<String, Room>> {
|
||||||
@override
|
@override
|
||||||
IMap<String, Room> build() => .new();
|
IMap<String, Room> build() => .new();
|
||||||
|
|
||||||
void addState(
|
void addState(String roomId, IList<Event> state, {bool isMembers = false}) =>
|
||||||
String roomId,
|
update(
|
||||||
IList<Event> state, {
|
.new({
|
||||||
bool isMembers = false,
|
roomId: stateRoom(state)
|
||||||
}) => update(
|
.copyWith(hasFetchedState: true, hasFetchedMembers: isMembers),
|
||||||
.new({
|
}),
|
||||||
roomId: Room(
|
.new(),
|
||||||
events: .fromEntries(state.map((event) => .new(event.rowId, event))),
|
);
|
||||||
hasFetchedState: true,
|
|
||||||
hasFetchedMembers: isMembers,
|
void addSpecificState(Iterable<Event> state) => update(
|
||||||
state: .new(
|
.new(
|
||||||
state
|
state
|
||||||
.groupListsBy((event) => event.type)
|
.groupListsBy((event) => event.roomId)
|
||||||
.map(
|
.map((roomId, roomState) => .new(roomId, stateRoom(roomState))),
|
||||||
(type, group) => .new(
|
),
|
||||||
type,
|
);
|
||||||
.fromEntries(group.map((e) => .new(e.stateKey!, e.rowId))),
|
|
||||||
),
|
static Room stateRoom(Iterable<Event> state) => Room(
|
||||||
),
|
events: .fromEntries(state.map((event) => .new(event.rowId, event))),
|
||||||
),
|
state: .new(
|
||||||
),
|
state
|
||||||
}),
|
.groupListsBy((event) => event.type)
|
||||||
.new(),
|
.map(
|
||||||
|
(type, group) => .new(
|
||||||
|
type,
|
||||||
|
.fromEntries(group.map((e) => .new(e.stateKey!, e.rowId))),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
static IMap<String, IList<ReadReceipt>> mergeReceipts(
|
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/event.dart";
|
||||||
import "package:nexus/models/relation_type.dart";
|
import "package:nexus/models/relation_type.dart";
|
||||||
import "package:nexus/widgets/emoji_picker.dart";
|
import "package:nexus/widgets/emoji_picker.dart";
|
||||||
|
import "package:nexus/widgets/mxc_emoji.dart";
|
||||||
import "package:nexus/main.dart";
|
import "package:nexus/main.dart";
|
||||||
|
|
||||||
extension BuildEventOptions on Event {
|
extension BuildEventOptions on Event {
|
||||||
|
|
@ -97,6 +98,8 @@ extension BuildEventOptions on Event {
|
||||||
mainAxisAlignment: isDesktop ? .center : .spaceEvenly,
|
mainAxisAlignment: isDesktop ? .center : .spaceEvenly,
|
||||||
children: [
|
children: [
|
||||||
...{
|
...{
|
||||||
|
// Custom emoji are sent without their shortcode, as only
|
||||||
|
// their mxc URL is recorded
|
||||||
...ref
|
...ref
|
||||||
.read(RecentEmojiController.provider)
|
.read(RecentEmojiController.provider)
|
||||||
.map((entry) => entry.emoji),
|
.map((entry) => entry.emoji),
|
||||||
|
|
@ -109,10 +112,15 @@ extension BuildEventOptions on Event {
|
||||||
.take(isDesktop ? 3 : 5)
|
.take(isDesktop ? 3 : 5)
|
||||||
.map(
|
.map(
|
||||||
(emoji) => IconButton(
|
(emoji) => IconButton(
|
||||||
icon: Text(
|
icon: emoji.startsWith("mxc://")
|
||||||
emoji,
|
? SizedBox.square(
|
||||||
style: .new(fontSize: isDesktop ? null : 18),
|
dimension: isDesktop ? 18 : 24,
|
||||||
),
|
child: MxcEmoji(Uri.parse(emoji)),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
emoji,
|
||||||
|
style: .new(fontSize: isDesktop ? null : 18),
|
||||||
|
),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
await ref
|
await ref
|
||||||
|
|
@ -134,12 +142,13 @@ extension BuildEventOptions on Event {
|
||||||
|
|
||||||
context.showAdaptiveSheet(
|
context.showAdaptiveSheet(
|
||||||
(context) => EmojiPicker(
|
(context) => EmojiPicker(
|
||||||
|
roomId: roomId,
|
||||||
allowFreeText: true,
|
allowFreeText: true,
|
||||||
onSelection: (emoji) {
|
onSelection: (emoji, {shortcode}) {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
ref
|
ref
|
||||||
.read(notifier)
|
.read(notifier)
|
||||||
.sendReaction(emoji, this)
|
.sendReaction(emoji, this, shortcode: shortcode)
|
||||||
.onError(showError);
|
.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)
|
@JsonKey(name: AccountData.recentEmojiKey)
|
||||||
final RecentEmojiData recentEmoji = const RecentEmojiData(),
|
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 {
|
}) with _$AccountData {
|
||||||
static const invitePermissionConfigKey = "m.invite_permission_config";
|
static const invitePermissionConfigKey = "m.invite_permission_config";
|
||||||
static const directKey = "m.direct";
|
static const directKey = "m.direct";
|
||||||
static const recentEmojiKey = "m.recent_emoji";
|
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);
|
Map<String, Object?> toJson() => _$AccountDataToJson(this);
|
||||||
|
|
||||||
|
|
@ -38,6 +52,28 @@ class const RecentEmojiData({
|
||||||
_$RecentEmojiDataFromJson(json);
|
_$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)
|
@Freezed(toJson: false, fromJson: false)
|
||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
class const InvitePermissionConfig({
|
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/topic.dart";
|
||||||
import "package:nexus/models/content/sticker.dart";
|
import "package:nexus/models/content/sticker.dart";
|
||||||
import "package:nexus/models/content/history_visibility.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}) {
|
class const Content({final String? parseError, final StackTrace? stackTrace}) {
|
||||||
factory Content.fromJson(Map<String, dynamic> json) => const Content();
|
factory Content.fromJson(Map<String, dynamic> json) => const Content();
|
||||||
|
|
@ -61,5 +62,7 @@ enum EventType(
|
||||||
tombstone("m.room.tombstone", Content.fromJson),
|
tombstone("m.room.tombstone", Content.fromJson),
|
||||||
reaction("m.reaction", ReactionContent.fromJson),
|
reaction("m.reaction", ReactionContent.fromJson),
|
||||||
pinnedEvents("m.room.pinned_events", PinnedEventsContent.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),
|
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({
|
factory ReactionContent({
|
||||||
@JsonKey(readValue: ReactionContent.keyJsonFromJson) String? key,
|
@JsonKey(readValue: ReactionContent.keyJsonFromJson) String? key,
|
||||||
|
|
||||||
|
/// For custom emoji reactions
|
||||||
|
@JsonKey(name: "com.beeper.reaction.shortcode") String? shortcode,
|
||||||
}) = _ReactionContent;
|
}) = _ReactionContent;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
"m.relates_to": {"key": key},
|
"m.relates_to": {"key": key},
|
||||||
|
"com.beeper.reaction.shortcode": ?shortcode,
|
||||||
};
|
};
|
||||||
|
|
||||||
factory ReactionContent.fromJson(Map<String, Object?> json) =>
|
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:nexus/helpers/file_dialog_helper.dart";
|
||||||
import "package:image_picker/image_picker.dart";
|
import "package:image_picker/image_picker.dart";
|
||||||
import "package:nexus/controllers/power_level.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/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/models/relation_type.dart";
|
||||||
import "package:nexus/widgets/composer/overlays/tagger_overlay.dart";
|
import "package:nexus/widgets/composer/overlays/tagger_overlay.dart";
|
||||||
import "package:nexus/widgets/composer/paste_files_action.dart";
|
import "package:nexus/widgets/composer/paste_files_action.dart";
|
||||||
|
|
@ -38,6 +42,8 @@ class const Composer(
|
||||||
required IList<Tag> tags,
|
required IList<Tag> tags,
|
||||||
})
|
})
|
||||||
onSend,
|
onSend,
|
||||||
|
required final Future<void> Function(String shortcode, PackImage image)
|
||||||
|
onSendSticker,
|
||||||
final FocusNode? node,
|
final FocusNode? node,
|
||||||
super.key,
|
super.key,
|
||||||
}) extends HookConsumerWidget {
|
}) extends HookConsumerWidget {
|
||||||
|
|
@ -48,6 +54,17 @@ class const Composer(
|
||||||
final triggerCharacter = useState("");
|
final triggerCharacter = useState("");
|
||||||
final shouldMention = useState(true);
|
final shouldMention = useState(true);
|
||||||
final query = useState("");
|
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
|
final relatedEvent = this.relatedEvent == null
|
||||||
? null
|
? null
|
||||||
: ref.watch(
|
: ref.watch(
|
||||||
|
|
@ -253,9 +270,27 @@ class const Composer(
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () => context.showAdaptiveSheet(
|
onPressed: () => context.showAdaptiveSheet(
|
||||||
(context) => EmojiPicker(
|
(context) => EmojiPicker(
|
||||||
onSelection: (value) {
|
roomId: roomId,
|
||||||
|
onSelection: (value, {shortcode}) {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
controller.text += value;
|
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();
|
node?.requestFocus();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -308,6 +343,22 @@ class const Composer(
|
||||||
leading: Icon(Icons.attachment),
|
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),
|
icon: Icon(Icons.add),
|
||||||
),
|
),
|
||||||
|
|
@ -315,6 +366,9 @@ class const Composer(
|
||||||
Expanded(
|
Expanded(
|
||||||
child: FlutterTagger(
|
child: FlutterTagger(
|
||||||
triggerStrategy: .eager,
|
triggerStrategy: .eager,
|
||||||
|
// Characters that continue a search, for
|
||||||
|
// e.g. :+1, :custom_emoji or @user.name
|
||||||
|
searchRegex: .new(r"^[\w.+-]*$"),
|
||||||
overlay: TaggerOverlay(
|
overlay: TaggerOverlay(
|
||||||
query.value,
|
query.value,
|
||||||
roomId: roomId,
|
roomId: roomId,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import "package:collection/collection.dart";
|
import "package:collection/collection.dart";
|
||||||
import "package:hooks_riverpod/hooks_riverpod.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:material_ui/material_ui.dart";
|
||||||
|
import "package:nexus/controllers/all_emoji.dart";
|
||||||
import "package:nexus/helpers/extensions/better_when.dart";
|
import "package:nexus/helpers/extensions/better_when.dart";
|
||||||
|
import "package:nexus/models/image_pack.dart";
|
||||||
|
|
||||||
class const EmojiOverlay(
|
class const EmojiOverlay(
|
||||||
final String query, {
|
final String query, {
|
||||||
|
|
@ -13,7 +14,7 @@ class const EmojiOverlay(
|
||||||
}) extends ConsumerWidget {
|
}) extends ConsumerWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) => ref
|
Widget build(BuildContext context, WidgetRef ref) => ref
|
||||||
.watch(EmojiController.provider)
|
.watch(AllEmojiController.provider(roomId))
|
||||||
.betterWhen(
|
.betterWhen(
|
||||||
data: (emojis) => ListView(
|
data: (emojis) => ListView(
|
||||||
children: emojis
|
children: emojis
|
||||||
|
|
@ -34,8 +35,11 @@ class const EmojiOverlay(
|
||||||
title: Text(emoji.aliases.first),
|
title: Text(emoji.aliases.first),
|
||||||
subtitle: Text(emoji.description),
|
subtitle: Text(emoji.description),
|
||||||
onTap: () => addTag(
|
onTap: () => addTag(
|
||||||
id: Uri.tryParse(emoji.value)?.scheme == "mxc"
|
id: emoji.value.startsWith("mxc://")
|
||||||
? "" // TODO: Handle custom emotes
|
? ImagePack.emojiMarkdown(
|
||||||
|
emoji.aliases.first,
|
||||||
|
emoji.value,
|
||||||
|
)
|
||||||
: emoji.value,
|
: emoji.value,
|
||||||
name: emoji.aliases.first,
|
name: emoji.aliases.first,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,23 @@
|
||||||
import "dart:async";
|
import "dart:async";
|
||||||
|
|
||||||
import "package:collection/collection.dart";
|
import "package:collection/collection.dart";
|
||||||
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
import "package:material_emoji_picker/material_emoji_picker.dart" as upstream;
|
import "package:material_emoji_picker/material_emoji_picker.dart" as upstream;
|
||||||
import "package:material_ui/material_ui.dart";
|
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/controllers/recent_emoji.dart";
|
||||||
import "package:nexus/helpers/extensions/better_when.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/main.dart";
|
||||||
|
import "package:nexus/models/content/image_pack.dart";
|
||||||
|
|
||||||
class const EmojiPicker({
|
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,
|
final bool allowFreeText = false,
|
||||||
super.key,
|
super.key,
|
||||||
}) extends ConsumerWidget {
|
}) extends ConsumerWidget {
|
||||||
|
|
@ -18,39 +26,51 @@ class const EmojiPicker({
|
||||||
final recentEmoji = ref.watch(RecentEmojiController.provider);
|
final recentEmoji = ref.watch(RecentEmojiController.provider);
|
||||||
|
|
||||||
return ref
|
return ref
|
||||||
.watch(upstream.EmojiController.provider)
|
.watch(AllEmojiController.provider(roomId))
|
||||||
.betterWhen(
|
.betterWhen(
|
||||||
data: (categories) => upstream.EmojiPicker(
|
data: (categories) {
|
||||||
onSelection: (emoji, isFreeText) async {
|
final allEmoji = categories.expand((category) => category.emojis);
|
||||||
await onSelection(emoji);
|
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
|
await ref
|
||||||
.read(RecentEmojiController.provider.notifier)
|
.read(RecentEmojiController.provider.notifier)
|
||||||
.add(emoji)
|
.add(emoji.value)
|
||||||
.onError(showError);
|
.onError(showError);
|
||||||
}
|
},
|
||||||
},
|
onFreeText: allowFreeText ? (text) => onSelection(text) : null,
|
||||||
allowFreeText: allowFreeText,
|
prependCategories: IList<upstream.EmojiCategory>([
|
||||||
prependCategories: .new([
|
.new(
|
||||||
.new(
|
icon: Icon(Icons.history),
|
||||||
icon: Icon(Icons.history),
|
name: "Recent",
|
||||||
name: "Recent",
|
emojis: .new(
|
||||||
emojis: .new(
|
recentEmoji
|
||||||
recentEmoji
|
.map(
|
||||||
.map(
|
(recent) => allEmoji.firstWhereOrNull(
|
||||||
(recent) => categories
|
(element) => element.value == recent.emoji,
|
||||||
.map((element) => element.emojis)
|
),
|
||||||
.flattened
|
)
|
||||||
.firstWhereOrNull(
|
.nonNulls,
|
||||||
(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,
|
relatedEvent.value,
|
||||||
)
|
)
|
||||||
.onError(showError),
|
.onError(showError),
|
||||||
|
onSendSticker: (shortcode, image) =>
|
||||||
|
notifier
|
||||||
|
.sendSticker(shortcode, image)
|
||||||
|
.onError(showError),
|
||||||
relationType: relationType.value,
|
relationType: relationType.value,
|
||||||
relatedEvent: relatedEvent.value,
|
relatedEvent: relatedEvent.value,
|
||||||
onDismiss: () {
|
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"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: material_emoji_picker
|
name: material_emoji_picker
|
||||||
sha256: "738e70a8d2f592be05dd9889037cc6098a63e0d8c48723125848865ab305f138"
|
sha256: "563acfa4dbfd023c2e1ff3b4ebf1083dbe2fc5f848f300682164245ad5fc5464"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.0"
|
version: "2.0.1"
|
||||||
material_ui:
|
material_ui:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,7 @@ dependencies:
|
||||||
unifiedpush_storage_shared_preferences: 1.0.0
|
unifiedpush_storage_shared_preferences: 1.0.0
|
||||||
flutter_local_notifications: 22.3.1
|
flutter_local_notifications: 22.3.1
|
||||||
timezone: 0.11.1
|
timezone: 0.11.1
|
||||||
material_emoji_picker: 1.2.0
|
material_emoji_picker: 2.0.1
|
||||||
xdg_desktop_portal:
|
xdg_desktop_portal:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/Henry-Hiles/xdg_desktop_portal.dart
|
url: https://github.com/Henry-Hiles/xdg_desktop_portal.dart
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue