Compare commits

...
6 changed files with 238 additions and 184 deletions

View file

@ -16,24 +16,22 @@ jobs:
with: with:
submodules: recursive submodules: recursive
- name: Lix GHA Installer Action - name: Set up Flutter
uses: samueldr/lix-gha-installer-action@v2026-02-22 uses: subosito/flutter-action@v2
with: 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 - name: Build app
run: | run: |
nix develop --command bash -c ' flutter pub get
set -e dart scripts/generate.dart
flutter pub get flutter pub run build_runner build
dart scripts/generate.dart flutter build ios --release --no-codesign
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 # 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. # user's own Apple ID at install time, so no certificate is needed here.

View file

@ -16,27 +16,25 @@ jobs:
with: with:
submodules: recursive submodules: recursive
- name: Lix GHA Installer Action - name: Set up Flutter
uses: samueldr/lix-gha-installer-action@v2026-02-22 uses: subosito/flutter-action@v2
with: 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 - name: Build app
run: | run: |
nix develop --command bash -c ' flutter pub get
set -e dart scripts/generate.dart
flutter pub get flutter pub run build_runner build
dart scripts/generate.dart flutter build macos --release
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 - name: Upload installer artifact
uses: actions/upload-artifact@v6 uses: actions/upload-artifact@v6
with: with:
name: App name: Nexus.App
path: build/macos/Build/Products/Release/Nexus.app path: build/macos/Build/Products/Release/Nexus.app

View file

@ -15,12 +15,12 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
## Progress ## Progress
- [ ] Platform Support - [x] Platform Support
- [x] Linux - [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 - [x] Android
- [ ] MacOS ([#50](https://git.federated.nexus/Nexus/nexus/issues/50)) - [x] MacOS - Unsigned .App only
- [ ] iOS - [x] iOS - Unsigned .ipa only
- [ ] Web (may not be possible) - [ ] Web (may not be possible)
- [x] Login (via OAuth) - [x] Login (via OAuth)
- [x] Rooms / Spaces - [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) - [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) - [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 - Flatpak
- [AArch64/Arm64](https://nightly.link/Henry-Hiles/nexus/workflows/flatpak/main/flatpak-aarch64.zip) - [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) - [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 #### Linux
- With Nix: Either use direnv and `direnv allow`, or `nix flake develop` - On NixOS: 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 other distros: Install Flutter, Go, Git, Libclang, Libass, MPV, and Glibc. Do not use any Snap packages, they cause various compilation issues.
#### Windows #### Windows

View file

@ -29,6 +29,7 @@ Future<void> main(List<String> args) => build(args, (input, output) async {
iosSdk = iosConfig.targetSdk; iosSdk = iosConfig.targetSdk;
final minVersion = iosConfig.targetVersion; final minVersion = iosConfig.targetVersion;
iosSdkPath = await getXCodeTool(sdkType: iosSdk.type); iosSdkPath = await getXCodeTool(sdkType: iosSdk.type);
final archTriple = switch (targetArch) { final archTriple = switch (targetArch) {
Architecture.arm64 => "arm64", Architecture.arm64 => "arm64",
Architecture.x64 => "x86_64", Architecture.x64 => "x86_64",
@ -36,6 +37,7 @@ Future<void> main(List<String> args) => build(args, (input, output) async {
"Unsupported iOS architecture: $targetArch", "Unsupported iOS architecture: $targetArch",
), ),
}; };
iosTargetTriple = iosSdk == IOSSdk.iPhoneSimulator iosTargetTriple = iosSdk == IOSSdk.iPhoneSimulator
? "$archTriple-apple-ios$minVersion.0-simulator" ? "$archTriple-apple-ios$minVersion.0-simulator"
: "$archTriple-apple-ios$minVersion.0"; : "$archTriple-apple-ios$minVersion.0";
@ -48,8 +50,7 @@ Future<void> main(List<String> args) => build(args, (input, output) async {
break; break;
case OS.macOS: case OS.macOS:
libFileName = "libgomuks.dylib"; libFileName = "libgomuks.dylib";
extraEnv = {"SDKROOT": await getXCodeTool()};
extraEnv = {"CC": await getXCodeTool(findTool: "clang")};
break; break;
case OS.windows: case OS.windows:
libFileName = "libgomuks.dll"; libFileName = "libgomuks.dll";

View file

@ -17,27 +17,31 @@ import "package:nexus/models/room.dart";
class RoomChatController extends AsyncNotifier<IList<Event>?> { class RoomChatController extends AsyncNotifier<IList<Event>?> {
final String roomId; final String roomId;
Future<bool>? _loadingOlder;
RoomChatController(this.roomId); RoomChatController(this.roomId);
@override @override
Future<IList<Event>?> build() async { Future<IList<Event>?> build() async {
final client = ref.watch(ClientController.provider.notifier); final client = ref.watch(ClientController.provider.notifier);
final room = ref.watch( final initialRoom = ref.watch(
RoomsController.provider.select((rooms) => rooms[roomId]), RoomsController.provider.select((rooms) => rooms[roomId]),
); );
if (room == null) return null; if (initialRoom == null) return null;
Room room = initialRoom;
if (!room.hasFetchedState) { if (!room.hasFetchedState) {
final state = await client.getRoomState(.new(roomId: roomId)); final state = await client.getRoomState(.new(roomId: roomId));
await ref.read(RoomsController.provider.notifier).addState(roomId, state); 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 // While there are under 20 events, try to load more
// until there's no more or the conditions are met. // until there's no more or the conditions are met.
if (room.hasMore && room.timeline.length < 20) { if (room.hasMore && room.timeline.length < 20) {
loadOlder(); await loadOlder();
room = ref.read(RoomsController.provider)[roomId] ?? room;
} }
return room.timeline return room.timeline
@ -74,18 +78,22 @@ class RoomChatController extends AsyncNotifier<IList<Event>?> {
), ),
); );
Future<bool> loadOlder() async { Future<bool> loadOlder() => _loadingOlder ??= _loadOlder().whenComplete(() {
final timelineKeys = ref _loadingOlder = null;
.read(RoomsController.provider.select((value) => value[roomId])) });
?.timeline
.keys; Future<bool> _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 final response = await ref
.watch(ClientController.provider.notifier) .watch(ClientController.provider.notifier)
.paginate( .paginate(
.new( .new(
roomId: roomId, roomId: roomId,
maxTimelineId: timelineKeys?.isNotEmpty == true maxTimelineId: timelineKeys.isNotEmpty
? timelineKeys?.reduce(min) ? timelineKeys.reduce(min)
: null, : null,
), ),
); );

View file

@ -72,23 +72,47 @@ class RoomChat extends HookConsumerWidget {
final controllerProvider = RoomChatController.provider(roomId); final controllerProvider = RoomChatController.provider(roomId);
final notifier = ref.watch(controllerProvider.notifier); final notifier = ref.watch(controllerProvider.notifier);
final client = ref.watch(ClientController.provider.notifier); final client = ref.watch(ClientController.provider.notifier);
final listController = useRef(ListController());
final scrollController = useScrollController(); final scrollController = useScrollController();
final controllerData = ref.watch(controllerProvider); final controllerData = ref.watch(controllerProvider);
final historyListController = useRef(ListController());
final topEventBeforeLoad = useState<String?>(null); final liveListController = useRef(ListController());
final timelineCenterKey = useMemoized(GlobalKey.new);
final timelineBoundary = useRef<String?>(null);
final hasMore = ref.watch(
RoomsController.provider.select(
(rooms) => rooms[roomId]?.hasMore ?? false,
),
);
Future<void> jumpToId(String eventId) async { Future<void> jumpToId(String eventId) async {
final index = controllerData.value?.indexWhere( var events = ref.read(controllerProvider).value;
(element) => element.eventId == eventId, while (events?.any((event) => event.eventId == eventId) != true) {
); final room = ref.read(RoomsController.provider)[roomId];
if (index == null) return; 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( final sourceIndex = events.indexWhere(
index: index, (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, scrollController: scrollController,
alignment: 0.5, alignment: 0.5,
duration: (_) => .new(milliseconds: 700), duration: (_) => .new(milliseconds: 700),
@ -103,87 +127,11 @@ class RoomChat extends HookConsumerWidget {
} }
Future<void> loadOlder() async { Future<void> loadOlder() async {
if (controllerData case AsyncData(:final value?)) { if (controllerData case AsyncData(value: final _?)) {
topEventBeforeLoad.value = value.firstOrNull?.eventId;
await notifier.loadOlder(); 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<void> 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( final composerNode = useFocusNode(
onKeyEvent: (_, event) { onKeyEvent: (_, event) {
if (event is KeyDownEvent && event.logicalKey == .escape) { if (event is KeyDownEvent && event.logicalKey == .escape) {
@ -465,58 +413,117 @@ class RoomChat extends HookConsumerWidget {
padding: .symmetric(horizontal: 4), padding: .symmetric(horizontal: 4),
child: switch (controllerData) { child: switch (controllerData) {
AsyncData(:final value?) || AsyncData(:final value?) ||
AsyncLoading(:final value?) => CustomScrollView( AsyncLoading(:final value?) => Builder(
keyboardDismissBehavior: builder: (_) {
ScrollViewKeyboardDismissBehavior.onDrag, if (value.isEmpty) return nothing;
controller: scrollController, var boundaryIndex = value.indexWhere(
slivers: [ (event) =>
SliverToBoxAdapter( event.eventId == timelineBoundary.value,
child: Padding( );
padding: .symmetric(vertical: 36), if (boundaryIndex < 0) {
child: Center( boundaryIndex = value.length - 1;
child: ElevatedButton( timelineBoundary.value =
onPressed: controllerData is AsyncData value[boundaryIndex].eventId;
? loadOlder }
: null,
child: Text("Load More"), 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<String>) 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<String>) 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(
), key: timelineCenterKey,
listController: historyListController.value,
SuperSliverList.builder( itemCount: historyLength + 1,
listController: listController.value, findChildIndexCallback: historyChildIndex,
itemCount: value.length, itemBuilder: (_, index) {
itemBuilder: (_, index) { if (index == historyLength) {
final event = value[index]; return _HistoryLoader(
final previousEvent = value.getOrNull( key: const ValueKey("history-loader"),
index - 1, hasMore: hasMore,
); isLoading:
return HighlightWrapper( controllerData is AsyncLoading,
EventRenderer( onLoad: loadOlder,
event, );
onTapReply: () => }
jumpToId(event.replyTo!), final event =
getEventOptions: getEventOptions, value[historyLength - index - 1];
isGrouped: return eventRow(
previousEvent?.content event,
is MessageContent && value.getOrNull(
previousEvent?.redactedBy == null && historyLength - index - 2,
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, AsyncData() => nothing,
AsyncLoading() => Loading(), AsyncLoading() => Loading(),
@ -564,3 +571,42 @@ class RoomChat extends HookConsumerWidget {
); );
} }
} }
class _HistoryLoader extends HookWidget {
final bool hasMore;
final bool isLoading;
final Future<void> 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"),
),
),
);
}
}