Compare commits

..

No commits in common. "main" and "10.rc" have entirely different histories.
main ... 10.rc

378 changed files with 39622 additions and 58392 deletions

View File

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

View File

@ -1,8 +1,6 @@
# Build the Flatpak
include:
- project: "GNOME/citemplates"
file: "flatpak/flatpak_ci_initiative.yml"
include: 'https://gitlab.gnome.org/GNOME/citemplates/-/raw/master/flatpak/flatpak_ci_initiative.yml'
variables:
RUNTIME_REPO: "https://nightly.gnome.org/gnome-nightly.flatpakrepo"
@ -30,13 +28,10 @@ build@aarch64:
# 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.
#
# 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:
image: 'quay.io/gnome_infrastructure/gnome-runtime-images:gnome-${GNOME_STABLE_VERSION}'
variables:
GNOME_STABLE_VERSION: "48"
GNOME_STABLE_VERSION: "47"
LLVM_NIGHTLY_VERSION: "18"
LLVM_STABLE_VERSION: "18"
RUNTIME_REPO: "https://flathub.org/repo/flathub.flatpakrepo"
@ -46,7 +41,7 @@ build@aarch64:
- sed -i "s|master|${GNOME_STABLE_VERSION}|g" ${MANIFEST_PATH}
# We want to use the latest LLVM extension for the stable runtime.
- sed -i "s|llvm${LLVM_NIGHTLY_VERSION}|llvm${LLVM_STABLE_VERSION}|g" ${MANIFEST_PATH}
build-stable@x86_64:
extends:

17
.gitlab-ci/docs.yml Normal file
View File

@ -0,0 +1,17 @@
.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

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

View File

@ -1,24 +1,18 @@
# Configure and run code checks
include: '.gitlab-ci/utils.yml'
# Custom checks and lints
checks:
stage: check
image: "rustlang/rust:nightly-slim"
interruptible: true
script:
- hooks/checks.sh --verbose --force-install
# Lint the code
cargo-clippy:
extends:
- .remove_build_only_modules
stage: check
image: 'quay.io/gnome_infrastructure/gnome-runtime-images:gnome-master'
tags:
- flatpak
interruptible: true
script:
- 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}

View File

@ -1,6 +1,6 @@
# Tests after the app is built.
include: '.gitlab-ci/utils.yml'
include: '.gitlab-ci/docs.yml'
# Validate the metainfo with Flathub's tool.
lint-metainfo:
@ -10,7 +10,6 @@ lint-metainfo:
entrypoint: [""]
variables:
METAINFO: "${APP_ID}.metainfo.xml"
interruptible: true
script:
# This tool has extra tests on top of appstreamcli and is required to pass for Flathub.
- flatpak-builder-lint appstream ${METAINFO}
@ -20,24 +19,17 @@ lint-metainfo:
# Run the Rust tests.
rust-tests:
extends:
- .remove_build_only_modules
stage: test
image: 'quay.io/gnome_infrastructure/gnome-runtime-images:gnome-master'
tags:
- flatpak
interruptible: true
variables:
TMP_MANIFEST_PATH: "build-aux/org.gnome.Fractal.CiRust.json"
script:
# Create a temporary file.
- TMP_FILE=$(mktemp)
# Add a module for nextest to the Flatpak manifest and write it to the temporary file.
- 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}
# Add a module for nextest to the Flatpak manifest
- jq --slurpfile nextest .gitlab-ci/nextest.module.json '.modules = [$nextest[], .modules[]]' ${MANIFEST_PATH} > ${TMP_MANIFEST_PATH}
- flatpak-builder --keep-build-dirs --user --disable-rofiles-fuse --stop-at=${FLATPAK_MODULE} flatpak_app --repo=repo ${BRANCH:+--default-branch=$BRANCH} ${TMP_MANIFEST_PATH}
- 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}
dependencies: []
artifacts:
reports:
@ -46,21 +38,9 @@ rust-tests:
# Test that there are no errors in the docs.
build-docs:
extends:
- .remove_build_only_modules
- .docs
stage: test
image: 'quay.io/gnome_infrastructure/gnome-runtime-images:gnome-master'
tags:
- flatpak
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
variables:
DOCS_FOLDER: "doc"
rules:
- if: $CI_COMMIT_BRANCH != "main"

View File

@ -1,16 +0,0 @@
# 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,10 +12,9 @@ or videos showing the issue.
## 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 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 -->
* **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. -->
<!-- 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,10 +1,7 @@
<!-- 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
for something that is not yet implemented, and doesnt have an existing issue that is open or that
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. -->
was closed as out of scope. -->
Detailed description of the feature. Provide as much information as you can.

2345
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -38,7 +38,7 @@ development version while keeping the stable release around for daily use.
### Stable version
The current stable version is 11.2 (released June 10th 2025).
The current stable version is 9 (released October 30th 2024).
You can get the official Fractal Flatpak from Flathub.
@ -53,7 +53,7 @@ You can get the official Fractal Flatpak from Flathub.
### Beta version
The current beta version is 11.2 (same as stable).
The current beta version is 10.rc (released January 14th 2025).
It is available as a Flatpak on Flathub Beta.
@ -154,10 +154,6 @@ following dependencies at runtime:
* Camera: scan QR codes during verification.
* Location: send the users location in a conversation.
* 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
be installed.

View File

