Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
e0b5dc58d0 |
|||
|
ffbf2722f7 |
|||
|
3e1552f246 |
|||
|
44a1ec667e |
|||
|
479279b68e |
|||
|
b0dd452926 |
|||
|
fcfe4eed1e |
|||
|
b390dbbbe1 |
|||
|
8e51852ae3 |
|||
|
48c04d9b3e |
|||
|
7f30150e95 |
|||
|
c8e1aef74b |
|||
|
5ee3449f02 |
|||
|
1715ee785e |
|||
|
e38307657d |
|||
|
e14ab3e708 |
|||
|
f5645db6ea |
|||
|
0fcbc275cd |
|||
|
a54004736b |
|||
|
427e06be3e |
|||
|
a1a15af30d |
|||
| 800fb5566d | |||
|
80197aff00 |
|||
|
78162efbde |
104 changed files with 4244 additions and 916 deletions
|
|
@ -1,5 +0,0 @@
|
|||
package nexus.federated.nexus
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity: FlutterActivity()
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
package nexus.federated.nexus
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.WindowInsets
|
||||
import android.view.WindowInsetsAnimation
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
// TODO: Revert this commit once https://github.com/flutter/flutter/issues/191156 is fixed
|
||||
class MainActivity: FlutterActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return
|
||||
|
||||
window.decorView.setWindowInsetsAnimationCallback(
|
||||
object : WindowInsetsAnimation.Callback(DISPATCH_MODE_CONTINUE_ON_SUBTREE) {
|
||||
override fun onProgress(
|
||||
insets: WindowInsets,
|
||||
runningAnimations: List<WindowInsetsAnimation>,
|
||||
) = insets
|
||||
|
||||
override fun onEnd(animation: WindowInsetsAnimation) {
|
||||
if ((animation.typeMask and WindowInsets.Type.ime()) != 0) reapplyInsets()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override fun onWindowFocusChanged(hasFocus: Boolean) {
|
||||
super.onWindowFocusChanged(hasFocus)
|
||||
if (hasFocus && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) reapplyInsets()
|
||||
}
|
||||
|
||||
// Posted so it runs after Flutter has applied any insets it deferred.
|
||||
private fun reapplyInsets() {
|
||||
val view = findViewById<View>(FLUTTER_VIEW_ID) ?: return
|
||||
view.post { view.rootWindowInsets?.let(view::onApplyWindowInsets) }
|
||||
}
|
||||
}
|
||||
2
gomuks
2
gomuks
|
|
@ -1 +1 @@
|
|||
Subproject commit ba82b6e31c87c99766a8366a72a9f07d643b323f
|
||||
Subproject commit 53cae0e0334aef5ac376ea84f19ba1cff8a92310
|
||||
|
|
@ -8,6 +8,7 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
|||
import "package:ffi/ffi.dart";
|
||||
import "package:flutter/cupertino.dart";
|
||||
import "package:intl/intl.dart";
|
||||
import "package:nexus/controllers/settings.dart";
|
||||
import "package:nexus/helpers/extensions/gomuks_buffer.dart";
|
||||
import "package:nexus/models/capabilities.dart";
|
||||
import "package:nexus/models/content/message.dart";
|
||||
|
|
@ -41,6 +42,7 @@ import "package:nexus/models/requests/paginate_manual.dart";
|
|||
import "package:nexus/models/requests/redact_event.dart";
|
||||
import "package:nexus/models/requests/register_pusher.dart";
|
||||
import "package:nexus/models/requests/report.dart";
|
||||
import "package:nexus/models/requests/search.dart";
|
||||
import "package:nexus/models/requests/send_event.dart";
|
||||
import "package:nexus/models/requests/send_message.dart";
|
||||
import "package:nexus/models/requests/set_account_data.dart";
|
||||
|
|
@ -105,39 +107,50 @@ class ClientController extends AsyncNotifier<int> {
|
|||
return handle;
|
||||
}
|
||||
|
||||
/// Calls gomuks with [data] as JSON, returning its decoded response.
|
||||
Future<dynamic> callGomuksMethod(
|
||||
Map<String, dynamic> data,
|
||||
FutureOr<GomuksResponse> Function(int handle, GomuksBorrowedBuffer data)
|
||||
callback,
|
||||
void Function(
|
||||
int handle,
|
||||
GomuksOwnedBuffer data,
|
||||
Pointer<NativeFunction<Void Function(GomuksResponse)>> callback,
|
||||
)
|
||||
call,
|
||||
) async {
|
||||
final bufferPointer = data.toGomuksBufferPtr();
|
||||
final handle = await future;
|
||||
final buffer = data.toGomuksOwnedBuffer();
|
||||
|
||||
final completer = Completer<dynamic>();
|
||||
late final NativeCallable<Void Function(GomuksResponse)> callable;
|
||||
callable = .listener((GomuksResponse response) {
|
||||
callable.close();
|
||||
try {
|
||||
final json = response.buf.toJsonAndFree();
|
||||
|
||||
if (response.command.cast<Utf8>().toDartString() == "error") {
|
||||
throw GomuksException(json.toString());
|
||||
}
|
||||
|
||||
completer.complete(json);
|
||||
} catch (error, stackTrace) {
|
||||
completer.completeError(error, stackTrace);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
final handle = await future;
|
||||
|
||||
final response = await Isolate.run(
|
||||
() => callback(handle, bufferPointer.ref),
|
||||
);
|
||||
|
||||
final json = response.buf.toJsonAndFree();
|
||||
|
||||
if (response.command.cast<Utf8>().toDartString() == "error") {
|
||||
throw GomuksException(json.toString());
|
||||
}
|
||||
|
||||
return json;
|
||||
} finally {
|
||||
calloc
|
||||
..free(bufferPointer.ref.base)
|
||||
..free(bufferPointer);
|
||||
call(handle, buffer, callable.nativeFunction);
|
||||
} catch (_) {
|
||||
// gomuks never got the call, so it won't respond or free the buffer
|
||||
callable.close();
|
||||
GomuksFreeBuffer(buffer);
|
||||
rethrow;
|
||||
}
|
||||
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
Future<PushNotification?> handlePush(Map<String, dynamic> data) async {
|
||||
final response = await callGomuksMethod(
|
||||
data,
|
||||
(handle, data) async => GomuksHandlePush(handle, data),
|
||||
);
|
||||
final response = await callGomuksMethod(data, GomuksHandlePushAsync);
|
||||
|
||||
return response?["notification"] == null
|
||||
? null
|
||||
|
|
@ -147,11 +160,12 @@ class ClientController extends AsyncNotifier<int> {
|
|||
Future<dynamic> _sendCommand(
|
||||
String command, [
|
||||
Map<String, dynamic> data = const {},
|
||||
]) => callGomuksMethod(data, (handle, data) {
|
||||
]) => callGomuksMethod(data, (handle, data, callback) {
|
||||
final commandPointer = command.toNativeUtf8().cast<Char>();
|
||||
try {
|
||||
return GomuksSubmitCommand(handle, commandPointer, data);
|
||||
GomuksSubmitCommandAsync(handle, commandPointer, data, callback);
|
||||
} finally {
|
||||
// gomuks copies the command name before returning
|
||||
calloc.free(commandPointer);
|
||||
}
|
||||
});
|
||||
|
|
@ -222,6 +236,9 @@ class ClientController extends AsyncNotifier<int> {
|
|||
Future<String> joinRoom(JoinRoomRequest request) async =>
|
||||
(await _sendCommand("join_room", request.toJson()))["room_id"];
|
||||
|
||||
Future<String> knockRoom(JoinRoomRequest request) async =>
|
||||
(await _sendCommand("knock_room", request.toJson()))["room_id"];
|
||||
|
||||
Future<String> createRoom(CreateRoomRequest request) async =>
|
||||
(await _sendCommand("create_room", request.toJson()))["room_id"];
|
||||
|
||||
|
|
@ -273,6 +290,12 @@ class ClientController extends AsyncNotifier<int> {
|
|||
Future<PaginateManual> paginateManual(PaginateManualRequest request) async =>
|
||||
.fromJson(await _sendCommand("paginate_manual", request.toJson()));
|
||||
|
||||
Future<PaginateManual> searchLocal(SearchRequest request) async =>
|
||||
.fromJson(await _sendCommand("search_local", request.toJson()));
|
||||
|
||||
Future<PaginateManual> searchServer(SearchRequest request) async =>
|
||||
.fromJson(await _sendCommand("search_server", request.toJson()));
|
||||
|
||||
Future<EventContext> getEventContext(GetEventContextRequest request) async =>
|
||||
.fromJson(await _sendCommand("get_event_context", request.toJson()));
|
||||
|
||||
|
|
@ -310,6 +333,11 @@ class ClientController extends AsyncNotifier<int> {
|
|||
|
||||
Future<void> logout() => _sendCommand("logout");
|
||||
|
||||
Future<void> setTyping(String roomId, Duration timeout) => _sendCommand(
|
||||
"set_typing",
|
||||
{"room_id": roomId, "timeout": timeout.inMilliseconds},
|
||||
);
|
||||
|
||||
Future<void> markRead(Room room) async {
|
||||
if (room.timeline.isEmpty || room.metadata == null) return;
|
||||
final eventRowId = room.timeline[room.timeline.keys.reduce(max)];
|
||||
|
|
@ -317,9 +345,14 @@ class ClientController extends AsyncNotifier<int> {
|
|||
|
||||
if (event == null) return;
|
||||
|
||||
// Private until settings have loaded, in case the user turned sending off
|
||||
final sendPublic =
|
||||
ref.read(SettingsController.provider).value?.readReceipts ==
|
||||
.showAndSend;
|
||||
|
||||
await _sendCommand("mark_read", {
|
||||
"room_id": room.metadata!.id,
|
||||
"receipt_type": "m.read",
|
||||
"receipt_type": sendPublic ? "m.read" : "m.read.private",
|
||||
"event_id": event.eventId,
|
||||
});
|
||||
}
|
||||
|
|
@ -338,6 +371,34 @@ class ClientController extends AsyncNotifier<int> {
|
|||
Future<void> exchangeToken(OAuthExchangeTokenRequest request) async =>
|
||||
await _sendCommand("oauth_exchange_token", request.toJson());
|
||||
|
||||
Future<IList<String>> getMutualRooms(String userId) async => IList(
|
||||
((await _sendCommand("get_mutual_rooms", {"user_id": userId}))["joined"]
|
||||
as List?)
|
||||
?.cast<String>(),
|
||||
);
|
||||
|
||||
Future<IList<String>> getLocalAliases(String roomId) async => IList(
|
||||
((await _sendCommand("get_local_aliases", {"room_id": roomId}))["aliases"]
|
||||
as List?)
|
||||
?.cast<String>(),
|
||||
);
|
||||
|
||||
Future<void> createAlias(String roomId, String alias) =>
|
||||
_sendCommand("create_alias", {"room_id": roomId, "alias": alias});
|
||||
|
||||
Future<void> deleteAlias(String alias) =>
|
||||
_sendCommand("delete_alias", {"alias": alias});
|
||||
|
||||
/// The room [alias] points to, or null if it doesn't exist.
|
||||
Future<String?> resolveAlias(String alias) async {
|
||||
try {
|
||||
return (await _sendCommand("resolve_alias", {"alias": alias}))["room_id"];
|
||||
} on GomuksException catch (error) {
|
||||
if (error.statusCode == 404) return null;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<SpecVersionsResponse> getSpecVersions() async =>
|
||||
.fromJson(await _sendCommand("get_versions"));
|
||||
|
||||
|
|
|
|||
97
lib/controllers/event_search.dart
Normal file
97
lib/controllers/event_search.dart
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/models/requests/search.dart";
|
||||
|
||||
typedef EventSearchParams = ({String roomId, String query});
|
||||
typedef EventSearchResults = ({IList<int> rowIds, String? nextBatch});
|
||||
|
||||
class EventSearchController(final EventSearchParams info)
|
||||
extends AsyncNotifier<EventSearchResults> {
|
||||
// We could have a mode without this for power users in the future
|
||||
static String toFtsQuery(String query) => query
|
||||
.split(RegExp(r"\s+"))
|
||||
.where((word) => word.isNotEmpty)
|
||||
.map((word) => '"${word.replaceAll('"', '""')}"*')
|
||||
.join(" ");
|
||||
|
||||
@override
|
||||
Future<EventSearchResults> build() async {
|
||||
if (info.query.trim().isEmpty) {
|
||||
return (rowIds: IList<int>(), nextBatch: null);
|
||||
}
|
||||
|
||||
final client = ref.read(ClientController.provider.notifier);
|
||||
|
||||
final encrypted = ref.read(
|
||||
RoomsController.provider.select(
|
||||
(rooms) => rooms[info.roomId]?.metadata?.encryptionEvent != null,
|
||||
),
|
||||
);
|
||||
final request = SearchRequest(
|
||||
searchTerm: encrypted ? toFtsQuery(info.query) : info.query,
|
||||
roomIds: .new([info.roomId]),
|
||||
);
|
||||
final response = encrypted
|
||||
? await client.searchLocal(request)
|
||||
: await client.searchServer(request);
|
||||
|
||||
if (!ref.mounted) return (rowIds: IList<int>(), nextBatch: null);
|
||||
|
||||
ref
|
||||
.read(RoomsController.provider.notifier)
|
||||
.storeEvents(response.events.addAll(response.relatedEvents));
|
||||
|
||||
return (
|
||||
rowIds: response.events.map((event) => event.rowId).toIList(),
|
||||
nextBatch: response.nextBatch,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> paginate() async {
|
||||
if (state.isLoading) return;
|
||||
|
||||
final results = await future;
|
||||
if (!ref.mounted || results.nextBatch == null) return;
|
||||
|
||||
state = .loading();
|
||||
|
||||
final encrypted = ref.read(
|
||||
RoomsController.provider.select(
|
||||
(rooms) => rooms[info.roomId]?.metadata?.encryptionEvent != null,
|
||||
),
|
||||
);
|
||||
|
||||
final request = SearchRequest(
|
||||
searchTerm: encrypted ? toFtsQuery(info.query) : info.query,
|
||||
roomIds: .new([info.roomId]),
|
||||
nextBatch: results.nextBatch,
|
||||
);
|
||||
|
||||
final client = ref.read(ClientController.provider.notifier);
|
||||
final response = encrypted
|
||||
? await client.searchLocal(request)
|
||||
: await client.searchServer(request);
|
||||
|
||||
if (!ref.mounted) return;
|
||||
|
||||
ref
|
||||
.read(RoomsController.provider.notifier)
|
||||
.storeEvents(response.events.addAll(response.relatedEvents));
|
||||
|
||||
state = .data((
|
||||
rowIds: results.rowIds.addAll(
|
||||
response.events.map((event) => event.rowId),
|
||||
),
|
||||
nextBatch: response.nextBatch,
|
||||
));
|
||||
}
|
||||
|
||||
static final provider = AsyncNotifierProvider.family
|
||||
.autoDispose<
|
||||
EventSearchController,
|
||||
EventSearchResults,
|
||||
EventSearchParams
|
||||
>(EventSearchController.new);
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import "package:nexus/controllers/rooms.dart";
|
|||
import "package:nexus/controllers/space_edges.dart";
|
||||
import "package:nexus/controllers/sync_status.dart";
|
||||
import "package:nexus/controllers/top_level_spaces.dart";
|
||||
import "package:nexus/controllers/typing.dart";
|
||||
import "package:nexus/helpers/extensions/gomuks_buffer.dart";
|
||||
import "package:nexus/main.dart";
|
||||
import "package:ffi/ffi.dart";
|
||||
|
|
@ -63,6 +64,10 @@ class GomuksListenerController extends AsyncNotifier<void> {
|
|||
event.roomId: .new(events: .new({event.rowId: event})),
|
||||
}),
|
||||
);
|
||||
case "typing":
|
||||
ref
|
||||
.read(TypingController.provider.notifier)
|
||||
.set(.fromJson(decodedMuksEvent));
|
||||
case "events_decrypted":
|
||||
final decrypted = EventsDecrypted.fromJson(decodedMuksEvent);
|
||||
final metadata = ref
|
||||
|
|
@ -96,6 +101,7 @@ class GomuksListenerController extends AsyncNotifier<void> {
|
|||
ref.invalidate(roomProvider);
|
||||
ref.invalidate(accountDataProvider);
|
||||
ref.invalidate(invitesProvider);
|
||||
ref.invalidate(TypingController.provider);
|
||||
}
|
||||
|
||||
ref
|
||||
|
|
@ -153,6 +159,7 @@ class GomuksListenerController extends AsyncNotifier<void> {
|
|||
if (paused) {
|
||||
GomuksPauseSync(handle);
|
||||
} else {
|
||||
ref.invalidate(TypingController.provider);
|
||||
GomuksResumeSync(handle);
|
||||
}
|
||||
}, fireImmediately: true);
|
||||
|
|
|
|||
28
lib/controllers/local_aliases.dart
Normal file
28
lib/controllers/local_aliases.dart
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
|
||||
/// A room's aliases on the user's own server.
|
||||
class LocalAliasesController(final String roomId)
|
||||
extends AsyncNotifier<IList<String>> {
|
||||
@override
|
||||
Future<IList<String>> build() =>
|
||||
ref.read(ClientController.provider.notifier).getLocalAliases(roomId);
|
||||
|
||||
Future<void> add(String alias) async {
|
||||
await ref
|
||||
.read(ClientController.provider.notifier)
|
||||
.createAlias(roomId, alias);
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
|
||||
Future<void> remove(String alias) async {
|
||||
await ref.read(ClientController.provider.notifier).deleteAlias(alias);
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
|
||||
static final provider = AsyncNotifierProvider.family
|
||||
.autoDispose<LocalAliasesController, IList<String>, String>(
|
||||
LocalAliasesController.new,
|
||||
);
|
||||
}
|
||||
|
|
@ -1,44 +1,27 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/members_by_status.dart";
|
||||
import "package:nexus/controllers/room_creators.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/controllers/room_power_levels.dart";
|
||||
import "package:nexus/models/configs/members_by_status.dart";
|
||||
import "package:nexus/models/content/content.dart";
|
||||
import "package:nexus/models/content/power_levels.dart";
|
||||
import "package:nexus/models/event.dart";
|
||||
|
||||
class MembersGroupedController(final MembersByStatusConfig config)
|
||||
extends AsyncNotifier<IList<MapEntry<int?, ISet<Event>>>> {
|
||||
@override
|
||||
Future<IList<MapEntry<int?, ISet<Event>>>> build() async {
|
||||
final room = ref.watch(
|
||||
RoomsController.provider.select((value) => value[config.roomId]),
|
||||
final powerLevels = ref.watch(
|
||||
RoomPowerLevelsController.provider(config.roomId),
|
||||
);
|
||||
|
||||
final roomCreators = room == null
|
||||
? null
|
||||
: ref.watch((RoomCreatorsController.provider(room)));
|
||||
|
||||
final powerLevelsRowId = room?.state[EventType.powerLevels.type]?[""];
|
||||
final powerLevelsEvent = powerLevelsRowId == null
|
||||
? null
|
||||
: room?.events[powerLevelsRowId];
|
||||
|
||||
final content = switch (powerLevelsEvent?.content) {
|
||||
PowerLevelsContent content => content,
|
||||
_ => PowerLevelsContent(),
|
||||
};
|
||||
|
||||
final members = await ref.watch(
|
||||
MembersByStatusController.provider(config).future,
|
||||
);
|
||||
|
||||
return members
|
||||
.fold<IMap<int?, ISet<Event>>>(.new(), (result, event) {
|
||||
final groupKey = roomCreators?.contains(event.stateKey!) == true
|
||||
final groupKey = powerLevels.creators.contains(event.stateKey!)
|
||||
? null
|
||||
: content.users[event.stateKey!] ?? content.usersDefault;
|
||||
: powerLevels.levelOf(event.stateKey!).toInt();
|
||||
|
||||
return result.update(
|
||||
groupKey,
|
||||
|
|
|
|||
15
lib/controllers/mutual_rooms.dart
Normal file
15
lib/controllers/mutual_rooms.dart
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
|
||||
class MutualRoomsController(final String userId)
|
||||
extends AsyncNotifier<IList<String>> {
|
||||
@override
|
||||
Future<IList<String>> build() =>
|
||||
ref.read(ClientController.provider.notifier).getMutualRooms(userId);
|
||||
|
||||
static final provider = AsyncNotifierProvider.family
|
||||
.autoDispose<MutualRoomsController, IList<String>, String>(
|
||||
MutualRoomsController.new,
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import "package:collection/collection.dart";
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
|
|
@ -9,26 +8,6 @@ typedef NotificationsRequest = (UnreadType? unreadType, String? roomId);
|
|||
|
||||
class NotificationsController(final NotificationsRequest request)
|
||||
extends AsyncNotifier<IList<(int, String)>> {
|
||||
void storeEvents(Iterable<Event> events) => ref
|
||||
.read(RoomsController.provider.notifier)
|
||||
.update(
|
||||
.new(
|
||||
events
|
||||
.groupListsBy((event) => event.roomId)
|
||||
.map(
|
||||
(roomId, roomEvents) => .new(
|
||||
roomId,
|
||||
.new(
|
||||
events: IMap.fromIterable(
|
||||
roomEvents,
|
||||
keyMapper: (event) => event.rowId,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<IList<(int, String)>> build() async {
|
||||
final client = ref.read(ClientController.provider.notifier);
|
||||
|
|
@ -43,7 +22,9 @@ class NotificationsController(final NotificationsRequest request)
|
|||
),
|
||||
);
|
||||
|
||||
storeEvents(mentions.events.addAll(mentions.relatedEvents));
|
||||
ref
|
||||
.read(RoomsController.provider.notifier)
|
||||
.storeEvents(mentions.events.addAll(mentions.relatedEvents));
|
||||
|
||||
return .new(mentions.events.map((event) => (event.rowId, event.roomId)));
|
||||
}
|
||||
|
|
@ -76,9 +57,11 @@ class NotificationsController(final NotificationsRequest request)
|
|||
),
|
||||
);
|
||||
|
||||
storeEvents(
|
||||
newNotifications.events.addAll(newNotifications.relatedEvents),
|
||||
);
|
||||
ref
|
||||
.read(RoomsController.provider.notifier)
|
||||
.storeEvents(
|
||||
newNotifications.events.addAll(newNotifications.relatedEvents),
|
||||
);
|
||||
|
||||
return currentNotifications.addAll(
|
||||
newNotifications.events.map((event) => (event.rowId, event.roomId)),
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/client_state.dart";
|
||||
import "package:nexus/controllers/room_creators.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/controllers/room_power_levels.dart";
|
||||
import "package:nexus/models/configs/power_level.dart";
|
||||
import "package:nexus/models/content/content.dart";
|
||||
import "package:nexus/models/content/power_levels.dart";
|
||||
|
||||
class PowerLevelController(final PowerLevelConfig config)
|
||||
extends Notifier<bool> {
|
||||
|
|
@ -17,31 +15,17 @@ class PowerLevelController(final PowerLevelConfig config)
|
|||
);
|
||||
}
|
||||
|
||||
final room = ref.watch(
|
||||
RoomsController.provider.select((value) => value[config.roomId]),
|
||||
final powerLevels = ref.watch(
|
||||
RoomPowerLevelsController.provider(config.roomId),
|
||||
);
|
||||
|
||||
final roomCreators = room == null
|
||||
? null
|
||||
: ref.watch(RoomCreatorsController.provider(room));
|
||||
|
||||
final eventRowId = room?.state[EventType.powerLevels.type]?[""];
|
||||
|
||||
final event = eventRowId == null ? null : room?.events[eventRowId];
|
||||
final content = event?.content is PowerLevelsContent
|
||||
? event!.content
|
||||
: PowerLevelsContent();
|
||||
final content = powerLevels.content;
|
||||
|
||||
final user = ref.watch(
|
||||
ClientStateController.provider.select((value) => value?.userId),
|
||||
);
|
||||
if (user == null || content is! PowerLevelsContent) return false;
|
||||
if (user == null) return false;
|
||||
|
||||
double powerLevelOf(String userId) => roomCreators?.contains(userId) == true
|
||||
? double.infinity
|
||||
: (content.users[userId] ?? content.usersDefault).toDouble();
|
||||
|
||||
final userLevel = powerLevelOf(user);
|
||||
final userLevel = powerLevels.levelOf(user);
|
||||
|
||||
return switch (config) {
|
||||
EventPowerLevelConfig(:final eventType) =>
|
||||
|
|
@ -52,10 +36,12 @@ class PowerLevelController(final PowerLevelConfig config)
|
|||
.invite => userLevel >= content.invite,
|
||||
|
||||
.kick =>
|
||||
userLevel >= content.kick && userLevel > powerLevelOf(targetUser),
|
||||
userLevel >= content.kick &&
|
||||
userLevel > powerLevels.levelOf(targetUser),
|
||||
|
||||
.ban =>
|
||||
userLevel >= content.ban && userLevel > powerLevelOf(targetUser),
|
||||
userLevel >= content.ban &&
|
||||
userLevel > powerLevels.levelOf(targetUser),
|
||||
|
||||
.unban => userLevel >= content.ban,
|
||||
},
|
||||
|
|
|
|||
20
lib/controllers/power_levels_saving.dart
Normal file
20
lib/controllers/power_levels_saving.dart
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
|
||||
class PowerLevelsSavingController(final String roomId) extends Notifier<bool> {
|
||||
@override
|
||||
bool build() => false;
|
||||
|
||||
Future<void> run(Future<void> Function() action) async {
|
||||
state = true;
|
||||
try {
|
||||
await action();
|
||||
} finally {
|
||||
if (ref.mounted) state = false;
|
||||
}
|
||||
}
|
||||
|
||||
static final provider = NotifierProvider.autoDispose
|
||||
.family<PowerLevelsSavingController, bool, String>(
|
||||
PowerLevelsSavingController.new,
|
||||
);
|
||||
}
|
||||
90
lib/controllers/read_receipts.dart
Normal file
90
lib/controllers/read_receipts.dart
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import "package:collection/collection.dart";
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/client_state.dart";
|
||||
import "package:nexus/controllers/room_chat.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/controllers/settings.dart";
|
||||
import "package:nexus/helpers/extensions/is_shown_as_message.dart";
|
||||
import "package:nexus/models/read_receipt.dart";
|
||||
|
||||
/// Other users' read receipts in a chat, by the event ID of the message to
|
||||
/// show them under: the nearest message at or before what they've read.
|
||||
class ReadReceiptsController(final ChatParams chat)
|
||||
extends Notifier<IMap<String, IList<ReadReceipt>>> {
|
||||
@override
|
||||
IMap<String, IList<ReadReceipt>> build() {
|
||||
final shouldShow = ref.watch(
|
||||
SettingsController.provider.select(
|
||||
(settings) => settings.value?.readReceipts != .neither,
|
||||
),
|
||||
);
|
||||
if (!shouldShow) return const .empty();
|
||||
|
||||
final userId = ref.watch(
|
||||
ClientStateController.provider.select((state) => state?.userId),
|
||||
);
|
||||
// The chat's own timeline, so receipts follow what's actually shown
|
||||
final timeline = ref.watch(
|
||||
RoomChatController.provider(chat)
|
||||
.select((roomChat) => roomChat.value?.timeline),
|
||||
);
|
||||
final room = ref.watch(
|
||||
RoomsController.provider.select(
|
||||
(rooms) => switch (rooms[chat.roomId]) {
|
||||
final room? => (events: room.events, receipts: room.receipts),
|
||||
null => null,
|
||||
},
|
||||
),
|
||||
);
|
||||
if (userId == null || timeline == null || room == null) {
|
||||
return const .empty();
|
||||
}
|
||||
|
||||
final events = timeline.map((rowId) => room.events[rowId]).nonNulls;
|
||||
final indexes = IMap.fromEntries(
|
||||
events.indexed.map((entry) => MapEntry(entry.$2.eventId, entry.$1)),
|
||||
);
|
||||
final messages = events.indexed
|
||||
.where((entry) => entry.$2.isShownAsMessage)
|
||||
.toIList();
|
||||
final messageIndexes = messages.map((entry) => entry.$1).toList();
|
||||
|
||||
return room.receipts.values.flattened
|
||||
.where(
|
||||
(receipt) =>
|
||||
receipt.userId != userId &&
|
||||
// Receipts in threads are for the thread, not the timeline
|
||||
receipt.thread == "main",
|
||||
)
|
||||
.map(
|
||||
(receipt) => switch (indexes[receipt.eventId]) {
|
||||
final index? => (
|
||||
receipt: receipt,
|
||||
message: lowerBound(messageIndexes, index + 1) - 1,
|
||||
),
|
||||
null => null,
|
||||
},
|
||||
)
|
||||
.nonNulls
|
||||
.where((entry) => entry.message >= 0)
|
||||
.groupListsBy((entry) => messages[entry.message].$2.eventId)
|
||||
.map(
|
||||
(eventId, entries) => MapEntry(
|
||||
eventId,
|
||||
entries
|
||||
.map((entry) => entry.receipt)
|
||||
.sorted((a, b) => b.timestamp.compareTo(a.timestamp))
|
||||
.toIList(),
|
||||
),
|
||||
)
|
||||
.toIMap();
|
||||
}
|
||||
|
||||
static final provider = NotifierProvider.family
|
||||
.autoDispose<
|
||||
ReadReceiptsController,
|
||||
IMap<String, IList<ReadReceipt>>,
|
||||
ChatParams
|
||||
>(ReadReceiptsController.new);
|
||||
}
|
||||
|
|
@ -73,16 +73,7 @@ class RoomChatController(final ChatParams info)
|
|||
.addAll(context.after);
|
||||
ref
|
||||
.read(RoomsController.provider.notifier)
|
||||
.update(
|
||||
.new({
|
||||
info.roomId: Room(
|
||||
events: IMap.fromIterable(
|
||||
events.addAll(context.relatedEvents),
|
||||
keyMapper: (event) => event.rowId,
|
||||
),
|
||||
),
|
||||
}),
|
||||
);
|
||||
.storeEvents(events.addAll(context.relatedEvents));
|
||||
return .new(
|
||||
timeline: .new(events.map((element) => element.rowId)),
|
||||
hasMoreBackward: true,
|
||||
|
|
@ -165,6 +156,7 @@ class RoomChatController(final ChatParams info)
|
|||
valueMapper: (event) => event,
|
||||
),
|
||||
hasMore: response.hasMore,
|
||||
receipts: response.receipts,
|
||||
timeline: IMap.fromIterable(
|
||||
response.events,
|
||||
keyMapper: (event) => event.timelineRowId,
|
||||
|
|
@ -186,17 +178,8 @@ class RoomChatController(final ChatParams info)
|
|||
|
||||
ref
|
||||
.read(RoomsController.provider.notifier)
|
||||
.update(
|
||||
.new({
|
||||
info.roomId: Room(
|
||||
events: IMap.fromIterable(
|
||||
paginationResponse.events.addAll(
|
||||
paginationResponse.relatedEvents,
|
||||
),
|
||||
keyMapper: (event) => event.rowId,
|
||||
),
|
||||
),
|
||||
}),
|
||||
.storeEvents(
|
||||
paginationResponse.events.addAll(paginationResponse.relatedEvents),
|
||||
);
|
||||
|
||||
final eventRowIds =
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/models/content/content.dart";
|
||||
import "package:nexus/models/content/create.dart";
|
||||
import "package:nexus/models/room.dart";
|
||||
|
||||
class RoomCreatorsController(final Room room) extends Notifier<IList<String>> {
|
||||
@override
|
||||
IList<String> build() {
|
||||
final createRowId = room.state[EventType.create.type]?[""];
|
||||
final createEvent = createRowId == null ? null : room.events[createRowId];
|
||||
|
||||
if (createEvent == null) return .new();
|
||||
|
||||
final createEventContent = switch (createEvent.content) {
|
||||
CreateContent content => content,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
return switch (createEventContent?.additionalCreatorIds) {
|
||||
IList<String> creators => creators.add(createEvent.sender),
|
||||
_ => .new([createEvent.sender]),
|
||||
};
|
||||
}
|
||||
|
||||
static final provider =
|
||||
NotifierProvider.family<RoomCreatorsController, IList<String>, Room>(
|
||||
RoomCreatorsController.new,
|
||||
);
|
||||
}
|
||||
59
lib/controllers/room_power_levels.dart
Normal file
59
lib/controllers/room_power_levels.dart
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/models/content/content.dart";
|
||||
import "package:nexus/models/content/create.dart";
|
||||
import "package:nexus/models/content/power_levels.dart";
|
||||
import "package:nexus/models/event.dart";
|
||||
import "package:nexus/models/room_power_levels.dart";
|
||||
|
||||
class RoomPowerLevelsController(final String roomId)
|
||||
extends Notifier<RoomPowerLevels> {
|
||||
@override
|
||||
RoomPowerLevels build() {
|
||||
final room = ref.watch(
|
||||
RoomsController.provider.select((rooms) => rooms[roomId]),
|
||||
);
|
||||
|
||||
Event? stateEvent(EventType type) => switch (room?.state[type.type]?[""]) {
|
||||
final rowId? => room?.events[rowId],
|
||||
null => null,
|
||||
};
|
||||
|
||||
final createEvent = stateEvent(.create);
|
||||
final create = switch (createEvent?.content) {
|
||||
final CreateContent content => content,
|
||||
_ => null,
|
||||
};
|
||||
// Creators only outrank everyone from room version 12, earlier ones just have a power level
|
||||
final creatorsPrivileged =
|
||||
(int.tryParse(create?.roomVersion ?? "") ?? 0) >= 12;
|
||||
|
||||
return .new(
|
||||
content: switch (stateEvent(.powerLevels)?.content) {
|
||||
final PowerLevelsContent content => content,
|
||||
// Until there are power levels, creators have 100
|
||||
_ => PowerLevelsContent(
|
||||
users: switch (createEvent) {
|
||||
final createEvent? when !creatorsPrivileged => .new({
|
||||
createEvent.sender: 100,
|
||||
}),
|
||||
_ => const .empty(),
|
||||
},
|
||||
),
|
||||
},
|
||||
creators: switch (createEvent) {
|
||||
final createEvent? when creatorsPrivileged =>
|
||||
(create?.additionalCreatorIds ?? const .empty())
|
||||
.add(createEvent.sender)
|
||||
.toISet(),
|
||||
_ => const .empty(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static final provider = NotifierProvider.autoDispose
|
||||
.family<RoomPowerLevelsController, RoomPowerLevels, String>(
|
||||
RoomPowerLevelsController.new,
|
||||
);
|
||||
}
|
||||
817
lib/controllers/room_settings_sections.dart
Normal file
817
lib/controllers/room_settings_sections.dart
Normal file
|
|
@ -0,0 +1,817 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/controllers/client_state.dart";
|
||||
import "package:nexus/controllers/local_aliases.dart";
|
||||
import "package:nexus/controllers/power_level.dart";
|
||||
import "package:nexus/controllers/power_levels_saving.dart";
|
||||
import "package:nexus/controllers/room_power_levels.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/controllers/space_edges.dart";
|
||||
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
|
||||
import "package:nexus/helpers/file_dialog_helper.dart";
|
||||
import "package:nexus/helpers/hooks/saving.dart";
|
||||
import "package:nexus/main.dart";
|
||||
import "package:nexus/models/content/avatar.dart";
|
||||
import "package:nexus/models/content/canonical_alias.dart";
|
||||
import "package:nexus/models/content/content.dart";
|
||||
import "package:nexus/models/content/create.dart";
|
||||
import "package:nexus/models/content/encryption.dart";
|
||||
import "package:nexus/models/content/history_visibility.dart";
|
||||
import "package:nexus/models/content/join_rules.dart";
|
||||
import "package:nexus/models/content/membership.dart";
|
||||
import "package:nexus/models/content/message.dart";
|
||||
import "package:nexus/models/content/name.dart";
|
||||
import "package:nexus/models/content/power_levels.dart";
|
||||
import "package:nexus/models/content/topic.dart";
|
||||
import "package:nexus/models/join_rule.dart";
|
||||
import "package:nexus/models/setting.dart";
|
||||
import "package:nexus/models/settings_category.dart";
|
||||
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||
import "package:nexus/widgets/divider_text.dart";
|
||||
import "package:nexus/widgets/settings/add_users_power_level_sheet.dart";
|
||||
import "package:nexus/widgets/settings/aliases_sheet.dart";
|
||||
import "package:nexus/widgets/settings/allowed_rooms_sheet.dart";
|
||||
import "package:nexus/widgets/settings/dialog_list_tile.dart";
|
||||
import "package:nexus/widgets/settings/local_aliases_sheet.dart";
|
||||
import "package:nexus/widgets/settings/power_level_entry_sheet.dart";
|
||||
import "package:nexus/widgets/settings/power_level_tile.dart";
|
||||
import "package:nexus/widgets/settings/text_setting_tile.dart";
|
||||
|
||||
class RoomSettingsSectionsController(final String roomId)
|
||||
extends AsyncNotifier<IMap<String, IList<SettingsCategory>>> {
|
||||
@override
|
||||
Future<IMap<String, IList<SettingsCategory>>> build() async {
|
||||
final room = ref.watch(
|
||||
RoomsController.provider.select((rooms) => rooms[roomId]),
|
||||
);
|
||||
final client = ref.read(ClientController.provider.notifier);
|
||||
final roomsController = ref.read(RoomsController.provider.notifier);
|
||||
|
||||
// The room's own state, rather than metadata like a name made up from
|
||||
// its members when it has none
|
||||
Content? stateContent(EventType type) =>
|
||||
switch (room?.state[type.type]?[""]) {
|
||||
final rowId? => room?.events[rowId]?.content,
|
||||
null => null,
|
||||
};
|
||||
final name = switch (stateContent(.name)) {
|
||||
NameContent(:final name) => name,
|
||||
_ => null,
|
||||
};
|
||||
final topic = switch (stateContent(.topic)) {
|
||||
TopicContent(:final topic) => topic,
|
||||
_ => room?.metadata?.topic,
|
||||
};
|
||||
final avatar = switch (stateContent(.avatar)) {
|
||||
AvatarContent(:final url) => url,
|
||||
_ => room?.metadata?.avatar,
|
||||
};
|
||||
|
||||
bool canSet(EventType type) => ref.watch(
|
||||
PowerLevelController.provider(.state(eventType: type, roomId: roomId)),
|
||||
);
|
||||
|
||||
final canSetAvatar = canSet(.avatar);
|
||||
final canSetName = canSet(.name);
|
||||
final canSetTopic = canSet(.topic);
|
||||
final canSetJoinRules = canSet(.joinRules);
|
||||
final canSetHistoryVisibility = canSet(.historyVisibility);
|
||||
final canSetAliases = canSet(.canonicalAlias);
|
||||
final canSetPowerLevels =
|
||||
canSet(.powerLevels) &&
|
||||
!ref.watch(PowerLevelsSavingController.provider(roomId));
|
||||
|
||||
final joinRules = switch (stateContent(.joinRules)) {
|
||||
final JoinRulesContent content => content,
|
||||
_ => JoinRulesContent(joinRule: .invite),
|
||||
};
|
||||
final historyVisibility = switch (stateContent(.historyVisibility)) {
|
||||
HistoryVisibilityContent(:final historyVisibility) => historyVisibility,
|
||||
_ => HistoryVisibility.shared,
|
||||
};
|
||||
final canonicalAlias = switch (stateContent(.canonicalAlias)) {
|
||||
final CanonicalAliasContent content => content,
|
||||
_ => CanonicalAliasContent(),
|
||||
};
|
||||
final published = canonicalAlias.altAliases.addAll([?canonicalAlias.alias]);
|
||||
final localAliases =
|
||||
ref.watch(LocalAliasesController.provider(roomId)).value ??
|
||||
const IList<String>.empty();
|
||||
|
||||
final knownAliases = localAliases.addAll(canonicalAlias.altAliases).addAll([
|
||||
?canonicalAlias.alias,
|
||||
]).toISet();
|
||||
|
||||
final roomVersion = switch (stateContent(.create)) {
|
||||
CreateContent(:final roomVersion) => int.tryParse(roomVersion),
|
||||
_ => null,
|
||||
};
|
||||
bool versionAtLeast(int version) =>
|
||||
roomVersion == null || roomVersion >= version;
|
||||
final joinRuleOptions = [
|
||||
JoinRule.public,
|
||||
JoinRule.invite,
|
||||
if (versionAtLeast(7)) JoinRule.knock,
|
||||
if (versionAtLeast(8)) JoinRule.restricted,
|
||||
if (versionAtLeast(10)) JoinRule.knockRestricted,
|
||||
];
|
||||
bool isRestricted(JoinRule rule) =>
|
||||
rule == .restricted || rule == .knockRestricted;
|
||||
|
||||
final parentSpaces = ref
|
||||
.watch(
|
||||
SpaceEdgesController.provider.select(
|
||||
(edges) => edges.entries
|
||||
.where(
|
||||
(entry) => entry.value.any((edge) => edge.childId == roomId),
|
||||
)
|
||||
.map((entry) => entry.key)
|
||||
.toIList(),
|
||||
),
|
||||
)
|
||||
.addAll(room?.state["m.space.parent"]?.keys ?? const [])
|
||||
.toISet();
|
||||
final allowedRooms = joinRules.allow
|
||||
.map((condition) => condition.roomId)
|
||||
.nonNulls
|
||||
.toISet();
|
||||
|
||||
final roomPowerLevels = ref.watch(
|
||||
RoomPowerLevelsController.provider(roomId),
|
||||
);
|
||||
final powerLevels = roomPowerLevels.content;
|
||||
final userId = ref.watch(
|
||||
ClientStateController.provider.select((state) => state?.userId),
|
||||
);
|
||||
final ownLevel = userId == null ? 0.0 : roomPowerLevels.levelOf(userId);
|
||||
// Encrypted rooms send messages as m.room.encrypted, so that's what limits them
|
||||
final messageType = stateContent(.encryption) is EncryptionContent
|
||||
? EventType.encrypted
|
||||
: EventType.message;
|
||||
|
||||
Future<void> setState(EventType type, Content content) async {
|
||||
final eventId = await client.setState(
|
||||
.new(roomId: roomId, type: type.type, stateKey: "", content: content),
|
||||
);
|
||||
if (eventId case final eventId?) {
|
||||
await roomsController.awaitState(roomId, type.type, eventId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setAliases(
|
||||
String? canonical,
|
||||
ISet<String> alternatives,
|
||||
) async {
|
||||
final stale = (await Future.wait(
|
||||
alternatives.addAll([?canonical]).map((alias) async {
|
||||
try {
|
||||
return await client.resolveAlias(alias) == roomId ? null : alias;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
)).nonNulls.toISet();
|
||||
bool kept(String alias) => !stale.contains(alias);
|
||||
|
||||
await setState(
|
||||
.canonicalAlias,
|
||||
CanonicalAliasContent(
|
||||
alias: canonical != null && kept(canonical) ? canonical : null,
|
||||
altAliases: alternatives.where(kept).toISet(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setAllowedRooms(ISet<String> roomIds) => setState(
|
||||
.joinRules,
|
||||
joinRules.copyWith(
|
||||
allow: roomIds
|
||||
.map((roomId) => AllowCondition(roomId: roomId, type: .membership))
|
||||
.toISet(),
|
||||
),
|
||||
);
|
||||
|
||||
Setting header(String title, String description, IconData icon) => .new(
|
||||
title: title,
|
||||
description: description,
|
||||
icon: icon,
|
||||
builder: (title, _, _) => Padding(
|
||||
padding: .symmetric(horizontal: 16),
|
||||
child: DividerText(title),
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> setPowerLevels(PowerLevelsContent content) => ref
|
||||
.read(PowerLevelsSavingController.provider(roomId).notifier)
|
||||
.run(() => setState(.powerLevels, content).onError(showError));
|
||||
|
||||
// Event types with their own level that don't have a setting of their own
|
||||
final listedEvents = [
|
||||
messageType,
|
||||
EventType.sticker,
|
||||
EventType.reaction,
|
||||
EventType.pinnedEvents,
|
||||
].map((type) => type.type).toISet();
|
||||
final otherEvents = powerLevels.events.entries
|
||||
.where((entry) => !listedEvents.contains(entry.key))
|
||||
.toIList()
|
||||
.sort((a, b) => a.key.compareTo(b.key));
|
||||
|
||||
// Users with their own level, the highest first
|
||||
final users = powerLevels.users.entries.toIList().sort(
|
||||
(a, b) => switch (b.value.compareTo(a.value)) {
|
||||
0 => a.key.compareTo(b.key),
|
||||
final order => order,
|
||||
},
|
||||
);
|
||||
MembershipContent? memberOf(String userId) =>
|
||||
switch (room?.state[EventType.membership.type]?[userId]) {
|
||||
final rowId? => switch (room?.events[rowId]?.content) {
|
||||
final MembershipContent content => content,
|
||||
_ => null,
|
||||
},
|
||||
null => null,
|
||||
};
|
||||
|
||||
/// A tile opening a [sheet] to add entries, like event types or users, with their own power level.
|
||||
Setting addSetting({
|
||||
required String title,
|
||||
required String description,
|
||||
required Widget Function(void Function(PowerLevelsContent content) save)
|
||||
sheet,
|
||||
}) => .new(
|
||||
title: title,
|
||||
description: description,
|
||||
icon: Icons.add,
|
||||
builder: (title, description, icon) => Builder(
|
||||
builder: (context) => ListTile(
|
||||
enabled: canSetPowerLevels,
|
||||
leading: Icon(icon),
|
||||
title: Text(title),
|
||||
subtitle: Text(description),
|
||||
onTap: () => context.showAdaptiveSheet((_) => sheet(setPowerLevels)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Setting powerLevelSetting({
|
||||
required String title,
|
||||
required String description,
|
||||
required IconData icon,
|
||||
required int value,
|
||||
required PowerLevelsContent Function(int level) update,
|
||||
}) => .new(
|
||||
title: title,
|
||||
description: description,
|
||||
icon: icon,
|
||||
builder: (title, description, icon) => PowerLevelTile(
|
||||
leading: Icon(icon),
|
||||
title: title,
|
||||
description: description,
|
||||
value: value,
|
||||
ownLevel: ownLevel,
|
||||
onChanged: canSetPowerLevels
|
||||
? (level) => setPowerLevels(update(level))
|
||||
: null,
|
||||
),
|
||||
);
|
||||
|
||||
/// A setting for the level needed to send [type], which otherwise needs [fallback].
|
||||
Setting eventSetting(
|
||||
EventType type, {
|
||||
required String title,
|
||||
required String description,
|
||||
required IconData icon,
|
||||
required int fallback,
|
||||
}) => powerLevelSetting(
|
||||
title: title,
|
||||
description: description,
|
||||
icon: icon,
|
||||
value: powerLevels.events[type.type] ?? fallback,
|
||||
update: (level) => powerLevels.copyWith(
|
||||
events: powerLevels.events.add(type.type, level),
|
||||
),
|
||||
);
|
||||
|
||||
return .new({
|
||||
"Room": .new([
|
||||
.new(
|
||||
title: "General",
|
||||
icon: Icons.tune,
|
||||
settings: .new([
|
||||
.new(
|
||||
title: "Avatar",
|
||||
description: "The image shown for the room.",
|
||||
icon: Icons.image,
|
||||
builder: (title, description, icon) => HookBuilder(
|
||||
builder: (context) {
|
||||
final saving = Saving.use();
|
||||
Future<void> save(Future<void> Function() action) =>
|
||||
saving.run(() => action().onError(showError));
|
||||
|
||||
return ListTile(
|
||||
enabled: canSetAvatar && !saving.active,
|
||||
leading: Icon(icon),
|
||||
title: Text(title),
|
||||
subtitle: Text(description),
|
||||
trailing: Row(
|
||||
mainAxisSize: .min,
|
||||
spacing: 8,
|
||||
children: [
|
||||
AvatarOrHash(
|
||||
avatar,
|
||||
name ?? "Unnamed Room",
|
||||
dimension: 40,
|
||||
fallback: Icon(Icons.numbers),
|
||||
),
|
||||
if (avatar != null && canSetAvatar)
|
||||
IconButton(
|
||||
tooltip: "Remove avatar",
|
||||
icon: Icon(Icons.delete),
|
||||
onPressed: saving.active
|
||||
? null
|
||||
: () => save(
|
||||
() => setState(.avatar, const .new()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () async {
|
||||
final file = await ProviderScope.containerOf(context)
|
||||
.read(FileDialogHelper.provider)
|
||||
.pickFile(images: true);
|
||||
if (file == null || !context.mounted) return;
|
||||
|
||||
await save(() async {
|
||||
final content = await client.uploadMedia(
|
||||
.new(path: file.path, encrypt: false),
|
||||
);
|
||||
if (content case ImageMessageContent(
|
||||
:final url,
|
||||
:final info,
|
||||
)) {
|
||||
await setState(
|
||||
.avatar,
|
||||
AvatarContent(url: url, info: info),
|
||||
);
|
||||
} else {
|
||||
throw Exception("The file isn't an image");
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
.new(
|
||||
title: "Room Name",
|
||||
description: "The name shown for the room.",
|
||||
icon: Icons.title,
|
||||
builder: (title, description, icon) => TextSettingTile(
|
||||
icon: Icon(icon),
|
||||
title: title,
|
||||
description: description,
|
||||
value: name,
|
||||
onChanged: canSetName
|
||||
? (value) => setState(
|
||||
.name,
|
||||
NameContent(name: value),
|
||||
).onError(showError)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
.new(
|
||||
title: "Topic",
|
||||
description: "A short description of the room.",
|
||||
icon: Icons.info,
|
||||
builder: (title, description, icon) => TextSettingTile(
|
||||
icon: Icon(icon),
|
||||
title: title,
|
||||
description: description,
|
||||
value: topic,
|
||||
multiline: true,
|
||||
onChanged: canSetTopic
|
||||
? (value) => setState(
|
||||
.topic,
|
||||
TopicContent(
|
||||
topic: value,
|
||||
content: .new(
|
||||
representations: .new([.new(body: value)]),
|
||||
),
|
||||
),
|
||||
).onError(showError)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
.new(
|
||||
title: "Access",
|
||||
icon: Icons.key,
|
||||
settings: .new([
|
||||
.new(
|
||||
title: "Join Rule",
|
||||
description: "Who can join the room.",
|
||||
icon: Icons.login,
|
||||
builder: (title, description, icon) => DialogListTile<JoinRule>(
|
||||
icon: Icon(icon),
|
||||
title: title,
|
||||
subtitle: Text(description),
|
||||
value: joinRules.joinRule,
|
||||
options: joinRuleOptions,
|
||||
getName: (rule) => switch (rule) {
|
||||
.public => "Public",
|
||||
.invite => "Invite only",
|
||||
.knock => "Knock",
|
||||
.restricted => "Restricted",
|
||||
.knockRestricted => "Restricted or knock",
|
||||
.private => "Private",
|
||||
},
|
||||
onChanged: canSetJoinRules
|
||||
? (rule) => setState(
|
||||
.joinRules,
|
||||
JoinRulesContent(
|
||||
joinRule: rule,
|
||||
allow: isRestricted(rule) && joinRules.allow.isEmpty
|
||||
? .new(
|
||||
parentSpaces.map(
|
||||
(roomId) =>
|
||||
.new(roomId: roomId, type: .membership),
|
||||
),
|
||||
)
|
||||
: joinRules.allow,
|
||||
),
|
||||
).onError(showError)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
if (isRestricted(joinRules.joinRule))
|
||||
.new(
|
||||
title: "Allowed Rooms",
|
||||
description:
|
||||
"Members of these rooms can join without an invite.",
|
||||
icon: Icons.meeting_room,
|
||||
builder: (title, description, icon) => HookConsumer(
|
||||
builder: (context, ref, _) {
|
||||
final saving = Saving.use();
|
||||
final names = ref.watch(
|
||||
RoomsController.provider.select(
|
||||
(rooms) => allowedRooms
|
||||
.map(
|
||||
(roomId) =>
|
||||
rooms[roomId]?.metadata?.name ?? roomId,
|
||||
)
|
||||
.toIList(),
|
||||
),
|
||||
);
|
||||
|
||||
return ListTile(
|
||||
enabled: canSetJoinRules && !saving.active,
|
||||
leading: Icon(icon),
|
||||
title: Text(title),
|
||||
subtitle: Text(
|
||||
names.isEmpty
|
||||
? "None, so only invited users can join"
|
||||
: names.join(", "),
|
||||
maxLines: 2,
|
||||
overflow: .ellipsis,
|
||||
),
|
||||
onTap: () => context.showAdaptiveSheet(
|
||||
(_) => AllowedRoomsSheet(
|
||||
roomId: roomId,
|
||||
allowed: allowedRooms,
|
||||
parentSpaces: parentSpaces,
|
||||
onSave: (allowed) => saving.run(
|
||||
() => setAllowedRooms(allowed).onError(showError),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
.new(
|
||||
title: "Aliases",
|
||||
description:
|
||||
"The addresses the room advertises, and its main one.",
|
||||
icon: Icons.alternate_email,
|
||||
builder: (title, description, icon) => HookBuilder(
|
||||
builder: (context) {
|
||||
final saving = Saving.use();
|
||||
|
||||
return ListTile(
|
||||
enabled: canSetAliases && !saving.active,
|
||||
leading: Icon(icon),
|
||||
title: Text(title),
|
||||
subtitle: Text(
|
||||
switch ((
|
||||
canonicalAlias.alias,
|
||||
canonicalAlias.altAliases.length,
|
||||
)) {
|
||||
(null, 0) => description,
|
||||
(final alias?, 0) => alias,
|
||||
(final alias?, 1) =>
|
||||
"$alias and ${canonicalAlias.altAliases.first}",
|
||||
(final alias?, final others) =>
|
||||
"$alias and $others more",
|
||||
(null, _) => canonicalAlias.altAliases.join(", "),
|
||||
},
|
||||
maxLines: 2,
|
||||
overflow: .ellipsis,
|
||||
),
|
||||
onTap: () => context.showAdaptiveSheet(
|
||||
(_) => AliasesSheet(
|
||||
aliases: knownAliases,
|
||||
canonical: canonicalAlias.alias,
|
||||
alternatives: canonicalAlias.altAliases,
|
||||
onSave: (canonical, alternatives) => saving.run(
|
||||
() => setAliases(
|
||||
canonical,
|
||||
alternatives,
|
||||
).onError(showError),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
.new(
|
||||
title: "Local Aliases",
|
||||
description: "Addresses for the room on your server.",
|
||||
icon: Icons.dns,
|
||||
builder: (title, description, icon) => Builder(
|
||||
builder: (context) => ListTile(
|
||||
leading: Icon(icon),
|
||||
title: Text(title),
|
||||
subtitle: Text(
|
||||
localAliases.isEmpty
|
||||
? description
|
||||
: localAliases.join(", "),
|
||||
maxLines: 2,
|
||||
overflow: .ellipsis,
|
||||
),
|
||||
onTap: () => context.showAdaptiveSheet(
|
||||
(_) => LocalAliasesSheet(
|
||||
roomId,
|
||||
onRemoved: canSetAliases
|
||||
? (alias) async {
|
||||
if (published.contains(alias)) {
|
||||
await setAliases(
|
||||
canonicalAlias.alias,
|
||||
canonicalAlias.altAliases,
|
||||
);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
.new(
|
||||
title: "History Visibility",
|
||||
description:
|
||||
"Which messages from before joining members can read.",
|
||||
icon: Icons.history,
|
||||
builder: (title, description, icon) =>
|
||||
DialogListTile<HistoryVisibility>(
|
||||
icon: Icon(icon),
|
||||
title: title,
|
||||
subtitle: Text(description),
|
||||
value: historyVisibility,
|
||||
options: [.shared, .invited, .joined],
|
||||
getName: (visibility) => switch (visibility) {
|
||||
.shared => "All events",
|
||||
.invited => "After invite",
|
||||
.joined => "After join",
|
||||
.worldReadable => "Anyone",
|
||||
},
|
||||
onChanged: canSetHistoryVisibility
|
||||
? (visibility) => setState(
|
||||
.historyVisibility,
|
||||
HistoryVisibilityContent(
|
||||
historyVisibility: visibility,
|
||||
),
|
||||
).onError(showError)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
.new(
|
||||
title: "Permissions",
|
||||
icon: Icons.admin_panel_settings,
|
||||
settings: .new([
|
||||
powerLevelSetting(
|
||||
title: "Default Power Level",
|
||||
description: "The power level of users who aren't given one.",
|
||||
icon: Icons.person,
|
||||
value: powerLevels.usersDefault,
|
||||
update: (level) => powerLevels.copyWith(usersDefault: level),
|
||||
),
|
||||
header("Users", "Power levels of specific users.", Icons.group),
|
||||
...users.map((entry) {
|
||||
final member = memberOf(entry.key);
|
||||
// Others can only be changed if they're below you, but your own level can be lowered
|
||||
final canChange =
|
||||
canSetPowerLevels &&
|
||||
(entry.key == userId || entry.value < ownLevel);
|
||||
|
||||
return Setting(
|
||||
title: member?.displayName ?? entry.key,
|
||||
description: entry.key,
|
||||
icon: Icons.person,
|
||||
builder: (title, description, _) => PowerLevelTile(
|
||||
leading: AvatarOrHash(
|
||||
member?.avatarUrl,
|
||||
title,
|
||||
dimension: 40,
|
||||
),
|
||||
title: title,
|
||||
description: description,
|
||||
value: entry.value,
|
||||
ownLevel: ownLevel,
|
||||
onChanged: canChange
|
||||
? (level) => setPowerLevels(
|
||||
powerLevels.copyWith(
|
||||
users: powerLevels.users.add(entry.key, level),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
onRemoved: canChange
|
||||
? () => setPowerLevels(
|
||||
powerLevels.copyWith(
|
||||
users: powerLevels.users.remove(entry.key),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
removeConfirmation: entry.key == userId
|
||||
? "Remove your own power level? You'll have the default of ${powerLevels.usersDefault}, and won't be able to raise it again yourself."
|
||||
: "Remove $title's power level? They'll have the default of ${powerLevels.usersDefault}.",
|
||||
),
|
||||
);
|
||||
}),
|
||||
addSetting(
|
||||
title: "Add Users",
|
||||
description: "Give users their own power level.",
|
||||
sheet: (save) => AddUsersPowerLevelSheet(
|
||||
roomId: roomId,
|
||||
existing: powerLevels.users.keys.toISet().union(
|
||||
roomPowerLevels.creators,
|
||||
),
|
||||
ownLevel: ownLevel,
|
||||
onSave: (users, level) => save(
|
||||
powerLevels.copyWith(
|
||||
users: powerLevels.users.addEntries(
|
||||
users.map((user) => MapEntry(user, level)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
header(
|
||||
"Regular Events",
|
||||
"Power levels needed to send messages and other timeline events.",
|
||||
Icons.chat,
|
||||
),
|
||||
eventSetting(
|
||||
messageType,
|
||||
title: "Send Messages",
|
||||
description: "The power level needed to send messages.",
|
||||
icon: Icons.message,
|
||||
fallback: powerLevels.eventsDefault,
|
||||
),
|
||||
eventSetting(
|
||||
.sticker,
|
||||
title: "Send Stickers",
|
||||
description: "The power level needed to send stickers.",
|
||||
icon: Icons.sticky_note_2,
|
||||
fallback: powerLevels.eventsDefault,
|
||||
),
|
||||
eventSetting(
|
||||
.reaction,
|
||||
title: "Send Reactions",
|
||||
description: "The power level needed to react to messages.",
|
||||
icon: Icons.add_reaction,
|
||||
fallback: powerLevels.eventsDefault,
|
||||
),
|
||||
powerLevelSetting(
|
||||
title: "Default for Events",
|
||||
description:
|
||||
"The power level needed to send events without their own.",
|
||||
icon: Icons.more_horiz,
|
||||
value: powerLevels.eventsDefault,
|
||||
update: (level) => powerLevels.copyWith(eventsDefault: level),
|
||||
),
|
||||
header(
|
||||
"State Events",
|
||||
"Power levels needed to manage members and change the room's state.",
|
||||
Icons.settings,
|
||||
),
|
||||
eventSetting(
|
||||
.pinnedEvents,
|
||||
title: "Pin Messages",
|
||||
description: "The power level needed to pin messages.",
|
||||
icon: Icons.push_pin,
|
||||
fallback: powerLevels.stateDefault,
|
||||
),
|
||||
powerLevelSetting(
|
||||
title: "Invite Users",
|
||||
description: "The power level needed to invite users.",
|
||||
icon: Icons.person_add,
|
||||
value: powerLevels.invite,
|
||||
update: (level) => powerLevels.copyWith(invite: level),
|
||||
),
|
||||
powerLevelSetting(
|
||||
title: "Kick Users",
|
||||
description: "The power level needed to kick users.",
|
||||
icon: Icons.sports_martial_arts,
|
||||
value: powerLevels.kick,
|
||||
update: (level) => powerLevels.copyWith(kick: level),
|
||||
),
|
||||
powerLevelSetting(
|
||||
title: "Ban Users",
|
||||
description: "The power level needed to ban and unban users.",
|
||||
icon: Icons.gavel,
|
||||
value: powerLevels.ban,
|
||||
update: (level) => powerLevels.copyWith(ban: level),
|
||||
),
|
||||
powerLevelSetting(
|
||||
title: "Delete Others' Messages",
|
||||
description: "The power level needed to remove others' messages.",
|
||||
icon: Icons.delete,
|
||||
value: powerLevels.redact,
|
||||
update: (level) => powerLevels.copyWith(redact: level),
|
||||
),
|
||||
powerLevelSetting(
|
||||
title: "Default for State Events",
|
||||
description: "The power level needed to send state events without their own.",
|
||||
icon: Icons.tune,
|
||||
value: powerLevels.stateDefault,
|
||||
update: (level) => powerLevels.copyWith(stateDefault: level),
|
||||
),
|
||||
header(
|
||||
"Other Events",
|
||||
"Power levels for specific event types.",
|
||||
Icons.code,
|
||||
),
|
||||
...otherEvents.map(
|
||||
(entry) => Setting(
|
||||
title: entry.key,
|
||||
description: "The power level needed to send this event.",
|
||||
icon: Icons.code,
|
||||
builder: (title, _, icon) => PowerLevelTile(
|
||||
leading: Icon(icon),
|
||||
title: title,
|
||||
description: null,
|
||||
value: entry.value,
|
||||
ownLevel: ownLevel,
|
||||
onChanged: canSetPowerLevels
|
||||
? (level) => setPowerLevels(
|
||||
powerLevels.copyWith(
|
||||
events: powerLevels.events.add(entry.key, level),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
onRemoved: canSetPowerLevels
|
||||
? () => setPowerLevels(
|
||||
powerLevels.copyWith(
|
||||
events: powerLevels.events.remove(entry.key),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
addSetting(
|
||||
title: "Add Event",
|
||||
description: "Give an event type its own power level.",
|
||||
sheet: (save) => PowerLevelEntrySheet(
|
||||
title: "Add Event",
|
||||
keyLabel: "Event type",
|
||||
keyHint: "m.room.name",
|
||||
ownLevel: ownLevel,
|
||||
onSave: (type, level) => save(
|
||||
powerLevels.copyWith(
|
||||
events: powerLevels.events.add(type, level),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
static final provider = AsyncNotifierProvider.family
|
||||
.autoDispose<
|
||||
RoomSettingsSectionsController,
|
||||
IMap<String, IList<SettingsCategory>>,
|
||||
String
|
||||
>(RoomSettingsSectionsController.new);
|
||||
}
|
||||
34
lib/controllers/room_typing.dart
Normal file
34
lib/controllers/room_typing.dart
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/client_state.dart";
|
||||
import "package:nexus/controllers/settings.dart";
|
||||
import "package:nexus/controllers/typing.dart";
|
||||
|
||||
/// The other users typing in a room, or none if typing indicators are hidden
|
||||
/// in settings.
|
||||
class RoomTypingController(final String roomId)
|
||||
extends Notifier<IList<String>> {
|
||||
@override
|
||||
IList<String> build() {
|
||||
final shouldShow = ref.watch(
|
||||
SettingsController.provider.select(
|
||||
(settings) => settings.value?.typingIndicators != .neither,
|
||||
),
|
||||
);
|
||||
if (!shouldShow) return const .empty();
|
||||
|
||||
final userId = ref.watch(
|
||||
ClientStateController.provider.select((state) => state?.userId),
|
||||
);
|
||||
|
||||
return ref
|
||||
.watch(TypingController.provider.select((typing) => typing[roomId]))
|
||||
?.remove(userId ?? "") ??
|
||||
const .empty();
|
||||
}
|
||||
|
||||
static final provider = NotifierProvider.family
|
||||
.autoDispose<RoomTypingController, IList<String>, String>(
|
||||
RoomTypingController.new,
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
import "dart:async";
|
||||
|
||||
import "package:collection/collection.dart";
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/models/event.dart";
|
||||
import "package:nexus/models/read_receipt.dart";
|
||||
import "package:nexus/models/room.dart";
|
||||
|
||||
class RoomsController extends Notifier<IMap<String, Room>> {
|
||||
|
|
@ -33,9 +36,49 @@ class RoomsController extends Notifier<IMap<String, Room>> {
|
|||
.new(),
|
||||
);
|
||||
|
||||
static IMap<String, IList<ReadReceipt>> mergeReceipts(
|
||||
IMap<String, IList<ReadReceipt>> existing,
|
||||
IMap<String, IList<ReadReceipt>> incoming,
|
||||
) => incoming.isEmpty
|
||||
? existing
|
||||
: existing.values.flattened
|
||||
.followedBy(incoming.values.flattened)
|
||||
.groupListsBy((receipt) => (receipt.userId, receipt.thread))
|
||||
.values
|
||||
.map(
|
||||
(receipts) => receipts.reduce(
|
||||
(newest, receipt) => newest.timestamp.isAfter(receipt.timestamp)
|
||||
? newest
|
||||
: receipt,
|
||||
),
|
||||
)
|
||||
.groupListsBy((receipt) => receipt.eventId)
|
||||
.map((eventId, receipts) => MapEntry(eventId, receipts.toIList()))
|
||||
.toIMap();
|
||||
|
||||
/// Adds events to their rooms, without changing the rooms' timelines.
|
||||
void storeEvents(Iterable<Event> events) => update(
|
||||
.new(
|
||||
events
|
||||
.groupListsBy((event) => event.roomId)
|
||||
.map(
|
||||
(roomId, roomEvents) => .new(
|
||||
roomId,
|
||||
Room(
|
||||
// ignore: unused_result - I am using it you donkey
|
||||
events: .fromIterable(
|
||||
roomEvents,
|
||||
keyMapper: (event) => event.rowId,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
void update(
|
||||
IMap<String, Room> rooms, [
|
||||
ISet<String> leftRooms = const ISet.empty(),
|
||||
ISet<String> leftRooms = const .empty(),
|
||||
]) {
|
||||
final merged = rooms.entries.fold(state, (acc, entry) {
|
||||
final roomId = entry.key;
|
||||
|
|
@ -76,21 +119,42 @@ class RoomsController extends Notifier<IMap<String, Room>> {
|
|||
timeline: incoming.reset
|
||||
? incoming.timeline
|
||||
: existing.timeline.addAll(incoming.timeline),
|
||||
receipts: incoming.receipts.entries.fold(
|
||||
existing.receipts,
|
||||
(receiptAcc, event) => receiptAcc.add(
|
||||
event.key,
|
||||
(receiptAcc[event.key] ?? .new()).addAll(event.value),
|
||||
),
|
||||
),
|
||||
receipts: mergeReceipts(existing.receipts, incoming.receipts),
|
||||
) ??
|
||||
incoming,
|
||||
incoming.copyWith(
|
||||
receipts: mergeReceipts(const .empty(), incoming.receipts),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
state = leftRooms.fold(merged, (acc, roomId) => acc.remove(roomId));
|
||||
}
|
||||
|
||||
/// Waits for a sent state event to become the room's state, which happens
|
||||
/// once it comes down sync. Gives up after a while, e.g. if another event
|
||||
/// replaced it first.
|
||||
Future<void> awaitState(
|
||||
String roomId,
|
||||
String type,
|
||||
String eventId, {
|
||||
String stateKey = "",
|
||||
}) {
|
||||
bool arrived(IMap<String, Room> rooms) =>
|
||||
switch (rooms[roomId]?.state[type]?[stateKey]) {
|
||||
final rowId? => rooms[roomId]?.events[rowId]?.eventId == eventId,
|
||||
null => false,
|
||||
};
|
||||
if (arrived(state)) return .value();
|
||||
|
||||
final completer = Completer<void>();
|
||||
final removeListener = listenSelf((_, rooms) {
|
||||
if (arrived(rooms) && !completer.isCompleted) completer.complete();
|
||||
});
|
||||
return completer.future
|
||||
.timeout(const .new(seconds: 10), onTimeout: () {})
|
||||
.whenComplete(removeListener);
|
||||
}
|
||||
|
||||
static final provider = NotifierProvider<RoomsController, IMap<String, Room>>(
|
||||
RoomsController.new,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@ import "package:nexus/controllers/unified_push_distributors.dart";
|
|||
import "package:nexus/helpers/extensions/distributor_name.dart";
|
||||
import "package:nexus/models/account_data.dart";
|
||||
import "package:nexus/models/room_order.dart";
|
||||
import "package:nexus/models/settings.dart";
|
||||
import "package:nexus/models/settings_category.dart";
|
||||
import "package:nexus/models/show_and_send.dart";
|
||||
import "package:nexus/main.dart";
|
||||
import "package:nexus/widgets/settings/dialog_list_tile.dart";
|
||||
import "package:nexus/widgets/reset_encryption_sheet.dart";
|
||||
|
|
@ -31,6 +33,30 @@ class SettingsSectionsController
|
|||
Future<IMap<String, IList<SettingsCategory>>> build() async {
|
||||
final settings = await ref.watch(SettingsController.provider.future);
|
||||
|
||||
/// A tile for a [ShowAndSend] setting, saving the settings from [update].
|
||||
Widget showAndSendTile(
|
||||
String title,
|
||||
String description,
|
||||
IconData icon, {
|
||||
required ShowAndSend value,
|
||||
required Settings Function(ShowAndSend value) update,
|
||||
}) => DialogListTile<ShowAndSend>(
|
||||
icon: Icon(icon),
|
||||
title: title,
|
||||
subtitle: Text(description),
|
||||
value: value,
|
||||
options: ShowAndSend.values,
|
||||
getName: (option) => switch (option) {
|
||||
.showAndSend => "Show and send",
|
||||
.show => "Only show",
|
||||
.neither => "Neither",
|
||||
},
|
||||
onChanged: (value) => ref
|
||||
.read(SettingsController.provider.notifier)
|
||||
.set(update(value))
|
||||
.onError(showError),
|
||||
);
|
||||
|
||||
return .new({
|
||||
"General": .new([
|
||||
.new(
|
||||
|
|
@ -151,7 +177,7 @@ class SettingsSectionsController
|
|||
),
|
||||
.new(
|
||||
title: "Linux Mobile Mode",
|
||||
description: "Enables some fixes for Linux mobile, e.g. disabling dragging appbar for moving window.",
|
||||
description: "Enables some fixes for Linux mobile, e.g. disabling dragging appbar for moving window, and swiping drawers open.",
|
||||
icon: Icons.construction,
|
||||
builder: (title, description, icon) => SwitchListTile(
|
||||
title: Text(title),
|
||||
|
|
@ -166,6 +192,51 @@ class SettingsSectionsController
|
|||
: null,
|
||||
),
|
||||
),
|
||||
.new(
|
||||
title: "Hide Membership Events",
|
||||
description: "Hides membership events (invites, joins, leaves & bans) from appearing in conversations. Knocks are still shown.",
|
||||
icon: Icons.person_off,
|
||||
builder: (title, description, icon) => SwitchListTile(
|
||||
title: Text(title),
|
||||
subtitle: Text(description),
|
||||
secondary: Icon(icon),
|
||||
value: settings.hideMembershipEvents,
|
||||
onChanged: (value) => ref
|
||||
.read(SettingsController.provider.notifier)
|
||||
.set(settings.copyWith(hideMembershipEvents: value))
|
||||
.onError(showError),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
.new(
|
||||
title: "Privacy",
|
||||
icon: Icons.privacy_tip,
|
||||
settings: .new([
|
||||
.new(
|
||||
title: "Typing Indicators",
|
||||
description: "Show when others are typing, and let others see when you're typing.",
|
||||
icon: Icons.keyboard,
|
||||
builder: (title, description, icon) => showAndSendTile(
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
value: settings.typingIndicators,
|
||||
update: (value) => settings.copyWith(typingIndicators: value),
|
||||
),
|
||||
),
|
||||
.new(
|
||||
title: "Read Receipts",
|
||||
description: "Show how far others have read, and let others see what you've read. When not sent, read receipts are still sent privately, so your other devices know what you've read.",
|
||||
icon: Icons.done_all,
|
||||
builder: (title, description, icon) => showAndSendTile(
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
value: settings.readReceipts,
|
||||
update: (value) => settings.copyWith(readReceipts: value),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
|
|
|
|||
15
lib/controllers/typing.dart
Normal file
15
lib/controllers/typing.dart
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/models/typing.dart";
|
||||
|
||||
class TypingController extends Notifier<IMap<String, IList<String>>> {
|
||||
@override
|
||||
IMap<String, IList<String>> build() => .new();
|
||||
|
||||
void set(Typing typing) => state = state.add(typing.roomId, typing.userIds);
|
||||
|
||||
static final provider =
|
||||
NotifierProvider<TypingController, IMap<String, IList<String>>>(
|
||||
TypingController.new,
|
||||
);
|
||||
}
|
||||
|
|
@ -88,7 +88,8 @@ class UnifiedPushController extends AsyncNotifier<String?> {
|
|||
|
||||
if (notification == null ||
|
||||
(!isInBackground &&
|
||||
await windowManager.isFocused().onError((_, _) => true) &&
|
||||
(!Platform.isAndroid &&
|
||||
(WindowManager.instance.getCurrent()?.isFocused ?? true)) &&
|
||||
ref.read(SelectedRoomController.provider) ==
|
||||
notification.roomId)) {
|
||||
if (isInBackground) exit(0);
|
||||
|
|
|
|||
|
|
@ -8,10 +8,12 @@ extension BetterWhen<T> on AsyncValue<T> {
|
|||
required Widget Function(T value) data,
|
||||
Widget Function() loading = Loading.new,
|
||||
bool skipLoadingOnRefresh = false,
|
||||
bool skipLoadingOnReload = false,
|
||||
}) => when(
|
||||
data: data,
|
||||
error: (error, stackTrace) => ErrorDialog(error, stackTrace),
|
||||
loading: loading,
|
||||
skipLoadingOnRefresh: skipLoadingOnRefresh,
|
||||
skipLoadingOnReload: skipLoadingOnReload,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,19 +19,21 @@ extension GomuksOwnedBufferToX on GomuksOwnedBuffer {
|
|||
}
|
||||
|
||||
extension JsonToGomuksBuffer on Map<String, dynamic> {
|
||||
GomuksOwnedBuffer toGomuksOwnedBuffer() {
|
||||
final bytes = utf8.encode(json.encode(this));
|
||||
final buffer = GomuksAllocBuffer(bytes.length);
|
||||
buffer.base.asTypedList(bytes.length).setAll(0, bytes);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
Pointer<GomuksBorrowedBuffer> toGomuksBufferPtr() {
|
||||
final jsonString = json.encode(this);
|
||||
final bytes = utf8.encode(jsonString);
|
||||
final bytes = utf8.encode(json.encode(this));
|
||||
|
||||
final dataPtr = calloc<Uint8>(bytes.length);
|
||||
dataPtr.asTypedList(bytes.length).setAll(0, bytes);
|
||||
|
||||
final ptr = calloc<GomuksBorrowedBuffer>();
|
||||
|
||||
ptr.ref
|
||||
..base = dataPtr
|
||||
..length = bytes.length;
|
||||
|
||||
return ptr;
|
||||
return calloc<GomuksBorrowedBuffer>()
|
||||
..ref.base = dataPtr
|
||||
..ref.length = bytes.length;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
15
lib/helpers/extensions/is_shown_as_message.dart
Normal file
15
lib/helpers/extensions/is_shown_as_message.dart
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import "package:nexus/models/content/encrypted.dart";
|
||||
import "package:nexus/models/content/message.dart";
|
||||
import "package:nexus/models/content/sticker.dart";
|
||||
import "package:nexus/models/event.dart";
|
||||
|
||||
extension IsShownAsMessage on Event {
|
||||
/// Whether this is shown in the timeline as a message, with MessageRenderer.
|
||||
bool get isShownAsMessage =>
|
||||
redactedBy == null &&
|
||||
relationType != "m.replace" &&
|
||||
switch (content) {
|
||||
MessageContent() || EncryptedContent() || StickerContent() => true,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
|
@ -40,4 +40,29 @@ extension JoinRoom on BuildContext {
|
|||
|
||||
return roomId;
|
||||
}
|
||||
|
||||
/// Knocks on a room, success and errors in snackbars.
|
||||
/// Returns the room's ID, or null if knocking failed.
|
||||
Future<String?> knockRoom(
|
||||
JoinRoomRequest request, {
|
||||
required String name,
|
||||
}) async {
|
||||
final container = ProviderScope.containerOf(this);
|
||||
final messenger = ScaffoldMessenger.of(this);
|
||||
|
||||
final roomId = await withProgressSnackbar(
|
||||
"Knocking on $name...",
|
||||
() =>
|
||||
container.read(ClientController.provider.notifier).knockRoom(request),
|
||||
);
|
||||
if (roomId == null) return null;
|
||||
|
||||
messenger.showSnackBar(
|
||||
.new(
|
||||
content: Text("Knocked on $name. Wait for a moderator to let you in."),
|
||||
),
|
||||
);
|
||||
|
||||
return roomId;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,8 @@
|
|||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/helpers/extensions/show_adaptive_popover.dart";
|
||||
import "package:nexus/models/content/membership.dart";
|
||||
import "package:nexus/widgets/user_bottom_sheet.dart";
|
||||
|
||||
extension ShowUserPopover on BuildContext {
|
||||
void showUserPopover(
|
||||
MembershipContent member,
|
||||
String userId, {
|
||||
String? roomId,
|
||||
}) => showAdaptivePopover(
|
||||
(context) => UserBottomSheet(member, userId, roomId: roomId),
|
||||
);
|
||||
void showUserPopover(String userId, {String? roomId}) =>
|
||||
showAdaptivePopover((context) => UserBottomSheet(userId, roomId: roomId));
|
||||
}
|
||||
|
|
|
|||
25
lib/helpers/hooks/saving.dart
Normal file
25
lib/helpers/hooks/saving.dart
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import "dart:async";
|
||||
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
|
||||
final class Saving({
|
||||
required final bool active,
|
||||
required final Future<void> Function(FutureOr<void> Function() action) run,
|
||||
}) {
|
||||
factory use() {
|
||||
final context = useContext();
|
||||
final active = useState(false);
|
||||
|
||||
return .new(
|
||||
active: active.value,
|
||||
run: (action) async {
|
||||
active.value = true;
|
||||
try {
|
||||
await action();
|
||||
} finally {
|
||||
if (context.mounted) active.value = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -83,14 +83,10 @@ void main(List<String> args) async {
|
|||
exit(0);
|
||||
} else {
|
||||
if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) {
|
||||
await windowManager.ensureInitialized();
|
||||
await windowManager.waitUntilReadyToShow(
|
||||
WindowOptions(
|
||||
titleBarStyle: TitleBarStyle.hidden,
|
||||
windowButtonVisibility: false,
|
||||
),
|
||||
);
|
||||
await windowManager.setMinimumSize(Size.square(500));
|
||||
WindowManager.instance.getCurrent()
|
||||
?..titleBarStyle = .hidden
|
||||
..isWindowControlButtonsVisible = false
|
||||
..minimumSize = Size.square(300).toNative();
|
||||
}
|
||||
|
||||
runApp(
|
||||
|
|
@ -111,10 +107,21 @@ class const App({super.key}) extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) => DynamicColorBuilder(
|
||||
builder: (lightDynamic, darkDynamic) => Consumer(
|
||||
builder: (context, ref, child) => MaterialApp(
|
||||
navigatorKey: navigatorKey,
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme:
|
||||
builder: (context, ref, child) {
|
||||
// Flutter turns off mobile behaviour like swiping drawers open on desktop platforms, so Linux mobile pretends to be Android
|
||||
ThemeData forPlatform(ThemeData theme) =>
|
||||
ref.watch(
|
||||
SettingsController.provider.select(
|
||||
(settings) => settings.value?.linuxMobileMode ?? false,
|
||||
),
|
||||
)
|
||||
? theme.copyWith(platform: .android)
|
||||
: theme;
|
||||
|
||||
return MaterialApp(
|
||||
navigatorKey: navigatorKey,
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: forPlatform(
|
||||
(ref
|
||||
.watch(SettingsController.provider)
|
||||
.maybeWhen(
|
||||
|
|
@ -124,7 +131,8 @@ class const App({super.key}) extends StatelessWidget {
|
|||
) ??
|
||||
ThemeData.light().colorScheme)
|
||||
.theme,
|
||||
darkTheme:
|
||||
),
|
||||
darkTheme: forPlatform(
|
||||
(ref
|
||||
.watch(SettingsController.provider)
|
||||
.maybeWhen(
|
||||
|
|
@ -134,14 +142,16 @@ class const App({super.key}) extends StatelessWidget {
|
|||
) ??
|
||||
ThemeData.dark().colorScheme)
|
||||
.theme,
|
||||
themeMode: ref
|
||||
.watch(SettingsController.provider)
|
||||
.maybeWhen(
|
||||
data: (settings) => settings.theme,
|
||||
orElse: () => ThemeMode.system,
|
||||
),
|
||||
home: child,
|
||||
),
|
||||
),
|
||||
themeMode: ref
|
||||
.watch(SettingsController.provider)
|
||||
.maybeWhen(
|
||||
data: (settings) => settings.theme,
|
||||
orElse: () => ThemeMode.system,
|
||||
),
|
||||
home: child,
|
||||
);
|
||||
},
|
||||
child: DefaultTextHeightBehavior(
|
||||
textHeightBehavior: .new(leadingDistribution: .even),
|
||||
child: Scaffold(
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ class const AccountData({
|
|||
const InvitePermissionConfig(),
|
||||
|
||||
@JsonKey(name: AccountData.directKey)
|
||||
final IMap<String, IList<String>> directMessages = const IMap.empty(),
|
||||
final IMap<String, IList<String>> directMessages = const .empty(),
|
||||
|
||||
@JsonKey(name: AccountData.recentEmojiKey)
|
||||
final RecentEmojiData recentEmoji = const RecentEmojiData(),
|
||||
|
|
@ -30,7 +30,7 @@ class const AccountData({
|
|||
@Freezed(toJson: false, fromJson: false)
|
||||
@JsonSerializable()
|
||||
class const RecentEmojiData({
|
||||
final IList<RecentEmoji> recentEmoji = const IList.empty(),
|
||||
final IList<RecentEmoji> recentEmoji = const .empty(),
|
||||
}) with _$RecentEmojiData {
|
||||
Map<String, Object?> toJson() => _$RecentEmojiDataToJson(this);
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ sealed class CanonicalAliasContent extends Content
|
|||
with _$CanonicalAliasContent {
|
||||
CanonicalAliasContent._();
|
||||
factory CanonicalAliasContent({
|
||||
String? alias,
|
||||
@JsonKey(includeIfNull: false) String? alias,
|
||||
@Default(ISet.empty()) ISet<String> altAliases,
|
||||
}) = _CanonicalAliasContent;
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ sealed class JoinRulesContent extends Content with _$JoinRulesContent {
|
|||
JoinRulesContent._();
|
||||
factory JoinRulesContent({
|
||||
required JoinRule joinRule,
|
||||
@Default(IList.empty()) IList<AllowCondition> allow,
|
||||
@Default(ISet.empty()) ISet<AllowCondition> allow,
|
||||
}) = _JoinRulesContent;
|
||||
|
||||
factory JoinRulesContent.fromJson(Map<String, Object?> json) =>
|
||||
|
|
@ -21,6 +21,8 @@ sealed class JoinRulesContent extends Content with _$JoinRulesContent {
|
|||
@JsonSerializable()
|
||||
class const AllowCondition({
|
||||
final String? roomId,
|
||||
|
||||
@JsonKey(unknownEnumValue: AllowConditionType.unknown)
|
||||
required final AllowConditionType type,
|
||||
}) with _$AllowCondition {
|
||||
Map<String, Object?> toJson() => _$AllowConditionToJson(this);
|
||||
|
|
@ -32,4 +34,5 @@ class const AllowCondition({
|
|||
enum AllowConditionType {
|
||||
@JsonValue("m.room_membership")
|
||||
membership,
|
||||
unknown,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ sealed class PowerLevelsContent extends Content with _$PowerLevelsContent {
|
|||
factory PowerLevelsContent({
|
||||
@Default(IMap.empty()) IMap<String, int> events,
|
||||
@Default(IMap.empty()) IMap<String, int> users,
|
||||
Notifications? notifications,
|
||||
@JsonKey(includeIfNull: false) Notifications? notifications,
|
||||
@Default(50) int ban,
|
||||
@Default(0) int eventsDefault,
|
||||
@Default(0) int invite,
|
||||
|
|
@ -28,7 +28,7 @@ sealed class PowerLevelsContent extends Content with _$PowerLevelsContent {
|
|||
@JsonSerializable()
|
||||
class const Notifications({
|
||||
final int room = 50,
|
||||
final IMap<String, int> other = const IMap.empty(),
|
||||
final IMap<String, int> other = const .empty(),
|
||||
}) with _$Notifications {
|
||||
Map<String, Object?> toJson() => _$NotificationsToJson(this);
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ class const Event({
|
|||
required final String sender,
|
||||
@JsonKey(readValue: Event.typeJsonFromJson) required final String type,
|
||||
@EpochDateTimeConverter() required final DateTime timestamp,
|
||||
final IMap<String, dynamic> unsigned = const IMap.empty(),
|
||||
final IMap<String, dynamic> unsigned = const .empty(),
|
||||
final LocalContent? localContent,
|
||||
final String? transactionId,
|
||||
final String? redactedBy,
|
||||
|
|
@ -27,7 +27,7 @@ class const Event({
|
|||
final String? replyTo,
|
||||
final String? decryptionError,
|
||||
final String? sendError,
|
||||
final IMap<String, int> reactions = const IMap.empty(),
|
||||
final IMap<String, int> reactions = const .empty(),
|
||||
@JsonKey(name: "last_edit_rowid") final int lastEditRowId = 0,
|
||||
final UnreadType? unreadType,
|
||||
final Profile? pmp,
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ class const EventContext({
|
|||
required final IList<Event> after,
|
||||
required final String start,
|
||||
required final String end,
|
||||
final IList<Event> relatedEvents = const IList.empty(),
|
||||
final IList<Event> relatedEvents = const .empty(),
|
||||
}) with _$EventContext {
|
||||
Map<String, Object?> toJson() => _$EventContextToJson(this);
|
||||
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ part "image.g.dart";
|
|||
@Freezed(toJson: false, fromJson: false)
|
||||
@JsonSerializable()
|
||||
class const ImageInfo({
|
||||
@JsonKey(name: "h") final double? height,
|
||||
@JsonKey(name: "w") final double? width,
|
||||
@JsonKey(name: "h") final int? height,
|
||||
@JsonKey(name: "w") final int? width,
|
||||
@JsonKey(name: "mimetype") final String? mimeType,
|
||||
@JsonKey(name: "xyz.amorgan.blurhash") final String? blurHash,
|
||||
final int? size,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:freezed_annotation/freezed_annotation.dart";
|
||||
import "package:nexus/models/event.dart";
|
||||
import "package:nexus/models/read_receipt.dart";
|
||||
|
||||
part "paginate.freezed.dart";
|
||||
part "paginate.g.dart";
|
||||
|
|
@ -10,6 +11,9 @@ part "paginate.g.dart";
|
|||
class const Paginate({
|
||||
required final IList<Event> events,
|
||||
required final IList<Event> relatedEvents,
|
||||
|
||||
/// Read receipts for the events, by event ID.
|
||||
final IMap<String, IList<ReadReceipt>> receipts = const .empty(),
|
||||
required final bool hasMore,
|
||||
}) with _$Paginate {
|
||||
Map<String, Object?> toJson() => _$PaginateToJson(this);
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ part "paginate_manual.g.dart";
|
|||
@Freezed(toJson: false, fromJson: false)
|
||||
@JsonSerializable()
|
||||
class const PaginateManual({
|
||||
required final IList<Event> events,
|
||||
final IList<Event> relatedEvents = const IList.empty(),
|
||||
final IList<Event> events = const .empty(),
|
||||
final IList<Event> relatedEvents = const .empty(),
|
||||
required final String? nextBatch,
|
||||
}) with _$PaginateManual {
|
||||
Map<String, Object?> toJson() => _$PaginateManualToJson(this);
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ class const Profile({
|
|||
final String? timezone,
|
||||
|
||||
@JsonKey(readValue: Profile.readPronouns, name: "m.pronouns")
|
||||
final IList<Pronoun> pronouns = const IList.empty(),
|
||||
final IList<Pronoun> pronouns = const .empty(),
|
||||
}) with _$Profile {
|
||||
Map<String, Object?> toJson() => _$ProfileToJson(this);
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ class const ReadReceipt({
|
|||
required final String eventId,
|
||||
@EpochDateTimeConverter() required final DateTime timestamp,
|
||||
}) with _$ReadReceipt {
|
||||
String get thread => switch (threadId) {
|
||||
final threadId? when threadId.isNotEmpty => threadId,
|
||||
_ => "main",
|
||||
};
|
||||
|
||||
Map<String, Object?> toJson() => _$ReadReceiptToJson(this);
|
||||
|
||||
factory ReadReceipt.fromJson(Map<String, Object?> json) =>
|
||||
|
|
|
|||
|
|
@ -13,10 +13,10 @@ class const CreateRoomRequest({
|
|||
final String? roomAliasName,
|
||||
final String? name,
|
||||
final String? topic,
|
||||
final IList<String> invite = const IList.empty(),
|
||||
final IList<String> invite = const .empty(),
|
||||
@JsonKey(name: "uk.timedout.msc4491.invite_reason")
|
||||
final String? inviteReason,
|
||||
final IList<InitialStateEvent> initialState = const IList.empty(),
|
||||
final IList<InitialStateEvent> initialState = const .empty(),
|
||||
final RoomPreset? preset,
|
||||
final bool isDirect = false,
|
||||
final IMap<String, dynamic>? creationContent,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ part "join_room.g.dart";
|
|||
@JsonSerializable()
|
||||
class const JoinRoomRequest({
|
||||
required final String roomIdOrAlias,
|
||||
final IList<String> via = const IList.empty(),
|
||||
final IList<String> via = const .empty(),
|
||||
final String? reason,
|
||||
|
||||
/// Set when accepting an invite, so gomuks can mark DMs as such.
|
||||
final bool fromInvite = false,
|
||||
|
|
|
|||
21
lib/models/requests/search.dart
Normal file
21
lib/models/requests/search.dart
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:freezed_annotation/freezed_annotation.dart";
|
||||
|
||||
part "search.freezed.dart";
|
||||
part "search.g.dart";
|
||||
|
||||
@Freezed(toJson: false, fromJson: false)
|
||||
@JsonSerializable(includeIfNull: false)
|
||||
class const SearchRequest({
|
||||
required final String searchTerm,
|
||||
final IList<String> roomIds = const .empty(),
|
||||
final int limit = 20,
|
||||
final bool sortByTime = true,
|
||||
|
||||
final String? nextBatch,
|
||||
}) with _$SearchRequest {
|
||||
Map<String, Object?> toJson() => _$SearchRequestToJson(this);
|
||||
|
||||
factory SearchRequest.fromJson(Map<String, Object?> json) =>
|
||||
_$SearchRequestFromJson(json);
|
||||
}
|
||||
|
|
@ -25,7 +25,7 @@ class const SendMessageRequest({
|
|||
@JsonSerializable()
|
||||
class const Mentions({
|
||||
final bool room = false,
|
||||
final IList<String> userIds = const IList.empty(),
|
||||
final IList<String> userIds = const .empty(),
|
||||
}) with _$Mentions {
|
||||
Map<String, Object?> toJson() => _$MentionsToJson(this);
|
||||
|
||||
|
|
|
|||
|
|
@ -14,15 +14,15 @@ class const Room({
|
|||
|
||||
/// [timeline] is an IMap of timelineRowId to eventRowId
|
||||
@JsonKey(fromJson: Room.timelineTupleJsonToIMap)
|
||||
final IMap<int, int?> timeline = const IMap.empty(),
|
||||
final IMap<int, int?> timeline = const .empty(),
|
||||
|
||||
/// [clientSticky] is an ISet of eventRowId
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
final ISet<int> clientSticky = const ISet.empty(),
|
||||
final ISet<int> clientSticky = const .empty(),
|
||||
|
||||
/// [events] is an IMap of eventRowId to event
|
||||
@JsonKey(fromJson: Room.eventsJsonToIMap)
|
||||
final IMap<int, Event> events = const IMap.empty(),
|
||||
final IMap<int, Event> events = const .empty(),
|
||||
|
||||
final bool reset = false,
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
|
|
@ -34,9 +34,9 @@ class const Room({
|
|||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
final bool hasFetchedMembers = false,
|
||||
|
||||
final IMap<String, IMap<String, int>> state = const IMap.empty(),
|
||||
final IMap<String, IMap<String, int>> state = const .empty(),
|
||||
|
||||
final IMap<String, IList<ReadReceipt>> receipts = const IMap.empty(),
|
||||
final IMap<String, IList<ReadReceipt>> receipts = const .empty(),
|
||||
final bool dismissNotifications = false,
|
||||
final bool hasMore = true,
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import "package:freezed_annotation/freezed_annotation.dart";
|
||||
import "package:nexus/models/content/encryption.dart";
|
||||
import "package:nexus/models/epoch_date_time_converter.dart";
|
||||
import "package:nexus/models/lazy_load_summary.dart";
|
||||
|
||||
|
|
@ -15,6 +16,8 @@ class const RoomMetadata({
|
|||
final String? topic,
|
||||
final String? canonicalAlias,
|
||||
final LazyLoadSummary? lazyLoadSummary,
|
||||
|
||||
final EncryptionContent? encryptionEvent,
|
||||
required final bool hasMemberList,
|
||||
@JsonKey(name: "preview_event_rowid") required final int previewEventRowID,
|
||||
@EpochDateTimeConverter() required final DateTime sortingTimestamp,
|
||||
|
|
|
|||
11
lib/models/room_power_levels.dart
Normal file
11
lib/models/room_power_levels.dart
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:nexus/models/content/power_levels.dart";
|
||||
|
||||
class const RoomPowerLevels({
|
||||
required final PowerLevelsContent content,
|
||||
final ISet<String> creators = const .empty(),
|
||||
}) {
|
||||
double levelOf(String userId) => creators.contains(userId)
|
||||
? double.infinity
|
||||
: (content.users[userId] ?? content.usersDefault).toDouble();
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:freezed_annotation/freezed_annotation.dart";
|
||||
import "package:nexus/models/content/create.dart";
|
||||
import "package:nexus/models/content/membership.dart";
|
||||
|
|
@ -14,6 +15,9 @@ class const RoomSummary({
|
|||
required final String roomId,
|
||||
@JsonKey(name: "num_joined_members") final int? joinedMembers,
|
||||
final JoinRule? joinRule,
|
||||
|
||||
/// Rooms whose members may join without an invite, for restricted rooms.
|
||||
final IList<String> allowedRoomIds = const .empty(),
|
||||
final String? name,
|
||||
final Uri? avatarUrl,
|
||||
final String? canonicalAlias,
|
||||
|
|
@ -41,6 +45,14 @@ class const RoomSummary({
|
|||
/// Whether this is an invite to a direct message.
|
||||
@JsonKey(includeFromJson: false) final bool isDirect = false,
|
||||
}) with _$RoomSummary {
|
||||
/// Whether the user has to knock rather than join, given the rooms they've
|
||||
/// joined. Restricted knock rooms can be joined directly from an allowed room.
|
||||
bool mustKnock(ISet<String> joinedRooms) => switch (joinRule) {
|
||||
.knock => true,
|
||||
.knockRestricted => !allowedRoomIds.any(joinedRooms.contains),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// A summary of a room the user is in, from its synced metadata.
|
||||
factory RoomSummary.fromMetadata(RoomMetadata metadata) => .new(
|
||||
roomId: metadata.id,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import "package:material_ui/material_ui.dart";
|
||||
import "package:freezed_annotation/freezed_annotation.dart";
|
||||
import "package:nexus/models/room_order.dart";
|
||||
import "package:nexus/models/show_and_send.dart";
|
||||
|
||||
part "settings.freezed.dart";
|
||||
part "settings.g.dart";
|
||||
|
|
@ -14,7 +15,13 @@ class const Settings({
|
|||
final bool showAllInHome = false,
|
||||
final bool showMinimizeButton = false,
|
||||
final bool swipeToReply = false,
|
||||
final bool hideMembershipEvents = false,
|
||||
final RoomOrder roomOrder = .unreadFirst,
|
||||
final ShowAndSend typingIndicators = .showAndSend,
|
||||
|
||||
/// When not sending, read receipts are still sent privately, so that the
|
||||
/// user's other devices know what has been read.
|
||||
final ShowAndSend readReceipts = .showAndSend,
|
||||
}) with _$Settings {
|
||||
Map<String, Object?> toJson() => _$SettingsToJson(this);
|
||||
|
||||
|
|
|
|||
2
lib/models/show_and_send.dart
Normal file
2
lib/models/show_and_send.dart
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/// Whether to show something from others, and send it to them, e.g. typing indicators or read receipts.
|
||||
enum ShowAndSend { showAndSend, show, neither }
|
||||
|
|
@ -11,10 +11,10 @@ part "sync_data.g.dart";
|
|||
@JsonSerializable(createToJson: false)
|
||||
class const SyncData({
|
||||
final bool clearState = false,
|
||||
final IMap<String, IMap<String, dynamic>> accountData = const IMap.empty(),
|
||||
final IMap<String, Room> rooms = const IMap.empty(),
|
||||
final ISet<String> leftRooms = const ISet.empty(),
|
||||
final IList<InvitedRoom> invitedRooms = const IList.empty(),
|
||||
final IMap<String, IMap<String, dynamic>> accountData = const .empty(),
|
||||
final IMap<String, Room> rooms = const .empty(),
|
||||
final ISet<String> leftRooms = const .empty(),
|
||||
final IList<InvitedRoom> invitedRooms = const .empty(),
|
||||
final IMap<String, IList<SpaceEdge>>? spaceEdges,
|
||||
final IList<String>? topLevelSpaces,
|
||||
}) with _$SyncData {
|
||||
|
|
|
|||
14
lib/models/typing.dart
Normal file
14
lib/models/typing.dart
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:freezed_annotation/freezed_annotation.dart";
|
||||
|
||||
part "typing.freezed.dart";
|
||||
part "typing.g.dart";
|
||||
|
||||
@Freezed(toJson: false, fromJson: false)
|
||||
@JsonSerializable(createToJson: false)
|
||||
class const Typing({
|
||||
required final String roomId,
|
||||
final IList<String> userIds = const .empty(),
|
||||
}) with _$Typing {
|
||||
factory Typing.fromJson(Map<String, Object?> json) => _$TypingFromJson(json);
|
||||
}
|
||||
|
|
@ -23,23 +23,26 @@ final class const Appbar({
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
Future<void> maximize() async {
|
||||
final isMaximized = await windowManager.isMaximized();
|
||||
final isDesktop = !(Platform.isAndroid || Platform.isIOS);
|
||||
|
||||
if (isMaximized) {
|
||||
return windowManager.unmaximize();
|
||||
void maximize() {
|
||||
final window = WindowManager.instance.getCurrent();
|
||||
if (window == null) return;
|
||||
|
||||
if (window.isMaximized) {
|
||||
return window.unmaximize();
|
||||
}
|
||||
|
||||
return windowManager.maximize();
|
||||
return window.maximize();
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onPanStart: ref
|
||||
.watch(SettingsController.provider)
|
||||
.whenOrNull(
|
||||
data: (settings) => settings.linuxMobileMode
|
||||
data: (settings) => !isDesktop || settings.linuxMobileMode
|
||||
? null
|
||||
: (_) => windowManager.startDragging(),
|
||||
: (_) => WindowManager.instance.getCurrent()?.startDragging(),
|
||||
),
|
||||
child: AppBar(
|
||||
leading: leading == null ? null : InkWell(onTap: onTap, child: leading),
|
||||
|
|
@ -50,10 +53,12 @@ final class const Appbar({
|
|||
onTap: onTap,
|
||||
child: IgnorePointer(child: title),
|
||||
),
|
||||
flexibleSpace: GestureDetector(onDoubleTap: maximize),
|
||||
flexibleSpace: GestureDetector(
|
||||
onDoubleTap: isDesktop ? maximize : null,
|
||||
),
|
||||
actions: [
|
||||
...actions,
|
||||
if (windowControls && !(Platform.isAndroid || Platform.isIOS)) ...[
|
||||
if (windowControls && isDesktop) ...[
|
||||
if (ref
|
||||
.watch(SettingsController.provider)
|
||||
.whenOrNull(
|
||||
|
|
@ -62,7 +67,8 @@ final class const Appbar({
|
|||
false)
|
||||
IconButton(
|
||||
tooltip: "Minimize window",
|
||||
onPressed: windowManager.minimize,
|
||||
onPressed: () =>
|
||||
WindowManager.instance.getCurrent()?.minimize(),
|
||||
icon: const Icon(Icons.minimize),
|
||||
),
|
||||
if (!Platform.isLinux)
|
||||
|
|
|
|||
39
lib/widgets/avatar_ring.dart
Normal file
39
lib/widgets/avatar_ring.dart
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import "package:material_ui/material_ui.dart";
|
||||
|
||||
class const AvatarRing({
|
||||
required final String tooltip,
|
||||
required final VoidCallback onTap,
|
||||
required final Widget child,
|
||||
final double dimension = 24,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
static const ringWidth = 1.5;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Tooltip(
|
||||
message: tooltip,
|
||||
child: Container(
|
||||
padding: .all(ringWidth),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
shape: .circle,
|
||||
),
|
||||
child: ClipOval(
|
||||
child: SizedBox.square(
|
||||
dimension: dimension,
|
||||
child: Stack(
|
||||
fit: .expand,
|
||||
children: [
|
||||
child,
|
||||
// Over the child, so the ripple shows on top of avatars
|
||||
Material(
|
||||
type: .transparency,
|
||||
child: InkWell(onTap: onTap),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
41
lib/widgets/bouncing_dots.dart
Normal file
41
lib/widgets/bouncing_dots.dart
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import "dart:math";
|
||||
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
|
||||
/// Three dots that bounce one after another, in a loop.
|
||||
class const BouncingDots({required final Color color, super.key})
|
||||
extends HookWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = useAnimationController(
|
||||
duration: const .new(milliseconds: 1200),
|
||||
);
|
||||
useEffect(() {
|
||||
controller.repeat();
|
||||
return null;
|
||||
}, []);
|
||||
final progress = useAnimation(controller);
|
||||
|
||||
return Row(
|
||||
mainAxisSize: .min,
|
||||
spacing: 3,
|
||||
children: [0, 1, 2].map((index) {
|
||||
final phase = (progress - index * 0.15) % 1;
|
||||
final bounce = phase < 0.4 ? sin(phase / 0.4 * pi) : 0.0;
|
||||
|
||||
return Transform.translate(
|
||||
offset: .new(0, -3 * bounce),
|
||||
child: Opacity(
|
||||
opacity: 0.4 + 0.6 * bounce,
|
||||
child: Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(color: color, shape: .circle),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,8 @@ import "package:flutter_hooks/flutter_hooks.dart";
|
|||
import "package:fluttertagger/fluttertagger.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:nexus/controllers/attachment.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/controllers/settings.dart";
|
||||
import "package:nexus/controllers/image_picker.dart";
|
||||
import "package:nexus/helpers/file_dialog_helper.dart";
|
||||
import "package:image_picker/image_picker.dart";
|
||||
|
|
@ -94,6 +96,44 @@ class const Composer(
|
|||
return null;
|
||||
}, [relationType, relatedEvent == null]);
|
||||
|
||||
final sinceTypingSent = useMemoized(Stopwatch.new);
|
||||
|
||||
useEffect(() {
|
||||
final client = ref.read(ClientController.provider.notifier);
|
||||
|
||||
void stopTyping() {
|
||||
if (!sinceTypingSent.isRunning) return;
|
||||
sinceTypingSent
|
||||
..stop()
|
||||
..reset();
|
||||
client.setTyping(roomId, .zero).ignore();
|
||||
}
|
||||
|
||||
void onChanged() {
|
||||
final shouldSend =
|
||||
ref.read(SettingsController.provider).value?.typingIndicators ==
|
||||
.showAndSend;
|
||||
|
||||
if (!shouldSend || controller.text.isEmpty) return stopTyping();
|
||||
|
||||
if (sinceTypingSent.isRunning &&
|
||||
sinceTypingSent.elapsed < const .new(seconds: 5)) {
|
||||
return;
|
||||
}
|
||||
|
||||
sinceTypingSent
|
||||
..reset()
|
||||
..start();
|
||||
client.setTyping(roomId, const .new(seconds: 10)).ignore();
|
||||
}
|
||||
|
||||
controller.addListener(onChanged);
|
||||
return () {
|
||||
controller.removeListener(onChanged);
|
||||
stopTyping();
|
||||
};
|
||||
}, [roomId]);
|
||||
|
||||
final attachment = ref.watch(AttachmentController.provider(roomId));
|
||||
|
||||
Future<void> attach(Future<XFile?> pick) async {
|
||||
|
|
|
|||
113
lib/widgets/composer/typing_indicator.dart
Normal file
113
lib/widgets/composer/typing_indicator.dart
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import "package:collection/collection.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/controllers/room_typing.dart";
|
||||
import "package:nexus/controllers/user.dart";
|
||||
import "package:nexus/helpers/extensions/get_localpart.dart";
|
||||
import "package:nexus/helpers/extensions/show_user_popover.dart";
|
||||
import "package:nexus/widgets/bouncing_dots.dart";
|
||||
|
||||
class const TypingIndicator(final String roomId, {super.key})
|
||||
extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final typing = ref.watch(RoomTypingController.provider(roomId));
|
||||
|
||||
// Past three people, only the first two are named
|
||||
final named = typing.length > 3 ? 2 : typing.length;
|
||||
final subjects = [
|
||||
...typing
|
||||
.take(named)
|
||||
.map(
|
||||
(typingUserId) => WidgetSpan(
|
||||
alignment: .baseline,
|
||||
baseline: .alphabetic,
|
||||
child: Builder(
|
||||
builder: (context) => InkWell(
|
||||
onTap: () =>
|
||||
context.showUserPopover(typingUserId, roomId: roomId),
|
||||
child: Text(
|
||||
ref
|
||||
.watch(
|
||||
UserController.provider(
|
||||
.new(roomId: roomId, userId: typingUserId),
|
||||
),
|
||||
)
|
||||
.value
|
||||
?.displayName ??
|
||||
typingUserId.localpart,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: .bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (typing.length > named)
|
||||
TextSpan(text: "${typing.length - named} others"),
|
||||
];
|
||||
|
||||
final spans = [
|
||||
...subjects.expandIndexed(
|
||||
(index, subject) => [
|
||||
if (index > 0)
|
||||
TextSpan(
|
||||
text: index < subjects.length - 1
|
||||
? ", "
|
||||
: subjects.length > 2
|
||||
? ", and "
|
||||
: " and ",
|
||||
),
|
||||
subject,
|
||||
],
|
||||
),
|
||||
TextSpan(text: subjects.length == 1 ? " is typing..." : " are typing..."),
|
||||
];
|
||||
|
||||
return AnimatedSwitcher(
|
||||
duration: Durations.medium2,
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
transitionBuilder: (child, animation) => FadeTransition(
|
||||
opacity: animation,
|
||||
child: ScaleTransition(
|
||||
scale: animation,
|
||||
alignment: .bottomLeft,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: typing.isEmpty
|
||||
? const SizedBox.shrink()
|
||||
: Align(
|
||||
alignment: .bottomLeft,
|
||||
child: Material(
|
||||
color: theme.colorScheme.surfaceContainerHigh,
|
||||
elevation: 1,
|
||||
shape: const StadiumBorder(),
|
||||
child: Padding(
|
||||
padding: .symmetric(horizontal: 12, vertical: 4),
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
spacing: 8,
|
||||
children: [
|
||||
BouncingDots(color: theme.colorScheme.onSurfaceVariant),
|
||||
Flexible(
|
||||
child: Text.rich(
|
||||
TextSpan(children: spans),
|
||||
maxLines: 1,
|
||||
overflow: .ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -40,8 +40,8 @@ class const MentionChip(final String content, final String? roomId, {super.key})
|
|||
? SizedBox.shrink()
|
||||
: InkWell(
|
||||
onTap: () async {
|
||||
if (data case MembershipContent membership) {
|
||||
context.showUserPopover(membership, mention, roomId: roomId);
|
||||
if (data is MembershipContent) {
|
||||
context.showUserPopover(mention, roomId: roomId);
|
||||
} else if (data case RoomSummary summary) {
|
||||
final eventRowId = linkInfo?.eventId == null
|
||||
? null
|
||||
|
|
|
|||
51
lib/widgets/knock_sheet.dart
Normal file
51
lib/widgets/knock_sheet.dart
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/helpers/extensions/join_room.dart";
|
||||
import "package:nexus/widgets/sheet_layout.dart";
|
||||
|
||||
/// Asks the moderators of a room to let the user in, with an optional reason.
|
||||
///
|
||||
/// Pops with true once the request has been sent.
|
||||
class const KnockSheet(
|
||||
final String roomId, {
|
||||
required final String name,
|
||||
final IList<String> via = const .empty(),
|
||||
super.key,
|
||||
}) extends HookWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final reason = useTextEditingController();
|
||||
|
||||
void onKnock() {
|
||||
context.knockRoom(
|
||||
.new(
|
||||
roomIdOrAlias: roomId,
|
||||
via: via,
|
||||
reason: reason.text.isEmpty ? null : reason.text,
|
||||
),
|
||||
name: name,
|
||||
);
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
|
||||
return SheetLayout(
|
||||
title: Text("Knock on $name"),
|
||||
actions: [
|
||||
TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")),
|
||||
TextButton(onPressed: onKnock, child: Text("Knock")),
|
||||
],
|
||||
children: [
|
||||
Text(
|
||||
"This room requires knocking to join. Its moderators will be able to see your knock and let you in.",
|
||||
),
|
||||
TextField(
|
||||
textCapitalization: .sentences,
|
||||
controller: reason,
|
||||
decoration: .new(labelText: "Reason (optional)"),
|
||||
onEditingComplete: onKnock,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -12,18 +12,17 @@ class const MessageAvatar(
|
|||
super.key,
|
||||
}) extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) => switch (ref.watch(
|
||||
AuthorController.provider(event),
|
||||
)) {
|
||||
AsyncData(:final value) || AsyncLoading(:final value?) => InkWell(
|
||||
onTap: () =>
|
||||
context.showUserPopover(value, event.sender, roomId: event.roomId),
|
||||
child: AvatarOrHash(
|
||||
value.avatarUrl,
|
||||
value.displayName ?? event.sender.localpart,
|
||||
dimension: height,
|
||||
),
|
||||
),
|
||||
_ => AvatarOrHash(null, event.sender.localpart, dimension: height),
|
||||
};
|
||||
Widget build(BuildContext context, WidgetRef ref) =>
|
||||
switch (ref.watch(AuthorController.provider(event))) {
|
||||
AsyncData(:final value) || AsyncLoading(:final value?) => InkWell(
|
||||
onTap: () =>
|
||||
context.showUserPopover(event.sender, roomId: event.roomId),
|
||||
child: AvatarOrHash(
|
||||
value.avatarUrl,
|
||||
value.displayName ?? event.sender.localpart,
|
||||
dimension: height,
|
||||
),
|
||||
),
|
||||
_ => AvatarOrHash(null, event.sender.localpart, dimension: height),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,12 +21,8 @@ class const MessageDisplayname(
|
|||
AsyncData(:final MembershipContent? value) ||
|
||||
AsyncLoading(:final value) ||
|
||||
AsyncError(:final value) => InkWell(
|
||||
onTap: clickable && value != null
|
||||
? () => context.showUserPopover(
|
||||
value,
|
||||
event.sender,
|
||||
roomId: event.roomId,
|
||||
)
|
||||
onTap: clickable
|
||||
? () => context.showUserPopover(event.sender, roomId: event.roomId)
|
||||
: null,
|
||||
child: switch ([
|
||||
Text(
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ class const MemberList(final String roomId, {super.key})
|
|||
final options = <String, MembershipStatus>{
|
||||
"Joined": .join,
|
||||
"Invited": .invite,
|
||||
"Knocked": .knock,
|
||||
"Banned": .ban,
|
||||
};
|
||||
final status = options.values.toIList()[statusIndex.value];
|
||||
|
|
@ -36,7 +37,7 @@ class const MemberList(final String roomId, {super.key})
|
|||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: .symmetric(vertical: 8),
|
||||
padding: .all(8),
|
||||
child: M3EToggleButtonGroup(
|
||||
selectedIndex: statusIndex.value,
|
||||
onSelectedIndexChanged: (index) =>
|
||||
|
|
@ -104,15 +105,13 @@ class const MemberList(final String roomId, {super.key})
|
|||
itemBuilder: (context, index) =>
|
||||
switch (members[index].content) {
|
||||
MembershipContent(
|
||||
:final avatarUrl,
|
||||
:final displayName,
|
||||
) &&
|
||||
final content =>
|
||||
:final avatarUrl,
|
||||
:final displayName,
|
||||
) =>
|
||||
Builder(
|
||||
builder: (context) => ListTile(
|
||||
onTap: () =>
|
||||
context.showUserPopover(
|
||||
content,
|
||||
members[index].stateKey!,
|
||||
roomId: roomId,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ final class const MessageImage(
|
|||
aspectRatio: (info?.width ?? 1) / (info?.height ?? 1),
|
||||
child: Image(
|
||||
image: MxcImage(ref, request),
|
||||
width: info?.width,
|
||||
width: info?.width?.toDouble(),
|
||||
fit: BoxFit.fitWidth,
|
||||
loadingBuilder: (_, child, loadingProgress) =>
|
||||
loadingProgress == null
|
||||
|
|
@ -40,7 +40,7 @@ final class const MessageImage(
|
|||
child: BlurHash(hash: blurHash),
|
||||
)
|
||||
: SizedBox(
|
||||
width: info!.width,
|
||||
width: info!.width!.toDouble(),
|
||||
child: BlurHash(hash: blurHash),
|
||||
),
|
||||
_ => Loading(),
|
||||
|
|
|
|||
72
lib/widgets/mutual_rooms_sheet.dart
Normal file
72
lib/widgets/mutual_rooms_sheet.dart
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/controllers/mutual_rooms.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/controllers/sidebar_page.dart";
|
||||
import "package:nexus/helpers/extensions/better_when.dart";
|
||||
import "package:nexus/helpers/extensions/focus_room.dart";
|
||||
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||
import "package:nexus/widgets/sheet_layout.dart";
|
||||
|
||||
/// The rooms the user shares with [userId].
|
||||
class const MutualRoomsSheet(final String userId, {super.key})
|
||||
extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) => SheetLayout(
|
||||
title: Text("Mutual Rooms"),
|
||||
actions: [
|
||||
TextButton(onPressed: Navigator.of(context).pop, child: Text("Close")),
|
||||
],
|
||||
children: [
|
||||
ref
|
||||
.watch(MutualRoomsController.provider(userId))
|
||||
.betterWhen(
|
||||
data: (roomIds) {
|
||||
final rooms = ref.watch(
|
||||
RoomsController.provider.select(
|
||||
(allRooms) => roomIds
|
||||
.map((roomId) => allRooms[roomId]?.metadata)
|
||||
.nonNulls
|
||||
.toIList(),
|
||||
),
|
||||
);
|
||||
|
||||
return rooms.isEmpty
|
||||
? Text("No mutual rooms")
|
||||
: Column(
|
||||
children: rooms
|
||||
.map(
|
||||
(room) => ListTile(
|
||||
leading: AvatarOrHash(
|
||||
room.avatar,
|
||||
room.name ?? "Unnamed Room",
|
||||
fallback: Icon(Icons.numbers),
|
||||
),
|
||||
title: Text(
|
||||
room.name ?? "Unnamed Room",
|
||||
overflow: .ellipsis,
|
||||
),
|
||||
onTap: () async {
|
||||
final container = ProviderScope.containerOf(
|
||||
context,
|
||||
);
|
||||
// Closes the user popover under this too
|
||||
Navigator.of(context)
|
||||
.popUntil((route) => route.isFirst);
|
||||
await container.focusRoom(room.id);
|
||||
container
|
||||
.read(
|
||||
SidebarPageController.provider.notifier,
|
||||
)
|
||||
.set(.rooms);
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
@ -1,247 +1,19 @@
|
|||
import "package:collection/collection.dart";
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:m3e_card_list/m3e_card_list.dart";
|
||||
import "package:navigation_rail_m3e/navigation_rail_m3e.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/controllers/settings_sections.dart";
|
||||
import "package:nexus/helpers/extensions/better_when.dart";
|
||||
import "package:nexus/helpers/extensions/show_about_sheet.dart";
|
||||
import "package:nexus/widgets/pages/settings_category.dart";
|
||||
import "package:nexus/widgets/divider_text.dart";
|
||||
import "package:nexus/widgets/highlight_wrapper.dart";
|
||||
import "package:super_sliver_list/super_sliver_list.dart";
|
||||
import "package:nexus/widgets/settings/settings_layout.dart";
|
||||
|
||||
class const SettingsPage({super.key}) extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) => LayoutBuilder(
|
||||
builder: (_, constraints) => HookBuilder(
|
||||
builder: (context) {
|
||||
final categoriesArePages = constraints.maxWidth < 550;
|
||||
|
||||
final selected = useState(0);
|
||||
|
||||
final highlightedMatch = useState<(int, int)?>(null);
|
||||
final listController = useRef(ListController());
|
||||
final scrollController = useScrollController();
|
||||
|
||||
final searchBar = SearchAnchor.bar(
|
||||
barHintText: "Search...",
|
||||
isFullScreen: categoriesArePages,
|
||||
suggestionsBuilder: (suggestionsContext, controller) async {
|
||||
final categories = await ref.read(
|
||||
SettingsSectionsController.provider.future,
|
||||
);
|
||||
final query = controller.text.toLowerCase();
|
||||
|
||||
final matches = categories.values.flattenedToList
|
||||
.asMap()
|
||||
.entries
|
||||
.expand(
|
||||
(categoryEntry) => categoryEntry.value.settings
|
||||
.asMap()
|
||||
.entries
|
||||
.where(
|
||||
(settingEntry) =>
|
||||
settingEntry.value.title.toLowerCase().contains(
|
||||
query,
|
||||
) ||
|
||||
settingEntry.value.description
|
||||
.toLowerCase()
|
||||
.contains(query),
|
||||
)
|
||||
.map(
|
||||
(settingEntry) => (
|
||||
(categoryEntry.key, settingEntry.key),
|
||||
settingEntry.value,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toIList();
|
||||
|
||||
return matches.map(
|
||||
(match) => ListTile(
|
||||
onTap: () async {
|
||||
if (context.mounted) Navigator.of(suggestionsContext).pop();
|
||||
controller.text = "";
|
||||
|
||||
if (categoriesArePages) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => SettingsCategoryPage(
|
||||
match.$1.$1,
|
||||
initialHighlight: match.$1.$2,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
selected.value = match.$1.$1;
|
||||
listController.value.animateToItem(
|
||||
index: match.$1.$2,
|
||||
scrollController: scrollController,
|
||||
alignment: 0.5,
|
||||
duration: (_) => Durations.extralong1,
|
||||
curve: (_) => Curves.easeInOut,
|
||||
);
|
||||
highlightedMatch.value = match.$1;
|
||||
await Future.delayed(Durations.extralong4, () {
|
||||
if (highlightedMatch.value == match.$1) {
|
||||
highlightedMatch.value = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
leading: Icon(match.$2.icon),
|
||||
title: Text(match.$2.title),
|
||||
subtitle: Text(match.$2.description),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
final settingsContent = Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text("Settings"),
|
||||
actionsPadding: .symmetric(horizontal: 12),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () => context.showAboutSheet(ref),
|
||||
icon: Icon(Icons.info_outline),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ref
|
||||
.watch(SettingsSectionsController.provider)
|
||||
.betterWhen(
|
||||
data: (sections) => categoriesArePages
|
||||
? CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(12).copyWith(bottom: 8),
|
||||
child: searchBar,
|
||||
),
|
||||
),
|
||||
...sections
|
||||
.mapTo(
|
||||
(section, categories) => [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
).copyWith(bottom: 4),
|
||||
child: DividerText(section),
|
||||
),
|
||||
),
|
||||
SliverM3ECardList(
|
||||
padding: .symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 8,
|
||||
),
|
||||
margin: .symmetric(horizontal: 12),
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primaryContainer,
|
||||
itemCount: categories.length,
|
||||
onTap: (index) => Navigator.of(context)
|
||||
.push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
SettingsCategoryPage(
|
||||
sections
|
||||
.values
|
||||
.flattenedToList
|
||||
.indexOf(
|
||||
categories[index],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
itemBuilder: (context, index) => ListTile(
|
||||
leading: Icon(categories[index].icon),
|
||||
title: Text(categories[index].title),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
.flattened,
|
||||
],
|
||||
)
|
||||
: Row(
|
||||
children: [
|
||||
MaterialUiCompatibilityBridge(
|
||||
child: NavigationRailM3E(
|
||||
type: .alwaysExpand,
|
||||
trailing: searchBar,
|
||||
scrollable: true,
|
||||
sections: sections
|
||||
.mapTo(
|
||||
(
|
||||
categoryGroup,
|
||||
categories,
|
||||
) => NavigationRailM3ESection(
|
||||
header: DividerText(categoryGroup),
|
||||
destinations: categories
|
||||
.map(
|
||||
(category) =>
|
||||
NavigationRailM3EDestination(
|
||||
icon: Icon(category.icon),
|
||||
label: category.title,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
selectedIndex: selected.value,
|
||||
onDestinationSelected: (value) =>
|
||||
selected.value = value,
|
||||
),
|
||||
),
|
||||
VerticalDivider(),
|
||||
Expanded(
|
||||
child: SuperListView(
|
||||
listController: listController.value,
|
||||
controller: scrollController,
|
||||
padding: .symmetric(vertical: 12),
|
||||
children: sections
|
||||
.values
|
||||
.flattenedToList[selected.value]
|
||||
.settings
|
||||
.mapIndexed(
|
||||
(index, setting) => Padding(
|
||||
padding: .only(bottom: 4),
|
||||
child: HighlightWrapper(
|
||||
setting.builder(
|
||||
setting.title,
|
||||
setting.description,
|
||||
setting.icon,
|
||||
),
|
||||
isHighlighted:
|
||||
highlightedMatch.value ==
|
||||
(selected.value, index),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return constraints.maxWidth < 650
|
||||
? settingsContent
|
||||
: Dialog(
|
||||
constraints: .loose(Size(900, 600)),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadiusGeometry.circular(12),
|
||||
child: settingsContent,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
Widget build(BuildContext context, WidgetRef ref) => SettingsLayout(
|
||||
title: "Settings",
|
||||
sectionsProvider: SettingsSectionsController.provider,
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () => context.showAboutSheet(ref),
|
||||
icon: Icon(Icons.info_outline),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,13 +4,19 @@ import "package:collection/collection.dart";
|
|||
import "package:material_ui/material_ui.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:nexus/controllers/settings_sections.dart";
|
||||
import "package:hooks_riverpod/misc.dart";
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:nexus/models/settings_category.dart";
|
||||
import "package:nexus/helpers/extensions/better_when.dart";
|
||||
import "package:nexus/widgets/highlight_wrapper.dart";
|
||||
import "package:super_sliver_list/super_sliver_list.dart";
|
||||
|
||||
class const SettingsCategoryPage(
|
||||
final int index, {
|
||||
required final ProviderListenable<
|
||||
AsyncValue<IMap<String, IList<SettingsCategory>>>
|
||||
>
|
||||
sectionsProvider,
|
||||
final int? initialHighlight,
|
||||
super.key,
|
||||
}) extends HookConsumerWidget {
|
||||
|
|
@ -45,8 +51,9 @@ class const SettingsCategoryPage(
|
|||
}, []);
|
||||
|
||||
return ref
|
||||
.watch(SettingsSectionsController.provider)
|
||||
.watch(sectionsProvider)
|
||||
.betterWhen(
|
||||
skipLoadingOnReload: true,
|
||||
data: (sections) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(sections.values.flattenedToList[index].title),
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ class const AudioPlayer(
|
|||
final audio = await ref
|
||||
.read(ClientController.provider.notifier)
|
||||
.downloadMedia(.new(mxc: uri, encrypted: encrypted));
|
||||
if (!context.mounted) return;
|
||||
await player.open(Media(audio.path), play: false);
|
||||
if (context.mounted) loaded.value = true;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ class const VideoPlayer(
|
|||
final video = await ref
|
||||
.read(ClientController.provider.notifier)
|
||||
.downloadMedia(.new(mxc: uri, encrypted: encrypted));
|
||||
if (!context.mounted) return;
|
||||
await player.open(Media(video.path), play: false);
|
||||
if (context.mounted) loaded.value = true;
|
||||
});
|
||||
|
|
|
|||
112
lib/widgets/read_receipts_row.dart
Normal file
112
lib/widgets/read_receipts_row.dart
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/controllers/read_receipts.dart";
|
||||
import "package:nexus/controllers/room_chat.dart";
|
||||
import "package:nexus/controllers/user.dart";
|
||||
import "package:nexus/helpers/extensions/get_localpart.dart";
|
||||
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
|
||||
import "package:nexus/helpers/extensions/show_user_popover.dart";
|
||||
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||
import "package:nexus/widgets/avatar_ring.dart";
|
||||
import "package:nexus/widgets/read_receipts_sheet.dart";
|
||||
|
||||
class const ReadReceiptsRow(
|
||||
final String eventId, {
|
||||
required final ChatParams chat,
|
||||
super.key,
|
||||
}) extends ConsumerWidget {
|
||||
static const avatarSize = 24.0;
|
||||
static const overlap = 6.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final receipts = ref.watch(
|
||||
ReadReceiptsController.provider(chat)
|
||||
.select((receipts) => receipts[eventId] ?? const .empty()),
|
||||
);
|
||||
|
||||
return AnimatedSize(
|
||||
duration: Durations.medium2,
|
||||
curve: Curves.easeInOutCubicEmphasized,
|
||||
alignment: .topRight,
|
||||
child: receipts.isEmpty
|
||||
? const SizedBox(width: .infinity)
|
||||
: Padding(
|
||||
// Clear of the scrollbar
|
||||
padding: .only(right: 16, bottom: 2),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
const width = avatarSize + AvatarRing.ringWidth * 2;
|
||||
final fits =
|
||||
(constraints.maxWidth - width) ~/ (width - overlap) + 1;
|
||||
|
||||
// If they don't all fit, the last spot shows how many more there are
|
||||
final shown = receipts.length <= fits
|
||||
? receipts.length
|
||||
: fits - 1;
|
||||
|
||||
return Align(
|
||||
alignment: .centerRight,
|
||||
child: Wrap(
|
||||
spacing: -overlap,
|
||||
children: [
|
||||
...receipts
|
||||
.take(shown)
|
||||
.map(
|
||||
(receipt) => Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final member = ref
|
||||
.watch(
|
||||
UserController.provider(
|
||||
.new(
|
||||
roomId: chat.roomId,
|
||||
userId: receipt.userId,
|
||||
),
|
||||
),
|
||||
)
|
||||
.value;
|
||||
final name =
|
||||
member?.displayName ??
|
||||
receipt.userId.localpart;
|
||||
|
||||
return AvatarRing(
|
||||
tooltip: name,
|
||||
onTap: () => context.showUserPopover(
|
||||
receipt.userId,
|
||||
roomId: chat.roomId,
|
||||
),
|
||||
child: AvatarOrHash(
|
||||
member?.avatarUrl,
|
||||
name,
|
||||
dimension: avatarSize,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (receipts.length > shown)
|
||||
AvatarRing(
|
||||
tooltip: "Show everyone who has read up to here",
|
||||
onTap: () => context.showAdaptiveSheet(
|
||||
(_) => ReadReceiptsSheet(eventId, chat: chat),
|
||||
),
|
||||
child: ColoredBox(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
child: Center(
|
||||
child: Text(
|
||||
"+${receipts.length - shown}",
|
||||
style: theme.textTheme.labelSmall,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
54
lib/widgets/read_receipts_sheet.dart
Normal file
54
lib/widgets/read_receipts_sheet.dart
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/controllers/read_receipts.dart";
|
||||
import "package:nexus/controllers/room_chat.dart";
|
||||
import "package:nexus/controllers/user.dart";
|
||||
import "package:nexus/helpers/extensions/get_localpart.dart";
|
||||
import "package:nexus/helpers/extensions/show_user_popover.dart";
|
||||
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||
import "package:nexus/widgets/sheet_layout.dart";
|
||||
|
||||
/// Everyone who has read up to an event, kept up to date while open.
|
||||
class const ReadReceiptsSheet(
|
||||
final String eventId, {
|
||||
required final ChatParams chat,
|
||||
super.key,
|
||||
}) extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) => SheetLayout(
|
||||
title: Text("Read by"),
|
||||
actions: [
|
||||
TextButton(onPressed: Navigator.of(context).pop, child: Text("Close")),
|
||||
],
|
||||
children: ref
|
||||
.watch(
|
||||
ReadReceiptsController.provider(chat)
|
||||
.select((receipts) => receipts[eventId] ?? const .empty()),
|
||||
)
|
||||
.map(
|
||||
(receipt) => Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final member = ref
|
||||
.watch(
|
||||
UserController.provider(
|
||||
.new(roomId: chat.roomId, userId: receipt.userId),
|
||||
),
|
||||
)
|
||||
.value;
|
||||
final name = member?.displayName ?? receipt.userId.localpart;
|
||||
|
||||
return ListTile(
|
||||
leading: AvatarOrHash(member?.avatarUrl, name, dimension: 40),
|
||||
title: Text(name, overflow: .ellipsis),
|
||||
subtitle: Text(receipt.userId, overflow: .ellipsis),
|
||||
onTap: () => context.showUserPopover(
|
||||
receipt.userId,
|
||||
roomId: chat.roomId,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
|
@ -6,27 +6,27 @@ import "package:hooks_riverpod/hooks_riverpod.dart";
|
|||
import "package:nexus/controllers/event.dart";
|
||||
import "package:nexus/controllers/room_chat.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/controllers/settings.dart";
|
||||
import "package:nexus/helpers/extensions/is_shown_as_message.dart";
|
||||
import "package:nexus/helpers/extensions/show_adaptive_menu.dart";
|
||||
import "package:nexus/helpers/hooks/chat_scroll.dart";
|
||||
import "package:nexus/models/content/avatar.dart";
|
||||
import "package:nexus/models/content/canonical_alias.dart";
|
||||
import "package:nexus/models/content/content.dart";
|
||||
import "package:nexus/models/content/create.dart";
|
||||
import "package:nexus/models/content/encrypted.dart";
|
||||
import "package:nexus/models/content/history_visibility.dart";
|
||||
import "package:nexus/models/content/join_rules.dart";
|
||||
import "package:nexus/models/content/membership.dart";
|
||||
import "package:nexus/models/content/message.dart";
|
||||
import "package:nexus/models/content/pinned_events.dart";
|
||||
import "package:nexus/models/content/power_levels.dart";
|
||||
import "package:nexus/models/content/server_acl.dart";
|
||||
import "package:nexus/models/content/sticker.dart";
|
||||
import "package:nexus/models/content/topic.dart";
|
||||
import "package:nexus/models/event.dart";
|
||||
import "package:nexus/widgets/error_dialog.dart";
|
||||
import "package:nexus/widgets/lazy_loading/message_displayname.dart";
|
||||
import "package:nexus/widgets/renderers/message.dart";
|
||||
import "package:nexus/widgets/reaction_row.dart";
|
||||
import "package:nexus/widgets/read_receipts_row.dart";
|
||||
import "package:nexus/widgets/renderers/membership.dart";
|
||||
import "package:nexus/widgets/renderers/generic_event.dart";
|
||||
import "package:nexus/widgets/swipe_to_reply.dart";
|
||||
|
|
@ -43,6 +43,10 @@ class const EventRenderer(
|
|||
|
||||
/// If set, events can be swiped, calling this
|
||||
final VoidCallback? onSwipe,
|
||||
|
||||
/// If set, shows who in this chat has read up to this event, e.g. in the
|
||||
/// timeline.
|
||||
final ChatParams? readReceiptsChat,
|
||||
super.key,
|
||||
}) extends HookConsumerWidget {
|
||||
@override
|
||||
|
|
@ -78,10 +82,24 @@ class const EventRenderer(
|
|||
|
||||
if (event == null) return SizedBox.shrink();
|
||||
|
||||
final hideMembershipEvents = (ref
|
||||
.watch(SettingsController.provider)
|
||||
.maybeWhen(
|
||||
orElse: () => false,
|
||||
data: (settings) => settings.hideMembershipEvents,
|
||||
));
|
||||
|
||||
// Knocks and their outcomes stay visible, as moderators need to act on them
|
||||
if (hideMembershipEvents &&
|
||||
event.content is MembershipContent &&
|
||||
![event.content, event.previousContent].any(
|
||||
(content) => content is MembershipContent && content.status == .knock,
|
||||
)) {
|
||||
return SizedBox.shrink();
|
||||
}
|
||||
|
||||
final isGrouped =
|
||||
previousEvent?.content is MessageContent &&
|
||||
previousEvent?.redactedBy == null &&
|
||||
previousEvent?.relationType != "m.replace" &&
|
||||
(previousEvent?.isShownAsMessage ?? false) &&
|
||||
event.sender == previousEvent?.sender &&
|
||||
event.pmp?.id == previousEvent?.pmp?.id;
|
||||
|
||||
|
|
@ -94,7 +112,26 @@ class const EventRenderer(
|
|||
TextSpan(text: text),
|
||||
]);
|
||||
|
||||
final child = event.redactedBy != null || event.relationType == "m.replace"
|
||||
final child = event.isShownAsMessage
|
||||
? MessageRenderer(
|
||||
event,
|
||||
onTapReply: jumpToEvent == null
|
||||
? null
|
||||
: () async {
|
||||
final replyEvent = await ref.read(
|
||||
EventController.provider(
|
||||
.new(roomId: roomId, eventId: event.replyTo!),
|
||||
).future,
|
||||
);
|
||||
if (replyEvent != null) {
|
||||
await jumpToEvent!(replyEvent.rowId);
|
||||
}
|
||||
},
|
||||
isGrouped: isGrouped,
|
||||
maxLines: maxLines,
|
||||
textOnly: textOnly,
|
||||
)
|
||||
: event.redactedBy != null || event.relationType == "m.replace"
|
||||
? null
|
||||
: switch (event.content) {
|
||||
Content(:final parseError?, :final stackTrace) => Row(
|
||||
|
|
@ -106,27 +143,6 @@ class const EventRenderer(
|
|||
],
|
||||
),
|
||||
|
||||
MessageContent() ||
|
||||
EncryptedContent() ||
|
||||
StickerContent() => MessageRenderer(
|
||||
event,
|
||||
onTapReply: jumpToEvent == null
|
||||
? null
|
||||
: () async {
|
||||
final replyEvent = await ref.read(
|
||||
EventController.provider(
|
||||
.new(roomId: roomId, eventId: event.replyTo!),
|
||||
).future,
|
||||
);
|
||||
if (replyEvent != null) {
|
||||
await jumpToEvent!(replyEvent.rowId);
|
||||
}
|
||||
},
|
||||
isGrouped: isGrouped,
|
||||
maxLines: maxLines,
|
||||
textOnly: textOnly,
|
||||
),
|
||||
|
||||
MembershipContent content => switch (event.previousContent) {
|
||||
MembershipContent(:final status) =>
|
||||
status == content.status
|
||||
|
|
@ -268,6 +284,10 @@ class const EventRenderer(
|
|||
...[
|
||||
if (child is! MessageRenderer) ReactionRow(event),
|
||||
|
||||
// Outside the message itself, so it doesn't move when swiped
|
||||
if (readReceiptsChat case final chat? when event.isShownAsMessage)
|
||||
ReadReceiptsRow(event.eventId, chat: chat),
|
||||
|
||||
if (event.sendError != null && event.sendError != "not sent")
|
||||
Padding(
|
||||
padding: .only(bottom: 4),
|
||||
|
|
|
|||
|
|
@ -19,6 +19,11 @@ class const MembershipRenderer(
|
|||
"Make sure to only pass membership events to MembershipRenderer",
|
||||
);
|
||||
|
||||
final previous = switch (event.previousContent) {
|
||||
MembershipContent(:final status) => status,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
return switch (event.content) {
|
||||
MembershipContent content => GenericEventRenderer(
|
||||
Icons.people,
|
||||
|
|
@ -28,7 +33,6 @@ class const MembershipRenderer(
|
|||
alignment: .middle,
|
||||
child: InkWell(
|
||||
onTap: () => context.showUserPopover(
|
||||
content,
|
||||
event.stateKey!,
|
||||
roomId: event.roomId,
|
||||
),
|
||||
|
|
@ -43,9 +47,10 @@ class const MembershipRenderer(
|
|||
TextSpan(
|
||||
text:
|
||||
"${switch (content.status) {
|
||||
.invite => "was invited to",
|
||||
.invite => previous == .knock ? "was accepted into" : "was invited to",
|
||||
.join => "joined",
|
||||
.leave => event.sender == event.stateKey ? "left" : (event.unsigned["prev_content"]?["membership"] == "ban" ? "was unbanned from" : "was kicked from"),
|
||||
.leave when previous == .knock => event.sender == event.stateKey ? "retracted their knock on" : "had their knock denied in",
|
||||
.leave => event.sender == event.stateKey ? "left" : (previous == .ban ? "was unbanned from" : "was kicked from"),
|
||||
.ban => "was banned from",
|
||||
.knock => "asked to join",
|
||||
}} the room${event.sender == event.stateKey ? "" : " by"}",
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ final class const RoomAppbar({
|
|||
required final void Function() onOpenDrawer,
|
||||
final void Function(BuildContext context)? onOpenMemberList,
|
||||
final void Function()? onOpenPinnedMessagesList,
|
||||
final void Function()? onOpenSearch,
|
||||
super.key,
|
||||
}) extends ConsumerWidget implements PreferredSizeWidget {
|
||||
@override
|
||||
|
|
@ -61,6 +62,11 @@ final class const RoomAppbar({
|
|||
room == null
|
||||
? []
|
||||
: [
|
||||
IconButton(
|
||||
onPressed: onOpenSearch?.call,
|
||||
icon: Icon(Icons.search),
|
||||
tooltip: "Search this room",
|
||||
),
|
||||
IconButton(
|
||||
onPressed: onOpenPinnedMessagesList?.call,
|
||||
icon: Icon(Icons.push_pin),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/controllers/room_chat.dart";
|
||||
import "package:nexus/helpers/hooks/chat_scroll.dart";
|
||||
import "package:nexus/models/event.dart";
|
||||
import "package:nexus/widgets/renderers/event.dart";
|
||||
|
|
@ -7,12 +8,15 @@ import "package:nexus/widgets/highlight_wrapper.dart";
|
|||
import "package:super_sliver_list/super_sliver_list.dart";
|
||||
|
||||
class const ChatTimeline({
|
||||
required final String roomId,
|
||||
required final ChatParams chat,
|
||||
required final ChatScroll scroll,
|
||||
required final JumpToEvent jumpToEvent,
|
||||
required final IList<PopupMenuEntry> Function(Event) getEventOptions,
|
||||
required final int? highlightedEvent,
|
||||
required final double composerHeight,
|
||||
|
||||
/// The typing indicator's height, or zero while it's hidden.
|
||||
required final double typingIndicatorHeight,
|
||||
final void Function(int rowId)? onSwipe,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
|
|
@ -24,10 +28,11 @@ class const ChatTimeline({
|
|||
EventRenderer(
|
||||
eventRowId,
|
||||
previousEventRowId: previousEventRowId,
|
||||
roomId: roomId,
|
||||
roomId: chat.roomId,
|
||||
jumpToEvent: jumpToEvent,
|
||||
getEventOptions: getEventOptions,
|
||||
onSwipe: onSwipe == null ? null : () => onSwipe!(eventRowId),
|
||||
readReceiptsChat: chat,
|
||||
),
|
||||
key: key,
|
||||
isHighlighted: highlightedEvent == eventRowId,
|
||||
|
|
@ -40,7 +45,22 @@ class const ChatTimeline({
|
|||
keyboardDismissBehavior: .onDrag,
|
||||
controller: scroll.scrollController,
|
||||
slivers: [
|
||||
SliverToBoxAdapter(child: SizedBox(height: composerHeight)),
|
||||
SliverToBoxAdapter(
|
||||
child: Column(
|
||||
children: [
|
||||
// Makes room for the typing indicator, with a small gap above it,
|
||||
// so it doesn't cover messages
|
||||
AnimatedContainer(
|
||||
duration: Durations.medium2,
|
||||
curve: Curves.easeInOutCubicEmphasized,
|
||||
height: typingIndicatorHeight == 0
|
||||
? 0
|
||||
: typingIndicatorHeight + 4,
|
||||
),
|
||||
SizedBox(height: composerHeight),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
SuperSliverList.builder(
|
||||
itemCount: scroll.liveRows.length,
|
||||
|
|
|
|||
|
|
@ -15,11 +15,13 @@ import "package:nexus/helpers/hooks/chat_scroll.dart";
|
|||
import "package:nexus/models/event.dart";
|
||||
import "package:nexus/models/relation_type.dart";
|
||||
import "package:nexus/widgets/composer/composer.dart";
|
||||
import "package:nexus/widgets/composer/typing_indicator.dart";
|
||||
import "package:nexus/widgets/error_dialog.dart";
|
||||
import "package:nexus/widgets/loading.dart";
|
||||
import "package:nexus/widgets/pinned_events_drawer.dart";
|
||||
import "package:nexus/widgets/member_list.dart";
|
||||
import "package:nexus/widgets/room_appbar.dart";
|
||||
import "package:nexus/widgets/search_drawer.dart";
|
||||
import "package:nexus/widgets/sync_status_banner.dart";
|
||||
import "package:nexus/main.dart";
|
||||
import "package:nexus/widgets/room_chat/chat_timeline.dart";
|
||||
|
|
@ -45,6 +47,7 @@ final class const RoomChat({
|
|||
final highlightedEvent = useState<int?>(null);
|
||||
|
||||
final composerSize = useState<double>(64);
|
||||
final typingIndicatorSize = useState<double>(0);
|
||||
|
||||
final userId = ref.watch(ClientStateController.provider)?.userId;
|
||||
final memberListOpened = ref
|
||||
|
|
@ -82,10 +85,11 @@ final class const RoomChat({
|
|||
),
|
||||
);
|
||||
|
||||
final controllerProvider = RoomChatController.provider((
|
||||
final ChatParams chat = (
|
||||
roomId: roomId,
|
||||
contextualEvent: contextualEvent.value,
|
||||
));
|
||||
);
|
||||
final controllerProvider = RoomChatController.provider(chat);
|
||||
final notifier = ref.watch(controllerProvider.notifier);
|
||||
|
||||
final client = ref.read(ClientController.provider.notifier);
|
||||
|
|
@ -198,115 +202,148 @@ final class const RoomChat({
|
|||
);
|
||||
|
||||
return Scaffold(
|
||||
endDrawer: PinnedEventsDrawer(
|
||||
endDrawerEnableOpenDragGesture: false,
|
||||
endDrawer: SearchDrawer(
|
||||
roomId,
|
||||
getEventOptions: getEventOptions,
|
||||
jumpToEvent: jumpToEvent,
|
||||
),
|
||||
body: Builder(
|
||||
builder: (middleContext) => Scaffold(
|
||||
endDrawer: showMembersByDefault ? null : MemberList(roomId),
|
||||
appBar: RoomAppbar(
|
||||
roomId: roomId,
|
||||
isDesktop: isDesktop,
|
||||
onOpenDrawer: Scaffold.of(context).openDrawer,
|
||||
onOpenMemberList: (thisContext) {
|
||||
ref
|
||||
.read(MemberListOpenedController.provider.notifier)
|
||||
.set(!memberListOpened);
|
||||
Scaffold.of(thisContext).openEndDrawer();
|
||||
},
|
||||
onOpenPinnedMessagesList: Scaffold.of(middleContext).openEndDrawer,
|
||||
builder: (searchContext) => Scaffold(
|
||||
endDrawerEnableOpenDragGesture: false,
|
||||
endDrawer: PinnedEventsDrawer(
|
||||
roomId,
|
||||
getEventOptions: getEventOptions,
|
||||
jumpToEvent: jumpToEvent,
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
const SyncStatusBanner(),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Padding(
|
||||
padding: .symmetric(horizontal: 4),
|
||||
child: switch (controllerData) {
|
||||
AsyncLoading(value: _?) ||
|
||||
AsyncData(value: _) => ChatTimeline(
|
||||
scroll: scroll,
|
||||
roomId: roomId,
|
||||
jumpToEvent: jumpToEvent,
|
||||
getEventOptions: getEventOptions,
|
||||
onSwipe: swipeToReply && canReply
|
||||
? (rowId) => onRelation(rowId, .reply)
|
||||
: null,
|
||||
highlightedEvent: highlightedEvent.value,
|
||||
composerHeight: composerSize.value,
|
||||
),
|
||||
AsyncLoading _ => Loading(),
|
||||
AsyncError(:final error, :final stackTrace) =>
|
||||
ErrorDialog(error, stackTrace),
|
||||
},
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: composerSize.value,
|
||||
child: IgnorePointer(
|
||||
ignoring: scroll.atBottom,
|
||||
child: AnimatedOpacity(
|
||||
opacity: scroll.atBottom ? 0 : 1,
|
||||
duration: Durations.short4,
|
||||
child: FloatingActionButton.small(
|
||||
onPressed: scroll.jumpToBottom,
|
||||
child: const Icon(Icons.keyboard_arrow_down),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: MeasureSize(
|
||||
onChange: (size) =>
|
||||
composerSize.value = size.height,
|
||||
child: Composer(
|
||||
roomId,
|
||||
node: composerNode,
|
||||
onSend:
|
||||
(
|
||||
text, {
|
||||
required shouldMention,
|
||||
required tags,
|
||||
}) => notifier
|
||||
.send(
|
||||
text,
|
||||
tags: tags,
|
||||
relationType: relationType.value,
|
||||
shouldMention: shouldMention,
|
||||
relatedEventRowId: relatedEvent.value,
|
||||
)
|
||||
.onError(showError),
|
||||
relationType: relationType.value,
|
||||
relatedEvent: relatedEvent.value,
|
||||
onDismiss: () {
|
||||
relatedEvent.value = null;
|
||||
composerNode.requestFocus();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (memberListOpened && showMembersByDefault)
|
||||
MemberList(roomId),
|
||||
],
|
||||
),
|
||||
body: Builder(
|
||||
builder: (middleContext) => Scaffold(
|
||||
endDrawer: showMembersByDefault ? null : MemberList(roomId),
|
||||
appBar: RoomAppbar(
|
||||
roomId: roomId,
|
||||
isDesktop: isDesktop,
|
||||
onOpenDrawer: Scaffold.of(context).openDrawer,
|
||||
onOpenMemberList: (thisContext) {
|
||||
ref
|
||||
.read(MemberListOpenedController.provider.notifier)
|
||||
.set(!memberListOpened);
|
||||
Scaffold.of(thisContext).openEndDrawer();
|
||||
},
|
||||
onOpenPinnedMessagesList: Scaffold.of(middleContext)
|
||||
.openEndDrawer,
|
||||
onOpenSearch: Scaffold.of(searchContext).openEndDrawer,
|
||||
),
|
||||
],
|
||||
body: Column(
|
||||
children: [
|
||||
const SyncStatusBanner(),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Padding(
|
||||
padding: .symmetric(horizontal: 4),
|
||||
child: switch (controllerData) {
|
||||
AsyncLoading(value: _?) ||
|
||||
AsyncData(value: _) => ChatTimeline(
|
||||
scroll: scroll,
|
||||
chat: chat,
|
||||
jumpToEvent: jumpToEvent,
|
||||
getEventOptions: getEventOptions,
|
||||
onSwipe: swipeToReply && canReply
|
||||
? (rowId) => onRelation(rowId, .reply)
|
||||
: null,
|
||||
highlightedEvent: highlightedEvent.value,
|
||||
composerHeight: composerSize.value,
|
||||
typingIndicatorHeight:
|
||||
typingIndicatorSize.value,
|
||||
),
|
||||
AsyncLoading _ => Loading(),
|
||||
AsyncError(
|
||||
:final error,
|
||||
:final stackTrace,
|
||||
) =>
|
||||
ErrorDialog(error, stackTrace),
|
||||
},
|
||||
),
|
||||
),
|
||||
AnimatedPositioned(
|
||||
duration: Durations.short4,
|
||||
bottom: composerSize.value,
|
||||
left: 12,
|
||||
// Leaves room for the jump to bottom button when it's shown
|
||||
right: scroll.atBottom ? 12 : 72,
|
||||
child: MeasureSize(
|
||||
onChange: (size) =>
|
||||
typingIndicatorSize.value = size.height,
|
||||
child: TypingIndicator(roomId),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: composerSize.value,
|
||||
child: IgnorePointer(
|
||||
ignoring: scroll.atBottom,
|
||||
child: AnimatedOpacity(
|
||||
opacity: scroll.atBottom ? 0 : 1,
|
||||
duration: Durations.short4,
|
||||
child: FloatingActionButton.small(
|
||||
onPressed: scroll.jumpToBottom,
|
||||
child: const Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: MeasureSize(
|
||||
onChange: (size) =>
|
||||
composerSize.value = size.height,
|
||||
child: Composer(
|
||||
roomId,
|
||||
node: composerNode,
|
||||
onSend:
|
||||
(
|
||||
text, {
|
||||
required shouldMention,
|
||||
required tags,
|
||||
}) => notifier
|
||||
.send(
|
||||
text,
|
||||
tags: tags,
|
||||
relationType: relationType.value,
|
||||
shouldMention: shouldMention,
|
||||
relatedEventRowId:
|
||||
relatedEvent.value,
|
||||
)
|
||||
.onError(showError),
|
||||
relationType: relationType.value,
|
||||
relatedEvent: relatedEvent.value,
|
||||
onDismiss: () {
|
||||
relatedEvent.value = null;
|
||||
composerNode.requestFocus();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (memberListOpened && showMembersByDefault)
|
||||
MemberList(roomId),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ import "package:nexus/widgets/sheet_layout.dart";
|
|||
import "package:flutter/services.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/controllers/room_settings_sections.dart";
|
||||
import "package:nexus/widgets/adaptive_menu_button.dart";
|
||||
import "package:nexus/widgets/settings/settings_layout.dart";
|
||||
import "package:nexus/controllers/via.dart";
|
||||
import "package:nexus/models/room.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
|
|
@ -15,7 +17,7 @@ import "package:nexus/main.dart";
|
|||
|
||||
final class const RoomMenuButton(
|
||||
final Room? room, {
|
||||
final IList<Room> children = const IList.empty(),
|
||||
final IList<Room> children = const .empty(),
|
||||
super.key,
|
||||
}) extends ConsumerWidget {
|
||||
@override
|
||||
|
|
@ -122,6 +124,21 @@ final class const RoomMenuButton(
|
|||
title: Text("Invite"),
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
onTap: () => showDialog(
|
||||
context: context,
|
||||
builder: (_) => SettingsLayout(
|
||||
title: "Room Settings",
|
||||
sectionsProvider: RoomSettingsSectionsController.provider(
|
||||
room!.metadata!.id,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.settings),
|
||||
title: Text("Settings"),
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
onTap: () => context.showLeaveRoomSheet(
|
||||
room!.metadata!.id,
|
||||
|
|
|
|||
|
|
@ -2,12 +2,16 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
|||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/widgets/sheet_layout.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/controllers/invites.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/helpers/extensions/get_localpart.dart";
|
||||
import "package:nexus/helpers/extensions/join_room.dart";
|
||||
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
|
||||
import "package:nexus/main.dart";
|
||||
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||
import "package:nexus/widgets/expandable_image.dart";
|
||||
import "package:nexus/widgets/knock_sheet.dart";
|
||||
import "package:nexus/widgets/linkified_text.dart";
|
||||
import "package:nexus/models/room_summary.dart";
|
||||
|
||||
|
|
@ -15,11 +19,17 @@ class const RoomSummarySheet(
|
|||
final RoomSummary summary, {
|
||||
final IList<String>? via,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
}) extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final name = summary.name ?? summary.canonicalAlias ?? summary.roomId;
|
||||
final isInvite = summary.membership == .invite;
|
||||
final isKnocking = summary.membership == .knock;
|
||||
final mustKnock = summary.mustKnock(
|
||||
ref.watch(
|
||||
RoomsController.provider.select((rooms) => rooms.keys.toISet()),
|
||||
),
|
||||
);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return SheetLayout(
|
||||
|
|
@ -83,7 +93,35 @@ class const RoomSummarySheet(
|
|||
},
|
||||
child: Text("Accept"),
|
||||
),
|
||||
] else if (via != null)
|
||||
] else if (isKnocking)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
final container = ProviderScope.containerOf(context);
|
||||
Navigator.of(context).pop();
|
||||
|
||||
container
|
||||
.read(ClientController.provider.notifier)
|
||||
.leaveRoom(summary.roomId)
|
||||
.onError(showError);
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: colorScheme.error),
|
||||
child: Text("Retract knock"),
|
||||
)
|
||||
else if (via != null && mustKnock)
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
if (await context.showAdaptiveSheet<bool>(
|
||||
(_) =>
|
||||
KnockSheet(summary.roomId, name: name, via: via!),
|
||||
) ==
|
||||
true &&
|
||||
context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
child: Text("Knock"),
|
||||
)
|
||||
else if (via != null)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.joinRoom(
|
||||
|
|
@ -139,6 +177,17 @@ class const RoomSummarySheet(
|
|||
subtitle: LinkifiedText(summary.topic!),
|
||||
leading: Icon(Icons.info),
|
||||
),
|
||||
if (isKnocking)
|
||||
ListTile(
|
||||
title: Text("You've knocked"),
|
||||
subtitle: Text("Waiting for a moderator to let you in"),
|
||||
leading: Icon(Icons.hourglass_top),
|
||||
)
|
||||
else if (mustKnock)
|
||||
ListTile(
|
||||
title: Text("Knock to join"),
|
||||
leading: Icon(Icons.door_front_door),
|
||||
),
|
||||
if (summary.encryption != null)
|
||||
ListTile(title: Text("Encrypted"), leading: Icon(Icons.lock)),
|
||||
if (summary.worldReadable == true)
|
||||
|
|
|
|||
147
lib/widgets/search_drawer.dart
Normal file
147
lib/widgets/search_drawer.dart
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/controllers/event_search.dart";
|
||||
import "package:nexus/helpers/hooks/chat_scroll.dart";
|
||||
import "package:nexus/main.dart";
|
||||
import "package:nexus/models/event.dart";
|
||||
import "package:nexus/widgets/error_dialog.dart";
|
||||
import "package:nexus/widgets/loading.dart";
|
||||
import "package:nexus/widgets/renderers/event.dart";
|
||||
|
||||
final class const SearchDrawer(
|
||||
final String roomId, {
|
||||
required final IList<PopupMenuEntry> Function(Event event) getEventOptions,
|
||||
required final JumpToEvent jumpToEvent,
|
||||
super.key,
|
||||
}) extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final controller = useTextEditingController();
|
||||
final query = useState("");
|
||||
final provider = EventSearchController.provider((
|
||||
roomId: roomId,
|
||||
query: query.value,
|
||||
));
|
||||
final results = ref.watch(provider);
|
||||
final theme = Theme.of(context);
|
||||
|
||||
void search() {
|
||||
final text = controller.text.trim();
|
||||
|
||||
// The provider is keyed on the query, so searching again needs a refetch
|
||||
if (text == query.value) {
|
||||
ref.invalidate(provider, asReload: true);
|
||||
} else {
|
||||
query.value = text;
|
||||
}
|
||||
}
|
||||
|
||||
Widget placeholder(IconData icon, String text) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
Icon(icon, size: 48, color: theme.colorScheme.onSurface),
|
||||
SizedBox(height: 12),
|
||||
Text(text, style: theme.textTheme.headlineSmall),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return Drawer(
|
||||
width: 400,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
appBar: AppBar(
|
||||
scrolledUnderElevation: 0,
|
||||
leading: Icon(Icons.search),
|
||||
title: Text("Search"),
|
||||
actionsPadding: .only(right: 4),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: Scaffold.of(context).closeEndDrawer,
|
||||
icon: Icon(Icons.close),
|
||||
tooltip: "Close search",
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: .symmetric(horizontal: 12, vertical: 4),
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
textInputAction: .search,
|
||||
onSubmitted: (_) => search(),
|
||||
decoration: .new(
|
||||
hintText: "Search this room",
|
||||
prefixIcon: Icon(Icons.search),
|
||||
suffixIcon: IconButton(
|
||||
onPressed: search,
|
||||
icon: Icon(Icons.arrow_forward),
|
||||
tooltip: "Search",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: query.value.isEmpty
|
||||
? placeholder(Icons.search, "Search for events")
|
||||
: switch (results) {
|
||||
AsyncData(:final value) when value.rowIds.isEmpty =>
|
||||
placeholder(Icons.search_off, "No results"),
|
||||
AsyncData(:final value) ||
|
||||
AsyncLoading(:final value?) => ListView.builder(
|
||||
padding: .all(8),
|
||||
itemCount:
|
||||
value.rowIds.length +
|
||||
(value.nextBatch == null ? 0 : 1),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == value.rowIds.length) {
|
||||
return Center(
|
||||
child: TextButton(
|
||||
onPressed: results.isLoading
|
||||
? null
|
||||
: () => ref
|
||||
.read(provider.notifier)
|
||||
.paginate()
|
||||
.onError(showError),
|
||||
child: Text("Load more"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final rowId = value.rowIds[index];
|
||||
return InkWell(
|
||||
borderRadius: .circular(12),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
jumpToEvent(rowId);
|
||||
},
|
||||
child: Padding(
|
||||
padding: .symmetric(vertical: 4),
|
||||
child: IgnorePointer(
|
||||
child: EventRenderer(
|
||||
rowId,
|
||||
roomId: roomId,
|
||||
maxLines: 2,
|
||||
getEventOptions: getEventOptions,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
AsyncLoading() => Loading(),
|
||||
AsyncError(:final error, :final stackTrace) =>
|
||||
ErrorDialog(error, stackTrace),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
112
lib/widgets/settings/add_users_power_level_sheet.dart
Normal file
112
lib/widgets/settings/add_users_power_level_sheet.dart
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import "package:collection/collection.dart";
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/controllers/members.dart";
|
||||
import "package:nexus/helpers/extensions/get_localpart.dart";
|
||||
import "package:nexus/models/content/membership.dart";
|
||||
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||
import "package:nexus/widgets/settings/power_level_field.dart";
|
||||
import "package:nexus/widgets/settings/search_chooser.dart";
|
||||
import "package:nexus/widgets/sheet_layout.dart";
|
||||
|
||||
/// Chooses users of [roomId] to give a power level of their own.
|
||||
class const AddUsersPowerLevelSheet({
|
||||
required final String roomId,
|
||||
|
||||
/// Users who already have a level, or can't be given one, so aren't suggested.
|
||||
required final ISet<String> existing,
|
||||
|
||||
/// The user's own power level, which they can't set levels above.
|
||||
required final double ownLevel,
|
||||
required final void Function(ISet<String> users, int level) onSave,
|
||||
super.key,
|
||||
}) extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final chosen = useState(const ISet<String>.empty());
|
||||
final error = useState<String?>(null);
|
||||
final searchController = useTextEditingController();
|
||||
final levelController = useTextEditingController(text: "0");
|
||||
final level = PowerLevelField.levelFrom(
|
||||
useValueListenable(levelController).text,
|
||||
ownLevel,
|
||||
);
|
||||
|
||||
final members = IMap.fromEntries(
|
||||
(ref.watch(MembersController.provider(roomId)).value ??
|
||||
const ISet.empty())
|
||||
.map(
|
||||
(event) => switch ((event.stateKey, event.content)) {
|
||||
(final userId?, final MembershipContent content)
|
||||
when content.status == .join || content.status == .invite =>
|
||||
MapEntry(userId, content),
|
||||
_ => null,
|
||||
},
|
||||
)
|
||||
.nonNulls,
|
||||
);
|
||||
|
||||
String nameOf(String id) => members[id]?.displayName ?? id.localpart;
|
||||
|
||||
void add(String id) {
|
||||
if (existing.contains(id)) {
|
||||
error.value = "Already has a power level";
|
||||
return;
|
||||
}
|
||||
chosen.value = chosen.value.add(id);
|
||||
searchController.clear();
|
||||
}
|
||||
|
||||
final valid = chosen.value.isNotEmpty && level != null;
|
||||
void save() {
|
||||
Navigator.of(context).pop();
|
||||
onSave(chosen.value, level!);
|
||||
}
|
||||
|
||||
return SheetLayout(
|
||||
title: Text("Add Users"),
|
||||
actions: [
|
||||
TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")),
|
||||
TextButton(onPressed: valid ? save : null, child: Text("Save")),
|
||||
],
|
||||
children: [
|
||||
SearchChooser(
|
||||
controller: searchController,
|
||||
chosen: chosen.value,
|
||||
options: members.keys
|
||||
.where((id) => !existing.contains(id))
|
||||
.sortedBy((id) => nameOf(id).toLowerCase())
|
||||
.toIList(),
|
||||
nameOf: nameOf,
|
||||
avatarOf: (id) =>
|
||||
AvatarOrHash(members[id]?.avatarUrl, nameOf(id), dimension: 40),
|
||||
subtitleOf: (id) => id,
|
||||
onAdded: add,
|
||||
onRemoved: (id) => chosen.value = chosen.value.remove(id),
|
||||
onSubmitted: (text, topMatch) {
|
||||
if (RegExp(r"^@[^:]+:.+$").hasMatch(text)) {
|
||||
add(text);
|
||||
} else if (topMatch != null) {
|
||||
add(topMatch);
|
||||
} else {
|
||||
error.value = "Enter a user ID";
|
||||
}
|
||||
},
|
||||
onChanged: (_) => error.value = null,
|
||||
label: "Add user",
|
||||
hint: "Search members, or enter a user ID",
|
||||
removeTooltip: "Remove user",
|
||||
error: error.value,
|
||||
autofocus: true,
|
||||
),
|
||||
PowerLevelField(
|
||||
controller: levelController,
|
||||
ownLevel: ownLevel,
|
||||
onSubmitted: valid ? save : null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
78
lib/widgets/settings/aliases_sheet.dart
Normal file
78
lib/widgets/settings/aliases_sheet.dart
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/widgets/sheet_layout.dart";
|
||||
|
||||
/// Chooses which of a room's [aliases] it publishes, and which one of those is canonical.
|
||||
class const AliasesSheet({
|
||||
required final ISet<String> aliases,
|
||||
required final String? canonical,
|
||||
required final ISet<String> alternatives,
|
||||
|
||||
/// The canonical alias, and the other published ones.
|
||||
required final void Function(String? canonical, ISet<String> alternatives)
|
||||
onSave,
|
||||
super.key,
|
||||
}) extends HookWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final chosenCanonical = useState(canonical);
|
||||
final published = useState(alternatives.addAll([?canonical]));
|
||||
|
||||
void setPublished(String alias, bool isPublished) {
|
||||
published.value = isPublished
|
||||
? published.value.add(alias)
|
||||
: published.value.remove(alias);
|
||||
if (!isPublished && chosenCanonical.value == alias) {
|
||||
chosenCanonical.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
return SheetLayout(
|
||||
title: Text("Aliases"),
|
||||
actions: [
|
||||
TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
onSave(
|
||||
chosenCanonical.value,
|
||||
published.value.removeAll([?chosenCanonical.value]),
|
||||
);
|
||||
},
|
||||
child: Text("Save"),
|
||||
),
|
||||
],
|
||||
children: [
|
||||
Text(
|
||||
"Star an item to set it as the room's canonical alias, the alias used to advertise and access the room",
|
||||
),
|
||||
Text(
|
||||
"Other checked items will be set as alternate aliases, which can still be used to join/access the room but are not as prominently advertised.",
|
||||
),
|
||||
if (aliases.isEmpty)
|
||||
Text("No aliases, add a local alias first")
|
||||
else
|
||||
...aliases.map(
|
||||
(alias) => CheckboxListTile(
|
||||
value: published.value.contains(alias),
|
||||
onChanged: (checked) => setPublished(alias, checked ?? false),
|
||||
title: Text(alias, overflow: .ellipsis),
|
||||
secondary: IconButton(
|
||||
tooltip: chosenCanonical.value == alias
|
||||
? "Canonical alias"
|
||||
: "Make canonical alias",
|
||||
isSelected: chosenCanonical.value == alias,
|
||||
icon: Icon(Icons.star_border),
|
||||
selectedIcon: Icon(Icons.star),
|
||||
onPressed: () {
|
||||
chosenCanonical.value = alias;
|
||||
setPublished(alias, true);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
138
lib/widgets/settings/allowed_rooms_sheet.dart
Normal file
138
lib/widgets/settings/allowed_rooms_sheet.dart
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import "package:collection/collection.dart";
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/main.dart";
|
||||
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||
import "package:nexus/widgets/settings/search_chooser.dart";
|
||||
import "package:nexus/widgets/sheet_layout.dart";
|
||||
|
||||
/// Chooses the rooms whose members can join [roomId] without an invite.
|
||||
class const AllowedRoomsSheet({
|
||||
required final String roomId,
|
||||
required final ISet<String> allowed,
|
||||
|
||||
/// Suggested first, as they're the usual choice.
|
||||
required final ISet<String> parentSpaces,
|
||||
required final void Function(ISet<String> allowed) onSave,
|
||||
super.key,
|
||||
}) extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final chosen = useState(allowed);
|
||||
final error = useState<String?>(null);
|
||||
final resolving = useState(false);
|
||||
final controller = useTextEditingController();
|
||||
final rooms = ref.watch(
|
||||
RoomsController.provider.select(
|
||||
(rooms) => IMap.fromEntries(
|
||||
rooms.values
|
||||
.map((room) => room.metadata)
|
||||
.nonNulls
|
||||
.map(
|
||||
(metadata) => MapEntry(metadata.id, (
|
||||
name: metadata.name,
|
||||
avatar: metadata.avatar,
|
||||
)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
String nameOf(String id) => rooms[id]?.name ?? id;
|
||||
|
||||
final suggestions = parentSpaces
|
||||
.addAll(
|
||||
rooms.entries
|
||||
.where((entry) => !parentSpaces.contains(entry.key))
|
||||
.sortedBy((entry) => entry.value.name?.toLowerCase() ?? "")
|
||||
.map((entry) => entry.key),
|
||||
)
|
||||
.where((id) => id != roomId)
|
||||
.toIList();
|
||||
|
||||
void add(String id) {
|
||||
if (id == roomId) {
|
||||
error.value = "A room can't allow itself";
|
||||
return;
|
||||
}
|
||||
if (!chosen.value.contains(id)) chosen.value = chosen.value.add(id);
|
||||
controller.clear();
|
||||
}
|
||||
|
||||
Future<void> addRoom(String text) async {
|
||||
if (text.startsWith("!")) return add(text);
|
||||
|
||||
resolving.value = true;
|
||||
try {
|
||||
final id = await ref
|
||||
.read(ClientController.provider.notifier)
|
||||
.resolveAlias(text);
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (id == null) {
|
||||
error.value = "No room has this alias";
|
||||
} else {
|
||||
add(id);
|
||||
}
|
||||
} finally {
|
||||
if (context.mounted) resolving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return SheetLayout(
|
||||
title: Text("Allowed Rooms"),
|
||||
actions: [
|
||||
TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
onSave(chosen.value);
|
||||
},
|
||||
child: Text("Save"),
|
||||
),
|
||||
],
|
||||
children: [
|
||||
Text("Members of these rooms can join without an invite."),
|
||||
SearchChooser(
|
||||
controller: controller,
|
||||
chosen: chosen.value,
|
||||
options: suggestions,
|
||||
nameOf: nameOf,
|
||||
avatarOf: (id) => AvatarOrHash(
|
||||
rooms[id]?.avatar,
|
||||
nameOf(id),
|
||||
fallback: Icon(Icons.numbers),
|
||||
),
|
||||
subtitleOf: (id) => parentSpaces.contains(id)
|
||||
? "Parent space"
|
||||
: rooms[id]?.name == null
|
||||
? null
|
||||
: id,
|
||||
onAdded: add,
|
||||
onRemoved: (id) => chosen.value = chosen.value.remove(id),
|
||||
onSubmitted: (text, topMatch) {
|
||||
if (text.startsWith(RegExp("[!#]"))) {
|
||||
addRoom(text).onError(showError);
|
||||
} else if (topMatch != null) {
|
||||
add(topMatch);
|
||||
} else {
|
||||
error.value = "Enter a room ID or alias";
|
||||
}
|
||||
},
|
||||
onChanged: (_) => error.value = null,
|
||||
label: "Add room",
|
||||
hint: "Search, or enter a room ID or alias",
|
||||
removeTooltip: "Remove room",
|
||||
emptyText: "None, so only invited users can join",
|
||||
error: error.value,
|
||||
loading: resolving.value,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
import "dart:async";
|
||||
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:nexus/helpers/hooks/saving.dart";
|
||||
import "package:nexus/widgets/settings/radio_sheet.dart";
|
||||
|
||||
final class const DialogListTile<T>({
|
||||
|
|
@ -8,32 +11,44 @@ final class const DialogListTile<T>({
|
|||
required final Icon icon,
|
||||
required final String title,
|
||||
required final List<T> options,
|
||||
required final void Function(T value)? onChanged,
|
||||
|
||||
/// While a returned future runs, the tile is disabled.
|
||||
required final FutureOr<void> Function(T value)? onChanged,
|
||||
required final String Function(T option) getName,
|
||||
final Widget? subtitle,
|
||||
super.key,
|
||||
}) extends ConsumerWidget {
|
||||
}) extends HookWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) => ListTile(
|
||||
enabled: onChanged != null,
|
||||
onTap: () => context.showAdaptiveSheet(
|
||||
(context) => RadioSheet<T>(
|
||||
title: title,
|
||||
getName: getName,
|
||||
onChanged: onChanged,
|
||||
value: value,
|
||||
options: options,
|
||||
Widget build(BuildContext context) {
|
||||
final saving = Saving.use();
|
||||
Future<void> change(T value) => saving.run(() => onChanged!(value));
|
||||
|
||||
return ListTile(
|
||||
enabled: onChanged != null && !saving.active,
|
||||
onTap: () => context.showAdaptiveSheet(
|
||||
(context) => RadioSheet<T>(
|
||||
title: title,
|
||||
getName: getName,
|
||||
onChanged: onChanged == null ? null : change,
|
||||
value: value,
|
||||
options: options,
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(title),
|
||||
subtitle: subtitle,
|
||||
leading: icon,
|
||||
trailing: Chip(
|
||||
label: Text(
|
||||
value == null ? "None" : getName(value as T),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: onChanged == null ? .new(color: Colors.grey) : null,
|
||||
title: Text(title),
|
||||
subtitle: subtitle,
|
||||
leading: icon,
|
||||
trailing: ConstrainedBox(
|
||||
constraints: const .new(maxWidth: 160),
|
||||
child: Chip(
|
||||
label: Text(
|
||||
value is T ? getName(value as T) : "None",
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: onChanged == null || saving.active
|
||||
? .new(color: Colors.grey)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
107
lib/widgets/settings/local_aliases_sheet.dart
Normal file
107
lib/widgets/settings/local_aliases_sheet.dart
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/controllers/client_state.dart";
|
||||
import "package:nexus/controllers/local_aliases.dart";
|
||||
import "package:nexus/helpers/extensions/better_when.dart";
|
||||
import "package:nexus/helpers/hooks/saving.dart";
|
||||
import "package:nexus/main.dart";
|
||||
import "package:nexus/models/gomuks_exception.dart";
|
||||
import "package:nexus/widgets/sheet_layout.dart";
|
||||
|
||||
class const LocalAliasesSheet(
|
||||
final String roomId, {
|
||||
|
||||
/// Called after an alias is deleted, e.g. to stop publishing it.
|
||||
final Future<void> Function(String alias)? onRemoved,
|
||||
super.key,
|
||||
}) extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final provider = LocalAliasesController.provider(roomId);
|
||||
final saving = Saving.use();
|
||||
final controller = useTextEditingController();
|
||||
final localpart = useValueListenable(controller).text.trim();
|
||||
final server = ref.watch(
|
||||
ClientStateController.provider.select(
|
||||
(state) => state?.userId?.split(":").skip(1).join(":"),
|
||||
),
|
||||
);
|
||||
|
||||
final error = useState<String?>(null);
|
||||
|
||||
void add() {
|
||||
if (localpart.isEmpty || server == null) return;
|
||||
saving
|
||||
.run(() async {
|
||||
try {
|
||||
await ref.read(provider.notifier).add("#$localpart:$server");
|
||||
controller.clear();
|
||||
} on GomuksException catch (exception) {
|
||||
// Taken, by this room or another, which is shown on the field
|
||||
if (exception.statusCode != 409) rethrow;
|
||||
if (context.mounted) error.value = "This alias already exists";
|
||||
}
|
||||
})
|
||||
.onError(showError);
|
||||
}
|
||||
|
||||
return SheetLayout(
|
||||
title: Text("Local Aliases"),
|
||||
actions: [
|
||||
TextButton(onPressed: Navigator.of(context).pop, child: Text("Close")),
|
||||
],
|
||||
children: [
|
||||
Text(
|
||||
"Addresses for the room on your server. Publish them to show them to others.",
|
||||
),
|
||||
ref
|
||||
.watch(provider)
|
||||
.betterWhen(
|
||||
data: (aliases) => aliases.isEmpty
|
||||
? Text("No local aliases")
|
||||
: Column(
|
||||
children: aliases
|
||||
.map(
|
||||
(alias) => ListTile(
|
||||
title: Text(alias, overflow: .ellipsis),
|
||||
trailing: IconButton(
|
||||
tooltip: "Delete alias",
|
||||
icon: Icon(Icons.delete),
|
||||
onPressed: saving.active
|
||||
? null
|
||||
: () => saving
|
||||
.run(() async {
|
||||
await ref
|
||||
.read(provider.notifier)
|
||||
.remove(alias);
|
||||
await onRemoved?.call(alias);
|
||||
})
|
||||
.onError(showError),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
TextField(
|
||||
controller: controller,
|
||||
enabled: !saving.active,
|
||||
onChanged: (_) => error.value = null,
|
||||
onSubmitted: (_) => add(),
|
||||
decoration: .new(
|
||||
labelText: "New alias",
|
||||
errorText: error.value,
|
||||
prefixText: "#",
|
||||
suffixText: server == null ? null : ":$server",
|
||||
suffixIcon: IconButton(
|
||||
tooltip: "Add alias",
|
||||
icon: Icon(Icons.add),
|
||||
onPressed: localpart.isEmpty || saving.active ? null : add,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
55
lib/widgets/settings/power_level_entry_sheet.dart
Normal file
55
lib/widgets/settings/power_level_entry_sheet.dart
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/widgets/settings/power_level_field.dart";
|
||||
import "package:nexus/widgets/sheet_layout.dart";
|
||||
|
||||
/// Gives something, like an event type, its own power level.
|
||||
class const PowerLevelEntrySheet({
|
||||
required final String title,
|
||||
|
||||
/// The label of the field for what's given the power level.
|
||||
required final String keyLabel,
|
||||
required final String keyHint,
|
||||
|
||||
/// The user's own power level, which they can't set levels above.
|
||||
required final double ownLevel,
|
||||
required final void Function(String key, int level) onSave,
|
||||
super.key,
|
||||
}) extends HookWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final keyController = useTextEditingController();
|
||||
final levelController = useTextEditingController(text: "0");
|
||||
final key = useValueListenable(keyController).text.trim();
|
||||
final level = PowerLevelField.levelFrom(
|
||||
useValueListenable(levelController).text,
|
||||
ownLevel,
|
||||
);
|
||||
final valid = key.isNotEmpty && level != null;
|
||||
|
||||
void save() {
|
||||
Navigator.of(context).pop();
|
||||
onSave(key, level!);
|
||||
}
|
||||
|
||||
return SheetLayout(
|
||||
title: Text(title),
|
||||
actions: [
|
||||
TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")),
|
||||
TextButton(onPressed: valid ? save : null, child: Text("Save")),
|
||||
],
|
||||
children: [
|
||||
TextField(
|
||||
controller: keyController,
|
||||
autofocus: true,
|
||||
decoration: .new(labelText: keyLabel, hintText: keyHint),
|
||||
),
|
||||
PowerLevelField(
|
||||
controller: levelController,
|
||||
ownLevel: ownLevel,
|
||||
onSubmitted: valid ? save : null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
48
lib/widgets/settings/power_level_field.dart
Normal file
48
lib/widgets/settings/power_level_field.dart
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import "package:flutter/services.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
|
||||
/// A field for a power level, which can't be above the user's own.
|
||||
class const PowerLevelField({
|
||||
required final TextEditingController controller,
|
||||
|
||||
/// The user's own power level, which they can't set levels above.
|
||||
required final double ownLevel,
|
||||
final VoidCallback? onSubmitted,
|
||||
final bool autofocus = false,
|
||||
super.key,
|
||||
}) extends HookWidget {
|
||||
/// The furthest from zero Matrix allows integers to be.
|
||||
static const maxMagnitude = (2 ^ 53) - 1;
|
||||
|
||||
/// The level in [text], if it's one someone with [ownLevel] can set.
|
||||
static int? levelFrom(String text, double ownLevel) => switch (int.tryParse(
|
||||
text,
|
||||
)) {
|
||||
final level? when level.abs() <= maxMagnitude && level <= ownLevel => level,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final text = useValueListenable(controller).text;
|
||||
|
||||
return TextField(
|
||||
controller: controller,
|
||||
autofocus: autofocus,
|
||||
keyboardType: .numberWithOptions(signed: true),
|
||||
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r"^-?\d*"))],
|
||||
onSubmitted: onSubmitted == null ? null : (_) => onSubmitted!(),
|
||||
decoration: .new(
|
||||
labelText: "Power level",
|
||||
errorText: switch (int.tryParse(text)) {
|
||||
_ when text.isEmpty || text == "-" => null,
|
||||
final level? when level.abs() <= maxMagnitude =>
|
||||
level > ownLevel ? "Can't be above your own power level" : null,
|
||||
// Including ones too far from zero to even parse
|
||||
_ => "Must be from -$maxMagnitude to $maxMagnitude",
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
120
lib/widgets/settings/power_level_tile.dart
Normal file
120
lib/widgets/settings/power_level_tile.dart
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
|
||||
import "package:nexus/widgets/settings/power_level_field.dart";
|
||||
import "package:nexus/widgets/sheet_layout.dart";
|
||||
|
||||
/// Sets the power level needed for something.
|
||||
class const PowerLevelTile({
|
||||
required final Widget leading,
|
||||
required final String title,
|
||||
required final String? description,
|
||||
required final int value,
|
||||
|
||||
/// The user's own power level, which they can't set or change levels above.
|
||||
required final double ownLevel,
|
||||
|
||||
/// Disables the tile if null.
|
||||
required final void Function(int value)? onChanged,
|
||||
|
||||
/// Shows a button to remove the level if given, disabled along with the tile.
|
||||
final VoidCallback? onRemoved,
|
||||
|
||||
/// If given, asked before removing the level.
|
||||
final String? removeConfirmation,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final enabled = onChanged != null && value <= ownLevel;
|
||||
|
||||
Future<void> remove() async {
|
||||
if (removeConfirmation case final confirmation?) {
|
||||
final confirmed = await context.showAdaptiveSheet<bool>(
|
||||
(context) => SheetLayout(
|
||||
title: Text("Remove Power Level"),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text("Cancel"),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
child: Text("Remove"),
|
||||
),
|
||||
],
|
||||
children: [Text(confirmation)],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
}
|
||||
onRemoved!();
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
enabled: enabled,
|
||||
leading: leading,
|
||||
title: Text(title),
|
||||
subtitle: description == null ? null : Text(description!),
|
||||
trailing: Row(
|
||||
mainAxisSize: .min,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Chip(
|
||||
label: Text(
|
||||
"$value",
|
||||
style: enabled ? null : .new(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
if (onRemoved != null)
|
||||
IconButton(
|
||||
tooltip: "Remove",
|
||||
icon: Icon(Icons.delete),
|
||||
onPressed: enabled ? remove : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () => context.showAdaptiveSheet(
|
||||
(context) => HookBuilder(
|
||||
builder: (context) {
|
||||
final controller = useTextEditingController(text: "$value");
|
||||
final level = PowerLevelField.levelFrom(
|
||||
useValueListenable(controller).text,
|
||||
ownLevel,
|
||||
);
|
||||
|
||||
void save() {
|
||||
Navigator.of(context).pop();
|
||||
if (level != value) onChanged!(level!);
|
||||
}
|
||||
|
||||
return SheetLayout(
|
||||
title: Text(title),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: Navigator.of(context).pop,
|
||||
child: Text("Cancel"),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: level == null ? null : save,
|
||||
child: Text("Save"),
|
||||
),
|
||||
],
|
||||
children: [
|
||||
PowerLevelField(
|
||||
controller: controller,
|
||||
ownLevel: ownLevel,
|
||||
autofocus: true,
|
||||
onSubmitted: level == null ? null : save,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -12,7 +12,8 @@ final class const RadioSheet<T>({
|
|||
}) extends HookWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final mutValue = useState<T?>(null);
|
||||
// Wrapped, so a null option can be chosen, rather than counting as nothing being chosen yet
|
||||
final chosen = useState<(T,)?>(null);
|
||||
return SheetLayout(
|
||||
title: Text(title),
|
||||
actions: [
|
||||
|
|
@ -20,7 +21,7 @@ final class const RadioSheet<T>({
|
|||
if (onChanged != null)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
if (mutValue.value != null) onChanged!(mutValue.value as T);
|
||||
if (chosen.value case (final picked,)) onChanged!(picked);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text("OK"),
|
||||
|
|
@ -28,8 +29,11 @@ final class const RadioSheet<T>({
|
|||
],
|
||||
children: [
|
||||
RadioGroup<T>(
|
||||
groupValue: mutValue.value ?? value,
|
||||
onChanged: (value) => mutValue.value = value ?? mutValue.value,
|
||||
groupValue: switch (chosen.value) {
|
||||
(final picked,) => picked,
|
||||
null => value,
|
||||
},
|
||||
onChanged: (value) => chosen.value = (value as T,),
|
||||
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
|
|
|||
142
lib/widgets/settings/search_chooser.dart
Normal file
142
lib/widgets/settings/search_chooser.dart
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
|
||||
/// The [chosen] items, above a field searching [options] to choose more.
|
||||
class const SearchChooser({
|
||||
required final TextEditingController controller,
|
||||
required final ISet<String> chosen,
|
||||
|
||||
required final IList<String> options,
|
||||
|
||||
required final String Function(String id) nameOf,
|
||||
required final Widget Function(String id) avatarOf,
|
||||
required final String? Function(String id) subtitleOf,
|
||||
required final void Function(String id) onAdded,
|
||||
required final void Function(String id) onRemoved,
|
||||
|
||||
/// The submitted text, and the top suggestion matching it if there is one.
|
||||
required final void Function(String text, String? topMatch) onSubmitted,
|
||||
required final String label,
|
||||
required final String hint,
|
||||
required final String removeTooltip,
|
||||
|
||||
final String? emptyText,
|
||||
final String? error,
|
||||
final bool loading = false,
|
||||
final bool autofocus = false,
|
||||
final void Function(String text)? onChanged,
|
||||
super.key,
|
||||
}) extends HookWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final query = useValueListenable(controller).text.trim().toLowerCase();
|
||||
final shown = options
|
||||
.where(
|
||||
(id) =>
|
||||
!chosen.contains(id) &&
|
||||
(nameOf(id).toLowerCase().contains(query) ||
|
||||
id.toLowerCase().contains(query)),
|
||||
)
|
||||
.toIList();
|
||||
|
||||
ListTile tileOf(String id, {Widget? trailing, VoidCallback? onTap}) =>
|
||||
ListTile(
|
||||
leading: avatarOf(id),
|
||||
title: Text(nameOf(id), overflow: .ellipsis),
|
||||
subtitle: switch (subtitleOf(id)) {
|
||||
final subtitle? => Text(subtitle, overflow: .ellipsis),
|
||||
null => null,
|
||||
},
|
||||
trailing: trailing,
|
||||
onTap: onTap,
|
||||
);
|
||||
|
||||
AnimatedSize animated(Widget? child) => AnimatedSize(
|
||||
duration: Durations.medium2,
|
||||
curve: Curves.easeInOut,
|
||||
alignment: .topCenter,
|
||||
child: child ?? const SizedBox(width: .infinity),
|
||||
);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: .min,
|
||||
crossAxisAlignment: .stretch,
|
||||
children: [
|
||||
animated(switch ((chosen.isEmpty, emptyText)) {
|
||||
(true, null) => null,
|
||||
(true, final emptyText?) => Padding(
|
||||
padding: .only(bottom: 12),
|
||||
child: Text(emptyText),
|
||||
),
|
||||
(false, _) => Padding(
|
||||
padding: .only(bottom: 12),
|
||||
child: Column(
|
||||
children: chosen
|
||||
.map(
|
||||
(id) => tileOf(
|
||||
id,
|
||||
trailing: IconButton(
|
||||
tooltip: removeTooltip,
|
||||
icon: Icon(Icons.delete),
|
||||
onPressed: () => onRemoved(id),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
}),
|
||||
TextField(
|
||||
controller: controller,
|
||||
enabled: !loading,
|
||||
autofocus: autofocus,
|
||||
onChanged: onChanged,
|
||||
onSubmitted: (text) {
|
||||
final trimmed = text.trim();
|
||||
if (trimmed.isNotEmpty) onSubmitted(trimmed, shown.firstOrNull);
|
||||
},
|
||||
decoration: .new(
|
||||
labelText: label,
|
||||
hintText: hint,
|
||||
errorText: error,
|
||||
suffixIcon: loading
|
||||
? Padding(
|
||||
padding: .all(12),
|
||||
child: SizedBox.square(
|
||||
dimension: 24,
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
animated(
|
||||
shown.isEmpty
|
||||
? null
|
||||
: Padding(
|
||||
padding: .only(top: 12, bottom: 4),
|
||||
child: Material(
|
||||
elevation: 4,
|
||||
borderRadius: .circular(12),
|
||||
clipBehavior: .antiAlias,
|
||||
child: ConstrainedBox(
|
||||
constraints: const .new(maxHeight: 240),
|
||||
child: ListView.builder(
|
||||
padding: .zero,
|
||||
shrinkWrap: true,
|
||||
itemExtent: 72,
|
||||
itemCount: shown.length,
|
||||
itemBuilder: (context, index) => tileOf(
|
||||
shown[index],
|
||||
onTap: () => onAdded(shown[index]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
256
lib/widgets/settings/settings_layout.dart
Normal file
256
lib/widgets/settings/settings_layout.dart
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
import "package:collection/collection.dart";
|
||||
import "package:fast_immutable_collections/fast_immutable_collections.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/misc.dart";
|
||||
import "package:m3e_card_list/m3e_card_list.dart";
|
||||
import "package:navigation_rail_m3e/navigation_rail_m3e.dart";
|
||||
import "package:nexus/helpers/extensions/better_when.dart";
|
||||
import "package:nexus/models/settings_category.dart";
|
||||
import "package:nexus/widgets/pages/settings_category.dart";
|
||||
import "package:nexus/widgets/divider_text.dart";
|
||||
import "package:nexus/widgets/highlight_wrapper.dart";
|
||||
import "package:super_sliver_list/super_sliver_list.dart";
|
||||
|
||||
/// A settings screen split into categories: a list of them that opens each
|
||||
/// as its own page on narrow screens, or a rail beside the selected one.
|
||||
class const SettingsLayout({
|
||||
required final String title,
|
||||
required final ProviderListenable<
|
||||
AsyncValue<IMap<String, IList<SettingsCategory>>>
|
||||
>
|
||||
sectionsProvider,
|
||||
final List<Widget> actions = const [],
|
||||
super.key,
|
||||
}) extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) => LayoutBuilder(
|
||||
builder: (_, constraints) => HookBuilder(
|
||||
builder: (context) {
|
||||
final categoriesArePages = constraints.maxWidth < 550;
|
||||
|
||||
final selected = useState(0);
|
||||
|
||||
final highlightedMatch = useState<(int, int)?>(null);
|
||||
final listController = useRef(ListController());
|
||||
final scrollController = useScrollController();
|
||||
|
||||
final searchBar = SearchAnchor.bar(
|
||||
barHintText: "Search...",
|
||||
isFullScreen: categoriesArePages,
|
||||
suggestionsBuilder: (suggestionsContext, controller) async {
|
||||
// Only searched once shown, by which point these have loaded
|
||||
final categories =
|
||||
ref.read(sectionsProvider).value ?? const IMap.empty();
|
||||
final query = controller.text.toLowerCase();
|
||||
|
||||
final matches = categories.values.flattenedToList
|
||||
.asMap()
|
||||
.entries
|
||||
.expand(
|
||||
(categoryEntry) => categoryEntry.value.settings
|
||||
.asMap()
|
||||
.entries
|
||||
.where(
|
||||
(settingEntry) =>
|
||||
settingEntry.value.title.toLowerCase().contains(
|
||||
query,
|
||||
) ||
|
||||
settingEntry.value.description
|
||||
.toLowerCase()
|
||||
.contains(query),
|
||||
)
|
||||
.map(
|
||||
(settingEntry) => (
|
||||
(categoryEntry.key, settingEntry.key),
|
||||
settingEntry.value,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toIList();
|
||||
|
||||
return matches.map(
|
||||
(match) => ListTile(
|
||||
onTap: () async {
|
||||
if (context.mounted) Navigator.of(suggestionsContext).pop();
|
||||
controller.text = "";
|
||||
|
||||
if (categoriesArePages) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => SettingsCategoryPage(
|
||||
match.$1.$1,
|
||||
sectionsProvider: sectionsProvider,
|
||||
initialHighlight: match.$1.$2,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
selected.value = match.$1.$1;
|
||||
listController.value.animateToItem(
|
||||
index: match.$1.$2,
|
||||
scrollController: scrollController,
|
||||
alignment: 0.5,
|
||||
duration: (_) => Durations.extralong1,
|
||||
curve: (_) => Curves.easeInOut,
|
||||
);
|
||||
highlightedMatch.value = match.$1;
|
||||
await Future.delayed(Durations.extralong4, () {
|
||||
if (highlightedMatch.value == match.$1) {
|
||||
highlightedMatch.value = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
leading: Icon(match.$2.icon),
|
||||
title: Text(match.$2.title),
|
||||
subtitle: Text(match.$2.description),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
final settingsContent = Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(title),
|
||||
actionsPadding: .symmetric(horizontal: 12),
|
||||
actions: actions,
|
||||
),
|
||||
body: ref
|
||||
.watch(sectionsProvider)
|
||||
.betterWhen(
|
||||
skipLoadingOnReload: true,
|
||||
data: (sections) => categoriesArePages
|
||||
? CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(12).copyWith(bottom: 8),
|
||||
child: searchBar,
|
||||
),
|
||||
),
|
||||
...sections
|
||||
.mapTo(
|
||||
(section, categories) => [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
).copyWith(bottom: 4),
|
||||
child: DividerText(section),
|
||||
),
|
||||
),
|
||||
SliverM3ECardList(
|
||||
padding: .symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 8,
|
||||
),
|
||||
margin: .symmetric(horizontal: 12),
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primaryContainer,
|
||||
itemCount: categories.length,
|
||||
onTap: (index) => Navigator.of(context)
|
||||
.push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
SettingsCategoryPage(
|
||||
sections
|
||||
.values
|
||||
.flattenedToList
|
||||
.indexOf(
|
||||
categories[index],
|
||||
),
|
||||
sectionsProvider:
|
||||
sectionsProvider,
|
||||
),
|
||||
),
|
||||
),
|
||||
itemBuilder: (context, index) => ListTile(
|
||||
leading: Icon(categories[index].icon),
|
||||
title: Text(categories[index].title),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
.flattened,
|
||||
],
|
||||
)
|
||||
: Row(
|
||||
children: [
|
||||
MaterialUiCompatibilityBridge(
|
||||
child: NavigationRailM3E(
|
||||
type: .alwaysExpand,
|
||||
trailing: searchBar,
|
||||
scrollable: true,
|
||||
sections: sections
|
||||
.mapTo(
|
||||
(
|
||||
categoryGroup,
|
||||
categories,
|
||||
) => NavigationRailM3ESection(
|
||||
header: DividerText(categoryGroup),
|
||||
destinations: categories
|
||||
.map(
|
||||
(category) =>
|
||||
NavigationRailM3EDestination(
|
||||
icon: Icon(category.icon),
|
||||
label: category.title,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
selectedIndex: selected.value,
|
||||
onDestinationSelected: (value) =>
|
||||
selected.value = value,
|
||||
),
|
||||
),
|
||||
VerticalDivider(),
|
||||
Expanded(
|
||||
child: SuperListView(
|
||||
listController: listController.value,
|
||||
controller: scrollController,
|
||||
padding: .symmetric(vertical: 12),
|
||||
children: sections
|
||||
.values
|
||||
.flattenedToList[selected.value]
|
||||
.settings
|
||||
.mapIndexed(
|
||||
(index, setting) => Padding(
|
||||
padding: .only(bottom: 4),
|
||||
child: HighlightWrapper(
|
||||
setting.builder(
|
||||
setting.title,
|
||||
setting.description,
|
||||
setting.icon,
|
||||
),
|
||||
isHighlighted:
|
||||
highlightedMatch.value ==
|
||||
(selected.value, index),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return constraints.maxWidth < 650
|
||||
? settingsContent
|
||||
: Dialog(
|
||||
constraints: .loose(Size(900, 600)),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadiusGeometry.circular(12),
|
||||
child: settingsContent,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
70
lib/widgets/settings/text_setting_tile.dart
Normal file
70
lib/widgets/settings/text_setting_tile.dart
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import "dart:async";
|
||||
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
|
||||
import "package:nexus/helpers/hooks/saving.dart";
|
||||
import "package:nexus/widgets/sheet_layout.dart";
|
||||
|
||||
class const TextSettingTile({
|
||||
required final Icon icon,
|
||||
required final String title,
|
||||
required final String description,
|
||||
required final String? value,
|
||||
|
||||
/// Disables the tile if null, and while a returned future runs.
|
||||
required final FutureOr<void> Function(String value)? onChanged,
|
||||
final bool multiline = false,
|
||||
super.key,
|
||||
}) extends HookWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final saving = Saving.use();
|
||||
Future<void> change(String value) => saving.run(() => onChanged!(value));
|
||||
|
||||
return ListTile(
|
||||
enabled: onChanged != null && !saving.active,
|
||||
leading: icon,
|
||||
title: Text(title),
|
||||
subtitle: Text(
|
||||
value?.isNotEmpty == true ? value! : description,
|
||||
maxLines: 2,
|
||||
overflow: .ellipsis,
|
||||
),
|
||||
onTap: () => context.showAdaptiveSheet(
|
||||
(context) => HookBuilder(
|
||||
builder: (context) {
|
||||
final controller = useTextEditingController(text: value);
|
||||
|
||||
void save() {
|
||||
Navigator.of(context).pop();
|
||||
change(controller.text.trim());
|
||||
}
|
||||
|
||||
return SheetLayout(
|
||||
title: Text(title),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: Navigator.of(context).pop,
|
||||
child: Text("Cancel"),
|
||||
),
|
||||
TextButton(onPressed: save, child: Text("Save")),
|
||||
],
|
||||
children: [
|
||||
TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
textCapitalization: .sentences,
|
||||
minLines: 1,
|
||||
maxLines: multiline ? 6 : 1,
|
||||
onSubmitted: multiline ? null : (_) => save(),
|
||||
decoration: .new(labelText: title),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -9,22 +9,34 @@ class const SheetLayout({
|
|||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: .only(bottom: MediaQuery.viewInsetsOf(context).bottom),
|
||||
child: SingleChildScrollView(
|
||||
padding: const .all(24),
|
||||
child: Column(
|
||||
mainAxisSize: .min,
|
||||
crossAxisAlignment: .stretch,
|
||||
spacing: 12,
|
||||
children: [
|
||||
if (title case final title?)
|
||||
DefaultTextStyle(
|
||||
child: Column(
|
||||
mainAxisSize: .min,
|
||||
crossAxisAlignment: .stretch,
|
||||
children: [
|
||||
if (title case final title?)
|
||||
Padding(
|
||||
padding: const .fromLTRB(24, 24, 24, 0),
|
||||
child: DefaultTextStyle(
|
||||
style: Theme.of(context).textTheme.headlineSmall!,
|
||||
child: title,
|
||||
),
|
||||
...children,
|
||||
Row(mainAxisAlignment: .end, spacing: 8, children: actions),
|
||||
],
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
padding: .fromLTRB(24, title == null ? 24 : 12, 24, 12),
|
||||
child: Column(
|
||||
mainAxisSize: .min,
|
||||
crossAxisAlignment: .stretch,
|
||||
spacing: 12,
|
||||
children: children,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const .fromLTRB(24, 0, 24, 24),
|
||||
child: Row(mainAxisAlignment: .end, spacing: 8, children: actions),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import "package:nexus/models/content/create.dart";
|
|||
import "package:nexus/models/space_hierarchy.dart";
|
||||
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||
import "package:nexus/widgets/expandable_image.dart";
|
||||
import "package:nexus/widgets/knock_sheet.dart";
|
||||
import "package:nexus/widgets/loading.dart";
|
||||
import "package:nexus/widgets/room_summary_sheet.dart";
|
||||
import "package:nexus/widgets/sheet_layout.dart";
|
||||
|
|
@ -47,6 +48,14 @@ class const SpaceHierarchySheet(final String spaceId, {super.key})
|
|||
),
|
||||
child: const Text("Leave"),
|
||||
)
|
||||
: room.summary.mustKnock(joinedIds)
|
||||
? TextButton(
|
||||
onPressed: () => context.showAdaptiveSheet(
|
||||
(_) =>
|
||||
KnockSheet(room.summary.roomId, name: name, via: child.via),
|
||||
),
|
||||
child: const Text("Knock"),
|
||||
)
|
||||
: TextButton(
|
||||
onPressed: () => context.joinRoom(
|
||||
.new(roomIdOrAlias: room.summary.roomId, via: child.via),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import "package:collection/collection.dart";
|
||||
import "package:dynamic_color/dynamic_color.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/widgets/sheet_layout.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
|
|
@ -7,14 +8,18 @@ import "package:intl/intl.dart";
|
|||
import "package:m3e_buttons/m3e_buttons.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/controllers/client_state.dart";
|
||||
import "package:nexus/controllers/mutual_rooms.dart";
|
||||
import "package:nexus/controllers/power_level.dart";
|
||||
import "package:nexus/controllers/profile.dart";
|
||||
import "package:nexus/controllers/rooms.dart";
|
||||
import "package:nexus/controllers/user.dart";
|
||||
import "package:nexus/helpers/extensions/better_when.dart";
|
||||
import "package:nexus/helpers/extensions/create_room.dart";
|
||||
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
|
||||
import "package:nexus/helpers/extensions/get_localpart.dart";
|
||||
import "package:nexus/models/content/membership.dart";
|
||||
import "package:nexus/widgets/error_dialog.dart";
|
||||
import "package:nexus/widgets/loading.dart";
|
||||
import "package:nexus/widgets/mutual_rooms_sheet.dart";
|
||||
import "package:nexus/models/membership_action.dart";
|
||||
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||
import "package:nexus/main.dart";
|
||||
|
|
@ -22,7 +27,6 @@ import "package:nexus/widgets/expandable_image.dart";
|
|||
import "package:nexus/widgets/start_dm_sheet.dart";
|
||||
|
||||
final class const UserBottomSheet(
|
||||
final MembershipContent member,
|
||||
final String userId, {
|
||||
final String? roomId,
|
||||
super.key,
|
||||
|
|
@ -35,6 +39,11 @@ final class const UserBottomSheet(
|
|||
color: theme.colorScheme.onSurfaceVariant,
|
||||
);
|
||||
final client = ref.read(ClientController.provider.notifier);
|
||||
// Green for accepting knocks, harmonized so it fits the current theme
|
||||
final acceptScheme = ColorScheme.fromSeed(
|
||||
seedColor: Colors.green.harmonizeWith(theme.colorScheme.primary),
|
||||
brightness: theme.brightness,
|
||||
);
|
||||
|
||||
void showMembershipSheet(MembershipAction action) =>
|
||||
context.showAdaptiveSheet(
|
||||
|
|
@ -82,181 +91,248 @@ final class const UserBottomSheet(
|
|||
),
|
||||
);
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: .all(42),
|
||||
child: Column(
|
||||
spacing: 4,
|
||||
mainAxisSize: .min,
|
||||
crossAxisAlignment: .center,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: .end,
|
||||
children: [
|
||||
M3EButton(
|
||||
onPressed: Navigator.of(context).pop,
|
||||
child: Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 18),
|
||||
|
||||
ExpandableImage(
|
||||
member.avatarUrl == null ? null : .new(mxc: member.avatarUrl!),
|
||||
downloadFilename: member.displayName ?? userId.localpart,
|
||||
child: AvatarOrHash(
|
||||
member.avatarUrl,
|
||||
member.displayName ?? userId.localpart,
|
||||
dimension: 200,
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 8),
|
||||
|
||||
SelectableText(
|
||||
member.displayName ?? userId.localpart,
|
||||
style: textTheme.headlineLarge,
|
||||
textAlign: .center,
|
||||
),
|
||||
SelectableText(userId, textAlign: .center, style: subtitleStyle),
|
||||
|
||||
ref
|
||||
.watch(ProfileController.provider(userId))
|
||||
.betterWhen(
|
||||
loading: () => Text(""),
|
||||
data: (profileResponse) => Column(
|
||||
children: [
|
||||
if (profileResponse.profile.timezone == null &&
|
||||
profileResponse.profile.pronouns.isEmpty)
|
||||
Text(""),
|
||||
Wrap(
|
||||
crossAxisAlignment: .center,
|
||||
alignment: .center,
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
...profileResponse.profile.pronouns
|
||||
.where(
|
||||
// TODO: Check system language (l10n)
|
||||
(pronoun) => pronoun.language == "en",
|
||||
)
|
||||
.mapIndexed(
|
||||
(index, pronoun) => [
|
||||
if (index != 0)
|
||||
Icon(
|
||||
Icons.circle,
|
||||
size: 4,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
Text(
|
||||
pronoun.summary,
|
||||
textAlign: .center,
|
||||
style: subtitleStyle,
|
||||
),
|
||||
],
|
||||
)
|
||||
.flattened,
|
||||
|
||||
if (profileResponse.profile.timezone != null) ...[
|
||||
if (profileResponse.profile.pronouns.isNotEmpty)
|
||||
SizedBox(
|
||||
height: 16,
|
||||
child: VerticalDivider(
|
||||
thickness: 1.5,
|
||||
width: 4,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
profileResponse.profile.timezone!,
|
||||
textAlign: .center,
|
||||
style: subtitleStyle,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 8),
|
||||
if (userId !=
|
||||
ref.watch(ClientStateController.provider)?.userId) ...[
|
||||
return switch (ref.watch(
|
||||
UserController.provider(.new(roomId: roomId, userId: userId)),
|
||||
)) {
|
||||
AsyncError(:final error, :final stackTrace) => ErrorDialog(
|
||||
error,
|
||||
stackTrace,
|
||||
),
|
||||
AsyncData(value: final member) ||
|
||||
AsyncLoading(value: final member?) => SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: .all(42),
|
||||
child: Column(
|
||||
spacing: 4,
|
||||
mainAxisSize: .min,
|
||||
crossAxisAlignment: .center,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: .end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: M3EButton.icon(
|
||||
onPressed: () async {
|
||||
final name = member.displayName ?? userId.localpart;
|
||||
final hasDm = ref
|
||||
.read(RoomsController.provider)
|
||||
.values
|
||||
.any((room) => room.metadata?.dmUserId == userId);
|
||||
|
||||
if (hasDm) {
|
||||
context.startDirectMessage(userId, name: name);
|
||||
} else if (await context.showAdaptiveSheet<bool>(
|
||||
(_) => StartDmSheet(userId: userId, name: name),
|
||||
) !=
|
||||
true) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.mounted) Navigator.of(context).pop();
|
||||
},
|
||||
shape: .square,
|
||||
style: .tonal,
|
||||
icon: Icon(Icons.message),
|
||||
label: Text("Message"),
|
||||
),
|
||||
M3EButton(
|
||||
onPressed: Navigator.of(context).pop,
|
||||
child: Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 18),
|
||||
|
||||
if (roomId != null &&
|
||||
ref.watch(
|
||||
PowerLevelController.provider(
|
||||
.membershipAction(
|
||||
action: .kick,
|
||||
roomId: roomId!,
|
||||
targetUser: userId,
|
||||
ExpandableImage(
|
||||
member.avatarUrl == null ? null : .new(mxc: member.avatarUrl!),
|
||||
downloadFilename: member.displayName ?? userId.localpart,
|
||||
child: AvatarOrHash(
|
||||
member.avatarUrl,
|
||||
member.displayName ?? userId.localpart,
|
||||
dimension: 200,
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 8),
|
||||
|
||||
SelectableText(
|
||||
member.displayName ?? userId.localpart,
|
||||
style: textTheme.headlineLarge,
|
||||
textAlign: .center,
|
||||
),
|
||||
SelectableText(userId, textAlign: .center, style: subtitleStyle),
|
||||
|
||||
ref
|
||||
.watch(ProfileController.provider(userId))
|
||||
.betterWhen(
|
||||
loading: () => Text(""),
|
||||
data: (profileResponse) => Column(
|
||||
children: [
|
||||
Wrap(
|
||||
crossAxisAlignment: .center,
|
||||
alignment: .center,
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
...profileResponse.profile.pronouns
|
||||
.where(
|
||||
// TODO: Check system language (l10n)
|
||||
(pronoun) => pronoun.language == "en",
|
||||
)
|
||||
.mapIndexed(
|
||||
(index, pronoun) => [
|
||||
if (index != 0)
|
||||
Icon(
|
||||
Icons.circle,
|
||||
size: 4,
|
||||
color:
|
||||
theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
Text(
|
||||
pronoun.summary,
|
||||
textAlign: .center,
|
||||
style: subtitleStyle,
|
||||
),
|
||||
],
|
||||
)
|
||||
.flattened,
|
||||
|
||||
if (profileResponse.profile.timezone != null) ...[
|
||||
if (profileResponse.profile.pronouns.isNotEmpty)
|
||||
SizedBox(
|
||||
height: 16,
|
||||
child: VerticalDivider(
|
||||
thickness: 1.5,
|
||||
width: 4,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
profileResponse.profile.timezone!,
|
||||
textAlign: .center,
|
||||
style: subtitleStyle,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (userId != ref.watch(ClientStateController.provider)?.userId)
|
||||
switch (ref.watch(MutualRoomsController.provider(userId))) {
|
||||
AsyncData(value: final roomIds) => switch (ref.watch(
|
||||
RoomsController.provider.select(
|
||||
(rooms) => roomIds.where(rooms.containsKey).length,
|
||||
),
|
||||
)) {
|
||||
0 => SizedBox.shrink(),
|
||||
final count => TextButton(
|
||||
onPressed: () => context.showAdaptiveSheet(
|
||||
(_) => MutualRoomsSheet(userId),
|
||||
),
|
||||
child: Text(
|
||||
"$count mutual ${Intl.plural(count, one: "room", other: "rooms")}",
|
||||
),
|
||||
),
|
||||
))
|
||||
Padding(
|
||||
padding: .only(top: 4),
|
||||
child: Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
if (member.status == .join || member.status == .invite)
|
||||
M3EButton.icon(
|
||||
onPressed: () => showMembershipSheet(.kick),
|
||||
shape: .square,
|
||||
icon: Icon(Icons.sports_martial_arts),
|
||||
label: Text("Kick"),
|
||||
decoration: .styleFrom(
|
||||
backgroundColor: theme.colorScheme.error,
|
||||
foregroundColor: theme.colorScheme.onError,
|
||||
),
|
||||
),
|
||||
},
|
||||
_ => SizedBox.shrink(),
|
||||
},
|
||||
|
||||
M3EButton.icon(
|
||||
onPressed: () => showMembershipSheet(.ban),
|
||||
SizedBox(height: 8),
|
||||
if (userId !=
|
||||
ref.watch(ClientStateController.provider)?.userId) ...[
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: M3EButton.icon(
|
||||
onPressed: () async {
|
||||
final name = member.displayName ?? userId.localpart;
|
||||
final hasDm = ref
|
||||
.read(RoomsController.provider)
|
||||
.values
|
||||
.any((room) => room.metadata?.dmUserId == userId);
|
||||
|
||||
if (hasDm) {
|
||||
context.startDirectMessage(userId, name: name);
|
||||
} else if (await context.showAdaptiveSheet<bool>(
|
||||
(_) => StartDmSheet(userId: userId, name: name),
|
||||
) !=
|
||||
true) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.mounted) Navigator.of(context).pop();
|
||||
},
|
||||
shape: .square,
|
||||
icon: Icon(Icons.gavel),
|
||||
label: Text("Ban"),
|
||||
decoration: .styleFrom(
|
||||
backgroundColor: theme.colorScheme.errorContainer,
|
||||
foregroundColor: theme.colorScheme.onErrorContainer,
|
||||
style: .tonal,
|
||||
icon: Icon(Icons.message),
|
||||
label: Text("Message"),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (roomId != null &&
|
||||
member.status == .knock &&
|
||||
ref.watch(
|
||||
PowerLevelController.provider(
|
||||
.membershipAction(
|
||||
action: .invite,
|
||||
roomId: roomId!,
|
||||
targetUser: userId,
|
||||
),
|
||||
),
|
||||
].map((e) => Expanded(child: e)).toList(),
|
||||
))
|
||||
Padding(
|
||||
padding: .only(top: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: M3EButton.icon(
|
||||
onPressed: () => client
|
||||
.setMembership(
|
||||
.new(
|
||||
userId: userId,
|
||||
roomId: roomId!,
|
||||
action: .invite,
|
||||
),
|
||||
)
|
||||
.onError(showError),
|
||||
shape: .square,
|
||||
icon: Icon(Icons.check),
|
||||
label: Text("Accept knock"),
|
||||
decoration: .styleFrom(
|
||||
backgroundColor: acceptScheme.primary,
|
||||
foregroundColor: acceptScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (roomId != null &&
|
||||
ref.watch(
|
||||
PowerLevelController.provider(
|
||||
.membershipAction(
|
||||
action: .kick,
|
||||
roomId: roomId!,
|
||||
targetUser: userId,
|
||||
),
|
||||
),
|
||||
))
|
||||
Padding(
|
||||
padding: .only(top: 4),
|
||||
child: Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
if (member.status case .join || .invite || .knock)
|
||||
M3EButton.icon(
|
||||
onPressed: () => showMembershipSheet(.kick),
|
||||
shape: .square,
|
||||
icon: Icon(Icons.sports_martial_arts),
|
||||
label: Text("Kick"),
|
||||
decoration: .styleFrom(
|
||||
backgroundColor: theme.colorScheme.error,
|
||||
foregroundColor: theme.colorScheme.onError,
|
||||
),
|
||||
),
|
||||
|
||||
M3EButton.icon(
|
||||
onPressed: () => showMembershipSheet(.ban),
|
||||
shape: .square,
|
||||
icon: Icon(Icons.gavel),
|
||||
label: Text("Ban"),
|
||||
decoration: .styleFrom(
|
||||
backgroundColor: theme.colorScheme.errorContainer,
|
||||
foregroundColor: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
].map((e) => Expanded(child: e)).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
AsyncLoading _ => Loading(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,9 +12,7 @@
|
|||
#include <media_kit_libs_linux/media_kit_libs_linux_plugin.h>
|
||||
#include <media_kit_video/media_kit_video_plugin.h>
|
||||
#include <pasteboard/pasteboard_plugin.h>
|
||||
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
#include <window_manager/window_manager_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) app_links_linux_registrar =
|
||||
|
|
@ -35,13 +33,7 @@ void fl_register_plugins(FlPluginRegistry* registry) {
|
|||
g_autoptr(FlPluginRegistrar) pasteboard_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "PasteboardPlugin");
|
||||
pasteboard_plugin_register_with_registrar(pasteboard_registrar);
|
||||
g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin");
|
||||
screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) window_manager_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin");
|
||||
window_manager_plugin_register_with_registrar(window_manager_registrar);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,9 +9,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||
media_kit_libs_linux
|
||||
media_kit_video
|
||||
pasteboard
|
||||
screen_retriever_linux
|
||||
url_launcher_linux
|
||||
window_manager
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
|
|
|||
|
|
@ -36,7 +36,13 @@ pkgs.mkShell {
|
|||
})
|
||||
android.platform-tools
|
||||
]
|
||||
++ lib.optional pkgs.stdenv.isLinux wayland;
|
||||
++ lib.optionals pkgs.stdenv.isLinux [
|
||||
wayland
|
||||
# nativeapi includes XInput2.h, which needs these, but xi.pc only lists them privately
|
||||
libxext
|
||||
libxfixes
|
||||
(callPackage ./pkg/hook_pkg_config.nix { })
|
||||
];
|
||||
|
||||
env = rec {
|
||||
LIBCLANG_PATH = lib.makeLibraryPath [ pkgs.libclang ];
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
callPackage,
|
||||
mpv-unwrapped,
|
||||
libass,
|
||||
libxext,
|
||||
libxfixes,
|
||||
libclang,
|
||||
flutter,
|
||||
stdenv,
|
||||
|
|
@ -25,9 +27,14 @@ flutter.buildFlutterApplication {
|
|||
"--dart-define=GIT_COMMIT=${commit}"
|
||||
];
|
||||
|
||||
nativeBuildInputs = [ (callPackage ./hook_pkg_config.nix { }) ];
|
||||
|
||||
buildInputs = [
|
||||
mpv-unwrapped
|
||||
libass
|
||||
# nativeapi includes XInput2.h, which needs these, but xi.pc only lists them privately
|
||||
libxext
|
||||
libxfixes
|
||||
];
|
||||
|
||||
env.LIBCLANG_PATH = lib.makeLibraryPath [ libclang ];
|
||||
|
|
@ -42,9 +49,10 @@ flutter.buildFlutterApplication {
|
|||
m3e_design = "sha256-5V9m6yY0jimx2gh7lQHYg4HDKQ4Wrl0ATmBhw5DkQMI=";
|
||||
native_toolchain_cmake = "sha256-/drnHZebmTnsCnHgC1xmlAe+U4PSrjw5Sm9axicuTrY=";
|
||||
navigation_rail_m3e = "sha256-5V9m6yY0jimx2gh7lQHYg4HDKQ4Wrl0ATmBhw5DkQMI=";
|
||||
unifiedpush_linux = "sha256-aIF/8oabSuuZo7K6qr4r7UBmf57qgoGAXyJ40fdntuQ=";
|
||||
unifiedpush_platform_interface = "sha256-aIF/8oabSuuZo7K6qr4r7UBmf57qgoGAXyJ40fdntuQ=";
|
||||
unifiedpush_linux = "sha256-KQKyBPL2ORK9O89C4K3RHGve/lvFaU1vIjSDnj6+bj4=";
|
||||
unifiedpush_platform_interface = "sha256-KQKyBPL2ORK9O89C4K3RHGve/lvFaU1vIjSDnj6+bj4=";
|
||||
webcrypto = "sha256-HW0NukbddwOYd9iVUFA3L8iy1qggnyOBgcbMj0MTYss=";
|
||||
window_manager = "sha256-xUd5hG+/V6F+m0ZUL/3XGPACFG/zqI5JM6IaMVvLgx4=";
|
||||
xdg_desktop_portal = "sha256-8GgiXHGv+wy6hJgj/rZxbymMitNVmdDLAnedZm9STSc=";
|
||||
};
|
||||
|
||||
|
|
|
|||
32
linux/nix/pkg/hook_pkg_config.nix
Normal file
32
linux/nix/pkg/hook_pkg_config.nix
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
stdenvNoCC,
|
||||
pkg-config,
|
||||
pkg-config-unwrapped,
|
||||
makeWrapper,
|
||||
gtk3,
|
||||
libx11,
|
||||
libxi,
|
||||
}:
|
||||
|
||||
# Build hooks run with a filtered environment that drops PKG_CONFIG_PATH, so
|
||||
# nativeapi's hook can only find its libraries through a pkg-config on PATH.
|
||||
stdenvNoCC.mkDerivation {
|
||||
name = "hook-pkg-config";
|
||||
dontUnpack = true;
|
||||
|
||||
nativeBuildInputs = [
|
||||
pkg-config
|
||||
makeWrapper
|
||||
];
|
||||
|
||||
buildInputs = [
|
||||
gtk3
|
||||
libx11
|
||||
libxi
|
||||
];
|
||||
|
||||
installPhase = ''
|
||||
makeWrapper ${pkg-config-unwrapped}/bin/pkg-config $out/bin/pkg-config \
|
||||
--prefix PKG_CONFIG_PATH : "$PKG_CONFIG_PATH"
|
||||
'';
|
||||
}
|
||||
|
|
@ -155,11 +155,6 @@ static void my_application_activate(
|
|||
}
|
||||
#endif
|
||||
|
||||
gtk_widget_set_size_request(
|
||||
GTK_WIDGET(window),
|
||||
250,
|
||||
-1);
|
||||
|
||||
if (use_header_bar) {
|
||||
GtkHeaderBar* header_bar =
|
||||
GTK_HEADER_BAR(
|
||||
|
|
|
|||
|
|
@ -13,11 +13,9 @@ import media_kit_libs_macos_video
|
|||
import media_kit_video
|
||||
import package_info_plus
|
||||
import pasteboard
|
||||
import screen_retriever_macos
|
||||
import shared_preferences_foundation
|
||||
import url_launcher_macos
|
||||
import wakelock_plus
|
||||
import window_manager
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
|
||||
|
|
@ -28,9 +26,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||
MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
PasteboardPlugin.register(with: registry.registrar(forPlugin: "PasteboardPlugin"))
|
||||
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
|
||||
WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin"))
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue