Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
d0da09cf15 |
|||
|
52c84c93d1 |
|||
|
7bbe2cccf6 |
|||
|
15287621e1 |
|||
|
5f9cf8c7f5 |
118 changed files with 750 additions and 1027 deletions
|
|
@ -1,31 +0,0 @@
|
||||||
import "package:collection/collection.dart";
|
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
|
||||||
import "package:material_emoji_picker/material_emoji_picker.dart";
|
|
||||||
|
|
||||||
/// Maps each unicode emoji, without variation selectors, to its shortcode.
|
|
||||||
class EmojiShortcodesController extends Notifier<IMap<String, String>> {
|
|
||||||
static final _variationSelectors = RegExp("[\uFE0E\uFE0F]");
|
|
||||||
|
|
||||||
static String normalize(String emoji) =>
|
|
||||||
emoji.replaceAll(_variationSelectors, "");
|
|
||||||
|
|
||||||
@override
|
|
||||||
IMap<String, String> build() => .fromEntries(
|
|
||||||
ref
|
|
||||||
.watch(EmojiController.provider)
|
|
||||||
.requireValue
|
|
||||||
.map((category) => category.emojis)
|
|
||||||
.flattened
|
|
||||||
.where((emoji) => emoji.aliases.isNotEmpty)
|
|
||||||
.map(
|
|
||||||
(emoji) =>
|
|
||||||
MapEntry(normalize(emoji.value), ":${emoji.aliases.first}:"),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
static final provider =
|
|
||||||
NotifierProvider<EmojiShortcodesController, IMap<String, String>>(
|
|
||||||
EmojiShortcodesController.new,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +1,14 @@
|
||||||
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";
|
||||||
import "package:nexus/controllers/emoji_shortcodes.dart";
|
|
||||||
import "package:nexus/controllers/rooms.dart";
|
import "package:nexus/controllers/rooms.dart";
|
||||||
import "package:nexus/models/configs/event.dart";
|
import "package:nexus/models/configs/event.dart";
|
||||||
import "package:nexus/models/content/reaction.dart";
|
import "package:nexus/models/content/reaction.dart";
|
||||||
import "package:nexus/models/reaction_group.dart";
|
|
||||||
|
|
||||||
class ReactionsController(final EventConfig config)
|
class ReactionsController(final EventConfig config)
|
||||||
extends AsyncNotifier<IMap<String, ReactionGroup>> {
|
extends AsyncNotifier<IMap<String, IList<String>>> {
|
||||||
@override
|
@override
|
||||||
Future<IMap<String, ReactionGroup>> build() async {
|
Future<IMap<String, IList<String>>> build() async {
|
||||||
final unicodeShortcodes = ref.watch(EmojiShortcodesController.provider);
|
|
||||||
|
|
||||||
final eventInfo = ref.watch(
|
final eventInfo = ref.watch(
|
||||||
RoomsController.provider.select((value) {
|
RoomsController.provider.select((value) {
|
||||||
final event = value[config.roomId]?.events[config.eventRowId];
|
final event = value[config.roomId]?.events[config.eventRowId];
|
||||||
|
|
@ -34,25 +30,12 @@ class ReactionsController(final EventConfig config)
|
||||||
|
|
||||||
return reactionEvents
|
return reactionEvents
|
||||||
?.where((event) => event.redactedBy == null)
|
?.where((event) => event.redactedBy == null)
|
||||||
.fold<IMap<String, ReactionGroup>>(.new(), (acc, event) {
|
.fold<IMap<String, IList<String>>>(.new(), (acc, event) {
|
||||||
if (event.content case ReactionContent(
|
if (event.content case ReactionContent(:final key?)) {
|
||||||
:final key?,
|
|
||||||
:final shortcode,
|
|
||||||
)) {
|
|
||||||
return acc.update(
|
return acc.update(
|
||||||
key,
|
key,
|
||||||
(group) => group.copyWith(
|
(list) => list.add(event.sender),
|
||||||
senders: group.senders.add(event.sender),
|
ifAbsent: () => .new([event.sender]),
|
||||||
shortcode: group.shortcode ?? shortcode,
|
|
||||||
),
|
|
||||||
ifAbsent: () => .new(
|
|
||||||
senders: .new([event.sender]),
|
|
||||||
shortcode:
|
|
||||||
shortcode ??
|
|
||||||
unicodeShortcodes[EmojiShortcodesController.normalize(
|
|
||||||
key,
|
|
||||||
)],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -64,7 +47,7 @@ class ReactionsController(final EventConfig config)
|
||||||
static final provider =
|
static final provider =
|
||||||
AsyncNotifierProvider.family<
|
AsyncNotifierProvider.family<
|
||||||
ReactionsController,
|
ReactionsController,
|
||||||
IMap<String, ReactionGroup>,
|
IMap<String, IList<String>>,
|
||||||
EventConfig
|
EventConfig
|
||||||
>(ReactionsController.new);
|
>(ReactionsController.new);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,9 @@ import "package:nexus/models/join_rule.dart";
|
||||||
import "package:nexus/models/power_level_permission.dart";
|
import "package:nexus/models/power_level_permission.dart";
|
||||||
import "package:nexus/models/setting.dart";
|
import "package:nexus/models/setting.dart";
|
||||||
import "package:nexus/models/settings_category.dart";
|
import "package:nexus/models/settings_category.dart";
|
||||||
import "package:nexus/widgets/avatars/avatar_or_hash.dart";
|
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||||
import "package:nexus/widgets/common/divider_text.dart";
|
import "package:nexus/widgets/divider_text.dart";
|
||||||
import "package:nexus/widgets/avatars/image_pack_avatar.dart";
|
import "package:nexus/widgets/image_pack_avatar.dart";
|
||||||
import "package:nexus/widgets/settings/add_users_power_level_sheet.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/aliases_sheet.dart";
|
||||||
import "package:nexus/widgets/settings/allowed_rooms_sheet.dart";
|
import "package:nexus/widgets/settings/allowed_rooms_sheet.dart";
|
||||||
|
|
|
||||||
|
|
@ -35,15 +35,15 @@ 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/models/show_and_send.dart";
|
||||||
import "package:nexus/main.dart";
|
import "package:nexus/main.dart";
|
||||||
import "package:nexus/widgets/avatars/avatar_or_hash.dart";
|
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||||
import "package:nexus/widgets/common/divider_text.dart";
|
import "package:nexus/widgets/divider_text.dart";
|
||||||
import "package:nexus/widgets/avatars/image_pack_avatar.dart";
|
import "package:nexus/widgets/image_pack_avatar.dart";
|
||||||
import "package:nexus/widgets/settings/block_user_field.dart";
|
import "package:nexus/widgets/settings/block_user_field.dart";
|
||||||
import "package:nexus/widgets/settings/dialog_list_tile.dart";
|
import "package:nexus/widgets/settings/dialog_list_tile.dart";
|
||||||
import "package:nexus/widgets/settings/enable_image_packs_sheet.dart";
|
import "package:nexus/widgets/settings/enable_image_packs_sheet.dart";
|
||||||
import "package:nexus/widgets/settings/keyword_field.dart";
|
import "package:nexus/widgets/settings/keyword_field.dart";
|
||||||
import "package:nexus/widgets/users/user_bottom_sheet.dart";
|
import "package:nexus/widgets/user_bottom_sheet.dart";
|
||||||
import "package:nexus/widgets/encryption/reset_encryption_sheet.dart";
|
import "package:nexus/widgets/reset_encryption_sheet.dart";
|
||||||
|
|
||||||
class SettingsSectionsController
|
class SettingsSectionsController
|
||||||
extends AsyncNotifier<IMap<String, IList<SettingsCategory>>> {
|
extends AsyncNotifier<IMap<String, IList<SettingsCategory>>> {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import "package:flutter/widgets.dart";
|
import "package:flutter/widgets.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:nexus/widgets/common/error_dialog.dart";
|
import "package:nexus/widgets/error_dialog.dart";
|
||||||
import "package:nexus/widgets/common/loading.dart";
|
import "package:nexus/widgets/loading.dart";
|
||||||
|
|
||||||
extension BetterWhen<T> on AsyncValue<T> {
|
extension BetterWhen<T> on AsyncValue<T> {
|
||||||
Widget betterWhen({
|
Widget betterWhen({
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ import "package:nexus/helpers/extensions/event_id_is_fake.dart";
|
||||||
import "package:nexus/models/content/message.dart";
|
import "package:nexus/models/content/message.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/emoji/emoji_picker.dart";
|
import "package:nexus/widgets/emoji_picker.dart";
|
||||||
import "package:nexus/widgets/emoji/mxc_emoji.dart";
|
import "package:nexus/widgets/mxc_emoji.dart";
|
||||||
import "package:nexus/main.dart";
|
import "package:nexus/main.dart";
|
||||||
|
|
||||||
extension BuildEventOptions on Event {
|
extension BuildEventOptions on Event {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
import "package:material_emoji_picker/material_emoji_picker.dart";
|
import "package:material_emoji_picker/material_emoji_picker.dart";
|
||||||
import "package:nexus/models/content/image_pack.dart";
|
import "package:nexus/models/content/image_pack.dart";
|
||||||
import "package:nexus/models/image_pack.dart";
|
import "package:nexus/models/image_pack.dart";
|
||||||
import "package:nexus/widgets/emoji/mxc_emoji.dart";
|
import "package:nexus/widgets/mxc_emoji.dart";
|
||||||
|
|
||||||
extension ImagePackEmoji on ImagePack {
|
extension ImagePackEmoji on ImagePack {
|
||||||
EmojiCategory? get emojiCategory => _category(emoticons);
|
EmojiCategory? get emojiCategory => _category(emoticons);
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import "package:nexus/helpers/extensions/focus_room.dart";
|
||||||
import "package:nexus/helpers/extensions/get_link_info.dart";
|
import "package:nexus/helpers/extensions/get_link_info.dart";
|
||||||
import "package:nexus/helpers/extensions/show_user_popover.dart";
|
import "package:nexus/helpers/extensions/show_user_popover.dart";
|
||||||
import "package:nexus/models/room_summary.dart";
|
import "package:nexus/models/room_summary.dart";
|
||||||
import "package:nexus/widgets/rooms/room_summary_sheet.dart";
|
import "package:nexus/widgets/room_summary_sheet.dart";
|
||||||
|
|
||||||
extension OpenLinkInfo on WidgetRef {
|
extension OpenLinkInfo on WidgetRef {
|
||||||
Future<void> openLinkInfo(
|
Future<void> openLinkInfo(
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ 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:navigation_rail_m3e/navigation_rail_m3e.dart";
|
import "package:navigation_rail_m3e/navigation_rail_m3e.dart";
|
||||||
import "package:nexus/models/room.dart";
|
import "package:nexus/models/room.dart";
|
||||||
import "package:nexus/widgets/avatars/avatar_or_hash.dart";
|
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||||
|
|
||||||
extension RoomDestination on Room {
|
extension RoomDestination on Room {
|
||||||
NavigationRailM3EDestination toDestination({
|
NavigationRailM3EDestination toDestination({
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,6 @@ extension SchemeToTheme on ColorScheme {
|
||||||
shape: RoundedRectangleBorder(borderRadius: .circular(16)),
|
shape: RoundedRectangleBorder(borderRadius: .circular(16)),
|
||||||
color: surfaceContainerHigh,
|
color: surfaceContainerHigh,
|
||||||
),
|
),
|
||||||
// So ink at the edges of a sheet doesn't spill past its rounded corners
|
|
||||||
bottomSheetTheme: .new(clipBehavior: .antiAlias),
|
|
||||||
appBarTheme: AppBarTheme(
|
appBarTheme: AppBarTheme(
|
||||||
titleSpacing: 0,
|
titleSpacing: 0,
|
||||||
backgroundColor: surfaceContainerLow,
|
backgroundColor: surfaceContainerLow,
|
||||||
|
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
|
||||||
import "package:m3e_buttons/m3e_buttons.dart";
|
|
||||||
import "package:material_ui/material_ui.dart";
|
|
||||||
import "package:nexus/helpers/extensions/save_attachment.dart";
|
|
||||||
import "package:nexus/helpers/extensions/size_to_string.dart";
|
|
||||||
import "package:nexus/helpers/mxc_image.dart";
|
|
||||||
import "package:nexus/models/requests/download_media.dart";
|
|
||||||
import "package:nexus/widgets/common/error_dialog.dart";
|
|
||||||
|
|
||||||
extension ShowImageViewer on BuildContext {
|
|
||||||
/// Shows the image from [request] fullscreen, zoomable.
|
|
||||||
///
|
|
||||||
/// If [downloadFilename] is set, a download button is shown, suggesting it.
|
|
||||||
void showImageViewer(
|
|
||||||
WidgetRef ref,
|
|
||||||
DownloadMediaRequest request, {
|
|
||||||
String? downloadFilename,
|
|
||||||
int? size,
|
|
||||||
}) => showDialog(
|
|
||||||
context: this,
|
|
||||||
builder: (context) => SafeArea(
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
Positioned.fill(
|
|
||||||
child: GestureDetector(
|
|
||||||
onTap: Navigator.of(context).pop,
|
|
||||||
child: InteractiveViewer(
|
|
||||||
maxScale: 10,
|
|
||||||
child: Image(
|
|
||||||
errorBuilder: (_, error, stackTrace) => ErrorDialog(
|
|
||||||
"Loading failed for ${request.mxc}\nError: $error",
|
|
||||||
stackTrace,
|
|
||||||
),
|
|
||||||
image: MxcImage(ref, request),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (size != null)
|
|
||||||
Align(
|
|
||||||
alignment: .topLeft,
|
|
||||||
child: Padding(
|
|
||||||
padding: .all(32),
|
|
||||||
child: Card(
|
|
||||||
margin: .zero,
|
|
||||||
child: Padding(
|
|
||||||
padding: .symmetric(horizontal: 12, vertical: 8),
|
|
||||||
child: Text(size.sizeAsString),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Align(
|
|
||||||
alignment: .topRight,
|
|
||||||
child: Padding(
|
|
||||||
padding: .all(32),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: .min,
|
|
||||||
spacing: 8,
|
|
||||||
children: [
|
|
||||||
if (downloadFilename != null)
|
|
||||||
M3EButton(
|
|
||||||
onPressed: () => context.saveAttachment(
|
|
||||||
ref,
|
|
||||||
request.mxc,
|
|
||||||
encrypted: request.encrypted,
|
|
||||||
filename: downloadFilename,
|
|
||||||
),
|
|
||||||
child: Icon(Icons.download),
|
|
||||||
),
|
|
||||||
M3EButton(
|
|
||||||
onPressed: Navigator.of(context).pop,
|
|
||||||
child: Icon(Icons.close),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import "package:material_ui/material_ui.dart";
|
import "package:material_ui/material_ui.dart";
|
||||||
import "package:material_adaptive_sheets/material_adaptive_sheets.dart";
|
import "package:material_adaptive_sheets/material_adaptive_sheets.dart";
|
||||||
import "package:nexus/widgets/users/user_bottom_sheet.dart";
|
import "package:nexus/widgets/user_bottom_sheet.dart";
|
||||||
|
|
||||||
extension ShowUserPopover on BuildContext {
|
extension ShowUserPopover on BuildContext {
|
||||||
void showUserPopover(String userId, {String? roomId, Rect? anchor}) =>
|
void showUserPopover(String userId, {String? roomId, Rect? anchor}) =>
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,9 @@ import "package:nexus/widgets/pages/select_server.dart";
|
||||||
import "package:nexus/widgets/pages/settings.dart";
|
import "package:nexus/widgets/pages/settings.dart";
|
||||||
import "package:nexus/widgets/pages/verify.dart";
|
import "package:nexus/widgets/pages/verify.dart";
|
||||||
import "package:nexus/widgets/account_scope.dart";
|
import "package:nexus/widgets/account_scope.dart";
|
||||||
import "package:nexus/widgets/common/appbar.dart";
|
import "package:nexus/widgets/appbar.dart";
|
||||||
import "package:nexus/widgets/common/error_dialog.dart";
|
import "package:nexus/widgets/error_dialog.dart";
|
||||||
import "package:nexus/widgets/common/loading.dart";
|
import "package:nexus/widgets/loading.dart";
|
||||||
import "package:window_manager/window_manager.dart";
|
import "package:window_manager/window_manager.dart";
|
||||||
import "package:material_ui/material_ui.dart";
|
import "package:material_ui/material_ui.dart";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
|
||||||
import "package:freezed_annotation/freezed_annotation.dart";
|
|
||||||
|
|
||||||
part "reaction_group.freezed.dart";
|
|
||||||
|
|
||||||
@Freezed(toJson: false, fromJson: false)
|
|
||||||
class const ReactionGroup({
|
|
||||||
required final IList<String> senders,
|
|
||||||
final String? shortcode,
|
|
||||||
}) with _$ReactionGroup;
|
|
||||||
133
lib/widgets/account_switcher.dart
Normal file
133
lib/widgets/account_switcher.dart
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
import "package:collection/collection.dart";
|
||||||
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
|
import "package:material_ui/material_ui.dart";
|
||||||
|
import "package:material_adaptive_sheets/material_adaptive_sheets.dart";
|
||||||
|
import "package:nexus/controllers/accounts.dart";
|
||||||
|
import "package:nexus/controllers/active_account.dart";
|
||||||
|
import "package:nexus/helpers/extensions/log_out.dart";
|
||||||
|
import "package:nexus/main.dart";
|
||||||
|
import "package:nexus/models/account.dart";
|
||||||
|
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||||
|
|
||||||
|
/// Switches between, adds and logs out of accounts. With [onlyWithOthers], it's
|
||||||
|
/// hidden when there's only one account.
|
||||||
|
class const AccountSwitcher({final bool onlyWithOthers = false, super.key})
|
||||||
|
extends ConsumerWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final danger = Theme.of(context).colorScheme.error;
|
||||||
|
final accounts = ref.watch(AccountsController.provider).value;
|
||||||
|
final activeId = ref.watch(ActiveAccountController.provider);
|
||||||
|
if (accounts == null || (onlyWithOthers && accounts.length < 2)) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
String nameOf(Account account) =>
|
||||||
|
account.displayName ?? account.userId ?? "New account";
|
||||||
|
|
||||||
|
Widget avatarOf(Account account, double dimension) => AvatarOrHash(
|
||||||
|
account.avatar,
|
||||||
|
nameOf(account),
|
||||||
|
dimension: dimension,
|
||||||
|
fallback: account.userId == null
|
||||||
|
? Icon(Icons.account_circle, size: dimension)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
final active = accounts.firstWhereOrNull(
|
||||||
|
(account) => account.id == activeId,
|
||||||
|
);
|
||||||
|
|
||||||
|
void switchTo(String accountId) => ref
|
||||||
|
.read(ActiveAccountController.provider.notifier)
|
||||||
|
.switchTo(accountId)
|
||||||
|
.onError(showError);
|
||||||
|
|
||||||
|
final loggingOut = ref.watch(LogOut.mutation).isPending;
|
||||||
|
|
||||||
|
return AdaptiveMenuButton<void>(
|
||||||
|
tooltip: loggingOut ? "Logging out..." : "Switch account",
|
||||||
|
enabled: !loggingOut,
|
||||||
|
icon: loggingOut
|
||||||
|
? const SizedBox.square(
|
||||||
|
dimension: 24,
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
)
|
||||||
|
: active == null
|
||||||
|
? const Icon(Icons.account_circle)
|
||||||
|
: avatarOf(active, 28),
|
||||||
|
itemBuilder: (_) => [
|
||||||
|
for (final account in accounts)
|
||||||
|
PopupMenuItem(
|
||||||
|
onTap: () => switchTo(account.id),
|
||||||
|
child: ListTile(
|
||||||
|
selected: account.id == activeId,
|
||||||
|
leading: avatarOf(account, 32),
|
||||||
|
title: Text(nameOf(account)),
|
||||||
|
subtitle: switch (account) {
|
||||||
|
Account(displayName: _?, :final userId?) => Text(userId),
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
|
trailing: account.userId == null
|
||||||
|
? null
|
||||||
|
// Inside the menu, so it can close it
|
||||||
|
: Builder(
|
||||||
|
builder: (menuContext) => IconButton(
|
||||||
|
tooltip: "Log out",
|
||||||
|
icon: Icon(Icons.logout, color: danger),
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.of(menuContext).pop();
|
||||||
|
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: Text("Log out of ${nameOf(account)}?"),
|
||||||
|
content: Text(
|
||||||
|
account.id == activeId
|
||||||
|
? "You'll be switched to another account, or returned to the login page if this is your only one."
|
||||||
|
: "You'll stop receiving notifications for this account.",
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () =>
|
||||||
|
Navigator.of(dialogContext).pop(false),
|
||||||
|
child: const Text("Cancel"),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () =>
|
||||||
|
Navigator.of(dialogContext).pop(true),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: danger,
|
||||||
|
),
|
||||||
|
child: const Text("Log out"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true) return;
|
||||||
|
|
||||||
|
await ref.logOut(account.id).onError(showError);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuDivider(),
|
||||||
|
PopupMenuItem(
|
||||||
|
onTap: () {
|
||||||
|
final id = DateTime.now().millisecondsSinceEpoch.toString();
|
||||||
|
ref
|
||||||
|
.read(AccountsController.provider.notifier)
|
||||||
|
.save(.new(id: id))
|
||||||
|
.then((_) => switchTo(id))
|
||||||
|
.onError(showError);
|
||||||
|
},
|
||||||
|
child: const ListTile(
|
||||||
|
leading: Icon(Icons.person_add),
|
||||||
|
title: Text("Add account"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
lib/widgets/add_directory_server_sheet.dart
Normal file
54
lib/widgets/add_directory_server_sheet.dart
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
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:material_adaptive_sheets/material_adaptive_sheets.dart";
|
||||||
|
import "package:nexus/helpers/extensions/get_localpart.dart";
|
||||||
|
|
||||||
|
class const AddDirectoryServerSheet({
|
||||||
|
required final ISet<String> existing,
|
||||||
|
super.key,
|
||||||
|
}) extends HookWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final controller = useTextEditingController();
|
||||||
|
final text = useValueListenable(controller).text;
|
||||||
|
final server = text.trim().toLowerCase();
|
||||||
|
final error = server.isEmpty
|
||||||
|
? null
|
||||||
|
: !server.isValidServerName
|
||||||
|
? "Enter a server name, without https:// or a path"
|
||||||
|
: existing.contains(server)
|
||||||
|
? "This server has already been added"
|
||||||
|
: null;
|
||||||
|
final valid = server.isNotEmpty && error == null;
|
||||||
|
|
||||||
|
void submit() {
|
||||||
|
if (valid) Navigator.of(context).pop(server);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SheetLayout(
|
||||||
|
title: const Text("Add Server"),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: Navigator.of(context).pop,
|
||||||
|
child: const Text("Cancel"),
|
||||||
|
),
|
||||||
|
TextButton(onPressed: valid ? submit : null, child: const Text("Add")),
|
||||||
|
],
|
||||||
|
children: [
|
||||||
|
const Text("Enter a server to browse the public rooms of."),
|
||||||
|
TextField(
|
||||||
|
controller: controller,
|
||||||
|
autofocus: true,
|
||||||
|
keyboardType: .url,
|
||||||
|
onSubmitted: (_) => submit(),
|
||||||
|
decoration: .new(
|
||||||
|
labelText: "Server name",
|
||||||
|
hintText: "matrix.org",
|
||||||
|
errorText: error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
181
lib/widgets/add_space_rooms_sheet.dart
Normal file
181
lib/widgets/add_space_rooms_sheet.dart
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
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:material_adaptive_sheets/material_adaptive_sheets.dart";
|
||||||
|
import "package:nexus/controllers/client.dart";
|
||||||
|
import "package:nexus/controllers/client_state.dart";
|
||||||
|
import "package:nexus/controllers/rooms.dart";
|
||||||
|
import "package:nexus/controllers/space_hierarchy.dart";
|
||||||
|
import "package:nexus/controllers/via.dart";
|
||||||
|
import "package:nexus/helpers/extensions/get_localpart.dart";
|
||||||
|
import "package:nexus/helpers/extensions/with_progress_snackbar.dart";
|
||||||
|
import "package:nexus/main.dart";
|
||||||
|
import "package:nexus/models/content/content.dart";
|
||||||
|
import "package:nexus/models/content/space_child.dart";
|
||||||
|
import "package:nexus/widgets/avatar_or_hash.dart";
|
||||||
|
import "package:nexus/widgets/settings/search_chooser.dart";
|
||||||
|
|
||||||
|
/// Chooses rooms to add to the space [spaceId], then adds them.
|
||||||
|
class const AddSpaceRoomsSheet(
|
||||||
|
final String spaceId, {
|
||||||
|
required final String spaceName,
|
||||||
|
|
||||||
|
/// The rooms already in the space, which aren't offered.
|
||||||
|
required final ISet<String> existing,
|
||||||
|
super.key,
|
||||||
|
}) extends HookConsumerWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final chosen = useState(const ISet<String>.empty());
|
||||||
|
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 = rooms.entries
|
||||||
|
.where((entry) => entry.key != spaceId && !existing.contains(entry.key))
|
||||||
|
.sortedBy((entry) => entry.value.name?.toLowerCase() ?? "")
|
||||||
|
.map((entry) => entry.key)
|
||||||
|
.toIList();
|
||||||
|
|
||||||
|
void add(String id) {
|
||||||
|
if (id == spaceId) {
|
||||||
|
error.value = "A space can't contain itself";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (existing.contains(id)) {
|
||||||
|
error.value = "This room is already in the space";
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs after the sheet closes, so it can't use [ref]
|
||||||
|
Future<void> save(ProviderContainer container, ISet<String> roomIds) async {
|
||||||
|
final client = container.read(ClientController.provider.notifier);
|
||||||
|
final joined = container.read(RoomsController.provider);
|
||||||
|
final ownServer = container
|
||||||
|
.read(ClientStateController.provider)
|
||||||
|
?.userId
|
||||||
|
?.serverName;
|
||||||
|
|
||||||
|
await Future.wait(
|
||||||
|
roomIds.map(
|
||||||
|
(roomId) => client.setState(
|
||||||
|
.new(
|
||||||
|
roomId: spaceId,
|
||||||
|
type: EventType.spaceChild.type,
|
||||||
|
stateKey: roomId,
|
||||||
|
content: SpaceChildContent(
|
||||||
|
via: switch (joined[roomId]) {
|
||||||
|
final room? => container.read(ViaController.provider(room)),
|
||||||
|
// Rooms the user isn't in can only be routed through their
|
||||||
|
// own server
|
||||||
|
null => ISet([?ownServer]),
|
||||||
|
}.toIList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
container.invalidate(SpaceHierarchyController.provider(spaceId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return SheetLayout(
|
||||||
|
title: Text("Add Rooms"),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")),
|
||||||
|
TextButton(
|
||||||
|
onPressed: chosen.value.isEmpty
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
final container = ProviderScope.containerOf(context);
|
||||||
|
final roomIds = chosen.value;
|
||||||
|
SpaceHierarchyController.addChildrenMutation(spaceId).run(
|
||||||
|
container,
|
||||||
|
(_) => context.withProgressSnackbar(
|
||||||
|
"Adding ${roomIds.length == 1 ? nameOf(roomIds.single) : "${roomIds.length} rooms"} to $spaceName...",
|
||||||
|
() => save(container, roomIds),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
child: Text("Add"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
children: [
|
||||||
|
Text("Choose rooms to add to \"$spaceName\"."),
|
||||||
|
SearchChooser(
|
||||||
|
controller: controller,
|
||||||
|
chosen: chosen.value,
|
||||||
|
options: suggestions,
|
||||||
|
nameOf: nameOf,
|
||||||
|
avatarOf: (id) => AvatarOrHash(
|
||||||
|
rooms[id]?.avatar,
|
||||||
|
nameOf(id),
|
||||||
|
fallback: Icon(Icons.numbers),
|
||||||
|
),
|
||||||
|
subtitleOf: (id) => 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",
|
||||||
|
error: error.value,
|
||||||
|
loading: resolving.value,
|
||||||
|
autofocus: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
96
lib/widgets/appbar.dart
Normal file
96
lib/widgets/appbar.dart
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import "dart:io";
|
||||||
|
|
||||||
|
import "package:fast_immutable_collections/fast_immutable_collections.dart";
|
||||||
|
import "package:material_ui/material_ui.dart";
|
||||||
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
|
import "package:nexus/controllers/settings.dart";
|
||||||
|
import "package:window_manager/window_manager.dart";
|
||||||
|
|
||||||
|
final class const Appbar({
|
||||||
|
final Widget? leading,
|
||||||
|
final Widget? title,
|
||||||
|
final Color? backgroundColor,
|
||||||
|
final double? scrolledUnderElevation,
|
||||||
|
final IList<Widget> actions = const .empty(),
|
||||||
|
final VoidCallback? onTap,
|
||||||
|
|
||||||
|
/// Whether to show the minimize, maximize and close buttons on desktop.
|
||||||
|
final bool windowControls = true,
|
||||||
|
super.key,
|
||||||
|
}) extends ConsumerWidget implements PreferredSizeWidget {
|
||||||
|
@override
|
||||||
|
Size get preferredSize => const .fromHeight(kToolbarHeight);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final isDesktop = !(Platform.isAndroid || Platform.isIOS);
|
||||||
|
|
||||||
|
void maximize() {
|
||||||
|
final window = WindowManager.instance.getCurrent();
|
||||||
|
if (window == null) return;
|
||||||
|
|
||||||
|
if (window.isMaximized) {
|
||||||
|
return window.unmaximize();
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.maximize();
|
||||||
|
}
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onPanStart: ref
|
||||||
|
.watch(SettingsController.provider)
|
||||||
|
.whenOrNull(
|
||||||
|
data: (settings) => !isDesktop || settings.linuxMobileMode
|
||||||
|
? null
|
||||||
|
: (_) => WindowManager.instance.getCurrent()?.startDragging(),
|
||||||
|
),
|
||||||
|
child: AppBar(
|
||||||
|
leading: leading == null ? null : InkWell(onTap: onTap, child: leading),
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
scrolledUnderElevation: scrolledUnderElevation,
|
||||||
|
actionsPadding: const .symmetric(horizontal: 8),
|
||||||
|
title: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: IgnorePointer(child: title),
|
||||||
|
),
|
||||||
|
flexibleSpace: GestureDetector(
|
||||||
|
onDoubleTap: isDesktop ? maximize : null,
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
...actions,
|
||||||
|
if (windowControls && isDesktop) ...[
|
||||||
|
if (ref
|
||||||
|
.watch(SettingsController.provider)
|
||||||
|
.whenOrNull(
|
||||||
|
data: (settings) => settings.showMinimizeButton,
|
||||||
|
) ??
|
||||||
|
false)
|
||||||
|
IconButton(
|
||||||
|
tooltip: "Minimize window",
|
||||||
|
onPressed: () =>
|
||||||
|
WindowManager.instance.getCurrent()?.minimize(),
|
||||||
|
icon: const Icon(Icons.minimize),
|
||||||
|
),
|
||||||
|
if (!Platform.isLinux)
|
||||||
|
IconButton(
|
||||||
|
tooltip: "Maximize window",
|
||||||
|
onPressed: maximize,
|
||||||
|
icon: const Icon(Icons.fullscreen),
|
||||||
|
),
|
||||||
|
if (ref
|
||||||
|
.watch(SettingsController.provider)
|
||||||
|
.whenOrNull(
|
||||||
|
data: (settings) => !settings.linuxMobileMode,
|
||||||
|
) ??
|
||||||
|
true)
|
||||||
|
IconButton(
|
||||||
|
tooltip: "Close window",
|
||||||
|
onPressed: () => exit(0),
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
lib/widgets/avatar_or_hash.dart
Normal file
42
lib/widgets/avatar_or_hash.dart
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import "package:color_hash/color_hash.dart";
|
||||||
|
import "package:material_ui/material_ui.dart";
|
||||||
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
|
import "package:nexus/helpers/mxc_image.dart";
|
||||||
|
|
||||||
|
final class const AvatarOrHash(
|
||||||
|
final Uri? avatar,
|
||||||
|
final String title, {
|
||||||
|
final Widget? fallback,
|
||||||
|
final double dimension = 24,
|
||||||
|
super.key,
|
||||||
|
}) extends ConsumerWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final box = ColoredBox(
|
||||||
|
color: ColorHash(title).color,
|
||||||
|
child: Center(child: Icon(Icons.person, size: dimension / 2)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
width: dimension,
|
||||||
|
height: dimension,
|
||||||
|
child: Center(
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: .all(.circular((dimension - 8) / 2.5)),
|
||||||
|
child: SizedBox.square(
|
||||||
|
dimension: dimension,
|
||||||
|
child: avatar == null
|
||||||
|
? fallback ?? box
|
||||||
|
: Image(
|
||||||
|
image: MxcImage(ref, .new(mxc: avatar!, isAvatar: true)),
|
||||||
|
fit: .cover,
|
||||||
|
loadingBuilder: (_, child, loadingProgress) =>
|
||||||
|
loadingProgress == null ? child : fallback ?? box,
|
||||||
|
errorBuilder: (_, _, _) => fallback ?? box,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue