Compare commits

..
13 changed files with 143 additions and 349 deletions

View file

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

View file

@ -25,7 +25,6 @@ import "package:nexus/models/paginate.dart";
import "package:nexus/models/requests/deregister_pusher.dart"; import "package:nexus/models/requests/deregister_pusher.dart";
import "package:nexus/models/requests/download_media.dart"; import "package:nexus/models/requests/download_media.dart";
import "package:nexus/models/requests/get_event.dart"; import "package:nexus/models/requests/get_event.dart";
import "package:nexus/models/requests/get_mentions.dart";
import "package:nexus/models/requests/get_related_events.dart"; import "package:nexus/models/requests/get_related_events.dart";
import "package:nexus/models/requests/get_room_state.dart"; import "package:nexus/models/requests/get_room_state.dart";
import "package:nexus/models/requests/join_room.dart"; import "package:nexus/models/requests/join_room.dart";
@ -273,12 +272,6 @@ class ClientController extends AsyncNotifier<int> {
return .new(response?.map((event) => .fromJson(event))); return .new(response?.map((event) => .fromJson(event)));
} }
Future<IList<Event>> getMentions(GetMentionsRequest request) async => .new(
((await _sendCommand("get_mentions", request.toJson())) as List).map(
(event) => .fromJson(event),
),
);
Future<Event?> getEvent(GetEventRequest request) async { Future<Event?> getEvent(GetEventRequest request) async {
final json = await _sendCommand("get_event", request.toJson()); final json = await _sendCommand("get_event", request.toJson());
return json == null ? null : .fromJson(json); return json == null ? null : .fromJson(json);

View file

@ -1,123 +0,0 @@
import "dart:io";
import "package:material_ui/material_ui.dart";
import "package:nexus/main.dart";
import "package:flutter_local_notifications/flutter_local_notifications.dart";
// ignore: implementation_imports
import "package:flutter_local_notifications_linux/src/model/hint.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/pages/notifications.dart";
class NotificationController
extends AsyncNotifier<FlutterLocalNotificationsPlugin> {
@override
Future<FlutterLocalNotificationsPlugin> build() async {
final notifications = FlutterLocalNotificationsPlugin();
final darwin = DarwinInitializationSettings();
await notifications.initialize(
settings: .new(
windows: .new(
appName: "Nexus",
appUserModelId: "nexus.federated.nexus",
guid: "dde78daf-130f-4e46-a80a-e31deeab45d7",
),
android: .new("ic_launcher_foreground"),
iOS: darwin,
macOS: darwin,
linux: .new(defaultActionName: "Open"),
),
onDidReceiveNotificationResponse: (details) {
if (details.payload case final eventId?) {
if (navigatorKey.currentContext case final context?) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => NotificationsPage(
eventId: eventId,
defaultToAllNotifications: true,
),
),
);
}
}
},
);
return notifications;
}
Future<bool> requestPermissions() async {
final controller = await future;
if (Platform.isIOS) {
return await controller
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin
>()
?.requestPermissions(alert: true, badge: true, sound: true) ??
true;
} else if (Platform.isMacOS) {
return await controller
.resolvePlatformSpecificImplementation<
MacOSFlutterLocalNotificationsPlugin
>()
?.requestPermissions(alert: true, badge: true, sound: true) ??
true;
} else if (Platform.isAndroid) {
return await controller
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.requestNotificationsPermission() ??
true;
}
return true;
}
Future<void> send({
required int id,
required String title,
String? body,
File? icon,
String? payload,
}) async {
final notificationDetails = NotificationDetails(
android: .new(
"messages",
"Messages",
largeIcon: icon == null ? null : FilePathAndroidBitmap(icon.path),
),
linux: .new(
customHints: icon == null
? null
: [
.new(
name: "image-path",
value: LinuxHintStringValue(icon.path),
),
],
),
// TODO: See if icons can be added to iOS, macOS, and Windows notifications (#68)
iOS: .new(),
macOS: .new(),
windows: .new(),
);
final controller = await future;
await controller.show(
id: id,
title: title,
body: body,
notificationDetails: notificationDetails,
payload: payload,
);
}
static final provider =
AsyncNotifierProvider<
NotificationController,
FlutterLocalNotificationsPlugin
>(NotificationController.new);
}

View file

@ -1,59 +1,120 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "dart:io";
import "package:material_ui/material_ui.dart";
import "package:nexus/main.dart";
import "package:flutter_local_notifications/flutter_local_notifications.dart";
// ignore: implementation_imports
import "package:flutter_local_notifications_linux/src/model/hint.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart"; import "package:nexus/pages/notifications.dart";
import "package:nexus/models/event.dart";
typedef NotificationsRequest = (UnreadType? unreadType, String? roomId);
class NotificationsController([final NotificationsRequest? request])
extends AsyncNotifier<IList<Event>> {
static const limit = 20;
class NotificationsController
extends AsyncNotifier<FlutterLocalNotificationsPlugin> {
@override @override
Future<IList<Event>> build() async { Future<FlutterLocalNotificationsPlugin> build() async {
final client = ref.watch(ClientController.provider.notifier); final notifications = FlutterLocalNotificationsPlugin();
final (unreadType, roomId) = request ?? (null, null); final darwin = DarwinInitializationSettings();
final mentions = await client.getMentions( await notifications.initialize(
.new( settings: .new(
maxTimestamp: .now(), windows: .new(
unreadType: unreadType ?? .highlight, appName: "Nexus",
limit: limit, appUserModelId: "nexus.federated.nexus",
roomId: roomId, guid: "dde78daf-130f-4e46-a80a-e31deeab45d7",
),
android: .new("ic_launcher_foreground"),
iOS: darwin,
macOS: darwin,
linux: .new(defaultActionName: "Open"),
),
onDidReceiveNotificationResponse: (details) {
if (details.payload case final eventId?) {
if (navigatorKey.currentContext case final context?) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => NotificationsPage(eventId: eventId),
), ),
); );
return mentions;
} }
}
Future<void> loadOlder() async { },
final currentNotifications = await future;
state = .loading();
state = await .guard(() async {
final lastTs = currentNotifications.lastOrNull?.timestamp;
if (lastTs == null) return const .empty();
final client = ref.watch(ClientController.provider.notifier);
final (unreadType, roomId) = request ?? (null, null);
final newNotifications = await client.getMentions(
.new(
maxTimestamp: lastTs,
unreadType: unreadType ?? .highlight,
limit: limit,
roomId: roomId,
),
); );
return currentNotifications.addAll(newNotifications); return notifications;
});
} }
static final provider = AsyncNotifierProvider.family Future<bool> requestPermissions() async {
.autoDispose< final controller = await future;
if (Platform.isIOS) {
return await controller
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin
>()
?.requestPermissions(alert: true, badge: true, sound: true) ??
true;
} else if (Platform.isMacOS) {
return await controller
.resolvePlatformSpecificImplementation<
MacOSFlutterLocalNotificationsPlugin
>()
?.requestPermissions(alert: true, badge: true, sound: true) ??
true;
} else if (Platform.isAndroid) {
return await controller
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.requestNotificationsPermission() ??
true;
}
return true;
}
Future<void> send({
required int id,
required String title,
String? body,
File? icon,
String? payload,
}) async {
final notificationDetails = NotificationDetails(
android: .new(
"messages",
"Messages",
largeIcon: icon == null ? null : FilePathAndroidBitmap(icon.path),
),
linux: .new(
customHints: icon == null
? null
: [
.new(
name: "image-path",
value: LinuxHintStringValue(icon.path),
),
],
),
// TODO: See if icons can be added to iOS, macOS, and Windows notifications (#68)
iOS: .new(),
macOS: .new(),
windows: .new(),
);
final controller = await future;
await controller.show(
id: id,
title: title,
body: body,
notificationDetails: notificationDetails,
payload: payload,
);
}
static final provider =
AsyncNotifierProvider<
NotificationsController, NotificationsController,
IList<Event>, FlutterLocalNotificationsPlugin
NotificationsRequest?
>(NotificationsController.new); >(NotificationsController.new);
} }

