diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 5f16f9f..dc7fa02 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -16,22 +16,24 @@ jobs: with: submodules: recursive - - name: Set up Flutter - uses: subosito/flutter-action@v2 + - name: Lix GHA Installer Action + uses: samueldr/lix-gha-installer-action@v2026-02-22 with: - flutter-version: 3.44.4 - - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version-file: gomuks/go.mod + extra_nix_config: experimental-features = nix-command flakes flake-self-attrs - name: Build app run: | - flutter pub get - dart scripts/generate.dart - flutter pub run build_runner build - flutter build ios --release --no-codesign + 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 + ' # 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 4005dc5..f263d68 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -16,25 +16,27 @@ jobs: with: submodules: recursive - - name: Set up Flutter - uses: subosito/flutter-action@v2 + - name: Lix GHA Installer Action + uses: samueldr/lix-gha-installer-action@v2026-02-22 with: - flutter-version: 3.44.4 - - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version-file: gomuks/go.mod + extra_nix_config: experimental-features = nix-command flakes flake-self-attrs - name: Build app run: | - flutter pub get - dart scripts/generate.dart - flutter pub run build_runner build - flutter build macos --release + 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 + ' - name: Upload installer artifact uses: actions/upload-artifact@v6 with: - name: Nexus.App + name: App path: build/macos/Build/Products/Release/Nexus.app diff --git a/README.md b/README.md index 368bb90..57b42d8 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 -- [x] Platform Support +- [ ] Platform Support - [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] Windows - [x] Android - - [x] MacOS - Unsigned .App only - - [x] iOS - Unsigned .ipa only + - [ ] MacOS ([#50](https://git.federated.nexus/Nexus/nexus/issues/50)) + - [ ] iOS - [ ] Web (may not be possible) - [x] Login (via OAuth) - [x] Rooms / Spaces @@ -116,9 +116,6 @@ 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) @@ -129,8 +126,8 @@ If you want to try out Nexus, grab one of the following artifacts from CI: #### Linux -- 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. +- 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. #### Windows diff --git a/hook/build.dart b/hook/build.dart index 66ae366..067ccba 100644 --- a/hook/build.dart +++ b/hook/build.dart @@ -29,7 +29,6 @@ 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", @@ -37,7 +36,6 @@ 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"; @@ -50,7 +48,8 @@ Future main(List args) => build(args, (input, output) async { break; case OS.macOS: libFileName = "libgomuks.dylib"; - extraEnv = {"SDKROOT": await getXCodeTool()}; + + extraEnv = {"CC": await getXCodeTool(findTool: "clang")}; break; case OS.windows: libFileName = "libgomuks.dll"; diff --git a/lib/controllers/room_chat.dart b/lib/controllers/room_chat.dart index 98a3fd9..3e531bb 100644 --- a/lib/controllers/room_chat.dart +++ b/lib/controllers/room_chat.dart @@ -17,31 +17,27 @@ 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 initialRoom = ref.watch( + final room = ref.watch( RoomsController.provider.select((rooms) => rooms[roomId]), ); - if (initialRoom == null) return null; - Room room = initialRoom; + if (room == null) return null; 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) { - await loadOlder(); - room = ref.read(RoomsController.provider)[roomId] ?? room; + loadOlder(); } return room.timeline @@ -78,22 +74,18 @@ class RoomChatController extends AsyncNotifier?> { ), ); - 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; + Future loadOlder() async { + final timelineKeys = ref + .read(RoomsController.provider.select((value) => value[roomId])) + ?.timeline + .keys; final response = await ref .watch(ClientController.provider.notifier) .paginate( .new( roomId: roomId, - maxTimelineId: timelineKeys.isNotEmpty - ? timelineKeys.reduce(min) + maxTimelineId: timelineKeys?.isNotEmpty == true + ? timelineKeys?.reduce(min) : null, ), ); diff --git a/lib/widgets/room_chat.dart b/lib/widgets/room_chat.dart index 97afca2..fcab5fe 100644 --- a/lib/widgets/room_chat.dart +++ b/lib/widgets/room_chat.dart @@ -72,47 +72,23 @@ 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 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, - ), - ); + + final topEventBeforeLoad = useState(null); Future jumpToId(String eventId) async { - 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; - - final sourceIndex = events.indexWhere( - (event) => event.eventId == eventId, + final index = controllerData.value?.indexWhere( + (element) => element.eventId == eventId, ); - final boundaryIndex = events.indexWhere( - (event) => event.eventId == timelineBoundary.value, - ); - if (sourceIndex < 0 || boundaryIndex < 0) return; + if (index == null) return; - final listController = sourceIndex <= boundaryIndex - ? historyListController.value - : liveListController.value; - if (!listController.isAttached) return; - listController.animateToItem( - index: sourceIndex <= boundaryIndex - ? boundaryIndex - sourceIndex - : sourceIndex - boundaryIndex - 1, + listController.value.animateToItem( + index: index, scrollController: scrollController, alignment: 0.5, duration: (_) => .new(milliseconds: 700), @@ -127,11 +103,87 @@ class RoomChat extends HookConsumerWidget { } Future loadOlder() async { - if (controllerData case AsyncData(value: final _?)) { + if (controllerData case AsyncData(:final value?)) { + topEventBeforeLoad.value = value.firstOrNull?.eventId; 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) { @@ -413,117 +465,58 @@ class RoomChat extends HookConsumerWidget { padding: .symmetric(horizontal: 4), child: switch (controllerData) { AsyncData(:final value?) || - 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, - ), + 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"), ), ), - 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, - ), - ); - }, - ), - ], - ); - }, + ), + ), + + 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), + ), + ], ), AsyncData() => nothing, AsyncLoading() => Loading(), @@ -571,42 +564,3 @@ 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"), - ), - ), - ); - } -}