diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 1476116..4005dc5 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -26,22 +26,15 @@ jobs: with: go-version-file: gomuks/go.mod - - name: Build App + - name: Build app run: | flutter pub get dart scripts/generate.dart flutter pub run build_runner build flutter build macos --release - - name: Create DMG - id: create-dmg - uses: L-Super/create-dmg-actions@28511e988b13ca34096d439159a66d468b8e724f - with: - dmg_name: nexus - src_dir: build/macos/Build/Products/Release/Nexus.app - - - name: Upload DMG + - name: Upload installer artifact uses: actions/upload-artifact@v6 with: - name: nexus.dmg - path: ${{ steps.create-dmg.outputs.dmg_path }} + name: Nexus.App + path: build/macos/Build/Products/Release/Nexus.app diff --git a/README.md b/README.md index 0c18acf..368bb90 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend. - [x] Linux - [x] Windows - Currently has some bugs, due to us not having any developers on Windows. If you want to fix these, get in contact with us though the [Nexus Client Matrix room](https://matrix.to/#/#nexus:federated.nexus). - [x] Android - - [x] MacOS - Unsigned .dmg only + - [x] MacOS - Unsigned .App only - [x] iOS - Unsigned .ipa only - [ ] Web (may not be possible) - [x] Login (via OAuth) @@ -117,7 +117,7 @@ If you want to try out Nexus, grab one of the following artifacts from CI: - [Android APK](https://nightly.link/Henry-Hiles/nexus/workflows/android/main/APK.zip) - [Windows EXE](https://nightly.link/Henry-Hiles/nexus/workflows/windows/main/windows-installer.zip) - [Unsigned iOS IPA](https://nightly.link/Henry-Hiles/nexus/workflows/ios/main/Nexus.ipa.zip) -- [Unsigned MacOS DMG](https://nightly.link/Henry-Hiles/nexus/workflows/macos/main/nexus.dmg.zip) +- [Unsigned MacOS App](https://nightly.link/Henry-Hiles/nexus/workflows/macos/main/Nexus.App.zip) - [Windows EXE](https://nightly.link/Henry-Hiles/nexus/workflows/windows/main/windows-installer.zip) - Flatpak - [AArch64/Arm64](https://nightly.link/Henry-Hiles/nexus/workflows/flatpak/main/flatpak-aarch64.zip) diff --git a/hook/build.dart b/hook/build.dart index a893544..66ae366 100644 --- a/hook/build.dart +++ b/hook/build.dart @@ -50,10 +50,7 @@ Future main(List args) => build(args, (input, output) async { break; case OS.macOS: libFileName = "libgomuks.dylib"; - extraEnv = { - "SDKROOT": await getXCodeTool(), - "MACOSX_DEPLOYMENT_TARGET": codeConfig.macOS.targetVersion.toString(), - }; + extraEnv = {"SDKROOT": await getXCodeTool()}; break; case OS.windows: libFileName = "libgomuks.dll"; diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index 1682af0..0d531c4 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index 51f4a9b..da4acee 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index 97b3b3f..a3cfb1d 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index 17d971e..adbdcd5 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index ca74554..fee4302 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index 6129702..4d21624 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index 16c3d1e..3e7a859 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index 97b3b3f..a3cfb1d 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index f515ebd..518d397 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index 2e49a13..25f2b47 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png index 66b9ef2..8f79bb9 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png index 53af9ff..c48dec6 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png index 455803d..99d44e8 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png index 7a2fd0f..6f987f0 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index 2e49a13..25f2b47 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index c34ad9e..fcf969a 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png index 5e21545..16dea29 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png index 2140e17..3366fb5 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index a27d854..81c9cb8 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index 8b4eb75..efda04b 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 21c424b..3774574 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index b7d1eb4..e31e4f3 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -38,7 +38,6 @@ import "package:nexus/models/requests/set_membership.dart"; import "package:nexus/models/requests/set_state.dart"; import "package:nexus/models/requests/upload_media.dart"; import "package:nexus/models/room.dart"; -import "package:nexus/models/room_summary.dart"; import "package:nexus/models/spec_versions_response.dart"; import "package:nexus/models/sync_data.dart"; import "package:nexus/src/third_party/gomuks.g.dart"; @@ -214,11 +213,10 @@ class ClientController extends AsyncNotifier { } } - Future joinRoom(JoinRoomRequest request) async => - (await _sendCommand("join_room", request.toJson()))["room_id"]; - - Future getRoomSummary(JoinRoomRequest request) async => - .fromJson(await _sendCommand("get_room_summary", request.toJson())); + Future joinRoom(JoinRoomRequest request) async { + final response = await _sendCommand("join_room", request.toJson()); + return response["room_id"]; + } Future leaveRoom(Room room) async { if (room.metadata == null) return; @@ -313,7 +311,7 @@ class ClientController extends AsyncNotifier { Future discoverHomeserver(Uri homeserver) async { try { final response = await _sendCommand("discover_homeserver", { - "user_id": "@fake-user:${homeserver.authority}", + "user_id": "@fake-user:${homeserver.host}", }); return Uri.parse(response["m.homeserver"]?["base_url"]); } catch (error) { diff --git a/lib/controllers/room_chat.dart b/lib/controllers/room_chat.dart index 35038a0..75ce7d8 100644 --- a/lib/controllers/room_chat.dart +++ b/lib/controllers/room_chat.dart @@ -17,27 +17,30 @@ import "package:nexus/models/room.dart"; class RoomChatController extends AsyncNotifier?> { final String roomId; + Future? _loadingOlder; RoomChatController(this.roomId); @override Future?> build() async { final client = ref.watch(ClientController.provider.notifier); - final room = ref.watch( + final initialRoom = ref.watch( RoomsController.provider.select((rooms) => rooms[roomId]), ); - if (room == null) return null; + if (initialRoom == null) return null; + Room room = initialRoom; if (!room.hasFetchedState) { final state = await client.getRoomState(.new(roomId: roomId)); await ref.read(RoomsController.provider.notifier).addState(roomId, state); + room = ref.read(RoomsController.provider)[roomId] ?? room; } - // While there are under 20 events, try to load more - // until there's no more or the conditions are met. + // Load one more page when the initial timeline is short. if (room.hasMore && room.timeline.length < 20) { - loadOlder(); + await loadOlder(); + room = ref.read(RoomsController.provider)[roomId] ?? room; } return room.timeline @@ -74,47 +77,46 @@ class RoomChatController extends AsyncNotifier?> { ), ); - Future loadOlder() async { - state = AsyncLoading(); - final timelineKeys = ref - .read(RoomsController.provider.select((value) => value[roomId])) - ?.timeline - .keys; + Future loadOlder() => _loadingOlder ??= _loadOlder().whenComplete(() { + _loadingOlder = null; + }); + + Future _loadOlder() async { + final room = ref.read(RoomsController.provider)[roomId]; + if (room == null || !room.hasMore) return false; + + final timelineKeys = room.timeline.keys; final response = await ref - .read(ClientController.provider.notifier) + .watch(ClientController.provider.notifier) .paginate( .new( roomId: roomId, - maxTimelineId: timelineKeys?.isNotEmpty == true - ? timelineKeys?.reduce(min) + maxTimelineId: timelineKeys.isNotEmpty + ? timelineKeys.reduce(min) : null, ), ); - if (response.events.isEmpty) { - state = .data(state.value); - } else { - ref - .read(RoomsController.provider.notifier) - .update( - IMap({ - roomId: Room( - events: IMap.fromIterable( - response.events.addAll(response.relatedEvents), - keyMapper: (event) => event.rowId, - valueMapper: (event) => event, - ), - hasMore: response.hasMore, - timeline: IMap.fromIterable( - response.events, - keyMapper: (event) => event.timelineRowId, - valueMapper: (event) => event.rowId, - ), + ref + .watch(RoomsController.provider.notifier) + .update( + IMap({ + roomId: Room( + events: IMap.fromIterable( + response.events.addAll(response.relatedEvents), + keyMapper: (event) => event.rowId, + valueMapper: (event) => event, ), - }), - .new(), - ); - } + hasMore: response.hasMore, + timeline: IMap.fromIterable( + response.events, + keyMapper: (event) => event.timelineRowId, + valueMapper: (event) => event.rowId, + ), + ), + }), + .new(), + ); return response.hasMore; } diff --git a/lib/controllers/room_summary.dart b/lib/controllers/room_summary.dart deleted file mode 100644 index d47d8ef..0000000 --- a/lib/controllers/room_summary.dart +++ /dev/null @@ -1,18 +0,0 @@ -import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/client.dart"; -import "package:nexus/models/requests/join_room.dart"; -import "package:nexus/models/room_summary.dart"; - -class RoomSummaryController extends AsyncNotifier { - final JoinRoomRequest request; - RoomSummaryController(this.request); - - @override - Future build() => - ref.watch(ClientController.provider.notifier).getRoomSummary(request); - - static final provider = AsyncNotifierProvider.family - .autoDispose( - RoomSummaryController.new, - ); -} diff --git a/lib/controllers/settings.dart b/lib/controllers/settings.dart index 8c107e3..572d2e9 100644 --- a/lib/controllers/settings.dart +++ b/lib/controllers/settings.dart @@ -9,14 +9,14 @@ class SettingsController extends AsyncNotifier { final file = await ref.watch(SettingsFileController.provider.future); try { - return .fromJson(json.decode(await file.readAsString())); + return Settings.fromJson(json.decode(await file.readAsString())); } catch (_) { - return .new(); + return Settings(); } } Future set(Settings settings) async { - state = .data(settings); + state = AsyncData(settings); final file = await ref.watch(SettingsFileController.provider.future); await file.writeAsString(json.encode(settings.toJson())); } diff --git a/lib/helpers/extensions/link_to_mention.dart b/lib/helpers/extensions/link_to_mention.dart index 8595669..f4868d3 100644 --- a/lib/helpers/extensions/link_to_mention.dart +++ b/lib/helpers/extensions/link_to_mention.dart @@ -9,7 +9,6 @@ extension LinkToMention on String { /// /// Returns the decoded identifier (e.g. "#room:matrix.org") /// or null if this is not a Matrix link. - /// TODO: Needs to be reworked to handle event links. Might be worth just rewriting, I don't like this code. String? get mention { final trimmed = trim(); diff --git a/lib/models/requests/join_room.dart b/lib/models/requests/join_room.dart index 72cce7f..8487298 100644 --- a/lib/models/requests/join_room.dart +++ b/lib/models/requests/join_room.dart @@ -7,7 +7,7 @@ part "join_room.g.dart"; abstract class JoinRoomRequest with _$JoinRoomRequest { const factory JoinRoomRequest({ required String roomIdOrAlias, - @Default(IList.empty()) IList via, + required IList via, }) = _JoinRoomRequest; factory JoinRoomRequest.fromJson(Map json) => diff --git a/lib/models/room_summary.dart b/lib/models/room_summary.dart deleted file mode 100644 index d0ee96b..0000000 --- a/lib/models/room_summary.dart +++ /dev/null @@ -1,23 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/content/create.dart"; -import "package:nexus/models/join_rule.dart"; -part "room_summary.freezed.dart"; -part "room_summary.g.dart"; - -@freezed -abstract class RoomSummary with _$RoomSummary { - const factory RoomSummary({ - required String roomId, - @JsonKey(name: "num_joined_members") required int joinedMembers, - JoinRule? joinRule, - String? name, - Uri? avatarUrl, - String? canonicalAlias, - String? topic, - String? roomVersion, - @JsonKey(unknownEnumValue: RoomType.room) RoomType? roomType, - }) = _RoomSummary; - - factory RoomSummary.fromJson(Map json) => - _$RoomSummaryFromJson(json); -} diff --git a/lib/pages/select_server.dart b/lib/pages/select_server.dart index e8e689b..853e5fc 100644 --- a/lib/pages/select_server.dart +++ b/lib/pages/select_server.dart @@ -26,17 +26,6 @@ class SelectServerPage extends HookConsumerWidget { final homeserverUrl = useTextEditingController(); Future setHomeserver(Uri? newHomeserver) async { - if (newHomeserver == null) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - "Failed to parse homeserver URL. Are you sure you typed it correctly?", - style: .new(color: theme.colorScheme.onErrorContainer), - ), - backgroundColor: theme.colorScheme.errorContainer, - ), - ); - } isLoading.value = true; try { @@ -44,14 +33,26 @@ class SelectServerPage extends HookConsumerWidget { newHomeserver = Uri.https(newHomeserver!.path); } - final newUrl = await ref - .read(ClientController.provider.notifier) - .discoverHomeserver(newHomeserver!); + final newUrl = newHomeserver == null + ? null + : await ref + .read(ClientController.provider.notifier) + .discoverHomeserver(newHomeserver); if (context.mounted) { - Future tryLogin(Uri url) async { + if (newUrl == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + "Homeserver verification failed. Is your homeserver down?", + style: .new(color: theme.colorScheme.onErrorContainer), + ), + backgroundColor: theme.colorScheme.errorContainer, + ), + ); + } else { final codeResponse = await ref.watch( - AuthUrlController.provider(url).future, + AuthUrlController.provider(newUrl).future, ); await ref.watch(LaunchHelper.provider).launchUrl(codeResponse.url); @@ -69,7 +70,7 @@ class SelectServerPage extends HookConsumerWidget { .watch(ClientController.provider.notifier) .exchangeToken( .new( - homeserverUrl: url, + homeserverUrl: newUrl, codeVerifier: codeResponse.codeVerifier, redirectUri: .new( scheme: "nexus.federated.nexus", @@ -77,7 +78,7 @@ class SelectServerPage extends HookConsumerWidget { ), code: code, clientId: await ref.watch( - ClientIdController.provider(url).future, + ClientIdController.provider(newUrl).future, ), ), ) @@ -88,25 +89,6 @@ class SelectServerPage extends HookConsumerWidget { } }); } - - if (newUrl == null) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - "Homeserver verification failed.", - style: .new(color: theme.colorScheme.onErrorContainer), - ), - action: SnackBarAction( - onPressed: () => tryLogin(newHomeserver!), - label: "Attempt log in anyways", - textColor: theme.colorScheme.onErrorContainer, - ), - backgroundColor: theme.colorScheme.errorContainer, - ), - ); - } else { - tryLogin(newUrl); - } } } catch (error, stackTrace) { showError(error, stackTrace); diff --git a/lib/widgets/html/mention_chip.dart b/lib/widgets/html/mention_chip.dart index 57e23b4..f6105e3 100644 --- a/lib/widgets/html/mention_chip.dart +++ b/lib/widgets/html/mention_chip.dart @@ -1,11 +1,8 @@ import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/room_summary.dart"; import "package:nexus/controllers/user.dart"; import "package:nexus/helpers/extensions/link_to_mention.dart"; import "package:nexus/helpers/extensions/show_user_popover.dart"; -import "package:nexus/models/content/membership.dart"; -import "package:nexus/models/room_summary.dart"; class MentionChip extends ConsumerWidget { final String? roomId; @@ -15,48 +12,29 @@ class MentionChip extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final mention = content.mention; - final data = switch (mention?.characters.firstOrNull) { - "@" => - ref - .watch( - UserController.provider(.new(roomId: roomId, userId: mention!)), - ) - .whenOrNull(data: (data) => data), - - "#" || "!" => - ref - .watch( - RoomSummaryController.provider(.new(roomIdOrAlias: mention!)), - ) - .whenOrNull(data: (data) => data), - - _ => null, - }; + final membership = mention?.startsWith("@") == true + ? ref + .watch( + UserController.provider(.new(roomId: roomId, userId: mention!)), + ) + .whenOrNull(data: (data) => data) + : null; return mention == null ? SizedBox.shrink() : InkWell( onTap: () { - if (data case MembershipContent membership) { + if (membership != null) { context.showUserPopover(membership, mention, roomId: roomId); - } else if (data case RoomSummary summary) { - // TODO: Handle summary } }, child: IgnorePointer( child: Chip( label: Text( - switch (data) { - RoomSummary summary => - (summary.name == null ? null : "#${summary.name}") ?? - summary.canonicalAlias ?? - summary.roomId, - MembershipContent membership => - membership.displayName == null - ? mention - : "@${membership.displayName}", - _ => mention, - }, + (membership?.displayName == null + ? null + : "@${membership!.displayName}") ?? + mention, style: .new( fontWeight: .bold, color: Theme.of(context).colorScheme.onPrimary, diff --git a/lib/widgets/room_chat.dart b/lib/widgets/room_chat.dart index 01908a8..f50db27 100644 --- a/lib/widgets/room_chat.dart +++ b/lib/widgets/room_chat.dart @@ -72,24 +72,40 @@ class RoomChat extends HookConsumerWidget { final controllerProvider = RoomChatController.provider(roomId); final notifier = ref.watch(controllerProvider.notifier); - final client = ref.watch(ClientController.provider.notifier); - final listController = useRef(ListController()); final scrollController = useScrollController(); final controllerData = ref.watch(controllerProvider); - - final topEventBeforeLoad = useState(null); - final hasMore = useState(true); + final historyListController = useRef(ListController()); + final liveListController = useRef(ListController()); + final timelineCenterKey = useMemoized(GlobalKey.new); + final timelineBoundary = useRef(null); + final hasMore = ref.watch( + RoomsController.provider.select( + (rooms) => rooms[roomId]?.hasMore ?? false, + ), + ); Future jumpToId(String eventId) async { - final index = controllerData.value?.indexWhere( - (element) => element.eventId == eventId, - ); - if (index == null) return; + final events = controllerData.value; + if (events == null || !scrollController.hasClients) return; - listController.value.animateToItem( - index: index, + final sourceIndex = events.indexWhere( + (event) => event.eventId == eventId, + ); + final boundaryIndex = events.indexWhere( + (event) => event.eventId == timelineBoundary.value, + ); + if (sourceIndex < 0 || boundaryIndex < 0) return; + + final listController = sourceIndex <= boundaryIndex + ? historyListController.value + : liveListController.value; + if (!listController.isAttached) return; + listController.animateToItem( + index: sourceIndex <= boundaryIndex + ? boundaryIndex - sourceIndex + : sourceIndex - boundaryIndex - 1, scrollController: scrollController, alignment: 0.5, duration: (_) => .new(milliseconds: 700), @@ -103,88 +119,6 @@ class RoomChat extends HookConsumerWidget { }); } - Future loadOlder() async { - if (controllerData case AsyncData(:final value?)) { - topEventBeforeLoad.value = value.firstOrNull?.eventId; - hasMore.value = await notifier.loadOlder(); - } - } - - useEffect(() { - ref - .read(controllerProvider.future) - .then( - (_) => WidgetsBinding.instance.addPostFrameCallback((_) { - if (scrollController.hasClients) { - scrollController.jumpTo( - scrollController.position.maxScrollExtent - .000001, - ); - } - }), - ); - - return null; - }, [scrollController.hasClients]); - - useEffect(() { - if (controllerData case AsyncData( - :final value?, - ) when scrollController.hasClients) { - if (topEventBeforeLoad.value != null) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (scrollController.hasClients) { - final index = value.indexWhere( - (event) => event.eventId == topEventBeforeLoad.value, - ); - if (index != -1) { - listController.value.jumpToItem( - index: index, - scrollController: scrollController, - alignment: 0, - ); - } - } - topEventBeforeLoad.value = null; - }); - } else if (scrollController.position.atEdge && - scrollController.position.pixels != 0) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (scrollController.hasClients) { - scrollController.jumpTo( - scrollController.position.maxScrollExtent, - ); - } - }); - } - } - - return null; - }, [controllerData]); - - useEffect(() { - Future listener() async { - if (!scrollController.hasClients || !scrollController.position.atEdge) { - return; - } - - final room = ref.watch( - RoomsController.provider.select((value) => value[roomId]), - ); - if (room == null) return; - - if (scrollController.position.pixels == 0) { - if (room.hasMore) { - await loadOlder(); - } - } else { - await client.markRead(room); - } - } - - scrollController.addListener(listener); - return () => scrollController.removeListener(listener); - }, [roomId, controllerData]); - final composerNode = useFocusNode( onKeyEvent: (_, event) { if (event is KeyDownEvent && event.logicalKey == .escape) { @@ -311,7 +245,7 @@ class RoomChat extends HookConsumerWidget { await Clipboard.setData( ClipboardData( text: - "matrix:roomid/${room.metadata?.id.substring(1)}/e/${event.eventId}$vias", + "matrix:roomid/${room.metadata?.id.substring(1)}/e/${event.eventId}$vias)", ), ); }, @@ -466,59 +400,120 @@ class RoomChat extends HookConsumerWidget { padding: .symmetric(horizontal: 4), child: switch (controllerData) { AsyncData(:final value?) || - AsyncLoading(:final value?) => CustomScrollView( - keyboardDismissBehavior: - ScrollViewKeyboardDismissBehavior.onDrag, - controller: scrollController, - slivers: [ - if (hasMore.value) - SliverToBoxAdapter( - child: Padding( - padding: .symmetric(vertical: 36), - child: Center( - child: ElevatedButton( - onPressed: controllerData is AsyncData - ? loadOlder - : null, - child: Text("Load More"), + AsyncLoading(:final value?) => Builder( + builder: (_) { + if (value.isEmpty) return nothing; + var boundaryIndex = value.indexWhere( + (event) => + event.eventId == timelineBoundary.value, + ); + if (boundaryIndex < 0) { + boundaryIndex = value.length - 1; + timelineBoundary.value = + value[boundaryIndex].eventId; + } + + final historyLength = boundaryIndex + 1; + final liveLength = value.length - historyLength; + + int? eventIndexForKey(Key key) { + if (key is! ValueKey) return null; + final index = value.indexWhere( + (event) => event.eventId == key.value, + ); + return index < 0 ? null : index; + } + + int? historyChildIndex(Key key) { + if (key == const ValueKey("history-loader")) { + return historyLength; + } + final eventIndex = eventIndexForKey(key); + return eventIndex == null || + eventIndex >= historyLength + ? null + : historyLength - eventIndex - 1; + } + + int? liveChildIndex(Key key) { + final eventIndex = eventIndexForKey(key); + return eventIndex == null || + eventIndex < historyLength + ? null + : eventIndex - historyLength; + } + + Widget eventRow( + Event event, + Event? previousEvent, + ) => HighlightWrapper( + EventRenderer( + event, + onTapReply: () => jumpToId(event.replyTo!), + getEventOptions: getEventOptions, + isGrouped: + previousEvent?.content + is MessageContent && + previousEvent?.redactedBy == null && + previousEvent?.relationType != + "m.replace" && + "${event.sender}${event.pmp?.id}" == + "${previousEvent?.sender}${previousEvent?.pmp?.id}", + ), + key: ValueKey(event.eventId), + isHighlighted: + highlightedEvent.value == event.eventId, + ); + + return CustomScrollView( + reverse: true, + center: timelineCenterKey, + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + controller: scrollController, + slivers: [ + SliverToBoxAdapter( + child: SizedBox(height: composerSize.value), + ), + SuperSliverList.builder( + listController: liveListController.value, + itemCount: liveLength, + findChildIndexCallback: liveChildIndex, + itemBuilder: (_, index) => eventRow( + value[historyLength + index], + value.getOrNull( + historyLength + index - 1, ), ), ), - ), - - SuperSliverList.builder( - listController: listController.value, - itemCount: value.length, - itemBuilder: (_, index) { - final event = value[index]; - final previousEvent = value.getOrNull( - index - 1, - ); - return HighlightWrapper( - EventRenderer( - event, - onTapReply: () => - jumpToId(event.replyTo!), - getEventOptions: getEventOptions, - isGrouped: - previousEvent?.content - is MessageContent && - previousEvent?.redactedBy == null && - previousEvent?.relationType != - "m.replace" && - "${event.sender}${event.pmp?.id}" == - "${previousEvent?.sender}${previousEvent?.pmp?.id}", - ), - isHighlighted: - highlightedEvent.value == event.eventId, - ); - }, - ), - - SliverPadding( - padding: .only(bottom: composerSize.value), - ), - ], + SuperSliverList.builder( + key: timelineCenterKey, + listController: historyListController.value, + itemCount: historyLength + 1, + findChildIndexCallback: historyChildIndex, + itemBuilder: (_, index) { + if (index == historyLength) { + return _HistoryLoader( + key: const ValueKey("history-loader"), + hasMore: hasMore, + isLoading: + controllerData is AsyncLoading, + onLoad: notifier.loadOlder, + ); + } + final event = + value[historyLength - index - 1]; + return eventRow( + event, + value.getOrNull( + historyLength - index - 2, + ), + ); + }, + ), + ], + ); + }, ), AsyncData() => nothing, AsyncLoading() => Loading(), @@ -566,3 +561,42 @@ class RoomChat extends HookConsumerWidget { ); } } + +class _HistoryLoader extends HookWidget { + final bool hasMore; + final bool isLoading; + final Future Function() onLoad; + + const _HistoryLoader({ + required this.hasMore, + required this.isLoading, + required this.onLoad, + super.key, + }); + + @override + Widget build(BuildContext context) { + useEffect(() { + if (hasMore) { + Future.microtask(onLoad); + } + return null; + }, const []); + + return Padding( + padding: .symmetric(vertical: 36), + child: Center( + child: ElevatedButton( + onPressed: hasMore && !isLoading ? onLoad : null, + child: isLoading + ? SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(hasMore ? "Load More" : "Start of timeline"), + ), + ), + ); + } +} diff --git a/lib/widgets/room_menu.dart b/lib/widgets/room_menu.dart index e99991f..95936df 100644 --- a/lib/widgets/room_menu.dart +++ b/lib/widgets/room_menu.dart @@ -35,7 +35,8 @@ class RoomMenu extends ConsumerWidget { await Clipboard.setData( .new( - text: "matrix:roomid/${room!.metadata?.id.substring(1)}$vias", + text: + "matrix:roomid/${room!.metadata?.id.substring(1)}$vias)", ), ); }, diff --git a/macos/Podfile b/macos/Podfile index 65543e2..ff5ddb3 100644 --- a/macos/Podfile +++ b/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '13.0' +platform :osx, '10.15' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index fa18d44..67876ad 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -567,7 +567,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 13.0; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -649,7 +649,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 13.0; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -699,7 +699,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 13.0; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json index 96d3fee..a2ec33f 100644 --- a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,68 +1,68 @@ { - "info": { - "version": 1, - "author": "xcode" + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" }, - "images": [ - { - "size": "16x16", - "idiom": "mac", - "filename": "app_icon_16.png", - "scale": "1x" - }, - { - "size": "16x16", - "idiom": "mac", - "filename": "app_icon_32.png", - "scale": "2x" - }, - { - "size": "32x32", - "idiom": "mac", - "filename": "app_icon_32.png", - "scale": "1x" - }, - { - "size": "32x32", - "idiom": "mac", - "filename": "app_icon_64.png", - "scale": "2x" - }, - { - "size": "128x128", - "idiom": "mac", - "filename": "app_icon_128.png", - "scale": "1x" - }, - { - "size": "128x128", - "idiom": "mac", - "filename": "app_icon_256.png", - "scale": "2x" - }, - { - "size": "256x256", - "idiom": "mac", - "filename": "app_icon_256.png", - "scale": "1x" - }, - { - "size": "256x256", - "idiom": "mac", - "filename": "app_icon_512.png", - "scale": "2x" - }, - { - "size": "512x512", - "idiom": "mac", - "filename": "app_icon_512.png", - "scale": "1x" - }, - { - "size": "512x512", - "idiom": "mac", - "filename": "app_icon_1024.png", - "scale": "2x" - } - ] -} \ No newline at end of file + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png index 1682af0..82b6f9d 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png index c2e488b..13b35eb 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png index e2bf256..0a3f5fa 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png index 983d7fa..bdb5722 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png index c8f0676..f083318 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png index 468be1f..326c0e7 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png index a77207d..2f1632c 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/pubspec.yaml b/pubspec.yaml index e74d5d7..adf1ba1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -87,15 +87,12 @@ dev_dependencies: json_serializable: 6.14.1 flutter_launcher_icons: - image_path: assets/icon.png ios: true - image_path_ios: assets/mobile.png android: true + image_path: assets/icon.png adaptive_icon_background: assets/background.png adaptive_icon_foreground: assets/foreground.png adaptive_icon_monochrome: assets/monochrome.png + remove_alpha_ios: true windows: generate: true - macos: - generate: true - image_path: assets/mobile.png