View file

@ -74,7 +74,7 @@ class RoomChatController(final String roomId)
); );
Future<bool> loadOlder() async { Future<bool> loadOlder() async {
state = .loading(); state = AsyncLoading();
final timelineKeys = ref final timelineKeys = ref
.read(RoomsController.provider.select((value) => value[roomId])) .read(RoomsController.provider.select((value) => value[roomId]))
?.timeline ?.timeline

View file

@ -9,7 +9,7 @@ import "package:m3e_buttons/m3e_buttons.dart";
import "package:nexus/controllers/account_data.dart"; import "package:nexus/controllers/account_data.dart";
import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/notification.dart"; import "package:nexus/controllers/notifications.dart";
import "package:nexus/controllers/settings.dart"; import "package:nexus/controllers/settings.dart";
import "package:nexus/controllers/unified_push.dart"; import "package:nexus/controllers/unified_push.dart";
import "package:nexus/controllers/spec_versions.dart"; import "package:nexus/controllers/spec_versions.dart";
@ -143,7 +143,7 @@ class SettingsSectionsController
if (value) { if (value) {
if (await ref if (await ref
.watch( .watch(
NotificationController NotificationsController
.provider .provider
.notifier, .notifier,
) )

View file

@ -5,7 +5,7 @@ import "package:flutter/foundation.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:intl/intl.dart"; import "package:intl/intl.dart";
import "package:nexus/controllers/key.dart"; import "package:nexus/controllers/key.dart";
import "package:nexus/controllers/notification.dart"; import "package:nexus/controllers/notifications.dart";
import "package:nexus/controllers/push_key.dart"; import "package:nexus/controllers/push_key.dart";
import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/rooms.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
@ -61,7 +61,7 @@ class UnifiedPushController extends AsyncNotifier<bool> {
); );
if (event == null || if (event == null ||
event.unreadType?.shouldNotify() != true || event.unreadType?.shouldNotify != true ||
(!isInBackground && (!isInBackground &&
await windowManager.isFocused().onError((_, _) => false) && await windowManager.isFocused().onError((_, _) => false) &&
ref.watch(KeyController.provider(KeyController.roomKey)) == ref.watch(KeyController.provider(KeyController.roomKey)) ==
@ -81,7 +81,7 @@ class UnifiedPushController extends AsyncNotifier<bool> {
.downloadMedia(.new(mxc: avatar, isAvatar: true)); .downloadMedia(.new(mxc: avatar, isAvatar: true));
await ref await ref
.read(NotificationController.provider.notifier) .read(NotificationsController.provider.notifier)
.send( .send(
id: event.eventId.hashCode & 0x7fffffff, id: event.eventId.hashCode & 0x7fffffff,
title: room?.metadata?.name ?? "New Event", title: room?.metadata?.name ?? "New Event",

View file

@ -8,7 +8,7 @@ import "package:media_kit/media_kit.dart";
import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/multi_provider.dart"; import "package:nexus/controllers/multi_provider.dart";
import "package:nexus/controllers/notification.dart"; import "package:nexus/controllers/notifications.dart";
import "package:nexus/controllers/settings.dart"; import "package:nexus/controllers/settings.dart";
import "package:nexus/controllers/shared_prefs.dart"; import "package:nexus/controllers/shared_prefs.dart";
import "package:nexus/controllers/unified_push.dart"; import "package:nexus/controllers/unified_push.dart";
@ -147,7 +147,7 @@ class const App(final bool isInBackground, {super.key})
IListConst([ IListConst([
SharedPrefsController.provider, SharedPrefsController.provider,
ClientController.provider, ClientController.provider,
NotificationController.provider, NotificationsController.provider,
UnifiedPushController.provider, UnifiedPushController.provider,
]), ]),
), ),

View file

@ -29,7 +29,7 @@ class const Event({
final String? sendError, final String? sendError,
final IMap<String, int> reactions = const IMap.empty(), final IMap<String, int> reactions = const IMap.empty(),
@JsonKey(name: "last_edit_rowid") final int lastEditRowId = 0, @JsonKey(name: "last_edit_rowid") final int lastEditRowId = 0,
final UnreadType? unreadType, @UnreadTypeConverter() final UnreadType? unreadType,
final Profile? pmp, final Profile? pmp,
required final Content content, required final Content content,
required final Content? previousContent, required final Content? previousContent,
@ -91,10 +91,22 @@ class const LocalContent({
_$LocalContentFromJson(json); _$LocalContentFromJson(json);
} }
@Freezed(toJson: false, fromJson: false) class UnreadTypeConverter implements JsonConverter<UnreadType?, int?> {
class const UnreadType(final int value) with _$UnreadType { const UnreadTypeConverter();
factory UnreadType.fromJson(int json) => UnreadType(json);
int toJson() => value; @override
UnreadType? fromJson(int? json) => json == null ? null : UnreadType(json);
@override
int? toJson(UnreadType? object) => object?.value;
}
// I think this is correct but I'm not sure, its some type of bitmask.
@immutable
class UnreadType {
final int value;
const UnreadType(this.value);
static const none = UnreadType(0); static const none = UnreadType(0);
static const normal = UnreadType(1); static const normal = UnreadType(1);
@ -102,9 +114,9 @@ class const UnreadType(final int value) with _$UnreadType {
static const highlight = UnreadType(4); static const highlight = UnreadType(4);
static const sound = UnreadType(8); static const sound = UnreadType(8);
bool isNone() => value == 0; bool get isNone => value == 0;
bool isNormal() => (value & 1) != 0; bool get isNormal => (value & 1) != 0;
bool shouldNotify() => (value & 2) != 0; bool get shouldNotify => (value & 2) != 0;
bool isHighlighted() => (value & 4) != 0; bool get isHighlighted => (value & 4) != 0;
bool playsSound() => (value & 8) != 0; bool get playsSound => (value & 8) != 0;
} }

View file

@ -1,19 +0,0 @@
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/epoch_date_time_converter.dart";
import "package:nexus/models/event.dart";
part "get_mentions.freezed.dart";
part "get_mentions.g.dart";
@Freezed(toJson: false, fromJson: false)
@JsonSerializable()
class GetMentionsRequest({
@EpochDateTimeConverter() required final DateTime maxTimestamp,
@JsonKey(name: "type") required final UnreadType unreadType,
required final int limit,
final String? roomId,
}) with _$GetMentionsRequest {
Map<String, Object?> toJson() => _$GetMentionsRequestToJson(this);
factory GetMentionsRequest.fromJson(Map<String, Object?> json) =>
_$GetMentionsRequestFromJson(json);
}

View file

@ -1,128 +1,14 @@
import "package:m3e_buttons/m3e_buttons.dart";
import "package:material_ui/material_ui.dart"; import "package:material_ui/material_ui.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/notifications.dart";
import "package:nexus/models/event.dart";
import "package:nexus/widgets/appbar.dart"; import "package:nexus/widgets/appbar.dart";
import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/highlight_wrapper.dart";
import "package:nexus/widgets/loading.dart";
import "package:nexus/widgets/renderers/event.dart";
import "package:super_sliver_list/super_sliver_list.dart";
class const NotificationsPage({ class const NotificationsPage({final String? eventId, super.key})
final String? eventId, extends HookConsumerWidget {
final bool defaultToAllNotifications = false,
super.key,
}) extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final options = <M3EToggleButtonGroupAction, UnreadType>{
M3EToggleButtonGroupAction(
label: Text("Mentions"),
icon: Icon(Icons.alternate_email),
): .highlight,
M3EToggleButtonGroupAction(
label: Text("All Notifications"),
icon: Icon(Icons.notifications),
): .notify,
};
final unreadTypeIndex = useState(defaultToAllNotifications ? 1 : 0);
final provider = NotificationsController.provider((
options.values.toList()[unreadTypeIndex.value],
null,
));
final notifications = ref.watch(provider);
final notifier = ref.watch(provider.notifier);
final scrollController = useScrollController();
useEffect(() {
Future<void> listener() async {
if (!scrollController.hasClients || notifications.isLoading) return;
if (scrollController.position.pixels >=
scrollController.position.maxScrollExtent) {
await notifier.loadOlder();
}
}
scrollController.addListener(listener);
return () => scrollController.removeListener(listener);
}, [scrollController, notifications]);
return Scaffold( return Scaffold(
appBar: Appbar(title: Text("Notifications")), appBar: Appbar(title: Text("Notifications")),
body: Stack( body: Placeholder(),
children: [
Column(
children: [
if (notifications is AsyncLoading && notifications.value != null)
const LinearProgressIndicator(minHeight: 2),
Expanded(
child: switch (notifications) {
AsyncData(:final value) || AsyncLoading(:final value?) =>
value.isEmpty
? Center(
child: Text(
"No notifications yet",
style: Theme.of(context).textTheme.headlineMedium,
),
)
: SuperListView.builder(
controller: scrollController,
itemCount: value.length,
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 12,
),
reverse: true,
itemBuilder: (context, index) {
final event = value[index];
final isHighlighted = event.eventId == eventId;
return Padding(
padding: .only(top: 8),
child: HighlightWrapper(
InkWell(
onTap: () {
//TODO
},
child: EventRenderer(event),
),
isHighlighted: isHighlighted,
),
);
},
),
AsyncLoading() => const Loading(),
AsyncError(:final error, :final stackTrace) => ErrorDialog(
error,
stackTrace,
),
},
),
],
),
Align(
alignment: .topRight,
child: Card(
color: Theme.of(context).colorScheme.surfaceContainer,
child: Padding(
padding: .all(12),
child: M3EToggleButtonGroup(
selectedIndex: unreadTypeIndex.value,
onSelectedIndexChanged: (index) =>
unreadTypeIndex.value = index ?? unreadTypeIndex.value,
actions: options.keys.toList(),
),
),
),
),
],
),
); );
} }
} }

View file

@ -78,12 +78,10 @@ final class const RoomChat({
final hasMore = useState<bool>(true); final hasMore = useState<bool>(true);
Future<void> jumpToId(String eventId) async { Future<void> jumpToId(String eventId) async {
final index = final index = controllerData.value?.indexWhere(
controllerData.value?.indexWhere(
(element) => element.eventId == eventId, (element) => element.eventId == eventId,
) ?? );
-1; if (index == null) return;
if (index == -1) return;
listController.value.animateToItem( listController.value.animateToItem(
index: index, index: index,

View file

@ -6,7 +6,6 @@ import "package:navigation_rail_m3e/navigation_rail_m3e.dart";
import "package:nexus/controllers/key.dart"; import "package:nexus/controllers/key.dart";
import "package:nexus/controllers/spaces.dart"; import "package:nexus/controllers/spaces.dart";
import "package:nexus/models/room.dart"; import "package:nexus/models/room.dart";
import "package:nexus/pages/notifications.dart";
import "package:nexus/pages/settings.dart"; import "package:nexus/pages/settings.dart";
import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/divider_widget.dart"; import "package:nexus/widgets/divider_widget.dart";
@ -190,15 +189,6 @@ class const Sidebar({required final bool isDesktop, super.key})
onPressed: null, onPressed: null,
icon: Icon(Icons.explore), icon: Icon(Icons.explore),
), ),
IconButton(
tooltip: "Open notifications",
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => NotificationsPage(),
),
),
icon: Icon(Icons.notifications),
),
IconButton( IconButton(
tooltip: "Open settings", tooltip: "Open settings",
onPressed: () => showDialog( onPressed: () => showDialog(