diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index dc7fa02..5f16f9f 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -16,24 +16,22 @@ jobs: with: submodules: recursive - - name: Lix GHA Installer Action - uses: samueldr/lix-gha-installer-action@v2026-02-22 + - name: Set up Flutter + uses: subosito/flutter-action@v2 with: - extra_nix_config: experimental-features = nix-command flakes flake-self-attrs + flutter-version: 3.44.4 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: gomuks/go.mod - name: Build app run: | - nix develop --command bash -c ' - set -e - flutter pub get - dart scripts/generate.dart - flutter pub run build_runner build - - FLUTTER_ROOT="$(dirname "$(dirname "$(readlink -f "$(command -v flutter)")")")" - sudo find -L "$FLUTTER_ROOT" -type d -iname "*.framework" -exec chmod -R u+w {} + - - flutter build ios --release --no-codesign - ' + flutter pub get + dart scripts/generate.dart + flutter pub run build_runner build + flutter build ios --release --no-codesign # Unsigned IPA: SideStore/AltStore re-sign this on-device with the # user's own Apple ID at install time, so no certificate is needed here. diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index f263d68..4005dc5 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -16,27 +16,25 @@ jobs: with: submodules: recursive - - name: Lix GHA Installer Action - uses: samueldr/lix-gha-installer-action@v2026-02-22 + - name: Set up Flutter + uses: subosito/flutter-action@v2 with: - extra_nix_config: experimental-features = nix-command flakes flake-self-attrs + flutter-version: 3.44.4 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: gomuks/go.mod - name: Build app run: | - nix develop --command bash -c ' - set -e - flutter pub get - dart scripts/generate.dart - flutter pub run build_runner build - - FLUTTER_ROOT="$(dirname "$(dirname "$(readlink -f "$(command -v flutter)")")")" - sudo find -L "$FLUTTER_ROOT" -type d -iname "*.framework" -exec chmod -R u+w {} + - - flutter build macos --release - ' + flutter pub get + dart scripts/generate.dart + flutter pub run build_runner build + flutter build macos --release - name: Upload installer artifact uses: actions/upload-artifact@v6 with: - name: App + name: Nexus.App path: build/macos/Build/Products/Release/Nexus.app diff --git a/README.md b/README.md index 57b42d8..368bb90 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,12 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend. ## Progress -- [ ] Platform Support +- [x] Platform Support - [x] Linux - - [x] Windows + - [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 - - [ ] MacOS ([#50](https://git.federated.nexus/Nexus/nexus/issues/50)) - - [ ] iOS + - [x] MacOS - Unsigned .App only + - [x] iOS - Unsigned .ipa only - [ ] Web (may not be possible) - [x] Login (via OAuth) - [x] Rooms / Spaces @@ -116,6 +116,9 @@ 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 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) - [x86_64/AMD64](https://nightly.link/Henry-Hiles/nexus/workflows/flatpak/main/flatpak-x86_64.zip) @@ -126,8 +129,8 @@ If you want to try out Nexus, grab one of the following artifacts from CI: #### Linux -- With Nix: Either use direnv and `direnv allow`, or `nix flake develop` -- Without Nix: Install Flutter, Go, Git, Libclang, Libass, MPV, and Glibc. Do not use any Snap packages, they cause various compilation issues. +- On NixOS: Either use direnv and `direnv allow`, or `nix flake develop` +- On other distros: Install Flutter, Go, Git, Libclang, Libass, MPV, and Glibc. Do not use any Snap packages, they cause various compilation issues. #### Windows diff --git a/hook/build.dart b/hook/build.dart index 067ccba..66ae366 100644 --- a/hook/build.dart +++ b/hook/build.dart @@ -29,6 +29,7 @@ Future main(List args) => build(args, (input, output) async { iosSdk = iosConfig.targetSdk; final minVersion = iosConfig.targetVersion; iosSdkPath = await getXCodeTool(sdkType: iosSdk.type); + final archTriple = switch (targetArch) { Architecture.arm64 => "arm64", Architecture.x64 => "x86_64", @@ -36,6 +37,7 @@ Future main(List args) => build(args, (input, output) async { "Unsupported iOS architecture: $targetArch", ), }; + iosTargetTriple = iosSdk == IOSSdk.iPhoneSimulator ? "$archTriple-apple-ios$minVersion.0-simulator" : "$archTriple-apple-ios$minVersion.0"; @@ -48,8 +50,7 @@ Future main(List args) => build(args, (input, output) async { break; case OS.macOS: libFileName = "libgomuks.dylib"; - - extraEnv = {"CC": await getXCodeTool(findTool: "clang")}; + extraEnv = {"SDKROOT": await getXCodeTool()}; break; case OS.windows: libFileName = "libgomuks.dll"; diff --git a/lib/controllers/room_chat.dart b/lib/controllers/room_chat.dart index 3e531bb..98a3fd9 100644 --- a/lib/controllers/room_chat.dart +++ b/lib/controllers/room_chat.dart @@ -17,27 +17,31 @@ 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. if (room.hasMore && room.timeline.length < 20) { - loadOlder(); + await loadOlder(); + room = ref.read(RoomsController.provider)[roomId] ?? room; } return room.timeline @@ -74,18 +78,22 @@ class RoomChatController extends AsyncNotifier?> { ), ); - Future loadOlder() async { - 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 .watch(ClientController.provider.notifier) .paginate( .new( roomId: roomId, - maxTimelineId: timelineKeys?.isNotEmpty == true - ? timelineKeys?.reduce(min) + maxTimelineId: timelineKeys.isNotEmpty + ? timelineKeys.reduce(min) : null, ), ); diff --git a/lib/widgets/room_chat.dart b/lib/widgets/room_chat.dart index fcab5fe..97afca2 100644 --- a/lib/widgets/room_chat.dart +++ b/lib/widgets/room_chat.dart @@ -72,23 +72,47 @@ 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 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; + var events = ref.read(controllerProvider).value; + while (events?.any((event) => event.eventId == eventId) != true) { + final room = ref.read(RoomsController.provider)[roomId]; + if (room == null || !room.hasMore) return; + await notifier.loadOlder(); + await WidgetsBinding.instance.endOfFrame; + events = ref.read(controllerProvider).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,87 +127,11 @@ class RoomChat extends HookConsumerWidget { } Future loadOlder() async { - if (controllerData case AsyncData(:final value?)) { - topEventBeforeLoad.value = value.firstOrNull?.eventId; + if (controllerData case AsyncData(value: final _?)) { 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) { @@ -465,58 +413,117 @@ class RoomChat extends HookConsumerWidget { padding: .symmetric(horizontal: 4), child: switch (controllerData) { AsyncData(:final value?) || - AsyncLoading(:final value?) => CustomScrollView( - keyboardDismissBehavior: - ScrollViewKeyboardDismissBehavior.onDrag, - controller: scrollController, - slivers: [ - 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? historyChildIndex(Key key) { + if (key == const ValueKey("history-loader")) { + return historyLength; + } + if (key is! ValueKey) return null; + final eventIndex = value.indexWhere( + (event) => event.eventId == key.value, + ); + return eventIndex < 0 || + eventIndex >= historyLength + ? null + : historyLength - eventIndex - 1; + } + + int? liveChildIndex(Key key) { + if (key is! ValueKey) return null; + final eventIndex = value.indexWhere( + (event) => event.eventId == key.value, + ); + return 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: loadOlder, + ); + } + final event = + value[historyLength - index - 1]; + return eventRow( + event, + value.getOrNull( + historyLength - index - 2, + ), + ); + }, + ), + ], + ); + }, ), AsyncData() => nothing, AsyncLoading() => Loading(), @@ -564,3 +571,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"), + ), + ), + ); + } +}