Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
d4ae5ebccf |
|||
|
850bd33b18 |
|||
|
8fbfcde9c5 |
|||
|
e0b5dc58d0 |
|||
|
ffbf2722f7 |
|||
|
3e1552f246 |
|||
|
44a1ec667e |
|||
|
479279b68e |
|||
|
b0dd452926 |
|||
|
fcfe4eed1e |
|||
|
b390dbbbe1 |
|||
|
8e51852ae3 |
|||
|
48c04d9b3e |
|||
|
7f30150e95 |
|||
|
c8e1aef74b |
|||
|
5ee3449f02 |
|||
|
1715ee785e |
|||
|
e38307657d |
|||
|
e14ab3e708 |
|||
|
f5645db6ea |
|||
|
0fcbc275cd |
|||
|
a54004736b |
|||
|
427e06be3e |
|||
|
a1a15af30d |
|||
| 800fb5566d | |||
|
80197aff00 |
|||
|
78162efbde |
109 changed files with 4787 additions and 927 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:ffi/ffi.dart";
|
||||||
import "package:flutter/cupertino.dart";
|
import "package:flutter/cupertino.dart";
|
||||||
import "package:intl/intl.dart";
|
import "package:intl/intl.dart";
|
||||||
|
import "package:nexus/controllers/settings.dart";
|
||||||
import "package:nexus/helpers/extensions/gomuks_buffer.dart";
|
import "package:nexus/helpers/extensions/gomuks_buffer.dart";
|
||||||
import "package:nexus/models/capabilities.dart";
|
import "package:nexus/models/capabilities.dart";
|
||||||
import "package:nexus/models/content/message.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/redact_event.dart";
|
||||||
import "package:nexus/models/requests/register_pusher.dart";
|
import "package:nexus/models/requests/register_pusher.dart";
|
||||||
import "package:nexus/models/requests/report.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_event.dart";
|
||||||
import "package:nexus/models/requests/send_message.dart";
|
import "package:nexus/models/requests/send_message.dart";
|
||||||
import "package:nexus/models/requests/set_account_data.dart";
|
import "package:nexus/models/requests/set_account_data.dart";
|
||||||
|
|
@ -105,39 +107,50 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
return handle;
|
return handle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Calls gomuks with [data] as JSON, returning its decoded response.
|
||||||
Future<dynamic> callGomuksMethod(
|
Future<dynamic> callGomuksMethod(
|
||||||
Map<String, dynamic> data,
|
Map<String, dynamic> data,
|
||||||
FutureOr<GomuksResponse> Function(int handle, GomuksBorrowedBuffer data)
|
void Function(
|
||||||
callback,
|
int handle,
|
||||||
|
GomuksOwnedBuffer data,
|
||||||
|
Pointer<NativeFunction<Void Function(GomuksResponse)>> callback,
|
||||||
|
)
|
||||||
|
call,
|
||||||
) async {
|
) 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 {
|
try {
|
||||||
final handle = await future;
|
call(handle, buffer, callable.nativeFunction);
|
||||||
|
} catch (_) {
|
||||||
final response = await Isolate.run(
|
// gomuks never got the call, so it won't respond or free the buffer
|
||||||
() => callback(handle, bufferPointer.ref),
|
callable.close();
|
||||||
);
|
GomuksFreeBuffer(buffer);
|
||||||
|
rethrow;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return completer.future;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<PushNotification?> handlePush(Map<String, dynamic> data) async {
|
Future<PushNotification?> handlePush(Map<String, dynamic> data) async {
|
||||||
final response = await callGomuksMethod(
|
final response = await callGomuksMethod(data, GomuksHandlePushAsync);
|
||||||
data,
|
|
||||||
(handle, data) async => GomuksHandlePush(handle, data),
|
|
||||||
);
|
|
||||||
|
|
||||||
return response?["notification"] == null
|
return response?["notification"] == null
|
||||||
? null
|
? null
|
||||||
|
|
@ -147,11 +160,12 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
Future<dynamic> _sendCommand(
|
Future<dynamic> _sendCommand(
|
||||||
String command, [
|
String command, [
|
||||||
Map<String, dynamic> data = const {},
|
Map<String, dynamic> data = const {},
|
||||||
]) => callGomuksMethod(data, (handle, data) {
|
]) => callGomuksMethod(data, (handle, data, callback) {
|
||||||
final commandPointer = command.toNativeUtf8().cast<Char>();
|
final commandPointer = command.toNativeUtf8().cast<Char>();
|
||||||
try {
|
try {
|
||||||
return GomuksSubmitCommand(handle, commandPointer, data);
|
GomuksSubmitCommandAsync(handle, commandPointer, data, callback);
|
||||||
} finally {
|
} finally {
|
||||||
|
// gomuks copies the command name before returning
|
||||||
calloc.free(commandPointer);
|
calloc.free(commandPointer);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -222,6 +236,9 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
Future<String> joinRoom(JoinRoomRequest request) async =>
|
Future<String> joinRoom(JoinRoomRequest request) async =>
|
||||||
(await _sendCommand("join_room", request.toJson()))["room_id"];
|
(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 =>
|
Future<String> createRoom(CreateRoomRequest request) async =>
|
||||||
(await _sendCommand("create_room", request.toJson()))["room_id"];
|
(await _sendCommand("create_room", request.toJson()))["room_id"];
|
||||||
|
|
||||||
|
|
@ -273,6 +290,12 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
Future<PaginateManual> paginateManual(PaginateManualRequest request) async =>
|
Future<PaginateManual> paginateManual(PaginateManualRequest request) async =>
|
||||||
.fromJson(await _sendCommand("paginate_manual", request.toJson()));
|
.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 =>
|
Future<EventContext> getEventContext(GetEventContextRequest request) async =>
|
||||||
.fromJson(await _sendCommand("get_event_context", request.toJson()));
|
.fromJson(await _sendCommand("get_event_context", request.toJson()));
|
||||||
|
|
||||||
|
|
@ -284,6 +307,15 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setProfileField(String field, Object? value) => _sendCommand(
|
||||||
|
"set_profile_field",
|
||||||
|
{
|
||||||
|
"field": field,
|
||||||
|
// gomuks deletes the field when the value is omitted, not when it's null
|
||||||
|
"value": ?value,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
Future<void> reportEvent(ReportRequest request) =>
|
Future<void> reportEvent(ReportRequest request) =>
|
||||||
_sendCommand("report_event", request.toJson());
|
_sendCommand("report_event", request.toJson());
|
||||||
|
|
||||||
|
|
@ -310,6 +342,11 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
|
|
||||||
Future<void> logout() => _sendCommand("logout");
|
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 {
|
Future<void> markRead(Room room) async {
|
||||||
if (room.timeline.isEmpty || room.metadata == null) return;
|
if (room.timeline.isEmpty || room.metadata == null) return;
|
||||||
final eventRowId = room.timeline[room.timeline.keys.reduce(max)];
|
final eventRowId = room.timeline[room.timeline.keys.reduce(max)];
|
||||||
|
|
@ -317,9 +354,14 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
|
|
||||||
if (event == null) return;
|
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", {
|
await _sendCommand("mark_read", {
|
||||||
"room_id": room.metadata!.id,
|
"room_id": room.metadata!.id,
|
||||||
"receipt_type": "m.read",
|
"receipt_type": sendPublic ? "m.read" : "m.read.private",
|
||||||
"event_id": event.eventId,
|
"event_id": event.eventId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -338,6 +380,34 @@ class ClientController extends AsyncNotifier<int> {
|
||||||
Future<void> exchangeToken(OAuthExchangeTokenRequest request) async =>
|
Future<void> exchangeToken(OAuthExchangeTokenRequest request) async =>
|
||||||
await _sendCommand("oauth_exchange_token", request.toJson());
|
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 =>
|
Future<SpecVersionsResponse> getSpecVersions() async =>
|
||||||
.fromJson(await _sendCommand("get_versions"));
|
.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/space_edges.dart";
|
||||||
import "package:nexus/controllers/sync_status.dart";
|
import "package:nexus/controllers/sync_status.dart";
|
||||||
import "package:nexus/controllers/top_level_spaces.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/helpers/extensions/gomuks_buffer.dart";
|
||||||
import "package:nexus/main.dart";
|
import "package:nexus/main.dart";
|
||||||
import "package:ffi/ffi.dart";
|
import "package:ffi/ffi.dart";
|
||||||
|
|
@ -63,6 +64,10 @@ class GomuksListenerController extends AsyncNotifier<void> {
|
||||||
event.roomId: .new(events: .new({event.rowId: event})),
|
event.roomId: .new(events: .new({event.rowId: event})),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
case "typing":
|
||||||
|
ref
|
||||||
|
.read(TypingController.provider.notifier)
|
||||||
|
.set(.fromJson(decodedMuksEvent));
|
||||||
case "events_decrypted":
|
case "events_decrypted":
|
||||||
final decrypted = EventsDecrypted.fromJson(decodedMuksEvent);
|
final decrypted = EventsDecrypted.fromJson(decodedMuksEvent);
|
||||||
final metadata = ref
|
final metadata = ref
|
||||||
|
|
@ -96,6 +101,7 @@ class GomuksListenerController extends AsyncNotifier<void> {
|
||||||
ref.invalidate(roomProvider);
|
ref.invalidate(roomProvider);
|
||||||
ref.invalidate(accountDataProvider);
|
ref.invalidate(accountDataProvider);
|
||||||
ref.invalidate(invitesProvider);
|
ref.invalidate(invitesProvider);
|
||||||
|
ref.invalidate(TypingController.provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
ref
|
ref
|
||||||
|
|
@ -153,6 +159,7 @@ class GomuksListenerController extends AsyncNotifier<void> {
|
||||||
if (paused) {
|
if (paused) {
|
||||||
GomuksPauseSync(handle);
|
GomuksPauseSync(handle);
|
||||||
} else {
|
} else {
|
||||||
|
ref.invalidate(TypingController.provider);
|
||||||
GomuksResumeSync(handle);
|
GomuksResumeSync(handle);
|
||||||
}
|
}
|
||||||
}, fireImmediately: true);
|
}, 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:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/controllers/members_by_status.dart";
|
import "package:nexus/controllers/members_by_status.dart";
|
||||||
import "package:nexus/controllers/room_creators.dart";
|
import "package:nexus/controllers/room_power_levels.dart";
|
||||||
import "package:nexus/controllers/rooms.dart";
|
|
||||||
import "package:nexus/models/configs/members_by_status.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";
|
import "package:nexus/models/event.dart";
|
||||||
|
|
||||||
class MembersGroupedController(final MembersByStatusConfig config)
|
class MembersGroupedController(final MembersByStatusConfig config)
|
||||||
extends AsyncNotifier<IList<MapEntry<int?, ISet<Event>>>> {
|
extends AsyncNotifier<IList<MapEntry<int?, ISet<Event>>>> {
|
||||||
@override
|
@override
|
||||||
Future<IList<MapEntry<int?, ISet<Event>>>> build() async {
|
Future<IList<MapEntry<int?, ISet<Event>>>> build() async {
|
||||||
final room = ref.watch(
|
final powerLevels = ref.watch(
|
||||||
RoomsController.provider.select((value) => value[config.roomId]),
|
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(
|
final members = await ref.watch(
|
||||||
MembersByStatusController.provider(config).future,
|
MembersByStatusController.provider(config).future,
|
||||||
);
|
);
|
||||||
|
|
||||||
return members
|
return members
|
||||||
.fold<IMap<int?, ISet<Event>>>(.new(), (result, event) {
|
.fold<IMap<int?, ISet<Event>>>(.new(), (result, event) {
|
||||||
final groupKey = roomCreators?.contains(event.stateKey!) == true
|
final groupKey = powerLevels.creators.contains(event.stateKey!)
|
||||||
? null
|
? null
|
||||||
: content.users[event.stateKey!] ?? content.usersDefault;
|
: powerLevels.levelOf(event.stateKey!).toInt();
|
||||||
|
|
||||||
return result.update(
|
return result.update(
|
||||||
groupKey,
|
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:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/controllers/client.dart";
|
import "package:nexus/controllers/client.dart";
|
||||||
|
|
@ -9,26 +8,6 @@ typedef NotificationsRequest = (UnreadType? unreadType, String? roomId);
|
||||||
|
|
||||||
class NotificationsController(final NotificationsRequest request)
|
class NotificationsController(final NotificationsRequest request)
|
||||||
extends AsyncNotifier<IList<(int, String)>> {
|
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
|
@override
|
||||||
Future<IList<(int, String)>> build() async {
|
Future<IList<(int, String)>> build() async {
|
||||||
final client = ref.read(ClientController.provider.notifier);
|
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)));
|
return .new(mentions.events.map((event) => (event.rowId, event.roomId)));
|
||||||
}
|
}
|
||||||
|
|
@ -76,9 +57,11 @@ class NotificationsController(final NotificationsRequest request)
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
storeEvents(
|
ref
|
||||||
newNotifications.events.addAll(newNotifications.relatedEvents),
|
.read(RoomsController.provider.notifier)
|
||||||
);
|
.storeEvents(
|
||||||
|
newNotifications.events.addAll(newNotifications.relatedEvents),
|
||||||
|
);
|
||||||
|
|
||||||
return currentNotifications.addAll(
|
return currentNotifications.addAll(
|
||||||
newNotifications.events.map((event) => (event.rowId, event.roomId)),
|
newNotifications.events.map((event) => (event.rowId, event.roomId)),
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/controllers/client_state.dart";
|
import "package:nexus/controllers/client_state.dart";
|
||||||
import "package:nexus/controllers/room_creators.dart";
|
import "package:nexus/controllers/room_power_levels.dart";
|
||||||
import "package:nexus/controllers/rooms.dart";
|
|
||||||
import "package:nexus/models/configs/power_level.dart";
|
import "package:nexus/models/configs/power_level.dart";
|
||||||
import "package:nexus/models/content/content.dart";
|
import "package:nexus/models/content/content.dart";
|
||||||
import "package:nexus/models/content/power_levels.dart";
|
|
||||||
|
|
||||||
class PowerLevelController(final PowerLevelConfig config)
|
class PowerLevelController(final PowerLevelConfig config)
|
||||||
extends Notifier<bool> {
|
extends Notifier<bool> {
|
||||||
|
|
@ -17,31 +15,17 @@ class PowerLevelController(final PowerLevelConfig config)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final room = ref.watch(
|
final powerLevels = ref.watch(
|
||||||
RoomsController.provider.select((value) => value[config.roomId]),
|
RoomPowerLevelsController.provider(config.roomId),
|
||||||
);
|
);
|
||||||
|
final content = powerLevels.content;
|
||||||
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 user = ref.watch(
|
final user = ref.watch(
|
||||||
ClientStateController.provider.select((value) => value?.userId),
|
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
|
final userLevel = powerLevels.levelOf(user);
|
||||||
? double.infinity
|
|
||||||
: (content.users[userId] ?? content.usersDefault).toDouble();
|
|
||||||
|
|
||||||
final userLevel = powerLevelOf(user);
|
|
||||||
|
|
||||||
return switch (config) {
|
return switch (config) {
|
||||||
EventPowerLevelConfig(:final eventType) =>
|
EventPowerLevelConfig(:final eventType) =>
|
||||||
|
|
@ -52,10 +36,12 @@ class PowerLevelController(final PowerLevelConfig config)
|
||||||
.invite => userLevel >= content.invite,
|
.invite => userLevel >= content.invite,
|
||||||
|
|
||||||
.kick =>
|
.kick =>
|
||||||
userLevel >= content.kick && userLevel > powerLevelOf(targetUser),
|
userLevel >= content.kick &&
|
||||||
|
userLevel > powerLevels.levelOf(targetUser),
|
||||||
|
|
||||||
.ban =>
|
.ban =>
|
||||||
userLevel >= content.ban && userLevel > powerLevelOf(targetUser),
|
userLevel >= content.ban &&
|
||||||
|
userLevel > powerLevels.levelOf(targetUser),
|
||||||
|
|
||||||
.unban => userLevel >= content.ban,
|
.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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,15 @@ class ProfileController(final String userId)
|
||||||
return client.getProfile(userId);
|
return client.getProfile(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setField(String field, Object? value) async {
|
||||||
|
await ref
|
||||||
|
.read(ClientController.provider.notifier)
|
||||||
|
.setProfileField(field, value);
|
||||||
|
if (!ref.mounted) return;
|
||||||
|
ref.invalidateSelf();
|
||||||
|
await future;
|
||||||
|
}
|
||||||
|
|
||||||
static final provider = AsyncNotifierProvider.family
|
static final provider = AsyncNotifierProvider.family
|
||||||
.autoDispose<ProfileController, ProfileResponse, String>(
|
.autoDispose<ProfileController, ProfileResponse, String>(
|
||||||
ProfileController.new,
|
ProfileController.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);
|
.addAll(context.after);
|
||||||
ref
|
ref
|
||||||
.read(RoomsController.provider.notifier)
|
.read(RoomsController.provider.notifier)
|
||||||
.update(
|
.storeEvents(events.addAll(context.relatedEvents));
|
||||||
.new({
|
|
||||||
info.roomId: Room(
|
|
||||||
events: IMap.fromIterable(
|
|
||||||
events.addAll(context.relatedEvents),
|
|
||||||
keyMapper: (event) => event.rowId,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return .new(
|
return .new(
|
||||||
timeline: .new(events.map((element) => element.rowId)),
|
timeline: .new(events.map((element) => element.rowId)),
|
||||||
hasMoreBackward: true,
|
hasMoreBackward: true,
|
||||||
|
|
@ -165,6 +156,7 @@ class RoomChatController(final ChatParams info)
|
||||||
valueMapper: (event) => event,
|
valueMapper: (event) => event,
|
||||||
),
|
),
|
||||||
hasMore: response.hasMore,
|
hasMore: response.hasMore,
|
||||||
|
receipts: response.receipts,
|
||||||
timeline: IMap.fromIterable(
|
timeline: IMap.fromIterable(
|
||||||
response.events,
|
response.events,
|
||||||
keyMapper: (event) => event.timelineRowId,
|
keyMapper: (event) => event.timelineRowId,
|
||||||
|
|
@ -186,17 +178,8 @@ class RoomChatController(final ChatParams info)
|
||||||
|
|
||||||
ref
|
ref
|
||||||
.read(RoomsController.provider.notifier)
|
.read(RoomsController.provider.notifier)
|
||||||
.update(
|
.storeEvents(
|
||||||
.new({
|
paginationResponse.events.addAll(paginationResponse.relatedEvents),
|
||||||
info.roomId: Room(
|
|
||||||
events: IMap.fromIterable(
|
|
||||||
paginationResponse.events.addAll(
|
|
||||||
paginationResponse.relatedEvents,
|
|
||||||
),
|
|
||||||
keyMapper: (event) => event.rowId,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final eventRowIds =
|
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:collection/collection.dart";
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/models/event.dart";
|
import "package:nexus/models/event.dart";
|
||||||
|
import "package:nexus/models/read_receipt.dart";
|
||||||
import "package:nexus/models/room.dart";
|
import "package:nexus/models/room.dart";
|
||||||
|
|
||||||
class RoomsController extends Notifier<IMap<String, Room>> {
|
class RoomsController extends Notifier<IMap<String, Room>> {
|
||||||
|
|
@ -33,9 +36,49 @@ class RoomsController extends Notifier<IMap<String, Room>> {
|
||||||
.new(),
|
.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(
|
void update(
|
||||||
IMap<String, Room> rooms, [
|
IMap<String, Room> rooms, [
|
||||||
ISet<String> leftRooms = const ISet.empty(),
|
ISet<String> leftRooms = const .empty(),
|
||||||
]) {
|
]) {
|
||||||
final merged = rooms.entries.fold(state, (acc, entry) {
|
final merged = rooms.entries.fold(state, (acc, entry) {
|
||||||
final roomId = entry.key;
|
final roomId = entry.key;
|
||||||
|
|
@ -76,21 +119,42 @@ class RoomsController extends Notifier<IMap<String, Room>> {
|
||||||
timeline: incoming.reset
|
timeline: incoming.reset
|
||||||
? incoming.timeline
|
? incoming.timeline
|
||||||
: existing.timeline.addAll(incoming.timeline),
|
: existing.timeline.addAll(incoming.timeline),
|
||||||
receipts: incoming.receipts.entries.fold(
|
receipts: mergeReceipts(existing.receipts, incoming.receipts),
|
||||||
existing.receipts,
|
|
||||||
(receiptAcc, event) => receiptAcc.add(
|
|
||||||
event.key,
|
|
||||||
(receiptAcc[event.key] ?? .new()).addAll(event.value),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
) ??
|
) ??
|
||||||
incoming,
|
incoming.copyWith(
|
||||||
|
receipts: mergeReceipts(const .empty(), incoming.receipts),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
state = leftRooms.fold(merged, (acc, roomId) => acc.remove(roomId));
|
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>>(
|
static final provider = NotifierProvider<RoomsController, IMap<String, Room>>(
|
||||||
RoomsController.new,
|
RoomsController.new,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,14 @@ import "package:nexus/controllers/unified_push_allowed.dart";
|
||||||
import "package:nexus/controllers/unified_push_distributors.dart";
|
import "package:nexus/controllers/unified_push_distributors.dart";
|
||||||
import "package:nexus/helpers/extensions/distributor_name.dart";
|
import "package:nexus/helpers/extensions/distributor_name.dart";
|
||||||
import "package:nexus/models/account_data.dart";
|
import "package:nexus/models/account_data.dart";
|
||||||
|
import "package:nexus/models/client_state.dart";
|
||||||
import "package:nexus/models/room_order.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/settings_category.dart";
|
||||||
|
import "package:nexus/models/show_and_send.dart";
|
||||||
import "package:nexus/main.dart";
|
import "package:nexus/main.dart";
|
||||||
import "package:nexus/widgets/settings/dialog_list_tile.dart";
|
import "package:nexus/widgets/settings/dialog_list_tile.dart";
|
||||||
|
import "package:nexus/widgets/user_bottom_sheet.dart";
|
||||||
import "package:nexus/widgets/reset_encryption_sheet.dart";
|
import "package:nexus/widgets/reset_encryption_sheet.dart";
|
||||||
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
|
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
|
||||||
|
|
||||||
|
|
@ -31,6 +35,30 @@ class SettingsSectionsController
|
||||||
Future<IMap<String, IList<SettingsCategory>>> build() async {
|
Future<IMap<String, IList<SettingsCategory>>> build() async {
|
||||||
final settings = await ref.watch(SettingsController.provider.future);
|
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({
|
return .new({
|
||||||
"General": .new([
|
"General": .new([
|
||||||
.new(
|
.new(
|
||||||
|
|
@ -151,7 +179,7 @@ class SettingsSectionsController
|
||||||
),
|
),
|
||||||
.new(
|
.new(
|
||||||
title: "Linux Mobile Mode",
|
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,
|
icon: Icons.construction,
|
||||||
builder: (title, description, icon) => SwitchListTile(
|
builder: (title, description, icon) => SwitchListTile(
|
||||||
title: Text(title),
|
title: Text(title),
|
||||||
|
|
@ -166,12 +194,76 @@ class SettingsSectionsController
|
||||||
: null,
|
: 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
if (ref.watch(ClientStateController.provider)?.isLoggedIn == true)
|
if (ref.watch(ClientStateController.provider) case ClientState(
|
||||||
|
isLoggedIn: true,
|
||||||
|
:final userId?,
|
||||||
|
))
|
||||||
"Account": .new([
|
"Account": .new([
|
||||||
.new(title: "Profile", icon: Icons.person, settings: .new([])),
|
.new(
|
||||||
|
title: "Profile",
|
||||||
|
icon: Icons.person,
|
||||||
|
settings: .new([
|
||||||
|
.new(
|
||||||
|
title: "Profile",
|
||||||
|
description: "Your avatar, display name, pronouns, and timezone, as others see them.",
|
||||||
|
icon: Icons.person,
|
||||||
|
builder: (title, description, icon) => Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: .new(maxWidth: 500),
|
||||||
|
child: UserBottomSheet(userId, editable: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
.new(
|
.new(
|
||||||
title: "Notifications",
|
title: "Notifications",
|
||||||
icon: Icons.notifications,
|
icon: Icons.notifications,
|
||||||
|
|
|
||||||
17
lib/controllers/timezones.dart
Normal file
17
lib/controllers/timezones.dart
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
|
import "package:timezone/data/latest_10y.dart";
|
||||||
|
import "package:timezone/timezone.dart";
|
||||||
|
|
||||||
|
/// The names of all IANA timezones, e.g. `Europe/London`, alphabetically.
|
||||||
|
class TimezonesController extends Notifier<IList<String>> {
|
||||||
|
@override
|
||||||
|
IList<String> build() {
|
||||||
|
initializeTimeZones();
|
||||||
|
return timeZoneDatabase.locations.keys.toIList().sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
static final provider = NotifierProvider<TimezonesController, IList<String>>(
|
||||||
|
TimezonesController.new,
|
||||||
|
);
|
||||||
|
}
|
||||||
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 ||
|
if (notification == null ||
|
||||||
(!isInBackground &&
|
(!isInBackground &&
|
||||||
await windowManager.isFocused().onError((_, _) => true) &&
|
(!Platform.isAndroid &&
|
||||||
|
(WindowManager.instance.getCurrent()?.isFocused ?? true)) &&
|
||||||
ref.read(SelectedRoomController.provider) ==
|
ref.read(SelectedRoomController.provider) ==
|
||||||
notification.roomId)) {
|
notification.roomId)) {
|
||||||
if (isInBackground) exit(0);
|
if (isInBackground) exit(0);
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,12 @@ extension BetterWhen<T> on AsyncValue<T> {
|
||||||
required Widget Function(T value) data,
|
required Widget Function(T value) data,
|
||||||
Widget Function() loading = Loading.new,
|
Widget Function() loading = Loading.new,
|
||||||
bool skipLoadingOnRefresh = false,
|
bool skipLoadingOnRefresh = false,
|
||||||
|
bool skipLoadingOnReload = false,
|
||||||
}) => when(
|
}) => when(
|
||||||
data: data,
|
data: data,
|
||||||
error: (error, stackTrace) => ErrorDialog(error, stackTrace),
|
error: (error, stackTrace) => ErrorDialog(error, stackTrace),
|
||||||
loading: loading,
|
loading: loading,
|
||||||
skipLoadingOnRefresh: skipLoadingOnRefresh,
|
skipLoadingOnRefresh: skipLoadingOnRefresh,
|
||||||
|
skipLoadingOnReload: skipLoadingOnReload,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,19 +19,21 @@ extension GomuksOwnedBufferToX on GomuksOwnedBuffer {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension JsonToGomuksBuffer on Map<String, dynamic> {
|
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() {
|
Pointer<GomuksBorrowedBuffer> toGomuksBufferPtr() {
|
||||||
final jsonString = json.encode(this);
|
final bytes = utf8.encode(json.encode(this));
|
||||||
final bytes = utf8.encode(jsonString);
|
|
||||||
|
|
||||||
final dataPtr = calloc<Uint8>(bytes.length);
|
final dataPtr = calloc<Uint8>(bytes.length);
|
||||||
dataPtr.asTypedList(bytes.length).setAll(0, bytes);
|
dataPtr.asTypedList(bytes.length).setAll(0, bytes);
|
||||||
|
|
||||||
final ptr = calloc<GomuksBorrowedBuffer>();
|
return calloc<GomuksBorrowedBuffer>()
|
||||||
|
..ref.base = dataPtr
|
||||||
ptr.ref
|
..ref.length = bytes.length;
|
||||||
..base = dataPtr
|
|
||||||
..length = bytes.length;
|
|
||||||
|
|
||||||
return ptr;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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;
|
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:material_ui/material_ui.dart";
|
||||||
import "package:nexus/helpers/extensions/show_adaptive_popover.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";
|
import "package:nexus/widgets/user_bottom_sheet.dart";
|
||||||
|
|
||||||
extension ShowUserPopover on BuildContext {
|
extension ShowUserPopover on BuildContext {
|
||||||
void showUserPopover(
|
void showUserPopover(String userId, {String? roomId}) =>
|
||||||
MembershipContent member,
|
showAdaptivePopover((context) => UserBottomSheet(userId, roomId: roomId));
|
||||||
String userId, {
|
|
||||||
String? roomId,
|
|
||||||
}) => showAdaptivePopover(
|
|
||||||
(context) => UserBottomSheet(member, 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);
|
exit(0);
|
||||||
} else {
|
} else {
|
||||||
if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) {
|
if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) {
|
||||||
await windowManager.ensureInitialized();
|
WindowManager.instance.getCurrent()
|
||||||
await windowManager.waitUntilReadyToShow(
|
?..titleBarStyle = .hidden
|
||||||
WindowOptions(
|
..isWindowControlButtonsVisible = false
|
||||||
titleBarStyle: TitleBarStyle.hidden,
|
..minimumSize = Size.square(300).toNative();
|
||||||
windowButtonVisibility: false,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await windowManager.setMinimumSize(Size.square(500));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
|
|
@ -111,10 +107,21 @@ class const App({super.key}) extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => DynamicColorBuilder(
|
Widget build(BuildContext context) => DynamicColorBuilder(
|
||||||
builder: (lightDynamic, darkDynamic) => Consumer(
|
builder: (lightDynamic, darkDynamic) => Consumer(
|
||||||
builder: (context, ref, child) => MaterialApp(
|
builder: (context, ref, child) {
|
||||||
navigatorKey: navigatorKey,
|
// Flutter turns off mobile behaviour like swiping drawers open on desktop platforms, so Linux mobile pretends to be Android
|
||||||
debugShowCheckedModeBanner: false,
|
ThemeData forPlatform(ThemeData theme) =>
|
||||||
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
|
(ref
|
||||||
.watch(SettingsController.provider)
|
.watch(SettingsController.provider)
|
||||||
.maybeWhen(
|
.maybeWhen(
|
||||||
|
|
@ -124,7 +131,8 @@ class const App({super.key}) extends StatelessWidget {
|
||||||
) ??
|
) ??
|
||||||
ThemeData.light().colorScheme)
|
ThemeData.light().colorScheme)
|
||||||
.theme,
|
.theme,
|
||||||
darkTheme:
|
),
|
||||||
|
darkTheme: forPlatform(
|
||||||
(ref
|
(ref
|
||||||
.watch(SettingsController.provider)
|
.watch(SettingsController.provider)
|
||||||
.maybeWhen(
|
.maybeWhen(
|
||||||
|
|
@ -134,14 +142,16 @@ class const App({super.key}) extends StatelessWidget {
|
||||||
) ??
|
) ??
|
||||||
ThemeData.dark().colorScheme)
|
ThemeData.dark().colorScheme)
|
||||||
.theme,
|
.theme,
|
||||||
themeMode: ref
|
),
|
||||||
.watch(SettingsController.provider)
|
themeMode: ref
|
||||||
.maybeWhen(
|
.watch(SettingsController.provider)
|
||||||
data: (settings) => settings.theme,
|
.maybeWhen(
|
||||||
orElse: () => ThemeMode.system,
|
data: (settings) => settings.theme,
|
||||||
),
|
orElse: () => ThemeMode.system,
|
||||||
home: child,
|
),
|
||||||
),
|
home: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
child: DefaultTextHeightBehavior(
|
child: DefaultTextHeightBehavior(
|
||||||
textHeightBehavior: .new(leadingDistribution: .even),
|
textHeightBehavior: .new(leadingDistribution: .even),
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ class const AccountData({
|
||||||
const InvitePermissionConfig(),
|
const InvitePermissionConfig(),
|
||||||
|
|
||||||
@JsonKey(name: AccountData.directKey)
|
@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)
|
@JsonKey(name: AccountData.recentEmojiKey)
|
||||||
final RecentEmojiData recentEmoji = const RecentEmojiData(),
|
final RecentEmojiData recentEmoji = const RecentEmojiData(),
|
||||||
|
|
@ -30,7 +30,7 @@ class const AccountData({
|
||||||
@Freezed(toJson: false, fromJson: false)
|
@Freezed(toJson: false, fromJson: false)
|
||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
class const RecentEmojiData({
|
class const RecentEmojiData({
|
||||||
final IList<RecentEmoji> recentEmoji = const IList.empty(),
|
final IList<RecentEmoji> recentEmoji = const .empty(),
|
||||||
}) with _$RecentEmojiData {
|
}) with _$RecentEmojiData {
|
||||||
Map<String, Object?> toJson() => _$RecentEmojiDataToJson(this);
|
Map<String, Object?> toJson() => _$RecentEmojiDataToJson(this);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ sealed class CanonicalAliasContent extends Content
|
||||||
with _$CanonicalAliasContent {
|
with _$CanonicalAliasContent {
|
||||||
CanonicalAliasContent._();
|
CanonicalAliasContent._();
|
||||||
factory CanonicalAliasContent({
|
factory CanonicalAliasContent({
|
||||||
String? alias,
|
@JsonKey(includeIfNull: false) String? alias,
|
||||||
@Default(ISet.empty()) ISet<String> altAliases,
|
@Default(ISet.empty()) ISet<String> altAliases,
|
||||||
}) = _CanonicalAliasContent;
|
}) = _CanonicalAliasContent;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ sealed class JoinRulesContent extends Content with _$JoinRulesContent {
|
||||||
JoinRulesContent._();
|
JoinRulesContent._();
|
||||||
factory JoinRulesContent({
|
factory JoinRulesContent({
|
||||||
required JoinRule joinRule,
|
required JoinRule joinRule,
|
||||||
@Default(IList.empty()) IList<AllowCondition> allow,
|
@Default(ISet.empty()) ISet<AllowCondition> allow,
|
||||||
}) = _JoinRulesContent;
|
}) = _JoinRulesContent;
|
||||||
|
|
||||||
factory JoinRulesContent.fromJson(Map<String, Object?> json) =>
|
factory JoinRulesContent.fromJson(Map<String, Object?> json) =>
|
||||||
|
|
@ -21,6 +21,8 @@ sealed class JoinRulesContent extends Content with _$JoinRulesContent {
|
||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
class const AllowCondition({
|
class const AllowCondition({
|
||||||
final String? roomId,
|
final String? roomId,
|
||||||
|
|
||||||
|
@JsonKey(unknownEnumValue: AllowConditionType.unknown)
|
||||||
required final AllowConditionType type,
|
required final AllowConditionType type,
|
||||||
}) with _$AllowCondition {
|
}) with _$AllowCondition {
|
||||||
Map<String, Object?> toJson() => _$AllowConditionToJson(this);
|
Map<String, Object?> toJson() => _$AllowConditionToJson(this);
|
||||||
|
|
@ -32,4 +34,5 @@ class const AllowCondition({
|
||||||
enum AllowConditionType {
|
enum AllowConditionType {
|
||||||
@JsonValue("m.room_membership")
|
@JsonValue("m.room_membership")
|
||||||
membership,
|
membership,
|
||||||
|
unknown,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ sealed class PowerLevelsContent extends Content with _$PowerLevelsContent {
|
||||||
factory PowerLevelsContent({
|
factory PowerLevelsContent({
|
||||||
@Default(IMap.empty()) IMap<String, int> events,
|
@Default(IMap.empty()) IMap<String, int> events,
|
||||||
@Default(IMap.empty()) IMap<String, int> users,
|
@Default(IMap.empty()) IMap<String, int> users,
|
||||||
Notifications? notifications,
|
@JsonKey(includeIfNull: false) Notifications? notifications,
|
||||||
@Default(50) int ban,
|
@Default(50) int ban,
|
||||||
@Default(0) int eventsDefault,
|
@Default(0) int eventsDefault,
|
||||||
@Default(0) int invite,
|
@Default(0) int invite,
|
||||||
|
|
@ -28,7 +28,7 @@ sealed class PowerLevelsContent extends Content with _$PowerLevelsContent {
|
||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
class const Notifications({
|
class const Notifications({
|
||||||
final int room = 50,
|
final int room = 50,
|
||||||
final IMap<String, int> other = const IMap.empty(),
|
final IMap<String, int> other = const .empty(),
|
||||||
}) with _$Notifications {
|
}) with _$Notifications {
|
||||||
Map<String, Object?> toJson() => _$NotificationsToJson(this);
|
Map<String, Object?> toJson() => _$NotificationsToJson(this);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ class const Event({
|
||||||
required final String sender,
|
required final String sender,
|
||||||
@JsonKey(readValue: Event.typeJsonFromJson) required final String type,
|
@JsonKey(readValue: Event.typeJsonFromJson) required final String type,
|
||||||
@EpochDateTimeConverter() required final DateTime timestamp,
|
@EpochDateTimeConverter() required final DateTime timestamp,
|
||||||
final IMap<String, dynamic> unsigned = const IMap.empty(),
|
final IMap<String, dynamic> unsigned = const .empty(),
|
||||||
final LocalContent? localContent,
|
final LocalContent? localContent,
|
||||||
final String? transactionId,
|
final String? transactionId,
|
||||||
final String? redactedBy,
|
final String? redactedBy,
|
||||||
|
|
@ -27,7 +27,7 @@ class const Event({
|
||||||
final String? replyTo,
|
final String? replyTo,
|
||||||
final String? decryptionError,
|
final String? decryptionError,
|
||||||
final String? sendError,
|
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,
|
@JsonKey(name: "last_edit_rowid") final int lastEditRowId = 0,
|
||||||
final UnreadType? unreadType,
|
final UnreadType? unreadType,
|
||||||
final Profile? pmp,
|
final Profile? pmp,
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ class const EventContext({
|
||||||
required final IList<Event> after,
|
required final IList<Event> after,
|
||||||
required final String start,
|
required final String start,
|
||||||
required final String end,
|
required final String end,
|
||||||
final IList<Event> relatedEvents = const IList.empty(),
|
final IList<Event> relatedEvents = const .empty(),
|
||||||
}) with _$EventContext {
|
}) with _$EventContext {
|
||||||
Map<String, Object?> toJson() => _$EventContextToJson(this);
|
Map<String, Object?> toJson() => _$EventContextToJson(this);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ part "image.g.dart";
|
||||||
@Freezed(toJson: false, fromJson: false)
|
@Freezed(toJson: false, fromJson: false)
|
||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
class const ImageInfo({
|
class const ImageInfo({
|
||||||
@JsonKey(name: "h") final double? height,
|
@JsonKey(name: "h") final int? height,
|
||||||
@JsonKey(name: "w") final double? width,
|
@JsonKey(name: "w") final int? width,
|
||||||
@JsonKey(name: "mimetype") final String? mimeType,
|
@JsonKey(name: "mimetype") final String? mimeType,
|
||||||
@JsonKey(name: "xyz.amorgan.blurhash") final String? blurHash,
|
@JsonKey(name: "xyz.amorgan.blurhash") final String? blurHash,
|
||||||
final int? size,
|
final int? size,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:freezed_annotation/freezed_annotation.dart";
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
import "package:nexus/models/event.dart";
|
import "package:nexus/models/event.dart";
|
||||||
|
import "package:nexus/models/read_receipt.dart";
|
||||||
|
|
||||||
part "paginate.freezed.dart";
|
part "paginate.freezed.dart";
|
||||||
part "paginate.g.dart";
|
part "paginate.g.dart";
|
||||||
|
|
@ -10,6 +11,9 @@ part "paginate.g.dart";
|
||||||
class const Paginate({
|
class const Paginate({
|
||||||
required final IList<Event> events,
|
required final IList<Event> events,
|
||||||
required final IList<Event> relatedEvents,
|
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,
|
required final bool hasMore,
|
||||||
}) with _$Paginate {
|
}) with _$Paginate {
|
||||||
Map<String, Object?> toJson() => _$PaginateToJson(this);
|
Map<String, Object?> toJson() => _$PaginateToJson(this);
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ part "paginate_manual.g.dart";
|
||||||
@Freezed(toJson: false, fromJson: false)
|
@Freezed(toJson: false, fromJson: false)
|
||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
class const PaginateManual({
|
class const PaginateManual({
|
||||||
required final IList<Event> events,
|
final IList<Event> events = const .empty(),
|
||||||
final IList<Event> relatedEvents = const IList.empty(),
|
final IList<Event> relatedEvents = const .empty(),
|
||||||
required final String? nextBatch,
|
required final String? nextBatch,
|
||||||
}) with _$PaginateManual {
|
}) with _$PaginateManual {
|
||||||
Map<String, Object?> toJson() => _$PaginateManualToJson(this);
|
Map<String, Object?> toJson() => _$PaginateManualToJson(this);
|
||||||
|
|
|
||||||
|
|
@ -31,24 +31,41 @@ class const Bio({required final String html, final String? editSource})
|
||||||
class const Profile({
|
class const Profile({
|
||||||
final String? id,
|
final String? id,
|
||||||
final String? parseError,
|
final String? parseError,
|
||||||
final Uri? avatarUrl,
|
@JsonKey(name: Profile.avatarUrlKey) final Uri? avatarUrl,
|
||||||
|
|
||||||
@JsonKey(name: "displayname", fromJson: MembershipContent.displaynameFromJson)
|
@JsonKey(
|
||||||
|
name: Profile.displayNameKey,
|
||||||
|
fromJson: MembershipContent.displaynameFromJson,
|
||||||
|
)
|
||||||
final String? displayName,
|
final String? displayName,
|
||||||
|
|
||||||
@JsonKey(readValue: Profile.readTimezone, name: "m.tz")
|
@JsonKey(name: Profile.timezoneKey) final String? timezone,
|
||||||
final String? timezone,
|
|
||||||
|
|
||||||
@JsonKey(readValue: Profile.readPronouns, name: "m.pronouns")
|
@JsonKey(readValue: Profile.readPronouns, name: Profile.pronounsKey)
|
||||||
final IList<Pronoun> pronouns = const IList.empty(),
|
final IList<Pronoun> pronouns = const .empty(),
|
||||||
}) with _$Profile {
|
}) with _$Profile {
|
||||||
|
static const avatarUrlKey = "avatar_url";
|
||||||
|
static const displayNameKey = "displayname";
|
||||||
|
static const timezoneKey = "m.tz";
|
||||||
|
static const pronounsKey = "m.pronouns";
|
||||||
|
static const unstablePronounsKey = "io.fsky.nyx.pronouns";
|
||||||
|
|
||||||
Map<String, Object?> toJson() => _$ProfileToJson(this);
|
Map<String, Object?> toJson() => _$ProfileToJson(this);
|
||||||
|
|
||||||
static Object? readPronouns(Map<dynamic, dynamic> map, String key) =>
|
Profile withPronouns(String language, Iterable<String> summaries) => copyWith(
|
||||||
map[key] ?? map["io.fsky.nyx.pronouns"];
|
pronouns: pronouns
|
||||||
|
.where((pronoun) => pronoun.language != language)
|
||||||
|
.toIList()
|
||||||
|
.addAll(
|
||||||
|
summaries.map(
|
||||||
|
(summary) => Pronoun(language: language, summary: summary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
static Object? readTimezone(Map<dynamic, dynamic> map, String key) =>
|
static Object? readPronouns(Map<dynamic, dynamic> map, String key) =>
|
||||||
map[key] ?? map["us.cloke.msc4175.tz"];
|
/* until stable, commented out: map[key] ?? */ map[Profile
|
||||||
|
.unstablePronounsKey];
|
||||||
|
|
||||||
factory Profile.fromJson(Map<String, dynamic> json) =>
|
factory Profile.fromJson(Map<String, dynamic> json) =>
|
||||||
_$ProfileFromJson(json);
|
_$ProfileFromJson(json);
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,11 @@ class const ReadReceipt({
|
||||||
required final String eventId,
|
required final String eventId,
|
||||||
@EpochDateTimeConverter() required final DateTime timestamp,
|
@EpochDateTimeConverter() required final DateTime timestamp,
|
||||||
}) with _$ReadReceipt {
|
}) with _$ReadReceipt {
|
||||||
|
String get thread => switch (threadId) {
|
||||||
|
final threadId? when threadId.isNotEmpty => threadId,
|
||||||
|
_ => "main",
|
||||||
|
};
|
||||||
|
|
||||||
Map<String, Object?> toJson() => _$ReadReceiptToJson(this);
|
Map<String, Object?> toJson() => _$ReadReceiptToJson(this);
|
||||||
|
|
||||||
factory ReadReceipt.fromJson(Map<String, Object?> json) =>
|
factory ReadReceipt.fromJson(Map<String, Object?> json) =>
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,10 @@ class const CreateRoomRequest({
|
||||||
final String? roomAliasName,
|
final String? roomAliasName,
|
||||||
final String? name,
|
final String? name,
|
||||||
final String? topic,
|
final String? topic,
|
||||||
final IList<String> invite = const IList.empty(),
|
final IList<String> invite = const .empty(),
|
||||||
@JsonKey(name: "uk.timedout.msc4491.invite_reason")
|
@JsonKey(name: "uk.timedout.msc4491.invite_reason")
|
||||||
final String? inviteReason,
|
final String? inviteReason,
|
||||||
final IList<InitialStateEvent> initialState = const IList.empty(),
|
final IList<InitialStateEvent> initialState = const .empty(),
|
||||||
final RoomPreset? preset,
|
final RoomPreset? preset,
|
||||||
final bool isDirect = false,
|
final bool isDirect = false,
|
||||||
final IMap<String, dynamic>? creationContent,
|
final IMap<String, dynamic>? creationContent,
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@ part "join_room.g.dart";
|
||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
class const JoinRoomRequest({
|
class const JoinRoomRequest({
|
||||||
required final String roomIdOrAlias,
|
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.
|
/// Set when accepting an invite, so gomuks can mark DMs as such.
|
||||||
final bool fromInvite = false,
|
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()
|
@JsonSerializable()
|
||||||
class const Mentions({
|
class const Mentions({
|
||||||
final bool room = false,
|
final bool room = false,
|
||||||
final IList<String> userIds = const IList.empty(),
|
final IList<String> userIds = const .empty(),
|
||||||
}) with _$Mentions {
|
}) with _$Mentions {
|
||||||
Map<String, Object?> toJson() => _$MentionsToJson(this);
|
Map<String, Object?> toJson() => _$MentionsToJson(this);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,15 +14,15 @@ class const Room({
|
||||||
|
|
||||||
/// [timeline] is an IMap of timelineRowId to eventRowId
|
/// [timeline] is an IMap of timelineRowId to eventRowId
|
||||||
@JsonKey(fromJson: Room.timelineTupleJsonToIMap)
|
@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
|
/// [clientSticky] is an ISet of eventRowId
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
@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
|
/// [events] is an IMap of eventRowId to event
|
||||||
@JsonKey(fromJson: Room.eventsJsonToIMap)
|
@JsonKey(fromJson: Room.eventsJsonToIMap)
|
||||||
final IMap<int, Event> events = const IMap.empty(),
|
final IMap<int, Event> events = const .empty(),
|
||||||
|
|
||||||
final bool reset = false,
|
final bool reset = false,
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
|
@ -34,9 +34,9 @@ class const Room({
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
final bool hasFetchedMembers = 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 dismissNotifications = false,
|
||||||
final bool hasMore = true,
|
final bool hasMore = true,
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import "package:freezed_annotation/freezed_annotation.dart";
|
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/epoch_date_time_converter.dart";
|
||||||
import "package:nexus/models/lazy_load_summary.dart";
|
import "package:nexus/models/lazy_load_summary.dart";
|
||||||
|
|
||||||
|
|
@ -15,6 +16,8 @@ class const RoomMetadata({
|
||||||
final String? topic,
|
final String? topic,
|
||||||
final String? canonicalAlias,
|
final String? canonicalAlias,
|
||||||
final LazyLoadSummary? lazyLoadSummary,
|
final LazyLoadSummary? lazyLoadSummary,
|
||||||
|
|
||||||
|
final EncryptionContent? encryptionEvent,
|
||||||
required final bool hasMemberList,
|
required final bool hasMemberList,
|
||||||
@JsonKey(name: "preview_event_rowid") required final int previewEventRowID,
|
@JsonKey(name: "preview_event_rowid") required final int previewEventRowID,
|
||||||
@EpochDateTimeConverter() required final DateTime sortingTimestamp,
|
@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:freezed_annotation/freezed_annotation.dart";
|
||||||
import "package:nexus/models/content/create.dart";
|
import "package:nexus/models/content/create.dart";
|
||||||
import "package:nexus/models/content/membership.dart";
|
import "package:nexus/models/content/membership.dart";
|
||||||
|
|
@ -14,6 +15,9 @@ class const RoomSummary({
|
||||||
required final String roomId,
|
required final String roomId,
|
||||||
@JsonKey(name: "num_joined_members") final int? joinedMembers,
|
@JsonKey(name: "num_joined_members") final int? joinedMembers,
|
||||||
final JoinRule? joinRule,
|
final JoinRule? joinRule,
|
||||||
|
|
||||||
|
/// Rooms whose members may join without an invite, for restricted rooms.
|
||||||
|
final IList<String> allowedRoomIds = const .empty(),
|
||||||
final String? name,
|
final String? name,
|
||||||
final Uri? avatarUrl,
|
final Uri? avatarUrl,
|
||||||
final String? canonicalAlias,
|
final String? canonicalAlias,
|
||||||
|
|
@ -41,6 +45,14 @@ class const RoomSummary({
|
||||||
/// Whether this is an invite to a direct message.
|
/// Whether this is an invite to a direct message.
|
||||||
@JsonKey(includeFromJson: false) final bool isDirect = false,
|
@JsonKey(includeFromJson: false) final bool isDirect = false,
|
||||||
}) with _$RoomSummary {
|
}) 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.
|
/// A summary of a room the user is in, from its synced metadata.
|
||||||
factory RoomSummary.fromMetadata(RoomMetadata metadata) => .new(
|
factory RoomSummary.fromMetadata(RoomMetadata metadata) => .new(
|
||||||
roomId: metadata.id,
|
roomId: metadata.id,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import "package:material_ui/material_ui.dart";
|
import "package:material_ui/material_ui.dart";
|
||||||
import "package:freezed_annotation/freezed_annotation.dart";
|
import "package:freezed_annotation/freezed_annotation.dart";
|
||||||
import "package:nexus/models/room_order.dart";
|
import "package:nexus/models/room_order.dart";
|
||||||
|
import "package:nexus/models/show_and_send.dart";
|
||||||
|
|
||||||
part "settings.freezed.dart";
|
part "settings.freezed.dart";
|
||||||
part "settings.g.dart";
|
part "settings.g.dart";
|
||||||
|
|
@ -14,7 +15,13 @@ class const Settings({
|
||||||
final bool showAllInHome = false,
|
final bool showAllInHome = false,
|
||||||
final bool showMinimizeButton = false,
|
final bool showMinimizeButton = false,
|
||||||
final bool swipeToReply = false,
|
final bool swipeToReply = false,
|
||||||
|
final bool hideMembershipEvents = false,
|
||||||
final RoomOrder roomOrder = .unreadFirst,
|
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 {
|
}) with _$Settings {
|
||||||
Map<String, Object?> toJson() => _$SettingsToJson(this);
|
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)
|
@JsonSerializable(createToJson: false)
|
||||||
class const SyncData({
|
class const SyncData({
|
||||||
final bool clearState = false,
|
final bool clearState = false,
|
||||||
final IMap<String, IMap<String, dynamic>> accountData = const IMap.empty(),
|
final IMap<String, IMap<String, dynamic>> accountData = const .empty(),
|
||||||
final IMap<String, Room> rooms = const IMap.empty(),
|
final IMap<String, Room> rooms = const .empty(),
|
||||||
final ISet<String> leftRooms = const ISet.empty(),
|
final ISet<String> leftRooms = const .empty(),
|
||||||
final IList<InvitedRoom> invitedRooms = const IList.empty(),
|
final IList<InvitedRoom> invitedRooms = const .empty(),
|
||||||
final IMap<String, IList<SpaceEdge>>? spaceEdges,
|
final IMap<String, IList<SpaceEdge>>? spaceEdges,
|
||||||
final IList<String>? topLevelSpaces,
|
final IList<String>? topLevelSpaces,
|
||||||
}) with _$SyncData {
|
}) 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
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
Future<void> maximize() async {
|
final isDesktop = !(Platform.isAndroid || Platform.isIOS);
|
||||||
final isMaximized = await windowManager.isMaximized();
|
|
||||||
|
|
||||||
if (isMaximized) {
|
void maximize() {
|
||||||
return windowManager.unmaximize();
|
final window = WindowManager.instance.getCurrent();
|
||||||
|
if (window == null) return;
|
||||||
|
|
||||||
|
if (window.isMaximized) {
|
||||||
|
return window.unmaximize();
|
||||||
}
|
}
|
||||||
|
|
||||||
return windowManager.maximize();
|
return window.maximize();
|
||||||
}
|
}
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onPanStart: ref
|
onPanStart: ref
|
||||||
.watch(SettingsController.provider)
|
.watch(SettingsController.provider)
|
||||||
.whenOrNull(
|
.whenOrNull(
|
||||||
data: (settings) => settings.linuxMobileMode
|
data: (settings) => !isDesktop || settings.linuxMobileMode
|
||||||
? null
|
? null
|
||||||
: (_) => windowManager.startDragging(),
|
: (_) => WindowManager.instance.getCurrent()?.startDragging(),
|
||||||
),
|
),
|
||||||
child: AppBar(
|
child: AppBar(
|
||||||
leading: leading == null ? null : InkWell(onTap: onTap, child: leading),
|
leading: leading == null ? null : InkWell(onTap: onTap, child: leading),
|
||||||
|
|
@ -50,10 +53,12 @@ final class const Appbar({
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: IgnorePointer(child: title),
|
child: IgnorePointer(child: title),
|
||||||
),
|
),
|
||||||
flexibleSpace: GestureDetector(onDoubleTap: maximize),
|
flexibleSpace: GestureDetector(
|
||||||
|
onDoubleTap: isDesktop ? maximize : null,
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
...actions,
|
...actions,
|
||||||
if (windowControls && !(Platform.isAndroid || Platform.isIOS)) ...[
|
if (windowControls && isDesktop) ...[
|
||||||
if (ref
|
if (ref
|
||||||
.watch(SettingsController.provider)
|
.watch(SettingsController.provider)
|
||||||
.whenOrNull(
|
.whenOrNull(
|
||||||
|
|
@ -62,7 +67,8 @@ final class const Appbar({
|
||||||
false)
|
false)
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: "Minimize window",
|
tooltip: "Minimize window",
|
||||||
onPressed: windowManager.minimize,
|
onPressed: () =>
|
||||||
|
WindowManager.instance.getCurrent()?.minimize(),
|
||||||
icon: const Icon(Icons.minimize),
|
icon: const Icon(Icons.minimize),
|
||||||
),
|
),
|
||||||
if (!Platform.isLinux)
|
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:fluttertagger/fluttertagger.dart";
|
||||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
import "package:nexus/controllers/attachment.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/controllers/image_picker.dart";
|
||||||
import "package:nexus/helpers/file_dialog_helper.dart";
|
import "package:nexus/helpers/file_dialog_helper.dart";
|
||||||
import "package:image_picker/image_picker.dart";
|
import "package:image_picker/image_picker.dart";
|
||||||
|
|
@ -94,6 +96,44 @@ class const Composer(
|
||||||
return null;
|
return null;
|
||||||
}, [relationType, relatedEvent == 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));
|
final attachment = ref.watch(AttachmentController.provider(roomId));
|
||||||
|
|
||||||
Future<void> attach(Future<XFile?> pick) async {
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
86
lib/widgets/editable_avatar.dart
Normal file
86
lib/widgets/editable_avatar.dart
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
|
import "package:material_ui/material_ui.dart";
|
||||||
|
import "package:nexus/controllers/client.dart";
|
||||||
|
import "package:nexus/helpers/extensions/show_adaptive_menu.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/message.dart";
|
||||||
|
|
||||||
|
/// Shows [child], an avatar, with a button to upload a new one or remove it.
|
||||||
|
class const EditableAvatar({
|
||||||
|
required final Widget child,
|
||||||
|
|
||||||
|
/// Whether there's an avatar to remove.
|
||||||
|
required final bool hasAvatar,
|
||||||
|
|
||||||
|
/// Called with the uploaded image, or null when the avatar is removed.
|
||||||
|
required final Future<void> Function(Uri? url) onChanged,
|
||||||
|
final double dimension = 200,
|
||||||
|
super.key,
|
||||||
|
}) extends HookConsumerWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final saving = Saving.use();
|
||||||
|
Future<void> save(Future<void> Function() action) =>
|
||||||
|
saving.run(() => action().onError(showError));
|
||||||
|
|
||||||
|
Future<void> pick() async {
|
||||||
|
final file = await ref
|
||||||
|
.read(FileDialogHelper.provider)
|
||||||
|
.pickFile(images: true);
|
||||||
|
if (file == null || !context.mounted) return;
|
||||||
|
|
||||||
|
await save(() async {
|
||||||
|
final content = await ref
|
||||||
|
.read(ClientController.provider.notifier)
|
||||||
|
.uploadMedia(.new(path: file.path, encrypt: false));
|
||||||
|
if (content case ImageMessageContent(:final url)) {
|
||||||
|
await onChanged(url);
|
||||||
|
} else {
|
||||||
|
throw Exception("The file isn't an image");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return SizedBox.square(
|
||||||
|
dimension: dimension,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
child,
|
||||||
|
if (saving.active) const Center(child: CircularProgressIndicator()),
|
||||||
|
Positioned(
|
||||||
|
right: dimension * 0.01,
|
||||||
|
bottom: dimension * 0.01,
|
||||||
|
child: Builder(
|
||||||
|
builder: (context) => IconButton.filled(
|
||||||
|
tooltip: "Edit avatar",
|
||||||
|
icon: Icon(Icons.edit),
|
||||||
|
onPressed: saving.active
|
||||||
|
? null
|
||||||
|
: !hasAvatar
|
||||||
|
? pick
|
||||||
|
: () => context.showAdaptiveMenu([
|
||||||
|
PopupMenuItem(
|
||||||
|
onTap: pick,
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(Icons.upload),
|
||||||
|
title: Text("Upload new avatar"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
onTap: () => save(() => onChanged(null)),
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(Icons.delete),
|
||||||
|
title: Text("Remove avatar"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -40,8 +40,8 @@ class const MentionChip(final String content, final String? roomId, {super.key})
|
||||||
? SizedBox.shrink()
|
? SizedBox.shrink()
|
||||||
: InkWell(
|
: InkWell(
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
if (data case MembershipContent membership) {
|
if (data is MembershipContent) {
|
||||||
context.showUserPopover(membership, mention, roomId: roomId);
|
context.showUserPopover(mention, roomId: roomId);
|
||||||
} else if (data case RoomSummary summary) {
|
} else if (data case RoomSummary summary) {
|
||||||
final eventRowId = linkInfo?.eventId == null
|
final eventRowId = linkInfo?.eventId == null
|
||||||
? 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,
|
super.key,
|
||||||
}) extends ConsumerWidget {
|
}) extends ConsumerWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) => switch (ref.watch(
|
Widget build(BuildContext context, WidgetRef ref) =>
|
||||||
AuthorController.provider(event),
|
switch (ref.watch(AuthorController.provider(event))) {
|
||||||
)) {
|
AsyncData(:final value) || AsyncLoading(:final value?) => InkWell(
|
||||||
AsyncData(:final value) || AsyncLoading(:final value?) => InkWell(
|
onTap: () =>
|
||||||
onTap: () =>
|
context.showUserPopover(event.sender, roomId: event.roomId),
|
||||||
context.showUserPopover(value, event.sender, roomId: event.roomId),
|
child: AvatarOrHash(
|
||||||
child: AvatarOrHash(
|
value.avatarUrl,
|
||||||
value.avatarUrl,
|
value.displayName ?? event.sender.localpart,
|
||||||
value.displayName ?? event.sender.localpart,
|
dimension: height,
|
||||||
dimension: height,
|
),
|
||||||
),
|
),
|
||||||
),
|
_ => AvatarOrHash(null, event.sender.localpart, dimension: height),
|
||||||
_ => AvatarOrHash(null, event.sender.localpart, dimension: height),
|
};
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,12 +21,8 @@ class const MessageDisplayname(
|
||||||
AsyncData(:final MembershipContent? value) ||
|
AsyncData(:final MembershipContent? value) ||
|
||||||
AsyncLoading(:final value) ||
|
AsyncLoading(:final value) ||
|
||||||
AsyncError(:final value) => InkWell(
|
AsyncError(:final value) => InkWell(
|
||||||
onTap: clickable && value != null
|
onTap: clickable
|
||||||
? () => context.showUserPopover(
|
? () => context.showUserPopover(event.sender, roomId: event.roomId)
|
||||||
value,
|
|
||||||
event.sender,
|
|
||||||
roomId: event.roomId,
|
|
||||||
)
|
|
||||||
: null,
|
: null,
|
||||||
child: switch ([
|
child: switch ([
|
||||||
Text(
|
Text(
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ class const MemberList(final String roomId, {super.key})
|
||||||
final options = <String, MembershipStatus>{
|
final options = <String, MembershipStatus>{
|
||||||
"Joined": .join,
|
"Joined": .join,
|
||||||
"Invited": .invite,
|
"Invited": .invite,
|
||||||
|
"Knocked": .knock,
|
||||||
"Banned": .ban,
|
"Banned": .ban,
|
||||||
};
|
};
|
||||||
final status = options.values.toIList()[statusIndex.value];
|
final status = options.values.toIList()[statusIndex.value];
|
||||||
|
|
@ -36,7 +37,7 @@ class const MemberList(final String roomId, {super.key})
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: .symmetric(vertical: 8),
|
padding: .all(8),
|
||||||
child: M3EToggleButtonGroup(
|
child: M3EToggleButtonGroup(
|
||||||
selectedIndex: statusIndex.value,
|
selectedIndex: statusIndex.value,
|
||||||
onSelectedIndexChanged: (index) =>
|
onSelectedIndexChanged: (index) =>
|
||||||
|
|
@ -104,15 +105,13 @@ class const MemberList(final String roomId, {super.key})
|
||||||
itemBuilder: (context, index) =>
|
itemBuilder: (context, index) =>
|
||||||
switch (members[index].content) {
|
switch (members[index].content) {
|
||||||
MembershipContent(
|
MembershipContent(
|
||||||
:final avatarUrl,
|
:final avatarUrl,
|
||||||
:final displayName,
|
:final displayName,
|
||||||
) &&
|
) =>
|
||||||
final content =>
|
|
||||||
Builder(
|
Builder(
|
||||||
builder: (context) => ListTile(
|
builder: (context) => ListTile(
|
||||||
onTap: () =>
|
onTap: () =>
|
||||||
context.showUserPopover(
|
context.showUserPopover(
|
||||||
content,
|
|
||||||
members[index].stateKey!,
|
members[index].stateKey!,
|
||||||
roomId: roomId,
|
roomId: roomId,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ final class const MessageImage(
|
||||||
aspectRatio: (info?.width ?? 1) / (info?.height ?? 1),
|
aspectRatio: (info?.width ?? 1) / (info?.height ?? 1),
|
||||||
child: Image(
|
child: Image(
|
||||||
image: MxcImage(ref, request),
|
image: MxcImage(ref, request),
|
||||||
width: info?.width,
|
width: info?.width?.toDouble(),
|
||||||
fit: BoxFit.fitWidth,
|
fit: BoxFit.fitWidth,
|
||||||
loadingBuilder: (_, child, loadingProgress) =>
|
loadingBuilder: (_, child, loadingProgress) =>
|
||||||
loadingProgress == null
|
loadingProgress == null
|
||||||
|
|
@ -40,7 +40,7 @@ final class const MessageImage(
|
||||||
child: BlurHash(hash: blurHash),
|
child: BlurHash(hash: blurHash),
|
||||||
)
|
)
|
||||||
: SizedBox(
|
: SizedBox(
|
||||||
width: info!.width,
|
width: info!.width!.toDouble(),
|
||||||
child: BlurHash(hash: blurHash),
|
child: BlurHash(hash: blurHash),
|
||||||
),
|
),
|
||||||
_ => Loading(),
|
_ => 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:hooks_riverpod/hooks_riverpod.dart";
|
||||||
import "package:m3e_card_list/m3e_card_list.dart";
|
import "package:material_ui/material_ui.dart";
|
||||||
import "package:navigation_rail_m3e/navigation_rail_m3e.dart";
|
|
||||||
import "package:nexus/controllers/settings_sections.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/helpers/extensions/show_about_sheet.dart";
|
||||||
import "package:nexus/widgets/pages/settings_category.dart";
|
import "package:nexus/widgets/settings/settings_layout.dart";
|
||||||
import "package:nexus/widgets/divider_text.dart";
|
|
||||||
import "package:nexus/widgets/highlight_wrapper.dart";
|
|
||||||
import "package:super_sliver_list/super_sliver_list.dart";
|
|
||||||
|
|
||||||
class const SettingsPage({super.key}) extends ConsumerWidget {
|
class const SettingsPage({super.key}) extends ConsumerWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) => LayoutBuilder(
|
Widget build(BuildContext context, WidgetRef ref) => SettingsLayout(
|
||||||
builder: (_, constraints) => HookBuilder(
|
title: "Settings",
|
||||||
builder: (context) {
|
sectionsProvider: SettingsSectionsController.provider,
|
||||||
final categoriesArePages = constraints.maxWidth < 550;
|
actions: [
|
||||||
|
IconButton(
|
||||||
final selected = useState(0);
|
onPressed: () => context.showAboutSheet(ref),
|
||||||
|
icon: Icon(Icons.info_outline),
|
||||||
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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,19 @@ import "package:collection/collection.dart";
|
||||||
import "package:material_ui/material_ui.dart";
|
import "package:material_ui/material_ui.dart";
|
||||||
import "package:flutter_hooks/flutter_hooks.dart";
|
import "package:flutter_hooks/flutter_hooks.dart";
|
||||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
import "package:nexus/controllers/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/helpers/extensions/better_when.dart";
|
||||||
import "package:nexus/widgets/highlight_wrapper.dart";
|
import "package:nexus/widgets/highlight_wrapper.dart";
|
||||||
import "package:super_sliver_list/super_sliver_list.dart";
|
import "package:super_sliver_list/super_sliver_list.dart";
|
||||||
|
|
||||||
class const SettingsCategoryPage(
|
class const SettingsCategoryPage(
|
||||||
final int index, {
|
final int index, {
|
||||||
|
required final ProviderListenable<
|
||||||
|
AsyncValue<IMap<String, IList<SettingsCategory>>>
|
||||||
|
>
|
||||||
|
sectionsProvider,
|
||||||
final int? initialHighlight,
|
final int? initialHighlight,
|
||||||
super.key,
|
super.key,
|
||||||
}) extends HookConsumerWidget {
|
}) extends HookConsumerWidget {
|
||||||
|
|
@ -45,8 +51,9 @@ class const SettingsCategoryPage(
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return ref
|
return ref
|
||||||
.watch(SettingsSectionsController.provider)
|
.watch(sectionsProvider)
|
||||||
.betterWhen(
|
.betterWhen(
|
||||||
|
skipLoadingOnReload: true,
|
||||||
data: (sections) => Scaffold(
|
data: (sections) => Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(sections.values.flattenedToList[index].title),
|
title: Text(sections.values.flattenedToList[index].title),
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ class const AudioPlayer(
|
||||||
final audio = await ref
|
final audio = await ref
|
||||||
.read(ClientController.provider.notifier)
|
.read(ClientController.provider.notifier)
|
||||||
.downloadMedia(.new(mxc: uri, encrypted: encrypted));
|
.downloadMedia(.new(mxc: uri, encrypted: encrypted));
|
||||||
|
if (!context.mounted) return;
|
||||||
await player.open(Media(audio.path), play: false);
|
await player.open(Media(audio.path), play: false);
|
||||||
if (context.mounted) loaded.value = true;
|
if (context.mounted) loaded.value = true;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ class const VideoPlayer(
|
||||||
final video = await ref
|
final video = await ref
|
||||||
.read(ClientController.provider.notifier)
|
.read(ClientController.provider.notifier)
|
||||||
.downloadMedia(.new(mxc: uri, encrypted: encrypted));
|
.downloadMedia(.new(mxc: uri, encrypted: encrypted));
|
||||||
|
if (!context.mounted) return;
|
||||||
await player.open(Media(video.path), play: false);
|
await player.open(Media(video.path), play: false);
|
||||||
if (context.mounted) loaded.value = true;
|
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/event.dart";
|
||||||
import "package:nexus/controllers/room_chat.dart";
|
import "package:nexus/controllers/room_chat.dart";
|
||||||
import "package:nexus/controllers/rooms.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/extensions/show_adaptive_menu.dart";
|
||||||
import "package:nexus/helpers/hooks/chat_scroll.dart";
|
import "package:nexus/helpers/hooks/chat_scroll.dart";
|
||||||
import "package:nexus/models/content/avatar.dart";
|
import "package:nexus/models/content/avatar.dart";
|
||||||
import "package:nexus/models/content/canonical_alias.dart";
|
import "package:nexus/models/content/canonical_alias.dart";
|
||||||
import "package:nexus/models/content/content.dart";
|
import "package:nexus/models/content/content.dart";
|
||||||
import "package:nexus/models/content/create.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/history_visibility.dart";
|
||||||
import "package:nexus/models/content/join_rules.dart";
|
import "package:nexus/models/content/join_rules.dart";
|
||||||
import "package:nexus/models/content/membership.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/pinned_events.dart";
|
||||||
import "package:nexus/models/content/power_levels.dart";
|
import "package:nexus/models/content/power_levels.dart";
|
||||||
import "package:nexus/models/content/server_acl.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/content/topic.dart";
|
||||||
import "package:nexus/models/event.dart";
|
import "package:nexus/models/event.dart";
|
||||||
import "package:nexus/widgets/error_dialog.dart";
|
import "package:nexus/widgets/error_dialog.dart";
|
||||||
import "package:nexus/widgets/lazy_loading/message_displayname.dart";
|
import "package:nexus/widgets/lazy_loading/message_displayname.dart";
|
||||||
import "package:nexus/widgets/renderers/message.dart";
|
import "package:nexus/widgets/renderers/message.dart";
|
||||||
import "package:nexus/widgets/reaction_row.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/membership.dart";
|
||||||
import "package:nexus/widgets/renderers/generic_event.dart";
|
import "package:nexus/widgets/renderers/generic_event.dart";
|
||||||
import "package:nexus/widgets/swipe_to_reply.dart";
|
import "package:nexus/widgets/swipe_to_reply.dart";
|
||||||
|
|
@ -43,6 +43,10 @@ class const EventRenderer(
|
||||||
|
|
||||||
/// If set, events can be swiped, calling this
|
/// If set, events can be swiped, calling this
|
||||||
final VoidCallback? onSwipe,
|
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,
|
super.key,
|
||||||
}) extends HookConsumerWidget {
|
}) extends HookConsumerWidget {
|
||||||
@override
|
@override
|
||||||
|
|
@ -78,10 +82,24 @@ class const EventRenderer(
|
||||||
|
|
||||||
if (event == null) return SizedBox.shrink();
|
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 =
|
final isGrouped =
|
||||||
previousEvent?.content is MessageContent &&
|
(previousEvent?.isShownAsMessage ?? false) &&
|
||||||
previousEvent?.redactedBy == null &&
|
|
||||||
previousEvent?.relationType != "m.replace" &&
|
|
||||||
event.sender == previousEvent?.sender &&
|
event.sender == previousEvent?.sender &&
|
||||||
event.pmp?.id == previousEvent?.pmp?.id;
|
event.pmp?.id == previousEvent?.pmp?.id;
|
||||||
|
|
||||||
|
|
@ -94,7 +112,26 @@ class const EventRenderer(
|
||||||
TextSpan(text: text),
|
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
|
? null
|
||||||
: switch (event.content) {
|
: switch (event.content) {
|
||||||
Content(:final parseError?, :final stackTrace) => Row(
|
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 content => switch (event.previousContent) {
|
||||||
MembershipContent(:final status) =>
|
MembershipContent(:final status) =>
|
||||||
status == content.status
|
status == content.status
|
||||||
|
|
@ -268,6 +284,10 @@ class const EventRenderer(
|
||||||
...[
|
...[
|
||||||
if (child is! MessageRenderer) ReactionRow(event),
|
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")
|
if (event.sendError != null && event.sendError != "not sent")
|
||||||
Padding(
|
Padding(
|
||||||
padding: .only(bottom: 4),
|
padding: .only(bottom: 4),
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,11 @@ class const MembershipRenderer(
|
||||||
"Make sure to only pass membership events to MembershipRenderer",
|
"Make sure to only pass membership events to MembershipRenderer",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final previous = switch (event.previousContent) {
|
||||||
|
MembershipContent(:final status) => status,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
return switch (event.content) {
|
return switch (event.content) {
|
||||||
MembershipContent content => GenericEventRenderer(
|
MembershipContent content => GenericEventRenderer(
|
||||||
Icons.people,
|
Icons.people,
|
||||||
|
|
@ -28,7 +33,6 @@ class const MembershipRenderer(
|
||||||
alignment: .middle,
|
alignment: .middle,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () => context.showUserPopover(
|
onTap: () => context.showUserPopover(
|
||||||
content,
|
|
||||||
event.stateKey!,
|
event.stateKey!,
|
||||||
roomId: event.roomId,
|
roomId: event.roomId,
|
||||||
),
|
),
|
||||||
|
|
@ -43,9 +47,10 @@ class const MembershipRenderer(
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text:
|
text:
|
||||||
"${switch (content.status) {
|
"${switch (content.status) {
|
||||||
.invite => "was invited to",
|
.invite => previous == .knock ? "was accepted into" : "was invited to",
|
||||||
.join => "joined",
|
.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",
|
.ban => "was banned from",
|
||||||
.knock => "asked to join",
|
.knock => "asked to join",
|
||||||
}} the room${event.sender == event.stateKey ? "" : " by"}",
|
}} the room${event.sender == event.stateKey ? "" : " by"}",
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ final class const RoomAppbar({
|
||||||
required final void Function() onOpenDrawer,
|
required final void Function() onOpenDrawer,
|
||||||
final void Function(BuildContext context)? onOpenMemberList,
|
final void Function(BuildContext context)? onOpenMemberList,
|
||||||
final void Function()? onOpenPinnedMessagesList,
|
final void Function()? onOpenPinnedMessagesList,
|
||||||
|
final void Function()? onOpenSearch,
|
||||||
super.key,
|
super.key,
|
||||||
}) extends ConsumerWidget implements PreferredSizeWidget {
|
}) extends ConsumerWidget implements PreferredSizeWidget {
|
||||||
@override
|
@override
|
||||||
|
|
@ -61,6 +62,11 @@ final class const RoomAppbar({
|
||||||
room == null
|
room == null
|
||||||
? []
|
? []
|
||||||
: [
|
: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: onOpenSearch?.call,
|
||||||
|
icon: Icon(Icons.search),
|
||||||
|
tooltip: "Search this room",
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: onOpenPinnedMessagesList?.call,
|
onPressed: onOpenPinnedMessagesList?.call,
|
||||||
icon: Icon(Icons.push_pin),
|
icon: Icon(Icons.push_pin),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:material_ui/material_ui.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/helpers/hooks/chat_scroll.dart";
|
||||||
import "package:nexus/models/event.dart";
|
import "package:nexus/models/event.dart";
|
||||||
import "package:nexus/widgets/renderers/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";
|
import "package:super_sliver_list/super_sliver_list.dart";
|
||||||
|
|
||||||
class const ChatTimeline({
|
class const ChatTimeline({
|
||||||
required final String roomId,
|
required final ChatParams chat,
|
||||||
required final ChatScroll scroll,
|
required final ChatScroll scroll,
|
||||||
required final JumpToEvent jumpToEvent,
|
required final JumpToEvent jumpToEvent,
|
||||||
required final IList<PopupMenuEntry> Function(Event) getEventOptions,
|
required final IList<PopupMenuEntry> Function(Event) getEventOptions,
|
||||||
required final int? highlightedEvent,
|
required final int? highlightedEvent,
|
||||||
required final double composerHeight,
|
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,
|
final void Function(int rowId)? onSwipe,
|
||||||
super.key,
|
super.key,
|
||||||
}) extends StatelessWidget {
|
}) extends StatelessWidget {
|
||||||
|
|
@ -24,10 +28,11 @@ class const ChatTimeline({
|
||||||
EventRenderer(
|
EventRenderer(
|
||||||
eventRowId,
|
eventRowId,
|
||||||
previousEventRowId: previousEventRowId,
|
previousEventRowId: previousEventRowId,
|
||||||
roomId: roomId,
|
roomId: chat.roomId,
|
||||||
jumpToEvent: jumpToEvent,
|
jumpToEvent: jumpToEvent,
|
||||||
getEventOptions: getEventOptions,
|
getEventOptions: getEventOptions,
|
||||||
onSwipe: onSwipe == null ? null : () => onSwipe!(eventRowId),
|
onSwipe: onSwipe == null ? null : () => onSwipe!(eventRowId),
|
||||||
|
readReceiptsChat: chat,
|
||||||
),
|
),
|
||||||
key: key,
|
key: key,
|
||||||
isHighlighted: highlightedEvent == eventRowId,
|
isHighlighted: highlightedEvent == eventRowId,
|
||||||
|
|
@ -40,7 +45,22 @@ class const ChatTimeline({
|
||||||
keyboardDismissBehavior: .onDrag,
|
keyboardDismissBehavior: .onDrag,
|
||||||
controller: scroll.scrollController,
|
controller: scroll.scrollController,
|
||||||
slivers: [
|
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(
|
SuperSliverList.builder(
|
||||||
itemCount: scroll.liveRows.length,
|
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/event.dart";
|
||||||
import "package:nexus/models/relation_type.dart";
|
import "package:nexus/models/relation_type.dart";
|
||||||
import "package:nexus/widgets/composer/composer.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/error_dialog.dart";
|
||||||
import "package:nexus/widgets/loading.dart";
|
import "package:nexus/widgets/loading.dart";
|
||||||
import "package:nexus/widgets/pinned_events_drawer.dart";
|
import "package:nexus/widgets/pinned_events_drawer.dart";
|
||||||
import "package:nexus/widgets/member_list.dart";
|
import "package:nexus/widgets/member_list.dart";
|
||||||
import "package:nexus/widgets/room_appbar.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/widgets/sync_status_banner.dart";
|
||||||
import "package:nexus/main.dart";
|
import "package:nexus/main.dart";
|
||||||
import "package:nexus/widgets/room_chat/chat_timeline.dart";
|
import "package:nexus/widgets/room_chat/chat_timeline.dart";
|
||||||
|
|
@ -45,6 +47,7 @@ final class const RoomChat({
|
||||||
final highlightedEvent = useState<int?>(null);
|
final highlightedEvent = useState<int?>(null);
|
||||||
|
|
||||||
final composerSize = useState<double>(64);
|
final composerSize = useState<double>(64);
|
||||||
|
final typingIndicatorSize = useState<double>(0);
|
||||||
|
|
||||||
final userId = ref.watch(ClientStateController.provider)?.userId;
|
final userId = ref.watch(ClientStateController.provider)?.userId;
|
||||||
final memberListOpened = ref
|
final memberListOpened = ref
|
||||||
|
|
@ -82,10 +85,11 @@ final class const RoomChat({
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final controllerProvider = RoomChatController.provider((
|
final ChatParams chat = (
|
||||||
roomId: roomId,
|
roomId: roomId,
|
||||||
contextualEvent: contextualEvent.value,
|
contextualEvent: contextualEvent.value,
|
||||||
));
|
);
|
||||||
|
final controllerProvider = RoomChatController.provider(chat);
|
||||||
final notifier = ref.watch(controllerProvider.notifier);
|
final notifier = ref.watch(controllerProvider.notifier);
|
||||||
|
|
||||||
final client = ref.read(ClientController.provider.notifier);
|
final client = ref.read(ClientController.provider.notifier);
|
||||||
|
|
@ -198,115 +202,148 @@ final class const RoomChat({
|
||||||
);
|
);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
endDrawer: PinnedEventsDrawer(
|
endDrawerEnableOpenDragGesture: false,
|
||||||
|
endDrawer: SearchDrawer(
|
||||||
roomId,
|
roomId,
|
||||||
getEventOptions: getEventOptions,
|
getEventOptions: getEventOptions,
|
||||||
jumpToEvent: jumpToEvent,
|
jumpToEvent: jumpToEvent,
|
||||||
),
|
),
|
||||||
body: Builder(
|
body: Builder(
|
||||||
builder: (middleContext) => Scaffold(
|
builder: (searchContext) => Scaffold(
|
||||||
endDrawer: showMembersByDefault ? null : MemberList(roomId),
|
endDrawerEnableOpenDragGesture: false,
|
||||||
appBar: RoomAppbar(
|
endDrawer: PinnedEventsDrawer(
|
||||||
roomId: roomId,
|
roomId,
|
||||||
isDesktop: isDesktop,
|
getEventOptions: getEventOptions,
|
||||||
onOpenDrawer: Scaffold.of(context).openDrawer,
|
jumpToEvent: jumpToEvent,
|
||||||
onOpenMemberList: (thisContext) {
|
|
||||||
ref
|
|
||||||
.read(MemberListOpenedController.provider.notifier)
|
|
||||||
.set(!memberListOpened);
|
|
||||||
Scaffold.of(thisContext).openEndDrawer();
|
|
||||||
},
|
|
||||||
onOpenPinnedMessagesList: Scaffold.of(middleContext).openEndDrawer,
|
|
||||||
),
|
),
|
||||||
body: Column(
|
body: Builder(
|
||||||
children: [
|
builder: (middleContext) => Scaffold(
|
||||||
const SyncStatusBanner(),
|
endDrawer: showMembersByDefault ? null : MemberList(roomId),
|
||||||
Expanded(
|
appBar: RoomAppbar(
|
||||||
child: Row(
|
roomId: roomId,
|
||||||
children: [
|
isDesktop: isDesktop,
|
||||||
Expanded(
|
onOpenDrawer: Scaffold.of(context).openDrawer,
|
||||||
child: Stack(
|
onOpenMemberList: (thisContext) {
|
||||||
children: [
|
ref
|
||||||
Positioned.fill(
|
.read(MemberListOpenedController.provider.notifier)
|
||||||
child: Padding(
|
.set(!memberListOpened);
|
||||||
padding: .symmetric(horizontal: 4),
|
Scaffold.of(thisContext).openEndDrawer();
|
||||||
child: switch (controllerData) {
|
},
|
||||||
AsyncLoading(value: _?) ||
|
onOpenPinnedMessagesList: Scaffold.of(middleContext)
|
||||||
AsyncData(value: _) => ChatTimeline(
|
.openEndDrawer,
|
||||||
scroll: scroll,
|
onOpenSearch: Scaffold.of(searchContext).openEndDrawer,
|
||||||
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: 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/services.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/controllers/client.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/adaptive_menu_button.dart";
|
||||||
|
import "package:nexus/widgets/settings/settings_layout.dart";
|
||||||
import "package:nexus/controllers/via.dart";
|
import "package:nexus/controllers/via.dart";
|
||||||
import "package:nexus/models/room.dart";
|
import "package:nexus/models/room.dart";
|
||||||
import "package:flutter_hooks/flutter_hooks.dart";
|
import "package:flutter_hooks/flutter_hooks.dart";
|
||||||
|
|
@ -15,7 +17,7 @@ import "package:nexus/main.dart";
|
||||||
|
|
||||||
final class const RoomMenuButton(
|
final class const RoomMenuButton(
|
||||||
final Room? room, {
|
final Room? room, {
|
||||||
final IList<Room> children = const IList.empty(),
|
final IList<Room> children = const .empty(),
|
||||||
super.key,
|
super.key,
|
||||||
}) extends ConsumerWidget {
|
}) extends ConsumerWidget {
|
||||||
@override
|
@override
|
||||||
|
|
@ -122,6 +124,21 @@ final class const RoomMenuButton(
|
||||||
title: Text("Invite"),
|
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(
|
PopupMenuItem(
|
||||||
onTap: () => context.showLeaveRoomSheet(
|
onTap: () => context.showLeaveRoomSheet(
|
||||||
room!.metadata!.id,
|
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:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:material_ui/material_ui.dart";
|
import "package:material_ui/material_ui.dart";
|
||||||
import "package:nexus/widgets/sheet_layout.dart";
|
import "package:nexus/widgets/sheet_layout.dart";
|
||||||
|
import "package:nexus/controllers/client.dart";
|
||||||
import "package:nexus/controllers/invites.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/get_localpart.dart";
|
||||||
import "package:nexus/helpers/extensions/join_room.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/main.dart";
|
||||||
import "package:nexus/widgets/avatar_or_hash.dart";
|
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||||
import "package:nexus/widgets/expandable_image.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/widgets/linkified_text.dart";
|
||||||
import "package:nexus/models/room_summary.dart";
|
import "package:nexus/models/room_summary.dart";
|
||||||
|
|
||||||
|
|
@ -15,11 +19,17 @@ class const RoomSummarySheet(
|
||||||
final RoomSummary summary, {
|
final RoomSummary summary, {
|
||||||
final IList<String>? via,
|
final IList<String>? via,
|
||||||
super.key,
|
super.key,
|
||||||
}) extends StatelessWidget {
|
}) extends ConsumerWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final name = summary.name ?? summary.canonicalAlias ?? summary.roomId;
|
final name = summary.name ?? summary.canonicalAlias ?? summary.roomId;
|
||||||
final isInvite = summary.membership == .invite;
|
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;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return SheetLayout(
|
return SheetLayout(
|
||||||
|
|
@ -83,7 +93,35 @@ class const RoomSummarySheet(
|
||||||
},
|
},
|
||||||
child: Text("Accept"),
|
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(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
context.joinRoom(
|
context.joinRoom(
|
||||||
|
|
@ -139,6 +177,17 @@ class const RoomSummarySheet(
|
||||||
subtitle: LinkifiedText(summary.topic!),
|
subtitle: LinkifiedText(summary.topic!),
|
||||||
leading: Icon(Icons.info),
|
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)
|
if (summary.encryption != null)
|
||||||
ListTile(title: Text("Encrypted"), leading: Icon(Icons.lock)),
|
ListTile(title: Text("Encrypted"), leading: Icon(Icons.lock)),
|
||||||
if (summary.worldReadable == true)
|
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:material_ui/material_ui.dart";
|
||||||
import "package:nexus/helpers/extensions/show_adaptive_sheet.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";
|
import "package:nexus/widgets/settings/radio_sheet.dart";
|
||||||
|
|
||||||
final class const DialogListTile<T>({
|
final class const DialogListTile<T>({
|
||||||
|
|
@ -8,32 +11,44 @@ final class const DialogListTile<T>({
|
||||||
required final Icon icon,
|
required final Icon icon,
|
||||||
required final String title,
|
required final String title,
|
||||||
required final List<T> options,
|
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,
|
required final String Function(T option) getName,
|
||||||
final Widget? subtitle,
|
final Widget? subtitle,
|
||||||
super.key,
|
super.key,
|
||||||
}) extends ConsumerWidget {
|
}) extends HookWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) => ListTile(
|
Widget build(BuildContext context) {
|
||||||
enabled: onChanged != null,
|
final saving = Saving.use();
|
||||||
onTap: () => context.showAdaptiveSheet(
|
Future<void> change(T value) => saving.run(() => onChanged!(value));
|
||||||
(context) => RadioSheet<T>(
|
|
||||||
title: title,
|
return ListTile(
|
||||||
getName: getName,
|
enabled: onChanged != null && !saving.active,
|
||||||
onChanged: onChanged,
|
onTap: () => context.showAdaptiveSheet(
|
||||||
value: value,
|
(context) => RadioSheet<T>(
|
||||||
options: options,
|
title: title,
|
||||||
|
getName: getName,
|
||||||
|
onChanged: onChanged == null ? null : change,
|
||||||
|
value: value,
|
||||||
|
options: options,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
title: Text(title),
|
||||||
title: Text(title),
|
subtitle: subtitle,
|
||||||
subtitle: subtitle,
|
leading: icon,
|
||||||
leading: icon,
|
trailing: ConstrainedBox(
|
||||||
trailing: Chip(
|
constraints: const .new(maxWidth: 160),
|
||||||
label: Text(
|
child: Chip(
|
||||||
value == null ? "None" : getName(value as T),
|
label: Text(
|
||||||
overflow: TextOverflow.ellipsis,
|
value is T ? getName(value as T) : "None",
|
||||||
style: onChanged == null ? .new(color: Colors.grey) : null,
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: onChanged == null || saving.active
|
||||||
|
? .new(color: Colors.grey)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
131
lib/widgets/settings/inline_text_field.dart
Normal file
131
lib/widgets/settings/inline_text_field.dart
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
import "dart:async";
|
||||||
|
|
||||||
|
import "package:flutter/services.dart";
|
||||||
|
import "package:flutter_hooks/flutter_hooks.dart";
|
||||||
|
import "package:material_ui/material_ui.dart";
|
||||||
|
import "package:nexus/helpers/hooks/saving.dart";
|
||||||
|
|
||||||
|
/// Centered text that's edited in place, saving when submitted or unfocused.
|
||||||
|
class const InlineTextField({
|
||||||
|
required final String? value,
|
||||||
|
required final String hint,
|
||||||
|
|
||||||
|
/// Disables the field if null, and while a returned future runs.
|
||||||
|
required final FutureOr<void> Function(String value)? onChanged,
|
||||||
|
final TextStyle? style,
|
||||||
|
|
||||||
|
/// Pads the start to match the edit icon, keeping the text centered when the
|
||||||
|
/// field is on its own, rather than beside others.
|
||||||
|
final bool balanced = true,
|
||||||
|
super.key,
|
||||||
|
}) extends HookWidget {
|
||||||
|
static double iconSizeOf(TextStyle? style) =>
|
||||||
|
((style?.fontSize ?? 16) * 0.7).clamp(16.0, 24.0);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final saving = Saving.use();
|
||||||
|
final controller = useTextEditingController(text: value);
|
||||||
|
final isEmpty = useValueListenable(controller).text.isEmpty;
|
||||||
|
final focusNode = useFocusNode();
|
||||||
|
final focused = useListenableSelector(focusNode, () => focusNode.hasFocus);
|
||||||
|
final latest = useRef(value);
|
||||||
|
latest.value = value;
|
||||||
|
|
||||||
|
// Show changes made elsewhere, unless they'd overwrite an edit. Effects run
|
||||||
|
// while building, so this waits until after, to not rebuild the field mid-build.
|
||||||
|
useEffect(() {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (context.mounted && !focusNode.hasFocus) {
|
||||||
|
controller.text = value ?? "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
Future<void> listener() async {
|
||||||
|
if (focusNode.hasFocus) return;
|
||||||
|
final text = controller.text.trim();
|
||||||
|
if (text != (latest.value ?? "")) {
|
||||||
|
await saving.run(() => onChanged?.call(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.mounted && !focusNode.hasFocus) {
|
||||||
|
controller.text = latest.value ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
focusNode.addListener(listener);
|
||||||
|
return () => focusNode.removeListener(listener);
|
||||||
|
}, [focusNode, onChanged]);
|
||||||
|
|
||||||
|
const border = UnderlineInputBorder(borderRadius: .zero, borderSide: .none);
|
||||||
|
final iconSize = iconSizeOf(style);
|
||||||
|
final iconConstraints = BoxConstraints.tight(Size(iconSize + 12, iconSize));
|
||||||
|
|
||||||
|
return CallbackShortcuts(
|
||||||
|
bindings: {
|
||||||
|
const SingleActivator(.escape): () {
|
||||||
|
controller.text = value ?? "";
|
||||||
|
focusNode.unfocus();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Sized to the text, so the edit icon sits beside it
|
||||||
|
child: IntrinsicWidth(
|
||||||
|
child: TextField(
|
||||||
|
controller: controller,
|
||||||
|
focusNode: focusNode,
|
||||||
|
enabled: onChanged != null && !saving.active,
|
||||||
|
style: style,
|
||||||
|
textAlign: .center,
|
||||||
|
textCapitalization: .sentences,
|
||||||
|
// Wraps long text, but stays one line, with enter saving it
|
||||||
|
maxLines: null,
|
||||||
|
keyboardType: .text,
|
||||||
|
textInputAction: .done,
|
||||||
|
inputFormatters: [FilteringTextInputFormatter.singleLineFormatter],
|
||||||
|
onSubmitted: (_) => focusNode.unfocus(),
|
||||||
|
onTapOutside: (_) => focusNode.unfocus(),
|
||||||
|
decoration: .new(
|
||||||
|
// The hint counts towards the field's width, so it's only set when it shows
|
||||||
|
hintText: isEmpty ? hint : null,
|
||||||
|
hintStyle: style?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.transparent,
|
||||||
|
hoverColor: colorScheme.surfaceContainerHighest,
|
||||||
|
isDense: true,
|
||||||
|
// Without the prefix, this keeps the text off the edge of the hover background
|
||||||
|
contentPadding: EdgeInsetsDirectional.only(
|
||||||
|
start: balanced ? 0 : 6,
|
||||||
|
top: 8,
|
||||||
|
bottom: 8,
|
||||||
|
),
|
||||||
|
border: border,
|
||||||
|
focusedBorder: border.copyWith(
|
||||||
|
borderSide: BorderSide(color: colorScheme.primary, width: 2),
|
||||||
|
),
|
||||||
|
prefixIcon: balanced ? const SizedBox() : null,
|
||||||
|
prefixIconConstraints: iconConstraints,
|
||||||
|
suffixIcon: saving.active
|
||||||
|
? Center(
|
||||||
|
child: SizedBox.square(
|
||||||
|
dimension: iconSize - 4,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
Icons.edit,
|
||||||
|
size: iconSize,
|
||||||
|
color: focused
|
||||||
|
? colorScheme.onSurfaceVariant.withValues(alpha: 0.38)
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
suffixIconConstraints: iconConstraints,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
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 = (1 << 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 {
|
}) extends HookWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return SheetLayout(
|
||||||
title: Text(title),
|
title: Text(title),
|
||||||
actions: [
|
actions: [
|
||||||
|
|
@ -20,7 +21,7 @@ final class const RadioSheet<T>({
|
||||||
if (onChanged != null)
|
if (onChanged != null)
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (mutValue.value != null) onChanged!(mutValue.value as T);
|
if (chosen.value case (final picked,)) onChanged!(picked);
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
},
|
},
|
||||||
child: Text("OK"),
|
child: Text("OK"),
|
||||||
|
|
@ -28,8 +29,11 @@ final class const RadioSheet<T>({
|
||||||
],
|
],
|
||||||
children: [
|
children: [
|
||||||
RadioGroup<T>(
|
RadioGroup<T>(
|
||||||
groupValue: mutValue.value ?? value,
|
groupValue: switch (chosen.value) {
|
||||||
onChanged: (value) => mutValue.value = value ?? mutValue.value,
|
(final picked,) => picked,
|
||||||
|
null => value,
|
||||||
|
},
|
||||||
|
onChanged: (value) => chosen.value = (value as T,),
|
||||||
|
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
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
|
@override
|
||||||
Widget build(BuildContext context) => Padding(
|
Widget build(BuildContext context) => Padding(
|
||||||
padding: .only(bottom: MediaQuery.viewInsetsOf(context).bottom),
|
padding: .only(bottom: MediaQuery.viewInsetsOf(context).bottom),
|
||||||
child: SingleChildScrollView(
|
child: Column(
|
||||||
padding: const .all(24),
|
mainAxisSize: .min,
|
||||||
child: Column(
|
crossAxisAlignment: .stretch,
|
||||||
mainAxisSize: .min,
|
children: [
|
||||||
crossAxisAlignment: .stretch,
|
if (title case final title?)
|
||||||
spacing: 12,
|
Padding(
|
||||||
children: [
|
padding: const .fromLTRB(24, 24, 24, 0),
|
||||||
if (title case final title?)
|
child: DefaultTextStyle(
|
||||||
DefaultTextStyle(
|
|
||||||
style: Theme.of(context).textTheme.headlineSmall!,
|
style: Theme.of(context).textTheme.headlineSmall!,
|
||||||
child: title,
|
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/models/space_hierarchy.dart";
|
||||||
import "package:nexus/widgets/avatar_or_hash.dart";
|
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||||
import "package:nexus/widgets/expandable_image.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/loading.dart";
|
||||||
import "package:nexus/widgets/room_summary_sheet.dart";
|
import "package:nexus/widgets/room_summary_sheet.dart";
|
||||||
import "package:nexus/widgets/sheet_layout.dart";
|
import "package:nexus/widgets/sheet_layout.dart";
|
||||||
|
|
@ -47,6 +48,14 @@ class const SpaceHierarchySheet(final String spaceId, {super.key})
|
||||||
),
|
),
|
||||||
child: const Text("Leave"),
|
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(
|
: TextButton(
|
||||||
onPressed: () => context.joinRoom(
|
onPressed: () => context.joinRoom(
|
||||||
.new(roomIdOrAlias: room.summary.roomId, via: child.via),
|
.new(roomIdOrAlias: room.summary.roomId, via: child.via),
|
||||||
|
|
|
||||||
102
lib/widgets/timezone_field.dart
Normal file
102
lib/widgets/timezone_field.dart
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import "dart:async";
|
||||||
|
|
||||||
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
|
import "package:material_ui/material_ui.dart";
|
||||||
|
import "package:nexus/controllers/timezones.dart";
|
||||||
|
import "package:nexus/helpers/extensions/is_desktop.dart";
|
||||||
|
import "package:nexus/helpers/hooks/saving.dart";
|
||||||
|
import "package:nexus/widgets/settings/inline_text_field.dart";
|
||||||
|
import "package:timezone/timezone.dart";
|
||||||
|
|
||||||
|
class const TimezoneField({
|
||||||
|
required final String? value,
|
||||||
|
|
||||||
|
required final FutureOr<void> Function(String? value)? onChanged,
|
||||||
|
final TextStyle? style,
|
||||||
|
super.key,
|
||||||
|
}) extends HookConsumerWidget {
|
||||||
|
static String offsetOf(String timezone) {
|
||||||
|
final offset = TZDateTime.now(getLocation(timezone)).timeZoneOffset;
|
||||||
|
final hours = offset.inHours.abs().toString().padLeft(2, "0");
|
||||||
|
final minutes = (offset.inMinutes.abs() % 60).toString().padLeft(2, "0");
|
||||||
|
return "UTC${offset.isNegative ? "-" : "+"}$hours:$minutes";
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final saving = Saving.use();
|
||||||
|
final timezones = ref.watch(TimezonesController.provider);
|
||||||
|
final enabled = onChanged != null && !saving.active;
|
||||||
|
final iconSize = InlineTextField.iconSizeOf(style);
|
||||||
|
|
||||||
|
void choose(SearchController controller, String? timezone) {
|
||||||
|
controller.closeView("");
|
||||||
|
if (timezone != value) saving.run(() => onChanged!(timezone));
|
||||||
|
}
|
||||||
|
|
||||||
|
return SearchAnchor(
|
||||||
|
viewHintText: "Search timezones...",
|
||||||
|
viewConstraints: context.isDesktop
|
||||||
|
? const .new(minWidth: 320, maxWidth: 400, maxHeight: 400)
|
||||||
|
: null,
|
||||||
|
builder: (context, controller) => InkWell(
|
||||||
|
hoverColor: colorScheme.surfaceContainerHighest,
|
||||||
|
onTap: enabled ? controller.openView : null,
|
||||||
|
child: Padding(
|
||||||
|
padding: const .fromLTRB(6, 8, 0, 8),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: .min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
value?.replaceAll("_", " ") ?? "Timezone",
|
||||||
|
style: value == null
|
||||||
|
? style?.copyWith(color: colorScheme.onSurfaceVariant)
|
||||||
|
: style,
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: iconSize + 12,
|
||||||
|
child: saving.active
|
||||||
|
? Center(
|
||||||
|
child: SizedBox.square(
|
||||||
|
dimension: iconSize - 4,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
Icons.edit,
|
||||||
|
size: iconSize,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
suggestionsBuilder: (context, controller) {
|
||||||
|
final query = controller.text.trim().toLowerCase().replaceAll(" ", "_");
|
||||||
|
|
||||||
|
return [
|
||||||
|
if (value != null && query.isEmpty)
|
||||||
|
ListTile(
|
||||||
|
leading: Icon(Icons.close),
|
||||||
|
title: Text("Remove timezone"),
|
||||||
|
onTap: () => choose(controller, null),
|
||||||
|
),
|
||||||
|
...timezones
|
||||||
|
.where((timezone) => timezone.toLowerCase().contains(query))
|
||||||
|
.map(
|
||||||
|
(timezone) => ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
timezone == value ? Icons.check : Icons.schedule,
|
||||||
|
),
|
||||||
|
title: Text(timezone.replaceAll("_", " ")),
|
||||||
|
subtitle: Text(offsetOf(timezone)),
|
||||||
|
onTap: () => choose(controller, timezone),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import "package:collection/collection.dart";
|
import "package:collection/collection.dart";
|
||||||
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
|
import "package:dynamic_color/dynamic_color.dart";
|
||||||
import "package:material_ui/material_ui.dart";
|
import "package:material_ui/material_ui.dart";
|
||||||
import "package:nexus/widgets/sheet_layout.dart";
|
import "package:nexus/widgets/sheet_layout.dart";
|
||||||
import "package:flutter_hooks/flutter_hooks.dart";
|
import "package:flutter_hooks/flutter_hooks.dart";
|
||||||
|
|
@ -7,24 +9,35 @@ import "package:intl/intl.dart";
|
||||||
import "package:m3e_buttons/m3e_buttons.dart";
|
import "package:m3e_buttons/m3e_buttons.dart";
|
||||||
import "package:nexus/controllers/client.dart";
|
import "package:nexus/controllers/client.dart";
|
||||||
import "package:nexus/controllers/client_state.dart";
|
import "package:nexus/controllers/client_state.dart";
|
||||||
|
import "package:nexus/controllers/mutual_rooms.dart";
|
||||||
import "package:nexus/controllers/power_level.dart";
|
import "package:nexus/controllers/power_level.dart";
|
||||||
import "package:nexus/controllers/profile.dart";
|
import "package:nexus/controllers/profile.dart";
|
||||||
import "package:nexus/controllers/rooms.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/better_when.dart";
|
||||||
import "package:nexus/helpers/extensions/create_room.dart";
|
import "package:nexus/helpers/extensions/create_room.dart";
|
||||||
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
|
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
|
||||||
import "package:nexus/helpers/extensions/get_localpart.dart";
|
import "package:nexus/helpers/extensions/get_localpart.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/content/membership.dart";
|
import "package:nexus/models/content/membership.dart";
|
||||||
import "package:nexus/models/membership_action.dart";
|
import "package:nexus/models/membership_action.dart";
|
||||||
|
import "package:nexus/models/profile_response.dart";
|
||||||
import "package:nexus/widgets/avatar_or_hash.dart";
|
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||||
import "package:nexus/main.dart";
|
import "package:nexus/main.dart";
|
||||||
|
import "package:nexus/widgets/editable_avatar.dart";
|
||||||
import "package:nexus/widgets/expandable_image.dart";
|
import "package:nexus/widgets/expandable_image.dart";
|
||||||
|
import "package:nexus/widgets/settings/inline_text_field.dart";
|
||||||
import "package:nexus/widgets/start_dm_sheet.dart";
|
import "package:nexus/widgets/start_dm_sheet.dart";
|
||||||
|
import "package:nexus/widgets/timezone_field.dart";
|
||||||
|
|
||||||
final class const UserBottomSheet(
|
final class const UserBottomSheet(
|
||||||
final MembershipContent member,
|
|
||||||
final String userId, {
|
final String userId, {
|
||||||
final String? roomId,
|
final String? roomId,
|
||||||
|
|
||||||
|
/// Edits the current user's global profile, instead of showing actions.
|
||||||
|
final bool editable = false,
|
||||||
super.key,
|
super.key,
|
||||||
}) extends ConsumerWidget {
|
}) extends ConsumerWidget {
|
||||||
@override
|
@override
|
||||||
|
|
@ -35,6 +48,43 @@ final class const UserBottomSheet(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
);
|
);
|
||||||
final client = ref.read(ClientController.provider.notifier);
|
final client = ref.read(ClientController.provider.notifier);
|
||||||
|
final profileController = ref.read(
|
||||||
|
ProfileController.provider(userId).notifier,
|
||||||
|
);
|
||||||
|
Future<void> setProfileField(String field, Object? value) =>
|
||||||
|
profileController.setField(field, value).onError(showError);
|
||||||
|
|
||||||
|
final profile = ref
|
||||||
|
.watch(ProfileController.provider(userId))
|
||||||
|
.value
|
||||||
|
?.profile;
|
||||||
|
final englishPronouns = (profile?.pronouns ?? const IList<Pronoun>.empty())
|
||||||
|
// TODO: Check system language (l10n)
|
||||||
|
.where((pronoun) => pronoun.language == "en");
|
||||||
|
final divider = SizedBox(
|
||||||
|
height: 16,
|
||||||
|
child: VerticalDivider(
|
||||||
|
thickness: 1.5,
|
||||||
|
width: 4,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget avatarOf(MembershipContent member) => ExpandableImage(
|
||||||
|
member.avatarUrl == null ? null : .new(mxc: member.avatarUrl!),
|
||||||
|
downloadFilename: member.displayName ?? userId.localpart,
|
||||||
|
child: AvatarOrHash(
|
||||||
|
member.avatarUrl,
|
||||||
|
member.displayName ?? userId.localpart,
|
||||||
|
dimension: 200,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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) =>
|
void showMembershipSheet(MembershipAction action) =>
|
||||||
context.showAdaptiveSheet(
|
context.showAdaptiveSheet(
|
||||||
|
|
@ -82,181 +132,346 @@ final class const UserBottomSheet(
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return switch (ref.watch(
|
||||||
child: Padding(
|
UserController.provider(.new(roomId: roomId, userId: userId)),
|
||||||
padding: .all(42),
|
)) {
|
||||||
child: Column(
|
AsyncError(:final error, :final stackTrace) => ErrorDialog(
|
||||||
spacing: 4,
|
error,
|
||||||
mainAxisSize: .min,
|
stackTrace,
|
||||||
crossAxisAlignment: .center,
|
),
|
||||||
children: [
|
AsyncData(value: final member) ||
|
||||||
Row(
|
AsyncLoading(value: final member?) => SingleChildScrollView(
|
||||||
mainAxisAlignment: .end,
|
child: Padding(
|
||||||
children: [
|
padding: .all(editable ? 16 : 42),
|
||||||
M3EButton(
|
child: Column(
|
||||||
onPressed: Navigator.of(context).pop,
|
spacing: 4,
|
||||||
child: Icon(Icons.close),
|
mainAxisSize: .min,
|
||||||
|
crossAxisAlignment: .center,
|
||||||
|
children: [
|
||||||
|
if (!editable) ...[
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: .end,
|
||||||
|
children: [
|
||||||
|
M3EButton(
|
||||||
|
onPressed: Navigator.of(context).pop,
|
||||||
|
child: Icon(Icons.close),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
SizedBox(height: 18),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
SizedBox(height: 18),
|
|
||||||
|
|
||||||
ExpandableImage(
|
if (editable)
|
||||||
member.avatarUrl == null ? null : .new(mxc: member.avatarUrl!),
|
EditableAvatar(
|
||||||
downloadFilename: member.displayName ?? userId.localpart,
|
hasAvatar: member.avatarUrl != null,
|
||||||
child: AvatarOrHash(
|
onChanged: (url) => setProfileField(
|
||||||
member.avatarUrl,
|
Profile.avatarUrlKey,
|
||||||
member.displayName ?? userId.localpart,
|
url?.toString() ?? "",
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
child: avatarOf(member),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
avatarOf(member),
|
||||||
|
|
||||||
|
SizedBox(height: 8),
|
||||||
|
|
||||||
|
if (editable)
|
||||||
|
InlineTextField(
|
||||||
|
value: profile?.displayName,
|
||||||
|
hint: userId.localpart,
|
||||||
|
style: textTheme.headlineLarge,
|
||||||
|
onChanged: profile == null
|
||||||
|
? null
|
||||||
|
: (value) =>
|
||||||
|
setProfileField(Profile.displayNameKey, value),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
SelectableText(
|
||||||
|
member.displayName ?? userId.localpart,
|
||||||
|
style: textTheme.headlineLarge,
|
||||||
|
textAlign: .center,
|
||||||
|
),
|
||||||
|
SelectableText(userId, textAlign: .center, style: subtitleStyle),
|
||||||
|
|
||||||
|
ref
|
||||||
|
.watch(ProfileController.provider(userId))
|
||||||
|
.betterWhen(
|
||||||
|
// Keeps showing the profile while it's fetched again after saving
|
||||||
|
skipLoadingOnRefresh: true,
|
||||||
|
loading: () => Text(""),
|
||||||
|
data: (profileResponse) => Column(
|
||||||
|
children: [
|
||||||
|
Wrap(
|
||||||
|
crossAxisAlignment: .center,
|
||||||
|
alignment: .center,
|
||||||
|
spacing: 4,
|
||||||
|
runSpacing: 4,
|
||||||
|
children: editable
|
||||||
|
? [
|
||||||
|
InlineTextField(
|
||||||
|
value: englishPronouns
|
||||||
|
.map((pronoun) => pronoun.summary)
|
||||||
|
.join(", "),
|
||||||
|
hint: "Pronouns, e.g. they/them",
|
||||||
|
balanced: false,
|
||||||
|
style: subtitleStyle,
|
||||||
|
onChanged: (value) {
|
||||||
|
final pronouns = profileResponse
|
||||||
|
.profile
|
||||||
|
.pronouns
|
||||||
|
.where(
|
||||||
|
(pronoun) =>
|
||||||
|
// TODO: Check system language (l10n)
|
||||||
|
pronoun.language != "en",
|
||||||
|
)
|
||||||
|
.toIList()
|
||||||
|
.addAll(
|
||||||
|
value
|
||||||
|
.split(",")
|
||||||
|
.map(
|
||||||
|
(summary) => summary.trim(),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
(summary) =>
|
||||||
|
summary.isNotEmpty,
|
||||||
|
)
|
||||||
|
.map(
|
||||||
|
(summary) => Pronoun(
|
||||||
|
language: "en",
|
||||||
|
summary: summary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return setProfileField(
|
||||||
|
Profile.unstablePronounsKey,
|
||||||
|
pronouns.isEmpty
|
||||||
|
? null
|
||||||
|
: pronouns
|
||||||
|
.map(
|
||||||
|
(pronoun) =>
|
||||||
|
pronoun.toJson(),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
divider,
|
||||||
|
TimezoneField(
|
||||||
|
value: profileResponse.profile.timezone,
|
||||||
|
style: subtitleStyle,
|
||||||
|
onChanged: (value) => setProfileField(
|
||||||
|
Profile.timezoneKey,
|
||||||
|
value,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
...englishPronouns
|
||||||
|
.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)
|
||||||
|
divider,
|
||||||
|
Text(
|
||||||
|
profileResponse.profile.timezone!,
|
||||||
|
textAlign: .center,
|
||||||
|
style: subtitleStyle,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
if (!editable &&
|
||||||
|
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")}",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
_ => SizedBox.shrink(),
|
||||||
|
},
|
||||||
|
|
||||||
|
SizedBox(height: 8),
|
||||||
|
if (editable ||
|
||||||
|
userId !=
|
||||||
|
ref.watch(ClientStateController.provider)?.userId) ...[
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: M3EButton.icon(
|
||||||
|
onPressed: editable
|
||||||
|
? null
|
||||||
|
: () 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"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
SizedBox(height: 8),
|
if (roomId != null &&
|
||||||
if (userId !=
|
member.status == .knock &&
|
||||||
ref.watch(ClientStateController.provider)?.userId) ...[
|
ref.watch(
|
||||||
Row(
|
PowerLevelController.provider(
|
||||||
children: [
|
.membershipAction(
|
||||||
Expanded(
|
action: .invite,
|
||||||
child: M3EButton.icon(
|
roomId: roomId!,
|
||||||
onPressed: () async {
|
targetUser: userId,
|
||||||
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"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
|
|
||||||
if (roomId != null &&
|
|
||||||
ref.watch(
|
|
||||||
PowerLevelController.provider(
|
|
||||||
.membershipAction(
|
|
||||||
action: .kick,
|
|
||||||
roomId: roomId!,
|
|
||||||
targetUser: userId,
|
|
||||||
),
|
),
|
||||||
),
|
))
|
||||||
))
|
Padding(
|
||||||
Padding(
|
padding: .only(top: 4),
|
||||||
padding: .only(top: 4),
|
child: Row(
|
||||||
child: Row(
|
children: [
|
||||||
spacing: 8,
|
Expanded(
|
||||||
children: [
|
child: M3EButton.icon(
|
||||||
if (member.status == .join || member.status == .invite)
|
onPressed: () => client
|
||||||
M3EButton.icon(
|
.setMembership(
|
||||||
onPressed: () => showMembershipSheet(.kick),
|
.new(
|
||||||
shape: .square,
|
userId: userId,
|
||||||
icon: Icon(Icons.sports_martial_arts),
|
roomId: roomId!,
|
||||||
label: Text("Kick"),
|
action: .invite,
|
||||||
decoration: .styleFrom(
|
),
|
||||||
backgroundColor: theme.colorScheme.error,
|
)
|
||||||
foregroundColor: theme.colorScheme.onError,
|
.onError(showError),
|
||||||
|
shape: .square,
|
||||||
|
icon: Icon(Icons.check),
|
||||||
|
label: Text("Accept knock"),
|
||||||
|
decoration: .styleFrom(
|
||||||
|
backgroundColor: acceptScheme.primary,
|
||||||
|
foregroundColor: acceptScheme.onPrimary,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
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(),
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
// Shown as a moderator would see it, while editing
|
||||||
|
if (editable ||
|
||||||
|
(roomId != null &&
|
||||||
|
ref.watch(
|
||||||
|
PowerLevelController.provider(
|
||||||
|
.membershipAction(
|
||||||
|
action: .kick,
|
||||||
|
roomId: roomId!,
|
||||||
|
targetUser: userId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)))
|
||||||
|
Padding(
|
||||||
|
padding: .only(top: 4),
|
||||||
|
child: Row(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
if (editable ||
|
||||||
|
switch (member.status) {
|
||||||
|
.join || .invite || .knock => true,
|
||||||
|
_ => false,
|
||||||
|
})
|
||||||
|
M3EButton.icon(
|
||||||
|
onPressed: editable
|
||||||
|
? null
|
||||||
|
: () => 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: editable
|
||||||
|
? null
|
||||||
|
: () => 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_libs_linux/media_kit_libs_linux_plugin.h>
|
||||||
#include <media_kit_video/media_kit_video_plugin.h>
|
#include <media_kit_video/media_kit_video_plugin.h>
|
||||||
#include <pasteboard/pasteboard_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 <url_launcher_linux/url_launcher_plugin.h>
|
||||||
#include <window_manager/window_manager_plugin.h>
|
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||||
g_autoptr(FlPluginRegistrar) app_links_linux_registrar =
|
g_autoptr(FlPluginRegistrar) app_links_linux_registrar =
|
||||||
|
|
@ -35,13 +33,7 @@ void fl_register_plugins(FlPluginRegistry* registry) {
|
||||||
g_autoptr(FlPluginRegistrar) pasteboard_registrar =
|
g_autoptr(FlPluginRegistrar) pasteboard_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "PasteboardPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "PasteboardPlugin");
|
||||||
pasteboard_plugin_register_with_registrar(pasteboard_registrar);
|
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 =
|
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
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_libs_linux
|
||||||
media_kit_video
|
media_kit_video
|
||||||
pasteboard
|
pasteboard
|
||||||
screen_retriever_linux
|
|
||||||
url_launcher_linux
|
url_launcher_linux
|
||||||
window_manager
|
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue