Compare commits

..
Author SHA1 Message Date
7b6a4b4181
fix: timeline anchoring 2026-08-10 09:02:17 +02:00
2f3bd7a8ac
fix: timeline pagination 2026-08-10 09:02:17 +02:00
46 changed files with 338 additions and 398 deletions

View file

@ -26,22 +26,15 @@ jobs:
with: with:
go-version-file: gomuks/go.mod go-version-file: gomuks/go.mod
- name: Build App - name: Build app
run: | run: |
flutter pub get flutter pub get
dart scripts/generate.dart dart scripts/generate.dart
flutter pub run build_runner build flutter pub run build_runner build
flutter build macos --release flutter build macos --release
- name: Create DMG - name: Upload installer artifact
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
uses: actions/upload-artifact@v6 uses: actions/upload-artifact@v6
with: with:
name: nexus.dmg name: Nexus.App
path: ${{ steps.create-dmg.outputs.dmg_path }} path: build/macos/Build/Products/Release/Nexus.app

View file

@ -19,7 +19,7 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
- [x] Linux - [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 - 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
- [x] MacOS - Unsigned .dmg only - [x] MacOS - Unsigned .App only
- [x] iOS - Unsigned .ipa only - [x] iOS - Unsigned .ipa only
- [ ] Web (may not be possible) - [ ] Web (may not be possible)
- [x] Login (via OAuth) - [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) - [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 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) - [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)

View file

@ -50,10 +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 = { extraEnv = {"SDKROOT": await getXCodeTool()};
"SDKROOT": await getXCodeTool(),
"MACOSX_DEPLOYMENT_TARGET": codeConfig.macOS.targetVersion.toString(),
};
break; break;
case OS.windows: case OS.windows:
libFileName = "libgomuks.dll"; libFileName = "libgomuks.dll";

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 846 B

After

Width:  |  Height:  |  Size: 712 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Before After
Before After

View file

@ -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/set_state.dart";
import "package:nexus/models/requests/upload_media.dart"; import "package:nexus/models/requests/upload_media.dart";
import "package:nexus/models/room.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/spec_versions_response.dart";
import "package:nexus/models/sync_data.dart"; import "package:nexus/models/sync_data.dart";
import "package:nexus/src/third_party/gomuks.g.dart"; import "package:nexus/src/third_party/gomuks.g.dart";
@ -214,11 +213,10 @@ class ClientController extends AsyncNotifier<int> {
} }
} }
Future<String> joinRoom(JoinRoomRequest request) async => Future<String> joinRoom(JoinRoomRequest request) async {
(await _sendCommand("join_room", request.toJson()))["room_id"]; final response = await _sendCommand("join_room", request.toJson());
return response["room_id"];
Future<RoomSummary> getRoomSummary(JoinRoomRequest request) async => }
.fromJson(await _sendCommand("get_room_summary", request.toJson()));
Future<void> leaveRoom(Room room) async { Future<void> leaveRoom(Room room) async {
if (room.metadata == null) return; if (room.metadata == null) return;
@ -313,7 +311,7 @@ class ClientController extends AsyncNotifier<int> {
Future<Uri?> discoverHomeserver(Uri homeserver) async { Future<Uri?> discoverHomeserver(Uri homeserver) async {
try { try {
final response = await _sendCommand("discover_homeserver", { 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"]); return Uri.parse(response["m.homeserver"]?["base_url"]);
} catch (error) { } catch (error) {

View file

@ -17,27 +17,30 @@ 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 // Load one more page when the initial timeline is short.
// 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,47 +77,46 @@ class RoomChatController extends AsyncNotifier<IList<Event>?> {
), ),
); );
Future<bool> loadOlder() async { Future<bool> loadOlder() => _loadingOlder ??= _loadOlder().whenComplete(() {
state = AsyncLoading(); _loadingOlder = null;
final timelineKeys = ref });
.read(RoomsController.provider.select((value) => value[roomId]))
?.timeline Future<bool> _loadOlder() async {
.keys; 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
.read(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,
), ),
); );
if (response.events.isEmpty) { ref
state = .data(state.value); .watch(RoomsController.provider.notifier)
} else { .update(
ref IMap({
.read(RoomsController.provider.notifier) roomId: Room(
.update( events: IMap.fromIterable(
IMap({ response.events.addAll(response.relatedEvents),
roomId: Room( keyMapper: (event) => event.rowId,
events: IMap.fromIterable( valueMapper: (event) => event,
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,
),
), ),
}), hasMore: response.hasMore,
.new(), timeline: IMap.fromIterable(
); response.events,
} keyMapper: (event) => event.timelineRowId,
valueMapper: (event) => event.rowId,
),
),
}),
.new(),
);
return response.hasMore; return response.hasMore;
} }

View file

@ -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<RoomSummary> {
final JoinRoomRequest request;
RoomSummaryController(this.request);
@override
Future<RoomSummary> build() =>
ref.watch(ClientController.provider.notifier).getRoomSummary(request);
static final provider = AsyncNotifierProvider.family
.autoDispose<RoomSummaryController, RoomSummary, JoinRoomRequest>(
RoomSummaryController.new,
);
}

View file

@ -9,14 +9,14 @@ class SettingsController extends AsyncNotifier<Settings> {
final file = await ref.watch(SettingsFileController.provider.future); final file = await ref.watch(SettingsFileController.provider.future);
try { try {
return .fromJson(json.decode(await file.readAsString())); return Settings.fromJson(json.decode(await file.readAsString()));
} catch (_) { } catch (_) {
return .new(); return Settings();
} }
} }
Future<void> set(Settings settings) async { Future<void> set(Settings settings) async {
state = .data(settings); state = AsyncData(settings);
final file = await ref.watch(SettingsFileController.provider.future); final file = await ref.watch(SettingsFileController.provider.future);
await file.writeAsString(json.encode(settings.toJson())); await file.writeAsString(json.encode(settings.toJson()));
} }

View file

@ -9,7 +9,6 @@ extension LinkToMention on String {
/// ///
/// Returns the decoded identifier (e.g. "#room:matrix.org") /// Returns the decoded identifier (e.g. "#room:matrix.org")
/// or null if this is not a Matrix link. /// 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 { String? get mention {
final trimmed = trim(); final trimmed = trim();

View file

@ -7,7 +7,7 @@ part "join_room.g.dart";
abstract class JoinRoomRequest with _$JoinRoomRequest { abstract class JoinRoomRequest with _$JoinRoomRequest {
const factory JoinRoomRequest({ const factory JoinRoomRequest({
required String roomIdOrAlias, required String roomIdOrAlias,
@Default(IList.empty()) IList<String> via, required IList<String> via,
}) = _JoinRoomRequest; }) = _JoinRoomRequest;
factory JoinRoomRequest.fromJson(Map<String, Object?> json) => factory JoinRoomRequest.fromJson(Map<String, Object?> json) =>

View file

@ -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<String, Object?> json) =>
_$RoomSummaryFromJson(json);
}

View file

@ -26,17 +26,6 @@ class SelectServerPage extends HookConsumerWidget {
final homeserverUrl = useTextEditingController(); final homeserverUrl = useTextEditingController();
Future<void> setHomeserver(Uri? newHomeserver) async { Future<void> 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; isLoading.value = true;
try { try {
@ -44,14 +33,26 @@ class SelectServerPage extends HookConsumerWidget {
newHomeserver = Uri.https(newHomeserver!.path); newHomeserver = Uri.https(newHomeserver!.path);
} }
final newUrl = await ref final newUrl = newHomeserver == null
.read(ClientController.provider.notifier) ? null
.discoverHomeserver(newHomeserver!); : await ref
.read(ClientController.provider.notifier)
.discoverHomeserver(newHomeserver);
if (context.mounted) { if (context.mounted) {
Future<void> 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( final codeResponse = await ref.watch(
AuthUrlController.provider(url).future, AuthUrlController.provider(newUrl).future,
); );
await ref.watch(LaunchHelper.provider).launchUrl(codeResponse.url); await ref.watch(LaunchHelper.provider).launchUrl(codeResponse.url);
@ -69,7 +70,7 @@ class SelectServerPage extends HookConsumerWidget {
.watch(ClientController.provider.notifier) .watch(ClientController.provider.notifier)
.exchangeToken( .exchangeToken(
.new( .new(
homeserverUrl: url, homeserverUrl: newUrl,
codeVerifier: codeResponse.codeVerifier, codeVerifier: codeResponse.codeVerifier,
redirectUri: .new( redirectUri: .new(
scheme: "nexus.federated.nexus", scheme: "nexus.federated.nexus",
@ -77,7 +78,7 @@ class SelectServerPage extends HookConsumerWidget {
), ),
code: code, code: code,
clientId: await ref.watch( 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) { } catch (error, stackTrace) {
showError(error, stackTrace); showError(error, stackTrace);

View file

@ -1,11 +1,8 @@
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/room_summary.dart";
import "package:nexus/controllers/user.dart"; import "package:nexus/controllers/user.dart";
import "package:nexus/helpers/extensions/link_to_mention.dart"; import "package:nexus/helpers/extensions/link_to_mention.dart";
import "package:nexus/helpers/extensions/show_user_popover.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 { class MentionChip extends ConsumerWidget {
final String? roomId; final String? roomId;
@ -15,48 +12,29 @@ class MentionChip extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final mention = content.mention; final mention = content.mention;
final data = switch (mention?.characters.firstOrNull) { final membership = mention?.startsWith("@") == true
"@" => ? ref
ref .watch(
.watch( UserController.provider(.new(roomId: roomId, userId: mention!)),
UserController.provider(.new(roomId: roomId, userId: mention!)), )
) .whenOrNull(data: (data) => data)
.whenOrNull(data: (data) => data), : null;
"#" || "!" =>
ref
.watch(
RoomSummaryController.provider(.new(roomIdOrAlias: mention!)),
)
.whenOrNull(data: (data) => data),
_ => null,
};
return mention == null return mention == null
? SizedBox.shrink() ? SizedBox.shrink()
: InkWell( : InkWell(
onTap: () { onTap: () {
if (data case MembershipContent membership) { if (membership != null) {
context.showUserPopover(membership, mention, roomId: roomId); context.showUserPopover(membership, mention, roomId: roomId);
} else if (data case RoomSummary summary) {
// TODO: Handle summary
} }
}, },
child: IgnorePointer( child: IgnorePointer(
child: Chip( child: Chip(
label: Text( label: Text(
switch (data) { (membership?.displayName == null
RoomSummary summary => ? null
(summary.name == null ? null : "#${summary.name}") ?? : "@${membership!.displayName}") ??
summary.canonicalAlias ?? mention,
summary.roomId,
MembershipContent membership =>
membership.displayName == null
? mention
: "@${membership.displayName}",
_ => mention,
},
style: .new( style: .new(
fontWeight: .bold, fontWeight: .bold,
color: Theme.of(context).colorScheme.onPrimary, color: Theme.of(context).colorScheme.onPrimary,

View file

@ -72,24 +72,40 @@ 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 hasMore = useState<bool>(true); 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( final events = controllerData.value;
(element) => element.eventId == eventId, if (events == null || !scrollController.hasClients) return;
);
if (index == null) 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,88 +119,6 @@ class RoomChat extends HookConsumerWidget {
}); });
} }
Future<void> 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<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) {
@ -311,7 +245,7 @@ class RoomChat extends HookConsumerWidget {
await Clipboard.setData( await Clipboard.setData(
ClipboardData( ClipboardData(
text: 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), 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) =>
if (hasMore.value) event.eventId == timelineBoundary.value,
SliverToBoxAdapter( );
child: Padding( if (boundaryIndex < 0) {
padding: .symmetric(vertical: 36), boundaryIndex = value.length - 1;
child: Center( timelineBoundary.value =
child: ElevatedButton( value[boundaryIndex].eventId;
onPressed: controllerData is AsyncData }
? loadOlder
: null, final historyLength = boundaryIndex + 1;
child: Text("Load More"), final liveLength = value.length - historyLength;
int? eventIndexForKey(Key key) {
if (key is! ValueKey<String>) 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(
key: timelineCenterKey,
SuperSliverList.builder( listController: historyListController.value,
listController: listController.value, itemCount: historyLength + 1,
itemCount: value.length, findChildIndexCallback: historyChildIndex,
itemBuilder: (_, index) { itemBuilder: (_, index) {
final event = value[index]; if (index == historyLength) {
final previousEvent = value.getOrNull( return _HistoryLoader(
index - 1, key: const ValueKey("history-loader"),
); hasMore: hasMore,
return HighlightWrapper( isLoading:
EventRenderer( controllerData is AsyncLoading,
event, onLoad: notifier.loadOlder,
onTapReply: () => );
jumpToId(event.replyTo!), }
getEventOptions: getEventOptions, final event =
isGrouped: value[historyLength - index - 1];
previousEvent?.content return eventRow(
is MessageContent && event,
previousEvent?.redactedBy == null && value.getOrNull(
previousEvent?.relationType != historyLength - index - 2,
"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(),
@ -566,3 +561,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"),
),
),
);
}
}

View file

@ -35,7 +35,8 @@ class RoomMenu extends ConsumerWidget {
await Clipboard.setData( await Clipboard.setData(
.new( .new(
text: "matrix:roomid/${room!.metadata?.id.substring(1)}$vias", text:
"matrix:roomid/${room!.metadata?.id.substring(1)}$vias)",
), ),
); );
}, },

View file

@ -1,4 +1,4 @@
platform :osx, '13.0' platform :osx, '10.15'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency. # CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true' ENV['COCOAPODS_DISABLE_STATS'] = 'true'

View file

@ -567,7 +567,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0; MACOSX_DEPLOYMENT_TARGET = 10.15;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx; SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
@ -649,7 +649,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0; MACOSX_DEPLOYMENT_TARGET = 10.15;
MTL_ENABLE_DEBUG_INFO = YES; MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx; SDKROOT = macosx;
@ -699,7 +699,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0; MACOSX_DEPLOYMENT_TARGET = 10.15;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx; SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;

View file

@ -1,68 +1,68 @@
{ {
"info": { "images" : [
"version": 1, {
"author": "xcode" "size" : "16x16",
"idiom" : "mac",
"filename" : "app_icon_16.png",
"scale" : "1x"
}, },
"images": [ {
{ "size" : "16x16",
"size": "16x16", "idiom" : "mac",
"idiom": "mac", "filename" : "app_icon_32.png",
"filename": "app_icon_16.png", "scale" : "2x"
"scale": "1x" },
}, {
{ "size" : "32x32",
"size": "16x16", "idiom" : "mac",
"idiom": "mac", "filename" : "app_icon_32.png",
"filename": "app_icon_32.png", "scale" : "1x"
"scale": "2x" },
}, {
{ "size" : "32x32",
"size": "32x32", "idiom" : "mac",
"idiom": "mac", "filename" : "app_icon_64.png",
"filename": "app_icon_32.png", "scale" : "2x"
"scale": "1x" },
}, {
{ "size" : "128x128",
"size": "32x32", "idiom" : "mac",
"idiom": "mac", "filename" : "app_icon_128.png",
"filename": "app_icon_64.png", "scale" : "1x"
"scale": "2x" },
}, {
{ "size" : "128x128",
"size": "128x128", "idiom" : "mac",
"idiom": "mac", "filename" : "app_icon_256.png",
"filename": "app_icon_128.png", "scale" : "2x"
"scale": "1x" },
}, {
{ "size" : "256x256",
"size": "128x128", "idiom" : "mac",
"idiom": "mac", "filename" : "app_icon_256.png",
"filename": "app_icon_256.png", "scale" : "1x"
"scale": "2x" },
}, {
{ "size" : "256x256",
"size": "256x256", "idiom" : "mac",
"idiom": "mac", "filename" : "app_icon_512.png",
"filename": "app_icon_256.png", "scale" : "2x"
"scale": "1x" },
}, {
{ "size" : "512x512",
"size": "256x256", "idiom" : "mac",
"idiom": "mac", "filename" : "app_icon_512.png",
"filename": "app_icon_512.png", "scale" : "1x"
"scale": "2x" },
}, {
{ "size" : "512x512",
"size": "512x512", "idiom" : "mac",
"idiom": "mac", "filename" : "app_icon_1024.png",
"filename": "app_icon_512.png", "scale" : "2x"
"scale": "1x" }
}, ],
{ "info" : {
"size": "512x512", "version" : 1,
"idiom": "mac", "author" : "xcode"
"filename": "app_icon_1024.png", }
"scale": "2x"
}
]
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 678 B

After

Width:  |  Height:  |  Size: 520 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Before After
Before After

View file

@ -87,15 +87,12 @@ dev_dependencies:
json_serializable: 6.14.1 json_serializable: 6.14.1
flutter_launcher_icons: flutter_launcher_icons:
image_path: assets/icon.png
ios: true ios: true
image_path_ios: assets/mobile.png
android: true android: true
image_path: assets/icon.png
adaptive_icon_background: assets/background.png adaptive_icon_background: assets/background.png
adaptive_icon_foreground: assets/foreground.png adaptive_icon_foreground: assets/foreground.png
adaptive_icon_monochrome: assets/monochrome.png adaptive_icon_monochrome: assets/monochrome.png
remove_alpha_ios: true
windows: windows:
generate: true generate: true
macos:
generate: true
image_path: assets/mobile.png