@ -16,7 +16,6 @@
4. Create a release on GitLab for that tag.
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).
7. [Get the stable branch added to Damned Lies](#getting-a-branch-added-to-damned-lies).
## Making a new beta release
@ -49,11 +48,6 @@ Make a single release commit containing the following changes:
- **stable.** remove all the `development` entries.
- **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`.
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
Fractal builds with the stable Flatpak runtime.
@ -75,42 +69,39 @@ 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/).
A permission from the Flathub team granted to your GitHub account is necessary to merge PRs on this
repository, but anyone can open a PR.
repository and interact with the buildbot, 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
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.
- Make sure that the Flatpak dependencies are the same as in the nightly manifest, and using the
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.
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.
## Launching a build manually for Flathub
If the build fails in CI and you want to trigger it again, post a comment saying `bot, build`.
In most cases, this should not be necessary. Flathub launches builds automatically for PRs and after
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:
If the build succeeds, test the generated Flatpak as instructed and watch for obvious errors. If
there are no issues, merge the PR.
4. Merging the PR will trigger an "official" build that will then be published on Flathub or Flathub
beta within 1 to 2 hours. If this build fails, contact the Flathub admins to launch it again.
More details about these steps can be found in the Flathub docs about [maintenance](https://docs.flathub.org/docs/for-app-authors/maintenance)
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.
1. Go to https://buildbot.flathub.org/ and log in.
2. Click on `Start build`.
3. Enter only the App ID. It is `org.gnome.Fractal` for the stable branch, and
`org.gnome.Fractal/beta` for the beta branch.
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.
5. Click on `Start build`.

View File

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

View File

@ -35,15 +35,15 @@
<screenshots>
<screenshot type="default">
<image type="source">https://gitlab.gnome.org/World/fractal/raw/fractal-11/screenshots/main.png</image>
<image type="source">https://gitlab.gnome.org/World/fractal/raw/fractal-9/screenshots/main.png</image>
<caption>Fractals main window</caption>
</screenshot>
<screenshot>
<image type="source">https://gitlab.gnome.org/World/fractal/raw/fractal-11/screenshots/media-history.png</image>
<image type="source">https://gitlab.gnome.org/World/fractal/raw/fractal-9/screenshots/media-history.png</image>
<caption>View the media history of a Matrix room</caption>
</screenshot>
<screenshot>
<image type="source">https://gitlab.gnome.org/World/fractal/raw/fractal-11/screenshots/adaptive.png</image>
<image type="source">https://gitlab.gnome.org/World/fractal/raw/fractal-9/screenshots/adaptive.png</image>
<caption>Fractals interface adapts to small screens</caption>
</screenshot>
</screenshots>
@ -71,107 +71,69 @@
</content_rating>
<releases>@development-release@
<release version="11.2" type="stable" date="2025-06-10">
<release version="10~rc" type="development" date="2025-01-14">
<description>
<p>
This version updates the matrix-sdk-crypto dependency to include a fix for a high severity
security issue.
</p>
</description>
</release>
<release version="11.1" type="stable" date="2025-05-15">
<description>
<p>
Due to a pesky bug that makes Fractal crash when our users attempt to start a
verification, we are releasing Fractal 11.1 only 2 weeks after Fractal 11. And while were
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:
In this cold weather, we hope this new release candidate will warm your hearts. Lets
celebrate this with our own awards ceremony:
</p>
<ul>
<li>
Support for login using the OAuth 2.0 API (as used by matrix.org, which recently made
the switch to Matrix Authentication Service)
The most next-gen addition goes to… making Fractal OIDC aware. This ensures
compatibility with the upcoming authentication changes for matrix.org.
</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!
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>
Rearranged account settings, with a new Safety tab that includes a setting to toggle
media preview visibility
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>
BlurHashes for images and videos, that are used as placeholders while the media is
loading or if the preview is disabled
The most underrated feature goes to… allowing to react to stickers, fixing a crash in
the process.
</li>
<li>
Contiguous state events are grouped behind a single item
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 and fixes thanks to all our
contributors, and our upstream projects.
As usual, this release includes other improvements, fixes and new translations 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.
As the version implies, it should be mostly stable and we expect to only include minor
improvements until the release of Fractal 10.
</p>
</description>
</release>
<release version="10.1" type="stable" date="2025-02-10">
<release version="10~beta" type="development" date="2024-12-26">
<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:
Vive le vent ! Vive le vent ! Vive le vent dhiver ! 🎶🌲 And vive Fractal 10.beta!
While everyone is resting for the holidays, we thought you could use a new release. It
focuses on improvements and bug fixes, including:
</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
Videos were often not playing after loading in the room history, this was fixed, and we
also show properly when an error occurred.
</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>
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
@ -183,9 +145,8 @@
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.
As the version implies, there might be a slight risk of regressions, but it should be
mostly stable. If all goes well the next step is the release candidate!
</p>
</description>
</release>

View File

@ -5,15 +5,47 @@
viewBox="0 0 89.958331 52.916668"
version="1.1"
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="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:cc="http://creativecommons.org/ns#"
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
id="defs8656">
<linearGradient
inkscape:collect="always"
id="linearGradient39832">
<stop
style="stop-color:#3584e4;stop-opacity:1"
@ -25,6 +57,7 @@
id="stop39830" />
</linearGradient>
<linearGradient
inkscape:collect="always"
id="linearGradient24559">
<stop
style="stop-color:#ed333b;stop-opacity:1;"
@ -36,6 +69,7 @@
id="stop24557" />
</linearGradient>
<linearGradient
inkscape:collect="always"
id="linearGradient12858">
<stop
style="stop-color:#f66151;stop-opacity:1"
@ -69,6 +103,7 @@
id="stop8143" />
</linearGradient>
<radialGradient
inkscape:collect="always"
xlink:href="#linearGradient976"
id="radialGradient1221"
gradientUnits="userSpaceOnUse"
@ -78,6 +113,7 @@
fy="212"
r="60" />
<linearGradient
inkscape:collect="always"
id="linearGradient976">
<stop
style="stop-color:#f8e45c;stop-opacity:1"
@ -89,6 +125,7 @@
id="stop974" />
</linearGradient>
<radialGradient
inkscape:collect="always"
xlink:href="#linearGradient1117"
id="radialGradient1223"
gradientUnits="userSpaceOnUse"
@ -99,6 +136,7 @@
fy="224"
r="16" />
<linearGradient
inkscape:collect="always"
id="linearGradient1117">
<stop
style="stop-color:#5e5c64;stop-opacity:1"
@ -110,6 +148,7 @@
id="stop1115" />
</linearGradient>
<radialGradient
inkscape:collect="always"
xlink:href="#linearGradient1117"
id="radialGradient1225"
gradientUnits="userSpaceOnUse"
@ -120,6 +159,7 @@
fy="224"
r="16" />
<linearGradient
inkscape:collect="always"
id="linearGradient1325">
<stop
style="stop-color:#f66151;stop-opacity:1"
@ -135,6 +175,7 @@
id="stop1323" />
</linearGradient>
<radialGradient
inkscape:collect="always"
xlink:href="#linearGradient976"
id="radialGradient1393-0"
gradientUnits="userSpaceOnUse"
@ -145,6 +186,7 @@
r="60"
gradientTransform="matrix(0.26458333,0,0,0.26458333,10.972649,-67.464743)" />
<radialGradient
inkscape:collect="always"
xlink:href="#linearGradient1325"
id="radialGradient1395-6"
gradientUnits="userSpaceOnUse"
@ -155,6 +197,7 @@
fy="29.856375"
r="16.084499" />
<radialGradient
inkscape:collect="always"
xlink:href="#linearGradient1325"
id="radialGradient1397-9-9"
gradientUnits="userSpaceOnUse"
@ -165,6 +208,7 @@
fy="29.856375"
r="16.084499" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient12858"
id="linearGradient12860"
x1="3467.3748"
@ -173,6 +217,7 @@
y2="-383.00339"
gradientUnits="userSpaceOnUse" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient8145"
id="linearGradient15076"
gradientUnits="userSpaceOnUse"
@ -182,6 +227,7 @@
x2="-3272.5"
y2="-438.48035" />
<radialGradient
inkscape:collect="always"
xlink:href="#linearGradient39832"
id="radialGradient39804"
cx="3496.6987"
@ -192,6 +238,7 @@
gradientTransform="matrix(0.61314223,0,0,0.49927324,-2207.2336,232.19657)"
gradientUnits="userSpaceOnUse" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient8161"
id="linearGradient40220"
x1="428.48035"
@ -201,6 +248,7 @@
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.85000059,0,0,0.85000059,64.271801,-492.37307)" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient24559"
id="linearGradient63749"
gradientUnits="userSpaceOnUse"
@ -289,10 +337,14 @@
id="g137264"
style="stroke-width:0.666667">
<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"
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
sodipodi:nodetypes="cssccccsccsccssc"
inkscape:connector-curvature="0"
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"
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" />
@ -322,13 +374,17 @@
id="g137282"
style="stroke-width:0.666667">
<path
inkscape:connector-curvature="0"
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"
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
inkscape:connector-curvature="0"
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"
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
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"
@ -413,12 +469,13 @@
ry="3.175" />
<text
xml:space="preserve"
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"
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"
x="25.548162"
y="7.6729164"
id="text63745"><tspan
sodipodi:role="line"
id="tspan63743"
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"
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"
x="25.548162"
y="7.6729164">999+</tspan></text>
</g>
@ -494,12 +551,22 @@
rx="2"
ry="2" />
<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"
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" />
<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"
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" />
<rect
ry="8"
@ -513,7 +580,16 @@
<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"
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)"
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" />
<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"
@ -564,13 +640,17 @@
transform="matrix(1.5,0,0,1.5,2465.0001,-75.912199)"
id="g137294">
<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"
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
inkscape:connector-curvature="0"
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"
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>
<rect
y="-412.48035"
@ -609,28 +689,42 @@
cy="-5.023077"
r="15.875" />
<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"
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" />
<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"
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" />
<path
inkscape:connector-curvature="0"
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"
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
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"
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" />
<path
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"
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
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"
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>
</svg>

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 36 KiB

View File

@ -1,2 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,55 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 5.5 KiB

View File

@ -1,2 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 270 B

View File

@ -1,4 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 424 B

View File

@ -1,4 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 786 B

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

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

Before

Width:  |  Height:  |  Size: 488 B

View File

@ -18,7 +18,6 @@
<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-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.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/actions/idp-facebook.svg</file>
@ -43,16 +42,13 @@
<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/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/blocked-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/document-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/encryption-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/home-symbolic.svg</file>
@ -60,12 +56,10 @@
<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/notifications-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/security-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-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/verified-danger-symbolic.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/status/verified-symbolic.svg</file>

View File

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

View File

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

View File

@ -3,40 +3,6 @@
@use 'config';
@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 {
padding-bottom: 0;
@ -47,7 +13,7 @@ room-title {
}
}
.room-history-row {
room-history-row {
padding-top: 2px;
padding-bottom: 2px;
padding-left: 8px;
@ -56,12 +22,12 @@ room-title {
@include vendor.focus-ring();
&.has-avatar {
&.has-header {
margin-top: 6px;
}
&:not(.has-avatar) {
.event-content {
&:not(.has-header) {
.event-content, message-reactions {
&:dir(ltr) {
margin-left: 54px;
}
@ -99,241 +65,30 @@ room-title {
}
}
}
}
sender-avatar {
padding: 5px;
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;
.event-content {
.emoji {
font-size: 3em;
}
:not(title) {
padding: 12px;
.emote {
color: var(--accent-color);
}
}
.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 {
&.opaque-bg {
border-radius: vendor.$menu_radius;
background-color: var(--border-color);
}
> .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 {
.spinner {
min-width: 32px;
min-height: 32px;
}
> 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 {
image.osd.circular {
min-width: 64px;
min-height: 64px;
border-radius: 32px;
@ -346,12 +101,12 @@ message-visual-media {
}
&.compact {
> .spinner {
.spinner {
min-width: 16px;
min-height: 16px;
}
> image.osd.circular {
image.osd.circular {
min-width: 32px;
min-height: 32px;
-gtk-icon-size: 16px;
@ -359,14 +114,21 @@ message-visual-media {
}
}
message-reactions {
flowboxchild {
&:hover, &:active {
// Cancel effect under .navigation-sidebar from libadwaita
background-color: transparent;
}
}
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 {
&:dir(ltr) .toggle {
padding: 1px 0 1px 6px;
}
@ -375,11 +137,11 @@ message-reactions {
padding: 1px 6px 1px 0;
}
.reaction-key-text {
.reaction-key {
font-size: 0.8em;
}
.reaction-key-emoji {
.reaction-key.emoji {
font-size: 1.1em;
padding-right: 2px;
padding-left: 2px;
@ -443,9 +205,17 @@ divider-row {
}
}
typing-row {
padding: 0 6px;
min-height: 30px;
.timestamp {
min-width: 36px;
font-weight: normal;
}
.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 {
@ -456,18 +226,125 @@ typing-row {
min-height: 24px;
min-width: 24px;
}
}
.event-content {
@extend %nested-effect;
padding-top: 2px;
padding-bottom: 2px;
.related-event-content {
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 {
transform: translateX(2px);
}
.composer-replacement {
margin: 12px;
}

View File

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

View File

@ -10,7 +10,6 @@ $active_color: color-mix(in srgb, currentColor 16%, transparent);
$selected_color: color-mix(in srgb, currentColor 10%, transparent);
$selected_hover_color: color-mix(in srgb, currentColor 13%, 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
$button_color: color-mix(in srgb, currentColor 10%, transparent);
@ -22,8 +21,9 @@ $ease-out-quad: cubic-bezier(0.25, 0.46, 0.45, 0.94);
$focus_transition: outline-color 200ms $ease-out-quad,
outline-width 200ms $ease-out-quad,
outline-offset 200ms $ease-out-quad;
$card_radius: 12px;
$menu_radius: 9px;
$button_radius: 6px;
$card_radius: $button_radius + 6;
$menu_radius: 6px;
// 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) {

View File

@ -1,34 +0,0 @@
[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
exit 2
elif check_rustup; then
echo -e "$error rustup is installed but the cargo command isnt available"
echo -e "$error rustup is installed but the cargo command isn't available"
exit 2
else
echo ""
echo "y: Install cargo via rustup"
echo "N: Dont install cargo and abort checks"
echo "N: Don't install cargo and abort checks"
echo ""
while true; do
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 ""
echo "y: Install rustfmt via rustup"
echo "N: Dont install rustfmt and abort checks"
echo "N: Don't install rustfmt and abort checks"
echo ""
while true; do
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 ""
echo "y: Install typos via cargo"
echo "N: Dont install typos and abort checks"
echo "N: Don't install typos and abort checks"
echo ""
while true; do
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 ""
echo "y: Install cargo-machete via cargo"
echo "N: Dont install cargo-machete and abort checks"
echo "N: Don't install cargo-machete and abort checks"
echo ""
while true; do
echo -n "Install cargo-machete? [y/N]: "; read yn < /dev/tty
@ -353,65 +353,6 @@ run_machete() {
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.
#
# This checks, in that order:
@ -638,7 +579,7 @@ run_cargo_sort() {
echo "Cargo-sort is needed to check the sorting in Cargo.toml, but it isnt available"
echo ""
echo "y: Install cargo-sort via cargo"
echo "N: Dont install cargo-sort and abort checks"
echo "N: Don't install cargo-sort and abort checks"
echo ""
while true; do
echo -n "Install cargo-sort? [y/N]: "; read yn < /dev/tty
@ -715,8 +656,6 @@ run_typos
echo ""
run_machete
echo ""
run_cargo_deny
echo ""
check_potfiles
echo ""
if [[ $git_staged -eq 1 ]]; then

View File

@ -1,6 +1,6 @@
project('fractal',
'rust',
version: '11.2',
version: '10.rc',
license: 'GPL-3.0-or-later',
meson_version: '>= 1.1')
@ -10,8 +10,8 @@ gnome = import('gnome')
base_id = 'org.gnome.Fractal'
application_id = base_id
major_version = '11.2'
pre_release_version = ''
major_version = '10'
pre_release_version = 'rc'
version = major_version
if pre_release_version != ''
@ -22,9 +22,10 @@ full_version = 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('gtk4', version: '>= 4.16')
dependency('libadwaita-1', version: '>= 1.7')
dependency('libadwaita-1', version: '>= 1.6')
# Please keep these dependencies sorted.
dependency('gstgtk4', version: '>= 0.13.0')
dependency('gstreamer-1.0', version: '>= 1.20')
dependency('gstreamer-app-1.0', version: '>= 1.20')
dependency('gstreamer-base-1.0', version: '>= 1.20')
@ -33,17 +34,17 @@ dependency('gstreamer-play-1.0', version: '>= 1.20')
dependency('gstreamer-video-1.0', version: '>= 1.20')
dependency('gtksourceview-5', version: '>= 5.0.0')
dependency('libwebp', version: '>= 1.0.0')
dependency('openssl', version: '>= 3.0.0')
dependency('openssl', version: '>= 1.0.1')
dependency('shumate-1.0', version: '>= 1.0.0')
dependency('sqlite3', version: '>= 3.24.0')
# Required by glycin crate
dependency('lcms2', version: '>=2.12.0')
dependency('libseccomp', version: '>= 2.5.0')
# Linux-only dependencies
if build_machine.system() == 'linux'
# Required by glycin crate
dependency('libseccomp', version: '>= 2.5.0')
dependency('libpipewire-0.3', version: '>= 0.3.0')
endif
glib_compile_resources = find_program('glib-compile-resources', required: true)
@ -66,12 +67,7 @@ iconsdir = datadir / 'icons'
podir = meson.project_source_root() / 'po'
gettext_package = meson.project_name()
# 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
if get_option('profile') == 'development'
profile = 'Devel'
application_id += '.Devel'
elif get_option('profile') == 'hack'
@ -99,11 +95,8 @@ if profile == 'Devel'
run_command('cp', '-f', 'hooks/pre-commit.hook', '.git/hooks/pre-commit')
endif
if not build_env_only
subdir('data')
subdir('po')
endif
subdir('data')
subdir('po')
subdir('src')
gnome.post_install(

View File

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

View File

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

View File

@ -12,20 +12,15 @@ src/application.rs
src/components/action_button.ui
src/components/avatar/editable.rs
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.ui
src/components/crypto/recovery_setup_view.rs
src/components/crypto/recovery_setup_view.ui
src/components/dialogs/auth/in_browser_page.ui
src/components/dialogs/auth/mod.rs
src/components/dialogs/auth/mod.ui
src/components/dialogs/auth/password_page.ui
src/components/dialogs/auth.rs
src/components/dialogs/auth.ui
src/components/dialogs/join_room.rs
src/components/dialogs/join_room.ui
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/offline_banner.rs
src/components/media/content_viewer.rs
@ -35,6 +30,7 @@ src/components/power_level_selection/popover.ui
src/components/rows/loading_row.ui
src/components/user_page.rs
src/components/user_page.ui
src/contrib/qr_code_scanner/mod.ui
src/contrib/qr_code.rs
src/error_page.rs
src/error_page.ui
@ -48,7 +44,6 @@ src/identity_verification_view/completed_page.rs
src/identity_verification_view/completed_page.ui
src/identity_verification_view/confirm_qr_code_page.rs
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/no_supported_methods_page.rs
src/identity_verification_view/no_supported_methods_page.ui
@ -65,7 +60,6 @@ src/login/advanced_dialog.ui
src/login/greeter.ui
src/login/homeserver_page.rs
src/login/homeserver_page.ui
src/login/in_browser_page.rs
src/login/in_browser_page.ui
src/login/method_page.rs
src/login/method_page.ui
@ -82,11 +76,6 @@ src/session/model/room/permissions.rs
src/session/model/room_list/mod.rs
src/session/model/sidebar_data/section/name.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.ui
src/session/view/account_settings/general_page/deactivate_account_subpage.rs
@ -98,15 +87,16 @@ src/session/view/account_settings/general_page/mod.ui
src/session/view/account_settings/mod.ui
src/session/view/account_settings/notifications_page.rs
src/session/view/account_settings/notifications_page.ui
src/session/view/account_settings/safety_page/ignored_users_subpage/ignored_user_row.rs
src/session/view/account_settings/safety_page/ignored_users_subpage/ignored_user_row.ui
src/session/view/account_settings/safety_page/ignored_users_subpage/mod.ui
src/session/view/account_settings/safety_page/mod.rs
src/session/view/account_settings/safety_page/mod.ui
src/session/view/account_settings/user_session/user_session_list_subpage.ui
src/session/view/account_settings/user_session/user_session_row.ui
src/session/view/account_settings/user_session/user_session_subpage.rs
src/session/view/account_settings/user_session/user_session_subpage.ui
src/session/view/account_settings/security_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/security_page/ignored_users_subpage/mod.ui
src/session/view/account_settings/security_page/import_export_keys_subpage.rs
src/session/view/account_settings/security_page/import_export_keys_subpage.ui
src/session/view/account_settings/security_page/mod.rs
src/session/view/account_settings/security_page/mod.ui
src/session/view/account_settings/user_sessions_page/mod.ui
src/session/view/account_settings/user_sessions_page/user_session_row.rs
src/session/view/account_settings/user_sessions_page/user_session_row.ui
src/session/view/content/explore/mod.ui
src/session/view/content/explore/public_room_row.rs
src/session/view/content/explore/servers_popover.ui
@ -144,10 +134,9 @@ 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/room_upgrade_dialog.rs
src/session/view/content/room_history/divider_row.rs
src/session/view/content/room_history/event_actions/context_menu.rs
src/session/view/content/room_history/event_actions/context_menu.ui
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/event_context_menu.ui
src/session/view/content/room_history/item_row.rs
src/session/view/content/room_history/item_row_context_menu.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/file.rs
@ -161,7 +150,6 @@ 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/text/widgets.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/completion/completion_popover.rs
src/session/view/content/room_history/message_toolbar/mod.rs
@ -169,24 +157,26 @@ 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/mod.rs
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/sender_avatar/mod.rs
src/session/view/content/room_history/sender_avatar/mod.ui
src/session/view/content/room_history/state/content.rs
src/session/view/content/room_history/state/creation.rs
src/session/view/content/room_history/state/creation.ui
src/session/view/content/room_history/state/group_row.rs
src/session/view/content/room_history/state_row/creation.rs
src/session/view/content/room_history/state_row/creation.ui
src/session/view/content/room_history/state_row/mod.rs
src/session/view/content/room_history/state_row/tombstone.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/typing_row.rs
src/session/view/content/room_history/verification_info_bar.rs
src/session/view/create_direct_chat_dialog/mod.rs
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/create_dm_dialog/mod.rs
src/session/view/create_dm_dialog/mod.ui
src/session/view/event_details_dialog.rs
src/session/view/event_details_dialog.ui
src/session/view/media_viewer.rs
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.ui
src/session/view/sidebar/room_row.rs

View File

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

2386
po/fr.po

File diff suppressed because it is too large Load Diff

5782
po/id.po

File diff suppressed because it is too large Load Diff

2567
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

2772
po/ru.po

File diff suppressed because it is too large Load Diff

2697
po/sl.po

File diff suppressed because it is too large Load Diff

2781
po/sv.po

File diff suppressed because it is too large Load Diff

1607
po/th.po

File diff suppressed because it is too large Load Diff

2542
po/tr.po

File diff suppressed because it is too large Load Diff

2799
po/uk.po

File diff suppressed because it is too large Load Diff

5221
po/uz.po

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: 115 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 113 KiB

View File

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

View File

@ -1,25 +1,30 @@
use gtk::{glib, glib::clone, prelude::*, subclass::prelude::*, CompositeTemplate};
use gtk::{
glib::{self, clone, closure},
prelude::*,
subclass::prelude::*,
CompositeTemplate,
};
use super::AccountSwitcherPopover;
use crate::{
components::Avatar,
session_list::SessionInfo,
utils::{BoundObjectWeakRef, TemplateCallbacks},
utils::{template_callbacks::TemplateCallbacks, BoundObjectWeakRef},
Window,
};
mod imp {
use std::cell::RefCell;
use glib::subclass::InitializingObject;
use super::*;
#[derive(Debug, Default, CompositeTemplate, glib::Properties)]
#[derive(Debug, Default, CompositeTemplate)]
#[template(resource = "/org/gnome/Fractal/ui/account_switcher/account_switcher_button.ui")]
#[properties(wrapper_type = super::AccountSwitcherButton)]
pub struct AccountSwitcherButton {
/// The popover of this button.
#[property(get, set = Self::set_popover, explicit_notify, nullable)]
popover: BoundObjectWeakRef<AccountSwitcherPopover>,
pub popover: BoundObjectWeakRef<AccountSwitcherPopover>,
pub watch: RefCell<Option<gtk::ExpressionWatch>>,
}
#[glib::object_subclass]
@ -33,7 +38,6 @@ mod imp {
SessionInfo::ensure_type();
Self::bind_template(klass);
Self::bind_template_callbacks(klass);
TemplateCallbacks::bind_template_callbacks(klass);
}
@ -42,85 +46,40 @@ mod imp {
}
}
#[glib::derived_properties]
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) {
self.reset();
if let Some(watch) = self.watch.take() {
watch.unwatch();
}
}
}
impl WidgetImpl for AccountSwitcherButton {}
impl ButtonImpl 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! {
/// A button showing the currently selected session and opening the account switcher popover.
/// A button showing the currently selected account and opening the account switcher popover.
pub struct AccountSwitcherButton(ObjectSubclass<imp::AccountSwitcherButton>)
@extends gtk::Widget, gtk::Button, gtk::ToggleButton, @implements gtk::Accessible;
}
@ -130,6 +89,60 @@ impl AccountSwitcherButton {
pub fn new() -> Self {
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 {

View File

@ -1,18 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<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">
<lookup name="user-id-string" type="SessionInfo">
<lookup name="selected-item" type="GtkSingleSelection">
@ -28,7 +16,6 @@
</accessibility>
<style>
<class name="image-button"/>
<class name="circular"/>
</style>
<property name="child">
<object class="Avatar">

View File

@ -18,12 +18,12 @@ mod imp {
#[properties(wrapper_type = super::AccountSwitcherPopover)]
pub struct AccountSwitcherPopover {
#[template_child]
sessions: TemplateChild<gtk::ListBox>,
pub sessions: TemplateChild<gtk::ListBox>,
/// The model containing the logged-in sessions selection.
#[property(get, set = Self::set_session_selection, explicit_notify, nullable)]
session_selection: BoundObjectWeakRef<gtk::SingleSelection>,
pub session_selection: BoundObjectWeakRef<gtk::SingleSelection>,
/// The selected row.
selected_row: glib::WeakRef<SessionItemRow>,
pub selected_row: glib::WeakRef<SessionItemRow>,
}
#[glib::object_subclass]
@ -34,7 +34,7 @@ mod imp {
fn class_init(klass: &mut Self::Class) {
Self::bind_template(klass);
Self::bind_template_callbacks(klass);
Self::Type::bind_template_callbacks(klass);
klass.install_action("account-switcher.close", None, |obj, _, _| {
obj.popdown();
@ -52,94 +52,97 @@ mod imp {
impl WidgetImpl for AccountSwitcherPopover {}
impl PopoverImpl for AccountSwitcherPopover {}
#[gtk::template_callbacks]
impl AccountSwitcherPopover {
/// Set the model containing the logged-in sessions selection.
fn set_session_selection(&self, selection: Option<&gtk::SingleSelection>) {
if selection == self.session_selection.obj().as_ref() {
return;
}
let obj = self.obj();
self.session_selection.disconnect_signals();
self.sessions.bind_model(selection, |session| {
let row = SessionItemRow::new(
session
.downcast_ref()
.expect("sessions list box item should be a Session"),
);
let row = SessionItemRow::new(session.downcast_ref().unwrap());
row.upcast()
});
if let Some(selection) = selection {
let selected_handler = selection.connect_selected_item_notify(clone!(
#[weak(rename_to = imp)]
self,
#[weak]
obj,
move |selection| {
imp.update_selected_item(selection.selected());
obj.update_selected_item(selection.selected());
}
));
self.update_selected_item(selection.selected());
obj.update_selected_item(selection.selected());
self.session_selection
.set(selection, vec![selected_handler]);
}
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());
obj.notify_session_selection();
}
}
}
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>)
@extends gtk::Widget, gtk::Popover, @implements gtk::Accessible;
}
#[gtk::template_callbacks]
impl AccountSwitcherPopover {
pub fn new() -> Self {
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 {

View File

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

View File

@ -1,4 +1,4 @@
use gtk::{glib, prelude::*, subclass::prelude::*, CompositeTemplate};
use gtk::{self, glib, prelude::*, subclass::prelude::*, CompositeTemplate};
use super::avatar_with_selection::AvatarWithSelection;
use crate::{
@ -20,22 +20,22 @@ mod imp {
#[properties(wrapper_type = super::SessionItemRow)]
pub struct SessionItemRow {
#[template_child]
avatar: TemplateChild<AvatarWithSelection>,
pub avatar: TemplateChild<AvatarWithSelection>,
#[template_child]
display_name: TemplateChild<gtk::Label>,
pub display_name: TemplateChild<gtk::Label>,
#[template_child]
user_id: TemplateChild<gtk::Label>,
pub user_id: TemplateChild<gtk::Label>,
#[template_child]
state_stack: TemplateChild<gtk::Stack>,
pub state_stack: TemplateChild<gtk::Stack>,
#[template_child]
error_image: TemplateChild<gtk::Image>,
pub error_image: TemplateChild<gtk::Image>,
/// The session this item represents.
#[property(get, set = Self::set_session, explicit_notify)]
session: glib::WeakRef<SessionInfo>,
user_bindings: RefCell<Vec<glib::Binding>>,
pub session: glib::WeakRef<SessionInfo>,
pub user_bindings: RefCell<Vec<glib::Binding>>,
/// Whether this session is selected.
#[property(get = Self::is_selected, set = Self::set_selected, explicit_notify)]
selected: PhantomData<bool>,
pub selected: PhantomData<bool>,
}
#[glib::object_subclass]
@ -46,7 +46,7 @@ mod imp {
fn class_init(klass: &mut Self::Class) {
Self::bind_template(klass);
Self::bind_template_callbacks(klass);
Self::Type::bind_template_callbacks(klass);
}
fn instance_init(obj: &InitializingObject<Self>) {
@ -66,7 +66,6 @@ mod imp {
impl WidgetImpl for SessionItemRow {}
impl ListBoxRowImpl for SessionItemRow {}
#[gtk::template_callbacks]
impl SessionItemRow {
/// Whether this session is selected.
fn is_selected(&self) -> bool {
@ -146,23 +145,6 @@ mod imp {
self.session.set(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");
}
}
}
@ -172,8 +154,24 @@ glib::wrapper! {
@extends gtk::Widget, gtk::ListBoxRow, @implements gtk::Accessible;
}
#[gtk::template_callbacks]
impl SessionItemRow {
pub fn new(session: &SessionInfo) -> Self {
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,16 +21,14 @@
<object class="GtkLabel" id="display_name">
<property name="xalign">0.0</property>
<property name="hexpand">True</property>
<property name="ellipsize">end</property>
</object>
</child>
<child>
<object class="GtkLabel" id="user_id">
<property name="xalign">0.0</property>
<property name="hexpand">True</property>
<property name="ellipsize">end</property>
<style>
<class name="dimmed"/>
<class name="dim-label"/>
<class name="caption"/>
</style>
</object>
@ -60,6 +58,9 @@
<property name="valign">center</property>
<property name="halign">center</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"/>
<style>
<class name="circular"/>

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -1,5 +1,5 @@
use adw::subclass::prelude::*;
use gtk::{gdk, glib, glib::clone, prelude::*, CompositeTemplate};
use gtk::{glib, glib::clone, prelude::*, CompositeTemplate};
mod crop_circle;
mod data;
@ -16,38 +16,11 @@ pub use self::{
};
use crate::{
components::AnimatedImagePaintable,
session::model::Room,
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 {
use std::{
cell::{Cell, RefCell},
marker::PhantomData,
};
use std::{cell::RefCell, marker::PhantomData};
use glib::subclass::InitializingObject;
@ -68,19 +41,8 @@ mod imp {
/// The size of the Avatar.
#[property(get = Self::size, set = Self::set_size, explicit_notify, builder().default_value(-1).minimum(-1))]
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_animation_ref: RefCell<Option<CountedRef>>,
watched_room_handler: RefCell<Option<glib::SignalHandlerId>>,
watched_global_account_data_handler: RefCell<Option<glib::SignalHandlerId>>,
}
#[glib::object_subclass]
@ -104,11 +66,7 @@ mod imp {
}
#[glib::derived_properties]
impl ObjectImpl for Avatar {
fn dispose(&self) {
self.disconnect_safety_setting_signals();
}
}
impl ObjectImpl for Avatar {}
impl WidgetImpl for Avatar {
fn map(&self) {
@ -150,136 +108,6 @@ mod imp {
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.
fn set_data(&self, data: Option<AvatarData>) {
if self.data.obj() == data {
@ -360,13 +188,6 @@ mod imp {
fn update_paintable(&self) {
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() {
// We do not need a paintable.
self.update_animated_paintable_state();
@ -397,7 +218,7 @@ mod imp {
fn update_animated_paintable_state(&self) {
let _old_paintable_animation_ref = self.paintable_animation_ref.take();
if !self.can_show_image() || !self.obj().is_mapped() {
if !self.obj().is_mapped() {
// We do not need to animate the paintable.
return;
}

View File

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

View File

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

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

@ -1,56 +0,0 @@
//! 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

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

@ -1,162 +0,0 @@
//! 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 gtk::{gdk, glib, glib::clone, prelude::*, CompositeTemplate};
use crate::utils::{key_bindings, BoundObject};
use crate::utils::BoundObject;
mod imp {
use std::cell::{Cell, RefCell};
@ -62,7 +62,16 @@ mod imp {
klass.install_action("context-menu.activate", None, |obj, _, _| {
obj.open_menu_at(0, 0);
});
key_bindings::add_context_menu_bindings(klass, "context-menu.activate");
klass.add_binding_action(
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, _, _| {
if let Some(popover) = obj.popover() {

View File

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

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

@ -0,0 +1,68 @@
<?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

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

@ -1,129 +0,0 @@
<?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

@ -1,635 +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::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

@ -1,16 +0,0 @@
<?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

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

@ -1,105 +0,0 @@
<?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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -230,7 +230,14 @@ mod imp {
/// View the given location as a geo URI.
pub(super) fn view_location(&self, geo_uri: &GeoUri) {
let location = self.viewer.child_or_default::<LocationViewer>();
let location =
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);
self.update_animated_paintable_state();

View File

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

View File

@ -32,8 +32,6 @@ mod imp {
state: Cell<LoadingState>,
/// The current error, if any.
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>,
}
@ -112,8 +110,6 @@ mod imp {
}
self.compact.set(compact);
self.update_timestamp();
self.obj().notify_compact();
}
@ -132,7 +128,7 @@ mod imp {
let uri = file.uri();
self.file.replace(Some(file));
self.set_duration(None);
self.duration_changed(None);
self.set_state(LoadingState::Loading);
self.player.set_uri(Some(uri.as_ref()));
@ -159,7 +155,7 @@ mod imp {
}
}
gst_play::PlayMessage::DurationChanged { duration } => {
self.set_duration(duration);
self.duration_changed(duration);
}
gst_play::PlayMessage::Warning { error, .. } => {
warn!("Warning playing video: {error}");
@ -173,23 +169,9 @@ mod imp {
}
}
/// Set the duration of the video.
fn set_duration(&self, duration: Option<gst::ClockTime>) {
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 {
/// Handle when the duration changed.
fn duration_changed(&self, duration: Option<gst::ClockTime>) {
if let Some(duration) = duration {
let mut time = duration.seconds();
let sec = time % 60;
@ -211,7 +193,7 @@ mod imp {
self.timestamp.set_label(&label);
}
self.timestamp.set_visible(is_visible);
self.timestamp.set_visible(duration.is_some());
}
}
}

View File

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

View File

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

View File

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

View File

@ -12,21 +12,18 @@ pub use self::{
source::{PillSource, PillSourceExt, PillSourceImpl},
source_row::PillSourceRow,
};
use super::{Avatar, AvatarImageSafetySetting, RoomPreviewDialog, UserProfileDialog};
use super::{Avatar, JoinRoomDialog, UserProfileDialog};
use crate::{
prelude::*,
session::{
model::{Member, RemoteRoom, Room},
view::SessionView,
},
utils::{key_bindings, BoundObject},
utils::{add_activate_binding_action, BoundObject},
};
mod imp {
use std::{
cell::{Cell, RefCell},
marker::PhantomData,
};
use std::cell::{Cell, RefCell};
use glib::subclass::InitializingObject;
@ -48,15 +45,6 @@ mod imp {
/// Whether the pill can be activated.
#[property(get, set = Self::set_activatable, explicit_notify)]
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>>,
}
@ -76,7 +64,7 @@ mod imp {
obj.imp().activate();
});
key_bindings::add_activate_bindings(klass, "pill.activate");
add_activate_binding_action(klass, "pill.activate");
}
fn instance_init(obj: &InitializingObject<Self>) {
@ -177,28 +165,6 @@ 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.
fn set_display_name(&self, label: &str) {
// We ellipsize the string manually because GtkTextView uses the minimum width.
@ -235,13 +201,13 @@ mod imp {
return;
};
session_view.select_room(room.clone());
} else if let Some(room) = source.downcast_ref::<RemoteRoom>() {
session_view.select_room(Some(room.clone()));
} else if let Ok(room) = source.downcast::<RemoteRoom>() {
let Some(session) = room.session() else {
return;
};
let dialog = RoomPreviewDialog::new(&session);
let dialog = JoinRoomDialog::new(&session);
dialog.set_room(room);
dialog.present(Some(&*obj));
}
@ -256,30 +222,8 @@ glib::wrapper! {
}
impl Pill {
/// Create a pill with the given source and watching the given safety
/// setting.
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()
/// Create a pill with the given source.
pub fn new(source: &impl IsA<PillSource>) -> Self {
glib::Object::builder().property("source", source).build()
}
}

View File

@ -5,7 +5,7 @@ use gtk::{
CompositeTemplate,
};
use crate::components::{AvatarImageSafetySetting, Pill, PillSource};
use crate::components::{Pill, PillSource};
mod imp {
use std::{cell::RefCell, collections::HashMap, marker::PhantomData, sync::LazyLock};
@ -136,12 +136,7 @@ mod imp {
}
}
impl WidgetImpl for PillSearchEntry {
fn grab_focus(&self) -> bool {
self.text_view.grab_focus()
}
}
impl WidgetImpl for PillSearchEntry {}
impl BinImpl for PillSearchEntry {}
impl PillSearchEntry {
@ -175,9 +170,7 @@ mod imp {
return;
}
// 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);
let pill = Pill::new(source);
pill.set_margin_start(3);
pill.set_margin_end(3);

View File

@ -1,9 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<template class="PillSearchEntry" parent="AdwBin">
<property name="accessible-role">search</property>
<property name="child">
<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>
<object class="GtkBox">
<property name="spacing">6</property>
@ -15,21 +18,15 @@
</child>
<child>
<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>
<object class="GtkTextView" id="text_view">
<property name="height-request">38</property>
<property name="hexpand">True</property>
<property name="justification">left</property>
<property name="wrap-mode">word-char</property>
<property name="accepts-tab">False</property>
<property name="pixels-above-lines">6</property>
<property name="pixels-below-lines">11</property>
<property name="pixels-inside-wrap">11</property>
<property name="pixels_above_lines">3</property>
<property name="pixels_below_lines">3</property>
<property name="pixels_inside_wrap">6</property>
<property name="buffer">
<object class="GtkTextBuffer" id="text_buffer"/>
</property>

View File

@ -1,10 +1,7 @@
use gtk::{glib, prelude::*, subclass::prelude::*};
use super::Pill;
use crate::{
components::{AvatarData, AvatarImageSafetySetting},
session::model::Room,
};
use crate::components::AvatarData;
mod imp {
use std::{cell::Cell, marker::PhantomData};
@ -156,13 +153,8 @@ pub trait PillSourceExt: 'static {
f: F,
) -> glib::SignalHandlerId;
/// Get a `Pill` representing this source, watching the given safety
/// setting.
fn to_pill(
&self,
watched_safety_setting: AvatarImageSafetySetting,
watched_room: Option<Room>,
) -> Pill;
/// Get a `Pill` representing this source.
fn to_pill(&self) -> Pill;
}
impl<O: IsA<PillSource>> PillSourceExt for O {
@ -217,12 +209,8 @@ impl<O: IsA<PillSource>> PillSourceExt for O {
}
/// Get a `Pill` representing this source.
fn to_pill(
&self,
watched_safety_setting: AvatarImageSafetySetting,
watched_room: Option<Room>,
) -> Pill {
Pill::new(self, watched_safety_setting, watched_room)
fn to_pill(&self) -> Pill {
Pill::new(self)
}
}

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