diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml new file mode 100644 index 0000000..8ed63cf --- /dev/null +++ b/.github/workflows/macos.yml @@ -0,0 +1,32 @@ +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 dc5e02e..145276a 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "gomuks"] path = gomuks - url = https://github.com/henry-hiles/gomuks + url = https://github.com/gomuks/gomuks branch = main diff --git a/README.md b/README.md index d191d6e..57b42d8 100644 --- a/README.md +++ b/README.md @@ -127,15 +127,16 @@ 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, and Glibc. Do not use any Snap packages, they cause various compilation issues. +- Without Nix: Install Flutter, Go, Git, Libclang, Libass, MPV, 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) @@ -202,7 +203,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 c06eb1c..0f8fb26 160000 --- a/gomuks +++ b/gomuks @@ -1 +1 @@ -Subproject commit c06eb1cbda2d49ff199a351e2d55df6ff7ab602b +Subproject commit 0f8fb261ab57977fa24a4f63b7aafbb9a4ff1b7b diff --git a/lib/controllers/attachment.dart b/lib/controllers/attachment.dart index e33493e..2a99ee5 100644 --- a/lib/controllers/attachment.dart +++ b/lib/controllers/attachment.dart @@ -17,18 +17,16 @@ class AttachmentController extends Notifier<(String, MessageContent?)?> { final filename = basename(file.path); state = (filename, null); - final room = ref.watch( - RoomsController.provider.select((value) => value[roomId]), + final isEncrypted = ref.read( + RoomsController.provider.select( + (value) => + value[roomId]?.state[EventType.encryption.type]?.isNotEmpty == true, + ), ); final content = await ref .watch(ClientController.provider.notifier) - .uploadMedia( - .new( - path: file.path, - encrypt: room?.state[EventType.encryption.type]?.isNotEmpty == true, - ), - ); + .uploadMedia(.new(path: file.path, encrypt: isEncrypted)); state = (filename, content); } diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index 2cb1d66..97c3ed6 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -17,6 +17,7 @@ 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"; @@ -215,11 +216,6 @@ 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}); @@ -255,6 +251,9 @@ 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 f1a5c79..caea3de 100644 --- a/lib/controllers/emoji.dart +++ b/lib/controllers/emoji.dart @@ -1,47 +1,16 @@ import "dart:convert"; -import "package:collection/collection.dart"; +import "package:emoji_text_field/models/emoji_category.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/gemoji.dart"; +import "package:nexus/models/emoji.dart"; -class EmoteSection { - final String category; - final String label; - final IconData icon; - final List items; - final bool isRecent; +typedef EmojiTuple = (IMap, IMap>); - 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> { +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"), ); @@ -50,72 +19,66 @@ class EmojiController extends AsyncNotifier> { throw Exception("Failed to load emoji data"); } - return compute(parseEmojiJson, response.body); - } + final data = json.decode(response.body); - static IList parseEmojiJson(String body) { - final data = json.decode(body) as List; - final allEntries = data + final entries = (data as List) .cast>() - .map(Gemoji.fromJson) + .map(Emoji.fromJson) .toIList(); - final groupedByCategory = groupBy(allEntries, (g) => g.category); - - final result = groupedByCategory.entries.fold>( + final categoryMap = 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, + (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), + ), ), ), ); - return result; + final customKeywords = IMap( + .fromEntries( + keywordMap.entries.map( + (e) => .new(e.key, e.value.toList(growable: false)), + ), + ), + ); + + return (customCategories, customKeywords); } - 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; - } - } + static final provider = AsyncNotifierProvider( + EmojiController.new, + ); } diff --git a/lib/controllers/header.dart b/lib/controllers/header.dart deleted file mode 100644 index 3e173ad..0000000 --- a/lib/controllers/header.dart +++ /dev/null @@ -1,20 +0,0 @@ -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 new file mode 100644 index 0000000..e3509f0 --- /dev/null +++ b/lib/controllers/member_list_opened.dart @@ -0,0 +1,22 @@ +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 558817c..3e531bb 100644 --- a/lib/controllers/room_chat.dart +++ b/lib/controllers/room_chat.dart @@ -125,11 +125,9 @@ class RoomChatController extends AsyncNotifier?> { if (relationType == .edit) { baseContent = relation?.content; } else { - final attachment = ref.watch(AttachmentController.provider(roomId)); - if (attachment != null && attachment.$2 == null) return; - - baseContent = attachment?.$2; - ref.invalidate(AttachmentController.provider(roomId)); + final provider = AttachmentController.provider(roomId); + baseContent = ref.watch(provider)?.$2; + ref.invalidate(provider); } var taggedMessage = text; diff --git a/lib/controllers/url_preview.dart b/lib/controllers/url_preview.dart index 2a81a3c..1b17870 100644 --- a/lib/controllers/url_preview.dart +++ b/lib/controllers/url_preview.dart @@ -1,50 +1,28 @@ -import "dart:convert"; +import "package:flutter/widgets.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:http/http.dart"; -import "package:nexus/controllers/client_state.dart"; -import "package:nexus/controllers/header.dart"; +import "package:nexus/controllers/client.dart"; import "package:nexus/models/open_graph_data.dart"; class UrlPreviewController extends AsyncNotifier { - final String link; - UrlPreviewController(this.link); + final Uri url; + UrlPreviewController(this.url); @override Future build() async { + if (url.host == "matrix.to") return null; + try { - 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 await ref + .watch(ClientController.provider.notifier) + .getUrlPreview(url); + } catch (error, stackTrace) { + debugPrintStack(label: error.toString(), stackTrace: stackTrace); return null; } - return null; } static final provider = - AsyncNotifierProvider.family< - UrlPreviewController, - OpenGraphData?, - String - >(UrlPreviewController.new); + AsyncNotifierProvider.family( + UrlPreviewController.new, + ); } diff --git a/lib/helpers/extensions/get_headers.dart b/lib/helpers/extensions/get_headers.dart deleted file mode 100644 index 0fcec48..0000000 --- a/lib/helpers/extensions/get_headers.dart +++ /dev/null @@ -1,7 +0,0 @@ -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 f50d22d..53cc57b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,7 +6,6 @@ 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"; @@ -144,7 +143,6 @@ class App extends StatelessWidget { IListConst([ SharedPrefsController.provider, ClientController.provider, - HeaderController.provider, ]), ), ) diff --git a/lib/models/gemoji.dart b/lib/models/emoji.dart similarity index 59% rename from lib/models/gemoji.dart rename to lib/models/emoji.dart index 084a1ca..8e4eac6 100644 --- a/lib/models/gemoji.dart +++ b/lib/models/emoji.dart @@ -1,17 +1,17 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:freezed_annotation/freezed_annotation.dart"; -part "gemoji.freezed.dart"; -part "gemoji.g.dart"; +part "emoji.freezed.dart"; +part "emoji.g.dart"; @freezed -abstract class Gemoji with _$Gemoji { - const factory Gemoji({ +abstract class Emoji with _$Emoji { + const factory Emoji({ required String emoji, required String category, required IList aliases, required String description, required IList tags, - }) = _Gemoji; + }) = _Emoji; - factory Gemoji.fromJson(Map json) => _$GemojiFromJson(json); + factory Emoji.fromJson(Map json) => _$EmojiFromJson(json); } diff --git a/lib/widgets/composer/composer.dart b/lib/widgets/composer/composer.dart index 281db0d..ca3de8a 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,7 +59,10 @@ class Composer extends HookConsumerWidget { final attachment = ref.watch(AttachmentController.provider(roomId)); void send() { - if (controller.value.text.isEmpty && attachment == null) return; + if (controller.value.text.isEmpty && attachment == null || + attachment != null && attachment.$2 == null) { + return; + } onSend( controller.value.formattedText, shouldMention: shouldMention.value, @@ -123,35 +126,10 @@ class Composer extends HookConsumerWidget { ), ) ? [ - 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(); - }, - ); - }, + EmojiPickerButton( + context: context, + onSelection: (_) => node?.requestFocus(), + controller: controller.value, ), PopupMenuButton( tooltip: "Add media", diff --git a/lib/widgets/emoji_picker_button.dart b/lib/widgets/emoji_picker_button.dart new file mode 100644 index 0000000..2ac906a --- /dev/null +++ b/lib/widgets/emoji_picker_button.dart @@ -0,0 +1,52 @@ +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 deleted file mode 100644 index 881526c..0000000 --- a/lib/widgets/emote_picker.dart +++ /dev/null @@ -1,399 +0,0 @@ -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 0c96579..22a91f7 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/helpers/extensions/get_headers.dart"; +import "package:nexus/controllers/client.dart"; import "package:nexus/models/info/audio.dart"; class AudioPlayer extends HookConsumerWidget { - final Uri url; + final Uri uri; final AudioInfo? info; + final bool encrypted; - const AudioPlayer(this.url, this.info, {super.key}); + const AudioPlayer(this.uri, this.info, {this.encrypted = false, super.key}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -24,11 +24,12 @@ class AudioPlayer extends HookConsumerWidget { final duration = useState(Duration.zero); useEffect(() { + player.platform?.state = player.platform!.state.copyWith(buffering: true); scheduleMicrotask(() async { - await player.open( - Media(url.toString(), httpHeaders: ref.headers), - play: false, - ); + final video = await ref + .watch(ClientController.provider.notifier) + .downloadMedia(.new(mxc: uri, encrypted: encrypted)); + await player.open(Media(video.path), play: false); player.stream.playing.listen((value) { playing.value = value; @@ -67,12 +68,18 @@ class AudioPlayer extends HookConsumerWidget { padding: .only(left: 8, right: 16), child: Row( children: [ - IconButton( - onPressed: player.playOrPause, - icon: Icon( - playing.value ? Icons.pause_circle : Icons.play_circle, + 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, + ), ), - ), SizedBox(width: 8), Text( format(position.value), diff --git a/lib/widgets/players/video.dart b/lib/widgets/players/video.dart index 8083860..cd4e569 100644 --- a/lib/widgets/players/video.dart +++ b/lib/widgets/players/video.dart @@ -2,16 +2,17 @@ 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 url; - const VideoPlayer(this.url, this.info, {super.key}); + final Uri uri; + final bool encrypted; + const VideoPlayer(this.uri, this.info, {this.encrypted = false, super.key}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -21,12 +22,13 @@ class VideoPlayer extends HookConsumerWidget { final controller = useMemoized(() => VideoController(player)); useEffect(() { - scheduleMicrotask( - () => player.open( - Media(url.toString(), httpHeaders: ref.headers), - play: false, - ), - ); + 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); + }); return player.dispose; }, []); diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index bcca961..2eb8bc3 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -216,10 +216,13 @@ class EventRenderer extends HookConsumerWidget { if (event.content is! MessageContent) ReactionRow(event), if (event.sendError != null && event.sendError != "not sent") - Text( - event.sendError!, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.error, + Padding( + padding: .only(bottom: 4), + child: 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 6d660af..012dcc9 100644 --- a/lib/widgets/renderers/message.dart +++ b/lib/widgets/renderers/message.dart @@ -226,10 +226,25 @@ class MessageRenderer extends ConsumerWidget { ConstrainedBox( constraints: .loose(.square(500)), child: switch (event.content) { - VideoMessageContent(:final info) => - VideoPlayer(url, info), - AudioMessageContent(:final info) => - AudioPlayer(url, info), + VideoMessageContent( + :final info, + :final file, + ) => + VideoPlayer( + url, + info, + encrypted: file != null, + ), + AudioMessageContent( + :final info, + :final file, + ) => + AudioPlayer( + url, + info, + + encrypted: file != null, + ), FileMessageContent( :final info, :final filename, @@ -258,7 +273,8 @@ class MessageRenderer extends ConsumerWidget { (element) => element is UrlElement, ) case final UrlElement link?) - UrlPreview(link.url), + if (Uri.tryParse(link.url) case final Uri url?) + UrlPreview(url), ReactionRow(event), ], @@ -270,9 +286,8 @@ class MessageRenderer extends ConsumerWidget { "This message is redacted", style: errorStyle, ) - : Row( + : Wrap( spacing: 8, - mainAxisSize: .min, children: [ Text( "Unknown message type:", diff --git a/lib/widgets/room_chat.dart b/lib/widgets/room_chat.dart index f679cf6..b1f29c4 100644 --- a/lib/widgets/room_chat.dart +++ b/lib/widgets/room_chat.dart @@ -7,6 +7,7 @@ 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"; @@ -16,7 +17,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/emote_picker.dart"; +import "package:nexus/widgets/emoji_picker_button.dart"; import "package:nexus/widgets/pinned_events_drawer.dart"; import "package:nexus/widgets/renderers/event.dart"; import "package:nexus/widgets/member_list.dart"; @@ -46,9 +47,8 @@ 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,23 +239,11 @@ class RoomChat extends HookConsumerWidget { icon: Text(emoji), ), ), - 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), - ), + EmojiPickerButton( + context: context, + onPressed: Navigator.of(context).pop, + onSelection: (emoji) => + notifier.sendReaction(emoji, event).onError(showError), ), ], ), @@ -460,7 +448,9 @@ class RoomChat extends HookConsumerWidget { isDesktop: isDesktop, onOpenDrawer: Scaffold.of(context).openDrawer, onOpenMemberList: (thisContext) { - memberListOpened.value = !memberListOpened.value; + ref + .watch(MemberListOpenedController.provider.notifier) + .set(!memberListOpened); Scaffold.of(thisContext).openEndDrawer(); }, onOpenPinnedMessagesList: () { @@ -565,7 +555,7 @@ class RoomChat extends HookConsumerWidget { ), ), - if (memberListOpened.value == true && showMembersByDefault) + if (memberListOpened == true && showMembersByDefault) MemberList(roomId), ], ), diff --git a/lib/widgets/url_preview.dart b/lib/widgets/url_preview.dart index 5831300..3a20e19 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 String link; + final Uri link; const UrlPreview(this.link, {super.key}); @override @@ -18,8 +18,7 @@ class UrlPreview extends ConsumerWidget { data: (preview) => preview == null ? SizedBox.shrink() : InkWell( - onTap: () => - ref.watch(LaunchHelper.provider).launchUrl(.parse(link)), + onTap: () => ref.watch(LaunchHelper.provider).launchUrl(link), child: Card( margin: .symmetric(vertical: 4), color: Theme.of( diff --git a/linux/nix/devshell.nix b/linux/nix/devshell.nix index ae77467..812082c 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 - wayland - (flutter.override { - extraPkgConfigPackages = [ - mpv-unwrapped - libass - ]; - }) - android.platform-tools - ]; + packages = + with pkgs; + [ + go + git + jdk17 + libGL + (flutter.override { + extraPkgConfigPackages = [ + mpv-unwrapped + libass + ]; + }) + android.platform-tools + ] + ++ lib.optional pkgs.stdenv.isLinux wayland; 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,5 +44,9 @@ 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 4e00959..c026606 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.linux; + platforms = lib.platforms.unix; maintainers = with lib.maintainers; [ quadradical ]; }; } diff --git a/linux/nix/pkg/gomuks.nix b/linux/nix/pkg/gomuks.nix index 40fe29c..9d7770b 100644 --- a/linux/nix/pkg/gomuks.nix +++ b/linux/nix/pkg/gomuks.nix @@ -1,31 +1,38 @@ { src, + stdenv, buildGoModule, }: -buildGoModule (finalAttrs: { - pname = "gomuks-ffi"; - version = "submodule"; +buildGoModule ( + finalAttrs: + let + filename = "libgomuks${stdenv.hostPlatform.extensions.sharedLibrary}"; + in + { + 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 libgomuks.so -tags goolm,noheic,sqlite_fts5 ./pkg/ffi + go build -buildmode=c-shared -o ${filename} -tags goolm,noheic,sqlite_fts5 ./pkg/ffi - runHook postBuild - ''; + runHook postBuild + ''; - installPhase = '' - runHook preInstall + installPhase = '' + runHook preInstall - install -Dm0644 libgomuks.so -t $out/lib + install -Dm0644 ${filename} -t $out/lib - runHook postInstall - ''; -}) + runHook postInstall + ''; + } +) diff --git a/scripts/generate.dart b/scripts/generate.dart index 0b0cf7c..c381230 100644 --- a/scripts/generate.dart +++ b/scripts/generate.dart @@ -24,7 +24,18 @@ void main(List args) async { ).generate( libclangDylib: libclangPath == null ? null - : Uri.file(join(libclangPath, "libclang.so")), + : Uri.file( + join( + libclangPath, + "libclang.${(Platform.isLinux || Platform.isAndroid) + ? "so" + : Platform.isMacOS + ? "dylib" + : Platform.isWindows + ? "dll" + : throw UnsupportedError("Unsupported Platform")}", + ), + ), ); print("Done!"); }