forked from Nexus/nexus
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 941e1c0e69 | |||
| 49b94a2e34 | |||
| 0813597a53 |
27 changed files with 709 additions and 340 deletions
32
.github/workflows/macos.yml
vendored
32
.github/workflows/macos.yml
vendored
|
|
@ -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
2
.gitmodules
vendored
|
|
@ -1,4 +1,4 @@
|
|||
[submodule "gomuks"]
|
||||
path = gomuks
|
||||
url = https://github.com/gomuks/gomuks
|
||||
url = https://github.com/henry-hiles/gomuks
|
||||
branch = main
|
||||
|
|
|
|||
|
|
@ -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
2
gomuks
|
|
@ -1 +1 @@
|
|||
Subproject commit 0f8fb261ab57977fa24a4f63b7aafbb9a4ff1b7b
|
||||
Subproject commit c06eb1cbda2d49ff199a351e2d55df6ff7ab602b
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
20
lib/controllers/header.dart
Normal file
20
lib/controllers/header.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
7
lib/helpers/extensions/get_headers.dart
Normal file
7
lib/helpers/extensions/get_headers.dart
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
399
lib/widgets/emote_picker.dart
Normal file
399
lib/widgets/emote_picker.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}, []);
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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:",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 ]}";
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ];
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
'';
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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!");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue