Compare commits
16 Commits
release/an
...
release/gt
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c23717897 | |||
| 3a113e3169 | |||
| 0173be356e | |||
| a3dfedcfd0 | |||
| 6c3e61e261 | |||
| 78f29907cc | |||
| 4ab4dc8f16 | |||
| 6013f47441 | |||
| 89beb3ff2c | |||
| f9798a41e9 | |||
| 2f70440834 | |||
| cfeb38cb51 | |||
| 803018dacf | |||
| a852f233ee | |||
| d946e1256e | |||
| 7264cce5b8 |
61
.gitea/scripts/prepare-rpm-release-assets.sh
Executable file
61
.gitea/scripts/prepare-rpm-release-assets.sh
Executable file
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
: "${RELEASE_TAG:?Missing RELEASE_TAG}"
|
||||||
|
: "${TAG_PREFIX:?Missing TAG_PREFIX}"
|
||||||
|
: "${ASSETS_DIR:?Missing ASSETS_DIR}"
|
||||||
|
: "${RPM_BINARY_DIR:?Missing RPM_BINARY_DIR}"
|
||||||
|
: "${RPM_BINARY_PATTERN_PREFIX:?Missing RPM_BINARY_PATTERN_PREFIX}"
|
||||||
|
: "${RPM_DISTRO_NAME:?Missing RPM_DISTRO_NAME}"
|
||||||
|
: "${RPM_DISTRO_VERSION:?Missing RPM_DISTRO_VERSION}"
|
||||||
|
: "${GITHUB_ENV:?Missing GITHUB_ENV}"
|
||||||
|
|
||||||
|
version="${RELEASE_TAG#${TAG_PREFIX}}"
|
||||||
|
if [[ -z "$version" || "$version" == "$RELEASE_TAG" ]]; then
|
||||||
|
echo "Expected tag in the form ${TAG_PREFIX}{version}, got: $RELEASE_TAG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "${EXPECTED_VERSION:-}" && "$EXPECTED_VERSION" != "$version" ]]; then
|
||||||
|
echo "Release tag version ($version) does not match expected package version ($EXPECTED_VERSION)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -rf "$ASSETS_DIR"
|
||||||
|
mkdir -p "$ASSETS_DIR"
|
||||||
|
|
||||||
|
found=0
|
||||||
|
binary_pattern="${RPM_BINARY_PATTERN_PREFIX}${version}-*.rpm"
|
||||||
|
if [[ -d "$RPM_BINARY_DIR" ]]; then
|
||||||
|
find_cmd=(
|
||||||
|
find "$RPM_BINARY_DIR" -type f -name "$binary_pattern"
|
||||||
|
)
|
||||||
|
while IFS= read -r exclude; do
|
||||||
|
[[ -n "$exclude" ]] || continue
|
||||||
|
find_cmd+=( ! -name "$exclude" )
|
||||||
|
done <<< "${RPM_BINARY_EXCLUDE_PATTERNS:-}"
|
||||||
|
find_cmd+=( -exec cp '{}' "$ASSETS_DIR/" ';' )
|
||||||
|
"${find_cmd[@]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if find "$ASSETS_DIR" -maxdepth 1 -type f -name '*.rpm' | grep -q .; then
|
||||||
|
found=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "${RPM_SOURCE_DIR:-}" && -n "${RPM_SOURCE_PATTERN_PREFIX:-}" && -d "$RPM_SOURCE_DIR" ]]; then
|
||||||
|
source_pattern="${RPM_SOURCE_PATTERN_PREFIX}${version}-*.src.rpm"
|
||||||
|
find "$RPM_SOURCE_DIR" -type f -name "$source_pattern" -exec cp '{}' "$ASSETS_DIR/" ';'
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$found" -ne 1 ]]; then
|
||||||
|
echo "No RPM artifacts were produced." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
package_group="${RPM_PACKAGE_GROUP:-}"
|
||||||
|
|
||||||
|
{
|
||||||
|
printf 'RELEASE_VERSION=%s\n' "$version"
|
||||||
|
printf 'RELEASE_ASSETS_DIR=%s\n' "$ASSETS_DIR"
|
||||||
|
printf 'RPM_PACKAGE_GROUP=%s\n' "$package_group"
|
||||||
|
} >> "$GITHUB_ENV"
|
||||||
65
.gitea/scripts/upload-rpm-packages.sh
Executable file
65
.gitea/scripts/upload-rpm-packages.sh
Executable file
@@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
: "${GITEA_SERVER_URL:?Missing GITEA_SERVER_URL}"
|
||||||
|
: "${GITEA_REPOSITORY_OWNER:?Missing GITEA_REPOSITORY_OWNER}"
|
||||||
|
: "${RELEASE_ASSETS_DIR:?Missing RELEASE_ASSETS_DIR}"
|
||||||
|
|
||||||
|
owner="${GITEA_REPOSITORY_OWNER}"
|
||||||
|
package_user="${RPM_PACKAGE_USERNAME:-${GITEA_REPOSITORY_OWNER}}"
|
||||||
|
token="${RPM_PACKAGE_TOKEN:-}"
|
||||||
|
group="${RPM_PACKAGE_GROUP:-}"
|
||||||
|
|
||||||
|
if [[ -z "$package_user" ]]; then
|
||||||
|
echo "Missing package upload username. Set repository or organization variable RPM_PACKAGE_USERNAME." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$token" ]]; then
|
||||||
|
echo "Missing package upload token. Set repository or organization secret RPM_PACKAGE_TOKEN." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
upload_url="${GITEA_SERVER_URL%/}/api/packages/${owner}/rpm"
|
||||||
|
if [[ -n "$group" ]]; then
|
||||||
|
upload_url="${upload_url}/${group}"
|
||||||
|
fi
|
||||||
|
upload_url="${upload_url}/upload"
|
||||||
|
|
||||||
|
shopt -s nullglob
|
||||||
|
found_rpm=0
|
||||||
|
for rpm in "$RELEASE_ASSETS_DIR"/*.rpm; do
|
||||||
|
case "$rpm" in
|
||||||
|
*.src.rpm)
|
||||||
|
continue
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
found_rpm=1
|
||||||
|
http_code="$(curl --silent --show-error \
|
||||||
|
--write-out '%{http_code}' \
|
||||||
|
--output /tmp/package-upload-response \
|
||||||
|
--user "${package_user}:${token}" \
|
||||||
|
--upload-file "$rpm" \
|
||||||
|
"$upload_url")"
|
||||||
|
|
||||||
|
case "$http_code" in
|
||||||
|
201)
|
||||||
|
echo "Uploaded $(basename "$rpm") to the RPM package registry."
|
||||||
|
;;
|
||||||
|
409)
|
||||||
|
echo "Package already exists for $(basename "$rpm"); skipping duplicate upload."
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Failed to upload $(basename "$rpm") to $upload_url (HTTP $http_code)." >&2
|
||||||
|
cat /tmp/package-upload-response >&2 || true
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
shopt -u nullglob
|
||||||
|
|
||||||
|
if [[ "$found_rpm" -ne 1 ]]; then
|
||||||
|
echo "No binary RPM artifacts were found to upload." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -14,7 +14,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
# Gitea's default act_runner labels map ubuntu-22.04 to node:16-bullseye,
|
# Gitea's default act_runner labels map ubuntu-latest to node:16-bullseye,
|
||||||
# so keep the GitHub-hosted actions on their Node 16-compatible v3 line.
|
# so keep the GitHub-hosted actions on their Node 16-compatible v3 line.
|
||||||
- name: Install system dependencies
|
- name: Install system dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -37,22 +37,105 @@ jobs:
|
|||||||
unzip -q /tmp/android-commandlinetools.zip -d /tmp/android-commandlinetools
|
unzip -q /tmp/android-commandlinetools.zip -d /tmp/android-commandlinetools
|
||||||
mv /tmp/android-commandlinetools/cmdline-tools "$ANDROID_SDK_ROOT/cmdline-tools/latest"
|
mv /tmp/android-commandlinetools/cmdline-tools "$ANDROID_SDK_ROOT/cmdline-tools/latest"
|
||||||
|
|
||||||
|
# sdkmanager exits successfully once it has consumed all input, which
|
||||||
|
# causes `yes` to receive SIGPIPE and return 141 under `pipefail`.
|
||||||
|
set +o pipefail
|
||||||
yes | "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" --sdk_root="$ANDROID_SDK_ROOT" --licenses
|
yes | "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" --sdk_root="$ANDROID_SDK_ROOT" --licenses
|
||||||
|
set -o pipefail
|
||||||
|
|
||||||
"$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" --sdk_root="$ANDROID_SDK_ROOT" \
|
"$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" --sdk_root="$ANDROID_SDK_ROOT" \
|
||||||
"platform-tools" \
|
"platform-tools" \
|
||||||
"build-tools;33.0.1" \
|
"build-tools;33.0.1" \
|
||||||
"platforms;android-33"
|
"platforms;android-33"
|
||||||
|
|
||||||
|
- name: Prepare Android signing config
|
||||||
|
env:
|
||||||
|
ANDROID_RELEASE_KEYSTORE_B64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_B64 }}
|
||||||
|
ORG_GRADLE_PROJECT_RELEASE_STORE_PASSWORD: ${{ secrets.ANDROID_RELEASE_STORE_PASSWORD }}
|
||||||
|
ORG_GRADLE_PROJECT_RELEASE_KEY_ALIAS: ${{ secrets.ANDROID_RELEASE_KEY_ALIAS }}
|
||||||
|
ORG_GRADLE_PROJECT_RELEASE_KEY_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEY_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
: "${ANDROID_RELEASE_KEYSTORE_B64:?Missing secret ANDROID_RELEASE_KEYSTORE_B64}"
|
||||||
|
: "${ORG_GRADLE_PROJECT_RELEASE_STORE_PASSWORD:?Missing secret ANDROID_RELEASE_STORE_PASSWORD}"
|
||||||
|
: "${ORG_GRADLE_PROJECT_RELEASE_KEY_ALIAS:?Missing secret ANDROID_RELEASE_KEY_ALIAS}"
|
||||||
|
: "${ORG_GRADLE_PROJECT_RELEASE_KEY_PASSWORD:?Missing secret ANDROID_RELEASE_KEY_PASSWORD}"
|
||||||
|
|
||||||
|
keystore_path="${{ gitea.workspace }}/android-release.keystore"
|
||||||
|
printf '%s' "$ANDROID_RELEASE_KEYSTORE_B64" | base64 -d > "$keystore_path"
|
||||||
|
chmod 600 "$keystore_path"
|
||||||
|
|
||||||
|
printf 'ORG_GRADLE_PROJECT_RELEASE_STORE_FILE=%s\n' "$keystore_path" >> "$GITHUB_ENV"
|
||||||
|
printf 'ORG_GRADLE_PROJECT_RELEASE_STORE_PASSWORD=%s\n' "$ORG_GRADLE_PROJECT_RELEASE_STORE_PASSWORD" >> "$GITHUB_ENV"
|
||||||
|
printf 'ORG_GRADLE_PROJECT_RELEASE_KEY_ALIAS=%s\n' "$ORG_GRADLE_PROJECT_RELEASE_KEY_ALIAS" >> "$GITHUB_ENV"
|
||||||
|
printf 'ORG_GRADLE_PROJECT_RELEASE_KEY_PASSWORD=%s\n' "$ORG_GRADLE_PROJECT_RELEASE_KEY_PASSWORD" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Build Android release APKs
|
- name: Build Android release APKs
|
||||||
working-directory: android
|
working-directory: android
|
||||||
run: ./gradlew assembleRelease
|
run: ./gradlew assembleRelease
|
||||||
|
|
||||||
- name: Upload release artifacts
|
- name: Prepare release assets
|
||||||
uses: actions/upload-artifact@v3
|
env:
|
||||||
|
RELEASE_TAG: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
version="${RELEASE_TAG#release/android/}"
|
||||||
|
if [ -z "$version" ] || [ "$version" = "$RELEASE_TAG" ]; then
|
||||||
|
echo "Expected tag in the form release/android/{version}, got: $RELEASE_TAG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
assets_dir="${{ gitea.workspace }}/release-assets/android"
|
||||||
|
rm -rf "$assets_dir"
|
||||||
|
mkdir -p "$assets_dir"
|
||||||
|
|
||||||
|
found_apk=0
|
||||||
|
for apk in android/app/build/outputs/apk/release/*.apk; do
|
||||||
|
if [ ! -e "$apk" ]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
found_apk=1
|
||||||
|
base="$(basename "$apk")"
|
||||||
|
|
||||||
|
case "$base" in
|
||||||
|
app-*-release*.apk)
|
||||||
|
arch="${base#app-}"
|
||||||
|
arch="${arch%%-release*}"
|
||||||
|
;;
|
||||||
|
app-release*.apk)
|
||||||
|
arch="universal"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unexpected APK filename: $base" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
cp "$apk" "$assets_dir/kordophone-${arch}-${version}.apk"
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$found_apk" -ne 1 ]; then
|
||||||
|
echo "No release APKs were produced." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
{
|
||||||
|
printf 'RELEASE_VERSION=%s\n' "$version"
|
||||||
|
printf 'RELEASE_ASSETS_DIR=%s\n' "$assets_dir"
|
||||||
|
} >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Create Gitea release
|
||||||
|
uses: https://gitea.com/actions/gitea-release-action@v1
|
||||||
with:
|
with:
|
||||||
name: kordophone-android-release
|
name: Kordophone Android ${{ env.RELEASE_VERSION }}
|
||||||
path: |
|
tag_name: ${{ github.ref_name }}
|
||||||
android/app/build/outputs/apk/release/*.apk
|
target_commitish: ${{ github.sha }}
|
||||||
android/app/build/outputs/apk/release/output-metadata.json
|
files: |
|
||||||
if-no-files-found: error
|
${{ env.RELEASE_ASSETS_DIR }}/*.apk
|
||||||
retention-days: 90
|
|
||||||
|
- name: Clean up signing material
|
||||||
|
if: ${{ always() }}
|
||||||
|
run: rm -f "${{ gitea.workspace }}/android-release.keystore"
|
||||||
|
|||||||
112
.gitea/workflows/core-rpm-release.yaml
Normal file
112
.gitea/workflows/core-rpm-release.yaml
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
name: Core RPM Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'release/core/*'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
code: read
|
||||||
|
releases: write
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
RPM_DISTRO_NAME: fedora
|
||||||
|
RPM_DISTRO_VERSION: '43'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-core-rpm-release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: fedora:43
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# Build inside Fedora so rpmbuild and generated dependencies match the
|
||||||
|
# Fedora release we publish for.
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
dnf install -y \
|
||||||
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
gcc \
|
||||||
|
gcc-c++ \
|
||||||
|
git \
|
||||||
|
make \
|
||||||
|
nodejs \
|
||||||
|
openssl-devel \
|
||||||
|
pkg-config \
|
||||||
|
python3 \
|
||||||
|
rpm-build \
|
||||||
|
sqlite-devel \
|
||||||
|
dbus-devel \
|
||||||
|
systemd-devel
|
||||||
|
dnf clean all
|
||||||
|
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||||
|
echo "/root/.cargo/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Install cargo-generate-rpm
|
||||||
|
run: cargo install cargo-generate-rpm
|
||||||
|
|
||||||
|
- name: Check out repository code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Build Core RPM
|
||||||
|
working-directory: core
|
||||||
|
run: make rpm
|
||||||
|
|
||||||
|
- name: Read Core Package Version
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
version="$(awk -F ' = ' '
|
||||||
|
$0 == "[package]" { in_pkg = 1; next }
|
||||||
|
/^\[/ { in_pkg = 0 }
|
||||||
|
in_pkg && $1 == "version" {
|
||||||
|
gsub(/"/, "", $2)
|
||||||
|
print $2
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
' core/kordophoned/Cargo.toml)"
|
||||||
|
if [ -z "$version" ]; then
|
||||||
|
echo "Could not determine kordophoned package version from core/kordophoned/Cargo.toml." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf 'CORE_PACKAGE_VERSION=%s\n' "$version" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Prepare release assets
|
||||||
|
env:
|
||||||
|
RELEASE_TAG: ${{ github.ref_name }}
|
||||||
|
TAG_PREFIX: release/core/
|
||||||
|
ASSETS_DIR: ${{ gitea.workspace }}/release-assets/core
|
||||||
|
RPM_BINARY_DIR: ${{ gitea.workspace }}/core/target/generate-rpm
|
||||||
|
RPM_BINARY_PATTERN_PREFIX: kordophoned-
|
||||||
|
RPM_PACKAGE_GROUP: ${{ vars.RPM_PACKAGE_GROUP }}
|
||||||
|
RPM_DISTRO_NAME: ${{ env.RPM_DISTRO_NAME }}
|
||||||
|
RPM_DISTRO_VERSION: ${{ env.RPM_DISTRO_VERSION }}
|
||||||
|
EXPECTED_VERSION: ${{ env.CORE_PACKAGE_VERSION }}
|
||||||
|
run: ./.gitea/scripts/prepare-rpm-release-assets.sh
|
||||||
|
|
||||||
|
- name: Upload RPMs to Gitea package registry
|
||||||
|
env:
|
||||||
|
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
||||||
|
GITEA_REPOSITORY_OWNER: ${{ gitea.repository_owner }}
|
||||||
|
RPM_PACKAGE_GROUP: ${{ env.RPM_PACKAGE_GROUP }}
|
||||||
|
RPM_PACKAGE_TOKEN: ${{ secrets.RPM_PACKAGE_TOKEN }}
|
||||||
|
RPM_PACKAGE_USERNAME: ${{ vars.RPM_PACKAGE_USERNAME }}
|
||||||
|
RELEASE_ASSETS_DIR: ${{ env.RELEASE_ASSETS_DIR }}
|
||||||
|
run: ./.gitea/scripts/upload-rpm-packages.sh
|
||||||
|
|
||||||
|
- name: Create Gitea release
|
||||||
|
uses: https://gitea.com/actions/gitea-release-action@v1
|
||||||
|
with:
|
||||||
|
name: Kordophoned Core ${{ env.RELEASE_VERSION }}
|
||||||
|
tag_name: ${{ github.ref_name }}
|
||||||
|
target_commitish: ${{ github.sha }}
|
||||||
|
files: |
|
||||||
|
${{ env.RELEASE_ASSETS_DIR }}/*.rpm
|
||||||
97
.gitea/workflows/gtk-rpm-release.yaml
Normal file
97
.gitea/workflows/gtk-rpm-release.yaml
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
name: GTK RPM Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'release/gtk/*'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
code: read
|
||||||
|
releases: write
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
RPM_DISTRO_NAME: fedora
|
||||||
|
RPM_DISTRO_VERSION: '43'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-gtk-rpm-release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: fedora:43
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# The default Gitea runner image is Debian-based. Build the GTK RPM in a
|
||||||
|
# Fedora container so rpmbuild and the RPM build dependencies match the
|
||||||
|
# existing local packaging environment.
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
dnf install -y \
|
||||||
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
gcc \
|
||||||
|
git \
|
||||||
|
ImageMagick \
|
||||||
|
libadwaita-devel \
|
||||||
|
libgee-devel \
|
||||||
|
libsecret-devel \
|
||||||
|
make \
|
||||||
|
meson \
|
||||||
|
ninja-build \
|
||||||
|
nodejs \
|
||||||
|
pkgconfig \
|
||||||
|
python3 \
|
||||||
|
redhat-rpm-config \
|
||||||
|
rpm-build \
|
||||||
|
rpmdevtools \
|
||||||
|
gtk4-devel \
|
||||||
|
glib2-devel \
|
||||||
|
vala
|
||||||
|
dnf clean all
|
||||||
|
rpmdev-setuptree
|
||||||
|
|
||||||
|
- name: Check out repository code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Build GTK RPMs
|
||||||
|
working-directory: gtk
|
||||||
|
run: make rpm
|
||||||
|
|
||||||
|
- name: Prepare release assets
|
||||||
|
env:
|
||||||
|
RELEASE_TAG: ${{ github.ref_name }}
|
||||||
|
TAG_PREFIX: release/gtk/
|
||||||
|
ASSETS_DIR: ${{ gitea.workspace }}/release-assets/gtk
|
||||||
|
RPM_BINARY_DIR: /root/rpmbuild/RPMS
|
||||||
|
RPM_BINARY_PATTERN_PREFIX: kordophone-
|
||||||
|
RPM_BINARY_EXCLUDE_PATTERNS: |
|
||||||
|
*-debuginfo-*
|
||||||
|
*-debugsource-*
|
||||||
|
RPM_PACKAGE_GROUP: ${{ vars.RPM_PACKAGE_GROUP }}
|
||||||
|
RPM_SOURCE_DIR: /root/rpmbuild/SRPMS
|
||||||
|
RPM_SOURCE_PATTERN_PREFIX: kordophone-
|
||||||
|
RPM_DISTRO_NAME: ${{ env.RPM_DISTRO_NAME }}
|
||||||
|
RPM_DISTRO_VERSION: ${{ env.RPM_DISTRO_VERSION }}
|
||||||
|
run: ./.gitea/scripts/prepare-rpm-release-assets.sh
|
||||||
|
|
||||||
|
- name: Upload RPMs to Gitea package registry
|
||||||
|
env:
|
||||||
|
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
||||||
|
GITEA_REPOSITORY_OWNER: ${{ gitea.repository_owner }}
|
||||||
|
RPM_PACKAGE_GROUP: ${{ env.RPM_PACKAGE_GROUP }}
|
||||||
|
RPM_PACKAGE_TOKEN: ${{ secrets.RPM_PACKAGE_TOKEN }}
|
||||||
|
RPM_PACKAGE_USERNAME: ${{ vars.RPM_PACKAGE_USERNAME }}
|
||||||
|
RELEASE_ASSETS_DIR: ${{ env.RELEASE_ASSETS_DIR }}
|
||||||
|
run: ./.gitea/scripts/upload-rpm-packages.sh
|
||||||
|
|
||||||
|
- name: Create Gitea release
|
||||||
|
uses: https://gitea.com/actions/gitea-release-action@v1
|
||||||
|
with:
|
||||||
|
name: Kordophone GTK ${{ env.RELEASE_VERSION }}
|
||||||
|
tag_name: ${{ github.ref_name }}
|
||||||
|
target_commitish: ${{ github.sha }}
|
||||||
|
files: |
|
||||||
|
${{ env.RELEASE_ASSETS_DIR }}/*.rpm
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
|||||||
|
.codex
|
||||||
ext/
|
ext/
|
||||||
target/
|
target/
|
||||||
|
|||||||
3
core/Cargo.lock
generated
3
core/Cargo.lock
generated
@@ -1274,7 +1274,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kordophoned"
|
name = "kordophoned"
|
||||||
version = "1.3.0"
|
version = "1.3.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -1313,6 +1313,7 @@ dependencies = [
|
|||||||
"block",
|
"block",
|
||||||
"dbus",
|
"dbus",
|
||||||
"dbus-codegen",
|
"dbus-codegen",
|
||||||
|
"keyring",
|
||||||
"log",
|
"log",
|
||||||
"xpc-connection",
|
"xpc-connection",
|
||||||
"xpc-connection-sys",
|
"xpc-connection-sys",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM fedora:40
|
FROM fedora:43
|
||||||
|
|
||||||
RUN dnf update -y && \
|
RUN dnf update -y && \
|
||||||
dnf install -y \
|
dnf install -y \
|
||||||
@@ -23,4 +23,3 @@ WORKDIR /workspace
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
CMD ["make", "rpm"]
|
CMD ["make", "rpm"]
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ log = "0.4.22"
|
|||||||
# D-Bus dependencies only on Linux
|
# D-Bus dependencies only on Linux
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
dbus = "0.9.7"
|
dbus = "0.9.7"
|
||||||
|
keyring = { version = "3.6.3", features = ["sync-secret-service"] }
|
||||||
|
|
||||||
# D-Bus codegen only on Linux
|
# D-Bus codegen only on Linux
|
||||||
[target.'cfg(target_os = "linux")'.build-dependencies]
|
[target.'cfg(target_os = "linux")'.build-dependencies]
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
mod platform;
|
mod platform;
|
||||||
mod worker;
|
mod worker;
|
||||||
|
|
||||||
pub use worker::{spawn_worker, ChatMessage, ConversationSummary, Event, Request};
|
pub use worker::{
|
||||||
|
spawn_worker, Attachment, AttachmentInfo, AttachmentMetadata, AttributionInfo, ChatMessage,
|
||||||
|
ConversationSummary, Event, Request, SettingsSnapshot,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
#![cfg(target_os = "linux")]
|
#![cfg(target_os = "linux")]
|
||||||
|
|
||||||
use crate::worker::{ChatMessage, ConversationSummary, DaemonClient, Event};
|
use crate::worker::{
|
||||||
|
Attachment, AttachmentInfo, AttachmentMetadata, AttributionInfo, ChatMessage,
|
||||||
|
ConversationSummary, DaemonClient, Event, SettingsSnapshot,
|
||||||
|
};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use dbus::arg::{PropMap, RefArg};
|
use dbus::arg::{cast, PropMap, RefArg};
|
||||||
use dbus::blocking::{Connection, Proxy};
|
use dbus::blocking::{Connection, Proxy};
|
||||||
use dbus::channel::Token;
|
use dbus::channel::Token;
|
||||||
use std::sync::mpsc::Sender;
|
use std::sync::mpsc::Sender;
|
||||||
@@ -17,6 +20,7 @@ mod dbus_interface {
|
|||||||
include!(concat!(env!("OUT_DIR"), "/kordophone-client.rs"));
|
include!(concat!(env!("OUT_DIR"), "/kordophone-client.rs"));
|
||||||
}
|
}
|
||||||
use dbus_interface::NetBuzzertKordophoneRepository as KordophoneRepository;
|
use dbus_interface::NetBuzzertKordophoneRepository as KordophoneRepository;
|
||||||
|
use dbus_interface::NetBuzzertKordophoneSettings as KordophoneSettings;
|
||||||
|
|
||||||
pub(crate) struct DBusClient {
|
pub(crate) struct DBusClient {
|
||||||
conn: Connection,
|
conn: Connection,
|
||||||
@@ -31,7 +35,7 @@ impl DBusClient {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn proxy(&self) -> Proxy<&Connection> {
|
fn proxy(&self) -> Proxy<'_, &Connection> {
|
||||||
self.conn
|
self.conn
|
||||||
.with_proxy(DBUS_NAME, DBUS_PATH, std::time::Duration::from_millis(5000))
|
.with_proxy(DBUS_NAME, DBUS_PATH, std::time::Duration::from_millis(5000))
|
||||||
}
|
}
|
||||||
@@ -52,6 +56,10 @@ fn get_u32(map: &PropMap, key: &str) -> u32 {
|
|||||||
get_i64(map, key).try_into().unwrap_or(0)
|
get_i64(map, key).try_into().unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_bool(map: &PropMap, key: &str) -> bool {
|
||||||
|
map.get(key).and_then(|v| v.0.as_i64()).unwrap_or(0) != 0
|
||||||
|
}
|
||||||
|
|
||||||
fn get_vec_string(map: &PropMap, key: &str) -> Vec<String> {
|
fn get_vec_string(map: &PropMap, key: &str) -> Vec<String> {
|
||||||
map.get(key)
|
map.get(key)
|
||||||
.and_then(|v| v.0.as_iter())
|
.and_then(|v| v.0.as_iter())
|
||||||
@@ -62,6 +70,122 @@ fn get_vec_string(map: &PropMap, key: &str) -> Vec<String> {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn clone_prop_map(map: &PropMap) -> PropMap {
|
||||||
|
map.iter()
|
||||||
|
.map(|(key, value)| (key.clone(), dbus::arg::Variant(value.0.box_clone())))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_prop_map(map: &PropMap, key: &str) -> Option<PropMap> {
|
||||||
|
let value = map.get(key)?;
|
||||||
|
|
||||||
|
if let Some(prop_map) = cast::<PropMap>(&value.0) {
|
||||||
|
return Some(clone_prop_map(prop_map));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut iter = value.0.as_iter()?;
|
||||||
|
let mut out = PropMap::new();
|
||||||
|
loop {
|
||||||
|
let Some(key) = iter.next().and_then(|arg| arg.as_str()) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let Some(value) = iter.next() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
|
||||||
|
out.insert(key.to_string(), dbus::arg::Variant(value.box_clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_vec_prop_map(map: &PropMap, key: &str) -> Vec<PropMap> {
|
||||||
|
let Some(value) = map.get(key) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(items) = cast::<Vec<PropMap>>(&value.0) {
|
||||||
|
return items.iter().map(clone_prop_map).collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
value
|
||||||
|
.0
|
||||||
|
.as_iter()
|
||||||
|
.map(|iter| {
|
||||||
|
iter.filter_map(|item| {
|
||||||
|
let mut item_iter = item.as_iter()?;
|
||||||
|
let mut out = PropMap::new();
|
||||||
|
loop {
|
||||||
|
let Some(key) = item_iter.next().and_then(|arg| arg.as_str()) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let Some(value) = item_iter.next() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
|
||||||
|
out.insert(key.to_string(), dbus::arg::Variant(value.box_clone()));
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attachment_from_prop_map(map: PropMap) -> Attachment {
|
||||||
|
let metadata = get_prop_map(&map, "metadata").map(|metadata_map| {
|
||||||
|
let attribution_info =
|
||||||
|
get_prop_map(&metadata_map, "attribution_info").map(|info_map| AttributionInfo {
|
||||||
|
width: info_map
|
||||||
|
.get("width")
|
||||||
|
.and_then(|v| v.0.as_i64())
|
||||||
|
.and_then(|v| v.try_into().ok()),
|
||||||
|
height: info_map
|
||||||
|
.get("height")
|
||||||
|
.and_then(|v| v.0.as_i64())
|
||||||
|
.and_then(|v| v.try_into().ok()),
|
||||||
|
});
|
||||||
|
|
||||||
|
AttachmentMetadata { attribution_info }
|
||||||
|
});
|
||||||
|
|
||||||
|
Attachment {
|
||||||
|
guid: get_string(&map, "guid"),
|
||||||
|
downloaded: get_bool(&map, "downloaded"),
|
||||||
|
preview_downloaded: get_bool(&map, "preview_downloaded"),
|
||||||
|
metadata,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn password_for_username(username: &str) -> Result<String> {
|
||||||
|
if username.trim().is_empty() {
|
||||||
|
return Ok(String::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let entry = keyring::Entry::new("net.buzzert.kordophonecd", username)?;
|
||||||
|
match entry.get_password() {
|
||||||
|
Ok(password) => Ok(password),
|
||||||
|
Err(keyring::Error::NoEntry) => Ok(String::new()),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_password_for_username(username: &str, password: &str) -> Result<()> {
|
||||||
|
if username.trim().is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let entry = keyring::Entry::new("net.buzzert.kordophonecd", username)?;
|
||||||
|
if password.is_empty() {
|
||||||
|
match entry.delete_credential() {
|
||||||
|
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
entry.set_password(password).map_err(Into::into)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl DaemonClient for DBusClient {
|
impl DaemonClient for DBusClient {
|
||||||
fn get_conversations(&mut self, limit: i32, offset: i32) -> Result<Vec<ConversationSummary>> {
|
fn get_conversations(&mut self, limit: i32, offset: i32) -> Result<Vec<ConversationSummary>> {
|
||||||
let mut items = KordophoneRepository::get_conversations(&self.proxy(), limit, offset)?;
|
let mut items = KordophoneRepository::get_conversations(&self.proxy(), limit, offset)?;
|
||||||
@@ -83,6 +207,7 @@ impl DaemonClient for DBusClient {
|
|||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
preview: get_string(&conv, "last_message_preview").replace('\n', " "),
|
preview: get_string(&conv, "last_message_preview").replace('\n', " "),
|
||||||
|
participants,
|
||||||
unread_count: get_u32(&conv, "unread_count"),
|
unread_count: get_u32(&conv, "unread_count"),
|
||||||
date_unix: get_i64(&conv, "date"),
|
date_unix: get_i64(&conv, "date"),
|
||||||
}
|
}
|
||||||
@@ -107,20 +232,37 @@ impl DaemonClient for DBusClient {
|
|||||||
Ok(messages
|
Ok(messages
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|msg| ChatMessage {
|
.map(|msg| ChatMessage {
|
||||||
|
id: get_string(&msg, "id"),
|
||||||
sender: get_string(&msg, "sender"),
|
sender: get_string(&msg, "sender"),
|
||||||
text: get_string(&msg, "text"),
|
text: get_string(&msg, "text"),
|
||||||
date_unix: get_i64(&msg, "date"),
|
date_unix: get_i64(&msg, "date"),
|
||||||
|
attachments: get_vec_prop_map(&msg, "attachments")
|
||||||
|
.into_iter()
|
||||||
|
.map(attachment_from_prop_map)
|
||||||
|
.collect(),
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send_message(&mut self, conversation_id: String, text: String) -> Result<Option<String>> {
|
fn send_message(&mut self, conversation_id: String, text: String) -> Result<Option<String>> {
|
||||||
let attachment_guids: Vec<&str> = vec![];
|
self.send_message_with_attachments(conversation_id, text, Vec::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_message_with_attachments(
|
||||||
|
&mut self,
|
||||||
|
conversation_id: String,
|
||||||
|
text: String,
|
||||||
|
attachment_guids: Vec<String>,
|
||||||
|
) -> Result<Option<String>> {
|
||||||
|
let attachment_guid_refs = attachment_guids
|
||||||
|
.iter()
|
||||||
|
.map(String::as_str)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
let outgoing_id = KordophoneRepository::send_message(
|
let outgoing_id = KordophoneRepository::send_message(
|
||||||
&self.proxy(),
|
&self.proxy(),
|
||||||
&conversation_id,
|
&conversation_id,
|
||||||
&text,
|
&text,
|
||||||
attachment_guids,
|
attachment_guid_refs,
|
||||||
)?;
|
)?;
|
||||||
Ok(Some(outgoing_id))
|
Ok(Some(outgoing_id))
|
||||||
}
|
}
|
||||||
@@ -135,6 +277,61 @@ impl DaemonClient for DBusClient {
|
|||||||
.map_err(|e| anyhow::anyhow!("Failed to sync conversation: {e}"))
|
.map_err(|e| anyhow::anyhow!("Failed to sync conversation: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sync_conversation_list(&mut self) -> Result<()> {
|
||||||
|
KordophoneRepository::sync_conversation_list(&self.proxy())
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to sync conversation list: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_all_conversations(&mut self) -> Result<()> {
|
||||||
|
KordophoneRepository::sync_all_conversations(&self.proxy())
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to sync all conversations: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download_attachment(&mut self, attachment_guid: String, preview: bool) -> Result<()> {
|
||||||
|
KordophoneRepository::download_attachment(&self.proxy(), &attachment_guid, preview)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to download attachment: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_attachment_info(&mut self, attachment_guid: String) -> Result<AttachmentInfo> {
|
||||||
|
let (path, preview_path, downloaded, preview_downloaded) =
|
||||||
|
KordophoneRepository::get_attachment_info(&self.proxy(), &attachment_guid)?;
|
||||||
|
|
||||||
|
Ok(AttachmentInfo {
|
||||||
|
path,
|
||||||
|
preview_path,
|
||||||
|
downloaded,
|
||||||
|
preview_downloaded,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upload_attachment(&mut self, path: String) -> Result<String> {
|
||||||
|
KordophoneRepository::upload_attachment(&self.proxy(), &path)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to upload attachment: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_settings(&mut self) -> Result<SettingsSnapshot> {
|
||||||
|
let server_url = KordophoneSettings::server_url(&self.proxy()).unwrap_or_default();
|
||||||
|
let username = KordophoneSettings::username(&self.proxy()).unwrap_or_default();
|
||||||
|
let password = password_for_username(&username)?;
|
||||||
|
|
||||||
|
Ok(SettingsSnapshot {
|
||||||
|
server_url,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_settings(
|
||||||
|
&mut self,
|
||||||
|
server_url: String,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
) -> Result<()> {
|
||||||
|
KordophoneSettings::set_server(&self.proxy(), &server_url, &username)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to save daemon settings: {e}"))?;
|
||||||
|
set_password_for_username(&username, &password)
|
||||||
|
}
|
||||||
|
|
||||||
fn install_signal_handlers(&mut self, event_tx: Sender<Event>) -> Result<()> {
|
fn install_signal_handlers(&mut self, event_tx: Sender<Event>) -> Result<()> {
|
||||||
let conversations_tx = event_tx.clone();
|
let conversations_tx = event_tx.clone();
|
||||||
let t1 = self
|
let t1 = self
|
||||||
@@ -164,7 +361,7 @@ impl DaemonClient for DBusClient {
|
|||||||
)
|
)
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to match MessagesUpdated: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("Failed to match MessagesUpdated: {e}"))?;
|
||||||
|
|
||||||
let reconnected_tx = event_tx;
|
let reconnected_tx = event_tx.clone();
|
||||||
let t3 = self
|
let t3 = self
|
||||||
.proxy()
|
.proxy()
|
||||||
.match_signal(
|
.match_signal(
|
||||||
@@ -177,7 +374,54 @@ impl DaemonClient for DBusClient {
|
|||||||
)
|
)
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to match UpdateStreamReconnected: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("Failed to match UpdateStreamReconnected: {e}"))?;
|
||||||
|
|
||||||
self.signal_tokens.extend([t1, t2, t3]);
|
let download_tx = event_tx.clone();
|
||||||
|
let t4 = self
|
||||||
|
.proxy()
|
||||||
|
.match_signal(
|
||||||
|
move |s: dbus_interface::NetBuzzertKordophoneRepositoryAttachmentDownloadCompleted,
|
||||||
|
_: &Connection,
|
||||||
|
_: &dbus::message::Message| {
|
||||||
|
let _ = download_tx.send(Event::AttachmentDownloaded {
|
||||||
|
attachment_guid: s.attachment_id,
|
||||||
|
});
|
||||||
|
true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to match AttachmentDownloadCompleted: {e}"))?;
|
||||||
|
|
||||||
|
let failed_tx = event_tx.clone();
|
||||||
|
let t5 = self
|
||||||
|
.proxy()
|
||||||
|
.match_signal(
|
||||||
|
move |s: dbus_interface::NetBuzzertKordophoneRepositoryAttachmentDownloadFailed,
|
||||||
|
_: &Connection,
|
||||||
|
_: &dbus::message::Message| {
|
||||||
|
let _ = failed_tx.send(Event::AttachmentDownloadFailed {
|
||||||
|
attachment_guid: s.attachment_id,
|
||||||
|
error: s.error_message,
|
||||||
|
});
|
||||||
|
true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to match AttachmentDownloadFailed: {e}"))?;
|
||||||
|
|
||||||
|
let upload_tx = event_tx;
|
||||||
|
let t6 = self
|
||||||
|
.proxy()
|
||||||
|
.match_signal(
|
||||||
|
move |s: dbus_interface::NetBuzzertKordophoneRepositoryAttachmentUploadCompleted,
|
||||||
|
_: &Connection,
|
||||||
|
_: &dbus::message::Message| {
|
||||||
|
let _ = upload_tx.send(Event::AttachmentUploaded {
|
||||||
|
upload_guid: s.upload_guid,
|
||||||
|
attachment_guid: s.attachment_guid,
|
||||||
|
});
|
||||||
|
true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to match AttachmentUploadCompleted: {e}"))?;
|
||||||
|
|
||||||
|
self.signal_tokens.extend([t1, t2, t3, t4, t5, t6]);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,4 +430,3 @@ impl DaemonClient for DBusClient {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ impl DaemonClient for XpcClient {
|
|||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
preview: preview.replace('\n', " "),
|
preview: preview.replace('\n', " "),
|
||||||
|
participants,
|
||||||
unread_count,
|
unread_count,
|
||||||
date_unix,
|
date_unix,
|
||||||
});
|
});
|
||||||
@@ -180,9 +181,11 @@ impl DaemonClient for XpcClient {
|
|||||||
for item in items {
|
for item in items {
|
||||||
let Message::Dictionary(msg) = item else { continue };
|
let Message::Dictionary(msg) = item else { continue };
|
||||||
messages.push(ChatMessage {
|
messages.push(ChatMessage {
|
||||||
|
id: Self::get_string(msg, "id").unwrap_or_default(),
|
||||||
sender: Self::get_string(msg, "sender").unwrap_or_default(),
|
sender: Self::get_string(msg, "sender").unwrap_or_default(),
|
||||||
text: Self::get_string(msg, "text").unwrap_or_default(),
|
text: Self::get_string(msg, "text").unwrap_or_default(),
|
||||||
date_unix: Self::get_i64_from_str(msg, "date"),
|
date_unix: Self::get_i64_from_str(msg, "date"),
|
||||||
|
attachments: Vec::new(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(messages)
|
Ok(messages)
|
||||||
@@ -230,4 +233,3 @@ impl DaemonClient for XpcClient {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,23 +8,98 @@ pub struct ConversationSummary {
|
|||||||
pub id: String,
|
pub id: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub preview: String,
|
pub preview: String,
|
||||||
|
pub participants: Vec<String>,
|
||||||
pub unread_count: u32,
|
pub unread_count: u32,
|
||||||
pub date_unix: i64,
|
pub date_unix: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct ChatMessage {
|
pub struct ChatMessage {
|
||||||
|
pub id: String,
|
||||||
pub sender: String,
|
pub sender: String,
|
||||||
pub text: String,
|
pub text: String,
|
||||||
pub date_unix: i64,
|
pub date_unix: i64,
|
||||||
|
pub attachments: Vec<Attachment>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChatMessage {
|
||||||
|
pub fn from_me(&self) -> bool {
|
||||||
|
self.sender == "(Me)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct AttributionInfo {
|
||||||
|
pub width: Option<u32>,
|
||||||
|
pub height: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct AttachmentMetadata {
|
||||||
|
pub attribution_info: Option<AttributionInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct Attachment {
|
||||||
|
pub guid: String,
|
||||||
|
pub downloaded: bool,
|
||||||
|
pub preview_downloaded: bool,
|
||||||
|
pub metadata: Option<AttachmentMetadata>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct AttachmentInfo {
|
||||||
|
pub path: String,
|
||||||
|
pub preview_path: String,
|
||||||
|
pub downloaded: bool,
|
||||||
|
pub preview_downloaded: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct SettingsSnapshot {
|
||||||
|
pub server_url: String,
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum Request {
|
pub enum Request {
|
||||||
RefreshConversations,
|
RefreshConversations,
|
||||||
RefreshMessages { conversation_id: String },
|
RefreshMessages {
|
||||||
SendMessage { conversation_id: String, text: String },
|
conversation_id: String,
|
||||||
MarkRead { conversation_id: String },
|
},
|
||||||
SyncConversation { conversation_id: String },
|
SendMessage {
|
||||||
|
conversation_id: String,
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
SendMessageWithAttachments {
|
||||||
|
conversation_id: String,
|
||||||
|
text: String,
|
||||||
|
attachment_guids: Vec<String>,
|
||||||
|
},
|
||||||
|
MarkRead {
|
||||||
|
conversation_id: String,
|
||||||
|
},
|
||||||
|
SyncConversation {
|
||||||
|
conversation_id: String,
|
||||||
|
},
|
||||||
|
SyncConversationList,
|
||||||
|
SyncAllConversations,
|
||||||
|
DownloadAttachment {
|
||||||
|
attachment_guid: String,
|
||||||
|
preview: bool,
|
||||||
|
},
|
||||||
|
GetAttachmentInfo {
|
||||||
|
attachment_guid: String,
|
||||||
|
},
|
||||||
|
UploadAttachment {
|
||||||
|
path: String,
|
||||||
|
},
|
||||||
|
LoadSettings,
|
||||||
|
SaveSettings {
|
||||||
|
server_url: String,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum Event {
|
pub enum Event {
|
||||||
@@ -38,9 +113,40 @@ pub enum Event {
|
|||||||
outgoing_id: Option<String>,
|
outgoing_id: Option<String>,
|
||||||
},
|
},
|
||||||
MarkedRead,
|
MarkedRead,
|
||||||
ConversationSyncTriggered { conversation_id: String },
|
ConversationSyncTriggered {
|
||||||
|
conversation_id: String,
|
||||||
|
},
|
||||||
|
ConversationListSyncTriggered,
|
||||||
|
AllConversationsSyncTriggered,
|
||||||
|
AttachmentDownloadQueued {
|
||||||
|
attachment_guid: String,
|
||||||
|
preview: bool,
|
||||||
|
},
|
||||||
|
AttachmentDownloaded {
|
||||||
|
attachment_guid: String,
|
||||||
|
},
|
||||||
|
AttachmentDownloadFailed {
|
||||||
|
attachment_guid: String,
|
||||||
|
error: String,
|
||||||
|
},
|
||||||
|
AttachmentUploaded {
|
||||||
|
upload_guid: String,
|
||||||
|
attachment_guid: String,
|
||||||
|
},
|
||||||
|
AttachmentUploadQueued {
|
||||||
|
upload_guid: String,
|
||||||
|
path: String,
|
||||||
|
},
|
||||||
|
AttachmentInfo {
|
||||||
|
attachment_guid: String,
|
||||||
|
info: AttachmentInfo,
|
||||||
|
},
|
||||||
|
SettingsLoaded(SettingsSnapshot),
|
||||||
|
SettingsSaved,
|
||||||
ConversationsUpdated,
|
ConversationsUpdated,
|
||||||
MessagesUpdated { conversation_id: String },
|
MessagesUpdated {
|
||||||
|
conversation_id: String,
|
||||||
|
},
|
||||||
UpdateStreamReconnected,
|
UpdateStreamReconnected,
|
||||||
Error(String),
|
Error(String),
|
||||||
}
|
}
|
||||||
@@ -59,16 +165,18 @@ pub fn spawn_worker(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = client.install_signal_handlers(event_tx.clone()) {
|
if let Err(e) = client.install_signal_handlers(event_tx.clone()) {
|
||||||
let _ = event_tx.send(Event::Error(format!("Failed to install daemon signals: {e}")));
|
let _ = event_tx.send(Event::Error(format!(
|
||||||
|
"Failed to install daemon signals: {e}"
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match request_rx.recv_timeout(Duration::from_millis(100)) {
|
match request_rx.recv_timeout(Duration::from_millis(100)) {
|
||||||
Ok(req) => {
|
Ok(req) => {
|
||||||
let res = match req {
|
let res = match req {
|
||||||
Request::RefreshConversations => client
|
Request::RefreshConversations => {
|
||||||
.get_conversations(200, 0)
|
client.get_conversations(200, 0).map(Event::Conversations)
|
||||||
.map(Event::Conversations),
|
}
|
||||||
Request::RefreshMessages { conversation_id } => client
|
Request::RefreshMessages { conversation_id } => client
|
||||||
.get_messages(conversation_id.clone(), None)
|
.get_messages(conversation_id.clone(), None)
|
||||||
.map(|messages| Event::Messages {
|
.map(|messages| Event::Messages {
|
||||||
@@ -78,8 +186,24 @@ pub fn spawn_worker(
|
|||||||
Request::SendMessage {
|
Request::SendMessage {
|
||||||
conversation_id,
|
conversation_id,
|
||||||
text,
|
text,
|
||||||
} => client
|
} => {
|
||||||
|
client
|
||||||
.send_message(conversation_id.clone(), text)
|
.send_message(conversation_id.clone(), text)
|
||||||
|
.map(|outgoing_id| Event::MessageSent {
|
||||||
|
conversation_id,
|
||||||
|
outgoing_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Request::SendMessageWithAttachments {
|
||||||
|
conversation_id,
|
||||||
|
text,
|
||||||
|
attachment_guids,
|
||||||
|
} => client
|
||||||
|
.send_message_with_attachments(
|
||||||
|
conversation_id.clone(),
|
||||||
|
text,
|
||||||
|
attachment_guids,
|
||||||
|
)
|
||||||
.map(|outgoing_id| Event::MessageSent {
|
.map(|outgoing_id| Event::MessageSent {
|
||||||
conversation_id,
|
conversation_id,
|
||||||
outgoing_id,
|
outgoing_id,
|
||||||
@@ -90,6 +214,38 @@ pub fn spawn_worker(
|
|||||||
Request::SyncConversation { conversation_id } => client
|
Request::SyncConversation { conversation_id } => client
|
||||||
.sync_conversation(conversation_id.clone())
|
.sync_conversation(conversation_id.clone())
|
||||||
.map(|_| Event::ConversationSyncTriggered { conversation_id }),
|
.map(|_| Event::ConversationSyncTriggered { conversation_id }),
|
||||||
|
Request::SyncConversationList => client
|
||||||
|
.sync_conversation_list()
|
||||||
|
.map(|_| Event::ConversationListSyncTriggered),
|
||||||
|
Request::SyncAllConversations => client
|
||||||
|
.sync_all_conversations()
|
||||||
|
.map(|_| Event::AllConversationsSyncTriggered),
|
||||||
|
Request::DownloadAttachment {
|
||||||
|
attachment_guid,
|
||||||
|
preview,
|
||||||
|
} => client
|
||||||
|
.download_attachment(attachment_guid.clone(), preview)
|
||||||
|
.map(|_| Event::AttachmentDownloadQueued {
|
||||||
|
attachment_guid,
|
||||||
|
preview,
|
||||||
|
}),
|
||||||
|
Request::GetAttachmentInfo { attachment_guid } => client
|
||||||
|
.get_attachment_info(attachment_guid.clone())
|
||||||
|
.map(|info| Event::AttachmentInfo {
|
||||||
|
attachment_guid,
|
||||||
|
info,
|
||||||
|
}),
|
||||||
|
Request::UploadAttachment { path } => client
|
||||||
|
.upload_attachment(path.clone())
|
||||||
|
.map(|upload_guid| Event::AttachmentUploadQueued { upload_guid, path }),
|
||||||
|
Request::LoadSettings => client.get_settings().map(Event::SettingsLoaded),
|
||||||
|
Request::SaveSettings {
|
||||||
|
server_url,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
} => client
|
||||||
|
.save_settings(server_url, username, password)
|
||||||
|
.map(|_| Event::SettingsSaved),
|
||||||
};
|
};
|
||||||
|
|
||||||
match res {
|
match res {
|
||||||
@@ -120,8 +276,43 @@ pub(crate) trait DaemonClient {
|
|||||||
last_message_id: Option<String>,
|
last_message_id: Option<String>,
|
||||||
) -> Result<Vec<ChatMessage>>;
|
) -> Result<Vec<ChatMessage>>;
|
||||||
fn send_message(&mut self, conversation_id: String, text: String) -> Result<Option<String>>;
|
fn send_message(&mut self, conversation_id: String, text: String) -> Result<Option<String>>;
|
||||||
|
fn send_message_with_attachments(
|
||||||
|
&mut self,
|
||||||
|
conversation_id: String,
|
||||||
|
text: String,
|
||||||
|
attachment_guids: Vec<String>,
|
||||||
|
) -> Result<Option<String>> {
|
||||||
|
let _ = attachment_guids;
|
||||||
|
self.send_message(conversation_id, text)
|
||||||
|
}
|
||||||
fn mark_conversation_as_read(&mut self, conversation_id: String) -> Result<()>;
|
fn mark_conversation_as_read(&mut self, conversation_id: String) -> Result<()>;
|
||||||
fn sync_conversation(&mut self, conversation_id: String) -> Result<()>;
|
fn sync_conversation(&mut self, conversation_id: String) -> Result<()>;
|
||||||
|
fn sync_conversation_list(&mut self) -> Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn sync_all_conversations(&mut self) -> Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn download_attachment(&mut self, _attachment_guid: String, _preview: bool) -> Result<()> {
|
||||||
|
anyhow::bail!("Attachment downloads are not supported on this platform")
|
||||||
|
}
|
||||||
|
fn get_attachment_info(&mut self, _attachment_guid: String) -> Result<AttachmentInfo> {
|
||||||
|
anyhow::bail!("Attachment info is not supported on this platform")
|
||||||
|
}
|
||||||
|
fn upload_attachment(&mut self, _path: String) -> Result<String> {
|
||||||
|
anyhow::bail!("Attachment uploads are not supported on this platform")
|
||||||
|
}
|
||||||
|
fn get_settings(&mut self) -> Result<SettingsSnapshot> {
|
||||||
|
anyhow::bail!("Settings are not supported on this platform")
|
||||||
|
}
|
||||||
|
fn save_settings(
|
||||||
|
&mut self,
|
||||||
|
_server_url: String,
|
||||||
|
_username: String,
|
||||||
|
_password: String,
|
||||||
|
) -> Result<()> {
|
||||||
|
anyhow::bail!("Settings are not supported on this platform")
|
||||||
|
}
|
||||||
fn install_signal_handlers(&mut self, _event_tx: mpsc::Sender<Event>) -> Result<()> {
|
fn install_signal_handlers(&mut self, _event_tx: mpsc::Sender<Event>) -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -130,4 +321,3 @@ pub(crate) trait DaemonClient {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "kordophoned"
|
name = "kordophoned"
|
||||||
version = "1.3.0"
|
version = "1.3.3"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "GPL-3.0"
|
license = "GPL-3.0"
|
||||||
description = "Client daemon for the Kordophone chat protocol"
|
description = "Client daemon for the Kordophone chat protocol"
|
||||||
|
|||||||
2
cosmic/.gitignore
vendored
Normal file
2
cosmic/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/target/
|
||||||
|
/screenshots/
|
||||||
6879
cosmic/Cargo.lock
generated
Normal file
6879
cosmic/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
cosmic/Cargo.toml
Normal file
29
cosmic/Cargo.toml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
[package]
|
||||||
|
name = "kordophone-cosmic"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
rust-version = "1.93"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1"
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
|
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||||
|
kordophoned-client = { path = "../core/kordophoned-client" }
|
||||||
|
open = "5"
|
||||||
|
regex = "1"
|
||||||
|
tokio = { version = "1", features = ["rt"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
url = "2"
|
||||||
|
|
||||||
|
[dependencies.libcosmic]
|
||||||
|
path = "/home/buzzert/src/cosmic/libcosmic"
|
||||||
|
default-features = false
|
||||||
|
features = [
|
||||||
|
"advanced-shaping",
|
||||||
|
"multi-window",
|
||||||
|
"tokio",
|
||||||
|
"winit",
|
||||||
|
"wgpu",
|
||||||
|
"xdg-portal",
|
||||||
|
]
|
||||||
1041
cosmic/src/app.rs
Normal file
1041
cosmic/src/app.rs
Normal file
File diff suppressed because it is too large
Load Diff
21
cosmic/src/main.rs
Normal file
21
cosmic/src/main.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
mod app;
|
||||||
|
mod transcript;
|
||||||
|
|
||||||
|
use app::{App, Flags};
|
||||||
|
use cosmic::app::Settings;
|
||||||
|
use cosmic::iced::Size;
|
||||||
|
|
||||||
|
fn main() -> anyhow::Result<()> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_timer(tracing_subscriber::fmt::time::uptime())
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| "warn,kordophone_cosmic=info".into()),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let flags = Flags::from_env();
|
||||||
|
let settings = Settings::default().size(Size::new(1120.0, 780.0));
|
||||||
|
cosmic::app::run::<App>(settings, flags)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
633
cosmic/src/transcript.rs
Normal file
633
cosmic/src/transcript.rs
Normal file
@@ -0,0 +1,633 @@
|
|||||||
|
use chrono::{Local, TimeZone};
|
||||||
|
use cosmic::iced::advanced::image::{self, Renderer as ImageRenderer};
|
||||||
|
use cosmic::iced::advanced::layout::{self, Layout};
|
||||||
|
use cosmic::iced::advanced::renderer;
|
||||||
|
use cosmic::iced::advanced::text;
|
||||||
|
use cosmic::iced::advanced::text::{Paragraph as _, Renderer as _};
|
||||||
|
use cosmic::iced::advanced::widget::{self, Widget};
|
||||||
|
use cosmic::iced::advanced::{Clipboard, Renderer as _, Shell};
|
||||||
|
use cosmic::iced::border;
|
||||||
|
use cosmic::iced::mouse;
|
||||||
|
use cosmic::iced::{
|
||||||
|
Background, Color, Event, Length, Pixels, Point, Rectangle, Size, Vector, alignment,
|
||||||
|
};
|
||||||
|
use cosmic::{Element, Renderer, Theme};
|
||||||
|
use regex::Regex;
|
||||||
|
use std::collections::hash_map::DefaultHasher;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
use std::ops::Range;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use std::time::Instant;
|
||||||
|
use tracing::Level;
|
||||||
|
|
||||||
|
const BUBBLE_MARGIN: f32 = 24.0;
|
||||||
|
const MAX_BUBBLE_WIDTH: f32 = 0.74;
|
||||||
|
const IMAGE_WIDTH_FACTOR: f32 = 0.70;
|
||||||
|
const RADIUS: f32 = 16.0;
|
||||||
|
const TEXT_X_PADDING: f32 = 14.0;
|
||||||
|
const TEXT_Y_PADDING: f32 = 8.0;
|
||||||
|
const DATE_PADDING: f32 = 36.0;
|
||||||
|
const SENDER_PADDING: f32 = 6.0;
|
||||||
|
const TRANSCRIPT_TOP_PADDING: f32 = 10.0;
|
||||||
|
|
||||||
|
type Paragraph = <Renderer as text::Renderer>::Paragraph;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Hash)]
|
||||||
|
pub struct TranscriptMessage {
|
||||||
|
pub id: String,
|
||||||
|
pub sender: String,
|
||||||
|
pub text: String,
|
||||||
|
pub date_unix: i64,
|
||||||
|
pub from_me: bool,
|
||||||
|
pub should_animate: bool,
|
||||||
|
pub attachments: Vec<TranscriptAttachment>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Hash)]
|
||||||
|
pub struct TranscriptAttachment {
|
||||||
|
pub guid: String,
|
||||||
|
pub preview_path: Option<String>,
|
||||||
|
pub downloaded: bool,
|
||||||
|
pub preview_downloaded: bool,
|
||||||
|
pub width: Option<u32>,
|
||||||
|
pub height: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum TranscriptAction {
|
||||||
|
CopyText(String),
|
||||||
|
OpenUrl(String),
|
||||||
|
OpenAttachment(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Transcript<'a, Message> {
|
||||||
|
messages: Vec<TranscriptMessage>,
|
||||||
|
show_sender: bool,
|
||||||
|
fingerprint: u64,
|
||||||
|
on_action: Box<dyn Fn(TranscriptAction) -> Message + 'a>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, Message> Transcript<'a, Message> {
|
||||||
|
pub fn new(
|
||||||
|
messages: Vec<TranscriptMessage>,
|
||||||
|
show_sender: bool,
|
||||||
|
on_action: impl Fn(TranscriptAction) -> Message + 'a,
|
||||||
|
) -> Self {
|
||||||
|
let fingerprint = messages_fingerprint(&messages);
|
||||||
|
Self {
|
||||||
|
messages,
|
||||||
|
show_sender,
|
||||||
|
fingerprint,
|
||||||
|
on_action: Box::new(on_action),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn messages_fingerprint(messages: &[TranscriptMessage]) -> u64 {
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
messages.hash(&mut hasher);
|
||||||
|
hasher.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
enum DisplayKind {
|
||||||
|
Date {
|
||||||
|
paragraph: Paragraph,
|
||||||
|
},
|
||||||
|
Sender {
|
||||||
|
paragraph: Paragraph,
|
||||||
|
},
|
||||||
|
Text {
|
||||||
|
paragraph: Paragraph,
|
||||||
|
text: String,
|
||||||
|
from_me: bool,
|
||||||
|
},
|
||||||
|
Image {
|
||||||
|
attachment_guid: String,
|
||||||
|
preview_handle: Option<image::Handle>,
|
||||||
|
from_me: bool,
|
||||||
|
downloaded: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct DisplayItem {
|
||||||
|
bounds: Rectangle,
|
||||||
|
content_bounds: Rectangle,
|
||||||
|
kind: DisplayKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct DisplayList {
|
||||||
|
items: Vec<DisplayItem>,
|
||||||
|
height: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
struct DisplayListKey {
|
||||||
|
width_px: u32,
|
||||||
|
show_sender: bool,
|
||||||
|
fingerprint: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct TranscriptState {
|
||||||
|
cache_key: Option<DisplayListKey>,
|
||||||
|
display_list: DisplayList,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DisplayList {
|
||||||
|
fn hit(&self, point: Point) -> Option<&DisplayItem> {
|
||||||
|
let start = self
|
||||||
|
.items
|
||||||
|
.partition_point(|item| item.bounds.y + item.bounds.height < point.y);
|
||||||
|
|
||||||
|
self.items[start..]
|
||||||
|
.iter()
|
||||||
|
.take_while(|item| item.bounds.y <= point.y)
|
||||||
|
.find(|item| item.bounds.contains(point))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visible_range(&self, viewport: Rectangle) -> Range<usize> {
|
||||||
|
let top = viewport.y;
|
||||||
|
let bottom = viewport.y + viewport.height;
|
||||||
|
let start = self
|
||||||
|
.items
|
||||||
|
.partition_point(|item| item.bounds.y + item.bounds.height < top);
|
||||||
|
let end = start
|
||||||
|
+ self.items[start..]
|
||||||
|
.iter()
|
||||||
|
.take_while(|item| item.bounds.y <= bottom)
|
||||||
|
.count();
|
||||||
|
|
||||||
|
start..end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn url_regex() -> &'static Regex {
|
||||||
|
static URL_RE: OnceLock<Regex> = OnceLock::new();
|
||||||
|
URL_RE.get_or_init(|| {
|
||||||
|
Regex::new(r"https?://(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-z]{2,6}\b[-a-zA-Z0-9@:%_+.~#?&//=]*")
|
||||||
|
.expect("valid URL regex")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_url(text: &str) -> Option<String> {
|
||||||
|
url_regex().find(text).map(|m| m.as_str().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_attachment_marker(text: &str) -> bool {
|
||||||
|
text == "\u{FFFC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn date_label(timestamp: i64) -> String {
|
||||||
|
Local
|
||||||
|
.timestamp_opt(timestamp, 0)
|
||||||
|
.single()
|
||||||
|
.map(|dt| dt.format("%b %d, %Y at %H:%M").to_string())
|
||||||
|
.unwrap_or_else(|| "Unknown date".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paragraph(
|
||||||
|
renderer: &Renderer,
|
||||||
|
content: &str,
|
||||||
|
width: f32,
|
||||||
|
size: f32,
|
||||||
|
line_height: f32,
|
||||||
|
align_x: text::Alignment,
|
||||||
|
) -> Paragraph {
|
||||||
|
Paragraph::with_text(text::Text {
|
||||||
|
content,
|
||||||
|
bounds: Size::new(width, f32::INFINITY),
|
||||||
|
size: Pixels(size),
|
||||||
|
line_height: text::LineHeight::Relative(line_height),
|
||||||
|
font: renderer.default_font(),
|
||||||
|
align_x,
|
||||||
|
align_y: alignment::Vertical::Top,
|
||||||
|
shaping: text::Shaping::Advanced,
|
||||||
|
wrapping: text::Wrapping::WordOrGlyph,
|
||||||
|
ellipsize: text::Ellipsize::None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_display_list(
|
||||||
|
messages: &[TranscriptMessage],
|
||||||
|
show_sender: bool,
|
||||||
|
width: f32,
|
||||||
|
renderer: &Renderer,
|
||||||
|
) -> DisplayList {
|
||||||
|
let max_width = (width * MAX_BUBBLE_WIDTH).max(220.0);
|
||||||
|
let max_image_width = max_width * IMAGE_WIDTH_FACTOR;
|
||||||
|
let mut items = Vec::new();
|
||||||
|
let mut y = TRANSCRIPT_TOP_PADDING;
|
||||||
|
let mut last_date: Option<i64> = None;
|
||||||
|
let mut last_sender: Option<&str> = None;
|
||||||
|
|
||||||
|
for message in messages {
|
||||||
|
if last_date.is_some_and(|date| message.date_unix - date > 60 * 60) {
|
||||||
|
let text = date_label(message.date_unix);
|
||||||
|
let paragraph = paragraph(
|
||||||
|
renderer,
|
||||||
|
&text,
|
||||||
|
max_width,
|
||||||
|
12.0,
|
||||||
|
1.2,
|
||||||
|
text::Alignment::Center,
|
||||||
|
);
|
||||||
|
let height = paragraph.min_height() + DATE_PADDING;
|
||||||
|
let x = ((width - max_width) / 2.0).max(BUBBLE_MARGIN);
|
||||||
|
let bounds = Rectangle::new(Point::new(x, y), Size::new(max_width, height));
|
||||||
|
items.push(DisplayItem {
|
||||||
|
bounds,
|
||||||
|
content_bounds: bounds,
|
||||||
|
kind: DisplayKind::Date { paragraph },
|
||||||
|
});
|
||||||
|
y += height;
|
||||||
|
last_sender = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if show_sender && !message.from_me && last_sender != Some(message.sender.as_str()) {
|
||||||
|
let paragraph = paragraph(
|
||||||
|
renderer,
|
||||||
|
&message.sender,
|
||||||
|
max_width,
|
||||||
|
12.0,
|
||||||
|
1.2,
|
||||||
|
text::Alignment::Left,
|
||||||
|
);
|
||||||
|
let height = paragraph.min_height() + SENDER_PADDING;
|
||||||
|
let x = BUBBLE_MARGIN;
|
||||||
|
let bounds = Rectangle::new(Point::new(x, y), Size::new(max_width, height));
|
||||||
|
items.push(DisplayItem {
|
||||||
|
bounds,
|
||||||
|
content_bounds: bounds,
|
||||||
|
kind: DisplayKind::Sender { paragraph },
|
||||||
|
});
|
||||||
|
y += height;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !message.text.is_empty() && !is_attachment_marker(&message.text) {
|
||||||
|
let text_available_width = max_width - TEXT_X_PADDING * 2.0;
|
||||||
|
let paragraph = paragraph(
|
||||||
|
renderer,
|
||||||
|
&message.text,
|
||||||
|
text_available_width,
|
||||||
|
14.0,
|
||||||
|
1.18,
|
||||||
|
text::Alignment::Left,
|
||||||
|
);
|
||||||
|
let text_bounds = paragraph.min_bounds();
|
||||||
|
let bubble_width = (text_bounds.width + TEXT_X_PADDING * 2.0).min(max_width);
|
||||||
|
let bubble_height = text_bounds.height + TEXT_Y_PADDING * 2.0;
|
||||||
|
let x = if message.from_me {
|
||||||
|
width - bubble_width - BUBBLE_MARGIN
|
||||||
|
} else {
|
||||||
|
BUBBLE_MARGIN
|
||||||
|
};
|
||||||
|
let vertical_padding = if last_sender == Some(message.sender.as_str()) {
|
||||||
|
4.0
|
||||||
|
} else {
|
||||||
|
10.0
|
||||||
|
};
|
||||||
|
let bounds = Rectangle::new(Point::new(x, y), Size::new(bubble_width, bubble_height));
|
||||||
|
let content_bounds = Rectangle::new(
|
||||||
|
Point::new(x + TEXT_X_PADDING, y + TEXT_Y_PADDING),
|
||||||
|
Size::new(text_available_width, text_bounds.height),
|
||||||
|
);
|
||||||
|
items.push(DisplayItem {
|
||||||
|
bounds,
|
||||||
|
content_bounds,
|
||||||
|
kind: DisplayKind::Text {
|
||||||
|
paragraph,
|
||||||
|
text: message.text.clone(),
|
||||||
|
from_me: message.from_me,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
y += bubble_height + vertical_padding;
|
||||||
|
}
|
||||||
|
|
||||||
|
for attachment in &message.attachments {
|
||||||
|
let intrinsic_width = attachment.width.unwrap_or(200) as f32;
|
||||||
|
let intrinsic_height = attachment.height.unwrap_or(150) as f32;
|
||||||
|
let scale = (max_image_width / intrinsic_width).min(1.0);
|
||||||
|
let image_width = (intrinsic_width * scale).max(200.0).min(max_image_width);
|
||||||
|
let image_height = (intrinsic_height * scale).max(100.0);
|
||||||
|
let x = if message.from_me {
|
||||||
|
width - image_width - BUBBLE_MARGIN
|
||||||
|
} else {
|
||||||
|
BUBBLE_MARGIN
|
||||||
|
};
|
||||||
|
let bounds = Rectangle::new(Point::new(x, y), Size::new(image_width, image_height));
|
||||||
|
items.push(DisplayItem {
|
||||||
|
bounds,
|
||||||
|
content_bounds: bounds,
|
||||||
|
kind: DisplayKind::Image {
|
||||||
|
attachment_guid: attachment.guid.clone(),
|
||||||
|
preview_handle: attachment
|
||||||
|
.preview_path
|
||||||
|
.as_ref()
|
||||||
|
.map(image::Handle::from_path),
|
||||||
|
from_me: message.from_me,
|
||||||
|
downloaded: attachment.downloaded || attachment.preview_downloaded,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
y += image_height + 8.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
last_sender = Some(&message.sender);
|
||||||
|
last_date = Some(message.date_unix);
|
||||||
|
}
|
||||||
|
|
||||||
|
DisplayList {
|
||||||
|
items,
|
||||||
|
height: y.max(200.0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn incoming_color() -> Color {
|
||||||
|
Color::from_rgba(1.0, 1.0, 1.0, 0.08)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn outgoing_color(theme: &Theme) -> Color {
|
||||||
|
theme.cosmic().accent.base.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_bubble_background(
|
||||||
|
renderer: &mut Renderer,
|
||||||
|
bounds: Rectangle,
|
||||||
|
_from_me: bool,
|
||||||
|
color_value: Color,
|
||||||
|
) {
|
||||||
|
renderer.fill_quad(
|
||||||
|
renderer::Quad {
|
||||||
|
bounds,
|
||||||
|
border: border::rounded(RADIUS),
|
||||||
|
..renderer::Quad::default()
|
||||||
|
},
|
||||||
|
Background::Color(color_value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_placeholder(renderer: &mut Renderer, bounds: Rectangle, downloaded: bool) {
|
||||||
|
let color_value = if downloaded {
|
||||||
|
Color::from_rgba(1.0, 1.0, 1.0, 0.14)
|
||||||
|
} else {
|
||||||
|
Color::from_rgba(1.0, 1.0, 1.0, 0.08)
|
||||||
|
};
|
||||||
|
renderer.fill_quad(
|
||||||
|
renderer::Quad {
|
||||||
|
bounds,
|
||||||
|
border: border::rounded(RADIUS),
|
||||||
|
..renderer::Quad::default()
|
||||||
|
},
|
||||||
|
Background::Color(color_value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Message> Widget<Message, Theme, Renderer> for Transcript<'_, Message>
|
||||||
|
where
|
||||||
|
Message: Clone,
|
||||||
|
{
|
||||||
|
fn tag(&self) -> widget::tree::Tag {
|
||||||
|
widget::tree::Tag::of::<TranscriptState>()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state(&self) -> widget::tree::State {
|
||||||
|
widget::tree::State::new(TranscriptState::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn size(&self) -> Size<Length> {
|
||||||
|
Size {
|
||||||
|
width: Length::Fill,
|
||||||
|
height: Length::Shrink,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn layout(
|
||||||
|
&mut self,
|
||||||
|
tree: &mut widget::Tree,
|
||||||
|
renderer: &Renderer,
|
||||||
|
limits: &layout::Limits,
|
||||||
|
) -> layout::Node {
|
||||||
|
let width = limits.max().width.max(320.0);
|
||||||
|
let state = tree.state.downcast_mut::<TranscriptState>();
|
||||||
|
let key = DisplayListKey {
|
||||||
|
width_px: width.round() as u32,
|
||||||
|
show_sender: self.show_sender,
|
||||||
|
fingerprint: self.fingerprint,
|
||||||
|
};
|
||||||
|
if state.cache_key != Some(key) {
|
||||||
|
let trace_start =
|
||||||
|
tracing::enabled!(target: "kordophone_cosmic::transcript", Level::TRACE)
|
||||||
|
.then(Instant::now);
|
||||||
|
state.display_list =
|
||||||
|
build_display_list(&self.messages, self.show_sender, width, renderer);
|
||||||
|
state.cache_key = Some(key);
|
||||||
|
if let Some(start) = trace_start {
|
||||||
|
tracing::trace!(
|
||||||
|
target: "kordophone_cosmic::transcript",
|
||||||
|
elapsed_us = start.elapsed().as_micros(),
|
||||||
|
messages = self.messages.len(),
|
||||||
|
items = state.display_list.items.len(),
|
||||||
|
width_px = key.width_px,
|
||||||
|
"rebuilt transcript display list"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
layout::Node::new(Size::new(width, state.display_list.height))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(
|
||||||
|
&mut self,
|
||||||
|
tree: &mut widget::Tree,
|
||||||
|
event: &Event,
|
||||||
|
layout: Layout<'_>,
|
||||||
|
cursor: mouse::Cursor,
|
||||||
|
renderer: &Renderer,
|
||||||
|
_clipboard: &mut dyn Clipboard,
|
||||||
|
shell: &mut Shell<'_, Message>,
|
||||||
|
_viewport: &Rectangle,
|
||||||
|
) {
|
||||||
|
let Event::Mouse(mouse::Event::ButtonPressed(button)) = event else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(position) = cursor.position_in(layout.bounds()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let state = tree.state.downcast_mut::<TranscriptState>();
|
||||||
|
if state.display_list.items.is_empty() {
|
||||||
|
state.display_list = build_display_list(
|
||||||
|
&self.messages,
|
||||||
|
self.show_sender,
|
||||||
|
layout.bounds().width,
|
||||||
|
renderer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let display_list = &state.display_list;
|
||||||
|
let point = Point::new(position.x, position.y);
|
||||||
|
let Some(item) = display_list.hit(point) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let action = match (&item.kind, button) {
|
||||||
|
(DisplayKind::Text { text, .. }, mouse::Button::Right) => {
|
||||||
|
Some(TranscriptAction::CopyText(text.clone()))
|
||||||
|
}
|
||||||
|
(DisplayKind::Text { text, .. }, mouse::Button::Left) => {
|
||||||
|
first_url(text).map(TranscriptAction::OpenUrl)
|
||||||
|
}
|
||||||
|
(
|
||||||
|
DisplayKind::Image {
|
||||||
|
attachment_guid, ..
|
||||||
|
},
|
||||||
|
mouse::Button::Left,
|
||||||
|
) => Some(TranscriptAction::OpenAttachment(attachment_guid.clone())),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(action) = action {
|
||||||
|
shell.publish((self.on_action)(action));
|
||||||
|
shell.capture_event();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw(
|
||||||
|
&self,
|
||||||
|
tree: &widget::Tree,
|
||||||
|
renderer: &mut Renderer,
|
||||||
|
theme: &Theme,
|
||||||
|
style: &renderer::Style,
|
||||||
|
layout: Layout<'_>,
|
||||||
|
_cursor: mouse::Cursor,
|
||||||
|
viewport: &Rectangle,
|
||||||
|
) {
|
||||||
|
let display_list = &tree.state.downcast_ref::<TranscriptState>().display_list;
|
||||||
|
let trace_start = tracing::enabled!(target: "kordophone_cosmic::transcript", Level::TRACE)
|
||||||
|
.then(Instant::now);
|
||||||
|
let offset = Vector::new(layout.bounds().x, layout.bounds().y);
|
||||||
|
let local_viewport = Rectangle::new(
|
||||||
|
Point::new(viewport.x - offset.x, viewport.y - offset.y),
|
||||||
|
viewport.size(),
|
||||||
|
);
|
||||||
|
let visible_range = display_list.visible_range(local_viewport);
|
||||||
|
let visible_count = visible_range.len();
|
||||||
|
renderer.with_translation(offset, |renderer| {
|
||||||
|
for item in &display_list.items[visible_range] {
|
||||||
|
let item_rect =
|
||||||
|
Rectangle::new(Point::new(item.bounds.x, item.bounds.y), item.bounds.size());
|
||||||
|
|
||||||
|
match &item.kind {
|
||||||
|
DisplayKind::Date { paragraph } => {
|
||||||
|
renderer.fill_paragraph(
|
||||||
|
paragraph,
|
||||||
|
Point::new(item_rect.x, item_rect.y + DATE_PADDING / 2.0),
|
||||||
|
Color::from_rgba(1.0, 1.0, 1.0, 0.48),
|
||||||
|
local_viewport,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
DisplayKind::Sender { paragraph } => {
|
||||||
|
renderer.fill_paragraph(
|
||||||
|
paragraph,
|
||||||
|
Point::new(item_rect.x + TEXT_X_PADDING, item_rect.y + SENDER_PADDING),
|
||||||
|
Color::from_rgba(1.0, 1.0, 1.0, 0.78),
|
||||||
|
local_viewport,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
DisplayKind::Text {
|
||||||
|
paragraph, from_me, ..
|
||||||
|
} => {
|
||||||
|
let bubble_color = if *from_me {
|
||||||
|
outgoing_color(theme)
|
||||||
|
} else {
|
||||||
|
incoming_color()
|
||||||
|
};
|
||||||
|
draw_bubble_background(renderer, item_rect, *from_me, bubble_color);
|
||||||
|
renderer.fill_paragraph(
|
||||||
|
paragraph,
|
||||||
|
Point::new(item.content_bounds.x, item.content_bounds.y),
|
||||||
|
if *from_me {
|
||||||
|
theme.cosmic().accent.on.into()
|
||||||
|
} else {
|
||||||
|
style.text_color
|
||||||
|
},
|
||||||
|
local_viewport,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
DisplayKind::Image {
|
||||||
|
preview_handle,
|
||||||
|
from_me,
|
||||||
|
downloaded,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
let bubble_color = if *from_me {
|
||||||
|
outgoing_color(theme)
|
||||||
|
} else {
|
||||||
|
incoming_color()
|
||||||
|
};
|
||||||
|
draw_bubble_background(renderer, item_rect, *from_me, bubble_color);
|
||||||
|
if let Some(handle) = preview_handle {
|
||||||
|
renderer.draw_image(
|
||||||
|
image::Image {
|
||||||
|
handle: handle.clone(),
|
||||||
|
border_radius: border::Radius::from(RADIUS),
|
||||||
|
filter_method: image::FilterMethod::Linear,
|
||||||
|
rotation: cosmic::iced::Radians(0.0),
|
||||||
|
opacity: 1.0,
|
||||||
|
snap: true,
|
||||||
|
},
|
||||||
|
item_rect,
|
||||||
|
item_rect,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
draw_placeholder(renderer, item_rect, *downloaded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if let Some(start) = trace_start {
|
||||||
|
tracing::trace!(
|
||||||
|
target: "kordophone_cosmic::transcript",
|
||||||
|
elapsed_us = start.elapsed().as_micros(),
|
||||||
|
visible_items = visible_count,
|
||||||
|
total_items = display_list.items.len(),
|
||||||
|
viewport_y = local_viewport.y,
|
||||||
|
viewport_height = local_viewport.height,
|
||||||
|
"drew transcript visible range"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mouse_interaction(
|
||||||
|
&self,
|
||||||
|
tree: &widget::Tree,
|
||||||
|
layout: Layout<'_>,
|
||||||
|
cursor: mouse::Cursor,
|
||||||
|
_viewport: &Rectangle,
|
||||||
|
_renderer: &Renderer,
|
||||||
|
) -> mouse::Interaction {
|
||||||
|
let Some(position) = cursor.position_in(layout.bounds()) else {
|
||||||
|
return mouse::Interaction::default();
|
||||||
|
};
|
||||||
|
let display_list = &tree.state.downcast_ref::<TranscriptState>().display_list;
|
||||||
|
if display_list
|
||||||
|
.hit(Point::new(position.x, position.y))
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
mouse::Interaction::Pointer
|
||||||
|
} else {
|
||||||
|
mouse::Interaction::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, Message> From<Transcript<'a, Message>> for Element<'a, Message>
|
||||||
|
where
|
||||||
|
Message: Clone + 'a,
|
||||||
|
{
|
||||||
|
fn from(value: Transcript<'a, Message>) -> Self {
|
||||||
|
Element::new(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM fedora:40
|
FROM fedora:43
|
||||||
|
|
||||||
# Install RPM build tools and dependencies
|
# Install RPM build tools and dependencies
|
||||||
RUN dnf update -y && dnf install -y \
|
RUN dnf update -y && dnf install -y \
|
||||||
|
|||||||
16
gtk/Makefile
16
gtk/Makefile
@@ -5,14 +5,20 @@ all: setup
|
|||||||
setup: build/
|
setup: build/
|
||||||
meson build
|
meson build
|
||||||
|
|
||||||
VER_RAW := $(shell git -C .. describe --tags --abbrev=0 2>/dev/null || git -C .. describe --tags 2>/dev/null || printf '0.0.0')
|
VER_RAW := $(shell git -C .. describe --tags --match 'release/gtk/*' --abbrev=0 2>/dev/null || true)
|
||||||
VER := $(patsubst v%,%,$(VER_RAW))
|
VER := $(patsubst release/gtk/%,%,$(VER_RAW))
|
||||||
TMP := $(shell mktemp -d)
|
TMP := $(shell mktemp -d)
|
||||||
rpm:
|
RPM_SOURCE := $(TMP)/$(VER).tar.gz
|
||||||
git -C .. archive --format=tar.gz --prefix=kordophone/ -o $(TMP)/v$(VER).tar.gz HEAD
|
.PHONY: check-version
|
||||||
|
check-version:
|
||||||
|
@test -n "$(VER_RAW)" || { echo "Could not determine GTK release version from git tags." >&2; echo "Expected a tag reachable from HEAD matching release/gtk/<version>." >&2; exit 1; }
|
||||||
|
@test "$(VER)" != "$(VER_RAW)" || { echo "Invalid GTK release tag: $(VER_RAW)" >&2; echo "Expected format: release/gtk/<version>." >&2; exit 1; }
|
||||||
|
|
||||||
|
rpm: check-version
|
||||||
|
git -C .. archive --format=tar.gz --prefix=kordophone/ -o $(RPM_SOURCE) HEAD
|
||||||
rpmbuild -ba dist/rpm/kordophone.spec --define "_sourcedir $(TMP)" --define "app_version $(VER)"
|
rpmbuild -ba dist/rpm/kordophone.spec --define "_sourcedir $(TMP)" --define "app_version $(VER)"
|
||||||
|
|
||||||
deb:
|
deb: check-version
|
||||||
./dist/deb/build-deb.sh $(VER)
|
./dist/deb/build-deb.sh $(VER)
|
||||||
|
|
||||||
.PHONY: flatpak
|
.PHONY: flatpak
|
||||||
|
|||||||
4
gtk/dist/rpm/kordophone.spec
vendored
4
gtk/dist/rpm/kordophone.spec
vendored
@@ -1,11 +1,11 @@
|
|||||||
Name: kordophone
|
Name: kordophone
|
||||||
Version: %{?app_version}%{!?app_version:1.3.0}
|
Version: %{?app_version}%{!?app_version:1.4.5}
|
||||||
Release: 1%{?dist}
|
Release: 1%{?dist}
|
||||||
Summary: GTK4/Libadwaita client for Kordophone
|
Summary: GTK4/Libadwaita client for Kordophone
|
||||||
|
|
||||||
License: GPL
|
License: GPL
|
||||||
URL: https://code.buzzert.dev/buzzert/Kordophone
|
URL: https://code.buzzert.dev/buzzert/Kordophone
|
||||||
Source0: %{url}/archive/v%{version}.tar.gz
|
Source0: %{url}/archive/release/gtk/%{version}.tar.gz
|
||||||
|
|
||||||
BuildRequires: meson >= 0.56.0
|
BuildRequires: meson >= 0.56.0
|
||||||
BuildRequires: vala
|
BuildRequires: vala
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
project('kordophone', 'vala',
|
project('kordophone', 'vala',
|
||||||
version : '1.0.2',
|
version : '1.4.5',
|
||||||
meson_version : '>=0.56.0',
|
meson_version : '>=0.56.0',
|
||||||
default_options : ['warning_level=2']
|
default_options : ['warning_level=2']
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -383,6 +383,10 @@ private class TranscriptDrawingArea : Widget
|
|||||||
items.add(image_layout);
|
items.add(image_layout);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// New-message animation is a one-shot effect. Clear the flag after
|
||||||
|
// scheduling bubble animations so later relayouts do not replay it.
|
||||||
|
message.should_animate = false;
|
||||||
|
|
||||||
last_sender = message.sender;
|
last_sender = message.sender;
|
||||||
last_date = date;
|
last_date = date;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user