diff --git a/README.md b/README.md index a0c111d..5d3090f 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ nexus = { This tracks the latest commit on `main`. To stay on a release instead, pin the input to its tag: ```nix -url = "git+https://git.federated.nexus/Nexus/nexus?ref=refs/tags/v1.0.0"; +url = "git+https://git.federated.nexus/Nexus/nexus?ref=refs/tags/v1.0.1"; ``` Then import the module and enable it in your NixOS configuration: diff --git a/gomuks b/gomuks index 081d5df..53cae0e 160000 --- a/gomuks +++ b/gomuks @@ -1 +1 @@ -Subproject commit 081d5df14be6bf7131e955461207e26da7ad23c9 +Subproject commit 53cae0e0334aef5ac376ea84f19ba1cff8a92310 diff --git a/lib/controllers/account_data.dart b/lib/controllers/account_data.dart index d98e4ca..c595565 100644 --- a/lib/controllers/account_data.dart +++ b/lib/controllers/account_data.dart @@ -1,10 +1,7 @@ -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 { @@ -12,56 +9,13 @@ class AccountDataController extends Notifier { AccountData build() => .new(); void update(IMap> newAccountData) => - 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; - } + state = .fromJson({ + ...json.decode(json.encode(state.toJson())), + ...newAccountData + .map((key, value) => MapEntry(key, value["content"])) + .unlock, }); - 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 deleted file mode 100644 index 5703356..0000000 --- a/lib/controllers/all_emoji.dart +++ /dev/null @@ -1,25 +0,0 @@ -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 f158069..569138f 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -29,7 +29,6 @@ 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"; @@ -186,12 +185,11 @@ class ClientController extends AsyncNotifier { ...json, "content": { ...content, - if (request.relatesTo != null) - "m.relates_to": { - ...((content["m.relates_to"] as Map?) ?? {}), - "event_id": request.relatesTo, - "rel_type": request.relationType, - }, + "m.relates_to": { + ...((content["m.relates_to"] as Map?) ?? {}), + "event_id": request.relatesTo, + "rel_type": request.relationType, + }, }, }), ); @@ -200,11 +198,14 @@ class ClientController extends AsyncNotifier { Future setState(SetStateRequest request) async => await _sendCommand("set_state", request.toJson()); - Future pauseSync() => _sendCommand("pause_sync"); - Future resumeSync() => _sendCommand("resume_sync"); - - Future verify(String recoveryKey) => - _sendCommand("verify", {"recovery_key": recoveryKey}); + Future verify(String recoveryKey) async { + try { + await _sendCommand("verify", {"recovery_key": recoveryKey}); + return null; + } catch (error) { + return error.toString(); + } + } Future generateRecoveryKey(String passphrase) async => .fromJson( await _sendCommand("generate_recovery_key", {"passphrase": passphrase}), @@ -264,15 +265,6 @@ 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 deleted file mode 100644 index c16799c..0000000 --- a/lib/controllers/enabled_image_packs.dart +++ /dev/null @@ -1,69 +0,0 @@ -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 44a44f0..a41d6b6 100644 --- a/lib/controllers/event.dart +++ b/lib/controllers/event.dart @@ -9,12 +9,11 @@ class EventController(final GetEventRequest request) extends AsyncNotifier { @override Future build() async { - final event = ref.watch( - RoomsController.provider.select( - (rooms) => rooms[request.roomId]?.events.values.firstWhereOrNull( - (event) => event.eventId == request.eventId, - ), - ), + final room = ref.watch( + RoomsController.provider.select((value) => value[request.roomId]), + ); + final event = room?.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 320ac2d..a97599b 100644 --- a/lib/controllers/gomuks_listener.dart +++ b/lib/controllers/gomuks_listener.dart @@ -156,12 +156,11 @@ 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) { - client.pauseSync().onError(showError); + GomuksPauseSync(handle); } else { ref.invalidate(TypingController.provider); - client.resumeSync().onError(showError); + GomuksResumeSync(handle); } }, fireImmediately: true); } diff --git a/lib/controllers/image_pack_sources.dart b/lib/controllers/image_pack_sources.dart deleted file mode 100644 index 61e4a2e..0000000 --- a/lib/controllers/image_pack_sources.dart +++ /dev/null @@ -1,75 +0,0 @@ -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 deleted file mode 100644 index 9248211..0000000 --- a/lib/controllers/image_packs.dart +++ /dev/null @@ -1,43 +0,0 @@ -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 b251e26..47ecdf0 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(.element); + const escape = HtmlEscape(HtmlEscapeMode.element); await portal.notification.addNotification( id.toString(), diff --git a/lib/controllers/recent_emoji.dart b/lib/controllers/recent_emoji.dart index a091afb..8dd3e59 100644 --- a/lib/controllers/recent_emoji.dart +++ b/lib/controllers/recent_emoji.dart @@ -23,13 +23,7 @@ class RecentEmojiController extends Notifier> { ), ); - return .new( - recents.where( - (recent) => - // Is unicode or custom emote - emoji.contains(recent.emoji) || recent.emoji.startsWith("mxc://"), - ), - ); + return .new(recents.where((recent) => emoji.contains(recent.emoji))); } Future add(String emoji) => ref diff --git a/lib/controllers/room_chat.dart b/lib/controllers/room_chat.dart index 94fb7a3..b8b688d 100644 --- a/lib/controllers/room_chat.dart +++ b/lib/controllers/room_chat.dart @@ -9,9 +9,7 @@ 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"; @@ -243,14 +241,17 @@ class RoomChatController(final ChatParams info) ref.invalidate(provider); } - // 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")(#[^#]*#)?"), + var taggedMessage = text; + + for (final tag in tags) { + final escaped = RegExp.escape(tag.id); + final pattern = RegExp(r"@+(" + escaped + r")(#[^#]*#)?"); + + taggedMessage = taggedMessage.replaceAllMapped( + pattern, (match) => match.group(1)!, - ), - ); + ); + } final client = ref.read(ClientController.provider.notifier); final event = await client.sendMessage( @@ -314,24 +315,14 @@ class RoomChatController(final ChatParams info) } } - /// 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 { + Future sendReaction(String reaction, Event event) async { final client = ref.read(ClientController.provider.notifier); await client.sendEvent( .new( roomId: info.roomId, type: EventType.reaction.type, - // Wrapped in colons, like other clients send it - content: ReactionContent( - key: reaction, - shortcode: shortcode == null ? null : ":$shortcode:", - ), + content: ReactionContent(key: reaction), synchronous: true, disableEncryption: true, relatesTo: event.eventId, @@ -340,33 +331,6 @@ 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 deleted file mode 100644 index 318d388..0000000 --- a/lib/controllers/room_image_packs.dart +++ /dev/null @@ -1,37 +0,0 @@ -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 a821164..583f0f9 100644 --- a/lib/controllers/room_settings_sections.dart +++ b/lib/controllers/room_settings_sections.dart @@ -7,12 +7,10 @@ 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"; @@ -22,7 +20,6 @@ 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"; @@ -30,17 +27,14 @@ 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"; @@ -95,7 +89,6 @@ 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)); @@ -163,81 +156,20 @@ class RoomSettingsSectionsController(final String roomId) ClientStateController.provider.select((state) => state?.userId), ); final ownLevel = userId == null ? 0.0 : roomPowerLevels.levelOf(userId); - final encrypted = stateContent(.encryption) is EncryptionContent; - final shownEvents = PowerLevelEvent.shownIn(encrypted: encrypted); + // Encrypted rooms send messages as m.room.encrypted, so that's what limits them + final messageType = stateContent(.encryption) is EncryptionContent + ? EventType.encrypted + : EventType.message; - Future setState( - EventType type, - Content content, { - String stateKey = "", - }) async { + Future setState(EventType type, Content content) async { final eventId = await client.setState( - .new( - roomId: roomId, - type: type.type, - stateKey: stateKey, - content: content, - ), + .new(roomId: roomId, type: type.type, stateKey: "", content: content), ); if (eventId case final eventId?) { - await roomsController.awaitState( - roomId, - type.type, - eventId, - stateKey: stateKey, - ); + await roomsController.awaitState(roomId, type.type, eventId); } } - 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, @@ -286,10 +218,12 @@ 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 = shownEvents - .addAll(PowerLevelGroup.values.expand((group) => group.events)) - .map((event) => event.type.type) - .toISet(); + final listedEvents = [ + messageType, + EventType.sticker, + EventType.reaction, + EventType.pinnedEvents, + ].map((type) => type.type).toISet(); final otherEvents = powerLevels.events.entries .where((entry) => !listedEvents.contains(entry.key)) .toIList() @@ -354,94 +288,20 @@ class RoomSettingsSectionsController(final String roomId) ), ); - 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), - ), - ], - ); - }, + /// 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), ), ); @@ -571,7 +431,14 @@ class RoomSettingsSectionsController(final String roomId) subtitle: Text(description), value: joinRules.joinRule, options: joinRuleOptions, - getName: (rule) => rule.displayName, + getName: (rule) => switch (rule) { + .public => "Public", + .invite => "Invite only", + .knock => "Knock", + .restricted => "Restricted", + .knockRestricted => "Restricted or knock", + .private => "Private", + }, onChanged: canSetJoinRules ? (rule) => setState( .joinRules, @@ -731,7 +598,6 @@ class RoomSettingsSectionsController(final String roomId) .invited => "After invite", .joined => "After join", .worldReadable => "Anyone", - .unknown => "Unknown", }, onChanged: canSetHistoryVisibility ? (visibility) => setState( @@ -749,7 +615,13 @@ class RoomSettingsSectionsController(final String roomId) title: "Permissions", icon: Icons.admin_panel_settings, settings: .new([ - permissionSetting(.usersDefault), + 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), + ), header("Users", "Power levels of specific users.", Icons.group), ...users.map((entry) { final member = memberOf(entry.key); @@ -810,31 +682,87 @@ 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, ), - ...shownEvents - .where((event) => event != .pinnedEvents) - .map( - (event) => - eventSetting(event, fallback: powerLevels.eventsDefault), - ), - permissionSetting(.eventsDefault), + 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), + ), header( "State Events", - "Power levels needed to change the room's state.", + "Power levels needed to manage members and change the room's state.", Icons.settings, ), - permissionSetting(.invite), - permissionSetting(.stateDefault), + 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), + ), header( "Other Events", "Power levels for specific event types.", @@ -885,90 +813,6 @@ 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 d52b317..5370bcf 100644 --- a/lib/controllers/rooms.dart +++ b/lib/controllers/rooms.dart @@ -11,35 +11,29 @@ class RoomsController extends Notifier> { @override IMap build() => .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))), - ), - ), - ), + 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(), ); static IMap> mergeReceipts( diff --git a/lib/controllers/settings_sections.dart b/lib/controllers/settings_sections.dart index b74f50d..2cd804c 100644 --- a/lib/controllers/settings_sections.dart +++ b/lib/controllers/settings_sections.dart @@ -9,31 +9,22 @@ 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"; @@ -44,33 +35,6 @@ 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, @@ -140,26 +104,6 @@ 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( @@ -265,21 +209,6 @@ 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( @@ -335,78 +264,6 @@ 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 4d6dffe..8af045c 100644 --- a/lib/controllers/spaces.dart +++ b/lib/controllers/spaces.dart @@ -25,50 +25,55 @@ class SpacesController extends AsyncNotifier> { MapEntry(spaceId, edges.map((edge) => edge.childId).toList()), ); - /// 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)), - ); + 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; + } 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( - 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, - ), + children: .new(directRooms), + subSpaces: .new(subSpaces), ); } diff --git a/lib/controllers/via.dart b/lib/controllers/via.dart index 8c7f0db..39db682 100644 --- a/lib/controllers/via.dart +++ b/lib/controllers/via.dart @@ -1,48 +1,53 @@ +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 powerLevelUsers = - switch (room.state[EventType.powerLevels.type]?[""]) { - final rowId? => switch (room.events[rowId]?.content) { - PowerLevelsContent(:final users) => users.keys.toIList(), - _ => null, - }, - null => null, - } ?? - .new([]); + final servers = {}; - 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(); + void addUserId(String? userId) { + final server = userId?.split(":").lastOrNull; + if (server != null) { + servers.add(server); + } + } - final servers = [?ref.watch(ClientStateController.provider)?.userId] - .toIList() - .addAll(powerLevelUsers) - .addAll(joinedMembers) - .map((userId) => userId.substring(userId.indexOf(":") + 1)) - .whereNoDuplicates() - .take(5) - .toISet(); + 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; + } return servers.isEmpty ? "" diff --git a/lib/helpers/extensions/build_event_options.dart b/lib/helpers/extensions/build_event_options.dart index e96284b..72ca2d2 100644 --- a/lib/helpers/extensions/build_event_options.dart +++ b/lib/helpers/extensions/build_event_options.dart @@ -18,7 +18,6 @@ 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 { @@ -98,8 +97,6 @@ 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), @@ -112,15 +109,10 @@ extension BuildEventOptions on Event { .take(isDesktop ? 3 : 5) .map( (emoji) => IconButton( - icon: emoji.startsWith("mxc://") - ? SizedBox.square( - dimension: isDesktop ? 18 : 24, - child: MxcEmoji(Uri.parse(emoji)), - ) - : Text( - emoji, - style: .new(fontSize: isDesktop ? null : 18), - ), + icon: Text( + emoji, + style: .new(fontSize: isDesktop ? null : 18), + ), onPressed: () async { Navigator.of(context).pop(); await ref @@ -142,13 +134,12 @@ extension BuildEventOptions on Event { context.showAdaptiveSheet( (context) => EmojiPicker( - roomId: roomId, allowFreeText: true, - onSelection: (emoji, {shortcode, description}) { + onSelection: (emoji) { Navigator.of(context).pop(); ref .read(notifier) - .sendReaction(emoji, this, shortcode: shortcode) + .sendReaction(emoji, this) .onError(showError); }, ), diff --git a/lib/helpers/extensions/displayname_color.dart b/lib/helpers/extensions/displayname_color.dart deleted file mode 100644 index eb81d16..0000000 --- a/lib/helpers/extensions/displayname_color.dart +++ /dev/null @@ -1,38 +0,0 @@ -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 4f2169f..91c8305 100644 --- a/lib/helpers/extensions/global_rect.dart +++ b/lib/helpers/extensions/global_rect.dart @@ -12,12 +12,4 @@ 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 9ab1307..57425be 100644 --- a/lib/helpers/extensions/gomuks_buffer.dart +++ b/lib/helpers/extensions/gomuks_buffer.dart @@ -15,10 +15,7 @@ extension GomuksOwnedBufferToX on GomuksOwnedBuffer { } } - dynamic toJsonAndFree() { - final bytes = toBytesAndFree(); - return bytes.isEmpty ? null : jsonDecode(utf8.decode(bytes)); - } + dynamic toJsonAndFree() => jsonDecode(utf8.decode(toBytesAndFree())); } extension JsonToGomuksBuffer on Map { diff --git a/lib/helpers/extensions/image_pack_emoji.dart b/lib/helpers/extensions/image_pack_emoji.dart deleted file mode 100644 index 2b04cf1..0000000 --- a/lib/helpers/extensions/image_pack_emoji.dart +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index 94c6001..0000000 --- a/lib/helpers/extensions/join_rule_name.dart +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 9b9d9ce..0000000 --- a/lib/helpers/extensions/room_image_packs.dart +++ /dev/null @@ -1,34 +0,0 @@ -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 3d2a94c..8a100e8 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 [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}) { + /// 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) { if (!isDesktop) return showAdaptiveSheet(builder); - final rect = anchor ?? globalRect; + final anchor = globalRect; return showDialog( context: this, barrierColor: Colors.transparent, useSafeArea: false, builder: (context) => CustomSingleChildLayout( - delegate: PopoverLayout(rect), + delegate: PopoverLayout(anchor), 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 ccf6898..a56417e 100644 --- a/lib/helpers/extensions/show_user_popover.dart +++ b/lib/helpers/extensions/show_user_popover.dart @@ -3,9 +3,6 @@ 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, Rect? anchor}) => - showAdaptivePopover( - (context) => UserBottomSheet(userId, roomId: roomId), - anchor: anchor, - ); + void showUserPopover(String userId, {String? roomId}) => + showAdaptivePopover((context) => UserBottomSheet(userId, roomId: roomId)); } diff --git a/lib/helpers/extensions/success_scheme.dart b/lib/helpers/extensions/success_scheme.dart deleted file mode 100644 index 77f83ae..0000000 --- a/lib/helpers/extensions/success_scheme.dart +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 97c5534..0000000 --- a/lib/helpers/hooks/tap_recognizer.dart +++ /dev/null @@ -1,11 +0,0 @@ -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 66b253a..5cd2129 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: () => .system, + orElse: () => ThemeMode.system, ), home: child, ); @@ -160,8 +160,8 @@ class const App({super.key}) extends StatelessWidget { MultiProviderController.provider( .new([ GomuksListenerController.provider, - UnifiedPushController.provider, NotificationController.provider, + UnifiedPushController.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 88da5a0..0ab5990 100644 --- a/lib/models/account_data.dart +++ b/lib/models/account_data.dart @@ -16,21 +16,10 @@ 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); @@ -49,42 +38,6 @@ 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 9efdb64..25167e9 100644 --- a/lib/models/content/avatar.dart +++ b/lib/models/content/avatar.dart @@ -4,13 +4,10 @@ import "package:nexus/models/info/image.dart"; part "avatar.freezed.dart"; part "avatar.g.dart"; -@Freezed(toJson: false, fromJson: false) -@JsonSerializable() -class const AvatarContent({final ImageInfo? info, final Uri? url}) - extends Content - with _$AvatarContent { - @override - Map toJson() => _$AvatarContentToJson(this); +@freezed +sealed class AvatarContent extends Content with _$AvatarContent { + AvatarContent._(); + factory AvatarContent({ImageInfo? info, Uri? url}) = _AvatarContent; factory AvatarContent.fromJson(Map json) => _$AvatarContentFromJson(json); diff --git a/lib/models/content/canonical_alias.dart b/lib/models/content/canonical_alias.dart index 058c007..6fb2829 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(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); +@freezed +sealed class CanonicalAliasContent extends Content + with _$CanonicalAliasContent { + CanonicalAliasContent._(); + factory CanonicalAliasContent({ + @JsonKey(includeIfNull: false) String? alias, + @Default(ISet.empty()) ISet altAliases, + }) = _CanonicalAliasContent; factory CanonicalAliasContent.fromJson(Map json) => _$CanonicalAliasContentFromJson(json); diff --git a/lib/models/content/content.dart b/lib/models/content/content.dart index 2809245..37a87b7 100644 --- a/lib/models/content/content.dart +++ b/lib/models/content/content.dart @@ -16,7 +16,6 @@ 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(); @@ -59,10 +58,7 @@ 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 1f9f125..b937a94 100644 --- a/lib/models/content/create.dart +++ b/lib/models/content/create.dart @@ -4,18 +4,21 @@ import "package:nexus/models/content/content.dart"; part "create.freezed.dart"; part "create.g.dart"; -@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); +@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; factory CreateContent.fromJson(Map json) => _$CreateContentFromJson(json); diff --git a/lib/models/content/encrypted.dart b/lib/models/content/encrypted.dart index 2fd7e4a..8658a71 100644 --- a/lib/models/content/encrypted.dart +++ b/lib/models/content/encrypted.dart @@ -3,11 +3,10 @@ import "package:nexus/models/content/content.dart"; part "encrypted.freezed.dart"; part "encrypted.g.dart"; -@Freezed(toJson: false, fromJson: false) -@JsonSerializable() -class const EncryptedContent() extends Content with _$EncryptedContent { - @override - Map toJson() => _$EncryptedContentToJson(this); +@freezed +sealed class EncryptedContent extends Content with _$EncryptedContent { + EncryptedContent._(); + factory EncryptedContent() = _EncryptedContent; factory EncryptedContent.fromJson(Map json) => _$EncryptedContentFromJson(json); diff --git a/lib/models/content/encryption.dart b/lib/models/content/encryption.dart index cb7f1cd..52c7b34 100644 --- a/lib/models/content/encryption.dart +++ b/lib/models/content/encryption.dart @@ -3,15 +3,20 @@ import "package:nexus/models/content/content.dart"; part "encryption.freezed.dart"; part "encryption.g.dart"; -@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); +@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; factory EncryptionContent.fromJson(Map json) => _$EncryptionContentFromJson(json); diff --git a/lib/models/content/history_visibility.dart b/lib/models/content/history_visibility.dart index 516cac8..3cfc011 100644 --- a/lib/models/content/history_visibility.dart +++ b/lib/models/content/history_visibility.dart @@ -3,18 +3,17 @@ import "package:nexus/models/content/content.dart"; part "history_visibility.freezed.dart"; part "history_visibility.g.dart"; -@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); +@freezed +sealed class HistoryVisibilityContent extends Content + with _$HistoryVisibilityContent { + HistoryVisibilityContent._(); + factory HistoryVisibilityContent({ + required HistoryVisibility historyVisibility, + }) = _HistoryVisibilityContent; factory HistoryVisibilityContent.fromJson(Map json) => _$HistoryVisibilityContentFromJson(json); } @JsonEnum(fieldRename: FieldRename.snake) -enum HistoryVisibility { invited, joined, shared, worldReadable, unknown } +enum HistoryVisibility { invited, joined, shared, worldReadable } diff --git a/lib/models/content/image_pack.dart b/lib/models/content/image_pack.dart deleted file mode 100644 index 5bcbb54..0000000 --- a/lib/models/content/image_pack.dart +++ /dev/null @@ -1,76 +0,0 @@ -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 4f1a929..948a762 100644 --- a/lib/models/content/join_rules.dart +++ b/lib/models/content/join_rules.dart @@ -5,14 +5,13 @@ import "package:nexus/models/join_rule.dart"; part "join_rules.freezed.dart"; part "join_rules.g.dart"; -@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); +@freezed +sealed class JoinRulesContent extends Content with _$JoinRulesContent { + JoinRulesContent._(); + factory JoinRulesContent({ + required JoinRule joinRule, + @Default(ISet.empty()) ISet allow, + }) = _JoinRulesContent; factory JoinRulesContent.fromJson(Map json) => _$JoinRulesContentFromJson(json); diff --git a/lib/models/content/membership.dart b/lib/models/content/membership.dart index e082bf6..b1a5508 100644 --- a/lib/models/content/membership.dart +++ b/lib/models/content/membership.dart @@ -4,22 +4,24 @@ import "package:nexus/models/membership_status.dart"; part "membership.freezed.dart"; part "membership.g.dart"; -@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 { +@freezed +sealed class MembershipContent extends Content with _$MembershipContent { + MembershipContent._(); + static String? displaynameFromJson(String? displayName) => displayName?.isEmpty == true ? null : displayName; - @override - Map toJson() => _$MembershipContentToJson(this); + 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; factory MembershipContent.fromJson(Map json) => _$MembershipContentFromJson(json); diff --git a/lib/models/content/message.dart b/lib/models/content/message.dart index 5212cd8..36bd725 100644 --- a/lib/models/content/message.dart +++ b/lib/models/content/message.dart @@ -20,7 +20,6 @@ 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; @@ -28,7 +27,6 @@ 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; @@ -36,7 +34,6 @@ 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; @@ -44,7 +41,6 @@ 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, @@ -56,7 +52,6 @@ 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, @@ -68,7 +63,6 @@ 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, @@ -80,7 +74,6 @@ 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 74f583d..c451d64 100644 --- a/lib/models/content/name.dart +++ b/lib/models/content/name.dart @@ -3,13 +3,10 @@ import "package:nexus/models/content/content.dart"; part "name.freezed.dart"; part "name.g.dart"; -@Freezed(toJson: false, fromJson: false) -@JsonSerializable() -class const NameContent({required final String name}) - extends Content - with _$NameContent { - @override - Map toJson() => _$NameContentToJson(this); +@freezed +sealed class NameContent extends Content with _$NameContent { + NameContent._(); + factory NameContent({required String name}) = _NameContent; factory NameContent.fromJson(Map json) => _$NameContentFromJson(json); diff --git a/lib/models/content/pinned_events.dart b/lib/models/content/pinned_events.dart index cf322af..9dac5de 100644 --- a/lib/models/content/pinned_events.dart +++ b/lib/models/content/pinned_events.dart @@ -4,13 +4,12 @@ import "package:nexus/models/content/content.dart"; part "pinned_events.freezed.dart"; part "pinned_events.g.dart"; -@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); +@freezed +sealed class PinnedEventsContent extends Content with _$PinnedEventsContent { + PinnedEventsContent._(); + factory PinnedEventsContent({ + @Default(IList.empty()) @JsonKey(name: "pinned") IList pinnedEvents, + }) = _PinnedEventsContent; factory PinnedEventsContent.fromJson(Map json) => _$PinnedEventsContentFromJson(json); diff --git a/lib/models/content/power_levels.dart b/lib/models/content/power_levels.dart index cdffd68..4eb1c82 100644 --- a/lib/models/content/power_levels.dart +++ b/lib/models/content/power_levels.dart @@ -4,22 +4,21 @@ import "package:nexus/models/content/content.dart"; part "power_levels.freezed.dart"; part "power_levels.g.dart"; -@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); +@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; factory PowerLevelsContent.fromJson(Map json) => _$PowerLevelsContentFromJson(json); diff --git a/lib/models/content/reaction.dart b/lib/models/content/reaction.dart index 3990788..93c563d 100644 --- a/lib/models/content/reaction.dart +++ b/lib/models/content/reaction.dart @@ -3,21 +3,19 @@ import "package:nexus/models/content/content.dart"; part "reaction.freezed.dart"; part "reaction.g.dart"; -@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 { +@Freezed(toJson: false) +sealed class ReactionContent extends Content with _$ReactionContent { + 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 9864c1f..2ed6436 100644 --- a/lib/models/content/redaction.dart +++ b/lib/models/content/redaction.dart @@ -3,13 +3,11 @@ import "package:nexus/models/content/content.dart"; part "redaction.freezed.dart"; part "redaction.g.dart"; -@Freezed(toJson: false, fromJson: false) -@JsonSerializable() -class const RedactionContent({final String? reason, final String? redacts}) - extends Content - with _$RedactionContent { - @override - Map toJson() => _$RedactionContentToJson(this); +@freezed +sealed class RedactionContent extends Content with _$RedactionContent { + RedactionContent._(); + factory RedactionContent({String? reason, String? redacts}) = + _RedactionContent; factory RedactionContent.fromJson(Map json) => _$RedactionContentFromJson(json); diff --git a/lib/models/content/server_acl.dart b/lib/models/content/server_acl.dart index 3c33072..3873bf3 100644 --- a/lib/models/content/server_acl.dart +++ b/lib/models/content/server_acl.dart @@ -4,15 +4,14 @@ import "package:nexus/models/content/content.dart"; part "server_acl.freezed.dart"; part "server_acl.g.dart"; -@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); +@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; factory ServerACLContent.fromJson(Map json) => _$ServerACLContentFromJson(json); diff --git a/lib/models/content/sticker.dart b/lib/models/content/sticker.dart index a47a576..c6fa893 100644 --- a/lib/models/content/sticker.dart +++ b/lib/models/content/sticker.dart @@ -4,15 +4,14 @@ import "package:nexus/models/info/image.dart"; part "sticker.freezed.dart"; part "sticker.g.dart"; -@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); +@freezed +sealed class StickerContent extends Content with _$StickerContent { + StickerContent._(); + factory StickerContent({ + required String body, + required ImageInfo info, + required Uri url, + }) = _StickerContent; factory StickerContent.fromJson(Map json) => _$StickerContentFromJson(json); diff --git a/lib/models/content/topic.dart b/lib/models/content/topic.dart index cd187fa..c8256e3 100644 --- a/lib/models/content/topic.dart +++ b/lib/models/content/topic.dart @@ -5,14 +5,13 @@ import "package:nexus/models/content/content.dart"; part "topic.freezed.dart"; part "topic.g.dart"; -@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); +@freezed +sealed class TopicContent extends Content with _$TopicContent { + TopicContent._(); + factory TopicContent({ + required String topic, + @JsonKey(name: "m.topic") TopicContentBlock? content, + }) = _TopicContent; factory TopicContent.fromJson(Map json) => _$TopicContentFromJson(json); @@ -21,7 +20,7 @@ class const TopicContent({ @Freezed(toJson: false, fromJson: false) @JsonSerializable() class const TopicContentBlock({ - final IList representations = const .empty(), + final IList representations = const IList.empty(), }) with _$TopicContentBlock { Map toJson() => _$TopicContentBlockToJson(this); diff --git a/lib/models/emoji.dart b/lib/models/emoji.dart new file mode 100644 index 0000000..75faf08 --- /dev/null +++ b/lib/models/emoji.dart @@ -0,0 +1,25 @@ +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 new file mode 100644 index 0000000..cc39bc3 --- /dev/null +++ b/lib/models/emoji_category.dart @@ -0,0 +1,12 @@ +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 deleted file mode 100644 index 33528c2..0000000 --- a/lib/models/image_pack.dart +++ /dev/null @@ -1,54 +0,0 @@ -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 a82c62a..44fb9bb 100644 --- a/lib/models/join_rule.dart +++ b/lib/models/join_rule.dart @@ -1,13 +1,4 @@ import "package:freezed_annotation/freezed_annotation.dart"; @JsonEnum(fieldRename: .snake) -enum JoinRule { - public, - knock, - invite, - private, - restricted, - knockRestricted, - - unknown, -} +enum JoinRule { public, knock, invite, private, restricted, knockRestricted } diff --git a/lib/models/membership_status.dart b/lib/models/membership_status.dart index f8197cb..7eb5f72 100644 --- a/lib/models/membership_status.dart +++ b/lib/models/membership_status.dart @@ -1 +1 @@ -enum MembershipStatus { leave, invite, ban, join, knock, unknown } +enum MembershipStatus { leave, invite, ban, join, knock } diff --git a/lib/models/name_color.dart b/lib/models/name_color.dart deleted file mode 100644 index f67429e..0000000 --- a/lib/models/name_color.dart +++ /dev/null @@ -1,2 +0,0 @@ -/// 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 deleted file mode 100644 index 8c26ee2..0000000 --- a/lib/models/power_level_permission.dart +++ /dev/null @@ -1,247 +0,0 @@ -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 deleted file mode 100644 index 1559bc0..0000000 --- a/lib/models/requests/get_specific_room_state.dart +++ /dev/null @@ -1,29 +0,0 @@ -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 2688ca3..05eaf43 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, - @JsonKey(unknownEnumValue: JoinRule.unknown) final JoinRule? joinRule, + 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 e3b95a2..7ea61d9 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -1,6 +1,5 @@ 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"; @@ -17,9 +16,7 @@ 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 18eebbf..2648347 100644 --- a/lib/widgets/composer/composer.dart +++ b/lib/widgets/composer/composer.dart @@ -17,11 +17,7 @@ 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"; @@ -42,8 +38,6 @@ class const Composer( required IList tags, }) onSend, - required final Future Function(String shortcode, PackImage image) - onSendSticker, final FocusNode? node, super.key, }) extends HookConsumerWidget { @@ -54,17 +48,6 @@ 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( @@ -270,31 +253,11 @@ class const Composer( IconButton( onPressed: () => context.showAdaptiveSheet( (context) => EmojiPicker( - 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(); - }, + onSelection: (value) { + Navigator.of(context).pop(); + controller.text += value; + node?.requestFocus(); + }, ), ), icon: Icon(Icons.emoji_emotions), @@ -345,22 +308,6 @@ 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), ), @@ -368,9 +315,6 @@ 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, @@ -401,11 +345,11 @@ class const Composer( if (!shiftPressed) { send(); - return .handled; + return KeyEventResult.handled; } } - return .ignored; + return KeyEventResult.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 3ff0d55..0e762c0 100644 --- a/lib/widgets/composer/overlays/emoji_overlay.dart +++ b/lib/widgets/composer/overlays/emoji_overlay.dart @@ -1,10 +1,8 @@ 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, { @@ -15,7 +13,7 @@ class const EmojiOverlay( }) extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) => ref - .watch(AllEmojiController.provider(roomId)) + .watch(EmojiController.provider) .betterWhen( data: (emojis) => ListView( children: emojis @@ -36,15 +34,8 @@ class const EmojiOverlay( title: Text(emoji.aliases.first), subtitle: Text(emoji.description), onTap: () => addTag( - id: emoji.value.startsWith("mxc://") - ? ImagePack.emojiMarkdown( - emoji.aliases.first, - emoji.value, - description: switch (emoji.data) { - PackImage(:final body) => body, - _ => null, - }, - ) + id: Uri.tryParse(emoji.value)?.scheme == "mxc" + ? "" // TODO: Handle custom emotes : emoji.value, name: emoji.aliases.first, ), diff --git a/lib/widgets/composer/relation_preview.dart b/lib/widgets/composer/relation_preview.dart index aaf1f5c..fca9de6 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: .new( + style: TextStyle( 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 9624937..2a30c4b 100644 --- a/lib/widgets/composer/typing_indicator.dart +++ b/lib/widgets/composer/typing_indicator.dart @@ -4,12 +4,11 @@ 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/hooks/tap_recognizer.dart"; +import "package:nexus/helpers/extensions/show_user_popover.dart"; import "package:nexus/widgets/bouncing_dots.dart"; -import "package:nexus/widgets/user_span.dart"; class const TypingIndicator(final String roomId, {super.key}) - extends HookConsumerWidget { + extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); @@ -17,31 +16,33 @@ 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) - .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), + .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, + ), + ), + ), + ), ), ), if (typing.length > named) diff --git a/lib/widgets/emoji_picker.dart b/lib/widgets/emoji_picker.dart index fb834c9..7e49836 100644 --- a/lib/widgets/emoji_picker.dart +++ b/lib/widgets/emoji_picker.dart @@ -1,28 +1,15 @@ 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 String roomId, - - /// Has the [shortcode] and [description] of images from packs. - required final FutureOr Function( - String value, { - String? shortcode, - String? description, - }) - onSelection, + required final FutureOr Function(String value) onSelection, final bool allowFreeText = false, super.key, }) extends ConsumerWidget { @@ -31,54 +18,39 @@ class const EmojiPicker({ final recentEmoji = ref.watch(RecentEmojiController.provider); return ref - .watch(AllEmojiController.provider(roomId)) + .watch(upstream.EmojiController.provider) .betterWhen( - 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, - ); + data: (categories) => upstream.EmojiPicker( + onSelection: (emoji, isFreeText) async { + await onSelection(emoji); + if (!isFreeText) { await ref .read(RecentEmojiController.provider.notifier) - .add(emoji.value) + .add(emoji) .onError(showError); - }, - 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, - ), + } + }, + 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, ), - ]).addAll(customCategories), - ); - }, + ), + ]), + ), ); } } diff --git a/lib/widgets/html/html.dart b/lib/widgets/html/html.dart index d356f6e..5ea584f 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: .middle, + alignment: PlaceholderAlignment.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 deleted file mode 100644 index aaebca2..0000000 --- a/lib/widgets/image_pack_avatar.dart +++ /dev/null @@ -1,14 +0,0 @@ -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 d7c91bd..e8e4791 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/displayname_color.dart"; +import "package:nexus/helpers/extensions/string_to_color.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/content/membership.dart"; @@ -15,44 +15,43 @@ class const MessageDisplayname( super.key, }) extends ConsumerWidget { @override - 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 ([ + 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) Text( - value?.displayName ?? event.sender.localpart, - style: style ?? .new(color: color, fontWeight: .bold), + "(via ${event.sender})", + style: Theme.of(context).textTheme.labelSmall + ?.copyWith(color: event.sender.colorHash, fontWeight: .bold), maxLines: 1, overflow: .ellipsis, ), - 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(), - ), - }, - ), - }; - } + ]) { + 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)], + ), + }, + ), + }; } diff --git a/lib/widgets/member_list.dart b/lib/widgets/member_list.dart index 02cc0c6..731270d 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/displayname_color.dart"; +import "package:nexus/helpers/extensions/string_to_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,44 +108,42 @@ class const MemberList(final String roomId, {super.key}) :final avatarUrl, :final displayName, ) => - 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( + Builder( + builder: (context) => ListTile( + onTap: () => + context.showUserPopover( members[index].stateKey!, - overflow: .ellipsis, - ), - leading: Transform.translate( - offset: .new(0, 2), - child: AvatarOrHash( - avatarUrl, - dimension: 40, - displayName ?? - members[index] - .stateKey! - .localpart, - ), + roomId: roomId, ), + 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 70aedd9..969dcd3 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: .fitWidth, + fit: BoxFit.fitWidth, loadingBuilder: (_, child, loadingProgress) => loadingProgress == null ? child diff --git a/lib/widgets/mxc_emoji.dart b/lib/widgets/mxc_emoji.dart deleted file mode 100644 index e8c4916..0000000 --- a/lib/widgets/mxc_emoji.dart +++ /dev/null @@ -1,16 +0,0 @@ -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 23ff676..0b0741f 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 .symmetric( + padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 12, ), diff --git a/lib/widgets/pages/select_server.dart b/lib/widgets/pages/select_server.dart index 4a35a6c..66c40a5 100644 --- a/lib/widgets/pages/select_server.dart +++ b/lib/widgets/pages/select_server.dart @@ -60,8 +60,6 @@ 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 { @@ -72,7 +70,7 @@ class const SelectServerPage({super.key}) extends HookConsumerWidget { try { final code = encodedUri.queryParameters["code"]; - if (code != null && context.mounted) { + if (code != null) { await ref .read(ClientController.provider.notifier) .exchangeToken( diff --git a/lib/widgets/pages/verify.dart b/lib/widgets/pages/verify.dart index 4858f8c..76bd94d 100644 --- a/lib/widgets/pages/verify.dart +++ b/lib/widgets/pages/verify.dart @@ -18,7 +18,6 @@ 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, @@ -26,24 +25,18 @@ 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 { - await ref + if (formKey.value.currentState?.validate() != true) { + return; + } + + inputError.value = await ref .read(ClientController.provider.notifier) .verify(passphraseController.text); - } catch (error) { - inputError.value = error.toString(); } finally { - if (context.mounted) { - isLoading.value = false; - } + isLoading.value = false; } } @@ -59,8 +52,7 @@ class const VerifyPage({super.key}) extends HookConsumerWidget { ), body: switch (verificationState) { null || VerificationState(stateChecked: false) => const Loading(), - - // Nothing to verify yet, so go straight to setup. + // Nothing to verify with yet, so go straight to setting it up. VerificationState(hasSecretStorage: false) => Dialog( insetPadding: const .symmetric(vertical: 12, horizontal: 32), child: ConstrainedBox( @@ -68,7 +60,6 @@ class const VerifyPage({super.key}) extends HookConsumerWidget { child: const ResetEncryptionSheet(isPage: true), ), ), - _ => AlertDialog( insetPadding: .symmetric(vertical: 12, horizontal: 32), scrollable: true, @@ -80,9 +71,7 @@ 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( @@ -105,13 +94,9 @@ class const VerifyPage({super.key}) extends HookConsumerWidget { ), actions: [ TextButton( - onPressed: isLoading.value - ? null - : () { - context.showAdaptiveSheet( - (_) => const ResetEncryptionSheet(), - ); - }, + onPressed: () => 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 deleted file mode 100644 index 7195882..0000000 --- a/lib/widgets/renderers/change_line.dart +++ /dev/null @@ -1,72 +0,0 @@ -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 49c7b19..47750fa 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -9,37 +9,28 @@ 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, { @@ -65,7 +56,6 @@ 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) { @@ -92,13 +82,12 @@ class const EventRenderer( if (event == null) return SizedBox.shrink(); - final (hideMembershipEvents, hideServerAclEvents) = ref + final hideMembershipEvents = (ref .watch(SettingsController.provider) .maybeWhen( - orElse: () => (false, true), - data: (settings) => - (settings.hideMembershipEvents, settings.hideServerAclEvents), - ); + orElse: () => false, + data: (settings) => settings.hideMembershipEvents, + )); // Knocks and their outcomes stay visible, as moderators need to act on them if (hideMembershipEvents && @@ -109,33 +98,19 @@ 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; - // 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), - ], - ); + GenericEventRenderer generic(IconData icon, String text) => + .new(icon, maxLines: maxLines, [ + WidgetSpan( + alignment: .middle, + child: MessageDisplayname(event, shouldWrap: maxLines == null), + ), + TextSpan(text: text), + ]); final child = event.isShownAsMessage ? MessageRenderer( @@ -181,57 +156,18 @@ class const EventRenderer( "changed the room avatar", ), CreateContent() => generic(Icons.add, "created the room"), - final PowerLevelsContent content => generic( + PowerLevelsContent() => generic( Icons.power, "changed the room's power levels", - details: [ - PowerLevelsDiff( - roomId, - content, - previous: switch (event.previousContent) { - final PowerLevelsContent previous => previous, - _ => null, - }, - ), - ], ), - final JoinRulesContent content => generic( + JoinRulesContent() => generic( Icons.rule, "changed the room's join rules", - details: [ - JoinRulesDiff( - content, - previous: switch (event.previousContent) { - final JoinRulesContent previous => previous, - _ => null, - }, - ), - ], ), - 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, - ), - ], - ); - }(), + TopicContent() => generic( + Icons.description, + "updated the room topic", + ), HistoryVisibilityContent(:final historyVisibility) => generic( Icons.history, "changed the room's history visibility to ${switch (historyVisibility) { @@ -239,116 +175,42 @@ 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(: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( + PinnedEventsContent() => generic( + Icons.push_pin, + "pinned/unpinned some events", + ), + ServerACLContent() => 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) => () { - final previous = switch (event.previousContent) { - final CanonicalAliasContent previous => previous, - _ => null, - }; - final previousAltAliases = - previous?.altAliases ?? const ISet.empty(); + 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", - 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, - ), + 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", ], - ); - }(), - 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", + ]) { + [] => "did something related to room aliases", + List prev => prev.join(" and "), + }, ), _ => null, }; @@ -408,8 +270,7 @@ class const EventRenderer( crossAxisAlignment: .center, children: [ child, - if (child is! MessageRenderer && - child is! GenericEventRenderer) + if (child is! MessageRenderer) Timestamp(event.timestamp), ], ), diff --git a/lib/widgets/renderers/generic_event.dart b/lib/widgets/renderers/generic_event.dart index 3922aed..101913c 100644 --- a/lib/widgets/renderers/generic_event.dart +++ b/lib/widgets/renderers/generic_event.dart @@ -5,68 +5,33 @@ 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) { - // 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, + 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, ], - ), - ), - if (!previewing) ...details, - ], + ) + .toList(), ), + maxLines: maxLines ?? 999, + overflow: .ellipsis, ), - ], - ), - ); - } + ), + ], + ), + ); } diff --git a/lib/widgets/renderers/join_rules_diff.dart b/lib/widgets/renderers/join_rules_diff.dart deleted file mode 100644 index 6d41c0b..0000000 --- a/lib/widgets/renderers/join_rules_diff.dart +++ /dev/null @@ -1,72 +0,0 @@ -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 17f8128..b1811f0 100644 --- a/lib/widgets/renderers/membership.dart +++ b/lib/widgets/renderers/membership.dart @@ -1,27 +1,24 @@ -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/hooks/tap_recognizer.dart"; +import "package:nexus/helpers/extensions/show_user_popover.dart"; +import "package:nexus/helpers/extensions/string_to_color.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 HookConsumerWidget { +}) extends StatelessWidget { @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { 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, @@ -32,15 +29,19 @@ class const MembershipRenderer( Icons.people, maxLines: maxLines, [ - 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, + 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), + ), ), ), TextSpan( @@ -52,11 +53,13 @@ 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) - UserSpan.sender(context, ref, event, recognizer: senderRecognizer), + WidgetSpan( + alignment: .middle, + child: MessageDisplayname(event, shouldWrap: maxLines == null), + ), if (content.reason != null) TextSpan(text: "for \"${content.reason}\""), ], diff --git a/lib/widgets/renderers/message.dart b/lib/widgets/renderers/message.dart index b380577..598e9f9 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 && formattedBody != null && !textOnly + format == .html && !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 deleted file mode 100644 index 9f3d34c..0000000 --- a/lib/widgets/renderers/pinned_events_diff.dart +++ /dev/null @@ -1,111 +0,0 @@ -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 deleted file mode 100644 index f90c917..0000000 --- a/lib/widgets/renderers/power_levels_diff.dart +++ /dev/null @@ -1,141 +0,0 @@ -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 deleted file mode 100644 index e232614..0000000 --- a/lib/widgets/renderers/server_acl_diff.dart +++ /dev/null @@ -1,76 +0,0 @@ -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 db7120c..e7fd6e0 100644 --- a/lib/widgets/room_chat/room_chat.dart +++ b/lib/widgets/room_chat/room_chat.dart @@ -323,10 +323,6 @@ 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 358c8d8..38ad847 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: .new(color: danger)), + title: Text("Leave", style: TextStyle(color: danger)), ), ), ], diff --git a/lib/widgets/settings/dialog_list_tile.dart b/lib/widgets/settings/dialog_list_tile.dart index f884f31..b31182f 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: .ellipsis, + overflow: TextOverflow.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 deleted file mode 100644 index afd983b..0000000 --- a/lib/widgets/settings/enable_image_packs_sheet.dart +++ /dev/null @@ -1,118 +0,0 @@ -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 deleted file mode 100644 index 97f8599..0000000 --- a/lib/widgets/settings/image_pack_sheet.dart +++ /dev/null @@ -1,250 +0,0 @@ -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 deleted file mode 100644 index e9d1fbb..0000000 --- a/lib/widgets/settings/image_usage_chips.dart +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index 9ef70d6..0000000 --- a/lib/widgets/settings/pack_image_sheet.dart +++ /dev/null @@ -1,134 +0,0 @@ -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 a553c62..5b7d4e6 100644 --- a/lib/widgets/settings/power_level_tile.dart +++ b/lib/widgets/settings/power_level_tile.dart @@ -9,9 +9,7 @@ class const PowerLevelTile({ required final Widget leading, required final String title, required final String? description, - - /// Null for several levels that differ, shown as custom. - required final int? value, + required final int value, /// The user's own power level, which they can't set or change levels above. required final double ownLevel, @@ -24,14 +22,11 @@ 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 ?? 0) <= ownLevel; + final enabled = onChanged != null && value <= ownLevel; Future remove() async { if (removeConfirmation case final confirmation?) { @@ -70,7 +65,7 @@ class const PowerLevelTile({ children: [ Chip( label: Text( - value?.toString() ?? "Custom", + "$value", style: enabled ? null : .new(color: Colors.grey), ), ), @@ -80,15 +75,12 @@ class const PowerLevelTile({ icon: Icon(Icons.delete), onPressed: enabled ? remove : null, ), - ?trailing, ], ), onTap: () => context.showAdaptiveSheet( (context) => HookBuilder( builder: (context) { - final controller = useTextEditingController( - text: value?.toString(), - ); + final controller = useTextEditingController(text: "$value"); 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 5572d48..9f9512e 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: .min, + mainAxisSize: MainAxisSize.min, children: options .map( (option) => RadioListTile( diff --git a/lib/widgets/sticker_picker.dart b/lib/widgets/sticker_picker.dart deleted file mode 100644 index 3222399..0000000 --- a/lib/widgets/sticker_picker.dart +++ /dev/null @@ -1,47 +0,0 @@ -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 10aec7e..3f1e47e 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: .new( + style: TextStyle( color: banner.foreground, fontWeight: .bold, ), diff --git a/lib/widgets/user_bottom_sheet.dart b/lib/widgets/user_bottom_sheet.dart index 1e21581..58e29d2 100644 --- a/lib/widgets/user_bottom_sheet.dart +++ b/lib/widgets/user_bottom_sheet.dart @@ -1,5 +1,6 @@ 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"; @@ -16,7 +17,6 @@ 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,6 +80,12 @@ 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( @@ -401,8 +407,8 @@ final class const UserBottomSheet( icon: Icon(Icons.check), label: Text("Accept knock"), decoration: .styleFrom( - backgroundColor: theme.successScheme.primary, - foregroundColor: theme.successScheme.onPrimary, + backgroundColor: acceptScheme.primary, + foregroundColor: acceptScheme.onPrimary, ), ), ), diff --git a/lib/widgets/user_span.dart b/lib/widgets/user_span.dart deleted file mode 100644 index 4fb2f9c..0000000 --- a/lib/widgets/user_span.dart +++ /dev/null @@ -1,63 +0,0 @@ -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 7f5e185..2e0c766 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -126,42 +126,3 @@ 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/default.nix b/linux/nix/pkg/default.nix index 47bf2d7..5cec239 100644 --- a/linux/nix/pkg/default.nix +++ b/linux/nix/pkg/default.nix @@ -14,7 +14,7 @@ flutter.buildFlutterApplication { pname = "nexus"; - version = "1.0.0"; + version = "1.0.1"; inherit src; preBuild = '' diff --git a/linux/nix/pkg/gomuks.nix b/linux/nix/pkg/gomuks.nix index 37769dc..6c383d2 100644 --- a/linux/nix/pkg/gomuks.nix +++ b/linux/nix/pkg/gomuks.nix @@ -17,7 +17,7 @@ buildGoModule ( src = "${src}/gomuks"; - vendorHash = "sha256-XVuRmDvrn43heD/Jba5paPkY1cZnCsYvO+V4IXIuFYI="; + vendorHash = "sha256-nd0NEKXscgrFE/gBEW2aP+YeZNTOdbRa4QDLTRwIAZU="; buildPhase = '' runHook preBuild diff --git a/pubspec.lock b/pubspec.lock index 36ccd36..670b2ca 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -924,10 +924,10 @@ packages: dependency: "direct main" description: name: material_emoji_picker - sha256: "563acfa4dbfd023c2e1ff3b4ebf1083dbe2fc5f848f300682164245ad5fc5464" + sha256: "738e70a8d2f592be05dd9889037cc6098a63e0d8c48723125848865ab305f138" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "1.2.0" material_ui: dependency: "direct main" description: @@ -1805,5 +1805,5 @@ packages: source: hosted version: "2.2.4" sdks: - dart: ">=3.13.3 <4.0.0" + dart: "3.13.3" flutter: ">=3.47.0" diff --git a/pubspec.yaml b/pubspec.yaml index bf1debb..6a436bc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: nexus description: "A simple and user-friendly Matrix client" -version: 1.0.0 +version: 1.0.1 publish_to: none flutter: @@ -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: 2.0.1 + material_emoji_picker: 1.2.0 xdg_desktop_portal: git: url: https://github.com/Henry-Hiles/xdg_desktop_portal.dart diff --git a/windows/installer.iss b/windows/installer.iss index 22f4dcf..14c9333 100644 --- a/windows/installer.iss +++ b/windows/installer.iss @@ -1,6 +1,6 @@ [Setup] AppName=Nexus -AppVersion=1.0.0 +AppVersion=1.0.1 DefaultDirName={pf}\Nexus DefaultGroupName=Nexus OutputDir=dist