diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml deleted file mode 100644 index 8ed63cf..0000000 --- a/.github/workflows/macos.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: "Build MacOS App" - -on: - # TODO: Uncomment this once the MacOS workflow actually works - # push: - # branches: ["main"] - # tags: ["*"] - workflow_dispatch: - -jobs: - build-app: - runs-on: macos-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - submodules: recursive - - - name: Lix GHA Installer Action - uses: samueldr/lix-gha-installer-action@v2026-02-22 - with: - extra_nix_config: experimental-features = nix-command flakes flake-self-attrs - - - name: Build app - run: nix develop --command bash -c "flutter pub get && dart scripts/generate.dart && flutter pub run build_runner build && flutter build macos --release" - - - name: Upload installer artifact - uses: actions/upload-artifact@v6 - with: - name: App - path: build/macos/Build/Products/Release/Nexus.app \ No newline at end of file diff --git a/.gitmodules b/.gitmodules index 145276a..dc5e02e 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "gomuks"] path = gomuks - url = https://github.com/gomuks/gomuks + url = https://github.com/henry-hiles/gomuks branch = main diff --git a/README.md b/README.md index 57b42d8..d191d6e 100644 --- a/README.md +++ b/README.md @@ -127,16 +127,15 @@ If you want to try out Nexus, grab one of the following artifacts from CI: #### Linux - With Nix: Either use direnv and `direnv allow`, or `nix flake develop` -- Without Nix: Install Flutter, Go, Git, Libclang, Libass, MPV, and Glibc. Do not use any Snap packages, they cause various compilation issues. +- Without Nix: Install Flutter, Go, Git, Libclang, and Glibc. Do not use any Snap packages, they cause various compilation issues. #### Windows You will need: - Flutter +- Android SDK + NDK - Git -- Libass -- MPV - Go - Visual Studio 2022 (Desktop development with C++) - [MSYS2/MinGW-w64 GCC](https://www.msys2.org/) (for CGO) @@ -203,7 +202,7 @@ Development instructions can be found in [DEVELOPMENT.md](./DEVELOPMENT.md). ## Community -Join the [Nexus Client Matrix room](https://matrix.to/#/#nexus:federated.nexus) for questions or help with developing or using Nexus Client. +Join the [Nexus Client Matrix Room](https://matrix.to/#/#nexus:federated.nexus) for questions or help with developing or using Nexus Client. # Credits diff --git a/gomuks b/gomuks index 0f8fb26..c06eb1c 160000 --- a/gomuks +++ b/gomuks @@ -1 +1 @@ -Subproject commit 0f8fb261ab57977fa24a4f63b7aafbb9a4ff1b7b +Subproject commit c06eb1cbda2d49ff199a351e2d55df6ff7ab602b diff --git a/lib/controllers/attachment.dart b/lib/controllers/attachment.dart index 2a99ee5..e33493e 100644 --- a/lib/controllers/attachment.dart +++ b/lib/controllers/attachment.dart @@ -17,16 +17,18 @@ class AttachmentController extends Notifier<(String, MessageContent?)?> { final filename = basename(file.path); state = (filename, null); - final isEncrypted = ref.read( - RoomsController.provider.select( - (value) => - value[roomId]?.state[EventType.encryption.type]?.isNotEmpty == true, - ), + final room = ref.watch( + RoomsController.provider.select((value) => value[roomId]), ); final content = await ref .watch(ClientController.provider.notifier) - .uploadMedia(.new(path: file.path, encrypt: isEncrypted)); + .uploadMedia( + .new( + path: file.path, + encrypt: room?.state[EventType.encryption.type]?.isNotEmpty == true, + ), + ); state = (filename, content); } diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index 97c3ed6..2cb1d66 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -17,7 +17,6 @@ import "package:nexus/main.dart"; import "package:nexus/models/content/message.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/oauth_auth_code_response.dart"; -import "package:nexus/models/open_graph_data.dart"; import "package:nexus/models/paginate.dart"; import "package:nexus/models/requests/download_media.dart"; import "package:nexus/models/requests/get_event.dart"; @@ -216,6 +215,11 @@ class ClientController extends AsyncNotifier { return response["room_id"]; } + Future getAccessToken() async { + final response = await _sendCommand("get_account_info", {}); + return response?["access_token"]; + } + Future leaveRoom(Room room) async { if (room.metadata == null) return; await _sendCommand("leave_room", {"room_id": room.metadata!.id}); @@ -251,9 +255,6 @@ class ClientController extends AsyncNotifier { return json == null ? null : .fromJson(json); } - Future getUrlPreview(Uri url) async => - .fromJson(await _sendCommand("get_url_preview", {"url": url.toString()})); - Future paginate(PaginateRequest request) async => .fromJson(await _sendCommand("paginate", request.toJson())); diff --git a/lib/controllers/emoji.dart b/lib/controllers/emoji.dart index caea3de..f1a5c79 100644 --- a/lib/controllers/emoji.dart +++ b/lib/controllers/emoji.dart @@ -1,16 +1,47 @@ import "dart:convert"; -import "package:emoji_text_field/models/emoji_category.dart"; +import "package:collection/collection.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter/foundation.dart"; import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:http/http.dart"; -import "package:nexus/models/emoji.dart"; +import "package:nexus/models/gemoji.dart"; -typedef EmojiTuple = (IMap, IMap>); +class EmoteSection { + final String category; + final String label; + final IconData icon; + final List items; + final bool isRecent; -class EmojiController extends AsyncNotifier { + const EmoteSection({ + required this.category, + required this.label, + required this.icon, + required this.items, + this.isRecent = false, + }); +} + +class EmoteItem { + final String id; + final String display; + final List keywords; + final int useCounter; + final Image? customImage; + + const EmoteItem({ + required this.id, + required this.display, + required this.keywords, + required this.useCounter, + this.customImage, + }); +} + +class EmojiController extends AsyncNotifier> { @override - Future build() async { + Future> build() async { final response = await get( .https("github.com", "github/gemoji/raw/refs/heads/master/db/emoji.json"), ); @@ -19,66 +50,72 @@ class EmojiController extends AsyncNotifier { throw Exception("Failed to load emoji data"); } - final data = json.decode(response.body); - - final entries = (data as List) - .cast>() - .map(Emoji.fromJson) - .toIList(); - - final categoryMap = entries.fold>>( - .new(), - (acc, entry) => acc.update( - entry.category, - (list) => list.add(entry.emoji), - ifAbsent: () => .new([entry.emoji]), - ), - ); - - final keywordMap = entries.fold>>( - .new(), - (acc, entry) => acc.add( - entry.emoji, - .new([...entry.tags, ...entry.aliases, entry.description]), - ), - ); - - final customCategories = IMap.fromEntries( - categoryMap.entries.map( - (entry) => MapEntry( - entry.key, - EmojiCategory( - name: entry.key, - icon: switch (entry.key) { - "Smileys & Emotion" => Icons.emoji_emotions, - "People & Body" => Icons.emoji_people, - "Animals & Nature" => Icons.emoji_nature, - "Food & Drink" => Icons.emoji_food_beverage, - "Travel & Places" => Icons.travel_explore, - "Activities" => Icons.sports_soccer, - "Objects" => Icons.emoji_objects, - "Symbols" => Icons.emoji_symbols, - "Flags" => Icons.emoji_flags, - _ => Icons.category, - }, - emojis: entry.value.toList(growable: false), - ), - ), - ), - ); - - final customKeywords = IMap( - .fromEntries( - keywordMap.entries.map( - (e) => .new(e.key, e.value.toList(growable: false)), - ), - ), - ); - - return (customCategories, customKeywords); + return compute(parseEmojiJson, response.body); } - static final provider = AsyncNotifierProvider( - EmojiController.new, - ); + static IList parseEmojiJson(String body) { + final data = json.decode(body) as List; + final allEntries = data + .cast>() + .map(Gemoji.fromJson) + .toIList(); + + final groupedByCategory = groupBy(allEntries, (g) => g.category); + + final result = groupedByCategory.entries.fold>( + .new(), + (resultList, entry) => resultList.add( + EmoteSection( + category: entry.key, + label: entry.key, + icon: getIconForCategory(entry.key), + items: entry.value + .map( + (gemoji) => EmoteItem( + id: gemoji.emoji, + display: gemoji.emoji, + keywords: [ + ...gemoji.tags, + ...gemoji.aliases, + ...gemoji.description.split(" "), + ], + useCounter: 0, + ), + ) + .toList(), + isRecent: false, + ), + ), + ); + + return result; + } + + static final provider = + AsyncNotifierProvider>( + EmojiController.new, + ); + + static IconData getIconForCategory(String category) { + switch (category.toLowerCase()) { + case "smileys & emotion": + return Icons.sentiment_very_satisfied; + case "people & body": + return Icons.face; + case "food & drink": + return Icons.restaurant; + case "travel & places": + return Icons.flag; + case "activities": + return Icons.sports_soccer; + case "objects": + return Icons.lightbulb; + case "symbols": + return Icons.circle; + case "flags": + return Icons.flag; + default: + return Icons.emoji_emotions_outlined; + } + } } diff --git a/lib/controllers/header.dart b/lib/controllers/header.dart new file mode 100644 index 0000000..3e173ad --- /dev/null +++ b/lib/controllers/header.dart @@ -0,0 +1,20 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/client_state.dart"; + +class HeaderController extends AsyncNotifier> { + @override + Future> build() async { + if (ref.watch(ClientStateController.provider)?.isLoggedIn != true) { + return {}; + } + final client = ref.watch(ClientController.provider.notifier); + final accessToken = await client.getAccessToken(); + return {"authorization": "Bearer $accessToken"}; + } + + static final provider = + AsyncNotifierProvider>( + HeaderController.new, + ); +} diff --git a/lib/controllers/member_list_opened.dart b/lib/controllers/member_list_opened.dart deleted file mode 100644 index e3509f0..0000000 --- a/lib/controllers/member_list_opened.dart +++ /dev/null @@ -1,22 +0,0 @@ -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/shared_prefs.dart"; - -class MemberListOpenedController extends Notifier { - static const String key = "memberListOpened"; - - @override - bool build() => - ref.watch(SharedPrefsController.provider).requireValue.getBool(key) ?? - true; - - Future set(bool value) async { - final prefs = ref.watch(SharedPrefsController.provider).requireValue; - state = value; - - prefs.setBool(key, value); - } - - static final provider = NotifierProvider( - MemberListOpenedController.new, - ); -} diff --git a/lib/controllers/room_chat.dart b/lib/controllers/room_chat.dart index 3e531bb..558817c 100644 --- a/lib/controllers/room_chat.dart +++ b/lib/controllers/room_chat.dart @@ -125,9 +125,11 @@ class RoomChatController extends AsyncNotifier?> { if (relationType == .edit) { baseContent = relation?.content; } else { - final provider = AttachmentController.provider(roomId); - baseContent = ref.watch(provider)?.$2; - ref.invalidate(provider); + final attachment = ref.watch(AttachmentController.provider(roomId)); + if (attachment != null && attachment.$2 == null) return; + + baseContent = attachment?.$2; + ref.invalidate(AttachmentController.provider(roomId)); } var taggedMessage = text; diff --git a/lib/controllers/url_preview.dart b/lib/controllers/url_preview.dart index 1b17870..2a81a3c 100644 --- a/lib/controllers/url_preview.dart +++ b/lib/controllers/url_preview.dart @@ -1,28 +1,50 @@ -import "package:flutter/widgets.dart"; +import "dart:convert"; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/client.dart"; +import "package:http/http.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/controllers/header.dart"; import "package:nexus/models/open_graph_data.dart"; class UrlPreviewController extends AsyncNotifier { - final Uri url; - UrlPreviewController(this.url); + final String link; + UrlPreviewController(this.link); @override Future build() async { - if (url.host == "matrix.to") return null; - try { - return await ref - .watch(ClientController.provider.notifier) - .getUrlPreview(url); - } catch (error, stackTrace) { - debugPrintStack(label: error.toString(), stackTrace: stackTrace); + final homeserver = ref.watch( + ClientStateController.provider.select((value) => value?.homeserverUrl), + ); + + if (homeserver != null && !link.contains("matrix.to")) { + { + final response = await get( + .parse(homeserver) + .resolve("/_matrix/client/v1/media/preview_url") + .replace(queryParameters: {"url": link}), + headers: await ref.watch(HeaderController.provider.future), + ); + + if (response.statusCode == 200) { + final decodedValue = json.decode(response.body); + if (decodedValue is! Map) return null; + + return .fromJson( + decodedValue, + ).copyWith(imageUrl: decodedValue["og:image"]); + } + } + } + } catch (_) { return null; } + return null; } static final provider = - AsyncNotifierProvider.family( - UrlPreviewController.new, - ); + AsyncNotifierProvider.family< + UrlPreviewController, + OpenGraphData?, + String + >(UrlPreviewController.new); } diff --git a/lib/helpers/extensions/get_headers.dart b/lib/helpers/extensions/get_headers.dart new file mode 100644 index 0000000..0fcec48 --- /dev/null +++ b/lib/helpers/extensions/get_headers.dart @@ -0,0 +1,7 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/header.dart"; + +extension GetHeaders on WidgetRef { + Map get headers => + watch(HeaderController.provider).requireValue; +} diff --git a/lib/main.dart b/lib/main.dart index 53cc57b..f50d22d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,6 +6,7 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:media_kit/media_kit.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client_state.dart"; +import "package:nexus/controllers/header.dart"; import "package:nexus/controllers/multi_provider.dart"; import "package:nexus/controllers/settings.dart"; import "package:nexus/controllers/shared_prefs.dart"; @@ -143,6 +144,7 @@ class App extends StatelessWidget { IListConst([ SharedPrefsController.provider, ClientController.provider, + HeaderController.provider, ]), ), ) diff --git a/lib/models/emoji.dart b/lib/models/gemoji.dart similarity index 59% rename from lib/models/emoji.dart rename to lib/models/gemoji.dart index 8e4eac6..084a1ca 100644 --- a/lib/models/emoji.dart +++ b/lib/models/gemoji.dart @@ -1,17 +1,17 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:freezed_annotation/freezed_annotation.dart"; -part "emoji.freezed.dart"; -part "emoji.g.dart"; +part "gemoji.freezed.dart"; +part "gemoji.g.dart"; @freezed -abstract class Emoji with _$Emoji { - const factory Emoji({ +abstract class Gemoji with _$Gemoji { + const factory Gemoji({ required String emoji, required String category, required IList aliases, required String description, required IList tags, - }) = _Emoji; + }) = _Gemoji; - factory Emoji.fromJson(Map json) => _$EmojiFromJson(json); + factory Gemoji.fromJson(Map json) => _$GemojiFromJson(json); } diff --git a/lib/widgets/composer/composer.dart b/lib/widgets/composer/composer.dart index ca3de8a..281db0d 100644 --- a/lib/widgets/composer/composer.dart +++ b/lib/widgets/composer/composer.dart @@ -14,8 +14,8 @@ import "package:nexus/models/event.dart"; import "package:nexus/models/relation_type.dart"; import "package:nexus/widgets/composer/mention_overlay.dart"; import "package:nexus/widgets/composer/relation_preview.dart"; -import "package:nexus/widgets/emoji_picker_button.dart"; import "package:nexus/main.dart"; +import "package:nexus/widgets/emote_picker.dart"; class Composer extends HookConsumerWidget { final String roomId; @@ -59,10 +59,7 @@ class Composer extends HookConsumerWidget { final attachment = ref.watch(AttachmentController.provider(roomId)); void send() { - if (controller.value.text.isEmpty && attachment == null || - attachment != null && attachment.$2 == null) { - return; - } + if (controller.value.text.isEmpty && attachment == null) return; onSend( controller.value.formattedText, shouldMention: shouldMention.value, @@ -126,10 +123,35 @@ class Composer extends HookConsumerWidget { ), ) ? [ - EmojiPickerButton( - context: context, - onSelection: (_) => node?.requestFocus(), - controller: controller.value, + IconButton( + icon: Icon(Icons.emoji_emotions), + tooltip: "Insert emoji", + onPressed: () { + showEmotePickerBottomSheet( + context: context, + roomId: roomId, + onSelect: (emojiId) { + final textController = controller.value; + final selection = textController.selection; + final newText = textController.text + .replaceRange( + selection.start, + selection.end, + emojiId, + ); + + textController.value = TextEditingValue( + text: newText, + selection: TextSelection.collapsed( + offset: + selection.start + emojiId.length, + ), + ); + + node?.requestFocus(); + }, + ); + }, ), PopupMenuButton( tooltip: "Add media", diff --git a/lib/widgets/emoji_picker_button.dart b/lib/widgets/emoji_picker_button.dart deleted file mode 100644 index 2ac906a..0000000 --- a/lib/widgets/emoji_picker_button.dart +++ /dev/null @@ -1,52 +0,0 @@ -import "package:emoji_text_field/emoji_text_field.dart"; -import "package:flutter/material.dart"; -import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/emoji.dart"; - -class EmojiPickerButton extends HookConsumerWidget { - final TextEditingController? controller; - final void Function(String emoji)? onSelection; - final VoidCallback? onPressed; - final BuildContext context; - const EmojiPickerButton({ - this.controller, - this.onPressed, - this.onSelection, - required this.context, - super.key, - }); - - @override - Widget build(_, WidgetRef ref) => IconButton( - onPressed: () async { - onPressed?.call(); - final controller = this.controller ?? .new(); - - final emojis = await ref.watch(EmojiController.provider.future); - if (context.mounted) { - showModalBottomSheet( - context: context, - builder: (context) => EmojiKeyboardView( - config: .new( - showRecentTab: false, - customCategories: emojis.$1.unlock, - customKeywords: emojis.$2.unlock, - backgroundColor: Theme.of(context).colorScheme.surfaceContainer, - height: 600, - ), - textController: controller - ..addListener(() { - // Without this, there will sometimes be a debugLocked is not true error sometimes - // It might be preferable to use a microtask instead of a `Future.delayed`. - Future.delayed(.zero, () { - if (context.mounted) Navigator.of(context).pop(); - }); - onSelection?.call(controller.text); - }), - ), - ); - } - }, - icon: Icon(Icons.emoji_emotions), - ); -} diff --git a/lib/widgets/emote_picker.dart b/lib/widgets/emote_picker.dart new file mode 100644 index 0000000..881526c --- /dev/null +++ b/lib/widgets/emote_picker.dart @@ -0,0 +1,399 @@ +import "dart:math" as math; + +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter/material.dart"; +import "package:flutter/rendering.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/emoji.dart"; + +class EmotePicker extends HookConsumerWidget { + final String? roomId; + final void Function(String emoteId) onSelect; + final VoidCallback? onClose; + + const EmotePicker({ + super.key, + this.roomId, + this.onClose, + required this.onSelect, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + backgroundColor: Colors.transparent, + body: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.6, + minHeight: math.min(MediaQuery.of(context).size.height * 0.6, 400), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Drag handle + Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Divider(height: 1), + // Content + Expanded( + child: ListView( + children: [ + EmoteCategoryList( + roomId: roomId, + onSelect: onSelect, + fetchSections: (rid, query) => + fetchEmojiSections(ref, rid, query), + placeholderText: "emojis & emotes", + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class EmoteCategoryList extends HookWidget { + final String? roomId; + final void Function(String) onSelect; + final Future> Function(String? roomId, String query) + fetchSections; + final String placeholderText; + + const EmoteCategoryList({ + super.key, + required this.roomId, + required this.onSelect, + required this.fetchSections, + required this.placeholderText, + }); + + @override + Widget build(BuildContext context) { + final searchController = useTextEditingController(text: ""); + final searchFocus = useFocusNode(); + final searchText = useState(""); + + useEffect(() { + void listener() { + searchText.value = searchController.text; + } + + searchController.addListener(listener); + return () => searchController.removeListener(listener); + }, [searchController]); + + final isSearching = searchText.value.isNotEmpty; + final sectionsFuture = useMemoized( + () => fetchSections(roomId, searchText.value), + [searchText.value], + ); + final sectionsSnapshot = useFuture(sectionsFuture); + final sections = sectionsSnapshot.data ?? [].toIList(); + + final scrollController = useScrollController(); + + final sectionKeysMap = useState({}); + + final activeCategoryId = useState(null); + + useEffect(() { + if (sections.isEmpty) return null; + + bool active = true; + void listener() { + if (!active || !scrollController.hasClients) return; + + final scrollPixels = scrollController.offset; + String? current; + + for (final section in sections) { + final key = sectionKeysMap.value.putIfAbsent( + section.category, + () => GlobalKey(), + ); + if (key.currentContext?.findRenderObject() == null) continue; + + final ro = key.currentContext!.findRenderObject()!; + if (!ro.attached) continue; + + try { + final vp = RenderAbstractViewport.of(ro); + final reveal = vp.getOffsetToReveal(ro, 0.0).offset; + + if (reveal <= scrollPixels + 80) { + current = section.category; + } else { + break; + } + } catch (_) { + continue; + } + } + + if (current != null && current != activeCategoryId.value) { + activeCategoryId.value = current; + } + } + + scrollController.addListener(listener); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (active) listener(); + }); + + return () { + active = false; + scrollController.removeListener(listener); + }; + }, [sections, scrollController]); + + return Column( + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: TextField( + controller: searchController, + focusNode: searchFocus, + decoration: InputDecoration( + hintText: "Search $placeholderText...", + prefixIcon: Icon(Icons.search), + suffixIcon: isSearching + ? IconButton( + icon: Icon(Icons.clear), + onPressed: () { + searchController.clear(); + searchFocus.requestFocus(); + }, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + Expanded( + child: Row( + children: [ + if (!isSearching) ...[ + SizedBox( + width: 100, + child: ListView.builder( + itemCount: sections.length, + itemBuilder: (context, index) { + final section = sections[index]; + final isActive = + activeCategoryId.value == section.category; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + activeCategoryId.value = section.category; + final key = sectionKeysMap.value[section.category]; + if (key?.currentContext != null) { + Scrollable.ensureVisible( + key!.currentContext!, + alignment: 0.0, + duration: const Duration(milliseconds: 250), + ); + } + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + decoration: BoxDecoration( + border: Border( + left: BorderSide( + width: 2, + color: isActive + ? Theme.of(context).colorScheme.primary + : Colors.transparent, + ), + ), + color: isActive + ? Theme.of(context).colorScheme.primary + .withValues(alpha: 0.08) + : Colors.transparent, + ), + child: Text( + section.isRecent ? "Recent" : section.label, + style: TextStyle( + fontSize: 13, + fontWeight: isActive ? FontWeight.w600 : null, + color: isActive + ? Theme.of(context).colorScheme.primary + : Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ); + }, + ), + ), + const VerticalDivider(width: 1), + ], + Expanded( + child: + sectionsSnapshot.connectionState == ConnectionState.waiting + ? const Center(child: CircularProgressIndicator()) + : sections.isEmpty + ? Center(child: Text("No $placeholderText found")) + : CustomScrollView( + controller: scrollController, + slivers: [ + for (final section in sections) ...[ + SliverToBoxAdapter( + key: sectionKeysMap.value.putIfAbsent( + section.category, + () => GlobalKey(), + ), + child: Container( + padding: const EdgeInsets.only( + left: 12, + top: 12, + bottom: 8, + ), + child: Text( + section.isRecent + ? "Recently Used" + : section.label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + ), + ), + ), + SliverPadding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + ), + sliver: SliverGrid( + gridDelegate: + const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 44, + crossAxisSpacing: 4, + mainAxisSpacing: 4, + ), + delegate: SliverChildBuilderDelegate(( + context, + index, + ) { + final item = section.items[index]; + return GestureDetector( + onTap: () => onSelect(item.id), + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular( + 6, + ), + ), + child: Text( + item.display, + style: const TextStyle(fontSize: 24), + ), + ), + ); + }, childCount: section.items.length), + ), + ), + ], + const SliverPadding( + padding: EdgeInsets.only(bottom: 24), + ), + ], + ), + ), + ], + ), + ), + ], + ); + } +} + +Future> fetchEmojiSections( + WidgetRef ref, + String? roomId, + String query, +) async { + final emotes = await ref.watch(EmojiController.provider.future); + final q = query.toLowerCase(); + + if (q.isEmpty) { + return emotes; + } + + return emotes + .map((cat) { + final filtered = cat.items + .where( + (e) => + e.keywords.any((k) => k.contains(q)) || e.id.contains(query), + ) + .toList(); + return filtered.isNotEmpty + ? EmoteSection( + category: cat.category, + label: cat.label, + icon: cat.icon, + items: filtered, + isRecent: cat.isRecent, + ) + : null; + }) + .whereType() + .toIList(); +} + +Future showEmotePickerBottomSheet({ + required BuildContext context, + String? roomId, + required void Function(String) onSelect, +}) async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + useSafeArea: true, + enableDrag: true, + isDismissible: true, + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.75, + maxWidth: 600, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => EmotePicker(roomId: roomId, onSelect: onSelect), + ); +} diff --git a/lib/widgets/players/audio.dart b/lib/widgets/players/audio.dart index 22a91f7..0c96579 100644 --- a/lib/widgets/players/audio.dart +++ b/lib/widgets/players/audio.dart @@ -1,17 +1,17 @@ import "dart:async"; + import "package:flutter/material.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:media_kit/media_kit.dart"; -import "package:nexus/controllers/client.dart"; +import "package:nexus/helpers/extensions/get_headers.dart"; import "package:nexus/models/info/audio.dart"; class AudioPlayer extends HookConsumerWidget { - final Uri uri; + final Uri url; final AudioInfo? info; - final bool encrypted; - const AudioPlayer(this.uri, this.info, {this.encrypted = false, super.key}); + const AudioPlayer(this.url, this.info, {super.key}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -24,12 +24,11 @@ class AudioPlayer extends HookConsumerWidget { final duration = useState(Duration.zero); useEffect(() { - player.platform?.state = player.platform!.state.copyWith(buffering: true); scheduleMicrotask(() async { - final video = await ref - .watch(ClientController.provider.notifier) - .downloadMedia(.new(mxc: uri, encrypted: encrypted)); - await player.open(Media(video.path), play: false); + await player.open( + Media(url.toString(), httpHeaders: ref.headers), + play: false, + ); player.stream.playing.listen((value) { playing.value = value; @@ -68,18 +67,12 @@ class AudioPlayer extends HookConsumerWidget { padding: .only(left: 8, right: 16), child: Row( children: [ - if (player.state.buffering) - SizedBox.square( - dimension: 24, - child: CircularProgressIndicator(padding: .all(4)), - ) - else - IconButton( - onPressed: player.playOrPause, - icon: Icon( - playing.value ? Icons.pause_circle : Icons.play_circle, - ), + IconButton( + onPressed: player.playOrPause, + icon: Icon( + playing.value ? Icons.pause_circle : Icons.play_circle, ), + ), SizedBox(width: 8), Text( format(position.value), diff --git a/lib/widgets/players/video.dart b/lib/widgets/players/video.dart index cd4e569..8083860 100644 --- a/lib/widgets/players/video.dart +++ b/lib/widgets/players/video.dart @@ -2,17 +2,16 @@ import "dart:async"; import "package:flutter/material.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:nexus/controllers/client.dart"; import "package:nexus/models/info/video.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:media_kit/media_kit.dart"; import "package:media_kit_video/media_kit_video.dart"; +import "package:nexus/helpers/extensions/get_headers.dart"; class VideoPlayer extends HookConsumerWidget { final VideoInfo? info; - final Uri uri; - final bool encrypted; - const VideoPlayer(this.uri, this.info, {this.encrypted = false, super.key}); + final Uri url; + const VideoPlayer(this.url, this.info, {super.key}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -22,13 +21,12 @@ class VideoPlayer extends HookConsumerWidget { final controller = useMemoized(() => VideoController(player)); useEffect(() { - player.platform?.state = player.platform!.state.copyWith(buffering: true); - scheduleMicrotask(() async { - final video = await ref - .watch(ClientController.provider.notifier) - .downloadMedia(.new(mxc: uri, encrypted: encrypted)); - await player.open(Media(video.path), play: false); - }); + scheduleMicrotask( + () => player.open( + Media(url.toString(), httpHeaders: ref.headers), + play: false, + ), + ); return player.dispose; }, []); diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index 2eb8bc3..bcca961 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -216,13 +216,10 @@ class EventRenderer extends HookConsumerWidget { if (event.content is! MessageContent) ReactionRow(event), if (event.sendError != null && event.sendError != "not sent") - Padding( - padding: .only(bottom: 4), - child: Text( - event.sendError!, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.error, - ), + Text( + event.sendError!, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.error, ), ), ].map((child) => Padding(padding: .only(left: 4), child: child)), diff --git a/lib/widgets/renderers/message.dart b/lib/widgets/renderers/message.dart index 012dcc9..6d660af 100644 --- a/lib/widgets/renderers/message.dart +++ b/lib/widgets/renderers/message.dart @@ -226,25 +226,10 @@ class MessageRenderer extends ConsumerWidget { ConstrainedBox( constraints: .loose(.square(500)), child: switch (event.content) { - VideoMessageContent( - :final info, - :final file, - ) => - VideoPlayer( - url, - info, - encrypted: file != null, - ), - AudioMessageContent( - :final info, - :final file, - ) => - AudioPlayer( - url, - info, - - encrypted: file != null, - ), + VideoMessageContent(:final info) => + VideoPlayer(url, info), + AudioMessageContent(:final info) => + AudioPlayer(url, info), FileMessageContent( :final info, :final filename, @@ -273,8 +258,7 @@ class MessageRenderer extends ConsumerWidget { (element) => element is UrlElement, ) case final UrlElement link?) - if (Uri.tryParse(link.url) case final Uri url?) - UrlPreview(url), + UrlPreview(link.url), ReactionRow(event), ], @@ -286,8 +270,9 @@ class MessageRenderer extends ConsumerWidget { "This message is redacted", style: errorStyle, ) - : Wrap( + : Row( spacing: 8, + mainAxisSize: .min, children: [ Text( "Unknown message type:", diff --git a/lib/widgets/room_chat.dart b/lib/widgets/room_chat.dart index b1f29c4..f679cf6 100644 --- a/lib/widgets/room_chat.dart +++ b/lib/widgets/room_chat.dart @@ -7,7 +7,6 @@ import "package:measure_size/measure_size.dart"; import "package:nexus/controllers/account_data.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client_state.dart"; -import "package:nexus/controllers/member_list_opened.dart"; import "package:nexus/controllers/pinned_ids.dart"; import "package:nexus/controllers/power_level.dart"; import "package:nexus/controllers/rooms.dart"; @@ -17,7 +16,7 @@ import "package:nexus/models/content/message.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/relation_type.dart"; import "package:nexus/widgets/composer/composer.dart"; -import "package:nexus/widgets/emoji_picker_button.dart"; +import "package:nexus/widgets/emote_picker.dart"; import "package:nexus/widgets/pinned_events_drawer.dart"; import "package:nexus/widgets/renderers/event.dart"; import "package:nexus/widgets/member_list.dart"; @@ -47,8 +46,9 @@ class RoomChat extends HookConsumerWidget { final composerSize = useState(64); + final memberListOpened = useState(showMembersByDefault); + final userId = ref.watch(ClientStateController.provider)?.userId; - final memberListOpened = ref.watch(MemberListOpenedController.provider); final theme = Theme.of(context); final nothing = Center( @@ -239,11 +239,23 @@ class RoomChat extends HookConsumerWidget { icon: Text(emoji), ), ), - EmojiPickerButton( - context: context, - onPressed: Navigator.of(context).pop, - onSelection: (emoji) => - notifier.sendReaction(emoji, event).onError(showError), + GestureDetector( + onTap: () { + Navigator.of(context).pop(); // Close popup menu + showEmotePickerBottomSheet( + context: context, + roomId: roomId, + onSelect: (emojiId) async { + await notifier + .sendReaction(emojiId, event) + .onError(showError); + }, + ); + }, + child: Padding( + padding: EdgeInsets.all(8), + child: Icon(Icons.emoji_emotions), + ), ), ], ), @@ -448,9 +460,7 @@ class RoomChat extends HookConsumerWidget { isDesktop: isDesktop, onOpenDrawer: Scaffold.of(context).openDrawer, onOpenMemberList: (thisContext) { - ref - .watch(MemberListOpenedController.provider.notifier) - .set(!memberListOpened); + memberListOpened.value = !memberListOpened.value; Scaffold.of(thisContext).openEndDrawer(); }, onOpenPinnedMessagesList: () { @@ -555,7 +565,7 @@ class RoomChat extends HookConsumerWidget { ), ), - if (memberListOpened == true && showMembersByDefault) + if (memberListOpened.value == true && showMembersByDefault) MemberList(roomId), ], ), diff --git a/lib/widgets/url_preview.dart b/lib/widgets/url_preview.dart index 3a20e19..5831300 100644 --- a/lib/widgets/url_preview.dart +++ b/lib/widgets/url_preview.dart @@ -6,7 +6,7 @@ import "package:nexus/helpers/launch_helper.dart"; import "package:nexus/helpers/mxc_image.dart"; class UrlPreview extends ConsumerWidget { - final Uri link; + final String link; const UrlPreview(this.link, {super.key}); @override @@ -18,7 +18,8 @@ class UrlPreview extends ConsumerWidget { data: (preview) => preview == null ? SizedBox.shrink() : InkWell( - onTap: () => ref.watch(LaunchHelper.provider).launchUrl(link), + onTap: () => + ref.watch(LaunchHelper.provider).launchUrl(.parse(link)), child: Card( margin: .symmetric(vertical: 4), color: Theme.of( diff --git a/linux/nix/devshell.nix b/linux/nix/devshell.nix index 812082c..ae77467 100644 --- a/linux/nix/devshell.nix +++ b/linux/nix/devshell.nix @@ -18,25 +18,25 @@ let }; in pkgs.mkShell { - packages = - with pkgs; - [ - go - git - jdk17 - libGL - (flutter.override { - extraPkgConfigPackages = [ - mpv-unwrapped - libass - ]; - }) - android.platform-tools - ] - ++ lib.optional pkgs.stdenv.isLinux wayland; + packages = with pkgs; [ + go + git + jdk17 + libGL + wayland + (flutter.override { + extraPkgConfigPackages = [ + mpv-unwrapped + libass + ]; + }) + android.platform-tools + ]; env = rec { LIBCLANG_PATH = lib.makeLibraryPath [ pkgs.libclang ]; + LD_LIBRARY_PATH = "./build/native_assets/linux:${lib.makeLibraryPath [ pkgs.zlib ]}"; + CPATH = lib.makeSearchPath "include" [ pkgs.glibc.dev ]; ANDROID_HOME = "${android.androidsdk}/libexec/android-sdk"; ANDROID_SDK_ROOT = ANDROID_HOME; @@ -44,9 +44,5 @@ pkgs.mkShell { TOOLS = "${ANDROID_HOME}/build-tools/${"36.0.0"}"; GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${TOOLS}/aapt2"; - } - // lib.optionalAttrs pkgs.stdenv.isLinux { - CPATH = lib.makeSearchPath "include" [ pkgs.glibc.dev ]; - LD_LIBRARY_PATH = "./build/native_assets/linux:${lib.makeLibraryPath [ pkgs.zlib ]}"; }; } diff --git a/linux/nix/pkg/default.nix b/linux/nix/pkg/default.nix index c026606..4e00959 100644 --- a/linux/nix/pkg/default.nix +++ b/linux/nix/pkg/default.nix @@ -44,7 +44,7 @@ flutter.buildFlutterApplication { meta = { description = "A simple and user-friendly Matrix client"; mainProgram = "nexus"; - platforms = lib.platforms.unix; + platforms = lib.platforms.linux; maintainers = with lib.maintainers; [ quadradical ]; }; } diff --git a/linux/nix/pkg/gomuks.nix b/linux/nix/pkg/gomuks.nix index 9d7770b..40fe29c 100644 --- a/linux/nix/pkg/gomuks.nix +++ b/linux/nix/pkg/gomuks.nix @@ -1,38 +1,31 @@ { src, - stdenv, buildGoModule, }: -buildGoModule ( - finalAttrs: - let - filename = "libgomuks${stdenv.hostPlatform.extensions.sharedLibrary}"; - in - { - pname = "gomuks-ffi"; - version = "submodule"; +buildGoModule (finalAttrs: { + pname = "gomuks-ffi"; + version = "submodule"; - doCheck = false; + doCheck = false; - src = "${src}/gomuks"; + src = "${src}/gomuks"; - vendorHash = "sha256-4QU51WGiNKYm7z/ajnas2qQaMK5BgyHwPfmNpQvW0qg="; + vendorHash = "sha256-4QU51WGiNKYm7z/ajnas2qQaMK5BgyHwPfmNpQvW0qg="; - buildPhase = '' - runHook preBuild + buildPhase = '' + runHook preBuild - go build -buildmode=c-shared -o ${filename} -tags goolm,noheic,sqlite_fts5 ./pkg/ffi + go build -buildmode=c-shared -o libgomuks.so -tags goolm,noheic,sqlite_fts5 ./pkg/ffi - runHook postBuild - ''; + runHook postBuild + ''; - installPhase = '' - runHook preInstall + installPhase = '' + runHook preInstall - install -Dm0644 ${filename} -t $out/lib + install -Dm0644 libgomuks.so -t $out/lib - runHook postInstall - ''; - } -) + runHook postInstall + ''; +}) diff --git a/scripts/generate.dart b/scripts/generate.dart index c381230..0b0cf7c 100644 --- a/scripts/generate.dart +++ b/scripts/generate.dart @@ -24,18 +24,7 @@ void main(List args) async { ).generate( libclangDylib: libclangPath == null ? null - : Uri.file( - join( - libclangPath, - "libclang.${(Platform.isLinux || Platform.isAndroid) - ? "so" - : Platform.isMacOS - ? "dylib" - : Platform.isWindows - ? "dll" - : throw UnsupportedError("Unsupported Platform")}", - ), - ), + : Uri.file(join(libclangPath, "libclang.so")), ); print("Done!"); }