Compare commits

..

348 Commits
10.rc ... main

Author SHA1 Message Date
Kévin Commaille
3f611f7ca2
message-toolbar: Use the sender of the tombstone event for routing the successor room
We shouldn't use the server name in the room ID, it is supposed to be an
opaque string and the `!localpart:server_name` format might change.
2025-06-21 13:47:34 +02:00
Baxrom Raxmatov
aaf145feb7 Add Uzbek (Latin) translation 2025-06-21 09:02:09 +00:00
David Akopov
3a6628adea
sidebar-section: signal aggregate notification count & highlight changes
Add notification-count, highligh and has-notifications properties to the
sidebar section model and signal on corresponding changes.
Aggregation is done as follows:
  is-read := conjunction_over_all_rooms_in_section(is-read)
  highligh := union_over_rooms_in_section(highligh)
  has-notification := any_over_rooms(has-notifications)
  notification-count := sum_over_rooms_in_section(notification-count)

Functionality of ExpressionList is reused on one of the intermediate
models to populate watches (internally on the underlying model) and signal
change of notification-count or highlight with the room indices.
This implementation recalculates the aggregate expressions via
naive iterations (discarding exactly which room(s)' values  were
changed).

UI state bits are reflected accordingly to consume the signalled changes.

Fixes: #1048
2025-06-20 21:31:33 +04:00
Kévin Commaille
a213ec8467
Release Fractal 11.2 2025-06-10 17:44:19 +02:00
Kévin Commaille
85bcfc74bc
Upgrade matrix-sdk
Use the newly released version.
2025-06-10 14:29:44 +02:00
Rafael Fontenelle
59c6e88311 Update Brazilian Portuguese translation 2025-06-08 01:18:31 +00:00
Luming Zh
37c8490abc Update Chinese (China) translation 2025-05-29 23:14:03 +00:00
Juliano de Souza Camargo
7f687871b7 Update Brazilian Portuguese translation 2025-05-29 12:19:26 +00:00
Yuri Chornoivan
da49563c35 Update Ukrainian translation 2025-05-28 19:34:41 +00:00
Martin
b31d74e8ba Update Slovenian translation 2025-05-28 18:11:52 +00:00
Kévin Commaille
d65fbacac6
room-history: Replace ratio character with colon in timestamps
Apparently using the ratio character can create issues for RTL languages.
2025-05-28 13:05:32 +02:00
Kévin Commaille
58ebb7e3bc
room-history: Add back support for redacted messages
We need it in case of replies to a redacted message.
2025-05-28 10:50:37 +02:00
Kévin Commaille
d025f24928
media-viewer: Allow to show a media without an event ID 2025-05-28 10:34:12 +02:00
Yuri Chornoivan
56a82d7cbf Update Ukrainian translation 2025-05-28 06:14:33 +00:00
Kévin Commaille
655676de7e
sidebar: Allow to mark a room as unread 2025-05-27 11:20:44 +02:00
Ekaterine Papava
44ea6d38ac Update Georgian translation 2025-05-25 01:59:45 +00:00
Luming Zh
e69bc3efaf Update Chinese (China) translation 2025-05-25 00:47:38 +00:00
Yuri Chornoivan
e903be2564 Update Ukrainian translation 2025-05-24 15:41:56 +00:00
Martin
b0dd62a90b Update Slovenian translation 2025-05-23 22:37:09 +00:00
Kévin Commaille
774f4b4690 ci: Fix cargo-sort check 2025-05-23 12:08:11 +00:00
Kévin Commaille
3388795df5 session: Synchronize the media previews safety settings with the Matrix account data
To share the setting between clients.
2025-05-23 12:08:11 +00:00
Kévin Commaille
59b6740ce5 account-settings: Refactor NotificationsPage 2025-05-23 12:08:11 +00:00
Kévin Commaille
4f573e54d4 Upgrade matrix-sdk 2025-05-23 12:08:11 +00:00
Luming Zh
134d271f68 Update Chinese (China) translation 2025-05-23 11:13:55 +00:00
Kévin Commaille
e1452fcda4
invite: Refactor and fix visibility 2025-05-21 12:41:29 +02:00
Kévin Commaille
edac9e3381
room-history: Make sure that mentions respect safety settings
Gets rid of a potential attack factor.
2025-05-21 12:25:04 +02:00
Kévin Commaille
366a602260
Upgrade matrix-sdk dependency 2025-05-20 11:11:28 +02:00
Álvaro Burns
45ee641a78 Update Brazilian Portuguese translation 2025-05-19 11:31:53 +00:00
Daniel Rusek
f7370be6a5 Update Czech translation 2025-05-19 11:26:23 +00:00
Kévin Commaille
2dd845f7e0
ci: Include flatpak template as a project rather than URL
It might get rid of the 429 errors.
2025-05-19 10:06:33 +02:00
Kévin Commaille
8ccb67cb8a
build: Enable frame pointers for Rust modules
It seems to be necessary to build the aarch64 flatpak in CI, and it
doesn't hurt anyway.
2025-05-19 09:55:35 +02:00
Kévin Commaille
0931854b98
Revert "ci: Allow failure of aarch64 jobs"
This reverts commit 69dde233e43179e4515df9c1875805af1642e082.
2025-05-19 09:50:05 +02:00
Sabri Ünal
9697badf22 Update Turkish translation 2025-05-18 16:44:26 +00:00
Martin
679c29069d Update Slovenian translation 2025-05-18 15:53:59 +00:00
Yuri Chornoivan
76b92815b0 Update Ukrainian translation 2025-05-18 15:23:58 +00:00
Kévin Commaille
e5ed751c1f
notifications: Fix notifications for invites
There was a race condition where we would not be able to show the invite
for a room because we didn't have the room yet in the room list.
2025-05-18 12:08:21 +02:00
Kévin Commaille
b84a584995
account-settings: Add safety setting to hide avatars in invites
For consistency, we also hide avatars for invites that were rejected or
retracted, if we manage to find that out for left rooms.
2025-05-18 11:26:02 +02:00
Kévin Commaille
6035b4ee37
misc: Fix new clippy warnings 2025-05-16 18:28:51 +02:00
Álvaro Burns
5bd1122a2b Update Brazilian Portuguese translation 2025-05-16 10:49:43 +00:00
Luming Zh
cc028458bd Update Chinese (China) translation 2025-05-16 09:06:43 +00:00
Kévin Commaille
b9757404e3
Release Fractal 11.1 2025-05-15 10:47:45 +02:00
Ekaterine Papava
ecc9eab92e Update Georgian translation 2025-05-15 03:44:46 +00:00
Martin
6649018303 Update Slovenian translation 2025-05-14 17:31:01 +00:00
Yuri Chornoivan
d7032bf945 Update Ukrainian translation 2025-05-14 13:03:27 +00:00
Kévin Commaille
e5f473d9b6
user-facing-error: Reduce the size of some strings that appear in toasts 2025-05-14 12:00:51 +02:00
Kévin Commaille
ec56f9471c
ci: Allow to interrupt most jobs 2025-05-13 15:15:34 +02:00
Yuri Chornoivan
65531c7870 Update Ukrainian translation 2025-05-13 12:47:33 +00:00
Kévin Commaille
ca387e3261
account-switcher: Make sure to unparent the popover when the button is disposed of
Avoids a warning that occurs sometimes when closing Fractal.
2025-05-13 12:18:54 +02:00
Kévin Commaille
4a5a4b4cde
room-history: Ignore empty inline elements when ellipsizing to a single line 2025-05-13 11:31:29 +02:00
Martin
94d2cdf81d Update Slovenian translation 2025-05-12 20:48:50 +00:00
Kévin Commaille
97e051235d
Upgrade matrix-sdk
And cargo update other crates.
2025-05-12 19:09:10 +02:00
Kévin Commaille
c85d6462b5
room-history: Update strings for creation event 2025-05-12 13:45:38 +02:00
Kévin Commaille
893f795228
room-history: Remove banner for tombstoned rooms
Instead we offer to join or view the new room instead of showing the
composer.

This also makes sure that the state of the Join/View button is updated
depending on the joining state of the successor.
2025-05-12 13:45:38 +02:00
Kévin Commaille
9f426e2c6d
message-toolbar: Fix style of text when user cannot send a message
Works better on narrow screens.
2025-05-12 13:45:38 +02:00
Kévin Commaille
8b3b816005
room-history: Do not show room tombstone event in history
There is already a banner for this so when both are visible it looks
weird to have a duplicate.
2025-05-12 13:45:38 +02:00
Kévin Commaille
41fb45cd90
explore: Fix and refactor
Use a GListStore for the list of rooms, and RemoteRoom to represent the
rooms.

Fix cleaning server popover when switching between sessions, and make
sure that triggering a search only happens when the view is mapped.
2025-05-12 12:28:46 +02:00
Kévin Commaille
793907383a room-preview-dialog: Update button according to current state of room
It was only updated depending on what happened in the dialog. Now it
also updates depending on what happens in the room list.
2025-05-11 09:35:27 +00:00
Kévin Commaille
76b3559dfe room-list: Rename room_list_metainfo file 2025-05-11 09:35:27 +00:00
Kévin Commaille
e51a8d73aa room-list: Rename pending rooms to joining rooms
"Pending" was not very clear about what it meant for a room to be in
that list.
2025-05-11 09:35:27 +00:00
Philipp Bartsch
8e2118dc67 media-viewer: halign the right-click context menu
Other right-click context menus are not centered on the cursor.
2025-05-10 14:25:58 +02:00
Kévin Commaille
cc1e231297
message-toolbar: Fix inserting mention before another mention 2025-05-09 10:19:55 +02:00
Kévin Commaille
7cf3823bea
room-history: Increase padding of SenderAvatar
Makes the hover effect around the avatar bigger, which makes it more
obvious that it behaves like a button.
2025-05-09 09:51:34 +02:00
Kévin Commaille
338c732e5f
event: Do not count unsent events as unread or as activity 2025-05-08 09:42:41 +02:00
Kévin Commaille
c646ca611b
account-settings: Fix renamed page name 2025-05-07 10:56:54 +02:00
Kévin Commaille
b79ddd453d
overlapping-avatars: Remove unnecessary debug logging 2025-05-07 10:28:51 +02:00
Kévin Commaille
5158e408d7
utils: Simplify truncate_end_whitespaces
Work with char_indices, it allows us to compute the index after the char
easily.
2025-05-07 10:25:34 +02:00
Kévin Commaille
a0aec0b176
room-history: Remove whitespaces before newlines in HTML messages 2025-05-07 10:25:00 +02:00
Kévin Commaille
0bf579e4b0
room-history: Fix jittery media during transition from loading to showing the media 2025-05-07 10:01:13 +02:00
Kévin Commaille
75d41ee146
overlapping-avatars: Use GtkSliceListModel to limit the number of avatars
That way we don't need our custom code to cap the number of avatars that
we present, which seems to have an error.
2025-05-06 18:03:19 +02:00
Kévin Commaille
d161a58424
ci: Don't build unnecessary Flatpak modules when they are not needed 2025-05-05 15:17:32 +02:00
Kévin Commaille
e76cf4097f utils: Re-enable event cache storage 2025-05-05 11:00:59 +00:00
Kévin Commaille
0092ae1e30 Upgrade matrix-sdk 2025-05-05 11:00:59 +00:00
Kévin Commaille
0ed68fd993 docs: Update Flathub instructions in RELEASING.md 2025-05-05 09:53:39 +00:00
Kévin Commaille
69dde233e4
ci: Allow failure of aarch64 jobs
There are issues currently with the only flatpak-arch64 CI runner that
prevents us from merging anything.

This means that the nightly aarch64 Flatpak will not be updated as long
as the problem is not fixed.
2025-05-05 11:25:10 +02:00
Kévin Commaille
7f3fe72503
camera: Fix crash in LinuxCamera::has_cameras()
`timeout` needs to be called in an async block to make the future "lazy",
so that it is executed on the tokio runtime.
2025-05-05 11:11:08 +02:00
Jiri Grönroos
0b5809b07d Update Finnish translation 2025-05-04 19:37:05 +00:00
Kévin Commaille
ebd7d029cf Release Fractal 11 2025-05-02 12:57:09 +00:00
Alexandre Franke
04c1df671d Update French translation 2025-05-01 09:04:40 +00:00
Kévin Commaille
862fc14744
room-history: Show focus ring around visual media 2025-04-28 15:53:20 +02:00
Kévin Commaille
eeff96e1db
room-history: Remove whitespaces at the beginning and end of HTML elements
To have the same behavior as in browsers.
2025-04-28 13:31:48 +02:00
Kévin Commaille
eea9e9122e
room-history: Be consistent about compact visual content activation
It was already not possible with the mouse, but there was still a hover
effect, and the key bindings where still working.
2025-04-28 12:27:23 +02:00
Kévin Commaille
537d31521b
utils: Replace remove_newlines with collapse_whitespace
In HTML, contiguous whitespaces must be replaced by a single space, not
removed.
2025-04-25 12:06:48 +02:00
Kévin Commaille
73989239b9
sidebar-data: Fix visibility 2025-04-25 10:30:08 +02:00
Kévin Commaille
67a12518bd
room-history: Add spaces instead of newlines in topic 2025-04-25 10:06:12 +02:00
Kévin Commaille
0cc9ed3ae9
user: Refactor and clean up 2025-04-25 09:50:46 +02:00
Kévin Commaille
72c44056b2
utils: Make data_dir_path a method of DataType
It reduces the necessary imports.
2025-04-25 09:20:44 +02:00
Kévin Commaille
5891f28548
utils: Remove timeout_future
Use the function provided by tokio instead.
2025-04-25 09:15:12 +02:00
Kévin Commaille
efe9189d76
session: Cache room and user profile in memory
Reduces the number of requests to the homeserver.
2025-04-24 15:31:00 +02:00
Kévin Commaille
108174e040
session: Sleep between failed sync attempts 2025-04-24 09:40:39 +02:00
Kévin Commaille
782889079f
room-history: Edit latest message when pressing the up key in an empty composer 2025-04-23 12:49:18 +02:00
Kévin Commaille
de4772bf1e
message-toolbar: Do not allow to send message with only whitespace 2025-04-23 12:49:18 +02:00
Kévin Commaille
30269e7dde
message-toolbar: Capture key presses even if CapsLock is enabled 2025-04-23 12:49:18 +02:00
Kévin Commaille
828fa2e91d window: Show button to open About dialog on error page 2025-04-23 08:30:28 +00:00
Artur S0
39cb8ca758 Update Russian translation 2025-04-22 15:20:15 +00:00
Kévin Commaille
d60117236f
resources: Update SVG to use Adwaita Sans font 2025-04-22 13:05:52 +02:00
Kévin Commaille
4b30e5f6f2
room-history: Add context menu action on invites to be able to revoke them 2025-04-22 12:44:20 +02:00
Kévin Commaille
22a6f63230
room-details: Refactor code for extra items in the list of joined members
Fixes bug where they would not appear anymore.
2025-04-22 11:43:16 +02:00
Kévin Commaille
b7b309f1d4
room-history: Only show timestamp when too much time has passed between events
Instead of the full header with name and avatar
2025-04-22 10:13:23 +02:00
Kévin Commaille
64bf58e2ea
timeline: Show event header again if too much time has passed since previous event 2025-04-21 14:17:10 +02:00
Kévin Commaille
5ca1b6a3e7
session-view: Do not center ScaleRevealer
It allows text to take as much space as it wants instead of its minimum
size.
2025-04-21 12:53:08 +02:00
Kévin Commaille
6813a4c98e
components: Reduce minimal height of PillSearchEntry
Since there have been measuring fixes in GTK and it doesn't grow
excessively anymore when we insert a pill.
2025-04-21 12:05:27 +02:00
Kévin Commaille
09e3ec218e
room-details: Rename invite-search-results class
Since it is not only used in the invite subpage.
2025-04-21 11:59:44 +02:00
Kévin Commaille
2122882f37
room-details: Make sure search entry is focused when opening subpage and it is the main widget 2025-04-21 11:55:01 +02:00
Kévin Commaille
21b5cd056e
room-history: Make sure there is enough space for overlays on small media 2025-04-21 10:04:48 +02:00
Kévin Commaille
97a528c5d3
account-settings: Always disconnect session via account management URL when it is available
A homeserver that supports the OAuth 2.0 API doesn't allow to disconnect
a session via the Matrix API, even if the session was connected before
the move to the OAuth 2.0 API.
2025-04-21 09:28:59 +02:00
Yuri Chornoivan
bca6fd7f67 Update Ukrainian translation 2025-04-20 12:53:57 +00:00
Kévin Commaille
5307cb1fe0
notifications: Truncate body if it is too long
This avoids performance issues with GNOME Shell.
2025-04-20 12:27:05 +02:00
Kévin Commaille
21b6e3b74b
room-history: Fix enabling and disabling copy-image action
We now track all the way down if there is currently a texture and
update whenever the texture is added/removed.

This prevents a race condition when the image is loaded before the child
is added to its parent and works with showing and hiding media previews
too.
2025-04-20 11:04:54 +02:00
Kévin Commaille
426b99ec73
utils: Add extension trait for reusing child widget
There would be less duplication if Rust allowed generic implementations
with different bounds. Oh well…
2025-04-20 09:18:58 +02:00
Ekaterine Papava
b5ef87f99f Update Georgian translation 2025-04-20 02:42:51 +00:00
Kévin Commaille
57941187dd
utils: Fix loading animated images in encrypted rooms 2025-04-19 15:03:32 +02:00
Kévin Commaille
10776c0f52
room-details: Respect media previews safety setting for media history viewer
Do not show the previews if they are not shown in the room history.

This also adds support for blurhashes.
2025-04-19 14:56:09 +02:00
Kévin Commaille
5ea7f82c2f
room-history: Add support for Blurhashes
It is displayed while the media is being downloaded, or instead of the
preview if the preview is hidden.
2025-04-19 13:13:15 +02:00
Kévin Commaille
5ef500e916
build: Disable grass submodules
It seems that they are only necessary for some tests, that we don't run.
2025-04-18 15:30:22 +02:00
Kévin Commaille
5c774ceccc
build: Properly clean up grass
This is the right way, removing all files generated by the module.
2025-04-18 13:32:47 +02:00
Kévin Commaille
16661287c3
utils: Don't send items-changed signal for new groups in GroupingListModel
Sending the items-changed signal for groups after signalling that the
group was added results in duplicates for APIs that only care about that
signal, not about the actual number of items.
2025-04-18 13:32:47 +02:00
Anders Jonsson
c8015d121d Update Swedish translation 2025-04-17 15:30:33 +00:00
Kévin Commaille
8a88b7fb07
build: Properly clean up grass 2025-04-17 16:31:30 +02:00
Daniel Rusek
cebd738569 Update Czech translation 2025-04-17 13:06:13 +00:00
Luming Zh
71e4f016dd Update Chinese (China) translation 2025-04-17 10:47:13 +00:00
Yuri Chornoivan
6616969582 Update Ukrainian translation 2025-04-17 10:14:18 +00:00
Kévin Commaille
c5325049f6
Release Fractal 11.rc 2025-04-17 10:59:31 +02:00
Kévin Commaille
b7c45229d0
flatpak: Properly clean up grass 2025-04-17 09:45:48 +02:00
Martin
8a7e0fe8ff Update Slovenian translation 2025-04-17 06:29:14 +00:00
Kévin Commaille
cb6c0ecffe room-history: Fix justification of text in media preview placeholder 2025-04-16 20:08:04 +00:00
Kévin Commaille
865733b151 Upgrade glycin 2025-04-16 20:08:04 +00:00
Álvaro Burns
67f6808f6d Update Brazilian Portuguese translation 2025-04-16 20:00:12 +00:00
Kévin Commaille
e0a7eb4cd1
room-history: Group contiguous state events
They are replaced by an item that shows the count of state rows that
are hidden. This item can be expanded to show all the hidden state rows.
2025-04-16 18:12:06 +02:00
Kévin Commaille
316e7296e4
misc: Fixes
Small unrelated fixes
2025-04-16 15:56:19 +02:00
Kévin Commaille
da2e81fbdb
timeline: Clean up TimelineItem API
Remove the `selectable` property which is not used, and move properties
about the header to Event, which is the only child type using it.
2025-04-16 15:33:00 +02:00
Kévin Commaille
4372f80a6b
room-history: Replace ItemRow by more specific widgets
It is now basically the new EventRow, since only events have a context
menu.

All the widgets that can be direct children of GtkListItem use the
`room-history-row` class for CSS styling.
2025-04-16 15:33:00 +02:00
Kévin Commaille
93ccda449f
utils: Rename DummyObject to PlaceholderObject
"dummy" can be viewed as offensive
2025-04-16 15:33:00 +02:00
Yuri Chornoivan
9396a8b036 Update Ukrainian translation 2025-04-15 17:38:20 +00:00
Kévin Commaille
213b5bd5dd account-settings: Add safety setting to choose which rooms should show media previews
It is a global choice between all rooms, only private rooms, or no
rooms.
2025-04-15 12:00:59 +00:00
Daniel Rusek
d29766fad5 Update Czech translation 2025-04-15 11:20:39 +00:00
Yuri Chornoivan
930ab34755 Update Ukrainian translation 2025-04-15 10:11:02 +00:00
Kévin Commaille
25c79797d1
account-settings: Reorganize
The main goal here is to move safety-related settings to a new tab so
we can add more in the future.

Since we do not want more tabs, we have to:

- Move the sessions to a subpage accessible with a button in the
"General" tab. While we are here, we reorder most of this tab.
- Move the settings that were in the "Privacy" section of the "Security"
tab to a new "Safety" tab.
- Rename the "Security" tab to "Encryption", to avoid confusion between
"Security" and "Safety".
2025-04-14 18:08:16 +02:00
Daniel Rusek
f07d350628 Update Czech translation 2025-04-13 11:14:37 +00:00
Kévin Commaille
5c9f9a2aff
Upgrade protobuf-c
Use new release.
2025-04-11 12:36:27 +02:00
Kévin Commaille
69da78094c
matrix: Disable event cache storage 2025-04-11 12:15:03 +02:00
Kévin Commaille
163fe968f2
Upgrade matrix-sdk
Use new release
2025-04-11 12:10:52 +02:00
Álvaro Burns
d702e0bb56 Update Brazilian Portuguese translation 2025-04-09 12:44:19 +00:00
Kévin Commaille
2c66f77be7
notifications: Use same algorithm as libadwaita to find initials
To avoid avatar discrepancies between the window and the notifications.
2025-04-08 17:30:30 +02:00
Kévin Commaille
15f21d1467
timeline: Filter timeline start item if we have room create event
The code was removed when the SDK was updated recently, because the SDK
now provides the timeline start item, so the logic had to change. This
re-adds the old code and adapts it to filter the SDK items.
2025-04-08 16:42:13 +02:00
Kévin Commaille
d870c1497d
misc: Use SingleItemListModel where possible
Instead of GListStore.
2025-04-08 16:03:37 +02:00
Ekaterine Papava
7a1d8ffa19 Update Georgian translation 2025-04-08 02:38:26 +00:00
Kévin Commaille
a2cc7999c3
verification: Refactor and clean up 2025-04-07 16:19:10 +02:00
Kévin Commaille
8e623c55c4
notifications: Refactor and clean up
Fixes a regression where clicking on notifications would not open the
app anymore.
2025-04-07 15:40:42 +02:00
Kévin Commaille
834a06612a
Upgrade crates
Gets rid of tokio advisory
2025-04-07 14:01:35 +02:00
Kévin Commaille
d63a96c509
utils: Allow to get the JoinHandle of the spawn! macro
That way the result of the task can be `.await`ed if wanted.
2025-04-06 12:10:11 +02:00
Kévin Commaille
3dc3333d99
Bump Rust version
To match the version from the matrix-sdk crates.
2025-04-06 12:10:10 +02:00
Kévin Commaille
c46404cbbf
utils: Refactor toast macro
Simplify the rules for using the macro:

- The message must always implement `AsRef<str>`,
- The variables values must implement `ToString`,
- Trailing commas are always optional.

Use functions for code that doesn't actually need to be in the macro,
it allows to have linting of the code.
2025-04-06 12:10:10 +02:00
Kévin Commaille
b2dd3acd08
system-settings: Refactor 2025-04-06 12:10:10 +02:00
Kévin Commaille
da8fb3b112
room-details: Refactor and clean up 2025-04-06 12:10:10 +02:00
Kévin Commaille
065e139126
utils: Refactor and clean up 2025-04-06 12:10:10 +02:00
Kévin Commaille
25b7b1603b
remote-room: Refactor and clean up 2025-04-06 12:10:10 +02:00
Kévin Commaille
3a6416f1b3
failed-session: Refactor 2025-04-06 12:10:10 +02:00
Kévin Commaille
194beff585
account-settings: Refactor and clean up 2025-04-06 12:10:10 +02:00
Yuri Chornoivan
3d0c9a78e7 Update Ukrainian translation 2025-04-06 06:52:04 +00:00
Martin
77d1c8a846 Update Slovenian translation 2025-04-06 06:45:42 +00:00
Luming Zh
b7eaa606bc Update Chinese (China) translation 2025-04-06 03:16:26 +00:00
Kévin Commaille
b577acb584
Add support for logging in with the OAuth 2.0 API 2025-04-05 20:28:26 +02:00
Kévin Commaille
9f9b0129f0
Upgrade crate dependencies
To get rid of the cargo-deny advisory.
2025-04-05 19:38:15 +02:00
Martin
c1a669a9a2 Update Slovenian translation 2025-04-05 16:30:42 +00:00
Yuri Chornoivan
14edc8d9fa Update Ukrainian translation 2025-04-05 14:42:18 +00:00
Titouan Real
41ab799c37 user-session-page: Fix toast string 2025-04-05 07:00:21 +00:00
Titouan Real
d7b79251e1 general: Remove debug repr in error log 2025-04-05 07:00:21 +00:00
Titouan Real
d427026b5b user-session-page: Allow the user to set the display name 2025-04-05 07:00:21 +00:00
Titouan Real
49f2f71e61 misc: Fix new clippy lint 2025-04-04 22:26:30 +02:00
Titouan Real
8af7a922e3 Address RUSTSEC-2025-0022
https://rustsec.org/advisories/RUSTSEC-2025-0022
2025-04-04 22:22:26 +02:00
Kévin Commaille
a263904386
explore: Adapt header if the window is too narrow
If the window is too narrow, the search entry moves below the servers
button.
2025-04-01 15:30:54 +02:00
Kévin Commaille
e0f8bd5c28
explore: Refactor and clean up 2025-04-01 12:35:39 +02:00
Kévin Commaille
8a78a7a3bf Revert "utils: Do not enable event cache storage"
This reverts commit 37fc796c9ce979cb0c23876be9425322df10024b.
2025-03-31 09:06:35 +00:00
Kévin Commaille
5c66a39c0c build: Clean up flatpak manifest 2025-03-31 09:06:35 +00:00
Kévin Commaille
48f569f327 account-settings: Use SDK's OAuth methods rather than implementing our own 2025-03-31 09:06:35 +00:00
Kévin Commaille
a137928496 Upgrade matrix-sdk 2025-03-31 09:06:35 +00:00
Artur S0
8285bc794b Update Russian translation 2025-03-30 08:40:46 +00:00
Brage Fuglseth
fa51da6393 Update Norwegian Bokmål translation 2025-03-29 17:24:31 +00:00
Daniel Rusek
e708570703 Update Czech translation 2025-03-28 23:02:44 +00:00
Álvaro Burns
d63d5c229b Update Brazilian Portuguese translation 2025-03-28 12:42:43 +00:00
Kévin Commaille
9566d14940
join-room-dialog: Rename to RoomPreviewDialog 2025-03-27 16:29:04 +01:00
Kévin Commaille
121219e539
create-dm-dialog: Rename to CreateDirectChatDialog, refactor and clean up 2025-03-27 16:29:04 +01:00
Kévin Commaille
2c84f96d46
room-creation: Rename to CreateRoomDialog, refactor and clean up 2025-03-27 16:29:04 +01:00
Kévin Commaille
a0fb433fd2
room-list: Refactor and clean up 2025-03-27 16:29:04 +01:00
Kévin Commaille
8eeb955746
window: Refactor and clean up 2025-03-27 16:29:04 +01:00
Kévin Commaille
fba0195977
application: Refactor 2025-03-27 16:29:04 +01:00
Kévin Commaille
d78a59dfed
application: Simplify intents 2025-03-27 16:29:04 +01:00
Kévin Commaille
e7727a4b82
session-view: Refactor and clean up 2025-03-27 11:04:26 +01:00
Kévin Commaille
f513aa5f8b
Release Fractal 11.beta 2025-03-27 09:05:25 +01:00
Kévin Commaille
ed66251c10
session-view: Fix focus behavior when selected item in sidebar changed
Make sure to always focus a widget in the visible page if possible.
2025-03-26 18:54:56 +01:00
Kévin Commaille
7b425eef0e
content: Refactor 2025-03-26 18:54:56 +01:00
Kévin Commaille
2a5da4b12e
event-details-dialog: Add button to open sender profile instead of copying the user ID 2025-03-26 18:54:55 +01:00
Kévin Commaille
2e1bfefde6
timeline: Show sender for unable to decrypt events 2025-03-26 15:44:27 +01:00
Kévin Commaille
cb921ac21e
window: Make sure offline banner is at the top of loading screen 2025-03-26 15:41:18 +01:00
Kévin Commaille
37fc796c9c utils: Do not enable event cache storage
It still has issues that we don't want to have in the beta
2025-03-26 10:36:53 +00:00
Kévin Commaille
384a80afbb Upgrade matrix-sdk 2025-03-26 10:36:53 +00:00
Luming Zh
72ea4095c9 Update Chinese (China) translation 2025-03-26 10:07:02 +00:00
Martin
eab4d059f1 Update Slovenian translation 2025-03-25 22:03:27 +00:00
Kévin Commaille
b57f072c22 session: Log the session ID 2025-03-25 21:21:56 +00:00
Kévin Commaille
b1050a555a session: Make sure a single network check per session is running at a time
If there are a lot of network state changes, we can have several
requests at once. The first will succeed but the other ones will timeout
and we will end up offline although there is nothing wrong with the
network.

To prevent that, we use a Mutex so only one requests is made at a time.

We also schedule another check if the network is fine but the server is
unreachable.
2025-03-25 21:21:56 +00:00
Yuri Chornoivan
73d9a142a8 Update Ukrainian translation 2025-03-25 21:05:23 +00:00
Kévin Commaille
be97fb5d03
identity-verification-view: Use AdwWrapBox
When there are 2 buttons to accept or cancel, use AdwWrapBox to show
them next to each other or one on top of the other, depending on the
available space.
2025-03-25 16:57:16 +01:00
Kévin Commaille
ad8d43667e
auth-dialog: Refactor to keep the dialog open until all stages are done
This is now an AdwDialog that look like an AlertDialog, but with a
GtkStack to switch between the different stages.

It also supports the OAuth 2.0 cross-signing reset stage provided by the
SDK.
2025-03-25 16:04:08 +01:00
Kévin Commaille
c55e39a640
Revert "Downgrade oo7"
This reverts commit 2f107332fd8e537284d9db0637be0c09e2aa6aa4.
2025-03-21 16:56:04 +01:00
Kévin Commaille
cd1b8419dc
Upgrade crate dependencies 2025-03-21 16:53:56 +01:00
Alexandre Franke
af780758e9 Port to libadwaita 1.7 2025-03-20 15:27:41 +01:00
Adrien Plazas
dea4ca177a window: Correctly center a label 2025-03-20 09:06:22 +01:00
Kévin Commaille
a7d41b9f99 ci: Bump GNOME stable version
Use GNOME 48
2025-03-19 17:18:52 +00:00
Harry Bond
aabae3d500 account-switcher: Make the account-switcher button circular 2025-03-19 15:59:14 +00:00
Kévin Commaille
499f9f9370
Upgrade dependencies 2025-03-19 13:19:43 +01:00
Artur S0
54b4540d32 Update Russian translation 2025-03-14 11:29:21 +00:00
Kévin Commaille
5a1cc6497e
Upgrade matrix-sdk 2025-03-14 10:07:15 +01:00
Álvaro Burns
03d27d222d Update Brazilian Portuguese translation 2025-03-11 23:02:32 +00:00
Kévin Commaille
694a07bfec
Upgrade matrix-sdk 2025-03-11 17:43:55 +01:00
Kévin Commaille
84b5d8f50e
ci: Ignore new advisory 2025-03-11 17:43:33 +01:00
Álvaro Burns
b509d80b9f Update Brazilian Portuguese translation 2025-03-11 12:05:56 +00:00
Kévin Commaille
2941169b80
timeline: Add TRACE level log for timeline initial items 2025-03-10 14:41:21 +01:00
Kévin Commaille
33edfe65cf
Upgrade matrix-sdk 2025-03-10 12:54:59 +01:00
Kévin Commaille
6b8c6db53d
Revert "ci: Use a previous commit of rust-nightly for building docs"
This reverts commit 8cc9b6c705f6d8908a2596ef617a0aedf569cc03.
2025-03-09 12:22:59 +01:00
Titouan Real
9ea87bd5a3 misc: Fix new clippy lint 2025-03-09 09:42:55 +00:00
Martin
5aefc3601e Update Slovenian translation 2025-03-09 09:12:49 +00:00
Ekaterine Papava
901690e77f Update Georgian translation 2025-03-09 03:46:50 +00:00
Luming Zh
c58781521d Update Chinese (China) translation 2025-03-09 03:35:33 +00:00
Yuri Chornoivan
32d4dfdaf8 Update Ukrainian translation 2025-03-08 20:31:07 +00:00
Jiri Grönroos
91d66c6654 Update Finnish translation 2025-03-08 20:29:47 +00:00
Titouan Real
10d3c7ec8d user-session: Fix last_seen_datetime_string doc 2025-03-08 18:08:12 +01:00
Titouan Real
4b846fd9d7 user-sessions-page: Redesign 2025-03-08 10:46:33 +00:00
Kévin Commaille
6d383f0a9b
ci: Silence new advisories 2025-03-08 11:10:36 +01:00
Alexandre Franke
126a5daa53 issues: features are better discussed before filing 2025-03-07 14:48:31 +01:00
Alexandre Franke
cc1062b423 issues: make it clear what is supported 2025-03-07 10:54:37 +00:00
Kévin Commaille
2f107332fd
Downgrade oo7
Until we find a solution for the current problem.
2025-03-07 11:05:28 +01:00
Kévin Commaille
cdeee6bffb
camera: Fix unimplemented module imports 2025-03-06 11:43:41 +01:00
Kévin Commaille
8126ce3a40
secret: Fix unimplemented module imports 2025-03-06 11:42:56 +01:00
Kévin Commaille
41efd8b809
location: Use a trait for API implementations instead of GObject subclasses 2025-03-06 11:38:01 +01:00
Kévin Commaille
c2253f2507
Upgrade crate dependencies
In particular upgrade oo7 to 0.4.1 to have an extra log for the current
error.
2025-03-05 14:35:37 +01:00
Kévin Commaille
5564609c99 Upgrade matrix-sdk 2025-03-05 12:00:51 +00:00
Andika Triwidada
be8c021c7f Update Indonesian translation 2025-03-05 11:17:58 +00:00
Artur S0
e3e5a2bf33 Update Russian translation 2025-03-05 09:16:01 +00:00
Kévin Commaille
8cc9b6c705
ci: Use a previous commit of rust-nightly for building docs
There is a regression in the latest commit that prevents the
dependencies from building.
2025-03-02 20:31:18 +01:00
Kévin Commaille
d8cc3eed82
account-settings: Update the items in UserSessionsList rather than replacing them 2025-03-02 18:23:44 +01:00
Brage Fuglseth
95a37306ff Update Norwegian Bokmål translation 2025-03-02 16:23:03 +00:00
Kévin Commaille
d5cbeeef99
Upgrade glycin 2025-03-01 11:49:06 +01:00
Álvaro Burns
d726cbc5a9 Update Brazilian Portuguese translation 2025-02-28 15:21:18 +00:00
Kévin Commaille
c51971f37c
Upgrade matrix-sdk 2025-02-28 13:51:59 +01:00
Kévin Commaille
7c8e4b2577
room: Count our own user's membership changes as activity
That way freshly joined or newly created rooms are at the top of the
list.
2025-02-28 13:34:25 +01:00
Kévin Commaille
0fb493674a timeline: Add TRACE level logs for item changes
To debug an issue with missing items
2025-02-27 14:36:41 +00:00
Kévin Commaille
2cafa29b92 Upgrade matrix-sdk 2025-02-27 14:36:41 +00:00
Titouan Real
7911dd2134 user-sessions-page: React to device disconnections
When a device is disconnected, the server sends us an empty
`DeviceUpdates`. In this case, we need to reload the sessions list.
2025-02-24 16:14:29 +01:00
Kévin Commaille
2b8276c416
secret: Create data directory before storing tokens 2025-02-23 13:48:43 +01:00
Andika Triwidada
b4224653c9 Update Indonesian translation 2025-02-22 11:32:22 +00:00
Martin
6b04bbcf93 Update Slovenian translation 2025-02-22 10:10:54 +00:00
Luming Zh
c0620fbafb Update Chinese (China) translation 2025-02-22 00:09:24 +00:00
Yuri Chornoivan
986491fcaa Update Ukrainian translation 2025-02-21 18:53:13 +00:00
Kévin Commaille
67362776c6
misc: Fix new clippy lints 2025-02-21 11:08:43 +01:00
Kévin Commaille
5fff035bff
session: Handle refreshing tokens 2025-02-20 14:38:21 +01:00
Kévin Commaille
8a6b71e496
secret: Store tokens in a separate file
When we switch to supporting OAuth 2.0, the tokens will need to be
refreshed often. To avoid issues where the secret backend might stop
responding, we store them encrypted in a separate file. The secret
backend now only stores the passphrase.
2025-02-20 13:37:16 +01:00
Kévin Commaille
1d69e8282b
secret: Add the SecretExt trait to implement by backends 2025-02-19 17:27:38 +01:00
Kévin Commaille
3736e2782d
secret: Use SampleString trait
The code looks cleaner like that.
2025-02-19 16:50:56 +01:00
Kévin Commaille
a37e63aecc
Upgrade crate dependencies 2025-02-19 15:19:06 +01:00
Artur S0
8471160d23 Update Russian translation 2025-02-17 13:06:16 +00:00
Aefgh Threenine
2f9908fd07 Update Thai translation 2025-02-17 04:47:56 +00:00
Sabri Ünal
8dc5668ff6 Update Turkish translation 2025-02-16 18:19:07 +00:00
Rafael Fontenelle
df23a0ec3a Update Brazilian Portuguese translation 2025-02-14 01:36:51 +00:00
Ekaterine Papava
49f2ab6677 Update Georgian translation 2025-02-13 04:25:24 +00:00
Kévin Commaille
5f48f0f973
session: Set up periodic media cache cleanups
Avoids to have it grow indefinitely and gets rid of old media.
2025-02-11 12:18:43 +01:00
Kévin Commaille
a005dfea72
chore: Upgrade matrix-sdk 2025-02-11 12:18:43 +01:00
Kévin Commaille
30a764b4e3 room: Rename timeline property to live-timeline 2025-02-10 16:25:14 +00:00
Kévin Commaille
14ec802dfd room-history: Use Timeline as source of data rather than room 2025-02-10 16:25:14 +00:00
Kévin Commaille
ca18d920c1 timeline-item: Add a property to access the Timeline of the item 2025-02-10 16:25:14 +00:00
Kévin Commaille
229a5a1d31 timeline-diff-minimizer: Rename and use a custom trait for timeline items 2025-02-10 16:25:14 +00:00
Kévin Commaille
56d395581b timeline: Prepare support for forward paginating
Until now we assumed that loading events always loaded older events.
If we want to support opening the room history at an arbitrary event,
we need to be able to load newer events too.

This refactors code to clarify that we are currently loading older
events.
2025-02-10 16:25:14 +00:00
Kévin Commaille
b67d763dea
Release Fractal 10.1
This version backports the fixes to the regressions introduced in
Fractal 10.
2025-02-10 17:19:07 +01:00
Kévin Commaille
e9b4ffa5b0 ci: Check Rust dependencies with cargo-deny 2025-02-10 12:12:56 +00:00
Luming Zh
cedc6fc539 Update Chinese (China) translation 2025-02-10 10:57:40 +00:00
Rafael Fontenelle
6246535d5b Update Brazilian Portuguese translation 2025-02-09 05:03:34 +00:00
Martin
a22d09af91 Update Slovenian translation 2025-02-08 10:49:11 +00:00
Yuri Chornoivan
42c8bd045a Update Ukrainian translation 2025-02-07 16:38:55 +00:00
Daniel Rusek
929c12836c Update Czech translation 2025-02-07 13:46:34 +00:00
Alexandre Franke
42b5fddadb about: drop years from copyright 2025-02-07 13:10:56 +01:00
Andika Triwidada
cfbf9ffa61 Update Indonesian translation 2025-02-07 11:30:21 +00:00
Kévin Commaille
3618573f83
build: Require at least OpenSSL 3.0.0
Versions older than that are not supported anymore
2025-02-07 09:31:39 +01:00
Daniel Rusek
e8f4b641b9 Update Czech translation 2025-02-06 22:04:30 +00:00
Yuri Chornoivan
38b6523235 Update Ukrainian translation 2025-02-06 15:19:52 +00:00
Martin
2187ca7146 Update Slovenian translation 2025-02-06 15:12:53 +00:00
Alexandre Franke
53cec746df ui: add ellipses for buttons that open dialogs 2025-02-06 12:19:29 +01:00
Kévin Commaille
be682d69b0 chore: Upgrade matrix-sdk and ruma 2025-02-04 17:31:31 +00:00
Sabri Ünal
81181b3dad Update Turkish translation 2025-02-04 17:31:23 +00:00
Rafael Fontenelle
e5683ed537 Update Brazilian Portuguese translation 2025-02-04 16:19:02 +00:00
Artur S0
7ba8a2d98e Update Russian translation 2025-02-04 13:06:47 +00:00
Luming Zh
85c63d9a62 Update Chinese (China) translation 2025-02-04 02:08:34 +00:00
Daniel Rusek
a3df283fa3 Update Czech translation 2025-02-03 11:38:59 +00:00
Kévin Commaille
f8ce19f33b
room: Construct timeline right away
A recent change made us wait for the category to be loaded before
constructing the Timeline, to know if we should preload it.

However the timeline property of Room assumes that there is always a
timeline available after the construction, which was not true anymore.

So we construct the Timeline during construction again, and use a
property to preload it when both the category and the matrix timeline
are ready.
2025-02-01 12:19:31 +01:00
Martin
9280b05b9f Update Slovenian translation 2025-02-01 08:41:41 +00:00
Rafael Fontenelle
0a105f5b83 Update Brazilian Portuguese translation 2025-02-01 02:23:08 +00:00
Yuri Chornoivan
b882606bf8 Update Ukrainian translation 2025-01-31 15:47:37 +00:00
twlvnn kraftwerk
5af9cf7e9b Update Bulgarian translation 2025-01-31 13:44:00 +00:00
Alexandre Franke
e1c9658d34 RELEASING: add process to add branch on DL 2025-01-31 12:00:05 +01:00
Maximiliano Sandoval
11b42442aa application: Add accel to close window via ctrl+w 2025-01-31 10:02:47 +00:00
Maximiliano Sandoval
129b841c47
app: Chain up activate
All vfuncs should chain up to prevent undefined behavior.
2025-01-30 20:21:27 +01:00
Maximiliano Sandoval
407fbba876
app: Set default icon name
This is used in x11 by certain DEs.
2025-01-30 20:21:13 +01:00
Daniel Rusek
94c333f8a0 Update Czech translation 2025-01-30 18:32:36 +00:00
Alexandre Franke
a04dc4cd69 style: adjust border radii
Following GNOME/libadwaita change d450cb30c59694f604d4396993281797035bc51c

Fixes #1538
2025-01-30 16:20:11 +01:00
Guillaume Girol
f50c06b5fe add keyboard shortcuts to navigate between rooms
They were previously added in https://gitlab.gnome.org/World/fractal/-/merge_requests/424
but lost in a rewrite.

These keybindings conflict with the default ones of TextView so a
ShortcutController with propagation phase capture is needed.
2025-01-30 13:47:25 +00:00
Kévin Commaille
c5955bbc74
Release Fractal 10 2025-01-30 10:20:48 +01:00
Kévin Commaille
55ce058dc5
docs: Improve release docs for screenshots 2025-01-30 09:44:24 +01:00
Kévin Commaille
1250725930
sidebar: Bundle icon for direct chats 2025-01-28 15:15:32 +01:00
Kévin Commaille
3a22a65549
room: Make sure to preload timeline only after it was initialized
This avoids a possible race condition where we try to load the timeline
before it is initialized, which is a noop.
2025-01-28 14:59:44 +01:00
Kévin Commaille
fcb4b80194
timeline: Fixes
Fix the position when removing the last item, and when truncating the
list.

Also add a check that we are removing the proper event from the map, in
case we have created the same event several times.
2025-01-28 02:57:42 +01:00
Kévin Commaille
edb65a3d14
room-details: Remove context for "_Edit" string
With the context, the string change breaks the string freeze, and it is
not necessary since it has the same meaning as the other string with the
mnemonic.
2025-01-27 12:26:11 +01:00
Artur S0
6f699fa949 Update Russian translation 2025-01-27 09:14:04 +00:00
Ekaterine Papava
98d0113460 Update Georgian translation 2025-01-27 01:53:22 +00:00
Rafael Fontenelle
86237c09ff Update Brazilian Portuguese translation 2025-01-25 16:58:32 +00:00
Yuri Chornoivan
274c0cc1a6 Update Ukrainian translation 2025-01-25 13:22:56 +00:00
Martin
5357575947 Update Slovenian translation 2025-01-25 13:09:20 +00:00
Maximiliano Sandoval
61d5e1bd13
room_details: Use AdwButtonContent on edit button 2025-01-24 20:09:04 +01:00
Kévin Commaille
46f6a77a6c
auth-dialog: Build UIAA fallback URL according to supported Matrix versions 2025-01-24 18:20:35 +01:00
Kévin Commaille
7cd95632de
utils: Log auth_issuer error at warn level
Unless it is not found, which can be expected.
2025-01-24 18:07:46 +01:00
Kévin Commaille
f480a232a8
utils: Rename most mentions of OIDC to OAuth 2.0
Since the MSCs have been updated to be based solely on OAuth 2.0
2025-01-24 18:02:43 +01:00
Kévin Commaille
63b4fe073b
chore: Upgrade matrix-sdk 2025-01-24 17:36:49 +01:00
Ekaterine Papava
5bd51cf27d Update Georgian translation 2025-01-21 03:32:00 +00:00
Kévin Commaille
07f36e2184
room-history: Refactor MessageContent
Reduce duplication to avoir errors, and group the functions to build
the content under a trait to make it look nicer.
2025-01-19 18:41:46 +01:00
Kévin Commaille
a4dec0503c
sidebar: Refactor and fix visibility 2025-01-18 16:00:18 +01:00
Kévin Commaille
2ff346f3fd
account-switcher: Refactor and fix visibility 2025-01-18 15:10:10 +01:00
Kévin Commaille
c23ded9f55
account-switcher: Allow labels to ellipsize
Should allow the popover to have a smaller minimum size if necessary.
2025-01-18 14:34:21 +01:00
Daniel Rusek
b64ad3bbec Update Czech translation 2025-01-18 00:46:23 +00:00
Kévin Commaille
7d3ea11331
event-details: Do not show edit section if we do not know anything about the edit
In general it shouldn't be the case, but it seems there is a regression
in the SDK and we cannot see the details about edits that were just
sent.
2025-01-17 20:18:09 +01:00
Kévin Commaille
5cec99f599
room-history: Proceed to auto-scrolling when main loop is idle
It seems that sometimes the GtkListView does nothing when we are
notified that its size has changed and we request to focus its last
child.

It might be because the size is allocated before the child is ready, so
we delay the request.
2025-01-17 14:55:35 +01:00
Kévin Commaille
80255955c7
room-history: Add has-popup a11y property to read receipts list 2025-01-17 11:58:24 +01:00
Kévin Commaille
7e40957a5e
room-history: Add key bindings to open context menu of reactions 2025-01-17 11:58:24 +01:00
Kévin Commaille
e59d99e344
utils: Create key_bindings module and move into it and rename add_activate_binding_action 2025-01-17 11:58:24 +01:00
Kévin Commaille
68f9c46559 room-history: Fix behaviour on scroll
Try to be smarter about what we are doing when we get signals from the
vadjustment.

We can ignore the upper and page-size signals when we are already
auto-scrolling, since trying to scroll more will just result in even
more signals later.

Only focus the last child once, then rely on scrolling to the end to
make sure that we are at the bottom.
2025-01-17 10:14:57 +00:00
Martin
54a8eabf64 Update Slovenian translation 2025-01-17 10:03:08 +00:00
Kévin Commaille
97c6c46b18 room: Reduce log noise for invited and knocked rooms
It is expected that we will not be able to access most of the room state
so do not try to load everything and reduce the logging level when a
permission error happens in those states.
2025-01-16 19:18:14 +00:00
Kévin Commaille
f11c57ed5e room: Only preload history for the main categories
Those are the categories that the user is most likely to visit.
Also we should not preload the timeline for categories where we do not
offer to view it.
2025-01-16 19:18:14 +00:00
Kévin Commaille
734c6d6d8c room: Wait for category to be loaded before initializing timeline
Since we do not preload the timeline for all categories
2025-01-16 19:18:14 +00:00
Kévin Commaille
0b6cc2019f room: Rename Room::set_category to change_category
To differentiate change_category which sends data to the homeserver,
with set_category from the private API that only changes the local
category.
2025-01-16 19:18:14 +00:00
Kévin Commaille
706c842d56 room: Introduce TargetRoomCategory for categories that can be targetted by the user
This makes the API more correct as we can't encounter categories that
are dependent of the state of the room.
2025-01-16 19:18:14 +00:00
Kévin Commaille
40af0116fb video-player: Fix visibility of duration
There are two conditions for the visibility so we always need to check
both before changing it.
2025-01-16 15:20:52 +00:00
Kévin Commaille
a5c71dec29 room-history: Return the proper minimum size when measuring visual media messages
It was always returning 0, which would trigger GTK warnings.
2025-01-16 15:20:52 +00:00
Anders Jonsson
8bccf661d4 Update Swedish translation 2025-01-16 13:33:55 +00:00
Brage Fuglseth
3eb5f9b99b Update Norwegian Bokmål translation 2025-01-16 11:30:45 +00:00
Kévin Commaille
37fbb89a26
docs: Update list of runtime dependencies 2025-01-15 18:04:01 +01:00
Kévin Commaille
6508f7411a
docs: Build docs for ruma-events 2025-01-15 18:04:01 +01:00
Kévin Commaille
0ba4b655c0
docs: Use the basic-deploy-docs CI template 2025-01-15 18:04:01 +01:00
Kévin Commaille
2835b999f8 camera: Use aperture library for scanning QR codes
That way we can benefit from improvements upstream.

This also simplifies the camera API in the process by using a trait
instead of a subclassable GObject.
2025-01-15 15:54:29 +00:00
Artur S0
10be4b5a6e Update Russian translation 2025-01-15 14:16:30 +00:00
Yuri Chornoivan
321232e005 Update Ukrainian translation 2025-01-14 19:23:02 +00:00
Alexandre Franke
a57e25c304 Update French translation 2025-01-14 13:06:32 +00:00
378 changed files with 58413 additions and 39643 deletions

View File

@ -17,9 +17,9 @@ include:
- local: .gitlab-ci/run_checks.yml - local: .gitlab-ci/run_checks.yml
- local: .gitlab-ci/build.yml - local: .gitlab-ci/build.yml
- local: .gitlab-ci/test.yml - local: .gitlab-ci/test.yml
- local: .gitlab-ci/publish_docs.yml - component: "gitlab.gnome.org/GNOME/citemplates/basic-deploy-docs@master"
rules: inputs:
- if: $CI_COMMIT_BRANCH == "main" docs-job-name: "build-docs"
- local: .gitlab-ci/publish_nightly.yml - local: .gitlab-ci/publish_nightly.yml
rules: rules:
- if: $CI_COMMIT_BRANCH == "main" - if: $CI_COMMIT_BRANCH == "main"

View File

@ -1,6 +1,8 @@
# Build the Flatpak # Build the Flatpak
include: 'https://gitlab.gnome.org/GNOME/citemplates/-/raw/master/flatpak/flatpak_ci_initiative.yml' include:
- project: "GNOME/citemplates"
file: "flatpak/flatpak_ci_initiative.yml"
variables: variables:
RUNTIME_REPO: "https://nightly.gnome.org/gnome-nightly.flatpakrepo" RUNTIME_REPO: "https://nightly.gnome.org/gnome-nightly.flatpakrepo"
@ -28,10 +30,13 @@ build@aarch64:
# Test builds with the stable runtime to make sure that the Flatpak will build on Flathub. # Test builds with the stable runtime to make sure that the Flatpak will build on Flathub.
# Should be run manually before tagging a new release. # Should be run manually before tagging a new release.
#
# To get a list of available GNOME and LLVM versions, see:
# https://gitlab.gnome.org/GNOME/gnome-runtime-images/-/blob/master/.gitlab-ci.yml
.build-stable: .build-stable:
image: 'quay.io/gnome_infrastructure/gnome-runtime-images:gnome-${GNOME_STABLE_VERSION}' image: 'quay.io/gnome_infrastructure/gnome-runtime-images:gnome-${GNOME_STABLE_VERSION}'
variables: variables:
GNOME_STABLE_VERSION: "47" GNOME_STABLE_VERSION: "48"
LLVM_NIGHTLY_VERSION: "18" LLVM_NIGHTLY_VERSION: "18"
LLVM_STABLE_VERSION: "18" LLVM_STABLE_VERSION: "18"
RUNTIME_REPO: "https://flathub.org/repo/flathub.flatpakrepo" RUNTIME_REPO: "https://flathub.org/repo/flathub.flatpakrepo"

View File

@ -1,17 +0,0 @@
.docs:
image: 'quay.io/gnome_infrastructure/gnome-runtime-images:gnome-master'
tags:
- flatpak
script:
- flatpak install --user --noninteractive org.freedesktop.Sdk.Extension.rust-nightly//24.08
# We want to use rust-nightly to build the app.
- sed -i 's|"org.freedesktop.Sdk.Extension.rust-stable"|"org.freedesktop.Sdk.Extension.rust-nightly"|g' ${MANIFEST_PATH}
- sed -i 's|/rust-stable/bin|/rust-nightly/extra/sdk/rust-nightly/bin|g' ${MANIFEST_PATH}
- flatpak-builder --keep-build-dirs --user --disable-rofiles-fuse --stop-at=${FLATPAK_MODULE} flatpak_app --repo=repo ${BRANCH:+--default-branch=$BRANCH} ${MANIFEST_PATH}
- echo "ninja src/doc" | flatpak-builder --disable-rofiles-fuse --build-shell=${FLATPAK_MODULE} flatpak_app ${MANIFEST_PATH}
- mv .flatpak-builder/build/${FLATPAK_MODULE}/_flatpak_build/src/doc ${DOCS_FOLDER}
- chmod -R a=rwx ${DOCS_FOLDER}
dependencies: []
artifacts:
paths:
- $DOCS_FOLDER

View File

@ -1,18 +0,0 @@
# Build and publish the docs
include: '.gitlab-ci/docs.yml'
pages:
extends:
- .docs
stage: deploy
variables:
DOCS_FOLDER: "public"
rules:
- changes:
- src/**/*
- Cargo.lock
- Cargo.toml
when: always
- when: manual
allow_failure: true

View File

@ -1,6 +1,8 @@
# Publish the nightly (Devel) version # Publish the nightly (Devel) version
include: 'https://gitlab.gnome.org/GNOME/citemplates/-/raw/master/flatpak/flatpak_ci_initiative.yml' include:
- project: "GNOME/citemplates"
file: "flatpak/flatpak_ci_initiative.yml"
publish_nightly@x86_64: publish_nightly@x86_64:
extends: .publish_nightly extends: .publish_nightly

View File

@ -1,18 +1,24 @@
# Configure and run code checks # Configure and run code checks
include: '.gitlab-ci/utils.yml'
# Custom checks and lints # Custom checks and lints
checks: checks:
stage: check stage: check
image: "rustlang/rust:nightly-slim" image: "rustlang/rust:nightly-slim"
interruptible: true
script: script:
- hooks/checks.sh --verbose --force-install - hooks/checks.sh --verbose --force-install
# Lint the code # Lint the code
cargo-clippy: cargo-clippy:
extends:
- .remove_build_only_modules
stage: check stage: check
image: 'quay.io/gnome_infrastructure/gnome-runtime-images:gnome-master' image: 'quay.io/gnome_infrastructure/gnome-runtime-images:gnome-master'
tags: tags:
- flatpak - flatpak
interruptible: true
script: script:
- flatpak-builder --keep-build-dirs --user --disable-rofiles-fuse --stop-at=${FLATPAK_MODULE} flatpak_app --repo=repo ${BRANCH:+--default-branch=$BRANCH} ${MANIFEST_PATH} - flatpak-builder --keep-build-dirs --user --disable-rofiles-fuse --stop-at=${FLATPAK_MODULE} flatpak_app --repo=repo ${BRANCH:+--default-branch=$BRANCH} ${MANIFEST_PATH}
- echo "cargo clippy -- -D warnings" | flatpak-builder --disable-rofiles-fuse --build-shell=${FLATPAK_MODULE} flatpak_app ${MANIFEST_PATH} - echo "cargo clippy -- -D warnings" | flatpak-builder --disable-rofiles-fuse --build-shell=${FLATPAK_MODULE} flatpak_app ${MANIFEST_PATH}

View File

@ -1,6 +1,6 @@
# Tests after the app is built. # Tests after the app is built.
include: '.gitlab-ci/docs.yml' include: '.gitlab-ci/utils.yml'
# Validate the metainfo with Flathub's tool. # Validate the metainfo with Flathub's tool.
lint-metainfo: lint-metainfo:
@ -10,6 +10,7 @@ lint-metainfo:
entrypoint: [""] entrypoint: [""]
variables: variables:
METAINFO: "${APP_ID}.metainfo.xml" METAINFO: "${APP_ID}.metainfo.xml"
interruptible: true
script: script:
# This tool has extra tests on top of appstreamcli and is required to pass for Flathub. # This tool has extra tests on top of appstreamcli and is required to pass for Flathub.
- flatpak-builder-lint appstream ${METAINFO} - flatpak-builder-lint appstream ${METAINFO}
@ -19,17 +20,24 @@ lint-metainfo:
# Run the Rust tests. # Run the Rust tests.
rust-tests: rust-tests:
extends:
- .remove_build_only_modules
stage: test stage: test
image: 'quay.io/gnome_infrastructure/gnome-runtime-images:gnome-master' image: 'quay.io/gnome_infrastructure/gnome-runtime-images:gnome-master'
tags: tags:
- flatpak - flatpak
variables: interruptible: true
TMP_MANIFEST_PATH: "build-aux/org.gnome.Fractal.CiRust.json"
script: script:
# Add a module for nextest to the Flatpak manifest # Create a temporary file.
- jq --slurpfile nextest .gitlab-ci/nextest.module.json '.modules = [$nextest[], .modules[]]' ${MANIFEST_PATH} > ${TMP_MANIFEST_PATH} - TMP_FILE=$(mktemp)
- flatpak-builder --keep-build-dirs --user --disable-rofiles-fuse --stop-at=${FLATPAK_MODULE} flatpak_app --repo=repo ${BRANCH:+--default-branch=$BRANCH} ${TMP_MANIFEST_PATH} # Add a module for nextest to the Flatpak manifest and write it to the temporary file.
- echo "cargo-nextest nextest run --config-file ../.gitlab-ci/nextest.toml" | flatpak-builder --disable-rofiles-fuse --build-shell=${FLATPAK_MODULE} flatpak_app ${TMP_MANIFEST_PATH} - jq --slurpfile nextest .gitlab-ci/nextest.module.json '.modules = [$nextest[], .modules[]]' ${MANIFEST_PATH} > ${TMP_FILE}
# Replace the manifest with the temporary file.
- mv $TMP_FILE ${MANIFEST_PATH}
# Initialize the Flatpak sandbox.
- flatpak-builder --keep-build-dirs --user --disable-rofiles-fuse --stop-at=${FLATPAK_MODULE} flatpak_app --repo=repo ${BRANCH:+--default-branch=$BRANCH} ${MANIFEST_PATH}
# Run the tests.
- echo "cargo-nextest nextest run --config-file ../.gitlab-ci/nextest.toml" | flatpak-builder --disable-rofiles-fuse --build-shell=${FLATPAK_MODULE} flatpak_app ${MANIFEST_PATH}
dependencies: [] dependencies: []
artifacts: artifacts:
reports: reports:
@ -38,9 +46,21 @@ rust-tests:
# Test that there are no errors in the docs. # Test that there are no errors in the docs.
build-docs: build-docs:
extends: extends:
- .docs - .remove_build_only_modules
stage: test stage: test
variables: image: 'quay.io/gnome_infrastructure/gnome-runtime-images:gnome-master'
DOCS_FOLDER: "doc" tags:
rules: - flatpak
- if: $CI_COMMIT_BRANCH != "main" interruptible: true
script:
- flatpak install --user --noninteractive org.freedesktop.Sdk.Extension.rust-nightly//24.08
# We want to use the nightly toolchain inside the build terminal.
- sed -i 's|"org.freedesktop.Sdk.Extension.rust-stable"|"org.freedesktop.Sdk.Extension.rust-nightly"|g' ${MANIFEST_PATH}
- sed -i 's|/rust-stable/bin|/rust-nightly/extra/sdk/rust-nightly/bin|g' ${MANIFEST_PATH}
- flatpak-builder --keep-build-dirs --user --disable-rofiles-fuse --stop-at=${FLATPAK_MODULE} flatpak_app --repo=repo ${BRANCH:+--default-branch=$BRANCH} ${MANIFEST_PATH}
- echo "ninja src/doc" | flatpak-builder --disable-rofiles-fuse --build-shell=${FLATPAK_MODULE} flatpak_app ${MANIFEST_PATH}
- tar --auto-compress --create --file "${CI_PROJECT_DIR}/${CI_PROJECT_NAME}-docs.tar.gz" --directory ".flatpak-builder/build/${FLATPAK_MODULE}/_flatpak_build/src/doc" .
dependencies: []
artifacts:
paths:
- ${CI_PROJECT_NAME}-docs.tar.gz

16
.gitlab-ci/utils.yml Normal file
View File

@ -0,0 +1,16 @@
# Utilities to include in other jobs.
# Remove the Flatpak modules that are only necessary when building the app with meson.
.remove_build_only_modules:
variables:
# JSON array of the names of the Flatpak modules to remove.
MODULES_TO_REMOVE: '["grass", "glycin-loaders"]'
before_script:
# Create a temporary file.
- TMP_FILE=$(mktemp)
# Remove the modules in the manifest and write the output to the temporary file.
- jq --argjson modules_to_remove "${MODULES_TO_REMOVE}" 'del(.modules[] | select(IN(.name; $modules_to_remove | .[])))' ${MANIFEST_PATH} > $TMP_FILE
# Replace the manifest with the temporary file.
- mv $TMP_FILE ${MANIFEST_PATH}
# Use meson's build-env profile.
- sed -i "s|-Dprofile=development|-Dprofile=build-env|g" ${MANIFEST_PATH}

View File

@ -12,9 +12,10 @@ or videos showing the issue.
## Information ## Information
* [ ] This bug is reproducible from the latest nightly build <!-- Check this box if the bug happens on Fractal's development version --> * [ ] This bug is reproducible from the latest nightly build <!-- Check this box if the bug happens on Fractal's development version -->
* [ ] This bug is reproducible with an [officially supported flatpak](https://gitlab.gnome.org/World/fractal#installation-instructions)
<!-- ⚠️ Issue with third party packages (distribution repository, AUR, snap, Fedora flatpak…) should be reported to your distributor -->
* **Fractal Version**: <!-- The version of Fractal you were using when the bug occurred. Check the "About Fractal" dialog for this information --> * **Fractal Version**: <!-- The version of Fractal you were using when the bug occurred. Check the "About Fractal" dialog for this information -->
* **OS Version**: <!-- Operating system version, e.g. Fedora 36 --> * **OS Version**: <!-- Operating system version, e.g. Fedora 36 -->
* **Installation Source**: <!-- Where you installed Fractal from, e.g. Flathub, GNOME Apps Nightly, AUR, or distro repositories -->
* **Homeserver**: <!-- The homeserver for your matrix account, e.g. matrix.org, gnome.org, … You can mention several of them if this is reproducible on multiple ones. --> * **Homeserver**: <!-- The homeserver for your matrix account, e.g. matrix.org, gnome.org, … You can mention several of them if this is reproducible on multiple ones. -->
<!-- If you have error logs or a crash report, use the "Attach A File" button in the issue editor to attach it, or paste it in a code block below. <!-- If you have error logs or a crash report, use the "Attach A File" button in the issue editor to attach it, or paste it in a code block below.

View File

@ -1,7 +1,10 @@
<!-- Please note that some features missing in the stable release are already available in the <!-- Please note that some features missing in the stable release are already available in the
development version. To avoid duplicates and unnecessary issues, please check that your request is development version. To avoid duplicates and unnecessary issues, please check that your request is
for something that is not yet implemented, and doesnt have an existing issue that is open or that for something that is not yet implemented, and doesnt have an existing issue that is open or that
was closed as out of scope. --> was closed as out of scope.
We also recommend talking to us in the #fractal:gnome.org Matrix room first. We do not intend to
implement everything and dont want our issue tracker to become a giant wishlist, but rather a
curated list of known problems and planned features. -->
Detailed description of the feature. Provide as much information as you can. Detailed description of the feature. Provide as much information as you can.

2333
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,14 @@
[package] [package]
name = "fractal" name = "fractal"
version = "10.0.0-rc" version = "11.2.0"
authors = ["Julian Sparber <julian@sparber.net>"] authors = ["Julian Sparber <julian@sparber.net>"]
edition = "2021" edition = "2021"
rust-version = "1.82" rust-version = "1.85"
publish = false publish = false
[package.metadata.cargo-machete]
ignored = ["serde_bytes"] # Used by the SecretFile API.
[profile.release] [profile.release]
debug = true debug = true
lto = "thin" lto = "thin"
@ -21,7 +24,8 @@ codegen-units = 16
# Please keep dependencies sorted. # Please keep dependencies sorted.
[dependencies] [dependencies]
async-once-cell = "0.5" blurhash = "0.2"
cfg-if = "1"
diff = "0.1" diff = "0.1"
djb_hash = "0.1" djb_hash = "0.1"
futures-channel = "0.3" futures-channel = "0.3"
@ -32,16 +36,16 @@ indexmap = "2"
linkify = "0.10.0" linkify = "0.10.0"
mime = "0.3" mime = "0.3"
mime_guess = "2" mime_guess = "2"
pulldown-cmark = "0.12" pulldown-cmark = "0.13"
qrcode = { version = "0.14", default-features = false } qrcode = { version = "0.14", default-features = false }
rand = "0.8" rand = "0.9"
regex = "1" regex = "1"
rmp-serde = "1" rmp-serde = "1"
rqrr = { version = "0.8", default-features = false }
secular = { version = "1", features = ["bmp", "normalization"] } secular = { version = "1", features = ["bmp", "normalization"] }
serde = "1" serde = "1"
serde_bytes = "0.11"
serde_json = "1" serde_json = "1"
strum = { version = "0.26", features = ["derive"] } strum = { version = "0.27.1", features = ["derive"] }
tempfile = "3" tempfile = "3"
thiserror = "2" thiserror = "2"
tld = "2" tld = "2"
@ -51,13 +55,14 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
url = "2" url = "2"
webp = { version = "0.3", default-features = false } webp = { version = "0.3", default-features = false }
wtinylfu = "0.2"
zeroize = "1"
# gtk-rs project and dependents. These usually need to be updated together. # gtk-rs project and dependents. These usually need to be updated together.
adw = { package = "libadwaita", version = "0.7", features = ["v1_6"] } adw = { package = "libadwaita", version = "0.7", features = ["v1_7"] }
glycin = { version = "2.0.1", default-features = false, features = ["tokio", "gdk4"] } glycin = { version = "2", default-features = false, features = ["tokio", "gdk4"] }
gst = { version = "0.23", package = "gstreamer" } gst = { version = "0.23", package = "gstreamer" }
gst_app = { version = "0.23", package = "gstreamer-app" } gst_app = { version = "0.23", package = "gstreamer-app" }
gst_base = { version = "0.23", package = "gstreamer-base" }
gst_pbutils = { version = "0.23", package = "gstreamer-pbutils" } gst_pbutils = { version = "0.23", package = "gstreamer-pbutils" }
gst_play = { version = "0.23", package = "gstreamer-play" } gst_play = { version = "0.23", package = "gstreamer-play" }
gst_video = { version = "0.23", package = "gstreamer-video" } gst_video = { version = "0.23", package = "gstreamer-video" }
@ -66,25 +71,25 @@ shumate = { package = "libshumate", version = "0.6" }
sourceview = { package = "sourceview5", version = "0.9" } sourceview = { package = "sourceview5", version = "0.9" }
[dependencies.matrix-sdk] [dependencies.matrix-sdk]
# version = "0.9" version = "0.12"
git = "https://github.com/matrix-org/matrix-rust-sdk.git" # git = "https://github.com/matrix-org/matrix-rust-sdk.git"
rev = "9514388108c7007cbdc822c582d26ad9e89af5d9" # rev = "1348525447e99cb27ddca2e23885d9bab3837297"
features = [ features = ["socks", "sso-login", "markdown", "qrcode"]
"socks",
"sso-login", [dependencies.matrix-sdk-store-encryption]
"markdown", version = "0.12"
"qrcode", # git = "https://github.com/matrix-org/matrix-rust-sdk.git"
] # rev = "1348525447e99cb27ddca2e23885d9bab3837297"
[dependencies.matrix-sdk-ui] [dependencies.matrix-sdk-ui]
# version = "0.9" version = "0.12"
git = "https://github.com/matrix-org/matrix-rust-sdk.git" # git = "https://github.com/matrix-org/matrix-rust-sdk.git"
rev = "9514388108c7007cbdc822c582d26ad9e89af5d9" # rev = "1348525447e99cb27ddca2e23885d9bab3837297"
[dependencies.ruma] [dependencies.ruma]
# version = "0.12" version = "0.12.3"
git = "https://github.com/ruma/ruma.git" # git = "https://github.com/ruma/ruma.git"
rev = "b266343136e8470a7d040efc207e16af0c20d374" # rev = "a8fd1b0322649bf59e2a5cfc73ab4fe46b21edd7"
features = [ features = [
"unstable-unspecified", "unstable-unspecified",
"client-api-c", "client-api-c",
@ -94,19 +99,18 @@ features = [
"compat-null", "compat-null",
"compat-optional", "compat-optional",
"compat-unset-avatar", "compat-unset-avatar",
"compat-get-3pids",
"html-matrix", "html-matrix",
"unstable-msc3824", "unstable-msc3824",
] ]
# Linux-only dependencies. # Linux-only dependencies.
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
ashpd = { version = "0.10", default-features = false, features = [ aperture = "0.9"
"pipewire", ashpd = { version = "0.11", default-features = false, features = [
"tracing", "tracing",
"tokio", "tokio",
] } ] }
oo7 = { version = "0.3", default-features = false, features = [ oo7 = { version = "0.4", default-features = false, features = [
"openssl_crypto", "openssl_crypto",
"tokio", "tokio",
"tracing", "tracing",

View File

@ -38,7 +38,7 @@ development version while keeping the stable release around for daily use.
### Stable version ### Stable version
The current stable version is 9 (released October 30th 2024). The current stable version is 11.2 (released June 10th 2025).
You can get the official Fractal Flatpak from Flathub. You can get the official Fractal Flatpak from Flathub.
@ -53,7 +53,7 @@ You can get the official Fractal Flatpak from Flathub.
### Beta version ### Beta version
The current beta version is 10.rc (released January 14th 2025). The current beta version is 11.2 (same as stable).
It is available as a Flatpak on Flathub Beta. It is available as a Flatpak on Flathub Beta.
@ -154,6 +154,10 @@ following dependencies at runtime:
* Camera: scan QR codes during verification. * Camera: scan QR codes during verification.
* Location: send the users location in a conversation. * Location: send the users location in a conversation.
* Settings: get the 12h/24h time format system preference. * Settings: get the 12h/24h time format system preference.
* GStreamer plugins:
* gst-plugin-gtk4 (gstgtk4): required to preview videos in the timeline and to present the output
of the camera.
* libgstpipewire with the `pipewiredeviceprovider`: used to list and access the cameras.
* glycin: all images are loaded with this library so loaders for the different image formats need to * glycin: all images are loaded with this library so loaders for the different image formats need to
be installed. be installed.

View File

@ -16,6 +16,7 @@
4. Create a release on GitLab for that tag. 4. Create a release on GitLab for that tag.
5. Make a fast-forward merge of the major version branch to `main`. 5. Make a fast-forward merge of the major version branch to `main`.
6. [Publish the new version on Flathub and Flathub beta](#publishing-a-version-on-flathub). 6. [Publish the new version on Flathub and Flathub beta](#publishing-a-version-on-flathub).
7. [Get the stable branch added to Damned Lies](#getting-a-branch-added-to-damned-lies).
## Making a new beta release ## Making a new beta release
@ -48,6 +49,11 @@ Make a single release commit containing the following changes:
- **stable.** remove all the `development` entries. - **stable.** remove all the `development` entries.
- **stable.** update the paths of the screenshots to point to the major version branch. - **stable.** update the paths of the screenshots to point to the major version branch.
- **stable.** If there were visible changes in the UI, update the screenshots in `/screenshots`. - **stable.** If there were visible changes in the UI, update the screenshots in `/screenshots`.
They should follow [Flathub's quality guidelines](https://docs.flathub.org/docs/for-app-authors/metainfo-guidelines/quality-guidelines#screenshots),
with the following window sizes:
- `main.png`: 760×550.
- `adaptive.png`: 360×600.
- `media-history.png`: 500×540.
A good practice in this merge request is to launch the `build-stable` CI jobs to make sure that A good practice in this merge request is to launch the `build-stable` CI jobs to make sure that
Fractal builds with the stable Flatpak runtime. Fractal builds with the stable Flatpak runtime.
@ -69,39 +75,42 @@ You will be prompted for a tag message. This message doesn't really matter so so
Publishing a version of Fractal on Flathub is done via its [Flathub repository on GitHub](https://github.com/flathub/org.gnome.Fractal/). Publishing a version of Fractal on Flathub is done via its [Flathub repository on GitHub](https://github.com/flathub/org.gnome.Fractal/).
A permission from the Flathub team granted to your GitHub account is necessary to merge PRs on this A permission from the Flathub team granted to your GitHub account is necessary to merge PRs on this
repository and interact with the buildbot, but anyone can open a PR. repository, but anyone can open a PR.
1. Open a PR against the correct branch. For a stable build, work against the `master` branch, for a 1. Open a PR against the correct branch. For a stable build, work against the `master` branch, for a
beta build, work against the `beta` branch. It must contain a commit that updates the manifest to: beta build, work against the `beta` branch.
It must contain a commit that updates the manifest to:
- Use the latest GNOME runtime. - Use the latest GNOME runtime.
- Make sure that the Flatpak dependencies are the same as in the nightly manifest, and using the - Make sure that the Flatpak dependencies are the same as in the nightly manifest, and using the
same version. same version.
- Build the latest version of Fractal, identified by its tag _and_ commit hash. - Build the latest version of Fractal, identified by its tag _and_ commit hash.
2. When the PR is opened, a CI job will update the `cargo-sources.json` file with the latest Rust
dependencies for Fractal and add a commit to the PR.
3. The Flathub buildbot will likely launch 2 builds, one for when the PR was opened, and another one
after the `cargo-sources.json` commit. You can stop any of those 2 builds, as long as the
remaining one uses the latest commit as the `flathub_revision` in the `Build Properties` tab.
4. Once the build succeeds, test the generated Flatpak as instructed and watch for obvious errors.
If there are no issues, merge the PR.
5. To watch the next steps, go to https://buildbot.flathub.org/. You should see a job for Fractal
start soon, unless there is a queue in `Builds > Pending Buildrequests`. Open that job and wait
for it to complete. When it is complete, there will be instructions again to test that build.
6. If everything is fine with the build, click the `Publish` button to publish the build right away.
If this is not done, the build will be published anyway after 3 to 4 hours. There can be some time
before the publish job is complete and `flatpak update` offers to update the app.
## Launching a build manually for Flathub If the list of Rust modules to build changes, the `MODULES` variable in the
`update-cargo-sources.sh` script must also be updated.
2. When the PR is opened, a CI job will update the `*-cargo-sources.json` files with the latest
dependencies for the Rust modules and add a commit to the PR if necessary.
3. The CI will trigger a test build automatically.
In most cases, this should not be necessary. Flathub launches builds automatically for PRs and after If the build fails in CI and you want to trigger it again, post a comment saying `bot, build`.
a PR is merged. If those builds fail, there is a `Rebuild` button to trigger a new build. However in
some cases, if the previous builds are not available anymore, if is necessary to trigger a build
manually:
1. Go to https://buildbot.flathub.org/ and log in. If the build succeeds, test the generated Flatpak as instructed and watch for obvious errors. If
2. Click on `Start build`. there are no issues, merge the PR.
3. Enter only the App ID. It is `org.gnome.Fractal` for the stable branch, and 4. Merging the PR will trigger an "official" build that will then be published on Flathub or Flathub
`org.gnome.Fractal/beta` for the beta branch. beta within 1 to 2 hours. If this build fails, contact the Flathub admins to launch it again.
4. If you only want to trigger a test build, i.e. one that will not be published in the end, check
the corresponding setting. More details about these steps can be found in the Flathub docs about [maintenance](https://docs.flathub.org/docs/for-app-authors/maintenance)
5. Click on `Start build`. and [updates](https://docs.flathub.org/docs/for-app-authors/updates).
## Getting a branch added to Damned Lies
Damned Lies is the GNOME translation management platform. It provides translation workflows, but
also statistics. Even though we dont publish any release from stable branches after the initial
one, we add them there so we can keep track of the evolution of translation coverage.
1. Go to https://l10n.gnome.org/module/fractal/ and log in.
2. Click on the pencil icon next to the branch list.
3. In the entry at the bottom, type in the name of the new branch, then click on the Save button.
4. Assign the newly added branch to the “Other Apps (stable)” Release, unassign the previous one.
5. Hit Save again for the assignments to take effect.

View File

@ -23,6 +23,7 @@
"append-ld-library-path": "/usr/lib/sdk/llvm18/lib", "append-ld-library-path": "/usr/lib/sdk/llvm18/lib",
"append-path": "/usr/lib/sdk/llvm18/bin:/usr/lib/sdk/rust-stable/bin", "append-path": "/usr/lib/sdk/llvm18/bin:/usr/lib/sdk/rust-stable/bin",
"env": { "env": {
"RUSTFLAGS": "-C force-frame-pointers=yes",
"CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER": "clang", "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER": "clang",
"CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS": "-C link-arg=-fuse-ld=/usr/lib/sdk/rust-stable/bin/mold --cfg=ruma_identifiers_storage=\"Arc\"", "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS": "-C link-arg=-fuse-ld=/usr/lib/sdk/rust-stable/bin/mold --cfg=ruma_identifiers_storage=\"Arc\"",
"CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER": "clang", "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER": "clang",
@ -48,9 +49,6 @@
"cleanup-commands": [ "cleanup-commands": [
"mkdir -p ${FLATPAK_DEST}/lib/ffmpeg" "mkdir -p ${FLATPAK_DEST}/lib/ffmpeg"
], ],
"cleanup": [
"bin/grass"
],
"modules": [ "modules": [
{ {
"name": "grass", "name": "grass",
@ -65,12 +63,14 @@
"mkdir -p /app/bin", "mkdir -p /app/bin",
"install -D ./target/release/grass /app/bin/" "install -D ./target/release/grass /app/bin/"
], ],
"cleanup": ["*"],
"sources": [ "sources": [
{ {
"type": "git", "type": "git",
"url": "https://github.com/connorskees/grass", "url": "https://github.com/connorskees/grass",
"tag": "0.13.4", "tag": "0.13.4",
"commit": "e0bb9e2eabfc3a58e42b03089cd7b22c68d09d0b" "commit": "e0bb9e2eabfc3a58e42b03089cd7b22c68d09d0b",
"disable-submodules": true
} }
] ]
}, },
@ -84,8 +84,8 @@
{ {
"type": "git", "type": "git",
"url": "https://github.com/protobuf-c/protobuf-c.git", "url": "https://github.com/protobuf-c/protobuf-c.git",
"tag": "v1.5.0", "tag": "v1.5.2",
"commit": "8c201f6e47a53feaab773922a743091eb6c8972a" "commit": "4719fdd7760624388c2c5b9d6759eb6a47490626"
} }
] ]
}, },
@ -102,8 +102,8 @@
{ {
"type": "git", "type": "git",
"url": "https://gitlab.gnome.org/GNOME/libshumate.git", "url": "https://gitlab.gnome.org/GNOME/libshumate.git",
"tag": "1.3.0", "tag": "1.4.0",
"commit": "e08d1442b80d0a352026505564e2cbe164b03997" "commit": "06021e35f0d479612fb1a3af91a73ba562175e03"
} }
] ]
}, },
@ -122,8 +122,9 @@
{ {
"type": "git", "type": "git",
"url": "https://gitlab.gnome.org/sophie-h/glycin.git", "url": "https://gitlab.gnome.org/sophie-h/glycin.git",
"tag": "1.1.2", "tag": "1.2.1",
"commit": "ec00d78b3e4f48030011601fcf991d0749fa3dab" "commit": "7d10c2f3e59e1d4dbc2a33241eb54aa4d9f84b3f",
"disable-submodules": true
} }
] ]
}, },

View File

@ -35,15 +35,15 @@
<screenshots> <screenshots>
<screenshot type="default"> <screenshot type="default">
<image type="source">https://gitlab.gnome.org/World/fractal/raw/fractal-9/screenshots/main.png</image> <image type="source">https://gitlab.gnome.org/World/fractal/raw/fractal-11/screenshots/main.png</image>
<caption>Fractals main window</caption> <caption>Fractals main window</caption>
</screenshot> </screenshot>
<screenshot> <screenshot>
<image type="source">https://gitlab.gnome.org/World/fractal/raw/fractal-9/screenshots/media-history.png</image> <image type="source">https://gitlab.gnome.org/World/fractal/raw/fractal-11/screenshots/media-history.png</image>
<caption>View the media history of a Matrix room</caption> <caption>View the media history of a Matrix room</caption>
</screenshot> </screenshot>
<screenshot> <screenshot>
<image type="source">https://gitlab.gnome.org/World/fractal/raw/fractal-9/screenshots/adaptive.png</image> <image type="source">https://gitlab.gnome.org/World/fractal/raw/fractal-11/screenshots/adaptive.png</image>
<caption>Fractals interface adapts to small screens</caption> <caption>Fractals interface adapts to small screens</caption>
</screenshot> </screenshot>
</screenshots> </screenshots>
@ -71,69 +71,107 @@
</content_rating> </content_rating>
<releases>@development-release@ <releases>@development-release@
<release version="10~rc" type="development" date="2025-01-14"> <release version="11.2" type="stable" date="2025-06-10">
<description> <description>
<p> <p>
In this cold weather, we hope this new release candidate will warm your hearts. Lets This version updates the matrix-sdk-crypto dependency to include a fix for a high severity
celebrate this with our own awards ceremony: security issue.
</p>
<ul>
<li>
The most next-gen addition goes to… making Fractal OIDC aware. This ensures
compatibility with the upcoming authentication changes for matrix.org.
</li>
<li>
The most valuable fix goes to… showing consistently pills for users and rooms mentions
in the right place instead of seemingly random places, getting rid of one of our oldest
and most annoying bug.
</li>
<li>
The most sensible improvement goes to… using the send queue for attachments, ensuring
correct order of all messages and improving the visual feedback.
</li>
<li>
The most underrated feature goes to… allowing to react to stickers, fixing a crash in
the process.
</li>
<li>
The most obvious tweak goes to… removing the “Open Direct Chat” menu entry from avatar
menu and member profile in direct chats.
</li>
<li>
The clearest enhancement goes to… labelling experimental versions in the room upgrade
menu as such.
</li>
</ul>
<p>
As usual, this release includes other improvements, fixes and new translations thanks to
all our contributors, and our upstream projects.
</p>
<p>
As the version implies, it should be mostly stable and we expect to only include minor
improvements until the release of Fractal 10.
</p> </p>
</description> </description>
</release> </release>
<release version="10~beta" type="development" date="2024-12-26"> <release version="11.1" type="stable" date="2025-05-15">
<description> <description>
<p> <p>
Vive le vent ! Vive le vent ! Vive le vent dhiver ! 🎶🌲 And vive Fractal 10.beta! Due to a pesky bug that makes Fractal crash when our users attempt to start a
While everyone is resting for the holidays, we thought you could use a new release. It verification, we are releasing Fractal 11.1 only 2 weeks after Fractal 11. And while were
focuses on improvements and bug fixes, including: at it we also backported a few fixes for smaller paper cuts!
</p>
</description>
</release>
<release version="11" type="stable" date="2025-05-01">
<description>
<p>
A new version of Fractal numbered Eleven? Stranger things have happened… Features come
running up that hill:
</p> </p>
<ul> <ul>
<li> <li>
Videos were often not playing after loading in the room history, this was fixed, and we Support for login using the OAuth 2.0 API (as used by matrix.org, which recently made
the switch to Matrix Authentication Service)
</li>
<li>
Overhaul of the page that lists user sessions, with details moved to subpages, for a
less cluttered feel, and allowing to rename sessions!
</li>
<li>
Rearranged account settings, with a new Safety tab that includes a setting to toggle
media preview visibility
</li>
<li>
BlurHashes for images and videos, that are used as placeholders while the media is
loading or if the preview is disabled
</li>
<li>
Contiguous state events are grouped behind a single item
</li>
</ul>
<p>
As usual, this release includes other improvements and fixes thanks to all our
contributors, and our upstream projects.
</p>
<p>
We want to address special thanks to the translators who worked on this version. We know
this is a huge undertaking and have a deep appreciation for what youve done. If you want
to help with this effort, head over to l10n.gnome.org.
</p>
</description>
</release>
<release version="10.1" type="stable" date="2025-02-10">
<description>
<p>
Due to a couple of unfortunate but important regressions in Fractal 10, we are releasing
Fractal 10.1 so our users dont have to wait too long for them to be addressed. This minor
version fixes the following issues:
</p>
<ul>
<li>
Some rooms were stuck in an unread state, even after reading them or marking them as
read.
</li>
<li>
Joining or creating a room would crash the app.
</li>
</ul>
</description>
</release>
<release version="10" type="stable" date="2025-01-30">
<description>
<p>
How are you going to find your friends and coordinate end of day drinks when youre lost
in the middle of a large crowd in a big city? With the new version of your favorite Matrix
client, of course! Here is Fractal 10.
</p>
<ul>
<li>
The QR code scanning code has been ported to libaperture, the library behind GNOME
Camera. This should result in better performance and more reliability.
</li>
<li>
OAuth 2.0 compatibility was added, to make sure that we are ready for the upcoming
authentication changes for matrix.org.
</li>
<li>
Pills for users and rooms mentions show consistently in the right place instead of
seemingly random places, getting rid of one of our oldest and most annoying bug.
</li>
<li>
Attachments go through the send queue, ensuring correct order of all messages and
improving the visual feedback.
</li>
<li>
Videos were often not playing after loading in the room history. This was fixed, and we
also show properly when an error occurred. also show properly when an error occurred.
</li> </li>
<li>
Computing the size of media messages was slightly wrong, which meant that sometimes a
grey line would appear below them. We rebooted the code and that problem is gone!
</li>
<li>
Our CSS file was a bit too big for our taste, so we decided to make use of SASS and
split it.
</li>
<li> <li>
We were downloading too many different sizes for avatar images, which would fill the We were downloading too many different sizes for avatar images, which would fill the
media cache needlessly. We now only download a couple of sizes. This has the extra media cache needlessly. We now only download a couple of sizes. This has the extra
@ -145,8 +183,9 @@
contributors, and our upstream projects. contributors, and our upstream projects.
</p> </p>
<p> <p>
As the version implies, there might be a slight risk of regressions, but it should be We want to address special thanks to the translators who worked on this version. We know
mostly stable. If all goes well the next step is the release candidate! this is a huge undertaking and have a deep appreciation for what youve done. If you want
to help with this effort, head over to l10n.gnome.org.
</p> </p>
</description> </description>
</release> </release>

View File

@ -5,47 +5,15 @@
viewBox="0 0 89.958331 52.916668" viewBox="0 0 89.958331 52.916668"
version="1.1" version="1.1"
id="svg8662" id="svg8662"
sodipodi:docname="welcome-export.svg"
inkscape:version="1.1-rc (52f87abb86, 2021-05-02)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#" xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/"> xmlns:dc="http://purl.org/dc/elements/1.1/">
<sodipodi:namedview
id="namedview47"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
objecttolerance="10.0"
gridtolerance="10.0"
guidetolerance="10.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
showgrid="false"
inkscape:zoom="0.99583586"
inkscape:cx="303.76492"
inkscape:cy="15.062723"
inkscape:current-layer="svg8662"
inkscape:object-paths="true"
inkscape:snap-intersection-paths="true"
inkscape:snap-midpoints="true"
inkscape:snap-smooth-nodes="true"
inkscape:snap-bbox="true"
inkscape:bbox-nodes="true"
inkscape:snap-text-baseline="true">
<inkscape:grid
type="xygrid"
id="grid1470" />
</sodipodi:namedview>
<defs <defs
id="defs8656"> id="defs8656">
<linearGradient <linearGradient
inkscape:collect="always"
id="linearGradient39832"> id="linearGradient39832">
<stop <stop
style="stop-color:#3584e4;stop-opacity:1" style="stop-color:#3584e4;stop-opacity:1"
@ -57,7 +25,6 @@
id="stop39830" /> id="stop39830" />
</linearGradient> </linearGradient>
<linearGradient <linearGradient
inkscape:collect="always"
id="linearGradient24559"> id="linearGradient24559">
<stop <stop
style="stop-color:#ed333b;stop-opacity:1;" style="stop-color:#ed333b;stop-opacity:1;"
@ -69,7 +36,6 @@
id="stop24557" /> id="stop24557" />
</linearGradient> </linearGradient>
<linearGradient <linearGradient
inkscape:collect="always"
id="linearGradient12858"> id="linearGradient12858">
<stop <stop
style="stop-color:#f66151;stop-opacity:1" style="stop-color:#f66151;stop-opacity:1"
@ -103,7 +69,6 @@
id="stop8143" /> id="stop8143" />
</linearGradient> </linearGradient>
<radialGradient <radialGradient
inkscape:collect="always"
xlink:href="#linearGradient976" xlink:href="#linearGradient976"
id="radialGradient1221" id="radialGradient1221"
gradientUnits="userSpaceOnUse" gradientUnits="userSpaceOnUse"
@ -113,7 +78,6 @@
fy="212" fy="212"
r="60" /> r="60" />
<linearGradient <linearGradient
inkscape:collect="always"
id="linearGradient976"> id="linearGradient976">
<stop <stop
style="stop-color:#f8e45c;stop-opacity:1" style="stop-color:#f8e45c;stop-opacity:1"
@ -125,7 +89,6 @@
id="stop974" /> id="stop974" />
</linearGradient> </linearGradient>
<radialGradient <radialGradient
inkscape:collect="always"
xlink:href="#linearGradient1117" xlink:href="#linearGradient1117"
id="radialGradient1223" id="radialGradient1223"
gradientUnits="userSpaceOnUse" gradientUnits="userSpaceOnUse"
@ -136,7 +99,6 @@
fy="224" fy="224"
r="16" /> r="16" />
<linearGradient <linearGradient
inkscape:collect="always"
id="linearGradient1117"> id="linearGradient1117">
<stop <stop
style="stop-color:#5e5c64;stop-opacity:1" style="stop-color:#5e5c64;stop-opacity:1"
@ -148,7 +110,6 @@
id="stop1115" /> id="stop1115" />
</linearGradient> </linearGradient>
<radialGradient <radialGradient
inkscape:collect="always"
xlink:href="#linearGradient1117" xlink:href="#linearGradient1117"
id="radialGradient1225" id="radialGradient1225"
gradientUnits="userSpaceOnUse" gradientUnits="userSpaceOnUse"
@ -159,7 +120,6 @@
fy="224" fy="224"
r="16" /> r="16" />
<linearGradient <linearGradient
inkscape:collect="always"
id="linearGradient1325"> id="linearGradient1325">
<stop <stop
style="stop-color:#f66151;stop-opacity:1" style="stop-color:#f66151;stop-opacity:1"
@ -175,7 +135,6 @@
id="stop1323" /> id="stop1323" />
</linearGradient> </linearGradient>
<radialGradient <radialGradient
inkscape:collect="always"
xlink:href="#linearGradient976" xlink:href="#linearGradient976"
id="radialGradient1393-0" id="radialGradient1393-0"
gradientUnits="userSpaceOnUse" gradientUnits="userSpaceOnUse"
@ -186,7 +145,6 @@
r="60" r="60"
gradientTransform="matrix(0.26458333,0,0,0.26458333,10.972649,-67.464743)" /> gradientTransform="matrix(0.26458333,0,0,0.26458333,10.972649,-67.464743)" />
<radialGradient <radialGradient
inkscape:collect="always"
xlink:href="#linearGradient1325" xlink:href="#linearGradient1325"
id="radialGradient1395-6" id="radialGradient1395-6"
gradientUnits="userSpaceOnUse" gradientUnits="userSpaceOnUse"
@ -197,7 +155,6 @@
fy="29.856375" fy="29.856375"
r="16.084499" /> r="16.084499" />
<radialGradient <radialGradient
inkscape:collect="always"
xlink:href="#linearGradient1325" xlink:href="#linearGradient1325"
id="radialGradient1397-9-9" id="radialGradient1397-9-9"
gradientUnits="userSpaceOnUse" gradientUnits="userSpaceOnUse"
@ -208,7 +165,6 @@
fy="29.856375" fy="29.856375"
r="16.084499" /> r="16.084499" />
<linearGradient <linearGradient
inkscape:collect="always"
xlink:href="#linearGradient12858" xlink:href="#linearGradient12858"
id="linearGradient12860" id="linearGradient12860"
x1="3467.3748" x1="3467.3748"
@ -217,7 +173,6 @@
y2="-383.00339" y2="-383.00339"
gradientUnits="userSpaceOnUse" /> gradientUnits="userSpaceOnUse" />
<linearGradient <linearGradient
inkscape:collect="always"
xlink:href="#linearGradient8145" xlink:href="#linearGradient8145"
id="linearGradient15076" id="linearGradient15076"
gradientUnits="userSpaceOnUse" gradientUnits="userSpaceOnUse"
@ -227,7 +182,6 @@
x2="-3272.5" x2="-3272.5"
y2="-438.48035" /> y2="-438.48035" />
<radialGradient <radialGradient
inkscape:collect="always"
xlink:href="#linearGradient39832" xlink:href="#linearGradient39832"
id="radialGradient39804" id="radialGradient39804"
cx="3496.6987" cx="3496.6987"
@ -238,7 +192,6 @@
gradientTransform="matrix(0.61314223,0,0,0.49927324,-2207.2336,232.19657)" gradientTransform="matrix(0.61314223,0,0,0.49927324,-2207.2336,232.19657)"
gradientUnits="userSpaceOnUse" /> gradientUnits="userSpaceOnUse" />
<linearGradient <linearGradient
inkscape:collect="always"
xlink:href="#linearGradient8161" xlink:href="#linearGradient8161"
id="linearGradient40220" id="linearGradient40220"
x1="428.48035" x1="428.48035"
@ -248,7 +201,6 @@
gradientUnits="userSpaceOnUse" gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.85000059,0,0,0.85000059,64.271801,-492.37307)" /> gradientTransform="matrix(0.85000059,0,0,0.85000059,64.271801,-492.37307)" />
<linearGradient <linearGradient
inkscape:collect="always"
xlink:href="#linearGradient24559" xlink:href="#linearGradient24559"
id="linearGradient63749" id="linearGradient63749"
gradientUnits="userSpaceOnUse" gradientUnits="userSpaceOnUse"
@ -337,14 +289,10 @@
id="g137264" id="g137264"
style="stroke-width:0.666667"> style="stroke-width:0.666667">
<path <path
sodipodi:nodetypes="sssscccsssss"
style="display:inline;fill:#62a0ea;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.00753214px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;enable-background:new" style="display:inline;fill:#62a0ea;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.00753214px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;enable-background:new"
d="m -3912.0003,-233.37879 c -4.4319,0 -8,3.56799 -8,8 v 27.89844 c 0,4.43202 3.5681,8 8,8 h 36.3333 l 12.6667,12.66667 v -12.66667 h 8.3333 c 4.432,0 8,-3.56798 8,-8 v -27.89844 c 0,-4.43201 -3.568,-8 -8,-8 z" d="m -3912.0003,-233.37879 c -4.4319,0 -8,3.56799 -8,8 v 27.89844 c 0,4.43202 3.5681,8 8,8 h 36.3333 l 12.6667,12.66667 v -12.66667 h 8.3333 c 4.432,0 8,-3.56798 8,-8 v -27.89844 c 0,-4.43201 -3.568,-8 -8,-8 z"
id="path137260" id="path137260" />
inkscape:connector-curvature="0" />
<path <path
sodipodi:nodetypes="cssccccsccsccssc"
inkscape:connector-curvature="0"
id="path137262" id="path137262"
d="m -3920.0003,-199.48035 v 2 c 0,4.43202 3.5681,8 8,8 h 36.3333 l 12.6667,12.66667 v -2 l -12.6667,-12.66667 h -36.3333 c -4.4319,0 -8,-3.56798 -8,-8 z m 73.3333,0 c 0,4.43202 -3.568,8 -8,8 h -8.3333 v 2 h 8.3333 c 4.432,0 8,-3.56798 8,-8 z" d="m -3920.0003,-199.48035 v 2 c 0,4.43202 3.5681,8 8,8 h 36.3333 l 12.6667,12.66667 v -2 l -12.6667,-12.66667 h -36.3333 c -4.4319,0 -8,-3.56798 -8,-8 z m 73.3333,0 c 0,4.43202 -3.568,8 -8,8 h -8.3333 v 2 h 8.3333 c 4.432,0 8,-3.56798 8,-8 z"
style="display:inline;fill:#3584e4;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.00753214px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;enable-background:new" /> style="display:inline;fill:#3584e4;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.00753214px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;enable-background:new" />
@ -374,17 +322,13 @@
id="g137282" id="g137282"
style="stroke-width:0.666667"> style="stroke-width:0.666667">
<path <path
inkscape:connector-curvature="0"
id="path137272" id="path137272"
d="m -3913.6666,-217.48035 c -4.4319,0 -8,3.56798 -8,8 v 24 c 0,4.43202 3.5681,8 8,8 h 15.3333 v 12.66667 l 12.6667,-12.66667 h 45.9999 c 4.432,0 8,-3.56798 8,-8 v -24 c 0,-4.43202 -3.568,-8 -8,-8 z" d="m -3913.6666,-217.48035 c -4.4319,0 -8,3.56798 -8,8 v 24 c 0,4.43202 3.5681,8 8,8 h 15.3333 v 12.66667 l 12.6667,-12.66667 h 45.9999 c 4.432,0 8,-3.56798 8,-8 v -24 c 0,-4.43202 -3.568,-8 -8,-8 z"
style="display:inline;fill:#3d3846;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.00753214px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;enable-background:new" style="display:inline;fill:#3d3846;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.00753214px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;enable-background:new" />
sodipodi:nodetypes="sssscccsssss" />
<path <path
inkscape:connector-curvature="0"
id="path137274" id="path137274"
d="m -3921.6666,-187.48035 v 2 c 0,4.43202 3.5681,8 8,8 h 15.3333 v -2 h -15.3333 c -4.4319,0 -8,-3.56798 -8,-8 z m 89.9999,0 c 0,4.43202 -3.568,8 -8,8 h -45.9999 l -12.6667,12.66667 v 2 l 12.6667,-12.66667 h 45.9999 c 4.432,0 8,-3.56798 8,-8 z" d="m -3921.6666,-187.48035 v 2 c 0,4.43202 3.5681,8 8,8 h 15.3333 v -2 h -15.3333 c -4.4319,0 -8,-3.56798 -8,-8 z m 89.9999,0 c 0,4.43202 -3.568,8 -8,8 h -45.9999 l -12.6667,12.66667 v 2 l 12.6667,-12.66667 h 45.9999 c 4.432,0 8,-3.56798 8,-8 z"
style="display:inline;fill:#241f31;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.00753214px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;enable-background:new" style="display:inline;fill:#241f31;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.00753214px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;enable-background:new" />
sodipodi:nodetypes="cssccsccsccccssc" />
<rect <rect
style="opacity:1;fill:#77767b;fill-opacity:1;stroke:none;stroke-width:9.33333;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" style="opacity:1;fill:#77767b;fill-opacity:1;stroke:none;stroke-width:9.33333;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect137276" id="rect137276"
@ -469,13 +413,12 @@
ry="3.175" /> ry="3.175" />
<text <text
xml:space="preserve" xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:4.23333px;line-height:1.25;font-family:Cantarell;-inkscape-font-specification:'Cantarell Bold';text-align:center;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#ffffff;stroke-width:0.264583" style="font-style:normal;font-variant:normal;font-weight:800;font-stretch:normal;font-size:4.23333px;line-height:1.25;font-family:'Adwaita Sans';-inkscape-font-specification:'Adwaita Sans Ultra-Bold';text-align:center;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#ffffff;stroke-width:0.264583"
x="25.548162" x="25.548162"
y="7.6729164" y="7.6729164"
id="text63745"><tspan id="text63745"><tspan
sodipodi:role="line"
id="tspan63743" id="tspan63743"
style="font-style:normal;font-variant:normal;font-weight:800;font-stretch:normal;font-size:4.23333px;font-family:Cantarell;-inkscape-font-specification:'Cantarell Ultra-Bold';fill:#ffffff;stroke-width:0.264583" style="font-style:normal;font-variant:normal;font-weight:800;font-stretch:normal;font-size:4.23333px;font-family:'Adwaita Sans';-inkscape-font-specification:'Adwaita Sans Ultra-Bold';fill:#ffffff;stroke-width:0.264583"
x="25.548162" x="25.548162"
y="7.6729164">999+</tspan></text> y="7.6729164">999+</tspan></text>
</g> </g>
@ -551,22 +494,12 @@
rx="2" rx="2"
ry="2" /> ry="2" />
<path <path
inkscape:connector-curvature="0"
style="opacity:1;vector-effect:none;fill:#e5a50a;fill-opacity:1;stroke:none;stroke-width:12.8571;stroke-linecap:butt;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" style="opacity:1;vector-effect:none;fill:#e5a50a;fill-opacity:1;stroke:none;stroke-width:12.8571;stroke-linecap:butt;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
d="M 123.82422,231.53906 A 60,60 0 0 1 64,288 60,60 0 0 1 4.17578,232.46094 60,60 0 0 0 4,236 a 60,60 0 0 0 60,60 60,60 0 0 0 60,-60 60,60 0 0 0 -0.17578,-4.46094 z" d="M 123.82422,231.53906 A 60,60 0 0 1 64,288 60,60 0 0 1 4.17578,232.46094 60,60 0 0 0 4,236 a 60,60 0 0 0 60,60 60,60 0 0 0 60,-60 60,60 0 0 0 -0.17578,-4.46094 z"
id="path1193" /> id="path1193" />
<path <path
sodipodi:open="true"
sodipodi:end="3.1415927"
sodipodi:start="0"
sodipodi:ry="7.0068064"
sodipodi:rx="7.6309938"
sodipodi:cy="236.99103"
sodipodi:cx="64"
sodipodi:type="arc"
id="path1195" id="path1195"
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#3d3846;stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#3d3846;stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
sodipodi:arc-type="arc"
d="m 71.630994,236.99103 a 7.6309938,7.0068064 0 0 1 -3.815497,6.06807 7.6309938,7.0068064 0 0 1 -7.630994,0 7.6309938,7.0068064 0 0 1 -3.815497,-6.06807" /> d="m 71.630994,236.99103 a 7.6309938,7.0068064 0 0 1 -3.815497,6.06807 7.6309938,7.0068064 0 0 1 -7.630994,0 7.6309938,7.0068064 0 0 1 -3.815497,-6.06807" />
<rect <rect
ry="8" ry="8"
@ -580,16 +513,7 @@
<path <path
style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#241f31;stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" style="opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#241f31;stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="path1199" id="path1199"
sodipodi:type="arc"
sodipodi:cx="64"
sodipodi:cy="-216"
sodipodi:rx="8"
sodipodi:ry="8"
sodipodi:start="0"
sodipodi:end="3.1415927"
sodipodi:open="true"
transform="scale(1,-1)" transform="scale(1,-1)"
sodipodi:arc-type="arc"
d="m 72,-216 a 8,8 0 0 1 -4,6.9282 8,8 0 0 1 -8,0 A 8,8 0 0 1 56,-216" /> d="m 72,-216 a 8,8 0 0 1 -4,6.9282 8,8 0 0 1 -8,0 A 8,8 0 0 1 56,-216" />
<rect <rect
style="opacity:1;vector-effect:none;fill:#241f31;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" style="opacity:1;vector-effect:none;fill:#241f31;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
@ -640,17 +564,13 @@
transform="matrix(1.5,0,0,1.5,2465.0001,-75.912199)" transform="matrix(1.5,0,0,1.5,2465.0001,-75.912199)"
id="g137294"> id="g137294">
<path <path
sodipodi:nodetypes="sssccssss"
style="display:inline;fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.00753214px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;enable-background:new" style="display:inline;fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.00753214px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;enable-background:new"
d="m -3905.3334,-234.37879 c -4.4319,0 -8,3.56799 -8,8 v 27.33333 c 0,4.43202 3.5681,8 8,8 H -3848 c 4.432,0 8,-3.56798 8,-8 v -27.33333 c 0,-4.43201 -3.568,-8 -8,-8 z" d="m -3905.3334,-234.37879 c -4.4319,0 -8,3.56799 -8,8 v 27.33333 c 0,4.43202 3.5681,8 8,8 H -3848 c 4.432,0 8,-3.56798 8,-8 v -27.33333 c 0,-4.43201 -3.568,-8 -8,-8 z"
id="path137290" id="path137290" />
inkscape:connector-curvature="0" />
<path <path
inkscape:connector-curvature="0"
id="path137292" id="path137292"
d="m -3913.3334,-201.04546 v 2 c 0,4.43202 3.5681,8 8,8 h 19 l 12.6667,12.66667 v -2 l -12.6667,-12.66667 h -19 c -4.4319,0 -8,-3.56798 -8,-8 z m 73.3334,0 c 0,4.43202 -3.568,8 -8,8 h -25.6667 v 2 H -3848 c 4.432,0 8,-3.56798 8,-8 z" d="m -3913.3334,-201.04546 v 2 c 0,4.43202 3.5681,8 8,8 h 19 l 12.6667,12.66667 v -2 l -12.6667,-12.66667 h -19 c -4.4319,0 -8,-3.56798 -8,-8 z m 73.3334,0 c 0,4.43202 -3.568,8 -8,8 h -25.6667 v 2 H -3848 c 4.432,0 8,-3.56798 8,-8 z"
style="display:inline;fill:#deddda;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.00753214px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;enable-background:new" style="display:inline;fill:#deddda;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.00753214px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;enable-background:new" />
sodipodi:nodetypes="cssccccsccsccssc" />
</g> </g>
<rect <rect
y="-412.48035" y="-412.48035"
@ -689,42 +609,28 @@
cy="-5.023077" cy="-5.023077"
r="15.875" /> r="15.875" />
<path <path
inkscape:connector-curvature="0"
style="display:inline;fill:#e5a50a;fill-opacity:1;stroke:none;stroke-width:3.40177;stroke-linecap:butt;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;enable-background:new" style="display:inline;fill:#e5a50a;fill-opacity:1;stroke:none;stroke-width:3.40177;stroke-linecap:butt;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;enable-background:new"
d="M 43.734473,-6.2033675 A 15.875,15.875 0 0 1 27.905982,8.735256 15.875,15.875 0 0 1 12.077491,-5.9594534 a 15.875,15.875 0 0 0 -0.04651,0.9363763 15.875,15.875 0 0 0 15.875,15.8750001 15.875,15.875 0 0 0 15.875,-15.8750001 15.875,15.875 0 0 0 -0.04651,-1.1802904 z" d="M 43.734473,-6.2033675 A 15.875,15.875 0 0 1 27.905982,8.735256 15.875,15.875 0 0 1 12.077491,-5.9594534 a 15.875,15.875 0 0 0 -0.04651,0.9363763 15.875,15.875 0 0 0 15.875,15.8750001 15.875,15.875 0 0 0 15.875,-15.8750001 15.875,15.875 0 0 0 -0.04651,-1.1802904 z"
id="path1369-2" /> id="path1369-2" />
<path <path
sodipodi:open="true"
sodipodi:end="3.1415927"
sodipodi:start="0"
sodipodi:ry="1.8538842"
sodipodi:rx="2.0190337"
sodipodi:cy="-4.7608676"
sodipodi:cx="27.905983"
sodipodi:type="arc"
id="path1371-3" id="path1371-3"
style="display:inline;opacity:1;fill:none;fill-opacity:1;stroke:#3d3846;stroke-width:1.05833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;enable-background:new" style="display:inline;opacity:1;fill:none;fill-opacity:1;stroke:#3d3846;stroke-width:1.05833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;enable-background:new"
sodipodi:arc-type="arc"
d="m 29.925017,-4.7608676 a 2.0190337,1.8538842 0 0 1 -1.009517,1.6055108 2.0190337,1.8538842 0 0 1 -2.019034,0 2.0190337,1.8538842 0 0 1 -1.009517,-1.6055108" /> d="m 29.925017,-4.7608676 a 2.0190337,1.8538842 0 0 1 -1.009517,1.6055108 2.0190337,1.8538842 0 0 1 -2.019034,0 2.0190337,1.8538842 0 0 1 -1.009517,-1.6055108" />
<path <path
inkscape:connector-curvature="0"
id="path1373-7" id="path1373-7"
d="m 24.707728,-13.490777 a 2.1168783,2.1168783 0 0 0 -1.459859,0.642337 l -0.620118,0.620117 -0.620117,-0.620117 a 2.1168783,2.1168783 0 0 0 -1.51877,-0.641302 2.1168783,2.1168783 0 0 0 -1.474329,3.6349196 l 2.865975,2.8659745 v -0.00212 a 1.0583333,1.0583333 0 0 0 1.49655,0 l 2.863907,-2.8639082 A 2.1168783,2.1168783 0 0 0 24.707728,-13.49083 Z" d="m 24.707728,-13.490777 a 2.1168783,2.1168783 0 0 0 -1.459859,0.642337 l -0.620118,0.620117 -0.620117,-0.620117 a 2.1168783,2.1168783 0 0 0 -1.51877,-0.641302 2.1168783,2.1168783 0 0 0 -1.474329,3.6349196 l 2.865975,2.8659745 v -0.00212 a 1.0583333,1.0583333 0 0 0 1.49655,0 l 2.863907,-2.8639082 A 2.1168783,2.1168783 0 0 0 24.707728,-13.49083 Z"
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:url(#radialGradient1395-6);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:4.23333;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" /> style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:url(#radialGradient1395-6);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:4.23333;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" />
<path <path
inkscape:connector-curvature="0"
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:url(#radialGradient1397-9-9);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:4.23333;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:url(#radialGradient1397-9-9);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:4.23333;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 35.291109,-13.490777 a 2.1168783,2.1168783 0 0 0 -1.459859,0.642337 l -0.620118,0.620117 -0.620117,-0.620117 a 2.1168783,2.1168783 0 0 0 -1.51877,-0.641302 2.1168783,2.1168783 0 0 0 -1.474329,3.6349196 l 2.865975,2.8659745 v -0.00212 a 1.0583333,1.0583333 0 0 0 1.49655,0 l 2.863908,-2.8639082 a 2.1168783,2.1168783 0 0 0 -1.53324,-3.6359539 z" d="m 35.291109,-13.490777 a 2.1168783,2.1168783 0 0 0 -1.459859,0.642337 l -0.620118,0.620117 -0.620117,-0.620117 a 2.1168783,2.1168783 0 0 0 -1.51877,-0.641302 2.1168783,2.1168783 0 0 0 -1.474329,3.6349196 l 2.865975,2.8659745 v -0.00212 a 1.0583333,1.0583333 0 0 0 1.49655,0 l 2.863908,-2.8639082 a 2.1168783,2.1168783 0 0 0 -1.53324,-3.6359539 z"
id="path1375-3-5" /> id="path1375-3-5" />
<path <path
id="path1377-9" id="path1377-9"
d="m 18.412505,-11.77202 a 2.1168783,2.1168783 0 0 0 0.60203,1.9171976 l 2.865975,2.8659745 v -0.00212 a 1.0583333,1.0583333 0 0 0 1.49655,0 l 2.863907,-2.8639109 a 2.1168783,2.1168783 0 0 0 0.604615,-1.9166782 2.1168783,2.1168783 0 0 1 -0.604615,1.122929 l -2.863907,2.8639074 a 1.0583333,1.0583333 0 0 1 -1.49655,0 v 0.00212 l -2.865975,-2.8659714 a 2.1168783,2.1168783 0 0 1 -0.60203,-1.123448 z" d="m 18.412505,-11.77202 a 2.1168783,2.1168783 0 0 0 0.60203,1.9171976 l 2.865975,2.8659745 v -0.00212 a 1.0583333,1.0583333 0 0 0 1.49655,0 l 2.863907,-2.8639109 a 2.1168783,2.1168783 0 0 0 0.604615,-1.9166782 2.1168783,2.1168783 0 0 1 -0.604615,1.122929 l -2.863907,2.8639074 a 1.0583333,1.0583333 0 0 1 -1.49655,0 v 0.00212 l -2.865975,-2.8659714 a 2.1168783,2.1168783 0 0 1 -0.60203,-1.123448 z"
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#c01c28;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:4.23333;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#c01c28;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:4.23333;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" />
inkscape:connector-curvature="0" />
<path <path
id="path1379-6-2" id="path1379-6-2"
d="m 28.995838,-11.77202 a 2.1168783,2.1168783 0 0 0 0.60203,1.9171976 l 2.865975,2.8659745 v -0.00212 a 1.0583333,1.0583333 0 0 0 1.49655,0 l 2.863908,-2.8639109 a 2.1168783,2.1168783 0 0 0 0.604614,-1.9166782 2.1168783,2.1168783 0 0 1 -0.604614,1.122929 l -2.863908,2.8639074 a 1.0583333,1.0583333 0 0 1 -1.49655,0 v 0.00212 l -2.865975,-2.8659714 a 2.1168783,2.1168783 0 0 1 -0.60203,-1.123448 z" d="m 28.995838,-11.77202 a 2.1168783,2.1168783 0 0 0 0.60203,1.9171976 l 2.865975,2.8659745 v -0.00212 a 1.0583333,1.0583333 0 0 0 1.49655,0 l 2.863908,-2.8639109 a 2.1168783,2.1168783 0 0 0 0.604614,-1.9166782 2.1168783,2.1168783 0 0 1 -0.604614,1.122929 l -2.863908,2.8639074 a 1.0583333,1.0583333 0 0 1 -1.49655,0 v 0.00212 l -2.865975,-2.8659714 a 2.1168783,2.1168783 0 0 1 -0.60203,-1.123448 z"
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#c01c28;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:4.23333;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#c01c28;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:4.23333;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" />
inkscape:connector-curvature="0" />
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 33 KiB

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px"><path d="m 13.980469 1.988281 c -0.261719 0.007813 -0.507813 0.117188 -0.6875 0.304688 l -0.984375 0.984375 c -1.285156 -0.828125 -2.78125 -1.273438 -4.308594 -1.277344 c -3.648438 0.003906 -6.832031 2.476562 -7.738281 6.011719 c 0.460937 1.746093 1.496093 3.285156 2.941406 4.371093 l -0.910156 0.910157 c -0.261719 0.25 -0.367188 0.625 -0.273438 0.972656 c 0.089844 0.351563 0.363281 0.625 0.714844 0.714844 c 0.347656 0.09375 0.722656 -0.011719 0.972656 -0.273438 l 11 -11 c 0.296875 -0.289062 0.382813 -0.726562 0.222657 -1.105469 c -0.160157 -0.382812 -0.539063 -0.625 -0.949219 -0.613281 z m -5.980469 2.011719 c 0.957031 0 1.886719 0.347656 2.609375 0.976562 l -1.417969 1.417969 c -0.34375 -0.257812 -0.761718 -0.394531 -1.191406 -0.394531 c -1.105469 0 -2 0.894531 -2 2 c 0 0.429688 0.140625 0.847656 0.394531 1.1875 l -1.417969 1.421875 c -0.628906 -0.726563 -0.972656 -1.652344 -0.976562 -2.609375 c 0 -2.210938 1.789062 -4 4 -4 z m 7.027344 2.207031 l -3.34375 3.34375 c -0.402344 0.960938 -1.167969 1.722657 -2.125 2.128907 l -2.28125 2.277343 c 0.242187 0.027344 0.480468 0.039063 0.722656 0.042969 c 3.648438 -0.003906 6.832031 -2.476562 7.738281 -6.011719 c -0.164062 -0.617187 -0.402343 -1.214843 -0.710937 -1.78125 z m -7.527344 0.792969 c 0.277344 0 0.5 0.222656 0.5 0.5 s -0.222656 0.5 -0.5 0.5 s -0.5 -0.222656 -0.5 -0.5 s 0.222656 -0.5 0.5 -0.5 z m 0 0" fill="#222222"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="128px" viewBox="0 0 128 128" width="128px" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<linearGradient id="a" gradientUnits="userSpaceOnUse" x1="8" x2="58" y1="69.999985" y2="69.999985">
<stop offset="0" stop-color="#4aaac9"/>
<stop offset="0.16" stop-color="#8bddf7"/>
<stop offset="0.32" stop-color="#4aaac9"/>
<stop offset="1" stop-color="#4aaac9"/>
</linearGradient>
<linearGradient id="b" gradientUnits="userSpaceOnUse" x1="31.462524" x2="39" y1="113.997253" y2="113.997253">
<stop offset="0" stop-color="#4aaac9"/>
<stop offset="0.469318" stop-color="#74d7f7"/>
<stop offset="1" stop-color="#4aaac9"/>
</linearGradient>
<linearGradient id="c" gradientUnits="userSpaceOnUse" x1="104" x2="120" y1="84" y2="84">
<stop offset="0" stop-color="#1a5fb4"/>
<stop offset="0.5" stop-color="#4296ff"/>
<stop offset="1" stop-color="#1a5fb4"/>
</linearGradient>
<clipPath id="d">
<path d="m 8 24 h 97 v 84 h -97 z m 0 0"/>
</clipPath>
<clipPath id="e">
<path d="m 24 24 h 80 c 8.835938 0 16 7.164062 16 16 v 52 c 0 8.835938 -7.164062 16 -16 16 h -80 c -8.835938 0 -16 -7.164062 -16 -16 v -52 c 0 -8.835938 7.164062 -16 16 -16 z m 0 0"/>
</clipPath>
<linearGradient id="f" gradientUnits="userSpaceOnUse" x1="55.608135" x2="71.783539" y1="100" y2="48.532928">
<stop offset="0" stop-color="#81dffe"/>
<stop offset="1" stop-color="#9bf8fe"/>
</linearGradient>
<filter id="g" height="100%" width="100%" x="0%" y="0%">
<feColorMatrix in="SourceGraphic" type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
</filter>
<mask id="h">
<g filter="url(#g)">
<rect fill-opacity="0.35" height="128" width="128"/>
</g>
</mask>
<clipPath id="i">
<rect height="152" width="192"/>
</clipPath>
<path d="m 24 28 h 72 c 8.835938 0 16 7.164062 16 16 v 52 c 0 8.835938 -7.164062 16 -16 16 h -72 c -8.835938 0 -16 -7.164062 -16 -16 v -52 c 0 -8.835938 7.164062 -16 16 -16 z m 0 0" fill="url(#a)"/>
<path d="m 24 28 h 80 c 8.835938 0 16 7.164062 16 16 v 48 c 0 8.835938 -7.164062 16 -16 16 h -80 c -8.835938 0 -16 -7.164062 -16 -16 v -48 c 0 -8.835938 7.164062 -16 16 -16 z m 0 0" fill="#53bde0"/>
<path d="m 24 100 v 12 h 4 c 2.210938 0 4 1.789062 4 4 v 7 c 0 1.992188 1.183594 3.792969 3.011719 4.585938 c 1.828125 0.789062 3.953125 0.417968 5.40625 -0.945313 l 13.523437 -12.707031 c 1.324219 -1.242188 3.070313 -1.933594 4.882813 -1.933594 h 9.175781 v -12 z m 0 0" fill="url(#b)" fill-rule="evenodd"/>
<path d="m 102 58.566406 h 2 c 8.835938 0 16 7.164063 16 16 v 21.433594 c 0 8.835938 -7.164062 16 -16 16 h -2 c -8.835938 0 -16 -7.164062 -16 -16 v -21.433594 c 0 -8.835937 7.164062 -16 16 -16 z m 0 0" fill="url(#c)"/>
<path d="m 86 87 h 18 v 25 h -18 z m 0 0" fill="#1a5fb4"/>
<path d="m 48 24 h 56 c 8.835938 0 16 7.164062 16 16 v 52 c 0 8.835938 -7.164062 16 -16 16 h -56 c -8.835938 0 -16 -7.164062 -16 -16 v -52 c 0 -8.835938 7.164062 -16 16 -16 z m 0 0" fill="#3584e4"/>
<g clip-path="url(#d)">
<g clip-path="url(#e)">
<path d="m 78.804688 16.023438 l 0.527343 2.460937 c -1.207031 -0.082031 -2.417969 4.964844 -3.621093 4.988281 c -19.335938 0.371094 -38.003907 14.230469 -39.148438 34.546875 c -0.835938 14.761719 9.570312 29.839844 25.15625 30.488281 c 10.371094 0.433594 20.96875 -6.957031 21.242188 -17.925781 c 0.179687 -7.078125 -4.953126 -14.3125 -12.488282 -14.355469 c -4.683594 -0.027343 -9.484375 3.425782 -9.398437 8.429688 c 0.074219 2.980469 2.300781 6.042969 5.511719 5.902344 c 1.8125 -0.082032 3.691406 -1.488282 3.539062 -3.453125 c -0.078125 -1.042969 -0.921875 -2.128907 -2.0625 -1.996094 c -0.5625 0.066406 -1.148438 0.539063 -1.046875 1.15625 c 0.070313 0.273437 0.285156 0.570313 0.597656 0.5 c 0.121094 -0.03125 0.25 -0.144531 0.214844 -0.28125 c 0 -0.042969 -0.070313 -0.09375 -0.113281 -0.074219 c 0 0.003906 -0.070313 0.023438 0 0.035156 v 0.007813 v -0.003906 v 0.035156 c 0 0.050781 -0.09375 0.050781 -0.136719 0.03125 c -0.121094 -0.066406 -0.113281 -0.242187 -0.070313 -0.347656 c 0.164063 -0.265625 0.542969 -0.230469 0.777344 -0.074219 c 0.519532 0.367188 0.429688 1.117188 0.070313 1.558594 c -0.710938 0.898437 -2.074219 0.726562 -2.867188 0.042968 c -1.5 -1.28125 -1.167969 -3.601562 0.070313 -4.941406 c 2.167968 -2.367187 5.929687 -1.792968 8.074218 0.28125 c 3.601563 3.476563 2.652344 9.308594 -0.675781 12.597656 c -5.359375 5.292969 -14.109375 3.800782 -18.992187 -1.324218 c -7.570313 -7.953125 -5.304688 -20.664063 2.335937 -27.6875 c 11.480469 -10.550782 29.507813 -7.242188 39.363281 3.785156 c 14.414063 16.121094 9.6875 41.066406 -5.855468 54.582031 c -11.121094 9.226563 -22.246094 15.429688 -32.949219 19.4375 c -13.058594 75.445313 -75.230469 6.835938 -81.039063 -4.195312 l 0.285157 -105.054688 z m 0 0" fill="url(#f)"/>
</g>
</g>
<path d="m 24 106 v 2 h 4 c 2.210938 0 4 1.789062 4 4 v 7 c 0 1.992188 1.183594 3.792969 3.011719 4.585938 c 1.828125 0.789062 3.953125 0.417968 5.40625 -0.945313 l 13.523437 -12.707031 c 1.324219 -1.242188 3.070313 -1.933594 4.882813 -1.933594 h 9.175781 v -2 z m 0 0" fill="#81dffe" fill-rule="evenodd"/>
<g clip-path="url(#i)" mask="url(#h)" transform="matrix(1 0 0 1 -8 -16)">
<path d="m 173 17 h 8 c 1.65625 0 3 1.34375 3 3 v 7 c 0 1.65625 -1.34375 3 -3 3 h -8 c -1.65625 0 -3 -1.34375 -3 -3 v -7 c 0 -1.65625 1.34375 -3 3 -3 z m 0 0" fill="#241f31"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px"><path d="m 11.019531 7.996094 c 0 1.65625 -1.34375 3 -3 3 s -3 -1.34375 -3 -3 s 1.34375 -3 3 -3 s 3 1.34375 3 3 z m 0 0" fill="#222222"/></svg>

After

Width:  |  Height:  |  Size: 270 B

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 8 1 c -1.65625 0 -3 1.34375 -3 3 s 1.34375 3 3 3 s 3 -1.34375 3 -3 s -1.34375 -3 -3 -3 z m -1.5 7 c -2.492188 0 -4.5 2.007812 -4.5 4.5 v 0.5 c 0 1.109375 0.890625 2 2 2 h 8 c 1.109375 0 2 -0.890625 2 -2 v -0.5 c 0 -2.492188 -2.007812 -4.5 -4.5 -4.5 z m 0 0" fill="#2e3436"/>
</svg>

After

Width:  |  Height:  |  Size: 424 B

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<path d="m 8 0 c 0.554688 0 1 0.445312 1 1 v 6.5 s 0 0.5 0.5 0.5 s 0.5 -0.5 0.5 -0.5 v -4.5 c 0 -0.554688 0.445312 -1 1 -1 s 1 0.445312 1 1 v 8.5 c 0 0.5 0.5 0.5 0.5 0.5 l 1.792969 -1.707031 c 0.1875 -0.195313 0.445312 -0.300781 0.71875 -0.304688 c 1.082031 0.085938 1.144531 1.269531 0.695312 1.71875 l -3 3 c -0.707031 0.792969 -1.757812 1.289063 -2.707031 1.292969 h -6 c -3 0 -3 -3 -3 -3 v -8 c 0 -0.554688 0.445312 -1 1 -1 s 1 0.445312 1 1 v 3.5 s 0 0.5 0.5 0.5 s 0.5 -0.5 0.5 -0.5 v -6.5 c 0 -0.554688 0.445312 -1 1 -1 s 1 0.445312 1 1 v 5.5 s 0 0.5 0.5 0.5 s 0.5 -0.5 0.5 -0.5 v -6.5 c 0 -0.554688 0.445312 -1 1 -1 z m 0 0" fill="#2e3436"/>
</svg>

After

Width:  |  Height:  |  Size: 786 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><g color="#bebebe" fill="#2e3436"><path d="M6 0a3 3 0 100 6 3 3 0 000-6zM4.5 7A4.49 4.49 0 000 11.5v.5c0 1 1 1 1 1h6V8.875c0-.83.587-1.554 1.355-1.79A4.532 4.532 0 007.5 7zM9 9v4h1V9z" style="marker:none" overflow="visible"/><path d="M8.875 8A.863.863 0 008 8.875v6.25c0 .492.383.875.875.875h6.25a.863.863 0 00.875-.875v-6.25A.863.863 0 0015.125 8zM11 9h2v1h-2zm0 2h2v4h-2z" style="marker:none" overflow="visible"/></g></svg>

After

Width:  |  Height:  |  Size: 488 B

View File

@ -18,6 +18,7 @@
<file preprocess="xml-stripblanks">icons/scalable/actions/go-bottom-symbolic.svg</file> <file preprocess="xml-stripblanks">icons/scalable/actions/go-bottom-symbolic.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/actions/go-next-symbolic.svg</file> <file preprocess="xml-stripblanks">icons/scalable/actions/go-next-symbolic.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/actions/go-previous-symbolic.svg</file> <file preprocess="xml-stripblanks">icons/scalable/actions/go-previous-symbolic.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/actions/hide-symbolic.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/actions/idp-apple-dark.svg</file> <file preprocess="xml-stripblanks">icons/scalable/actions/idp-apple-dark.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/actions/idp-apple.svg</file> <file preprocess="xml-stripblanks">icons/scalable/actions/idp-apple.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/actions/idp-facebook.svg</file> <file preprocess="xml-stripblanks">icons/scalable/actions/idp-facebook.svg</file>
@ -42,13 +43,16 @@
<file preprocess="xml-stripblanks">icons/scalable/actions/settings-symbolic.svg</file> <file preprocess="xml-stripblanks">icons/scalable/actions/settings-symbolic.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/actions/system-search-symbolic.svg</file> <file preprocess="xml-stripblanks">icons/scalable/actions/system-search-symbolic.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/actions/user-add-symbolic.svg</file> <file preprocess="xml-stripblanks">icons/scalable/actions/user-add-symbolic.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/apps/org.gnome.Fractal.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/status/audio-symbolic.svg</file> <file preprocess="xml-stripblanks">icons/scalable/status/audio-symbolic.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/status/blocked-symbolic.svg</file> <file preprocess="xml-stripblanks">icons/scalable/status/blocked-symbolic.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/status/checkmark-symbolic.svg</file> <file preprocess="xml-stripblanks">icons/scalable/status/checkmark-symbolic.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/status/devices-symbolic.svg</file> <file preprocess="xml-stripblanks">icons/scalable/status/devices-symbolic.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/status/document-symbolic.svg</file> <file preprocess="xml-stripblanks">icons/scalable/status/document-symbolic.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/status/done-symbolic.svg</file> <file preprocess="xml-stripblanks">icons/scalable/status/done-symbolic.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/status/dot-symbolic.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/status/empty-page-symbolic.svg</file> <file preprocess="xml-stripblanks">icons/scalable/status/empty-page-symbolic.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/status/encryption-symbolic.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/status/error-symbolic.svg</file> <file preprocess="xml-stripblanks">icons/scalable/status/error-symbolic.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/status/explore-symbolic.svg</file> <file preprocess="xml-stripblanks">icons/scalable/status/explore-symbolic.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/status/home-symbolic.svg</file> <file preprocess="xml-stripblanks">icons/scalable/status/home-symbolic.svg</file>
@ -56,10 +60,12 @@
<file preprocess="xml-stripblanks">icons/scalable/status/key-symbolic.svg</file> <file preprocess="xml-stripblanks">icons/scalable/status/key-symbolic.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/status/no-camera-symbolic.svg</file> <file preprocess="xml-stripblanks">icons/scalable/status/no-camera-symbolic.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/status/notifications-symbolic.svg</file> <file preprocess="xml-stripblanks">icons/scalable/status/notifications-symbolic.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/status/security-symbolic.svg</file> <file preprocess="xml-stripblanks">icons/scalable/status/person-symbolic.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/status/safety-symbolic.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/status/sync-off-symbolic.svg</file> <file preprocess="xml-stripblanks">icons/scalable/status/sync-off-symbolic.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/status/sync-on-symbolic.svg</file> <file preprocess="xml-stripblanks">icons/scalable/status/sync-on-symbolic.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/status/sync-partial-symbolic.svg</file> <file preprocess="xml-stripblanks">icons/scalable/status/sync-partial-symbolic.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/status/user-info-symbolic.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/status/users-symbolic.svg</file> <file preprocess="xml-stripblanks">icons/scalable/status/users-symbolic.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/status/verified-danger-symbolic.svg</file> <file preprocess="xml-stripblanks">icons/scalable/status/verified-danger-symbolic.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/status/verified-symbolic.svg</file> <file preprocess="xml-stripblanks">icons/scalable/status/verified-symbolic.svg</file>

View File

@ -115,3 +115,7 @@ scrolledwindow.card {
padding: 12px; padding: 12px;
} }
} }
.padded-top-bar {
padding: 0 12px;
}

View File

@ -2,7 +2,6 @@
@use 'vendor'; @use 'vendor';
// Visual media history viewer
visual-media-history-viewer { visual-media-history-viewer {
background: black; background: black;
color: white; color: white;
@ -19,7 +18,7 @@ visual-media-history-viewer {
} }
visual-media-history-viewer-item { visual-media-history-viewer-item {
background: none; background-color: var(--border-color);
transition: vendor.$ease-out-quad; transition: vendor.$ease-out-quad;
&:hover, &:focus { &:hover, &:focus {
@ -48,7 +47,6 @@ file-history-viewer, audio-history-viewer {
} }
} }
// Room details
.room-details listview { .room-details listview {
background: transparent; background: transparent;
} }
@ -109,3 +107,11 @@ permissions-member-row {
background-color: vendor.$active_color; background-color: vendor.$active_color;
} }
} }
.user-search-results {
padding: 12px 0px;
> row {
border-radius: vendor.$menu_radius;
}
}

View File

@ -3,6 +3,40 @@
@use 'config'; @use 'config';
@use 'vendor'; @use 'vendor';
%nested-effect {
border-left: 2px solid var(--accent-bg-color);
padding-left: 6px;
opacity: if(config.$contrast == 'high', 90%, 70%);
}
room-title {
margin-top: -6px;
margin-bottom: -6px;
min-height: 12px;
padding: 3px 0;
.title {
padding: 0;
font-weight: bold;
}
.subtitle {
padding: 0;
font-weight: normal;
}
&.with-subtitle {
button {
padding-top: 0;
padding-bottom: 0;
}
.title, .subtitle {
margin-top: -0.2rem;
}
}
}
.room-history .room-history-list { .room-history .room-history-list {
padding-bottom: 0; padding-bottom: 0;
@ -13,7 +47,7 @@
} }
} }
room-history-row { .room-history-row {
padding-top: 2px; padding-top: 2px;
padding-bottom: 2px; padding-bottom: 2px;
padding-left: 8px; padding-left: 8px;
@ -22,12 +56,12 @@ room-history-row {
@include vendor.focus-ring(); @include vendor.focus-ring();
&.has-header { &.has-avatar {
margin-top: 6px; margin-top: 6px;
} }
&:not(.has-header) { &:not(.has-avatar) {
.event-content, message-reactions { .event-content {
&:dir(ltr) { &:dir(ltr) {
margin-left: 54px; margin-left: 54px;
} }
@ -65,30 +99,241 @@ room-history-row {
} }
} }
} }
}
.event-content { sender-avatar {
.emoji { padding: 5px;
font-size: 3em; border-radius: 100%;
@include vendor.focus-ring();
&:hover {
background-color: vendor.$hover_color;
image {
filter: brightness(1.07) ;
}
}
&:active {
background-color: vendor.$active_color;
image {
filter: brightness(1.16) ;
}
}
&:checked {
background-color: vendor.$selected_color;
image {
filter: brightness(1.1) ;
}
}
popover button.text-button {
padding-left: 10px;
padding-right: 10px;
font-weight: 400;
}
}
.event-content {
.h1 {
font-weight: 800;
font-size: 15pt;
}
.h2 {
font-weight: 800;
font-size: 14pt;
}
.h3 {
font-weight: 700;
font-size: 14pt;
}
.h4 {
font-weight: 700;
font-size: 13pt;
}
.h5 {
font-weight: 700;
font-size: 12pt;
}
.h6 {
font-weight: 700;
font-size: 11pt;
}
.emoji-message {
font-size: 3em;
}
.emote {
color: var(--accent-color);
}
.quote {
@extend %nested-effect;
}
expander-widget > box > {
title {
border-spacing: 6px;
} }
.emote { :not(title) {
color: var(--accent-color); padding: 12px;
}
}
.codeview {
border-radius: vendor.$menu_radius;
padding: 6px;
font-family: monospace;
background-color: var(--text-view-bg);
color: var(--view-fg-color);
}
.timestamp {
min-width: 36px;
font-weight: normal;
}
}
state-group-row.room-history-row {
&:not(.has-avatar) {
.event-content {
&:dir(ltr) {
margin-left: 42px;
}
&:dir(rtl) {
margin-right: 42px;
}
}
}
.expander-title {
padding: 6px 12px;
border-radius: vendor.$menu_radius;
&:hover {
background-color: vendor.$button_hover_color;
}
&:active {
background-color: vendor.$button_active_color;
}
}
image.arrow {
transition: 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
&:not(:checked) image.arrow {
&:dir(ltr) {
transform: rotate(-0.5turn);
}
&:dir(rtl) {
transform: rotate(0.5turn);
}
}
.expander-content {
padding: 3px 6px;
background-color: color-mix(in srgb, var(--view-fg-color) 4%, transparent);
border-radius: vendor.$menu_radius;
}
state-group-item-row {
padding: 6px 12px;
margin: 2px 0;
border-radius: vendor.$menu_radius;
@include vendor.focus-ring();
&.has-open-popup {
background-color: vendor.$hover_color;
} }
} }
} }
message-visual-media {
border-radius: vendor.$menu_radius;
@include vendor.focus-ring();
}
.visual-content { .visual-content {
&.opaque-bg { &.opaque-bg {
border-radius: vendor.$menu_radius;
background-color: var(--border-color); background-color: var(--border-color);
} }
.spinner { > .overlaid {
margin: 6px;
}
> .instructions {
padding: 12px;
border-radius: vendor.$menu_radius;
}
&.compact {
> .instructions {
padding: 6px;
}
}
// Copied from .osd button style in https://gitlab.gnome.org/GNOME/libadwaita/-/blob/main/src/stylesheet/widgets/_buttons.scss
&.has-placeholder {
> .instructions {
color: vendor.$osd_fg_color;
background-color: rgb(0 0 0 / 65%);
@if config.$contrast == 'high' {
box-shadow: 0 0 0 1px currentColor;
}
}
&:not(.compact) {
&:hover {
> .instructions {
color: white;
background-color: color-mix(in srgb, black calc(0.85 * 65%), currentColor calc(0.15 * 65%));
}
}
&:active {
> .instructions {
color: white;
background-color: color-mix(in srgb, black calc(0.75 * 65%), currentColor calc(0.25 * 65%));
}
}
}
}
> .spinner {
min-width: 32px; min-width: 32px;
min-height: 32px; min-height: 32px;
} }
image.osd.circular { > button {
// Leave enough space at the start to click to be able to view small images.
&:dir(ltr) {
margin-left: 64px;
}
&:dir(rtl) {
margin-right: 64px;
}
}
> image.osd.circular {
min-width: 64px; min-width: 64px;
min-height: 64px; min-height: 64px;
border-radius: 32px; border-radius: 32px;
@ -101,12 +346,12 @@ room-history-row {
} }
&.compact { &.compact {
.spinner { > .spinner {
min-width: 16px; min-width: 16px;
min-height: 16px; min-height: 16px;
} }
image.osd.circular { > image.osd.circular {
min-width: 32px; min-width: 32px;
min-height: 32px; min-height: 32px;
-gtk-icon-size: 16px; -gtk-icon-size: 16px;
@ -114,21 +359,14 @@ room-history-row {
} }
} }
room-history-row .event-content .quote,
.related-event-content {
border-left: 2px solid var(--accent-bg-color);
padding-left: 6px;
opacity: if(config.$contrast == 'high', 90%, 70%);;
}
message-reactions flowboxchild {
&:hover, &:active {
// Cancel effect under .navigation-sidebar from libadwaita
background-color: transparent;
}
}
message-reactions { message-reactions {
flowboxchild {
&:hover, &:active {
// Cancel effect under .navigation-sidebar from libadwaita
background-color: transparent;
}
}
&:dir(ltr) .toggle { &:dir(ltr) .toggle {
padding: 1px 0 1px 6px; padding: 1px 0 1px 6px;
} }
@ -137,11 +375,11 @@ message-reactions {
padding: 1px 6px 1px 0; padding: 1px 6px 1px 0;
} }
.reaction-key { .reaction-key-text {
font-size: 0.8em; font-size: 0.8em;
} }
.reaction-key.emoji { .reaction-key-emoji {
font-size: 1.1em; font-size: 1.1em;
padding-right: 2px; padding-right: 2px;
padding-left: 2px; padding-left: 2px;
@ -205,17 +443,9 @@ divider-row {
} }
} }
.timestamp { typing-row {
min-width: 36px; padding: 0 6px;
font-weight: normal; min-height: 30px;
}
.codeview {
border-radius: vendor.$menu_radius;
padding: 6px;
font-family: monospace;
background-color: var(--text-view-bg);
color: var(--view-fg-color);
} }
.related-event-toolbar { .related-event-toolbar {
@ -226,125 +456,18 @@ divider-row {
min-height: 24px; min-height: 24px;
min-width: 24px; min-width: 24px;
} }
}
.related-event-content { .event-content {
padding-top: 2px; @extend %nested-effect;
padding-bottom: 2px; padding-top: 2px;
} padding-bottom: 2px;
typing-row {
padding: 0 6px;
min-height: 30px;
}
room-history-row, .related-event-content {
.h1 {
font-weight: 800;
font-size: 15pt;
}
.h2 {
font-weight: 800;
font-size: 14pt;
}
.h3 {
font-weight: 700;
font-size: 14pt;
}
.h4 {
font-weight: 700;
font-size: 13pt;
}
.h5 {
font-weight: 700;
font-size: 12pt;
}
.h6 {
font-weight: 700;
font-size: 11pt;
}
}
room-history-row expander-widget > box > {
title {
border-spacing: 6px;
}
:not(title) {
padding: 12px;
}
}
room-title {
margin-top: -6px;
margin-bottom: -6px;
min-height: 12px;
padding: 3px 0;
.title {
padding: 0;
font-weight: bold;
}
.subtitle {
padding: 0;
font-weight: normal;
}
&.with-subtitle {
button {
padding-top: 0;
padding-bottom: 0;
}
.title, .subtitle {
margin-top: -0.2rem;
}
}
}
sender-avatar {
padding: 3px;
border-radius: 100%;
@include vendor.focus-ring();
&:hover {
background-color: vendor.$hover_color;
image {
filter: brightness(1.07) ;
}
}
&:active {
background-color: vendor.$active_color;
image {
filter: brightness(1.16) ;
}
}
&:checked {
background-color: vendor.$selected_color;
image {
filter: brightness(1.1) ;
}
}
popover button.text-button {
padding-left: 10px;
padding-right: 10px;
font-weight: 400;
} }
} }
button.send-text-message-button image { button.send-text-message-button image {
transform: translateX(2px); transform: translateX(2px);
} }
.composer-replacement {
margin: 12px;
}

View File

@ -157,7 +157,7 @@ sidebar {
color: var(--accent-color); color: var(--accent-color);
} }
.dim-label, &.drop-empty .dim-label { .dimmed, &.drop-empty .dimmed {
opacity: 1; opacity: 1;
} }
} }
@ -199,14 +199,6 @@ sidebar {
font-size: 1.6em; font-size: 1.6em;
} }
.invite-search-results {
padding: 12px 0px;
> row {
border-radius: vendor.$menu_radius;
}
}
// Event details dialog // Event details dialog
.event-details-dialog .sourceview { .event-details-dialog .sourceview {
font-family: monospace; font-family: monospace;

View File

@ -10,6 +10,7 @@ $active_color: color-mix(in srgb, currentColor 16%, transparent);
$selected_color: color-mix(in srgb, currentColor 10%, transparent); $selected_color: color-mix(in srgb, currentColor 10%, transparent);
$selected_hover_color: color-mix(in srgb, currentColor 13%, transparent); $selected_hover_color: color-mix(in srgb, currentColor 13%, transparent);
$selected_active_color: color-mix(in srgb, currentColor 19%, transparent); $selected_active_color: color-mix(in srgb, currentColor 19%, transparent);
$osd_fg_color: RGB(255 255 255 / 90%);
// https://gitlab.gnome.org/GNOME/libadwaita/-/blob/1.6.1/src/stylesheet/widgets/_buttons.scss // https://gitlab.gnome.org/GNOME/libadwaita/-/blob/1.6.1/src/stylesheet/widgets/_buttons.scss
$button_color: color-mix(in srgb, currentColor 10%, transparent); $button_color: color-mix(in srgb, currentColor 10%, transparent);
@ -21,9 +22,8 @@ $ease-out-quad: cubic-bezier(0.25, 0.46, 0.45, 0.94);
$focus_transition: outline-color 200ms $ease-out-quad, $focus_transition: outline-color 200ms $ease-out-quad,
outline-width 200ms $ease-out-quad, outline-width 200ms $ease-out-quad,
outline-offset 200ms $ease-out-quad; outline-offset 200ms $ease-out-quad;
$button_radius: 6px; $card_radius: 12px;
$card_radius: $button_radius + 6; $menu_radius: 9px;
$menu_radius: 6px;
// https://gitlab.gnome.org/GNOME/libadwaita/-/blob/1.6.1/src/stylesheet/_drawing.scss // https://gitlab.gnome.org/GNOME/libadwaita/-/blob/1.6.1/src/stylesheet/_drawing.scss
@mixin focus-ring($target: null, $width: 2px, $offset: -$width, $outer: false, $focus-state: ':focus:focus-visible', $transition: null) { @mixin focus-ring($target: null, $width: 2px, $offset: -$width, $outer: false, $focus-state: ':focus:focus-visible', $transition: null) {

34
deny.toml Normal file
View File

@ -0,0 +1,34 @@
[advisories]
yanked = "deny"
ignore = [
{ id = "RUSTSEC-2024-0436", reason = "paste is unmaintained but used by various dependencies" },
]
[bans]
multiple-versions = "allow"
# To check if a license if compatible with our GPL v3.0 license, see: https://www.gnu.org/licenses/license-list.html
# Keep list sorted alphabetically.
[licenses]
unused-allowed-license = "deny"
allow = [
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"BSD-2-Clause",
"BSD-3-Clause",
"BSL-1.0",
"GPL-3.0",
"LGPL-2.1",
"ISC",
"MIT",
"MPL-2.0",
"Unicode-3.0",
"Zlib",
]
[sources]
required-git-spec="rev"
allow-git = [
"https://github.com/ruma/ruma",
"https://github.com/matrix-org/matrix-rust-sdk",
]

View File

@ -121,12 +121,12 @@ check_cargo() {
elif [ ! -t 1 ]; then elif [ ! -t 1 ]; then
exit 2 exit 2
elif check_rustup; then elif check_rustup; then
echo -e "$error rustup is installed but the cargo command isn't available" echo -e "$error rustup is installed but the cargo command isnt available"
exit 2 exit 2
else else
echo "" echo ""
echo "y: Install cargo via rustup" echo "y: Install cargo via rustup"
echo "N: Don't install cargo and abort checks" echo "N: Dont install cargo and abort checks"
echo "" echo ""
while true; do while true; do
echo -n "Install cargo? [y/N]: "; read yn < /dev/tty echo -n "Install cargo? [y/N]: "; read yn < /dev/tty
@ -176,7 +176,7 @@ run_rustfmt() {
echo "Rustfmt is needed to check Fractals code style, but it isnt available" echo "Rustfmt is needed to check Fractals code style, but it isnt available"
echo "" echo ""
echo "y: Install rustfmt via rustup" echo "y: Install rustfmt via rustup"
echo "N: Don't install rustfmt and abort checks" echo "N: Dont install rustfmt and abort checks"
echo "" echo ""
while true; do while true; do
echo -n "Install rustfmt? [y/N]: "; read yn < /dev/tty echo -n "Install rustfmt? [y/N]: "; read yn < /dev/tty
@ -255,7 +255,7 @@ run_typos() {
echo "Typos is needed to check spelling mistakes, but it isnt available" echo "Typos is needed to check spelling mistakes, but it isnt available"
echo "" echo ""
echo "y: Install typos via cargo" echo "y: Install typos via cargo"
echo "N: Don't install typos and abort checks" echo "N: Dont install typos and abort checks"
echo "" echo ""
while true; do while true; do
echo -n "Install typos? [y/N]: "; read yn < /dev/tty echo -n "Install typos? [y/N]: "; read yn < /dev/tty
@ -316,7 +316,7 @@ run_machete() {
echo "cargo-machete is needed to check for unused dependencies, but it isnt available" echo "cargo-machete is needed to check for unused dependencies, but it isnt available"
echo "" echo ""
echo "y: Install cargo-machete via cargo" echo "y: Install cargo-machete via cargo"
echo "N: Don't install cargo-machete and abort checks" echo "N: Dont install cargo-machete and abort checks"
echo "" echo ""
while true; do while true; do
echo -n "Install cargo-machete? [y/N]: "; read yn < /dev/tty echo -n "Install cargo-machete? [y/N]: "; read yn < /dev/tty
@ -353,6 +353,65 @@ run_machete() {
fi fi
} }
# Install cargo-deny with cargo.
install_cargo_deny() {
echo -e "$Installing cargo-deny…"
cargo install cargo-deny
if ! cargo deny --version>/dev/null 2>&1; then
echo -e "$Could not install cargo-deny"
exit 2
fi
}
# Run cargo-deny to check Rust dependencies.
run_cargo_deny() {
if ! cargo deny --version >/dev/null 2>&1; then
if [[ $force_install -eq 1 ]]; then
install_cargo_deny
elif [ ! -t 1 ]; then
echo "Could not check Rust dependencies, because cargo-deny could not be run"
exit 2
else
echo "cargo-deny is needed to check the Rust dependencies, but it isnt available"
echo ""
echo "y: Install cargo-deny via cargo"
echo "N: Dont install cargo-deny and abort checks"
echo ""
while true; do
echo -n "Install cargo-deny? [y/N]: "; read yn < /dev/tty
case $yn in
[Yy]* )
install_cargo_deny
break
;;
[Nn]* | "" )
exit 2
;;
* )
echo $invalid
;;
esac
done
fi
fi
echo -e "$Checking Rust dependencies…"
if [[ $verbose -eq 1 ]]; then
echo ""
cargo deny --version
echo ""
fi
if ! cargo deny check; then
echo -e " Checking Rust dependencies result: $fail"
echo "Please fix the above issues, either by removing the dependencies, or by adding the necessary configuration option in deny.toml (see cargo-deny documentation)"
exit 1
else
echo -e " Checking Rust dependencies result: $ok"
fi
}
# Check if files in POTFILES.in are correct. # Check if files in POTFILES.in are correct.
# #
# This checks, in that order: # This checks, in that order:
@ -579,7 +638,7 @@ run_cargo_sort() {
echo "Cargo-sort is needed to check the sorting in Cargo.toml, but it isnt available" echo "Cargo-sort is needed to check the sorting in Cargo.toml, but it isnt available"
echo "" echo ""
echo "y: Install cargo-sort via cargo" echo "y: Install cargo-sort via cargo"
echo "N: Don't install cargo-sort and abort checks" echo "N: Dont install cargo-sort and abort checks"
echo "" echo ""
while true; do while true; do
echo -n "Install cargo-sort? [y/N]: "; read yn < /dev/tty echo -n "Install cargo-sort? [y/N]: "; read yn < /dev/tty
@ -656,6 +715,8 @@ run_typos
echo "" echo ""
run_machete run_machete
echo "" echo ""
run_cargo_deny
echo ""
check_potfiles check_potfiles
echo "" echo ""
if [[ $git_staged -eq 1 ]]; then if [[ $git_staged -eq 1 ]]; then

View File

@ -1,6 +1,6 @@
project('fractal', project('fractal',
'rust', 'rust',
version: '10.rc', version: '11.2',
license: 'GPL-3.0-or-later', license: 'GPL-3.0-or-later',
meson_version: '>= 1.1') meson_version: '>= 1.1')
@ -10,8 +10,8 @@ gnome = import('gnome')
base_id = 'org.gnome.Fractal' base_id = 'org.gnome.Fractal'
application_id = base_id application_id = base_id
major_version = '10' major_version = '11.2'
pre_release_version = 'rc' pre_release_version = ''
version = major_version version = major_version
if pre_release_version != '' if pre_release_version != ''
@ -22,10 +22,9 @@ full_version = version
dependency('glib-2.0', version: '>= 2.76') # update when changing gtk version dependency('glib-2.0', version: '>= 2.76') # update when changing gtk version
dependency('gio-2.0', version: '>= 2.76') # always same version as glib dependency('gio-2.0', version: '>= 2.76') # always same version as glib
dependency('gtk4', version: '>= 4.16') dependency('gtk4', version: '>= 4.16')
dependency('libadwaita-1', version: '>= 1.6') dependency('libadwaita-1', version: '>= 1.7')
# Please keep these dependencies sorted. # Please keep these dependencies sorted.
dependency('gstgtk4', version: '>= 0.13.0')
dependency('gstreamer-1.0', version: '>= 1.20') dependency('gstreamer-1.0', version: '>= 1.20')
dependency('gstreamer-app-1.0', version: '>= 1.20') dependency('gstreamer-app-1.0', version: '>= 1.20')
dependency('gstreamer-base-1.0', version: '>= 1.20') dependency('gstreamer-base-1.0', version: '>= 1.20')
@ -34,17 +33,17 @@ dependency('gstreamer-play-1.0', version: '>= 1.20')
dependency('gstreamer-video-1.0', version: '>= 1.20') dependency('gstreamer-video-1.0', version: '>= 1.20')
dependency('gtksourceview-5', version: '>= 5.0.0') dependency('gtksourceview-5', version: '>= 5.0.0')
dependency('libwebp', version: '>= 1.0.0') dependency('libwebp', version: '>= 1.0.0')
dependency('openssl', version: '>= 1.0.1') dependency('openssl', version: '>= 3.0.0')
dependency('shumate-1.0', version: '>= 1.0.0') dependency('shumate-1.0', version: '>= 1.0.0')
dependency('sqlite3', version: '>= 3.24.0') dependency('sqlite3', version: '>= 3.24.0')
# Required by glycin crate # Required by glycin crate
dependency('lcms2', version: '>=2.12.0') dependency('lcms2', version: '>=2.12.0')
dependency('libseccomp', version: '>= 2.5.0')
# Linux-only dependencies # Linux-only dependencies
if build_machine.system() == 'linux' if build_machine.system() == 'linux'
dependency('libpipewire-0.3', version: '>= 0.3.0') # Required by glycin crate
dependency('libseccomp', version: '>= 2.5.0')
endif endif
glib_compile_resources = find_program('glib-compile-resources', required: true) glib_compile_resources = find_program('glib-compile-resources', required: true)
@ -67,7 +66,12 @@ iconsdir = datadir / 'icons'
podir = meson.project_source_root() / 'po' podir = meson.project_source_root() / 'po'
gettext_package = meson.project_name() gettext_package = meson.project_name()
if get_option('profile') == 'development' # When the `build-env` profile is used, we only want to set up the build
# environment for the sandbox, we will not try to compile the app, so we can
# remove some build steps.
build_env_only = get_option('profile') == 'build-env'
if get_option('profile') == 'development' or build_env_only
profile = 'Devel' profile = 'Devel'
application_id += '.Devel' application_id += '.Devel'
elif get_option('profile') == 'hack' elif get_option('profile') == 'hack'
@ -95,8 +99,11 @@ if profile == 'Devel'
run_command('cp', '-f', 'hooks/pre-commit.hook', '.git/hooks/pre-commit') run_command('cp', '-f', 'hooks/pre-commit.hook', '.git/hooks/pre-commit')
endif endif
subdir('data') if not build_env_only
subdir('po') subdir('data')
subdir('po')
endif
subdir('src') subdir('src')
gnome.post_install( gnome.post_install(

View File

@ -6,6 +6,7 @@ option(
'beta', 'beta',
'development', 'development',
'hack', 'hack',
'build-env',
], ],
value: 'default', value: 'default',
description: 'The build profile for Fractal. One of "default", "beta", "development" or "hack".' description: 'The build profile for Fractal. One of "default", "beta", "development" or "hack".'

View File

@ -41,4 +41,5 @@ sv
th th
tr tr
uk uk
uz
zh_CN zh_CN

View File

@ -12,15 +12,20 @@ src/application.rs
src/components/action_button.ui src/components/action_button.ui
src/components/avatar/editable.rs src/components/avatar/editable.rs
src/components/avatar/editable.ui src/components/avatar/editable.ui
src/components/camera/qrcode_scanner.rs
src/components/camera/qrcode_scanner.ui
src/components/camera/viewfinder.rs
src/components/crypto/identity_setup_view.rs src/components/crypto/identity_setup_view.rs
src/components/crypto/identity_setup_view.ui src/components/crypto/identity_setup_view.ui
src/components/crypto/recovery_setup_view.rs src/components/crypto/recovery_setup_view.rs
src/components/crypto/recovery_setup_view.ui src/components/crypto/recovery_setup_view.ui
src/components/dialogs/auth.rs src/components/dialogs/auth/in_browser_page.ui
src/components/dialogs/auth.ui src/components/dialogs/auth/mod.rs
src/components/dialogs/join_room.rs src/components/dialogs/auth/mod.ui
src/components/dialogs/join_room.ui src/components/dialogs/auth/password_page.ui
src/components/dialogs/message_dialogs.rs src/components/dialogs/message_dialogs.rs
src/components/dialogs/room_preview.rs
src/components/dialogs/room_preview.ui
src/components/dialogs/user_profile.ui src/components/dialogs/user_profile.ui
src/components/offline_banner.rs src/components/offline_banner.rs
src/components/media/content_viewer.rs src/components/media/content_viewer.rs
@ -30,7 +35,6 @@ src/components/power_level_selection/popover.ui
src/components/rows/loading_row.ui src/components/rows/loading_row.ui
src/components/user_page.rs src/components/user_page.rs
src/components/user_page.ui src/components/user_page.ui
src/contrib/qr_code_scanner/mod.ui
src/contrib/qr_code.rs src/contrib/qr_code.rs
src/error_page.rs src/error_page.rs
src/error_page.ui src/error_page.ui
@ -44,6 +48,7 @@ src/identity_verification_view/completed_page.rs
src/identity_verification_view/completed_page.ui src/identity_verification_view/completed_page.ui
src/identity_verification_view/confirm_qr_code_page.rs src/identity_verification_view/confirm_qr_code_page.rs
src/identity_verification_view/confirm_qr_code_page.ui src/identity_verification_view/confirm_qr_code_page.ui
src/identity_verification_view/mod.rs
src/identity_verification_view/mod.ui src/identity_verification_view/mod.ui
src/identity_verification_view/no_supported_methods_page.rs src/identity_verification_view/no_supported_methods_page.rs
src/identity_verification_view/no_supported_methods_page.ui src/identity_verification_view/no_supported_methods_page.ui
@ -60,6 +65,7 @@ src/login/advanced_dialog.ui
src/login/greeter.ui src/login/greeter.ui
src/login/homeserver_page.rs src/login/homeserver_page.rs
src/login/homeserver_page.ui src/login/homeserver_page.ui
src/login/in_browser_page.rs
src/login/in_browser_page.ui src/login/in_browser_page.ui
src/login/method_page.rs src/login/method_page.rs
src/login/method_page.ui src/login/method_page.ui
@ -76,6 +82,11 @@ src/session/model/room/permissions.rs
src/session/model/room_list/mod.rs src/session/model/room_list/mod.rs
src/session/model/sidebar_data/section/name.rs src/session/model/sidebar_data/section/name.rs
src/session/model/sidebar_data/icon_item.rs src/session/model/sidebar_data/icon_item.rs
src/session/model/user_sessions_list/user_session.rs
src/session/view/account_settings/encryption_page/import_export_keys_subpage.rs
src/session/view/account_settings/encryption_page/import_export_keys_subpage.ui
src/session/view/account_settings/encryption_page/mod.rs
src/session/view/account_settings/encryption_page/mod.ui
src/session/view/account_settings/general_page/change_password_subpage.rs src/session/view/account_settings/general_page/change_password_subpage.rs
src/session/view/account_settings/general_page/change_password_subpage.ui src/session/view/account_settings/general_page/change_password_subpage.ui
src/session/view/account_settings/general_page/deactivate_account_subpage.rs src/session/view/account_settings/general_page/deactivate_account_subpage.rs
@ -87,16 +98,15 @@ src/session/view/account_settings/general_page/mod.ui
src/session/view/account_settings/mod.ui src/session/view/account_settings/mod.ui
src/session/view/account_settings/notifications_page.rs src/session/view/account_settings/notifications_page.rs
src/session/view/account_settings/notifications_page.ui src/session/view/account_settings/notifications_page.ui
src/session/view/account_settings/security_page/ignored_users_subpage/ignored_user_row.rs src/session/view/account_settings/safety_page/ignored_users_subpage/ignored_user_row.rs
src/session/view/account_settings/security_page/ignored_users_subpage/ignored_user_row.ui src/session/view/account_settings/safety_page/ignored_users_subpage/ignored_user_row.ui
src/session/view/account_settings/security_page/ignored_users_subpage/mod.ui src/session/view/account_settings/safety_page/ignored_users_subpage/mod.ui
src/session/view/account_settings/security_page/import_export_keys_subpage.rs src/session/view/account_settings/safety_page/mod.rs
src/session/view/account_settings/security_page/import_export_keys_subpage.ui src/session/view/account_settings/safety_page/mod.ui
src/session/view/account_settings/security_page/mod.rs src/session/view/account_settings/user_session/user_session_list_subpage.ui
src/session/view/account_settings/security_page/mod.ui src/session/view/account_settings/user_session/user_session_row.ui
src/session/view/account_settings/user_sessions_page/mod.ui src/session/view/account_settings/user_session/user_session_subpage.rs
src/session/view/account_settings/user_sessions_page/user_session_row.rs src/session/view/account_settings/user_session/user_session_subpage.ui
src/session/view/account_settings/user_sessions_page/user_session_row.ui
src/session/view/content/explore/mod.ui src/session/view/content/explore/mod.ui
src/session/view/content/explore/public_room_row.rs src/session/view/content/explore/public_room_row.rs
src/session/view/content/explore/servers_popover.ui src/session/view/content/explore/servers_popover.ui
@ -134,9 +144,10 @@ src/session/view/content/room_details/permissions/permissions_subpage.rs
src/session/view/content/room_details/permissions/permissions_subpage.ui src/session/view/content/room_details/permissions/permissions_subpage.ui
src/session/view/content/room_details/room_upgrade_dialog.rs src/session/view/content/room_details/room_upgrade_dialog.rs
src/session/view/content/room_history/divider_row.rs src/session/view/content/room_history/divider_row.rs
src/session/view/content/room_history/event_context_menu.ui src/session/view/content/room_history/event_actions/context_menu.rs
src/session/view/content/room_history/item_row.rs src/session/view/content/room_history/event_actions/context_menu.ui
src/session/view/content/room_history/item_row_context_menu.rs src/session/view/content/room_history/event_actions/group.rs
src/session/view/content/room_history/event_actions/quick_reaction_chooser.ui
src/session/view/content/room_history/message_row/audio.rs src/session/view/content/room_history/message_row/audio.rs
src/session/view/content/room_history/message_row/content.rs src/session/view/content/room_history/message_row/content.rs
src/session/view/content/room_history/message_row/file.rs src/session/view/content/room_history/message_row/file.rs
@ -150,6 +161,7 @@ src/session/view/content/room_history/message_row/reaction_list.ui
src/session/view/content/room_history/message_row/reply.ui src/session/view/content/room_history/message_row/reply.ui
src/session/view/content/room_history/message_row/text/widgets.rs src/session/view/content/room_history/message_row/text/widgets.rs
src/session/view/content/room_history/message_row/visual_media.rs src/session/view/content/room_history/message_row/visual_media.rs
src/session/view/content/room_history/message_row/visual_media.ui
src/session/view/content/room_history/message_toolbar/attachment_dialog.ui src/session/view/content/room_history/message_toolbar/attachment_dialog.ui
src/session/view/content/room_history/message_toolbar/completion/completion_popover.rs src/session/view/content/room_history/message_toolbar/completion/completion_popover.rs
src/session/view/content/room_history/message_toolbar/mod.rs src/session/view/content/room_history/message_toolbar/mod.rs
@ -157,26 +169,24 @@ src/session/view/content/room_history/message_toolbar/mod.ui
src/session/view/content/room_history/member_timestamp/row.rs src/session/view/content/room_history/member_timestamp/row.rs
src/session/view/content/room_history/mod.rs src/session/view/content/room_history/mod.rs
src/session/view/content/room_history/mod.ui src/session/view/content/room_history/mod.ui
src/session/view/content/room_history/quick_reaction_chooser.ui
src/session/view/content/room_history/read_receipts_list/mod.rs src/session/view/content/room_history/read_receipts_list/mod.rs
src/session/view/content/room_history/sender_avatar/mod.rs src/session/view/content/room_history/sender_avatar/mod.rs
src/session/view/content/room_history/sender_avatar/mod.ui src/session/view/content/room_history/sender_avatar/mod.ui
src/session/view/content/room_history/state_row/creation.rs src/session/view/content/room_history/state/content.rs
src/session/view/content/room_history/state_row/creation.ui src/session/view/content/room_history/state/creation.rs
src/session/view/content/room_history/state_row/mod.rs src/session/view/content/room_history/state/creation.ui
src/session/view/content/room_history/state_row/tombstone.rs src/session/view/content/room_history/state/group_row.rs
src/session/view/content/room_history/state_row/tombstone.ui
src/session/view/content/room_history/title.ui src/session/view/content/room_history/title.ui
src/session/view/content/room_history/typing_row.rs src/session/view/content/room_history/typing_row.rs
src/session/view/content/room_history/verification_info_bar.rs src/session/view/content/room_history/verification_info_bar.rs
src/session/view/create_dm_dialog/mod.rs src/session/view/create_direct_chat_dialog/mod.rs
src/session/view/create_dm_dialog/mod.ui src/session/view/create_direct_chat_dialog/mod.ui
src/session/view/create_room_dialog.rs
src/session/view/create_room_dialog.ui
src/session/view/event_details_dialog.rs src/session/view/event_details_dialog.rs
src/session/view/event_details_dialog.ui src/session/view/event_details_dialog.ui
src/session/view/media_viewer.rs src/session/view/media_viewer.rs
src/session/view/media_viewer.ui src/session/view/media_viewer.ui
src/session/view/room_creation.rs
src/session/view/room_creation.ui
src/session/view/sidebar/mod.rs src/session/view/sidebar/mod.rs
src/session/view/sidebar/mod.ui src/session/view/sidebar/mod.ui
src/session/view/sidebar/room_row.rs src/session/view/sidebar/room_row.rs

View File

@ -1,4 +1,4 @@
# These are files that we don't want to translate # These are files that we don't want to translate
# Please keep this file sorted alphabetically. # Please keep this file sorted alphabetically.
src/i18n.rs src/i18n.rs
src/utils/macros.rs src/utils/toast.rs

1071
po/bg.po

File diff suppressed because it is too large Load Diff

2988
po/cs.po

File diff suppressed because it is too large Load Diff

3026
po/fi.po

File diff suppressed because it is too large Load Diff

2378
po/fr.po

File diff suppressed because it is too large Load Diff

5754
po/id.po

File diff suppressed because it is too large Load Diff

2587
po/ka.po

File diff suppressed because it is too large Load Diff

1656
po/nb.po

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

2758
po/ru.po

File diff suppressed because it is too large Load Diff

2689
po/sl.po

File diff suppressed because it is too large Load Diff

2767
po/sv.po

File diff suppressed because it is too large Load Diff

1627
po/th.po

File diff suppressed because it is too large Load Diff

2584
po/tr.po

File diff suppressed because it is too large Load Diff

2791
po/uk.po

File diff suppressed because it is too large Load Diff

5221
po/uz.po Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 116 KiB

View File

@ -25,7 +25,7 @@
<property name="xalign">0.0</property> <property name="xalign">0.0</property>
<property name="hexpand">True</property> <property name="hexpand">True</property>
<style> <style>
<class name="dim-label"/> <class name="dimmed"/>
<class name="caption"/> <class name="caption"/>
</style> </style>
</object> </object>

View File

@ -1,30 +1,25 @@
use gtk::{ use gtk::{glib, glib::clone, prelude::*, subclass::prelude::*, CompositeTemplate};
glib::{self, clone, closure},
prelude::*,
subclass::prelude::*,
CompositeTemplate,
};
use super::AccountSwitcherPopover; use super::AccountSwitcherPopover;
use crate::{ use crate::{
components::Avatar, components::Avatar,
session_list::SessionInfo, session_list::SessionInfo,
utils::{template_callbacks::TemplateCallbacks, BoundObjectWeakRef}, utils::{BoundObjectWeakRef, TemplateCallbacks},
Window, Window,
}; };
mod imp { mod imp {
use std::cell::RefCell;
use glib::subclass::InitializingObject; use glib::subclass::InitializingObject;
use super::*; use super::*;
#[derive(Debug, Default, CompositeTemplate)] #[derive(Debug, Default, CompositeTemplate, glib::Properties)]
#[template(resource = "/org/gnome/Fractal/ui/account_switcher/account_switcher_button.ui")] #[template(resource = "/org/gnome/Fractal/ui/account_switcher/account_switcher_button.ui")]
#[properties(wrapper_type = super::AccountSwitcherButton)]
pub struct AccountSwitcherButton { pub struct AccountSwitcherButton {
pub popover: BoundObjectWeakRef<AccountSwitcherPopover>, /// The popover of this button.
pub watch: RefCell<Option<gtk::ExpressionWatch>>, #[property(get, set = Self::set_popover, explicit_notify, nullable)]
popover: BoundObjectWeakRef<AccountSwitcherPopover>,
} }
#[glib::object_subclass] #[glib::object_subclass]
@ -38,6 +33,7 @@ mod imp {
SessionInfo::ensure_type(); SessionInfo::ensure_type();
Self::bind_template(klass); Self::bind_template(klass);
Self::bind_template_callbacks(klass);
TemplateCallbacks::bind_template_callbacks(klass); TemplateCallbacks::bind_template_callbacks(klass);
} }
@ -46,40 +42,85 @@ mod imp {
} }
} }
#[glib::derived_properties]
impl ObjectImpl for AccountSwitcherButton { impl ObjectImpl for AccountSwitcherButton {
fn constructed(&self) {
self.parent_constructed();
let obj = self.obj();
obj.connect_toggled(|obj| {
obj.handle_toggled();
});
let watch = obj
.property_expression("root")
.chain_property::<Window>("session-selection")
.chain_property::<gtk::SingleSelection>("n-items")
.chain_closure::<bool>(closure!(|_: Option<glib::Object>, n_items: u32| {
n_items > 0
}))
.bind(&*obj, "visible", glib::Object::NONE);
self.watch.replace(Some(watch));
}
fn dispose(&self) { fn dispose(&self) {
if let Some(watch) = self.watch.take() { self.reset();
watch.unwatch();
}
} }
} }
impl WidgetImpl for AccountSwitcherButton {} impl WidgetImpl for AccountSwitcherButton {}
impl ButtonImpl for AccountSwitcherButton {} impl ButtonImpl for AccountSwitcherButton {}
impl ToggleButtonImpl for AccountSwitcherButton {} impl ToggleButtonImpl for AccountSwitcherButton {}
#[gtk::template_callbacks]
impl AccountSwitcherButton {
/// Set the popover of this button.
fn set_popover(&self, popover: Option<&AccountSwitcherPopover>) {
if self.popover.obj().as_ref() == popover {
return;
}
// Reset the state.
self.reset();
let obj = self.obj();
if let Some(popover) = popover {
// We need to remove the popover from the previous button, if any.
if let Some(parent) = popover
.parent()
.and_downcast::<super::AccountSwitcherButton>()
{
parent.set_popover(None::<AccountSwitcherPopover>);
}
let closed_handler = popover.connect_closed(clone!(
#[weak]
obj,
move |_| {
obj.set_active(false);
}
));
popover.set_parent(&*obj);
self.popover.set(popover, vec![closed_handler]);
}
obj.notify_popover();
}
/// Toggle the popover of this button.
#[template_callback]
fn toggle_popover(&self) {
let obj = self.obj();
if obj.is_active() {
let Some(window) = obj.root().and_downcast::<Window>() else {
return;
};
let popover = window.account_switcher();
self.set_popover(Some(popover));
popover.popup();
} else if let Some(popover) = self.popover.obj() {
popover.popdown();
}
}
/// Reset the state of this button.
fn reset(&self) {
if let Some(popover) = self.popover.obj() {
popover.unparent();
}
self.popover.disconnect_signals();
self.obj().set_active(false);
}
}
} }
glib::wrapper! { glib::wrapper! {
/// A button showing the currently selected account and opening the account switcher popover. /// A button showing the currently selected session and opening the account switcher popover.
pub struct AccountSwitcherButton(ObjectSubclass<imp::AccountSwitcherButton>) pub struct AccountSwitcherButton(ObjectSubclass<imp::AccountSwitcherButton>)
@extends gtk::Widget, gtk::Button, gtk::ToggleButton, @implements gtk::Accessible; @extends gtk::Widget, gtk::Button, gtk::ToggleButton, @implements gtk::Accessible;
} }
@ -89,60 +130,6 @@ impl AccountSwitcherButton {
pub fn new() -> Self { pub fn new() -> Self {
glib::Object::new() glib::Object::new()
} }
pub fn popover(&self) -> Option<AccountSwitcherPopover> {
self.imp().popover.obj()
}
pub fn set_popover(&self, popover: Option<&AccountSwitcherPopover>) {
let old_popover = self.popover();
if old_popover.as_ref() == popover {
return;
}
let imp = self.imp();
// Reset the state.
if let Some(popover) = old_popover {
popover.unparent();
}
imp.popover.disconnect_signals();
self.set_active(false);
if let Some(popover) = popover {
// We need to remove the popover from the previous button, if any.
if let Some(parent) = popover.parent().and_downcast::<AccountSwitcherButton>() {
parent.set_popover(None);
}
let closed_handler = popover.connect_closed(clone!(
#[weak(rename_to = obj)]
self,
move |_| {
obj.set_active(false);
}
));
popover.set_parent(self);
imp.popover.set(popover, vec![closed_handler]);
}
}
fn handle_toggled(&self) {
if self.is_active() {
let Some(window) = self.root().and_downcast::<Window>() else {
return;
};
let popover = window.account_switcher();
self.set_popover(Some(popover));
popover.popup();
} else if let Some(popover) = self.popover() {
popover.popdown();
}
}
} }
impl Default for AccountSwitcherButton { impl Default for AccountSwitcherButton {

View File

@ -1,6 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<interface> <interface>
<template class="AccountSwitcherButton" parent="GtkToggleButton"> <template class="AccountSwitcherButton" parent="GtkToggleButton">
<signal name="toggled" handler="toggle_popover" swapped="yes"/>
<binding name="visible">
<closure type="gboolean" function="invert_boolean">
<closure type="gboolean" function="guint_is_zero">
<lookup name="n-items" type="GtkSingleSelection">
<lookup name="session-selection" type="Window">
<lookup name="root">AccountSwitcherButton</lookup>
</lookup>
</lookup>
</closure>
</closure>
</binding>
<binding name="tooltip-text"> <binding name="tooltip-text">
<lookup name="user-id-string" type="SessionInfo"> <lookup name="user-id-string" type="SessionInfo">
<lookup name="selected-item" type="GtkSingleSelection"> <lookup name="selected-item" type="GtkSingleSelection">
@ -16,6 +28,7 @@
</accessibility> </accessibility>
<style> <style>
<class name="image-button"/> <class name="image-button"/>
<class name="circular"/>
</style> </style>
<property name="child"> <property name="child">
<object class="Avatar"> <object class="Avatar">

View File

@ -18,12 +18,12 @@ mod imp {
#[properties(wrapper_type = super::AccountSwitcherPopover)] #[properties(wrapper_type = super::AccountSwitcherPopover)]
pub struct AccountSwitcherPopover { pub struct AccountSwitcherPopover {
#[template_child] #[template_child]
pub sessions: TemplateChild<gtk::ListBox>, sessions: TemplateChild<gtk::ListBox>,
/// The model containing the logged-in sessions selection. /// The model containing the logged-in sessions selection.
#[property(get, set = Self::set_session_selection, explicit_notify, nullable)] #[property(get, set = Self::set_session_selection, explicit_notify, nullable)]
pub session_selection: BoundObjectWeakRef<gtk::SingleSelection>, session_selection: BoundObjectWeakRef<gtk::SingleSelection>,
/// The selected row. /// The selected row.
pub selected_row: glib::WeakRef<SessionItemRow>, selected_row: glib::WeakRef<SessionItemRow>,
} }
#[glib::object_subclass] #[glib::object_subclass]
@ -34,7 +34,7 @@ mod imp {
fn class_init(klass: &mut Self::Class) { fn class_init(klass: &mut Self::Class) {
Self::bind_template(klass); Self::bind_template(klass);
Self::Type::bind_template_callbacks(klass); Self::bind_template_callbacks(klass);
klass.install_action("account-switcher.close", None, |obj, _, _| { klass.install_action("account-switcher.close", None, |obj, _, _| {
obj.popdown(); obj.popdown();
@ -52,97 +52,94 @@ mod imp {
impl WidgetImpl for AccountSwitcherPopover {} impl WidgetImpl for AccountSwitcherPopover {}
impl PopoverImpl for AccountSwitcherPopover {} impl PopoverImpl for AccountSwitcherPopover {}
#[gtk::template_callbacks]
impl AccountSwitcherPopover { impl AccountSwitcherPopover {
/// Set the model containing the logged-in sessions selection. /// Set the model containing the logged-in sessions selection.
fn set_session_selection(&self, selection: Option<&gtk::SingleSelection>) { fn set_session_selection(&self, selection: Option<&gtk::SingleSelection>) {
if selection == self.session_selection.obj().as_ref() { if selection == self.session_selection.obj().as_ref() {
return; return;
} }
let obj = self.obj();
self.session_selection.disconnect_signals(); self.session_selection.disconnect_signals();
self.sessions.bind_model(selection, |session| { self.sessions.bind_model(selection, |session| {
let row = SessionItemRow::new(session.downcast_ref().unwrap()); let row = SessionItemRow::new(
session
.downcast_ref()
.expect("sessions list box item should be a Session"),
);
row.upcast() row.upcast()
}); });
if let Some(selection) = selection { if let Some(selection) = selection {
let selected_handler = selection.connect_selected_item_notify(clone!( let selected_handler = selection.connect_selected_item_notify(clone!(
#[weak] #[weak(rename_to = imp)]
obj, self,
move |selection| { move |selection| {
obj.update_selected_item(selection.selected()); imp.update_selected_item(selection.selected());
} }
)); ));
obj.update_selected_item(selection.selected()); self.update_selected_item(selection.selected());
self.session_selection self.session_selection
.set(selection, vec![selected_handler]); .set(selection, vec![selected_handler]);
} }
obj.notify_session_selection(); self.obj().notify_session_selection();
}
/// Select the given row in the session list.
#[template_callback]
fn select_row(&self, row: &gtk::ListBoxRow) {
self.obj().popdown();
let Some(selection) = self.session_selection.obj() else {
return;
};
let index = row.index().try_into().expect("selected row has an index");
selection.set_selected(index);
}
/// Update the selected item in the session list.
fn update_selected_item(&self, selected: u32) {
let old_selected = self.selected_row.upgrade();
let new_selected = if selected == gtk::INVALID_LIST_POSITION {
None
} else {
let index = selected.try_into().expect("item index should fit into i32");
self.sessions
.row_at_index(index)
.and_downcast::<SessionItemRow>()
};
if old_selected == new_selected {
return;
}
if let Some(row) = &old_selected {
row.set_selected(false);
}
if let Some(row) = &new_selected {
row.set_selected(true);
}
self.selected_row.set(new_selected.as_ref());
} }
} }
} }
glib::wrapper! { glib::wrapper! {
/// A popover allowing to switch between the available sessions, to open their
/// account settings, or to log into a new account.
pub struct AccountSwitcherPopover(ObjectSubclass<imp::AccountSwitcherPopover>) pub struct AccountSwitcherPopover(ObjectSubclass<imp::AccountSwitcherPopover>)
@extends gtk::Widget, gtk::Popover, @implements gtk::Accessible; @extends gtk::Widget, gtk::Popover, @implements gtk::Accessible;
} }
#[gtk::template_callbacks]
impl AccountSwitcherPopover { impl AccountSwitcherPopover {
pub fn new() -> Self { pub fn new() -> Self {
glib::Object::new() glib::Object::new()
} }
fn selected_row(&self) -> Option<SessionItemRow> {
self.imp().selected_row.upgrade()
}
/// Select the given row in the session list.
#[template_callback]
fn select_row(&self, row: &gtk::ListBoxRow) {
self.popdown();
let Some(selection) = self.session_selection() else {
return;
};
let index = row.index().try_into().expect("selected row has an index");
selection.set_selected(index);
}
/// Update the selected item in the session list.
fn update_selected_item(&self, selected: u32) {
let imp = self.imp();
let old_selected = self.selected_row();
let new_selected = if selected == gtk::INVALID_LIST_POSITION {
None
} else {
let index = selected
.try_into()
.expect("item index always fits into i32");
imp.sessions
.row_at_index(index)
.and_downcast::<SessionItemRow>()
};
if old_selected == new_selected {
return;
}
if let Some(row) = &old_selected {
row.set_selected(false);
}
if let Some(row) = &new_selected {
row.set_selected(true);
}
imp.selected_row.set(new_selected.as_ref());
}
} }
impl Default for AccountSwitcherPopover { impl Default for AccountSwitcherPopover {

View File

@ -1,5 +1,5 @@
use adw::subclass::prelude::*; use adw::{prelude::*, subclass::prelude::*};
use gtk::{glib, prelude::*, CompositeTemplate}; use gtk::{glib, CompositeTemplate};
use crate::components::{Avatar, AvatarData}; use crate::components::{Avatar, AvatarData};
@ -15,18 +15,18 @@ mod imp {
#[properties(wrapper_type = super::AvatarWithSelection)] #[properties(wrapper_type = super::AvatarWithSelection)]
pub struct AvatarWithSelection { pub struct AvatarWithSelection {
#[template_child] #[template_child]
pub child_avatar: TemplateChild<Avatar>, child_avatar: TemplateChild<Avatar>,
#[template_child] #[template_child]
pub checkmark: TemplateChild<gtk::Image>, checkmark: TemplateChild<gtk::Image>,
/// The [`AvatarData`] displayed by this widget. /// The [`AvatarData`] displayed by this widget.
#[property(get = Self::data, set = Self::set_data, explicit_notify, nullable)] #[property(get = Self::data, set = Self::set_data, explicit_notify, nullable)]
pub data: PhantomData<Option<AvatarData>>, data: PhantomData<Option<AvatarData>>,
/// The size of the Avatar. /// The size of the Avatar.
#[property(get = Self::size, set = Self::set_size, minimum = -1, default = -1)] #[property(get = Self::size, set = Self::set_size, minimum = -1, default = -1)]
pub size: PhantomData<i32>, size: PhantomData<i32>,
/// Whether this avatar is selected. /// Whether this avatar is selected.
#[property(get = Self::is_selected, set = Self::set_selected, explicit_notify)] #[property(get = Self::is_selected, set = Self::set_selected, explicit_notify)]
pub selected: PhantomData<bool>, selected: PhantomData<bool>,
} }
#[glib::object_subclass] #[glib::object_subclass]
@ -103,7 +103,7 @@ mod imp {
} }
glib::wrapper! { glib::wrapper! {
/// A widget displaying an `Avatar` for a `Room` or `User` and an optional selected effect. /// A widget displaying an [`Avatar`] and an optional selected effect.
pub struct AvatarWithSelection(ObjectSubclass<imp::AvatarWithSelection>) pub struct AvatarWithSelection(ObjectSubclass<imp::AvatarWithSelection>)
@extends gtk::Widget, adw::Bin, @implements gtk::Accessible; @extends gtk::Widget, adw::Bin, @implements gtk::Accessible;
} }
@ -112,8 +112,4 @@ impl AvatarWithSelection {
pub fn new() -> Self { pub fn new() -> Self {
glib::Object::new() glib::Object::new()
} }
pub fn avatar(&self) -> &Avatar {
&self.imp().child_avatar
}
} }

View File

@ -3,7 +3,7 @@ mod account_switcher_popover;
mod avatar_with_selection; mod avatar_with_selection;
mod session_item; mod session_item;
pub use self::{ pub(crate) use self::{
account_switcher_button::AccountSwitcherButton, account_switcher_button::AccountSwitcherButton,
account_switcher_popover::AccountSwitcherPopover, account_switcher_popover::AccountSwitcherPopover,
}; };

View File

@ -1,4 +1,4 @@
use gtk::{self, glib, prelude::*, subclass::prelude::*, CompositeTemplate}; use gtk::{glib, prelude::*, subclass::prelude::*, CompositeTemplate};
use super::avatar_with_selection::AvatarWithSelection; use super::avatar_with_selection::AvatarWithSelection;
use crate::{ use crate::{
@ -20,22 +20,22 @@ mod imp {
#[properties(wrapper_type = super::SessionItemRow)] #[properties(wrapper_type = super::SessionItemRow)]
pub struct SessionItemRow { pub struct SessionItemRow {
#[template_child] #[template_child]
pub avatar: TemplateChild<AvatarWithSelection>, avatar: TemplateChild<AvatarWithSelection>,
#[template_child] #[template_child]
pub display_name: TemplateChild<gtk::Label>, display_name: TemplateChild<gtk::Label>,
#[template_child] #[template_child]
pub user_id: TemplateChild<gtk::Label>, user_id: TemplateChild<gtk::Label>,
#[template_child] #[template_child]
pub state_stack: TemplateChild<gtk::Stack>, state_stack: TemplateChild<gtk::Stack>,
#[template_child] #[template_child]
pub error_image: TemplateChild<gtk::Image>, error_image: TemplateChild<gtk::Image>,
/// The session this item represents. /// The session this item represents.
#[property(get, set = Self::set_session, explicit_notify)] #[property(get, set = Self::set_session, explicit_notify)]
pub session: glib::WeakRef<SessionInfo>, session: glib::WeakRef<SessionInfo>,
pub user_bindings: RefCell<Vec<glib::Binding>>, user_bindings: RefCell<Vec<glib::Binding>>,
/// Whether this session is selected. /// Whether this session is selected.
#[property(get = Self::is_selected, set = Self::set_selected, explicit_notify)] #[property(get = Self::is_selected, set = Self::set_selected, explicit_notify)]
pub selected: PhantomData<bool>, selected: PhantomData<bool>,
} }
#[glib::object_subclass] #[glib::object_subclass]
@ -46,7 +46,7 @@ mod imp {
fn class_init(klass: &mut Self::Class) { fn class_init(klass: &mut Self::Class) {
Self::bind_template(klass); Self::bind_template(klass);
Self::Type::bind_template_callbacks(klass); Self::bind_template_callbacks(klass);
} }
fn instance_init(obj: &InitializingObject<Self>) { fn instance_init(obj: &InitializingObject<Self>) {
@ -66,6 +66,7 @@ mod imp {
impl WidgetImpl for SessionItemRow {} impl WidgetImpl for SessionItemRow {}
impl ListBoxRowImpl for SessionItemRow {} impl ListBoxRowImpl for SessionItemRow {}
#[gtk::template_callbacks]
impl SessionItemRow { impl SessionItemRow {
/// Whether this session is selected. /// Whether this session is selected.
fn is_selected(&self) -> bool { fn is_selected(&self) -> bool {
@ -145,6 +146,23 @@ mod imp {
self.session.set(session); self.session.set(session);
self.obj().notify_session(); self.obj().notify_session();
} }
/// Show the account settings for the session of this row.
#[template_callback]
fn show_account_settings(&self) {
let Some(session) = self.session.upgrade() else {
return;
};
let obj = self.obj();
obj.activate_action("account-switcher.close", None)
.expect("`account-switcher.close` action should exist");
obj.activate_action(
"win.open-account-settings",
Some(&session.session_id().to_variant()),
)
.expect("`win.open-account-settings` action should exist");
}
} }
} }
@ -154,24 +172,8 @@ glib::wrapper! {
@extends gtk::Widget, gtk::ListBoxRow, @implements gtk::Accessible; @extends gtk::Widget, gtk::ListBoxRow, @implements gtk::Accessible;
} }
#[gtk::template_callbacks]
impl SessionItemRow { impl SessionItemRow {
pub fn new(session: &SessionInfo) -> Self { pub fn new(session: &SessionInfo) -> Self {
glib::Object::builder().property("session", session).build() glib::Object::builder().property("session", session).build()
} }
#[template_callback]
pub fn show_account_settings(&self) {
let Some(session) = self.session() else {
return;
};
self.activate_action("account-switcher.close", None)
.unwrap();
self.activate_action(
"win.open-account-settings",
Some(&session.session_id().to_variant()),
)
.unwrap();
}
} }

View File

@ -21,14 +21,16 @@
<object class="GtkLabel" id="display_name"> <object class="GtkLabel" id="display_name">
<property name="xalign">0.0</property> <property name="xalign">0.0</property>
<property name="hexpand">True</property> <property name="hexpand">True</property>
<property name="ellipsize">end</property>
</object> </object>
</child> </child>
<child> <child>
<object class="GtkLabel" id="user_id"> <object class="GtkLabel" id="user_id">
<property name="xalign">0.0</property> <property name="xalign">0.0</property>
<property name="hexpand">True</property> <property name="hexpand">True</property>
<property name="ellipsize">end</property>
<style> <style>
<class name="dim-label"/> <class name="dimmed"/>
<class name="caption"/> <class name="caption"/>
</style> </style>
</object> </object>
@ -58,9 +60,6 @@
<property name="valign">center</property> <property name="valign">center</property>
<property name="halign">center</property> <property name="halign">center</property>
<property name="tooltip-text" translatable="yes">Account Settings</property> <property name="tooltip-text" translatable="yes">Account Settings</property>
<accessibility>
<property name="label" translatable="yes">Account Settings</property>
</accessibility>
<signal name="clicked" handler="show_account_settings" swapped="true"/> <signal name="clicked" handler="show_account_settings" swapped="true"/>
<style> <style>
<class name="circular"/> <class name="circular"/>

File diff suppressed because it is too large Load Diff

View File

@ -66,15 +66,12 @@ impl AvatarData {
glib::Object::new() glib::Object::new()
} }
/// Constructs an `AvatarData` with the given image data.
pub(crate) fn with_image(image: AvatarImage) -> Self {
glib::Object::builder().property("image", image).build()
}
/// Get this avatar as a notification icon. /// Get this avatar as a notification icon.
/// ///
/// If `inhibit_image` is set, the image of the avatar will not be used.
///
/// Returns `None` if an error occurred while generating the icon. /// Returns `None` if an error occurred while generating the icon.
pub(crate) async fn as_notification_icon(&self) -> Option<gdk::Texture> { pub(crate) async fn as_notification_icon(&self, inhibit_image: bool) -> Option<gdk::Texture> {
let Some(window) = Application::default().active_window() else { let Some(window) = Application::default().active_window() else {
warn!("Could not generate icon for notification: no active window"); warn!("Could not generate icon for notification: no active window");
return None; return None;
@ -85,21 +82,23 @@ impl AvatarData {
}; };
let scale_factor = window.scale_factor(); let scale_factor = window.scale_factor();
if let Some(image) = self.image() { if !inhibit_image {
match image.load_small_paintable().await { if let Some(image) = self.image() {
Ok(Some(paintable)) => { match image.load_small_paintable().await {
let texture = paintable_as_notification_icon( Ok(Some(paintable)) => {
paintable.upcast_ref(), let texture = paintable_as_notification_icon(
scale_factor, paintable.upcast_ref(),
&renderer, scale_factor,
); &renderer,
return Some(texture); );
} return Some(texture);
// No paintable, we will try to generate the fallback. }
Ok(None) => {} // No paintable, we will try to generate the fallback.
// Could not get the paintable, we will try to generate the fallback. Ok(None) => {}
Err(error) => { // Could not get the paintable, we will try to generate the fallback.
warn!("Could not generate icon for notification: {error}"); Err(error) => {
warn!("Could not generate icon for notification: {error}");
}
} }
} }
} }

View File

@ -20,7 +20,7 @@ use crate::{
image::{ImageError, IMAGE_QUEUE}, image::{ImageError, IMAGE_QUEUE},
FrameDimensions, FrameDimensions,
}, },
BoundObject, BoundObjectWeakRef, CountedRef, BoundObject, BoundObjectWeakRef, CountedRef, SingleItemListModel,
}, },
}; };
@ -505,12 +505,11 @@ impl EditableAvatar {
/// Choose a new avatar. /// Choose a new avatar.
pub(super) async fn choose_avatar(&self) { pub(super) async fn choose_avatar(&self) {
let filters = gio::ListStore::new::<gtk::FileFilter>();
let image_filter = gtk::FileFilter::new(); let image_filter = gtk::FileFilter::new();
image_filter.set_name(Some(&gettext("Images"))); image_filter.set_name(Some(&gettext("Images")));
image_filter.add_mime_type("image/*"); image_filter.add_mime_type("image/*");
filters.append(&image_filter);
let filters = SingleItemListModel::new(&image_filter);
let dialog = gtk::FileDialog::builder() let dialog = gtk::FileDialog::builder()
.title(gettext("Choose Avatar")) .title(gettext("Choose Avatar"))

View File

@ -1,5 +1,5 @@
use adw::subclass::prelude::*; use adw::subclass::prelude::*;
use gtk::{glib, glib::clone, prelude::*, CompositeTemplate}; use gtk::{gdk, glib, glib::clone, prelude::*, CompositeTemplate};
mod crop_circle; mod crop_circle;
mod data; mod data;
@ -16,11 +16,38 @@ pub use self::{
}; };
use crate::{ use crate::{
components::AnimatedImagePaintable, components::AnimatedImagePaintable,
session::model::Room,
utils::{BoundObject, BoundObjectWeakRef, CountedRef}, utils::{BoundObject, BoundObjectWeakRef, CountedRef},
}; };
/// The safety setting to watch to decide whether the image of the avatar should
/// be displayed.
#[derive(Debug, Clone, Copy, PartialEq, Eq, glib::Enum, Default)]
#[enum_type(name = "AvatarImageSafetySetting")]
pub enum AvatarImageSafetySetting {
/// No setting needs to be watched, the image is always shown when
/// available.
#[default]
None,
/// The media previews safety setting should be watched, with the image only
/// shown when allowed.
///
/// This setting also requires the [`Room`] where the avatar is presented.
MediaPreviews,
/// The invite avatars safety setting should be watched, with the image only
/// shown when allowed.
///
/// This setting also requires the [`Room`] where the avatar is presented.
InviteAvatars,
}
mod imp { mod imp {
use std::{cell::RefCell, marker::PhantomData}; use std::{
cell::{Cell, RefCell},
marker::PhantomData,
};
use glib::subclass::InitializingObject; use glib::subclass::InitializingObject;
@ -41,8 +68,19 @@ mod imp {
/// The size of the Avatar. /// The size of the Avatar.
#[property(get = Self::size, set = Self::set_size, explicit_notify, builder().default_value(-1).minimum(-1))] #[property(get = Self::size, set = Self::set_size, explicit_notify, builder().default_value(-1).minimum(-1))]
size: PhantomData<i32>, size: PhantomData<i32>,
/// The safety setting to watch to decide whether the image of the
/// avatar should be displayed.
#[property(get, set = Self::set_watched_safety_setting, explicit_notify, builder(AvatarImageSafetySetting::default()))]
watched_safety_setting: Cell<AvatarImageSafetySetting>,
/// The room to watch to apply the current safety settings.
///
/// This is required if `watched_safety_setting` is not `None`.
#[property(get, set = Self::set_watched_room, explicit_notify, nullable)]
watched_room: RefCell<Option<Room>>,
paintable_ref: RefCell<Option<CountedRef>>, paintable_ref: RefCell<Option<CountedRef>>,
paintable_animation_ref: RefCell<Option<CountedRef>>, paintable_animation_ref: RefCell<Option<CountedRef>>,
watched_room_handler: RefCell<Option<glib::SignalHandlerId>>,
watched_global_account_data_handler: RefCell<Option<glib::SignalHandlerId>>,
} }
#[glib::object_subclass] #[glib::object_subclass]
@ -66,7 +104,11 @@ mod imp {
} }
#[glib::derived_properties] #[glib::derived_properties]
impl ObjectImpl for Avatar {} impl ObjectImpl for Avatar {
fn dispose(&self) {
self.disconnect_safety_setting_signals();
}
}
impl WidgetImpl for Avatar { impl WidgetImpl for Avatar {
fn map(&self) { fn map(&self) {
@ -108,6 +150,136 @@ mod imp {
self.obj().notify_size(); self.obj().notify_size();
} }
/// Set the safety setting to watch to decide whether the image of the
/// avatar should be displayed.
fn set_watched_safety_setting(&self, setting: AvatarImageSafetySetting) {
if self.watched_safety_setting.get() == setting {
return;
}
self.disconnect_safety_setting_signals();
self.watched_safety_setting.set(setting);
self.connect_safety_setting_signals();
self.obj().notify_watched_safety_setting();
}
/// Set the room to watch to apply the current safety settings.
fn set_watched_room(&self, room: Option<Room>) {
if *self.watched_room.borrow() == room {
return;
}
self.disconnect_safety_setting_signals();
self.watched_room.replace(room);
self.connect_safety_setting_signals();
self.obj().notify_watched_room();
}
/// Connect to the proper signals for the current safety setting.
fn connect_safety_setting_signals(&self) {
let Some(room) = self.watched_room.borrow().clone() else {
return;
};
let Some(session) = room.session() else {
return;
};
match self.watched_safety_setting.get() {
AvatarImageSafetySetting::None => {}
AvatarImageSafetySetting::MediaPreviews => {
let room_handler = room.connect_join_rule_notify(clone!(
#[weak(rename_to = imp)]
self,
move |_| {
imp.update_paintable();
}
));
self.watched_room_handler.replace(Some(room_handler));
let global_account_data_handler = session
.global_account_data()
.connect_media_previews_enabled_changed(clone!(
#[weak(rename_to = imp)]
self,
move |_| {
imp.update_paintable();
}
));
self.watched_global_account_data_handler
.replace(Some(global_account_data_handler));
}
AvatarImageSafetySetting::InviteAvatars => {
let room_handler = room.connect_is_invite_notify(clone!(
#[weak(rename_to = imp)]
self,
move |_| {
imp.update_paintable();
}
));
self.watched_room_handler.replace(Some(room_handler));
let global_account_data_handler = session
.global_account_data()
.connect_invite_avatars_enabled_notify(clone!(
#[weak(rename_to = imp)]
self,
move |_| {
imp.update_paintable();
}
));
self.watched_global_account_data_handler
.replace(Some(global_account_data_handler));
}
}
self.update_paintable();
}
/// Disconnect the handlers for the signals of the safety setting.
fn disconnect_safety_setting_signals(&self) {
if let Some(room) = self.watched_room.borrow().as_ref() {
if let Some(handler) = self.watched_room_handler.take() {
room.disconnect(handler);
}
if let Some(handler) = self.watched_global_account_data_handler.take() {
room.session()
.inspect(|session| session.global_account_data().disconnect(handler));
}
}
}
/// Whether we can display the image of the avatar with the current
/// state.
fn can_show_image(&self) -> bool {
let watched_safety_setting = self.watched_safety_setting.get();
if watched_safety_setting == AvatarImageSafetySetting::None {
return true;
}
let Some(room) = self.watched_room.borrow().clone() else {
return false;
};
let Some(session) = room.session() else {
return false;
};
match watched_safety_setting {
AvatarImageSafetySetting::None => unreachable!(),
AvatarImageSafetySetting::MediaPreviews => session
.global_account_data()
.should_room_show_media_previews(&room),
AvatarImageSafetySetting::InviteAvatars => {
!room.is_invite() || session.global_account_data().invite_avatars_enabled()
}
}
}
/// Set the [`AvatarData`] displayed by this widget. /// Set the [`AvatarData`] displayed by this widget.
fn set_data(&self, data: Option<AvatarData>) { fn set_data(&self, data: Option<AvatarData>) {
if self.data.obj() == data { if self.data.obj() == data {
@ -188,6 +360,13 @@ mod imp {
fn update_paintable(&self) { fn update_paintable(&self) {
let _old_paintable_ref = self.paintable_ref.take(); let _old_paintable_ref = self.paintable_ref.take();
if !self.can_show_image() {
// We need to unset the paintable.
self.avatar.set_custom_image(None::<&gdk::Paintable>);
self.update_animated_paintable_state();
return;
}
if !self.obj().is_mapped() { if !self.obj().is_mapped() {
// We do not need a paintable. // We do not need a paintable.
self.update_animated_paintable_state(); self.update_animated_paintable_state();
@ -218,7 +397,7 @@ mod imp {
fn update_animated_paintable_state(&self) { fn update_animated_paintable_state(&self) {
let _old_paintable_animation_ref = self.paintable_animation_ref.take(); let _old_paintable_animation_ref = self.paintable_animation_ref.take();
if !self.obj().is_mapped() { if !self.can_show_image() || !self.obj().is_mapped() {
// We do not need to animate the paintable. // We do not need to animate the paintable.
return; return;
} }

View File

@ -1,14 +1,17 @@
use adw::prelude::*; use adw::{prelude::*, subclass::prelude::*};
use gtk::{gdk, gio, glib, glib::clone, subclass::prelude::*}; use gtk::{gdk, gio, glib, glib::clone};
use tracing::error;
use super::{crop_circle::CropCircle, Avatar, AvatarData}; use super::{crop_circle::CropCircle, Avatar, AvatarData};
use crate::utils::BoundObject;
/// Function to extract the avatar data from a supported `GObject`. /// Function to extract the avatar data from a supported `GObject`.
type ExtractAvatarDataFn = dyn Fn(&glib::Object) -> AvatarData + 'static; type ExtractAvatarDataFn = dyn Fn(&glib::Object) -> AvatarData + 'static;
mod imp { mod imp {
use std::cell::{Cell, RefCell}; use std::{
cell::{Cell, RefCell},
marker::PhantomData,
};
use super::*; use super::*;
@ -24,12 +27,9 @@ mod imp {
#[property(get, set = Self::set_spacing, explicit_notify)] #[property(get, set = Self::set_spacing, explicit_notify)]
spacing: Cell<u32>, spacing: Cell<u32>,
/// The maximum number of avatars to display. /// The maximum number of avatars to display.
/// #[property(get = Self::max_avatars, set = Self::set_max_avatars)]
/// `0` means that all avatars are displayed. max_avatars: PhantomData<u32>,
#[property(get, set = Self::set_max_avatars, explicit_notify)] slice_model: gtk::SliceListModel,
max_avatars: Cell<u32>,
/// The list model that is bound, if any.
bound_model: BoundObject<gio::ListModel>,
/// The method used to extract `AvatarData` from the items of the list /// The method used to extract `AvatarData` from the items of the list
/// model, if any. /// model, if any.
extract_avatar_data_fn: RefCell<Option<Box<ExtractAvatarDataFn>>>, extract_avatar_data_fn: RefCell<Option<Box<ExtractAvatarDataFn>>>,
@ -48,6 +48,18 @@ mod imp {
#[glib::derived_properties] #[glib::derived_properties]
impl ObjectImpl for OverlappingAvatars { impl ObjectImpl for OverlappingAvatars {
fn constructed(&self) {
self.parent_constructed();
self.slice_model.connect_items_changed(clone!(
#[weak(rename_to = imp)]
self,
move |_, position, removed, added| {
imp.handle_items_changed(position, removed, added);
}
));
}
fn dispose(&self) { fn dispose(&self) {
for child in self.children.take() { for child in self.children.take() {
child.unparent(); child.unparent();
@ -58,7 +70,7 @@ mod imp {
impl WidgetImpl for OverlappingAvatars { impl WidgetImpl for OverlappingAvatars {
fn measure(&self, orientation: gtk::Orientation, _for_size: i32) -> (i32, i32, i32, i32) { fn measure(&self, orientation: gtk::Orientation, _for_size: i32) -> (i32, i32, i32, i32) {
if self.children.borrow().is_empty() { if self.children.borrow().is_empty() {
return (0, 0, -1, 1); return (0, 0, -1, -1);
} }
let avatar_size = self.avatar_size.get(); let avatar_size = self.avatar_size.get();
@ -155,113 +167,47 @@ mod imp {
obj.notify_avatar_size(); obj.notify_avatar_size();
} }
/// The maximum number of avatars to display.
fn max_avatars(&self) -> u32 {
self.slice_model.size()
}
/// Set the maximum number of avatars to display. /// Set the maximum number of avatars to display.
fn set_max_avatars(&self, max_avatars: u32) { fn set_max_avatars(&self, max_avatars: u32) {
let old_max_avatars = self.max_avatars.get(); self.slice_model.set_size(max_avatars);
if old_max_avatars == max_avatars {
return;
}
let obj = self.obj();
self.max_avatars.set(max_avatars);
if max_avatars != 0 && self.children.borrow().len() > max_avatars as usize {
// We have more children than we should, remove them.
let children = self.children.borrow_mut().split_off(max_avatars as usize);
for child in children {
child.unparent();
}
if let Some(child) = self.children.borrow().last() {
child.set_is_cropped(false);
}
obj.queue_resize();
} else if max_avatars == 0 || (old_max_avatars != 0 && max_avatars > old_max_avatars) {
let Some(model) = self.bound_model.obj() else {
return;
};
let diff = model.n_items() - old_max_avatars;
if diff > 0 {
// We could have more children, create them.
self.handle_items_changed(&model, old_max_avatars, 0, diff);
}
}
obj.notify_max_avatars();
} }
/// Bind a `ListModel` to this list. /// Bind a `GListModel` to this list.
pub(super) fn bind_model<P: Fn(&glib::Object) -> AvatarData + 'static>( pub(super) fn bind_model<P: Fn(&glib::Object) -> AvatarData + 'static>(
&self, &self,
model: Option<gio::ListModel>, model: Option<&gio::ListModel>,
extract_avatar_data_fn: P, extract_avatar_data_fn: P,
) { ) {
self.bound_model.disconnect_signals();
for child in self.children.take() {
child.unparent();
}
self.extract_avatar_data_fn.take();
let Some(model) = model else {
return;
};
let signal_handler_id = model.connect_items_changed(clone!(
#[weak(rename_to = imp)]
self,
move |model, position, removed, added| {
imp.handle_items_changed(model, position, removed, added);
}
));
self.bound_model.set(model.clone(), vec![signal_handler_id]);
self.extract_avatar_data_fn self.extract_avatar_data_fn
.replace(Some(Box::new(extract_avatar_data_fn))); .replace(Some(Box::new(extract_avatar_data_fn)));
self.slice_model.set_model(model);
self.handle_items_changed(&model, 0, 0, model.n_items());
} }
fn handle_items_changed( /// Handle when the items of the model changed.
&self, fn handle_items_changed(&self, position: u32, removed: u32, added: u32) {
model: &impl IsA<gio::ListModel>,
position: u32,
mut removed: u32,
added: u32,
) {
let max_avatars = self.max_avatars.get();
if max_avatars != 0 && position >= max_avatars {
// No changes here.
return;
}
let mut children = self.children.borrow_mut(); let mut children = self.children.borrow_mut();
let prev_count = children.len();
let extract_avatar_data_fn_borrow = self.extract_avatar_data_fn.borrow(); let extract_avatar_data_fn_borrow = self.extract_avatar_data_fn.borrow();
let extract_avatar_data_fn = extract_avatar_data_fn_borrow.as_ref().unwrap(); let extract_avatar_data_fn = extract_avatar_data_fn_borrow
.as_ref()
.expect("extract avatar data fn should be set if model is set");
let avatar_size = i32::try_from(self.avatar_size.get()).unwrap_or(i32::MAX); let avatar_size = i32::try_from(self.avatar_size.get()).unwrap_or(i32::MAX);
let cropped_width = self.overlap(); let cropped_width = self.overlap();
while removed > 0 {
if position as usize >= children.len() {
break;
}
let child = children.remove(position as usize);
child.unparent();
removed -= 1;
}
let obj = self.obj(); let obj = self.obj();
for i in position..(position + added) { let added = (position..(position + added)).filter_map(|position| {
if max_avatars != 0 && i >= max_avatars { let Some(item) = self.slice_model.item(position) else {
break; error!("Could not get item in slice model at position {position}");
} return None;
};
let item = model.item(i).unwrap();
let avatar_data = extract_avatar_data_fn(&item); let avatar_data = extract_avatar_data_fn(&item);
let avatar = Avatar::new(); let avatar = Avatar::new();
@ -273,16 +219,22 @@ mod imp {
child.set_cropped_width(cropped_width); child.set_cropped_width(cropped_width);
child.set_parent(&*obj); child.set_parent(&*obj);
children.insert(i as usize, child); Some(child)
});
for child in children.splice(position as usize..(position + removed) as usize, added) {
child.unparent();
} }
// Make sure that only the last avatar is not cropped. // Make sure that only the last avatar is not cropped.
let last_pos = children.len().saturating_sub(1); let mut peekable_children = children.iter().peekable();
for (i, child) in children.iter().enumerate() { while let Some(child) = peekable_children.next() {
child.set_is_cropped(i != last_pos); child.set_is_cropped(peekable_children.peek().is_some());
} }
obj.queue_resize(); if prev_count != children.len() {
obj.queue_resize();
}
} }
} }
} }
@ -299,13 +251,13 @@ impl OverlappingAvatars {
glib::Object::new() glib::Object::new()
} }
/// Bind a `ListModel` to this list. /// Bind a `GListModel` to this list.
pub(crate) fn bind_model<P: Fn(&glib::Object) -> AvatarData + 'static>( pub(crate) fn bind_model<P: Fn(&glib::Object) -> AvatarData + 'static>(
&self, &self,
model: Option<impl IsA<gio::ListModel>>, model: Option<&impl IsA<gio::ListModel>>,
extract_avatar_data_fn: P, extract_avatar_data_fn: P,
) { ) {
self.imp() self.imp()
.bind_model(model.and_upcast(), extract_avatar_data_fn); .bind_model(model.map(Cast::upcast_ref), extract_avatar_data_fn);
} }
} }

View File

@ -0,0 +1,50 @@
use std::time::Duration;
use ashpd::desktop::camera;
use gtk::prelude::*;
use tokio::time::timeout;
use tracing::error;
mod viewfinder;
use self::viewfinder::LinuxCameraViewfinder;
use super::{CameraExt, CameraViewfinder};
use crate::spawn_tokio;
/// Camera API under Linux.
#[derive(Debug)]
pub(crate) struct LinuxCamera;
impl CameraExt for LinuxCamera {
async fn has_cameras() -> bool {
let fut = async move {
let camera = match camera::Camera::new().await {
Ok(camera) => camera,
Err(error) => {
error!("Could not create instance of camera proxy: {error}");
return false;
}
};
match camera.is_present().await {
Ok(is_present) => is_present,
Err(error) => {
error!("Could not check whether system has cameras: {error}");
false
}
}
};
let handle = spawn_tokio!(async move { timeout(Duration::from_secs(1), fut).await });
if let Ok(is_present) = handle.await.expect("task was not aborted") {
is_present
} else {
error!("Could not check whether system has cameras: request timed out");
false
}
}
async fn viewfinder() -> Option<CameraViewfinder> {
LinuxCameraViewfinder::new().await.and_upcast()
}
}

View File

@ -0,0 +1,190 @@
use ashpd::desktop::camera;
use gtk::{
glib,
glib::{clone, subclass::prelude::*},
prelude::*,
subclass::prelude::*,
};
use matrix_sdk::encryption::verification::QrVerificationData;
use tokio::task::AbortHandle;
use tracing::{debug, error};
use crate::{
components::camera::{
CameraViewfinder, CameraViewfinderExt, CameraViewfinderImpl, CameraViewfinderState,
},
spawn_tokio,
};
impl From<aperture::ViewfinderState> for CameraViewfinderState {
fn from(value: aperture::ViewfinderState) -> Self {
match value {
aperture::ViewfinderState::Loading => Self::Loading,
aperture::ViewfinderState::Ready => Self::Ready,
aperture::ViewfinderState::NoCameras => Self::NoCameras,
aperture::ViewfinderState::Error => Self::Error,
}
}
}
mod imp {
use std::cell::RefCell;
use matrix_sdk::encryption::verification::DecodingError;
use super::*;
#[derive(Debug)]
pub struct LinuxCameraViewfinder {
/// The child viewfinder.
child: aperture::Viewfinder,
/// The device provider for the viewfinder.
provider: aperture::DeviceProvider,
abort_handle: RefCell<Option<AbortHandle>>,
}
impl Default for LinuxCameraViewfinder {
fn default() -> Self {
Self {
child: Default::default(),
provider: aperture::DeviceProvider::instance().clone(),
abort_handle: Default::default(),
}
}
}
#[glib::object_subclass]
impl ObjectSubclass for LinuxCameraViewfinder {
const NAME: &'static str = "LinuxCameraViewfinder";
type Type = super::LinuxCameraViewfinder;
type ParentType = CameraViewfinder;
fn class_init(klass: &mut Self::Class) {
klass.set_layout_manager_type::<gtk::BinLayout>();
}
}
impl ObjectImpl for LinuxCameraViewfinder {
fn constructed(&self) {
self.parent_constructed();
let obj = self.obj();
self.child.set_parent(&*obj);
self.child.set_detect_codes(true);
self.child.connect_state_notify(glib::clone!(
#[weak(rename_to = imp)]
self,
move |_| {
imp.update_state();
}
));
self.update_state();
self.child.connect_code_detected(clone!(
#[weak]
obj,
move |_, code| {
match QrVerificationData::from_bytes(&code) {
Ok(data) => obj.emit_qrcode_detected(data),
Err(error) => {
let code = String::from_utf8_lossy(&code);
if matches!(error, DecodingError::Header) {
debug!("Detected non-Matrix QR Code: {code}");
} else {
error!(
"Could not decode Matrix verification QR code {code}: {error}"
);
}
}
}
}
));
}
fn dispose(&self) {
self.child.stop_stream();
self.child.unparent();
if let Some(abort_handle) = self.abort_handle.take() {
abort_handle.abort();
}
}
}
impl WidgetImpl for LinuxCameraViewfinder {}
impl CameraViewfinderImpl for LinuxCameraViewfinder {}
impl LinuxCameraViewfinder {
/// Initialize the viewfinder.
pub(super) async fn init(&self) -> Result<(), ()> {
if self.provider.started() {
return Ok(());
}
let handle = spawn_tokio!(camera::request());
self.set_abort_handle(Some(handle.abort_handle()));
let Ok(request_result) = handle.await else {
debug!("Camera request was aborted");
self.set_abort_handle(None);
return Err(());
};
self.set_abort_handle(None);
let fd = match request_result {
Ok(Some(fd)) => fd,
Ok(None) => {
error!("Could not access camera: no camera present");
return Err(());
}
Err(error) => {
error!("Could not access camera: {error}");
return Err(());
}
};
if let Err(error) = self.provider.set_fd(fd) {
error!("Could not access camera: {error}");
return Err(());
}
if let Err(error) = self.provider.start_with_default(|camera| {
matches!(camera.location(), aperture::CameraLocation::Back)
}) {
error!("Could not access camera: {error}");
return Err(());
}
Ok(())
}
/// Update the current state.
fn update_state(&self) {
self.obj().set_state(self.child.state().into());
}
/// Set the current abort handle.
fn set_abort_handle(&self, abort_handle: Option<AbortHandle>) {
self.abort_handle.replace(abort_handle);
}
}
}
glib::wrapper! {
/// A camera viewfinder widget for Linux.
pub struct LinuxCameraViewfinder(ObjectSubclass<imp::LinuxCameraViewfinder>)
@extends gtk::Widget, CameraViewfinder;
}
impl LinuxCameraViewfinder {
pub(super) async fn new() -> Option<Self> {
let obj = glib::Object::new::<Self>();
obj.imp().init().await.ok()?;
Some(obj)
}
}

View File

@ -0,0 +1,56 @@
//! Camera API.
#[cfg(target_os = "linux")]
mod linux;
mod qrcode_scanner;
mod viewfinder;
pub(crate) use self::qrcode_scanner::QrCodeScanner;
use self::{
qrcode_scanner::QrVerificationDataBoxed,
viewfinder::{
CameraViewfinder, CameraViewfinderExt, CameraViewfinderImpl, CameraViewfinderState,
},
};
cfg_if::cfg_if! {
if #[cfg(target_os = "linux")] {
/// The camera API.
pub(crate) type Camera = linux::LinuxCamera;
} else {
/// The camera API.
pub(crate) type Camera = unimplemented::UnimplementedCamera;
}
}
/// Trait implemented by camera backends.
pub trait CameraExt {
/// Whether any cameras are available.
async fn has_cameras() -> bool;
/// Get a viewfinder displaying the output of the camera.
///
/// This method should try to get the permission to access cameras, and
/// return `None` when it fails.
async fn viewfinder() -> Option<CameraViewfinder>;
}
/// The fallback `Camera` API, to use on platforms where it is unimplemented.
#[cfg(not(target_os = "linux"))]
mod unimplemented {
use super::*;
#[derive(Debug)]
pub(crate) struct UnimplementedCamera;
impl CameraExt for UnimplementedCamera {
async fn has_cameras() -> bool {
false
}
async fn viewfinder() -> Option<CameraViewfinder> {
tracing::error!("The camera API is not supported on this platform");
None
}
}
}

View File

@ -0,0 +1,144 @@
use adw::{prelude::*, subclass::prelude::*};
use gettextrs::gettext;
use gtk::{
glib,
glib::{clone, closure_local},
};
use matrix_sdk::encryption::verification::QrVerificationData;
use super::{Camera, CameraExt, CameraViewfinder, CameraViewfinderExt, CameraViewfinderState};
use crate::utils::BoundConstructOnlyObject;
#[derive(Clone, Debug, PartialEq, Eq, glib::Boxed)]
#[boxed_type(name = "QrVerificationDataBoxed")]
pub(super) struct QrVerificationDataBoxed(pub(super) QrVerificationData);
mod imp {
use std::sync::LazyLock;
use glib::subclass::{InitializingObject, Signal};
use gtk::CompositeTemplate;
use super::*;
#[derive(Debug, CompositeTemplate, Default, glib::Properties)]
#[template(resource = "/org/gnome/Fractal/ui/components/camera/qrcode_scanner.ui")]
#[properties(wrapper_type = super::QrCodeScanner)]
pub struct QrCodeScanner {
#[template_child]
stack: TemplateChild<gtk::Stack>,
/// The viewfinder to use to scan the QR code.
#[property(get, set = Self::set_viewfinder, construct_only)]
viewfinder: BoundConstructOnlyObject<CameraViewfinder>,
}
#[glib::object_subclass]
impl ObjectSubclass for QrCodeScanner {
const NAME: &'static str = "QrCodeScanner";
type Type = super::QrCodeScanner;
type ParentType = adw::Bin;
fn class_init(klass: &mut Self::Class) {
Self::bind_template(klass);
}
fn instance_init(obj: &InitializingObject<Self>) {
obj.init_template();
}
}
#[glib::derived_properties]
impl ObjectImpl for QrCodeScanner {
fn signals() -> &'static [Signal] {
static SIGNALS: LazyLock<Vec<Signal>> = LazyLock::new(|| {
vec![Signal::builder("qrcode-detected")
.param_types([QrVerificationDataBoxed::static_type()])
.run_first()
.build()]
});
SIGNALS.as_ref()
}
}
impl WidgetImpl for QrCodeScanner {}
impl BinImpl for QrCodeScanner {}
impl QrCodeScanner {
/// Set the viewfinder to use to scan the QR code.
fn set_viewfinder(&self, viewfinder: CameraViewfinder) {
let obj = self.obj();
let state_handler = viewfinder.connect_state_notify(clone!(
#[weak(rename_to = imp)]
self,
move |_| {
imp.update_visible_page();
}
));
let qrcode_detected_handler = viewfinder.connect_qrcode_detected(clone!(
#[weak]
obj,
move |_, data| {
obj.emit_by_name::<()>("qrcode-detected", &[&QrVerificationDataBoxed(data)]);
}
));
viewfinder.set_overflow(gtk::Overflow::Hidden);
viewfinder.add_css_class("card");
self.stack
.add_titled(&viewfinder, Some("camera"), &gettext("Camera"));
self.viewfinder
.set(viewfinder, vec![state_handler, qrcode_detected_handler]);
self.update_visible_page();
}
/// Update the visible page according to the current state.
fn update_visible_page(&self) {
let name = match self.viewfinder.obj().state() {
CameraViewfinderState::Loading => "loading",
CameraViewfinderState::Ready => "camera",
CameraViewfinderState::NoCameras | CameraViewfinderState::Error => "no-camera",
};
self.stack.set_visible_child_name(name);
}
}
}
glib::wrapper! {
/// A widget to show the output of the camera and detect QR codes with it.
pub struct QrCodeScanner(ObjectSubclass<imp::QrCodeScanner>)
@extends gtk::Widget, adw::Bin;
}
impl QrCodeScanner {
/// Try to construct a new `QrCodeScanner`.
///
/// Returns `None` if we could not get a [`CameraViewfinder`].
pub async fn new() -> Option<Self> {
let viewfinder = Camera::viewfinder().await?;
let obj = glib::Object::builder()
.property("viewfinder", viewfinder)
.build();
Some(obj)
}
/// Connect to the signal emitted when a QR code is detected.
pub fn connect_qrcode_detected<F: Fn(&Self, QrVerificationData) + 'static>(
&self,
f: F,
) -> glib::SignalHandlerId {
self.connect_closure(
"qrcode-detected",
true,
closure_local!(move |obj: Self, data: QrVerificationDataBoxed| {
f(&obj, data.0);
}),
)
}
}

View File

@ -7,6 +7,15 @@
<child> <child>
<object class="GtkStack" id="stack"> <object class="GtkStack" id="stack">
<property name="transition-type">crossfade</property> <property name="transition-type">crossfade</property>
<child>
<object class="GtkStackPage">
<property name="name">loading</property>
<property name="title" translatable="yes">Loading</property>
<property name="child">
<object class="AdwSpinner" />
</property>
</object>
</child>
<child> <child>
<object class="GtkStackPage"> <object class="GtkStackPage">
<property name="name">no-camera</property> <property name="name">no-camera</property>
@ -30,26 +39,6 @@
</property> </property>
</object> </object>
</child> </child>
<child>
<object class="GtkStackPage">
<property name="name">camera</property>
<property name="title" translatable="yes">Camera</property>
<property name="child">
<object class="GtkPicture" id="picture">
<accessibility>
<!-- Translators: This is the widget where we see a camera's output -->
<property name="label" translatable="yes">Viewfinder</property>
</accessibility>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="overflow">hidden</property>
<style>
<class name="card"/>
</style>
</object>
</property>
</object>
</child>
</object> </object>
</child> </child>
</template> </template>

View File

@ -0,0 +1,162 @@
//! Camera viewfinder API.
use gettextrs::gettext;
use gtk::{glib, glib::closure_local, prelude::*, subclass::prelude::*};
use matrix_sdk::encryption::verification::QrVerificationData;
use super::QrVerificationDataBoxed;
/// The possible states of a [`CameraViewfinder`].
#[derive(Default, Debug, Copy, Clone, glib::Enum, PartialEq)]
#[enum_type(name = "CameraViewfinderState")]
pub enum CameraViewfinderState {
/// The viewfinder is still loading.
#[default]
Loading,
/// The viewfinder is ready for use.
Ready,
/// The viewfinder could not find any cameras to use.
NoCameras,
/// The viewfinder had an error and is not usable.
Error,
}
mod imp {
use std::{cell::Cell, sync::LazyLock};
use glib::subclass::Signal;
use super::*;
#[repr(C)]
pub struct CameraViewfinderClass {
parent_class: glib::object::Class<gtk::Widget>,
}
unsafe impl ClassStruct for CameraViewfinderClass {
type Type = CameraViewfinder;
}
#[derive(Debug, Default, glib::Properties)]
#[properties(wrapper_type = super::CameraViewfinder)]
pub struct CameraViewfinder {
/// The state of this viewfinder.
#[property(get, set = Self::set_state, explicit_notify, builder(CameraViewfinderState::default()))]
state: Cell<CameraViewfinderState>,
}
#[glib::object_subclass]
impl ObjectSubclass for CameraViewfinder {
const NAME: &'static str = "CameraViewfinder";
type Type = super::CameraViewfinder;
type ParentType = gtk::Widget;
type Class = CameraViewfinderClass;
}
#[glib::derived_properties]
impl ObjectImpl for CameraViewfinder {
fn signals() -> &'static [Signal] {
static SIGNALS: LazyLock<Vec<Signal>> = LazyLock::new(|| {
vec![Signal::builder("qrcode-detected")
.param_types([QrVerificationDataBoxed::static_type()])
.run_first()
.build()]
});
SIGNALS.as_ref()
}
fn constructed(&self) {
self.parent_constructed();
self.obj()
.update_property(&[gtk::accessible::Property::Label(&gettext("Viewfinder"))]);
}
}
impl WidgetImpl for CameraViewfinder {}
impl CameraViewfinder {
/// Set the state of this viewfinder.
fn set_state(&self, state: CameraViewfinderState) {
if self.state.get() == state {
return;
}
self.state.set(state);
self.obj().notify_state();
}
}
}
glib::wrapper! {
/// Subclassable camera viewfinder widget.
///
/// The widget presents the output of the camera and detects QR codes.
///
/// To construct this, use `Camera::viewfinder()`.
pub struct CameraViewfinder(ObjectSubclass<imp::CameraViewfinder>)
@extends gtk::Widget, @implements gtk::Accessible;
}
/// Trait implemented by types that subclass [`CameraViewfinder`].
#[allow(dead_code)]
pub(super) trait CameraViewfinderExt: 'static {
/// The state of this viewfinder.
fn state(&self) -> CameraViewfinderState;
/// Set the state of this viewfinder.
fn set_state(&self, state: CameraViewfinderState);
/// Connect to the signal emitted when a QR code is detected.
fn connect_qrcode_detected<F: Fn(&Self, QrVerificationData) + 'static>(
&self,
f: F,
) -> glib::SignalHandlerId;
/// Emit the signal that a QR code was detected.
fn emit_qrcode_detected(&self, data: QrVerificationData);
}
impl<O: IsA<CameraViewfinder>> CameraViewfinderExt for O {
fn state(&self) -> CameraViewfinderState {
self.upcast_ref().state()
}
/// Set the state of this viewfinder.
fn set_state(&self, state: CameraViewfinderState) {
self.upcast_ref().set_state(state);
}
fn connect_qrcode_detected<F: Fn(&Self, QrVerificationData) + 'static>(
&self,
f: F,
) -> glib::SignalHandlerId {
self.connect_closure(
"qrcode-detected",
true,
closure_local!(|obj: Self, data: QrVerificationDataBoxed| {
f(&obj, data.0);
}),
)
}
fn emit_qrcode_detected(&self, data: QrVerificationData) {
self.emit_by_name::<()>("qrcode-detected", &[&QrVerificationDataBoxed(data)]);
}
}
/// Trait that must be implemented for types that subclass `CameraViewfinder`.
///
/// Overriding a method from this Trait overrides also its behavior in
/// [`CameraViewfinderExt`].
pub(super) trait CameraViewfinderImpl: ObjectImpl {}
unsafe impl<T> IsSubclassable<T> for CameraViewfinder
where
T: CameraViewfinderImpl + WidgetImpl,
T::Type: IsA<CameraViewfinder>,
{
fn class_init(class: &mut glib::Class<Self>) {
Self::parent_class_init::<T>(class.upcast_ref_mut());
}
}

View File

@ -1,7 +1,7 @@
use adw::subclass::prelude::*; use adw::subclass::prelude::*;
use gtk::{gdk, glib, glib::clone, prelude::*, CompositeTemplate}; use gtk::{gdk, glib, glib::clone, prelude::*, CompositeTemplate};
use crate::utils::BoundObject; use crate::utils::{key_bindings, BoundObject};
mod imp { mod imp {
use std::cell::{Cell, RefCell}; use std::cell::{Cell, RefCell};
@ -62,16 +62,7 @@ mod imp {
klass.install_action("context-menu.activate", None, |obj, _, _| { klass.install_action("context-menu.activate", None, |obj, _, _| {
obj.open_menu_at(0, 0); obj.open_menu_at(0, 0);
}); });
klass.add_binding_action( key_bindings::add_context_menu_bindings(klass, "context-menu.activate");
gdk::Key::F10,
gdk::ModifierType::SHIFT_MASK,
"context-menu.activate",
);
klass.add_binding_action(
gdk::Key::Menu,
gdk::ModifierType::empty(),
"context-menu.activate",
);
klass.install_action("context-menu.close", None, |obj, _, _| { klass.install_action("context-menu.close", None, |obj, _, _| {
if let Some(popover) = obj.popover() { if let Some(popover) = obj.popover() {

View File

@ -380,8 +380,10 @@ mod imp {
self.send_request_btn.set_is_loading(true); self.send_request_btn.set_is_loading(true);
if let Err(()) = session.verification_list().create(None).await { if let Err(()) = session.verification_list().create(None).await {
let obj = self.obj(); toast!(
toast!(obj, gettext("Could not send a new verification request")); self.obj(),
gettext("Could not send a new verification request")
);
} }
// On success, the verification should be shown automatically. // On success, the verification should be shown automatically.
@ -432,7 +434,7 @@ mod imp {
} }
Err(error) => { Err(error) => {
error!("Could not bootstrap cross-signing: {error:?}"); error!("Could not bootstrap cross-signing: {error:?}");
toast!(obj, gettext("Could not create the crypto identity",)); toast!(obj, gettext("Could not create the crypto identity"));
} }
} }

View File

@ -1,446 +0,0 @@
use std::{fmt::Debug, future::Future};
use adw::{prelude::*, subclass::prelude::*};
use futures_channel::oneshot;
use gettextrs::gettext;
use gtk::{glib, glib::clone, CompositeTemplate};
use matrix_sdk::{encryption::CrossSigningResetAuthType, Error};
use ruma::{
api::client::{
error::StandardErrorBody,
uiaa::{
AuthData, AuthType, Dummy, FallbackAcknowledgement, Password, UiaaInfo, UserIdentifier,
},
},
assign,
};
use thiserror::Error;
use tracing::error;
use crate::{prelude::*, session::model::Session, spawn, spawn_tokio};
/// An error during UIAA interaction.
#[derive(Debug, Error)]
pub enum AuthError {
/// The server returned a non-UIAA error.
#[error(transparent)]
ServerResponse(#[from] Error),
/// The ID of the UIAA session is missing for a stage that requires it.
#[error("The ID of the session is missing")]
MissingSessionId,
/// The available flows are empty or done but the endpoint still requires
/// UIAA.
#[error("There is no stage to choose from")]
NoStageToChoose,
/// The user cancelled the authentication.
#[error("The user cancelled the authentication")]
UserCancelled,
/// The parent `Session` could not be upgraded.
#[error("The session could not be upgraded")]
NoSession,
/// The parent `gtk::Widget` could not be upgraded.
#[error("The parent widget could not be upgraded")]
NoParentWidget,
/// An unexpected error occurred.
#[error("An unexpected error occurred")]
Unknown,
}
mod imp {
use std::cell::RefCell;
use glib::subclass::InitializingObject;
use super::*;
#[derive(Debug, Default, CompositeTemplate, glib::Properties)]
#[template(resource = "/org/gnome/Fractal/ui/components/dialogs/auth.ui")]
#[properties(wrapper_type = super::AuthDialog)]
pub struct AuthDialog {
#[template_child]
password: TemplateChild<gtk::PasswordEntry>,
#[template_child]
open_browser_btn: TemplateChild<gtk::Button>,
open_browser_btn_handler: RefCell<Option<glib::SignalHandlerId>>,
#[template_child]
error: TemplateChild<gtk::Label>,
/// The parent session.
#[property(get, set, construct_only)]
session: glib::WeakRef<Session>,
/// The parent widget.
#[property(get)]
parent: glib::WeakRef<gtk::Widget>,
/// The sender for the response.
sender: RefCell<Option<oneshot::Sender<String>>>,
}
#[glib::object_subclass]
impl ObjectSubclass for AuthDialog {
const NAME: &'static str = "AuthDialog";
type Type = super::AuthDialog;
type ParentType = adw::AlertDialog;
fn class_init(klass: &mut Self::Class) {
Self::bind_template(klass);
Self::bind_template_callbacks(klass);
}
fn instance_init(obj: &InitializingObject<Self>) {
obj.init_template();
}
}
#[glib::derived_properties]
impl ObjectImpl for AuthDialog {}
impl WidgetImpl for AuthDialog {}
impl AdwDialogImpl for AuthDialog {}
impl AdwAlertDialogImpl for AuthDialog {
fn response(&self, response: &str) {
if let Some(sender) = self.sender.take() {
if sender.send(response.to_owned()).is_err() {
error!("Could not send response");
}
}
}
}
#[gtk::template_callbacks]
impl AuthDialog {
/// Authenticate the user to the server via an interactive
/// authentication flow.
///
/// The type of flow and the required stages are negotiated during the
/// authentication. Returns the last server response on success.
pub(super) async fn authenticate<
Response: Send + 'static,
F1: Future<Output = Result<Response, Error>> + Send + 'static,
FN: Fn(matrix_sdk::Client, Option<AuthData>) -> F1 + Send + 'static + Sync + Clone,
>(
&self,
parent: &gtk::Widget,
callback: FN,
) -> Result<Response, AuthError> {
let Some(client) = self.session.upgrade().map(|s| s.client()) else {
return Err(AuthError::NoSession);
};
self.parent.set(Some(parent));
let mut auth_data = None;
loop {
let callback_clone = callback.clone();
let client_clone = client.clone();
// Get the current state of the authentication.
let handle =
spawn_tokio!(async move { callback_clone(client_clone, auth_data).await });
let response = handle.await.expect("task was not aborted");
let error = match response {
// Authentication is over.
Ok(result) => return Ok(result),
Err(error) => error,
};
// If this is a UIAA error, authentication continues.
let Some(uiaa_info) = error.as_uiaa_response() else {
return Err(error.into());
};
let next_auth_data = self.perform_next_stage(uiaa_info).await?;
auth_data = Some(next_auth_data);
}
}
/// Reset the cross-signing keys while handling the interactive
/// authentication flow.
///
/// The type of flow and the required stages are negotiated during the
/// authentication. Returns the last server response on success.
pub(super) async fn reset_cross_signing(
&self,
parent: &gtk::Widget,
) -> Result<(), AuthError> {
let Some(encryption) = self.session.upgrade().map(|s| s.client().encryption()) else {
return Err(AuthError::NoSession);
};
self.parent.set(Some(parent));
let handle = spawn_tokio!(async move { encryption.reset_cross_signing().await })
.await
.expect("task was not aborted")?;
if let Some(handle) = handle {
match handle.auth_type() {
CrossSigningResetAuthType::Uiaa(uiaa_info) => {
let auth_data = self.perform_next_stage(uiaa_info).await?;
spawn_tokio!(async move { handle.auth(Some(auth_data)).await })
.await
.expect("task was not aborted")?;
}
CrossSigningResetAuthType::Oidc(_) => {
// According to the code, this is only used with the `experimental-oidc`
// feature. Return an error in case this changes.
error!(
"Could not perform cross-signing reset: received unexpected OIDC stage"
);
return Err(AuthError::Unknown);
}
}
}
Ok(())
}
/// Performs the preferred next stage in the given UIAA info.
///
/// Stages that are actually supported are preferred. If no stages are
/// supported, we use the web-based fallback.
async fn perform_next_stage(&self, uiaa_info: &UiaaInfo) -> Result<AuthData, AuthError> {
// Show the authentication error, if there is one.
self.show_auth_error(uiaa_info.auth_error.as_ref());
// Find and perform the next stage.
let stages = uiaa_info
.flows
.iter()
.filter_map(|flow| flow.stages.strip_prefix(uiaa_info.completed.as_slice()))
.filter_map(|stages_left| stages_left.first());
let mut first_stage = None;
for stage in stages {
if let Some(auth_result) = self
.try_perform_stage(uiaa_info.session.as_ref(), stage)
.await
{
return auth_result;
}
if first_stage.is_none() {
first_stage = Some(stage);
}
}
// Default to first stage if no stages are supported.
let first_stage = first_stage.ok_or(AuthError::NoStageToChoose)?;
self.perform_fallback(uiaa_info.session.clone(), first_stage)
.await
}
/// Tries to perform the given stage.
///
/// Returns `None` if the stage is not implemented.
async fn try_perform_stage(
&self,
uiaa_session: Option<&String>,
stage: &AuthType,
) -> Option<Result<AuthData, AuthError>> {
match stage {
AuthType::Password => {
Some(self.perform_password_stage(uiaa_session.cloned()).await)
}
AuthType::Sso => Some(self.perform_fallback(uiaa_session.cloned(), stage).await),
AuthType::Dummy => Some(Ok(Self::perform_dummy_stage(uiaa_session.cloned()))),
_ => None,
}
}
/// Performs the password stage.
async fn perform_password_stage(
&self,
uiaa_session: Option<String>,
) -> Result<AuthData, AuthError> {
let Some(session) = self.session.upgrade() else {
return Err(AuthError::NoSession);
};
let obj = self.obj();
self.password.set_visible(true);
self.open_browser_btn.set_visible(false);
obj.set_body(&gettext(
"Please authenticate the operation with your password",
));
obj.set_response_enabled("confirm", false);
self.show_and_wait_for_response().await?;
let user_id = session.user_id().to_string();
let password = self.password.text().into();
let data = assign!(
Password::new(UserIdentifier::UserIdOrLocalpart(user_id), password),
{ session: uiaa_session }
);
Ok(AuthData::Password(data))
}
/// Performs the dummy stage.
fn perform_dummy_stage(uiaa_session: Option<String>) -> AuthData {
AuthData::Dummy(assign!(Dummy::new(), { session: uiaa_session }))
}
/// Performs a web-based fallback for the given stage.
async fn perform_fallback(
&self,
uiaa_session: Option<String>,
stage: &AuthType,
) -> Result<AuthData, AuthError> {
let Some(client) = self.session.upgrade().map(|s| s.client()) else {
return Err(AuthError::NoSession);
};
let uiaa_session = uiaa_session.ok_or(AuthError::MissingSessionId)?;
let obj = self.obj();
self.password.set_visible(false);
self.open_browser_btn.set_visible(true);
obj.set_body(&gettext(
"Please authenticate the operation via the browser and, once completed, press confirm",
));
obj.set_response_enabled("confirm", false);
let homeserver = client.homeserver();
self.set_up_fallback(homeserver.as_str(), stage.as_ref(), &uiaa_session);
self.show_and_wait_for_response().await?;
Ok(AuthData::FallbackAcknowledgement(
FallbackAcknowledgement::new(uiaa_session),
))
}
/// Let the user complete the current stage.
async fn show_and_wait_for_response(&self) -> Result<(), AuthError> {
let Some(parent) = self.parent.upgrade() else {
return Err(AuthError::NoParentWidget);
};
let obj = self.obj();
let (sender, receiver) = futures_channel::oneshot::channel();
self.sender.replace(Some(sender));
// Show this dialog.
obj.present(Some(&parent));
// Wait for the response.
let result = receiver.await;
// Close this dialog.
obj.close();
match result.as_deref() {
Ok("confirm") => Ok(()),
Ok(_) => Err(AuthError::UserCancelled),
Err(_) => {
error!("Could not get the response, the channel was closed");
Err(AuthError::Unknown)
}
}
}
/// Show the given error.
fn show_auth_error(&self, auth_error: Option<&StandardErrorBody>) {
if let Some(auth_error) = auth_error {
self.error.set_label(&auth_error.message);
}
self.error.set_visible(auth_error.is_some());
}
/// Prepare the button to open the web-based fallback with the given
/// settings.
fn set_up_fallback(&self, homeserver: &str, auth_type: &str, uiaa_session: &str) {
if let Some(handler) = self.open_browser_btn_handler.take() {
self.open_browser_btn.disconnect(handler);
}
let uri = format!(
"{homeserver}_matrix/client/r0/auth/{auth_type}/fallback/web?session={uiaa_session}"
);
let handler = self.open_browser_btn.connect_clicked(clone!(
#[weak(rename_to = imp)]
self,
move |_| {
let uri = uri.clone();
spawn!(async move {
let Some(parent) = imp.parent.upgrade() else {
return;
};
if let Err(error) = gtk::UriLauncher::new(&uri)
.launch_future(parent.root().and_downcast_ref::<gtk::Window>())
.await
{
error!("Could not launch URI: {error}");
}
imp.obj().set_response_enabled("confirm", true);
});
}
));
self.open_browser_btn_handler.replace(Some(handler));
}
/// Update the confirm response for the current state.
#[template_callback]
fn update_confirm(&self) {
self.obj()
.set_response_enabled("confirm", !self.password.text().is_empty());
}
}
}
glib::wrapper! {
/// Dialog to guide the user through the [User-Interactive Authentication API] (UIAA).
///
/// [User-Interactive Authentication API]: https://spec.matrix.org/latest/client-server-api/#user-interactive-authentication-api
pub struct AuthDialog(ObjectSubclass<imp::AuthDialog>)
@extends gtk::Widget, adw::Dialog, adw::AlertDialog, @implements gtk::Accessible;
}
impl AuthDialog {
pub fn new(session: &Session) -> Self {
glib::Object::builder().property("session", session).build()
}
/// Authenticate the user to the server via an interactive authentication
/// flow.
///
/// The type of flow and the required stages are negotiated during the
/// authentication. Returns the last server response on success.
pub(crate) async fn authenticate<
Response: Send + 'static,
F1: Future<Output = Result<Response, Error>> + Send + 'static,
FN: Fn(matrix_sdk::Client, Option<AuthData>) -> F1 + Send + 'static + Sync + Clone,
>(
&self,
parent: &impl IsA<gtk::Widget>,
callback: FN,
) -> Result<Response, AuthError> {
self.imp().authenticate(parent.upcast_ref(), callback).await
}
/// Reset the cross-signing keys while handling the interactive
/// authentication flow.
///
/// The type of flow and the required stages are negotiated during the
/// authentication. Returns the last server response on success.
pub(crate) async fn reset_cross_signing(
&self,
parent: &impl IsA<gtk::Widget>,
) -> Result<(), AuthError> {
self.imp().reset_cross_signing(parent.upcast_ref()).await
}
}

View File

@ -1,68 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<template class="AuthDialog" parent="AdwAlertDialog">
<property name="heading" translatable="yes">Authentication</property>
<property name="default-response">confirm</property>
<property name="close-response">cancel</property>
<property name="extra_child">
<object class="GtkBox">
<property name="orientation">vertical</property>
<property name="spacing">12</property>
<child>
<object class="GtkPasswordEntry" id="password">
<property name="activates-default">True</property>
<property name="show-peek-icon">True</property>
<signal name="changed" handler="update_confirm" swapped="yes" />
</object>
</child>
<child>
<object class="GtkButton" id="open_browser_btn">
<property name="can-shrink">true</property>
<property name="halign">center</property>
<style>
<class name="suggested-action"/>
<class name="pill"/>
<class name="large"/>
<class name="image-text-button"/>
</style>
<property name="child">
<object class="GtkBox">
<property name="halign">center</property>
<child>
<object class="GtkLabel">
<property name="label" translatable="yes">Contin_ue</property>
<property name="use-underline">True</property>
<property name="ellipsize">end</property>
<property name="mnemonic-widget">open_browser_btn</property>
</object>
</child>
<child>
<object class="GtkImage">
<property name="icon-name">external-link-symbolic</property>
<property name="accessible-role">presentation</property>
<property name="valign">center</property>
</object>
</child>
</object>
</property>
</object>
</child>
<child>
<object class="GtkLabel" id="error">
<property name="visible">False</property>
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="max-width-chars">60</property>
<property name="halign">center</property>
<property name="valign">start</property>
<property name="margin-bottom">12</property>
</object>
</child>
</object>
</property>
<responses>
<response id="cancel" translatable="yes">_Cancel</response>
<response id="confirm" translatable="yes" appearance="suggested" enabled="false">C_onfirm</response>
</responses>
</template>
</interface>

View File

@ -0,0 +1,104 @@
use std::fmt::Debug;
use adw::{prelude::*, subclass::prelude::*};
use gtk::{glib, CompositeTemplate};
use tracing::error;
use crate::components::LoadingButton;
mod imp {
use std::cell::OnceCell;
use glib::subclass::InitializingObject;
use super::*;
#[derive(Debug, Default, CompositeTemplate, glib::Properties)]
#[template(resource = "/org/gnome/Fractal/ui/components/dialogs/auth/in_browser_page.ui")]
#[properties(wrapper_type = super::AuthDialogInBrowserPage)]
pub struct AuthDialogInBrowserPage {
#[template_child]
pub(super) confirm_button: TemplateChild<LoadingButton>,
/// The URL to launch.
#[property(get, construct_only)]
url: OnceCell<String>,
}
#[glib::object_subclass]
impl ObjectSubclass for AuthDialogInBrowserPage {
const NAME: &'static str = "AuthDialogInBrowserPage";
type Type = super::AuthDialogInBrowserPage;
type ParentType = adw::Bin;
fn class_init(klass: &mut Self::Class) {
Self::bind_template(klass);
Self::bind_template_callbacks(klass);
}
fn instance_init(obj: &InitializingObject<Self>) {
obj.init_template();
}
}
#[glib::derived_properties]
impl ObjectImpl for AuthDialogInBrowserPage {}
impl WidgetImpl for AuthDialogInBrowserPage {}
impl BinImpl for AuthDialogInBrowserPage {}
#[gtk::template_callbacks]
impl AuthDialogInBrowserPage {
/// Open the URL in the browser.
#[template_callback]
async fn launch_url(&self) {
let url = self
.url
.get()
.expect("URL should be set during construction");
if let Err(error) = gtk::UriLauncher::new(url)
.launch_future(self.obj().root().and_downcast_ref::<gtk::Window>())
.await
{
error!("Could not launch authentication URI: {error}");
}
self.confirm_button.set_sensitive(true);
}
/// Proceed to authentication with the current password.
#[template_callback]
fn proceed(&self) {
self.confirm_button.set_is_loading(true);
let _ = self.obj().activate_action("auth-dialog.continue", None);
}
/// Retry this stage.
pub(super) fn retry(&self) {
self.confirm_button.set_is_loading(false);
}
}
}
glib::wrapper! {
/// Page to pass a stage in the browser for the [`AuthDialog`].
pub struct AuthDialogInBrowserPage(ObjectSubclass<imp::AuthDialogInBrowserPage>)
@extends gtk::Widget, adw::Bin, @implements gtk::Accessible;
}
impl AuthDialogInBrowserPage {
/// Construct an `AuthDialogInBrowserPage` that will launch the given URL.
pub fn new(url: String) -> Self {
glib::Object::builder().property("url", url).build()
}
/// Get the default widget of this page.
pub fn default_widget(&self) -> &gtk::Widget {
self.imp().confirm_button.upcast_ref()
}
/// Retry this stage.
pub fn retry(&self) {
self.imp().retry();
}
}

View File

@ -0,0 +1,129 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<template class="AuthDialogInBrowserPage" parent="AdwBin">
<property name="child">
<object class="GtkWindowHandle">
<property name="child">
<object class="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="GtkScrolledWindow">
<property name="propagate-natural-width">True</property>
<property name="propagate-natural-height">True</property>
<property name="hscrollbar-policy">never</property>
<style>
<class name="body-scrolled-window"/>
<class name="undershoot-bottom"/>
</style>
<property name="child">
<object class="GtkBox">
<property name="orientation">vertical</property>
<style>
<class name="message-area"/>
<class name="has-heading"/>
<class name="has-body"/>
</style>
<child>
<object class="AdwBin">
<style>
<class name="heading-bin"/>
</style>
<child>
<object class="GtkLabel">
<property name="justify">center</property>
<property name="xalign">0.5</property>
<property name="label" translatable="yes">Authentication</property>
<style>
<class name="title-2"/>
</style>
</object>
</child>
</object>
</child>
<child>
<object class="GtkLabel">
<property name="vexpand">True</property>
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="justify">center</property>
<property name="xalign">0.5</property>
<property name="label" translatable="yes">Please authenticate the operation via the browser and, once completed, press confirm</property>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>
<object class="GtkButton" id="launch_url_btn">
<property name="margin-top">6</property>
<property name="can-shrink">true</property>
<property name="halign">center</property>
<signal name="clicked" handler="launch_url" swapped="yes"/>
<style>
<class name="suggested-action"/>
<class name="pill"/>
<class name="large"/>
<class name="image-text-button"/>
</style>
<property name="child">
<object class="GtkBox">
<property name="halign">center</property>
<child>
<object class="GtkLabel">
<property name="label" translatable="yes">Contin_ue</property>
<property name="use-underline">True</property>
<property name="ellipsize">end</property>
<property name="mnemonic-widget">launch_url_btn</property>
</object>
</child>
<child>
<object class="GtkImage">
<property name="icon-name">external-link-symbolic</property>
<property name="accessible-role">presentation</property>
<property name="valign">center</property>
</object>
</child>
</object>
</property>
</object>
</child>
</object>
</property>
</object>
</child>
<child>
<object class="AdwWrapBox">
<property name="child-spacing">12</property>
<property name="line-spacing">12</property>
<property name="justify">fill</property>
<property name="justify-last-line">True</property>
<style>
<class name="response-area"/>
</style>
<child>
<object class="GtkButton">
<property name="can-shrink">True</property>
<property name="use-underline">True</property>
<property name="label" translatable="yes">_Cancel</property>
<property name="action-name">auth-dialog.close</property>
</object>
</child>
<child>
<object class="LoadingButton" id="confirm_button">
<property name="sensitive">False</property>
<property name="can-shrink">True</property>
<property name="content-label" translatable="yes">C_onfirm</property>
<signal name="clicked" handler="proceed" swapped="yes"/>
<style>
<class name="suggested-action"/>
</style>
</object>
</child>
</object>
</child>
</object>
</property>
</object>
</property>
</template>
</interface>

View File

@ -0,0 +1,635 @@
use std::{fmt::Debug, future::Future};
use adw::{prelude::*, subclass::prelude::*};
use futures_channel::oneshot;
use gettextrs::gettext;
use gtk::{glib, glib::clone, CompositeTemplate};
use matrix_sdk::{encryption::CrossSigningResetAuthType, Error};
use ruma::{
api::{
client::uiaa::{
get_uiaa_fallback_page, AuthData, AuthType, Dummy, FallbackAcknowledgement, Password,
UiaaInfo, UserIdentifier,
},
MatrixVersion, OutgoingRequest, SendAccessToken,
},
assign,
};
use thiserror::Error;
use tracing::{error, warn};
mod in_browser_page;
mod password_page;
use self::{in_browser_page::AuthDialogInBrowserPage, password_page::AuthDialogPasswordPage};
use crate::{components::ToastableDialog, prelude::*, session::model::Session, spawn_tokio, toast};
mod imp {
use std::{
cell::{Cell, RefCell},
rc::Rc,
sync::Arc,
};
use glib::subclass::InitializingObject;
use tokio::task::JoinHandle;
use super::*;
#[derive(Debug, Default, CompositeTemplate, glib::Properties)]
#[template(resource = "/org/gnome/Fractal/ui/components/dialogs/auth/mod.ui")]
#[properties(wrapper_type = super::AuthDialog)]
pub struct AuthDialog {
#[template_child]
stack: TemplateChild<gtk::Stack>,
/// The parent session.
#[property(get, set, construct_only)]
session: glib::WeakRef<Session>,
/// Whether this dialog is presented.
is_presented: Cell<bool>,
/// The current state of the authentication.
///
/// `None` means that the authentication has not started yet.
state: RefCell<Option<AuthState>>,
/// The page for the current stage.
current_page: RefCell<Option<gtk::Widget>>,
/// The sender to get the signal to perform the current stage.
sender: RefCell<Option<oneshot::Sender<()>>>,
/// The handle to abort the current future.
abort_handle: RefCell<Option<tokio::task::AbortHandle>>,
}
#[glib::object_subclass]
impl ObjectSubclass for AuthDialog {
const NAME: &'static str = "AuthDialog";
type Type = super::AuthDialog;
type ParentType = ToastableDialog;
fn class_init(klass: &mut Self::Class) {
Self::bind_template(klass);
klass.install_action("auth-dialog.continue", None, |obj, _, _| {
if let Some(sender) = obj.imp().sender.take() {
let _ = sender.send(());
}
});
klass.install_action("auth-dialog.close", None, |obj, _, _| {
obj.imp().close();
});
}
fn instance_init(obj: &InitializingObject<Self>) {
obj.init_template();
}
}
#[glib::derived_properties]
impl ObjectImpl for AuthDialog {
fn dispose(&self) {
if let Some(abort_handle) = self.abort_handle.take() {
abort_handle.abort();
}
}
}
impl WidgetImpl for AuthDialog {}
impl AdwDialogImpl for AuthDialog {}
impl ToastableDialogImpl for AuthDialog {}
impl AuthDialog {
/// Authenticate the user to the server via an interactive
/// authentication flow.
///
/// The type of flow and the required stages are negotiated during the
/// authentication. Returns the last server response on success.
pub(super) async fn authenticate<Response, Fut, FN>(
&self,
parent: &gtk::Widget,
callback: FN,
) -> Result<Response, AuthError>
where
Response: Send + 'static,
Fut: Future<Output = Result<Response, Error>> + Send + 'static,
FN: Fn(matrix_sdk::Client, Option<AuthData>) -> Fut + Send + Sync + 'static + Clone,
{
let Some(client) = self.session.upgrade().map(|s| s.client()) else {
return Err(AuthError::Unknown);
};
// Perform the request once, to see if UIAA if required.
let callback_clone = callback.clone();
let client_clone = client.clone();
let handle = spawn_tokio!(async move { callback_clone(client_clone, None).await });
let result = self.await_tokio_task(handle).await;
// If this is a UIAA error, we need authentication.
let Some(uiaa_info) = result.uiaa_info() else {
return result;
};
let result = self
.perform_uiaa(uiaa_info.clone(), parent, move |auth_data| {
let client = client.clone();
let callback = callback.clone();
async move { callback(client, Some(auth_data)).await }
})
.await;
self.close();
result
}
/// Reset the cross-signing keys while handling the interactive
/// authentication flow.
///
/// The type of flow and the required stages are negotiated during the
/// authentication.
///
/// Note that due to the implementation of the underlying SDK API, this
/// will not work if there are several stages in the flow.
///
/// Returns the last server response on success.
pub(super) async fn reset_cross_signing(
&self,
parent: &gtk::Widget,
) -> Result<(), AuthError> {
let Some(encryption) = self.session.upgrade().map(|s| s.client().encryption()) else {
return Err(AuthError::Unknown);
};
let handle = spawn_tokio!(async move { encryption.reset_cross_signing().await });
let result = self.await_tokio_task(handle).await?;
let Some(cross_signing_reset_handle) = result else {
// No authentication is needed.
return Ok(());
};
let result = match cross_signing_reset_handle.auth_type().clone() {
CrossSigningResetAuthType::Uiaa(uiaa_info) => {
let cross_signing_reset_handle = Arc::new(cross_signing_reset_handle);
self.perform_uiaa(uiaa_info, parent, move |auth_data| {
let cross_signing_reset_handle = cross_signing_reset_handle.clone();
async move { cross_signing_reset_handle.auth(Some(auth_data)).await }
})
.await
}
CrossSigningResetAuthType::OAuth(info) => {
// This is a special stage, which requires opening a URL in the browser.
let page = AuthDialogInBrowserPage::new(info.approval_url.to_string());
let default_widget = page.default_widget().clone();
self.show_page(page.upcast(), &default_widget, parent);
// The `CrossSigningResetHandle` will poll the endpoint until it succeeds.
let handle =
spawn_tokio!(async move { cross_signing_reset_handle.auth(None).await });
self.await_tokio_task(handle).await
}
};
self.close();
result
}
/// Await the given tokio task, handling if it is aborted.
async fn await_tokio_task<Response>(
&self,
handle: JoinHandle<Result<Response, Error>>,
) -> Result<Response, AuthError>
where
Response: Send + 'static,
{
self.abort_handle.replace(Some(handle.abort_handle()));
let Ok(result) = handle.await else {
// The future was aborted, which means that the user closed the dialog.
return Err(AuthError::UserCancelled);
};
self.abort_handle.take();
Ok(result?)
}
/// Perform UIAA for the given callback, starting with the given UIAA
/// info.
async fn perform_uiaa<Response, Fut, FN>(
&self,
mut uiaa_info: UiaaInfo,
parent: &gtk::Widget,
callback: FN,
) -> Result<Response, AuthError>
where
Response: Send + 'static,
Fut: Future<Output = Result<Response, Error>> + Send + 'static,
FN: Fn(AuthData) -> Fut + Send + Sync + 'static + Clone,
{
loop {
let callback = callback.clone();
let auth_data = self.perform_next_stage(&uiaa_info, parent).await?;
// Get the current state of the authentication.
let handle = spawn_tokio!(async move { callback(auth_data).await });
let result = self.await_tokio_task(handle).await;
// If this is a UIAA error, authentication continues.
let Some(next_uiaa_info) = result.uiaa_info() else {
return result;
};
uiaa_info = next_uiaa_info.clone();
}
}
/// Perform the preferred next stage in the given UIAA info.
///
/// Stages that are actually supported are preferred. If no stages are
/// supported, we use the web-based fallback.
///
/// When this function returns, the next stage is ready to be performed.
async fn perform_next_stage(
&self,
uiaa_info: &UiaaInfo,
parent: &gtk::Widget,
) -> Result<AuthData, AuthError> {
let Some(next_state) = AuthState::next(uiaa_info) else {
// There is no stage left, this should not happen.
error!("Cannot perform next stage when flow is complete");
return Err(AuthError::Unknown);
};
if matches!(next_state.stage, AuthType::Dummy) {
// We can just do this stage without waiting for user input.
self.state.replace(Some(next_state));
return self.current_stage_auth_data();
}
let (sender, receiver) = futures_channel::oneshot::channel();
self.sender.replace(Some(sender));
// If the stage didn't succeed, we get the same state again.
let is_same_state = self
.state
.borrow()
.as_ref()
.is_some_and(|state| *state == next_state);
if is_same_state {
self.retry_current_stage(&next_state.stage, uiaa_info);
} else {
let (next_page, default_widget) = self.page(&next_state).await?;
self.show_page(next_page, &default_widget, parent);
self.state.replace(Some(next_state));
}
if receiver.await.is_err() {
// The sender was dropped, which means that the user closed the dialog.
return Err(AuthError::UserCancelled);
}
self.sender.take();
self.current_stage_auth_data()
}
// Retry the current stage.
fn retry_current_stage(&self, stage: &AuthType, uiaa_info: &UiaaInfo) {
// Show the authentication error, if there is one.
if let Some(error) = &uiaa_info.auth_error {
warn!("Could not perform authentication stage: {}", error.message);
if matches!(stage, AuthType::Password) {
toast!(self.stack, gettext("The password is invalid."));
} else {
toast!(self.stack, gettext("An unexpected error occurred."));
}
}
// Reset the loading state of the page.
if let Some(page) = self.current_page.borrow().as_ref() {
if let Some(password_page) = page.downcast_ref::<AuthDialogPasswordPage>() {
password_page.retry();
} else if let Some(in_browser_page) = page.downcast_ref::<AuthDialogInBrowserPage>()
{
in_browser_page.retry();
}
}
}
/// Show the given page.
fn show_page(&self, page: gtk::Widget, default_widget: &gtk::Widget, parent: &gtk::Widget) {
self.stack.add_child(&page);
self.stack.set_visible_child(&page);
self.obj().set_default_widget(Some(default_widget));
let prev_page = self.current_page.replace(Some(page));
// Remove the previous page from the stack when the transition is over.
if let Some(page) = prev_page {
let cell = Rc::new(RefCell::new(None));
let handler = self.stack.connect_transition_running_notify(clone!(
#[strong]
cell,
#[strong]
page,
move |stack| {
if !stack.is_transition_running()
&& stack.visible_child().is_some_and(|child| child != page)
{
stack.remove(&page);
if let Some(handler) = cell.take() {
stack.disconnect(handler);
}
}
}
));
cell.replace(Some(handler));
}
// Present the dialog if it is not already the case.
if !self.is_presented.get() {
self.obj().present(Some(parent));
self.is_presented.set(true);
}
}
/// Get the page for the given state.
///
/// Returns a `(page, default_widget)` tuple.
async fn page(&self, state: &AuthState) -> Result<(gtk::Widget, gtk::Widget), AuthError> {
if state.stage == AuthType::Password {
let page = AuthDialogPasswordPage::new();
let default_widget = page.default_widget().clone();
Ok((page.upcast(), default_widget))
} else {
let fallback_url = self.fallback_url(state).await?;
let page = AuthDialogInBrowserPage::new(fallback_url);
let default_widget = page.default_widget().clone();
Ok((page.upcast(), default_widget))
}
}
/// Get the fallback URL for the given state.
async fn fallback_url(&self, state: &AuthState) -> Result<String, AuthError> {
let Some(session) = self.session.upgrade() else {
return Err(AuthError::Unknown);
};
let uiaa_session = state.session.clone().ok_or(AuthError::MissingSessionId)?;
let request =
get_uiaa_fallback_page::v3::Request::new(state.stage.to_string(), uiaa_session);
let client = session.client();
let homeserver = client.homeserver();
let handle =
spawn_tokio!(async move { client.server_versions().await.map_err(Into::into) });
let result = self.await_tokio_task(handle).await;
let server_versions = match result {
Ok(server_versions) => server_versions,
Err(AuthError::ServerResponse(server_error)) => {
warn!("Could not get Matrix versions supported by homeserver: {server_error}");
// Default to the v3 endpoint.
Box::new([MatrixVersion::V1_1])
}
Err(error) => {
return Err(error);
}
};
let http_request = match request.try_into_http_request::<Vec<u8>>(
homeserver.as_ref(),
SendAccessToken::None,
&server_versions,
) {
Ok(http_request) => http_request,
Err(error) => {
error!("Could not construct fallback UIAA URL: {error}");
return Err(AuthError::Unknown);
}
};
Ok(http_request.uri().to_string())
}
/// Get the authentication data for the current stage.
fn current_stage_auth_data(&self) -> Result<AuthData, AuthError> {
let Some(state) = self.state.borrow().clone() else {
error!("Could not get current authentication state");
return Err(AuthError::Unknown);
};
let auth_data = match state.stage {
AuthType::Password => {
let password = self
.current_page
.borrow()
.as_ref()
.and_then(|page| page.downcast_ref::<AuthDialogPasswordPage>())
.ok_or_else(|| {
error!(
"Could not get password because current page is not password page"
);
AuthError::Unknown
})?
.password();
let user_id = self
.session
.upgrade()
.ok_or(AuthError::Unknown)?
.user_id()
.to_string();
AuthData::Password(assign!(
Password::new(UserIdentifier::UserIdOrLocalpart(user_id), password),
{ session: state.session }
))
}
AuthType::Dummy => AuthData::Dummy(assign!(Dummy::new(), {
session: state.session
})),
_ => {
let uiaa_session = state.session.ok_or(AuthError::MissingSessionId)?;
AuthData::FallbackAcknowledgement(FallbackAcknowledgement::new(uiaa_session))
}
};
Ok(auth_data)
}
// Close the dialog and cancel any ongoing task.
fn close(&self) {
if self.is_presented.get() {
self.obj().close();
}
if let Some(abort_handle) = self.abort_handle.take() {
abort_handle.abort();
}
self.sender.take();
}
}
}
glib::wrapper! {
/// Dialog to guide the user through the [User-Interactive Authentication API] (UIAA).
///
/// [User-Interactive Authentication API]: https://spec.matrix.org/latest/client-server-api/#user-interactive-authentication-api
pub struct AuthDialog(ObjectSubclass<imp::AuthDialog>)
@extends gtk::Widget, adw::Dialog, ToastableDialog,
@implements gtk::Accessible;
}
impl AuthDialog {
pub fn new(session: &Session) -> Self {
glib::Object::builder().property("session", session).build()
}
/// Authenticate the user to the server via an interactive authentication
/// flow.
///
/// The type of flow and the required stages are negotiated during the
/// authentication. Returns the last server response on success.
pub(crate) async fn authenticate<Response, Fut, FN>(
&self,
parent: &impl IsA<gtk::Widget>,
callback: FN,
) -> Result<Response, AuthError>
where
Response: Send + 'static,
Fut: Future<Output = Result<Response, Error>> + Send + 'static,
FN: Fn(matrix_sdk::Client, Option<AuthData>) -> Fut + Send + Sync + 'static + Clone,
{
self.imp().authenticate(parent.upcast_ref(), callback).await
}
/// Reset the cross-signing keys while handling the interactive
/// authentication flow.
///
/// The type of flow and the required stages are negotiated during the
/// authentication. Returns the last server response on success.
pub(crate) async fn reset_cross_signing(
&self,
parent: &impl IsA<gtk::Widget>,
) -> Result<(), AuthError> {
self.imp().reset_cross_signing(parent.upcast_ref()).await
}
}
/// Data about the current authentication state.
#[derive(Debug, Clone, PartialEq, Eq)]
struct AuthState {
/// The completed stages.
completed: Vec<AuthType>,
/// The current stage.
stage: AuthType,
/// The ID of the authentication session.
session: Option<String>,
}
impl AuthState {
/// Try to construct the next `AuthState` from the given UIAA info.
///
/// Returns `None` if the next stage could not be determined.
fn next(uiaa_info: &UiaaInfo) -> Option<Self> {
// Find the possible next stages.
// These are the next stage in flows that have the same stages as the ones we
// have completed.
let stages = uiaa_info
.flows
.iter()
.filter_map(|flow| flow.stages.strip_prefix(uiaa_info.completed.as_slice()))
.filter_map(|stages_left| stages_left.first());
// Now get the first stage that we support.
let mut next_stage = None;
for stage in stages {
if matches!(stage, AuthType::Password | AuthType::Sso | AuthType::Dummy) {
// We found a supported stage.
next_stage = Some(stage);
break;
} else if next_stage.is_none() {
// We will default to the first stage if we do not find one that we support.
next_stage = Some(stage);
}
}
let stage = next_stage?.clone();
Some(Self {
completed: uiaa_info.completed.clone(),
stage,
session: uiaa_info.session.clone(),
})
}
}
/// An error during UIAA interaction.
#[derive(Debug, Error)]
pub enum AuthError {
/// The server returned a non-UIAA error.
#[error(transparent)]
ServerResponse(#[from] Error),
/// The ID of the UIAA session is missing for a stage that requires it.
#[error("The ID of the session is missing")]
MissingSessionId,
/// The user cancelled the authentication.
#[error("The user cancelled the authentication")]
UserCancelled,
/// An unexpected error occurred.
#[error("An unexpected error occurred")]
Unknown,
}
/// Helper trait to extract [`UiaaInfo`].
trait ExtractUiaa {
/// Extract the [`UiaaInfo`] from this type, if it contains one.
fn uiaa_info(&self) -> Option<&UiaaInfo>;
}
impl ExtractUiaa for AuthError {
fn uiaa_info(&self) -> Option<&UiaaInfo> {
if let Self::ServerResponse(server_error) = self {
server_error.as_uiaa_response()
} else {
None
}
}
}
impl ExtractUiaa for Error {
fn uiaa_info(&self) -> Option<&UiaaInfo> {
self.as_uiaa_response()
}
}
impl<T, Err> ExtractUiaa for Result<T, Err>
where
Err: ExtractUiaa,
{
fn uiaa_info(&self) -> Option<&UiaaInfo> {
match self {
Ok(_) => None,
Err(error) => error.uiaa_info(),
}
}
}

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<template class="AuthDialog" parent="ToastableDialog">
<property name="title" translatable="yes">Authentication</property>
<property name="follows-content-size">True</property>
<property name="presentation-mode">floating</property>
<style>
<class name="alert"/>
</style>
<property name="child-content">
<object class="GtkStack" id="stack">
<property name="transition-type">slide-left</property>
</object>
</property>
</template>
</interface>

View File

@ -0,0 +1,99 @@
use std::fmt::Debug;
use adw::{prelude::*, subclass::prelude::*};
use gtk::{glib, CompositeTemplate};
use crate::components::LoadingButton;
mod imp {
use glib::subclass::InitializingObject;
use super::*;
#[derive(Debug, Default, CompositeTemplate)]
#[template(resource = "/org/gnome/Fractal/ui/components/dialogs/auth/password_page.ui")]
pub struct AuthDialogPasswordPage {
#[template_child]
pub(super) password: TemplateChild<gtk::PasswordEntry>,
#[template_child]
pub(super) confirm_button: TemplateChild<LoadingButton>,
}
#[glib::object_subclass]
impl ObjectSubclass for AuthDialogPasswordPage {
const NAME: &'static str = "AuthDialogPasswordPage";
type Type = super::AuthDialogPasswordPage;
type ParentType = adw::Bin;
fn class_init(klass: &mut Self::Class) {
Self::bind_template(klass);
Self::bind_template_callbacks(klass);
}
fn instance_init(obj: &InitializingObject<Self>) {
obj.init_template();
}
}
impl ObjectImpl for AuthDialogPasswordPage {}
impl WidgetImpl for AuthDialogPasswordPage {}
impl BinImpl for AuthDialogPasswordPage {}
#[gtk::template_callbacks]
impl AuthDialogPasswordPage {
/// Whether the user can proceed given the current state.
fn can_proceed(&self) -> bool {
!self.password.text().is_empty()
}
/// Update the confirm button for the current state.
#[template_callback]
fn update_confirm(&self) {
self.confirm_button.set_sensitive(self.can_proceed());
}
/// Proceed to authentication with the current password.
#[template_callback]
fn proceed(&self) {
if !self.can_proceed() {
return;
}
self.confirm_button.set_is_loading(true);
let _ = self.obj().activate_action("auth-dialog.continue", None);
}
/// Retry this stage.
pub(super) fn retry(&self) {
self.confirm_button.set_is_loading(false);
self.update_confirm();
}
}
}
glib::wrapper! {
/// Page to pass the password stage for the [`AuthDialog`].
pub struct AuthDialogPasswordPage(ObjectSubclass<imp::AuthDialogPasswordPage>)
@extends gtk::Widget, adw::Bin, @implements gtk::Accessible;
}
impl AuthDialogPasswordPage {
pub fn new() -> Self {
glib::Object::new()
}
/// Get the default widget of this page.
pub fn default_widget(&self) -> &gtk::Widget {
self.imp().confirm_button.upcast_ref()
}
/// Get the current password in the entry.
pub fn password(&self) -> String {
self.imp().password.text().into()
}
/// Retry this stage.
pub fn retry(&self) {
self.imp().retry();
}
}

View File

@ -0,0 +1,105 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<template class="AuthDialogPasswordPage" parent="AdwBin">
<property name="child">
<object class="GtkWindowHandle">
<property name="child">
<object class="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="GtkScrolledWindow">
<property name="propagate-natural-width">True</property>
<property name="propagate-natural-height">True</property>
<property name="hscrollbar-policy">never</property>
<style>
<class name="body-scrolled-window"/>
<class name="undershoot-bottom"/>
</style>
<property name="child">
<object class="GtkBox">
<property name="orientation">vertical</property>
<style>
<class name="message-area"/>
<class name="has-heading"/>
<class name="has-body"/>
</style>
<child>
<object class="AdwBin">
<style>
<class name="heading-bin"/>
</style>
<child>
<object class="GtkLabel">
<property name="justify">center</property>
<property name="xalign">0.5</property>
<property name="label" translatable="yes">Authentication</property>
<style>
<class name="title-2"/>
</style>
</object>
</child>
</object>
</child>
<child>
<object class="GtkLabel">
<property name="vexpand">True</property>
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="justify">center</property>
<property name="xalign">0.5</property>
<property name="label" translatable="yes">Please authenticate the operation with your password</property>
<style>
<class name="body"/>
</style>
</object>
</child>
<child>
<object class="GtkPasswordEntry" id="password">
<property name="margin-top">6</property>
<property name="margin-start">24</property>
<property name="margin-end">24</property>
<property name="activates-default">True</property>
<property name="show-peek-icon">True</property>
<signal name="changed" handler="update_confirm" swapped="yes" />
</object>
</child>
</object>
</property>
</object>
</child>
<child>
<object class="AdwWrapBox">
<property name="child-spacing">12</property>
<property name="line-spacing">12</property>
<property name="justify">fill</property>
<property name="justify-last-line">True</property>
<style>
<class name="response-area"/>
</style>
<child>
<object class="GtkButton">
<property name="can-shrink">True</property>
<property name="use-underline">True</property>
<property name="label" translatable="yes">_Cancel</property>
<property name="action-name">auth-dialog.close</property>
</object>
</child>
<child>
<object class="LoadingButton" id="confirm_button">
<property name="sensitive">False</property>
<property name="can-shrink">True</property>
<property name="content-label" translatable="yes">C_onfirm</property>
<signal name="clicked" handler="proceed" swapped="yes"/>
<style>
<class name="suggested-action"/>
</style>
</object>
</child>
</object>
</child>
</object>
</property>
</object>
</property>
</template>
</interface>

View File

@ -1,13 +1,13 @@
mod auth; mod auth;
mod join_room;
mod message_dialogs; mod message_dialogs;
mod room_preview;
mod toastable; mod toastable;
mod user_profile; mod user_profile;
pub(crate) use self::{ pub(crate) use self::{
auth::{AuthDialog, AuthError}, auth::{AuthDialog, AuthError},
join_room::JoinRoomDialog,
message_dialogs::*, message_dialogs::*,
room_preview::RoomPreviewDialog,
toastable::{ToastableDialog, ToastableDialogExt, ToastableDialogImpl}, toastable::{ToastableDialog, ToastableDialogExt, ToastableDialogImpl},
user_profile::UserProfileDialog, user_profile::UserProfileDialog,
}; };

View File

@ -24,9 +24,9 @@ mod imp {
use super::*; use super::*;
#[derive(Debug, Default, CompositeTemplate, glib::Properties)] #[derive(Debug, Default, CompositeTemplate, glib::Properties)]
#[template(resource = "/org/gnome/Fractal/ui/components/dialogs/join_room.ui")] #[template(resource = "/org/gnome/Fractal/ui/components/dialogs/room_preview.ui")]
#[properties(wrapper_type = super::JoinRoomDialog)] #[properties(wrapper_type = super::RoomPreviewDialog)]
pub struct JoinRoomDialog { pub struct RoomPreviewDialog {
#[template_child] #[template_child]
go_back_btn: TemplateChild<gtk::Button>, go_back_btn: TemplateChild<gtk::Button>,
#[template_child] #[template_child]
@ -50,7 +50,7 @@ mod imp {
#[template_child] #[template_child]
room_members_count: TemplateChild<gtk::Label>, room_members_count: TemplateChild<gtk::Label>,
#[template_child] #[template_child]
join_btn: TemplateChild<LoadingButton>, view_or_join_btn: TemplateChild<LoadingButton>,
/// The current session. /// The current session.
#[property(get, set = Self::set_session, construct_only)] #[property(get, set = Self::set_session, construct_only)]
session: glib::WeakRef<Session>, session: glib::WeakRef<Session>,
@ -61,12 +61,14 @@ mod imp {
room: RefCell<Option<RemoteRoom>>, room: RefCell<Option<RemoteRoom>>,
/// Whether the "Go back" button is disabled. /// Whether the "Go back" button is disabled.
disable_go_back: Cell<bool>, disable_go_back: Cell<bool>,
room_loading_handler: RefCell<Option<glib::SignalHandlerId>>,
room_list_info_handlers: RefCell<Vec<glib::SignalHandlerId>>,
} }
#[glib::object_subclass] #[glib::object_subclass]
impl ObjectSubclass for JoinRoomDialog { impl ObjectSubclass for RoomPreviewDialog {
const NAME: &'static str = "JoinRoomDialog"; const NAME: &'static str = "RoomPreviewDialog";
type Type = super::JoinRoomDialog; type Type = super::RoomPreviewDialog;
type ParentType = ToastableDialog; type ParentType = ToastableDialog;
fn class_init(klass: &mut Self::Class) { fn class_init(klass: &mut Self::Class) {
@ -80,7 +82,7 @@ mod imp {
} }
#[glib::derived_properties] #[glib::derived_properties]
impl ObjectImpl for JoinRoomDialog { impl ObjectImpl for RoomPreviewDialog {
fn constructed(&self) { fn constructed(&self) {
self.parent_constructed(); self.parent_constructed();
let obj = self.obj(); let obj = self.obj();
@ -105,14 +107,18 @@ mod imp {
} }
)); ));
} }
fn dispose(&self) {
self.disconnect_signals();
}
} }
impl WidgetImpl for JoinRoomDialog {} impl WidgetImpl for RoomPreviewDialog {}
impl AdwDialogImpl for JoinRoomDialog {} impl AdwDialogImpl for RoomPreviewDialog {}
impl ToastableDialogImpl for JoinRoomDialog {} impl ToastableDialogImpl for RoomPreviewDialog {}
#[gtk::template_callbacks] #[gtk::template_callbacks]
impl JoinRoomDialog { impl RoomPreviewDialog {
/// Set the current session. /// Set the current session.
fn set_session(&self, session: Option<&Session>) { fn set_session(&self, session: Option<&Session>) {
self.session.set(session); self.session.set(session);
@ -131,39 +137,64 @@ mod imp {
} }
/// Set the room that is previewed. /// Set the room that is previewed.
pub(super) fn set_room(&self, room: Option<RemoteRoom>) { pub(super) fn set_room(&self, room: &RemoteRoom) {
if *self.room.borrow() == room { if self.room.borrow().as_ref().is_some_and(|r| r == room) {
return; return;
} }
self.room.replace(room.clone()); self.disconnect_signals();
if let Some(room) = room { let room_list_info = room.room_list_info();
if matches!( let is_joining_handler = room_list_info.connect_is_joining_notify(clone!(
room.loading_state(), #[weak(rename_to = imp)]
LoadingState::Ready | LoadingState::Error self,
) { move |_| {
self.fill_details(); imp.update_view_or_join_button();
} else {
room.connect_loading_state_notify(clone!(
#[weak(rename_to = imp)]
self,
move |room| {
if matches!(
room.loading_state(),
LoadingState::Ready | LoadingState::Error
) {
imp.fill_details();
}
}
));
} }
));
let local_room_handler = room_list_info.connect_local_room_notify(clone!(
#[weak(rename_to = imp)]
self,
move |_| {
imp.update_view_or_join_button();
}
));
self.room_list_info_handlers
.replace(vec![is_joining_handler, local_room_handler]);
self.room.replace(Some(room.clone()));
if matches!(
room.loading_state(),
LoadingState::Ready | LoadingState::Error
) {
self.fill_details();
} else {
let room_loading_handler = room.connect_loading_state_notify(clone!(
#[weak(rename_to = imp)]
self,
move |room| {
if matches!(
room.loading_state(),
LoadingState::Ready | LoadingState::Error
) {
if let Some(handler) = imp.room_loading_handler.take() {
room.disconnect(handler);
}
imp.fill_details();
}
}
));
self.room_loading_handler
.replace(Some(room_loading_handler));
} }
self.update_view_or_join_button();
self.obj().notify_room(); self.obj().notify_room();
} }
/// Se whether to disable the "Go back" button. /// Set whether to disable the "Go back" button.
pub(super) fn disable_go_back(&self, disable: bool) { pub(super) fn disable_go_back(&self, disable: bool) {
self.disable_go_back.set(disable); self.disable_go_back.set(disable);
} }
@ -199,7 +230,11 @@ mod imp {
let id = uri.id.clone(); let id = uri.id.clone();
self.uri.replace(Some(uri)); self.uri.replace(Some(uri));
if session.room_list().joined_room(&id).is_some() { if session
.room_list()
.get_by_identifier(&id)
.is_some_and(|room| room.is_joined())
{
// Translators: This is a verb, as in 'View Room'. // Translators: This is a verb, as in 'View Room'.
self.look_up_btn.set_content_label(gettext("View")); self.look_up_btn.set_content_label(gettext("View"));
} else { } else {
@ -240,10 +275,9 @@ mod imp {
// Reset state before switching to possible pages. // Reset state before switching to possible pages.
self.go_back_btn.set_sensitive(true); self.go_back_btn.set_sensitive(true);
self.join_btn.set_is_loading(false);
let room = RemoteRoom::new(&session, uri); let room = session.remote_cache().room(uri);
self.set_room(Some(room)); self.set_room(&room);
} }
/// Fill the details with the given result. /// Fill the details with the given result.
@ -254,7 +288,7 @@ mod imp {
self.room_name.set_label(&room.display_name()); self.room_name.set_label(&room.display_name());
let alias = room.alias(); let alias = room.canonical_alias();
if let Some(alias) = &alias { if let Some(alias) = &alias {
self.room_alias.set_label(alias.as_str()); self.room_alias.set_label(alias.as_str());
} }
@ -300,18 +334,50 @@ mod imp {
self.set_visible_page("details"); self.set_visible_page("details");
} }
/// Join the room that was entered, if it is valid. /// Update the button for viewing or joining the previewed room given
#[template_callback] /// the current state.
async fn join_room(&self) { fn update_view_or_join_button(&self) {
let Some(session) = self.session.upgrade() else { let Some(room) = self.room.borrow().clone() else {
return; return;
}; };
let room_list_info = room.room_list_info();
let label = if room_list_info.local_room().is_some() {
gettext("View")
} else {
gettext("Join")
};
self.view_or_join_btn.set_content_label(label);
self.view_or_join_btn
.set_is_loading(room_list_info.is_joining());
}
/// View or join the room that was previewed.
#[template_callback]
async fn view_or_join_room(&self) {
let Some(room) = self.room.borrow().clone() else { let Some(room) = self.room.borrow().clone() else {
return; return;
}; };
if let Some(local_room) = room.room_list_info().local_room() {
let obj = self.obj();
if let Some(window) = obj.root().and_downcast_ref::<Window>() {
window.session_view().select_room(local_room);
obj.close();
}
} else {
self.join_room(&room).await;
}
}
/// Join the room that was previewed, if it is valid.
async fn join_room(&self, room: &RemoteRoom) {
let Some(session) = self.session.upgrade() else {
return;
};
self.go_back_btn.set_sensitive(false); self.go_back_btn.set_sensitive(false);
self.join_btn.set_is_loading(true);
// Join the room with the given identifier. // Join the room with the given identifier.
let room_list = session.room_list(); let room_list = session.room_list();
@ -321,19 +387,17 @@ mod imp {
Ok(room_id) => { Ok(room_id) => {
let obj = self.obj(); let obj = self.obj();
if let Some(room) = room_list.get_wait(&room_id).await { if let Some(local_room) = room_list.get_wait(&room_id, None).await {
if let Some(window) = obj.root().and_downcast_ref::<Window>() { if let Some(window) = obj.root().and_downcast_ref::<Window>() {
window.session_view().select_room(Some(room)); window.session_view().select_room(local_room);
} }
} }
obj.close(); obj.close();
} }
Err(error) => { Err(error) => {
let obj = self.obj(); toast!(self.obj(), error);
toast!(obj, error);
self.join_btn.set_is_loading(false);
self.go_back_btn.set_sensitive(true); self.go_back_btn.set_sensitive(true);
} }
} }
@ -353,17 +417,32 @@ mod imp {
self.obj().close(); self.obj().close();
} }
} }
/// Disconnect the signal handlers of this dialog.
fn disconnect_signals(&self) {
if let Some(room) = self.room.borrow().as_ref() {
if let Some(handler) = self.room_loading_handler.take() {
room.disconnect(handler);
}
let room_list_info = room.room_list_info();
for handler in self.room_list_info_handlers.take() {
room_list_info.disconnect(handler);
}
}
}
} }
} }
glib::wrapper! { glib::wrapper! {
/// Dialog to join a room. /// Dialog to preview a room and eventually join it.
pub struct JoinRoomDialog(ObjectSubclass<imp::JoinRoomDialog>) pub struct RoomPreviewDialog(ObjectSubclass<imp::RoomPreviewDialog>)
@extends gtk::Widget, adw::Dialog, ToastableDialog, @implements gtk::Accessible; @extends gtk::Widget, adw::Dialog, ToastableDialog,
@implements gtk::Accessible;
} }
#[gtk::template_callbacks] #[gtk::template_callbacks]
impl JoinRoomDialog { impl RoomPreviewDialog {
pub fn new(session: &Session) -> Self { pub fn new(session: &Session) -> Self {
glib::Object::builder().property("session", session).build() glib::Object::builder().property("session", session).build()
} }
@ -374,9 +453,9 @@ impl JoinRoomDialog {
} }
/// Set the room to preview. /// Set the room to preview.
pub(crate) fn set_room(&self, room: RemoteRoom) { pub(crate) fn set_room(&self, room: &RemoteRoom) {
let imp = self.imp(); let imp = self.imp();
imp.disable_go_back(true); imp.disable_go_back(true);
imp.set_room(Some(room)); imp.set_room(room);
} }
} }

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<interface> <interface>
<template class="JoinRoomDialog" parent="ToastableDialog"> <template class="RoomPreviewDialog" parent="ToastableDialog">
<property name="title" translatable="yes">Join a Room</property> <property name="title" translatable="yes">Join a Room</property>
<property name="content-width">480</property> <property name="content-width">480</property>
<property name="content-height">500</property> <property name="content-height">500</property>
@ -150,7 +150,7 @@
<property name="wrap-mode">word-char</property> <property name="wrap-mode">word-char</property>
<property name="justify">center</property> <property name="justify">center</property>
<style> <style>
<class name="dim-label"/> <class name="dimmed"/>
</style> </style>
</object> </object>
</child> </child>
@ -172,25 +172,24 @@
<property name="icon-name">users-symbolic</property> <property name="icon-name">users-symbolic</property>
<property name="accessible-role">presentation</property> <property name="accessible-role">presentation</property>
<style> <style>
<class name="dim-label"/> <class name="dimmed"/>
</style> </style>
</object> </object>
</child> </child>
<child> <child>
<object class="GtkLabel" id="room_members_count"> <object class="GtkLabel" id="room_members_count">
<style> <style>
<class name="dim-label"/> <class name="dimmed"/>
</style> </style>
</object> </object>
</child> </child>
</object> </object>
</child> </child>
<child> <child>
<object class="LoadingButton" id="join_btn"> <object class="LoadingButton" id="view_or_join_btn">
<property name="content-label" translatable="yes">Join</property>
<property name="margin-top">12</property> <property name="margin-top">12</property>
<property name="halign">center</property> <property name="halign">center</property>
<signal name="clicked" handler="join_room" swapped="yes"/> <signal name="clicked" handler="view_or_join_room" swapped="yes"/>
<style> <style>
<class name="suggested-action"/> <class name="suggested-action"/>
<class name="standalone-button"/> <class name="standalone-button"/>

View File

@ -6,11 +6,13 @@ use super::ToastableDialog;
use crate::{ use crate::{
components::UserPage, components::UserPage,
prelude::*, prelude::*,
session::model::{Member, RemoteUser, Session, User}, session::model::{Member, Session, User},
spawn, utils::LoadingState,
}; };
mod imp { mod imp {
use std::cell::RefCell;
use glib::subclass::InitializingObject; use glib::subclass::InitializingObject;
use super::*; use super::*;
@ -22,6 +24,7 @@ mod imp {
stack: TemplateChild<gtk::Stack>, stack: TemplateChild<gtk::Stack>,
#[template_child] #[template_child]
user_page: TemplateChild<UserPage>, user_page: TemplateChild<UserPage>,
user_loading_handler: RefCell<Option<glib::SignalHandlerId>>,
} }
#[glib::object_subclass] #[glib::object_subclass]
@ -39,31 +42,70 @@ mod imp {
} }
} }
impl ObjectImpl for UserProfileDialog {} impl ObjectImpl for UserProfileDialog {
fn dispose(&self) {
self.reset();
}
}
impl WidgetImpl for UserProfileDialog {} impl WidgetImpl for UserProfileDialog {}
impl AdwDialogImpl for UserProfileDialog {} impl AdwDialogImpl for UserProfileDialog {}
impl ToastableDialogImpl for UserProfileDialog {} impl ToastableDialogImpl for UserProfileDialog {}
impl UserProfileDialog { impl UserProfileDialog {
/// Show the details page.
fn show_details(&self) {
self.stack.set_visible_child_name("details");
}
/// Load the user with the given session and user ID. /// Load the user with the given session and user ID.
pub(super) fn load_user(&self, session: &Session, user_id: OwnedUserId) { pub(super) fn load_user(&self, session: &Session, user_id: OwnedUserId) {
let user = RemoteUser::new(session, user_id); self.reset();
let user = session.remote_cache().user(user_id);
self.user_page.set_user(Some(user.clone())); self.user_page.set_user(Some(user.clone()));
spawn!(clone!( if matches!(
#[weak(rename_to = imp)] user.loading_state(),
self, LoadingState::Initial | LoadingState::Loading
async move { ) {
user.load_profile().await; let user_loading_handler = user.connect_loading_state_notify(clone!(
imp.stack.set_visible_child_name("details"); #[weak(rename_to = imp)]
} self,
)); move |user| {
if !matches!(
user.loading_state(),
LoadingState::Initial | LoadingState::Loading
) {
if let Some(handler) = imp.user_loading_handler.take() {
user.disconnect(handler);
imp.show_details();
}
}
}
));
self.user_loading_handler
.replace(Some(user_loading_handler));
} else {
self.show_details();
}
} }
/// Set the member to present. /// Set the member to present.
pub(super) fn set_room_member(&self, member: Member) { pub(super) fn set_room_member(&self, member: Member) {
self.reset();
self.user_page.set_user(Some(member.upcast::<User>())); self.user_page.set_user(Some(member.upcast::<User>()));
self.stack.set_visible_child_name("details"); self.show_details();
}
/// Reset this dialog.
fn reset(&self) {
if let Some(handler) = self.user_loading_handler.take() {
if let Some(user) = self.user_page.user() {
user.disconnect(handler);
}
}
} }
} }
} }

View File

@ -338,3 +338,9 @@ impl LabelWithWidgets {
self.imp().set_label_and_widgets(label, widgets); self.imp().set_label_and_widgets(label, widgets);
} }
} }
impl Default for LabelWithWidgets {
fn default() -> Self {
Self::new()
}
}

View File

@ -1,6 +1,8 @@
use adw::prelude::*; use adw::prelude::*;
use gtk::{glib, subclass::prelude::*, CompositeTemplate}; use gtk::{glib, subclass::prelude::*, CompositeTemplate};
use crate::utils::ChildPropertyExt;
mod imp { mod imp {
use std::marker::PhantomData; use std::marker::PhantomData;
@ -118,3 +120,13 @@ impl Default for LoadingBin {
Self::new() Self::new()
} }
} }
impl ChildPropertyExt for LoadingBin {
fn child_property(&self) -> Option<gtk::Widget> {
self.child()
}
fn set_child_property(&self, child: Option<&impl IsA<gtk::Widget>>) {
self.set_child(child.map(Cast::upcast_ref));
}
}

View File

@ -1,7 +1,8 @@
use adw::subclass::prelude::*; use adw::{prelude::*, subclass::prelude::*};
use gtk::{glib, pango, prelude::*}; use gtk::{glib, pango};
use super::LoadingBin; use super::LoadingBin;
use crate::prelude::*;
mod imp { mod imp {
use std::marker::PhantomData; use std::marker::PhantomData;
@ -65,26 +66,22 @@ mod imp {
} }
let obj = self.obj(); let obj = self.obj();
let child_label = let child_label = self.loading_bin.child_or_else::<gtk::Label>(|| {
if let Some(child_label) = self.loading_bin.child().and_downcast::<gtk::Label>() { let child_label = gtk::Label::builder()
child_label .ellipsize(pango::EllipsizeMode::End)
} else { .use_underline(true)
let child_label = gtk::Label::builder() .mnemonic_widget(&*obj)
.ellipsize(pango::EllipsizeMode::End) .css_classes(["text-button"])
.use_underline(true) .build();
.mnemonic_widget(&*obj)
.css_classes(["text-button"])
.build();
self.loading_bin.set_child(Some(child_label.clone())); // In case it was an image before.
// In case it was an image before. obj.remove_css_class("image-button");
obj.remove_css_class("image-button"); obj.update_relation(&[gtk::accessible::Relation::LabelledBy(&[
obj.update_relation(&[gtk::accessible::Relation::LabelledBy(&[ child_label.upcast_ref()
child_label.upcast_ref() ])]);
])]);
child_label child_label
}; });
child_label.set_label(label); child_label.set_label(label);
@ -106,19 +103,13 @@ mod imp {
} }
let obj = self.obj(); let obj = self.obj();
let child_image = let child_image = self.loading_bin.child_or_else::<gtk::Image>(|| {
if let Some(child_image) = self.loading_bin.child().and_downcast::<gtk::Image>() { obj.add_css_class("image-button");
child_image
} else {
let child_image = gtk::Image::builder()
.accessible_role(gtk::AccessibleRole::Presentation)
.build();
self.loading_bin.set_child(Some(child_image.clone())); gtk::Image::builder()
obj.add_css_class("image-button"); .accessible_role(gtk::AccessibleRole::Presentation)
.build()
child_image });
};
child_image.set_icon_name(Some(icon_name)); child_image.set_icon_name(Some(icon_name));

View File

@ -230,14 +230,7 @@ mod imp {
/// View the given location as a geo URI. /// View the given location as a geo URI.
pub(super) fn view_location(&self, geo_uri: &GeoUri) { pub(super) fn view_location(&self, geo_uri: &GeoUri) {
let location = let location = self.viewer.child_or_default::<LocationViewer>();
if let Some(location) = self.viewer.child().and_downcast::<LocationViewer>() {
location
} else {
let location = LocationViewer::new();
self.viewer.set_child(Some(&location));
location
};
location.set_location(geo_uri); location.set_location(geo_uri);
self.update_animated_paintable_state(); self.update_animated_paintable_state();

View File

@ -148,3 +148,9 @@ impl LocationViewer {
self.imp().set_location(geo_uri); self.imp().set_location(geo_uri);
} }
} }
impl Default for LocationViewer {
fn default() -> Self {
Self::new()
}
}

View File

@ -32,6 +32,8 @@ mod imp {
state: Cell<LoadingState>, state: Cell<LoadingState>,
/// The current error, if any. /// The current error, if any.
pub(super) error: RefCell<Option<glib::Error>>, pub(super) error: RefCell<Option<glib::Error>>,
/// The duration of the video, if it is known.
duration: Cell<Option<gst::ClockTime>>,
bus_guard: OnceCell<gst::bus::BusWatchGuard>, bus_guard: OnceCell<gst::bus::BusWatchGuard>,
} }
@ -110,6 +112,8 @@ mod imp {
} }
self.compact.set(compact); self.compact.set(compact);
self.update_timestamp();
self.obj().notify_compact(); self.obj().notify_compact();
} }
@ -128,7 +132,7 @@ mod imp {
let uri = file.uri(); let uri = file.uri();
self.file.replace(Some(file)); self.file.replace(Some(file));
self.duration_changed(None); self.set_duration(None);
self.set_state(LoadingState::Loading); self.set_state(LoadingState::Loading);
self.player.set_uri(Some(uri.as_ref())); self.player.set_uri(Some(uri.as_ref()));
@ -155,7 +159,7 @@ mod imp {
} }
} }
gst_play::PlayMessage::DurationChanged { duration } => { gst_play::PlayMessage::DurationChanged { duration } => {
self.duration_changed(duration); self.set_duration(duration);
} }
gst_play::PlayMessage::Warning { error, .. } => { gst_play::PlayMessage::Warning { error, .. } => {
warn!("Warning playing video: {error}"); warn!("Warning playing video: {error}");
@ -169,9 +173,23 @@ mod imp {
} }
} }
/// Handle when the duration changed. /// Set the duration of the video.
fn duration_changed(&self, duration: Option<gst::ClockTime>) { fn set_duration(&self, duration: Option<gst::ClockTime>) {
if let Some(duration) = duration { if self.duration.get() == duration {
return;
}
self.duration.set(duration);
self.update_timestamp();
}
/// Update the timestamp for the current state.
fn update_timestamp(&self) {
// We show the duration if we know it and if we are not in compact mode.
let visible_duration = self.duration.get().filter(|_| !self.compact.get());
let is_visible = visible_duration.is_some();
if let Some(duration) = visible_duration {
let mut time = duration.seconds(); let mut time = duration.seconds();
let sec = time % 60; let sec = time % 60;
@ -193,7 +211,7 @@ mod imp {
self.timestamp.set_label(&label); self.timestamp.set_label(&label);
} }
self.timestamp.set_visible(duration.is_some()); self.timestamp.set_visible(is_visible);
} }
} }
} }

View File

@ -19,7 +19,7 @@
<class name="osd"/> <class name="osd"/>
<class name="timestamp"/> <class name="timestamp"/>
</style> </style>
<property name="visible" bind-source="VideoPlayer" bind-property="compact" bind-flags="sync-create | invert-boolean"/> <property name="visible">false</property>
<property name="halign">start</property> <property name="halign">start</property>
<property name="valign">start</property> <property name="valign">start</property>
<property name="margin-start">5</property> <property name="margin-start">5</property>

View File

@ -1,5 +1,6 @@
mod action_button; mod action_button;
mod avatar; mod avatar;
mod camera;
mod context_menu_bin; mod context_menu_bin;
pub mod crypto; pub mod crypto;
mod custom_entry; mod custom_entry;
@ -19,6 +20,7 @@ mod user_page;
pub(crate) use self::{ pub(crate) use self::{
action_button::{ActionButton, ActionState}, action_button::{ActionButton, ActionState},
avatar::*, avatar::*,
camera::{Camera, CameraExt, QrCodeScanner},
context_menu_bin::{ContextMenuBin, ContextMenuBinExt, ContextMenuBinImpl}, context_menu_bin::{ContextMenuBin, ContextMenuBinExt, ContextMenuBinImpl},
custom_entry::CustomEntry, custom_entry::CustomEntry,
dialogs::*, dialogs::*,

View File

@ -1,18 +1,20 @@
use gettextrs::gettext; use gettextrs::gettext;
use gtk::{glib, subclass::prelude::*}; use gtk::{glib, prelude::*, subclass::prelude::*};
use ruma::OwnedRoomId; use ruma::RoomId;
use crate::{components::PillSource, prelude::*}; use crate::{components::PillSource, prelude::*, session::model::Room};
mod imp { mod imp {
use std::cell::OnceCell; use std::cell::OnceCell;
use super::*; use super::*;
#[derive(Debug, Default)] #[derive(Debug, Default, glib::Properties)]
#[properties(wrapper_type = super::AtRoom)]
pub struct AtRoom { pub struct AtRoom {
/// The ID of the room currently represented. /// The room represented by this mention.
room_id: OnceCell<OwnedRoomId>, #[property(get, set = Self::set_room, construct_only)]
room: OnceCell<Room>,
} }
#[glib::object_subclass] #[glib::object_subclass]
@ -22,6 +24,7 @@ mod imp {
type ParentType = PillSource; type ParentType = PillSource;
} }
#[glib::derived_properties]
impl ObjectImpl for AtRoom {} impl ObjectImpl for AtRoom {}
impl PillSourceImpl for AtRoom { impl PillSourceImpl for AtRoom {
@ -31,14 +34,23 @@ mod imp {
} }
impl AtRoom { impl AtRoom {
/// Set the ID of the room currently represented. /// Set the room represented by this mention.
pub(super) fn set_room_id(&self, room_id: OwnedRoomId) { fn set_room(&self, room: Room) {
self.room_id.set(room_id).expect("room ID is uninitialized"); let room = self.room.get_or_init(|| room);
// Bind the avatar image so it always looks the same.
room.avatar_data()
.bind_property("image", &self.obj().avatar_data(), "image")
.sync_create()
.build();
} }
/// The ID of the room currently represented. /// The ID of the room represented by this mention.
pub(super) fn room_id(&self) -> &OwnedRoomId { pub(super) fn room_id(&self) -> &RoomId {
self.room_id.get().expect("room ID is initialized") self.room
.get()
.expect("room should be initialized")
.room_id()
} }
} }
} }
@ -49,19 +61,16 @@ glib::wrapper! {
} }
impl AtRoom { impl AtRoom {
/// Constructs an empty `@room` mention. /// Constructs an `@room` mention for the given room.
pub fn new(room_id: OwnedRoomId) -> Self { pub fn new(room: &Room) -> Self {
let obj = glib::Object::builder::<Self>() glib::Object::builder()
.property("display-name", "@room") .property("display-name", "@room")
.build(); .property("room", room)
.build()
obj.imp().set_room_id(room_id);
obj
} }
/// The ID of the room currently represented. /// The ID of the room represented by this mention.
pub fn room_id(&self) -> &OwnedRoomId { pub fn room_id(&self) -> &RoomId {
self.imp().room_id() self.imp().room_id()
} }
} }

View File

@ -12,18 +12,21 @@ pub use self::{
source::{PillSource, PillSourceExt, PillSourceImpl}, source::{PillSource, PillSourceExt, PillSourceImpl},
source_row::PillSourceRow, source_row::PillSourceRow,
}; };
use super::{Avatar, JoinRoomDialog, UserProfileDialog}; use super::{Avatar, AvatarImageSafetySetting, RoomPreviewDialog, UserProfileDialog};
use crate::{ use crate::{
prelude::*, prelude::*,
session::{ session::{
model::{Member, RemoteRoom, Room}, model::{Member, RemoteRoom, Room},
view::SessionView, view::SessionView,
}, },
utils::{add_activate_binding_action, BoundObject}, utils::{key_bindings, BoundObject},
}; };
mod imp { mod imp {
use std::cell::{Cell, RefCell}; use std::{
cell::{Cell, RefCell},
marker::PhantomData,
};
use glib::subclass::InitializingObject; use glib::subclass::InitializingObject;
@ -45,6 +48,15 @@ mod imp {
/// Whether the pill can be activated. /// Whether the pill can be activated.
#[property(get, set = Self::set_activatable, explicit_notify)] #[property(get, set = Self::set_activatable, explicit_notify)]
activatable: Cell<bool>, activatable: Cell<bool>,
/// The safety setting to watch to decide whether the image of the
/// avatar should be displayed.
#[property(get = Self::watched_safety_setting, set = Self::set_watched_safety_setting, builder(AvatarImageSafetySetting::default()))]
watched_safety_setting: PhantomData<AvatarImageSafetySetting>,
/// The room to watch to apply the current safety settings.
///
/// This is required if `watched_safety_setting` is not `None`.
#[property(get = Self::watched_room, set = Self::set_watched_room, nullable)]
watched_room: PhantomData<Option<Room>>,
gesture_click: RefCell<Option<gtk::GestureClick>>, gesture_click: RefCell<Option<gtk::GestureClick>>,
} }
@ -64,7 +76,7 @@ mod imp {
obj.imp().activate(); obj.imp().activate();
}); });
add_activate_binding_action(klass, "pill.activate"); key_bindings::add_activate_bindings(klass, "pill.activate");
} }
fn instance_init(obj: &InitializingObject<Self>) { fn instance_init(obj: &InitializingObject<Self>) {
@ -165,6 +177,28 @@ mod imp {
} }
} }
/// The safety setting to watch to decide whether the image of the
/// avatar should be displayed.
fn watched_safety_setting(&self) -> AvatarImageSafetySetting {
self.avatar.watched_safety_setting()
}
/// Set the safety setting to watch to decide whether the image of the
/// avatar should be displayed.
fn set_watched_safety_setting(&self, setting: AvatarImageSafetySetting) {
self.avatar.set_watched_safety_setting(setting);
}
/// The room to watch to apply the current safety settings.
fn watched_room(&self) -> Option<Room> {
self.avatar.watched_room()
}
/// Set the room to watch to apply the current safety settings.
fn set_watched_room(&self, room: Option<Room>) {
self.avatar.set_watched_room(room);
}
/// Set the display name of this pill. /// Set the display name of this pill.
fn set_display_name(&self, label: &str) { fn set_display_name(&self, label: &str) {
// We ellipsize the string manually because GtkTextView uses the minimum width. // We ellipsize the string manually because GtkTextView uses the minimum width.
@ -201,13 +235,13 @@ mod imp {
return; return;
}; };
session_view.select_room(Some(room.clone())); session_view.select_room(room.clone());
} else if let Ok(room) = source.downcast::<RemoteRoom>() { } else if let Some(room) = source.downcast_ref::<RemoteRoom>() {
let Some(session) = room.session() else { let Some(session) = room.session() else {
return; return;
}; };
let dialog = JoinRoomDialog::new(&session); let dialog = RoomPreviewDialog::new(&session);
dialog.set_room(room); dialog.set_room(room);
dialog.present(Some(&*obj)); dialog.present(Some(&*obj));
} }
@ -222,8 +256,30 @@ glib::wrapper! {
} }
impl Pill { impl Pill {
/// Create a pill with the given source. /// Create a pill with the given source and watching the given safety
pub fn new(source: &impl IsA<PillSource>) -> Self { /// setting.
glib::Object::builder().property("source", source).build() pub fn new(
source: &impl IsA<PillSource>,
watched_safety_setting: AvatarImageSafetySetting,
watched_room: Option<Room>,
) -> Self {
let source = source.upcast_ref();
let (watched_safety_setting, watched_room) = if let Some(room) = source
.downcast_ref::<Room>()
.cloned()
.or_else(|| source.downcast_ref::<AtRoom>().map(AtRoom::room))
{
// We must always watch the invite avatars setting for local rooms.
(AvatarImageSafetySetting::InviteAvatars, Some(room))
} else {
(watched_safety_setting, watched_room)
};
glib::Object::builder()
.property("source", source)
.property("watched-safety-setting", watched_safety_setting)
.property("watched-room", watched_room)
.build()
} }
} }

View File

@ -5,7 +5,7 @@ use gtk::{
CompositeTemplate, CompositeTemplate,
}; };
use crate::components::{Pill, PillSource}; use crate::components::{AvatarImageSafetySetting, Pill, PillSource};
mod imp { mod imp {
use std::{cell::RefCell, collections::HashMap, marker::PhantomData, sync::LazyLock}; use std::{cell::RefCell, collections::HashMap, marker::PhantomData, sync::LazyLock};
@ -136,7 +136,12 @@ mod imp {
} }
} }
impl WidgetImpl for PillSearchEntry {} impl WidgetImpl for PillSearchEntry {
fn grab_focus(&self) -> bool {
self.text_view.grab_focus()
}
}
impl BinImpl for PillSearchEntry {} impl BinImpl for PillSearchEntry {}
impl PillSearchEntry { impl PillSearchEntry {
@ -170,7 +175,9 @@ mod imp {
return; return;
} }
let pill = Pill::new(source); // We do not need to watch the safety setting as this entry should only be used
// with search results.
let pill = Pill::new(source, AvatarImageSafetySetting::None, None);
pill.set_margin_start(3); pill.set_margin_start(3);
pill.set_margin_end(3); pill.set_margin_end(3);

View File

@ -1,12 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<interface> <interface>
<template class="PillSearchEntry" parent="AdwBin"> <template class="PillSearchEntry" parent="AdwBin">
<property name="accessible-role">search</property>
<property name="child"> <property name="child">
<object class="CustomEntry"> <object class="CustomEntry">
<!-- FIXME: inserting a Pill makes the Entry grow, therefore we force more height so that it doesn't grow visually
Would be nice to fix it properly. Including the vertical alignment of Pills in the textview
-->
<property name="height-request">74</property>
<child> <child>
<object class="GtkBox"> <object class="GtkBox">
<property name="spacing">6</property> <property name="spacing">6</property>
@ -18,15 +15,21 @@
</child> </child>
<child> <child>
<object class="GtkScrolledWindow"> <object class="GtkScrolledWindow">
<property name="vexpand">True</property>
<property name="hexpand">True</property>
<property name="vscrollbar-policy">external</property>
<property name="max-content-height">200</property>
<property name="propagate-natural-height">True</property>
<child> <child>
<object class="GtkTextView" id="text_view"> <object class="GtkTextView" id="text_view">
<property name="height-request">38</property>
<property name="hexpand">True</property> <property name="hexpand">True</property>
<property name="justification">left</property> <property name="justification">left</property>
<property name="wrap-mode">word-char</property> <property name="wrap-mode">word-char</property>
<property name="accepts-tab">False</property> <property name="accepts-tab">False</property>
<property name="pixels_above_lines">3</property> <property name="pixels-above-lines">6</property>
<property name="pixels_below_lines">3</property> <property name="pixels-below-lines">11</property>
<property name="pixels_inside_wrap">6</property> <property name="pixels-inside-wrap">11</property>
<property name="buffer"> <property name="buffer">
<object class="GtkTextBuffer" id="text_buffer"/> <object class="GtkTextBuffer" id="text_buffer"/>
</property> </property>

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