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

Compare commits

..
Author SHA1 Message Date
115bec5f05
slightly tweak readme
[skip ci]
2026-07-31 11:55:48 -04:00
fc4aa5a10d
persist member list opened status 2026-07-31 10:20:06 -04:00
f3c31db244
update build requirements 2026-07-29 21:57:03 -04:00
419d75ca83
wrap messages with unknown message types 2026-07-29 19:46:39 -04:00
9831446c66
add a little padding to send errors 2026-07-29 16:18:07 -04:00
d7f49568a7
move attachment sending check to be earlier
fixes sending with keyboard before attachment uploaded
2026-07-29 12:11:57 -04:00
f0f426a647
improve matcher for encrypted media 2026-07-29 12:06:19 -04:00
80fa9673bb
fix pending attachments disappearing when new events are sent 2026-07-29 11:58:12 -04:00
8c25228bd9
comment out macos CI auto running [skip ci] 2026-07-28 20:39:03 -04:00
9bf664624a
clone submodules on macos 2026-07-28 17:43:51 -04:00
e8b90591d5
only include wayland in devshell on macos 2026-07-28 17:16:21 -04:00
b2813a0eb5
use devshell for macos 2026-07-28 17:04:06 -04:00
9298eb815d
use libclang.dylib on macos 2026-07-28 15:57:39 -04:00
06075f75c5
make gomuks nix package build to a .dylib on macos 2026-07-28 15:49:27 -04:00
60968f5f3e
make macos builder build app from nix 2026-07-28 15:29:41 -04:00
afd6168535
fix macos CI name
[skip ci]
2026-07-28 15:15:20 -04:00
2a1cc33ee3
add macos ci
hopefully this works first try :3
2026-07-28 15:13:14 -04:00
5f13d3fbf5
don't show errors when url preview fails 2026-07-27 22:02:20 -04:00
eca9d3b411
use gomuks for URL previews 2026-07-27 16:01:40 -04:00
16fdf0c420
download audio and video using gomuks 2026-07-27 15:46:08 -04:00
27 changed files with 340 additions and 709 deletions

32
.github/workflows/macos.yml vendored Normal file
View file

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

2
.gitmodules vendored
View file

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

View file

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

2
gomuks

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

View file

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

View file

@ -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<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});
@ -255,6 +251,9 @@ 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,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<EmoteItem> items;
final bool isRecent;
typedef EmojiTuple = (IMap<String, EmojiCategory>, IMap<String, List<String>>);
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>> {
class EmojiController extends AsyncNotifier<EmojiTuple> {
@override
Future<IList<EmoteSection>> build() async {
Future<EmojiTuple> 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<IList<EmoteSection>> {
throw Exception("Failed to load emoji data");
}
return compute(parseEmojiJson, response.body);
}
final data = json.decode(response.body);
static IList<EmoteSection> parseEmojiJson(String body) {
final data = json.decode(body) as List;
final allEntries = data
final entries = (data as List)
.cast<Map<String, dynamic>>()
.map(Gemoji.fromJson)
.map(Emoji.fromJson)
.toIList();
final groupedByCategory = groupBy(allEntries, (g) => g.category);
final result = groupedByCategory.entries.fold<IList<EmoteSection>>(
final categoryMap = entries.fold<IMap<String, IList<String>>>(
.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<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),
),
),
),
);
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, 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;
}
}
static final provider = AsyncNotifierProvider<EmojiController, EmojiTuple>(
EmojiController.new,
);
}

View file

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

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

View file

@ -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<OpenGraphData?> {
final String link;
UrlPreviewController(this.link);
final Uri url;
UrlPreviewController(this.url);
@override
Future<OpenGraphData?> 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<String, dynamic>) 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, OpenGraphData?, Uri>(
UrlPreviewController.new,
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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