Watch
1
0
Fork
You've already forked nexus
0
forked from Nexus/nexus

Compare commits

..
Author SHA1 Message Date
941e1c0e69 WIP: New Emoji Picker 2026-08-01 00:58:57 +02:00
49b94a2e34 Merge remote-tracking branch 'origin/main' into custom_emoji_picker_stickers_and_more 2026-07-27 20:17:15 +02:00
0813597a53 Small refactor of emoji controller and widget
Isolated deserialization of the large emoji json to not keep main thread busy to long
Refactored textController logic in EmojiPickerButton to make popping mid buid impossible. The bug from the comment should be impossible now
(The bug could be reproduce with future.delayed and microtask but)
2026-07-25 19:04:56 +02:00
27 changed files with 709 additions and 340 deletions

View file

@ -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

2
.gitmodules vendored
View file

@ -1,4 +1,4 @@
[submodule "gomuks"]
path = gomuks
url = https://github.com/gomuks/gomuks
url = https://github.com/henry-hiles/gomuks
branch = main

View file

@ -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

2
gomuks

@ -1 +1 @@
Subproject commit 0f8fb261ab57977fa24a4f63b7aafbb9a4ff1b7b
Subproject commit c06eb1cbda2d49ff199a351e2d55df6ff7ab602b

View file

@ -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);
}

View file

@ -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<int> {
return response["room_id"];
}
Future<String?> getAccessToken() async {
final response = await _sendCommand("get_account_info", {});
return response?["access_token"];
}
Future<void> 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<int> {
return json == null ? null : .fromJson(json);
}
Future<OpenGraphData> getUrlPreview(Uri url) async =>
.fromJson(await _sendCommand("get_url_preview", {"url": url.toString()}));
Future<Paginate> paginate(PaginateRequest request) async =>
.fromJson(await _sendCommand("paginate", request.toJson()));

View file

@ -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<String, EmojiCategory>, IMap<String, List<String>>);
class EmoteSection {
final String category;
final String label;
final IconData icon;
final List<EmoteItem> items;
final bool isRecent;
class EmojiController extends AsyncNotifier<EmojiTuple> {
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<String> 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<IList<EmoteSection>> {
@override
Future<EmojiTuple> build() async {
Future<IList<EmoteSection>> 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<EmojiTuple> {
throw Exception("Failed to load emoji data");
}
final data = json.decode(response.body);
final entries = (data as List)
.cast<Map<String, dynamic>>()
.map(Emoji.fromJson)
.toIList();
final categoryMap = entries.fold<IMap<String, IList<String>>>(
.new(),
(acc, entry) => acc.update(
entry.category,
(list) => list.add(entry.emoji),
ifAbsent: () => .new([entry.emoji]),
),
);
final keywordMap = entries.fold<IMap<String, IList<String>>>(
.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, EmojiTuple>(
EmojiController.new,
);
static IList<EmoteSection> parseEmojiJson(String body) {
final data = json.decode(body) as List;
final allEntries = data
.cast<Map<String, dynamic>>()
.map(Gemoji.fromJson)
.toIList();
final groupedByCategory = groupBy(allEntries, (g) => g.category);
final result = groupedByCategory.entries.fold<IList<EmoteSection>>(
.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, IList<EmoteSection>>(
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;
}
}
}

View file

@ -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<Map<String, String>> {
@override
Future<Map<String, String>> 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, Map<String, String>>(
HeaderController.new,
);
}

View file

@ -1,22 +0,0 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/shared_prefs.dart";
class MemberListOpenedController extends Notifier<bool> {
static const String key = "memberListOpened";
@override
bool build() =>
ref.watch(SharedPrefsController.provider).requireValue.getBool(key) ??
true;
Future<void> set(bool value) async {
final prefs = ref.watch(SharedPrefsController.provider).requireValue;
state = value;
prefs.setBool(key, value);
}
static final provider = NotifierProvider<MemberListOpenedController, bool>(
MemberListOpenedController.new,
);
}

View file

@ -125,9 +125,11 @@ class RoomChatController extends AsyncNotifier<IList<Event>?> {
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;

View file

@ -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<OpenGraphData?> {
final Uri url;
UrlPreviewController(this.url);
final String link;
UrlPreviewController(this.link);
@override
Future<OpenGraphData?> 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<String, dynamic>) return null;
return .fromJson(
decodedValue,
).copyWith(imageUrl: decodedValue["og:image"]);
}
}
}
} catch (_) {
return null;
}
return null;
}
static final provider =
AsyncNotifierProvider.family<UrlPreviewController, OpenGraphData?, Uri>(
UrlPreviewController.new,
);
AsyncNotifierProvider.family<
UrlPreviewController,
OpenGraphData?,
String
>(UrlPreviewController.new);
}

View file

@ -0,0 +1,7 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/header.dart";
extension GetHeaders on WidgetRef {
Map<String, String> get headers =>
watch(HeaderController.provider).requireValue;
}

View file

@ -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,
]),
),
)

View file

@ -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<String> aliases,
required String description,
required IList<String> tags,
}) = _Emoji;
}) = _Gemoji;
factory Emoji.fromJson(Map<String, Object?> json) => _$EmojiFromJson(json);
factory Gemoji.fromJson(Map<String, Object?> json) => _$GemojiFromJson(json);
}

View file

@ -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",

View file

@ -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),
);
}

View file

@ -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<IList<EmoteSection>> 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(<String, GlobalKey>{});
final activeCategoryId = useState<String?>(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<IList<EmoteSection>> 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<EmoteSection>()
.toIList();
}
Future<void> showEmotePickerBottomSheet({
required BuildContext context,
String? roomId,
required void Function(String) onSelect,
}) async {
await showModalBottomSheet<void>(
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),
);
}

View file

@ -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),

View file

@ -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;
}, []);

View file

@ -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)),

View file

@ -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:",

View file

@ -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<double>(64);
final memberListOpened = useState<bool>(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),
],
),

View file

@ -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(

View file

@ -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 ]}";
};
}

View file

@ -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 ];
};
}

View file

@ -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
'';
})

View file

@ -24,18 +24,7 @@ void main(List<String> 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!");
}