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

Compare commits

..
Author SHA1 Message Date
0a09a4ee0a
general cleanups and mounted guards 2026-10-24 12:02:23 -04:00
62e200f305
fix typing indicators not ellipsizing 2026-10-24 11:30:14 -04:00
dd01ad9d74
migrate Content classes to primary constructors
The Riverpod bug preventing us from doing this has been fixed.
2026-10-23 20:44:29 -04:00
853116ab2f
treewide cleanups 2026-10-23 20:26:37 -04:00
53d9c2b1e6
make enums fall back to unknown or null where possible
Futureproofing.
2026-10-23 20:12:54 -04:00
401b527714
ignore invalid account data 2026-10-23 20:05:23 -04:00
3754bc2feb
allow sending emoji/stickers from emote packs 2026-10-23 18:16:45 -04:00
868023a4a1
avoid un-needed reloads for diffs 2026-10-23 17:02:41 -04:00
9e6951818a
hide server ACL events from timeline by default, introduce setting to show them 2026-10-23 14:29:37 -04:00
0743c4cfc6
reorder appearance settings 2026-10-23 14:26:26 -04:00
6336764da0
add power level groups 2026-10-23 12:24:22 -04:00
3a39f1c5ac
more intuitive permissions in encrypted rooms 2026-10-23 12:12:20 -04:00
6e2d56ec75
add diffing for a lot of events 2026-10-23 12:01:38 -04:00
4e140e9fc7
add pinned event and server ACL diffing 2026-10-23 11:28:44 -04:00
e5dd53ed6c
tweak color picking algorithm for power levels, and introduce setting for per PL or per user colors 2026-10-23 10:23:49 -04:00
c0f4c053b0
Make displayname colors based off of power level
Fixes #128
2026-10-23 01:10:01 -04:00
ee36a9b1c8
Revert "use media_kit from main"
This reverts commit f5371382d7.
2026-10-23 00:36:31 -04:00
f5371382d7
use media_kit from main
Has some perf improvements, especially for Linux
2026-10-23 00:28:43 -04:00
2937c241e0
release v1.0.0 2026-10-22 22:10:33 -04:00
9dcddb3fbe
Revert "quick smoketest of macos"
This reverts commit 3014a9d029.
2026-10-22 22:08:59 -04:00
3014a9d029
quick smoketest of macos 2026-10-22 21:41:31 -04:00
4a9f151d92
don't watch entire room in room settings
performance improvement
2026-10-22 21:36:16 -04:00
d4ae5ebccf
drop unstable timezone key support 2026-10-22 21:32:19 -04:00
850bd33b18
fix maxMagnitude value for power levels
(oops thats a xor not an exponent operator)
2026-10-22 21:28:51 -04:00
8fbfcde9c5
add profile editor
Fixes #12
2026-10-22 21:20:13 -04:00
e0b5dc58d0
gate android branches of window_manager 2026-10-22 17:31:43 -04:00
ffbf2722f7
turn down minimum size
too large for linux mobile rn
2026-10-22 16:33:02 -04:00
3e1552f246
don't open media if context not mounted 2026-10-22 16:09:27 -04:00
44a1ec667e
switch to new window_manager from main 2026-10-22 16:06:28 -04:00
479279b68e
bring back padding for filters on member list 2026-10-22 15:33:29 -04:00
b0dd452926
add power level room settings 2026-10-22 15:29:02 -04:00
fcfe4eed1e
more correct fallback for unknown join conditions 2026-10-22 14:32:58 -04:00
b390dbbbe1
add room access settings 2026-10-22 14:22:54 -04:00
8e51852ae3
add general room settings 2026-10-22 11:29:19 -04:00
48c04d9b3e
make swipe from right open member list 2026-10-22 10:53:07 -04:00
7f30150e95
make theme's platform Android when Linux Mobile Mode is on
Allows for swiping drawers closed
2026-10-22 10:37:31 -04:00
c8e1aef74b
show mutual rooms in user popover
Fixes #97
2026-10-22 10:25:50 -04:00
5ee3449f02
bump gomuks 2026-10-22 09:56:52 -04:00
1715ee785e
make names in typing indicators clickable 2026-10-21 23:01:22 -04:00
e38307657d
Read receipt support
Fixes #93
2026-10-21 22:58:06 -04:00
e14ab3e708
use *Async methods from gomuks
Performance improvement due to not needing as many Isolates.
2026-10-21 22:57:26 -04:00
f5645db6ea
only reserve space for jump to bottom when it exists 2026-10-21 15:09:41 -04:00
0fcbc275cd
add typing indicators
Fixes #92
2026-10-21 15:07:02 -04:00
a54004736b
add event search
Fixes #77
2026-10-21 13:31:01 -04:00
427e06be3e
add knock support
Fixes #123
2026-10-21 12:32:02 -04:00
a1a15af30d
use dot shorthands for default immutable collections where possible 2026-10-20 18:23:41 -04:00
800fb5566d add a global toggle to hide membership events (#142)
fixes #141

Co-authored-by: seres <seres@seres.eu.org>
Reviewed-on: Nexus/nexus#142
2026-10-20 18:15:01 -04:00
80197aff00
add keyboard inset workaround to MainActivity
Should be reverted once https://github.com/flutter/flutter/issues/191156 is fixed
2026-10-20 15:17:34 -04:00
78162efbde
move MainActivity to correct directory 2026-10-20 15:15:42 -04:00
a2906746b9
release v1.0.0-alpha5 2026-10-19 21:18:33 -04:00
6659f21ceb
apply SafeArea on showAdaptiveSheet
This fixes actions being cut off when using button navigation.
2026-10-19 21:18:03 -04:00
a065b823bb
remove outdated bundled-emoji reference 2026-10-19 21:10:05 -04:00
765e96cce4
refactor and improve notification handling 2026-10-19 21:10:05 -04:00
9cf16db669
use webcrypto fork to fix macOS builds 2026-10-19 20:39:33 -04:00
2c8a4da2a8
Reapply "override webcrypto to a version with build hooks"
This reverts commit eca2b0b558.
2026-10-19 20:02:34 -04:00
cb426cd27e
add fallback emoji font list until flutter 3.50 releases 2026-10-19 19:39:09 -04:00
bfd259543d
don't bundle an emoji font
Might need reverts after testing.
2026-10-19 18:19:05 -04:00
9f0b80072d
pause sync when app is backgrounded 2026-10-19 17:40:57 -04:00
b6ee5aec64
use system emoji before bundled
Needs testing on:
- Android
- Windows
- MacOS
To make sure that it doesn't break stuff.
2026-10-19 10:38:30 -04:00
deac9ef24e
handle permission denials more gracefully 2026-10-18 16:36:36 -04:00
e24e99fd8e
slight improvements to windows and darwin notifs 2026-10-18 16:14:00 -04:00
4580110da0
update gomuks vendorHash 2026-10-18 16:04:56 -04:00
d1724932b2
clean up and improve notifications
Thanks to Gomuks' new handling, we have sender displayname and avatar, and can use android's conversation notifications.

On other platforms, we just append sender name to the body, bolded if possible.

This also improves the logic on when to send notifications, as we drop notifs where `response["notification"]` is null.

We also now correctly set if notifications should play a sound or not, based on `PushNotification.sound`.

`PushNotification.image` handling might come sometime in the future, but it seems hard on Android.
2026-10-18 16:01:37 -04:00
a6fe80fea2
split APKs per abi and only upload arm64 APK 2026-10-17 16:19:27 -04:00
9f45cbc1e7
add warning when user has cross signing but not secret storage 2026-10-17 16:12:10 -04:00
eca2b0b558
Revert "override webcrypto to a version with build hooks"
This reverts commit 4e6ba571fc.

Due to an upstream bug, this broke Linux aarch64 builds, as well as MacOS builds.
2026-10-17 11:53:23 -04:00
4e6ba571fc
override webcrypto to a version with build hooks 2026-10-17 11:37:51 -04:00
200e03f082
noheic always 2026-10-17 11:21:20 -04:00
8180338d99
use another avif library that works on aarch64-linux 2026-10-17 11:18:23 -04:00
72c5e7d819
add errorBuilder to reactions 2026-10-17 10:24:30 -04:00
254b7e134f
release v1.0.0-alpha4 2026-10-16 22:14:15 -04:00
ef25e765d4
handle events_decrypted 2026-10-16 22:12:15 -04:00
58a27fdd9e
tiny cleanup in avif decoding 2026-10-16 22:00:00 -04:00
6d42477041
add support for avif images
Fixes #138
2026-10-16 20:35:42 -04:00
2f17f5b6c6
add swipe to reply
Fixes #90
2026-10-16 18:08:42 -04:00
1380233f2e
release v1.0.0-alpha3 2026-10-16 17:20:36 -04:00
a3a3781252
open sidebar on homepage pop 2026-10-16 17:17:19 -04:00
81851bdc42
fix no room selected after leave / init 2026-10-16 17:13:25 -04:00
f19a51939c
small cleanups 2026-10-16 17:06:22 -04:00
0ad85f24d5
Add support for downloading media
Fixes #85
2026-10-16 17:05:26 -04:00
1c2ba2971b
release v1.0.0-alpha2 2026-10-16 11:23:14 -04:00
fb0af571ed
fixup controls on narrow videos 2026-10-16 11:14:07 -04:00
9cd43f4e3c
only start timer to end highlight after scroll complete 2026-10-16 11:08:24 -04:00
0fa0f57738
fix ctrl+v text paste not working 2026-10-16 11:02:23 -04:00
00f22d46b7
slight cleanups 2026-10-16 10:56:55 -04:00
23046f08a9
add support for initializing and resetting crypto identity
Fixes #137
2026-10-16 10:49:04 -04:00
784e5254e2
fixup credits header on readme 2026-10-15 23:41:40 -04:00
7193ac0dba
remove progress list and disclaimer for readme
Progress list has been replaced by issues, now there aren't too many unchecked boxes.
2026-10-15 23:40:33 -04:00
7ab4b5e59f
update screenshots 2026-10-15 23:28:08 -04:00
c23dc19c6e
update readme progress list
[skip ci]
2026-10-15 23:03:48 -04:00
0b79aac7af
update readme for release
[skip ci]
2026-10-15 23:01:47 -04:00
e4f290eaa6
release v1.0.0-alpha1 2026-10-15 22:57:36 -04:00
d309754c60
fix nix build 2026-10-15 22:57:36 -04:00
a5dedb0d38
add Not yet implemented warning to discover / directory page 2026-10-15 22:41:20 -04:00
659e76b8f8
add slight padding above browse space fab 2026-10-15 22:40:55 -04:00
ab3eda0beb
show topics and summaries for spaces 2026-10-15 22:39:37 -04:00
b71a7f0bd6
Show space hierarchies
Fixes #83
2026-10-15 22:34:27 -04:00
8d944831f5
treewide: small refactors 2026-10-15 20:10:44 -04:00
9117663c48
mass migrate to adaptive sheets 2026-10-15 20:06:37 -04:00
ba258c2fcd
invite-related fixups 2026-10-15 20:01:24 -04:00
485383e99c
fixup weird behavior when leaving spaces 2026-10-15 19:50:35 -04:00
ed84410039
add support for creating rooms
Fixes #81
2026-10-15 19:49:04 -04:00
df592b3f38
add better OAuth error
Fixes #88
2026-10-15 18:30:12 -04:00
d47c4ae15c
update gomuks vendorHash 2026-10-15 15:30:33 -04:00
d308ef98cb
small fixups
Fixes #67
2026-10-15 15:13:31 -04:00
f082a75d9c
run GomuksStart on an isolate 2026-10-15 14:25:45 -04:00
36f61a62d8
allow pasting attachments from keyboard
Closes #55
2026-10-15 13:56:10 -04:00
27137c488d
replace media_kit's video widget with a custom impl
Fixes #48

This helps Linux touchscreen devices like Linux phones, where the old widget didn't work correctly.
2026-10-15 10:53:30 -04:00
ce5c7b972b
make user sheets popovers on desktop 2026-10-15 10:25:20 -04:00
76d78596cd
show bottom sheets / dialogs / menus based on screen size
Fixes #76
2026-10-15 10:20:27 -04:00
ed2d08ed40
fixup showing an error when opening info dialogue on sync waiting 2026-10-15 10:02:43 -04:00
24e516e6dd
store related events for mentions 2026-10-15 00:44:08 -04:00
66a437094a
show badges totaled in more areas 2026-10-15 00:42:56 -04:00
d37ec2878b
add invite support
Closes #82
2026-10-15 00:35:38 -04:00
dbf30c3706
fix historical event jumps
Fixes #118
2026-10-14 23:52:16 -04:00
2d619bf1ad
properly order historical timelines
Fixes #117
2026-10-14 23:37:53 -04:00
07700c1eff
add syncing waiting / error / failed indicator 2026-10-14 23:21:31 -04:00
a5fef138dd
add support for altering room ordering
Fixes #115
2026-10-14 22:58:10 -04:00
7353955d4e
add more guidelines to DEVELOPMENT.md 2026-10-14 17:04:05 -04:00
012ecd9249
treewide sweepfixes 2026-10-14 17:01:39 -04:00
8fda43ccf8
Push sidebar if needed and scroll to notif when tapped
Fixes #116
2026-10-14 16:51:14 -04:00
3a33bf7bfe
Allow swapping UnifiedPush distributor
Fixes #120
2026-10-14 16:41:01 -04:00
9237a3dbed
use upstream nix2flatpak
All PRs have been merged.
2026-10-14 16:27:27 -04:00
30a5fa100c
gracefully catch errors when parsing membership state 2026-10-12 10:36:41 -04:00
f198888635
bump deps
fixes #119
2026-10-11 13:43:13 -04:00
be945031cd
bump nix2flatpak 2026-10-11 12:48:53 -04:00
ad525b338c
fix state events not wrapping
Why did it do this ??????
2026-10-08 10:12:43 -04:00
0010160714
fix bigEmoji causing an overflow in replies 2026-10-08 10:08:33 -04:00
f741a6790d
update gomuks vendorHash 2026-10-06 19:27:37 -04:00
7be1dfe0bc
add inviting from room menu 2026-10-06 19:23:00 -04:00
08dcfdab75
fixup topic alignment for short topics 2026-10-06 10:26:59 -04:00
c81000d6c6
slightly improve bitfield handling for UnreadType 2026-10-05 20:16:01 -04:00
3c6c610f7c
improve UP timeout behavior
Hopefully more reliable.
2026-10-05 17:00:56 -04:00
61c5aa94c2
add timeout for UP reg 2026-10-05 15:07:22 -04:00
02ba9180db
fix error when opening settings during init sync 2026-10-05 14:23:06 -04:00
3c6f9f5da2
allow adding minimized button on all desktop platforms
Didn't mean to limit that to Linux
2026-10-05 12:35:18 -04:00
1d4af179d4
try opting out of predictive back
Needed until flutter bug is fixed
2026-10-05 12:23:08 -04:00
9974a6c225
add enableOnBackInvokedCallback true
Hopefully fixes drawers not popping?
2026-10-05 12:01:13 -04:00
9c83ae573a
add setting for showing minimize button 2026-10-05 11:26:26 -04:00
66cfb0e2c6
fix wrapping issues on event previews 2026-10-05 11:16:11 -04:00
02c486e1b3
make it more clear which methods free the buffer 2026-10-05 10:11:16 -04:00
b694da31f9
Revert "swap to SystemNavigator.pop from exit"
This reverts commit a86748e85f, as it was causing issues on Windows.
2026-10-05 10:10:47 -04:00
f931615890
fix scrollview not sticking to bottom if composer sends multiline messages 2026-10-04 17:15:23 -04:00
ae8fa17db3
mark homeserver url field as a .url keyboardType 2026-10-04 16:45:19 -04:00
a63f4674fd
make composer auto capitalize sentences 2026-10-04 16:06:54 -04:00
a46d93dfc5
filter shown recent emoji to valid emoji 2026-10-04 13:01:06 -04:00
294723fdfb
tweak reply sizing more 2026-10-04 11:22:14 -04:00
5e3c7ffa68
alter text alignment and icon sizes treewide 2026-10-04 11:16:39 -04:00
1fd9810b83
fall back to filename when body is empty 2026-10-04 10:25:29 -04:00
d119370816
tweak padding and sizing in reply renderer 2026-10-04 10:08:17 -04:00
ffaa516dcd
always make event previews two lines for non-state events 2026-10-04 00:32:20 -04:00
e480e6193b
refactor and improve room list 2026-10-03 21:58:01 -04:00
e41efd92c1
turn up verify page horizontal padding
looks a little nicer
2026-10-03 21:32:53 -04:00
b253fc1d96
change font size of tagline on select server page 2026-10-03 21:05:42 -04:00
967fe66a1b
remove unredacted from login page for now 2026-10-03 21:01:20 -04:00
658fa562cf
await route.completed before assuming settings are popped 2026-10-03 20:58:11 -04:00
fe33ae2e97
lower insetPadding for verify dialog 2026-10-03 20:39:00 -04:00
bcb085c035
scroll chat by composer height change delta
slightly evil but works
2026-10-03 20:35:44 -04:00
eae79d943d
make verify dialog scrollable 2026-10-03 20:15:17 -04:00
bdac4a11d7
default to empty via when none are provided
fixes joining with only a room alias
2026-10-03 14:44:22 -04:00
2ed2443298
make composer revert to unedited content when dismissing edit, plus more fixes 2026-10-03 14:40:19 -04:00
7f71adab4c
fix double watch in composer 2026-10-03 13:59:11 -04:00
0a3ec830a3
stop event preview resizing itself on load
Fixes #109
2026-10-03 13:50:46 -04:00
650f1ac8e4
allow hiding gomuks internal messages 2026-10-03 13:24:47 -04:00
af5f238db0
stop complaining at me formatter 2026-10-03 12:34:56 -04:00
006008f61d
rename AvatarOrHash.height to be more accurate 2026-10-03 12:25:22 -04:00
f23a29e7c1
tweak MessageDisplayname fallback state 2026-10-03 12:23:33 -04:00
493776e3dd
clear init complete state on logout 2026-10-03 11:56:17 -04:00
25afcf4acd
fix silent useless error during verify 2026-10-03 11:53:32 -04:00
e54b059bc9
fix not in range error when logging out 2026-10-03 11:44:35 -04:00
b8b074b229
bump gomuks 2026-10-03 11:26:18 -04:00
9ffad93469
cleanup logic in event renderer 2026-10-03 01:22:03 -04:00
3875c3a14a
Add a setting (off by default) for showing all rooms in home
Fixes #107

@LogN review
2026-10-03 01:15:11 -04:00
a86748e85f
swap to SystemNavigator.pop from exit 2026-10-03 01:01:59 -04:00
19363baa8d
fixup issues in join dialog
Fixes #112
2026-10-03 00:53:05 -04:00
41bdb04b72
make chat follow new messages when scrolled to bottom
Fixes #106
2026-10-02 19:57:48 -04:00
8da46dffe4
allow hiding client stickies
Fixes #105
2026-10-02 17:01:25 -04:00
49f2dc55b8
update platform support in readme 2026-10-02 12:13:26 -04:00
f9ff04405f
mark nix package as broken on darwin 2026-10-02 12:09:55 -04:00
ecb965994a
small tweaks to room list 2026-10-02 11:36:03 -04:00
36917bbea5
fix localContent being outdated for edits 2026-10-02 11:32:10 -04:00
684d6bad82
only pop from notifications list if Navigator reports canPop 2026-10-02 11:22:03 -04:00
45d69b0b9f
move NotificationsPage into sidebar via NavigationBar 2026-10-02 11:19:46 -04:00
3b2cf6b2a4
move padding for user sheet inside the scrollview 2026-10-01 18:29:03 -04:00
a61cac130b
make user bottom sheet scrollable 2026-10-01 18:22:02 -04:00
ec30adc0e3
allow sending media even when last event was an edit 2026-10-01 18:12:59 -04:00
a63d182c50
show git commit in about dialog 2026-10-01 16:15:36 -04:00
64a8edda7d
don't make composer buttons narrow
It doesn't actually change the hitbox? Only the shape of the InkWell.
2026-10-01 15:27:22 -04:00
2e85b26511
make PinnedEventsController expose IList of eventRowIds 2026-10-01 12:26:12 -04:00
a91b45be31
make send button narrow 2026-10-01 12:23:13 -04:00
d9062f68c4
bump nix2flatpak, use updated runtime 2026-10-01 12:07:24 -04:00
b3b873ffce
hardcode padding in order to make PopupMenuButton actually .narrow
This is needed because of a flutter bug, see https://github.com/flutter/flutter/issues/193656.
2026-10-01 11:33:10 -04:00
ab69bfe553
bump lock 2026-10-01 10:31:41 -04:00
98108d013e
use m3e iconbuttons 2026-09-30 11:47:40 -04:00
bdf8fc6ce1
make inline codeblocks actually inline 2026-09-30 11:16:03 -04:00
b738c1d0e0
Bump gomuks
Fixes #104
2026-09-30 09:51:52 -04:00
93b832839b
only add non-free text reactions to recent 2026-09-29 21:40:08 -04:00
301bcd94f2
fixup jumps from notifs sometimes not working 2026-09-29 20:47:20 -04:00
da7f2dcf4e
change how cross-room jumping works 2026-09-29 20:23:16 -04:00
b92aaed610
fixup notif jumping 2026-09-29 20:11:57 -04:00
1e4aab08ad
clean up and fix up jumping 2026-09-29 19:52:11 -04:00
1c4bf99be2 Add support for jumping to event links (#103)
Fixes #78.

Reviewed-on: Nexus/nexus#103
2026-09-29 17:00:43 -04:00
597112f5d3
add room summary image for TWIM
Yes, I'm preparing rather early this week :p
2026-09-29 16:05:28 -04:00
c8b9654219
Clickable room links
Fixes #80
2026-09-29 16:02:25 -04:00
9bc2d9955a
make relatedEvent store a rowId 2026-09-29 15:17:35 -04:00
ead463262c
make NotificationsController work off of rowIds 2026-09-29 15:10:54 -04:00
281bab0202
Add room preview dialog
Closes #79
2026-09-29 13:55:17 -04:00
9e024fb6f2
only mark as read when fully at bottom 2026-09-29 10:46:15 -04:00
a0036bdda3
watch clientSticky in RoomChatController 2026-09-29 10:24:45 -04:00
bd7cd0b10c
fixup edit handling 2026-09-29 09:45:51 -04:00
ec0934110f
swap msystem to avoid warning on windows build 2026-09-28 23:01:50 -04:00
1cfda9c68b
bundle more DLLs with windows build 2026-09-28 22:03:21 -04:00
1758815cc9
bundle all fonts
Should fix #57
2026-09-28 20:43:50 -04:00
9f4702b9d1
remove SelectableRegion as it is causing issues 2026-09-28 19:32:18 -04:00
195d212d68
improve speed of state merge 2026-09-28 18:29:46 -04:00
a698cf8c28
small cleanups 2026-09-28 18:20:36 -04:00
338d5dfee9
clarify flatpak updates 2026-09-28 17:56:13 -04:00
efa3681816
improve install docs, incl flatpak repo and nixos module 2026-09-28 17:53:38 -04:00
3824ca1c13
remove metadata
It is now statically hosted by caddy
2026-09-28 17:45:01 -04:00
01566894a6
add flatpak repo
Hopefully this works
2026-09-28 16:48:18 -04:00
64e548f0c5
fix hash for xdg_desktop_portal
Fixes nix build
2026-09-28 14:36:27 -04:00
4bde7875fb
up-to-date xdg portals
Fixes #101
2026-09-28 14:32:18 -04:00
07f8e58a4c
named records, small fixes to last commit 2026-09-28 13:17:15 -04:00
a220e27877
Make RoomChatController only return IList of eventRowId
Fixes #102
2026-09-28 12:46:00 -04:00
5304f22456
workaround for reactions not updating on historical timeline 2026-09-28 11:03:55 -04:00
e5b39507c8
Jump to current timelime when scrolled to bottom of historical timeline
Fixes #100.
2026-09-28 09:55:08 -04:00
d37538b69f
gate pop behind canPop 2026-09-28 09:49:20 -04:00
e300f4efc9
small fixups 2026-09-26 21:11:11 -04:00
bab699658f
pop sidebar before pushing notif page 2026-09-26 20:32:44 -04:00
1c1f585362
add JumpToEventController to allow jumping to events from the NotificationsPage when it has been pushed from the NotificationController 2026-09-26 19:37:47 -04:00
c90a1c41b0
working jumps from notif page
Doesn't work from notif page pushed from notif controller
2026-09-26 19:13:30 -04:00
9f070fcdba
reorganize pages into being under widgets 2026-09-26 18:06:51 -04:00
d712bb3193
tweak scroll behavior 2026-09-26 14:52:24 -04:00
3217c17ea0
don't depend on SuperSliverList for scrolls 2026-09-26 13:58:30 -04:00
031fbe326c
change highlight timing 2026-09-26 13:42:31 -04:00
0149aa2f84
fixup jumping to pins 2026-09-26 13:30:39 -04:00
a1efdb0f46
check off jumping on readme 2026-09-26 13:15:48 -04:00
389b65ae61 add support for jumping to out-of-context events (#95)
Fixes #84.

Reviewed-on: Nexus/nexus#95
2026-09-26 13:03:16 -04:00
3429a3ed98
add twim image 2026-09-25 10:20:53 -04:00
7ab92f76bf
fix for janky scrolling when tapping reply with SuperSliverView
I don't like this but it does work.
2026-09-24 22:46:48 -04:00
aecfac9533
free base before buffer 2026-09-24 22:20:31 -04:00
c7b89b8e56
small cleanups 2026-09-24 20:59:18 -04:00
721727d48c
small cleanups 2026-09-24 20:50:28 -04:00
004c3bcd0d
remove outdated error ignores
I believe some of these are fixed, if not they should be noisy so I notice them and can re-add
2026-09-24 20:38:30 -04:00
943cd85e22
treewide: ref.watch(ClientController.provider.notifier) -> ref.read 2026-09-24 20:35:20 -04:00
09904536e7
small fixups 2026-09-24 20:31:22 -04:00
856072e0b0
fixup! refactor chatview to have two sliver lists 2026-09-24 20:22:30 -04:00
110caff170
refactor chatview to have two sliver lists
Co-authored-by: Erwan Leboucher <erwanleboucher@gmail.com>
2026-09-24 20:02:23 -04:00
eeb2e2f54b
extract scroll helpers into their own hook 2026-09-24 18:10:30 -04:00
529a2e35c4
make buildEventOptions an extension 2026-09-24 14:41:13 -04:00
6ad4657d76
refactor chatview 2026-09-24 14:24:41 -04:00
38fd19ad1a
move isInBackground in main 2026-09-24 14:08:54 -04:00
0636366359
fixup errors caused by bump 2026-09-24 11:56:46 -04:00
efe9262a97
tweak timeout 2026-09-24 11:49:19 -04:00
2763a0fdf5
bump deps 2026-09-24 11:47:20 -04:00
a5da44fdea
more restrictive timeout on notification init 2026-09-24 11:42:57 -04:00
ccca02a0bc
Separate out GomuksListenerController from ClientController
Fixes #89
2026-09-24 11:03:16 -04:00
721349ca21
explicit listener for ClientStateController in notifs 2026-09-23 11:22:47 -04:00
5d994bf2bb
wait for metadata to come in on notif's rooms before sending them 2026-09-23 11:17:02 -04:00
a90bf00f18
dispose notif handler 2026-09-23 10:30:12 -04:00
f2efbbeb9f
fixup select 2026-09-22 20:48:59 -04:00
ed761d6071
gate selectable text behind mouse connected 2026-09-22 20:40:55 -04:00
8f35ea2435
Add "Copy Text" context menu option 2026-09-22 20:18:16 -04:00
023e75e063
always make events selectable (on mobile the long press overrides it) 2026-09-22 20:11:18 -04:00
4d0a4605d6
type contextMenuCallback 2026-09-22 19:57:23 -04:00
faa7292001
add codeblock copy button 2026-09-22 19:19:21 -04:00
027033ad9e
allow copying messages when on desktop without mobile mode on 2026-09-22 19:13:32 -04:00
ece3d16318
use dot shorthands in about dialog 2026-09-22 18:48:47 -04:00
5738239da2
fix name of ShowAboutDialog extension 2026-09-22 18:31:38 -04:00
a2791a6717
various small fixups 2026-09-22 15:53:37 -04:00
f910d87a23
don't require login for UP controller to finish init 2026-09-22 15:33:55 -04:00
f4142859ba
fallback isFocused to true 2026-09-22 15:05:57 -04:00
775c77194b
run in background properly on android 2026-09-22 15:02:30 -04:00
41806be9bf
More platform-independent approach to managing registered state 2026-09-22 14:34:08 -04:00
6e47594a91
fix race condition on startup 2026-09-22 11:36:29 -04:00
386b3ad1cf
inline the mention fetch 2026-09-22 10:37:49 -04:00
61f2aa7f2a
update gomuks vendorHash 2026-09-22 10:02:56 -04:00
0c660a98a7
just grab events out of get_mentions for now 2026-09-22 09:57:10 -04:00
09534a6775
use GomuksConfig 2026-09-22 09:53:00 -04:00
0cd815dc79
init ClientController before other providers 2026-09-21 23:03:57 -04:00
800ed0636d
bump nix2flatpak 2026-09-21 22:18:24 -04:00
e4cf2a837c
fixup login 2026-09-21 21:41:18 -04:00
11dca9bf14
seperate URI handler desktop file 2026-09-21 21:20:16 -04:00
436b8642b3
read not watch MultiProviderController providers
Fixes random loading animations
2026-09-21 19:27:10 -04:00
269d97be8f
bump nix2flatpak 2026-09-21 18:53:49 -04:00
43f70f35a3
bump nix2flatpak 2026-09-21 18:00:42 -04:00
92b148f5b6
gate registration behind not flatpak 2026-09-21 17:54:03 -04:00
00a42b5f1a
fixup flatpak build 2026-09-21 17:20:43 -04:00
700b4e720f
open notifications page on notif tap
please please please let me never touch c++ again i hate it

also the c++ got reformatted here but i dont really care
2026-09-21 16:56:12 -04:00
2a11bcb7e8
update hashes 2026-09-21 15:44:10 -04:00
fea39e4191
use xdg_desktop_portal over flutter_local_notifications on linux 2026-09-21 15:43:01 -04:00
deaf564c36
use portals for linux notifs 2026-09-21 14:16:29 -04:00
c0d14ac30e
don't write service by default 2026-09-20 17:27:45 -04:00
1a6a5c36ea
link to module from readme 2026-09-20 17:21:26 -04:00
35ef82accd
fixup module 2026-09-20 17:18:39 -04:00
f40dd22277
use mkEnableOption for enableNotifications 2026-09-20 17:14:56 -04:00
1cfea5a819
add nix module 2026-09-20 17:13:22 -04:00
b3023d8147
don't auto create dbus service 2026-09-20 17:00:10 -04:00
e8fb3a8523
use SharedPreferencesAsync to avoid race condition in Sharedprefs
This was so painful to debug...
2026-09-20 15:45:32 -04:00
0d1ace3fd0
add more logs for notifs 2026-09-20 13:41:22 -04:00
3d690c26d8
hopefully fixup UP on linux when backgrounded 2026-09-20 13:27:47 -04:00
3fee789c90
fixup flatpak perms 2026-09-20 00:07:47 -04:00
d7c64fea80
potentially fix notifs on flatpak 2026-09-20 00:04:50 -04:00
87e7e093f7
Revert "don't invalidate UP controller"
This reverts commit 73c4a7d07f.
2026-09-20 00:02:49 -04:00
8b7e29ae33
Set device name to "Nexus on $platform"
Fixes #86.
2026-09-19 22:41:13 -04:00
673c5bd894
fix memory leaks
Oops! They were pretty slow, but still adds up. This might fix the bug that was stopping Nexus from working on GraphineOS.
2026-09-19 21:13:03 -04:00
650c62fb99
update gomuks hash 2026-09-19 18:45:05 -04:00
4f3b77f9f3
set env for gomuks
Fixes #62
2026-09-19 18:41:09 -04:00
85bb653980
fix gomuks init on android / ios 2026-09-18 15:39:45 -04:00
a9d2ccbcd1
bump gradle 2026-09-18 13:02:40 -04:00
73c4a7d07f
don't invalidate UP controller 2026-09-18 10:57:16 -04:00
57f9498aa3
better wrapping behavior for timestamps on state events 2026-09-18 09:58:49 -04:00
cfab803fa0
show timestamps for state events 2026-09-17 23:21:33 -04:00
f23a35384d
nicer / less intrusive mention style 2026-09-17 14:27:51 -04:00
7f7fb451e6
Bump emoji picker to 1.1.1
Better performance now.
2026-09-17 12:09:16 -04:00
5d62e66cd9
remove duplicate "Windows EXE" entry in "Try it out" section of README
Thanks to @julian45 for spotting this!
2026-09-16 22:11:43 -04:00
009ba504e3
fixup logic for not init-ing UP
An XOR was never the right choice for this
2026-09-16 22:07:00 -04:00
30b922d390
enlarge emojis on emoji overlay 2026-09-16 22:03:42 -04:00
63183e0824
don't init UP on platforms other than Linux or Android 2026-09-16 21:59:55 -04:00
163947fc90
add emoji overlay 2026-09-16 16:52:58 -04:00
11457470a0
move around args to overlays 2026-09-16 16:30:15 -04:00
dca0b7dd7d
refactor overlay system 2026-09-16 16:27:10 -04:00
e5dc270f83
bump emoji picker 2026-09-16 16:27:07 -04:00
810a0b102b
add support for setting recent emoji 2026-09-16 15:46:51 -04:00
ec1603b828
add support for showing recent emoji 2026-09-16 15:18:41 -04:00
5acdbb5be0 Better emoji picker (#73)
Adds a better, custom, emoji picker.

Reviewed-on: Nexus/nexus#73
2026-09-16 14:47:46 -04:00
e21c9e8c22
make highlights work better for Notifications page 2026-09-12 09:52:01 -04:00
ea687471bc
Make TODO on notifications page more useful 2026-09-11 22:42:43 -04:00
8c440447f2
check off Notifications page in readme 2026-09-11 22:42:17 -04:00
8ebec15324
hide label whe not selected for filter 2026-09-11 22:41:38 -04:00
cd8d551e26
take notif filter out of card 2026-09-11 22:39:18 -04:00
118e5a5826
update readme for platform support to be more clear 2026-09-11 22:35:13 -04:00
6ca9b2a948
add notification page filter 2026-09-11 22:34:43 -04:00
23e01c7a0d
Notifications page
Doesn't yet allow for jumping to events
2026-09-11 22:03:42 -04:00
9e7fcdbef7
sticky -> clientSticky to avoid conflicts with sticky events 2026-09-11 19:50:29 -04:00
9507d69458
move windows warning in readme 2026-09-11 19:29:47 -04:00
13d90df226
remove un-needed overrides 2026-09-11 19:28:10 -04:00
e925f9657c
add needed permissions for UnifiedPush to nexus flatpak 2026-09-10 17:14:41 -04:00
b8882d6e48
mark off notification support as added, add notifications page todo 2026-09-10 17:06:14 -04:00
ff9bf3bec4 Push Notifications (#66)
Fixes #35.

Things to do:
- [x] Register with local distributor
- [x] Register with homeserver
- [x] Recieve messages
- [x] Show notifications
- [ ] Notifications page (could come later)

Reviewed-on: Nexus/nexus#66
2026-09-10 15:37:05 -04:00
6e42902322
run GomuksInit on an Isolate
Fixes #70
2026-09-10 15:32:30 -04:00
c105a6035c
use dot shorthands where possible in generate script 2026-09-10 15:21:53 -04:00
3d7ef9b32f
actually generate bindings
Yes `ffigen`, I did actually want you to generate the bindings, not a file consisting only of comments. Crazy, right?
2026-09-10 15:20:56 -04:00
51ef66174a
fix generate script to work with new ffigen 2026-09-10 15:15:12 -04:00
1efb68ebe0
bump deps, remove unwanted and now un-needed analyzer rule
Fixes #69
2026-09-10 15:09:47 -04:00
326 changed files with 16113 additions and 4746 deletions

View file

@ -1,39 +1,39 @@
name: "Build APK"
on:
push:
branches: ["main"]
tags: ["*"]
workflow_dispatch:
push:
branches: ["main"]
tags: ["*"]
workflow_dispatch:
jobs:
build-apk:
runs-on: ubuntu-latest
build-apk:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
submodules: recursive
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: Decode keystore
run: echo "$KEYSTORE_CONTENT" | base64 --decode > keystore.jks
env:
KEYSTORE_CONTENT: ${{ secrets.KEYSTORE_CONTENT }}
- 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 apk --target-platform android-arm64"
env:
KEYSTORE_PATH: ../../keystore.jks
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
- name: Decode keystore
run: echo "$KEYSTORE_CONTENT" | base64 --decode > keystore.jks
env:
KEYSTORE_CONTENT: ${{ secrets.KEYSTORE_CONTENT }}
- name: Upload installer artifact
uses: actions/upload-artifact@v6
with:
name: APK
path: build/app/outputs/flutter-apk/app-release.apk
- name: Build app
run: nix develop --command bash -c "flutter pub get && dart scripts/generate.dart && flutter pub run build_runner build && flutter build apk --target-platform android-arm64 --split-per-abi --dart-define=GIT_COMMIT=${{ github.sha }}"
env:
KEYSTORE_PATH: ../../keystore.jks
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
- name: Upload installer artifact
uses: actions/upload-artifact@v6
with:
name: APK
path: build/app/outputs/flutter-apk/app-arm64-v8a-release.apk

View file

@ -1,37 +1,73 @@
name: "Build Flatpaks"
on:
push:
branches: ["main"]
tags: ["*"]
workflow_dispatch:
push:
branches: ["main"]
tags: ["*"]
workflow_dispatch:
jobs:
build-flatpak:
strategy:
fail-fast: false
matrix:
include:
- arch: x86_64
runner: ubuntu-latest
- arch: aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
build-flatpak:
strategy:
fail-fast: false
matrix:
include:
- arch: x86_64
runner: ubuntu-latest
- arch: aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
steps:
- name: Checkout repository
uses: actions/checkout@v6
- 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: 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 build .#flatpak
- name: Build app
run: nix build .#flatpak
- name: Upload installer artifact
uses: actions/upload-artifact@v6
with:
name: flatpak-${{ matrix.arch }}
path: result/nexus.federated.nexus.flatpak
- name: Upload installer artifact
uses: actions/upload-artifact@v6
with:
name: flatpak-${{ matrix.arch }}
path: result/nexus.federated.nexus.flatpak
publish:
needs: build-flatpak
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Install flatpak tooling
run: sudo apt-get update && sudo apt-get install -y flatpak ostree
- name: Download bundles
uses: actions/download-artifact@v7
with:
pattern: flatpak-*
path: bundles
- name: Build OSTree repo
run: |
ostree init --repo=repo --mode=archive-z2
for bundle in bundles/*/nexus.federated.nexus.flatpak; do
flatpak build-import-bundle repo "$bundle"
done
flatpak build-update-repo --prune repo
- uses: actions/upload-pages-artifact@v4
with:
path: repo
- name: Deploy to Pages
id: deployment
uses: actions/deploy-pages@v4

View file

@ -19,7 +19,7 @@ jobs:
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: 3.47.0
flutter-version: 3.47.4
- name: Set up Go
uses: actions/setup-go@v6
@ -31,7 +31,7 @@ jobs:
flutter pub get
dart scripts/generate.dart
flutter pub run build_runner build
flutter build ios --release --no-codesign
flutter build ios --no-codesign --dart-define=GIT_COMMIT=${{ github.sha }}
# Unsigned IPA: SideStore/AltStore re-sign this on-device with the
# user's own Apple ID at install time, so no certificate is needed here.

View file

@ -19,7 +19,7 @@ jobs:
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: 3.47.0
flutter-version: 3.47.4
- name: Set up Go
uses: actions/setup-go@v6
@ -31,7 +31,7 @@ jobs:
flutter pub get
dart scripts/generate.dart
flutter pub run build_runner build
flutter build macos --release
flutter build macos --dart-define=GIT_COMMIT=${{ github.sha }}
- name: Create DMG
id: create-dmg

View file

@ -19,7 +19,7 @@ jobs:
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: 3.47.0
flutter-version: 3.47.4
- name: Set up Go
uses: actions/setup-go@v6
@ -29,7 +29,7 @@ jobs:
- name: Setup MSYS2
uses: msys2/setup-msys2@v2
with:
msystem: MINGW64
msystem: CLANG64
install: >-
mingw-w64-x86_64-gcc
@ -43,7 +43,7 @@ jobs:
flutter pub get
dart scripts/generate.dart
flutter pub run build_runner build
flutter build windows --release
flutter build windows --dart-define=GIT_COMMIT=${{ github.sha }}
- name: Copy MinGW runtime DLLs
shell: msys2 {0}
@ -52,6 +52,15 @@ jobs:
cp /mingw64/bin/libwinpthread-1.dll build/windows/x64/runner/Release/
cp /mingw64/bin/libstdc++-6.dll build/windows/x64/runner/Release/
- name: Copy MSVC runtime DLLs
shell: powershell
run: |
$release = "build/windows/x64/runner/Release"
Copy-Item C:\Windows\System32\MSVCP140.dll $release -Force
Copy-Item C:\Windows\System32\VCRUNTIME140.dll $release -Force
Copy-Item C:\Windows\System32\VCRUNTIME140_1.dll $release -Force
- name: Install Inno Setup
run: choco install innosetup -y

4
.gitignore vendored
View file

@ -41,4 +41,6 @@ key.properties
password.txt
# Nix
/result
/result
# Widget Preview related
.widget_preview/

1
.gitmodules vendored
View file

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

17
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,17 @@
{
"configurations": [
{
"name": "Flutter",
"type": "dart",
"request": "launch",
"program": "lib/main.dart"
},
{
"name": "Flutter (Headless)",
"type": "dart",
"request": "launch",
"program": "lib/main.dart",
"env": { "FLUTTER_HEADLESS": "1" }
}
]
}

View file

@ -10,7 +10,10 @@
"msgtype",
"muks",
"prefs",
"recents",
"unban",
"unredact"
"unifiedpush",
"unredact",
"webpush"
]
}

View file

@ -21,28 +21,32 @@ See [Effective Dart: Style](https://dart.dev/effective-dart/style) for general r
Controllers live in `lib/controllers/` and provide a source that exposes data and logic via Riverpod providers, allowing other parts of the code to watch state changes with ref.watch (`ref.watch(MyController.provider)`), access the current value with ref.read (`ref.read(MyController.provider)`), and run helper methods on those classes using the notifier:
```dart
ref.watch(MyController.provider.notifier).helperMethod()
ref.read(MyController.provider.notifier).helperMethod()
```
We use an object oriented style for controllers, where `provider` is a static member on the controller class. E.g.
```dart
class MyController extends AsyncNotifier<DataThisControllerExposes> {
final SomeInputType input;
MyController(this.input);
class MyController(final SomeInputType input)
extends AsyncNotifier<DataThisControllerExposes> {
@override
Future<DataThisControllerExposes> build() async {
return input.foo;
}
static final provider =
AsyncNotifierProvider.family<MyController, DataThisControllerExposes, SomeInputType>(
AuthorController.new,
);
AsyncNotifierProvider.family<MyController, DataThisControllerExposes, SomeInputType>(
MyController.new,
);
}
```
#### `watch` vs `read`
Only use `ref.watch` in a `build` method, where you want to rebuild when the value changes. Everywhere else, like callbacks, `useEffect`, and methods on a controller, use `ref.read`.
Use `.select` to narrow down what you depend on, e.g. `ref.watch(RoomsController.provider.select((rooms) => rooms[roomId]))`.
Providers which are not controllers, e.g. they expose no data, only methods, should instead live in `lib/helpers/`. For an example, see `lib/helpers/launch_helper.dart`. Other, non-provider helpers, like extensions or helper methods can also go in `lib/helpers/`.
### Don't use StatefulWidgets ([Flutter Hooks](https://pub.dev/packages/flutter_hooks))
@ -61,6 +65,36 @@ When possible, use immutable collections instead of the mutable equivalent. For
When possible, we prefer not to create global variables or methods. You can usually replace a global variable with a Riverpod controller, and a global method with an extension method.
I'm also not a big fan of unneeded private methods or variables, but this is contextual.
### Modern Dart syntax
We try to use newer Dart language features where they make code shorter:
- Primary constructors for classes, including widgets, e.g. `class const MemberList(final String roomId, {super.key}) extends HookConsumerWidget`.
- Dot shorthands wherever the type can be inferred, e.g. `.new(...)`, `padding: .all(8)`, `relationType == .reply`.
- Patterns and switch expressions over null checks and casts, e.g. `if (event.content case MessageContent(:final body?))` rather than checking `is` then using `!`.
### Prefer `map` over inline `for` loops
When building a list (or set, or map) from another collection, use `.map`, `.expand` and friends rather than a `for` inside the collection literal:
```dart
// Prefer
children: rooms.map((room) => RoomTile(room)).toList(),
// Over
children: [for (final room in rooms) RoomTile(room)],
```
Regular `for` statements are fine for logic that doesn't just build a collection.
### Async values and errors
When rendering an `AsyncValue`, use the `betterWhen` extension, which shows our standard loading and error widgets, or a switch expression if you need more control (e.g. `AsyncData(:final value) || AsyncLoading(:final value?)` to keep showing old data while reloading).
For futures started from the UI that nothing awaits, like button presses, add `.onError(showError)` so failures are shown to the user instead of being lost.
## LLM/AI Assisted Contributions
LLM generated code is NOT allowed. All contributions should be written by humans. Using LLMs for interacting with others, e.g. for Comments, PRs, etc, is also not allowed.

159
README.md
View file

@ -1,8 +1,5 @@
# Nexus Client
> [!WARNING]
> Nexus Client is still in development, and doesn't support everything needed for daily use.
## Description
A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
@ -13,115 +10,63 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
| :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------: |
| ![Screenshot of Nexus Client in dark mode, showing users talking, with a sidebar showing rooms and spaces, and another sidebar showing members](./assets/screenshotDark.png) | ![The same screenshot as above, but in light mode](./assets/screenshotLight.png) |
## Progress
- [x] Platform Support
- [x] Linux
- [x] Windows - Currently has some bugs, due to us not having any developers on Windows. If you want to fix these, get in contact with us though the [Nexus Client Matrix room](https://matrix.to/#/#nexus:federated.nexus).
- [x] Android
- [x] MacOS - Unsigned .dmg only
- [x] iOS - Unsigned .ipa only
- [ ] Web (may not be possible)
- [x] Login (via OAuth)
- [x] Rooms / Spaces
- [x] Displaying and choosing
- [x] Reading, showing unread
- [x] Mark as read button on rooms and spaces
- [ ] Searching
- [ ] Creating (Rooms, Spaces, and DMs)
- [x] Joining
- [x] Parse vias
- [x] Using a text/uri/link
- [x] Plain text
- [x] `matrix:` Uri
- [x] Matrix.to link
- [ ] From space
- [ ] From directory
- [x] Leaving
- [x] Subspaces
- [x] Messages
- [x] Encryption
- [x] Restoring crypto identity from a recovery passphrase/key
- [x] Sending
- [x] Plain text
- [x] HTML/Markdown
- [x] Replies
- [x] Choose ping on/off
- [x] Per message profiles
- [x] Attachments
- [ ] Commands with [MSC4391](https://github.com/matrix-org/matrix-spec-proposals/pull/4391)
- [x] Mentions
- [x] Users
- [x] Rooms
- [ ] Inline emoji picker (Putting this here since it'll be implemented the same way as mentions)
- [ ] Custom emojis/stickers
- [ ] GIFs using Gomuks' GIF proxies
- [x] Receiving
- [x] Plain text
- [x] Per message profiles
- [x] HTML
- [x] URL Previews
- [x] Replies
- [x] Viewing
- [ ] Jump to original message
- [x] In loaded timeline
- [ ] Out of loaded timeline
- [x] Edits
- [x] Attachments
- [x] Unencrypted
- [x] Encrypted
- [x] Blurhashing
- [ ] Downloading attachments
- [x] Opening attachments in their own view
- [ ] Polls
- [x] Mentions
- [x] Users
- [x] Clickable
- [x] Rooms
- [ ] Clickable
- [x] Matrix URIs
- [x] Matrix.to links
- [x] Events
- [ ] Render more nicely
- [ ] Clickable
- [x] Custom emojis/stickers
- [x] History loading
- [x] Editing
- [x] Deleting
- [x] Reactions
- [x] Pins
- [x] Displaying
- [x] Pinning/Unpinning
- [ ] Threads
- [x] Profile popouts
- [x] Working actions
- [x] Copy link to:
- [x] Room
- [x] Space
- [x] Message
- [ ] Reporting
- [x] Events
- [ ] Rooms
- [x] Member list
- [x] Sort by power level
- [ ] Colors based off of power level
- [ ] Notifications using UnifiedPush ([#35](https://git.federated.nexus/Nexus/nexus/issues/35))
- [ ] Group calls using [MSC4195](https://github.com/matrix-org/matrix-spec-proposals/pull/4195)
- [ ] Invites
- [x] Settings
## Try it out
If you want to try out Nexus, grab one of the following artifacts from CI:
If you want to try out Nexus, download the [latest release](https://git.federated.nexus/Nexus/nexus/releases/latest).
### Nightly Builds
For the newest changes, grab one of the following nightly artifacts, built by CI from the latest commit on `main`.
- [Android APK](https://nightly.link/Henry-Hiles/nexus/workflows/android/main/APK.zip)
- [Windows EXE](https://nightly.link/Henry-Hiles/nexus/workflows/windows/main/windows-installer.zip)
- [Unsigned iOS IPA](https://nightly.link/Henry-Hiles/nexus/workflows/ios/main/Nexus.ipa.zip)
- [Unsigned MacOS DMG](https://nightly.link/Henry-Hiles/nexus/workflows/macos/main/nexus.dmg.zip)
- [Windows EXE](https://nightly.link/Henry-Hiles/nexus/workflows/windows/main/windows-installer.zip)
- Flatpak
- [AArch64/Arm64](https://nightly.link/Henry-Hiles/nexus/workflows/flatpak/main/flatpak-aarch64.zip)
- [x86_64/AMD64](https://nightly.link/Henry-Hiles/nexus/workflows/flatpak/main/flatpak-x86_64.zip)
Or, use one of the below methods:
### Nightly Flatpak Repo
Make sure Flatpak is set up on your system, then run:
```bash
flatpak install --user https://nexus.federated.nexus/flatpak/nexus.flatpakref
```
This adds the Nexus remote and installs the app for your user only, so no root access is needed (x86_64 and aarch64 are supported). To install system-wide instead, use `--system` in place of `--user`. Updates arrive through `flatpak update` or your software center.
### NixOS Module
Add Nexus as a flake input, following `nixpkgs` for less fetches:
```nix
nexus = {
url = "git+https://git.federated.nexus/Nexus/nexus";
inputs.nixpkgs.follows = "nixpkgs";
};
```
This tracks the latest commit on `main`. To stay on a release instead, pin the input to its tag:
```nix
url = "git+https://git.federated.nexus/Nexus/nexus?ref=refs/tags/v1.0.0";
```
Then import the module and enable it in your NixOS configuration:
```nix
{ inputs, ... }:
{
imports = [ inputs.nexus.nixosModules.default ];
programs.nexus = {
enable = true;
enableNotifications = true; # optional: push notifications via UnifiedPush
};
}
```
This installs Nexus system-wide. With `enableNotifications`, it also installs KUnifiedPush and registers its systemd and D-Bus services so notifications work. The `package` option lets you override the package if you need to.
## Build it yourself
@ -129,7 +74,7 @@ If you want to try out Nexus, grab one of the following artifacts from CI:
#### Linux
- On NixOS: Either use direnv and `direnv allow`, or `nix flake develop`
- On NixOS: Either use direnv and `direnv allow`, or `nix develop`
- On other distros: Install Flutter, Go, Git, Libclang, Libass, MPV, and Glibc. Do not use any Snap packages, they cause various compilation issues.
#### Windows
@ -208,7 +153,7 @@ Development instructions can be found in [DEVELOPMENT.md](./DEVELOPMENT.md).
Join the [Nexus Client Matrix room](https://matrix.to/#/#nexus:federated.nexus) for questions or help with developing or using Nexus Client.
# Credits
## Credits
Thank you Hylke Bons (https://planetpeanut.studio) for making the amazing icon for Nexus!
Thank you Tulir Asokan for making [Gomuks](https://github.com/gomuks/gomuks), and helping us integrate it into Nexus!

View file

@ -1,22 +0,0 @@
{
"name": "Nexus",
"identifier": "nexus.federated.nexus.source",
"subtitle": "Nexus for iOS",
"description": "Nexus iOS builds for AltStore and SideStore.",
"iconURL": "https://git.federated.nexus/Nexus/nexus/raw/branch/main/assets/icon.png",
"website": "https://git.federated.nexus/Nexus/nexus",
"apps": [
{
"name": "Nexus",
"bundleIdentifier": "nexus.federated.nexus",
"developerName": "Nexus",
"subtitle": "A Matrix client",
"localizedDescription": "Nexus for iOS. Unsigned builds, resigned at install by AltStore/SideStore.",
"iconURL": "https://git.federated.nexus/Nexus/nexus/raw/branch/main/assets/icon.png",
"category": "social",
"screenshots": [],
"versions": []
}
],
"news": []
}

View file

@ -2,7 +2,6 @@ analyzer:
errors:
invalid_annotation_target: ignore
avoid_print: ignore
annotate_overrides: ignore
exclude:
- "build/**"
- "**/*.g.dart"

View file

@ -20,16 +20,21 @@ android {
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
defaultConfig {
applicationId = "nexus.federated.nexus"
minSdk = 29
minSdk = 29
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
multiDexEnabled = true
}
signingConfigs {
@ -63,6 +68,11 @@ android {
}
}
dependencies {
implementation("org.unifiedpush.android:embedded-fcm-distributor:3.1.0")
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21

View file

@ -6,7 +6,9 @@
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" />
<!-- Until https://github.com/flutter/flutter/issues/145290 is fixed, we need enableOnBackInvokedCallback as false -->
<application
android:enableOnBackInvokedCallback="false"
android:label="Nexus"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
@ -49,6 +51,16 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<!-- Needed by pasteboard, for pasting files from the clipboard -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and

View file

@ -1,5 +0,0 @@
package nexus.federated.nexus
import io.flutter.embedding.android.FlutterActivity
class MainActivity: FlutterActivity()

View file

@ -0,0 +1,40 @@
package nexus.federated.nexus
import android.os.Build
import android.os.Bundle
import android.view.View
import android.view.WindowInsets
import android.view.WindowInsetsAnimation
import io.flutter.embedding.android.FlutterActivity
// TODO: Revert this commit once https://github.com/flutter/flutter/issues/191156 is fixed
class MainActivity: FlutterActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return
window.decorView.setWindowInsetsAnimationCallback(
object : WindowInsetsAnimation.Callback(DISPATCH_MODE_CONTINUE_ON_SUBTREE) {
override fun onProgress(
insets: WindowInsets,
runningAnimations: List<WindowInsetsAnimation>,
) = insets
override fun onEnd(animation: WindowInsetsAnimation) {
if ((animation.typeMask and WindowInsets.Type.ime()) != 0) reapplyInsets()
}
},
)
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) reapplyInsets()
}
// Posted so it runs after Flutter has applied any insets it deferred.
private fun reapplyInsets() {
val view = findViewById<View>(FLUTTER_VIEW_ID) ?: return
view.post { view.rootWindowInsets?.let(view::onApplyWindowInsets) }
}
}

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path
name="external_files"
path="." />
</paths>

View file

@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-all.zip

Binary file not shown.

BIN
assets/fonts/Inter.ttf Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 616 KiB

After

Width:  |  Height:  |  Size: 246 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 616 KiB

After

Width:  |  Height:  |  Size: 251 KiB

Before After
Before After

0
assets/twim/.gitkeep Normal file
View file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

24
flake.lock generated
View file

@ -5,11 +5,11 @@
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1785627969,
"narHash": "sha256-4dtXQk/NMePegK/nWp5NSeuZKLATItOq61lpEvmXqGw=",
"lastModified": 1788450739,
"narHash": "sha256-glZLQlzIn1fXH6PazR2iUmTo7kzzyYSshrWhLS9TqCU=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "427bf4bd9435fdf21321c8cc628c24efc14c0f7a",
"rev": "31729ca8cbdb4fa927b34e5f4353e6a83f39e993",
"type": "github"
},
"original": {
@ -42,11 +42,11 @@
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1774860670,
"narHash": "sha256-YjJkQrvxrErXtfDi3obUn6rNmkA+CIAZ3f5NgL5xuYE=",
"lastModified": 1791751882,
"narHash": "sha256-DWcMlQFQMEqQGAHU6rAbl4uKWmlJMR1HZG0BvH0H2fk=",
"owner": "neobrain",
"repo": "nix2flatpak",
"rev": "61d68e21e3fbc2d57590051f48736bea271f4aba",
"rev": "7731e8b3fca73879f8217de5a919c0a5d17303de",
"type": "github"
},
"original": {
@ -73,11 +73,11 @@
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1785031560,
"narHash": "sha256-OmshNvn2vupOFpYinLUu+1Dnpu4n7Q5N3ggGVNHpkUI=",
"lastModified": 1788057806,
"narHash": "sha256-DTQSMxzDWmT0zhguthvegnVkn7CFqGCv4IHCzk5ZUpM=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "0e79af5e3d4dcfcd676ab5ba3f95d2e3352e078c",
"rev": "596e2e3940e09b2abbeb03f75fa1828c57fcd72c",
"type": "github"
},
"original": {
@ -88,11 +88,11 @@
},
"nixpkgs_2": {
"locked": {
"lastModified": 1786862985,
"narHash": "sha256-FBJRXmbGXiSUDvYEbfLYRkckayyZ6SK1UEqhCrIZ2Cs=",
"lastModified": 1790689126,
"narHash": "sha256-ilerN1WLSvF+HMjziC/Wv99J5y02maDH+6hZPwsORKg=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "e5bdc4a41d4c072fe1e3787eaa0320a384741d44",
"rev": "b4fd65b198c599cbe814fcb9f42d25d021595ec9",
"type": "github"
},
"original": {

View file

@ -16,62 +16,12 @@
...
}@inputs:
flake-parts.lib.mkFlake { inherit inputs; } {
imports = [ ./linux/nix ];
systems = [
"x86_64-linux"
"aarch64-linux"
"aarch64-darwin"
"x86_64-darwin"
];
perSystem =
{
lib,
pkgs,
system,
...
}:
{
_module.args.pkgs = import nixpkgs {
inherit system;
config = {
android_sdk.accept_license = true;
allowUnfree = true;
};
};
packages =
let
default = pkgs.callPackage ./linux/nix/pkg {
src = self;
};
in
{
inherit default;
flatpak = inputs.nix2flatpak.lib.${system}.mkFlatpak {
appName = "Nexus";
developer = "QuadRadical";
appId = "nexus.federated.nexus";
package = default;
runtime = "org.gnome.Platform/49";
permissions = {
share = [ "network" ];
sockets = [
"pulseaudio"
"fallback-x11"
"wayland"
];
devices = [ "dri" ];
};
};
gomuks = pkgs.callPackage ./linux/nix/pkg/gomuks.nix {
src = self;
};
};
devShells.default = pkgs.callPackage ./linux/nix/devshell.nix { };
};
};
}

2
gomuks

@ -1 +1 @@
Subproject commit 0e06c7b74f96d3e29a8101893ca1921d13a90573
Subproject commit 53cae0e0334aef5ac376ea84f19ba1cff8a92310

View file

@ -122,7 +122,7 @@ Future<void> main(List<String> args) => build(args, (input, output) async {
"sqlite_fts5",
"goolm",
// goheif/dav1d is not supported on Android or iOS, would need to be fixed upstream
if (targetOS == OS.android || targetOS == OS.iOS) "noheic",
"noheic",
].join(",");
final archiveFile = targetOS == OS.iOS

View file

@ -7,7 +7,8 @@ import UIKit
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {

View file

@ -1,6 +1,7 @@
import "dart:convert";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/foundation.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/models/account_data.dart";
@ -9,11 +10,16 @@ class AccountDataController extends Notifier<AccountData> {
AccountData build() => .new();
void update(IMap<String, IMap<String, dynamic>> newAccountData) =>
state = .fromJson({
...json.decode(json.encode(state.toJson())),
...newAccountData
.map((key, value) => MapEntry(key, value["content"]))
.unlock,
state = newAccountData.entries.fold(state, (data, entry) {
try {
return .fromJson({
...json.decode(json.encode(data.toJson())),
entry.key: entry.value["content"],
});
} catch (error) {
debugPrint("Ignoring invalid ${entry.key} account data: $error");
return data;
}
});
static final provider = NotifierProvider<AccountDataController, AccountData>(

View file

@ -0,0 +1,25 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:material_emoji_picker/material_emoji_picker.dart";
import "package:nexus/controllers/image_packs.dart";
import "package:nexus/helpers/extensions/image_pack_emoji.dart";
class AllEmojiController(final String roomId)
extends AsyncNotifier<IList<EmojiCategory>> {
@override
Future<IList<EmojiCategory>> build() async {
final packs = await ref.watch(ImagePacksController.provider(roomId).future);
final unicode = await ref.watch(EmojiController.provider.future);
return packs
.map((pack) => pack.emojiCategory)
.nonNulls
.toIList()
.addAll(unicode);
}
static final provider = AsyncNotifierProvider.autoDispose
.family<AllEmojiController, IList<EmojiCategory>, String>(
AllEmojiController.new,
);
}

View file

@ -0,0 +1,18 @@
import "package:flutter/widgets.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
class AppPausedController extends Notifier<bool> {
@override
bool build() {
final listener = AppLifecycleListener(
onPause: () => state = true,
onResume: () => state = false,
);
ref.onDispose(listener.dispose);
return WidgetsBinding.instance.lifecycleState == .paused;
}
static final provider = NotifierProvider<AppPausedController, bool>(
AppPausedController.new,
);
}

View file

@ -23,7 +23,7 @@ class AttachmentController(final String roomId)
);
final content = await ref
.watch(ClientController.provider.notifier)
.read(ClientController.provider.notifier)
.uploadMedia(.new(path: file.path, encrypt: isEncrypted));
state = (filename, content);

View file

@ -8,7 +8,7 @@ class AuthUrlController(final Uri homeserver)
extends AsyncNotifier<OAuthAuthCodeResponse> {
@override
Future<OAuthAuthCodeResponse> build() async => ref
.watch(ClientController.provider.notifier)
.read(ClientController.provider.notifier)
.getAuthUrl(
.new(
homeserverUrl: homeserver,

View file

@ -1,3 +1,4 @@
import "dart:async";
import "dart:ffi";
import "dart:io";
import "dart:isolate";
@ -5,33 +6,44 @@ import "dart:math";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:ffi/ffi.dart";
import "package:flutter/foundation.dart";
import "package:nexus/controllers/account_data.dart";
import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/init_complete.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/space_edges.dart";
import "package:nexus/controllers/sync_status.dart";
import "package:nexus/controllers/top_level_spaces.dart";
import "package:flutter/cupertino.dart";
import "package:intl/intl.dart";
import "package:nexus/controllers/settings.dart";
import "package:nexus/helpers/extensions/gomuks_buffer.dart";
import "package:nexus/main.dart";
import "package:nexus/models/capabilities.dart";
import "package:nexus/models/content/message.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/event_context.dart";
import "package:nexus/models/mentions_response.dart";
import "package:nexus/models/gomuks_config.dart";
import "package:nexus/models/gomuks_exception.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/paginate_manual.dart";
import "package:nexus/models/requests/create_room.dart";
import "package:nexus/models/requests/deregister_pusher.dart";
import "package:nexus/models/requests/download_media.dart";
import "package:nexus/models/requests/get_event.dart";
import "package:nexus/models/requests/get_event_context.dart";
import "package:nexus/models/requests/get_mentions.dart";
import "package:nexus/models/requests/get_related_events.dart";
import "package:nexus/models/requests/get_room_state.dart";
import "package:nexus/models/requests/get_specific_room_state.dart";
import "package:nexus/models/requests/get_space_hierarchy.dart";
import "package:nexus/models/requests/join_room.dart";
import "package:nexus/models/profile_response.dart";
import "package:nexus/models/push_notification.dart";
import "package:nexus/models/recovery_key.dart";
import "package:nexus/models/requests/oauth/exchange_token.dart";
import "package:nexus/models/requests/oauth/get_auth_url.dart";
import "package:nexus/models/requests/oauth/register_client.dart";
import "package:nexus/models/requests/paginate.dart";
import "package:nexus/models/requests/paginate_manual.dart";
import "package:nexus/models/requests/redact_event.dart";
import "package:nexus/models/requests/register_pusher.dart";
import "package:nexus/models/requests/report.dart";
import "package:nexus/models/requests/search.dart";
import "package:nexus/models/requests/send_event.dart";
import "package:nexus/models/requests/send_message.dart";
import "package:nexus/models/requests/set_account_data.dart";
@ -40,8 +52,8 @@ import "package:nexus/models/requests/set_state.dart";
import "package:nexus/models/requests/upload_media.dart";
import "package:nexus/models/room.dart";
import "package:nexus/models/room_summary.dart";
import "package:nexus/models/space_hierarchy.dart";
import "package:nexus/models/spec_versions_response.dart";
import "package:nexus/models/sync_data.dart";
import "package:nexus/src/third_party/gomuks.g.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:path_provider/path_provider.dart";
@ -49,134 +61,115 @@ import "package:path_provider/path_provider.dart";
class ClientController extends AsyncNotifier<int> {
@override
Future<int> build() async {
final Pointer<Char> root;
debugPrint("Setting Gomuks env...");
if (Platform.isAndroid || Platform.isIOS) {
final dir = await getApplicationSupportDirectory();
root = "${dir.path}/gomuks".toNativeUtf8().cast();
} else {
root = nullptr.cast();
final env = {
"GOMUKS_ROOT": (await getApplicationSupportDirectory()).path,
"GOMUKS_CACHE_HOME": (await getApplicationCacheDirectory()).path,
};
for (final MapEntry(:key, :value) in env.entries) {
final keyPtr = key.toNativeUtf8().cast<Char>();
final valuePtr = value.toNativeUtf8().cast<Char>();
try {
GomuksSetEnv(keyPtr, valuePtr);
} finally {
calloc
..free(keyPtr)
..free(valuePtr);
}
}
}
final handle = GomuksInit(root);
debugPrint("Initializing Gomuks...");
final handle = await Isolate.run(() {
final bufferPointer = GomuksConfig(
matrix: .new(
initialDeviceDisplayName:
"Nexus on ${toBeginningOfSentenceCase(Platform.operatingSystem)}",
),
).toJson().toGomuksBufferPtr();
final callable =
NativeCallable<
Void Function(Pointer<Char>, Int64, GomuksOwnedBuffer)
>.listener((
Pointer<Char> command,
int requestId,
GomuksOwnedBuffer data,
) {
try {
final muksEventType = command.cast<Utf8>().toDartString();
debugPrint("Handling $muksEventType...");
final decodedMuksEvent = data.toJson();
try {
return GomuksInit(bufferPointer.ref);
} finally {
calloc
..free(bufferPointer.ref.base)
..free(bufferPointer);
}
});
switch (muksEventType) {
case "client_state":
ref
.watch(ClientStateController.provider.notifier)
.set(.fromJson(decodedMuksEvent));
break;
case "sync_status":
ref
.watch(SyncStatusController.provider.notifier)
.set(.fromJson(decodedMuksEvent));
break;
case "init_complete":
ref.watch(InitCompleteController.provider.notifier).complete();
break;
case "send_complete":
final event = Event.fromJson(decodedMuksEvent["event"]);
ref
.watch(RoomsController.provider.notifier)
.update(
.new({
event.roomId: .new(events: .new({event.rowId: event})),
}),
.new(),
);
break;
case "sync_complete":
final syncData = SyncData.fromJson(decodedMuksEvent);
final roomProvider = RoomsController.provider;
final accountDataProvider = AccountDataController.provider;
if (syncData.clearState) {
ref.invalidate(roomProvider);
ref.invalidate(accountDataProvider);
}
ref
.watch(roomProvider.notifier)
.update(syncData.rooms, syncData.leftRooms);
ref
.watch(accountDataProvider.notifier)
.update(syncData.accountData);
if (syncData.topLevelSpaces != null) {
ref
.watch(TopLevelSpacesController.provider.notifier)
.set(syncData.topLevelSpaces!);
}
if (syncData.spaceEdges != null) {
ref
.watch(SpaceEdgesController.provider.notifier)
.set(syncData.spaceEdges!);
}
// ref
// .watch(SyncStatusController.provider.notifier)
// .set(SyncStatus.fromJson(decodedMuksEvent));
break;
default:
debugPrint("Unhandled event: $muksEventType");
}
debugPrint("Finished handling $muksEventType...");
} catch (error, stackTrace) {
if (kDebugMode) {
debugPrintStack(stackTrace: stackTrace, label: error.toString());
rethrow;
} else {
showError(error, stackTrace);
}
}
});
if (!ref.mounted) {
GomuksDestroy(handle);
return Completer<int>().future;
}
ref.onDispose(() => GomuksDestroy(handle));
ref.onDispose(callable.close);
final errorCode = GomuksStart(handle, callable.nativeFunction);
return handle;
}
if (errorCode == 0) return handle;
throw Exception("GomuksStart returned error code $errorCode");
/// Calls gomuks with [data] as JSON, returning its decoded response.
Future<dynamic> callGomuksMethod(
Map<String, dynamic> data,
void Function(
int handle,
GomuksOwnedBuffer data,
Pointer<NativeFunction<Void Function(GomuksResponse)>> callback,
)
call,
) async {
final handle = await future;
final buffer = data.toGomuksOwnedBuffer();
final completer = Completer<dynamic>();
late final NativeCallable<Void Function(GomuksResponse)> callable;
callable = .listener((GomuksResponse response) {
callable.close();
try {
final json = response.buf.toJsonAndFree();
if (response.command.cast<Utf8>().toDartString() == "error") {
throw GomuksException(json.toString());
}
completer.complete(json);
} catch (error, stackTrace) {
completer.completeError(error, stackTrace);
}
});
try {
call(handle, buffer, callable.nativeFunction);
} catch (_) {
// gomuks never got the call, so it won't respond or free the buffer
callable.close();
GomuksFreeBuffer(buffer);
rethrow;
}
return completer.future;
}
Future<PushNotification?> handlePush(Map<String, dynamic> data) async {
final response = await callGomuksMethod(data, GomuksHandlePushAsync);
return response?["notification"] == null
? null
: .fromJson(response["notification"]);
}
Future<dynamic> _sendCommand(
String command, [
Map<String, dynamic> data = const {},
]) async {
final bufferPointer = data.toGomuksBufferPtr();
final handle = await future;
final response = await Isolate.run(
() => GomuksSubmitCommand(
handle,
command.toNativeUtf8().cast<Char>(),
bufferPointer.ref,
),
);
calloc.free(bufferPointer);
final json = response.buf.toJson();
if (response.command.cast<Utf8>().toDartString() == "error") {
throw json;
]) => callGomuksMethod(data, (handle, data, callback) {
final commandPointer = command.toNativeUtf8().cast<Char>();
try {
GomuksSubmitCommandAsync(handle, commandPointer, data, callback);
} finally {
// gomuks copies the command name before returning
calloc.free(commandPointer);
}
return json;
}
});
Future<void> redactEvent(RedactEventRequest report) =>
_sendCommand("redact_event", report.toJson());
@ -188,16 +181,17 @@ class ClientController extends AsyncNotifier<int> {
final json = request.toJson();
final content = request.content.toJson();
return Event.fromJson(
return .fromJson(
await _sendCommand("send_event", {
...json,
"content": {
...content,
"m.relates_to": {
...((content["m.relates_to"] as Map<String, dynamic>?) ?? {}),
"event_id": request.relatesTo,
"rel_type": request.relationType,
},
if (request.relatesTo != null)
"m.relates_to": {
...((content["m.relates_to"] as Map<String, dynamic>?) ?? {}),
"event_id": request.relatesTo,
"rel_type": request.relationType,
},
},
}),
);
@ -206,30 +200,54 @@ class ClientController extends AsyncNotifier<int> {
Future<String?> setState(SetStateRequest request) async =>
await _sendCommand("set_state", request.toJson());
Future<String?> verify(String recoveryKey) async {
try {
await _sendCommand("verify", {"recovery_key": recoveryKey});
return null;
} catch (error) {
return error.toString();
Future<void> verify(String recoveryKey) =>
_sendCommand("verify", {"recovery_key": recoveryKey});
Future<RecoveryKey> generateRecoveryKey(String passphrase) async => .fromJson(
await _sendCommand("generate_recovery_key", {"passphrase": passphrase}),
);
Future<void> resetEncryption(RecoveryKey key) =>
_sendCommand("reset_encryption", key.toJson());
Future<Uri?> getCrossSigningResetUrl(Uri homeserver) async {
const action = "org.matrix.cross_signing_reset";
final oauth = (await _sendCommand("get_login_flows", {
"homeserver_url": homeserver.toString(),
}))["oauth"];
if (oauth?["account_management_uri"] case final String uri
when (oauth["account_management_actions_supported"] as List?)?.contains(
action,
) ??
false) {
final parsed = Uri.parse(uri);
return parsed.replace(
queryParameters: {...parsed.queryParameters, "action": action},
);
}
return null;
}
Future<String> joinRoom(JoinRoomRequest request) async =>
(await _sendCommand("join_room", request.toJson()))["room_id"];
Future<String> knockRoom(JoinRoomRequest request) async =>
(await _sendCommand("knock_room", request.toJson()))["room_id"];
Future<String> createRoom(CreateRoomRequest request) async =>
(await _sendCommand("create_room", request.toJson()))["room_id"];
Future<SpaceHierarchy> getSpaceHierarchy(
GetSpaceHierarchyRequest request,
) async =>
.fromJson(await _sendCommand("get_space_hierarchy", request.toJson()));
Future<RoomSummary> getRoomSummary(JoinRoomRequest request) async =>
.fromJson(await _sendCommand("get_room_summary", request.toJson()));
Future<void> leaveRoom(Room room) async {
if (room.metadata == null) return;
await _sendCommand("leave_room", {"room_id": room.metadata!.id});
}
// (await _sendCommand("get_event_context", {
// "room_id": request.roomId,
// "event_id": r"$OqZT4NuTj0J1-771IOEEWRI4XdumRNu6ighlvO3K3gc",
// }));
Future<void> leaveRoom(String roomId) =>
_sendCommand("leave_room", {"room_id": roomId});
Future<IList<Event>> getRoomState(GetRoomStateRequest request) async {
Future<List?> getState(GetRoomStateRequest request) async =>
@ -243,6 +261,15 @@ class ClientController extends AsyncNotifier<int> {
);
}
Future<IList<Event>> getSpecificRoomState(
GetSpecificRoomStateRequest request,
) async => .new(
((await _sendCommand("get_specific_room_state", request.toJson()))
as List? ??
[])
.map((event) => .fromJson(event)),
);
Future<IList<Event>?> getRelatedEvents(
GetRelatedEventsRequest request,
) async {
@ -251,6 +278,9 @@ class ClientController extends AsyncNotifier<int> {
return .new(response?.map((event) => .fromJson(event)));
}
Future<MentionsResponse> getMentions(GetMentionsRequest request) async =>
.fromJson(await _sendCommand("get_mentions", request.toJson()));
Future<Event?> getEvent(GetEventRequest request) async {
final json = await _sendCommand("get_event", request.toJson());
return json == null ? null : .fromJson(json);
@ -262,14 +292,35 @@ class ClientController extends AsyncNotifier<int> {
Future<Paginate> paginate(PaginateRequest request) async =>
.fromJson(await _sendCommand("paginate", request.toJson()));
Future<PaginateManual> paginateManual(PaginateManualRequest request) async =>
.fromJson(await _sendCommand("paginate_manual", request.toJson()));
Future<PaginateManual> searchLocal(SearchRequest request) async =>
.fromJson(await _sendCommand("search_local", request.toJson()));
Future<PaginateManual> searchServer(SearchRequest request) async =>
.fromJson(await _sendCommand("search_server", request.toJson()));
Future<EventContext> getEventContext(GetEventContextRequest request) async =>
.fromJson(await _sendCommand("get_event_context", request.toJson()));
Future<ProfileResponse> getProfile(String userId) async {
try {
return .fromJson(await _sendCommand("get_profile", {"user_id": userId}));
} catch (_) {
return ProfileResponse(profile: .new(id: userId));
return .new(profile: .new(id: userId));
}
}
Future<void> setProfileField(String field, Object? value) => _sendCommand(
"set_profile_field",
{
"field": field,
// gomuks deletes the field when the value is omitted, not when it's null
"value": ?value,
},
);
Future<void> reportEvent(ReportRequest request) =>
_sendCommand("report_event", request.toJson());
@ -279,6 +330,15 @@ class ClientController extends AsyncNotifier<int> {
Future<void> setAccountData(SetAccountDataRequest request) =>
_sendCommand("set_account_data", request.toJson());
Future<void> registerPusher(RegisterPusherRequest request) =>
_sendCommand("register_homeserver_push", request.toJson());
Future<void> deregisterPusher(DeregisterPusherRequest request) =>
_sendCommand("register_homeserver_push", {
...request.toJson(),
"kind": null,
});
Future<MessageContent> uploadMedia(UploadMediaRequest request) async =>
.fromJson(await _sendCommand("upload_media", request.toJson()));
@ -287,14 +347,26 @@ class ClientController extends AsyncNotifier<int> {
Future<void> logout() => _sendCommand("logout");
Future<void> setTyping(String roomId, Duration timeout) => _sendCommand(
"set_typing",
{"room_id": roomId, "timeout": timeout.inMilliseconds},
);
Future<void> markRead(Room room) async {
if (room.timeline.isEmpty || room.metadata == null) return;
final eventRowId = room.timeline[room.timeline.keys.reduce(max)];
final event = eventRowId == null ? null : room.events[eventRowId];
if (event == null || room.metadata == null) return;
if (event == null) return;
// Private until settings have loaded, in case the user turned sending off
final sendPublic =
ref.read(SettingsController.provider).value?.readReceipts ==
.showAndSend;
await _sendCommand("mark_read", {
"room_id": room.metadata!.id,
"receipt_type": "m.read",
"receipt_type": sendPublic ? "m.read" : "m.read.private",
"event_id": event.eventId,
});
}
@ -313,9 +385,40 @@ class ClientController extends AsyncNotifier<int> {
Future<void> exchangeToken(OAuthExchangeTokenRequest request) async =>
await _sendCommand("oauth_exchange_token", request.toJson());
Future<IList<String>> getMutualRooms(String userId) async => IList(
((await _sendCommand("get_mutual_rooms", {"user_id": userId}))["joined"]
as List?)
?.cast<String>(),
);
Future<IList<String>> getLocalAliases(String roomId) async => IList(
((await _sendCommand("get_local_aliases", {"room_id": roomId}))["aliases"]
as List?)
?.cast<String>(),
);
Future<void> createAlias(String roomId, String alias) =>
_sendCommand("create_alias", {"room_id": roomId, "alias": alias});
Future<void> deleteAlias(String alias) =>
_sendCommand("delete_alias", {"alias": alias});
/// The room [alias] points to, or null if it doesn't exist.
Future<String?> resolveAlias(String alias) async {
try {
return (await _sendCommand("resolve_alias", {"alias": alias}))["room_id"];
} on GomuksException catch (error) {
if (error.statusCode == 404) return null;
rethrow;
}
}
Future<SpecVersionsResponse> getSpecVersions() async =>
.fromJson(await _sendCommand("get_versions"));
Future<Capabilities> getCapabilities() async =>
.fromJson((await _sendCommand("get_capabilities"))["capabilities"]);
Future<Uri?> discoverHomeserver(Uri homeserver) async {
try {
final response = await _sendCommand("discover_homeserver", {

View file

@ -4,7 +4,7 @@ import "package:nexus/controllers/client.dart";
class ClientIdController(final Uri homeserver) extends AsyncNotifier<String> {
@override
Future<String> build() => ref
.watch(ClientController.provider.notifier)
.read(ClientController.provider.notifier)
.registerClient(
.new(
clientName: "Nexus",

View file

@ -1,84 +0,0 @@
import "dart:convert";
import "package:emoji_text_field/models/emoji_category.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:material_ui/material_ui.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:http/http.dart";
import "package:nexus/models/emoji.dart";
typedef EmojiTuple = (IMap<String, EmojiCategory>, IMap<String, List<String>>);
class EmojiController extends AsyncNotifier<EmojiTuple> {
@override
Future<EmojiTuple> build() async {
final response = await get(
.https("github.com", "github/gemoji/raw/refs/heads/master/db/emoji.json"),
);
if (response.statusCode != 200) {
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);
}
static final provider = AsyncNotifierProvider<EmojiController, EmojiTuple>(
EmojiController.new,
);
}

View file

@ -9,18 +9,40 @@ class EventController(final GetEventRequest request)
extends AsyncNotifier<Event?> {
@override
Future<Event?> build() async {
final room = ref.watch(
RoomsController.provider.select((value) => value[request.roomId]),
);
final event = room?.events.values.firstWhereOrNull(
(event) => event.eventId == request.eventId,
final event = ref.watch(
RoomsController.provider.select(
(rooms) => rooms[request.roomId]?.events.values.firstWhereOrNull(
(event) => event.eventId == request.eventId,
),
),
);
return event ??
await ref
.watch(ClientController.provider.notifier)
.getEvent(request)
.onError((_, _) => null);
if (event != null) return event;
final link = ref.keepAlive();
final fetchedEvent = await ref
.read(ClientController.provider.notifier)
.getEvent(request)
.onError((_, _) => null)
.whenComplete(link.close);
if (fetchedEvent != null) {
// Storing the event invalidates this provider, as it watches the room.
// If nothing is listening, Riverpod won't rebuild it, and this build's
// result would be lost, so store it after returning.
final rooms = ref.read(RoomsController.provider.notifier);
Future.microtask(
() => rooms.update(
.new({
request.roomId: .new(
events: .new({fetchedEvent.rowId: fetchedEvent}),
),
}),
),
);
}
return fetchedEvent;
}
static final provider = AsyncNotifierProvider.family

View file

@ -0,0 +1,97 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/models/requests/search.dart";
typedef EventSearchParams = ({String roomId, String query});
typedef EventSearchResults = ({IList<int> rowIds, String? nextBatch});
class EventSearchController(final EventSearchParams info)
extends AsyncNotifier<EventSearchResults> {
// We could have a mode without this for power users in the future
static String toFtsQuery(String query) => query
.split(RegExp(r"\s+"))
.where((word) => word.isNotEmpty)
.map((word) => '"${word.replaceAll('"', '""')}"*')
.join(" ");
@override
Future<EventSearchResults> build() async {
if (info.query.trim().isEmpty) {
return (rowIds: IList<int>(), nextBatch: null);
}
final client = ref.read(ClientController.provider.notifier);
final encrypted = ref.read(
RoomsController.provider.select(
(rooms) => rooms[info.roomId]?.metadata?.encryptionEvent != null,
),
);
final request = SearchRequest(
searchTerm: encrypted ? toFtsQuery(info.query) : info.query,
roomIds: .new([info.roomId]),
);
final response = encrypted
? await client.searchLocal(request)
: await client.searchServer(request);
if (!ref.mounted) return (rowIds: IList<int>(), nextBatch: null);
ref
.read(RoomsController.provider.notifier)
.storeEvents(response.events.addAll(response.relatedEvents));
return (
rowIds: response.events.map((event) => event.rowId).toIList(),
nextBatch: response.nextBatch,
);
}
Future<void> paginate() async {
if (state.isLoading) return;
final results = await future;
if (!ref.mounted || results.nextBatch == null) return;
state = .loading();
final encrypted = ref.read(
RoomsController.provider.select(
(rooms) => rooms[info.roomId]?.metadata?.encryptionEvent != null,
),
);
final request = SearchRequest(
searchTerm: encrypted ? toFtsQuery(info.query) : info.query,
roomIds: .new([info.roomId]),
nextBatch: results.nextBatch,
);
final client = ref.read(ClientController.provider.notifier);
final response = encrypted
? await client.searchLocal(request)
: await client.searchServer(request);
if (!ref.mounted) return;
ref
.read(RoomsController.provider.notifier)
.storeEvents(response.events.addAll(response.relatedEvents));
state = .data((
rowIds: results.rowIds.addAll(
response.events.map((event) => event.rowId),
),
nextBatch: response.nextBatch,
));
}
static final provider = AsyncNotifierProvider.family
.autoDispose<
EventSearchController,
EventSearchResults,
EventSearchParams
>(EventSearchController.new);
}

View file

@ -0,0 +1,193 @@
import "dart:async";
import "dart:ffi";
import "dart:io";
import "dart:isolate";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/foundation.dart";
import "package:nexus/controllers/account_data.dart";
import "package:nexus/controllers/app_paused.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/init_complete.dart";
import "package:nexus/controllers/invites.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/space_edges.dart";
import "package:nexus/controllers/sync_status.dart";
import "package:nexus/controllers/top_level_spaces.dart";
import "package:nexus/controllers/typing.dart";
import "package:nexus/helpers/extensions/gomuks_buffer.dart";
import "package:nexus/main.dart";
import "package:ffi/ffi.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/events_decrypted.dart";
import "package:nexus/models/sync_data.dart";
import "package:nexus/src/third_party/gomuks.g.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
class GomuksListenerController extends AsyncNotifier<void> {
@override
Future<void> build() async {
debugPrint("Starting gomuks...");
final handle = await ref.watch(ClientController.provider.future);
final callable =
NativeCallable<
Void Function(Pointer<Char>, Int64, GomuksOwnedBuffer)
>.listener((
Pointer<Char> command,
int requestId,
GomuksOwnedBuffer data,
) {
try {
final muksEventType = command.cast<Utf8>().toDartString();
debugPrint("Handling $muksEventType...");
final decodedMuksEvent = data.toJsonAndFree();
switch (muksEventType) {
case "client_state":
ref
.read(ClientStateController.provider.notifier)
.set(.fromJson(decodedMuksEvent));
case "sync_status":
ref
.read(SyncStatusController.provider.notifier)
.set(.fromJson(decodedMuksEvent));
case "init_complete":
ref.read(InitCompleteController.provider.notifier).complete();
case "send_complete":
final event = Event.fromJson(decodedMuksEvent["event"]);
ref
.read(RoomsController.provider.notifier)
.update(
.new({
event.roomId: .new(events: .new({event.rowId: event})),
}),
);
case "typing":
ref
.read(TypingController.provider.notifier)
.set(.fromJson(decodedMuksEvent));
case "events_decrypted":
final decrypted = EventsDecrypted.fromJson(decodedMuksEvent);
final metadata = ref
.read(RoomsController.provider)[decrypted.roomId]
?.metadata;
ref
.read(RoomsController.provider.notifier)
.update(
.new({
decrypted.roomId: .new(
events: decrypted.events,
metadata: metadata?.copyWith(
previewEventRowID:
decrypted.previewEventRowID ??
metadata.previewEventRowID,
sortingTimestamp:
decrypted.sortingTimestamp ??
metadata.sortingTimestamp,
),
),
}),
);
case "sync_complete":
final syncData = SyncData.fromJson(decodedMuksEvent);
final roomProvider = RoomsController.provider;
final accountDataProvider = AccountDataController.provider;
final invitesProvider = InvitesController.provider;
if (syncData.clearState) {
ref.invalidate(roomProvider);
ref.invalidate(accountDataProvider);
ref.invalidate(invitesProvider);
ref.invalidate(TypingController.provider);
}
ref
.read(roomProvider.notifier)
.update(syncData.rooms, syncData.leftRooms);
ref
.read(accountDataProvider.notifier)
.update(syncData.accountData);
ref
.read(invitesProvider.notifier)
.update(
syncData.invitedRooms,
joinedRooms: syncData.rooms.keys.toISet(),
leftRooms: syncData.leftRooms,
);
if (syncData.topLevelSpaces case final topLevelSpaces?) {
ref
.read(TopLevelSpacesController.provider.notifier)
.set(topLevelSpaces);
}
if (syncData.spaceEdges case final spaceEdges?) {
ref
.read(SpaceEdgesController.provider.notifier)
.set(spaceEdges);
}
default:
debugPrint("Unhandled event: $muksEventType");
}
debugPrint("Finished handling $muksEventType...");
} catch (error, stackTrace) {
if (kDebugMode) {
debugPrintStack(stackTrace: stackTrace, label: error.toString());
rethrow;
} else {
showError(error, stackTrace);
}
}
});
ref.onDispose(callable.close);
final errorCode = await _start(handle, callable.nativeFunction);
if (errorCode != 0) {
throw Exception("GomuksStart returned error code $errorCode");
}
if (!ref.mounted) return;
if (Platform.isAndroid || Platform.isIOS) {
// Mobile OSes block network access for backgrounded apps, so pause
// syncing instead of letting it fail.
ref.listen(AppPausedController.provider, (_, paused) {
if (paused) {
GomuksPauseSync(handle);
} else {
ref.invalidate(TypingController.provider);
GomuksResumeSync(handle);
}
}, fireImmediately: true);
}
}
/// Waits for gomuks to report the client's device ID
Future<String> deviceId() {
final completer = Completer<String>();
final subscription = ref.listen(
ClientStateController.provider.select((value) => value?.deviceId),
(_, next) {
if (next != null && !completer.isCompleted) completer.complete(next);
},
fireImmediately: true,
);
return completer.future.whenComplete(subscription.close);
}
static Future<int> _start(
int handle,
Pointer<
NativeFunction<Void Function(Pointer<Char>, Int64, GomuksOwnedBuffer)>
>
callback,
) => Isolate.run(() => GomuksStart(handle, callback));
static final provider = AsyncNotifierProvider<GomuksListenerController, void>(
GomuksListenerController.new,
);
}

View file

@ -0,0 +1,112 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/account_data.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/space_edges.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/requests/get_specific_room_state.dart";
typedef ImagePackSource = ({String roomId, String? stateKey});
class ImagePackSourcesController(final String roomId)
extends AsyncNotifier<IList<ImagePackSource>> {
@override
Future<IList<ImagePackSource>> build() async {
final enabledPacks = ref.watch(
AccountDataController.provider.select(
(data) =>
(data.imagePackRooms ?? data.legacyImagePackRooms)?.rooms.map(
(roomId, packs) => .new(roomId, packs.keys.toISet()),
) ??
const .empty(),
),
);
final spaceEdges = ref.watch(SpaceEdgesController.provider);
final directParents = ref.watch(
RoomsController.provider.select(
(rooms) =>
rooms[roomId]?.state["m.space.parent"]?.keys.toISet() ??
const .empty(),
),
);
Iterable<String> parentsOf(String childId) => spaceEdges.entries
.where((entry) => entry.value.any((edge) => edge.childId == childId))
.map((entry) => entry.key)
.followedBy(childId == roomId ? directParents : const []);
ISet<String> withAncestors(ISet<String> spaces, String childId) =>
parentsOf(childId).fold(
spaces,
(spaces, parentId) => spaces.contains(parentId)
? spaces
: withAncestors(spaces.add(parentId), parentId),
);
final rooms = ref.read(RoomsController.provider);
final spaces = withAncestors(
const .empty(),
roomId,
).remove(roomId).where(rooms.containsKey).toIList();
final client = ref.read(ClientController.provider.notifier);
final roomsNotifier = ref.read(RoomsController.provider.notifier);
await Future.wait(
spaces
.where((spaceId) => !rooms[spaceId]!.hasFetchedState)
.map(
(spaceId) async => roomsNotifier.addState(
spaceId,
await client.getRoomState(.new(roomId: spaceId)),
),
),
);
const packTypes = <EventType>[.imagePack, .legacyImagePack];
// Rooms we aren't in can't be fetched, and fetched rooms have every pack
final missingKeys = enabledPacks.entries
.where((entry) => !(rooms[entry.key]?.hasFetchedState ?? true))
.expand(
(entry) => entry.value
.where(
(stateKey) => packTypes.every(
(type) =>
rooms[entry.key]?.state[type.type]?[stateKey] == null,
),
)
.expand(
(stateKey) => packTypes.map(
(type) => RoomStateKey(
roomId: entry.key,
type: type.type,
stateKey: stateKey,
),
),
),
)
.toIList();
if (missingKeys.isNotEmpty) {
roomsNotifier.addSpecificState(
await client.getSpecificRoomState(.new(keys: missingKeys)),
);
}
return .new([
(roomId: roomId, stateKey: null),
...spaces.map((spaceId) => (roomId: spaceId, stateKey: null)),
...enabledPacks.entries.expand(
(entry) => entry.value.map(
(stateKey) => (roomId: entry.key, stateKey: stateKey),
),
),
]);
}
static final provider = AsyncNotifierProvider.autoDispose
.family<ImagePackSourcesController, IList<ImagePackSource>, String>(
ImagePackSourcesController.new,
);
}

View file

@ -0,0 +1,72 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/image_pack_sources.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/image_pack.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/image_pack.dart";
class ImagePacksController(final String roomId)
extends AsyncNotifier<IList<ImagePack>> {
@override
Future<IList<ImagePack>> build() async {
final sources = await ref.watch(
ImagePackSourcesController.provider(roomId).future,
);
final packs = ref.watch(
RoomsController.provider.select(
(rooms) => sources.expand((source) {
final room = rooms[source.roomId];
if (room == null) return const <ImagePack>[];
final newPacks = room.state[EventType.imagePack.type];
final legacyPacks = room.state[EventType.legacyImagePack.type];
final roomName = room.metadata?.name ?? source.roomId;
return switch (source.stateKey) {
final stateKey? => ISet([stateKey]),
null => ISet(
newPacks?.keys ?? const <String>[],
).addAll(legacyPacks?.keys ?? const []),
}
.map(
(stateKey) =>
switch (newPacks?[stateKey] ?? legacyPacks?[stateKey]) {
final rowId? => switch (room.events[rowId]) {
Event(
redactedBy: null,
content: final ImagePackContent content,
) =>
ImagePack.fromContent(
content,
id: "${source.roomId}/$stateKey",
fallbackName: stateKey.isEmpty
? roomName
: "$roomName - $stateKey",
),
_ => null,
},
null => null,
},
)
.nonNulls;
}).toIList(),
),
);
return .new(
packs
.whereNoDuplicates(by: (pack) => pack.id)
.where(
(pack) => pack.emoticons.isNotEmpty || pack.stickers.isNotEmpty,
),
);
}
static final provider = AsyncNotifierProvider.autoDispose
.family<ImagePacksController, IList<ImagePack>, String>(
ImagePacksController.new,
);
}

View file

@ -0,0 +1,122 @@
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/client_state.dart";
import "package:nexus/helpers/extensions/get_localpart.dart";
import "package:nexus/models/content/avatar.dart";
import "package:nexus/models/content/canonical_alias.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/create.dart";
import "package:nexus/models/content/encryption.dart";
import "package:nexus/models/content/history_visibility.dart";
import "package:nexus/models/content/join_rules.dart";
import "package:nexus/models/content/membership.dart";
import "package:nexus/models/content/name.dart";
import "package:nexus/models/content/topic.dart";
import "package:nexus/models/invited_room.dart";
import "package:nexus/models/room_summary.dart";
/// Pending invites, newest first.
class InvitesController extends Notifier<IList<RoomSummary>> {
@override
IList<RoomSummary> build() => .new();
/// Adds new invites, and removes invites for rooms that have since been
/// joined or left.
void update(
IList<InvitedRoom> invitedRooms, {
ISet<String> joinedRooms = const .empty(),
ISet<String> leftRooms = const .empty(),
}) {
final userId = ref.read(ClientStateController.provider)?.userId;
RoomSummary toSummary(InvitedRoom invite) {
final summary = invite.inviteState.fold(
RoomSummary(roomId: invite.roomId, membership: .invite),
(summary, event) => switch (event.content) {
NameContent(:final name) when name.isNotEmpty => summary.copyWith(
name: name,
),
AvatarContent(:final url) => summary.copyWith(avatarUrl: url),
TopicContent(:final topic) when topic.isNotEmpty => summary.copyWith(
topic: topic,
),
CanonicalAliasContent(:final alias) => summary.copyWith(
canonicalAlias: alias,
),
JoinRulesContent(:final joinRule) => summary.copyWith(
joinRule: joinRule,
),
CreateContent(:final type, :final roomVersion) => summary.copyWith(
roomType: type,
roomVersion: roomVersion,
),
EncryptionContent(:final algorithm) => summary.copyWith(
encryption: algorithm,
),
HistoryVisibilityContent(:final historyVisibility) =>
summary.copyWith(
worldReadable: historyVisibility == .worldReadable,
),
MembershipContent(:final isDirect, :final reason)
when event.stateKey == userId =>
summary.copyWith(
invitedBy: event.sender,
inviteReason: reason,
isDirect: isDirect,
),
_ => summary,
},
);
// The inviter is only known once the user's own invite has been found
final inviterProfile = switch (invite.inviteState
.firstWhereOrNull(
(event) =>
event.type == EventType.membership.type &&
event.stateKey == summary.invitedBy,
)
?.content) {
MembershipContent content => content,
_ => null,
};
return summary.copyWith(
inviterProfile: inviterProfile,
// Direct messages usually have no name, so use the inviter's
name:
summary.name ??
(summary.isDirect
? inviterProfile?.displayName ?? summary.invitedBy?.localpart
: null),
);
}
final newInvites = invitedRooms.map(toSummary).toIList();
final newRoomIds = newInvites.map((invite) => invite.roomId).toISet();
final removed = joinedRooms.addAll(leftRooms);
state = newInvites
.addAll(state.where((invite) => !newRoomIds.contains(invite.roomId)))
.removeWhere((invite) => removed.contains(invite.roomId));
}
void remove(String roomId) =>
state = state.removeWhere((invite) => invite.roomId == roomId);
Future<void> decline(String roomId) async {
await ref.read(ClientController.provider.notifier).leaveRoom(roomId);
remove(roomId);
}
/// Declines every invite, removing each as it succeeds. If any fail, the
/// first error is thrown once all have finished.
Future<void> declineAll() =>
Future.wait(state.map((invite) => decline(invite.roomId)));
static final provider =
NotifierProvider<InvitesController, IList<RoomSummary>>(
InvitesController.new,
);
}

View file

@ -0,0 +1,17 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
typedef JumpRequest = ({String roomId, int rowId});
class JumpRequestController extends Notifier<JumpRequest?> {
@override
JumpRequest? build() => null;
void request(String roomId, int rowId) =>
state = (roomId: roomId, rowId: rowId);
void consume() => state = null;
static final provider = NotifierProvider<JumpRequestController, JumpRequest?>(
JumpRequestController.new,
);
}

View file

@ -1,27 +1,28 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/shared_prefs.dart";
class KeyController(final String key) extends Notifier<String?> {
class KeyController(final String key) extends AsyncNotifier<String?> {
static const String spaceKey = "space";
static const String roomKey = "room";
static const String pushKeyKey = "pushKey";
@override
String? build() =>
ref.watch(SharedPrefsController.provider).requireValue.getString(key);
Future<String?> build() =>
ref.watch(SharedPrefsController.provider).getString(key);
Future<void> set(String? value) async {
final prefs = ref.watch(SharedPrefsController.provider).requireValue;
state = value;
final prefs = ref.read(SharedPrefsController.provider);
state = .data(value);
if (value == null) {
prefs.remove(key);
await prefs.remove(key);
} else {
prefs.setString(key, value);
await prefs.setString(key, value);
}
}
static final provider =
NotifierProvider.family<KeyController, String?, String>(
AsyncNotifierProvider.family<KeyController, String?, String>(
KeyController.new,
);
}

View file

@ -0,0 +1,28 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/client.dart";
/// A room's aliases on the user's own server.
class LocalAliasesController(final String roomId)
extends AsyncNotifier<IList<String>> {
@override
Future<IList<String>> build() =>
ref.read(ClientController.provider.notifier).getLocalAliases(roomId);
Future<void> add(String alias) async {
await ref
.read(ClientController.provider.notifier)
.createAlias(roomId, alias);
ref.invalidateSelf();
}
Future<void> remove(String alias) async {
await ref.read(ClientController.provider.notifier).deleteAlias(alias);
ref.invalidateSelf();
}
static final provider = AsyncNotifierProvider.family
.autoDispose<LocalAliasesController, IList<String>, String>(
LocalAliasesController.new,
);
}

View file

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

View file

@ -18,7 +18,7 @@ class MembersController(final String roomId)
if (!room.hasFetchedMembers) {
final fetchedState = await ref
.watch(ClientController.provider.notifier)
.read(ClientController.provider.notifier)
.getRoomState(
GetRoomStateRequest(
roomId: roomId,
@ -27,7 +27,7 @@ class MembersController(final String roomId)
),
);
await ref
ref
.read(RoomsController.provider.notifier)
.addState(roomId, fetchedState, isMembers: true);
}

View file

@ -1,44 +1,27 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/members_by_status.dart";
import "package:nexus/controllers/room_creators.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/room_power_levels.dart";
import "package:nexus/models/configs/members_by_status.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/power_levels.dart";
import "package:nexus/models/event.dart";
class MembersGroupedController(final MembersByStatusConfig config)
extends AsyncNotifier<IList<MapEntry<int?, ISet<Event>>>> {
@override
Future<IList<MapEntry<int?, ISet<Event>>>> build() async {
final room = ref.watch(
RoomsController.provider.select((value) => value[config.roomId]),
final powerLevels = ref.watch(
RoomPowerLevelsController.provider(config.roomId),
);
final roomCreators = room == null
? null
: ref.watch((RoomCreatorsController.provider(room)));
final powerLevelsRowId = room?.state[EventType.powerLevels.type]?[""];
final powerLevelsEvent = powerLevelsRowId == null
? null
: room?.events[powerLevelsRowId];
final content = switch (powerLevelsEvent?.content) {
PowerLevelsContent content => content,
_ => PowerLevelsContent(),
};
final members = await ref.watch(
MembersByStatusController.provider(config).future,
);
return members
.fold<IMap<int?, ISet<Event>>>(.new(), (result, event) {
final groupKey = roomCreators?.contains(event.stateKey!) == true
final groupKey = powerLevels.creators.contains(event.stateKey!)
? null
: content.users[event.stateKey!] ?? content.usersDefault;
: powerLevels.levelOf(event.stateKey!).toInt();
return result.update(
groupKey,

View file

@ -6,8 +6,10 @@ import "package:flutter_riverpod/flutter_riverpod.dart";
class MultiProviderController(final IList<AsyncNotifierProvider> providers)
extends AsyncNotifier<void> {
@override
Future<void> build() =>
.wait(providers.map((provider) => ref.watch(provider.future)));
Future<void> build() => .wait(
providers.map((provider) => ref.watch(provider.future)),
eagerError: true,
);
static final provider =
AsyncNotifierProvider.family<

View file

@ -0,0 +1,15 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/client.dart";
class MutualRoomsController(final String userId)
extends AsyncNotifier<IList<String>> {
@override
Future<IList<String>> build() =>
ref.read(ClientController.provider.notifier).getMutualRooms(userId);
static final provider = AsyncNotifierProvider.family
.autoDispose<MutualRoomsController, IList<String>, String>(
MutualRoomsController.new,
);
}

View file

@ -0,0 +1,281 @@
import "dart:async";
import "dart:convert";
import "dart:io";
import "package:flutter/services.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/portal.dart";
import "package:nexus/controllers/sidebar_page.dart";
import "package:nexus/main.dart";
import "package:flutter_local_notifications/flutter_local_notifications.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/notification_request.dart";
import "package:nexus/models/push_notification.dart";
import "package:xdg_desktop_portal/xdg_desktop_portal.dart";
typedef NotificationMessage = ({
NotificationUser sender,
NotificationUser self,
File? senderAvatar,
bool isDm,
});
class NotificationController
extends AsyncNotifier<FlutterLocalNotificationsPlugin> {
@override
Future<FlutterLocalNotificationsPlugin> build() async {
final notifications = FlutterLocalNotificationsPlugin();
if (!Platform.isLinux) {
final darwin = DarwinInitializationSettings();
await notifications.initialize(
settings: .new(
windows: .new(
appName: "Nexus",
appUserModelId: "nexus.federated.nexus",
guid: "dde78daf-130f-4e46-a80a-e31deeab45d7",
),
android: .new("ic_launcher_foreground"),
iOS: darwin,
macOS: darwin,
),
onDidReceiveNotificationResponse: (details) {
if (details.payload == null) return;
if (int.tryParse(details.payload!) case final rowId?) {
navigatorKey.currentState?.popUntil((route) => route.isFirst);
ref
.read(SidebarPageController.provider.notifier)
.set(.notifications);
ref
.read(NotificationRequestController.provider.notifier)
.request(rowId);
}
},
);
}
if (Platform.isLinux) {
const notificationChannel = MethodChannel("nexus/notifications");
notificationChannel.setMethodCallHandler((call) async {
if (call.method != "notificationClicked") {
return;
}
if (int.tryParse(call.arguments) case int rowId) {
navigatorKey.currentState?.popUntil((route) => route.isFirst);
ref.read(SidebarPageController.provider.notifier).set(.notifications);
ref
.read(NotificationRequestController.provider.notifier)
.request(rowId);
}
});
ref.onDispose(() => notificationChannel.setMethodCallHandler(null));
}
return notifications;
}
Future<bool> requestPermissions() async {
if (Platform.isLinux) {
return true;
}
final controller = await future;
if (Platform.isIOS) {
return await controller
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin
>()
?.requestPermissions(alert: true, badge: true, sound: true) ??
true;
} else if (Platform.isMacOS) {
return await controller
.resolvePlatformSpecificImplementation<
MacOSFlutterLocalNotificationsPlugin
>()
?.requestPermissions(alert: true, badge: true, sound: true) ??
true;
} else if (Platform.isAndroid) {
return await controller
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.requestNotificationsPermission() ??
true;
}
return true;
}
/// Whether notifications can currently be shown, without prompting
Future<bool> permissionsGranted() async {
if (Platform.isLinux) {
return true;
}
final controller = await future;
if (Platform.isIOS) {
return (await controller
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin
>()
?.checkPermissions())
?.isEnabled ??
true;
} else if (Platform.isMacOS) {
return (await controller
.resolvePlatformSpecificImplementation<
MacOSFlutterLocalNotificationsPlugin
>()
?.checkPermissions())
?.isEnabled ??
true;
} else if (Platform.isAndroid) {
return await controller
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.areNotificationsEnabled() ??
true;
}
return true;
}
/// Passing [message] shows it as a chat message in the conversation for
/// [threadId], naming the sender in group chats.
Future<void> send({
required int id,
required String title,
required String body,
String? payload,
String? threadId,
DateTime? timestamp,
File? icon,
bool sound = true,
NotificationMessage? message,
}) async {
debugPrint("Sending notification for $id");
// In DMs the title is already the sender
final sender = message == null || message.isDm ? null : message.sender;
final prefixedBody = sender == null ? body : "${sender.name}: $body";
if (Platform.isLinux) {
final portal = await ref.read(PortalController.provider.future);
const escape = HtmlEscape(.element);
await portal.notification.addNotification(
id.toString(),
title: title,
body: prefixedBody,
markupBody: sender == null
? null
: "<b>${escape.convert(sender.name)}</b>: ${escape.convert(body)}",
defaultAction: "app.event",
defaultActionTarget: payload,
sound: sound ? null : const XdgNotificationSoundSilent(),
category: message == null ? null : XdgNotificationCategory.imReceived,
priority: .normal,
buttons: [
// TODO: Impl when notif daemons actualy support imReplyWithText
// .new(
// label: "Reply",
// action: "app.reply",
// purpose: XdgNotificationButtonPurpose.imReplyWithText,
// ),
],
);
return;
}
final controller = await future;
// Append to the conversation already shown under this id, if any
final existingStyle = Platform.isAndroid && message != null
? await controller
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.getActiveNotificationMessagingStyle(id: id)
.onError((_, _) => null)
: null;
final darwin = DarwinNotificationDetails(
presentSound: sound,
threadIdentifier: threadId,
);
await controller.show(
id: id,
title: title,
body: prefixedBody,
payload: payload,
notificationDetails: .new(
android: message == null
? .new(
"general",
"General",
silent: !sound,
when: timestamp?.millisecondsSinceEpoch,
largeIcon: icon == null
? null
: FilePathAndroidBitmap(icon.path),
styleInformation: BigTextStyleInformation(body),
)
: .new(
"messages",
"Messages",
silent: !sound,
when: timestamp?.millisecondsSinceEpoch,
category: .message,
largeIcon: icon == null
? null
: FilePathAndroidBitmap(icon.path),
styleInformation: MessagingStyleInformation(
.new(key: message.self.id, name: message.self.name),
conversationTitle: message.isDm ? null : title,
groupConversation: !message.isDm,
messages: [
...?existingStyle?.messages,
.new(
body,
timestamp ?? .now(),
.new(
key: message.sender.id,
name: message.sender.name,
icon: message.senderAvatar == null
? null
: BitmapFilePathAndroidIcon(
message.senderAvatar!.path,
),
),
),
],
),
),
iOS: darwin,
macOS: darwin,
windows: .new(
audio: sound ? null : .silent(),
timestamp: timestamp,
header: threadId == null
? null
: .new(id: threadId, title: title, arguments: payload ?? ""),
),
),
);
}
static final provider =
AsyncNotifierProvider<
NotificationController,
FlutterLocalNotificationsPlugin
>(NotificationController.new);
}

View file

@ -0,0 +1,14 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
class NotificationRequestController extends Notifier<int?> {
@override
int? build() => null;
void request(int rowId) => state = rowId;
void consume() => state = null;
static final provider = NotifierProvider<NotificationRequestController, int?>(
NotificationRequestController.new,
);
}

View file

@ -0,0 +1,78 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/models/event.dart";
typedef NotificationsRequest = (UnreadType? unreadType, String? roomId);
class NotificationsController(final NotificationsRequest request)
extends AsyncNotifier<IList<(int, String)>> {
@override
Future<IList<(int, String)>> build() async {
final client = ref.read(ClientController.provider.notifier);
final (unreadType, roomId) = request;
final mentions = await client.getMentions(
.new(
maxTimestamp: .now(),
unreadType: unreadType ?? .highlight,
roomId: roomId,
),
);
ref
.read(RoomsController.provider.notifier)
.storeEvents(mentions.events.addAll(mentions.relatedEvents));
return .new(mentions.events.map((event) => (event.rowId, event.roomId)));
}
Future<void> loadOlder() async {
final currentNotifications = await future;
state = .loading();
state = await .guard(() async {
final lastNotification = currentNotifications.lastOrNull;
final lastTs = lastNotification == null
? null
: ref.read(
RoomsController.provider.select(
(rooms) => rooms[lastNotification.$2]
?.events[lastNotification.$1]
?.timestamp,
),
);
if (lastTs == null) return currentNotifications;
final client = ref.read(ClientController.provider.notifier);
final (unreadType, roomId) = request;
final newNotifications = await client.getMentions(
.new(
maxTimestamp: lastTs,
unreadType: unreadType ?? .highlight,
roomId: roomId,
),
);
ref
.read(RoomsController.provider.notifier)
.storeEvents(
newNotifications.events.addAll(newNotifications.relatedEvents),
);
return currentNotifications.addAll(
newNotifications.events.map((event) => (event.rowId, event.roomId)),
);
});
}
static final provider = AsyncNotifierProvider.family
.autoDispose<
NotificationsController,
IList<(int, String)>,
NotificationsRequest
>(NotificationsController.new);
}

View file

@ -2,26 +2,25 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/event.dart";
import "package:nexus/controllers/pinned_ids.dart";
import "package:nexus/models/event.dart";
class PinnedEventsController(final String roomId)
extends AsyncNotifier<IList<Event>> {
extends AsyncNotifier<IList<int>> {
@override
Future<IList<Event>> build() async {
Future<IList<int>> build() async {
final pinIds = ref.watch(PinnedIdsController.provider(roomId));
return (await Future.wait(
pinIds.map(
(eventId) => ref.watch(
EventController.provider(.new(eventId: eventId, roomId: roomId))
.future,
.selectAsync((data) => data?.rowId),
),
),
)).nonNulls.toIList();
}
static final provider = AsyncNotifierProvider.family
.autoDispose<PinnedEventsController, IList<Event>, String>(
.autoDispose<PinnedEventsController, IList<int>, String>(
PinnedEventsController.new,
);
}

View file

@ -0,0 +1,22 @@
import "dart:io";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:xdg_desktop_portal/xdg_desktop_portal.dart";
class PortalController extends AsyncNotifier<XdgDesktopPortalClient> {
@override
Future<XdgDesktopPortalClient> build() async {
final portal = XdgDesktopPortalClient();
if (!await File("/.flatpak-info").exists()) {
await portal.registerApplication("nexus.federated.nexus");
}
ref.onDispose(portal.close);
return portal;
}
static final provider =
AsyncNotifierProvider<PortalController, XdgDesktopPortalClient>(
PortalController.new,
);
}

View file

@ -1,10 +1,8 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/room_creators.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/room_power_levels.dart";
import "package:nexus/models/configs/power_level.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/power_levels.dart";
class PowerLevelController(final PowerLevelConfig config)
extends Notifier<bool> {
@ -17,31 +15,17 @@ class PowerLevelController(final PowerLevelConfig config)
);
}
final room = ref.watch(
RoomsController.provider.select((value) => value[config.roomId]),
final powerLevels = ref.watch(
RoomPowerLevelsController.provider(config.roomId),
);
final roomCreators = room == null
? null
: ref.watch(RoomCreatorsController.provider(room));
final eventRowId = room?.state[EventType.powerLevels.type]?[""];
final event = eventRowId == null ? null : room?.events[eventRowId];
final content = event?.content is PowerLevelsContent
? event!.content
: PowerLevelsContent();
final content = powerLevels.content;
final user = ref.watch(
ClientStateController.provider.select((value) => value?.userId),
);
if (user == null || content is! PowerLevelsContent) return false;
if (user == null) return false;
double powerLevelOf(String userId) => roomCreators?.contains(userId) == true
? double.infinity
: (content.users[userId] ?? content.usersDefault).toDouble();
final userLevel = powerLevelOf(user);
final userLevel = powerLevels.levelOf(user);
return switch (config) {
EventPowerLevelConfig(:final eventType) =>
@ -52,10 +36,12 @@ class PowerLevelController(final PowerLevelConfig config)
.invite => userLevel >= content.invite,
.kick =>
userLevel >= content.kick && userLevel > powerLevelOf(targetUser),
userLevel >= content.kick &&
userLevel > powerLevels.levelOf(targetUser),
.ban =>
userLevel >= content.ban && userLevel > powerLevelOf(targetUser),
userLevel >= content.ban &&
userLevel > powerLevels.levelOf(targetUser),
.unban => userLevel >= content.ban,
},

View file

@ -0,0 +1,20 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
class PowerLevelsSavingController(final String roomId) extends Notifier<bool> {
@override
bool build() => false;
Future<void> run(Future<void> Function() action) async {
state = true;
try {
await action();
} finally {
if (ref.mounted) state = false;
}
}
static final provider = NotifierProvider.autoDispose
.family<PowerLevelsSavingController, bool, String>(
PowerLevelsSavingController.new,
);
}

View file

@ -6,10 +6,19 @@ class ProfileController(final String userId)
extends AsyncNotifier<ProfileResponse> {
@override
Future<ProfileResponse> build() {
final client = ref.watch(ClientController.provider.notifier);
final client = ref.read(ClientController.provider.notifier);
return client.getProfile(userId);
}
Future<void> setField(String field, Object? value) async {
await ref
.read(ClientController.provider.notifier)
.setProfileField(field, value);
if (!ref.mounted) return;
ref.invalidateSelf();
await future;
}
static final provider = AsyncNotifierProvider.family
.autoDispose<ProfileController, ProfileResponse, String>(
ProfileController.new,

View file

@ -0,0 +1,31 @@
import "dart:convert";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/key.dart";
class PushKeyController(final String instance) extends AsyncNotifier<String?> {
@override
Future<String?> build() async => json.decode(
await ref.watch(KeyController.provider(KeyController.pushKeyKey).future) ??
"{}",
)[instance];
Future<void> set(String? value) async {
final provider = KeyController.provider(KeyController.pushKeyKey);
final notifier = ref.read(provider.notifier);
final prefs = IMap(json.decode(await ref.read(provider.future) ?? "{}"));
state = .data(value);
notifier.set(
json.encode(
prefs.add(instance, value).where((_, value) => value != null).unlock,
),
);
}
static final provider =
AsyncNotifierProvider.family<PushKeyController, String?, String>(
PushKeyController.new,
);
}

View file

@ -18,7 +18,7 @@ class ReactionsController(final ReactionsConfig config)
final reactionEvents = eventInfo?.$2.isNotEmpty == true
? await ref
.watch(ClientController.provider.notifier)
.read(ClientController.provider.notifier)
.getRelatedEvents(
.new(
roomId: config.roomId,

View file

@ -0,0 +1,90 @@
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/room_chat.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/settings.dart";
import "package:nexus/helpers/extensions/is_shown_as_message.dart";
import "package:nexus/models/read_receipt.dart";
/// Other users' read receipts in a chat, by the event ID of the message to
/// show them under: the nearest message at or before what they've read.
class ReadReceiptsController(final ChatParams chat)
extends Notifier<IMap<String, IList<ReadReceipt>>> {
@override
IMap<String, IList<ReadReceipt>> build() {
final shouldShow = ref.watch(
SettingsController.provider.select(
(settings) => settings.value?.readReceipts != .neither,
),
);
if (!shouldShow) return const .empty();
final userId = ref.watch(
ClientStateController.provider.select((state) => state?.userId),
);
// The chat's own timeline, so receipts follow what's actually shown
final timeline = ref.watch(
RoomChatController.provider(chat)
.select((roomChat) => roomChat.value?.timeline),
);
final room = ref.watch(
RoomsController.provider.select(
(rooms) => switch (rooms[chat.roomId]) {
final room? => (events: room.events, receipts: room.receipts),
null => null,
},
),
);
if (userId == null || timeline == null || room == null) {
return const .empty();
}
final events = timeline.map((rowId) => room.events[rowId]).nonNulls;
final indexes = IMap.fromEntries(
events.indexed.map((entry) => MapEntry(entry.$2.eventId, entry.$1)),
);
final messages = events.indexed
.where((entry) => entry.$2.isShownAsMessage)
.toIList();
final messageIndexes = messages.map((entry) => entry.$1).toList();
return room.receipts.values.flattened
.where(
(receipt) =>
receipt.userId != userId &&
// Receipts in threads are for the thread, not the timeline
receipt.thread == "main",
)
.map(
(receipt) => switch (indexes[receipt.eventId]) {
final index? => (
receipt: receipt,
message: lowerBound(messageIndexes, index + 1) - 1,
),
null => null,
},
)
.nonNulls
.where((entry) => entry.message >= 0)
.groupListsBy((entry) => messages[entry.message].$2.eventId)
.map(
(eventId, entries) => MapEntry(
eventId,
entries
.map((entry) => entry.receipt)
.sorted((a, b) => b.timestamp.compareTo(a.timestamp))
.toIList(),
),
)
.toIMap();
}
static final provider = NotifierProvider.family
.autoDispose<
ReadReceiptsController,
IMap<String, IList<ReadReceipt>>,
ChatParams
>(ReadReceiptsController.new);
}

View file

@ -0,0 +1,63 @@
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:material_emoji_picker/material_emoji_picker.dart";
import "package:nexus/controllers/account_data.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/models/account_data.dart";
class RecentEmojiController extends Notifier<IList<RecentEmoji>> {
@override
IList<RecentEmoji> build() {
final Set<String> emoji = ref
.watch(EmojiController.provider)
.requireValue
.map((element) => element.emojis)
.flattened
.map((e) => e.value)
.toSet();
final IList<RecentEmoji> recents = ref.watch(
AccountDataController.provider.select(
(value) => value.recentEmoji.recentEmoji,
),
);
return .new(
recents.where(
(recent) =>
// Is unicode or custom emote
emoji.contains(recent.emoji) || recent.emoji.startsWith("mxc://"),
),
);
}
Future<void> add(String emoji) => ref
.read(ClientController.provider.notifier)
.setAccountData(
.new(
type: AccountData.recentEmojiKey,
content: RecentEmojiData(
recentEmoji: .new([
.new(
emoji: emoji,
total:
(state
.firstWhereOrNull(
(element) => element.emoji == emoji,
)
?.total ??
0) +
1,
),
...state.whereNot((element) => element.emoji == emoji),
]),
),
),
);
static final provider =
NotifierProvider.autoDispose<RecentEmojiController, IList<RecentEmoji>>(
RecentEmojiController.new,
);
}

View file

@ -9,101 +9,156 @@ import "package:nexus/controllers/attachment.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/image_pack.dart";
import "package:nexus/models/content/reaction.dart";
import "package:nexus/models/content/sticker.dart";
import "package:nexus/models/direction.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/requests/redact_event.dart";
import "package:nexus/models/relation_type.dart";
import "package:nexus/models/requests/send_message.dart";
import "package:nexus/models/room.dart";
import "package:nexus/models/room_chat.dart";
class RoomChatController(final String roomId)
extends AsyncNotifier<IList<Event>?> {
typedef ChatParams = ({String roomId, int? contextualEvent});
class RoomChatController(final ChatParams info)
extends AsyncNotifier<RoomChat?> {
@override
Future<IList<Event>?> build() async {
final client = ref.watch(ClientController.provider.notifier);
final room = ref.watch(
RoomsController.provider.select((rooms) => rooms[roomId]),
Future<RoomChat?> build() async {
final client = ref.read(ClientController.provider.notifier);
final room = ref.read(
RoomsController.provider.select((rooms) => rooms[info.roomId]),
);
if (room == null) return null;
if (!room.hasFetchedState) {
final state = await client.getRoomState(.new(roomId: roomId));
await ref.read(RoomsController.provider.notifier).addState(roomId, state);
final state = await client.getRoomState(.new(roomId: info.roomId));
ref.read(RoomsController.provider.notifier).addState(info.roomId, state);
}
// While there are under 20 events, try to load more
// until there's no more or the conditions are met.
if (room.hasMore && room.timeline.length < 20) {
loadOlder();
}
return room.timeline
final timeline = room.timeline
.toEntryIList(compare: (a, b) => (a?.key ?? 0).compareTo(b?.key ?? 0))
.map((element) => element.value)
.map((e) => e.value)
.toIList()
.addAll(room.sticky)
.map((entry) {
final foundEvent = entry == null ? null : room.events[entry];
final editedEvent =
foundEvent == null || foundEvent.lastEditRowId == 0
? null
: room.events[foundEvent.lastEditRowId];
return editedEvent == null
? foundEvent
: foundEvent?.copyWith(
content: editedEvent.content,
localContent: editedEvent.localContent,
);
})
.addAll(room.clientSticky)
.nonNulls
.toIList();
if (info.contextualEvent == null ||
timeline.contains(info.contextualEvent)) {
ref.watch(
RoomsController.provider.select(
(rooms) =>
(rooms[info.roomId]?.timeline, rooms[info.roomId]?.clientSticky),
),
);
return .new(
timeline: timeline,
hasMoreBackward: room.hasMore,
hasMoreForward: false,
);
} else {
final context = await client.getEventContext(
.new(
roomId: info.roomId,
eventId: room.events[info.contextualEvent!]!.eventId,
),
);
// `before` is in reverse chronological order
final events = context.before.reversed
.toIList()
.add(context.event)
.addAll(context.after);
ref
.read(RoomsController.provider.notifier)
.storeEvents(events.addAll(context.relatedEvents));
return .new(
timeline: .new(events.map((element) => element.rowId)),
hasMoreBackward: true,
hasMoreForward: true,
historicalData: .new(start: context.start, end: context.end),
);
}
}
Future<void> deleteMessage(Event event, {String? reason}) => ref
.watch(ClientController.provider.notifier)
.read(ClientController.provider.notifier)
.redactEvent(
RedactEventRequest(
eventId: event.eventId,
roomId: roomId,
roomId: info.roomId,
reason: reason,
),
);
Future<bool> loadOlder() async {
state = AsyncLoading();
final timelineKeys = ref
.read(RoomsController.provider.select((value) => value[roomId]))
?.timeline
.keys;
final response = await ref
.read(ClientController.provider.notifier)
.paginate(
.new(
roomId: roomId,
maxTimelineId: timelineKeys?.isNotEmpty == true
? timelineKeys?.reduce(min)
: null,
void hideClientSticky(int eventRowId) => ref
.read(RoomsController.provider.notifier)
.update(
.new({
info.roomId: Room(
resetClientSticky: true,
clientSticky:
ref
.read(
RoomsController.provider.select(
(rooms) => rooms[info.roomId]?.clientSticky,
),
)
?.remove(eventRowId) ??
.new(),
),
);
}),
);
Future<void> paginate(Direction direction) async {
if (state.isLoading) return;
final chat = await future;
if (direction == .forward
? chat?.hasMoreForward == false
: chat?.hasMoreBackward == false) {
return;
}
state = .loading();
final client = ref.read(ClientController.provider.notifier);
if (chat?.historicalData == null) {
final timelineKeys = ref
.read(RoomsController.provider.select((value) => value[info.roomId]))
?.timeline
.keys;
final response = await client.paginate(
.new(
roomId: info.roomId,
maxTimelineId: timelineKeys?.isNotEmpty == true
? timelineKeys?.reduce(min)
: null,
),
);
if (response.events.isEmpty) {
state = .data(chat);
}
if (response.events.isEmpty) {
state = .data(state.value);
} else {
ref
.read(RoomsController.provider.notifier)
.update(
IMap({
roomId: Room(
info.roomId: Room(
events: IMap.fromIterable(
response.events.addAll(response.relatedEvents),
keyMapper: (event) => event.rowId,
valueMapper: (event) => event,
),
hasMore: response.hasMore,
receipts: response.receipts,
timeline: IMap.fromIterable(
response.events,
keyMapper: (event) => event.timelineRowId,
@ -111,11 +166,57 @@ class RoomChatController(final String roomId)
),
),
}),
.new(),
);
}
} else {
final paginationResponse = await client.paginateManual(
.new(
roomId: info.roomId,
direction: direction,
since: direction == .forward
? chat!.historicalData!.end
: chat!.historicalData!.start,
),
);
return response.hasMore;
ref
.read(RoomsController.provider.notifier)
.storeEvents(
paginationResponse.events.addAll(paginationResponse.relatedEvents),
);
final eventRowIds =
(direction == .forward
? paginationResponse.events
: paginationResponse.events.reversed)
.map((element) => element.rowId)
.toIList();
state = .data(
.new(
timeline: direction == .forward
? chat.timeline.addAll(eventRowIds)
: eventRowIds.addAll(chat.timeline),
hasMoreForward:
direction == .forward && paginationResponse.nextBatch == null
? false
: chat.hasMoreForward,
hasMoreBackward:
direction == .backward && paginationResponse.nextBatch == null
? false
: chat.hasMoreBackward,
historicalData: chat.historicalData?.copyWith(
start:
(direction == .backward
? paginationResponse.nextBatch
: null) ??
chat.historicalData!.start,
end:
(direction == .forward ? paginationResponse.nextBatch : null) ??
chat.historicalData!.end,
),
),
);
}
}
Future<void> send(
@ -123,60 +224,62 @@ class RoomChatController(final String roomId)
bool shouldMention = true,
required IList<Tag> tags,
required RelationType relationType,
Event? relation,
int? relatedEventRowId,
}) async {
Content? baseContent;
if (relationType == .edit) {
baseContent = relation?.content;
final relatedEvent = relatedEventRowId == null
? null
: ref.read(
RoomsController.provider.select(
(rooms) => rooms[info.roomId]?.events[relatedEventRowId],
),
);
if (relationType == .edit && relatedEventRowId != null) {
baseContent = relatedEvent?.content;
} else {
final provider = AttachmentController.provider(roomId);
baseContent = ref.watch(provider)?.$2;
final provider = AttachmentController.provider(info.roomId);
baseContent = ref.read(provider)?.$2;
ref.invalidate(provider);
}
var taggedMessage = text;
for (final tag in tags) {
final escaped = RegExp.escape(tag.id);
final pattern = RegExp(r"@+(" + escaped + r")(#[^#]*#)?");
taggedMessage = taggedMessage.replaceAllMapped(
pattern,
// Replaces each formatted tag, like "@id#name#", with its ID
final taggedMessage = tags.fold(
text,
(message, tag) => message.replaceAllMapped(
RegExp(r"@+(" + RegExp.escape(tag.id) + r")(#[^#]*#)?"),
(match) => match.group(1)!,
);
}
),
);
final client = ref.watch(ClientController.provider.notifier);
final client = ref.read(ClientController.provider.notifier);
final event = await client.sendMessage(
SendMessageRequest(
roomId: roomId,
roomId: info.roomId,
baseContent: baseContent,
mentions: Mentions(
userIds: [
if (shouldMention == true &&
relation != null &&
relationType == RelationType.reply)
relation.sender,
if (shouldMention && relatedEvent != null && relationType == .reply)
relatedEvent.sender,
].toIList(),
room: taggedMessage.contains("@room"),
),
text: taggedMessage,
relation: relation == null
relation: relatedEvent == null
? null
: .new(eventId: relation.eventId, relationType: relationType),
: .new(eventId: relatedEvent.eventId, relationType: relationType),
),
);
ref
.watch(RoomsController.provider.notifier)
.read(RoomsController.provider.notifier)
.update(
.new({
roomId: .new(
info.roomId: .new(
events: .new({event.rowId: event}),
sticky: .new({event.rowId}),
clientSticky: .new({event.rowId}),
),
}),
.new(),
);
}
@ -185,42 +288,50 @@ class RoomChatController(final String roomId)
Event event,
String userId,
) async {
final client = ref.watch(ClientController.provider.notifier);
final client = ref.read(ClientController.provider.notifier);
final allReactionEvents = await client.getRelatedEvents(
.new(
roomId: roomId,
roomId: info.roomId,
eventId: event.eventId,
relationType: "m.annotation",
),
);
final reactionEvents = allReactionEvents
?.where((event) => event.redactedBy == null)
.toIList();
final reactionEvent = reactionEvents?.firstWhereOrNull(
(event) => switch (event.content) {
ReactionContent(:final key) =>
key == reaction && event.sender == userId,
_ => false,
},
final reactionEvent = allReactionEvents?.firstWhereOrNull(
(event) =>
event.redactedBy == null &&
switch (event.content) {
ReactionContent(:final key) =>
key == reaction && event.sender == userId,
_ => false,
},
);
if (reactionEvent != null) {
await ref
.watch(ClientController.provider.notifier)
.redactEvent(.new(eventId: reactionEvent.eventId, roomId: roomId));
await client.redactEvent(
.new(eventId: reactionEvent.eventId, roomId: info.roomId),
);
}
}
Future<void> sendReaction(String reaction, Event event) async {
final client = ref.watch(ClientController.provider.notifier);
/// Reacts to [event] with [reaction], which is an mxc URL for custom emoji,
/// with their [shortcode].
Future<void> sendReaction(
String reaction,
Event event, {
String? shortcode,
}) async {
final client = ref.read(ClientController.provider.notifier);
await client.sendEvent(
.new(
roomId: roomId,
roomId: info.roomId,
type: EventType.reaction.type,
content: ReactionContent(key: reaction),
// Wrapped in colons, like other clients send it
content: ReactionContent(
key: reaction,
shortcode: shortcode == null ? null : ":$shortcode:",
),
synchronous: true,
disableEncryption: true,
relatesTo: event.eventId,
@ -229,8 +340,35 @@ class RoomChatController(final String roomId)
);
}
Future<void> sendSticker(String shortcode, PackImage image) async {
final event = await ref
.read(ClientController.provider.notifier)
.sendEvent(
.new(
roomId: info.roomId,
type: EventType.sticker.type,
content: StickerContent(
body: image.body ?? shortcode,
info: image.info ?? const .new(),
url: image.url,
),
),
);
ref
.read(RoomsController.provider.notifier)
.update(
.new({
info.roomId: .new(
events: .new({event.rowId: event}),
clientSticky: .new({event.rowId}),
),
}),
);
}
static final provider = AsyncNotifierProvider.family
.autoDispose<RoomChatController, IList<Event>?, String>(
.autoDispose<RoomChatController, RoomChat?, ChatParams>(
RoomChatController.new,
);
}

View file

@ -1,30 +0,0 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/create.dart";
import "package:nexus/models/room.dart";
class RoomCreatorsController(final Room room) extends Notifier<IList<String>> {
@override
IList<String> build() {
final createRowId = room.state[EventType.create.type]?[""];
final createEvent = createRowId == null ? null : room.events[createRowId];
if (createEvent == null) return .new();
final createEventContent = switch (createEvent.content) {
CreateContent content => content,
_ => null,
};
return switch (createEventContent?.additionalCreatorIds) {
IList<String> creators => creators.add(createEvent.sender),
_ => .new([createEvent.sender]),
};
}
static final provider =
NotifierProvider.family<RoomCreatorsController, IList<String>, Room>(
RoomCreatorsController.new,
);
}

View file

@ -0,0 +1,59 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/create.dart";
import "package:nexus/models/content/power_levels.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/room_power_levels.dart";
class RoomPowerLevelsController(final String roomId)
extends Notifier<RoomPowerLevels> {
@override
RoomPowerLevels build() {
final room = ref.watch(
RoomsController.provider.select((rooms) => rooms[roomId]),
);
Event? stateEvent(EventType type) => switch (room?.state[type.type]?[""]) {
final rowId? => room?.events[rowId],
null => null,
};
final createEvent = stateEvent(.create);
final create = switch (createEvent?.content) {
final CreateContent content => content,
_ => null,
};
// Creators only outrank everyone from room version 12, earlier ones just have a power level
final creatorsPrivileged =
(int.tryParse(create?.roomVersion ?? "") ?? 0) >= 12;
return .new(
content: switch (stateEvent(.powerLevels)?.content) {
final PowerLevelsContent content => content,
// Until there are power levels, creators have 100
_ => PowerLevelsContent(
users: switch (createEvent) {
final createEvent? when !creatorsPrivileged => .new({
createEvent.sender: 100,
}),
_ => const .empty(),
},
),
},
creators: switch (createEvent) {
final createEvent? when creatorsPrivileged =>
(create?.additionalCreatorIds ?? const .empty())
.add(createEvent.sender)
.toISet(),
_ => const .empty(),
},
);
}
static final provider = NotifierProvider.autoDispose
.family<RoomPowerLevelsController, RoomPowerLevels, String>(
RoomPowerLevelsController.new,
);
}

View file

@ -0,0 +1,830 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/local_aliases.dart";
import "package:nexus/controllers/power_level.dart";
import "package:nexus/controllers/power_levels_saving.dart";
import "package:nexus/controllers/room_power_levels.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/space_edges.dart";
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
import "package:nexus/helpers/extensions/join_rule_name.dart";
import "package:nexus/helpers/file_dialog_helper.dart";
import "package:nexus/helpers/hooks/saving.dart";
import "package:nexus/main.dart";
import "package:nexus/models/content/avatar.dart";
import "package:nexus/models/content/canonical_alias.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/create.dart";
import "package:nexus/models/content/encryption.dart";
import "package:nexus/models/content/history_visibility.dart";
import "package:nexus/models/content/join_rules.dart";
import "package:nexus/models/content/membership.dart";
import "package:nexus/models/content/message.dart";
import "package:nexus/models/content/name.dart";
import "package:nexus/models/content/power_levels.dart";
import "package:nexus/models/content/topic.dart";
import "package:nexus/models/join_rule.dart";
import "package:nexus/models/power_level_permission.dart";
import "package:nexus/models/setting.dart";
import "package:nexus/models/settings_category.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/divider_text.dart";
import "package:nexus/widgets/settings/add_users_power_level_sheet.dart";
import "package:nexus/widgets/settings/aliases_sheet.dart";
import "package:nexus/widgets/settings/allowed_rooms_sheet.dart";
import "package:nexus/widgets/settings/dialog_list_tile.dart";
import "package:nexus/widgets/settings/local_aliases_sheet.dart";
import "package:nexus/widgets/settings/power_level_entry_sheet.dart";
import "package:nexus/widgets/settings/power_level_tile.dart";
import "package:nexus/widgets/settings/text_setting_tile.dart";
class RoomSettingsSectionsController(final String roomId)
extends AsyncNotifier<IMap<String, IList<SettingsCategory>>> {
@override
Future<IMap<String, IList<SettingsCategory>>> build() async {
final (:state, :metadataTopic, :metadataAvatar) = ref.watch(
RoomsController.provider.select((rooms) {
final room = rooms[roomId];
return (
state: room?.state ?? const IMap.empty(),
metadataTopic: room?.metadata?.topic,
metadataAvatar: room?.metadata?.avatar,
);
}),
);
Content? contentOf(int rowId) =>
ref.read(RoomsController.provider)[roomId]?.events[rowId]?.content;
final client = ref.read(ClientController.provider.notifier);
final roomsController = ref.read(RoomsController.provider.notifier);
// The room's own state, rather than metadata like a name made up from
// its members when it has none
Content? stateContent(EventType type) => switch (state[type.type]?[""]) {
final rowId? => contentOf(rowId),
null => null,
};
final name = switch (stateContent(.name)) {
NameContent(:final name) => name,
_ => null,
};
final topic = switch (stateContent(.topic)) {
TopicContent(:final topic) => topic,
_ => metadataTopic,
};
final avatar = switch (stateContent(.avatar)) {
AvatarContent(:final url) => url,
_ => metadataAvatar,
};
bool canSet(EventType type) => ref.watch(
PowerLevelController.provider(.state(eventType: type, roomId: roomId)),
);
final canSetAvatar = canSet(.avatar);
final canSetName = canSet(.name);
final canSetTopic = canSet(.topic);
final canSetJoinRules = canSet(.joinRules);
final canSetHistoryVisibility = canSet(.historyVisibility);
final canSetAliases = canSet(.canonicalAlias);
final canSetPowerLevels =
canSet(.powerLevels) &&
!ref.watch(PowerLevelsSavingController.provider(roomId));
final joinRules = switch (stateContent(.joinRules)) {
final JoinRulesContent content => content,
_ => JoinRulesContent(joinRule: .invite),
};
final historyVisibility = switch (stateContent(.historyVisibility)) {
HistoryVisibilityContent(:final historyVisibility) => historyVisibility,
_ => HistoryVisibility.shared,
};
final canonicalAlias = switch (stateContent(.canonicalAlias)) {
final CanonicalAliasContent content => content,
_ => CanonicalAliasContent(),
};
final published = canonicalAlias.altAliases.addAll([?canonicalAlias.alias]);
final localAliases =
ref.watch(LocalAliasesController.provider(roomId)).value ??
const IList<String>.empty();
final knownAliases = localAliases.addAll(canonicalAlias.altAliases).addAll([
?canonicalAlias.alias,
]).toISet();
final roomVersion = switch (stateContent(.create)) {
CreateContent(:final roomVersion) => int.tryParse(roomVersion),
_ => null,
};
bool versionAtLeast(int version) =>
roomVersion == null || roomVersion >= version;
final joinRuleOptions = [
JoinRule.public,
JoinRule.invite,
if (versionAtLeast(7)) JoinRule.knock,
if (versionAtLeast(8)) JoinRule.restricted,
if (versionAtLeast(10)) JoinRule.knockRestricted,
];
bool isRestricted(JoinRule rule) =>
rule == .restricted || rule == .knockRestricted;
final parentSpaces = ref
.watch(
SpaceEdgesController.provider.select(
(edges) => edges.entries
.where(
(entry) => entry.value.any((edge) => edge.childId == roomId),
)
.map((entry) => entry.key)
.toIList(),
),
)
.addAll(state["m.space.parent"]?.keys ?? const [])
.toISet();
final allowedRooms = joinRules.allow
.map((condition) => condition.roomId)
.nonNulls
.toISet();
final roomPowerLevels = ref.watch(
RoomPowerLevelsController.provider(roomId),
);
final powerLevels = roomPowerLevels.content;
final userId = ref.watch(
ClientStateController.provider.select((state) => state?.userId),
);
final ownLevel = userId == null ? 0.0 : roomPowerLevels.levelOf(userId);
final encrypted = stateContent(.encryption) is EncryptionContent;
final shownEvents = PowerLevelEvent.shownIn(encrypted: encrypted);
Future<void> setState(EventType type, Content content) async {
final eventId = await client.setState(
.new(roomId: roomId, type: type.type, stateKey: "", content: content),
);
if (eventId case final eventId?) {
await roomsController.awaitState(roomId, type.type, eventId);
}
}
Future<void> setAliases(
String? canonical,
ISet<String> alternatives,
) async {
final stale = (await Future.wait(
alternatives.addAll([?canonical]).map((alias) async {
try {
return await client.resolveAlias(alias) == roomId ? null : alias;
} catch (_) {
return null;
}
}),
)).nonNulls.toISet();
bool kept(String alias) => !stale.contains(alias);
await setState(
.canonicalAlias,
CanonicalAliasContent(
alias: canonical != null && kept(canonical) ? canonical : null,
altAliases: alternatives.where(kept).toISet(),
),
);
}
Future<void> setAllowedRooms(ISet<String> roomIds) => setState(
.joinRules,
joinRules.copyWith(
allow: roomIds
.map((roomId) => AllowCondition(roomId: roomId, type: .membership))
.toISet(),
),
);
Setting header(String title, String description, IconData icon) => .new(
title: title,
description: description,
icon: icon,
builder: (title, _, _) => Padding(
padding: .symmetric(horizontal: 16),
child: DividerText(title),
),
);
Future<void> setPowerLevels(PowerLevelsContent content) => ref
.read(PowerLevelsSavingController.provider(roomId).notifier)
.run(() => setState(.powerLevels, content).onError(showError));
// Event types with their own level that don't have a setting of their own
final listedEvents = shownEvents
.addAll(PowerLevelGroup.values.expand((group) => group.events))
.map((event) => event.type.type)
.toISet();
final otherEvents = powerLevels.events.entries
.where((entry) => !listedEvents.contains(entry.key))
.toIList()
.sort((a, b) => a.key.compareTo(b.key));
// Users with their own level, the highest first
final users = powerLevels.users.entries.toIList().sort(
(a, b) => switch (b.value.compareTo(a.value)) {
0 => a.key.compareTo(b.key),
final order => order,
},
);
MembershipContent? memberOf(String userId) =>
switch (state[EventType.membership.type]?[userId]) {
final rowId? => switch (contentOf(rowId)) {
final MembershipContent content => content,
_ => null,
},
null => null,
};
/// A tile opening a [sheet] to add entries, like event types or users, with their own power level.
Setting addSetting({
required String title,
required String description,
required Widget Function(void Function(PowerLevelsContent content) save)
sheet,
}) => .new(
title: title,
description: description,
icon: Icons.add,
builder: (title, description, icon) => Builder(
builder: (context) => ListTile(
enabled: canSetPowerLevels,
leading: Icon(icon),
title: Text(title),
subtitle: Text(description),
onTap: () => context.showAdaptiveSheet((_) => sheet(setPowerLevels)),
),
),
);
Setting powerLevelSetting({
required String title,
required String description,
required IconData icon,
required int value,
required PowerLevelsContent Function(int level) update,
}) => .new(
title: title,
description: description,
icon: icon,
builder: (title, description, icon) => PowerLevelTile(
leading: Icon(icon),
title: title,
description: description,
value: value,
ownLevel: ownLevel,
onChanged: canSetPowerLevels
? (level) => setPowerLevels(update(level))
: null,
),
);
Setting permissionSetting(PowerLevelPermission permission) =>
powerLevelSetting(
title: permission.title,
description: permission.description,
icon: permission.icon,
value: permission.levelIn(powerLevels),
update: (level) => permission.withLevel(powerLevels, level),
);
/// A setting for the level needed to send [event], which otherwise needs [fallback].
Setting eventSetting(PowerLevelEvent event, {required int fallback}) =>
powerLevelSetting(
title: event.title,
description: event.description,
icon: event.icon,
value: powerLevels.events[event.type.type] ?? fallback,
update: (level) => powerLevels.copyWith(
events: powerLevels.events.add(event.type.type, level),
),
);
Widget tileOf(Setting setting) =>
setting.builder(setting.title, setting.description, setting.icon);
/// A setting for all of [group]'s levels at once, which expands to set each.
Setting groupSetting(PowerLevelGroup group) => .new(
title: group.title,
description: group.description,
icon: group.icon,
builder: (title, description, icon) => HookBuilder(
builder: (context) {
final expanded = useState(false);
return Column(
mainAxisSize: .min,
children: [
PowerLevelTile(
leading: Icon(icon),
title: title,
description: description,
value: group.levelIn(powerLevels),
ownLevel: ownLevel,
// Changing it would change levels above the user's own
onChanged:
canSetPowerLevels &&
group.highestIn(powerLevels) <= ownLevel
? (level) =>
setPowerLevels(group.withLevel(powerLevels, level))
: null,
trailing: IconButton(
tooltip: expanded.value ? "Collapse" : "Set each",
icon: AnimatedRotation(
turns: expanded.value ? 0.5 : 0,
duration: Durations.medium2,
child: Icon(Icons.expand_more),
),
onPressed: () => expanded.value = !expanded.value,
),
),
AnimatedSize(
duration: Durations.medium2,
curve: Curves.easeInOut,
alignment: .topCenter,
child: expanded.value
? Padding(
padding: .only(left: 16),
child: Column(
mainAxisSize: .min,
children: [
...group.permissions
.map(permissionSetting)
.map(tileOf),
...group.events
.map(
(event) => eventSetting(
event,
fallback: powerLevels.stateDefault,
),
)
.map(tileOf),
],
),
)
: const SizedBox(width: .infinity),
),
],
);
},
),
);
return .new({
"Room": .new([
.new(
title: "General",
icon: Icons.tune,
settings: .new([
.new(
title: "Avatar",
description: "The image shown for the room.",
icon: Icons.image,
builder: (title, description, icon) => HookBuilder(
builder: (context) {
final saving = Saving.use();
Future<void> save(Future<void> Function() action) =>
saving.run(() => action().onError(showError));
return ListTile(
enabled: canSetAvatar && !saving.active,
leading: Icon(icon),
title: Text(title),
subtitle: Text(description),
trailing: Row(
mainAxisSize: .min,
spacing: 8,
children: [
AvatarOrHash(
avatar,
name ?? "Unnamed Room",
dimension: 40,
fallback: Icon(Icons.numbers),
),
if (avatar != null && canSetAvatar)
IconButton(
tooltip: "Remove avatar",
icon: Icon(Icons.delete),
onPressed: saving.active
? null
: () => save(
() => setState(.avatar, const .new()),
),
),
],
),
onTap: () async {
final file = await ProviderScope.containerOf(context)
.read(FileDialogHelper.provider)
.pickFile(images: true);
if (file == null || !context.mounted) return;
await save(() async {
final content = await client.uploadMedia(
.new(path: file.path, encrypt: false),
);
if (content case ImageMessageContent(
:final url,
:final info,
)) {
await setState(
.avatar,
AvatarContent(url: url, info: info),
);
} else {
throw Exception("The file isn't an image");
}
});
},
);
},
),
),
.new(
title: "Room Name",
description: "The name shown for the room.",
icon: Icons.title,
builder: (title, description, icon) => TextSettingTile(
icon: Icon(icon),
title: title,
description: description,
value: name,
onChanged: canSetName
? (value) => setState(
.name,
NameContent(name: value),
).onError(showError)
: null,
),
),
.new(
title: "Topic",
description: "A short description of the room.",
icon: Icons.info,
builder: (title, description, icon) => TextSettingTile(
icon: Icon(icon),
title: title,
description: description,
value: topic,
multiline: true,
onChanged: canSetTopic
? (value) => setState(
.topic,
TopicContent(
topic: value,
content: .new(
representations: .new([.new(body: value)]),
),
),
).onError(showError)
: null,
),
),
]),
),
.new(
title: "Access",
icon: Icons.key,
settings: .new([
.new(
title: "Join Rule",
description: "Who can join the room.",
icon: Icons.login,
builder: (title, description, icon) => DialogListTile<JoinRule>(
icon: Icon(icon),
title: title,
subtitle: Text(description),
value: joinRules.joinRule,
options: joinRuleOptions,
getName: (rule) => rule.displayName,
onChanged: canSetJoinRules
? (rule) => setState(
.joinRules,
JoinRulesContent(
joinRule: rule,
allow: isRestricted(rule) && joinRules.allow.isEmpty
? .new(
parentSpaces.map(
(roomId) =>
.new(roomId: roomId, type: .membership),
),
)
: joinRules.allow,
),
).onError(showError)
: null,
),
),
if (isRestricted(joinRules.joinRule))
.new(
title: "Allowed Rooms",
description:
"Members of these rooms can join without an invite.",
icon: Icons.meeting_room,
builder: (title, description, icon) => HookConsumer(
builder: (context, ref, _) {
final saving = Saving.use();
final names = ref.watch(
RoomsController.provider.select(
(rooms) => allowedRooms
.map(
(roomId) =>
rooms[roomId]?.metadata?.name ?? roomId,
)
.toIList(),
),
);
return ListTile(
enabled: canSetJoinRules && !saving.active,
leading: Icon(icon),
title: Text(title),
subtitle: Text(
names.isEmpty
? "None, so only invited users can join"
: names.join(", "),
maxLines: 2,
overflow: .ellipsis,
),
onTap: () => context.showAdaptiveSheet(
(_) => AllowedRoomsSheet(
roomId: roomId,
allowed: allowedRooms,
parentSpaces: parentSpaces,
onSave: (allowed) => saving.run(
() => setAllowedRooms(allowed).onError(showError),
),
),
),
);
},
),
),
.new(
title: "Aliases",
description:
"The addresses the room advertises, and its main one.",
icon: Icons.alternate_email,
builder: (title, description, icon) => HookBuilder(
builder: (context) {
final saving = Saving.use();
return ListTile(
enabled: canSetAliases && !saving.active,
leading: Icon(icon),
title: Text(title),
subtitle: Text(
switch ((
canonicalAlias.alias,
canonicalAlias.altAliases.length,
)) {
(null, 0) => description,
(final alias?, 0) => alias,
(final alias?, 1) =>
"$alias and ${canonicalAlias.altAliases.first}",
(final alias?, final others) =>
"$alias and $others more",
(null, _) => canonicalAlias.altAliases.join(", "),
},
maxLines: 2,
overflow: .ellipsis,
),
onTap: () => context.showAdaptiveSheet(
(_) => AliasesSheet(
aliases: knownAliases,
canonical: canonicalAlias.alias,
alternatives: canonicalAlias.altAliases,
onSave: (canonical, alternatives) => saving.run(
() => setAliases(
canonical,
alternatives,
).onError(showError),
),
),
),
);
},
),
),
.new(
title: "Local Aliases",
description: "Addresses for the room on your server.",
icon: Icons.dns,
builder: (title, description, icon) => Builder(
builder: (context) => ListTile(
leading: Icon(icon),
title: Text(title),
subtitle: Text(
localAliases.isEmpty
? description
: localAliases.join(", "),
maxLines: 2,
overflow: .ellipsis,
),
onTap: () => context.showAdaptiveSheet(
(_) => LocalAliasesSheet(
roomId,
onRemoved: canSetAliases
? (alias) async {
if (published.contains(alias)) {
await setAliases(
canonicalAlias.alias,
canonicalAlias.altAliases,
);
}
}
: null,
),
),
),
),
),
.new(
title: "History Visibility",
description:
"Which messages from before joining members can read.",
icon: Icons.history,
builder: (title, description, icon) =>
DialogListTile<HistoryVisibility>(
icon: Icon(icon),
title: title,
subtitle: Text(description),
value: historyVisibility,
options: [.shared, .invited, .joined],
getName: (visibility) => switch (visibility) {
.shared => "All events",
.invited => "After invite",
.joined => "After join",
.worldReadable => "Anyone",
.unknown => "Unknown",
},
onChanged: canSetHistoryVisibility
? (visibility) => setState(
.historyVisibility,
HistoryVisibilityContent(
historyVisibility: visibility,
),
).onError(showError)
: null,
),
),
]),
),
.new(
title: "Permissions",
icon: Icons.admin_panel_settings,
settings: .new([
permissionSetting(.usersDefault),
header("Users", "Power levels of specific users.", Icons.group),
...users.map((entry) {
final member = memberOf(entry.key);
// Others can only be changed if they're below you, but your own level can be lowered
final canChange =
canSetPowerLevels &&
(entry.key == userId || entry.value < ownLevel);
return Setting(
title: member?.displayName ?? entry.key,
description: entry.key,
icon: Icons.person,
builder: (title, description, _) => PowerLevelTile(
leading: AvatarOrHash(
member?.avatarUrl,
title,
dimension: 40,
),
title: title,
description: description,
value: entry.value,
ownLevel: ownLevel,
onChanged: canChange
? (level) => setPowerLevels(
powerLevels.copyWith(
users: powerLevels.users.add(entry.key, level),
),
)
: null,
onRemoved: canChange
? () => setPowerLevels(
powerLevels.copyWith(
users: powerLevels.users.remove(entry.key),
),
)
: null,
removeConfirmation: entry.key == userId
? "Remove your own power level? You'll have the default of ${powerLevels.usersDefault}, and won't be able to raise it again yourself."
: "Remove $title's power level? They'll have the default of ${powerLevels.usersDefault}.",
),
);
}),
addSetting(
title: "Add Users",
description: "Give users their own power level.",
sheet: (save) => AddUsersPowerLevelSheet(
roomId: roomId,
existing: powerLevels.users.keys.toISet().union(
roomPowerLevels.creators,
),
ownLevel: ownLevel,
onSave: (users, level) => save(
powerLevels.copyWith(
users: powerLevels.users.addEntries(
users.map((user) => MapEntry(user, level)),
),
),
),
),
),
header(
"Groups",
"Power levels set together, which can each be set on their own.",
Icons.workspaces,
),
...PowerLevelGroup.values.map(groupSetting),
header(
"Regular Events",
"Power levels needed to send messages and other timeline events.",
Icons.chat,
),
...shownEvents
.where((event) => event != .pinnedEvents)
.map(
(event) =>
eventSetting(event, fallback: powerLevels.eventsDefault),
),
permissionSetting(.eventsDefault),
header(
"State Events",
"Power levels needed to change the room's state.",
Icons.settings,
),
permissionSetting(.invite),
permissionSetting(.stateDefault),
header(
"Other Events",
"Power levels for specific event types.",
Icons.code,
),
...otherEvents.map(
(entry) => Setting(
title: entry.key,
description: "The power level needed to send this event.",
icon: Icons.code,
builder: (title, _, icon) => PowerLevelTile(
leading: Icon(icon),
title: title,
description: null,
value: entry.value,
ownLevel: ownLevel,
onChanged: canSetPowerLevels
? (level) => setPowerLevels(
powerLevels.copyWith(
events: powerLevels.events.add(entry.key, level),
),
)
: null,
onRemoved: canSetPowerLevels
? () => setPowerLevels(
powerLevels.copyWith(
events: powerLevels.events.remove(entry.key),
),
)
: null,
),
),
),
addSetting(
title: "Add Event",
description: "Give an event type its own power level.",
sheet: (save) => PowerLevelEntrySheet(
title: "Add Event",
keyLabel: "Event type",
keyHint: "m.room.name",
ownLevel: ownLevel,
onSave: (type, level) => save(
powerLevels.copyWith(
events: powerLevels.events.add(type, level),
),
),
),
),
]),
),
]),
});
}
static final provider = AsyncNotifierProvider.family
.autoDispose<
RoomSettingsSectionsController,
IMap<String, IList<SettingsCategory>>,
String
>(RoomSettingsSectionsController.new);
}

View file

@ -7,7 +7,7 @@ class RoomSummaryController(final JoinRoomRequest request)
extends AsyncNotifier<RoomSummary> {
@override
Future<RoomSummary> build() =>
ref.watch(ClientController.provider.notifier).getRoomSummary(request);
ref.read(ClientController.provider.notifier).getRoomSummary(request);
static final provider = AsyncNotifierProvider.family
.autoDispose<RoomSummaryController, RoomSummary, JoinRoomRequest>(

View file

@ -0,0 +1,34 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/settings.dart";
import "package:nexus/controllers/typing.dart";
/// The other users typing in a room, or none if typing indicators are hidden
/// in settings.
class RoomTypingController(final String roomId)
extends Notifier<IList<String>> {
@override
IList<String> build() {
final shouldShow = ref.watch(
SettingsController.provider.select(
(settings) => settings.value?.typingIndicators != .neither,
),
);
if (!shouldShow) return const .empty();
final userId = ref.watch(
ClientStateController.provider.select((state) => state?.userId),
);
return ref
.watch(TypingController.provider.select((typing) => typing[roomId]))
?.remove(userId ?? "") ??
const .empty();
}
static final provider = NotifierProvider.family
.autoDispose<RoomTypingController, IList<String>, String>(
RoomTypingController.new,
);
}

View file

@ -1,42 +1,91 @@
import "dart:isolate";
import "dart:async";
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/read_receipt.dart";
import "package:nexus/models/room.dart";
class RoomsController extends Notifier<IMap<String, Room>> {
@override
IMap<String, Room> build() => .new();
Future<void> addState(
String roomId,
IList<Event> state, {
bool isMembers = false,
}) async => update(
.new({
roomId: Room(
events: .fromEntries(state.map((event) => .new(event.rowId, event))),
hasFetchedState: true,
hasFetchedMembers: isMembers,
state: await Isolate.run(() {
final newState = state.fold<IMap<String, IMap<String, int>>>(
.new(),
(previousValue, stateEvent) => previousValue.add(
stateEvent.type,
(previousValue[stateEvent.type] ?? .new()).add(
stateEvent.stateKey!,
stateEvent.rowId,
),
),
);
return newState;
void addState(String roomId, IList<Event> state, {bool isMembers = false}) =>
update(
.new({
roomId: stateRoom(state)
.copyWith(hasFetchedState: true, hasFetchedMembers: isMembers),
}),
),
}),
.new(),
.new(),
);
void addSpecificState(Iterable<Event> state) => update(
.new(
state
.groupListsBy((event) => event.roomId)
.map((roomId, roomState) => .new(roomId, stateRoom(roomState))),
),
);
void update(IMap<String, Room> rooms, ISet<String> leftRooms) {
static Room stateRoom(Iterable<Event> state) => Room(
events: .fromEntries(state.map((event) => .new(event.rowId, event))),
state: .new(
state
.groupListsBy((event) => event.type)
.map(
(type, group) => .new(
type,
.fromEntries(group.map((e) => .new(e.stateKey!, e.rowId))),
),
),
),
);
static IMap<String, IList<ReadReceipt>> mergeReceipts(
IMap<String, IList<ReadReceipt>> existing,
IMap<String, IList<ReadReceipt>> incoming,
) => incoming.isEmpty
? existing
: existing.values.flattened
.followedBy(incoming.values.flattened)
.groupListsBy((receipt) => (receipt.userId, receipt.thread))
.values
.map(
(receipts) => receipts.reduce(
(newest, receipt) => newest.timestamp.isAfter(receipt.timestamp)
? newest
: receipt,
),
)
.groupListsBy((receipt) => receipt.eventId)
.map((eventId, receipts) => MapEntry(eventId, receipts.toIList()))
.toIMap();
/// Adds events to their rooms, without changing the rooms' timelines.
void storeEvents(Iterable<Event> events) => update(
.new(
events
.groupListsBy((event) => event.roomId)
.map(
(roomId, roomEvents) => .new(
roomId,
Room(
// ignore: unused_result - I am using it you donkey
events: .fromIterable(
roomEvents,
keyMapper: (event) => event.rowId,
),
),
),
),
),
);
void update(
IMap<String, Room> rooms, [
ISet<String> leftRooms = const .empty(),
]) {
final merged = rooms.entries.fold(state, (acc, entry) {
final roomId = entry.key;
final incoming = entry.value;
@ -46,13 +95,16 @@ class RoomsController extends Notifier<IMap<String, Room>> {
roomId,
existing?.copyWith(
hasMore: incoming.hasMore,
sticky:
(incoming.sticky.isEmpty == true
? existing.sticky
: existing.sticky.addAll(incoming.sticky))
.removeWhere(
(rowId) => incoming.timeline.values.contains(rowId),
),
clientSticky: incoming.resetClientSticky
? incoming.clientSticky
: (incoming.clientSticky.isEmpty
? existing.clientSticky
: existing.clientSticky.addAll(
incoming.clientSticky,
))
.removeWhere(
(rowId) => incoming.timeline.values.contains(rowId),
),
metadata: incoming.metadata ?? existing.metadata,
events: incoming.events.isEmpty
? existing.events
@ -64,32 +116,49 @@ class RoomsController extends Notifier<IMap<String, Room>> {
(previousValue[event.key] ?? .new()).addAll(event.value),
),
),
resetClientSticky: false,
reset: false,
hasFetchedMembers:
incoming.hasFetchedMembers || existing.hasFetchedMembers,
hasFetchedState:
incoming.hasFetchedState || existing.hasFetchedState,
timeline: (incoming.reset
timeline: incoming.reset
? incoming.timeline
: existing.timeline.addAll(incoming.timeline)),
receipts: incoming.receipts.entries.fold(
existing.receipts,
(receiptAcc, event) => receiptAcc.add(
event.key,
(receiptAcc[event.key] ?? .new()).addAll(event.value),
),
),
: existing.timeline.addAll(incoming.timeline),
receipts: mergeReceipts(existing.receipts, incoming.receipts),
) ??
incoming,
incoming.copyWith(
receipts: mergeReceipts(const .empty(), incoming.receipts),
),
);
});
final prunedList = leftRooms.fold(
merged,
(acc, roomId) => acc.remove(roomId),
);
state = leftRooms.fold(merged, (acc, roomId) => acc.remove(roomId));
}
state = prunedList;
/// Waits for a sent state event to become the room's state, which happens
/// once it comes down sync. Gives up after a while, e.g. if another event
/// replaced it first.
Future<void> awaitState(
String roomId,
String type,
String eventId, {
String stateKey = "",
}) {
bool arrived(IMap<String, Room> rooms) =>
switch (rooms[roomId]?.state[type]?[stateKey]) {
final rowId? => rooms[roomId]?.events[rowId]?.eventId == eventId,
null => false,
};
if (arrived(state)) return .value();
final completer = Completer<void>();
final removeListener = listenSelf((_, rooms) {
if (arrived(rooms) && !completer.isCompleted) completer.complete();
});
return completer.future
.timeout(const .new(seconds: 10), onTimeout: () {})
.whenComplete(removeListener);
}
static final provider = NotifierProvider<RoomsController, IMap<String, Room>>(

View file

@ -0,0 +1,31 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/key.dart";
import "package:nexus/controllers/selected_space.dart";
import "package:nexus/helpers/extensions/get_all_child_rooms.dart";
class SelectedRoomController extends Notifier<String?> {
@override
String? build() {
final roomId = ref.watch(
KeyController.provider(KeyController.roomKey)
.select((async) => async.value),
);
final roomIds = ref.watch(
SelectedSpaceController.provider.select(
(space) => space?.allChildRooms
.map((room) => room.metadata?.id)
.nonNulls
.toIList(),
),
);
if (roomIds == null) return roomId;
return roomIds.contains(roomId) ? roomId : roomIds.firstOrNull;
}
static final provider = NotifierProvider<SelectedRoomController, String?>(
SelectedRoomController.new,
);
}

View file

@ -0,0 +1,25 @@
import "package:collection/collection.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/key.dart";
import "package:nexus/controllers/spaces.dart";
import "package:nexus/models/space.dart";
class SelectedSpaceController extends Notifier<Space?> {
@override
Space? build() {
final spaces = ref.watch(SpacesController.provider).value;
if (spaces == null) return null;
final spaceId = ref.watch(
KeyController.provider(KeyController.spaceKey)
.select((async) => async.value),
);
return spaces.firstWhereOrNull((space) => space.id == spaceId) ??
spaces.firstOrNull;
}
static final provider = NotifierProvider<SelectedSpaceController, Space?>(
SelectedSpaceController.new,
);
}

View file

@ -1,4 +1,5 @@
import "dart:convert";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/settings_file.dart";
import "package:nexus/models/settings.dart";
@ -17,7 +18,7 @@ class SettingsController extends AsyncNotifier<Settings> {
Future<void> set(Settings settings) async {
state = .data(settings);
final file = await ref.watch(SettingsFileController.provider.future);
final file = await ref.read(SettingsFileController.provider.future);
await file.writeAsString(json.encode(settings.toJson()));
}

View file

@ -1,4 +1,5 @@
import "dart:io";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:path/path.dart";
import "package:path_provider/path_provider.dart";

View file

@ -1,19 +1,34 @@
import "dart:io";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:intl/intl.dart";
import "package:m3e_buttons/m3e_buttons.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/init_complete.dart";
import "package:nexus/controllers/notification.dart";
import "package:nexus/controllers/settings.dart";
import "package:nexus/controllers/unified_push.dart";
import "package:nexus/controllers/spec_versions.dart";
import "package:nexus/controllers/unified_push_allowed.dart";
import "package:nexus/controllers/unified_push_distributors.dart";
import "package:nexus/helpers/extensions/distributor_name.dart";
import "package:nexus/models/account_data.dart";
import "package:nexus/models/client_state.dart";
import "package:nexus/models/name_color.dart";
import "package:nexus/models/room_order.dart";
import "package:nexus/models/settings.dart";
import "package:nexus/models/settings_category.dart";
import "package:nexus/models/show_and_send.dart";
import "package:nexus/main.dart";
import "package:nexus/widgets/settings/dialog_list_tile.dart";
import "package:nexus/widgets/user_bottom_sheet.dart";
import "package:nexus/widgets/reset_encryption_sheet.dart";
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
class SettingsSectionsController
extends AsyncNotifier<IMap<String, IList<SettingsCategory>>> {
@ -21,6 +36,30 @@ class SettingsSectionsController
Future<IMap<String, IList<SettingsCategory>>> build() async {
final settings = await ref.watch(SettingsController.provider.future);
/// A tile for a [ShowAndSend] setting, saving the settings from [update].
Widget showAndSendTile(
String title,
String description,
IconData icon, {
required ShowAndSend value,
required Settings Function(ShowAndSend value) update,
}) => DialogListTile<ShowAndSend>(
icon: Icon(icon),
title: title,
subtitle: Text(description),
value: value,
options: ShowAndSend.values,
getName: (option) => switch (option) {
.showAndSend => "Show and send",
.show => "Only show",
.neither => "Neither",
},
onChanged: (value) => ref
.read(SettingsController.provider.notifier)
.set(update(value))
.onError(showError),
);
return .new({
"General": .new([
.new(
@ -36,11 +75,11 @@ class SettingsSectionsController
icon: Icon(icon),
title: title,
subtitle: Text(description),
initialValue: settings.theme,
value: settings.theme,
options: ThemeMode.values,
getName: (option) => toBeginningOfSentenceCase(option.name),
onChanged: (value) => ref
.watch(SettingsController.provider.notifier)
.read(SettingsController.provider.notifier)
.set(settings.copyWith(theme: value))
.onError(showError),
),
@ -60,21 +99,108 @@ class SettingsSectionsController
Platform.isMacOS ||
Platform.isWindows)
? (value) => ref
.watch(SettingsController.provider.notifier)
.read(SettingsController.provider.notifier)
.set(settings.copyWith(useDynamicTheming: value))
.onError(showError)
: null,
),
),
.new(
title: "Name Colors",
description: "Color names by power level, so moderators and admins stand out, or by user, so everyone has their own.",
icon: Icons.format_color_text,
builder: (title, description, icon) => DialogListTile<NameColor>(
icon: Icon(icon),
title: title,
subtitle: Text(description),
value: settings.nameColor,
options: NameColor.values,
getName: (option) => switch (option) {
.powerLevel => "By power level",
.user => "By user",
},
onChanged: (value) => ref
.read(SettingsController.provider.notifier)
.set(settings.copyWith(nameColor: value))
.onError(showError),
),
),
]),
),
.new(
title: "Behavior",
icon: Icons.psychology,
settings: .new([
.new(
title: "All Rooms In Home",
description: "Show all rooms in home, instead of just ones that aren't in spaces",
icon: Icons.home,
builder: (title, description, icon) => SwitchListTile(
title: Text(title),
subtitle: Text(description),
secondary: Icon(icon),
value: settings.showAllInHome,
onChanged: (value) => ref
.read(SettingsController.provider.notifier)
.set(settings.copyWith(showAllInHome: value))
.onError(showError),
),
),
.new(
title: "Room Ordering",
description: "Sort rooms by recent activity, optionally showing mentioned and unread rooms first.",
icon: Icons.sort,
builder: (title, description, icon) => DialogListTile<RoomOrder>(
icon: Icon(icon),
title: title,
subtitle: Text(description),
value: settings.roomOrder,
options: RoomOrder.values,
getName: (option) => switch (option) {
.unreadFirst => "Mentioned, then unread",
.recentActivity => "Recent activity only",
},
onChanged: (value) => ref
.read(SettingsController.provider.notifier)
.set(settings.copyWith(roomOrder: value))
.onError(showError),
),
),
.new(
title: "Swipe To Reply",
description: "Swipe a message to the right to reply to it.",
icon: Icons.swipe_right,
builder: (title, description, icon) => SwitchListTile(
title: Text(title),
subtitle: Text(description),
secondary: Icon(icon),
value: settings.swipeToReply,
onChanged: (value) => ref
.read(SettingsController.provider.notifier)
.set(settings.copyWith(swipeToReply: value))
.onError(showError),
),
),
if (!Platform.isAndroid && !Platform.isIOS)
.new(
title: "Show Minimize Button",
description:
"Enable this to add a minimize button to the appbar",
icon: Icons.minimize,
builder: (title, description, icon) => SwitchListTile(
title: Text(title),
subtitle: Text(description),
secondary: Icon(icon),
value: settings.showMinimizeButton,
onChanged: (value) => ref
.read(SettingsController.provider.notifier)
.set(settings.copyWith(showMinimizeButton: value))
.onError(showError),
),
),
.new(
title: "Linux Mobile Mode",
description: "Enables some fixes for Linux mobile, e.g. disabling dragging appbar for moving window.",
description: "Enables some fixes for Linux mobile, e.g. disabling dragging appbar for moving window, and swiping drawers open.",
icon: Icons.construction,
builder: (title, description, icon) => SwitchListTile(
title: Text(title),
@ -83,18 +209,201 @@ class SettingsSectionsController
value: settings.linuxMobileMode,
onChanged: Platform.isLinux
? (value) => ref
.watch(SettingsController.provider.notifier)
.read(SettingsController.provider.notifier)
.set(settings.copyWith(linuxMobileMode: value))
.onError(showError)
: null,
),
),
.new(
title: "Hide Membership Events",
description: "Hides membership events (invites, joins, leaves & bans) from appearing in conversations. Knocks are still shown.",
icon: Icons.person_off,
builder: (title, description, icon) => SwitchListTile(
title: Text(title),
subtitle: Text(description),
secondary: Icon(icon),
value: settings.hideMembershipEvents,
onChanged: (value) => ref
.read(SettingsController.provider.notifier)
.set(settings.copyWith(hideMembershipEvents: value))
.onError(showError),
),
),
.new(
title: "Hide Server ACL Events",
description: "Hides server ACL (server ban list) changes from appearing in conversations.",
icon: Icons.dns,
builder: (title, description, icon) => SwitchListTile(
title: Text(title),
subtitle: Text(description),
secondary: Icon(icon),
value: settings.hideServerAclEvents,
onChanged: (value) => ref
.read(SettingsController.provider.notifier)
.set(settings.copyWith(hideServerAclEvents: value))
.onError(showError),
),
),
]),
),
.new(
title: "Privacy",
icon: Icons.privacy_tip,
settings: .new([
.new(
title: "Typing Indicators",
description: "Show when others are typing, and let others see when you're typing.",
icon: Icons.keyboard,
builder: (title, description, icon) => showAndSendTile(
title,
description,
icon,
value: settings.typingIndicators,
update: (value) => settings.copyWith(typingIndicators: value),
),
),
.new(
title: "Read Receipts",
description: "Show how far others have read, and let others see what you've read. When not sent, read receipts are still sent privately, so your other devices know what you've read.",
icon: Icons.done_all,
builder: (title, description, icon) => showAndSendTile(
title,
description,
icon,
value: settings.readReceipts,
update: (value) => settings.copyWith(readReceipts: value),
),
),
]),
),
]),
if (ref.watch(ClientStateController.provider)?.isLoggedIn == true)
if (ref.watch(ClientStateController.provider) case ClientState(
isLoggedIn: true,
:final userId?,
))
"Account": .new([
.new(title: "Profile", icon: Icons.person, settings: .new([])),
.new(
title: "Profile",
icon: Icons.person,
settings: .new([
.new(
title: "Profile",
description: "Your avatar, display name, pronouns, and timezone, as others see them.",
icon: Icons.person,
builder: (title, description, icon) => Center(
child: ConstrainedBox(
constraints: .new(maxWidth: 500),
child: UserBottomSheet(userId, editable: true),
),
),
),
]),
),
.new(
title: "Notifications",
icon: Icons.notifications,
settings: .new([
.new(
title: "Push notifications",
description: "Enable push notifications using Web Push, and choose which UnifiedPush distributor to use.",
builder: (title, description, icon) => HookConsumer(
builder: (context, ref, _) {
final loading = useState(false);
final pusherRegistered = ref.watch(
UnifiedPushController.provider,
);
final unifiedPushAllowed = ref.watch(
UnifiedPushAllowedController.provider,
);
final distributors = ref.watch(
UnifiedPushDistributorsController.provider,
);
final current = pusherRegistered.value;
return DialogListTile<String?>(
title: title,
subtitle: Text(
unifiedPushAllowed.maybeWhen(
data: (data) => data,
orElse: () => null,
) ??
description,
),
icon: Icon(icon),
value: current,
options: [
null,
...?distributors.value,
if (current != null &&
distributors.value?.contains(current) != true)
current,
],
getName: (option) =>
option == null ? "Off" : option.distributorName,
onChanged: loading.value
? null
: unifiedPushAllowed.maybeWhen(
data: (data) => data == null,
orElse: () => false,
)
? pusherRegistered.maybeWhen(
data: (_) => (value) async {
if (value == current) return;
try {
loading.value = true;
if (value != null) {
final messenger = ScaffoldMessenger.of(
context,
);
if (!await ref
.read(
NotificationController
.provider
.notifier,
)
.requestPermissions()) {
messenger.showSnackBar(
.new(
content: Text(
"Notifications are disabled. Enable them for Nexus in your system settings.",
),
),
);
return;
}
await ref
.read(
UnifiedPushController
.provider
.notifier,
)
.register(value);
} else {
await ref
.read(
UnifiedPushController
.provider
.notifier,
)
.deregister();
}
} catch (error, stackTrace) {
showError(error, stackTrace);
} finally {
loading.value = false;
}
},
orElse: () => null,
)
: null,
);
},
),
icon: Icons.notification_add,
),
]),
),
.new(
title: "Safety",
icon: Icons.gpp_good,
@ -111,7 +420,7 @@ class SettingsSectionsController
icon: Icon(icon),
title: title,
subtitle: Text(description),
initialValue: ref
value: ref
.watch(AccountDataController.provider)
.invitePermissionConfig
.defaultAction,
@ -132,7 +441,7 @@ class SettingsSectionsController
onChanged: specVersionsResponse.maybeWhen(
data: (_) =>
(value) => ref
.watch(ClientController.provider.notifier)
.read(ClientController.provider.notifier)
.setAccountData(
.new(
type: AccountData.invitePermissionConfigKey,
@ -155,6 +464,21 @@ class SettingsSectionsController
title: "Other",
icon: Icons.key,
settings: .new([
.new(
title: "Reset Encryption Identity",
description: "Replace your encryption identity and recovery key with new ones, if you've lost your recovery key or your encryption is broken.",
builder: (title, description, icon) => Builder(
builder: (context) => M3EButton.icon(
onPressed: () => context.showAdaptiveSheet(
(_) => const ResetEncryptionSheet(),
),
label: Text(title),
icon: Icon(icon),
tooltip: description,
),
),
icon: Icons.key_off,
),
.new(
title: "Log Out",
description:
@ -164,14 +488,21 @@ class SettingsSectionsController
final colorScheme = Theme.of(context).colorScheme;
return M3EButton.icon(
onPressed: () async {
Navigator.of(context)
.popUntil((route) => route.isFirst);
final container = ProviderScope.containerOf(context);
await WidgetsBinding.instance.endOfFrame;
final route = ModalRoute.of(context)!;
Navigator.of(context).popUntil((r) => r.isFirst);
await route.completed;
await ref
.watch(ClientController.provider.notifier)
.logout();
await container
.read(UnifiedPushController.provider.notifier)
.deregister()
.onError(showError);
container.invalidate(InitCompleteController.provider);
await container
.read(ClientController.provider.notifier)
.logout()
.onError(showError);
},
label: Text(title),
icon: Icon(icon),

View file

@ -1,12 +1,12 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:shared_preferences/shared_preferences.dart";
class SharedPrefsController extends AsyncNotifier<SharedPreferences> {
class SharedPrefsController extends Notifier<SharedPreferencesAsync> {
@override
Future<SharedPreferences> build() async => .getInstance();
SharedPreferencesAsync build() => SharedPreferencesAsync();
static final provider =
AsyncNotifierProvider<SharedPrefsController, SharedPreferences>(
NotifierProvider<SharedPrefsController, SharedPreferencesAsync>(
SharedPrefsController.new,
);
}

View file

@ -0,0 +1,13 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/models/sidebar_page.dart";
class SidebarPageController extends Notifier<SidebarPage> {
@override
SidebarPage build() => .rooms;
void set(SidebarPage page) => state = page;
static final provider = NotifierProvider<SidebarPageController, SidebarPage>(
SidebarPageController.new,
);
}

View file

@ -0,0 +1,34 @@
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/models/space_hierarchy.dart";
class SpaceHierarchyController(final String spaceId)
extends AsyncNotifier<SpaceHierarchy> {
@override
Future<SpaceHierarchy> build() => ref
.read(ClientController.provider.notifier)
.getSpaceHierarchy(.new(roomId: spaceId, maxDepth: 1));
Future<void> loadMore() async {
final current = await future;
if (current.nextBatch == null) return;
final next = await ref
.read(ClientController.provider.notifier)
.getSpaceHierarchy(
.new(roomId: spaceId, maxDepth: 1, from: current.nextBatch),
);
state = AsyncData(
current.copyWith(
rooms: current.rooms.addAll(next.rooms),
nextBatch: next.nextBatch,
),
);
}
static final provider = AsyncNotifierProvider.family
.autoDispose<SpaceHierarchyController, SpaceHierarchy, String>(
SpaceHierarchyController.new,
);
}

View file

@ -4,87 +4,90 @@ import "package:material_ui/material_ui.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/account_data.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/settings.dart";
import "package:nexus/controllers/top_level_spaces.dart";
import "package:nexus/controllers/space_edges.dart";
import "package:nexus/models/room.dart";
import "package:nexus/models/room_metadata.dart";
import "package:nexus/models/space.dart";
import "package:nexus/models/subspace.dart";
class SpacesController extends Notifier<IList<Space>> {
class SpacesController extends AsyncNotifier<IList<Space>> {
@override
IList<Space> build() {
Future<IList<Space>> build() async {
final rooms = ref.watch(RoomsController.provider);
final topLevelSpaceIds = ref.watch(TopLevelSpacesController.provider);
final spaceEdges = ref.watch(SpaceEdgesController.provider);
final accountData = ref.watch(AccountDataController.provider);
final childrenById = {
for (final entry in spaceEdges.entries)
entry.key: entry.value.map((e) => e.childId).toList(),
};
final childrenById = spaceEdges.map(
(spaceId, edges) =>
MapEntry(spaceId, edges.map((edge) => edge.childId).toList()),
);
Set<String> collectDescendants(String startId) {
final visited = <String>{};
final stack = [startId];
while (stack.isNotEmpty) {
final current = stack.removeLast();
final children = childrenById[current] ?? const [];
for (final child in children) {
if (visited.add(child)) {
stack.add(child);
}
}
}
return visited;
}
/// Everything under [spaceId], however deep, skipping any already
/// [visited] so cycles end.
ISet<String> collectDescendants(
String spaceId, [
ISet<String> visited = const .empty(),
]) => (childrenById[spaceId] ?? const []).fold(
visited,
(visited, childId) => visited.contains(childId)
? visited
: collectDescendants(childId, visited.add(childId)),
);
Space buildSpace(String spaceId) {
final space = rooms[spaceId];
final directChildrenIds = childrenById[spaceId] ?? const [];
final directRooms = <Room>[];
final subSpaces = <Subspace>[];
for (final childId in directChildrenIds) {
final room = rooms[childId];
if (room == null) continue;
if (childrenById.containsKey(childId)) {
final descendants = collectDescendants(childId);
subSpaces.add(
.new(
room: room,
children: .new(descendants.map((id) => rooms[id]).nonNulls),
),
);
} else {
directRooms.add(room);
}
}
return .new(
id: spaceId,
room: space,
title: space?.metadata?.name ?? "Unnamed Space",
children: .new(directRooms),
subSpaces: .new(subSpaces),
children: .new(
directChildrenIds
.where((childId) => !childrenById.containsKey(childId))
.map((childId) => rooms[childId])
.nonNulls,
),
subSpaces: .new(
directChildrenIds
.where(childrenById.containsKey)
.map(
(childId) => switch (rooms[childId]) {
final room? => Subspace(
room: room,
children: .new(
collectDescendants(childId)
.map((id) => rooms[id])
.nonNulls,
),
),
null => null,
},
)
.nonNulls,
),
);
}
final spaces = topLevelSpaceIds.map(buildSpace).toIList();
final spaces = topLevelSpaceIds
.where(rooms.containsKey)
.map(buildSpace)
.toIList();
final usedRoomIds = {
for (final space in spaces) ...[
...space.children.map((r) => r.metadata?.id),
...space.subSpaces.expand((s) => s.children.map((r) => r.metadata?.id)),
],
}.nonNulls.toISet();
final usedRooms = spaces
.expand(
(space) => [
...space.children,
...space.subSpaces.expand((s) => s.children),
],
)
.toISet();
final usedRoomIds = usedRooms.map((room) => room.metadata?.id).toSet();
final directMessages = accountData.directMessages.values.flattened;
final directMessages = accountData.directMessages.values.flattened.toSet();
final otherRooms = rooms.entries
.where(
@ -97,6 +100,15 @@ class SpacesController extends Notifier<IList<Space>> {
.toIList();
final homeRooms = otherRooms
.addAll(
await ref.watch(
SettingsController.provider.selectAsync(
(data) => data.showAllInHome,
),
)
? usedRooms
: .empty(),
)
.where((r) => !directMessages.contains(r.metadata?.id))
.toIList();
@ -122,28 +134,38 @@ class SpacesController extends Notifier<IList<Space>> {
...spaces,
];
final roomOrder = await ref.watch(
SettingsController.provider.selectAsync((data) => data.roomOrder),
);
// Mentioned -> Unread -> Other
int unreadPriority(Room room) => switch (room.metadata) {
RoomMetadata(unreadHighlights: > 0) => 0,
RoomMetadata(unreadNotifications: > 0) ||
RoomMetadata(unreadMessages: > 0) => 1,
_ => 2,
};
return allSpaces
.map(
(space) => space.copyWith(
children: .new(
space.children
.sortedBy(
(element) =>
element
.metadata
?.sortingTimestamp
.millisecondsSinceEpoch ??
0,
)
.sortedBy((room) => room.metadata?.unreadMessages ?? 0)
.reversed,
),
children: space.children.sort((a, b) {
if (roomOrder == .unreadFirst) {
final byUnread = unreadPriority(a).compareTo(unreadPriority(b));
if (byUnread != 0) return byUnread;
}
// Most recent activity first
return (b.metadata?.sortingTimestamp ?? DateTime(0)).compareTo(
a.metadata?.sortingTimestamp ?? DateTime(0),
);
}),
),
)
.toIList();
}
static final provider = NotifierProvider<SpacesController, IList<Space>>(
static final provider = AsyncNotifierProvider<SpacesController, IList<Space>>(
SpacesController.new,
);
}

View file

@ -5,7 +5,7 @@ import "package:nexus/models/spec_versions_response.dart";
class SpecVersionsController extends AsyncNotifier<SpecVersionsResponse> {
@override
Future<SpecVersionsResponse> build() =>
ref.watch(ClientController.provider.notifier).getSpecVersions();
ref.read(ClientController.provider.notifier).getSpecVersions();
static final provider =
AsyncNotifierProvider.autoDispose<

View file

@ -0,0 +1,17 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:timezone/data/latest_10y.dart";
import "package:timezone/timezone.dart";
/// The names of all IANA timezones, e.g. `Europe/London`, alphabetically.
class TimezonesController extends Notifier<IList<String>> {
@override
IList<String> build() {
initializeTimeZones();
return timeZoneDatabase.locations.keys.toIList().sort();
}
static final provider = NotifierProvider<TimezonesController, IList<String>>(
TimezonesController.new,
);
}

View file

@ -0,0 +1,15 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/models/typing.dart";
class TypingController extends Notifier<IMap<String, IList<String>>> {
@override
IMap<String, IList<String>> build() => .new();
void set(Typing typing) => state = state.add(typing.roomId, typing.userIds);
static final provider =
NotifierProvider<TypingController, IMap<String, IList<String>>>(
TypingController.new,
);
}

View file

@ -0,0 +1,260 @@
import "dart:async";
import "dart:convert";
import "dart:io";
import "package:flutter/foundation.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:intl/intl.dart";
import "package:nexus/controllers/selected_room.dart";
import "package:nexus/controllers/notification.dart";
import "package:nexus/controllers/push_key.dart";
import "package:nexus/main.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/gomuks_listener.dart";
import "package:unifiedpush/unifiedpush.dart";
import "package:unifiedpush_storage_shared_preferences/storage.dart";
import "package:window_manager/window_manager.dart";
class UnifiedPushController extends AsyncNotifier<String?> {
Completer<void>? _endpoint;
@override
Future<String?> build() async {
if (!Platform.isLinux && !Platform.isAndroid) return null;
final registered = await UnifiedPush.initialize(
linuxOptions: .new(
dbusName: "nexus.federated.nexus.UnifiedPush",
storage: UnifiedPushStorageSharedPreferences(),
background: isInBackground,
shouldWriteService: false,
),
onNewEndpoint: (endpoint, instance) async {
final task = Future(() async {
final pushKey = endpoint.pubKeySet!.pubKey;
await ref
.read(PushKeyController.provider(instance).notifier)
.set(pushKey);
await ref
.read(ClientController.provider.notifier)
.registerPusher(
.new(
appDisplayName: "Nexus",
appId: "nexus.federated.nexus",
data: .webPush(
url: .parse(endpoint.url),
auth: endpoint.pubKeySet!.auth,
),
deviceDisplayName:
"Nexus on ${toBeginningOfSentenceCase(Platform.operatingSystem)}",
kind: .webPush,
lang: "en",
pushKey: pushKey,
),
);
state = .data(await UnifiedPush.getDistributor());
});
if (_endpoint case final completer? when !completer.isCompleted) {
completer.complete(task);
} else {
await task;
}
},
onRegistrationFailed: (reason, instance) {
if (_endpoint case final completer? when !completer.isCompleted) {
completer.completeError(reason);
} else {
throw reason;
}
},
onMessage: (message, instance) async {
debugPrint("UP message received for $instance");
if (await deregisterIfDisabled(instance)) {
if (isInBackground) exit(0);
return;
}
if (message.decrypted == false) {
throw Exception(
"Failed to decrypt notification. Try toggling off and on UnifiedPush in settings.",
);
}
final notification = await ref
.read(ClientController.provider.notifier)
.handlePush(json.decode(String.fromCharCodes(message.content)));
if (notification == null ||
(!isInBackground &&
(!Platform.isAndroid &&
(WindowManager.instance.getCurrent()?.isFocused ?? true)) &&
ref.read(SelectedRoomController.provider) ==
notification.roomId)) {
if (isInBackground) exit(0);
return;
}
final client = ref.read(ClientController.provider.notifier);
final icon = notification.roomAvatar == null
? null
: await client.downloadMedia(
.new(mxc: notification.roomAvatar!, isAvatar: true),
);
final pmp = notification.perMessageProfile;
final senderAvatarUrl = pmp?.avatar ?? notification.sender.avatar;
// Only Android can show the sender's avatar
final senderAvatar = senderAvatarUrl == null || !Platform.isAndroid
? null
: await client.downloadMedia(
.new(mxc: senderAvatarUrl, isAvatar: true),
);
await ref
.read(NotificationController.provider.notifier)
.send(
// Android stacks messages into one conversation per room
id:
(Platform.isAndroid
? notification.roomId
: notification.eventId)
.hashCode &
0x7fffffff,
threadId: notification.roomId,
title: notification.roomName,
body: notification.text,
payload: notification.eventRowId.toString(),
timestamp: notification.timestamp,
icon: icon,
sound: notification.sound,
message: (
sender: pmp == null
? notification.sender
: pmp.copyWith(id: "${notification.sender.id}/${pmp.id}"),
self: notification.self,
senderAvatar: senderAvatar,
isDm: notification.isDm,
),
);
if (isInBackground) exit(0);
},
onUnregistered: deregister,
);
if (!registered || isInBackground) return null;
try {
if (await deregisterIfDisabled()) return null;
return await register();
} catch (error, stackTrace) {
showError(error, stackTrace);
return null;
}
}
/// Registers with [distributor], or the current/default distributor if none
/// is given. Returns the distributor that was registered with.
Future<String?> register([String? distributor]) async {
if (distributor != null &&
state.value != null &&
state.value != distributor) {
await deregister();
}
state = .loading();
try {
final deviceId = await ref
.read(GomuksListenerController.provider.notifier)
.deviceId();
await Future(() async {
final capabilities = await ref
.read(ClientController.provider.notifier)
.getCapabilities();
if (capabilities.webpush?.vapid == null) {
throw UnsupportedError(
"Your homeserver does not support MSC4174 (Web Push), and therefore cannot send notifications to Nexus.",
);
}
if (distributor != null) {
await UnifiedPush.saveDistributor(distributor);
} else if (!await UnifiedPush.tryUseCurrentOrDefaultDistributor()) {
throw Exception("No UnifiedPush distributors found.");
}
final endpoint = _endpoint = Completer<void>();
try {
await Future.wait([
UnifiedPush.register(
instance: deviceId,
vapid: capabilities.webpush?.vapid,
),
endpoint.future,
], eagerError: true);
} finally {
if (_endpoint == endpoint) _endpoint = null;
}
}).timeout(
.new(seconds: 10),
onTimeout: () => throw Exception("UnifiedPush registration timed out."),
);
final current = await UnifiedPush.getDistributor();
state = .data(current);
return current;
} catch (_) {
state = .data(null);
rethrow;
}
}
Future<bool> deregisterIfDisabled([String? notificationInstance]) async {
if (await ref
.read(NotificationController.provider.notifier)
.permissionsGranted()) {
return false;
}
final instance =
notificationInstance ??
await ref.read(GomuksListenerController.provider.notifier).deviceId();
final key = await ref.read(PushKeyController.provider(instance).future);
if (key != null) await deregister(instance);
return true;
}
Future<void> deregister([String? instance]) async {
instance ??= await ref
.read(GomuksListenerController.provider.notifier)
.deviceId();
final keyProvider = PushKeyController.provider(instance);
final key = await ref.read(keyProvider.future);
if (key != null) {
await ref
.read(ClientController.provider.notifier)
.deregisterPusher(.new(appId: "nexus.federated.nexus", pushKey: key));
await ref.read(keyProvider.notifier).set(null);
} else {
debugPrint(
"No matching pushKey found. Skipping deregistration from homeserver.",
);
}
await UnifiedPush.unregister(instance);
state = .data(null);
}
static final provider = AsyncNotifierProvider<UnifiedPushController, String?>(
UnifiedPushController.new,
);
}

View file

@ -0,0 +1,37 @@
import "dart:io";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/unified_push_distributors.dart";
class UnifiedPushAllowedController extends AsyncNotifier<String?> {
@override
Future<String?> build() async {
final distributors = await ref.watch(
UnifiedPushDistributorsController.provider.future,
);
if (distributors.isEmpty) {
return "No valid distributors found. ${Platform.isLinux
? "Try installing KUnifiedPush"
: Platform.isAndroid
? "Try installing Google Play Services or NTFY"
: "Your platform is not currently supported by UnifiedPush"}.";
}
final capabilities = await ref
.read(ClientController.provider.notifier)
.getCapabilities();
if (capabilities.webpush?.vapid == null) {
return "Your homeserver does not support MSC4174 (Web Push), and therefore cannot send notifications to Nexus.";
}
return null;
}
static final provider =
AsyncNotifierProvider.autoDispose<UnifiedPushAllowedController, String?>(
UnifiedPushAllowedController.new,
);
}

View file

@ -0,0 +1,26 @@
import "dart:io";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:unifiedpush/unifiedpush.dart";
class UnifiedPushDistributorsController extends AsyncNotifier<IList<String>> {
@override
Future<IList<String>> build() async {
if (!Platform.isLinux && !Platform.isAndroid) return .new();
return IList(
await UnifiedPush.getDistributors().timeout(
.new(seconds: 10),
onTimeout: () =>
throw Exception("UnifiedPush distributor check timed out."),
),
);
}
static final provider =
AsyncNotifierProvider.autoDispose<
UnifiedPushDistributorsController,
IList<String>
>(UnifiedPushDistributorsController.new);
}

View file

@ -11,7 +11,7 @@ class UrlPreviewController(final Uri url)
try {
return await ref
.watch(ClientController.provider.notifier)
.read(ClientController.provider.notifier)
.getUrlPreview(url);
} catch (error, stackTrace) {
debugPrintStack(label: error.toString(), stackTrace: stackTrace);

View file

@ -1,4 +1,5 @@
import "dart:async";
import "package:collection/collection.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/members.dart";

View file

@ -1,53 +1,48 @@
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_state.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/membership.dart";
import "package:nexus/models/content/power_levels.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/room.dart";
class ViaController(final Room room) extends Notifier<String> {
@override
String build() {
final servers = <String>{};
final powerLevelUsers =
switch (room.state[EventType.powerLevels.type]?[""]) {
final rowId? => switch (room.events[rowId]?.content) {
PowerLevelsContent(:final users) => users.keys.toIList(),
_ => null,
},
null => null,
} ??
.new([]);
void addUserId(String? userId) {
final server = userId?.split(":").lastOrNull;
if (server != null) {
servers.add(server);
}
}
final joinedMembers =
(room.state[EventType.membership.type]?.values ?? const [])
.map(
(rowId) => switch (room.events[rowId]) {
Event(
stateKey: final userId?,
content: MembershipContent(status: .join),
) =>
userId,
_ => null,
},
)
.nonNulls
.toIList();
addUserId(ref.watch(ClientStateController.provider)?.userId);
final powerLevelsEventId = room.state[EventType.powerLevels.type]?[""];
final powerLevels = powerLevelsEventId == null
? null
: room.events[powerLevelsEventId];
if (powerLevels?.content case PowerLevelsContent(:final users)) {
for (final userId in users.keys) {
addUserId(userId);
if (servers.length >= 5) break;
}
}
final members = room.state[EventType.membership.type]?.values.toIList();
for (var i = 0; servers.length < 5; i++) {
final membershipEventId = members?.getOrNull(i);
final member = membershipEventId == null
? null
: room.events[membershipEventId];
if (member?.content case MembershipContent(:final status)) {
if (status == .join) {
addUserId(member?.stateKey);
}
}
if (members?.getOrNull(i) == null) break;
}
final servers = [?ref.watch(ClientStateController.provider)?.userId]
.toIList()
.addAll(powerLevelUsers)
.addAll(joinedMembers)
.map((userId) => userId.substring(userId.indexOf(":") + 1))
.whereNoDuplicates()
.take(5)
.toISet();
return servers.isEmpty
? ""

View file

@ -8,10 +8,12 @@ extension BetterWhen<T> on AsyncValue<T> {
required Widget Function(T value) data,
Widget Function() loading = Loading.new,
bool skipLoadingOnRefresh = false,
bool skipLoadingOnReload = false,
}) => when(
data: data,
error: (error, stackTrace) => ErrorDialog(error, stackTrace),
loading: loading,
skipLoadingOnRefresh: skipLoadingOnRefresh,
skipLoadingOnReload: skipLoadingOnReload,
);
}

View file

@ -0,0 +1,295 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/widgets/sheet_layout.dart";
import "package:flutter/services.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/pinned_ids.dart";
import "package:nexus/controllers/power_level.dart";
import "package:nexus/controllers/recent_emoji.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/room_chat.dart";
import "package:nexus/controllers/via.dart";
import "package:nexus/helpers/extensions/event_id_is_fake.dart";
import "package:nexus/helpers/extensions/is_desktop.dart";
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
import "package:nexus/models/content/message.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/relation_type.dart";
import "package:nexus/widgets/emoji_picker.dart";
import "package:nexus/widgets/mxc_emoji.dart";
import "package:nexus/main.dart";
extension BuildEventOptions on Event {
IList<PopupMenuEntry> buildEventOptions({
required BuildContext context,
required WidgetRef ref,
required String roomId,
required String userId,
required void Function(int, RelationType) onRelation,
}) {
final theme = Theme.of(context);
final danger = theme.colorScheme.error;
final isDesktop = context.isDesktop;
final notifier = RoomChatController.provider((
roomId: roomId,
contextualEvent: null,
)).notifier;
final isPinned = ref
.read(PinnedIdsController.provider(roomId))
.contains(eventId);
void showReasonSheet({
required String title,
required String description,
required String action,
required Future<void> Function(String reason) onConfirm,
}) {
context.showAdaptiveSheet(
(context) => HookBuilder(
builder: (context) {
final reasonController = useTextEditingController();
return SheetLayout(
title: Text(title),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: const Text("Cancel"),
),
TextButton(
onPressed: () {
final reason = reasonController.text;
Navigator.of(context).pop();
onConfirm(reason).onError(showError);
},
child: Text(action),
),
],
children: [
Text(description),
TextField(
controller: reasonController,
textCapitalization: .sentences,
decoration: .new(labelText: "Reason (optional)"),
),
],
);
},
),
);
}
return .new([
if (ref.read(
PowerLevelController.provider(
.new(eventType: .reaction, roomId: roomId),
),
))
PopupMenuItem(
enabled: false,
child: IconTheme(
data: theme.iconTheme,
child: Row(
mainAxisAlignment: isDesktop ? .center : .spaceEvenly,
children: [
...{
// Custom emoji are sent without their shortcode, as only
// their mxc URL is recorded
...ref
.read(RecentEmojiController.provider)
.map((entry) => entry.emoji),
"👍",
"🤣",
"😭",
"🤔",
"❤️",
}
.take(isDesktop ? 3 : 5)
.map(
(emoji) => IconButton(
icon: emoji.startsWith("mxc://")
? SizedBox.square(
dimension: isDesktop ? 18 : 24,
child: MxcEmoji(Uri.parse(emoji)),
)
: Text(
emoji,
style: .new(fontSize: isDesktop ? null : 18),
),
onPressed: () async {
Navigator.of(context).pop();
await ref
.read(notifier)
.sendReaction(emoji, this)
.onError(showError);
await ref
.read(RecentEmojiController.provider.notifier)
.add(emoji)
.onError(showError);
},
),
),
IconButton(
icon: const Icon(Icons.emoji_emotions),
onPressed: () {
Navigator.of(context).pop();
context.showAdaptiveSheet(
(context) => EmojiPicker(
roomId: roomId,
allowFreeText: true,
onSelection: (emoji, {shortcode}) {
Navigator.of(context).pop();
ref
.read(notifier)
.sendReaction(emoji, this, shortcode: shortcode)
.onError(showError);
},
),
);
},
),
],
),
),
),
if (ref.read(
PowerLevelController.provider(
.new(eventType: .message, roomId: roomId),
),
))
PopupMenuItem(
onTap: () => onRelation(rowId, .reply),
child: const ListTile(
leading: Icon(Icons.reply),
title: Text("Reply"),
),
),
if (content is MessageContent && sender == userId)
PopupMenuItem(
onTap: () => onRelation(rowId, .edit),
child: const ListTile(leading: Icon(Icons.edit), title: Text("Edit")),
),
if (ref.read(
PowerLevelController.provider(
.state(eventType: .pinnedEvents, roomId: roomId),
),
))
PopupMenuItem(
onTap: () async {
try {
final pins = ref.read(
PinnedIdsController.provider(roomId).notifier,
);
if (isPinned) {
await pins.removePin(eventId);
} else {
await pins.addPin(eventId);
}
} catch (error, stackTrace) {
showError(error, stackTrace);
}
},
child: ListTile(
leading: const Icon(Icons.push_pin),
title: Text(isPinned ? "Unpin Event" : "Pin Event"),
),
),
PopupMenuItem(
onTap: () async {
final room = ref.read(
RoomsController.provider.select((rooms) => rooms[roomId]),
);
if (room == null) return;
final vias = ref.read(ViaController.provider(room));
await Clipboard.setData(
ClipboardData(
text:
"matrix:roomid/${room.metadata?.id.substring(1)}/e/${eventId.substring(1)}$vias",
),
);
},
child: const ListTile(
leading: Icon(Icons.link),
title: Text("Copy Link"),
),
),
if (content case MessageContent(:final body?))
PopupMenuItem(
onTap: () => Clipboard.setData(ClipboardData(text: body)),
child: const ListTile(
leading: Icon(Icons.copy),
title: Text("Copy Text"),
),
),
if (ref.read(
PowerLevelController.provider(
.redaction(targetUser: sender, roomId: roomId),
),
) &&
!eventId.isFake)
PopupMenuItem(
onTap: () => showReasonSheet(
title: "Delete Message",
description:
"Are you sure you want to delete this message? "
"This cannot be reversed.",
action: "Delete",
onConfirm: (reason) =>
ref.read(notifier).deleteMessage(this, reason: reason),
),
child: ListTile(
leading: Icon(Icons.delete, color: danger),
title: Text("Delete", style: .new(color: danger)),
),
),
if (eventId.isFake)
PopupMenuItem(
onTap: () => ref.read(notifier).hideClientSticky(rowId),
child: ListTile(
leading: Icon(Icons.delete, color: danger),
title: Text("Hide", style: .new(color: danger)),
),
),
PopupMenuItem(
onTap: () => showReasonSheet(
title: "Report",
description:
"Report this event to your server administrators, "
"who can take action like banning this server or room.",
action: "Report",
onConfirm: (reason) => ref
.read(ClientController.provider.notifier)
.reportEvent(
.new(
roomId: roomId,
eventId: eventId,
reason: reason.isEmpty ? null : reason,
),
),
),
child: ListTile(
leading: Icon(Icons.report, color: danger),
title: Text("Report", style: .new(color: danger)),
),
),
]);
}
}

View file

@ -0,0 +1,111 @@
import "dart:async";
import "package:collection/collection.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/sidebar_page.dart";
import "package:nexus/controllers/spaces.dart";
import "package:nexus/helpers/extensions/focus_room.dart";
import "package:nexus/helpers/extensions/get_all_child_rooms.dart";
import "package:nexus/helpers/extensions/with_progress_snackbar.dart";
import "package:nexus/models/requests/create_room.dart";
extension CreateRoom on BuildContext {
/// Creates a room, showing progress and errors in snackbars, and opens it
/// once it has synced. [request] may be a future, so any lookups needed to
/// build it happen while the progress snackbar is shown.
///
/// Returns the created room's ID, or null if creating it failed.
Future<String?> createRoom(
CreateRoomRequest request, {
required String name,
}) async {
final container = ProviderScope.containerOf(this);
final messenger = ScaffoldMessenger.of(this);
final result = await withProgressSnackbar("Creating $name...", () async {
final roomId = await container
.read(ClientController.provider.notifier)
.createRoom(request);
// Wait for the room to show up in the room list (under DMs, if it's
// one) before opening it.
final synced = Completer<bool>();
final subscription = container.listen(
SpacesController.provider.select(
(async) =>
async.value?.any(
(space) =>
space.id == roomId ||
((!request.isDirect || space.id == "dms") &&
space.allChildRooms.any(
(room) => room.metadata?.id == roomId,
)),
) ??
false,
),
(_, found) {
if (found && !synced.isCompleted) synced.complete(true);
},
fireImmediately: true,
);
return (
roomId,
await synced.future
.timeout(const .new(seconds: 15), onTimeout: () => false)
.whenComplete(subscription.close),
);
});
if (result == null) return null;
final (roomId, synced) = result;
if (synced && await container.focusRoom(roomId)) {
container.read(SidebarPageController.provider.notifier).set(.rooms);
} else {
messenger.showSnackBar(
.new(
content: Text("Created $name, but it hasn't finished syncing yet."),
),
);
}
return roomId;
}
/// Opens the existing DM with [userId], or creates one if there isn't one.
Future<void> startDirectMessage(
String userId, {
required String name,
bool encrypted = true,
String? inviteReason,
}) async {
final container = ProviderScope.containerOf(this);
final existing = container
.read(RoomsController.provider)
.values
.firstWhereOrNull((room) => room.metadata?.dmUserId == userId)
?.metadata
?.id;
if (existing != null) {
if (await container.focusRoom(existing)) {
container.read(SidebarPageController.provider.notifier).set(.rooms);
}
return;
}
await createRoom(
.new(
isDirect: true,
preset: .trustedPrivateChat,
invite: .new([userId]),
inviteReason: inviteReason,
initialState: encrypted ? .new([.encryption]) : const .empty(),
),
name: "a DM with $name",
);
}
}

View file

@ -0,0 +1,38 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/room_power_levels.dart";
import "package:nexus/controllers/settings.dart";
import "package:nexus/helpers/extensions/string_to_color.dart";
extension DisplaynameColor on WidgetRef {
/// The color of [userId]'s name in [roomId], from their power level
/// (so everyone with the same level shares one) or their user ID, depending
/// on the user's settings.
Color displaynameColor(String roomId, String userId) => switch (watch(
SettingsController.provider.select((settings) => settings.value?.nameColor),
)) {
.user => userId.colorHash,
.powerLevel || null => powerLevelColor(
watch(
RoomPowerLevelsController.provider(roomId)
.select((levels) => levels.levelOf(userId)),
),
),
};
/// Like [displaynameColor], for when [userId]'s [level] is already known,
/// like a level they're being given.
Color displaynameColorAt(String userId, double level) => switch (watch(
SettingsController.provider.select((settings) => settings.value?.nameColor),
)) {
.user => userId.colorHash,
.powerLevel || null => powerLevelColor(level),
};
static Color powerLevelColor(double level) => HSLColor.fromAHSL(
1,
level.isInfinite ? 30 : (210 + level / 50 * 137.50776) % 360,
0.7,
0.5,
).toColor();
}

View file

@ -0,0 +1,5 @@
extension DistributorName on String {
String get distributorName => startsWith("nexus.federated.nexus")
? "Google Play Services"
: split(".").last;
}

View file

@ -0,0 +1,3 @@
extension EventIdIsFake on String {
bool get isFake => startsWith("~") || startsWith("\$gomuks");
}

View file

@ -0,0 +1,46 @@
import "package:collection/collection.dart";
import "package:flutter_riverpod/experimental/mutation.dart";
import "package:nexus/controllers/jump_request.dart";
import "package:nexus/controllers/key.dart";
import "package:nexus/controllers/spaces.dart";
import "package:nexus/models/space.dart";
extension FocusRoom on MutationTarget {
Future<bool> focusRoom(String roomId, [int? eventRowId]) async {
final spaces = await container.read(SpacesController.provider.future);
if (spaces.firstWhereOrNull((space) => space.id == roomId) case Space _?) {
await container
.read(KeyController.provider(KeyController.spaceKey).notifier)
.set(roomId);
return true;
}
final parent = spaces.firstWhereOrNull(
(space) =>
space.children.any((room) => room.metadata?.id == roomId) ||
space.subSpaces.any(
(sub) =>
sub.room.metadata?.id == roomId ||
sub.children.any((room) => room.metadata?.id == roomId),
),
);
if (parent == null) return false;
await container
.read(KeyController.provider(KeyController.spaceKey).notifier)
.set(parent.id);
if (eventRowId != null) {
container
.read(JumpRequestController.provider.notifier)
.request(roomId, eventRowId);
}
await container
.read(KeyController.provider(KeyController.roomKey).notifier)
.set(roomId);
return true;
}
}

View file

@ -0,0 +1,8 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:nexus/models/room.dart";
import "package:nexus/models/space.dart";
extension GetAllChildRooms on Space {
IList<Room> get allChildRooms =>
children.addAll(subSpaces.expand((s) => s.children));
}

Some files were not shown because too many files have changed in this diff Show more