diff --git a/gomuks b/gomuks index 53cae0e..081d5df 160000 --- a/gomuks +++ b/gomuks @@ -1 +1 @@ -Subproject commit 53cae0e0334aef5ac376ea84f19ba1cff8a92310 +Subproject commit 081d5df14be6bf7131e955461207e26da7ad23c9 diff --git a/lib/controllers/account_data.dart b/lib/controllers/account_data.dart index c595565..d98e4ca 100644 --- a/lib/controllers/account_data.dart +++ b/lib/controllers/account_data.dart @@ -1,7 +1,10 @@ +import "dart:async"; import "dart:convert"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter/foundation.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; import "package:nexus/models/account_data.dart"; class AccountDataController extends Notifier { @@ -9,13 +12,56 @@ class AccountDataController extends Notifier { AccountData build() => .new(); void update(IMap> newAccountData) => - state = .fromJson({ - ...json.decode(json.encode(state.toJson())), - ...newAccountData - .map((key, value) => MapEntry(key, value["content"])) - .unlock, + state = newAccountData.entries.fold(state, (data, entry) { + try { + return .fromJson({ + ...json.decode(json.encode(data.toJson())), + entry.key: entry.value["content"], + }); + } catch (error) { + debugPrint("Ignoring invalid ${entry.key} account data: $error"); + return data; + } }); + Future setEnabledImagePacks(ImagePackRooms packs) async { + ISet<(String, String)> keysOf(ImagePackRooms rooms) => rooms.rooms.entries + .expand((entry) => entry.value.keys.map((key) => (entry.key, key))) + .toISet(); + final keys = keysOf(packs); + bool arrived(AccountData data) { + final current = keysOf(data.enabledImagePacks); + return current.length == keys.length && current.containsAll(keys); + } + + final completer = Completer(); + final removeListener = listenSelf((_, data) { + if (arrived(data) && !completer.isCompleted) completer.complete(); + }); + + try { + final client = ref.read(ClientController.provider.notifier); + await Future.wait([ + if (state.imagePackRooms != null) + client.setAccountData( + .new(type: AccountData.imagePackRoomsKey, content: packs), + ), + client.setAccountData( + .new(type: AccountData.legacyImagePackRoomsKey, content: packs), + ), + ]); + + if (!arrived(state)) { + await completer.future.timeout( + const .new(seconds: 10), + onTimeout: () {}, + ); + } + } finally { + removeListener(); + } + } + static final provider = NotifierProvider( AccountDataController.new, ); diff --git a/lib/controllers/all_emoji.dart b/lib/controllers/all_emoji.dart new file mode 100644 index 0000000..5703356 --- /dev/null +++ b/lib/controllers/all_emoji.dart @@ -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> { + @override + Future> 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, String>( + AllEmojiController.new, + ); +} diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index 569138f..f158069 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -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,11 +186,12 @@ class ClientController extends AsyncNotifier { ...json, "content": { ...content, - "m.relates_to": { - ...((content["m.relates_to"] as Map?) ?? {}), - "event_id": request.relatesTo, - "rel_type": request.relationType, - }, + if (request.relatesTo != null) + "m.relates_to": { + ...((content["m.relates_to"] as Map?) ?? {}), + "event_id": request.relatesTo, + "rel_type": request.relationType, + }, }, }), ); @@ -198,14 +200,11 @@ class ClientController extends AsyncNotifier { Future setState(SetStateRequest request) async => await _sendCommand("set_state", request.toJson()); - Future verify(String recoveryKey) async { - try { - await _sendCommand("verify", {"recovery_key": recoveryKey}); - return null; - } catch (error) { - return error.toString(); - } - } + Future pauseSync() => _sendCommand("pause_sync"); + Future resumeSync() => _sendCommand("resume_sync"); + + Future verify(String recoveryKey) => + _sendCommand("verify", {"recovery_key": recoveryKey}); Future generateRecoveryKey(String passphrase) async => .fromJson( await _sendCommand("generate_recovery_key", {"passphrase": passphrase}), @@ -265,6 +264,15 @@ class ClientController extends AsyncNotifier { ); } + Future> getSpecificRoomState( + GetSpecificRoomStateRequest request, + ) async => .new( + ((await _sendCommand("get_specific_room_state", request.toJson())) + as List? ?? + []) + .map((event) => .fromJson(event)), + ); + Future?> getRelatedEvents( GetRelatedEventsRequest request, ) async { diff --git a/lib/controllers/enabled_image_packs.dart b/lib/controllers/enabled_image_packs.dart new file mode 100644 index 0000000..c16799c --- /dev/null +++ b/lib/controllers/enabled_image_packs.dart @@ -0,0 +1,69 @@ +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/models/content/content.dart"; +import "package:nexus/models/requests/get_specific_room_state.dart"; + +/// The image packs the user has enabled everywhere, as room IDs to their +/// packs' state keys, once the packs' state has been loaded. +class EnabledImagePacksController + extends AsyncNotifier>> { + @override + Future>> build() async { + final enabledPacks = ref.watch( + AccountDataController.provider.select( + (data) => data.enabledImagePacks.rooms.map( + (roomId, packs) => .new(roomId, packs.keys.toISet()), + ), + ), + ); + + const packTypes = [.imagePack, .legacyImagePack]; + // Rooms we aren't in can't be fetched, and fetched rooms have every pack. + final missingKeys = ref.watch( + RoomsController.provider.select( + (rooms) => 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) { + ref + .read(RoomsController.provider.notifier) + .addSpecificState( + await ref + .read(ClientController.provider.notifier) + .getSpecificRoomState(.new(keys: missingKeys)), + ); + } + + return enabledPacks; + } + + static final provider = + AsyncNotifierProvider.autoDispose< + EnabledImagePacksController, + IMap> + >(EnabledImagePacksController.new); +} diff --git a/lib/controllers/event.dart b/lib/controllers/event.dart index a41d6b6..44a44f0 100644 --- a/lib/controllers/event.dart +++ b/lib/controllers/event.dart @@ -9,11 +9,12 @@ class EventController(final GetEventRequest request) extends AsyncNotifier { @override Future build() async { - final room = ref.watch( - RoomsController.provider.select((value) => value[request.roomId]), - ); - final event = room?.events.values.firstWhereOrNull( - (event) => event.eventId == request.eventId, + final event = ref.watch( + RoomsController.provider.select( + (rooms) => rooms[request.roomId]?.events.values.firstWhereOrNull( + (event) => event.eventId == request.eventId, + ), + ), ); if (event != null) return event; diff --git a/lib/controllers/gomuks_listener.dart b/lib/controllers/gomuks_listener.dart index a97599b..320ac2d 100644 --- a/lib/controllers/gomuks_listener.dart +++ b/lib/controllers/gomuks_listener.dart @@ -156,11 +156,12 @@ class GomuksListenerController extends AsyncNotifier { // Mobile OSes block network access for backgrounded apps, so pause // syncing instead of letting it fail. ref.listen(AppPausedController.provider, (_, paused) { + final client = ref.read(ClientController.provider.notifier); if (paused) { - GomuksPauseSync(handle); + client.pauseSync().onError(showError); } else { ref.invalidate(TypingController.provider); - GomuksResumeSync(handle); + client.resumeSync().onError(showError); } }, fireImmediately: true); } diff --git a/lib/controllers/image_pack_sources.dart b/lib/controllers/image_pack_sources.dart new file mode 100644 index 0000000..61e4a2e --- /dev/null +++ b/lib/controllers/image_pack_sources.dart @@ -0,0 +1,75 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/enabled_image_packs.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/controllers/space_edges.dart"; + +typedef ImagePackSource = ({String roomId, String? stateKey}); + +class ImagePackSourcesController(final String roomId) + extends AsyncNotifier> { + @override + Future> build() async { + final enabledPacksFuture = ref.watch( + EnabledImagePacksController.provider.future, + ); + 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 parentsOf(String childId) => spaceEdges.entries + .where((entry) => entry.value.any((edge) => edge.childId == childId)) + .map((entry) => entry.key) + .followedBy(childId == roomId ? directParents : const []); + + ISet withAncestors(ISet 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)), + ), + ), + ); + final enabledPacks = await enabledPacksFuture; + + 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, String>( + ImagePackSourcesController.new, + ); +} diff --git a/lib/controllers/image_packs.dart b/lib/controllers/image_packs.dart new file mode 100644 index 0000000..9248211 --- /dev/null +++ b/lib/controllers/image_packs.dart @@ -0,0 +1,43 @@ +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/helpers/extensions/room_image_packs.dart"; +import "package:nexus/models/image_pack.dart"; + +class ImagePacksController(final String roomId) + extends AsyncNotifier> { + @override + Future> build() async { + final sources = await ref.watch( + ImagePackSourcesController.provider(roomId).future, + ); + + final packs = ref.watch( + RoomsController.provider.select( + (rooms) => sources.expand((source) { + final packs = + rooms[source.roomId]?.imagePacks(source.roomId) ?? const .empty(); + + return switch (source.stateKey) { + final stateKey? => [?packs[stateKey]], + null => packs.values, + }; + }).toIList(), + ), + ); + + return .new( + packs + .whereNoDuplicates(by: (pack) => pack.id) + .where( + (pack) => pack.emoticons.isNotEmpty || pack.stickers.isNotEmpty, + ), + ); + } + + static final provider = AsyncNotifierProvider.autoDispose + .family, String>( + ImagePacksController.new, + ); +} diff --git a/lib/controllers/notification.dart b/lib/controllers/notification.dart index 47ecdf0..b251e26 100644 --- a/lib/controllers/notification.dart +++ b/lib/controllers/notification.dart @@ -169,7 +169,7 @@ class NotificationController if (Platform.isLinux) { final portal = await ref.read(PortalController.provider.future); - const escape = HtmlEscape(HtmlEscapeMode.element); + const escape = HtmlEscape(.element); await portal.notification.addNotification( id.toString(), diff --git a/lib/controllers/recent_emoji.dart b/lib/controllers/recent_emoji.dart index 8dd3e59..a091afb 100644 --- a/lib/controllers/recent_emoji.dart +++ b/lib/controllers/recent_emoji.dart @@ -23,7 +23,13 @@ class RecentEmojiController extends Notifier> { ), ); - 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 add(String emoji) => ref diff --git a/lib/controllers/room_chat.dart b/lib/controllers/room_chat.dart index b8b688d..94fb7a3 100644 --- a/lib/controllers/room_chat.dart +++ b/lib/controllers/room_chat.dart @@ -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"; @@ -241,17 +243,14 @@ class RoomChatController(final ChatParams info) ref.invalidate(provider); } - var taggedMessage = text; - - for (final tag in tags) { - final escaped = RegExp.escape(tag.id); - final pattern = RegExp(r"@+(" + escaped + r")(#[^#]*#)?"); - - taggedMessage = taggedMessage.replaceAllMapped( - pattern, + // Replaces each formatted tag, like "@id#name#", with its ID + final taggedMessage = tags.fold( + text, + (message, tag) => message.replaceAllMapped( + RegExp(r"@+(" + RegExp.escape(tag.id) + r")(#[^#]*#)?"), (match) => match.group(1)!, - ); - } + ), + ); final client = ref.read(ClientController.provider.notifier); final event = await client.sendMessage( @@ -315,14 +314,24 @@ class RoomChatController(final ChatParams info) } } - Future sendReaction(String reaction, Event event) async { + /// Reacts to [event] with [reaction], which is an mxc URL for custom emoji, + /// with their [shortcode]. + Future 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 +340,33 @@ class RoomChatController(final ChatParams info) ); } + Future 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.new, diff --git a/lib/controllers/room_image_packs.dart b/lib/controllers/room_image_packs.dart new file mode 100644 index 0000000..318d388 --- /dev/null +++ b/lib/controllers/room_image_packs.dart @@ -0,0 +1,37 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/helpers/extensions/room_image_packs.dart"; +import "package:nexus/models/image_pack.dart"; + +/// The image packs in [roomId]'s own state by state key, loading its state if +/// it hasn't been yet. +class RoomImagePacksController(final String roomId) + extends AsyncNotifier> { + @override + Future> build() async { + if (ref.read(RoomsController.provider)[roomId] case final room? + when !room.hasFetchedState) { + ref + .read(RoomsController.provider.notifier) + .addState( + roomId, + await ref + .read(ClientController.provider.notifier) + .getRoomState(.new(roomId: roomId)), + ); + } + + return ref.watch( + RoomsController.provider.select( + (rooms) => rooms[roomId]?.imagePacks(roomId) ?? const .empty(), + ), + ); + } + + static final provider = AsyncNotifierProvider.autoDispose + .family, String>( + RoomImagePacksController.new, + ); +} diff --git a/lib/controllers/room_settings_sections.dart b/lib/controllers/room_settings_sections.dart index 583f0f9..a821164 100644 --- a/lib/controllers/room_settings_sections.dart +++ b/lib/controllers/room_settings_sections.dart @@ -7,10 +7,12 @@ import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/local_aliases.dart"; import "package:nexus/controllers/power_level.dart"; import "package:nexus/controllers/power_levels_saving.dart"; +import "package:nexus/controllers/room_image_packs.dart"; import "package:nexus/controllers/room_power_levels.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/space_edges.dart"; import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; +import "package:nexus/helpers/extensions/join_rule_name.dart"; import "package:nexus/helpers/file_dialog_helper.dart"; import "package:nexus/helpers/hooks/saving.dart"; import "package:nexus/main.dart"; @@ -20,6 +22,7 @@ import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/create.dart"; import "package:nexus/models/content/encryption.dart"; import "package:nexus/models/content/history_visibility.dart"; +import "package:nexus/models/content/image_pack.dart"; import "package:nexus/models/content/join_rules.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/content/message.dart"; @@ -27,14 +30,17 @@ import "package:nexus/models/content/name.dart"; import "package:nexus/models/content/power_levels.dart"; import "package:nexus/models/content/topic.dart"; import "package:nexus/models/join_rule.dart"; +import "package:nexus/models/power_level_permission.dart"; import "package:nexus/models/setting.dart"; import "package:nexus/models/settings_category.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/divider_text.dart"; +import "package:nexus/widgets/image_pack_avatar.dart"; import "package:nexus/widgets/settings/add_users_power_level_sheet.dart"; import "package:nexus/widgets/settings/aliases_sheet.dart"; import "package:nexus/widgets/settings/allowed_rooms_sheet.dart"; import "package:nexus/widgets/settings/dialog_list_tile.dart"; +import "package:nexus/widgets/settings/image_pack_sheet.dart"; import "package:nexus/widgets/settings/local_aliases_sheet.dart"; import "package:nexus/widgets/settings/power_level_entry_sheet.dart"; import "package:nexus/widgets/settings/power_level_tile.dart"; @@ -89,6 +95,7 @@ class RoomSettingsSectionsController(final String roomId) final canSetJoinRules = canSet(.joinRules); final canSetHistoryVisibility = canSet(.historyVisibility); final canSetAliases = canSet(.canonicalAlias); + final canCreatePacks = canSet(.imagePack); final canSetPowerLevels = canSet(.powerLevels) && !ref.watch(PowerLevelsSavingController.provider(roomId)); @@ -156,20 +163,81 @@ class RoomSettingsSectionsController(final String roomId) ClientStateController.provider.select((state) => state?.userId), ); final ownLevel = userId == null ? 0.0 : roomPowerLevels.levelOf(userId); - // Encrypted rooms send messages as m.room.encrypted, so that's what limits them - final messageType = stateContent(.encryption) is EncryptionContent - ? EventType.encrypted - : EventType.message; + final encrypted = stateContent(.encryption) is EncryptionContent; + final shownEvents = PowerLevelEvent.shownIn(encrypted: encrypted); - Future setState(EventType type, Content content) async { + Future setState( + EventType type, + Content content, { + String stateKey = "", + }) async { final eventId = await client.setState( - .new(roomId: roomId, type: type.type, stateKey: "", content: content), + .new( + roomId: roomId, + type: type.type, + stateKey: stateKey, + content: content, + ), ); if (eventId case final eventId?) { - await roomsController.awaitState(roomId, type.type, eventId); + await roomsController.awaitState( + roomId, + type.type, + eventId, + stateKey: stateKey, + ); } } + final imagePacks = + ref.watch(RoomImagePacksController.provider(roomId)).value ?? + const .empty(); + + // Pack sheets can stay open while the state changes, so these read it when + // called, rather than using the state from building + IMap> currentState() => + ref.read(RoomsController.provider)[roomId]?.state ?? const .empty(); + + // Whether the legacy type still has a pack at [stateKey], to clear it + bool hasLegacyPack(String stateKey) => + switch (currentState()[EventType.legacyImagePack.type]?[stateKey]) { + final rowId? => switch (contentOf(rowId)) { + final ImagePackContent content => content.hasContent, + _ => false, + }, + null => false, + }; + final canSetLegacyPacks = canSet(.legacyImagePack); + + /// Saves the pack at [stateKey] as the stable type, migrating it by + /// clearing its legacy event if it has one. + Future setPack(String stateKey, Content content) async { + await setState(.imagePack, content, stateKey: stateKey); + if (hasLegacyPack(stateKey)) { + await setState(.legacyImagePack, const .new(), stateKey: stateKey); + } + } + + /// A state key for a new pack named [name]. It's one no pack has ever + /// had, even deleted ones, as users may still have those enabled. + String newPackKey(String name) { + final state = currentState(); + final usedKeys = [ + EventType.imagePack, + EventType.legacyImagePack, + ].expand((type) => state[type.type]?.keys ?? []).toISet(); + final slug = name + .toLowerCase() + .replaceAll(RegExp(r"[^\p{L}\p{N}_-]+", unicode: true), "_") + .replaceAll(RegExp(r"^_+|_+$"), ""); + final base = slug.isEmpty ? "pack" : slug; + + return Iterable.generate( + usedKeys.length + 1, + (index) => index == 0 ? base : "${base}_${index + 1}", + ).firstWhere((key) => !usedKeys.contains(key)); + } + Future setAliases( String? canonical, ISet alternatives, @@ -218,12 +286,10 @@ class RoomSettingsSectionsController(final String roomId) .run(() => setState(.powerLevels, content).onError(showError)); // Event types with their own level that don't have a setting of their own - final listedEvents = [ - messageType, - EventType.sticker, - EventType.reaction, - EventType.pinnedEvents, - ].map((type) => type.type).toISet(); + final listedEvents = shownEvents + .addAll(PowerLevelGroup.values.expand((group) => group.events)) + .map((event) => event.type.type) + .toISet(); final otherEvents = powerLevels.events.entries .where((entry) => !listedEvents.contains(entry.key)) .toIList() @@ -288,20 +354,94 @@ class RoomSettingsSectionsController(final String roomId) ), ); - /// A setting for the level needed to send [type], which otherwise needs [fallback]. - Setting eventSetting( - EventType type, { - required String title, - required String description, - required IconData icon, - required int fallback, - }) => powerLevelSetting( - title: title, - description: description, - icon: icon, - value: powerLevels.events[type.type] ?? fallback, - update: (level) => powerLevels.copyWith( - events: powerLevels.events.add(type.type, level), + Setting permissionSetting(PowerLevelPermission permission) => + powerLevelSetting( + title: permission.title, + description: permission.description, + icon: permission.icon, + value: permission.levelIn(powerLevels), + update: (level) => permission.withLevel(powerLevels, level), + ); + + /// A setting for the level needed to send [event], which otherwise needs [fallback]. + Setting eventSetting(PowerLevelEvent event, {required int fallback}) => + powerLevelSetting( + title: event.title, + description: event.description, + icon: event.icon, + value: powerLevels.events[event.type.type] ?? fallback, + update: (level) => powerLevels.copyWith( + events: powerLevels.events.add(event.type.type, level), + ), + ); + + Widget tileOf(Setting setting) => + setting.builder(setting.title, setting.description, setting.icon); + + /// A setting for all of [group]'s levels at once, which expands to set each. + Setting groupSetting(PowerLevelGroup group) => .new( + title: group.title, + description: group.description, + icon: group.icon, + builder: (title, description, icon) => HookBuilder( + builder: (context) { + final expanded = useState(false); + + return Column( + mainAxisSize: .min, + children: [ + PowerLevelTile( + leading: Icon(icon), + title: title, + description: description, + value: group.levelIn(powerLevels), + ownLevel: ownLevel, + // Changing it would change levels above the user's own + onChanged: + canSetPowerLevels && + group.highestIn(powerLevels) <= ownLevel + ? (level) => + setPowerLevels(group.withLevel(powerLevels, level)) + : null, + trailing: IconButton( + tooltip: expanded.value ? "Collapse" : "Set each", + icon: AnimatedRotation( + turns: expanded.value ? 0.5 : 0, + duration: Durations.medium2, + child: Icon(Icons.expand_more), + ), + onPressed: () => expanded.value = !expanded.value, + ), + ), + AnimatedSize( + duration: Durations.medium2, + curve: Curves.easeInOut, + alignment: .topCenter, + child: expanded.value + ? Padding( + padding: .only(left: 16), + child: Column( + mainAxisSize: .min, + children: [ + ...group.permissions + .map(permissionSetting) + .map(tileOf), + ...group.events + .map( + (event) => eventSetting( + event, + fallback: powerLevels.stateDefault, + ), + ) + .map(tileOf), + ], + ), + ) + : const SizedBox(width: .infinity), + ), + ], + ); + }, ), ); @@ -431,14 +571,7 @@ class RoomSettingsSectionsController(final String roomId) subtitle: Text(description), value: joinRules.joinRule, options: joinRuleOptions, - getName: (rule) => switch (rule) { - .public => "Public", - .invite => "Invite only", - .knock => "Knock", - .restricted => "Restricted", - .knockRestricted => "Restricted or knock", - .private => "Private", - }, + getName: (rule) => rule.displayName, onChanged: canSetJoinRules ? (rule) => setState( .joinRules, @@ -598,6 +731,7 @@ class RoomSettingsSectionsController(final String roomId) .invited => "After invite", .joined => "After join", .worldReadable => "Anyone", + .unknown => "Unknown", }, onChanged: canSetHistoryVisibility ? (visibility) => setState( @@ -615,13 +749,7 @@ class RoomSettingsSectionsController(final String roomId) title: "Permissions", icon: Icons.admin_panel_settings, settings: .new([ - powerLevelSetting( - title: "Default Power Level", - description: "The power level of users who aren't given one.", - icon: Icons.person, - value: powerLevels.usersDefault, - update: (level) => powerLevels.copyWith(usersDefault: level), - ), + permissionSetting(.usersDefault), header("Users", "Power levels of specific users.", Icons.group), ...users.map((entry) { final member = memberOf(entry.key); @@ -682,87 +810,31 @@ class RoomSettingsSectionsController(final String roomId) ), ), ), + header( + "Groups", + "Power levels set together, which can each be set on their own.", + Icons.workspaces, + ), + ...PowerLevelGroup.values.map(groupSetting), header( "Regular Events", "Power levels needed to send messages and other timeline events.", Icons.chat, ), - eventSetting( - messageType, - title: "Send Messages", - description: "The power level needed to send messages.", - icon: Icons.message, - fallback: powerLevels.eventsDefault, - ), - eventSetting( - .sticker, - title: "Send Stickers", - description: "The power level needed to send stickers.", - icon: Icons.sticky_note_2, - fallback: powerLevels.eventsDefault, - ), - eventSetting( - .reaction, - title: "Send Reactions", - description: "The power level needed to react to messages.", - icon: Icons.add_reaction, - fallback: powerLevels.eventsDefault, - ), - powerLevelSetting( - title: "Default for Events", - description: - "The power level needed to send events without their own.", - icon: Icons.more_horiz, - value: powerLevels.eventsDefault, - update: (level) => powerLevels.copyWith(eventsDefault: level), - ), + ...shownEvents + .where((event) => event != .pinnedEvents) + .map( + (event) => + eventSetting(event, fallback: powerLevels.eventsDefault), + ), + permissionSetting(.eventsDefault), header( "State Events", - "Power levels needed to manage members and change the room's state.", + "Power levels needed to change the room's state.", Icons.settings, ), - eventSetting( - .pinnedEvents, - title: "Pin Messages", - description: "The power level needed to pin messages.", - icon: Icons.push_pin, - fallback: powerLevels.stateDefault, - ), - powerLevelSetting( - title: "Invite Users", - description: "The power level needed to invite users.", - icon: Icons.person_add, - value: powerLevels.invite, - update: (level) => powerLevels.copyWith(invite: level), - ), - powerLevelSetting( - title: "Kick Users", - description: "The power level needed to kick users.", - icon: Icons.sports_martial_arts, - value: powerLevels.kick, - update: (level) => powerLevels.copyWith(kick: level), - ), - powerLevelSetting( - title: "Ban Users", - description: "The power level needed to ban and unban users.", - icon: Icons.gavel, - value: powerLevels.ban, - update: (level) => powerLevels.copyWith(ban: level), - ), - powerLevelSetting( - title: "Delete Others' Messages", - description: "The power level needed to remove others' messages.", - icon: Icons.delete, - value: powerLevels.redact, - update: (level) => powerLevels.copyWith(redact: level), - ), - powerLevelSetting( - title: "Default for State Events", - description: "The power level needed to send state events without their own.", - icon: Icons.tune, - value: powerLevels.stateDefault, - update: (level) => powerLevels.copyWith(stateDefault: level), - ), + permissionSetting(.invite), + permissionSetting(.stateDefault), header( "Other Events", "Power levels for specific event types.", @@ -813,6 +885,90 @@ class RoomSettingsSectionsController(final String roomId) ), ]), ), + .new( + title: "Image Packs", + icon: Icons.emoji_emotions, + settings: .new([ + ...imagePacks.entries.map((entry) { + final stateKey = entry.key; + final pack = entry.value; + final canEdit = + canCreatePacks && + (canSetLegacyPacks || !hasLegacyPack(stateKey)); + + return Setting( + title: pack.name, + description: pack.imageCountLabel, + icon: Icons.emoji_emotions, + builder: (title, description, _) => HookBuilder( + key: ValueKey(stateKey), + builder: (context) { + final saving = Saving.use(); + + return ListTile( + enabled: !saving.active, + leading: ImagePackAvatar(pack.icon), + title: Text(title), + subtitle: Text(description), + onTap: () => context.showAdaptiveSheet( + (_) => ImagePackSheet( + title: canEdit ? "Edit Image Pack" : title, + content: pack.content, + onSave: canEdit + ? (content) => saving.run( + () => setPack( + stateKey, + content, + ).onError(showError), + ) + : null, + onDeleted: canEdit + ? () => saving.run( + () => setPack( + stateKey, + const .new(), + ).onError(showError), + ) + : null, + ), + ), + ); + }, + ), + ); + }), + .new( + title: "Create Pack", + description: "Add a pack of emoticons and stickers to the room.", + icon: Icons.add, + builder: (title, description, icon) => HookBuilder( + builder: (context) { + final saving = Saving.use(); + + return ListTile( + enabled: canCreatePacks && !saving.active, + leading: Icon(icon), + title: Text(title), + subtitle: Text(description), + onTap: () => context.showAdaptiveSheet( + (_) => ImagePackSheet( + title: "Create Pack", + content: const .new(), + onSave: (content) => saving.run( + () => setState( + .imagePack, + content, + stateKey: newPackKey(content.pack!.displayName!), + ).onError(showError), + ), + ), + ), + ); + }, + ), + ), + ]), + ), ]), }); } diff --git a/lib/controllers/rooms.dart b/lib/controllers/rooms.dart index 5370bcf..d52b317 100644 --- a/lib/controllers/rooms.dart +++ b/lib/controllers/rooms.dart @@ -11,29 +11,35 @@ class RoomsController extends Notifier> { @override IMap build() => .new(); - void addState( - String roomId, - IList state, { - bool isMembers = false, - }) => update( - .new({ - roomId: Room( - events: .fromEntries(state.map((event) => .new(event.rowId, event))), - hasFetchedState: true, - hasFetchedMembers: isMembers, - state: .new( - state - .groupListsBy((event) => event.type) - .map( - (type, group) => .new( - type, - .fromEntries(group.map((e) => .new(e.stateKey!, e.rowId))), - ), - ), - ), - ), - }), - .new(), + void addState(String roomId, IList state, {bool isMembers = false}) => + update( + .new({ + roomId: stateRoom(state) + .copyWith(hasFetchedState: true, hasFetchedMembers: isMembers), + }), + .new(), + ); + + void addSpecificState(Iterable state) => update( + .new( + state + .groupListsBy((event) => event.roomId) + .map((roomId, roomState) => .new(roomId, stateRoom(roomState))), + ), + ); + + static Room stateRoom(Iterable state) => Room( + events: .fromEntries(state.map((event) => .new(event.rowId, event))), + state: .new( + state + .groupListsBy((event) => event.type) + .map( + (type, group) => .new( + type, + .fromEntries(group.map((e) => .new(e.stateKey!, e.rowId))), + ), + ), + ), ); static IMap> mergeReceipts( diff --git a/lib/controllers/settings_sections.dart b/lib/controllers/settings_sections.dart index 2cd804c..b74f50d 100644 --- a/lib/controllers/settings_sections.dart +++ b/lib/controllers/settings_sections.dart @@ -9,22 +9,31 @@ import "package:m3e_buttons/m3e_buttons.dart"; import "package:nexus/controllers/account_data.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client_state.dart"; +import "package:nexus/controllers/enabled_image_packs.dart"; import "package:nexus/controllers/init_complete.dart"; import "package:nexus/controllers/notification.dart"; +import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/settings.dart"; import "package:nexus/controllers/unified_push.dart"; import "package:nexus/controllers/spec_versions.dart"; import "package:nexus/controllers/unified_push_allowed.dart"; import "package:nexus/controllers/unified_push_distributors.dart"; import "package:nexus/helpers/extensions/distributor_name.dart"; +import "package:nexus/helpers/extensions/room_image_packs.dart"; +import "package:nexus/helpers/hooks/saving.dart"; import "package:nexus/models/account_data.dart"; import "package:nexus/models/client_state.dart"; +import "package:nexus/models/image_pack.dart"; +import "package:nexus/models/name_color.dart"; import "package:nexus/models/room_order.dart"; +import "package:nexus/models/setting.dart"; import "package:nexus/models/settings.dart"; import "package:nexus/models/settings_category.dart"; import "package:nexus/models/show_and_send.dart"; import "package:nexus/main.dart"; +import "package:nexus/widgets/image_pack_avatar.dart"; import "package:nexus/widgets/settings/dialog_list_tile.dart"; +import "package:nexus/widgets/settings/enable_image_packs_sheet.dart"; import "package:nexus/widgets/user_bottom_sheet.dart"; import "package:nexus/widgets/reset_encryption_sheet.dart"; import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; @@ -35,6 +44,33 @@ class SettingsSectionsController Future>> build() async { final settings = await ref.watch(SettingsController.provider.future); + // Loads the enabled packs' state, so they can be shown + ref.watch(EnabledImagePacksController.provider); + final enabledPacks = ref.watch( + AccountDataController.provider.select((data) => data.enabledImagePacks), + ); + final enabledPackInfo = ref.watch( + RoomsController.provider.select( + (rooms) => enabledPacks.rooms.entries.expand((entry) { + final room = rooms[entry.key]; + final packs = room?.imagePacks(entry.key) ?? const .empty(); + + return entry.value.keys.map( + (stateKey) => ( + roomId: entry.key, + stateKey: stateKey, + roomName: room?.metadata?.name ?? entry.key, + pack: packs[stateKey], + ), + ); + }).toIList(), + ), + ); + + Future setEnabledPacks(ImagePackRooms packs) => ref + .read(AccountDataController.provider.notifier) + .setEnabledImagePacks(packs); + /// A tile for a [ShowAndSend] setting, saving the settings from [update]. Widget showAndSendTile( String title, @@ -104,6 +140,26 @@ class SettingsSectionsController : null, ), ), + .new( + title: "Name Colors", + description: "Color names by power level, so moderators and admins stand out, or by user, so everyone has their own.", + icon: Icons.format_color_text, + builder: (title, description, icon) => DialogListTile( + icon: Icon(icon), + title: title, + subtitle: Text(description), + value: settings.nameColor, + options: NameColor.values, + getName: (option) => switch (option) { + .powerLevel => "By power level", + .user => "By user", + }, + onChanged: (value) => ref + .read(SettingsController.provider.notifier) + .set(settings.copyWith(nameColor: value)) + .onError(showError), + ), + ), ]), ), .new( @@ -209,6 +265,21 @@ class SettingsSectionsController .onError(showError), ), ), + .new( + title: "Hide Server ACL Events", + description: "Hides server ACL (server ban list) changes from appearing in conversations.", + icon: Icons.dns, + builder: (title, description, icon) => SwitchListTile( + title: Text(title), + subtitle: Text(description), + secondary: Icon(icon), + value: settings.hideServerAclEvents, + onChanged: (value) => ref + .read(SettingsController.provider.notifier) + .set(settings.copyWith(hideServerAclEvents: value)) + .onError(showError), + ), + ), ]), ), .new( @@ -264,6 +335,78 @@ class SettingsSectionsController ), ]), ), + .new( + title: "Image Packs", + icon: Icons.emoji_emotions, + settings: .new([ + ...enabledPackInfo.map( + (info) => Setting( + title: + info.pack?.name ?? + ImagePack.fallbackName(info.roomName, info.stateKey), + description: info.roomName, + icon: Icons.emoji_emotions, + builder: (title, description, _) => HookBuilder( + key: ValueKey((info.roomId, info.stateKey)), + builder: (context) { + final saving = Saving.use(); + + return ListTile( + enabled: !saving.active, + leading: ImagePackAvatar(info.pack?.icon), + title: Text(title), + subtitle: Text(description), + trailing: IconButton( + tooltip: "Remove pack", + icon: Icon(Icons.delete), + onPressed: saving.active + ? null + : () => saving.run( + () => setEnabledPacks( + ref + .read(AccountDataController.provider) + .enabledImagePacks + .withPack( + info.roomId, + info.stateKey, + enabled: false, + ), + ).onError(showError), + ), + ), + ); + }, + ), + ), + ), + .new( + title: "Add Packs", + description: + "Use image packs from any of your rooms in every room.", + icon: Icons.add, + builder: (title, description, icon) => HookBuilder( + builder: (context) { + final saving = Saving.use(); + + return ListTile( + enabled: !saving.active, + leading: Icon(icon), + title: Text(title), + subtitle: Text(description), + onTap: () => context.showAdaptiveSheet( + (_) => EnableImagePacksSheet( + enabled: enabledPacks, + onSave: (packs) => saving.run( + () => setEnabledPacks(packs).onError(showError), + ), + ), + ), + ); + }, + ), + ), + ]), + ), .new( title: "Notifications", icon: Icons.notifications, diff --git a/lib/controllers/spaces.dart b/lib/controllers/spaces.dart index 8af045c..4d6dffe 100644 --- a/lib/controllers/spaces.dart +++ b/lib/controllers/spaces.dart @@ -25,55 +25,50 @@ class SpacesController extends AsyncNotifier> { MapEntry(spaceId, edges.map((edge) => edge.childId).toList()), ); - Set collectDescendants(String startId) { - final visited = {}; - final stack = [startId]; - - while (stack.isNotEmpty) { - final current = stack.removeLast(); - final children = childrenById[current] ?? const []; - - for (final child in children) { - if (visited.add(child)) { - stack.add(child); - } - } - } - - return visited; - } + /// Everything under [spaceId], however deep, skipping any already + /// [visited] so cycles end. + ISet collectDescendants( + String spaceId, [ + ISet visited = const .empty(), + ]) => (childrenById[spaceId] ?? const []).fold( + visited, + (visited, childId) => visited.contains(childId) + ? visited + : collectDescendants(childId, visited.add(childId)), + ); Space buildSpace(String spaceId) { final space = rooms[spaceId]; final directChildrenIds = childrenById[spaceId] ?? const []; - final directRooms = []; - final subSpaces = []; - - for (final childId in directChildrenIds) { - final room = rooms[childId]; - if (room == null) continue; - - if (childrenById.containsKey(childId)) { - final descendants = collectDescendants(childId); - - subSpaces.add( - .new( - room: room, - children: .new(descendants.map((id) => rooms[id]).nonNulls), - ), - ); - } else { - directRooms.add(room); - } - } - return .new( id: spaceId, room: space, title: space?.metadata?.name ?? "Unnamed Space", - children: .new(directRooms), - subSpaces: .new(subSpaces), + children: .new( + directChildrenIds + .where((childId) => !childrenById.containsKey(childId)) + .map((childId) => rooms[childId]) + .nonNulls, + ), + subSpaces: .new( + directChildrenIds + .where(childrenById.containsKey) + .map( + (childId) => switch (rooms[childId]) { + final room? => Subspace( + room: room, + children: .new( + collectDescendants(childId) + .map((id) => rooms[id]) + .nonNulls, + ), + ), + null => null, + }, + ) + .nonNulls, + ), ); } diff --git a/lib/controllers/via.dart b/lib/controllers/via.dart index 39db682..8c7f0db 100644 --- a/lib/controllers/via.dart +++ b/lib/controllers/via.dart @@ -1,53 +1,48 @@ -import "package:collection/collection.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client_state.dart"; import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/content/power_levels.dart"; +import "package:nexus/models/event.dart"; import "package:nexus/models/room.dart"; class ViaController(final Room room) extends Notifier { @override String build() { - final servers = {}; + final powerLevelUsers = + switch (room.state[EventType.powerLevels.type]?[""]) { + final rowId? => switch (room.events[rowId]?.content) { + PowerLevelsContent(:final users) => users.keys.toIList(), + _ => null, + }, + null => null, + } ?? + .new([]); - void addUserId(String? userId) { - final server = userId?.split(":").lastOrNull; - if (server != null) { - servers.add(server); - } - } + final joinedMembers = + (room.state[EventType.membership.type]?.values ?? const []) + .map( + (rowId) => switch (room.events[rowId]) { + Event( + stateKey: final userId?, + content: MembershipContent(status: .join), + ) => + userId, + _ => null, + }, + ) + .nonNulls + .toIList(); - addUserId(ref.watch(ClientStateController.provider)?.userId); - - final powerLevelsEventId = room.state[EventType.powerLevels.type]?[""]; - final powerLevels = powerLevelsEventId == null - ? null - : room.events[powerLevelsEventId]; - - if (powerLevels?.content case PowerLevelsContent(:final users)) { - for (final userId in users.keys) { - addUserId(userId); - if (servers.length >= 5) break; - } - } - - final members = room.state[EventType.membership.type]?.values.toIList(); - for (var i = 0; servers.length < 5; i++) { - final membershipEventId = members?.getOrNull(i); - final member = membershipEventId == null - ? null - : room.events[membershipEventId]; - - if (member?.content case MembershipContent(:final status)) { - if (status == .join) { - addUserId(member?.stateKey); - } - } - - if (members?.getOrNull(i) == null) break; - } + final servers = [?ref.watch(ClientStateController.provider)?.userId] + .toIList() + .addAll(powerLevelUsers) + .addAll(joinedMembers) + .map((userId) => userId.substring(userId.indexOf(":") + 1)) + .whereNoDuplicates() + .take(5) + .toISet(); return servers.isEmpty ? "" diff --git a/lib/helpers/extensions/build_event_options.dart b/lib/helpers/extensions/build_event_options.dart index 72ca2d2..e96284b 100644 --- a/lib/helpers/extensions/build_event_options.dart +++ b/lib/helpers/extensions/build_event_options.dart @@ -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,10 +112,15 @@ extension BuildEventOptions on Event { .take(isDesktop ? 3 : 5) .map( (emoji) => IconButton( - icon: Text( - emoji, - style: .new(fontSize: isDesktop ? null : 18), - ), + icon: emoji.startsWith("mxc://") + ? SizedBox.square( + dimension: isDesktop ? 18 : 24, + child: MxcEmoji(Uri.parse(emoji)), + ) + : Text( + emoji, + style: .new(fontSize: isDesktop ? null : 18), + ), onPressed: () async { Navigator.of(context).pop(); await ref @@ -134,12 +142,13 @@ extension BuildEventOptions on Event { context.showAdaptiveSheet( (context) => EmojiPicker( + roomId: roomId, allowFreeText: true, - onSelection: (emoji) { + onSelection: (emoji, {shortcode, description}) { Navigator.of(context).pop(); ref .read(notifier) - .sendReaction(emoji, this) + .sendReaction(emoji, this, shortcode: shortcode) .onError(showError); }, ), diff --git a/lib/helpers/extensions/displayname_color.dart b/lib/helpers/extensions/displayname_color.dart new file mode 100644 index 0000000..eb81d16 --- /dev/null +++ b/lib/helpers/extensions/displayname_color.dart @@ -0,0 +1,38 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/room_power_levels.dart"; +import "package:nexus/controllers/settings.dart"; +import "package:nexus/helpers/extensions/string_to_color.dart"; + +extension DisplaynameColor on WidgetRef { + /// The color of [userId]'s name in [roomId], from their power level + /// (so everyone with the same level shares one) or their user ID, depending + /// on the user's settings. + Color displaynameColor(String roomId, String userId) => switch (watch( + SettingsController.provider.select((settings) => settings.value?.nameColor), + )) { + .user => userId.colorHash, + .powerLevel || null => powerLevelColor( + watch( + RoomPowerLevelsController.provider(roomId) + .select((levels) => levels.levelOf(userId)), + ), + ), + }; + + /// Like [displaynameColor], for when [userId]'s [level] is already known, + /// like a level they're being given. + Color displaynameColorAt(String userId, double level) => switch (watch( + SettingsController.provider.select((settings) => settings.value?.nameColor), + )) { + .user => userId.colorHash, + .powerLevel || null => powerLevelColor(level), + }; + + static Color powerLevelColor(double level) => HSLColor.fromAHSL( + 1, + level.isInfinite ? 30 : (210 + level / 50 * 137.50776) % 360, + 0.7, + 0.5, + ).toColor(); +} diff --git a/lib/helpers/extensions/global_rect.dart b/lib/helpers/extensions/global_rect.dart index 91c8305..4f2169f 100644 --- a/lib/helpers/extensions/global_rect.dart +++ b/lib/helpers/extensions/global_rect.dart @@ -12,4 +12,12 @@ extension GlobalRect on BuildContext { _ => Rect.zero, }; } + + /// [position] on screen as an empty rect, like where text was tapped, to + /// open a popover there. + Rect globalRectAt(Offset position) { + final overlay = Overlay.of(this).context.findRenderObject() as RenderBox; + + return overlay.globalToLocal(position) & .zero; + } } diff --git a/lib/helpers/extensions/gomuks_buffer.dart b/lib/helpers/extensions/gomuks_buffer.dart index 57425be..9ab1307 100644 --- a/lib/helpers/extensions/gomuks_buffer.dart +++ b/lib/helpers/extensions/gomuks_buffer.dart @@ -15,7 +15,10 @@ extension GomuksOwnedBufferToX on GomuksOwnedBuffer { } } - dynamic toJsonAndFree() => jsonDecode(utf8.decode(toBytesAndFree())); + dynamic toJsonAndFree() { + final bytes = toBytesAndFree(); + return bytes.isEmpty ? null : jsonDecode(utf8.decode(bytes)); + } } extension JsonToGomuksBuffer on Map { diff --git a/lib/helpers/extensions/image_pack_emoji.dart b/lib/helpers/extensions/image_pack_emoji.dart new file mode 100644 index 0000000..2b04cf1 --- /dev/null +++ b/lib/helpers/extensions/image_pack_emoji.dart @@ -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 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(), + ); +} diff --git a/lib/helpers/extensions/join_rule_name.dart b/lib/helpers/extensions/join_rule_name.dart new file mode 100644 index 0000000..94c6001 --- /dev/null +++ b/lib/helpers/extensions/join_rule_name.dart @@ -0,0 +1,14 @@ +import "package:nexus/models/join_rule.dart"; + +extension JoinRuleName on JoinRule { + /// What the rule is called in the UI, e.g. "Invite only". + String get displayName => switch (this) { + .public => "Public", + .invite => "Invite only", + .knock => "Knock", + .restricted => "Restricted", + .knockRestricted => "Restricted or knock", + .private => "Private", + .unknown => "Unknown", + }; +} diff --git a/lib/helpers/extensions/room_image_packs.dart b/lib/helpers/extensions/room_image_packs.dart new file mode 100644 index 0000000..9b9d9ce --- /dev/null +++ b/lib/helpers/extensions/room_image_packs.dart @@ -0,0 +1,34 @@ +import "package:fast_immutable_collections/fast_immutable_collections.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"; +import "package:nexus/models/room.dart"; + +extension RoomImagePacks on Room { + /// The room's image packs by state key, preferring the stable type when a + /// pack has both, and leaving out deleted ones. [roomId] names those without + /// a name when the room has none either. + IMap imagePacks(String roomId) => .fromEntries( + const [EventType.legacyImagePack, EventType.imagePack].expand( + (type) => (state[type.type] ?? .new()).entries + .map( + (entry) => switch (events[entry.value]) { + Event(redactedBy: null, content: final ImagePackContent content) + when content.hasContent => + MapEntry( + entry.key, + ImagePack.fromContent( + content, + roomId: roomId, + roomName: metadata?.name ?? roomId, + stateKey: entry.key, + ), + ), + _ => null, + }, + ) + .nonNulls, + ), + ); +} diff --git a/lib/helpers/extensions/show_adaptive_popover.dart b/lib/helpers/extensions/show_adaptive_popover.dart index 8a100e8..3d2a94c 100644 --- a/lib/helpers/extensions/show_adaptive_popover.dart +++ b/lib/helpers/extensions/show_adaptive_popover.dart @@ -5,20 +5,20 @@ import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; import "package:nexus/widgets/popover_layout.dart"; extension ShowAdaptivePopover on BuildContext { - /// Shows the widget from [builder] in a popover next to the widget this - /// context belongs to on desktop, or a bottom sheet on mobile. Use the - /// context passed to [builder] to close it. - Future showAdaptivePopover(WidgetBuilder builder) { + /// Shows the widget from [builder] in a popover next to [anchor], or the + /// widget this context belongs to, on desktop, or a bottom sheet on mobile. + /// Use the context passed to [builder] to close it. + Future showAdaptivePopover(WidgetBuilder builder, {Rect? anchor}) { if (!isDesktop) return showAdaptiveSheet(builder); - final anchor = globalRect; + final rect = anchor ?? globalRect; return showDialog( context: this, barrierColor: Colors.transparent, useSafeArea: false, builder: (context) => CustomSingleChildLayout( - delegate: PopoverLayout(anchor), + delegate: PopoverLayout(rect), child: ConstrainedBox( constraints: const .new(maxWidth: 420, maxHeight: 600), child: Material( diff --git a/lib/helpers/extensions/show_user_popover.dart b/lib/helpers/extensions/show_user_popover.dart index a56417e..ccf6898 100644 --- a/lib/helpers/extensions/show_user_popover.dart +++ b/lib/helpers/extensions/show_user_popover.dart @@ -3,6 +3,9 @@ import "package:nexus/helpers/extensions/show_adaptive_popover.dart"; import "package:nexus/widgets/user_bottom_sheet.dart"; extension ShowUserPopover on BuildContext { - void showUserPopover(String userId, {String? roomId}) => - showAdaptivePopover((context) => UserBottomSheet(userId, roomId: roomId)); + void showUserPopover(String userId, {String? roomId, Rect? anchor}) => + showAdaptivePopover( + (context) => UserBottomSheet(userId, roomId: roomId), + anchor: anchor, + ); } diff --git a/lib/helpers/extensions/success_scheme.dart b/lib/helpers/extensions/success_scheme.dart new file mode 100644 index 0000000..77f83ae --- /dev/null +++ b/lib/helpers/extensions/success_scheme.dart @@ -0,0 +1,11 @@ +import "package:dynamic_color/dynamic_color.dart"; +import "package:material_ui/material_ui.dart"; + +extension SuccessScheme on ThemeData { + /// Green colors for positive things, like accepting knocks or new pins, + /// harmonized so they fit the current theme. + ColorScheme get successScheme => ColorScheme.fromSeed( + seedColor: Colors.green.harmonizeWith(colorScheme.primary), + brightness: brightness, + ); +} diff --git a/lib/helpers/hooks/tap_recognizer.dart b/lib/helpers/hooks/tap_recognizer.dart new file mode 100644 index 0000000..97c5534 --- /dev/null +++ b/lib/helpers/hooks/tap_recognizer.dart @@ -0,0 +1,11 @@ +import "package:flutter/gestures.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; + +/// A recognizer for a tappable span in text, like a name, disposed with the +/// widget. +TapGestureRecognizer useTapRecognizer() { + final recognizer = useMemoized(TapGestureRecognizer.new); + useEffect(() => recognizer.dispose, [recognizer]); + + return recognizer; +} diff --git a/lib/main.dart b/lib/main.dart index 5cd2129..66b253a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -147,7 +147,7 @@ class const App({super.key}) extends StatelessWidget { .watch(SettingsController.provider) .maybeWhen( data: (settings) => settings.theme, - orElse: () => ThemeMode.system, + orElse: () => .system, ), home: child, ); @@ -160,8 +160,8 @@ class const App({super.key}) extends StatelessWidget { MultiProviderController.provider( .new([ GomuksListenerController.provider, - NotificationController.provider, UnifiedPushController.provider, + NotificationController.provider, MemberListOpenedController.provider, KeyController.provider(KeyController.roomKey), KeyController.provider(KeyController.spaceKey), diff --git a/lib/models/account_data.dart b/lib/models/account_data.dart index 0ab5990..88da5a0 100644 --- a/lib/models/account_data.dart +++ b/lib/models/account_data.dart @@ -16,10 +16,21 @@ class const AccountData({ @JsonKey(name: AccountData.recentEmojiKey) final RecentEmojiData recentEmoji = const RecentEmojiData(), + + /// Room image packs the user has enabled everywhere + @JsonKey(name: AccountData.imagePackRoomsKey) + final ImagePackRooms? imagePackRooms, + @JsonKey(name: AccountData.legacyImagePackRoomsKey) + 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"; + + ImagePackRooms get enabledImagePacks => + imagePackRooms ?? legacyImagePackRooms ?? const .new(); Map toJson() => _$AccountDataToJson(this); @@ -38,6 +49,42 @@ 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>> rooms = const .empty(), +}) with _$ImagePackRooms { + bool contains(String roomId, String stateKey) => + rooms[roomId]?.containsKey(stateKey) ?? false; + + ImagePackRooms withPack( + String roomId, + String stateKey, { + required bool enabled, + }) { + final packs = + rooms[roomId] ?? const IMap>.empty(); + final updated = switch ((enabled, packs.containsKey(stateKey))) { + (true, false) => packs.add(stateKey, const {}), + (false, true) => packs.remove(stateKey), + _ => packs, + }; + + return copyWith( + rooms: updated.isEmpty + ? rooms.remove(roomId) + : rooms.add(roomId, updated), + ); + } + + Map toJson() => _$ImagePackRoomsToJson(this); + + factory ImagePackRooms.fromJson(Map json) => + _$ImagePackRoomsFromJson(json); +} + @Freezed(toJson: false, fromJson: false) @JsonSerializable() class const InvitePermissionConfig({ diff --git a/lib/models/content/avatar.dart b/lib/models/content/avatar.dart index 25167e9..9efdb64 100644 --- a/lib/models/content/avatar.dart +++ b/lib/models/content/avatar.dart @@ -4,10 +4,13 @@ import "package:nexus/models/info/image.dart"; part "avatar.freezed.dart"; part "avatar.g.dart"; -@freezed -sealed class AvatarContent extends Content with _$AvatarContent { - AvatarContent._(); - factory AvatarContent({ImageInfo? info, Uri? url}) = _AvatarContent; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const AvatarContent({final ImageInfo? info, final Uri? url}) + extends Content + with _$AvatarContent { + @override + Map toJson() => _$AvatarContentToJson(this); factory AvatarContent.fromJson(Map json) => _$AvatarContentFromJson(json); diff --git a/lib/models/content/canonical_alias.dart b/lib/models/content/canonical_alias.dart index 6fb2829..058c007 100644 --- a/lib/models/content/canonical_alias.dart +++ b/lib/models/content/canonical_alias.dart @@ -4,14 +4,14 @@ import "package:nexus/models/content/content.dart"; part "canonical_alias.freezed.dart"; part "canonical_alias.g.dart"; -@freezed -sealed class CanonicalAliasContent extends Content - with _$CanonicalAliasContent { - CanonicalAliasContent._(); - factory CanonicalAliasContent({ - @JsonKey(includeIfNull: false) String? alias, - @Default(ISet.empty()) ISet altAliases, - }) = _CanonicalAliasContent; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const CanonicalAliasContent({ + @JsonKey(includeIfNull: false) final String? alias, + final ISet altAliases = const .empty(), +}) extends Content with _$CanonicalAliasContent { + @override + Map toJson() => _$CanonicalAliasContentToJson(this); factory CanonicalAliasContent.fromJson(Map json) => _$CanonicalAliasContentFromJson(json); diff --git a/lib/models/content/content.dart b/lib/models/content/content.dart index 37a87b7..2809245 100644 --- a/lib/models/content/content.dart +++ b/lib/models/content/content.dart @@ -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 json) => const Content(); @@ -58,7 +59,10 @@ enum EventType( avatar("m.room.avatar", AvatarContent.fromJson), topic("m.room.topic", TopicContent.fromJson), name("m.room.name", NameContent.fromJson), + 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), } diff --git a/lib/models/content/create.dart b/lib/models/content/create.dart index b937a94..1f9f125 100644 --- a/lib/models/content/create.dart +++ b/lib/models/content/create.dart @@ -4,21 +4,18 @@ import "package:nexus/models/content/content.dart"; part "create.freezed.dart"; part "create.g.dart"; -@freezed -sealed class CreateContent extends Content with _$CreateContent { - CreateContent._(); - factory CreateContent({ - @JsonKey(name: "additional_creators") - @Default(IList.empty()) - IList additionalCreatorIds, - - PreviousRoom? predecessor, - - @JsonKey(name: "m.federate") @Default(true) bool federated, - - @Default("1") String roomVersion, - @JsonKey(unknownEnumValue: RoomType.room) RoomType? type, - }) = _CreateContent; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const CreateContent({ + @JsonKey(name: "additional_creators") + final IList additionalCreatorIds = const .empty(), + final PreviousRoom? predecessor, + @JsonKey(name: "m.federate") final bool federated = true, + final String roomVersion = "1", + @JsonKey(unknownEnumValue: RoomType.room) final RoomType? type, +}) extends Content with _$CreateContent { + @override + Map toJson() => _$CreateContentToJson(this); factory CreateContent.fromJson(Map json) => _$CreateContentFromJson(json); diff --git a/lib/models/content/encrypted.dart b/lib/models/content/encrypted.dart index 8658a71..2fd7e4a 100644 --- a/lib/models/content/encrypted.dart +++ b/lib/models/content/encrypted.dart @@ -3,10 +3,11 @@ import "package:nexus/models/content/content.dart"; part "encrypted.freezed.dart"; part "encrypted.g.dart"; -@freezed -sealed class EncryptedContent extends Content with _$EncryptedContent { - EncryptedContent._(); - factory EncryptedContent() = _EncryptedContent; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const EncryptedContent() extends Content with _$EncryptedContent { + @override + Map toJson() => _$EncryptedContentToJson(this); factory EncryptedContent.fromJson(Map json) => _$EncryptedContentFromJson(json); diff --git a/lib/models/content/encryption.dart b/lib/models/content/encryption.dart index 52c7b34..cb7f1cd 100644 --- a/lib/models/content/encryption.dart +++ b/lib/models/content/encryption.dart @@ -3,20 +3,15 @@ import "package:nexus/models/content/content.dart"; part "encryption.freezed.dart"; part "encryption.g.dart"; -@freezed -sealed class EncryptionContent extends Content with _$EncryptionContent { - EncryptionContent._(); - factory EncryptionContent({ - required String algorithm, - - @JsonKey(name: "rotation_period_ms") - @Default(604800000) - int rotationPeriodMS, - - @JsonKey(name: "rotation_period_msgs") - @Default(100) - int rotationPeriodMessages, - }) = _EncryptionContent; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const EncryptionContent({ + required final String algorithm, + @JsonKey(name: "rotation_period_ms") final int rotationPeriodMS = 604800000, + @JsonKey(name: "rotation_period_msgs") final int rotationPeriodMessages = 100, +}) extends Content with _$EncryptionContent { + @override + Map toJson() => _$EncryptionContentToJson(this); factory EncryptionContent.fromJson(Map json) => _$EncryptionContentFromJson(json); diff --git a/lib/models/content/history_visibility.dart b/lib/models/content/history_visibility.dart index 3cfc011..516cac8 100644 --- a/lib/models/content/history_visibility.dart +++ b/lib/models/content/history_visibility.dart @@ -3,17 +3,18 @@ import "package:nexus/models/content/content.dart"; part "history_visibility.freezed.dart"; part "history_visibility.g.dart"; -@freezed -sealed class HistoryVisibilityContent extends Content - with _$HistoryVisibilityContent { - HistoryVisibilityContent._(); - factory HistoryVisibilityContent({ - required HistoryVisibility historyVisibility, - }) = _HistoryVisibilityContent; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const HistoryVisibilityContent({ + @JsonKey(unknownEnumValue: HistoryVisibility.unknown) + required final HistoryVisibility historyVisibility, +}) extends Content with _$HistoryVisibilityContent { + @override + Map toJson() => _$HistoryVisibilityContentToJson(this); factory HistoryVisibilityContent.fromJson(Map json) => _$HistoryVisibilityContentFromJson(json); } @JsonEnum(fieldRename: FieldRename.snake) -enum HistoryVisibility { invited, joined, shared, worldReadable } +enum HistoryVisibility { invited, joined, shared, worldReadable, unknown } diff --git a/lib/models/content/image_pack.dart b/lib/models/content/image_pack.dart new file mode 100644 index 0000000..5bcbb54 --- /dev/null +++ b/lib/models/content/image_pack.dart @@ -0,0 +1,76 @@ +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(toJson: false, fromJson: false) +@JsonSerializable(includeIfNull: false) +class const ImagePackContent({ + final IMap images = const .empty(), + final PackInfo? pack, +}) extends Content with _$ImagePackContent { + /// Whether the pack has images or a name, as deleted packs are left empty. + bool get hasContent => images.isNotEmpty || pack?.displayName != null; + + IMap 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, + }, + ); + + @override + Map toJson() => _$ImagePackContentToJson(this); + + factory ImagePackContent.fromJson(Map json) => + _$ImagePackContentFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(includeIfNull: false) +class const PackImage({ + required final Uri url, + final String? body, + final ImageInfo? info, + @JsonKey(fromJson: ImageUsage.setFromJson, toJson: ImageUsage.setToJson) + final ISet usage = const .empty(), +}) with _$PackImage { + Map toJson() => _$PackImageToJson(this); + + factory PackImage.fromJson(Map json) => + _$PackImageFromJson(json); +} + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(includeIfNull: false) +class const PackInfo({ + final String? displayName, + final Uri? avatarUrl, + final String? attribution, + @JsonKey(fromJson: ImageUsage.setFromJson, toJson: ImageUsage.setToJson) + final ISet usage = const .empty(), +}) with _$PackInfo { + Map toJson() => _$PackInfoToJson(this); + + factory PackInfo.fromJson(Map json) => + _$PackInfoFromJson(json); +} + +enum ImageUsage { + emoticon, + sticker; + + /// Ignores usages we don't know about. + static ISet setFromJson(List? json) => + ISet(values.where((value) => json?.contains(value.name) ?? false)); + + /// Omits an empty set, which means every usage. + static List? setToJson(ISet usages) => + usages.isEmpty ? null : usages.map((usage) => usage.name).toList(); +} diff --git a/lib/models/content/join_rules.dart b/lib/models/content/join_rules.dart index 948a762..4f1a929 100644 --- a/lib/models/content/join_rules.dart +++ b/lib/models/content/join_rules.dart @@ -5,13 +5,14 @@ import "package:nexus/models/join_rule.dart"; part "join_rules.freezed.dart"; part "join_rules.g.dart"; -@freezed -sealed class JoinRulesContent extends Content with _$JoinRulesContent { - JoinRulesContent._(); - factory JoinRulesContent({ - required JoinRule joinRule, - @Default(ISet.empty()) ISet allow, - }) = _JoinRulesContent; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const JoinRulesContent({ + @JsonKey(unknownEnumValue: JoinRule.unknown) required final JoinRule joinRule, + final ISet allow = const .empty(), +}) extends Content with _$JoinRulesContent { + @override + Map toJson() => _$JoinRulesContentToJson(this); factory JoinRulesContent.fromJson(Map json) => _$JoinRulesContentFromJson(json); diff --git a/lib/models/content/membership.dart b/lib/models/content/membership.dart index b1a5508..e082bf6 100644 --- a/lib/models/content/membership.dart +++ b/lib/models/content/membership.dart @@ -4,24 +4,22 @@ import "package:nexus/models/membership_status.dart"; part "membership.freezed.dart"; part "membership.g.dart"; -@freezed -sealed class MembershipContent extends Content with _$MembershipContent { - MembershipContent._(); - +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const MembershipContent({ + @JsonKey(name: "displayname", fromJson: MembershipContent.displaynameFromJson) + required final String? displayName, + @JsonKey(name: "membership", unknownEnumValue: MembershipStatus.unknown) + required final MembershipStatus status, + final Uri? avatarUrl, + final String? reason, + final bool isDirect = false, +}) extends Content with _$MembershipContent { static String? displaynameFromJson(String? displayName) => displayName?.isEmpty == true ? null : displayName; - factory MembershipContent({ - @JsonKey( - name: "displayname", - fromJson: MembershipContent.displaynameFromJson, - ) - required String? displayName, - @JsonKey(name: "membership") required MembershipStatus status, - Uri? avatarUrl, - String? reason, - @Default(false) bool isDirect, - }) = _MembershipContent; + @override + Map toJson() => _$MembershipContentToJson(this); factory MembershipContent.fromJson(Map json) => _$MembershipContentFromJson(json); diff --git a/lib/models/content/message.dart b/lib/models/content/message.dart index 36bd725..5212cd8 100644 --- a/lib/models/content/message.dart +++ b/lib/models/content/message.dart @@ -20,6 +20,7 @@ sealed class MessageContent extends Content with _$MessageContent { @FreezedUnionValue("m.text") factory MessageContent.text({ required String body, + @JsonKey(unknownEnumValue: JsonKey.nullForUndefinedEnumValue) MessageFormat? format, String? formattedBody, }) = TextMessageContent; @@ -27,6 +28,7 @@ sealed class MessageContent extends Content with _$MessageContent { @FreezedUnionValue("m.notice") factory MessageContent.notice({ required String body, + @JsonKey(unknownEnumValue: JsonKey.nullForUndefinedEnumValue) MessageFormat? format, String? formattedBody, }) = NoticeMessageContent; @@ -34,6 +36,7 @@ sealed class MessageContent extends Content with _$MessageContent { @FreezedUnionValue("m.emote") factory MessageContent.emote({ required String body, + @JsonKey(unknownEnumValue: JsonKey.nullForUndefinedEnumValue) MessageFormat? format, String? formattedBody, }) = EmoteMessageContent; @@ -41,6 +44,7 @@ sealed class MessageContent extends Content with _$MessageContent { @FreezedUnionValue("m.image") factory MessageContent.image({ required String body, + @JsonKey(unknownEnumValue: JsonKey.nullForUndefinedEnumValue) MessageFormat? format, String? formattedBody, EncryptedFile? file, @@ -52,6 +56,7 @@ sealed class MessageContent extends Content with _$MessageContent { @FreezedUnionValue("m.file") factory MessageContent.file({ required String body, + @JsonKey(unknownEnumValue: JsonKey.nullForUndefinedEnumValue) MessageFormat? format, String? formattedBody, EncryptedFile? file, @@ -63,6 +68,7 @@ sealed class MessageContent extends Content with _$MessageContent { @FreezedUnionValue("m.audio") factory MessageContent.audio({ required String body, + @JsonKey(unknownEnumValue: JsonKey.nullForUndefinedEnumValue) MessageFormat? format, String? formattedBody, EncryptedFile? file, @@ -74,6 +80,7 @@ sealed class MessageContent extends Content with _$MessageContent { @FreezedUnionValue("m.video") factory MessageContent.video({ required String body, + @JsonKey(unknownEnumValue: JsonKey.nullForUndefinedEnumValue) MessageFormat? format, String? formattedBody, EncryptedFile? file, diff --git a/lib/models/content/name.dart b/lib/models/content/name.dart index c451d64..74f583d 100644 --- a/lib/models/content/name.dart +++ b/lib/models/content/name.dart @@ -3,10 +3,13 @@ import "package:nexus/models/content/content.dart"; part "name.freezed.dart"; part "name.g.dart"; -@freezed -sealed class NameContent extends Content with _$NameContent { - NameContent._(); - factory NameContent({required String name}) = _NameContent; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const NameContent({required final String name}) + extends Content + with _$NameContent { + @override + Map toJson() => _$NameContentToJson(this); factory NameContent.fromJson(Map json) => _$NameContentFromJson(json); diff --git a/lib/models/content/pinned_events.dart b/lib/models/content/pinned_events.dart index 9dac5de..cf322af 100644 --- a/lib/models/content/pinned_events.dart +++ b/lib/models/content/pinned_events.dart @@ -4,12 +4,13 @@ import "package:nexus/models/content/content.dart"; part "pinned_events.freezed.dart"; part "pinned_events.g.dart"; -@freezed -sealed class PinnedEventsContent extends Content with _$PinnedEventsContent { - PinnedEventsContent._(); - factory PinnedEventsContent({ - @Default(IList.empty()) @JsonKey(name: "pinned") IList pinnedEvents, - }) = _PinnedEventsContent; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const PinnedEventsContent({ + @JsonKey(name: "pinned") final IList pinnedEvents = const .empty(), +}) extends Content with _$PinnedEventsContent { + @override + Map toJson() => _$PinnedEventsContentToJson(this); factory PinnedEventsContent.fromJson(Map json) => _$PinnedEventsContentFromJson(json); diff --git a/lib/models/content/power_levels.dart b/lib/models/content/power_levels.dart index afa0e23..cdffd68 100644 --- a/lib/models/content/power_levels.dart +++ b/lib/models/content/power_levels.dart @@ -4,21 +4,22 @@ import "package:nexus/models/content/content.dart"; part "power_levels.freezed.dart"; part "power_levels.g.dart"; -@freezed -sealed class PowerLevelsContent extends Content with _$PowerLevelsContent { - PowerLevelsContent._(); - factory PowerLevelsContent({ - @Default(IMap.empty()) IMap events, - @Default(IMap.empty()) IMap users, - @JsonKey(includeIfNull: false) Notifications? notifications, - @Default(50) int ban, - @Default(0) int eventsDefault, - @Default(0) int invite, - @Default(50) int kick, - @Default(50) int redact, - @Default(50) int stateDefault, - @Default(0) int usersDefault, - }) = _PowerLevelsContent; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const PowerLevelsContent({ + final IMap events = const .empty(), + final IMap users = const .empty(), + @JsonKey(includeIfNull: false) final Notifications? notifications, + final int ban = 50, + final int eventsDefault = 0, + final int invite = 0, + final int kick = 50, + final int redact = 50, + final int stateDefault = 50, + final int usersDefault = 0, +}) extends Content with _$PowerLevelsContent { + @override + Map toJson() => _$PowerLevelsContentToJson(this); factory PowerLevelsContent.fromJson(Map json) => _$PowerLevelsContentFromJson(json); @@ -26,10 +27,7 @@ sealed class PowerLevelsContent extends Content with _$PowerLevelsContent { @Freezed(toJson: false, fromJson: false) @JsonSerializable() -class const Notifications({ - final int room = 50, - final IMap other = const .empty(), -}) with _$Notifications { +class const Notifications({final int room = 50}) with _$Notifications { Map toJson() => _$NotificationsToJson(this); factory Notifications.fromJson(Map json) => diff --git a/lib/models/content/reaction.dart b/lib/models/content/reaction.dart index 93c563d..3990788 100644 --- a/lib/models/content/reaction.dart +++ b/lib/models/content/reaction.dart @@ -3,19 +3,21 @@ import "package:nexus/models/content/content.dart"; part "reaction.freezed.dart"; part "reaction.g.dart"; -@Freezed(toJson: false) -sealed class ReactionContent extends Content with _$ReactionContent { - ReactionContent._(); +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const ReactionContent({ + @JsonKey(readValue: ReactionContent.keyJsonFromJson) final String? key, + + /// For custom emoji reactions + @JsonKey(name: "com.beeper.reaction.shortcode") final String? shortcode, +}) extends Content with _$ReactionContent { static String? keyJsonFromJson(Map json, String key) => json["m.relates_to"]?["key"]; - factory ReactionContent({ - @JsonKey(readValue: ReactionContent.keyJsonFromJson) String? key, - }) = _ReactionContent; - @override Map toJson() => { "m.relates_to": {"key": key}, + "com.beeper.reaction.shortcode": ?shortcode, }; factory ReactionContent.fromJson(Map json) => diff --git a/lib/models/content/redaction.dart b/lib/models/content/redaction.dart index 2ed6436..9864c1f 100644 --- a/lib/models/content/redaction.dart +++ b/lib/models/content/redaction.dart @@ -3,11 +3,13 @@ import "package:nexus/models/content/content.dart"; part "redaction.freezed.dart"; part "redaction.g.dart"; -@freezed -sealed class RedactionContent extends Content with _$RedactionContent { - RedactionContent._(); - factory RedactionContent({String? reason, String? redacts}) = - _RedactionContent; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const RedactionContent({final String? reason, final String? redacts}) + extends Content + with _$RedactionContent { + @override + Map toJson() => _$RedactionContentToJson(this); factory RedactionContent.fromJson(Map json) => _$RedactionContentFromJson(json); diff --git a/lib/models/content/server_acl.dart b/lib/models/content/server_acl.dart index 3873bf3..3c33072 100644 --- a/lib/models/content/server_acl.dart +++ b/lib/models/content/server_acl.dart @@ -4,14 +4,15 @@ import "package:nexus/models/content/content.dart"; part "server_acl.freezed.dart"; part "server_acl.g.dart"; -@freezed -sealed class ServerACLContent extends Content with _$ServerACLContent { - ServerACLContent._(); - factory ServerACLContent({ - @Default(IList.empty()) IList allow, - @Default(IList.empty()) IList deny, - @Default(true) allowIpLiterals, - }) = _ServerACLContent; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const ServerACLContent({ + final IList allow = const .empty(), + final IList deny = const .empty(), + final bool allowIpLiterals = true, +}) extends Content with _$ServerACLContent { + @override + Map toJson() => _$ServerACLContentToJson(this); factory ServerACLContent.fromJson(Map json) => _$ServerACLContentFromJson(json); diff --git a/lib/models/content/sticker.dart b/lib/models/content/sticker.dart index c6fa893..a47a576 100644 --- a/lib/models/content/sticker.dart +++ b/lib/models/content/sticker.dart @@ -4,14 +4,15 @@ import "package:nexus/models/info/image.dart"; part "sticker.freezed.dart"; part "sticker.g.dart"; -@freezed -sealed class StickerContent extends Content with _$StickerContent { - StickerContent._(); - factory StickerContent({ - required String body, - required ImageInfo info, - required Uri url, - }) = _StickerContent; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const StickerContent({ + required final String body, + required final ImageInfo info, + required final Uri url, +}) extends Content with _$StickerContent { + @override + Map toJson() => _$StickerContentToJson(this); factory StickerContent.fromJson(Map json) => _$StickerContentFromJson(json); diff --git a/lib/models/content/topic.dart b/lib/models/content/topic.dart index c8256e3..cd187fa 100644 --- a/lib/models/content/topic.dart +++ b/lib/models/content/topic.dart @@ -5,13 +5,14 @@ import "package:nexus/models/content/content.dart"; part "topic.freezed.dart"; part "topic.g.dart"; -@freezed -sealed class TopicContent extends Content with _$TopicContent { - TopicContent._(); - factory TopicContent({ - required String topic, - @JsonKey(name: "m.topic") TopicContentBlock? content, - }) = _TopicContent; +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const TopicContent({ + required final String topic, + @JsonKey(name: "m.topic") final TopicContentBlock? content, +}) extends Content with _$TopicContent { + @override + Map toJson() => _$TopicContentToJson(this); factory TopicContent.fromJson(Map json) => _$TopicContentFromJson(json); @@ -20,7 +21,7 @@ sealed class TopicContent extends Content with _$TopicContent { @Freezed(toJson: false, fromJson: false) @JsonSerializable() class const TopicContentBlock({ - final IList representations = const IList.empty(), + final IList representations = const .empty(), }) with _$TopicContentBlock { Map toJson() => _$TopicContentBlockToJson(this); diff --git a/lib/models/emoji.dart b/lib/models/emoji.dart deleted file mode 100644 index 75faf08..0000000 --- a/lib/models/emoji.dart +++ /dev/null @@ -1,25 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:flutter/widgets.dart"; -part "emoji.freezed.dart"; -part "emoji.g.dart"; - -@Freezed(toJson: false, fromJson: false) -@JsonSerializable(createToJson: false) -class Emoji({ - @JsonKey( - fromJson: Emoji.widgetFromJson, - readValue: Emoji.readWidgetValueFromJson, - ) - required final Widget widget, - @JsonKey(name: "emoji") required final String value, - required final IList aliases, - required final String description, - required final IList tags, -}) with _$Emoji { - static Widget widgetFromJson(String emoji) => Text(emoji); - - static String readWidgetValueFromJson(Map json, _) => - json["emoji"]; - factory Emoji.fromJson(Map json) => _$EmojiFromJson(json); -} diff --git a/lib/models/emoji_category.dart b/lib/models/emoji_category.dart deleted file mode 100644 index cc39bc3..0000000 --- a/lib/models/emoji_category.dart +++ /dev/null @@ -1,12 +0,0 @@ -import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/emoji.dart"; -import "package:flutter/widgets.dart"; -part "emoji_category.freezed.dart"; - -@Freezed(toJson: false, fromJson: false) -class EmojiCategory({ - required final Widget icon, - required final String name, - required final IList emojis, -}) with _$EmojiCategory; diff --git a/lib/models/image_pack.dart b/lib/models/image_pack.dart new file mode 100644 index 0000000..33528c2 --- /dev/null +++ b/lib/models/image_pack.dart @@ -0,0 +1,54 @@ +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 emoticons, + required final IMap stickers, + + /// The state event the pack is from, for editing it. + required final ImagePackContent content, +}) with _$ImagePack { + /// The pack's avatar, or its first image if it has none. + Uri? get icon => avatar ?? content.images.values.firstOrNull?.url; + + String get imageCountLabel => content.images.length == 1 + ? "1 image" + : "${content.images.length} images"; + + /// The name of a pack without one, from its room's name and state key. + static String fallbackName(String roomName, String stateKey) => + stateKey.isEmpty ? roomName : "$roomName - $stateKey"; + + static String emojiMarkdown( + String shortcode, + String url, { + String? description, + }) => + '![:$shortcode:]($url "Emoji: ${switch (description) { + final description? when description.isNotEmpty => description, + _ => ":$shortcode:", + }.replaceAll(r"\", r"\\").replaceAll('"', r'\"')}")'; + + factory ImagePack.fromContent( + ImagePackContent content, { + required String roomId, + required String roomName, + required String stateKey, + }) => .new( + id: "$roomId/$stateKey", + name: content.pack?.displayName ?? fallbackName(roomName, stateKey), + avatar: content.pack?.avatarUrl, + emoticons: content.imagesFor(.emoticon), + stickers: content.imagesFor(.sticker), + content: content, + ); +} diff --git a/lib/models/join_rule.dart b/lib/models/join_rule.dart index 44fb9bb..a82c62a 100644 --- a/lib/models/join_rule.dart +++ b/lib/models/join_rule.dart @@ -1,4 +1,13 @@ import "package:freezed_annotation/freezed_annotation.dart"; @JsonEnum(fieldRename: .snake) -enum JoinRule { public, knock, invite, private, restricted, knockRestricted } +enum JoinRule { + public, + knock, + invite, + private, + restricted, + knockRestricted, + + unknown, +} diff --git a/lib/models/membership_status.dart b/lib/models/membership_status.dart index 7eb5f72..f8197cb 100644 --- a/lib/models/membership_status.dart +++ b/lib/models/membership_status.dart @@ -1 +1 @@ -enum MembershipStatus { leave, invite, ban, join, knock } +enum MembershipStatus { leave, invite, ban, join, knock, unknown } diff --git a/lib/models/name_color.dart b/lib/models/name_color.dart new file mode 100644 index 0000000..f67429e --- /dev/null +++ b/lib/models/name_color.dart @@ -0,0 +1,2 @@ +/// What users' name colors are hashed from. +enum NameColor { powerLevel, user } diff --git a/lib/models/power_level_permission.dart b/lib/models/power_level_permission.dart new file mode 100644 index 0000000..8c26ee2 --- /dev/null +++ b/lib/models/power_level_permission.dart @@ -0,0 +1,247 @@ +import "dart:math"; + +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/content/power_levels.dart"; + +enum PowerLevelPermission( + final String title, + final String description, + final IconData icon, +) { + usersDefault( + "Default Power Level", + "The power level of users who aren't given one.", + Icons.person, + ), + eventsDefault( + "Default for Events", + "The power level needed to send events without their own.", + Icons.more_horiz, + ), + stateDefault( + "Default for State Events", + "The power level needed to send state events without their own.", + Icons.tune, + ), + invite( + "Invite Users", + "The power level needed to invite users.", + Icons.person_add, + ), + kick( + "Kick Users", + "The power level needed to kick users.", + Icons.sports_martial_arts, + ), + ban( + "Ban Users", + "The power level needed to ban and unban users.", + Icons.gavel, + ), + redact( + "Delete Others' Messages", + "The power level needed to remove others' messages.", + Icons.delete, + ), + notifyRoom( + "Notify Everyone", + "The power level needed to notify everyone in the room with @room.", + Icons.campaign, + ); + + int levelIn(PowerLevelsContent content) => switch (this) { + .usersDefault => content.usersDefault, + .eventsDefault => content.eventsDefault, + .stateDefault => content.stateDefault, + .invite => content.invite, + .kick => content.kick, + .ban => content.ban, + .redact => content.redact, + .notifyRoom => content.notifications?.room ?? 50, + }; + + PowerLevelsContent withLevel(PowerLevelsContent content, int level) => + switch (this) { + .usersDefault => content.copyWith(usersDefault: level), + .eventsDefault => content.copyWith(eventsDefault: level), + .stateDefault => content.copyWith(stateDefault: level), + .invite => content.copyWith(invite: level), + .kick => content.copyWith(kick: level), + .ban => content.copyWith(ban: level), + .redact => content.copyWith(redact: level), + .notifyRoom => content.copyWith( + notifications: (content.notifications ?? const Notifications()) + .copyWith(room: level), + ), + }; +} + +enum PowerLevelEvent( + final EventType type, + final String title, + final String description, + final IconData icon, +) { + message( + .message, + "Send Messages", + "The power level needed to send messages.", + Icons.message, + ), + // Everything a client encrypts is sent as this, so its level is what limits + // messages, stickers, and the like in encrypted rooms + encrypted( + .encrypted, + "Send Encrypted Events", + "The power level needed to send messages, stickers, files and anything else encrypted.", + Icons.lock, + ), + sticker( + .sticker, + "Send Stickers", + "The power level needed to send stickers.", + Icons.sticky_note_2, + ), + reaction( + .reaction, + "Send Reactions", + "The power level needed to react to messages.", + Icons.add_reaction, + ), + pinnedEvents( + .pinnedEvents, + "Pin Messages", + "The power level needed to pin messages.", + Icons.push_pin, + ), + name( + .name, + "Change Name", + "The power level needed to change the room's name.", + Icons.title, + ), + topic( + .topic, + "Change Topic", + "The power level needed to change the room's topic.", + Icons.description, + ), + avatar( + .avatar, + "Change Avatar", + "The power level needed to change the room's avatar.", + Icons.image, + ), + canonicalAlias( + .canonicalAlias, + "Change Aliases", + "The power level needed to change the room's canonical alias and published aliases.", + Icons.numbers, + ), + powerLevels( + .powerLevels, + "Change Power Levels", + "The power level needed to change power levels, including these.", + Icons.admin_panel_settings, + ), + joinRules( + .joinRules, + "Change Join Rule", + "The power level needed to change who can join the room.", + Icons.login, + ), + historyVisibility( + .historyVisibility, + "Change History Visibility", + "The power level needed to change who can read the room's history.", + Icons.history, + ), + serverACL( + .serverACL, + "Change Server ACL", + "The power level needed to change which servers can take part in the room.", + Icons.dns, + ), + encryption( + .encryption, + "Enable Encryption", + "The power level needed to enable encryption, which can't be turned off.", + Icons.lock, + ), + tombstone( + .tombstone, + "Upgrade Room", + "The power level needed to replace the room with an upgraded one.", + Icons.upgrade, + ); + + static IList shownIn({required bool encrypted}) => .new([ + if (encrypted) .encrypted else ...[.message, .sticker], + .reaction, + ]); + + static PowerLevelEvent? of(String type) => + values.where((event) => event.type.type == type).firstOrNull; +} + +/// Power levels set together, like everything moderators need, to save +/// setting each one. Each can still be set on its own, by expanding it. +enum PowerLevelGroup(final String title, final IconData icon) { + moderation("Moderation", Icons.shield), + roomDetails("Room Details", Icons.edit), + administration("Administration", Icons.admin_panel_settings); + + IList get permissions => .new(switch (this) { + .moderation => [.kick, .ban, .redact, .notifyRoom], + .roomDetails || .administration => [], + }); + + IList get events => .new(switch (this) { + .moderation => [], + .roomDetails => [.name, .topic, .avatar, .canonicalAlias, .pinnedEvents], + .administration => [ + .powerLevels, + .joinRules, + .historyVisibility, + .serverACL, + .encryption, + .tombstone, + ], + }); + + String get description => [ + ...permissions.map((permission) => permission.title), + ...events.map((event) => event.title), + ].join(", "); + + int? levelIn(PowerLevelsContent content) { + final levels = { + ...permissions.map((permission) => permission.levelIn(content)), + ...events.map( + (event) => content.events[event.type.type] ?? content.stateDefault, + ), + }; + return levels.length == 1 ? levels.single : null; + } + + int highestIn(PowerLevelsContent content) => [ + ...permissions.map((permission) => permission.levelIn(content)), + ...events.map( + (event) => content.events[event.type.type] ?? content.stateDefault, + ), + ].reduce(max); + + PowerLevelsContent withLevel(PowerLevelsContent content, int level) => + permissions + .fold( + content, + (content, permission) => permission.withLevel(content, level), + ) + .copyWith( + events: content.events.addEntries( + events.map((event) => MapEntry(event.type.type, level)), + ), + ); +} diff --git a/lib/models/requests/get_specific_room_state.dart b/lib/models/requests/get_specific_room_state.dart new file mode 100644 index 0000000..1559bc0 --- /dev/null +++ b/lib/models/requests/get_specific_room_state.dart @@ -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 keys, +}) with _$GetSpecificRoomStateRequest { + Map toJson() => _$GetSpecificRoomStateRequestToJson(this); + + factory GetSpecificRoomStateRequest.fromJson(Map 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 toJson() => _$RoomStateKeyToJson(this); + + factory RoomStateKey.fromJson(Map json) => + _$RoomStateKeyFromJson(json); +} diff --git a/lib/models/room_summary.dart b/lib/models/room_summary.dart index 05eaf43..2688ca3 100644 --- a/lib/models/room_summary.dart +++ b/lib/models/room_summary.dart @@ -14,7 +14,7 @@ part "room_summary.g.dart"; class const RoomSummary({ required final String roomId, @JsonKey(name: "num_joined_members") final int? joinedMembers, - final JoinRule? joinRule, + @JsonKey(unknownEnumValue: JoinRule.unknown) final JoinRule? joinRule, /// Rooms whose members may join without an invite, for restricted rooms. final IList allowedRoomIds = const .empty(), diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 7ea61d9..e3b95a2 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -1,5 +1,6 @@ import "package:material_ui/material_ui.dart"; import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/name_color.dart"; import "package:nexus/models/room_order.dart"; import "package:nexus/models/show_and_send.dart"; @@ -16,7 +17,9 @@ class const Settings({ final bool showMinimizeButton = false, final bool swipeToReply = false, final bool hideMembershipEvents = false, + final bool hideServerAclEvents = true, final RoomOrder roomOrder = .unreadFirst, + final NameColor nameColor = .powerLevel, final ShowAndSend typingIndicators = .showAndSend, /// When not sending, read receipts are still sent privately, so that the diff --git a/lib/widgets/composer/composer.dart b/lib/widgets/composer/composer.dart index 2648347..18eebbf 100644 --- a/lib/widgets/composer/composer.dart +++ b/lib/widgets/composer/composer.dart @@ -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 tags, }) onSend, + required final Future 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,11 +270,31 @@ class const Composer( IconButton( onPressed: () => context.showAdaptiveSheet( (context) => EmojiPicker( - onSelection: (value) { - Navigator.of(context).pop(); - controller.text += value; - node?.requestFocus(); - }, + roomId: roomId, + onSelection: + (value, {shortcode, description}) { + 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, + description: description, + ), + name: shortcode, + ); + } + node?.requestFocus(); + }, ), ), icon: Icon(Icons.emoji_emotions), @@ -308,6 +345,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 +368,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, @@ -345,11 +401,11 @@ class const Composer( if (!shiftPressed) { send(); - return KeyEventResult.handled; + return .handled; } } - return KeyEventResult.ignored; + return .ignored; }, // Overrides the keyboard shortcut for pasting child: Actions( diff --git a/lib/widgets/composer/overlays/emoji_overlay.dart b/lib/widgets/composer/overlays/emoji_overlay.dart index 0e762c0..3ff0d55 100644 --- a/lib/widgets/composer/overlays/emoji_overlay.dart +++ b/lib/widgets/composer/overlays/emoji_overlay.dart @@ -1,8 +1,10 @@ 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/content/image_pack.dart"; +import "package:nexus/models/image_pack.dart"; class const EmojiOverlay( final String query, { @@ -13,7 +15,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 +36,15 @@ 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, + description: switch (emoji.data) { + PackImage(:final body) => body, + _ => null, + }, + ) : emoji.value, name: emoji.aliases.first, ), diff --git a/lib/widgets/composer/relation_preview.dart b/lib/widgets/composer/relation_preview.dart index fca9de6..aaf1f5c 100644 --- a/lib/widgets/composer/relation_preview.dart +++ b/lib/widgets/composer/relation_preview.dart @@ -38,7 +38,7 @@ class const RelationPreview( onPressed: toggleShouldMention, child: Text( shouldMention ? "@On" : "@Off", - style: TextStyle( + style: .new( fontWeight: .w900, color: shouldMention ? null : Theme.of(context).disabledColor, ), diff --git a/lib/widgets/composer/typing_indicator.dart b/lib/widgets/composer/typing_indicator.dart index 2a30c4b..9624937 100644 --- a/lib/widgets/composer/typing_indicator.dart +++ b/lib/widgets/composer/typing_indicator.dart @@ -4,11 +4,12 @@ import "package:material_ui/material_ui.dart"; import "package:nexus/controllers/room_typing.dart"; import "package:nexus/controllers/user.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; -import "package:nexus/helpers/extensions/show_user_popover.dart"; +import "package:nexus/helpers/hooks/tap_recognizer.dart"; import "package:nexus/widgets/bouncing_dots.dart"; +import "package:nexus/widgets/user_span.dart"; class const TypingIndicator(final String roomId, {super.key}) - extends ConsumerWidget { + extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); @@ -16,33 +17,31 @@ class const TypingIndicator(final String roomId, {super.key}) // Past three people, only the first two are named final named = typing.length > 3 ? 2 : typing.length; + // One for each person who can be named + final recognizers = [ + useTapRecognizer(), + useTapRecognizer(), + useTapRecognizer(), + ]; final subjects = [ ...typing .take(named) - .map( - (typingUserId) => WidgetSpan( - alignment: .baseline, - baseline: .alphabetic, - child: Builder( - builder: (context) => InkWell( - onTap: () => - context.showUserPopover(typingUserId, roomId: roomId), - child: Text( - ref - .watch( - UserController.provider( - .new(roomId: roomId, userId: typingUserId), - ), - ) - .value - ?.displayName ?? - typingUserId.localpart, - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: .bold, - ), - ), - ), - ), + .mapIndexed( + (index, typingUserId) => UserSpan( + context, + ref + .watch( + UserController.provider( + .new(roomId: roomId, userId: typingUserId), + ), + ) + .value + ?.displayName ?? + typingUserId.localpart, + userId: typingUserId, + roomId: roomId, + recognizer: recognizers[index], + style: TextStyle(fontWeight: .bold), ), ), if (typing.length > named) diff --git a/lib/widgets/emoji_picker.dart b/lib/widgets/emoji_picker.dart index 7e49836..fb834c9 100644 --- a/lib/widgets/emoji_picker.dart +++ b/lib/widgets/emoji_picker.dart @@ -1,15 +1,28 @@ 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 Function(String value) onSelection, + required final String roomId, + + /// Has the [shortcode] and [description] of images from packs. + required final FutureOr Function( + String value, { + String? shortcode, + String? description, + }) + onSelection, final bool allowFreeText = false, super.key, }) extends ConsumerWidget { @@ -18,39 +31,54 @@ 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 { + final image = switch (emoji.data) { + final PackImage image => image, + _ => null, + }; + await onSelection( + emoji.value, + shortcode: image == null ? null : emoji.aliases.first, + description: image?.body, + ); - if (!isFreeText) { await ref .read(RecentEmojiController.provider.notifier) - .add(emoji) + .add(emoji.value) .onError(showError); - } - }, - allowFreeText: allowFreeText, - prependCategories: .new([ - .new( - icon: Icon(Icons.history), - name: "Recent", - emojis: .new( - recentEmoji - .map( - (recent) => categories - .map((element) => element.emojis) - .flattened - .firstWhereOrNull( - (element) => element.value == recent.emoji, - ), - ) - .nonNulls, + }, + onFreeText: allowFreeText ? (text) => onSelection(text) : null, + prependCategories: IList([ + .new( + icon: Icon(Icons.history), + name: "Recent", + emojis: .new( + recentEmoji + .map( + (recent) => allEmoji.firstWhereOrNull( + (element) => element.value == recent.emoji, + ), + ) + .nonNulls, + ), ), - ), - ]), - ), + ]).addAll(customCategories), + ); + }, ); } } diff --git a/lib/widgets/html/html.dart b/lib/widgets/html/html.dart index 5ea584f..d356f6e 100644 --- a/lib/widgets/html/html.dart +++ b/lib/widgets/html/html.dart @@ -76,7 +76,7 @@ class const Html( src == null ? SizedBox.shrink() : InlineCustomWidget( - alignment: PlaceholderAlignment.middle, + alignment: .middle, child: ExpandableImage( .new(mxc: src), downloadFilename: src.pathSegments.last, diff --git a/lib/widgets/image_pack_avatar.dart b/lib/widgets/image_pack_avatar.dart new file mode 100644 index 0000000..aaebca2 --- /dev/null +++ b/lib/widgets/image_pack_avatar.dart @@ -0,0 +1,14 @@ +import "package:material_ui/material_ui.dart"; +import "package:nexus/widgets/mxc_emoji.dart"; + +class const ImagePackAvatar(final Uri? icon, {super.key}) + extends StatelessWidget { + @override + Widget build(BuildContext context) => SizedBox.square( + dimension: 40, + child: switch (icon) { + final icon? => MxcEmoji(icon), + null => Icon(Icons.emoji_emotions), + }, + ); +} diff --git a/lib/widgets/lazy_loading/message_displayname.dart b/lib/widgets/lazy_loading/message_displayname.dart index e8e4791..d7c91bd 100644 --- a/lib/widgets/lazy_loading/message_displayname.dart +++ b/lib/widgets/lazy_loading/message_displayname.dart @@ -3,7 +3,7 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/author.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/show_user_popover.dart"; -import "package:nexus/helpers/extensions/string_to_color.dart"; +import "package:nexus/helpers/extensions/displayname_color.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/content/membership.dart"; @@ -15,43 +15,44 @@ class const MessageDisplayname( super.key, }) extends ConsumerWidget { @override - Widget build(BuildContext context, WidgetRef ref) => switch (ref.watch( - AuthorController.provider(event), - )) { - AsyncData(:final MembershipContent? value) || - AsyncLoading(:final value) || - AsyncError(:final value) => InkWell( - onTap: clickable - ? () => context.showUserPopover(event.sender, roomId: event.roomId) - : null, - child: switch ([ - Text( - value?.displayName ?? event.sender.localpart, - style: - style ?? .new(color: event.sender.colorHash, fontWeight: .bold), - maxLines: 1, - overflow: .ellipsis, - ), - if (event.pmp != null) + Widget build(BuildContext context, WidgetRef ref) { + final color = ref.displaynameColor(event.roomId, event.sender); + + return switch (ref.watch(AuthorController.provider(event))) { + AsyncData(:final MembershipContent? value) || + AsyncLoading(:final value) || + AsyncError(:final value) => InkWell( + onTap: clickable + ? () => context.showUserPopover(event.sender, roomId: event.roomId) + : null, + child: switch ([ Text( - "(via ${event.sender})", - style: Theme.of(context).textTheme.labelSmall - ?.copyWith(color: event.sender.colorHash, fontWeight: .bold), + value?.displayName ?? event.sender.localpart, + style: style ?? .new(color: color, fontWeight: .bold), maxLines: 1, overflow: .ellipsis, ), - ]) { - final children when shouldWrap => Wrap( - spacing: 4, - crossAxisAlignment: .center, - children: children, - ), - final children => Row( - spacing: 4, - mainAxisSize: .min, - children: [for (final child in children) Flexible(child: child)], - ), - }, - ), - }; + if (event.pmp != null) + Text( + "(via ${event.sender})", + style: Theme.of(context).textTheme.labelSmall + ?.copyWith(color: color, fontWeight: .bold), + maxLines: 1, + overflow: .ellipsis, + ), + ]) { + final children when shouldWrap => Wrap( + spacing: 4, + crossAxisAlignment: .center, + children: children, + ), + final children => Row( + spacing: 4, + mainAxisSize: .min, + children: children.map((child) => Flexible(child: child)).toList(), + ), + }, + ), + }; + } } diff --git a/lib/widgets/member_list.dart b/lib/widgets/member_list.dart index 731270d..02cc0c6 100644 --- a/lib/widgets/member_list.dart +++ b/lib/widgets/member_list.dart @@ -8,7 +8,7 @@ import "package:nexus/controllers/members_by_status.dart"; import "package:nexus/controllers/members_grouped.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/show_user_popover.dart"; -import "package:nexus/helpers/extensions/string_to_color.dart"; +import "package:nexus/helpers/extensions/displayname_color.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/membership_status.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; @@ -108,42 +108,44 @@ class const MemberList(final String roomId, {super.key}) :final avatarUrl, :final displayName, ) => - Builder( - builder: (context) => ListTile( - onTap: () => - context.showUserPopover( + Consumer( + builder: (context, ref, _) => + ListTile( + onTap: () => + context.showUserPopover( + members[index].stateKey!, + roomId: roomId, + ), + title: Text( + displayName ?? + members[index] + .stateKey! + .localpart, + overflow: .ellipsis, + style: .new( + color: ref.displaynameColor( + roomId, + members[index].stateKey!, + ), + fontWeight: .bold, + ), + ), + subtitle: Text( members[index].stateKey!, - roomId: roomId, + overflow: .ellipsis, + ), + leading: Transform.translate( + offset: .new(0, 2), + child: AvatarOrHash( + avatarUrl, + dimension: 40, + displayName ?? + members[index] + .stateKey! + .localpart, + ), ), - title: Text( - displayName ?? - members[index] - .stateKey! - .localpart, - overflow: .ellipsis, - style: .new( - color: members[index] - .stateKey! - .colorHash, - fontWeight: .bold, ), - ), - subtitle: Text( - members[index].stateKey!, - overflow: .ellipsis, - ), - leading: Transform.translate( - offset: .new(0, 2), - child: AvatarOrHash( - avatarUrl, - dimension: 40, - displayName ?? - members[index] - .stateKey! - .localpart, - ), - ), - ), ), _ => throw Exception( "Member content was not MembershipContent", diff --git a/lib/widgets/message_image.dart b/lib/widgets/message_image.dart index 969dcd3..70aedd9 100644 --- a/lib/widgets/message_image.dart +++ b/lib/widgets/message_image.dart @@ -27,7 +27,7 @@ final class const MessageImage( child: Image( image: MxcImage(ref, request), width: info?.width?.toDouble(), - fit: BoxFit.fitWidth, + fit: .fitWidth, loadingBuilder: (_, child, loadingProgress) => loadingProgress == null ? child diff --git a/lib/widgets/mxc_emoji.dart b/lib/widgets/mxc_emoji.dart new file mode 100644 index 0000000..e8c4916 --- /dev/null +++ b/lib/widgets/mxc_emoji.dart @@ -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), + ); +} diff --git a/lib/widgets/notifications_list.dart b/lib/widgets/notifications_list.dart index 0b0741f..23ff676 100644 --- a/lib/widgets/notifications_list.dart +++ b/lib/widgets/notifications_list.dart @@ -151,7 +151,7 @@ class const NotificationsList({super.key}) listController: listController.value, controller: scrollController, itemCount: value.length, - padding: const EdgeInsets.symmetric( + padding: const .symmetric( horizontal: 8, vertical: 12, ), diff --git a/lib/widgets/pages/select_server.dart b/lib/widgets/pages/select_server.dart index 66c40a5..4a35a6c 100644 --- a/lib/widgets/pages/select_server.dart +++ b/lib/widgets/pages/select_server.dart @@ -60,6 +60,8 @@ class const SelectServerPage({super.key}) extends HookConsumerWidget { return; } + if (!context.mounted) return; + await ref.read(LaunchHelper.provider).launchUrl(codeResponse.url); AppLinks().uriLinkStream.listen((encodedUri) async { @@ -70,7 +72,7 @@ class const SelectServerPage({super.key}) extends HookConsumerWidget { try { final code = encodedUri.queryParameters["code"]; - if (code != null) { + if (code != null && context.mounted) { await ref .read(ClientController.provider.notifier) .exchangeToken( diff --git a/lib/widgets/pages/verify.dart b/lib/widgets/pages/verify.dart index 76bd94d..4858f8c 100644 --- a/lib/widgets/pages/verify.dart +++ b/lib/widgets/pages/verify.dart @@ -18,6 +18,7 @@ class const VerifyPage({super.key}) extends HookConsumerWidget { final isLoading = useState(false); final inputError = useState(null); final formKey = useRef(GlobalKey()); + final verificationState = ref.watch( ClientStateController.provider.select( (state) => state?.verificationState, @@ -25,18 +26,24 @@ class const VerifyPage({super.key}) extends HookConsumerWidget { ); Future verify() async { + if (isLoading.value) return; + + if (formKey.value.currentState?.validate() != true) { + return; + } + isLoading.value = true; try { - if (formKey.value.currentState?.validate() != true) { - return; - } - - inputError.value = await ref + await ref .read(ClientController.provider.notifier) .verify(passphraseController.text); + } catch (error) { + inputError.value = error.toString(); } finally { - isLoading.value = false; + if (context.mounted) { + isLoading.value = false; + } } } @@ -52,7 +59,8 @@ class const VerifyPage({super.key}) extends HookConsumerWidget { ), body: switch (verificationState) { null || VerificationState(stateChecked: false) => const Loading(), - // Nothing to verify with yet, so go straight to setting it up. + + // Nothing to verify yet, so go straight to setup. VerificationState(hasSecretStorage: false) => Dialog( insetPadding: const .symmetric(vertical: 12, horizontal: 32), child: ConstrainedBox( @@ -60,6 +68,7 @@ class const VerifyPage({super.key}) extends HookConsumerWidget { child: const ResetEncryptionSheet(isPage: true), ), ), + _ => AlertDialog( insetPadding: .symmetric(vertical: 12, horizontal: 32), scrollable: true, @@ -71,7 +80,9 @@ class const VerifyPage({super.key}) extends HookConsumerWidget { crossAxisAlignment: .start, children: [ Text( - "Enter your recovery key or passphrase below to unlock encrypted events.\nYour passphrase is usually not the same as your password.", + "Enter your recovery key or passphrase below to " + "unlock encrypted events.\nYour passphrase is usually " + "not the same as your password.", ), SizedBox(height: 12), TextFormField( @@ -94,9 +105,13 @@ class const VerifyPage({super.key}) extends HookConsumerWidget { ), actions: [ TextButton( - onPressed: () => context.showAdaptiveSheet( - (_) => const ResetEncryptionSheet(), - ), + onPressed: isLoading.value + ? null + : () { + context.showAdaptiveSheet( + (_) => const ResetEncryptionSheet(), + ); + }, style: TextButton.styleFrom( foregroundColor: Theme.of(context).colorScheme.error, ), diff --git a/lib/widgets/renderers/change_line.dart b/lib/widgets/renderers/change_line.dart new file mode 100644 index 0000000..7195882 --- /dev/null +++ b/lib/widgets/renderers/change_line.dart @@ -0,0 +1,72 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:material_ui/material_ui.dart"; + +/// A line of what a state event changed, like a room's old name, struck +/// through, then its new one. +class const ChangeLine({ + /// What changed, e.g. `Denied`, in bold before the items. + final String? label, + final IList removed = const .empty(), + final IList added = const .empty(), + required final Color removedColor, + required final Color addedColor, + + /// Whether to quote the items, like server names, but not whole sentences. + final bool quoted = true, + + /// Shown after the items, like a change that isn't one of them. + final List trailing = const [], + + /// How many removed and how many added items to show, as some events, like + /// a room's first server ACL, can change hundreds. + final int maxShown = 5, + + /// The most lines each item can take, as some, like topics, can be long. + final int maxLines = 2, + super.key, +}) extends StatelessWidget { + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final style = theme.textTheme.labelMedium; + + Iterable items( + IList items, { + required Color color, + required bool struck, + }) => [ + ...items + .take(maxShown) + .map( + (item) => Text( + quoted ? "\"$item\"" : item, + maxLines: maxLines, + overflow: .ellipsis, + style: style?.copyWith( + color: color, + decoration: struck ? .lineThrough : null, + decorationColor: color, + ), + ), + ), + if (items.length > maxShown) + Text( + "+${items.length - maxShown} more", + style: style?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ]; + + return Wrap( + spacing: 8, + runSpacing: 2, + crossAxisAlignment: .center, + children: [ + if (label case final label?) + Text(label, style: style?.copyWith(fontWeight: .bold)), + ...items(removed, color: removedColor, struck: true), + ...items(added, color: addedColor, struck: false), + ...trailing, + ], + ); + } +} diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index 47750fa..49c7b19 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -9,28 +9,37 @@ import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/settings.dart"; import "package:nexus/helpers/extensions/is_shown_as_message.dart"; import "package:nexus/helpers/extensions/show_adaptive_menu.dart"; +import "package:nexus/helpers/extensions/success_scheme.dart"; import "package:nexus/helpers/hooks/chat_scroll.dart"; +import "package:nexus/helpers/hooks/tap_recognizer.dart"; import "package:nexus/models/content/avatar.dart"; import "package:nexus/models/content/canonical_alias.dart"; import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/create.dart"; +import "package:nexus/models/content/encryption.dart"; import "package:nexus/models/content/history_visibility.dart"; import "package:nexus/models/content/join_rules.dart"; import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/content/name.dart"; import "package:nexus/models/content/pinned_events.dart"; import "package:nexus/models/content/power_levels.dart"; import "package:nexus/models/content/server_acl.dart"; import "package:nexus/models/content/topic.dart"; import "package:nexus/models/event.dart"; import "package:nexus/widgets/error_dialog.dart"; -import "package:nexus/widgets/lazy_loading/message_displayname.dart"; import "package:nexus/widgets/renderers/message.dart"; import "package:nexus/widgets/reaction_row.dart"; import "package:nexus/widgets/read_receipts_row.dart"; import "package:nexus/widgets/renderers/membership.dart"; +import "package:nexus/widgets/renderers/change_line.dart"; import "package:nexus/widgets/renderers/generic_event.dart"; +import "package:nexus/widgets/renderers/join_rules_diff.dart"; +import "package:nexus/widgets/renderers/power_levels_diff.dart"; +import "package:nexus/widgets/renderers/pinned_events_diff.dart"; +import "package:nexus/widgets/renderers/server_acl_diff.dart"; import "package:nexus/widgets/swipe_to_reply.dart"; import "package:nexus/widgets/timestamp.dart"; +import "package:nexus/widgets/user_span.dart"; class const EventRenderer( final int eventRowId, { @@ -56,6 +65,7 @@ class const EventRenderer( final errorStyle = TextStyle(color: colorScheme.error); final focusNode = useFocusNode(); useListenable(focusNode); + final senderRecognizer = useTapRecognizer(); final (event, previousEvent) = ref.watch( RoomsController.provider.select((rooms) { @@ -82,12 +92,13 @@ class const EventRenderer( if (event == null) return SizedBox.shrink(); - final hideMembershipEvents = (ref + final (hideMembershipEvents, hideServerAclEvents) = ref .watch(SettingsController.provider) .maybeWhen( - orElse: () => false, - data: (settings) => settings.hideMembershipEvents, - )); + orElse: () => (false, true), + data: (settings) => + (settings.hideMembershipEvents, settings.hideServerAclEvents), + ); // Knocks and their outcomes stay visible, as moderators need to act on them if (hideMembershipEvents && @@ -98,19 +109,33 @@ class const EventRenderer( return SizedBox.shrink(); } + if (hideServerAclEvents && event.content is ServerACLContent) { + return SizedBox.shrink(); + } + final isGrouped = (previousEvent?.isShownAsMessage ?? false) && event.sender == previousEvent?.sender && event.pmp?.id == previousEvent?.pmp?.id; - GenericEventRenderer generic(IconData icon, String text) => - .new(icon, maxLines: maxLines, [ - WidgetSpan( - alignment: .middle, - child: MessageDisplayname(event, shouldWrap: maxLines == null), - ), - TextSpan(text: text), - ]); + // For what a state event changed, from and to + final removedColor = colorScheme.error; + final addedColor = theme.successScheme.primary; + + GenericEventRenderer generic( + IconData icon, + String text, { + List details = const [], + }) => .new( + icon, + maxLines: maxLines, + details: details, + trailing: Timestamp(event.timestamp), + [ + UserSpan.sender(context, ref, event, recognizer: senderRecognizer), + TextSpan(text: text), + ], + ); final child = event.isShownAsMessage ? MessageRenderer( @@ -156,18 +181,57 @@ class const EventRenderer( "changed the room avatar", ), CreateContent() => generic(Icons.add, "created the room"), - PowerLevelsContent() => generic( + final PowerLevelsContent content => generic( Icons.power, "changed the room's power levels", + details: [ + PowerLevelsDiff( + roomId, + content, + previous: switch (event.previousContent) { + final PowerLevelsContent previous => previous, + _ => null, + }, + ), + ], ), - JoinRulesContent() => generic( + final JoinRulesContent content => generic( Icons.rule, "changed the room's join rules", + details: [ + JoinRulesDiff( + content, + previous: switch (event.previousContent) { + final JoinRulesContent previous => previous, + _ => null, + }, + ), + ], ), - TopicContent() => generic( - Icons.description, - "updated the room topic", - ), + TopicContent(:final topic) => () { + final previous = switch (event.previousContent) { + TopicContent(:final topic) when topic.isNotEmpty => topic, + _ => null, + }; + + return generic( + Icons.description, + topic.isEmpty + ? "removed the room topic" + : previous == null + ? "set the room topic" + : "changed the room topic", + details: [ + ChangeLine( + removed: [?previous].lock, + added: [if (topic.isNotEmpty) topic].lock, + removedColor: removedColor, + addedColor: addedColor, + quoted: false, + ), + ], + ); + }(), HistoryVisibilityContent(:final historyVisibility) => generic( Icons.history, "changed the room's history visibility to ${switch (historyVisibility) { @@ -175,42 +239,116 @@ class const EventRenderer( .joined => "since joined", .shared => "all history visible (shared)", .worldReadable => "all history visible (world readable)", + .unknown => "something Nexus doesn't know about", }}", ), - PinnedEventsContent() => generic( - Icons.push_pin, - "pinned/unpinned some events", - ), - ServerACLContent() => generic( + PinnedEventsContent(:final pinnedEvents) => () { + final before = switch (event.previousContent) { + PinnedEventsContent(:final pinnedEvents) => pinnedEvents, + _ => const IList.empty(), + }; + final pinned = pinnedEvents + .where((id) => !before.contains(id)) + .toIList(); + final unpinned = before + .where((id) => !pinnedEvents.contains(id)) + .toIList(); + + return generic( + Icons.push_pin, + switch ((pinned.length, unpinned.length)) { + (1, 0) => "pinned a message", + (0, 1) => "unpinned a message", + _ => "changed the pinned messages", + }, + details: [ + PinnedEventsDiff( + roomId, + pinned: pinned, + unpinned: unpinned, + jumpToEvent: jumpToEvent, + ), + ], + ); + }(), + final ServerACLContent content => generic( Icons.list, "updated the server ban list", + details: [ + ServerAclDiff( + content, + previous: switch (event.previousContent) { + final ServerACLContent previous => previous, + _ => null, + }, + ), + ], ), - CanonicalAliasContent(:final alias, :final altAliases) => generic( - Icons.numbers, - switch ([ - if (event.previousContent case CanonicalAliasContent( - alias: final prevAlias, - altAliases: final prevAltAliases, - )) ...[ - if (prevAlias != alias) - if (alias == null) - "removed the room's canonical alias" - else - "changed the room's canonical alias to $alias", + CanonicalAliasContent(:final alias, :final altAliases) => () { + final previous = switch (event.previousContent) { + final CanonicalAliasContent previous => previous, + _ => null, + }; + final previousAltAliases = + previous?.altAliases ?? const ISet.empty(); - if (prevAltAliases - .remove(alias ?? "") - .remove(prevAlias ?? "") != - altAliases.remove(alias ?? "").remove(prevAlias ?? "")) - "changed the room's aliases", - ] else ...[ - if (alias != null) "set the room's canonical alias", - if (altAliases.isNotEmpty) "set the room's aliases", + return generic( + Icons.numbers, + previous == null + ? "set the room's aliases" + : "changed the room's aliases", + details: [ + if (previous?.alias != alias) + ChangeLine( + label: "Canonical alias", + removed: [?previous?.alias].lock, + added: [?alias].lock, + removedColor: removedColor, + addedColor: addedColor, + ), + if (previousAltAliases != altAliases) + ChangeLine( + label: "Aliases", + removed: previousAltAliases + .difference(altAliases) + .toIList(), + added: altAliases + .difference(previousAltAliases) + .toIList(), + removedColor: removedColor, + addedColor: addedColor, + ), ], - ]) { - [] => "did something related to room aliases", - List prev => prev.join(" and "), - }, + ); + }(), + NameContent(:final name) => () { + final previous = switch (event.previousContent) { + NameContent(:final name) when name.isNotEmpty => name, + _ => null, + }; + + return generic( + Icons.title, + name.isEmpty + ? "removed the room name" + : previous == null + ? "set the room name" + : "changed the room name", + details: [ + ChangeLine( + removed: [?previous].lock, + added: [if (name.isNotEmpty) name].lock, + removedColor: removedColor, + addedColor: addedColor, + ), + ], + ); + }(), + EncryptionContent() => generic( + Icons.lock, + event.previousContent is EncryptionContent + ? "changed the room's encryption settings" + : "enabled encryption", ), _ => null, }; @@ -270,7 +408,8 @@ class const EventRenderer( crossAxisAlignment: .center, children: [ child, - if (child is! MessageRenderer) + if (child is! MessageRenderer && + child is! GenericEventRenderer) Timestamp(event.timestamp), ], ), diff --git a/lib/widgets/renderers/generic_event.dart b/lib/widgets/renderers/generic_event.dart index 101913c..3922aed 100644 --- a/lib/widgets/renderers/generic_event.dart +++ b/lib/widgets/renderers/generic_event.dart @@ -5,33 +5,68 @@ class const GenericEventRenderer( final IconData icon, final List children, { final int? maxLines, + + /// Shown at the end of the text, like the event's timestamp. + final Widget? trailing, + + /// Shown under the text, like what changed in a state event. + final List details = const [], super.key, }) extends StatelessWidget { @override - Widget build(BuildContext context) => Padding( - padding: .symmetric(vertical: 4), - child: Row( - spacing: 8, - mainAxisSize: .min, - children: [ - Padding(padding: .symmetric(horizontal: 4), child: Icon(icon)), - Flexible( - child: Text.rich( - TextSpan( - children: children - .expandIndexed( - (index, child) => [ - if (index > 0) TextSpan(text: " "), - child, + Widget build(BuildContext context) { + // Only previewing the event, so it should stay short + final previewing = maxLines != null; + final iconSize = IconTheme.of(context).size ?? 24; + + return Padding( + padding: .symmetric(vertical: 4), + child: Row( + spacing: 8, + mainAxisSize: .min, + // At the top, by the text, rather than the middle of any details + crossAxisAlignment: .start, + children: [ + Padding(padding: .symmetric(horizontal: 4), child: Icon(icon)), + Flexible( + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + spacing: 4, + children: [ + // At least as tall as the icon, so they're centered together + ConstrainedBox( + constraints: .new(minHeight: iconSize), + child: Row( + spacing: 8, + mainAxisSize: .min, + children: [ + Flexible( + child: Text.rich( + TextSpan( + children: children + .expandIndexed( + (index, child) => [ + if (index > 0) TextSpan(text: " "), + child, + ], + ) + .toList(), + ), + maxLines: maxLines ?? 999, + overflow: .ellipsis, + ), + ), + if (!previewing) ?trailing, ], - ) - .toList(), + ), + ), + if (!previewing) ...details, + ], ), - maxLines: maxLines ?? 999, - overflow: .ellipsis, ), - ), - ], - ), - ); + ], + ), + ); + } } diff --git a/lib/widgets/renderers/join_rules_diff.dart b/lib/widgets/renderers/join_rules_diff.dart new file mode 100644 index 0000000..6d41c0b --- /dev/null +++ b/lib/widgets/renderers/join_rules_diff.dart @@ -0,0 +1,72 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/helpers/extensions/join_rule_name.dart"; +import "package:nexus/helpers/extensions/success_scheme.dart"; +import "package:nexus/models/content/join_rules.dart"; +import "package:nexus/widgets/renderers/change_line.dart"; + +/// The join rule a join rules event changed to, and for restricted rooms, the +/// rooms whose members it let join, or stopped letting join. +class const JoinRulesDiff( + final JoinRulesContent content, { + final JoinRulesContent? previous, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final removedColor = theme.colorScheme.error; + final addedColor = theme.successScheme.primary; + + ISet roomIdsOf(JoinRulesContent? content) => + (content?.allow ?? const ISet.empty()) + .map((condition) => condition.roomId) + .nonNulls + .toISet(); + final before = roomIdsOf(previous); + final after = roomIdsOf(content); + final removed = before.difference(after); + final added = after.difference(before); + + // Rooms the user isn't in are shown by ID + final names = ref.watch( + RoomsController.provider.select( + (rooms) => IMap.fromEntries( + removed + .addAll(added) + .map( + (roomId) => + MapEntry(roomId, rooms[roomId]?.metadata?.name ?? roomId), + ), + ), + ), + ); + IList named(ISet roomIds) => + roomIds.map((roomId) => names[roomId] ?? roomId).toIList(); + + return Column( + crossAxisAlignment: .start, + spacing: 2, + children: [ + if (previous?.joinRule != content.joinRule) + ChangeLine( + removed: [?previous?.joinRule.displayName].lock, + added: [content.joinRule.displayName].lock, + removedColor: removedColor, + addedColor: addedColor, + quoted: false, + ), + if (removed.isNotEmpty || added.isNotEmpty) + ChangeLine( + label: "Members of", + removed: named(removed), + added: named(added), + removedColor: removedColor, + addedColor: addedColor, + ), + ], + ); + } +} diff --git a/lib/widgets/renderers/membership.dart b/lib/widgets/renderers/membership.dart index b1811f0..17f8128 100644 --- a/lib/widgets/renderers/membership.dart +++ b/lib/widgets/renderers/membership.dart @@ -1,24 +1,27 @@ +import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/displayname_color.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; -import "package:nexus/helpers/extensions/show_user_popover.dart"; -import "package:nexus/helpers/extensions/string_to_color.dart"; +import "package:nexus/helpers/hooks/tap_recognizer.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/event.dart"; -import "package:nexus/widgets/lazy_loading/message_displayname.dart"; import "package:nexus/widgets/renderers/generic_event.dart"; +import "package:nexus/widgets/user_span.dart"; class const MembershipRenderer( final Event event, { final int? maxLines, super.key, -}) extends StatelessWidget { +}) extends HookConsumerWidget { @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { assert( event.content is MembershipContent, "Make sure to only pass membership events to MembershipRenderer", ); + final targetRecognizer = useTapRecognizer(); + final senderRecognizer = useTapRecognizer(); final previous = switch (event.previousContent) { MembershipContent(:final status) => status, _ => null, @@ -29,19 +32,15 @@ class const MembershipRenderer( Icons.people, maxLines: maxLines, [ - WidgetSpan( - alignment: .middle, - child: InkWell( - onTap: () => context.showUserPopover( - event.stateKey!, - roomId: event.roomId, - ), - child: Text( - overflow: .ellipsis, - content.displayName ?? event.stateKey!.localpart, - maxLines: 1, - style: .new(color: event.sender.colorHash, fontWeight: .bold), - ), + UserSpan( + context, + content.displayName ?? event.stateKey!.localpart, + userId: event.stateKey!, + roomId: event.roomId, + recognizer: targetRecognizer, + style: .new( + color: ref.displaynameColor(event.roomId, event.stateKey!), + fontWeight: .bold, ), ), TextSpan( @@ -53,13 +52,11 @@ class const MembershipRenderer( .leave => event.sender == event.stateKey ? "left" : (previous == .ban ? "was unbanned from" : "was kicked from"), .ban => "was banned from", .knock => "asked to join", + .unknown => "changed their membership in", }} the room${event.sender == event.stateKey ? "" : " by"}", ), if (event.sender != event.stateKey) - WidgetSpan( - alignment: .middle, - child: MessageDisplayname(event, shouldWrap: maxLines == null), - ), + UserSpan.sender(context, ref, event, recognizer: senderRecognizer), if (content.reason != null) TextSpan(text: "for \"${content.reason}\""), ], diff --git a/lib/widgets/renderers/message.dart b/lib/widgets/renderers/message.dart index 598e9f9..b380577 100644 --- a/lib/widgets/renderers/message.dart +++ b/lib/widgets/renderers/message.dart @@ -73,11 +73,11 @@ class const MessageRenderer( ) => Column( crossAxisAlignment: .start, children: [ - format == .html && !textOnly + format == .html && formattedBody != null && !textOnly ? Html( roomId: event.roomId, textStyle: textStyle, - formattedBody!.replaceAllMapped( + formattedBody.replaceAllMapped( RegExp( r"(]*>.*?<\/a>)|(\bhttps?:\/\/[^\s<]+)", caseSensitive: false, diff --git a/lib/widgets/renderers/pinned_events_diff.dart b/lib/widgets/renderers/pinned_events_diff.dart new file mode 100644 index 0000000..9f3d34c --- /dev/null +++ b/lib/widgets/renderers/pinned_events_diff.dart @@ -0,0 +1,111 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/event.dart"; +import "package:nexus/helpers/hooks/chat_scroll.dart"; +import "package:nexus/helpers/extensions/success_scheme.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/widgets/lazy_loading/message_displayname.dart"; +import "package:nexus/widgets/renderers/event.dart"; + +/// A line for each message a pinned events event pinned, in green, and each one +/// it unpinned, in red and struck through. +class const PinnedEventsDiff( + final String roomId, { + required final IList pinned, + required final IList unpinned, + + /// If set, tapping a message jumps to it. + final JumpToEvent? jumpToEvent, + super.key, +}) extends ConsumerWidget { + static const maxShown = 5; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final style = theme.textTheme.labelMedium; + final changes = [ + ...pinned.map((eventId) => (eventId, unpinned: false)), + ...unpinned.map((eventId) => (eventId, unpinned: true)), + ]; + + return Column( + crossAxisAlignment: .start, + spacing: 2, + children: [ + ...changes.take(maxShown).map((change) { + final color = change.unpinned + ? theme.colorScheme.error + : theme.successScheme.primary; + final decoration = change.unpinned + ? TextDecoration.lineThrough + : null; + + return DefaultTextStyle.merge( + style: style?.copyWith( + color: color, + decoration: decoration, + decorationColor: color, + ), + child: switch (ref.watch( + EventController.provider( + .new(roomId: roomId, eventId: change.$1), + ), + )) { + // Keeps showing the event while it reloads + AsyncValue(value: final event?) => InkWell( + borderRadius: .circular(4), + hoverColor: color.withValues(alpha: 0.12), + onTap: jumpToEvent == null + ? null + : () => jumpToEvent!(event.rowId), + child: Row( + mainAxisSize: .min, + children: [ + // Other events already say who sent them + if (event.content is MessageContent) ...[ + MessageDisplayname( + event, + clickable: false, + shouldWrap: false, + style: .new( + color: color, + fontWeight: .bold, + decoration: decoration, + decorationColor: color, + ), + ), + Text(": "), + ], + Flexible( + child: IgnorePointer( + child: EventRenderer( + event.rowId, + roomId: event.roomId, + textOnly: true, + maxLines: 1, + ), + ), + ), + ], + ), + ), + AsyncLoading() => SizedBox.square( + dimension: 12, + child: CircularProgressIndicator(strokeWidth: 2, color: color), + ), + // Like if it was deleted, or the server won't share it + _ => Text("Message not found"), + }, + ); + }), + if (changes.length > maxShown) + Text( + "+${changes.length - maxShown} more", + style: style?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ], + ); + } +} diff --git a/lib/widgets/renderers/power_levels_diff.dart b/lib/widgets/renderers/power_levels_diff.dart new file mode 100644 index 0000000..f90c917 --- /dev/null +++ b/lib/widgets/renderers/power_levels_diff.dart @@ -0,0 +1,141 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/user.dart"; +import "package:nexus/helpers/extensions/displayname_color.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/success_scheme.dart"; +import "package:nexus/models/content/power_levels.dart"; +import "package:nexus/models/power_level_permission.dart"; + +/// Whose power levels, and which permissions' levels, a power levels event +/// changed, in green when raised, and red when lowered. +class const PowerLevelsDiff( + final String roomId, + final PowerLevelsContent content, { + final PowerLevelsContent? previous, + super.key, +}) extends ConsumerWidget { + static const maxShown = 5; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final style = theme.textTheme.labelMedium; + final before = previous ?? PowerLevelsContent(); + + Text levels(int? from, int? to, {int? fromLevel, int? toLevel}) => Text( + "${from ?? "Default"} → ${to ?? "Default"}", + style: style?.copyWith( + color: switch ((fromLevel ?? from, toLevel ?? to)) { + (final from?, final to?) when to > from => + theme.successScheme.primary, + (final from?, final to?) when to < from => theme.colorScheme.error, + _ => theme.colorScheme.onSurfaceVariant, + }, + ), + ); + + Iterable capped(Iterable lines) { + final list = lines.toList(); + return [ + ...list.take(maxShown), + if (list.length > maxShown) + Text( + "+${list.length - maxShown} more", + style: style?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ]; + } + + final users = before.users.keys + .toISet() + .addAll(content.users.keys) + .map( + (userId) => ( + userId, + from: before.users[userId], + to: content.users[userId], + fromLevel: before.users[userId] ?? before.usersDefault, + toLevel: content.users[userId] ?? content.usersDefault, + ), + ) + .where((change) => change.from != change.to) + // The most powerful first + .toIList() + .sort((a, b) => b.toLevel.compareTo(a.toLevel)); + + final permissions = <(String, int?, int?)>[ + ...PowerLevelPermission.values.map( + (permission) => ( + permission.title, + permission.levelIn(before), + permission.levelIn(content), + ), + ), + ...before.events.keys + .toISet() + .addAll(content.events.keys) + .map( + (type) => ( + PowerLevelEvent.of(type)?.title ?? type, + before.events[type], + content.events[type], + ), + ), + ].where((permission) => permission.$2 != permission.$3); + + return Column( + crossAxisAlignment: .start, + spacing: 2, + children: [ + ...capped( + users.map( + (change) => Wrap( + spacing: 8, + crossAxisAlignment: .center, + children: [ + Text( + ref + .watch( + UserController.provider( + .new(roomId: roomId, userId: change.$1), + ), + ) + .value + ?.displayName ?? + change.$1.localpart, + style: style?.copyWith( + fontWeight: .bold, + color: ref.displaynameColorAt( + change.$1, + change.toLevel.toDouble(), + ), + ), + ), + levels( + change.from, + change.to, + fromLevel: change.fromLevel, + toLevel: change.toLevel, + ), + ], + ), + ), + ), + ...capped( + permissions.map( + (permission) => Wrap( + spacing: 8, + crossAxisAlignment: .center, + children: [ + Text(permission.$1, style: style?.copyWith(fontWeight: .bold)), + levels(permission.$2, permission.$3), + ], + ), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/renderers/server_acl_diff.dart b/lib/widgets/renderers/server_acl_diff.dart new file mode 100644 index 0000000..e232614 --- /dev/null +++ b/lib/widgets/renderers/server_acl_diff.dart @@ -0,0 +1,76 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/success_scheme.dart"; +import "package:nexus/models/content/server_acl.dart"; +import "package:nexus/widgets/renderers/change_line.dart"; + +/// The servers newly denied (in red) and allowed (in green) by a server ACL +/// event, and those no longer denied or allowed, struck through. +class const ServerAclDiff( + final ServerACLContent content, { + final ServerACLContent? previous, + super.key, +}) extends StatelessWidget { + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + final ipLiteralsAllowed = switch (previous) { + final previous? + when previous.allowIpLiterals != content.allowIpLiterals => + content.allowIpLiterals == true, + _ => null, + }; + + Widget? listDiff( + String label, + Color color, + IList before, + IList after, { + required bool ipLiterals, + }) { + // Sets, as an ACL can list hundreds of servers + final beforeSet = before.toISet(); + final afterSet = after.toISet(); + final removed = before.where((server) => !afterSet.contains(server)); + final added = after.where((server) => !beforeSet.contains(server)); + if (removed.isEmpty && added.isEmpty && !ipLiterals) return null; + + return ChangeLine( + label: label, + removed: .new(removed), + added: .new(added), + removedColor: color, + addedColor: color, + trailing: [ + if (ipLiterals) + Text( + "servers using IP addresses", + style: theme.textTheme.labelMedium?.copyWith(color: color), + ), + ], + ); + } + + return Column( + crossAxisAlignment: .start, + spacing: 2, + children: [ + ?listDiff( + "Denied", + theme.colorScheme.error, + previous?.deny ?? const .empty(), + content.deny, + ipLiterals: ipLiteralsAllowed == false, + ), + ?listDiff( + "Allowed", + theme.successScheme.primary, + previous?.allow ?? const .empty(), + content.allow, + ipLiterals: ipLiteralsAllowed == true, + ), + ], + ); + } +} diff --git a/lib/widgets/room_chat/room_chat.dart b/lib/widgets/room_chat/room_chat.dart index e7fd6e0..db7120c 100644 --- a/lib/widgets/room_chat/room_chat.dart +++ b/lib/widgets/room_chat/room_chat.dart @@ -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: () { diff --git a/lib/widgets/room_menu_button.dart b/lib/widgets/room_menu_button.dart index 38ad847..358c8d8 100644 --- a/lib/widgets/room_menu_button.dart +++ b/lib/widgets/room_menu_button.dart @@ -146,7 +146,7 @@ final class const RoomMenuButton( ), child: ListTile( leading: Icon(Icons.logout, color: danger), - title: Text("Leave", style: TextStyle(color: danger)), + title: Text("Leave", style: .new(color: danger)), ), ), ], diff --git a/lib/widgets/settings/dialog_list_tile.dart b/lib/widgets/settings/dialog_list_tile.dart index b31182f..f884f31 100644 --- a/lib/widgets/settings/dialog_list_tile.dart +++ b/lib/widgets/settings/dialog_list_tile.dart @@ -42,7 +42,7 @@ final class const DialogListTile({ child: Chip( label: Text( value is T ? getName(value as T) : "None", - overflow: TextOverflow.ellipsis, + overflow: .ellipsis, style: onChanged == null || saving.active ? .new(color: Colors.grey) : null, diff --git a/lib/widgets/settings/enable_image_packs_sheet.dart b/lib/widgets/settings/enable_image_packs_sheet.dart new file mode 100644 index 0000000..afd983b --- /dev/null +++ b/lib/widgets/settings/enable_image_packs_sheet.dart @@ -0,0 +1,118 @@ +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/room_image_packs.dart"; +import "package:nexus/controllers/rooms.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/models/account_data.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/image_pack_avatar.dart"; +import "package:nexus/widgets/sheet_layout.dart"; + +/// Chooses a room, then which of its image packs to use in every room. +class const EnableImagePacksSheet({ + required final ImagePackRooms enabled, + required final void Function(ImagePackRooms enabled) onSave, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final chosen = useState(enabled); + final roomId = useState(null); + final rooms = ref.watch( + RoomsController.provider.select( + (rooms) => rooms.values + .map((room) => room.metadata) + .nonNulls + .map( + (metadata) => ( + id: metadata.id, + name: metadata.name ?? metadata.id, + avatar: metadata.avatar, + ), + ) + .sortedBy((room) => room.name.toLowerCase()) + .toIList(), + ), + ); + + return SheetLayout( + title: Text("Add Image Packs"), + actions: [ + TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + onSave(chosen.value); + }, + child: Text("Save"), + ), + ], + children: [ + Text("Packs you add can be used in every room."), + DropdownMenu( + expandedInsets: .zero, + menuHeight: 300, + enableFilter: true, + requestFocusOnTap: true, + label: Text("Room"), + onSelected: (id) => roomId.value = id, + dropdownMenuEntries: rooms + .map( + (room) => DropdownMenuEntry( + value: room.id, + label: room.name, + leadingIcon: AvatarOrHash( + room.avatar, + room.name, + fallback: Icon(Icons.numbers), + ), + ), + ) + .toList(), + ), + AnimatedSize( + duration: Durations.medium2, + curve: Curves.easeInOut, + alignment: .topCenter, + child: switch (roomId.value) { + final roomId? => + ref + .watch(RoomImagePacksController.provider(roomId)) + .betterWhen( + data: (packs) => packs.isEmpty + ? Text("This room has no image packs") + : Column( + mainAxisSize: .min, + children: packs.entries + .map( + (entry) => CheckboxListTile( + secondary: ImagePackAvatar( + entry.value.icon, + ), + title: Text(entry.value.name), + subtitle: Text(entry.value.imageCountLabel), + value: chosen.value.contains( + roomId, + entry.key, + ), + onChanged: (value) => + chosen.value = chosen.value.withPack( + roomId, + entry.key, + enabled: value ?? false, + ), + ), + ) + .toList(), + ), + ), + null => const SizedBox(width: .infinity), + }, + ), + ], + ); + } +} diff --git a/lib/widgets/settings/image_pack_sheet.dart b/lib/widgets/settings/image_pack_sheet.dart new file mode 100644 index 0000000..97f8599 --- /dev/null +++ b/lib/widgets/settings/image_pack_sheet.dart @@ -0,0 +1,250 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; +import "package:nexus/helpers/file_dialog_helper.dart"; +import "package:nexus/helpers/hooks/saving.dart"; +import "package:nexus/main.dart"; +import "package:nexus/models/content/image_pack.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/widgets/image_pack_avatar.dart"; +import "package:nexus/widgets/mxc_emoji.dart"; +import "package:nexus/widgets/settings/image_usage_chips.dart"; +import "package:nexus/widgets/settings/pack_image_sheet.dart"; +import "package:nexus/widgets/sheet_layout.dart"; + +/// Edits an image pack, or only shows it if [onSave] is null. +class const ImagePackSheet({ + required final String title, + required final ImagePackContent content, + required final void Function(ImagePackContent content)? onSave, + final VoidCallback? onDeleted, + + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final editable = onSave != null; + final nameController = useTextEditingController( + text: content.pack?.displayName, + ); + final name = useValueListenable(nameController).text.trim(); + final avatar = useState(content.pack?.avatarUrl); + final usage = useState(content.pack?.usage ?? const ISet.empty()); + final images = useState(content.images); + final uploading = Saving.use(); + final packUsage = usage.value.isEmpty + ? ISet(ImageUsage.values) + : usage.value; + final valid = !uploading.active && name.isNotEmpty; + + Future upload( + void Function(String filename, PackImage image) onUploaded, + ) async { + final file = await ref + .read(FileDialogHelper.provider) + .pickFile(images: true); + if (file == null || !context.mounted) return; + + await uploading.run(() async { + final content = await ref + .read(ClientController.provider.notifier) + .uploadMedia(.new(path: file.path, encrypt: false)); + if (!context.mounted) return; + + if (content case ImageMessageContent(:final url, :final info)) { + onUploaded(file.name, PackImage(url: url, info: info)); + } else { + throw Exception("The file isn't an image"); + } + }); + } + + void editImage(String shortcode, PackImage image, {bool isNew = false}) => + context.showAdaptiveSheet( + (_) => PackImageSheet( + title: isNew + ? "Add Image" + : editable + ? "Edit Image" + : ":$shortcode:", + shortcode: shortcode, + image: image, + taken: isNew + ? images.value.keys.toISet() + : images.value.keys.toISet().remove(shortcode), + packUsage: packUsage, + onSave: editable + ? (newShortcode, newImage) => images.value = isNew + ? images.value.add(newShortcode, newImage) + // Replaced in place, to keep the images' order + : .fromEntries( + images.value.entries.map( + (entry) => entry.key == shortcode + ? MapEntry(newShortcode, newImage) + : entry, + ), + ) + : null, + onRemoved: editable && !isNew + ? () => images.value = images.value.remove(shortcode) + : null, + ), + ); + + Future delete() async { + final confirmed = await context.showAdaptiveSheet( + (context) => SheetLayout( + title: Text("Delete Pack"), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text("Cancel"), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + child: Text("Delete"), + ), + ], + children: [ + Text( + "Delete this pack? Its images will no longer be available here, or to anyone using it in other rooms.", + ), + ], + ), + ); + if (confirmed != true || !context.mounted) return; + + Navigator.of(context).pop(); + onDeleted?.call(); + } + + void save() { + Navigator.of(context).pop(); + onSave!( + content.copyWith( + images: images.value, + pack: (content.pack ?? const PackInfo()).copyWith( + displayName: name, + avatarUrl: avatar.value, + usage: usage.value, + ), + ), + ); + } + + final textTheme = Theme.of(context).textTheme; + + return SheetLayout( + title: Text(title), + actions: [ + if (onDeleted != null) + TextButton( + onPressed: uploading.active ? null : delete, + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + child: Text("Delete"), + ), + TextButton( + onPressed: Navigator.of(context).pop, + child: Text(editable ? "Cancel" : "Close"), + ), + if (editable) + TextButton(onPressed: valid ? save : null, child: Text("Save")), + ], + children: [ + TextField( + controller: nameController, + enabled: editable, + autofocus: true, + decoration: .new(labelText: "Name"), + ), + ListTile( + enabled: editable && !uploading.active, + leading: Icon(Icons.image), + title: Text("Avatar"), + subtitle: Text("The pack's first image if not set."), + trailing: Row( + mainAxisSize: .min, + spacing: 8, + children: [ + ImagePackAvatar( + avatar.value ?? images.value.values.firstOrNull?.url, + ), + if (avatar.value != null && editable) + IconButton( + tooltip: "Remove avatar", + icon: Icon(Icons.delete), + onPressed: uploading.active + ? null + : () => avatar.value = null, + ), + ], + ), + onTap: () => + upload((_, image) => avatar.value = image.url).onError(showError), + ), + Text("Default usage", style: textTheme.titleSmall), + ImageUsageChips( + value: packUsage, + onChanged: editable + ? (value) => + usage.value = value.length == ImageUsage.values.length + ? const .empty() + : value + : null, + ), + Text("Images", style: textTheme.titleSmall), + if (images.value.isEmpty && !editable) Text("No images"), + Wrap( + spacing: 4, + runSpacing: 4, + children: [ + ...images.value.entries.map( + (entry) => Tooltip( + message: ":${entry.key}:", + child: InkWell( + borderRadius: .circular(8), + onTap: () => editImage(entry.key, entry.value), + child: Padding( + padding: .all(4), + child: SizedBox.square( + dimension: 56, + child: MxcEmoji(entry.value.url, label: entry.key), + ), + ), + ), + ), + ), + if (editable) + SizedBox.square( + dimension: 64, + child: uploading.active + ? Center(child: CircularProgressIndicator()) + : IconButton( + tooltip: "Add image", + icon: Icon(Icons.add_photo_alternate), + onPressed: () => upload( + (filename, image) => editImage( + filename + .split(".") + .first + .replaceAll(RegExp(r"[^\w-]"), ""), + image, + isNew: true, + ), + ).onError(showError), + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/widgets/settings/image_usage_chips.dart b/lib/widgets/settings/image_usage_chips.dart new file mode 100644 index 0000000..e9d1fbb --- /dev/null +++ b/lib/widgets/settings/image_usage_chips.dart @@ -0,0 +1,38 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/models/content/image_pack.dart"; + +/// Chooses what images are used as, keeping at least one usage chosen. +class const ImageUsageChips({ + required final ISet value, + + /// Disables the chips if null. + required final void Function(ISet value)? onChanged, + super.key, +}) extends StatelessWidget { + static String nameOf(ImageUsage usage) => switch (usage) { + .emoticon => "Emoticons", + .sticker => "Stickers", + }; + + @override + Widget build(BuildContext context) => Wrap( + spacing: 8, + runSpacing: 8, + children: ImageUsage.values + .map( + (usage) => FilterChip( + label: Text(nameOf(usage)), + selected: value.contains(usage), + onSelected: + onChanged == null || + (value.length == 1 && value.contains(usage)) + ? null + : (selected) => onChanged!( + selected ? value.add(usage) : value.remove(usage), + ), + ), + ) + .toList(), + ); +} diff --git a/lib/widgets/settings/pack_image_sheet.dart b/lib/widgets/settings/pack_image_sheet.dart new file mode 100644 index 0000000..9ef70d6 --- /dev/null +++ b/lib/widgets/settings/pack_image_sheet.dart @@ -0,0 +1,134 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/models/content/image_pack.dart"; +import "package:nexus/widgets/mxc_emoji.dart"; +import "package:nexus/widgets/settings/image_usage_chips.dart"; +import "package:nexus/widgets/sheet_layout.dart"; + +/// Edits an image in a pack, or only shows it if [onSave] is null. +class const PackImageSheet({ + required final String title, + required final String shortcode, + required final PackImage image, + + /// The other images' shortcodes, which this one can't also have. + required final ISet taken, + + /// The pack's default usage, which images have unless they set their own. + required final ISet packUsage, + required final void Function(String shortcode, PackImage image)? onSave, + final VoidCallback? onRemoved, + super.key, +}) extends HookWidget { + static final shortcodePattern = RegExp(r"^[\w-]+$"); + + @override + Widget build(BuildContext context) { + final shortcodeController = useTextEditingController(text: shortcode); + final bodyController = useTextEditingController(text: image.body); + final usage = useState(image.usage); + final newShortcode = useValueListenable(shortcodeController).text.trim(); + final error = taken.contains(newShortcode) + ? "Another image has this shortcode" + : newShortcode.isNotEmpty && !shortcodePattern.hasMatch(newShortcode) + ? "Only letters, numbers, - and _ are allowed" + : null; + final valid = newShortcode.isNotEmpty && error == null; + + void save() { + final body = bodyController.text.trim(); + Navigator.of(context).pop(); + onSave!( + newShortcode, + image.copyWith(body: body.isEmpty ? null : body, usage: usage.value), + ); + } + + return SheetLayout( + title: Text(title), + actions: [ + if (onRemoved case final onRemoved?) + TextButton( + onPressed: () { + Navigator.of(context).pop(); + onRemoved(); + }, + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + child: Text("Remove"), + ), + TextButton( + onPressed: Navigator.of(context).pop, + child: Text(onSave == null ? "Close" : "Cancel"), + ), + if (onSave != null) + TextButton(onPressed: valid ? save : null, child: Text("Save")), + ], + children: [ + Center( + child: SizedBox.square( + dimension: 128, + child: MxcEmoji(image.url, label: shortcode), + ), + ), + TextField( + controller: shortcodeController, + enabled: onSave != null, + onSubmitted: valid ? (_) => save() : null, + decoration: .new( + labelText: "Shortcode", + prefixText: ":", + suffixText: ":", + errorText: error, + ), + ), + TextField( + controller: bodyController, + enabled: onSave != null, + onSubmitted: valid ? (_) => save() : null, + decoration: .new( + labelText: "Description", + hintText: "Read by screen readers", + ), + ), + Column( + mainAxisSize: .min, + crossAxisAlignment: .stretch, + children: [ + SwitchListTile( + title: Text("Custom usage"), + subtitle: Text( + usage.value.isEmpty + ? "Same as the pack's default (${packUsage.map(ImageUsageChips.nameOf).join(", ")})" + : "Used as chosen below, instead of the pack's default", + ), + value: usage.value.isNotEmpty, + onChanged: onSave == null + ? null + : (custom) => + usage.value = custom ? packUsage : const .empty(), + ), + AnimatedSize( + duration: Durations.medium2, + curve: Curves.easeInOut, + alignment: .topCenter, + child: usage.value.isEmpty + ? const SizedBox(width: .infinity) + : Padding( + padding: .only(top: 8), + child: ImageUsageChips( + value: usage.value, + onChanged: onSave == null + ? null + : (value) => usage.value = value, + ), + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/widgets/settings/power_level_tile.dart b/lib/widgets/settings/power_level_tile.dart index 5b7d4e6..a553c62 100644 --- a/lib/widgets/settings/power_level_tile.dart +++ b/lib/widgets/settings/power_level_tile.dart @@ -9,7 +9,9 @@ class const PowerLevelTile({ required final Widget leading, required final String title, required final String? description, - required final int value, + + /// Null for several levels that differ, shown as custom. + required final int? value, /// The user's own power level, which they can't set or change levels above. required final double ownLevel, @@ -22,11 +24,14 @@ class const PowerLevelTile({ /// If given, asked before removing the level. final String? removeConfirmation, + + /// Shown after the level, like a button to expand what it sets. + final Widget? trailing, super.key, }) extends StatelessWidget { @override Widget build(BuildContext context) { - final enabled = onChanged != null && value <= ownLevel; + final enabled = onChanged != null && (value ?? 0) <= ownLevel; Future remove() async { if (removeConfirmation case final confirmation?) { @@ -65,7 +70,7 @@ class const PowerLevelTile({ children: [ Chip( label: Text( - "$value", + value?.toString() ?? "Custom", style: enabled ? null : .new(color: Colors.grey), ), ), @@ -75,12 +80,15 @@ class const PowerLevelTile({ icon: Icon(Icons.delete), onPressed: enabled ? remove : null, ), + ?trailing, ], ), onTap: () => context.showAdaptiveSheet( (context) => HookBuilder( builder: (context) { - final controller = useTextEditingController(text: "$value"); + final controller = useTextEditingController( + text: value?.toString(), + ); final level = PowerLevelField.levelFrom( useValueListenable(controller).text, ownLevel, diff --git a/lib/widgets/settings/radio_sheet.dart b/lib/widgets/settings/radio_sheet.dart index 9f9512e..5572d48 100644 --- a/lib/widgets/settings/radio_sheet.dart +++ b/lib/widgets/settings/radio_sheet.dart @@ -36,7 +36,7 @@ final class const RadioSheet({ onChanged: (value) => chosen.value = (value as T,), child: Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: options .map( (option) => RadioListTile( diff --git a/lib/widgets/sticker_picker.dart b/lib/widgets/sticker_picker.dart new file mode 100644 index 0000000..3222399 --- /dev/null +++ b/lib/widgets/sticker_picker.dart @@ -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 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); + } + }, + ); + }, + ); +} diff --git a/lib/widgets/sync_status_banner.dart b/lib/widgets/sync_status_banner.dart index 3f1e47e..10aec7e 100644 --- a/lib/widgets/sync_status_banner.dart +++ b/lib/widgets/sync_status_banner.dart @@ -67,7 +67,7 @@ class const SyncStatusBanner({super.key}) extends ConsumerWidget { banner.title, maxLines: 1, overflow: .ellipsis, - style: TextStyle( + style: .new( color: banner.foreground, fontWeight: .bold, ), diff --git a/lib/widgets/user_bottom_sheet.dart b/lib/widgets/user_bottom_sheet.dart index 58e29d2..1e21581 100644 --- a/lib/widgets/user_bottom_sheet.dart +++ b/lib/widgets/user_bottom_sheet.dart @@ -1,6 +1,5 @@ import "package:collection/collection.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; -import "package:dynamic_color/dynamic_color.dart"; import "package:material_ui/material_ui.dart"; import "package:nexus/widgets/sheet_layout.dart"; import "package:flutter_hooks/flutter_hooks.dart"; @@ -17,6 +16,7 @@ import "package:nexus/controllers/user.dart"; import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/create_room.dart"; import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; +import "package:nexus/helpers/extensions/success_scheme.dart"; import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/loading.dart"; @@ -80,12 +80,6 @@ final class const UserBottomSheet( ), ); - // Green for accepting knocks, harmonized so it fits the current theme - final acceptScheme = ColorScheme.fromSeed( - seedColor: Colors.green.harmonizeWith(theme.colorScheme.primary), - brightness: theme.brightness, - ); - void showMembershipSheet(MembershipAction action) => context.showAdaptiveSheet( (context) => HookBuilder( @@ -407,8 +401,8 @@ final class const UserBottomSheet( icon: Icon(Icons.check), label: Text("Accept knock"), decoration: .styleFrom( - backgroundColor: acceptScheme.primary, - foregroundColor: acceptScheme.onPrimary, + backgroundColor: theme.successScheme.primary, + foregroundColor: theme.successScheme.onPrimary, ), ), ), diff --git a/lib/widgets/user_span.dart b/lib/widgets/user_span.dart new file mode 100644 index 0000000..4fb2f9c --- /dev/null +++ b/lib/widgets/user_span.dart @@ -0,0 +1,63 @@ +import "package:flutter/gestures.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/author.dart"; +import "package:nexus/helpers/extensions/displayname_color.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/global_rect.dart"; +import "package:nexus/helpers/extensions/show_user_popover.dart"; +import "package:nexus/models/event.dart"; + +/// A user's [name] in text, opening their popover when tapped. Unlike a widget +/// in text, it wraps and ellipsizes with the rest of it. +class UserSpan extends TextSpan { + UserSpan( + BuildContext context, + String name, { + required String userId, + required TapGestureRecognizer recognizer, + String? roomId, + super.style, + super.children, + }) : super( + text: name, + recognizer: recognizer + ..onTapUp = (details) { + if (!context.mounted) return; + + context.showUserPopover( + userId, + roomId: roomId, + anchor: context.globalRectAt(details.globalPosition), + ); + }, + ); + + factory UserSpan.sender( + BuildContext context, + WidgetRef ref, + Event event, { + required TapGestureRecognizer recognizer, + }) { + final color = ref.displaynameColor(event.roomId, event.sender); + + return UserSpan( + context, + ref.watch(AuthorController.provider(event)).value?.displayName ?? + event.sender.localpart, + userId: event.sender, + roomId: event.roomId, + recognizer: recognizer, + style: TextStyle(color: color, fontWeight: .bold), + children: [ + if (event.pmp != null) + TextSpan( + text: " (via ${event.sender})", + recognizer: recognizer, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith(color: color, fontWeight: .bold), + ), + ], + ); + } +} diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index 2e0c766..7f5e185 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -126,3 +126,42 @@ if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" COMPONENT Runtime) endif() + +# On Debug builds, register this build as the handler for the app's URI +# scheme, so links like OAuth redirects open it, based on the packaged +# desktop file but running this bundle's binary. +if(CMAKE_BUILD_TYPE MATCHES "Debug") + install(CODE " + set(URI_DESKTOP_FILE \"${CMAKE_CURRENT_SOURCE_DIR}/${APPLICATION_ID}.uri.desktop\") + set(URI_DESKTOP_NAME \"${APPLICATION_ID}.uri.desktop\") + set(URI_BINARY_NAME \"${BINARY_NAME}\") + " COMPONENT Runtime) + install(CODE [[ + if(DEFINED ENV{XDG_DATA_HOME} AND NOT "$ENV{XDG_DATA_HOME}" STREQUAL "") + set(applications_dir "$ENV{XDG_DATA_HOME}/applications") + else() + set(applications_dir "$ENV{HOME}/.local/share/applications") + endif() + + file(READ "${URI_DESKTOP_FILE}" desktop_entry) + string(REGEX REPLACE "(^|\n)Exec=${URI_BINARY_NAME} " + "\\1Exec=\"${CMAKE_INSTALL_PREFIX}/${URI_BINARY_NAME}\" " + desktop_entry "${desktop_entry}") + + # Only rewritten when it changes, so the desktop database isn't refreshed + # on every build + set(destination "${applications_dir}/${URI_DESKTOP_NAME}") + set(existing "") + if(EXISTS "${destination}") + file(READ "${destination}" existing) + endif() + if(NOT existing STREQUAL desktop_entry) + message(STATUS "Registering URI handler: ${destination}") + file(WRITE "${destination}" "${desktop_entry}") + execute_process( + COMMAND update-desktop-database "${applications_dir}" + RESULT_VARIABLE _ OUTPUT_QUIET ERROR_QUIET + ) + endif() + ]] COMPONENT Runtime) +endif() diff --git a/linux/nix/pkg/gomuks.nix b/linux/nix/pkg/gomuks.nix index 6c383d2..37769dc 100644 --- a/linux/nix/pkg/gomuks.nix +++ b/linux/nix/pkg/gomuks.nix @@ -17,7 +17,7 @@ buildGoModule ( src = "${src}/gomuks"; - vendorHash = "sha256-nd0NEKXscgrFE/gBEW2aP+YeZNTOdbRa4QDLTRwIAZU="; + vendorHash = "sha256-XVuRmDvrn43heD/Jba5paPkY1cZnCsYvO+V4IXIuFYI="; buildPhase = '' runHook preBuild diff --git a/pubspec.lock b/pubspec.lock index 670b2ca..36ccd36 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: @@ -1805,5 +1805,5 @@ packages: source: hosted version: "2.2.4" sdks: - dart: "3.13.3" + dart: ">=3.13.3 <4.0.0" flutter: ">=3.47.0" diff --git a/pubspec.yaml b/pubspec.yaml index 11c4808..bf1debb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ flutter: uses-material-design: true environment: - sdk: 3.13.3 + sdk: ^3.13.3 dependency_overrides: path_provider_android: 2.2.23 # Pinned to avoid JNI @@ -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